feat: day-grouped table, day highlights timeline, and grace period for loud sections
- Recordings table now groups files by day with collapsible headings showing file count, total duration, and size; most recent day expands by default, older days start collapsed - Day highlights button runs loudness analysis across all WAV/FLAC files in a day and renders a combined SVG activity timeline (blue = file extents, orange = loud sections) with time labels - Grace period control (default 2 s, max 300 s) merges loud sections separated by less than this gap; exposed in the controls bar, via --min-gap CLI flag, and as a per-request min_gap param on /api/analyze - Delete re-renders via applyFilters so day headings stay consistent
This commit is contained in:
@@ -151,16 +151,20 @@ 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)
|
||||||
```
|
```
|
||||||
|
|
||||||
Shows a table of all recordings sorted newest-first. 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 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.
|
||||||
- **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.
|
- **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.
|
||||||
|
- **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).
|
||||||
- **Filters** — live filename search and from/to date pickers above the table; applied client-side with no additional requests. Shows `N of M shown` when a filter is active.
|
- **Filters** — live filename search and from/to date pickers above the table; applied client-side with no additional requests. Shows `N of M shown` when a filter is active.
|
||||||
- **Delete** — `✕ Delete` button per row with confirmation prompt; disabled for files currently being recorded; sends `DELETE /api/files/<name>` and removes the row without a full page reload.
|
- **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 `—` (header is unfinalized until recording stops).
|
- **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 `—` (header is unfinalized until recording stops).
|
||||||
- **WCAG-compliant** — skip link, `aria-expanded`/`aria-controls` on the player toggle, `aria-live` status, focus management, `role=img` on SVG waveforms.
|
- **WCAG-compliant** — skip link, `aria-expanded`/`aria-controls` on the player toggle, `aria-live` status, focus management, `role=img` on SVG waveforms.
|
||||||
|
|
||||||
|
|||||||
@@ -114,7 +114,7 @@ def _compute_rms_windows_wav(wf, channels: int, sampwidth: int, framerate: int,
|
|||||||
|
|
||||||
|
|
||||||
def _loud_sections(rms_values: list, window_dur: float, duration: float,
|
def _loud_sections(rms_values: list, window_dur: float, duration: float,
|
||||||
threshold: float) -> list:
|
threshold: float, min_gap: float = MIN_GAP_SECONDS) -> list:
|
||||||
sections = []
|
sections = []
|
||||||
start_t = None
|
start_t = None
|
||||||
last_loud_t = None
|
last_loud_t = None
|
||||||
@@ -126,7 +126,7 @@ def _loud_sections(rms_values: list, window_dur: float, duration: float,
|
|||||||
start_t = t
|
start_t = t
|
||||||
last_loud_t = t
|
last_loud_t = t
|
||||||
else:
|
else:
|
||||||
if start_t is not None and (t - last_loud_t) > MIN_GAP_SECONDS:
|
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)})
|
||||||
start_t = None
|
start_t = None
|
||||||
@@ -139,7 +139,8 @@ def _loud_sections(rms_values: list, window_dur: float, duration: float,
|
|||||||
|
|
||||||
|
|
||||||
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) -> dict:
|
window_samples: int, threshold: float,
|
||||||
|
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
|
||||||
|
|
||||||
@@ -152,14 +153,15 @@ def _package_result(rms_values: list, framerate: int, n_frames: int,
|
|||||||
return {
|
return {
|
||||||
'rms': rms_values,
|
'rms': rms_values,
|
||||||
'rms_display': rms_display,
|
'rms_display': rms_display,
|
||||||
'sections': _loud_sections(rms_values, window_dur, duration, threshold),
|
'sections': _loud_sections(rms_values, window_dur, duration, threshold, 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) -> dict:
|
threshold: float = LOUD_THRESHOLD,
|
||||||
|
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:
|
||||||
channels = wf.getnchannels()
|
channels = wf.getnchannels()
|
||||||
@@ -171,11 +173,12 @@ 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)
|
return _package_result(rms_values, framerate, n_frames, window_samples, threshold, 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) -> dict:
|
threshold: float = LOUD_THRESHOLD,
|
||||||
|
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:
|
||||||
return {'error': 'FLAC analysis requires: pip install numpy soundfile'}
|
return {'error': 'FLAC analysis requires: pip install numpy soundfile'}
|
||||||
@@ -197,7 +200,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)
|
return _package_result(rms_values, framerate, n_frames, window_samples, threshold, min_gap)
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -254,6 +257,7 @@ def list_files(recordings_dir: str):
|
|||||||
class _Handler(BaseHTTPRequestHandler):
|
class _Handler(BaseHTTPRequestHandler):
|
||||||
recordings_dir: str = 'recordings'
|
recordings_dir: str = 'recordings'
|
||||||
threshold: float = LOUD_THRESHOLD
|
threshold: float = LOUD_THRESHOLD
|
||||||
|
min_gap: float = MIN_GAP_SECONDS
|
||||||
|
|
||||||
def do_DELETE(self):
|
def do_DELETE(self):
|
||||||
parsed = urlparse(self.path)
|
parsed = urlparse(self.path)
|
||||||
@@ -312,6 +316,12 @@ class _Handler(BaseHTTPRequestHandler):
|
|||||||
except (ValueError, TypeError):
|
except (ValueError, TypeError):
|
||||||
threshold = self.threshold
|
threshold = self.threshold
|
||||||
|
|
||||||
|
try:
|
||||||
|
min_gap = float(qs.get('min_gap', [self.min_gap])[0])
|
||||||
|
min_gap = max(0.0, min(300.0, min_gap))
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
min_gap = self.min_gap
|
||||||
|
|
||||||
status_path = Path(self.recordings_dir) / 'status.json'
|
status_path = Path(self.recordings_dir) / 'status.json'
|
||||||
try:
|
try:
|
||||||
with open(status_path) as fh:
|
with open(status_path) as fh:
|
||||||
@@ -323,12 +333,12 @@ class _Handler(BaseHTTPRequestHandler):
|
|||||||
|
|
||||||
ext = path.suffix.lower()
|
ext = path.suffix.lower()
|
||||||
if ext == '.wav':
|
if ext == '.wav':
|
||||||
result = analyze_wav(path, threshold=threshold)
|
result = analyze_wav(path, threshold=threshold, 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)
|
result = analyze_flac(path, threshold=threshold, 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
|
||||||
@@ -432,7 +442,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})
|
data = json.dumps({'threshold': self.threshold, '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):
|
||||||
@@ -667,6 +677,21 @@ button.cut:hover:not(:disabled){background:#1e3a8a}
|
|||||||
.filter-bar input[type=date]{background:var(--bg);border:1px solid var(--brd);
|
.filter-bar input[type=date]{background:var(--bg);border:1px solid var(--brd);
|
||||||
color:var(--txt);padding:3px 6px;border-radius:4px;font-size:13px;color-scheme:dark}
|
color:var(--txt);padding:3px 6px;border-radius:4px;font-size:13px;color-scheme:dark}
|
||||||
.filter-bar input:focus{outline:2px solid var(--accent);outline-offset:1px}
|
.filter-bar input:focus{outline:2px solid var(--accent);outline-offset:1px}
|
||||||
|
/* day groups */
|
||||||
|
tr.day-head td{background:var(--surf);padding:7px 10px;border-bottom:2px solid var(--brd);border-top:2px solid var(--brd)}
|
||||||
|
.day-toggle{background:none;border:none;color:var(--txt);font-size:13px;font-weight:600;
|
||||||
|
cursor:pointer;padding:2px 0;display:inline-flex;align-items:center;gap:8px}
|
||||||
|
.day-toggle:hover{color:var(--accent)}
|
||||||
|
.day-toggle:focus-visible{outline:2px solid var(--accent);outline-offset:2px;border-radius:2px}
|
||||||
|
.day-meta{color:var(--muted);font-size:12px;font-weight:400}
|
||||||
|
button.day-hl{color:var(--green);border-color:#166534;background:#052e16;font-size:11px;
|
||||||
|
margin-left:14px;vertical-align:middle}
|
||||||
|
button.day-hl:hover:not(:disabled){background:#0a3d1f}
|
||||||
|
button.day-hl:disabled{opacity:.5;cursor:default}
|
||||||
|
tr.day-hl-row td{background:var(--bg);padding:8px 12px 12px}
|
||||||
|
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}
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
@@ -687,6 +712,10 @@ button.cut:hover:not(:disabled){background:#1e3a8a}
|
|||||||
<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">
|
||||||
<span id="preroll-hint" class="controls-hint">seconds to rewind before section start</span>
|
<span id="preroll-hint" class="controls-hint">seconds to rewind before section start</span>
|
||||||
|
<label for="min-gap-input" style="margin-left:16px">Grace period:</label>
|
||||||
|
<input type="number" id="min-gap-input" min="0" max="300" step="0.5" value="2"
|
||||||
|
aria-describedby="min-gap-hint">
|
||||||
|
<span id="min-gap-hint" class="controls-hint">seconds — merge loud sections closer than this</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="filter-bar" role="search" aria-label="Filter recordings">
|
<div class="filter-bar" role="search" aria-label="Filter recordings">
|
||||||
<label for="filter-name">Search:</label>
|
<label for="filter-name">Search:</label>
|
||||||
@@ -759,6 +788,18 @@ const sectionMap = new Map();
|
|||||||
let activePlayerIdx = null;
|
let activePlayerIdx = null;
|
||||||
// full file list from server, annotated with stable _idx
|
// full file list from server, annotated with stable _idx
|
||||||
let allFiles = [];
|
let allFiles = [];
|
||||||
|
// dayId -> boolean, persists expanded state across re-renders
|
||||||
|
const dayExpanded = new Map();
|
||||||
|
|
||||||
|
function groupByDay(files) {
|
||||||
|
const map = new Map();
|
||||||
|
files.forEach(f => {
|
||||||
|
const day = f.date.slice(0, 10);
|
||||||
|
if (!map.has(day)) map.set(day, []);
|
||||||
|
map.get(day).push(f);
|
||||||
|
});
|
||||||
|
return map;
|
||||||
|
}
|
||||||
|
|
||||||
function togglePlayer(idx, filename) {
|
function togglePlayer(idx, filename) {
|
||||||
const prow = document.getElementById('prow-'+idx);
|
const prow = document.getElementById('prow-'+idx);
|
||||||
@@ -854,9 +895,11 @@ async function analyse(idx, filename, cell, btn) {
|
|||||||
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 threshold = document.getElementById('threshold-input').value || '0.05';
|
||||||
|
const minGap = document.getElementById('min-gap-input').value || '2';
|
||||||
try {
|
try {
|
||||||
const r = await fetch('/api/analyze?file='+encodeURIComponent(filename)
|
const r = await fetch('/api/analyze?file='+encodeURIComponent(filename)
|
||||||
+'&threshold='+encodeURIComponent(threshold));
|
+'&threshold='+encodeURIComponent(threshold)
|
||||||
|
+'&min_gap='+encodeURIComponent(minGap));
|
||||||
const d = await r.json();
|
const d = await r.json();
|
||||||
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>`;
|
||||||
@@ -941,30 +984,22 @@ document.addEventListener('keydown', e => {
|
|||||||
async function deleteFile(idx, filename) {
|
async function deleteFile(idx, filename) {
|
||||||
if (!confirm(`Delete "${filename}"?\nThis cannot be undone.`)) return;
|
if (!confirm(`Delete "${filename}"?\nThis cannot be undone.`)) return;
|
||||||
const btn = document.getElementById('delbtn-'+idx);
|
const btn = document.getElementById('delbtn-'+idx);
|
||||||
btn.disabled = true;
|
if (btn) { btn.disabled = true; btn.textContent = '…'; }
|
||||||
btn.textContent = '…';
|
|
||||||
try {
|
try {
|
||||||
const r = await fetch('/api/files/'+encodeURIComponent(filename), {method:'DELETE'});
|
const r = await fetch('/api/files/'+encodeURIComponent(filename), {method:'DELETE'});
|
||||||
if (r.ok) {
|
if (r.ok) {
|
||||||
document.getElementById('row-'+idx)?.remove();
|
|
||||||
document.getElementById('prow-'+idx)?.remove();
|
|
||||||
recMap.delete(idx);
|
|
||||||
allFiles = allFiles.filter(f => f._idx !== idx);
|
allFiles = allFiles.filter(f => f._idx !== idx);
|
||||||
const visible = document.querySelectorAll('tr.data-row').length;
|
recMap.delete(idx);
|
||||||
const total = allFiles.length;
|
applyFilters();
|
||||||
document.getElementById('subtitle').textContent = total === visible
|
|
||||||
? `${total} recording${total!==1?'s':''} found`
|
|
||||||
: `${visible} of ${total} recording${total!==1?'s':''} shown`;
|
|
||||||
if (!visible) document.getElementById('empty').style.display = '';
|
|
||||||
updateStorage();
|
updateStorage();
|
||||||
} else {
|
} else {
|
||||||
const d = await r.json().catch(()=>({}));
|
const d = await r.json().catch(()=>({}));
|
||||||
alert('Delete failed: '+(d.error||r.statusText));
|
alert('Delete failed: '+(d.error||r.statusText));
|
||||||
btn.disabled = false; btn.textContent = '✕ Delete';
|
if (btn) { btn.disabled = false; btn.textContent = '✕ Delete'; }
|
||||||
}
|
}
|
||||||
} catch(e) {
|
} catch(e) {
|
||||||
alert('Delete failed: '+e.message);
|
alert('Delete failed: '+e.message);
|
||||||
btn.disabled = false; btn.textContent = '✕ Delete';
|
if (btn) { btn.disabled = false; btn.textContent = '✕ Delete'; }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -979,6 +1014,55 @@ async function updateStorage() {
|
|||||||
} catch(e) {}
|
} catch(e) {}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function _attachFileRowHandlers(f, isRec, expanded, dayId) {
|
||||||
|
const i = f._idx;
|
||||||
|
const ext = f.ext;
|
||||||
|
const canAnalyse = ext === 'wav' || ext === 'flac';
|
||||||
|
|
||||||
|
if (canAnalyse) {
|
||||||
|
const cell = document.getElementById('wave-'+i);
|
||||||
|
const abtn = document.createElement('button');
|
||||||
|
abtn.textContent = 'Analyse';
|
||||||
|
abtn.setAttribute('aria-label', `Analyse loudness of ${f.name}`);
|
||||||
|
if (isRec) {
|
||||||
|
abtn.disabled = true;
|
||||||
|
abtn.title = 'Recording in progress — analyse after recording stops';
|
||||||
|
} else {
|
||||||
|
abtn.addEventListener('click', () => analyse(i, f.name, cell, abtn));
|
||||||
|
}
|
||||||
|
cell.appendChild(abtn);
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById('pbtn-'+i)
|
||||||
|
.addEventListener('click', () => togglePlayer(i, f.name));
|
||||||
|
|
||||||
|
if (!isRec) {
|
||||||
|
document.getElementById('cutbtn-'+i).addEventListener('click', () => {
|
||||||
|
const pbtn = document.getElementById('pbtn-'+i);
|
||||||
|
if (pbtn.getAttribute('aria-expanded') !== 'true') togglePlayer(i, f.name);
|
||||||
|
document.getElementById('cut-start-'+i)?.focus();
|
||||||
|
});
|
||||||
|
document.getElementById('cut-dl-'+i).addEventListener('click', () => {
|
||||||
|
const startStr = document.getElementById('cut-start-'+i).value;
|
||||||
|
const endStr = document.getElementById('cut-end-'+i).value;
|
||||||
|
const start = parseTime(startStr);
|
||||||
|
const end = parseTime(endStr);
|
||||||
|
if (start === null || end === null) {
|
||||||
|
alert('Enter start and end times, e.g. 1:30 or 0:01:30');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (start >= end) { alert('Start must be before end'); return; }
|
||||||
|
window.location.href =
|
||||||
|
'/api/cut?file=' + encodeURIComponent(f.name) +
|
||||||
|
'&start=' + start + '&end=' + end;
|
||||||
|
});
|
||||||
|
document.getElementById('delbtn-'+i)
|
||||||
|
.addEventListener('click', () => deleteFile(i, f.name));
|
||||||
|
}
|
||||||
|
|
||||||
|
recMap.set(i, f.name);
|
||||||
|
}
|
||||||
|
|
||||||
function renderFiles(files) {
|
function renderFiles(files) {
|
||||||
const tbody = document.getElementById('tbody');
|
const tbody = document.getElementById('tbody');
|
||||||
tbody.innerHTML = '';
|
tbody.innerHTML = '';
|
||||||
@@ -992,129 +1076,278 @@ function renderFiles(files) {
|
|||||||
: `${visible} of ${total} recording${total!==1?'s':''} shown`;
|
: `${visible} of ${total} recording${total!==1?'s':''} shown`;
|
||||||
document.getElementById('empty').style.display = visible ? 'none' : '';
|
document.getElementById('empty').style.display = visible ? 'none' : '';
|
||||||
|
|
||||||
files.forEach(f => {
|
const days = groupByDay(files);
|
||||||
const i = f._idx;
|
let isFirst = true;
|
||||||
const ext = f.ext;
|
|
||||||
const canAnalyse = ext === 'wav' || ext === 'flac';
|
|
||||||
const isRec = !!f.recording;
|
|
||||||
|
|
||||||
// ---- main data row ----
|
days.forEach((dayFiles, day) => {
|
||||||
const tr = document.createElement('tr');
|
const dayId = 'day-' + day.replace(/-/g, '');
|
||||||
tr.className = 'data-row';
|
if (!dayExpanded.has(dayId)) dayExpanded.set(dayId, isFirst);
|
||||||
tr.id = 'row-'+i;
|
const expanded = dayExpanded.get(dayId);
|
||||||
|
isFirst = false;
|
||||||
|
|
||||||
const recBadge = `<span id="rec-${i}" class="badge-rec"${isRec?'':' hidden'}
|
const totalSize = dayFiles.reduce((a, f) => a + f.size, 0);
|
||||||
aria-label="Currently recording" aria-hidden="${isRec?'false':'true'}">
|
const totalDur = dayFiles.reduce((a, f) => a + (f.duration || 0), 0);
|
||||||
<span aria-hidden="true">●</span> REC</span>`;
|
const canHl = dayFiles.some(f => (f.ext === 'wav' || f.ext === 'flac') && !f.recording);
|
||||||
|
const durStr = totalDur > 0 ? ' · ' + fmtDur(Math.round(totalDur)) : '';
|
||||||
|
const sizeStr = ' · ' + fmtSize(totalSize);
|
||||||
|
const fileStr = `${dayFiles.length} file${dayFiles.length !== 1 ? 's' : ''}`;
|
||||||
|
|
||||||
tr.innerHTML = `
|
// Day heading row
|
||||||
<td>
|
const headRow = document.createElement('tr');
|
||||||
<span class="badge badge-${esc(ext)}" aria-label="${esc(ext.toUpperCase())} format">${esc(ext)}</span>${recBadge}<span class="fn">${esc(f.name)}</span>
|
headRow.className = 'day-head';
|
||||||
</td>
|
headRow.id = 'dayhead-' + dayId;
|
||||||
<td class="muted" style="white-space:nowrap">${esc(f.date)}</td>
|
headRow.innerHTML = `<td colspan="6">
|
||||||
<td style="white-space:nowrap">${fmtDur(f.duration)}</td>
|
<button class="day-toggle" id="daytgl-${dayId}"
|
||||||
<td class="muted" style="white-space:nowrap">${fmtSize(f.size)}</td>
|
aria-expanded="${expanded}"
|
||||||
<td id="wave-${i}">${canAnalyse ? '' :
|
aria-controls="${dayId}-body"
|
||||||
'<span class="muted" style="font-size:12px" aria-label="Loudness analysis unavailable for this format">—</span>'}</td>
|
aria-label="${expanded ? 'Collapse' : 'Expand'} ${esc(day)}">
|
||||||
<td>
|
<span class="day-arrow" aria-hidden="true">${expanded ? '▼' : '▶'}</span>
|
||||||
<div class="actions">
|
<strong>${esc(day)}</strong>
|
||||||
<button id="pbtn-${i}"
|
<span class="day-meta">${fileStr}${durStr}${sizeStr}</span>
|
||||||
aria-expanded="false"
|
</button>
|
||||||
aria-controls="prow-${i}"
|
${canHl ? `<button class="day-hl" id="dayhln-${dayId}"
|
||||||
aria-label="Play ${esc(f.name)}">▶ Play</button>
|
aria-label="Show day highlights for ${esc(day)}">★ Highlights</button>` : ''}
|
||||||
<a class="dl" href="/download/${encodeURIComponent(f.name)}"
|
</td>`;
|
||||||
aria-label="Download ${esc(f.name)}">↓ Download</a>
|
tbody.appendChild(headRow);
|
||||||
<button id="cutbtn-${i}" class="cut"
|
|
||||||
aria-label="Cut ${esc(f.name)}"
|
// Highlights row (hidden until button clicked)
|
||||||
${isRec ? 'disabled title="Cannot cut while recording"' : ''}>✂ Cut</button>
|
const hlRow = document.createElement('tr');
|
||||||
<button id="delbtn-${i}" class="del"
|
hlRow.className = 'day-hl-row';
|
||||||
aria-label="Delete ${esc(f.name)}"
|
hlRow.id = 'dayhl-' + dayId;
|
||||||
${isRec ? 'disabled title="Cannot delete while recording"' : ''}>✕ Delete</button>
|
hlRow.hidden = true;
|
||||||
|
hlRow.innerHTML = `<td colspan="6"><div id="dayhlc-${dayId}"></div></td>`;
|
||||||
|
tbody.appendChild(hlRow);
|
||||||
|
|
||||||
|
// File rows
|
||||||
|
dayFiles.forEach(f => {
|
||||||
|
const i = f._idx;
|
||||||
|
const ext = f.ext;
|
||||||
|
const canAnalyse = ext === 'wav' || ext === 'flac';
|
||||||
|
const isRec = !!f.recording;
|
||||||
|
|
||||||
|
const tr = document.createElement('tr');
|
||||||
|
tr.className = 'data-row';
|
||||||
|
tr.id = 'row-'+i;
|
||||||
|
tr.setAttribute('data-day', dayId);
|
||||||
|
if (!expanded) tr.hidden = true;
|
||||||
|
|
||||||
|
const recBadge = `<span id="rec-${i}" class="badge-rec"${isRec?'':' hidden'}
|
||||||
|
aria-label="Currently recording" aria-hidden="${isRec?'false':'true'}">
|
||||||
|
<span aria-hidden="true">●</span> REC</span>`;
|
||||||
|
|
||||||
|
tr.innerHTML = `
|
||||||
|
<td>
|
||||||
|
<span class="badge badge-${esc(ext)}" aria-label="${esc(ext.toUpperCase())} format">${esc(ext)}</span>${recBadge}<span class="fn">${esc(f.name)}</span>
|
||||||
|
</td>
|
||||||
|
<td class="muted" style="white-space:nowrap">${esc(f.date)}</td>
|
||||||
|
<td style="white-space:nowrap">${fmtDur(f.duration)}</td>
|
||||||
|
<td class="muted" style="white-space:nowrap">${fmtSize(f.size)}</td>
|
||||||
|
<td id="wave-${i}">${canAnalyse ? '' :
|
||||||
|
'<span class="muted" style="font-size:12px" aria-label="Loudness analysis unavailable for this format">—</span>'}</td>
|
||||||
|
<td>
|
||||||
|
<div class="actions">
|
||||||
|
<button id="pbtn-${i}"
|
||||||
|
aria-expanded="false"
|
||||||
|
aria-controls="prow-${i}"
|
||||||
|
aria-label="Play ${esc(f.name)}">▶ Play</button>
|
||||||
|
<a class="dl" href="/download/${encodeURIComponent(f.name)}"
|
||||||
|
aria-label="Download ${esc(f.name)}">↓ Download</a>
|
||||||
|
<button id="cutbtn-${i}" class="cut"
|
||||||
|
aria-label="Cut ${esc(f.name)}"
|
||||||
|
${isRec ? 'disabled title="Cannot cut while recording"' : ''}>✂ Cut</button>
|
||||||
|
<button id="delbtn-${i}" class="del"
|
||||||
|
aria-label="Delete ${esc(f.name)}"
|
||||||
|
${isRec ? 'disabled title="Cannot delete while recording"' : ''}>✕ Delete</button>
|
||||||
|
</div>
|
||||||
|
</td>`;
|
||||||
|
tbody.appendChild(tr);
|
||||||
|
|
||||||
|
const prow = document.createElement('tr');
|
||||||
|
prow.className = 'player-row';
|
||||||
|
prow.id = 'prow-'+i;
|
||||||
|
prow.hidden = true;
|
||||||
|
prow.setAttribute('data-day', dayId);
|
||||||
|
const durLabel = f.duration != null
|
||||||
|
? `<div class="muted" style="font-size:11px;margin-top:3px">Duration: ${fmtDur(f.duration)}</div>`
|
||||||
|
: '';
|
||||||
|
prow.innerHTML = `<td colspan="6">
|
||||||
|
<audio id="aud-${i}" controls preload="none"
|
||||||
|
aria-label="Playback: ${esc(f.name)}"></audio>${durLabel}
|
||||||
|
<div class="cut-panel">
|
||||||
|
<span class="cut-label">✂ Cut:</span>
|
||||||
|
<label class="cut-field">Start
|
||||||
|
<input type="text" id="cut-start-${i}" class="cut-time" placeholder="m:ss or h:mm:ss">
|
||||||
|
</label>
|
||||||
|
<label class="cut-field">End
|
||||||
|
<input type="text" id="cut-end-${i}" class="cut-time" placeholder="m:ss or h:mm:ss">
|
||||||
|
</label>
|
||||||
|
<button id="cut-dl-${i}" class="cut"
|
||||||
|
${isRec ? 'disabled title="Cannot cut while recording"' : ''}
|
||||||
|
aria-label="Download cut of ${esc(f.name)}">↓ Download cut</button>
|
||||||
</div>
|
</div>
|
||||||
</td>`;
|
</td>`;
|
||||||
tbody.appendChild(tr);
|
tbody.appendChild(prow);
|
||||||
|
|
||||||
// ---- player row (hidden by default) ----
|
_attachFileRowHandlers(f, isRec, expanded, dayId);
|
||||||
const prow = document.createElement('tr');
|
});
|
||||||
prow.className = 'player-row';
|
|
||||||
prow.id = 'prow-'+i;
|
|
||||||
prow.hidden = true;
|
|
||||||
const durLabel = f.duration != null
|
|
||||||
? `<div class="muted" style="font-size:11px;margin-top:3px">Duration: ${fmtDur(f.duration)}</div>`
|
|
||||||
: '';
|
|
||||||
prow.innerHTML = `<td colspan="6">
|
|
||||||
<audio id="aud-${i}" controls preload="none"
|
|
||||||
aria-label="Playback: ${esc(f.name)}"></audio>${durLabel}
|
|
||||||
<div class="cut-panel">
|
|
||||||
<span class="cut-label">✂ Cut:</span>
|
|
||||||
<label class="cut-field">Start
|
|
||||||
<input type="text" id="cut-start-${i}" class="cut-time" placeholder="m:ss or h:mm:ss">
|
|
||||||
</label>
|
|
||||||
<label class="cut-field">End
|
|
||||||
<input type="text" id="cut-end-${i}" class="cut-time" placeholder="m:ss or h:mm:ss">
|
|
||||||
</label>
|
|
||||||
<button id="cut-dl-${i}" class="cut"
|
|
||||||
${isRec ? 'disabled title="Cannot cut while recording"' : ''}
|
|
||||||
aria-label="Download cut of ${esc(f.name)}">↓ Download cut</button>
|
|
||||||
</div>
|
|
||||||
</td>`;
|
|
||||||
tbody.appendChild(prow);
|
|
||||||
|
|
||||||
// ---- attach analyse button ----
|
// Day toggle handler
|
||||||
if (canAnalyse) {
|
document.getElementById('daytgl-' + dayId).addEventListener('click', () => {
|
||||||
const cell = document.getElementById('wave-'+i);
|
const nowExp = !dayExpanded.get(dayId);
|
||||||
const abtn = document.createElement('button');
|
dayExpanded.set(dayId, nowExp);
|
||||||
abtn.textContent = 'Analyse';
|
const tgl = document.getElementById('daytgl-' + dayId);
|
||||||
abtn.setAttribute('aria-label', `Analyse loudness of ${f.name}`);
|
tgl.setAttribute('aria-expanded', nowExp);
|
||||||
if (isRec) {
|
tgl.setAttribute('aria-label', (nowExp ? 'Collapse' : 'Expand') + ' ' + day);
|
||||||
abtn.disabled = true;
|
tgl.querySelector('.day-arrow').textContent = nowExp ? '▼' : '▶';
|
||||||
abtn.title = 'Recording in progress — analyse after recording stops';
|
document.querySelectorAll(`tr[data-day="${dayId}"].data-row`).forEach(r => {
|
||||||
} else {
|
r.hidden = !nowExp;
|
||||||
abtn.addEventListener('click', () => analyse(i, f.name, cell, abtn));
|
});
|
||||||
|
if (!nowExp) {
|
||||||
|
// Collapse: pause any open players, hide player rows and highlights
|
||||||
|
dayFiles.forEach(f => {
|
||||||
|
const prow = document.getElementById('prow-' + f._idx);
|
||||||
|
if (prow && !prow.hidden) {
|
||||||
|
document.getElementById('aud-' + f._idx)?.pause();
|
||||||
|
prow.hidden = true;
|
||||||
|
const pbtn = document.getElementById('pbtn-' + f._idx);
|
||||||
|
if (pbtn) {
|
||||||
|
pbtn.setAttribute('aria-expanded', 'false');
|
||||||
|
pbtn.textContent = '▶ Play';
|
||||||
|
pbtn.setAttribute('aria-label', 'Play ' + f.name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
document.getElementById('dayhl-' + dayId).hidden = true;
|
||||||
}
|
}
|
||||||
cell.appendChild(abtn);
|
});
|
||||||
}
|
|
||||||
|
|
||||||
// ---- attach play button handler ----
|
// Highlights button handler
|
||||||
document.getElementById('pbtn-'+i)
|
if (canHl) {
|
||||||
.addEventListener('click', () => togglePlayer(i, f.name));
|
document.getElementById('dayhln-' + dayId)?.addEventListener('click', () => {
|
||||||
|
const hlFiles = dayFiles.filter(f => (f.ext === 'wav' || f.ext === 'flac') && !f.recording);
|
||||||
// ---- attach cut button handler (opens player row, focuses start field) ----
|
dayHighlights(dayId, hlFiles);
|
||||||
if (!isRec) {
|
|
||||||
document.getElementById('cutbtn-'+i).addEventListener('click', () => {
|
|
||||||
const pbtn = document.getElementById('pbtn-'+i);
|
|
||||||
if (pbtn.getAttribute('aria-expanded') !== 'true') togglePlayer(i, f.name);
|
|
||||||
document.getElementById('cut-start-'+i)?.focus();
|
|
||||||
});
|
|
||||||
document.getElementById('cut-dl-'+i).addEventListener('click', () => {
|
|
||||||
const startStr = document.getElementById('cut-start-'+i).value;
|
|
||||||
const endStr = document.getElementById('cut-end-'+i).value;
|
|
||||||
const start = parseTime(startStr);
|
|
||||||
const end = parseTime(endStr);
|
|
||||||
if (start === null || end === null) {
|
|
||||||
alert('Enter start and end times, e.g. 1:30 or 0:01:30');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (start >= end) {
|
|
||||||
alert('Start must be before end');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
window.location.href =
|
|
||||||
'/api/cut?file=' + encodeURIComponent(f.name) +
|
|
||||||
'&start=' + start + '&end=' + end;
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- attach delete button handler ----
|
|
||||||
if (!isRec) {
|
|
||||||
document.getElementById('delbtn-'+i)
|
|
||||||
.addEventListener('click', () => deleteFile(i, f.name));
|
|
||||||
}
|
|
||||||
|
|
||||||
recMap.set(i, f.name);
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function dayHighlights(dayId, analyzableFiles) {
|
||||||
|
const hlRow = document.getElementById('dayhl-' + dayId);
|
||||||
|
const contentEl = document.getElementById('dayhlc-' + dayId);
|
||||||
|
const btn = document.getElementById('dayhln-' + dayId);
|
||||||
|
|
||||||
|
hlRow.hidden = false;
|
||||||
|
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;
|
||||||
|
|
||||||
|
const threshold = document.getElementById('threshold-input').value || '0.05';
|
||||||
|
const minGap = document.getElementById('min-gap-input').value || '2';
|
||||||
|
|
||||||
|
const results = [];
|
||||||
|
for (const f of analyzableFiles) {
|
||||||
|
try {
|
||||||
|
const r = await fetch('/api/analyze?file=' + encodeURIComponent(f.name)
|
||||||
|
+ '&threshold=' + encodeURIComponent(threshold)
|
||||||
|
+ '&min_gap=' + encodeURIComponent(minGap));
|
||||||
|
const d = await r.json();
|
||||||
|
if (!d.error) results.push({ f, data: d });
|
||||||
|
} catch(e) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!results.length) {
|
||||||
|
contentEl.innerHTML = '<div class="quiet">No analysable results.</div>';
|
||||||
|
if (btn) btn.disabled = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Map files onto the day timeline using mtime as file-end, duration for start
|
||||||
|
const positioned = results.map(({ f, data }) => {
|
||||||
|
const fileEnd = f.mtime;
|
||||||
|
const fileDur = data.duration || f.duration || 0;
|
||||||
|
const fileStart = fileEnd - fileDur;
|
||||||
|
return { f, data, fileStart, fileEnd, fileDur };
|
||||||
|
}).filter(r => r.fileDur > 0);
|
||||||
|
|
||||||
|
if (!positioned.length) {
|
||||||
|
contentEl.innerHTML = '<div class="quiet">Could not determine file time positions.</div>';
|
||||||
|
if (btn) btn.disabled = false;
|
||||||
|
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');
|
||||||
|
};
|
||||||
|
|
||||||
|
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>${esc(fmtHM(minT))}</span><span>${esc(fmtHM((minT+maxT)/2))}</span><span>${esc(fmtHM(maxT))}</span>`;
|
||||||
|
box.appendChild(labels);
|
||||||
|
|
||||||
|
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);
|
||||||
|
if (btn) btn.disabled = false;
|
||||||
|
}
|
||||||
|
|
||||||
function applyFilters() {
|
function applyFilters() {
|
||||||
const nameQ = document.getElementById('filter-name').value.toLowerCase().trim();
|
const nameQ = document.getElementById('filter-name').value.toLowerCase().trim();
|
||||||
const fromD = document.getElementById('filter-from').value;
|
const fromD = document.getElementById('filter-from').value;
|
||||||
@@ -1175,10 +1408,12 @@ document.getElementById('filter-clear').addEventListener('click', () => {
|
|||||||
applyFilters();
|
applyFilters();
|
||||||
});
|
});
|
||||||
|
|
||||||
// Seed threshold input from server config, then start
|
// Seed threshold 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.threshold != null)
|
||||||
document.getElementById('threshold-input').value = cfg.threshold;
|
document.getElementById('threshold-input').value = cfg.threshold;
|
||||||
|
if (cfg.min_gap != null)
|
||||||
|
document.getElementById('min-gap-input').value = cfg.min_gap;
|
||||||
}).catch(() => {}).finally(() => load().then(() => setInterval(pollStatus, 5000)));
|
}).catch(() => {}).finally(() => load().then(() => setInterval(pollStatus, 5000)));
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
@@ -1199,6 +1434,8 @@ 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,
|
||||||
|
help=f'Seconds gap for merging loud sections (default: {MIN_GAP_SECONDS})')
|
||||||
args = parser.parse_args()
|
args = parser.parse_args()
|
||||||
|
|
||||||
rec_dir = Path(args.dir)
|
rec_dir = Path(args.dir)
|
||||||
@@ -1208,6 +1445,7 @@ def main():
|
|||||||
class Handler(_Handler):
|
class Handler(_Handler):
|
||||||
recordings_dir = str(rec_dir.resolve())
|
recordings_dir = str(rec_dir.resolve())
|
||||||
threshold = args.threshold
|
threshold = args.threshold
|
||||||
|
min_gap = args.min_gap
|
||||||
|
|
||||||
server = ThreadingHTTPServer((args.host, args.port), Handler)
|
server = ThreadingHTTPServer((args.host, args.port), Handler)
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user