980 lines
35 KiB
Python
980 lines
35 KiB
Python
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 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 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",
|
||
"-fflags", "+genpts",
|
||
"-ss", f"{start:.3f}",
|
||
"-i", str(source),
|
||
"-t", f"{end - start:.3f}",
|
||
"-map", "0:a:0",
|
||
"-vn", "-sn", "-dn",
|
||
"-af", "loudnorm=I=-16:TP=-1.5:LRA=11",
|
||
"-c:a", "libmp3lame",
|
||
"-b:a", "128k",
|
||
"-avoid_negative_ts", "make_zero",
|
||
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-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; }
|
||
.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="playFromStart()">▶ Ab Auswahlstart</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>
|
||
<button class="button danger" onclick="deleteOriginal()">🗑 Originalaufnahme löschen</button>
|
||
</div>
|
||
<div class="info">Beim Erstellen einer fertigen Aufnahme bleibt das Original unverändert. Das Original kann hier bewusst gelöscht werden; bereits erstellte fertige Aufnahmen bleiben dabei erhalten.</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 deleteOriginal() {
|
||
if (!confirm('Originalaufnahme wirklich löschen?\\n\\n' + filename + '\\n\\nBereits erstellte fertige Aufnahmen bleiben erhalten.')) return;
|
||
|
||
audio.pause();
|
||
const response = await fetch('/api/file', {
|
||
method: 'POST',
|
||
headers: {'Content-Type': 'application/json'},
|
||
body: JSON.stringify({action: 'delete', kind: 'recording', filename})
|
||
});
|
||
const result = await response.json();
|
||
if (!result.success) {
|
||
alert(result.error || 'Löschen fehlgeschlagen.');
|
||
return;
|
||
}
|
||
location.href = '/';
|
||
}
|
||
|
||
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 contentType = r.headers.get('content-type') || '';
|
||
let d;
|
||
if (contentType.includes('application/json')) {
|
||
d = await r.json();
|
||
} else {
|
||
const text = await r.text();
|
||
throw new Error(`Serverfehler HTTP ${r.status}: ${text.slice(0, 200)}`);
|
||
}
|
||
if(!r.ok || !d.success) throw new Error(d.error || `Fehler beim Schneiden (HTTP ${r.status})`);
|
||
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 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 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 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()
|
||
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/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")
|
||
|
||
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)
|