lovemefan commited on
Commit
111ee11
·
1 Parent(s): 4bff715

Initial RapidSpeech WASM demo

Browse files
Dockerfile ADDED
@@ -0,0 +1,11 @@
 
 
 
 
 
 
 
 
 
 
 
 
1
+ FROM python:3.11-slim
2
+
3
+ WORKDIR /app
4
+
5
+ # Copy the static WASM demo files (built locally — see HOWTO.md).
6
+ COPY public/ /app/public/
7
+ COPY serve.py /app/serve.py
8
+
9
+ EXPOSE 7860
10
+
11
+ CMD ["python3", "serve.py", "7860", "/app/public"]
README.md CHANGED
@@ -1,12 +1,43 @@
1
  ---
2
- title: RapidSpeech Wasm
3
- emoji: 🔥
4
- colorFrom: pink
5
- colorTo: red
6
  sdk: docker
 
7
  pinned: false
8
- license: mit
9
- short_description: 'RapidSpeech.cpp WASM demo '
10
  ---
11
 
12
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  ---
2
+ title: RapidSpeech.cpp WASM Demo
3
+ emoji: 🎙️
4
+ colorFrom: blue
5
+ colorTo: purple
6
  sdk: docker
7
+ app_port: 7860
8
  pinned: false
9
+ license: apache-2.0
10
+ short_description: On-device ASR + TTS in your browser via WebAssembly + WebGPU
11
  ---
12
 
13
+ # RapidSpeech.cpp Browser Demo
14
+
15
+ Three-tab WebAssembly demo for [RapidSpeech.cpp](https://github.com/RapidAI/RapidSpeech.cpp):
16
+
17
+ - **ASR Offline** — upload a WAV (or record one) → optional neural VAD → transcript with timestamps.
18
+ - **ASR Online** — live mic → neural VAD → streaming transcripts, with optional two-pass (CTC → LLM rescore).
19
+ - **TTS Offline** — text → speech via OmniVoice / OpenVoice2 (with voice-cloning).
20
+
21
+ Everything runs **locally in your browser** via WebAssembly + WebGPU + pthreads. After the WASM module loads, audio and model bytes never leave the page.
22
+
23
+ ## Why Docker SDK?
24
+
25
+ The demo needs cross-origin isolation (COOP+COEP) so `SharedArrayBuffer` and pthreads are available. HuggingFace's static SDK does not reliably set those headers, so this Space uses the **Docker SDK** with a tiny Python server (`serve.py`) that emits the right headers explicitly.
26
+
27
+ ## Models
28
+
29
+ Pick any GGUF from [RapidAI/RapidSpeech](https://huggingface.co/RapidAI/RapidSpeech) and paste the resolve URL into the demo. Recommended starters:
30
+
31
+ - ASR: `funasr-nano-q4_k_m.gguf` (~600 MB)
32
+ - VAD: `silero-vad-v6.gguf` (~2 MB)
33
+ - TTS: `omnivoice-q4_k_m.gguf` or `openvoice2-base-en.gguf`
34
+
35
+ ## Browser requirements
36
+
37
+ - **Chromium 113+** for WebGPU (Chrome / Edge / Opera). Firefox / Safari fall back to CPU.
38
+ - Mic input requires HTTPS — HuggingFace Spaces already serves over HTTPS.
39
+
40
+ ## Source
41
+
42
+ Full project, native C++ CLI, Python / Node bindings, and conversion scripts:
43
+ [github.com/RapidAI/RapidSpeech.cpp](https://github.com/RapidAI/RapidSpeech.cpp)
public/app.js ADDED
@@ -0,0 +1,51 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * Tab router for the multi-task RapidSpeech web demo.
3
+ *
4
+ * Loads each per-tab module exactly once with a fresh RapidSpeechWASM
5
+ * instance factory. Pages are isolated — switching tabs does not reset
6
+ * loaded models on the inactive tabs.
7
+ */
8
+ (function () {
9
+ // Factory: each call returns a brand-new RapidSpeechWASM instance, which
10
+ // owns its own Emscripten module (MODULARIZE=1 returns a fresh instance).
11
+ async function createInstance() {
12
+ const mod = await RapidSpeechModule();
13
+ return new RapidSpeechWASM(mod);
14
+ }
15
+
16
+ const tabs = [
17
+ { id: 'asr-offline', mod: window.RSPageAsrOffline },
18
+ { id: 'asr-online', mod: window.RSPageAsrOnline },
19
+ { id: 'tts-offline', mod: window.RSPageTtsOffline },
20
+ ];
21
+
22
+ function showTab(id) {
23
+ document.querySelectorAll('.tab-btn').forEach(b => {
24
+ b.classList.toggle('active', b.dataset.tab === id);
25
+ });
26
+ document.querySelectorAll('section[data-page]').forEach(s => {
27
+ s.hidden = s.dataset.page !== id;
28
+ });
29
+ if (location.hash.slice(1) !== id) {
30
+ history.replaceState(null, '', '#' + id);
31
+ }
32
+ }
33
+
34
+ // Wire tab clicks.
35
+ document.querySelectorAll('.tab-btn').forEach(btn => {
36
+ btn.addEventListener('click', () => showTab(btn.dataset.tab));
37
+ });
38
+
39
+ // Initialize every page once (so DOM listeners and state are bound).
40
+ for (const { id, mod } of tabs) {
41
+ if (!mod) { console.warn('Missing page module for', id); continue; }
42
+ const root = document.querySelector(`section[data-page="${id}"]`);
43
+ if (!root) { console.warn('Missing root for', id); continue; }
44
+ mod.init({ root, createInstance });
45
+ }
46
+
47
+ // Honor URL hash on load.
48
+ const initial = (location.hash || '').slice(1);
49
+ const valid = tabs.find(t => t.id === initial);
50
+ showTab(valid ? initial : 'asr-offline');
51
+ })();
public/index.html ADDED
@@ -0,0 +1,271 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="zh-CN">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title>RapidSpeech — WebAssembly Demo</title>
7
+ <link rel="stylesheet" href="styles.css">
8
+ </head>
9
+ <body>
10
+ <div class="container">
11
+
12
+ <div class="header">
13
+ <h1>Rapid<span>Speech</span></h1>
14
+ <p>ASR &amp; TTS in the Browser — WebAssembly + WebGPU</p>
15
+ </div>
16
+
17
+ <nav class="tabs">
18
+ <button class="tab-btn active" data-tab="asr-offline">ASR Offline</button>
19
+ <button class="tab-btn" data-tab="asr-online">ASR Online</button>
20
+ <button class="tab-btn" data-tab="tts-offline">TTS Offline</button>
21
+ </nav>
22
+
23
+ <!-- ─── ASR Offline ─────────────────────────────────────── -->
24
+ <section data-page="asr-offline">
25
+ <div class="card">
26
+ <div class="card-title">Model</div>
27
+ <div class="form-row">
28
+ <label>Model URL</label>
29
+ <input type="text" data-id="model-url"
30
+ placeholder="https://huggingface.co/RapidAI/RapidSpeech/resolve/main/ASR/FunasrNano/funasr-nano-q4_k_m.gguf">
31
+ </div>
32
+ <div class="form-row">
33
+ <label>Threads</label>
34
+ <input type="number" data-id="n-threads" value="4" min="1" max="8">
35
+ <label style="min-width: 80px;">Use LLM</label>
36
+ <input type="checkbox" data-id="use-llm" checked>
37
+ </div>
38
+ <div class="progress-container" data-id="progress-container">
39
+ <div class="progress-track"><div class="progress-fill" data-id="progress-bar"></div></div>
40
+ </div>
41
+ <div class="btn-row">
42
+ <button class="btn btn-load" data-id="load-btn">Load Model</button>
43
+ </div>
44
+ <div class="status idle" data-id="status">Enter a model URL and click Load.</div>
45
+ </div>
46
+
47
+ <div class="card">
48
+ <div class="card-title">VAD (optional — silero-vad or firered-vad)</div>
49
+ <div class="form-row">
50
+ <label>VAD model URL</label>
51
+ <input type="text" data-id="vad-url"
52
+ placeholder="https://huggingface.co/RapidAI/RapidSpeech/resolve/main/VAD/silero_vad_v6.gguf">
53
+ </div>
54
+ <div class="form-row">
55
+ <label>Threshold</label>
56
+ <input type="range" data-id="vad-threshold" min="0.1" max="0.9" step="0.05" value="0.5">
57
+ <span class="range-value" data-id="vad-threshold-val">0.50</span>
58
+ <label style="min-width: 100px;">Min seg (s)</label>
59
+ <input type="number" data-id="vad-min-seg" value="0.3" step="0.1" min="0" max="2">
60
+ </div>
61
+ <div class="progress-container" data-id="vad-progress-container">
62
+ <div class="progress-track"><div class="progress-fill" data-id="vad-progress-bar"></div></div>
63
+ </div>
64
+ <div class="btn-row">
65
+ <button class="btn btn-load" data-id="vad-load-btn">Load VAD</button>
66
+ </div>
67
+ <div class="status idle" data-id="vad-status">No VAD loaded — full clip will be transcribed.</div>
68
+ </div>
69
+
70
+ <div class="card">
71
+ <div class="card-title">Input</div>
72
+ <div class="form-row">
73
+ <label>Source</label>
74
+ <select data-id="source-mode">
75
+ <option value="upload">Upload WAV</option>
76
+ <option value="record">Record from mic</option>
77
+ </select>
78
+ </div>
79
+ <div class="form-row" data-id="upload-row">
80
+ <label>Audio file</label>
81
+ <input type="file" data-id="audio-file" accept="audio/*">
82
+ </div>
83
+ <div class="form-row" data-id="record-row" style="display:none;">
84
+ <label>Recording</label>
85
+ <button class="btn btn-primary" data-id="rec-btn" disabled>● Record</button>
86
+ <span data-id="rec-time" class="vad-info">00:00.0</span>
87
+ </div>
88
+ <div class="btn-row">
89
+ <button class="btn btn-primary" data-id="run-btn" disabled>Transcribe</button>
90
+ <button class="btn btn-ghost" data-id="redecode-btn" disabled>Re-decode (LLM)</button>
91
+ </div>
92
+ </div>
93
+
94
+ <div class="card">
95
+ <div class="card-title">Transcript</div>
96
+ <div class="transcript" data-id="transcript"></div>
97
+ </div>
98
+ </section>
99
+
100
+ <!-- ─── ASR Online ──────────────────────────────────────── -->
101
+ <section data-page="asr-online" hidden>
102
+ <div class="card">
103
+ <div class="card-title">Model</div>
104
+ <div class="form-row">
105
+ <label>Model URL</label>
106
+ <input type="text" data-id="model-url"
107
+ placeholder="https://huggingface.co/RapidAI/RapidSpeech/resolve/main/ASR/FunasrNano/funasr-nano-q4_k_m.gguf">
108
+ </div>
109
+ <div class="form-row">
110
+ <label>Threads</label>
111
+ <input type="number" data-id="n-threads" value="4" min="1" max="8">
112
+ <label style="min-width: 80px;">Use LLM</label>
113
+ <input type="checkbox" data-id="use-llm" checked>
114
+ <label style="min-width: 90px;">Two-pass</label>
115
+ <input type="checkbox" data-id="two-pass">
116
+ </div>
117
+ <div class="progress-container" data-id="progress-container">
118
+ <div class="progress-track"><div class="progress-fill" data-id="progress-bar"></div></div>
119
+ </div>
120
+ <div class="btn-row">
121
+ <button class="btn btn-load" data-id="load-btn">Load Model</button>
122
+ </div>
123
+ <div class="status idle" data-id="status">Enter a model URL and click Load.</div>
124
+ </div>
125
+
126
+ <div class="card">
127
+ <div class="card-title">VAD (neural — silero-vad or firered-vad; falls back to energy gate)</div>
128
+ <div class="form-row">
129
+ <label>VAD model URL</label>
130
+ <input type="text" data-id="vad-url"
131
+ placeholder="https://huggingface.co/RapidAI/RapidSpeech/resolve/main/VAD/firered-stream-vad.gguf">
132
+ </div>
133
+ <div class="progress-container" data-id="vad-progress-container">
134
+ <div class="progress-track"><div class="progress-fill" data-id="vad-progress-bar"></div></div>
135
+ </div>
136
+ <div class="btn-row">
137
+ <button class="btn btn-load" data-id="vad-load-btn">Load VAD</button>
138
+ <span class="status idle" data-id="vad-status" style="flex:1;">Energy gate (default).</span>
139
+ </div>
140
+ <div class="form-row">
141
+ <label>Speech threshold</label>
142
+ <input type="range" data-id="vad-threshold" min="0.001" max="0.9" step="0.005" value="0.5">
143
+ <span class="range-value" data-id="vad-threshold-val">0.500</span>
144
+ </div>
145
+ <div class="form-row">
146
+ <label>Silence frames (energy mode only)</label>
147
+ <input type="range" data-id="silence-frames" min="5" max="40" step="1" value="15">
148
+ <span class="range-value" data-id="silence-frames-val">15</span>
149
+ </div>
150
+ </div>
151
+
152
+ <div class="card">
153
+ <div class="card-title">Microphone</div>
154
+ <div class="btn-row">
155
+ <button class="btn btn-primary" data-id="start-btn" disabled>Start</button>
156
+ <button class="btn btn-secondary" data-id="stop-btn" disabled>Stop</button>
157
+ <button class="btn btn-ghost" data-id="clear-btn">Clear</button>
158
+ </div>
159
+ <div class="vad-container">
160
+ <div class="vad-track"><div class="vad-fill" data-id="vad-bar"></div></div>
161
+ <div class="vad-meta">
162
+ <span class="vad-label" data-id="vad-label">silence</span>
163
+ <span class="vad-info" data-id="info"></span>
164
+ </div>
165
+ </div>
166
+ <div class="status error" data-id="rtf-warn"
167
+ style="display:none; margin-top:8px;"></div>
168
+ </div>
169
+
170
+ <div class="card">
171
+ <div class="card-title">Transcript</div>
172
+ <div class="transcript" data-id="transcript"></div>
173
+ <div class="partial" data-id="partial"></div>
174
+ </div>
175
+ </section>
176
+
177
+ <!-- ─── TTS Offline ─────────────────────────────────────── -->
178
+ <section data-page="tts-offline" hidden>
179
+ <div class="card">
180
+ <div class="card-title">Model</div>
181
+ <div class="form-row">
182
+ <label>Model URL</label>
183
+ <input type="text" data-id="model-url"
184
+ placeholder="https://huggingface.co/RapidAI/RapidSpeech/resolve/main/TTS/omnivoice-q4_k_m.gguf">
185
+ </div>
186
+ <div class="form-row">
187
+ <label>Threads</label>
188
+ <input type="number" data-id="n-threads" value="4" min="1" max="8">
189
+ </div>
190
+ <div class="progress-container" data-id="progress-container">
191
+ <div class="progress-track"><div class="progress-fill" data-id="progress-bar"></div></div>
192
+ </div>
193
+ <div class="btn-row">
194
+ <button class="btn btn-load" data-id="load-btn">Load Model</button>
195
+ </div>
196
+ <div class="status idle" data-id="status">Enter a model URL and click Load.</div>
197
+ </div>
198
+
199
+ <div class="card">
200
+ <div class="card-title">Generation params</div>
201
+ <div class="form-row">
202
+ <label>Instruct</label>
203
+ <select data-id="instruct">
204
+ <option value="male">male</option>
205
+ <option value="female">female</option>
206
+ <option value="child">child</option>
207
+ </select>
208
+ <label style="min-width: 80px;">Language</label>
209
+ <select data-id="language">
210
+ <option value="English">English</option>
211
+ <option value="Chinese">Chinese</option>
212
+ </select>
213
+ </div>
214
+ <div class="form-row">
215
+ <label>Seed</label>
216
+ <input type="number" data-id="seed" value="42">
217
+ <label style="min-width: 120px;">Diffusion steps</label>
218
+ <input type="range" data-id="steps" min="8" max="50" step="1" value="32">
219
+ <span class="range-value" data-id="steps-val">32</span>
220
+ </div>
221
+ </div>
222
+
223
+ <div class="card">
224
+ <div class="card-title">Voice cloning (optional)</div>
225
+ <div class="form-row">
226
+ <label>Reference WAV</label>
227
+ <input type="file" data-id="ref-audio" accept="audio/*">
228
+ </div>
229
+ <div class="form-row">
230
+ <label>Reference text</label>
231
+ <input type="text" data-id="ref-text" placeholder="Transcription of the reference clip">
232
+ </div>
233
+ </div>
234
+
235
+ <div class="card">
236
+ <div class="card-title">Synthesize</div>
237
+ <div class="form-row">
238
+ <label style="min-width: 110px;">Text</label>
239
+ <textarea data-id="text" placeholder="Type the text you want to synthesize..."></textarea>
240
+ </div>
241
+ <div class="btn-row">
242
+ <button class="btn btn-primary" data-id="gen-btn" disabled>Generate</button>
243
+ <button class="btn btn-ghost" data-id="clear-btn">Clear</button>
244
+ </div>
245
+ <div class="audio-output" data-id="audio-output" hidden>
246
+ <audio data-id="audio" controls></audio>
247
+ <a class="download-link" data-id="download" download="rapidspeech-tts.wav">⬇ Download WAV</a>
248
+ <span class="vad-info" data-id="gen-info"></span>
249
+ </div>
250
+ </div>
251
+ </section>
252
+
253
+ <div class="footer">
254
+ <a href="https://github.com/lovemefan/RapidSpeech.cpp" target="_blank">RapidSpeech.cpp</a>
255
+ &mdash; GGUF speech models, in your browser.
256
+ </div>
257
+ </div>
258
+
259
+ <!-- Emscripten-generated WASM glue -->
260
+ <script src="rapidspeech-wasm.js"></script>
261
+ <!-- Bridge + shared utils -->
262
+ <script src="rapidspeech-bridge.js"></script>
263
+ <script src="utils.js"></script>
264
+ <!-- Per-tab modules -->
265
+ <script src="pages/asr-offline.js"></script>
266
+ <script src="pages/asr-online.js"></script>
267
+ <script src="pages/tts-offline.js"></script>
268
+ <!-- Tab router -->
269
+ <script src="app.js"></script>
270
+ </body>
271
+ </html>
public/pages/asr-offline.js ADDED
@@ -0,0 +1,313 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * ASR Offline — upload a file or record a clip, transcribe the whole thing.
3
+ * Optional neural VAD (silero-vad / firered-vad) segments long clips into
4
+ * speech regions and transcribes each region separately.
5
+ *
6
+ * Exposes window.RSPageAsrOffline.init({root, createInstance}).
7
+ */
8
+ (function () {
9
+ const { decodeFileToMono16k, escapeHtml, statusSetter, TARGET_SR } = window.RSUtils;
10
+
11
+ function $(root, id) { return root.querySelector(`[data-id="${id}"]`); }
12
+
13
+ async function init({ root, createInstance }) {
14
+ const els = {
15
+ modelUrl: $(root, 'model-url'),
16
+ nThreads: $(root, 'n-threads'),
17
+ useLlm: $(root, 'use-llm'),
18
+ loadBtn: $(root, 'load-btn'),
19
+ status: $(root, 'status'),
20
+ progress: $(root, 'progress-container'),
21
+ progressBar: $(root, 'progress-bar'),
22
+
23
+ vadUrl: $(root, 'vad-url'),
24
+ vadThreshold: $(root, 'vad-threshold'),
25
+ vadThresholdVal: $(root, 'vad-threshold-val'),
26
+ vadMinSeg: $(root, 'vad-min-seg'),
27
+ vadLoadBtn: $(root, 'vad-load-btn'),
28
+ vadStatus: $(root, 'vad-status'),
29
+ vadProgress: $(root, 'vad-progress-container'),
30
+ vadProgressBar:$(root, 'vad-progress-bar'),
31
+
32
+ sourceMode: $(root, 'source-mode'),
33
+ uploadRow: $(root, 'upload-row'),
34
+ recordRow: $(root, 'record-row'),
35
+ audioFile: $(root, 'audio-file'),
36
+ recBtn: $(root, 'rec-btn'),
37
+ recTime: $(root, 'rec-time'),
38
+
39
+ runBtn: $(root, 'run-btn'),
40
+ redecodeBtn: $(root, 'redecode-btn'),
41
+ transcript: $(root, 'transcript'),
42
+ };
43
+
44
+ const setStatus = statusSetter(els.status);
45
+ const setVadStatus = statusSetter(els.vadStatus);
46
+
47
+ let rs = null;
48
+ let vad = null;
49
+ let pendingPcm = null; // Float32Array @ 16k, ready to run
50
+ let mediaStream = null;
51
+ let mediaRecorder = null;
52
+ let recChunks = [];
53
+ let recStartTime = 0;
54
+ let recTimer = null;
55
+ let recording = false;
56
+
57
+ els.vadThreshold.addEventListener('input', () => {
58
+ const v = parseFloat(els.vadThreshold.value);
59
+ els.vadThresholdVal.textContent = v.toFixed(2);
60
+ if (vad && vad.isReady) vad.setThreshold(v);
61
+ });
62
+ els.vadThresholdVal.textContent = parseFloat(els.vadThreshold.value).toFixed(2);
63
+
64
+ els.sourceMode.addEventListener('change', () => {
65
+ const isRecord = els.sourceMode.value === 'record';
66
+ els.uploadRow.style.display = isRecord ? 'none' : '';
67
+ els.recordRow.style.display = isRecord ? '' : 'none';
68
+ });
69
+
70
+ els.loadBtn.addEventListener('click', loadModel);
71
+ els.vadLoadBtn.addEventListener('click', loadVad);
72
+ els.audioFile.addEventListener('change', onFilePicked);
73
+ els.recBtn.addEventListener('click', toggleRecord);
74
+ els.runBtn.addEventListener('click', runTranscribe);
75
+ els.redecodeBtn.addEventListener('click', runRedecode);
76
+
77
+ async function loadModel() {
78
+ const url = els.modelUrl.value.trim();
79
+ if (!url) { setStatus('Please enter a model URL.', 'error'); return; }
80
+
81
+ setStatus('Downloading model...', 'loading');
82
+ els.loadBtn.disabled = true;
83
+ els.progress.style.display = 'block';
84
+
85
+ try {
86
+ rs = await createInstance();
87
+ const nThreads = parseInt(els.nThreads.value, 10) || 4;
88
+ await rs.initAsr(url, nThreads, (p) => {
89
+ els.progressBar.style.width = (p * 100).toFixed(1) + '%';
90
+ });
91
+ rs.setUseLlm(els.useLlm.checked);
92
+ els.useLlm.addEventListener('change', () => rs.setUseLlm(els.useLlm.checked));
93
+ els.progress.style.display = 'none';
94
+ els.recBtn.disabled = false;
95
+ if (pendingPcm) els.runBtn.disabled = false;
96
+ setStatus(`Ready — ${rs.archName} @ ${rs.sampleRate}Hz`, 'ready');
97
+ } catch (err) {
98
+ setStatus('Failed: ' + err.message, 'error');
99
+ els.progress.style.display = 'none';
100
+ } finally {
101
+ els.loadBtn.disabled = false;
102
+ }
103
+ }
104
+
105
+ async function loadVad() {
106
+ const url = els.vadUrl.value.trim();
107
+ if (!url) { setVadStatus('Enter a VAD model URL first.', 'error'); return; }
108
+
109
+ setVadStatus('Downloading VAD...', 'loading');
110
+ els.vadLoadBtn.disabled = true;
111
+ els.vadProgress.style.display = 'block';
112
+
113
+ try {
114
+ // The VAD shares the same WASM module as the ASR context. Each tab
115
+ // already has its own MODULARIZE'd module, so the VAD lives next to
116
+ // the rs context on that module.
117
+ if (!rs) {
118
+ rs = await createInstance();
119
+ }
120
+ vad = new window.RapidSpeechVAD(rs._mod);
121
+ await vad.load(url, 2, (p) => {
122
+ els.vadProgressBar.style.width = (p * 100).toFixed(1) + '%';
123
+ });
124
+ vad.setThreshold(parseFloat(els.vadThreshold.value));
125
+ els.vadProgress.style.display = 'none';
126
+ setVadStatus(`Ready — ${vad.arch}`, 'ready');
127
+ } catch (err) {
128
+ setVadStatus('Failed: ' + err.message, 'error');
129
+ els.vadProgress.style.display = 'none';
130
+ vad = null;
131
+ } finally {
132
+ els.vadLoadBtn.disabled = false;
133
+ }
134
+ }
135
+
136
+ async function onFilePicked() {
137
+ const f = els.audioFile.files[0];
138
+ if (!f) return;
139
+ setStatus('Decoding audio...', 'loading');
140
+ try {
141
+ pendingPcm = await decodeFileToMono16k(f);
142
+ setStatus(`Decoded ${f.name} — ${(pendingPcm.length / TARGET_SR).toFixed(2)} s`, 'ready');
143
+ if (rs) els.runBtn.disabled = false;
144
+ } catch (err) {
145
+ setStatus('Decode failed: ' + err.message, 'error');
146
+ }
147
+ }
148
+
149
+ async function toggleRecord() {
150
+ if (recording) {
151
+ stopRecording();
152
+ return;
153
+ }
154
+ try {
155
+ mediaStream = await navigator.mediaDevices.getUserMedia({
156
+ audio: { channelCount: 1, sampleRate: { ideal: TARGET_SR } }
157
+ });
158
+ } catch { setStatus('Microphone access denied.', 'error'); return; }
159
+
160
+ recChunks = [];
161
+ mediaRecorder = new MediaRecorder(mediaStream);
162
+ mediaRecorder.ondataavailable = (e) => { if (e.data.size > 0) recChunks.push(e.data); };
163
+ mediaRecorder.onstop = async () => {
164
+ const blob = new Blob(recChunks, { type: mediaRecorder.mimeType || 'audio/webm' });
165
+ setStatus('Decoding recording...', 'loading');
166
+ try {
167
+ pendingPcm = await decodeFileToMono16k(blob);
168
+ setStatus(`Recorded ${(pendingPcm.length / TARGET_SR).toFixed(2)} s`, 'ready');
169
+ if (rs) els.runBtn.disabled = false;
170
+ } catch (err) {
171
+ setStatus('Decode failed: ' + err.message, 'error');
172
+ }
173
+ };
174
+ mediaRecorder.start();
175
+ recording = true;
176
+ recStartTime = performance.now();
177
+ els.recBtn.textContent = '■ Stop';
178
+ recTimer = setInterval(() => {
179
+ const t = (performance.now() - recStartTime) / 1000;
180
+ const m = Math.floor(t / 60);
181
+ const s = (t % 60).toFixed(1);
182
+ els.recTime.textContent = String(m).padStart(2, '0') + ':' + String(s).padStart(4, '0');
183
+ }, 100);
184
+ }
185
+
186
+ function stopRecording() {
187
+ if (!recording) return;
188
+ recording = false;
189
+ els.recBtn.textContent = '● Record';
190
+ clearInterval(recTimer);
191
+ mediaRecorder.stop();
192
+ mediaStream.getTracks().forEach(t => t.stop());
193
+ mediaStream = null;
194
+ }
195
+
196
+ // Detect speech segments using the loaded VAD.
197
+ function detectSegments(pcm) {
198
+ vad.reset();
199
+ vad.setThreshold(parseFloat(els.vadThreshold.value));
200
+ // Push in chunks so we don't fight a huge single allocation.
201
+ const STRIDE = 16000;
202
+ for (let off = 0; off < pcm.length; off += STRIDE) {
203
+ const end = Math.min(off + STRIDE, pcm.length);
204
+ vad.pushAudio(pcm.subarray(off, end));
205
+ }
206
+ const segments = vad.drainSegments(1024);
207
+ // If the clip ends mid-speech, close out a final segment so we don't
208
+ // drop the trailing audio.
209
+ if (vad.isSpeech) {
210
+ const lastEnd = pcm.length / TARGET_SR;
211
+ const lastStart = segments.length > 0 ? segments[segments.length - 1].end_s : 0;
212
+ segments.push({ start_s: Math.max(lastStart, 0), end_s: lastEnd });
213
+ }
214
+ const minSeg = parseFloat(els.vadMinSeg.value) || 0;
215
+ return segments.filter(s => s.end_s - s.start_s >= minSeg);
216
+ }
217
+
218
+ async function runTranscribe() {
219
+ if (!rs || !pendingPcm) return;
220
+ els.runBtn.disabled = true;
221
+ els.redecodeBtn.disabled = true;
222
+
223
+ const useVad = vad && vad.isReady;
224
+ const audioDur = pendingPcm.length / TARGET_SR;
225
+
226
+ if (!useVad) {
227
+ setStatus('Running ASR (full clip)...', 'loading');
228
+ rs.reset();
229
+ rs.pushAudio(pendingPcm);
230
+ const t0 = performance.now();
231
+ let result;
232
+ try { result = await rs.process(); }
233
+ catch (err) { setStatus('ASR failed: ' + err.message, 'error'); els.runBtn.disabled = false; return; }
234
+ const dt = (performance.now() - t0) / 1000;
235
+ const rtf = dt / audioDur;
236
+ if (result.status > 0 && result.text) {
237
+ appendLine(0, audioDur, result.text, rtf);
238
+ els.redecodeBtn.disabled = false;
239
+ } else {
240
+ appendLine(0, audioDur, '(no speech detected)', rtf);
241
+ }
242
+ setStatus(`Done in ${dt.toFixed(2)} s (RTF ${rtf.toFixed(2)})`, 'ready');
243
+ els.runBtn.disabled = false;
244
+ return;
245
+ }
246
+
247
+ setStatus('Running VAD...', 'loading');
248
+ const segments = detectSegments(pendingPcm);
249
+ if (segments.length === 0) {
250
+ appendLine(0, audioDur, '(no speech detected by VAD)', 0);
251
+ setStatus('VAD found no speech.', 'ready');
252
+ els.runBtn.disabled = false;
253
+ return;
254
+ }
255
+
256
+ const t0 = performance.now();
257
+ for (let i = 0; i < segments.length; ++i) {
258
+ const seg = segments[i];
259
+ setStatus(`Transcribing segment ${i+1}/${segments.length}...`, 'loading');
260
+ const s0 = Math.max(0, Math.floor(seg.start_s * TARGET_SR));
261
+ const s1 = Math.min(pendingPcm.length, Math.ceil(seg.end_s * TARGET_SR));
262
+ if (s1 <= s0) continue;
263
+ const slice = pendingPcm.subarray(s0, s1);
264
+ rs.reset();
265
+ rs.pushAudio(slice);
266
+ let result;
267
+ try { result = await rs.process(); }
268
+ catch (err) { appendLine(seg.start_s, seg.end_s, `(error: ${err.message})`, null); continue; }
269
+ const text = (result.status > 0 && result.text) ? result.text : '(no speech)';
270
+ const segDur = seg.end_s - seg.start_s;
271
+ appendLine(seg.start_s, seg.end_s, text, null, segDur);
272
+ }
273
+ const dt = (performance.now() - t0) / 1000;
274
+ const rtf = dt / audioDur;
275
+ els.redecodeBtn.disabled = false;
276
+ setStatus(`Done — ${segments.length} segment(s) in ${dt.toFixed(2)} s (RTF ${rtf.toFixed(2)})`, 'ready');
277
+ els.runBtn.disabled = false;
278
+ }
279
+
280
+ async function runRedecode() {
281
+ if (!rs) return;
282
+ els.redecodeBtn.disabled = true;
283
+ setStatus('Re-decoding...', 'loading');
284
+ const t0 = performance.now();
285
+ let result;
286
+ try { result = await rs.redecode(); }
287
+ catch (err) { setStatus('Redecode failed: ' + err.message, 'error'); els.redecodeBtn.disabled = false; return; }
288
+ const dt = (performance.now() - t0) / 1000;
289
+ if (result.status > 0 && result.text) {
290
+ appendLine(null, null, '↻ ' + result.text, null);
291
+ }
292
+ setStatus(`Re-decoded in ${dt.toFixed(2)} s`, 'ready');
293
+ els.redecodeBtn.disabled = false;
294
+ }
295
+
296
+ function appendLine(startS, endS, text, rtf, segDur) {
297
+ const line = document.createElement('div');
298
+ line.className = 'transcript-line';
299
+ let meta = '';
300
+ if (startS != null && endS != null) {
301
+ meta = `[${startS.toFixed(2)}s → ${endS.toFixed(2)}s]`;
302
+ if (rtf != null) meta += ` RTF ${rtf.toFixed(2)}`;
303
+ } else if (rtf != null) {
304
+ meta = `[RTF ${rtf.toFixed(2)}]`;
305
+ }
306
+ line.innerHTML = `<span class="timestamp">${meta}</span> ${escapeHtml(text)}`;
307
+ els.transcript.appendChild(line);
308
+ els.transcript.scrollTop = els.transcript.scrollHeight;
309
+ }
310
+ }
311
+
312
+ window.RSPageAsrOffline = { init };
313
+ })();
public/pages/asr-online.js ADDED
@@ -0,0 +1,521 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * ASR Online — microphone capture → VAD (neural or energy fallback) → ASR.
3
+ *
4
+ * When a VAD model (silero-vad or firered-vad) is loaded, audio is fed through
5
+ * the neural VAD and segments are dispatched to ASR on `is_speech_end` events.
6
+ * Otherwise the page falls back to an RMS-energy gate with `silence-frames`
7
+ * hysteresis.
8
+ *
9
+ * Exposes window.RSPageAsrOnline.init({root, createInstance}).
10
+ */
11
+ (function () {
12
+ const { formatTime, escapeHtml, statusSetter } = window.RSUtils;
13
+ const SAMPLE_RATE = 16000;
14
+
15
+ function $(root, id) { return root.querySelector(`[data-id="${id}"]`); }
16
+
17
+ async function init({ root, createInstance }) {
18
+ const els = {
19
+ modelUrl: $(root, 'model-url'),
20
+ nThreads: $(root, 'n-threads'),
21
+ useLlm: $(root, 'use-llm'),
22
+ twoPass: $(root, 'two-pass'),
23
+ loadBtn: $(root, 'load-btn'),
24
+ status: $(root, 'status'),
25
+ progress: $(root, 'progress-container'),
26
+ progressBar: $(root, 'progress-bar'),
27
+
28
+ vadUrl: $(root, 'vad-url'),
29
+ vadLoadBtn: $(root, 'vad-load-btn'),
30
+ vadStatus: $(root, 'vad-status'),
31
+ vadProgress: $(root, 'vad-progress-container'),
32
+ vadProgressBar:$(root, 'vad-progress-bar'),
33
+
34
+ threshold: $(root, 'vad-threshold'),
35
+ thresholdVal: $(root, 'vad-threshold-val'),
36
+ silenceFrames: $(root, 'silence-frames'),
37
+ silenceFramesVal: $(root, 'silence-frames-val'),
38
+
39
+ startBtn: $(root, 'start-btn'),
40
+ stopBtn: $(root, 'stop-btn'),
41
+ clearBtn: $(root, 'clear-btn'),
42
+ vadBar: $(root, 'vad-bar'),
43
+ vadLabel: $(root, 'vad-label'),
44
+ info: $(root, 'info'),
45
+ transcript: $(root, 'transcript'),
46
+ partial: $(root, 'partial'),
47
+ rtfWarn: $(root, 'rtf-warn'),
48
+ };
49
+
50
+ const setStatus = statusSetter(els.status);
51
+ const setVadStatus = statusSetter(els.vadStatus);
52
+
53
+ // Mutable runtime state (rebuilt each Start).
54
+ let rs = null;
55
+ let vad = null;
56
+ let audioCtx = null;
57
+ let mediaStream = null;
58
+ let running = false;
59
+ let carry = new Float32Array(0);
60
+ let silenceFrames = 0;
61
+ let inSpeech = false;
62
+ let speechBuffer = [];
63
+ let speechStartTime = 0;
64
+ let audioTime = 0;
65
+ let workletNode = null;
66
+ let scriptNode = null;
67
+ // VAD-mode (neural) speech-buffer state.
68
+ let vadSpeechBuf = [];
69
+ let vadSpeechStart = 0;
70
+ // Serialize ASR dispatches — rs is a single shared context and calling
71
+ // rs.reset/pushAudio/process concurrently from overlapping VAD segments
72
+ // corrupts state and deadlocks the WebGPU queue.
73
+ let asrQueue = Promise.resolve();
74
+
75
+ function vadParams() {
76
+ return {
77
+ threshold: parseFloat(els.threshold.value),
78
+ silenceMax: parseInt(els.silenceFrames.value, 10),
79
+ };
80
+ }
81
+
82
+ els.threshold.addEventListener('input', () => {
83
+ const v = parseFloat(els.threshold.value);
84
+ els.thresholdVal.textContent = v.toFixed(3);
85
+ if (vad && vad.isReady) vad.setThreshold(v);
86
+ });
87
+ els.silenceFrames.addEventListener('input', () => {
88
+ els.silenceFramesVal.textContent = els.silenceFrames.value;
89
+ });
90
+ els.thresholdVal.textContent = parseFloat(els.threshold.value).toFixed(3);
91
+ els.silenceFramesVal.textContent = els.silenceFrames.value;
92
+
93
+ els.loadBtn.addEventListener('click', loadModel);
94
+ els.vadLoadBtn.addEventListener('click', loadVad);
95
+ els.startBtn.addEventListener('click', startMic);
96
+ els.stopBtn.addEventListener('click', stopMic);
97
+ els.clearBtn.addEventListener('click', clearTranscript);
98
+
99
+ window.addEventListener('beforeunload', stopMic);
100
+
101
+ async function loadModel() {
102
+ const url = els.modelUrl.value.trim();
103
+ if (!url) { setStatus('Please enter a model URL.', 'error'); return; }
104
+
105
+ setStatus('Downloading model...', 'loading');
106
+ els.loadBtn.disabled = true;
107
+ els.progress.style.display = 'block';
108
+
109
+ try {
110
+ rs = await createInstance();
111
+ const nThreads = parseInt(els.nThreads.value, 10) || 4;
112
+ await rs.initAsr(url, nThreads, (p) => {
113
+ els.progressBar.style.width = (p * 100).toFixed(1) + '%';
114
+ });
115
+ rs.setUseLlm(els.useLlm.checked);
116
+ els.useLlm.addEventListener('change', () => rs.setUseLlm(els.useLlm.checked));
117
+
118
+ els.progress.style.display = 'none';
119
+ els.startBtn.disabled = false;
120
+ setStatus(`Ready — ${rs.archName} @ ${rs.sampleRate}Hz`, 'ready');
121
+ els.info.textContent = `${rs.archName} | ${rs.sampleRate} Hz`;
122
+ } catch (err) {
123
+ setStatus('Failed: ' + err.message, 'error');
124
+ els.progress.style.display = 'none';
125
+ } finally {
126
+ els.loadBtn.disabled = false;
127
+ }
128
+ }
129
+
130
+ async function loadVad() {
131
+ const url = els.vadUrl.value.trim();
132
+ if (!url) { setVadStatus('Enter a VAD model URL first.', 'error'); return; }
133
+ setVadStatus('Downloading VAD...', 'loading');
134
+ els.vadLoadBtn.disabled = true;
135
+ els.vadProgress.style.display = 'block';
136
+ try {
137
+ if (!rs) { rs = await createInstance(); }
138
+ vad = new window.RapidSpeechVAD(rs._mod);
139
+ await vad.load(url, 2, (p) => {
140
+ els.vadProgressBar.style.width = (p * 100).toFixed(1) + '%';
141
+ });
142
+ vad.setThreshold(parseFloat(els.threshold.value));
143
+ els.vadProgress.style.display = 'none';
144
+ setVadStatus(`Neural VAD — ${vad.arch}`, 'ready');
145
+ } catch (err) {
146
+ setVadStatus('Failed: ' + err.message, 'error');
147
+ els.vadProgress.style.display = 'none';
148
+ vad = null;
149
+ } finally {
150
+ els.vadLoadBtn.disabled = false;
151
+ }
152
+ }
153
+
154
+ async function startMic() {
155
+ if (running) return;
156
+ try {
157
+ mediaStream = await navigator.mediaDevices.getUserMedia({
158
+ audio: { channelCount: 1, sampleRate: { ideal: SAMPLE_RATE },
159
+ echoCancellation: true, noiseSuppression: true }
160
+ });
161
+ } catch {
162
+ setStatus('Microphone access denied.', 'error');
163
+ return;
164
+ }
165
+
166
+ audioCtx = new (window.AudioContext || window.webkitAudioContext)({ sampleRate: SAMPLE_RATE });
167
+ if (audioCtx.audioWorklet) {
168
+ await startWithAudioWorklet();
169
+ } else {
170
+ startWithScriptProcessor();
171
+ }
172
+
173
+ // Reset segmentation state.
174
+ carry = new Float32Array(0);
175
+ inSpeech = false;
176
+ silenceFrames = 0;
177
+ speechBuffer = [];
178
+ vadSpeechBuf = [];
179
+ vadSpeechStart = 0;
180
+ asrQueue = Promise.resolve();
181
+ rtfHistory.length = 0;
182
+ rtfWarned = false;
183
+ if (els.rtfWarn) els.rtfWarn.style.display = 'none';
184
+ if (vad && vad.isReady) vad.reset();
185
+
186
+ running = true;
187
+ els.startBtn.disabled = true;
188
+ els.stopBtn.disabled = false;
189
+ setStatus(vad && vad.isReady ? 'Listening (neural VAD)...' : 'Listening (energy gate)...',
190
+ 'listening');
191
+ }
192
+
193
+ function startWithScriptProcessor() {
194
+ const source = audioCtx.createMediaStreamSource(mediaStream);
195
+ scriptNode = audioCtx.createScriptProcessor(4096, 1, 1);
196
+ scriptNode.onaudioprocess = (event) => {
197
+ if (!running) return;
198
+ processAudioChunk(event.inputBuffer.getChannelData(0));
199
+ };
200
+ source.connect(scriptNode);
201
+ scriptNode.connect(audioCtx.destination);
202
+ }
203
+
204
+ async function startWithAudioWorklet() {
205
+ const workletCode = `
206
+ class RSProcessor extends AudioWorkletProcessor {
207
+ process(inputs) {
208
+ const input = inputs[0];
209
+ if (input && input.length > 0) this.port.postMessage(input[0]);
210
+ return true;
211
+ }
212
+ }
213
+ registerProcessor('rs-processor', RSProcessor);
214
+ `;
215
+ const blob = new Blob([workletCode], { type: 'application/javascript' });
216
+ const url = URL.createObjectURL(blob);
217
+ await audioCtx.audioWorklet.addModule(url);
218
+ URL.revokeObjectURL(url);
219
+
220
+ workletNode = new AudioWorkletNode(audioCtx, 'rs-processor');
221
+ workletNode.port.onmessage = (event) => {
222
+ if (!running) return;
223
+ processAudioChunk(event.data);
224
+ };
225
+ const source = audioCtx.createMediaStreamSource(mediaStream);
226
+ source.connect(workletNode);
227
+ workletNode.connect(audioCtx.destination);
228
+ }
229
+
230
+ function processAudioChunk(buffer) {
231
+ // If the audio device gave us a non-16k stream, resample on the fly.
232
+ // (most browsers honor the AudioContext's 16 k sampleRate hint.)
233
+ let pcm = buffer;
234
+ if (audioCtx && audioCtx.sampleRate !== SAMPLE_RATE) {
235
+ // Cheap linear resample for the live path — segments going to ASR
236
+ // already pass through the high-quality OfflineAudioContext resampler
237
+ // in decodeFileToMono16k; here it just feeds VAD/energy gating.
238
+ const ratio = audioCtx.sampleRate / SAMPLE_RATE;
239
+ const outLen = Math.floor(buffer.length / ratio);
240
+ const tmp = new Float32Array(outLen);
241
+ for (let i = 0; i < outLen; ++i) tmp[i] = buffer[Math.floor(i * ratio)] || 0;
242
+ pcm = tmp;
243
+ }
244
+
245
+ if (vad && vad.isReady) {
246
+ processWithNeuralVad(pcm);
247
+ } else {
248
+ processWithEnergyGate(pcm);
249
+ }
250
+ }
251
+
252
+ function processWithNeuralVad(pcm) {
253
+ audioTime += pcm.length / SAMPLE_RATE;
254
+ vad.pushAudio(pcm);
255
+ const prob = vad.probability;
256
+ updateVadDisplay(Math.min(prob, 1.0), vad.isSpeech);
257
+
258
+ // Buffer the audio while drains tell us what segments closed. We always
259
+ // buffer (even during silence) so we can slice [start_s..end_s] out of
260
+ // it when an end-event arrives.
261
+ // Memory bound: keep at most 60 s of trailing audio in the buffer.
262
+ vadSpeechBuf.push(pcm);
263
+ const maxSamples = SAMPLE_RATE * 60;
264
+ let totalBuf = 0;
265
+ for (const b of vadSpeechBuf) totalBuf += b.length;
266
+ while (totalBuf > maxSamples && vadSpeechBuf.length > 1) {
267
+ const dropped = vadSpeechBuf.shift().length;
268
+ totalBuf -= dropped;
269
+ vadSpeechStart += dropped / SAMPLE_RATE;
270
+ }
271
+
272
+ // Walk frame events to find segment closes.
273
+ const frames = vad.drainFrames(512);
274
+ for (const f of frames) {
275
+ if (f.is_speech_end) {
276
+ // Segments come from drainSegments — they have absolute timestamps
277
+ // relative to the VAD's `samples_in` counter.
278
+ // We already track audioTime which mirrors that — close the segment
279
+ // using the segment queue.
280
+ }
281
+ }
282
+ const segs = vad.drainSegments(32);
283
+ for (const seg of segs) {
284
+ const startS = seg.start_s;
285
+ const endS = seg.end_s;
286
+ // Chain onto the serial ASR queue so concurrent segments don't race.
287
+ asrQueue = asrQueue.then(() => dispatchVadSegment(startS, endS))
288
+ .catch((err) => console.error('ASR queue error', err));
289
+ }
290
+ }
291
+
292
+ async function dispatchVadSegment(startS, endS) {
293
+ // Find the slice of pcm covering [startS, endS] using vadSpeechStart as
294
+ // the buffer origin in seconds.
295
+ const bufStart = vadSpeechStart;
296
+ const sliceStart = Math.max(0, Math.floor((startS - bufStart) * SAMPLE_RATE));
297
+ const sliceEnd = Math.max(sliceStart, Math.floor((endS - bufStart) * SAMPLE_RATE));
298
+ if (sliceEnd <= sliceStart) return;
299
+
300
+ // Concatenate buffered chunks into a flat slice.
301
+ const flat = new Float32Array(sliceEnd - sliceStart);
302
+ let off = 0, srcOff = 0, written = 0;
303
+ for (const b of vadSpeechBuf) {
304
+ const next = srcOff + b.length;
305
+ if (next <= sliceStart) { srcOff = next; continue; }
306
+ if (srcOff >= sliceEnd) break;
307
+ const from = Math.max(0, sliceStart - srcOff);
308
+ const to = Math.min(b.length, sliceEnd - srcOff);
309
+ flat.set(b.subarray(from, to), off);
310
+ off += to - from;
311
+ srcOff = next;
312
+ written += to - from;
313
+ if (srcOff >= sliceEnd) break;
314
+ }
315
+ if (written < SAMPLE_RATE * 0.2) { return; } // <200 ms — skip
316
+
317
+ const twoPass = els.twoPass && els.twoPass.checked;
318
+ const useLlm = els.useLlm.checked;
319
+ const audioS = written / SAMPLE_RATE;
320
+ const startLabel = formatTime(startS);
321
+ const endLabel = formatTime(endS);
322
+
323
+ rs.reset();
324
+ rs.pushAudio(flat);
325
+
326
+ if (twoPass) {
327
+ // Pass 1: CTC greedy (fast).
328
+ rs.setUseLlm(false);
329
+ const t0 = performance.now();
330
+ let ctcResult;
331
+ try { ctcResult = await rs.process(); }
332
+ catch (err) { console.error('ASR process failed', err); rs.reset(); return; }
333
+ const ctcInferS = (performance.now() - t0) / 1000;
334
+ const ctcRtf = audioS > 0 ? ctcInferS / audioS : 0;
335
+
336
+ // Render CTC as a partial line we can upgrade in place.
337
+ const line = appendTranscript(startLabel, endLabel,
338
+ ctcResult.text || '(no speech)',
339
+ { kind: 'ctc' });
340
+
341
+ // Pass 2: LLM rescore.
342
+ rs.setUseLlm(true);
343
+ const t1 = performance.now();
344
+ let llmResult;
345
+ try { llmResult = await rs.redecode(); }
346
+ catch (err) { console.error('ASR redecode failed', err); rs.reset(); return; }
347
+ const llmInferS = (performance.now() - t1) / 1000;
348
+ const totalRtf = audioS > 0 ? (ctcInferS + llmInferS) / audioS : 0;
349
+ maybeWarnRtf(totalRtf);
350
+
351
+ if (llmResult.status > 0 && llmResult.text) {
352
+ updateTranscriptLine(line, startLabel, endLabel, llmResult.text,
353
+ { kind: 'llm' });
354
+ }
355
+ } else {
356
+ if (!useLlm) rs.setUseLlm(false);
357
+ else rs.setUseLlm(true);
358
+ const t0 = performance.now();
359
+ let result;
360
+ try { result = await rs.process(); }
361
+ catch (err) { console.error('ASR process failed', err); rs.reset(); return; }
362
+ const inferS = (performance.now() - t0) / 1000;
363
+ const rtf = audioS > 0 ? inferS / audioS : 0;
364
+ maybeWarnRtf(rtf);
365
+ if (result.status > 0 && result.text) {
366
+ appendTranscript(startLabel, endLabel, result.text);
367
+ }
368
+ }
369
+ els.partial.textContent = '';
370
+ }
371
+
372
+ function processWithEnergyGate(buffer) {
373
+ const { threshold, silenceMax } = vadParams();
374
+ const chunkSize = 512;
375
+
376
+ const merged = new Float32Array(carry.length + buffer.length);
377
+ merged.set(carry, 0);
378
+ merged.set(buffer, carry.length);
379
+
380
+ let offset = 0;
381
+ for (; offset + chunkSize <= merged.length; offset += chunkSize) {
382
+ const chunk = merged.subarray(offset, offset + chunkSize);
383
+ const energy = rmsEnergy(chunk);
384
+ updateVadDisplay(Math.min(energy / 0.05, 1.0), energy >= threshold);
385
+ audioTime += chunkSize / SAMPLE_RATE;
386
+
387
+ if (energy >= threshold) {
388
+ silenceFrames = 0;
389
+ if (!inSpeech) {
390
+ inSpeech = true;
391
+ speechStartTime = audioTime - chunkSize / SAMPLE_RATE;
392
+ speechBuffer = [];
393
+ }
394
+ speechBuffer.push(...chunk);
395
+ } else if (inSpeech) {
396
+ speechBuffer.push(...chunk);
397
+ silenceFrames++;
398
+ if (silenceFrames >= silenceMax) {
399
+ finalizeEnergySegment();
400
+ }
401
+ }
402
+ }
403
+ carry = merged.slice(offset);
404
+ }
405
+
406
+ function rmsEnergy(pcm) {
407
+ let sum = 0;
408
+ for (let i = 0; i < pcm.length; i++) sum += pcm[i] * pcm[i];
409
+ return Math.sqrt(sum / pcm.length);
410
+ }
411
+
412
+ async function finalizeEnergySegment() {
413
+ if (speechBuffer.length === 0) return;
414
+ const buf = speechBuffer;
415
+ const segStart = speechStartTime;
416
+ const segEnd = audioTime;
417
+ inSpeech = false;
418
+ speechBuffer = [];
419
+ silenceFrames = 0;
420
+
421
+ let pcm = new Float32Array(buf);
422
+ if (pcm.length < SAMPLE_RATE * 0.2) { rs.reset(); els.partial.textContent = ''; return; }
423
+
424
+ rs.pushAudio(pcm);
425
+ let result;
426
+ try { result = await rs.process(); }
427
+ catch (err) { console.error('ASR process failed', err); rs.reset(); return; }
428
+
429
+ if (result.status > 0 && result.text) {
430
+ appendTranscript(formatTime(segStart), formatTime(segEnd), result.text);
431
+ }
432
+ rs.reset();
433
+ els.partial.textContent = '';
434
+ }
435
+
436
+ // Track recent RTFs — warn the user once the running average passes 1.
437
+ const rtfHistory = [];
438
+ let rtfWarned = false;
439
+ function maybeWarnRtf(rtf) {
440
+ if (!isFinite(rtf) || rtf <= 0) return;
441
+ rtfHistory.push(rtf);
442
+ if (rtfHistory.length > 5) rtfHistory.shift();
443
+ const avg = rtfHistory.reduce((a, b) => a + b, 0) / rtfHistory.length;
444
+ els.info.textContent = `${rs.archName} | ${rs.sampleRate} Hz | RTF ${avg.toFixed(2)}`;
445
+ if (els.rtfWarn) {
446
+ if (avg > 1.0 && !rtfWarned) {
447
+ rtfWarned = true;
448
+ const hint = els.useLlm.checked
449
+ ? '关闭 "Use LLM" 可显著提升速度(仅 CTC 解码)。'
450
+ : '当前已关闭 LLM;浏览器 WebGPU 内核调度开销限制了 encoder 吞吐。';
451
+ els.rtfWarn.textContent = `⚠ RTF ≈ ${avg.toFixed(2)} — 无法实时跟上音频。${hint}`;
452
+ els.rtfWarn.style.display = 'block';
453
+ } else if (avg <= 0.9 && rtfWarned) {
454
+ rtfWarned = false;
455
+ els.rtfWarn.style.display = 'none';
456
+ }
457
+ }
458
+ }
459
+
460
+ function appendTranscript(start, end, text, opts = {}) {
461
+ const line = document.createElement('div');
462
+ line.className = 'transcript-line';
463
+ if (opts.kind) line.dataset.kind = opts.kind;
464
+ renderLine(line, start, end, text, opts.kind);
465
+ els.transcript.appendChild(line);
466
+ els.transcript.scrollTop = els.transcript.scrollHeight;
467
+ return line;
468
+ }
469
+
470
+ function updateTranscriptLine(line, start, end, text, opts = {}) {
471
+ if (!line) return;
472
+ if (opts.kind) line.dataset.kind = opts.kind;
473
+ renderLine(line, start, end, text, opts.kind);
474
+ els.transcript.scrollTop = els.transcript.scrollHeight;
475
+ }
476
+
477
+ function renderLine(line, start, end, text, kind) {
478
+ // CTC partial gets a tag + greyed text; LLM result replaces it.
479
+ const tag = kind === 'ctc' ? ' <span class="tag tag-ctc">CTC</span>'
480
+ : kind === 'llm' ? ' <span class="tag tag-llm">LLM</span>'
481
+ : '';
482
+ const cls = kind === 'ctc' ? ' style="opacity:0.65; font-style:italic;"' : '';
483
+ line.innerHTML = `<span class="timestamp">[${start} → ${end}]</span>${tag} <span${cls}>${escapeHtml(text)}</span>`;
484
+ }
485
+
486
+ function stopMic() {
487
+ if (!running) return;
488
+ running = false;
489
+ els.startBtn.disabled = false;
490
+ els.stopBtn.disabled = true;
491
+
492
+ if (!vad || !vad.isReady) {
493
+ if (inSpeech && speechBuffer.length > 0) finalizeEnergySegment();
494
+ }
495
+ carry = new Float32Array(0);
496
+
497
+ if (workletNode) { try { workletNode.disconnect(); } catch {} workletNode = null; }
498
+ if (scriptNode) { try { scriptNode.disconnect(); } catch {} scriptNode = null; }
499
+ if (mediaStream) { mediaStream.getTracks().forEach(t => t.stop()); mediaStream = null; }
500
+ if (audioCtx && audioCtx.state !== 'closed') { audioCtx.close().catch(() => {}); audioCtx = null; }
501
+
502
+ setStatus('Stopped.', 'idle');
503
+ updateVadDisplay(0, false);
504
+ }
505
+
506
+ function clearTranscript() {
507
+ els.transcript.innerHTML = '';
508
+ els.partial.textContent = '';
509
+ audioTime = 0;
510
+ }
511
+
512
+ function updateVadDisplay(prob, isSpeech) {
513
+ els.vadBar.style.width = (prob * 100).toFixed(1) + '%';
514
+ els.vadBar.className = 'vad-fill' + (isSpeech ? ' speech' : '');
515
+ els.vadLabel.textContent = isSpeech ? 'SPEECH' : 'silence';
516
+ els.vadLabel.className = 'vad-label' + (isSpeech ? ' speech' : '');
517
+ }
518
+ }
519
+
520
+ window.RSPageAsrOnline = { init };
521
+ })();
public/pages/tts-offline.js ADDED
@@ -0,0 +1,152 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * TTS Offline — text → audio, optional voice cloning via reference audio.
3
+ * Exposes window.RSPageTtsOffline.init({root, createInstance}).
4
+ */
5
+ (function () {
6
+ const { decodeFileToMonoAt, encodeWav, statusSetter } = window.RSUtils;
7
+
8
+ function $(root, id) { return root.querySelector(`[data-id="${id}"]`); }
9
+
10
+ async function init({ root, createInstance }) {
11
+ const els = {
12
+ modelUrl: $(root, 'model-url'),
13
+ nThreads: $(root, 'n-threads'),
14
+ loadBtn: $(root, 'load-btn'),
15
+ status: $(root, 'status'),
16
+ progress: $(root, 'progress-container'),
17
+ progressBar: $(root, 'progress-bar'),
18
+
19
+ instruct: $(root, 'instruct'),
20
+ language: $(root, 'language'),
21
+ seed: $(root, 'seed'),
22
+ steps: $(root, 'steps'),
23
+ stepsVal: $(root, 'steps-val'),
24
+
25
+ refAudio: $(root, 'ref-audio'),
26
+ refText: $(root, 'ref-text'),
27
+
28
+ text: $(root, 'text'),
29
+ genBtn: $(root, 'gen-btn'),
30
+ clearBtn: $(root, 'clear-btn'),
31
+
32
+ audioOutput: $(root, 'audio-output'),
33
+ audio: $(root, 'audio'),
34
+ download: $(root, 'download'),
35
+ genInfo: $(root, 'gen-info'),
36
+ };
37
+
38
+ const setStatus = statusSetter(els.status);
39
+
40
+ let rs = null;
41
+ let refPcm = null; // Float32Array at refSr
42
+ let refSr = 16000;
43
+ let lastObjectUrl = null;
44
+
45
+ els.steps.addEventListener('input', () => {
46
+ els.stepsVal.textContent = els.steps.value;
47
+ });
48
+ els.stepsVal.textContent = els.steps.value;
49
+
50
+ els.loadBtn.addEventListener('click', loadModel);
51
+ els.refAudio.addEventListener('change', onRefPicked);
52
+ els.genBtn.addEventListener('click', generate);
53
+ els.clearBtn.addEventListener('click', clearOutput);
54
+
55
+ async function loadModel() {
56
+ const url = els.modelUrl.value.trim();
57
+ if (!url) { setStatus('Please enter a model URL.', 'error'); return; }
58
+
59
+ setStatus('Downloading model...', 'loading');
60
+ els.loadBtn.disabled = true;
61
+ els.progress.style.display = 'block';
62
+
63
+ try {
64
+ rs = await createInstance();
65
+ const nThreads = parseInt(els.nThreads.value, 10) || 4;
66
+ // Use generic init with TTS_OFFLINE task type so the engine batches the
67
+ // whole utterance instead of streaming chunks.
68
+ await rs.init(url, RS_TASK.TTS_OFFLINE, nThreads, (p) => {
69
+ els.progressBar.style.width = (p * 100).toFixed(1) + '%';
70
+ });
71
+ refSr = rs.sampleRate || 24000;
72
+ els.progress.style.display = 'none';
73
+ els.genBtn.disabled = false;
74
+ setStatus(`Ready — ${rs.archName} @ ${rs.sampleRate}Hz`, 'ready');
75
+ } catch (err) {
76
+ setStatus('Failed: ' + err.message, 'error');
77
+ els.progress.style.display = 'none';
78
+ } finally {
79
+ els.loadBtn.disabled = false;
80
+ }
81
+ }
82
+
83
+ async function onRefPicked() {
84
+ const f = els.refAudio.files[0];
85
+ if (!f) { refPcm = null; return; }
86
+ setStatus('Decoding reference audio...', 'loading');
87
+ try {
88
+ // Decode at the model's native rate (24k for OmniVoice, but we let the
89
+ // engine resample if it differs). We use rs.sampleRate if loaded, else
90
+ // a sensible default.
91
+ refPcm = await decodeFileToMonoAt(f, refSr);
92
+ setStatus(`Reference loaded (${(refPcm.length / refSr).toFixed(2)} s)`, 'ready');
93
+ } catch (err) {
94
+ setStatus('Decode failed: ' + err.message, 'error');
95
+ refPcm = null;
96
+ }
97
+ }
98
+
99
+ async function generate() {
100
+ if (!rs) return;
101
+ const text = els.text.value.trim();
102
+ if (!text) { setStatus('Enter some text first.', 'error'); return; }
103
+
104
+ els.genBtn.disabled = true;
105
+ setStatus('Synthesizing...', 'loading');
106
+
107
+ // Push voice-clone reference if provided.
108
+ if (refPcm) {
109
+ rs.pushReferenceAudio(refPcm, refSr);
110
+ const refT = els.refText.value.trim();
111
+ if (refT) rs.pushReferenceText(refT);
112
+ }
113
+ rs.setTtsParams({
114
+ instruct: els.instruct.value,
115
+ language: els.language.value,
116
+ seed: parseInt(els.seed.value, 10) || 42,
117
+ });
118
+ rs.setDiffusionSteps(parseInt(els.steps.value, 10) || 32);
119
+
120
+ const t0 = performance.now();
121
+ let pcm;
122
+ try { pcm = await rs.synthesize(text); }
123
+ catch (err) {
124
+ setStatus('Synthesis failed: ' + err.message, 'error');
125
+ els.genBtn.disabled = false;
126
+ return;
127
+ }
128
+ const dt = (performance.now() - t0) / 1000;
129
+ const dur = pcm.length / rs.sampleRate;
130
+ const rtf = dt / Math.max(dur, 1e-6);
131
+
132
+ const blob = encodeWav(pcm, rs.sampleRate);
133
+ if (lastObjectUrl) URL.revokeObjectURL(lastObjectUrl);
134
+ lastObjectUrl = URL.createObjectURL(blob);
135
+ els.audio.src = lastObjectUrl;
136
+ els.download.href = lastObjectUrl;
137
+ els.audioOutput.hidden = false;
138
+ els.genInfo.textContent =
139
+ `${dur.toFixed(2)} s synthesized in ${dt.toFixed(2)} s — RTF ${rtf.toFixed(2)}`;
140
+ setStatus('Done.', 'ready');
141
+ els.genBtn.disabled = false;
142
+ }
143
+
144
+ function clearOutput() {
145
+ els.text.value = '';
146
+ els.audioOutput.hidden = true;
147
+ if (lastObjectUrl) { URL.revokeObjectURL(lastObjectUrl); lastObjectUrl = null; }
148
+ }
149
+ }
150
+
151
+ window.RSPageTtsOffline = { init };
152
+ })();
public/rapidspeech-bridge.js ADDED
@@ -0,0 +1,406 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * RapidSpeech WASM JavaScript bridge.
3
+ *
4
+ * Wraps the Emscripten-compiled rapidspeech-wasm module with a high-level JS
5
+ * API. Supports both ASR (push PCM → text) and TTS (push text → PCM chunks).
6
+ *
7
+ * Usage (browser):
8
+ * const mod = await RapidSpeechModule();
9
+ * const asr = new RapidSpeechWASM(mod);
10
+ * await asr.initAsr(modelUrl);
11
+ * asr.pushAudio(float32Pcm);
12
+ * const { text } = asr.process();
13
+ *
14
+ * const tts = new RapidSpeechWASM(mod);
15
+ * await tts.initTts(modelUrl);
16
+ * tts.setTtsParams({ instruct: 'female', language: 'English', seed: 42 });
17
+ * const pcm = tts.synthesize('Hello world'); // Float32Array @ tts.sampleRate
18
+ */
19
+
20
+ // Mirror of rs_task_type_t in rapidspeech.h
21
+ const RS_TASK = Object.freeze({
22
+ ASR_OFFLINE: 0,
23
+ ASR_ONLINE: 1,
24
+ TTS_OFFLINE: 2,
25
+ TTS_ONLINE: 3,
26
+ E2E_SPEECH_LLM: 4,
27
+ });
28
+
29
+ class RapidSpeechWASM {
30
+ constructor(module) {
31
+ this._mod = module;
32
+ this._ready = false;
33
+
34
+ // ── Bind C functions (cwrap = lighter than ccall on hot paths) ──
35
+ // Functions that may suspend through WebGPU's async device/buffer ops are
36
+ // bound with {async: true} so cwrap returns a Promise instead of dropping
37
+ // the result when ASYNCIFY unwinds. Always await these.
38
+ this._initEx = module.cwrap('rs_wasm_init_ex', 'number', ['string', 'number', 'number'], { async: true });
39
+ this._init = module.cwrap('rs_wasm_init', 'number', ['string', 'number'], { async: true });
40
+ this._free = module.cwrap('rs_wasm_free', null, []);
41
+
42
+ this._pushAudio = module.cwrap('rs_wasm_push_audio', 'number', ['number', 'number']);
43
+ this._pushText = module.cwrap('rs_wasm_push_text', 'number', ['string']);
44
+ this._pushRefAudio = module.cwrap('rs_wasm_push_reference_audio','number', ['number', 'number', 'number']);
45
+ this._pushRefText = module.cwrap('rs_wasm_push_reference_text', 'number', ['string']);
46
+
47
+ this._process = module.cwrap('rs_wasm_process', 'number', [], { async: true });
48
+ this._redecode = module.cwrap('rs_wasm_redecode', 'number', [], { async: true });
49
+ this._getText = module.cwrap('rs_wasm_get_text', 'string', []);
50
+ this._getAudioPtr = module.cwrap('rs_wasm_get_audio_ptr','number', []);
51
+ this._getAudioLen = module.cwrap('rs_wasm_get_audio_len','number', []);
52
+ this._reset = module.cwrap('rs_wasm_reset', 'number', []);
53
+
54
+ this._setPrompt = module.cwrap('rs_wasm_set_user_input_prompt', 'number', ['string']);
55
+ this._setUseLlm = module.cwrap('rs_wasm_set_use_llm', 'number', ['number']);
56
+ this._setCtcPrecheck= module.cwrap('rs_wasm_set_ctc_precheck', 'number', ['number']);
57
+ this._setTtsParams = module.cwrap('rs_wasm_set_tts_params', 'number', ['string','string','number']);
58
+ this._setTtsSteps = module.cwrap('rs_wasm_set_tts_diffusion_steps','number', ['number']);
59
+
60
+ this._getSampleRate = module.cwrap('rs_wasm_get_sample_rate', 'number', []);
61
+ this._getArchName = module.cwrap('rs_wasm_get_arch_name', 'string', []);
62
+ this._isReady = module.cwrap('rs_wasm_is_ready', 'number', []);
63
+ this._getVersion = module.cwrap('rs_wasm_get_version', 'string', []);
64
+
65
+ this._sampleRate = 16000;
66
+ this._taskType = RS_TASK.ASR_OFFLINE;
67
+ }
68
+
69
+ // ── Model loading ──────────────────────────────────────────
70
+ /**
71
+ * Generic init: fetch model from URL, write to /model.gguf, init context.
72
+ * @param {string} modelUrl
73
+ * @param {number} taskType one of RS_TASK.*
74
+ * @param {number} nThreads
75
+ * @param {function} onProgress(0..1)
76
+ */
77
+ async init(modelUrl, taskType = RS_TASK.ASR_OFFLINE, nThreads = 2, onProgress = null) {
78
+ const fileName = '/model.gguf';
79
+
80
+ let buffer;
81
+ if (modelUrl instanceof Uint8Array || modelUrl instanceof ArrayBuffer) {
82
+ buffer = modelUrl instanceof ArrayBuffer ? new Uint8Array(modelUrl) : modelUrl;
83
+ } else {
84
+ const response = await fetch(modelUrl);
85
+ if (!response.ok) {
86
+ throw new Error(`Failed to fetch model: ${response.status} ${response.statusText}`);
87
+ }
88
+ const total = parseInt(response.headers.get('Content-Length') || '0', 10);
89
+ const reader = response.body.getReader();
90
+ const chunks = [];
91
+ let loaded = 0;
92
+ while (true) {
93
+ const { done, value } = await reader.read();
94
+ if (done) break;
95
+ chunks.push(value);
96
+ loaded += value.length;
97
+ if (onProgress && total > 0) onProgress(Math.min(loaded / total, 1.0));
98
+ }
99
+ buffer = new Uint8Array(loaded);
100
+ let off = 0;
101
+ for (const c of chunks) { buffer.set(c, off); off += c.length; }
102
+ }
103
+
104
+ this._mod.FS.writeFile(fileName, buffer);
105
+
106
+ const ret = await this._initEx(fileName, taskType, nThreads);
107
+ if (ret !== 0) throw new Error('rs_wasm_init_ex failed with code ' + ret);
108
+
109
+ this._sampleRate = this._getSampleRate();
110
+ this._taskType = taskType;
111
+ this._ready = true;
112
+ }
113
+
114
+ initAsr(modelUrl, nThreads = 2, onProgress = null) {
115
+ return this.init(modelUrl, RS_TASK.ASR_OFFLINE, nThreads, onProgress);
116
+ }
117
+
118
+ initTts(modelUrl, nThreads = 2, onProgress = null) {
119
+ return this.init(modelUrl, RS_TASK.TTS_ONLINE, nThreads, onProgress);
120
+ }
121
+
122
+ free() {
123
+ if (this._ready) {
124
+ this._free();
125
+ this._ready = false;
126
+ }
127
+ }
128
+
129
+ // ── ASR ───────────────────────────────────────────────────
130
+ /** Push float32 PCM samples to the audio buffer. */
131
+ pushAudio(pcm) {
132
+ if (!this._ready) return;
133
+ const n = pcm.length;
134
+ const ptr = this._mod._malloc(n * 4);
135
+ this._mod.HEAPF32.set(pcm, ptr / 4);
136
+ this._pushAudio(ptr, n);
137
+ this._mod._free(ptr);
138
+ }
139
+
140
+ /** Set the LLM decoder prompt (FunASRNano). */
141
+ setUserInputPrompt(prompt) { this._setPrompt(String(prompt)); }
142
+ setUseLlm(enable) { this._setUseLlm(enable ? 1 : 0); }
143
+ setCtcPrecheck(enable) { this._setCtcPrecheck(enable ? 1 : 0); }
144
+ async redecode() {
145
+ const status = await this._redecode();
146
+ return { status, text: status > 0 ? this._getText() : '' };
147
+ }
148
+
149
+ // ── TTS ───────────────────────────────────────────────────
150
+ /** Set TTS generation params (OmniVoice). */
151
+ setTtsParams({ instruct = 'male', language = 'English', seed = 42 } = {}) {
152
+ this._setTtsParams(instruct, language, seed | 0);
153
+ }
154
+
155
+ setDiffusionSteps(nSteps) { this._setTtsSteps(nSteps | 0); }
156
+
157
+ /**
158
+ * Push a reference audio buffer for voice cloning.
159
+ * @param {Float32Array} pcm
160
+ * @param {number} sampleRate
161
+ */
162
+ pushReferenceAudio(pcm, sampleRate = 16000) {
163
+ if (!this._ready) return;
164
+ const n = pcm.length;
165
+ const ptr = this._mod._malloc(n * 4);
166
+ this._mod.HEAPF32.set(pcm, ptr / 4);
167
+ this._pushRefAudio(ptr, n, sampleRate | 0);
168
+ this._mod._free(ptr);
169
+ }
170
+
171
+ pushReferenceText(text) { this._pushRefText(String(text)); }
172
+
173
+ /** Push text for TTS to consume on the next process() call. */
174
+ pushText(text) {
175
+ if (!this._ready) return -1;
176
+ return this._pushText(String(text));
177
+ }
178
+
179
+ /**
180
+ * Synthesize text and return the assembled Float32Array (all chunks
181
+ * concatenated). Use processStreaming() if you need chunked playback.
182
+ */
183
+ async synthesize(text) {
184
+ if (!this._ready) throw new Error('Not initialized');
185
+ this.reset();
186
+ if (this._pushText(String(text)) !== 0) {
187
+ throw new Error('rs_wasm_push_text failed');
188
+ }
189
+ const chunks = [];
190
+ let total = 0;
191
+ let ret;
192
+ do {
193
+ ret = await this._process();
194
+ if (ret < 0) throw new Error('TTS inference error');
195
+ const n = this._getAudioLen();
196
+ if (n > 0) {
197
+ const ptr = this._getAudioPtr();
198
+ const view = this._mod.HEAPF32.subarray(ptr / 4, ptr / 4 + n);
199
+ const chunk = new Float32Array(n);
200
+ chunk.set(view); // copy out of WASM heap before the next process()
201
+ chunks.push(chunk);
202
+ total += n;
203
+ }
204
+ } while (ret > 0);
205
+
206
+ const out = new Float32Array(total);
207
+ let off = 0;
208
+ for (const c of chunks) { out.set(c, off); off += c.length; }
209
+ return out;
210
+ }
211
+
212
+ /**
213
+ * Async generator yielding TTS chunks as they arrive.
214
+ * for await (const chunk of tts.streamSynthesize('hi')) { play(chunk); }
215
+ */
216
+ async *streamSynthesize(text) {
217
+ if (!this._ready) throw new Error('Not initialized');
218
+ this.reset();
219
+ if (this._pushText(String(text)) !== 0) {
220
+ throw new Error('rs_wasm_push_text failed');
221
+ }
222
+ let ret;
223
+ do {
224
+ ret = await this._process();
225
+ if (ret < 0) throw new Error('TTS inference error');
226
+ const n = this._getAudioLen();
227
+ if (n > 0) {
228
+ const ptr = this._getAudioPtr();
229
+ const view = this._mod.HEAPF32.subarray(ptr / 4, ptr / 4 + n);
230
+ const chunk = new Float32Array(n);
231
+ chunk.set(view);
232
+ yield chunk;
233
+ }
234
+ // Yield to the event loop so playback / UI can keep up.
235
+ await new Promise((r) => setTimeout(r, 0));
236
+ } while (ret > 0);
237
+ }
238
+
239
+ // ── Generic ───────────────────────────────────────────────
240
+ /**
241
+ * Run one inference step. Returns { status, text } for ASR; for TTS use
242
+ * synthesize() / streamSynthesize() which handle the loop.
243
+ */
244
+ async process() {
245
+ if (!this._ready) return { status: -1, text: '' };
246
+ const status = await this._process();
247
+ const text = status > 0 ? this._getText() : '';
248
+ return { status, text };
249
+ }
250
+
251
+ reset() {
252
+ if (this._ready) this._reset();
253
+ }
254
+
255
+ get sampleRate() { return this._sampleRate; }
256
+ get isReady() { return this._ready; }
257
+ get archName() { return this._ready ? this._getArchName() : 'unknown'; }
258
+ get version() { return this._getVersion(); }
259
+ get taskType() { return this._taskType; }
260
+ }
261
+
262
+ // ─────────────────────────────────────────────────────────────
263
+ // RapidSpeechVAD — unified wrapper around silero-vad / firered-vad
264
+ // ─────────────────────────────────────────────────────────────
265
+ //
266
+ // Same WASM module the speech context uses. Auto-detects the model
267
+ // architecture from GGUF. Push 16 kHz mono Float32 PCM, drain segments
268
+ // or per-frame events on demand.
269
+
270
+ const VAD_SEGMENT_BYTES = 8; // {float start_s; float end_s}
271
+ const VAD_FRAME_BYTES = 24; // {i32 idx; f32 raw; f32 smooth; i32 is_sp; i32 is_st; i32 is_ed}
272
+
273
+ class RapidSpeechVAD {
274
+ constructor(module) {
275
+ this._mod = module;
276
+ this._ready = false;
277
+
278
+ this._init = module.cwrap('rs_wasm_vad_init', 'number', ['string', 'number'], { async: true });
279
+ this._free = module.cwrap('rs_wasm_vad_free', null, []);
280
+ this._reset = module.cwrap('rs_wasm_vad_reset', 'number', []);
281
+ this._setThresh = module.cwrap('rs_wasm_vad_set_threshold','number', ['number']);
282
+ this._push = module.cwrap('rs_wasm_vad_push_audio', 'number', ['number', 'number']);
283
+ this._isSpeech = module.cwrap('rs_wasm_vad_is_speech', 'number', []);
284
+ this._getProb = module.cwrap('rs_wasm_vad_get_probability','number', []);
285
+ this._getArch = module.cwrap('rs_wasm_vad_get_arch', 'string', []);
286
+ this._drainSegs = module.cwrap('rs_wasm_vad_drain_segments','number', ['number', 'number']);
287
+ this._drainFrames = module.cwrap('rs_wasm_vad_drain_frames', 'number', ['number', 'number']);
288
+
289
+ // Reusable IO buffers in the WASM heap. Grown on demand.
290
+ this._segBuf = { ptr: 0, capacity: 0 };
291
+ this._frmBuf = { ptr: 0, capacity: 0 };
292
+ }
293
+
294
+ /**
295
+ * Load a VAD model. URL points to a GGUF file whose general.architecture
296
+ * is "silero-vad" or "firered-vad".
297
+ */
298
+ async load(modelUrl, nThreads = 2, onProgress = null) {
299
+ const fileName = '/vad.gguf';
300
+
301
+ let buffer;
302
+ if (modelUrl instanceof Uint8Array || modelUrl instanceof ArrayBuffer) {
303
+ buffer = modelUrl instanceof ArrayBuffer ? new Uint8Array(modelUrl) : modelUrl;
304
+ } else {
305
+ const response = await fetch(modelUrl);
306
+ if (!response.ok) {
307
+ throw new Error(`Failed to fetch VAD model: ${response.status} ${response.statusText}`);
308
+ }
309
+ const total = parseInt(response.headers.get('Content-Length') || '0', 10);
310
+ const reader = response.body.getReader();
311
+ const chunks = [];
312
+ let loaded = 0;
313
+ while (true) {
314
+ const { done, value } = await reader.read();
315
+ if (done) break;
316
+ chunks.push(value);
317
+ loaded += value.length;
318
+ if (onProgress && total > 0) onProgress(Math.min(loaded / total, 1.0));
319
+ }
320
+ buffer = new Uint8Array(loaded);
321
+ let off = 0;
322
+ for (const c of chunks) { buffer.set(c, off); off += c.length; }
323
+ }
324
+
325
+ this._mod.FS.writeFile(fileName, buffer);
326
+ const ret = await this._init(fileName, nThreads);
327
+ if (ret !== 0) throw new Error('rs_wasm_vad_init failed with code ' + ret);
328
+ this._ready = true;
329
+ }
330
+
331
+ free() {
332
+ if (this._segBuf.ptr) { this._mod._free(this._segBuf.ptr); this._segBuf = { ptr: 0, capacity: 0 }; }
333
+ if (this._frmBuf.ptr) { this._mod._free(this._frmBuf.ptr); this._frmBuf = { ptr: 0, capacity: 0 }; }
334
+ if (this._ready) { this._free(); this._ready = false; }
335
+ }
336
+
337
+ reset() { if (this._ready) this._reset(); }
338
+ setThreshold(threshold) { if (this._ready) this._setThresh(threshold); }
339
+ get isSpeech() { return this._ready ? !!this._isSpeech() : false; }
340
+ get probability() { return this._ready ? this._getProb() : 0; }
341
+ get arch() { return this._ready ? this._getArch() : ''; }
342
+ get isReady() { return this._ready; }
343
+
344
+ /** Push 16 kHz mono Float32Array PCM. Updates internal queues. */
345
+ pushAudio(pcm) {
346
+ if (!this._ready) return;
347
+ const n = pcm.length;
348
+ const ptr = this._mod._malloc(n * 4);
349
+ this._mod.HEAPF32.set(pcm, ptr / 4);
350
+ this._push(ptr, n);
351
+ this._mod._free(ptr);
352
+ }
353
+
354
+ _ensureBuf(slot, capacity, elemBytes) {
355
+ if (slot.capacity >= capacity) return;
356
+ if (slot.ptr) this._mod._free(slot.ptr);
357
+ slot.ptr = this._mod._malloc(capacity * elemBytes);
358
+ slot.capacity = capacity;
359
+ }
360
+
361
+ /** Drain queued segments as [{start_s, end_s}, ...]. */
362
+ drainSegments(maxCount = 64) {
363
+ if (!this._ready) return [];
364
+ this._ensureBuf(this._segBuf, maxCount, VAD_SEGMENT_BYTES);
365
+ const n = this._drainSegs(this._segBuf.ptr, maxCount);
366
+ const out = new Array(n);
367
+ const base = this._segBuf.ptr / 4;
368
+ for (let i = 0; i < n; ++i) {
369
+ const off = base + i * 2;
370
+ out[i] = { start_s: this._mod.HEAPF32[off], end_s: this._mod.HEAPF32[off + 1] };
371
+ }
372
+ return out;
373
+ }
374
+
375
+ /** Drain queued per-frame events. */
376
+ drainFrames(maxCount = 256) {
377
+ if (!this._ready) return [];
378
+ this._ensureBuf(this._frmBuf, maxCount, VAD_FRAME_BYTES);
379
+ const n = this._drainFrames(this._frmBuf.ptr, maxCount);
380
+ const out = new Array(n);
381
+ const i32 = this._mod.HEAP32;
382
+ const f32 = this._mod.HEAPF32;
383
+ const base32 = this._frmBuf.ptr / 4; // 6 i32-slots per frame
384
+ for (let i = 0; i < n; ++i) {
385
+ const off = base32 + i * 6;
386
+ out[i] = {
387
+ frame_idx: i32[off],
388
+ raw_prob: f32[off + 1],
389
+ smoothed_prob: f32[off + 2],
390
+ is_speech: !!i32[off + 3],
391
+ is_speech_start: !!i32[off + 4],
392
+ is_speech_end: !!i32[off + 5],
393
+ };
394
+ }
395
+ return out;
396
+ }
397
+ }
398
+
399
+ // Export for both browser and Node.js
400
+ if (typeof module !== 'undefined' && module.exports) {
401
+ module.exports = { RapidSpeechWASM, RapidSpeechVAD, RS_TASK };
402
+ } else if (typeof globalThis !== 'undefined') {
403
+ globalThis.RapidSpeechWASM = RapidSpeechWASM;
404
+ globalThis.RapidSpeechVAD = RapidSpeechVAD;
405
+ globalThis.RS_TASK = RS_TASK;
406
+ }
public/rapidspeech-wasm.js ADDED
The diff for this file is too large to render. See raw diff
 
public/rapidspeech-wasm.wasm ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:f6032e567114c89bbc95b2020b697c3f0aa07ecb1b1aad4446ffa89be41f334b
3
+ size 3983821
public/styles.css ADDED
@@ -0,0 +1,217 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ :root {
2
+ --bg: #1a1a2e;
3
+ --card: #16213e;
4
+ --accent: #0f3460;
5
+ --highlight: #e94560;
6
+ --text: #eee;
7
+ --dim: #8892b0;
8
+ --green: #4ecca3;
9
+ --yellow: #f0a500;
10
+ --radius: 8px;
11
+ }
12
+ * { margin: 0; padding: 0; box-sizing: border-box; }
13
+ body {
14
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
15
+ background: var(--bg);
16
+ color: var(--text);
17
+ min-height: 100vh;
18
+ display: flex;
19
+ justify-content: center;
20
+ padding: 20px;
21
+ }
22
+ .container { max-width: 760px; width: 100%; }
23
+
24
+ /* Header */
25
+ .header { text-align: center; margin-bottom: 16px; }
26
+ .header h1 { font-size: 28px; font-weight: 700; letter-spacing: -0.5px; }
27
+ .header h1 span { color: var(--highlight); }
28
+ .header p { color: var(--dim); font-size: 14px; margin-top: 4px; }
29
+
30
+ /* Tabs */
31
+ .tabs {
32
+ display: flex;
33
+ gap: 4px;
34
+ margin-bottom: 16px;
35
+ border-bottom: 1px solid var(--accent);
36
+ }
37
+ .tab-btn {
38
+ flex: 1;
39
+ padding: 12px 16px;
40
+ background: transparent;
41
+ border: none;
42
+ border-bottom: 2px solid transparent;
43
+ color: var(--dim);
44
+ font-size: 14px;
45
+ font-weight: 600;
46
+ cursor: pointer;
47
+ transition: color 0.2s, border-color 0.2s;
48
+ }
49
+ .tab-btn:hover { color: var(--text); }
50
+ .tab-btn.active { color: var(--highlight); border-bottom-color: var(--highlight); }
51
+
52
+ /* Card */
53
+ .card {
54
+ background: var(--card);
55
+ border-radius: var(--radius);
56
+ padding: 20px;
57
+ margin-bottom: 16px;
58
+ }
59
+ .card-title {
60
+ font-size: 14px;
61
+ font-weight: 600;
62
+ text-transform: uppercase;
63
+ letter-spacing: 1px;
64
+ color: var(--dim);
65
+ margin-bottom: 12px;
66
+ }
67
+
68
+ /* Form */
69
+ .form-row { display: flex; gap: 8px; margin-bottom: 12px; align-items: center; flex-wrap: wrap; }
70
+ .form-row label { font-size: 13px; color: var(--dim); min-width: 110px; }
71
+ .form-row input[type="text"],
72
+ .form-row input[type="number"],
73
+ .form-row textarea,
74
+ .form-row select {
75
+ flex: 1;
76
+ min-width: 200px;
77
+ padding: 8px 12px;
78
+ border: 1px solid var(--accent);
79
+ border-radius: var(--radius);
80
+ background: var(--bg);
81
+ color: var(--text);
82
+ font-size: 14px;
83
+ outline: none;
84
+ font-family: inherit;
85
+ }
86
+ .form-row input[type="number"] { min-width: 80px; max-width: 120px; flex: 0; }
87
+ .form-row textarea { resize: vertical; min-height: 80px; }
88
+ .form-row input:focus, .form-row textarea:focus, .form-row select:focus { border-color: var(--highlight); }
89
+ .form-row input[type="range"] { flex: 1; }
90
+ .form-row input[type="checkbox"] { width: 16px; height: 16px; }
91
+ .range-value { color: var(--dim); font-size: 13px; min-width: 40px; text-align: right; }
92
+
93
+ /* Buttons */
94
+ .btn-row { display: flex; gap: 8px; margin-top: 12px; flex-wrap: wrap; }
95
+ .btn {
96
+ padding: 8px 18px;
97
+ border: none;
98
+ border-radius: var(--radius);
99
+ font-size: 14px;
100
+ font-weight: 600;
101
+ cursor: pointer;
102
+ transition: opacity 0.2s;
103
+ font-family: inherit;
104
+ }
105
+ .btn:disabled { opacity: 0.4; cursor: not-allowed; }
106
+ .btn-load { background: var(--accent); color: var(--text); }
107
+ .btn-primary { background: var(--green); color: var(--bg); }
108
+ .btn-secondary { background: var(--highlight); color: var(--text); }
109
+ .btn-ghost { background: transparent; border: 1px solid var(--dim); color: var(--dim); }
110
+
111
+ /* Progress bar */
112
+ .progress-container { display: none; margin-top: 10px; }
113
+ .progress-track {
114
+ width: 100%; height: 4px; background: var(--accent);
115
+ border-radius: 2px; overflow: hidden;
116
+ }
117
+ .progress-fill {
118
+ height: 100%; width: 0%; background: var(--green);
119
+ transition: width 0.2s;
120
+ }
121
+
122
+ /* Status */
123
+ .status {
124
+ margin-top: 10px;
125
+ font-size: 13px;
126
+ padding: 6px 12px;
127
+ border-radius: 4px;
128
+ display: inline-block;
129
+ }
130
+ .status.idle { color: var(--dim); background: transparent; }
131
+ .status.loading { color: var(--yellow); background: rgba(240,165,0,0.1); }
132
+ .status.ready { color: var(--green); background: rgba(78,204,163,0.1); }
133
+ .status.listening { color: var(--green); background: rgba(78,204,163,0.1); }
134
+ .status.error { color: var(--highlight); background: rgba(233,69,96,0.1); }
135
+
136
+ /* VAD meter */
137
+ .vad-container { margin-top: 12px; }
138
+ .vad-track {
139
+ width: 100%; height: 6px; background: var(--accent);
140
+ border-radius: 3px; overflow: hidden;
141
+ }
142
+ .vad-fill {
143
+ height: 100%; width: 0%; background: var(--dim);
144
+ transition: width 0.1s, background 0.2s;
145
+ border-radius: 3px;
146
+ }
147
+ .vad-fill.speech { background: var(--green); }
148
+ .vad-meta { display: flex; justify-content: space-between; margin-top: 4px; }
149
+ .vad-label { font-size: 12px; color: var(--dim); text-transform: uppercase; }
150
+ .vad-label.speech { color: var(--green); font-weight: 600; }
151
+ .vad-info { font-size: 12px; color: var(--dim); }
152
+
153
+ /* Transcript */
154
+ .transcript {
155
+ background: var(--bg);
156
+ border-radius: var(--radius);
157
+ padding: 16px;
158
+ min-height: 160px;
159
+ max-height: 360px;
160
+ overflow-y: auto;
161
+ font-size: 15px;
162
+ line-height: 1.7;
163
+ word-break: break-all;
164
+ }
165
+ .transcript-line {
166
+ margin-bottom: 6px;
167
+ padding-bottom: 6px;
168
+ border-bottom: 1px solid rgba(255,255,255,0.05);
169
+ }
170
+ .transcript-line .timestamp {
171
+ color: var(--dim);
172
+ font-size: 12px;
173
+ font-family: monospace;
174
+ margin-right: 8px;
175
+ }
176
+ .transcript-line .tag {
177
+ display: inline-block;
178
+ font-size: 10px;
179
+ font-family: monospace;
180
+ padding: 1px 6px;
181
+ border-radius: 3px;
182
+ margin-right: 6px;
183
+ vertical-align: middle;
184
+ }
185
+ .transcript-line .tag-ctc { background: rgba(160,160,160,0.25); color: #bbb; }
186
+ .transcript-line .tag-llm { background: rgba(80,180,120,0.25); color: #6ec491; }
187
+ .partial {
188
+ color: var(--dim);
189
+ font-style: italic;
190
+ padding: 8px 16px;
191
+ min-height: 24px;
192
+ font-size: 14px;
193
+ }
194
+
195
+ /* TTS output */
196
+ .audio-output {
197
+ margin-top: 12px;
198
+ display: flex;
199
+ flex-direction: column;
200
+ gap: 8px;
201
+ }
202
+ .audio-output audio { width: 100%; }
203
+ .download-link {
204
+ color: var(--green);
205
+ text-decoration: none;
206
+ font-size: 13px;
207
+ }
208
+ .download-link:hover { text-decoration: underline; }
209
+
210
+ /* Footer */
211
+ .footer {
212
+ text-align: center;
213
+ margin-top: 20px;
214
+ font-size: 12px;
215
+ color: var(--dim);
216
+ }
217
+ .footer a { color: var(--highlight); text-decoration: none; }
public/utils.js ADDED
@@ -0,0 +1,111 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * Shared helpers used by the per-tab demo pages.
3
+ * Exposed under window.RSUtils.
4
+ */
5
+ (function () {
6
+ const TARGET_SR = 16000;
7
+
8
+ // Read a File, decode any browser-supported audio container, mix to mono,
9
+ // resample to 16 kHz via OfflineAudioContext. Returns Float32Array.
10
+ async function decodeFileToMono16k(file) {
11
+ return decodeFileToMonoAt(file, TARGET_SR);
12
+ }
13
+
14
+ // Same as above but with caller-chosen target sample rate (used by TTS
15
+ // voice-cloning, which wants the model's native rate).
16
+ async function decodeFileToMonoAt(file, targetSr) {
17
+ const buf = await file.arrayBuffer();
18
+ const ctx = new (window.AudioContext || window.webkitAudioContext)();
19
+ let decoded;
20
+ try {
21
+ decoded = await ctx.decodeAudioData(buf);
22
+ } finally {
23
+ ctx.close().catch(() => {});
24
+ }
25
+ // Downmix to mono by averaging channels.
26
+ const n = decoded.length;
27
+ const mono = new Float32Array(n);
28
+ for (let ch = 0; ch < decoded.numberOfChannels; ch++) {
29
+ const src = decoded.getChannelData(ch);
30
+ for (let i = 0; i < n; i++) mono[i] += src[i];
31
+ }
32
+ if (decoded.numberOfChannels > 1) {
33
+ const inv = 1 / decoded.numberOfChannels;
34
+ for (let i = 0; i < n; i++) mono[i] *= inv;
35
+ }
36
+ if (decoded.sampleRate === targetSr) return mono;
37
+ return resample(mono, decoded.sampleRate, targetSr);
38
+ }
39
+
40
+ // Resample float32 PCM from `fromRate` to `toRate` via OfflineAudioContext.
41
+ async function resample(pcm, fromRate, toRate) {
42
+ if (fromRate === toRate) return pcm;
43
+ const targetLen = Math.ceil(pcm.length * toRate / fromRate);
44
+ const off = new OfflineAudioContext(1, targetLen, toRate);
45
+ const buf = off.createBuffer(1, pcm.length, fromRate);
46
+ buf.copyToChannel(pcm, 0);
47
+ const src = off.createBufferSource();
48
+ src.buffer = buf;
49
+ src.connect(off.destination);
50
+ src.start();
51
+ const rendered = await off.startRendering();
52
+ return rendered.getChannelData(0);
53
+ }
54
+
55
+ // Encode Float32 mono PCM into a 16-bit PCM WAV Blob.
56
+ function encodeWav(samples, sampleRate) {
57
+ const buf = new ArrayBuffer(44 + samples.length * 2);
58
+ const view = new DataView(buf);
59
+ const writeStr = (off, s) => { for (let i = 0; i < s.length; i++) view.setUint8(off + i, s.charCodeAt(i)); };
60
+ writeStr(0, 'RIFF');
61
+ view.setUint32(4, 36 + samples.length * 2, true);
62
+ writeStr(8, 'WAVE');
63
+ writeStr(12, 'fmt ');
64
+ view.setUint32(16, 16, true);
65
+ view.setUint16(20, 1, true); // PCM
66
+ view.setUint16(22, 1, true); // mono
67
+ view.setUint32(24, sampleRate, true);
68
+ view.setUint32(28, sampleRate * 2, true); // byte rate
69
+ view.setUint16(32, 2, true); // block align
70
+ view.setUint16(34, 16, true); // bits per sample
71
+ writeStr(36, 'data');
72
+ view.setUint32(40, samples.length * 2, true);
73
+ let off = 44;
74
+ for (let i = 0; i < samples.length; i++, off += 2) {
75
+ const s = Math.max(-1, Math.min(1, samples[i]));
76
+ view.setInt16(off, s < 0 ? s * 0x8000 : s * 0x7fff, true);
77
+ }
78
+ return new Blob([buf], { type: 'audio/wav' });
79
+ }
80
+
81
+ function formatTime(seconds) {
82
+ const m = Math.floor(seconds / 60);
83
+ const s = (seconds % 60).toFixed(1);
84
+ return String(m).padStart(2, '0') + ':' + String(s).padStart(4, '0');
85
+ }
86
+
87
+ function escapeHtml(str) {
88
+ const div = document.createElement('div');
89
+ div.textContent = String(str);
90
+ return div.innerHTML;
91
+ }
92
+
93
+ // setStatus helper bound to a given <span> element.
94
+ function statusSetter(el) {
95
+ return (msg, cls = 'idle') => {
96
+ el.textContent = msg;
97
+ el.className = 'status ' + cls;
98
+ };
99
+ }
100
+
101
+ window.RSUtils = {
102
+ TARGET_SR,
103
+ decodeFileToMono16k,
104
+ decodeFileToMonoAt,
105
+ resample,
106
+ encodeWav,
107
+ formatTime,
108
+ escapeHtml,
109
+ statusSetter,
110
+ };
111
+ })();
serve.py ADDED
@@ -0,0 +1,33 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """HuggingFace-Space-friendly static server with COOP+COEP headers.
3
+
4
+ WASM pthreads + SharedArrayBuffer require cross-origin isolation; the default
5
+ HF static SDK does not reliably set these headers, so this Space uses the
6
+ Docker SDK and serves the demo through this tiny shim.
7
+
8
+ Usage: python3 serve.py [port] [root]
9
+ (defaults: port 7860, root ./public)
10
+ """
11
+ import os
12
+ import sys
13
+ from functools import partial
14
+ from http.server import HTTPServer, SimpleHTTPRequestHandler
15
+
16
+
17
+ class COOPHandler(SimpleHTTPRequestHandler):
18
+ def end_headers(self):
19
+ self.send_header("Cross-Origin-Opener-Policy", "same-origin")
20
+ self.send_header("Cross-Origin-Embedder-Policy", "require-corp")
21
+ self.send_header("Cross-Origin-Resource-Policy", "cross-origin")
22
+ # Allow large model downloads from anywhere
23
+ self.send_header("Access-Control-Allow-Origin", "*")
24
+ super().end_headers()
25
+
26
+
27
+ if __name__ == "__main__":
28
+ port = int(sys.argv[1]) if len(sys.argv) > 1 else 7860
29
+ root = sys.argv[2] if len(sys.argv) > 2 else "public"
30
+ os.chdir(root)
31
+ handler = partial(COOPHandler)
32
+ print(f"Serving {root} on http://0.0.0.0:{port} (crossOriginIsolated)")
33
+ HTTPServer(("0.0.0.0", port), handler).serve_forever()