Spaces:
Sleeping
Sleeping
| 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 ` | |
| <div class="stage ${esc(stage.status)}" title="${esc(stage.detail || "")}"> | |
| <span class="badge"></span> | |
| <div><strong>${esc(stage.label)}</strong><small>${esc(stage.detail || stage.status)}${stage.work_total ? ` · ${esc(stage.work_done)}/${esc(stage.work_total)}` : ""}</small></div> | |
| <time>${fmtSec(stage.duration_sec)}</time> | |
| <div class="stage-progress"><span style="width:${pct}%"></span></div> | |
| </div> | |
| `; | |
| }).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]) => ` | |
| <section class="sample-column" data-sample-type="${esc(type)}" style="--column-color: ${esc(clusterColor(rows[0]?.cluster_id ?? type.length))}"> | |
| <header class="sample-column-head"> | |
| <strong>${esc(type)}</strong> | |
| <span>${rows.length}</span> | |
| <button class="draw-card-button" type="button" data-draw-type="${esc(type)}" title="Draw another candidate when this type is missing">Draw</button> | |
| </header> | |
| <div class="sample-column-list"> | |
| ${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 ` | |
| <article class="sample-card ${absoluteIndex === selectedSampleIndex ? "selected" : ""} ${selectedSampleKeys.has(sample._key) ? "checked" : ""}" style="--card-color: ${esc(color)}" data-sample-card="${absoluteIndex}"> | |
| <label class="sample-select" title="Include in Export Selected"><input type="checkbox" data-sample-select="${absoluteIndex}" ${selectedSampleKeys.has(sample._key) ? "checked" : ""} /> <span></span></label> | |
| <button class="sample-play-zone" type="button" data-sample-audition="${absoluteIndex}"> | |
| <canvas class="sample-wave" data-wave-url="${esc(sample.url)}" data-wave-color="${esc(color)}"></canvas> | |
| <span class="sample-card-footer"> | |
| <span class="play-dot">▶</span> | |
| <span><strong class="sample-name">${esc(sample.label || `Sample ${absoluteIndex + 1}`)}</strong><small class="sample-meta">${esc(sample.hits)} hits · ${esc(sample.duration_ms)} ms${esc(editLabel)}</small></span> | |
| </span> | |
| </button> | |
| <div class="sample-card-actions"> | |
| <button type="button" data-sample-dismiss="${absoluteIndex}" title="Dismiss">Dismiss</button> | |
| <button type="button" data-sample-draw="${absoluteIndex}" title="Draw another">Draw</button> | |
| <button type="button" data-sample-trim-start="${absoluteIndex}" title="Trim start">Trim start</button> | |
| <button type="button" data-sample-extend-tail="${absoluteIndex}" title="Extend tail">Extend tail</button> | |
| <button type="button" data-sample-save-edit="${absoluteIndex}" title="Save timing edit" ${edit.startMs || edit.tailMs ? "" : "disabled"}>Save edit</button> | |
| </div> | |
| </article> | |
| `; | |
| }).join("")} | |
| </div> | |
| </section> | |
| `).join("") || `<div class="empty-drop-state"><strong>Drop audio anywhere.</strong><span>The app will upload, separate stems, tune itself, and deal sample cards automatically.</span></div>`; | |
| 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) => ` | |
| <tr data-sample-index="${i}"> | |
| <td><button class="mini-button" type="button" data-sample-audition="${i}">Audition</button></td> | |
| <td>${esc(sample.label)}</td> | |
| <td>${esc(sample.classification)}</td> | |
| <td>${esc(sample.hits)}</td> | |
| <td>${esc(sample.score)}</td> | |
| <td>${esc(sample.duration_ms)} ms</td> | |
| <td>${esc(sample.first_onset_sec)} s</td> | |
| <td><a href="${esc(sample.url)}" download>WAV</a></td> | |
| </tr> | |
| `).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 ` | |
| <tr data-hit-index="${esc(hit.index)}" class="${esc(classes)}"> | |
| <td><button class="mini-button" type="button" data-hit-audition="${esc(hit.index)}">Audition</button></td> | |
| <td>${esc(hit.index)}</td> | |
| <td>${esc(hit.label)}${hit.is_representative || hit.favorite ? " ★" : ""}</td> | |
| <td>${esc(hit.cluster_label)}</td> | |
| <td>${esc(confidence)}</td> | |
| <td>${esc(flags.join(", ") || "—")}</td> | |
| <td>${esc(hit.onset_sec)} s</td> | |
| <td>${esc(hit.duration_ms)} ms</td> | |
| <td>${esc(hit.rms_energy)}</td> | |
| <td><a href="${esc(hit.url)}" download>WAV</a></td> | |
| </tr> | |
| `; | |
| }).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 = ` | |
| <span>${esc(summary.hit_count ?? 0)} hits</span> | |
| <span>${esc(summary.cluster_count ?? 0)} clusters</span> | |
| <span>${esc(summary.constraint_count ?? 0)} constraints</span> | |
| <span>${esc(summary.open_suggestion_count ?? 0)} suggestions</span> | |
| <span>${esc(summary.suppressed_hit_count ?? 0)} suppressed</span> | |
| <span>${esc(summary.forced_hit_count ?? 0)} forced</span> | |
| <span>${esc(summary.locked_cluster_count ?? 0)} locked</span> | |
| ${summary.latest_export ? `<span>latest edited export · ${esc(summary.latest_export.hit_count)} hits / ${esc(summary.latest_export.cluster_count)} clusters</span>` : ""} | |
| `; | |
| if (summary.latest_export?.url) { | |
| $("editedDownloads").innerHTML = `<a href="${esc(summary.latest_export.url)}" download>Edited export manifest</a>`; | |
| } | |
| const currentTarget = $("targetClusterSelect").value; | |
| $("targetClusterSelect").innerHTML = (state.clusters ?? []).map((cluster) => ` | |
| <option value="${esc(cluster.id)}">${cluster.locked ? "locked · " : ""}${esc(cluster.label)} · ${esc(cluster.hit_ids?.length ?? 0)} hits · ${Math.round(Number(cluster.confidence ?? 0) * 100)}%</option> | |
| `).join(""); | |
| if ((state.clusters ?? []).some((cluster) => cluster.id === currentTarget)) $("targetClusterSelect").value = currentTarget; | |
| $("reviewQueue").innerHTML = (state.review_queue ?? []).slice(0, 14).map((item) => ` | |
| <button class="compact-row ${item.suppressed ? "suppressed" : ""}" type="button" data-review-hit="${esc(item.hit_index)}"> | |
| <span><strong>#${esc(item.hit_index)} · ${esc(item.label)}</strong><small>${esc(item.cluster_label)} · ${Math.round(Number(item.confidence ?? 0) * 100)}% · ${esc((item.reasons ?? []).join(", "))}</small></span> | |
| <span>${Math.round(Number(item.priority ?? 0) * 100)}</span> | |
| </button> | |
| `).join("") || `<p class="empty">No review items.</p>`; | |
| for (const button of $("reviewQueue").querySelectorAll("[data-review-hit]")) { | |
| button.addEventListener("click", () => selectHit(button.dataset.reviewHit)); | |
| } | |
| $("clusterBoard").innerHTML = (state.clusters ?? []).map((cluster) => ` | |
| <button class="compact-row ${cluster.locked ? "locked" : ""}" type="button" data-cluster-select="${esc(cluster.id)}"> | |
| <span><strong>${cluster.locked ? "Locked · " : ""}${esc(cluster.label)}</strong><small>${esc(cluster.hit_ids?.length ?? 0)} hits · ${Math.round(Number(cluster.confidence ?? 0) * 100)}% · ${esc((cluster.confidence_reasons ?? []).join(", "))}</small></span> | |
| <span>${esc(cluster.suppressed_count ?? 0)} suppr.</span> | |
| </button> | |
| `).join("") || `<p class="empty">No clusters.</p>`; | |
| 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 ` | |
| <div class="suggestion-row"> | |
| <div><strong>${esc(suggestion.type)}</strong><small>${esc(suggestion.reason)} · ${Math.round(Number(suggestion.confidence ?? 0) * 100)}% · ${esc(diffText)}${hitPreview ? ` · ${esc(hitPreview)}` : ""}</small></div> | |
| <div class="row-actions"> | |
| <button class="mini-button" type="button" data-preview-suggestion="${esc(suggestion.id)}">Diff</button> | |
| <button class="mini-button" type="button" data-accept-suggestion="${esc(suggestion.id)}">Accept</button> | |
| <button class="mini-button" type="button" data-reject-suggestion="${esc(suggestion.id)}">Reject</button> | |
| </div> | |
| </div> | |
| `; | |
| }).join("") || `<p class="empty">No open suggestions.</p>`; | |
| 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) => `<div class="log-row"><strong>${esc(event.type)}</strong><small>${esc(event.source)} · ${fmtDate(event.created_at)}</small></div>`); | |
| const constraintRows = (state.constraints ?? []).slice(-8).reverse().map((constraint) => `<div class="log-row constraint"><strong>${esc(constraint.type)}</strong><small>${esc(constraint.source)} · ${fmtDate(constraint.created_at)}</small></div>`); | |
| $("stateLog").innerHTML = [...eventRows, ...constraintRows].join("") || `<p class="empty">No state events yet.</p>`; | |
| 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]) => `<a href="${esc(url)}" download>${esc(labels[key] ?? key)}</a>`) | |
| .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) => `<a href="${esc(fileUrls[key])}" download>${esc(labels[key] ?? key)}</a>`) | |
| .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 = `<p class="empty">No completed runs yet.</p>`; | |
| return; | |
| } | |
| $("historyList").innerHTML = rows.map((row) => ` | |
| <button class="history-row" type="button" data-job-id="${esc(row.id)}"> | |
| <span><strong>${esc(row.filename || row.id)}</strong><small>${esc(row.stem || "—")} · ${esc(row.clustering_mode || "—")} · ${fmtDate(row.created_at)}</small></span> | |
| <span>${esc(row.hit_count ?? "…")} hits</span> | |
| <span>${esc(row.cluster_count ?? "…")} samples</span> | |
| <span>${row.realtime_factor == null ? "—" : `${Number(row.realtime_factor).toFixed(2)}×`}</span> | |
| </button> | |
| `).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 = `<p class="empty">${esc(error.message)}</p>`; | |
| } | |
| } | |
| 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 = `<p class="empty">Samples appear here after extraction.</p>`; | |
| $("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(); | |