Efradeca commited on
Commit
14d6183
·
verified ·
1 Parent(s): 4b8c7f1

chore: deploy private lightloom build

Browse files
.gitattributes CHANGED
@@ -33,3 +33,4 @@ saved_model/**/* filter=lfs diff=lfs merge=lfs -text
33
  *.zip filter=lfs diff=lfs merge=lfs -text
34
  *.zst filter=lfs diff=lfs merge=lfs -text
35
  *tfevents* filter=lfs diff=lfs merge=lfs -text
 
 
33
  *.zip filter=lfs diff=lfs merge=lfs -text
34
  *.zst filter=lfs diff=lfs merge=lfs -text
35
  *tfevents* filter=lfs diff=lfs merge=lfs -text
36
+ 2406.09394v4.pdf filter=lfs diff=lfs merge=lfs -text
2406.09394v4.pdf ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:4f4b043583868ed52c9cfaf952ecc0eb8a9e4434d590a02ae7e46da98679fd4a
3
+ size 28467487
app.py CHANGED
@@ -144,7 +144,7 @@ def health() -> dict[str, Any]:
144
  "privacy_mode": os.getenv("LIGHTLOOM_PRIVACY_MODE", "1") == "1",
145
  "cpu_count": os.cpu_count(),
146
  "director_backend": CONFIG.director_backend,
147
- "build": "showcase-2",
148
  }
149
 
150
 
@@ -225,6 +225,43 @@ def asr_probe_endpoint() -> dict[str, Any]:
225
  return {"ok": True, "data": _asr_probe_on_gpu()}
226
 
227
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
228
  # --- Panorama (Voice-Scroll-in-3D) verification: klein-base + the 360 ERP outpaint
229
  # LoRA produce a 2:1 equirectangular panorama via Flux2 inpaint. Code-only (diffusers
230
  # already ships Flux2KleinInpaintPipeline + load_lora_weights). ---
 
144
  "privacy_mode": os.getenv("LIGHTLOOM_PRIVACY_MODE", "1") == "1",
145
  "cpu_count": os.cpu_count(),
146
  "director_backend": CONFIG.director_backend,
147
+ "build": "voice-1",
148
  }
149
 
150
 
 
225
  return {"ok": True, "data": _asr_probe_on_gpu()}
226
 
227
 
228
+ # --- VOICE input (the "speak" in "speak, and watch your story become a world"):
229
+ # Cohere Transcribe turns the narrator's mic audio into text, which then drives the
230
+ # same panorama pipeline. The browser captures a 16 kHz mono PCM WAV and sends it
231
+ # base64-encoded; read_wav decodes it with the stdlib (no ffmpeg needed). ---
232
+ @spaces.GPU(duration=60)
233
+ def _transcribe_on_gpu(wav_path: str, lang: str) -> dict[str, Any]:
234
+ from lightloom.audio_in.asr import load_asr, read_wav, transcribe
235
+
236
+ audio, sr = read_wav(wav_path)
237
+ processor, model = load_asr()
238
+ text = transcribe(processor, model, audio, sampling_rate=sr, language=lang)
239
+ return {"text": text}
240
+
241
+
242
+ @app.api(name="transcribe", concurrency_id="gpu", concurrency_limit=1, stream_every=0.05)
243
+ def transcribe_api(audio_b64: str = "", lang: str = "en") -> dict:
244
+ import base64
245
+ import tempfile
246
+
247
+ yield {"stage": "transcribing"}
248
+ try:
249
+ payload = audio_b64.split(",")[-1] if audio_b64 else ""
250
+ raw = base64.b64decode(payload) if payload else b""
251
+ if len(raw) < 64: # nothing meaningful captured
252
+ yield {"stage": "error", "error": "empty_audio"}
253
+ return
254
+ with tempfile.NamedTemporaryFile(suffix=".wav", delete=False) as handle:
255
+ handle.write(raw)
256
+ path = handle.name
257
+ result = _transcribe_on_gpu(path, lang)
258
+ yield {"stage": "transcript", "text": (result.get("text") or "").strip()}
259
+ except Exception as exc: # noqa: BLE001 - quota/decoding guard.
260
+ message = str(exc).lower()
261
+ stage = "quota_exceeded" if any(t in message for t in ("gpu", "quota", "zerogpu", "exceeded")) else "error"
262
+ yield {"stage": stage, "error": str(exc)[:200]}
263
+
264
+
265
  # --- Panorama (Voice-Scroll-in-3D) verification: klein-base + the 360 ERP outpaint
266
  # LoRA produce a 2:1 equirectangular panorama via Flux2 inpaint. Code-only (diffusers
267
  # already ships Flux2KleinInpaintPipeline + load_lora_weights). ---
frontend/index.html CHANGED
@@ -474,6 +474,8 @@ Years later, the same road lay buried under snow and silence.</textarea>
474
  controller). Same-origin /frontend paths.
475
  -->
476
  <script type="module" src="/frontend/js/api.js"></script>
 
 
477
  <!--
478
  stage-gl.js (LL.stage): a WebGL2 beat renderer kept ONLY as a passive
479
  fallback — it never grabs #stage-canvas unless the controller calls
 
474
  controller). Same-origin /frontend paths.
475
  -->
476
  <script type="module" src="/frontend/js/api.js"></script>
477
+ <!-- Voice input (LL.recorder): mic -> 16 kHz WAV -> Cohere Transcribe -> recital. -->
478
+ <script type="module" src="/frontend/js/recorder.js"></script>
479
  <!--
480
  stage-gl.js (LL.stage): a WebGL2 beat renderer kept ONLY as a passive
481
  fallback — it never grabs #stage-canvas unless the controller calls
frontend/js/api.js CHANGED
@@ -73,6 +73,42 @@ const api = {
73
  return this._streamEndpoint("/panorama", text, lang, onEvent);
74
  },
75
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
76
  /** Shared streaming core for the @app.api endpoints (recital + panorama). */
77
  async _streamEndpoint(endpoint, text, lang = "en", onEvent) {
78
  const emit = typeof onEvent === "function" ? onEvent : () => {};
 
73
  return this._streamEndpoint("/panorama", text, lang, onEvent);
74
  },
75
 
76
+ /**
77
+ * Transcribe a base64 WAV (the narrator's mic audio) via Cohere Transcribe.
78
+ * Resolves with {text, error}. Used by LL.recorder; the transcript then drives
79
+ * streamPanorama just like a typed recital.
80
+ *
81
+ * @param {string} audioB64 data:audio/wav;base64,... (or bare base64)
82
+ * @param {string} lang ISO code ("en"/"es"/...); default "en"
83
+ * @param {(event:object)=>void} [onEvent] optional per-stage callback
84
+ * @returns {Promise<{text:string, error:?string}>}
85
+ */
86
+ async transcribe(audioB64, lang = "en", onEvent) {
87
+ const emit = typeof onEvent === "function" ? onEvent : () => {};
88
+ let client;
89
+ try {
90
+ client = await this.connect();
91
+ } catch (err) {
92
+ return { text: "", error: `connect_failed: ${shortErr(err)}` };
93
+ }
94
+ let text = "";
95
+ let error = null;
96
+ try {
97
+ const job = client.submit("/transcribe", { audio_b64: audioB64, lang: lang || "en" });
98
+ for await (const msg of job) {
99
+ if (!msg || msg.type !== "data") continue;
100
+ const ev = asEvent(msg.data);
101
+ if (!ev || typeof ev !== "object") continue;
102
+ emit(ev);
103
+ if (ev.stage === "transcript") text = ev.text || "";
104
+ if (ev.stage === "error" || ev.stage === "quota_exceeded") error = ev.error || ev.stage;
105
+ }
106
+ } catch (err) {
107
+ error = `stream_failed: ${shortErr(err)}`;
108
+ }
109
+ return { text, error };
110
+ },
111
+
112
  /** Shared streaming core for the @app.api endpoints (recital + panorama). */
113
  async _streamEndpoint(endpoint, text, lang = "en", onEvent) {
114
  const emit = typeof onEvent === "function" ? onEvent : () => {};
frontend/js/recorder.js ADDED
@@ -0,0 +1,202 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // frontend/js/recorder.js — LL.recorder: capture the narrator's voice and turn it
2
+ // into text via Cohere Transcribe (the "speak" in "speak, and watch your story
3
+ // become a world"). We capture raw PCM through the Web Audio API and encode a
4
+ // 16 kHz mono WAV in-browser (no MediaRecorder/webm, no server-side ffmpeg), then
5
+ // hand the base64 WAV to LL.api.transcribe — the transcript drives the same
6
+ // panorama pipeline as the typed recital.
7
+ //
8
+ // Public surface:
9
+ // LL.recorder.init({ onState, onTranscript, getLang })
10
+ // LL.recorder.toggle() -> start if idle, stop+transcribe if recording
11
+ // LL.recorder.start() / .stop() / .cancel()
12
+ // LL.recorder.state -> "idle" | "recording" | "transcribing"
13
+ //
14
+ // Vanilla ES module, no deps.
15
+ "use strict";
16
+
17
+ const LL = (window.LL = window.LL || {});
18
+ const TARGET_SR = 16000; // Cohere Transcribe expects 16 kHz mono
19
+ const MAX_SECONDS = 30; // safety cap so a forgotten mic can't record forever
20
+
21
+ const recorder = {
22
+ state: "idle",
23
+ _ctx: null,
24
+ _stream: null,
25
+ _source: null,
26
+ _node: null,
27
+ _chunks: [],
28
+ _inRate: 48000,
29
+ _capTimer: null,
30
+ _cb: { onState: null, onTranscript: null, getLang: null },
31
+
32
+ init(opts = {}) {
33
+ this._cb.onState = typeof opts.onState === "function" ? opts.onState : null;
34
+ this._cb.onTranscript = typeof opts.onTranscript === "function" ? opts.onTranscript : null;
35
+ this._cb.getLang = typeof opts.getLang === "function" ? opts.getLang : null;
36
+ return this;
37
+ },
38
+
39
+ /** Browser support gate the controller can check before offering the mic. */
40
+ get supported() {
41
+ return !!(navigator.mediaDevices && navigator.mediaDevices.getUserMedia && (window.AudioContext || window.webkitAudioContext));
42
+ },
43
+
44
+ toggle() {
45
+ if (this.state === "recording") return this.stop();
46
+ if (this.state === "idle") return this.start();
47
+ // "transcribing" — ignore re-entry.
48
+ },
49
+
50
+ async start() {
51
+ if (this.state !== "idle") return;
52
+ if (!this.supported) {
53
+ this._setState("idle");
54
+ if (this._cb.onTranscript) this._cb.onTranscript("", "unsupported");
55
+ return;
56
+ }
57
+ try {
58
+ this._stream = await navigator.mediaDevices.getUserMedia({
59
+ audio: { channelCount: 1, echoCancellation: true, noiseSuppression: true },
60
+ });
61
+ } catch (err) {
62
+ this._setState("idle");
63
+ if (this._cb.onTranscript) this._cb.onTranscript("", "mic_denied");
64
+ return;
65
+ }
66
+ const AC = window.AudioContext || window.webkitAudioContext;
67
+ this._ctx = new AC();
68
+ this._inRate = this._ctx.sampleRate || 48000;
69
+ this._source = this._ctx.createMediaStreamSource(this._stream);
70
+ // ScriptProcessorNode is deprecated but universally available (incl. headless
71
+ // Chromium) and ample for short narration; AudioWorklet would be overkill here.
72
+ this._node = this._ctx.createScriptProcessor(4096, 1, 1);
73
+ this._chunks = [];
74
+ this._node.onaudioprocess = (e) => {
75
+ const ch = e.inputBuffer.getChannelData(0);
76
+ this._chunks.push(new Float32Array(ch)); // copy: the buffer is reused
77
+ };
78
+ this._source.connect(this._node);
79
+ this._node.connect(this._ctx.destination);
80
+ this._setState("recording");
81
+ this._capTimer = setTimeout(() => {
82
+ if (this.state === "recording") this.stop();
83
+ }, MAX_SECONDS * 1000);
84
+ },
85
+
86
+ async stop() {
87
+ if (this.state !== "recording") return;
88
+ if (this._capTimer) {
89
+ clearTimeout(this._capTimer);
90
+ this._capTimer = null;
91
+ }
92
+ this._teardownGraph();
93
+ this._setState("transcribing");
94
+
95
+ const wavB64 = this._encodeWavBase64(this._chunks, this._inRate);
96
+ this._chunks = [];
97
+ const lang = (this._cb.getLang && this._cb.getLang()) || "en";
98
+
99
+ let text = "";
100
+ let error = null;
101
+ try {
102
+ if (LL.api && typeof LL.api.transcribe === "function") {
103
+ const res = await LL.api.transcribe(wavB64, lang);
104
+ text = (res && res.text) || "";
105
+ error = res && res.error;
106
+ } else {
107
+ error = "no_api";
108
+ }
109
+ } catch (err) {
110
+ error = String((err && err.message) || err).slice(0, 120);
111
+ }
112
+ this._setState("idle");
113
+ if (this._cb.onTranscript) this._cb.onTranscript(text, error);
114
+ },
115
+
116
+ cancel() {
117
+ if (this._capTimer) {
118
+ clearTimeout(this._capTimer);
119
+ this._capTimer = null;
120
+ }
121
+ this._teardownGraph();
122
+ this._chunks = [];
123
+ this._setState("idle");
124
+ },
125
+
126
+ _teardownGraph() {
127
+ try { if (this._node) this._node.disconnect(); } catch (_) {}
128
+ try { if (this._source) this._source.disconnect(); } catch (_) {}
129
+ try { if (this._stream) this._stream.getTracks().forEach((t) => t.stop()); } catch (_) {}
130
+ try { if (this._ctx && this._ctx.state !== "closed") this._ctx.close(); } catch (_) {}
131
+ this._node = this._source = this._stream = this._ctx = null;
132
+ },
133
+
134
+ _setState(s) {
135
+ this.state = s;
136
+ if (this._cb.onState) {
137
+ try { this._cb.onState(s); } catch (_) {}
138
+ }
139
+ },
140
+
141
+ // ---- encode captured Float32 chunks -> 16 kHz mono 16-bit PCM WAV (base64) ----
142
+ _encodeWavBase64(chunks, inRate) {
143
+ let length = 0;
144
+ for (const c of chunks) length += c.length;
145
+ const merged = new Float32Array(length);
146
+ let off = 0;
147
+ for (const c of chunks) {
148
+ merged.set(c, off);
149
+ off += c.length;
150
+ }
151
+ const pcm = this._resample(merged, inRate, TARGET_SR);
152
+ const buffer = new ArrayBuffer(44 + pcm.length * 2);
153
+ const view = new DataView(buffer);
154
+ const writeStr = (o, s) => { for (let i = 0; i < s.length; i++) view.setUint8(o + i, s.charCodeAt(i)); };
155
+ const dataLen = pcm.length * 2;
156
+ writeStr(0, "RIFF");
157
+ view.setUint32(4, 36 + dataLen, true);
158
+ writeStr(8, "WAVE");
159
+ writeStr(12, "fmt ");
160
+ view.setUint32(16, 16, true); // PCM chunk size
161
+ view.setUint16(20, 1, true); // PCM
162
+ view.setUint16(22, 1, true); // mono
163
+ view.setUint32(24, TARGET_SR, true);
164
+ view.setUint32(28, TARGET_SR * 2, true); // byte rate
165
+ view.setUint16(32, 2, true); // block align
166
+ view.setUint16(34, 16, true); // bits per sample
167
+ writeStr(36, "data");
168
+ view.setUint32(40, dataLen, true);
169
+ let p = 44;
170
+ for (let i = 0; i < pcm.length; i++, p += 2) {
171
+ let s = Math.max(-1, Math.min(1, pcm[i]));
172
+ view.setInt16(p, s < 0 ? s * 0x8000 : s * 0x7fff, true);
173
+ }
174
+ // ArrayBuffer -> base64 in chunks (avoid call-stack limits on big buffers).
175
+ const bytes = new Uint8Array(buffer);
176
+ let bin = "";
177
+ const CH = 0x8000;
178
+ for (let i = 0; i < bytes.length; i += CH) {
179
+ bin += String.fromCharCode.apply(null, bytes.subarray(i, i + CH));
180
+ }
181
+ return "data:audio/wav;base64," + btoa(bin);
182
+ },
183
+
184
+ _resample(input, inRate, outRate) {
185
+ if (inRate === outRate) return input;
186
+ const ratio = inRate / outRate;
187
+ const outLen = Math.round(input.length / ratio);
188
+ const out = new Float32Array(outLen);
189
+ for (let i = 0; i < outLen; i++) {
190
+ const idx = i * ratio;
191
+ const i0 = Math.floor(idx);
192
+ const i1 = Math.min(i0 + 1, input.length - 1);
193
+ const frac = idx - i0;
194
+ out[i] = input[i0] * (1 - frac) + input[i1] * frac; // linear interpolation
195
+ }
196
+ return out;
197
+ },
198
+ };
199
+
200
+ LL.recorder = recorder;
201
+
202
+ export default LL.recorder;
frontend/js/stage.js CHANGED
@@ -29,6 +29,10 @@ const COPY = {
29
  painting: { es: "Pintando el plano…", en: "Painting the shot…" },
30
  fogged: { es: "El plano se veló; seguimos rodando.", en: "The shot got fogged; rolling on." },
31
  empty: { es: "Pega un poema antes de rodar.", en: "Paste a poem before rolling." },
 
 
 
 
32
  connect: {
33
  es: "No se pudo abrir la sala. Reintenta en un momento.",
34
  en: "Couldn't open the theater. Try again in a moment.",
@@ -82,6 +86,7 @@ const controller = {
82
  this._wireControls();
83
  this._wireLangToggle();
84
  this._initStageRenderer();
 
85
  this._applyLang(this.lang); // paint all data-es/data-en nodes once
86
  this.show("lobby");
87
  return this;
@@ -107,10 +112,16 @@ const controller = {
107
  on("btn-recital", "click", () => this.startRecital());
108
  on("btn-recital-go", "click", () => this.startRecital());
109
  on("btn-narrate", "click", () => {
110
- // Live narration (mic) is a sibling module; if it isn't present, fall back
111
- // to the recital flow so the button is never dead.
112
- if (LL.recorder && typeof LL.recorder.start === "function") LL.recorder.start();
113
- else this.startRecital();
 
 
 
 
 
 
114
  });
115
  on("btn-showcase", "click", () => this.startShowcase());
116
  },
@@ -168,6 +179,38 @@ const controller = {
168
  return LL.pano || LL.stage || null;
169
  },
170
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
171
  // ---- the main flow: start a recital ---------------------------------------
172
 
173
  /**
 
29
  painting: { es: "Pintando el plano…", en: "Painting the shot…" },
30
  fogged: { es: "El plano se veló; seguimos rodando.", en: "The shot got fogged; rolling on." },
31
  empty: { es: "Pega un poema antes de rodar.", en: "Paste a poem before rolling." },
32
+ listening: { es: "Te escucho… habla y pulsa para parar.", en: "Listening… speak, then click to stop." },
33
+ transcribing: { es: "Transcribiendo tu voz…", en: "Transcribing your voice…" },
34
+ micDenied: { es: "No pude usar el micrófono. Escribe el poema y pulsa Rodar.", en: "Couldn't use the mic. Type your poem and hit Roll." },
35
+ noSpeech: { es: "No escuché nada. Inténtalo otra vez o escribe el poema.", en: "I didn't catch that. Try again or type the poem." },
36
  connect: {
37
  es: "No se pudo abrir la sala. Reintenta en un momento.",
38
  en: "Couldn't open the theater. Try again in a moment.",
 
86
  this._wireControls();
87
  this._wireLangToggle();
88
  this._initStageRenderer();
89
+ this._initRecorder();
90
  this._applyLang(this.lang); // paint all data-es/data-en nodes once
91
  this.show("lobby");
92
  return this;
 
112
  on("btn-recital", "click", () => this.startRecital());
113
  on("btn-recital-go", "click", () => this.startRecital());
114
  on("btn-narrate", "click", () => {
115
+ // Live narration: capture the mic, transcribe (Cohere), then film the world.
116
+ // If the recorder isn't available/supported, fall back to the typed recital
117
+ // so the button is never dead.
118
+ if (LL.recorder && LL.recorder.supported && typeof LL.recorder.toggle === "function") {
119
+ LL.recorder.toggle();
120
+ } else {
121
+ const ta = document.getElementById("recital-text");
122
+ if (ta && ta.value.trim()) this.startRecital();
123
+ else { this.toast(t(COPY.micDenied, this.lang)); if (ta) ta.focus(); }
124
+ }
125
  });
126
  on("btn-showcase", "click", () => this.startShowcase());
127
  },
 
179
  return LL.pano || LL.stage || null;
180
  },
181
 
182
+ /** Wire the voice recorder (LL.recorder): mic -> Cohere Transcribe -> the
183
+ * transcript fills the recital and films the world. Mic state drives toasts +
184
+ * a recording class on the Narrate button. No-op if the recorder is absent. */
185
+ _initRecorder() {
186
+ if (!LL.recorder || typeof LL.recorder.init !== "function") return;
187
+ LL.recorder.init({
188
+ getLang: () => this.lang,
189
+ onState: (s) => {
190
+ const btn = document.getElementById("btn-narrate");
191
+ if (btn) {
192
+ btn.classList.toggle("is-recording", s === "recording");
193
+ btn.classList.toggle("is-busy", s === "transcribing");
194
+ btn.setAttribute("aria-pressed", String(s === "recording"));
195
+ }
196
+ if (s === "recording") this.toast(t(COPY.listening, this.lang));
197
+ else if (s === "transcribing") this.toast(t(COPY.transcribing, this.lang));
198
+ },
199
+ onTranscript: (text, error) => {
200
+ const ta = document.getElementById("recital-text");
201
+ const clean = (text || "").trim();
202
+ if (clean) {
203
+ if (ta) ta.value = clean;
204
+ this.startRecital();
205
+ } else {
206
+ const denied = error === "mic_denied" || error === "unsupported";
207
+ this.toast(t(denied ? COPY.micDenied : COPY.noSpeech, this.lang));
208
+ if (ta) ta.focus();
209
+ }
210
+ },
211
+ });
212
+ },
213
+
214
  // ---- the main flow: start a recital ---------------------------------------
215
 
216
  /**
requirements.txt CHANGED
@@ -12,3 +12,7 @@ diffusers @ git+https://github.com/huggingface/diffusers.git@784fa62652fb2719d41
12
  peft>=0.14
13
  llama-cpp-python==0.3.28
14
  spaces==0.50.4
 
 
 
 
 
12
  peft>=0.14
13
  llama-cpp-python==0.3.28
14
  spaces==0.50.4
15
+ # Cohere Transcribe's processor (trust_remote_code) imports librosa at load; soundfile
16
+ # lets us decode the browser's PCM WAV without ffmpeg. Verified conflict-free locally.
17
+ librosa>=0.10
18
+ soundfile>=0.12
scripts/shoot-voice.cjs ADDED
@@ -0,0 +1,96 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // Validate the voice wiring locally: fake mic -> LL.recorder captures + encodes a
2
+ // WAV -> (stubbed) LL.api.transcribe -> transcript fills the recital -> (stubbed)
3
+ // streamPanorama reveals the world. Proves the recorder graph + flow without the
4
+ // real ASR endpoint. Usage: node scripts/shoot-voice.cjs
5
+ const { chromium } = require("playwright");
6
+ const http = require("http");
7
+ const fs = require("fs");
8
+ const path = require("path");
9
+
10
+ const ROOT = process.cwd();
11
+ const PORT = 8804;
12
+ const CT = {
13
+ ".html": "text/html", ".js": "application/javascript", ".mjs": "application/javascript",
14
+ ".css": "text/css", ".woff2": "font/woff2", ".webp": "image/webp", ".png": "image/png",
15
+ ".json": "application/json", ".svg": "image/svg+xml",
16
+ };
17
+
18
+ const server = http.createServer((req, res) => {
19
+ let p = decodeURIComponent(req.url.split("?")[0]);
20
+ if (p === "/") p = "/frontend/index.html";
21
+ if (p.startsWith("/shots/")) p = "/.shots/" + p.slice(7);
22
+ const file = path.join(ROOT, p);
23
+ fs.readFile(file, (err, data) => {
24
+ if (err) { res.writeHead(404); res.end("nf"); return; }
25
+ res.writeHead(200, { "content-type": CT[path.extname(file)] || "application/octet-stream" });
26
+ res.end(data);
27
+ });
28
+ });
29
+
30
+ (async () => {
31
+ await new Promise((r) => server.listen(PORT, r));
32
+ const browser = await chromium.launch({
33
+ args: [
34
+ "--use-gl=angle", "--use-angle=swiftshader", "--ignore-gpu-blocklist", "--enable-webgl",
35
+ "--use-fake-device-for-media-stream", "--use-fake-ui-for-media-stream",
36
+ ],
37
+ });
38
+ const ctx = await browser.newContext({ viewport: { width: 1680, height: 820 }, permissions: ["microphone"] });
39
+ const page = await ctx.newPage();
40
+ const errs = [];
41
+ page.on("console", (m) => { if (m.type() === "error") errs.push(m.text().slice(0, 200)); });
42
+ page.on("pageerror", (e) => errs.push("PAGEERROR: " + e.message.slice(0, 200)));
43
+
44
+ await page.goto(`http://localhost:${PORT}/frontend/index.html`, { waitUntil: "networkidle", timeout: 25000 }).catch((e) => errs.push("goto:" + e.message));
45
+ await page.waitForTimeout(1000);
46
+
47
+ // Stub the network bits: transcribe returns a known line; panorama reveals a real world.
48
+ await page.evaluate(() => {
49
+ const LL = window.LL;
50
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
51
+ window.__transcribeCalled = false;
52
+ window.__wavBytes = 0;
53
+ LL.api.transcribe = async (b64) => {
54
+ window.__transcribeCalled = true;
55
+ window.__wavBytes = (b64 || "").length;
56
+ return { text: "An old lighthouse keeper counted the waves at the edge of the world.", error: null };
57
+ };
58
+ LL.api.streamPanorama = async (text, lang, onEvent) => {
59
+ window.__panoText = text;
60
+ onEvent({ stage: "warming" });
61
+ await sleep(40);
62
+ onEvent({ stage: "painted", panorama: "/shots/real-pano.webp" });
63
+ await sleep(60);
64
+ onEvent({ stage: "depth", depth: "/shots/real-depth.png" });
65
+ onEvent({ stage: "done", session: "voice" });
66
+ return { session: "voice", done: true, cancelled: false };
67
+ };
68
+ return { supported: LL.recorder && LL.recorder.supported };
69
+ });
70
+
71
+ // Click Narrate (start recording) -> wait (capture fake audio) -> click again (stop).
72
+ const sup = await page.evaluate(() => window.LL.recorder && window.LL.recorder.supported);
73
+ await page.click("#btn-narrate").catch(() => {});
74
+ await page.waitForTimeout(1500);
75
+ const recState = await page.evaluate(() => window.LL.recorder.state);
76
+ await page.click("#btn-narrate").catch(() => {});
77
+ // Wait for transcribe -> recital -> world reveal.
78
+ await page.waitForFunction(() => window.LL.controller && window.LL.controller._worldShown, { timeout: 8000 }).catch(() => {});
79
+ await page.evaluate(() => window.dispatchEvent(new Event("resize")));
80
+ await page.waitForTimeout(1500);
81
+ await page.screenshot({ path: path.join(ROOT, ".shots", "voice-world.png") });
82
+
83
+ const st = await page.evaluate(() => ({
84
+ supported: window.LL.recorder.supported,
85
+ transcribeCalled: window.__transcribeCalled,
86
+ wavLen: window.__wavBytes,
87
+ panoText: window.__panoText,
88
+ worldShown: window.LL.controller._worldShown,
89
+ activeScreen: [...document.querySelectorAll(".screen.is-active")].map((s) => s.id),
90
+ }));
91
+ console.log("SUPPORTED:", sup, "REC_STATE_WHILE_RECORDING:", recState);
92
+ console.log("RESULT:", JSON.stringify(st));
93
+ console.log("JS_ERRORS:", JSON.stringify(errs.slice(0, 10)));
94
+ await browser.close();
95
+ server.close();
96
+ })().catch((e) => { console.error("HARNESS ERROR:", e.message); process.exit(1); });