├── .eslintignore ├── .eslintrc.js ├── .gitignore ├── .npmrc ├── .prettierrc.js ├── .travis.yml ├── LICENSE ├── NFPlayerJS.png ├── NOTICE ├── README.md ├── charts ├── Audio Graph.png ├── Audio Graph.txt ├── Enqueuing Scores.png ├── Enqueuing Scores.txt ├── Score Time Processing.png └── Score Time Processing.txt ├── debug-harness ├── declarations.d.ts ├── index.html └── index.ts ├── demo ├── .npmrc ├── nf-grapher.d.ts ├── nf-player.d.ts ├── package-lock.json ├── package.json ├── src │ ├── ExampleJSONScores.ts │ ├── ExampleScripts.ts │ ├── components │ │ ├── App.tsx │ │ ├── CODEEditor │ │ │ └── CODEEditor.tsx │ │ ├── JSONEditor │ │ │ └── JSONEditor.tsx │ │ ├── Monaco.tsx │ │ ├── PlayerControlBar.tsx │ │ ├── PlayerWatcher.tsx │ │ ├── ScoreVisualizer │ │ │ ├── CanvasPowered.tsx │ │ │ ├── FileNodeMonitor.tsx │ │ │ ├── LoopNodeMonitor.tsx │ │ │ ├── NodePanel.tsx │ │ │ ├── ParamMonitor.tsx │ │ │ ├── SourceMonitor.tsx │ │ │ ├── VisualGridColumn.tsx │ │ │ └── index.tsx │ │ ├── Theme.ts │ │ ├── VerticalLayout.tsx │ │ └── WaveVisualizer │ │ │ ├── FrequencyMonitor.tsx │ │ │ └── WaveVisualizer.tsx │ ├── declarations.d.ts │ ├── index.html │ ├── index.ts │ └── styles.css └── tsconfig.json ├── fixtures ├── TNG-Crysknife007-16-899-s.wav ├── TNG-Infinite-Idle-Engine.json ├── chirp_linear_5.50.wav ├── ratatat-loop.json ├── roxanne-30s-preview-shifted-infinite.json ├── sine-440hz-1s.wav └── sine-523.251hz-44079samples.wav ├── jest.config.js ├── package-lock.json ├── package.json ├── src ├── AudioBufferUtils.ts ├── ContentCache.ts ├── DirectedScore.ts ├── Mutations.ts ├── SmartPlayer.test.ts ├── SmartPlayer.ts ├── WebAudioContext.ts ├── XAudioBuffer.ts ├── cli.ts ├── declarations.d.ts ├── index.test.ts ├── index.ts ├── nodes │ ├── SPDestinationNode.ts │ ├── SPFileNode.ts │ ├── SPGainNode.ts │ ├── SPLoopNode.test.ts │ ├── SPLoopNode.ts │ ├── SPNode.ts │ ├── SPNodeFactory.ts │ ├── SPPassthroughNode.ts │ ├── SPStretchNode.test.ts │ └── SPStretchNode.ts ├── params │ └── ScoreAudioParam.ts ├── pio.ts ├── renderers │ ├── BaseRenderer.ts │ ├── MemoryRenderer.test.ts │ ├── MemoryRenderer.ts │ ├── RendererInfo.ts │ └── ScriptProcessorRenderer.ts ├── test-utils │ ├── DumpWave.ts │ ├── ExpectQuantums.ts │ ├── LoadWave.ts │ ├── TestAudioContext.ts │ └── TestRendererInfo.ts └── time.ts └── tsconfig.json /.eslintignore: -------------------------------------------------------------------------------- 1 | .gitignore -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | const copyrightTemplate = `/* 2 | * Copyright (c) 2018-Present, Spotify AB. 3 | * 4 | * Licensed to the Apache Software Foundation (ASF) under one 5 | * or more contributor license agreements. See the NOTICE file 6 | * distributed with this work for additional information 7 | * regarding copyright ownership. The ASF licenses this file 8 | * to you under the Apache License, Version 2.0 (the 9 | * "License"); you may not use this file except in compliance 10 | * with the License. You may obtain a copy of the License at 11 | * 12 | * http://www.apache.org/licenses/LICENSE-2.0 13 | * 14 | * Unless required by applicable law or agreed to in writing, 15 | * software distributed under the License is distributed on an 16 | * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY 17 | * KIND, either express or implied. See the License for the 18 | * specific language governing permissions and limitations 19 | * under the License. 20 | */ 21 | 22 | `; 23 | 24 | module.exports = { 25 | extends: [ 26 | //"plugin:@typescript-eslint/recommended", 27 | 'prettier', 28 | 'prettier/@typescript-eslint' 29 | ], 30 | parser: '@typescript-eslint/parser', 31 | plugins: ['@typescript-eslint', 'notice'], 32 | parserOptions: { 33 | ecmaVersion: 2018, 34 | sourceType: 'module', 35 | ecmaFeatures: { 36 | jsx: true 37 | } 38 | }, 39 | rules: { 40 | 'notice/notice': [ 41 | 'error', 42 | { 43 | template: copyrightTemplate, 44 | onNonMatchingHeader: 'replace' 45 | } 46 | ] 47 | } 48 | }; 49 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .vscode 2 | .DS_Store 3 | node_modules 4 | build 5 | release 6 | dist 7 | dist-debug 8 | .cache-debug 9 | .cache-demo 10 | demo/.cache 11 | coverage 12 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | registry=https://registry.npmjs.org 2 | -------------------------------------------------------------------------------- /.prettierrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | singleQuote: true 3 | }; -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | before_install: 3 | - npm i -g npm@6.8 4 | cache: npm 5 | node_js: 6 | - '10' 7 | script: 8 | - npm run setup:ci 9 | - npm run build 10 | - npm test -- --coverage 11 | - npm run predeploy:demo 12 | deploy: 13 | - provider: npm 14 | email: kibysayshi@gmail.com 15 | skip_cleanup: true 16 | api_key: 17 | secure: LFF+TBDO2lguN5rpciP09qUKWIoVVWA2arapTtX+zGMZHBa7QmDV+iBDZSLkf+5iJAgrgXsehIazXyQ7HKDad59y5kjJBOtQOqcSkENn1RTx10FHGvH/lrsO6FxLa5JD0r2cd9KdUjz2EC+4AlgrJo8wr6SKA2/cNOZ2gMVV/6cZVS/sPWfDx9Y1EDQwWlT+74G/vj4FFI+cuHQ2t28aywguaYdMQXPlORo0hXOde4JysewvO2mXcP9viMLg2qckEOeNo0ONDoQtJc4ZDJo7AmOqKmbf14sUQU04ejaw4RLveTe+ebkLYFKC72ABR0RN/VJIlUR78MZXTpJ9tM9A3iLRz1J4ca++3HQMcHQKOG+4P6lSk3bjyUg2pqrYLeUVzAoDIBABJUy0rbhiDMluKiPizO+Y9MmBf57d68+Ks+7ptDuVdYu6J01oyep0D2Mx25IlpgVOORMY05K1CbbWKNAdL8zn54SifqbCDVfDRgrMD0+iQC2D8gKMtOhap3OwtZWOEysUf5wwDz4cEzaeDcL5iOPwxKc6CpjyhdchJDxWozZTA+2wtSAUoddQ4c/iQM1FFQYQ5IugdVOA4/f66aWGftT0CzImT4jC1EHFF6QqAC5R5NAmpt6WaHDt3T/kmSIwXpDxnVq80nzK4t6k1VYjjQhum+IYMq1bx3g6DOM= 18 | on: 19 | tags: true 20 | repo: spotify/NFPlayerJS 21 | branch: master 22 | - provider: pages 23 | github-token: $GITHUB_TOKEN 24 | skip_cleanup: true 25 | keep-history: true 26 | local-dir: demo/dist/ 27 | on: 28 | branch: master 29 | -------------------------------------------------------------------------------- /NFPlayerJS.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nativeformat/NFPlayerJS/a03fc221298301aa6b67bcbbb931b0dcb79d2397/NFPlayerJS.png -------------------------------------------------------------------------------- /NOTICE: -------------------------------------------------------------------------------- 1 | NF-Player-JS 2 | Copyright 2019 Spotify AB 3 | 4 | SoundTouch-TS 5 | GNU Lesser General Public Library, version 2.1. 6 | https://www.gnu.org/licenses/lgpl-2.1.en.html 7 | -------------------------------------------------------------------------------- /charts/Audio Graph.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nativeformat/NFPlayerJS/a03fc221298301aa6b67bcbbb931b0dcb79d2397/charts/Audio Graph.png -------------------------------------------------------------------------------- /charts/Audio Graph.txt: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | File Node 1: bass line ->> Gain Node 1 5 | Gain Node 1 ->> Stretch Node 1 6 | Stretch Node 1 ->> Audio Destination Node 7 | File Node 2: drum beat ->> Gain Node 2 8 | Gain Node 2 ->> Loop Node 2 9 | Loop Node 2 ->> Stretch Node 1 10 | Audio Destination Node ->> Audio Driver (ScriptProcessorNode / AudioWorklet / CLI Loop) -------------------------------------------------------------------------------- /charts/Enqueuing Scores.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nativeformat/NFPlayerJS/a03fc221298301aa6b67bcbbb931b0dcb79d2397/charts/Enqueuing Scores.png -------------------------------------------------------------------------------- /charts/Enqueuing Scores.txt: -------------------------------------------------------------------------------- 1 | participant User 2 | participant NFPlayer 3 | participant Driver 4 | User ->> NFPlayer: enqueueScore(scoreJSON) 5 | note left of NFPlayer: Player will parse, fetch/decode all sources, and report back when loaded. 6 | NFPlayer -->> User: Ok, it's loaded! 7 | User ->> NFPlayer: start playing! 8 | NFPlayer ->> Driver: start playing! 9 | NFPlayer -->> User: Ok! 10 | loop Player Render Loop 11 | Driver -->> NFPlayer: need 8192 samples 12 | note right of NFPlayer: Request samples from all loaded Scores. renderTime += 8192 samples 13 | NFPlayer ->> Driver: 8192 samples 14 | end 15 | note left of User: User decides to mutate score 16 | User -->> NFPlayer: getJson() 17 | NFPlayer ->> User: JSON of all processing Scores 18 | note left of User: User finds Score in JSON, adds a new File node (source), and changes graphId to create an independent copy. 19 | User ->> NFPlayer: enqueueScore(modifiedScoreJSON) 20 | NFPlayer -->> User: Ok, it's loaded! 21 | User ->> NFPlayer: dequeueScore(scoreJSON.id) 22 | note left of User: Old copy has been dequeued, and new copy is now used for primary processing, resulting in seamless audio. 23 | NFPlayer -->> User: Score dequeued! -------------------------------------------------------------------------------- /charts/Score Time Processing.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nativeformat/NFPlayerJS/a03fc221298301aa6b67bcbbb931b0dcb79d2397/charts/Score Time Processing.png -------------------------------------------------------------------------------- /charts/Score Time Processing.txt: -------------------------------------------------------------------------------- 1 | participant DestinationNode 2 | participant StretchNode1 3 | participant LoopNode1 4 | participant FileNode1 5 | 6 | note right of DestinationNode: I need 8192 samples for time=10 seconds. 7 | DestinationNode -->> StretchNode1: 8 | 9 | note right of StretchNode1: I have compressed time by 2, give me 8192 samples for time=20 seconds. 10 | StretchNode1 -->> LoopNode1: 11 | 12 | note right of LoopNode1: We are 0% through loop 20 (starts at 0 seconds, 1 second long), give me 8192 samples for time 0 seconds 13 | LoopNode1 -->> FileNode1: 14 | 15 | FileNode1 ->> LoopNode1: 8192 samples 16 | LoopNode1 ->> StretchNode1: 8192 samples 17 | note left of StretchNode1: I process 8192 samples at 2x, which transforms into ~4096. I need more! Give me 8192 samples for time=(20 seconds + 8192 samples). 18 | 19 | StretchNode1 -->> LoopNode1: 20 | note right of LoopNode1: We are ~18.5% through loop 20 (starts at 0 seconds, 1 second long), give me 8192 samples for time 0.185758 seconds 21 | LoopNode1 -->> FileNode1: 22 | FileNode1 ->> LoopNode1: 8192 samples 23 | LoopNode1 ->> StretchNode1: 8192 samples 24 | note left of StretchNode1: I process, and now have a total transformed count of >= 8192. I have enough! 25 | StretchNode1 ->> DestinationNode: 8192 samples 26 | note left of DestinationNode: I can send these to the driver! -------------------------------------------------------------------------------- /debug-harness/declarations.d.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) 2018-Present, Spotify AB. 3 | * 4 | * Licensed to the Apache Software Foundation (ASF) under one 5 | * or more contributor license agreements. See the NOTICE file 6 | * distributed with this work for additional information 7 | * regarding copyright ownership. The ASF licenses this file 8 | * to you under the Apache License, Version 2.0 (the 9 | * "License"); you may not use this file except in compliance 10 | * with the License. You may obtain a copy of the License at 11 | * 12 | * http://www.apache.org/licenses/LICENSE-2.0 13 | * 14 | * Unless required by applicable law or agreed to in writing, 15 | * software distributed under the License is distributed on an 16 | * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY 17 | * KIND, either express or implied. See the License for the 18 | * specific language governing permissions and limitations 19 | * under the License. 20 | */ 21 | 22 | declare module '*.wav'; 23 | declare module '*.mp3'; 24 | -------------------------------------------------------------------------------- /debug-harness/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | NF Debug Harness 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /debug-harness/index.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) 2018-Present, Spotify AB. 3 | * 4 | * Licensed to the Apache Software Foundation (ASF) under one 5 | * or more contributor license agreements. See the NOTICE file 6 | * distributed with this work for additional information 7 | * regarding copyright ownership. The ASF licenses this file 8 | * to you under the Apache License, Version 2.0 (the 9 | * "License"); you may not use this file except in compliance 10 | * with the License. You may obtain a copy of the License at 11 | * 12 | * http://www.apache.org/licenses/LICENSE-2.0 13 | * 14 | * Unless required by applicable law or agreed to in writing, 15 | * software distributed under the License is distributed on an 16 | * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY 17 | * KIND, either express or implied. See the License for the 18 | * specific language governing permissions and limitations 19 | * under the License. 20 | */ 21 | 22 | import { SmartPlayer, TimeInstant } from '../src/index'; 23 | import { StretchNode, FileNode, Score } from 'nf-grapher'; 24 | 25 | import { default as Sine } from '../fixtures/chirp_linear_5.50.wav'; 26 | 27 | const s1 = StretchNode.create('s1'); 28 | const s2 = StretchNode.create('s2'); 29 | 30 | s1.stretch.setValueAtTime(0.5, 0); 31 | s2.stretch.setValueAtTime(0.5, 0); 32 | 33 | // const g1 = GGainNode.create('g1'); 34 | // const g2 = GGainNode.create('g2'); 35 | 36 | // g1.gain.setValueAtTime(2, 0); 37 | // g2.gain.setValueAtTime(2, 0); 38 | 39 | const f1 = FileNode.create( 40 | { 41 | file: Sine, 42 | when: 0, 43 | duration: TimeInstant.fromSeconds(5.5).asNanos() 44 | }, 45 | 'f1' 46 | ); 47 | 48 | const edges = [ 49 | f1.connectToTarget(s2), 50 | s2.connectToTarget(s1) 51 | // f1.connectToTarget(s1), 52 | 53 | // f1.connectToTarget(g2), 54 | // g2.connectToTarget(g1) 55 | ]; 56 | 57 | const nodes = [ 58 | s1, 59 | s2, 60 | // g1, 61 | // g2, 62 | f1 63 | ]; 64 | 65 | let s = new Score(); 66 | s.graph.nodes.push(...nodes); 67 | s.graph.edges.push(...edges); 68 | 69 | // Ensure they are plain Nodes. 70 | s = JSON.parse(JSON.stringify(s)); 71 | console.log(JSON.stringify(s, null, ' ')); 72 | 73 | const p = new SmartPlayer(); 74 | 75 | (async function() { 76 | const ready = await p.enqueueScore(s); 77 | console.log('ready'); 78 | })(); 79 | 80 | (window as any).p = p; 81 | (window as any).TimeInstant = TimeInstant; 82 | -------------------------------------------------------------------------------- /demo/.npmrc: -------------------------------------------------------------------------------- 1 | registry=https://registry.npmjs.org 2 | -------------------------------------------------------------------------------- /demo/nf-player.d.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) 2018-Present, Spotify AB. 3 | * 4 | * Licensed to the Apache Software Foundation (ASF) under one 5 | * or more contributor license agreements. See the NOTICE file 6 | * distributed with this work for additional information 7 | * regarding copyright ownership. The ASF licenses this file 8 | * to you under the Apache License, Version 2.0 (the 9 | * "License"); you may not use this file except in compliance 10 | * with the License. You may obtain a copy of the License at 11 | * 12 | * http://www.apache.org/licenses/LICENSE-2.0 13 | * 14 | * Unless required by applicable law or agreed to in writing, 15 | * software distributed under the License is distributed on an 16 | * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY 17 | * KIND, either express or implied. See the License for the 18 | * specific language governing permissions and limitations 19 | * under the License. 20 | */ 21 | 22 | // This file was generated. 23 | // Steps from the root dir: 24 | // npm install dts-bundle-generator 25 | // node_modules/.bin/dts-bundle-generator --out-file demo/nf-player.d.ts ./src/index.ts --project ./tsconfig.json 26 | 27 | // And then a few modifications, mostly to remove the "duplicate" definitions 28 | // of Score/Graph as interfaces and classes. Interfaces become IScore/IGraph. 29 | 30 | import { Command, Score } from 'nf-grapher'; 31 | 32 | export declare class TimeInstant { 33 | protected nanos: number; 34 | static fromNanos(nanos: number): TimeInstant; 35 | static fromMillis(ms: number): TimeInstant; 36 | static fromSeconds(seconds: number): TimeInstant; 37 | static fromSamples(samples: number, hz: number): TimeInstant; 38 | static from(instant: TimeInstant): TimeInstant; 39 | static max(time1: TimeInstant, time2: TimeInstant): TimeInstant; 40 | static min(time1: TimeInstant, time2: TimeInstant): TimeInstant; 41 | static readonly ZERO: TimeInstant; 42 | constructor(nanos?: number); 43 | asSeconds(): number; 44 | asMillis(): number; 45 | asNanos(): number; 46 | asSamples(hz: number): number; 47 | sub(time: TimeInstant): TimeInstant; 48 | add(time: TimeInstant): TimeInstant; 49 | scale(factor: number): TimeInstant; 50 | mod(factor: number): TimeInstant; 51 | mul(time: TimeInstant): number; 52 | div(time: TimeInstant): number; 53 | gt(time: TimeInstant): boolean; 54 | gte(time: TimeInstant): boolean; 55 | lt(time: TimeInstant): boolean; 56 | lte(time: TimeInstant): boolean; 57 | eq(time: TimeInstant): boolean; 58 | neq(time: TimeInstant): boolean; 59 | } 60 | export interface PseudoAudioParamEvent { 61 | type: string; 62 | time: number; 63 | } 64 | declare class PseudoAudioParam { 65 | public events: PseudoAudioParamEvent[]; 66 | 67 | /** 68 | * WARNING: This property is not actually in the impl! Only to facilitate 69 | * casting to AudioParam. 70 | */ 71 | public readonly defaultValue: number; 72 | 73 | /** 74 | * WARNING: This property is not actually in the impl! Only to facilitate 75 | * casting to AudioParam. 76 | */ 77 | public readonly value: number; 78 | 79 | constructor(defaultValue: number); 80 | 81 | /** return scheduled value at time */ 82 | getValueAtTime(time: number): number; 83 | 84 | /** apply scheduled methods to the provided audioParam. If reset is `true`, 85 | * cancel all events of AudioParam before applying */ 86 | applyTo(audioParam: AudioParam, reset: boolean): PseudoAudioParam; 87 | 88 | setValueAtTime(value: number, time: number): PseudoAudioParam; 89 | linearRampToValueAtTime(value: number, time: number): PseudoAudioParam; 90 | exponentialRampToValueAtTime(value: number, time: number): PseudoAudioParam; 91 | setTargetAtTime( 92 | value: number, 93 | time: number, 94 | timeConstant: number 95 | ): PseudoAudioParam; 96 | setValueCurveAtTime( 97 | values: number[], 98 | time: number, 99 | duration: number 100 | ): PseudoAudioParam; 101 | cancelScheduledValues(time: number): PseudoAudioParam; 102 | cancelAndHoldAtTime(time: number): PseudoAudioParam; 103 | } 104 | export interface ScoreAudioParamEvent { 105 | type: string; 106 | time: number; 107 | } 108 | export declare class ScoreAudioParam { 109 | private param; 110 | constructor(initialValue: number, param?: PseudoAudioParam); 111 | getValueAtTime(seconds: number): number; 112 | cancelScheduledValues(seconds: number): PseudoAudioParam; 113 | readonly events: ScoreAudioParamEvent[]; 114 | applyScoreCommands(source: Command[]): void; 115 | applyScoreCommand(cmd: Command): void; 116 | } 117 | export interface SetValueAtTimeCmd { 118 | name: 'setValueAtTime'; 119 | args: { 120 | value: number; 121 | startTime: number; 122 | }; 123 | } 124 | export interface ExponentialRampToValueAtTimeCmd { 125 | name: 'exponentialRampToValueAtTime'; 126 | args: { 127 | value: number; 128 | endTime: number; 129 | }; 130 | } 131 | export interface LinearRampToValueAtTimeCmd { 132 | name: 'linearRampToValueAtTime'; 133 | args: { 134 | value: number; 135 | endTime: number; 136 | }; 137 | } 138 | export interface SetTargetAtTimeCmd { 139 | name: 'setTargetAtTime'; 140 | args: { 141 | target: number; 142 | startTime: number; 143 | timeConstant: number; 144 | }; 145 | } 146 | export interface SetValueCurveAtTimeCmd { 147 | name: 'setValueCurveAtTime'; 148 | args: { 149 | values: number[]; 150 | startTime: number; 151 | duration: number; 152 | }; 153 | } 154 | export declare type ScoreAudioParamCmd = 155 | | SetTargetAtTimeCmd 156 | | SetValueAtTimeCmd 157 | | SetValueCurveAtTimeCmd 158 | | LinearRampToValueAtTimeCmd 159 | | ExponentialRampToValueAtTimeCmd; 160 | export declare type Mutation = PushCommandsMutation | ClearCommandsMutation; 161 | export declare enum MutationNames { 162 | PushCommands = 'PUSH_COMMANDS', 163 | ClearCommands = 'CLEAR_COMMANDS' 164 | } 165 | export declare type MutationBase = { 166 | name: MutationNames; 167 | }; 168 | export declare type PushCommandsMutation = { 169 | graphId?: string; 170 | nodeId: string; 171 | paramName: string; 172 | commands: Array; 173 | } & MutationBase; 174 | export declare type ClearCommandsMutation = { 175 | graphId?: string; 176 | nodeId: string; 177 | paramName: string; 178 | } & MutationBase; 179 | export declare type NodePlaybackDescription = { 180 | id: string; 181 | kind: string; 182 | time: TimeInstant; 183 | file?: { 184 | maxDuration: TimeInstant; 185 | }; 186 | loop?: { 187 | loopsSinceStart: number; 188 | currentLoopStartTime: TimeInstant; 189 | currentLoopEndTime: TimeInstant; 190 | loopElapsedTime: TimeInstant; 191 | infinite: boolean; 192 | }; 193 | }; 194 | export declare class SmartPlayer { 195 | private ctx; 196 | private bufferSize; 197 | static DEFAULT_BUFFER_SIZE: number; 198 | private renderer; 199 | constructor(ctx?: AudioContext, bufferSize?: number); 200 | setJson(json: string): Promise; 201 | getJson(graphId?: string): string; 202 | playing: boolean; 203 | renderTime: TimeInstant; 204 | getPlaybackDescription(renderTime: TimeInstant): NodePlaybackDescription[]; 205 | enqueueMutation(effect: Mutation): Promise<{}>; 206 | enqueueScore(score: string): Promise; 207 | enqueueScore(score: Score): Promise; 208 | dequeueScore(graphId: string): Promise<{}>; 209 | private timeChange; 210 | } 211 | -------------------------------------------------------------------------------- /demo/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "nf-player-demo", 3 | "version": "1.0.0", 4 | "description": "Demo UI for the JS Edition of the Native Format Smart Player", 5 | "author": "Drew Petersen ", 6 | "license": "Apache-2.0", 7 | "dependencies": { 8 | "monaco-editor": "^0.20.0", 9 | "react": "^16.13.1", 10 | "react-dom": "^16.13.1", 11 | "styled-components": "^5.0.1" 12 | }, 13 | "devDependencies": { 14 | "@types/react": "^16.9.23", 15 | "@types/react-dom": "^16.9.5" 16 | }, 17 | "//browserslist": "this is to prevent parcel from causing babel to transpile anything and injecting regenerator runtime", 18 | "browserslist": [ 19 | "chrome 69" 20 | ] 21 | } 22 | -------------------------------------------------------------------------------- /demo/src/ExampleJSONScores.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) 2018-Present, Spotify AB. 3 | * 4 | * Licensed to the Apache Software Foundation (ASF) under one 5 | * or more contributor license agreements. See the NOTICE file 6 | * distributed with this work for additional information 7 | * regarding copyright ownership. The ASF licenses this file 8 | * to you under the Apache License, Version 2.0 (the 9 | * "License"); you may not use this file except in compliance 10 | * with the License. You may obtain a copy of the License at 11 | * 12 | * http://www.apache.org/licenses/LICENSE-2.0 13 | * 14 | * Unless required by applicable law or agreed to in writing, 15 | * software distributed under the License is distributed on an 16 | * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY 17 | * KIND, either express or implied. See the License for the 18 | * specific language governing permissions and limitations 19 | * under the License. 20 | */ 21 | 22 | import * as TNGJSON from '../../fixtures/TNG-Infinite-Idle-Engine.json'; 23 | import * as TNGEngines from '../../fixtures/TNG-Crysknife007-16-899-s.wav'; 24 | import * as RoxanneShiftedInfinite from '../../fixtures/roxanne-30s-preview-shifted-infinite.json'; 25 | import * as RatatatLoop from '../../fixtures/ratatat-loop.json'; 26 | import { Score, FileNode } from 'nf-grapher'; 27 | 28 | type ExampleJSON = { name: string; score: Score }; 29 | 30 | // Note: The double JSON.stringify/parsing is mostly for TypeScript, so it knows 31 | // that the incoming JSON is actually a Score. It's hard to tell TS that we 32 | // actually have a score. 33 | 34 | const examples: ExampleJSON[] = [ 35 | { 36 | name: "Ratatat forever", 37 | score: JSON.parse(JSON.stringify(RatatatLoop)) 38 | }, 39 | { 40 | name: 'Star Trek TNG Infinite Ambient Engine Noise', 41 | score: JSON.parse( 42 | JSON.stringify({ 43 | ...TNGJSON, 44 | graph: { 45 | ...TNGJSON.graph, 46 | nodes: TNGJSON.graph.nodes.map(node => { 47 | if (node.kind === FileNode.PLUGIN_KIND) { 48 | node.config.file = TNGEngines.default; 49 | } 50 | return node; 51 | }) 52 | } 53 | }) 54 | ) 55 | }, 56 | { 57 | name: 'Roxanne, but pitched on every "Roxanne" (infinite JSON version)', 58 | score: JSON.parse(JSON.stringify(RoxanneShiftedInfinite)) 59 | }, 60 | ]; 61 | 62 | export { examples, ExampleJSON }; 63 | -------------------------------------------------------------------------------- /demo/src/components/App.tsx: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) 2018-Present, Spotify AB. 3 | * 4 | * Licensed to the Apache Software Foundation (ASF) under one 5 | * or more contributor license agreements. See the NOTICE file 6 | * distributed with this work for additional information 7 | * regarding copyright ownership. The ASF licenses this file 8 | * to you under the Apache License, Version 2.0 (the 9 | * "License"); you may not use this file except in compliance 10 | * with the License. You may obtain a copy of the License at 11 | * 12 | * http://www.apache.org/licenses/LICENSE-2.0 13 | * 14 | * Unless required by applicable law or agreed to in writing, 15 | * software distributed under the License is distributed on an 16 | * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY 17 | * KIND, either express or implied. See the License for the 18 | * specific language governing permissions and limitations 19 | * under the License. 20 | */ 21 | 22 | import * as React from 'react'; 23 | import { 24 | SmartPlayer, 25 | TimeInstant, 26 | ScriptProcessorRenderer 27 | } from '../../../src'; 28 | import { XAudioContext } from '../../../src/WebAudioContext'; 29 | 30 | import { JSONEditor } from './JSONEditor/JSONEditor'; 31 | import { CODEEditor } from './CODEEditor/CODEEditor'; 32 | import { WaveVisualizer } from './WaveVisualizer/WaveVisualizer'; 33 | import styled from 'styled-components'; 34 | import { DemoTheme } from './Theme'; 35 | import { Score } from 'nf-grapher'; 36 | import { 37 | VerticalFitArea, 38 | VerticalFixedSection, 39 | VerticalExpandableSection 40 | } from './VerticalLayout'; 41 | 42 | const StyledApplication = styled.div` 43 | font-family: ${DemoTheme.fontFamily}; 44 | color: #000; 45 | font-size: ${DemoTheme.bodyFontSize}; 46 | height: 100%; 47 | `; 48 | 49 | enum Panels { 50 | CODE, 51 | JSON, 52 | VISUALIZER 53 | } 54 | 55 | // https://webaudio.github.io/web-audio-api/#AnalyserNode-attributes 56 | const defaultAnalyserOptions = { 57 | smoothingTimeConstant: 0.8, 58 | fftSize: 2048, 59 | minDecibels: -100, 60 | maxDecibels: -30 61 | }; 62 | 63 | const initialAppState = { 64 | panel: Panels.CODE, 65 | player: new SmartPlayer(), 66 | analyser: XAudioContext().createAnalyser() 67 | }; 68 | 69 | type AppState = Readonly; 70 | type AppProps = {}; 71 | 72 | export class App extends React.Component { 73 | readonly state: AppState = initialAppState; 74 | 75 | switchPanel(to: Panels) { 76 | if (this.state.player.playing) { 77 | this.state.player.playing = false; 78 | } 79 | 80 | this.state.player.renderTime = TimeInstant.ZERO; 81 | this.state.player.setJson(JSON.stringify(new Score())); 82 | 83 | let nextRenderer; 84 | let analyser; 85 | if (to === Panels.VISUALIZER) { 86 | const context = XAudioContext(); 87 | analyser = new AnalyserNode(context, defaultAnalyserOptions); 88 | analyser.connect(context.destination); 89 | nextRenderer = new ScriptProcessorRenderer(context, undefined); 90 | 91 | // FFT analyzer - note that processor is a private property 92 | nextRenderer.processor.connect(analyser); 93 | } else { 94 | // SmashEditor needs a much smaller quantum in order to 95 | // feel responsive when triggering one-shots! 96 | nextRenderer = new ScriptProcessorRenderer(undefined, undefined); 97 | } 98 | 99 | const nextPlayer = new SmartPlayer(nextRenderer); 100 | const nextState = { 101 | panel: to, 102 | player: nextPlayer 103 | }; 104 | 105 | // typescript type guard 106 | if (analyser !== undefined) { 107 | this.setState({ 108 | ...nextState, 109 | analyser 110 | }); 111 | } else { 112 | this.setState(nextState); 113 | } 114 | } 115 | 116 | componentDidUpdate() { 117 | (window as any).p = this.state.player; 118 | } 119 | 120 | render() { 121 | const { panel, player, analyser } = this.state; 122 | 123 | return ( 124 | 125 | 126 | 127 | 130 | 133 | 136 | 137 | 138 | {panel === Panels.JSON && } 139 | {panel === Panels.CODE && } 140 | {panel === Panels.VISUALIZER && ( 141 | 142 | )} 143 | 144 | 145 | 146 | ); 147 | } 148 | } 149 | -------------------------------------------------------------------------------- /demo/src/components/CODEEditor/CODEEditor.tsx: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) 2018-Present, Spotify AB. 3 | * 4 | * Licensed to the Apache Software Foundation (ASF) under one 5 | * or more contributor license agreements. See the NOTICE file 6 | * distributed with this work for additional information 7 | * regarding copyright ownership. The ASF licenses this file 8 | * to you under the Apache License, Version 2.0 (the 9 | * "License"); you may not use this file except in compliance 10 | * with the License. You may obtain a copy of the License at 11 | * 12 | * http://www.apache.org/licenses/LICENSE-2.0 13 | * 14 | * Unless required by applicable law or agreed to in writing, 15 | * software distributed under the License is distributed on an 16 | * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY 17 | * KIND, either express or implied. See the License for the 18 | * specific language governing permissions and limitations 19 | * under the License. 20 | */ 21 | 22 | import * as React from 'react'; 23 | import { SmartPlayer, TimeInstant } from '../../../../src/'; 24 | import { 25 | VerticalFitArea, 26 | VerticalFixedSection, 27 | VerticalExpandableSection 28 | } from '../VerticalLayout'; 29 | import { 30 | examples, 31 | ExampleScript, 32 | CompiledPlaygroundScript 33 | } from '../../ExampleScripts'; 34 | import { PlayerControlBar } from '../PlayerControlBar'; 35 | import { PlayerWatcher } from '../PlayerWatcher'; 36 | import { MonacoEditor } from '../Monaco'; 37 | 38 | import * as NFGrapher from 'nf-grapher'; 39 | import * as NFPlayer from '../../../../src/'; 40 | 41 | import * as ts from 'typescript'; 42 | 43 | type Props = { 44 | player: SmartPlayer; 45 | }; 46 | 47 | type State = { 48 | example: undefined | ExampleScript; 49 | loading: boolean; 50 | }; 51 | 52 | const initialState: State = { 53 | example: examples[0], 54 | loading: false 55 | }; 56 | 57 | // This is super esoteric. The TS compiler will transpile `async`, so to get 58 | // an _actual_ async function, we need to eval it... 59 | const GetAsyncFunctionCtor = new Function( 60 | 'return Object.getPrototypeOf(async function(){}).constructor' 61 | ); 62 | const AsyncFunction = GetAsyncFunctionCtor(); 63 | 64 | export class CODEEditor extends React.Component { 65 | readonly state = initialState; 66 | 67 | private playerControlBarRef = React.createRef(); 68 | 69 | private getEditorValue: () => string = () => ''; 70 | 71 | componentDidMount() { 72 | this.setState({ 73 | example: { 74 | name: examples[0].name, 75 | script: this.processExample(examples[0]) 76 | } 77 | }); 78 | } 79 | 80 | processExample(example: ExampleScript) { 81 | // const firstIdx = example.script.indexOf('{'); 82 | // const lastIdx = example.script.lastIndexOf('}'); 83 | // const stripped = example.script.substring(firstIdx + 1, lastIdx); 84 | // return stripped; 85 | 86 | // Strip the leading whitespace. 87 | 88 | let lines = example.script.split('\n'); 89 | if (lines[0].length === 0) lines.shift(); 90 | 91 | const nonWhitespaceMatch = lines[0].match(/\S/); 92 | 93 | if (nonWhitespaceMatch !== null) { 94 | lines = lines.map(line => line.substring(nonWhitespaceMatch.index!)); 95 | } 96 | 97 | // This is to allow monaco to not red squiggle top-level await. 98 | lines.unshift('async function main () {'); 99 | lines.push('}'); 100 | 101 | return lines.join('\n'); 102 | } 103 | 104 | onExampleSelect = async (event: React.ChangeEvent) => { 105 | const example = examples.find( 106 | example => example.name === event.target.value 107 | ); 108 | 109 | if (example) { 110 | this.setState({ 111 | example: { 112 | name: example.name, 113 | script: this.processExample(example) 114 | } 115 | }); 116 | } else { 117 | this.setState({ 118 | example 119 | }); 120 | } 121 | 122 | const { player } = this.props; 123 | 124 | if (player.playing) { 125 | player.playing = false; 126 | } 127 | 128 | player.renderTime = TimeInstant.ZERO; 129 | }; 130 | 131 | handlePlayPause = () => { 132 | const { player } = this.props; 133 | player.playing = !player.playing; 134 | }; 135 | 136 | onEvalCode = async () => { 137 | const code = this.getEditorValue(); 138 | const { player } = this.props; 139 | 140 | // TODO: expose a document or iframe env to the code so it can 141 | // attach arbitrary DOM stuff too, like user-clicks or buttons. 142 | // Probably have to re-evaluate how the player is passed around, 143 | // since it will be difficult to expose it to an iframe. 144 | 145 | const transpiled = ts.transpileModule(code, { 146 | compilerOptions: { module: ts.ModuleKind.ES2015 } 147 | }); 148 | 149 | const fn = new AsyncFunction( 150 | 'p', 151 | 'NFGrapher', 152 | 'NFPlayer', 153 | transpiled.outputText + ';return main();' 154 | ) as CompiledPlaygroundScript; 155 | 156 | try { 157 | this.setState({ loading: true }); 158 | // blank out the current scores 159 | await player.dequeueScores(); 160 | await fn(player, NFGrapher, NFPlayer); 161 | } catch (e) { 162 | // temp for now 163 | console.error(e); 164 | } 165 | 166 | this.setState({ loading: false }); 167 | }; 168 | 169 | componentDidMount() { 170 | this.forceUpdate(); 171 | } 172 | 173 | render() { 174 | const { player } = this.props; 175 | const { example, loading } = this.state; 176 | 177 | return ( 178 | 179 | 180 | 193 | 194 | {(currentTime, playing) => ( 195 | { 203 | const total = player.renderTime.add(amount); 204 | player.renderTime = total.lt(TimeInstant.ZERO) 205 | ? TimeInstant.ZERO 206 | : total; 207 | }} 208 | /> 209 | )} 210 | 211 | 212 | 213 | 214 | (this.getEditorValue = getValue)} 218 | onChange={ 219 | this.playerControlBarRef.current 220 | ? this.playerControlBarRef.current.onChange 221 | : () => {} 222 | } 223 | /> 224 | 225 | 226 | ); 227 | } 228 | } 229 | -------------------------------------------------------------------------------- /demo/src/components/JSONEditor/JSONEditor.tsx: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) 2018-Present, Spotify AB. 3 | * 4 | * Licensed to the Apache Software Foundation (ASF) under one 5 | * or more contributor license agreements. See the NOTICE file 6 | * distributed with this work for additional information 7 | * regarding copyright ownership. The ASF licenses this file 8 | * to you under the Apache License, Version 2.0 (the 9 | * "License"); you may not use this file except in compliance 10 | * with the License. You may obtain a copy of the License at 11 | * 12 | * http://www.apache.org/licenses/LICENSE-2.0 13 | * 14 | * Unless required by applicable law or agreed to in writing, 15 | * software distributed under the License is distributed on an 16 | * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY 17 | * KIND, either express or implied. See the License for the 18 | * specific language governing permissions and limitations 19 | * under the License. 20 | */ 21 | 22 | import * as React from 'react'; 23 | import { SmartPlayer, TimeInstant } from '../../../../src'; 24 | import { examples, ExampleJSON } from '../../ExampleJSONScores'; 25 | 26 | import { MonacoEditor } from '../Monaco'; 27 | import { PlayerControlBar } from '../PlayerControlBar'; 28 | import { PlayerWatcher } from '../PlayerWatcher'; 29 | import { Score, TypedNode } from 'nf-grapher'; 30 | import { ScoreVisualizer } from '../ScoreVisualizer'; 31 | import { 32 | VerticalFitArea, 33 | VerticalFixedSection, 34 | VerticalExpandableSection 35 | } from '../VerticalLayout'; 36 | 37 | // Nodes within Grapher Scores are of type Node and are a 1:1 with their JSON 38 | // serialization. But if you call Score.from(obj) the nodes are "parsed" into 39 | // a TypedNode. This is a gotcha. 40 | const extractTypedNodes = (score: Score) => 41 | Score.from(score).graph.nodes as TypedNode[]; 42 | 43 | type Props = { 44 | player: SmartPlayer; 45 | }; 46 | 47 | const initialState = { 48 | example: examples[0], 49 | exampleTypedNodes: extractTypedNodes(examples[0].score), 50 | loading: false 51 | }; 52 | 53 | type State = Readonly<{ 54 | example: undefined | ExampleJSON; 55 | exampleTypedNodes: TypedNode[]; 56 | loading: boolean; 57 | }>; 58 | 59 | export class JSONEditor extends React.Component { 60 | readonly state = initialState; 61 | 62 | private getEditorValue: () => string = () => ''; 63 | 64 | onExampleSelect = async (event: React.ChangeEvent) => { 65 | const example = examples.find( 66 | example => example.name === event.target.value 67 | ); 68 | 69 | this.setState({ 70 | example: example, 71 | exampleTypedNodes: example ? extractTypedNodes(example.score) : [] 72 | }); 73 | 74 | const { player } = this.props; 75 | 76 | if (player.playing) { 77 | player.playing = false; 78 | } 79 | }; 80 | 81 | handlePlayPause = async () => { 82 | const { player } = this.props; 83 | 84 | if (player.playing) { 85 | player.playing = false; 86 | return; 87 | } 88 | 89 | try { 90 | const editorValue: Score = JSON.parse(this.getEditorValue()); 91 | const playerValue: Score[] = JSON.parse(player.getJson()); 92 | 93 | const editorJSON = JSON.stringify(editorValue); 94 | const playerJSON = JSON.stringify(playerValue[0]); 95 | const exampleJSON = JSON.stringify(this.state.example.score); 96 | 97 | // We only want to reload the graph if the Score is actually different. 98 | if (!playerValue.length || editorJSON !== playerJSON) { 99 | // The editor value is the source of true, send it into the player. 100 | this.setState({ loading: true }); 101 | await player.setJson(editorJSON); 102 | 103 | // We need these to visualize the score. 104 | const exampleTypedNodes = extractTypedNodes(editorValue); 105 | 106 | // Only set the internal state to "user edited" if the current JSON is 107 | // likely user-edited. 108 | if (editorJSON !== exampleJSON) { 109 | this.setState({ 110 | example: { 111 | name: 'User edited', 112 | score: editorValue 113 | } 114 | }); 115 | } 116 | 117 | this.setState({ loading: false, exampleTypedNodes }); 118 | } 119 | } catch (e) {} 120 | 121 | player.playing = !player.playing; 122 | }; 123 | 124 | render() { 125 | const { player } = this.props; 126 | const { example, loading, exampleTypedNodes } = this.state; 127 | 128 | return ( 129 | 130 | 131 | 144 | 145 | {(currentTime, playing) => ( 146 | { 152 | const total = player.renderTime.add(amount); 153 | player.renderTime = total.lt(TimeInstant.ZERO) 154 | ? TimeInstant.ZERO 155 | : total; 156 | }} 157 | /> 158 | )} 159 | 160 | 161 | 162 | 163 | {(currentTime, playing) => 164 | playing ? ( 165 | 169 | ) : ( 170 | (this.getEditorValue = getValue)} 176 | /> 177 | ) 178 | } 179 | 180 | 181 | 182 | ); 183 | } 184 | } 185 | -------------------------------------------------------------------------------- /demo/src/components/PlayerControlBar.tsx: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) 2018-Present, Spotify AB. 3 | * 4 | * Licensed to the Apache Software Foundation (ASF) under one 5 | * or more contributor license agreements. See the NOTICE file 6 | * distributed with this work for additional information 7 | * regarding copyright ownership. The ASF licenses this file 8 | * to you under the Apache License, Version 2.0 (the 9 | * "License"); you may not use this file except in compliance 10 | * with the License. You may obtain a copy of the License at 11 | * 12 | * http://www.apache.org/licenses/LICENSE-2.0 13 | * 14 | * Unless required by applicable law or agreed to in writing, 15 | * software distributed under the License is distributed on an 16 | * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY 17 | * KIND, either express or implied. See the License for the 18 | * specific language governing permissions and limitations 19 | * under the License. 20 | */ 21 | 22 | import styled from 'styled-components'; 23 | import * as React from 'react'; 24 | import { TimeInstant } from '../../../src/'; 25 | import { DemoTheme } from './Theme'; 26 | 27 | const StyledControlBar = styled.div` 28 | display: flex; 29 | justify-content: space-between; 30 | `; 31 | 32 | const StyledControlBarChild = styled.div` 33 | flex: 1; 34 | text-align: center; 35 | 36 | &:first-child { 37 | text-align: left; 38 | } 39 | 40 | &:last-child { 41 | text-align: right; 42 | } 43 | `; 44 | 45 | const StyledButton = styled.button` 46 | font-size: ${DemoTheme.bodyFontSize}; 47 | `; 48 | 49 | type Props = { 50 | currentTime: TimeInstant; 51 | isPlaying: boolean; 52 | isLoading: boolean; 53 | onPlayPause: () => void; 54 | onSeek: (amount: TimeInstant) => void; 55 | onEval?: () => void; 56 | }; 57 | 58 | type State = { 59 | changing: boolean; 60 | }; 61 | 62 | const initialState: State = { 63 | changing: false 64 | }; 65 | 66 | export class PlayerControlBar extends React.PureComponent { 67 | private state = initialState; 68 | 69 | onSeekBack = (seconds?: number) => { 70 | this.props.onSeek(TimeInstant.fromSeconds(seconds || -30)); 71 | }; 72 | 73 | onSeekForward = (seconds?: number) => { 74 | this.props.onSeek(TimeInstant.fromSeconds(seconds || 30)); 75 | }; 76 | 77 | onChange = (changing: boolean) => { 78 | this.setState({ changing }); 79 | }; 80 | 81 | render() { 82 | const icon = this.props.isPlaying ? '❙❙' : '►'; 83 | const { isLoading } = this.props; 84 | return ( 85 | 86 | 87 | {this.props.currentTime.asSeconds().toFixed(3)} 88 | 89 | 90 | {this.props.onEval && ( 91 | 92 | { 95 | this.onChange(false); 96 | this.props.onEval && this.props.onEval(); 97 | }} 98 | > 99 | Eval/Load Code from Editor 100 | 101 | 102 | )} 103 | 104 | 105 | this.onSeekBack(-30)} 108 | > 109 | << 30s 110 | 111 | this.onSeekBack(-5)} 114 | > 115 | << 5s 116 | 117 | 121 | {icon} 122 | 123 | this.onSeekBack(5)} 126 | > 127 | 5s >> 128 | 129 | this.onSeekForward(30)} 132 | > 133 | 30s >> 134 | 135 | 136 | 137 | 138 | {isLoading && 'Loading...'} 139 | 140 | 141 | ); 142 | } 143 | } 144 | -------------------------------------------------------------------------------- /demo/src/components/PlayerWatcher.tsx: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) 2018-Present, Spotify AB. 3 | * 4 | * Licensed to the Apache Software Foundation (ASF) under one 5 | * or more contributor license agreements. See the NOTICE file 6 | * distributed with this work for additional information 7 | * regarding copyright ownership. The ASF licenses this file 8 | * to you under the Apache License, Version 2.0 (the 9 | * "License"); you may not use this file except in compliance 10 | * with the License. You may obtain a copy of the License at 11 | * 12 | * http://www.apache.org/licenses/LICENSE-2.0 13 | * 14 | * Unless required by applicable law or agreed to in writing, 15 | * software distributed under the License is distributed on an 16 | * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY 17 | * KIND, either express or implied. See the License for the 18 | * specific language governing permissions and limitations 19 | * under the License. 20 | */ 21 | 22 | import * as React from 'react'; 23 | import { SmartPlayer, TimeInstant } from '../../../src/'; 24 | 25 | type Props = { 26 | player: SmartPlayer; 27 | // forceUpdate: boolean; 28 | children: ( 29 | renderTime: TimeInstant, 30 | playing: boolean 31 | ) => React.ReactChildren | React.ReactChild; 32 | }; 33 | 34 | const initialState = { 35 | renderTime: TimeInstant.ZERO, 36 | playing: false 37 | }; 38 | 39 | type State = Readonly; 40 | 41 | export class PlayerWatcher extends React.Component { 42 | state = initialState; 43 | 44 | private playheadPoll: null | number = null; 45 | 46 | componentDidMount() { 47 | window.clearInterval(this.playheadPoll || 0); 48 | const tickInterval = 20; //(8192 / 3 / 44100) * 1000; 49 | 50 | this.playheadPoll = window.setInterval(() => { 51 | const { player } = this.props; 52 | const { renderTime, playing } = player; 53 | 54 | this.setState(state => { 55 | if (state.renderTime.eq(renderTime) && state.playing === playing) 56 | return state; 57 | return { renderTime, playing }; 58 | }); 59 | 60 | // this.setState({ 61 | // renderTime, 62 | // playing 63 | // }); 64 | }, tickInterval); 65 | } 66 | 67 | // shouldComponentUpdate(nextProps: Props, nextState: State) { 68 | // if (nextProps.forceUpdate) return true; 69 | // if (this.state.playing !== nextState.playing) return true; 70 | // if (this.state.renderTime.neq(nextState.renderTime)) return true; 71 | // return false; 72 | // } 73 | 74 | componentWillUnmount() { 75 | window.clearInterval(this.playheadPoll || 0); 76 | } 77 | 78 | render() { 79 | return this.props.children( 80 | this.state.renderTime, 81 | this.props.player.playing 82 | ); 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /demo/src/components/ScoreVisualizer/CanvasPowered.tsx: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) 2018-Present, Spotify AB. 3 | * 4 | * Licensed to the Apache Software Foundation (ASF) under one 5 | * or more contributor license agreements. See the NOTICE file 6 | * distributed with this work for additional information 7 | * regarding copyright ownership. The ASF licenses this file 8 | * to you under the Apache License, Version 2.0 (the 9 | * "License"); you may not use this file except in compliance 10 | * with the License. You may obtain a copy of the License at 11 | * 12 | * http://www.apache.org/licenses/LICENSE-2.0 13 | * 14 | * Unless required by applicable law or agreed to in writing, 15 | * software distributed under the License is distributed on an 16 | * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY 17 | * KIND, either express or implied. See the License for the 18 | * specific language governing permissions and limitations 19 | * under the License. 20 | */ 21 | 22 | import * as React from 'react'; 23 | 24 | type CanvasProps = { 25 | width: number; 26 | height: number; 27 | autofit: 'none' | 'width'; 28 | children: (cvs: HTMLCanvasElement, ctx: CanvasRenderingContext2D) => void; 29 | }; 30 | 31 | const initialCanvasState = { 32 | width: 0 33 | }; 34 | 35 | export class CanvasPowered extends React.Component< 36 | CanvasProps, 37 | typeof initialCanvasState 38 | > { 39 | private cvsRef = React.createRef(); 40 | private ctx: CanvasRenderingContext2D | null = null; 41 | 42 | state = initialCanvasState; 43 | 44 | componentDidMount() { 45 | if (!this.cvsRef.current) return; 46 | this.ctx = this.cvsRef.current.getContext('2d'); 47 | 48 | if (this.props.autofit === 'none') return; 49 | 50 | const parent = this.cvsRef.current.parentNode; 51 | if (!parent) return; 52 | 53 | const computed = window.getComputedStyle(parent as Element); 54 | const rect = (parent as Element).getBoundingClientRect(); 55 | const maxWidth = 56 | rect.width - 57 | parseFloat(computed.paddingLeft || '0') - 58 | parseFloat(computed.paddingRight || '0'); 59 | 60 | this.setState({ width: maxWidth }, () => { 61 | // Wait until after the state change has been propagated, because setting 62 | // width/height on a canvas clears the contents. 63 | // Without this, the canvas will be blank until we start playing. 64 | this.redraw(); 65 | }); 66 | } 67 | 68 | redraw() { 69 | if (this.cvsRef.current && this.ctx) { 70 | this.props.children(this.cvsRef.current, this.ctx); 71 | } 72 | } 73 | 74 | render() { 75 | this.redraw(); 76 | 77 | const width = 78 | this.state.width !== initialCanvasState.width 79 | ? this.state.width 80 | : this.props.width; 81 | 82 | return ( 83 | 84 | ); 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /demo/src/components/ScoreVisualizer/FileNodeMonitor.tsx: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) 2018-Present, Spotify AB. 3 | * 4 | * Licensed to the Apache Software Foundation (ASF) under one 5 | * or more contributor license agreements. See the NOTICE file 6 | * distributed with this work for additional information 7 | * regarding copyright ownership. The ASF licenses this file 8 | * to you under the Apache License, Version 2.0 (the 9 | * "License"); you may not use this file except in compliance 10 | * with the License. You may obtain a copy of the License at 11 | * 12 | * http://www.apache.org/licenses/LICENSE-2.0 13 | * 14 | * Unless required by applicable law or agreed to in writing, 15 | * software distributed under the License is distributed on an 16 | * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY 17 | * KIND, either express or implied. See the License for the 18 | * specific language governing permissions and limitations 19 | * under the License. 20 | */ 21 | 22 | import { FileNode } from 'nf-grapher'; 23 | import { NodePlaybackDescription, TimeInstant } from '../../../../src/'; 24 | import * as React from 'react'; 25 | import { CanvasPowered } from './CanvasPowered'; 26 | 27 | type FileNodeMonitorProps = { 28 | node: FileNode; 29 | description: NodePlaybackDescription; 30 | }; 31 | 32 | export class FileNodeMonitor extends React.Component { 33 | shouldComponentUpdate(nextProps: FileNodeMonitorProps) { 34 | return this.props.description.time.neq(nextProps.description.time); 35 | } 36 | 37 | componentDidMount() {} 38 | 39 | render() { 40 | const { node } = this.props; 41 | const when = TimeInstant.fromNanos(node.when); 42 | const duration = TimeInstant.fromNanos(node.duration); 43 | const offset = TimeInstant.fromNanos(node.offset); 44 | 45 | const { time } = this.props.description; 46 | const { maxDuration } = this.props.description.file!; 47 | 48 | const endTime = when.add(duration); 49 | const rangeProgress = time.gte(endTime) 50 | ? 1 51 | : time.lte(when) 52 | ? 0 53 | : time.sub(when).div(duration); 54 | const leadupProgress = time.lte(when) ? when.sub(time).div(when) : 0; 55 | 56 | // leadup (height * 0.25) ---------------------------- 57 | // the file (height * 0.75) =====[========|====]======== 58 | // 59 | 60 | const leadupColor = 'magenta'; 61 | const playheadColor = 'magenta'; 62 | const rangeColor = 'rgba(0, 0, 0, 0.5)'; 63 | const fileColor = 'blue'; 64 | const playheadWidth = 2; 65 | 66 | const hoverDescription = 67 | 'MAGENTA BAR: time until this node begins playing, ' + 68 | 'according to its playback config. \n' + 69 | 'BLUE: The entire file. \n' + 70 | 'DARKER BLUE: The range of the file that will actually play. \n' + 71 | "MAGENTA LINE: The file's playhead"; 72 | 73 | return ( 74 |
75 | 76 | {(cvs, ctx) => { 77 | const leadupHeight = cvs.height * 0.25; 78 | const fileY = leadupHeight; 79 | const fileHeight = cvs.height * 0.75; 80 | 81 | const rangeStartX = offset.div(maxDuration) * cvs.width; 82 | const rangeEndX = 83 | maxDuration.sub(duration).div(maxDuration) * cvs.width; 84 | const rangeWidth = rangeEndX - rangeStartX; 85 | 86 | ctx.clearRect(0, 0, cvs.width, cvs.height); 87 | 88 | // Draw the leadup. 89 | ctx.fillStyle = leadupColor; 90 | ctx.fillRect(0, 0, cvs.width * leadupProgress, leadupHeight); 91 | 92 | // Draw the "file" 93 | ctx.fillStyle = fileColor; 94 | ctx.fillRect(0, fileY, cvs.width, fileHeight); 95 | 96 | // Draw the range 97 | ctx.fillStyle = rangeColor; 98 | ctx.fillRect(rangeStartX, fileY, rangeWidth, fileHeight); 99 | 100 | // Draw the playhead 101 | ctx.fillStyle = playheadColor; 102 | ctx.fillRect( 103 | Math.min( 104 | rangeStartX + rangeWidth * rangeProgress, 105 | cvs.width - playheadWidth 106 | ), 107 | fileY, 108 | playheadWidth, 109 | fileHeight 110 | ); 111 | }} 112 | 113 |
114 | ); 115 | } 116 | } 117 | -------------------------------------------------------------------------------- /demo/src/components/ScoreVisualizer/LoopNodeMonitor.tsx: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) 2018-Present, Spotify AB. 3 | * 4 | * Licensed to the Apache Software Foundation (ASF) under one 5 | * or more contributor license agreements. See the NOTICE file 6 | * distributed with this work for additional information 7 | * regarding copyright ownership. The ASF licenses this file 8 | * to you under the Apache License, Version 2.0 (the 9 | * "License"); you may not use this file except in compliance 10 | * with the License. You may obtain a copy of the License at 11 | * 12 | * http://www.apache.org/licenses/LICENSE-2.0 13 | * 14 | * Unless required by applicable law or agreed to in writing, 15 | * software distributed under the License is distributed on an 16 | * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY 17 | * KIND, either express or implied. See the License for the 18 | * specific language governing permissions and limitations 19 | * under the License. 20 | */ 21 | 22 | import { LoopNode } from 'nf-grapher'; 23 | import { NodePlaybackDescription, TimeInstant } from '../../../../src/'; 24 | import * as React from 'react'; 25 | import { CanvasPowered } from './CanvasPowered'; 26 | 27 | type LoopNodeMonitorProps = { 28 | node: LoopNode; 29 | description: NodePlaybackDescription; 30 | }; 31 | 32 | export class LoopNodeMonitor extends React.Component { 33 | shouldComponentUpdate(nextProps: LoopNodeMonitorProps) { 34 | return this.props.description.time.neq(nextProps.description.time); 35 | } 36 | 37 | componentDidMount() {} 38 | 39 | render() { 40 | const { node } = this.props; 41 | const when = TimeInstant.fromNanos(node.when); 42 | const duration = TimeInstant.fromNanos(node.duration); 43 | const loopCount = node.loopCount; 44 | 45 | const { time } = this.props.description; 46 | 47 | const { 48 | loopElapsedTime, 49 | loopsSinceStart, 50 | currentLoopStartTime, 51 | currentLoopEndTime, 52 | infinite 53 | } = this.props.description.loop!; 54 | 55 | // This is the endTime for only the first loop! 56 | const endTime = when.add(duration); 57 | let loopProgress: number; 58 | 59 | if (loopElapsedTime.lte(TimeInstant.ZERO)) { 60 | // loop hasn't started yet 61 | loopProgress = 0; 62 | } else if (!infinite && loopsSinceStart >= loopCount) { 63 | // loop is over! 64 | loopProgress = 1; 65 | } else { 66 | // loop in progress 67 | loopProgress = loopElapsedTime.div(duration); 68 | } 69 | 70 | const leadupProgress = time.lte(when) ? when.sub(time).div(when) : 0; 71 | const loopBars = infinite ? 1 : loopCount; 72 | 73 | // leadup (height * 0.25) ---------------------------- 74 | // the loop iteration (height * (0.75 / loopCount) ==============|============= 75 | // if infinite loop, div by 1. 76 | 77 | const leadupColor = 'magenta'; 78 | const playheadColor = 'magenta'; 79 | const fileColor = 'blue'; 80 | const playheadWidth = 2; 81 | 82 | const hoverDescription = 83 | 'MAGENTA BAR: time until this node begins playing, ' + 84 | 'according to its playback config. \n' + 85 | 'BLUE: A single loop duration. \n' + 86 | "MAGENTA LINE: The loop's playhead"; 87 | 88 | return ( 89 |
90 | 91 | {(cvs, ctx) => { 92 | const leadupHeight = cvs.height * 0.25; 93 | const loopY = leadupHeight; 94 | const singleLoopHeight = cvs.height * (0.75 / loopBars); 95 | 96 | ctx.clearRect(0, 0, cvs.width, cvs.height); 97 | 98 | // Draw the leadup. 99 | ctx.fillStyle = leadupColor; 100 | ctx.fillRect(0, 0, cvs.width * leadupProgress, leadupHeight); 101 | 102 | if (!infinite) { 103 | const completedHeight = 104 | singleLoopHeight * (loopsSinceStart > 0 ? loopsSinceStart : 0); 105 | 106 | // Draw the completed bars 107 | ctx.fillStyle = fileColor; 108 | ctx.fillRect(0, loopY, cvs.width, completedHeight); 109 | 110 | // Draw the current/upcoming loop 111 | ctx.fillStyle = fileColor; 112 | ctx.fillRect( 113 | 0, 114 | loopY + completedHeight, 115 | cvs.width, 116 | singleLoopHeight 117 | ); 118 | 119 | // Draw the playhead 120 | ctx.fillStyle = playheadColor; 121 | ctx.fillRect( 122 | Math.min(cvs.width * loopProgress, cvs.width - playheadWidth), 123 | Math.min( 124 | loopY + completedHeight, 125 | cvs.height - singleLoopHeight 126 | ), 127 | playheadWidth, 128 | singleLoopHeight 129 | ); 130 | } else { 131 | // Draw the current/upcoming loop 132 | ctx.fillStyle = fileColor; 133 | ctx.fillRect(0, loopY, cvs.width, singleLoopHeight); 134 | 135 | // Draw the playhead 136 | ctx.fillStyle = playheadColor; 137 | ctx.fillRect( 138 | cvs.width * loopProgress, 139 | loopY, 140 | playheadWidth, 141 | singleLoopHeight 142 | ); 143 | } 144 | }} 145 | 146 |
147 | ); 148 | } 149 | } 150 | -------------------------------------------------------------------------------- /demo/src/components/ScoreVisualizer/NodePanel.tsx: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) 2018-Present, Spotify AB. 3 | * 4 | * Licensed to the Apache Software Foundation (ASF) under one 5 | * or more contributor license agreements. See the NOTICE file 6 | * distributed with this work for additional information 7 | * regarding copyright ownership. The ASF licenses this file 8 | * to you under the Apache License, Version 2.0 (the 9 | * "License"); you may not use this file except in compliance 10 | * with the License. You may obtain a copy of the License at 11 | * 12 | * http://www.apache.org/licenses/LICENSE-2.0 13 | * 14 | * Unless required by applicable law or agreed to in writing, 15 | * software distributed under the License is distributed on an 16 | * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY 17 | * KIND, either express or implied. See the License for the 18 | * specific language governing permissions and limitations 19 | * under the License. 20 | */ 21 | 22 | import * as React from 'react'; 23 | import { TypedNode } from 'nf-grapher'; 24 | import styled from 'styled-components'; 25 | import { SourceMonitor } from './SourceMonitor'; 26 | import { NodePlaybackDescription } from '../../../../src/'; 27 | import { DemoTheme } from '../Theme'; 28 | import { ParamMonitor } from './ParamMonitor'; 29 | 30 | const niceNodeName = (name: string) => { 31 | const last = name.split('.').pop(); 32 | return last; 33 | }; 34 | 35 | const shortGID = (id: string) => id.substr(0, 8); 36 | 37 | const StyledNodePanel = styled.div` 38 | position: relative; 39 | background-color: #ccc; 40 | font-size: 12px; 41 | padding: ${DemoTheme.unitPx}; 42 | margin: ${DemoTheme.unitPx}; 43 | 44 | &:before { 45 | content: ''; 46 | position: absolute; 47 | top: 0; 48 | left: 0; 49 | border-width: ${DemoTheme.unitPx} ${DemoTheme.unitPx} 0px 0px; 50 | border-style: solid; 51 | border-color: #fff transparent transparent #fff; 52 | } 53 | `; 54 | 55 | const PanelTitleBar = styled.header` 56 | text-transform: uppercase; 57 | display: flex; 58 | `; 59 | 60 | const PanelTitle = styled.span` 61 | flex: 1; 62 | `; 63 | 64 | // TODO: get some flexbox in here? 65 | const PanelNumericTimeDisplay = styled.span` 66 | text-align: right; 67 | flex: 1; 68 | `; 69 | 70 | const StyledConfigTable = styled.table` 71 | margin: ${DemoTheme.unitPx} 0 ${DemoTheme.unitPx} 0; 72 | width: 100%; 73 | table-layout: fixed; 74 | `; 75 | 76 | const StyledTruncatedText = styled.div` 77 | overflow: hidden; 78 | white-space: nowrap; 79 | text-overflow: ellipsis; 80 | `; 81 | 82 | // TODO: don't use a table? 83 | const configTableForNode = (node: TypedNode) => { 84 | const config = node.toNode().config; 85 | if (!config) return null; 86 | return ( 87 | 88 | 89 | {Object.keys(config).map(key => ( 90 | 91 | {key} 92 | 93 | 94 | {config[key]} 95 | 96 | 97 | 98 | ))} 99 | 100 | 101 | ); 102 | }; 103 | 104 | export const NodePanel: React.SFC<{ 105 | node: TypedNode; 106 | description: NodePlaybackDescription; 107 | }> = ({ node, description }) => { 108 | const { time } = description; 109 | const timeNotion = 110 | "This node's notion of current time, which can be " + 111 | 'dilated by downstream stretch/loop nodes'; 112 | 113 | return ( 114 | 115 | 116 | 117 | {niceNodeName(node.kind)} {shortGID(node.id)} 118 | 119 | 123 | {time.asSeconds().toFixed(3)} 124 | 125 | 126 | {configTableForNode(node)} 127 | {SourceMonitor.fromNode(node, description)} 128 | {ParamMonitor.fromNode(node, time)} 129 | 130 | ); 131 | }; 132 | -------------------------------------------------------------------------------- /demo/src/components/ScoreVisualizer/ParamMonitor.tsx: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) 2018-Present, Spotify AB. 3 | * 4 | * Licensed to the Apache Software Foundation (ASF) under one 5 | * or more contributor license agreements. See the NOTICE file 6 | * distributed with this work for additional information 7 | * regarding copyright ownership. The ASF licenses this file 8 | * to you under the Apache License, Version 2.0 (the 9 | * "License"); you may not use this file except in compliance 10 | * with the License. You may obtain a copy of the License at 11 | * 12 | * http://www.apache.org/licenses/LICENSE-2.0 13 | * 14 | * Unless required by applicable law or agreed to in writing, 15 | * software distributed under the License is distributed on an 16 | * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY 17 | * KIND, either express or implied. See the License for the 18 | * specific language governing permissions and limitations 19 | * under the License. 20 | */ 21 | 22 | import * as React from 'react'; 23 | import { TimeInstant, ScoreAudioParam } from '../../../../src/'; 24 | import styled from 'styled-components'; 25 | import { TypedNode, Command } from 'nf-grapher'; 26 | import { CanvasPowered } from './CanvasPowered'; 27 | import { DemoTheme } from '../Theme'; 28 | 29 | const StyledParamMonitor = styled.div``; 30 | 31 | const StyledParamName = styled.div` 32 | padding-bottom: ${DemoTheme.unitPx}; 33 | `; 34 | 35 | type ParamDescription = { 36 | first: TimeInstant; 37 | last: TimeInstant; 38 | firstDefined: TimeInstant; 39 | values: number[]; 40 | }; 41 | 42 | type Props = { 43 | name: string; 44 | desc: ParamDescription; 45 | currentTime: TimeInstant; 46 | }; 47 | 48 | export class ParamMonitor extends React.Component { 49 | static fromNode(node: TypedNode, currentTime: TimeInstant) { 50 | let meters: JSX.Element[] = []; 51 | 52 | const n = node.toNode(); 53 | const params = n.params!; 54 | Object.keys(params).forEach(name => { 55 | const commands = params[name]; 56 | 57 | meters.push( 58 | 64 | ); 65 | }); 66 | 67 | return meters; 68 | } 69 | 70 | static ExtractDescription( 71 | paramCommands: Command[], 72 | timeStep = TimeInstant.fromSeconds(8192 / 44100) 73 | ): ParamDescription { 74 | const p1 = new ScoreAudioParam(1); 75 | for (let i = 0; i < paramCommands.length; i++) { 76 | p1.applyScoreCommand(paramCommands[i]); 77 | } 78 | 79 | if (!p1.events.length) { 80 | return { 81 | first: TimeInstant.ZERO, 82 | last: TimeInstant.ZERO, 83 | firstDefined: TimeInstant.ZERO, 84 | // TODO: put actual default value here? 85 | values: [] 86 | }; 87 | } 88 | 89 | const firstDefined = TimeInstant.fromSeconds(p1.events[0].time); 90 | const first = TimeInstant.ZERO; 91 | const last = TimeInstant.fromSeconds(p1.events[p1.events.length - 1].time); 92 | const duration = last.sub(first); 93 | const ts = timeStep.asSeconds(); 94 | 95 | if (ts === 0) { 96 | throw new Error('Invalid time step requested for AudioParam extraction'); 97 | } 98 | 99 | const valuesRequired = duration.eq(TimeInstant.ZERO) 100 | ? 1 101 | : Math.floor(duration.div(timeStep)); 102 | const values = []; 103 | let time = 0; 104 | 105 | while (values.length < valuesRequired) { 106 | values.push(p1.getValueAtTime(time)); 107 | time += ts; 108 | } 109 | 110 | return { 111 | first, 112 | last, 113 | firstDefined, 114 | values 115 | }; 116 | } 117 | 118 | drawParamValues = (cvs: HTMLCanvasElement, ctx: CanvasRenderingContext2D) => { 119 | const { first, last, values } = this.props.desc; 120 | const { currentTime } = this.props; 121 | 122 | const height2 = cvs.height / 2; 123 | const min = Math.min(...values); // might have a problem with arg length 124 | const max = Math.max(...values); 125 | let spread = max - min; 126 | if (spread === 0) { 127 | spread = 1; // maybe??? 128 | } 129 | 130 | ctx.clearRect(0, 0, cvs.width, cvs.height); 131 | 132 | ctx.strokeStyle = 'yellow'; 133 | ctx.beginPath(); 134 | ctx.moveTo(0, height2); 135 | 136 | let lastY = height2; 137 | 138 | if (!values.length) { 139 | ctx.lineTo(cvs.width - 1, height2); 140 | } else { 141 | const valueHorizontalInterval = cvs.width / values.length; 142 | 143 | // Draw the values 144 | for (let i = 0; i < values.length; i++) { 145 | // figure out horizontal 146 | const x = valueHorizontalInterval * i; 147 | const y = height2 - ((values[i] - min) / spread) * height2; 148 | lastY = y; 149 | 150 | if (i === 0) { 151 | // Avoid drawing a line from the start point. 152 | ctx.moveTo(x, y); 153 | } else { 154 | ctx.lineTo(x, y); 155 | } 156 | } 157 | 158 | // Always finish the line. 159 | ctx.lineTo(cvs.width, lastY); 160 | } 161 | 162 | ctx.stroke(); 163 | 164 | ctx.fillStyle = 'magenta'; 165 | const playheadWidth = 2; 166 | 167 | if (currentTime.gte(last)) { 168 | // draw progress at the end 169 | ctx.fillRect(cvs.width - playheadWidth, 0, playheadWidth, cvs.height); 170 | } else if (currentTime.lte(first)) { 171 | // draw progress at the beginning 172 | ctx.fillRect(0, 0, playheadWidth, cvs.height); 173 | } else { 174 | // draw in the middle... 175 | const duration = last.sub(first); 176 | const progress = currentTime.sub(first).div(duration); 177 | ctx.fillRect( 178 | progress * (cvs.width - playheadWidth), 179 | 0, 180 | playheadWidth, 181 | cvs.height 182 | ); 183 | } 184 | }; 185 | 186 | render() { 187 | const { first, last, values } = this.props.desc; 188 | const { currentTime } = this.props; 189 | const duration = last.sub(first); 190 | 191 | // Really this is just the time until the last defined event. 192 | const progress = 193 | duration.asNanos() !== 0 194 | ? Math.min(currentTime.sub(first).div(duration), 1) 195 | : 0; 196 | 197 | // TODO: really need a default value... 198 | // Find the nearest computed value to the current time. 199 | const index = Math.floor( 200 | Math.min(Math.max(progress, 0), 1) * (values.length - 1) 201 | ); 202 | const value = values.length ? values[index] : 1; 203 | 204 | const hover = 205 | 'YELLOW: The value of the Param over time, normalized to ' + 206 | "the maximum and minimum values found in the Param's command list. \n" + 207 | 'MAGENTA: Current time (playhead) for this Param. When the playhead ' + 208 | 'reaches the end, that is only the last specified value for the Param: ' + 209 | 'that value is still applied to any audio passing through.'; 210 | 211 | return ( 212 | 213 | 214 | {this.props.name} {value.toFixed(3)} 215 | 216 | 217 | {this.drawParamValues} 218 | 219 | 220 | ); 221 | } 222 | } 223 | -------------------------------------------------------------------------------- /demo/src/components/ScoreVisualizer/SourceMonitor.tsx: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) 2018-Present, Spotify AB. 3 | * 4 | * Licensed to the Apache Software Foundation (ASF) under one 5 | * or more contributor license agreements. See the NOTICE file 6 | * distributed with this work for additional information 7 | * regarding copyright ownership. The ASF licenses this file 8 | * to you under the Apache License, Version 2.0 (the 9 | * "License"); you may not use this file except in compliance 10 | * with the License. You may obtain a copy of the License at 11 | * 12 | * http://www.apache.org/licenses/LICENSE-2.0 13 | * 14 | * Unless required by applicable law or agreed to in writing, 15 | * software distributed under the License is distributed on an 16 | * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY 17 | * KIND, either express or implied. See the License for the 18 | * specific language governing permissions and limitations 19 | * under the License. 20 | */ 21 | 22 | import * as React from 'react'; 23 | import { NodePlaybackDescription } from '../../../../src/'; 24 | import { TypedNode, FileNode, LoopNode } from 'nf-grapher'; 25 | import { FileNodeMonitor } from './FileNodeMonitor'; 26 | import { LoopNodeMonitor } from './LoopNodeMonitor'; 27 | 28 | export class SourceMonitor { 29 | static fromNode(node: TypedNode, description: NodePlaybackDescription) { 30 | switch (node.kind) { 31 | case FileNode.PLUGIN_KIND: { 32 | const n = node as FileNode; 33 | return ( 34 | 35 | ); 36 | } 37 | 38 | case LoopNode.PLUGIN_KIND: { 39 | const n = node as LoopNode; 40 | return ( 41 | 42 | ); 43 | } 44 | 45 | default: { 46 | return
; 47 | } 48 | } 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /demo/src/components/ScoreVisualizer/VisualGridColumn.tsx: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) 2018-Present, Spotify AB. 3 | * 4 | * Licensed to the Apache Software Foundation (ASF) under one 5 | * or more contributor license agreements. See the NOTICE file 6 | * distributed with this work for additional information 7 | * regarding copyright ownership. The ASF licenses this file 8 | * to you under the Apache License, Version 2.0 (the 9 | * "License"); you may not use this file except in compliance 10 | * with the License. You may obtain a copy of the License at 11 | * 12 | * http://www.apache.org/licenses/LICENSE-2.0 13 | * 14 | * Unless required by applicable law or agreed to in writing, 15 | * software distributed under the License is distributed on an 16 | * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY 17 | * KIND, either express or implied. See the License for the 18 | * specific language governing permissions and limitations 19 | * under the License. 20 | */ 21 | 22 | import * as React from 'react'; 23 | import { NodePlaybackDescription } from '../../../../src/'; 24 | import { TypedNode } from 'nf-grapher'; 25 | import { NodePanel } from './NodePanel'; 26 | 27 | type Props = { 28 | descriptions: NodePlaybackDescription[]; 29 | nodes: TypedNode[]; 30 | kind: string; 31 | }; 32 | 33 | export const VisualGridColumn: React.SFC = ({ 34 | descriptions, 35 | nodes, 36 | kind 37 | }) => { 38 | const descs = descriptions.filter(desc => desc.kind === kind); 39 | return ( 40 |
41 | {descs.map(desc => { 42 | const node = nodes.find(node => node.id === desc.id); 43 | if (!node) return; 44 | return ( 45 | 46 | ); 47 | })} 48 |
49 | ); 50 | }; 51 | -------------------------------------------------------------------------------- /demo/src/components/ScoreVisualizer/index.tsx: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) 2018-Present, Spotify AB. 3 | * 4 | * Licensed to the Apache Software Foundation (ASF) under one 5 | * or more contributor license agreements. See the NOTICE file 6 | * distributed with this work for additional information 7 | * regarding copyright ownership. The ASF licenses this file 8 | * to you under the Apache License, Version 2.0 (the 9 | * "License"); you may not use this file except in compliance 10 | * with the License. You may obtain a copy of the License at 11 | * 12 | * http://www.apache.org/licenses/LICENSE-2.0 13 | * 14 | * Unless required by applicable law or agreed to in writing, 15 | * software distributed under the License is distributed on an 16 | * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY 17 | * KIND, either express or implied. See the License for the 18 | * specific language governing permissions and limitations 19 | * under the License. 20 | */ 21 | 22 | import * as React from 'react'; 23 | import { 24 | FileNode, 25 | LoopNode, 26 | StretchNode, 27 | GainNode as GGainNode, 28 | TypedNode 29 | } from 'nf-grapher'; 30 | import styled from 'styled-components'; 31 | import { NodePlaybackDescription } from '../../../../src'; 32 | import { VisualGridColumn } from './VisualGridColumn'; 33 | 34 | const KIND_COLUMN_ORDER = [ 35 | FileNode.PLUGIN_KIND, 36 | LoopNode.PLUGIN_KIND, 37 | StretchNode.PLUGIN_KIND, 38 | GGainNode.PLUGIN_KIND 39 | ]; 40 | 41 | const AppMainArea = styled.div` 42 | display: flex; 43 | justify-content: space-between; 44 | `; 45 | 46 | const AppColumn = styled.div` 47 | flex: 1; 48 | max-width: ${100 / (KIND_COLUMN_ORDER.length + 1)}vw; 49 | `; 50 | 51 | type Props = { 52 | descriptions: NodePlaybackDescription[]; 53 | nodes: TypedNode[]; 54 | }; 55 | 56 | export class ScoreVisualizer extends React.Component { 57 | render() { 58 | const { descriptions, nodes } = this.props; 59 | return ( 60 | 61 | {KIND_COLUMN_ORDER.map(kind => ( 62 | 63 | 68 | 69 | ))} 70 | 71 | ); 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /demo/src/components/Theme.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) 2018-Present, Spotify AB. 3 | * 4 | * Licensed to the Apache Software Foundation (ASF) under one 5 | * or more contributor license agreements. See the NOTICE file 6 | * distributed with this work for additional information 7 | * regarding copyright ownership. The ASF licenses this file 8 | * to you under the Apache License, Version 2.0 (the 9 | * "License"); you may not use this file except in compliance 10 | * with the License. You may obtain a copy of the License at 11 | * 12 | * http://www.apache.org/licenses/LICENSE-2.0 13 | * 14 | * Unless required by applicable law or agreed to in writing, 15 | * software distributed under the License is distributed on an 16 | * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY 17 | * KIND, either express or implied. See the License for the 18 | * specific language governing permissions and limitations 19 | * under the License. 20 | */ 21 | 22 | export const DemoTheme = { 23 | unit: 8, 24 | unitPx: '8px', 25 | playheadColor: 'magenta', 26 | bodyFontSize: '12px', 27 | fontFamily: "'Roboto Mono', monospace" 28 | }; 29 | 30 | export type DemoTheme = typeof DemoTheme; 31 | -------------------------------------------------------------------------------- /demo/src/components/VerticalLayout.tsx: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) 2018-Present, Spotify AB. 3 | * 4 | * Licensed to the Apache Software Foundation (ASF) under one 5 | * or more contributor license agreements. See the NOTICE file 6 | * distributed with this work for additional information 7 | * regarding copyright ownership. The ASF licenses this file 8 | * to you under the Apache License, Version 2.0 (the 9 | * "License"); you may not use this file except in compliance 10 | * with the License. You may obtain a copy of the License at 11 | * 12 | * http://www.apache.org/licenses/LICENSE-2.0 13 | * 14 | * Unless required by applicable law or agreed to in writing, 15 | * software distributed under the License is distributed on an 16 | * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY 17 | * KIND, either express or implied. See the License for the 18 | * specific language governing permissions and limitations 19 | * under the License. 20 | */ 21 | 22 | import styled from 'styled-components'; 23 | 24 | export const VerticalFitArea = styled.div` 25 | display: flex; 26 | flex-direction: column; 27 | height: 100%; 28 | `; 29 | 30 | export const VerticalFixedSection = styled.div` 31 | min-height: 24px; 32 | `; 33 | 34 | export const VerticalExpandableSection = styled.div` 35 | flex: 1; 36 | overflow-y: auto; 37 | overflow-x: hidden; 38 | `; 39 | -------------------------------------------------------------------------------- /demo/src/components/WaveVisualizer/FrequencyMonitor.tsx: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) 2018-Present, Spotify AB. 3 | * 4 | * Licensed to the Apache Software Foundation (ASF) under one 5 | * or more contributor license agreements. See the NOTICE file 6 | * distributed with this work for additional information 7 | * regarding copyright ownership. The ASF licenses this file 8 | * to you under the Apache License, Version 2.0 (the 9 | * "License"); you may not use this file except in compliance 10 | * with the License. You may obtain a copy of the License at 11 | * 12 | * http://www.apache.org/licenses/LICENSE-2.0 13 | * 14 | * Unless required by applicable law or agreed to in writing, 15 | * software distributed under the License is distributed on an 16 | * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY 17 | * KIND, either express or implied. See the License for the 18 | * specific language governing permissions and limitations 19 | * under the License. 20 | */ 21 | 22 | import * as React from 'react'; 23 | import { CanvasPowered } from '../ScoreVisualizer/CanvasPowered'; 24 | 25 | type Props = { 26 | frequencies: Uint8Array; 27 | }; 28 | 29 | // Frequency values are 8-bit unsigned integers, per AnalyserNode.getByteFrequencyData(); 30 | const scalingDenominator = 1 / 255; // denominator is maximum value, 2^8 -1 31 | const barColor = '#000000'; 32 | 33 | export class FrequencyMonitor extends React.Component { 34 | drawParamValues = (cvs: HTMLCanvasElement, ctx: CanvasRenderingContext2D) => { 35 | const { frequencies } = this.props; 36 | 37 | ctx.clearRect(0, 0, cvs.width, cvs.height); 38 | const canvasHeight = cvs.height; 39 | const canvasWidth = cvs.width; 40 | const binCount = frequencies.length; 41 | const barWidth = canvasWidth / binCount; 42 | ctx.fillStyle = barColor; 43 | 44 | for (let i = 0; i < binCount; i++) { 45 | const percent = frequencies[i] * scalingDenominator; 46 | const height = canvasHeight * percent; 47 | const offset = canvasHeight - height - 1; 48 | ctx.fillRect(i * barWidth, offset, barWidth, height); 49 | } 50 | }; 51 | 52 | render() { 53 | return ( 54 | 55 | {this.drawParamValues} 56 | 57 | ); 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /demo/src/components/WaveVisualizer/WaveVisualizer.tsx: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) 2018-Present, Spotify AB. 3 | * 4 | * Licensed to the Apache Software Foundation (ASF) under one 5 | * or more contributor license agreements. See the NOTICE file 6 | * distributed with this work for additional information 7 | * regarding copyright ownership. The ASF licenses this file 8 | * to you under the Apache License, Version 2.0 (the 9 | * "License"); you may not use this file except in compliance 10 | * with the License. You may obtain a copy of the License at 11 | * 12 | * http://www.apache.org/licenses/LICENSE-2.0 13 | * 14 | * Unless required by applicable law or agreed to in writing, 15 | * software distributed under the License is distributed on an 16 | * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY 17 | * KIND, either express or implied. See the License for the 18 | * specific language governing permissions and limitations 19 | * under the License. 20 | */ 21 | 22 | import * as React from 'react'; 23 | import { SmartPlayer, TimeInstant } from '../../../../src'; 24 | import { examples, ExampleJSON } from '../../ExampleJSONScores'; 25 | 26 | import { MonacoEditor } from '../Monaco'; 27 | import { PlayerControlBar } from '../PlayerControlBar'; 28 | import { PlayerWatcher } from '../PlayerWatcher'; 29 | import { Score } from 'nf-grapher'; 30 | import { FrequencyMonitor } from './FrequencyMonitor'; 31 | import { 32 | VerticalFitArea, 33 | VerticalFixedSection, 34 | VerticalExpandableSection 35 | } from '../VerticalLayout'; 36 | 37 | type Props = { 38 | player: SmartPlayer; 39 | analyser: AnalyserNode; 40 | }; 41 | 42 | const initialState = { 43 | example: examples[0], 44 | loading: false 45 | }; 46 | 47 | type State = Readonly<{ 48 | example: undefined | ExampleJSON; 49 | loading: boolean; 50 | }>; 51 | 52 | // Based on JSONEditor/JSONEditor 53 | export class WaveVisualizer extends React.Component { 54 | readonly state = initialState; 55 | 56 | private getEditorValue: () => string = () => ''; 57 | 58 | onExampleSelect = async (event: React.ChangeEvent) => { 59 | const example = examples.find( 60 | example => example.name === event.target.value 61 | ); 62 | 63 | this.setState({ 64 | example: example 65 | }); 66 | 67 | const { player } = this.props; 68 | 69 | if (player.playing) { 70 | player.playing = false; 71 | } 72 | }; 73 | 74 | handlePlayPause = async () => { 75 | const { player } = this.props; 76 | 77 | if (player.playing) { 78 | player.playing = false; 79 | return; 80 | } 81 | 82 | try { 83 | const editorValue: Score = JSON.parse(this.getEditorValue()); 84 | const playerValue: Score[] = JSON.parse(player.getJson()); 85 | 86 | const editorJSON = JSON.stringify(editorValue); 87 | const playerJSON = JSON.stringify(playerValue[0]); 88 | const exampleJSON = JSON.stringify(this.state.example.score); 89 | 90 | // We only want to reload the graph if the Score is actually different. 91 | if (!playerValue.length || editorJSON !== playerJSON) { 92 | // The editor value is the source of true, send it into the player. 93 | this.setState({ loading: true }); 94 | await player.setJson(editorJSON); 95 | 96 | // Only set the internal state to "user edited" if the current JSON is 97 | // likely user-edited. 98 | if (editorJSON !== exampleJSON) { 99 | this.setState({ 100 | example: { 101 | name: 'User edited', 102 | score: editorValue 103 | } 104 | }); 105 | } 106 | 107 | this.setState({ loading: false }); 108 | } 109 | } catch (e) {} 110 | 111 | player.playing = !player.playing; 112 | }; 113 | 114 | render() { 115 | const { player, analyser } = this.props; 116 | const { example, loading } = this.state; 117 | 118 | // A buffer for analyser to place frequency values 119 | const frequencies = new Uint8Array(analyser.frequencyBinCount); 120 | return ( 121 | 122 | 123 | 136 | 137 | {(currentTime, playing) => ( 138 | { 144 | const total = player.renderTime.add(amount); 145 | player.renderTime = total.lt(TimeInstant.ZERO) 146 | ? TimeInstant.ZERO 147 | : total; 148 | }} 149 | /> 150 | )} 151 | 152 | 153 | 154 | 155 | {(_, playing) => { 156 | analyser.getByteFrequencyData(frequencies); 157 | return playing ? ( 158 | 159 | ) : ( 160 | (this.getEditorValue = getValue)} 166 | /> 167 | ); 168 | }} 169 | 170 | 171 | 172 | ); 173 | } 174 | } 175 | -------------------------------------------------------------------------------- /demo/src/declarations.d.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) 2018-Present, Spotify AB. 3 | * 4 | * Licensed to the Apache Software Foundation (ASF) under one 5 | * or more contributor license agreements. See the NOTICE file 6 | * distributed with this work for additional information 7 | * regarding copyright ownership. The ASF licenses this file 8 | * to you under the Apache License, Version 2.0 (the 9 | * "License"); you may not use this file except in compliance 10 | * with the License. You may obtain a copy of the License at 11 | * 12 | * http://www.apache.org/licenses/LICENSE-2.0 13 | * 14 | * Unless required by applicable law or agreed to in writing, 15 | * software distributed under the License is distributed on an 16 | * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY 17 | * KIND, either express or implied. See the License for the 18 | * specific language governing permissions and limitations 19 | * under the License. 20 | */ 21 | 22 | declare module '*.wav'; 23 | -------------------------------------------------------------------------------- /demo/src/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Native Format Smart Player, JS Edition 7 | 8 | 9 | 10 | 17 | 18 | 19 |
20 | 21 | 22 | -------------------------------------------------------------------------------- /demo/src/index.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) 2018-Present, Spotify AB. 3 | * 4 | * Licensed to the Apache Software Foundation (ASF) under one 5 | * or more contributor license agreements. See the NOTICE file 6 | * distributed with this work for additional information 7 | * regarding copyright ownership. The ASF licenses this file 8 | * to you under the Apache License, Version 2.0 (the 9 | * "License"); you may not use this file except in compliance 10 | * with the License. You may obtain a copy of the License at 11 | * 12 | * http://www.apache.org/licenses/LICENSE-2.0 13 | * 14 | * Unless required by applicable law or agreed to in writing, 15 | * software distributed under the License is distributed on an 16 | * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY 17 | * KIND, either express or implied. See the License for the 18 | * specific language governing permissions and limitations 19 | * under the License. 20 | */ 21 | 22 | import * as React from 'react'; 23 | import { render } from 'react-dom'; 24 | import { App } from './components/App'; 25 | 26 | const root = document.getElementById('react-root'); 27 | root!.style.height = '100%'; 28 | render(React.createElement(App), root); 29 | -------------------------------------------------------------------------------- /demo/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "strict": true, 4 | "module": "commonjs", 5 | "target": "ES6", 6 | "jsx": "react", 7 | "resolveJsonModule": true 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /fixtures/TNG-Crysknife007-16-899-s.wav: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nativeformat/NFPlayerJS/a03fc221298301aa6b67bcbbb931b0dcb79d2397/fixtures/TNG-Crysknife007-16-899-s.wav -------------------------------------------------------------------------------- /fixtures/TNG-Infinite-Idle-Engine.json: -------------------------------------------------------------------------------- 1 | { 2 | "graph": { 3 | "id": "240c4146-2308-485b-8139-66e3d7f9f3b6", 4 | "loadingPolicy": "allContentPlaythrough", 5 | "nodes": [ 6 | { 7 | "id": "93973b2f-b19d-4907-9afe-fd04532a4f07", 8 | "kind": "com.nativeformat.plugin.file.file", 9 | "config": { 10 | "file": "/TNG-Crysknife007-16-899-s.91e05b63.wav", 11 | "when": 0, 12 | "duration": 16899000000, 13 | "offset": 0 14 | }, 15 | "params": {} 16 | }, 17 | { 18 | "id": "574abbed-67b4-4676-a8c0-93ba770a6a2e", 19 | "kind": "com.nativeformat.plugin.file.file", 20 | "config": { 21 | "file": "/TNG-Crysknife007-16-899-s.91e05b63.wav", 22 | "when": 16299000000, 23 | "duration": 16899000000, 24 | "offset": 0 25 | }, 26 | "params": {} 27 | }, 28 | { 29 | "id": "f1843726-05bf-4057-acb0-e9206dfed176", 30 | "kind": "com.nativeformat.plugin.waa.gain", 31 | "config": {}, 32 | "params": { 33 | "gain": [ 34 | { 35 | "name": "setValueAtTime", 36 | "args": { 37 | "value": 0, 38 | "startTime": 0 39 | } 40 | }, 41 | { 42 | "name": "setValueAtTime", 43 | "args": { 44 | "value": 0.0001, 45 | "startTime": 0.0001 46 | } 47 | }, 48 | { 49 | "name": "exponentialRampToValueAtTime", 50 | "args": { 51 | "value": 1, 52 | "endTime": 300000000 53 | } 54 | }, 55 | { 56 | "name": "setValueAtTime", 57 | "args": { 58 | "value": 1, 59 | "startTime": 16599000000 60 | } 61 | }, 62 | { 63 | "name": "exponentialRampToValueAtTime", 64 | "args": { 65 | "value": 0.0001, 66 | "endTime": 16899000000 67 | } 68 | } 69 | ] 70 | } 71 | }, 72 | { 73 | "id": "1040dc92-a0d3-4eec-aa8b-c095908cce40", 74 | "kind": "com.nativeformat.plugin.waa.gain", 75 | "config": {}, 76 | "params": { 77 | "gain": [ 78 | { 79 | "name": "setValueAtTime", 80 | "args": { 81 | "value": 0, 82 | "startTime": 0 83 | } 84 | }, 85 | { 86 | "name": "setValueAtTime", 87 | "args": { 88 | "value": 0.0001, 89 | "startTime": 16299000000 90 | } 91 | }, 92 | { 93 | "name": "exponentialRampToValueAtTime", 94 | "args": { 95 | "value": 1, 96 | "endTime": 16599000000 97 | } 98 | }, 99 | { 100 | "name": "setValueAtTime", 101 | "args": { 102 | "value": 1, 103 | "startTime": 32898000000 104 | } 105 | }, 106 | { 107 | "name": "exponentialRampToValueAtTime", 108 | "args": { 109 | "value": 0.0001, 110 | "endTime": 33198000000 111 | } 112 | } 113 | ] 114 | } 115 | }, 116 | { 117 | "id": "18b01832-6c33-402e-a0c0-e216b869533f", 118 | "kind": "com.nativeformat.plugin.time.loop", 119 | "config": { 120 | "when": 0, 121 | "duration": 32598000000, 122 | "loopCount": -1 123 | }, 124 | "params": {} 125 | }, 126 | { 127 | "id": "e83e8c62-8419-425f-b8d1-857b2e1c926c", 128 | "kind": "com.nativeformat.plugin.time.loop", 129 | "config": { 130 | "when": 600000000, 131 | "duration": 32598000000, 132 | "loopCount": -1 133 | }, 134 | "params": {} 135 | } 136 | ], 137 | "edges": [ 138 | { 139 | "id": "42076344-b4c4-4399-b79f-f1f860339d46", 140 | "source": "93973b2f-b19d-4907-9afe-fd04532a4f07", 141 | "target": "f1843726-05bf-4057-acb0-e9206dfed176" 142 | }, 143 | { 144 | "id": "59079a39-1fb5-43a5-adee-c96b560fd4da", 145 | "source": "574abbed-67b4-4676-a8c0-93ba770a6a2e", 146 | "target": "1040dc92-a0d3-4eec-aa8b-c095908cce40" 147 | }, 148 | { 149 | "id": "8c33d997-f690-4d7c-a534-2057792cca70", 150 | "source": "f1843726-05bf-4057-acb0-e9206dfed176", 151 | "target": "18b01832-6c33-402e-a0c0-e216b869533f" 152 | }, 153 | { 154 | "id": "afbcbc83-8577-46df-8687-f497e558b273", 155 | "source": "1040dc92-a0d3-4eec-aa8b-c095908cce40", 156 | "target": "e83e8c62-8419-425f-b8d1-857b2e1c926c" 157 | } 158 | ], 159 | "scripts": [] 160 | }, 161 | "version": "1.2.24" 162 | } 163 | -------------------------------------------------------------------------------- /fixtures/chirp_linear_5.50.wav: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nativeformat/NFPlayerJS/a03fc221298301aa6b67bcbbb931b0dcb79d2397/fixtures/chirp_linear_5.50.wav -------------------------------------------------------------------------------- /fixtures/ratatat-loop.json: -------------------------------------------------------------------------------- 1 | { 2 | "graph": { 3 | "id": "Smash_ID!!!_0", 4 | "loadingPolicy": "allContentPlaythrough", 5 | "nodes": [ 6 | { 7 | "id": "d1cea6ae-0f01-4eba-b158-5b9783ef5a43", 8 | "kind": "com.nativeformat.plugin.file.file", 9 | "config": { 10 | "file": "https://p.scdn.co/mp3-preview/aa9904ed0fd1f8e134f423148795b7e224c910ea?cid=774b29d4f13844c495f206cafdad9c86", 11 | "when": 0, 12 | "duration": 16622750000, 13 | "offset": 0 14 | }, 15 | "params": {} 16 | }, 17 | { 18 | "id": "9d8adbf0-1247-4c60-8921-a54a0c19a118", 19 | "kind": "com.nativeformat.plugin.time.loop", 20 | "config": { 21 | "when": 0, 22 | "duration": 16622750000, 23 | "loopCount": -1 24 | }, 25 | "params": {} 26 | }, 27 | { 28 | "id": "f8d728f3-ba60-4584-b8b6-d9aef32c2d31", 29 | "kind": "com.nativeformat.plugin.time.stretch", 30 | "config": {}, 31 | "params": { 32 | "pitchRatio": [], 33 | "stretch": [ 34 | { 35 | "name": "setValueAtTime", 36 | "args": { 37 | "value": 0.5, 38 | "startTime": 16622750000 39 | } 40 | } 41 | ], 42 | "formantRatio": [] 43 | } 44 | } 45 | ], 46 | "edges": [ 47 | { 48 | "id": "45ff2bda-e178-4a5e-b3de-92a7bff419f3", 49 | "source": "d1cea6ae-0f01-4eba-b158-5b9783ef5a43", 50 | "target": "9d8adbf0-1247-4c60-8921-a54a0c19a118" 51 | }, 52 | { 53 | "id": "f190478e-a2c7-4c5b-9f10-1e99b6fc1eb0", 54 | "source": "9d8adbf0-1247-4c60-8921-a54a0c19a118", 55 | "target": "f8d728f3-ba60-4584-b8b6-d9aef32c2d31" 56 | } 57 | ], 58 | "scripts": [] 59 | }, 60 | "version": "1.2.24" 61 | } -------------------------------------------------------------------------------- /fixtures/roxanne-30s-preview-shifted-infinite.json: -------------------------------------------------------------------------------- 1 | { 2 | "graph": { 3 | "id": "a6784e70-168d-4e3d-af49-7c6d33f81e37", 4 | "loadingPolicy": "allContentPlaythrough", 5 | "nodes": [ 6 | { 7 | "id": "3a01d0ae-ba6e-4c6b-b754-5642ec081500", 8 | "kind": "com.nativeformat.plugin.file.file", 9 | "config": { 10 | "file": "https://p.scdn.co/mp3-preview/6975d56d8fb372f33a9b6414899fa9c5bc4cb8e1?cid=774b29d4f13844c495f206cafdad9c86", 11 | "when": 0, 12 | "duration": 47315000000, 13 | "offset": 0 14 | }, 15 | "params": {} 16 | }, 17 | { 18 | "id": "e5809254-3aab-482b-b6d9-296dd17d8a5c", 19 | "kind": "com.nativeformat.plugin.time.stretch", 20 | "config": {}, 21 | "params": { 22 | "pitchRatio": [ 23 | { 24 | "name": "setValueAtTime", 25 | "args": { 26 | "value": 1, 27 | "startTime": 0 28 | } 29 | }, 30 | { 31 | "name": "setValueAtTime", 32 | "args": { 33 | "value": 1.0594630943592953, 34 | "startTime": 2574000000 35 | } 36 | }, 37 | { 38 | "name": "setValueAtTime", 39 | "args": { 40 | "value": 1.122462048309373, 41 | "startTime": 9554000000 42 | } 43 | }, 44 | { 45 | "name": "setValueAtTime", 46 | "args": { 47 | "value": 1.1892071150027212, 48 | "startTime": 13064000000 49 | } 50 | }, 51 | { 52 | "name": "setValueAtTime", 53 | "args": { 54 | "value": 1.2599210498948732, 55 | "startTime": 16535000000 56 | } 57 | }, 58 | { 59 | "name": "setValueAtTime", 60 | "args": { 61 | "value": 1.3348398541700344, 62 | "startTime": 20006000000 63 | } 64 | }, 65 | { 66 | "name": "setValueAtTime", 67 | "args": { 68 | "value": 1.4142135623730951, 69 | "startTime": 23476000000 70 | } 71 | } 72 | ], 73 | "stretch": [], 74 | "formantRatio": [] 75 | } 76 | }, 77 | { 78 | "id": "5b9ed8f6-4d26-4b3d-a5eb-57035abfc70c", 79 | "kind": "com.nativeformat.plugin.time.loop", 80 | "config": { 81 | "when": 9593000000, 82 | "duration": 17315000000, 83 | "loopCount": -1 84 | }, 85 | "params": {} 86 | } 87 | ], 88 | "edges": [ 89 | { 90 | "id": "83a72f5b-06e4-41be-9369-dee74d522928", 91 | "source": "3a01d0ae-ba6e-4c6b-b754-5642ec081500", 92 | "target": "e5809254-3aab-482b-b6d9-296dd17d8a5c" 93 | }, 94 | { 95 | "id": "1d9faf61-d2eb-49ed-93f5-821d26d43969", 96 | "source": "e5809254-3aab-482b-b6d9-296dd17d8a5c", 97 | "target": "5b9ed8f6-4d26-4b3d-a5eb-57035abfc70c" 98 | } 99 | ], 100 | "scripts": [] 101 | }, 102 | "version": "1.2.24" 103 | } 104 | -------------------------------------------------------------------------------- /fixtures/sine-440hz-1s.wav: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nativeformat/NFPlayerJS/a03fc221298301aa6b67bcbbb931b0dcb79d2397/fixtures/sine-440hz-1s.wav -------------------------------------------------------------------------------- /fixtures/sine-523.251hz-44079samples.wav: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nativeformat/NFPlayerJS/a03fc221298301aa6b67bcbbb931b0dcb79d2397/fixtures/sine-523.251hz-44079samples.wav -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | roots: ['/src', '/demo'], 3 | transform: { 4 | '^.+\\.tsx?$': 'ts-jest' 5 | }, 6 | testRegex: '(/__tests__/.*|(\\.|/)(test|spec))\\.tsx?$', 7 | moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json', 'node'], 8 | coverageReporters: ['json', 'text', 'lcov', 'cobertura'] 9 | // Someday :D 10 | // "coverageThreshold": { 11 | // "global": { 12 | // "branches": 60, 13 | // "functions": 60, 14 | // "lines": 60, 15 | // "statements": 60 16 | // } 17 | // } 18 | }; 19 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "nf-player", 3 | "version": "0.11.0", 4 | "description": "The Native Format Smart Player, JS Edition", 5 | "main": "dist/index.js", 6 | "types": "dist/index.d.ts", 7 | "files": [ 8 | "src", 9 | "dist" 10 | ], 11 | "bin": { 12 | "nf-player": "./dist/cli.js" 13 | }, 14 | "repository": { 15 | "type": "git", 16 | "url": "git@github.com:spotify/NFPlayerJS.git" 17 | }, 18 | "scripts": { 19 | "test": "jest", 20 | "clean": "rm -rf ./dist ./dist-debug .cache .cache-demo .cache-debug", 21 | "setup": "npm i && npm run build && cd ./demo && npm i", 22 | "setup:ci": "npm ci && npm run build && cd ./demo && npm ci", 23 | "start": "NODE_OPTIONS=--max-old-space-size=8196 run-p start:*", 24 | "start:demo": "parcel --out-dir demo/dist --cache-dir .cache-demo ./demo/src/index.html", 25 | "debug-harness": "parcel serve debug-harness/index.html --out-dir dist-debug --cache-dir .cache-debug", 26 | "build": "tsc -b", 27 | "prepublishOnly": "npm run build", 28 | "build:demo": "cd demo && NODE_OPTIONS=--max-old-space-size=8196 parcel build ./src/index.html --public-url ./", 29 | "docs:gen": "typedoc --tsconfig ./tsconfig.json --excludeNotExported --out ./demo/dist/docs src/ --exclude '**/*.test.ts' --mode file", 30 | "predeploy:demo": "npm run build && npm run build:demo && npm run docs:gen", 31 | "deploy:demo:manual": "npm run predeploy:demo && cd ./demo && gh-pages -d ./dist" 32 | }, 33 | "author": "Drew Petersen ", 34 | "license": "Apache-2.0", 35 | "dependencies": { 36 | "cross-fetch": "^3.0.4", 37 | "debug": "^4.1.1", 38 | "nf-grapher": "^1.2.24", 39 | "pseudo-audio-param": "^1.3.1", 40 | "soundtouch-ts": "^1.1.1", 41 | "tempy": "^0.5.0", 42 | "wav-decoder": "^1.3.0", 43 | "wav-encoder": "^1.3.0" 44 | }, 45 | "devDependencies": { 46 | "@commitlint/cli": "^8.3.5", 47 | "@commitlint/config-conventional": "^8.3.4", 48 | "@types/debug": "^4.1.5", 49 | "@types/jest": "^25.1.4", 50 | "@types/node": "^13.9.2", 51 | "@types/pseudo-audio-param": "^1.3.0", 52 | "@typescript-eslint/eslint-plugin": "^2.24.0", 53 | "@typescript-eslint/parser": "^2.24.0", 54 | "eslint": "^6.8.0", 55 | "eslint-config-prettier": "^6.10.0", 56 | "eslint-plugin-notice": "^0.8.9", 57 | "eslint-plugin-prettier": "^3.1.2", 58 | "gh-pages": "^2.2.0", 59 | "husky": "^4.2.3", 60 | "jest": "^25.1.0", 61 | "lint-staged": "^10.0.8", 62 | "npm-run-all": "^4.1.5", 63 | "parcel-bundler": "^1.12.4", 64 | "prettier": "^1.19.1", 65 | "ts-jest": "^25.2.1", 66 | "typedoc": "^0.17.1", 67 | "typescript": "^3.8.3", 68 | "web-audio-test-api": "^0.5.2" 69 | }, 70 | "husky": { 71 | "hooks": { 72 | "pre-commit": "lint-staged", 73 | "commit-msg": "[[ -n $HUSKY_BYPASS ]] || commitlint -E HUSKY_GIT_PARAMS" 74 | } 75 | }, 76 | "commitlint": { 77 | "extends": [ 78 | "@commitlint/config-conventional" 79 | ] 80 | }, 81 | "lint-staged": { 82 | "*.{js,json,css,md,ts,tsx}": [ 83 | "prettier --write" 84 | ], 85 | "*.{js,ts,tsx}": [ 86 | "eslint --fix" 87 | ] 88 | }, 89 | "//browserslist": "this is to prevent parcel from causing babel to transpile anything and injecting regenerator runtime", 90 | "browserslist": [ 91 | "chrome 69" 92 | ] 93 | } 94 | -------------------------------------------------------------------------------- /src/AudioBufferUtils.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) 2018-Present, Spotify AB. 3 | * 4 | * Licensed to the Apache Software Foundation (ASF) under one 5 | * or more contributor license agreements. See the NOTICE file 6 | * distributed with this work for additional information 7 | * regarding copyright ownership. The ASF licenses this file 8 | * to you under the Apache License, Version 2.0 (the 9 | * "License"); you may not use this file except in compliance 10 | * with the License. You may obtain a copy of the License at 11 | * 12 | * http://www.apache.org/licenses/LICENSE-2.0 13 | * 14 | * Unless required by applicable law or agreed to in writing, 15 | * software distributed under the License is distributed on an 16 | * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY 17 | * KIND, either express or implied. See the License for the 18 | * specific language governing permissions and limitations 19 | * under the License. 20 | */ 21 | 22 | import { XAudioBuffer } from './XAudioBuffer'; 23 | 24 | // TODO: allow buffers to be a single buffer to prevent extra array creations. 25 | export function mixdown( 26 | dest: XAudioBuffer, 27 | buffers: XAudioBuffer[] 28 | ): XAudioBuffer { 29 | if (buffers.length === 0) { 30 | return dest; 31 | } 32 | 33 | for (let i = 0; i < buffers.length; i++) { 34 | const buffer = buffers[i]; 35 | for (let c = 0; c < dest.numberOfChannels; c++) { 36 | // copy either the matching or first source channel to the destination 37 | // (this means a mono source will be converted to stereo) 38 | const sourceIdx = c < buffer.numberOfChannels ? c : 0; 39 | const sourceChan = buffer.getChannelData(sourceIdx); 40 | const destChan = dest.getChannelData(c); 41 | for (let s = 0; s < sourceChan.length; s++) { 42 | destChan[s] = destChan[s] + sourceChan[s]; 43 | } 44 | } 45 | } 46 | 47 | return dest; 48 | } 49 | 50 | export function mixdownToAudioBuffer( 51 | dest: AudioBuffer, 52 | buffers: XAudioBuffer[] 53 | ): AudioBuffer { 54 | return mixdown(dest as XAudioBuffer, buffers); 55 | } 56 | 57 | /** 58 | * Copy the src to the dst. If the src has fewer channels than the dst, the 59 | * last src channel is used. If the dst has fewer channels than the src, 60 | * the other src channels are not used. 61 | */ 62 | export function copy( 63 | dst: XAudioBuffer, 64 | src: XAudioBuffer, 65 | startInDestination: number, 66 | boundsCheck: boolean = true 67 | ) { 68 | if (boundsCheck && dst.length - startInDestination < src.length) { 69 | throw new Error( 70 | 'InvalidSourceSize: The Source must fit within destination.' 71 | ); 72 | } 73 | for (let i = 0; i < dst.numberOfChannels; i++) { 74 | if (i < src.numberOfChannels) { 75 | dst.copyToChannel(src.getChannelData(i), i, startInDestination); 76 | } else { 77 | dst.copyToChannel( 78 | src.getChannelData(src.numberOfChannels - 1), 79 | i, 80 | startInDestination 81 | ); 82 | } 83 | } 84 | return dst; 85 | } 86 | 87 | export function zeroOut(dest: XAudioBuffer) { 88 | // Mix each sample by averaging. 89 | for (let i = 0; i < dest.numberOfChannels; i++) { 90 | const destChan = dest.getChannelData(i); 91 | for (let s = 0; s < destChan.length; s++) { 92 | destChan[s] = 0; 93 | } 94 | } 95 | 96 | return dest; 97 | } 98 | 99 | export function asInterleaved(ab: XAudioBuffer): Float32Array { 100 | const channels = ab.numberOfChannels; 101 | 102 | const output = new Float32Array(channels * ab.length); 103 | 104 | for (let i = 0; i < ab.length; i++) { 105 | for (let c = 0; c < channels; c++) { 106 | const chan = ab.getChannelData(c); 107 | output[i * channels + c] = chan[i]; 108 | } 109 | } 110 | 111 | return output; 112 | } 113 | 114 | export function asPlanar( 115 | buffer: Float32Array, 116 | sampleRate: number, 117 | channels: number = 2 118 | ): XAudioBuffer { 119 | // const output = new Float32Array(buffer.length); 120 | const channelLength = Math.floor(buffer.length / channels); 121 | const output = new XAudioBuffer({ 122 | numberOfChannels: channels, 123 | length: channelLength, 124 | sampleRate: sampleRate 125 | }); 126 | 127 | for (let c = 0; c < channels; c++) { 128 | const chan = output.getChannelData(c); 129 | for (let i = 0; i < channelLength; i++) { 130 | chan[i] = buffer[i * channels + c]; 131 | // output[i + (c * channelLength)] = buffer[i * channels + c]; 132 | } 133 | } 134 | 135 | return output; 136 | } 137 | 138 | // Straight from the pseudo-audio-param package. 139 | function getTargetValueAtTime( 140 | t: number, 141 | v0: number, 142 | v1: number, 143 | t0: number, 144 | timeConstant: number 145 | ) { 146 | if (t <= t0) { 147 | return v0; 148 | } 149 | return v1 + (v0 - v1) * Math.exp((t0 - t) / timeConstant); 150 | } 151 | 152 | // How does targetValueAtTime timeConstant work? 153 | // https://stackoverflow.com/a/20617245/169491 154 | 155 | export function applyFadeIn(buffer: XAudioBuffer): XAudioBuffer { 156 | const t0 = 0; 157 | const v0 = 0; 158 | const v1 = 1; 159 | const constant = buffer.length / 10; 160 | 161 | for (let i = 0; i < buffer.numberOfChannels; i++) { 162 | const chan = buffer.getChannelData(i); 163 | for (let j = 0; j < chan.length; j++) { 164 | const v = getTargetValueAtTime(j, v0, v1, t0, constant); 165 | chan[j] = chan[j] * v; 166 | } 167 | } 168 | 169 | return buffer; 170 | } 171 | 172 | export function applyFadeOut(buffer: XAudioBuffer): XAudioBuffer { 173 | const t0 = 0; 174 | const v0 = 1; 175 | const v1 = 0; 176 | const constant = buffer.length / 4; 177 | 178 | for (let i = 0; i < buffer.numberOfChannels; i++) { 179 | const chan = buffer.getChannelData(i); 180 | for (let j = 0; j < chan.length; j++) { 181 | const v = getTargetValueAtTime(j, v0, v1, t0, constant); 182 | chan[j] = chan[j] * v; 183 | } 184 | } 185 | 186 | return buffer; 187 | } 188 | -------------------------------------------------------------------------------- /src/ContentCache.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) 2018-Present, Spotify AB. 3 | * 4 | * Licensed to the Apache Software Foundation (ASF) under one 5 | * or more contributor license agreements. See the NOTICE file 6 | * distributed with this work for additional information 7 | * regarding copyright ownership. The ASF licenses this file 8 | * to you under the Apache License, Version 2.0 (the 9 | * "License"); you may not use this file except in compliance 10 | * with the License. You may obtain a copy of the License at 11 | * 12 | * http://www.apache.org/licenses/LICENSE-2.0 13 | * 14 | * Unless required by applicable law or agreed to in writing, 15 | * software distributed under the License is distributed on an 16 | * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY 17 | * KIND, either express or implied. See the License for the 18 | * specific language governing permissions and limitations 19 | * under the License. 20 | */ 21 | 22 | import { debug as Debug } from 'debug'; 23 | import { XAudioBuffer } from './XAudioBuffer'; 24 | 25 | const DBG_STR = 'nf:content-cache'; 26 | const dbg = Debug(DBG_STR); 27 | 28 | type LoadingScore = { 29 | requests: Set>; 30 | graphId: string; 31 | loaded: Promise; 32 | signalLoaded: () => void; 33 | signalFailed: (err: Error) => void; 34 | }; 35 | 36 | export class ContentCache { 37 | constructor( 38 | // Shared cache of requests, to share resources across Scores. 39 | private requests = new Map>(), 40 | // The global cache of loaded/decoded audio. 41 | private audio = new Map(), 42 | // Per-score mapping of requests, so loading a single Score does not block 43 | // other scores, and we can know when a single Score is done. 44 | private scores = new Map() 45 | ) {} 46 | 47 | async get( 48 | uri: string, 49 | graphId: string, 50 | fetcher: () => Promise 51 | ): Promise { 52 | const existingAudio = this.audio.get(uri); 53 | if (existingAudio) return existingAudio; 54 | 55 | let lscore = this.scores.get(graphId); 56 | if (!lscore) { 57 | dbg('creating LoadingScore for graph %s', graphId); 58 | 59 | // This is super hacky, just to prevent typescript from complaining 60 | // and allow the promise controls to escape. 61 | let signalLoaded: () => void = () => {}; 62 | let signalFailed: (err: Error) => void = _ => {}; 63 | 64 | const p = new Promise((resolve, reject) => { 65 | signalLoaded = resolve; 66 | signalFailed = reject; 67 | }); 68 | 69 | lscore = { 70 | requests: new Set(), 71 | graphId, 72 | loaded: p, 73 | signalLoaded, 74 | signalFailed 75 | }; 76 | 77 | this.scores.set(graphId, lscore); 78 | } 79 | 80 | let request; 81 | 82 | const existingRequest = this.requests.get(uri); 83 | if (existingRequest) { 84 | request = existingRequest; 85 | } else { 86 | request = fetcher(); 87 | this.requests.set(uri, request); 88 | } 89 | 90 | lscore.requests.add(request); 91 | 92 | let ab; 93 | 94 | try { 95 | ab = await request; 96 | // Add to the globally-shared cache. 97 | this.audio.set(uri, ab); 98 | 99 | // Remove from requests pool 100 | this.requests.delete(uri); 101 | 102 | // Remove from score-specific pool 103 | lscore.requests.delete(request); 104 | 105 | // Check if this score is done. It would be nice if we could be more 106 | // sure than juse size 0. 107 | if (lscore.requests.size === 0) { 108 | dbg('resolving LoadingScore for graph %s', graphId); 109 | lscore.signalLoaded(); 110 | 111 | // And then remove the lscore, in case the graph is loaded again. 112 | this.scores.delete(graphId); 113 | } 114 | 115 | return ab; 116 | } catch (e) { 117 | // A single failed request fails the graph? I guess so for now. 118 | lscore.signalFailed(e); 119 | // Delete the lscore in case another attempt is made. 120 | this.scores.delete(graphId); 121 | throw e; 122 | } 123 | } 124 | 125 | scoreContentLoaded(graphId: string): Promise { 126 | const lscore = this.scores.get(graphId); 127 | // Can't find it? Assume all the content was already loaded... 128 | if (!lscore) return Promise.resolve(); 129 | return lscore.loaded; 130 | } 131 | } 132 | -------------------------------------------------------------------------------- /src/DirectedScore.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) 2018-Present, Spotify AB. 3 | * 4 | * Licensed to the Apache Software Foundation (ASF) under one 5 | * or more contributor license agreements. See the NOTICE file 6 | * distributed with this work for additional information 7 | * regarding copyright ownership. The ASF licenses this file 8 | * to you under the Apache License, Version 2.0 (the 9 | * "License"); you may not use this file except in compliance 10 | * with the License. You may obtain a copy of the License at 11 | * 12 | * http://www.apache.org/licenses/LICENSE-2.0 13 | * 14 | * Unless required by applicable law or agreed to in writing, 15 | * software distributed under the License is distributed on an 16 | * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY 17 | * KIND, either express or implied. See the License for the 18 | * specific language governing permissions and limitations 19 | * under the License. 20 | */ 21 | 22 | import { Edge, Score, Node, TypedNode, Graph } from 'nf-grapher'; 23 | 24 | // Serious Bug in Grapher: Score.Graph.Nodes is of type Node[], but _could_ be TypedNode[]! 25 | // Note: I recall that this is by design, but this should be changed. It makes using a Score 26 | // very difficult / runtime-error-prone. 27 | 28 | export class DirectedScore { 29 | constructor(private score: Score = new Score()) {} 30 | 31 | graphId() { 32 | return this.score.graph.id; 33 | } 34 | 35 | toJSON() { 36 | return this.score; 37 | } 38 | 39 | incomingEdges(id: string): Edge[]; 40 | incomingEdges(node: Node): Edge[]; 41 | incomingEdges(nodeOrId: string | Node) { 42 | const id = typeof nodeOrId === 'string' ? nodeOrId : nodeOrId.id; 43 | const edges = this.score.graph.edges.filter(edge => edge.target === id); 44 | return edges; 45 | } 46 | 47 | outgoingEdges(id: string): Edge[]; 48 | outgoingEdges(node: Node): Edge[]; 49 | outgoingEdges(nodeOrId: string | Node): Edge[] { 50 | const id = typeof nodeOrId === 'string' ? nodeOrId : nodeOrId.id; 51 | const edges = this.score.graph.edges.filter(edge => edge.source === id); 52 | return edges; 53 | } 54 | 55 | leaves(): Node[] { 56 | const leaves = []; 57 | for (let node of this.score.graph.nodes) { 58 | const edges = this.outgoingEdges(node); 59 | if (edges.length === 0) { 60 | leaves.push(node); 61 | } 62 | } 63 | return leaves; 64 | } 65 | 66 | source(edge: Edge): Node | undefined { 67 | const node = this.score.graph.nodes.find(n => edge.source === n.id); 68 | return node; 69 | } 70 | 71 | target(edge: Edge): Node | undefined { 72 | const node = this.score.graph.nodes.find(n => edge.target === n.id); 73 | return node; 74 | } 75 | 76 | byId(id: string): Node | undefined { 77 | const node = this.score.graph.nodes.find(n => n.id === id); 78 | return node; 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /src/Mutations.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) 2018-Present, Spotify AB. 3 | * 4 | * Licensed to the Apache Software Foundation (ASF) under one 5 | * or more contributor license agreements. See the NOTICE file 6 | * distributed with this work for additional information 7 | * regarding copyright ownership. The ASF licenses this file 8 | * to you under the Apache License, Version 2.0 (the 9 | * "License"); you may not use this file except in compliance 10 | * with the License. You may obtain a copy of the License at 11 | * 12 | * http://www.apache.org/licenses/LICENSE-2.0 13 | * 14 | * Unless required by applicable law or agreed to in writing, 15 | * software distributed under the License is distributed on an 16 | * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY 17 | * KIND, either express or implied. See the License for the 18 | * specific language governing permissions and limitations 19 | * under the License. 20 | */ 21 | 22 | import { Command } from 'nf-grapher'; 23 | import { ScoreAudioParam } from './params/ScoreAudioParam'; 24 | 25 | export type Mutation = PushCommandsMutation | ClearCommandsMutation; 26 | 27 | export enum MutationNames { 28 | PushCommands = 'PUSH_COMMANDS', 29 | ClearCommands = 'CLEAR_COMMANDS' 30 | } 31 | 32 | export type MutationBase = { 33 | name: MutationNames; 34 | }; 35 | 36 | export type PushCommandsMutation = { 37 | graphId?: string; 38 | nodeId: string; 39 | paramName: string; 40 | commands: Array; 41 | } & MutationBase; 42 | 43 | // removes ALL commands, you better follow up with a replacement! 44 | export type ClearCommandsMutation = { 45 | graphId?: string; 46 | nodeId: string; 47 | paramName: string; 48 | } & MutationBase; 49 | 50 | export type CommandsMutation = PushCommandsMutation | ClearCommandsMutation; 51 | 52 | export function applyMutationToParam( 53 | effect: Mutation, 54 | param: ScoreAudioParam, 55 | commands: Command[] 56 | ) { 57 | switch (effect.name) { 58 | case MutationNames.ClearCommands: { 59 | commands.length = 0; 60 | param.cancelScheduledValues(0); 61 | return Promise.resolve(); 62 | } 63 | 64 | case MutationNames.PushCommands: { 65 | // aka cast the effect to the specialized type, since we know via the 66 | // enum that it must be. 67 | const mutation = effect; 68 | commands.push(...mutation.commands); 69 | // Cancel everything, then reapply everything. 70 | param.cancelScheduledValues(0); 71 | param.applyScoreCommands(commands); 72 | return Promise.resolve(); 73 | } 74 | 75 | default: { 76 | const exhaustive: never = effect.name; 77 | return Promise.reject(); 78 | } 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /src/SmartPlayer.test.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) 2018-Present, Spotify AB. 3 | * 4 | * Licensed to the Apache Software Foundation (ASF) under one 5 | * or more contributor license agreements. See the NOTICE file 6 | * distributed with this work for additional information 7 | * regarding copyright ownership. The ASF licenses this file 8 | * to you under the Apache License, Version 2.0 (the 9 | * "License"); you may not use this file except in compliance 10 | * with the License. You may obtain a copy of the License at 11 | * 12 | * http://www.apache.org/licenses/LICENSE-2.0 13 | * 14 | * Unless required by applicable law or agreed to in writing, 15 | * software distributed under the License is distributed on an 16 | * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY 17 | * KIND, either express or implied. See the License for the 18 | * specific language governing permissions and limitations 19 | * under the License. 20 | */ 21 | 22 | import { SmartPlayer } from './SmartPlayer'; 23 | import { 24 | Score, 25 | StretchNode, 26 | GainNode as GGainNode, 27 | Graph, 28 | LoopNode 29 | } from 'nf-grapher'; 30 | import { SetValueAtTimeCmd } from './params/ScoreAudioParam'; 31 | import { TimeInstant } from './time'; 32 | import { 33 | Mutation, 34 | PushCommandsMutation, 35 | ClearCommandsMutation, 36 | MutationNames 37 | // RemoveNodesEffectPayload 38 | } from './Mutations'; 39 | 40 | // Shim the API for testing, globally. 41 | import 'web-audio-test-api'; 42 | import { TestAudioContext } from './test-utils/TestAudioContext'; 43 | import { ScriptProcessorRenderer } from './renderers/ScriptProcessorRenderer'; 44 | 45 | test('Apply PushCommandsEffectPayload Effect', async () => { 46 | const ctx = new AudioContext() as TestAudioContext; // actually the test shim. 47 | const renderer = new ScriptProcessorRenderer(ctx); 48 | const p = new SmartPlayer(renderer); 49 | p.playing = true; // not actually necessary 50 | 51 | const s = new Score(); 52 | const n = StretchNode.create(); 53 | 54 | s.graph.nodes.push(n); 55 | 56 | const c: SetValueAtTimeCmd = { 57 | name: 'setValueAtTime', 58 | args: { 59 | value: 1, 60 | startTime: TimeInstant.fromSeconds(1).asNanos() 61 | } 62 | }; 63 | 64 | const e: PushCommandsMutation = { 65 | name: MutationNames.PushCommands, 66 | nodeId: n.id, 67 | // TODO: make StretchNode.STRETCH_PARAM a public static property so these names 68 | // can be used! 69 | paramName: 'stretch', 70 | commands: [c] 71 | }; 72 | 73 | // Must await! Otherwise nodes will not be loaded. 74 | await p.setJson(JSON.stringify(s)); 75 | const result = p.enqueueMutation(e); 76 | 77 | ctx.$processTo('00:01.000'); 78 | 79 | await result; 80 | const uScore: Score = JSON.parse(p.getJson()).pop(); 81 | expect(uScore.graph.nodes[0].params!.stretch[0]).toEqual(c); 82 | }); 83 | 84 | test('Apply ClearCommandsEffectPayload Effect', async () => { 85 | const ctx = new AudioContext() as TestAudioContext; // actually the test shim. 86 | const renderer = new ScriptProcessorRenderer(ctx); 87 | const p = new SmartPlayer(renderer); 88 | p.playing = true; // not actually necessary 89 | 90 | const s = new Score(); 91 | const n = StretchNode.create(); 92 | n.stretch.setValueAtTime(1, TimeInstant.fromSeconds(1).asNanos()); 93 | s.graph.nodes.push(n); 94 | 95 | const e: ClearCommandsMutation = { 96 | name: MutationNames.ClearCommands, 97 | nodeId: n.id, 98 | paramName: 'stretch' 99 | }; 100 | 101 | // Must await! Otherwise nodes will not be loaded. 102 | await p.setJson(JSON.stringify(s)); 103 | const result = p.enqueueMutation(e); 104 | 105 | ctx.$processTo('00:01.000'); 106 | 107 | await result; 108 | const uScore: Score = Score.from(JSON.parse(p.getJson()).pop()); 109 | expect(n.stretch.getCommands()).toHaveLength(1); 110 | expect( 111 | (uScore.graph.nodes[0] as StretchNode).stretch.getCommands() 112 | ).toHaveLength(0); 113 | }); 114 | 115 | test('Enqueue/Dequeue a running Score', async () => { 116 | const ctx = new AudioContext() as TestAudioContext; // actually the test shim. 117 | const renderer = new ScriptProcessorRenderer(ctx); 118 | const p = new SmartPlayer(renderer); 119 | 120 | const s = new Score(); 121 | const n0 = GGainNode.create(); 122 | s.graph.nodes.push(n0); 123 | 124 | await p.setJson(JSON.stringify(s)); 125 | 126 | const prevId = s.graph.id; 127 | // "Copy" the graph, but generate a new ID 128 | s.graph = new Graph(undefined, undefined, s.graph.nodes, s.graph.edges); 129 | const n2 = LoopNode.create({ 130 | loopCount: -1, 131 | when: 0, 132 | duration: TimeInstant.fromSeconds(1).asNanos() 133 | }); 134 | const e1 = n0.connectToTarget(n2); 135 | s.graph.edges.push(e1); 136 | s.graph.nodes.push(n2); 137 | 138 | const enqueued = p.enqueueScore(s); 139 | const dequeued = p.dequeueScore(prevId); 140 | 141 | // Schedule the enqueue/dequeue, then process. Otherwise the internal state 142 | // will only be kicked once! And Dequeue will never resolve. 143 | 144 | ctx.$processTo('00:01.000'); 145 | 146 | await enqueued; 147 | await dequeued; 148 | 149 | const scores: Score[] = JSON.parse(p.getJson()); 150 | 151 | expect(scores).toHaveLength(1); 152 | expect(scores[0]).toEqual(JSON.parse(JSON.stringify(s))); 153 | }); 154 | 155 | test('Grapher adjusts malformed Scores', async () => { 156 | const ctx = new AudioContext() as TestAudioContext; // actually the test shim. 157 | const renderer = new ScriptProcessorRenderer(ctx); 158 | const p = new SmartPlayer(renderer); 159 | 160 | const malformed = JSON.stringify({ 161 | graph: { 162 | nodes: [GGainNode.create()] 163 | } 164 | }); 165 | 166 | const s = Score.from(JSON.parse(malformed)); 167 | 168 | await p.setJson(JSON.stringify(s)); 169 | expect(JSON.parse(p.getJson())).toHaveLength(1); 170 | }); 171 | -------------------------------------------------------------------------------- /src/SmartPlayer.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) 2018-Present, Spotify AB. 3 | * 4 | * Licensed to the Apache Software Foundation (ASF) under one 5 | * or more contributor license agreements. See the NOTICE file 6 | * distributed with this work for additional information 7 | * regarding copyright ownership. The ASF licenses this file 8 | * to you under the Apache License, Version 2.0 (the 9 | * "License"); you may not use this file except in compliance 10 | * with the License. You may obtain a copy of the License at 11 | * 12 | * http://www.apache.org/licenses/LICENSE-2.0 13 | * 14 | * Unless required by applicable law or agreed to in writing, 15 | * software distributed under the License is distributed on an 16 | * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY 17 | * KIND, either express or implied. See the License for the 18 | * specific language governing permissions and limitations 19 | * under the License. 20 | */ 21 | 22 | import { TimeInstant } from './time'; 23 | import { Score } from 'nf-grapher'; 24 | import { Mutation } from './Mutations'; 25 | import { NodePlaybackDescription } from './nodes/SPNodeFactory'; 26 | import { BaseRenderer } from './renderers/BaseRenderer'; 27 | import { ScriptProcessorRenderer } from './renderers/ScriptProcessorRenderer'; 28 | 29 | export { NodePlaybackDescription }; 30 | 31 | export class SmartPlayer { 32 | constructor(private renderer: BaseRenderer = new ScriptProcessorRenderer()) {} 33 | 34 | async setJson(json: string) { 35 | await this.renderer.dequeueScores(); 36 | await this.enqueueScore(json); 37 | if (this.renderTime.neq(TimeInstant.ZERO)) { 38 | // Only reset render time if something has already been started. 39 | // SetJSON is the user halting current ops and starting over. 40 | // The conditional is here because enqueueScore calls a local timeChange 41 | // using the current renderTime from the renderer. 42 | await this.renderer.timeChange(TimeInstant.ZERO); 43 | } 44 | } 45 | 46 | getJson(graphId?: string) { 47 | if (!graphId) { 48 | return JSON.stringify(this.renderer.getScores()); 49 | } 50 | const score = this.renderer.getScore(graphId); 51 | return JSON.stringify(score); 52 | } 53 | 54 | get playing() { 55 | return this.renderer.playing; 56 | } 57 | 58 | set playing(state: boolean) { 59 | this.renderer.playing = !!state; 60 | } 61 | 62 | get renderTime(): TimeInstant { 63 | return this.renderer.renderedTime(); 64 | } 65 | 66 | /** @deprecated */ 67 | set renderTime(time: TimeInstant) { 68 | this.renderer.timeChange(time); 69 | } 70 | 71 | /** 72 | * Set renderTime to `time`. Seeking / setting renderTime is actually 73 | * an asynchronous process since the internal graph must be destroyed and 74 | * rebuilt, and potentially new content loaded. `seek` allows a user to 75 | * await the operation, unlike set/get `renderTime`. 76 | */ 77 | seek(time: TimeInstant): Promise { 78 | return this.renderer.timeChange(time); 79 | } 80 | 81 | public getPlaybackDescription( 82 | renderTime: TimeInstant 83 | ): NodePlaybackDescription[] { 84 | return this.renderer.getPlaybackDescription(renderTime); 85 | } 86 | 87 | public async enqueueMutation(effect: Mutation) { 88 | return this.renderer.enqueueEffect(effect); 89 | } 90 | 91 | public enqueueScore(score: string, fadeIn?: boolean): Promise; 92 | public enqueueScore(score: Score, fadeIn?: boolean): Promise; 93 | public enqueueScore( 94 | scoreOrJSON: string | Score, 95 | fadeIn: boolean = true 96 | ): Promise { 97 | // Do not preparse into TypedNodes! They're too restrictive. 98 | // Score.from() creates TypedNodes. 99 | 100 | let score: Score; 101 | if (typeof scoreOrJSON === 'string') { 102 | score = JSON.parse(scoreOrJSON); 103 | } else { 104 | // It might have TypedNode(s) in it. So we must make them plain 105 | // by going to JSON and back. Could ducktype each node by checking 106 | // for various TypedNode-only properties, but that is much more error- 107 | // prone than JSON and back. 108 | score = JSON.parse(JSON.stringify(scoreOrJSON)); 109 | } 110 | 111 | return this.renderer.enqueueScore(score, fadeIn); 112 | } 113 | 114 | public dequeueScore(graphId: string, fadeOut: boolean = true) { 115 | return this.renderer.dequeueScore(graphId, fadeOut); 116 | } 117 | 118 | public dequeueScores() { 119 | return this.renderer.dequeueScores(); 120 | } 121 | } 122 | -------------------------------------------------------------------------------- /src/WebAudioContext.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) 2018-Present, Spotify AB. 3 | * 4 | * Licensed to the Apache Software Foundation (ASF) under one 5 | * or more contributor license agreements. See the NOTICE file 6 | * distributed with this work for additional information 7 | * regarding copyright ownership. The ASF licenses this file 8 | * to you under the Apache License, Version 2.0 (the 9 | * "License"); you may not use this file except in compliance 10 | * with the License. You may obtain a copy of the License at 11 | * 12 | * http://www.apache.org/licenses/LICENSE-2.0 13 | * 14 | * Unless required by applicable law or agreed to in writing, 15 | * software distributed under the License is distributed on an 16 | * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY 17 | * KIND, either express or implied. See the License for the 18 | * specific language governing permissions and limitations 19 | * under the License. 20 | */ 21 | 22 | // This is basically just for Safari at this point. 23 | export const XAudioContext = () => { 24 | if (typeof AudioContext !== 'undefined') { 25 | return new AudioContext(); 26 | } else { 27 | return new (window as any).webkitAudioContext() as AudioContext; 28 | } 29 | }; 30 | -------------------------------------------------------------------------------- /src/XAudioBuffer.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) 2018-Present, Spotify AB. 3 | * 4 | * Licensed to the Apache Software Foundation (ASF) under one 5 | * or more contributor license agreements. See the NOTICE file 6 | * distributed with this work for additional information 7 | * regarding copyright ownership. The ASF licenses this file 8 | * to you under the Apache License, Version 2.0 (the 9 | * "License"); you may not use this file except in compliance 10 | * with the License. You may obtain a copy of the License at 11 | * 12 | * http://www.apache.org/licenses/LICENSE-2.0 13 | * 14 | * Unless required by applicable law or agreed to in writing, 15 | * software distributed under the License is distributed on an 16 | * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY 17 | * KIND, either express or implied. See the License for the 18 | * specific language governing permissions and limitations 19 | * under the License. 20 | */ 21 | 22 | type XAudioBufferOptions = { 23 | numberOfChannels?: number; 24 | length: number; 25 | sampleRate: number; 26 | }; 27 | 28 | /** 29 | * An implementation of AudioBuffer as seen in the Web Audio API. The 30 | * AudioBuffer in Web Audio is tied to the browser, and only recently it became 31 | * possible to instantiate an AudioBuffer without a Web Audio Context. This 32 | * implementation allows for a fully WebAudio-free player, while still allowing 33 | * for easy interop with the Web Audio API when needed when still in a browser 34 | * environment. 35 | * 36 | * Originally from https://github.com/audiojs/audio-buffer/, MIT Licensed. 37 | * Converted to TS. 38 | * https://github.com/audiojs/audio-buffer/blob/master/LICENSE 39 | */ 40 | export class XAudioBuffer { 41 | sampleRate: number; // float 42 | length: number; // unsigned long 43 | duration: number; // double 44 | numberOfChannels: number; //unsigned long 45 | 46 | protected _data: Float32Array; // planar 47 | protected _channelData: Float32Array[]; // "cached" channels as subarrays 48 | 49 | constructor(options: XAudioBufferOptions) { 50 | this.sampleRate = options.sampleRate; 51 | // The original impl Math.ceil(options.length)... 52 | // Flooring here because that's what the Float32Array Ctor will do anyway. 53 | this.length = Math.floor(options.length); 54 | this.numberOfChannels = options.numberOfChannels 55 | ? options.numberOfChannels 56 | : 1; 57 | this.duration = this.length / this.sampleRate; 58 | 59 | //data is stored as a planar sequence 60 | this._data = new Float32Array(this.length * this.numberOfChannels); 61 | 62 | //channels data is cached as subarrays 63 | this._channelData = []; 64 | for (let c = 0; c < this.numberOfChannels; c++) { 65 | this._channelData.push( 66 | this._data.subarray(c * this.length, (c + 1) * this.length) 67 | ); 68 | } 69 | } 70 | 71 | /** 72 | * Create a new XAudioBuffer by copying data from an existing AudioBuffer. 73 | */ 74 | static fromAudioBuffer(ab: AudioBuffer) { 75 | const x = new XAudioBuffer({ 76 | numberOfChannels: ab.numberOfChannels, 77 | sampleRate: ab.sampleRate, 78 | length: ab.length 79 | }); 80 | for (let i = 0; i < ab.numberOfChannels; i++) { 81 | x.copyToChannel(ab.getChannelData(i), i, 0); 82 | } 83 | return x; 84 | } 85 | 86 | /** 87 | * Return data associated with the channel. 88 | */ 89 | getChannelData(channel: number): Float32Array { 90 | if (channel >= this.numberOfChannels || channel < 0 || channel == null) { 91 | throw Error( 92 | 'Cannot getChannelData: channel number (' + 93 | channel + 94 | ') exceeds number of channels (' + 95 | this.numberOfChannels + 96 | ')' 97 | ); 98 | } 99 | 100 | return this._channelData[channel]; 101 | } 102 | 103 | /** 104 | * Place data to the destination buffer, starting from the position 105 | */ 106 | copyFromChannel( 107 | destination: Float32Array, 108 | channelNumber: number, 109 | startInChannel = 0 110 | ) { 111 | const data = this._channelData[channelNumber]; 112 | for ( 113 | let i = startInChannel, j = 0; 114 | i < this.length && j < destination.length; 115 | i++, j++ 116 | ) { 117 | destination[j] = data[i]; 118 | } 119 | } 120 | 121 | /** 122 | * Place data from the source to the channel, starting (in self) from the position 123 | */ 124 | copyToChannel( 125 | source: Float32Array, 126 | channelNumber: number, 127 | startInChannel = 0 128 | ) { 129 | const data = this._channelData[channelNumber]; 130 | for ( 131 | let i = startInChannel, j = 0; 132 | i < this.length && j < source.length; 133 | i++, j++ 134 | ) { 135 | data[i] = source[j]; 136 | } 137 | } 138 | } 139 | -------------------------------------------------------------------------------- /src/declarations.d.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) 2018-Present, Spotify AB. 3 | * 4 | * Licensed to the Apache Software Foundation (ASF) under one 5 | * or more contributor license agreements. See the NOTICE file 6 | * distributed with this work for additional information 7 | * regarding copyright ownership. The ASF licenses this file 8 | * to you under the Apache License, Version 2.0 (the 9 | * "License"); you may not use this file except in compliance 10 | * with the License. You may obtain a copy of the License at 11 | * 12 | * http://www.apache.org/licenses/LICENSE-2.0 13 | * 14 | * Unless required by applicable law or agreed to in writing, 15 | * software distributed under the License is distributed on an 16 | * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY 17 | * KIND, either express or implied. See the License for the 18 | * specific language governing permissions and limitations 19 | * under the License. 20 | */ 21 | 22 | declare module 'web-audio-test-api'; 23 | 24 | declare module 'wav-decoder' { 25 | type WavDecoderOptions = { 26 | symmetric?: boolean; 27 | }; 28 | 29 | type AudioData = { 30 | sampleRate: number; 31 | channelData: Float32Array[]; 32 | }; 33 | 34 | export function decode( 35 | src: ArrayBuffer | Buffer, 36 | opts?: object 37 | ): Promise; 38 | 39 | // Note: this is missing the decode.sync function because 40 | // it involves a much more complicated declaration. 41 | } 42 | 43 | declare module 'wav-encoder' { 44 | type WavEncoderOptions = { 45 | bitDepth?: number; // Default: 16 46 | float?: boolean; // Default: false 47 | symmetric?: boolean; 48 | }; 49 | 50 | type AudioData = { 51 | sampleRate: number; 52 | channelData: Float32Array[]; 53 | }; 54 | 55 | export function encode( 56 | audioData: AudioData, 57 | opts?: object 58 | ): Promise; 59 | 60 | // Note: this is missing the encode.sync function because 61 | // it involves a much more complicated declaration. 62 | } 63 | -------------------------------------------------------------------------------- /src/index.test.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) 2018-Present, Spotify AB. 3 | * 4 | * Licensed to the Apache Software Foundation (ASF) under one 5 | * or more contributor license agreements. See the NOTICE file 6 | * distributed with this work for additional information 7 | * regarding copyright ownership. The ASF licenses this file 8 | * to you under the Apache License, Version 2.0 (the 9 | * "License"); you may not use this file except in compliance 10 | * with the License. You may obtain a copy of the License at 11 | * 12 | * http://www.apache.org/licenses/LICENSE-2.0 13 | * 14 | * Unless required by applicable law or agreed to in writing, 15 | * software distributed under the License is distributed on an 16 | * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY 17 | * KIND, either express or implied. See the License for the 18 | * specific language governing permissions and limitations 19 | * under the License. 20 | */ 21 | 22 | import * as NFSP from './'; 23 | 24 | test('Public API', () => { 25 | expect(NFSP.SmartPlayer).toBeDefined(); 26 | expect(NFSP.ScoreAudioParam).toBeDefined(); 27 | expect(NFSP.TimeInstant).toBeDefined(); 28 | expect(NFSP.ScriptProcessorRenderer).toBeDefined(); 29 | expect(NFSP.MemoryRenderer).toBeDefined(); 30 | expect(NFSP.BaseRenderer).toBeDefined(); 31 | }); 32 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) 2018-Present, Spotify AB. 3 | * 4 | * Licensed to the Apache Software Foundation (ASF) under one 5 | * or more contributor license agreements. See the NOTICE file 6 | * distributed with this work for additional information 7 | * regarding copyright ownership. The ASF licenses this file 8 | * to you under the Apache License, Version 2.0 (the 9 | * "License"); you may not use this file except in compliance 10 | * with the License. You may obtain a copy of the License at 11 | * 12 | * http://www.apache.org/licenses/LICENSE-2.0 13 | * 14 | * Unless required by applicable law or agreed to in writing, 15 | * software distributed under the License is distributed on an 16 | * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY 17 | * KIND, either express or implied. See the License for the 18 | * specific language governing permissions and limitations 19 | * under the License. 20 | */ 21 | 22 | export { SmartPlayer, NodePlaybackDescription } from './SmartPlayer'; 23 | export { ScriptProcessorRenderer } from './renderers/ScriptProcessorRenderer'; 24 | export { BaseRenderer } from './renderers/BaseRenderer'; 25 | export { MemoryRenderer } from './renderers/MemoryRenderer'; 26 | export { RendererInfo, XAudioBufferFromInfo } from './renderers/RendererInfo'; 27 | export { TimeInstant } from './time'; 28 | export { 29 | ScoreAudioParam, 30 | ScoreAudioParamCmd, 31 | ExponentialRampToValueAtTimeCmd, 32 | LinearRampToValueAtTimeCmd, 33 | SetTargetAtTimeCmd, 34 | SetValueAtTimeCmd, 35 | SetValueCurveAtTimeCmd 36 | } from './params/ScoreAudioParam'; 37 | export { 38 | Mutation, 39 | MutationBase, 40 | MutationNames, 41 | PushCommandsMutation, 42 | ClearCommandsMutation 43 | } from './Mutations'; 44 | export { XAudioBuffer } from './XAudioBuffer'; 45 | -------------------------------------------------------------------------------- /src/nodes/SPDestinationNode.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) 2018-Present, Spotify AB. 3 | * 4 | * Licensed to the Apache Software Foundation (ASF) under one 5 | * or more contributor license agreements. See the NOTICE file 6 | * distributed with this work for additional information 7 | * regarding copyright ownership. The ASF licenses this file 8 | * to you under the Apache License, Version 2.0 (the 9 | * "License"); you may not use this file except in compliance 10 | * with the License. You may obtain a copy of the License at 11 | * 12 | * http://www.apache.org/licenses/LICENSE-2.0 13 | * 14 | * Unless required by applicable law or agreed to in writing, 15 | * software distributed under the License is distributed on an 16 | * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY 17 | * KIND, either express or implied. See the License for the 18 | * specific language governing permissions and limitations 19 | * under the License. 20 | */ 21 | 22 | import { TimeInstant } from '../time'; 23 | import { Node } from 'nf-grapher'; 24 | import { SPNode } from './SPNode'; 25 | import { DirectedScore } from '../DirectedScore'; 26 | import { RendererInfo } from '../renderers/RendererInfo'; 27 | 28 | export class SPDestinationNode extends SPNode { 29 | // HACK: just to help things be consistent. 30 | static KIND = 'com.nativeformat.plugin.destination'; 31 | 32 | public node: Node; 33 | // public playing: boolean; 34 | // private processor?: ScriptProcessorNode; 35 | // private bufferSize: number; 36 | // private samplesElapsed: number; 37 | 38 | // private effects: Array; 39 | public readonly dscore: DirectedScore; 40 | 41 | constructor(info: RendererInfo, dscore: DirectedScore) { 42 | const destination = { 43 | id: 'destination', 44 | kind: SPDestinationNode.KIND 45 | }; 46 | super(info, destination, dscore); 47 | this.node = destination; 48 | this.dscore = dscore; 49 | } 50 | 51 | graphId() { 52 | return this.dscore.graphId(); 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /src/nodes/SPFileNode.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) 2018-Present, Spotify AB. 3 | * 4 | * Licensed to the Apache Software Foundation (ASF) under one 5 | * or more contributor license agreements. See the NOTICE file 6 | * distributed with this work for additional information 7 | * regarding copyright ownership. The ASF licenses this file 8 | * to you under the Apache License, Version 2.0 (the 9 | * "License"); you may not use this file except in compliance 10 | * with the License. You may obtain a copy of the License at 11 | * 12 | * http://www.apache.org/licenses/LICENSE-2.0 13 | * 14 | * Unless required by applicable law or agreed to in writing, 15 | * software distributed under the License is distributed on an 16 | * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY 17 | * KIND, either express or implied. See the License for the 18 | * specific language governing permissions and limitations 19 | * under the License. 20 | */ 21 | 22 | import { ContentCache } from '../ContentCache'; 23 | import { TimeInstant } from '../time'; 24 | import { FileNode } from 'nf-grapher'; 25 | import { SPNode, NodePlaybackDescription } from './SPNode'; 26 | 27 | import { debug as Debug } from 'debug'; 28 | import { XAudioBuffer } from '../XAudioBuffer'; 29 | 30 | const DBG_STR = 'nf:filenode'; 31 | const dbg = Debug(DBG_STR); 32 | 33 | export class SPFileNode extends SPNode { 34 | protected ab: XAudioBuffer = new XAudioBuffer({ 35 | numberOfChannels: this.info.channelCount, 36 | length: 1, 37 | sampleRate: this.info.sampleRate 38 | }); 39 | 40 | getPlaybackDescription( 41 | renderTime: TimeInstant, 42 | descriptions: NodePlaybackDescription[] 43 | ) { 44 | const { id, kind } = this.node; 45 | 46 | descriptions.push({ 47 | id, 48 | kind, 49 | time: renderTime, 50 | file: { maxDuration: TimeInstant.fromSeconds(this.ab.duration) } 51 | }); 52 | } 53 | 54 | feed(renderTime: TimeInstant, buffers: XAudioBuffer[], sampleCount: number) { 55 | const ab = this.ab; 56 | 57 | // TODO: make this possible more easily: This is useful debug code to verify that values are transformed as expected! 58 | // const buf = this.ctx.createBuffer(2, sampleCount, this.ctx.sampleRate); 59 | // for (let i = 0; i < buf.numberOfChannels; i++) { 60 | // const chan = buf.getChannelData(i); 61 | // chan.forEach((_, i) => chan[i] = 1); 62 | // } 63 | // buffers.push(buf); 64 | // return; 65 | 66 | if (ab.length === 1) { 67 | // File has not loaded yet. 68 | // TODO: this is a bug! feed() should not be able to be called 69 | // unless timeChange has completed! 70 | // But SmartPlayer, when told to start playing, will call feed() 71 | // on the destination node with no regard for when timeChange() 72 | // has finished. 73 | return; 74 | } 75 | 76 | const node = FileNode.from(this.node); 77 | const when = TimeInstant.fromNanos(node.when); 78 | const offset = TimeInstant.fromNanos(node.offset); 79 | const duration = TimeInstant.fromNanos(node.duration); 80 | 81 | const hz = this.info.sampleRate; 82 | const nowSamples = renderTime.asSamples(hz); 83 | const whenSamples = when.asSamples(hz); 84 | const offsetSamples = offset.asSamples(hz); 85 | const durationSamples = duration.asSamples(hz); 86 | 87 | if (whenSamples >= nowSamples + sampleCount) { 88 | // start time is beyond this frame 89 | return; 90 | } 91 | 92 | if (whenSamples + durationSamples < nowSamples) { 93 | // end time is before this frame 94 | return; 95 | } 96 | 97 | let contentBufferStartIndex = 0; 98 | let outputBufferStartIndex = 0; 99 | 100 | if (whenSamples > nowSamples) { 101 | // Starting in the future, but within this frame 102 | outputBufferStartIndex = Math.floor(0 + (whenSamples - nowSamples)); 103 | } else { 104 | // Started in the past or now 105 | contentBufferStartIndex = Math.floor( 106 | nowSamples - whenSamples + (0 + offsetSamples) 107 | ); 108 | } 109 | 110 | if (contentBufferStartIndex > ab.length) { 111 | // We're done, nothing to write. 112 | return; 113 | } 114 | 115 | // TODO: need a case for if the file begins and ends this frame. 116 | // TODO: there has to be a less error-prone way to do this, right? 117 | // Maybe something involving ranges/frames/quantums? 118 | 119 | // We always want to start at beginning of the output buffer. 120 | const output = new XAudioBuffer({ 121 | numberOfChannels: ab.numberOfChannels, 122 | length: sampleCount, 123 | sampleRate: hz 124 | }); 125 | for (let i = 0; i < output.numberOfChannels; i++) { 126 | // Subarray is used below because these APIs are nuts. They don't allow 127 | // any specific _end_ index for when to start copying. Firefox will throw 128 | // if the content is too large or too small, whereas Chrome just skips 129 | // or fills with zeros (but doesn't throw). 130 | 131 | if (outputBufferStartIndex > 0) { 132 | // The content buffer begins playing during this frame. 133 | const offsetted = ab 134 | .getChannelData(i) 135 | .subarray( 136 | offsetSamples, 137 | offsetSamples + (sampleCount - outputBufferStartIndex) 138 | ); 139 | output.copyToChannel(offsetted, i, outputBufferStartIndex); 140 | } else if (contentBufferStartIndex + sampleCount > ab.length) { 141 | // The content buffer ends this frame 142 | ab.copyFromChannel( 143 | output 144 | .getChannelData(i) 145 | .subarray(0, ab.length - contentBufferStartIndex), 146 | i, 147 | contentBufferStartIndex 148 | ); 149 | } else { 150 | // The frame is contained within the content buffer 151 | ab.copyFromChannel( 152 | output.getChannelData(i), 153 | i, 154 | contentBufferStartIndex 155 | ); 156 | } 157 | } 158 | 159 | buffers.push(output); 160 | } 161 | 162 | async timeChange( 163 | renderTime: TimeInstant, 164 | cache: ContentCache, 165 | quantumSize: number 166 | ) { 167 | await Promise.all([ 168 | super.timeChange(renderTime, cache, quantumSize), 169 | this.load(cache) 170 | ]); 171 | } 172 | 173 | // Eventually this could probably be abstracted to include renderTime + sampleCount, 174 | // which allows streaming only portions, and being able to "cache" the creation of 175 | // the individual window buffers. 176 | private async load(cache: ContentCache): Promise { 177 | const node = FileNode.from(this.node); 178 | const uri = node.file; 179 | 180 | if (uri.match(/spotify:/)) { 181 | throw new Error('Spotify file URIs are not supported'); 182 | } 183 | 184 | const content = await cache.get(uri, this.dscore.graphId(), () => { 185 | dbg('loading file %s', uri); 186 | return fetch(uri) 187 | .then(res => { 188 | if (!res.ok) 189 | throw new Error( 190 | `Failed to load ${uri}. Status: ${res.status} ${res.statusText}` 191 | ); 192 | return res.arrayBuffer(); 193 | }) 194 | .then(ab => { 195 | dbg('loaded file %s', uri); 196 | return this.info.decode(uri, ab); 197 | }) 198 | .then(ab => XAudioBuffer.fromAudioBuffer(ab)); 199 | }); 200 | 201 | this.ab = content; 202 | 203 | return content; 204 | } 205 | } 206 | -------------------------------------------------------------------------------- /src/nodes/SPGainNode.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) 2018-Present, Spotify AB. 3 | * 4 | * Licensed to the Apache Software Foundation (ASF) under one 5 | * or more contributor license agreements. See the NOTICE file 6 | * distributed with this work for additional information 7 | * regarding copyright ownership. The ASF licenses this file 8 | * to you under the Apache License, Version 2.0 (the 9 | * "License"); you may not use this file except in compliance 10 | * with the License. You may obtain a copy of the License at 11 | * 12 | * http://www.apache.org/licenses/LICENSE-2.0 13 | * 14 | * Unless required by applicable law or agreed to in writing, 15 | * software distributed under the License is distributed on an 16 | * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY 17 | * KIND, either express or implied. See the License for the 18 | * specific language governing permissions and limitations 19 | * under the License. 20 | */ 21 | 22 | import { TimeInstant } from '../time'; 23 | import { GainNode as GGainNode } from 'nf-grapher'; 24 | import { ScoreAudioParam } from '../params/ScoreAudioParam'; 25 | import { SPNode } from './SPNode'; 26 | import { SPNodeFactory } from './SPNodeFactory'; 27 | import { CommandsMutation, applyMutationToParam } from '../Mutations'; 28 | 29 | import { debug as Debug } from 'debug'; 30 | import { XAudioBuffer } from '../XAudioBuffer'; 31 | 32 | const DBG_STR = 'nf:gainnode'; 33 | const dbg = Debug(DBG_STR); 34 | 35 | export class SPGainNode extends SPNode { 36 | // TODO: looks like TS Grapher doesn't expose the default/initial values. 37 | // Also might have to fork PseudoAudioParam and add in default/initial values. 38 | protected gainParam = new ScoreAudioParam(1); 39 | 40 | // This avoids constructor argument chaining 41 | async nodeDidMount() { 42 | // const node = this.node as GGainNode; 43 | this.gainParam.applyScoreCommands(this.node.params!.gain); 44 | } 45 | 46 | // TODO: Probably will eventually need a "node grapher data changed" lifecycle method. 47 | 48 | feed(renderTime: TimeInstant, buffers: XAudioBuffer[], sampleCount: number) { 49 | const seconds = renderTime.asSeconds(); 50 | 51 | dbg('%s feed @ %f', this.node.id, seconds); 52 | 53 | const ancestorBuffers: XAudioBuffer[] = []; 54 | SPNodeFactory.feed( 55 | this.ancestors, 56 | renderTime, 57 | ancestorBuffers, 58 | sampleCount 59 | ); 60 | 61 | for (let buffer of ancestorBuffers) { 62 | const incr = 1 / buffer.sampleRate; 63 | for (let c = 0; c < buffer.numberOfChannels; c++) { 64 | const chan = buffer.getChannelData(c); 65 | for (let i = 0; i < chan.length; i++) { 66 | const sampleTime = seconds + incr * i; 67 | const pvalue = this.gainParam.getValueAtTime(sampleTime); 68 | chan[i] *= pvalue; 69 | } 70 | } 71 | } 72 | 73 | for (let buffer of ancestorBuffers) { 74 | buffers.push(buffer); 75 | } 76 | } 77 | 78 | acceptCommandsEffect(effect: CommandsMutation) { 79 | const node = GGainNode.from(this.node); 80 | const { paramName } = effect; 81 | 82 | if (!Object.keys(node.getParams()).includes(paramName)) { 83 | throw new Error( 84 | `GainNode does not contain param with name: ${paramName}` 85 | ); 86 | } 87 | 88 | return applyMutationToParam(effect, this.gainParam, this.node.params!.gain); 89 | } 90 | } 91 | -------------------------------------------------------------------------------- /src/nodes/SPLoopNode.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) 2018-Present, Spotify AB. 3 | * 4 | * Licensed to the Apache Software Foundation (ASF) under one 5 | * or more contributor license agreements. See the NOTICE file 6 | * distributed with this work for additional information 7 | * regarding copyright ownership. The ASF licenses this file 8 | * to you under the Apache License, Version 2.0 (the 9 | * "License"); you may not use this file except in compliance 10 | * with the License. You may obtain a copy of the License at 11 | * 12 | * http://www.apache.org/licenses/LICENSE-2.0 13 | * 14 | * Unless required by applicable law or agreed to in writing, 15 | * software distributed under the License is distributed on an 16 | * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY 17 | * KIND, either express or implied. See the License for the 18 | * specific language governing permissions and limitations 19 | * under the License. 20 | */ 21 | 22 | import { LoopNode } from 'nf-grapher'; 23 | import { TimeInstant } from '../time'; 24 | import { SPNode, NodePlaybackDescription } from './SPNode'; 25 | import { mixdown, copy } from '../AudioBufferUtils'; 26 | import { SPNodeFactory } from './SPNodeFactory'; 27 | import { XAudioBuffer } from '../XAudioBuffer'; 28 | import { XAudioBufferFromInfo } from '../renderers/RendererInfo'; 29 | 30 | export class SPLoopNode extends SPNode { 31 | getPlaybackDescription( 32 | renderTime: TimeInstant, 33 | descriptions: NodePlaybackDescription[] 34 | ) { 35 | const node = LoopNode.from(this.node); 36 | const loopDuration = TimeInstant.fromNanos(node.duration); 37 | const loopWhen = TimeInstant.fromNanos(node.when); 38 | 39 | const loopsSinceStart = renderTime.sub(loopWhen).div(loopDuration); 40 | 41 | let currentLoopStartTime; 42 | if (loopsSinceStart < 0) { 43 | currentLoopStartTime = loopWhen; 44 | } else { 45 | currentLoopStartTime = loopWhen.add( 46 | loopDuration.scale(Math.floor(loopsSinceStart)) 47 | ); 48 | } 49 | 50 | const currentLoopEndTime = currentLoopStartTime.add(loopDuration); 51 | const loopElapsedTime = renderTime.sub(currentLoopStartTime); 52 | const infinite = node.loopCount === -1; 53 | 54 | // Assume the loop hasn't started yet. 55 | let ancestorRenderTime: TimeInstant = renderTime; 56 | 57 | if (renderTime.gte(loopWhen) && node.loopCount === -1) { 58 | // loop is in progress and infinite 59 | ancestorRenderTime = loopWhen.add(loopElapsedTime); 60 | } else if (renderTime.gte(loopWhen) && node.loopCount !== -1) { 61 | // loop is in progress and finite 62 | const unrolledDuration = loopDuration.scale(node.loopCount); 63 | const loopEndTime = loopWhen.add(unrolledDuration); 64 | 65 | // c++ player might use the start of the loop time as ancestor time now? 66 | // Not sure, let's just pass through. 67 | ancestorRenderTime = renderTime.gte(loopEndTime) 68 | ? // loop is finished and finite 69 | renderTime 70 | : // loop is in progress and finite 71 | loopWhen.add(loopElapsedTime); 72 | } 73 | 74 | const desc: NodePlaybackDescription = { 75 | id: this.node.id, 76 | kind: this.node.kind, 77 | time: ancestorRenderTime, 78 | 79 | loop: { 80 | loopElapsedTime, 81 | loopsSinceStart, 82 | currentLoopStartTime, 83 | currentLoopEndTime, 84 | infinite 85 | } 86 | }; 87 | 88 | descriptions.push(desc); 89 | 90 | SPNodeFactory.getPlaybackDescription( 91 | this.ancestors, 92 | ancestorRenderTime, 93 | descriptions 94 | ); 95 | } 96 | 97 | dilateSampleIndex(sampleIndex: number) { 98 | const hz = this.info.sampleRate; 99 | const node = LoopNode.from(this.node); 100 | const loopDuration = TimeInstant.fromNanos(node.duration); 101 | const loopWhen = TimeInstant.fromNanos(node.when); 102 | 103 | const startSampleIndex = loopWhen.asSamples(hz); 104 | const durationSamples = loopDuration.asSamples(hz); 105 | 106 | if (sampleIndex < startSampleIndex) { 107 | // Loop has not started yet. 108 | return sampleIndex; 109 | } 110 | 111 | if (node.loopCount > 0) { 112 | const loopedSamples = durationSamples * node.loopCount; 113 | const endSampleIndex = startSampleIndex + loopedSamples; 114 | if (sampleIndex >= endSampleIndex) { 115 | // The loop has ended 116 | const extraSamples = loopedSamples - durationSamples; 117 | return sampleIndex - extraSamples; 118 | } 119 | } 120 | 121 | return ( 122 | ((sampleIndex - startSampleIndex) % durationSamples) + startSampleIndex 123 | ); 124 | } 125 | 126 | nextSampleCount(sampleIndex: number, sampleCount: number) { 127 | const hz = this.info.sampleRate; 128 | const node = LoopNode.from(this.node); 129 | const loopDuration = TimeInstant.fromNanos(node.duration); 130 | const loopWhen = TimeInstant.fromNanos(node.when); 131 | 132 | const startSampleIndex = loopWhen.asSamples(hz); 133 | const durationSamples = loopDuration.asSamples(hz); 134 | 135 | const dilatedSampleIndex = this.dilateSampleIndex(sampleIndex); 136 | const dilatedEndSampleIndex = dilatedSampleIndex + sampleCount; 137 | const dilatedEndLoopIndex = startSampleIndex + durationSamples; 138 | const undilatedEndLoopIndex = 139 | startSampleIndex + durationSamples * node.loopCount; 140 | 141 | const dilatedStartIndex = this.dilateSampleIndex(startSampleIndex); 142 | 143 | // TOOD: c++ player uses an in_range() function for this to ensure 144 | // conistent inclusive/exclusive bounds. 145 | // undilatedEndLoopIndex can be a negative number if node.loopCount is -1 146 | // which denotes an infinite loop. If so, then we're always in the loop 147 | // after passing the startSampleIndex. 148 | const inLoop = 149 | sampleIndex >= startSampleIndex && 150 | (node.loopCount > 0 ? sampleIndex < undilatedEndLoopIndex : true); 151 | 152 | if (inLoop && dilatedEndSampleIndex > dilatedEndLoopIndex) { 153 | const extraSamples = dilatedEndSampleIndex - dilatedEndLoopIndex; 154 | const newSampleCount = sampleCount - extraSamples; 155 | return newSampleCount; 156 | } else if ( 157 | !inLoop && 158 | dilatedStartIndex > sampleIndex && 159 | dilatedStartIndex < sampleIndex + sampleCount 160 | ) { 161 | // loop starts mid-quantum 162 | const newSampleCount = dilatedStartIndex - sampleIndex; 163 | return newSampleCount; 164 | } else if (inLoop && dilatedEndLoopIndex < dilatedEndSampleIndex) { 165 | // loop ends mid-quantum 166 | const newSampleCount = dilatedEndLoopIndex - dilatedSampleIndex; 167 | return newSampleCount; 168 | } 169 | 170 | return sampleCount; 171 | } 172 | 173 | feed(renderTime: TimeInstant, buffers: XAudioBuffer[], sampleCount: number) { 174 | const hz = this.info.sampleRate; 175 | const renderTimeSampleIndex = renderTime.asSamples(hz); 176 | let sampleIndex = renderTimeSampleIndex; 177 | let receivedSampleCount = 0; 178 | 179 | const outputs: XAudioBuffer[] = []; 180 | 181 | while (receivedSampleCount < sampleCount) { 182 | let startIndex = sampleIndex; 183 | let count = this.nextSampleCount(sampleIndex, sampleCount); 184 | 185 | // arguably this should be a case in this.nextSampleCount. 186 | if (receivedSampleCount + count > sampleCount) { 187 | count = sampleCount - receivedSampleCount; 188 | } 189 | 190 | const ancestorRenderTimeSamples = this.dilateSampleIndex(startIndex); 191 | const ancestorRenderTime = TimeInstant.fromSamples( 192 | ancestorRenderTimeSamples, 193 | hz 194 | ); 195 | const ancestorBuffers: XAudioBuffer[] = []; 196 | const ancestorFrameSize = count; 197 | 198 | SPNodeFactory.feed( 199 | this.ancestors, 200 | ancestorRenderTime, 201 | ancestorBuffers, 202 | ancestorFrameSize 203 | ); 204 | 205 | sampleIndex += count; 206 | receivedSampleCount += count; 207 | 208 | // We might not get anything from ancestors this step, but we still 209 | // need to track how much silence to stitch possible future buffers. 210 | const silence = XAudioBufferFromInfo(this.info, ancestorFrameSize); 211 | 212 | const mixed = mixdown(silence, ancestorBuffers); 213 | outputs.push(mixed); 214 | } 215 | 216 | const output = XAudioBufferFromInfo(this.info, sampleCount); 217 | 218 | // Stitch the buffers together. 219 | let startIndex = 0; 220 | for (let i = 0; i < outputs.length; i++) { 221 | const source = outputs[i]; 222 | copy(output, source, startIndex); 223 | startIndex = source.length; 224 | } 225 | 226 | buffers.push(output); 227 | } 228 | } 229 | -------------------------------------------------------------------------------- /src/nodes/SPNode.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) 2018-Present, Spotify AB. 3 | * 4 | * Licensed to the Apache Software Foundation (ASF) under one 5 | * or more contributor license agreements. See the NOTICE file 6 | * distributed with this work for additional information 7 | * regarding copyright ownership. The ASF licenses this file 8 | * to you under the Apache License, Version 2.0 (the 9 | * "License"); you may not use this file except in compliance 10 | * with the License. You may obtain a copy of the License at 11 | * 12 | * http://www.apache.org/licenses/LICENSE-2.0 13 | * 14 | * Unless required by applicable law or agreed to in writing, 15 | * software distributed under the License is distributed on an 16 | * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY 17 | * KIND, either express or implied. See the License for the 18 | * specific language governing permissions and limitations 19 | * under the License. 20 | */ 21 | 22 | import { TimeInstant } from '../time'; 23 | import { Node } from 'nf-grapher'; 24 | import { DirectedScore } from '../DirectedScore'; 25 | import { SPNodeFactory } from './SPNodeFactory'; 26 | import { ContentCache } from '../ContentCache'; 27 | import { CommandsMutation } from '../Mutations'; 28 | import { XAudioBuffer } from '../XAudioBuffer'; 29 | import { RendererInfo } from '../renderers/RendererInfo'; 30 | 31 | export type NodePlaybackDescription = { 32 | id: string; 33 | kind: string; 34 | // The renderTime the node used to request samples from its ancestors. 35 | // For example, the dilated stretch time the stretch node passed to the 36 | // nodes it pulled samples from. 37 | time: TimeInstant; 38 | 39 | // It's a grab bag! 40 | 41 | // File Node 42 | file?: { 43 | maxDuration: TimeInstant; 44 | }; 45 | 46 | // Loop Node 47 | loop?: { 48 | loopsSinceStart: number; 49 | currentLoopStartTime: TimeInstant; 50 | currentLoopEndTime: TimeInstant; 51 | loopElapsedTime: TimeInstant; 52 | infinite: boolean; 53 | }; 54 | }; 55 | 56 | export abstract class SPNode { 57 | public ancestors: SPNode[] = []; 58 | constructor( 59 | protected info: RendererInfo, 60 | public readonly node: Node, 61 | protected dscore: DirectedScore 62 | ) {} 63 | 64 | feed(renderTime: TimeInstant, buffers: XAudioBuffer[], quantumSize: number) { 65 | for (let i = 0; i < this.ancestors.length; i++) { 66 | this.ancestors[i].feed(renderTime, buffers, quantumSize); 67 | } 68 | } 69 | 70 | // Multiple ancestors could have the same id, if there is a diamond in the graph. 71 | // For example: A -> B, A -> C. Both B and C will have an instance of A 72 | // in their ancestor list, and each instance will have the same node id. 73 | ancestorsWithId(nodeId: string): SPNode[] { 74 | let result: SPNode[] = []; 75 | this.ancestors.forEach(ancestor => { 76 | if (ancestor.node.id === nodeId) { 77 | result.push(ancestor); 78 | return; 79 | } 80 | 81 | const ancestorResult = ancestor.ancestorsWithId(nodeId); 82 | if (ancestorResult) { 83 | result.push(...ancestorResult); 84 | } 85 | }); 86 | 87 | return result; 88 | } 89 | 90 | async unmountAncestors() { 91 | return Promise.all(this.ancestors.map(node => node.nodeWillUnmount())); 92 | } 93 | 94 | async timeChange( 95 | renderTime: TimeInstant, 96 | cache: ContentCache, 97 | quantumSize: number 98 | ): Promise { 99 | // Tell everyone they're about to be destroyed 100 | await this.unmountAncestors(); 101 | 102 | this.ancestors = SPNodeFactory.createAncestors( 103 | this.node, 104 | this.info, 105 | this.dscore 106 | ); 107 | 108 | // Tell everyone to load themselves given the renderTime 109 | await Promise.all( 110 | this.ancestors.map(node => 111 | node.timeChange(renderTime, cache, quantumSize) 112 | ) 113 | ); 114 | 115 | // Tell this node it has been mounted and its ancestors are in place. 116 | await this.nodeDidMount(); 117 | } 118 | 119 | getPlaybackDescription( 120 | renderTime: TimeInstant, 121 | descriptions: NodePlaybackDescription[] 122 | ) { 123 | descriptions.push({ 124 | id: this.node.id, 125 | kind: this.node.kind, 126 | time: renderTime 127 | }); 128 | 129 | SPNodeFactory.getPlaybackDescription( 130 | this.ancestors, 131 | renderTime, 132 | descriptions 133 | ); 134 | } 135 | 136 | async nodeDidMount(): Promise {} 137 | async nodeWillUnmount(): Promise {} 138 | 139 | acceptCommandsEffect(effect: CommandsMutation) { 140 | throw new Error(`${this.node.kind} has no params to accept commands.`); 141 | } 142 | // abstract async acceptNodesEffect( 143 | // effect: SmartPlayerEffect 144 | // ): Promise>; 145 | } 146 | -------------------------------------------------------------------------------- /src/nodes/SPNodeFactory.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) 2018-Present, Spotify AB. 3 | * 4 | * Licensed to the Apache Software Foundation (ASF) under one 5 | * or more contributor license agreements. See the NOTICE file 6 | * distributed with this work for additional information 7 | * regarding copyright ownership. The ASF licenses this file 8 | * to you under the Apache License, Version 2.0 (the 9 | * "License"); you may not use this file except in compliance 10 | * with the License. You may obtain a copy of the License at 11 | * 12 | * http://www.apache.org/licenses/LICENSE-2.0 13 | * 14 | * Unless required by applicable law or agreed to in writing, 15 | * software distributed under the License is distributed on an 16 | * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY 17 | * KIND, either express or implied. See the License for the 18 | * specific language governing permissions and limitations 19 | * under the License. 20 | */ 21 | 22 | import { 23 | Node, 24 | GainNode as GGainNode, 25 | LoopNode, 26 | FileNode, 27 | StretchNode 28 | } from 'nf-grapher'; 29 | import { DirectedScore } from '../DirectedScore'; 30 | import { SPNode, NodePlaybackDescription } from './SPNode'; 31 | import { SPGainNode } from './SPGainNode'; 32 | import { SPFileNode } from './SPFileNode'; 33 | import { SPLoopNode } from './SPLoopNode'; 34 | import { TimeInstant } from '../time'; 35 | import { SPDestinationNode } from './SPDestinationNode'; 36 | import { SPPassthroughNode } from './SPPassthroughNode'; 37 | import { SPStretchNode } from './SPStretchNode'; 38 | import { XAudioBuffer } from '../XAudioBuffer'; 39 | import { RendererInfo } from '../renderers/RendererInfo'; 40 | 41 | // NOTE: IT IS EXTREMELY IMPORTANT that any files outside this folder that 42 | // import anything from this folder, DO SO FROM THIS FILE! Otherwise, an 43 | // impossible inheritance/require cycle is formed. 44 | // Eventually SPGainNode, for example, will be unresolvable due to the parent 45 | // class (SPNode) being undefined because it's waiting on SPNodeFactory, 46 | // which is waiting on all SP*Node impls to do the `extends` mechanics. 47 | 48 | export class SPNodeFactory { 49 | static fromNode( 50 | node: Node, 51 | info: RendererInfo, 52 | dscore: DirectedScore 53 | ): SPNode { 54 | switch (node.kind) { 55 | case GGainNode.PLUGIN_KIND: { 56 | return new SPGainNode(info, node, dscore); 57 | } 58 | 59 | case FileNode.PLUGIN_KIND: { 60 | return new SPFileNode(info, node, dscore); 61 | } 62 | 63 | case LoopNode.PLUGIN_KIND: { 64 | return new SPLoopNode(info, node, dscore); 65 | } 66 | 67 | case StretchNode.PLUGIN_KIND: { 68 | return new SPStretchNode(info, node, dscore); 69 | } 70 | 71 | default: { 72 | console.warn( 73 | 'Unimplemented node: ' + 74 | node.kind + 75 | '. Substituting with Passthrough.' 76 | ); 77 | return new SPPassthroughNode(info, node, dscore); 78 | } 79 | } 80 | } 81 | 82 | static createAncestors( 83 | forNode: Node, 84 | info: RendererInfo, 85 | dscore: DirectedScore 86 | ) { 87 | const ancestors: SPNode[] = []; 88 | 89 | if (forNode.kind === SPDestinationNode.KIND) { 90 | const leaves = dscore.leaves(); 91 | leaves.forEach(node => { 92 | const ancestor = SPNodeFactory.fromNode(node, info, dscore); 93 | if (!ancestor) return; 94 | ancestors.push(ancestor); 95 | }); 96 | } else { 97 | const incoming = dscore.incomingEdges(forNode); 98 | incoming.forEach(edge => { 99 | const ancestor = dscore.source(edge); 100 | if (!ancestor) return; 101 | let mixer = SPNodeFactory.fromNode(ancestor, info, dscore); 102 | if (mixer !== undefined) { 103 | ancestors.push(mixer); 104 | } 105 | }); 106 | } 107 | 108 | return ancestors; 109 | } 110 | 111 | static feed( 112 | ancestors: SPNode[], 113 | renderTime: TimeInstant, 114 | buffers: XAudioBuffer[], 115 | quantumSize: number 116 | ) { 117 | for (let i = 0; i < ancestors.length; i++) { 118 | ancestors[i].feed(renderTime, buffers, quantumSize); 119 | } 120 | } 121 | 122 | static getPlaybackDescription( 123 | ancestors: SPNode[], 124 | renderTime: TimeInstant, 125 | descriptions: NodePlaybackDescription[] 126 | ) { 127 | for (let i = 0; i < ancestors.length; i++) { 128 | ancestors[i].getPlaybackDescription(renderTime, descriptions); 129 | } 130 | } 131 | } 132 | 133 | export { 134 | SPNode, 135 | SPFileNode, 136 | SPGainNode, 137 | SPLoopNode, 138 | SPStretchNode, 139 | SPDestinationNode, 140 | NodePlaybackDescription 141 | }; 142 | -------------------------------------------------------------------------------- /src/nodes/SPPassthroughNode.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) 2018-Present, Spotify AB. 3 | * 4 | * Licensed to the Apache Software Foundation (ASF) under one 5 | * or more contributor license agreements. See the NOTICE file 6 | * distributed with this work for additional information 7 | * regarding copyright ownership. The ASF licenses this file 8 | * to you under the Apache License, Version 2.0 (the 9 | * "License"); you may not use this file except in compliance 10 | * with the License. You may obtain a copy of the License at 11 | * 12 | * http://www.apache.org/licenses/LICENSE-2.0 13 | * 14 | * Unless required by applicable law or agreed to in writing, 15 | * software distributed under the License is distributed on an 16 | * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY 17 | * KIND, either express or implied. See the License for the 18 | * specific language governing permissions and limitations 19 | * under the License. 20 | */ 21 | 22 | import { SPNode } from './SPNode'; 23 | 24 | // Use default abstract impls 25 | export class SPPassthroughNode extends SPNode {} 26 | -------------------------------------------------------------------------------- /src/nodes/SPStretchNode.test.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) 2018-Present, Spotify AB. 3 | * 4 | * Licensed to the Apache Software Foundation (ASF) under one 5 | * or more contributor license agreements. See the NOTICE file 6 | * distributed with this work for additional information 7 | * regarding copyright ownership. The ASF licenses this file 8 | * to you under the Apache License, Version 2.0 (the 9 | * "License"); you may not use this file except in compliance 10 | * with the License. You may obtain a copy of the License at 11 | * 12 | * http://www.apache.org/licenses/LICENSE-2.0 13 | * 14 | * Unless required by applicable law or agreed to in writing, 15 | * software distributed under the License is distributed on an 16 | * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY 17 | * KIND, either express or implied. See the License for the 18 | * specific language governing permissions and limitations 19 | * under the License. 20 | */ 21 | 22 | import { FileNode, Score, StretchNode } from 'nf-grapher'; 23 | import { TimeInstant } from '../time'; 24 | import { XAudioBufferFromInfo } from '../renderers/RendererInfo'; 25 | import { SmartPlayer } from '../SmartPlayer'; 26 | import { MemoryRenderer } from '../renderers/MemoryRenderer'; 27 | import { ContentCache } from '../ContentCache'; 28 | import { TestRendererInfo } from '../test-utils/TestRendererInfo'; 29 | 30 | test('Seeking produces equivalent audio at 1x stretch', async () => { 31 | const info = { ...TestRendererInfo, quantumSize: 400 }; 32 | const input = XAudioBufferFromInfo(info, info.quantumSize * 4); 33 | const chan = input.getChannelData(0); 34 | // Fill the audio with known transition points. 35 | for (let i = 0; i < chan.length; i++) { 36 | chan[i] = 37 | i < chan.length * 0.25 38 | ? -1 39 | : i < chan.length * 0.5 40 | ? -0.5 41 | : i < chan.length * 0.75 42 | ? 0.5 43 | : 1; 44 | } 45 | 46 | const renderer = new MemoryRenderer(info, false); 47 | const player = new SmartPlayer(renderer); 48 | const cache = new ContentCache(undefined, new Map([['test:audio', input]])); 49 | renderer.unsafelyReplaceContentCache(cache); 50 | 51 | const f = FileNode.create({ 52 | when: 0, 53 | duration: TimeInstant.fromSamples(input.length, info.sampleRate).asNanos(), 54 | file: 'test:audio' 55 | }); 56 | const s = StretchNode.create(); 57 | 58 | const score = new Score(); 59 | score.graph.edges.push(f.connectToTarget(s)); 60 | score.graph.nodes.push(f, s); 61 | 62 | await player.enqueueScore(score); 63 | player.playing = true; 64 | const output1 = renderer.renderDuration(input.length); 65 | 66 | // Set false then kick to ensure all scores are dequeued and that the true 67 | // playing state === 'STOPPED' instead of 'STOPPING'. This is wonky, I 68 | // wish there were another way. 69 | player.playing = false; 70 | renderer.render(); 71 | 72 | await player.dequeueScore(score.graph.id); 73 | await player.enqueueScore(score); 74 | await player.seek(TimeInstant.fromSamples(input.length / 2, info.sampleRate)); 75 | player.playing = true; 76 | const output2 = renderer.renderDuration(input.length / 2); 77 | 78 | // Verify the lengths. 79 | expect(output1).toHaveLength(input.length); 80 | expect(output2).toHaveLength(input.length / 2); 81 | 82 | // SoundTouch "primes" by starting at zero and ramping up to the actual 83 | // sample, eventually. (Looks like a setTargetAtTime). So we cannot directly 84 | // compare buffers. Instead, pick a known transition point and compare 85 | // the surrounding values. 86 | const midpoint = output2.length / 2; 87 | const midpointStart = midpoint - 5; 88 | const midpointEnd = midpoint + 5; 89 | 90 | const subOutput1 = output1 91 | .getChannelData(0) 92 | .subarray(input.length / 2) 93 | .subarray(midpointStart, midpointEnd); 94 | const subOutput2 = output2 95 | .getChannelData(0) 96 | .subarray(midpointStart, midpointEnd); 97 | 98 | expect(subOutput1).toEqual(subOutput2); 99 | }); 100 | 101 | test('Seeking produces equivalent audio at 2x stretch', async () => { 102 | const info = { ...TestRendererInfo, quantumSize: 8192 }; 103 | const input = XAudioBufferFromInfo(info, info.quantumSize * 8); 104 | const chan = input.getChannelData(0); 105 | // Fill the audio with known transition points. 106 | for (let i = 0; i < chan.length; i++) { 107 | chan[i] = 108 | i < chan.length * 0.25 109 | ? -1 110 | : i < chan.length * 0.5 111 | ? -0.5 112 | : i < chan.length * 0.75 113 | ? 0.5 114 | : 1; 115 | } 116 | 117 | const renderer = new MemoryRenderer(info, false); 118 | const player = new SmartPlayer(renderer); 119 | const cache = new ContentCache(undefined, new Map([['test:audio', input]])); 120 | renderer.unsafelyReplaceContentCache(cache); 121 | 122 | const f = FileNode.create({ 123 | when: 0, 124 | duration: TimeInstant.fromSamples(input.length, info.sampleRate).asNanos(), 125 | file: 'test:audio' 126 | }); 127 | const s = StretchNode.create(); 128 | s.stretch.setValueAtTime(2, TimeInstant.ZERO.asNanos()); 129 | 130 | const score = new Score(); 131 | score.graph.edges.push(f.connectToTarget(s)); 132 | score.graph.nodes.push(f, s); 133 | 134 | await player.enqueueScore(score); 135 | player.playing = true; 136 | 137 | const maxDuration = input.length * 2; 138 | const boundaries = maxDuration / 4; 139 | 140 | const output1 = renderer.renderDuration(maxDuration); 141 | 142 | // Set false then kick to ensure all scores are dequeued and that the true 143 | // playing state === 'STOPPED' instead of 'STOPPING'. This is wonky, I 144 | // wish there were another way. 145 | player.playing = false; 146 | renderer.render(); 147 | 148 | await player.dequeueScore(score.graph.id); 149 | await player.enqueueScore(score); 150 | 151 | await player.seek(TimeInstant.fromSamples(maxDuration / 2, info.sampleRate)); 152 | player.playing = true; 153 | const output2 = renderer.renderDuration(maxDuration / 2); 154 | 155 | // Verify the lengths. 156 | expect(output1).toHaveLength(maxDuration); 157 | expect(output2).toHaveLength(maxDuration / 2); 158 | 159 | // SoundTouch "primes" by starting at zero and ramping up to the actual 160 | // sample, eventually. (Looks like a setTargetAtTime). So we cannot directly 161 | // compare buffers. Instead, pick a known transition point and compare 162 | // the surrounding values. 163 | const midpoint = output2.length / 2; 164 | const midpointStart = midpoint - 10; 165 | const midpointEnd = midpoint + 10; 166 | 167 | const subOutput1 = output1 168 | .getChannelData(0) 169 | .subarray(maxDuration / 2) 170 | .subarray(midpointStart, midpointEnd); 171 | const subOutput2 = output2 172 | .getChannelData(0) 173 | .subarray(midpointStart, midpointEnd); 174 | 175 | expect(subOutput1).toEqual(subOutput2); 176 | }); 177 | -------------------------------------------------------------------------------- /src/params/ScoreAudioParam.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) 2018-Present, Spotify AB. 3 | * 4 | * Licensed to the Apache Software Foundation (ASF) under one 5 | * or more contributor license agreements. See the NOTICE file 6 | * distributed with this work for additional information 7 | * regarding copyright ownership. The ASF licenses this file 8 | * to you under the Apache License, Version 2.0 (the 9 | * "License"); you may not use this file except in compliance 10 | * with the License. You may obtain a copy of the License at 11 | * 12 | * http://www.apache.org/licenses/LICENSE-2.0 13 | * 14 | * Unless required by applicable law or agreed to in writing, 15 | * software distributed under the License is distributed on an 16 | * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY 17 | * KIND, either express or implied. See the License for the 18 | * specific language governing permissions and limitations 19 | * under the License. 20 | */ 21 | 22 | import { Command } from 'nf-grapher'; 23 | import { TimeInstant } from '../time'; 24 | import PseudoAudioParam = require('pseudo-audio-param'); 25 | 26 | interface ScoreAudioParamEvent { 27 | type: string; 28 | time: number; 29 | } 30 | 31 | export class ScoreAudioParam { 32 | constructor( 33 | initialValue: number, 34 | private param = new PseudoAudioParam(initialValue) 35 | ) {} 36 | 37 | // Proxy the few necessary calls to PseudoAudioParam for external 38 | // callers. Otherwise, most are not necessary. 39 | 40 | getValueAtTime(seconds: number): number { 41 | return this.param.getValueAtTime(seconds); 42 | } 43 | 44 | cancelScheduledValues(seconds: number) { 45 | return this.param.cancelScheduledValues(seconds); 46 | } 47 | 48 | get events() { 49 | // This cast avoid exposing the "internal" PseudoAudioParamEvent interface 50 | // beyond this package. 51 | return this.param.events as Readonly; 52 | } 53 | 54 | applyScoreCommands(source: Command[]) { 55 | for (let cmd of source) { 56 | this.applyScoreCommand(cmd); 57 | } 58 | } 59 | 60 | applyScoreCommand(cmd: Command) { 61 | const param = this.param; 62 | const apCommand = cmd as ScoreAudioParamCmd; 63 | if (apCommand.name === 'setValueAtTime') { 64 | const tcmd = apCommand as SetValueAtTimeCmd; 65 | const startTime = TimeInstant.fromNanos(tcmd.args.startTime); 66 | param.setValueAtTime(tcmd.args.value, startTime.asSeconds()); 67 | } else if (apCommand.name === 'exponentialRampToValueAtTime') { 68 | const tcmd = apCommand as ExponentialRampToValueAtTimeCmd; 69 | const endTime = TimeInstant.fromNanos(tcmd.args.endTime); 70 | param.exponentialRampToValueAtTime(tcmd.args.value, endTime.asSeconds()); 71 | } else if (apCommand.name === 'setTargetAtTime') { 72 | const tcmd = apCommand as SetTargetAtTimeCmd; 73 | const startTime = TimeInstant.fromNanos(tcmd.args.startTime); 74 | param.setTargetAtTime( 75 | tcmd.args.target, 76 | startTime.asSeconds(), 77 | tcmd.args.timeConstant 78 | ); 79 | } else if (apCommand.name === 'linearRampToValueAtTime') { 80 | const tcmd = apCommand as LinearRampToValueAtTimeCmd; 81 | const endTime = TimeInstant.fromNanos(tcmd.args.endTime); 82 | param.linearRampToValueAtTime(tcmd.args.value, endTime.asSeconds()); 83 | } else if (apCommand.name === 'setValueCurveAtTime') { 84 | const tcmd = apCommand as SetValueCurveAtTimeCmd; 85 | const startTime = TimeInstant.fromNanos(tcmd.args.startTime); 86 | const duration = TimeInstant.fromNanos(tcmd.args.duration); 87 | param.setValueCurveAtTime( 88 | tcmd.args.values, 89 | startTime.asSeconds(), 90 | duration.asSeconds() 91 | ); 92 | } else { 93 | const exhausitve: never = apCommand; 94 | } 95 | } 96 | } 97 | 98 | export interface SetValueAtTimeCmd { 99 | name: 'setValueAtTime'; 100 | args: { 101 | value: number; 102 | startTime: number; 103 | }; 104 | } 105 | 106 | export interface ExponentialRampToValueAtTimeCmd { 107 | name: 'exponentialRampToValueAtTime'; 108 | args: { 109 | value: number; 110 | endTime: number; 111 | }; 112 | } 113 | 114 | export interface LinearRampToValueAtTimeCmd { 115 | name: 'linearRampToValueAtTime'; 116 | args: { 117 | value: number; 118 | endTime: number; 119 | }; 120 | } 121 | 122 | export interface SetTargetAtTimeCmd { 123 | name: 'setTargetAtTime'; 124 | args: { 125 | target: number; 126 | startTime: number; 127 | timeConstant: number; 128 | }; 129 | } 130 | 131 | export interface SetValueCurveAtTimeCmd { 132 | name: 'setValueCurveAtTime'; 133 | args: { 134 | values: number[]; 135 | startTime: number; 136 | duration: number; 137 | }; 138 | } 139 | 140 | export type ScoreAudioParamCmd = 141 | | SetTargetAtTimeCmd 142 | | SetValueAtTimeCmd 143 | | SetValueCurveAtTimeCmd 144 | | LinearRampToValueAtTimeCmd 145 | | ExponentialRampToValueAtTimeCmd; 146 | -------------------------------------------------------------------------------- /src/pio.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) 2018-Present, Spotify AB. 3 | * 4 | * Licensed to the Apache Software Foundation (ASF) under one 5 | * or more contributor license agreements. See the NOTICE file 6 | * distributed with this work for additional information 7 | * regarding copyright ownership. The ASF licenses this file 8 | * to you under the Apache License, Version 2.0 (the 9 | * "License"); you may not use this file except in compliance 10 | * with the License. You may obtain a copy of the License at 11 | * 12 | * http://www.apache.org/licenses/LICENSE-2.0 13 | * 14 | * Unless required by applicable law or agreed to in writing, 15 | * software distributed under the License is distributed on an 16 | * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY 17 | * KIND, either express or implied. See the License for the 18 | * specific language governing permissions and limitations 19 | * under the License. 20 | */ 21 | 22 | import { writeFile as FSWriteFile, readFile as FSReadFile } from 'fs'; 23 | import { promisify } from 'util'; 24 | 25 | // These should only be used from either the CLI (node) or jest (testing + node)! 26 | export const readFile = promisify(FSReadFile); 27 | export const writeFile = promisify(FSWriteFile); 28 | -------------------------------------------------------------------------------- /src/renderers/MemoryRenderer.test.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) 2018-Present, Spotify AB. 3 | * 4 | * Licensed to the Apache Software Foundation (ASF) under one 5 | * or more contributor license agreements. See the NOTICE file 6 | * distributed with this work for additional information 7 | * regarding copyright ownership. The ASF licenses this file 8 | * to you under the Apache License, Version 2.0 (the 9 | * "License"); you may not use this file except in compliance 10 | * with the License. You may obtain a copy of the License at 11 | * 12 | * http://www.apache.org/licenses/LICENSE-2.0 13 | * 14 | * Unless required by applicable law or agreed to in writing, 15 | * software distributed under the License is distributed on an 16 | * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY 17 | * KIND, either express or implied. See the License for the 18 | * specific language governing permissions and limitations 19 | * under the License. 20 | */ 21 | 22 | import { MemoryRenderer } from './MemoryRenderer'; 23 | import { RendererInfo, XAudioBufferFromInfo } from './RendererInfo'; 24 | import { TestRendererInfo } from '../test-utils/TestRendererInfo'; 25 | import { Score, FileNode } from 'nf-grapher'; 26 | import { TimeInstant } from '../time'; 27 | import { ContentCache } from '../ContentCache'; 28 | 29 | const sum = (arr: Float32Array) => arr.reduce((t, v) => t + v, 0); 30 | 31 | test('play/pause fades in/out', async () => { 32 | const info: RendererInfo = { ...TestRendererInfo }; 33 | const renderer = new MemoryRenderer(info); 34 | 35 | const fBuffer = XAudioBufferFromInfo(info, info.quantumSize * 3); 36 | fBuffer 37 | .getChannelData(0) 38 | .forEach((v, i) => (fBuffer.getChannelData(0)[i] = 1)); 39 | const cache = new ContentCache(undefined, new Map([['test:audio', fBuffer]])); 40 | renderer.unsafelyReplaceContentCache(cache); 41 | 42 | const s = new Score(); 43 | s.graph.nodes.push( 44 | FileNode.create({ 45 | file: 'test:audio', 46 | when: TimeInstant.ZERO.asNanos(), 47 | duration: TimeInstant.fromSeconds(fBuffer.duration).asNanos(), 48 | offset: TimeInstant.ZERO.asNanos() 49 | }) 50 | ); 51 | 52 | // Enqueue before playing so an initial enqueue fadeIn is skipped. 53 | await renderer.enqueueScore(JSON.parse(JSON.stringify(s))); 54 | renderer.playing = true; 55 | const fadeIn = renderer.render(); 56 | const steady = renderer.render(); 57 | renderer.playing = false; 58 | const fadeOut = renderer.render(); 59 | 60 | if (!fadeIn || !steady || !fadeOut) throw new Error('Got no audio!'); 61 | 62 | const totalFadeIn = sum(fadeIn.getChannelData(0)); 63 | const totalSteady = sum(steady.getChannelData(0)); 64 | const totalFadeOut = sum(fadeOut.getChannelData(0)); 65 | 66 | expect(totalFadeIn).toBeGreaterThan(info.quantumSize * 0.8); 67 | expect(totalFadeIn).toBeLessThan(info.quantumSize - info.quantumSize * 0.1); 68 | expect(totalSteady).toBe(info.quantumSize); 69 | expect(totalFadeOut).toBeGreaterThan(info.quantumSize * 0.1); 70 | expect(totalFadeOut).toBeLessThan(info.quantumSize * 0.8); 71 | }); 72 | 73 | test('enqueuing a score fades in/out', async () => { 74 | const info: RendererInfo = { ...TestRendererInfo }; 75 | const renderer = new MemoryRenderer(info); 76 | 77 | const fBuffer = XAudioBufferFromInfo(info, info.quantumSize * 3); 78 | fBuffer 79 | .getChannelData(0) 80 | .forEach((v, i) => (fBuffer.getChannelData(0)[i] = 1)); 81 | const cache = new ContentCache(undefined, new Map([['test:audio', fBuffer]])); 82 | renderer.unsafelyReplaceContentCache(cache); 83 | 84 | const s = new Score(); 85 | s.graph.nodes.push( 86 | FileNode.create({ 87 | file: 'test:audio', 88 | when: TimeInstant.ZERO.asNanos(), 89 | duration: TimeInstant.fromSeconds(fBuffer.duration).asNanos(), 90 | offset: TimeInstant.ZERO.asNanos() 91 | }) 92 | ); 93 | 94 | // Start playing immediately to force the score to be rolled-in due to 95 | // enqueuing, rather than play/pause 96 | renderer.playing = true; 97 | await renderer.enqueueScore(JSON.parse(JSON.stringify(s))); 98 | const fadeIn = renderer.render(); 99 | const steady = renderer.render(); 100 | await renderer.dequeueScore(s.graph.id); 101 | const fadeOut = renderer.render(); 102 | 103 | if (!fadeIn || !steady || !fadeOut) throw new Error('Got no audio!'); 104 | 105 | const totalFadeIn = sum(fadeIn.getChannelData(0)); 106 | const totalSteady = sum(steady.getChannelData(0)); 107 | const totalFadeOut = sum(fadeOut.getChannelData(0)); 108 | 109 | expect(totalFadeIn).toBeGreaterThan(info.quantumSize * 0.8); 110 | expect(totalFadeIn).toBeLessThan(info.quantumSize - info.quantumSize * 0.1); 111 | expect(totalSteady).toBe(info.quantumSize); 112 | expect(totalFadeOut).toBeGreaterThan(info.quantumSize * 0.1); 113 | expect(totalFadeOut).toBeLessThan(info.quantumSize * 0.8); 114 | }); 115 | -------------------------------------------------------------------------------- /src/renderers/MemoryRenderer.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) 2018-Present, Spotify AB. 3 | * 4 | * Licensed to the Apache Software Foundation (ASF) under one 5 | * or more contributor license agreements. See the NOTICE file 6 | * distributed with this work for additional information 7 | * regarding copyright ownership. The ASF licenses this file 8 | * to you under the Apache License, Version 2.0 (the 9 | * "License"); you may not use this file except in compliance 10 | * with the License. You may obtain a copy of the License at 11 | * 12 | * http://www.apache.org/licenses/LICENSE-2.0 13 | * 14 | * Unless required by applicable law or agreed to in writing, 15 | * software distributed under the License is distributed on an 16 | * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY 17 | * KIND, either express or implied. See the License for the 18 | * specific language governing permissions and limitations 19 | * under the License. 20 | */ 21 | 22 | import { debug as Debug } from 'debug'; 23 | import { BaseRenderer } from './BaseRenderer'; 24 | import { RendererInfo } from './RendererInfo'; 25 | import { TimeInstant } from '../time'; 26 | import { XAudioBuffer } from '../XAudioBuffer'; 27 | import { mixdown, copy } from '../AudioBufferUtils'; 28 | 29 | const DBG_STR = 'nf:memory-renderer'; 30 | const dbg = Debug(DBG_STR); 31 | 32 | export class MemoryRenderer extends BaseRenderer { 33 | constructor(info: RendererInfo, autoRolloff: boolean = true) { 34 | super(info, autoRolloff); 35 | } 36 | 37 | // This complicated overload is here purely to allow the user to be very 38 | // specific and avoid divisions / multiplications which could affect 39 | // precision of an expected sample count. This is likely only useful in a 40 | // testing scenario. 41 | /** 42 | * Render the specified duration, starting at current renderTime, into an 43 | * XAudioBuffer. This could give more samples than expected if the 44 | * quantumSize does not align with the specified sampleCount or duration! 45 | */ 46 | renderDuration(sampleCount: number): XAudioBuffer; 47 | renderDuration(duration: TimeInstant): XAudioBuffer; 48 | renderDuration(durationOrSampleCount: TimeInstant | number): XAudioBuffer { 49 | const start = Date.now(); 50 | const bufs: XAudioBuffer[] = []; 51 | 52 | const endTime = 53 | this.samplesElapsed + 54 | (typeof durationOrSampleCount === 'number' 55 | ? durationOrSampleCount 56 | : durationOrSampleCount.asSamples(this.info.sampleRate)); 57 | 58 | while (this.samplesElapsed < endTime) { 59 | const buf = this.render(); 60 | if (buf) { 61 | bufs.push(buf); 62 | } 63 | } 64 | 65 | const length = bufs.length * this.info.quantumSize; 66 | const output = new XAudioBuffer({ 67 | sampleRate: this.info.sampleRate, 68 | numberOfChannels: this.info.channelCount, 69 | length 70 | }); 71 | 72 | const copyStart = Date.now(); 73 | for (let i = 0; i < bufs.length; i++) { 74 | copy(output, bufs[i], i * this.info.quantumSize); 75 | } 76 | const copyEnd = Date.now(); 77 | dbg('appended %d buffers in %dms', bufs.length, copyEnd - copyStart); 78 | 79 | const end = Date.now(); 80 | dbg('rendered %d samples in %dms', length, end - start); 81 | 82 | return output; 83 | } 84 | 85 | /** 86 | * Render a single quantum of audio, and return the result. 87 | * If the player is not `playing`, nothing will be rendered. 88 | */ 89 | render() { 90 | if (!this.playing) return; 91 | const start = Date.now(); 92 | const output = this.renderQuantum(); 93 | const end = Date.now(); 94 | const lastDuration = end - start; 95 | dbg( 96 | 'sampleIndex %d, stepTime %fms', 97 | this.samplesElapsed - this.info.quantumSize, 98 | lastDuration 99 | ); 100 | return output; 101 | } 102 | } 103 | -------------------------------------------------------------------------------- /src/renderers/RendererInfo.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) 2018-Present, Spotify AB. 3 | * 4 | * Licensed to the Apache Software Foundation (ASF) under one 5 | * or more contributor license agreements. See the NOTICE file 6 | * distributed with this work for additional information 7 | * regarding copyright ownership. The ASF licenses this file 8 | * to you under the Apache License, Version 2.0 (the 9 | * "License"); you may not use this file except in compliance 10 | * with the License. You may obtain a copy of the License at 11 | * 12 | * http://www.apache.org/licenses/LICENSE-2.0 13 | * 14 | * Unless required by applicable law or agreed to in writing, 15 | * software distributed under the License is distributed on an 16 | * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY 17 | * KIND, either express or implied. See the License for the 18 | * specific language governing permissions and limitations 19 | * under the License. 20 | */ 21 | 22 | import { XAudioBuffer } from '../XAudioBuffer'; 23 | 24 | export type RendererInfo = { 25 | // hz 26 | sampleRate: number; 27 | // how large the render window/frame/sample count is 28 | quantumSize: number; 29 | // How many channels our output "device" has. 30 | channelCount: number; 31 | // Really just a shim around decodeAudioData for now. Mostly just to 32 | // divorce the abilities of the AudioContext from the AudioContext itself. 33 | decode: (uri: string, data: ArrayBuffer) => Promise; 34 | }; 35 | 36 | export function XAudioBufferFromInfo(info: RendererInfo, length: number) { 37 | return new XAudioBuffer({ 38 | numberOfChannels: info.channelCount, 39 | sampleRate: info.sampleRate, 40 | length 41 | }); 42 | } 43 | -------------------------------------------------------------------------------- /src/renderers/ScriptProcessorRenderer.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) 2018-Present, Spotify AB. 3 | * 4 | * Licensed to the Apache Software Foundation (ASF) under one 5 | * or more contributor license agreements. See the NOTICE file 6 | * distributed with this work for additional information 7 | * regarding copyright ownership. The ASF licenses this file 8 | * to you under the Apache License, Version 2.0 (the 9 | * "License"); you may not use this file except in compliance 10 | * with the License. You may obtain a copy of the License at 11 | * 12 | * http://www.apache.org/licenses/LICENSE-2.0 13 | * 14 | * Unless required by applicable law or agreed to in writing, 15 | * software distributed under the License is distributed on an 16 | * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY 17 | * KIND, either express or implied. See the License for the 18 | * specific language governing permissions and limitations 19 | * under the License. 20 | */ 21 | 22 | import { debug as Debug } from 'debug'; 23 | import { BaseRenderer, PlayingState } from './BaseRenderer'; 24 | import { XAudioBuffer } from '../XAudioBuffer'; 25 | import { TimeInstant } from '../time'; 26 | import { mixdownToAudioBuffer } from '../AudioBufferUtils'; 27 | import { XAudioContext } from '../WebAudioContext'; 28 | 29 | const DBG_STR = 'nf:script-processor-renderer'; 30 | const dbg = Debug(DBG_STR); 31 | 32 | export class ScriptProcessorRenderer extends BaseRenderer { 33 | private processor?: ScriptProcessorNode; 34 | 35 | constructor( 36 | private ctx: AudioContext = XAudioContext(), 37 | quantumSize: number = BaseRenderer.DEFAULT_QUANTUM_SIZE, 38 | autoRolloff: boolean = true 39 | ) { 40 | super( 41 | { 42 | channelCount: ctx.destination.channelCount, 43 | sampleRate: ctx.sampleRate, 44 | // ScriptProcessorNode only accepts 0 - 16384. 45 | quantumSize: Math.min(Math.max(quantumSize, 0), 16384), 46 | decode: (uri, ab: ArrayBuffer) => 47 | new Promise((resolve, reject) => { 48 | dbg('decoding file %s', uri); 49 | // Using the callback form of decodeAudioData due to Safari, which often 50 | // does not emit an actual Error object, and does not track rejected 51 | // promises well, either. 52 | ctx.decodeAudioData( 53 | ab, 54 | buffer => { 55 | dbg('decoded file %s with length %d', uri, buffer.length); 56 | resolve(XAudioBuffer.fromAudioBuffer(buffer)); 57 | }, 58 | err => { 59 | // err is often `null` in safari. 60 | dbg('failed to decode file %s, %s', uri, err && err.message); 61 | reject(err); 62 | } 63 | ); 64 | }) 65 | }, 66 | autoRolloff 67 | ); 68 | 69 | this.processor = this.ctx.createScriptProcessor(this.info.quantumSize); 70 | this.processor.onaudioprocess = evt => { 71 | const start = window.performance.now(); 72 | 73 | // WHY IS THIS NECESSARY!?!?! Without this, the output buffer 74 | // comes in with non-zero samples!!! WHYYYY 75 | // Blank out the damn output. 76 | for (let i = 0; i < evt.outputBuffer.numberOfChannels; i++) { 77 | const destChan = evt.outputBuffer.getChannelData(i); 78 | for (let s = 0; s < destChan.length; s++) { 79 | destChan[s] = 0; 80 | } 81 | } 82 | 83 | const buffer = this.renderQuantum(); 84 | if (buffer) { 85 | mixdownToAudioBuffer(evt.outputBuffer, [buffer]); 86 | } 87 | 88 | if (!this.playing) return; 89 | 90 | if (dbg.enabled) { 91 | const end = window.performance.now(); 92 | const lastDuration = end - start; 93 | 94 | // Note: Firefox appears to not adjust playbackTime using the processor 95 | // buffer size 96 | 97 | console.log( 98 | 'sampleIndex', 99 | this.samplesElapsed - this.info.quantumSize, 100 | 'processor duration', 101 | lastDuration + 'ms', 102 | 'budget remaining', 103 | TimeInstant.fromSeconds(evt.playbackTime) 104 | .sub(TimeInstant.fromSeconds(this.ctx.currentTime)) 105 | .asMillis() + 'ms' 106 | ); 107 | } 108 | }; 109 | this.processor.connect(this.ctx.destination); 110 | } 111 | 112 | get playing() { 113 | // We override the `playing` setter, so we need a getter. 114 | // ES2015 classes do not link accessors if one is overridden. 115 | return super.playing; 116 | } 117 | 118 | set playing(state: boolean) { 119 | // Attempt to resume in the case that the player was instantiated 120 | // at page load (or another situation where user-interaction would not 121 | // unlock the context/audio playback). 122 | // The real solution is to always assume the developer only creates a 123 | // player as a result of direct user input (or injects an AudioContext 124 | // that was the result thereof). 125 | // Of course, this hack assumes that setting `playing` is a result of 126 | // direct user input! 127 | if (this.ctx && this.ctx.state === 'suspended') { 128 | this.ctx.resume(); 129 | } 130 | 131 | super.playing = !!state; 132 | } 133 | } 134 | -------------------------------------------------------------------------------- /src/test-utils/DumpWave.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) 2018-Present, Spotify AB. 3 | * 4 | * Licensed to the Apache Software Foundation (ASF) under one 5 | * or more contributor license agreements. See the NOTICE file 6 | * distributed with this work for additional information 7 | * regarding copyright ownership. The ASF licenses this file 8 | * to you under the Apache License, Version 2.0 (the 9 | * "License"); you may not use this file except in compliance 10 | * with the License. You may obtain a copy of the License at 11 | * 12 | * http://www.apache.org/licenses/LICENSE-2.0 13 | * 14 | * Unless required by applicable law or agreed to in writing, 15 | * software distributed under the License is distributed on an 16 | * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY 17 | * KIND, either express or implied. See the License for the 18 | * specific language governing permissions and limitations 19 | * under the License. 20 | */ 21 | 22 | import * as WavDecoder from 'wav-decoder'; 23 | import * as WavEncoder from 'wav-encoder'; 24 | import { copy } from '../AudioBufferUtils'; 25 | import { XAudioBuffer } from '../XAudioBuffer'; 26 | import { writeFile } from '../pio'; 27 | import { join } from 'path'; 28 | 29 | export async function dumpWave( 30 | output: XAudioBuffer, 31 | filename: string = 'test.wav' 32 | ) { 33 | let channelData = []; 34 | for (let i = 0; i < output.numberOfChannels; i++) { 35 | channelData.push(output.getChannelData(i)); 36 | } 37 | 38 | const encoded = await WavEncoder.encode({ 39 | sampleRate: output.sampleRate, 40 | channelData 41 | }); 42 | 43 | const dest = join(process.cwd(), filename); 44 | await writeFile(dest, new DataView(encoded)); 45 | } 46 | -------------------------------------------------------------------------------- /src/test-utils/ExpectQuantums.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) 2018-Present, Spotify AB. 3 | * 4 | * Licensed to the Apache Software Foundation (ASF) under one 5 | * or more contributor license agreements. See the NOTICE file 6 | * distributed with this work for additional information 7 | * regarding copyright ownership. The ASF licenses this file 8 | * to you under the Apache License, Version 2.0 (the 9 | * "License"); you may not use this file except in compliance 10 | * with the License. You may obtain a copy of the License at 11 | * 12 | * http://www.apache.org/licenses/LICENSE-2.0 13 | * 14 | * Unless required by applicable law or agreed to in writing, 15 | * software distributed under the License is distributed on an 16 | * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY 17 | * KIND, either express or implied. See the License for the 18 | * specific language governing permissions and limitations 19 | * under the License. 20 | */ 21 | 22 | import { XAudioBuffer } from '../XAudioBuffer'; 23 | 24 | /** 25 | * Compare two XAudioBuffers, quantum by quantum. 26 | * 27 | * When I attempted to use a simple jest matcher for each channel, or even the 28 | * audio data, the entire node process hung and never completed. For example: 29 | * expect(received.getChannelData(0)).toEqual(expected.getChannelData(0)); 30 | * This bypasses that problem, and also adds context for which quantums 31 | * differed to help aid debugging the problem. 32 | */ 33 | export function expectQuantums( 34 | received: XAudioBuffer, 35 | expected: XAudioBuffer, 36 | quantumSize: number 37 | ) { 38 | const requiredQuantums = expected.length / quantumSize; 39 | for (let i = 0; i < requiredQuantums; i++) { 40 | for (let j = 0; j < expected.numberOfChannels; j++) { 41 | const msg = `channel: ${j}, sampleIndex: ${i * 42 | quantumSize}, quantumIndex: ${i}`; 43 | const start = i * quantumSize; 44 | const end = (i + 1) * quantumSize; 45 | const receivedQuantum = received.getChannelData(0).subarray(start, end); 46 | const expectedQuantum = expected.getChannelData(0).subarray(start, end); 47 | expect({ msg, data: receivedQuantum }).toEqual({ 48 | msg, 49 | data: expectedQuantum 50 | }); 51 | } 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /src/test-utils/LoadWave.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) 2018-Present, Spotify AB. 3 | * 4 | * Licensed to the Apache Software Foundation (ASF) under one 5 | * or more contributor license agreements. See the NOTICE file 6 | * distributed with this work for additional information 7 | * regarding copyright ownership. The ASF licenses this file 8 | * to you under the Apache License, Version 2.0 (the 9 | * "License"); you may not use this file except in compliance 10 | * with the License. You may obtain a copy of the License at 11 | * 12 | * http://www.apache.org/licenses/LICENSE-2.0 13 | * 14 | * Unless required by applicable law or agreed to in writing, 15 | * software distributed under the License is distributed on an 16 | * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY 17 | * KIND, either express or implied. See the License for the 18 | * specific language governing permissions and limitations 19 | * under the License. 20 | */ 21 | 22 | import * as WavDecoder from 'wav-decoder'; 23 | import { XAudioBuffer } from '../XAudioBuffer'; 24 | import { readFile } from '../pio'; 25 | 26 | export async function loadWave(filePath: string): Promise { 27 | const fileData = await readFile(filePath); 28 | const decoded = await WavDecoder.decode(fileData); 29 | 30 | const audio = new XAudioBuffer({ 31 | sampleRate: decoded.sampleRate, 32 | numberOfChannels: decoded.channelData.length, 33 | length: decoded.channelData[0].length 34 | }); 35 | 36 | for (let i = 0; i < decoded.channelData.length; i++) { 37 | const chan = decoded.channelData[i]; 38 | audio.copyToChannel(chan, i, 0); 39 | } 40 | 41 | return audio; 42 | } 43 | -------------------------------------------------------------------------------- /src/test-utils/TestAudioContext.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) 2018-Present, Spotify AB. 3 | * 4 | * Licensed to the Apache Software Foundation (ASF) under one 5 | * or more contributor license agreements. See the NOTICE file 6 | * distributed with this work for additional information 7 | * regarding copyright ownership. The ASF licenses this file 8 | * to you under the Apache License, Version 2.0 (the 9 | * "License"); you may not use this file except in compliance 10 | * with the License. You may obtain a copy of the License at 11 | * 12 | * http://www.apache.org/licenses/LICENSE-2.0 13 | * 14 | * Unless required by applicable law or agreed to in writing, 15 | * software distributed under the License is distributed on an 16 | * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY 17 | * KIND, either express or implied. See the License for the 18 | * specific language governing permissions and limitations 19 | * under the License. 20 | */ 21 | 22 | export interface TestAudioContext extends AudioContext { 23 | $processTo(time: string): void; 24 | } 25 | -------------------------------------------------------------------------------- /src/test-utils/TestRendererInfo.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) 2018-Present, Spotify AB. 3 | * 4 | * Licensed to the Apache Software Foundation (ASF) under one 5 | * or more contributor license agreements. See the NOTICE file 6 | * distributed with this work for additional information 7 | * regarding copyright ownership. The ASF licenses this file 8 | * to you under the Apache License, Version 2.0 (the 9 | * "License"); you may not use this file except in compliance 10 | * with the License. You may obtain a copy of the License at 11 | * 12 | * http://www.apache.org/licenses/LICENSE-2.0 13 | * 14 | * Unless required by applicable law or agreed to in writing, 15 | * software distributed under the License is distributed on an 16 | * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY 17 | * KIND, either express or implied. See the License for the 18 | * specific language governing permissions and limitations 19 | * under the License. 20 | */ 21 | 22 | import { RendererInfo } from '../renderers/RendererInfo'; 23 | 24 | export const TestRendererInfo: RendererInfo = { 25 | sampleRate: 44100, 26 | quantumSize: 400, 27 | channelCount: 2, 28 | decode: async () => { 29 | throw new Error('Unexpected Decode request.'); 30 | } 31 | }; 32 | -------------------------------------------------------------------------------- /src/time.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) 2018-Present, Spotify AB. 3 | * 4 | * Licensed to the Apache Software Foundation (ASF) under one 5 | * or more contributor license agreements. See the NOTICE file 6 | * distributed with this work for additional information 7 | * regarding copyright ownership. The ASF licenses this file 8 | * to you under the Apache License, Version 2.0 (the 9 | * "License"); you may not use this file except in compliance 10 | * with the License. You may obtain a copy of the License at 11 | * 12 | * http://www.apache.org/licenses/LICENSE-2.0 13 | * 14 | * Unless required by applicable law or agreed to in writing, 15 | * software distributed under the License is distributed on an 16 | * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY 17 | * KIND, either express or implied. See the License for the 18 | * specific language governing permissions and limitations 19 | * under the License. 20 | */ 21 | 22 | export class TimeInstant { 23 | static fromNanos(nanos: number) { 24 | return new TimeInstant(nanos); 25 | } 26 | 27 | static fromMillis(ms: number) { 28 | return new TimeInstant(ms * 1e6); 29 | } 30 | 31 | static fromSeconds(seconds: number) { 32 | return new TimeInstant(seconds * 1e9); 33 | } 34 | 35 | static fromSamples(samples: number, hz: number) { 36 | return new TimeInstant((samples / hz) * 1e9); 37 | } 38 | 39 | static from(instant: TimeInstant) { 40 | return new TimeInstant(instant.asNanos()); 41 | } 42 | 43 | static max(time1: TimeInstant, time2: TimeInstant) { 44 | if (time1.asNanos() > time2.asNanos()) return time1; 45 | else return time2; 46 | } 47 | 48 | static min(time1: TimeInstant, time2: TimeInstant) { 49 | if (time1.asNanos() < time2.asNanos()) return time1; 50 | else return time2; 51 | } 52 | 53 | static readonly ZERO = new TimeInstant(0); 54 | 55 | constructor(protected nanos: number = 0) {} 56 | 57 | asSeconds() { 58 | return this.nanos / 1e9; 59 | } 60 | 61 | asMillis() { 62 | return this.nanos / 1e6; 63 | } 64 | 65 | asNanos() { 66 | return Math.round(this.nanos); 67 | } 68 | 69 | asSamples(hz: number) { 70 | return this.nanos === 0 ? 0 : Math.round((this.nanos / 1e9) * hz); 71 | } 72 | 73 | sub(time: TimeInstant) { 74 | return TimeInstant.fromNanos(this.nanos - time.nanos); 75 | } 76 | 77 | add(time: TimeInstant) { 78 | return TimeInstant.fromNanos(this.nanos + time.nanos); 79 | } 80 | 81 | scale(factor: number) { 82 | return TimeInstant.fromNanos(this.nanos * factor); 83 | } 84 | 85 | mod(factor: number) { 86 | return TimeInstant.fromNanos(this.nanos % factor); 87 | } 88 | 89 | // These are weird when it comes to units! What is Time^2?? 90 | mul(time: TimeInstant) { 91 | return this.nanos * time.nanos; 92 | } 93 | 94 | div(time: TimeInstant) { 95 | return this.nanos / time.nanos; 96 | } 97 | 98 | gt(time: TimeInstant) { 99 | return this.nanos > time.nanos; 100 | } 101 | 102 | gte(time: TimeInstant) { 103 | return this.nanos >= time.nanos; 104 | } 105 | 106 | lt(time: TimeInstant) { 107 | return this.nanos < time.nanos; 108 | } 109 | 110 | lte(time: TimeInstant) { 111 | return this.nanos <= time.nanos; 112 | } 113 | 114 | eq(time: TimeInstant) { 115 | return this.nanos === time.nanos; 116 | } 117 | 118 | neq(time: TimeInstant) { 119 | return this.nanos !== time.nanos; 120 | } 121 | } 122 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "strict": true, 4 | "module": "commonjs", 5 | "target": "ES6", 6 | "jsx": "react", 7 | "outDir": "./dist", 8 | "emitDecoratorMetadata": true, 9 | "experimentalDecorators": true, 10 | "declaration": true, 11 | "lib": ["es2015", "es2016.array.include", "dom"], 12 | "resolveJsonModule": true 13 | }, 14 | "include": ["src/**/*"], 15 | "exclude": ["./package.json"] 16 | } 17 | --------------------------------------------------------------------------------