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 `
${esc(stage.label)}${esc(stage.detail || stage.status)}${stage.work_total ? ` · ${esc(stage.work_done)}/${esc(stage.work_total)}` : ""}
`; }).join(""); } function clampWaveView() { waveZoom = Math.max(1, Math.min(64, Number(waveZoom) || 1)); const visible = 1 / waveZoom; waveOffset = Math.max(0, Math.min(1 - visible, Number(waveOffset) || 0)); } function visibleWaveRange() { clampWaveView(); const visible = 1 / waveZoom; return { start: waveOffset, end: Math.min(1, waveOffset + visible), span: visible }; } function timeToWaveX(timeSec, overview, width) { const duration = Math.max(Number(overview?.duration_sec || 0), 0.001); const ratio = Math.max(0, Math.min(1, Number(timeSec) / duration)); const view = visibleWaveRange(); return ((ratio - view.start) / Math.max(view.span, 1e-6)) * width; } function drawWaveform(overview) { const canvas = $("waveform"); if (!canvas) return; const ctx = canvas.getContext("2d"); const ratio = window.devicePixelRatio || 1; const rect = canvas.getBoundingClientRect(); const cssHeight = Math.max(220, Math.floor(rect.height || 360)); canvas.width = Math.max(1, Math.floor(rect.width * ratio)); canvas.height = Math.max(1, Math.floor(cssHeight * ratio)); ctx.setTransform(ratio, 0, 0, ratio, 0, 0); const w = Math.max(1, Math.floor(rect.width)); const h = cssHeight; ctx.clearRect(0, 0, w, h); const env = overview?.envelope ?? []; const mid = Math.round(h * 0.54); ctx.fillStyle = "#fbfbfc"; ctx.fillRect(0, 0, w, h); ctx.strokeStyle = "rgba(36, 37, 43, .08)"; ctx.lineWidth = 1; for (let line = 0; line < 5; line += 1) { const y = Math.round((h * (line + 1)) / 6); ctx.beginPath(); ctx.moveTo(18, y); ctx.lineTo(w - 18, y); ctx.stroke(); } if (!env.length) { ctx.fillStyle = "rgba(36, 37, 43, .42)"; ctx.textAlign = "center"; ctx.font = "600 18px ui-sans-serif, system-ui"; ctx.fillText("Drop an audio file here", w / 2, mid - 8); ctx.font = "500 12px ui-sans-serif, system-ui"; ctx.fillStyle = "rgba(36, 37, 43, .34)"; ctx.fillText("processing starts automatically", w / 2, mid + 16); return; } const view = visibleWaveRange(); const startIndex = Math.max(0, Math.floor(view.start * (env.length - 1))); const endIndex = Math.min(env.length - 1, Math.ceil(view.end * (env.length - 1))); const visibleCount = Math.max(1, endIndex - startIndex + 1); function traceWavePath() { ctx.beginPath(); for (let px = 0; px < w; px += 1) { const local = px / Math.max(1, w - 1); const idx = Math.min(env.length - 1, Math.max(0, Math.round(startIndex + local * visibleCount))); const v = Math.min(1, Number(env[idx] || 0)); const y = mid - v * (h * 0.36); if (px === 0) ctx.moveTo(px, y); else ctx.lineTo(px, y); } for (let px = w - 1; px >= 0; px -= 1) { const local = px / Math.max(1, w - 1); const idx = Math.min(env.length - 1, Math.max(0, Math.round(startIndex + local * visibleCount))); const v = Math.min(1, Number(env[idx] || 0)); const y = mid + v * (h * 0.27); ctx.lineTo(px, y); } ctx.closePath(); } const isRunning = ["pending", "running"].includes(currentJobStatus); const fraction = Math.max(0, Math.min(1, Number(currentProgress?.fraction ?? 0))); traceWavePath(); ctx.fillStyle = "#e7e9ee"; ctx.fill(); if (isRunning) { const progressX = Math.max(0, Math.min(w, ((fraction - view.start) / Math.max(view.span, 1e-6)) * w)); if (progressX > 0) { ctx.save(); ctx.beginPath(); ctx.rect(0, 0, progressX, h); ctx.clip(); traceWavePath(); ctx.fillStyle = "rgba(109, 69, 236, .70)"; ctx.fill(); ctx.restore(); } } else { traceWavePath(); ctx.fillStyle = "rgba(36, 37, 43, .74)"; ctx.fill(); } const duration = Math.max(Number(overview.duration_sec || 0), 0.001); for (const onset of overview.onsets ?? []) { const x = timeToWaveX(onset.time_sec, overview, w); if (x < -4 || x > w + 4) continue; const selected = Number(onset.index) === Number(selectedHitIndex); const stateHit = stateHitByIndex(onset.index); const color = selected ? "#26272b" : clusterColor(stateHit?.cluster_id ?? onset.index); ctx.beginPath(); ctx.moveTo(x, h * 0.16); ctx.lineTo(x, h * 0.86); ctx.strokeStyle = selected ? "rgba(36,37,43,.9)" : `${color}99`; ctx.lineWidth = selected ? 2 : 1; ctx.stroke(); ctx.beginPath(); ctx.arc(x, h * 0.15, selected ? 5 : 3.2, 0, Math.PI * 2); ctx.fillStyle = color; ctx.fill(); } for (const hit of lastSupervisionState?.hits ?? []) { if (hit.source !== "forced") continue; const x = timeToWaveX(hit.onset_sec, overview, w); if (x < -4 || x > w + 4) continue; const selected = Number(hit.index) === Number(selectedHitIndex); ctx.beginPath(); ctx.moveTo(x, h * 0.10); ctx.lineTo(x, h * 0.90); ctx.strokeStyle = selected ? "#1f2025" : "rgba(239, 147, 67, .88)"; ctx.lineWidth = selected ? 2.4 : 1.5; ctx.setLineDash([5, 5]); ctx.stroke(); ctx.setLineDash([]); } if (waveZoom > 1.01) { ctx.fillStyle = "rgba(36,37,43,.48)"; ctx.font = "600 11px ui-sans-serif, system-ui"; ctx.textAlign = "right"; ctx.fillText(`${waveZoom.toFixed(waveZoom < 10 ? 1 : 0)}× zoom · ${fmtClock(view.start * duration)}-${fmtClock(view.end * duration)}`, w - 18, h - 16); } } function zoomWaveformAround(clientX, factor) { const overview = activeOverview(); if (!overview?.envelope?.length) return; const rect = $("waveform").getBoundingClientRect(); const pointer = Math.max(0, Math.min(1, (clientX - rect.left) / Math.max(1, rect.width))); const view = visibleWaveRange(); const anchor = view.start + pointer * view.span; waveZoom = Math.max(1, Math.min(64, waveZoom * factor)); const nextSpan = 1 / waveZoom; waveOffset = anchor - pointer * nextSpan; clampWaveView(); drawWaveform(overview); } function panWaveform(deltaRatio) { if (waveZoom <= 1) return; const view = visibleWaveRange(); waveOffset += deltaRatio * view.span; clampWaveView(); drawWaveform(activeOverview()); } function playAudio(el, url) { if (!url) return; const currentTransport = transportAudio(); if (currentTransport && !currentTransport.paused) currentTransport.pause(); el.src = url; el.currentTime = 0; const promise = el.play(); if (promise && typeof promise.catch === "function") promise.catch(() => {}); } function stateOnlyHitByIndex(index) { const stateHit = stateHitByIndex(index); if (!stateHit) return null; return { index: stateHit.index, label: stateHit.label, cluster_id: stateHit.cluster_id, cluster_label: stateHit.cluster_label, is_representative: false, onset_sec: stateHit.onset_sec, duration_ms: stateHit.duration_ms, rms_energy: stateHit.rms_energy, spectral_centroid_hz: stateHit.spectral_centroid_hz, file: stateHit.file, url: stateHit.url, }; } function selectHit(index, shouldPlay = true) { if (!lastResult && !lastSupervisionState) return; const rawHit = (lastResult?.hits ?? []).find((item) => Number(item.index) === Number(index)) ?? stateOnlyHitByIndex(index); if (!rawHit) return; const hit = decorateHit(rawHit); selectedHitIndex = hit.index; const confidence = hit.confidence === undefined ? "—" : `${Math.round(Number(hit.confidence) * 100)}%`; const flags = [hit.is_representative ? "representative" : null, hit.favorite ? "favorite" : null, hit.suppressed ? "suppressed" : null, hit.review_status !== "unreviewed" ? hit.review_status : null].filter(Boolean).join(" · "); $("selectedHitMeta").textContent = `#${hit.index} · ${hit.label} · ${hit.cluster_label} · ${hit.onset_sec}s · ${hit.duration_ms} ms · confidence ${confidence}${flags ? ` · ${flags}` : ""}`; if (shouldPlay) playAudio($("hitAudio"), hit.url); for (const row of document.querySelectorAll("[data-hit-index]")) { row.classList.toggle("selected", Number(row.dataset.hitIndex) === Number(hit.index)); } drawWaveform(lastResult.overview); setActionButtons(); } function auditionSample(sample) { $("selectedSampleMeta").textContent = `${sample.label} · ${sample.classification} · ${sample.hits} hits · score ${sample.score}`; playAudio($("sampleAudio"), sample.url); } async function drawMiniWaveform(canvas, url, color = "#8b8f9a") { const ctx = canvas.getContext("2d"); const ratio = window.devicePixelRatio || 1; const rect = canvas.getBoundingClientRect(); const w = Math.max(1, Math.floor(rect.width)); const h = Math.max(80, Math.floor(rect.height || 130)); canvas.width = w * ratio; canvas.height = h * ratio; ctx.setTransform(ratio, 0, 0, ratio, 0, 0); ctx.clearRect(0, 0, w, h); ctx.fillStyle = "#fff"; ctx.fillRect(0, 0, w, h); ctx.strokeStyle = "#e0e2e7"; ctx.beginPath(); ctx.moveTo(12, h / 2); ctx.lineTo(w - 12, h / 2); ctx.stroke(); try { if (!url) throw new Error("missing sample URL"); const response = await fetch(url); const buffer = await response.arrayBuffer(); const decoded = await getAudioContext().decodeAudioData(buffer.slice(0)); const data = decoded.getChannelData(0); const points = Math.min(w - 24, 260); ctx.beginPath(); for (let i = 0; i < points; i++) { const start = Math.floor((i / points) * data.length); const end = Math.max(start + 1, Math.floor(((i + 1) / points) * data.length)); let peak = 0; for (let j = start; j < end; j++) peak = Math.max(peak, Math.abs(data[j] || 0)); const x = 12 + (i / Math.max(1, points - 1)) * (w - 24); const y1 = h / 2 - peak * h * 0.38; const y2 = h / 2 + peak * h * 0.38; ctx.moveTo(x, y1); ctx.lineTo(x, y2); } ctx.strokeStyle = "rgba(113, 119, 132, .72)"; ctx.lineWidth = 1; ctx.stroke(); } catch { // Deterministic fallback thumbnail: still shows the transient-card affordance if WebAudio cannot decode yet. ctx.beginPath(); const points = 90; for (let i = 0; i < points; i++) { const t = i / Math.max(1, points - 1); const amp = Math.exp(-t * 6) * (0.72 + 0.25 * Math.sin(i * 1.7)); const x = 14 + t * (w - 28); ctx.moveTo(x, h / 2 - amp * h * 0.32); ctx.lineTo(x, h / 2 + amp * h * 0.32); } ctx.strokeStyle = "rgba(113, 119, 132, .58)"; ctx.stroke(); } } function sampleKey(sample) { return String(sample?.label ?? sample?.file ?? sample?.url ?? sample?.representative_hit_index ?? "sample"); } function sampleType(sample) { const raw = String(sample?.classification || sample?.label || "other").split("_")[0].toLowerCase(); if (raw.includes("kick")) return "kick"; if (raw.includes("snare") || raw.includes("clap")) return "snare"; if (raw.includes("hat") || raw.includes("hihat")) return "hihat"; if (raw.includes("cymbal") || raw.includes("crash") || raw.includes("ride")) return "cymbal"; if (raw.includes("tom")) return "tom"; if (raw.includes("perc") || raw.includes("mid") || raw.includes("bright")) return "perc"; return raw || "other"; } function visibleSamples(result) { const base = [...(result?.samples ?? []), ...extraDrawnSamples]; const merged = base.map((sample) => { const key = sampleKey(sample); return { ...sample, ...(sampleOverrides.get(key) || {}) }; }); return merged .map((sample) => ({ ...sample, _key: sampleKey(sample), _type: sampleType(sample), _edit: sampleEdits.get(sampleKey(sample)) || { startMs: 0, tailMs: 0 } })) .filter((sample) => !dismissedSampleKeys.has(sample._key)); } function ensureSelectionForSamples(samples) { if (userChangedSampleSelection) return; for (const sample of samples) { if (!sample._key) continue; if (!dismissedSampleKeys.has(sample._key) && !selectedSampleKeys.has(sample._key) && sample._autoSelected !== false) { selectedSampleKeys.add(sample._key); } } } function selectedVisibleSamples(samples = visibleSamples(lastResult || { samples: [] })) { return samples.filter((sample) => selectedSampleKeys.has(sample._key)); } function groupedSamples(samples) { const preferred = ["kick", "snare", "hihat", "cymbal", "tom", "perc", "other"]; const map = new Map(); for (const sample of samples) { const key = sample._type || "other"; if (!map.has(key)) map.set(key, []); map.get(key).push(sample); } return [...map.entries()].sort((a, b) => { const ai = preferred.indexOf(a[0]); const bi = preferred.indexOf(b[0]); return (ai === -1 ? 999 : ai) - (bi === -1 ? 999 : bi) || a[0].localeCompare(b[0]); }); } function updateSelectedExportCount(_count = null) { const visible = visibleSamples(lastResult || { samples: [] }); const selected = selectedVisibleSamples(visible); const count = selected.length; const text = `${count} Selected`; if ($("selectedCountTop")) $("selectedCountTop").textContent = `(${count})`; if ($("selectedCountBottom")) $("selectedCountBottom").textContent = text; if ($("exportSelectedButton")) $("exportSelectedButton").disabled = count === 0 || !activeJobId; if ($("exportAllButton")) $("exportAllButton").disabled = !lastResult; } function setSampleSelected(sample, selected) { userChangedSampleSelection = true; const key = sample._key || sampleKey(sample); if (selected) selectedSampleKeys.add(key); else selectedSampleKeys.delete(key); updateSelectedExportCount(); } function selectAllVisibleSamples() { userChangedSampleSelection = true; for (const sample of visibleSamples(lastResult || { samples: [] })) selectedSampleKeys.add(sample._key); renderSamples(lastResult || { samples: [] }); } function clearSampleSelection() { userChangedSampleSelection = true; selectedSampleKeys.clear(); renderSamples(lastResult || { samples: [] }); } function updateControlOutputs() { const pct = Math.round((Number($("onset_delta")?.value || 0) / 0.35) * 100); if ($("sensitivityOutput")) $("sensitivityOutput").textContent = Number.isFinite(pct) ? `${pct}%` : "Auto"; if ($("minGapOutput")) $("minGapOutput").textContent = `${Math.round(Number($("min_gap")?.value || 0) * 1000)} ms`; if ($("energyOutput")) $("energyOutput").textContent = `${Number($("energy_threshold_db")?.value || 0).toFixed(0)} dB`; if ($("targetMaxOutput")) $("targetMaxOutput").textContent = String($("target_max")?.value || "Auto"); if ($("nccOutput")) $("nccOutput").textContent = `${Math.round(Number($("ncc_threshold")?.value || 0) * 100)}%`; } async function dismissSample(sample) { const key = sample._key || sampleKey(sample); dismissedSampleKeys.add(key); selectedSampleKeys.delete(key); renderSamples(lastResult || { samples: [] }); const index = sample.representative_hit_index; if (activeJobId && index !== undefined && index !== null) { const hitId = hitIdFromIndex(index); if (hitId) { try { await jsonApi(`/api/jobs/${encodeURIComponent(activeJobId)}/hits/${encodeURIComponent(hitId)}/suppress`, { reason: "dismissed sample card" }); } catch { /* local dismissal still applies; state may not be ready during progressive results */ } } } } async function drawAnotherSample(type, sample = null) { if (!activeJobId) { showError("No active extraction", new Error("Run extraction before drawing replacement cards.")); return; } const sourceSample = sample || visibleSamples(lastResult || { samples: [] }).find((item) => item._type === type); if (!sourceSample?.label) { showError("No card to redraw", new Error(`No ${type} card exists yet. Try a higher sensitivity or force a missing onset.`)); return; } try { const payload = await jsonApi(`/api/jobs/${encodeURIComponent(activeJobId)}/samples/${encodeURIComponent(sourceSample.label)}/draw`, {}); const key = sampleKey(sourceSample); sampleOverrides.set(key, { ...payload.sample, _autoSelected: true }); selectedSampleKeys.add(key); renderSupervisionState(payload.state); renderSamples(lastResult || { samples: [] }); } catch (error) { showError("No more candidates", error, "Try adding a missing onset on the waveform or rerun with higher sensitivity."); } } function updateSampleEdit(sample, patch) { const key = sample._key || sampleKey(sample); const current = sampleEdits.get(key) || { startMs: 0, tailMs: 0 }; sampleEdits.set(key, { startMs: Math.max(-120, Math.min(250, Number(current.startMs || 0) + Number(patch.startMs || 0))), tailMs: Math.max(-250, Math.min(500, Number(current.tailMs || 0) + Number(patch.tailMs || 0))), }); renderSamples(lastResult || { samples: [] }); } async function persistSampleEdit(sample, patch) { if (!activeJobId || !sample?.label) return; const key = sample._key || sampleKey(sample); const current = sampleEdits.get(key) || { startMs: 0, tailMs: 0 }; const next = { startMs: Math.max(-120, Math.min(250, Number(current.startMs || 0) + Number(patch.startMs || 0))), tailMs: Math.max(-250, Math.min(500, Number(current.tailMs || 0) + Number(patch.tailMs || 0))), }; sampleEdits.set(key, next); const payload = await jsonApi(`/api/jobs/${encodeURIComponent(activeJobId)}/samples/${encodeURIComponent(sample.label)}/edit`, { start_offset_ms: next.startMs, tail_offset_ms: next.tailMs, }); sampleOverrides.set(key, { ...payload.sample, _autoSelected: true }); selectedSampleKeys.add(key); sampleEdits.set(key, { startMs: 0, tailMs: 0 }); renderSupervisionState(payload.state); renderSamples(lastResult || { samples: [] }); } async function saveSampleEdit(sample) { if (!activeJobId) return; const edit = sampleEdits.get(sample._key || sampleKey(sample)); if (!edit) return; await persistSampleEdit(sample, { startMs: 0, tailMs: 0 }); } function renderSamples(result) { const samples = visibleSamples(result); ensureSelectionForSamples(samples); if ($("sampleCountLabel")) $("sampleCountLabel").textContent = `(${samples.length})`; updateSelectedExportCount(); const grid = $("samplesGrid"); if (grid) { const groups = groupedSamples(samples); grid.innerHTML = groups.map(([type, rows]) => `
${esc(type)} ${rows.length}
${rows.map((sample, i) => { const absoluteIndex = samples.indexOf(sample); const color = clusterColor(sample.cluster_id ?? absoluteIndex); const edit = sample._edit || { startMs: 0, tailMs: 0 }; const editLabel = (edit.startMs || edit.tailMs) ? ` · edit ${edit.startMs >= 0 ? "+" : ""}${edit.startMs}ms/${edit.tailMs >= 0 ? "+" : ""}${edit.tailMs}ms` : ""; return `
`; }).join("")}
`).join("") || `
Drop audio anywhere.The app will upload, separate stems, tune itself, and deal sample cards automatically.
`; for (const button of grid.querySelectorAll("[data-sample-audition]")) { button.addEventListener("click", () => { selectedSampleIndex = Number(button.dataset.sampleAudition); const sample = samples[selectedSampleIndex]; auditionSample(sample); renderSamples(result); }); } for (const input of grid.querySelectorAll("[data-sample-select]")) { input.addEventListener("click", (event) => event.stopPropagation()); input.addEventListener("change", () => { const sample = samples[Number(input.dataset.sampleSelect)]; setSampleSelected(sample, input.checked); renderSamples(result); }); } for (const button of grid.querySelectorAll("[data-sample-dismiss]")) { button.addEventListener("click", (event) => { event.stopPropagation(); const sample = samples[Number(button.dataset.sampleDismiss)]; dismissSample(sample).catch(() => {}); }); } for (const button of grid.querySelectorAll("[data-draw-type]")) { button.addEventListener("click", (event) => { event.stopPropagation(); drawAnotherSample(button.dataset.drawType).catch((error) => showError("Could not draw another sample", error)); }); } for (const button of grid.querySelectorAll("[data-sample-draw]")) { button.addEventListener("click", (event) => { event.stopPropagation(); const sample = samples[Number(button.dataset.sampleDraw)]; drawAnotherSample(sample._type, sample).catch((error) => showError("Could not draw another sample", error)); }); } for (const button of grid.querySelectorAll("[data-sample-trim-start]")) { button.addEventListener("click", (event) => { event.stopPropagation(); persistSampleEdit(samples[Number(button.dataset.sampleTrimStart)], { startMs: 10 }).catch((error) => showError("Could not trim sample", error)); }); } for (const button of grid.querySelectorAll("[data-sample-extend-tail]")) { button.addEventListener("click", (event) => { event.stopPropagation(); persistSampleEdit(samples[Number(button.dataset.sampleExtendTail)], { tailMs: 20 }).catch((error) => showError("Could not extend sample", error)); }); } for (const button of grid.querySelectorAll("[data-sample-save-edit]")) { button.addEventListener("click", (event) => { event.stopPropagation(); saveSampleEdit(samples[Number(button.dataset.sampleSaveEdit)]).catch((error) => showError("Could not save clip edit", error)); }); } for (const canvas of grid.querySelectorAll(".sample-wave")) { drawMiniWaveform(canvas, canvas.dataset.waveUrl, canvas.dataset.waveColor); } } const tbody = $("samplesTable").querySelector("tbody"); tbody.innerHTML = samples.map((sample, i) => ` ${esc(sample.label)} ${esc(sample.classification)} ${esc(sample.hits)} ${esc(sample.score)} ${esc(sample.duration_ms)} ms ${esc(sample.first_onset_sec)} s WAV `).join(""); for (const button of tbody.querySelectorAll("[data-sample-audition]")) { button.addEventListener("click", (event) => { event.stopPropagation(); selectedSampleIndex = Number(button.dataset.sampleAudition); const sample = samples[selectedSampleIndex]; auditionSample(sample); renderSamples(result); }); } } function renderHits(result) { const tbody = $("hitsTable").querySelector("tbody"); const baseHits = (result.hits ?? []).map(decorateHit); const seen = new Set(baseHits.map((hit) => Number(hit.index))); const stateOnlyHits = (lastSupervisionState?.hits ?? []) .filter((hit) => !seen.has(Number(hit.index))) .map((hit) => decorateHit(stateOnlyHitByIndex(hit.index))); const hits = [...baseHits, ...stateOnlyHits].sort((a, b) => Number(a.index) - Number(b.index)); tbody.innerHTML = hits.map((hit) => { const confidence = hit.confidence === undefined ? "—" : `${Math.round(Number(hit.confidence) * 100)}%`; const flags = [hit.is_representative ? "rep" : null, hit.favorite ? "fav" : null, hit.suppressed ? "suppressed" : null, hit.review_status !== "unreviewed" ? hit.review_status : null].filter(Boolean); const classes = [Number(hit.index) === Number(selectedHitIndex) ? "selected" : "", hit.suppressed ? "suppressed" : "", Number(hit.confidence ?? 1) < 0.55 ? "low-confidence" : ""].filter(Boolean).join(" "); return ` ${esc(hit.index)} ${esc(hit.label)}${hit.is_representative || hit.favorite ? " ★" : ""} ${esc(hit.cluster_label)} ${esc(confidence)} ${esc(flags.join(", ") || "—")} ${esc(hit.onset_sec)} s ${esc(hit.duration_ms)} ms ${esc(hit.rms_energy)} WAV `; }).join(""); for (const row of tbody.querySelectorAll("[data-hit-index]")) { row.addEventListener("click", () => selectHit(row.dataset.hitIndex)); } for (const button of tbody.querySelectorAll("[data-hit-audition]")) { button.addEventListener("click", (event) => { event.stopPropagation(); selectHit(button.dataset.hitAudition); }); } if (hits.length && selectedHitIndex === null) selectHit(hits[0].index, false); } function renderSupervisionState(state) { lastSupervisionState = state; const summary = state.summary ?? {}; $("supervisionSummary").innerHTML = ` ${esc(summary.hit_count ?? 0)} hits ${esc(summary.cluster_count ?? 0)} clusters ${esc(summary.constraint_count ?? 0)} constraints ${esc(summary.open_suggestion_count ?? 0)} suggestions ${esc(summary.suppressed_hit_count ?? 0)} suppressed ${esc(summary.forced_hit_count ?? 0)} forced ${esc(summary.locked_cluster_count ?? 0)} locked ${summary.latest_export ? `latest edited export · ${esc(summary.latest_export.hit_count)} hits / ${esc(summary.latest_export.cluster_count)} clusters` : ""} `; if (summary.latest_export?.url) { $("editedDownloads").innerHTML = `Edited export manifest`; } const currentTarget = $("targetClusterSelect").value; $("targetClusterSelect").innerHTML = (state.clusters ?? []).map((cluster) => ` `).join(""); if ((state.clusters ?? []).some((cluster) => cluster.id === currentTarget)) $("targetClusterSelect").value = currentTarget; $("reviewQueue").innerHTML = (state.review_queue ?? []).slice(0, 14).map((item) => ` `).join("") || `

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 `
${esc(suggestion.type)}${esc(suggestion.reason)} · ${Math.round(Number(suggestion.confidence ?? 0) * 100)}% · ${esc(diffText)}${hitPreview ? ` · ${esc(hitPreview)}` : ""}
`; }).join("") || `

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) => `
${esc(event.type)}${esc(event.source)} · ${fmtDate(event.created_at)}
`); const constraintRows = (state.constraints ?? []).slice(-8).reverse().map((constraint) => `
${esc(constraint.type)}${esc(constraint.source)} · ${fmtDate(constraint.created_at)}
`); $("stateLog").innerHTML = [...eventRows, ...constraintRows].join("") || `

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();