diff --git a/.gitignore b/.gitignore index f61b880..c04882f 100644 --- a/.gitignore +++ b/.gitignore @@ -144,3 +144,4 @@ Cargo.lock *.env +examples/audio-worklet-to-worker.wav diff --git a/examples/audio-worklet-to-worker.js b/examples/audio-worklet-to-worker.js new file mode 100644 index 0000000..fe33d44 --- /dev/null +++ b/examples/audio-worklet-to-worker.js @@ -0,0 +1,74 @@ +// ----------------------------------------------------------------------------- +// Adapted from padenot's ringbuf.js example +// https://github.com/padenot/ringbuf.js/tree/main/public/example/audioworklet-to-worker +// ----------------------------------------------------------------------------- +import path from 'node:path'; +import fs from 'node:fs'; +import { Worker } from 'node:worker_threads'; + +import { AudioContext, OscillatorNode, GainNode, StereoPannerNode, AudioWorkletNode } from '#node-web-audio-api'; +import { RingBuffer } from 'ringbuf.js'; +import { sleep } from '@ircam/sc-utils'; + +const audioContext = new AudioContext(); +await audioContext.audioWorklet.addModule(path.join('worklets', 'audio-worklet-to-worker', 'recorder-worklet.js')); +// One second of stereo Float32 PCM ought to be plentiful. +const sharedArrayBuffer = RingBuffer.getStorageForCapacity(audioContext.sampleRate * 2, Float32Array); + +// Setup the wav writer worker +const recorderWorker = new Worker('./examples/worklets/audio-worklet-to-worker/wav-writer.js', { + workerData: { + sharedArrayBuffer, + channelCount: 2, + sampleRate: audioContext.sampleRate, + }, +}); + +// Setup web audio +audioContext.resume(); + +// Generate a tone that goes left and right and up and down. Route it to an +// AudioWorkletProcessor that does the recording, as well as to the output. +const osc = new OscillatorNode(audioContext); +const fm = new OscillatorNode(audioContext, { frequency: 1.0 }); +const gain = new GainNode(audioContext, { gain: 110 }); +const panner = new StereoPannerNode(audioContext); +const panModulation = new OscillatorNode(audioContext, { frequency: 2.0 }); +const recorderWorklet = new AudioWorkletNode(audioContext, 'recorder-worklet', { + processorOptions: { sharedArrayBuffer }, +}); + +// setup graph +panModulation.connect(panner.pan); +fm.connect(gain).connect(osc.frequency); +osc.connect(panner).connect(audioContext.destination); +panner.connect(recorderWorklet); + +osc.start(0); +fm.start(0); +panModulation.start(0); + +// Starve the main thread +const mainThreadLoadIntervalId = setInterval(function() { + var start = Date.now(); + // eslint-disable-next-line no-empty + while (Date.now() - start < 90) {} +}, 100); + +await sleep(2); + +recorderWorker.on('message', async arrayBuffer => { + console.log('> main thread: stop rendering'); + + clearInterval(mainThreadLoadIntervalId); + await audioContext.close(); + recorderWorker.terminate(); + + // Replay the wav file in audio buffer source node + const pathname = path.join(import.meta.dirname, 'audio-worklet-to-worker.wav'); + console.log('> main thread: write file to disk: ', pathname); + + fs.writeFileSync(pathname, Buffer.from(arrayBuffer)); +}); + +recorderWorker.postMessage({ command: 'stop' }); diff --git a/examples/main-thread-to-audio-worklet.js b/examples/main-thread-to-audio-worklet.js new file mode 100644 index 0000000..9fff805 --- /dev/null +++ b/examples/main-thread-to-audio-worklet.js @@ -0,0 +1,61 @@ +// ----------------------------------------------------------------------------- +// Adapted from padenot's ringbuf.js example +// https://github.com/padenot/ringbuf.js/tree/main/public/example/main-thread-to-audioworklet +// ----------------------------------------------------------------------------- +import path from 'node:path'; + +import { AudioContext, AudioWorkletNode } from '#node-web-audio-api'; +import { RingBuffer, AudioWriter, ParameterWriter } from 'ringbuf.js'; + +const audioContext = new AudioContext(); +await audioContext.audioWorklet.addModule(path.join('worklets', 'main-thread-to-audio-worklet', 'processor.js')); + +let frequency = 440; +let phase = 0.0; +const sine = new Float32Array(128); + +// 50ms of buffer, increase in case of glitches +const sharedArrayBuffer = RingBuffer.getStorageForCapacity( + audioContext.sampleRate / 20, + Float32Array, +); +const ringBuffer = new RingBuffer(sharedArrayBuffer, Float32Array); +const audioWriter = new AudioWriter(ringBuffer); + +const sharedArrayBuffer2 = RingBuffer.getStorageForCapacity(31, Uint8Array); +const ringBuffer2 = new RingBuffer(sharedArrayBuffer2, Uint8Array); +const paramWriter = new ParameterWriter(ringBuffer2); + +const processor = new AudioWorkletNode(audioContext, 'processor', { + processorOptions: { + audioQueue: sharedArrayBuffer, + paramQueue: sharedArrayBuffer2, + }, +}); + +processor.connect(audioContext.destination); + +// change freq and amp every second +setInterval(() => { + frequency = Math.random() * 900 + 100; + + const gain = Math.random(); + paramWriter.enqueue_change(0, gain); + + console.log(`Frequency: ${frequency}, Gain: ${gain}`); +}, 1000); + +setInterval(() => { + // Synthetize a simple sine wave so it's easy to hear glitches, continuously + // if there is room in the ring buffer. + while (audioWriter.available_write() > 128) { + for (let i = 0; i < 128; i++) { + sine[i] = Math.sin(phase); + phase += (2 * Math.PI * frequency) / audioContext.sampleRate; + if (phase > 2 * Math.PI) { + phase -= 2 * Math.PI; + } + } + audioWriter.enqueue(sine); + } +}, 10); diff --git a/examples/worklets/audio-worklet-to-worker/recorder-worklet.js b/examples/worklets/audio-worklet-to-worker/recorder-worklet.js new file mode 100644 index 0000000..882024e --- /dev/null +++ b/examples/worklets/audio-worklet-to-worker/recorder-worklet.js @@ -0,0 +1,31 @@ + +// ----------------------------------------------------------------------------- +// Adapted from padenot's ringbuf.js example +// https://github.com/padenot/ringbuf.js/tree/main/public/example/audioworklet-to-worker +// ----------------------------------------------------------------------------- + +import { AudioWriter, RingBuffer, interleave } from 'ringbuf.js'; + +class RecorderWorklet extends AudioWorkletProcessor { + constructor(options) { + super(); + // Staging buffer to interleave the audio data. + this.interleaved = new Float32Array(128 * 2); // stereo + const { sharedArrayBuffer } = options.processorOptions; + this.audioWriter = new AudioWriter(new RingBuffer(sharedArrayBuffer, Float32Array)); + } + + process(inputs, _outputs, _parameters) { + // interleave and store in the queue + if (inputs[0]) { + interleave(inputs[0], this.interleaved); + + if (this.audioWriter.enqueue(this.interleaved) !== 256) { + console.log(`underrun: the worker doesn't dequeue fast enough!`); + } + } + return true; + } +} + +registerProcessor("recorder-worklet", RecorderWorklet); diff --git a/examples/worklets/audio-worklet-to-worker/wav-writer.js b/examples/worklets/audio-worklet-to-worker/wav-writer.js new file mode 100644 index 0000000..cf03fa8 --- /dev/null +++ b/examples/worklets/audio-worklet-to-worker/wav-writer.js @@ -0,0 +1,118 @@ + +// ----------------------------------------------------------------------------- +// Adapted from padenot's ringbuf.js example +// https://github.com/padenot/ringbuf.js/tree/main/public/example/audioworklet-to-worker +// ----------------------------------------------------------------------------- + +import { AudioReader, RingBuffer } from 'ringbuf.js'; +import { + parentPort, + workerData, +} from 'node:worker_threads'; + +const { + sharedArrayBuffer, + // The number of channels of the audio stream read from the queue. + channelCount, + // The sample-rate of the audio stream read from the queue. + sampleRate, +} = workerData; + +const audioReader = new AudioReader( + new RingBuffer(sharedArrayBuffer, Float32Array), +); + +// Store the audio data, segment by segments, as array of int16 samples. +const pcm = []; +// A smaller staging array to copy the audio samples from, before conversion +// to uint16. It's size is 4 times less than the 1 second worth of data +// that the ring buffer can hold, so it's 250ms, allowing to not make +// deadlines: +// staging buffer size = ring buffer size / sizeof(float32) / stereo / 4 +const staging = new Float32Array(sharedArrayBuffer.byteLength / 4 / 4 / 2); +// Attempt to dequeue every 100ms. Making this deadline isn't critical: +// there's 1 second worth of space in the queue, and we'll be dequeing +const readQueueIntervalId = setInterval(readFromQueue, 100); + +// Read some float32 pcm from the queue, convert to int16 pcm, and push it to our global queue +function readFromQueue() { + const samplesRead = audioReader.dequeue(staging); + if (!samplesRead) { + return 0; + } + + const segment = new Int16Array(samplesRead); + + for (let i = 0; i < samplesRead; i++) { + segment[i] = Math.min(Math.max(staging[i], -1.0), 1.0) * (2 << 14 - 1); + } + + pcm.push(segment); + + return samplesRead; +} + +parentPort.on('message', e => { + switch (e.command) { + case "stop": { + clearInterval(readQueueIntervalId); + // Drain the ring buffer + while (readFromQueue()) { + /* empty */ + } + + // Structure of a wav file, with a byte offset for the values to modify: + // sample-rate, channel count, block align. + const CHANNEL_OFFSET = 22; + const SAMPLE_RATE_OFFSET = 24; + const BLOCK_ALIGN_OFFSET = 32; + const header = [ + // RIFF header + 0x52, 0x49, 0x46, 0x46, 0x00, 0x00, 0x00, 0x00, 0x57, 0x41, 0x56, 0x45, + // fmt chunk. We always write 16-bit samples. + 0x66, 0x6d, 0x74, 0x20, 0x10, 0x00, 0x00, 0x00, 0x01, 0x00, 0xff, 0xff, + 0xff, 0xff, 0xff, 0xff, 0x00, 0x00, 0x00, 0x00, 0xff, 0xff, 0x10, 0x00, + // data chunk + 0x64, 0x61, 0x74, 0x61, 0xfe, 0xff, 0xff, 0x7f, + ]; + // Find final size: size of the header + number of samples * channel count + // * 2 because pcm16 + let size = header.length; + for (let i = 0; i < pcm.length; i++) { + size += pcm[i].length * 2; + } + const wav = new Uint8Array(size); + const view = new DataView(wav.buffer); + + // Copy the header, and modify the values: note that RIFF + // is little-endian, we need to pass `true` as the last param. + for (let i = 0; i < wav.length; i++) { + wav[i] = header[i]; + } + + console.log( + `> wav writer thread: sending back wav file: ${sampleRate}Hz, ${channelCount} channels, int16` + ); + + view.setUint16(CHANNEL_OFFSET, channelCount, true); + view.setUint32(SAMPLE_RATE_OFFSET, sampleRate, true); + view.setUint16(BLOCK_ALIGN_OFFSET, channelCount * 2, true); + + // Finally, copy each segment in order as int16, and transfer the array + // back to the main thread for download. + let writeIndex = header.length; + + for (let segment = 0; segment < pcm.length; segment++) { + for (let sample = 0; sample < pcm[segment].length; sample++) { + view.setInt16(writeIndex, pcm[segment][sample], true); + writeIndex += 2; + } + } + parentPort.postMessage(wav.buffer, [wav.buffer]); + break; + } + default: { + throw Error("Case not handled"); + } + } +}); diff --git a/examples/worklets/main-thread-to-audio-worklet/processor.js b/examples/worklets/main-thread-to-audio-worklet/processor.js new file mode 100644 index 0000000..46acd03 --- /dev/null +++ b/examples/worklets/main-thread-to-audio-worklet/processor.js @@ -0,0 +1,45 @@ +// ----------------------------------------------------------------------------- +// Adapted from padenot's ringbuf.js example +// https://github.com/padenot/ringbuf.js/tree/main/public/example/main-thread-to-audioworklet +// ----------------------------------------------------------------------------- +import { AudioReader, ParameterReader, RingBuffer } from 'ringbuf.js'; + +class Processor extends AudioWorkletProcessor { + static get parameterDescriptors() { + return []; + } + + constructor(options) { + super(options); + this.interleaved = new Float32Array(128); + this.amp = 1.0; + this.o = { index: 0, value: 0 }; + + const { audioQueue, paramQueue } = options.processorOptions; + + this.audioReader = new AudioReader( + new RingBuffer(audioQueue, Float32Array) + ); + this.paramReader = new ParameterReader( + new RingBuffer(paramQueue, Uint8Array) + ); + } + + process(inputs, outputs, parameters) { + // get any param changes + if (this.paramReader.dequeue_change(this.o)) { + this.amp = this.o.value; + } + + // read 128 frames from the queue, [deinterleave,] and write to output buffers. + this.audioReader.dequeue(this.interleaved); + + for (let i = 0; i < 128; i++) { + outputs[0][0][i] = this.amp * this.interleaved[i]; + } + + return true; + } +} + +registerProcessor("processor", Processor); diff --git a/package.json b/package.json index 5ba60b6..d4baacb 100644 --- a/package.json +++ b/package.json @@ -67,6 +67,7 @@ "js-beautify": "^1.15.1", "mocha": "^11.0.1", "octokit": "^5.0.5", + "ringbuf.js": "^0.4.0", "template-literal": "^1.0.4", "webidl2": "^24.2.0", "wpt-runner": "^7.0.0"