fix: clip queue resolves row index by filename, not stored _idx

clipQueue entries snapshotted fileIdx (= _idx) when the queue was armed,
but _idx is reassigned positionally on every load(), and pollStatus()
reloads the list whenever a recording starts, stops, or rolls over to a
new split file. With the newest-first sort a rollover shifts every index
by one, so an armed queue then pre-filled the wrong row's cut fields and
"Open in file" (O) opened the wrong row while streaming the clip's file
into it — during exactly the workflow of reviewing yesterday while
today's recording runs.

Store only the filename in queue entries and resolve the row index at
use time (fileIdxByName); if the file left the listing, Open in file
does nothing instead of acting on a wrong row.

Also drop seekToSection's sectionIdx parameter: the only remaining
caller passed null, leaving the announce branch dead since chips moved
to clip playback.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-02 00:00:51 +02:00
co-authored by Claude Fable 5
parent 89c95a70a2
commit 24569efe20
2 changed files with 15 additions and 11 deletions
+1 -1
View File
@@ -38,7 +38,7 @@ Dependencies: `requests` (streams), `numpy` + `soundfile` (FLAC output and FLAC
- Serving: `_stream()` (Range support), `_copy_to_response()`, `_safe_path()` (path traversal guard). - Serving: `_stream()` (Range support), `_copy_to_response()`, `_safe_path()` (path traversal guard).
`webui.html` (one `<script>` block): `webui.html` (one `<script>` block):
- Clip review: `clipQueue`/`clipCursor`/`scoreCursor` globals, `playClip()`, `playFileSection()`, `hideClipBar()`; markup is the `#clip-bar` div. The clip label shows wall-clock occurrence time + dB prominence + position (`17:09:56 to 17:09:57 · +30 dB (30 / 426 by time)`): queue entries carry `absStart` (epoch s), derived from `fileStartEpoch(f.date)` — the filename clock — with in-file offsets as fallback for non-standard names; only the filename/in-file offset lives in the tooltip now. **`playClip()` builds one `text` string used for both `label.textContent` and the `announce()` aria-live message** — they must never diverge (a past bug had the announcement read `Clip N of M: …` while the label read the wall-clock form). **Two cursors, deliberately independent:** `clipCursor` is the time-order position and follows every play; `scoreCursor` is the loudness walk's own position in `scoreOrder()` and is moved *only* by U/I (and "highlights only" auto-advance) in `stepClip`'s by-score branch — J/K must never touch it, so a J/K detour to review nearby clips leaves the loudness walk's place intact and the next U/I resumes the ranking where it left off. `scoreCursor` resets to `-1` on every explicit jump / queue (re-)arm (`hideClipBar`, `playFileSection`, `jumpToDaySection`, both day-highlights arm points), so the next U/I re-anchors on `clipCursor` (the section just selected) else the loudest. `playClip(i, byScore)`: when `byScore` the count shows `scoreCursor` (rank, suffix "by loudness"), else `i` (time-order index, suffix "by time") — the suffix tells the blind user which dimension the count is in. `stepClip`'s by-score branch passes `byScore=true`; every other caller (chip click, J/K, Prev/Next) leaves it false. - Clip review: `clipQueue`/`clipCursor`/`scoreCursor` globals, `playClip()`, `playFileSection()`, `hideClipBar()`; markup is the `#clip-bar` div. The clip label shows wall-clock occurrence time + dB prominence + position (`17:09:56 to 17:09:57 · +30 dB (30 / 426 by time)`): queue entries carry `absStart` (epoch s), derived from `fileStartEpoch(f.date)` — the filename clock — with in-file offsets as fallback for non-standard names; only the filename/in-file offset lives in the tooltip now. **Queue entries carry the filename, never a row index:** `_idx` is reassigned positionally on every `load()`, and `pollStatus()` reloads whenever a recording starts/stops/rolls over, so a stored index goes stale mid-review (a past bug had "Open in file"/cut fields target the row one off after a split rollover). Resolve at use time via `fileIdxByName()`. **`playClip()` builds one `text` string used for both `label.textContent` and the `announce()` aria-live message** — they must never diverge (a past bug had the announcement read `Clip N of M: …` while the label read the wall-clock form). **Two cursors, deliberately independent:** `clipCursor` is the time-order position and follows every play; `scoreCursor` is the loudness walk's own position in `scoreOrder()` and is moved *only* by U/I (and "highlights only" auto-advance) in `stepClip`'s by-score branch — J/K must never touch it, so a J/K detour to review nearby clips leaves the loudness walk's place intact and the next U/I resumes the ranking where it left off. `scoreCursor` resets to `-1` on every explicit jump / queue (re-)arm (`hideClipBar`, `playFileSection`, `jumpToDaySection`, both day-highlights arm points), so the next U/I re-anchors on `clipCursor` (the section just selected) else the loudest. `playClip(i, byScore)`: when `byScore` the count shows `scoreCursor` (rank, suffix "by loudness"), else `i` (time-order index, suffix "by time") — the suffix tells the blind user which dimension the count is in. `stepClip`'s by-score branch passes `byScore=true`; every other caller (chip click, J/K, Prev/Next) leaves it false.
- Day review: `dayHighlights()` builds `dayActiveSections` (chronological); `jumpToDaySection()` arms the queue. Section `absStart` comes from `fileStartEpoch(f.date)` (filename clock), mtime−duration only as fallback. **The user is blind and uses a screen reader — there is deliberately no day-timeline SVG** (one existed and was removed on request as useless); the highlights panel is linear text/buttons: summary line → key-hint note → chips toggle → chips. Do not add decorative visualizations; any future graphic must be aria-hidden and must not be the only carrier of information. Chip lists longer than 12 are collapsed behind an `aria-expanded` toggle button (the `.chips[hidden]{display:none}` rule is required — the author-level `display:flex` on `.chips` would otherwise override the UA `[hidden]` rule). Group `aria-label`s stay short ("Day loud sections") — the J/K/U/I key explanation lives only in the visible note, per user feedback against repeating info text in labels. The Highlights button is a collapse/expand toggle (`setHlExpanded()` keeps arrow + `aria-expanded` in sync, also from the day-collapse path): a built panel is kept and re-armed from `dayHlSections` instead of recomputing, keyed by `hlRow.dataset.loaded = hlParams()` (margin|gap|minDur string) so changed params force a re-run. The `#dayhls-<dayId>` "· analysed" suffix appears when every file's `cached_analysis` passes `cachedParamsMatch()`; `fetchAnalysis()` updates `f.cached_analysis` client-side so the marker survives re-renders without refetching `/api/files`. **`aria-label` on a button replaces its visible content for screen readers** — any status text rendered inside a labelled button (like the analysed suffix) must be mirrored into the label (render path + the end of `dayHighlights()`), or the blind user never hears it. Likewise, the accessible text of a button built as `aria-hidden arrow span + text node` must not start the text node with a space (the hidden arrow drops out and the leading space reads as an indent) — keep separator spaces inside the aria-hidden span. - Day review: `dayHighlights()` builds `dayActiveSections` (chronological); `jumpToDaySection()` arms the queue. Section `absStart` comes from `fileStartEpoch(f.date)` (filename clock), mtime−duration only as fallback. **The user is blind and uses a screen reader — there is deliberately no day-timeline SVG** (one existed and was removed on request as useless); the highlights panel is linear text/buttons: summary line → key-hint note → chips toggle → chips. Do not add decorative visualizations; any future graphic must be aria-hidden and must not be the only carrier of information. Chip lists longer than 12 are collapsed behind an `aria-expanded` toggle button (the `.chips[hidden]{display:none}` rule is required — the author-level `display:flex` on `.chips` would otherwise override the UA `[hidden]` rule). Group `aria-label`s stay short ("Day loud sections") — the J/K/U/I key explanation lives only in the visible note, per user feedback against repeating info text in labels. The Highlights button is a collapse/expand toggle (`setHlExpanded()` keeps arrow + `aria-expanded` in sync, also from the day-collapse path): a built panel is kept and re-armed from `dayHlSections` instead of recomputing, keyed by `hlRow.dataset.loaded = hlParams()` (margin|gap|minDur string) so changed params force a re-run. The `#dayhls-<dayId>` "· analysed" suffix appears when every file's `cached_analysis` passes `cachedParamsMatch()`; `fetchAnalysis()` updates `f.cached_analysis` client-side so the marker survives re-renders without refetching `/api/files`. **`aria-label` on a button replaces its visible content for screen readers** — any status text rendered inside a labelled button (like the analysed suffix) must be mirrored into the label (render path + the end of `dayHighlights()`), or the blind user never hears it. Likewise, the accessible text of a button built as `aria-hidden arrow span + text node` must not start the text node with a space (the hidden arrow drops out and the leading space reads as an indent) — keep separator spaces inside the aria-hidden span.
- J/K/U/I/O: single document-level `keydown` listener — clip queue takes priority, in-player `currentTime` stepping is the fallback when no queue is armed; O calls `openClipInFile()` (shared with the "Open in file" button). J/K (and Prev/Next) always step in time order; U/I walk the loudest-first ranking from `scoreOrder()` — no top-N cutoff (the `#clip-top` input and `#clip-hl-only` checkbox were removed deliberately; J/K must never be affected by an auto-advance/highlights setting). Auto-advance is the `input[name="clip-adv"]` radio (off / next in time / next by loudness), read by `advanceMode()`; `stepClip(dir, byScore)` is the shared queue-stepping path. In-player U/I anchor the ranking on the section under the playhead, else start at the loudest. - J/K/U/I/O: single document-level `keydown` listener — clip queue takes priority, in-player `currentTime` stepping is the fallback when no queue is armed; O calls `openClipInFile()` (shared with the "Open in file" button). J/K (and Prev/Next) always step in time order; U/I walk the loudest-first ranking from `scoreOrder()` — no top-N cutoff (the `#clip-top` input and `#clip-hl-only` checkbox were removed deliberately; J/K must never be affected by an auto-advance/highlights setting). Auto-advance is the `input[name="clip-adv"]` radio (off / next in time / next by loudness), read by `advanceMode()`; `stepClip(dir, byScore)` is the shared queue-stepping path. In-player U/I anchor the ranking on the section under the playhead, else start at the loudest.
- Analysis: `fetchAnalysis()` (session `analysisCache`), `analyse()` (per-row render: meta line with section count + params, then chips — no waveform SVG, see day-review note on the blind user), `cachedParamsMatch()` (autoload guard). - Analysis: `fetchAnalysis()` (session `analysisCache`), `analyse()` (per-row render: meta line with section count + params, then chips — no waveform SVG, see day-review note on the blind user), `cachedParamsMatch()` (autoload guard).
+14 -10
View File
@@ -235,6 +235,12 @@ function setCutFields(idx, startSec, endSec) {
if (endEl && endSec != null) endEl.value = fmtDur(endSec); if (endEl && endSec != null) endEl.value = fmtDur(endSec);
} }
// Row index for a filename, resolved at use time: _idx values are reassigned
// positionally on every load(), and pollStatus() reloads the list whenever a
// recording starts/stops/rolls over — a stored index would point one row off
// after that. Null when the file is gone from the current listing.
const fileIdxByName = name => allFiles.find(f => f.name === name)?._idx ?? null;
// idx -> filename, for live-status polling // idx -> filename, for live-status polling
const recMap = new Map(); const recMap = new Map();
// idx -> [{start,end}], populated after analysis // idx -> [{start,end}], populated after analysis
@@ -325,7 +331,7 @@ function parseTime(s) {
return parts[0]; return parts[0];
} }
function seekToSection(idx, filename, startSec, endSec, sectionIdx) { function seekToSection(idx, filename, startSec, endSec) {
const pbtn = document.getElementById('pbtn-'+idx); const pbtn = document.getElementById('pbtn-'+idx);
if (pbtn.getAttribute('aria-expanded') !== 'true') togglePlayer(idx, filename); if (pbtn.getAttribute('aria-expanded') !== 'true') togglePlayer(idx, filename);
activePlayerIdx = idx; activePlayerIdx = idx;
@@ -336,10 +342,6 @@ function seekToSection(idx, filename, startSec, endSec, sectionIdx) {
if (audio.readyState >= 1) doSeek(); if (audio.readyState >= 1) doSeek();
else audio.addEventListener('loadedmetadata', doSeek, {once: true}); else audio.addEventListener('loadedmetadata', doSeek, {once: true});
setCutFields(idx, startSec, endSec); setCutFields(idx, startSec, endSec);
if (sectionIdx != null) {
const total = (sectionMap.get(idx) || []).length;
announce(`Section ${sectionIdx + 1} of ${total}: ${fmtDur(startSec)} to ${fmtDur(endSec)}`);
}
} }
// --- clip player ----------------------------------------------------------- // --- clip player -----------------------------------------------------------
@@ -390,7 +392,8 @@ function playClip(i, byScore) {
label.title = `${c.filename} @ ${fmtDur(c.start)}–${fmtDur(c.end)}`; label.title = `${c.filename} @ ${fmtDur(c.start)}–${fmtDur(c.end)}`;
document.getElementById('clip-bar').hidden = false; document.getElementById('clip-bar').hidden = false;
document.body.classList.add('clip-open'); document.body.classList.add('clip-open');
setCutFields(c.fileIdx, c.start, c.end); const rowIdx = fileIdxByName(c.filename);
if (rowIdx != null) setCutFields(rowIdx, c.start, c.end);
announce(text); announce(text);
} }
@@ -409,7 +412,7 @@ function playFileSection(idx, filename, si) {
const secs = sectionMap.get(idx) || []; const secs = sectionMap.get(idx) || [];
const f = allFiles.find(f => f._idx === idx); const f = allFiles.find(f => f._idx === idx);
const epoch = f ? fileStartEpoch(f.date) : null; const epoch = f ? fileStartEpoch(f.date) : null;
clipQueue = secs.map(s => ({fileIdx: idx, filename, start: s.start, end: s.end, score: s.score, clipQueue = secs.map(s => ({filename, start: s.start, end: s.end, score: s.score,
absStart: epoch != null ? epoch + s.start : null})); absStart: epoch != null ? epoch + s.start : null}));
scoreCursor = -1; // explicit jump: next U/I re-anchors here scoreCursor = -1; // explicit jump: next U/I re-anchors here
playClip(si); playClip(si);
@@ -468,15 +471,17 @@ document.getElementById('clip-audio').addEventListener('ended', () => {
function openClipInFile() { function openClipInFile() {
const c = clipQueue[clipCursor]; const c = clipQueue[clipCursor];
if (!c) return; if (!c) return;
const idx = fileIdxByName(c.filename);
if (idx == null) return;
document.getElementById('clip-audio').pause(); document.getElementById('clip-audio').pause();
const row = document.getElementById('row-' + c.fileIdx); const row = document.getElementById('row-' + idx);
if (row) { if (row) {
const tbl = row.closest('table'); const tbl = row.closest('table');
if (tbl && tbl.hidden) // file's day is collapsed: expand it if (tbl && tbl.hidden) // file's day is collapsed: expand it
document.getElementById('daytgl-' + tbl.id.slice('daytbl-'.length))?.click(); document.getElementById('daytgl-' + tbl.id.slice('daytbl-'.length))?.click();
row.scrollIntoView({block: 'center'}); row.scrollIntoView({block: 'center'});
} }
seekToSection(c.fileIdx, c.filename, c.start, c.end, null); seekToSection(idx, c.filename, c.start, c.end);
} }
document.getElementById('clip-context').addEventListener('click', openClipInFile); document.getElementById('clip-context').addEventListener('click', openClipInFile);
@@ -1024,7 +1029,6 @@ async function dayHighlights(dayId, analyzableFiles) {
positioned.forEach(({ f, data, fileStart }) => { positioned.forEach(({ f, data, fileStart }) => {
(data.sections || []).forEach(s => { (data.sections || []).forEach(s => {
dayActiveSections.push({ dayActiveSections.push({
fileIdx: f._idx,
filename: f.name, filename: f.name,
start: s.start, start: s.start,
end: s.end, end: s.end,