const state = { examples: [], selectedExample: null, source: "library", activePreview: null, resultUrl: null, uploadUrl: null, uploadValidation: Promise.resolve(true), }; const MAX_REFERENCE_SECONDS = 30; const MAX_SPEECH_UNITS = 150; const INVISIBLE_CHARACTERS = /[\u0000-\u001f\u007f-\u009f\u200b-\u200f\u202a-\u202e\u2060-\u206f\ufeff]/; const CJK_CHARACTER = /[\p{Script=Han}\p{Script=Hiragana}\p{Script=Katakana}\p{Script=Hangul}]/u; const LATIN_OR_NUMBER = /[\p{Script=Latin}\p{Number}]/u; const LETTER_OR_NUMBER = /[\p{Letter}\p{Number}]/u; const textPresets = { zh: "今天想和你分享一个好消息,Audio8 现在可以用更高效的方式生成自然流畅的语音。", en: "Every voice carries a story. With Audio8, that story can travel across languages and reach more people.", ja: "今日は穏やかな一日ですね。新しい音声技術について、ゆっくりお話ししましょう。", es: "Cada voz tiene una historia, y hoy podemos compartirla de una forma más natural y cercana.", }; const elements = { form: document.querySelector("#generationForm"), voiceLibrary: document.querySelector("#voiceLibrary"), uploadPanel: document.querySelector("#uploadPanel"), referenceAudio: document.querySelector("#referenceAudio"), referenceText: document.querySelector("#referenceText"), uploadTitle: document.querySelector("#uploadTitle"), uploadPreview: document.querySelector("#uploadPreview"), speechText: document.querySelector("#speechText"), characterCount: document.querySelector("#characterCount"), consent: document.querySelector("#consent"), generateButton: document.querySelector("#generateButton"), runtimeStatus: document.querySelector("#runtimeStatus"), runtimeLabel: document.querySelector("#runtimeLabel"), emptyOutput: document.querySelector("#emptyOutput"), loadingOutput: document.querySelector("#loadingOutput"), loadingTitle: document.querySelector("#loadingTitle"), elapsedTime: document.querySelector("#elapsedTime"), resultOutput: document.querySelector("#resultOutput"), resultBadge: document.querySelector("#resultBadge"), resultAudio: document.querySelector("#resultAudio"), resultWaveform: document.querySelector("#resultWaveform"), generationTime: document.querySelector("#generationTime"), audioDuration: document.querySelector("#audioDuration"), downloadButton: document.querySelector("#downloadButton"), errorOutput: document.querySelector("#errorOutput"), errorMessage: document.querySelector("#errorMessage"), }; function refreshIcons() { if (window.lucide) { window.lucide.createIcons({ attrs: { "stroke-width": 1.8 } }); } } 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 normalizeSpeechText(value, trim = true) { const separator = "\ue000"; const withSeparators = value.replace(/\\[nrt]|[\n\r\t]/g, separator); const characters = Array.from(withSeparators); const cleaned = []; characters.forEach((character, index) => { if (character === separator) { const previous = [...cleaned].reverse().find((item) => !/\s/.test(item)) || ""; const following = characters.slice(index + 1).find((item) => ( item !== separator && !/\s/.test(item) && !INVISIBLE_CHARACTERS.test(item) )) || ""; if (LATIN_OR_NUMBER.test(previous) && LATIN_OR_NUMBER.test(following)) cleaned.push(" "); } else if (!INVISIBLE_CHARACTERS.test(character)) { cleaned.push(character); } }); const normalized = cleaned.join("").replace(/\s+/g, " "); return trim ? normalized.trim() : normalized; } function countSpeechUnits(value) { let units = 0; let inLatinWord = false; for (const character of value) { if (CJK_CHARACTER.test(character)) { units += 1; inLatinWord = false; } else if (LATIN_OR_NUMBER.test(character)) { if (!inLatinWord) units += 1; inLatinWord = true; } else if (["'", "\u2019", "-"].includes(character) && inLatinWord) { continue; } else { inLatinWord = false; if (LETTER_OR_NUMBER.test(character)) units += 1; } } return units; } function updateSpeechLimit() { const units = countSpeechUnits(elements.speechText.value); elements.characterCount.textContent = units; elements.speechText.setAttribute("aria-invalid", String(units > MAX_SPEECH_UNITS)); elements.generateButton.disabled = ( !elements.consent.checked || units === 0 || units > MAX_SPEECH_UNITS ); return units; } function readAudioDuration(file) { return new Promise((resolve, reject) => { const probe = new Audio(); const url = URL.createObjectURL(file); const cleanup = () => { probe.removeAttribute("src"); URL.revokeObjectURL(url); }; probe.preload = "metadata"; probe.addEventListener("loadedmetadata", () => { const { duration } = probe; cleanup(); Number.isFinite(duration) ? resolve(duration) : reject(new Error("Invalid audio duration")); }, { once: true }); probe.addEventListener("error", () => { cleanup(); reject(new Error("Could not read the reference audio")); }, { once: true }); probe.src = url; }); } function clearReferenceUpload() { if (state.uploadUrl) URL.revokeObjectURL(state.uploadUrl); state.uploadUrl = null; elements.referenceAudio.value = ""; elements.uploadPreview.pause(); elements.uploadPreview.removeAttribute("src"); elements.uploadPreview.load(); elements.uploadPreview.hidden = true; elements.uploadTitle.textContent = "Choose reference audio"; } async function validateReferenceAudio(file) { try { const duration = await readAudioDuration(file); if (elements.referenceAudio.files[0] !== file) return false; if (duration > MAX_REFERENCE_SECONDS) { clearReferenceUpload(); elements.errorMessage.textContent = `Reference audio must be ${MAX_REFERENCE_SECONDS} seconds or shorter.`; showOutput("error"); return false; } return true; } catch (error) { if (elements.referenceAudio.files[0] !== file) return false; clearReferenceUpload(); elements.errorMessage.textContent = error.message; showOutput("error"); return false; } } async function drawWaveform(url, canvas, color = "#8a8780") { const response = await fetch(url); if (!response.ok) return; const data = await response.arrayBuffer(); const context = new AudioContext(); try { const buffer = await context.decodeAudioData(data.slice(0)); const samples = buffer.getChannelData(0); const width = canvas.width; const height = canvas.height; const blocks = Math.min(120, Math.max(36, Math.floor(width / 8))); const blockSize = Math.max(1, Math.floor(samples.length / blocks)); const peaks = []; for (let block = 0; block < blocks; block += 1) { let peak = 0; const start = block * blockSize; const end = Math.min(samples.length, start + blockSize); for (let index = start; index < end; index += 1) { peak = Math.max(peak, Math.abs(samples[index])); } peaks.push(peak); } const maxPeak = Math.max(...peaks, 0.001); const draw = canvas.getContext("2d"); draw.clearRect(0, 0, width, height); draw.fillStyle = color; const barWidth = Math.max(2, width / blocks - 3); peaks.forEach((peak, index) => { const normalized = peak / maxPeak; const barHeight = Math.max(3, normalized * height * 0.86); const x = index * (width / blocks) + 1; draw.fillRect(x, (height - barHeight) / 2, barWidth, barHeight); }); } finally { await context.close(); } } function stopPreview() { if (!state.activePreview) return; state.activePreview.audio.pause(); state.activePreview.audio.currentTime = 0; state.activePreview.button.innerHTML = ''; state.activePreview.button.setAttribute("aria-label", "Play voice sample"); state.activePreview = null; refreshIcons(); } function selectExample(example) { state.selectedExample = example; elements.referenceText.value = example.transcript; document.querySelectorAll(".voice-option").forEach((option) => { const selected = option.dataset.exampleId === example.id; option.classList.toggle("selected", selected); option.setAttribute("aria-checked", String(selected)); }); } function createVoiceOption(example, index) { const option = document.createElement("div"); option.className = "voice-option"; option.dataset.exampleId = example.id; option.setAttribute("role", "radio"); option.setAttribute("aria-checked", "false"); option.tabIndex = 0; const play = document.createElement("button"); play.type = "button"; play.className = "voice-play"; play.setAttribute("aria-label", `Play ${example.name} voice sample`); play.innerHTML = ''; const meta = document.createElement("div"); meta.className = "voice-meta"; meta.innerHTML = `${example.name}${example.locale} / ${example.tone}`; const canvas = document.createElement("canvas"); canvas.width = 360; canvas.height = 56; canvas.setAttribute("aria-hidden", "true"); option.append(play, meta, canvas); option.addEventListener("click", () => selectExample(example)); option.addEventListener("keydown", (event) => { if (event.key === "Enter" || event.key === " ") { event.preventDefault(); selectExample(example); } }); const audio = new Audio(example.audio_url); play.addEventListener("click", (event) => { event.stopPropagation(); if (state.activePreview?.audio === audio && !audio.paused) { stopPreview(); return; } stopPreview(); selectExample(example); audio.play(); play.innerHTML = ''; play.setAttribute("aria-label", `Stop ${example.name} voice sample`); state.activePreview = { audio, button: play }; refreshIcons(); }); audio.addEventListener("ended", stopPreview); drawWaveform(example.audio_url, canvas, index === 0 ? "#e64b2f" : "#8a8780").catch(() => {}); return option; } async function loadExamples() { const response = await fetch("/api/examples"); if (!response.ok) throw new Error("Could not load reference voices"); state.examples = await response.json(); state.examples.forEach((example, index) => { elements.voiceLibrary.append(createVoiceOption(example, index)); }); if (state.examples.length) selectExample(state.examples[0]); refreshIcons(); } function setSource(source) { state.source = source; stopPreview(); document.querySelectorAll(".source-tab").forEach((tab) => { const active = tab.dataset.source === source; tab.classList.toggle("active", active); tab.setAttribute("aria-selected", String(active)); }); elements.voiceLibrary.hidden = source !== "library"; elements.uploadPanel.hidden = source !== "upload"; if (source === "library" && state.selectedExample) { elements.referenceText.value = state.selectedExample.transcript; } } function showOutput(mode) { elements.emptyOutput.hidden = mode !== "empty"; elements.loadingOutput.hidden = mode !== "loading"; elements.resultOutput.hidden = mode !== "result"; elements.errorOutput.hidden = mode !== "error"; elements.resultBadge.hidden = mode !== "result"; } async function updateStatus() { try { const response = await fetch("/api/status", { cache: "no-store" }); const payload = await response.json(); const ready = payload.state === "ready"; elements.runtimeStatus.classList.toggle("ready", ready); elements.runtimeLabel.textContent = ready ? "Model ready" : "Warming up"; } catch { elements.runtimeStatus.classList.remove("ready"); elements.runtimeLabel.textContent = "Connecting"; } } function setGenerating(generating) { elements.generateButton.disabled = generating || !elements.consent.checked; elements.consent.disabled = generating; elements.speechText.disabled = generating; elements.referenceText.disabled = generating; } async function submitGeneration(event) { event.preventDefault(); if (!elements.consent.checked) return; elements.speechText.value = normalizeSpeechText(elements.speechText.value); const speechUnits = updateSpeechLimit(); if (speechUnits === 0 || speechUnits > MAX_SPEECH_UNITS) { showOutput("error"); elements.errorMessage.textContent = `Speech text must be ${MAX_SPEECH_UNITS} Chinese characters or English words or fewer.`; return; } if (state.source === "upload" && !elements.referenceAudio.files.length) { showOutput("error"); elements.errorMessage.textContent = "Choose a reference audio file first."; return; } if (state.source === "upload" && !(await state.uploadValidation)) return; const formData = new FormData(elements.form); if (state.source === "library") { formData.delete("reference_audio"); formData.set("example_id", state.selectedExample?.id || ""); } showOutput("loading"); setGenerating(true); const started = performance.now(); const phases = ["Encoding reference voice", "Generating acoustic tokens", "Decoding 44.1 kHz audio"]; let phase = 0; const ticker = window.setInterval(() => { const elapsed = (performance.now() - started) / 1000; elements.elapsedTime.textContent = `${elapsed.toFixed(1)}s`; const nextPhase = Math.min(phases.length - 1, Math.floor(elapsed / 8)); if (nextPhase !== phase) { phase = nextPhase; elements.loadingTitle.textContent = phases[phase]; } }, 100); try { const response = await fetch("/api/generate", { method: "POST", body: formData }); if (!response.ok) { const payload = await response.json().catch(() => ({})); throw new Error(typeof payload.detail === "string" ? payload.detail : "The model could not generate audio."); } const blob = await response.blob(); if (state.resultUrl) URL.revokeObjectURL(state.resultUrl); state.resultUrl = URL.createObjectURL(blob); elements.resultAudio.src = state.resultUrl; elements.downloadButton.href = state.resultUrl; const serverMs = Number(response.headers.get("x-generation-duration-ms")); const durationMs = Number.isFinite(serverMs) ? serverMs : performance.now() - started; elements.generationTime.textContent = formatDuration(durationMs / 1000); await drawWaveform(state.resultUrl, elements.resultWaveform, "#e64b2f"); showOutput("result"); elements.resultAudio.play().catch(() => {}); } catch (error) { elements.errorMessage.textContent = error.message || "Unexpected generation error"; showOutput("error"); } finally { window.clearInterval(ticker); elements.loadingTitle.textContent = phases[0]; setGenerating(false); } } document.querySelectorAll(".source-tab").forEach((tab) => { tab.addEventListener("click", () => setSource(tab.dataset.source)); }); document.querySelectorAll(".text-presets button").forEach((button) => { button.addEventListener("click", () => { elements.speechText.value = textPresets[button.dataset.preset]; elements.speechText.dispatchEvent(new Event("input")); elements.speechText.focus(); }); }); elements.referenceAudio.addEventListener("change", () => { const file = elements.referenceAudio.files[0]; if (!file) return; if (state.uploadUrl) URL.revokeObjectURL(state.uploadUrl); state.uploadUrl = URL.createObjectURL(file); elements.uploadPreview.src = state.uploadUrl; elements.uploadPreview.hidden = false; elements.uploadTitle.textContent = file.name; state.uploadValidation = validateReferenceAudio(file); }); elements.speechText.addEventListener("input", () => { const normalized = normalizeSpeechText(elements.speechText.value, false); if (normalized !== elements.speechText.value) { elements.speechText.value = normalized; } updateSpeechLimit(); }); elements.consent.addEventListener("change", () => { updateSpeechLimit(); }); elements.resultAudio.addEventListener("loadedmetadata", () => { elements.audioDuration.textContent = formatDuration(elements.resultAudio.duration); }); elements.form.addEventListener("submit", submitGeneration); updateSpeechLimit(); refreshIcons(); loadExamples().catch((error) => { elements.errorMessage.textContent = error.message; showOutput("error"); }); updateStatus(); window.setInterval(updateStatus, 5000);