Compare commits
6
Commits
476a8d2752
...
abfe81e734
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
abfe81e734 | ||
|
|
eff9240b5d | ||
|
|
de667821b7 | ||
|
|
d583620f8c | ||
|
|
6d16b2c0a3 | ||
|
|
7db0e0870f |
@@ -2,6 +2,7 @@ FROM python:3.12-slim
|
|||||||
|
|
||||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
alsa-utils \
|
alsa-utils \
|
||||||
|
ffmpeg \
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|||||||
@@ -155,10 +155,13 @@ python web.py --threshold 0.03 # loudness threshold 0–1 (default 0.05)
|
|||||||
|
|
||||||
Shows a table of all recordings sorted newest-first. Features:
|
Shows a table of all recordings sorted newest-first. Features:
|
||||||
|
|
||||||
- **Inline playback** — collapsible `▶ Play` button per row; audio loads lazily via a seekable `/stream/` endpoint with HTTP Range support.
|
- **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.
|
||||||
|
- **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).
|
||||||
|
- **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 removes the row without a full page reload.
|
||||||
- **Live REC badge** — files currently being written by `isr.py` show an animated REC indicator, polled every 5 seconds via `/api/status`.
|
- **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.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
@@ -19,9 +19,11 @@ import os
|
|||||||
import re
|
import re
|
||||||
import shutil
|
import shutil
|
||||||
import struct
|
import struct
|
||||||
|
import subprocess
|
||||||
|
import tempfile
|
||||||
import wave
|
import wave
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from http.server import BaseHTTPRequestHandler, HTTPServer
|
from http.server import BaseHTTPRequestHandler, HTTPServer, ThreadingHTTPServer
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from urllib.parse import parse_qs, unquote, urlparse
|
from urllib.parse import parse_qs, unquote, urlparse
|
||||||
|
|
||||||
@@ -222,10 +224,14 @@ def list_files(recordings_dir: str):
|
|||||||
for path in base.rglob('*'):
|
for path in base.rglob('*'):
|
||||||
if path.suffix.lower() not in AUDIO_EXTENSIONS:
|
if path.suffix.lower() not in AUDIO_EXTENSIONS:
|
||||||
continue
|
continue
|
||||||
stat = path.stat()
|
stat = path.stat()
|
||||||
rel = str(path.relative_to(base)).replace('\\', '/')
|
rel = str(path.relative_to(base)).replace('\\', '/')
|
||||||
|
is_active = rel in active_files
|
||||||
|
|
||||||
duration = _get_audio_duration(path)
|
# Skip reading partial headers for in-progress files — the WAV nframes
|
||||||
|
# field and FLAC total_samples are both unfinalized while recording,
|
||||||
|
# producing wildly incorrect values (e.g. 53375995583:39:01).
|
||||||
|
duration = None if is_active else _get_audio_duration(path)
|
||||||
|
|
||||||
files.append({
|
files.append({
|
||||||
'name': rel,
|
'name': rel,
|
||||||
@@ -234,7 +240,7 @@ def list_files(recordings_dir: str):
|
|||||||
'date': datetime.fromtimestamp(stat.st_mtime).strftime('%Y-%m-%d %H:%M:%S'),
|
'date': datetime.fromtimestamp(stat.st_mtime).strftime('%Y-%m-%d %H:%M:%S'),
|
||||||
'duration': duration,
|
'duration': duration,
|
||||||
'ext': path.suffix.lower().lstrip('.'),
|
'ext': path.suffix.lower().lstrip('.'),
|
||||||
'recording': rel in active_files,
|
'recording': is_active,
|
||||||
})
|
})
|
||||||
|
|
||||||
files.sort(key=lambda f: f['mtime'], reverse=True)
|
files.sort(key=lambda f: f['mtime'], reverse=True)
|
||||||
@@ -274,6 +280,8 @@ class _Handler(BaseHTTPRequestHandler):
|
|||||||
self._api_storage()
|
self._api_storage()
|
||||||
elif p == '/api/config':
|
elif p == '/api/config':
|
||||||
self._api_config()
|
self._api_config()
|
||||||
|
elif p == '/api/cut':
|
||||||
|
self._api_cut(qs)
|
||||||
elif p.startswith('/download/'):
|
elif p.startswith('/download/'):
|
||||||
self._download(unquote(p[len('/download/'):]))
|
self._download(unquote(p[len('/download/'):]))
|
||||||
elif p.startswith('/stream/'):
|
elif p.startswith('/stream/'):
|
||||||
@@ -449,6 +457,79 @@ class _Handler(BaseHTTPRequestHandler):
|
|||||||
|
|
||||||
self._send(200, json.dumps({'deleted': filename}).encode(), 'application/json')
|
self._send(200, json.dumps({'deleted': filename}).encode(), 'application/json')
|
||||||
|
|
||||||
|
def _api_cut(self, qs):
|
||||||
|
filename = qs.get('file', [None])[0]
|
||||||
|
start_s = qs.get('start', [None])[0]
|
||||||
|
end_s = qs.get('end', [None])[0]
|
||||||
|
|
||||||
|
if not filename or start_s is None or end_s is None:
|
||||||
|
self._json_err(400, 'missing file, start, or end parameter')
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
start = float(start_s)
|
||||||
|
end = float(end_s)
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
self._json_err(400, 'start and end must be numbers (seconds)')
|
||||||
|
return
|
||||||
|
|
||||||
|
if start < 0 or end <= start:
|
||||||
|
self._json_err(400, 'start must be ≥ 0 and end must be > start')
|
||||||
|
return
|
||||||
|
|
||||||
|
path = self._safe_path(filename)
|
||||||
|
if path is None:
|
||||||
|
return
|
||||||
|
|
||||||
|
status_path = Path(self.recordings_dir) / 'status.json'
|
||||||
|
try:
|
||||||
|
with open(status_path) as fh:
|
||||||
|
if filename in set(json.load(fh).get('active', [])):
|
||||||
|
self._json_err(409, 'Cannot cut a file that is currently being recorded')
|
||||||
|
return
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
if not shutil.which('ffmpeg'):
|
||||||
|
self._json_err(500, 'ffmpeg is not available on this server')
|
||||||
|
return
|
||||||
|
|
||||||
|
ext = path.suffix.lower()
|
||||||
|
out_name = f'{path.stem}_cut_{int(start)}s-{int(end)}s{ext}'
|
||||||
|
|
||||||
|
fd, tmp_path = tempfile.mkstemp(suffix=ext)
|
||||||
|
os.close(fd)
|
||||||
|
try:
|
||||||
|
cmd = ['ffmpeg', '-y', '-i', str(path),
|
||||||
|
'-ss', str(start), '-to', str(end),
|
||||||
|
'-c', 'copy', tmp_path]
|
||||||
|
result = subprocess.run(cmd, capture_output=True, timeout=120)
|
||||||
|
if result.returncode != 0:
|
||||||
|
err = result.stderr.decode('utf-8', errors='replace')[-400:]
|
||||||
|
self._json_err(500, f'ffmpeg error: {err}')
|
||||||
|
return
|
||||||
|
|
||||||
|
tmp_size = os.path.getsize(tmp_path)
|
||||||
|
content_type = MIME_TYPES.get(ext, 'application/octet-stream')
|
||||||
|
self.send_response(200)
|
||||||
|
self.send_header('Content-Type', content_type)
|
||||||
|
self.send_header('Content-Disposition', f'attachment; filename="{out_name}"')
|
||||||
|
self.send_header('Content-Length', str(tmp_size))
|
||||||
|
self.end_headers()
|
||||||
|
with open(tmp_path, 'rb') as fh:
|
||||||
|
while True:
|
||||||
|
chunk = fh.read(65536)
|
||||||
|
if not chunk:
|
||||||
|
break
|
||||||
|
self.wfile.write(chunk)
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
self._json_err(504, 'ffmpeg timed out — file may be too large')
|
||||||
|
finally:
|
||||||
|
try:
|
||||||
|
os.unlink(tmp_path)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
def _safe_path(self, filename: str):
|
def _safe_path(self, filename: str):
|
||||||
base = Path(self.recordings_dir).resolve()
|
base = Path(self.recordings_dir).resolve()
|
||||||
try:
|
try:
|
||||||
@@ -550,6 +631,9 @@ svg.wave{display:block;width:100%;height:56px}
|
|||||||
.chips{display:flex;flex-wrap:wrap;gap:5px;margin-top:8px}
|
.chips{display:flex;flex-wrap:wrap;gap:5px;margin-top:8px}
|
||||||
.chip{background:#431407;color:var(--orange);border:1px solid #7c2d12;border-radius:4px;
|
.chip{background:#431407;color:var(--orange);border:1px solid #7c2d12;border-radius:4px;
|
||||||
padding:2px 8px;font-size:11px;font-family:ui-monospace,monospace}
|
padding:2px 8px;font-size:11px;font-family:ui-monospace,monospace}
|
||||||
|
button.chip{cursor:pointer}
|
||||||
|
button.chip:hover{background:#6c1f08;border-color:#9a3412}
|
||||||
|
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}
|
||||||
.empty{text-align:center;padding:60px;color:var(--muted)}
|
.empty{text-align:center;padding:60px;color:var(--muted)}
|
||||||
@@ -557,6 +641,25 @@ svg.wave{display:block;width:100%;height:56px}
|
|||||||
.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)}
|
||||||
audio{width:100%;height:36px;border-radius:4px;display:block;
|
audio{width:100%;height:36px;border-radius:4px;display:block;
|
||||||
color-scheme:dark;accent-color:var(--accent)}
|
color-scheme:dark;accent-color:var(--accent)}
|
||||||
|
/* cut panel */
|
||||||
|
.cut-panel{display:flex;align-items:center;gap:8px;margin-top:8px;flex-wrap:wrap;
|
||||||
|
padding-top:8px;border-top:1px solid var(--brd)}
|
||||||
|
.cut-label{font-size:12px;color:var(--muted);white-space:nowrap}
|
||||||
|
.cut-field{display:flex;align-items:center;gap:4px;font-size:12px;color:var(--muted)}
|
||||||
|
.cut-time{width:90px;background:var(--bg);border:1px solid var(--brd);color:var(--txt);
|
||||||
|
padding:3px 6px;border-radius:4px;font-size:12px;font-family:ui-monospace,monospace}
|
||||||
|
.cut-time:focus{outline:2px solid var(--accent);outline-offset:1px}
|
||||||
|
button.cut{color:var(--accent);border-color:#1e40af;background:#0c1a40}
|
||||||
|
button.cut:hover:not(:disabled){background:#1e3a8a}
|
||||||
|
/* filter bar */
|
||||||
|
.filter-bar{display:flex;align-items:center;gap:10px;padding:8px 28px;
|
||||||
|
border-bottom:1px solid var(--brd);background:var(--surf);flex-wrap:wrap}
|
||||||
|
.filter-bar label{font-size:13px;color:var(--muted);white-space:nowrap}
|
||||||
|
.filter-bar input[type=text]{width:180px;background:var(--bg);border:1px solid var(--brd);
|
||||||
|
color:var(--txt);padding:3px 6px;border-radius:4px;font-size:13px}
|
||||||
|
.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}
|
||||||
|
.filter-bar input:focus{outline:2px solid var(--accent);outline-offset:1px}
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
@@ -573,6 +676,15 @@ audio{width:100%;height:36px;border-radius:4px;display:block;
|
|||||||
aria-describedby="threshold-hint">
|
aria-describedby="threshold-hint">
|
||||||
<span id="threshold-hint" class="controls-hint">RMS 0–1 · sections above this value are marked loud</span>
|
<span id="threshold-hint" class="controls-hint">RMS 0–1 · sections above this value are marked loud</span>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="filter-bar" role="search" aria-label="Filter recordings">
|
||||||
|
<label for="filter-name">Search:</label>
|
||||||
|
<input type="text" id="filter-name" placeholder="filename…" aria-label="Filter by filename">
|
||||||
|
<label for="filter-from">From:</label>
|
||||||
|
<input type="date" id="filter-from" aria-label="From date">
|
||||||
|
<label for="filter-to">To:</label>
|
||||||
|
<input type="date" id="filter-to" aria-label="To date">
|
||||||
|
<button id="filter-clear" aria-label="Clear all filters">✕ Clear</button>
|
||||||
|
</div>
|
||||||
<div class="wrap" id="main">
|
<div class="wrap" id="main">
|
||||||
<table aria-label="Recordings archive">
|
<table aria-label="Recordings archive">
|
||||||
<thead>
|
<thead>
|
||||||
@@ -613,6 +725,11 @@ const pad = n => String(n).padStart(2,'0');
|
|||||||
|
|
||||||
// idx -> filename, for live-status polling
|
// idx -> filename, for live-status polling
|
||||||
const recMap = new Map();
|
const recMap = new Map();
|
||||||
|
// idx -> [{start,end}], populated after analysis
|
||||||
|
const sectionMap = new Map();
|
||||||
|
let activePlayerIdx = null;
|
||||||
|
// full file list from server, annotated with stable _idx
|
||||||
|
let allFiles = [];
|
||||||
|
|
||||||
function togglePlayer(idx, filename) {
|
function togglePlayer(idx, filename) {
|
||||||
const prow = document.getElementById('prow-'+idx);
|
const prow = document.getElementById('prow-'+idx);
|
||||||
@@ -622,14 +739,16 @@ function togglePlayer(idx, filename) {
|
|||||||
|
|
||||||
if (!open) {
|
if (!open) {
|
||||||
if (!audio.getAttribute('data-src-set')) {
|
if (!audio.getAttribute('data-src-set')) {
|
||||||
|
audio.preload = 'metadata';
|
||||||
audio.src = '/stream/' + encodeURIComponent(filename);
|
audio.src = '/stream/' + encodeURIComponent(filename);
|
||||||
|
audio.load();
|
||||||
audio.setAttribute('data-src-set','1');
|
audio.setAttribute('data-src-set','1');
|
||||||
}
|
}
|
||||||
|
activePlayerIdx = idx;
|
||||||
prow.hidden = false;
|
prow.hidden = false;
|
||||||
btn.setAttribute('aria-expanded','true');
|
btn.setAttribute('aria-expanded','true');
|
||||||
btn.textContent = '⏹ Hide';
|
btn.textContent = '⏹ Hide';
|
||||||
btn.setAttribute('aria-label','Hide player for '+filename);
|
btn.setAttribute('aria-label','Hide player for '+filename);
|
||||||
// Move focus to audio control so keyboard users can operate it immediately
|
|
||||||
audio.focus();
|
audio.focus();
|
||||||
} else {
|
} else {
|
||||||
audio.pause();
|
audio.pause();
|
||||||
@@ -676,7 +795,31 @@ function drawWave(rms, sections, duration, filename) {
|
|||||||
return svg;
|
return svg;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function analyse(filename, cell, btn) {
|
function parseTime(s) {
|
||||||
|
if (!s || !s.trim()) return null;
|
||||||
|
const parts = s.trim().split(':').map(v => parseFloat(v));
|
||||||
|
if (parts.some(isNaN) || parts.length < 1 || parts.length > 3) return null;
|
||||||
|
if (parts.length === 3) return parts[0]*3600 + parts[1]*60 + parts[2];
|
||||||
|
if (parts.length === 2) return parts[0]*60 + parts[1];
|
||||||
|
return parts[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
function seekToSection(idx, filename, startSec, endSec) {
|
||||||
|
const pbtn = document.getElementById('pbtn-'+idx);
|
||||||
|
if (pbtn.getAttribute('aria-expanded') !== 'true') togglePlayer(idx, filename);
|
||||||
|
activePlayerIdx = idx;
|
||||||
|
const audio = document.getElementById('aud-'+idx);
|
||||||
|
const doSeek = () => { audio.currentTime = startSec; };
|
||||||
|
if (audio.readyState >= 1) doSeek();
|
||||||
|
else audio.addEventListener('loadedmetadata', doSeek, {once: true});
|
||||||
|
// Pre-fill cut panel fields with section boundaries
|
||||||
|
const startEl = document.getElementById('cut-start-'+idx);
|
||||||
|
const endEl = document.getElementById('cut-end-'+idx);
|
||||||
|
if (startEl) startEl.value = fmtT(startSec);
|
||||||
|
if (endEl && endSec != null) endEl.value = fmtT(endSec);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function analyse(idx, filename, cell, btn) {
|
||||||
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>';
|
||||||
@@ -696,15 +839,19 @@ async function analyse(filename, cell, btn) {
|
|||||||
const chips = document.createElement('div');
|
const chips = document.createElement('div');
|
||||||
chips.className='chips';
|
chips.className='chips';
|
||||||
chips.setAttribute('role','list');
|
chips.setAttribute('role','list');
|
||||||
chips.setAttribute('aria-label','Loud sections');
|
chips.setAttribute('aria-label','Loud sections — click to jump, J/K to step');
|
||||||
if (d.sections && d.sections.length) {
|
if (d.sections && d.sections.length) {
|
||||||
|
sectionMap.set(idx, d.sections);
|
||||||
d.sections.forEach(s => {
|
d.sections.forEach(s => {
|
||||||
const c = document.createElement('span');
|
const c = document.createElement('button');
|
||||||
c.className='chip'; c.setAttribute('role','listitem');
|
c.className='chip'; c.setAttribute('role','listitem');
|
||||||
|
c.title = 'Jump to this section (or use J/K keys)';
|
||||||
c.textContent = `${fmtT(s.start)} – ${fmtT(s.end)}`;
|
c.textContent = `${fmtT(s.start)} – ${fmtT(s.end)}`;
|
||||||
|
c.addEventListener('click', () => seekToSection(idx, filename, s.start, s.end));
|
||||||
chips.appendChild(c);
|
chips.appendChild(c);
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
|
sectionMap.delete(idx);
|
||||||
const q = document.createElement('span');
|
const q = document.createElement('span');
|
||||||
q.className='quiet'; q.setAttribute('role','listitem');
|
q.className='quiet'; q.setAttribute('role','listitem');
|
||||||
q.textContent='No loud sections found';
|
q.textContent='No loud sections found';
|
||||||
@@ -718,6 +865,31 @@ async function analyse(filename, cell, btn) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// J = previous section, K = next section (only when focus is not in an input)
|
||||||
|
document.addEventListener('keydown', e => {
|
||||||
|
if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') return;
|
||||||
|
if (activePlayerIdx === null) return;
|
||||||
|
const sections = sectionMap.get(activePlayerIdx) || [];
|
||||||
|
if (!sections.length) return;
|
||||||
|
const audio = document.getElementById('aud-'+activePlayerIdx);
|
||||||
|
if (!audio) return;
|
||||||
|
if (e.key === 'j' || e.key === 'J') {
|
||||||
|
const cur = audio.currentTime;
|
||||||
|
let target = sections[0].start;
|
||||||
|
for (let i = sections.length - 1; i >= 0; i--) {
|
||||||
|
if (sections[i].start < cur - 1) { target = sections[i].start; break; }
|
||||||
|
}
|
||||||
|
audio.currentTime = target;
|
||||||
|
e.preventDefault();
|
||||||
|
} else if (e.key === 'k' || e.key === 'K') {
|
||||||
|
const cur = audio.currentTime;
|
||||||
|
for (const s of sections) {
|
||||||
|
if (s.start > cur + 0.5) { audio.currentTime = s.start; break; }
|
||||||
|
}
|
||||||
|
e.preventDefault();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
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);
|
||||||
@@ -729,10 +901,13 @@ async function deleteFile(idx, filename) {
|
|||||||
document.getElementById('row-'+idx)?.remove();
|
document.getElementById('row-'+idx)?.remove();
|
||||||
document.getElementById('prow-'+idx)?.remove();
|
document.getElementById('prow-'+idx)?.remove();
|
||||||
recMap.delete(idx);
|
recMap.delete(idx);
|
||||||
const remaining = document.querySelectorAll('tr.data-row').length;
|
allFiles = allFiles.filter(f => f._idx !== idx);
|
||||||
document.getElementById('subtitle').textContent =
|
const visible = document.querySelectorAll('tr.data-row').length;
|
||||||
`${remaining} recording${remaining!==1?'s':''} found`;
|
const total = allFiles.length;
|
||||||
if (!remaining) document.getElementById('empty').style.display = '';
|
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(()=>({}));
|
||||||
@@ -756,32 +931,21 @@ async function updateStorage() {
|
|||||||
} catch(e) {}
|
} catch(e) {}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function load() {
|
function renderFiles(files) {
|
||||||
const refreshBtn = document.getElementById('refresh-btn');
|
|
||||||
refreshBtn.disabled = true;
|
|
||||||
document.getElementById('subtitle').textContent = 'Loading…';
|
|
||||||
recMap.clear();
|
|
||||||
|
|
||||||
let files;
|
|
||||||
try {
|
|
||||||
files = await (await fetch('/api/files')).json();
|
|
||||||
} catch(e) {
|
|
||||||
document.getElementById('subtitle').textContent = 'Error loading files';
|
|
||||||
refreshBtn.disabled = false;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const tbody = document.getElementById('tbody');
|
const tbody = document.getElementById('tbody');
|
||||||
tbody.innerHTML = '';
|
tbody.innerHTML = '';
|
||||||
|
recMap.clear();
|
||||||
|
sectionMap.clear();
|
||||||
|
|
||||||
const n = files.length;
|
const total = allFiles.length;
|
||||||
document.getElementById('subtitle').textContent =
|
const visible = files.length;
|
||||||
`${n} recording${n!==1?'s':''} found`;
|
document.getElementById('subtitle').textContent = total === visible
|
||||||
document.getElementById('empty').style.display = n ? 'none' : '';
|
? `${total} recording${total!==1?'s':''} found`
|
||||||
updateStorage();
|
: `${visible} of ${total} recording${total!==1?'s':''} shown`;
|
||||||
if (!n) { refreshBtn.disabled = false; return; }
|
document.getElementById('empty').style.display = visible ? 'none' : '';
|
||||||
|
|
||||||
files.forEach((f, i) => {
|
files.forEach(f => {
|
||||||
|
const i = f._idx;
|
||||||
const ext = f.ext;
|
const ext = f.ext;
|
||||||
const canAnalyse = ext === 'wav' || ext === 'flac';
|
const canAnalyse = ext === 'wav' || ext === 'flac';
|
||||||
const isRec = !!f.recording;
|
const isRec = !!f.recording;
|
||||||
@@ -812,6 +976,9 @@ async function load() {
|
|||||||
aria-label="Play ${esc(f.name)}">▶ Play</button>
|
aria-label="Play ${esc(f.name)}">▶ Play</button>
|
||||||
<a class="dl" href="/download/${encodeURIComponent(f.name)}"
|
<a class="dl" href="/download/${encodeURIComponent(f.name)}"
|
||||||
aria-label="Download ${esc(f.name)}">↓ Download</a>
|
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"
|
<button id="delbtn-${i}" class="del"
|
||||||
aria-label="Delete ${esc(f.name)}"
|
aria-label="Delete ${esc(f.name)}"
|
||||||
${isRec ? 'disabled title="Cannot delete while recording"' : ''}>✕ Delete</button>
|
${isRec ? 'disabled title="Cannot delete while recording"' : ''}>✕ Delete</button>
|
||||||
@@ -824,9 +991,24 @@ async function load() {
|
|||||||
prow.className = 'player-row';
|
prow.className = 'player-row';
|
||||||
prow.id = 'prow-'+i;
|
prow.id = 'prow-'+i;
|
||||||
prow.hidden = true;
|
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">
|
prow.innerHTML = `<td colspan="6">
|
||||||
<audio id="aud-${i}" controls preload="none"
|
<audio id="aud-${i}" controls preload="none"
|
||||||
aria-label="Playback: ${esc(f.name)}"></audio>
|
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>`;
|
</td>`;
|
||||||
tbody.appendChild(prow);
|
tbody.appendChild(prow);
|
||||||
|
|
||||||
@@ -840,7 +1022,7 @@ async function load() {
|
|||||||
abtn.disabled = true;
|
abtn.disabled = true;
|
||||||
abtn.title = 'Recording in progress — analyse after recording stops';
|
abtn.title = 'Recording in progress — analyse after recording stops';
|
||||||
} else {
|
} else {
|
||||||
abtn.addEventListener('click', () => analyse(f.name, cell, abtn));
|
abtn.addEventListener('click', () => analyse(i, f.name, cell, abtn));
|
||||||
}
|
}
|
||||||
cell.appendChild(abtn);
|
cell.appendChild(abtn);
|
||||||
}
|
}
|
||||||
@@ -849,16 +1031,72 @@ async function load() {
|
|||||||
document.getElementById('pbtn-'+i)
|
document.getElementById('pbtn-'+i)
|
||||||
.addEventListener('click', () => togglePlayer(i, f.name));
|
.addEventListener('click', () => togglePlayer(i, f.name));
|
||||||
|
|
||||||
|
// ---- attach cut button handler (opens player row, focuses start field) ----
|
||||||
|
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 ----
|
// ---- attach delete button handler ----
|
||||||
if (!isRec) {
|
if (!isRec) {
|
||||||
document.getElementById('delbtn-'+i)
|
document.getElementById('delbtn-'+i)
|
||||||
.addEventListener('click', () => deleteFile(i, f.name));
|
.addEventListener('click', () => deleteFile(i, f.name));
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- register for live-status polling ----
|
|
||||||
recMap.set(i, f.name);
|
recMap.set(i, f.name);
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyFilters() {
|
||||||
|
const nameQ = document.getElementById('filter-name').value.toLowerCase().trim();
|
||||||
|
const fromD = document.getElementById('filter-from').value;
|
||||||
|
const toD = document.getElementById('filter-to').value;
|
||||||
|
const filtered = allFiles.filter(f => {
|
||||||
|
if (nameQ && !f.name.toLowerCase().includes(nameQ)) return false;
|
||||||
|
if (fromD && f.date < fromD + ' 00:00:00') return false;
|
||||||
|
if (toD && f.date > toD + ' 23:59:59') return false;
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
renderFiles(filtered);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
const refreshBtn = document.getElementById('refresh-btn');
|
||||||
|
refreshBtn.disabled = true;
|
||||||
|
document.getElementById('subtitle').textContent = 'Loading…';
|
||||||
|
|
||||||
|
let files;
|
||||||
|
try {
|
||||||
|
files = await (await fetch('/api/files')).json();
|
||||||
|
} catch(e) {
|
||||||
|
document.getElementById('subtitle').textContent = 'Error loading files';
|
||||||
|
refreshBtn.disabled = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
allFiles = files.map((f, i) => ({...f, _idx: i}));
|
||||||
|
updateStorage();
|
||||||
|
applyFilters();
|
||||||
refreshBtn.disabled = false;
|
refreshBtn.disabled = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -879,6 +1117,16 @@ async function pollStatus() {
|
|||||||
|
|
||||||
document.getElementById('refresh-btn').addEventListener('click', load);
|
document.getElementById('refresh-btn').addEventListener('click', load);
|
||||||
|
|
||||||
|
document.getElementById('filter-name').addEventListener('input', applyFilters);
|
||||||
|
document.getElementById('filter-from').addEventListener('change', applyFilters);
|
||||||
|
document.getElementById('filter-to').addEventListener('change', applyFilters);
|
||||||
|
document.getElementById('filter-clear').addEventListener('click', () => {
|
||||||
|
document.getElementById('filter-name').value = '';
|
||||||
|
document.getElementById('filter-from').value = '';
|
||||||
|
document.getElementById('filter-to').value = '';
|
||||||
|
applyFilters();
|
||||||
|
});
|
||||||
|
|
||||||
// Seed threshold input from server config, then start
|
// Seed threshold input 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)
|
||||||
@@ -913,7 +1161,7 @@ def main():
|
|||||||
recordings_dir = str(rec_dir.resolve())
|
recordings_dir = str(rec_dir.resolve())
|
||||||
threshold = args.threshold
|
threshold = args.threshold
|
||||||
|
|
||||||
server = HTTPServer((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.resolve()}")
|
||||||
|
|||||||
Reference in New Issue
Block a user