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:
@@ -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()}")
|
||||
|
||||
Reference in New Issue
Block a user