Files
event-audio-recorder/recorder.py
T
2026-09-28 16:20:20 +02:00

1005 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 signal
import subprocess
import threading
import time
from datetime import datetime
from pathlib import Path
from zoneinfo import ZoneInfo
import requests
from flask import Flask, render_template_string, send_from_directory, abort, Response, request
# ---------------------------------------------------------------------------
# Configuration
# ---------------------------------------------------------------------------
ICECAST_STATUS_URL = os.environ.get(
"ICECAST_STATUS_URL",
"http://icecast:8000/status-json.xsl",
)
ICECAST_STREAM_URL = os.environ.get(
"ICECAST_STREAM_URL",
"http://icecast:8000/stream.mp3",
)
ICECAST_PUBLIC_URL = os.environ.get(
"ICECAST_PUBLIC_URL",
"https://audiostream.diereuthers.de/stream.mp3",
)
STREAM_MOUNTPOINT = os.environ.get(
"STREAM_MOUNTPOINT",
"/stream.mp3",
)
RECORDINGS_DIR = Path(
os.environ.get("RECORDINGS_DIR", "/recordings")
)
WEB_PORT = int(
os.environ.get("WEB_PORT", "8080")
)
TIMEZONE = ZoneInfo(
os.environ.get("TIMEZONE", "Europe/Berlin")
)
START_DELAY = float(
os.environ.get("START_DELAY", "3")
)
STOP_DELAY = float(
os.environ.get("STOP_DELAY", "10")
)
POLL_INTERVAL = float(
os.environ.get("POLL_INTERVAL", "2")
)
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")
# ---------------------------------------------------------------------------
# Application state
# ---------------------------------------------------------------------------
app = Flask(__name__)
state_lock = threading.Lock()
ffmpeg_process = None
recording_filename = None
recording_started = None
normalizing_filename = None
source_seen_since = None
source_missing_since = None
last_status_ok = False
last_status_check = None
last_status_error = None
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def now():
return datetime.now(TIMEZONE)
def format_datetime(timestamp):
if not timestamp:
return "–"
return datetime.fromtimestamp(
timestamp,
TIMEZONE
).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 get_duration(filename):
path = RECORDINGS_DIR / filename
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=5,
)
if result.returncode != 0:
return None
return float(result.stdout.strip())
except Exception:
return None
def is_stream_active(data):
"""
Detect the configured Icecast mountpoint.
Icecast normally returns a dict for one source and can return
a list when multiple sources exist.
"""
try:
source = data["icestats"]["source"]
except (KeyError, TypeError):
return False
if not source:
return False
if isinstance(source, dict):
sources = [source]
elif isinstance(source, list):
sources = source
else:
return False
for item in sources:
if not isinstance(item, dict):
continue
listenurl = item.get("listenurl", "")
# Icecast returns the public listen URL, so compare the path.
if listenurl:
try:
from urllib.parse import urlparse
path = urlparse(listenurl).path
if path == STREAM_MOUNTPOINT:
return True
except Exception:
pass
return False
def query_icecast():
global last_status_ok
global last_status_check
global last_status_error
try:
response = requests.get(
ICECAST_STATUS_URL,
timeout=3,
)
response.raise_for_status()
data = response.json()
with state_lock:
last_status_ok = True
last_status_check = time.time()
last_status_error = None
return is_stream_active(data)
except Exception as exc:
with state_lock:
last_status_ok = False
last_status_check = time.time()
last_status_error = str(exc)
print(
f"[monitor] Icecast status check failed: {exc}",
flush=True,
)
# Important:
# A temporary status failure must NOT be interpreted as
# "stream stopped".
return None
# ---------------------------------------------------------------------------
# Recording
# ---------------------------------------------------------------------------
def start_recording():
global ffmpeg_process
global recording_filename
global recording_started
timestamp = now().strftime("%Y-%m-%d_%H-%M-%S")
filename = f"{timestamp}.mp3"
output = RECORDINGS_DIR / filename
RECORDINGS_DIR.mkdir(
parents=True,
exist_ok=True,
)
print(
f"[recorder] Starting recording: {filename}",
flush=True,
)
ffmpeg_process = subprocess.Popen(
[
"ffmpeg",
"-hide_banner",
"-loglevel", "warning",
"-i", ICECAST_STREAM_URL,
"-c:a", "copy",
"-y",
str(output),
]
)
recording_filename = filename
recording_started = time.time()
def normalize_recording(filename):
"""Normalize a completed MP3 and replace it only after success."""
if not NORMALIZE:
return
source = RECORDINGS_DIR / filename
if not source.exists():
return
temp = RECORDINGS_DIR / (filename + ".normalizing.mp3")
cmd = [
"ffmpeg",
"-hide_banner",
"-loglevel", "warning",
"-y",
"-i", str(source),
"-af",
f"loudnorm=I={NORMALIZE_TARGET}:TP={NORMALIZE_TRUE_PEAK}:LRA={NORMALIZE_LRA}",
"-c:a", "libmp3lame",
"-b:a", "128k",
str(temp),
]
try:
result = subprocess.run(
cmd,
stdout=subprocess.DEVNULL,
stderr=subprocess.PIPE,
text=True,
check=False,
)
if result.returncode != 0 or not temp.exists() or temp.stat().st_size == 0:
print(
f"[normalize] failed for {filename}: "
f"{result.stderr.strip()[-1000:]}",
flush=True,
)
temp.unlink(missing_ok=True)
return
# Only replace the original after a complete successful conversion.
temp.replace(source)
print(f"[normalize] completed: {filename}", flush=True)
except Exception as exc:
print(f"[normalize] exception for {filename}: {exc}", flush=True)
temp.unlink(missing_ok=True)
def stop_recording():
global ffmpeg_process
global recording_filename
global recording_started
global normalizing_filename
if ffmpeg_process is None:
return
filename = recording_filename
print(
f"[recorder] Stopping recording: {filename}",
flush=True,
)
try:
ffmpeg_process.send_signal(signal.SIGINT)
ffmpeg_process.wait(timeout=15)
except subprocess.TimeoutExpired:
print(
"[recorder] FFmpeg did not stop cleanly, terminating",
flush=True,
)
ffmpeg_process.terminate()
try:
ffmpeg_process.wait(timeout=5)
except subprocess.TimeoutExpired:
ffmpeg_process.kill()
except Exception as exc:
print(
f"[recorder] Error stopping FFmpeg: {exc}",
flush=True,
)
ffmpeg_process = None
recording_filename = None
recording_started = None
# The live recording is now complete. Normalize it before it appears
# as the finished recording in the web UI.
if filename and NORMALIZE:
normalizing_filename = filename
try:
normalize_recording(filename)
finally:
normalizing_filename = None
# ---------------------------------------------------------------------------
# Monitoring loop
# ---------------------------------------------------------------------------
def recorder_loop():
global ffmpeg_process
global recording_filename
global recording_started
global source_seen_since
global source_missing_since
print("[monitor] Recorder monitor started", flush=True)
while True:
active = query_icecast()
now_ts = time.time()
with state_lock:
process = ffmpeg_process
current_filename = recording_filename
if active is None:
time.sleep(POLL_INTERVAL)
continue
# FFmpeg ending after the Icecast source disappeared is a normal
# end of the recording. Finalize it so normalization is performed.
if process is not None and process.poll() is not None:
if not active:
print(
f"[recorder] FFmpeg ended because the Icecast stream stopped: {current_filename}",
flush=True,
)
stop_recording()
source_seen_since = None
source_missing_since = None
time.sleep(POLL_INTERVAL)
continue
print(
"[recorder] FFmpeg exited unexpectedly while Icecast is active",
flush=True,
)
ffmpeg_process = None
recording_filename = None
recording_started = None
source_seen_since = now_ts
time.sleep(POLL_INTERVAL)
continue
if active:
source_missing_since = None
if process is None:
if source_seen_since is None:
source_seen_since = now_ts
elif now_ts - source_seen_since >= START_DELAY:
start_recording()
source_seen_since = None
else:
source_seen_since = None
else:
source_seen_since = None
if process is not None:
if source_missing_since is None:
source_missing_since = now_ts
elif now_ts - source_missing_since >= STOP_DELAY:
stop_recording()
source_missing_since = None
else:
source_missing_since = None
time.sleep(POLL_INTERVAL)
# ---------------------------------------------------------------------------
# Web UI
# ---------------------------------------------------------------------------
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 – Aufnahmen</title>
<style>
* {
box-sizing: border-box;
}
body {
margin: 0;
padding: 24px;
background: #111;
color: #eee;
font-family: Arial, Helvetica, sans-serif;
}
h1 {
margin: 0 0 8px 0;
font-size: 28px;
}
.subtitle {
color: #aaa;
margin-bottom: 24px;
}
.status {
display: inline-block;
padding: 8px 14px;
border-radius: 6px;
margin-bottom: 24px;
font-weight: bold;
}
.status.recording {
background: #8b0000;
color: white;
}
.status.idle {
background: #333;
color: #ccc;
}
.status.error {
background: #6b4e00;
color: white;
}
.recording {
border: 1px solid #b00000;
background: #1d1111;
border-radius: 8px;
padding: 16px;
margin-bottom: 24px;
}
.recording-title {
font-size: 20px;
font-weight: bold;
margin-bottom: 8px;
}
.recording-info {
color: #bbb;
margin-bottom: 12px;
}
.live-panel {
display: flex;
gap: 16px;
border: 1px solid #b00000;
background: #1d1111;
border-radius: 8px;
padding: 16px;
margin-bottom: 24px;
}
.live-item {
flex: 1;
min-width: 0;
}
.live-item + .live-item {
border-left: 1px solid #3a2222;
padding-left: 16px;
}
.live-title {
font-size: 19px;
font-weight: bold;
margin-bottom: 6px;
}
.live-info {
color: #aaa;
font-size: 14px;
margin-bottom: 10px;
}
table {
width: 100%;
border-collapse: collapse;
background: #181818;
border-radius: 8px;
overflow: hidden;
}
th {
text-align: left;
background: #252525;
padding: 12px;
color: #ccc;
}
td {
padding: 12px;
border-top: 1px solid #2b2b2b;
vertical-align: middle;
}
audio {
width: 280px;
max-width: 100%;
}
a.button {
display: inline-block;
padding: 8px 12px;
margin-left: 8px;
background: #333;
color: #fff;
text-decoration: none;
border-radius: 5px;
}
a.button:hover {
background: #444;
}
.empty {
padding: 30px;
text-align: center;
color: #777;
}
.footer {
margin-top: 24px;
color: #666;
font-size: 13px;
}
@media (max-width: 800px) {
body {
padding: 12px;
}
table, thead, tbody, th, td, tr {
display: block;
}
thead {
display: none;
}
tr {
border-bottom: 1px solid #333;
padding: 12px 0;
}
td {
border: 0;
padding: 6px 0;
}
audio {
width: 100%;
}
.live-panel {
display: block;
}
.live-item + .live-item {
border-left: 0;
border-top: 1px solid #3a2222;
padding-left: 0;
padding-top: 16px;
margin-top: 16px;
}
a.button {
margin-left: 0;
margin-right: 8px;
}
}
</style>
</head>
<body>
<h1>EVENT AUDIO – Aufnahmen</h1>
<div class="subtitle">
Audiostream-Aufzeichnungen
</div>
<div id="dynamic-content">
{% if normalizing_filename %}
<div class="status recording">
● AUFNAHME WIRD NORMALISIERT
</div>
<div class="recording">
<div class="recording-title">{{ normalizing_filename }}</div>
<div class="recording-info">Lautstärke wird automatisch normalisiert …</div>
</div>
{% elif recording %}
<div class="status recording">
● AUFNAHME LÄUFT
</div>
<div class="live-panel">
<div class="live-item">
<div class="live-title">🔴 Live-Stream</div>
<div class="live-info">
Direkt vom Icecast-Stream
</div>
<audio controls preload="none">
<source src="{{ icecast_public_url }}"
type="audio/mpeg">
</audio>
</div>
<div class="live-item">
<div class="live-title">▶ Aktuelle Aufnahme</div>
<div class="live-info">
Zeitversetzt · gestartet {{ recording.started }}
· aktuell {{ recording.size }}
</div>
<audio controls>
<source src="/recordings/{{ recording.filename }}?live=1"
type="audio/mpeg">
</audio>
</div>
</div>
{% elif status_error %}
<div class="status error">
⚠ Icecast-Status momentan nicht erreichbar
</div>
{% else %}
<div class="status idle">
● keine laufende Aufnahme
</div>
{% endif %}
<h2>Fertige Aufnahmen</h2>
{% if recordings %}
<table>
<thead>
<tr>
<th>Start</th>
<th>Datei</th>
<th>Größe</th>
<th>Dauer</th>
<th>Wiedergabe</th>
<th></th>
</tr>
</thead>
<tbody>
{% for item in recordings %}
<tr>
<td>{{ item.datetime }}</td>
<td>{{ item.filename }}</td>
<td>{{ item.size }}</td>
<td>{{ item.duration }}</td>
<td>
<audio controls preload="none">
<source src="/recordings/{{ item.filename }}"
type="audio/mpeg">
</audio>
</td>
<td>
<a class="button"
href="/recordings/{{ item.filename }}"
download>
Download
</a>
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<div class="empty">
Noch keine fertigen Aufnahmen vorhanden.
</div>
{% endif %}
</div>
<div class="footer">
Aktualisierung automatisch alle 10 Sekunden
</div>
<script>
async function updatePage() {
// Never rebuild the DOM while an audio player is playing or paused at
// a non-zero position. Replacing <audio> elements would reset playback.
const players = Array.from(document.querySelectorAll("audio"));
const audioActive = players.some(player =>
!player.paused || player.currentTime > 0
);
if (audioActive) {
return;
}
try {
const response = await fetch("/api/status", {
cache: "no-store"
});
if (!response.ok) {
return;
}
const data = await response.text();
const parser = new DOMParser();
const doc = parser.parseFromString(data, "text/html");
const newContent = doc.querySelector("#dynamic-content");
const currentContent = document.querySelector("#dynamic-content");
if (newContent && currentContent) {
currentContent.innerHTML = newContent.innerHTML;
}
} catch (error) {
console.warn("Status update failed:", error);
}
}
setInterval(updatePage, 10000);
</script>
</body>
</html>
"""
def render_index():
global ffmpeg_process
global recording_filename
global recording_started
global normalizing_filename
files = []
RECORDINGS_DIR.mkdir(
parents=True,
exist_ok=True,
)
for path in RECORDINGS_DIR.glob("*.mp3"):
try:
stat = path.stat()
# Skip the currently active file from the finished list.
if path.name == recording_filename:
continue
files.append({
"filename": path.name,
"datetime": format_datetime(stat.st_mtime),
"size": format_size(stat.st_size),
"duration": format_duration(
get_duration(path.name)
),
"mtime": stat.st_mtime,
})
except FileNotFoundError:
continue
files.sort(
key=lambda item: item["mtime"],
reverse=True,
)
with state_lock:
process = ffmpeg_process
current_filename = recording_filename
current_started = recording_started
status_error = not last_status_ok
recording = None
if process is not None and process.poll() is None:
current_size = "–"
if current_filename:
current_path = RECORDINGS_DIR / current_filename
try:
current_size = format_size(
current_path.stat().st_size
)
except FileNotFoundError:
pass
recording = {
"filename": current_filename,
"started": format_datetime(current_started),
"size": current_size,
}
return render_template_string(
HTML,
recordings=files,
recording=recording,
normalizing_filename=normalizing_filename,
status_error=status_error,
icecast_public_url=ICECAST_PUBLIC_URL,
)
@app.route("/")
def index():
return render_index()
@app.route("/api/status")
def api_status():
return render_index()
@app.route("/recordings/<path:filename>")
def recording(filename):
path = RECORDINGS_DIR / filename
if not path.is_file():
abort(404)
# For the active recording, keep the HTTP connection open and wait for
# additional MP3 data appended by FFmpeg. A normal static-file response
# would expose the current Content-Length and the browser would stop
# when it reaches the file size that existed when playback started.
if request.args.get("live") == "1" and recording_filename == filename and ffmpeg_process is not None:
def generate():
position = 0
while True:
try:
size = path.stat().st_size
except FileNotFoundError:
break
if position < size:
try:
with path.open("rb") as f:
f.seek(position)
while True:
chunk = f.read(64 * 1024)
if not chunk:
break
position += len(chunk)
yield chunk
except (FileNotFoundError, OSError):
break
continue
# The recording is still active: wait for FFmpeg to append
# more data instead of closing the HTTP response.
if recording_filename == filename and ffmpeg_process is not None:
time.sleep(0.5)
continue
# Recording ended; all remaining bytes have been delivered.
break
return Response(
generate(),
mimetype="audio/mpeg",
headers={
"Cache-Control": "no-cache, no-store, must-revalidate",
"Accept-Ranges": "none",
},
)
return send_from_directory(
RECORDINGS_DIR,
filename,
as_attachment=False,
)
# ---------------------------------------------------------------------------
# Start monitor
# ---------------------------------------------------------------------------
monitor_thread = threading.Thread(
target=recorder_loop,
daemon=True,
)
monitor_thread.start()
if __name__ == "__main__":
app.run(
host="0.0.0.0",
port=WEB_PORT,
)