From d9554c590cf5fafad7e137e39b50571325149acd Mon Sep 17 00:00:00 2001 From: schakal00783 Date: Sun, 2 Aug 2026 23:40:29 +0200 Subject: [PATCH] fix(audio): Wiedergabe-MP3 als CBR ohne Xing-Tabelle -- Wortmarkierung war 28 s voraus MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Die Markierung im Transkript stand auf einem Wort, gesprochen wurde eins vier Zeilen weiter oben (gemessen an #85: 24 s Vorlauf). Nicht die Ursache -- jeweils per Gegenprobe ausgeschlossen: die Wortzeiten (Ausschnitte bei 600/1240/3400 s einzeln transkribiert, enthalten exakt die Wörter der DB), MP3- vs WAV-Zeitachse (identisch), ffmpeg-Seek (trifft), ontimeupdate-Logik (korrekt). Ursache war der Sprung im Browser: in einer VBR-Datei gibt es keine feste Byte-pro-Sekunde-Regel, also nutzt er die Xing-Tabelle -- 100 Stützstellen, bei 63 Minuten ein 38-s-Raster. Er landet auf der Stützstelle davor, meldet über currentTime aber die angefragte Zeit. Nachgerechnet: Ziel 20:40 spielt ab 20:11,5 (-28,5 s). Fix: -b:a 64k -ar 48000 -ac 1 -write_xing 0 statt -qscale:a 3. Byte-Position proportional zur Zeit. Die Tabelle muss auch bei CBR weg, sie rastert auf 1/256 der Dateigröße (~15 s pro Stunde, Restfehler bis -6,4 s gemessen). Ohne sie rechnet der Decoder linear: Restfehler 0,02 s an neun Stichpunkten. lab/recode-cbr.sh schreibt den Altbestand einmalig um (Quelle WAV, sonst die MP3 selbst), lab/tocerr.py ist das Messwerkzeug dahinter. Co-Authored-By: Claude Opus 5 (1M context) --- app/lab/recode-cbr.sh | 67 ++++++++++++++++++++++++++++++ app/lab/tocerr.py | 79 ++++++++++++++++++++++++++++++++++++ app/recorder.js | 21 ++++++++-- docs/03-aufnahme-pipeline.md | 35 ++++++++++++++++ docs/08-changelog.md | 40 ++++++++++++++++++ 5 files changed, 239 insertions(+), 3 deletions(-) create mode 100644 app/lab/recode-cbr.sh create mode 100644 app/lab/tocerr.py diff --git a/app/lab/recode-cbr.sh b/app/lab/recode-cbr.sh new file mode 100644 index 0000000..5b30e64 --- /dev/null +++ b/app/lab/recode-cbr.sh @@ -0,0 +1,67 @@ +#!/bin/bash +# Altbestand von VBR- auf CBR-MP3 umstellen. +# +# Warum: der Browser springt in einer VBR-Datei über die Xing-Tabelle (100 +# Stützstellen — bei einer Stunde Audio ein 36-Sekunden-Raster) und landet +# systematisch zu früh, meldet aber die angefragte Zeit. Genau daher lief die +# Wortmarkierung im Transkript dem Ton voraus. Bei CBR ist die Byte-Position +# proportional zur Zeit, jeder Sprung sitzt. Die Xing-Tabelle muss dabei WEG +# (-write_xing 0): sie speichert Byte-Positionen nur in 1/256 der Dateigröße, +# das sind bei einer Stunde Audio ~15 s Rundung. Ohne sie rechnet der Decoder +# linear — bei CBR nachgemessen auf 0,02 s genau. +# +# Quelle ist die WAV, wo es sie noch gibt (gleiche Filterkette wie beim +# Aufnehmen). Für die alten Aufnahmen ohne WAV bleibt nur die MP3 selbst — +# dann OHNE Filter, die stecken dort ja schon drin. +# +# `-f mp3` ist Pflicht: die Zieldatei heisst *.mp3.tmp, und aus dieser Endung +# leitet ffmpeg kein Format ab ("Unable to choose an output format"). +# +# Sicherheit: erst in eine .tmp schreiben, Dauer gegen das Original prüfen +# (±2 s), dann erst ersetzen. Ein Abbruch lässt die alte Datei unangetastet. +set -u +cd /opt/callrec/recordings || exit 1 +MAXTEMP=88000 # °C*1000 — darüber wird pausiert (kein aktiver Lüfter) +ok=0; skip=0; fail=0 + +dur() { ffprobe -v error -show_entries format=duration -of default=nw=1:nk=1 "$1" 2>/dev/null; } + +for m in rec_*.mp3; do + [ -f "$m" ] || continue + # schon fertig = 64 kbit konstant UND ohne Xing-Tabelle (die ist selbst bei + # CBR nur auf 1/256 der Dateigröße genau, bei einer Stunde also ~15 s). + if ffprobe -v error -show_entries stream=bit_rate -of default=nw=1:nk=1 "$m" 2>/dev/null | grep -q '^64000$' && ! head -c 2048 "$m" | grep -qa Xing; then + skip=$((skip+1)); continue + fi + # Thermik: das Board hat keinen Lüfter, also vor jeder Datei kurz nachsehen. + while :; do + t=$(cat /sys/class/thermal/thermal_zone0/temp 2>/dev/null || echo 0) + [ "$t" -lt "$MAXTEMP" ] && break + echo " … $((t/1000)) °C, warte 30 s"; sleep 30 + done + + w="${m%.mp3}.wav" + d0=$(dur "$m") + if [ -f "$w" ]; then + nice -n 10 ffmpeg -v error -y -i "$w" \ + -af 'highpass=f=80,loudnorm=I=-16:TP=-1.5:LRA=11' \ + -codec:a libmp3lame -b:a 64k -ar 48000 -ac 1 -write_xing 0 -f mp3 "$m.tmp" 2)?"1":"0"}') + if [ "$diff" = "1" ]; then + echo "FEHLER $m: Dauer weicht ab ($d0 -> $d1)"; rm -f "$m.tmp"; fail=$((fail+1)); continue + fi + chown callrec:callrec "$m.tmp" + mv -f "$m.tmp" "$m" + ok=$((ok+1)) + echo "ok $m ($d0 s, Quelle: $([ -f "$w" ] && echo wav || echo mp3))" +done +echo "fertig: $ok neu kodiert, $skip schon CBR, $fail fehlgeschlagen" diff --git a/app/lab/tocerr.py b/app/lab/tocerr.py new file mode 100644 index 0000000..071bb0b --- /dev/null +++ b/app/lab/tocerr.py @@ -0,0 +1,79 @@ +#!/usr/bin/env python3 +"""Wie weit daneben landet ein Browser-Seek in dieser MP3? + +Ein Browser springt in einer VBR-Datei über die Xing-TOC: 100 Stützstellen, +linear interpoliert -> Byte-Position. Er meldet danach die ANGEFRAGTE Zeit, +dekodiert aber ab dem gefundenen Byte. Dieses Skript rechnet beides nach: +Byte-Position wie der Browser, echte Zeit per Frame-Zählung. +""" +import sys, struct + +path = sys.argv[1] +targets = [float(x) for x in sys.argv[2].split(',')] +data = open(path, 'rb').read() + +BITRATES = [0, 32, 40, 48, 56, 64, 80, 96, 112, 128, 160, 192, 224, 256, 320, 0] # MPEG1 Layer3 +SRATES = [44100, 48000, 32000] + +def frame_len(hdr): + if hdr[0] != 0xFF or (hdr[1] & 0xE0) != 0xE0: + return None, None, None + ver = (hdr[1] >> 3) & 3 # 3 = MPEG1 + layer = (hdr[1] >> 1) & 3 # 1 = Layer III + if ver != 3 or layer != 1: + return None, None, None + br = BITRATES[(hdr[2] >> 4) & 0xF] * 1000 + sr = SRATES[(hdr[2] >> 2) & 3] + pad = (hdr[2] >> 1) & 1 + if br == 0 or sr == 0: + return None, None, None + return int(144 * br / sr) + pad, sr, 1152 + +# --- alle Frames einmal durchzählen: Byte -> exakte Zeit ------------------- +pos, table = 0, [] # (byte_offset, start_time) +t = 0.0 +while pos < len(data) - 4: + ln, sr, spf = frame_len(data[pos:pos + 4]) + if not ln: + pos += 1 + continue + table.append((pos, t)) + t += spf / sr + pos += ln +print(f'{len(table)} frames, echte Dauer {t:.1f} s, Dateigroesse {len(data)} bytes') + +# --- Xing-TOC --------------------------------------------------------------- +i = data.find(b'Xing') +if i < 0: + i = data.find(b'Info') +flags = struct.unpack('>I', data[i + 4:i + 8])[0] +p = i + 8 +frames = struct.unpack('>I', data[p:p + 4])[0] if flags & 1 else None +if flags & 1: p += 4 +bytes_total = struct.unpack('>I', data[p:p + 4])[0] if flags & 2 else len(data) +if flags & 2: p += 4 +toc = list(data[p:p + 100]) if flags & 4 else None +print(f'Xing: frames={frames}, bytes={bytes_total}, TOC={"ja" if toc else "nein"}') +if not toc: + sys.exit('kein TOC -> Browser extrapoliert rein linear') + +def real_time_at(byte): + lo, best = 0, table[0][1] + for off, tt in table: + if off <= byte: best = tt + else: break + return best + +print(f'\n{"Ziel":>8} {"Byte(TOC)":>12} {"echte Zeit":>12} {"Fehler":>10}') +for target in targets: + pct = target / t * 100.0 + a = int(pct) + fa = toc[a] + fb = toc[a + 1] if a < 99 else 256.0 + frac = pct - a + byte = int((fa + (fb - fa) * frac) / 256.0 * bytes_total) + real = real_time_at(byte) + lin_byte = int(target / t * len(data)) + lin_real = real_time_at(lin_byte) + print(f'{target:8.1f} {byte:12d} {real:12.1f} {real - target:+10.1f} s' + f' | linear: {lin_real:8.1f} {lin_real - target:+8.1f} s') diff --git a/app/recorder.js b/app/recorder.js index 15162b9..00b0423 100644 --- a/app/recorder.js +++ b/app/recorder.js @@ -334,14 +334,29 @@ export async function stopAndSave() { // lame's psymodel asserts (`calc_energy: el >= 0`) on pure digital silence — // real since the gated mic path: nobody spoke = all-zero samples. Fall back // to a filterless encode instead of failing the whole stop. + // KONSTANTE BITRATE, kein VBR (früher `-qscale:a 3`). Grund ist die + // Wort-Markierung im Transkript: der Browser springt in einer VBR-MP3 über + // die Xing-Tabelle, und die hat nur 100 Stützstellen — bei einer 63-Minuten- + // Aufnahme also ein Raster von 38 s. Er landet auf der Stützstelle davor, + // meldet aber die angefragte Zeit zurück. Gemessen an #85: Sprung auf 20:40 + // spielte in Wahrheit ab 20:11,5 (−28,5 s), die Markierung lief entsprechend + // dem Ton voraus. Bei CBR ist die Byte-Position proportional zur Zeit, jeder + // Sprung trifft damit auf Frame-Genauigkeit (~26 ms). 64 kbit ≈ die bisherige + // Durchschnittsrate (68 kbit), die Dateigröße bleibt also praktisch gleich. + // `-ar 48000` fest, damit der Fallback-Zweig ohne loudnorm nicht bei 16 kHz + // landet (= MPEG-2.5, andere Framelänge). + // `-write_xing 0` muss dazu: die Xing-Tabelle speichert Byte-Positionen nur + // in 1/256 der Dateigröße, bei einer Stunde Audio also ~15 s grob — ein + // Browser, der sie benutzt, wäre trotz CBR wieder daneben. Ohne sie rechnet + // er linear, was bei CBR exakt ist (nachgemessen: 0,02 s = ein halber Frame). + const MP3_ARGS = ['-codec:a', 'libmp3lame', '-b:a', '64k', '-ar', '48000', '-ac', '1', '-write_xing', '0']; try { await pexec('ffmpeg', ['-y', '-i', c.wavPath, '-af', 'highpass=f=80,loudnorm=I=-16:TP=-1.5:LRA=11', - '-codec:a', 'libmp3lame', '-qscale:a', '3', mp3Path]); + ...MP3_ARGS, mp3Path]); } catch (e) { console.warn('mp3 encode with filters failed (silence?), retrying plain:', String(e.message).slice(0, 200)); - await pexec('ffmpeg', ['-y', '-i', c.wavPath, - '-codec:a', 'libmp3lame', '-qscale:a', '3', mp3Path]); + await pexec('ffmpeg', ['-y', '-i', c.wavPath, ...MP3_ARGS, mp3Path]); } // Move the wav into REC_DIR (rootfs — survives a reboot); it is NOT left in diff --git a/docs/03-aufnahme-pipeline.md b/docs/03-aufnahme-pipeline.md index 67b02a5..87375bb 100644 --- a/docs/03-aufnahme-pipeline.md +++ b/docs/03-aufnahme-pipeline.md @@ -75,6 +75,41 @@ stateDiagram-v2 - **GUI-Anzeige „⏱ Analysezeit“:** `analysis_started_at`/`analysis_ms` in der DB; beim Polling auf `analysis_status` achten, NICHT auf `status` (sonst wirkt die Summary „leer“). +## Wiedergabe-MP3: konstante Bitrate, keine Xing-Tabelle + +Die WAV ist die Arbeitskopie (Whisper, Diarization), die MP3 ist nur fürs Abspielen im Browser — +`highpass=f=80` gegen Rumpeln, `loudnorm` für gleichmäßige Lautstärke. Beim Codec sind aber **zwei +Einstellungen nicht verhandelbar**, sonst läuft die Wortmarkierung im Transkript aus dem Takt: + +``` +-codec:a libmp3lame -b:a 64k -ar 48000 -ac 1 -write_xing 0 +``` + +**Kein VBR** (früher `-qscale:a 3`) und **keine Xing-Tabelle**. Der Grund liegt im Sprung, nicht in der +Wiedergabe: + +```mermaid +flowchart LR + A["Klick auf 20:40
audio.currentTime = 1240"] --> B{Wie findet der
Browser das Byte?} + B -->|"VBR: Xing-Tabelle
100 Stützstellen ≈ 38 s Raster"| C["landet bei 20:11,5
meldet aber 20:40"] + B -->|"CBR ohne Tabelle:
Byte = Zeit × Bitrate"| D["landet bei 20:40,0
Fehler 0,02 s"] + C --> E["Ton hinkt 28 s nach,
Markierung läuft voraus"] + D --> F["synchron"] +``` + +Nachgemessen an #85 (63 min): ein Sprung auf 20:40 spielte in Wahrheit ab **20:11,5 (−28,5 s)** — genau +das Bild, das der Nutzer meldete („markiert ist ein Wort vier Zeilen unter dem, was ich höre"). Die +Wortzeitstempel selbst waren dabei korrekt; per Gegenprobe transkribierte Ausschnitte an t = 600/1240/3400 s +enthielten exakt die Wörter, die die Datenbank dort führt. + +Die Tabelle muss auch bei CBR weg: sie speichert Byte-Positionen in 1/256 der Dateigröße, was bei einer +Stunde Audio rund 15 s Rundung sind (gemessen: bis −6,4 s Restfehler). Ohne sie rechnet der Decoder +linear — bei konstanter Bitrate exakt. + +64 kbit CBR entspricht praktisch der bisherigen Durchschnittsrate (68 kbit); #85 schrumpfte von 32,3 auf +30,3 MB. Der Altbestand wurde einmalig mit `lab/recode-cbr.sh` umgeschrieben (Quelle: die WAV, wo noch +vorhanden — sonst die MP3 selbst, dann ohne erneute Filter). + ## API-Endpunkte (Auszug) | Endpunkt | Zweck | diff --git a/docs/08-changelog.md b/docs/08-changelog.md index 2dc9a70..c7328b7 100644 --- a/docs/08-changelog.md +++ b/docs/08-changelog.md @@ -3,6 +3,46 @@ Chronik der Entwicklungsschritte. **Regel: jede zukünftige Änderung bekommt hier einen Eintrag** (Datum, Was, Warum, betroffene Dateien/Backups). +## 2026-08-02 — Wortmarkierung lief dem Ton voraus: VBR-MP3 war schuld + +**Symptom:** im Transkript war ein Wort markiert, gesprochen wurde aber eins rund vier Zeilen weiter oben. +Gemessen an #85: markiert „auf" (20:40,3), zu hören „OneDrive" (20:16,3) — **24 s Vorlauf**. + +**Was es NICHT war** (der Reihe nach ausgeschlossen, jeweils mit Gegenprobe): + +| Verdacht | Prüfung | Ergebnis | +|---|---|---| +| Wortzeiten driften (VAD-Trimming im Batch-Modus) | Ausschnitte bei t = 600/1240/3400 s aus der WAV geschnitten und einzeln transkribiert | dort stehen exakt die Wörter, die die DB führt — **Zeitstempel korrekt** | +| MP3 und WAV haben verschiedene Zeitachsen | MP3 komplett dekodiert, dieselben Stellen geprüft | identisch | +| Datei kaputt / ffmpeg-Seek daneben | `ffmpeg -ss` an denselben Stellen | trifft genau | +| Anzeige-Logik (`ontimeupdate`) | Code gelesen: letztes Wort mit `t_start <= t` | korrekt | + +**Was es war:** der Sprung im Browser. Die MP3 war VBR, dort gibt es keine feste Byte-pro-Sekunde-Regel, +also nutzt der Browser die **Xing-Tabelle — 100 Stützstellen, bei 63 Minuten ein Raster von 38 s**. Er +landet auf der Stützstelle davor, meldet über `currentTime` aber die angefragte Zeit zurück. Nachgerechnet +(`lab/tocerr.py`, Frames gezählt + Tabelle geparst): Ziel 20:40 → tatsächliche Abspielposition **20:11,5, +also −28,5 s**. Genau die beobachtete Größenordnung, und der Versatz bleibt bis zum nächsten Sprung stehen. + +**Fix:** MP3 künftig **CBR mit 64 kbit und ohne Xing-Tabelle** +(`-b:a 64k -ar 48000 -ac 1 -write_xing 0` statt `-qscale:a 3`). Dann ist die Byte-Position proportional +zur Zeit und jeder Sprung sitzt. Die Tabelle muss auch bei CBR weg — sie rastert Byte-Positionen auf +1/256 der Dateigröße, was bei einer Stunde ~15 s sind (Restfehler gemessen: bis −6,4 s). Ohne sie rechnet +der Decoder linear: **Restfehler 0,02 s = ein halber Frame**, an neun Stichpunkten über die ganze Datei +geprüft. + +Dateigröße bleibt praktisch gleich (#85: 32,3 → 30,3 MB), 64 kbit CBR ≈ die bisherige Durchschnittsrate +von 68 kbit. + +**Altbestand:** `lab/recode-cbr.sh` schreibt alle vorhandenen Aufnahmen einmalig um — Quelle ist die WAV +mit derselben Filterkette wie beim Aufnehmen, für die alten Aufnahmen ohne WAV die MP3 selbst (dann ohne +erneutes `loudnorm`, das steckt dort schon drin). Schreibt erst in eine `.tmp`, vergleicht die Dauer +(±2 s) und ersetzt erst dann; pausiert oberhalb von 88 °C, weil das Board keinen Lüfter hat. +Zwei Stolpersteine dabei: `-f mp3` ist Pflicht (aus der Endung `.mp3.tmp` leitet ffmpeg kein Format ab), +und eine 0-Byte-Altdatei (`rec_1784247236781.mp3`) bleibt als Fehler liegen statt den Lauf abzubrechen. + +Dateien: `app/recorder.js` (Board-Backup `.bak-cbr`), `app/lab/recode-cbr.sh` (neu), +`app/lab/tocerr.py` (neu, Messwerkzeug), Kapitel [03 – Aufnahme-Pipeline](03-aufnahme-pipeline.md). + ## 2026-08-02 (nachgereicht) — Grüne Nadel, Logo führt zurück zu den Aufnahmen **Nadel grün statt rot:** der Anheften-Knopf in der Aufnahmenliste zeigte bei einer angehefteten Aufnahme