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:
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
@@ -278,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/'):
|
||||||
@@ -453,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:
|
||||||
@@ -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)}
|
.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 */
|
||||||
.filter-bar{display:flex;align-items:center;gap:10px;padding:8px 28px;
|
.filter-bar{display:flex;align-items:center;gap:10px;padding:8px 28px;
|
||||||
border-bottom:1px solid var(--brd);background:var(--surf);flex-wrap:wrap}
|
border-bottom:1px solid var(--brd);background:var(--surf);flex-wrap:wrap}
|
||||||
@@ -708,7 +795,16 @@ function drawWave(rms, sections, duration, filename) {
|
|||||||
return svg;
|
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);
|
const pbtn = document.getElementById('pbtn-'+idx);
|
||||||
if (pbtn.getAttribute('aria-expanded') !== 'true') togglePlayer(idx, filename);
|
if (pbtn.getAttribute('aria-expanded') !== 'true') togglePlayer(idx, filename);
|
||||||
activePlayerIdx = idx;
|
activePlayerIdx = idx;
|
||||||
@@ -716,6 +812,11 @@ function seekToSection(idx, filename, startSec) {
|
|||||||
const doSeek = () => { audio.currentTime = startSec; };
|
const doSeek = () => { audio.currentTime = startSec; };
|
||||||
if (audio.readyState >= 1) doSeek();
|
if (audio.readyState >= 1) doSeek();
|
||||||
else audio.addEventListener('loadedmetadata', doSeek, {once: true});
|
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) {
|
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.className='chip'; c.setAttribute('role','listitem');
|
||||||
c.title = 'Jump to this section (or use J/K keys)';
|
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));
|
c.addEventListener('click', () => seekToSection(idx, filename, s.start, s.end));
|
||||||
chips.appendChild(c);
|
chips.appendChild(c);
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
@@ -875,6 +976,9 @@ function renderFiles(files) {
|
|||||||
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>
|
||||||
@@ -893,6 +997,18 @@ function renderFiles(files) {
|
|||||||
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>${durLabel}
|
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);
|
||||||
|
|
||||||
@@ -915,6 +1031,32 @@ function renderFiles(files) {
|
|||||||
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)
|
||||||
@@ -1019,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