├── .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 |
--------------------------------------------------------------------------------