Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 9ba084107b | |||
| 4aea07ae40 | |||
| b6b328dfb8 | |||
| a68af56421 | |||
| 7821f8823d | |||
| 75434ca96d |
@@ -147,11 +147,12 @@ strftime codes are substituted at split time. The file extension is added automa
|
|||||||
## Web UI (`web.py`)
|
## Web UI (`web.py`)
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
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 --threshold 0.03 # loudness threshold 0–1 (default 0.05)
|
||||||
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)
|
||||||
```
|
```
|
||||||
|
|
||||||
Shows recordings grouped by day with collapsible sections. Features:
|
Shows recordings grouped by day with collapsible sections. Features:
|
||||||
@@ -211,6 +212,8 @@ docker compose down && docker compose up -d --build
|
|||||||
|
|
||||||
**Log file in Docker:** The recorder always logs to stdout, so `docker compose logs -f` shows live output. To persist logs on the host, set `log_file = /app/recordings/recorder.log` in `config.ini` (the `recordings` directory is the bind mount).
|
**Log file in Docker:** The recorder always logs to stdout, so `docker compose logs -f` shows live output. To persist logs on the host, set `log_file = /app/recordings/recorder.log` in `config.ini` (the `recordings` directory is the bind mount).
|
||||||
|
|
||||||
|
**Analysis cache in Docker:** The web container mounts `./recordings` read-only, so analysis cache files are written to a separate `./analyses` bind mount (mapped to `/analyses` inside the container). This directory is created automatically by Docker Compose on first run. Cache files are stored as `analyses/<filename>.analysis.json` on the host.
|
||||||
|
|
||||||
**File retention:** Individual recordings can be deleted from the web UI. For bulk / automated cleanup, add a cron job on the host:
|
**File retention:** Individual recordings can be deleted from the web UI. For bulk / automated cleanup, add a cron job on the host:
|
||||||
```bash
|
```bash
|
||||||
# Delete recordings older than 30 days
|
# Delete recordings older than 30 days
|
||||||
|
|||||||
+2
-1
@@ -16,7 +16,8 @@ services:
|
|||||||
build: .
|
build: .
|
||||||
volumes:
|
volumes:
|
||||||
- ./recordings:/recordings:ro
|
- ./recordings:/recordings:ro
|
||||||
|
- ./analyses:/analyses
|
||||||
ports:
|
ports:
|
||||||
- "8080:8080"
|
- "8080:8080"
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
command: ["python", "web.py", "--dir", "/recordings"]
|
command: ["python", "web.py", "--dir", "/recordings", "--analyses-dir", "/analyses"]
|
||||||
|
|||||||
@@ -54,7 +54,7 @@ class AudioDevice:
|
|||||||
name: str # Human-readable name
|
name: str # Human-readable name
|
||||||
channels: int # Max input channels
|
channels: int # Max input channels
|
||||||
sample_rate: int # Default sample rate
|
sample_rate: int # Default sample rate
|
||||||
backend: str # Backend name (pulseaudio, pipewire, portaudio)
|
backend: str # Backend name ('alsa')
|
||||||
is_default: bool = False # Is system default
|
is_default: bool = False # Is system default
|
||||||
is_monitor: bool = False # Is a monitor/loopback source
|
is_monitor: bool = False # Is a monitor/loopback source
|
||||||
description: str = "" # Extended description
|
description: str = "" # Extended description
|
||||||
|
|||||||
+1
-47
@@ -4,15 +4,13 @@ Tests for isr.py
|
|||||||
Run with: pytest tests/
|
Run with: pytest tests/
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import io
|
|
||||||
import logging
|
import logging
|
||||||
import struct
|
import struct
|
||||||
import wave
|
import wave
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from types import SimpleNamespace
|
|
||||||
from typing import List
|
from typing import List
|
||||||
from unittest.mock import MagicMock, patch, call
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
@@ -571,50 +569,6 @@ class TestStreamRecorderRecord:
|
|||||||
)
|
)
|
||||||
return r
|
return r
|
||||||
|
|
||||||
def test_mp3_chunks_written_to_file(self, tmp_path):
|
|
||||||
chunks = [b"A" * 512, b"B" * 512, b"C" * 512]
|
|
||||||
rec = self._recorder(fmt="mp3")
|
|
||||||
rec.output_dir = str(tmp_path)
|
|
||||||
|
|
||||||
mock_resp = MagicMock()
|
|
||||||
mock_resp.headers = {"Content-Type": "audio/mpeg"}
|
|
||||||
mock_resp.iter_content.return_value = iter(chunks)
|
|
||||||
|
|
||||||
def _stop_after_connect(*args, **kwargs):
|
|
||||||
rec.running = True
|
|
||||||
return mock_resp
|
|
||||||
|
|
||||||
with patch.object(rec, "connect_stream", side_effect=[mock_resp, None]):
|
|
||||||
# connect_stream returns the mocked response on the first call;
|
|
||||||
# we stop the loop via a side-effectful iter_content
|
|
||||||
original_iter = mock_resp.iter_content.return_value
|
|
||||||
|
|
||||||
def _chunks_then_stop(chunk_size=8192):
|
|
||||||
for c in chunks:
|
|
||||||
yield c
|
|
||||||
rec.running = False # stop the outer while loop
|
|
||||||
|
|
||||||
mock_resp.iter_content.side_effect = _chunks_then_stop
|
|
||||||
mock_resp.headers = {"Content-Type": "audio/mpeg"}
|
|
||||||
rec.detected_format = "mp3"
|
|
||||||
rec.running = True
|
|
||||||
rec.header_capture_complete = True
|
|
||||||
rec.stream_headers = None
|
|
||||||
|
|
||||||
# Open a file manually so we can verify writes
|
|
||||||
filename = rec.generate_filename("mp3")
|
|
||||||
rec.current_file = open(filename, "wb")
|
|
||||||
rec.current_filename = filename
|
|
||||||
|
|
||||||
# Simulate one inner loop iteration
|
|
||||||
for chunk in _chunks_then_stop():
|
|
||||||
if chunk:
|
|
||||||
rec.current_file.write(chunk)
|
|
||||||
rec.close_current_file()
|
|
||||||
|
|
||||||
written = Path(filename).read_bytes()
|
|
||||||
assert written == b"A" * 512 + b"B" * 512 + b"C" * 512
|
|
||||||
|
|
||||||
def test_connection_failure_retries(self, tmp_path):
|
def test_connection_failure_retries(self, tmp_path):
|
||||||
rec = self._recorder()
|
rec = self._recorder()
|
||||||
rec.output_dir = str(tmp_path)
|
rec.output_dir = str(tmp_path)
|
||||||
|
|||||||
@@ -207,19 +207,18 @@ def analyze_flac(path: Path, window_samples: int = WINDOW_SAMPLES,
|
|||||||
# Analysis cache helpers
|
# Analysis cache helpers
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
def _analysis_cache_path(base: Path, audio_path: Path) -> Path:
|
def _analysis_cache_path(analyses_base: Path, recordings_base: Path, audio_path: Path) -> Path:
|
||||||
rel = audio_path.relative_to(base)
|
rel = audio_path.relative_to(recordings_base)
|
||||||
return base / 'analyses' / rel.parent / (rel.name + '.analysis.json')
|
return analyses_base / rel.parent / (rel.name + '.analysis.json')
|
||||||
|
|
||||||
|
|
||||||
def prune_orphan_analyses(base: Path):
|
def prune_orphan_analyses(analyses_base: Path, recordings_base: Path):
|
||||||
analyses_dir = base / 'analyses'
|
if not analyses_base.exists():
|
||||||
if not analyses_dir.exists():
|
|
||||||
return
|
return
|
||||||
removed = 0
|
removed = 0
|
||||||
for cache in analyses_dir.rglob('*.analysis.json'):
|
for cache in analyses_base.rglob('*.analysis.json'):
|
||||||
rel = cache.relative_to(analyses_dir)
|
rel = cache.relative_to(analyses_base)
|
||||||
audio_path = base / rel.parent / rel.name[:-len('.analysis.json')]
|
audio_path = recordings_base / rel.parent / rel.name[:-len('.analysis.json')]
|
||||||
if not audio_path.exists():
|
if not audio_path.exists():
|
||||||
try:
|
try:
|
||||||
cache.unlink()
|
cache.unlink()
|
||||||
@@ -283,6 +282,7 @@ def list_files(recordings_dir: str):
|
|||||||
|
|
||||||
class _Handler(BaseHTTPRequestHandler):
|
class _Handler(BaseHTTPRequestHandler):
|
||||||
recordings_dir: str = 'recordings'
|
recordings_dir: str = 'recordings'
|
||||||
|
analyses_dir: str = 'recordings/analyses'
|
||||||
threshold: float = LOUD_THRESHOLD
|
threshold: float = LOUD_THRESHOLD
|
||||||
min_gap: float = MIN_GAP_SECONDS
|
min_gap: float = MIN_GAP_SECONDS
|
||||||
|
|
||||||
@@ -358,12 +358,14 @@ class _Handler(BaseHTTPRequestHandler):
|
|||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
base = Path(self.recordings_dir).resolve()
|
recordings_base = Path(self.recordings_dir).resolve()
|
||||||
cache_path = _analysis_cache_path(base, path)
|
analyses_base = Path(self.analyses_dir).resolve()
|
||||||
|
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('threshold') == threshold and cached.get('min_gap') == min_gap:
|
||||||
self._send(200, json.dumps(cached['result']).encode('utf-8'), 'application/json')
|
payload = dict(cached['result']); payload['cached'] = True
|
||||||
|
self._send(200, json.dumps(payload).encode('utf-8'), 'application/json')
|
||||||
return
|
return
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
@@ -385,8 +387,8 @@ class _Handler(BaseHTTPRequestHandler):
|
|||||||
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')
|
tmp.write_text(json.dumps({'threshold': threshold, 'min_gap': min_gap, 'result': result}), 'utf-8')
|
||||||
os.replace(tmp, cache_path)
|
os.replace(tmp, cache_path)
|
||||||
except Exception:
|
except Exception as e:
|
||||||
pass
|
print(f'Warning: could not write analysis cache {cache_path}: {e}', flush=True)
|
||||||
|
|
||||||
self._send(200, json.dumps(result).encode('utf-8'), 'application/json')
|
self._send(200, json.dumps(result).encode('utf-8'), 'application/json')
|
||||||
|
|
||||||
@@ -511,7 +513,11 @@ class _Handler(BaseHTTPRequestHandler):
|
|||||||
return
|
return
|
||||||
|
|
||||||
try:
|
try:
|
||||||
_analysis_cache_path(Path(self.recordings_dir).resolve(), path).unlink()
|
_analysis_cache_path(
|
||||||
|
Path(self.analyses_dir).resolve(),
|
||||||
|
Path(self.recordings_dir).resolve(),
|
||||||
|
path,
|
||||||
|
).unlink()
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
@@ -703,6 +709,11 @@ button.chip:hover{background:#6c1f08;border-color:#9a3412}
|
|||||||
button.chip:focus-visible{outline:2px solid var(--accent);outline-offset:2px}
|
button.chip:focus-visible{outline:2px solid var(--accent);outline-offset:2px}
|
||||||
.quiet{color:var(--muted);font-size:12px;margin-top:6px}
|
.quiet{color:var(--muted);font-size:12px;margin-top:6px}
|
||||||
.spin{color:var(--muted);font-style:italic;font-size:12px;padding:6px 0}
|
.spin{color:var(--muted);font-style:italic;font-size:12px;padding:6px 0}
|
||||||
|
.prog-bar{height:3px;background:var(--brd);border-radius:2px;margin:6px 0 4px;overflow:hidden}
|
||||||
|
.prog-fill{height:100%;background:var(--accent);border-radius:2px;transition:width 0.15s ease}
|
||||||
|
.prog-file{font-size:12px;color:var(--muted);font-style:italic;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:100%}
|
||||||
|
.prog-tally{font-size:11px;color:var(--muted);margin-top:3px}
|
||||||
|
.cached-badge{font-size:10px;color:var(--muted);background:var(--surf);border:1px solid var(--brd);border-radius:3px;padding:1px 5px;margin-left:6px;vertical-align:middle;font-style:normal}
|
||||||
.empty{text-align:center;padding:60px;color:var(--muted)}
|
.empty{text-align:center;padding:60px;color:var(--muted)}
|
||||||
/* player row */
|
/* player row */
|
||||||
.player-row td{padding:0 10px 10px;background:var(--bg);border-bottom:1px solid var(--brd)}
|
.player-row td{padding:0 10px 10px;background:var(--bg);border-bottom:1px solid var(--brd)}
|
||||||
@@ -855,7 +866,7 @@ function togglePlayer(idx, filename) {
|
|||||||
|
|
||||||
if (!open) {
|
if (!open) {
|
||||||
if (!audio.getAttribute('data-src-set')) {
|
if (!audio.getAttribute('data-src-set')) {
|
||||||
audio.preload = 'metadata';
|
audio.preload = 'auto';
|
||||||
audio.src = '/stream/' + encodeURIComponent(filename);
|
audio.src = '/stream/' + encodeURIComponent(filename);
|
||||||
audio.load();
|
audio.load();
|
||||||
audio.setAttribute('data-src-set','1');
|
audio.setAttribute('data-src-set','1');
|
||||||
@@ -925,6 +936,7 @@ function seekToSection(idx, filename, startSec, endSec, sectionIdx) {
|
|||||||
if (pbtn.getAttribute('aria-expanded') !== 'true') togglePlayer(idx, filename);
|
if (pbtn.getAttribute('aria-expanded') !== 'true') togglePlayer(idx, filename);
|
||||||
activePlayerIdx = idx;
|
activePlayerIdx = idx;
|
||||||
const audio = document.getElementById('aud-'+idx);
|
const audio = document.getElementById('aud-'+idx);
|
||||||
|
audio.preload = 'auto';
|
||||||
const seekTo = Math.max(0, startSec - getPreroll());
|
const seekTo = Math.max(0, startSec - getPreroll());
|
||||||
const doSeek = () => { audio.currentTime = seekTo; audio.play().catch(() => {}); };
|
const doSeek = () => { audio.currentTime = seekTo; audio.play().catch(() => {}); };
|
||||||
if (audio.readyState >= 1) doSeek();
|
if (audio.readyState >= 1) doSeek();
|
||||||
@@ -954,6 +966,12 @@ async function analyse(idx, filename, cell, btn) {
|
|||||||
}
|
}
|
||||||
const box = document.createElement('div'); box.className='wbox';
|
const box = document.createElement('div'); box.className='wbox';
|
||||||
box.appendChild(drawWave(d.rms_display||[], d.sections||[], d.duration||0, filename));
|
box.appendChild(drawWave(d.rms_display||[], d.sections||[], d.duration||0, filename));
|
||||||
|
if (d.cached) {
|
||||||
|
const badge = document.createElement('span');
|
||||||
|
badge.className = 'cached-badge'; badge.textContent = 'cached';
|
||||||
|
badge.title = 'Result loaded from cache — change threshold/gap and re-analyse to recompute';
|
||||||
|
box.firstChild.after(badge);
|
||||||
|
}
|
||||||
|
|
||||||
const chips = document.createElement('div');
|
const chips = document.createElement('div');
|
||||||
chips.className='chips';
|
chips.className='chips';
|
||||||
@@ -1322,22 +1340,43 @@ async function dayHighlights(dayId, analyzableFiles) {
|
|||||||
|
|
||||||
hlRow.hidden = false;
|
hlRow.hidden = false;
|
||||||
const n = analyzableFiles.length;
|
const n = analyzableFiles.length;
|
||||||
contentEl.innerHTML = `<div class="spin" aria-live="polite" aria-busy="true">Analysing ${n} file${n!==1?'s':''}…</div>`;
|
|
||||||
if (btn) btn.disabled = true;
|
if (btn) btn.disabled = true;
|
||||||
|
|
||||||
|
// Build progress UI
|
||||||
|
const progWrap = document.createElement('div');
|
||||||
|
progWrap.setAttribute('aria-live', 'polite'); progWrap.setAttribute('aria-busy', 'true');
|
||||||
|
const progBar = document.createElement('div'); progBar.className = 'prog-bar';
|
||||||
|
const progFill = document.createElement('div'); progFill.className = 'prog-fill'; progFill.style.width = '0%';
|
||||||
|
progBar.appendChild(progFill);
|
||||||
|
const progFile = document.createElement('div'); progFile.className = 'prog-file';
|
||||||
|
const progTally = document.createElement('div'); progTally.className = 'prog-tally';
|
||||||
|
progWrap.appendChild(progBar); progWrap.appendChild(progFile); progWrap.appendChild(progTally);
|
||||||
|
contentEl.innerHTML = ''; contentEl.appendChild(progWrap);
|
||||||
|
|
||||||
const threshold = document.getElementById('threshold-input').value || '0.05';
|
const threshold = document.getElementById('threshold-input').value || '0.05';
|
||||||
const minGap = document.getElementById('min-gap-input').value || '2';
|
const minGap = document.getElementById('min-gap-input').value || '2';
|
||||||
|
|
||||||
const results = [];
|
const results = [];
|
||||||
for (const f of analyzableFiles) {
|
let nCached = 0, nLive = 0;
|
||||||
|
for (let i = 0; i < analyzableFiles.length; i++) {
|
||||||
|
const f = analyzableFiles[i];
|
||||||
|
progFile.textContent = `${i + 1} / ${n} — ${f.name}`;
|
||||||
|
progFill.style.width = `${(i / n) * 100}%`;
|
||||||
|
const tallyParts = [];
|
||||||
|
if (nCached) tallyParts.push(`${nCached} cached`);
|
||||||
|
if (nLive) tallyParts.push(`${nLive} analysed`);
|
||||||
|
progTally.textContent = tallyParts.join(' · ');
|
||||||
try {
|
try {
|
||||||
const r = await fetch('/api/analyze?file=' + encodeURIComponent(f.name)
|
const r = await fetch('/api/analyze?file=' + encodeURIComponent(f.name)
|
||||||
+ '&threshold=' + encodeURIComponent(threshold)
|
+ '&threshold=' + encodeURIComponent(threshold)
|
||||||
+ '&min_gap=' + encodeURIComponent(minGap));
|
+ '&min_gap=' + encodeURIComponent(minGap));
|
||||||
const d = await r.json();
|
const d = await r.json();
|
||||||
if (!d.error) results.push({ f, data: d });
|
if (!d.error) { results.push({ f, data: d }); d.cached ? nCached++ : nLive++; }
|
||||||
} catch(e) {}
|
} catch(e) {}
|
||||||
}
|
}
|
||||||
|
progFill.style.width = '100%';
|
||||||
|
progFile.textContent = `Done — ${n} file${n!==1?'s':''} (${nCached} cached, ${nLive} analysed)`;
|
||||||
|
progTally.textContent = '';
|
||||||
|
|
||||||
if (!results.length) {
|
if (!results.length) {
|
||||||
contentEl.innerHTML = '<div class="quiet">No analysable results.</div>';
|
contentEl.innerHTML = '<div class="quiet">No analysable results.</div>';
|
||||||
@@ -1439,24 +1478,33 @@ async function dayHighlights(dayId, analyzableFiles) {
|
|||||||
box.appendChild(labels);
|
box.appendChild(labels);
|
||||||
|
|
||||||
if (dayActiveSections.length) {
|
if (dayActiveSections.length) {
|
||||||
const chips = document.createElement('div');
|
const MAX_DAY_CHIPS = 50;
|
||||||
chips.className = 'chips';
|
if (dayActiveSections.length > MAX_DAY_CHIPS) {
|
||||||
chips.setAttribute('role', 'list');
|
const note = document.createElement('p');
|
||||||
chips.setAttribute('aria-label', 'Day loud sections — click to jump, J/K to step across files');
|
note.className = 'quiet';
|
||||||
dayActiveSections.forEach((sec, si) => {
|
note.style.marginTop = '6px';
|
||||||
const c = document.createElement('button');
|
note.textContent = `${dayActiveSections.length} sections — use J / K to navigate`;
|
||||||
c.className = 'chip';
|
box.appendChild(note);
|
||||||
c.setAttribute('role', 'listitem');
|
} else {
|
||||||
c.title = sec.filename + ' @ ' + fmtT(sec.start);
|
const chips = document.createElement('div');
|
||||||
const d = new Date(sec.absStart * 1000);
|
chips.className = 'chips';
|
||||||
const hms = d.getHours().toString().padStart(2,'0') + ':'
|
chips.setAttribute('role', 'list');
|
||||||
+ d.getMinutes().toString().padStart(2,'0') + ':'
|
chips.setAttribute('aria-label', 'Day loud sections — click to jump, J/K to step across files');
|
||||||
+ d.getSeconds().toString().padStart(2,'0');
|
dayActiveSections.forEach((sec, si) => {
|
||||||
c.textContent = hms;
|
const c = document.createElement('button');
|
||||||
c.addEventListener('click', () => jumpToDaySection(si));
|
c.className = 'chip';
|
||||||
chips.appendChild(c);
|
c.setAttribute('role', 'listitem');
|
||||||
});
|
c.title = sec.filename + ' @ ' + fmtT(sec.start);
|
||||||
box.appendChild(chips);
|
const d = new Date(sec.absStart * 1000);
|
||||||
|
const hms = d.getHours().toString().padStart(2,'0') + ':'
|
||||||
|
+ d.getMinutes().toString().padStart(2,'0') + ':'
|
||||||
|
+ d.getSeconds().toString().padStart(2,'0');
|
||||||
|
c.textContent = hms;
|
||||||
|
c.addEventListener('click', () => jumpToDaySection(si));
|
||||||
|
chips.appendChild(c);
|
||||||
|
});
|
||||||
|
box.appendChild(chips);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const summary = document.createElement('div');
|
const summary = document.createElement('div');
|
||||||
@@ -1498,6 +1546,7 @@ function jumpToDaySection(si) {
|
|||||||
|
|
||||||
const audio = document.getElementById('aud-' + fileIdx);
|
const audio = document.getElementById('aud-' + fileIdx);
|
||||||
if (!audio) return;
|
if (!audio) return;
|
||||||
|
audio.preload = 'auto';
|
||||||
const seekTo = Math.max(0, start - getPreroll());
|
const seekTo = Math.max(0, start - getPreroll());
|
||||||
const doSeek = () => { audio.currentTime = seekTo; audio.play().catch(() => {}); };
|
const doSeek = () => { audio.currentTime = seekTo; audio.play().catch(() => {}); };
|
||||||
if (audio.readyState >= 1) doSeek();
|
if (audio.readyState >= 1) doSeek();
|
||||||
@@ -1593,25 +1642,31 @@ def main():
|
|||||||
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('--threshold', type=float, default=LOUD_THRESHOLD,
|
||||||
help=f'RMS loudness threshold 0–1 (default: {LOUD_THRESHOLD})')
|
help=f'RMS loudness threshold 0–1 (default: {LOUD_THRESHOLD})')
|
||||||
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,
|
||||||
|
help='Directory for analysis cache files (default: <recordings-dir>/analyses)')
|
||||||
args = parser.parse_args()
|
args = parser.parse_args()
|
||||||
|
|
||||||
rec_dir = Path(args.dir)
|
rec_dir = Path(args.dir).resolve()
|
||||||
if not rec_dir.exists():
|
analyses_dir = Path(args.analyses_dir).resolve() if args.analyses_dir else rec_dir / 'analyses'
|
||||||
print(f"Warning: recordings directory '{args.dir}' does not exist yet.")
|
|
||||||
|
|
||||||
prune_orphan_analyses(rec_dir.resolve())
|
if not rec_dir.exists():
|
||||||
|
print(f"Warning: recordings directory '{rec_dir}' does not exist yet.")
|
||||||
|
|
||||||
|
prune_orphan_analyses(analyses_dir, rec_dir)
|
||||||
|
|
||||||
class Handler(_Handler):
|
class Handler(_Handler):
|
||||||
recordings_dir = str(rec_dir.resolve())
|
recordings_dir = str(rec_dir)
|
||||||
|
analyses_dir = str(analyses_dir)
|
||||||
threshold = args.threshold
|
threshold = args.threshold
|
||||||
min_gap = args.min_gap
|
min_gap = args.min_gap
|
||||||
|
|
||||||
server = ThreadingHTTPServer((args.host, args.port), Handler)
|
server = ThreadingHTTPServer((args.host, args.port), Handler)
|
||||||
|
|
||||||
print(f"ISR Web running → http://{args.host}:{args.port}/")
|
print(f"ISR Web running → http://{args.host}:{args.port}/")
|
||||||
print(f"Recordings dir → {rec_dir.resolve()}")
|
print(f"Recordings dir → {rec_dir}")
|
||||||
|
print(f"Analyses dir → {analyses_dir}")
|
||||||
print(f"Loud threshold → {args.threshold}")
|
print(f"Loud threshold → {args.threshold}")
|
||||||
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")
|
||||||
|
|||||||
Reference in New Issue
Block a user