File size: 6,381 Bytes
1f28297
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
152
153
154
155
156
157
158
159
160
// @ts-check
/**
 * AudioWorkletProcessor that resamples the AudioContext rate (typically 48 kHz)
 * down to 16 kHz, packs the result as little-endian Int16 PCM, and posts it
 * back to the main thread in fixed-size chunks.
 *
 * The Hugging Face speech-to-speech WebSocket route expects the
 * `input_audio_buffer.append` payload at 16 kHz PCM16 mono.
 *
 * Design notes:
 *   - 48 -> 16 is an exact 3:1 ratio so we use a 3-tap boxcar average as a
 *     cheap low-pass before decimating. Good enough for voice STT; we lose
 *     a tiny bit of >8 kHz content which the pipeline discards anyway.
 *   - Output frames are emitted at the cadence dictated by `chunkMs`
 *     (default 40 ms = 640 samples = 1280 bytes). The OpenAI Realtime
 *     server batches incoming audio so the cadence is flexible; 20-100 ms
 *     is the sweet spot.
 *   - Float -> Int16 saturates to [-1, 1] before scaling.
 *   - Optional noise gate: per-chunk RMS decides open/closed against a
 *     threshold; the gain ramps (fast attack, hold, slow release) so word
 *     onsets aren't clipped and quiet tails don't click. The gate only
 *     affects the audio we SEND; the main-thread visualiser taps the raw
 *     mic separately. We post the chunk RMS up every frame so the Settings
 *     mic meter can show the live level against the threshold.
 */

const TARGET_RATE = 16000;
const DEFAULT_CHUNK_MS = 40;
// Gate envelope timing (fixed; only the threshold is user-tunable).
const GATE_ATTACK_MS = 5; // open almost instantly so word onsets survive
const GATE_HOLD_MS = 250; // stay open this long after the level drops back under
const GATE_RELEASE_MS = 80; // then fade closed over this long (no click)

class MicCaptureProcessor extends AudioWorkletProcessor {
  constructor(options) {
    super();
    const chunkMs = options?.processorOptions?.chunkMs ?? DEFAULT_CHUNK_MS;
    this._inputRate = sampleRate;
    this._ratio = this._inputRate / TARGET_RATE;
    this._chunkSamples16k = Math.round((TARGET_RATE * chunkMs) / 1000);
    this._scratch = new Float32Array(0);
    this._decimated = new Float32Array(this._chunkSamples16k);
    this._enabled = true;

    // Noise gate state. Disabled by default (pure passthrough).
    this._gateEnabled = false;
    this._thresholdLin = 0; // linear amplitude; signal RMS must exceed this to open
    this._gateGain = 1; // smoothed gain currently applied
    this._holdRemaining = 0; // samples left before the gate may start closing
    this._attackCoef = Math.exp(-1 / ((GATE_ATTACK_MS / 1000) * TARGET_RATE));
    this._releaseCoef = Math.exp(-1 / ((GATE_RELEASE_MS / 1000) * TARGET_RATE));
    this._holdSamples = Math.round((GATE_HOLD_MS / 1000) * TARGET_RATE);

    this.port.onmessage = (e) => {
      const data = e.data;
      if (data?.kind === "enable") this._enabled = !!data.value;
      else if (data?.kind === "gate") {
        this._gateEnabled = !!data.enabled;
        // dB -> linear amplitude. When off, threshold 0 keeps the gate open.
        this._thresholdLin = data.enabled ? Math.pow(10, data.thresholdDb / 20) : 0;
      }
    };
  }

  /**
   * Append `incoming` to the internal scratch buffer, then emit as many
   * full output chunks as we have material for.
   * @param {Float32Array} incoming
   */
  _ingest(incoming) {
    if (incoming.length === 0) return;
    const next = new Float32Array(this._scratch.length + incoming.length);
    next.set(this._scratch, 0);
    next.set(incoming, this._scratch.length);
    this._scratch = next;
    this._maybeEmit();
  }

  _maybeEmit() {
    const r = this._ratio;
    const n = this._chunkSamples16k;
    const needIn = Math.ceil(n * r);
    const dec = this._decimated;
    while (this._scratch.length >= needIn) {
      // 1. Decimate to 16 kHz floats and accumulate energy for the gate/meter.
      let sumSq = 0;
      if (Math.abs(r - 3) < 1e-6) {
        // 48 kHz -> 16 kHz fast path with boxcar lowpass.
        for (let i = 0; i < n; i++) {
          const idx = i * 3;
          const s = (this._scratch[idx] + this._scratch[idx + 1] + this._scratch[idx + 2]) / 3;
          dec[i] = s;
          sumSq += s * s;
        }
      } else {
        // Generic path: linear interpolation. Slower but works at any rate
        // (e.g. some Windows boxes report sampleRate=44100).
        for (let i = 0; i < n; i++) {
          const srcPos = i * r;
          const idx = Math.floor(srcPos);
          const frac = srcPos - idx;
          const a = this._scratch[idx];
          const b = this._scratch[idx + 1] ?? a;
          const s = a + (b - a) * frac;
          dec[i] = s;
          sumSq += s * s;
        }
      }
      const rms = Math.sqrt(sumSq / n);

      // 2. Decide the gate target for this chunk, then ramp sample-by-sample.
      let target = 1;
      if (this._gateEnabled) {
        if (rms >= this._thresholdLin) {
          this._holdRemaining = this._holdSamples; // re-arm the hold
        } else if (this._holdRemaining > 0) {
          this._holdRemaining -= n; // coasting through the hold window
        } else {
          target = 0;
        }
      }

      // 3. Apply the (smoothed) gain and pack to Int16.
      const out = new Int16Array(n);
      let gain = this._gateGain;
      for (let i = 0; i < n; i++) {
        const coef = target > gain ? this._attackCoef : this._releaseCoef;
        gain = target + (gain - target) * coef;
        const s = dec[i] * gain;
        const clamped = s < -1 ? -1 : s > 1 ? 1 : s;
        out[i] = clamped < 0 ? clamped * 0x8000 : clamped * 0x7fff;
      }
      this._gateGain = gain;

      // Shift the scratch buffer to keep only the trailing unused samples.
      const consumed = Math.floor(n * r);
      this._scratch = this._scratch.slice(consumed);

      // Live input level for the Settings meter (raw RMS, pre-gate).
      this.port.postMessage({ kind: "level", rms });

      if (this._enabled) {
        this.port.postMessage(out.buffer, [out.buffer]);
      }
      // When disabled (mic muted) we silently consume input so the worklet
      // stays alive and the buffer never grows unbounded.
    }
  }

  process(inputs) {
    const input = inputs[0];
    if (!input || input.length === 0 || !input[0]) return true;
    const mono = input[0];
    if (mono.length > 0) this._ingest(mono);
    return true;
  }
}

registerProcessor("mic-capture", MicCaptureProcessor);