web-audio-api
Version:
Portable Web Audio API
124 lines (103 loc) • 4.25 kB
JavaScript
import AudioNode from './AudioNode.js'
import AudioBuffer from 'audio-buffer'
import { BLOCK_SIZE, fpCeil } from './constants.js'
import { DOMErr } from './errors.js'
class AudioScheduledSourceNode extends AudioNode {
#onended = null
get onended() { return this.#onended }
set onended(fn) {
if (this.#onended) this.removeEventListener('ended', this.#onended)
this.#onended = fn
if (fn) this.addEventListener('ended', fn)
}
constructor(context, numberOfInputs, numberOfOutputs, channelCount, channelCountMode, channelInterpretation) {
super(context, numberOfInputs, numberOfOutputs, channelCount, channelCountMode, channelInterpretation)
this._playing = false
this._started = false
this._startTime = -1
this._stopTime = -1
this._ended = false
this._zeroBuf = new AudioBuffer(1, BLOCK_SIZE, context.sampleRate)
}
start(when = 0) {
if (typeof when !== 'number' || isNaN(when) || !isFinite(when)) throw new TypeError('when must be a finite number')
if (when < 0) throw new RangeError('when must be non-negative')
if (this._started) throw DOMErr('start has already been called', 'InvalidStateError')
this._started = true
this._startTime = when
}
stop(when = 0) {
if (typeof when !== 'number' || isNaN(when) || !isFinite(when)) throw new TypeError('when must be a finite number')
if (when < 0) throw new RangeError('when must be non-negative')
if (!this._started) throw DOMErr('cannot stop before start', 'InvalidStateError')
this._stopTime = when
}
// hook for subclasses to initialize on start
_onStart() {}
// schedule ended event + dispose
_scheduleEnded(delay = 0) {
if (this._ended) return
this._ended = true
this._playing = false
// use microtask to fire after current tick completes
this._schedule('ended', this.context.currentTime + delay, () => {
this.dispatchEvent(new Event('ended'))
})
this._schedule('kill', this.context.currentTime + delay, () => this[Symbol.dispose]())
}
_tick() {
super._tick()
if (this._ended) return this._zeroBuf
let sr = this.context.sampleRate
let blockStart = this.context.currentTime
// Compute blockEnd from frame counter to avoid float precision drift
let blockEnd = this.context._frame != null
? (this.context._frame + BLOCK_SIZE) / sr
: blockStart + BLOCK_SIZE / sr
// Not started yet
if (!this._started || this._startTime >= blockEnd) return this._zeroBuf
let startSample = 0
if (this._startTime > blockStart)
startSample = fpCeil((this._startTime - blockStart) * sr)
// If the source starts at/past the end of this block, defer to next quantum
if (startSample >= BLOCK_SIZE) return this._zeroBuf
// Check if we just crossed start boundary — initialize on first playing tick
if (!this._playing) {
this._playing = true
this._onStart()
}
let stopSample = BLOCK_SIZE
if (this._stopTime >= 0 && this._stopTime < blockEnd) {
stopSample = Math.max(0, fpCeil((this._stopTime - blockStart) * sr))
if (stopSample <= startSample) {
this._scheduleEnded(0)
return this._zeroBuf
}
}
// Full block — fast path
if (startSample === 0 && stopSample === BLOCK_SIZE)
return this._dsp(0, BLOCK_SIZE)
// Partial block — DSP produces only the active samples
let activeSamples = stopSample - startSample
let out = this._dsp(startSample, activeSamples)
if (startSample > 0 || stopSample < BLOCK_SIZE) {
let nch = out.numberOfChannels
let partial = new AudioBuffer(nch, BLOCK_SIZE, sr)
for (let ch = 0; ch < nch; ch++) {
let src = out.getChannelData(ch)
let dst = partial.getChannelData(ch)
for (let i = 0; i < activeSamples; i++)
dst[startSample + i] = src[i]
}
out = partial
}
// End of playback
if (this._stopTime >= 0 && this._stopTime < blockEnd)
this._scheduleEnded(0)
return out
}
// subclasses override: _dsp(offset, count) → AudioBuffer
// offset = start sample within block, count = number of samples to produce
_dsp(offset, count) { return this._zeroBuf }
}
export default AudioScheduledSourceNode