Files
event-audio-recorder/editor.py
T
2026-09-29 08:52:58 +02:00

610 lines
22 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import os
import subprocess
from datetime import datetime
from pathlib import Path
from zoneinfo import ZoneInfo
from flask import Flask, abort, jsonify, render_template_string, request, send_from_directory, url_for
# ---------------------------------------------------------------------------
# Configuration
# ---------------------------------------------------------------------------
RECORDINGS_DIR = Path(os.environ.get("RECORDINGS_DIR", "/recordings"))
FINISHED_DIR = Path(os.environ.get("FINISHED_DIR", "/finished"))
WAVEFORMS_DIR = Path(os.environ.get("WAVEFORMS_DIR", "/waveforms"))
WEB_PORT = int(os.environ.get("WEB_PORT", "8080"))
# ---------------------------------------------------------------------------
# Application
# ---------------------------------------------------------------------------
app = Flask(__name__)
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def format_datetime(timestamp):
return datetime.fromtimestamp(timestamp, ZoneInfo("Europe/Berlin")).strftime(
"%d.%m.%Y %H:%M:%S"
)
def format_size(size):
if size < 1024:
return f"{size} B"
if size < 1024 ** 2:
return f"{size / 1024:.1f} KB"
if size < 1024 ** 3:
return f"{size / 1024 ** 2:.1f} MB"
return f"{size / 1024 ** 3:.2f} GB"
def format_duration(seconds):
if seconds is None:
return "–"
seconds = int(seconds)
hours = seconds // 3600
minutes = (seconds % 3600) // 60
seconds = seconds % 60
if hours:
return f"{hours}:{minutes:02d}:{seconds:02d}"
return f"{minutes}:{seconds:02d}"
def safe_filename(filename):
if not filename or Path(filename).name != filename:
return None
if not filename.lower().endswith(".mp3"):
return None
return filename
def source_path(filename):
filename = safe_filename(filename)
if filename is None:
return None
path = RECORDINGS_DIR / filename
return path if path.is_file() else None
def finished_path(filename):
filename = safe_filename(filename)
if filename is None:
return None
return FINISHED_DIR / filename
def get_duration(path):
try:
result = subprocess.run(
[
"ffprobe", "-v", "error",
"-show_entries", "format=duration",
"-of", "default=noprint_wrappers=1:nokey=1",
str(path),
],
capture_output=True, text=True, timeout=10,
)
if result.returncode != 0:
return None
return float(result.stdout.strip())
except (OSError, ValueError, subprocess.SubprocessError):
return None
def waveform_path(filename):
return WAVEFORMS_DIR / f"{filename}.png"
def create_waveform(filename):
source = source_path(filename)
if source is None:
return None
output = waveform_path(filename)
WAVEFORMS_DIR.mkdir(parents=True, exist_ok=True)
try:
if output.is_file() and output.stat().st_mtime >= source.stat().st_mtime:
return output
except FileNotFoundError:
pass
temp = output.with_suffix(".tmp.png")
try:
result = subprocess.run(
[
"ffmpeg", "-hide_banner", "-loglevel", "error", "-y",
"-i", str(source),
"-filter_complex",
"aformat=channel_layouts=mono,showwavespic=s=2400x240:split_channels=0",
"-frames:v", "1",
str(temp),
],
capture_output=True, text=True, timeout=120,
)
if result.returncode != 0 or not temp.is_file():
print(f"[waveform] failed for {filename}: {result.stderr.strip()}", flush=True)
return None
temp.replace(output)
return output
except Exception as exc:
print(f"[waveform] failed for {filename}: {exc}", flush=True)
try:
temp.unlink(missing_ok=True)
except OSError:
pass
return None
def create_finished(filename, start, end):
source = source_path(filename)
if source is None:
raise ValueError("Aufnahme nicht gefunden")
try:
start = float(start)
end = float(end)
except (TypeError, ValueError):
raise ValueError("Ungültige Zeitangabe")
duration = get_duration(source)
if duration is None:
raise ValueError("Aufnahmedauer konnte nicht ermittelt werden")
if start < 0 or end > duration or end <= start:
raise ValueError("Ungültiger Auswahlbereich")
if end - start < 0.5:
raise ValueError("Der ausgewählte Bereich ist zu kurz")
FINISHED_DIR.mkdir(parents=True, exist_ok=True)
destination = finished_path(filename)
temp = FINISHED_DIR / f".{filename}.tmp.mp3"
result = subprocess.run(
[
"ffmpeg", "-hide_banner", "-loglevel", "warning", "-y",
"-i", str(source),
"-ss", f"{start:.3f}",
"-t", f"{end - start:.3f}",
"-af", "loudnorm=I=-16:TP=-1.5:LRA=11",
"-c:a", "libmp3lame",
"-b:a", "128k",
str(temp),
],
capture_output=True, text=True, timeout=1800,
)
if result.returncode != 0 or not temp.is_file():
try:
temp.unlink(missing_ok=True)
except OSError:
pass
print(f"[edit] ffmpeg failed for {filename}: {result.stderr.strip()}", flush=True)
raise RuntimeError("Schneiden fehlgeschlagen")
temp.replace(destination)
return destination, get_duration(destination)
# ---------------------------------------------------------------------------
# HTML
# ---------------------------------------------------------------------------
EDITOR_HTML = """
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Event Audio – Aufnahme bearbeiten</title>
<style>
* { box-sizing: border-box; }
body { margin:0; padding:24px; background:#111; color:#eee; font-family:Arial,Helvetica,sans-serif; }
.container { max-width:1400px; margin:0 auto; }
h1 { margin:0 0 6px; font-size:28px; }
.subtitle { color:#aaa; margin-bottom:22px; }
.editor { background:#181818; border:1px solid #333; border-radius:10px; padding:20px; }
.wave-wrap { position:relative; width:100%; height:240px; background:#0b0b0b; border-radius:6px; overflow:hidden; user-select:none; touch-action:none; }
.wave-wrap img { width:100%; height:100%; object-fit:fill; display:block; opacity:.9; }
.selection { position:absolute; top:0; bottom:0; background:rgba(80,150,255,.16); border-left:2px solid #fff; border-right:2px solid #fff; pointer-events:none; }
.playhead { position:absolute; top:0; bottom:0; width:2px; margin-left:-1px; background:#ffcc33; box-shadow:0 0 5px #000; z-index:4; pointer-events:none; }
.handle { position:absolute; top:0; bottom:0; width:18px; margin-left:-9px; cursor:ew-resize; z-index:3; touch-action:none; }
.handle::after { content:""; position:absolute; left:7px; top:0; bottom:0; width:4px; background:#fff; border-radius:2px; box-shadow:0 0 5px #000; }
.time-row { display:flex; justify-content:space-between; margin-top:8px; color:#aaa; font-size:14px; }
.controls { display:flex; flex-wrap:wrap; gap:8px; align-items:center; margin-top:18px; }
button, a.button { border:0; border-radius:6px; padding:11px 16px; background:#333; color:#fff; cursor:pointer; text-decoration:none; font-size:15px; }
button:hover, a.button:hover { background:#444; }
button.primary { background:#1769aa; }
button.danger { background:#8b0000; }
button:disabled { opacity:.45; cursor:not-allowed; }
.position { margin-left:auto; color:#ccc; font-variant-numeric:tabular-nums; }
.values { display:grid; grid-template-columns:repeat(3,1fr); gap:12px; margin-top:20px; }
.value { background:#222; border-radius:6px; padding:12px; }
.value span { display:block; color:#999; font-size:13px; margin-bottom:5px; }
.value strong { font-size:20px; font-variant-numeric:tabular-nums; }
.actions { display:flex; gap:10px; flex-wrap:wrap; margin-top:20px; }
.info { margin-top:16px; color:#999; font-size:14px; }
.message { margin-top:15px; padding:10px 12px; border-radius:6px; display:none; }
.message.ok { display:block; background:#173d24; color:#b8f5c9; }
.message.error { display:block; background:#4a1b1b; color:#ffd0d0; }
.back { display:inline-block; margin-bottom:18px; color:#bbb; text-decoration:none; }
@media(max-width:700px) {
body { padding:12px; }
.editor { padding:12px; }
.wave-wrap { height:180px; }
.values { grid-template-columns:1fr; }
.position { width:100%; margin-left:0; }
button, a.button { flex:1 1 auto; }
}
</style>
</head>
<body>
<div class="container">
<a class="back" href="/">← Zurück zu den Aufnahmen</a>
<h1>Aufnahme bearbeiten</h1>
<div class="subtitle">{{ filename }} · Originaldauer {{ duration }}</div>
<div class="editor">
<div class="wave-wrap" id="wave">
<img src="{{ waveform_url }}" alt="Wellenform">
<div class="selection" id="selection"></div>
<div class="playhead" id="playhead"></div>
<div class="handle" id="startHandle" title="Anfang verschieben"></div>
<div class="handle" id="endHandle" title="Ende verschieben"></div>
</div>
<div class="time-row">
<span>00:00:00</span>
<span>{{ duration }}</span>
</div>
<audio id="audio" controls preload="metadata" style="width:100%;margin-top:18px">
<source src="{{ audio_url }}" type="audio/mpeg">
</audio>
<div class="controls">
<button onclick="jump(-10)">⏮ 10 s</button>
<button onclick="jump(-1)">⏪ 1 s</button>
<button onclick="jump(1)">1 s ⏩</button>
<button onclick="jump(10)">10 s ⏭</button>
<button onclick="playSelection()">▶ Auswahl abspielen</button>
<button onclick="playFromStart()">▶ Ab Auswahlstart</button>
<button onclick="setStartAtCurrent()">⏱ Anfang = Position</button>
<button onclick="setEndAtCurrent()">⏱ Ende = Position</button>
<span class="position" id="position">00:00:00</span>
</div>
<div class="values">
<div class="value"><span>Anfang</span><strong id="startValue">00:00:00</strong></div>
<div class="value"><span>Ende</span><strong id="endValue">00:00:00</strong></div>
<div class="value"><span>Neue Dauer</span><strong id="keepValue">00:00:00</strong></div>
</div>
<div class="actions">
<button class="primary" id="trimButton" onclick="trim()">✂ Fertige Aufnahme erstellen</button>
<a class="button" href="{{ audio_url }}" download>Original herunterladen</a>
</div>
<div class="info">Die Originalaufnahme bleibt unverändert. Aus dem gewählten Bereich wird eine fertige Aufnahme im separaten Ausgabeordner erstellt.</div>
<div id="message" class="message"></div>
</div>
</div>
<script>
const duration = {{ duration_seconds|tojson }};
const filename = {{ filename|tojson }};
let start = 0;
let end = duration;
let dragging = null;
const wave = document.getElementById('wave');
const startHandle = document.getElementById('startHandle');
const endHandle = document.getElementById('endHandle');
const selection = document.getElementById('selection');
const playhead = document.getElementById('playhead');
const audio = document.getElementById('audio');
function fmt(sec) {
sec = Math.max(0, Math.round(sec));
const h = Math.floor(sec / 3600);
const m = Math.floor((sec % 3600) / 60);
const s = sec % 60;
return `${String(h).padStart(2,'0')}:${String(m).padStart(2,'0')}:${String(s).padStart(2,'0')}`;
}
function xToTime(clientX) {
const r = wave.getBoundingClientRect();
return Math.max(0, Math.min(duration, ((clientX-r.left)/r.width)*duration));
}
function render() {
const a = (start/duration)*100;
const b = (end/duration)*100;
startHandle.style.left = a+'%';
endHandle.style.left = b+'%';
selection.style.left = a+'%';
selection.style.width = (b-a)+'%';
document.getElementById('startValue').textContent = fmt(start);
document.getElementById('endValue').textContent = fmt(end);
document.getElementById('keepValue').textContent = fmt(end-start);
renderPlayhead();
}
function renderPlayhead() {
const t = Math.max(0, Math.min(duration, audio.currentTime || 0));
playhead.style.left = ((t/duration)*100)+'%';
}
function beginDrag(which, e) {
e.preventDefault();
dragging = which;
document.body.style.cursor='ew-resize';
}
startHandle.addEventListener('pointerdown', e => beginDrag('start',e));
endHandle.addEventListener('pointerdown', e => beginDrag('end',e));
document.addEventListener('pointermove', e => {
if (!dragging) return;
const t = xToTime(e.clientX);
if (dragging === 'start') start = Math.min(t, end-0.5);
else end = Math.max(t, start+0.5);
render();
});
document.addEventListener('pointerup', () => { dragging=null; document.body.style.cursor=''; });
wave.addEventListener('pointerdown', e => {
if (e.target === startHandle || e.target === endHandle) return;
const t=xToTime(e.clientX);
audio.currentTime = Math.max(0, Math.min(duration,t));
});
audio.addEventListener('timeupdate', () => {
document.getElementById('position').textContent=fmt(audio.currentTime);
renderPlayhead();
if (audio.currentTime >= end && !audio.paused) audio.pause();
});
audio.addEventListener('seeking', renderPlayhead);
function jump(sec) { audio.currentTime=Math.max(0,Math.min(duration,audio.currentTime+sec)); renderPlayhead(); }
function playFromStart() { audio.currentTime=start; renderPlayhead(); audio.play(); }
function playSelection() { audio.currentTime=start; renderPlayhead(); audio.play(); }
function setStartAtCurrent() {
const t = Math.max(0, Math.min(duration, audio.currentTime || 0));
if (t >= end - 0.5) {
msg('Der Anfang muss mindestens 0,5 s vor dem Ende liegen.', false);
return;
}
start = t;
render();
}
function setEndAtCurrent() {
const t = Math.max(0, Math.min(duration, audio.currentTime || 0));
if (t <= start + 0.5) {
msg('Das Ende muss mindestens 0,5 s nach dem Anfang liegen.', false);
return;
}
end = t;
render();
}
function msg(text, ok) {
const el=document.getElementById('message'); el.textContent=text; el.className='message '+(ok?'ok':'error');
}
async function trim() {
if (end-start < 0.5) return msg('Der ausgewählte Bereich ist zu kurz.',false);
if (!confirm(`Aufnahme auf ${fmt(start)} bis ${fmt(end)} kürzen?`)) return;
const btn=document.getElementById('trimButton'); btn.disabled=true; audio.pause();
msg('Aufnahme wird geschnitten …',true);
try {
const r=await fetch('/api/edit', {method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({filename,start,end})});
const d=await r.json();
if(!r.ok || !d.success) throw new Error(d.error || 'Fehler beim Schneiden');
msg('Fertige Aufnahme erfolgreich erstellt.',true);
setTimeout(()=>location.reload(),700);
} catch(e) { msg(e.message,false); btn.disabled=false; }
}
render();
</script>
</body>
</html>
"""
INDEX_HTML = """
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Event Audio – Editor</title>
<style>
* { box-sizing:border-box; }
body { margin:0; padding:24px; background:#111; color:#eee; font-family:Arial,Helvetica,sans-serif; }
.container { max-width:1400px; margin:0 auto; }
h1 { margin:0 0 6px; }
.subtitle { color:#aaa; margin-bottom:22px; }
.section { background:#181818; border:1px solid #333; border-radius:10px; padding:18px; margin-bottom:18px; }
table { width:100%; border-collapse:collapse; }
th,td { padding:10px 8px; border-bottom:1px solid #333; text-align:left; }
th { color:#aaa; font-weight:normal; }
.button { display:inline-block; border:0; border-radius:6px; padding:9px 13px; background:#333; color:#fff; cursor:pointer; text-decoration:none; }
.button:hover { background:#444; }
.empty { color:#999; padding:15px 0; }
.loading-overlay { position:fixed; inset:0; background:rgba(0,0,0,.78); display:none; align-items:center; justify-content:center; z-index:1000; }
.loading-overlay.active { display:flex; }
.spinner { width:54px; height:54px; border:5px solid #444; border-top-color:#fff; border-radius:50%; animation:spin .8s linear infinite; }
@keyframes spin { to { transform:rotate(360deg); } }
@media(max-width:800px) { body { padding:12px; } table,thead,tbody,tr,th,td { display:block; } thead { display:none; } tr { padding:10px 0; border-bottom:1px solid #333; } td { border:0; padding:5px 0; } }
</style>
</head>
<body>
<div class="loading-overlay" id="loadingOverlay"><div class="spinner" aria-label="Laden"></div></div>
<div class="container">
<h1>Event Audio – Editor</h1>
<div class="subtitle">Fertige Aufnahmen aus {{ recordings_dir }}</div>
<div class="section">
<h2>Aufnahmen bearbeiten</h2>
{% if recordings %}
<table>
<thead><tr><th>Aufnahme</th><th>Datum</th><th>Größe</th><th>Dauer</th><th></th></tr></thead>
<tbody>
{% for item in recordings %}
<tr>
<td>{{ item.filename }}</td>
<td>{{ item.datetime }}</td>
<td>{{ item.size }}</td>
<td>{{ item.duration }}</td>
<td><a class="button" href="{{ url_for('edit_recording', filename=item.filename) }}" onclick="showLoading()">✂ Bearbeiten</a></td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}<div class="empty">Keine fertigen Aufnahmen vorhanden.</div>{% endif %}
</div>
<div class="section">
<h2>Bearbeitete Aufnahmen</h2>
{% if finished %}
<table>
<thead><tr><th>Datei</th><th>Datum</th><th>Größe</th><th>Dauer</th><th></th></tr></thead>
<tbody>
{% for item in finished %}
<tr>
<td>{{ item.filename }}</td>
<td>{{ item.datetime }}</td>
<td>{{ item.size }}</td>
<td>{{ item.duration }}</td>
<td><a class="button" href="{{ url_for('finished_file', filename=item.filename) }}">▶ Abspielen / Download</a></td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}<div class="empty">Noch keine bearbeiteten Aufnahmen vorhanden.</div>{% endif %}
</div>
</div>
<script>
function showLoading() { document.getElementById('loadingOverlay').classList.add('active'); }
</script>
</body>
</html>
"""
# ---------------------------------------------------------------------------
# Routes
# ---------------------------------------------------------------------------
@app.route("/")
def index():
RECORDINGS_DIR.mkdir(parents=True, exist_ok=True)
FINISHED_DIR.mkdir(parents=True, exist_ok=True)
recordings = []
for path in RECORDINGS_DIR.glob("*.mp3"):
try:
stat = path.stat()
recordings.append({
"filename": path.name,
"datetime": format_datetime(stat.st_mtime),
"size": format_size(stat.st_size),
"duration": format_duration(get_duration(path)),
"mtime": stat.st_mtime,
})
except FileNotFoundError:
pass
finished = []
for path in FINISHED_DIR.glob("*.mp3"):
try:
stat = path.stat()
finished.append({
"filename": path.name,
"datetime": format_datetime(stat.st_mtime),
"size": format_size(stat.st_size),
"duration": format_duration(get_duration(path)),
"mtime": stat.st_mtime,
})
except FileNotFoundError:
pass
recordings.sort(key=lambda x: x["mtime"], reverse=True)
finished.sort(key=lambda x: x["mtime"], reverse=True)
return render_template_string(
INDEX_HTML,
recordings=recordings,
finished=finished,
recordings_dir=str(RECORDINGS_DIR),
)
@app.route("/edit/<path:filename>")
def edit_recording(filename):
path = source_path(filename)
if path is None:
abort(404)
duration = get_duration(path)
if duration is None:
abort(500, description="Aufnahmedauer konnte nicht ermittelt werden")
waveform = create_waveform(filename)
if waveform is None:
abort(500, description="Wellenform konnte nicht erzeugt werden")
return render_template_string(
EDITOR_HTML,
filename=filename,
duration=format_duration(duration),
duration_seconds=duration,
waveform_url=url_for("waveform", filename=filename),
audio_url=url_for("recording", filename=filename),
)
@app.route("/waveforms/<path:filename>")
def waveform(filename):
if source_path(filename) is None:
abort(404)
output = create_waveform(filename)
if output is None:
abort(404)
return send_from_directory(WAVEFORMS_DIR, output.name, mimetype="image/png")
@app.route("/recordings/<path:filename>")
def recording(filename):
path = source_path(filename)
if path is None:
abort(404)
return send_from_directory(RECORDINGS_DIR, path.name, mimetype="audio/mpeg")
@app.route("/finished/<path:filename>")
def finished_file(filename):
path = finished_path(filename)
if path is None or not path.is_file():
abort(404)
return send_from_directory(FINISHED_DIR, path.name, mimetype="audio/mpeg", as_attachment=False)
@app.route("/api/edit", methods=["POST"])
def api_edit():
data = request.get_json(silent=True) or {}
filename = data.get("filename")
try:
destination, duration = create_finished(filename, data.get("start"), data.get("end"))
return jsonify({
"success": True,
"filename": destination.name,
"duration": duration,
"duration_formatted": format_duration(duration),
})
except ValueError as exc:
return jsonify({"success": False, "error": str(exc)}), 400
except Exception as exc:
print(f"[edit] failed for {filename}: {exc}", flush=True)
return jsonify({"success": False, "error": "Schneiden fehlgeschlagen"}), 500
if __name__ == "__main__":
RECORDINGS_DIR.mkdir(parents=True, exist_ok=True)
FINISHED_DIR.mkdir(parents=True, exist_ok=True)
app.run(host="0.0.0.0", port=WEB_PORT)