ChatGPT
fix: make upload and fallback robust
5a90820
Raw
History Blame Contribute Delete
77.3 kB
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) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", "'": "&#39;", '"': "&quot;" }[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();