airwaves / web /engine.js
AndresCarreon's picture
AIRWAVES v0 — air-DJ (MediaPipe + Web Audio) + VoxCPM2 hype-man
860eb59 verified
Raw
History Blame Contribute Delete
7.28 kB
// 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;
}
}
}