| |
| |
| |
| |
| import { renderStems, makeIR, LOOP_LEN, BEAT } from "./groove.js"; |
|
|
| export class AudioEngine { |
| constructor() { |
| this.ctx = null; this.sources = []; this.ready = false; this.playing = false; |
| this._stutter = 0; this._gateT = 0; this._sched = null; |
| this.mode = "beat"; |
| this.extSource = null; this.extStream = null; |
| } |
|
|
| async init() { |
| this.ctx = new (window.AudioContext || window.webkitAudioContext)(); |
| const ctx = this.ctx; |
| this.buffers = await renderStems(); |
|
|
| this.master = ctx.createGain(); this.master.gain.value = 0.9; |
| this.analyser = ctx.createAnalyser(); this.analyser.fftSize = 256; this.analyser.smoothingTimeConstant = 0.78; |
|
|
| this.filter = ctx.createBiquadFilter(); |
| this.filter.type = "lowpass"; this.filter.frequency.value = 18000; this.filter.Q.value = 7; |
|
|
| this.gate = ctx.createGain(); this.gate.gain.value = 1; |
|
|
| this.convolver = ctx.createConvolver(); this.convolver.buffer = makeIR(ctx, 2.0); |
| this.reverbSend = ctx.createGain(); this.reverbSend.gain.value = 0; |
|
|
| this.delay = ctx.createDelay(1.0); this.delay.delayTime.value = BEAT / 2; |
| this.fb = ctx.createGain(); this.fb.gain.value = 0.36; |
| this.delay.connect(this.fb); this.fb.connect(this.delay); |
| this.delaySend = ctx.createGain(); this.delaySend.gain.value = 0; |
|
|
| this.stemGain = {}; |
| this._mix = { drums: 1.0, bass: 0.92, melody: 0.5 }; |
| for (const name of Object.keys(this.buffers)) { |
| const g = ctx.createGain(); g.gain.value = this._mix[name] ?? 1; g.connect(this.filter); |
| this.stemGain[name] = g; |
| } |
|
|
| this.filter.connect(this.gate); this.gate.connect(this.master); |
| this.filter.connect(this.reverbSend); this.reverbSend.connect(this.convolver); this.convolver.connect(this.master); |
| this.filter.connect(this.delaySend); this.delaySend.connect(this.delay); this.delay.connect(this.master); |
| this.master.connect(this.analyser); this.analyser.connect(ctx.destination); |
|
|
| this.ready = true; |
| } |
|
|
| play() { |
| if (!this.ready || this.playing) return; |
| const ctx = this.ctx; |
| if (ctx.state === "suspended") ctx.resume(); |
| const t0 = ctx.currentTime + 0.12; |
| this.sources = []; |
| for (const [name, buf] of Object.entries(this.buffers)) { |
| const src = ctx.createBufferSource(); |
| src.buffer = buf; src.loop = true; src.loopStart = 0; src.loopEnd = LOOP_LEN; |
| src.connect(this.stemGain[name]); src.start(t0); |
| this.sources.push(src); |
| } |
| this.playing = true; this._gateT = t0; |
| this._sched = setInterval(() => this._tick(), 25); |
| } |
|
|
| |
| setFilter(t01) { |
| const f = 180 * Math.pow(18000 / 180, Math.max(0, Math.min(1, t01))); |
| this.filter.frequency.setTargetAtTime(f, this.ctx.currentTime, 0.04); |
| } |
| setSpace(wet) { |
| this.reverbSend.gain.setTargetAtTime(wet * 0.9, this.ctx.currentTime, 0.06); |
| this.delaySend.gain.setTargetAtTime(wet * 0.5, this.ctx.currentTime, 0.06); |
| } |
| setWarp(t01) { |
| if (this.mode !== "beat") return; |
| const rate = 0.5 + t01; |
| for (const s of this.sources) s.playbackRate.setTargetAtTime(rate, this.ctx.currentTime, 0.06); |
| } |
| setStutter(amount) { this._stutter = amount; } |
| crossfade(t01) { |
| if (!this.stemGain.drums) return; |
| this.stemGain.drums.gain.setTargetAtTime(0.55 + (1 - t01) * 0.45, this.ctx.currentTime, 0.08); |
| this.stemGain.melody.gain.setTargetAtTime(0.28 + t01 * 0.5, this.ctx.currentTime, 0.08); |
| } |
| drop() { |
| const ctx = this.ctx, now = ctx.currentTime; |
| this.gate.gain.cancelScheduledValues(now); this.gate.gain.setValueAtTime(1, now); |
| this.master.gain.cancelScheduledValues(now); |
| this.master.gain.setValueAtTime(1.25, now); |
| this.master.gain.exponentialRampToValueAtTime(0.9, now + 0.5); |
| } |
| duck(level = 0.4, hold = 1.0) { |
| const g = this.master.gain, now = this.ctx.currentTime; |
| g.cancelScheduledValues(now); |
| g.setTargetAtTime(0.9 * level, now, 0.04); |
| g.setTargetAtTime(0.9, now + hold, 0.12); |
| } |
|
|
| |
| async captureTab() { |
| |
| |
| const stream = await navigator.mediaDevices.getDisplayMedia({ |
| video: true, |
| audio: { suppressLocalAudioPlayback: true, echoCancellation: false, noiseSuppression: false, autoGainControl: false }, |
| }); |
| stream.getVideoTracks().forEach((t) => t.stop()); |
| if (!stream.getAudioTracks().length) { |
| stream.getTracks().forEach((t) => t.stop()); |
| throw new Error("No tab audio — re-share a Chrome tab and tick “Share tab audio”."); |
| } |
| this._teardownExt(); |
| this.extStream = stream; |
| this.extSource = this.ctx.createMediaStreamSource(stream); |
| this.extSource.connect(this.filter); |
| for (const g of Object.values(this.stemGain)) g.gain.setTargetAtTime(0, this.ctx.currentTime, 0.05); |
| this.mode = "stream"; |
| stream.getAudioTracks()[0].addEventListener("ended", () => this.useBeat()); |
| return true; |
| } |
|
|
| useBeat() { |
| this._teardownExt(); |
| for (const [name, g] of Object.entries(this.stemGain)) { |
| g.gain.setTargetAtTime(this._mix[name] ?? 1, this.ctx.currentTime, 0.05); |
| } |
| this.mode = "beat"; |
| } |
|
|
| _teardownExt() { |
| if (this.extSource) { try { this.extSource.disconnect(); } catch (_) {} this.extSource = null; } |
| if (this.extStream) { this.extStream.getTracks().forEach((t) => t.stop()); this.extStream = null; } |
| } |
|
|
| |
| _tick() { |
| if (!this.playing) return; |
| const ctx = this.ctx, ahead = ctx.currentTime + 0.12; |
| if (this._stutter < 0.18) { this._gateT = ctx.currentTime; this.gate.gain.setTargetAtTime(1, ctx.currentTime, 0.01); return; } |
| const slice = (BEAT / 2) * (1 - this._stutter) + (BEAT / 8) * this._stutter; |
| while (this._gateT < ahead) { |
| const t = Math.max(this._gateT, ctx.currentTime); |
| this.gate.gain.setValueAtTime(1, t); |
| this.gate.gain.exponentialRampToValueAtTime(0.16, t + slice * 0.92); |
| this._gateT += slice; |
| } |
| } |
| } |
|
|