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.
- package/dist/boot.js +1415 -91
- package/dist/boot.js.map +1 -1
- package/dist/cli.js +1415 -91
- package/dist/cli.js.map +1 -1
- package/dist/server.js +1271 -81
- package/dist/server.js.map +1 -1
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/dist/version.js.map +1 -1
- package/package.json +1 -1
- package/public/__avatar3d_test.html +156 -0
- package/public/adjourn-overlay.css +2 -2
- package/public/agent-overlay.css +27 -15
- package/public/agent-overlay.js +3 -1
- package/public/agent-profile.css +331 -41
- package/public/agent-profile.js +499 -75
- package/public/app-updater.css +1 -1
- package/public/app.js +2090 -547
- package/public/avatar-3d-snap.js +205 -0
- package/public/avatar-3d.js +792 -0
- package/public/avatar-customizer.html +274 -0
- package/public/avatar3d-editor.css +240 -0
- package/public/avatar3d-editor.js +481 -0
- package/public/avatars/3d/chair.png +0 -0
- package/public/avatars/3d/first-principles.png +0 -0
- package/public/avatars/3d/historian.png +0 -0
- package/public/avatars/3d/long-horizon.png +0 -0
- package/public/avatars/3d/phenomenologist.png +0 -0
- package/public/avatars/3d/socrates.png +0 -0
- package/public/avatars/3d/user-empathy.png +0 -0
- package/public/avatars/3d/value-investor.png +0 -0
- package/public/core-avatars.js +86 -0
- package/public/home-3d-loader.js +15 -4
- package/public/home-3d-mock.js +18 -7
- package/public/home.html +80 -18
- package/public/i18n.js +279 -4
- package/public/icons/avatar_1779855104027.glb +0 -0
- package/public/icons/logo.png +0 -0
- package/public/icons/new-style.glb +0 -0
- package/public/icons/new-style2.glb +0 -0
- package/public/icons/new-style3.glb +0 -0
- package/public/icons/new-style4.glb +0 -0
- package/public/icons/new-style5.glb +0 -0
- package/public/icons/office.glb +0 -0
- package/public/icons/stuff.glb +0 -0
- package/public/index.html +203 -182
- package/public/mention-picker.js +1 -1
- package/public/new-agent.css +7 -7
- package/public/new-agent.js +46 -20
- package/public/office-viewer.html +340 -0
- package/public/onboarding.css +5 -5
- package/public/quote-cta.css +5 -4
- package/public/quote-cta.js +50 -5
- package/public/room-settings.css +24 -9
- package/public/stuff-viewer.html +330 -0
- package/public/thread.css +1211 -0
- package/public/user-settings.css +16 -19
- package/public/user-settings.js +86 -78
- package/public/vendor/BufferGeometryUtils.js +1434 -0
- package/public/vendor/DRACOLoader.js +739 -0
- package/public/vendor/GLTFLoader.js +4860 -0
- package/public/vendor/RoomEnvironment.js +185 -0
- package/public/vendor/SkeletonUtils.js +496 -0
- package/public/vendor/draco/draco_decoder.js +34 -0
- package/public/vendor/draco/draco_decoder.wasm +0 -0
- package/public/vendor/draco/draco_encoder.js +33 -0
- package/public/vendor/draco/draco_wasm_wrapper.js +117 -0
- package/public/vendor/meshopt_decoder.module.js +196 -0
- package/public/voice-3d-banner.js +12 -0
- package/public/voice-3d.js +1407 -432
- package/public/voice-clone.css +875 -0
- package/public/voice-clone.js +1351 -0
- package/public/voice-replay.css +3 -3
- package/public/voice-replay.js +21 -0
- package/public/avatar-skill.js +0 -629
- 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("&", "&").replaceAll("<", "<").replaceAll(">", ">")
|
|
79
|
+
.replaceAll("\"", """).replaceAll("'", "'");
|
|
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);
|