Compare commits
4 Commits
98d2d7085d
...
9c58e35546
| Author | SHA1 | Date | |
|---|---|---|---|
| 9c58e35546 | |||
| 91701ce4d3 | |||
| 41d921a42a | |||
| 653084e90b |
@@ -39,9 +39,9 @@ Dependencies: `requests` (streams), `numpy` + `soundfile` (FLAC output and FLAC
|
||||
|
||||
`webui.html` (one `<script>` block):
|
||||
- Clip review: `clipQueue`/`clipCursor` globals, `playClip()`, `playFileSection()`, `hideClipBar()`; markup is the `#clip-bar` div. The clip label shows the wall-clock occurrence time + queue position (`03:46:20 to 03:46:22 (73 / 187)`): queue entries carry `absStart` (epoch s), derived from `fileStartEpoch(f.date)` — the filename clock — with in-file offsets as fallback for non-standard names; filename/score live in the label tooltip.
|
||||
- Day review: `dayHighlights()` builds `dayActiveSections` (chronological); `jumpToDaySection()` arms the queue. The day timeline positions files by `fileStartEpoch(f.date)` (filename clock), mtime−duration only as fallback. 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`.
|
||||
- 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`.
|
||||
- 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), `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).
|
||||
|
||||
## Verifying changes
|
||||
|
||||
@@ -61,7 +61,7 @@ Dependencies: `requests` (streams), `numpy` + `soundfile` (FLAC output and FLAC
|
||||
- **Loud-section detection is adaptive — do not regress it to an absolute threshold.** Per-window dB is compared against a rolling noise floor (`NOISE_PERCENTILE`-th percentile per `NOISE_BLOCK_SECONDS` block, min-smoothed over ±2 blocks so events can't raise their own floor; clamped to ≥ `MIN_RMS`). A section needs `margin` dB of prominence and carries a `score` used for ranking: peak dB above floor, **capped by the sharpest rise within `ONSET_SECONDS` (0.5 s)** — so a short (~10 s) swell that outruns the 30 s floor blocks still flags but scores ≈ 0 and sinks in the U/I highlight ranking, while sharp events keep their full prominence. A section starting in the first 0.5 s of a file is scored against the floor instead (events cut off by a file split must not be punished as swells). Do not regress the scoring to raw peak, and do not fight swells with a higher margin. If flagging itself (not just ranking) ever needs improving, the next step is a spectral filter or optional Silero VAD over candidate sections. Sections shorter than `min_duration` (default 0.5 s, after `min_gap` merging) are discarded — without this, isolated 100 ms pops (clicks, single raindrops) produced thousands of zero-length sections per day. The original fixed RMS threshold flagged every ambience change (passing cars, rain) and produced ~600 useless sections/day — that is why it was replaced. Tests in `tests/test_web.py`.
|
||||
- **Analysis params are coupled in five places.** CLI `--margin`/`--min-gap`/`--min-duration` → `/api/config` → UI inputs `#margin-input`/`#min-gap-input`/`#min-duration-input` → `/api/analyze` query params → cache JSON head keys. Renaming or adding a param means touching all five plus `cachedParamsMatch()` and the `_cached_analysis_params()` regex (see the threshold→margin change `c84b7d8` and the min_duration addition).
|
||||
- **Analysis cache:** results stored as `<analyses-dir>/<file>.analysis.json` keyed by margin+min_gap+min_duration; orphans pruned at web startup. In Docker the recordings mount is **read-only** for the web container, so docker-compose layers a read-write `./recordings/analyses` bind mount over it. The `detector`, `margin`, `min_gap`, and `min_duration` keys MUST stay first in the cache JSON — `_cached_analysis_params()` reads only the first 256 bytes to avoid parsing the large embedded result. `detector` is `DETECTOR_VERSION`: bump it whenever detection/scoring changes make old cached results wrong (e.g. v2 = onset-capped scores); caches with another version (or missing keys) never match and get overwritten on the next analyse.
|
||||
- **Analyze responses:** `/api/analyze` returns `rms_display` (~800 points), never the full per-window RMS list — the UI doesn't use it and it is ~45x larger.
|
||||
- **Analyze responses:** `/api/analyze` returns only `sections`, `duration`, `window` — no RMS data of any kind. `rms_display` (~800-point waveform preview) and the full per-window list were dropped when the waveform SVGs were removed (user is blind, see webui notes). Old caches that embed `rms`/`rms_display` are still valid; both keys are popped when serving from cache, so no DETECTOR_VERSION bump was needed.
|
||||
- **Section playback uses clips, not seeks:** `/api/clip?file&start&end` decodes the slice server-side (wave/soundfile) and returns a standalone 16-bit WAV with exact Content-Length (capped at `CLIP_MAX_SECONDS`), `Cache-Control: private` so re-listening is free. The UI plays chips/J-K through the bottom clip bar (`clipQueue` in webui.html); seeking the full file only happens via "Open in file". Rationale (finding): libsndfile writes FLAC **without a SEEKTABLE**, so a browser seek bisects the whole multi-hundred-MB file with Range requests — seeking big FLACs in `<audio>` is inherently slow and must not be reintroduced as the primary navigation. Server-side `sf.SoundFile.seek()` on local disk is fast and frame-accurate.
|
||||
- **HTTP/1.1 keep-alive:** `_Handler.protocol_version = 'HTTP/1.1'`; every response path must set an accurate `Content-Length`. `_copy_to_response()` force-closes the connection if it under-delivers (file truncated mid-serve).
|
||||
- **Live playback:** for files listed in status.json, `/stream/` patches the header on the fly so the browser sees the duration recorded so far and can seek; responses get `Cache-Control: no-store`. WAV: `_live_wav_header` derives sizes from the byte count. FLAC: `_live_flac_header` parses the sample count out of the last frame header in the file tail (CRC-8-verified to reject false sync matches) and rewrites STREAMINFO total_samples — duration is NOT derivable from byte size for FLAC.
|
||||
|
||||
@@ -154,9 +154,9 @@ The browser UI (HTML/CSS/JS) lives in `webui.html`, which `web.py` loads at star
|
||||
Shows recordings grouped by day with collapsible sections. Features:
|
||||
|
||||
- **Day groups** — recordings are grouped under a collapsible day heading showing date, file count, total duration, and total size. The most recent day is expanded by default; older days start collapsed. Expanded state is preserved across filter changes.
|
||||
- **Day highlights** — click **Highlights** on any day heading to run loudness analysis across all WAV/FLAC files in that day and display a combined activity timeline SVG. The button is a toggle: clicking again collapses the panel, and re-expanding it reuses the already-computed results (they are only recomputed when the analysis parameters change). A **· analysed** suffix on the button marks days where every file already has a cached analysis for the current parameters, i.e. highlights open instantly. Orange segments show when loud sections occurred relative to the day's time span; blue shows the file extents. The labels under the timeline are the wall-clock start of the first recording, the timeline midpoint, and the end of the last recording. When a day has more sections than fit as chips, the chips show the top 50 by score (loudest-above-background first) so the most promising events are reviewed first; J/K still steps through all sections in time order, and U/I steps through them by loudness.
|
||||
- **Day highlights** — click **Highlights** on any day heading to run loudness analysis across all WAV/FLAC files in that day. The button is a toggle: clicking again collapses the panel, and re-expanding it reuses the already-computed results (they are only recomputed when the analysis parameters change). A **· analysed** suffix on the button marks days where every file already has a cached analysis for the current parameters, i.e. highlights open instantly. The panel is plain text and buttons in linear reading order (screen-reader friendly): files-analysed/section totals, a key hint, then the section chips. When a day has more sections than fit as chips, the chips are the top 50 by score (loudest-above-background first) so the most promising events are reviewed first; long chip lists are collapsed behind a toggle button so the panel stays compact. J/K still steps through all sections in time order, and U/I steps through them by loudness.
|
||||
- **Inline playback** — collapsible `Play` button per row; audio loads lazily via a seekable `/stream/` endpoint with HTTP Range support. Metadata is fetched immediately so the duration is visible without pressing play.
|
||||
- **Waveform analysis** — on demand per file; computes RMS per 100 ms window and marks sections that stand out above the background. Detection is **adaptive**: a rolling noise floor (20th percentile per 30 s block) is estimated across the file, and a section is flagged when the level rises at least *margin* dB (default 12) above that floor. Slow ambience changes — rain setting in, day/night traffic hum — move the floor instead of producing false positives. Each section gets a **score** used to rank it: its peak dB above the floor, capped by the sharpest rise within 0.5 s. Abrupt events — voices, impacts, barks — rise fast, so their score is their full prominence; a gradual swell (a gust, a distant approaching car) that drifts up faster than the floor can track still gets flagged, but scores near zero and sinks to the bottom of the highlight ranking. Supported for WAV and FLAC (FLAC requires `numpy` + `soundfile`). Pure-Python fallback for WAV when numpy is absent. Results are cached in `recordings/analyses/<filename>.analysis.json`; subsequent requests at the same margin, min-gap, and min-duration settings return instantly without re-reading the audio. The cache file is deleted automatically when the audio file is deleted. Orphaned cache files (audio deleted outside the UI) are pruned on startup.
|
||||
- **Loudness analysis** — on demand per file; computes RMS per 100 ms window and lists sections that stand out above the background as clickable chips (no visual waveform — the UI is built for screen-reader use). Detection is **adaptive**: a rolling noise floor (20th percentile per 30 s block) is estimated across the file, and a section is flagged when the level rises at least *margin* dB (default 12) above that floor. Slow ambience changes — rain setting in, day/night traffic hum — move the floor instead of producing false positives. Each section gets a **score** used to rank it: its peak dB above the floor, capped by the sharpest rise within 0.5 s. Abrupt events — voices, impacts, barks — rise fast, so their score is their full prominence; a gradual swell (a gust, a distant approaching car) that drifts up faster than the floor can track still gets flagged, but scores near zero and sinks to the bottom of the highlight ranking. Supported for WAV and FLAC (FLAC requires `numpy` + `soundfile`). Pure-Python fallback for WAV when numpy is absent. Results are cached in `recordings/analyses/<filename>.analysis.json`; subsequent requests at the same margin, min-gap, and min-duration settings return instantly without re-reading the audio. The cache file is deleted automatically when the audio file is deleted. Orphaned cache files (audio deleted outside the UI) are pruned on startup.
|
||||
- **Grace period** — configurable in the controls bar (default 2 s). Loud sections separated by less than this gap are merged into one. Raise this (e.g. to 15–30 s) when a single event generates many timestamps due to brief quiet gaps within it.
|
||||
- **Min duration** — configurable in the controls bar (default 0.5 s). Loud sections shorter than this (after grace-period merging) are discarded, so isolated sub-second pops — a click, a single raindrop — don't flood a day with thousands of near-zero-length sections. Set to 0 to disable.
|
||||
- **Clip playback** — clicking a loud-section chip plays a short server-rendered WAV clip (`/api/clip`, pre-roll included) in a player bar at the bottom of the page. Playback starts instantly even for sections deep inside multi-hundred-MB FLACs, because the browser never has to seek the full file. The player bar labels each clip with the wall-clock time it occurred (derived from the recording's filename) and its position in the queue, e.g. `03:46:20 to 03:46:22 (73 / 187)`; the filename and score are in the label's hover tooltip. **J** / **K** (or the **Prev** / **Next** buttons) always step through the queued sections in time order — one file's, or a whole day's after **Highlights**. **U** / **I** step through the same queue by loudness instead: **I** plays the next-loudest section, **U** goes back up the ranking, so a day with thousands of detections is reviewed loudest-first for as long as it stays interesting — there is no top-N cutoff, just stop when it gets boring. The auto-advance radio in the player bar picks what happens when a clip ends: **Don't auto-advance**, **Auto-advance** (next section in time), or **Auto-advance highlights only** (next section by loudness) — it never changes what J/K do. The same keys work during full-file playback, seeking the open recording to the next section in time (J/K) or by loudness (U/I). **Open in file** (or the **O** key) switches to the full recording at the same position for context; each chip click also pre-fills the cut panel.
|
||||
@@ -165,8 +165,8 @@ Shows recordings grouped by day with collapsible sections. Features:
|
||||
- **Delete** — `Delete` button per row with confirmation prompt; disabled for files currently being recorded; sends `DELETE /api/files/<name>` and re-renders the table.
|
||||
- **Live REC badge** — files currently being written by `isr.py` show an animated REC indicator, polled every 5 seconds via `/api/status`. Duration for in-progress files shows `—` in the table (header is unfinalized until recording stops). The file list refreshes automatically when a recording starts, stops, or rolls over to a new split file (unless audio is playing).
|
||||
- **Listen while recording** — in-progress files are playable and seekable. For WAV and FLAC the server patches the (still unfinalized) header on the fly so the browser sees the real duration-so-far — for FLAC the exact sample count is parsed from the last frame header in the file tail. Reopening the player reloads the source to pick up newly recorded audio. Live responses are sent with `Cache-Control: no-store`.
|
||||
- **Fast loading** — analysis results are cached server-side on disk and client-side per session; cached waveforms load only for expanded day groups, and collapsed days fetch nothing until opened.
|
||||
- **WCAG-compliant** — skip link, `aria-expanded`/`aria-controls` on the player toggle, `aria-live` status, focus management, `role=img` on SVG waveforms.
|
||||
- **Fast loading** — analysis results are cached server-side on disk and client-side per session; cached analyses load only for expanded day groups, and collapsed days fetch nothing until opened.
|
||||
- **WCAG-compliant** — skip link, `aria-expanded`/`aria-controls` on the player toggle, `aria-live` status, focus management; all information is plain text and buttons in linear reading order, with no visual-only elements.
|
||||
|
||||
### Too many sections per day?
|
||||
|
||||
@@ -186,7 +186,7 @@ Everything the UI does goes through these endpoints, so they can also be scripte
|
||||
| Endpoint | Description |
|
||||
|----------|-------------|
|
||||
| `GET /api/files` | File listing with size, mtime, duration, recording state, cached-analysis params |
|
||||
| `GET /api/analyze?file=&margin=&min_gap=&min_duration=` | Loud-section analysis: `rms_display` (~800-point waveform), scored `sections`, `duration` |
|
||||
| `GET /api/analyze?file=&margin=&min_gap=&min_duration=` | Loud-section analysis: scored `sections`, `duration` |
|
||||
| `GET /api/clip?file=&start=&end=` | Section of a WAV/FLAC decoded server-side, returned as a standalone WAV (max 600 s) |
|
||||
| `GET /api/cut?file=&start=&end=` | ffmpeg-trimmed copy of the file as a download |
|
||||
| `GET /stream/<name>` | Inline playback with HTTP Range support; live files get an on-the-fly patched header |
|
||||
|
||||
@@ -402,16 +402,9 @@ def _package_result(rms_values: list, framerate: int, n_frames: int,
|
||||
window_dur = window_samples / framerate
|
||||
duration = n_frames / framerate
|
||||
|
||||
if len(rms_values) > 800:
|
||||
step = len(rms_values) / 800
|
||||
rms_display = [rms_values[int(i * step)] for i in range(800)]
|
||||
else:
|
||||
rms_display = rms_values
|
||||
|
||||
# Note: the full per-window RMS list is deliberately NOT returned — the UI
|
||||
# only renders rms_display (~800 points), and the full list is ~45x larger.
|
||||
# Note: no RMS data is returned — the UI is screen-reader oriented and
|
||||
# draws no waveform, so sections + duration is all it needs.
|
||||
return {
|
||||
'rms_display': rms_display,
|
||||
'sections': _loud_sections(rms_values, window_dur, duration, margin_db, min_gap, min_duration),
|
||||
'duration': round(duration, 2),
|
||||
'window': round(window_dur, 4),
|
||||
@@ -690,7 +683,9 @@ class _Handler(BaseHTTPRequestHandler):
|
||||
and cached.get('margin') == margin and cached.get('min_gap') == min_gap
|
||||
and cached.get('min_duration') == min_duration):
|
||||
payload = dict(cached['result'])
|
||||
payload.pop('rms', None) # caches written before the full-RMS field was dropped
|
||||
# strip fields that older cache versions embedded
|
||||
payload.pop('rms', None) # full per-window RMS list
|
||||
payload.pop('rms_display', None) # ~800-point waveform preview
|
||||
payload['cached'] = True
|
||||
self._send(200, json.dumps(payload).encode('utf-8'), 'application/json')
|
||||
return
|
||||
|
||||
+40
-113
@@ -63,8 +63,8 @@ button.del{color:var(--red);border-color:#7f1d1d}
|
||||
button.del:hover:not(:disabled){background:#2d0808}
|
||||
/* waveform */
|
||||
.wbox{background:var(--surf);border:1px solid var(--brd);border-radius:6px;padding:10px 12px}
|
||||
svg.wave{display:block;width:100%;height:56px}
|
||||
.chips{display:flex;flex-wrap:wrap;gap:5px;margin-top:8px}
|
||||
.chips[hidden]{display:none}
|
||||
.chip{background:#431407;color:var(--orange);border:1px solid #7c2d12;border-radius:4px;
|
||||
padding:2px 8px;font-size:11px;font-family:ui-monospace,monospace}
|
||||
button.chip{cursor:pointer}
|
||||
@@ -120,9 +120,6 @@ button.day-hl .day-arrow{font-size:9px}
|
||||
h2.day-heading{margin:0;font-size:inherit;font-weight:inherit;line-height:inherit;flex:1 1 auto}
|
||||
.day-hl-container{background:var(--bg);border:1px solid var(--brd);border-top:none;padding:8px 12px 12px}
|
||||
table.day-table{width:100%;border-collapse:collapse;border:1px solid var(--brd);border-top:none}
|
||||
svg.day-timeline{display:block;width:100%;height:22px}
|
||||
.day-tl-labels{display:flex;justify-content:space-between;font-size:10px;
|
||||
color:var(--muted);font-family:ui-monospace,monospace;margin-top:2px}
|
||||
/* 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 28px;display:flex;align-items:center;
|
||||
@@ -319,42 +316,6 @@ function togglePlayer(idx, filename) {
|
||||
audio.focus();
|
||||
}
|
||||
|
||||
function drawWave(rms, sections, duration, filename) {
|
||||
const ns = 'http://www.w3.org/2000/svg';
|
||||
const svg = document.createElementNS(ns,'svg');
|
||||
svg.setAttribute('class','wave');
|
||||
svg.setAttribute('viewBox',`0 0 ${rms.length} 1`);
|
||||
svg.setAttribute('preserveAspectRatio','none');
|
||||
svg.setAttribute('role','img');
|
||||
const nSec = sections ? sections.length : 0;
|
||||
svg.setAttribute('aria-label',
|
||||
`Waveform for ${filename}: duration ${fmtDur(duration)}, ${nSec} loud section${nSec!==1?'s':''}`);
|
||||
|
||||
if (duration > 0 && sections) {
|
||||
sections.forEach(s => {
|
||||
const r = document.createElementNS(ns,'rect');
|
||||
r.setAttribute('x', (s.start/duration)*rms.length);
|
||||
r.setAttribute('y', 0);
|
||||
r.setAttribute('width', ((s.end-s.start)/duration)*rms.length);
|
||||
r.setAttribute('height', 1);
|
||||
r.setAttribute('fill','rgba(249,115,22,0.22)');
|
||||
r.setAttribute('aria-hidden','true');
|
||||
svg.appendChild(r);
|
||||
});
|
||||
}
|
||||
const maxV = Math.max(...rms, 0.001);
|
||||
rms.forEach((v,i) => {
|
||||
const h = v/maxV;
|
||||
const r = document.createElementNS(ns,'rect');
|
||||
r.setAttribute('x',i); r.setAttribute('y',1-h);
|
||||
r.setAttribute('width',1); r.setAttribute('height',h);
|
||||
r.setAttribute('fill','#4f9cf9');
|
||||
r.setAttribute('aria-hidden','true');
|
||||
svg.appendChild(r);
|
||||
});
|
||||
return svg;
|
||||
}
|
||||
|
||||
function parseTime(s) {
|
||||
if (!s || !s.trim()) return null;
|
||||
const parts = s.trim().split(':').map(v => parseFloat(v));
|
||||
@@ -530,16 +491,16 @@ async function analyse(idx, filename, cell, btn, force = false) {
|
||||
restoreBtn(); return;
|
||||
}
|
||||
const box = document.createElement('div'); box.className='wbox';
|
||||
box.appendChild(drawWave(d.rms_display||[], d.sections||[], d.duration||0, filename));
|
||||
|
||||
const nSec = (d.sections || []).length;
|
||||
const meta = document.createElement('div'); meta.className='analysis-meta';
|
||||
meta.textContent = `margin: ${margin} dB · gap: ${minGap}s · min: ${minDur}s${d.cached ? ' · cached' : ''}`;
|
||||
meta.textContent = `${nSec} loud section${nSec!==1?'s':''}`
|
||||
+ ` · margin: ${margin} dB · gap: ${minGap}s · min: ${minDur}s${d.cached ? ' · cached' : ''}`;
|
||||
box.appendChild(meta);
|
||||
|
||||
const chips = document.createElement('div');
|
||||
chips.className='chips';
|
||||
chips.setAttribute('role','group');
|
||||
chips.setAttribute('aria-label','Loud sections — click to jump, J/K to step in time order, U/I by loudness');
|
||||
chips.setAttribute('aria-label','Loud sections');
|
||||
if (d.sections && d.sections.length) {
|
||||
sectionMap.set(idx, d.sections);
|
||||
d.sections.forEach((s, si) => {
|
||||
@@ -1014,57 +975,7 @@ async function dayHighlights(dayId, analyzableFiles) {
|
||||
return;
|
||||
}
|
||||
|
||||
const minT = Math.min(...positioned.map(r => r.fileStart));
|
||||
const maxT = Math.max(...positioned.map(r => r.fileEnd));
|
||||
const spanT = maxT - minT || 1;
|
||||
const W = 800, H = 22;
|
||||
const ns = 'http://www.w3.org/2000/svg';
|
||||
|
||||
const svg = document.createElementNS(ns, 'svg');
|
||||
svg.setAttribute('class', 'wave day-timeline');
|
||||
svg.setAttribute('viewBox', `0 0 ${W} ${H}`);
|
||||
svg.setAttribute('preserveAspectRatio', 'none');
|
||||
svg.setAttribute('role', 'img');
|
||||
const totalSecs = positioned.reduce((a, r) => a + r.data.sections.length, 0);
|
||||
svg.setAttribute('aria-label', `Day activity: ${results.length} file${results.length!==1?'s':''}, ${totalSecs} loud section${totalSecs!==1?'s':''}`);
|
||||
|
||||
// Background track
|
||||
const bgR = document.createElementNS(ns, 'rect');
|
||||
bgR.setAttribute('x', 0); bgR.setAttribute('y', 7);
|
||||
bgR.setAttribute('width', W); bgR.setAttribute('height', 8);
|
||||
bgR.setAttribute('fill', '#1e2535');
|
||||
svg.appendChild(bgR);
|
||||
|
||||
positioned.forEach(({ f, data, fileStart, fileEnd, fileDur }) => {
|
||||
const fx = ((fileStart - minT) / spanT) * W;
|
||||
const fw = Math.max(1, ((fileEnd - fileStart) / spanT) * W);
|
||||
|
||||
// File span (dim blue)
|
||||
const fr = document.createElementNS(ns, 'rect');
|
||||
fr.setAttribute('x', fx); fr.setAttribute('y', 8);
|
||||
fr.setAttribute('width', fw); fr.setAttribute('height', 6);
|
||||
fr.setAttribute('fill', '#1e3a5f');
|
||||
fr.setAttribute('aria-hidden', 'true');
|
||||
svg.appendChild(fr);
|
||||
|
||||
// Loud sections (orange)
|
||||
(data.sections || []).forEach(s => {
|
||||
const sx = fx + (s.start / fileDur) * fw;
|
||||
const sw = Math.max(1, ((s.end - s.start) / fileDur) * fw);
|
||||
const sr = document.createElementNS(ns, 'rect');
|
||||
sr.setAttribute('x', sx); sr.setAttribute('y', 4);
|
||||
sr.setAttribute('width', sw); sr.setAttribute('height', 14);
|
||||
sr.setAttribute('fill', '#f97316');
|
||||
sr.setAttribute('rx', '1');
|
||||
sr.setAttribute('aria-hidden', 'true');
|
||||
svg.appendChild(sr);
|
||||
});
|
||||
});
|
||||
|
||||
const fmtHM = ts => {
|
||||
const d = new Date(ts * 1000);
|
||||
return d.getHours().toString().padStart(2,'0') + ':' + d.getMinutes().toString().padStart(2,'0');
|
||||
};
|
||||
|
||||
// Build cross-file section list for J/K navigation and chips
|
||||
dayActiveSections = [];
|
||||
@@ -1089,16 +1000,21 @@ async function dayHighlights(dayId, analyzableFiles) {
|
||||
const box = document.createElement('div');
|
||||
box.className = 'wbox';
|
||||
box.style.marginBottom = '4px';
|
||||
box.appendChild(svg);
|
||||
|
||||
const labels = document.createElement('div');
|
||||
labels.className = 'day-tl-labels';
|
||||
labels.innerHTML = `<span title="First recording starts">${esc(fmtHM(minT))}</span>`
|
||||
+ `<span title="Timeline midpoint">${esc(fmtHM((minT+maxT)/2))}</span>`
|
||||
+ `<span title="Last recording ends">${esc(fmtHM(maxT))}</span>`;
|
||||
box.appendChild(labels);
|
||||
// Totals first, then the key hint, then the chips: linear reading order
|
||||
const summary = document.createElement('div');
|
||||
summary.className = 'quiet';
|
||||
summary.style.marginTop = '0';
|
||||
summary.textContent = `${results.length} file${results.length!==1?'s':''} analysed · ${totalSecs} loud section${totalSecs!==1?'s':''}`;
|
||||
box.appendChild(summary);
|
||||
|
||||
if (dayActiveSections.length) {
|
||||
const note = document.createElement('p');
|
||||
note.className = 'quiet';
|
||||
note.style.marginTop = '6px';
|
||||
note.textContent = 'J / K plays through all sections in time order, U / I by loudness (loudest first)';
|
||||
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.
|
||||
@@ -1108,16 +1024,11 @@ async function dayHighlights(dayId, analyzableFiles) {
|
||||
chipList = chipList
|
||||
.sort((a, b) => (b.sec.score || 0) - (a.sec.score || 0))
|
||||
.slice(0, MAX_DAY_CHIPS);
|
||||
const note = document.createElement('p');
|
||||
note.className = 'quiet';
|
||||
note.style.marginTop = '6px';
|
||||
note.textContent = `${dayActiveSections.length} sections — chips show the top ${MAX_DAY_CHIPS} by loudness; J / K steps through all in time order, U / I by loudness (loudest first)`;
|
||||
box.appendChild(note);
|
||||
}
|
||||
const chips = document.createElement('div');
|
||||
chips.className = 'chips';
|
||||
chips.setAttribute('role', 'group');
|
||||
chips.setAttribute('aria-label', 'Day loud sections — click to jump, J/K to step across files in time order, U/I by loudness');
|
||||
chips.setAttribute('aria-label', 'Day loud sections');
|
||||
chipList.forEach(({sec, si}) => {
|
||||
const c = document.createElement('button');
|
||||
c.className = 'chip';
|
||||
@@ -1126,15 +1037,31 @@ async function dayHighlights(dayId, analyzableFiles) {
|
||||
c.addEventListener('click', () => jumpToDaySection(si));
|
||||
chips.appendChild(c);
|
||||
});
|
||||
// A long chip list is a wall of buttons: collapse it behind a toggle
|
||||
if (chipList.length > 12) {
|
||||
chips.hidden = true;
|
||||
const tog = document.createElement('button');
|
||||
tog.style.marginTop = '8px';
|
||||
tog.setAttribute('aria-expanded', 'false');
|
||||
const arrow = document.createElement('span');
|
||||
arrow.className = 'day-arrow';
|
||||
arrow.setAttribute('aria-hidden', 'true');
|
||||
arrow.textContent = '▸';
|
||||
tog.appendChild(arrow);
|
||||
tog.appendChild(document.createTextNode(truncated
|
||||
? ` Top ${chipList.length} sections by loudness`
|
||||
: ` ${chipList.length} sections`));
|
||||
tog.addEventListener('click', () => {
|
||||
const exp = chips.hidden;
|
||||
chips.hidden = !exp;
|
||||
tog.setAttribute('aria-expanded', exp);
|
||||
arrow.textContent = exp ? '▾' : '▸';
|
||||
});
|
||||
box.appendChild(tog);
|
||||
}
|
||||
box.appendChild(chips);
|
||||
}
|
||||
|
||||
const summary = document.createElement('div');
|
||||
summary.className = 'quiet';
|
||||
summary.style.marginTop = '4px';
|
||||
summary.textContent = `${results.length} file${results.length!==1?'s':''} analysed · ${totalSecs} loud section${totalSecs!==1?'s':''}`;
|
||||
box.appendChild(summary);
|
||||
|
||||
contentEl.innerHTML = '';
|
||||
contentEl.appendChild(box);
|
||||
hlRow.dataset.loaded = hlParams();
|
||||
|
||||
Reference in New Issue
Block a user