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);