feat: adaptive noise-floor loudness detection with section scoring
Replace the fixed RMS threshold with prominence over a rolling noise floor (20th percentile per 30s block, min-smoothed so events cannot raise their own floor, clamped to -54 dBFS). Slow ambience changes such as rain or daytime traffic hum move the floor instead of flagging everything; sections now need `margin` dB (default 12) of prominence. Each section carries a score (peak dB above floor); day-highlight chips show the top 50 by score when there are too many to list, so the most striking events are reviewed first. --threshold is replaced by --margin; analysis caches are now keyed by margin+min_gap, old threshold-keyed caches never match and are overwritten on the next analyse. Detector covered by tests/test_web.py. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -5,7 +5,7 @@ Guidance for Claude Code when working in this repository.
|
|||||||
## Rules
|
## 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.
|
- **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` (tests cover the recorder only).
|
- Run `python -m pytest tests/` after changing `isr.py` or `web.py` (tests cover the recorder and the loud-section detector).
|
||||||
|
|
||||||
## Files
|
## Files
|
||||||
|
|
||||||
@@ -21,7 +21,7 @@ Guidance for Claude Code when working in this repository.
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
python isr.py [config.ini] # recorder; --list-devices to list ALSA inputs
|
python isr.py [config.ini] # recorder; --list-devices to list ALSA inputs
|
||||||
python web.py # web UI on :8080 (--dir, --port, --threshold, --min-gap, --analyses-dir)
|
python web.py # web UI on :8080 (--dir, --port, --margin, --min-gap, --analyses-dir)
|
||||||
python -m pytest tests/ # test suite
|
python -m pytest tests/ # test suite
|
||||||
docker compose up -d / down # web UI mapped to host port 8050
|
docker compose up -d / down # web UI mapped to host port 8050
|
||||||
```
|
```
|
||||||
@@ -35,7 +35,8 @@ Dependencies: `requests` (streams), `numpy` + `soundfile` (FLAC output and FLAC/
|
|||||||
- **Split timing:** files split at clock-aligned boundaries (`get_next_split_time()`), e.g. `split_minutes = 60` → on the hour.
|
- **Split timing:** files split at clock-aligned boundaries (`get_next_split_time()`), e.g. `split_minutes = 60` → on the hour.
|
||||||
- **ALSA:** capture spawns `arecord` as a subprocess, raw PCM read in 100 ms chunks by a thread. Device spec resolution: `default` → exact `hw:X,Y` → partial name → fallback to any literal ALSA PCM name (so `shared_mic` from asound.conf works without appearing in `arecord -l`).
|
- **ALSA:** capture spawns `arecord` as a subprocess, raw PCM read in 100 ms chunks by a thread. Device spec resolution: `default` → exact `hw:X,Y` → partial name → fallback to any literal ALSA PCM name (so `shared_mic` from asound.conf works without appearing in `arecord -l`).
|
||||||
- **Shutdown:** SIGTERM is converted to KeyboardInterrupt in `main()`; `RecorderManager.stop()` joins all threads against a single shared 25 s deadline to stay inside Docker's `stop_grace_period: 30s`.
|
- **Shutdown:** SIGTERM is converted to KeyboardInterrupt in `main()`; `RecorderManager.stop()` joins all threads against a single shared 25 s deadline to stay inside Docker's `stop_grace_period: 30s`.
|
||||||
- **Analysis cache:** results stored as `<analyses-dir>/<file>.analysis.json` keyed by threshold+min_gap; orphans pruned at web startup. In Docker the recordings mount is **read-only** for the web container, so the cache uses a separate `./analyses` bind mount. The `threshold` and `min_gap` keys MUST stay first in the cache JSON — `_cached_analysis_params()` reads only the first 256 bytes to avoid parsing the large embedded result.
|
- **Loud-section detection is adaptive:** 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` (peak dB above floor) used for ranking. Tests in `tests/test_web.py`.
|
||||||
|
- **Analysis cache:** results stored as `<analyses-dir>/<file>.analysis.json` keyed by margin+min_gap; orphans pruned at web startup. In Docker the recordings mount is **read-only** for the web container, so the cache uses a separate `./analyses` bind mount. The `margin` and `min_gap` keys MUST stay first in the cache JSON — `_cached_analysis_params()` reads only the first 256 bytes to avoid parsing the large embedded result. Old `threshold`-keyed caches 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 `rms_display` (~800 points), never the full per-window RMS list — the UI doesn't use it and it is ~45x larger.
|
||||||
- **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).
|
- **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.
|
- **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.
|
||||||
|
|||||||
@@ -150,7 +150,7 @@ strftime codes are substituted at split time. The file extension is added automa
|
|||||||
python web.py # serves ./recordings on port 8080
|
python web.py # serves ./recordings on port 8080
|
||||||
python web.py --dir /path/to/audio # custom recordings directory
|
python web.py --dir /path/to/audio # custom recordings directory
|
||||||
python web.py --port 8888 # custom port
|
python web.py --port 8888 # custom port
|
||||||
python web.py --threshold 0.03 # loudness threshold 0–1 (default 0.05)
|
python web.py --margin 15 # dB above background noise for a section to count as loud (default 12)
|
||||||
python web.py --min-gap 15 # grace period in seconds for merging loud sections (default 2)
|
python web.py --min-gap 15 # grace period in seconds for merging loud sections (default 2)
|
||||||
python web.py --analyses-dir /path/to/dir # where to store analysis cache files (default: <recordings>/analyses)
|
python web.py --analyses-dir /path/to/dir # where to store analysis cache files (default: <recordings>/analyses)
|
||||||
```
|
```
|
||||||
@@ -160,9 +160,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:
|
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 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. Orange segments show when loud sections occurred relative to the day's time span; blue shows the file extents. Labels show the start, midpoint, and end times.
|
- **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. Orange segments show when loud sections occurred relative to the day's time span; blue shows the file extents. Labels show the start, midpoint, and end times. 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.
|
||||||
- **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.
|
- **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 highlights loud sections. 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 threshold and min-gap 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.
|
- **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** (its peak dB above the floor) used to rank sections by how much they stand out. 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 and min-gap 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.
|
- **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.
|
||||||
- **Timestamp jump** — after analysis, click any loud-section chip to seek the player to that position and pre-fill the cut panel. Use **J** / **K** keyboard shortcuts to jump to the previous / next section while audio is playing.
|
- **Timestamp jump** — after analysis, click any loud-section chip to seek the player to that position and pre-fill the cut panel. Use **J** / **K** keyboard shortcuts to jump to the previous / next section while audio is playing.
|
||||||
- **Cut & download** — `✂ Cut` button opens the player row and reveals a cut panel. Enter start and end times in `m:ss` or `h:mm:ss` format and click **↓ Download cut** to receive an ffmpeg-trimmed copy without re-encoding. Requires ffmpeg (included in the Docker image).
|
- **Cut & download** — `✂ Cut` button opens the player row and reveals a cut panel. Enter start and end times in `m:ss` or `h:mm:ss` format and click **↓ Download cut** to receive an ffmpeg-trimmed copy without re-encoding. Requires ffmpeg (included in the Docker image).
|
||||||
|
|||||||
@@ -0,0 +1,69 @@
|
|||||||
|
"""Tests for the adaptive loud-section detector in web.py."""
|
||||||
|
|
||||||
|
import math
|
||||||
|
|
||||||
|
from web import _loud_sections, _noise_floor_db
|
||||||
|
|
||||||
|
WINDOW_DUR = 0.1 # 100 ms windows, as produced by WINDOW_SAMPLES at 48 kHz
|
||||||
|
|
||||||
|
|
||||||
|
def _run(rms, margin_db=12.0, min_gap=2.0):
|
||||||
|
duration = len(rms) * WINDOW_DUR
|
||||||
|
return _loud_sections(rms, WINDOW_DUR, duration, margin_db, min_gap)
|
||||||
|
|
||||||
|
|
||||||
|
def test_burst_above_quiet_floor_is_detected():
|
||||||
|
rms = [0.002] * 1200 # 2 min of quiet ambience (−54 dBFS)
|
||||||
|
rms[600:610] = [0.05] * 10 # 1 s burst at −26 dBFS (+28 dB)
|
||||||
|
sections = _run(rms)
|
||||||
|
assert len(sections) == 1
|
||||||
|
s = sections[0]
|
||||||
|
assert s['start'] == 60.0
|
||||||
|
assert 60.5 <= s['end'] <= 62.0
|
||||||
|
assert 26.0 <= s['score'] <= 30.0
|
||||||
|
|
||||||
|
|
||||||
|
def test_slow_ambience_swell_is_not_detected():
|
||||||
|
# Level rises 20 dB over 10 minutes and back down — e.g. rain setting in.
|
||||||
|
# The old fixed threshold would have flagged the entire loud half.
|
||||||
|
up = [0.002 * 10 ** (i / 6000 * 1.0) for i in range(6000)] # −54 → −34 dB
|
||||||
|
down = list(reversed(up))
|
||||||
|
assert _run(up + down) == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_burst_still_detected_on_loud_ambience():
|
||||||
|
# Same +28 dB prominence as the quiet test, but on a 20 dB louder floor.
|
||||||
|
rms = [0.02] * 1200
|
||||||
|
rms[600:610] = [0.5] * 10
|
||||||
|
sections = _run(rms)
|
||||||
|
assert len(sections) == 1
|
||||||
|
assert sections[0]['start'] == 60.0
|
||||||
|
|
||||||
|
|
||||||
|
def test_min_rms_floor_suppresses_blips_in_digital_silence():
|
||||||
|
rms = [0.000001] * 1200
|
||||||
|
rms[600:605] = [0.004] * 5 # −48 dBFS: audible blip, but below MIN_RMS+12dB
|
||||||
|
assert _run(rms) == []
|
||||||
|
rms[600:605] = [0.05] * 5 # −26 dBFS clearly clears the clamped floor
|
||||||
|
assert len(_run(rms)) == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_min_gap_merges_nearby_bursts():
|
||||||
|
rms = [0.002] * 1200
|
||||||
|
rms[600:605] = [0.05] * 5
|
||||||
|
rms[615:620] = [0.05] * 5 # 1 s gap < 2 s min_gap → one section
|
||||||
|
rms[900:905] = [0.05] * 5 # 28 s away → separate section
|
||||||
|
sections = _run(rms)
|
||||||
|
assert len(sections) == 2
|
||||||
|
assert sections[0]['start'] == 60.0
|
||||||
|
assert sections[0]['end'] >= 61.5
|
||||||
|
assert sections[1]['start'] == 90.0
|
||||||
|
|
||||||
|
|
||||||
|
def test_noise_floor_tracks_blocks_and_ignores_short_events():
|
||||||
|
quiet_db = 20 * math.log10(0.002)
|
||||||
|
db = [quiet_db] * 1200
|
||||||
|
db[600:650] = [-20.0] * 50 # 5 s event must not raise its own floor
|
||||||
|
floor = _noise_floor_db(db, WINDOW_DUR)
|
||||||
|
assert len(floor) == len(db)
|
||||||
|
assert all(abs(f - quiet_db) < 1.0 for f in floor)
|
||||||
@@ -3,13 +3,14 @@
|
|||||||
ISR Web — Browse and download recorded audio files.
|
ISR Web — Browse and download recorded audio files.
|
||||||
|
|
||||||
Shows a chronological table of all recordings, allows inline playback,
|
Shows a chronological table of all recordings, allows inline playback,
|
||||||
download, and analyses WAV/FLAC files for loud sections using RMS.
|
download, and analyses WAV/FLAC files for sections that stand out above the
|
||||||
|
background noise.
|
||||||
|
|
||||||
Usage:
|
Usage:
|
||||||
python web.py # serves recordings/ on port 8080
|
python web.py # serves recordings/ on port 8080
|
||||||
python web.py --dir /path/to/audio # custom recordings directory
|
python web.py --dir /path/to/audio # custom recordings directory
|
||||||
python web.py --port 8888 # custom port
|
python web.py --port 8888 # custom port
|
||||||
python web.py --threshold 0.03 # loudness threshold (0-1, default 0.05)
|
python web.py --margin 15 # dB above noise floor (default 12)
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
@@ -46,9 +47,14 @@ except ImportError:
|
|||||||
|
|
||||||
AUDIO_EXTENSIONS = {'.wav', '.mp3', '.ogg', '.flac', '.aac', '.opus'}
|
AUDIO_EXTENSIONS = {'.wav', '.mp3', '.ogg', '.flac', '.aac', '.opus'}
|
||||||
WINDOW_SAMPLES = 4800 # 100 ms at 48 kHz
|
WINDOW_SAMPLES = 4800 # 100 ms at 48 kHz
|
||||||
LOUD_THRESHOLD = 0.05 # RMS 0–1 scale; sections above this are "interesting"
|
MARGIN_DB = 12.0 # sections must rise this many dB above the noise floor
|
||||||
MIN_GAP_SECONDS = 2.0 # merge loud sections separated by less than this
|
MIN_GAP_SECONDS = 2.0 # merge loud sections separated by less than this
|
||||||
|
|
||||||
|
NOISE_BLOCK_SECONDS = 30.0 # noise floor is estimated per block of this length
|
||||||
|
NOISE_PERCENTILE = 20 # percentile of windowed dB levels taken as the floor
|
||||||
|
MIN_RMS = 0.002 # ≈ −54 dBFS; the floor never drops below this, so
|
||||||
|
# digital silence does not make every tiny sound loud
|
||||||
|
|
||||||
MIME_TYPES = {
|
MIME_TYPES = {
|
||||||
'.wav': 'audio/wav',
|
'.wav': 'audio/wav',
|
||||||
'.mp3': 'audio/mpeg',
|
'.mp3': 'audio/mpeg',
|
||||||
@@ -254,33 +260,64 @@ def _compute_rms_windows_wav(wf, channels: int, sampwidth: int, framerate: int,
|
|||||||
yield round(rms, 5)
|
yield round(rms, 5)
|
||||||
|
|
||||||
|
|
||||||
|
def _noise_floor_db(db_values: list, window_dur: float) -> list:
|
||||||
|
"""Per-window background noise floor in dBFS.
|
||||||
|
|
||||||
|
The floor is the NOISE_PERCENTILE-th percentile of the windowed dB levels
|
||||||
|
in each NOISE_BLOCK_SECONDS block, then min-smoothed over ±2 neighbouring
|
||||||
|
blocks so an event spanning a whole block cannot raise its own floor.
|
||||||
|
Tracks slow ambience changes (day/night, rain, traffic hum) so detection
|
||||||
|
is relative to "how loud it normally is right now"."""
|
||||||
|
n = len(db_values)
|
||||||
|
block = max(1, int(round(NOISE_BLOCK_SECONDS / window_dur)))
|
||||||
|
floors = []
|
||||||
|
for i in range(0, n, block):
|
||||||
|
chunk = sorted(db_values[i:i + block])
|
||||||
|
floors.append(chunk[int(len(chunk) * NOISE_PERCENTILE / 100)])
|
||||||
|
smoothed = [min(floors[max(0, b - 2):b + 3]) for b in range(len(floors))]
|
||||||
|
return [smoothed[min(i // block, len(smoothed) - 1)] for i in range(n)]
|
||||||
|
|
||||||
|
|
||||||
def _loud_sections(rms_values: list, window_dur: float, duration: float,
|
def _loud_sections(rms_values: list, window_dur: float, duration: float,
|
||||||
threshold: float, min_gap: float = MIN_GAP_SECONDS) -> list:
|
margin_db: float, min_gap: float = MIN_GAP_SECONDS) -> list:
|
||||||
|
"""Sections whose level rises at least margin_db above the local noise
|
||||||
|
floor. Each section carries a 'score': its peak dB above the floor, used
|
||||||
|
by the UI to rank sections by how much they stand out."""
|
||||||
|
db = [20 * math.log10(max(r, 1e-6)) for r in rms_values]
|
||||||
|
floor = _noise_floor_db(db, window_dur)
|
||||||
|
min_db = 20 * math.log10(MIN_RMS)
|
||||||
|
|
||||||
sections = []
|
sections = []
|
||||||
start_t = None
|
start_t = None
|
||||||
last_loud_t = None
|
last_loud_t = None
|
||||||
|
peak = 0.0
|
||||||
|
|
||||||
for i, rms in enumerate(rms_values):
|
for i, d in enumerate(db):
|
||||||
t = i * window_dur
|
t = i * window_dur
|
||||||
if rms >= threshold:
|
floor_eff = max(floor[i], min_db)
|
||||||
|
if d >= floor_eff + margin_db:
|
||||||
if start_t is None:
|
if start_t is None:
|
||||||
start_t = t
|
start_t = t
|
||||||
|
peak = 0.0
|
||||||
last_loud_t = t
|
last_loud_t = t
|
||||||
|
peak = max(peak, d - floor_eff)
|
||||||
else:
|
else:
|
||||||
if start_t is not None and (t - last_loud_t) > min_gap:
|
if start_t is not None and (t - last_loud_t) > min_gap:
|
||||||
sections.append({'start': round(start_t, 1),
|
sections.append({'start': round(start_t, 1),
|
||||||
'end': round(last_loud_t + window_dur, 1)})
|
'end': round(last_loud_t + window_dur, 1),
|
||||||
|
'score': round(peak, 1)})
|
||||||
start_t = None
|
start_t = None
|
||||||
last_loud_t = None
|
last_loud_t = None
|
||||||
|
|
||||||
if start_t is not None:
|
if start_t is not None:
|
||||||
sections.append({'start': round(start_t, 1), 'end': round(duration, 1)})
|
sections.append({'start': round(start_t, 1), 'end': round(duration, 1),
|
||||||
|
'score': round(peak, 1)})
|
||||||
|
|
||||||
return sections
|
return sections
|
||||||
|
|
||||||
|
|
||||||
def _package_result(rms_values: list, framerate: int, n_frames: int,
|
def _package_result(rms_values: list, framerate: int, n_frames: int,
|
||||||
window_samples: int, threshold: float,
|
window_samples: int, margin_db: float,
|
||||||
min_gap: float = MIN_GAP_SECONDS) -> dict:
|
min_gap: float = MIN_GAP_SECONDS) -> dict:
|
||||||
window_dur = window_samples / framerate
|
window_dur = window_samples / framerate
|
||||||
duration = n_frames / framerate
|
duration = n_frames / framerate
|
||||||
@@ -295,14 +332,14 @@ def _package_result(rms_values: list, framerate: int, n_frames: int,
|
|||||||
# only renders rms_display (~800 points), and the full list is ~45x larger.
|
# only renders rms_display (~800 points), and the full list is ~45x larger.
|
||||||
return {
|
return {
|
||||||
'rms_display': rms_display,
|
'rms_display': rms_display,
|
||||||
'sections': _loud_sections(rms_values, window_dur, duration, threshold, min_gap),
|
'sections': _loud_sections(rms_values, window_dur, duration, margin_db, min_gap),
|
||||||
'duration': round(duration, 2),
|
'duration': round(duration, 2),
|
||||||
'window': round(window_dur, 4),
|
'window': round(window_dur, 4),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def analyze_wav(path: Path, window_samples: int = WINDOW_SAMPLES,
|
def analyze_wav(path: Path, window_samples: int = WINDOW_SAMPLES,
|
||||||
threshold: float = LOUD_THRESHOLD,
|
margin_db: float = MARGIN_DB,
|
||||||
min_gap: float = MIN_GAP_SECONDS) -> dict:
|
min_gap: float = MIN_GAP_SECONDS) -> dict:
|
||||||
try:
|
try:
|
||||||
with wave.open(str(path), 'rb') as wf:
|
with wave.open(str(path), 'rb') as wf:
|
||||||
@@ -315,11 +352,11 @@ def analyze_wav(path: Path, window_samples: int = WINDOW_SAMPLES,
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
return {'error': str(e)}
|
return {'error': str(e)}
|
||||||
|
|
||||||
return _package_result(rms_values, framerate, n_frames, window_samples, threshold, min_gap)
|
return _package_result(rms_values, framerate, n_frames, window_samples, margin_db, min_gap)
|
||||||
|
|
||||||
|
|
||||||
def analyze_flac(path: Path, window_samples: int = WINDOW_SAMPLES,
|
def analyze_flac(path: Path, window_samples: int = WINDOW_SAMPLES,
|
||||||
threshold: float = LOUD_THRESHOLD,
|
margin_db: float = MARGIN_DB,
|
||||||
min_gap: float = MIN_GAP_SECONDS) -> dict:
|
min_gap: float = MIN_GAP_SECONDS) -> dict:
|
||||||
"""Analyse a FLAC file for loudness. Requires numpy and soundfile."""
|
"""Analyse a FLAC file for loudness. Requires numpy and soundfile."""
|
||||||
if not NUMPY_AVAILABLE or not SOUNDFILE_AVAILABLE:
|
if not NUMPY_AVAILABLE or not SOUNDFILE_AVAILABLE:
|
||||||
@@ -342,7 +379,7 @@ def analyze_flac(path: Path, window_samples: int = WINDOW_SAMPLES,
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
return {'error': str(e)}
|
return {'error': str(e)}
|
||||||
|
|
||||||
return _package_result(rms_values, framerate, n_frames, window_samples, threshold, min_gap)
|
return _package_result(rms_values, framerate, n_frames, window_samples, margin_db, min_gap)
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -355,18 +392,19 @@ def _analysis_cache_path(analyses_base: Path, recordings_base: Path, audio_path:
|
|||||||
|
|
||||||
|
|
||||||
def _cached_analysis_params(cache_path: Path):
|
def _cached_analysis_params(cache_path: Path):
|
||||||
"""Read just threshold/min_gap from a cache file without parsing the whole
|
"""Read just margin/min_gap from a cache file without parsing the whole
|
||||||
JSON (the embedded result can be hundreds of KB). Relies on the writer in
|
JSON (the embedded result can be hundreds of KB). Relies on the writer in
|
||||||
_api_analyze putting these two keys first."""
|
_api_analyze putting these two keys first. Caches written by the old
|
||||||
|
fixed-threshold detector have no margin key and simply never match."""
|
||||||
try:
|
try:
|
||||||
with open(cache_path, 'r', encoding='utf-8') as fh:
|
with open(cache_path, 'r', encoding='utf-8') as fh:
|
||||||
head = fh.read(256)
|
head = fh.read(256)
|
||||||
except OSError:
|
except OSError:
|
||||||
return None
|
return None
|
||||||
m = re.search(r'"threshold":\s*([0-9.eE+-]+),\s*"min_gap":\s*([0-9.eE+-]+)', head)
|
m = re.search(r'"margin":\s*([0-9.eE+-]+),\s*"min_gap":\s*([0-9.eE+-]+)', head)
|
||||||
if not m:
|
if not m:
|
||||||
return None
|
return None
|
||||||
return {'threshold': float(m.group(1)), 'min_gap': float(m.group(2))}
|
return {'margin': float(m.group(1)), 'min_gap': float(m.group(2))}
|
||||||
|
|
||||||
|
|
||||||
def prune_orphan_analyses(analyses_base: Path, recordings_base: Path):
|
def prune_orphan_analyses(analyses_base: Path, recordings_base: Path):
|
||||||
@@ -465,7 +503,7 @@ class _Handler(BaseHTTPRequestHandler):
|
|||||||
|
|
||||||
recordings_dir: str = 'recordings'
|
recordings_dir: str = 'recordings'
|
||||||
analyses_dir: str = 'recordings/analyses'
|
analyses_dir: str = 'recordings/analyses'
|
||||||
threshold: float = LOUD_THRESHOLD
|
margin_db: float = MARGIN_DB
|
||||||
min_gap: float = MIN_GAP_SECONDS
|
min_gap: float = MIN_GAP_SECONDS
|
||||||
|
|
||||||
def do_DELETE(self):
|
def do_DELETE(self):
|
||||||
@@ -529,10 +567,10 @@ class _Handler(BaseHTTPRequestHandler):
|
|||||||
return
|
return
|
||||||
|
|
||||||
try:
|
try:
|
||||||
threshold = float(qs.get('threshold', [self.threshold])[0])
|
margin = float(qs.get('margin', [self.margin_db])[0])
|
||||||
threshold = max(0.0, min(1.0, threshold))
|
margin = max(1.0, min(60.0, margin))
|
||||||
except (ValueError, TypeError):
|
except (ValueError, TypeError):
|
||||||
threshold = self.threshold
|
margin = self.margin_db
|
||||||
|
|
||||||
try:
|
try:
|
||||||
min_gap = float(qs.get('min_gap', [self.min_gap])[0])
|
min_gap = float(qs.get('min_gap', [self.min_gap])[0])
|
||||||
@@ -549,7 +587,7 @@ class _Handler(BaseHTTPRequestHandler):
|
|||||||
cache_path = _analysis_cache_path(analyses_base, recordings_base, path)
|
cache_path = _analysis_cache_path(analyses_base, recordings_base, path)
|
||||||
try:
|
try:
|
||||||
cached = json.loads(cache_path.read_text('utf-8'))
|
cached = json.loads(cache_path.read_text('utf-8'))
|
||||||
if cached.get('threshold') == threshold and cached.get('min_gap') == min_gap:
|
if cached.get('margin') == margin and cached.get('min_gap') == min_gap:
|
||||||
payload = dict(cached['result'])
|
payload = dict(cached['result'])
|
||||||
payload.pop('rms', None) # caches written before the full-RMS field was dropped
|
payload.pop('rms', None) # caches written before the full-RMS field was dropped
|
||||||
payload['cached'] = True
|
payload['cached'] = True
|
||||||
@@ -560,12 +598,12 @@ class _Handler(BaseHTTPRequestHandler):
|
|||||||
|
|
||||||
ext = path.suffix.lower()
|
ext = path.suffix.lower()
|
||||||
if ext == '.wav':
|
if ext == '.wav':
|
||||||
result = analyze_wav(path, threshold=threshold, min_gap=min_gap)
|
result = analyze_wav(path, margin_db=margin, min_gap=min_gap)
|
||||||
elif ext == '.flac':
|
elif ext == '.flac':
|
||||||
if not (NUMPY_AVAILABLE and SOUNDFILE_AVAILABLE):
|
if not (NUMPY_AVAILABLE and SOUNDFILE_AVAILABLE):
|
||||||
self._json_err(400, 'FLAC analysis requires: pip install numpy soundfile')
|
self._json_err(400, 'FLAC analysis requires: pip install numpy soundfile')
|
||||||
return
|
return
|
||||||
result = analyze_flac(path, threshold=threshold, min_gap=min_gap)
|
result = analyze_flac(path, margin_db=margin, min_gap=min_gap)
|
||||||
else:
|
else:
|
||||||
self._json_err(400, f'Loudness analysis is not available for {ext} files')
|
self._json_err(400, f'Loudness analysis is not available for {ext} files')
|
||||||
return
|
return
|
||||||
@@ -573,7 +611,9 @@ class _Handler(BaseHTTPRequestHandler):
|
|||||||
try:
|
try:
|
||||||
cache_path.parent.mkdir(parents=True, exist_ok=True)
|
cache_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
tmp = cache_path.with_suffix('.tmp')
|
tmp = cache_path.with_suffix('.tmp')
|
||||||
tmp.write_text(json.dumps({'threshold': threshold, 'min_gap': min_gap, 'result': result}), 'utf-8')
|
# margin and min_gap MUST stay first: _cached_analysis_params reads
|
||||||
|
# only the first 256 bytes of this file
|
||||||
|
tmp.write_text(json.dumps({'margin': margin, 'min_gap': min_gap, 'result': result}), 'utf-8')
|
||||||
os.replace(tmp, cache_path)
|
os.replace(tmp, cache_path)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f'Warning: could not write analysis cache {cache_path}: {e}', flush=True)
|
print(f'Warning: could not write analysis cache {cache_path}: {e}', flush=True)
|
||||||
@@ -690,7 +730,7 @@ class _Handler(BaseHTTPRequestHandler):
|
|||||||
self._send(200, data.encode(), 'application/json')
|
self._send(200, data.encode(), 'application/json')
|
||||||
|
|
||||||
def _api_config(self):
|
def _api_config(self):
|
||||||
data = json.dumps({'threshold': self.threshold, 'min_gap': self.min_gap})
|
data = json.dumps({'margin': self.margin_db, 'min_gap': self.min_gap})
|
||||||
self._send(200, data.encode(), 'application/json')
|
self._send(200, data.encode(), 'application/json')
|
||||||
|
|
||||||
def _api_delete(self, filename: str):
|
def _api_delete(self, filename: str):
|
||||||
@@ -873,8 +913,9 @@ def main():
|
|||||||
help='HTTP port (default: 8080)')
|
help='HTTP port (default: 8080)')
|
||||||
parser.add_argument('--host', default='0.0.0.0',
|
parser.add_argument('--host', default='0.0.0.0',
|
||||||
help='Bind address (default: 0.0.0.0)')
|
help='Bind address (default: 0.0.0.0)')
|
||||||
parser.add_argument('--threshold', type=float, default=LOUD_THRESHOLD,
|
parser.add_argument('--margin', type=float, default=MARGIN_DB,
|
||||||
help=f'RMS loudness threshold 0–1 (default: {LOUD_THRESHOLD})')
|
help=f'dB above the background noise floor for a section '
|
||||||
|
f'to count as loud (default: {MARGIN_DB})')
|
||||||
parser.add_argument('--min-gap', type=float, default=MIN_GAP_SECONDS,
|
parser.add_argument('--min-gap', type=float, default=MIN_GAP_SECONDS,
|
||||||
help=f'Seconds gap for merging loud sections (default: {MIN_GAP_SECONDS})')
|
help=f'Seconds gap for merging loud sections (default: {MIN_GAP_SECONDS})')
|
||||||
parser.add_argument('--analyses-dir', default=None,
|
parser.add_argument('--analyses-dir', default=None,
|
||||||
@@ -893,7 +934,7 @@ def main():
|
|||||||
class Handler(_Handler):
|
class Handler(_Handler):
|
||||||
recordings_dir = str(rec_dir)
|
recordings_dir = str(rec_dir)
|
||||||
analyses_dir = str(_analyses_dir)
|
analyses_dir = str(_analyses_dir)
|
||||||
threshold = args.threshold
|
margin_db = args.margin
|
||||||
min_gap = args.min_gap
|
min_gap = args.min_gap
|
||||||
|
|
||||||
server = _Server((args.host, args.port), Handler)
|
server = _Server((args.host, args.port), Handler)
|
||||||
@@ -901,7 +942,7 @@ def main():
|
|||||||
print(f"ISR Web running on http://{args.host}:{args.port}/")
|
print(f"ISR Web running on http://{args.host}:{args.port}/")
|
||||||
print(f"Recordings dir: {rec_dir}")
|
print(f"Recordings dir: {rec_dir}")
|
||||||
print(f"Analyses dir: {analyses_dir}")
|
print(f"Analyses dir: {analyses_dir}")
|
||||||
print(f"Loud threshold: {args.threshold}")
|
print(f"Loudness margin: {args.margin} dB above noise floor")
|
||||||
if not NUMPY_AVAILABLE:
|
if not NUMPY_AVAILABLE:
|
||||||
print("Note: numpy not installed — WAV RMS uses pure Python (slower); FLAC analysis unavailable")
|
print("Note: numpy not installed — WAV RMS uses pure Python (slower); FLAC analysis unavailable")
|
||||||
elif not SOUNDFILE_AVAILABLE:
|
elif not SOUNDFILE_AVAILABLE:
|
||||||
|
|||||||
+32
-24
@@ -133,10 +133,10 @@ svg.day-timeline{display:block;width:100%;height:22px}
|
|||||||
<button id="refresh-btn" aria-label="Refresh file list">↻ Refresh</button>
|
<button id="refresh-btn" aria-label="Refresh file list">↻ Refresh</button>
|
||||||
</header>
|
</header>
|
||||||
<div class="controls-bar">
|
<div class="controls-bar">
|
||||||
<label for="threshold-input">Analysis threshold:</label>
|
<label for="margin-input">Loudness margin:</label>
|
||||||
<input type="number" id="threshold-input" min="0" max="1" step="0.005" value="0.05"
|
<input type="number" id="margin-input" min="1" max="60" step="1" value="12"
|
||||||
aria-describedby="threshold-hint">
|
aria-describedby="margin-hint">
|
||||||
<span id="threshold-hint" class="controls-hint">RMS amplitude 0–1 (linear; 0.05 ≈ −26 dBFS) · sections above this are marked loud</span>
|
<span id="margin-hint" class="controls-hint">dB above background noise — sections that rise this far above the rolling noise floor are marked loud</span>
|
||||||
<label for="preroll-input" style="margin-left:16px">Pre-roll:</label>
|
<label for="preroll-input" style="margin-left:16px">Pre-roll:</label>
|
||||||
<input type="number" id="preroll-input" min="0" max="30" step="0.5" value="3"
|
<input type="number" id="preroll-input" min="0" max="30" step="0.5" value="3"
|
||||||
aria-describedby="preroll-hint">
|
aria-describedby="preroll-hint">
|
||||||
@@ -321,15 +321,15 @@ function seekToSection(idx, filename, startSec, endSec, sectionIdx) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// filename|threshold|gap -> analysis result, so re-renders (filtering,
|
// filename|margin|gap -> analysis result, so re-renders (filtering,
|
||||||
// refresh) never refetch what this session already has
|
// refresh) never refetch what this session already has
|
||||||
const analysisCache = new Map();
|
const analysisCache = new Map();
|
||||||
|
|
||||||
async function fetchAnalysis(filename, threshold, minGap, force = false) {
|
async function fetchAnalysis(filename, margin, minGap, force = false) {
|
||||||
const key = `${filename}|${threshold}|${minGap}`;
|
const key = `${filename}|${margin}|${minGap}`;
|
||||||
if (!force && analysisCache.has(key)) return analysisCache.get(key);
|
if (!force && analysisCache.has(key)) return analysisCache.get(key);
|
||||||
const r = await fetch('/api/analyze?file='+encodeURIComponent(filename)
|
const r = await fetch('/api/analyze?file='+encodeURIComponent(filename)
|
||||||
+'&threshold='+encodeURIComponent(threshold)
|
+'&margin='+encodeURIComponent(margin)
|
||||||
+'&min_gap='+encodeURIComponent(minGap));
|
+'&min_gap='+encodeURIComponent(minGap));
|
||||||
const d = await r.json();
|
const d = await r.json();
|
||||||
if (!d.error) analysisCache.set(key, d);
|
if (!d.error) analysisCache.set(key, d);
|
||||||
@@ -340,7 +340,7 @@ async function analyse(idx, filename, cell, btn, force = false) {
|
|||||||
btn.disabled = true;
|
btn.disabled = true;
|
||||||
btn.textContent = '…';
|
btn.textContent = '…';
|
||||||
cell.innerHTML = '<div class="spin" aria-live="polite" aria-busy="true">Analysing…</div>';
|
cell.innerHTML = '<div class="spin" aria-live="polite" aria-busy="true">Analysing…</div>';
|
||||||
const threshold = document.getElementById('threshold-input').value || '0.05';
|
const margin = document.getElementById('margin-input').value || '12';
|
||||||
const minGap = document.getElementById('min-gap-input').value || '2';
|
const minGap = document.getElementById('min-gap-input').value || '2';
|
||||||
const restoreBtn = () => {
|
const restoreBtn = () => {
|
||||||
btn.textContent = 'Analyse'; btn.disabled = false;
|
btn.textContent = 'Analyse'; btn.disabled = false;
|
||||||
@@ -348,7 +348,7 @@ async function analyse(idx, filename, cell, btn, force = false) {
|
|||||||
if (!cell.contains(btn)) cell.appendChild(btn);
|
if (!cell.contains(btn)) cell.appendChild(btn);
|
||||||
};
|
};
|
||||||
try {
|
try {
|
||||||
const d = await fetchAnalysis(filename, threshold, minGap, force);
|
const d = await fetchAnalysis(filename, margin, minGap, force);
|
||||||
if (d.error) {
|
if (d.error) {
|
||||||
cell.innerHTML = `<div class="spin" role="alert">Error: ${esc(d.error)}</div>`;
|
cell.innerHTML = `<div class="spin" role="alert">Error: ${esc(d.error)}</div>`;
|
||||||
restoreBtn(); return;
|
restoreBtn(); return;
|
||||||
@@ -357,7 +357,7 @@ async function analyse(idx, filename, cell, btn, force = false) {
|
|||||||
box.appendChild(drawWave(d.rms_display||[], d.sections||[], d.duration||0, filename));
|
box.appendChild(drawWave(d.rms_display||[], d.sections||[], d.duration||0, filename));
|
||||||
|
|
||||||
const meta = document.createElement('div'); meta.className='analysis-meta';
|
const meta = document.createElement('div'); meta.className='analysis-meta';
|
||||||
meta.textContent = `threshold: ${threshold} · gap: ${minGap}s${d.cached ? ' · cached' : ''}`;
|
meta.textContent = `margin: ${margin} dB · gap: ${minGap}s${d.cached ? ' · cached' : ''}`;
|
||||||
box.appendChild(meta);
|
box.appendChild(meta);
|
||||||
|
|
||||||
const chips = document.createElement('div');
|
const chips = document.createElement('div');
|
||||||
@@ -370,7 +370,8 @@ async function analyse(idx, filename, cell, btn, force = false) {
|
|||||||
const c = document.createElement('button');
|
const c = document.createElement('button');
|
||||||
c.className='chip';
|
c.className='chip';
|
||||||
c.title = 'Jump to this section (or use J/K keys)';
|
c.title = 'Jump to this section (or use J/K keys)';
|
||||||
c.textContent = `${fmtDur(s.start)} – ${fmtDur(s.end)}`;
|
c.textContent = `${fmtDur(s.start)} – ${fmtDur(s.end)}`
|
||||||
|
+ (s.score != null ? ` · +${Math.round(s.score)} dB` : '');
|
||||||
c.addEventListener('click', () => seekToSection(idx, filename, s.start, s.end, si));
|
c.addEventListener('click', () => seekToSection(idx, filename, s.start, s.end, si));
|
||||||
chips.appendChild(c);
|
chips.appendChild(c);
|
||||||
});
|
});
|
||||||
@@ -494,7 +495,7 @@ async function updateStorage() {
|
|||||||
// Auto-loading on a mismatch would silently recompute every file.
|
// Auto-loading on a mismatch would silently recompute every file.
|
||||||
function cachedParamsMatch(ca) {
|
function cachedParamsMatch(ca) {
|
||||||
return ca != null
|
return ca != null
|
||||||
&& Number(ca.threshold) === parseFloat(document.getElementById('threshold-input').value)
|
&& Number(ca.margin) === parseFloat(document.getElementById('margin-input').value)
|
||||||
&& Number(ca.min_gap) === parseFloat(document.getElementById('min-gap-input').value);
|
&& Number(ca.min_gap) === parseFloat(document.getElementById('min-gap-input').value);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -769,7 +770,7 @@ async function dayHighlights(dayId, analyzableFiles) {
|
|||||||
progWrap.appendChild(progBar); progWrap.appendChild(progFile);
|
progWrap.appendChild(progBar); progWrap.appendChild(progFile);
|
||||||
contentEl.innerHTML = ''; contentEl.appendChild(progWrap);
|
contentEl.innerHTML = ''; contentEl.appendChild(progWrap);
|
||||||
|
|
||||||
const threshold = document.getElementById('threshold-input').value || '0.05';
|
const margin = document.getElementById('margin-input').value || '12';
|
||||||
const minGap = document.getElementById('min-gap-input').value || '2';
|
const minGap = document.getElementById('min-gap-input').value || '2';
|
||||||
|
|
||||||
const results = [];
|
const results = [];
|
||||||
@@ -779,7 +780,7 @@ async function dayHighlights(dayId, analyzableFiles) {
|
|||||||
progFile.textContent = `${i + 1} / ${n} — ${f.name}`;
|
progFile.textContent = `${i + 1} / ${n} — ${f.name}`;
|
||||||
progFill.style.width = `${(i / n) * 100}%`;
|
progFill.style.width = `${(i / n) * 100}%`;
|
||||||
try {
|
try {
|
||||||
const d = await fetchAnalysis(f.name, threshold, minGap);
|
const d = await fetchAnalysis(f.name, margin, minGap);
|
||||||
if (!d.error) { results.push({ f, data: d }); d.cached ? nCached++ : nLive++; }
|
if (!d.error) { results.push({ f, data: d }); d.cached ? nCached++ : nLive++; }
|
||||||
} catch(e) {}
|
} catch(e) {}
|
||||||
}
|
}
|
||||||
@@ -868,6 +869,7 @@ async function dayHighlights(dayId, analyzableFiles) {
|
|||||||
filename: f.name,
|
filename: f.name,
|
||||||
start: s.start,
|
start: s.start,
|
||||||
end: s.end,
|
end: s.end,
|
||||||
|
score: s.score,
|
||||||
absStart: fileStart + s.start,
|
absStart: fileStart + s.start,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -888,18 +890,25 @@ async function dayHighlights(dayId, analyzableFiles) {
|
|||||||
|
|
||||||
if (dayActiveSections.length) {
|
if (dayActiveSections.length) {
|
||||||
const MAX_DAY_CHIPS = 50;
|
const MAX_DAY_CHIPS = 50;
|
||||||
if (dayActiveSections.length > MAX_DAY_CHIPS) {
|
// 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 = dayActiveSections.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 note = document.createElement('p');
|
const note = document.createElement('p');
|
||||||
note.className = 'quiet';
|
note.className = 'quiet';
|
||||||
note.style.marginTop = '6px';
|
note.style.marginTop = '6px';
|
||||||
note.textContent = `${dayActiveSections.length} sections — use J / K to navigate`;
|
note.textContent = `${dayActiveSections.length} sections — chips show the top ${MAX_DAY_CHIPS} by loudness; J / K steps through all in time order`;
|
||||||
box.appendChild(note);
|
box.appendChild(note);
|
||||||
} else {
|
}
|
||||||
const chips = document.createElement('div');
|
const chips = document.createElement('div');
|
||||||
chips.className = 'chips';
|
chips.className = 'chips';
|
||||||
chips.setAttribute('role', 'group');
|
chips.setAttribute('role', 'group');
|
||||||
chips.setAttribute('aria-label', 'Day loud sections — click to jump, J/K to step across files');
|
chips.setAttribute('aria-label', 'Day loud sections — click to jump, J/K to step across files');
|
||||||
dayActiveSections.forEach((sec, si) => {
|
chipList.forEach(({sec, si}) => {
|
||||||
const c = document.createElement('button');
|
const c = document.createElement('button');
|
||||||
c.className = 'chip';
|
c.className = 'chip';
|
||||||
c.title = sec.filename + ' @ ' + fmtDur(sec.start);
|
c.title = sec.filename + ' @ ' + fmtDur(sec.start);
|
||||||
@@ -907,13 +916,12 @@ async function dayHighlights(dayId, analyzableFiles) {
|
|||||||
const hms = d.getHours().toString().padStart(2,'0') + ':'
|
const hms = d.getHours().toString().padStart(2,'0') + ':'
|
||||||
+ d.getMinutes().toString().padStart(2,'0') + ':'
|
+ d.getMinutes().toString().padStart(2,'0') + ':'
|
||||||
+ d.getSeconds().toString().padStart(2,'0');
|
+ d.getSeconds().toString().padStart(2,'0');
|
||||||
c.textContent = hms;
|
c.textContent = hms + (sec.score != null ? ` · +${Math.round(sec.score)} dB` : '');
|
||||||
c.addEventListener('click', () => jumpToDaySection(si));
|
c.addEventListener('click', () => jumpToDaySection(si));
|
||||||
chips.appendChild(c);
|
chips.appendChild(c);
|
||||||
});
|
});
|
||||||
box.appendChild(chips);
|
box.appendChild(chips);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
const summary = document.createElement('div');
|
const summary = document.createElement('div');
|
||||||
summary.className = 'quiet';
|
summary.className = 'quiet';
|
||||||
@@ -1009,10 +1017,10 @@ document.getElementById('filter-clear').addEventListener('click', () => {
|
|||||||
applyFilters();
|
applyFilters();
|
||||||
});
|
});
|
||||||
|
|
||||||
// Seed threshold and min_gap from server config, then start
|
// Seed margin and min_gap from server config, then start
|
||||||
fetch('/api/config').then(r => r.json()).then(cfg => {
|
fetch('/api/config').then(r => r.json()).then(cfg => {
|
||||||
if (cfg.threshold != null)
|
if (cfg.margin != null)
|
||||||
document.getElementById('threshold-input').value = cfg.threshold;
|
document.getElementById('margin-input').value = cfg.margin;
|
||||||
if (cfg.min_gap != null)
|
if (cfg.min_gap != null)
|
||||||
document.getElementById('min-gap-input').value = cfg.min_gap;
|
document.getElementById('min-gap-input').value = cfg.min_gap;
|
||||||
}).catch(() => {}).finally(() => load().then(() => setInterval(pollStatus, 5000)));
|
}).catch(() => {}).finally(() => load().then(() => setInterval(pollStatus, 5000)));
|
||||||
|
|||||||
Reference in New Issue
Block a user