Spaces:
Running on A10G
Running on A10G
| 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 = '<i data-lucide="play" aria-hidden="true"></i>'; | |
| 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 = '<i data-lucide="play" aria-hidden="true"></i>'; | |
| const meta = document.createElement("div"); | |
| meta.className = "voice-meta"; | |
| meta.innerHTML = `<strong>${example.name}</strong><span>${example.locale} / ${example.tone}</span>`; | |
| 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 = '<i data-lucide="square" aria-hidden="true"></i>'; | |
| 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); | |