aliSaac510's picture
feat: integrate Supertonic-3 TTS support
081af21
Raw
History Blame Contribute Delete
13.9 kB
(function () {
const state = {
defaults: null,
providers: [],
voices: [],
voicesLoaded: false,
activeVoiceFilter: "",
payload: null,
queueIndex: 0,
lastRequest: null,
voiceLoadTimer: null,
voicesProvider: null,
};
const elements = {
form: document.getElementById("tts-form"),
providerSelect: document.getElementById("provider-select"),
langSelect: document.getElementById("lang-select"),
slowInput: document.getElementById("slow-input"),
textInput: document.getElementById("text-input"),
edgeControls: document.getElementById("edge-controls"),
voiceFilterInput: document.getElementById("voice-filter-input"),
voiceSelect: document.getElementById("voice-select"),
loadVoicesButton: document.getElementById("load-voices-button"),
rateInput: document.getElementById("rate-input"),
volumeInput: document.getElementById("volume-input"),
pitchInput: document.getElementById("pitch-input"),
generateButton: document.getElementById("generate-button"),
sampleButton: document.getElementById("sample-button"),
statusPill: document.getElementById("status-pill"),
summaryBadge: document.getElementById("summary-badge"),
messageBox: document.getElementById("message-box"),
player: document.getElementById("player"),
openLink: document.getElementById("open-link"),
warningsBox: document.getElementById("warnings-box"),
directUrl: document.getElementById("direct-url"),
proxyUrl: document.getElementById("proxy-url"),
jsonOutput: document.getElementById("json-output"),
directUrlLabel: document.getElementById("direct-url-label"),
proxyUrlLabel: document.getElementById("proxy-url-label"),
jsonOutputLabel: document.getElementById("json-output-label"),
baseHtmlSnippet: document.getElementById("base-html-snippet"),
exampleGetUrl: document.getElementById("example-get-url"),
exampleAudioUrl: document.getElementById("example-audio-url"),
exampleVoicesUrl: document.getElementById("example-voices-url"),
audioTagSnippet: document.getElementById("audio-tag-snippet"),
fetchGetSnippet: document.getElementById("fetch-get-snippet"),
fetchPostSnippet: document.getElementById("fetch-post-snippet"),
translateResponseSnippet: document.getElementById("translate-response-snippet"),
edgeResponseSnippet: document.getElementById("edge-response-snippet"),
processSummary: document.getElementById("process-summary"),
};
bindEvents();
bootstrap();
function bindEvents() {
elements.form.addEventListener("submit", handleSubmit);
elements.providerSelect.addEventListener("change", syncProviderMode);
elements.loadVoicesButton.addEventListener("click", loadVoices);
elements.textInput.addEventListener("input", updateExamples);
elements.langSelect.addEventListener("change", updateExamples);
elements.providerSelect.addEventListener("change", updateExamples);
elements.slowInput.addEventListener("change", updateExamples);
elements.voiceSelect.addEventListener("change", updateExamples);
elements.rateInput.addEventListener("input", updateExamples);
elements.volumeInput.addEventListener("input", updateExamples);
elements.pitchInput.addEventListener("input", updateExamples);
elements.voiceFilterInput.addEventListener("input", function () {
const nextFilter = elements.voiceFilterInput.value.trim();
if (nextFilter !== state.activeVoiceFilter) {
state.voicesLoaded = false;
state.voices = [];
renderVoices();
scheduleVoiceLoad();
}
});
elements.sampleButton.addEventListener("click", function () {
if (state.defaults) {
elements.textInput.value = state.defaults.text || "";
}
elements.textInput.focus();
updateExamples();
});
elements.player.addEventListener("ended", handlePlayerEnded);
document.querySelectorAll("[data-copy-target]").forEach(function (button) {
button.addEventListener("click", function () {
copyField(button.getAttribute("data-copy-target"));
});
});
}
async function bootstrap() {
setStatus("Loading");
setMessage("Loading configuration...");
try {
const bootstrapData = await fetchJson("/api/bootstrap");
state.defaults = bootstrapData.defaults || {};
state.providers = bootstrapData.providers || [];
renderLanguages();
applyDefaults();
setStatus("Ready");
setMessage("Enter text and click Generate audio.");
} catch (error) {
setStatus("Error");
setMessage(error.message || "Could not load configuration.");
}
}
function renderProviders() {
elements.providerSelect.innerHTML = "";
state.providers.forEach(function (provider) {
const option = document.createElement("option");
option.value = provider.id;
option.textContent = provider.displayName;
elements.providerSelect.appendChild(option);
});
}
function renderLanguages() {
elements.langSelect.innerHTML = "";
(state.defaults.translateLanguages || []).forEach(function (language) {
const option = document.createElement("option");
option.value = language.code;
option.textContent = language.label + " (" + language.code + ")";
elements.langSelect.appendChild(option);
});
}
function applyDefaults() {
renderProviders();
elements.providerSelect.value = state.defaults.provider || "edge";
elements.langSelect.value = state.defaults.lang || "en";
elements.textInput.value = state.defaults.text || "";
elements.voiceFilterInput.value = "";
elements.rateInput.value = state.defaults.rate || "+0%";
elements.volumeInput.value = state.defaults.volume || "+0%";
elements.pitchInput.value = state.defaults.pitch || "+0Hz";
renderVoices();
syncProviderMode();
updateExamples();
}
async function loadVoices() {
const provider = elements.providerSelect.value || "edge";
if (
state.voicesLoaded &&
state.voicesProvider === provider &&
state.activeVoiceFilter === elements.voiceFilterInput.value.trim()
) {
renderVoices();
return;
}
setMessage("Loading available voices...");
elements.loadVoicesButton.disabled = true;
try {
const filter = elements.voiceFilterInput.value.trim();
const voicesUrl = filter
? "/api/voices?provider=" + encodeURIComponent(provider) + "&filter=" + encodeURIComponent(filter)
: "/api/voices?provider=" + encodeURIComponent(provider);
const response = await fetchJson(voicesUrl);
state.voices = response.items || [];
state.voicesLoaded = true;
state.voicesProvider = provider;
state.activeVoiceFilter = filter;
renderVoices();
setMessage(
state.voices.length
? "Loaded " + state.voices.length + " voices."
: "No voices matched this filter. Try clearing the filter."
);
} catch (error) {
state.voices = [];
state.voicesLoaded = false;
renderVoices();
setMessage(error.message || "Could not load voices.");
} finally {
elements.loadVoicesButton.disabled = false;
}
}
function renderVoices() {
elements.voiceSelect.innerHTML = "";
if (!state.voices.length) {
const option = document.createElement("option");
option.value = state.defaults ? state.defaults.voice : "en-US-AriaNeural";
option.textContent = state.voicesLoaded ? "No voices found" : "Loading voices...";
elements.voiceSelect.appendChild(option);
return;
}
state.voices.forEach(function (voice, index) {
const option = document.createElement("option");
option.value = voice.name;
option.textContent = voice.name + " (" + voice.locale + ", " + voice.gender + ")";
const isKokoroDefault = elements.providerSelect.value === "kokoro" && voice.name === "af_heart";
if (index === 0 || isKokoroDefault || voice.name === (state.defaults && state.defaults.voice)) {
option.selected = true;
}
elements.voiceSelect.appendChild(option);
});
}
function syncProviderMode() {
const provider = elements.providerSelect.value;
elements.edgeControls.classList.toggle("hidden", provider !== "edge" && provider !== "kokoro" && provider !== "supertonic");
if (provider === "kokoro" || provider === "supertonic") {
elements.voiceFilterInput.value = "";
}
updateExamples();
elements.generateButton.disabled = !provider;
if (state.voicesProvider !== provider) {
state.voicesLoaded = false;
state.voices = [];
state.activeVoiceFilter = "";
renderVoices();
}
if ((provider === "edge" || provider === "kokoro" || provider === "supertonic") && !state.voicesLoaded) {
setMessage(provider.toUpperCase() + " provider selected. Loading voices...");
loadVoices();
}
}
function scheduleVoiceLoad() {
const provider = elements.providerSelect.value;
if (provider !== "edge" && provider !== "kokoro" && provider !== "supertonic") {
return;
}
if (state.voiceLoadTimer) {
clearTimeout(state.voiceLoadTimer);
}
state.voiceLoadTimer = setTimeout(function () {
loadVoices();
}, 280);
}
async function handleSubmit(event) {
event.preventDefault();
const payload = collectPayload();
const requestUrl = window.location.origin + "/api/synthesize";
state.lastRequest = {
url: requestUrl,
body: payload,
};
setBusy(true);
setStatus("Loading");
setMessage("Generating audio...");
try {
const response = await fetch("/api/synthesize", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(payload),
});
const body = await response.json();
if (!response.ok) {
throw new Error(body.detail || "Could not generate audio.");
}
state.payload = body;
state.queueIndex = 0;
renderPayload();
setStatus("Ready");
setMessage(body.provider.toUpperCase() + " audio generated successfully.");
autoplayFirst();
} catch (error) {
setStatus("Error");
setMessage(error.message || "Could not generate audio.");
} finally {
setBusy(false);
}
}
function collectPayload() {
return {
provider: elements.providerSelect.value,
text: elements.textInput.value.trim(),
lang: elements.langSelect.value,
slow: elements.slowInput.checked,
voice: elements.voiceSelect.value,
rate: elements.rateInput.value.trim() || "+0%",
volume: elements.volumeInput.value.trim() || "+0%",
pitch: elements.pitchInput.value.trim() || "+0Hz",
};
}
function renderPayload() {
const payload = state.payload || {};
const requestMeta = state.lastRequest || {};
const requestUrl = requestMeta.url || (window.location.origin + "/api/synthesize");
const requestBody = requestMeta.body || collectPayload();
fillReadonly(elements.directUrl, requestUrl);
fillReadonly(elements.proxyUrl, JSON.stringify(requestBody, null, 2));
fillReadonly(elements.jsonOutput, JSON.stringify(payload, null, 2));
elements.directUrlLabel.textContent = "Request URL";
elements.proxyUrlLabel.textContent = "Request Body";
elements.jsonOutputLabel.textContent = "POST /api/synthesize response";
elements.player.src = payload.audioUrl || "";
elements.summaryBadge.textContent = (payload.provider || "tts").toUpperCase();
toggleOpenLink(payload.audioUrl || "");
renderWarnings(["Audio generated via " + (payload.provider || "selected") + " provider."]);
if (elements.player.src) {
elements.player.load();
}
updateExamples();
}
function renderWarnings(items) {
elements.warningsBox.innerHTML = "";
(items || []).forEach(function (item) {
const warning = document.createElement("div");
warning.className = "warning";
warning.textContent = item;
elements.warningsBox.appendChild(warning);
});
}
function autoplayFirst() {
if (!elements.player.src) {
return;
}
const promise = elements.player.play();
if (promise && typeof promise.catch === "function") {
promise.catch(function () {
setMessage("Audio is ready. If autoplay is blocked, click Play manually.");
});
}
}
function handlePlayerEnded() {
return;
}
function updateExamples() {
return;
}
async function fetchJson(url) {
const response = await fetch(url);
const body = await response.json();
if (!response.ok) {
throw new Error(body.detail || "Request failed.");
}
return body;
}
function setBusy(isBusy) {
elements.generateButton.disabled = isBusy;
elements.generateButton.textContent = isBusy ? "Generating..." : "Generate audio";
}
function setStatus(value) {
elements.statusPill.textContent = value;
}
function setMessage(value) {
elements.messageBox.textContent = value;
}
function fillReadonly(node, value) {
if (!node) {
return;
}
node.value = value || "";
}
function toggleOpenLink(url) {
if (!url) {
elements.openLink.classList.add("hidden");
elements.openLink.href = "#";
return;
}
elements.openLink.classList.remove("hidden");
elements.openLink.href = url;
}
function copyField(targetId) {
const field = document.getElementById(targetId);
if (!field || !field.value) {
return;
}
if (navigator.clipboard && navigator.clipboard.writeText) {
navigator.clipboard.writeText(field.value).then(function () {
setMessage("Copied: " + targetId);
});
return;
}
field.focus();
field.select();
document.execCommand("copy");
setMessage("Copied: " + targetId);
}
})();