const $ = (id) => document.getElementById(id); const fields = [ "stem", "separation_backend", "spleeter_model", "demucs_model", "clustering_mode", "demucs_shifts", "demucs_overlap", "onset_mode", "onset_delta", "energy_threshold_db", "pre_pad", "min_dur", "max_dur", "min_gap", "ncc_threshold", "attack_ms", "mel_threshold", "linkage", "target_min", "target_max", "subdivision", "synthesize", "quantize_midi", "auto_tune", "use_disk_cache", "allow_backend_fallback" ]; let config = null; let selectedFile = null; let activePoll = null; let activeEvents = null; let lastResult = null; let lastSupervisionState = null; let activeJobId = null; let selectedHitIndex = null; let selectedSampleIndex = null; let forceOnsetMode = false; let previewMode = "source"; let audioContext = null; let uploadedOverview = null; let waveformRenderToken = 0; let currentProgress = { fraction: 0, status: "idle", stage_label: null, stage_fraction: 0 }; let currentJobStatus = "idle"; let autoRunToken = 0; let dismissedSampleKeys = new Set(); let extraDrawnSamples = []; let sampleEdits = new Map(); let selectedSampleKeys = new Set(); let sampleOverrides = new Map(); let userChangedSampleSelection = false; let waveZoom = 1; let waveOffset = 0; let configLoadError = null; const palette = ["#9b72ef", "#4f7df2", "#42b8b4", "#ef9343", "#ea5ca9", "#6d9be8", "#8abc59", "#805fe6"]; function clusterColor(index) { const numeric = Number(String(index ?? 0).replace(/[^0-9-]/g, "")); return palette[Math.abs(Number.isFinite(numeric) ? numeric : 0) % palette.length]; } function getAudioContext() { if (!audioContext) audioContext = new (window.AudioContext || window.webkitAudioContext)(); return audioContext; } function esc(value) { return String(value ?? "").replace(/[&<>'"]/g, (c) => ({ "&": "&", "<": "<", ">": ">", "'": "'", '"': """ }[c])); } class ApiRequestError extends Error { constructor(message, meta = {}) { super(message); this.name = "ApiRequestError"; this.status = meta.status ?? null; this.statusText = meta.statusText ?? ""; this.path = meta.path ?? ""; this.detail = meta.detail ?? null; this.payload = meta.payload ?? null; } } function stringifyErrorDetail(value) { if (value === null || value === undefined || value === "") return ""; if (typeof value === "string") return value; try { return JSON.stringify(value, null, 2); } catch { return String(value); } } function showError(title, error, detail = "") { const banner = $("errorBanner"); if (!banner) return; const message = error instanceof Error ? error.message : String(error ?? "Unknown error"); const status = error?.status ? `HTTP ${error.status}${error.statusText ? ` ${error.statusText}` : ""}` : ""; const path = error?.path ? ` · ${error.path}` : ""; const payloadDetail = error?.payload?.detail ?? error?.payload?.error ?? error?.payload?.message ?? ""; const extra = stringifyErrorDetail(detail || error?.detail || payloadDetail || ""); $("errorTitle").textContent = title || "Request failed"; $("errorMessage").textContent = message || "Request failed"; $("errorDetail").textContent = [status + path, extra].filter(Boolean).join("\n"); banner.hidden = false; } function clearError() { const banner = $("errorBanner"); if (banner) banner.hidden = true; } function fmtSec(value) { if (value === null || value === undefined || Number.isNaN(Number(value))) return "—"; const n = Number(value); if (n < 0.001) return `${(n * 1000).toFixed(2)} ms`; if (n < 1) return `${(n * 1000).toFixed(1)} ms`; return `${n.toFixed(2)} s`; } function fmtDate(epochSeconds) { if (!epochSeconds) return "—"; return new Date(epochSeconds * 1000).toLocaleString(undefined, { dateStyle: "medium", timeStyle: "short" }); } function fmtClock(seconds) { const value = Number(seconds); if (!Number.isFinite(value) || value <= 0) return "0:00"; const mins = Math.floor(value / 60); const secs = Math.floor(value % 60).toString().padStart(2, "0"); return `${mins}:${secs}`; } function activeOverview() { return lastResult?.overview ?? uploadedOverview ?? { envelope: [], onsets: [], duration_sec: 0 }; } function setWorkflowStep(step) { const order = ["Load", "Controls", "Extract", "Review"]; const activeIndex = Math.max(0, order.indexOf(step)); order.forEach((name, index) => { const el = $(`flow${name}`); if (!el) return; el.classList.toggle("active", index === activeIndex); el.classList.toggle("done", index < activeIndex); }); } function progressLabel(progress, status) { const pct = Math.round(Math.max(0, Math.min(1, Number(progress?.fraction ?? 0))) * 100); const stage = progress?.stage_label || progress?.stage_key || "pipeline"; const unit = progress?.stage_work_total ? ` · ${progress.stage_work_done}/${progress.stage_work_total}` : ""; if (status === "running" || status === "pending") return `${pct}% · ${stage}${unit}`; if (status === "complete") return "Extraction complete. Click sample cards to audition or export the pack."; if (uploadedOverview?.envelope?.length) return "Waveform ready. Set the common controls, then extract samples."; return "Upload or drop an audio file to render its waveform before extraction."; } function applyJobProgress(job) { currentJobStatus = job?.status ?? "idle"; currentProgress = job?.progress ?? { fraction: 0, status: currentJobStatus, stage_label: null, stage_fraction: 0 }; const status = currentJobStatus; if (status === "running" || status === "pending") setWorkflowStep("Extract"); else if (status === "complete") setWorkflowStep("Review"); else if (uploadedOverview?.envelope?.length) setWorkflowStep("Controls"); else setWorkflowStep("Load"); const title = $("waveTitle"); const text = $("waveProgressText"); if (title) title.textContent = status === "running" || status === "pending" ? "3. Extracting samples" : status === "complete" ? "4. Review extracted samples" : uploadedOverview?.envelope?.length ? "2. Set controls" : "1. Load audio"; if (text) text.textContent = progressLabel(currentProgress, status); } async function buildClientOverview(file, maxPoints = 1800) { const buffer = await file.arrayBuffer(); const decoded = await getAudioContext().decodeAudioData(buffer.slice(0)); const channels = Array.from({ length: decoded.numberOfChannels }, (_, index) => decoded.getChannelData(index)); const frame = Math.max(1, Math.ceil(decoded.length / maxPoints)); const points = Math.max(1, Math.ceil(decoded.length / frame)); const envelope = new Array(points); for (let i = 0; i < points; i += 1) { const start = i * frame; const end = Math.min(decoded.length, start + frame); let peak = 0; for (const channel of channels) { for (let j = start; j < end; j += 1) peak = Math.max(peak, Math.abs(channel[j] || 0)); } envelope[i] = Math.round(peak * 1000000) / 1000000; } return { sample_rate: decoded.sampleRate, duration_sec: Math.round(decoded.duration * 1000000) / 1000000, frame_duration_sec: Math.round((frame / decoded.sampleRate) * 1000000) / 1000000, envelope, onsets: [], source: "browser-upload", }; } async function renderUploadedWaveform(file, token = waveformRenderToken) { const title = $("waveTitle"); const text = $("waveProgressText"); if (title) title.textContent = "1. Loading waveform"; if (text) text.textContent = "Decoding the uploaded file locally for immediate visual feedback…"; const overview = await buildClientOverview(file); if (token !== waveformRenderToken || file !== selectedFile) return; uploadedOverview = overview; currentProgress = { fraction: 0, status: "idle", stage_label: null, stage_fraction: 0 }; currentJobStatus = "idle"; setWorkflowStep("Controls"); applyJobProgress({ status: "idle", progress: currentProgress }); drawWaveform(uploadedOverview); } function transportAudio() { const candidates = { source: $("sourcePreview"), stem: $("stemAudio"), reproduction: $("reconAudio"), }; const selected = candidates[previewMode]; if (selected?.src) return selected; return candidates.reproduction?.src || candidates.source?.src ? (candidates.reproduction?.src ? candidates.reproduction : candidates.source) : candidates.stem; } function setPreviewMode(mode) { const previous = transportAudio(); if (previous && !previous.paused) previous.pause(); previewMode = mode; for (const button of document.querySelectorAll("[data-preview-mode]")) { button.classList.toggle("active", button.dataset.previewMode === previewMode); } updateTransport(); } function updateTransport() { const audio = transportAudio(); const current = audio ? Number(audio.currentTime || 0) : 0; const duration = audio && Number.isFinite(Number(audio.duration)) ? Number(audio.duration) : 0; const time = $("transportTime"); if (time) time.textContent = `${fmtClock(current)} / ${fmtClock(duration)}`; const seek = $("transportSeek"); if (seek && !seek.matches(":active")) { seek.value = duration > 0 ? String(Math.max(0, Math.min(1000, Math.round((current / duration) * 1000)))) : "0"; } const button = $("transportPlayButton"); if (button) button.textContent = audio && !audio.paused ? "❚❚" : "▶"; } function pauseNonTransportAudio() { const keep = transportAudio(); for (const id of ["sourcePreview", "stemAudio", "reconAudio", "hitAudio", "sampleAudio"]) { const el = $(id); if (el && el !== keep && !el.paused) el.pause(); } } async function toggleTransportPlayback() { const audio = transportAudio(); if (!audio?.src) return; if (audio.paused) { pauseNonTransportAudio(); const promise = audio.play(); if (promise && typeof promise.catch === "function") await promise.catch(() => {}); } else { audio.pause(); } updateTransport(); } function seekTransport(value) { const audio = transportAudio(); if (!audio?.src || !Number.isFinite(Number(audio.duration)) || Number(audio.duration) <= 0) return; audio.currentTime = (Number(value) / 1000) * Number(audio.duration); updateTransport(); } function setHealth(ok, text, subtext) { $("healthDot").className = `status-dot ${ok ? "ok" : "bad"}`; $("healthText").textContent = text; $("healthSubtext").textContent = subtext; } async function api(path, options = {}) { let response; try { response = await fetch(path, options); } catch (cause) { const error = new ApiRequestError(cause?.message || "Network request failed", { path }); showError("Network request failed", error); throw error; } if (!response.ok) { let payload = null; let detail = response.statusText || `HTTP ${response.status}`; try { payload = await response.json(); detail = stringifyErrorDetail(payload.detail ?? payload.error ?? payload.message ?? detail); } catch { try { detail = await response.text(); } catch {} } const error = new ApiRequestError(detail, { status: response.status, statusText: response.statusText, path, detail, payload, }); showError("API request failed", error); throw error; } return response.json(); } async function jsonApi(path, body = {}, method = "POST") { return api(path, { method, headers: { "Content-Type": "application/json" }, body: JSON.stringify(body), }); } async function waitForConfigReady() { if (config) return config; if (configLoadError) throw configLoadError; for (let i = 0; i < 80; i += 1) { await new Promise((resolve) => setTimeout(resolve, 100)); if (config) return config; if (configLoadError) throw configLoadError; } throw new Error("Backend configuration did not load yet. Reload the page or check the server logs."); } function backendAvailable(name) { return Boolean(config?.runtime?.backends?.[name]?.available); } function applyRuntimeBackendHints() { const backend = $("separation_backend"); if (!backend || !config?.runtime?.backends) return; const spleeterAvailable = backendAvailable("spleeter"); const demucsAvailable = backendAvailable("demucs"); if (!spleeterAvailable && backend.value === "spleeter") { backend.value = "none"; updateStemOptions(); $("resultSummary").textContent = demucsAvailable ? "Spleeter is not available in this runtime. Full-mix mode is active by default; choose Demucs under Expert controls when you want slower quality separation." : "Spleeter is not available in this runtime. Full-mix mode is active so the app still works."; } } function hitIdFromIndex(index) { if (index === null || index === undefined) return null; return `hit:${String(Number(index)).padStart(5, "0")}`; } function stateHitByIndex(index) { const id = hitIdFromIndex(index); return (lastSupervisionState?.hits ?? []).find((hit) => hit.id === id) ?? null; } function decorateHit(hit) { const stateHit = stateHitByIndex(hit.index); return { ...hit, state_hit_id: stateHit?.id ?? hitIdFromIndex(hit.index), cluster_ref: stateHit?.cluster_id ?? `cluster:${hit.cluster_id}`, cluster_label: stateHit?.cluster_label ?? hit.cluster_label, confidence: stateHit?.confidence, confidence_reasons: stateHit?.confidence_reasons ?? [], suppressed: Boolean(stateHit?.suppressed), favorite: Boolean(stateHit?.favorite), review_status: stateHit?.review_status ?? "unreviewed", }; } function currentTargetCluster() { const id = $("targetClusterSelect")?.value; return (lastSupervisionState?.clusters ?? []).find((cluster) => cluster.id === id) ?? null; } function setActionButtons() { const hasState = Boolean(activeJobId && lastSupervisionState); const hasHit = hasState && selectedHitIndex !== null; for (const id of ["moveHitButton", "pullHitButton", "acceptHitButton", "favoriteHitButton", "suppressHitButton", "restoreHitButton"]) { const button = $(id); if (button) button.disabled = !hasHit; } for (const id of ["refreshStateButton", "undoButton", "lockClusterButton", "explainClusterButton", "exportStateButton", "forceOnsetButton"]) { const button = $(id); if (button) button.disabled = !hasState; } const target = currentTargetCluster(); if ($("lockClusterButton")) $("lockClusterButton").textContent = target?.locked ? "Unlock target cluster" : "Lock target cluster"; if ($("forceOnsetButton")) { $("forceOnsetButton").textContent = forceOnsetMode ? "Add-onset mode on" : "Add-onset mode off"; $("forceOnsetButton").classList.toggle("active", forceOnsetMode); } if ($("undoButton") && lastSupervisionState) $("undoButton").disabled = !lastSupervisionState.summary?.undo_available; } function setSelectOptions(select, values, labels = null) { select.innerHTML = ""; for (const value of values) { const option = document.createElement("option"); option.value = String(value); option.textContent = labels?.[value] ?? String(value); select.appendChild(option); } } function populateConfig() { if ($("separation_backend")) setSelectOptions($("separation_backend"), config.separation_backends ?? ["spleeter", "demucs", "none"], { spleeter: "Spleeter (default)", demucs: "Demucs", none: "No separation / full mix" }); if ($("spleeter_model")) setSelectOptions($("spleeter_model"), config.spleeter_models ?? ["spleeter:4stems"]); setSelectOptions($("demucs_model"), config.demucs_models); setSelectOptions($("clustering_mode"), Object.keys(config.clustering_modes ?? { batch_quality: "", online_preview: "" }), config.clustering_modes); const defaults = config.defaults; for (const field of fields) { const el = $(field); if (!el || defaults[field] === undefined) continue; if (el.type === "checkbox") el.checked = Boolean(defaults[field]); else el.value = defaults[field]; } updateStemOptions(); applyRuntimeBackendHints(); updateControlOutputs(); renderStages(config.stages); } function updateStemOptions() { const backend = $("separation_backend")?.value || config.defaults.separation_backend || "spleeter"; let stems = ["drums", "bass", "other", "vocals", "all"]; if (backend === "spleeter") { const model = $("spleeter_model")?.value || config.defaults.spleeter_model || "spleeter:4stems"; stems = config.spleeter_stems?.[model] ?? stems; } else if (backend === "demucs") { const model = $("demucs_model")?.value || config.defaults.demucs_model; stems = config.demucs_stems?.[model] ?? stems; } else { stems = ["all"]; } const current = $("stem").value || config.defaults.stem; setSelectOptions($("stem"), stems); $("stem").value = stems.includes(current) ? current : stems[0]; } function collectParams() { const params = {}; const defaults = config?.defaults ?? {}; for (const field of fields) { const el = $(field); if (!el) continue; const defaultValue = defaults[field]; if (el.type === "checkbox") { params[field] = el.checked; } else if (["number", "range"].includes(el.type) || typeof defaultValue === "number") { const raw = String(el.value ?? "").trim(); params[field] = raw === "" && defaultValue !== undefined ? defaultValue : Number(raw); } else if (typeof defaultValue === "boolean") { params[field] = el.value === "true" || el.value === "1" || el.value === "on"; } else { params[field] = el.value; } } params.auto_tune = $("auto_tune") ? Boolean($("auto_tune").checked) : true; if (params.separation_backend === "none") params.stem = "all"; return params; } function renderStages(stages = []) { $("stageList").innerHTML = stages.map((stage) => { const pct = Math.round(Math.max(0, Math.min(1, Number(stage.progress ?? (stage.status === "done" ? 1 : 0)))) * 100); return `
No review items.
`; for (const button of $("reviewQueue").querySelectorAll("[data-review-hit]")) { button.addEventListener("click", () => selectHit(button.dataset.reviewHit)); } $("clusterBoard").innerHTML = (state.clusters ?? []).map((cluster) => ` `).join("") || `No clusters.
`; for (const button of $("clusterBoard").querySelectorAll("[data-cluster-select]")) { button.addEventListener("click", () => { $("targetClusterSelect").value = button.dataset.clusterSelect; setActionButtons(); explainTargetCluster().catch(() => {}); }); } $("suggestionInbox").innerHTML = (state.suggestions ?? []).map((suggestion) => { const diff = suggestion.diff ?? {}; const diffText = diff.affected_hit_count == null ? "no diff" : `${diff.affected_hit_count} affected · ${Object.keys(diff.clusters_before ?? {}).length} clusters`; const hitPreview = (diff.hits ?? []).slice(0, 4).map((hit) => `#${hit.hit_index}: ${hit.from_cluster_label ?? hit.from_cluster_id} → ${hit.after_suppressed ? "suppressed" : (hit.to_cluster_label ?? hit.to_cluster_id)}`).join("; "); return `No open suggestions.
`; for (const button of $("suggestionInbox").querySelectorAll("[data-preview-suggestion]")) { button.addEventListener("click", () => { const suggestion = (lastSupervisionState?.suggestions ?? []).find((item) => item.id === button.dataset.previewSuggestion); $("clusterExplanation").classList.remove("empty"); $("clusterExplanation").textContent = JSON.stringify(suggestion?.diff ?? {}, null, 2); }); } for (const button of $("suggestionInbox").querySelectorAll("[data-accept-suggestion]")) { button.addEventListener("click", () => acceptSuggestion(button.dataset.acceptSuggestion)); } for (const button of $("suggestionInbox").querySelectorAll("[data-reject-suggestion]")) { button.addEventListener("click", () => rejectSuggestion(button.dataset.rejectSuggestion)); } const eventRows = (state.events ?? []).slice(-12).reverse().map((event) => `No state events yet.
`; setActionButtons(); if (lastResult) renderHits(lastResult); } async function fetchState() { if (!activeJobId) return null; const state = await api(`/api/jobs/${encodeURIComponent(activeJobId)}/state`); renderSupervisionState(state); return state; } async function applyStateAction(path, body = {}) { if (!activeJobId) return; const state = await jsonApi(path, body); renderSupervisionState(state); } async function moveSelectedHit() { const hitId = hitIdFromIndex(selectedHitIndex); const target = $("targetClusterSelect").value; if (!activeJobId || !hitId || !target) return; await applyStateAction(`/api/jobs/${encodeURIComponent(activeJobId)}/hits/${encodeURIComponent(hitId)}/move`, { target_cluster_id: target }); } async function pullSelectedHit() { const hitId = hitIdFromIndex(selectedHitIndex); if (!activeJobId || !hitId) return; await applyStateAction(`/api/jobs/${encodeURIComponent(activeJobId)}/hits/${encodeURIComponent(hitId)}/pull-out`, {}); } async function suppressSelectedHit() { const hitId = hitIdFromIndex(selectedHitIndex); if (!activeJobId || !hitId) return; await applyStateAction(`/api/jobs/${encodeURIComponent(activeJobId)}/hits/${encodeURIComponent(hitId)}/suppress`, { reason: "bleed" }); } async function restoreSelectedHit() { const hitId = hitIdFromIndex(selectedHitIndex); if (!activeJobId || !hitId) return; await applyStateAction(`/api/jobs/${encodeURIComponent(activeJobId)}/hits/${encodeURIComponent(hitId)}/restore`, {}); } async function reviewSelectedHit(status) { const hitId = hitIdFromIndex(selectedHitIndex); if (!activeJobId || !hitId) return; await applyStateAction(`/api/jobs/${encodeURIComponent(activeJobId)}/hits/${encodeURIComponent(hitId)}/review`, { status }); } async function toggleTargetClusterLock() { const target = currentTargetCluster(); if (!activeJobId || !target) return; await applyStateAction(`/api/jobs/${encodeURIComponent(activeJobId)}/clusters/${encodeURIComponent(target.id)}/lock`, { locked: !target.locked }); } async function explainTargetCluster() { const target = currentTargetCluster(); if (!activeJobId || !target) return; const explanation = await api(`/api/jobs/${encodeURIComponent(activeJobId)}/explain/cluster/${encodeURIComponent(target.id)}`); $("clusterExplanation").classList.remove("empty"); $("clusterExplanation").textContent = JSON.stringify(explanation, null, 2); } async function acceptSuggestion(id) { if (!activeJobId) return; await applyStateAction(`/api/jobs/${encodeURIComponent(activeJobId)}/suggestions/${encodeURIComponent(id)}/accept`, {}); } async function rejectSuggestion(id) { if (!activeJobId) return; await applyStateAction(`/api/jobs/${encodeURIComponent(activeJobId)}/suggestions/${encodeURIComponent(id)}/reject`, {}); } async function undoLastEdit() { if (!activeJobId) return; await applyStateAction(`/api/jobs/${encodeURIComponent(activeJobId)}/undo`, {}); } function renderEditedExport(exportPayload) { const fileUrls = exportPayload?.file_urls ?? {}; const labels = { archive: "Edited sample pack ZIP", midi: "Edited MIDI", reconstruction: "Edited reproduced mix WAV", target_reconstruction: "Edited target reconstruction WAV" }; $("editedDownloads").innerHTML = Object.entries(fileUrls) .map(([key, url]) => `${esc(labels[key] ?? key)}`) .join(""); } async function exportEditedPack() { if (!activeJobId) return; $("exportStateButton").disabled = true; try { const payload = await jsonApi(`/api/jobs/${encodeURIComponent(activeJobId)}/export`, { synthesize: true }); renderEditedExport(payload.export); renderSupervisionState(payload.state); $("clusterExplanation").classList.remove("empty"); $("clusterExplanation").textContent = JSON.stringify(payload.export, null, 2); } finally { setActionButtons(); } } async function forceOnsetAtTime(timeSec) { if (!activeJobId) return; const body = { onset_sec: Number(timeSec) }; const target = currentTargetCluster(); if (target) body.target_cluster_id = target.id; const before = new Set((lastSupervisionState?.hits ?? []).map((hit) => hit.id)); const state = await jsonApi(`/api/jobs/${encodeURIComponent(activeJobId)}/hits/force-onset`, body); renderSupervisionState(state); const added = (state.hits ?? []).find((hit) => !before.has(hit.id) && hit.source === "forced"); if (added) selectHit(added.index); } function renderResult(job) { const result = job.result; if (!result) return; activeJobId = job.id; currentJobStatus = "complete"; currentProgress = job.progress ?? { fraction: 1, status: "complete" }; applyJobProgress({ status: "complete", progress: currentProgress }); lastResult = result; if (!(result.samples ?? [])[selectedSampleIndex]) selectedSampleIndex = null; if (!(result.hits ?? []).some((hit) => Number(hit.index) === Number(selectedHitIndex))) { selectedHitIndex = (result.hits ?? [])[0]?.index ?? null; } const rtf = Number(result.realtime_factor).toFixed(2); const mode = result.params?.clustering_mode ?? "—"; $("resultSummary").textContent = `${result.hit_count} hits → ${result.cluster_count} samples · BPM ${result.bpm ?? "—"} · ${fmtSec(result.duration_sec)} total · ${rtf}× realtime · ${mode}`; const fileUrls = result.file_urls ?? {}; const labels = { archive: "Sample pack ZIP", midi: "MIDI", source: "Source mix WAV", stem: "Target stem WAV", context_bed: "Non-target stems WAV", target_reconstruction: "Target reconstruction WAV", reconstruction: "Reproduced mix WAV", }; const downloadOrder = ["archive", "reconstruction", "target_reconstruction", "midi", "source", "stem", "context_bed"]; $("downloads").innerHTML = downloadOrder .filter((key) => fileUrls[key]) .map((key) => `${esc(labels[key] ?? key)}`) .join(""); $("sourcePreview").src = fileUrls.source ?? $("sourcePreview").src ?? ""; $("stemAudio").src = fileUrls.stem ?? ""; $("reconAudio").src = fileUrls.reconstruction ?? ""; if (fileUrls.reconstruction) setPreviewMode("reproduction"); else updateTransport(); renderSamples(result); renderHits(result); drawWaveform(result.overview); if (activeJobId) fetchState().catch((error) => { $("supervisionSummary").textContent = error.message; }); } function renderJob(job) { $("jobPill").textContent = `${job.status}${job.id ? ` · ${job.id}` : ""}`; applyJobProgress(job); renderStages(job.stages ?? []); $("logs").textContent = (job.logs ?? []).join("\n"); if (job.status !== "complete") { if ((job.partial_samples ?? []).length) renderSamples({ samples: job.partial_samples }); drawWaveform(activeOverview()); } if (job.status === "complete") renderResult(job); if (job.status === "error") { $("resultSummary").textContent = `Extraction failed: ${job.error}`; $("logs").textContent = `${(job.logs ?? []).join("\n")}\n\n${job.traceback ?? ""}`; showError("Extraction failed", new Error(job.error || "Pipeline error"), job.traceback || "See the Pipeline panel for logs."); } } function renderHistory(payload) { const rows = [...(payload.active ?? []), ...(payload.history ?? [])]; if (!rows.length) { $("historyList").innerHTML = `No completed runs yet.
`; return; } $("historyList").innerHTML = rows.map((row) => ` `).join(""); for (const button of $("historyList").querySelectorAll(".history-row")) { button.addEventListener("click", async () => { const job = await api(`/api/jobs/${button.dataset.jobId}`); selectedHitIndex = null; renderJob(job); setWorkflowStep("Review"); }); } } async function refreshHistory() { try { const payload = await api("/api/jobs?limit=50"); renderHistory(payload); } catch (error) { $("historyList").innerHTML = `${esc(error.message)}
`; } } function stopWatchers() { if (activePoll) clearInterval(activePoll); activePoll = null; if (activeEvents) activeEvents.close(); activeEvents = null; } async function pollJob(id) { stopWatchers(); const tick = async () => { try { const job = await api(`/api/jobs/${id}`); renderJob(job); if (["complete", "error"].includes(job.status)) { stopWatchers(); $("runButton").disabled = !selectedFile; await refreshHistory(); } } catch (error) { stopWatchers(); $("runButton").disabled = !selectedFile; $("resultSummary").textContent = error.message; } }; await tick(); activePoll = setInterval(tick, 800); } async function watchJob(id) { if (!("EventSource" in window)) return pollJob(id); stopWatchers(); return new Promise((resolve) => { activeEvents = new EventSource(`/api/jobs/${id}/events`); activeEvents.addEventListener("job", async (event) => { const job = JSON.parse(event.data); renderJob(job); if (["complete", "error"].includes(job.status)) { stopWatchers(); $("runButton").disabled = !selectedFile; await refreshHistory(); resolve(); } }); activeEvents.onerror = () => { stopWatchers(); pollJob(id).then(resolve); }; }); } async function runExtraction(options = {}) { if (!selectedFile) return; const automatic = Boolean(options.automatic); try { await waitForConfigReady(); } catch (error) { showError("App is still loading", error); $("resultSummary").textContent = error.message; return; } selectedHitIndex = null; lastResult = null; lastSupervisionState = null; activeJobId = null; $("runButton").disabled = true; $("jobPill").textContent = "uploading"; currentJobStatus = "pending"; currentProgress = { fraction: 0, status: "pending", stage_label: "Uploading source", stage_fraction: 0 }; applyJobProgress({ status: "pending", progress: currentProgress }); drawWaveform(activeOverview()); $("logs").textContent = automatic ? "Audio loaded. Uploading and starting automatic extraction…" : "Uploading source and starting extraction…"; const form = new FormData(); form.append("file", selectedFile, selectedFile.name); form.append("params", JSON.stringify(collectParams())); try { clearError(); const job = await api("/api/jobs", { method: "POST", body: form }); renderJob(job); await watchJob(job.id); await refreshHistory(); } catch (error) { $("runButton").disabled = false; $("jobPill").textContent = "error"; const detail = stringifyErrorDetail(error.detail || error.payload || ""); $("resultSummary").textContent = `${automatic ? "Automatic extraction" : "Extraction"} could not start: ${error.message}`; $("logs").textContent = `Request failed before the pipeline started.\n${error.message}${detail ? `\n\n${detail}` : ""}`; showError("Extraction could not start", error, "The request did not enter the extraction pipeline. Check the visible controls first; advanced parameters may also be invalid."); } } function scheduleAutomaticExtraction(file) { const token = ++autoRunToken; window.setTimeout(() => { if (token !== autoRunToken || selectedFile !== file) return; if (["pending", "running"].includes(currentJobStatus)) return; runExtraction({ automatic: true }).catch((error) => showError("Automatic extraction failed", error)); }, 220); } function clearRunViews() { lastResult = null; lastSupervisionState = null; activeJobId = null; selectedHitIndex = null; selectedSampleIndex = null; dismissedSampleKeys = new Set(); extraDrawnSamples = []; sampleEdits = new Map(); selectedSampleKeys = new Set(); sampleOverrides = new Map(); userChangedSampleSelection = false; $("downloads").innerHTML = ""; $("editedDownloads").innerHTML = ""; $("supervisionSummary").textContent = "No interactive state loaded."; $("samplesGrid").innerHTML = `Samples appear here after extraction.
`; $("sampleCountLabel").textContent = "(0)"; $("samplesTable").querySelector("tbody").innerHTML = ""; $("hitsTable").querySelector("tbody").innerHTML = ""; } function setFile(file) { clearError(); selectedFile = file; clearRunViews(); $("dropTitle").textContent = file ? file.name : "Drop audio anywhere"; $("dropMeta").textContent = file ? `${(file.size / 1024 / 1024).toFixed(2)} MB · processing starts automatically` : "or use Choose audio"; $("runButton").disabled = !file; currentProgress = { fraction: 0, status: "idle", stage_label: null, stage_fraction: 0 }; currentJobStatus = "idle"; if (file) { $("resultSummary").textContent = `${file.name} loaded. The waveform renders immediately and extraction starts automatically.`; $("stemAudio").removeAttribute("src"); $("reconAudio").removeAttribute("src"); $("sourcePreview").src = URL.createObjectURL(file); setPreviewMode("source"); setWorkflowStep("Controls"); const token = ++waveformRenderToken; renderUploadedWaveform(file, token).catch((error) => { uploadedOverview = null; setWorkflowStep("Controls"); $("waveTitle").textContent = "Processing audio"; $("waveProgressText").textContent = `Could not decode waveform in browser: ${error.message}. Automatic extraction can still run.`; drawWaveform({ envelope: [], onsets: [], duration_sec: 0 }); }); scheduleAutomaticExtraction(file); } else { uploadedOverview = null; $("sourcePreview").removeAttribute("src"); setWorkflowStep("Load"); applyJobProgress({ status: "idle", progress: currentProgress }); drawWaveform({ envelope: [], onsets: [], duration_sec: 0 }); } } function selectNearestWaveformHit(event) { if (!lastResult?.overview) return; const rect = $("waveform").getBoundingClientRect(); const localRatio = Math.min(1, Math.max(0, (event.clientX - rect.left) / Math.max(1, rect.width))); const view = visibleWaveRange(); const ratio = view.start + localRatio * view.span; const time = ratio * Math.max(lastResult.overview.duration_sec, 0.001); if (forceOnsetMode) { forceOnsetAtTime(time).catch((error) => { $("clusterExplanation").textContent = error.message; }); return; } if (!lastResult.overview.onsets?.length) return; let best = null; let bestDelta = Infinity; for (const onset of lastResult.overview.onsets) { const delta = Math.abs(Number(onset.time_sec) - time); if (delta < bestDelta) { best = onset; bestDelta = delta; } } if (best) selectHit(best.index); } async function boot() { try { await api("/api/health"); config = await api("/api/config"); populateConfig(); await refreshHistory(); setWorkflowStep("Load"); applyJobProgress({ status: "idle", progress: currentProgress }); drawWaveform({ envelope: [], onsets: [], duration_sec: 0 }); setHealth(true, "Ready", "Backend online"); } catch (error) { configLoadError = error; setHealth(false, "Offline", error.message); showError("Backend unavailable", error, "Start the FastAPI server, then reload this page."); } } $("dismissErrorButton").addEventListener("click", clearError); if ($("separation_backend")) $("separation_backend").addEventListener("change", updateStemOptions); if ($("spleeter_model")) $("spleeter_model").addEventListener("change", updateStemOptions); $("demucs_model").addEventListener("change", updateStemOptions); $("fileInput").addEventListener("change", (event) => setFile(event.target.files?.[0] ?? null)); if ($("chooseAudioButton")) $("chooseAudioButton").addEventListener("click", (event) => { event.preventDefault(); event.stopPropagation(); $("fileInput").click(); }); if ($("dropzone")) { $("dropzone").addEventListener("keydown", (event) => { if (event.key === "Enter" || event.key === " ") { event.preventDefault(); $("fileInput").click(); } }); } $("runButton").addEventListener("click", () => runExtraction({ automatic: false })); $("usePreviewButton").addEventListener("click", () => { $("separation_backend").value = "none"; updateStemOptions(); $("stem").value = "all"; $("clustering_mode").value = "online_preview"; $("demucs_shifts").value = 0; $("target_min").value = 4; $("target_max").value = 16; $("mel_threshold").value = 0.62; $("ncc_threshold").value = 0.72; $("resultSummary").textContent = "Fast preview preset applied: full mix, online grouping, no Demucs shifts."; }); $("useQualityButton").addEventListener("click", () => { $("separation_backend").value = "demucs"; updateStemOptions(); if (($("stem").value || "") === "all") $("stem").value = "drums"; $("clustering_mode").value = "batch_quality"; $("demucs_shifts").value = 1; $("demucs_overlap").value = 0.25; $("target_min").value = 5; $("target_max").value = 20; $("mel_threshold").value = 0.75; $("ncc_threshold").value = 0.80; $("resultSummary").textContent = "Best quality preset applied: separated stem, batch clustering, conservative grouping."; }); for (const [id, delta] of [["clusterMinusButton", -1], ["clusterPlusButton", 1]]) { const button = $(id); if (button) { button.addEventListener("click", () => { const input = $("target_max"); const current = Number(input.value || 0); const min = Number(input.min || 0); const max = Number(input.max || 256); input.value = Math.max(min, Math.min(max, current + delta)); }); } } $("refreshHistoryButton").addEventListener("click", refreshHistory); $("clearCacheButton").addEventListener("click", async () => { try { await api("/api/cache/clear", { method: "POST" }); $("logs").textContent = "Pipeline memory and disk cache cleared."; } catch (error) { $("logs").textContent = error.message; } }); $("refreshStateButton").addEventListener("click", () => fetchState().catch((error) => { $("supervisionSummary").textContent = error.message; })); $("undoButton").addEventListener("click", () => undoLastEdit().catch((error) => { $("clusterExplanation").textContent = error.message; })); $("moveHitButton").addEventListener("click", () => moveSelectedHit().catch((error) => { $("clusterExplanation").textContent = error.message; })); $("pullHitButton").addEventListener("click", () => pullSelectedHit().catch((error) => { $("clusterExplanation").textContent = error.message; })); $("acceptHitButton").addEventListener("click", () => reviewSelectedHit("accepted").catch((error) => { $("clusterExplanation").textContent = error.message; })); $("favoriteHitButton").addEventListener("click", () => reviewSelectedHit("favorite").catch((error) => { $("clusterExplanation").textContent = error.message; })); $("suppressHitButton").addEventListener("click", () => suppressSelectedHit().catch((error) => { $("clusterExplanation").textContent = error.message; })); $("restoreHitButton").addEventListener("click", () => restoreSelectedHit().catch((error) => { $("clusterExplanation").textContent = error.message; })); $("exportStateButton").addEventListener("click", () => exportEditedPack().catch((error) => { $("clusterExplanation").textContent = error.message; setActionButtons(); })); $("forceOnsetButton").addEventListener("click", () => { forceOnsetMode = !forceOnsetMode; setActionButtons(); }); $("lockClusterButton").addEventListener("click", () => toggleTargetClusterLock().catch((error) => { $("clusterExplanation").textContent = error.message; })); $("explainClusterButton").addEventListener("click", () => explainTargetCluster().catch((error) => { $("clusterExplanation").textContent = error.message; })); $("targetClusterSelect").addEventListener("change", setActionButtons); $("waveform").addEventListener("click", selectNearestWaveformHit); $("waveform").addEventListener("wheel", (event) => { if (event.ctrlKey || event.metaKey) { event.preventDefault(); zoomWaveformAround(event.clientX, event.deltaY < 0 ? 1.18 : 1 / 1.18); return; } if (Math.abs(event.deltaX) > 0 || event.shiftKey) { event.preventDefault(); const delta = (event.deltaX || event.deltaY) / Math.max(1, $("waveform").getBoundingClientRect().width); panWaveform(delta); } }, { passive: false }); $("transportPlayButton").addEventListener("click", () => { toggleTransportPlayback().catch(() => {}); }); $("transportSeek").addEventListener("input", (event) => seekTransport(event.target.value)); for (const id of ["sourcePreview", "stemAudio", "reconAudio"]) { const audio = $(id); audio.addEventListener("timeupdate", updateTransport); audio.addEventListener("durationchange", updateTransport); audio.addEventListener("play", updateTransport); audio.addEventListener("pause", updateTransport); audio.addEventListener("ended", updateTransport); } for (const button of document.querySelectorAll("[data-preview-mode]")) { button.addEventListener("click", () => setPreviewMode(button.dataset.previewMode)); } const dropzone = $("dropzone"); const globalDropOverlay = $("globalDropOverlay"); let pageDragDepth = 0; function eventHasFiles(event) { return Array.from(event.dataTransfer?.types ?? []).includes("Files"); } function setDragActive(active) { dropzone?.classList.toggle("dragging", Boolean(active)); globalDropOverlay?.classList.toggle("visible", Boolean(active)); } function acceptDroppedFile(event) { const file = event.dataTransfer?.files?.[0] ?? null; if (file) setFile(file); } for (const eventName of ["dragenter", "dragover"]) { dropzone.addEventListener(eventName, (event) => { if (!eventHasFiles(event)) return; event.preventDefault(); event.stopPropagation(); event.dataTransfer.dropEffect = "copy"; setDragActive(true); }); } for (const eventName of ["dragleave", "drop"]) { dropzone.addEventListener(eventName, (event) => { if (!eventHasFiles(event)) return; event.preventDefault(); event.stopPropagation(); setDragActive(false); }); } dropzone.addEventListener("drop", acceptDroppedFile); window.addEventListener("dragenter", (event) => { if (!eventHasFiles(event)) return; event.preventDefault(); pageDragDepth += 1; setDragActive(true); }); window.addEventListener("dragover", (event) => { if (!eventHasFiles(event)) return; event.preventDefault(); event.dataTransfer.dropEffect = "copy"; setDragActive(true); }); window.addEventListener("dragleave", (event) => { if (!eventHasFiles(event)) return; pageDragDepth = Math.max(0, pageDragDepth - 1); if (pageDragDepth === 0) setDragActive(false); }); window.addEventListener("drop", (event) => { if (!eventHasFiles(event)) return; event.preventDefault(); pageDragDepth = 0; setDragActive(false); acceptDroppedFile(event); }); window.addEventListener("blur", () => { pageDragDepth = 0; setDragActive(false); }); window.addEventListener("resize", () => { drawWaveform(activeOverview()); }); for (const id of ["onset_delta", "min_gap", "energy_threshold_db", "target_max", "ncc_threshold"]) { const input = $(id); if (input) input.addEventListener("input", updateControlOutputs); } for (const [id, factor] of [["zoomOutButton", 1 / 1.35], ["zoomInButton", 1.35]]) { const button = $(id); if (button) button.addEventListener("click", () => zoomWaveformAround($("waveform").getBoundingClientRect().left + $("waveform").getBoundingClientRect().width / 2, factor)); } if ($("zoomFitButton")) $("zoomFitButton").addEventListener("click", () => { waveZoom = 1; waveOffset = 0; drawWaveform(activeOverview()); }); for (const button of document.querySelectorAll("[data-zoom-command]")) { button.addEventListener("click", () => zoomWaveformAround($("waveform").getBoundingClientRect().left + $("waveform").getBoundingClientRect().width / 2, button.dataset.zoomCommand === "in" ? 1.35 : 1 / 1.35)); } if ($("openToolsButton")) $("openToolsButton").addEventListener("click", () => { const drawer = $("toolsDrawer"); drawer.hidden = !drawer.hidden; }); if ($("selectAllSamplesButton")) $("selectAllSamplesButton").addEventListener("click", selectAllVisibleSamples); if ($("clearSelectionButton")) $("clearSelectionButton").addEventListener("click", clearSampleSelection); function clickArchiveDownload() { const url = lastResult?.file_urls?.archive; if (url) { window.location.href = url; return; } const link = $("downloads")?.querySelector('a[href*="sample-pack"], a[download], a'); if (link) link.click(); else showError("Nothing to export yet", new Error("Run extraction first; sample-pack ZIP appears when processing completes.")); } async function exportSelectedSamples() { if (!activeJobId) { showError("Nothing selected", new Error("Run extraction first.")); return; } const samples = selectedVisibleSamples(); const labels = samples.map((sample) => sample.label).filter(Boolean); if (!labels.length) { showError("Nothing selected", new Error("Select at least one sample card.")); return; } const payload = await jsonApi(`/api/jobs/${encodeURIComponent(activeJobId)}/export-selected`, { labels, synthesize: true }); renderEditedExport(payload.export); if (payload.state) renderSupervisionState(payload.state); const archiveUrl = payload.export?.file_urls?.archive; if (archiveUrl) window.location.href = archiveUrl; } if ($("exportAllButton")) $("exportAllButton").addEventListener("click", clickArchiveDownload); if ($("exportSelectedButton")) $("exportSelectedButton").addEventListener("click", () => exportSelectedSamples().catch((error) => showError("Could not export selected samples", error))); if ($("resetUiButton")) $("resetUiButton").addEventListener("click", () => { populateConfig(); updateControlOutputs(); }); if ($("groupSimilarToggle")) $("groupSimilarToggle").addEventListener("change", () => { $("clustering_mode").value = $("groupSimilarToggle").checked ? "batch_quality" : "online_preview"; }); updateControlOutputs(); boot();