UNPKG

web-audio-api

Version:
185 lines (161 loc) 7.06 kB
import { BLOCK_SIZE, fpCeil } from './constants.js' import AudioScheduledSourceNode from './AudioScheduledSourceNode.js' import AudioNode from './AudioNode.js' import AudioParam from './AudioParam.js' import AudioBuffer from 'audio-buffer' import { DOMErr } from './errors.js' class AudioBufferSourceNode extends AudioScheduledSourceNode { #playbackRate get playbackRate() { return this.#playbackRate } #detune get detune() { return this.#detune } #buffer = null #bufferSet = false // tracks if buffer was ever assigned a non-null value get buffer() { return this.#buffer } set buffer(val) { if (val !== null && !(val instanceof AudioBuffer)) throw new TypeError('buffer must be an AudioBuffer or null') if (val !== null && this.#bufferSet) throw DOMErr('buffer can only be set once', 'InvalidStateError') if (val !== null) { this.#bufferSet = true // Spec: "acquire the content" — copy buffer data so external mutations don't affect playback. // Also replace the original buffer's internal channel arrays so that getChannelData // returns the acquired snapshot (detached from any prior Float32Array references). let nch = val.numberOfChannels, len = val.length let copy = new AudioBuffer(nch, len, val.sampleRate) for (let c = 0; c < nch; c++) { let snapshot = new Float32Array(val.getChannelData(c)) copy.getChannelData(c).set(snapshot) // Spec "acquire the content": replace original buffer's channel backing store // so prior getChannelData() references are detached from the buffer. // Uses audio-buffer internal _channels — no public API exists for this. if (val._channels) val._channels[c] = new Float32Array(snapshot) } this.#buffer = copy } else { this.#buffer = null } } constructor(context, options) { options = AudioNode._checkOpts(options) super(context, 0, 1, undefined, 'max', 'speakers') this.#buffer = options.buffer ?? null this.loop = options.loop ?? false this.loopStart = options.loopStart ?? 0 this.loopEnd = options.loopEnd ?? 0 this.#playbackRate = new AudioParam(this.context, options.playbackRate ?? 1, 'k') this.#playbackRate._fixedRate = true this.#detune = new AudioParam(this.context, options.detune ?? 0, 'k') this.#detune._fixedRate = true this._cursor = 0 // current position in buffer (samples) this._bufEnd = 0 // loop end or buffer end (samples) this._framesLeft = 0 // remaining frames from duration (0 = unlimited) this._offset = 0 this._duration = 0 this._applyOpts(options) } start(when, offset, duration) { if (offset !== undefined && offset < 0) throw new RangeError('offset must be non-negative') if (duration !== undefined && duration < 0) throw new RangeError('duration must be non-negative') this._offset = offset || 0 this._duration = duration || 0 super.start(when) } _onStart() { if (!this.buffer) return // no buffer yet — will initialize when buffer is set later let bufSr = this.buffer.sampleRate // Cursor is in buffer-sample space (fractional) this._cursor = this._offset * bufSr this._bufEnd = this.loopEnd > 0 ? this.loopEnd * bufSr : this.buffer.length this._framesLeft = this._duration > 0 ? Math.round(this._duration * this.context.sampleRate) : 0 // Compute sub-sample start offset: how far past the integer output frame // the actual start time falls. This is used to offset the initial cursor. let sr = this.context.sampleRate let startFrame = this._startTime * sr let firstOutputFrame = fpCeil(startFrame) let subSampleOffset = firstOutputFrame - startFrame // how far past the start // Snap tiny offsets to zero to avoid fp noise causing an extra sample if (Math.abs(subSampleOffset) < 1e-8) subSampleOffset = 0 this._cursor += subSampleOffset * (bufSr / sr) * (this.#playbackRate.value || 0) } _dsp(offset, count) { if (!this.buffer) return this._zeroBuf // no buffer assigned yet // Lazy init: if buffer was set after start(), initialize playback now if (this._bufEnd === 0 && this.buffer) this._onStart() let sr = this.context.sampleRate let bufSr = this.buffer.sampleRate let nch = this.buffer.numberOfChannels let bufLen = this.buffer.length let loopStart = this.loop && this.loopStart > 0 ? this.loopStart * bufSr : 0 let blockSize = count // Duration exhausted — silence if (this._duration > 0 && this._framesLeft <= 0) return new AudioBuffer(nch, blockSize, sr) // Compute effective playback step per output sample: // accounts for buffer resampling, playbackRate, and detune let rate = this.#playbackRate._tick()[0] let detune = this.#detune._tick()[0] let step = (bufSr / sr) * rate * Math.pow(2, detune / 1200) // How many output frames to produce this block let toWrite = blockSize if (this._framesLeft > 0) toWrite = Math.min(toWrite, this._framesLeft) let out = new AudioBuffer(nch, blockSize, sr) let written = 0 let cursor = this._cursor let end = Math.min(this._bufEnd, bufLen) while (written < toWrite) { // playbackRate 0: sample-and-hold — don't advance cursor if (step === 0) { let idx = Math.floor(cursor) if (idx < 0) idx = 0 if (idx >= bufLen) idx = bufLen - 1 let val = (idx >= 0 && idx < bufLen) ? 1 : 0 for (let ch = 0; ch < nch; ch++) { let src = this.buffer.getChannelData(ch) let dst = out.getChannelData(ch) let s = idx >= 0 && idx < bufLen ? src[idx] : 0 for (let i = written; i < toWrite; i++) dst[i] = s } written = toWrite break } if (cursor >= end) { if (this.loop) { cursor = loopStart + ((cursor - end) % Math.max(1, end - loopStart)) continue } break // non-loop: end of buffer } // Linear interpolation read at fractional cursor position let idx = Math.floor(cursor) let frac = cursor - idx for (let ch = 0; ch < nch; ch++) { let srcData = this.buffer.getChannelData(ch) let dst = out.getChannelData(ch) let s0 = idx >= 0 && idx < bufLen ? srcData[idx] : 0 let s1 if (idx + 1 < bufLen) { s1 = srcData[idx + 1] } else if (idx > 0 && idx < bufLen) { // Extrapolate past buffer end using last two samples s1 = 2 * srcData[idx] - srcData[idx - 1] } else { s1 = 0 } dst[written] = s0 + (s1 - s0) * frac } written++ cursor += step } this._cursor = cursor if (this._framesLeft > 0) { this._framesLeft -= toWrite if (this._framesLeft <= 0) this._scheduleEnded(0) } else if (!this.loop && written < blockSize) { this._scheduleEnded((blockSize - written) / sr) } return out } } export default AudioBufferSourceNode