| const MAX_TURNS = 50; |
|
|
| const SCRIPT_GEN_MESSAGES = [ |
| "Writing script...", "Still generating...", "Making magic happen...", |
| "Bossing around robot writers...", "Entering the matrix...", "Crafting dialogue...", |
| "Teaching AI to be dramatic...", "Consulting the creative robots...", "Spilling digital ink...", |
| "Herding AI cats into a script...", "Negotiating with the muse...", "Downloading inspiration...", |
| "Warming up the plot engine...", "Shaking the idea tree...", "Feeding the word machine...", |
| "Rehearsing in the green room...", "Untangling narrative spaghetti...", |
| "Summoning fictional characters...", "Tuning the dialogue generator...", |
| "Loading dramatic tension...", "Generating witty banter...", "Almost done, probably...", |
| "Finalizing the masterpiece...", |
| ]; |
|
|
| const PRIMARY_STAGE_MESSAGES = { |
| connecting: ["Submitted", "GPU resources warming up — a cold start can take up to a minute."], |
| queued: ["Queued", "GPU resources warming up — the worker is spinning up."], |
| loading_model: ["Loading model", "Streaming VibeVoice weights to the GPU."], |
| loading_voices: ["Loading voices", null], |
| preparing_inputs: ["Preparing", "Formatting the conversation for the model."], |
| generating_audio: ["Generating", "Synthesizing speech — this is the longest step."], |
| processing_audio: ["Finalizing", "Converting tensors into a playable waveform."], |
| complete: ["Complete", "Press play, or download the WAV."], |
| error: ["Error", "Check the log for details."], |
| cancelled: ["Stopped", "Generation cancelled. The next run may need a cold start."], |
| }; |
|
|
| const QUALITY_LABELS = { "VibeVoice-1.5B": "Fast", "VibeVoice-7B": "Best" }; |
| const GENDER_LABELS = { F: "Feminine", M: "Masculine" }; |
| const SPEAKER_FALLBACK_COLORS = ["#e2582a", "#2f6f63", "#cc8a2e", "#7b4b94"]; |
| const CUSTOM_PREFIX = "custom:"; |
| const CUSTOM_COLORS = ["#8a5a44", "#4a6b8a", "#7b5e7d", "#5e7b60"]; |
| const MAX_CUSTOM_VOICES = 4; |
| const MAX_CUSTOM_AUDIO_BYTES = 15 * 1024 * 1024; |
|
|
| const state = { |
| turns: [], |
| numSpeakers: 2, |
| voices: [], |
| voiceSelections: [null, null, null, null], |
| customVoices: [], |
| models: [], |
| model: null, |
| examples: [], |
| parodyLines: [], |
| parodyIndex: 0, |
| previewAudio: new Audio(), |
| playingVoice: null, |
| librarySearch: "", |
| libraryFilter: "all", |
| libraryTargetSlot: null, |
| cloneTargetSlots: [], |
| cloneBlob: null, |
| cloneName: "", |
| cloneDuration: 0, |
| cloneReplaceId: null, |
| cloneRawBuffer: null, |
| mediaRecorder: null, |
| resultTurns: [], |
| resultTitle: "", |
| wavePeaks: null, |
| takeDuration: 0, |
| dockWS: null, |
| stageWS: null, |
| activeSyncIndex: -1, |
| }; |
|
|
| const el = {}; |
| [ |
| "runtimeStatus", "runtimeLabel", "browseVoicesBtn", "aboutBtn", "aboutDialog", "closeAboutBtn", |
| "speakerStepper", "voiceRows", "qualityPills", "cfgScale", "cfgScaleValue", |
| "voiceConsentRow", "voiceConsentCheckbox", |
| "scriptPrompt", "durationSelect", "generateScriptBtn", "examplePills", "openImportBtn", "scriptGenStatus", |
| "scriptTitle", "scriptDuration", "turnsList", "addTurnBtn", |
| "generateBarMeta", "generateBtn", |
| "statusCard", "statusTitle", "statusDesc", "stopGenBtn", |
| "progressTrack", "progressFill", "progressMeta", |
| "stageGenPane", "stagePlayPane", "genStageTitle", "genStagePct", "genStageTrack", |
| "genStageFill", "genStageMeta", "genStageDesc", "genStageLog", "genStageStopBtn", |
| "dockEmpty", "resultBlock", "resultWaveform", "resultAudio", |
| "playBtn", "playerTime", "syncedTranscript", "openPlayerBtn", |
| "composerCollapsedStrip", "collapsedSummary", "composerBody", |
| "playerStage", "stageTitle", "stagePlayBtn", "stageWaveform", "stageTime", |
| "stageDot", "stageLine", "stageSpeaker", "stageCloseBtn", "stageDownloadBtn", |
| "stageScriptToggle", "stageTranscript", "stagePolishToggle", "polishPanel", |
| "polishSpeed", "polishSpeedValue", "polishTone", "polishBoost", "polishReset", |
| "polishNote", "polishStatus", |
| "generationTime", "audioDuration", "resultModel", "downloadBtn", |
| "realtimeRow", "realtimeFactor", "warmupRow", "warmupTime", |
| "downloadMp3Btn", "stageDownloadMp3Btn", |
| "logToggleBtn", "logBox", |
| "voiceLibraryDialog", "closeLibraryBtn", "librarySearch", "libraryFilters", "libraryGrid", "libraryTitle", |
| "cloneVoiceBtn", "cloneDialog", "closeCloneBtn", "recordBtn", "cloneFileInput", "recordTimer", |
| "clonePreview", "cloneAudio", "cloneMeta", "cloneSlotRow", "cloneConsentCheckbox", |
| "cloneNameInput", "cloneReplaceRow", "cloneReplacePills", "optimizeCheckbox", |
| "readScriptToggle", "readScriptCard", "readScriptText", "readScriptMeta", "readScriptShuffle", |
| "cancelCloneBtn", "useCloneBtn", |
| "importDialog", "pastedScript", "scriptFileUpload", "scriptFileName", "cancelImportBtn", "loadScriptBtn", |
| ].forEach((id) => { el[id] = document.getElementById(id); }); |
|
|
| function autoGrow(textarea) { |
| textarea.style.height = "auto"; |
| textarea.style.height = `${textarea.scrollHeight}px`; |
| } |
|
|
| function voiceByName(name) { |
| return state.voices.find((v) => v.name === name) || null; |
| } |
|
|
| function isCustomVoice(i) { |
| return (state.voiceSelections[i] || "").startsWith(CUSTOM_PREFIX); |
| } |
|
|
| function customVoiceById(id) { |
| return state.customVoices.find((v) => v.id === id) || null; |
| } |
|
|
| function customVoiceForSlot(i) { |
| return isCustomVoice(i) ? customVoiceById(state.voiceSelections[i].slice(CUSTOM_PREFIX.length)) : null; |
| } |
|
|
| function customColor(voice) { |
| const idx = state.customVoices.indexOf(voice); |
| return CUSTOM_COLORS[Math.max(0, idx) % CUSTOM_COLORS.length]; |
| } |
|
|
| function anyCustomVoiceActive() { |
| return Array.from({ length: state.numSpeakers }, (_, i) => i).some(isCustomVoice); |
| } |
|
|
| function slotColor(i) { |
| if (isCustomVoice(i)) { |
| const voice = customVoiceForSlot(i); |
| return voice ? customColor(voice) : SPEAKER_FALLBACK_COLORS[i]; |
| } |
| const voice = voiceByName(state.voiceSelections[i]); |
| return voice ? voice.color : SPEAKER_FALLBACK_COLORS[i]; |
| } |
|
|
| function slotVoiceLabel(i) { |
| if (isCustomVoice(i)) { |
| const voice = customVoiceForSlot(i); |
| return voice ? voice.name : "Custom voice"; |
| } |
| return state.voiceSelections[i] || `Voice ${i + 1}`; |
| } |
|
|
| |
| function idbOpen() { |
| return new Promise((resolve, reject) => { |
| const req = indexedDB.open("chorus-voices", 1); |
| req.onupgradeneeded = () => req.result.createObjectStore("voices", { keyPath: "id" }); |
| req.onsuccess = () => resolve(req.result); |
| req.onerror = () => reject(req.error); |
| }); |
| } |
|
|
| async function idbRequest(mode, fn) { |
| const db = await idbOpen(); |
| return new Promise((resolve, reject) => { |
| const req = fn(db.transaction("voices", mode).objectStore("voices")); |
| req.onsuccess = () => resolve(req.result); |
| req.onerror = () => reject(req.error); |
| }); |
| } |
|
|
| async function loadCustomVoices() { |
| try { |
| const rows = await idbRequest("readonly", (s) => s.getAll()); |
| rows.sort((a, b) => a.createdAt - b.createdAt); |
| state.customVoices = rows.map((r) => ({ ...r, url: URL.createObjectURL(r.blob) })); |
| } catch { |
| state.customVoices = []; |
| } |
| } |
|
|
| async function persistCustomVoice(v) { |
| try { |
| await idbRequest("readwrite", (s) => |
| s.put({ id: v.id, name: v.name, duration: v.duration, createdAt: v.createdAt, blob: v.blob })); |
| } catch { } |
| } |
|
|
| async function removeCustomVoiceRecord(id) { |
| try { await idbRequest("readwrite", (s) => s.delete(id)); } catch { } |
| } |
|
|
| function estimateDuration(turns) { |
| const totalWords = turns.reduce((sum, t) => sum + (t.text || "").trim().split(/\s+/).filter(Boolean).length, 0); |
| if (totalWords === 0) return ""; |
| const minutes = totalWords / 150; |
| return minutes < 1 ? `~${Math.round(minutes * 60)}s` : `~${minutes.toFixed(1)}m`; |
| } |
|
|
| function formatDuration(seconds) { |
| if (!Number.isFinite(seconds)) return "--"; |
| return seconds < 60 ? `${seconds.toFixed(1)}s` : `${Math.floor(seconds / 60)}m ${Math.round(seconds % 60)}s`; |
| } |
|
|
| function formatClock(seconds) { |
| if (!Number.isFinite(seconds)) return "0:00"; |
| const m = Math.floor(seconds / 60); |
| const s = Math.floor(seconds % 60); |
| return `${m}:${String(s).padStart(2, "0")}`; |
| } |
|
|
| |
| el.aboutBtn.addEventListener("click", () => el.aboutDialog.showModal()); |
| el.closeAboutBtn.addEventListener("click", () => el.aboutDialog.close()); |
| el.aboutDialog.addEventListener("click", (e) => { if (e.target === el.aboutDialog) el.aboutDialog.close(); }); |
|
|
| el.openImportBtn.addEventListener("click", () => el.importDialog.showModal()); |
| el.scriptFileUpload.addEventListener("change", () => { |
| el.scriptFileName.textContent = el.scriptFileUpload.files[0] |
| ? el.scriptFileUpload.files[0].name |
| : "No file chosen"; |
| }); |
| el.cancelImportBtn.addEventListener("click", () => el.importDialog.close()); |
| el.importDialog.addEventListener("click", (e) => { if (e.target === el.importDialog) el.importDialog.close(); }); |
|
|
| function openLibrary(targetSlot = null) { |
| state.libraryTargetSlot = targetSlot; |
| el.libraryTitle.textContent = |
| targetSlot === null ? "Choose your voices" : `Choose a voice for Speaker ${targetSlot + 1}`; |
| renderLibrary(); |
| el.voiceLibraryDialog.showModal(); |
| } |
| el.browseVoicesBtn.addEventListener("click", () => openLibrary(null)); |
| el.closeLibraryBtn.addEventListener("click", () => el.voiceLibraryDialog.close()); |
| el.voiceLibraryDialog.addEventListener("click", (e) => { |
| if (e.target === el.voiceLibraryDialog) el.voiceLibraryDialog.close(); |
| }); |
|
|
| |
| async function updateStatus() { |
| try { |
| const res = await fetch("/api/status", { cache: "no-store" }); |
| const payload = await res.json(); |
| const ready = payload.backend === "ready"; |
| el.runtimeStatus.classList.toggle("ready", ready); |
| el.runtimeLabel.textContent = ready ? "Model ready" : "Backend offline"; |
| } catch { |
| el.runtimeStatus.classList.remove("ready"); |
| el.runtimeLabel.textContent = "Connecting"; |
| } |
| } |
|
|
| |
| function refreshPreviewButtons() { |
| document.querySelectorAll(".voice-play").forEach((b) => { |
| const playing = b.dataset.voice === state.playingVoice; |
| b.classList.toggle("playing", playing); |
| b.textContent = playing ? "❚❚" : "▶"; |
| }); |
| document.querySelectorAll(".voice-preview-link").forEach((b) => { |
| b.textContent = b.dataset.voice === state.playingVoice ? "Playing..." : "Preview"; |
| }); |
| } |
|
|
| function previewSrcFor(key) { |
| if (key.startsWith(CUSTOM_PREFIX)) { |
| const voice = customVoiceById(key.slice(CUSTOM_PREFIX.length)); |
| return voice ? voice.url : null; |
| } |
| const voice = voiceByName(key); |
| return voice ? voice.preview_url : null; |
| } |
|
|
| function playVoicePreview(key) { |
| if (state.playingVoice === key) { |
| state.previewAudio.pause(); |
| state.playingVoice = null; |
| refreshPreviewButtons(); |
| return; |
| } |
| const src = previewSrcFor(key); |
| if (!src) return; |
| state.previewAudio.src = src; |
| state.playingVoice = key; |
| refreshPreviewButtons(); |
| state.previewAudio.play().catch((err) => { |
| state.playingVoice = null; |
| refreshPreviewButtons(); |
| console.error("Voice preview failed to play:", err); |
| }); |
| } |
| state.previewAudio.addEventListener("ended", () => { |
| state.playingVoice = null; |
| refreshPreviewButtons(); |
| }); |
|
|
| |
| function updateVoiceConsentVisibility() { |
| el.voiceConsentRow.hidden = !anyCustomVoiceActive(); |
| } |
|
|
| function fillEmptySlots() { |
| if (!state.voices.length) return; |
| for (let i = 0; i < state.numSpeakers; i += 1) { |
| if (state.voiceSelections[i]) continue; |
| const used = new Set(state.voiceSelections.filter(Boolean)); |
| const pick = state.voices.find((v) => !used.has(v.name)) || state.voices[i % state.voices.length]; |
| state.voiceSelections[i] = pick.name; |
| } |
| } |
|
|
| function renderCast() { |
| fillEmptySlots(); |
| el.speakerStepper.querySelectorAll("button").forEach((btn) => { |
| btn.classList.toggle("active", Number(btn.dataset.count) === state.numSpeakers); |
| }); |
|
|
| el.voiceRows.innerHTML = ""; |
| for (let i = 0; i < state.numSpeakers; i += 1) { |
| const card = document.createElement("div"); |
| card.className = "slot-card"; |
|
|
| const dot = document.createElement("span"); |
| dot.className = "voice-dot"; |
| dot.style.background = slotColor(i); |
|
|
| const info = document.createElement("div"); |
| info.className = "slot-info"; |
| const name = document.createElement("div"); |
| name.className = "slot-name"; |
| name.textContent = slotVoiceLabel(i); |
| const meta = document.createElement("div"); |
| meta.className = "slot-meta"; |
| if (isCustomVoice(i)) { |
| const voice = customVoiceForSlot(i); |
| meta.textContent = voice ? `Cloned voice · ${Math.round(voice.duration)}s` : "Clip missing — re-record"; |
| } else { |
| const voice = voiceByName(state.voiceSelections[i]); |
| meta.textContent = voice ? [GENDER_LABELS[voice.gender], ...(voice.tags || [])].join(" · ") : ""; |
| } |
| info.append(name, meta); |
| card.append(dot, info); |
|
|
| if (state.voiceSelections[i] && previewSrcFor(state.voiceSelections[i])) { |
| const playBtn = document.createElement("button"); |
| playBtn.type = "button"; |
| playBtn.className = "voice-play"; |
| playBtn.dataset.voice = state.voiceSelections[i]; |
| playBtn.textContent = "▶"; |
| playBtn.title = "Preview voice"; |
| playBtn.addEventListener("click", () => playVoicePreview(playBtn.dataset.voice)); |
| card.append(playBtn); |
| } |
|
|
| const changeBtn = document.createElement("button"); |
| changeBtn.type = "button"; |
| changeBtn.className = "slot-change"; |
| changeBtn.textContent = "Change"; |
| changeBtn.addEventListener("click", () => openLibrary(i)); |
| card.append(changeBtn); |
|
|
| el.voiceRows.append(card); |
|
|
| if (isCustomVoice(i) && !customVoiceForSlot(i)) { |
| const uploadRow = document.createElement("div"); |
| uploadRow.className = "custom-voice-row"; |
| const fixBtn = document.createElement("button"); |
| fixBtn.type = "button"; |
| fixBtn.className = "btn upload-mini-btn"; |
| fixBtn.textContent = "Record or upload a clip…"; |
| fixBtn.addEventListener("click", () => openCloneDialog(i)); |
| uploadRow.append(fixBtn); |
| el.voiceRows.append(uploadRow); |
| } |
| } |
|
|
| updateVoiceConsentVisibility(); |
| refreshPreviewButtons(); |
| } |
|
|
| function renderQuality() { |
| el.qualityPills.innerHTML = ""; |
| state.models.forEach((model) => { |
| const btn = document.createElement("button"); |
| btn.type = "button"; |
| btn.className = "single-line"; |
| btn.title = model; |
| const label = document.createElement("span"); |
| label.textContent = QUALITY_LABELS[model] || model; |
| btn.append(label); |
| btn.classList.toggle("active", model === state.model); |
| btn.addEventListener("click", () => { |
| state.model = model; |
| renderQuality(); |
| }); |
| el.qualityPills.append(btn); |
| }); |
| } |
|
|
| el.speakerStepper.querySelectorAll("button").forEach((btn) => { |
| btn.addEventListener("click", () => { |
| state.numSpeakers = Number(btn.dataset.count); |
| renderCast(); |
| }); |
| }); |
|
|
| function expressivenessWord(value) { |
| if (value < 1.35) return "Calm"; |
| if (value < 1.7) return "Balanced"; |
| return "Dynamic"; |
| } |
|
|
| function updateCfgLabel() { |
| const value = Number(el.cfgScale.value); |
| el.cfgScaleValue.textContent = `${value.toFixed(2)} · ${expressivenessWord(value)}`; |
| } |
| el.cfgScale.addEventListener("input", updateCfgLabel); |
| updateCfgLabel(); |
|
|
| |
| function libraryFilterOptions() { |
| const tags = Array.from(new Set(state.voices.flatMap((v) => v.tags || []))); |
| return [ |
| { key: "all", label: "All" }, |
| { key: "F", label: "Feminine" }, |
| { key: "M", label: "Masculine" }, |
| ...tags.map((t) => ({ key: t, label: t })), |
| ]; |
| } |
|
|
| function renderLibraryFilters() { |
| el.libraryFilters.innerHTML = ""; |
| libraryFilterOptions().forEach((opt) => { |
| const chip = document.createElement("button"); |
| chip.type = "button"; |
| chip.className = "filter-chip"; |
| chip.textContent = opt.label; |
| chip.classList.toggle("active", state.libraryFilter === opt.key); |
| chip.addEventListener("click", () => { |
| state.libraryFilter = opt.key; |
| renderLibraryFilters(); |
| renderLibraryGrid(); |
| }); |
| el.libraryFilters.append(chip); |
| }); |
| } |
|
|
| function makeSlotAssignRow(isAssigned, assignedColor, onAssign) { |
| const row = document.createElement("div"); |
| row.className = "slot-assign"; |
| const label = document.createElement("span"); |
| label.className = "slot-assign-label"; |
| label.textContent = "Assign to"; |
| row.append(label); |
| for (let i = 0; i < state.numSpeakers; i += 1) { |
| const btn = document.createElement("button"); |
| btn.type = "button"; |
| btn.textContent = String(i + 1); |
| if (isAssigned(i)) { |
| btn.classList.add("assigned"); |
| btn.style.background = assignedColor(i); |
| } |
| btn.addEventListener("click", () => onAssign(i)); |
| row.append(btn); |
| } |
| return row; |
| } |
|
|
| function assignVoiceToSlot(i, value) { |
| state.voiceSelections[i] = value; |
| renderCast(); |
| renderTurns(); |
| renderLibraryGrid(); |
| } |
|
|
| function makeUseVoiceButton(slot, key) { |
| const useBtn = document.createElement("button"); |
| useBtn.type = "button"; |
| useBtn.className = "btn btn-accent use-voice-btn"; |
| const current = state.voiceSelections[slot] === key; |
| useBtn.textContent = current ? "Current voice" : "Use voice"; |
| useBtn.disabled = current; |
| useBtn.addEventListener("click", () => { |
| assignVoiceToSlot(slot, key); |
| el.voiceLibraryDialog.close(); |
| }); |
| return useBtn; |
| } |
|
|
| function deleteCustomVoice(voice) { |
| state.customVoices = state.customVoices.filter((v) => v !== voice); |
| URL.revokeObjectURL(voice.url); |
| removeCustomVoiceRecord(voice.id); |
| const key = CUSTOM_PREFIX + voice.id; |
| state.voiceSelections = state.voiceSelections.map((sel) => (sel === key ? null : sel)); |
| renderCast(); |
| renderTurns(); |
| renderLibraryGrid(); |
| } |
|
|
| function buildCustomVoiceCard(voice) { |
| const key = CUSTOM_PREFIX + voice.id; |
| const card = document.createElement("div"); |
| card.className = "voice-card"; |
|
|
| const avatar = document.createElement("span"); |
| avatar.className = "voice-avatar"; |
| avatar.style.background = customColor(voice); |
| avatar.textContent = (voice.name || "?")[0].toUpperCase(); |
|
|
| const body = document.createElement("div"); |
| body.className = "voice-card-body"; |
|
|
| const head = document.createElement("div"); |
| head.className = "voice-card-head"; |
| const name = document.createElement("span"); |
| name.className = "voice-card-name"; |
| name.textContent = voice.name; |
|
|
| const actions = document.createElement("span"); |
| actions.className = "voice-card-actions"; |
| const preview = document.createElement("button"); |
| preview.type = "button"; |
| preview.className = "voice-preview-link"; |
| preview.dataset.voice = key; |
| preview.textContent = "Preview"; |
| preview.addEventListener("click", () => playVoicePreview(key)); |
|
|
| const renameBtn = document.createElement("button"); |
| renameBtn.type = "button"; |
| renameBtn.className = "voice-mini-action"; |
| renameBtn.textContent = "✎"; |
| renameBtn.title = "Rename"; |
| renameBtn.addEventListener("click", () => { |
| const input = document.createElement("input"); |
| input.className = "voice-rename-input"; |
| input.value = voice.name; |
| input.maxLength = 40; |
| name.replaceWith(input); |
| input.focus(); |
| input.select(); |
| const commit = () => { |
| voice.name = input.value.trim() || voice.name; |
| persistCustomVoice(voice); |
| renderCast(); |
| renderTurns(); |
| renderLibraryGrid(); |
| }; |
| input.addEventListener("blur", commit); |
| input.addEventListener("keydown", (e) => { if (e.key === "Enter") input.blur(); }); |
| }); |
|
|
| const deleteBtn = document.createElement("button"); |
| deleteBtn.type = "button"; |
| deleteBtn.className = "voice-mini-action"; |
| deleteBtn.textContent = "✕"; |
| deleteBtn.title = "Delete this voice"; |
| deleteBtn.addEventListener("click", () => deleteCustomVoice(voice)); |
|
|
| actions.append(preview, renameBtn, deleteBtn); |
| head.append(name, actions); |
|
|
| const meta = document.createElement("div"); |
| meta.className = "voice-card-meta"; |
| meta.textContent = `Cloned voice · ${Math.round(voice.duration)}s`; |
|
|
| const slot = state.libraryTargetSlot; |
| if (slot === null) { |
| body.append(head, meta, makeSlotAssignRow( |
| (i) => state.voiceSelections[i] === key, |
| () => customColor(voice), |
| (i) => assignVoiceToSlot(i, key), |
| )); |
| } else { |
| body.append(head, meta, makeUseVoiceButton(slot, key)); |
| } |
| card.append(avatar, body); |
| return card; |
| } |
|
|
| function librarySectionLabel(text) { |
| const label = document.createElement("div"); |
| label.className = "library-section-label"; |
| label.textContent = text; |
| return label; |
| } |
|
|
| function renderLibraryGrid() { |
| el.libraryGrid.innerHTML = ""; |
| const q = state.librarySearch.trim().toLowerCase(); |
|
|
| const myVoices = state.customVoices.filter((v) => !q || v.name.toLowerCase().includes(q)); |
| if (myVoices.length && state.libraryFilter === "all") { |
| el.libraryGrid.append(librarySectionLabel("My voices")); |
| myVoices.forEach((v) => el.libraryGrid.append(buildCustomVoiceCard(v))); |
| el.libraryGrid.append(librarySectionLabel("Presets")); |
| } |
|
|
| const filtered = state.voices.filter((v) => { |
| const matchesSearch = !q || v.name.toLowerCase().includes(q); |
| const matchesFilter = |
| state.libraryFilter === "all" || |
| v.gender === state.libraryFilter || |
| (v.tags || []).includes(state.libraryFilter); |
| return matchesSearch && matchesFilter; |
| }); |
|
|
| filtered.forEach((voice) => { |
| const card = document.createElement("div"); |
| card.className = "voice-card"; |
|
|
| const avatar = document.createElement("span"); |
| avatar.className = "voice-avatar"; |
| avatar.style.background = voice.color; |
| avatar.textContent = voice.name[0]; |
|
|
| const body = document.createElement("div"); |
| body.className = "voice-card-body"; |
|
|
| const head = document.createElement("div"); |
| head.className = "voice-card-head"; |
| const name = document.createElement("span"); |
| name.className = "voice-card-name"; |
| name.textContent = voice.name; |
| const preview = document.createElement("button"); |
| preview.type = "button"; |
| preview.className = "voice-preview-link"; |
| preview.dataset.voice = voice.name; |
| preview.textContent = "Preview"; |
| preview.addEventListener("click", () => playVoicePreview(voice.name)); |
| head.append(name, preview); |
|
|
| const meta = document.createElement("div"); |
| meta.className = "voice-card-meta"; |
| meta.textContent = [GENDER_LABELS[voice.gender], ...(voice.tags || [])].join(" · "); |
|
|
| const slot = state.libraryTargetSlot; |
| if (slot === null) { |
| body.append(head, meta, makeSlotAssignRow( |
| (i) => state.voiceSelections[i] === voice.name, |
| () => voice.color, |
| (i) => assignVoiceToSlot(i, voice.name), |
| )); |
| } else { |
| |
| body.append(head, meta, makeUseVoiceButton(slot, voice.name)); |
| } |
| card.append(avatar, body); |
| el.libraryGrid.append(card); |
| }); |
|
|
| |
| const clone = document.createElement("div"); |
| clone.className = "voice-card clone-card"; |
| const cloneAvatar = document.createElement("span"); |
| cloneAvatar.className = "voice-avatar"; |
| cloneAvatar.textContent = "🎙️"; |
| const cloneBody = document.createElement("div"); |
| cloneBody.className = "voice-card-body"; |
| const cloneHead = document.createElement("div"); |
| cloneHead.className = "voice-card-head"; |
| const cloneName = document.createElement("span"); |
| cloneName.className = "voice-card-name"; |
| cloneName.textContent = "Clone a voice"; |
| cloneHead.append(cloneName); |
| const cloneMeta = document.createElement("div"); |
| cloneMeta.className = "voice-card-meta"; |
| cloneMeta.textContent = "Record or upload 15–30s of a voice you have rights to use"; |
| const cloneBtn = document.createElement("button"); |
| cloneBtn.type = "button"; |
| cloneBtn.className = "btn btn-accent use-voice-btn"; |
| cloneBtn.textContent = "Record or upload"; |
| cloneBtn.addEventListener("click", () => { |
| const target = state.libraryTargetSlot; |
| el.voiceLibraryDialog.close(); |
| openCloneDialog(target); |
| }); |
| cloneBody.append(cloneHead, cloneMeta, cloneBtn); |
| clone.append(cloneAvatar, cloneBody); |
| el.libraryGrid.append(clone); |
|
|
| refreshPreviewButtons(); |
| } |
|
|
| function renderLibrary() { |
| el.librarySearch.value = state.librarySearch; |
| renderLibraryFilters(); |
| renderLibraryGrid(); |
| } |
|
|
| el.librarySearch.addEventListener("input", () => { |
| state.librarySearch = el.librarySearch.value; |
| renderLibraryGrid(); |
| }); |
|
|
| |
| |
| |
| |
| const READ_SCRIPTS = [ |
| "Okay, so here's the thing about mornings: I always swear I'll get up early, and somehow the snooze button wins every single time. Last Tuesday I actually did it — coffee, a quick walk, the whole routine — and honestly? Best day I'd had in months. The air was cool, the streets were quiet, and for once nobody needed a single thing from me. I watched the bakery on the corner pull its first trays out of the oven, and the smell alone was worth the alarm. So naturally, I told everyone I was a morning person now. That lasted exactly four days! But I keep coming back to it, because those quiet hours feel like borrowed time. Maybe tomorrow I'll try it again — no promises, though.", |
| "When I was about nine, my grandfather taught me to fish off the old wooden dock behind his house. He'd say, \"Patience isn't waiting — it's what you do while you wait.\" I had no idea what that meant back then. I just liked the sandwiches, and the way the water slapped against the posts. We'd sit for hours, mostly in silence, watching dragonflies stitch back and forth across the surface. Did we ever catch much? Almost never! One summer the biggest thing we pulled up was somebody's old boot, and he laughed so hard he nearly fell in. Now, every time I'm stuck in line or watching the kettle boil, I hear his voice again, and I catch myself smiling without meaning to. Funny how the smallest afternoons turn out to be the ones you keep.", |
| "You want to know the best meal I've ever had? A tiny noodle shop, eleven o'clock at night, rain hammering against the windows. Six seats, no menu, and a cook who never said a word. He just looked at you, nodded once, and started cooking. That broth changed my life! Rich and smoky and somehow gentle at the same time, with noodles he'd pulled by hand maybe ninety seconds earlier. I sat there dripping wet and completely happy, and I ordered a second bowl before I'd finished the first. I've chased that flavor everywhere since — big cities, little towns, my own kitchen at two in the morning — and nothing has ever come close. Some things only taste right once. The trick is knowing it while it's happening, and that night, I did.", |
| "There's a particular hour just before sunset when everything slows down. The light turns gold, shadows stretch long across the yard, and even the birds seem to lower their voices. I like to sit outside then, with a cup of tea going cold beside me, and let my thoughts wander wherever they want. Sometimes they drift to old friends, or to trips I still mean to take; sometimes they don't go anywhere at all, and that's fine too. The neighbor's dog usually wanders over, flops down on the warm stones, and sighs like he's had the longest day of anyone. Honestly, he might be right! Then the streetlights blink on, the cool air moves in, and the moment is over. It never lasts long. Isn't that exactly why it matters?", |
| "Here's my confession: I talk to my plants. Not just a quick hello, either — full conversations. The fern gets encouragement, the cactus gets tough love, and the orchid? The orchid gets bribed. I promise it better light, less draft, a bigger pot in the spring — whatever it takes, because it blooms exactly when it feels like it and not a moment sooner. My sister thinks I've completely lost the plot. She stood in my kitchen last month, listening to me thank the basil for pulling through a rough week, and just slowly shook her head. But does any of it work? Who knows! All I can tell you is that every single one of them is still alive, which is more than I can say for every plant I owned before, so I'm not changing a thing.", |
| ]; |
|
|
| const CLONE_MIN_SECONDS = 5; |
| const CLONE_GOOD_SECONDS = 10; |
| const CLONE_MAX_RECORD_SECONDS = 60; |
|
|
| function encodeWav(samples, sampleRate) { |
| const buffer = new ArrayBuffer(44 + samples.length * 2); |
| const view = new DataView(buffer); |
| const writeStr = (off, s) => { for (let i = 0; i < s.length; i += 1) view.setUint8(off + i, s.charCodeAt(i)); }; |
| writeStr(0, "RIFF"); view.setUint32(4, 36 + samples.length * 2, true); writeStr(8, "WAVE"); |
| writeStr(12, "fmt "); view.setUint32(16, 16, true); view.setUint16(20, 1, true); |
| view.setUint16(22, 1, true); view.setUint32(24, sampleRate, true); |
| view.setUint32(28, sampleRate * 2, true); view.setUint16(32, 2, true); view.setUint16(34, 16, true); |
| writeStr(36, "data"); view.setUint32(40, samples.length * 2, true); |
| for (let i = 0; i < samples.length; i += 1) { |
| const s = Math.max(-1, Math.min(1, samples[i])); |
| view.setInt16(44 + i * 2, s < 0 ? s * 0x8000 : s * 0x7fff, true); |
| } |
| return new Blob([buffer], { type: "audio/wav" }); |
| } |
|
|
| function trimSilence(samples, rate) { |
| const threshold = 0.012; |
| const pad = Math.round(rate * 0.15); |
| let start = 0; |
| let end = samples.length - 1; |
| while (start < samples.length && Math.abs(samples[start]) < threshold) start += 1; |
| while (end > start && Math.abs(samples[end]) < threshold) end -= 1; |
| if (end - start < rate) return samples; |
| return samples.slice(Math.max(0, start - pad), Math.min(samples.length, end + pad)); |
| } |
|
|
| function normalizePeak(samples, target = 0.85) { |
| let peak = 0; |
| for (let i = 0; i < samples.length; i += 1) peak = Math.max(peak, Math.abs(samples[i])); |
| if (peak < 0.001 || peak >= target) return samples; |
| const gain = target / peak; |
| const out = new Float32Array(samples.length); |
| for (let i = 0; i < samples.length; i += 1) out[i] = samples[i] * gain; |
| return out; |
| } |
|
|
| |
| |
| |
| |
| async function toMonoWav(arrayBuffer, optimize) { |
| const probe = new AudioContext(); |
| let decoded; |
| try { |
| decoded = await probe.decodeAudioData(arrayBuffer.slice(0)); |
| } finally { |
| await probe.close(); |
| } |
| const rate = 24000; |
| const frames = Math.ceil(decoded.duration * rate); |
| const offline = new OfflineAudioContext(1, frames, rate); |
| const source = offline.createBufferSource(); |
| source.buffer = decoded; |
| if (optimize) { |
| const highpass = offline.createBiquadFilter(); |
| highpass.type = "highpass"; |
| highpass.frequency.value = 80; |
| source.connect(highpass); |
| highpass.connect(offline.destination); |
| } else { |
| source.connect(offline.destination); |
| } |
| source.start(); |
| const rendered = await offline.startRendering(); |
| let samples = rendered.getChannelData(0); |
| if (optimize) { |
| samples = normalizePeak(trimSilence(samples, rate)); |
| } |
| return { blob: encodeWav(samples, rate), duration: samples.length / rate }; |
| } |
|
|
| function setCloneClip(blob, name, duration) { |
| state.cloneBlob = blob; |
| state.cloneName = name; |
| state.cloneDuration = duration; |
| el.cloneAudio.src = URL.createObjectURL(blob); |
| el.clonePreview.hidden = false; |
| const secs = Math.round(duration); |
| let note = `${name} · ${secs}s`; |
| if (duration < CLONE_MIN_SECONDS) note += " — too short; record at least 5 seconds."; |
| else if (duration < CLONE_GOOD_SECONDS) note += " — usable, but 15–30s clones much better."; |
| else note += " — looks good."; |
| el.cloneMeta.textContent = note; |
| updateCloneConfirm(); |
| } |
|
|
| function cloneAtCapacity() { |
| return state.customVoices.length >= MAX_CUSTOM_VOICES; |
| } |
|
|
| function updateCloneSaveLabel() { |
| const slots = state.cloneTargetSlots; |
| if (!slots.length) { |
| el.useCloneBtn.textContent = "Save voice"; |
| } else if (slots.length === 1) { |
| el.useCloneBtn.textContent = `Save & use as Speaker ${slots[0] + 1}`; |
| } else { |
| const nums = [...slots].sort().map((i) => i + 1); |
| el.useCloneBtn.textContent = |
| `Save & use as Speakers ${nums.slice(0, -1).join(", ")} & ${nums[nums.length - 1]}`; |
| } |
| } |
|
|
| function renderCloneSlots() { |
| el.cloneSlotRow.innerHTML = ""; |
| for (let i = 0; i < state.numSpeakers; i += 1) { |
| const pill = document.createElement("button"); |
| pill.type = "button"; |
| pill.className = "clone-slot-pill"; |
| pill.textContent = `Speaker ${i + 1} · ${slotVoiceLabel(i)}`; |
| pill.classList.toggle("active", state.cloneTargetSlots.includes(i)); |
| pill.addEventListener("click", () => { |
| state.cloneTargetSlots = state.cloneTargetSlots.includes(i) |
| ? state.cloneTargetSlots.filter((s) => s !== i) |
| : [...state.cloneTargetSlots, i]; |
| renderCloneSlots(); |
| updateCloneSaveLabel(); |
| }); |
| el.cloneSlotRow.append(pill); |
| } |
| } |
|
|
| function updateCloneConfirm() { |
| el.useCloneBtn.disabled = !( |
| state.cloneBlob && |
| state.cloneDuration >= CLONE_MIN_SECONDS && |
| el.cloneConsentCheckbox.checked && |
| (!cloneAtCapacity() || state.cloneReplaceId) |
| ); |
| } |
|
|
| function renderCloneReplacePills() { |
| el.cloneReplaceRow.hidden = !cloneAtCapacity(); |
| el.cloneReplacePills.innerHTML = ""; |
| if (!cloneAtCapacity()) return; |
| state.customVoices.forEach((v) => { |
| const pill = document.createElement("button"); |
| pill.type = "button"; |
| pill.className = "clone-slot-pill"; |
| pill.textContent = v.name; |
| pill.classList.toggle("active", v.id === state.cloneReplaceId); |
| pill.addEventListener("click", () => { |
| state.cloneReplaceId = v.id; |
| renderCloneReplacePills(); |
| updateCloneConfirm(); |
| }); |
| el.cloneReplacePills.append(pill); |
| }); |
| } |
|
|
| function stopRecording() { |
| if (state.mediaRecorder && state.mediaRecorder.state !== "inactive") state.mediaRecorder.stop(); |
| } |
|
|
| function resetRecordButton() { |
| clearInterval(recordTicker); |
| el.recordBtn.textContent = "● Record"; |
| el.recordBtn.classList.remove("recording"); |
| el.recordTimer.classList.remove("rec-live"); |
| el.recordTimer.hidden = true; |
| } |
|
|
| let recordTicker = null; |
|
|
| async function startRecording() { |
| let stream; |
| const optimize = el.optimizeCheckbox.checked; |
| try { |
| stream = await navigator.mediaDevices.getUserMedia({ |
| audio: { noiseSuppression: optimize, echoCancellation: optimize, autoGainControl: optimize }, |
| }); |
| } catch { |
| el.cloneMeta.textContent = "Microphone access was denied — allow it in your browser, or upload a file instead."; |
| el.clonePreview.hidden = false; |
| return; |
| } |
| const chunks = []; |
| const recorder = new MediaRecorder(stream); |
| state.mediaRecorder = recorder; |
| recorder.addEventListener("dataavailable", (e) => { if (e.data.size) chunks.push(e.data); }); |
| recorder.addEventListener("stop", async () => { |
| stream.getTracks().forEach((t) => t.stop()); |
| resetRecordButton(); |
| try { |
| const raw = await new Blob(chunks).arrayBuffer(); |
| state.cloneRawBuffer = raw; |
| const { blob, duration } = await toMonoWav(raw, el.optimizeCheckbox.checked); |
| setCloneClip(blob, "Recorded clip", duration); |
| } catch { |
| el.cloneMeta.textContent = "Could not process the recording — try again or upload a file."; |
| el.clonePreview.hidden = false; |
| } |
| }); |
| recorder.start(); |
| const startedAt = Date.now(); |
| el.recordBtn.textContent = "■ Stop"; |
| el.recordBtn.classList.add("recording"); |
| el.recordTimer.hidden = false; |
| el.recordTimer.classList.add("rec-live"); |
| el.recordTimer.textContent = "0:00"; |
| clearInterval(recordTicker); |
| recordTicker = setInterval(() => { |
| const elapsed = (Date.now() - startedAt) / 1000; |
| el.recordTimer.textContent = formatClock(elapsed); |
| if (elapsed >= CLONE_MAX_RECORD_SECONDS) stopRecording(); |
| }, 250); |
| } |
|
|
| el.recordBtn.addEventListener("click", () => { |
| if (state.mediaRecorder && state.mediaRecorder.state === "recording") stopRecording(); |
| else startRecording(); |
| }); |
|
|
| el.cloneFileInput.addEventListener("change", async () => { |
| const file = el.cloneFileInput.files[0]; |
| el.cloneFileInput.value = ""; |
| if (!file) return; |
| if (file.size > MAX_CUSTOM_AUDIO_BYTES) { |
| alert(`That file is too large (max ${MAX_CUSTOM_AUDIO_BYTES / (1024 * 1024)} MB).`); |
| return; |
| } |
| try { |
| const raw = await file.arrayBuffer(); |
| const { blob, duration } = await toMonoWav(raw, el.optimizeCheckbox.checked); |
| state.cloneRawBuffer = raw; |
| setCloneClip(blob, file.name, duration); |
| } catch { |
| |
| state.cloneRawBuffer = null; |
| state.cloneBlob = file; |
| state.cloneName = file.name; |
| state.cloneDuration = CLONE_GOOD_SECONDS; |
| el.cloneAudio.removeAttribute("src"); |
| el.clonePreview.hidden = false; |
| el.cloneMeta.textContent = `${file.name} — couldn't preview this format; it will be sent as-is.`; |
| updateCloneConfirm(); |
| } |
| }); |
|
|
| el.cloneConsentCheckbox.addEventListener("change", updateCloneConfirm); |
|
|
| |
| let readScriptIndex = 0; |
|
|
| function showReadScript() { |
| const text = READ_SCRIPTS[readScriptIndex % READ_SCRIPTS.length]; |
| el.readScriptText.textContent = text; |
| const words = text.split(/\s+/).length; |
| el.readScriptMeta.textContent = |
| `~${Math.round((words / 150) * 60)}s at a relaxed pace · read it like you'd say it, not like an announcement`; |
| } |
|
|
| el.readScriptToggle.addEventListener("click", () => { |
| el.readScriptCard.hidden = !el.readScriptCard.hidden; |
| el.readScriptToggle.textContent = el.readScriptCard.hidden |
| ? "Not sure what to say? Show a script to read →" |
| : "Hide the script"; |
| if (!el.readScriptCard.hidden) showReadScript(); |
| }); |
|
|
| el.readScriptShuffle.addEventListener("click", () => { |
| readScriptIndex += 1; |
| showReadScript(); |
| }); |
|
|
| |
| el.optimizeCheckbox.addEventListener("change", async () => { |
| if (!state.cloneRawBuffer) return; |
| try { |
| const { blob, duration } = await toMonoWav(state.cloneRawBuffer, el.optimizeCheckbox.checked); |
| setCloneClip(blob, state.cloneName, duration); |
| } catch { } |
| }); |
|
|
| function openCloneDialog(targetSlot = null) { |
| |
| |
| state.cloneTargetSlots = [targetSlot === null ? 0 : Math.min(targetSlot, state.numSpeakers - 1)]; |
| state.cloneBlob = null; |
| state.cloneName = ""; |
| state.cloneDuration = 0; |
| state.cloneReplaceId = null; |
| state.cloneRawBuffer = null; |
| el.clonePreview.hidden = true; |
| el.cloneAudio.removeAttribute("src"); |
| el.cloneMeta.textContent = ""; |
| el.cloneNameInput.value = ""; |
| el.cloneConsentCheckbox.checked = el.voiceConsentCheckbox.checked; |
| renderCloneSlots(); |
| updateCloneSaveLabel(); |
| renderCloneReplacePills(); |
| updateCloneConfirm(); |
| el.cloneDialog.showModal(); |
| } |
|
|
| function closeCloneDialog() { |
| stopRecording(); |
| resetRecordButton(); |
| el.cloneDialog.close(); |
| } |
|
|
| el.cloneVoiceBtn.addEventListener("click", () => openCloneDialog(null)); |
| el.closeCloneBtn.addEventListener("click", closeCloneDialog); |
| el.cancelCloneBtn.addEventListener("click", closeCloneDialog); |
| el.cloneDialog.addEventListener("click", (e) => { if (e.target === el.cloneDialog) closeCloneDialog(); }); |
|
|
| el.useCloneBtn.addEventListener("click", () => { |
| const typed = el.cloneNameInput.value.trim(); |
| const fromFile = state.cloneName && state.cloneName !== "Recorded clip" |
| ? state.cloneName.replace(/\.[a-z0-9]+$/i, "") |
| : ""; |
| const name = typed || fromFile || `My voice ${state.customVoices.length + 1}`; |
|
|
| const existing = state.cloneReplaceId ? customVoiceById(state.cloneReplaceId) : null; |
| let voice; |
| if (existing) { |
| URL.revokeObjectURL(existing.url); |
| Object.assign(existing, { |
| name, duration: state.cloneDuration, blob: state.cloneBlob, |
| url: URL.createObjectURL(state.cloneBlob), |
| }); |
| voice = existing; |
| } else { |
| voice = { |
| id: crypto.randomUUID(), name, |
| duration: state.cloneDuration, blob: state.cloneBlob, |
| url: URL.createObjectURL(state.cloneBlob), createdAt: Date.now(), |
| }; |
| state.customVoices.push(voice); |
| } |
| persistCustomVoice(voice); |
| state.cloneTargetSlots.forEach((i) => { |
| state.voiceSelections[i] = CUSTOM_PREFIX + voice.id; |
| }); |
| el.voiceConsentCheckbox.checked = el.cloneConsentCheckbox.checked; |
| closeCloneDialog(); |
| renderCast(); |
| renderTurns(); |
| }); |
|
|
| |
| function speakerChoiceLabel(i) { |
| const sel = state.voiceSelections[i]; |
| if (!sel) return `Speaker ${i + 1}`; |
| return `Speaker ${i + 1} · ${slotVoiceLabel(i)}`; |
| } |
|
|
| function renderTurns() { |
| el.turnsList.innerHTML = ""; |
| if (!state.turns.length) { |
| setComposerCollapsed(false); |
| const empty = document.createElement("div"); |
| empty.className = "empty-transcript"; |
| empty.id = "emptyTurns"; |
| empty.innerHTML = |
| '<div class="empty-title">No scene yet</div>' + |
| "Describe a scenario above and click <strong>Write with AI</strong>, import a script, or start typing your own line below."; |
| el.turnsList.append(empty); |
| updateMeta(); |
| return; |
| } |
|
|
| state.turns.forEach((turn, idx) => { |
| const spk = Math.min(4, Math.max(1, turn.speaker || 1)); |
| const card = document.createElement("div"); |
| card.className = "turn-card"; |
| card.style.borderLeftColor = slotColor(spk - 1); |
|
|
| const head = document.createElement("div"); |
| head.className = "turn-head"; |
|
|
| const spkSelect = document.createElement("select"); |
| spkSelect.className = "turn-speaker-select"; |
| for (let i = 1; i <= 4; i += 1) { |
| const opt = document.createElement("option"); |
| opt.value = String(i); |
| opt.textContent = speakerChoiceLabel(i - 1); |
| if (i === spk) opt.selected = true; |
| spkSelect.append(opt); |
| } |
| spkSelect.addEventListener("change", () => { |
| state.turns[idx].speaker = Number(spkSelect.value); |
| renderTurns(); |
| }); |
|
|
| const removeBtn = document.createElement("button"); |
| removeBtn.type = "button"; |
| removeBtn.className = "turn-remove"; |
| removeBtn.textContent = "✕"; |
| removeBtn.addEventListener("click", () => { |
| state.turns.splice(idx, 1); |
| renderTurns(); |
| }); |
|
|
| head.append(spkSelect, removeBtn); |
|
|
| const textArea = document.createElement("textarea"); |
| textArea.rows = 1; |
| textArea.value = turn.text || ""; |
| textArea.addEventListener("input", () => { |
| state.turns[idx].text = textArea.value; |
| autoGrow(textArea); |
| updateMeta(); |
| }); |
|
|
| card.append(head, textArea); |
| el.turnsList.append(card); |
| requestAnimationFrame(() => autoGrow(textArea)); |
| }); |
| updateMeta(); |
| } |
|
|
| function updateMeta() { |
| const duration = estimateDuration(state.turns); |
| el.scriptDuration.textContent = duration; |
| el.generateBarMeta.textContent = state.turns.length |
| ? `${state.turns.length} line${state.turns.length === 1 ? "" : "s"} · ${duration || "—"}` |
| : "Add dialogue to begin"; |
| } |
|
|
| el.addTurnBtn.addEventListener("click", () => { |
| if (state.turns.length >= MAX_TURNS) { |
| alert(`Maximum ${MAX_TURNS} turns reached.`); |
| return; |
| } |
| let nextSpeaker = 1; |
| if (state.turns.length) { |
| const maxSpk = Math.max(...state.turns.map((t) => t.speaker)); |
| const last = state.turns[state.turns.length - 1].speaker; |
| nextSpeaker = (last % maxSpk) + 1; |
| } |
| state.turns.push({ speaker: nextSpeaker, text: "" }); |
| renderTurns(); |
| }); |
|
|
| |
| |
| function setComposerCollapsed(collapsed, summary) { |
| el.composerBody.hidden = collapsed; |
| el.composerCollapsedStrip.hidden = !collapsed; |
| if (summary != null) el.collapsedSummary.textContent = summary; |
| } |
|
|
| el.composerCollapsedStrip.addEventListener("click", () => setComposerCollapsed(false)); |
|
|
| function loadScriptResult(result, titleFallback, summary) { |
| state.turns = result.turns; |
| state.numSpeakers = result.num_speakers; |
| const suggested = (result.voices || []).slice(0, 4); |
| while (suggested.length < 4) suggested.push(null); |
| |
| state.voiceSelections = state.voiceSelections.map((current, i) => |
| (current || "").startsWith(CUSTOM_PREFIX) ? current : suggested[i]); |
| el.scriptTitle.textContent = result.title || titleFallback || "Untitled conversation"; |
| renderCast(); |
| renderTurns(); |
| setComposerCollapsed(true, summary || result.title || titleFallback || ""); |
| el.dockEmpty.hidden = false; |
| el.resultBlock.classList.remove("visible"); |
| el.statusCard.classList.remove("visible"); |
| } |
|
|
| |
| function renderExamplePills() { |
| el.examplePills.innerHTML = ""; |
| el.examplePills.hidden = !state.examples.length; |
| state.examples.forEach((example) => { |
| const btn = document.createElement("button"); |
| btn.type = "button"; |
| btn.className = "chip"; |
| btn.textContent = example.title; |
| btn.addEventListener("click", () => loadScriptResult(example, example.title, `Example: ${example.title}`)); |
| el.examplePills.append(btn); |
| }); |
| } |
|
|
| |
| el.loadScriptBtn.addEventListener("click", async () => { |
| const text = el.pastedScript.value.trim(); |
| const file = el.scriptFileUpload.files[0]; |
| if (!text && !file) { |
| alert("Paste a script or upload a .txt file first."); |
| return; |
| } |
| const formData = new FormData(); |
| formData.set("text", text); |
| if (file) formData.set("file", file); |
|
|
| try { |
| const res = await fetch("/api/parse-script", { method: "POST", body: formData }); |
| const payload = await res.json(); |
| if (!res.ok) throw new Error(payload.detail || "Could not parse script."); |
| if (payload.over_limit) { |
| alert(`Script is ${payload.word_count} words; loading anyway — trim before generating.`); |
| } |
| loadScriptResult(payload, "Uploaded script", "Imported script"); |
| el.importDialog.close(); |
| el.pastedScript.value = ""; |
| el.scriptFileUpload.value = ""; |
| el.scriptFileName.textContent = "No file chosen"; |
| } catch (error) { |
| alert(error.message); |
| } |
| }); |
|
|
| |
| const PROMPT_HINTS = [ |
| "Describe a scenario above first.", |
| "Describe a new scenario above — or press Generate Audio to voice the script you already have.", |
| ]; |
|
|
| el.scriptPrompt.addEventListener("input", () => { |
| if (PROMPT_HINTS.includes(el.scriptGenStatus.textContent)) el.scriptGenStatus.textContent = ""; |
| }); |
|
|
| el.generateScriptBtn.addEventListener("click", async () => { |
| const prompt = el.scriptPrompt.value.trim(); |
| if (!prompt) { |
| el.scriptPrompt.focus(); |
| el.scriptGenStatus.textContent = state.turns.length ? PROMPT_HINTS[1] : PROMPT_HINTS[0]; |
| return; |
| } |
|
|
| el.generateScriptBtn.disabled = true; |
| el.generateScriptBtn.textContent = "Writing..."; |
| let msgIdx = 0; |
| el.scriptGenStatus.textContent = SCRIPT_GEN_MESSAGES[0]; |
| const ticker = window.setInterval(() => { |
| msgIdx += 1; |
| el.scriptGenStatus.textContent = SCRIPT_GEN_MESSAGES[msgIdx % SCRIPT_GEN_MESSAGES.length]; |
| }, 3000); |
|
|
| try { |
| const res = await fetch("/api/generate-script", { |
| method: "POST", |
| headers: { "Content-Type": "application/json" }, |
| body: JSON.stringify({ prompt, target_minutes: Number(el.durationSelect.value) }), |
| }); |
| const payload = await res.json(); |
| if (!res.ok) throw new Error(payload.detail || "Script generation failed."); |
| state.parodyLines = payload.parody_lines || []; |
| state.parodyIndex = 0; |
| loadScriptResult(payload, "Untitled conversation", `“${prompt}”`); |
| el.scriptGenStatus.textContent = ""; |
| } catch (error) { |
| el.scriptGenStatus.textContent = error.message; |
| } finally { |
| window.clearInterval(ticker); |
| el.generateScriptBtn.disabled = false; |
| el.generateScriptBtn.textContent = "Write with AI"; |
| } |
| }); |
|
|
| |
| |
| |
| |
| |
| const WAVE_SKIN = { |
| waveColor: "#e4d8c2", |
| progressColor: "#e2582a", |
| cursorColor: "#c1481f", |
| cursorWidth: 2, |
| barWidth: 3, |
| barGap: 1.5, |
| barRadius: 2, |
| }; |
|
|
| function makeWave(container, height, withTimeline) { |
| const plugins = []; |
| if (withTimeline && window.WaveSurfer && WaveSurfer.Timeline) { |
| plugins.push(WaveSurfer.Timeline.create({ height: 14 })); |
| } |
| return WaveSurfer.create({ |
| container, |
| height, |
| media: el.resultAudio, |
| peaks: [state.wavePeaks || Array.from({ length: 128 }, () => 0.4)], |
| duration: state.takeDuration || el.resultAudio.duration || 0, |
| interact: true, |
| plugins, |
| ...WAVE_SKIN, |
| }); |
| } |
|
|
| function rebuildDockWave() { |
| if (state.dockWS) state.dockWS.destroy(); |
| el.resultWaveform.innerHTML = ""; |
| state.dockWS = makeWave(el.resultWaveform, 44, false); |
| } |
|
|
| function buildSyncedTranscript(snapshot) { |
| |
| const words = snapshot.map((t) => t.text.split(/\s+/).filter(Boolean).length || 1); |
| const total = words.reduce((a, b) => a + b, 0); |
| let acc = 0; |
| state.resultTurns = snapshot.map((t, i) => { |
| const startRatio = acc / total; |
| acc += words[i]; |
| const endRatio = acc / total; |
| |
| |
| const parts = t.text.split(/(?<=[.!?…])\s+/).filter(Boolean); |
| const partWords = parts.map((s) => s.split(/\s+/).filter(Boolean).length || 1); |
| const partTotal = partWords.reduce((a, b) => a + b, 0); |
| let partAcc = 0; |
| const sentences = parts.map((text, j) => { |
| const s = startRatio + (partAcc / partTotal) * (endRatio - startRatio); |
| partAcc += partWords[j]; |
| const e = startRatio + (partAcc / partTotal) * (endRatio - startRatio); |
| return { text, startRatio: s, endRatio: e }; |
| }); |
| return { ...t, startRatio, endRatio, sentences }; |
| }); |
| state.activeSyncIndex = -1; |
| lastCaptionKey = ""; |
|
|
| el.syncedTranscript.innerHTML = ""; |
| el.stageTranscript.innerHTML = ""; |
| state.resultTurns.forEach((turn, i) => { |
| state.resultTurns[i].rows = [el.syncedTranscript, el.stageTranscript].map((container) => { |
| const row = document.createElement("div"); |
| row.className = "sync-line"; |
| const dot = document.createElement("span"); |
| dot.className = "sync-dot"; |
| dot.style.background = slotColor(turn.speaker - 1); |
| const body = document.createElement("div"); |
| const label = document.createElement("div"); |
| label.className = "sync-label"; |
| label.textContent = `Speaker ${turn.speaker} · ${slotVoiceLabel(turn.speaker - 1)}`; |
| const text = document.createElement("div"); |
| text.className = "sync-text"; |
| text.textContent = turn.text; |
| body.append(label, text); |
| row.append(dot, body); |
| row.addEventListener("click", () => { |
| const audio = el.resultAudio; |
| if (Number.isFinite(audio.duration) && audio.duration > 0) { |
| audio.currentTime = turn.startRatio * audio.duration; |
| if (audio.paused) audio.play().catch(() => {}); |
| } |
| }); |
| container.append(row); |
| return row; |
| }); |
| }); |
| } |
|
|
| let lastCaptionKey = ""; |
|
|
| function updateStageCaption(ratio = 0) { |
| const turn = state.resultTurns[Math.max(0, state.activeSyncIndex)]; |
| if (!turn) { |
| el.stageLine.textContent = ""; |
| el.stageSpeaker.textContent = ""; |
| return; |
| } |
| let sentence = turn.sentences[0]; |
| for (const s of turn.sentences) { |
| if (ratio >= s.startRatio) sentence = s; |
| } |
| const key = `${state.activeSyncIndex}:${sentence ? sentence.text : ""}`; |
| if (key === lastCaptionKey) return; |
| lastCaptionKey = key; |
| el.stageDot.style.background = slotColor(turn.speaker - 1); |
| el.stageLine.textContent = sentence ? sentence.text : turn.text; |
| el.stageSpeaker.textContent = `Speaker ${turn.speaker} · ${slotVoiceLabel(turn.speaker - 1)}`; |
| } |
|
|
| function updatePlaybackUI() { |
| const audio = el.resultAudio; |
| const duration = audio.duration; |
| if (!Number.isFinite(duration) || duration <= 0) return; |
| const ratio = audio.currentTime / duration; |
| const timeLabel = `${formatClock(audio.currentTime)} / ${formatClock(duration)}`; |
| el.playerTime.textContent = timeLabel; |
| el.stageTime.textContent = timeLabel; |
|
|
| let active = -1; |
| for (let i = 0; i < state.resultTurns.length; i += 1) { |
| if (ratio >= state.resultTurns[i].startRatio) active = i; |
| } |
| if (active !== state.activeSyncIndex) { |
| state.resultTurns.forEach((t, i) => |
| (t.rows || []).forEach((row) => row.classList.toggle("active", i === active))); |
| state.activeSyncIndex = active; |
| const rows = (state.resultTurns[active] && state.resultTurns[active].rows) || []; |
| rows.forEach((row) => { |
| if (row.offsetParent !== null) row.scrollIntoView({ block: "nearest", behavior: "smooth" }); |
| }); |
| } |
| if (el.playerStage.open) updateStageCaption(ratio); |
| } |
|
|
| function togglePlayback() { |
| const audio = el.resultAudio; |
| if (!audio.src) return; |
| if (audio.paused) audio.play().catch(() => {}); |
| else audio.pause(); |
| } |
|
|
| function setPlayIcons(icon) { |
| el.playBtn.textContent = icon; |
| el.stagePlayBtn.textContent = icon; |
| } |
|
|
| el.playBtn.addEventListener("click", togglePlayback); |
| el.stagePlayBtn.addEventListener("click", togglePlayback); |
| el.resultAudio.addEventListener("play", () => setPlayIcons("❚❚")); |
| el.resultAudio.addEventListener("pause", () => setPlayIcons("►")); |
| el.resultAudio.addEventListener("ended", () => setPlayIcons("►")); |
| el.resultAudio.addEventListener("timeupdate", updatePlaybackUI); |
| el.resultAudio.addEventListener("loadedmetadata", updatePlaybackUI); |
|
|
| |
| |
| |
| |
| |
| const TONE_CURVES = { |
| neutral: { low: 0, high: 0 }, |
| warm: { low: 4, high: -3 }, |
| bright: { low: -2, high: 4.5 }, |
| }; |
|
|
| const polish = { ctx: null, low: null, high: null, gain: null, limiter: null, tone: "neutral", audioId: null }; |
|
|
| function polishSettings() { |
| return { |
| speed: Number(el.polishSpeed.value), |
| tone: polish.tone, |
| level: el.polishBoost.checked, |
| }; |
| } |
|
|
| function polishIsDefault() { |
| const s = polishSettings(); |
| return Math.abs(s.speed - 1) < 0.001 && s.tone === "neutral" && !s.level; |
| } |
|
|
| function ensureAudioGraph() { |
| if (polish.ctx) return true; |
| const Ctx = window.AudioContext || window.webkitAudioContext; |
| if (!Ctx) return false; |
| try { |
| polish.ctx = new Ctx(); |
| |
| |
| const source = polish.ctx.createMediaElementSource(el.resultAudio); |
| polish.low = polish.ctx.createBiquadFilter(); |
| polish.low.type = "lowshelf"; |
| polish.low.frequency.value = 250; |
| polish.high = polish.ctx.createBiquadFilter(); |
| polish.high.type = "highshelf"; |
| polish.high.frequency.value = 3500; |
| polish.gain = polish.ctx.createGain(); |
| |
| |
| |
| polish.limiter = polish.ctx.createDynamicsCompressor(); |
| polish.limiter.threshold.value = -1.5; |
| polish.limiter.knee.value = 0; |
| polish.limiter.ratio.value = 20; |
| polish.limiter.attack.value = 0.003; |
| polish.limiter.release.value = 0.12; |
| source.connect(polish.low); |
| polish.low.connect(polish.high); |
| polish.high.connect(polish.gain); |
| polish.gain.connect(polish.limiter); |
| polish.limiter.connect(polish.ctx.destination); |
| applyPolish(); |
| return true; |
| } catch (error) { |
| console.warn("Audio polish unavailable:", error); |
| polish.ctx = null; |
| return false; |
| } |
| } |
|
|
| function applyPolish() { |
| const speed = Number(el.polishSpeed.value); |
| el.resultAudio.preservesPitch = true; |
| el.resultAudio.mozPreservesPitch = true; |
| el.resultAudio.webkitPreservesPitch = true; |
| el.resultAudio.playbackRate = speed; |
| el.polishSpeedValue.textContent = `${speed.toFixed(2)}×`; |
|
|
| el.polishTone.querySelectorAll("button").forEach((btn) => { |
| btn.classList.toggle("active", btn.dataset.tone === polish.tone); |
| }); |
|
|
| updateExportLinks(); |
|
|
| if (!polish.ctx) return; |
| const curve = TONE_CURVES[polish.tone] || TONE_CURVES.neutral; |
| polish.low.gain.value = curve.low; |
| polish.high.gain.value = curve.high; |
| polish.gain.gain.value = el.polishBoost.checked ? 1.6 : 1; |
| } |
|
|
| |
| |
| function updateExportLinks() { |
| if (!polish.audioId) return; |
| const plain = `/api/audio/${polish.audioId}`; |
| const s = polishSettings(); |
| const query = `speed=${s.speed}&tone=${s.tone}&level=${s.level}`; |
| const isDefault = polishIsDefault(); |
| const wavUrl = isDefault ? plain : `${plain}/export?${query}&fmt=wav`; |
| const mp3Url = isDefault ? `${plain}.mp3` : `${plain}/export?${query}&fmt=mp3`; |
| el.downloadBtn.href = wavUrl; |
| el.stageDownloadBtn.href = wavUrl; |
| el.downloadMp3Btn.href = mp3Url; |
| el.stageDownloadMp3Btn.href = mp3Url; |
| el.polishNote.textContent = isDefault |
| ? "Settings are live here; downloads give you the original take." |
| : "Downloads are rendered with these settings — that can take a moment."; |
| } |
|
|
| |
| |
| function noteExportStarted(kind) { |
| el.polishStatus.hidden = false; |
| el.polishStatus.textContent = `Preparing your ${kind}… the download starts when it's ready (long takes can take a minute or two).`; |
| clearTimeout(noteExportStarted.timer); |
| noteExportStarted.timer = setTimeout(() => { el.polishStatus.hidden = true; }, 20000); |
| } |
|
|
| const TAKE_GONE_MESSAGE = |
| "This take is no longer on the server — it is held in memory, so a Space restart or " + |
| "redeploy clears it. Generate a new one; download it before deploying next time."; |
|
|
| function showTakeGone() { |
| el.polishStatus.hidden = false; |
| el.polishStatus.textContent = TAKE_GONE_MESSAGE; |
| el.dockEmpty.hidden = false; |
| el.dockEmpty.textContent = TAKE_GONE_MESSAGE; |
| setStatus("error", "The finished audio is no longer available on the server."); |
| } |
|
|
| |
| |
| |
| [["downloadBtn", "WAV"], ["stageDownloadBtn", "WAV"], |
| ["downloadMp3Btn", "MP3"], ["stageDownloadMp3Btn", "MP3"]].forEach(([id, kind]) => { |
| el[id].addEventListener("click", async (event) => { |
| const anchor = el[id]; |
| if (anchor.dataset.verified === "1") { |
| anchor.dataset.verified = ""; |
| return; |
| } |
| if (!polish.audioId) return; |
| event.preventDefault(); |
| let alive = false; |
| try { |
| |
| const probe = await fetch(`/api/audio/${polish.audioId}`, { headers: { Range: "bytes=0-0" } }); |
| alive = probe.ok || probe.status === 206; |
| } catch { |
| alive = false; |
| } |
| if (!alive) { |
| showTakeGone(); |
| return; |
| } |
| if (kind === "MP3" || !polishIsDefault()) noteExportStarted(kind); |
| anchor.dataset.verified = "1"; |
| anchor.click(); |
| }); |
| }); |
|
|
| |
| el.resultAudio.addEventListener("error", () => { |
| if (el.resultAudio.getAttribute("src")) showTakeGone(); |
| }); |
|
|
| function polishTouched() { |
| ensureAudioGraph(); |
| if (polish.ctx && polish.ctx.state === "suspended") polish.ctx.resume(); |
| applyPolish(); |
| } |
|
|
| el.polishSpeed.addEventListener("input", polishTouched); |
| el.polishBoost.addEventListener("change", polishTouched); |
| el.polishTone.querySelectorAll("button").forEach((btn) => { |
| btn.addEventListener("click", () => { |
| polish.tone = btn.dataset.tone; |
| polishTouched(); |
| }); |
| }); |
| el.polishReset.addEventListener("click", () => { |
| el.polishSpeed.value = "1"; |
| el.polishBoost.checked = false; |
| polish.tone = "neutral"; |
| polishTouched(); |
| }); |
| el.stagePolishToggle.addEventListener("click", () => { |
| el.polishPanel.hidden = !el.polishPanel.hidden; |
| el.stagePolishToggle.textContent = el.polishPanel.hidden ? "Polish" : "Hide polish"; |
| if (!el.polishPanel.hidden) { |
| polishTouched(); |
| el.polishPanel.scrollIntoView({ block: "nearest", behavior: "smooth" }); |
| } |
| }); |
| |
| el.resultAudio.addEventListener("play", () => { |
| if (polish.ctx && polish.ctx.state === "suspended") polish.ctx.resume(); |
| }); |
|
|
| |
| function openPlayerStage() { |
| showStagePane("player"); |
| el.stageTitle.textContent = (state.resultTitle || "Untitled conversation").toUpperCase(); |
| const audio = el.resultAudio; |
| updateStageCaption(audio.duration ? audio.currentTime / audio.duration : 0); |
| |
| |
| if (!el.playerStage.open) el.playerStage.showModal(); |
| |
| if (state.stageWS) state.stageWS.destroy(); |
| el.stageWaveform.innerHTML = ""; |
| state.stageWS = makeWave(el.stageWaveform, 80, true); |
| } |
|
|
| el.playerStage.addEventListener("close", () => { |
| if (state.stageWS) { |
| state.stageWS.destroy(); |
| state.stageWS = null; |
| el.stageWaveform.innerHTML = ""; |
| } |
| }); |
|
|
| el.openPlayerBtn.addEventListener("click", openPlayerStage); |
| el.stageCloseBtn.addEventListener("click", () => el.playerStage.close()); |
| el.playerStage.addEventListener("click", (e) => { if (e.target === el.playerStage) el.playerStage.close(); }); |
|
|
| el.stageScriptToggle.addEventListener("click", () => { |
| el.stageTranscript.hidden = !el.stageTranscript.hidden; |
| el.stageScriptToggle.textContent = el.stageTranscript.hidden ? "View full script" : "Hide script"; |
| if (!el.stageTranscript.hidden) { |
| const active = state.resultTurns[state.activeSyncIndex]; |
| const target = (active && active.rows && active.rows[1]) || el.stageTranscript; |
| target.scrollIntoView({ block: "nearest", behavior: "smooth" }); |
| } |
| }); |
|
|
| document.addEventListener("keydown", (e) => { |
| if (!el.playerStage.open || e.code !== "Space") return; |
| if (/^(input|textarea|select)$/i.test(e.target.tagName)) return; |
| e.preventDefault(); |
| togglePlayback(); |
| }); |
|
|
| |
| function nextParodyLine() { |
| if (!state.parodyLines.length) return null; |
| const line = state.parodyLines[state.parodyIndex % state.parodyLines.length]; |
| state.parodyIndex += 1; |
| return line; |
| } |
|
|
| function setStatus(stage, fallbackText) { |
| const [title, defaultDesc] = PRIMARY_STAGE_MESSAGES[stage] || ["Working", "Processing..."]; |
| const running = stage !== "complete" && stage !== "error" && stage !== "cancelled"; |
| el.statusCard.classList.add("visible"); |
| el.statusCard.classList.toggle("active", running); |
| el.statusCard.classList.toggle("complete", stage === "complete"); |
| el.statusCard.classList.toggle("error", stage === "error" || stage === "cancelled"); |
| el.statusTitle.textContent = title; |
| el.statusDesc.textContent = fallbackText || defaultDesc || ""; |
| el.genStageDesc.textContent = `${title} — ${fallbackText || defaultDesc || ""}`; |
| el.stopGenBtn.hidden = !running; |
| el.genStageStopBtn.hidden = !running; |
| } |
|
|
| let generateAbort = null; |
| el.stopGenBtn.addEventListener("click", () => { |
| if (generateAbort) generateAbort.abort(); |
| }); |
|
|
| |
| |
| |
| |
| |
| const progress = { |
| startedAt: 0, wave: 0, totalWaves: 0, ticker: null, label: "", |
| waveStartedAt: 0, waveDurations: [], |
| genStartedAt: 0, warmupSecs: 0, |
| }; |
|
|
| |
| |
| |
| function markGenerationStarted() { |
| if (progress.genStartedAt || !progress.startedAt) return; |
| progress.genStartedAt = Date.now(); |
| progress.warmupSecs = (progress.genStartedAt - progress.startedAt) / 1000; |
| } |
|
|
| function generationSeconds() { |
| const base = progress.genStartedAt || progress.startedAt; |
| return base ? (Date.now() - base) / 1000 : 0; |
| } |
|
|
| function formatMinutes(seconds) { |
| if (!Number.isFinite(seconds) || seconds <= 0) return null; |
| if (seconds < 90) return `${Math.round(seconds)}s`; |
| const mins = Math.round(seconds / 60); |
| return mins < 60 ? `${mins} min` : `${Math.floor(mins / 60)}h ${mins % 60}m`; |
| } |
|
|
| function paintProgress() { |
| if (!progress.startedAt) return; |
| const elapsed = generationSeconds(); |
| const parts = []; |
| let pctText = "Starting…"; |
| if (!progress.genStartedAt) { |
| |
| |
| el.progressMeta.hidden = false; |
| el.progressTrack.hidden = false; |
| el.genStageTrack.hidden = false; |
| el.progressTrack.classList.add("indeterminate"); |
| el.genStageTrack.classList.add("indeterminate"); |
| const warm = `GPU warming up · ${formatMinutes(elapsed) || "0s"}`; |
| el.progressMeta.textContent = warm; |
| el.genStagePct.textContent = "Warming up…"; |
| el.genStageMeta.textContent = warm; |
| return; |
| } |
| if (progress.totalWaves) { |
| const done = Math.max(0, progress.wave - 1); |
| |
| |
| |
| |
| const avgWave = progress.waveDurations.length |
| ? progress.waveDurations.reduce((a, b) => a + b, 0) / progress.waveDurations.length |
| : 0; |
| const inWave = avgWave |
| ? Math.min(0.98, ((Date.now() - progress.waveStartedAt) / 1000) / avgWave) |
| : 0; |
| const indeterminate = !avgWave; |
| const frac = (done + inWave) / progress.totalWaves; |
|
|
| el.progressTrack.hidden = false; |
| el.genStageTrack.hidden = false; |
| el.progressTrack.classList.toggle("indeterminate", indeterminate); |
| el.genStageTrack.classList.toggle("indeterminate", indeterminate); |
| if (!indeterminate) { |
| const width = `${Math.max(1.5, frac * 100).toFixed(1)}%`; |
| el.progressFill.style.width = width; |
| el.genStageFill.style.width = width; |
| pctText = `${Math.round(frac * 100)}%`; |
| document.title = `${pctText} · Chorus`; |
| } else { |
| pctText = "Rendering…"; |
| } |
|
|
| parts.push(`Wave ${progress.wave} of ${progress.totalWaves}`); |
| if (done >= 1) { |
| const remaining = (elapsed / (done + inWave)) * (progress.totalWaves - done - inWave); |
| const eta = formatMinutes(remaining); |
| if (eta) parts.push(`~${eta} left`); |
| } |
| } else if (progress.label) { |
| parts.push(progress.label); |
| } |
| parts.push(`${formatMinutes(elapsed) || "0s"} generating`); |
| if (progress.warmupSecs >= 5) { |
| parts.push(`${formatMinutes(progress.warmupSecs)} warm-up`); |
| } |
| const meta = parts.join(" · "); |
| el.progressMeta.hidden = false; |
| el.progressMeta.textContent = meta; |
| el.genStagePct.textContent = pctText; |
| el.genStageMeta.textContent = meta; |
| } |
|
|
| |
| function showStagePane(which) { |
| el.stageGenPane.hidden = which !== "generating"; |
| el.stagePlayPane.hidden = which !== "player"; |
| } |
|
|
| function openGenerateStage(title) { |
| el.genStageTitle.textContent = (title || "Untitled conversation").toUpperCase(); |
| el.genStagePct.textContent = "Starting…"; |
| el.genStageTrack.hidden = false; |
| el.genStageTrack.classList.add("indeterminate"); |
| el.genStageFill.style.width = "0%"; |
| el.genStageLog.textContent = ""; |
| showStagePane("generating"); |
| if (!el.playerStage.open) el.playerStage.showModal(); |
| } |
|
|
| el.genStageStopBtn.addEventListener("click", () => { |
| if (generateAbort) generateAbort.abort(); |
| }); |
|
|
| function startProgress() { |
| progress.startedAt = Date.now(); |
| progress.wave = 0; |
| progress.totalWaves = 0; |
| progress.label = ""; |
| progress.waveStartedAt = 0; |
| progress.waveDurations = []; |
| progress.genStartedAt = 0; |
| progress.warmupSecs = 0; |
| el.progressFill.style.width = "0%"; |
| el.progressTrack.classList.add("indeterminate"); |
| el.genStageTrack.classList.add("indeterminate"); |
| el.progressTrack.hidden = false; |
| clearInterval(progress.ticker); |
| progress.ticker = setInterval(paintProgress, 1000); |
| paintProgress(); |
| } |
|
|
| function stopProgress() { |
| clearInterval(progress.ticker); |
| progress.ticker = null; |
| progress.startedAt = 0; |
| el.progressTrack.hidden = true; |
| el.progressMeta.hidden = true; |
| document.title = "Chorus — AI Voice Studio"; |
| } |
|
|
| function noteProgressFromStatus(text) { |
| if (!text) return; |
| const m = text.match(/wave\s+(\d+)\s*\/\s*(\d+)/i); |
| if (m) { |
| const wave = Number(m[1]); |
| if (wave !== progress.wave) { |
| |
| |
| if (progress.wave && progress.waveStartedAt) { |
| progress.waveDurations.push((Date.now() - progress.waveStartedAt) / 1000); |
| } |
| progress.waveStartedAt = Date.now(); |
| progress.wave = wave; |
| } |
| progress.totalWaves = Number(m[2]); |
| } else if (/re-?rolling/i.test(text)) { |
| progress.label = "Re-rolling flagged chunks"; |
| } |
| paintProgress(); |
| } |
|
|
| el.logToggleBtn.addEventListener("click", () => { |
| const visible = el.logBox.classList.toggle("visible"); |
| el.logToggleBtn.textContent = visible ? "Hide generation log" : "View generation log"; |
| }); |
|
|
| |
| async function fetchPeaks(audioId) { |
| try { |
| const res = await fetch(`/api/audio/${audioId}/peaks?buckets=2048`); |
| if (!res.ok) return null; |
| return (await res.json()).peaks || null; |
| } catch { |
| return null; |
| } |
| } |
|
|
| |
| |
| |
| |
| async function presentTake(audioId, durationSeconds, snapshot) { |
| setStatus("complete"); |
| const url = `/api/audio/${audioId}`; |
| el.resultAudio.src = url; |
| polish.audioId = audioId; |
| el.downloadMp3Btn.hidden = false; |
| el.stageDownloadMp3Btn.hidden = false; |
| el.polishStatus.hidden = true; |
| updateExportLinks(); |
| el.audioDuration.textContent = formatDuration(durationSeconds); |
| el.playerTime.textContent = `0:00 / ${formatClock(durationSeconds)}`; |
| el.stageTime.textContent = `0:00 / ${formatClock(durationSeconds)}`; |
| setPlayIcons("►"); |
| applyPolish(); |
| buildSyncedTranscript(snapshot); |
| el.dockEmpty.hidden = true; |
| el.resultBlock.classList.add("visible"); |
| state.takeDuration = durationSeconds; |
| state.wavePeaks = await fetchPeaks(audioId); |
| rebuildDockWave(); |
| openPlayerStage(); |
| } |
|
|
| |
| |
| async function checkLastTake() { |
| try { |
| const res = await fetch("/api/last-take", { cache: "no-store" }); |
| if (!res.ok) return; |
| const info = await res.json(); |
| const age = info.age_seconds < 120 |
| ? "moments ago" |
| : `${Math.round(info.age_seconds / 60)} min ago`; |
| el.dockEmpty.textContent = |
| `A finished take (${formatDuration(info.duration)}, generated ${age}) is still on the server.`; |
| const btn = document.createElement("button"); |
| btn.type = "button"; |
| btn.className = "btn btn-accent"; |
| btn.style.cssText = "display:block; margin:12px auto 0;"; |
| btn.textContent = "Recover last take"; |
| btn.addEventListener("click", async () => { |
| btn.disabled = true; |
| try { |
| el.generationTime.textContent = "--"; |
| el.resultModel.textContent = "recovered"; |
| state.resultTitle = "Recovered take"; |
| await presentTake(info.audio_id, info.duration, []); |
| } catch (error) { |
| setStatus("error", error.message); |
| btn.disabled = false; |
| } |
| }); |
| el.dockEmpty.append(btn); |
| } catch { } |
| } |
|
|
| |
| |
| |
| function setWorkspaceEnabled(enabled) { |
| document.body.classList.toggle("is-generating", !enabled); |
| document |
| .querySelectorAll(".sidebar button, .sidebar input, .sidebar select, .canvas button, .canvas input, .canvas select, .canvas textarea") |
| .forEach((node) => { |
| if (node === el.generateBtn) return; |
| node.disabled = !enabled; |
| }); |
| } |
|
|
| function fileToBase64(file) { |
| return new Promise((resolve, reject) => { |
| const reader = new FileReader(); |
| reader.onload = () => resolve(String(reader.result).split(",")[1] || ""); |
| reader.onerror = () => reject(new Error(`Could not read ${file.name}`)); |
| reader.readAsDataURL(file); |
| }); |
| } |
|
|
| el.generateBtn.addEventListener("click", async () => { |
| const script = state.turns.map((t) => (t.text || "").trim()).filter(Boolean).join(" "); |
| if (!script) { |
| alert("Add dialogue before generating."); |
| return; |
| } |
|
|
| for (let i = 0; i < state.numSpeakers; i += 1) { |
| if (isCustomVoice(i) && !customVoiceForSlot(i)) { |
| alert(`Speaker ${i + 1}'s cloned voice is missing its clip — re-record it or pick a preset voice.`); |
| return; |
| } |
| } |
| if (anyCustomVoiceActive() && !el.voiceConsentCheckbox.checked) { |
| alert("Confirm you have the right to use each uploaded voice before generating."); |
| return; |
| } |
|
|
| const turnsSnapshot = state.turns |
| .filter((t) => (t.text || "").trim()) |
| .map((t) => ({ speaker: Math.min(4, Math.max(1, t.speaker || 1)), text: t.text.trim() })); |
|
|
| el.generateBtn.disabled = true; |
| el.generateBtn.textContent = "Generating..."; |
| el.resultBlock.classList.remove("visible"); |
| el.resultAudio.pause(); |
| el.dockEmpty.hidden = true; |
| el.logBox.textContent = ""; |
| el.logBox.classList.remove("visible"); |
| el.logToggleBtn.hidden = true; |
| el.logToggleBtn.textContent = "View generation log"; |
| startProgress(); |
| |
| |
| |
| setWorkspaceEnabled(false); |
| openGenerateStage(el.scriptTitle.textContent); |
| setStatus("connecting", nextParodyLine() || "Provisioning GPU resources..."); |
|
|
| let customAudio; |
| try { |
| customAudio = await Promise.all( |
| Array.from({ length: 4 }, (_, i) => { |
| const voice = i < state.numSpeakers ? customVoiceForSlot(i) : null; |
| return voice ? fileToBase64(voice.blob) : Promise.resolve(null); |
| }) |
| ); |
| } catch (error) { |
| setStatus("error", error.message); |
| el.generateBtn.disabled = false; |
| el.generateBtn.textContent = "Generate Audio"; |
| return; |
| } |
|
|
| const payload = { |
| model: state.model, |
| num_speakers: state.numSpeakers, |
| turns: state.turns, |
| speakers: state.voiceSelections.map((v) => ((v || "").startsWith(CUSTOM_PREFIX) ? null : v)), |
| cfg_scale: Number(el.cfgScale.value), |
| custom_audio: customAudio, |
| voice_consent: el.voiceConsentCheckbox.checked, |
| }; |
|
|
| generateAbort = new AbortController(); |
| try { |
| const response = await fetch("/api/generate", { |
| method: "POST", |
| headers: { "Content-Type": "application/json" }, |
| body: JSON.stringify(payload), |
| signal: generateAbort.signal, |
| }); |
| if (!response.ok) { |
| const body = await response.json().catch(() => ({})); |
| throw new Error(body.detail || "The model could not generate audio."); |
| } |
|
|
| const reader = response.body.getReader(); |
| const decoder = new TextDecoder(); |
| let buffer = ""; |
| let done = false; |
|
|
| while (!done) { |
| const chunk = await reader.read(); |
| done = chunk.done; |
| if (chunk.value) buffer += decoder.decode(chunk.value, { stream: true }); |
|
|
| let boundary; |
| while ((boundary = buffer.indexOf("\n\n")) >= 0) { |
| const rawEvent = buffer.slice(0, boundary); |
| buffer = buffer.slice(boundary + 2); |
| if (!rawEvent.startsWith("data: ")) continue; |
| const evt = JSON.parse(rawEvent.slice(6)); |
| const isDone = evt.stage === "complete" || evt.stage === "error"; |
| if (evt.stage === "generating_audio") markGenerationStarted(); |
| |
| noteProgressFromStatus(evt.status); |
| const displayLine = isDone ? evt.status : nextParodyLine() || evt.status; |
| setStatus(evt.stage, displayLine); |
| if (evt.log) { |
| const atBottom = |
| el.logBox.scrollHeight - el.logBox.scrollTop - el.logBox.clientHeight < 40; |
| const stageAtBottom = |
| el.genStageLog.scrollHeight - el.genStageLog.scrollTop - el.genStageLog.clientHeight < 40; |
| el.logBox.textContent = evt.log; |
| el.genStageLog.textContent = evt.log; |
| el.logToggleBtn.hidden = false; |
| el.logBox.classList.add("visible"); |
| el.logToggleBtn.textContent = "Hide generation log"; |
| if (atBottom) el.logBox.scrollTop = el.logBox.scrollHeight; |
| if (stageAtBottom) el.genStageLog.scrollTop = el.genStageLog.scrollHeight; |
| } |
|
|
| if (evt.stage === "complete" && evt.audio_id) { |
| |
| |
| const renderSeconds = generationSeconds(); |
| const warmupSeconds = progress.warmupSecs; |
| el.generationTime.textContent = formatDuration(renderSeconds); |
| if (renderSeconds > 0 && evt.audio_duration) { |
| el.realtimeRow.hidden = false; |
| el.realtimeFactor.textContent = `${(evt.audio_duration / renderSeconds).toFixed(2)}× realtime`; |
| } |
| el.warmupRow.hidden = warmupSeconds < 5; |
| el.warmupTime.textContent = formatDuration(warmupSeconds); |
| el.resultModel.textContent = state.model; |
| state.resultTitle = el.scriptTitle.textContent; |
| await presentTake(evt.audio_id, evt.audio_duration, turnsSnapshot); |
| } |
| } |
| } |
| } catch (error) { |
| if (error.name === "AbortError") { |
| setStatus("cancelled"); |
| } else { |
| setStatus("error", error.message); |
| } |
| el.dockEmpty.hidden = false; |
| checkLastTake(); |
| } finally { |
| stopProgress(); |
| setWorkspaceEnabled(true); |
| generateAbort = null; |
| el.generateBtn.disabled = false; |
| el.generateBtn.textContent = "Generate Audio"; |
| } |
| }); |
|
|
| |
| async function init() { |
| const [models, voices, examples, durationOptions] = await Promise.all([ |
| fetch("/api/models").then((r) => r.json()), |
| fetch("/api/voices").then((r) => r.json()), |
| fetch("/api/examples").then((r) => r.json()), |
| fetch("/api/duration-options").then((r) => r.json()), |
| loadCustomVoices(), |
| ]); |
| state.models = models; |
| state.model = models[0] || null; |
| state.voices = voices; |
| state.examples = examples; |
| state.voiceSelections = voices.slice(0, 4).map((v) => v.name); |
| while (state.voiceSelections.length < 4) state.voiceSelections.push(null); |
|
|
| el.durationSelect.innerHTML = durationOptions |
| .map((m) => `<option value="${m}">${m >= 60 ? "1 hr" : `${m} min`}</option>`) |
| .join(""); |
| const defaultDuration = durationOptions.includes(2) ? 2 : durationOptions[0]; |
| el.durationSelect.value = String(defaultDuration); |
|
|
| renderCast(); |
| renderQuality(); |
| renderTurns(); |
| renderExamplePills(); |
| updateStatus(); |
| checkLastTake(); |
| window.setInterval(updateStatus, 8000); |
| } |
|
|
| init().catch((error) => { |
| el.scriptGenStatus.textContent = `Failed to load app data: ${error.message}`; |
| }); |
|
|