import { app } from "../../scripts/app.js"; import { api } from "../../scripts/api.js"; const TARGET = "AudioCppEngineNode"; const ENDPOINT = "/api/tts-audio-suite/audio-cpp-capabilities"; const STATUS_ENDPOINT = "/api/tts-audio-suite/audio-cpp-status"; const PANEL_HEIGHT = 210; const PANEL_MIN_WIDTH = 360; const PANEL_BOTTOM_PADDING = 14; const PANEL_LAYOUT_HEIGHT = PANEL_HEIGHT + PANEL_BOTTOM_PADDING; const REQUEST_ADVANCED_WIDGETS = [ "temperature", "top_p", "top_k", "repetition_penalty", "max_tokens", "max_steps", "num_inference_steps", "guidance_scale", "advanced_json", ]; const OWNED_ADVANCED_WIDGETS = [ "auto_download_runtime", "auto_download_model", "show_server_console", ]; let manifestPromise; function manifest() { manifestPromise ??= api.fetchApi(ENDPOINT).then((response) => { if (!response.ok) throw new Error(`Capability request failed (${response.status})`); return response.json(); }); return manifestPromise; } function widget(node, name) { return (node.widgets || []).find((item) => item.name === name); } function hideWidget(item) { if (!item || item.__ttsAudioCppHidden) return; item.__ttsAudioCppOriginalType = item.type; item.__ttsAudioCppOriginalComputeSize = item.computeSize; item.type = "hidden"; item.hidden = true; item.computeSize = () => [0, -4]; if (item.element) item.element.style.display = "none"; item.__ttsAudioCppHidden = true; } function showWidget(item) { if (!item || !item.__ttsAudioCppHidden) return; item.type = item.__ttsAudioCppOriginalType; item.computeSize = item.__ttsAudioCppOriginalComputeSize; item.hidden = false; if (item.element) item.element.style.display = ""; item.__ttsAudioCppHidden = false; } function setWidgetVisible(node, name, visible) { const item = widget(node, name); if (visible) showWidget(item); else hideWidget(item); } function resizeNodeToContent(node) { if (node.__ttsAudioCppResizeFrame) cancelAnimationFrame(node.__ttsAudioCppResizeFrame); node.__ttsAudioCppResizeFrame = requestAnimationFrame(() => { node.__ttsAudioCppResizeFrame = 0; const computed = node.computeSize(); const width = Math.max(Number(node.size?.[0]) || 0, PANEL_MIN_WIDTH, Number(computed?.[0]) || 0); const height = Number(computed?.[1]) || Number(node.size?.[1]) || PANEL_LAYOUT_HEIGHT; if (Math.abs(width - node.size[0]) > 0.5 || Math.abs(height - node.size[1]) > 0.5) { node.setSize([width, height]); } app.graph?.setDirtyCanvas(true, true); }); } function applyWidgetVisibility(node, capability) { if (!capability) return; const advanced = Boolean(node.__ttsAudioCppAdvancedOpen); const mode = String(widget(node, "connection_mode")?.value || "auto"); const backend = String(widget(node, "backend")?.value || "auto"); const external = mode === "external_server"; const existingBinary = mode === "existing_binary"; const suiteTasks = new Set(capability.suite_tasks || []); const upstreamTasks = new Set(capability.upstream_tasks || []); setWidgetVisible(node, "package_id", !external); setWidgetVisible(node, "task", external || upstreamTasks.size > 1 || advanced); setWidgetVisible(node, "backend", !external); setWidgetVisible(node, "device", !external && backend !== "cpu" && advanced); setWidgetVisible(node, "threads", !external && (backend === "cpu" || advanced)); setWidgetVisible(node, "language", suiteTasks.has("tts") || suiteTasks.has("asr")); setWidgetVisible(node, "server_url", external); setWidgetVisible(node, "binary_path", existingBinary || (!external && advanced)); setWidgetVisible(node, "model_path", existingBinary || (!external && advanced)); setWidgetVisible(node, "model_id", external); setWidgetVisible(node, "voice_id", Boolean(capability.built_in_voices)); setWidgetVisible(node, "instruct", Boolean(capability.voice_design)); for (const name of REQUEST_ADVANCED_WIDGETS) setWidgetVisible(node, name, advanced); for (const name of OWNED_ADVANCED_WIDGETS) setWidgetVisible(node, name, !external && advanced); const toggle = widget(node, "audio_cpp_advanced_toggle"); if (toggle) { toggle.label = advanced ? "▾ Hide advanced settings" : "▸ Show advanced settings"; } resizeNodeToContent(node); } function addAdvancedToggle(node) { const toggle = node.addWidget("button", "▸ Show advanced settings", null, () => { node.__ttsAudioCppAdvancedOpen = !node.__ttsAudioCppAdvancedOpen; applyWidgetVisibility(node, node.__ttsAudioCppCapability); }); toggle.name = "audio_cpp_advanced_toggle"; toggle.label = "▸ Show advanced settings"; toggle.options ??= {}; toggle.options.tooltip = "Show uncommon runtime paths, device tuning, sampling overrides, download controls, server debugging, and raw request JSON."; toggle.options.serialize = false; toggle.tooltip = toggle.options.tooltip; toggle.serialize = false; toggle.serializeValue = () => undefined; return toggle; } function formatBytes(bytes) { const value = Number(bytes); if (!Number.isFinite(value) || value <= 0) return "unavailable"; if (value >= 1073741824) return `${(value / 1073741824).toFixed(2)} GB`; return `${(value / 1048576).toFixed(1)} MB`; } function syncPackageChoices(node, data, capability) { const packageWidget = widget(node, "package_id"); if (!packageWidget || !capability) return null; const allowed = ["auto", ...(capability.packages || [])]; packageWidget.options ??= {}; packageWidget.options.values = allowed; if (!allowed.includes(String(packageWidget.value))) packageWidget.value = "auto"; const resolvedId = packageWidget.value === "auto" ? capability.recommended_package_id : String(packageWidget.value); return data.packages?.[resolvedId] || null; } function syncTaskChoices(node, capability) { const taskWidget = widget(node, "task"); if (!taskWidget || !capability) return; const allowed = ["auto", ...(capability.upstream_tasks || [])]; taskWidget.options ??= {}; taskWidget.options.values = [...new Set(allowed)]; if (!taskWidget.options.values.includes(String(taskWidget.value))) taskWidget.value = "auto"; } function normalizedUrl(value) { return String(value || "").trim().replace(/\/$/, ""); } function matchingStatus(node, sessions) { const family = String(widget(node, "family")?.value || ""); const modelId = String(widget(node, "model_id")?.value || "").trim(); const mode = String(widget(node, "connection_mode")?.value || "auto"); const endpoint = normalizedUrl(widget(node, "server_url")?.value); return (sessions || []).find((session) => { if (modelId && session.model_id !== modelId) return false; if (mode === "external_server" && endpoint) { return !session.owned && normalizedUrl(session.endpoint) === endpoint; } return session.family === family && (mode === "external_server" ? !session.owned : true); }); } function renderStatus(node, panel, sessions, failed = false) { const status = matchingStatus(node, sessions); const light = panel.querySelector(".tts-acpp-light"); const label = panel.querySelector(".tts-acpp-status-text"); const state = failed ? "error" : (status?.state || "unchecked"); const labels = { unchecked: "Not checked", configured: "Server stopped", server_ready: "Server connected · model not confirmed", model_ready: "Server connected · model ready", error: "Status unavailable", }; light.dataset.state = state; label.textContent = labels[state] || state; const memory = panel.querySelector(".tts-acpp-memory"); const stop = panel.querySelector(".tts-acpp-stop"); node.__ttsAudioCppSessionId = status?.session_id || ""; if (status?.pid) { const privateGb = status.private_bytes ? ` · ${(status.private_bytes / 1073741824).toFixed(1)} GB private` : ""; memory.textContent = `PID ${status.pid}${privateGb}`; memory.hidden = false; } else { memory.hidden = true; } stop.hidden = !(status?.owned && ["server_ready", "model_ready"].includes(status.state)); } async function refreshStatus(node, panel) { try { const response = await api.fetchApi(STATUS_ENDPOINT); if (!response.ok) throw new Error(`Status request failed (${response.status})`); const data = await response.json(); renderStatus(node, panel, data.sessions); } catch (_error) { renderStatus(node, panel, [], true); } } function inputIsSpeaker(input) { return /^speaker\d+$/.test(String(input?.name || "")); } function removeUnusedSpeakerInputs(node, maximum) { for (let index = (node.inputs || []).length - 1; index >= 0; index -= 1) { const input = node.inputs[index]; const number = Number(String(input?.name || "").replace("speaker", "")); if (inputIsSpeaker(input) && input.link == null && number > maximum) node.removeInput(index); } } function syncSpeakers(node, capability) { const native = capability?.native_multi_speaker || {}; const maximum = native.supported ? Number(native.max_speakers || 1) : 1; removeUnusedSpeakerInputs(node, maximum); const speakers = (node.inputs || []).filter(inputIsSpeaker); if (maximum <= 1) { for (let index = (node.inputs || []).length - 1; index >= 0; index -= 1) { if (inputIsSpeaker(node.inputs[index]) && node.inputs[index].link == null) node.removeInput(index); } return; } speakers.forEach((input, index) => { input.name = `speaker${index + 2}`; input.label = `Speaker ${index + 2}`; }); const last = speakers[speakers.length - 1]; if (speakers.length < maximum - 1 && (!last || last.link != null)) { node.addInput(`speaker${speakers.length + 2}`, "*"); } } function pill(text, tone = "normal", title = "") { const element = document.createElement("span"); element.textContent = text; element.className = `tts-acpp-pill ${tone}`; if (title) element.title = title; return element; } function appendAsrFeaturePills(container, capability) { const features = capability.asr_features || {}; if (features.diarization === "native") { container.append(pill( "Diarization", "special", "Native speaker-attributed turns are available from this ASR family.", )); } else { container.append(pill( "No diarization", "muted", "This ASR family does not return speaker identities.", )); } const timing = features.timing || "none"; if (timing === "native_word") { container.append(pill( "Word timestamps", "info", "Native word or token timestamps are available without a separate aligner.", )); } else if (timing === "native_segment") { container.append(pill( "Segment timestamps", "info", "Native timed transcript or speaker segments are available.", )); } else if (timing === "optional_forced_aligner") { container.append(pill( "Optional forced aligner", "warn", "Word timestamps require the separate Qwen3 Forced Aligner model.", )); } else { container.append(pill( "No timestamps", "muted", "This ASR family currently returns transcription text without timing alignment.", )); } } function setWidgetHeight(widget, height) { try { widget.height = height; } catch (_error) { /* getter-only on some builds */ } try { widget.computedHeight = height; } catch (_error) { /* getter-only on some builds */ } } function makePanel(node) { const panel = document.createElement("div"); panel.className = "tts-acpp-panel"; panel.innerHTML = `
audio.cpp capabilities
Not checked
`; panel.querySelector(".tts-acpp-stop").addEventListener("click", async () => { const sessionId = node.__ttsAudioCppSessionId; if (!sessionId) return; const response = await api.fetchApi("/api/tts-audio-suite/audio-cpp-stop", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ session_id: sessionId }), }); if (!response.ok) { const data = await response.json().catch(() => ({})); throw new Error(data.error || `Stop failed (${response.status})`); } refreshStatus(node, panel); }); const panelWidget = node.addDOMWidget("audio_cpp_capabilities", "div", panel, { serialize: false, hideOnZoom: false, getMinHeight: () => PANEL_LAYOUT_HEIGHT, getHeight: () => PANEL_LAYOUT_HEIGHT, }); panelWidget.computeSize = (inputWidth) => { const width = Array.isArray(inputWidth) ? inputWidth[0] : inputWidth; return [Math.max(PANEL_MIN_WIDTH, Number(width) || PANEL_MIN_WIDTH), PANEL_LAYOUT_HEIGHT]; }; panelWidget.getHeight = () => PANEL_LAYOUT_HEIGHT; panelWidget.computeLayoutSize = () => ({ minWidth: PANEL_MIN_WIDTH, minHeight: PANEL_LAYOUT_HEIGHT, }); panelWidget.options ??= {}; panelWidget.options.minNodeSize = [PANEL_MIN_WIDTH, PANEL_LAYOUT_HEIGHT]; setWidgetHeight(panelWidget, PANEL_LAYOUT_HEIGHT); if (panelWidget.element) { panelWidget.element.style.boxSizing = "border-box"; panelWidget.element.style.width = "100%"; panelWidget.element.style.maxWidth = "100%"; panelWidget.element.style.height = `${PANEL_HEIGHT}px`; panelWidget.element.style.minHeight = `${PANEL_HEIGHT}px`; panelWidget.element.style.overflow = "hidden"; } return panel; } function render(node, panel, data, capability) { if (!capability) return; node.__ttsAudioCppCapability = capability; const selectedPackage = syncPackageChoices(node, data, capability); syncTaskChoices(node, capability); panel.querySelector(".tts-acpp-title").textContent = capability.display_name; const pills = panel.querySelector(".tts-acpp-pills"); pills.replaceChildren(); const suiteTasks = new Set(capability.suite_tasks || []); if (suiteTasks.has("tts")) pills.append(pill("TTS", "good", "Text-to-speech is wired to the Suite's Unified Text and SRT nodes.")); if (suiteTasks.has("asr")) pills.append(pill("ASR", "good", "Speech recognition is wired to the Suite's Unified ASR node.")); if (suiteTasks.has("voice_conversion")) pills.append(pill("Voice conversion", "good", "Voice conversion is wired to the Suite's Unified Voice Changer node.")); if (suiteTasks.has("asr")) appendAsrFeaturePills(pills, capability); if (suiteTasks.has("tts") && capability.reference_audio !== "none") pills.append(pill("Voice clone", "normal", "This family accepts reference audio for voice cloning or conditioning.")); if (["required", "required_per_speaker"].includes(capability.reference_audio)) { pills.append(pill("Reference required", "warn", "Generation requires reference audio.")); } if (capability.reference_transcript === "required") { pills.append(pill("Transcript required", "warn", "The transcript matching the reference audio is required.")); } if (capability.built_in_voices) pills.append(pill("Built-in voices", "info", "This family includes model-provided voices.")); if (capability.voice_design) pills.append(pill("Voice design", "normal", "This family can synthesize from a written voice description.")); if (capability.inline_controls) pills.append(pill("Inline controls", "normal", "Suite-standard inline controls are translated for this family.")); if (capability.native_multi_speaker?.supported) { const status = capability.native_multi_speaker.suite_status === "supported" ? "good" : "warn"; pills.append(pill(`Up to ${capability.native_multi_speaker.max_speakers} speakers`, status)); } const transcript = capability.reference_transcript; let taskDetails = ""; if (suiteTasks.has("tts")) { taskDetails = `
Reference audio: ${capability.reference_audio.replaceAll("_", " ")}
` + `
Reference transcript: ${transcript}
`; } else if (suiteTasks.has("asr")) { const languages = capability.languages || []; const languageSummary = languages.length > 8 ? `${languages.length} declared languages` : languages.join(", "); taskDetails = `
Languages: ${languageSummary || "model-defined"}
`; } else if (suiteTasks.has("voice_conversion")) { taskDetails = `
Inputs: source audio + target reference audio
`; } panel.querySelector(".tts-acpp-details").innerHTML = `
Package: ${selectedPackage?.display_name || "external server / unresolved"}
` + `
Estimated download: ${formatBytes(selectedPackage?.estimated_download_bytes)}
` + taskDetails + `
Suite: ${(capability.suite_tasks || []).join(" · ")}
`; panel.querySelector(".tts-acpp-summary").textContent = capability.summary || capability.description; syncSpeakers(node, capability); applyWidgetVisibility(node, capability); } function hookWidgetCallback(node, name, callback) { const item = widget(node, name); if (!item || item.__ttsAudioCppCallbackHooked) return; item.__ttsAudioCppCallbackHooked = true; const original = item.callback; item.callback = (...args) => { const result = original?.apply(item, args); callback(); return result; }; } app.registerExtension({ name: "TTS_Audio_Suite.AudioCppCapabilities", async beforeRegisterNodeDef(nodeType, nodeData) { if ((nodeData?.name || nodeData?.comfyClass) !== TARGET) return; const original = nodeType.prototype.onNodeCreated; nodeType.prototype.onNodeCreated = function() { const result = original?.apply(this, arguments); addAdvancedToggle(this); const panel = makePanel(this); const family = widget(this, "family"); const update = () => manifest().then((data) => render(this, panel, data, data.families?.[family?.value])).catch((error) => { panel.querySelector(".tts-acpp-summary").textContent = error.message; }); hookWidgetCallback(this, "family", update); hookWidgetCallback(this, "package_id", update); hookWidgetCallback(this, "task", update); hookWidgetCallback(this, "connection_mode", () => applyWidgetVisibility(this, this.__ttsAudioCppCapability)); hookWidgetCallback(this, "backend", () => applyWidgetVisibility(this, this.__ttsAudioCppCapability)); const refresh = () => refreshStatus(this, panel); this.__ttsAudioCppStatusRefresh = refresh; setTimeout(() => { update(); refresh(); }, 0); this.__ttsAudioCppStatusTimer = setInterval(refresh, 5000); return result; }; const removed = nodeType.prototype.onRemoved; nodeType.prototype.onRemoved = function() { clearInterval(this.__ttsAudioCppStatusTimer); if (this.__ttsAudioCppResizeFrame) cancelAnimationFrame(this.__ttsAudioCppResizeFrame); return removed?.apply(this, arguments); }; const connectionChanged = nodeType.prototype.onConnectionsChange; nodeType.prototype.onConnectionsChange = function(type, index) { const result = connectionChanged?.apply(this, arguments); if (inputIsSpeaker(this.inputs?.[index])) { const family = widget(this, "family")?.value; manifest().then((data) => syncSpeakers(this, data.families?.[family])); } return result; }; }, setup() { for (const eventName of ["executing", "executed", "execution_error"]) { api.addEventListener(eventName, () => { for (const node of app.graph?._nodes || []) node.__ttsAudioCppStatusRefresh?.(); }); } }, });