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:
@@ -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).
|
||||
|
||||
`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.
|
||||
- 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).
|
||||
|
||||
+14
-10
@@ -235,6 +235,12 @@ function setCutFields(idx, startSec, 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
|
||||
const recMap = new Map();
|
||||
// idx -> [{start,end}], populated after analysis
|
||||
@@ -325,7 +331,7 @@ function parseTime(s) {
|
||||
return parts[0];
|
||||
}
|
||||
|
||||
function seekToSection(idx, filename, startSec, endSec, sectionIdx) {
|
||||
function seekToSection(idx, filename, startSec, endSec) {
|
||||
const pbtn = document.getElementById('pbtn-'+idx);
|
||||
if (pbtn.getAttribute('aria-expanded') !== 'true') togglePlayer(idx, filename);
|
||||
activePlayerIdx = idx;
|
||||
@@ -336,10 +342,6 @@ function seekToSection(idx, filename, startSec, endSec, sectionIdx) {
|
||||
if (audio.readyState >= 1) doSeek();
|
||||
else audio.addEventListener('loadedmetadata', doSeek, {once: true});
|
||||
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 -----------------------------------------------------------
|
||||
@@ -390,7 +392,8 @@ function playClip(i, byScore) {
|
||||
label.title = `${c.filename} @ ${fmtDur(c.start)}–${fmtDur(c.end)}`;
|
||||
document.getElementById('clip-bar').hidden = false;
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -409,7 +412,7 @@ function playFileSection(idx, filename, si) {
|
||||
const secs = sectionMap.get(idx) || [];
|
||||
const f = allFiles.find(f => f._idx === idx);
|
||||
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}));
|
||||
scoreCursor = -1; // explicit jump: next U/I re-anchors here
|
||||
playClip(si);
|
||||
@@ -468,15 +471,17 @@ document.getElementById('clip-audio').addEventListener('ended', () => {
|
||||
function openClipInFile() {
|
||||
const c = clipQueue[clipCursor];
|
||||
if (!c) return;
|
||||
const idx = fileIdxByName(c.filename);
|
||||
if (idx == null) return;
|
||||
document.getElementById('clip-audio').pause();
|
||||
const row = document.getElementById('row-' + c.fileIdx);
|
||||
const row = document.getElementById('row-' + idx);
|
||||
if (row) {
|
||||
const tbl = row.closest('table');
|
||||
if (tbl && tbl.hidden) // file's day is collapsed: expand it
|
||||
document.getElementById('daytgl-' + tbl.id.slice('daytbl-'.length))?.click();
|
||||
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);
|
||||
|
||||
@@ -1024,7 +1029,6 @@ async function dayHighlights(dayId, analyzableFiles) {
|
||||
positioned.forEach(({ f, data, fileStart }) => {
|
||||
(data.sections || []).forEach(s => {
|
||||
dayActiveSections.push({
|
||||
fileIdx: f._idx,
|
||||
filename: f.name,
|
||||
start: s.start,
|
||||
end: s.end,
|
||||
|
||||
Reference in New Issue
Block a user