UNPKG

react-native-audio-api

Version:

react-native-audio-api provides system for controlling audio in React Native environment compatible with Web Audio API specification

268 lines (264 loc) • 8.23 kB
"use strict"; import { InvalidStateError, RangeError } from "../../../errors/index.js"; import AudioParam from "../../AudioParam.web.js"; import { clamp } from "../../../utils/index.js"; import LoadCustomWasm, { globalWasmPromise, globalTag } from "./LoadCustomWasm.js"; import AudioStretcherParam from "./AudioStretcherParam.js"; function buildScheduleOptions(options, time) { if (time === undefined) { return options; } return { ...options, output: time }; } function detuneCentsToSemitones(cents) { return clamp(cents / 100, -12, 12); } export default class AudioBufferSourceNodeStretcher { stretcherPromise = null; node = null; hasBeenStarted = false; _onEnded = null; _loop = false; _loopStart = -1; _loopEnd = -1; _loopSkip = false; _onLoopEnded = undefined; _buffer = null; bufferHasBeenSet = false; _operationChain = Promise.resolve(); _pendingParamEvents = []; constructor(context, options) { this.context = context; // The worklet is loaded as a static asset (served next to the app) rather // than bundled, so it is never transformed by the consumer's bundler. The // consumer preloads it via LoadCustomWasm(prefix); this call is idempotent. const stretcherPromise = (async () => { await LoadCustomWasm('/react-native-audio-api'); await globalWasmPromise; const factory = window[globalTag]; return factory(context.context); })(); this.stretcherPromise = stretcherPromise; stretcherPromise.then(node => { this.node = node; }); this.detune = new AudioStretcherParam(context, options.detune ?? 0, 0, -1200, 1200, event => { this.handleParamEvent(this.detune, event, value => ({ semitones: detuneCentsToSemitones(value) })); }, cancelTime => { this.cancelStretcherAutomation(cancelTime); }); this.playbackRate = new AudioStretcherParam(context, options.playbackRate ?? 1, 1, 0, Infinity, event => { this.handleParamEvent(this.playbackRate, event, value => ({ rate: value })); }, cancelTime => { this.cancelStretcherAutomation(cancelTime); }); this.buffer = options.buffer ?? null; } runOnStretcher(action) { this._operationChain = this._operationChain.then(() => this.stretcherPromise.then(action)); return this._operationChain; } scheduleStretcher(options, time) { const scheduleOptions = buildScheduleOptions(options, time); this.runOnStretcher(node => { node.schedule(scheduleOptions); }); } cancelStretcherAutomation(cancelTime) { this._pendingParamEvents = this._pendingParamEvents.filter(pending => pending.event.time < cancelTime); this.runOnStretcher(node => { node.cancelScheduledValues(cancelTime); }); } handleParamEvent(param, event, mapOptions) { if (!this.hasBeenStarted) { this._pendingParamEvents.push({ param, event, mapOptions }); return; } this.applyParamEvent(param, event, mapOptions); } applyParamEvent(param, event, mapOptions) { const samples = param.sampleAutomation(event, value => value); for (const sample of samples) { this.scheduleStretcher(mapOptions(sample.value), sample.time); } } flushPendingParamEvents() { const pending = this._pendingParamEvents; this._pendingParamEvents = []; for (const { param, event, mapOptions } of pending) { this.applyParamEvent(param, event, mapOptions); } } connect(destination) { const action = node => { if (destination instanceof AudioParam) { node.connect(destination.param); return; } node.connect(destination.node); }; this.runOnStretcher(action); return destination; } disconnect(destination) { const action = node => { if (destination === undefined) { node.disconnect(); return; } if (destination instanceof AudioParam) { node.disconnect(destination.param); return; } node.disconnect(destination.node); }; this.runOnStretcher(action); } start(when, offset, duration) { if (when && when < 0) { throw new RangeError(`when must be a finite non-negative number: ${when}`); } if (offset && offset < 0) { throw new RangeError(`offset must be a finite non-negative number: ${offset}`); } if (duration && duration < 0) { throw new RangeError(`duration must be a finite non-negative number: ${duration}`); } if (this.hasBeenStarted) { throw new InvalidStateError('Cannot call start more than once'); } this.hasBeenStarted = true; const startAt = !when || when < this.context.currentTime ? this.context.currentTime : when; this.runOnStretcher(node => { const playbackRate = this.playbackRate.getValueAtTime(startAt); const detune = this.detune.getValueAtTime(startAt); node.start(startAt, offset, duration, playbackRate, detuneCentsToSemitones(detune)); // Pin the loop to the same segment start() just scheduled (output=startAt). // A separate schedule() with no output would land at the worklet's own // (later) currentTime as an inactive trailing segment and silence playback. if (this.loop && this._loopStart !== -1 && this._loopEnd !== -1) { node.schedule({ output: startAt, loopStart: this._loopStart, loopEnd: this._loopEnd }); } this.flushPendingParamEvents(); }); } stop(when) { if (when !== undefined && when < 0) { throw new RangeError(`when must be a finite non-negative number: ${when}`); } // Passing `undefined` lets the worklet stop at its own fresh currentTime. // Passing 0 would schedule deactivation before the start segment (a no-op). this.runOnStretcher(node => { node.stop(when); }); } get buffer() { return this._buffer; } set buffer(buffer) { if (buffer !== null && this.bufferHasBeenSet) { throw new InvalidStateError('The buffer can only be set once and cannot be changed afterwards.'); } this._buffer = buffer; if (buffer !== null) { this.bufferHasBeenSet = true; } this.runOnStretcher(node => { node.dropBuffers(); if (!buffer) { return; } const channelArrays = []; for (let i = 0; i < buffer.numberOfChannels; i++) { channelArrays.push(buffer.getChannelData(i)); } node.addBuffers(channelArrays); }); } // Unlike the browser AudioBufferSourceNode, the WASM worklet only picks up // loop bounds when they are scheduled. start() schedules the initial loop; // these setters must re-schedule so live changes take effect while playing. applyLoopToStretcher() { if (!this.hasBeenStarted) { return; } this.runOnStretcher(node => { if (this._loop && this._loopStart !== -1 && this._loopEnd !== -1) { node.schedule({ loopStart: this._loopStart, loopEnd: this._loopEnd }); } else { // Zero-length loop disables looping in the worklet going forward. node.schedule({ loopStart: 0, loopEnd: 0 }); } }); } get loop() { return this._loop; } set loop(value) { this._loop = value; this.applyLoopToStretcher(); } get loopStart() { return this._loopStart; } set loopStart(value) { this._loopStart = value; this.applyLoopToStretcher(); } get loopEnd() { return this._loopEnd; } set loopEnd(value) { this._loopEnd = value; this.applyLoopToStretcher(); } get loopSkip() { return this._loopSkip; } set loopSkip(value) { this._loopSkip = value; } get onLoopEnded() { return this._onLoopEnded; } // The WASM stretcher has no per-loop event; callback is stored but never fired. set onLoopEnded(callback) { this._onLoopEnded = callback ?? undefined; } get onEnded() { return this._onEnded; } set onEnded(callback) { this._onEnded = callback; this.runOnStretcher(node => { node.onEnded = callback; }); } } //# sourceMappingURL=AudioBufferSourceNodeStretcher.js.map