|
const looperProcessorModule = ` |
|
class LooperProcessor extends AudioWorkletProcessor { |
|
static get parameterDescriptors() { |
|
return [ |
|
{ |
|
name: "playbackRate", |
|
defaultValue: 1, |
|
minValue: 0 |
|
}, |
|
{ |
|
name: "loopStart", |
|
defaultValue: 0, |
|
minValue: 0 |
|
} |
|
]; |
|
} |
|
|
|
constructor({processorOptions}) { |
|
super(); |
|
this.bufferData = null; |
|
this.loopLength = processorOptions.loopLength; |
|
this.crossfadeLength = processorOptions.crossfadeLength; |
|
this.position = 0; |
|
this.currentLoopStart = -1; |
|
this.nextLoopStart = -1; |
|
this.port.onmessage = this.onmessage.bind(this); |
|
} |
|
|
|
onmessage(msg) { |
|
switch (msg.data.msg) { |
|
case 'setBuffer': |
|
this.bufferData = msg.data.data; |
|
break; |
|
} |
|
} |
|
|
|
process(inputs, outputs, params) { |
|
if (!this.bufferData) return true; |
|
|
|
const playbackRate = params.playbackRate; |
|
const playbackRateARate = playbackRate.length > 1; |
|
const loopStart = params.loopStart; |
|
const loopStartARate = loopStart.length > 1; |
|
const quantumSize = outputs[0][0].length; |
|
|
|
|
|
for (let s=0 ; s<quantumSize ; s++) { |
|
for (let o=0 ; o<outputs.length ; o++) { |
|
for (let oCh=0 ; oCh<outputs[o].length ; oCh++) { |
|
outputs[o][oCh][s] = 0; |
|
} |
|
} |
|
|
|
if (this.currentLoopStart === -1 || this.position > this.currentLoopStart + this.loopLength) { |
|
if (this.nextLoopStart >= 0) { |
|
this.currentLoopStart = this.nextLoopStart; |
|
this.nextLoopStart = -1; |
|
} else { |
|
this.currentLoopStart = loopStartARate ? loopStart[s] : loopStart[0]; |
|
} |
|
this.position = this.currentLoopStart; |
|
} |
|
|
|
if (this.position > this.currentLoopStart + this.loopLength - this.crossfadeLength) { |
|
let i = this.currentLoopStart + this.loopLength - this.position; |
|
let relI = i / this.crossfadeLength; |
|
if (this.nextLoopStart === -1) { |
|
this.nextLoopStart = loopStartARate ? loopStart[s] : loopStart[0]; |
|
} |
|
this.outputInterpolatedSampleValue(this.position, s, Math.sqrt(relI), outputs); |
|
this.outputInterpolatedSampleValue(this.nextLoopStart - i, s, Math.sqrt(1 - relI), outputs); |
|
} else { |
|
this.outputInterpolatedSampleValue(this.position, s, 1, outputs); |
|
} |
|
|
|
this.position += playbackRateARate ? playbackRate[s] : playbackRate[0]; |
|
} |
|
|
|
return true; |
|
} |
|
|
|
outputInterpolatedSampleValue(bufferPosition, outputPosition, weight, outputs) { |
|
let minPos = this.loopStart - this.crossfadeLength; |
|
let maxPos = this.loopEnd - 3; |
|
let iPos = Math.floor(bufferPosition); |
|
let frac = 0; |
|
if (iPos < minPos) { |
|
iPos = minPos; |
|
frac = 0; |
|
} else if (iPos > maxPos) { |
|
iPos = maxPos; |
|
frac = 1; |
|
} else { |
|
frac = bufferPosition - iPos; |
|
} |
|
for (let ch=0 ; ch<this.bufferData.length ; ch++) { |
|
const buf = this.bufferData[ch]; |
|
const a = buf[iPos - 1]; |
|
const b = buf[iPos]; |
|
const c = buf[iPos + 1]; |
|
const d = buf[iPos + 2]; |
|
const cMinusB = c - b; |
|
const sample = |
|
b + |
|
frac * (cMinusB - 0.1666667 * (1.0 - frac) * ((d - a - 3.0 * cMinusB) * frac + (d + 2.0 * a - 3.0 * b))); |
|
for (let o=0 ; o<outputs.length ; o++) { |
|
for (let oCh=ch ; ch<outputs[o].length ; ch += this.bufferData.length) { |
|
outputs[o][oCh][outputPosition] += sample * weight; |
|
} |
|
} |
|
} |
|
} |
|
} |
|
|
|
registerProcessor('looper-processor', LooperProcessor); |
|
`; |
|
class LooperNode extends AudioWorkletNode { |
|
static register(audioCtx) { |
|
return audioCtx.audioWorklet.addModule( |
|
`data:text/javascript,${encodeURIComponent(looperProcessorModule)}` |
|
); |
|
} |
|
|
|
constructor(loopLength, crossfadeLength, audioCtx) { |
|
super(audioCtx, "looper-processor", { |
|
numberOfInputs: 0, |
|
processorOptions: { loopLength, crossfadeLength }, |
|
}); |
|
this._buffer = null; |
|
} |
|
|
|
get playbackRate() { |
|
return this.parameters.get("playbackRate"); |
|
} |
|
|
|
get loopStart() { |
|
return this.parameters.get("loopStart"); |
|
} |
|
|
|
get buffer() { |
|
return this._buffer; |
|
} |
|
|
|
set buffer(buffer) { |
|
this._buffer = buffer; |
|
let data = []; |
|
for (let i = 0; i < buffer.numberOfChannels; i++) { |
|
data.push(buffer.getChannelData(0)); |
|
} |
|
this.port.postMessage({ msg: "setBuffer", data }); |
|
} |
|
} |
|
|
|
let audioCtx = new AudioContext(), |
|
comp = audioCtx.createDynamicsCompressor(), |
|
masterGain = audioCtx.createGain(), |
|
ws; |
|
|
|
let samplePromise = fetch("https://teropa.info/ext-assets/39914__digifishmusic__katy-sings-laaoooaaa.mp3") |
|
.then((res) => res.arrayBuffer()) |
|
.then((buf) => audioCtx.decodeAudioData(buf)); |
|
|
|
async function init(buffer) { |
|
if (ws) return; |
|
await LooperNode.register(audioCtx); |
|
comp.attack.value = 0.05; |
|
comp.release.value = 0.5; |
|
masterGain.gain.value = 0.75; |
|
ws = WaveSurfer.create({ |
|
container: "#wave", |
|
height: window.innerHeight, |
|
barHeight: 0.75, |
|
waveColor: "#99a", |
|
cursorWidth: 2, |
|
cursorColor: "rgba(255, 255, 255, 0)", |
|
progressColor: "rgba(255, 255, 255, 0)", |
|
plugins: [WaveSurfer.regions.create({})], |
|
}); |
|
ws.loadDecodedBuffer(buffer); |
|
ws.on("seek", (pos) => { |
|
audioCtx.resume(); |
|
comp.connect(masterGain); |
|
masterGain.connect(audioCtx.destination); |
|
startNote(buffer, Math.round(pos * buffer.length)); |
|
document.getElementById("hint").classList.add("gone"); |
|
}); |
|
} |
|
|
|
function startNote(sample, offset) { |
|
let playbackRate = 1.0; |
|
let rnd = Math.random(); |
|
if (rnd < 0.15) { |
|
playbackRate = 0.25; |
|
} else if (rnd < 0.3) { |
|
playbackRate = 0.5; |
|
} else { |
|
playbackRate += (Math.random() - 0.5) * 0.05; |
|
} |
|
let offsetLfoAmount = 7000; |
|
let offsetLfoFreq = Math.random() / 10; |
|
let loopLength = 1500 + Math.round(Math.random() * 1000); |
|
let crossfadeLength = Math.round(loopLength / 3); |
|
let attackDur = 3.0; |
|
let sustainDur = 10; |
|
let releaseDur = 15; |
|
|
|
let envelopeGain = audioCtx.createGain(); |
|
envelopeGain.gain.setValueAtTime(0, audioCtx.currentTime); |
|
envelopeGain.gain.linearRampToValueAtTime( |
|
1, |
|
audioCtx.currentTime + attackDur |
|
); |
|
envelopeGain.gain.setValueAtTime( |
|
1, |
|
audioCtx.currentTime + attackDur + sustainDur |
|
); |
|
envelopeGain.gain.exponentialRampToValueAtTime( |
|
0.0001, |
|
audioCtx.currentTime + attackDur + sustainDur + releaseDur |
|
); |
|
envelopeGain.connect(comp); |
|
|
|
let looper = new LooperNode(loopLength, crossfadeLength, audioCtx); |
|
looper.buffer = sample; |
|
looper.loopStart.value = offset; |
|
looper.playbackRate.value = playbackRate; |
|
looper.connect(envelopeGain); |
|
|
|
let offsetLfo = audioCtx.createOscillator(); |
|
offsetLfo.frequency.value = offsetLfoFreq; |
|
let offsetLfoGain = audioCtx.createGain(); |
|
offsetLfoGain.gain.value = offsetLfoAmount; |
|
offsetLfo.connect(offsetLfoGain); |
|
offsetLfoGain.connect(looper.loopStart); |
|
offsetLfo.start(); |
|
|
|
let region = ws.addRegion({ |
|
start: (offset / sample.length) * sample.duration, |
|
end: (offset / sample.length) * sample.duration + 0.07, |
|
drag: false, |
|
resize: false, |
|
color: "white", |
|
}); |
|
|
|
let running = true, |
|
startedAt = audioCtx.currentTime; |
|
let frame = () => { |
|
if (!running) return; |
|
let age = |
|
(audioCtx.currentTime - startedAt) / |
|
((attackDur + sustainDur + releaseDur) * 0.8); |
|
region.update({ |
|
color: `rgba(255, 255, 255, ${Math.min(1, Math.max(0, 1 - age))})`, |
|
}); |
|
setTimeout(frame); |
|
}; |
|
frame(); |
|
|
|
setTimeout(() => { |
|
envelopeGain.disconnect(); |
|
region.remove(); |
|
running = false; |
|
}, (attackDur + sustainDur + releaseDur) * 1000); |
|
} |
|
|
|
samplePromise.then(init); |