chore: deploy private lightloom build
Browse files- app.py +1 -1
- frontend/js/asr-worker.js +47 -0
- frontend/js/asr.js +61 -0
- frontend/js/controller.js +19 -0
- frontend/js/recorder.js +24 -1
- scripts/asr-feasibility.cjs +12 -0
- scripts/asr-feasibility.html +41 -0
app.py
CHANGED
|
@@ -145,7 +145,7 @@ def health() -> dict[str, Any]:
|
|
| 145 |
"privacy_mode": os.getenv("LIGHTLOOM_PRIVACY_MODE", "1") == "1",
|
| 146 |
"cpu_count": os.cpu_count(),
|
| 147 |
"director_backend": CONFIG.director_backend,
|
| 148 |
-
"build": "clean-
|
| 149 |
}
|
| 150 |
|
| 151 |
|
|
|
|
| 145 |
"privacy_mode": os.getenv("LIGHTLOOM_PRIVACY_MODE", "1") == "1",
|
| 146 |
"cpu_count": os.cpu_count(),
|
| 147 |
"director_backend": CONFIG.director_backend,
|
| 148 |
+
"build": "clean-asr-1",
|
| 149 |
}
|
| 150 |
|
| 151 |
|
frontend/js/asr-worker.js
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/* frontend/js/asr-worker.js — in-browser streaming ASR (Whisper) via transformers.js.
|
| 2 |
+
* Runs Whisper-base.en ENTIRELY in the user's browser (WebGPU, WASM fallback) so the
|
| 3 |
+
* live transcript appears AS you speak — instant, private, OFF THE GRID (no cloud ASR),
|
| 4 |
+
* and it frees the Space GPU. Loaded as a module Worker so transcription never blocks
|
| 5 |
+
* the scroll animation. Purely additive: the server still transcribes for painting.
|
| 6 |
+
*/
|
| 7 |
+
import { pipeline, env } from "https://cdn.jsdelivr.net/npm/@huggingface/transformers@3.7.5";
|
| 8 |
+
|
| 9 |
+
env.allowLocalModels = false;
|
| 10 |
+
|
| 11 |
+
const MODEL = "onnx-community/whisper-base.en";
|
| 12 |
+
let transcriber = null;
|
| 13 |
+
let loadPromise = null;
|
| 14 |
+
let backend = null;
|
| 15 |
+
|
| 16 |
+
async function load() {
|
| 17 |
+
if (transcriber) return transcriber;
|
| 18 |
+
if (!loadPromise) {
|
| 19 |
+
loadPromise = (async () => {
|
| 20 |
+
try {
|
| 21 |
+
transcriber = await pipeline("automatic-speech-recognition", MODEL, { device: "webgpu", dtype: "fp32" });
|
| 22 |
+
backend = "webgpu";
|
| 23 |
+
} catch (e) {
|
| 24 |
+
transcriber = await pipeline("automatic-speech-recognition", MODEL, { device: "wasm" });
|
| 25 |
+
backend = "wasm";
|
| 26 |
+
}
|
| 27 |
+
return transcriber;
|
| 28 |
+
})();
|
| 29 |
+
}
|
| 30 |
+
return loadPromise;
|
| 31 |
+
}
|
| 32 |
+
|
| 33 |
+
self.onmessage = async (e) => {
|
| 34 |
+
const { id, type, audio } = e.data || {};
|
| 35 |
+
try {
|
| 36 |
+
if (type === "load") {
|
| 37 |
+
await load();
|
| 38 |
+
self.postMessage({ type: "ready", backend });
|
| 39 |
+
} else if (type === "transcribe") {
|
| 40 |
+
const t = await load();
|
| 41 |
+
const out = await t(audio, { language: "en", task: "transcribe", return_timestamps: false });
|
| 42 |
+
self.postMessage({ id, type: "result", text: (out && out.text ? out.text : "").trim() });
|
| 43 |
+
}
|
| 44 |
+
} catch (err) {
|
| 45 |
+
self.postMessage({ id, type: "error", error: String((err && err.message) || err).slice(0, 200) });
|
| 46 |
+
}
|
| 47 |
+
};
|
frontend/js/asr.js
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/* frontend/js/asr.js — LL.asr: manager for the in-browser streaming Whisper worker.
|
| 2 |
+
*
|
| 3 |
+
* LL.asr.init() -> Promise<bool> (true if the worker + model loaded)
|
| 4 |
+
* LL.asr.ready -> bool
|
| 5 |
+
* LL.asr.transcribe(float32) -> Promise<string> (16 kHz mono audio -> text)
|
| 6 |
+
*
|
| 7 |
+
* Purely additive + best-effort: init() resolves false (never throws) if WebGPU/WASM
|
| 8 |
+
* is unavailable, so the caller silently falls back to the existing server-side ASR.
|
| 9 |
+
*/
|
| 10 |
+
"use strict";
|
| 11 |
+
|
| 12 |
+
const LL = (window.LL = window.LL || {});
|
| 13 |
+
|
| 14 |
+
const asr = {
|
| 15 |
+
_worker: null,
|
| 16 |
+
_ready: false,
|
| 17 |
+
_readyPromise: null,
|
| 18 |
+
_seq: 0,
|
| 19 |
+
_pending: new Map(),
|
| 20 |
+
|
| 21 |
+
/** Spawn the worker and load the model. Resolves true on success, false on any failure
|
| 22 |
+
* (no WebGPU, no module-worker, model download blocked, 60 s timeout). Never throws. */
|
| 23 |
+
init() {
|
| 24 |
+
if (this._readyPromise) return this._readyPromise;
|
| 25 |
+
this._readyPromise = new Promise((resolve) => {
|
| 26 |
+
let settled = false;
|
| 27 |
+
const done = (v) => { if (!settled) { settled = true; resolve(v); } };
|
| 28 |
+
try {
|
| 29 |
+
this._worker = new Worker(new URL("./asr-worker.js", import.meta.url), { type: "module" });
|
| 30 |
+
this._worker.onmessage = (e) => {
|
| 31 |
+
const d = e.data || {};
|
| 32 |
+
if (d.type === "ready") { this._ready = true; this.backend = d.backend; done(true); }
|
| 33 |
+
else if (d.type === "result" || d.type === "error") {
|
| 34 |
+
const p = this._pending.get(d.id);
|
| 35 |
+
if (p) { this._pending.delete(d.id); d.type === "result" ? p.resolve(d.text) : p.reject(d.error); }
|
| 36 |
+
}
|
| 37 |
+
};
|
| 38 |
+
this._worker.onerror = () => done(false);
|
| 39 |
+
this._worker.postMessage({ type: "load" });
|
| 40 |
+
setTimeout(() => done(false), 60000); // model didn't load in time -> fall back
|
| 41 |
+
} catch (_) { done(false); }
|
| 42 |
+
});
|
| 43 |
+
return this._readyPromise;
|
| 44 |
+
},
|
| 45 |
+
|
| 46 |
+
get ready() { return this._ready; },
|
| 47 |
+
|
| 48 |
+
/** Transcribe a 16 kHz mono Float32Array. The buffer is COPIED (caller keeps theirs). */
|
| 49 |
+
transcribe(audioFloat32) {
|
| 50 |
+
if (!this._ready || !this._worker) return Promise.reject("asr_not_ready");
|
| 51 |
+
const copy = audioFloat32.slice();
|
| 52 |
+
const id = ++this._seq;
|
| 53 |
+
return new Promise((resolve, reject) => {
|
| 54 |
+
this._pending.set(id, { resolve, reject });
|
| 55 |
+
this._worker.postMessage({ id, type: "transcribe", audio: copy }, [copy.buffer]);
|
| 56 |
+
});
|
| 57 |
+
},
|
| 58 |
+
};
|
| 59 |
+
|
| 60 |
+
LL.asr = asr;
|
| 61 |
+
export default asr;
|
frontend/js/controller.js
CHANGED
|
@@ -10,6 +10,7 @@
|
|
| 10 |
import "./api.js";
|
| 11 |
import "./recorder.js";
|
| 12 |
import "./stage-scroll.js";
|
|
|
|
| 13 |
// (subtitles.js is intentionally not used: captions render in #transcript, which
|
| 14 |
// carries aria-live="polite" for screen readers.)
|
| 15 |
|
|
@@ -50,6 +51,9 @@ const controller = {
|
|
| 50 |
this._startAmbient(); // a pre-rendered scroll flows behind the intro + during warm-up
|
| 51 |
this._loadLedger(); // keep the About param count honest + current from /health
|
| 52 |
this._prewarm(); // load the GPU models DURING the intro so the first phrase is ~instant
|
|
|
|
|
|
|
|
|
|
| 53 |
return this;
|
| 54 |
},
|
| 55 |
|
|
@@ -150,6 +154,8 @@ const controller = {
|
|
| 150 |
},
|
| 151 |
// LIVE narration: each VAD-cut phrase is painted into the SAME scroll as it's spoken.
|
| 152 |
onSegment: (wavB64) => this._onLiveSegment(wavB64),
|
|
|
|
|
|
|
| 153 |
onTranscript: (text, error) => {
|
| 154 |
const clean = (text || "").trim();
|
| 155 |
if (clean) this.startWorld(clean);
|
|
@@ -249,6 +255,19 @@ const controller = {
|
|
| 249 |
}
|
| 250 |
},
|
| 251 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 252 |
_onEvent(ev) {
|
| 253 |
if (!ev || typeof ev !== "object") return;
|
| 254 |
switch (ev.stage) {
|
|
|
|
| 10 |
import "./api.js";
|
| 11 |
import "./recorder.js";
|
| 12 |
import "./stage-scroll.js";
|
| 13 |
+
import "./asr.js";
|
| 14 |
// (subtitles.js is intentionally not used: captions render in #transcript, which
|
| 15 |
// carries aria-live="polite" for screen readers.)
|
| 16 |
|
|
|
|
| 51 |
this._startAmbient(); // a pre-rendered scroll flows behind the intro + during warm-up
|
| 52 |
this._loadLedger(); // keep the About param count honest + current from /health
|
| 53 |
this._prewarm(); // load the GPU models DURING the intro so the first phrase is ~instant
|
| 54 |
+
// best-effort in-browser Whisper for an INSTANT live transcript (off-grid, frees the
|
| 55 |
+
// GPU). If WebGPU/WASM is unavailable it silently stays off and the server ASR is used.
|
| 56 |
+
if (LL.asr && LL.asr.init) LL.asr.init().then((ok) => { this._asrReady = ok; }).catch(() => {});
|
| 57 |
return this;
|
| 58 |
},
|
| 59 |
|
|
|
|
| 154 |
},
|
| 155 |
// LIVE narration: each VAD-cut phrase is painted into the SAME scroll as it's spoken.
|
| 156 |
onSegment: (wavB64) => this._onLiveSegment(wavB64),
|
| 157 |
+
// INSTANT live transcript via in-browser Whisper (additive; the server still paints).
|
| 158 |
+
onInterim: (float32) => this._onInterim(float32),
|
| 159 |
onTranscript: (text, error) => {
|
| 160 |
const clean = (text || "").trim();
|
| 161 |
if (clean) this.startWorld(clean);
|
|
|
|
| 255 |
}
|
| 256 |
},
|
| 257 |
|
| 258 |
+
/** In-browser ASR interim -> INSTANT live transcript ("words as you speak"). Display only;
|
| 259 |
+
* the server-transcribed phrase still drives painting. Skips while a transcription is in
|
| 260 |
+
* flight so it never backs up behind the audio. */
|
| 261 |
+
_onInterim(float32) {
|
| 262 |
+
if (!this._liveOn || !this._asrReady || this._interimBusy || !LL.asr || !LL.asr.ready) return;
|
| 263 |
+
this._orch("asr", true);
|
| 264 |
+
this._interimBusy = true;
|
| 265 |
+
LL.asr.transcribe(float32)
|
| 266 |
+
.then((text) => { const s = (text || "").trim(); if (s && this._liveOn) this._transcript(s); })
|
| 267 |
+
.catch(() => {})
|
| 268 |
+
.finally(() => { this._interimBusy = false; });
|
| 269 |
+
},
|
| 270 |
+
|
| 271 |
_onEvent(ev) {
|
| 272 |
if (!ev || typeof ev !== "object") return;
|
| 273 |
switch (ev.stage) {
|
frontend/js/recorder.js
CHANGED
|
@@ -40,7 +40,7 @@ const recorder = {
|
|
| 40 |
_chunks: [],
|
| 41 |
_inRate: 48000,
|
| 42 |
_capTimer: null,
|
| 43 |
-
_cb: { onState: null, onTranscript: null, onSegment: null, getLang: null },
|
| 44 |
// live-segmentation state (only used when onSegment is wired)
|
| 45 |
_live: false,
|
| 46 |
_seg: [],
|
|
@@ -49,11 +49,13 @@ const recorder = {
|
|
| 49 |
_silMs: 0,
|
| 50 |
_nf: 0.004, // running noise floor (adaptive VAD)
|
| 51 |
_pk: 0.02, // running speech peak (adaptive VAD)
|
|
|
|
| 52 |
|
| 53 |
init(opts = {}) {
|
| 54 |
this._cb.onState = typeof opts.onState === "function" ? opts.onState : null;
|
| 55 |
this._cb.onTranscript = typeof opts.onTranscript === "function" ? opts.onTranscript : null;
|
| 56 |
this._cb.onSegment = typeof opts.onSegment === "function" ? opts.onSegment : null;
|
|
|
|
| 57 |
this._cb.getLang = typeof opts.getLang === "function" ? opts.getLang : null;
|
| 58 |
return this;
|
| 59 |
},
|
|
@@ -117,6 +119,16 @@ const recorder = {
|
|
| 117 |
this._segMs += ms;
|
| 118 |
if (voicedFrame) { this._voicedMs += ms; this._silMs = 0; }
|
| 119 |
else { this._silMs += ms; }
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 120 |
const enough = this._voicedMs >= VAD_MIN_VOICED_MS;
|
| 121 |
const naturalPause = this._silMs >= VAD_SIL_MS && enough;
|
| 122 |
const microPause = this._segMs >= VAD_SOFT_MS && !voicedFrame && enough; // cut in a word gap
|
|
@@ -140,6 +152,17 @@ const recorder = {
|
|
| 140 |
this._silMs = 0;
|
| 141 |
},
|
| 142 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 143 |
/** Encode the buffered phrase and hand it to onSegment, then start a fresh one.
|
| 144 |
* Runs WHILE recording continues — this is what makes the world build as you talk. */
|
| 145 |
_cutLiveSegment() {
|
|
|
|
| 40 |
_chunks: [],
|
| 41 |
_inRate: 48000,
|
| 42 |
_capTimer: null,
|
| 43 |
+
_cb: { onState: null, onTranscript: null, onSegment: null, onInterim: null, getLang: null },
|
| 44 |
// live-segmentation state (only used when onSegment is wired)
|
| 45 |
_live: false,
|
| 46 |
_seg: [],
|
|
|
|
| 49 |
_silMs: 0,
|
| 50 |
_nf: 0.004, // running noise floor (adaptive VAD)
|
| 51 |
_pk: 0.02, // running speech peak (adaptive VAD)
|
| 52 |
+
_lastInterimAt: 0, // for in-browser live transcript pacing
|
| 53 |
|
| 54 |
init(opts = {}) {
|
| 55 |
this._cb.onState = typeof opts.onState === "function" ? opts.onState : null;
|
| 56 |
this._cb.onTranscript = typeof opts.onTranscript === "function" ? opts.onTranscript : null;
|
| 57 |
this._cb.onSegment = typeof opts.onSegment === "function" ? opts.onSegment : null;
|
| 58 |
+
this._cb.onInterim = typeof opts.onInterim === "function" ? opts.onInterim : null;
|
| 59 |
this._cb.getLang = typeof opts.getLang === "function" ? opts.getLang : null;
|
| 60 |
return this;
|
| 61 |
},
|
|
|
|
| 119 |
this._segMs += ms;
|
| 120 |
if (voicedFrame) { this._voicedMs += ms; this._silMs = 0; }
|
| 121 |
else { this._silMs += ms; }
|
| 122 |
+
// in-browser LIVE TRANSCRIPT: every ~1.1s of an active segment, hand the current
|
| 123 |
+
// audio to the client-side ASR for an instant "words as you speak" caption. Purely
|
| 124 |
+
// additive — the server still transcribes the finished segment for PAINTING.
|
| 125 |
+
if (this._cb.onInterim && this._voicedMs >= 350) {
|
| 126 |
+
const nowMs = typeof performance !== "undefined" ? performance.now() : Date.now();
|
| 127 |
+
if (nowMs - this._lastInterimAt > 1100) {
|
| 128 |
+
this._lastInterimAt = nowMs;
|
| 129 |
+
try { this._cb.onInterim(this._segTo16k()); } catch (_) {}
|
| 130 |
+
}
|
| 131 |
+
}
|
| 132 |
const enough = this._voicedMs >= VAD_MIN_VOICED_MS;
|
| 133 |
const naturalPause = this._silMs >= VAD_SIL_MS && enough;
|
| 134 |
const microPause = this._segMs >= VAD_SOFT_MS && !voicedFrame && enough; // cut in a word gap
|
|
|
|
| 152 |
this._silMs = 0;
|
| 153 |
},
|
| 154 |
|
| 155 |
+
/** Merge the current live segment's Float32 chunks and resample to 16 kHz mono (a COPY,
|
| 156 |
+
* so the live buffer keeps growing) — for the in-browser ASR interim transcript. */
|
| 157 |
+
_segTo16k() {
|
| 158 |
+
let length = 0;
|
| 159 |
+
for (const c of this._seg) length += c.length;
|
| 160 |
+
const merged = new Float32Array(length);
|
| 161 |
+
let off = 0;
|
| 162 |
+
for (const c of this._seg) { merged.set(c, off); off += c.length; }
|
| 163 |
+
return this._resample(merged, this._inRate, TARGET_SR);
|
| 164 |
+
},
|
| 165 |
+
|
| 166 |
/** Encode the buffered phrase and hand it to onSegment, then start a fresh one.
|
| 167 |
* Runs WHILE recording continues — this is what makes the world build as you talk. */
|
| 168 |
_cutLiveSegment() {
|
scripts/asr-feasibility.cjs
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
const { chromium } = require("playwright");
|
| 2 |
+
(async () => {
|
| 3 |
+
const browser = await chromium.launch({ headless: false, args: ["--enable-unsafe-webgpu", "--enable-features=Vulkan", "--use-gl=angle", "--use-angle=swiftshader", "--ignore-gpu-blocklist"] });
|
| 4 |
+
const page = await browser.newPage();
|
| 5 |
+
page.on("console", (m) => console.log("PAGE:", m.text().slice(0, 200)));
|
| 6 |
+
await page.goto("http://127.0.0.1:8842/scripts/asr-feasibility.html", { waitUntil: "domcontentloaded" });
|
| 7 |
+
for (let i = 0; i < 150; i++) { const done = await page.evaluate(() => window.__done).catch(() => false); if (done) break; await page.waitForTimeout(2000); }
|
| 8 |
+
const r = await page.evaluate(() => ({ log: window.__log, device: window.__device, text: window.__text, rt: window.__rt, err: window.__err }));
|
| 9 |
+
console.log("=== RESULT ===" + (r.log || ""));
|
| 10 |
+
console.log("DEVICE:", r.device, "| RTfactor:", r.rt, "| ERR:", r.err || "none");
|
| 11 |
+
await browser.close();
|
| 12 |
+
})();
|
scripts/asr-feasibility.html
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!doctype html><html><head><meta charset="utf-8"><title>ASR feasibility</title></head>
|
| 2 |
+
<body><pre id="log">loading…</pre>
|
| 3 |
+
<script type="module">
|
| 4 |
+
const log = (m) => { document.getElementById("log").textContent += "\n" + m; window.__log = (window.__log||"") + "\n" + m; };
|
| 5 |
+
window.__done = false;
|
| 6 |
+
try {
|
| 7 |
+
const t0 = performance.now();
|
| 8 |
+
const { pipeline, env } = await import("https://cdn.jsdelivr.net/npm/@huggingface/transformers@3.7.5");
|
| 9 |
+
env.allowLocalModels = false;
|
| 10 |
+
log("transformers.js loaded");
|
| 11 |
+
// try WebGPU, fall back to wasm
|
| 12 |
+
let device = "webgpu";
|
| 13 |
+
let transcriber;
|
| 14 |
+
try {
|
| 15 |
+
transcriber = await pipeline("automatic-speech-recognition", "onnx-community/whisper-base.en", { device: "webgpu", dtype: "fp32" });
|
| 16 |
+
log("model loaded on WEBGPU in " + Math.round(performance.now()-t0) + "ms");
|
| 17 |
+
} catch (e) {
|
| 18 |
+
device = "wasm";
|
| 19 |
+
log("webgpu failed (" + String(e).slice(0,80) + "), trying wasm…");
|
| 20 |
+
transcriber = await pipeline("automatic-speech-recognition", "onnx-community/whisper-base.en", { device: "wasm" });
|
| 21 |
+
log("model loaded on WASM in " + Math.round(performance.now()-t0) + "ms");
|
| 22 |
+
}
|
| 23 |
+
// fetch + decode the test wav to 16k mono Float32
|
| 24 |
+
const resp = await fetch("/.shots/cont_en.wav");
|
| 25 |
+
const buf = await resp.arrayBuffer();
|
| 26 |
+
const ac = new (window.AudioContext||window.webkitAudioContext)({ sampleRate: 16000 });
|
| 27 |
+
const decoded = await ac.decodeAudioData(buf);
|
| 28 |
+
const audio = decoded.getChannelData(0);
|
| 29 |
+
log("audio decoded: " + (audio.length/16000).toFixed(1) + "s @ " + decoded.sampleRate + "Hz");
|
| 30 |
+
const t1 = performance.now();
|
| 31 |
+
const out = await transcriber(audio, { language: "en", task: "transcribe", return_timestamps: false });
|
| 32 |
+
const dt = performance.now()-t1;
|
| 33 |
+
log("TRANSCRIBED in " + Math.round(dt) + "ms (" + (dt/(audio.length/16000)).toFixed(2) + "x realtime, " + device + ")");
|
| 34 |
+
log("TEXT: " + (out.text||"").trim());
|
| 35 |
+
window.__device = device; window.__text = (out.text||"").trim(); window.__rt = dt/(audio.length/16000);
|
| 36 |
+
} catch (e) {
|
| 37 |
+
log("FATAL: " + String(e).slice(0,300));
|
| 38 |
+
window.__err = String(e).slice(0,300);
|
| 39 |
+
}
|
| 40 |
+
window.__done = true;
|
| 41 |
+
</script></body></html>
|