Spaces:
Runtime error
Runtime error
Initial RapidSpeech WASM demo
Browse files- Dockerfile +11 -0
- README.md +38 -7
- public/app.js +51 -0
- public/index.html +271 -0
- public/pages/asr-offline.js +313 -0
- public/pages/asr-online.js +521 -0
- public/pages/tts-offline.js +152 -0
- public/rapidspeech-bridge.js +406 -0
- public/rapidspeech-wasm.js +0 -0
- public/rapidspeech-wasm.wasm +3 -0
- public/styles.css +217 -0
- public/utils.js +111 -0
- serve.py +33 -0
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
|
| 3 |
-
emoji:
|
| 4 |
-
colorFrom:
|
| 5 |
-
colorTo:
|
| 6 |
sdk: docker
|
|
|
|
| 7 |
pinned: false
|
| 8 |
-
license:
|
| 9 |
-
short_description:
|
| 10 |
---
|
| 11 |
|
| 12 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 & 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 |
+
— 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()
|