feat: add ffmpeg audio trim / cut download

Adds a Cut panel inside every player row (✂ Cut button in the actions
column opens the row and focuses the Start field). Users type start and
end times in m:ss or h:mm:ss format; Download cut triggers
GET /api/cut which runs ffmpeg -c copy (no re-encode) and streams the
result as an attachment.

Clicking an analysis chip now also pre-fills the cut panel start/end
with the loud-section boundaries.

Server switched to ThreadingHTTPServer so ffmpeg runs don't block
other requests. ffmpeg added to Dockerfile apt install.
This commit is contained in:
2026-04-29 20:41:18 +02:00
parent de667821b7
commit eff9240b5d
2 changed files with 147 additions and 4 deletions
+1
View File
@@ -2,6 +2,7 @@ FROM python:3.12-slim
RUN apt-get update && apt-get install -y --no-install-recommends \
alsa-utils \
ffmpeg \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
+146 -4
View File
@@ -19,9 +19,11 @@ import os
import re
import shutil
import struct
import subprocess
import tempfile
import wave
from datetime import datetime
from http.server import BaseHTTPRequestHandler, HTTPServer
from http.server import BaseHTTPRequestHandler, HTTPServer, ThreadingHTTPServer
from pathlib import Path
from urllib.parse import parse_qs, unquote, urlparse
@@ -278,6 +280,8 @@ class _Handler(BaseHTTPRequestHandler):
self._api_storage()
elif p == '/api/config':
self._api_config()
elif p == '/api/cut':
self._api_cut(qs)
elif p.startswith('/download/'):
self._download(unquote(p[len('/download/'):]))
elif p.startswith('/stream/'):
@@ -453,6 +457,79 @@ class _Handler(BaseHTTPRequestHandler):
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):
base = Path(self.recordings_dir).resolve()
try:
@@ -564,6 +641,16 @@ button.chip:focus-visible{outline:2px solid var(--accent);outline-offset:2px}
.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;
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}
@@ -708,7 +795,16 @@ function drawWave(rms, sections, duration, filename) {
return svg;
}
function seekToSection(idx, filename, startSec) {
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;
@@ -716,6 +812,11 @@ function seekToSection(idx, filename, startSec) {
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) {
@@ -746,7 +847,7 @@ async function analyse(idx, filename, cell, btn) {
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.addEventListener('click', () => seekToSection(idx, filename, s.start));
c.addEventListener('click', () => seekToSection(idx, filename, s.start, s.end));
chips.appendChild(c);
});
} else {
@@ -875,6 +976,9 @@ function renderFiles(files) {
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>
@@ -893,6 +997,18 @@ function renderFiles(files) {
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);
@@ -915,6 +1031,32 @@ function renderFiles(files) {
document.getElementById('pbtn-'+i)
.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 ----
if (!isRec) {
document.getElementById('delbtn-'+i)
@@ -1019,7 +1161,7 @@ def main():
recordings_dir = str(rec_dir.resolve())
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"Recordings dir → {rec_dir.resolve()}")