UNPKG

soundtouchjs

Version:

An ES2015 library for manipulating Web Audio Contexts

788 lines (773 loc) 24.9 kB
/* * SoundTouch JS v0.2.1 audio processing library * Copyright (c) Olli Parviainen * Copyright (c) Ryan Berdeen * Copyright (c) Jakub Fiala * Copyright (c) Steve 'Cutter' Blades * * This library is free software; you can redistribute it and/or * modify it under the terms of the GNU Lesser General Public * License as published by the Free Software Foundation; either * version 2.1 of the License, or (at your option) any later version. * * This library is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU * Lesser General Public License for more details. * * You should have received a copy of the GNU Lesser General Public * License along with this library; if not, write to the Free Software * Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA */ class FifoSampleBuffer { constructor() { this._vector = new Float32Array(); this._position = 0; this._frameCount = 0; } get vector() { return this._vector; } get position() { return this._position; } get startIndex() { return this._position * 2; } get frameCount() { return this._frameCount; } get endIndex() { return (this._position + this._frameCount) * 2; } clear() { this.receive(this._frameCount); this.rewind(); } put(numFrames) { this._frameCount += numFrames; } putSamples(samples, position, numFrames = 0) { position = position || 0; const sourceOffset = position * 2; if (!(numFrames >= 0)) { numFrames = (samples.length - sourceOffset) / 2; } const numSamples = numFrames * 2; this.ensureCapacity(numFrames + this._frameCount); const destOffset = this.endIndex; this.vector.set(samples.subarray(sourceOffset, sourceOffset + numSamples), destOffset); this._frameCount += numFrames; } putBuffer(buffer, position, numFrames = 0) { position = position || 0; if (!(numFrames >= 0)) { numFrames = buffer.frameCount - position; } this.putSamples(buffer.vector, buffer.position + position, numFrames); } receive(numFrames) { if (!(numFrames >= 0) || numFrames > this._frameCount) { numFrames = this.frameCount; } this._frameCount -= numFrames; this._position += numFrames; } receiveSamples(output, numFrames = 0) { const numSamples = numFrames * 2; const sourceOffset = this.startIndex; output.set(this._vector.subarray(sourceOffset, sourceOffset + numSamples)); this.receive(numFrames); } extract(output, position = 0, numFrames = 0) { const sourceOffset = this.startIndex + position * 2; const numSamples = numFrames * 2; output.set(this._vector.subarray(sourceOffset, sourceOffset + numSamples)); } ensureCapacity(numFrames = 0) { const minLength = parseInt(numFrames * 2); if (this._vector.length < minLength) { const newVector = new Float32Array(minLength); newVector.set(this._vector.subarray(this.startIndex, this.endIndex)); this._vector = newVector; this._position = 0; } else { this.rewind(); } } ensureAdditionalCapacity(numFrames = 0) { this.ensureCapacity(this._frameCount + numFrames); } rewind() { if (this._position > 0) { this._vector.set(this._vector.subarray(this.startIndex, this.endIndex)); this._position = 0; } } } class AbstractFifoSamplePipe { constructor(createBuffers) { if (createBuffers) { this._inputBuffer = new FifoSampleBuffer(); this._outputBuffer = new FifoSampleBuffer(); } else { this._inputBuffer = this._outputBuffer = null; } } get inputBuffer() { return this._inputBuffer; } set inputBuffer(inputBuffer) { this._inputBuffer = inputBuffer; } get outputBuffer() { return this._outputBuffer; } set outputBuffer(outputBuffer) { this._outputBuffer = outputBuffer; } clear() { this._inputBuffer.clear(); this._outputBuffer.clear(); } } class RateTransposer extends AbstractFifoSamplePipe { constructor(createBuffers) { super(createBuffers); this.reset(); this._rate = 1; } set rate(rate) { this._rate = rate; } reset() { this.slopeCount = 0; this.prevSampleL = 0; this.prevSampleR = 0; } clone() { const result = new RateTransposer(); result.rate = this._rate; return result; } process() { const numFrames = this._inputBuffer.frameCount; this._outputBuffer.ensureAdditionalCapacity(numFrames / this._rate + 1); const numFramesOutput = this.transpose(numFrames); this._inputBuffer.receive(); this._outputBuffer.put(numFramesOutput); } transpose(numFrames = 0) { if (numFrames === 0) { return 0; } const src = this._inputBuffer.vector; const srcOffset = this._inputBuffer.startIndex; const dest = this._outputBuffer.vector; const destOffset = this._outputBuffer.endIndex; let used = 0; let i = 0; while (this.slopeCount < 1.0) { dest[destOffset + 2 * i] = (1.0 - this.slopeCount) * this.prevSampleL + this.slopeCount * src[srcOffset]; dest[destOffset + 2 * i + 1] = (1.0 - this.slopeCount) * this.prevSampleR + this.slopeCount * src[srcOffset + 1]; i = i + 1; this.slopeCount += this._rate; } this.slopeCount -= 1.0; if (numFrames !== 1) { out: while (true) { while (this.slopeCount > 1.0) { this.slopeCount -= 1.0; used = used + 1; if (used >= numFrames - 1) { break out; } } const srcIndex = srcOffset + 2 * used; dest[destOffset + 2 * i] = (1.0 - this.slopeCount) * src[srcIndex] + this.slopeCount * src[srcIndex + 2]; dest[destOffset + 2 * i + 1] = (1.0 - this.slopeCount) * src[srcIndex + 1] + this.slopeCount * src[srcIndex + 3]; i = i + 1; this.slopeCount += this._rate; } } this.prevSampleL = src[srcOffset + 2 * numFrames - 2]; this.prevSampleR = src[srcOffset + 2 * numFrames - 1]; return i; } } class FilterSupport { constructor(pipe) { this._pipe = pipe; } get pipe() { return this._pipe; } get inputBuffer() { return this._pipe.inputBuffer; } get outputBuffer() { return this._pipe.outputBuffer; } fillInputBuffer( ) { throw new Error('fillInputBuffer() not overridden'); } fillOutputBuffer(numFrames = 0) { while (this.outputBuffer.frameCount < numFrames) { const numInputFrames = 8192 * 2 - this.inputBuffer.frameCount; this.fillInputBuffer(numInputFrames); if (this.inputBuffer.frameCount < 8192 * 2) { break; } this._pipe.process(); } } clear() { this._pipe.clear(); } } const noop = function () { return; }; class SimpleFilter extends FilterSupport { constructor(sourceSound, pipe, callback = noop) { super(pipe); this.callback = callback; this.sourceSound = sourceSound; this.historyBufferSize = 22050; this._sourcePosition = 0; this.outputBufferPosition = 0; this._position = 0; } get position() { return this._position; } set position(position) { if (position > this._position) { throw new RangeError('New position may not be greater than current position'); } const newOutputBufferPosition = this.outputBufferPosition - (this._position - position); if (newOutputBufferPosition < 0) { throw new RangeError('New position falls outside of history buffer'); } this.outputBufferPosition = newOutputBufferPosition; this._position = position; } get sourcePosition() { return this._sourcePosition; } set sourcePosition(sourcePosition) { this.clear(); this._sourcePosition = sourcePosition; } onEnd() { this.callback(); } fillInputBuffer(numFrames = 0) { const samples = new Float32Array(numFrames * 2); const numFramesExtracted = this.sourceSound.extract(samples, numFrames, this._sourcePosition); this._sourcePosition += numFramesExtracted; this.inputBuffer.putSamples(samples, 0, numFramesExtracted); } extract(target, numFrames = 0) { this.fillOutputBuffer(this.outputBufferPosition + numFrames); const numFramesExtracted = Math.min(numFrames, this.outputBuffer.frameCount - this.outputBufferPosition); this.outputBuffer.extract(target, this.outputBufferPosition, numFramesExtracted); const currentFrames = this.outputBufferPosition + numFramesExtracted; this.outputBufferPosition = Math.min(this.historyBufferSize, currentFrames); this.outputBuffer.receive(Math.max(currentFrames - this.historyBufferSize, 0)); this._position += numFramesExtracted; return numFramesExtracted; } handleSampleData(event) { this.extract(event.data, 4096); } clear() { super.clear(); this.outputBufferPosition = 0; } } const USE_AUTO_SEQUENCE_LEN = 0; const DEFAULT_SEQUENCE_MS = USE_AUTO_SEQUENCE_LEN; const USE_AUTO_SEEKWINDOW_LEN = 0; const DEFAULT_SEEKWINDOW_MS = USE_AUTO_SEEKWINDOW_LEN; const DEFAULT_OVERLAP_MS = 8; const _SCAN_OFFSETS = [[124, 186, 248, 310, 372, 434, 496, 558, 620, 682, 744, 806, 868, 930, 992, 1054, 1116, 1178, 1240, 1302, 1364, 1426, 1488, 0], [-100, -75, -50, -25, 25, 50, 75, 100, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], [-20, -15, -10, -5, 5, 10, 15, 20, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], [-4, -3, -2, -1, 1, 2, 3, 4, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]]; const AUTOSEQ_TEMPO_LOW = 0.25; const AUTOSEQ_TEMPO_TOP = 4.0; const AUTOSEQ_AT_MIN = 125.0; const AUTOSEQ_AT_MAX = 50.0; const AUTOSEQ_K = (AUTOSEQ_AT_MAX - AUTOSEQ_AT_MIN) / (AUTOSEQ_TEMPO_TOP - AUTOSEQ_TEMPO_LOW); const AUTOSEQ_C = AUTOSEQ_AT_MIN - AUTOSEQ_K * AUTOSEQ_TEMPO_LOW; const AUTOSEEK_AT_MIN = 25.0; const AUTOSEEK_AT_MAX = 15.0; const AUTOSEEK_K = (AUTOSEEK_AT_MAX - AUTOSEEK_AT_MIN) / (AUTOSEQ_TEMPO_TOP - AUTOSEQ_TEMPO_LOW); const AUTOSEEK_C = AUTOSEEK_AT_MIN - AUTOSEEK_K * AUTOSEQ_TEMPO_LOW; class Stretch extends AbstractFifoSamplePipe { constructor(createBuffers) { super(createBuffers); this._quickSeek = true; this.midBufferDirty = false; this.midBuffer = null; this.overlapLength = 0; this.autoSeqSetting = true; this.autoSeekSetting = true; this._tempo = 1; this.setParameters(44100, DEFAULT_SEQUENCE_MS, DEFAULT_SEEKWINDOW_MS, DEFAULT_OVERLAP_MS); } clear() { super.clear(); this.clearMidBuffer(); } clearMidBuffer() { if (this.midBufferDirty) { this.midBufferDirty = false; this.midBuffer = null; } } setParameters(sampleRate, sequenceMs, seekWindowMs, overlapMs) { if (sampleRate > 0) { this.sampleRate = sampleRate; } if (overlapMs > 0) { this.overlapMs = overlapMs; } if (sequenceMs > 0) { this.sequenceMs = sequenceMs; this.autoSeqSetting = false; } else { this.autoSeqSetting = true; } if (seekWindowMs > 0) { this.seekWindowMs = seekWindowMs; this.autoSeekSetting = false; } else { this.autoSeekSetting = true; } this.calculateSequenceParameters(); this.calculateOverlapLength(this.overlapMs); this.tempo = this._tempo; } set tempo(newTempo) { let intskip; this._tempo = newTempo; this.calculateSequenceParameters(); this.nominalSkip = this._tempo * (this.seekWindowLength - this.overlapLength); this.skipFract = 0; intskip = Math.floor(this.nominalSkip + 0.5); this.sampleReq = Math.max(intskip + this.overlapLength, this.seekWindowLength) + this.seekLength; } get tempo() { return this._tempo; } get inputChunkSize() { return this.sampleReq; } get outputChunkSize() { return this.overlapLength + Math.max(0, this.seekWindowLength - 2 * this.overlapLength); } calculateOverlapLength(overlapInMsec = 0) { let newOvl; newOvl = this.sampleRate * overlapInMsec / 1000; newOvl = newOvl < 16 ? 16 : newOvl; newOvl -= newOvl % 8; this.overlapLength = newOvl; this.refMidBuffer = new Float32Array(this.overlapLength * 2); this.midBuffer = new Float32Array(this.overlapLength * 2); } checkLimits(x, mi, ma) { return x < mi ? mi : x > ma ? ma : x; } calculateSequenceParameters() { let seq; let seek; if (this.autoSeqSetting) { seq = AUTOSEQ_C + AUTOSEQ_K * this._tempo; seq = this.checkLimits(seq, AUTOSEQ_AT_MAX, AUTOSEQ_AT_MIN); this.sequenceMs = Math.floor(seq + 0.5); } if (this.autoSeekSetting) { seek = AUTOSEEK_C + AUTOSEEK_K * this._tempo; seek = this.checkLimits(seek, AUTOSEEK_AT_MAX, AUTOSEEK_AT_MIN); this.seekWindowMs = Math.floor(seek + 0.5); } this.seekWindowLength = Math.floor(this.sampleRate * this.sequenceMs / 1000); this.seekLength = Math.floor(this.sampleRate * this.seekWindowMs / 1000); } set quickSeek(enable) { this._quickSeek = enable; } clone() { const result = new Stretch(); result.tempo = this._tempo; result.setParameters(this.sampleRate, this.sequenceMs, this.seekWindowMs, this.overlapMs); return result; } seekBestOverlapPosition() { return this._quickSeek ? this.seekBestOverlapPositionStereoQuick() : this.seekBestOverlapPositionStereo(); } seekBestOverlapPositionStereo() { let bestOffset; let bestCorrelation; let correlation; let i = 0; this.preCalculateCorrelationReferenceStereo(); bestOffset = 0; bestCorrelation = Number.MIN_VALUE; for (; i < this.seekLength; i = i + 1) { correlation = this.calculateCrossCorrelationStereo(2 * i, this.refMidBuffer); if (correlation > bestCorrelation) { bestCorrelation = correlation; bestOffset = i; } } return bestOffset; } seekBestOverlapPositionStereoQuick() { let bestOffset; let bestCorrelation; let correlation; let scanCount = 0; let correlationOffset; let tempOffset; this.preCalculateCorrelationReferenceStereo(); bestCorrelation = Number.MIN_VALUE; bestOffset = 0; correlationOffset = 0; tempOffset = 0; for (; scanCount < 4; scanCount = scanCount + 1) { let j = 0; while (_SCAN_OFFSETS[scanCount][j]) { tempOffset = correlationOffset + _SCAN_OFFSETS[scanCount][j]; if (tempOffset >= this.seekLength) { break; } correlation = this.calculateCrossCorrelationStereo(2 * tempOffset, this.refMidBuffer); if (correlation > bestCorrelation) { bestCorrelation = correlation; bestOffset = tempOffset; } j = j + 1; } correlationOffset = bestOffset; } return bestOffset; } preCalculateCorrelationReferenceStereo() { let i = 0; let context; let temp; for (; i < this.overlapLength; i = i + 1) { temp = i * (this.overlapLength - i); context = i * 2; this.refMidBuffer[context] = this.midBuffer[context] * temp; this.refMidBuffer[context + 1] = this.midBuffer[context + 1] * temp; } } calculateCrossCorrelationStereo(mixingPosition, compare) { const mixing = this._inputBuffer.vector; mixingPosition += this._inputBuffer.startIndex; let correlation = 0; let i = 2; const calcLength = 2 * this.overlapLength; let mixingOffset; for (; i < calcLength; i = i + 2) { mixingOffset = i + mixingPosition; correlation += mixing[mixingOffset] * compare[i] + mixing[mixingOffset + 1] * compare[i + 1]; } return correlation; } overlap(overlapPosition) { this.overlapStereo(2 * overlapPosition); } overlapStereo(inputPosition) { const input = this._inputBuffer.vector; inputPosition += this._inputBuffer.startIndex; const output = this._outputBuffer.vector; const outputPosition = this._outputBuffer.endIndex; let i = 0; let context; let tempFrame; const frameScale = 1 / this.overlapLength; let fi; let inputOffset; let outputOffset; for (; i < this.overlapLength; i = i + 1) { tempFrame = (this.overlapLength - i) * frameScale; fi = i * frameScale; context = 2 * i; inputOffset = context + inputPosition; outputOffset = context + outputPosition; output[outputOffset + 0] = input[inputOffset + 0] * fi + this.midBuffer[context + 0] * tempFrame; output[outputOffset + 1] = input[inputOffset + 1] * fi + this.midBuffer[context + 1] * tempFrame; } } process() { let offset; let temp; let overlapSkip; if (this.midBuffer === null) { if (this._inputBuffer.frameCount < this.overlapLength) { return; } this.midBuffer = new Float32Array(this.overlapLength * 2); this._inputBuffer.receiveSamples(this.midBuffer, this.overlapLength); } while (this._inputBuffer.frameCount >= this.sampleReq) { offset = this.seekBestOverlapPosition(); this._outputBuffer.ensureAdditionalCapacity(this.overlapLength); this.overlap(Math.floor(offset)); this._outputBuffer.put(this.overlapLength); temp = this.seekWindowLength - 2 * this.overlapLength; if (temp > 0) { this._outputBuffer.putBuffer(this._inputBuffer, offset + this.overlapLength, temp); } const start = this._inputBuffer.startIndex + 2 * (offset + this.seekWindowLength - this.overlapLength); this.midBuffer.set(this._inputBuffer.vector.subarray(start, start + 2 * this.overlapLength)); this.skipFract += this.nominalSkip; overlapSkip = Math.floor(this.skipFract); this.skipFract -= overlapSkip; this._inputBuffer.receive(overlapSkip); } } } const testFloatEqual = function (a, b) { return (a > b ? a - b : b - a) > 1e-10; }; class SoundTouch { constructor() { this.transposer = new RateTransposer(false); this.stretch = new Stretch(false); this._inputBuffer = new FifoSampleBuffer(); this._intermediateBuffer = new FifoSampleBuffer(); this._outputBuffer = new FifoSampleBuffer(); this._rate = 0; this._tempo = 0; this.virtualPitch = 1.0; this.virtualRate = 1.0; this.virtualTempo = 1.0; this.calculateEffectiveRateAndTempo(); } clear() { this.transposer.clear(); this.stretch.clear(); } clone() { const result = new SoundTouch(); result.rate = this.rate; result.tempo = this.tempo; return result; } get rate() { return this._rate; } set rate(rate) { this.virtualRate = rate; this.calculateEffectiveRateAndTempo(); } set rateChange(rateChange) { this._rate = 1.0 + 0.01 * rateChange; } get tempo() { return this._tempo; } set tempo(tempo) { this.virtualTempo = tempo; this.calculateEffectiveRateAndTempo(); } set tempoChange(tempoChange) { this.tempo = 1.0 + 0.01 * tempoChange; } set pitch(pitch) { this.virtualPitch = pitch; this.calculateEffectiveRateAndTempo(); } set pitchOctaves(pitchOctaves) { this.pitch = Math.exp(0.69314718056 * pitchOctaves); this.calculateEffectiveRateAndTempo(); } set pitchSemitones(pitchSemitones) { this.pitchOctaves = pitchSemitones / 12.0; } get inputBuffer() { return this._inputBuffer; } get outputBuffer() { return this._outputBuffer; } calculateEffectiveRateAndTempo() { const previousTempo = this._tempo; const previousRate = this._rate; this._tempo = this.virtualTempo / this.virtualPitch; this._rate = this.virtualRate * this.virtualPitch; if (testFloatEqual(this._tempo, previousTempo)) { this.stretch.tempo = this._tempo; } if (testFloatEqual(this._rate, previousRate)) { this.transposer.rate = this._rate; } if (this._rate > 1.0) { if (this._outputBuffer != this.transposer.outputBuffer) { this.stretch.inputBuffer = this._inputBuffer; this.stretch.outputBuffer = this._intermediateBuffer; this.transposer.inputBuffer = this._intermediateBuffer; this.transposer.outputBuffer = this._outputBuffer; } } else { if (this._outputBuffer != this.stretch.outputBuffer) { this.transposer.inputBuffer = this._inputBuffer; this.transposer.outputBuffer = this._intermediateBuffer; this.stretch.inputBuffer = this._intermediateBuffer; this.stretch.outputBuffer = this._outputBuffer; } } } process() { if (this._rate > 1.0) { this.stretch.process(); this.transposer.process(); } else { this.transposer.process(); this.stretch.process(); } } } class WebAudioBufferSource { constructor(buffer) { this.buffer = buffer; this._position = 0; } get dualChannel() { return this.buffer.numberOfChannels > 1; } get position() { return this._position; } set position(value) { this._position = value; } extract(target, numFrames = 0, position = 0) { this.position = position; let left = this.buffer.getChannelData(0); let right = this.dualChannel ? this.buffer.getChannelData(1) : this.buffer.getChannelData(0); let i = 0; for (; i < numFrames; i++) { target[i * 2] = left[i + position]; target[i * 2 + 1] = right[i + position]; } return Math.min(numFrames, left.length - position); } } const getWebAudioNode = function (context, filter, sourcePositionCallback = noop, bufferSize = 4096) { const node = context.createScriptProcessor(bufferSize, 2, 2); const samples = new Float32Array(bufferSize * 2); node.onaudioprocess = event => { let left = event.outputBuffer.getChannelData(0); let right = event.outputBuffer.getChannelData(1); let framesExtracted = filter.extract(samples, bufferSize); sourcePositionCallback(filter.sourcePosition); if (framesExtracted === 0) { filter.onEnd(); } let i = 0; for (; i < framesExtracted; i++) { left[i] = samples[i * 2]; right[i] = samples[i * 2 + 1]; } }; return node; }; const pad = function (n, width, z) { z = z || '0'; n = n + ''; return n.length >= width ? n : new Array(width - n.length + 1).join(z) + n; }; const minsSecs = function (secs) { const mins = Math.floor(secs / 60); const seconds = secs - mins * 60; return `${mins}:${pad(parseInt(seconds), 2)}`; }; const onUpdate = function (sourcePosition) { const currentTimePlayed = this.timePlayed; const sampleRate = this.sampleRate; this.sourcePosition = sourcePosition; this.timePlayed = sourcePosition / sampleRate; if (currentTimePlayed !== this.timePlayed) { const timePlayed = new CustomEvent('play', { detail: { timePlayed: this.timePlayed, formattedTimePlayed: this.formattedTimePlayed, percentagePlayed: this.percentagePlayed } }); this._node.dispatchEvent(timePlayed); } }; class PitchShifter { constructor(context, buffer, bufferSize, onEnd = noop) { this._soundtouch = new SoundTouch(); const source = new WebAudioBufferSource(buffer); this.timePlayed = 0; this.sourcePosition = 0; this._filter = new SimpleFilter(source, this._soundtouch, onEnd); this._node = getWebAudioNode(context, this._filter, sourcePostion => onUpdate.call(this, sourcePostion), bufferSize); this.tempo = 1; this.rate = 1; this.duration = buffer.duration; this.sampleRate = context.sampleRate; this.listeners = []; } get formattedDuration() { return minsSecs(this.duration); } get formattedTimePlayed() { return minsSecs(this.timePlayed); } get percentagePlayed() { return 100 * this._filter.sourcePosition / (this.duration * this.sampleRate); } set percentagePlayed(perc) { this._filter.sourcePosition = parseInt(perc * this.duration * this.sampleRate); this.sourcePosition = this._filter.sourcePosition; this.timePlayed = this.sourcePosition / this.sampleRate; } get node() { return this._node; } set pitch(pitch) { this._soundtouch.pitch = pitch; } set pitchSemitones(semitone) { this._soundtouch.pitchSemitones = semitone; } set rate(rate) { this._soundtouch.rate = rate; } set tempo(tempo) { this._soundtouch.tempo = tempo; } connect(toNode) { this._node.connect(toNode); } disconnect() { this._node.disconnect(); } on(eventName, cb) { this.listeners.push({ name: eventName, cb: cb }); this._node.addEventListener(eventName, event => cb(event.detail)); } off(eventName = null) { let listeners = this.listeners; if (eventName) { listeners = listeners.filter(e => e.name === eventName); } listeners.forEach(e => { this._node.removeEventListener(e.name, event => e.cb(event.detail)); }); } } export { AbstractFifoSamplePipe, PitchShifter, RateTransposer, SimpleFilter, SoundTouch, Stretch, WebAudioBufferSource, getWebAudioNode }; //# sourceMappingURL=soundtouch.js.map