Efradeca commited on
Commit
996ecbe
·
verified ·
1 Parent(s): 587801b

chore: deploy private lightloom build

Browse files
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-scenes-1",
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>