Skip to content

Commit cfbd848

Browse files
authored
Use HTML5 AudioWorklet beside ScriptProcessor for passing audio. (#642)
1 parent f1f2af5 commit cfbd848

8 files changed

+474
-184
lines changed

src.csharp/AlphaTab.Windows/NAudioSynthOutput.cs

+21-7
Original file line numberDiff line numberDiff line change
@@ -13,11 +13,13 @@ namespace AlphaTab
1313
public class NAudioSynthOutput : WaveProvider32, ISynthOutput, IDisposable
1414
{
1515
private const int BufferSize = 4096;
16-
private const int BufferCount = 10;
1716
private const int PreferredSampleRate = 44100;
18-
17+
private const int TotalBufferTimeInMilliseconds = 5000;
18+
1919
private DirectSoundOut _context;
2020
private CircularSampleBuffer _circularBuffer;
21+
private int _bufferCount = 0;
22+
private int _requestedBufferCount = 0;
2123

2224
/// <inheritdoc />
2325
public double SampleRate => PreferredSampleRate;
@@ -41,8 +43,12 @@ public void Activate()
4143
/// <inheritdoc />
4244
public void Open()
4345
{
44-
_circularBuffer = new CircularSampleBuffer(BufferSize * BufferCount);
45-
46+
_bufferCount = (int)(
47+
(TotalBufferTimeInMilliseconds * PreferredSampleRate) /
48+
1000 /
49+
BufferSize
50+
);
51+
_circularBuffer = new CircularSampleBuffer(BufferSize * _bufferCount);
4652
_context = new DirectSoundOut(100);
4753
_context.Init(this);
4854

@@ -88,6 +94,7 @@ public void Pause()
8894
public void AddSamples(Float32Array f)
8995
{
9096
_circularBuffer.Write(f, 0, f.Length);
97+
_requestedBufferCount--;
9198
}
9299

93100
/// <inheritdoc />
@@ -100,12 +107,19 @@ private void RequestBuffers()
100107
{
101108
// if we fall under the half of buffers
102109
// we request one half
103-
const int count = BufferCount / 2 * BufferSize;
104-
if (_circularBuffer.Count < count && SampleRequest != null)
110+
var halfBufferCount = _bufferCount / 2;
111+
var halfSamples = halfBufferCount * BufferSize;
112+
// Issue #631: it can happen that requestBuffers is called multiple times
113+
// before we already get samples via addSamples, therefore we need to
114+
// remember how many buffers have been requested, and consider them as available.
115+
var bufferedSamples = _circularBuffer.Count + _requestedBufferCount * BufferSize;
116+
117+
if (bufferedSamples < halfSamples)
105118
{
106-
for (var i = 0; i < BufferCount / 2; i++)
119+
for (var i = 0; i < halfBufferCount; i++)
107120
{
108121
((EventEmitter) SampleRequest).Trigger();
122+
_requestedBufferCount++;
109123
}
110124
}
111125
}

src/Environment.ts

+12-2
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,7 @@ import { CapellaImporter } from './importer/CapellaImporter';
5656
import { ResizeObserverPolyfill } from './platform/javascript/ResizeObserverPolyfill';
5757
import { WebPlatform } from './platform/javascript/WebPlatform';
5858
import { IntersectionObserverPolyfill } from './platform/javascript/IntersectionObserverPolyfill';
59+
import { AlphaSynthWebWorklet } from './platform/javascript/AlphaSynthAudioWorkletOutput';
5960

6061
export class LayoutEngineFactory {
6162
public readonly vertical: boolean;
@@ -205,6 +206,13 @@ export class Environment {
205206
return 'WorkerGlobalScope' in Environment.globalThis;
206207
}
207208

209+
/**
210+
* @target web
211+
*/
212+
public static get isRunningInAudioWorklet(): boolean {
213+
return 'AudioWorkletGlobalScope' in Environment.globalThis;
214+
}
215+
208216
/**
209217
* @target web
210218
* @partial
@@ -221,7 +229,7 @@ export class Environment {
221229
* @target web
222230
*/
223231
private static detectScriptFile(): string | null {
224-
if (Environment.isRunningInWorker || Environment.webPlatform !== WebPlatform.Browser) {
232+
if (!('document' in Environment.globalThis)) {
225233
return null;
226234
}
227235
return (document.currentScript as HTMLScriptElement).src;
@@ -462,7 +470,9 @@ export class Environment {
462470
* @partial
463471
*/
464472
public static platformInit(): void {
465-
if (Environment.isRunningInWorker) {
473+
if (Environment.isRunningInAudioWorklet) {
474+
AlphaSynthWebWorklet.init();
475+
} else if (Environment.isRunningInWorker) {
466476
AlphaTabWebWorker.init();
467477
AlphaSynthWebWorker.init();
468478
} else if (Environment.webPlatform === WebPlatform.Browser) {
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,193 @@
1+
import { CircularSampleBuffer } from '@src/synth/ds/CircularSampleBuffer';
2+
import { Environment } from '@src/Environment';
3+
import { Logger } from '@src/Logger';
4+
import { AlphaSynthWorkerSynthOutput } from './AlphaSynthWorkerSynthOutput';
5+
import { AlphaSynthWebAudioOutputBase } from './AlphaSynthWebAudioOutputBase';
6+
7+
/**
8+
* @target web
9+
*/
10+
interface AudioWorkletProcessor {
11+
readonly port: MessagePort;
12+
process(inputs: Float32Array[][], outputs: Float32Array[][], parameters: Record<string, Float32Array>): boolean;
13+
}
14+
15+
/**
16+
* @target web
17+
*/
18+
declare var AudioWorkletProcessor: {
19+
prototype: AudioWorkletProcessor;
20+
new (options?: AudioWorkletNodeOptions): AudioWorkletProcessor;
21+
};
22+
23+
/**
24+
* This class implements a HTML5 Web Audio API based audio output device
25+
* for alphaSynth using the modern Audio Worklets.
26+
* @target web
27+
*/
28+
export class AlphaSynthWebWorklet {
29+
public static init() {
30+
(Environment.globalThis as any).registerProcessor(
31+
'alphatab',
32+
class AlphaSynthWebWorkletProcessor extends AudioWorkletProcessor {
33+
public static readonly BufferSize: number = 4096;
34+
private static readonly TotalBufferTimeInMilliseconds: number = 5000;
35+
36+
private _outputBuffer: Float32Array = new Float32Array(0);
37+
private _circularBuffer!: CircularSampleBuffer;
38+
private _bufferCount: number = 0;
39+
private _requestedBufferCount: number = 0;
40+
41+
constructor(...args: any[]) {
42+
super(...args);
43+
44+
this._bufferCount = Math.floor(
45+
(AlphaSynthWebWorkletProcessor.TotalBufferTimeInMilliseconds *
46+
Environment.globalThis.sampleRate) /
47+
1000 /
48+
AlphaSynthWebWorkletProcessor.BufferSize
49+
);
50+
this._circularBuffer = new CircularSampleBuffer(
51+
AlphaSynthWebWorkletProcessor.BufferSize * this._bufferCount
52+
);
53+
54+
this.port.onmessage = this.handleMessage.bind(this);
55+
}
56+
57+
private handleMessage(e: MessageEvent) {
58+
let data: any = e.data;
59+
let cmd: any = data.cmd;
60+
switch (cmd) {
61+
case AlphaSynthWorkerSynthOutput.CmdOutputAddSamples:
62+
const f: Float32Array = data.samples;
63+
this._circularBuffer.write(f, 0, f.length);
64+
this._requestedBufferCount--;
65+
break;
66+
case AlphaSynthWorkerSynthOutput.CmdOutputResetSamples:
67+
this._circularBuffer.clear();
68+
break;
69+
}
70+
}
71+
72+
public process(
73+
_inputs: Float32Array[][],
74+
outputs: Float32Array[][],
75+
_parameters: Record<string, Float32Array>
76+
): boolean {
77+
if (outputs.length !== 1 && outputs[0].length !== 2) {
78+
return false;
79+
}
80+
let left: Float32Array = outputs[0][0];
81+
let right: Float32Array = outputs[0][1];
82+
let samples: number = left.length + right.length;
83+
let buffer = this._outputBuffer;
84+
if (buffer.length !== samples) {
85+
buffer = new Float32Array(samples);
86+
this._outputBuffer = buffer;
87+
}
88+
this._circularBuffer.read(buffer, 0, Math.min(buffer.length, this._circularBuffer.count));
89+
let s: number = 0;
90+
for (let i: number = 0; i < left.length; i++) {
91+
left[i] = buffer[s++];
92+
right[i] = buffer[s++];
93+
}
94+
this.port.postMessage({
95+
cmd: AlphaSynthWorkerSynthOutput.CmdOutputSamplesPlayed,
96+
samples: left.length
97+
});
98+
this.requestBuffers();
99+
100+
return true;
101+
}
102+
103+
private requestBuffers(): void {
104+
// if we fall under the half of buffers
105+
// we request one half
106+
const halfBufferCount = (this._bufferCount / 2) | 0;
107+
let halfSamples: number = halfBufferCount * AlphaSynthWebWorkletProcessor.BufferSize;
108+
// Issue #631: it can happen that requestBuffers is called multiple times
109+
// before we already get samples via addSamples, therefore we need to
110+
// remember how many buffers have been requested, and consider them as available.
111+
let bufferedSamples =
112+
this._circularBuffer.count +
113+
this._requestedBufferCount * AlphaSynthWebWorkletProcessor.BufferSize;
114+
if (bufferedSamples < halfSamples) {
115+
for (let i: number = 0; i < halfBufferCount; i++) {
116+
this.port.postMessage({
117+
cmd: AlphaSynthWorkerSynthOutput.CmdOutputSampleRequest
118+
});
119+
}
120+
this._requestedBufferCount += halfBufferCount;
121+
}
122+
}
123+
}
124+
);
125+
}
126+
}
127+
128+
/**
129+
* This class implements a HTML5 Web Audio API based audio output device
130+
* for alphaSynth. It can be controlled via a JS API.
131+
* @target web
132+
*/
133+
export class AlphaSynthAudioWorkletOutput extends AlphaSynthWebAudioOutputBase {
134+
private _worklet: AudioWorkletNode | null = null;
135+
136+
public open() {
137+
super.open();
138+
this.onReady();
139+
}
140+
141+
public play(): void {
142+
super.play();
143+
let ctx = this._context!;
144+
// create a script processor node which will replace the silence with the generated audio
145+
ctx.audioWorklet.addModule(Environment.scriptFile!).then(
146+
() => {
147+
this._worklet = new AudioWorkletNode(ctx!, 'alphatab');
148+
this._worklet.port.onmessage = this.handleMessage.bind(this);
149+
150+
this._source!.connect(this._worklet, 0, 0);
151+
this._source!.start(0);
152+
this._worklet.connect(ctx!.destination);
153+
},
154+
reason => {
155+
Logger.debug('WebAudio', `Audio Worklet creation failed: reason=${reason}`);
156+
}
157+
);
158+
}
159+
160+
private handleMessage(e: MessageEvent) {
161+
let data: any = e.data;
162+
let cmd: any = data.cmd;
163+
switch (cmd) {
164+
case AlphaSynthWorkerSynthOutput.CmdOutputSamplesPlayed:
165+
this.onSamplesPlayed(data.samples);
166+
break;
167+
case AlphaSynthWorkerSynthOutput.CmdOutputSampleRequest:
168+
this.onSampleRequest();
169+
break;
170+
}
171+
}
172+
173+
public pause(): void {
174+
super.pause();
175+
if (this._worklet) {
176+
this._worklet.disconnect(0);
177+
}
178+
this._worklet = null;
179+
}
180+
181+
public addSamples(f: Float32Array): void {
182+
this._worklet?.port.postMessage({
183+
cmd: AlphaSynthWorkerSynthOutput.CmdOutputAddSamples,
184+
samples: f
185+
});
186+
}
187+
188+
public resetSamples(): void {
189+
this._worklet?.port.postMessage({
190+
cmd: AlphaSynthWorkerSynthOutput.CmdOutputResetSamples
191+
});
192+
}
193+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
import { CircularSampleBuffer } from '@src/synth/ds/CircularSampleBuffer';
2+
import { AlphaSynthWebAudioOutputBase } from './AlphaSynthWebAudioOutputBase';
3+
4+
// tslint:disable: deprecation
5+
6+
/**
7+
* This class implements a HTML5 Web Audio API based audio output device
8+
* for alphaSynth using the legacy ScriptProcessor node.
9+
* @target web
10+
*/
11+
export class AlphaSynthScriptProcessorOutput extends AlphaSynthWebAudioOutputBase {
12+
private _audioNode: ScriptProcessorNode | null = null;
13+
private _circularBuffer!: CircularSampleBuffer;
14+
private _bufferCount: number = 0;
15+
private _requestedBufferCount: number = 0;
16+
17+
public open() {
18+
super.open();
19+
this._bufferCount = Math.floor(
20+
(AlphaSynthWebAudioOutputBase.TotalBufferTimeInMilliseconds * this.sampleRate) /
21+
1000 /
22+
AlphaSynthWebAudioOutputBase.BufferSize
23+
);
24+
this._circularBuffer = new CircularSampleBuffer(AlphaSynthWebAudioOutputBase.BufferSize * this._bufferCount);
25+
this.onReady();
26+
}
27+
28+
public play(): void {
29+
super.play();
30+
let ctx = this._context!;
31+
// create a script processor node which will replace the silence with the generated audio
32+
this._audioNode = ctx.createScriptProcessor(4096, 0, 2);
33+
this._audioNode.onaudioprocess = this.generateSound.bind(this);
34+
this._circularBuffer.clear();
35+
this.requestBuffers();
36+
this._source = ctx.createBufferSource();
37+
this._source.buffer = this._buffer;
38+
this._source.loop = true;
39+
this._source.connect(this._audioNode, 0, 0);
40+
this._source.start(0);
41+
this._audioNode.connect(ctx.destination, 0, 0);
42+
}
43+
44+
public pause(): void {
45+
super.pause();
46+
if (this._audioNode) {
47+
this._audioNode.disconnect(0);
48+
}
49+
this._audioNode = null;
50+
}
51+
52+
public addSamples(f: Float32Array): void {
53+
this._circularBuffer.write(f, 0, f.length);
54+
this._requestedBufferCount--;
55+
}
56+
57+
public resetSamples(): void {
58+
this._circularBuffer.clear();
59+
}
60+
61+
private requestBuffers(): void {
62+
// if we fall under the half of buffers
63+
// we request one half
64+
const halfBufferCount = (this._bufferCount / 2) | 0;
65+
let halfSamples: number = halfBufferCount * AlphaSynthWebAudioOutputBase.BufferSize;
66+
// Issue #631: it can happen that requestBuffers is called multiple times
67+
// before we already get samples via addSamples, therefore we need to
68+
// remember how many buffers have been requested, and consider them as available.
69+
let bufferedSamples =
70+
this._circularBuffer.count + this._requestedBufferCount * AlphaSynthWebAudioOutputBase.BufferSize;
71+
if (bufferedSamples < halfSamples) {
72+
for (let i: number = 0; i < halfBufferCount; i++) {
73+
this.onSampleRequest();
74+
}
75+
this._requestedBufferCount += halfBufferCount;
76+
}
77+
}
78+
79+
private _outputBuffer: Float32Array = new Float32Array(0);
80+
private generateSound(e: AudioProcessingEvent): void {
81+
let left: Float32Array = e.outputBuffer.getChannelData(0);
82+
let right: Float32Array = e.outputBuffer.getChannelData(1);
83+
let samples: number = left.length + right.length;
84+
let buffer = this._outputBuffer;
85+
if (buffer.length !== samples) {
86+
buffer = new Float32Array(samples);
87+
this._outputBuffer = buffer;
88+
}
89+
this._circularBuffer.read(buffer, 0, Math.min(buffer.length, this._circularBuffer.count));
90+
let s: number = 0;
91+
for (let i: number = 0; i < left.length; i++) {
92+
left[i] = buffer[s++];
93+
right[i] = buffer[s++];
94+
}
95+
this.onSamplesPlayed(left.length);
96+
this.requestBuffers();
97+
}
98+
}

0 commit comments

Comments
 (0)