WolfDavid commited on
Commit
b4ca58d
·
1 Parent(s): 76d5860

feat(01-07): add tiered browser ASR and wire push-to-talk into the shared turn loop

Browse files

- avatar/asr.js: transformers.js 4.2.0 from a pinned esm.sh URL, WebGPU when an adapter
really exists and WASM when it does not, announcing the active tier
- avatar/turn-loop.js: startListening/stopListening implemented once, imports mic.js and
asr.js itself so BOTH transports gained push-to-talk with zero changed lines
- avatar/facade.js: fan out events to turnLoop.observe so the loop learns speech-start
and speech-end without either transport knowing about the mic
- measured A/B over three models and three clips: default is whisper-base at q4
(135.8 MB, 779 ms WebGPU / 3636 ms WASM, CER 0.023), better-accuracy is
whisper-large-v3-turbo at q4f16 (537.4 MB, CER 0.000)
- two measured defects recorded in docs/ASR-TIERS.md: q8/int8/quantized cannot create a
session on the WASM backend at all, and a failed WebGPU init poisons the ONNX Runtime
backend registry so catch-and-retry is not a working fallback
- no remote ASR path exists; a seam guard fails the build if one appears

avatar/asr-harness.html ADDED
@@ -0,0 +1,327 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8" />
5
+ <title>ASR harness - gate, tier and model A/B</title>
6
+ <style>
7
+ body {
8
+ font: 14px/1.5 system-ui, sans-serif;
9
+ margin: 0;
10
+ padding: 1rem 1.25rem;
11
+ background: #14161a;
12
+ color: #e6e8ec;
13
+ }
14
+ h1 {
15
+ font-size: 1rem;
16
+ letter-spacing: 0.04em;
17
+ text-transform: uppercase;
18
+ color: #8ba0b6;
19
+ }
20
+ #ptt-button {
21
+ font: inherit;
22
+ padding: 0.6rem 1.2rem;
23
+ border-radius: 999px;
24
+ border: 1px solid #3a4757;
25
+ background: #1e232b;
26
+ color: inherit;
27
+ cursor: pointer;
28
+ }
29
+ #ptt-button[data-listening='true'] {
30
+ background: #7a2230;
31
+ border-color: #b8404f;
32
+ }
33
+ pre {
34
+ background: #10131a;
35
+ border: 1px solid #262d38;
36
+ border-radius: 6px;
37
+ padding: 0.75rem;
38
+ white-space: pre-wrap;
39
+ word-break: break-word;
40
+ }
41
+ .tier {
42
+ color: #7fd1a2;
43
+ }
44
+ </style>
45
+ </head>
46
+ <body>
47
+ <h1>ASR harness</h1>
48
+ <p>
49
+ No renderer, no host framework, no VRM. A fake stagePort is handed to the REAL
50
+ <code>createTurnLoop</code> and the REAL <code>installFacade</code>, so everything
51
+ exercised here is the same code both transports run.
52
+ </p>
53
+ <p>
54
+ <button id="ptt-button" type="button">hold to talk</button>
55
+ <span id="tier" class="tier">tier: (not loaded)</span>
56
+ </p>
57
+ <pre id="log">ready</pre>
58
+
59
+ <script type="module">
60
+ import { installFacade } from './facade.js';
61
+ import { createTurnLoop } from './turn-loop.js';
62
+ import { createAsr, MODELS, TIERS, hasWebGpu } from './asr.js';
63
+ import { GATE, REJECT, analyse, gate, isHallucination } from './mic.js';
64
+
65
+ const params = new URLSearchParams(location.search);
66
+
67
+ // ---------------------------------------------------------------- observability
68
+ window.__asrEvents = [];
69
+ window.__asrResults = null;
70
+ window.__pageErrors = [];
71
+ window.addEventListener('error', (e) => window.__pageErrors.push(String(e.message)));
72
+ window.addEventListener('unhandledrejection', (e) =>
73
+ window.__pageErrors.push(String(e.reason))
74
+ );
75
+
76
+ const logEl = document.getElementById('log');
77
+ const tierEl = document.getElementById('tier');
78
+ const button = document.getElementById('ptt-button');
79
+ function log(line) {
80
+ logEl.textContent = `${line}\n${logEl.textContent}`.split('\n').slice(0, 40).join('\n');
81
+ }
82
+
83
+ // ------------------------------------------------------------- the fake stage
84
+ // Six methods, no rendering. The stage core is proven separately by
85
+ // tests/e2e/test_stage_standalone.py; loading three.js and a 10 MiB VRM here would
86
+ // only make the ASR suite slower and its failures ambiguous.
87
+ const stageDebug = { ready: false, stage: 'fake', setListeningCalls: 0, setThinkingCalls: 0 };
88
+ const stagePort = {
89
+ async mount() {
90
+ stageDebug.ready = true;
91
+ return stageDebug;
92
+ },
93
+ async speak() {
94
+ return null;
95
+ },
96
+ async replayCached() {
97
+ throw new Error('nothing cached to re-play');
98
+ },
99
+ setThinking(value) {
100
+ stageDebug.setThinkingCalls += 1;
101
+ stageDebug.thinking = !!value;
102
+ },
103
+ setListening(value) {
104
+ stageDebug.setListeningCalls += 1;
105
+ stageDebug.listening = !!value;
106
+ button.dataset.listening = String(!!value);
107
+ },
108
+ async getDebug() {
109
+ return stageDebug;
110
+ },
111
+ };
112
+
113
+ // ----------------------------------------------------------------- the real loop
114
+ const asrOptions = {
115
+ model: params.get('model') || MODELS.default.model,
116
+ dtype: params.get('dtype') || MODELS.default.dtype,
117
+ device: params.get('device') || 'auto',
118
+ };
119
+
120
+ // ?processing=off drives the mic with echo cancellation, noise suppression and AGC
121
+ // all disabled. See the note on createMic: with Chrome's processing ON the cafe
122
+ // fixture is rejected by the RMS floor and the modulation condition never runs, so
123
+ // the suite exercises the gate in the configuration where it has no help.
124
+ const micOptions = { processing: params.get('processing') !== 'off' };
125
+
126
+ // The same deferred bus both transports use: createTurnLoop needs an emit before
127
+ // installFacade has built one, and events raised in between must not be dropped.
128
+ function deferredBus() {
129
+ let sink = null;
130
+ const queued = [];
131
+ const emit = (name, data) => {
132
+ if (sink) sink(name, data);
133
+ else queued.push([name, data]);
134
+ };
135
+ emit.connect = (real) => {
136
+ sink = real;
137
+ while (queued.length > 0) {
138
+ const [name, data] = queued.shift();
139
+ sink(name, data);
140
+ }
141
+ };
142
+ return emit;
143
+ }
144
+ const emit = deferredBus();
145
+
146
+ const turnLoop = createTurnLoop({ stagePort, emit, asrOptions, micOptions });
147
+ const installed = installFacade({ transport: 'harness', stagePort, turnLoop });
148
+
149
+ for (const name of ['ready', 'error', 'asr-tier', 'transcript', 'listening']) {
150
+ installed.avatar.on(name, (data) => {
151
+ window.__asrEvents.push({ event: name, data, at: performance.now() });
152
+ if (name === 'asr-tier') {
153
+ tierEl.textContent = `tier: ${data.tier} ${data.model} ${data.dtype} (${data.loadMs} ms)`;
154
+ }
155
+ log(`${name} ${JSON.stringify(data ?? null)}`);
156
+ });
157
+ }
158
+ emit.connect(installed.emit);
159
+ await installed.avatar.mount('');
160
+
161
+ // The control is plan 01-08's to own for real; this is the minimum needed to drive
162
+ // the same two facade methods a pointerdown/pointerup pair will drive there.
163
+ button.addEventListener('pointerdown', () => window.Avatar.startListening());
164
+ button.addEventListener('pointerup', () => window.Avatar.stopListening());
165
+
166
+ // ------------------------------------------------------------- test affordances
167
+ window.__events = (name) => window.__asrEvents.filter((e) => e.event === name);
168
+ window.__micDebug = () => ({ ...turnLoop.mic.__debug });
169
+ window.__asrDebug = () => ({ ...turnLoop.asr.__debug });
170
+ window.__gateConstants = () => ({ GATE, REJECT, TIERS, hasWebGpu: hasWebGpu() });
171
+ window.__gate = (samples, sampleRate) => gate(Float32Array.from(samples), sampleRate);
172
+ window.__analyse = (samples, sampleRate) => analyse(Float32Array.from(samples), sampleRate);
173
+ window.__isHallucination = (text, durationMs) => isHallucination(text, durationMs);
174
+
175
+ /** One full push-to-talk cycle, held for `holdMs`. Returns what the loop produced. */
176
+ window.__push = async (holdMs) => {
177
+ const started = await window.Avatar.startListening();
178
+ await new Promise((r) => setTimeout(r, holdMs));
179
+ const text = await window.Avatar.stopListening();
180
+ return { started, text, mic: { ...turnLoop.mic.__debug } };
181
+ };
182
+
183
+ // ------------------------------------------------------------------ the A/B rig
184
+ /** Decode a WAV URL to the mono 16 kHz Float32Array Whisper expects. */
185
+ async function loadClip(url) {
186
+ const bytes = await (await fetch(url)).arrayBuffer();
187
+ const Ctor = window.AudioContext || window.webkitAudioContext;
188
+ const ctx = new Ctor();
189
+ const decoded = await ctx.decodeAudioData(bytes.slice(0));
190
+ const frames = Math.round((decoded.duration * 16000));
191
+ const Offline = window.OfflineAudioContext || window.webkitOfflineAudioContext;
192
+ const offline = new Offline(1, frames, 16000);
193
+ const source = offline.createBufferSource();
194
+ source.buffer = decoded;
195
+ source.connect(offline.destination);
196
+ source.start(0);
197
+ const rendered = await offline.startRendering();
198
+ await ctx.close();
199
+ return {
200
+ samples: rendered.getChannelData(0).slice(),
201
+ durationSeconds: decoded.duration,
202
+ bytes: bytes.byteLength,
203
+ };
204
+ }
205
+ window.__loadClip = loadClip;
206
+ /** A bare ASR instance, so a driver can time a warm load on its own. */
207
+ window.__newAsr = (options) => createAsr({ ...options, emit: () => {} });
208
+
209
+ /** Measured gate statistics for a WAV URL, independent of any microphone. */
210
+ window.__measureClip = async (url) => {
211
+ const clip = await loadClip(url);
212
+ const verdict = gate(clip.samples, 16000);
213
+ return { url, ...verdict, sourceSeconds: clip.durationSeconds };
214
+ };
215
+
216
+ /**
217
+ * What the runtime actually stored, measured rather than looked up in a table.
218
+ * Cross-origin resource timing reports transferSize 0 without Timing-Allow-Origin,
219
+ * so the Cache API is the only honest source for "how many bytes is this model".
220
+ */
221
+ const MODEL_CACHE = 'transformers-cache';
222
+ window.__cacheBytes = async (modelId) => {
223
+ if (!('caches' in window)) return null;
224
+ const cache = await caches.open(MODEL_CACHE);
225
+ let total = 0;
226
+ let files = 0;
227
+ for (const request of await cache.keys()) {
228
+ if (modelId && !request.url.includes(modelId)) continue;
229
+ const response = await cache.match(request);
230
+ if (!response) continue;
231
+ total += (await response.blob()).size;
232
+ files += 1;
233
+ }
234
+ return { bytes: total, files };
235
+ };
236
+
237
+ /**
238
+ * The model A/B. candidates: [{model, dtype, device}], clips: [{url, reference}].
239
+ * Every number written to tests/fixtures/asr_ab_results.json comes from here.
240
+ *
241
+ * A COLD number comes from a fresh browser profile, never from deleting the
242
+ * cache: caches.delete() followed by caches.open() throws
243
+ * "Unexpected internal error" in Chromium 151 while the runtime still holds
244
+ * handles into the deleted cache.
245
+ */
246
+ window.__runAB = async (candidates, clips, options = {}) => {
247
+ const results = [];
248
+ const decoded = {};
249
+ for (const clip of clips) decoded[clip.url] = await loadClip(clip.url);
250
+
251
+ for (const candidate of candidates) {
252
+ const asr = createAsr({ ...candidate, emit: () => {} });
253
+ const record = {
254
+ model: candidate.model,
255
+ dtype: candidate.dtype,
256
+ requestedDevice: candidate.device || 'auto',
257
+ clips: [],
258
+ };
259
+ const loadStarted = performance.now();
260
+ try {
261
+ record.tier = await asr.init();
262
+ } catch (err) {
263
+ record.tier = null;
264
+ record.error = String(err?.message ?? err);
265
+ record.coldLoadMs = Math.round(performance.now() - loadStarted);
266
+ results.push(record);
267
+ log(`A/B FAILED ${candidate.model} ${candidate.dtype}: ${record.error}`);
268
+ continue;
269
+ }
270
+ record.coldLoadMs = asr.__debug.loadMs;
271
+ record.effectiveDtype = asr.__debug.dtype;
272
+ record.webgpuError = asr.__debug.webgpuError;
273
+ // Best-effort: CacheStorage.open() intermittently throws "Unexpected internal
274
+ // error" in Chromium 151 under a persistent context, and a size probe must
275
+ // never be able to discard a measurement run that already succeeded.
276
+ try {
277
+ record.cache = await window.__cacheBytes(candidate.model);
278
+ } catch (err) {
279
+ record.cache = { error: String(err?.message ?? err) };
280
+ }
281
+
282
+ // A second instantiation with the cache warm. The difference between the two
283
+ // is what a returning visitor actually experiences, and it is the number that
284
+ // decides whether a bigger default model is affordable.
285
+ if (options.warm) {
286
+ const warmStarted = performance.now();
287
+ const warmAsr = createAsr({ ...candidate, emit: () => {} });
288
+ try {
289
+ await warmAsr.init();
290
+ record.warmLoadMs = Math.round(performance.now() - warmStarted);
291
+ } catch (err) {
292
+ record.warmLoadError = String(err?.message ?? err);
293
+ }
294
+ }
295
+
296
+ for (const clip of clips) {
297
+ const c = decoded[clip.url];
298
+ const started = performance.now();
299
+ let transcript = null;
300
+ let error = null;
301
+ try {
302
+ transcript = (await asr.transcribe(c.samples, 16000)).text;
303
+ } catch (err) {
304
+ error = String(err?.message ?? err);
305
+ }
306
+ record.clips.push({
307
+ url: clip.url,
308
+ reference: clip.reference,
309
+ transcript,
310
+ error,
311
+ inferMs: Math.round(performance.now() - started),
312
+ clipSeconds: Number(c.durationSeconds.toFixed(3)),
313
+ });
314
+ log(`${candidate.model} ${clip.url.split('/').pop()} -> ${transcript}`);
315
+ }
316
+
317
+ results.push(record);
318
+ }
319
+ window.__asrResults = results;
320
+ return results;
321
+ };
322
+
323
+ window.__harnessReady = true;
324
+ log(`harness ready; navigator.gpu ${hasWebGpu() ? 'present' : 'absent'}`);
325
+ </script>
326
+ </body>
327
+ </html>
avatar/asr.js ADDED
@@ -0,0 +1,244 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // avatar/asr.js
2
+ //
3
+ // BROWSER-SIDE JAPANESE ASR. WebGPU when the machine has it, WASM when it does not.
4
+ //
5
+ // Every tier here costs ZERO visitor GPU quota, which is the whole point: SC-4 asks
6
+ // whether the turn loop still completes with the accelerated path disabled, and an ASR
7
+ // that reached for hosted acceleration would make that answer a lie about listening
8
+ // specifically. There is deliberately no remote transcription path in this file, and
9
+ // tests/test_transport_seam.py::test_no_remote_asr_path_exists keeps it that way.
10
+ //
11
+ // Like mic.js this module runs in the PARENT document under both transports and is
12
+ // imported by avatar/turn-loop.js, never by a transport file.
13
+ //
14
+ // Same esm.sh discipline as vrm-stage.js: one pinned URL, dynamically imported, no
15
+ // import map. A pinned URL is the difference between a reproducible Space rebuild and
16
+ // a model runtime that floats into a breaking release while nobody is watching.
17
+
18
+ const TRANSFORMERS_URL = 'https://esm.sh/@huggingface/transformers@4.2.0';
19
+
20
+ /** The two backends. Tier C - type instead of speak, VOIC-04 - is the universal floor
21
+ * and lives outside this module by construction. */
22
+ export const TIERS = {
23
+ WEBGPU: 'webgpu',
24
+ WASM: 'wasm',
25
+ };
26
+
27
+ /**
28
+ * The measured choice. See docs/ASR-TIERS.md for the A/B table these came from and for
29
+ * the caveat that the clips were synthesised, so the measured CER is a best case.
30
+ *
31
+ * THE DTYPE IS NOT NEGOTIABLE AND IS NOT q8. Measured on this stack, `q8`, `int8` and
32
+ * `quantized` all fail to create a session on the WASM backend at all:
33
+ *
34
+ * Can't create a session. ERROR_CODE: 1, ERROR_MESSAGE: qdq_actions.cc:137
35
+ * TransposeDQWeightsForMatMulNBits Missing required scale:
36
+ * model.decoder.embed_tokens.weight_merged_0_scale
37
+ *
38
+ * for whisper-base, whisper-small and whisper-large-v3-turbo alike. They load fine on
39
+ * WebGPU, which is exactly how a q8 default would ship: green on the developer's machine
40
+ * and dead on every browser that lacks an adapter - the tier the fallback exists for.
41
+ * `q4` is the only quantisation measured working on BOTH tiers. 01-RESEARCH.md's
42
+ * "whisper-base with dtype:'q8'" recommendation predates this measurement.
43
+ */
44
+ export const MODELS = {
45
+ default: { model: 'onnx-community/whisper-base', dtype: 'q4' },
46
+ accurate: { model: 'onnx-community/whisper-large-v3-turbo', dtype: 'q4f16' },
47
+ };
48
+
49
+ /** Frozen at the facade in plan 01-03; repeated here so the shape is visible at source. */
50
+ export const TRANSCRIBE_OPTIONS = {
51
+ language: 'ja',
52
+ task: 'transcribe',
53
+ chunk_length_s: 30,
54
+ return_timestamps: false,
55
+ };
56
+
57
+ /** Whisper's feature extractor expects exactly this rate. */
58
+ export const REQUIRED_SAMPLE_RATE = 16000;
59
+
60
+ let modulePromise = null;
61
+
62
+ /** One dynamic import for the whole page, however many pipelines get built on top. */
63
+ function loadRuntime() {
64
+ if (!modulePromise) {
65
+ modulePromise = import(TRANSFORMERS_URL).then((mod) => {
66
+ // Without this the runtime probes a same-origin /models/ path first and the
67
+ // console fills with 404s that look like real failures.
68
+ if (mod.env) {
69
+ mod.env.allowLocalModels = false;
70
+ mod.env.allowRemoteModels = true;
71
+ }
72
+ return mod;
73
+ });
74
+ }
75
+ return modulePromise;
76
+ }
77
+
78
+ /** Does this browser expose the WebGPU API surface? Cheap, synchronous. NOT sufficient. */
79
+ export function hasWebGpu() {
80
+ return typeof navigator !== 'undefined' && !!navigator.gpu;
81
+ }
82
+
83
+ /**
84
+ * Whether WebGPU can ACTUALLY be used, which is a different question from whether
85
+ * `navigator.gpu` exists.
86
+ *
87
+ * This probe is not defensive tidiness, it is load-bearing, and it is here because the
88
+ * obvious design does not work. Measured on this stack: when `navigator.gpu` is present
89
+ * but `requestAdapter()` resolves to null - the normal state of a headless browser, a
90
+ * machine whose GPU is blocklisted, or a browser started with GPU access denied - calling
91
+ * `pipeline(..., { device: 'webgpu' })` throws, AND POISONS THE ONNX RUNTIME BACKEND
92
+ * REGISTRY FOR THE WHOLE PAGE. A subsequent `pipeline(..., { device: 'wasm' })` for the
93
+ * same model then fails with the identical WebGPU error:
94
+ *
95
+ * no available backend found. ERR: [webgpu] Error: Failed to get GPU adapter.
96
+ *
97
+ * So "feature-detect navigator.gpu, catch the failure, re-instantiate on WASM" - which is
98
+ * what 01-RESEARCH.md prescribes - produces an app that is broken for exactly the users
99
+ * the fallback exists to serve. Probing the adapter first means the doomed call is never
100
+ * made and the registry is never poisoned. The try/catch below stays as a second line of
101
+ * defence for failures a probe cannot predict, such as an out-of-memory adapter.
102
+ *
103
+ * @returns {Promise<{available: boolean, reason: string|null}>}
104
+ */
105
+ export async function probeWebGpu() {
106
+ if (!hasWebGpu()) return { available: false, reason: 'navigator.gpu is not present' };
107
+ try {
108
+ const adapter = await navigator.gpu.requestAdapter();
109
+ if (!adapter) return { available: false, reason: 'requestAdapter() resolved to null' };
110
+ return { available: true, reason: null };
111
+ } catch (err) {
112
+ return { available: false, reason: String(err?.message ?? err) };
113
+ }
114
+ }
115
+
116
+ /**
117
+ * @param {object} opts
118
+ * @param {Function} [opts.emit] the facade event bus; 'asr-tier' is announced through it
119
+ * @param {string} [opts.model] defaults to the measured default model
120
+ * @param {string} [opts.dtype] dtype used on the WASM tier
121
+ * @param {string} [opts.webgpuDtype] dtype used on the WebGPU tier
122
+ * @param {string} [opts.device] 'auto' | 'webgpu' | 'wasm'; 'auto' feature-detects
123
+ */
124
+ export function createAsr({
125
+ emit = () => {},
126
+ model = MODELS.default.model,
127
+ dtype = MODELS.default.dtype,
128
+ webgpuDtype = null,
129
+ device = 'auto',
130
+ } = {}) {
131
+ const debug = {
132
+ tier: null,
133
+ model,
134
+ dtype: null,
135
+ loadMs: 0,
136
+ inferMs: 0,
137
+ transcribeCount: 0,
138
+ /** the API surface exists */
139
+ webgpuPresent: hasWebGpu(),
140
+ /** an adapter was actually obtained; null until init() has probed */
141
+ webgpuAvailable: null,
142
+ webgpuError: null,
143
+ };
144
+
145
+ let pipe = null;
146
+ let initPromise = null;
147
+
148
+ async function build(requestedTier) {
149
+ const { pipeline } = await loadRuntime();
150
+ if (requestedTier === TIERS.WEBGPU) {
151
+ const gpuDtype = webgpuDtype || dtype;
152
+ const built = await pipeline('automatic-speech-recognition', model, {
153
+ device: 'webgpu',
154
+ dtype: gpuDtype,
155
+ });
156
+ return { built, tier: TIERS.WEBGPU, dtype: gpuDtype };
157
+ }
158
+ // Ask for WASM by name rather than leaving `device` unset: the runtime's own
159
+ // auto-selection reaches for WebGPU whenever navigator.gpu exists, which is the
160
+ // same trap probeWebGpu() exists to avoid.
161
+ const built = await pipeline('automatic-speech-recognition', model, {
162
+ device: 'wasm',
163
+ dtype,
164
+ });
165
+ return { built, tier: TIERS.WASM, dtype };
166
+ }
167
+
168
+ /**
169
+ * Tier selection. WebGPU is opt-in and still labelled experimental upstream, so a
170
+ * failure to initialise it is an expected branch rather than an incident: catch it,
171
+ * record why, and re-instantiate on WASM. A learner must never see a broken app
172
+ * because their GPU adapter was busy.
173
+ */
174
+ async function init() {
175
+ if (initPromise) return initPromise;
176
+ initPromise = (async () => {
177
+ const started = performance.now();
178
+ let outcome = null;
179
+
180
+ // 'wasm' is the only value that skips the probe entirely; both 'auto' and an
181
+ // explicit 'webgpu' request are subject to it, because an explicit request that
182
+ // cannot be honoured must degrade rather than break the page.
183
+ if (device !== TIERS.WASM) {
184
+ const probe = await probeWebGpu();
185
+ debug.webgpuAvailable = probe.available;
186
+ if (!probe.available) debug.webgpuError = probe.reason;
187
+ if (probe.available) {
188
+ try {
189
+ outcome = await build(TIERS.WEBGPU);
190
+ } catch (err) {
191
+ debug.webgpuError = String(err?.message ?? err);
192
+ console.warn('WebGPU ASR init failed, falling back to WASM:', debug.webgpuError);
193
+ outcome = null;
194
+ }
195
+ }
196
+ } else {
197
+ debug.webgpuAvailable = false;
198
+ }
199
+ if (!outcome) outcome = await build(TIERS.WASM);
200
+
201
+ pipe = outcome.built;
202
+ debug.tier = outcome.tier;
203
+ debug.dtype = outcome.dtype;
204
+ debug.loadMs = Math.round(performance.now() - started);
205
+ emit('asr-tier', {
206
+ tier: debug.tier,
207
+ model: debug.model,
208
+ dtype: debug.dtype,
209
+ loadMs: debug.loadMs,
210
+ });
211
+ return debug.tier;
212
+ })();
213
+ return initPromise;
214
+ }
215
+
216
+ /**
217
+ * @param {Float32Array} samples mono
218
+ * @param {number} sampleRate must be 16000; mic.js already resamples
219
+ * @returns {Promise<{text:string, tier:string, model:string, inferMs:number}>}
220
+ */
221
+ async function transcribe(samples, sampleRate = REQUIRED_SAMPLE_RATE) {
222
+ if (sampleRate !== REQUIRED_SAMPLE_RATE) {
223
+ throw new Error(
224
+ `asr.transcribe expects ${REQUIRED_SAMPLE_RATE} Hz mono, got ${sampleRate} Hz`
225
+ );
226
+ }
227
+ await init();
228
+ const started = performance.now();
229
+ const out = await pipe(samples, { ...TRANSCRIBE_OPTIONS });
230
+ debug.inferMs = Math.round(performance.now() - started);
231
+ debug.transcribeCount += 1;
232
+ const text = String((Array.isArray(out) ? out[0]?.text : out?.text) ?? '').trim();
233
+ return { text, tier: debug.tier, model: debug.model, inferMs: debug.inferMs };
234
+ }
235
+
236
+ return {
237
+ __debug: debug,
238
+ init,
239
+ transcribe,
240
+ getTier: () => debug.tier,
241
+ getModel: () => debug.model,
242
+ isReady: () => pipe !== null,
243
+ };
244
+ }
avatar/facade.js CHANGED
@@ -89,6 +89,17 @@ export function installFacade({ transport, stagePort, turnLoop, onEmit }) {
89
  }
90
  }
91
  }
 
 
 
 
 
 
 
 
 
 
 
92
  if (typeof onEmit === 'function') {
93
  try {
94
  onEmit(name, data);
 
89
  }
90
  }
91
  }
92
+ // The turn loop is the only transport-agnostic behaviour module, and it needs to
93
+ // know when speech starts and ends so the mic can refuse to open into the avatar's
94
+ // own voice. Handing it the event stream here keeps that knowledge out of both
95
+ // transports and out of window.Avatar.
96
+ if (typeof turnLoop.observe === 'function') {
97
+ try {
98
+ turnLoop.observe(name, data);
99
+ } catch (err) {
100
+ console.error('Avatar turn-loop observer failed', err);
101
+ }
102
+ }
103
  if (typeof onEmit === 'function') {
104
  try {
105
  onEmit(name, data);
avatar/turn-loop.js CHANGED
@@ -6,6 +6,17 @@
6
  // The renderer is reachable only through the six-method stagePort. The host bridge is
7
  // reachable only through getServer(), which is a GETTER rather than a value so this
8
  // module never holds a host object and can be constructed before the bridge exists.
 
 
 
 
 
 
 
 
 
 
 
9
 
10
  /**
11
  * A deferred method. Calling one now fails loudly and names the plan that fills it in,
@@ -23,22 +34,82 @@ function notWiredYet(name, plan) {
23
  * @param {object} opts.stagePort the renderer boundary
24
  * @param {Function} [opts.emit] the facade's event bus
25
  * @param {Function} [opts.getServer] returns the host bridge, or null when there is none
 
 
 
 
26
  */
27
- export function createTurnLoop({ stagePort, emit = () => {}, getServer = () => null } = {}) {
 
 
 
 
 
 
 
 
28
  if (!stagePort) throw new Error('createTurnLoop: a stagePort is required');
29
 
30
  // The turn loop's own slice of __debug. The facade merges this object; it does not
31
- // own it, and this module does not own the facade's.
 
 
32
  const state = {
33
  thinking: false,
34
  listening: false,
35
  lastTurnId: null,
36
  replayCount: 0,
 
 
 
 
37
  };
38
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
39
  return {
40
  state,
41
  getServer,
 
 
 
42
 
43
  setThinking(value) {
44
  const on = !!value;
@@ -69,8 +140,62 @@ export function createTurnLoop({ stagePort, emit = () => {}, getServer = () => n
69
  }
70
  },
71
 
72
- startListening: notWiredYet('startListening', '01-07'),
73
- stopListening: notWiredYet('stopListening', '01-07'),
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
74
  dispatchTurn: notWiredYet('dispatchTurn', '01-08'),
75
  requestSlower: notWiredYet('requestSlower', '01-08'),
76
  };
 
6
  // The renderer is reachable only through the six-method stagePort. The host bridge is
7
  // reachable only through getServer(), which is a GETTER rather than a value so this
8
  // module never holds a host object and can be constructed before the bridge exists.
9
+ //
10
+ // Push-to-talk landed in wave 4. Note that mic.js and asr.js are imported HERE and
11
+ // constructed lazily by this module rather than being handed in by a transport: mic
12
+ // capture and ASR run in the parent document under BOTH transports, and the iframe
13
+ // transport has no AudioContext of its own to lend. Constructing them here means the
14
+ // two transport files needed zero lines of change to gain push-to-talk, which is the
15
+ // strongest possible form of the guarantee the seam exists to give. Both are still
16
+ // injectable through the factory so a harness can substitute a different model.
17
+
18
+ import { createAsr } from './asr.js';
19
+ import { createMic, isHallucination, REJECT } from './mic.js';
20
 
21
  /**
22
  * A deferred method. Calling one now fails loudly and names the plan that fills it in,
 
34
  * @param {object} opts.stagePort the renderer boundary
35
  * @param {Function} [opts.emit] the facade's event bus
36
  * @param {Function} [opts.getServer] returns the host bridge, or null when there is none
37
+ * @param {object} [opts.mic] overrides the mic built from avatar/mic.js
38
+ * @param {object} [opts.asr] overrides the ASR built from avatar/asr.js
39
+ * @param {object} [opts.asrOptions] model/dtype/device overrides for the default ASR
40
+ * @param {object} [opts.micOptions] capture overrides for the default mic
41
  */
42
+ export function createTurnLoop({
43
+ stagePort,
44
+ emit = () => {},
45
+ getServer = () => null,
46
+ mic = null,
47
+ asr = null,
48
+ asrOptions = {},
49
+ micOptions = {},
50
+ } = {}) {
51
  if (!stagePort) throw new Error('createTurnLoop: a stagePort is required');
52
 
53
  // The turn loop's own slice of __debug. The facade merges this object; it does not
54
+ // own it, and this module does not own the facade's. Every key is initialised here
55
+ // rather than on first use, because the parity suite compares __debug KEY SETS across
56
+ // transports and a lazily-added key would make that comparison time-dependent.
57
  const state = {
58
  thinking: false,
59
  listening: false,
60
  lastTurnId: null,
61
  replayCount: 0,
62
+ asrTier: null,
63
+ asrModel: null,
64
+ lastTranscript: null,
65
+ micRejectedCount: 0,
66
  };
67
 
68
+ let speaking = false;
69
+
70
+ function setListeningState(on) {
71
+ state.listening = on;
72
+ stagePort.setListening(on);
73
+ }
74
+
75
+ const micInstance =
76
+ mic ||
77
+ createMic({
78
+ emit,
79
+ onListening: setListeningState,
80
+ // Push-to-talk exists to make acoustic feedback impossible, so the mic refuses to
81
+ // open while the avatar is thinking or speaking. mic.js adds the 200 ms tail after
82
+ // speech-end on top of this.
83
+ isBusy: () => state.thinking || speaking,
84
+ ...micOptions,
85
+ });
86
+
87
+ const asrInstance = asr || createAsr({ emit, ...asrOptions });
88
+
89
+ /**
90
+ * The facade calls this for every event it fans out, under both transports - the
91
+ * iframe transport forwards the frame's events through the same bus. It is how this
92
+ * module learns that speech started or ended without holding a reference to the
93
+ * audio path, which belongs to the stage.
94
+ */
95
+ function observe(name, data) {
96
+ if (name === 'speech-start') {
97
+ speaking = true;
98
+ } else if (name === 'speech-end') {
99
+ speaking = false;
100
+ micInstance.noteSpeechEnd();
101
+ } else if (name === 'asr-tier' && data) {
102
+ state.asrTier = data.tier ?? null;
103
+ state.asrModel = data.model ?? null;
104
+ }
105
+ }
106
+
107
  return {
108
  state,
109
  getServer,
110
+ observe,
111
+ mic: micInstance,
112
+ asr: asrInstance,
113
 
114
  setThinking(value) {
115
  const on = !!value;
 
140
  }
141
  },
142
 
143
+ /**
144
+ * pointerdown on the push-to-talk control. Plan 01-08 wires the control itself;
145
+ * the behaviour is here so both transports get it from one implementation.
146
+ *
147
+ * @returns {Promise<boolean>} whether capture actually started
148
+ */
149
+ async startListening() {
150
+ const started = await micInstance.start();
151
+ if (!started) state.micRejectedCount = micInstance.__debug.rejectedCount;
152
+ return started;
153
+ },
154
+
155
+ /**
156
+ * pointerup. Gate -> ASR -> 'transcript'.
157
+ *
158
+ * A gated-out push emits NOTHING - not an empty transcript, which every downstream
159
+ * consumer would then have to special-case - and returns null.
160
+ *
161
+ * @returns {Promise<string|null>} the transcript, or null when nothing survived
162
+ */
163
+ async stopListening() {
164
+ const utterance = await micInstance.stop();
165
+ state.micRejectedCount = micInstance.__debug.rejectedCount;
166
+ if (!utterance.ok) return null;
167
+
168
+ let result;
169
+ try {
170
+ result = await asrInstance.transcribe(utterance.samples, utterance.sampleRate);
171
+ } catch (err) {
172
+ emit('error', { message: String(err?.message ?? err), where: 'stopListening' });
173
+ return null;
174
+ }
175
+
176
+ state.asrTier = asrInstance.getTier();
177
+ state.asrModel = asrInstance.getModel();
178
+
179
+ // Second line of defence: the audio passed the gate but Whisper still produced
180
+ // subtitle boilerplate. Only short pushes are eligible - see mic.js.
181
+ if (!result.text || isHallucination(result.text, utterance.durationMs)) {
182
+ micInstance.noteTranscriptRejected(
183
+ result.text ? REJECT.HALLUCINATION : REJECT.NO_AUDIO
184
+ );
185
+ state.micRejectedCount = micInstance.__debug.rejectedCount;
186
+ return null;
187
+ }
188
+
189
+ state.lastTranscript = result.text;
190
+ emit('transcript', {
191
+ text: result.text,
192
+ durationMs: Math.round(utterance.durationMs),
193
+ tier: result.tier,
194
+ gated: false,
195
+ });
196
+ return result.text;
197
+ },
198
+
199
  dispatchTurn: notWiredYet('dispatchTurn', '01-08'),
200
  requestSlower: notWiredYet('requestSlower', '01-08'),
201
  };
docs/ASR-TIERS.md ADDED
@@ -0,0 +1,200 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # ASR tiers and model choice (VOIC-02)
2
+
3
+ Every number on this page was measured on this project's own audio, by
4
+ `tests/fixtures/make_asr_ab_results.py` driving `avatar/asr-harness.html` in a real
5
+ browser. The raw data is `tests/fixtures/asr_ab_results.json`. Nothing here is quoted
6
+ from a model card.
7
+
8
+ | | |
9
+ |---|---|
10
+ | Measured | 2026-08-27 |
11
+ | Browser | Chromium 151.0.0.0 (Playwright), Windows NT 10.0 Win64 |
12
+ | GPU adapter | `intel xe-lpg` (WebGPU available, **headed** Chromium only) |
13
+ | Runtime | `https://esm.sh/@huggingface/transformers@4.2.0` |
14
+ | Clips | `speech_ja.wav`, `speech_ja_long.wav`, `speech_ja_slow.wav` — VOICEVOX ずんだもん, plan 01-04 |
15
+
16
+ ---
17
+
18
+ ## Tier design
19
+
20
+ | Tier | Path | GPU quota | Availability |
21
+ |---|---|---|---|
22
+ | **A** | `@huggingface/transformers` 4.2.0, `device: 'webgpu'` | **zero** | Chrome 113+ desktop, Chrome 121+ Android 12+, Safari macOS 26 / iOS 26, Firefox 141+ |
23
+ | **B** | same library, `device: 'wasm'` | **zero** | everywhere WASM+SIMD works; several times slower |
24
+ | **C** | type instead of speak (VOIC-04) | zero | universal — the true floor |
25
+ | **D** | hosted whisper-large-v3-turbo on accelerated hardware | **burns visitor quota** | **NOT built, and must not be.** It would consume the exact resource SC-4 tests the loop's survival without |
26
+
27
+ `tests/test_transport_seam.py::test_no_remote_asr_path_exists` fails the build if tier D
28
+ ever appears in `avatar/`.
29
+
30
+ ---
31
+
32
+ ## Gate thresholds (measured against the committed fixtures)
33
+
34
+ Read straight off the WAV files, independent of any microphone: 20 ms frames, `peak frame
35
+ RMS / median frame RMS`. Reproduce with `window.__measureClip(url)` in the harness.
36
+
37
+ | Fixture | duration | RMS | peak/median frame RMS | gate verdict | condition that fired |
38
+ |---|---|---|---|---|---|
39
+ | `silence_30s.wav` | 30.000 s | 0.00000 | ∞ (median is 0) | **REJECT** | `rms-floor` |
40
+ | `cafe_noise_30s.wav` | 30.000 s | 0.05760 | **1.961** | **REJECT** | `envelope-modulation` |
41
+ | `speech_ja.wav` | 1.056 s | 0.07153 | **10.711** | **ACCEPT** | — |
42
+ | `speech_ja_long.wav` | 5.504 s | 0.05515 | 7.509 | **ACCEPT** | — |
43
+ | `speech_ja_slow.wav` | 7.381 s | 0.04918 | 6.368 | **ACCEPT** | — |
44
+
45
+ Thresholds: **300 ms** duration floor, **0.01** RMS floor, **2.5** modulation floor.
46
+
47
+ The 2.5 figure is the whole reason the third condition exists. The café fixture is written
48
+ at −24.8 dBFS — roughly **six times** the RMS floor — so an RMS-only gate passes it
49
+ comfortably. It is rejected because steady broadband noise is not modulated: 1.961 against
50
+ speech's 6.4–10.7. The threshold sits with ~28% headroom below the noise and a 2.5×
51
+ margin below the least-modulated speech clip.
52
+
53
+ ### The same gate, measured through a real microphone
54
+
55
+ Chromium fed the fixture as its capture device
56
+ (`--use-file-for-fake-audio-capture`), which is the path a learner actually exercises.
57
+
58
+ | Fixture | hold | browser audio processing | duration | RMS | modulation | verdict |
59
+ |---|---|---|---|---|---|---|
60
+ | `silence_30s.wav` | 3000 ms | on | 2731 ms | 0.00000 | ∞ | REJECT `rms-floor` |
61
+ | `cafe_noise_30s.wav` | 3000 ms | on | 2731 ms | **0.00547** | 2.344 | REJECT `rms-floor` |
62
+ | `speech_ja.wav` | 1100 ms | on | 768 ms | 0.15451 | 6.694 | ACCEPT |
63
+ | `silence_30s.wav` | 3000 ms | **off** | 2731 ms | 0.00000 | ∞ | REJECT `rms-floor` |
64
+ | `cafe_noise_30s.wav` | 3000 ms | **off** | 2304 ms | 0.04409 | **1.360** | REJECT `envelope-modulation` |
65
+ | `speech_ja.wav` | 1400 ms | **off** | 1195 ms | 0.06717 | 15.012 | ACCEPT |
66
+ | `speech_ja.wav` | 150 ms | **off** | 0 ms | — | — | REJECT `duration-floor` |
67
+
68
+ **Chromium's WebRTC noise suppression is doing part of the gate's job for us** — it drops
69
+ the café fixture from 0.0577 to 0.0055 RMS, below the floor, so the modulation condition
70
+ is never consulted. That is a good outcome in production and a useless one in a test,
71
+ because it would leave the third condition unexercised while the suite looked green.
72
+ `tests/e2e/test_asr_standalone.py` therefore drives the harness with `?processing=off` and
73
+ verifies the gate in its **pessimistic** configuration — the raw microphone every browser
74
+ without WebRTC processing hands us anyway. Production keeps all three processing flags on.
75
+
76
+ ### The hallucination blocklist
77
+
78
+ Applied to the transcript, not the audio, and only under **1.5 s**. Seven known Japanese
79
+ subtitle-boilerplate strings, listed in `avatar/mic.js`.
80
+
81
+ Deliberately **not** blocklisted: the bare polite form 「ありがとうございました」. It is an
82
+ ordinary thing a learner says out loud, and swallowing a real utterance is a worse failure
83
+ than echoing one hallucination.
84
+ `tests/test_transport_seam.py::test_blocklist_does_not_swallow_ordinary_japanese` exists
85
+ specifically so a later "helpful" edit cannot add it back.
86
+
87
+ ---
88
+
89
+ ## Model A/B
90
+
91
+ CER is against the exact strings plan 01-04 handed to VOICEVOX, so the ground truth is
92
+ known rather than transcribed. "CER no-punct" strips 、。 and spaces from both sides,
93
+ because punctuation is a rendering choice rather than a mishearing. "infer" is the median
94
+ of the three clips. "first load" is the `Content-Length` of the two ONNX files the runtime
95
+ fetches.
96
+
97
+ | Model | dtype | device asked | tier used | first load | cold load | warm load | infer (p50) | CER | CER no-punct |
98
+ |---|---|---|---|---|---|---|---|---|---|
99
+ | `whisper-base` | q4 | wasm | wasm | 135.8 MB | 4064 ms | 1617 ms | 3636 ms | 0.062 | **0.023** |
100
+ | `whisper-base` | q4 | webgpu | webgpu | 135.8 MB | 4415 ms | 2152 ms | **779 ms** | 0.062 | **0.023** |
101
+ | `whisper-small` | q4 | wasm | wasm | 285.5 MB | 7355 ms | 3908 ms | 15485 ms | 0.083 | 0.023 |
102
+ | `whisper-small` | q4 | webgpu | webgpu | 285.5 MB | 12846 ms | 4480 ms | 1934 ms | 0.083 | 0.023 |
103
+ | `whisper-large-v3-turbo` | q4f16 | webgpu | webgpu | 537.4 MB | 29413 ms | 4055 ms | 2858 ms | 0.063 | **0.000** |
104
+ | `whisper-large-v3-turbo` | q4 | wasm | wasm | 723.9 MB | 20692 ms | 4113 ms | **68200 ms** | 0.063 | 0.000 |
105
+ | `whisper-base` | **q8** | wasm | **SESSION FAILED** | 73.3 MB | 3471 ms | — | — | — | — |
106
+ | `whisper-base` | q8 | webgpu | webgpu | 73.3 MB | 3474 ms | 2021 ms | 4679 ms | 0.021 | 0.000 |
107
+ | `whisper-small` | **q8** | wasm | **SESSION FAILED** | 237.5 MB | 5867 ms | — | — | — | — |
108
+
109
+ Transcripts, per clip, in `tests/fixtures/asr_ab_results.json`. The single error
110
+ `whisper-base` makes on this material is writing 今日は**良い**天気 where VOICEVOX was given
111
+ 今日は**いい**天気 — the same word, the same reading, a different orthography. It is not a
112
+ mishearing, which is why the punctuation-insensitive CER is 0.023 rather than 0.062.
113
+
114
+ ### The finding that changed the default: `q8` does not work on the WASM tier
115
+
116
+ `01-RESEARCH.md` recommends `onnx-community/whisper-base` with `dtype: 'q8'`. Measured,
117
+ that configuration **cannot create an inference session on the WASM backend at all**:
118
+
119
+ ```
120
+ Can't create a session. ERROR_CODE: 1, ERROR_MESSAGE: qdq_actions.cc:137
121
+ TransposeDQWeightsForMatMulNBits Missing required scale:
122
+ model.decoder.embed_tokens.weight_merged_0_scale
123
+ for node: model.decoder.embed_tokens.weight_transposed_DequantizeLinear
124
+ ```
125
+
126
+ The same error appears for `whisper-base`, `whisper-small` and `whisper-large-v3-turbo`,
127
+ and for the `int8` and `quantized` aliases. Every one of them loads fine on **WebGPU**,
128
+ which is precisely how this would have shipped: green on a developer machine with a GPU,
129
+ dead on every browser without one — the tier the fallback exists to serve. `q4` is the only
130
+ quantisation measured working on **both** tiers, so `q4` is the default.
131
+
132
+ ### The other finding: the documented fallback does not fall back
133
+
134
+ `01-RESEARCH.md` prescribes "feature-detect `navigator.gpu`, request `webgpu` when present,
135
+ catch initialisation failure, re-instantiate on WASM." Measured, that does not work.
136
+
137
+ When `navigator.gpu` exists but `requestAdapter()` resolves to `null` — a headless browser,
138
+ a blocklisted GPU, a browser started without GPU access — calling
139
+ `pipeline(..., { device: 'webgpu' })` throws **and poisons the ONNX Runtime Web backend
140
+ registry for the rest of the page**. The subsequent `pipeline(..., { device: 'wasm' })` for
141
+ the same model then fails with the identical WebGPU error:
142
+
143
+ ```
144
+ no available backend found. ERR: [webgpu] Error: Failed to get GPU adapter.
145
+ ```
146
+
147
+ So `avatar/asr.js` **probes for an actual adapter first** (`probeWebGpu()`) and only issues
148
+ the WebGPU call when one exists. The doomed call is never made, so the registry is never
149
+ poisoned. The `try`/`catch` remains as a second line of defence for failures a probe cannot
150
+ predict, such as an out-of-memory adapter.
151
+ `tests/test_transport_seam.py::test_asr_webgpu_fallback_is_real_not_aspirational` fails if
152
+ `requestAdapter` ever disappears from `avatar/asr.js`.
153
+
154
+ ---
155
+
156
+ ## Default chosen
157
+
158
+ **`onnx-community/whisper-base` at `q4`**, on WebGPU when an adapter is available and on
159
+ WASM when it is not.
160
+
161
+ - It is the **only** measured configuration that runs on **both** tiers. `q8` is smaller
162
+ (73.3 MB) and more accurate (CER 0.021), and it is disqualified outright because it
163
+ cannot create a session on WASM.
164
+ - 135.8 MB first load, then ~1.6–2.2 s to warm from the browser cache.
165
+ - 779 ms median inference on WebGPU, 3636 ms on WASM. Both leave room inside VOIC-05's
166
+ perceived-response budget; `whisper-small` at 15.5 s on WASM does not.
167
+ - CER 0.023 ignoring punctuation, and its one error is an orthography choice for the same
168
+ word rather than a misheard one.
169
+
170
+ **`whisper-small` is rejected** on the numbers: 2.1× the bytes of `whisper-base`, an
171
+ *identical* punctuation-insensitive CER of 0.023, and 4.3× the WASM inference time. It is
172
+ dominated on every axis that matters.
173
+
174
+ **"Better accuracy" toggle: `onnx-community/whisper-large-v3-turbo` at `q4f16`, WebGPU
175
+ only.** CER 0.000 on this material and 2858 ms median inference — but a 537.4 MB first
176
+ load, and 68 s per clip if it ever ran on WASM, which is why it must never be offered
177
+ without an adapter. Both entries live in `MODELS` in `avatar/asr.js`; plan 01-08 owns the
178
+ control that switches between them.
179
+
180
+ ---
181
+
182
+ ## Honest caveats
183
+
184
+ - These clips are **VOICEVOX-synthesised speech**: cleaner, more regular and better
185
+ articulated than a learner speaking into a laptop microphone in a room with a fan. Every
186
+ CER above is a **best case** and overstates real-world accuracy. Real-speech confirmation
187
+ is a manual verification in plan 01-10.
188
+ - Phase 1's bar is "their transcript appears", not "the transcript is right". If the chosen
189
+ default turns out to be embarrassing on real speech, that is a Phase 2/3 input, not a
190
+ Phase 1 failure.
191
+ - Three clips and two distinct sentences is a **small sample**. It is enough to rule
192
+ `whisper-small` out and to disqualify `q8`, and it is not enough to claim a CER figure
193
+ with confidence intervals.
194
+ - Load and inference times are from one Windows laptop with an `intel xe-lpg` iGPU on one
195
+ network. A visitor on a phone will see different numbers, particularly on WASM.
196
+ - WebGPU numbers required **headed** Chromium. Headless Chromium exposes `navigator.gpu`
197
+ and then returns a null adapter, so a headless re-run of the A/B records every candidate
198
+ as WASM. That is true, and useless as a comparison.
199
+ - Tier D (hosted ASR on accelerated hardware) is deliberately not built: it would consume
200
+ visitor GPU quota and contradict SC-4.
tests/fixtures/asr_ab_results.json ADDED
@@ -0,0 +1,480 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "generated": "2026-08-27",
3
+ "harness": "avatar/asr-harness.html driven by Playwright Chromium",
4
+ "runtime": "https://esm.sh/@huggingface/transformers@4.2.0",
5
+ "headless": false,
6
+ "environment": {
7
+ "userAgent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/151.0.0.0 Safari/537.36",
8
+ "platform": "Win32",
9
+ "webgpuAvailable": true,
10
+ "adapter": "intel xe-lpg"
11
+ },
12
+ "referenceIsGroundTruth": "The reference strings are the exact inputs plan 01-04 handed to VOICEVOX, so CER is measured against known text, not against a human transcription.",
13
+ "caveat": "These clips are VOICEVOX-synthesised speech: cleaner and more regular than a learner speaking into a laptop microphone. Every CER here is a BEST CASE.",
14
+ "gate": [
15
+ {
16
+ "url": "http://127.0.0.1:8477/tests/fixtures/silence_30s.wav",
17
+ "ok": false,
18
+ "reason": "rms-floor",
19
+ "durationMs": 30000,
20
+ "rms": 0,
21
+ "modulation": Infinity,
22
+ "frameCount": 1500,
23
+ "sourceSeconds": 30,
24
+ "fixture": "silence_30s.wav"
25
+ },
26
+ {
27
+ "url": "http://127.0.0.1:8477/tests/fixtures/cafe_noise_30s.wav",
28
+ "ok": false,
29
+ "reason": "envelope-modulation",
30
+ "durationMs": 30000,
31
+ "rms": 0.05759779420430498,
32
+ "modulation": 1.9612655599696533,
33
+ "frameCount": 1500,
34
+ "sourceSeconds": 30,
35
+ "fixture": "cafe_noise_30s.wav"
36
+ },
37
+ {
38
+ "url": "http://127.0.0.1:8477/tests/fixtures/speech_ja.wav",
39
+ "ok": true,
40
+ "reason": null,
41
+ "durationMs": 1056,
42
+ "rms": 0.07152851150220688,
43
+ "modulation": 10.710682777175906,
44
+ "frameCount": 52,
45
+ "sourceSeconds": 1.056,
46
+ "fixture": "speech_ja.wav"
47
+ },
48
+ {
49
+ "url": "http://127.0.0.1:8477/tests/fixtures/speech_ja_long.wav",
50
+ "ok": true,
51
+ "reason": null,
52
+ "durationMs": 5504,
53
+ "rms": 0.05514636579038092,
54
+ "modulation": 7.5091750971935385,
55
+ "frameCount": 275,
56
+ "sourceSeconds": 5.504,
57
+ "fixture": "speech_ja_long.wav"
58
+ },
59
+ {
60
+ "url": "http://127.0.0.1:8477/tests/fixtures/speech_ja_slow.wav",
61
+ "ok": true,
62
+ "reason": null,
63
+ "durationMs": 7381.3125,
64
+ "rms": 0.049179610521588375,
65
+ "modulation": 6.3681243480621985,
66
+ "frameCount": 369,
67
+ "sourceSeconds": 7.381333333333333,
68
+ "fixture": "speech_ja_slow.wav"
69
+ }
70
+ ],
71
+ "clips": [
72
+ {
73
+ "wav": "speech_ja.wav",
74
+ "reference": "こんにちは"
75
+ },
76
+ {
77
+ "wav": "speech_ja_long.wav",
78
+ "reference": "今日はいい天気ですから、公園を散歩してから、買い物に行きました。"
79
+ },
80
+ {
81
+ "wav": "speech_ja_slow.wav",
82
+ "reference": "今日はいい天気ですから、公園を散歩してから、買い物に行きました。"
83
+ }
84
+ ],
85
+ "results": [
86
+ {
87
+ "model": "onnx-community/whisper-base",
88
+ "dtype": "q4",
89
+ "requestedDevice": "wasm",
90
+ "clips": [
91
+ {
92
+ "url": "http://127.0.0.1:8477/tests/fixtures/speech_ja.wav",
93
+ "reference": "こんにちは",
94
+ "transcript": "こんにちは",
95
+ "error": null,
96
+ "inferMs": 2510,
97
+ "clipSeconds": 1.056,
98
+ "cer": 0.0,
99
+ "cerNoPunctuation": 0.0
100
+ },
101
+ {
102
+ "url": "http://127.0.0.1:8477/tests/fixtures/speech_ja_long.wav",
103
+ "reference": "今日はいい天気ですから、公園を散歩してから、買い物に行きました。",
104
+ "transcript": "今日は良い天気ですから、公園を散歩してから買い物に行きました。",
105
+ "error": null,
106
+ "inferMs": 3743,
107
+ "clipSeconds": 5.504,
108
+ "cer": 0.0625,
109
+ "cerNoPunctuation": 0.0345
110
+ },
111
+ {
112
+ "url": "http://127.0.0.1:8477/tests/fixtures/speech_ja_slow.wav",
113
+ "reference": "今日はいい天気ですから、公園を散歩してから、買い物に行きました。",
114
+ "transcript": "今日は良い天気ですから 公園を散歩してから買い物に行きました",
115
+ "error": null,
116
+ "inferMs": 3636,
117
+ "clipSeconds": 7.381,
118
+ "cer": 0.125,
119
+ "cerNoPunctuation": 0.0345
120
+ }
121
+ ],
122
+ "tier": "wasm",
123
+ "coldLoadMs": 4064,
124
+ "effectiveDtype": "q4",
125
+ "webgpuError": null,
126
+ "warmLoadMs": 1617,
127
+ "firstLoad": {
128
+ "files": {
129
+ "encoder_model_q4.onnx": 18772451,
130
+ "decoder_model_merged_q4.onnx": 123602419
131
+ },
132
+ "totalBytes": 142374870,
133
+ "totalMB": 135.8
134
+ },
135
+ "meanCerNoPunctuation": 0.023
136
+ },
137
+ {
138
+ "model": "onnx-community/whisper-base",
139
+ "dtype": "q4",
140
+ "requestedDevice": "webgpu",
141
+ "clips": [
142
+ {
143
+ "url": "http://127.0.0.1:8477/tests/fixtures/speech_ja.wav",
144
+ "reference": "こんにちは",
145
+ "transcript": "こんにちは",
146
+ "error": null,
147
+ "inferMs": 1182,
148
+ "clipSeconds": 1.056,
149
+ "cer": 0.0,
150
+ "cerNoPunctuation": 0.0
151
+ },
152
+ {
153
+ "url": "http://127.0.0.1:8477/tests/fixtures/speech_ja_long.wav",
154
+ "reference": "今日はいい天気ですから、公園を散歩してから、買い物に行きました。",
155
+ "transcript": "今日は良い天気ですから、公園を散歩してから買い物に行きました。",
156
+ "error": null,
157
+ "inferMs": 779,
158
+ "clipSeconds": 5.504,
159
+ "cer": 0.0625,
160
+ "cerNoPunctuation": 0.0345
161
+ },
162
+ {
163
+ "url": "http://127.0.0.1:8477/tests/fixtures/speech_ja_slow.wav",
164
+ "reference": "今日はいい天気ですから、公園を散歩してから、買い物に行きました。",
165
+ "transcript": "今日は良い天気ですから 公園を散歩してから買い物に行きました",
166
+ "error": null,
167
+ "inferMs": 728,
168
+ "clipSeconds": 7.381,
169
+ "cer": 0.125,
170
+ "cerNoPunctuation": 0.0345
171
+ }
172
+ ],
173
+ "tier": "webgpu",
174
+ "coldLoadMs": 4415,
175
+ "effectiveDtype": "q4",
176
+ "webgpuError": null,
177
+ "warmLoadMs": 2152,
178
+ "firstLoad": {
179
+ "files": {
180
+ "encoder_model_q4.onnx": 18772451,
181
+ "decoder_model_merged_q4.onnx": 123602419
182
+ },
183
+ "totalBytes": 142374870,
184
+ "totalMB": 135.8
185
+ },
186
+ "meanCerNoPunctuation": 0.023
187
+ },
188
+ {
189
+ "model": "onnx-community/whisper-small",
190
+ "dtype": "q4",
191
+ "requestedDevice": "wasm",
192
+ "clips": [
193
+ {
194
+ "url": "http://127.0.0.1:8477/tests/fixtures/speech_ja.wav",
195
+ "reference": "こんにちは",
196
+ "transcript": "こんにちは",
197
+ "error": null,
198
+ "inferMs": 10241,
199
+ "clipSeconds": 1.056,
200
+ "cer": 0.0,
201
+ "cerNoPunctuation": 0.0
202
+ },
203
+ {
204
+ "url": "http://127.0.0.1:8477/tests/fixtures/speech_ja_long.wav",
205
+ "reference": "今日はいい天気ですから、公園を散歩してから、買い物に行きました。",
206
+ "transcript": "今日は良い天気ですから公園を散歩してから買い物に行きました",
207
+ "error": null,
208
+ "inferMs": 15485,
209
+ "clipSeconds": 5.504,
210
+ "cer": 0.125,
211
+ "cerNoPunctuation": 0.0345
212
+ },
213
+ {
214
+ "url": "http://127.0.0.1:8477/tests/fixtures/speech_ja_slow.wav",
215
+ "reference": "今日はいい天気ですから、公園を散歩してから、買い物に行きました。",
216
+ "transcript": "今日は良い天気ですから公園を散歩してから買い物に行きました",
217
+ "error": null,
218
+ "inferMs": 22609,
219
+ "clipSeconds": 7.381,
220
+ "cer": 0.125,
221
+ "cerNoPunctuation": 0.0345
222
+ }
223
+ ],
224
+ "tier": "wasm",
225
+ "coldLoadMs": 7355,
226
+ "effectiveDtype": "q4",
227
+ "webgpuError": null,
228
+ "warmLoadMs": 3908,
229
+ "firstLoad": {
230
+ "files": {
231
+ "encoder_model_q4.onnx": 66182104,
232
+ "decoder_model_merged_q4.onnx": 233149327
233
+ },
234
+ "totalBytes": 299331431,
235
+ "totalMB": 285.5
236
+ },
237
+ "meanCerNoPunctuation": 0.023
238
+ },
239
+ {
240
+ "model": "onnx-community/whisper-small",
241
+ "dtype": "q4",
242
+ "requestedDevice": "webgpu",
243
+ "clips": [
244
+ {
245
+ "url": "http://127.0.0.1:8477/tests/fixtures/speech_ja.wav",
246
+ "reference": "こんにちは",
247
+ "transcript": "こんにちは",
248
+ "error": null,
249
+ "inferMs": 2676,
250
+ "clipSeconds": 1.056,
251
+ "cer": 0.0,
252
+ "cerNoPunctuation": 0.0
253
+ },
254
+ {
255
+ "url": "http://127.0.0.1:8477/tests/fixtures/speech_ja_long.wav",
256
+ "reference": "今日はいい天気ですから、公園を散歩してから、買い物に行きました。",
257
+ "transcript": "今日は良い天気ですから公園を散歩してから買い物に行きました",
258
+ "error": null,
259
+ "inferMs": 1568,
260
+ "clipSeconds": 5.504,
261
+ "cer": 0.125,
262
+ "cerNoPunctuation": 0.0345
263
+ },
264
+ {
265
+ "url": "http://127.0.0.1:8477/tests/fixtures/speech_ja_slow.wav",
266
+ "reference": "今日はいい天気ですから、公園を散歩してから、買い物に行きました。",
267
+ "transcript": "今日は良い天気ですから公園を散歩してから買い物に行きました",
268
+ "error": null,
269
+ "inferMs": 1934,
270
+ "clipSeconds": 7.381,
271
+ "cer": 0.125,
272
+ "cerNoPunctuation": 0.0345
273
+ }
274
+ ],
275
+ "tier": "webgpu",
276
+ "coldLoadMs": 12846,
277
+ "effectiveDtype": "q4",
278
+ "webgpuError": null,
279
+ "warmLoadMs": 4480,
280
+ "firstLoad": {
281
+ "files": {
282
+ "encoder_model_q4.onnx": 66182104,
283
+ "decoder_model_merged_q4.onnx": 233149327
284
+ },
285
+ "totalBytes": 299331431,
286
+ "totalMB": 285.5
287
+ },
288
+ "meanCerNoPunctuation": 0.023
289
+ },
290
+ {
291
+ "model": "onnx-community/whisper-large-v3-turbo",
292
+ "dtype": "q4f16",
293
+ "requestedDevice": "webgpu",
294
+ "clips": [
295
+ {
296
+ "url": "http://127.0.0.1:8477/tests/fixtures/speech_ja.wav",
297
+ "reference": "こんにちは",
298
+ "transcript": "こんにちは",
299
+ "error": null,
300
+ "inferMs": 4199,
301
+ "clipSeconds": 1.056,
302
+ "cer": 0.0,
303
+ "cerNoPunctuation": 0.0
304
+ },
305
+ {
306
+ "url": "http://127.0.0.1:8477/tests/fixtures/speech_ja_long.wav",
307
+ "reference": "今日はいい天気ですから、公園を散歩してから、買い物に行きました。",
308
+ "transcript": "今日はいい天気ですから公園を散歩してから買い物に行きました",
309
+ "error": null,
310
+ "inferMs": 2858,
311
+ "clipSeconds": 5.504,
312
+ "cer": 0.0938,
313
+ "cerNoPunctuation": 0.0
314
+ },
315
+ {
316
+ "url": "http://127.0.0.1:8477/tests/fixtures/speech_ja_slow.wav",
317
+ "reference": "今日はいい天気ですから、公園を散歩してから、買い物に行きました。",
318
+ "transcript": "今日はいい天気ですから公園を散歩してから買い物に行きました",
319
+ "error": null,
320
+ "inferMs": 2561,
321
+ "clipSeconds": 7.381,
322
+ "cer": 0.0938,
323
+ "cerNoPunctuation": 0.0
324
+ }
325
+ ],
326
+ "tier": "webgpu",
327
+ "coldLoadMs": 29413,
328
+ "effectiveDtype": "q4f16",
329
+ "webgpuError": null,
330
+ "warmLoadMs": 4055,
331
+ "firstLoad": {
332
+ "files": {
333
+ "encoder_model_q4f16.onnx": 369974078,
334
+ "decoder_model_merged_q4f16.onnx": 193505017
335
+ },
336
+ "totalBytes": 563479095,
337
+ "totalMB": 537.4
338
+ },
339
+ "meanCerNoPunctuation": 0.0
340
+ },
341
+ {
342
+ "model": "onnx-community/whisper-large-v3-turbo",
343
+ "dtype": "q4",
344
+ "requestedDevice": "wasm",
345
+ "clips": [
346
+ {
347
+ "url": "http://127.0.0.1:8477/tests/fixtures/speech_ja.wav",
348
+ "reference": "こんにちは",
349
+ "transcript": "こんにちは",
350
+ "error": null,
351
+ "inferMs": 68200,
352
+ "clipSeconds": 1.056,
353
+ "cer": 0.0,
354
+ "cerNoPunctuation": 0.0
355
+ },
356
+ {
357
+ "url": "http://127.0.0.1:8477/tests/fixtures/speech_ja_long.wav",
358
+ "reference": "今日はいい天気ですから、公園を散歩してから、買い物に行きました。",
359
+ "transcript": "今日はいい天気ですから公園を散歩してから買い物に行きました",
360
+ "error": null,
361
+ "inferMs": 71643,
362
+ "clipSeconds": 5.504,
363
+ "cer": 0.0938,
364
+ "cerNoPunctuation": 0.0
365
+ },
366
+ {
367
+ "url": "http://127.0.0.1:8477/tests/fixtures/speech_ja_slow.wav",
368
+ "reference": "今日はいい天気ですから、公園を散歩してから、買い物に行きました。",
369
+ "transcript": "今日はいい天気ですから公園を散歩してから買い物に行きました",
370
+ "error": null,
371
+ "inferMs": 65004,
372
+ "clipSeconds": 7.381,
373
+ "cer": 0.0938,
374
+ "cerNoPunctuation": 0.0
375
+ }
376
+ ],
377
+ "tier": "wasm",
378
+ "coldLoadMs": 20692,
379
+ "effectiveDtype": "q4",
380
+ "webgpuError": null,
381
+ "warmLoadMs": 4113,
382
+ "firstLoad": {
383
+ "files": {
384
+ "encoder_model_q4.onnx": 424942775,
385
+ "decoder_model_merged_q4.onnx": 334147222
386
+ },
387
+ "totalBytes": 759089997,
388
+ "totalMB": 723.9
389
+ },
390
+ "meanCerNoPunctuation": 0.0
391
+ },
392
+ {
393
+ "model": "onnx-community/whisper-base",
394
+ "dtype": "q8",
395
+ "requestedDevice": "wasm",
396
+ "clips": [],
397
+ "tier": null,
398
+ "error": "Can't create a session. ERROR_CODE: 1, ERROR_MESSAGE: qdq_actions.cc:137 TransposeDQWeightsForMatMulNBits Missing required scale: model.decoder.embed_tokens.weight_merged_0_scale for node: model.decoder.embed_tokens.weight_transposed_DequantizeLinear",
399
+ "coldLoadMs": 3471,
400
+ "firstLoad": {
401
+ "files": {
402
+ "encoder_model_quantized.onnx": 23201314,
403
+ "decoder_model_merged_quantized.onnx": 53693315
404
+ },
405
+ "totalBytes": 76894629,
406
+ "totalMB": 73.3
407
+ },
408
+ "meanCerNoPunctuation": null
409
+ },
410
+ {
411
+ "model": "onnx-community/whisper-base",
412
+ "dtype": "q8",
413
+ "requestedDevice": "webgpu",
414
+ "clips": [
415
+ {
416
+ "url": "http://127.0.0.1:8477/tests/fixtures/speech_ja.wav",
417
+ "reference": "こんにちは",
418
+ "transcript": "こんにちは",
419
+ "error": null,
420
+ "inferMs": 2782,
421
+ "clipSeconds": 1.056,
422
+ "cer": 0.0,
423
+ "cerNoPunctuation": 0.0
424
+ },
425
+ {
426
+ "url": "http://127.0.0.1:8477/tests/fixtures/speech_ja_long.wav",
427
+ "reference": "今日はいい天気ですから、公園を散歩してから、買い物に行きました。",
428
+ "transcript": "今日はいい天気ですから、公園を散歩してから買い物に行きました。",
429
+ "error": null,
430
+ "inferMs": 4817,
431
+ "clipSeconds": 5.504,
432
+ "cer": 0.0312,
433
+ "cerNoPunctuation": 0.0
434
+ },
435
+ {
436
+ "url": "http://127.0.0.1:8477/tests/fixtures/speech_ja_slow.wav",
437
+ "reference": "今日はいい天気ですから、公園を散歩してから、買い物に行きました。",
438
+ "transcript": "今日はいい天気ですから、公園を散歩してから買い物に行きました。",
439
+ "error": null,
440
+ "inferMs": 4679,
441
+ "clipSeconds": 7.381,
442
+ "cer": 0.0312,
443
+ "cerNoPunctuation": 0.0
444
+ }
445
+ ],
446
+ "tier": "webgpu",
447
+ "coldLoadMs": 3474,
448
+ "effectiveDtype": "q8",
449
+ "webgpuError": null,
450
+ "warmLoadMs": 2021,
451
+ "firstLoad": {
452
+ "files": {
453
+ "encoder_model_quantized.onnx": 23201314,
454
+ "decoder_model_merged_quantized.onnx": 53693315
455
+ },
456
+ "totalBytes": 76894629,
457
+ "totalMB": 73.3
458
+ },
459
+ "meanCerNoPunctuation": 0.0
460
+ },
461
+ {
462
+ "model": "onnx-community/whisper-small",
463
+ "dtype": "q8",
464
+ "requestedDevice": "wasm",
465
+ "clips": [],
466
+ "tier": null,
467
+ "error": "Can't create a session. ERROR_CODE: 1, ERROR_MESSAGE: qdq_actions.cc:137 TransposeDQWeightsForMatMulNBits Missing required scale: model.decoder.embed_tokens.weight_merged_0_scale for node: model.decoder.embed_tokens.weight_transposed_DequantizeLinear",
468
+ "coldLoadMs": 5867,
469
+ "firstLoad": {
470
+ "files": {
471
+ "encoder_model_quantized.onnx": 92326160,
472
+ "decoder_model_merged_quantized.onnx": 156750845
473
+ },
474
+ "totalBytes": 249077005,
475
+ "totalMB": 237.5
476
+ },
477
+ "meanCerNoPunctuation": null
478
+ }
479
+ ]
480
+ }
tests/fixtures/make_asr_ab_results.py ADDED
@@ -0,0 +1,313 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Produce tests/fixtures/asr_ab_results.json: the measured ASR model A/B.
2
+
3
+ Run it, do not hand-edit its output::
4
+
5
+ uv run python tests/fixtures/make_asr_ab_results.py
6
+
7
+ What it does, and why each part is measured rather than looked up:
8
+
9
+ * Drives ``avatar/asr-harness.html`` over a loopback static server with real Playwright
10
+ Chromium, so every number comes from the same code path the Space will run.
11
+ * Gives each candidate a **fresh browser profile** for its cold load, then reopens that
12
+ profile for the warm load. Deleting the runtime's CacheStorage instead would be
13
+ cheaper, but ``caches.delete()`` followed by ``caches.open()`` throws "Unexpected
14
+ internal error" in Chromium 151 while the runtime still holds handles into it.
15
+ * Takes first-load size from the ``Content-Length`` of the exact ONNX files the runtime
16
+ fetches. Cross-origin resource timing reports ``transferSize: 0`` without
17
+ ``Timing-Allow-Origin``, so the browser cannot honestly report this to itself.
18
+ * Includes the ``q8`` candidates **on purpose, expecting them to fail on WASM**. The
19
+ failure is the single most consequential finding of this exercise and it belongs in the
20
+ committed record, not in a commit message.
21
+
22
+ WebGPU note: headless Chromium exposes ``navigator.gpu`` but returns a null adapter, so
23
+ this script runs HEADED. A headless run will silently record every WebGPU candidate as
24
+ having fallen back to WASM, which is true but useless as an A/B.
25
+ """
26
+
27
+ from __future__ import annotations
28
+
29
+ import argparse
30
+ import datetime as dt
31
+ import functools
32
+ import http.server
33
+ import json
34
+ import shutil
35
+ import socket
36
+ import sys
37
+ import tempfile
38
+ import threading
39
+ import time
40
+ import urllib.request
41
+ from pathlib import Path
42
+
43
+ HERE = Path(__file__).resolve().parent
44
+ REPO_ROOT = HERE.parent.parent
45
+ OUT = HERE / "asr_ab_results.json"
46
+
47
+ # The ground truth is exact because we generated the audio: plan 01-04 synthesised these
48
+ # three clips from these three strings with VOICEVOX. No human transcribed anything.
49
+ CLIPS = [
50
+ {"wav": "speech_ja.wav", "reference": "こんにちは"},
51
+ {
52
+ "wav": "speech_ja_long.wav",
53
+ "reference": "今日はいい天気ですから、公園を散歩してから、買い物に行きました。",
54
+ },
55
+ {
56
+ "wav": "speech_ja_slow.wav",
57
+ "reference": "今日はいい天気ですから、公園を散歩してから、買い物に行きました。",
58
+ },
59
+ ]
60
+
61
+ GATE_FIXTURES = ["silence_30s.wav", "cafe_noise_30s.wav", *[c["wav"] for c in CLIPS]]
62
+
63
+ CANDIDATES = [
64
+ {"model": "onnx-community/whisper-base", "dtype": "q4", "device": "wasm"},
65
+ {"model": "onnx-community/whisper-base", "dtype": "q4", "device": "webgpu"},
66
+ {"model": "onnx-community/whisper-small", "dtype": "q4", "device": "wasm"},
67
+ {"model": "onnx-community/whisper-small", "dtype": "q4", "device": "webgpu"},
68
+ {"model": "onnx-community/whisper-large-v3-turbo", "dtype": "q4f16", "device": "webgpu"},
69
+ {"model": "onnx-community/whisper-large-v3-turbo", "dtype": "q4", "device": "wasm"},
70
+ # The q8 control group. 01-RESEARCH.md recommends exactly this configuration.
71
+ {"model": "onnx-community/whisper-base", "dtype": "q8", "device": "wasm"},
72
+ {"model": "onnx-community/whisper-base", "dtype": "q8", "device": "webgpu"},
73
+ {"model": "onnx-community/whisper-small", "dtype": "q8", "device": "wasm"},
74
+ ]
75
+
76
+ SIZE_URL = "https://huggingface.co/{model}/resolve/main/onnx/{file}"
77
+ # Punctuation is a rendering choice, not a transcription error, so CER is reported both
78
+ # ways. A tutor cares about the second number.
79
+ PUNCTUATION = "、。,.,.!?!?  \n\t"
80
+
81
+
82
+ def levenshtein(a: str, b: str) -> int:
83
+ if a == b:
84
+ return 0
85
+ if not a:
86
+ return len(b)
87
+ if not b:
88
+ return len(a)
89
+ previous = list(range(len(b) + 1))
90
+ for i, ca in enumerate(a, start=1):
91
+ current = [i]
92
+ for j, cb in enumerate(b, start=1):
93
+ current.append(min(previous[j] + 1, current[j - 1] + 1, previous[j - 1] + (ca != cb)))
94
+ previous = current
95
+ return previous[-1]
96
+
97
+
98
+ def cer(hypothesis: str | None, reference: str) -> float | None:
99
+ if hypothesis is None:
100
+ return None
101
+ if not reference:
102
+ return None
103
+ return round(levenshtein(hypothesis, reference) / len(reference), 4)
104
+
105
+
106
+ def strip_punctuation(text: str) -> str:
107
+ return "".join(c for c in text if c not in PUNCTUATION)
108
+
109
+
110
+ def content_length(url: str) -> int | None:
111
+ request = urllib.request.Request(url, method="HEAD") # noqa: S310
112
+ request.add_header("User-Agent", "japanese-learning-avatar/asr-ab")
113
+ try:
114
+ with urllib.request.urlopen(request, timeout=60) as response: # noqa: S310
115
+ # LFS-backed files answer with x-linked-size; Content-Length is the pointer.
116
+ raw = response.headers.get("x-linked-size") or response.headers.get("Content-Length")
117
+ return int(raw) if raw else None
118
+ except OSError:
119
+ return None
120
+
121
+
122
+ # transformers.js does not name the file after the dtype: `q8` resolves to the
123
+ # `_quantized` suffix, not `_q8`. Getting this wrong silently reports "size unknown" for
124
+ # exactly the candidate the whole q8 control group exists to size.
125
+ DTYPE_SUFFIX = {
126
+ "fp32": "",
127
+ "fp16": "_fp16",
128
+ "q8": "_quantized",
129
+ "int8": "_int8",
130
+ "uint8": "_uint8",
131
+ "q4": "_q4",
132
+ "q4f16": "_q4f16",
133
+ "bnb4": "_bnb4",
134
+ }
135
+
136
+
137
+ def first_load_bytes(model: str, dtype: str) -> dict:
138
+ suffix = DTYPE_SUFFIX.get(dtype, f"_{dtype}")
139
+ files = [f"encoder_model{suffix}.onnx", f"decoder_model_merged{suffix}.onnx"]
140
+ sizes = {f: content_length(SIZE_URL.format(model=model, file=f)) for f in files}
141
+ known = [v for v in sizes.values() if isinstance(v, int)]
142
+ total = sum(known)
143
+ return {
144
+ "files": sizes,
145
+ "totalBytes": total if len(known) == len(files) else None,
146
+ "totalMB": round(total / 1024 / 1024, 1) if len(known) == len(files) else None,
147
+ }
148
+
149
+
150
+ def start_server(port: int):
151
+ handler = functools.partial(http.server.SimpleHTTPRequestHandler, directory=str(REPO_ROOT))
152
+ try:
153
+ server = http.server.ThreadingHTTPServer(("127.0.0.1", port), handler)
154
+ except OSError:
155
+ with socket.socket() as probe:
156
+ probe.bind(("127.0.0.1", 0))
157
+ port = probe.getsockname()[1]
158
+ server = http.server.ThreadingHTTPServer(("127.0.0.1", port), handler)
159
+ server.daemon_threads = True
160
+ threading.Thread(target=server.serve_forever, daemon=True).start()
161
+ return server, f"http://127.0.0.1:{server.server_port}"
162
+
163
+
164
+ WARM_LOAD = """
165
+ async (candidate) => {
166
+ const started = performance.now();
167
+ const asr = window.__newAsr(candidate);
168
+ await asr.init();
169
+ return { warmLoadMs: Math.round(performance.now() - started), tier: asr.getTier() };
170
+ }
171
+ """
172
+
173
+ ENVIRONMENT = """
174
+ async () => ({
175
+ userAgent: navigator.userAgent,
176
+ platform: navigator.platform,
177
+ webgpuAvailable: !!navigator.gpu,
178
+ adapter: await (async () => {
179
+ if (!navigator.gpu) return null;
180
+ const a = await navigator.gpu.requestAdapter();
181
+ if (!a) return 'adapter-null';
182
+ const i = a.info || {};
183
+ const parts = [i.vendor, i.architecture, i.device, i.description].filter(Boolean);
184
+ return parts.join(' ') || 'adapter-ok';
185
+ })(),
186
+ })
187
+ """
188
+
189
+
190
+ def main() -> int:
191
+ parser = argparse.ArgumentParser(description=__doc__)
192
+ parser.add_argument("--port", type=int, default=8477)
193
+ parser.add_argument("--headless", action="store_true", help="records WASM-only numbers")
194
+ parser.add_argument("--out", type=Path, default=OUT)
195
+ args = parser.parse_args()
196
+
197
+ try:
198
+ from playwright.sync_api import sync_playwright
199
+ except ImportError:
200
+ print("playwright is required: uv sync --extra dev && playwright install chromium")
201
+ return 2
202
+
203
+ server, base = start_server(args.port)
204
+ clips = [
205
+ {"url": f"{base}/tests/fixtures/{c['wav']}", "reference": c["reference"]} for c in CLIPS
206
+ ]
207
+ profiles = Path(tempfile.mkdtemp(prefix="jla-asr-ab-"))
208
+ environment: dict = {}
209
+ gate_rows: list[dict] = []
210
+ records: list[dict] = []
211
+
212
+ def open_page(pw, profile: Path):
213
+ context = pw.chromium.launch_persistent_context(
214
+ user_data_dir=str(profile),
215
+ headless=args.headless,
216
+ args=["--autoplay-policy=no-user-gesture-required"],
217
+ )
218
+ page = context.new_page()
219
+ page.set_default_timeout(0)
220
+ page.goto(f"{base}/avatar/asr-harness.html")
221
+ page.wait_for_function("() => window.__harnessReady === true", timeout=120_000)
222
+ return context, page
223
+
224
+ try:
225
+ with sync_playwright() as pw:
226
+ for index, candidate in enumerate(CANDIDATES):
227
+ profile = profiles / f"p{index}"
228
+ profile.mkdir(parents=True, exist_ok=True)
229
+ label = f"{candidate['model']} {candidate['dtype']} {candidate['device']}"
230
+ print(f"--- cold {label}", flush=True)
231
+ started = time.time()
232
+ context, page = open_page(pw, profile)
233
+ if not environment:
234
+ environment = page.evaluate(ENVIRONMENT)
235
+ print(f" {environment}", flush=True)
236
+ # The gate table: measured off the WAV files themselves, so it is
237
+ # independent of any microphone, any fake device and any model.
238
+ for wav in GATE_FIXTURES:
239
+ gate_rows.append(
240
+ page.evaluate(
241
+ "(u) => window.__measureClip(u)",
242
+ f"{base}/tests/fixtures/{wav}",
243
+ )
244
+ | {"fixture": wav}
245
+ )
246
+ try:
247
+ record = page.evaluate(
248
+ "async ([c, clips]) => (await window.__runAB([c], clips))[0]",
249
+ [candidate, clips],
250
+ )
251
+ except Exception as err: # noqa: BLE001 - a driver failure is data too
252
+ record = {**candidate, "tier": None, "error": f"driver: {err}", "clips": []}
253
+ context.close()
254
+ record["requestedDevice"] = candidate["device"]
255
+ record.pop("cache", None)
256
+ print(f" tier={record.get('tier')} {time.time() - started:.1f}s", flush=True)
257
+
258
+ if record.get("tier"):
259
+ context, page = open_page(pw, profile)
260
+ try:
261
+ record["warmLoadMs"] = page.evaluate(WARM_LOAD, candidate)["warmLoadMs"]
262
+ except Exception as err: # noqa: BLE001
263
+ record["warmLoadError"] = str(err)[:300]
264
+ context.close()
265
+ print(f" warm={record.get('warmLoadMs')} ms", flush=True)
266
+
267
+ shutil.rmtree(profile, ignore_errors=True)
268
+ record["firstLoad"] = first_load_bytes(candidate["model"], candidate["dtype"])
269
+ for clip in record.get("clips", []):
270
+ transcript = clip.get("transcript")
271
+ reference = clip["reference"]
272
+ clip["cer"] = cer(transcript, reference)
273
+ clip["cerNoPunctuation"] = (
274
+ cer(strip_punctuation(transcript), strip_punctuation(reference))
275
+ if transcript is not None
276
+ else None
277
+ )
278
+ scored = [c["cerNoPunctuation"] for c in record.get("clips", [])]
279
+ scored = [v for v in scored if v is not None]
280
+ record["meanCerNoPunctuation"] = (
281
+ round(sum(scored) / len(scored), 4) if scored else None
282
+ )
283
+ records.append(record)
284
+ finally:
285
+ server.shutdown()
286
+ server.server_close()
287
+ shutil.rmtree(profiles, ignore_errors=True)
288
+
289
+ payload = {
290
+ "generated": dt.date.today().isoformat(),
291
+ "harness": "avatar/asr-harness.html driven by Playwright Chromium",
292
+ "runtime": "https://esm.sh/@huggingface/transformers@4.2.0",
293
+ "headless": args.headless,
294
+ "environment": environment,
295
+ "referenceIsGroundTruth": (
296
+ "The reference strings are the exact inputs plan 01-04 handed to VOICEVOX, so "
297
+ "CER is measured against known text, not against a human transcription."
298
+ ),
299
+ "caveat": (
300
+ "These clips are VOICEVOX-synthesised speech: cleaner and more regular than a "
301
+ "learner speaking into a laptop microphone. Every CER here is a BEST CASE."
302
+ ),
303
+ "gate": gate_rows,
304
+ "clips": CLIPS,
305
+ "results": records,
306
+ }
307
+ args.out.write_text(json.dumps(payload, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
308
+ print(f"wrote {args.out}", flush=True)
309
+ return 0
310
+
311
+
312
+ if __name__ == "__main__":
313
+ sys.exit(main())
tests/test_transport_seam.py CHANGED
@@ -22,8 +22,8 @@ AMPLITUDE_TOKENS = ["AnalyserNode", "getByteFrequencyData", "getFloatTimeDomainD
22
  TURN_SURFACE = ["startListening", "stopListening", "dispatchTurn", "requestSlower"]
23
  # Plan 01-07. Mic capture and ASR run in the PARENT document under both transports -
24
  # only rendering and audio playback live inside the iframe - so these modules must hang
25
- # off the shared turn loop, never off a transport. asr.js joins this list in task 2.
26
- AUDIO_IN = ["mic.js"]
27
 
28
 
29
  def src(name: str) -> str:
@@ -153,12 +153,15 @@ def test_audio_input_modules_are_gradio_free(name):
153
 
154
 
155
  @pytest.mark.parametrize("name", AUDIO_IN)
156
- def test_audio_input_modules_are_not_imported_by_a_transport(name):
157
- """If a transport imported these directly, the other transport would silently lose
 
 
158
  push-to-talk - the exact failure mode the shared turn loop exists to prevent.
159
- The positive half of this guard (turn-loop.js DOES import them) lands with the
160
- wiring in task 2, as test_audio_input_modules_hang_off_the_turn_loop.
161
  """
 
 
 
162
  for transport in TRANSPORTS:
163
  assert name not in src(transport), (
164
  f"{transport} imports {name}; mic/ASR wiring belongs in avatar/turn-loop.js"
@@ -199,3 +202,70 @@ def test_gate_thresholds_match_the_measured_fixtures():
199
  assert "MIN_MODULATION: 2.5" in s
200
  assert "BLOCKLIST_MAX_MS: 1500" in s
201
  assert "REARM_TAIL_MS = 200" in s
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
22
  TURN_SURFACE = ["startListening", "stopListening", "dispatchTurn", "requestSlower"]
23
  # Plan 01-07. Mic capture and ASR run in the PARENT document under both transports -
24
  # only rendering and audio playback live inside the iframe - so these modules must hang
25
+ # off the shared turn loop, never off a transport.
26
+ AUDIO_IN = ["mic.js", "asr.js"]
27
 
28
 
29
  def src(name: str) -> str:
 
153
 
154
 
155
  @pytest.mark.parametrize("name", AUDIO_IN)
156
+ def test_audio_input_modules_hang_off_the_turn_loop(name):
157
+ """Imported by turn-loop.js, by neither transport.
158
+
159
+ If a transport imported these directly, the other transport would silently lose
160
  push-to-talk - the exact failure mode the shared turn loop exists to prevent.
 
 
161
  """
162
+ assert f"./{name}" in src("turn-loop.js"), (
163
+ f"avatar/turn-loop.js must import {name} so BOTH transports get it at once"
164
+ )
165
  for transport in TRANSPORTS:
166
  assert name not in src(transport), (
167
  f"{transport} imports {name}; mic/ASR wiring belongs in avatar/turn-loop.js"
 
202
  assert "MIN_MODULATION: 2.5" in s
203
  assert "BLOCKLIST_MAX_MS: 1500" in s
204
  assert "REARM_TAIL_MS = 200" in s
205
+
206
+
207
+ def test_no_remote_asr_path_exists():
208
+ """Tier D would burn the exact visitor GPU quota SC-4 tests the loop's survival
209
+ without, so no hosted transcription path may exist anywhere in avatar/."""
210
+ for name in ["asr.js", "mic.js", "turn-loop.js"]:
211
+ lowered = src(name).lower()
212
+ for tok in ["zerogpu", "spaces.gpu", "/api/asr", "@spaces"]:
213
+ assert tok not in lowered, (
214
+ f"{name} references {tok!r}; browser ASR must cost zero GPU quota"
215
+ )
216
+
217
+
218
+ def test_asr_module_url_is_pinned():
219
+ s = src("asr.js")
220
+ assert s.count("https://esm.sh/@huggingface/transformers@4.2.0") == 1, (
221
+ "exactly one pinned runtime URL, same discipline as vrm-stage.js"
222
+ )
223
+ assert "importmap" not in s
224
+
225
+
226
+ def test_asr_webgpu_fallback_is_real_not_aspirational():
227
+ """The fallback must probe for an ADAPTER, not merely for navigator.gpu.
228
+
229
+ Measured on this stack: when navigator.gpu exists but requestAdapter() resolves to
230
+ null, calling pipeline(..., {device:'webgpu'}) throws AND poisons the ONNX Runtime
231
+ backend registry for the whole page, so the subsequent WASM re-instantiation fails
232
+ with the same WebGPU error. Catch-and-retry alone is therefore not a working
233
+ fallback; the doomed call must never be made. Deleting this assertion re-introduces
234
+ a bug that only shows up on the machines the fallback exists for.
235
+ """
236
+ s = src("asr.js")
237
+ assert "navigator.gpu" in s
238
+ assert "webgpu" in s
239
+ assert "requestAdapter" in s, (
240
+ "avatar/asr.js checks navigator.gpu but never requests an adapter; that is the "
241
+ "exact naive check that ships a broken app to every machine without a GPU"
242
+ )
243
+ assert re.search(r"catch\s*\(", s), "no catch around the WebGPU init path"
244
+ assert s.count("language: 'ja'") == 1
245
+
246
+
247
+ @pytest.mark.parametrize("key", ["asrTier", "asrModel", "lastTranscript", "micRejectedCount"])
248
+ def test_push_to_talk_debug_keys_present(key):
249
+ assert key in src("turn-loop.js"), (
250
+ f"__debug.{key} is contributed by avatar/turn-loop.js, so both transports report it"
251
+ )
252
+
253
+
254
+ @pytest.mark.parametrize("member", ["startListening", "stopListening"])
255
+ def test_push_to_talk_is_implemented_not_deferred(member):
256
+ """The two methods this plan owns must no longer be notWiredYet stubs."""
257
+ s = src("turn-loop.js")
258
+ assert f"notWiredYet('{member}'" not in s, (
259
+ f"{member} is still a deferred stub in avatar/turn-loop.js"
260
+ )
261
+ assert f"async {member}(" in s, f"{member} must be implemented in avatar/turn-loop.js"
262
+
263
+
264
+ @pytest.mark.parametrize("name", TRANSPORTS)
265
+ def test_transports_gained_push_to_talk_without_gaining_code(name):
266
+ """The whole point of the seam: the iframe fallback got this feature for free."""
267
+ s = src(name)
268
+ for token in ["startListening", "stopListening", "mic.", "asr.", "createMic", "createAsr"]:
269
+ assert token not in s, (
270
+ f"{name} mentions {token!r}; push-to-talk must live only in avatar/turn-loop.js"
271
+ )