Spaces:
Running on Zero
Running on Zero
| (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 <strong>Separate audio</strong> to process <code>${file.name}</code>.`); | |
| } | |
| function clearFile() { | |
| state.selectedFile = null; | |
| preview.src = ""; | |
| previewWrap.hidden = true; | |
| dz.style.display = ""; | |
| separateBtn.disabled = true; | |
| setStatus("Ready. Upload audio and click <strong>Separate</strong>."); | |
| 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 <code>${state.selectedFile.name}</code>β¦ 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( | |
| `β <strong>Done.</strong> ${data.stems.length} stem(s): ${stemNames} Β· ` + | |
| `Model <code>${data.model}</code> Β· Architecture <code>${data.architecture}</code> Β· ` + | |
| `Device <code>${data.device}</code>`, | |
| "success" | |
| ); | |
| setMetrics({ | |
| dur: data.duration || "β", | |
| est: data.est || "β", | |
| stems: String(data.stems.length), | |
| }); | |
| loadHistory(); | |
| } catch (err) { | |
| console.error("Separation failed:", err); | |
| setStatus(`β <strong>Error:</strong> ${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 <strong>Separate</strong>.", "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 <script type="module"> | |
| // tag, which executes asynchronously. Wait for it to expose `window.gradio` | |
| // before initializing. | |
| function waitForClient() { | |
| return new Promise((resolve) => { | |
| if (window.gradio) return resolve(); | |
| window.addEventListener("gradio-client-ready", () => resolve(), { once: true }); | |
| // Safety timeout β if the CDN fails, surface an error after 15s. | |
| setTimeout(() => resolve(), 15000); | |
| }); | |
| } | |
| async function bootstrap() { | |
| await waitForClient(); | |
| if (!window.gradio) { | |
| setStatus("Failed to load the Gradio JS client from CDN. Check your network connection.", "error"); | |
| return; | |
| } | |
| await init(); | |
| } | |
| // The Gradio client ESM loads async; app.js is `defer` so DOM is ready. | |
| if (document.readyState === "loading") { | |
| document.addEventListener("DOMContentLoaded", bootstrap); | |
| } else { | |
| bootstrap(); | |
| } | |
| })(); | |