diff --git a/CLAUDE.md b/CLAUDE.md index 4af8041..56ba0c4 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -5,17 +5,17 @@ Guidance for Claude Code when working in this repository. ## Rules - **Always update `README.md`** when user-facing behaviour changes (flags, endpoints, Docker setup, features), and **commit it in the same commit** as the code change. README is the external reference; CLAUDE.md documents internals. -- Run `python -m pytest tests/` after changing `isr.py`, `web.py` or `isrweb/` (tests cover the recorder, the detector, the filename clock and the HTTP layer). Test files mirror the modules: `test_isr.py`, `test_loudness.py`, `test_audiofile.py`, `test_web.py`; `tests/conftest.py` owns the sys.path setup and the `write_wav` fixture. +- Run `python -m pytest tests/` after changing `isr.py`, `web.py`, `isrweb/` or `webui/js/` (tests cover the recorder, the detector, the filename clock and the HTTP layer). Test files mirror the modules: `test_isr.py`, `test_loudness.py`, `test_audiofile.py`, `test_web.py`; `tests/conftest.py` owns the sys.path setup and the `write_wav` fixture. ## Files | File | Purpose | |------|---------| | `isr.py` | Recorder: streams (Icecast/HTTP) + ALSA soundcards, time-aligned file splits | -| `web.py` | Archive browser: HTTP server, file listing, analysis cache, cut/delete. Entry point — stays at the repo root so `python web.py` and the `webui.html` lookup work | +| `web.py` | Archive browser: HTTP server, file listing, analysis cache, cut/delete. Entry point — stays at the repo root so `python web.py` and the `webui/` lookup work | | `isrweb/loudness.py` | The adaptive loud-section detector. Pure computation, imports nothing from the app — future spectral/VAD work lands here and never touches HTTP | | `isrweb/audiofile.py` | What an audio file is and what its name means: the filename clock, FLAC/WAV header parsing, live-header patching. Also a leaf | -| `webui.html` | Single-page UI (HTML/CSS/JS), loaded by `web.py` at startup — must sit next to `web.py` and be copied in the Dockerfile | +| `webui/` | Browser UI: `index.html`, `style.css`, ES modules in `js/` (no build step). Served by `web.py` (`/` and `/ui/`) — must sit next to `web.py` and be copied in the Dockerfile | | `config.ini` | Recording sources; copy from `config.example.ini`. `[general]` gives defaults, every other section is a source (`type = stream` or `type = soundcard`) | | `asound.conf` | dsnoop device `shared_mic` so ISR and other ALSA apps can share a soundcard | @@ -43,18 +43,29 @@ Dependencies: `requests` (streams), `numpy` + `soundfile` (FLAC output and FLAC `web.py`: - Clips: `_api_clip()` validates params, `_clip_wav()` / `_clip_flac()` stream the decoded slice. -- Serving: `_serve_bytes()` is the single Range-capable file sender — `_stream()` (live header patch via its `prefix` argument, `no_store`) and `_download()` (`Content-Disposition`) both go through it, and it is what makes `do_HEAD` a body-less `do_GET`. Plus `_copy_to_response()`, `_safe_path()` (path traversal guard). +- Serving: `_serve_bytes()` is the single Range-capable file sender — `_stream()` (live header patch via its `prefix` argument, `no_store`) and `_download()` (`Content-Disposition`) both go through it, and it is what makes `do_HEAD` a body-less `do_GET`. Plus `_copy_to_response()`, `_safe_path()` (path traversal guard). `_ui()` serves `webui/` (same containment check, html/css/js only, read per request so UI edits need no restart). -`webui.html` (one ` - - \ No newline at end of file diff --git a/webui/index.html b/webui/index.html new file mode 100644 index 0000000..d873202 --- /dev/null +++ b/webui/index.html @@ -0,0 +1,78 @@ + + + + + + +ISR Archive + + + + +
+ + +
+

ISR Archive

+

Loading…

+

+ +
+ +
+
+ Settings +
+ + + dB above the background noise for a section to count as loud + + + + seconds; loud sections closer than this are merged + + + + seconds; shorter loud sections are ignored + + + + seconds played before each section starts +
+
+ + + +
+ +
+ + + + diff --git a/webui/js/analysis.js b/webui/js/analysis.js new file mode 100644 index 0000000..4e59e86 --- /dev/null +++ b/webui/js/analysis.js @@ -0,0 +1,145 @@ +// Per-file loudness analysis: fetching (with a session cache) and the result +// cell — a meta line, the section chips, a Re-analyse button. + +import {fmtDur, fmtClock, fileStartEpoch, announce, chipToggle} from './util.js'; +import * as api from './api.js'; +import {fileByName, fileByIdx, fileSpan, sectionMap} from './store.js'; +import {analysisParams} from './settings.js'; +import {playFrom} from './clips.js'; + +// filename|margin|gap|minDur -> analysis result, so re-renders (filtering, +// refresh) never refetch what this session already has +const analysisCache = new Map(); + +export async function fetchAnalysis(filename, params, force = false) { + const {margin, minGap, minDur} = params; + const key = `${filename}|${margin}|${minGap}|${minDur}`; + if (!force && analysisCache.has(key)) return analysisCache.get(key); + const d = await api.analyze(filename, params); + if (!d.error) { + analysisCache.set(key, d); + // Keep the listing's cache info current so re-renders (filtering) still + // know the file is analysed without refetching /api/files + const f = fileByName(filename); + if (f) f.cached_analysis = {margin, min_gap: minGap, min_duration: minDur}; + } + return d; +} + +export function forgetAnalyses(filename) { + for (const k of [...analysisCache.keys()]) + if (k.startsWith(filename + '|')) analysisCache.delete(k); +} + +export function playFileSection(idx, si) { + const f = fileByIdx(idx); + if (!f) return; + const epoch = fileStartEpoch(f.date); + const queue = (sectionMap.get(idx) || []).map(s => ({ + filename: f.name, start: s.start, end: s.end, score: s.score, + absStart: epoch != null ? epoch + s.start : null, + })); + playFrom(queue, si); +} + +export function analyseButton(idx, cell, text) { + const f = fileByIdx(idx); + const b = document.createElement('button'); + b.textContent = text; + b.setAttribute('aria-label', `${text} ${f ? fileSpan(f) : ''}`); + b.addEventListener('click', () => + analyse(idx, cell, b, {force: text === 'Re-analyse', speak: true})); + return b; +} + +const para = (cls, text) => { + const p = document.createElement('p'); + p.className = cls; + p.textContent = text; + return p; +}; + +// btn is the button that started it (optional). It stays in place and +// focusable while busy — disabling or replacing a focused button drops the +// screen reader at the top of the page. speak: the user asked for this one, so +// say how it went; background autoloads stay quiet. +export async function analyse(idx, cell, btn, {force = false, speak = false} = {}) { + const f = fileByIdx(idx); + if (!f || cell.dataset.busy) return; + cell.dataset.busy = '1'; + const span = fileSpan(f); + if (btn) { + btn.textContent = 'Analysing…'; + btn.setAttribute('aria-label', `Analysing ${span}`); + btn.setAttribute('aria-disabled', 'true'); + } else { + cell.replaceChildren(para('quiet', 'Analysing…')); + } + const params = analysisParams(); + let d; + try { + d = await fetchAnalysis(f.name, params, force); + } catch (e) { + d = {error: e.message}; + } + delete cell.dataset.busy; + // Focus inside the cell is about to be destroyed: move it to the new content + const hadFocus = cell.contains(document.activeElement); + + if (d.error) { + const b = analyseButton(idx, cell, 'Analyse'); + cell.replaceChildren(para('quiet', `Error: ${d.error}`), b); + if (hadFocus) b.focus(); + if (speak) announce(`Analysis of ${span} failed: ${d.error}`); + return; + } + + const secs = d.sections || []; + const n = secs.length; + const box = document.createElement('div'); + box.className = 'wbox'; + box.append(para('analysis-meta', `${n} loud section${n !== 1 ? 's' : ''}` + + ` · margin ${params.margin} dB · gap ${params.minGap} s · min ${params.minDur} s` + + (d.cached ? ' · cached' : ''))); + + if (n) { + sectionMap.set(idx, secs); + const epoch = fileStartEpoch(f.date); + const chips = document.createElement('div'); + chips.className = 'chips'; + chips.setAttribute('role', 'group'); + chips.setAttribute('aria-label', 'Loud sections'); + secs.forEach((s, si) => { + const c = document.createElement('button'); + c.className = 'chip'; + // Wall-clock time like the day highlights and the clip label; the + // in-file offset only matters for cutting and lives in the tooltip + c.textContent = (epoch != null ? fmtClock(epoch + s.start) : `${fmtDur(s.start)} – ${fmtDur(s.end)}`) + + (s.score != null ? ` · +${Math.round(s.score)} dB` : ''); + c.title = `${fmtDur(s.start)}–${fmtDur(s.end)} into ${f.name}`; + c.addEventListener('click', () => playFileSection(idx, si)); + chips.appendChild(c); + }); + const tog = chipToggle(chips, `${n} sections`); + if (tog) box.append(tog); + box.append(chips); + } else { + sectionMap.delete(idx); + } + box.append(analyseButton(idx, cell, 'Re-analyse')); + box.lastChild.classList.add('reanalyse-btn'); + + cell.replaceChildren(box); + // First control in reading order: the first chip, the chip toggle when the + // list is collapsed, or Re-analyse when there are no sections + if (hadFocus) box.querySelector('button').focus(); + if (speak) announce(n ? `${n} loud section${n !== 1 ? 's' : ''} in ${span}` : `No loud sections in ${span}`); +} + +// Run the deferred analyses of a freshly expanded day +export function autoloadDayAnalyses(dayEl) { + dayEl.querySelectorAll('td[data-autoload]').forEach(cell => { + cell.removeAttribute('data-autoload'); + analyse(Number(cell.dataset.idx), cell, cell.querySelector('button')); + }); +} diff --git a/webui/js/api.js b/webui/js/api.js new file mode 100644 index 0000000..50ba86c --- /dev/null +++ b/webui/js/api.js @@ -0,0 +1,30 @@ +// Every URL the UI talks to, in one place. A leaf module. + +const q = encodeURIComponent; + +async function getJson(url) { + return (await fetch(url)).json(); +} + +export const getConfig = () => getJson('/api/config'); +export const getFiles = () => getJson('/api/files'); +export const getStatus = () => getJson('/api/status'); +export const getStorage = () => getJson('/api/storage'); + +export const analyze = (file, {margin, minGap, minDur}) => + getJson(`/api/analyze?file=${q(file)}&margin=${q(margin)}` + + `&min_gap=${q(minGap)}&min_duration=${q(minDur)}`); + +export async function deleteFile(file) { + const r = await fetch('/api/files/' + q(file), {method: 'DELETE'}); + if (!r.ok) { + const d = await r.json().catch(() => ({})); + throw new Error(d.error || r.statusText); + } +} + +export const clipUrl = (file, start, end) => + `/api/clip?file=${q(file)}&start=${start.toFixed(1)}&end=${end.toFixed(1)}`; +export const cutUrl = (file, start, end) => `/api/cut?file=${q(file)}&start=${start}&end=${end}`; +export const streamUrl = file => '/stream/' + q(file); +export const downloadUrl = file => '/download/' + q(file); diff --git a/webui/js/clips.js b/webui/js/clips.js new file mode 100644 index 0000000..497fbac --- /dev/null +++ b/webui/js/clips.js @@ -0,0 +1,193 @@ +// The clip player: sections play as small server-rendered WAV clips +// (/api/clip) in the bottom bar instead of seeking the full recording, which +// is slow for big FLACs. The queue holds the active review list (one file's +// sections, or a whole day's); J/K and Prev/Next step through it in time +// order, U/I step through the loudest-first ranking. The auto-advance radio +// picks what plays when a clip ends: nothing, the next in time, or the next +// by loudness. +// +// Two cursors, deliberately independent. clipCursor is the time-order position +// and follows every play (chip click, J/K, U/I). scoreCursor is the loudness +// walk's own position in scoreOrder(): only U/I (and "next loudest" auto- +// advance) move it; J/K detours to review nearby clips leave it alone, so +// coming back to U/I resumes the ranking exactly where you left it. An explicit +// jump (chip click, new queue) resets it to -1 so the next U/I re-anchors there. + +import {$, fmtDur, fmtClock, announce} from './util.js'; +import {fileIdxByName} from './store.js'; +import {advanceMode, getPreroll} from './settings.js'; +import {clipUrl} from './api.js'; +import {setCutFields, seekToSection} from './player.js'; + +let clipQueue = []; +let clipCursor = -1; +let scoreCursor = -1; +// Where focus was when the bar opened, so closing it doesn't drop the user at +// the top of the page +let returnFocus = null; + +export const hasQueue = () => clipQueue.length > 0; + +// byScore: this play is part of a loudness walk (U/I or "next loudest" +// auto-advance), so the position shown is scoreCursor — the rank in the +// loudest-first ranking, labelled "by loudness" — not the time-order index. +export function playClip(i, byScore) { + if (i < 0 || i >= clipQueue.length) return; + clipCursor = i; + const c = clipQueue[i]; + const cs = Math.max(0, c.start - getPreroll()); + const ce = c.end + 1.5; // small tail after the section + const a = $('clip-audio'); + a.src = clipUrl(c.filename, cs, ce); + a.play().catch(() => {}); + // Label = wall-clock time of occurrence (absStart from the filename clock); + // falls back to in-file offsets for non-standard filenames. The visible + // label, the screen-reader announcement and the lock-screen title use one + // identical string: "17:09:56 to 17:09:57 · +30 dB (30 / 426 by time)" — + // the count is the time-order index "by time" (J/K) or the loudness rank + // "by loudness" (U/I). + const when = c.absStart != null + ? `${fmtClock(c.absStart)} to ${fmtClock(c.absStart + (c.end - c.start))}` + : `${fmtDur(c.start)} to ${fmtDur(c.end)}`; + const score = c.score != null ? ` · +${Math.round(c.score)} dB` : ''; + const pos = byScore ? scoreCursor : i; + const dim = byScore ? 'by loudness' : 'by time'; + const text = `${when}${score} (${pos + 1} / ${clipQueue.length} ${dim})`; + const label = $('clip-label'); + label.textContent = text; + label.title = `${c.filename} @ ${fmtDur(c.start)}–${fmtDur(c.end)}`; + + const bar = $('clip-bar'); + const ae = document.activeElement; + if (ae && ae !== document.body && !bar.contains(ae)) returnFocus = ae; + bar.hidden = false; + document.body.classList.add('clip-open'); + const rowIdx = fileIdxByName(c.filename); + if (rowIdx != null) setCutFields(rowIdx, c.start, c.end); + if ('mediaSession' in navigator && 'MediaMetadata' in window) + navigator.mediaSession.metadata = new MediaMetadata({title: text, artist: 'ISR', album: c.filename}); + announce(text); +} + +export function hideClipBar() { + const a = $('clip-audio'); + a.pause(); + a.removeAttribute('src'); + $('clip-bar').hidden = true; + document.body.classList.remove('clip-open'); + if ('mediaSession' in navigator) navigator.mediaSession.metadata = null; + clipQueue = []; + clipCursor = -1; + scoreCursor = -1; +} + +// Point the queue at a new section list without playing: nothing is selected +// yet, and the next U/I must re-anchor here rather than resume a walk through +// the previous queue. +export function armQueue(sections) { + clipQueue = sections; + clipCursor = -1; + scoreCursor = -1; +} + +// Explicit jump (chip click): play section i of sections; the next U/I +// re-anchors on it +export function playFrom(sections, i) { + clipQueue = sections; + scoreCursor = -1; + playClip(i); +} + +// Highlight order = section indices sorted loudest-first. U/I (and "next +// loudest" auto-advance) walk this ranking, so a review runs from the most to +// the least interesting event until the user stops — no top-N cutoff. +// Cached against the section array's identity: a day queue runs to thousands +// of entries and U/I would otherwise re-sort all of them on every keypress. +// Every arming path assigns a fresh array, so identity is a safe key. +let scoreOrderCache = {secs: null, order: null}; +export function scoreOrder(secs) { + if (scoreOrderCache.secs === secs) return scoreOrderCache.order; + const order = secs.map((s, i) => ({i, score: s.score || 0})) + .sort((a, b) => b.score - a.score) + .map(r => r.i); + scoreOrderCache = {secs, order}; + return order; +} + +// Step a position within an ordering: pos + dir, or straight to the extreme in +// that direction when jump. Returns null when the step runs off either end. +// pos === -1 means "not anchored yet" — start at the first entry going forward, +// or off the front going backward so the caller announces the boundary. +// Shared by both U/I walks (clip queue and in-player); they differ only in what +// the anchor is and what "play" means, and drifting apart is exactly how the +// two would stop agreeing. +export function nextInOrder(order, pos, dir, jump) { + const next = jump ? (dir > 0 ? order.length - 1 : 0) + : pos === -1 ? (dir > 0 ? 0 : -1) : pos + dir; + return (next >= 0 && next < order.length) ? next : null; +} + +// Step the clip queue by dir (±1): in time order, or with byScore down/up +// the loudest-first ranking (next = next quieter). With jump=true, go straight +// to the extreme in that direction (first/last section, or loudest/quietest). +export function stepClip(dir, byScore, jump) { + if (!clipQueue.length) return; + if (byScore) { + // Walk scoreCursor, not the shared clipCursor: a J/K detour never moved + // it, so we resume the ranking where we left off. scoreCursor === -1 means + // the walk hasn't started since the queue was armed or an explicit jump — + // anchor on the section currently selected (clipCursor), else the loudest. + const order = scoreOrder(clipQueue); + const pos = scoreCursor !== -1 ? scoreCursor + : clipCursor >= 0 ? order.indexOf(clipCursor) + : -1; + const next = nextInOrder(order, pos, dir, jump); + if (next === null) { announce(dir < 0 ? 'Loudest highlight reached' : 'End of highlights'); return; } + scoreCursor = next; + playClip(order[next], true); + return; + } + // J/K (time order) deliberately does NOT touch scoreCursor. + const i = jump ? (dir > 0 ? clipQueue.length - 1 : 0) : clipCursor + dir; + if (i >= 0 && i < clipQueue.length) playClip(i); + else announce(dir < 0 ? 'Beginning of sections' : 'End of sections'); +} + +// "Open in file": switch from the current clip to the full recording at the +// same position. Bound to the clip-bar button and the O key. +export function openClipInFile() { + const c = clipQueue[clipCursor]; + if (!c) return; + const idx = fileIdxByName(c.filename); + if (idx == null) return; + $('clip-audio').pause(); + const row = $('row-' + idx); + if (row) { + const wrap = row.closest('.table-wrap'); + if (wrap?.hidden) // file's day is collapsed: expand it + $('daytgl-' + wrap.id.slice('daytbl-'.length))?.click(); + row.scrollIntoView({block: 'center'}); + } + seekToSection(idx, c.start, c.end); +} + +function closeClipBar() { + const back = returnFocus; + returnFocus = null; + hideClipBar(); + (back?.isConnected ? back : $('page-title')).focus(); +} + +export function initClipBar() { + $('clip-prev').addEventListener('click', () => stepClip(-1, false)); + $('clip-next').addEventListener('click', () => stepClip(1, false)); + $('clip-close').addEventListener('click', closeClipBar); + $('clip-context').addEventListener('click', openClipInFile); + $('clip-audio').addEventListener('ended', () => { + const mode = advanceMode(); + if (mode !== 'off') stepClip(1, mode === 'hl'); + }); + $('clip-bar').addEventListener('keydown', e => { + if (e.key === 'Escape') { e.preventDefault(); closeClipBar(); } + }); +} diff --git a/webui/js/day.js b/webui/js/day.js new file mode 100644 index 0000000..2c1f840 --- /dev/null +++ b/webui/js/day.js @@ -0,0 +1,190 @@ +// Day review: the chronological cross-file section list behind a day's +// Highlights button. +// +// The user is blind and uses a screen reader — there is deliberately no day +// timeline graphic. The panel is linear text and buttons: summary line -> +// key-hint note -> chips toggle -> chips. + +import {$, fmtDur, fmtClock, fileStartEpoch, announce, chipToggle} from './util.js'; +import {fetchAnalysis} from './analysis.js'; +import {armQueue, playFrom, hideClipBar} from './clips.js'; +import {analysisParams, hlParams} from './settings.js'; + +let dayActiveSections = []; +let dayActiveId = null; +// dayId -> section list of an already-built highlights panel, so collapsing +// and re-expanding it re-arms J/K without recomputing +const dayHlSections = new Map(); + +export function setHlExpanded(dayId, exp) { + const btn = $('dayhln-' + dayId); + if (!btn) return; + btn.setAttribute('aria-expanded', exp); + const arrow = btn.querySelector('.day-arrow'); + if (arrow) arrow.textContent = exp ? '▾ ' : '▸ '; +} + +// The status suffix inside the Highlights button (" · analysed", progress). +// The button has no aria-label, so this text is part of its accessible name. +const setHlStatus = (dayId, text) => { + const el = $('dayhls-' + dayId); + if (el) el.textContent = text; +}; + +// Point the clip queue at a day's cross-file section list, from either the +// fresh-build path or the re-open-a-kept-panel path. Coupled globals set in +// two places is how the stale-_idx and diverging-label bugs happened. +function armDayQueue(dayId, sections) { + dayActiveSections = sections; + dayActiveId = dayId; + armQueue(sections); +} + +function jumpToDaySection(si) { + if (si < 0 || si >= dayActiveSections.length) return; + playFrom(dayActiveSections, si); +} + +// The day was collapsed: close its panel and drop its queue +export function releaseDay(dayId) { + const panel = $('dayhl-' + dayId); + if (panel) panel.hidden = true; + setHlExpanded(dayId, false); + if (dayActiveId === dayId) { + dayActiveSections = []; + dayActiveId = null; + hideClipBar(); + } +} + +// Highlights button: expand/collapse the panel; only (re)compute when it has +// not been built yet this session or the analysis params changed +export function toggleHighlights(dayId, files) { + const panel = $('dayhl-' + dayId); + const btn = $('dayhln-' + dayId); + if (btn?.dataset.busy) return; + if (!panel.hidden) { // collapse, keep the panel + panel.hidden = true; + setHlExpanded(dayId, false); + return; + } + if (panel.dataset.loaded === hlParams()) { // re-open and re-arm J/K + panel.hidden = false; + setHlExpanded(dayId, true); + armDayQueue(dayId, dayHlSections.get(dayId) || []); + return; + } + dayHighlights(dayId, files); +} + +async function dayHighlights(dayId, files) { + const panel = $('dayhl-' + dayId); + const content = $('dayhlc-' + dayId); + const btn = $('dayhln-' + dayId); + const n = files.length; + + panel.hidden = false; + setHlExpanded(dayId, true); + // Busy, not disabled: disabling the focused button would throw the screen + // reader's focus to the top of the page + btn.dataset.busy = '1'; + btn.setAttribute('aria-disabled', 'true'); + + const progBar = document.createElement('div'); progBar.className = 'prog-bar'; + const progFill = document.createElement('div'); progFill.className = 'prog-fill'; + progBar.setAttribute('aria-hidden', 'true'); + progBar.appendChild(progFill); + const progFile = document.createElement('p'); progFile.className = 'prog-file'; + content.replaceChildren(progBar, progFile); + + const params = analysisParams(); + const results = []; + let nCached = 0; + for (let i = 0; i < n; i++) { + const f = files[i]; + progFile.textContent = `${i + 1} / ${n} — ${f.name}`; + progFill.style.width = `${(i / n) * 100}%`; + setHlStatus(dayId, ` · ${i + 1} of ${n}`); + try { + const d = await fetchAnalysis(f.name, params); + if (!d.error) { results.push({f, data: d}); if (d.cached) nCached++; } + } catch { /* counted as missing below */ } + } + delete btn.dataset.busy; + btn.removeAttribute('aria-disabled'); + // Every file is now cached with the current params + setHlStatus(dayId, results.length === n ? ' · analysed' : ''); + + // Map files onto the day. The filename is the clock: f.date is the + // recording start parsed server-side; mtime (≈ file end) is only a fallback + // for non-standard names. + const sections = []; + results.forEach(({f, data}) => { + const fileDur = data.duration || f.duration || 0; + if (fileDur <= 0) return; + const startEpoch = fileStartEpoch(f.date); + const fileStart = startEpoch != null ? startEpoch : f.mtime - fileDur; + (data.sections || []).forEach(s => sections.push({ + filename: f.name, start: s.start, end: s.end, score: s.score, + absStart: fileStart + s.start, + })); + }); + sections.sort((a, b) => a.absStart - b.absStart); + // Arm the clip queue so J/K steps through the day immediately + armDayQueue(dayId, sections); + + const box = document.createElement('div'); + box.className = 'wbox'; + const nRes = results.length, nSec = sections.length; + const missing = n - nRes; + // Totals first, then the key hint, then the chips: linear reading order + const summary = document.createElement('p'); + summary.className = 'quiet'; + summary.textContent = `${nRes} recording${nRes !== 1 ? 's' : ''} analysed` + + (nCached ? ` (${nCached} from cache)` : '') + + (missing ? `, ${missing} failed` : '') + + ` · ${nSec} loud section${nSec !== 1 ? 's' : ''}`; + box.appendChild(summary); + + if (nSec) { + const note = document.createElement('p'); + note.className = 'quiet'; + note.textContent = 'J / K plays through all sections in time order, U / I by loudness (loudest first). Hold Shift to jump to the first / last section (Shift + J / K) or the loudest / quietest (Shift + U / I)'; + box.appendChild(note); + + const MAX_DAY_CHIPS = 50; + // When there are too many sections to show them all, show the ones most + // worth reviewing: the top MAX_DAY_CHIPS by score, loudest first. + let chipList = sections.map((sec, si) => ({sec, si})); + const truncated = chipList.length > MAX_DAY_CHIPS; + if (truncated) { + chipList = chipList + .sort((a, b) => (b.sec.score || 0) - (a.sec.score || 0)) + .slice(0, MAX_DAY_CHIPS); + } + const chips = document.createElement('div'); + chips.className = 'chips'; + chips.setAttribute('role', 'group'); + chips.setAttribute('aria-label', 'Day loud sections'); + chipList.forEach(({sec, si}) => { + const c = document.createElement('button'); + c.className = 'chip'; + c.title = sec.filename + ' @ ' + fmtDur(sec.start); + c.textContent = fmtClock(sec.absStart) + (sec.score != null ? ` · +${Math.round(sec.score)} dB` : ''); + c.addEventListener('click', () => jumpToDaySection(si)); + chips.appendChild(c); + }); + const tog = chipToggle(chips, truncated + ? `Top ${chipList.length} sections by loudness` + : `${chipList.length} sections`); + if (tog) box.appendChild(tog); + box.appendChild(chips); + } + + content.replaceChildren(box); + panel.dataset.loaded = hlParams(); + dayHlSections.set(dayId, sections); + announce(nSec + ? `${nSec} loud section${nSec !== 1 ? 's' : ''} across ${nRes} recording${nRes !== 1 ? 's' : ''}. K plays the first, I the loudest.` + : `No loud sections in ${nRes} recording${nRes !== 1 ? 's' : ''}.`); +} diff --git a/webui/js/list.js b/webui/js/list.js new file mode 100644 index 0000000..d2902f5 --- /dev/null +++ b/webui/js/list.js @@ -0,0 +1,323 @@ +// The recordings listing: one collapsible section per day, a table of +// recordings inside, plus loading, filtering, deleting and status polling. + +import {$, esc, fmtDur, fmtDurWords, fmtSize, pad, parseTime, dayLabel, announce} from './util.js'; +import * as api from './api.js'; +import {allFiles, setFiles, removeFile, sectionMap, fileSpan, isAnalysable} from './store.js'; +import {cachedParamsMatch, hlParams} from './settings.js'; +import {analyse, analyseButton, autoloadDayAnalyses, forgetAnalyses} from './analysis.js'; +import {togglePlayer, closePlayer, forgetPlayer, toggleCut} from './player.js'; +import {toggleHighlights, releaseDay} from './day.js'; + +// dayId -> boolean, persists expanded state across re-renders +const dayExpanded = new Map(); + +function groupByDay(files) { + const map = new Map(); + files.forEach(f => { + const day = f.date.slice(0, 10); + if (!map.has(day)) map.set(day, []); + map.get(day).push(f); + }); + return map; +} + +// Local date, not toISOString() — f.date is the recording start in local +// time, so a UTC "today" leaves the current day collapsed for part of every +// day in any non-UTC timezone (00:00–02:00 in UTC+2, all evening in UTC−5). +const localToday = () => { + const now = new Date(); + return `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}`; +}; + +// Everything a day's markup is built from. A day whose signature is unchanged +// keeps its DOM across reloads — open players, analyses, the highlights panel +// and, above all, the screen reader's position in it. +const daySignature = dayFiles => hlParams() + '#' + + dayFiles.map(f => `${f.name}:${f.recording ? 1 : 0}:${f.duration ?? ''}`).join('|'); + +function buildRow(f, tbody, expanded) { + const i = f._idx; + const isRec = !!f.recording; + const span = esc(fileSpan(f)); + const off = isRec ? ' disabled' : ''; + + const tr = document.createElement('tr'); + tr.className = 'data-row'; + tr.id = 'row-' + i; + tr.dataset.idx = i; + // Time is the row header, so table navigation names the row by its span. + // The player opens inside its own cell; the options cell right after it is + // always there, and Cut expands its fields inside it. Format, size and + // filename come last: rarely needed, and the filename reads as a string of + // digits. + tr.innerHTML = ` + ${span} Recording + ${fmtDur(f.duration)} + ${isAnalysable(f) ? '' : 'Not available for this format'} + + + + + +
+ Download + + +
+ + + ${esc(f.ext.toUpperCase())} ${fmtSize(f.size)} · ${esc(f.name)}`; + tbody.append(tr); + + if (isAnalysable(f)) { + const cell = tr.querySelector('#wave-' + i); + const abtn = analyseButton(i, cell, 'Analyse'); + cell.appendChild(abtn); + if (isRec) { + abtn.disabled = true; + } else if (cachedParamsMatch(f.cached_analysis)) { + if (expanded) analyse(i, cell, abtn); + // Collapsed day: defer the fetch until the day is opened + else { cell.dataset.autoload = '1'; cell.dataset.idx = i; } + } + } + + tr.querySelector('#pbtn-' + i).addEventListener('click', () => togglePlayer(i)); + if (!isRec) { + tr.querySelector('#cutbtn-' + i).addEventListener('click', () => toggleCut(i)); + tr.querySelector('#delbtn-' + i).addEventListener('click', () => deleteFile(f)); + tr.querySelector('#cut-dl-' + i).addEventListener('click', () => { + const start = parseTime($('cut-start-' + i).value); + const end = parseTime($('cut-end-' + i).value); + if (start === null || end === null) { alert('Enter start and end times, e.g. 1:30 or 0:01:30'); return; } + if (start >= end) { alert('Start must be before end'); return; } + window.location.href = api.cutUrl(f.name, start, end); + }); + } +} + +function buildDay(day, dayFiles) { + const dayId = 'day-' + day.replace(/-/g, ''); + if (!dayExpanded.has(dayId)) dayExpanded.set(dayId, day === localToday()); + const expanded = dayExpanded.get(dayId); + const label = esc(dayLabel(day)); + + const totalSize = dayFiles.reduce((a, f) => a + f.size, 0); + const totalDur = dayFiles.reduce((a, f) => a + (f.duration || 0), 0); + const hlFiles = dayFiles.filter(f => isAnalysable(f) && !f.recording); + // Every analysable file already has a cached analysis for the current + // params -> the day's highlights are available without recomputing + const analysed = hlFiles.length > 0 && hlFiles.every(f => cachedParamsMatch(f.cached_analysis)); + const meta = `${dayFiles.length} recording${dayFiles.length !== 1 ? 's' : ''}` + + (totalDur > 0 ? ' · ' + fmtDurWords(totalDur) : '') + ' · ' + fmtSize(totalSize); + + const el = document.createElement('div'); + el.className = 'day-section'; + el.id = 'daysec-' + dayId; + el.dataset.day = day; + // The Highlights button has no aria-label on purpose: its content is its + // name, so the " · analysed" / progress suffix is heard without mirroring. + el.innerHTML = ` +
+

+ +

+ ${hlFiles.length ? `` : ''} +
+ +
+ + + + + + + + + + + +
Recordings, ${label}
TimeLengthLoudnessPlayerOptionsFile
+
`; + + const tbody = el.querySelector('tbody'); + dayFiles.forEach(f => buildRow(f, tbody, expanded)); + + const headBar = el.querySelector('.day-heading-bar'); + const wrap = el.querySelector('.table-wrap'); + const tgl = el.querySelector('.day-toggle'); + tgl.addEventListener('click', () => { + const nowExp = !dayExpanded.get(dayId); + dayExpanded.set(dayId, nowExp); + tgl.setAttribute('aria-expanded', nowExp); + tgl.querySelector('.day-arrow').textContent = nowExp ? '▾ ' : '▸ '; + headBar.classList.toggle('open', nowExp); + wrap.hidden = !nowExp; + if (nowExp) { + autoloadDayAnalyses(wrap); + } else { + dayFiles.forEach(f => closePlayer(f._idx)); + releaseDay(dayId); + } + }); + el.querySelector('.day-hl')?.addEventListener('click', () => toggleHighlights(dayId, hlFiles)); + return el; +} + +function discardDay(el) { + el.querySelectorAll('audio').forEach(a => a.pause()); + el.querySelectorAll('tr.data-row').forEach(tr => { + const i = Number(tr.dataset.idx); + sectionMap.delete(i); + forgetPlayer(i); + }); + el.remove(); +} + +// Re-render the listing, rebuilding only the days whose content changed. A +// rollover touches today alone, so the rest of the page — and whatever the +// screen reader is reading — stays put. Focus inside a rebuilt day is put back +// on the element with the same id (ids are stable per filename, see store.js). +function renderFiles(files) { + const container = $('days'); + const focusId = document.activeElement?.id || null; + + const total = allFiles.length, visible = files.length; + $('subtitle').textContent = total === visible + ? `${total} recording${total !== 1 ? 's' : ''}` + : `${visible} of ${total} recording${total !== 1 ? 's' : ''} shown`; + $('empty').hidden = visible > 0; + + const old = new Map([...container.children].map(el => [el.dataset.day, el])); + let prev = null; + groupByDay(files).forEach((dayFiles, day) => { + const sig = daySignature(dayFiles); + let el = old.get(day); + old.delete(day); + if (!el || el.dataset.sig !== sig) { + if (el) discardDay(el); + el = buildDay(day, dayFiles); + el.dataset.sig = sig; + } + // Only move nodes that are out of place: moving one blurs focus inside it + const ref = prev ? prev.nextSibling : container.firstChild; + if (el !== ref) container.insertBefore(el, ref); + prev = el; + }); + old.forEach(discardDay); + + if (focusId && document.activeElement?.id !== focusId) $(focusId)?.focus(); +} + +export function applyFilters({speak = false} = {}) { + const nameQ = $('filter-name').value.toLowerCase().trim(); + const fromD = $('filter-from').value; + const toD = $('filter-to').value; + renderFiles(allFiles.filter(f => { + if (nameQ && !f.name.toLowerCase().includes(nameQ)) return false; + if (fromD && f.date < fromD + ' 00:00:00') return false; + if (toD && f.date > toD + ' 23:59:59') return false; + return true; + })); + if (speak) announce($('subtitle').textContent); +} + +async function updateStorage() { + try { + const s = await api.getStorage(); + const used = allFiles.reduce((a, f) => a + f.size, 0); + let txt = fmtSize(used) + ' used'; + if (s.disk_free != null) txt += ' · ' + fmtSize(s.disk_free) + ' free'; + if (s.disk_total != null) txt += ' of ' + fmtSize(s.disk_total); + $('storage-info').textContent = txt; + } catch { /* keep the last value */ } +} + +let loading = false; +// speak: the user pressed Refresh, so say what came back. Poll-driven reloads +// stay silent — a rollover every hour must not interrupt whatever is being read. +export async function load({speak = false} = {}) { + if (loading) return; + loading = true; + const btn = $('refresh-btn'); + btn.setAttribute('aria-disabled', 'true'); + try { + setFiles(await api.getFiles()); + updateStorage(); + applyFilters(); + if (speak) announce('Refreshed. ' + $('subtitle').textContent); + } catch { + $('subtitle').textContent = 'Error loading recordings'; + if (speak) announce('Could not load recordings'); + } finally { + loading = false; + btn.removeAttribute('aria-disabled'); + } +} + +async function deleteFile(f) { + const span = fileSpan(f); + if (!confirm(`Delete the recording ${span} (${f.name})?\nThis cannot be undone.`)) return; + const btn = $('delbtn-' + f._idx); + if (btn) btn.setAttribute('aria-disabled', 'true'); + // Where focus goes once the row is gone: the next recording's Play button, + // else the previous one's, else the day's heading button + const row = $('row-' + f._idx); + const rows = [...row.parentNode.querySelectorAll('tr.data-row')]; + const k = rows.indexOf(row); + const near = rows[k + 1] || rows[k - 1]; + const dayToggle = row.closest('.day-section').querySelector('.day-toggle'); + try { + await api.deleteFile(f.name); + } catch (e) { + if (btn) btn.removeAttribute('aria-disabled'); + alert('Delete failed: ' + e.message); + return; + } + removeFile(f.name); + forgetAnalyses(f.name); + applyFilters(); + updateStorage(); + // The day was rebuilt without the row; look the targets up again by id + const target = (near && $('pbtn-' + near.dataset.idx)) || $(dayToggle.id) || $('page-title'); + target.focus(); + announce(`Deleted ${span}`); +} + +// Poll recording status every 5 s to update REC badges +let lastActiveKey = null; +export async function pollStatus() { + try { + const s = await api.getStatus(); + const active = new Set(s.active || []); + // Rows not currently rendered (filtered out) have no badge element, so + // getElementById skips them. + allFiles.forEach(f => { + const badge = $('rec-' + f._idx); + if (badge) badge.hidden = !active.has(f.name); + }); + // Active set changed (recording started/stopped or rolled over to a new + // split file): refresh the list so new files appear — but never yank the + // DOM out from under an in-progress playback. + const key = [...active].sort().join('|'); + if (lastActiveKey === null) { lastActiveKey = key; return; } + if (key !== lastActiveKey) { + const playing = [...document.querySelectorAll('audio')].some(a => !a.paused && !a.ended); + if (!playing) { lastActiveKey = key; load(); } + } + } catch { /* next poll */ } +} diff --git a/webui/js/main.js b/webui/js/main.js new file mode 100644 index 0000000..1858d10 --- /dev/null +++ b/webui/js/main.js @@ -0,0 +1,35 @@ +// Entry point: wire the page up and start. +// +// Modules import only from modules earlier in this list: +// util, api, store, settings, player, clips, analysis, day, list, nav, main +// Keep it that way. A module that needs something from a later one reaches it +// through the DOM (openClipInFile clicks the day toggle) rather than an import. + +import {$} from './util.js'; +import {getConfig} from './api.js'; +import {initSettings} from './settings.js'; +import {initClipBar} from './clips.js'; +import {initNav} from './nav.js'; +import {load, applyFilters, pollStatus} from './list.js'; + +initClipBar(); +initNav(); + +$('refresh-btn').addEventListener('click', () => load({speak: true})); + +let filterDebounce; +$('filter-name').addEventListener('input', () => { + clearTimeout(filterDebounce); + filterDebounce = setTimeout(() => applyFilters({speak: true}), 400); +}); +$('filter-from').addEventListener('change', () => applyFilters({speak: true})); +$('filter-to').addEventListener('change', () => applyFilters({speak: true})); +$('filter-clear').addEventListener('click', () => { + $('filter-name').value = ''; + $('filter-from').value = ''; + $('filter-to').value = ''; + applyFilters({speak: true}); +}); + +getConfig().catch(() => ({})).then(initSettings) + .finally(() => load().then(() => setInterval(pollStatus, 5000))); diff --git a/webui/js/nav.js b/webui/js/nav.js new file mode 100644 index 0000000..bc02d2b --- /dev/null +++ b/webui/js/nav.js @@ -0,0 +1,89 @@ +// Section navigation from the keyboard (J/K/U/I/O) and from media keys — +// headset buttons, AirPods, the lock screen — via the Media Session API. +// +// J/K = previous/next section in time order, U/I = up/down the loudest-first +// ranking, O = open the current clip in the full file. Holding Shift jumps to +// the extreme in that direction: Shift+J/K = first/last section in time, +// Shift+U/I = loudest/quietest. The clip queue takes priority; with no queue +// armed, the keys step the playhead of the open full-file player instead. + +import {fmtDur, fmtClock, fileStartEpoch, announce} from './util.js'; +import {hasQueue, stepClip, openClipInFile, scoreOrder, nextInOrder} from './clips.js'; +import {activePlayerIdx, setCutFields} from './player.js'; +import {sectionMap, fileByIdx} from './store.js'; +import {getPreroll} from './settings.js'; + +export function navigate(dir, byScore, jump) { + if (hasQueue()) { stepClip(dir, byScore, jump); return; } + + // Per-file in-player navigation (full-file listening, no clip queue) + const idx = activePlayerIdx; + if (idx === null) return; + const all = sectionMap.get(idx) || []; + const audio = document.getElementById('aud-' + idx); + if (!all.length || !audio) return; + const preroll = getPreroll(); + const cur = audio.currentTime; + const epoch = fileStartEpoch(fileByIdx(idx)?.date); + + const jumpTo = (s, i, n, word) => { + audio.currentTime = Math.max(0, s.start - preroll); + setCutFields(idx, s.start, s.end); + const when = epoch != null + ? `${fmtClock(epoch + s.start)} to ${fmtClock(epoch + s.end)}` + : `${fmtDur(s.start)} to ${fmtDur(s.end)}`; + announce(`${word} ${i + 1} of ${n}: ${when}`); + }; + + if (byScore) { + // U/I walk the ranking; the anchor is the section the playhead sits in + // (incl. its preroll lead-in). Anywhere else, I starts at the loudest. + // Shift jumps straight to the loudest (Shift+U) or quietest (Shift+I). + const order = scoreOrder(all); + const curIdx = all.findIndex(s => cur >= s.start - preroll - 0.5 && cur <= s.end + 0.5); + const pos = curIdx === -1 ? -1 : order.indexOf(curIdx); + const next = nextInOrder(order, pos, dir, jump); + if (next === null) { announce(dir < 0 ? 'Loudest highlight reached' : 'End of highlights'); return; } + jumpTo(all[order[next]], next, order.length, 'Highlight'); + return; + } + + // Shift+J/K jump to the first/last section regardless of the playhead. + if (jump) { + const i = dir < 0 ? 0 : all.length - 1; + jumpTo(all[i], i, all.length, 'Section'); + return; + } + + if (dir < 0) { + for (let i = all.length - 1; i >= 0; i--) + if (all[i].start < cur - 1) { jumpTo(all[i], i, all.length, 'Section'); return; } + announce('Beginning of sections'); + } else { + for (let i = 0; i < all.length; i++) + if (all[i].start > cur + preroll) { jumpTo(all[i], i, all.length, 'Section'); return; } + announce('End of sections'); + } +} + +export function initNav() { + document.addEventListener('keydown', e => { + const t = e.target; + if (t.tagName === 'INPUT' || t.tagName === 'TEXTAREA' || t.tagName === 'SELECT' || t.isContentEditable) return; + if (e.ctrlKey || e.metaKey || e.altKey) return; + const key = e.key.toLowerCase(); + if (!['j', 'k', 'u', 'i', 'o'].includes(key)) return; + e.preventDefault(); + if (key === 'o') { openClipInFile(); return; } + navigate(key === 'j' || key === 'u' ? -1 : 1, key === 'u' || key === 'i', e.shiftKey); + }); + + // Previous/next track = J/K. Works from AirPods, the iOS lock screen and + // Control Center, and the media keys on a Windows keyboard. + if ('mediaSession' in navigator) { + for (const [action, dir] of [['previoustrack', -1], ['nexttrack', 1]]) { + try { navigator.mediaSession.setActionHandler(action, () => navigate(dir, false, false)); } + catch { /* action not supported here */ } + } + } +} diff --git a/webui/js/player.js b/webui/js/player.js new file mode 100644 index 0000000..68f1ec9 --- /dev/null +++ b/webui/js/player.js @@ -0,0 +1,91 @@ +// The per-row full-file player and its cut fields. Section review normally +// goes through clips.js; this is for listening around a section ("Open in +// file") or to a whole recording. + +import {$, fmtDur} from './util.js'; +import {fileByIdx, fileSpan} from './store.js'; +import {streamUrl} from './api.js'; +import {getPreroll} from './settings.js'; + +// Row of the full-file player opened last. The J/K/U/I fallback steps through +// its sections when no clip queue is armed. Live binding — read, don't assign. +export let activePlayerIdx = null; + +export function forgetPlayer(idx) { + if (activePlayerIdx === idx) activePlayerIdx = null; +} + +export function setCutFields(idx, startSec, endSec) { + const startEl = $('cut-start-' + idx); + const endEl = $('cut-end-' + idx); + if (startEl) startEl.value = fmtDur(startSec); + if (endEl && endSec != null) endEl.value = fmtDur(endSec); +} + +const isOpen = idx => $('pbtn-' + idx)?.getAttribute('aria-expanded') === 'true'; + +function setPlayButton(idx, open) { + const btn = $('pbtn-' + idx); + const f = fileByIdx(idx); + if (!btn || !f) return; + btn.setAttribute('aria-expanded', open); + btn.textContent = open ? 'Hide' : 'Play'; + btn.setAttribute('aria-label', (open ? 'Hide player for ' : 'Play ') + fileSpan(f)); +} + +// The player lives in its own table cell: the Play button stays, and the +// audio element appears right after it +export function closePlayer(idx) { + const audio = $('aud-' + idx); + if (!audio || audio.hidden) return; + audio.pause(); + audio.hidden = true; + setPlayButton(idx, false); +} + +export function togglePlayer(idx) { + if (isOpen(idx)) { closePlayer(idx); return; } + const f = fileByIdx(idx); + const audio = $('aud-' + idx); + if (!f || !audio) return; + if (!audio.dataset.srcSet) { + audio.preload = 'auto'; + audio.src = streamUrl(f.name); + audio.load(); + audio.dataset.srcSet = '1'; + } else if (!$('rec-' + idx)?.hidden) { + // Still recording: re-fetch so duration and seek range cover the audio + // captured since the source was last loaded + audio.load(); + } + activePlayerIdx = idx; + audio.hidden = false; + setPlayButton(idx, true); + audio.focus(); +} + +export function seekToSection(idx, startSec, endSec) { + if (!isOpen(idx)) togglePlayer(idx); + activePlayerIdx = idx; + const audio = $('aud-' + idx); + if (!audio) return; + audio.preload = 'auto'; + const seekTo = Math.max(0, startSec - getPreroll()); + const doSeek = () => { audio.currentTime = seekTo; audio.play().catch(() => {}); }; + if (audio.readyState >= 1) doSeek(); + else audio.addEventListener('loadedmetadata', doSeek, {once: true}); + setCutFields(idx, startSec, endSec); +} + +// Cut button: show/hide the start/end fields inside the options cell. Opening +// puts focus in Start. Clip playback pre-fills the fields either way. +export function toggleCut(idx) { + const btn = $('cutbtn-' + idx); + const panel = $('cut-' + idx); + if (!btn || !panel) return; + const open = panel.hidden; + panel.hidden = !open; + btn.setAttribute('aria-expanded', open); + btn.querySelector('.day-arrow').textContent = open ? '▾ ' : '▸ '; + if (open) $('cut-start-' + idx)?.focus(); +} diff --git a/webui/js/settings.js b/webui/js/settings.js new file mode 100644 index 0000000..8c7ec2c --- /dev/null +++ b/webui/js/settings.js @@ -0,0 +1,58 @@ +// The settings panel: analysis params, pre-roll and the auto-advance mode. + +import {$, remember} from './util.js'; + +// The three analysis controls, read straight from the DOM. Deliberately no +// hardcoded fallbacks: the inputs are seeded from /api/config at startup, so +// web.py's MARGIN_DB / MIN_GAP_SECONDS / MIN_DURATION_SECONDS stay the single +// source of truth. An empty input sends an empty param, which _qs_float() +// falls back to the server default for. +export const analysisParams = () => ({ + margin: $('margin-input').value, + minGap: $('min-gap-input').value, + minDur: $('min-duration-input').value, +}); +// Same values as one string; a highlights panel built with other values is +// stale and gets recomputed on the next expand +export const hlParams = () => Object.values(analysisParams()).join('|'); + +export const getPreroll = () => { + const v = parseFloat($('preroll-input').value); + return isNaN(v) || v < 0 ? 0 : v; +}; + +export const advanceMode = () => + document.querySelector('input[name="clip-adv"]:checked')?.value || 'off'; + +// Does a server-side cached analysis match the current control values? +// Auto-loading on a mismatch would silently recompute every file. +export function cachedParamsMatch(ca) { + const p = analysisParams(); + return ca != null + && Number(ca.margin) === parseFloat(p.margin) + && Number(ca.min_gap) === parseFloat(p.minGap) + && Number(ca.min_duration) === parseFloat(p.minDur); +} + +// Seed the analysis params from /api/config, and restore the per-browser +// conveniences. Margin/gap/min-duration are not remembered client-side: a +// stale browser value would quietly disagree with the server's defaults. +export function initSettings(cfg) { + if (cfg.margin != null) $('margin-input').value = cfg.margin; + if (cfg.min_gap != null) $('min-gap-input').value = cfg.min_gap; + if (cfg.min_duration != null) $('min-duration-input').value = cfg.min_duration; + + const pre = remember.get('preroll'); + if (pre != null) $('preroll-input').value = pre; + $('preroll-input').addEventListener('change', e => remember.set('preroll', e.target.value)); + + const adv = remember.get('advance'); + const radio = adv && document.querySelector(`input[name="clip-adv"][value="${adv}"]`); + if (radio) radio.checked = true; + document.querySelectorAll('input[name="clip-adv"]').forEach(r => + r.addEventListener('change', () => remember.set('advance', r.value))); + + const panel = $('settings'); + panel.open = remember.get('settingsOpen') === '1'; + panel.addEventListener('toggle', () => remember.set('settingsOpen', panel.open ? '1' : '0')); +} diff --git a/webui/js/store.js b/webui/js/store.js new file mode 100644 index 0000000..4e21f91 --- /dev/null +++ b/webui/js/store.js @@ -0,0 +1,49 @@ +// The file listing and what the UI knows about each file. A leaf apart from +// util.js. + +import {fileStartEpoch, fmtHM} from './util.js'; + +// Full listing from /api/files. Reassigned on every load; importers see the +// new array through the live binding. +export let allFiles = []; + +// _idx is per filename and stable for the whole session: assigned the first +// time a name is seen and never reused. DOM ids (row-, pbtn-, aud-, …) are +// built from it, so they survive reloads — that is what lets a re-rendered day +// keep focus where it was. It used to be the position in the listing, which +// shifted on every rollover and left stored indexes pointing one row off. +const idxByName = new Map(); +const stableIdx = name => { + if (!idxByName.has(name)) idxByName.set(name, idxByName.size); + return idxByName.get(name); +}; + +export function setFiles(list) { + allFiles = list.map(f => ({...f, _idx: stableIdx(f.name)})); +} + +export function removeFile(name) { + allFiles = allFiles.filter(f => f.name !== name); +} + +export const fileByName = name => allFiles.find(f => f.name === name) ?? null; +export const fileByIdx = idx => allFiles.find(f => f._idx === idx) ?? null; +// Queue entries carry the filename, never a row index; resolve at use time. +// Null when the file is gone from the current listing. +export const fileIdxByName = name => fileByName(name)?._idx ?? null; + +export const isAnalysable = f => f.ext === 'wav' || f.ext === 'flac'; + +// idx -> [{start, end, score}], populated by analyse() +export const sectionMap = new Map(); + +// What a recording is called wherever it is spoken: its wall-clock span from +// the filename clock, "22:00 to 23:00". The filename itself ("20260523_220000") +// reads as a string of numbers. Non-standard names fall back to the name. +export function fileSpan(f) { + const start = fileStartEpoch(f.date); + if (start == null) return f.name; + if (f.recording) return `${fmtHM(start)} to now`; + if (f.duration == null) return fmtHM(start); + return `${fmtHM(start)} to ${fmtHM(Math.round((start + f.duration) / 60) * 60)}`; +} diff --git a/webui/js/util.js b/webui/js/util.js new file mode 100644 index 0000000..fefd6d6 --- /dev/null +++ b/webui/js/util.js @@ -0,0 +1,115 @@ +// Formatting and screen-reader helpers shared by every module. A leaf: it +// imports nothing, so any module can use it without creating a cycle. + +export const $ = id => document.getElementById(id); + +export const esc = s => String(s) + .replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"'); + +export const pad = n => String(n).padStart(2, '0'); + +export const fmtDur = s => { + if (s == null) return '—'; + const h = Math.floor(s / 3600), m = Math.floor((s % 3600) / 60), sec = Math.floor(s % 60); + return h ? `${h}:${pad(m)}:${pad(sec)}` : `${m}:${pad(sec)}`; +}; + +// "2 h 59 min" — for running text a screen reader speaks, where "2:59:59" +// reads like a clock time +export const fmtDurWords = s => { + const h = Math.floor(s / 3600), m = Math.floor((s % 3600) / 60); + if (h) return m ? `${h} h ${m} min` : `${h} h`; + return m ? `${m} min` : `${Math.round(s)} s`; +}; + +export const fmtSize = b => { + if (b < 1024) return b + ' B'; + if (b < 1 << 20) return (b / 1024).toFixed(0) + ' KB'; + if (b < 1 << 30) return (b / (1 << 20)).toFixed(1) + ' MB'; + return (b / (1 << 30)).toFixed(2) + ' GB'; +}; + +// Epoch seconds -> local wall-clock "HH:MM:SS" +export const fmtClock = ts => { + const d = new Date(ts * 1000); + return pad(d.getHours()) + ':' + pad(d.getMinutes()) + ':' + pad(d.getSeconds()); +}; + +// Epoch seconds -> local "HH:MM" +export const fmtHM = ts => { + const d = new Date(ts * 1000); + return pad(d.getHours()) + ':' + pad(d.getMinutes()); +}; + +// Listing `date` ("YYYY-MM-DD HH:MM:SS", the recording start parsed out of +// the filename server-side) -> epoch seconds, or null for unparseable values +export const fileStartEpoch = date => { + const t = Date.parse(String(date).replace(' ', 'T')); + return isNaN(t) ? null : t / 1000; +}; + +// "1:30", "0:01:30" or "90" -> seconds; null when unparseable +export function parseTime(s) { + if (!s || !s.trim()) return null; + const parts = s.trim().split(':').map(v => parseFloat(v)); + if (parts.some(isNaN) || parts.length > 3) return null; + if (parts.length === 3) return parts[0] * 3600 + parts[1] * 60 + parts[2]; + if (parts.length === 2) return parts[0] * 60 + parts[1]; + return parts[0]; +} + +// "2026-10-17" -> "Today, Saturday 17 October". Spoken far better than the +// ISO date, which screen readers read digit group by digit group. en-GB to +// match the page's lang="en", so the speech language never switches mid-page. +export function dayLabel(day) { + const [y, m, d] = day.split('-').map(Number); + const date = new Date(y, m - 1, d); + if (isNaN(date)) return day; + const now = new Date(); + const opts = {weekday: 'long', day: 'numeric', month: 'long'}; + if (y !== now.getFullYear()) opts.year = 'numeric'; + const text = date.toLocaleDateString('en-GB', opts); + // Math.round absorbs the 23/25 h days around DST switches + const ago = Math.round((new Date(now.getFullYear(), now.getMonth(), now.getDate()) - date) / 86400000); + return ago === 0 ? 'Today, ' + text : ago === 1 ? 'Yesterday, ' + text : text; +} + +export function announce(msg) { + const el = $('sr-announce'); + if (!el) return; + el.textContent = ''; // clear first so same text re-triggers + setTimeout(() => { el.textContent = msg; }, 50); +} + +// A long chip list is a wall of buttons: hide it behind an aria-expanded +// toggle. Returns the toggle to insert before the chips, or null when the list +// is short enough to show as is. The separator space lives inside the +// aria-hidden arrow — a leading space in the text node reads as an indent. +export function chipToggle(chips, text) { + if (chips.children.length <= 12) return null; + chips.hidden = true; + const tog = document.createElement('button'); + tog.className = 'chip-toggle'; + tog.setAttribute('aria-expanded', 'false'); + const arrow = document.createElement('span'); + arrow.className = 'day-arrow'; + arrow.setAttribute('aria-hidden', 'true'); + arrow.textContent = '▸ '; + tog.append(arrow, text); + tog.addEventListener('click', () => { + const exp = chips.hidden; + chips.hidden = !exp; + tog.setAttribute('aria-expanded', exp); + arrow.textContent = exp ? '▾ ' : '▸ '; + }); + return tog; +} + +// localStorage for per-browser conveniences (pre-roll, auto-advance, whether +// the settings are open). It can be missing or throw (private mode, blocked +// storage), and nothing here may depend on it working. +export const remember = { + get(key) { try { return localStorage.getItem('isr.' + key); } catch { return null; } }, + set(key, value) { try { localStorage.setItem('isr.' + key, value); } catch { /* not kept */ } }, +}; diff --git a/webui/style.css b/webui/style.css new file mode 100644 index 0000000..1199a0f --- /dev/null +++ b/webui/style.css @@ -0,0 +1,135 @@ +:root{--bg:#0f1117;--surf:#1a1d27;--brd:#2c3040;--txt:#e2e8f0;--muted:#8b93ad; + --accent:#4f9cf9;--orange:#f97316;--green:#22c55e;--red:#ef4444; + --gutter:28px} +@media (max-width:640px){:root{--gutter:16px}} +*{box-sizing:border-box;margin:0;padding:0} +/* One rule for every [hidden] element. Author display rules (flex chips, the + inline-flex REC badge, the flex clip bar) beat the UA's [hidden] otherwise — + the REC badge used to show on every row that way. */ +[hidden]{display:none !important} +body{background:var(--bg);color:var(--txt);font:15px/1.5 system-ui,sans-serif} +.sr{position:absolute;width:1px;height:1px;padding:0;margin:-1px; + overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border:0} +.skip{position:absolute;left:-999px;top:0;padding:8px 14px;background:var(--accent); + color:#000;border-radius:0 0 4px 4px;text-decoration:none;font-weight:600;z-index:30} +.skip:focus{left:0} +:focus-visible{outline:2px solid var(--accent);outline-offset:2px} +#page-title:focus,#days:focus{outline:none} +.muted{color:var(--muted)} +.nowrap{white-space:nowrap} + +header{padding:16px var(--gutter);border-bottom:1px solid var(--brd); + display:flex;align-items:baseline;gap:6px 14px;flex-wrap:wrap} +header h1{font-size:20px;font-weight:600} +#subtitle{color:var(--muted)} +#storage-info{color:var(--muted);font-size:13px;margin-right:auto} + +button{cursor:pointer;border:1px solid var(--brd);background:var(--surf);color:var(--txt); + padding:6px 12px;border-radius:6px;font:inherit;font-size:13px;white-space:nowrap;min-height:32px} +button:hover{background:var(--brd)} +button:disabled,button[aria-disabled=true]{opacity:.55;cursor:default} +input{font:inherit;font-size:14px;background:var(--bg);border:1px solid var(--brd); + color:var(--txt);padding:5px 8px;border-radius:5px;min-height:32px} +input[type=date]{color-scheme:dark} +input[type=radio]{min-height:0;accent-color:var(--accent)} + +/* settings */ +#settings{border-bottom:1px solid var(--brd);background:var(--surf)} +#settings summary{padding:10px var(--gutter);cursor:pointer;color:var(--muted);font-size:14px} +#settings summary:hover{color:var(--txt)} +.settings-grid{display:grid;grid-template-columns:max-content 90px 1fr;gap:8px 12px; + align-items:center;padding:4px var(--gutter) 14px} +.settings-grid label{font-size:14px} +.hint{font-size:12px;color:var(--muted)} +@media (max-width:640px){ + .settings-grid{grid-template-columns:1fr 90px} + .settings-grid .hint{grid-column:1 / -1;margin-top:-6px} +} + +/* filters */ +.filter-bar{display:flex;align-items:center;gap:8px 10px;padding:10px var(--gutter); + border-bottom:1px solid var(--brd);flex-wrap:wrap} +.filter-bar label{font-size:14px;color:var(--muted)} +#filter-name{width:200px} + +/* days */ +#days{padding:20px var(--gutter)} +.day-section{margin-bottom:18px} +.day-heading-bar{background:var(--surf);padding:6px 10px;border:1px solid var(--brd); + border-radius:8px;display:flex;align-items:center;gap:8px;flex-wrap:wrap} +.day-heading-bar.open{border-radius:8px 8px 0 0} +h2.day-heading{font-size:inherit;font-weight:inherit;flex:1 1 auto} +.day-toggle{background:none;border:none;color:var(--txt);font-size:15px;font-weight:600; + padding:4px 0;display:inline-flex;align-items:baseline;gap:4px 10px;flex-wrap:wrap;text-align:left;white-space:normal} +.day-toggle:hover{background:none;color:var(--accent)} +.day-meta{color:var(--muted);font-size:13px;font-weight:400} +.day-arrow{font-size:10px} +button.day-hl{color:var(--green);border-color:#166534;background:#052e16} +button.day-hl:hover{background:#0a3d1f} +.day-hl-status{opacity:.8} +.day-hl-container{border:1px solid var(--brd);border-top:none;padding:10px 12px 12px} + +/* recordings table; scrolls sideways on narrow screens instead of the page */ +.table-wrap{overflow-x:auto;border:1px solid var(--brd);border-top:none} +table{width:100%;border-collapse:collapse} +th,td{padding:9px 10px;border-bottom:1px solid var(--brd);vertical-align:middle;text-align:left} +thead th{color:var(--muted);font-weight:500;font-size:13px;white-space:nowrap} +th.when{font-weight:600;white-space:nowrap} +tr.data-row:hover>*{background:var(--surf)} +td.file{color:var(--muted);font-size:12px;white-space:nowrap} +.fn{font-family:ui-monospace,monospace} +.badge{display:inline-block;padding:1px 6px;border-radius:3px;font-size:10px; + font-weight:700;border:1px solid} +.badge-wav{color:var(--green);border-color:#166534;background:#052e16} +.badge-mp3{color:var(--accent);border-color:#1e40af;background:#0c1a40} +.badge-ogg{color:#c084fc;border-color:#6b21a8;background:#2d1157} +.badge-flac{color:#fb923c;border-color:#7c2d12;background:#2c0e04} +.badge-aac,.badge-opus{color:var(--muted);border-color:var(--brd);background:var(--surf)} +.badge-rec{display:inline-flex;align-items:center;padding:1px 6px;border-radius:3px; + font-size:11px;font-weight:700;color:var(--red);border:1px solid #7f1d1d;background:#2d0808; + animation:pulse 1.5s ease-in-out infinite} +@keyframes pulse{0%,100%{opacity:1}50%{opacity:.45}} +@media (prefers-reduced-motion:reduce){.badge-rec{animation:none}} +.actions{display:flex;gap:6px;align-items:center} +a.dl{color:var(--accent);text-decoration:none;font-size:13px;padding:6px 4px} +a.dl:hover{text-decoration:underline} +button.del{color:var(--red);border-color:#7f1d1d} +button.del:hover:not(:disabled){background:#2d0808} +button.cut{color:var(--accent);border-color:#1e40af;background:#0c1a40} +button.cut:hover:not(:disabled){background:#1e3a8a} + +/* analysis */ +td.loudness{min-width:200px} +.wbox{background:var(--surf);border:1px solid var(--brd);border-radius:6px;padding:8px 10px} +.analysis-meta{font-size:12px;color:var(--muted)} +.chips{display:flex;flex-wrap:wrap;gap:6px;margin-top:8px} +.chip{background:#431407;color:var(--orange);border:1px solid #7c2d12;border-radius:5px; + padding:4px 9px;font-size:12px;font-family:ui-monospace,monospace;min-height:30px} +.chip:hover{background:#6c1f08;border-color:#9a3412} +.chip-toggle{margin-top:8px} +button.reanalyse-btn{margin-top:8px;font-size:12px;color:var(--muted);background:transparent} +.quiet{color:var(--muted);font-size:13px} +.quiet+.quiet{margin-top:6px} +.prog-bar{height:4px;background:var(--brd);border-radius:2px;margin:6px 0 4px;overflow:hidden} +.prog-fill{height:100%;width:0;background:var(--accent);transition:width .15s ease} +@media (prefers-reduced-motion:reduce){.prog-fill{transition:none}} +.prog-file{font-size:12px;color:var(--muted);white-space:nowrap;overflow:hidden;text-overflow:ellipsis} +.empty{text-align:center;padding:60px 16px;color:var(--muted)} + +/* full-file player cell, options cell */ +audio{width:100%;height:40px;display:block;color-scheme:dark;accent-color:var(--accent)} +td.player audio{width:300px;margin-top:6px} +.cut-panel{display:flex;align-items:center;gap:8px 12px;margin-top:8px;flex-wrap:wrap} +.cut-field{display:flex;align-items:center;gap:6px;font-size:13px;color:var(--muted)} +.cut-time{width:110px;font-family:ui-monospace,monospace} + +/* clip player bar */ +#clip-bar{position:fixed;bottom:0;left:0;right:0;z-index:20;background:var(--surf); + border-top:1px solid var(--brd);padding:8px var(--gutter);display:flex;flex-direction:column;gap:6px} +.clip-row{display:flex;align-items:center;gap:8px 10px;flex-wrap:wrap} +#clip-label{flex:1 1 240px;font-family:ui-monospace,monospace;font-size:13px;min-width:0} +#clip-audio{flex:1 1 260px;min-width:200px;width:auto} +#clip-adv{border:none;display:flex;align-items:center;gap:4px 12px;flex-wrap:wrap} +#clip-adv legend{float:left;font-size:12px;color:var(--muted);margin-right:4px} +#clip-adv label{font-size:13px;display:flex;align-items:center;gap:5px;cursor:pointer} +body.clip-open{padding-bottom:130px}