Spaces:
Running on Zero
Running on Zero
Download app.py from fuze-eletrik/SAM-Audio-WebGPU: direct link, hf CLI and curl.
- Browser
- Download file 40.4 kB
-
https://huggingface.co/spaces/fuze-eletrik/SAM-Audio-WebGPU/resolve/main/app.py
- Command line
-
hf download hf://spaces/fuze-eletrik/SAM-Audio-WebGPU/app.py
-
curl -L -o app.py https://huggingface.co/spaces/fuze-eletrik/SAM-Audio-WebGPU/resolve/main/app.py
40.4 kB
| import gradio as gr | |
| import spaces | |
| PAGE = r""" | |
| <div id="sam-browser-app" class="sam-app"> | |
| <style> | |
| #sam-browser-app{--sam-accent:#7c6cf2;--sam-accent2:#a89dff;max-width:1120px;margin:0 auto;font-family:Inter,ui-sans-serif,system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;color:var(--body-text-color)} | |
| #sam-browser-app *{box-sizing:border-box} | |
| #sam-browser-app .hero{padding:26px 0 20px} | |
| #sam-browser-app .eyebrow{font-size:12px;font-weight:800;letter-spacing:.13em;text-transform:uppercase;color:var(--sam-accent2);margin-bottom:9px} | |
| #sam-browser-app h1{font-size:clamp(28px,4vw,40px);letter-spacing:-.04em;line-height:1.12;margin:0 0 12px;font-weight:780} | |
| #sam-browser-app h2{font-size:18px;letter-spacing:-.02em;margin:0;font-weight:750} | |
| #sam-browser-app p{line-height:1.55} | |
| #sam-browser-app .subhead{font-size:15px;max-width:760px;color:var(--body-text-color);opacity:.76;margin:0} | |
| #sam-browser-app .pill{display:inline-flex;align-items:center;gap:7px;border:1px solid var(--border-color-primary);border-radius:999px;padding:7px 11px;font-size:12px;font-weight:650;margin-top:16px} | |
| #sam-browser-app .dot{width:7px;height:7px;border-radius:50%;background:#3dbb86;display:inline-block} | |
| #sam-browser-app .panel{border:1px solid var(--border-color-primary);border-radius:18px;padding:clamp(16px,3vw,24px);margin:15px 0;background:var(--background-fill-primary);box-shadow:0 5px 22px rgba(0,0,0,.035)} | |
| #sam-browser-app .section-head{display:flex;align-items:center;gap:12px;margin-bottom:19px} | |
| #sam-browser-app .step{display:grid;place-items:center;flex:0 0 34px;height:34px;border-radius:11px;background:color-mix(in srgb,var(--sam-accent) 14%,transparent);color:var(--sam-accent2);font-weight:800} | |
| #sam-browser-app .muted{opacity:.68;font-size:13px;line-height:1.5} | |
| #sam-browser-app label{display:block;margin:0 0 7px;font-size:13px;font-weight:700} | |
| #sam-browser-app input[type=file],#sam-browser-app input[type=text],#sam-browser-app input[type=number],#sam-browser-app select{width:100%;min-width:0;padding:12px 13px;border-radius:10px;border:1px solid var(--border-color-primary);background:var(--input-background-fill);color:var(--body-text-color);font:inherit;font-size:14px} | |
| #sam-browser-app input[type=file]{padding:10px} | |
| #sam-browser-app .file-wrap{padding:16px;border:1px dashed var(--border-color-primary);border-radius:14px;background:var(--background-fill-secondary)} | |
| #sam-browser-app .controls{display:grid;grid-template-columns:minmax(0,2fr) minmax(150px,1fr) minmax(110px,.7fr);gap:14px;margin-top:18px} | |
| #sam-browser-app .field{min-width:0} | |
| #sam-browser-app .actions{display:flex;align-items:center;gap:12px;flex-wrap:wrap;margin-top:20px} | |
| #sam-browser-app button{padding:12px 17px;border:0;border-radius:11px;background:var(--sam-accent);color:white;cursor:pointer;font-weight:750;font-size:14px;transition:filter .15s,transform .15s} | |
| #sam-browser-app button:hover:not(:disabled){filter:brightness(1.08);transform:translateY(-1px)} | |
| #sam-browser-app button:disabled{opacity:.55;cursor:wait;transform:none} | |
| #sam-browser-app .secondary{background:var(--button-secondary-background-fill);color:var(--button-secondary-text-color);border:1px solid var(--border-color-primary)} | |
| #sam-browser-app .status{display:flex;align-items:center;gap:9px;min-height:42px;padding:10px 13px;border-radius:11px;background:var(--background-fill-secondary);font-size:13px;flex:1;min-width:180px} | |
| #sam-browser-app .status-dot{width:8px;height:8px;flex:0 0 8px;border-radius:50%;background:#a1a1aa} | |
| #sam-browser-app .status[data-state=busy] .status-dot{background:#e9a23b;box-shadow:0 0 0 4px color-mix(in srgb,#e9a23b 17%,transparent)} | |
| #sam-browser-app .status[data-state=success] .status-dot{background:#35b67a} | |
| #sam-browser-app .status[data-state=error] .status-dot{background:#e05a68} | |
| #sam-browser-app .audio-grid{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:14px} | |
| #sam-browser-app .audio-card{min-width:0;border:1px solid var(--border-color-primary);border-radius:15px;padding:15px;background:var(--background-fill-secondary)} | |
| #sam-browser-app .audio-card-head{display:flex;align-items:center;justify-content:space-between;gap:8px;margin-bottom:13px} | |
| #sam-browser-app .audio-title{font-size:14px;font-weight:800;letter-spacing:-.01em} | |
| #sam-browser-app .tag{font-size:10px;letter-spacing:.08em;text-transform:uppercase;font-weight:800;border-radius:6px;padding:5px 7px;background:color-mix(in srgb,var(--sam-accent) 15%,transparent);color:var(--sam-accent2)} | |
| #sam-browser-app .tag.neutral{background:var(--border-color-primary);color:var(--body-text-color)} | |
| #sam-browser-app .wave-wrap{position:relative;width:100%;height:100px;border-radius:9px;overflow:hidden;background:color-mix(in srgb,var(--background-fill-primary) 78%,transparent)} | |
| #sam-browser-app canvas{display:block;width:100%;height:100px;cursor:pointer;touch-action:pan-y} | |
| #sam-browser-app .wave-empty[hidden]{display:none} | |
| #sam-browser-app .wave-empty{position:absolute;inset:0;display:grid;place-items:center;text-align:center;padding:10px;color:var(--body-text-color);opacity:.45;font-size:12px;pointer-events:none} | |
| #sam-browser-app .audio-meta{display:flex;justify-content:space-between;gap:8px;font-size:11px;opacity:.62;margin:9px 0 7px;font-variant-numeric:tabular-nums} | |
| #sam-browser-app audio{display:block;width:100%;height:40px;margin:7px 0 11px} | |
| #sam-browser-app .download{display:inline-flex;align-items:center;gap:7px;text-decoration:none;font-size:12px;font-weight:750;color:var(--sam-accent2);padding:7px 0} | |
| #sam-browser-app .download[hidden]{display:none} | |
| #sam-browser-app .empty-results{padding:24px 14px;border:1px dashed var(--border-color-primary);border-radius:14px;text-align:center;color:var(--body-text-color);opacity:.65;font-size:13px} | |
| #sam-browser-app .privacy{display:flex;gap:10px;align-items:flex-start;padding:12px 14px;border-radius:11px;background:color-mix(in srgb,#35b67a 8%,transparent);font-size:12px;line-height:1.5;margin-top:16px} | |
| #sam-browser-app .privacy svg{flex:0 0 17px;margin-top:1px} | |
| #sam-browser-app .footer-note{font-size:12px;opacity:.58;text-align:center;margin:18px 0 8px;line-height:1.5} | |
| #sam-browser-app .credits{font-size:12px;opacity:.7;text-align:center;margin:0 0 28px;line-height:1.8} | |
| #sam-browser-app .credits a{color:var(--sam-accent2);text-decoration:none;font-weight:650} | |
| #sam-browser-app .credits a:hover{text-decoration:underline} | |
| @media(max-width:820px){#sam-browser-app .audio-grid{grid-template-columns:1fr}#sam-browser-app .controls{grid-template-columns:1fr 1fr}#sam-browser-app .controls .prompt-field{grid-column:1/-1}} | |
| @media(max-width:480px){#sam-browser-app .controls{grid-template-columns:1fr}#sam-browser-app .controls .prompt-field{grid-column:auto}#sam-browser-app .actions{align-items:stretch}#sam-browser-app .actions button{width:100%}#sam-browser-app .status{flex-basis:100%}} | |
| </style> | |
| <header class="hero"> | |
| <div class="eyebrow">Audio separation studio</div> | |
| <h1>SAM Audio <span style="color:var(--sam-accent2)">Studio</span></h1> | |
| <p class="subhead">Separate speech, music, or other sounds from a recording using Meta's SAM Audio, running locally right in your browser.<br><br>Once downloaded, the model takes up about 2.7 GB in the browser cache.</p> | |
| <div class="pill"><span class="dot"></span> Local browser processing <span style="opacity:.45">·</span> ONNX Runtime</div> | |
| </header> | |
| <section class="panel"> | |
| <div class="section-head"><div class="step">01</div><div><h2>Source & settings</h2><div class="muted">Choose a recording and describe what you want to isolate.</div></div></div> | |
| <div class="file-wrap"><label for="sam-audio-file">Audio file</label><input id="sam-audio-file" type="file" accept=".wav,.mp3,.flac,.ogg,.m4a,audio/wav,audio/mpeg,audio/flac,audio/ogg,audio/mp4"><div class="muted" style="margin-top:8px">WAV, MP3, FLAC, OGG, or M4A · Maximum duration: 2 minutes</div></div> | |
| <div class="controls"> | |
| <div class="field prompt-field"><label for="sam-prompt">What should be isolated?</label><input id="sam-prompt" type="text" value="a person speaking" placeholder="e.g. a person speaking"></div> | |
| <div class="field"><label for="sam-provider">Processing device</label><select id="sam-provider"><option value="auto" selected>Automatic</option><option value="webgpu">GPU · WebGPU</option><option value="wasm">CPU · WASM</option></select></div> | |
| <div class="field"><label for="sam-steps">ODE steps</label><input id="sam-steps" type="number" min="1" max="16" value="8"></div> | |
| </div> | |
| <div class="muted" style="margin-top:9px">Automatic mode uses CPU on phones/tablets and WebGPU on desktop when available. More ODE steps can improve quality but take longer.</div> | |
| <div class="actions"><button id="sam-run">▶ Separate audio</button><button id="sam-clear-cache" class="secondary" type="button">Clear model cache</button><div id="sam-status-box" class="status" data-state="idle"><span class="status-dot"></span><span id="sam-status" aria-live="polite">Ready when you are</span></div></div> | |
| <div class="privacy"><svg viewBox="0 0 24 24" width="17" height="17" fill="none" stroke="currentColor" stroke-width="2"><rect x="4" y="10" width="16" height="11" rx="2"/><path d="M8 10V7a4 4 0 0 1 8 0v3"/></svg><div><strong>Your audio stays on your device.</strong> Inference runs locally in this browser. The Space hosts the interface and model downloads; your selected audio is not uploaded for processing.</div></div> | |
| </section> | |
| <section class="panel"> | |
| <div class="section-head"><div class="step">02</div><div><h2>Listen & compare</h2><div class="muted">Use the waveform to seek through each track. Download outputs as WAV files.</div></div></div> | |
| <div class="audio-grid"> | |
| <article class="audio-card"><div class="audio-card-head"><span class="audio-title">Original audio</span><span class="tag neutral">Input</span></div><div class="wave-wrap"><canvas id="sam-input-wave" aria-label="Original audio waveform"></canvas><div class="wave-empty" id="sam-input-empty">Choose an audio file to preview its waveform</div></div><div class="audio-meta"><span id="sam-input-duration">No audio loaded</span><span id="sam-input-format">Source</span></div><audio id="sam-input-player" controls preload="metadata"></audio><a class="download" id="sam-input-download" download hidden>↓ Download original</a></article> | |
| <article class="audio-card"><div class="audio-card-head"><span class="audio-title">Target</span><span class="tag">Isolated</span></div><div class="wave-wrap"><canvas id="sam-target-wave" aria-label="Target audio waveform"></canvas><div class="wave-empty" id="sam-target-empty">Run separation to create the target</div></div><div class="audio-meta"><span id="sam-target-duration">Waiting for result</span><span>48 kHz · WAV</span></div><audio id="sam-target-player" controls preload="metadata"></audio><a class="download" id="sam-target-download" download="target.wav" hidden>↓ Download target.wav</a></article> | |
| <article class="audio-card"><div class="audio-card-head"><span class="audio-title">Residual</span><span class="tag neutral">Everything else</span></div><div class="wave-wrap"><canvas id="sam-residual-wave" aria-label="Residual audio waveform"></canvas><div class="wave-empty" id="sam-residual-empty">Run separation to create the residual</div></div><div class="audio-meta"><span id="sam-residual-duration">Waiting for result</span><span>48 kHz · WAV</span></div><audio id="sam-residual-player" controls preload="metadata"></audio><a class="download" id="sam-residual-download" download="residual.wav" hidden>↓ Download residual.wav</a></article> | |
| </div> | |
| </section> | |
| <p class="footer-note">First use downloads model files and may take several minutes on CPU.</p> | |
| <div class="credits">Credits: <a href="https://github.com/facebookresearch/sam-audio" target="_blank" rel="noopener noreferrer">Meta's SAM Audio</a> <span aria-hidden="true">·</span> ONNX-converted models by <a href="https://huggingface.co/matbee/sam-audio-small-onnx" target="_blank" rel="noopener noreferrer">matbee</a></div> | |
| </div> | |
| """ | |
| CLIENT_JS = r""" | |
| async () => { | |
| const root = document.getElementById("sam-browser-app"); | |
| if (!root || root.dataset.initialized === "yes") return; | |
| root.dataset.initialized = "yes"; | |
| const $ = (id) => root.querySelector("#" + id); | |
| const MODEL_BASE = "https://huggingface.co/matbee/sam-audio-small-onnx/resolve/main"; | |
| const CACHE_NAME = "sam-audio-webgpu-models-v1"; | |
| const MAX_AUDIO_SECONDS = 120; | |
| const MODEL_FILES = { | |
| encoder: "dacvae_encoder.onnx", | |
| decoder: "dacvae_decoder.onnx", | |
| t5: "t5_encoder.onnx", | |
| dit: "dit_single_step.onnx" | |
| }; | |
| let ort = null; | |
| let tokenizer = null; | |
| let sessions = null; | |
| let sessionsProvider = null; | |
| let runtimeProvider = null; | |
| let lastAttemptProvider = null; | |
| let previousUrls = []; | |
| function setLog(message) { | |
| // Compact user-facing progress message; technical diagnostics are intentionally hidden. | |
| if (message) setStatus(String(message).split("\n")[0]); | |
| } | |
| function setStatus(message, state = "busy") { | |
| $("sam-status").textContent = message; | |
| $("sam-status-box").dataset.state = state; | |
| } | |
| const waveforms = {}; | |
| const colors = {input: "#8b879b", target: "#8b7cf6", residual: "#40b995"}; | |
| function formatDuration(seconds) { | |
| if (!Number.isFinite(seconds) || seconds < 0) return "—"; | |
| const mins = Math.floor(seconds / 60); | |
| const secs = Math.floor(seconds % 60); | |
| return mins ? `${mins}:${String(secs).padStart(2, "0")}` : `0:${String(secs).padStart(2, "0")}`; | |
| } | |
| function drawWaveform(canvasId, samples, kind) { | |
| const canvas = $(canvasId); | |
| if (!canvas) return; | |
| const rect = canvas.getBoundingClientRect(); | |
| if (!rect.width || !rect.height) return; | |
| const dpr = Math.max(1, Math.min(window.devicePixelRatio || 1, 2)); | |
| canvas.width = Math.round(rect.width * dpr); | |
| canvas.height = Math.round(rect.height * dpr); | |
| const ctx = canvas.getContext("2d"); | |
| ctx.setTransform(dpr, 0, 0, dpr, 0, 0); | |
| const width = rect.width, height = rect.height, mid = height / 2; | |
| ctx.clearRect(0, 0, width, height); | |
| ctx.strokeStyle = "rgba(128,128,150,.13)"; ctx.lineWidth = 1; | |
| ctx.beginPath(); ctx.moveTo(0, mid); ctx.lineTo(width, mid); ctx.stroke(); | |
| if (!samples || !samples.length) return; | |
| const bars = Math.max(1, Math.floor(width / 3)); | |
| const stride = Math.max(1, Math.floor(samples.length / bars)); | |
| const color = colors[kind] || colors.input; | |
| ctx.strokeStyle = color; ctx.lineWidth = Math.max(1, Math.min(2, width / bars * .7)); ctx.lineCap = "round"; | |
| ctx.beginPath(); | |
| for (let x = 0; x < bars; x++) { | |
| const start = x * stride; | |
| const end = Math.min(samples.length, start + stride); | |
| let peak = 0; | |
| for (let i = start; i < end; i++) peak = Math.max(peak, Math.abs(samples[i] || 0)); | |
| const amplitude = Math.max(1.25, peak * (height * .43)); | |
| const px = (x + .5) * width / bars; | |
| ctx.moveTo(px, mid - amplitude); ctx.lineTo(px, mid + amplitude); | |
| } | |
| ctx.stroke(); | |
| waveforms[canvasId] = {samples, kind}; | |
| updateWaveProgress(canvasId); | |
| } | |
| function updateWaveProgress(canvasId) { | |
| const canvas = $(canvasId); | |
| const player = $(canvasId.replace("-wave", "-player")); | |
| const entry = waveforms[canvasId]; | |
| if (!canvas || !entry || !player || !player.duration) return; | |
| const rect = canvas.getBoundingClientRect(); | |
| const dpr = Math.max(1, Math.min(window.devicePixelRatio || 1, 2)); | |
| const ctx = canvas.getContext("2d"); | |
| const x = Math.max(0, Math.min(rect.width, player.currentTime / player.duration * rect.width)); | |
| ctx.save(); ctx.setTransform(dpr, 0, 0, dpr, 0, 0); | |
| ctx.fillStyle = "rgba(255,255,255,.13)"; ctx.fillRect(0, 0, x, rect.height); | |
| ctx.restore(); | |
| } | |
| function attachWavePlayer(canvasId, playerId) { | |
| const canvas = $(canvasId), player = $(playerId); | |
| if (!canvas || !player) return; | |
| player.addEventListener("timeupdate", () => drawWaveform(canvasId, waveforms[canvasId]?.samples, waveforms[canvasId]?.kind || "input")); | |
| canvas.addEventListener("click", event => { | |
| if (!Number.isFinite(player.duration) || !player.duration) return; | |
| const rect = canvas.getBoundingClientRect(); | |
| player.currentTime = Math.max(0, Math.min(1, (event.clientX - rect.left) / rect.width)) * player.duration; | |
| }); | |
| } | |
| function setWave(id, samples, kind, durationId, emptyId) { | |
| const empty = $(emptyId); | |
| if (samples && samples.length) { | |
| if (empty) empty.hidden = true; | |
| drawWaveform(id, samples, kind); | |
| const duration = $(durationId); | |
| if (duration) duration.textContent = formatDuration(samples.length / 48000); | |
| } else { | |
| if (empty) empty.hidden = false; | |
| const canvas = $(id), ctx = canvas.getContext("2d"); | |
| ctx.clearRect(0, 0, canvas.width, canvas.height); | |
| delete waveforms[id]; | |
| } | |
| } | |
| function isMobileDevice() { | |
| // Prefer CPU on phones/tablets: mobile WebGPU drivers may expose the API | |
| // but fail during large model inference. Desktop users can still override manually. | |
| return /Android|iPhone|iPad|iPod|Mobile|Tablet/i.test(navigator.userAgent) || | |
| (navigator.userAgentData?.mobile === true); | |
| } | |
| async function chooseProvider() { | |
| const requested = $("sam-provider").value; | |
| if (requested === "webgpu" || requested === "wasm") return requested; | |
| if (isMobileDevice()) { | |
| setLog("Automatic device selection: mobile device detected. Using CPU (WASM) for compatibility."); | |
| return "wasm"; | |
| } | |
| try { | |
| if (navigator.gpu) { | |
| const adapter = await navigator.gpu.requestAdapter(); | |
| if (adapter) { | |
| setLog("Automatic device selection: desktop WebGPU adapter found. Using GPU (WebGPU)."); | |
| return "webgpu"; | |
| } | |
| } | |
| } catch (error) { | |
| setLog("WebGPU adapter check failed. Using CPU (WASM).\n" + String(error)); | |
| } | |
| setLog("Automatic device selection: WebGPU is unavailable. Using CPU (WASM)."); | |
| return "wasm"; | |
| } | |
| async function loadOrt() { | |
| if (!ort) { | |
| ort = await import("https://cdn.jsdelivr.net/npm/onnxruntime-web@1.23.2/+esm"); | |
| if (ort.env?.wasm) { | |
| // Let ONNX Runtime choose its supported thread count. Multi-threading | |
| // is enabled only when the browser provides the required isolation. | |
| ort.env.wasm.numThreads = 0; | |
| ort.env.wasm.proxy = false; | |
| } | |
| if (ort.env) ort.env.logLevel = "warning"; | |
| } | |
| return ort; | |
| } | |
| async function loadTokenizer() { | |
| if (!tokenizer) { | |
| const transformers = await import("https://cdn.jsdelivr.net/npm/@huggingface/transformers@3.7.2"); | |
| // Let Transformers.js persist tokenizer assets in its browser cache when supported. | |
| transformers.env.useBrowserCache = true; | |
| // Use the Transformers.js-compatible T5 tokenizer repository. | |
| tokenizer = await transformers.AutoTokenizer.from_pretrained("Xenova/t5-base"); | |
| } | |
| return tokenizer; | |
| } | |
| async function fetchCachedBytes(url, label) { | |
| // Cache Storage persists across page reloads. If unavailable or quota is exceeded, | |
| // continue with a normal network download rather than failing inference. | |
| let cache = null; | |
| if ("caches" in window) { | |
| try { cache = await caches.open(CACHE_NAME); } catch (_) {} | |
| } | |
| if (cache) { | |
| try { | |
| const cached = await cache.match(url); | |
| if (cached) { | |
| setLog("Using browser cache: " + label + "\nNo network download needed for this file."); | |
| return new Uint8Array(await cached.arrayBuffer()); | |
| } | |
| } catch (_) {} | |
| } | |
| setLog("Downloading " + label + "…\nThe file will be cached for future runs if browser storage permits."); | |
| const response = await fetch(url); | |
| if (!response.ok) throw new Error("Download failed (" + response.status + ") for " + url); | |
| if (cache) { | |
| try { | |
| // Cache a clone so the original response remains available for the ONNX runtime. | |
| await cache.put(url, response.clone()); | |
| } catch (error) { | |
| setLog("Downloaded " + label + ", but the browser could not persist it in cache (quota/private mode). Continuing without persistent cache.\n" + String(error)); | |
| } | |
| } | |
| return new Uint8Array(await response.arrayBuffer()); | |
| } | |
| async function configureRuntime(provider) { | |
| const runtime = await loadOrt(); | |
| if (runtimeProvider !== null && runtimeProvider !== provider) { | |
| throw new Error( | |
| "The CPU worker and GPU modes require different ONNX Runtime initialization. " + | |
| "Reload this page before switching between CPU and GPU, then select the desired mode before the first inference." | |
| ); | |
| } | |
| if (runtimeProvider === null) { | |
| if (runtime.env?.wasm) { | |
| // In CPU mode, run WASM inference in a proxy Web Worker so heavy | |
| // inference does not block the browser's UI thread. | |
| // WebGPU and the WASM proxy worker cannot be used together. | |
| runtime.env.wasm.proxy = provider === "wasm"; | |
| runtime.env.wasm.numThreads = 0; // automatic, subject to browser support | |
| } | |
| runtimeProvider = provider; | |
| } | |
| return runtime; | |
| } | |
| async function createSession(filename, provider) { | |
| const url = MODEL_BASE + "/" + filename; | |
| const runtime = await configureRuntime(provider); | |
| const modelBytes = await fetchCachedBytes(url, filename); | |
| const dataUrl = url + ".data"; | |
| const externalBytes = await fetchCachedBytes(dataUrl, filename + ".data (external weights)"); | |
| return runtime.InferenceSession.create(modelBytes, { | |
| executionProviders: [provider], | |
| graphOptimizationLevel: "all", | |
| externalData: [{ path: filename + ".data", data: externalBytes.buffer }] | |
| }); | |
| } | |
| async function clearModelCache() { | |
| if (sessions) { | |
| for (const session of Object.values(sessions)) { | |
| try { await session.release(); } catch (_) {} | |
| } | |
| sessions = null; | |
| sessionsProvider = null; | |
| } | |
| if ("caches" in window) { | |
| try { await caches.delete(CACHE_NAME); } catch (_) {} | |
| } | |
| setStatus("Model cache cleared · reload before next run", "success"); | |
| } | |
| async function loadModels(provider) { | |
| if (sessions && sessionsProvider === provider) return sessions; | |
| if (sessions) { | |
| for (const session of Object.values(sessions)) { | |
| try { await session.release(); } catch (_) {} | |
| } | |
| sessions = null; | |
| sessionsProvider = null; | |
| } | |
| const loaded = {}; | |
| const order = [ | |
| ["encoder", "dacvae_encoder.onnx"], | |
| ["t5", "t5_encoder.onnx"], | |
| ["dit", "dit_single_step.onnx"], | |
| ["decoder", "dacvae_decoder.onnx"] | |
| ]; | |
| try { | |
| for (const [key, filename] of order) { | |
| setStatus("Loading " + filename + "…"); | |
| setLog("Loading model component: " + filename + "\nThe browser may download a large external .onnx.data file. Keep this tab open."); | |
| loaded[key] = await createSession(filename, provider); | |
| setLog("Loaded " + filename + "\nInputs: " + loaded[key].inputNames.join(", ") + "\nOutputs: " + loaded[key].outputNames.join(", ")); | |
| } | |
| await loadTokenizer(); | |
| sessions = loaded; | |
| sessionsProvider = provider; | |
| return sessions; | |
| } catch (error) { | |
| for (const session of Object.values(loaded)) { | |
| try { await session.release(); } catch (_) {} | |
| } | |
| throw error; | |
| } | |
| } | |
| function getTensorMetadata(session, name) { | |
| try { return session.inputMetadata?.[name] || {}; } catch (_) { return {}; } | |
| } | |
| function floatTensor(data, dims) { | |
| // IMPORTANT: with ort.env.wasm.proxy=true, ONNX Runtime transfers input | |
| // ArrayBuffers to its worker. Passing the original typed array detaches it, | |
| // leaving length 0 for later ODE steps. Always pass a fresh copy. | |
| const copiedData = data instanceof Float32Array ? new Float32Array(data) : Float32Array.from(data); | |
| return new ort.Tensor("float32", copiedData, dims); | |
| } | |
| function int64Tensor(data, dims) { | |
| const values = Array.from(data, value => BigInt(value)); | |
| return new ort.Tensor("int64", BigInt64Array.from(values), dims); | |
| } | |
| function boolTensor(data, dims) { | |
| return new ort.Tensor("bool", Uint8Array.from(data, value => value ? 1 : 0), dims); | |
| } | |
| async function decodeAudio(file) { | |
| const bytes = await file.arrayBuffer(); | |
| const AudioContextClass = window.AudioContext || window.webkitAudioContext; | |
| const context = new AudioContextClass(); | |
| try { | |
| const decoded = await context.decodeAudioData(bytes.slice(0)); | |
| if (decoded.duration > MAX_AUDIO_SECONDS) { | |
| throw new Error("Audio must be 2 minutes or shorter. Selected file is " + formatDuration(decoded.duration) + "."); | |
| } | |
| const offline = new OfflineAudioContext(1, Math.ceil(decoded.duration * 48000), 48000); | |
| const source = offline.createBufferSource(); | |
| const mono = offline.createBuffer(1, decoded.length, decoded.sampleRate); | |
| const channels = []; | |
| for (let i = 0; i < decoded.numberOfChannels; i++) channels.push(decoded.getChannelData(i)); | |
| const monoData = mono.getChannelData(0); | |
| for (let i = 0; i < monoData.length; i++) { | |
| let sum = 0; | |
| for (const channel of channels) sum += channel[i]; | |
| monoData[i] = sum / channels.length; | |
| } | |
| source.buffer = mono; | |
| source.connect(offline.destination); | |
| source.start(); | |
| const resampled = await offline.startRendering(); | |
| return {samples: new Float32Array(resampled.getChannelData(0)), sampleRate: 48000}; | |
| } finally { | |
| await context.close().catch(() => {}); | |
| } | |
| } | |
| async function runSession(session, outputName, feeds) { | |
| const outputs = await session.run(feeds); | |
| const tensor = outputs[outputName] || outputs[session.outputNames[0]]; | |
| if (!tensor) throw new Error("Expected output not found: " + outputName); | |
| return tensor.data; | |
| } | |
| async function encodeAudio(samples) { | |
| const session = sessions.encoder; | |
| // In WASM proxy mode, ONNX Runtime may transfer/detach the tensor's | |
| // ArrayBuffer when sending it to the worker. Never give it audio.samples | |
| // directly: keep the decoded source intact for duration checks and output trimming. | |
| const inputSamples = new Float32Array(samples); | |
| const input = new ort.Tensor("float32", inputSamples, [1, 1, inputSamples.length]); | |
| const out = await session.run({audio: input}); | |
| const tensor = out.latent_features || out[session.outputNames[0]]; | |
| return {data: new Float32Array(tensor.data), dims: tensor.dims}; | |
| } | |
| async function encodeText(text) { | |
| const tok = await loadTokenizer(); | |
| const encoded = await tok(text, { | |
| padding: "max_length", | |
| truncation: true, | |
| max_length: 77 | |
| }); | |
| const idsRaw = encoded.input_ids?.data ?? encoded.input_ids; | |
| const maskRaw = encoded.attention_mask?.data ?? encoded.attention_mask; | |
| const ids = Array.from(idsRaw); | |
| const mask = Array.from(maskRaw); | |
| const idsTensor = int64Tensor(ids, [1, ids.length]); | |
| const maskTensor = int64Tensor(mask, [1, mask.length]); | |
| const out = await sessions.t5.run({input_ids: idsTensor, attention_mask: maskTensor}); | |
| const tensor = out.hidden_states || out[sessions.t5.outputNames[0]]; | |
| return { | |
| features: new Float32Array(tensor.data), | |
| featureDims: tensor.dims, | |
| mask: Uint8Array.from(mask, value => value ? 1 : 0), | |
| maskDims: [1, mask.length] | |
| }; | |
| } | |
| async function runDit(noisy, timeValue, audioFeatures, text, sequenceLength) { | |
| const batch = 1; | |
| const zerosInt = new BigInt64Array(sequenceLength); | |
| const mask = new Uint8Array(sequenceLength).fill(1); | |
| const video = new Float32Array(1024 * sequenceLength); | |
| const feeds = { | |
| noisy_audio: floatTensor(noisy, [batch, sequenceLength, 256]), | |
| time: floatTensor(new Float32Array([timeValue]), [1]), | |
| audio_features: floatTensor(audioFeatures, [batch, sequenceLength, 256]), | |
| text_features: floatTensor(text.features, text.featureDims), | |
| text_mask: boolTensor(text.mask, text.maskDims), | |
| masked_video_features: floatTensor(video, [batch, 1024, sequenceLength]), | |
| anchor_ids: new ort.Tensor("int64", zerosInt, [batch, sequenceLength]), | |
| anchor_alignment: new ort.Tensor("int64", new BigInt64Array(sequenceLength), [batch, sequenceLength]), | |
| audio_pad_mask: boolTensor(mask, [batch, sequenceLength]) | |
| }; | |
| const outputs = await sessions.dit.run(feeds); | |
| const tensor = outputs.velocity || outputs[sessions.dit.outputNames[0]]; | |
| return new Float32Array(tensor.data); | |
| } | |
| async function runOde(initial, audioFeatures, text, steps, sequenceLength) { | |
| let x = new Float32Array(initial); | |
| const dt = 1 / steps; | |
| for (let i = 0; i < steps; i++) { | |
| setStatus("Denoising step " + (i + 1) + "/" + steps + " (1/2)…"); | |
| const t = i * dt; | |
| const k1 = await runDit(x, t, audioFeatures, text, sequenceLength); | |
| const midpoint = new Float32Array(x.length); | |
| for (let j = 0; j < x.length; j++) midpoint[j] = x[j] + (dt / 2) * k1[j]; | |
| setStatus("Denoising step " + (i + 1) + "/" + steps + " (2/2)…"); | |
| const k2 = await runDit(midpoint, t + dt / 2, audioFeatures, text, sequenceLength); | |
| for (let j = 0; j < x.length; j++) x[j] += dt * k2[j]; | |
| // Yield so status/progress updates can paint between long CPU steps. | |
| await new Promise(resolve => setTimeout(resolve, 0)); | |
| } | |
| return x; | |
| } | |
| async function decodeLatent(latent, latentChannels, timeSteps) { | |
| const session = sessions.decoder; | |
| const chunkSize = 25; | |
| const hopLength = 1920; | |
| const chunks = []; | |
| for (let start = 0; start < timeSteps; start += chunkSize) { | |
| const actual = Math.min(chunkSize, timeSteps - start); | |
| const chunk = new Float32Array(latentChannels * chunkSize); | |
| for (let c = 0; c < latentChannels; c++) { | |
| for (let t = 0; t < actual; t++) { | |
| chunk[c * chunkSize + t] = latent[c * timeSteps + start + t]; | |
| } | |
| } | |
| const out = await session.run({ | |
| latent_features: floatTensor(chunk, [1, latentChannels, chunkSize]) | |
| }); | |
| const tensor = out.waveform || out[session.outputNames[0]]; | |
| const data = tensor.data; | |
| const count = actual * hopLength; | |
| chunks.push(new Float32Array(data.slice(0, count))); | |
| setStatus("Decoding audio " + Math.min(start + chunkSize, timeSteps) + "/" + timeSteps + " latent frames…"); | |
| } | |
| const totalLength = chunks.reduce((sum, part) => sum + part.length, 0); | |
| const output = new Float32Array(totalLength); | |
| let offset = 0; | |
| for (const chunk of chunks) { output.set(chunk, offset); offset += chunk.length; } | |
| return output; | |
| } | |
| function wavBlob(samples, sampleRate) { | |
| const buffer = new ArrayBuffer(44 + samples.length * 2); | |
| const view = new DataView(buffer); | |
| function writeString(offset, value) { for (let i = 0; i < value.length; i++) view.setUint8(offset + i, value.charCodeAt(i)); } | |
| writeString(0, "RIFF"); view.setUint32(4, 36 + samples.length * 2, true); | |
| writeString(8, "WAVE"); writeString(12, "fmt "); view.setUint32(16, 16, true); | |
| view.setUint16(20, 1, true); view.setUint16(22, 1, true); view.setUint32(24, sampleRate, true); | |
| view.setUint32(28, sampleRate * 2, true); view.setUint16(32, 2, true); view.setUint16(34, 16, true); | |
| writeString(36, "data"); view.setUint32(40, samples.length * 2, true); | |
| for (let i = 0; i < samples.length; i++) { | |
| const sample = Math.max(-1, Math.min(1, samples[i])); | |
| view.setInt16(44 + i * 2, sample < 0 ? sample * 32768 : sample * 32767, true); | |
| } | |
| return new Blob([buffer], {type:"audio/wav"}); | |
| } | |
| function showOutput(playerId, linkId, samples, sampleRate, filename, waveId, kind, durationId, emptyId) { | |
| const url = URL.createObjectURL(wavBlob(samples, sampleRate)); | |
| previousUrls.push(url); | |
| const player = $(playerId); | |
| player.src = url; | |
| player.load(); | |
| const link = $(linkId); link.href = url; link.download = filename; link.hidden = false; | |
| setWave(waveId, samples, kind, durationId, emptyId); | |
| } | |
| function showInput(file, samples) { | |
| const url = URL.createObjectURL(file); | |
| previousUrls.push(url); | |
| const player = $("sam-input-player"); player.src = url; player.load(); | |
| const link = $("sam-input-download"); link.href = url; link.download = file.name; link.hidden = false; link.textContent = "↓ Download original"; | |
| $("sam-input-format").textContent = file.name.length > 24 ? file.name.slice(0, 21) + "…" : file.name; | |
| setWave("sam-input-wave", samples, "input", "sam-input-duration", "sam-input-empty"); | |
| } | |
| async function separate(providerOverride = null) { | |
| const file = $("sam-audio-file").files?.[0]; | |
| const prompt = $("sam-prompt").value.trim(); | |
| const steps = Number.parseInt($("sam-steps").value, 10); | |
| const provider = providerOverride || await chooseProvider(); | |
| lastAttemptProvider = provider; | |
| const deviceLabel = provider === "wasm" ? "CPU (WASM)" : "GPU (WebGPU)"; | |
| if (!file) throw new Error("Choose an audio file first."); | |
| if (!prompt) throw new Error("Enter a text prompt."); | |
| if (!Number.isInteger(steps) || steps < 1 || steps > 16) throw new Error("ODE steps must be between 1 and 16."); | |
| const audio = await decodeAudio(file); | |
| const expectedSamples = audio.samples.length; | |
| if (expectedSamples > 48000 * MAX_AUDIO_SECONDS) throw new Error("Audio must be 2 minutes or shorter."); | |
| setStatus("Loading models for " + deviceLabel + "…"); | |
| if (provider === "wasm") { | |
| setLog("CPU mode selected. ONNX Runtime will use WebAssembly in a background worker where supported, keeping the page responsive. This may take longer than GPU inference."); | |
| } | |
| await loadModels(provider); | |
| setStatus("Encoding audio on " + deviceLabel + "…"); | |
| const encoded = await encodeAudio(audio.samples); | |
| if (encoded.dims.length !== 3) throw new Error("Unexpected encoder output dimensions: " + JSON.stringify(encoded.dims)); | |
| const [batch, latentChannels, timeSteps] = encoded.dims; | |
| if (batch !== 1 || latentChannels !== 128) { | |
| throw new Error("Unexpected audio latent shape " + JSON.stringify(encoded.dims) + "; expected [1, 128, time]."); | |
| } | |
| setStatus("Encoding text…"); | |
| const text = await encodeText(prompt); | |
| const mixture = new Float32Array(timeSteps * 256); | |
| for (let t = 0; t < timeSteps; t++) { | |
| for (let c = 0; c < latentChannels; c++) { | |
| const value = encoded.data[c * timeSteps + t]; | |
| mixture[t * 256 + c] = value; | |
| mixture[t * 256 + latentChannels + c] = value; | |
| } | |
| } | |
| const initial = new Float32Array(timeSteps * 256); | |
| // Standard normal noise, matching the reference implementation's randn initialization. | |
| for (let i = 0; i < initial.length; i += 2) { | |
| const u1 = Math.max(Number.EPSILON, Math.random()); | |
| const u2 = Math.random(); | |
| const radius = Math.sqrt(-2 * Math.log(u1)); | |
| initial[i] = radius * Math.cos(2 * Math.PI * u2); | |
| if (i + 1 < initial.length) initial[i + 1] = radius * Math.sin(2 * Math.PI * u2); | |
| } | |
| const result = await runOde(initial, mixture, text, steps, timeSteps); | |
| const targetLatent = new Float32Array(latentChannels * timeSteps); | |
| const residualLatent = new Float32Array(latentChannels * timeSteps); | |
| for (let t = 0; t < timeSteps; t++) { | |
| for (let c = 0; c < latentChannels; c++) { | |
| targetLatent[c * timeSteps + t] = result[t * 256 + c]; | |
| residualLatent[c * timeSteps + t] = result[t * 256 + latentChannels + c]; | |
| } | |
| } | |
| setStatus("Decoding target…"); | |
| const target = await decodeLatent(targetLatent, latentChannels, timeSteps); | |
| setStatus("Decoding residual…"); | |
| const residual = await decodeLatent(residualLatent, latentChannels, timeSteps); | |
| if (audio.samples.length !== expectedSamples) { | |
| throw new Error("Internal audio buffer was unexpectedly detached or resized: expected " + expectedSamples + " samples, got " + audio.samples.length + "."); | |
| } | |
| showOutput("sam-target-player", "sam-target-download", target.slice(0, expectedSamples), 48000, "target.wav", "sam-target-wave", "target", "sam-target-duration", "sam-target-empty"); | |
| showOutput("sam-residual-player", "sam-residual-download", residual.slice(0, expectedSamples), 48000, "residual.wav", "sam-residual-wave", "residual", "sam-residual-duration", "sam-residual-empty"); | |
| setStatus("Separation complete · " + deviceLabel, "success"); | |
| } | |
| attachWavePlayer("sam-input-wave", "sam-input-player"); | |
| attachWavePlayer("sam-target-wave", "sam-target-player"); | |
| attachWavePlayer("sam-residual-wave", "sam-residual-player"); | |
| $("sam-audio-file").addEventListener("change", async () => { | |
| const file = $("sam-audio-file").files?.[0]; | |
| if (!file) return; | |
| for (const url of previousUrls) URL.revokeObjectURL(url); | |
| previousUrls = []; | |
| for (const [playerId, linkId, waveId, durationId, emptyId, waitingText] of [ | |
| ["sam-input-player", "sam-input-download", "sam-input-wave", "sam-input-duration", "sam-input-empty", "No audio loaded"], | |
| ["sam-target-player", "sam-target-download", "sam-target-wave", "sam-target-duration", "sam-target-empty", "Waiting for result"], | |
| ["sam-residual-player", "sam-residual-download", "sam-residual-wave", "sam-residual-duration", "sam-residual-empty", "Waiting for result"] | |
| ]) { | |
| const player = $(playerId); player.pause(); player.removeAttribute("src"); player.load(); | |
| const link = $(linkId); link.removeAttribute("href"); link.hidden = true; | |
| $(durationId).textContent = waitingText; | |
| $(emptyId).hidden = false; | |
| const canvas = $(waveId); const ctx = canvas.getContext("2d"); ctx.clearRect(0, 0, canvas.width, canvas.height); | |
| delete waveforms[waveId]; | |
| } | |
| try { | |
| setStatus("Preparing audio preview…", "busy"); | |
| const preview = await decodeAudio(file); | |
| showInput(file, preview.samples); | |
| setStatus("Source ready · " + formatDuration(preview.samples.length / preview.sampleRate), "idle"); | |
| } catch (error) { | |
| setStatus("Could not preview this audio: " + String(error?.message || error), "error"); | |
| } | |
| }); | |
| window.addEventListener("resize", () => { | |
| for (const [id, entry] of Object.entries(waveforms)) drawWaveform(id, entry.samples, entry.kind); | |
| }); | |
| $("sam-clear-cache").addEventListener("click", async () => { | |
| const button = $("sam-clear-cache"); | |
| button.disabled = true; | |
| try { await clearModelCache(); } | |
| finally { button.disabled = false; } | |
| }); | |
| $("sam-run").addEventListener("click", async () => { | |
| const button = $("sam-run"); | |
| button.disabled = true; | |
| try { | |
| await separate(); | |
| } catch (error) { | |
| const message = String(error?.message || error); | |
| const isWebGpuBufferFailure = lastAttemptProvider === "webgpu" && | |
| /createBuffer failed.*mappedAtCreation|mappedAtCreation.*too large/i.test(message); | |
| if (isWebGpuBufferFailure) { | |
| const retryOnCpu = window.confirm( | |
| "WebGPU failed to allocate a GPU buffer on this device. This can be a browser/driver or GPU-memory limitation.\n\nRetry the same audio using CPU (WASM)? This may be slower." | |
| ); | |
| if (retryOnCpu) { | |
| $("sam-provider").value = "wasm"; | |
| setStatus("Retrying on CPU…"); | |
| setLog("WebGPU buffer allocation failed. Retrying locally with CPU (WASM).\nThe audio is not uploaded to the server."); | |
| try { | |
| await separate("wasm"); | |
| setStatus("Separation complete · CPU fallback used", "success"); | |
| } catch (cpuError) { | |
| setStatus("CPU retry failed: " + String(cpuError?.message || cpuError), "error"); | |
| } | |
| } else { | |
| setStatus("GPU allocation failed · try CPU (WASM)", "error"); | |
| } | |
| } else { | |
| setStatus("Separation failed: " + message, "error"); | |
| } | |
| } finally { | |
| button.disabled = false; | |
| } | |
| }); | |
| } | |
| """ | |
| def zerogpu_startup_marker(): | |
| return "No server inference; browser WebGPU prototype." | |
| with gr.Blocks(title="SAM Audio Studio", theme=gr.themes.Soft(primary_hue="violet", secondary_hue="slate"), css="footer {visibility: hidden}") as demo: | |
| gr.HTML(PAGE) | |
| demo.load(fn=None, inputs=None, outputs=None, js=CLIENT_JS) | |
| if __name__ == "__main__": | |
| demo.launch(server_name="0.0.0.0", server_port=7860, ssr_mode=False) |