(function () {
"use strict";
// The Gradio JS client is loaded as a global from CDN (see index.html).
// It exposes `window.gradio` with `Client` and `handle_file`.
const { Client, handle_file } = window.gradio;
// ------------------------------------------------------------------ //
// State //
// ------------------------------------------------------------------ //
const state = {
presets: null,
selectedFile: null,
isWorking: false,
client: null,
};
// ------------------------------------------------------------------ //
// DOM helpers //
// ------------------------------------------------------------------ //
const $ = (sel) => document.querySelector(sel);
const $$ = (sel) => document.querySelectorAll(sel);
function el(tag, attrs = {}, ...children) {
const node = document.createElement(tag);
for (const [k, v] of Object.entries(attrs)) {
if (k === "class") node.className = v;
else if (k === "html") node.innerHTML = v;
else if (k.startsWith("on") && typeof v === "function") {
node.addEventListener(k.slice(2).toLowerCase(), v);
} else if (v !== null && v !== undefined) {
node.setAttribute(k, v);
}
}
for (const child of children) {
if (child === null || child === undefined) continue;
node.appendChild(typeof child === "string" ? document.createTextNode(child) : child);
}
return node;
}
// ------------------------------------------------------------------ //
// Status / metrics //
// ------------------------------------------------------------------ //
function setStatus(text, kind = "ready") {
const box = $("#status");
box.className = `status ${kind}`;
box.innerHTML = text;
}
function setMetrics({ dur = "—", est = "—", stems = "—" } = {}) {
$("#metric-dur").textContent = dur;
$("#metric-est").textContent = est;
$("#metric-stems").textContent = stems;
}
// ------------------------------------------------------------------ //
// Slider value display //
// ------------------------------------------------------------------ //
function bindSliderDisplay(sliderId, displayId, formatter = (v) => v) {
const slider = document.getElementById(sliderId);
const display = document.getElementById(displayId);
if (!slider || !display) return;
const update = () => { display.textContent = formatter(slider.value); };
slider.addEventListener("input", update);
update();
}
// ------------------------------------------------------------------ //
// Dropzone & file input //
// ------------------------------------------------------------------ //
function initDropzone() {
const dz = $("#dropzone");
const input = $("#file-input");
const preview = $("#preview");
const previewWrap = $("#preview-wrap");
const previewName = $("#preview-name");
const previewDur = $("#preview-dur");
const clearBtn = $("#clear-input");
const separateBtn = $("#separate-btn");
function handleFile(file) {
if (!file) return;
if (!file.type.startsWith("audio/") && !/\.(wav|mp3|flac|m4a|ogg|aac|opus)$/i.test(file.name)) {
setStatus(`⚠️ "${file.name}" doesn't look like an audio file. Try anyway?`, "error");
}
state.selectedFile = file;
const url = URL.createObjectURL(file);
preview.src = url;
previewName.textContent = file.name;
previewDur.textContent = `${(file.size / 1024 / 1024).toFixed(2)} MB`;
previewWrap.hidden = false;
dz.style.display = "none";
separateBtn.disabled = false;
setStatus(`Ready. Click Separate audio to process ${file.name}.`);
}
function clearFile() {
state.selectedFile = null;
preview.src = "";
previewWrap.hidden = true;
dz.style.display = "";
separateBtn.disabled = true;
setStatus("Ready. Upload audio and click Separate.");
setMetrics({});
}
// Click to browse
dz.addEventListener("click", () => input.click());
dz.addEventListener("keydown", (e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
input.click();
}
});
// File picker
input.addEventListener("change", (e) => {
const file = e.target.files && e.target.files[0];
if (file) handleFile(file);
});
// Drag & drop
["dragenter", "dragover"].forEach((ev) => {
dz.addEventListener(ev, (e) => {
e.preventDefault();
dz.classList.add("dragging");
});
});
["dragleave", "drop"].forEach((ev) => {
dz.addEventListener(ev, (e) => {
e.preventDefault();
dz.classList.remove("dragging");
});
});
dz.addEventListener("drop", (e) => {
const file = e.dataTransfer.files && e.dataTransfer.files[0];
if (file) handleFile(file);
});
clearBtn.addEventListener("click", clearFile);
// Compute duration once metadata loads
preview.addEventListener("loadedmetadata", () => {
const s = preview.duration;
if (isFinite(s)) {
const mm = Math.floor(s / 60).toString().padStart(2, "0");
const ss = Math.floor(s % 60).toString().padStart(2, "0");
previewDur.textContent = `${mm}:${ss} · ${(state.selectedFile.size / 1024 / 1024).toFixed(2)} MB`;
}
});
}
// ------------------------------------------------------------------ //
// Preset / model dropdowns //
// ------------------------------------------------------------------ //
async function loadPresets() {
try {
const result = await state.client.predict("/presets", {});
const data = result.data[0];
state.presets = data;
const useCaseSel = $("#use-case");
useCaseSel.innerHTML = "";
Object.keys(data.presets).forEach((name) => {
const opt = el("option", { value: name }, name);
if (name === data.default_use_case) opt.selected = true;
useCaseSel.appendChild(opt);
});
// Load model list for the default architecture
await refreshModelDropdown(data.default_use_case);
// Trigger initial preset description
useCaseSel.dispatchEvent(new Event("change"));
} catch (err) {
console.error("Failed to load presets:", err);
setStatus(`⚠️ Failed to load presets: ${err.message || err}`, "error");
}
}
async function refreshModelDropdown(useCaseName) {
if (!state.presets) return;
const preset = state.presets.presets[useCaseName];
if (!preset) return;
const arch = preset.architecture;
try {
const result = await state.client.predict("/models", { architecture: arch });
const models = result.data[0];
const sel = $("#model");
sel.innerHTML = "";
models.forEach((name) => {
const opt = el("option", { value: name }, name);
if (name === preset.model) opt.selected = true;
sel.appendChild(opt);
});
} catch (err) {
console.error("Failed to load models:", err);
}
}
function onUseCaseChange() {
const useCaseName = $("#use-case").value;
if (!state.presets) return;
const preset = state.presets.presets[useCaseName];
if (!preset) return;
$("#preset-desc").textContent = `ℹ️ ${preset.description}`;
refreshModelDropdown(useCaseName);
}
function onModelChange() {
// Architecture groups visibility is auto-managed by the backend's
// response, but since we don't get the architecture back from /models,
// we re-derive it client-side from the preset.
const modelName = $("#model").value;
const useCaseName = $("#use-case").value;
if (!state.presets) return;
const preset = state.presets.presets[useCaseName];
if (!preset) return;
const arch = (modelName === preset.model) ? preset.architecture : inferArchFromModel(modelName);
updateAdvancedGroups(arch);
}
function inferArchFromModel(name) {
if (!name) return "roformer";
if (name.endsWith(".yaml")) return "demucs";
if (name.endsWith(".pth")) return "vr";
if (name.endsWith(".onnx")) return "mdx";
if (name.endsWith(".ckpt")) return "roformer";
return "roformer";
}
function updateAdvancedGroups(arch) {
$("#adv-rof").hidden = !(arch === "roformer" || arch === "mdx23c");
$("#adv-mdx").hidden = !(arch === "mdx");
$("#adv-vr").hidden = !(arch === "vr");
$("#adv-demucs").hidden = !(arch === "demucs");
}
// ------------------------------------------------------------------ //
// Advanced toggle //
// ------------------------------------------------------------------ //
function initAdvancedToggle() {
const cb = $("#advanced");
const acc = $("#adv-accordion");
cb.addEventListener("change", () => {
acc.hidden = !cb.checked;
if (cb.checked) acc.open = true;
});
}
// ------------------------------------------------------------------ //
// History //
// ------------------------------------------------------------------ //
async function loadHistory() {
try {
const result = await state.client.predict("/history", {});
const rows = result.data[0] || [];
const body = $("#history-body");
body.innerHTML = "";
if (!rows.length) {
body.appendChild(el("tr", { class: "empty-row" }, el("td", { colspan: "5" }, "No history yet.")));
return;
}
rows.forEach((r) => {
body.appendChild(el("tr",
null,
el("td", null, r[0] || ""),
el("td", null, r[1] || ""),
el("td", null, r[2] || ""),
el("td", null, r[3] || ""),
el("td", null, r[4] || "")
));
});
} catch (err) {
// Silent — history is best-effort
console.warn("History load failed:", err);
}
}
// ------------------------------------------------------------------ //
// Separation //
// ------------------------------------------------------------------ //
async function runSeparation() {
if (state.isWorking) return;
if (!state.selectedFile) {
setStatus("⚠️ Please upload an audio file first.", "error");
return;
}
state.isWorking = true;
const btn = $("#separate-btn");
btn.disabled = true;
$(".btn-label", btn).textContent = "Working…";
$(".btn-spinner", btn).hidden = false;
setStatus(`Working on ${state.selectedFile.name}… First run downloads the model, so this can take a while.`, "working");
setMetrics({ stems: "…" });
// Build payload
const payload = {
audio_file: handle_file(state.selectedFile),
use_case: $("#use-case").value,
model_display_name: $("#model").value,
output_format: $("#output-format").value,
advanced_mode: $("#advanced").checked,
seg_size: parseInt($("#seg-size").value, 10),
overlap_rof: parseInt($("#overlap-rof").value, 10),
overlap_mdx: parseFloat($("#overlap-mdx").value),
pitch_shift: parseInt($("#pitch-shift").value, 10),
hop_length: parseInt($("#hop-length").value, 10),
enable_denoise: $("#enable-denoise").checked,
window_size: parseInt($("#window-size").value, 10),
aggression: parseInt($("#aggression").value, 10),
enable_tta: $("#enable-tta").checked,
enable_post: $("#enable-post").checked,
post_thresh: parseFloat($("#post-thresh").value),
enable_high_end: $("#enable-high-end").checked,
shifts: parseInt($("#shifts").value, 10),
segments_enabled: $("#segments-enabled").checked,
norm_thresh: parseFloat($("#norm-thresh").value),
amp_thresh: parseFloat($("#amp-thresh").value),
batch_size: parseInt($("#batch-size").value, 10),
};
try {
const result = await state.client.predict("/separate", payload);
const data = result.data[0];
if (data.error) {
setStatus(`❌ ${data.error}`, "error");
setMetrics({
dur: data.duration || "—",
est: data.est || "—",
stems: "0",
});
return;
}
renderStems(data.stems || []);
renderZip(data.zip);
const stemNames = (data.stems || []).map((s) => s.label).join(", ");
setStatus(
`✅ Done. ${data.stems.length} stem(s): ${stemNames} · ` +
`Model ${data.model} · Architecture ${data.architecture} · ` +
`Device ${data.device}`,
"success"
);
setMetrics({
dur: data.duration || "—",
est: data.est || "—",
stems: String(data.stems.length),
});
loadHistory();
} catch (err) {
console.error("Separation failed:", err);
setStatus(`❌ Error: ${err.message || err}`, "error");
} finally {
state.isWorking = false;
btn.disabled = false;
$(".btn-label", btn).textContent = "Separate audio";
$(".btn-spinner", btn).hidden = true;
}
}
function renderStems(stems) {
const container = $("#stems");
container.innerHTML = "";
if (!stems.length) {
$("#empty-state").classList.remove("hidden");
return;
}
$("#empty-state").classList.add("hidden");
stems.forEach((stem) => {
// stem.file is a FileData — its `.url` is the playable URL.
// Some serialization paths leave `url` null and only populate `path`;
// in that case we build the URL ourselves via Gradio's /file= endpoint.
const url = fileDataUrl(stem.file);
if (!url) return;
const card = el("div", { class: "stem-card" },
el("div", { class: "stem-header" },
el("div", { class: "stem-label" }, `🎵 ${stem.label}`),
el("a", { class: "stem-download", href: url, download: "" }, "download ↓")
),
el("audio", { controls: "", preload: "metadata", src: url })
);
container.appendChild(card);
});
}
function renderZip(zipFileData) {
const link = $("#zip-link");
if (!zipFileData) {
link.hidden = true;
return;
}
const url = fileDataUrl(zipFileData);
if (!url) {
link.hidden = true;
return;
}
link.href = url;
link.hidden = false;
}
/**
* Resolve a FileData (or string path) to a playable/downloadable URL.
* The Gradio wire protocol gives us either:
* - { url: "https://...", path: "/abs/path" } — use url directly
* - { url: null, path: "/abs/path" } — build via /gradio_api/file=
* - "/abs/path" — build via /gradio_api/file=
* - "https://..." — use directly
*/
function fileDataUrl(fileData) {
if (!fileData) return null;
if (typeof fileData === "string") {
if (/^https?:\/\//.test(fileData)) return fileData;
// Gradio expects the path UNencoded after "file=" — it does its own
// path parsing. Encoding with encodeURIComponent would break it.
return `${window.location.origin}/gradio_api/file=${fileData}`;
}
if (fileData.url) return fileData.url;
if (fileData.path) {
return `${window.location.origin}/gradio_api/file=${fileData.path}`;
}
return null;
}
// ------------------------------------------------------------------ //
// Clear //
// ------------------------------------------------------------------ //
function clearOutputs() {
$("#stems").innerHTML = "";
$("#zip-link").hidden = true;
$("#empty-state").classList.remove("hidden");
setMetrics({});
setStatus("Ready. Upload audio and click Separate.", "ready");
}
// ------------------------------------------------------------------ //
// Health badge //
// ------------------------------------------------------------------ //
async function loadHealth() {
try {
const res = await fetch("/healthz");
const data = await res.json();
$("#device-badge").textContent = `device: ${data.device || "—"}`;
$("#engine-badge").textContent = `engine: ${data.engine_installed ? "ok" : "missing"}`;
$("#footer-device").textContent = data.device || "—";
$("#footer-engine").textContent = data.engine_installed ? "installed" : "missing";
} catch (err) {
$("#device-badge").textContent = "device: ?";
$("#engine-badge").textContent = "engine: ?";
}
}
// ------------------------------------------------------------------ //
// Init //
// ------------------------------------------------------------------ //
async function init() {
// Bind slider value displays
bindSliderDisplay("norm-thresh", "norm-thresh-val");
bindSliderDisplay("amp-thresh", "amp-thresh-val");
bindSliderDisplay("batch-size", "batch-size-val");
bindSliderDisplay("seg-size", "seg-size-val");
bindSliderDisplay("overlap-rof", "overlap-rof-val");
bindSliderDisplay("overlap-mdx", "overlap-mdx-val", (v) => parseFloat(v).toFixed(3));
bindSliderDisplay("pitch-shift", "pitch-shift-val");
bindSliderDisplay("hop-length", "hop-length-val");
bindSliderDisplay("window-size", "window-size-val");
bindSliderDisplay("aggression", "aggression-val");
bindSliderDisplay("post-thresh", "post-thresh-val");
bindSliderDisplay("shifts", "shifts-val");
initDropzone();
initAdvancedToggle();
// Wire up events
$("#use-case").addEventListener("change", onUseCaseChange);
$("#model").addEventListener("change", onModelChange);
$("#separate-btn").addEventListener("click", runSeparation);
$("#clear-btn").addEventListener("click", clearOutputs);
// Connect Gradio client to the current origin (as the blog shows)
try {
state.client = await Client.connect(window.location.origin);
} catch (err) {
console.error("Failed to connect Gradio client:", err);
setStatus(`Failed to connect to backend: ${err.message || err}`, "error");
return;
}
await Promise.all([loadHealth(), loadPresets(), loadHistory()]);
// Initial advanced-group visibility (matches default preset's arch)
onModelChange();
}
// The Gradio client is loaded as an ESM module via a separate