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;
    }
  }
}