privateboard 0.1.37 → 0.1.40

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (76) hide show
  1. package/dist/boot.js +1415 -91
  2. package/dist/boot.js.map +1 -1
  3. package/dist/cli.js +1415 -91
  4. package/dist/cli.js.map +1 -1
  5. package/dist/server.js +1271 -81
  6. package/dist/server.js.map +1 -1
  7. package/dist/version.d.ts +1 -1
  8. package/dist/version.js +1 -1
  9. package/dist/version.js.map +1 -1
  10. package/package.json +1 -1
  11. package/public/__avatar3d_test.html +156 -0
  12. package/public/adjourn-overlay.css +2 -2
  13. package/public/agent-overlay.css +27 -15
  14. package/public/agent-overlay.js +3 -1
  15. package/public/agent-profile.css +331 -41
  16. package/public/agent-profile.js +499 -75
  17. package/public/app-updater.css +1 -1
  18. package/public/app.js +2090 -547
  19. package/public/avatar-3d-snap.js +205 -0
  20. package/public/avatar-3d.js +792 -0
  21. package/public/avatar-customizer.html +274 -0
  22. package/public/avatar3d-editor.css +240 -0
  23. package/public/avatar3d-editor.js +481 -0
  24. package/public/avatars/3d/chair.png +0 -0
  25. package/public/avatars/3d/first-principles.png +0 -0
  26. package/public/avatars/3d/historian.png +0 -0
  27. package/public/avatars/3d/long-horizon.png +0 -0
  28. package/public/avatars/3d/phenomenologist.png +0 -0
  29. package/public/avatars/3d/socrates.png +0 -0
  30. package/public/avatars/3d/user-empathy.png +0 -0
  31. package/public/avatars/3d/value-investor.png +0 -0
  32. package/public/core-avatars.js +86 -0
  33. package/public/home-3d-loader.js +15 -4
  34. package/public/home-3d-mock.js +18 -7
  35. package/public/home.html +80 -18
  36. package/public/i18n.js +279 -4
  37. package/public/icons/avatar_1779855104027.glb +0 -0
  38. package/public/icons/logo.png +0 -0
  39. package/public/icons/new-style.glb +0 -0
  40. package/public/icons/new-style2.glb +0 -0
  41. package/public/icons/new-style3.glb +0 -0
  42. package/public/icons/new-style4.glb +0 -0
  43. package/public/icons/new-style5.glb +0 -0
  44. package/public/icons/office.glb +0 -0
  45. package/public/icons/stuff.glb +0 -0
  46. package/public/index.html +203 -182
  47. package/public/mention-picker.js +1 -1
  48. package/public/new-agent.css +7 -7
  49. package/public/new-agent.js +46 -20
  50. package/public/office-viewer.html +340 -0
  51. package/public/onboarding.css +5 -5
  52. package/public/quote-cta.css +5 -4
  53. package/public/quote-cta.js +50 -5
  54. package/public/room-settings.css +24 -9
  55. package/public/stuff-viewer.html +330 -0
  56. package/public/thread.css +1211 -0
  57. package/public/user-settings.css +16 -19
  58. package/public/user-settings.js +86 -78
  59. package/public/vendor/BufferGeometryUtils.js +1434 -0
  60. package/public/vendor/DRACOLoader.js +739 -0
  61. package/public/vendor/GLTFLoader.js +4860 -0
  62. package/public/vendor/RoomEnvironment.js +185 -0
  63. package/public/vendor/SkeletonUtils.js +496 -0
  64. package/public/vendor/draco/draco_decoder.js +34 -0
  65. package/public/vendor/draco/draco_decoder.wasm +0 -0
  66. package/public/vendor/draco/draco_encoder.js +33 -0
  67. package/public/vendor/draco/draco_wasm_wrapper.js +117 -0
  68. package/public/vendor/meshopt_decoder.module.js +196 -0
  69. package/public/voice-3d-banner.js +12 -0
  70. package/public/voice-3d.js +1407 -432
  71. package/public/voice-clone.css +875 -0
  72. package/public/voice-clone.js +1351 -0
  73. package/public/voice-replay.css +3 -3
  74. package/public/voice-replay.js +21 -0
  75. package/public/avatar-skill.js +0 -629
  76. package/public/icons/folded-sidebar.png +0 -0
@@ -0,0 +1,1351 @@
1
+ /* voice-clone.js · Global singleton driving the director voice-
2
+ * cloning UX:
3
+ *
4
+ * · `boardroomVoiceClone.open({ agentId, agentName, onApplied })`
5
+ * mounts the overlay (source picker → confirm → progress).
6
+ * · `boardroomVoiceClone.minimize()` hides the overlay and shows
7
+ * a right-bottom pill with live progress.
8
+ * · `boardroomVoiceClone.restore()` reverses it. The SSE channel
9
+ * stays live across minimize/restore so progress doesn't reset.
10
+ *
11
+ * One job per process; the backend enforces this and the UI mirrors
12
+ * that constraint by short-circuiting open() when a job is active.
13
+ *
14
+ * SSE consumes events emitted by /api/voice-clone/:id/stream. Two
15
+ * event kinds: `snapshot` (initial state on connect, lets the
16
+ * pill re-attach to a long-running job after a page reload) and
17
+ * `progress` (per-update). `end` is the terminal marker.
18
+ */
19
+ (function (root) {
20
+ "use strict";
21
+
22
+ // ── State ──────────────────────────────────────────────────────
23
+ const STATE = {
24
+ overlay: null,
25
+ pill: null,
26
+ agentId: null,
27
+ agentName: "",
28
+ onApplied: null,
29
+ sourceMode: "upload", // "upload" | "record"
30
+ selectedFile: null, // File object · audio or video (or recorded Blob)
31
+ selectedFileName: "", // display
32
+ selectedIsVideo: false, // true when the picked file is video/*
33
+ decodedAudio: null, // AudioBuffer · null until decode succeeds
34
+ trimStart: 0, // seconds · start of selection
35
+ trimEnd: 0, // seconds · end of selection
36
+ // Recording mode
37
+ recorder: null, // MediaRecorder instance
38
+ recorderStream: null, // MediaStream · stopped on tear-down
39
+ recordChunks: [], // Blob chunks coming in from ondataavailable
40
+ recordBlob: null, // finished recording, ready to clone-confirm
41
+ recordStartedAt: 0, // performance.now() when recording began
42
+ recordTimerId: 0, // setInterval handle for the running counter
43
+ recordLevelAudio: null, // AudioContext + AnalyserNode bookkeeping
44
+ recordPreviewAudio: null, // HTMLAudioElement for play-back of the take
45
+ label: "", // optional voice label
46
+ miniMaxGroupId: "", // optional MiniMax Group ID override
47
+ clonedVoiceId: "", // voice_id of the just-finished clone (success stage)
48
+ clonedProvider: "", // provider that did the clone
49
+ terminalHandled: false, // idempotent guard · onTerminal fires twice (progress done + SSE end)
50
+ previewAudio: null, // HTMLAudioElement · success-stage preview
51
+ previewBusy: false, // throttle preview button while a request is in flight
52
+ jobId: null,
53
+ stage: "fetch",
54
+ pct: 0,
55
+ status: null, // null | "running" | "done" | "failed" | "cancelled"
56
+ errorCode: null,
57
+ errorMessage: null,
58
+ eventSource: null,
59
+ inProgress: false,
60
+ };
61
+
62
+ // ── i18n helper · falls back to provided defaults ─────────────
63
+ function tx(key, vars, fallback) {
64
+ try {
65
+ const I = root.I18n;
66
+ if (I && typeof I.t === "function") {
67
+ const out = I.t(key, vars || {});
68
+ if (out && out !== key) return out;
69
+ }
70
+ } catch { /* */ }
71
+ let s = fallback || key;
72
+ if (vars) for (const k of Object.keys(vars)) s = s.replace(new RegExp("\\{" + k + "\\}", "g"), String(vars[k]));
73
+ return s;
74
+ }
75
+
76
+ function escape(s) {
77
+ return String(s == null ? "" : s)
78
+ .replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;")
79
+ .replaceAll("\"", "&quot;").replaceAll("'", "&#39;");
80
+ }
81
+
82
+ // ── Public API ────────────────────────────────────────────────
83
+ function open(opts) {
84
+ opts = opts || {};
85
+ if (STATE.inProgress) {
86
+ // A job is already running. Restore the overlay (it might
87
+ // already be the same agent's job) so the user can manage it.
88
+ restore();
89
+ return;
90
+ }
91
+ STATE.agentId = String(opts.agentId || "");
92
+ STATE.agentName = String(opts.agentName || "");
93
+ STATE.onApplied = typeof opts.onApplied === "function" ? opts.onApplied : null;
94
+ STATE.selectedFile = null;
95
+ STATE.selectedFileName = "";
96
+ STATE.selectedIsVideo = false;
97
+ STATE.sourceMode = "upload";
98
+ STATE.decodedAudio = null;
99
+ STATE.trimStart = 0;
100
+ STATE.trimEnd = 0;
101
+ tearDownRecorder();
102
+ STATE.recordBlob = null;
103
+ STATE.label = "";
104
+ STATE.miniMaxGroupId = "";
105
+ STATE.clonedVoiceId = "";
106
+ STATE.clonedProvider = "";
107
+ STATE.terminalHandled = false;
108
+ if (STATE.previewAudio) { try { STATE.previewAudio.pause(); } catch { /* */ } STATE.previewAudio = null; }
109
+ STATE.previewBusy = false;
110
+ STATE.jobId = null;
111
+ STATE.stage = "fetch";
112
+ STATE.pct = 0;
113
+ STATE.status = null;
114
+ STATE.errorCode = null;
115
+ STATE.errorMessage = null;
116
+ mountOverlay();
117
+ document.addEventListener("keydown", onKeyDown);
118
+ }
119
+
120
+ function close() {
121
+ document.removeEventListener("keydown", onKeyDown);
122
+ if (STATE.eventSource) { try { STATE.eventSource.close(); } catch { /* */ } STATE.eventSource = null; }
123
+ tearDownRecorder();
124
+ if (STATE.previewAudio) { try { STATE.previewAudio.pause(); } catch { /* */ } STATE.previewAudio = null; }
125
+ if (STATE.overlay) { STATE.overlay.remove(); STATE.overlay = null; }
126
+ if (STATE.pill) { STATE.pill.remove(); STATE.pill = null; }
127
+ STATE.inProgress = false;
128
+ }
129
+
130
+ function minimize() {
131
+ if (!STATE.overlay) return;
132
+ STATE.overlay.classList.add("is-collapsed");
133
+ mountPill();
134
+ }
135
+
136
+ function restore() {
137
+ if (!STATE.overlay) {
138
+ // Edge · job is in-flight but overlay was destroyed (page
139
+ // reload). Mount a progress-stage overlay attached to the
140
+ // existing job id.
141
+ if (STATE.jobId) {
142
+ mountOverlay(/* directlyShowProgress */ true);
143
+ }
144
+ return;
145
+ }
146
+ STATE.overlay.classList.remove("is-collapsed");
147
+ if (STATE.pill) { STATE.pill.remove(); STATE.pill = null; }
148
+ }
149
+
150
+ function cancel() {
151
+ if (!STATE.jobId) {
152
+ close();
153
+ return;
154
+ }
155
+ void fetch(`/api/voice-clone/${encodeURIComponent(STATE.jobId)}`, { method: "DELETE" }).catch(() => {});
156
+ // The SSE channel will receive a `cancelled` event and run the
157
+ // terminal-state path. As a UX fallback, also close immediately
158
+ // since cancel is an intentional dismissal.
159
+ setTimeout(close, 200);
160
+ }
161
+
162
+ function onKeyDown(e) {
163
+ if (e.key !== "Escape") return;
164
+ if (STATE.inProgress) { minimize(); return; }
165
+ close();
166
+ }
167
+
168
+ // ── Overlay DOM ───────────────────────────────────────────────
169
+ function mountOverlay(showProgressOnly) {
170
+ if (STATE.overlay) STATE.overlay.remove();
171
+ const title = tx("voice_clone_modal_title", { name: STATE.agentName }, `Clone voice · ${STATE.agentName}`);
172
+ const labelPlaceholder = tx("voice_clone_label_placeholder", null, "Cloned voice name (optional)");
173
+ const filePick = tx("voice_clone_file_pick", null, "Choose an audio or video file");
174
+ const hint = tx(
175
+ "voice_clone_hint",
176
+ null,
177
+ "Use a clean 10s-3min sample of the target voice. <strong>One voice cloning runs at a time.</strong>",
178
+ );
179
+
180
+ const root = document.createElement("div");
181
+ root.className = "vc-overlay is-open";
182
+ root.innerHTML = `
183
+ <div class="vc-backdrop" data-vc-backdrop></div>
184
+ <div class="vc-panel" role="dialog" aria-modal="true">
185
+ <div class="vc-classification">
186
+ <span>${escape(tx("voice_clone_classification_left", null, "// VOICE CLONE · CLASSIFIED"))}</span>
187
+ <span class="right">${escape(tx("voice_clone_classification_right", null, "private board"))}</span>
188
+ </div>
189
+ <div class="vc-head">
190
+ <div class="vc-title-wrap">
191
+ <div class="meta">${escape(tx("voice_clone_head_meta", null, "// CLONE · ACTIVE"))}</div>
192
+ <div class="title">${escape(title)}</div>
193
+ </div>
194
+ <div class="vc-head-controls">
195
+ <button type="button" class="vc-head-btn" data-vc-minimize aria-label="${escape(tx("voice_clone_minimize", null, "Minimize"))}" title="${escape(tx("voice_clone_minimize", null, "Minimize"))}"></button>
196
+ <button type="button" class="vc-head-btn" data-vc-close aria-label="${escape(tx("voice_clone_close", null, "Close"))}" title="${escape(tx("voice_clone_close", null, "Close"))}"></button>
197
+ </div>
198
+ </div>
199
+ <div class="vc-body" data-vc-body${showProgressOnly ? " hidden" : ""}>
200
+ <p class="vc-section-label">${escape(tx("voice_clone_source_label", null, "Voice source"))}</p>
201
+ <div class="vc-source-modes" role="tablist">
202
+ <button type="button" class="vc-source-mode is-active" data-vc-source-mode="upload" role="tab" aria-selected="true">
203
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="17 8 12 3 7 8"/><line x1="12" y1="3" x2="12" y2="15"/></svg>
204
+ <span>${escape(tx("voice_clone_mode_upload", null, "Upload file"))}</span>
205
+ </button>
206
+ <button type="button" class="vc-source-mode" data-vc-source-mode="record" role="tab" aria-selected="false">
207
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 1a3 3 0 0 0-3 3v8a3 3 0 0 0 6 0V4a3 3 0 0 0-3-3z"/><path d="M19 10v2a7 7 0 0 1-14 0v-2"/><line x1="12" y1="19" x2="12" y2="23"/></svg>
208
+ <span>${escape(tx("voice_clone_mode_record", null, "Record voice"))}</span>
209
+ </button>
210
+ </div>
211
+
212
+ <div class="vc-source-input" data-vc-mode-pane="upload">
213
+ <label class="vc-file-pick" data-vc-file-pick>
214
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="17 8 12 3 7 8"/><line x1="12" y1="3" x2="12" y2="15"/></svg>
215
+ <span class="vc-file-name" data-vc-file-name>${escape(filePick)}</span>
216
+ <input type="file" accept="audio/*,video/*,.mp3,.m4a,.wav,.webm,.ogg,.mp4,.mov,.mkv" hidden data-vc-file-input>
217
+ </label>
218
+ <p class="vc-file-hint">${escape(tx("voice_clone_file_hint", null, "Audio: mp3 / m4a / wav · Video: mp4 / mov / webm — we'll pull the audio track in your browser."))}</p>
219
+ <div class="vc-trim" data-vc-file-trim hidden>
220
+ <div class="vc-trim-status" data-vc-trim-status>${escape(tx("voice_clone_trim_decoding", null, "Decoding audio…"))}</div>
221
+ <div class="vc-trim-track" data-vc-trim-track hidden>
222
+ <div class="vc-trim-track-fill" data-vc-trim-fill></div>
223
+ <input type="range" class="vc-trim-range vc-trim-range-start" data-vc-trim-start min="0" max="100" step="0.1" value="0">
224
+ <input type="range" class="vc-trim-range vc-trim-range-end" data-vc-trim-end min="0" max="100" step="0.1" value="100">
225
+ </div>
226
+ <div class="vc-trim-meta" data-vc-trim-meta hidden>
227
+ <span data-vc-trim-start-label>0:00</span>
228
+ <span class="vc-trim-meta-sep">→</span>
229
+ <span data-vc-trim-end-label>0:00</span>
230
+ <span class="vc-trim-meta-dur" data-vc-trim-dur-label></span>
231
+ </div>
232
+ </div>
233
+ </div>
234
+
235
+ <div class="vc-source-input" data-vc-mode-pane="record" hidden>
236
+ <p class="vc-record-script">${escape(tx("voice_clone_record_script", null, "Read this aloud — it covers a wide phoneme range so the clone captures your timbre well:"))}</p>
237
+ <blockquote class="vc-record-script-text">${escape(tx("voice_clone_record_script_text", null, "The quick brown fox jumps over the lazy dog. She sells seashells by the seashore. How vexingly quick daft zebras jump! Sphinx of black quartz, judge my vow."))}</blockquote>
238
+ <div class="vc-record-stage">
239
+ <button type="button" class="vc-record-btn" data-vc-record-toggle aria-label="${escape(tx("voice_clone_record_start", null, "Start recording"))}">
240
+ <span class="vc-record-glyph" data-vc-record-glyph>●</span>
241
+ <span class="vc-record-ring" aria-hidden="true"></span>
242
+ </button>
243
+ <div class="vc-record-meta">
244
+ <span class="vc-record-time" data-vc-record-time>0:00</span>
245
+ <span class="vc-record-state" data-vc-record-state>${escape(tx("voice_clone_record_idle", null, "Tap to record"))}</span>
246
+ </div>
247
+ <div class="vc-record-level" aria-hidden="true">
248
+ <i></i><i></i><i></i><i></i><i></i><i></i><i></i><i></i><i></i><i></i><i></i><i></i>
249
+ </div>
250
+ </div>
251
+ <div class="vc-record-actions" data-vc-record-actions hidden>
252
+ <button type="button" class="vc-btn" data-vc-record-play>${escape(tx("voice_clone_record_play", null, "Play back"))}</button>
253
+ <button type="button" class="vc-btn" data-vc-record-redo>${escape(tx("voice_clone_record_redo", null, "Re-record"))}</button>
254
+ </div>
255
+ </div>
256
+ <p class="vc-section-label" style="margin-top: 14px;">${escape(tx("voice_clone_label_label", null, "Label (optional)"))}</p>
257
+ <div class="vc-label-input">
258
+ <input type="text" placeholder="${escape(labelPlaceholder)}" data-vc-label maxlength="60">
259
+ </div>
260
+ <p class="vc-section-label" style="margin-top: 14px;" data-vc-mm-group-label>${escape(tx("voice_clone_minimax_group_label", null, "MiniMax Group ID"))}</p>
261
+ <div class="vc-label-input" data-vc-mm-group-wrap>
262
+ <input type="text" placeholder="${escape(tx("voice_clone_minimax_group_placeholder", null, "e.g. 1838xxxxxx · required if your key isn't a JWT"))}" data-vc-mm-group maxlength="64" autocomplete="off" spellcheck="false">
263
+ </div>
264
+ <p class="vc-hint">${hint}</p>
265
+ </div>
266
+ <div class="vc-progress" data-vc-progress${showProgressOnly ? "" : " hidden"}>
267
+ ${progressInnerHtml()}
268
+ </div>
269
+ <div class="vc-success" data-vc-success hidden>
270
+ ${successInnerHtml()}
271
+ </div>
272
+ <div class="vc-foot" data-vc-foot>
273
+ ${footHtml(/* showCancel */ false)}
274
+ </div>
275
+ </div>
276
+ `;
277
+ document.body.appendChild(root);
278
+ STATE.overlay = root;
279
+ wireOverlay(root);
280
+
281
+ if (showProgressOnly && STATE.jobId) {
282
+ // Re-attach to running job: open SSE, update progress UI.
283
+ ensureSse(STATE.jobId);
284
+ updateProgressDom();
285
+ updateFootForRunning();
286
+ }
287
+ }
288
+
289
+ function progressInnerHtml() {
290
+ const stages = [
291
+ { key: "fetch", label: tx("voice_clone_stage_fetch", null, "Fetch audio") },
292
+ { key: "upload", label: tx("voice_clone_stage_upload", null, "Upload to provider") },
293
+ { key: "clone", label: tx("voice_clone_stage_train", null, "Wait for clone") },
294
+ ];
295
+ return `
296
+ <div class="vc-stage-row">
297
+ ${stages.map((s, i) => `
298
+ <div class="vc-step" data-vc-step="${s.key}">
299
+ <span class="vc-step-num">${i + 1}</span>
300
+ <span class="vc-step-label">${escape(s.label)}</span>
301
+ <span class="vc-step-pct" data-vc-step-pct="${s.key}">0%</span>
302
+ <div class="vc-step-bar"><div class="vc-step-bar-fill" data-vc-step-fill="${s.key}"></div></div>
303
+ </div>
304
+ `).join("")}
305
+ </div>
306
+ <p class="vc-stage-text" data-vc-stage-text></p>
307
+ `;
308
+ }
309
+
310
+ function successInnerHtml() {
311
+ const samplePlaceholder = tx("voice_clone_preview_sample_placeholder", null, "Sample line for preview");
312
+ return `
313
+ <div class="vc-success-head">
314
+ <div class="vc-success-kicker">${escape(tx("voice_clone_success_kicker", null, "// CLONED"))}</div>
315
+ <div class="vc-success-title" data-vc-success-title></div>
316
+ </div>
317
+ <button type="button" class="vc-preview-btn" data-vc-preview aria-label="${escape(tx("voice_clone_preview_btn_aria", null, "Preview cloned voice"))}">
318
+ <span class="vc-preview-glyph" data-vc-preview-glyph>▶</span>
319
+ <span class="vc-preview-dots" aria-hidden="true"><i></i><i></i><i></i></span>
320
+ </button>
321
+ <textarea
322
+ class="vc-preview-text"
323
+ data-vc-preview-text
324
+ rows="3"
325
+ maxlength="240"
326
+ placeholder="${escape(samplePlaceholder)}"
327
+ ></textarea>
328
+ <p class="vc-preview-hint">${escape(tx("voice_clone_preview_hint", null, "Edit the line above, then tap the play button to hear the cloned voice."))}</p>
329
+ `;
330
+ }
331
+
332
+ function footHtml(showRunning) {
333
+ if (showRunning) {
334
+ return `
335
+ <button type="button" class="vc-btn" data-vc-cancel>${escape(tx("voice_clone_cancel", null, "Cancel"))}</button>
336
+ <button type="button" class="vc-btn vc-btn-primary" data-vc-minimize-btn>${escape(tx("voice_clone_minimize_btn", null, "Run in background"))}</button>
337
+ `;
338
+ }
339
+ return `
340
+ <button type="button" class="vc-btn" data-vc-dismiss>${escape(tx("voice_clone_dismiss", null, "Cancel"))}</button>
341
+ <button type="button" class="vc-btn vc-btn-primary" data-vc-confirm disabled>${escape(tx("voice_clone_confirm", null, "Start cloning"))}</button>
342
+ `;
343
+ }
344
+
345
+ function footTerminalHtml(isDone) {
346
+ if (isDone) {
347
+ return `
348
+ <button type="button" class="vc-btn vc-btn-primary" data-vc-close>${escape(tx("voice_clone_apply_close_btn", null, "Apply and close"))}</button>
349
+ `;
350
+ }
351
+ return `
352
+ <button type="button" class="vc-btn" data-vc-close>${escape(tx("voice_clone_dismiss", null, "Cancel"))}</button>
353
+ <button type="button" class="vc-btn vc-btn-primary" data-vc-retry>${escape(tx("voice_clone_retry", null, "Retry"))}</button>
354
+ `;
355
+ }
356
+
357
+ function wireOverlay(root) {
358
+ root.querySelector("[data-vc-backdrop]").addEventListener("click", () => {
359
+ if (STATE.inProgress) minimize();
360
+ else close();
361
+ });
362
+ root.querySelector("[data-vc-minimize]").addEventListener("click", minimize);
363
+ root.querySelector("[data-vc-close]").addEventListener("click", () => {
364
+ if (STATE.inProgress) { minimize(); return; }
365
+ close();
366
+ });
367
+
368
+ // Mode switcher · upload vs record. Switching tears down any
369
+ // open mic stream so we don't keep an active capture while the
370
+ // user is fiddling with the file picker.
371
+ const modeBtns = root.querySelectorAll("[data-vc-source-mode]");
372
+ modeBtns.forEach((btn) => {
373
+ btn.addEventListener("click", () => {
374
+ const mode = btn.getAttribute("data-vc-source-mode");
375
+ if (mode === STATE.sourceMode) return;
376
+ switchSourceMode(root, mode);
377
+ });
378
+ });
379
+
380
+ // Record-mode controls
381
+ const recordBtn = root.querySelector("[data-vc-record-toggle]");
382
+ const recordPlay = root.querySelector("[data-vc-record-play]");
383
+ const recordRedo = root.querySelector("[data-vc-record-redo]");
384
+ if (recordBtn) recordBtn.addEventListener("click", toggleRecording);
385
+ if (recordPlay) recordPlay.addEventListener("click", playRecording);
386
+ if (recordRedo) recordRedo.addEventListener("click", () => {
387
+ tearDownRecorder();
388
+ STATE.recordBlob = null;
389
+ STATE.selectedFile = null;
390
+ STATE.decodedAudio = null;
391
+ hydrateTrimPanel(root, null);
392
+ const actions = root.querySelector("[data-vc-record-actions]");
393
+ if (actions) actions.hidden = true;
394
+ const stateLabel = root.querySelector("[data-vc-record-state]");
395
+ if (stateLabel) stateLabel.textContent = tx("voice_clone_record_idle", null, "Tap to record");
396
+ const timeEl = root.querySelector("[data-vc-record-time]");
397
+ if (timeEl) timeEl.textContent = "0:00";
398
+ refreshConfirmState();
399
+ });
400
+
401
+ // File input · on pick we decode via Web Audio API so the user
402
+ // can preview the duration and pick a trim window. Works for
403
+ // both audio (mp3 / m4a / wav / webm) and video (mp4 / mov /
404
+ // webm) containers — the browser's `decodeAudioData` pulls the
405
+ // audio track out of the video file for us.
406
+ const fileInput = root.querySelector("[data-vc-file-input]");
407
+ const filePick = root.querySelector("[data-vc-file-pick]");
408
+ const fileNameEl = root.querySelector("[data-vc-file-name]");
409
+ fileInput.addEventListener("change", () => {
410
+ const f = fileInput.files && fileInput.files[0];
411
+ STATE.selectedFile = f || null;
412
+ STATE.selectedFileName = f ? f.name : "";
413
+ STATE.selectedIsVideo = !!(f && f.type && f.type.startsWith("video/"));
414
+ STATE.decodedAudio = null;
415
+ STATE.trimStart = 0;
416
+ STATE.trimEnd = 0;
417
+ fileNameEl.textContent = f ? f.name : tx("voice_clone_file_pick", null, "Choose an audio or video file");
418
+ filePick.classList.toggle("has-file", !!f);
419
+ hydrateTrimPanel(root, f);
420
+ refreshConfirmState();
421
+ });
422
+
423
+ // Label · required, used as the cloned voice's display name in
424
+ // the picker. The Confirm button stays disabled until both file
425
+ // and label are present (see refreshConfirmState).
426
+ const labelInput = root.querySelector("[data-vc-label]");
427
+ labelInput.addEventListener("input", () => {
428
+ STATE.label = labelInput.value;
429
+ refreshConfirmState();
430
+ });
431
+
432
+ // MiniMax Group ID · pre-fill from localStorage so the user only
433
+ // ever has to type it once. Persisted on confirm; cleared on
434
+ // explicit user blank-out.
435
+ const groupInput = root.querySelector("[data-vc-mm-group]");
436
+ if (groupInput) {
437
+ try {
438
+ const remembered = localStorage.getItem("pb.voice-clone.minimax-group-id") || "";
439
+ if (remembered) {
440
+ groupInput.value = remembered;
441
+ STATE.miniMaxGroupId = remembered;
442
+ }
443
+ } catch { /* */ }
444
+ groupInput.addEventListener("input", () => {
445
+ STATE.miniMaxGroupId = groupInput.value.trim();
446
+ });
447
+ }
448
+
449
+ wireFoot();
450
+ }
451
+
452
+ function wireFoot() {
453
+ const root = STATE.overlay;
454
+ if (!root) return;
455
+ const foot = root.querySelector("[data-vc-foot]");
456
+ foot.querySelectorAll("[data-vc-confirm]").forEach((b) => b.addEventListener("click", confirmStart));
457
+ foot.querySelectorAll("[data-vc-dismiss]").forEach((b) => b.addEventListener("click", close));
458
+ foot.querySelectorAll("[data-vc-cancel]").forEach((b) => b.addEventListener("click", cancel));
459
+ foot.querySelectorAll("[data-vc-minimize-btn]").forEach((b) => b.addEventListener("click", minimize));
460
+ foot.querySelectorAll("[data-vc-retry]").forEach((b) => b.addEventListener("click", retry));
461
+ foot.querySelectorAll("[data-vc-close]").forEach((b) => b.addEventListener("click", close));
462
+ }
463
+
464
+ // ── Recording helpers ────────────────────────────────────────
465
+ function switchSourceMode(root, mode) {
466
+ STATE.sourceMode = mode;
467
+ root.querySelectorAll("[data-vc-source-mode]").forEach((b) => {
468
+ const active = b.getAttribute("data-vc-source-mode") === mode;
469
+ b.classList.toggle("is-active", active);
470
+ b.setAttribute("aria-selected", active ? "true" : "false");
471
+ });
472
+ root.querySelectorAll("[data-vc-mode-pane]").forEach((p) => {
473
+ p.hidden = p.getAttribute("data-vc-mode-pane") !== mode;
474
+ });
475
+ // Wipe inputs on switch so confirm state matches the visible mode.
476
+ if (mode === "upload") {
477
+ // Free the mic stream when leaving record mode.
478
+ tearDownRecorder();
479
+ STATE.recordBlob = null;
480
+ } else {
481
+ STATE.selectedFile = null;
482
+ STATE.selectedFileName = "";
483
+ STATE.decodedAudio = null;
484
+ const fileInput = root.querySelector("[data-vc-file-input]");
485
+ if (fileInput) fileInput.value = "";
486
+ const fileNameEl = root.querySelector("[data-vc-file-name]");
487
+ if (fileNameEl) fileNameEl.textContent = tx("voice_clone_file_pick", null, "Choose an audio or video file");
488
+ const filePick = root.querySelector("[data-vc-file-pick]");
489
+ if (filePick) filePick.classList.remove("has-file");
490
+ hydrateTrimPanel(root, null);
491
+ }
492
+ refreshConfirmState();
493
+ }
494
+
495
+ async function toggleRecording() {
496
+ const root = STATE.overlay;
497
+ if (!root) return;
498
+ // If we're currently recording, stop.
499
+ if (STATE.recorder && STATE.recorder.state === "recording") {
500
+ try { STATE.recorder.stop(); } catch { /* */ }
501
+ return;
502
+ }
503
+ // Re-recording · clear last take.
504
+ STATE.recordBlob = null;
505
+ STATE.selectedFile = null;
506
+ STATE.decodedAudio = null;
507
+ const actions = root.querySelector("[data-vc-record-actions]");
508
+ if (actions) actions.hidden = true;
509
+
510
+ let stream;
511
+ try {
512
+ stream = await navigator.mediaDevices.getUserMedia({
513
+ audio: { echoCancellation: true, noiseSuppression: true, autoGainControl: true },
514
+ });
515
+ } catch (e) {
516
+ alert(tx("voice_clone_record_mic_err", { msg: e?.message || String(e) }, `Microphone access denied: ${e?.message || e}`));
517
+ return;
518
+ }
519
+ STATE.recorderStream = stream;
520
+
521
+ const mime = pickRecorderMime();
522
+ let recorder;
523
+ try {
524
+ recorder = new MediaRecorder(stream, mime ? { mimeType: mime } : undefined);
525
+ } catch (e) {
526
+ stream.getTracks().forEach((t) => t.stop());
527
+ alert(tx("voice_clone_record_init_err", { msg: e?.message || String(e) }, `Recorder init failed: ${e?.message || e}`));
528
+ return;
529
+ }
530
+ STATE.recorder = recorder;
531
+ STATE.recordChunks = [];
532
+ STATE.recordStartedAt = performance.now();
533
+
534
+ recorder.addEventListener("dataavailable", (e) => {
535
+ if (e.data && e.data.size > 0) STATE.recordChunks.push(e.data);
536
+ });
537
+ recorder.addEventListener("stop", () => onRecordingStopped(root));
538
+ recorder.start();
539
+
540
+ // UI state
541
+ const btn = root.querySelector("[data-vc-record-toggle]");
542
+ const glyph = root.querySelector("[data-vc-record-glyph]");
543
+ const stateLabel = root.querySelector("[data-vc-record-state]");
544
+ if (btn) btn.classList.add("is-recording");
545
+ if (glyph) glyph.textContent = "■";
546
+ if (stateLabel) stateLabel.textContent = tx("voice_clone_record_recording", null, "Recording… tap to stop");
547
+
548
+ // Running counter + level meter
549
+ if (STATE.recordTimerId) clearInterval(STATE.recordTimerId);
550
+ STATE.recordTimerId = window.setInterval(() => {
551
+ const elapsed = Math.floor((performance.now() - STATE.recordStartedAt) / 1000);
552
+ const timeEl = root.querySelector("[data-vc-record-time]");
553
+ if (timeEl) timeEl.textContent = `${Math.floor(elapsed / 60)}:${String(elapsed % 60).padStart(2, "0")}`;
554
+ // Hard cap at 3 min to keep within MiniMax/ElevenLabs limits.
555
+ if (elapsed >= 180) toggleRecording();
556
+ }, 250);
557
+
558
+ startLevelMeter(stream, root);
559
+ }
560
+
561
+ function onRecordingStopped(root) {
562
+ if (STATE.recordTimerId) { clearInterval(STATE.recordTimerId); STATE.recordTimerId = 0; }
563
+ stopLevelMeter();
564
+ const chunks = STATE.recordChunks || [];
565
+ const mime = (STATE.recorder && STATE.recorder.mimeType) || "audio/webm";
566
+ if (chunks.length === 0) {
567
+ tearDownRecorder();
568
+ const stateLabel = root.querySelector("[data-vc-record-state]");
569
+ if (stateLabel) stateLabel.textContent = tx("voice_clone_record_empty", null, "No audio captured. Try again.");
570
+ return;
571
+ }
572
+ const blob = new Blob(chunks, { type: mime });
573
+ STATE.recordBlob = blob;
574
+ // Treat the recording as the selected source. The same code path
575
+ // that handles file-mode trim + WAV encode kicks in next.
576
+ STATE.selectedFile = new File([blob], `recording.${mime.includes("webm") ? "webm" : "audio"}`, { type: mime });
577
+ STATE.selectedFileName = STATE.selectedFile.name;
578
+ STATE.selectedIsVideo = false;
579
+
580
+ // Stop the mic stream now that we've captured chunks.
581
+ if (STATE.recorderStream) {
582
+ STATE.recorderStream.getTracks().forEach((t) => t.stop());
583
+ STATE.recorderStream = null;
584
+ }
585
+ STATE.recorder = null;
586
+
587
+ // UI state · idle, show post-record actions.
588
+ const btn = root.querySelector("[data-vc-record-toggle]");
589
+ const glyph = root.querySelector("[data-vc-record-glyph]");
590
+ const stateLabel = root.querySelector("[data-vc-record-state]");
591
+ if (btn) btn.classList.remove("is-recording");
592
+ if (glyph) glyph.textContent = "●";
593
+ if (stateLabel) stateLabel.textContent = tx("voice_clone_record_ready", null, "Recording ready. Confirm to clone.");
594
+ const actions = root.querySelector("[data-vc-record-actions]");
595
+ if (actions) actions.hidden = false;
596
+
597
+ // Run through the same decode + trim pipeline as a file upload —
598
+ // the trim slider can still chop off head/tail silence even on a
599
+ // self-recording. If decode fails (unusual codec) we'll just
600
+ // upload the raw bytes.
601
+ hydrateTrimPanel(root, STATE.selectedFile);
602
+ refreshConfirmState();
603
+ }
604
+
605
+ function pickRecorderMime() {
606
+ const candidates = [
607
+ "audio/webm;codecs=opus",
608
+ "audio/webm",
609
+ "audio/mp4",
610
+ "audio/ogg;codecs=opus",
611
+ ];
612
+ for (const m of candidates) {
613
+ if (typeof MediaRecorder !== "undefined" && MediaRecorder.isTypeSupported && MediaRecorder.isTypeSupported(m)) return m;
614
+ }
615
+ return "";
616
+ }
617
+
618
+ function startLevelMeter(stream, root) {
619
+ try {
620
+ const Ctx = window.AudioContext || window.webkitAudioContext;
621
+ if (!Ctx) return;
622
+ const ctx = new Ctx();
623
+ const src = ctx.createMediaStreamSource(stream);
624
+ const analyser = ctx.createAnalyser();
625
+ analyser.fftSize = 256;
626
+ analyser.smoothingTimeConstant = 0.7;
627
+ src.connect(analyser);
628
+ const data = new Uint8Array(analyser.frequencyBinCount);
629
+ const bars = root.querySelectorAll(".vc-record-level i");
630
+ STATE.recordLevelAudio = { ctx, src, analyser, raf: 0 };
631
+ const tick = () => {
632
+ if (!STATE.recordLevelAudio) return;
633
+ analyser.getByteFrequencyData(data);
634
+ // Aggregate into N bins to match bar count.
635
+ const N = bars.length || 12;
636
+ const step = Math.floor(data.length / N);
637
+ for (let i = 0; i < N; i++) {
638
+ let sum = 0;
639
+ for (let j = 0; j < step; j++) sum += data[i * step + j];
640
+ const avg = sum / step;
641
+ const h = Math.min(100, Math.max(8, (avg / 200) * 100));
642
+ if (bars[i]) bars[i].style.height = `${h}%`;
643
+ }
644
+ STATE.recordLevelAudio.raf = requestAnimationFrame(tick);
645
+ };
646
+ STATE.recordLevelAudio.raf = requestAnimationFrame(tick);
647
+ } catch { /* level meter is decorative · ignore errors */ }
648
+ }
649
+
650
+ function stopLevelMeter() {
651
+ const m = STATE.recordLevelAudio;
652
+ if (!m) return;
653
+ if (m.raf) cancelAnimationFrame(m.raf);
654
+ try { m.src.disconnect(); } catch { /* */ }
655
+ try { m.ctx.close(); } catch { /* */ }
656
+ STATE.recordLevelAudio = null;
657
+ // Decay the bars to baseline.
658
+ const bars = STATE.overlay ? STATE.overlay.querySelectorAll(".vc-record-level i") : [];
659
+ bars.forEach((b) => { b.style.height = "12%"; });
660
+ }
661
+
662
+ function tearDownRecorder() {
663
+ if (STATE.recordTimerId) { clearInterval(STATE.recordTimerId); STATE.recordTimerId = 0; }
664
+ stopLevelMeter();
665
+ if (STATE.recorder && STATE.recorder.state === "recording") {
666
+ try { STATE.recorder.stop(); } catch { /* */ }
667
+ }
668
+ if (STATE.recorderStream) {
669
+ STATE.recorderStream.getTracks().forEach((t) => t.stop());
670
+ STATE.recorderStream = null;
671
+ }
672
+ STATE.recorder = null;
673
+ STATE.recordChunks = [];
674
+ if (STATE.recordPreviewAudio) {
675
+ try { STATE.recordPreviewAudio.pause(); } catch { /* */ }
676
+ STATE.recordPreviewAudio = null;
677
+ }
678
+ }
679
+
680
+ function playRecording() {
681
+ if (!STATE.recordBlob) return;
682
+ if (STATE.recordPreviewAudio) {
683
+ try { STATE.recordPreviewAudio.pause(); } catch { /* */ }
684
+ STATE.recordPreviewAudio = null;
685
+ }
686
+ const url = URL.createObjectURL(STATE.recordBlob);
687
+ const audio = new Audio(url);
688
+ STATE.recordPreviewAudio = audio;
689
+ audio.addEventListener("ended", () => {
690
+ try { URL.revokeObjectURL(url); } catch { /* */ }
691
+ STATE.recordPreviewAudio = null;
692
+ });
693
+ audio.play().catch(() => { /* */ });
694
+ }
695
+
696
+ // ── Trim helpers ─────────────────────────────────────────────
697
+ function formatMmSs(secs) {
698
+ const s = Math.max(0, Math.floor(secs || 0));
699
+ const m = Math.floor(s / 60);
700
+ return `${m}:${String(s % 60).padStart(2, "0")}`;
701
+ }
702
+
703
+ async function hydrateTrimPanel(root, file) {
704
+ const wrap = root.querySelector("[data-vc-file-trim]");
705
+ const status = root.querySelector("[data-vc-trim-status]");
706
+ const track = root.querySelector("[data-vc-trim-track]");
707
+ const meta = root.querySelector("[data-vc-trim-meta]");
708
+ if (!wrap || !status || !track || !meta) return;
709
+ if (!file) {
710
+ wrap.hidden = true;
711
+ return;
712
+ }
713
+ wrap.hidden = false;
714
+ track.hidden = true;
715
+ meta.hidden = true;
716
+ status.textContent = tx("voice_clone_trim_decoding", null, "Decoding audio…");
717
+
718
+ try {
719
+ const buf = await file.arrayBuffer();
720
+ const Ctx = window.AudioContext || window.webkitAudioContext;
721
+ if (!Ctx) throw new Error("Web Audio API unavailable");
722
+ const ctx = new Ctx();
723
+ const audio = await new Promise((resolve, reject) => {
724
+ ctx.decodeAudioData(buf.slice(0), resolve, reject);
725
+ });
726
+ try { ctx.close(); } catch { /* */ }
727
+ STATE.decodedAudio = audio;
728
+ const duration = audio.duration;
729
+ STATE.trimStart = 0;
730
+ // Default selection · first 90 s (or full file if shorter), the
731
+ // sweet spot for MiniMax / ElevenLabs voice cloning.
732
+ STATE.trimEnd = Math.min(duration, 90);
733
+
734
+ const startEl = root.querySelector("[data-vc-trim-start]");
735
+ const endEl = root.querySelector("[data-vc-trim-end]");
736
+ startEl.min = "0";
737
+ startEl.max = String(duration);
738
+ startEl.step = duration > 300 ? "1" : "0.1";
739
+ startEl.value = "0";
740
+ endEl.min = "0";
741
+ endEl.max = String(duration);
742
+ endEl.step = duration > 300 ? "1" : "0.1";
743
+ endEl.value = String(STATE.trimEnd);
744
+
745
+ const onSlide = () => {
746
+ let s = parseFloat(startEl.value);
747
+ let e = parseFloat(endEl.value);
748
+ if (!Number.isFinite(s)) s = 0;
749
+ if (!Number.isFinite(e)) e = duration;
750
+ // Maintain ≥3s window so the user can't drag the handles
751
+ // past each other into nothingness.
752
+ const MIN_WIN = 3;
753
+ if (e - s < MIN_WIN) {
754
+ if (document.activeElement === startEl) s = Math.max(0, e - MIN_WIN);
755
+ else e = Math.min(duration, s + MIN_WIN);
756
+ startEl.value = String(s);
757
+ endEl.value = String(e);
758
+ }
759
+ STATE.trimStart = s;
760
+ STATE.trimEnd = e;
761
+ repaintTrimMeta(root, duration);
762
+ };
763
+ startEl.addEventListener("input", onSlide);
764
+ endEl.addEventListener("input", onSlide);
765
+
766
+ status.textContent = "";
767
+ status.hidden = true;
768
+ track.hidden = false;
769
+ meta.hidden = false;
770
+ repaintTrimMeta(root, duration);
771
+ } catch (e) {
772
+ // Decode failed. For video files this means the browser
773
+ // couldn't read the audio track out of the container (rare —
774
+ // mp4 / mov / webm with AAC or Opus audio normally work in
775
+ // Chromium and Safari; .mkv or unusual codecs are the most
776
+ // common offenders). Block confirm rather than upload a video
777
+ // file the provider APIs would reject.
778
+ track.hidden = true;
779
+ meta.hidden = true;
780
+ if (STATE.selectedIsVideo) {
781
+ status.textContent = tx(
782
+ "voice_clone_trim_video_unsupported",
783
+ null,
784
+ "Couldn't extract audio from this video. Re-export it as mp4/mov (AAC) or convert to a plain audio file (mp3 / m4a / wav) and try again.",
785
+ );
786
+ // Force the user to re-pick · we cannot ship the raw video bytes.
787
+ STATE.selectedFile = null;
788
+ STATE.selectedFileName = "";
789
+ refreshConfirmState();
790
+ } else {
791
+ status.textContent = tx(
792
+ "voice_clone_trim_unsupported",
793
+ null,
794
+ "Can't preview this format; the file will be uploaded as-is.",
795
+ );
796
+ }
797
+ }
798
+ }
799
+
800
+ function repaintTrimMeta(root, duration) {
801
+ const startLab = root.querySelector("[data-vc-trim-start-label]");
802
+ const endLab = root.querySelector("[data-vc-trim-end-label]");
803
+ const durLab = root.querySelector("[data-vc-trim-dur-label]");
804
+ const fill = root.querySelector("[data-vc-trim-fill]");
805
+ if (startLab) startLab.textContent = formatMmSs(STATE.trimStart);
806
+ if (endLab) endLab.textContent = formatMmSs(STATE.trimEnd);
807
+ if (durLab) {
808
+ const sel = Math.max(0, STATE.trimEnd - STATE.trimStart);
809
+ durLab.textContent = tx("voice_clone_trim_selected", { sel: formatMmSs(sel), total: formatMmSs(duration) }, `· ${formatMmSs(sel)} of ${formatMmSs(duration)}`);
810
+ }
811
+ if (fill && duration > 0) {
812
+ const startPct = (STATE.trimStart / duration) * 100;
813
+ const endPct = (STATE.trimEnd / duration) * 100;
814
+ fill.style.left = `${startPct}%`;
815
+ fill.style.right = `${100 - endPct}%`;
816
+ }
817
+ }
818
+
819
+ // PCM16 WAV encoder · no external deps. Used to ship the trimmed
820
+ // AudioBuffer to /api/voice-clone/upload as `audio/wav`. MiniMax
821
+ // and ElevenLabs both accept WAV cleanly.
822
+ function encodeWavPcm16(audioBuffer) {
823
+ const numCh = audioBuffer.numberOfChannels;
824
+ const sr = audioBuffer.sampleRate;
825
+ const numFrames = audioBuffer.length;
826
+ const dataLen = numFrames * numCh * 2;
827
+ const buffer = new ArrayBuffer(44 + dataLen);
828
+ const view = new DataView(buffer);
829
+ const writeStr = (off, s) => { for (let i = 0; i < s.length; i++) view.setUint8(off + i, s.charCodeAt(i)); };
830
+ writeStr(0, "RIFF");
831
+ view.setUint32(4, 36 + dataLen, true);
832
+ writeStr(8, "WAVE");
833
+ writeStr(12, "fmt ");
834
+ view.setUint32(16, 16, true);
835
+ view.setUint16(20, 1, true); // PCM
836
+ view.setUint16(22, numCh, true);
837
+ view.setUint32(24, sr, true);
838
+ view.setUint32(28, sr * numCh * 2, true); // byte rate
839
+ view.setUint16(32, numCh * 2, true); // block align
840
+ view.setUint16(34, 16, true); // bits per sample
841
+ writeStr(36, "data");
842
+ view.setUint32(40, dataLen, true);
843
+ const channels = [];
844
+ for (let c = 0; c < numCh; c++) channels.push(audioBuffer.getChannelData(c));
845
+ let off = 44;
846
+ for (let i = 0; i < numFrames; i++) {
847
+ for (let c = 0; c < numCh; c++) {
848
+ let sample = channels[c][i];
849
+ sample = Math.max(-1, Math.min(1, sample));
850
+ view.setInt16(off, sample < 0 ? sample * 0x8000 : sample * 0x7FFF, true);
851
+ off += 2;
852
+ }
853
+ }
854
+ return new Blob([buffer], { type: "audio/wav" });
855
+ }
856
+
857
+ async function trimToWavBlob(audioBuffer, startSec, endSec) {
858
+ const sr = audioBuffer.sampleRate;
859
+ const numCh = audioBuffer.numberOfChannels;
860
+ const durSec = Math.max(0.5, endSec - startSec);
861
+ const numFrames = Math.floor(durSec * sr);
862
+ const OffCtx = window.OfflineAudioContext || window.webkitOfflineAudioContext;
863
+ const ctx = new OffCtx(numCh, numFrames, sr);
864
+ const src = ctx.createBufferSource();
865
+ src.buffer = audioBuffer;
866
+ src.connect(ctx.destination);
867
+ src.start(0, startSec, durSec);
868
+ const trimmed = await ctx.startRendering();
869
+ return encodeWavPcm16(trimmed);
870
+ }
871
+
872
+ function refreshConfirmState() {
873
+ const root = STATE.overlay;
874
+ if (!root) return;
875
+ const confirmBtn = root.querySelector("[data-vc-confirm]");
876
+ if (!confirmBtn) return;
877
+ const hasFile = !!STATE.selectedFile;
878
+ const hasLabel = !!(STATE.label && STATE.label.trim());
879
+ confirmBtn.disabled = !(hasFile && hasLabel);
880
+ }
881
+
882
+ // ── Start / progress flow ─────────────────────────────────────
883
+ async function confirmStart() {
884
+ const root = STATE.overlay;
885
+ if (!root) return;
886
+ const confirmBtn = root.querySelector("[data-vc-confirm]");
887
+ if (confirmBtn) confirmBtn.disabled = true;
888
+
889
+ // Switch to progress view eagerly so the user sees feedback
890
+ // even before the network round-trip resolves.
891
+ root.querySelector("[data-vc-body]").hidden = true;
892
+ root.querySelector("[data-vc-progress]").hidden = false;
893
+ updateFootForRunning();
894
+
895
+ try {
896
+ // Upload local file (audio or video; if video, the browser
897
+ // already decoded its audio track into STATE.decodedAudio and
898
+ // we ship a trimmed WAV instead of the original container).
899
+ setStageText(tx("voice_clone_uploading_file", null, "Uploading file…"));
900
+ let blob, name;
901
+ if (STATE.decodedAudio) {
902
+ // Trim selection to a WAV blob in the browser before upload.
903
+ // For video inputs this is also our audio-extract step —
904
+ // OfflineAudioContext renders just the audio track out, no
905
+ // video data leaves the browser. Keeps the payload small
906
+ // (~5MB per minute @ 44.1k mono) so it stays under MiniMax
907
+ // 20MB / ElevenLabs 10MB caps even when the source was a
908
+ // 30-minute lecture or screen recording.
909
+ setStageText(tx("voice_clone_trimming", null, "Trimming selection…"));
910
+ blob = await trimToWavBlob(STATE.decodedAudio, STATE.trimStart, STATE.trimEnd);
911
+ name = (STATE.selectedFileName || "source").replace(/\.[^.]+$/, "") + "-trim.wav";
912
+ setStageText(tx("voice_clone_uploading_file", null, "Uploading file…"));
913
+ } else {
914
+ // Decoder failed earlier · just upload the original bytes.
915
+ blob = STATE.selectedFile;
916
+ name = STATE.selectedFile.name;
917
+ }
918
+ const fd = new FormData();
919
+ fd.append("file", blob, name);
920
+ const upRes = await fetch("/api/voice-clone/upload", { method: "POST", body: fd });
921
+ if (!upRes.ok) throw new Error("upload failed");
922
+ const upJson = await upRes.json();
923
+ const source = { kind: "file", filePath: upJson.filePath };
924
+
925
+ // Persist the Group ID so the next clone doesn't ask again.
926
+ try {
927
+ if (STATE.miniMaxGroupId) localStorage.setItem("pb.voice-clone.minimax-group-id", STATE.miniMaxGroupId);
928
+ } catch { /* */ }
929
+
930
+ const startRes = await fetch("/api/voice-clone/start", {
931
+ method: "POST",
932
+ headers: { "content-type": "application/json" },
933
+ body: JSON.stringify({
934
+ agentId: STATE.agentId,
935
+ source,
936
+ label: STATE.label,
937
+ miniMaxGroupId: STATE.miniMaxGroupId || undefined,
938
+ }),
939
+ });
940
+ if (!startRes.ok) {
941
+ const err = await startRes.json().catch(() => ({ error: "unknown" }));
942
+ throw new Error(String(err.error || `HTTP ${startRes.status}`));
943
+ }
944
+ const { jobId } = await startRes.json();
945
+ STATE.jobId = jobId;
946
+ STATE.inProgress = true;
947
+ ensureSse(jobId);
948
+ } catch (e) {
949
+ setStageText(String(e && e.message || e), true);
950
+ updateFootForTerminal(false);
951
+ }
952
+ }
953
+
954
+ function ensureSse(jobId) {
955
+ if (STATE.eventSource) { try { STATE.eventSource.close(); } catch { /* */ } STATE.eventSource = null; }
956
+ const es = new EventSource(`/api/voice-clone/${encodeURIComponent(jobId)}/stream`);
957
+ es.addEventListener("snapshot", (ev) => onProgressEvent(JSON.parse(ev.data)));
958
+ es.addEventListener("progress", (ev) => onProgressEvent(JSON.parse(ev.data)));
959
+ es.addEventListener("end", (ev) => {
960
+ try { es.close(); } catch { /* */ }
961
+ STATE.eventSource = null;
962
+ onTerminal(JSON.parse(ev.data));
963
+ });
964
+ es.onerror = () => {
965
+ // Transient errors trigger an auto-reconnect by EventSource;
966
+ // we don't need to do anything here. Only act on `end`.
967
+ };
968
+ STATE.eventSource = es;
969
+ }
970
+
971
+ function onProgressEvent(ev) {
972
+ STATE.jobId = ev.jobId;
973
+ STATE.stage = ev.stage;
974
+ STATE.pct = typeof ev.pct === "number" ? ev.pct : 0;
975
+ STATE.status = ev.status;
976
+ if (ev.message) setStageText(ev.message);
977
+ if (ev.status === "running" || ev.status === "queued") STATE.inProgress = true;
978
+ updateProgressDom();
979
+ updatePillDom();
980
+ if (ev.status === "done") onTerminal({ jobId: ev.jobId, status: "done", voiceId: ev.voiceId, label: ev.message, provider: ev.provider });
981
+ if (ev.status === "failed" || ev.status === "cancelled") {
982
+ STATE.errorCode = ev.errorCode || null;
983
+ STATE.errorMessage = ev.errorMessage || null;
984
+ onTerminal({ jobId: ev.jobId, status: ev.status });
985
+ }
986
+ }
987
+
988
+ function onTerminal(payload) {
989
+ STATE.inProgress = false;
990
+ STATE.status = payload.status;
991
+ if (payload.status === "done") {
992
+ // CRITICAL · onTerminal fires TWICE on done · once from the
993
+ // `progress` SSE event (full payload with voiceId/provider),
994
+ // once again from the `end` SSE event (server side emits only
995
+ // `{ jobId, status }` there). Only update STATE when the
996
+ // payload actually carries a value · the previous code wrote
997
+ // `STATE.clonedVoiceId = payload.voiceId || ""` which the end
998
+ // event quietly cleared, so by the time the user pressed the
999
+ // preview button STATE.clonedVoiceId was empty and the
1000
+ // playPreview short-circuit fired before a single byte of
1001
+ // audio left the browser. Same defensive pattern for provider.
1002
+ if (payload.voiceId) STATE.clonedVoiceId = payload.voiceId;
1003
+ if (payload.provider) STATE.clonedProvider = payload.provider;
1004
+ else if (!STATE.clonedProvider) STATE.clonedProvider = "minimax";
1005
+ // Idempotent guard · onTerminal can fire twice on `done`
1006
+ // (progress event + SSE end event). Run the one-shot side
1007
+ // effects (onApplied, success view hydrate, pill auto-close)
1008
+ // only the first time so we don't double-inject picker rows
1009
+ // or re-wire the preview button listener.
1010
+ if (!STATE.terminalHandled) {
1011
+ STATE.terminalHandled = true;
1012
+ // The friendly name is persisted on the server side in the
1013
+ // `voice_labels` table (see routes/voice-clone.ts →
1014
+ // setVoiceLabel) so it survives localStorage clears +
1015
+ // multi-device. We pass it forward to `onApplied` along
1016
+ // with the new voice_id so the caller can optimistically
1017
+ // inject a picker row (the upstream `/v1/get_voice`
1018
+ // catalogue typically takes 10-30s to reflect a brand-new
1019
+ // clone, so without injection the dropdown looks empty
1020
+ // until that propagation lands).
1021
+ if (STATE.onApplied) {
1022
+ try {
1023
+ STATE.onApplied({
1024
+ voiceId: STATE.clonedVoiceId,
1025
+ label: STATE.label || (payload.label || ""),
1026
+ provider: STATE.clonedProvider,
1027
+ });
1028
+ } catch { /* */ }
1029
+ }
1030
+ }
1031
+ }
1032
+ updateProgressDom();
1033
+ updatePillDom();
1034
+ updateFootForTerminal(payload.status === "done");
1035
+ if (payload.status === "done") {
1036
+ // Swap progress view for the success-with-preview view. Both
1037
+ // are idempotent — `hydrateSuccessView` checks the wired-once
1038
+ // flag, the hidden swap is no-op on the second pass.
1039
+ const root = STATE.overlay;
1040
+ if (root) {
1041
+ const prog = root.querySelector("[data-vc-progress]");
1042
+ const succ = root.querySelector("[data-vc-success]");
1043
+ if (prog) prog.hidden = true;
1044
+ if (succ) {
1045
+ succ.hidden = false;
1046
+ hydrateSuccessView(root);
1047
+ }
1048
+ }
1049
+ }
1050
+ }
1051
+
1052
+ /** Populate the success view's title + sample line + wire preview
1053
+ * button to /api/voices/preview. Idempotent · uses a data-attr
1054
+ * marker so onTerminal firing twice doesn't double-bind the
1055
+ * click handler (which would cause two parallel TTS requests on
1056
+ * every tap). */
1057
+ function hydrateSuccessView(root) {
1058
+ const titleEl = root.querySelector("[data-vc-success-title]");
1059
+ const sampleEl = root.querySelector("[data-vc-preview-text]");
1060
+ const playBtn = root.querySelector("[data-vc-preview]");
1061
+ if (!titleEl || !sampleEl || !playBtn) return;
1062
+ const name = (STATE.label && STATE.label.trim()) || tx("voice_clone_preview_default_name", null, "this voice");
1063
+ titleEl.textContent = tx("voice_clone_success_title", { name }, `Cloned · ${name}`);
1064
+ if (!sampleEl.dataset.hydrated) {
1065
+ sampleEl.value = tx("voice_clone_preview_default_text", { name }, `I'm ${name}, a member of your private boardroom. Looking forward to working with you.`);
1066
+ sampleEl.dataset.hydrated = "1";
1067
+ }
1068
+ if (!playBtn.dataset.wired) {
1069
+ playBtn.addEventListener("click", playPreview);
1070
+ playBtn.dataset.wired = "1";
1071
+ }
1072
+ }
1073
+
1074
+ async function playPreview() {
1075
+ const root = STATE.overlay;
1076
+ if (!root) return;
1077
+ // Second tap while playing or loading → stop and reset.
1078
+ if (STATE.previewBusy) {
1079
+ if (STATE.previewAudio) {
1080
+ try { STATE.previewAudio.pause(); } catch { /* */ }
1081
+ STATE.previewAudio = null;
1082
+ }
1083
+ STATE.previewBusy = false;
1084
+ setPreviewBtnState("idle");
1085
+ setPreviewStatus("");
1086
+ return;
1087
+ }
1088
+ const sample = root.querySelector("[data-vc-preview-text]");
1089
+ const text = (sample?.value || "").trim();
1090
+ if (!text) return;
1091
+ if (!STATE.clonedVoiceId) {
1092
+ setPreviewStatus(tx("voice_clone_preview_missing_voice", null, "No voice_id captured — re-open the clone modal."), true);
1093
+ return;
1094
+ }
1095
+ STATE.previewBusy = true;
1096
+ setPreviewBtnState("loading");
1097
+ setPreviewStatus("");
1098
+ try {
1099
+ const provider = STATE.clonedProvider || "minimax";
1100
+ const reqBody = {
1101
+ text,
1102
+ provider,
1103
+ model: provider === "elevenlabs" ? "eleven_multilingual_v2" : "speech-2.8-hd",
1104
+ voiceId: STATE.clonedVoiceId,
1105
+ };
1106
+ console.log("[voice-clone] preview request", reqBody);
1107
+ const res = await fetch("/api/voices/preview", {
1108
+ method: "POST",
1109
+ headers: { "content-type": "application/json" },
1110
+ body: JSON.stringify(reqBody),
1111
+ });
1112
+ if (!res.ok) {
1113
+ let err = {};
1114
+ try { err = await res.json(); } catch { /* */ }
1115
+ const errMsg = err && err.error ? String(err.error) : `HTTP ${res.status}`;
1116
+ console.error("[voice-clone] preview HTTP error", res.status, err);
1117
+ throw new Error(errMsg);
1118
+ }
1119
+ // Endpoint returns `{ audioBase64, mimeType }` JSON. Decode the
1120
+ // base64 into a Blob and serve it through an object URL · a
1121
+ // `data:` URL of the same payload sometimes failed to play on
1122
+ // larger samples (Chrome's media element has a generous but
1123
+ // not unlimited buffer for data URLs) and surfaced as silent
1124
+ // playback with no error event.
1125
+ const json = await res.json();
1126
+ if (!json || !json.audioBase64) {
1127
+ console.error("[voice-clone] preview missing audio", json);
1128
+ throw new Error(json && json.error ? json.error : "no audio in response");
1129
+ }
1130
+ const mime = json.mimeType || "audio/mpeg";
1131
+ const bytes = base64ToBytes(json.audioBase64);
1132
+ const blob = new Blob([bytes], { type: mime });
1133
+ const url = URL.createObjectURL(blob);
1134
+ console.log("[voice-clone] preview audio", { mime, bytes: bytes.length, url });
1135
+ const audio = new Audio(url);
1136
+ audio.preload = "auto";
1137
+ STATE.previewAudio = audio;
1138
+ audio.addEventListener("ended", () => {
1139
+ STATE.previewBusy = false;
1140
+ STATE.previewAudio = null;
1141
+ try { URL.revokeObjectURL(url); } catch { /* */ }
1142
+ setPreviewBtnState("idle");
1143
+ });
1144
+ audio.addEventListener("error", (ev) => {
1145
+ console.error("[voice-clone] audio error", ev, audio.error);
1146
+ STATE.previewBusy = false;
1147
+ STATE.previewAudio = null;
1148
+ try { URL.revokeObjectURL(url); } catch { /* */ }
1149
+ setPreviewBtnState("idle");
1150
+ setPreviewStatus(tx("voice_clone_preview_audio_err", null, "Browser couldn't decode the audio. Try a different sample line."), true);
1151
+ });
1152
+ try {
1153
+ await audio.play();
1154
+ setPreviewBtnState("playing");
1155
+ } catch (e) {
1156
+ console.error("[voice-clone] audio.play() rejected", e);
1157
+ STATE.previewBusy = false;
1158
+ STATE.previewAudio = null;
1159
+ try { URL.revokeObjectURL(url); } catch { /* */ }
1160
+ setPreviewBtnState("idle");
1161
+ setPreviewStatus(String(e?.message || e), true);
1162
+ }
1163
+ } catch (e) {
1164
+ console.error("[voice-clone] preview failed", e);
1165
+ STATE.previewBusy = false;
1166
+ setPreviewBtnState("idle");
1167
+ setPreviewStatus(tx("voice_clone_preview_err", { msg: e?.message || String(e) }, `Preview failed: ${e?.message || e}`), true);
1168
+ }
1169
+ }
1170
+
1171
+ /** base64 → Uint8Array · MiniMax returns audio as a base64 string
1172
+ * (NOT a hex-encoded buffer like the streaming endpoint). We use
1173
+ * `atob` then walk the binary string into a Uint8Array so the
1174
+ * Blob constructor gets actual bytes rather than a re-encoded
1175
+ * utf-8 view. */
1176
+ function base64ToBytes(b64) {
1177
+ const bin = atob(String(b64 || ""));
1178
+ const len = bin.length;
1179
+ const out = new Uint8Array(len);
1180
+ for (let i = 0; i < len; i++) out[i] = bin.charCodeAt(i);
1181
+ return out;
1182
+ }
1183
+
1184
+ /** Write a one-line status note under the preview button. Replaces
1185
+ * the previous alert() flow so the user can read the error in
1186
+ * place + we can show transient propagation hints. */
1187
+ function setPreviewStatus(text, isError) {
1188
+ const root = STATE.overlay;
1189
+ if (!root) return;
1190
+ let el = root.querySelector("[data-vc-preview-status]");
1191
+ if (!el) {
1192
+ el = document.createElement("p");
1193
+ el.setAttribute("data-vc-preview-status", "");
1194
+ el.className = "vc-preview-status";
1195
+ const hint = root.querySelector(".vc-preview-hint");
1196
+ hint?.parentNode?.insertBefore(el, hint.nextSibling);
1197
+ }
1198
+ el.textContent = text || "";
1199
+ el.classList.toggle("is-error", !!isError);
1200
+ el.style.display = text ? "" : "none";
1201
+ }
1202
+
1203
+ function setPreviewBtnState(state) {
1204
+ const btn = STATE.overlay && STATE.overlay.querySelector("[data-vc-preview]");
1205
+ if (!btn) return;
1206
+ btn.classList.remove("is-loading", "is-playing");
1207
+ if (state === "loading") btn.classList.add("is-loading");
1208
+ else if (state === "playing") btn.classList.add("is-playing");
1209
+ const glyph = btn.querySelector("[data-vc-preview-glyph]");
1210
+ if (glyph) glyph.textContent = state === "playing" ? "■" : "▶";
1211
+ }
1212
+
1213
+ // ── DOM updates ───────────────────────────────────────────────
1214
+ function updateProgressDom() {
1215
+ const root = STATE.overlay;
1216
+ if (!root) return;
1217
+ const order = ["fetch", "upload", "clone"];
1218
+ const activeIdx = order.indexOf(STATE.stage);
1219
+ for (let i = 0; i < order.length; i++) {
1220
+ const key = order[i];
1221
+ const step = root.querySelector(`[data-vc-step="${key}"]`);
1222
+ if (!step) continue;
1223
+ step.classList.remove("is-active", "is-done");
1224
+ if (i < activeIdx) step.classList.add("is-done");
1225
+ else if (i === activeIdx) {
1226
+ if (STATE.status === "done") step.classList.add("is-done");
1227
+ else step.classList.add("is-active");
1228
+ }
1229
+ const pctEl = root.querySelector(`[data-vc-step-pct="${key}"]`);
1230
+ const fillEl = root.querySelector(`[data-vc-step-fill="${key}"]`);
1231
+ const localPct = i < activeIdx ? 100
1232
+ : i === activeIdx ? Math.round(((STATE.pct - i * (100 / 3)) * 3))
1233
+ : 0;
1234
+ const clamped = Math.max(0, Math.min(100, localPct));
1235
+ if (pctEl) pctEl.textContent = `${clamped}%`;
1236
+ if (fillEl) fillEl.style.width = `${clamped}%`;
1237
+ }
1238
+ }
1239
+
1240
+ function setStageText(text, isError) {
1241
+ const root = STATE.overlay;
1242
+ if (!root) return;
1243
+ const el = root.querySelector("[data-vc-stage-text]");
1244
+ if (!el) return;
1245
+ el.textContent = text || "";
1246
+ el.classList.toggle("is-error", !!isError);
1247
+ }
1248
+
1249
+ function updateFootForRunning() {
1250
+ const root = STATE.overlay;
1251
+ if (!root) return;
1252
+ const foot = root.querySelector("[data-vc-foot]");
1253
+ foot.innerHTML = footHtml(true);
1254
+ wireFoot();
1255
+ }
1256
+
1257
+ function updateFootForTerminal(isDone) {
1258
+ const root = STATE.overlay;
1259
+ if (!root) return;
1260
+ const foot = root.querySelector("[data-vc-foot]");
1261
+ foot.innerHTML = footTerminalHtml(isDone);
1262
+ wireFoot();
1263
+ if (!isDone) {
1264
+ const msg = STATE.errorMessage || tx("voice_clone_failed", null, "Clone failed.");
1265
+ setStageText(msg, true);
1266
+ } else {
1267
+ setStageText(tx("voice_clone_success", null, "Voice cloned and applied to the director."));
1268
+ }
1269
+ }
1270
+
1271
+ // ── Pill ──────────────────────────────────────────────────────
1272
+ function mountPill() {
1273
+ if (STATE.pill) STATE.pill.remove();
1274
+ const pill = document.createElement("aside");
1275
+ pill.className = "vc-pill";
1276
+ pill.setAttribute("role", "button");
1277
+ pill.setAttribute("tabindex", "0");
1278
+ pill.setAttribute("aria-label", tx("voice_clone_pill_aria", null, "Open voice cloning panel"));
1279
+ pill.innerHTML = `
1280
+ <span class="vc-pill-icon">
1281
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 1a3 3 0 0 0-3 3v8a3 3 0 0 0 6 0V4a3 3 0 0 0-3-3z"/><path d="M19 10v2a7 7 0 0 1-14 0v-2"/><line x1="12" y1="19" x2="12" y2="23"/></svg>
1282
+ </span>
1283
+ <span class="vc-pill-label" data-vc-pill-label>${escape(tx("voice_clone_pill_label", { pct: STATE.pct }, "Cloning"))}</span>
1284
+ <span class="vc-pill-pct" data-vc-pill-pct>${STATE.pct}%</span>
1285
+ <span class="vc-pill-progress"><span class="vc-pill-progress-fill" data-vc-pill-fill style="width: ${STATE.pct}%"></span></span>
1286
+ `;
1287
+ pill.addEventListener("click", restore);
1288
+ pill.addEventListener("keydown", (e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); restore(); } });
1289
+ document.body.appendChild(pill);
1290
+ STATE.pill = pill;
1291
+ updatePillDom();
1292
+ }
1293
+
1294
+ function updatePillDom() {
1295
+ const pill = STATE.pill;
1296
+ if (!pill) return;
1297
+ pill.classList.toggle("is-failed", STATE.status === "failed" || STATE.status === "cancelled");
1298
+ pill.classList.toggle("is-done", STATE.status === "done");
1299
+ const labelEl = pill.querySelector("[data-vc-pill-label]");
1300
+ const pctEl = pill.querySelector("[data-vc-pill-pct]");
1301
+ const fillEl = pill.querySelector("[data-vc-pill-fill]");
1302
+ if (labelEl) {
1303
+ labelEl.textContent = STATE.status === "done"
1304
+ ? tx("voice_clone_pill_done", null, "Cloned")
1305
+ : STATE.status === "failed"
1306
+ ? tx("voice_clone_pill_failed", null, "Clone failed")
1307
+ : tx("voice_clone_pill_label_short", null, "Cloning");
1308
+ }
1309
+ if (pctEl) pctEl.textContent = STATE.status === "done" ? "✓" : `${STATE.pct}%`;
1310
+ if (fillEl) fillEl.style.width = `${STATE.pct}%`;
1311
+ }
1312
+
1313
+ function retry() {
1314
+ // Reset state, keep agentId + source if available.
1315
+ const agentId = STATE.agentId;
1316
+ const agentName = STATE.agentName;
1317
+ const onApplied = STATE.onApplied;
1318
+ close();
1319
+ open({ agentId, agentName, onApplied });
1320
+ }
1321
+
1322
+ // ── Boot · check for an active job and re-attach if present ───
1323
+ async function bootCheck() {
1324
+ try {
1325
+ const res = await fetch("/api/voice-clone/active", { cache: "no-store" });
1326
+ if (!res.ok) return;
1327
+ const j = (await res.json()).job;
1328
+ if (!j) return;
1329
+ STATE.agentId = j.agentId;
1330
+ STATE.jobId = j.id;
1331
+ STATE.stage = j.currentStage;
1332
+ STATE.pct = j.pct;
1333
+ STATE.status = j.status;
1334
+ STATE.inProgress = j.status === "running" || j.status === "queued";
1335
+ if (!STATE.inProgress) return;
1336
+ // Try to lift the agent name from window.app's cache.
1337
+ try {
1338
+ const a = root.app && root.app.agentsById && root.app.agentsById[j.agentId];
1339
+ if (a) STATE.agentName = a.name || "";
1340
+ } catch { /* */ }
1341
+ ensureSse(j.id);
1342
+ mountPill();
1343
+ } catch { /* */ }
1344
+ }
1345
+
1346
+ // ── Public ────────────────────────────────────────────────────
1347
+ root.boardroomVoiceClone = { open, close, minimize, restore };
1348
+
1349
+ if (document.readyState === "complete" || document.readyState === "interactive") setTimeout(bootCheck, 600);
1350
+ else document.addEventListener("DOMContentLoaded", () => setTimeout(bootCheck, 600));
1351
+ })(typeof window !== "undefined" ? window : this);