diff --git a/.github/workflows/build-native.yml b/.github/workflows/build-native.yml index e34659b1d..d1261534f 100644 --- a/.github/workflows/build-native.yml +++ b/.github/workflows/build-native.yml @@ -12,6 +12,8 @@ # canvas-windows- canvasnative.node + ANGLE DLLs packages/canvas/platforms/windows// # canvas-svg-windows- canvassvg.node packages/canvas-svg/platforms/windows// # +# audio-context-windows- audiocontext.node packages/audio-context/platforms/windows// +# # # xcframeworks are tarred before upload because upload-artifact does not # preserve file permissions. @@ -21,11 +23,12 @@ # (what `npm run build.canvas.ios.framework.release` runs, minus nx) # Android: make android, then gradle :canvas:assembleRelease and copy the aar # -# Windows: tools/scripts/build-napi.sh release-napi [canvas-svg-napi] (`make windows`, -# `make windows-svg`); Skia builds from source with the runner's LLVM and ninja, as -# locally with .tools/env.sh (.github/actions/setup-windows-native). arm64 is -# cross-compiled; the x64 jobs also run the Node-API test suites on the shipped modules -# (on WARP: runners have no GPU). +# Windows: tools/scripts/build-napi.sh release-napi [canvas-svg-napi|audio-context-napi] +# (`make windows`, `make windows-svg`, `make windows-audio`); Skia builds from source +# with the runner's LLVM and ninja, as locally with .tools/env.sh +# (.github/actions/setup-windows-native). arm64 is cross-compiled; the x64 jobs also +# run the Node-API test suites on the shipped modules (on WARP: runners have no GPU; +# audio renders offline and on the 'none' sink: they have no audio device either). # # canvas-svg and audio-context change rarely, so their jobs cache the final # artifact keyed on a hash of their sources and skip the whole build on a hit. @@ -323,3 +326,51 @@ jobs: name: canvas-svg-windows-${{ matrix.arch }} path: packages/canvas-svg/platforms/windows/${{ matrix.arch }}/ if-no-files-found: error + + audio-context-windows: + name: Audio Context Windows (${{ matrix.arch }}) + runs-on: windows-2025 + strategy: + fail-fast: false + matrix: + include: + - arch: x64 + target: x86_64-pc-windows-msvc + - arch: arm64 + target: aarch64-pc-windows-msvc + defaults: + run: + shell: bash + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ inputs.ref }} + - name: Restore cached module + id: audio-node + uses: actions/cache@v4 + with: + path: packages/audio-context/platforms/windows/${{ matrix.arch }}/audiocontext.node + key: ${{ env.NATIVE_CACHE_VERSION }}-audio-context-windows-${{ matrix.arch }}-${{ hashFiles('crates/audio-context-napi/**', 'crates/vendor/napi-sys/**', 'Cargo.toml', 'Cargo.lock', 'tools/scripts/build-napi.sh') }} + # No Skia here; the action's Rust toolchain and cargo cache are what this job needs. + - uses: ./.github/actions/setup-windows-native + if: steps.audio-node.outputs.cache-hit != 'true' + with: + target: ${{ matrix.target }} + - uses: actions/setup-node@v4 + if: steps.audio-node.outputs.cache-hit != 'true' && matrix.arch == 'x64' + with: + node-version: '22' + - name: Build audiocontext.node + if: steps.audio-node.outputs.cache-hit != 'true' + run: ./tools/scripts/build-napi.sh ${{ matrix.target }} release-napi audio-context-napi + # Runners have no audio device: the suite renders offline and on the 'none' sink. + - name: Node-API tests + if: steps.audio-node.outputs.cache-hit != 'true' && matrix.arch == 'x64' + env: + AUDIO_CONTEXT_NAPI_ADDON: ${{ github.workspace }}\packages\audio-context\platforms\windows\x64\audiocontext.node + run: node --expose-gc --test "crates/audio-context-napi/__test__/*.test.mjs" + - uses: actions/upload-artifact@v4 + with: + name: audio-context-windows-${{ matrix.arch }} + path: packages/audio-context/platforms/windows/${{ matrix.arch }}/ + if-no-files-found: error diff --git a/.github/workflows/pull-request.yml b/.github/workflows/pull-request.yml index 64d094227..15c7506e2 100644 --- a/.github/workflows/pull-request.yml +++ b/.github/workflows/pull-request.yml @@ -96,7 +96,7 @@ jobs: tar -xzf native-artifacts/audio-context-ios/audio-context-ios.tar.gz -C packages/audio-context/platforms/ios # Node-API modules (+ ANGLE DLLs) per Windows arch for arch in x64 arm64; do - for pkg in canvas canvas-svg; do + for pkg in canvas canvas-svg audio-context; do rm -rf "packages/$pkg/platforms/windows/$arch" mkdir -p "packages/$pkg/platforms/windows/$arch" cp native-artifacts/$pkg-windows-$arch/* "packages/$pkg/platforms/windows/$arch/" diff --git a/Cargo.toml b/Cargo.toml index 9ea852e60..c81120cc4 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -14,6 +14,7 @@ members = [ "crates/canvas-svg-c", "crates/canvas-napi", "crates/canvas-svg-napi", + "crates/audio-context-napi", "crates/canvas-svg-ios", "crates/canvas-svg-android", ] diff --git a/Makefile b/Makefile index bacff9d2d..f5d0cade1 100644 --- a/Makefile +++ b/Makefile @@ -22,6 +22,8 @@ windows: $(ARCHS_WINDOWS) windows-svg: $(addsuffix _svg_napi,$(ARCHS_WINDOWS)) +windows-audio: $(addsuffix _audio_napi,$(ARCHS_WINDOWS)) + ios-svg: GENERATE_IOS_SVG visionos-svg: GENERATE_VISIONOS_SVG @@ -92,6 +94,10 @@ $(ARCHS_WINDOWS): %: $(addsuffix _svg_napi,$(ARCHS_WINDOWS)): %_svg_napi: ./tools/scripts/build-napi.sh $* release-napi canvas-svg-napi +.PHONY: windows-audio $(addsuffix _audio_napi,$(ARCHS_WINDOWS)) +$(addsuffix _audio_napi,$(ARCHS_WINDOWS)): %_audio_napi: + ./tools/scripts/build-napi.sh $* release-napi audio-context-napi + .PHONY: GENERATE_ANDROID GENERATE_ANDROID: $(ARCHS_ANDROID) diff --git a/crates/audio-context-napi/Cargo.toml b/crates/audio-context-napi/Cargo.toml new file mode 100644 index 000000000..706cd1465 --- /dev/null +++ b/crates/audio-context-napi/Cargo.toml @@ -0,0 +1,24 @@ +[package] +name = "audio-context-napi" +version = "0.1.0" +edition = "2021" +publish = false +description = "Node-API binding for @nativescript/audio-context: a Web Audio graph rendered by the web-audio-api crate and played through WASAPI (cpal), for Node-API hosts (NativeScript Windows, Node)." + +[lib] +crate-type = ["cdylib"] + +[features] +default = ["napi8"] +napi8 = ["napi/napi8"] +# Hosts whose Node-API lacks type tags (NativeScript Windows on JavaScriptCore). +napi7 = ["napi/napi7"] + +[dependencies] +napi = { version = "3.13.0", default-features = false, features = ["dyn-symbols"] } +napi-derive = "3.6.9" +web-audio-api = "1.7.0" +base64 = "0.22.1" + +[build-dependencies] +napi-build = "2.5.0" diff --git a/crates/audio-context-napi/__test__/audio.test.mjs b/crates/audio-context-napi/__test__/audio.test.mjs new file mode 100644 index 000000000..931d72ee0 --- /dev/null +++ b/crates/audio-context-napi/__test__/audio.test.mjs @@ -0,0 +1,223 @@ +// Offline contexts and the 'none' sink: no audio device needed. +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import path from 'node:path'; +import test from 'node:test'; +import url from 'node:url'; + +const here = path.dirname(url.fileURLToPath(import.meta.url)); +const root = path.resolve(here, '../../..'); +const file = { win32: 'audio_context_napi.dll', darwin: 'libaudio_context_napi.dylib', linux: 'libaudio_context_napi.so' }[process.platform]; +const addon = [process.env.AUDIO_CONTEXT_NAPI_ADDON, path.join(root, 'target', 'debug', file), path.join(root, 'target', 'release-napi', file)].filter(Boolean).find((f) => fs.existsSync(f)); +const module = { exports: {} }; +process.dlopen(module, addon); +const Audio = module.exports; +const assets = path.join(root, 'apps', 'demo', 'src', 'assets', 'file-assets', 'audio'); + +const rms = (samples) => Math.sqrt(samples.reduce((sum, v) => sum + v * v, 0) / samples.length); + +test('exports the classes index.windows.ts builds on', () => { + for (const name of ['AudioContext', 'AudioNode', 'AudioParam', 'AudioBuffer', 'PeriodicWave']) { + assert.equal(typeof Audio[name], 'function', name); + } +}); + +test('renders an oscillator through a gain offline', async () => { + const context = Audio.AudioContext.offline(1, 4800, 48000); + assert.equal(context.sampleRate, 48000); + assert.equal(context.length, 4800); + const oscillator = context.createOscillator(); + oscillator.type = 'square'; + assert.equal(oscillator.type, 'square'); + oscillator.param('frequency').value = 1000; + const gain = context.createGain(); + gain.param('gain').value = 0.5; + oscillator.connect(gain); + gain.connect(context.destination()); + oscillator.start(0); + const rendered = await context.startRendering(); + assert.equal(rendered.length, 4800); + assert.equal(rendered.numberOfChannels, 1); + const samples = rendered.channelData(0); + assert.ok(Math.abs(rms(samples.subarray(480)) - 0.5) < 0.02, `rms ${rms(samples)}`); + assert.equal(context.state, 'closed'); + await assert.rejects(async () => context.startRendering(), /InvalidStateError/); +}); + +test('automates params on the timeline', async () => { + const context = Audio.AudioContext.offline(1, 48000, 48000); + const source = context.createConstantSource(); + const offset = source.param('offset'); + assert.equal(offset.defaultValue, 1); + offset.setValueAtTime(0, 0); + offset.linearRampToValueAtTime(1, 1); + source.connect(context.destination()); + source.start(); + const samples = (await context.startRendering()).channelData(0); + assert.ok(Math.abs(samples[24000] - 0.5) < 0.01, `${samples[24000]}`); + assert.ok(samples[47999] > 0.99); +}); + +test('plays buffers with loops, offsets and ended events', async () => { + const context = Audio.AudioContext.offline(2, 9600, 48000); + const left = new Float32Array(4800).fill(0.25); + const right = new Float32Array(4800).fill(-0.25); + const buffer = Audio.AudioBuffer.fromChannels([left, right], 48000); + assert.equal(buffer.numberOfChannels, 2); + assert.equal(buffer.duration, 0.1); + const source = context.createBufferSource(); + source.setBuffer(buffer); + assert.throws(() => source.setBuffer(buffer), /InvalidStateError/); + source.loop = true; + source.connect(context.destination()); + let ended = false; + source.setOnended(() => (ended = true)); + source.start(0, 0, 0.15); + assert.throws(() => source.start(), /InvalidStateError/); + const rendered = await context.startRendering(); + const out = new Float32Array(9600); + rendered.copyFromChannel(out, 1, 0); + assert.equal(out[100], -0.25); + assert.equal(out[7000], -0.25); + assert.equal(out[7300], 0); + await new Promise((resolve) => setTimeout(resolve, 50)); + assert.ok(ended, 'onended'); +}); + +test('reports spec errors as exceptions instead of aborting', () => { + const context = Audio.AudioContext.offline(1, 128, 48000); + const other = Audio.AudioContext.offline(1, 128, 48000); + const gain = context.createGain(); + assert.throws(() => gain.connect(other.createGain()), /InvalidAccessError/); + assert.throws(() => gain.connect(context.createGain(), 3), /IndexSizeError/); + assert.throws(() => context.createOscillator().stop(), /InvalidStateError/); + assert.throws(() => (context.createAnalyser().fftSize = 1000), /IndexSizeError/); + assert.throws(() => gain.fftSize, /GainNode has no member 'fftSize'/); + assert.throws(() => (context.createOscillator().type = 'nope'), /TypeError/); + assert.equal(gain.param('frequency'), null); +}); + +test('connects nodes to params and splits channels', async () => { + const context = Audio.AudioContext.offline(2, 1280, 48000); + const lfo = context.createConstantSource(); + lfo.param('offset').value = 0.25; + const carrier = context.createConstantSource(); + const gain = context.createGain(); + gain.param('gain').value = 0; + lfo.connectParam(gain.param('gain')); + carrier.connect(gain); + const merger = context.createChannelMerger(2); + assert.equal(merger.numberOfInputs, 2); + gain.connect(merger, 0, 1); + merger.connect(context.destination()); + lfo.start(); + carrier.start(); + const rendered = await context.startRendering(); + assert.equal(rendered.channelData(0)[640], 0); + assert.equal(rendered.channelData(1)[640], 0.25); +}); + +test('filters, analyses, shapes and compresses', async () => { + const context = Audio.AudioContext.offline(1, 4096, 48000); + const filter = context.createBiquadFilter(); + filter.type = 'lowpass'; + filter.param('frequency').value = 1000; + const frequencies = new Float32Array([100, 10000]); + const magnitude = new Float32Array(2); + const phase = new Float32Array(2); + filter.getFrequencyResponse(frequencies, magnitude, phase); + assert.ok(magnitude[0] > 0.9 && magnitude[1] < 0.05, `${magnitude}`); + + const iir = context.createIirFilter([0.5, 0.5], [1]); + iir.getFrequencyResponse(new Float32Array([0]), magnitude.subarray(0, 1), phase.subarray(0, 1)); + assert.ok(Math.abs(magnitude[0] - 1) < 1e-4); + + const shaper = context.createWaveShaper(); + shaper.setCurve(new Float32Array([-0.5, 0, 0.5])); + shaper.oversample = '2x'; + assert.equal(shaper.oversample, '2x'); + const compressor = context.createDynamicsCompressor(); + assert.equal(compressor.param('threshold').value, -24); + assert.equal(compressor.reduction, 0); + + const analyser = context.createAnalyser(); + analyser.fftSize = 512; + assert.equal(analyser.frequencyBinCount, 256); + const oscillator = context.createOscillator(); + oscillator.connect(analyser); + analyser.connect(context.destination()); + oscillator.start(); + await context.startRendering(); + const time = new Float32Array(512); + analyser.getFloatTimeDomainData(time); + assert.ok(Math.max(...time) > 0.9); + const bytes = new Uint8Array(256); + analyser.getByteFrequencyData(bytes); + assert.ok(bytes.some((b) => b > 200)); +}); + +test('pans with the listener and HRTF', async () => { + const context = Audio.AudioContext.offline(2, 2048, 44100); + assert.equal(context.listenerParam('upY').value, 1); + assert.equal(context.listenerParam('nope'), null); + const panner = context.createPanner(); + panner.panningModel = 'HRTF'; + panner.distanceModel = 'linear'; + panner.refDistance = 2; + assert.deepEqual([panner.panningModel, panner.distanceModel, panner.refDistance], ['HRTF', 'linear', 2]); + panner.param('positionX').value = 5; + const source = context.createConstantSource(); + source.connect(panner); + panner.connect(context.destination()); + source.start(); + const rendered = await context.startRendering(); + assert.ok(rms(rendered.channelData(1).subarray(1024)) > rms(rendered.channelData(0).subarray(1024))); +}); + +test('uses a custom periodic wave', async () => { + const context = Audio.AudioContext.offline(1, 480, 48000); + const wave = context.createPeriodicWave(new Float32Array([0, 0]), new Float32Array([0, 1]), false); + const oscillator = context.createOscillator(); + oscillator.setPeriodicWave(wave); + assert.equal(oscillator.type, 'custom'); + oscillator.connect(context.destination()); + oscillator.start(); + assert.ok(rms((await context.startRendering()).channelData(0)) > 0.5); +}); + +test('decodes wav, mp3 and ogg, resampled to the context rate', async () => { + const context = Audio.AudioContext.offline(1, 128, 48000); + const wav = await context.decodeAudioFile(path.join(assets, 'gs-16b-1c-44100hz.wav')); + assert.equal(wav.sampleRate, 48000); + assert.equal(wav.numberOfChannels, 1); + assert.ok(wav.duration > 15, `${wav.duration}`); + const mp3 = await context.decodeAudioData(fs.readFileSync(path.join(assets, 'sine441stereo.mp3'))); + assert.equal(mp3.numberOfChannels, 2); + const ogg = await context.decodeAudioFile(path.join(assets, 'ogg_sample.ogg')); + assert.ok(ogg.length > 0); + const base64 = fs.readFileSync(path.join(assets, 'gs-16b-1c-44100hz.wav')).toString('base64'); + assert.equal((await context.decodeAudioBase64(base64)).length, wav.length); + assert.equal((await context.decodeAudioBase64(`data:audio/wav;base64,${base64}`)).length, wav.length); + await assert.rejects(context.decodeAudioBase64('not base64!'), /EncodingError/); + await assert.rejects(context.decodeAudioData(new Uint8Array([1, 2, 3, 4])), /EncodingError/); + await assert.rejects(context.decodeAudioFile(path.join(assets, 'missing.wav')), /NotFoundError/); +}); + +test('runs a realtime context without a device and reports its state', async () => { + const context = new Audio.AudioContext({ sinkId: 'none', latencyHint: 'playback', sampleRate: 44100 }); + assert.equal(context.sampleRate, 44100); + assert.equal(context.sinkId, 'none'); + const states = []; + context.setOnstatechange(() => states.push(context.state)); + await context.suspend(); + assert.equal(context.state, 'suspended'); + await context.resume(); + const start = context.currentTime; + await new Promise((resolve) => setTimeout(resolve, 200)); + assert.ok(context.currentTime > start, `${context.currentTime} > ${start}`); + await context.close(); + assert.equal(context.state, 'closed'); + await new Promise((resolve) => setTimeout(resolve, 50)); + assert.ok(states.includes('suspended') && states.at(-1) === 'closed', states.join()); + assert.throws(() => context.startRendering(), /InvalidStateError/); +}); diff --git a/crates/audio-context-napi/build.rs b/crates/audio-context-napi/build.rs new file mode 100644 index 000000000..0f1b01002 --- /dev/null +++ b/crates/audio-context-napi/build.rs @@ -0,0 +1,3 @@ +fn main() { + napi_build::setup(); +} diff --git a/crates/audio-context-napi/package.json b/crates/audio-context-napi/package.json new file mode 100644 index 000000000..6820ea6af --- /dev/null +++ b/crates/audio-context-napi/package.json @@ -0,0 +1,19 @@ +{ + "name": "@nativescript/audio-context-napi", + "private": true, + "version": "0.1.0", + "description": "Node-API build of the audio-context native module (Web Audio on the web-audio-api crate).", + "napi": { + "binaryName": "audiocontext", + "targets": [ + "x86_64-pc-windows-msvc", + "aarch64-pc-windows-msvc" + ] + }, + "scripts": { + "test": "node --expose-gc --test \"__test__/*.test.mjs\"" + }, + "engines": { + "node": ">=20" + } +} diff --git a/crates/audio-context-napi/src/buffer.rs b/crates/audio-context-napi/src/buffer.rs new file mode 100644 index 000000000..fd323fbc6 --- /dev/null +++ b/crates/audio-context-napi/src/buffer.rs @@ -0,0 +1,75 @@ +use napi::bindgen_prelude::{Float32Array, Float32ArraySlice}; +use napi::Result; +use napi_derive::napi; +use web_audio_api::AudioBufferOptions; + +use crate::guard; + +/// Non-interleaved f32 samples. Immutable once built: the JS `AudioBuffer` keeps its own +/// channel arrays and builds a new one from them whenever a node acquires the buffer. +#[napi] +pub struct AudioBuffer { + pub(crate) inner: web_audio_api::AudioBuffer, +} + +#[napi] +impl AudioBuffer { + #[napi(constructor)] + pub fn new(number_of_channels: u32, length: u32, sample_rate: f64) -> Result { + let options = AudioBufferOptions { + number_of_channels: number_of_channels as usize, + length: length as usize, + sample_rate: sample_rate as f32, + }; + guard(|| Self { inner: web_audio_api::AudioBuffer::new(options) }) + } + + /// A buffer holding a copy of `channels` (all of one length). + #[napi(factory)] + pub fn from_channels(channels: Vec, sample_rate: f64) -> Result { + let samples = channels.iter().map(|channel| channel.to_vec()).collect(); + guard(|| Self { inner: web_audio_api::AudioBuffer::from(samples, sample_rate as f32) }) + } + + #[napi(getter)] + pub fn sample_rate(&self) -> f64 { + self.inner.sample_rate() as f64 + } + + #[napi(getter)] + pub fn length(&self) -> u32 { + self.inner.length() as u32 + } + + #[napi(getter)] + pub fn duration(&self) -> f64 { + self.inner.duration() + } + + #[napi(getter)] + pub fn number_of_channels(&self) -> u32 { + self.inner.number_of_channels() as u32 + } + + /// A copy of one channel's samples. + #[napi] + pub fn channel_data(&self, channel: u32) -> Result { + guard(|| Float32Array::new(self.inner.get_channel_data(channel as usize).to_vec())) + } + + #[napi] + pub fn copy_from_channel(&self, mut destination: Float32ArraySlice, channel: u32, start_in_channel: u32) -> Result<()> { + let destination = unsafe { destination.as_mut() }; + guard(|| { + self + .inner + .copy_from_channel_with_offset(destination, channel as usize, start_in_channel as usize) + }) + } +} + +/// A custom oscillator waveform. +#[napi] +pub struct PeriodicWave { + pub(crate) inner: web_audio_api::PeriodicWave, +} diff --git a/crates/audio-context-napi/src/context.rs b/crates/audio-context-napi/src/context.rs new file mode 100644 index 000000000..014e941b3 --- /dev/null +++ b/crates/audio-context-napi/src/context.rs @@ -0,0 +1,414 @@ +use std::io::Cursor; + +use base64::Engine as _; +use std::sync::{Arc, Mutex}; + +use napi::bindgen_prelude::{AsyncTask, Either, Float32ArraySlice, Function, Uint8ArraySlice}; +use napi::threadsafe_function::{ThreadsafeFunctionCallMode, UnknownReturnValue}; +use napi::{Env, Result, Task}; +use napi_derive::napi; +use web_audio_api::context::{ + AudioContextLatencyCategory, AudioContextOptions, AudioContextState, BaseAudioContext, ConcreteBaseAudioContext, + OfflineAudioContext, +}; +use web_audio_api::PeriodicWaveOptions; + +use crate::buffer::{AudioBuffer, PeriodicWave}; +use crate::node::{AudioNode, Kind}; +use crate::param::AudioParam; +use crate::{error, guard}; + +#[napi(object)] +#[derive(Default)] +pub struct ContextOptions { + /// Hz; the output device's rate when unset. + pub sample_rate: Option, + /// `'interactive' | 'balanced' | 'playback'` or seconds. + pub latency_hint: Option>, + /// An output device id, `''` for the default device or `'none'` to render without one. + pub sink_id: Option, +} + +enum Backing { + Online(Arc), + /// Taken by `startRendering`. + Offline(Mutex>, usize), +} + +type StateChangeCallback<'a> = Function<'a, (), UnknownReturnValue>; + +/// An `AudioContext` or an `OfflineAudioContext`. Nodes are made on the shared base, so both +/// kinds build graphs the same way. +#[napi] +pub struct AudioContext { + base: ConcreteBaseAudioContext, + backing: Backing, +} + +impl AudioContext { + fn online(&self) -> Result<&Arc> { + match &self.backing { + Backing::Online(context) => Ok(context), + Backing::Offline(..) => Err(error("InvalidStateError: not supported by an OfflineAudioContext")), + } + } + + fn node(&self, make: impl FnOnce(&ConcreteBaseAudioContext) -> Kind) -> Result { + guard(|| AudioNode::new(make(&self.base))) + } +} + +#[napi] +impl AudioContext { + /// A realtime context on an output device (WASAPI). + #[napi(constructor)] + pub fn new(options: Option) -> Result { + let options = options.unwrap_or_default(); + let latency_hint = match options.latency_hint { + Some(Either::A(hint)) => match hint.as_str() { + "balanced" => AudioContextLatencyCategory::Balanced, + "playback" => AudioContextLatencyCategory::Playback, + _ => AudioContextLatencyCategory::Interactive, + }, + Some(Either::B(seconds)) if seconds > 0.0 => AudioContextLatencyCategory::Custom(seconds), + _ => AudioContextLatencyCategory::Interactive, + }; + let options = AudioContextOptions { + latency_hint, + sample_rate: options.sample_rate.filter(|rate| *rate > 0.0).map(|rate| rate as f32), + sink_id: options.sink_id.unwrap_or_default(), + ..Default::default() + }; + let context = guard(|| web_audio_api::context::AudioContext::try_new(options))? + .map_err(|e| error(format!("NotSupportedError: no audio output ({e})")))?; + Ok(Self { + base: context.base().clone(), + backing: Backing::Online(Arc::new(context)), + }) + } + + #[napi(factory)] + pub fn offline(number_of_channels: u32, length: u32, sample_rate: f64) -> Result { + let context = guard(|| OfflineAudioContext::new(number_of_channels as usize, length as usize, sample_rate as f32))?; + Ok(Self { + base: context.base().clone(), + backing: Backing::Offline(Mutex::new(Some(context)), length as usize), + }) + } + + #[napi(getter)] + pub fn sample_rate(&self) -> f64 { + self.base.sample_rate() as f64 + } + + #[napi(getter)] + pub fn current_time(&self) -> f64 { + self.base.current_time() + } + + #[napi(getter)] + pub fn state(&self) -> &'static str { + match self.base.state() { + AudioContextState::Suspended => "suspended", + AudioContextState::Running => "running", + AudioContextState::Closed => "closed", + } + } + + #[napi(getter)] + pub fn base_latency(&self) -> f64 { + self.online().map(|context| context.base_latency()).unwrap_or(0.0) + } + + #[napi(getter)] + pub fn output_latency(&self) -> f64 { + self.online().map(|context| context.output_latency()).unwrap_or(0.0) + } + + #[napi(getter)] + pub fn sink_id(&self) -> String { + self.online().map(|context| context.sink_id()).unwrap_or_default() + } + + /// Offline contexts: the length of the rendered buffer, in frames. + #[napi(getter)] + pub fn length(&self) -> u32 { + match &self.backing { + Backing::Offline(_, length) => *length as u32, + Backing::Online(_) => 0, + } + } + + /// `callback` runs on the JS thread after each state change; `null` clears it. + #[napi(ts_args_type = "callback: (() => void) | null")] + pub fn set_onstatechange(&self, callback: Option) -> Result<()> { + match callback { + Some(callback) => { + let tsfn = callback + .build_threadsafe_function::<()>() + .weak::() + .build_callback(|_| Ok(()))?; + self.base.set_onstatechange(move |_| { + tsfn.call((), ThreadsafeFunctionCallMode::NonBlocking); + }); + } + None => self.base.clear_onstatechange(), + } + Ok(()) + } + + #[napi(ts_return_type = "Promise")] + pub fn resume(&self) -> Result> { + Ok(AsyncTask::new(StateTask { context: self.online()?.clone(), change: StateChange::Resume })) + } + + #[napi(ts_return_type = "Promise")] + pub fn suspend(&self) -> Result> { + Ok(AsyncTask::new(StateTask { context: self.online()?.clone(), change: StateChange::Suspend })) + } + + #[napi(ts_return_type = "Promise")] + pub fn close(&self) -> Result> { + Ok(AsyncTask::new(StateTask { context: self.online()?.clone(), change: StateChange::Close })) + } + + #[napi(ts_return_type = "Promise")] + pub fn set_sink_id(&self, sink_id: String) -> Result> { + Ok(AsyncTask::new(StateTask { context: self.online()?.clone(), change: StateChange::Sink(sink_id) })) + } + + /// Renders the graph once, off the JS thread. + #[napi(ts_return_type = "Promise")] + pub fn start_rendering(&self) -> Result> { + let Backing::Offline(context, _) = &self.backing else { + return Err(error("InvalidStateError: only an OfflineAudioContext renders")); + }; + let context = context.lock().ok().and_then(|mut context| context.take()); + match context { + Some(context) => Ok(AsyncTask::new(RenderTask { context: Some(context) })), + None => Err(error("InvalidStateError: startRendering can only be called once")), + } + } + + /// Decodes a complete encoded file (wav, mp3, ogg/vorbis, flac, aac, ...) to the context's + /// sample rate, off the JS thread. + #[napi(ts_return_type = "Promise")] + pub fn decode_audio_data(&self, data: Uint8ArraySlice) -> AsyncTask { + AsyncTask::new(DecodeTask { base: self.base.clone(), source: DecodeSource::Bytes(data.to_vec()) }) + } + + /// As `decodeAudioData`, from base64 or a base64 `data:` URL. + #[napi(ts_return_type = "Promise")] + pub fn decode_audio_base64(&self, data: String) -> AsyncTask { + AsyncTask::new(DecodeTask { base: self.base.clone(), source: DecodeSource::Base64(data) }) + } + + #[napi(ts_return_type = "Promise")] + pub fn decode_audio_file(&self, path: String) -> AsyncTask { + AsyncTask::new(DecodeTask { base: self.base.clone(), source: DecodeSource::File(path) }) + } + + #[napi] + pub fn destination(&self) -> Result { + self.node(|base| Kind::Destination(base.destination())) + } + + /// One of the listener's `AudioParam`s by its JS name (`positionX`, `forwardY`, `upZ`, ...). + #[napi] + pub fn listener_param(&self, name: String) -> Option { + let listener = self.base.listener(); + let param = match name.as_str() { + "positionX" => listener.position_x(), + "positionY" => listener.position_y(), + "positionZ" => listener.position_z(), + "forwardX" => listener.forward_x(), + "forwardY" => listener.forward_y(), + "forwardZ" => listener.forward_z(), + "upX" => listener.up_x(), + "upY" => listener.up_y(), + "upZ" => listener.up_z(), + _ => return None, + }; + Some(AudioParam::wrap(param)) + } + + #[napi] + pub fn create_gain(&self) -> Result { + self.node(|base| Kind::Gain(base.create_gain())) + } + + #[napi] + pub fn create_biquad_filter(&self) -> Result { + self.node(|base| Kind::BiquadFilter(base.create_biquad_filter())) + } + + #[napi] + pub fn create_iir_filter(&self, feedforward: Vec, feedback: Vec) -> Result { + self.node(|base| Kind::IirFilter(base.create_iir_filter(feedforward, feedback))) + } + + #[napi] + pub fn create_panner(&self) -> Result { + self.node(|base| Kind::Panner(base.create_panner())) + } + + #[napi] + pub fn create_stereo_panner(&self) -> Result { + self.node(|base| Kind::StereoPanner(base.create_stereo_panner())) + } + + #[napi] + pub fn create_delay(&self, max_delay_time: f64) -> Result { + self.node(|base| Kind::Delay(base.create_delay(max_delay_time))) + } + + #[napi] + pub fn create_oscillator(&self) -> Result { + self.node(|base| Kind::Oscillator(base.create_oscillator())) + } + + #[napi] + pub fn create_buffer_source(&self) -> Result { + self.node(|base| Kind::BufferSource(base.create_buffer_source())) + } + + #[napi] + pub fn create_constant_source(&self) -> Result { + self.node(|base| Kind::ConstantSource(base.create_constant_source())) + } + + #[napi] + pub fn create_analyser(&self) -> Result { + self.node(|base| Kind::Analyser(base.create_analyser())) + } + + #[napi] + pub fn create_wave_shaper(&self) -> Result { + self.node(|base| Kind::WaveShaper(base.create_wave_shaper())) + } + + #[napi] + pub fn create_convolver(&self) -> Result { + self.node(|base| Kind::Convolver(base.create_convolver())) + } + + #[napi] + pub fn create_dynamics_compressor(&self) -> Result { + self.node(|base| Kind::DynamicsCompressor(base.create_dynamics_compressor())) + } + + #[napi] + pub fn create_channel_splitter(&self, number_of_outputs: u32) -> Result { + self.node(|base| Kind::ChannelSplitter(base.create_channel_splitter(number_of_outputs as usize))) + } + + #[napi] + pub fn create_channel_merger(&self, number_of_inputs: u32) -> Result { + self.node(|base| Kind::ChannelMerger(base.create_channel_merger(number_of_inputs as usize))) + } + + #[napi] + pub fn create_periodic_wave( + &self, + real: Float32ArraySlice, + imag: Float32ArraySlice, + disable_normalization: bool, + ) -> Result { + let options = PeriodicWaveOptions { + real: Some(real.to_vec()), + imag: Some(imag.to_vec()), + disable_normalization, + }; + guard(|| PeriodicWave { inner: self.base.create_periodic_wave(options) }) + } +} + +pub enum StateChange { + Resume, + Suspend, + Close, + Sink(String), +} + +/// A realtime context's state change; web-audio-api blocks until the render thread has made it. +pub struct StateTask { + context: Arc, + change: StateChange, +} + +impl Task for StateTask { + type Output = (); + type JsValue = (); + + fn compute(&mut self) -> Result<()> { + let context = &self.context; + match &self.change { + StateChange::Resume => guard(|| context.resume_sync()), + StateChange::Suspend => guard(|| context.suspend_sync()), + StateChange::Close => guard(|| context.close_sync()), + StateChange::Sink(sink_id) => guard(|| context.set_sink_id_sync(sink_id.clone()).map_err(|e| e.to_string()))? + .map_err(|e| error(format!("NotFoundError: {e}"))), + } + } + + fn resolve(&mut self, _: Env, _: ()) -> Result<()> { + Ok(()) + } +} + +pub struct RenderTask { + context: Option, +} + +impl Task for RenderTask { + type Output = web_audio_api::AudioBuffer; + type JsValue = AudioBuffer; + + fn compute(&mut self) -> Result { + let mut context = self.context.take().ok_or_else(|| error("InvalidStateError: already rendered"))?; + guard(|| context.start_rendering_sync()) + } + + fn resolve(&mut self, _: Env, buffer: Self::Output) -> Result { + Ok(AudioBuffer { inner: buffer }) + } +} + +pub enum DecodeSource { + Bytes(Vec), + Base64(String), + File(String), +} + +pub struct DecodeTask { + base: ConcreteBaseAudioContext, + source: DecodeSource, +} + +impl Task for DecodeTask { + type Output = web_audio_api::AudioBuffer; + type JsValue = AudioBuffer; + + fn compute(&mut self) -> Result { + let base = &self.base; + let decoded = match std::mem::replace(&mut self.source, DecodeSource::Bytes(Vec::new())) { + DecodeSource::Bytes(bytes) => guard(|| base.decode_audio_data_sync(Cursor::new(bytes)))?, + DecodeSource::Base64(data) => { + let data = data.split_once(";base64,").map_or(data.as_str(), |(_, data)| data); + let bytes = base64::engine::general_purpose::STANDARD + .decode(data.trim()) + .map_err(|e| error(format!("EncodingError: invalid base64 ({e})")))?; + guard(|| base.decode_audio_data_sync(Cursor::new(bytes)))? + } + DecodeSource::File(path) => { + let file = std::fs::File::open(&path).map_err(|e| error(format!("NotFoundError: {path}: {e}")))?; + guard(|| base.decode_audio_data_sync(file))? + } + }; + decoded.map_err(|e| error(format!("EncodingError: unable to decode audio data ({e})"))) + } + + fn resolve(&mut self, _: Env, buffer: Self::Output) -> Result { + Ok(AudioBuffer { inner: buffer }) + } +} diff --git a/crates/audio-context-napi/src/lib.rs b/crates/audio-context-napi/src/lib.rs new file mode 100644 index 000000000..1af192897 --- /dev/null +++ b/crates/audio-context-napi/src/lib.rs @@ -0,0 +1,53 @@ +#![deny(clippy::all)] + +use std::cell::Cell; +use std::panic::{catch_unwind, AssertUnwindSafe}; +use std::sync::Once; + +use napi::{Error, Result}; + +mod buffer; +mod context; +mod node; +mod param; + +pub use buffer::{AudioBuffer, PeriodicWave}; +pub use context::AudioContext; +pub use node::AudioNode; +pub use param::AudioParam; + +thread_local! { + static GUARDED: Cell = const { Cell::new(false) }; +} + +/// web-audio-api panics on spec violations; a panic unwinding into Node-API aborts the app, so +/// every call into it returns the panic as an error instead ("Error: ..."). +pub(crate) fn guard(f: impl FnOnce() -> T) -> Result { + static QUIET_HOOK: Once = Once::new(); + QUIET_HOOK.call_once(|| { + let hook = std::panic::take_hook(); + std::panic::set_hook(Box::new(move |info| { + if !GUARDED.get() { + hook(info); + } + })); + }); + let outer = GUARDED.replace(true); + let result = catch_unwind(AssertUnwindSafe(f)); + GUARDED.set(outer); + result.map_err(|payload| { + let message = payload + .downcast_ref::<&str>() + .map(|message| message.to_string()) + .or_else(|| payload.downcast_ref::().cloned()) + .unwrap_or_else(|| "The audio engine failed".to_owned()); + error(match message.split_once(" - ") { + Some((name, rest)) if name.ends_with("Error") && !name.contains(' ') => format!("{name}: {rest}"), + _ => message, + }) + }) +} + +pub(crate) fn error(message: impl Into) -> Error { + Error::from_reason(message.into()) +} diff --git a/crates/audio-context-napi/src/node.rs b/crates/audio-context-napi/src/node.rs new file mode 100644 index 000000000..71eba487f --- /dev/null +++ b/crates/audio-context-napi/src/node.rs @@ -0,0 +1,785 @@ +use napi::bindgen_prelude::{Float32ArraySlice, Function, Uint8ArraySlice}; +use napi::threadsafe_function::{ThreadsafeFunctionCallMode, UnknownReturnValue}; +use napi::{Error, Result}; +use napi_derive::napi; +use web_audio_api::node::{ + self, AudioScheduledSourceNode as _, BiquadFilterType, ChannelCountMode, ChannelInterpretation, + DistanceModelType, OscillatorType, OverSampleType, PanningModelType, +}; + +use crate::buffer::{AudioBuffer, PeriodicWave}; +use crate::param::AudioParam; +use crate::{error, guard}; + +pub(crate) enum Kind { + Destination(node::AudioDestinationNode), + Gain(node::GainNode), + BiquadFilter(node::BiquadFilterNode), + IirFilter(node::IIRFilterNode), + Panner(node::PannerNode), + StereoPanner(node::StereoPannerNode), + Delay(node::DelayNode), + Oscillator(node::OscillatorNode), + BufferSource(node::AudioBufferSourceNode), + ConstantSource(node::ConstantSourceNode), + Analyser(node::AnalyserNode), + WaveShaper(node::WaveShaperNode), + Convolver(node::ConvolverNode), + DynamicsCompressor(node::DynamicsCompressorNode), + ChannelSplitter(node::ChannelSplitterNode), + ChannelMerger(node::ChannelMergerNode), +} + +/// Evaluates `$body` with `$node` bound to whichever node `$kind` holds. +macro_rules! any_node { + ($kind:expr, $node:ident => $body:expr) => { + match $kind { + Kind::Destination($node) => $body, + Kind::Gain($node) => $body, + Kind::BiquadFilter($node) => $body, + Kind::IirFilter($node) => $body, + Kind::Panner($node) => $body, + Kind::StereoPanner($node) => $body, + Kind::Delay($node) => $body, + Kind::Oscillator($node) => $body, + Kind::BufferSource($node) => $body, + Kind::ConstantSource($node) => $body, + Kind::Analyser($node) => $body, + Kind::WaveShaper($node) => $body, + Kind::Convolver($node) => $body, + Kind::DynamicsCompressor($node) => $body, + Kind::ChannelSplitter($node) => $body, + Kind::ChannelMerger($node) => $body, + } + }; +} + +/// As `any_node!`, over the scheduled sources only. +macro_rules! scheduled { + ($self:ident, $member:literal, $node:ident => $body:expr) => { + match &mut $self.kind { + Kind::Oscillator($node) => $body, + Kind::BufferSource($node) => $body, + Kind::ConstantSource($node) => $body, + _ => return Err($self.unsupported($member)), + } + }; +} + +type EndedCallback<'a> = Function<'a, (), UnknownReturnValue>; + +/// Any node of a graph. One class for every kind keeps `connect` and `disconnect` simple; the +/// members a kind does not have throw. +#[napi] +pub struct AudioNode { + pub(crate) kind: Kind, +} + +impl AudioNode { + pub(crate) fn new(kind: Kind) -> Self { + Self { kind } + } + + fn node(&self) -> &dyn node::AudioNode { + any_node!(&self.kind, node => node as &dyn node::AudioNode) + } + + fn name(&self) -> &'static str { + match self.kind { + Kind::Destination(_) => "AudioDestinationNode", + Kind::Gain(_) => "GainNode", + Kind::BiquadFilter(_) => "BiquadFilterNode", + Kind::IirFilter(_) => "IIRFilterNode", + Kind::Panner(_) => "PannerNode", + Kind::StereoPanner(_) => "StereoPannerNode", + Kind::Delay(_) => "DelayNode", + Kind::Oscillator(_) => "OscillatorNode", + Kind::BufferSource(_) => "AudioBufferSourceNode", + Kind::ConstantSource(_) => "ConstantSourceNode", + Kind::Analyser(_) => "AnalyserNode", + Kind::WaveShaper(_) => "WaveShaperNode", + Kind::Convolver(_) => "ConvolverNode", + Kind::DynamicsCompressor(_) => "DynamicsCompressorNode", + Kind::ChannelSplitter(_) => "ChannelSplitterNode", + Kind::ChannelMerger(_) => "ChannelMergerNode", + } + } + + fn unsupported(&self, member: &str) -> Error { + error(format!("TypeError: {} has no member '{member}'", self.name())) + } +} + +fn oscillator_type(value: OscillatorType) -> &'static str { + match value { + OscillatorType::Sine => "sine", + OscillatorType::Square => "square", + OscillatorType::Sawtooth => "sawtooth", + OscillatorType::Triangle => "triangle", + OscillatorType::Custom => "custom", + } +} + +fn biquad_type(value: BiquadFilterType) -> &'static str { + match value { + BiquadFilterType::Lowpass => "lowpass", + BiquadFilterType::Highpass => "highpass", + BiquadFilterType::Bandpass => "bandpass", + BiquadFilterType::Notch => "notch", + BiquadFilterType::Allpass => "allpass", + BiquadFilterType::Peaking => "peaking", + BiquadFilterType::Lowshelf => "lowshelf", + BiquadFilterType::Highshelf => "highshelf", + } +} + +fn invalid_enum(value: &str, name: &str) -> Error { + error(format!("TypeError: '{value}' is not a valid value for enumeration {name}")) +} + +#[napi] +impl AudioNode { + #[napi(getter)] + pub fn number_of_inputs(&self) -> u32 { + self.node().number_of_inputs() as u32 + } + + #[napi(getter)] + pub fn number_of_outputs(&self) -> u32 { + self.node().number_of_outputs() as u32 + } + + #[napi(getter)] + pub fn channel_count(&self) -> u32 { + self.node().channel_count() as u32 + } + + #[napi(setter, js_name = "channelCount")] + pub fn set_channel_count(&self, value: u32) -> Result<()> { + guard(|| self.node().set_channel_count(value as usize)) + } + + #[napi(getter)] + pub fn channel_count_mode(&self) -> &'static str { + match self.node().channel_count_mode() { + ChannelCountMode::Max => "max", + ChannelCountMode::ClampedMax => "clamped-max", + ChannelCountMode::Explicit => "explicit", + } + } + + #[napi(setter, js_name = "channelCountMode")] + pub fn set_channel_count_mode(&self, value: String) -> Result<()> { + let mode = match value.as_str() { + "max" => ChannelCountMode::Max, + "clamped-max" => ChannelCountMode::ClampedMax, + "explicit" => ChannelCountMode::Explicit, + _ => return Err(invalid_enum(&value, "ChannelCountMode")), + }; + guard(|| self.node().set_channel_count_mode(mode)) + } + + #[napi(getter)] + pub fn channel_interpretation(&self) -> &'static str { + match self.node().channel_interpretation() { + ChannelInterpretation::Speakers => "speakers", + ChannelInterpretation::Discrete => "discrete", + } + } + + #[napi(setter, js_name = "channelInterpretation")] + pub fn set_channel_interpretation(&self, value: String) -> Result<()> { + let interpretation = match value.as_str() { + "speakers" => ChannelInterpretation::Speakers, + "discrete" => ChannelInterpretation::Discrete, + _ => return Err(invalid_enum(&value, "ChannelInterpretation")), + }; + guard(|| self.node().set_channel_interpretation(interpretation)) + } + + #[napi] + pub fn connect(&self, destination: &AudioNode, output: Option, input: Option) -> Result<()> { + guard(|| { + self.node().connect_from_output_to_input( + destination.node(), + output.unwrap_or(0) as usize, + input.unwrap_or(0) as usize, + ); + }) + } + + #[napi] + pub fn connect_param(&self, destination: &AudioParam, output: Option) -> Result<()> { + guard(|| { + self + .node() + .connect_from_output_to_input(&destination.inner, output.unwrap_or(0) as usize, 0); + }) + } + + /// Every outgoing connection. + #[napi] + pub fn disconnect(&self) -> Result<()> { + guard(|| self.node().disconnect()) + } + + /// Every connection from one output. + #[napi] + pub fn disconnect_output(&self, output: u32) -> Result<()> { + guard(|| self.node().disconnect_output(output as usize)) + } + + #[napi] + pub fn disconnect_node(&self, destination: &AudioNode, output: Option, input: Option) -> Result<()> { + let node = self.node(); + guard(|| match (output, input) { + (Some(output), Some(input)) => { + node.disconnect_dest_from_output_to_input(destination.node(), output as usize, input as usize) + } + (Some(output), None) => node.disconnect_dest_from_output(destination.node(), output as usize), + _ => node.disconnect_dest(destination.node()), + }) + } + + #[napi] + pub fn disconnect_param(&self, destination: &AudioParam, output: Option) -> Result<()> { + let node = self.node(); + guard(|| match output { + Some(output) => node.disconnect_dest_from_output(&destination.inner, output as usize), + None => node.disconnect_dest(&destination.inner), + }) + } + + /// One of the node's `AudioParam`s by its JS name (`gain`, `frequency`, `Q`, `positionX`, ...). + #[napi] + pub fn param(&self, name: String) -> Option { + let param = match (&self.kind, name.as_str()) { + (Kind::Gain(node), "gain") => node.gain(), + (Kind::BiquadFilter(node), "frequency") => node.frequency(), + (Kind::BiquadFilter(node), "detune") => node.detune(), + (Kind::BiquadFilter(node), "Q") => node.q(), + (Kind::BiquadFilter(node), "gain") => node.gain(), + (Kind::Panner(node), "positionX") => node.position_x(), + (Kind::Panner(node), "positionY") => node.position_y(), + (Kind::Panner(node), "positionZ") => node.position_z(), + (Kind::Panner(node), "orientationX") => node.orientation_x(), + (Kind::Panner(node), "orientationY") => node.orientation_y(), + (Kind::Panner(node), "orientationZ") => node.orientation_z(), + (Kind::StereoPanner(node), "pan") => node.pan(), + (Kind::Delay(node), "delayTime") => node.delay_time(), + (Kind::Oscillator(node), "frequency") => node.frequency(), + (Kind::Oscillator(node), "detune") => node.detune(), + (Kind::BufferSource(node), "playbackRate") => node.playback_rate(), + (Kind::BufferSource(node), "detune") => node.detune(), + (Kind::ConstantSource(node), "offset") => node.offset(), + (Kind::DynamicsCompressor(node), "threshold") => node.threshold(), + (Kind::DynamicsCompressor(node), "knee") => node.knee(), + (Kind::DynamicsCompressor(node), "ratio") => node.ratio(), + (Kind::DynamicsCompressor(node), "attack") => node.attack(), + (Kind::DynamicsCompressor(node), "release") => node.release(), + _ => return None, + }; + Some(AudioParam::wrap(param)) + } + + // Scheduled sources. + + /// `start(when, offset, duration)`; `offset` and `duration` apply to buffer sources only. + #[napi] + pub fn start(&mut self, when: Option, offset: Option, duration: Option) -> Result<()> { + let when = when.unwrap_or(0.0); + if let Kind::BufferSource(node) = &mut self.kind { + return guard(|| match (offset, duration) { + (offset, Some(duration)) => node.start_at_with_offset_and_duration(when, offset.unwrap_or(0.0), duration), + (Some(offset), None) => node.start_at_with_offset(when, offset), + (None, None) => node.start_at(when), + }); + } + scheduled!(self, "start", node => guard(|| node.start_at(when))) + } + + #[napi] + pub fn stop(&mut self, when: Option) -> Result<()> { + let when = when.unwrap_or(0.0); + scheduled!(self, "stop", node => guard(|| node.stop_at(when))) + } + + /// `callback` runs on the JS thread once the source has ended; `null` clears it. + #[napi(ts_args_type = "callback: (() => void) | null")] + pub fn set_onended(&mut self, callback: Option) -> Result<()> { + let Some(callback) = callback else { + scheduled!(self, "onended", node => node.clear_onended()); + return Ok(()); + }; + // Weak: a source that never ends must not keep the host's event loop alive. + let tsfn = callback + .build_threadsafe_function::<()>() + .weak::() + .build_callback(|_| Ok(()))?; + scheduled!(self, "onended", node => node.set_onended(move |_| { + tsfn.call((), ThreadsafeFunctionCallMode::NonBlocking); + })); + Ok(()) + } + + // OscillatorNode, BiquadFilterNode. + + #[napi(getter, js_name = "type")] + pub fn get_type(&self) -> Result<&'static str> { + match &self.kind { + Kind::Oscillator(node) => Ok(oscillator_type(node.type_())), + Kind::BiquadFilter(node) => Ok(biquad_type(node.type_())), + _ => Err(self.unsupported("type")), + } + } + + #[napi(setter, js_name = "type")] + pub fn set_type(&mut self, value: String) -> Result<()> { + match &mut self.kind { + Kind::Oscillator(node) => { + let type_ = match value.as_str() { + "sine" => OscillatorType::Sine, + "square" => OscillatorType::Square, + "sawtooth" => OscillatorType::Sawtooth, + "triangle" => OscillatorType::Triangle, + "custom" => OscillatorType::Custom, + _ => return Err(invalid_enum(&value, "OscillatorType")), + }; + guard(|| node.set_type(type_)) + } + Kind::BiquadFilter(node) => { + let type_ = match value.as_str() { + "lowpass" => BiquadFilterType::Lowpass, + "highpass" => BiquadFilterType::Highpass, + "bandpass" => BiquadFilterType::Bandpass, + "notch" => BiquadFilterType::Notch, + "allpass" => BiquadFilterType::Allpass, + "peaking" => BiquadFilterType::Peaking, + "lowshelf" => BiquadFilterType::Lowshelf, + "highshelf" => BiquadFilterType::Highshelf, + _ => return Err(invalid_enum(&value, "BiquadFilterType")), + }; + guard(|| node.set_type(type_)) + } + _ => Err(self.unsupported("type")), + } + } + + #[napi] + pub fn set_periodic_wave(&mut self, wave: &PeriodicWave) -> Result<()> { + match &mut self.kind { + Kind::Oscillator(node) => guard(|| node.set_periodic_wave(wave.inner.clone())), + _ => Err(self.unsupported("setPeriodicWave")), + } + } + + /// BiquadFilterNode and IIRFilterNode. + #[napi] + pub fn get_frequency_response( + &self, + frequency_hz: Float32ArraySlice, + mut mag_response: Float32ArraySlice, + mut phase_response: Float32ArraySlice, + ) -> Result<()> { + let (mag, phase) = unsafe { (mag_response.as_mut(), phase_response.as_mut()) }; + match &self.kind { + Kind::BiquadFilter(node) => guard(|| node.get_frequency_response(&frequency_hz, mag, phase)), + Kind::IirFilter(node) => guard(|| node.get_frequency_response(&frequency_hz, mag, phase)), + _ => Err(self.unsupported("getFrequencyResponse")), + } + } + + // AudioBufferSourceNode, ConvolverNode. + + /// Buffer sources and convolvers take a buffer once. + #[napi] + pub fn set_buffer(&mut self, buffer: &AudioBuffer) -> Result<()> { + let buffer = buffer.inner.clone(); + match &mut self.kind { + Kind::BufferSource(node) => guard(|| node.set_buffer(buffer)), + Kind::Convolver(node) => guard(|| node.set_buffer(buffer)), + _ => Err(self.unsupported("buffer")), + } + } + + #[napi(getter, js_name = "loop")] + pub fn get_loop(&self) -> Result { + match &self.kind { + Kind::BufferSource(node) => Ok(node.loop_()), + _ => Err(self.unsupported("loop")), + } + } + + #[napi(setter, js_name = "loop")] + pub fn set_loop(&mut self, value: bool) -> Result<()> { + match &mut self.kind { + Kind::BufferSource(node) => guard(|| node.set_loop(value)), + _ => Err(self.unsupported("loop")), + } + } + + #[napi(getter)] + pub fn loop_start(&self) -> Result { + match &self.kind { + Kind::BufferSource(node) => Ok(node.loop_start()), + _ => Err(self.unsupported("loopStart")), + } + } + + #[napi(setter, js_name = "loopStart")] + pub fn set_loop_start(&mut self, value: f64) -> Result<()> { + match &mut self.kind { + Kind::BufferSource(node) => guard(|| node.set_loop_start(value)), + _ => Err(self.unsupported("loopStart")), + } + } + + #[napi(getter)] + pub fn loop_end(&self) -> Result { + match &self.kind { + Kind::BufferSource(node) => Ok(node.loop_end()), + _ => Err(self.unsupported("loopEnd")), + } + } + + #[napi(setter, js_name = "loopEnd")] + pub fn set_loop_end(&mut self, value: f64) -> Result<()> { + match &mut self.kind { + Kind::BufferSource(node) => guard(|| node.set_loop_end(value)), + _ => Err(self.unsupported("loopEnd")), + } + } + + #[napi(getter)] + pub fn normalize(&self) -> Result { + match &self.kind { + Kind::Convolver(node) => Ok(node.normalize()), + _ => Err(self.unsupported("normalize")), + } + } + + #[napi(setter, js_name = "normalize")] + pub fn set_normalize(&mut self, value: bool) -> Result<()> { + match &mut self.kind { + Kind::Convolver(node) => guard(|| node.set_normalize(value)), + _ => Err(self.unsupported("normalize")), + } + } + + // PannerNode. + + #[napi(getter)] + pub fn panning_model(&self) -> Result<&'static str> { + match &self.kind { + Kind::Panner(node) => Ok(match node.panning_model() { + PanningModelType::EqualPower => "equalpower", + PanningModelType::HRTF => "HRTF", + }), + _ => Err(self.unsupported("panningModel")), + } + } + + #[napi(setter, js_name = "panningModel")] + pub fn set_panning_model(&mut self, value: String) -> Result<()> { + let model = match value.as_str() { + "equalpower" => PanningModelType::EqualPower, + "HRTF" => PanningModelType::HRTF, + _ => return Err(invalid_enum(&value, "PanningModelType")), + }; + match &mut self.kind { + Kind::Panner(node) => guard(|| node.set_panning_model(model)), + _ => Err(self.unsupported("panningModel")), + } + } + + #[napi(getter)] + pub fn distance_model(&self) -> Result<&'static str> { + match &self.kind { + Kind::Panner(node) => Ok(match node.distance_model() { + DistanceModelType::Linear => "linear", + DistanceModelType::Inverse => "inverse", + DistanceModelType::Exponential => "exponential", + }), + _ => Err(self.unsupported("distanceModel")), + } + } + + #[napi(setter, js_name = "distanceModel")] + pub fn set_distance_model(&mut self, value: String) -> Result<()> { + let model = match value.as_str() { + "linear" => DistanceModelType::Linear, + "inverse" => DistanceModelType::Inverse, + "exponential" => DistanceModelType::Exponential, + _ => return Err(invalid_enum(&value, "DistanceModelType")), + }; + match &mut self.kind { + Kind::Panner(node) => guard(|| node.set_distance_model(model)), + _ => Err(self.unsupported("distanceModel")), + } + } + + #[napi(getter)] + pub fn ref_distance(&self) -> Result { + match &self.kind { + Kind::Panner(node) => Ok(node.ref_distance()), + _ => Err(self.unsupported("refDistance")), + } + } + + #[napi(setter, js_name = "refDistance")] + pub fn set_ref_distance(&mut self, value: f64) -> Result<()> { + match &mut self.kind { + Kind::Panner(node) => guard(|| node.set_ref_distance(value)), + _ => Err(self.unsupported("refDistance")), + } + } + + #[napi(getter)] + pub fn max_distance(&self) -> Result { + match &self.kind { + Kind::Panner(node) => Ok(node.max_distance()), + _ => Err(self.unsupported("maxDistance")), + } + } + + #[napi(setter, js_name = "maxDistance")] + pub fn set_max_distance(&mut self, value: f64) -> Result<()> { + match &mut self.kind { + Kind::Panner(node) => guard(|| node.set_max_distance(value)), + _ => Err(self.unsupported("maxDistance")), + } + } + + #[napi(getter)] + pub fn rolloff_factor(&self) -> Result { + match &self.kind { + Kind::Panner(node) => Ok(node.rolloff_factor()), + _ => Err(self.unsupported("rolloffFactor")), + } + } + + #[napi(setter, js_name = "rolloffFactor")] + pub fn set_rolloff_factor(&mut self, value: f64) -> Result<()> { + match &mut self.kind { + Kind::Panner(node) => guard(|| node.set_rolloff_factor(value)), + _ => Err(self.unsupported("rolloffFactor")), + } + } + + #[napi(getter)] + pub fn cone_inner_angle(&self) -> Result { + match &self.kind { + Kind::Panner(node) => Ok(node.cone_inner_angle()), + _ => Err(self.unsupported("coneInnerAngle")), + } + } + + #[napi(setter, js_name = "coneInnerAngle")] + pub fn set_cone_inner_angle(&mut self, value: f64) -> Result<()> { + match &mut self.kind { + Kind::Panner(node) => guard(|| node.set_cone_inner_angle(value)), + _ => Err(self.unsupported("coneInnerAngle")), + } + } + + #[napi(getter)] + pub fn cone_outer_angle(&self) -> Result { + match &self.kind { + Kind::Panner(node) => Ok(node.cone_outer_angle()), + _ => Err(self.unsupported("coneOuterAngle")), + } + } + + #[napi(setter, js_name = "coneOuterAngle")] + pub fn set_cone_outer_angle(&mut self, value: f64) -> Result<()> { + match &mut self.kind { + Kind::Panner(node) => guard(|| node.set_cone_outer_angle(value)), + _ => Err(self.unsupported("coneOuterAngle")), + } + } + + #[napi(getter)] + pub fn cone_outer_gain(&self) -> Result { + match &self.kind { + Kind::Panner(node) => Ok(node.cone_outer_gain()), + _ => Err(self.unsupported("coneOuterGain")), + } + } + + #[napi(setter, js_name = "coneOuterGain")] + pub fn set_cone_outer_gain(&mut self, value: f64) -> Result<()> { + match &mut self.kind { + Kind::Panner(node) => guard(|| node.set_cone_outer_gain(value)), + _ => Err(self.unsupported("coneOuterGain")), + } + } + + // AnalyserNode. + + #[napi(getter)] + pub fn fft_size(&self) -> Result { + match &self.kind { + Kind::Analyser(node) => Ok(node.fft_size() as u32), + _ => Err(self.unsupported("fftSize")), + } + } + + #[napi(setter, js_name = "fftSize")] + pub fn set_fft_size(&mut self, value: u32) -> Result<()> { + match &mut self.kind { + Kind::Analyser(node) => guard(|| node.set_fft_size(value as usize)), + _ => Err(self.unsupported("fftSize")), + } + } + + #[napi(getter)] + pub fn frequency_bin_count(&self) -> Result { + match &self.kind { + Kind::Analyser(node) => Ok(node.frequency_bin_count() as u32), + _ => Err(self.unsupported("frequencyBinCount")), + } + } + + #[napi(getter)] + pub fn smoothing_time_constant(&self) -> Result { + match &self.kind { + Kind::Analyser(node) => Ok(node.smoothing_time_constant()), + _ => Err(self.unsupported("smoothingTimeConstant")), + } + } + + #[napi(setter, js_name = "smoothingTimeConstant")] + pub fn set_smoothing_time_constant(&mut self, value: f64) -> Result<()> { + match &mut self.kind { + Kind::Analyser(node) => guard(|| node.set_smoothing_time_constant(value)), + _ => Err(self.unsupported("smoothingTimeConstant")), + } + } + + #[napi(getter)] + pub fn min_decibels(&self) -> Result { + match &self.kind { + Kind::Analyser(node) => Ok(node.min_decibels()), + _ => Err(self.unsupported("minDecibels")), + } + } + + #[napi(setter, js_name = "minDecibels")] + pub fn set_min_decibels(&mut self, value: f64) -> Result<()> { + match &mut self.kind { + Kind::Analyser(node) => guard(|| node.set_min_decibels(value)), + _ => Err(self.unsupported("minDecibels")), + } + } + + #[napi(getter)] + pub fn max_decibels(&self) -> Result { + match &self.kind { + Kind::Analyser(node) => Ok(node.max_decibels()), + _ => Err(self.unsupported("maxDecibels")), + } + } + + #[napi(setter, js_name = "maxDecibels")] + pub fn set_max_decibels(&mut self, value: f64) -> Result<()> { + match &mut self.kind { + Kind::Analyser(node) => guard(|| node.set_max_decibels(value)), + _ => Err(self.unsupported("maxDecibels")), + } + } + + #[napi] + pub fn get_float_time_domain_data(&mut self, mut destination: Float32ArraySlice) -> Result<()> { + let destination = unsafe { destination.as_mut() }; + match &mut self.kind { + Kind::Analyser(node) => guard(|| node.get_float_time_domain_data(destination)), + _ => Err(self.unsupported("getFloatTimeDomainData")), + } + } + + #[napi] + pub fn get_byte_time_domain_data(&mut self, mut destination: Uint8ArraySlice) -> Result<()> { + let destination = unsafe { destination.as_mut() }; + match &mut self.kind { + Kind::Analyser(node) => guard(|| node.get_byte_time_domain_data(destination)), + _ => Err(self.unsupported("getByteTimeDomainData")), + } + } + + #[napi] + pub fn get_float_frequency_data(&mut self, mut destination: Float32ArraySlice) -> Result<()> { + let destination = unsafe { destination.as_mut() }; + match &mut self.kind { + Kind::Analyser(node) => guard(|| node.get_float_frequency_data(destination)), + _ => Err(self.unsupported("getFloatFrequencyData")), + } + } + + #[napi] + pub fn get_byte_frequency_data(&mut self, mut destination: Uint8ArraySlice) -> Result<()> { + let destination = unsafe { destination.as_mut() }; + match &mut self.kind { + Kind::Analyser(node) => guard(|| node.get_byte_frequency_data(destination)), + _ => Err(self.unsupported("getByteFrequencyData")), + } + } + + // WaveShaperNode. + + /// The shaping curve (the engine takes one curve per node). + #[napi] + pub fn set_curve(&mut self, curve: Float32ArraySlice) -> Result<()> { + let curve = curve.to_vec(); + match &mut self.kind { + Kind::WaveShaper(node) => guard(|| node.set_curve(curve)), + _ => Err(self.unsupported("curve")), + } + } + + #[napi(getter)] + pub fn oversample(&self) -> Result<&'static str> { + match &self.kind { + Kind::WaveShaper(node) => Ok(match node.oversample() { + OverSampleType::None => "none", + OverSampleType::X2 => "2x", + OverSampleType::X4 => "4x", + }), + _ => Err(self.unsupported("oversample")), + } + } + + #[napi(setter, js_name = "oversample")] + pub fn set_oversample(&mut self, value: String) -> Result<()> { + let oversample = match value.as_str() { + "none" => OverSampleType::None, + "2x" => OverSampleType::X2, + "4x" => OverSampleType::X4, + _ => return Err(invalid_enum(&value, "OverSampleType")), + }; + match &mut self.kind { + Kind::WaveShaper(node) => guard(|| node.set_oversample(oversample)), + _ => Err(self.unsupported("oversample")), + } + } + + // DynamicsCompressorNode. + + /// The current gain reduction, in dB. + #[napi(getter)] + pub fn reduction(&self) -> Result { + match &self.kind { + Kind::DynamicsCompressor(node) => Ok(node.reduction() as f64), + _ => Err(self.unsupported("reduction")), + } + } + + // AudioDestinationNode. + + #[napi(getter)] + pub fn max_channel_count(&self) -> Result { + match &self.kind { + Kind::Destination(node) => Ok(node.max_channel_count() as u32), + _ => Err(self.unsupported("maxChannelCount")), + } + } +} diff --git a/crates/audio-context-napi/src/param.rs b/crates/audio-context-napi/src/param.rs new file mode 100644 index 000000000..9170fbb74 --- /dev/null +++ b/crates/audio-context-napi/src/param.rs @@ -0,0 +1,116 @@ +use napi::bindgen_prelude::Float32ArraySlice; +use napi::Result; +use napi_derive::napi; +use web_audio_api::AutomationRate; + +use crate::{error, guard}; + +/// An `AudioParam` of a node or of the context's listener; clones share the one timeline. +#[napi] +pub struct AudioParam { + pub(crate) inner: web_audio_api::AudioParam, +} + +impl AudioParam { + pub(crate) fn wrap(param: &web_audio_api::AudioParam) -> Self { + Self { inner: param.clone() } + } +} + +#[napi] +impl AudioParam { + /// The value the render thread last computed (or the last one set, before it has rendered). + #[napi(getter)] + pub fn value(&self) -> f64 { + self.inner.value() as f64 + } + + #[napi(setter, js_name = "value")] + pub fn set_value(&self, value: f64) -> Result<()> { + guard(|| { + self.inner.set_value(value as f32); + }) + } + + #[napi(getter)] + pub fn default_value(&self) -> f64 { + self.inner.default_value() as f64 + } + + #[napi(getter)] + pub fn min_value(&self) -> f64 { + self.inner.min_value() as f64 + } + + #[napi(getter)] + pub fn max_value(&self) -> f64 { + self.inner.max_value() as f64 + } + + #[napi(getter)] + pub fn automation_rate(&self) -> &'static str { + match self.inner.automation_rate() { + AutomationRate::A => "a-rate", + AutomationRate::K => "k-rate", + } + } + + #[napi(setter, js_name = "automationRate")] + pub fn set_automation_rate(&self, value: String) -> Result<()> { + let rate = match value.as_str() { + "a-rate" => AutomationRate::A, + "k-rate" => AutomationRate::K, + _ => return Err(error(format!("TypeError: '{value}' is not a valid AutomationRate"))), + }; + guard(|| self.inner.set_automation_rate(rate)) + } + + #[napi] + pub fn set_value_at_time(&self, value: f64, start_time: f64) -> Result<()> { + guard(|| { + self.inner.set_value_at_time(value as f32, start_time); + }) + } + + #[napi] + pub fn linear_ramp_to_value_at_time(&self, value: f64, end_time: f64) -> Result<()> { + guard(|| { + self.inner.linear_ramp_to_value_at_time(value as f32, end_time); + }) + } + + #[napi] + pub fn exponential_ramp_to_value_at_time(&self, value: f64, end_time: f64) -> Result<()> { + guard(|| { + self.inner.exponential_ramp_to_value_at_time(value as f32, end_time); + }) + } + + #[napi] + pub fn set_target_at_time(&self, value: f64, start_time: f64, time_constant: f64) -> Result<()> { + guard(|| { + self.inner.set_target_at_time(value as f32, start_time, time_constant); + }) + } + + #[napi] + pub fn set_value_curve_at_time(&self, values: Float32ArraySlice, start_time: f64, duration: f64) -> Result<()> { + guard(|| { + self.inner.set_value_curve_at_time(&values, start_time, duration); + }) + } + + #[napi] + pub fn cancel_scheduled_values(&self, cancel_time: f64) -> Result<()> { + guard(|| { + self.inner.cancel_scheduled_values(cancel_time); + }) + } + + #[napi] + pub fn cancel_and_hold_at_time(&self, cancel_time: f64) -> Result<()> { + guard(|| { + self.inner.cancel_and_hold_at_time(cancel_time); + }) + } +} diff --git a/crates/canvas-napi/__test__/windows-host.test.mjs b/crates/canvas-napi/__test__/windows-host.test.mjs index 7c86dfd2e..444e99bcc 100644 --- a/crates/canvas-napi/__test__/windows-host.test.mjs +++ b/crates/canvas-napi/__test__/windows-host.test.mjs @@ -95,12 +95,14 @@ test('NSCCanvas: fit is an int CanvasFit; unknown values are ignored', { skip }, }); test('drawing schedules its own flush (no __flushAll needed)', { skip }, async () => { - const { ctx } = hostContext(8, 8); + const { host, ctx } = hostContext(8, 8); ctx.fillRect(0, 0, 8, 8); await Promise.resolve(); ctx.fillRect(0, 0, 4, 4); await new Promise((resolve) => setImmediate(resolve)); assert.deepEqual(pixel(ctx, 1, 1), [0, 0, 0, 255]); + // The host owns the context: a host collected during the awaits frees it under `ctx`. + assert.equal(host.surfaceWidth, 8); }); test('NSCCanvas: rejects a malformed panel pointer', { skip }, () => { diff --git a/crates/canvas-napi/src/gpu/callback.rs b/crates/canvas-napi/src/gpu/callback.rs index 60a6f3764..465461aca 100644 --- a/crates/canvas-napi/src/gpu/callback.rs +++ b/crates/canvas-napi/src/gpu/callback.rs @@ -161,8 +161,12 @@ impl Drop for Tsfn { /// A promise settled from any thread. Dropping it unsettled leaves the promise pending. pub(crate) struct PendingPromise { tsfn: Tsfn, + deferred: sys::napi_deferred, } +// `deferred` is only used on the JS thread (`settle_now`, `settle_on_js_thread`). +unsafe impl Send for PendingPromise {} + unsafe extern "C" fn settle_on_js_thread( env: sys::napi_env, _js_callback: sys::napi_value, @@ -177,7 +181,14 @@ unsafe extern "C" fn settle_on_js_thread( if env.is_null() { return; } - let deferred = context as sys::napi_deferred; + unsafe { settle_deferred(env, context as sys::napi_deferred, settler) }; +} + +unsafe fn settle_deferred( + env: sys::napi_env, + deferred: sys::napi_deferred, + settler: impl FnOnce(&Env) -> Result, +) { let js_env = Env::from_raw(env); unsafe { match settler(&js_env) { @@ -209,7 +220,13 @@ impl PendingPromise { ptr::null_mut(), weak, )?; - Ok((Self { tsfn }, promise)) + Ok((Self { tsfn, deferred }, promise)) + } + + /// Settles the promise now, on the JS thread. A queued `settle` would not keep the event loop + /// alive for a weak promise, so an `await` on it right after could see the loop end first. + pub(crate) fn settle_now(self, env: &Env, settler: impl FnOnce(&Env) -> Result) { + unsafe { settle_deferred(env.raw(), self.deferred, settler) }; } /// Settles the promise with what `settler` makes of it, on the JS thread. diff --git a/crates/canvas-napi/src/gpu/device.rs b/crates/canvas-napi/src/gpu/device.rs index e630b1763..9a5ac4cfb 100644 --- a/crates/canvas-napi/src/gpu/device.rs +++ b/crates/canvas-napi/src/gpu/device.rs @@ -374,7 +374,7 @@ impl g_p_u_device { } let (pending, promise) = PendingPromise::new(&env, true)?; if self.destroyed.get() { - pending.settle(|env| lost_info(env, 1, String::new())); + pending.settle_now(&env, |env| lost_info(env, 1, String::new())); } else { let slot = Slot::leak(); slot.set(Some(Box::new(pending))); @@ -462,11 +462,13 @@ impl g_p_u_device { /// Destroys the device; `lost` resolves with reason 1 ("destroyed"). #[napi] - pub fn destroy(&self) { + pub fn destroy(&self, env: Env) { + // Taken first, so canvas-c's own lost callback (another thread) finds the slot empty. + let pending = self.lost_slot.get().and_then(|slot| slot.take()); canvas_c::webgpu::gpu_device::canvas_native_webgpu_device_destroy(self.ptr()); self.destroyed.set(true); - if let Some(pending) = self.lost_slot.get().and_then(|slot| slot.take()) { - pending.settle(|env| lost_info(env, 1, String::new())); + if let Some(pending) = pending { + pending.settle_now(&env, |env| lost_info(env, 1, String::new())); } } diff --git a/packages/audio-context/README.md b/packages/audio-context/README.md index 6fe70a8e9..98450d3c6 100644 --- a/packages/audio-context/README.md +++ b/packages/audio-context/README.md @@ -1,6 +1,6 @@ # @nativescript/audio-context -Web Audio API style audio graph support for NativeScript (iOS and Android). +Web Audio API style audio graph support for NativeScript (iOS, Android and Windows). This package provides a native-backed `AudioContext`/`OfflineAudioContext` with common Web Audio nodes so you can keep a familiar Web Audio programming model in NativeScript apps. @@ -134,6 +134,17 @@ Context lifecycle and routing: - `state`, `onstatechange` - `sinkId`, `setSinkId(deviceId)` +## Windows + +On Windows the graph is rendered by the [web-audio-api](https://github.com/orottier/web-audio-api-rs) +engine and played through WASAPI (`audiocontext.node`, a Node-API module). `decodeAudioData` +reads wav, mp3, ogg/vorbis, flac and aac. Differences from iOS and Android: + +- `createMediaElementSource` throws `NotSupportedError` and `createSourceNodeFromPlayer` + returns `null`: `@nativescript/canvas-media` has no Windows backend yet. +- A `WaveShaperNode` takes one curve; assigning a second one throws `InvalidStateError`. +- `sinkId` is `''` for the default device; `'none'` renders without a device. + ## Optional: Global Polyfill Integration If you also use `@nativescript/canvas-polyfill`, it can register Web Audio globals such as: diff --git a/packages/audio-context/index.windows.ts b/packages/audio-context/index.windows.ts index 61036034d..406787909 100644 --- a/packages/audio-context/index.windows.ts +++ b/packages/audio-context/index.windows.ts @@ -1,3 +1,1217 @@ -// No Windows audio backend yet (AudioGraph). Importing fails loudly, so callers that probe for it -// (canvas-polyfill) leave AudioContext undefined and apps feature-detect it as absent. -throw new Error('@nativescript/audio-context is not supported on Windows yet'); +import { + AnalyserOptions, + AudioBufferCopyOptions, + AudioContextOptions, + AudioContextState, + AudioListenerBase, + AudioNodeBase, + AudioParamBase, + AudioParamHooks, + BaseAudioContext, + ChannelMergerOptions, + ChannelSplitterOptions, + ConstantSourceOptions, + ConvolverOptions, + DelayOptions, + DistanceModelType, + DynamicsCompressorOptions, + IIRFilterOptions, + MediaElementLike, + PanningModelType, + PannerOptions, + PeriodicWaveOptions, + StereoPannerOptions, + WaveShaperOptions, + context_, + distanceModelFromNumber, + distanceModelToNumber, + looksLikePath, + native_, + nativeCtor_, + normalizeSourcePath, + panningModelFromNumber, + panningModelToNumber, +} from './common'; + +declare const __non_webpack_require__: (specifier: string) => any; + +interface NativeParam { + value: number; + readonly defaultValue: number; + readonly minValue: number; + readonly maxValue: number; + automationRate: string; + setValueAtTime(value: number, time: number): void; + linearRampToValueAtTime(value: number, time: number): void; + exponentialRampToValueAtTime(value: number, time: number): void; + setTargetAtTime(value: number, startTime: number, timeConstant: number): void; + setValueCurveAtTime(values: Float32Array, startTime: number, duration: number): void; + cancelScheduledValues(time: number): void; + cancelAndHoldAtTime(time: number): void; +} + +interface NativeBuffer { + readonly sampleRate: number; + readonly length: number; + readonly duration: number; + readonly numberOfChannels: number; + channelData(channel: number): Float32Array; + copyFromChannel(destination: Float32Array, channel: number, startInChannel: number): void; +} + +/** Any node; members a kind does not have throw. */ +interface NativeNode { + readonly numberOfInputs: number; + readonly numberOfOutputs: number; + channelCount: number; + channelCountMode: string; + channelInterpretation: string; + connect(destination: NativeNode, output?: number, input?: number): void; + connectParam(destination: NativeParam, output?: number): void; + disconnect(): void; + disconnectOutput(output: number): void; + disconnectNode(destination: NativeNode, output?: number, input?: number): void; + disconnectParam(destination: NativeParam, output?: number): void; + param(name: string): NativeParam | null; + [member: string]: any; +} + +interface NativeContext { + readonly sampleRate: number; + readonly currentTime: number; + readonly state: AudioContextState; + readonly baseLatency: number; + readonly outputLatency: number; + readonly sinkId: string; + readonly length: number; + setOnstatechange(callback: (() => void) | null): void; + resume(): Promise; + suspend(): Promise; + close(): Promise; + setSinkId(sinkId: string): Promise; + startRendering(): Promise; + decodeAudioData(data: Uint8Array): Promise; + decodeAudioBase64(data: string): Promise; + decodeAudioFile(path: string): Promise; + destination(): NativeNode; + listenerParam(name: string): NativeParam | null; + createPeriodicWave(real: Float32Array, imag: Float32Array, disableNormalization: boolean): unknown; + [factory: string]: any; +} + +// Loaded on import, so a missing module leaves AudioContext undefined in canvas-polyfill's probe. +const Native = __non_webpack_require__('system_lib://audiocontext.node'); + +type NativeBaseAudioContext = BaseAudioContext & { native: NativeContext }; + +function nativeContext(context: BaseAudioContext): NativeContext { + const native = (context as NativeBaseAudioContext)?.native; + if (!(context instanceof BaseAudioContext) || !native) { + throw new TypeError('Argument 1 does not implement interface BaseAudioContext.'); + } + return native; +} + +function toFloat32Array(values: Float32Array | ArrayLike): Float32Array { + return values instanceof Float32Array ? values : Float32Array.from(values); +} + +function normalizeCopyByteOffset(view: ArrayBufferView, byteOffset?: number): number { + let offset = typeof byteOffset === 'number' && Number.isFinite(byteOffset) ? byteOffset : view.byteOffset; + offset = Math.max(0, offset | 0); + if (offset > view.buffer.byteLength) offset = view.buffer.byteLength; + const align = ((view as any).BYTES_PER_ELEMENT as number) || 1; + return offset - (offset % align); +} + +function resolveAudioBufferCopyOptions(view: ArrayBufferView, startOrOptions?: number | AudioBufferCopyOptions): { startInChannel: number; byteOffset: number } { + let startInChannel = 0; + let byteOffset: number | undefined; + if (typeof startOrOptions === 'number') { + startInChannel = startOrOptions; + } else if (startOrOptions) { + if (typeof startOrOptions.startInChannel === 'number') startInChannel = startOrOptions.startInChannel; + if (typeof startOrOptions.byteOffset === 'number') byteOffset = startOrOptions.byteOffset; + } + if (!Number.isFinite(startInChannel)) startInChannel = 0; + startInChannel = Math.max(0, startInChannel | 0); + return { + startInChannel, + byteOffset: normalizeCopyByteOffset(view, byteOffset), + }; +} + +function viewAtByteOffset(view: Float32Array, byteOffset: number): Float32Array { + if (byteOffset === view.byteOffset) return view; + if (byteOffset >= view.buffer.byteLength) return new Float32Array(0); + const availableBytes = view.buffer.byteLength - byteOffset; + const length = Math.floor(availableBytes / Float32Array.BYTES_PER_ELEMENT); + if (length <= 0) return new Float32Array(0); + return new Float32Array(view.buffer, byteOffset, length); +} + +/** `file:///C:/x` and `/C:/x` name a drive path on Windows. */ +function windowsPath(source: string): string { + const path = normalizeSourcePath(source.startsWith('file:///') ? source.substring('file://'.length) : source); + return /^\/[a-zA-Z]:[\\/]/.test(path) ? path.substring(1) : path; +} + +function makeWindowsHooks(native: NativeParam): AudioParamHooks { + return { + nativeSet(v) { + native.value = v; + }, + nativeScheduleSet(v, t) { + native.setValueAtTime(v, t); + }, + nativeScheduleLinearRamp(v, t) { + native.linearRampToValueAtTime(v, t); + }, + nativeScheduleExpRamp(v, t) { + native.exponentialRampToValueAtTime(v, t); + }, + nativeScheduleTarget(v, startTime, timeConstant) { + native.setTargetAtTime(v, startTime, timeConstant); + }, + nativeScheduleCurve(values, startTime, duration) { + native.setValueCurveAtTime(values, startTime, duration); + }, + nativeCancel(t) { + native.cancelScheduledValues(t); + }, + nativeCancelAndHold(_v, t) { + native.cancelAndHoldAtTime(t); + }, + nativeGetValue() { + return native.value; + }, + nativeSetAutomationRate(rate) { + native.automationRate = rate; + }, + }; +} + +export class AudioParam extends AudioParamBase { + private [native_]: NativeParam; + + constructor(a?: any, b?: NativeParam) { + if (a !== nativeCtor_ || !b) { + throw new TypeError('Illegal constructor.'); + } + super(makeWindowsHooks(b), b.value); + this[native_] = b; + } + + get native() { + return this[native_]; + } + + /** The value the render thread last computed, automation included. */ + get value(): number { + return this[native_].value; + } + + set value(v: number) { + this[native_].value = +v; + } + + get defaultValue(): number { + return this[native_].defaultValue; + } + + get minValue(): number { + return this[native_].minValue; + } + + get maxValue(): number { + return this[native_].maxValue; + } + + get automationRate(): string { + return this[native_].automationRate; + } + + set automationRate(v: string) { + this[native_].automationRate = v; + } +} + +function param(node: NativeNode, name: string): AudioParam { + return new AudioParam(nativeCtor_, node.param(name)); +} + +export class AudioNode extends AudioNodeBase { + protected [native_]: NativeNode; + private _params: { [name: string]: AudioParam } = {}; + + constructor(context: BaseAudioContext, native: NativeNode) { + super(context); + this[native_] = native; + } + + get native() { + return this[native_]; + } + + /** The node's `name` param, one wrapper per param. */ + protected _param(name: string): AudioParam { + return this._params[name] || (this._params[name] = param(this[native_], name)); + } + + get numberOfInputs(): number { + return this[native_].numberOfInputs; + } + + get numberOfOutputs(): number { + return this[native_].numberOfOutputs; + } + + get channelCount(): number { + return this[native_].channelCount; + } + + set channelCount(value: number) { + this[native_].channelCount = value; + } + + get channelCountMode(): string { + return this[native_].channelCountMode; + } + + set channelCountMode(value: string) { + this[native_].channelCountMode = value; + } + + get channelInterpretation(): string { + return this[native_].channelInterpretation; + } + + set channelInterpretation(value: string) { + this[native_].channelInterpretation = value; + } + + connect(destination: any, output?: number, input?: number) { + if (destination instanceof AudioParam) { + this[native_].connectParam(destination.native, output); + return; + } + if (!(destination instanceof AudioNode)) { + throw new TypeError('AudioNode.connect: Argument 1 is not an AudioNode or AudioParam.'); + } + this[native_].connect(destination.native, output, input); + return destination; + } + + disconnect(destinationOrOutput?: any, output?: number, input?: number) { + const native = this[native_]; + if (destinationOrOutput == null) { + native.disconnect(); + } else if (typeof destinationOrOutput === 'number') { + native.disconnectOutput(destinationOrOutput); + } else if (destinationOrOutput instanceof AudioParam) { + native.disconnectParam(destinationOrOutput.native, output); + } else if (destinationOrOutput instanceof AudioNode) { + native.disconnectNode(destinationOrOutput.native, output, input); + } else { + throw new TypeError('AudioNode.disconnect: Argument 1 is not an AudioNode or AudioParam.'); + } + } +} + +/** + * Samples live in the JS channel arrays once read or written (`getChannelData` hands those out + * for in-place writes); the native buffer a node plays is rebuilt from them when it acquires one. + */ +export class AudioBuffer { + private _native: NativeBuffer | null = null; + private _channels: Float32Array[] | null = null; + private _dirty = false; + private readonly _sampleRate: number; + private readonly _length: number; + private readonly _numberOfChannels: number; + + constructor(options: { length: number; numberOfChannels?: number; sampleRate?: number } | Symbol, nativeBuffer?: NativeBuffer) { + if (!options) throw new TypeError('AudioBuffer constructor requires options'); + if (options === nativeCtor_ && nativeBuffer) { + this._native = nativeBuffer; + this._sampleRate = nativeBuffer.sampleRate; + this._length = nativeBuffer.length; + this._numberOfChannels = nativeBuffer.numberOfChannels; + return; + } + const opts = options as { length: number; numberOfChannels?: number; sampleRate?: number }; + const length = Math.max(0, opts.length | 0); + const channels = opts.numberOfChannels != null ? opts.numberOfChannels | 0 : 1; + const sampleRate = opts.sampleRate != null ? +opts.sampleRate : 48000; + // The native constructor applies the spec's limits. + this._native = new Native.AudioBuffer(channels, length, sampleRate); + this._sampleRate = sampleRate; + this._length = length; + this._numberOfChannels = channels; + } + + /** The samples as a native buffer, rebuilt after JS writes. */ + get native(): NativeBuffer { + if (this._dirty || !this._native) { + this._native = Native.AudioBuffer.fromChannels(this._channels, this._sampleRate); + this._dirty = false; + } + return this._native; + } + + get sampleRate(): number { + return this._sampleRate; + } + get length(): number { + return this._length; + } + get duration(): number { + return this._length / this._sampleRate; + } + get numberOfChannels(): number { + return this._numberOfChannels; + } + + private _channel(channel: number): Float32Array { + if (!(channel >= 0 && channel < this._numberOfChannels)) { + throw new RangeError(`IndexSizeError: channel ${channel} is out of bounds (${this._numberOfChannels} channels)`); + } + if (!this._channels) { + const native = this._native; + this._channels = []; + for (let i = 0; i < this._numberOfChannels; i++) this._channels.push(native.channelData(i)); + } + return this._channels[channel | 0]; + } + + getChannelData(channel: number): Float32Array { + const data = this._channel(channel); + this._dirty = true; + return data; + } + + copyFromChannel(dest: Float32Array, channel: number, startInChannel: number | AudioBufferCopyOptions = 0) { + if (!dest) return; + const options = resolveAudioBufferCopyOptions(dest, startInChannel); + const target = viewAtByteOffset(dest, options.byteOffset); + if (target.length === 0 || options.startInChannel >= this._length) return; + if (this._channels) { + const source = this._channel(channel); + target.set(source.subarray(options.startInChannel, options.startInChannel + target.length)); + } else { + this._native.copyFromChannel(target, channel, options.startInChannel); + } + } + + copyToChannel(source: Float32Array, channel: number, startInChannel: number | AudioBufferCopyOptions = 0) { + if (!source) return; + const options = resolveAudioBufferCopyOptions(source, startInChannel); + const input = viewAtByteOffset(source, options.byteOffset); + if (input.length === 0 || options.startInChannel >= this._length) return; + const target = this._channel(channel); + target.set(input.subarray(0, this._length - options.startInChannel), options.startInChannel); + this._dirty = true; + } +} + +export class GainNode extends AudioNode { + constructor(context: BaseAudioContext, options: { gain?: number } = {}) { + super(context, nativeContext(context).createGain()); + if (typeof options?.gain === 'number') this.gain.value = options.gain; + } + + get gain(): AudioParam { + return this._param('gain'); + } +} + +export class AudioDestinationNode extends AudioNode { + constructor(context: BaseAudioContext, node: NativeNode) { + super(context, node); + } + + get maxChannelCount(): number { + return this.native.maxChannelCount; + } +} + +export class BiquadFilterNode extends AudioNode { + constructor(context: BaseAudioContext, options: { type?: string; frequency?: number; Q?: number; gain?: number; detune?: number } = {}) { + super(context, nativeContext(context).createBiquadFilter()); + if (options.type) this.type = options.type; + if (typeof options.frequency === 'number') this.frequency.value = options.frequency; + if (typeof options.Q === 'number') this.Q.value = options.Q; + if (typeof options.gain === 'number') this.gain.value = options.gain; + if (typeof options.detune === 'number') this.detune.value = options.detune; + } + + get type(): string { + return this.native.type; + } + set type(value: string) { + this.native.type = value; + } + get frequency(): AudioParam { + return this._param('frequency'); + } + get Q(): AudioParam { + return this._param('Q'); + } + get gain(): AudioParam { + return this._param('gain'); + } + get detune(): AudioParam { + return this._param('detune'); + } + + getFrequencyResponse(frequencyHz: Float32Array, magResponse: Float32Array, phaseResponse: Float32Array) { + this.native.getFrequencyResponse(frequencyHz, magResponse, phaseResponse); + } +} + +export class PannerNode extends AudioNode { + constructor(context: BaseAudioContext, options: PannerOptions = {}) { + super(context, nativeContext(context).createPanner()); + const o = options ?? {}; + for (const name of ['positionX', 'positionY', 'positionZ', 'orientationX', 'orientationY', 'orientationZ']) { + if (typeof o[name] === 'number') this._param(name).value = o[name]; + } + if (o.panningModel != null) this.panningModel = o.panningModel; + if (o.distanceModel != null) this.distanceModel = o.distanceModel; + for (const name of ['refDistance', 'maxDistance', 'rolloffFactor', 'coneInnerAngle', 'coneOuterAngle', 'coneOuterGain']) { + if (typeof o[name] === 'number') this[name] = o[name]; + } + } + + get positionX() { + return this._param('positionX'); + } + get positionY() { + return this._param('positionY'); + } + get positionZ() { + return this._param('positionZ'); + } + get orientationX() { + return this._param('orientationX'); + } + get orientationY() { + return this._param('orientationY'); + } + get orientationZ() { + return this._param('orientationZ'); + } + + get distanceModel(): DistanceModelType { + return this.native.distanceModel; + } + set distanceModel(v: DistanceModelType | number) { + this.native.distanceModel = distanceModelFromNumber(distanceModelToNumber(v)); + } + get panningModel(): PanningModelType { + return this.native.panningModel; + } + set panningModel(v: PanningModelType | number) { + this.native.panningModel = panningModelFromNumber(panningModelToNumber(v)); + } + get refDistance(): number { + return this.native.refDistance; + } + set refDistance(v: number) { + this.native.refDistance = v; + } + get maxDistance(): number { + return this.native.maxDistance; + } + set maxDistance(v: number) { + this.native.maxDistance = v; + } + get rolloffFactor(): number { + return this.native.rolloffFactor; + } + set rolloffFactor(v: number) { + this.native.rolloffFactor = v; + } + get coneInnerAngle(): number { + return this.native.coneInnerAngle; + } + set coneInnerAngle(v: number) { + this.native.coneInnerAngle = v; + } + get coneOuterAngle(): number { + return this.native.coneOuterAngle; + } + set coneOuterAngle(v: number) { + this.native.coneOuterAngle = v; + } + get coneOuterGain(): number { + return this.native.coneOuterGain; + } + set coneOuterGain(v: number) { + this.native.coneOuterGain = v; + } + + setPosition(x: number, y: number, z: number) { + this.positionX.value = +x; + this.positionY.value = +y; + this.positionZ.value = +z; + } + setOrientation(x: number, y: number, z: number) { + this.orientationX.value = +x; + this.orientationY.value = +y; + this.orientationZ.value = +z; + } +} + +export class AudioScheduledSourceNode extends AudioNode { + private _onended: ((ev: { type: 'ended' }) => void) | null = null; + private _nativeEndedWired = false; + + constructor(context: BaseAudioContext, node: NativeNode) { + super(context, node); + if (!node || typeof node.start !== 'function') throw new TypeError('Illegal constructor.'); + } + + get onended() { + return this._onended; + } + set onended(cb: ((ev: { type: 'ended' }) => void) | null) { + if (this._onended) super.removeEventListener('ended', this._onended); + this._onended = typeof cb === 'function' ? cb : null; + if (this._onended) { + super.addEventListener('ended', this._onended); + this._ensureNativeEndedWired(); + } + } + + protected _onFirstListenerAdded(type: string) { + if (type === 'ended') this._ensureNativeEndedWired(); + } + + private _ensureNativeEndedWired() { + if (this._nativeEndedWired) return; + this._nativeEndedWired = true; + // Holds this node until it ends, as a playing node with a listener must stay reachable. + this.native.setOnended(() => this.dispatchEvent({ type: 'ended', target: this })); + } + + start(when?: number, offset?: number, duration?: number) { + this.native.start(when, offset, duration); + } + + stop(when?: number) { + this.native.stop(when); + } +} + +export class AudioBufferSourceNode extends AudioScheduledSourceNode { + private _buffer: AudioBuffer | null = null; + + constructor(context: BaseAudioContext, options: { buffer?: AudioBuffer; loop?: boolean; loopStart?: number; loopEnd?: number; playbackRate?: number; detune?: number } = {}) { + super(context, nativeContext(context).createBufferSource()); + const o = options ?? {}; + if (o.buffer) this.buffer = o.buffer; + if (typeof o.loop === 'boolean') this.loop = o.loop; + if (typeof o.loopStart === 'number') this.loopStart = o.loopStart; + if (typeof o.loopEnd === 'number') this.loopEnd = o.loopEnd; + if (typeof o.playbackRate === 'number') this.playbackRate.value = o.playbackRate; + if (typeof o.detune === 'number') this.detune.value = o.detune; + } + + get loop(): boolean { + return this.native.loop; + } + set loop(v: boolean) { + this.native.loop = !!v; + } + get loopStart(): number { + return this.native.loopStart; + } + set loopStart(v: number) { + this.native.loopStart = v; + } + get loopEnd(): number { + return this.native.loopEnd; + } + set loopEnd(v: number) { + this.native.loopEnd = v; + } + + get buffer(): AudioBuffer | null { + return this._buffer; + } + /** Takes the buffer's samples as they are now; a source takes one buffer. */ + set buffer(v: AudioBuffer | null) { + if (!v) { + this._buffer = null; + return; + } + this.native.setBuffer(v.native); + this._buffer = v; + } + + get playbackRate(): AudioParam { + return this._param('playbackRate'); + } + + get detune(): AudioParam { + return this._param('detune'); + } +} + +function notSupported(message: string): Error { + if (typeof DOMException !== 'undefined') { + return new DOMException(message, 'NotSupportedError'); + } + return new Error(`NotSupportedError: ${message}`); +} + +/** Media elements have no Windows backend yet (canvas-media), so nothing can be tapped. */ +export class MediaElementAudioSourceNode extends AudioNode { + constructor(context: AudioContext, mediaElement: MediaElementLike) { + super(context, null as never); + throw notSupported('MediaElementAudioSourceNode is not supported on Windows yet'); + } + + get mediaElement(): MediaElementLike { + return null; + } + + get playbackRate(): AudioParam | null { + return null; + } + + disposeMediaElementSource() {} +} + +export class OscillatorNode extends AudioScheduledSourceNode { + constructor(context: BaseAudioContext, options: { type?: string; frequency?: number; detune?: number; periodicWave?: PeriodicWave } = {}) { + super(context, nativeContext(context).createOscillator()); + const o = options ?? {}; + if (o.periodicWave) this.setPeriodicWave(o.periodicWave); + else if (o.type) this.type = o.type; + if (typeof o.frequency === 'number') this.frequency.value = o.frequency; + if (typeof o.detune === 'number') this.detune.value = o.detune; + } + + get type(): string { + return this.native.type; + } + set type(value: string) { + this.native.type = value; + } + + get frequency(): AudioParam { + return this._param('frequency'); + } + + get detune(): AudioParam { + return this._param('detune'); + } + + setPeriodicWave(wave: PeriodicWave) { + if (!wave) return; + this.native.setPeriodicWave(wave.native); + } +} + +export class StereoPannerNode extends AudioNode { + constructor(context: BaseAudioContext, options: StereoPannerOptions = {}) { + super(context, nativeContext(context).createStereoPanner()); + if (typeof options?.pan === 'number') this.pan.value = options.pan; + } + + get pan(): AudioParam { + return this._param('pan'); + } +} + +export class DelayNode extends AudioNode { + readonly maxDelayTime: number; + + constructor(context: BaseAudioContext, options: DelayOptions = {}) { + const maxDelayTime = typeof options?.maxDelayTime === 'number' ? options.maxDelayTime : 1.0; + super(context, nativeContext(context).createDelay(maxDelayTime)); + this.maxDelayTime = maxDelayTime; + if (typeof options?.delayTime === 'number') this.delayTime.value = options.delayTime; + } + + get delayTime(): AudioParam { + return this._param('delayTime'); + } +} + +export class ConstantSourceNode extends AudioScheduledSourceNode { + constructor(context: BaseAudioContext, options: ConstantSourceOptions = {}) { + super(context, nativeContext(context).createConstantSource()); + if (typeof options?.offset === 'number') this.offset.value = options.offset; + } + + get offset(): AudioParam { + return this._param('offset'); + } +} + +export class AnalyserNode extends AudioNode { + constructor(context: BaseAudioContext, options: AnalyserOptions = {}) { + super(context, nativeContext(context).createAnalyser()); + const o = options ?? {}; + if (typeof o.fftSize === 'number') this.fftSize = o.fftSize; + if (typeof o.smoothingTimeConstant === 'number') this.smoothingTimeConstant = o.smoothingTimeConstant; + // Both at once could pass through a min >= max state one at a time. + if (typeof o.minDecibels === 'number' && o.minDecibels >= this.maxDecibels) { + if (typeof o.maxDecibels === 'number') this.maxDecibels = o.maxDecibels; + this.minDecibels = o.minDecibels; + } else { + if (typeof o.minDecibels === 'number') this.minDecibels = o.minDecibels; + if (typeof o.maxDecibels === 'number') this.maxDecibels = o.maxDecibels; + } + } + + get fftSize(): number { + return this.native.fftSize; + } + set fftSize(v: number) { + this.native.fftSize = v | 0; + } + get frequencyBinCount(): number { + return this.native.frequencyBinCount; + } + get smoothingTimeConstant(): number { + return this.native.smoothingTimeConstant; + } + set smoothingTimeConstant(v: number) { + this.native.smoothingTimeConstant = v; + } + get minDecibels(): number { + return this.native.minDecibels; + } + set minDecibels(v: number) { + this.native.minDecibels = v; + } + get maxDecibels(): number { + return this.native.maxDecibels; + } + set maxDecibels(v: number) { + this.native.maxDecibels = v; + } + + getFloatTimeDomainData(dest: Float32Array) { + if (!dest || dest.length === 0) return; + this.native.getFloatTimeDomainData(dest); + } + getByteTimeDomainData(dest: Uint8Array) { + if (!dest || dest.length === 0) return; + this.native.getByteTimeDomainData(dest); + } + getFloatFrequencyData(dest: Float32Array) { + if (!dest || dest.length === 0) return; + this.native.getFloatFrequencyData(dest); + } + getByteFrequencyData(dest: Uint8Array) { + if (!dest || dest.length === 0) return; + this.native.getByteFrequencyData(dest); + } +} + +export class WaveShaperNode extends AudioNode { + private _curve: Float32Array | null = null; + + constructor(context: BaseAudioContext, options: WaveShaperOptions = {}) { + super(context, nativeContext(context).createWaveShaper()); + if (options?.curve) this.curve = toFloat32Array(options.curve); + if (options?.oversample) this.oversample = options.oversample; + } + + get curve(): Float32Array | null { + return this._curve; + } + /** The engine takes one curve per node: a second one (or clearing it) throws. */ + set curve(v: Float32Array | null) { + if (v == null) { + if (this._curve) throw new Error('InvalidStateError: a WaveShaperNode curve cannot be cleared on Windows'); + return; + } + const curve = toFloat32Array(v); + this.native.setCurve(curve); + this._curve = curve; + } + get oversample(): 'none' | '2x' | '4x' { + return this.native.oversample; + } + set oversample(v: 'none' | '2x' | '4x') { + this.native.oversample = v; + } +} + +export class IIRFilterNode extends AudioNode { + constructor(context: BaseAudioContext, options: IIRFilterOptions) { + if (!options?.feedforward?.length || !options?.feedback?.length) throw new TypeError('IIRFilterNode: feedforward and feedback are required'); + super(context, nativeContext(context).createIirFilter(Array.from(options.feedforward), Array.from(options.feedback))); + } + + getFrequencyResponse(frequencyHz: Float32Array, magResponse: Float32Array, phaseResponse: Float32Array) { + if (!frequencyHz || !magResponse || !phaseResponse) return; + this.native.getFrequencyResponse(frequencyHz, magResponse, phaseResponse); + } +} + +export class ConvolverNode extends AudioNode { + private _buffer: AudioBuffer | null = null; + + constructor(context: BaseAudioContext, options: ConvolverOptions & { buffer?: AudioBuffer } = {}) { + super(context, nativeContext(context).createConvolver()); + this.normalize = !options?.disableNormalization; + if (options?.buffer) this.buffer = options.buffer; + } + + get buffer(): AudioBuffer | null { + return this._buffer; + } + set buffer(value: AudioBuffer | null) { + if (!value) { + this._buffer = null; + return; + } + this.native.setBuffer(value.native); + this._buffer = value; + } + get normalize(): boolean { + return this.native.normalize; + } + set normalize(value: boolean) { + this.native.normalize = !!value; + } +} + +export class DynamicsCompressorNode extends AudioNode { + private _reduction: AudioParam | null = null; + + constructor(context: BaseAudioContext, options: DynamicsCompressorOptions = {}) { + super(context, nativeContext(context).createDynamicsCompressor()); + const o = options ?? {}; + if (typeof o.threshold === 'number') this.threshold.value = o.threshold; + if (typeof o.knee === 'number') this.knee.value = o.knee; + if (typeof o.ratio === 'number') this.ratio.value = o.ratio; + if (typeof o.attack === 'number') this.attack.value = o.attack; + if (typeof o.release === 'number') this.release.value = o.release; + } + + get threshold() { + return this._param('threshold'); + } + get knee() { + return this._param('knee'); + } + get ratio() { + return this._param('ratio'); + } + get attack() { + return this._param('attack'); + } + get release() { + return this._param('release'); + } + + /** The current gain reduction in dB, read-only; an AudioParam as on iOS and Android. */ + get reduction(): AudioParam { + if (!this._reduction) { + const native = this.native; + const readOnly = () => {}; + const reduction: NativeParam = { + get value() { + return native.reduction; + }, + set value(_) {}, + defaultValue: 0, + minValue: -Infinity, + maxValue: 0, + automationRate: 'k-rate', + setValueAtTime: readOnly, + linearRampToValueAtTime: readOnly, + exponentialRampToValueAtTime: readOnly, + setTargetAtTime: readOnly, + setValueCurveAtTime: readOnly, + cancelScheduledValues: readOnly, + cancelAndHoldAtTime: readOnly, + }; + this._reduction = new AudioParam(nativeCtor_, reduction); + } + return this._reduction; + } +} + +export class ChannelSplitterNode extends AudioNode { + constructor(context: BaseAudioContext, options: ChannelSplitterOptions = {}) { + super(context, nativeContext(context).createChannelSplitter(Math.max(1, options?.numberOfOutputs ?? 6))); + } +} + +export class ChannelMergerNode extends AudioNode { + constructor(context: BaseAudioContext, options: ChannelMergerOptions = {}) { + super(context, nativeContext(context).createChannelMerger(Math.max(1, options?.numberOfInputs ?? 6))); + } +} + +export class PeriodicWave { + [native_]: unknown; + + constructor(context: BaseAudioContext, options: PeriodicWaveOptions = {}) { + const real = options.real ? toFloat32Array(options.real) : null; + const imag = options.imag ? toFloat32Array(options.imag) : null; + const length = real?.length ?? imag?.length ?? 2; + this[native_] = nativeContext(context).createPeriodicWave(real ?? new Float32Array(length), imag ?? new Float32Array(length), !!options.disableNormalization); + } + + get native() { + return this[native_]; + } +} + +export class AudioListener extends AudioListenerBase { + constructor(guard: Symbol, context: BaseAudioContext) { + if (guard !== nativeCtor_) throw new TypeError('Illegal constructor.'); + super(context); + } + + private _listenerParam(name: string): AudioParam { + return new AudioParam(nativeCtor_, nativeContext(this[context_]).listenerParam(name)); + } + + get positionX() { + return this._positionX || (this._positionX = this._listenerParam('positionX')); + } + get positionY() { + return this._positionY || (this._positionY = this._listenerParam('positionY')); + } + get positionZ() { + return this._positionZ || (this._positionZ = this._listenerParam('positionZ')); + } + + get forwardX() { + return this._forwardX || (this._forwardX = this._listenerParam('forwardX')); + } + get forwardY() { + return this._forwardY || (this._forwardY = this._listenerParam('forwardY')); + } + get forwardZ() { + return this._forwardZ || (this._forwardZ = this._listenerParam('forwardZ')); + } + + get upX() { + return this._upX || (this._upX = this._listenerParam('upX')); + } + get upY() { + return this._upY || (this._upY = this._listenerParam('upY')); + } + get upZ() { + return this._upZ || (this._upZ = this._listenerParam('upZ')); + } +} + +/** What AudioContext and OfflineAudioContext share: node factories, decoding, the listener. */ +abstract class NativeAudioContextBase extends BaseAudioContext { + protected [native_]: NativeContext; + readonly destination: AudioDestinationNode; + private _listener: AudioListener | null = null; + + protected constructor(native: NativeContext) { + super(); + this[native_] = native; + this._state = native.state; + this.destination = new AudioDestinationNode(this, native.destination()); + // Weak, so an unreferenced context can still be collected (and its device released). + const ref = new WeakRef(this); + native.setOnstatechange(() => ref.deref()?._syncState()); + } + + get native() { + return this[native_]; + } + + protected _syncState() { + this._setState(this[native_].state); + } + + get sampleRate(): number { + return this[native_].sampleRate; + } + + get currentTime(): number { + return this[native_].currentTime; + } + + get listener() { + if (!this._listener) this._listener = new AudioListener(nativeCtor_, this); + return this._listener; + } + + createGain(options?: { gain?: number }) { + return new GainNode(this, options ?? {}); + } + createBiquadFilter(options?: { type?: string; frequency?: number; Q?: number; gain?: number }) { + return new BiquadFilterNode(this, options ?? {}); + } + createPanner(options?: PannerOptions) { + return new PannerNode(this, options ?? {}); + } + createOscillator(options?: { type?: string; frequency?: number }) { + return new OscillatorNode(this, options ?? {}); + } + createStereoPanner(options?: StereoPannerOptions) { + return new StereoPannerNode(this, options ?? {}); + } + createDelay(options?: DelayOptions | number) { + return new DelayNode(this, typeof options === 'number' ? { maxDelayTime: options } : (options ?? {})); + } + createConstantSource(options?: ConstantSourceOptions) { + return new ConstantSourceNode(this, options ?? {}); + } + createAnalyser(options?: AnalyserOptions) { + return new AnalyserNode(this, options ?? {}); + } + createWaveShaper(options?: WaveShaperOptions) { + return new WaveShaperNode(this, options ?? {}); + } + createIIRFilter(feedforward: number[], feedback: number[]) { + return new IIRFilterNode(this, { feedforward, feedback }); + } + createConvolver(options?: ConvolverOptions) { + return new ConvolverNode(this, options ?? {}); + } + createDynamicsCompressor(options?: DynamicsCompressorOptions) { + return new DynamicsCompressorNode(this, options ?? {}); + } + createChannelSplitter(options?: ChannelSplitterOptions | number) { + return new ChannelSplitterNode(this, typeof options === 'number' ? { numberOfOutputs: options } : (options ?? {})); + } + createChannelMerger(options?: ChannelMergerOptions | number) { + return new ChannelMergerNode(this, typeof options === 'number' ? { numberOfInputs: options } : (options ?? {})); + } + createPeriodicWave(real: Float32Array | number[], imag: Float32Array | number[], options?: { disableNormalization?: boolean }) { + return new PeriodicWave(this, { real, imag, disableNormalization: options?.disableNormalization }); + } + createBuffer(options: { length: number; numberOfChannels: number; sampleRate: number } | number, length?: number, sampleRate?: number) { + if (typeof options === 'number') return new AudioBuffer({ numberOfChannels: options, length, sampleRate }); + return new AudioBuffer(options); + } + createBufferSource(options?: { buffer?: AudioBuffer }) { + return new AudioBufferSourceNode(this, options ?? {}); + } + + /** Paths (`~/`, `file://`, absolute) are read natively; `data:` URLs and other strings are base64. */ + decodeAudioData(source: string | ArrayBuffer | ArrayBufferView, successCallback?: (buffer: AudioBuffer) => void, errorCallback?: (error: Error) => void): Promise { + const native = this[native_]; + let decoded: Promise; + try { + if (typeof source === 'string') { + // base64 has '/' too, so it is recognised before looksLikePath. + const base64 = source.startsWith('data:') || /^[A-Za-z0-9+/\s]{16,}={0,2}$/.test(source) || !looksLikePath(source); + decoded = base64 ? native.decodeAudioBase64(source) : native.decodeAudioFile(windowsPath(source)); + } else if (source instanceof ArrayBuffer) { + decoded = native.decodeAudioData(new Uint8Array(source)); + } else if (ArrayBuffer.isView(source)) { + decoded = native.decodeAudioData(new Uint8Array(source.buffer, source.byteOffset, source.byteLength)); + } else { + throw new TypeError('decodeAudioData: source must be a path, base64 string, ArrayBuffer or view'); + } + } catch (e) { + decoded = Promise.reject(e); + } + const ret = decoded.then((buffer) => new AudioBuffer(nativeCtor_, buffer)); + if (successCallback) ret.then(successCallback); + if (errorCallback) ret.catch(errorCallback); + return ret; + } +} + +export class OfflineAudioContext extends NativeAudioContextBase { + constructor(numberOfChannels: number | { numberOfChannels?: number; length: number; sampleRate: number }, lengthInFrames?: number, sampleRate?: number) { + const options = typeof numberOfChannels === 'object' ? numberOfChannels : { numberOfChannels, length: lengthInFrames, sampleRate }; + super(Native.AudioContext.offline(Math.max(1, (options.numberOfChannels ?? 1) | 0), Math.max(0, options.length | 0), options.sampleRate && Number.isFinite(options.sampleRate) ? options.sampleRate : 48000)); + } + + get length(): number { + return this[native_].length; + } + + startRendering(): Promise { + let rendering: Promise; + try { + rendering = this[native_].startRendering(); + } catch (e) { + return Promise.reject(e); + } + return rendering.then((buffer) => { + this._syncState(); + return new AudioBuffer(nativeCtor_, buffer); + }); + } +} + +export class AudioContext extends NativeAudioContextBase { + constructor(options?: AudioContextOptions & { sinkId?: string }) { + super( + new Native.AudioContext({ + sampleRate: options?.sampleRate, + latencyHint: options?.latencyHint, + sinkId: options?.sinkId === 'default' ? '' : options?.sinkId, + }), + ); + } + + get baseLatency(): number { + return this[native_].baseLatency; + } + + get outputLatency(): number { + return this[native_].outputLatency; + } + + /** `''` for the default output device. */ + get sinkId(): string { + return this[native_].sinkId; + } + + setSinkId(deviceId: string): Promise { + const value = deviceId == null || deviceId === 'default' ? '' : String(deviceId); + try { + return this[native_].setSinkId(value); + } catch (e) { + return Promise.reject(e); + } + } + + resume(): Promise { + return this._changeState((native) => native.resume()); + } + + suspend(): Promise { + return this._changeState((native) => native.suspend()); + } + + close(): Promise { + if (this._state === 'closed') return Promise.resolve(); + return this._changeState((native) => native.close()); + } + + private _changeState(change: (native: NativeContext) => Promise): Promise { + if (this._state === 'closed') return Promise.reject(new Error('InvalidStateError: AudioContext is closed')); + let changed: Promise; + try { + changed = change(this[native_]); + } catch (e) { + return Promise.reject(e); + } + // The state event arrives on its own schedule; `state` is current once the promise settles. + return changed.then(() => this._syncState()); + } + + createMediaElementSource(mediaElement: MediaElementLike): MediaElementAudioSourceNode { + return new MediaElementAudioSourceNode(this, mediaElement); + } + + createSourceNodeFromPlayer(_playerNative: any): MediaElementAudioSourceNode | null { + return null; + } +} diff --git a/packages/audio-context/package.json b/packages/audio-context/package.json index 093aae943..10d66f547 100644 --- a/packages/audio-context/package.json +++ b/packages/audio-context/package.json @@ -7,7 +7,8 @@ "nativescript": { "platforms": { "ios": "6.0.0", - "android": "6.0.0" + "android": "6.0.0", + "windows": "9.1.0" } }, "repository": { @@ -20,6 +21,7 @@ "TypeScript", "iOS", "Android", + "Windows", "Web Audio API", "AudioContext" ], diff --git a/packages/audio-context/platforms/windows/THIRD_PARTY_NOTICES.txt b/packages/audio-context/platforms/windows/THIRD_PARTY_NOTICES.txt new file mode 100644 index 000000000..e6886b51c --- /dev/null +++ b/packages/audio-context/platforms/windows/THIRD_PARTY_NOTICES.txt @@ -0,0 +1,41 @@ +@nativescript/audio-context for Windows (audiocontext.node) includes third-party software: + +-------------------------------------------------------------------------------- +web-audio-api 1.7 -- https://github.com/orottier/web-audio-api-rs + +MIT License + +Copyright (c) 2021 Otto Rottier + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +-------------------------------------------------------------------------------- +Symphonia (audio decoding) -- https://github.com/pdeljanov/Symphonia + +Licensed under the Mozilla Public License, v. 2.0. Its source code, unmodified, is available +from the repository above and from https://crates.io/crates/symphonia. A copy of the MPL is at +https://mozilla.org/MPL/2.0/. + +-------------------------------------------------------------------------------- +cpal (WASAPI output) -- https://github.com/RustAudio/cpal, Apache-2.0 + +-------------------------------------------------------------------------------- +The module also links Rust crates published under MIT, Apache-2.0, BSD-3-Clause, ISC, Zlib, +Unlicense, CC0-1.0 and Unicode-3.0 terms; `cargo tree -p audio-context-napi -f "{p} {l}"` in +https://github.com/NativeScript/canvas lists each one with its license. diff --git a/packages/audio-context/platforms/windows/arm64/audiocontext.node b/packages/audio-context/platforms/windows/arm64/audiocontext.node new file mode 100644 index 000000000..8f3a290d1 Binary files /dev/null and b/packages/audio-context/platforms/windows/arm64/audiocontext.node differ diff --git a/packages/audio-context/platforms/windows/plugin.props b/packages/audio-context/platforms/windows/plugin.props new file mode 100644 index 000000000..e265fc772 --- /dev/null +++ b/packages/audio-context/platforms/windows/plugin.props @@ -0,0 +1,5 @@ + + + diff --git a/packages/audio-context/platforms/windows/plugin.targets b/packages/audio-context/platforms/windows/plugin.targets new file mode 100644 index 000000000..dfac55d92 --- /dev/null +++ b/packages/audio-context/platforms/windows/plugin.targets @@ -0,0 +1,19 @@ + + + + <_NSAudioContextArch Condition="'$(NSLibPlatform)' != ''">$(NSLibPlatform) + <_NSAudioContextArch Condition="'$(_NSAudioContextArch)' == '' and ('$(Platform)' == 'ARM64' or '$(Platform)' == 'arm64')">arm64 + + <_NSAudioContextArch Condition="'$(_NSAudioContextArch)' == '' or '$(_NSAudioContextArch)' == 'x86_64'">x64 + <_NSAudioContextDir>$(MSBuildThisFileDirectory)$(_NSAudioContextArch)\ + + + + + + <_NSAudioContextFiles Include="$(_NSAudioContextDir)*.node" /> + + + + + diff --git a/packages/audio-context/platforms/windows/x64/audiocontext.node b/packages/audio-context/platforms/windows/x64/audiocontext.node new file mode 100644 index 000000000..ee6009fa0 Binary files /dev/null and b/packages/audio-context/platforms/windows/x64/audiocontext.node differ diff --git a/packages/canvas/platforms/windows/arm64/canvasnative.node b/packages/canvas/platforms/windows/arm64/canvasnative.node index c2073c2c9..be74c43f6 100644 Binary files a/packages/canvas/platforms/windows/arm64/canvasnative.node and b/packages/canvas/platforms/windows/arm64/canvasnative.node differ diff --git a/packages/canvas/platforms/windows/x64/canvasnative.node b/packages/canvas/platforms/windows/x64/canvasnative.node index f5915f07f..fb8df4cb2 100644 Binary files a/packages/canvas/platforms/windows/x64/canvasnative.node and b/packages/canvas/platforms/windows/x64/canvasnative.node differ diff --git a/tools/demo/canvas/spec/audio.ts b/tools/demo/canvas/spec/audio.ts new file mode 100644 index 000000000..397e6c836 --- /dev/null +++ b/tools/demo/canvas/spec/audio.ts @@ -0,0 +1,130 @@ +import { AudioContext, OfflineAudioContext } from '@nativescript/audio-context'; +import { suite, test, ok, equal, closeTo, throws, rejects } from './harness'; + +declare const global: any; + +function rms(samples: Float32Array): number { + let sum = 0; + for (let i = 0; i < samples.length; i++) sum += samples[i] * samples[i]; + return Math.sqrt(sum / samples.length); +} + +/** A mono 16-bit WAV of `samples`, base64. */ +function wavBase64(samples: Float32Array, sampleRate: number): string { + const bytes = new DataView(new ArrayBuffer(44 + samples.length * 2)); + const text = (offset: number, value: string) => { + for (let i = 0; i < value.length; i++) bytes.setUint8(offset + i, value.charCodeAt(i)); + }; + text(0, 'RIFF'); + bytes.setUint32(4, 36 + samples.length * 2, true); + text(8, 'WAVEfmt '); + bytes.setUint32(16, 16, true); + bytes.setUint16(20, 1, true); + bytes.setUint16(22, 1, true); + bytes.setUint32(24, sampleRate, true); + bytes.setUint32(28, sampleRate * 2, true); + bytes.setUint16(32, 2, true); + bytes.setUint16(34, 16, true); + text(36, 'data'); + bytes.setUint32(40, samples.length * 2, true); + for (let i = 0; i < samples.length; i++) bytes.setInt16(44 + i * 2, Math.round(samples[i] * 32767), true); + const alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'; + const raw = new Uint8Array(bytes.buffer); + let out = ''; + for (let i = 0; i < raw.length; i += 3) { + const n = (raw[i] << 16) | ((raw[i + 1] ?? 0) << 8) | (raw[i + 2] ?? 0); + out += alphabet[(n >> 18) & 63] + alphabet[(n >> 12) & 63] + (i + 1 < raw.length ? alphabet[(n >> 6) & 63] : '=') + (i + 2 < raw.length ? alphabet[n & 63] : '='); + } + return out; +} + +const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); + +export function registerAudioSpec() { + suite('audio.offline', () => { + test('canvas-polyfill installs the Web Audio globals', () => { + equal(global.AudioContext, AudioContext, 'global AudioContext'); + equal(global.OfflineAudioContext, OfflineAudioContext, 'global OfflineAudioContext'); + }); + + test('renders an oscillator through a gain', async () => { + const context = new OfflineAudioContext(1, 4800, 48000); + const oscillator = context.createOscillator({ type: 'square', frequency: 1000 }); + oscillator.connect(context.createGain({ gain: 0.5 })).connect(context.destination); + oscillator.start(); + const buffer = await context.startRendering(); + equal(buffer.length, 4800); + closeTo(rms(buffer.getChannelData(0).subarray(480)), 0.5, 0.02, 'square wave rms'); + equal(context.state, 'closed'); + }); + + test('fires ended on the JS thread', async () => { + const context = new OfflineAudioContext(1, 4800, 48000); + const source = context.createConstantSource(); + source.connect(context.destination); + let ended = 0; + source.onended = () => ended++; + source.start(0); + source.stop(0.01); + await context.startRendering(); + await wait(100); + equal(ended, 1, 'ended events'); + }); + + test('decodes base64 audio', async () => { + const samples = new Float32Array(4410); + for (let i = 0; i < samples.length; i++) samples[i] = 0.5 * Math.sin((2 * Math.PI * 441 * i) / 44100); + const context = new OfflineAudioContext(1, 128, 44100); + const buffer = await context.decodeAudioData(wavBase64(samples, 44100)); + equal(buffer.length, 4410); + closeTo(buffer.getChannelData(0)[25], 0.5, 1e-3, 'peak sample'); + await rejects(context.decodeAudioData(new ArrayBuffer(16)), 'garbage decodes'); + }); + + test('spec violations throw', () => { + const context = new OfflineAudioContext(1, 128, 48000); + const oscillator = context.createOscillator(); + oscillator.start(); + throws(() => oscillator.start()); + throws(() => oscillator.connect(new OfflineAudioContext(1, 128, 48000).destination)); + throws(() => (context.createAnalyser().fftSize = 1000)); + }); + }); + + suite('audio.realtime', () => { + test('plays on the default device and changes state', async () => { + const context = new AudioContext(); + const states: string[] = []; + context.onstatechange = () => states.push(context.state); + const gain = context.createGain({ gain: 0 }); + const oscillator = context.createOscillator(); + oscillator.connect(gain).connect(context.destination); + oscillator.start(); + equal(context.state, 'running'); + ok(context.sampleRate > 0, `sampleRate ${context.sampleRate}`); + const start = context.currentTime; + await wait(300); + ok(context.currentTime - start > 0.15, `currentTime advanced ${context.currentTime - start}s in 0.3s`); + await context.suspend(); + equal(context.state, 'suspended'); + await context.resume(); + await context.close(); + equal(context.state, 'closed'); + await wait(100); + ok(states.indexOf('suspended') >= 0 && states[states.length - 1] === 'closed', `statechange events: ${states.join(',')}`); + }); + + test('fires ended from the render thread', async () => { + const context = new AudioContext(); + const source = context.createConstantSource({ offset: 0 }); + source.connect(context.destination); + let ended = false; + source.addEventListener('ended', () => (ended = true)); + source.start(); + source.stop(context.currentTime + 0.05); + await wait(400); + await context.close(); + ok(ended, 'ended event'); + }); + }); +} diff --git a/tools/demo/canvas/spec/index.ts b/tools/demo/canvas/spec/index.ts index 933fa13f8..a9fbce149 100644 --- a/tools/demo/canvas/spec/index.ts +++ b/tools/demo/canvas/spec/index.ts @@ -6,8 +6,8 @@ * adb logcat -d | grep 'SPEC|' * * `--es suite ` narrows the run: a group (`2d`, `webgl`, `webgpu`, - * `imagebitmap`, `bitmaprenderer`) or a single suite (`2d.path2d`). `contextlost` (Windows) runs - * on its own: it removes the GPU device. + * `imagebitmap`, `bitmaprenderer`, `audio` on Windows) or a single suite (`2d.path2d`). + * `contextlost` (Windows) runs on its own: it removes the GPU device. */ import { runAll, results, setPageCanvas } from './harness'; @@ -18,6 +18,9 @@ import { registerWebGPUSpec } from './webgpu'; import { registerCanvasSourceSpec } from './canvassource'; import { registerScalingSpec } from './scaling'; import { registerContextLossSpec } from './contextloss'; +import { registerAudioSpec } from './audio'; + +declare const __WINDOWS__: boolean; export type SpecGroup = string; @@ -32,6 +35,9 @@ export async function runSpecTests(group: SpecGroup = 'all') { registerWebGPUSpec(); registerCanvasSourceSpec(); registerScalingSpec(); + if (typeof __WINDOWS__ !== 'undefined' && __WINDOWS__) { + registerAudioSpec(); + } // Only when asked for: removing the GPU device takes every other GPU suite in the process with it. if (group === 'contextlost') { registerContextLossSpec(); diff --git a/tools/scripts/build-napi.sh b/tools/scripts/build-napi.sh index cafc575c3..90e35fa4c 100644 --- a/tools/scripts/build-napi.sh +++ b/tools/scripts/build-napi.sh @@ -5,8 +5,9 @@ # Usage: build-napi.sh TARGET [PROFILE] [CRATE] # TARGET x86_64-pc-windows-msvc | aarch64-pc-windows-msvc # PROFILE release-napi (default; panic=unwind so the host app survives a panic) or dev -# CRATE canvas-napi (default: @nativescript/canvas, canvasnative.node) or canvas-svg-napi -# (@nativescript/canvas-svg, canvassvg.node) +# CRATE canvas-napi (default: @nativescript/canvas, canvasnative.node), canvas-svg-napi +# (@nativescript/canvas-svg, canvassvg.node) or audio-context-napi +# (@nativescript/audio-context, audiocontext.node) set -e @@ -24,6 +25,7 @@ ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" case "$CRATE" in canvas-napi) PACKAGE="$ROOT/packages/canvas"; NAME=canvasnative ;; canvas-svg-napi) PACKAGE="$ROOT/packages/canvas-svg"; NAME=canvassvg ;; + audio-context-napi) PACKAGE="$ROOT/packages/audio-context"; NAME=audiocontext ;; *) echo "unsupported crate: $CRATE" exit 1