Spaces:
Sleeping
Sleeping
| // 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; | |
| } | |
| } | |
| } | |