gsaon commited on
Commit
b84872e
·
verified ·
1 Parent(s): f599880

Load the app as one bundled classic script: module fetches are not authenticated in a private Space's iframe

Browse files
Files changed (2) hide show
  1. app.bundle.js +1687 -0
  2. index.html +9 -1
app.bundle.js ADDED
@@ -0,0 +1,1687 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /*
2
+ * GENERATED by build_bundle.py -- do not edit.
3
+ *
4
+ * The app's ES modules concatenated into one classic script. A private
5
+ * Space's cross-site iframe does not authenticate the module fetches issued
6
+ * while the page is parsing (they 401, and the module map then caches that
7
+ * failure), whereas classic scripts always carry the cookie. Edit the
8
+ * sources listed below and re-run build_bundle.py.
9
+ *
10
+ * Sources, in dependency order: ctc-frontend.js, ctc-tokenizer.js, ctc-engine.js, ctc-streaming.js, app.js
11
+ */
12
+ (() => {
13
+ 'use strict';
14
+ // Stands in for the module namespace: each module destructures what it
15
+ // imports off this and assigns its exports back onto it.
16
+ const __M = {};
17
+
18
+ // ==== ctc-frontend.js ===================================================
19
+ (() => {
20
+ /**
21
+ * Audio front-end for the FastConformer CTC model.
22
+ *
23
+ * A port of the model's `CtcConformerProcessor._frontend` (log-mel + deltas +
24
+ * frame stacking) plus the sidecar's `_agc`. The mel filterbank and the STFT
25
+ * window are NOT recomputed here -- they are loaded from `mel_filters.bin` /
26
+ * `stft_window.bin`, dumped by export_ctc_onnx.py straight out of the torchaudio
27
+ * objects the Python path uses. Reimplementing torchaudio's htk mel scale
28
+ * (norm=None) and torch.stft's window centring in JS is the most likely place for
29
+ * a silent mismatch, and a front-end that is subtly wrong degrades WER without
30
+ * ever raising an error.
31
+ *
32
+ * Verified bit-for-bit against the Python features by test-frontend.mjs.
33
+ */
34
+
35
+ const EPS = 1e-10;
36
+
37
+ /** In-place iterative radix-2 complex FFT (Cooley-Tukey, decimation in time). */
38
+ class FFT {
39
+ constructor(size) {
40
+ this.size = size;
41
+ this.levels = Math.log2(size) | 0;
42
+ if (2 ** this.levels !== size) throw new Error(`FFT size ${size} is not a power of two`);
43
+
44
+ // Bit-reversal permutation and twiddle tables, precomputed once: the
45
+ // front-end runs this transform ~100 times per second of audio.
46
+ this.rev = new Uint32Array(size);
47
+ for (let i = 0; i < size; i++) {
48
+ let r = 0;
49
+ for (let b = 0; b < this.levels; b++) r |= ((i >> b) & 1) << (this.levels - 1 - b);
50
+ this.rev[i] = r;
51
+ }
52
+ this.cos = new Float32Array(size / 2);
53
+ this.sin = new Float32Array(size / 2);
54
+ for (let i = 0; i < size / 2; i++) {
55
+ this.cos[i] = Math.cos((-2 * Math.PI * i) / size);
56
+ this.sin[i] = Math.sin((-2 * Math.PI * i) / size);
57
+ }
58
+ }
59
+
60
+ /** Transform `re`/`im` in place (both length `size`). */
61
+ run(re, im) {
62
+ const { size, rev, cos, sin } = this;
63
+ for (let i = 0; i < size; i++) {
64
+ const j = rev[i];
65
+ if (j > i) {
66
+ let t = re[i]; re[i] = re[j]; re[j] = t;
67
+ t = im[i]; im[i] = im[j]; im[j] = t;
68
+ }
69
+ }
70
+ for (let len = 2; len <= size; len <<= 1) {
71
+ const half = len >> 1;
72
+ const step = size / len;
73
+ for (let i = 0; i < size; i += len) {
74
+ for (let j = 0, k = 0; j < half; j++, k += step) {
75
+ const c = cos[k], s = sin[k];
76
+ const a = i + j, b = a + half;
77
+ const tre = re[b] * c - im[b] * s;
78
+ const tim = re[b] * s + im[b] * c;
79
+ re[b] = re[a] - tre; im[b] = im[a] - tim;
80
+ re[a] += tre; im[a] += tim;
81
+ }
82
+ }
83
+ }
84
+ }
85
+ }
86
+
87
+ /**
88
+ * Centred moving average, cumsum-based (O(n)).
89
+ *
90
+ * Mirrors the sidecar's `_moving_avg`, including its edge handling: the valid
91
+ * region is padded by replicating the first/last valid value, with the extra
92
+ * sample going on the right when the window is even.
93
+ */
94
+ function movingAvg(v, win) {
95
+ const n = v.length;
96
+ win = Math.max(1, win | 0);
97
+ if (win <= 1 || n <= 1) return v;
98
+ win = Math.min(win, n);
99
+
100
+ // float64 accumulator: a float32 cumsum over ~10 s of samples loses enough
101
+ // precision to shift the gain envelope (the Python side uses float64 too).
102
+ const csum = new Float64Array(n + 1);
103
+ for (let i = 0; i < n; i++) csum[i + 1] = csum[i] + v[i];
104
+
105
+ const valid = n - win + 1;
106
+ const padL = (win - 1) >> 1;
107
+ const out = new Float32Array(n);
108
+ const first = (csum[win] - csum[0]) / win;
109
+ const last = (csum[n] - csum[n - win]) / win;
110
+ for (let i = 0; i < n; i++) {
111
+ const k = i - padL;
112
+ out[i] = k < 0 ? first : k >= valid ? last : (csum[k + win] - csum[k]) / win;
113
+ }
114
+ return out;
115
+ }
116
+
117
+ /**
118
+ * Automatic gain control -- port of the sidecar's `_agc`.
119
+ *
120
+ * Not cosmetic: the CTC front-end normalizes log-mel against the clip's *global
121
+ * peak*, so a quiet tail 20-40 dB below a loud head gets floored and silently
122
+ * dropped. A smoothed moving-RMS gain toward `target` equalizes the levels.
123
+ */
124
+ function agc(x, { sampleRate = 16000, target = 0.12, winMs = 150, maxGain = 20 } = {}) {
125
+ const n = x.length;
126
+ if (n === 0) return x;
127
+ const win = Math.max(1, Math.round((sampleRate * winMs) / 1000));
128
+
129
+ const sq = new Float32Array(n);
130
+ for (let i = 0; i < n; i++) sq[i] = x[i] * x[i];
131
+ const ms = movingAvg(sq, win);
132
+
133
+ const g = new Float32Array(n);
134
+ for (let i = 0; i < n; i++) {
135
+ const env = Math.sqrt(ms[i] + 1e-9);
136
+ g[i] = Math.min(Math.max(target / Math.max(env, 1e-4), 0), maxGain);
137
+ }
138
+ const gs = movingAvg(g, win); // smooth the gain to avoid pumping
139
+
140
+ const y = new Float32Array(n);
141
+ let peak = 0;
142
+ for (let i = 0; i < n; i++) {
143
+ y[i] = x[i] * gs[i];
144
+ const a = Math.abs(y[i]);
145
+ if (a > peak) peak = a;
146
+ }
147
+ if (peak > 0.97) {
148
+ const k = 0.97 / peak;
149
+ for (let i = 0; i < n; i++) y[i] *= k;
150
+ }
151
+ return y;
152
+ }
153
+
154
+ class CtcFrontend {
155
+ constructor(config, melFilters, window) {
156
+ this.config = config;
157
+ this.melFilters = melFilters; // [n_mels][n_freqs], mel-major
158
+ this.window = window; // n_fft, already centred by the exporter
159
+ this.fft = new FFT(config.n_fft);
160
+ this.nFreqs = config.n_fft / 2 + 1;
161
+ }
162
+
163
+ static async load(baseUrl = './onnx-ctc/') {
164
+ const get = async (name, as) => {
165
+ const r = await fetch(baseUrl + name);
166
+ if (!r.ok) throw new Error(`front-end asset ${name}: HTTP ${r.status}`);
167
+ return as === 'json' ? r.json() : new Float32Array(await r.arrayBuffer());
168
+ };
169
+ const [config, melFilters, window] = await Promise.all([
170
+ get('frontend_config.json', 'json'),
171
+ get('mel_filters.bin'),
172
+ get('stft_window.bin'),
173
+ ]);
174
+ return new CtcFrontend(config, melFilters, window);
175
+ }
176
+
177
+ /** Feature frames a clip of `nSamples` produces (before padding to pad_multiple). */
178
+ frameCount(nSamples) {
179
+ const s = this.config.stack_factor;
180
+ const melFrames = Math.floor(nSamples / this.config.hop_length);
181
+ return s * Math.ceil(melFrames / s);
182
+ }
183
+
184
+ /**
185
+ * waveform (Float32Array, 16 kHz mono) -> stacked log-mel(+delta) features.
186
+ *
187
+ * Returns `{ data, frames, dim }` where `data` is [frames, dim] row-major,
188
+ * ready to become the model's `input_features` tensor.
189
+ */
190
+ compute(wav) {
191
+ const { hop_length: hop, n_fft: nFft, n_mels: nMels, stack_factor: stack,
192
+ logmel_floor_db: floorDb, deltas: useDeltas } = this.config;
193
+ const nFrames = this.frameCount(wav.length);
194
+ if (nFrames === 0) return { data: new Float32Array(0), frames: 0, dim: this.config.input_dim };
195
+
196
+ // The processor right-pads the waveform so the trailing partial stack
197
+ // group is filled rather than dropped, then slices to exactly nFrames.
198
+ const need = (nFrames - 1) * hop + 1;
199
+ let x = wav;
200
+ if (x.length < need) {
201
+ x = new Float32Array(need);
202
+ x.set(wav);
203
+ }
204
+
205
+ // torch.stft(center=True, pad_mode='reflect'): reflect-pad by n_fft/2 so
206
+ // frame i is centred on sample i*hop.
207
+ const half = nFft >> 1;
208
+ const padded = new Float32Array(x.length + nFft);
209
+ padded.set(x, half);
210
+ for (let i = 0; i < half; i++) {
211
+ padded[half - 1 - i] = x[Math.min(i + 1, x.length - 1)];
212
+ padded[half + x.length + i] = x[Math.max(x.length - 2 - i, 0)];
213
+ }
214
+
215
+ // Mel spectrogram, mel-major ([nMels][nFrames]) because deltas run along time.
216
+ const mel = new Float32Array(nMels * nFrames);
217
+ const re = new Float32Array(nFft);
218
+ const im = new Float32Array(nFft);
219
+ const power = new Float32Array(this.nFreqs);
220
+ for (let t = 0; t < nFrames; t++) {
221
+ const off = t * hop;
222
+ for (let i = 0; i < nFft; i++) {
223
+ re[i] = padded[off + i] * this.window[i];
224
+ im[i] = 0;
225
+ }
226
+ this.fft.run(re, im);
227
+ for (let k = 0; k < this.nFreqs; k++) power[k] = re[k] * re[k] + im[k] * im[k];
228
+ for (let m = 0; m < nMels; m++) {
229
+ const fb = m * this.nFreqs;
230
+ let acc = 0;
231
+ for (let k = 0; k < this.nFreqs; k++) acc += this.melFilters[fb + k] * power[k];
232
+ mel[m * nFrames + t] = acc;
233
+ }
234
+ }
235
+
236
+ // log10 with a floor relative to the clip's global peak, then the /4 +1
237
+ // scaling the model was trained with.
238
+ let mx = -Infinity;
239
+ for (let i = 0; i < mel.length; i++) {
240
+ const v = Math.log10(Math.max(mel[i], EPS));
241
+ mel[i] = v;
242
+ if (v > mx) mx = v;
243
+ }
244
+ const floor = mx - floorDb;
245
+ for (let i = 0; i < mel.length; i++) mel[i] = Math.max(mel[i], floor) / 4 + 1;
246
+
247
+ // Deltas: torchaudio.functional.compute_deltas(win_length=3) is
248
+ // (x[t+1] - x[t-1]) / 2 with replicate edge padding.
249
+ const nCh = useDeltas ? nMels * 2 : nMels;
250
+ const chans = new Float32Array(nCh * nFrames);
251
+ chans.set(mel);
252
+ if (useDeltas) {
253
+ for (let m = 0; m < nMels; m++) {
254
+ const src = m * nFrames;
255
+ const dst = (nMels + m) * nFrames;
256
+ for (let t = 0; t < nFrames; t++) {
257
+ const prev = mel[src + Math.max(t - 1, 0)];
258
+ const next = mel[src + Math.min(t + 1, nFrames - 1)];
259
+ chans[dst + t] = (next - prev) / 2;
260
+ }
261
+ }
262
+ }
263
+
264
+ // Stack: (nCh, nFrames) -> (nFrames/stack, stack*nCh), i.e. row j is the
265
+ // channels of frame stack*j followed by those of frame stack*j+1.
266
+ const outFrames = nFrames / stack;
267
+ const dim = stack * nCh;
268
+ const data = new Float32Array(outFrames * dim);
269
+ for (let j = 0; j < outFrames; j++) {
270
+ for (let k = 0; k < stack; k++) {
271
+ const t = j * stack + k;
272
+ const base = j * dim + k * nCh;
273
+ for (let c = 0; c < nCh; c++) data[base + c] = chans[c * nFrames + t];
274
+ }
275
+ }
276
+ return { data, frames: outFrames, dim };
277
+ }
278
+ }
279
+
280
+ Object.assign(__M, { agc, CtcFrontend });
281
+ })();
282
+
283
+ // ==== ctc-tokenizer.js ==================================================
284
+ (() => {
285
+ /**
286
+ * Decoder for the model's 16384-entry ByteLevel BPE vocabulary.
287
+ *
288
+ * Only decoding is needed (the model never encodes text), so export_ctc_onnx.py
289
+ * dumps the id->piece slice that can reach the decoder as vocab.json (~170 KB)
290
+ * instead of shipping the whole tokenizer.json.
291
+ *
292
+ * granite-speech-4.2-470m-turboctc uses a GPT-2 style **ByteLevel** tokenizer:
293
+ * every byte 0-255 is represented by one printable codepoint (space is `Ġ`), so a
294
+ * piece is a string of byte-stand-ins rather than text, and one UTF-8 character
295
+ * may be split across several pieces ("é" arrives as "Ã" + "©"). Decoding is
296
+ * therefore: map each character back to its byte, concatenate, then UTF-8 decode
297
+ * once at the end -- never per piece, or multi-byte characters break.
298
+ *
299
+ * (The earlier C-T26.18 build used a SentencePiece tokenizer whose decoder was
300
+ * Replace(U+2581 -> space) / ByteFallback / Fuse / Strip. That is a different
301
+ * algorithm and would silently produce mojibake here, so the exporter asserts the
302
+ * tokenizer's decoder type matches this implementation.)
303
+ */
304
+
305
+ /**
306
+ * GPT-2's byte <-> codepoint table, inverted to codepoint -> byte.
307
+ *
308
+ * The 188 bytes that are already printable map to themselves; the remaining 68
309
+ * are moved up to U+0100 and beyond so no piece ever contains a control
310
+ * character or a bare space.
311
+ */
312
+ function byteLevelCharToByte() {
313
+ const bytes = [];
314
+ for (let b = 0x21; b <= 0x7e; b++) bytes.push(b); // !..~
315
+ for (let b = 0xa1; b <= 0xac; b++) bytes.push(b); // ¡..¬
316
+ for (let b = 0xae; b <= 0xff; b++) bytes.push(b); // ®..ÿ
317
+ const codepoints = bytes.slice();
318
+ const printable = new Set(bytes);
319
+ let next = 0;
320
+ for (let b = 0; b < 256; b++) {
321
+ if (!printable.has(b)) {
322
+ bytes.push(b);
323
+ codepoints.push(256 + next);
324
+ next++;
325
+ }
326
+ }
327
+ const map = new Map();
328
+ for (let i = 0; i < bytes.length; i++) map.set(codepoints[i], bytes[i]);
329
+ return map;
330
+ }
331
+
332
+ class CtcDecoder {
333
+ constructor(pieces, numSpecialTokens) {
334
+ this.numSpecialTokens = numSpecialTokens;
335
+ const charToByte = byteLevelCharToByte();
336
+ // Pre-resolve every piece to its bytes, so decoding a frame of ids is a
337
+ // few array copies rather than per-call string work.
338
+ this.encoded = new Array(pieces.length);
339
+ for (let i = 0; i < pieces.length; i++) {
340
+ const piece = pieces[i];
341
+ const bytes = new Uint8Array(piece.length);
342
+ let n = 0;
343
+ for (const ch of piece) {
344
+ const b = charToByte.get(ch.codePointAt(0));
345
+ // A codepoint outside the table means this vocab was not produced
346
+ // by a ByteLevel tokenizer; skip rather than emit a wrong byte.
347
+ if (b !== undefined) bytes[n++] = b;
348
+ }
349
+ this.encoded[i] = bytes.subarray(0, n);
350
+ }
351
+ this.utf8Decoder = new TextDecoder('utf-8');
352
+ }
353
+
354
+ static async load(baseUrl = './onnx-ctc/') {
355
+ const [vocab, config] = await Promise.all([
356
+ fetch(baseUrl + 'vocab.json').then((r) => {
357
+ if (!r.ok) throw new Error(`vocab.json: HTTP ${r.status}`);
358
+ return r.json();
359
+ }),
360
+ fetch(baseUrl + 'frontend_config.json').then((r) => {
361
+ if (!r.ok) throw new Error(`frontend_config.json: HTTP ${r.status}`);
362
+ return r.json();
363
+ }),
364
+ ]);
365
+ return new CtcDecoder(vocab, config.num_special_tokens);
366
+ }
367
+
368
+ /** Decode content ids (all >= numSpecialTokens) to text. */
369
+ decode(ids) {
370
+ let total = 0;
371
+ for (const id of ids) {
372
+ const e = this.encoded[id - this.numSpecialTokens];
373
+ if (e) total += e.length;
374
+ }
375
+ const buf = new Uint8Array(total);
376
+ let at = 0;
377
+ for (const id of ids) {
378
+ const e = this.encoded[id - this.numSpecialTokens];
379
+ if (!e) continue; // out-of-range id: skip rather than poison the string
380
+ buf.set(e, at);
381
+ at += e.length;
382
+ }
383
+ return this.utf8Decoder.decode(buf);
384
+ }
385
+ }
386
+
387
+ /**
388
+ * Greedy CTC collapse: drop repeats, then the blank, then any remaining
389
+ * below-content ids.
390
+ *
391
+ * Order matters and matches the Python path: repeats are collapsed against the
392
+ * *raw* previous id (blanks included), which is what lets a blank between two
393
+ * identical tokens preserve both. For this model `numSpecialTokens` is 1 -- the
394
+ * CTC blank at id 0 is the only non-text id, and everything above it is a
395
+ * tokenizer id as-is.
396
+ */
397
+ function ctcCollapse(ids, { blankId = 0, numSpecialTokens = 1 } = {}) {
398
+ const out = [];
399
+ let prev = -1;
400
+ for (let i = 0; i < ids.length; i++) {
401
+ const id = ids[i];
402
+ if (id !== prev && id !== blankId && id >= numSpecialTokens) out.push(id);
403
+ prev = id;
404
+ }
405
+ return out;
406
+ }
407
+
408
+ Object.assign(__M, { CtcDecoder, ctcCollapse });
409
+ })();
410
+
411
+ // ==== ctc-engine.js =====================================================
412
+ (() => {
413
+ const { CtcFrontend, agc, CtcDecoder, ctcCollapse } = __M;
414
+ /**
415
+ * FastConformer CTC inference engine (onnxruntime-web, WebGPU).
416
+ *
417
+ * The model is a custom `ctc_conformer` architecture with no transformers.js
418
+ * support, so this drives an ONNX session directly: features -> one forward pass
419
+ * -> greedy CTC collapse. There is no autoregressive loop, no KV cache and no
420
+ * chat template; a window is one `session.run`.
421
+ *
422
+ * Two contracts come from export_ctc_onnx.py and must hold here:
423
+ *
424
+ * - **Frames are padded to `pad_multiple`.** The encoder chunks attention into
425
+ * context_size (128) blocks at every layer and subsamples time by 4, so a
426
+ * frame count with no partial block anywhere is a multiple of 512 (10.24 s).
427
+ * The exported graph therefore has no tail-block path, and short windows are
428
+ * padded up with `padding_mask` marking the pad. Measured cost of the masked
429
+ * padding on granite-speech-4.2-470m-turboctc: none -- all 14 fixture clips
430
+ * decode identically to the unpadded path, with zero per-frame argmax flips.
431
+ * (Its encoder masks inside the conv module specifically so a padded frame
432
+ * cannot leak through the stride-2 kernel into the last valid frames.)
433
+ *
434
+ * - **The graph returns ids, not logits.** `[1, T/4, 16384]` float32 logits
435
+ * would be an ~8 MB device-to-host copy per window; argmax and the
436
+ * log-softmax max happen inside the graph instead, so a window costs ~1 KB of
437
+ * output. `top_logprob` is the confidence signal the streaming gate uses.
438
+ */
439
+
440
+
441
+
442
+ const CACHE_NAME = 'granite-ctc-models-v1';
443
+
444
+ /**
445
+ * Cache-first fetch, so a reload doesn't re-download hundreds of MB.
446
+ *
447
+ * The body is streamed to completion first and only then handed to the cache.
448
+ * Doing it the other way round -- `await cache.put(response.clone())` before
449
+ * reading -- makes the tee buffer the entire 312 MB while the cache write drains
450
+ * it, so the progress callback fires nothing for minutes and the UI looks hung.
451
+ * A failed cache write (quota, private mode) must not fail the load either: the
452
+ * bytes are already in hand, and the only cost is re-downloading next time.
453
+ */
454
+ async function cachedFetch(url, onProgress) {
455
+ const cache = await caches.open(CACHE_NAME).catch(() => null);
456
+ const hit = await cache?.match(url).catch(() => null);
457
+ if (hit) {
458
+ onProgress?.({ url, loaded: 1, total: 1, cached: true });
459
+ return hit.arrayBuffer();
460
+ }
461
+
462
+ const response = await fetch(url);
463
+ if (!response.ok) throw new Error(`${url}: HTTP ${response.status}`);
464
+
465
+ const total = Number(response.headers.get('content-length')) || 0;
466
+ let buffer;
467
+ if (!response.body) {
468
+ buffer = await response.arrayBuffer();
469
+ } else {
470
+ const reader = response.body.getReader();
471
+ const chunks = [];
472
+ let loaded = 0;
473
+ for (;;) {
474
+ const { done, value } = await reader.read();
475
+ if (done) break;
476
+ chunks.push(value);
477
+ loaded += value.length;
478
+ onProgress?.({ url, loaded, total });
479
+ }
480
+ const bytes = new Uint8Array(loaded);
481
+ let at = 0;
482
+ for (const c of chunks) { bytes.set(c, at); at += c.length; }
483
+ buffer = bytes.buffer;
484
+ }
485
+
486
+ if (cache) {
487
+ try {
488
+ await cache.put(url, new Response(buffer.slice(0), {
489
+ headers: { 'Content-Type': 'application/octet-stream', 'Content-Length': String(buffer.byteLength) },
490
+ }));
491
+ } catch (e) {
492
+ console.warn(`could not cache ${url} (will re-download next time):`, e);
493
+ }
494
+ }
495
+ return buffer;
496
+ }
497
+
498
+ class CtcEngine {
499
+ constructor({ session, frontend, decoder, config, device }) {
500
+ this.session = session;
501
+ this.frontend = frontend;
502
+ this.decoder = decoder;
503
+ this.config = config;
504
+ this.device = device;
505
+ }
506
+
507
+ /**
508
+ * Create the session and load the front-end assets.
509
+ *
510
+ * `device: 'webgpu'` falls back to wasm if the adapter or a kernel is
511
+ * missing -- a CTC pass is still usable on wasm, just slower.
512
+ */
513
+ static async load({
514
+ baseUrl = './onnx-ctc/',
515
+ modelFile = 'ctc_conformer_q4f16-b16.onnx',
516
+ device = 'webgpu',
517
+ onProgress = null,
518
+ allowFallback = true,
519
+ } = {}) {
520
+ const [frontend, decoder] = await Promise.all([
521
+ CtcFrontend.load(baseUrl),
522
+ CtcDecoder.load(baseUrl),
523
+ ]);
524
+
525
+ const modelUrl = baseUrl + modelFile;
526
+ // The exporter writes weights to `<model>.onnx.data` and records that
527
+ // basename inside the graph, so the session's externalData path must
528
+ // match it exactly.
529
+ const dataName = `${modelFile}.data`;
530
+ const [modelBuffer, dataBuffer] = await Promise.all([
531
+ cachedFetch(modelUrl, onProgress),
532
+ cachedFetch(baseUrl + dataName, onProgress),
533
+ ]);
534
+
535
+ const options = {
536
+ executionProviders: [device],
537
+ externalData: [{ path: dataName, data: dataBuffer }],
538
+ graphOptimizationLevel: 'all',
539
+ };
540
+ let session;
541
+ let used = device;
542
+ let fallbackError = null;
543
+ try {
544
+ session = await ort.InferenceSession.create(modelBuffer, options);
545
+ } catch (e) {
546
+ if (device !== 'webgpu' || !allowFallback) throw e;
547
+ console.warn('WebGPU session failed, falling back to wasm:', e);
548
+ fallbackError = String(e?.message || e);
549
+ used = 'wasm';
550
+ session = await ort.InferenceSession.create(modelBuffer, {
551
+ ...options,
552
+ executionProviders: ['wasm'],
553
+ });
554
+ }
555
+ const engine = new CtcEngine({ session, frontend, decoder, config: frontend.config, device: used });
556
+ // A WebGPU kernel can also fail on its *first run* rather than at session
557
+ // build (ORT-web reports unsupported MatMulNBits shapes that way), so the
558
+ // warmup pass is where a fallback is really decided.
559
+ const warmupError = await engine.warmup();
560
+ if (warmupError && used === 'webgpu') {
561
+ if (!allowFallback) throw new Error(`WebGPU warmup failed: ${warmupError}`);
562
+ console.warn('WebGPU warmup failed, falling back to wasm:', warmupError);
563
+ fallbackError = warmupError;
564
+ engine.device = 'wasm';
565
+ engine.session = await ort.InferenceSession.create(modelBuffer, {
566
+ ...options,
567
+ executionProviders: ['wasm'],
568
+ });
569
+ await engine.warmup();
570
+ }
571
+ engine.fallbackError = fallbackError;
572
+ return engine;
573
+ }
574
+
575
+ /**
576
+ * Run one throwaway pass so shader compilation doesn't land on the user's
577
+ * first spoken window (measured on WebGPU: 1271 ms first call vs 295 ms
578
+ * steady state). Every window is padded to the same frame count, so a single
579
+ * warmup covers the shapes the streaming loop will use.
580
+ */
581
+ async warmup() {
582
+ try {
583
+ const frames = this.config.pad_multiple;
584
+ await this.session.run({
585
+ input_features: new ort.Tensor('float32', new Float32Array(frames * this.config.input_dim), [1, frames, this.config.input_dim]),
586
+ padding_mask: new ort.Tensor('float32', new Float32Array(frames), [1, frames]),
587
+ });
588
+ return null;
589
+ } catch (e) {
590
+ return String(e?.message || e);
591
+ }
592
+ }
593
+
594
+ /** Encoder output frames covering `frames` of real audio (both subsample blocks floor-halve). */
595
+ static realOutFrames(frames) {
596
+ return Math.floor(Math.floor(frames / 2) / 2);
597
+ }
598
+
599
+ /**
600
+ * Transcribe a waveform (Float32Array, 16 kHz mono).
601
+ *
602
+ * `applyAgc` mirrors the sidecar: the log-mel front-end normalizes against
603
+ * the clip's global peak, so without it a quiet tail after a loud passage is
604
+ * floored away and its words are silently dropped.
605
+ */
606
+ async transcribe(wav, { applyAgc = true } = {}) {
607
+ const t0 = performance.now();
608
+ const audio = applyAgc ? agc(wav, { sampleRate: this.config.sample_rate }) : wav;
609
+ const { data, frames, dim } = this.frontend.compute(audio);
610
+ if (frames === 0) return { text: '', words: [], avgLogprob: null, frames: 0, ms: 0 };
611
+
612
+ const multiple = this.config.pad_multiple;
613
+ const padded = Math.ceil(frames / multiple) * multiple;
614
+ const features = new Float32Array(padded * dim);
615
+ features.set(data);
616
+ const mask = new Float32Array(padded).fill(1);
617
+ mask.fill(0, 0, frames);
618
+
619
+ const outputs = await this.session.run({
620
+ input_features: new ort.Tensor('float32', features, [1, padded, dim]),
621
+ padding_mask: new ort.Tensor('float32', mask, [1, padded]),
622
+ });
623
+
624
+ // The graph emits padded/4 frames; only those covering real audio may
625
+ // contribute tokens, so trim before collapsing.
626
+ const nOut = CtcEngine.realOutFrames(frames);
627
+ const ids = outputs.ids.data.subarray(0, nOut);
628
+ const logp = outputs.top_logprob.data.subarray(0, nOut);
629
+
630
+ const content = ctcCollapse(ids, {
631
+ blankId: this.config.blank_id,
632
+ numSpecialTokens: this.config.num_special_tokens,
633
+ });
634
+ const text = this.decoder.decode(content).trim();
635
+
636
+ // Confidence: mean top log-probability over non-blank frames. The CTC
637
+ // analogue of a mean per-token logprob -- clean speech sits near -0.03,
638
+ // silence/noise confabulation near -1.85 and below.
639
+ let sum = 0, n = 0;
640
+ for (let i = 0; i < nOut; i++) {
641
+ if (ids[i] !== this.config.blank_id) { sum += logp[i]; n++; }
642
+ }
643
+ return {
644
+ text,
645
+ words: text.length ? text.split(/\s+/) : [],
646
+ avgLogprob: n ? sum / n : null,
647
+ frames,
648
+ paddedFrames: padded,
649
+ ms: performance.now() - t0,
650
+ };
651
+ }
652
+ }
653
+
654
+ Object.assign(__M, { CtcEngine });
655
+ })();
656
+
657
+ // ==== ctc-streaming.js ==================================================
658
+ (() => {
659
+ /**
660
+ * Live dictation: growing window + LocalAgreement-2, ported from the macOS
661
+ * GraniteLiveDictation app (SpeechGate / Reconciler / StreamingCoordinator).
662
+ *
663
+ * continuous 16 kHz capture -> rolling window + energy SpeechGate
664
+ * every `step` while speech is in the window:
665
+ * window = audio[segment start .. now] (grows; overlaps the last)
666
+ * hypo = words(CTC transcription of window)
667
+ * a word COMMITS once two consecutive windows agree on it (longest
668
+ * common prefix); everything past that is TENTATIVE (shown grey)
669
+ * on a pause: one clean full-window pass commits the segment and re-anchors
670
+ *
671
+ * The window *grows* rather than sliding because the model returns text with no
672
+ * word timestamps -- there is no safe place to trim a fixed window without
673
+ * risking a mid-word cut. Anchoring each window at the start of the current
674
+ * speech segment makes reconciliation a plain prefix comparison and keeps the
675
+ * window bounded (a phrase between pauses).
676
+ *
677
+ * The tuning constants are the ones the macOS app converged on against real
678
+ * far-field recordings, not defaults -- see the comments on each.
679
+ */
680
+
681
+ const SAMPLE_RATE = 16000;
682
+ const INT16_SCALE = 32768; // gate thresholds are in Int16 RMS units (as tuned)
683
+
684
+ /**
685
+ * Energy speech/silence gate with an adaptive noise floor.
686
+ *
687
+ * Not a neural VAD -- an RMS gate whose enter/exit thresholds float with the
688
+ * measured background level. That matters for its one job: finding a *pause* so
689
+ * the growing window can flush. With fixed thresholds, a room whose ambient RMS
690
+ * sits above the silence cutoff never produces a silence frame, the endpoint
691
+ * timer never fires, and every window runs to the maxWindow cap and cuts
692
+ * mid-sentence.
693
+ */
694
+ class SpeechGate {
695
+ constructor(config = {}) {
696
+ this.config = {
697
+ endpointSilenceMs: 600, // contiguous below-exit audio that ends a segment
698
+ frameMs: 30,
699
+ // Biased toward inclusion: this CTC model doesn't confabulate on
700
+ // silence the way an autoregressive decoder does (it decodes to empty
701
+ // / low confidence, and the confidence gate backstops it), so
702
+ // admitting soft audio costs nothing -- while excluding it dropped
703
+ // real words (soft onsets never triggered, soft tails got endpointed
704
+ // mid-phrase).
705
+ onRatio: 1.5,
706
+ minEnter: 80,
707
+ // Exit stays near the original: dropping it toward ambient stops
708
+ // pause detection altogether and every segment runs to maxWindow,
709
+ // which wrecks paragraphing.
710
+ offRatio: 1.4,
711
+ minExit: 60,
712
+ onFrames: 2, // debounce transient clicks
713
+ // Seeded from the *mean* RMS over the startup window, not the min: the
714
+ // min tracks the quietest valley of fluctuating noise and seeds the
715
+ // floor far below the ambient body, which then lets noise read as
716
+ // speech.
717
+ calibrationMs: 360,
718
+ floorAdapt: 0.05, // per-frame EMA; at 30 ms frames ~0.6 s to settle
719
+ initialNoiseFloor: 60,
720
+ maxNoiseFloor: 4000,
721
+ ...config,
722
+ };
723
+ this.frameLen = Math.max(1, (SAMPLE_RATE * this.config.frameMs) / 1000);
724
+ this.calibrationFrames = Math.floor(this.config.calibrationMs / this.config.frameMs);
725
+ this.onEvent = null;
726
+ this.isSpeaking = false;
727
+ this.noiseFloor = this.config.initialNoiseFloor;
728
+ this.silenceMs = 0;
729
+ this.onCount = 0;
730
+ this.pending = [];
731
+ this.pendingLen = 0;
732
+ this.calibLeft = this.calibrationFrames;
733
+ this.calibSum = 0;
734
+ this.calibCount = 0;
735
+ }
736
+
737
+ get enterThreshold() { return Math.max(this.config.minEnter, this.noiseFloor * this.config.onRatio); }
738
+ get exitThreshold() { return Math.max(this.config.minExit, this.noiseFloor * this.config.offRatio); }
739
+
740
+ /**
741
+ * Clear per-utterance state. The noise floor and calibration are deliberately
742
+ * preserved: they describe the *room*, not the utterance, and this is called
743
+ * on every commit (including maxWindow cuts taken mid-speech), where
744
+ * re-seeding would calibrate the floor from speech energy and blind the gate.
745
+ */
746
+ reset() {
747
+ this.isSpeaking = false;
748
+ this.silenceMs = 0;
749
+ this.onCount = 0;
750
+ this.pending = [];
751
+ this.pendingLen = 0;
752
+ }
753
+
754
+ /** Re-arm calibration -- for a fresh capture session / new environment. */
755
+ recalibrate() {
756
+ this.noiseFloor = this.config.initialNoiseFloor;
757
+ this.calibLeft = this.calibrationFrames;
758
+ this.calibSum = 0;
759
+ this.calibCount = 0;
760
+ }
761
+
762
+ /** Feed a chunk of float samples (any length); re-framed into fixed blocks. */
763
+ feed(samples) {
764
+ if (!samples.length) return;
765
+ this.pending.push(samples);
766
+ this.pendingLen += samples.length;
767
+ while (this.pendingLen >= this.frameLen) {
768
+ const frame = this.takeFrame();
769
+ // process() emits events synchronously and a handler may call reset()
770
+ // re-entrantly (which drops `pending`), so the frame is removed first.
771
+ this.process(frame);
772
+ }
773
+ }
774
+
775
+ takeFrame() {
776
+ const frame = new Float32Array(this.frameLen);
777
+ let at = 0;
778
+ while (at < this.frameLen) {
779
+ const head = this.pending[0];
780
+ const take = Math.min(head.length, this.frameLen - at);
781
+ frame.set(take === head.length ? head : head.subarray(0, take), at);
782
+ at += take;
783
+ if (take === head.length) this.pending.shift();
784
+ else this.pending[0] = head.subarray(take);
785
+ }
786
+ this.pendingLen -= this.frameLen;
787
+ return frame;
788
+ }
789
+
790
+ process(frame) {
791
+ const rms = rmsInt16(frame);
792
+
793
+ if (this.calibLeft > 0) {
794
+ this.calibLeft--;
795
+ this.calibSum += rms;
796
+ this.calibCount++;
797
+ this.noiseFloor = Math.min(Math.max(this.calibSum / this.calibCount, 1), this.config.maxNoiseFloor);
798
+ return;
799
+ }
800
+
801
+ this.adaptNoise(rms);
802
+ if (this.isSpeaking) {
803
+ if (rms < this.exitThreshold) {
804
+ this.silenceMs += this.config.frameMs;
805
+ if (this.silenceMs >= this.config.endpointSilenceMs) {
806
+ this.isSpeaking = false;
807
+ this.silenceMs = 0;
808
+ this.onCount = 0;
809
+ this.onEvent?.('speechEnded');
810
+ }
811
+ } else {
812
+ this.silenceMs = 0; // speech resumed; reset the endpoint timer
813
+ }
814
+ } else if (rms >= this.enterThreshold) {
815
+ this.onCount++;
816
+ if (this.onCount >= this.config.onFrames) {
817
+ this.isSpeaking = true;
818
+ this.silenceMs = 0;
819
+ this.onCount = 0;
820
+ this.onEvent?.('speechStarted');
821
+ }
822
+ } else {
823
+ this.onCount = 0;
824
+ }
825
+ }
826
+
827
+ /**
828
+ * Learn the floor from ambient frames only, tracking the *body* (mean) of the
829
+ * ambient level. Updating solely when not in a segment AND below the enter
830
+ * threshold keeps sustained speech (or a speech attack) from dragging the
831
+ * floor up to speech level.
832
+ */
833
+ adaptNoise(rms) {
834
+ if (this.isSpeaking || rms >= this.enterThreshold) return;
835
+ const a = this.config.floorAdapt;
836
+ this.noiseFloor = Math.min(Math.max((1 - a) * this.noiseFloor + a * rms, 1), this.config.maxNoiseFloor);
837
+ }
838
+ }
839
+
840
+ function rmsInt16(samples) {
841
+ if (!samples.length) return 0;
842
+ let acc = 0;
843
+ for (let i = 0; i < samples.length; i++) acc += samples[i] * samples[i];
844
+ return Math.sqrt(acc / samples.length) * INT16_SCALE;
845
+ }
846
+
847
+ /**
848
+ * LocalAgreement-2 reconciliation.
849
+ *
850
+ * Each step transcribes the growing window and yields a fresh hypothesis. Because
851
+ * the window overlaps the previous one, the two should agree on the stable part
852
+ * and differ only at the unfinished tail. A word is committed once two
853
+ * *consecutive* hypotheses agree on it (their longest common prefix, compared
854
+ * case-insensitively and ignoring edge punctuation); everything past that is
855
+ * tentative and may still change.
856
+ */
857
+ class Reconciler {
858
+ constructor() {
859
+ this.confirmed = [];
860
+ this.prevHypo = [];
861
+ }
862
+
863
+ update(hypothesis) {
864
+ const agreed = commonPrefixCount(this.prevHypo, hypothesis);
865
+ if (agreed > this.confirmed.length) {
866
+ this.confirmed.push(...hypothesis.slice(this.confirmed.length, agreed));
867
+ }
868
+ this.prevHypo = hypothesis;
869
+ const tentative = hypothesis.length > this.confirmed.length
870
+ ? hypothesis.slice(this.confirmed.length)
871
+ : [];
872
+ return { confirmed: this.confirmed, tentative };
873
+ }
874
+
875
+ /** End of segment: a clean full-window hypothesis is authoritative. */
876
+ finalize(words) {
877
+ this.confirmed = words;
878
+ this.prevHypo = words;
879
+ return this.confirmed;
880
+ }
881
+
882
+ reset() {
883
+ this.confirmed = [];
884
+ this.prevHypo = [];
885
+ }
886
+ }
887
+
888
+ /** Lowercase and strip edge punctuation so "Cat," and "cat" match. */
889
+ function normalizeWord(word) {
890
+ return word.replace(/^[^\p{L}\p{N}]+|[^\p{L}\p{N}]+$/gu, '').toLowerCase();
891
+ }
892
+
893
+ function commonPrefixCount(a, b) {
894
+ const n = Math.min(a.length, b.length);
895
+ let i = 0;
896
+ while (i < n && normalizeWord(a[i]) === normalizeWord(b[i])) i++;
897
+ return i;
898
+ }
899
+
900
+ /**
901
+ * Stock phrases the model confidently emits on ambient/silence -- the classic ASR
902
+ * hallucination. Confidence can't catch these (they score *higher* than genuine
903
+ * soft speech), so they're matched against the whole commit and dropped only when
904
+ * word density is low: a silence-padded hallucination is long and slow, a real
905
+ * crisp "thank you" is short and fast.
906
+ */
907
+ const CANNED_HALLUCINATIONS = new Set([
908
+ 'thank you', 'thank you very much', 'thanks for watching',
909
+ 'thank you for watching', 'thanks for watching everyone',
910
+ ]);
911
+
912
+ class StreamingCoordinator {
913
+ /**
914
+ * @param {object} opts
915
+ * @param {import('./ctc-engine.js').CtcEngine} opts.engine
916
+ * @param {(state: {committed: string, tentative: string}) => void} opts.onTranscript
917
+ * @param {(level: number) => void} [opts.onLevel] 0..1 input level
918
+ * @param {(info: object) => void} [opts.onStep] per-window diagnostics
919
+ * @param {(text: string) => Promise<string>} [opts.punctuate] committed-text post-pass
920
+ */
921
+ constructor({ engine, onTranscript, onLevel = null, onStep = null, punctuate = null, tuning = {} }) {
922
+ this.engine = engine;
923
+ this.onTranscript = onTranscript;
924
+ this.onLevel = onLevel;
925
+ this.onStep = onStep;
926
+ this.punctuate = punctuate;
927
+
928
+ this.tuning = {
929
+ stepMs: 400, // re-transcribe cadence. A window costs one padded
930
+ // forward pass (>= 10.24 s of frames, see
931
+ // ctc-engine), so this is coarser than the macOS
932
+ // app's 100 ms; an in-flight tick is dropped, so
933
+ // it self-limits either way.
934
+ minWindowSec: 0.4, // don't transcribe windows shorter than this
935
+ maxWindowSec: 10.0, // force-commit a window that grows this long with no pause
936
+ newlineGapMs: 1200, // a pause >= this starts a new paragraph
937
+ hardPauseMs: 1500,
938
+ prerollSec: 0.5, // audio kept before speech onset, so the first word isn't clipped
939
+ // Calibrated on real far-field logs: clean speech ~ -0.03, but genuine
940
+ // soft utterances scored -0.98..-1.34 and were wrongly rejected at a
941
+ // -0.6 gate, dropping whole clips; silence confabulation sits at
942
+ // -1.85 and below, so -1.5 lands in the gap.
943
+ minCommitAvgLogprob: -1.5,
944
+ cannedHallucinationMaxWps: 2.0,
945
+ ...tuning,
946
+ };
947
+
948
+ this.gate = new SpeechGate();
949
+ this.gate.onEvent = (e) => this.handleGate(e);
950
+ this.reconciler = new Reconciler();
951
+
952
+ this.window = []; // Float32Array chunks since the last commit
953
+ this.windowLen = 0;
954
+ this.windowHasSpeech = false;
955
+ this.transcribing = false;
956
+ this.windowGen = 0;
957
+ this.paragraphs = []; // committed text, one entry per paragraph
958
+ this.lastVoiceAt = 0;
959
+ this.segmentGapMs = Infinity;
960
+ this.active = false;
961
+ this.audioContext = null;
962
+ this.mediaStream = null;
963
+ this.stepTimer = null;
964
+ // One ONNX session can't have two concurrent run() calls, and a commit
965
+ // fired by the gate can land while a step transcription is still in
966
+ // flight, so every engine call goes through this chain.
967
+ this.engineLock = Promise.resolve();
968
+ // Commits are started from the gate callback (inside onSamples), which
969
+ // can't await them; stop() drains these so the last words aren't lost.
970
+ this.pending = new Set();
971
+ }
972
+
973
+ /** Serialize engine access; returns the callback's result. */
974
+ async withEngine(fn) {
975
+ const prior = this.engineLock;
976
+ let release;
977
+ this.engineLock = new Promise((r) => { release = r; });
978
+ try {
979
+ await prior;
980
+ return await fn();
981
+ } finally {
982
+ release();
983
+ }
984
+ }
985
+
986
+ /** Track a fire-and-forget async task so stop() can wait for it. */
987
+ track(promise) {
988
+ this.pending.add(promise);
989
+ promise.catch(() => {}).finally(() => this.pending.delete(promise));
990
+ return promise;
991
+ }
992
+
993
+ /** Resolve once no commit or step is outstanding. */
994
+ async idle() {
995
+ while (this.pending.size) await Promise.allSettled([...this.pending]);
996
+ await this.engineLock;
997
+ }
998
+
999
+ async start() {
1000
+ if (this.active) return;
1001
+ this.mediaStream = await navigator.mediaDevices.getUserMedia({
1002
+ audio: {
1003
+ channelCount: 1,
1004
+ echoCancellation: true,
1005
+ noiseSuppression: false, // the gate wants the true ambient level
1006
+ autoGainControl: false, // the engine's own AGC handles levels
1007
+ },
1008
+ });
1009
+ this.audioContext = new AudioContext({ sampleRate: SAMPLE_RATE });
1010
+ // Resolve against this module, not the document: a bare './' would break
1011
+ // for any page not sitting in the same directory as these scripts.
1012
+ await this.audioContext.audioWorklet.addModule(new URL('./ctc-capture-worklet.js', document.baseURI));
1013
+ const source = this.audioContext.createMediaStreamSource(this.mediaStream);
1014
+ const node = new AudioWorkletNode(this.audioContext, 'ctc-capture');
1015
+ node.port.onmessage = (e) => this.onSamples(e.data);
1016
+ source.connect(node);
1017
+ // Keep the graph pulling without routing mic audio to the speakers.
1018
+ const sink = this.audioContext.createGain();
1019
+ sink.gain.value = 0;
1020
+ node.connect(sink).connect(this.audioContext.destination);
1021
+
1022
+ this.resetWindow();
1023
+ this.gate.recalibrate();
1024
+ this.paragraphs = [];
1025
+ this.lastVoiceAt = 0;
1026
+ this.segmentGapMs = Infinity;
1027
+ this.active = true;
1028
+ this.publish([], []);
1029
+ this.stepTimer = setInterval(() => this.tick(), this.tuning.stepMs);
1030
+ }
1031
+
1032
+ async stop() {
1033
+ if (!this.active) return;
1034
+ this.active = false;
1035
+ clearInterval(this.stepTimer);
1036
+ this.stepTimer = null;
1037
+ this.mediaStream?.getTracks().forEach((t) => t.stop());
1038
+ await this.audioContext?.close();
1039
+ this.audioContext = null;
1040
+ this.onLevel?.(0);
1041
+ // Let any commit the gate started finish before deciding whether the
1042
+ // window still holds uncommitted speech.
1043
+ await this.idle();
1044
+ if (this.windowHasSpeech) await this.commit('stop');
1045
+ this.resetWindow();
1046
+ this.publish([], []);
1047
+ }
1048
+
1049
+ // ---------------------------------------------------------------- capture
1050
+
1051
+ onSamples(chunk) {
1052
+ this.window.push(chunk);
1053
+ this.windowLen += chunk.length;
1054
+
1055
+ // Before speech is detected, keep only a short pre-roll. The window is
1056
+ // otherwise cleared only on commit, so a long ambient stretch would
1057
+ // accumulate unbounded and the first real utterance would commit an
1058
+ // enormous mostly-noise window.
1059
+ if (!this.windowHasSpeech) {
1060
+ const maxPreroll = Math.floor(SAMPLE_RATE * this.tuning.prerollSec);
1061
+ while (this.windowLen > maxPreroll && this.window.length > 1) {
1062
+ this.windowLen -= this.window.shift().length;
1063
+ }
1064
+ }
1065
+
1066
+ const now = performance.now();
1067
+ this.onLevel?.(meterLevel(rmsInt16(chunk)));
1068
+ this.gate.feed(chunk); // may fire speechEnded -> commit
1069
+
1070
+ // Drive the window's speech state from the gate's debounced,
1071
+ // calibration-aware decision rather than a parallel per-chunk RMS test:
1072
+ // such a test has no debounce and runs during calibration, so one noise
1073
+ // chunk latches the window open and the model transcribes ambient.
1074
+ if (this.gate.isSpeaking) {
1075
+ if (!this.windowHasSpeech) {
1076
+ this.segmentGapMs = this.lastVoiceAt ? now - this.lastVoiceAt : Infinity;
1077
+ this.windowHasSpeech = true;
1078
+ }
1079
+ this.lastVoiceAt = now;
1080
+ }
1081
+ }
1082
+
1083
+ handleGate(event) {
1084
+ if (event !== 'speechEnded') return; // capture is continuous; onset needs nothing
1085
+ // Called synchronously from onSamples, so the commit can only be tracked,
1086
+ // not awaited (see idle()).
1087
+ if (this.windowHasSpeech) this.track(this.commit('pause'));
1088
+ else this.resetWindow(); // pure-silence window: drop it
1089
+ }
1090
+
1091
+ // -------------------------------------------------------------- step loop
1092
+
1093
+ async tick() {
1094
+ if (!this.active || !this.windowHasSpeech || this.transcribing) return;
1095
+ if (this.windowLen / SAMPLE_RATE >= this.tuning.maxWindowSec) {
1096
+ await this.commit('maxWindow');
1097
+ return;
1098
+ }
1099
+ const speech = this.trimmedSpeech();
1100
+ if (speech.length / SAMPLE_RATE < this.tuning.minWindowSec) return;
1101
+
1102
+ const gen = this.windowGen;
1103
+ this.transcribing = true;
1104
+ try {
1105
+ const result = await this.withEngine(() => this.engine.transcribe(speech));
1106
+ if (gen !== this.windowGen) return; // window committed meanwhile; discard
1107
+ const { confirmed, tentative } = this.reconciler.update(result.words);
1108
+ this.publish(confirmed, tentative);
1109
+ this.onStep?.({ kind: 'step', windowSec: speech.length / SAMPLE_RATE, ...result });
1110
+ } catch (e) {
1111
+ console.error('step transcription failed:', e);
1112
+ } finally {
1113
+ this.transcribing = false;
1114
+ }
1115
+ }
1116
+
1117
+ /**
1118
+ * Commit the current window: snapshot it, immediately clear and re-anchor (so
1119
+ * capture continues uninterrupted and any in-flight partial is discarded),
1120
+ * then transcribe the snapshot once cleanly and append it.
1121
+ */
1122
+ async commit(reason) {
1123
+ const speech = this.trimmedSpeech();
1124
+ const windowSec = speech.length / SAMPLE_RATE;
1125
+ if (windowSec < this.tuning.minWindowSec) {
1126
+ this.resetWindow();
1127
+ return;
1128
+ }
1129
+ const gapMs = this.segmentGapMs;
1130
+ this.resetWindow();
1131
+ this.publish([], []);
1132
+
1133
+ let result;
1134
+ try {
1135
+ result = await this.withEngine(() => this.engine.transcribe(speech));
1136
+ } catch (e) {
1137
+ console.error('commit transcription failed:', e);
1138
+ return;
1139
+ }
1140
+ const words = result.words;
1141
+ const wps = windowSec > 0 ? words.length / windowSec : 0;
1142
+
1143
+ // Confidence gate: a low mean logprob means the model wasn't sure of its
1144
+ // own tokens -- the signature of confabulation on noise/silence. A null
1145
+ // confidence fails open.
1146
+ if (result.avgLogprob !== null && result.avgLogprob < this.tuning.minCommitAvgLogprob) {
1147
+ this.onStep?.({ kind: 'reject', why: 'confidence', reason, windowSec, wps, ...result });
1148
+ this.publish([], []);
1149
+ return;
1150
+ }
1151
+ const normalized = words.join(' ').toLowerCase();
1152
+ if (CANNED_HALLUCINATIONS.has(normalized) && wps < this.tuning.cannedHallucinationMaxWps) {
1153
+ this.onStep?.({ kind: 'reject', why: 'canned', reason, windowSec, wps, ...result });
1154
+ this.publish([], []);
1155
+ return;
1156
+ }
1157
+ if (!words.length) {
1158
+ this.publish([], []);
1159
+ return;
1160
+ }
1161
+
1162
+ await this.appendSegment(words.join(' '), gapMs, reason);
1163
+ this.onStep?.({ kind: 'commit', reason, windowSec, wps, gapMs, ...result });
1164
+ this.publish([], []);
1165
+ }
1166
+
1167
+ /**
1168
+ * Append a committed segment, opening a new paragraph after a real pause.
1169
+ *
1170
+ * Unlike the macOS app, sentence punctuation and capitalization are NOT
1171
+ * synthesized here -- the model emits raw lowercase text and the punctuator
1172
+ * restores both. A maxWindow cut is mid-utterance, so it extends the current
1173
+ * paragraph and is re-punctuated together with what follows.
1174
+ */
1175
+ async appendSegment(text, gapMs, reason) {
1176
+ const startsParagraph = this.paragraphs.length === 0
1177
+ || gapMs >= this.tuning.hardPauseMs
1178
+ || (gapMs >= this.tuning.newlineGapMs && reason !== 'maxWindow');
1179
+
1180
+ if (startsParagraph) this.paragraphs.push(text);
1181
+ else this.paragraphs[this.paragraphs.length - 1] += ` ${text}`;
1182
+
1183
+ if (this.punctuate) {
1184
+ const i = this.paragraphs.length - 1;
1185
+ const raw = this.paragraphs[i];
1186
+ try {
1187
+ this.paragraphs[i] = await this.punctuate(raw);
1188
+ } catch (e) {
1189
+ console.warn('punctuation failed; keeping raw text:', e);
1190
+ }
1191
+ }
1192
+ }
1193
+
1194
+ // ---------------------------------------------------------------- helpers
1195
+
1196
+ /**
1197
+ * The speech-bearing span of the window, with leading/trailing silence
1198
+ * removed. Trailing trim matters: the front-end normalizes against the clip's
1199
+ * global peak, so an untrimmed quiet tail gets amplified and decoded into
1200
+ * spurious words.
1201
+ */
1202
+ trimmedSpeech() {
1203
+ const audio = this.flattenWindow();
1204
+ const frame = 480; // 30 ms
1205
+ const floor = this.gate.exitThreshold;
1206
+ let first = -1, last = -1;
1207
+ for (let i = 0; i < audio.length; i += frame) {
1208
+ const end = Math.min(i + frame, audio.length);
1209
+ if (rmsInt16(audio.subarray(i, end)) >= floor) {
1210
+ if (first < 0) first = i;
1211
+ last = end;
1212
+ }
1213
+ }
1214
+ if (first < 0) return new Float32Array(0);
1215
+ const margin = frame * 3; // ~90 ms of context each side
1216
+ return audio.subarray(Math.max(0, first - margin), Math.min(audio.length, last + margin));
1217
+ }
1218
+
1219
+ flattenWindow() {
1220
+ if (this.window.length === 1) return this.window[0];
1221
+ const out = new Float32Array(this.windowLen);
1222
+ let at = 0;
1223
+ for (const c of this.window) { out.set(c, at); at += c.length; }
1224
+ this.window = [out]; // collapse so repeated ticks don't re-copy
1225
+ return out;
1226
+ }
1227
+
1228
+ resetWindow() {
1229
+ this.window = [];
1230
+ this.windowLen = 0;
1231
+ this.windowHasSpeech = false;
1232
+ this.reconciler.reset();
1233
+ this.gate.reset();
1234
+ this.windowGen++;
1235
+ }
1236
+
1237
+ publish(confirmed, tentative) {
1238
+ const live = confirmed.join(' ');
1239
+ const committed = live
1240
+ ? [...this.paragraphs, live].join('\n\n')
1241
+ : this.paragraphs.join('\n\n');
1242
+ this.onTranscript({ committed, tentative: tentative.join(' ') });
1243
+ }
1244
+
1245
+ /** Plain text of everything committed so far. */
1246
+ get transcript() {
1247
+ return this.paragraphs.join('\n\n');
1248
+ }
1249
+ }
1250
+
1251
+ /** Int16 RMS -> 0..1 meter level on a dBFS scale (-60 dBFS -> 0, 0 dBFS -> 1). */
1252
+ function meterLevel(rms) {
1253
+ if (rms <= 0) return 0;
1254
+ const dbfs = 20 * Math.log10(rms / 32767);
1255
+ return Math.max(0, Math.min(1, (dbfs + 60) / 60));
1256
+ }
1257
+
1258
+ Object.assign(__M, { SpeechGate, Reconciler, StreamingCoordinator });
1259
+ })();
1260
+
1261
+ // ==== app.js ============================================================
1262
+ (() => {
1263
+ const { CtcEngine, SpeechGate, StreamingCoordinator } = __M;
1264
+ /**
1265
+ * Granite Live Dictation (WebGPU)
1266
+ *
1267
+ * Real-time in-browser dictation with ibm-granite/granite-speech-4.2-470m-turboctc,
1268
+ * a 473M-parameter FastConformer CTC model. Unlike the previous
1269
+ * granite-speech-4.1-2b build, this is not a transformers.js pipeline:
1270
+ * `ctc_conformer` is a custom architecture, so the ONNX session is driven directly
1271
+ * (see ctc-engine.js) -- one forward pass and a greedy collapse per window, no
1272
+ * autoregressive decode. That is what makes it fast enough to re-transcribe a
1273
+ * growing window several times a second.
1274
+ *
1275
+ * The model emits raw lowercase, unpunctuated English. Punctuation and
1276
+ * capitalization are restored by the separate punctuator model (punctuator.js),
1277
+ * which is loaded on demand because it is a 209 MB download of its own.
1278
+ *
1279
+ * `ort` is the global from the onnxruntime-web script tag in index.html, shared
1280
+ * with punctuator.js so both sessions use one runtime.
1281
+ */
1282
+
1283
+
1284
+
1285
+ const MODEL_DIR = './onnx-ctc/';
1286
+ // 4-bit weights, block 32, on an fp16 graph (312 MB from a 1.9 GB fp32 export).
1287
+ // This is the only shape WebGPU can actually run: ORT-web's WebGPU EP enforces
1288
+ // `nbits == 4 || nbits == 2` on MatMulNBits, so an 8-bit build silently falls
1289
+ // back to wasm (1320 ms per window against 305 ms) and buys nothing -- on 8
1290
+ // minutes of LibriSpeech the 4-bit weights cost 0.09% WER against the fp32
1291
+ // export, and what residue remains in the browser comes from fp16 activations,
1292
+ // which more weight bits would not fix.
1293
+ const MODEL_FILE = 'ctc_conformer_q4f16.onnx';
1294
+ const SAMPLE_RATE = 16000;
1295
+ // One padded forward pass covers 10.24 s of frames, so file segments are capped
1296
+ // near that: longer segments cost proportionally more and buy nothing.
1297
+ const MAX_SEGMENT_SEC = 20;
1298
+
1299
+ let engine = null;
1300
+ let coordinator = null;
1301
+ let punctuatorReady = false;
1302
+ let punctuatorLoad = null;
1303
+ let isLoading = false;
1304
+ let currentAudioData = null;
1305
+
1306
+ const statusDot = document.getElementById('statusDot');
1307
+ const statusText = document.getElementById('statusText');
1308
+ const dictateBtn = document.getElementById('dictateBtn');
1309
+ const levelFill = document.getElementById('levelFill');
1310
+ const audioFile = document.getElementById('audioFile');
1311
+ const fileTile = document.querySelector('.file-label');
1312
+ const inputCard = document.querySelector('.input-card');
1313
+ const punctCheckbox = document.getElementById('punctCheckbox');
1314
+ const transcriptCard = document.getElementById('transcriptCard');
1315
+ const committedEl = document.getElementById('committedText');
1316
+ const tentativeEl = document.getElementById('tentativeText');
1317
+ const copyBtn = document.getElementById('copyBtn');
1318
+ const downloadBtn = document.getElementById('downloadBtn');
1319
+ const clearBtn = document.getElementById('clearBtn');
1320
+ const progressFill = document.getElementById('progressFill');
1321
+ const progressSection = document.getElementById('progressSection');
1322
+ const progressText = document.getElementById('progressText');
1323
+ const gpuInfo = document.getElementById('gpuInfo');
1324
+ const statsEl = document.getElementById('stats');
1325
+
1326
+ function setStatus(state, message) {
1327
+ statusDot.className = `status-dot ${state}`;
1328
+ statusText.textContent = message;
1329
+ }
1330
+
1331
+ function showProgress(show, text = '') {
1332
+ progressSection.style.display = show ? 'block' : 'none';
1333
+ if (text) progressText.textContent = text;
1334
+ }
1335
+
1336
+ // ---------------------------------------------------------------- model load
1337
+
1338
+ async function initEngine() {
1339
+ if (engine || isLoading) return engine;
1340
+ isLoading = true;
1341
+ try {
1342
+ // The runtime is a blocking classic script in index.html, so reaching here
1343
+ // without it means it was blocked (Edge Tracking Prevention, an extension,
1344
+ // a CSP). Say that plainly instead of throwing a bare ReferenceError.
1345
+ if (typeof ort === 'undefined') {
1346
+ throw new Error('the ONNX runtime failed to load (ort/ort.all.min.js) — '
1347
+ + 'check for a blocked request in the network tab');
1348
+ }
1349
+ if (!navigator.gpu) {
1350
+ gpuInfo.textContent = 'WebGPU not available — running on WASM (slower)';
1351
+ }
1352
+ // Absolute, not './ort/': ORT resolves a relative wasmPaths against the
1353
+ // location of ort.all.min.js (already in ort/), which would look for
1354
+ // ort/ort/ort-wasm-simd-threaded.jsep.mjs and fail to find a backend.
1355
+ ort.env.wasm.wasmPaths = new URL('./ort/', document.baseURI).href;
1356
+ // Threaded wasm needs cross-origin isolation (COOP *and* COEP). Hugging
1357
+ // Face Spaces send COOP but not COEP, so asking for threads there only
1358
+ // produces a console warning and a wasted init before ORT falls back.
1359
+ ort.env.wasm.numThreads = self.crossOriginIsolated ? (navigator.hardwareConcurrency || 4) : 1;
1360
+
1361
+ setStatus('loading', 'Downloading model...');
1362
+ showProgress(true, 'Downloading model...');
1363
+ const progress = {};
1364
+ engine = await CtcEngine.load({
1365
+ baseUrl: MODEL_DIR,
1366
+ modelFile: MODEL_FILE,
1367
+ device: navigator.gpu ? 'webgpu' : 'wasm',
1368
+ onProgress: ({ url, loaded, total, cached }) => {
1369
+ progress[url] = { loaded, total, cached };
1370
+ let l = 0, t = 0, allCached = true;
1371
+ for (const p of Object.values(progress)) {
1372
+ l += p.loaded;
1373
+ t += p.total;
1374
+ if (!p.cached) allCached = false;
1375
+ }
1376
+ if (allCached) {
1377
+ showProgress(true, 'Loading model from cache...');
1378
+ return;
1379
+ }
1380
+ const pct = t > 0 ? (l / t) * 100 : 0;
1381
+ progressFill.style.width = `${pct}%`;
1382
+ showProgress(true, `Downloading model... ${(l / 1e6).toFixed(0)} / ${(t / 1e6).toFixed(0)} MB`);
1383
+ },
1384
+ });
1385
+ progressFill.style.width = '0%';
1386
+ showProgress(false);
1387
+ // A wasm fallback is ~4x slower per window (1325 ms vs 305 ms for a 10 s
1388
+ // window), which is the difference between live and not, so say so
1389
+ // rather than letting it look like the model is just slow.
1390
+ gpuInfo.textContent = engine.device === 'webgpu'
1391
+ ? 'Backend: WebGPU'
1392
+ : 'Backend: WASM — WebGPU unavailable, so dictation will lag well behind speech';
1393
+ setStatus('ready', 'Ready — start dictation or upload audio');
1394
+ dictateBtn.disabled = false;
1395
+ audioFile.disabled = false;
1396
+ if (punctCheckbox.checked) startPunctuatorLoad();
1397
+ return engine;
1398
+ } catch (e) {
1399
+ console.error('Model loading failed:', e);
1400
+ setStatus('error', `Error: ${e.message || e}`);
1401
+ showProgress(false);
1402
+ throw e;
1403
+ } finally {
1404
+ isLoading = false;
1405
+ }
1406
+ }
1407
+
1408
+ /**
1409
+ * Load the punctuator (209 MB of its own).
1410
+ *
1411
+ * Kicked off in the background as soon as the CTC engine is ready, so it is
1412
+ * usually done before the user presses record -- loading it on the click would
1413
+ * put a ~30 s download between "start dictation" and hearing anything. Calls
1414
+ * share one promise, and only a caller that actually has to wait shows progress.
1415
+ */
1416
+ function startPunctuatorLoad() {
1417
+ if (!punctuatorLoad && typeof window.loadPunctuator === 'function') {
1418
+ punctuatorLoad = window.loadPunctuator()
1419
+ .then(() => { punctuatorReady = true; })
1420
+ .catch((e) => {
1421
+ console.warn('punctuator failed to load; transcript stays lowercase:', e);
1422
+ punctuatorReady = false;
1423
+ });
1424
+ }
1425
+ return punctuatorLoad;
1426
+ }
1427
+
1428
+ async function ensurePunctuator() {
1429
+ if (punctuatorReady || !punctCheckbox.checked) return punctuatorReady;
1430
+ const load = startPunctuatorLoad();
1431
+ if (!load) return false;
1432
+ const stillLoading = !punctuatorReady;
1433
+ if (stillLoading) showProgress(true, 'Loading punctuation model...');
1434
+ await load;
1435
+ if (stillLoading) showProgress(false);
1436
+ return punctuatorReady;
1437
+ }
1438
+
1439
+ /** Punctuate committed text, or pass it through if the model is off/unavailable. */
1440
+ async function punctuate(text) {
1441
+ if (!punctCheckbox.checked || !punctuatorReady) return text;
1442
+ return window.applyPunctuation(text);
1443
+ }
1444
+
1445
+ // ------------------------------------------------------------ live dictation
1446
+
1447
+ function renderTranscript({ committed, tentative }) {
1448
+ transcriptCard.style.display = 'block';
1449
+ committedEl.textContent = committed;
1450
+ tentativeEl.textContent = tentative ? (committed ? ` ${tentative}` : tentative) : '';
1451
+ const out = committedEl.parentElement;
1452
+ out.scrollTop = out.scrollHeight;
1453
+ const hasText = Boolean(committed || tentative);
1454
+ copyBtn.disabled = !hasText;
1455
+ downloadBtn.disabled = !hasText;
1456
+ }
1457
+
1458
+ async function toggleDictation() {
1459
+ if (coordinator) {
1460
+ dictateBtn.disabled = true;
1461
+ setStatus('processing', 'Finishing…');
1462
+ try {
1463
+ await coordinator.stop();
1464
+ } finally {
1465
+ coordinator = null;
1466
+ dictateBtn.classList.remove('recording');
1467
+ dictateBtn.querySelector('span').textContent = 'Start dictation';
1468
+ dictateBtn.disabled = false;
1469
+ levelFill.style.width = '0%';
1470
+ setStatus('ready', 'Ready — start dictation or upload audio');
1471
+ }
1472
+ return;
1473
+ }
1474
+
1475
+ await initEngine();
1476
+ await ensurePunctuator();
1477
+
1478
+ coordinator = new StreamingCoordinator({
1479
+ engine,
1480
+ onTranscript: renderTranscript,
1481
+ onLevel: (level) => { levelFill.style.width = `${(level * 100).toFixed(0)}%`; },
1482
+ onStep: (info) => {
1483
+ if (info.kind === 'step') {
1484
+ statsEl.textContent = `${info.windowSec.toFixed(1)}s window · ${info.ms.toFixed(0)} ms · conf ${info.avgLogprob?.toFixed(2) ?? 'n/a'}`;
1485
+ } else if (info.kind === 'reject') {
1486
+ console.log(`[reject:${info.why}] "${info.text}" conf=${info.avgLogprob?.toFixed(2)} wps=${info.wps.toFixed(2)}`);
1487
+ } else {
1488
+ console.log(`[commit:${info.reason}] ${info.windowSec.toFixed(1)}s ${info.ms.toFixed(0)}ms conf=${info.avgLogprob?.toFixed(2)} "${info.text}"`);
1489
+ }
1490
+ },
1491
+ punctuate,
1492
+ });
1493
+
1494
+ try {
1495
+ await coordinator.start();
1496
+ dictateBtn.classList.add('recording');
1497
+ dictateBtn.querySelector('span').textContent = 'Stop dictation';
1498
+ setStatus('recording', 'Listening — speak naturally, pause between sentences');
1499
+ } catch (e) {
1500
+ console.error('dictation failed to start:', e);
1501
+ coordinator = null;
1502
+ setStatus('error', e.name === 'NotAllowedError' ? 'Microphone access denied' : `Error: ${e.message || e}`);
1503
+ }
1504
+ }
1505
+
1506
+ // --------------------------------------------------------- file transcription
1507
+
1508
+ /**
1509
+ * Split a file into speech segments with the same energy gate the live path uses.
1510
+ *
1511
+ * This replaces the old Silero VAD download: the gate is already here, it is what
1512
+ * the streaming path is tuned against, and long audio has to be split anyway --
1513
+ * a single pass over a 5-minute file would allocate a ~250 MB logits tensor
1514
+ * inside the graph.
1515
+ */
1516
+ function segmentAudio(audio) {
1517
+ const gate = new SpeechGate();
1518
+ const frame = 480;
1519
+ const segments = [];
1520
+ let start = null;
1521
+ let consumed = 0;
1522
+
1523
+ gate.onEvent = (event) => {
1524
+ const t = consumed / SAMPLE_RATE;
1525
+ if (event === 'speechStarted') start = t;
1526
+ else if (start !== null) {
1527
+ segments.push({ start, end: t });
1528
+ start = null;
1529
+ }
1530
+ };
1531
+ for (let i = 0; i < audio.length; i += frame) {
1532
+ const chunk = audio.subarray(i, Math.min(i + frame, audio.length));
1533
+ consumed = i + chunk.length;
1534
+ gate.feed(chunk);
1535
+ }
1536
+ if (start !== null) segments.push({ start, end: audio.length / SAMPLE_RATE });
1537
+ if (!segments.length) return [{ start: 0, end: audio.length / SAMPLE_RATE }];
1538
+
1539
+ // Pad each segment slightly (the gate's onset debounce trims the first
1540
+ // phoneme) and cap the length.
1541
+ const out = [];
1542
+ for (const seg of segments) {
1543
+ let from = Math.max(0, seg.start - 0.2);
1544
+ const to = Math.min(audio.length / SAMPLE_RATE, seg.end + 0.2);
1545
+ while (to - from > MAX_SEGMENT_SEC) {
1546
+ out.push({ start: from, end: from + MAX_SEGMENT_SEC });
1547
+ from += MAX_SEGMENT_SEC;
1548
+ }
1549
+ out.push({ start: from, end: to });
1550
+ }
1551
+ return out;
1552
+ }
1553
+
1554
+ async function transcribeFile() {
1555
+ if (!currentAudioData) return;
1556
+ await initEngine();
1557
+ await ensurePunctuator();
1558
+
1559
+ setStatus('processing', 'Transcribing…');
1560
+ transcriptCard.style.display = 'block';
1561
+ const segments = segmentAudio(currentAudioData);
1562
+ const parts = [];
1563
+ let totalMs = 0;
1564
+
1565
+ for (let i = 0; i < segments.length; i++) {
1566
+ const seg = segments[i];
1567
+ showProgress(true, `Segment ${i + 1} / ${segments.length}`);
1568
+ progressFill.style.width = `${((i + 1) / segments.length) * 100}%`;
1569
+ const slice = currentAudioData.subarray(
1570
+ Math.floor(seg.start * SAMPLE_RATE),
1571
+ Math.floor(seg.end * SAMPLE_RATE),
1572
+ );
1573
+ const result = await engine.transcribe(slice);
1574
+ totalMs += result.ms;
1575
+ if (result.text) {
1576
+ parts.push(await punctuate(result.text));
1577
+ renderTranscript({ committed: parts.join('\n\n'), tentative: '' });
1578
+ }
1579
+ }
1580
+
1581
+ showProgress(false);
1582
+ progressFill.style.width = '0%';
1583
+ const audioSec = currentAudioData.length / SAMPLE_RATE;
1584
+ statsEl.textContent = `${audioSec.toFixed(1)}s audio · ${segments.length} segment(s) · ${totalMs.toFixed(0)} ms · ${(audioSec / (totalMs / 1000)).toFixed(0)}x realtime`;
1585
+ if (!parts.length) renderTranscript({ committed: '(no speech detected)', tentative: '' });
1586
+ setStatus('ready', 'Transcription complete');
1587
+ }
1588
+
1589
+ /** Decode any audio file to 16 kHz mono float32. */
1590
+ async function loadAudioFile(file) {
1591
+ setStatus('processing', 'Decoding audio…');
1592
+ try {
1593
+ const audioCtx = new AudioContext({ sampleRate: SAMPLE_RATE });
1594
+ const buffer = await audioCtx.decodeAudioData(await file.arrayBuffer());
1595
+ let audio = buffer.getChannelData(0);
1596
+ if (buffer.numberOfChannels > 1) {
1597
+ const mixed = new Float32Array(audio.length);
1598
+ for (let c = 0; c < buffer.numberOfChannels; c++) {
1599
+ const ch = buffer.getChannelData(c);
1600
+ for (let i = 0; i < mixed.length; i++) mixed[i] += ch[i] / buffer.numberOfChannels;
1601
+ }
1602
+ audio = mixed;
1603
+ }
1604
+ // decodeAudioData already resampled to the context rate.
1605
+ await audioCtx.close();
1606
+ currentAudioData = audio;
1607
+ await transcribeFile();
1608
+ } catch (e) {
1609
+ console.error('audio decode failed:', e);
1610
+ setStatus('error', `Could not read that file: ${e.message || e}`);
1611
+ }
1612
+ }
1613
+
1614
+ // -------------------------------------------------------------- transcript IO
1615
+
1616
+ function transcriptText() {
1617
+ return committedEl.textContent + (tentativeEl.textContent || '');
1618
+ }
1619
+
1620
+ async function copyTranscript() {
1621
+ await navigator.clipboard.writeText(transcriptText());
1622
+ const original = copyBtn.title;
1623
+ copyBtn.title = 'Copied';
1624
+ setTimeout(() => { copyBtn.title = original; }, 1200);
1625
+ }
1626
+
1627
+ function downloadTranscript() {
1628
+ const blob = new Blob([transcriptText()], { type: 'text/plain' });
1629
+ const a = document.createElement('a');
1630
+ a.href = URL.createObjectURL(blob);
1631
+ a.download = `transcript-${new Date().toISOString().slice(0, 19).replace(/[:T]/g, '-')}.txt`;
1632
+ a.click();
1633
+ URL.revokeObjectURL(a.href);
1634
+ }
1635
+
1636
+ function clearTranscript() {
1637
+ renderTranscript({ committed: '', tentative: '' });
1638
+ transcriptCard.style.display = 'none';
1639
+ statsEl.textContent = '';
1640
+ if (coordinator) coordinator.paragraphs = [];
1641
+ }
1642
+
1643
+ // ---------------------------------------------------------------------- wiring
1644
+
1645
+ dictateBtn.addEventListener('click', toggleDictation);
1646
+ audioFile.addEventListener('change', (e) => {
1647
+ const file = e.target.files[0];
1648
+ if (file) loadAudioFile(file);
1649
+ e.target.value = '';
1650
+ });
1651
+ copyBtn.addEventListener('click', copyTranscript);
1652
+ downloadBtn.addEventListener('click', downloadTranscript);
1653
+ clearBtn.addEventListener('click', clearTranscript);
1654
+
1655
+ // Drag and drop onto the input card
1656
+ for (const [event, handler] of [
1657
+ ['dragover', (e) => { e.preventDefault(); inputCard.classList.add('drag-over'); }],
1658
+ ['dragleave', () => inputCard.classList.remove('drag-over')],
1659
+ ['drop', (e) => {
1660
+ e.preventDefault();
1661
+ inputCard.classList.remove('drag-over');
1662
+ const file = e.dataTransfer.files[0];
1663
+ if (file?.type.startsWith('audio/')) loadAudioFile(file);
1664
+ else setStatus('error', 'Please drop an audio file');
1665
+ }],
1666
+ ]) {
1667
+ inputCard.addEventListener(event, handler);
1668
+ fileTile.addEventListener(event, (e) => e.stopPropagation(), { capture: false });
1669
+ }
1670
+
1671
+ punctCheckbox.addEventListener('change', () => {
1672
+ if (punctCheckbox.checked) ensurePunctuator();
1673
+ });
1674
+
1675
+ // Space bar toggles dictation, unless a control has focus.
1676
+ document.addEventListener('keydown', (e) => {
1677
+ if (e.code !== 'Space' || e.repeat) return;
1678
+ if (['INPUT', 'TEXTAREA', 'SELECT', 'BUTTON'].includes(document.activeElement?.tagName)) return;
1679
+ e.preventDefault();
1680
+ toggleDictation();
1681
+ });
1682
+
1683
+ setStatus('loading', 'Loading…');
1684
+ initEngine().catch(() => {});
1685
+ })();
1686
+
1687
+ })();
index.html CHANGED
@@ -283,6 +283,14 @@
283
  interferes with the jsdelivr request. See ort/README.md. -->
284
  <script src="./ort/ort.all.min.js"></script>
285
  <script src="punctuator.js?v=2"></script>
286
- <script type="module" src="app.js?v=101"></script>
 
 
 
 
 
 
 
 
287
  </body>
288
  </html>
 
283
  interferes with the jsdelivr request. See ort/README.md. -->
284
  <script src="./ort/ort.all.min.js"></script>
285
  <script src="punctuator.js?v=2"></script>
286
+ <!-- app.bundle.js, not `type="module" src="app.js"`, and this is not a
287
+ packaging preference. On a private Space the module fetches issued while
288
+ this page is parsing are not authenticated (they 401, and the module map
289
+ then caches that failure for the document's lifetime), so app.js was
290
+ fetched but could never execute and the page sat on "Loading..." forever.
291
+ Classic scripts carry the cookie every time -- punctuator.js and
292
+ ort.all.min.js always loaded. Generated by build_bundle.py from app.js and
293
+ the four ctc-*.js modules; edit those, then re-run it. -->
294
+ <script src="app.bundle.js?v=1"></script>
295
  </body>
296
  </html>