Spaces:
Running on Zero
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 +327 -0
- avatar/asr.js +244 -0
- avatar/facade.js +11 -0
- avatar/turn-loop.js +129 -4
- docs/ASR-TIERS.md +200 -0
- tests/fixtures/asr_ab_results.json +480 -0
- tests/fixtures/make_asr_ab_results.py +313 -0
- tests/test_transport_seam.py +76 -6
|
@@ -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>
|
|
@@ -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 |
+
}
|
|
@@ -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);
|
|
@@ -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({
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 73 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
};
|
|
@@ -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.
|
|
@@ -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 |
+
}
|
|
@@ -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())
|
|
@@ -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.
|
| 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
|
| 157 |
-
"""
|
|
|
|
|
|
|
| 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 |
+
)
|