File size: 7,281 Bytes
860eb59 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 | // 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;
}
}
}
|