Files
event-audio-recorder/editor.py
T
2026-09-29 12:22:19 +02:00

1157 lines
44 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
import threading
import time
import uuid
import json
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"))
NORMALIZE = os.environ.get("NORMALIZE", "true").lower() not in ("0", "false", "no")
NORMALIZE_TARGET = os.environ.get("NORMALIZE_TARGET", "-16")
NORMALIZE_TRUE_PEAK = os.environ.get("NORMALIZE_TRUE_PEAK", "-1.5")
NORMALIZE_LRA = os.environ.get("NORMALIZE_LRA", "11")
JOBS = {}
JOBS_LOCK = threading.Lock()
# ---------------------------------------------------------------------------
# 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 clean_new_filename(filename):
"""Validate and normalize a user-supplied MP3 filename."""
if not isinstance(filename, str):
return None
filename = filename.strip()
if not filename:
return None
if Path(filename).name != filename:
return None
if filename.lower().endswith(".mp3"):
filename = filename[:-4]
if not filename or filename in {".", ".."}:
return None
if any(ch in filename for ch in '/\\\x00'):
return None
return filename + ".mp3"
def directory_for_kind(kind):
if kind == "recording":
return RECORDINGS_DIR
if kind == "finished":
return FINISHED_DIR
return None
def path_for_kind(kind, filename):
filename = safe_filename(filename)
directory = directory_for_kind(kind)
if filename is None or directory is None:
return None
return directory / filename
def rename_file(kind, old_filename, new_filename):
old_path = path_for_kind(kind, old_filename)
new_filename = clean_new_filename(new_filename)
if old_path is None or new_filename is None or not old_path.is_file():
raise ValueError("Ungültiger Dateiname")
new_path = old_path.with_name(new_filename)
if new_path.exists():
raise ValueError("Eine Datei mit diesem Namen existiert bereits")
old_waveform = waveform_path(old_path.name) if kind == "recording" else None
new_waveform = waveform_path(new_path.name) if kind == "recording" else None
old_path.rename(new_path)
if old_waveform and old_waveform.is_file():
try:
old_waveform.rename(new_waveform)
except OSError:
# The audio file was renamed successfully; an old waveform cache
# is harmless and will be regenerated under the new name.
pass
return new_path
def delete_file(kind, filename):
path = path_for_kind(kind, filename)
if path is None or not path.is_file():
raise ValueError("Datei nicht gefunden")
path.unlink()
if kind == "recording":
try:
waveform_path(path.name).unlink(missing_ok=True)
except OSError:
pass
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 read_metadata(path):
"""Read common ID3/FFmpeg metadata from an MP3."""
try:
result = subprocess.run(
["ffprobe", "-v", "error", "-show_entries", "format_tags=date,artist,title,album",
"-of", "json", str(path)],
capture_output=True, text=True, timeout=10,
)
if result.returncode != 0:
return {}
data = json.loads(result.stdout)
tags = data.get("format", {}).get("tags", {}) or {}
return {str(k).lower(): str(v) for k, v in tags.items() if v is not None}
except Exception:
return {}
def metadata_datetime(path):
tags = read_metadata(path)
value = tags.get("date", "")
if value:
try:
dt = datetime.fromisoformat(value.replace("Z", "+00:00"))
return dt.astimezone(ZoneInfo("Europe/Berlin")).strftime("%d.%m.%Y %H:%M:%S")
except ValueError:
pass
return format_datetime(path.stat().st_mtime)
def metadata_form_value(path):
tags = read_metadata(path)
value = tags.get("date", "")
if value:
try:
dt = datetime.fromisoformat(value.replace("Z", "+00:00"))
return dt.astimezone(ZoneInfo("Europe/Berlin")).strftime("%Y-%m-%dT%H:%M")
except ValueError:
pass
return datetime.fromtimestamp(path.stat().st_mtime, ZoneInfo("Europe/Berlin")).strftime("%Y-%m-%dT%H:%M")
def create_finished(filename, start, end, metadata=None, progress_callback=None):
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"
selected_duration = end - start
metadata = metadata or {}
cmd = [
"ffmpeg", "-hide_banner", "-loglevel", "error", "-y",
"-fflags", "+genpts",
"-ss", f"{start:.3f}",
"-i", str(source),
"-t", f"{selected_duration:.3f}",
"-map", "0:a:0",
"-vn", "-sn", "-dn",
*( ["-af", f"loudnorm=I={NORMALIZE_TARGET}:TP={NORMALIZE_TRUE_PEAK}:LRA={NORMALIZE_LRA}"] if NORMALIZE else [] ),
"-c:a", "libmp3lame",
"-b:a", "128k",
"-avoid_negative_ts", "make_zero",
"-metadata", f"date={metadata.get('date', '')}",
"-metadata", f"artist={metadata.get('artist', '')}",
"-metadata", f"title={metadata.get('title', '')}",
"-metadata", f"album={metadata.get('album', '')}",
"-progress", "pipe:1",
"-nostats",
str(temp),
]
try:
process = subprocess.Popen(
cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE,
text=True, bufsize=1
)
stderr_lines = []
stderr_thread = threading.Thread(
target=lambda: stderr_lines.extend(process.stderr.readlines()),
daemon=True,
)
stderr_thread.start()
last_progress = -1
while True:
line = process.stdout.readline()
if not line:
if process.poll() is not None:
break
time.sleep(0.05)
continue
line = line.strip()
if line.startswith("out_time_us="):
try:
out_us = int(line.split("=", 1)[1])
progress = max(0.0, min(100.0, out_us / 1_000_000 / selected_duration * 100.0))
if progress_callback and (progress - last_progress >= 0.2 or progress >= 100):
progress_callback(progress)
last_progress = progress
except (ValueError, ZeroDivisionError):
pass
returncode = process.wait()
stderr_thread.join(timeout=2)
stderr = "".join(stderr_lines).strip()
if returncode != 0 or not temp.is_file():
try:
temp.unlink(missing_ok=True)
except OSError:
pass
print(f"[edit] ffmpeg failed for {filename}: {stderr}", flush=True)
raise RuntimeError("Schneiden fehlgeschlagen" + (f": {stderr[-500]}" if stderr else ""))
temp.replace(destination)
if progress_callback:
progress_callback(100.0)
return destination, get_duration(destination)
except Exception:
try:
temp.unlink(missing_ok=True)
except OSError:
pass
raise
def run_edit_job(job_id, filename, start, end, metadata):
def update(**values):
with JOBS_LOCK:
job = JOBS.get(job_id)
if job:
job.update(values)
update(status="running", progress=0.0)
try:
destination, duration = create_finished(
filename, start, end, metadata=metadata,
progress_callback=lambda value: update(progress=value),
)
update(status="done", progress=100.0, filename=destination.name,
duration=duration, finished_at=time.time())
except Exception as exc:
print(f"[edit] job {job_id} failed for {filename}: {exc}", flush=True)
update(status="error", error=str(exc), finished_at=time.time())
# ---------------------------------------------------------------------------
# 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-image { position:absolute; top:0; left:0; width:100%; height:100%; object-fit:fill; display:block; opacity:.9; pointer-events:none; }
.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; }
.view-scroll { position:relative; width:100%; height:14px; margin-top:5px; background:#292929; border-radius:7px; overflow:hidden; touch-action:none; cursor:grab; }
.view-scroll-thumb { position:absolute; top:2px; bottom:2px; left:0; min-width:14px; background:#777; border-radius:5px; cursor:grab; touch-action:none; }
.view-scroll-thumb:hover { background:#999; }
.view-scroll-thumb.dragging { background:#aaa; cursor:grabbing; }
.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.danger { background:#4a2020; }
.button.danger:hover { background:#6a2b2b; }
.actions { display:flex; gap:6px; flex-wrap:wrap; }
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; }
.zoom { color:#aaa; 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; }
.meta-dialog { position:fixed; inset:0; background:rgba(0,0,0,.78); display:none; align-items:center; justify-content:center; z-index:900; padding:20px; }
.meta-dialog.active { display:flex; }
.meta-card { width:min(560px,100%); background:#202020; border:1px solid #444; border-radius:10px; padding:22px; box-shadow:0 10px 40px #000; }
.meta-card h2 { margin-top:0; }
.meta-card label { display:block; margin:14px 0; color:#ccc; }
.meta-card input { display:block; width:100%; margin-top:6px; padding:10px; background:#111; color:#eee; border:1px solid #555; border-radius:5px; font-size:16px; }
.meta-actions { display:flex; gap:10px; justify-content:flex-end; margin-top:20px; }
.meta-actions button { margin:0; }
.progress-box { display:none; margin-top:16px; }
.progress-box.active { display:block; }
.progress-track { height:18px; background:#292929; border-radius:9px; overflow:hidden; }
.progress-bar { height:100%; width:0; background:#1769aa; transition:width .2s ease; }
.progress-info { display:flex; justify-content:space-between; margin-top:7px; color:#aaa; font-size:14px; font-variant-numeric:tabular-nums; }
.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; }
.view-scroll { height:18px; }
.view-scroll-thumb { min-width:28px; }
.zoom { width:100%; margin-left:0; }
.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 class="wave-image" id="waveImage" 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="view-scroll" id="viewScroll" aria-label="Sichtbaren Bereich verschieben">
<div class="view-scroll-thumb" id="viewScrollThumb"></div>
</div>
<div class="time-row">
<span id="viewStart">00:00:00</span>
<span id="viewEnd">{{ 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="setStartAtCurrent()">⏱ Anfang = Position</button>
<button onclick="setEndAtCurrent()">⏱ Ende = Position</button>
<button onclick="resetZoom()">↺ Gesamtansicht</button>
<span class="zoom" id="zoomValue">Zoom: 100 %</span>
<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">Beim Erstellen einer fertigen Aufnahme bleibt das Original unverändert.</div>
<div class="meta-dialog" id="metaDialog">
<div class="meta-card">
<h2>Metadaten der fertigen Aufnahme</h2>
<label>Zeitpunkt<input id="metaDate" type="datetime-local" value="{{ metadata_datetime }}" required></label>
<label>Redner<input id="metaArtist" type="text" value="{{ metadata.artist|default('', true) }}" placeholder="Name des Redners"></label>
<label>Beschreibung<input id="metaTitle" type="text" value="{{ metadata.title|default('', true) }}" placeholder="Titel / Beschreibung des Vortrags"></label>
<label>Veranstaltung<input id="metaAlbum" type="text" value="{{ metadata.album|default('', true) }}" placeholder="Veranstaltung"></label>
<div class="meta-actions"><button onclick="closeMetaDialog()">Abbrechen</button><button class="primary" onclick="submitWithMetadata()">Fertige Aufnahme erstellen</button></div>
</div>
</div>
<div class="progress-box id="progressBox"><div class="progress-track"><div class="progress-bar" id="progressBar"></div></div><div class="progress-info"><span id="progressText">Verarbeitung läuft …</span><span id="progressPercent">0 %</span></div></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;
let zoom = 1;
let viewStart = 0;
let viewEnd = duration;
const MIN_ZOOM = 1;
const MAX_ZOOM = 16;
const ZOOM_FACTOR = 1.5;
const wave = document.getElementById('wave');
const waveImage = document.getElementById('waveImage');
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');
const viewScroll = document.getElementById('viewScroll');
const viewScrollThumb = document.getElementById('viewScrollThumb');
let scrollbarDragging = false;
let scrollbarPointerId = null;
let scrollbarStartX = 0;
let scrollbarStartView = 0;
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();
const ratio = Math.max(0, Math.min(1, (clientX-r.left)/r.width));
return viewStart + ratio * (viewEnd-viewStart);
}
function timeToPercent(t) {
const span = viewEnd-viewStart;
if (span <= 0) return 0;
return ((t-viewStart)/span)*100;
}
function render() {
const a = timeToPercent(start);
const b = timeToPercent(end);
startHandle.style.left = a+'%';
endHandle.style.left = b+'%';
selection.style.left = a+'%';
selection.style.width = Math.max(0, b-a)+'%';
const imageWidth = zoom * 100;
waveImage.style.width = imageWidth+'%';
waveImage.style.left = (-(viewStart/duration)*imageWidth)+'%';
document.getElementById('startValue').textContent = fmt(start);
document.getElementById('endValue').textContent = fmt(end);
document.getElementById('keepValue').textContent = fmt(end-start);
document.getElementById('viewStart').textContent = fmt(viewStart);
document.getElementById('viewEnd').textContent = fmt(viewEnd);
document.getElementById('zoomValue').textContent = 'Zoom: '+Math.round(zoom*100)+' %';
// The scrollbar thumb represents the currently visible part of the full recording.
const visiblePercent = Math.max(0, Math.min(100, ((viewEnd-viewStart)/duration)*100));
const offsetPercent = Math.max(0, Math.min(100-visiblePercent, (viewStart/duration)*100));
viewScrollThumb.style.width = visiblePercent+'%';
viewScrollThumb.style.left = offsetPercent+'%';
viewScrollThumb.style.pointerEvents = zoom > 1 ? 'auto' : 'none';
viewScrollThumb.classList.toggle('dragging', scrollbarDragging);
renderPlayhead();
}
function renderPlayhead() {
const t = Math.max(0, Math.min(duration, audio.currentTime || 0));
playhead.style.left = timeToPercent(t)+'%';
}
function setZoom(newZoom, clientX) {
newZoom = Math.max(MIN_ZOOM, Math.min(MAX_ZOOM, newZoom));
if (newZoom === zoom) return;
const r = wave.getBoundingClientRect();
let focusRatio = 0.5;
if (typeof clientX === 'number' && r.width > 0) {
focusRatio = Math.max(0, Math.min(1, (clientX-r.left)/r.width));
}
const oldSpan = viewEnd-viewStart;
const focusTime = viewStart + focusRatio*oldSpan;
const newSpan = duration/newZoom;
let newStart = focusTime-focusRatio*newSpan;
newStart = Math.max(0, Math.min(duration-newSpan, newStart));
zoom = newZoom;
viewStart = newStart;
viewEnd = newStart+newSpan;
render();
}
function resetZoom() {
zoom = 1;
viewStart = 0;
viewEnd = duration;
render();
}
function moveViewTo(newStart) {
const span = viewEnd-viewStart;
if (span >= duration) {
viewStart = 0;
viewEnd = duration;
} else {
viewStart = Math.max(0, Math.min(duration-span, newStart));
viewEnd = viewStart+span;
}
render();
}
viewScrollThumb.addEventListener('pointerdown', e => {
if (zoom <= 1) return;
e.preventDefault();
e.stopPropagation();
scrollbarDragging = true;
scrollbarPointerId = e.pointerId;
scrollbarStartX = e.clientX;
scrollbarStartView = viewStart;
viewScrollThumb.setPointerCapture(e.pointerId);
render();
});
viewScrollThumb.addEventListener('pointermove', e => {
if (!scrollbarDragging || e.pointerId !== scrollbarPointerId) return;
const trackWidth = viewScroll.clientWidth;
const thumbWidth = viewScrollThumb.offsetWidth;
const movableWidth = Math.max(1, trackWidth-thumbWidth);
const span = viewEnd-viewStart;
const deltaPixels = e.clientX-scrollbarStartX;
const deltaTime = (deltaPixels/movableWidth) * (duration-span);
moveViewTo(scrollbarStartView+deltaTime);
e.preventDefault();
});
function endScrollbarDrag(e) {
if (!scrollbarDragging || e.pointerId !== scrollbarPointerId) return;
scrollbarDragging = false;
scrollbarPointerId = null;
render();
}
viewScrollThumb.addEventListener('pointerup', endScrollbarDrag);
viewScrollThumb.addEventListener('pointercancel', endScrollbarDrag);
// Clicking/touching the empty part of the scrollbar moves the visible window
// so that the clicked position becomes its center.
viewScroll.addEventListener('pointerdown', e => {
if (zoom <= 1 || e.target === viewScrollThumb) return;
e.preventDefault();
const r = viewScroll.getBoundingClientRect();
const ratio = Math.max(0, Math.min(1, (e.clientX-r.left)/r.width));
const span = viewEnd-viewStart;
moveViewTo(ratio*duration-span/2);
});
wave.addEventListener('wheel', e => {
e.preventDefault();
const factor = e.deltaY < 0 ? ZOOM_FACTOR : 1/ZOOM_FACTOR;
setZoom(zoom*factor, e.clientX);
}, {passive:false});
// Touchscreen: two-finger pinch zoom. The midpoint between the fingers
// is used as the zoom focus, just like the mouse position for the wheel.
const touchPointers = new Map();
let pinchDistance = null;
let pinchZoom = null;
function touchDistance(a, b) {
return Math.hypot(a.clientX-b.clientX, a.clientY-b.clientY);
}
function touchMidpoint(a, b) {
return (a.clientX+b.clientX)/2;
}
function startPinch() {
const points = [...touchPointers.values()];
if (points.length !== 2) return;
pinchDistance = touchDistance(points[0], points[1]);
pinchZoom = zoom;
}
wave.addEventListener('pointerdown', e => {
if (e.pointerType !== 'touch') return;
touchPointers.set(e.pointerId, e);
if (touchPointers.size === 2) {
pinchZoom = zoom;
startPinch();
}
}, {passive:false});
wave.addEventListener('pointermove', e => {
if (e.pointerType !== 'touch' || !touchPointers.has(e.pointerId)) return;
touchPointers.set(e.pointerId, e);
if (touchPointers.size !== 2 || !pinchDistance || pinchZoom === null) return;
const points = [...touchPointers.values()];
const distance = touchDistance(points[0], points[1]);
if (distance <= 0) return;
const midpointX = touchMidpoint(points[0], points[1]);
setZoom(pinchZoom * (distance/pinchDistance), midpointX);
e.preventDefault();
}, {passive:false});
function endTouchPointer(e) {
if (e.pointerType !== 'touch') return;
touchPointers.delete(e.pointerId);
if (touchPointers.size < 2) {
pinchDistance = null;
pinchZoom = null;
}
}
wave.addEventListener('pointerup', endTouchPointer);
wave.addEventListener('pointercancel', endTouchPointer);
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;
if (e.pointerType === 'touch' && touchPointers.size >= 2) 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('loadedmetadata', renderPlayhead);
audio.addEventListener('play', renderPlayhead);
audio.addEventListener('pause', renderPlayhead);
audio.addEventListener('seeking', renderPlayhead);
function animatePlayhead() {
renderPlayhead();
if (!audio.paused) requestAnimationFrame(animatePlayhead);
}
audio.addEventListener('play', () => requestAnimationFrame(animatePlayhead));
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);
document.getElementById('metaDialog').classList.add('active');
}
function closeMetaDialog() { document.getElementById('metaDialog').classList.remove('active'); }
async function submitWithMetadata() {
const date=document.getElementById('metaDate').value;
const artist=document.getElementById('metaArtist').value.trim();
const title=document.getElementById('metaTitle').value.trim();
const album=document.getElementById('metaAlbum').value.trim();
if (!date) return alert('Bitte einen Zeitpunkt angeben.');
closeMetaDialog();
const btn=document.getElementById('trimButton');
const progressBox=document.getElementById('progressBox');
const progressBar=document.getElementById('progressBar');
const progressText=document.getElementById('progressText');
const progressPercent=document.getElementById('progressPercent');
btn.disabled=true; audio.pause(); progressBox.classList.add('active'); progressBar.style.width='0%'; progressPercent.textContent='0 %'; progressText.textContent='Verarbeitung wird gestartet …'; msg('Fertige Aufnahme wird im Hintergrund erstellt …',true);
try {
const r=await fetch('/api/edit', {method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({filename,start,end,metadata:{date,artist,title,album}})});
const d=await r.json();
if(!r.ok || !d.success) throw new Error(d.error || `Fehler beim Starten (HTTP ${r.status})`);
const poll=async()=>{
const sr=await fetch('/api/edit/status/'+encodeURIComponent(d.job_id), {cache:'no-store'}); const sd=await sr.json();
if(!sr.ok || !sd.success) throw new Error(sd.error || `Statusfehler HTTP ${sr.status}`);
const percent=Math.max(0,Math.min(100,sd.progress || 0)); progressBar.style.width=percent+'%'; progressPercent.textContent=Math.round(percent)+' %';
if(sd.status==='running' || sd.status==='queued') { progressText.textContent=sd.status==='queued'?'Verarbeitung wird gestartet …':'Verarbeitung läuft …'; setTimeout(poll,700); }
else if(sd.status==='done') { progressBar.style.width='100%'; progressPercent.textContent='100 %'; progressText.textContent='Fertig'; msg('Fertige Aufnahme erfolgreich erstellt.',true); setTimeout(()=>location.reload(),800); }
else throw new Error(sd.error || 'Schneiden fehlgeschlagen');
}; poll().catch(e=>{msg(e.message,false);btn.disabled=false;progressText.textContent='Fehler';});
} catch(e) { msg(e.message,false); btn.disabled=false; progressBox.classList.remove('active'); }
}
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>Dateiname</th><th>Größe</th><th>Dauer</th><th></th></tr></thead>
<tbody>
{% for item in recordings %}
<tr>
<td>{{ item.filename }}</td>
<td>{{ item.size }}</td>
<td>{{ item.duration }}</td>
<td class="actions">
<a class="button" href="{{ url_for('edit_recording', filename=item.filename) }}" onclick="showLoading()">✂ Bearbeiten</a>
<button class="button" onclick='renameFile("recording", {{ item.filename|tojson }})'>✎ Umbenennen</button>
<button class="button danger" onclick='deleteFile("recording", {{ item.filename|tojson }})'>🗑 Löschen</button>
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}<div class="empty">Keine unbearbeiteten Aufnahmen vorhanden.</div>{% endif %}
</div>
<div class="section">
<h2>Bearbeitete Aufnahmen</h2>
{% if finished %}
<table>
<thead><tr><th>Zeitpunkt</th><th>Redner</th><th>Beschreibung</th><th>Veranstaltung</th><th>Dauer</th><th></th></tr></thead>
<tbody>
{% for item in finished %}
<tr>
<td>{{ item.datetime }}</td>
<td>{{ item.artist or "–" }}</td>
<td>{{ item.title or "–" }}</td>
<td>{{ item.album or "–" }}</td>
<td>{{ item.duration }}</td>
<td class="actions">
<a class="button" href="{{ url_for('finished_file', filename=item.filename) }}">▶ Abspielen / Download</a>
<button class="button" onclick='renameFile("finished", {{ item.filename|tojson }})'>✎ Umbenennen</button>
<button class="button danger" onclick='deleteFile("finished", {{ item.filename|tojson }})'>🗑 Löschen</button>
</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'); }
async function renameFile(kind, filename) {
const current = filename.replace(/\\.mp3$/i, '');
const entered = prompt('Neuer Dateiname:', current);
if (entered === null) return;
const newName = entered.trim();
if (!newName || newName === current) return;
try {
const response = await fetch('/api/file', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({action: 'rename', kind, filename, new_filename: newName})
});
const result = await response.json();
if (!response.ok || !result.success) throw new Error(result.error || `HTTP ${response.status}`);
location.reload();
} catch (error) {
console.error('Umbenennen fehlgeschlagen:', error);
alert('Umbenennen fehlgeschlagen: ' + error.message);
}
}
async function deleteFile(kind, filename) {
if (!confirm('Datei wirklich löschen?\\n\\n' + filename)) return;
try {
const response = await fetch('/api/file', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({action: 'delete', kind, filename})
});
const result = await response.json();
if (!response.ok || !result.success) throw new Error(result.error || `HTTP ${response.status}`);
location.reload();
} catch (error) {
console.error('Löschen fehlgeschlagen:', error);
alert('Löschen fehlgeschlagen: ' + error.message);
}
}
</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()
tags = read_metadata(path)
finished.append({
"filename": path.name,
"datetime": tags.get("date", "") or format_datetime(stat.st_mtime),
"artist": tags.get("artist", ""),
"title": tags.get("title", ""),
"album": tags.get("album", ""),
"size": format_size(stat.st_size),
"duration": format_duration(get_duration(path)),
"sort_date": tags.get("date", "") or datetime.fromtimestamp(stat.st_mtime, ZoneInfo("Europe/Berlin")).isoformat(),
})
except FileNotFoundError:
pass
recordings.sort(key=lambda x: x["mtime"], reverse=True)
finished.sort(key=lambda x: x["sort_date"], 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,
metadata=read_metadata(path),
metadata_datetime=metadata_form_value(path),
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/file", methods=["POST"])
def api_file():
data = request.get_json(silent=True) or {}
action = data.get("action")
kind = data.get("kind")
filename = data.get("filename")
if kind not in {"recording", "finished"}:
return jsonify({"success": False, "error": "Ungültiger Dateityp"}), 400
try:
if action == "rename":
path = rename_file(kind, filename, data.get("new_filename"))
return jsonify({"success": True, "filename": path.name})
if action == "delete":
delete_file(kind, filename)
return jsonify({"success": True})
return jsonify({"success": False, "error": "Ungültige Aktion"}), 400
except ValueError as exc:
return jsonify({"success": False, "error": str(exc)}), 400
except OSError as exc:
print(f"[file] operation failed for {filename}: {exc}", flush=True)
return jsonify({"success": False, "error": "Dateioperation fehlgeschlagen"}), 500
@app.route("/api/edit", methods=["POST"])
def api_edit():
data = request.get_json(silent=True) or {}
filename = data.get("filename")
start = data.get("start")
end = data.get("end")
metadata = data.get("metadata") or {}
source = source_path(filename)
if source is None:
return jsonify({"success": False, "error": "Aufnahme nicht gefunden"}), 404
try:
start_f = float(start)
end_f = float(end)
except (TypeError, ValueError):
return jsonify({"success": False, "error": "Ungültige Zeitangabe"}), 400
if end_f <= start_f or end_f - start_f < 0.5:
return jsonify({"success": False, "error": "Ungültiger Auswahlbereich"}), 400
job_id = uuid.uuid4().hex
with JOBS_LOCK:
JOBS[job_id] = {"status":"queued", "progress":0.0, "filename":filename,
"created_at":time.time()}
metadata = {
"date": str(metadata.get("date", "")).strip(),
"artist": str(metadata.get("artist", "")).strip(),
"title": str(metadata.get("title", "")).strip(),
"album": str(metadata.get("album", "")).strip(),
}
if not metadata["date"]:
return jsonify({"success": False, "error": "Zeitpunkt fehlt"}), 400
try:
dt = datetime.fromisoformat(metadata["date"])
if dt.tzinfo is None:
dt = dt.replace(tzinfo=ZoneInfo("Europe/Berlin"))
metadata["date"] = dt.isoformat()
except ValueError:
return jsonify({"success": False, "error": "Ungültiger Zeitpunkt"}), 400
threading.Thread(target=run_edit_job, args=(job_id, filename, start_f, end_f, metadata), daemon=True).start()
return jsonify({"success": True, "job_id": job_id}), 202
@app.route("/api/edit/status/<job_id>")
def api_edit_status(job_id):
with JOBS_LOCK:
job = JOBS.get(job_id)
if not job:
return jsonify({"success": False, "error": "Job nicht gefunden"}), 404
result = dict(job)
return jsonify({"success": True, **result})
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)