// AIRWAVES live engine. Three looping stems -> per-stem gain -> shared lowpass // filter -> [dry through a stutter GATE] + [reverb send] + [delay send] -> master // -> analyser -> out. Hands drive the params directly (zero model in this path), // always via setTargetAtTime so the bend is buttery and never clicks. 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"; // "beat" (built-in, full control) | "stream" (captured tab) 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; // dry path + stutter gate 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 }; // drums lead, marimba underneath 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); // dry 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; // shared start => phase-locked stems 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); } // ---- hand-driven params ---- setFilter(t01) { // hero: hand height -> cutoff (exp 180Hz..18kHz) 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) { // pinch -> reverb + delay send 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) { // roll -> turntable warp (tempo + pitch coupled), 0.5x..1.5x if (this.mode !== "beat") return; // can't time-stretch a live captured stream const rate = 0.5 + t01; // t01 0..1 -> 0.5..1.5 for (const s of this.sources) s.playbackRate.setTargetAtTime(rate, this.ctx.currentTime, 0.06); } setStutter(amount) { this._stutter = amount; } // fist -> gate chop intensity crossfade(t01) { // two-hand: drums (0) <-> melody (1) 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() { // fist-release: slam open + a momentary push 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) { // dip the music so the hype-man cuts through 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); } // ---- input source: bend a captured browser tab (YouTube, SoundCloud…) ---- async captureTab() { // Chrome shows the tab/screen picker; user ticks "Share tab audio". // suppressLocalAudioPlayback mutes the original so only the BENT mix is heard. const stream = await navigator.mediaDevices.getDisplayMedia({ video: true, audio: { suppressLocalAudioPlayback: true, echoCancellation: false, noiseSuppression: false, autoGainControl: false }, }); stream.getVideoTracks().forEach((t) => t.stop()); // we only want the audio 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); // into the same bend chain 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()); // user hit "Stop sharing" 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; } } // stutter gate scheduler — chops the dry path into 1/8..1/32 slices while held. _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; // ~1/8 -> ~1/32 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; } } }