ChatGPT commited on
Commit
f08496f
·
1 Parent(s): e07820e

feat: simplify web ui around waveform workflow

Browse files
Files changed (9) hide show
  1. README.md +2 -1
  2. docs/FEATURES.md +12 -0
  3. docs/PROGRESS.md +23 -0
  4. docs/REMAINING_WORK.md +4 -0
  5. docs/TASKS.md +6 -0
  6. docs/UI_REPLACEMENT.md +21 -5
  7. web/app.js +175 -24
  8. web/index.html +206 -196
  9. web/styles.css +642 -102
README.md CHANGED
@@ -39,6 +39,7 @@ Implemented:
39
  - event log,
40
  - suggestions,
41
  - undo stack.
 
42
  - Supervision UI:
43
  - selected-hit actions,
44
  - move hit to cluster,
@@ -153,7 +154,7 @@ curl http://127.0.0.1:7860/api/jobs
153
  | `sample_extractor.py` | Core DSP/sample extraction implementation |
154
  | `supervised_state.py` | Persistent semantic state, confidence, constraints, events, suggestions, force-onset, restore, undo |
155
  | `supervised_export.py` | Renders edited semantic state into supervised WAV/MIDI/reconstruction/ZIP artifacts |
156
- | `web/` | Custom no-build browser frontend with waveform, hit review, sample audition, add-onset mode, edited export, and supervision panel |
157
  | `scripts/benchmark_subprocesses.py` | Synthetic benchmark runner for stage timings |
158
  | `scripts/test_interactive_supervision.py` | Smoke test for supervised state endpoints |
159
  | `scripts/test_supervised_export_and_force_onset.py` | Smoke test for force-onset, restore, suggestion diffs, and edited exports |
 
39
  - event log,
40
  - suggestions,
41
  - undo stack.
42
+ - Minimal waveform-first UI inspired by dedicated sample-extractor hardware/software: compact top bar, large waveform workspace, right-side core controls, sample-card grid, and collapsible advanced panels.
43
  - Supervision UI:
44
  - selected-hit actions,
45
  - move hit to cluster,
 
154
  | `sample_extractor.py` | Core DSP/sample extraction implementation |
155
  | `supervised_state.py` | Persistent semantic state, confidence, constraints, events, suggestions, force-onset, restore, undo |
156
  | `supervised_export.py` | Renders edited semantic state into supervised WAV/MIDI/reconstruction/ZIP artifacts |
157
+ | `web/` | Custom no-build browser frontend with the light waveform-first UI, sample-card grid, hit review, add-onset mode, edited export, and supervision panel |
158
  | `scripts/benchmark_subprocesses.py` | Synthetic benchmark runner for stage timings |
159
  | `scripts/test_interactive_supervision.py` | Smoke test for supervised state endpoints |
160
  | `scripts/test_supervised_export_and_force_onset.py` | Smoke test for force-onset, restore, suggestion diffs, and edited exports |
docs/FEATURES.md CHANGED
@@ -86,3 +86,15 @@ Turn an input audio file into a practical drum sample pack: detected hits, group
86
  | Supervision | Cluster explanation | Implemented | Backend and UI show confidence reasons, label distribution, outliers, and constraints. |
87
  | Supervision | Edited artifact re-export | Implemented | Exports edited state into `supervised/` without mutating original batch artifacts. |
88
  | Supervision | Force-onset from waveform | Implemented | Add-onset mode turns waveform clicks into forced hit slices from `stem.wav`. |
 
 
 
 
 
 
 
 
 
 
 
 
 
86
  | Supervision | Cluster explanation | Implemented | Backend and UI show confidence reasons, label distribution, outliers, and constraints. |
87
  | Supervision | Edited artifact re-export | Implemented | Exports edited state into `supervised/` without mutating original batch artifacts. |
88
  | Supervision | Force-onset from waveform | Implemented | Add-onset mode turns waveform clicks into forced hit slices from `stem.wav`. |
89
+
90
+
91
+ ## Visual/UI experience
92
+
93
+ Status: implemented.
94
+
95
+ - Light, minimal, waveform-first interface matching the supplied reference direction.
96
+ - Compact top bar with file selector, backend status, and primary extract action.
97
+ - Main waveform card with colored onset markers and click-to-select / force-onset behavior.
98
+ - Right-side core-control card for stem, sensitivity, cluster count, edited export, and fast modes.
99
+ - Collapsible advanced controls keep detailed DSP/model parameters available without cluttering the main flow.
100
+ - Extracted samples render as auditionable cards with waveform thumbnails; raw tables remain available in a collapsible detail panel.
docs/PROGRESS.md CHANGED
@@ -187,3 +187,26 @@ Next recommended pass after Pass 5:
187
  2. Add cached feature-vector local reclustering around edited hits.
188
  3. Add edited-vs-original run comparison.
189
  4. Add browser-level UI tests and migrate the frontend to TypeScript/Vite once the UX stops shifting.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
187
  2. Add cached feature-vector local reclustering around edited hits.
188
  3. Add edited-vs-original run comparison.
189
  4. Add browser-level UI tests and migrate the frontend to TypeScript/Vite once the UX stops shifting.
190
+
191
+
192
+ ## Pass 6: visual simplification toward the supplied reference UI
193
+
194
+ Completed in this pass:
195
+
196
+ 1. Reworked `web/index.html` around a simpler first screen: top file/action bar, large waveform workspace, compact right-side extraction controls, sample cards, and lower utility panels.
197
+ 2. Replaced the previous dark dashboard styling with a light, minimal, card-based visual system closer to the supplied screenshot.
198
+ 3. Moved advanced extraction controls into a collapsible panel so the primary workflow exposes only stem, sensitivity, cluster count, extraction, export, and fast-mode decisions.
199
+ 4. Added representative sample cards with waveform thumbnails decoded from the sample WAV URLs in the browser.
200
+ 5. Updated waveform rendering to use grey filled waveforms plus colored lollipop onset markers similar to the reference image.
201
+ 6. Preserved all existing IDs/API wiring so supervised editing, SSE progress, run history, force-onset, and edited export continue to work.
202
+
203
+ Validation performed in this pass:
204
+
205
+ - `python3 -m py_compile app.py pipeline_runner.py sample_extractor.py supervised_state.py supervised_export.py scripts/*.py`
206
+ - `node --check web/app.js`
207
+ - HTML parser sanity check for `web/index.html`
208
+ - `python3 scripts/test_api_job.py`
209
+
210
+ Outcome:
211
+
212
+ The app now looks and behaves more like a focused sample-extraction tool instead of a generic control dashboard while keeping the advanced review/supervision functionality available below the main workflow.
docs/REMAINING_WORK.md CHANGED
@@ -72,3 +72,7 @@ Highest-priority remaining work now:
72
  3. Edited-vs-original comparison view.
73
  4. Batch restore / bulk operations for suppressed hits.
74
  5. Browser-level UI tests and TypeScript/Vite hardening.
 
 
 
 
 
72
  3. Edited-vs-original comparison view.
73
  4. Batch restore / bulk operations for suppressed hits.
74
  5. Browser-level UI tests and TypeScript/Vite hardening.
75
+
76
+ ## UI status note
77
+
78
+ The minimal waveform-first restyle is complete. Remaining UI work is now interaction depth and engineering hardening, not matching the supplied visual direction: browser tests, TypeScript/Vite migration, edited-vs-original comparison, and richer cluster merge/split/relabel workflows.
docs/TASKS.md CHANGED
@@ -82,6 +82,12 @@ Last updated: 2026-05-12
82
  | Add exact suggestion diff previews | Done | Suggestions expose `diff`; UI has `Diff` preview. |
83
  | Add true local feature-neighborhood reclustering | Todo | Requires cached feature vectors and constraint-aware assignment. |
84
 
 
 
 
 
 
 
85
  ## Latest validation tasks
86
 
87
  - [x] `python3 -m py_compile app.py pipeline_runner.py sample_extractor.py supervised_state.py scripts/*.py`
 
82
  | Add exact suggestion diff previews | Done | Suggestions expose `diff`; UI has `Diff` preview. |
83
  | Add true local feature-neighborhood reclustering | Todo | Requires cached feature vectors and constraint-aware assignment. |
84
 
85
+ ## Visual simplification tasks
86
+
87
+ | Task | Status | Notes |
88
+ |---|---|---|
89
+ | Restyle web UI to supplied minimal waveform-first reference | Done | Light theme, top-bar primary action, large waveform, compact right controls, sample-card grid, collapsible advanced/power panels. |
90
+
91
  ## Latest validation tasks
92
 
93
  - [x] `python3 -m py_compile app.py pipeline_runner.py sample_extractor.py supervised_state.py scripts/*.py`
docs/UI_REPLACEMENT.md CHANGED
@@ -15,6 +15,21 @@ The active interface is a custom browser UI served from `web/` by the FastAPI ap
15
  5. Make `online_preview` obvious as the near-realtime clustering path.
16
  6. Keep the frontend deployable without a JavaScript build step until the interaction model stabilizes.
17
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
18
  ## UI structure
19
 
20
  | Area | Purpose |
@@ -24,7 +39,10 @@ The active interface is a custom browser UI served from `web/` by the FastAPI ap
24
  | Controls panel | Stem, onset, clustering, MIDI, synthesis, and disk-cache parameters. |
25
  | Pipeline panel | Stage statuses, durations, and logs. |
26
  | Run history panel | Loads completed manifests from `.runs/`. |
27
- | Result panel | Summary, waveform/onsets, downloads, stem/reconstruction audio, sample table. |
 
 
 
28
 
29
  ## Frontend implementation
30
 
@@ -65,11 +83,9 @@ Two modes are exposed:
65
  | `batch_quality` | Slower, final-quality clustering using all-pairs similarity plus agglomerative clustering. |
66
  | `online_preview` | Faster near-realtime-style clustering using prototype assignment. Best for quick iteration after bypassing Demucs. |
67
 
68
- ## Why SSE progress with polling fallback instead of websockets/SSE
69
-
70
- Polling is the simplest robust option here because the current pipeline is CPU-heavy and mostly stage-based. The UI polls every 800 ms, which is enough to show stage transitions and logs without introducing websocket lifecycle complexity.
71
 
72
- Future improvement: use Server-Sent Events for lower-latency log streaming once the backend has a persistent job store.
73
 
74
  ## Remaining UI improvements
75
 
 
15
  5. Make `online_preview` obvious as the near-realtime clustering path.
16
  6. Keep the frontend deployable without a JavaScript build step until the interaction model stabilizes.
17
 
18
+
19
+ ## Pass 6 visual simplification
20
+
21
+ The UI has been restyled to match the supplied minimal reference direction:
22
+
23
+ - light native-feeling canvas instead of the previous dark dashboard;
24
+ - compact top bar with file selector/status and one primary purple `Extract Samples` action;
25
+ - large waveform-first workspace with colored lollipop onset markers;
26
+ - right-side control card for the primary extraction decisions: stem, sensitivity, cluster count, edited export, fast modes;
27
+ - advanced DSP/model parameters moved into a collapsible panel;
28
+ - representative samples rendered as auditionable cards with waveform thumbnails instead of forcing the table to be the primary view;
29
+ - pipeline, run history, supervision, and raw tables kept available as collapsible utility panels so power features remain reachable without dominating the first screen.
30
+
31
+ This pass intentionally keeps the no-build frontend architecture. The goal was to simplify the product surface without destabilizing the backend/API or the supervised-editing flow.
32
+
33
  ## UI structure
34
 
35
  | Area | Purpose |
 
39
  | Controls panel | Stem, onset, clustering, MIDI, synthesis, and disk-cache parameters. |
40
  | Pipeline panel | Stage statuses, durations, and logs. |
41
  | Run history panel | Loads completed manifests from `.runs/`. |
42
+ | Waveform workspace | Summary, waveform/onsets, stem/reconstruction audio, and downloads. |
43
+ | Core control card | Stem, sensitivity, cluster count, fast modes, edited export, and collapsible advanced settings. |
44
+ | Sample card grid | Primary representative-sample browsing and audition surface. |
45
+ | Utility panels | Pipeline, run history, supervision, and raw detail tables. |
46
 
47
  ## Frontend implementation
48
 
 
83
  | `batch_quality` | Slower, final-quality clustering using all-pairs similarity plus agglomerative clustering. |
84
  | `online_preview` | Faster near-realtime-style clustering using prototype assignment. Best for quick iteration after bypassing Demucs. |
85
 
86
+ ## Why SSE progress with polling fallback instead of websockets
 
 
87
 
88
+ The active UI uses Server-Sent Events through `GET /api/jobs/{job_id}/events` for stage/log updates, with the older polling loop retained as a fallback. WebSockets are unnecessary here because the pipeline is stage-oriented and the frontend does not need bidirectional streaming while extraction runs.
89
 
90
  ## Remaining UI improvements
91
 
web/app.js CHANGED
@@ -15,7 +15,21 @@ let lastResult = null;
15
  let lastSupervisionState = null;
16
  let activeJobId = null;
17
  let selectedHitIndex = null;
 
18
  let forceOnsetMode = false;
 
 
 
 
 
 
 
 
 
 
 
 
 
19
 
20
  function esc(value) {
21
  return String(value ?? "").replace(/[&<>'"]/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", "'": "&#39;", '"': "&quot;" }[c]));
@@ -146,7 +160,7 @@ function collectParams() {
146
  const el = $(field);
147
  if (!el) continue;
148
  if (el.type === "checkbox") params[field] = el.checked;
149
- else if (el.type === "number") params[field] = Number(el.value);
150
  else params[field] = el.value;
151
  }
152
  return params;
@@ -168,56 +182,87 @@ function drawWaveform(overview) {
168
  const ctx = canvas.getContext("2d");
169
  const ratio = window.devicePixelRatio || 1;
170
  const rect = canvas.getBoundingClientRect();
 
171
  canvas.width = Math.max(1, Math.floor(rect.width * ratio));
172
- canvas.height = Math.max(160, Math.floor(160 * ratio));
173
- ctx.scale(ratio, ratio);
174
  const w = rect.width;
175
- const h = 160;
176
  ctx.clearRect(0, 0, w, h);
177
- ctx.fillStyle = "rgba(139,211,255,.045)";
 
 
 
 
178
  ctx.fillRect(0, 0, w, h);
 
179
  const env = overview?.envelope ?? [];
180
- if (!env.length) return;
181
- ctx.strokeStyle = "rgba(139,211,255,.92)";
182
- ctx.lineWidth = 1.4;
 
 
 
 
 
 
 
 
 
 
 
 
183
  ctx.beginPath();
184
- const mid = h / 2;
185
  env.forEach((v, i) => {
186
  const x = (i / Math.max(1, env.length - 1)) * w;
187
- const y = mid - Math.min(1, v) * (h * 0.42);
188
  if (i === 0) ctx.moveTo(x, y); else ctx.lineTo(x, y);
189
  });
190
  for (let i = env.length - 1; i >= 0; i--) {
191
  const v = env[i];
192
  const x = (i / Math.max(1, env.length - 1)) * w;
193
- const y = mid + Math.min(1, v) * (h * 0.42);
194
  ctx.lineTo(x, y);
195
  }
196
  ctx.closePath();
197
- ctx.fillStyle = "rgba(139,211,255,.28)";
198
  ctx.fill();
 
199
  ctx.stroke();
200
 
201
  for (const onset of overview.onsets ?? []) {
202
  const x = (onset.time_sec / Math.max(overview.duration_sec, 0.001)) * w;
203
  const selected = Number(onset.index) === Number(selectedHitIndex);
204
- ctx.strokeStyle = selected ? "rgba(255,255,255,.95)" : "rgba(200,165,255,.55)";
205
- ctx.lineWidth = selected ? 2.4 : 1;
 
 
 
206
  ctx.beginPath();
207
- ctx.moveTo(x, selected ? 3 : 10);
208
- ctx.lineTo(x, selected ? h - 3 : h - 10);
209
  ctx.stroke();
 
 
 
 
 
210
  }
 
211
  for (const hit of lastSupervisionState?.hits ?? []) {
212
  if (hit.source !== "forced") continue;
213
  const x = (Number(hit.onset_sec) / Math.max(overview.duration_sec, 0.001)) * w;
214
  const selected = Number(hit.index) === Number(selectedHitIndex);
215
- ctx.strokeStyle = selected ? "rgba(255,255,255,.98)" : "rgba(85,230,165,.9)";
216
- ctx.lineWidth = selected ? 2.8 : 1.6;
217
  ctx.beginPath();
218
- ctx.moveTo(x, 2);
219
- ctx.lineTo(x, h - 2);
220
  ctx.stroke();
 
 
 
 
221
  }
222
  }
223
 
@@ -269,9 +314,95 @@ function auditionSample(sample) {
269
  playAudio($("sampleAudio"), sample.url);
270
  }
271
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
272
  function renderSamples(result) {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
273
  const tbody = $("samplesTable").querySelector("tbody");
274
- tbody.innerHTML = (result.samples ?? []).map((sample, i) => `
275
  <tr data-sample-index="${i}">
276
  <td><button class="mini-button" type="button" data-sample-audition="${i}">Audition</button></td>
277
  <td>${esc(sample.label)}</td>
@@ -286,8 +417,10 @@ function renderSamples(result) {
286
  for (const button of tbody.querySelectorAll("[data-sample-audition]")) {
287
  button.addEventListener("click", (event) => {
288
  event.stopPropagation();
289
- const sample = result.samples[Number(button.dataset.sampleAudition)];
 
290
  auditionSample(sample);
 
291
  });
292
  }
293
  }
@@ -529,6 +662,7 @@ function renderResult(job) {
529
  if (!result) return;
530
  activeJobId = job.id;
531
  lastResult = result;
 
532
  if (!(result.hits ?? []).some((hit) => Number(hit.index) === Number(selectedHitIndex))) {
533
  selectedHitIndex = (result.hits ?? [])[0]?.index ?? null;
534
  }
@@ -669,9 +803,12 @@ async function runExtraction() {
669
 
670
  function setFile(file) {
671
  selectedFile = file;
672
- $("dropTitle").textContent = file ? file.name : "Drop audio here or click to browse";
673
- $("dropMeta").textContent = file ? `${(file.size / 1024 / 1024).toFixed(2)} MB · ${file.type || "audio"}` : "No file selected";
674
  $("runButton").disabled = !file;
 
 
 
675
  if (file) {
676
  $("sourcePreview").hidden = false;
677
  $("sourcePreview").src = URL.createObjectURL(file);
@@ -703,6 +840,7 @@ async function boot() {
703
  config = await api("/api/config");
704
  populateConfig();
705
  await refreshHistory();
 
706
  setHealth(true, "Ready", "Backend online");
707
  } catch (error) {
708
  setHealth(false, "Offline", error.message);
@@ -727,6 +865,19 @@ $("usePreviewButton").addEventListener("click", () => {
727
  $("mel_threshold").value = 0.62;
728
  $("ncc_threshold").value = 0.72;
729
  });
 
 
 
 
 
 
 
 
 
 
 
 
 
730
  $("refreshHistoryButton").addEventListener("click", refreshHistory);
731
  $("clearCacheButton").addEventListener("click", async () => {
732
  try {
 
15
  let lastSupervisionState = null;
16
  let activeJobId = null;
17
  let selectedHitIndex = null;
18
+ let selectedSampleIndex = null;
19
  let forceOnsetMode = false;
20
+ let audioContext = null;
21
+
22
+ const palette = ["#9b72ef", "#4f7df2", "#42b8b4", "#ef9343", "#ea5ca9", "#6d9be8", "#8abc59", "#805fe6"];
23
+
24
+ function clusterColor(index) {
25
+ const numeric = Number(String(index ?? 0).replace(/[^0-9-]/g, ""));
26
+ return palette[Math.abs(Number.isFinite(numeric) ? numeric : 0) % palette.length];
27
+ }
28
+
29
+ function getAudioContext() {
30
+ if (!audioContext) audioContext = new (window.AudioContext || window.webkitAudioContext)();
31
+ return audioContext;
32
+ }
33
 
34
  function esc(value) {
35
  return String(value ?? "").replace(/[&<>'"]/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", "'": "&#39;", '"': "&quot;" }[c]));
 
160
  const el = $(field);
161
  if (!el) continue;
162
  if (el.type === "checkbox") params[field] = el.checked;
163
+ else if (["number", "range"].includes(el.type)) params[field] = Number(el.value);
164
  else params[field] = el.value;
165
  }
166
  return params;
 
182
  const ctx = canvas.getContext("2d");
183
  const ratio = window.devicePixelRatio || 1;
184
  const rect = canvas.getBoundingClientRect();
185
+ const cssHeight = Math.max(260, Math.floor(rect.height || 360));
186
  canvas.width = Math.max(1, Math.floor(rect.width * ratio));
187
+ canvas.height = Math.max(cssHeight, Math.floor(cssHeight * ratio));
188
+ ctx.setTransform(ratio, 0, 0, ratio, 0, 0);
189
  const w = rect.width;
190
+ const h = cssHeight;
191
  ctx.clearRect(0, 0, w, h);
192
+
193
+ const gradient = ctx.createLinearGradient(0, 0, 0, h);
194
+ gradient.addColorStop(0, "#ffffff");
195
+ gradient.addColorStop(1, "#fbfbfc");
196
+ ctx.fillStyle = gradient;
197
  ctx.fillRect(0, 0, w, h);
198
+
199
  const env = overview?.envelope ?? [];
200
+ const mid = Math.round(h * 0.56);
201
+ ctx.strokeStyle = "#d9dbe2";
202
+ ctx.lineWidth = 1;
203
+ ctx.beginPath();
204
+ ctx.moveTo(0, mid);
205
+ ctx.lineTo(w, mid);
206
+ ctx.stroke();
207
+
208
+ if (!env.length) {
209
+ ctx.fillStyle = "#9ca0aa";
210
+ ctx.font = "14px system-ui, -apple-system, BlinkMacSystemFont, sans-serif";
211
+ ctx.fillText("Waveform and onset markers appear after extraction.", 32, mid - 16);
212
+ return;
213
+ }
214
+
215
  ctx.beginPath();
 
216
  env.forEach((v, i) => {
217
  const x = (i / Math.max(1, env.length - 1)) * w;
218
+ const y = mid - Math.min(1, v) * (h * 0.36);
219
  if (i === 0) ctx.moveTo(x, y); else ctx.lineTo(x, y);
220
  });
221
  for (let i = env.length - 1; i >= 0; i--) {
222
  const v = env[i];
223
  const x = (i / Math.max(1, env.length - 1)) * w;
224
+ const y = mid + Math.min(1, v) * (h * 0.26);
225
  ctx.lineTo(x, y);
226
  }
227
  ctx.closePath();
228
+ ctx.fillStyle = "rgba(107, 113, 126, .58)";
229
  ctx.fill();
230
+ ctx.strokeStyle = "rgba(107, 113, 126, .28)";
231
  ctx.stroke();
232
 
233
  for (const onset of overview.onsets ?? []) {
234
  const x = (onset.time_sec / Math.max(overview.duration_sec, 0.001)) * w;
235
  const selected = Number(onset.index) === Number(selectedHitIndex);
236
+ const stateHit = stateHitByIndex(onset.index);
237
+ const color = selected ? "#26272b" : clusterColor(stateHit?.cluster_id ?? onset.index);
238
+ ctx.strokeStyle = color;
239
+ ctx.globalAlpha = selected ? 1 : 0.5;
240
+ ctx.lineWidth = selected ? 2.2 : 1.2;
241
  ctx.beginPath();
242
+ ctx.moveTo(x, 64);
243
+ ctx.lineTo(x, mid + h * 0.24);
244
  ctx.stroke();
245
+ ctx.globalAlpha = 1;
246
+ ctx.fillStyle = color;
247
+ ctx.beginPath();
248
+ ctx.arc(x, 64, selected ? 7 : 6, 0, Math.PI * 2);
249
+ ctx.fill();
250
  }
251
+
252
  for (const hit of lastSupervisionState?.hits ?? []) {
253
  if (hit.source !== "forced") continue;
254
  const x = (Number(hit.onset_sec) / Math.max(overview.duration_sec, 0.001)) * w;
255
  const selected = Number(hit.index) === Number(selectedHitIndex);
256
+ ctx.strokeStyle = selected ? "#26272b" : "#64aa45";
257
+ ctx.lineWidth = selected ? 2.5 : 1.8;
258
  ctx.beginPath();
259
+ ctx.moveTo(x, 34);
260
+ ctx.lineTo(x, h - 42);
261
  ctx.stroke();
262
+ ctx.fillStyle = selected ? "#26272b" : "#64aa45";
263
+ ctx.beginPath();
264
+ ctx.arc(x, 34, selected ? 7 : 6, 0, Math.PI * 2);
265
+ ctx.fill();
266
  }
267
  }
268
 
 
314
  playAudio($("sampleAudio"), sample.url);
315
  }
316
 
317
+ async function drawMiniWaveform(canvas, url, color = "#8b8f9a") {
318
+ const ctx = canvas.getContext("2d");
319
+ const ratio = window.devicePixelRatio || 1;
320
+ const rect = canvas.getBoundingClientRect();
321
+ const w = Math.max(1, Math.floor(rect.width));
322
+ const h = Math.max(80, Math.floor(rect.height || 130));
323
+ canvas.width = w * ratio;
324
+ canvas.height = h * ratio;
325
+ ctx.setTransform(ratio, 0, 0, ratio, 0, 0);
326
+ ctx.clearRect(0, 0, w, h);
327
+ ctx.fillStyle = "#fff";
328
+ ctx.fillRect(0, 0, w, h);
329
+ ctx.strokeStyle = "#e0e2e7";
330
+ ctx.beginPath();
331
+ ctx.moveTo(12, h / 2);
332
+ ctx.lineTo(w - 12, h / 2);
333
+ ctx.stroke();
334
+
335
+ try {
336
+ if (!url) throw new Error("missing sample URL");
337
+ const response = await fetch(url);
338
+ const buffer = await response.arrayBuffer();
339
+ const decoded = await getAudioContext().decodeAudioData(buffer.slice(0));
340
+ const data = decoded.getChannelData(0);
341
+ const points = Math.min(w - 24, 260);
342
+ ctx.beginPath();
343
+ for (let i = 0; i < points; i++) {
344
+ const start = Math.floor((i / points) * data.length);
345
+ const end = Math.max(start + 1, Math.floor(((i + 1) / points) * data.length));
346
+ let peak = 0;
347
+ for (let j = start; j < end; j++) peak = Math.max(peak, Math.abs(data[j] || 0));
348
+ const x = 12 + (i / Math.max(1, points - 1)) * (w - 24);
349
+ const y1 = h / 2 - peak * h * 0.38;
350
+ const y2 = h / 2 + peak * h * 0.38;
351
+ ctx.moveTo(x, y1);
352
+ ctx.lineTo(x, y2);
353
+ }
354
+ ctx.strokeStyle = "rgba(113, 119, 132, .72)";
355
+ ctx.lineWidth = 1;
356
+ ctx.stroke();
357
+ } catch {
358
+ // Deterministic fallback thumbnail: still shows the transient-card affordance if WebAudio cannot decode yet.
359
+ ctx.beginPath();
360
+ const points = 90;
361
+ for (let i = 0; i < points; i++) {
362
+ const t = i / Math.max(1, points - 1);
363
+ const amp = Math.exp(-t * 6) * (0.72 + 0.25 * Math.sin(i * 1.7));
364
+ const x = 14 + t * (w - 28);
365
+ ctx.moveTo(x, h / 2 - amp * h * 0.32);
366
+ ctx.lineTo(x, h / 2 + amp * h * 0.32);
367
+ }
368
+ ctx.strokeStyle = "rgba(113, 119, 132, .58)";
369
+ ctx.stroke();
370
+ }
371
+ }
372
+
373
  function renderSamples(result) {
374
+ const samples = result.samples ?? [];
375
+ if ($("sampleCountLabel")) $("sampleCountLabel").textContent = `(${samples.length})`;
376
+
377
+ const grid = $("samplesGrid");
378
+ if (grid) {
379
+ grid.innerHTML = samples.map((sample, i) => {
380
+ const color = clusterColor(sample.cluster_id ?? i);
381
+ return `
382
+ <button class="sample-card ${i === selectedSampleIndex ? "selected" : ""}" type="button" data-sample-audition="${i}" style="border-top-color: ${esc(color)}">
383
+ <canvas class="sample-wave" data-wave-url="${esc(sample.url)}" data-wave-color="${esc(color)}"></canvas>
384
+ <span class="sample-card-footer">
385
+ <span class="play-dot">▶</span>
386
+ <span><strong class="sample-name">${esc(sample.label || `Sample ${i + 1}`)}</strong><small class="sample-meta">${esc(sample.classification)} · ${esc(sample.hits)} hits</small></span>
387
+ </span>
388
+ </button>
389
+ `;
390
+ }).join("") || `<p class="empty">Run extraction to populate sample cards.</p>`;
391
+ for (const button of grid.querySelectorAll("[data-sample-audition]")) {
392
+ button.addEventListener("click", () => {
393
+ selectedSampleIndex = Number(button.dataset.sampleAudition);
394
+ const sample = samples[selectedSampleIndex];
395
+ auditionSample(sample);
396
+ renderSamples(result);
397
+ });
398
+ }
399
+ for (const canvas of grid.querySelectorAll(".sample-wave")) {
400
+ drawMiniWaveform(canvas, canvas.dataset.waveUrl, canvas.dataset.waveColor);
401
+ }
402
+ }
403
+
404
  const tbody = $("samplesTable").querySelector("tbody");
405
+ tbody.innerHTML = samples.map((sample, i) => `
406
  <tr data-sample-index="${i}">
407
  <td><button class="mini-button" type="button" data-sample-audition="${i}">Audition</button></td>
408
  <td>${esc(sample.label)}</td>
 
417
  for (const button of tbody.querySelectorAll("[data-sample-audition]")) {
418
  button.addEventListener("click", (event) => {
419
  event.stopPropagation();
420
+ selectedSampleIndex = Number(button.dataset.sampleAudition);
421
+ const sample = samples[selectedSampleIndex];
422
  auditionSample(sample);
423
+ renderSamples(result);
424
  });
425
  }
426
  }
 
662
  if (!result) return;
663
  activeJobId = job.id;
664
  lastResult = result;
665
+ if (!(result.samples ?? [])[selectedSampleIndex]) selectedSampleIndex = null;
666
  if (!(result.hits ?? []).some((hit) => Number(hit.index) === Number(selectedHitIndex))) {
667
  selectedHitIndex = (result.hits ?? [])[0]?.index ?? null;
668
  }
 
803
 
804
  function setFile(file) {
805
  selectedFile = file;
806
+ $("dropTitle").textContent = file ? file.name : "Choose an audio file";
807
+ $("dropMeta").textContent = file ? `${(file.size / 1024 / 1024).toFixed(2)} MB · ${file.type || "audio"}` : "WAV, MP3, FLAC, AIFF, OGG, M4A";
808
  $("runButton").disabled = !file;
809
+ if (file && (!lastResult || $("resultSummary").textContent.startsWith("Load audio"))) {
810
+ $("resultSummary").textContent = `${file.name} is ready. Extract samples to see waveform markers and sample cards.`;
811
+ }
812
  if (file) {
813
  $("sourcePreview").hidden = false;
814
  $("sourcePreview").src = URL.createObjectURL(file);
 
840
  config = await api("/api/config");
841
  populateConfig();
842
  await refreshHistory();
843
+ drawWaveform({ envelope: [], onsets: [], duration_sec: 0 });
844
  setHealth(true, "Ready", "Backend online");
845
  } catch (error) {
846
  setHealth(false, "Offline", error.message);
 
865
  $("mel_threshold").value = 0.62;
866
  $("ncc_threshold").value = 0.72;
867
  });
868
+
869
+ for (const [id, delta] of [["clusterMinusButton", -1], ["clusterPlusButton", 1]]) {
870
+ const button = $(id);
871
+ if (button) {
872
+ button.addEventListener("click", () => {
873
+ const input = $("target_max");
874
+ const current = Number(input.value || 0);
875
+ const min = Number(input.min || 0);
876
+ const max = Number(input.max || 256);
877
+ input.value = Math.max(min, Math.min(max, current + delta));
878
+ });
879
+ }
880
+ }
881
  $("refreshHistoryButton").addEventListener("click", refreshHistory);
882
  $("clearCacheButton").addEventListener("click", async () => {
883
  try {
web/index.html CHANGED
@@ -8,234 +8,244 @@
8
  </head>
9
  <body>
10
  <div class="shell">
11
- <header class="hero">
12
- <div>
13
- <p class="eyebrow">Sample extraction workstation</p>
14
- <h1>Extract reusable drum samples from one audio file.</h1>
15
- <p class="lede">Upload a track, isolate or bypass the stem, detect hits, cluster similar transients, export WAVs, MIDI, reconstruction audio, and a complete sample pack.</p>
16
- </div>
17
- <div class="hero-card" aria-live="polite">
18
- <span class="status-dot" id="healthDot"></span>
19
- <div>
20
- <strong id="healthText">Connecting</strong>
21
- <span id="healthSubtext">FastAPI backend</span>
 
 
22
  </div>
 
23
  </div>
24
  </header>
25
 
26
- <main class="workspace">
27
- <section class="panel ingest-panel">
28
- <div class="panel-heading">
29
  <div>
30
- <h2>1. Source</h2>
31
- <p>Drop a WAV, MP3, FLAC, AIFF, or OGG file. Use <code>all</code> stem for fast iteration without Demucs.</p>
32
  </div>
 
33
  </div>
34
- <label class="dropzone" id="dropzone">
35
- <input id="fileInput" type="file" accept="audio/*,.wav,.mp3,.flac,.aiff,.ogg,.m4a" />
36
- <span class="drop-icon"></span>
37
- <strong id="dropTitle">Drop audio here or click to browse</strong>
38
- <small id="dropMeta">No file selected</small>
39
- </label>
40
- <audio id="sourcePreview" controls hidden></audio>
41
- </section>
42
-
43
- <section class="panel controls-panel">
44
- <div class="panel-heading">
45
- <div>
46
- <h2>2. Extraction controls</h2>
47
- <p>Batch quality gives the best final grouping. Online preview is the near-realtime clustering path.</p>
48
- </div>
49
- <button id="clearCacheButton" class="ghost-button" type="button">Clear cache</button>
50
  </div>
 
 
51
 
52
- <div class="control-grid">
 
53
  <label>Stem
54
  <select id="stem"></select>
55
  </label>
56
- <label>Demucs model
57
- <select id="demucs_model"></select>
58
- </label>
59
- <label>Clustering mode
60
- <select id="clustering_mode">
61
- <option value="batch_quality">batch quality</option>
62
- <option value="online_preview">online preview</option>
63
- </select>
64
- </label>
65
- <label>Shifts
66
- <input id="demucs_shifts" type="number" min="0" max="8" step="1" />
67
- </label>
68
- <label>Overlap
69
- <input id="demucs_overlap" type="number" min="0" max="0.9" step="0.05" />
70
- </label>
71
- <label>Onset mode
72
- <select id="onset_mode">
73
- <option value="auto">auto / multiband</option>
74
- <option value="percussive">percussive</option>
75
- <option value="harmonic">harmonic</option>
76
- <option value="broadband">broadband</option>
77
- </select>
78
- </label>
79
- <label>Onset delta
80
- <input id="onset_delta" type="number" min="0.001" max="1" step="0.01" />
81
- </label>
82
- <label>Energy threshold dB
83
- <input id="energy_threshold_db" type="number" min="-100" max="0" step="1" />
84
- </label>
85
- <label>Minimum gap seconds
86
- <input id="min_gap" type="number" min="0.001" max="1" step="0.005" />
87
- </label>
88
- <label>Pre-pad seconds
89
- <input id="pre_pad" type="number" min="0" max="0.25" step="0.001" />
90
- </label>
91
- <label>Min duration seconds
92
- <input id="min_dur" type="number" min="0.001" max="10" step="0.005" />
93
- </label>
94
- <label>Max duration seconds
95
- <input id="max_dur" type="number" min="0.01" max="10" step="0.1" />
96
- </label>
97
- <label>NCC threshold
98
- <input id="ncc_threshold" type="number" min="0" max="1" step="0.01" />
99
- </label>
100
- <label>Attack window ms
101
- <input id="attack_ms" type="number" min="1" max="250" step="1" />
102
- </label>
103
- <label>Mel prefilter
104
- <input id="mel_threshold" type="number" min="0" max="1" step="0.01" />
105
- </label>
106
- <label>Linkage
107
- <select id="linkage">
108
- <option value="average">average</option>
109
- <option value="complete">complete</option>
110
- <option value="single">single</option>
111
- </select>
112
- </label>
113
- <label>Target min clusters
114
- <input id="target_min" type="number" min="0" max="256" step="1" />
115
- </label>
116
- <label>Target max clusters
117
- <input id="target_max" type="number" min="0" max="256" step="1" />
118
- </label>
119
- <label>MIDI grid
120
- <select id="subdivision">
121
- <option value="8">8th</option>
122
- <option value="16">16th</option>
123
- <option value="32">32nd</option>
124
- <option value="64">64th</option>
125
- </select>
126
- </label>
127
  </div>
128
 
129
- <div class="toggles">
130
- <label><input id="synthesize" type="checkbox" /> synthesize alternates</label>
131
- <label><input id="quantize_midi" type="checkbox" /> quantize MIDI</label>
132
- <label><input id="use_disk_cache" type="checkbox" /> disk cache stems/source loads</label>
 
 
 
 
 
 
 
 
 
133
  </div>
134
 
135
- <div class="actions">
136
- <button id="runButton" class="primary-button" type="button" disabled>Extract samples</button>
137
- <button id="useFastButton" class="secondary-button" type="button">Use fast full-mix mode</button>
138
- <button id="usePreviewButton" class="secondary-button" type="button">Use online preview mode</button>
139
  </div>
140
- </section>
141
 
142
- <section class="panel progress-panel">
143
- <div class="panel-heading">
144
- <div>
145
- <h2>3. Pipeline</h2>
146
- <p>Stage timings are captured per run. Stem separation is deliberately isolated because it dominates offline extraction.</p>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
147
  </div>
148
- <span class="job-pill" id="jobPill">idle</span>
 
 
 
 
 
 
 
 
 
 
 
 
149
  </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
150
  <div id="stageList" class="stage-list"></div>
151
  <pre id="logs" class="logs" aria-live="polite"></pre>
152
- </section>
153
 
154
- <section class="panel history-panel">
155
- <div class="panel-heading">
156
- <div>
157
- <h2>Run history</h2>
158
- <p>Completed manifests under <code>.runs/</code> are indexed automatically. Load a run to compare timings and artifacts.</p>
159
- </div>
160
- <button id="refreshHistoryButton" class="ghost-button" type="button">Refresh</button>
161
- </div>
162
  <div id="historyList" class="history-list"></div>
163
- </section>
164
 
165
- <section class="panel result-panel">
166
- <div class="panel-heading">
 
 
 
 
167
  <div>
168
- <h2>4. Results</h2>
169
- <p id="resultSummary">Run extraction to populate samples, timing, MIDI, reconstruction, and downloads.</p>
 
 
 
 
 
170
  </div>
171
  </div>
172
- <canvas id="waveform" class="waveform" height="160"></canvas>
173
- <div class="downloads" id="downloads"></div>
174
- <div class="audio-grid">
175
- <label>Stem audio<audio id="stemAudio" controls></audio></label>
176
- <label>Reconstruction<audio id="reconAudio" controls></audio></label>
 
 
 
 
 
 
 
 
 
177
  </div>
178
- <div class="review-grid">
179
- <article class="review-card">
180
- <strong>Selected hit</strong>
181
- <span id="selectedHitMeta">Click an onset marker or hit row to audition the detected slice.</span>
182
- <audio id="hitAudio" controls></audio>
183
  </article>
184
- <article class="review-card">
185
- <strong>Selected sample</strong>
186
- <span id="selectedSampleMeta">Click Audition in the sample table to hear the representative sample.</span>
187
- <audio id="sampleAudio" controls></audio>
 
 
 
 
 
 
 
188
  </article>
189
  </div>
 
 
190
 
191
- <section class="supervision-panel" aria-live="polite">
192
- <div class="supervision-header">
193
- <div>
194
- <h3>Interactive supervision</h3>
195
- <p class="subtle">Moves, locks, suppressions, favorites, and accepted suggestions are saved as replayable semantic state next to the run manifest.</p>
196
- </div>
197
- <div class="supervision-actions">
198
- <button id="refreshStateButton" class="ghost-button" type="button">Refresh state</button>
199
- <button id="exportStateButton" class="ghost-button" type="button" disabled>Export edited pack</button>
200
- <button id="forceOnsetButton" class="ghost-button" type="button" disabled>Add-onset mode off</button>
201
- <button id="undoButton" class="ghost-button" type="button" disabled>Undo edit</button>
202
- </div>
203
- </div>
204
- <div id="supervisionSummary" class="state-summary">No interactive state loaded.</div>
205
- <div id="editedDownloads" class="downloads edited-downloads"></div>
206
- <div class="supervision-tools">
207
- <label>Target cluster
208
- <select id="targetClusterSelect"></select>
209
- </label>
210
- <button id="moveHitButton" class="secondary-button" type="button" disabled>Move selected hit</button>
211
- <button id="pullHitButton" class="secondary-button" type="button" disabled>Pull into new cluster</button>
212
- <button id="acceptHitButton" class="secondary-button" type="button" disabled>Accept hit</button>
213
- <button id="favoriteHitButton" class="secondary-button" type="button" disabled>Favorite as representative</button>
214
- <button id="suppressHitButton" class="secondary-button danger-button" type="button" disabled>Suppress as bleed</button>
215
- <button id="restoreHitButton" class="secondary-button" type="button" disabled>Restore selected hit</button>
216
- <button id="lockClusterButton" class="secondary-button" type="button" disabled>Lock target cluster</button>
217
- <button id="explainClusterButton" class="secondary-button" type="button" disabled>Explain target cluster</button>
218
- </div>
219
- <div class="supervision-grid">
220
- <article>
221
- <h4>Outlier-first review queue</h4>
222
- <div id="reviewQueue" class="compact-list"></div>
223
- </article>
224
- <article>
225
- <h4>Cluster board</h4>
226
- <div id="clusterBoard" class="compact-list"></div>
227
- </article>
228
- <article>
229
- <h4>Suggestion inbox</h4>
230
- <div id="suggestionInbox" class="compact-list"></div>
231
- </article>
232
- <article>
233
- <h4>Constraint / event log</h4>
234
- <div id="stateLog" class="compact-list"></div>
235
- </article>
236
- </div>
237
- <pre id="clusterExplanation" class="explanation empty">Select a cluster and click Explain.</pre>
238
- </section>
239
  <div class="result-columns">
240
  <section>
241
  <h3>Representative samples</h3>
@@ -265,7 +275,7 @@
265
  </div>
266
  </section>
267
  </div>
268
- </section>
269
  </main>
270
  </div>
271
  <script type="module" src="/web/app.js"></script>
 
8
  </head>
9
  <body>
10
  <div class="shell">
11
+ <header class="topbar">
12
+ <label class="file-chip" id="dropzone" title="Drop audio here or click to browse">
13
+ <input id="fileInput" type="file" accept="audio/*,.wav,.mp3,.flac,.aiff,.ogg,.m4a" />
14
+ <span class="brand-mark" aria-hidden="true"><i></i><i></i><i></i><i></i><i></i></span>
15
+ <span class="file-copy">
16
+ <strong id="dropTitle">Choose an audio file</strong>
17
+ <small id="dropMeta">WAV, MP3, FLAC, AIFF, OGG, M4A</small>
18
+ </span>
19
+ </label>
20
+ <div class="topbar-actions">
21
+ <div class="backend-pill" aria-live="polite">
22
+ <span class="status-dot" id="healthDot"></span>
23
+ <span><strong id="healthText">Connecting</strong><small id="healthSubtext">FastAPI backend</small></span>
24
  </div>
25
+ <button id="runButton" class="primary-button" type="button" disabled><span>✦</span> Extract Samples</button>
26
  </div>
27
  </header>
28
 
29
+ <main class="app-layout">
30
+ <section class="wave-card panel">
31
+ <div class="wave-header">
32
  <div>
33
+ <p class="eyebrow">Sample extraction workstation</p>
34
+ <h1 id="resultSummary">Load audio, tune sensitivity, extract clean drum hits.</h1>
35
  </div>
36
+ <span class="job-pill" id="jobPill">idle</span>
37
  </div>
38
+ <canvas id="waveform" class="waveform" height="360"></canvas>
39
+ <div class="transport-row">
40
+ <audio id="sourcePreview" controls hidden></audio>
41
+ <label class="transport-audio">Stem audio<audio id="stemAudio" controls></audio></label>
42
+ <label class="transport-audio">Reconstruction<audio id="reconAudio" controls></audio></label>
 
 
 
 
 
 
 
 
 
 
 
43
  </div>
44
+ <div class="downloads" id="downloads"></div>
45
+ </section>
46
 
47
+ <aside class="control-card panel">
48
+ <div class="control-group">
49
  <label>Stem
50
  <select id="stem"></select>
51
  </label>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
52
  </div>
53
 
54
+ <div class="control-group sensitivity-group">
55
+ <label for="onset_delta">Sensitivity</label>
56
+ <input id="onset_delta" type="range" min="0.01" max="0.35" step="0.005" />
57
+ <div class="range-caption"><span>Low</span><span>High</span></div>
58
+ </div>
59
+
60
+ <div class="control-group">
61
+ <label>Cluster Count</label>
62
+ <div class="stepper">
63
+ <button id="clusterMinusButton" type="button" class="step-button">−</button>
64
+ <input id="target_max" type="number" min="0" max="256" step="1" />
65
+ <button id="clusterPlusButton" type="button" class="step-button">+</button>
66
+ </div>
67
  </div>
68
 
69
+ <div class="primary-actions-stack">
70
+ <button id="exportStateButton" class="export-button" type="button" disabled> Export edited pack</button>
71
+ <button id="usePreviewButton" class="ghost-button" type="button">Online preview mode</button>
72
+ <button id="useFastButton" class="ghost-button" type="button">Fast full-mix mode</button>
73
  </div>
 
74
 
75
+ <details class="advanced-controls">
76
+ <summary>Advanced extraction settings</summary>
77
+ <div class="control-grid compact-controls">
78
+ <label>Demucs model
79
+ <select id="demucs_model"></select>
80
+ </label>
81
+ <label>Clustering mode
82
+ <select id="clustering_mode">
83
+ <option value="batch_quality">batch quality</option>
84
+ <option value="online_preview">online preview</option>
85
+ </select>
86
+ </label>
87
+ <label>Shifts
88
+ <input id="demucs_shifts" type="number" min="0" max="8" step="1" />
89
+ </label>
90
+ <label>Overlap
91
+ <input id="demucs_overlap" type="number" min="0" max="0.9" step="0.05" />
92
+ </label>
93
+ <label>Onset mode
94
+ <select id="onset_mode">
95
+ <option value="auto">auto / multiband</option>
96
+ <option value="percussive">percussive</option>
97
+ <option value="harmonic">harmonic</option>
98
+ <option value="broadband">broadband</option>
99
+ </select>
100
+ </label>
101
+ <label>Energy threshold dB
102
+ <input id="energy_threshold_db" type="number" min="-100" max="0" step="1" />
103
+ </label>
104
+ <label>Minimum gap seconds
105
+ <input id="min_gap" type="number" min="0.001" max="1" step="0.005" />
106
+ </label>
107
+ <label>Pre-pad seconds
108
+ <input id="pre_pad" type="number" min="0" max="0.25" step="0.001" />
109
+ </label>
110
+ <label>Min duration seconds
111
+ <input id="min_dur" type="number" min="0.001" max="10" step="0.005" />
112
+ </label>
113
+ <label>Max duration seconds
114
+ <input id="max_dur" type="number" min="0.01" max="10" step="0.1" />
115
+ </label>
116
+ <label>NCC threshold
117
+ <input id="ncc_threshold" type="number" min="0" max="1" step="0.01" />
118
+ </label>
119
+ <label>Attack window ms
120
+ <input id="attack_ms" type="number" min="1" max="250" step="1" />
121
+ </label>
122
+ <label>Mel prefilter
123
+ <input id="mel_threshold" type="number" min="0" max="1" step="0.01" />
124
+ </label>
125
+ <label>Linkage
126
+ <select id="linkage">
127
+ <option value="average">average</option>
128
+ <option value="complete">complete</option>
129
+ <option value="single">single</option>
130
+ </select>
131
+ </label>
132
+ <label>Target min clusters
133
+ <input id="target_min" type="number" min="0" max="256" step="1" />
134
+ </label>
135
+ <label>MIDI grid
136
+ <select id="subdivision">
137
+ <option value="8">8th</option>
138
+ <option value="16">16th</option>
139
+ <option value="32">32nd</option>
140
+ <option value="64">64th</option>
141
+ </select>
142
+ </label>
143
  </div>
144
+ <div class="toggles">
145
+ <label><input id="synthesize" type="checkbox" /> synthesize alternates</label>
146
+ <label><input id="quantize_midi" type="checkbox" /> quantize MIDI</label>
147
+ <label><input id="use_disk_cache" type="checkbox" /> disk cache stems/source loads</label>
148
+ </div>
149
+ <button id="clearCacheButton" class="ghost-button full-width" type="button">Clear cache</button>
150
+ </details>
151
+ </aside>
152
+
153
+ <section class="samples-section">
154
+ <div class="section-heading">
155
+ <h2>Extracted Samples <span id="sampleCountLabel">(0)</span></h2>
156
+ <p>Representative samples appear as auditionable cards. Use the supervision tools below for deeper correction.</p>
157
  </div>
158
+ <div id="samplesGrid" class="sample-grid"></div>
159
+ </section>
160
+
161
+ <section class="review-strip">
162
+ <article class="review-card">
163
+ <strong>Selected hit</strong>
164
+ <span id="selectedHitMeta">Click an onset marker or hit row to audition the detected slice.</span>
165
+ <audio id="hitAudio" controls></audio>
166
+ </article>
167
+ <article class="review-card">
168
+ <strong>Selected sample</strong>
169
+ <span id="selectedSampleMeta">Click a sample card to hear the representative sample.</span>
170
+ <audio id="sampleAudio" controls></audio>
171
+ </article>
172
+ </section>
173
+
174
+ <details class="panel progress-panel utility-panel" open>
175
+ <summary>
176
+ <span>Pipeline</span>
177
+ <small>Stage timings, logs, and streaming job progress</small>
178
+ </summary>
179
  <div id="stageList" class="stage-list"></div>
180
  <pre id="logs" class="logs" aria-live="polite"></pre>
181
+ </details>
182
 
183
+ <details class="panel history-panel utility-panel">
184
+ <summary>
185
+ <span>Run history</span>
186
+ <small>Completed manifests under .runs/</small>
187
+ </summary>
188
+ <button id="refreshHistoryButton" class="ghost-button" type="button">Refresh history</button>
 
 
189
  <div id="historyList" class="history-list"></div>
190
+ </details>
191
 
192
+ <details class="panel supervision-panel utility-panel">
193
+ <summary>
194
+ <span>Interactive supervision</span>
195
+ <small>Move, suppress, restore, force-onset, explain, and export edited packs</small>
196
+ </summary>
197
+ <div class="supervision-header">
198
  <div>
199
+ <h3>Semantic edit state</h3>
200
+ <p class="subtle">Moves, locks, suppressions, favorites, and accepted suggestions are saved as replayable semantic state next to the run manifest.</p>
201
+ </div>
202
+ <div class="supervision-actions">
203
+ <button id="refreshStateButton" class="ghost-button" type="button">Refresh state</button>
204
+ <button id="forceOnsetButton" class="ghost-button" type="button" disabled>Add-onset mode off</button>
205
+ <button id="undoButton" class="ghost-button" type="button" disabled>Undo edit</button>
206
  </div>
207
  </div>
208
+ <div id="supervisionSummary" class="state-summary">No interactive state loaded.</div>
209
+ <div id="editedDownloads" class="downloads edited-downloads"></div>
210
+ <div class="supervision-tools">
211
+ <label>Target cluster
212
+ <select id="targetClusterSelect"></select>
213
+ </label>
214
+ <button id="moveHitButton" class="secondary-button" type="button" disabled>Move selected hit</button>
215
+ <button id="pullHitButton" class="secondary-button" type="button" disabled>Pull into new cluster</button>
216
+ <button id="acceptHitButton" class="secondary-button" type="button" disabled>Accept hit</button>
217
+ <button id="favoriteHitButton" class="secondary-button" type="button" disabled>Favorite as representative</button>
218
+ <button id="suppressHitButton" class="secondary-button danger-button" type="button" disabled>Suppress as bleed</button>
219
+ <button id="restoreHitButton" class="secondary-button" type="button" disabled>Restore selected hit</button>
220
+ <button id="lockClusterButton" class="secondary-button" type="button" disabled>Lock target cluster</button>
221
+ <button id="explainClusterButton" class="secondary-button" type="button" disabled>Explain target cluster</button>
222
  </div>
223
+ <div class="supervision-grid">
224
+ <article>
225
+ <h4>Outlier-first review queue</h4>
226
+ <div id="reviewQueue" class="compact-list"></div>
 
227
  </article>
228
+ <article>
229
+ <h4>Cluster board</h4>
230
+ <div id="clusterBoard" class="compact-list"></div>
231
+ </article>
232
+ <article>
233
+ <h4>Suggestion inbox</h4>
234
+ <div id="suggestionInbox" class="compact-list"></div>
235
+ </article>
236
+ <article>
237
+ <h4>Constraint / event log</h4>
238
+ <div id="stateLog" class="compact-list"></div>
239
  </article>
240
  </div>
241
+ <pre id="clusterExplanation" class="explanation empty">Select a cluster and click Explain.</pre>
242
+ </details>
243
 
244
+ <details class="panel utility-panel data-panel">
245
+ <summary>
246
+ <span>Detailed tables</span>
247
+ <small>Raw sample and detected-hit review rows</small>
248
+ </summary>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
249
  <div class="result-columns">
250
  <section>
251
  <h3>Representative samples</h3>
 
275
  </div>
276
  </section>
277
  </div>
278
+ </details>
279
  </main>
280
  </div>
281
  <script type="module" src="/web/app.js"></script>
web/styles.css CHANGED
@@ -1,125 +1,665 @@
1
  :root {
2
- color-scheme: dark;
3
- --bg: #08090d;
4
- --panel: rgba(18, 22, 32, 0.84);
5
- --panel-strong: rgba(28, 34, 48, 0.92);
6
- --line: rgba(255, 255, 255, 0.1);
7
- --muted: #8b93a7;
8
- --text: #eef2ff;
9
- --accent: #8bd3ff;
10
- --accent-2: #c8a5ff;
11
- --good: #55e6a5;
12
- --bad: #ff6d7a;
13
- --warn: #ffca6b;
14
- --shadow: 0 24px 90px rgba(0,0,0,.38);
 
 
 
 
 
 
 
 
 
 
 
15
  }
 
16
  * { box-sizing: border-box; }
17
- html, body { margin: 0; min-height: 100%; background: radial-gradient(circle at 20% 0%, rgba(139,211,255,.20), transparent 30rem), radial-gradient(circle at 88% 8%, rgba(200,165,255,.18), transparent 28rem), var(--bg); color: var(--text); font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "SF Pro Display", "Segoe UI", sans-serif; }
 
 
 
 
 
 
 
 
 
18
  button, input, select { font: inherit; }
19
- code { color: var(--accent); }
20
- .shell { width: min(1520px, calc(100% - 32px)); margin: 0 auto; padding: 32px 0 56px; }
21
- .hero { display: grid; grid-template-columns: 1fr auto; gap: 24px; align-items: end; margin-bottom: 24px; }
22
- .eyebrow { margin: 0 0 10px; text-transform: uppercase; letter-spacing: .16em; color: var(--accent); font-size: 12px; font-weight: 800; }
23
- h1 { margin: 0; font-size: clamp(36px, 6vw, 76px); line-height: .92; letter-spacing: -.07em; max-width: 980px; }
24
- .lede { margin: 18px 0 0; color: #cbd3e5; font-size: 17px; max-width: 860px; line-height: 1.55; }
25
- .hero-card { min-width: 250px; display: flex; align-items: center; gap: 14px; padding: 18px; border: 1px solid var(--line); background: rgba(255,255,255,.06); border-radius: 24px; box-shadow: var(--shadow); backdrop-filter: blur(18px); }
26
- .hero-card strong, .hero-card span { display: block; }
27
- .hero-card span:last-child { color: var(--muted); font-size: 13px; margin-top: 3px; }
28
- .status-dot { width: 12px; height: 12px; border-radius: 999px; background: var(--warn); box-shadow: 0 0 26px currentColor; }
29
- .status-dot.ok { background: var(--good); }
30
- .status-dot.bad { background: var(--bad); }
31
- .workspace { display: grid; grid-template-columns: minmax(320px, .9fr) minmax(520px, 1.35fr); gap: 18px; align-items: start; }
32
- .panel { border: 1px solid var(--line); border-radius: 28px; background: linear-gradient(180deg, var(--panel-strong), var(--panel)); box-shadow: var(--shadow); backdrop-filter: blur(22px); padding: 22px; }
33
- .result-panel { grid-column: 1 / -1; }
34
- .panel-heading { display: flex; align-items: flex-start; justify-content: space-between; gap: 18px; margin-bottom: 18px; }
35
- h2 { margin: 0; font-size: 20px; letter-spacing: -.025em; }
36
- .panel p { margin: 7px 0 0; color: var(--muted); line-height: 1.45; }
37
- .dropzone { position: relative; display: grid; place-items: center; gap: 8px; min-height: 260px; padding: 22px; border: 1.5px dashed rgba(139,211,255,.42); border-radius: 24px; background: linear-gradient(145deg, rgba(139,211,255,.08), rgba(200,165,255,.05)); text-align: center; cursor: pointer; transition: transform .2s ease, border-color .2s ease, background .2s ease; }
38
- .dropzone:hover, .dropzone.dragging { transform: translateY(-1px); border-color: var(--accent); background: rgba(139,211,255,.12); }
39
- .dropzone input { position: absolute; inset: 0; opacity: 0; cursor: pointer; }
40
- .drop-icon { width: 74px; height: 74px; display: grid; place-items: center; border-radius: 22px; background: rgba(255,255,255,.08); color: var(--accent); font-size: 42px; line-height: 1; }
41
- .dropzone strong { font-size: 18px; }
42
- .dropzone small { color: var(--muted); }
43
- audio { width: 100%; margin-top: 12px; }
44
- .control-grid { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 12px; }
45
- label { display: block; color: #c7d0e4; font-size: 12px; font-weight: 750; letter-spacing: .02em; }
46
- input, select { width: 100%; margin-top: 7px; border: 1px solid var(--line); border-radius: 14px; padding: 11px 12px; color: var(--text); background: rgba(5, 7, 12, .62); outline: none; }
47
- input:focus, select:focus { border-color: rgba(139,211,255,.8); box-shadow: 0 0 0 4px rgba(139,211,255,.12); }
48
- .toggles { display: flex; flex-wrap: wrap; gap: 14px; margin: 16px 0 0; }
49
- .toggles label { display: flex; align-items: center; gap: 8px; font-size: 13px; font-weight: 700; }
50
- .toggles input { width: auto; margin: 0; }
51
- .actions { display: flex; flex-wrap: wrap; gap: 12px; margin-top: 18px; }
52
- button { border: 0; border-radius: 16px; padding: 12px 16px; color: var(--text); cursor: pointer; transition: transform .16s ease, opacity .16s ease, border-color .16s ease; }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
53
  button:hover:not(:disabled) { transform: translateY(-1px); }
54
  button:disabled { opacity: .45; cursor: not-allowed; }
55
- .primary-button { background: linear-gradient(135deg, var(--accent), var(--accent-2)); color: #07101d; font-weight: 900; }
56
- .secondary-button, .ghost-button { border: 1px solid var(--line); background: rgba(255,255,255,.07); }
57
- .ghost-button { padding: 9px 12px; color: #cbd3e5; }
58
- .job-pill { display: inline-flex; align-items: center; border: 1px solid var(--line); border-radius: 999px; padding: 7px 10px; color: var(--muted); background: rgba(255,255,255,.06); font-size: 12px; }
59
- .stage-list { display: grid; gap: 9px; }
60
- .stage { display: grid; grid-template-columns: 24px 1fr auto; gap: 10px; align-items: center; padding: 12px; border: 1px solid var(--line); border-radius: 18px; background: rgba(0,0,0,.16); }
61
- .stage .badge { width: 18px; height: 18px; border-radius: 999px; background: rgba(255,255,255,.16); }
62
- .stage.running .badge { background: var(--accent); box-shadow: 0 0 22px rgba(139,211,255,.8); }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
63
  .stage.done .badge { background: var(--good); }
64
  .stage.error .badge { background: var(--bad); }
65
- .stage strong { display: block; font-size: 14px; }
66
  .stage small { display: block; color: var(--muted); margin-top: 2px; }
67
- .stage time { color: #d7def0; font-variant-numeric: tabular-nums; }
68
- .logs { min-height: 140px; max-height: 240px; overflow: auto; border: 1px solid var(--line); border-radius: 18px; padding: 14px; margin: 14px 0 0; background: #05070b; color: #9db8c8; font-size: 12px; line-height: 1.45; white-space: pre-wrap; }
69
- .waveform { width: 100%; min-height: 160px; border: 1px solid var(--line); border-radius: 20px; background: rgba(0,0,0,.18); margin: 4px 0 16px; }
70
- .downloads { display: flex; flex-wrap: wrap; gap: 10px; margin-bottom: 16px; }
71
- .downloads a, .table-wrap a { color: #07101d; text-decoration: none; font-weight: 850; background: var(--accent); border-radius: 999px; padding: 8px 11px; }
72
- .audio-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 16px; margin-bottom: 16px; }
73
- .table-wrap { overflow: auto; border: 1px solid var(--line); border-radius: 20px; }
74
- table { width: 100%; border-collapse: collapse; min-width: 860px; }
75
- th, td { text-align: left; padding: 12px 14px; border-bottom: 1px solid var(--line); font-size: 13px; }
76
- th { position: sticky; top: 0; background: #101521; color: #aeb9ce; z-index: 1; }
77
- td { color: #e5eaf7; }
78
- tr:last-child td { border-bottom: 0; }
79
- @media (max-width: 1100px) { .workspace, .hero { grid-template-columns: 1fr; } .control-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
80
- @media (max-width: 680px) { .shell { width: min(100% - 20px, 1520px); padding-top: 16px; } .panel { padding: 16px; border-radius: 22px; } .control-grid, .audio-grid { grid-template-columns: 1fr; } h1 { letter-spacing: -.045em; } }
81
- .history-panel { align-self: stretch; }
82
- .history-list { display: grid; gap: 8px; max-height: 360px; overflow: auto; }
83
- .history-row { width: 100%; display: grid; grid-template-columns: minmax(0, 1fr) auto auto auto; gap: 12px; align-items: center; text-align: left; border: 1px solid var(--line); background: rgba(0,0,0,.16); border-radius: 16px; padding: 12px; }
 
 
 
 
 
 
 
 
 
 
 
84
  .history-row strong { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--text); }
85
  .history-row small { display: block; color: var(--muted); margin-top: 3px; }
86
- .history-row span:not(:first-child) { color: #dbe5f7; font-size: 12px; font-variant-numeric: tabular-nums; }
87
  .empty { color: var(--muted); margin: 0; }
88
- @media (max-width: 680px) { .history-row { grid-template-columns: 1fr 1fr; } }
89
  h3 { margin: 0 0 10px; font-size: 16px; letter-spacing: -.015em; }
90
  .subtle { margin: -4px 0 12px; color: var(--muted); font-size: 13px; }
91
- .review-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 16px; margin: 0 0 18px; }
92
- .review-card { border: 1px solid var(--line); border-radius: 20px; background: rgba(0,0,0,.16); padding: 14px; }
93
- .review-card strong, .review-card span { display: block; }
94
- .review-card span { color: var(--muted); font-size: 13px; margin-top: 5px; line-height: 1.4; }
95
- .result-columns { display: grid; grid-template-columns: minmax(0, 1fr); gap: 20px; }
96
  .hit-table-wrap { max-height: 420px; }
97
- .mini-button { padding: 7px 10px; border-radius: 999px; background: rgba(255,255,255,.08); border: 1px solid var(--line); color: var(--text); font-size: 12px; font-weight: 800; }
98
- tr.selected td { background: rgba(139,211,255,.12); }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
99
  tr[data-hit-index] { cursor: pointer; }
100
- tr[data-hit-index]:hover td { background: rgba(255,255,255,.045); }
101
- @media (max-width: 760px) { .review-grid { grid-template-columns: 1fr; } }
102
- .supervision-panel { border: 1px solid var(--line); border-radius: 24px; background: rgba(0,0,0,.14); padding: 16px; margin: 0 0 20px; }
103
- .supervision-header { display: flex; align-items: flex-start; justify-content: space-between; gap: 16px; margin-bottom: 14px; }
 
 
 
 
 
 
 
 
104
  .supervision-header h3, .supervision-grid h4 { margin: 0; }
105
  .supervision-actions, .row-actions { display: flex; gap: 8px; flex-wrap: wrap; justify-content: flex-end; }
106
- .state-summary { display: flex; flex-wrap: wrap; gap: 8px; margin-bottom: 14px; color: #dbe5f7; }
107
- .state-summary span { border: 1px solid var(--line); border-radius: 999px; background: rgba(255,255,255,.06); padding: 7px 10px; font-size: 12px; font-weight: 800; }
108
- .supervision-tools { display: grid; grid-template-columns: minmax(220px, 1fr) repeat(7, auto); gap: 10px; align-items: end; margin-bottom: 16px; }
109
- .danger-button { border-color: rgba(255,109,122,.35); color: #ffd4d8; }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
110
  .supervision-grid { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 12px; }
111
  .compact-list { display: grid; gap: 8px; max-height: 300px; overflow: auto; }
112
- .compact-row, .suggestion-row, .log-row { width: 100%; display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 10px; align-items: center; border: 1px solid var(--line); border-radius: 14px; padding: 10px; background: rgba(0,0,0,.14); color: var(--text); text-align: left; }
 
 
 
 
 
 
 
 
 
 
 
 
113
  .compact-row strong, .suggestion-row strong, .log-row strong { display: block; font-size: 13px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
114
  .compact-row small, .suggestion-row small, .log-row small { display: block; color: var(--muted); font-size: 11px; margin-top: 3px; line-height: 1.35; }
115
- .compact-row.locked { border-color: rgba(85,230,165,.45); background: rgba(85,230,165,.08); }
116
- .compact-row.suppressed, tr.suppressed td { opacity: .62; text-decoration: line-through; }
117
- .log-row.constraint { border-color: rgba(200,165,255,.26); }
118
- .explanation { min-height: 120px; max-height: 320px; overflow: auto; border: 1px solid var(--line); border-radius: 16px; background: #05070b; color: #b9d7e9; padding: 12px; margin: 14px 0 0; font-size: 12px; line-height: 1.45; }
119
- tr.low-confidence td { background: rgba(255,202,107,.06); }
120
- tr.low-confidence.selected td { background: rgba(139,211,255,.15); }
121
- @media (max-width: 1320px) { .supervision-tools { grid-template-columns: repeat(3, minmax(0, 1fr)); } .supervision-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
122
- @media (max-width: 760px) { .supervision-header { display: block; } .supervision-actions { justify-content: flex-start; margin-top: 10px; } .supervision-tools, .supervision-grid { grid-template-columns: 1fr; } }
123
- .ghost-button.active, .secondary-button.active { border-color: rgba(85,230,165,.7); background: rgba(85,230,165,.13); color: #d9ffe9; }
124
- .edited-downloads { margin: -4px 0 14px; }
 
 
 
 
 
 
 
125
  .edited-downloads:empty { display: none; }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  :root {
2
+ color-scheme: light;
3
+ --bg: #f7f7f8;
4
+ --surface: #ffffff;
5
+ --surface-soft: #fbfbfc;
6
+ --line: #e3e4e8;
7
+ --line-strong: #d4d6dd;
8
+ --muted: #8b8f9a;
9
+ --text: #26272b;
10
+ --text-soft: #4a4d55;
11
+ --accent: #6b3ff2;
12
+ --accent-strong: #5129d9;
13
+ --accent-soft: #eee9ff;
14
+ --good: #64aa45;
15
+ --bad: #d95767;
16
+ --warn: #f08f3e;
17
+ --cyan: #42b8b4;
18
+ --blue: #4f7df2;
19
+ --pink: #ea5ca9;
20
+ --orange: #ef9343;
21
+ --green: #8abc59;
22
+ --shadow: 0 18px 50px rgba(20, 22, 30, .06);
23
+ --radius-xl: 22px;
24
+ --radius-lg: 16px;
25
+ font-synthesis-weight: none;
26
  }
27
+
28
  * { box-sizing: border-box; }
29
+ html, body {
30
+ margin: 0;
31
+ min-height: 100%;
32
+ background:
33
+ radial-gradient(circle at 12% 0%, rgba(107, 63, 242, .05), transparent 28rem),
34
+ radial-gradient(circle at 90% 12%, rgba(66, 184, 180, .04), transparent 30rem),
35
+ var(--bg);
36
+ color: var(--text);
37
+ font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "SF Pro Display", "Segoe UI", sans-serif;
38
+ }
39
  button, input, select { font: inherit; }
40
+ code { color: var(--accent-strong); }
41
+
42
+ .shell {
43
+ width: min(100% - 64px, 1760px);
44
+ margin: 0 auto;
45
+ padding: 34px 0 56px;
46
+ }
47
+
48
+ .topbar {
49
+ display: flex;
50
+ align-items: center;
51
+ justify-content: space-between;
52
+ gap: 24px;
53
+ margin-bottom: 34px;
54
+ }
55
+ .file-chip {
56
+ position: relative;
57
+ display: inline-flex;
58
+ align-items: center;
59
+ gap: 28px;
60
+ min-width: min(620px, 100%);
61
+ cursor: pointer;
62
+ color: var(--text);
63
+ }
64
+ .file-chip input {
65
+ position: absolute;
66
+ inset: 0;
67
+ opacity: 0;
68
+ cursor: pointer;
69
+ }
70
+ .file-chip.dragging .brand-mark,
71
+ .file-chip:hover .brand-mark { transform: translateY(-1px); }
72
+ .brand-mark {
73
+ width: 52px;
74
+ height: 52px;
75
+ display: inline-flex;
76
+ align-items: center;
77
+ justify-content: center;
78
+ gap: 4px;
79
+ border-radius: 14px;
80
+ transition: transform .18s ease;
81
+ }
82
+ .brand-mark i {
83
+ width: 4px;
84
+ border-radius: 999px;
85
+ background: #1f2024;
86
+ display: block;
87
+ }
88
+ .brand-mark i:nth-child(1) { height: 17px; }
89
+ .brand-mark i:nth-child(2) { height: 29px; }
90
+ .brand-mark i:nth-child(3) { height: 40px; }
91
+ .brand-mark i:nth-child(4) { height: 25px; }
92
+ .brand-mark i:nth-child(5) { height: 13px; }
93
+ .file-copy strong {
94
+ display: block;
95
+ font-size: clamp(20px, 2vw, 27px);
96
+ line-height: 1.1;
97
+ font-weight: 640;
98
+ letter-spacing: -.035em;
99
+ }
100
+ .file-copy small {
101
+ display: block;
102
+ margin-top: 5px;
103
+ color: var(--muted);
104
+ font-size: 13px;
105
+ }
106
+ .topbar-actions {
107
+ display: inline-flex;
108
+ align-items: center;
109
+ justify-content: flex-end;
110
+ gap: 14px;
111
+ flex-wrap: wrap;
112
+ }
113
+ .backend-pill {
114
+ display: inline-flex;
115
+ align-items: center;
116
+ gap: 9px;
117
+ color: var(--text-soft);
118
+ font-size: 12px;
119
+ padding: 8px 11px;
120
+ border: 1px solid var(--line);
121
+ border-radius: 999px;
122
+ background: rgba(255, 255, 255, .72);
123
+ }
124
+ .backend-pill strong { display: block; font-size: 12px; line-height: 1; }
125
+ .backend-pill small { display: block; color: var(--muted); margin-top: 3px; }
126
+ .status-dot {
127
+ width: 9px;
128
+ height: 9px;
129
+ border-radius: 999px;
130
+ background: var(--warn);
131
+ box-shadow: 0 0 0 4px rgba(240, 143, 62, .13);
132
+ }
133
+ .status-dot.ok { background: var(--good); box-shadow: 0 0 0 4px rgba(100, 170, 69, .13); }
134
+ .status-dot.bad { background: var(--bad); box-shadow: 0 0 0 4px rgba(217, 87, 103, .13); }
135
+
136
+ .app-layout {
137
+ display: grid;
138
+ grid-template-columns: minmax(0, 1fr) 360px;
139
+ grid-template-areas:
140
+ "wave controls"
141
+ "samples samples"
142
+ "review review"
143
+ "progress progress"
144
+ "history history"
145
+ "supervision supervision"
146
+ "data data";
147
+ gap: 28px;
148
+ align-items: start;
149
+ }
150
+ .panel {
151
+ border: 1px solid var(--line);
152
+ border-radius: var(--radius-xl);
153
+ background: rgba(255, 255, 255, .82);
154
+ box-shadow: var(--shadow);
155
+ }
156
+ .wave-card { grid-area: wave; padding: 0; overflow: hidden; min-height: 590px; }
157
+ .control-card { grid-area: controls; padding: 30px; min-height: 590px; }
158
+ .samples-section { grid-area: samples; }
159
+ .review-strip { grid-area: review; }
160
+ .progress-panel { grid-area: progress; }
161
+ .history-panel { grid-area: history; }
162
+ .supervision-panel { grid-area: supervision; }
163
+ .data-panel { grid-area: data; }
164
+
165
+ .wave-header {
166
+ display: flex;
167
+ justify-content: space-between;
168
+ gap: 18px;
169
+ align-items: flex-start;
170
+ padding: 26px 30px 8px;
171
+ }
172
+ .eyebrow {
173
+ margin: 0 0 8px;
174
+ color: var(--muted);
175
+ text-transform: uppercase;
176
+ letter-spacing: .14em;
177
+ font-size: 11px;
178
+ font-weight: 780;
179
+ }
180
+ h1 {
181
+ margin: 0;
182
+ font-size: clamp(20px, 2.2vw, 30px);
183
+ line-height: 1.16;
184
+ letter-spacing: -.045em;
185
+ font-weight: 620;
186
+ color: var(--text);
187
+ }
188
+ .job-pill {
189
+ display: inline-flex;
190
+ align-items: center;
191
+ white-space: nowrap;
192
+ border: 1px solid var(--line);
193
+ border-radius: 999px;
194
+ padding: 8px 11px;
195
+ color: var(--muted);
196
+ background: #fff;
197
+ font-size: 12px;
198
+ }
199
+ .waveform {
200
+ display: block;
201
+ width: 100%;
202
+ height: 420px;
203
+ min-height: 420px;
204
+ margin: 0;
205
+ background: linear-gradient(180deg, #fff, #fbfbfc);
206
+ cursor: crosshair;
207
+ }
208
+ .transport-row {
209
+ display: grid;
210
+ grid-template-columns: repeat(2, minmax(0, 1fr));
211
+ gap: 16px;
212
+ align-items: center;
213
+ padding: 0 30px 26px;
214
+ }
215
+ audio { width: 100%; margin-top: 8px; height: 38px; }
216
+ .transport-audio {
217
+ color: var(--muted);
218
+ font-size: 11px;
219
+ font-weight: 680;
220
+ letter-spacing: .02em;
221
+ }
222
+ .downloads {
223
+ display: flex;
224
+ flex-wrap: wrap;
225
+ gap: 10px;
226
+ padding: 0 30px 28px;
227
+ }
228
+ .downloads a, .table-wrap a {
229
+ color: var(--accent-strong);
230
+ text-decoration: none;
231
+ font-weight: 760;
232
+ background: var(--accent-soft);
233
+ border: 1px solid rgba(107, 63, 242, .18);
234
+ border-radius: 999px;
235
+ padding: 8px 12px;
236
+ }
237
+
238
+ label {
239
+ display: block;
240
+ color: var(--text-soft);
241
+ font-size: 20px;
242
+ font-weight: 520;
243
+ letter-spacing: -.025em;
244
+ }
245
+ .control-group { margin-bottom: 34px; }
246
+ input, select {
247
+ width: 100%;
248
+ margin-top: 12px;
249
+ border: 1px solid var(--line);
250
+ border-radius: 10px;
251
+ padding: 15px 18px;
252
+ color: var(--text);
253
+ background: #fff;
254
+ outline: none;
255
+ font-size: 18px;
256
+ }
257
+ input:focus, select:focus {
258
+ border-color: rgba(107, 63, 242, .62);
259
+ box-shadow: 0 0 0 4px rgba(107, 63, 242, .09);
260
+ }
261
+ input[type="range"] {
262
+ appearance: none;
263
+ padding: 0;
264
+ height: 6px;
265
+ border: 0;
266
+ border-radius: 999px;
267
+ background: linear-gradient(90deg, var(--accent), #ececf1);
268
+ }
269
+ input[type="range"]::-webkit-slider-thumb {
270
+ appearance: none;
271
+ width: 26px;
272
+ height: 26px;
273
+ border-radius: 50%;
274
+ background: var(--accent);
275
+ border: 0;
276
+ box-shadow: 0 6px 18px rgba(107, 63, 242, .28);
277
+ }
278
+ input[type="range"]::-moz-range-thumb {
279
+ width: 26px;
280
+ height: 26px;
281
+ border-radius: 50%;
282
+ background: var(--accent);
283
+ border: 0;
284
+ box-shadow: 0 6px 18px rgba(107, 63, 242, .28);
285
+ }
286
+ .range-caption {
287
+ display: flex;
288
+ justify-content: space-between;
289
+ margin-top: 14px;
290
+ color: var(--muted);
291
+ font-size: 16px;
292
+ }
293
+ .stepper {
294
+ display: grid;
295
+ grid-template-columns: 64px minmax(0, 1fr) 64px;
296
+ margin-top: 12px;
297
+ border: 1px solid var(--line);
298
+ border-radius: 10px;
299
+ overflow: hidden;
300
+ background: #fff;
301
+ }
302
+ .stepper input {
303
+ margin: 0;
304
+ border: 0;
305
+ border-left: 1px solid var(--line);
306
+ border-right: 1px solid var(--line);
307
+ border-radius: 0;
308
+ text-align: center;
309
+ font-size: 18px;
310
+ }
311
+ .step-button {
312
+ border: 0;
313
+ border-radius: 0;
314
+ background: #fff;
315
+ color: var(--text);
316
+ font-size: 26px;
317
+ line-height: 1;
318
+ }
319
+ .primary-actions-stack { display: grid; gap: 14px; margin-top: 40px; }
320
+ button {
321
+ border: 0;
322
+ border-radius: 10px;
323
+ padding: 14px 18px;
324
+ color: var(--text);
325
+ cursor: pointer;
326
+ transition: transform .16s ease, box-shadow .16s ease, opacity .16s ease, border-color .16s ease, background .16s ease;
327
+ }
328
  button:hover:not(:disabled) { transform: translateY(-1px); }
329
  button:disabled { opacity: .45; cursor: not-allowed; }
330
+ .primary-button {
331
+ min-width: 250px;
332
+ background: linear-gradient(135deg, #7048f5, #5129d9);
333
+ color: #fff;
334
+ font-weight: 690;
335
+ font-size: 18px;
336
+ box-shadow: 0 14px 34px rgba(81, 41, 217, .22);
337
+ }
338
+ .primary-button span { margin-right: 8px; }
339
+ .export-button, .secondary-button, .ghost-button {
340
+ border: 1px solid var(--line-strong);
341
+ background: #fff;
342
+ color: var(--text-soft);
343
+ font-weight: 610;
344
+ }
345
+ .export-button { min-height: 64px; font-size: 18px; }
346
+ .ghost-button { padding: 10px 13px; font-size: 14px; }
347
+ .full-width { width: 100%; margin-top: 16px; }
348
+ .ghost-button.active, .secondary-button.active {
349
+ border-color: rgba(100, 170, 69, .55);
350
+ background: rgba(100, 170, 69, .10);
351
+ color: #386b24;
352
+ }
353
+ .advanced-controls {
354
+ margin-top: 28px;
355
+ border-top: 1px solid var(--line);
356
+ padding-top: 18px;
357
+ }
358
+ .advanced-controls summary,
359
+ .utility-panel summary {
360
+ cursor: pointer;
361
+ color: var(--text-soft);
362
+ font-weight: 680;
363
+ }
364
+ .control-grid {
365
+ display: grid;
366
+ grid-template-columns: repeat(2, minmax(0, 1fr));
367
+ gap: 12px;
368
+ }
369
+ .compact-controls label {
370
+ font-size: 12px;
371
+ font-weight: 720;
372
+ letter-spacing: .01em;
373
+ }
374
+ .compact-controls input, .compact-controls select {
375
+ padding: 10px 11px;
376
+ font-size: 13px;
377
+ margin-top: 7px;
378
+ }
379
+ .toggles {
380
+ display: grid;
381
+ gap: 10px;
382
+ margin: 16px 0 0;
383
+ }
384
+ .toggles label {
385
+ display: flex;
386
+ align-items: center;
387
+ gap: 8px;
388
+ font-size: 13px;
389
+ font-weight: 650;
390
+ }
391
+ .toggles input { width: auto; margin: 0; }
392
+
393
+ .section-heading {
394
+ display: flex;
395
+ align-items: end;
396
+ justify-content: space-between;
397
+ gap: 20px;
398
+ margin: 10px 8px 20px;
399
+ }
400
+ h2 { margin: 0; font-size: 21px; letter-spacing: -.035em; font-weight: 620; }
401
+ .section-heading p { margin: 0; color: var(--muted); max-width: 680px; }
402
+ .sample-grid {
403
+ display: grid;
404
+ grid-template-columns: repeat(auto-fill, minmax(170px, 1fr));
405
+ gap: 34px;
406
+ }
407
+ .sample-card {
408
+ display: grid;
409
+ grid-template-rows: 130px auto;
410
+ min-height: 220px;
411
+ border: 1px solid var(--line);
412
+ border-top: 4px solid var(--accent);
413
+ border-radius: 10px;
414
+ background: #fff;
415
+ box-shadow: 0 10px 26px rgba(20, 22, 30, .04);
416
+ overflow: hidden;
417
+ text-align: left;
418
+ padding: 0;
419
+ }
420
+ .sample-card:hover { box-shadow: 0 16px 38px rgba(20, 22, 30, .08); }
421
+ .sample-card.selected { outline: 3px solid rgba(107, 63, 242, .14); }
422
+ .sample-wave {
423
+ width: 100%;
424
+ height: 130px;
425
+ display: block;
426
+ background: #fff;
427
+ }
428
+ .sample-card-footer {
429
+ display: grid;
430
+ grid-template-columns: 44px minmax(0, 1fr);
431
+ gap: 12px;
432
+ align-items: center;
433
+ padding: 14px 14px 16px;
434
+ }
435
+ .play-dot {
436
+ width: 44px;
437
+ height: 44px;
438
+ display: grid;
439
+ place-items: center;
440
+ border-radius: 999px;
441
+ background: #fff;
442
+ border: 1px solid var(--line);
443
+ color: var(--text);
444
+ font-size: 14px;
445
+ }
446
+ .sample-name {
447
+ display: block;
448
+ overflow: hidden;
449
+ text-overflow: ellipsis;
450
+ white-space: nowrap;
451
+ font-size: 17px;
452
+ font-weight: 570;
453
+ letter-spacing: -.02em;
454
+ }
455
+ .sample-meta {
456
+ display: block;
457
+ margin-top: 4px;
458
+ color: var(--muted);
459
+ font-size: 12px;
460
+ font-weight: 500;
461
+ }
462
+ .review-strip {
463
+ display: grid;
464
+ grid-template-columns: repeat(2, minmax(0, 1fr));
465
+ gap: 22px;
466
+ }
467
+ .review-card {
468
+ border: 1px solid var(--line);
469
+ border-radius: var(--radius-lg);
470
+ background: rgba(255, 255, 255, .82);
471
+ box-shadow: var(--shadow);
472
+ padding: 18px;
473
+ }
474
+ .review-card strong, .review-card span { display: block; }
475
+ .review-card span { color: var(--muted); font-size: 13px; margin-top: 5px; line-height: 1.4; }
476
+
477
+ .utility-panel {
478
+ padding: 18px 20px;
479
+ }
480
+ .utility-panel > summary {
481
+ display: grid;
482
+ grid-template-columns: minmax(0, 1fr);
483
+ gap: 4px;
484
+ list-style: none;
485
+ }
486
+ .utility-panel > summary::-webkit-details-marker { display: none; }
487
+ .utility-panel > summary span { font-size: 17px; color: var(--text); }
488
+ .utility-panel > summary small { color: var(--muted); font-weight: 500; }
489
+ .stage-list {
490
+ display: grid;
491
+ grid-template-columns: repeat(auto-fit, minmax(210px, 1fr));
492
+ gap: 10px;
493
+ margin-top: 16px;
494
+ }
495
+ .stage {
496
+ display: grid;
497
+ grid-template-columns: 20px 1fr auto;
498
+ gap: 10px;
499
+ align-items: center;
500
+ padding: 12px;
501
+ border: 1px solid var(--line);
502
+ border-radius: 14px;
503
+ background: var(--surface-soft);
504
+ }
505
+ .stage .badge { width: 14px; height: 14px; border-radius: 999px; background: #d7d9e1; }
506
+ .stage.running .badge { background: var(--accent); box-shadow: 0 0 0 5px rgba(107, 63, 242, .12); }
507
  .stage.done .badge { background: var(--good); }
508
  .stage.error .badge { background: var(--bad); }
509
+ .stage strong { display: block; font-size: 13px; color: var(--text); }
510
  .stage small { display: block; color: var(--muted); margin-top: 2px; }
511
+ .stage time { color: var(--text-soft); font-variant-numeric: tabular-nums; }
512
+ .logs {
513
+ min-height: 110px;
514
+ max-height: 240px;
515
+ overflow: auto;
516
+ border: 1px solid var(--line);
517
+ border-radius: 14px;
518
+ padding: 14px;
519
+ margin: 14px 0 0;
520
+ background: #fbfbfc;
521
+ color: #606571;
522
+ font-size: 12px;
523
+ line-height: 1.45;
524
+ white-space: pre-wrap;
525
+ }
526
+ .history-list { display: grid; gap: 8px; max-height: 360px; overflow: auto; margin-top: 14px; }
527
+ .history-row {
528
+ width: 100%;
529
+ display: grid;
530
+ grid-template-columns: minmax(0, 1fr) auto auto auto;
531
+ gap: 12px;
532
+ align-items: center;
533
+ text-align: left;
534
+ border: 1px solid var(--line);
535
+ background: #fff;
536
+ border-radius: 14px;
537
+ padding: 12px;
538
+ }
539
  .history-row strong { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--text); }
540
  .history-row small { display: block; color: var(--muted); margin-top: 3px; }
541
+ .history-row span:not(:first-child) { color: var(--text-soft); font-size: 12px; font-variant-numeric: tabular-nums; }
542
  .empty { color: var(--muted); margin: 0; }
543
+
544
  h3 { margin: 0 0 10px; font-size: 16px; letter-spacing: -.015em; }
545
  .subtle { margin: -4px 0 12px; color: var(--muted); font-size: 13px; }
546
+ .result-columns { display: grid; grid-template-columns: minmax(0, 1fr); gap: 20px; margin-top: 18px; }
 
 
 
 
547
  .hit-table-wrap { max-height: 420px; }
548
+ .table-wrap { overflow: auto; border: 1px solid var(--line); border-radius: 14px; }
549
+ table { width: 100%; border-collapse: collapse; min-width: 860px; background: #fff; }
550
+ th, td { text-align: left; padding: 12px 14px; border-bottom: 1px solid var(--line); font-size: 13px; }
551
+ th { position: sticky; top: 0; background: #fbfbfc; color: var(--muted); z-index: 1; }
552
+ td { color: var(--text-soft); }
553
+ tr:last-child td { border-bottom: 0; }
554
+ .mini-button {
555
+ padding: 7px 10px;
556
+ border-radius: 999px;
557
+ background: #fff;
558
+ border: 1px solid var(--line);
559
+ color: var(--text-soft);
560
+ font-size: 12px;
561
+ font-weight: 720;
562
+ }
563
+ tr.selected td { background: var(--accent-soft); }
564
  tr[data-hit-index] { cursor: pointer; }
565
+ tr[data-hit-index]:hover td { background: #f6f4ff; }
566
+ tr.low-confidence td { background: rgba(240,143,62,.06); }
567
+ tr.low-confidence.selected td { background: var(--accent-soft); }
568
+ tr.suppressed td { opacity: .62; text-decoration: line-through; }
569
+
570
+ .supervision-header {
571
+ display: flex;
572
+ align-items: flex-start;
573
+ justify-content: space-between;
574
+ gap: 16px;
575
+ margin: 18px 0 14px;
576
+ }
577
  .supervision-header h3, .supervision-grid h4 { margin: 0; }
578
  .supervision-actions, .row-actions { display: flex; gap: 8px; flex-wrap: wrap; justify-content: flex-end; }
579
+ .state-summary { display: flex; flex-wrap: wrap; gap: 8px; margin-bottom: 14px; color: var(--text-soft); }
580
+ .state-summary span {
581
+ border: 1px solid var(--line);
582
+ border-radius: 999px;
583
+ background: #fff;
584
+ padding: 7px 10px;
585
+ font-size: 12px;
586
+ font-weight: 760;
587
+ }
588
+ .supervision-tools {
589
+ display: grid;
590
+ grid-template-columns: minmax(220px, 1fr) repeat(7, auto);
591
+ gap: 10px;
592
+ align-items: end;
593
+ margin-bottom: 16px;
594
+ }
595
+ .supervision-tools label { font-size: 13px; font-weight: 720; }
596
+ .supervision-tools input, .supervision-tools select { font-size: 13px; padding: 10px 11px; }
597
+ .danger-button { border-color: rgba(217,87,103,.34); color: #973844; }
598
  .supervision-grid { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 12px; }
599
  .compact-list { display: grid; gap: 8px; max-height: 300px; overflow: auto; }
600
+ .compact-row, .suggestion-row, .log-row {
601
+ width: 100%;
602
+ display: grid;
603
+ grid-template-columns: minmax(0, 1fr) auto;
604
+ gap: 10px;
605
+ align-items: center;
606
+ border: 1px solid var(--line);
607
+ border-radius: 14px;
608
+ padding: 10px;
609
+ background: #fff;
610
+ color: var(--text);
611
+ text-align: left;
612
+ }
613
  .compact-row strong, .suggestion-row strong, .log-row strong { display: block; font-size: 13px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
614
  .compact-row small, .suggestion-row small, .log-row small { display: block; color: var(--muted); font-size: 11px; margin-top: 3px; line-height: 1.35; }
615
+ .compact-row.locked { border-color: rgba(100,170,69,.45); background: rgba(100,170,69,.08); }
616
+ .compact-row.suppressed { opacity: .62; text-decoration: line-through; }
617
+ .log-row.constraint { border-color: rgba(107,63,242,.22); }
618
+ .explanation {
619
+ min-height: 120px;
620
+ max-height: 320px;
621
+ overflow: auto;
622
+ border: 1px solid var(--line);
623
+ border-radius: 14px;
624
+ background: #fbfbfc;
625
+ color: #606571;
626
+ padding: 12px;
627
+ margin: 14px 0 0;
628
+ font-size: 12px;
629
+ line-height: 1.45;
630
+ }
631
+ .edited-downloads { margin: -4px 0 14px; padding: 0; }
632
  .edited-downloads:empty { display: none; }
633
+
634
+ @media (max-width: 1180px) {
635
+ .app-layout {
636
+ grid-template-columns: 1fr;
637
+ grid-template-areas:
638
+ "controls"
639
+ "wave"
640
+ "samples"
641
+ "review"
642
+ "progress"
643
+ "history"
644
+ "supervision"
645
+ "data";
646
+ }
647
+ .control-card, .wave-card { min-height: 0; }
648
+ .control-card { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 18px; }
649
+ .control-group, .primary-actions-stack, .advanced-controls { margin: 0; }
650
+ .advanced-controls { grid-column: 1 / -1; }
651
+ }
652
+ @media (max-width: 760px) {
653
+ .shell { width: min(100% - 24px, 1760px); padding-top: 20px; }
654
+ .topbar, .section-heading, .supervision-header { display: block; }
655
+ .topbar-actions { margin-top: 18px; justify-content: stretch; width: 100%; }
656
+ .primary-button { width: 100%; min-width: 0; }
657
+ .file-chip { min-width: 0; width: 100%; gap: 12px; }
658
+ .wave-header, .transport-row, .downloads { padding-left: 18px; padding-right: 18px; }
659
+ .waveform { height: 300px; min-height: 300px; }
660
+ .transport-row, .review-strip, .control-card, .control-grid, .supervision-tools, .supervision-grid { grid-template-columns: 1fr; }
661
+ .sample-grid { grid-template-columns: repeat(auto-fill, minmax(145px, 1fr)); gap: 18px; }
662
+ .history-row { grid-template-columns: 1fr 1fr; }
663
+ }
664
+ #sourcePreview { grid-column: 1 / -1; }
665
+ .downloads:empty { display: none; }