fix(audio): Wiedergabe-MP3 als CBR ohne Xing-Tabelle -- Wortmarkierung war 28 s voraus
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) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
3496b4a2df
commit
d9554c590c
@@ -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" </dev/null
|
||||
else
|
||||
nice -n 10 ffmpeg -v error -y -i "$m" \
|
||||
-codec:a libmp3lame -b:a 64k -ar 48000 -ac 1 -write_xing 0 -f mp3 "$m.tmp" </dev/null
|
||||
fi
|
||||
rc=$?
|
||||
d1=$(dur "$m.tmp")
|
||||
if [ $rc -ne 0 ] || [ -z "$d1" ]; then
|
||||
echo "FEHLER $m (ffmpeg rc=$rc)"; rm -f "$m.tmp"; fail=$((fail+1)); continue
|
||||
fi
|
||||
diff=$(awk -v a="$d0" -v b="$d1" 'BEGIN{d=a-b; if(d<0)d=-d; print (d>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"
|
||||
@@ -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')
|
||||
Reference in New Issue
Block a user