NACHGEMESSEN AN DEN SECHS, DIE HEUTE NACHT LIVE ENTSTANDEN SIND --
vier tragen das Loch am Anfang, weil sie gebaut wurden, bevor es
bekannt war:
387,6 s Zeitachse fuer wenige Sekunden Ton
164,2 s
134,1 s
63,8 s
9,7 s (in Ordnung)
3,3 s (in Ordnung)
Sie gehoeren dem Dienstbenutzer; von aussen sind sie nicht
wegzuraeumen. Und mit „gibt es schon, dann weiter" waeren sie fuer
immer so geblieben.
EIN SCHALTER „alles neu ab Fassung 2" WAERE DIE BEQUEME LOESUNG und
die falsche: eine Zahl, die jemand hochzaehlen muss, wird beim
naechsten Mal vergessen. Gefragt wird deshalb die DATEI SELBST --
nach dem, was schiefgehen kann: Passt ihre Zeitachse zu der
Tonmenge, die sie traegt? AAC packt 1024 Abtastwerte in einen
Rahmen; Rahmenzahl mal 1024 durch die Abtastrate ist die echte
Laenge. Mehr als anderthalb Sekunden darueber heisst: Loch.
Was nicht passt, wird beim naechsten Nachruestlauf weggeraeumt und
neu gebaut -- einmal, denn danach passt es.
ZWEI FEHLER IN DIESER FUNKTION, BEIDE VON DER GEGENPROBE GEFUNDEN
Und beide waeren still geblieben:
1. ffprobe GIBT DIE WERTE IN SEINER REIHENFOLGE AUS, nicht in
meiner: erst `sample_rate`, dann `nb_frames`. Ich hatte
`[rahmen, rate, dauer]` destrukturiert und damit 48000 Rahmen
bei 95 Hz gerechnet -- eine „echte Laenge" von einer halben
Million Sekunden. Die Funktion haette JEDE Datei fuer tauglich
erklaert, immer. Gelesen wird jetzt mit Namen.
2. DER DATEINAME FEHLTE IM AUFRUF. ffprobe antwortete „You have to
specify one input file", der `catch` machte daraus ein
freundliches „taugt" -- und wieder sagte die Funktion zu allem
ja.
Das ist zweimal dieselbe Sorte: gruen und wertlos. Gefunden hat es
nicht das Lesen, sondern die Frage „kann sie ueberhaupt NEIN
sagen?". Genau dafuer gibt es die Gegenprobe.
WIE MAN EINE DATEI MIT LOCH NACHSTELLT -- UND WIE NICHT
`-itsoffset 40` war der naheliegende Weg und ergab 2,5 s: Der
MP4-Baukasten rechnet einen reinen Anfangsversatz wieder heraus.
Dasselbe mit `-copyts`, `-output_ts_offset` und `-muxdelay`. Was
bleibt, ist eine Zeitachse, die laenger ist als ihr Ton -- und das
stellt eine stumme Bildspur von 40 Sekunden her. Der Weg dorthin
ist ein anderer als bei `MediaRecorder`; die ZAHLEN, die die
Funktion liest, sind dieselben.
GEPRUEFT -- pruef-chat-anhaenge 164 -> 168 ok
eine Fassung mit zu langer Zeitachse nachgestellt (40,0 s)
und sie wird als untauglich erkannt
der Nachruestlauf ersetzt sie (40,0 s → 2,5 s)
und die neue gilt als tauglich — er wandelt sie nicht ewig weiter
Die letzte Zeile ist die zweite Gegenprobe: Ein Lauf, der bei jedem
Durchgang alles neu wandelt, waere eine Warnung, die immer kommt.
Co-Authored-By: Claude Opus 5 <[email protected]>
320 lines
14 KiB
JavaScript
320 lines
14 KiB
JavaScript
/* =====================================================================
|
||
helfer-ffmpeg.mjs — eine Sprachnachricht, die JEDES Gerät abspielt
|
||
|
||
=====================================================================
|
||
WARUM ES DIESE DATEI GIBT (03.10.2026)
|
||
|
||
Miss im Support, Meldung #12 — fünf Runden lang: „Bei mir lässt
|
||
sich die Nachricht nicht abspielen." Zuletzt: „Jetzt lädt es die
|
||
ganze Zeit." Bei allen anderen ging es.
|
||
|
||
GEMESSEN, BEVOR GEBAUT WURDE:
|
||
· Alle sechs Sprachnachrichten im Haus sind `audio/webm`.
|
||
· Miss' Gerät: iPhone, iOS 18.7, WebKit.
|
||
· Seit der Umstellung am 02.10. (neue Aufnahmen bevorzugen MP4)
|
||
wurde KEINE EINZIGE neue aufgenommen — ihr Problem betrifft
|
||
also ausschließlich die sechs alten Dateien.
|
||
|
||
WAS ICH NICHT MESSEN KONNTE, und das gehört hierher: Ob WebKit
|
||
WebM/Opus abspielen kann, lässt sich auf diesem Rechner nicht
|
||
nachsehen — Playwrights WebKit startet hier nicht (`libegl.dll`
|
||
fehlt). Die Vermutung „iPhones können kein WebM" ist deshalb
|
||
begründet, aber nicht von mir gemessen.
|
||
|
||
GENAU DESHALB IST DIE LÖSUNG HIER EINE, DIE DIE FRAGE NICHT
|
||
STELLT. Statt zu raten, welches Gerät welchen Behälter kann, legt
|
||
der Server neben jede Sprachnachricht eine zweite Fassung in dem
|
||
Format, das seit zwanzig Jahren überall läuft: AAC in MP4. Wer sie
|
||
hat, bekommt sie. Kein Gerätename im Code, keine Liste, die
|
||
altert.
|
||
|
||
---------------------------------------------------------------------
|
||
WARUM EIN FREMDES PROGRAMM UND NICHT NODE
|
||
|
||
Node kann kein Audio umwandeln, und ein Paket dafür wäre die
|
||
einzige nennenswerte Abhängigkeit in einem Haus, das ohne Baulauf
|
||
auskommt. ffmpeg ist auf dem Server installiert (7.1.5, Debian) —
|
||
abgesprochen, nicht nebenbei eingerichtet.
|
||
|
||
DREI AUSGÄNGE, wie überall: umgewandelt / nicht umwandelbar /
|
||
ffmpeg nicht auffindbar. Der dritte ist KEIN Fehler: Fehlt ffmpeg,
|
||
wird die Sprachnachricht wie bisher im Originalformat
|
||
ausgeliefert. Niemand verliert etwas, was er vorher hatte — es
|
||
kommt nur niemand dazu.
|
||
===================================================================== */
|
||
|
||
import { existsSync, renameSync, unlinkSync } from "node:fs";
|
||
import { execFile, execFileSync } from "node:child_process";
|
||
import { delimiter, join, extname, basename, dirname } from "node:path";
|
||
|
||
/* Die Endung der Zweitfassung. AAC in MP4 — das Format, bei dem sich
|
||
iPhone, Android, Windows, Mac und jeder Browser einig sind. */
|
||
export const ZWEITFASSUNG_ENDUNG = ".m4a";
|
||
export const ZWEITFASSUNG_TYP = "audio/mp4";
|
||
|
||
/* 96 kbit/s für Sprache. Opus holt bei 32 kbit/s dasselbe heraus,
|
||
AAC braucht mehr — und eine Sprachnachricht von einer Minute wiegt
|
||
damit 700 KB statt 240. Das ist der Preis dafür, dass sie überall
|
||
läuft, und er ist es wert. Niedriger wäre hörbar dumpf; höher
|
||
brächte bei einem Handymikrofon nichts. */
|
||
const RATE = "96k";
|
||
|
||
/* Nach dieser Zeit wird abgebrochen. Eine Sprachnachricht von einer
|
||
Minute braucht auf dem Server unter einer Sekunde; wer hier in die
|
||
Frist läuft, hat ein anderes Problem, und ein hängender Prozess
|
||
wäre das schlimmere. */
|
||
const FRIST_MS = 60_000;
|
||
|
||
const BEKANNTE_ORTE = [
|
||
"/usr/bin/ffmpeg",
|
||
"/usr/local/bin/ffmpeg",
|
||
"C:/Program Files/ffmpeg/bin/ffmpeg.exe",
|
||
];
|
||
|
||
let gemerkt = null;
|
||
let schonGesagt = false;
|
||
|
||
/* IM PATH SUCHEN, OHNE `where` ODER `which` ZU BRAUCHEN — dieselbe
|
||
Überlegung wie in helfer-openssl.mjs: Beide sind selbst Programme
|
||
und können genauso fehlen. */
|
||
function imPfad() {
|
||
const pfade = (process.env.PATH || "").split(delimiter).filter(Boolean);
|
||
const namen = process.platform === "win32" ? ["ffmpeg.exe", "ffmpeg"] : ["ffmpeg"];
|
||
for (const p of pfade) {
|
||
for (const n of namen) {
|
||
const voll = join(p, n);
|
||
if (existsSync(voll)) return voll;
|
||
}
|
||
}
|
||
return null;
|
||
}
|
||
|
||
/** Wo liegt ffmpeg — oder `null`.
|
||
*
|
||
* GIBT `null` ZURUECK UND WIRFT NICHT. Anders als bei openssl ist
|
||
* das Fehlen hier kein Abbruchgrund: Die Sprachnachricht
|
||
* funktioniert auch ohne Zweitfassung, nur eben nicht überall. Ein
|
||
* geworfener Fehler würde das Hochladen scheitern lassen — und
|
||
* damit aus einer fehlenden Verbesserung einen Ausfall machen. */
|
||
export function ffmpegPfad() {
|
||
if (gemerkt !== null) return gemerkt || null;
|
||
gemerkt = imPfad() || BEKANNTE_ORTE.find((o) => existsSync(o)) || "";
|
||
if (!gemerkt && !schonGesagt) {
|
||
schonGesagt = true;
|
||
/* EINMAL SAGEN, NICHT BEI JEDER NACHRICHT. Eine Zeile, die bei
|
||
jedem Hochladen kommt, liest nach dem dritten Mal niemand
|
||
mehr — und deckt dabei die echten zu. */
|
||
console.log("[ton] ffmpeg nicht gefunden – Sprachnachrichten bekommen keine "
|
||
+ "zweite Fassung. Auf Debian: sudo apt-get install -y ffmpeg");
|
||
}
|
||
return gemerkt || null;
|
||
}
|
||
|
||
/** Wo liegt ffprobe — oder `null`.
|
||
*
|
||
* ES LIEGT IMMER NEBEN ffmpeg (dasselbe Paket, derselbe Ordner),
|
||
* deshalb wird es dort gesucht und nicht noch einmal im PATH.
|
||
* Gebraucht wird es nur von den Prüfungen: Nur ffprobe kann sagen,
|
||
* ob in der erzeugten Datei wirklich AAC steckt und ob sie so lang
|
||
* ist wie das Original. Ohne diese Frage wäre „die Datei ist da"
|
||
* ein grüner Haken über einer Datei, die niemand abspielen kann. */
|
||
export function ffprobePfad() {
|
||
const ff = ffmpegPfad();
|
||
if (!ff) return null;
|
||
const nachbar = ff.replace(/ffmpeg(\.exe)?$/i, (m) => m.replace(/ffmpeg/i, "ffprobe"));
|
||
return existsSync(nachbar) ? nachbar : null;
|
||
}
|
||
|
||
/** Der Pfad, unter dem die Zweitfassung einer Datei liegt (oder läge). */
|
||
export function zweitfassungPfad(pfad) {
|
||
return join(dirname(pfad), basename(pfad, extname(pfad)) + ZWEITFASSUNG_ENDUNG);
|
||
}
|
||
|
||
/** Braucht diese Datei überhaupt eine Zweitfassung?
|
||
*
|
||
* NEIN, WENN SIE SCHON DAS ZIELFORMAT IST. Eine m4a nach m4a zu
|
||
* wandeln kostet Rechenzeit und Qualität für genau nichts — und
|
||
* seit dem 02.10. nehmen die meisten Geräte ohnehin MP4 auf. */
|
||
export function brauchtZweitfassung(pfad) {
|
||
return extname(pfad).toLowerCase() !== ZWEITFASSUNG_ENDUNG;
|
||
}
|
||
|
||
/** Taugt eine vorhandene Zweitfassung noch?
|
||
*
|
||
* ==== WARUM DIESE FRAGE UEBERHAUPT GESTELLT WIRD (03.10.2026) ====
|
||
*
|
||
* Die ersten sechs Zweitfassungen sind entstanden, BEVOR das Loch
|
||
* am Anfang bekannt war (siehe `asetpts` weiter unten). Vier davon
|
||
* tragen es: 387 s, 164 s, 134 s und 63,8 s Zeitachse fuer jeweils
|
||
* wenige Sekunden Ton. Sie liegen auf dem Server und gehoeren dem
|
||
* Dienstbenutzer — von aussen sind sie nicht wegzuraeumen.
|
||
*
|
||
* EIN SCHALTER „alles neu ab Fassung 2" WAERE DIE BEQUEME LOESUNG
|
||
* und die falsche: Eine Zahl, die jemand hochzaehlen muss, wird
|
||
* beim naechsten Mal vergessen. Gefragt wird deshalb die DATEI
|
||
* SELBST — und zwar nach dem, was schiefgehen kann: Passt ihre
|
||
* Zeitachse zu der Tonmenge, die sie traegt?
|
||
*
|
||
* GERECHNET WIRD AUS DEN TONRAHMEN. AAC packt 1024 Abtastwerte in
|
||
* einen Rahmen; Rahmenzahl mal 1024 geteilt durch die Abtastrate
|
||
* ist die echte Laenge. Weicht die angegebene Dauer um mehr als
|
||
* anderthalb Sekunden nach OBEN ab, steckt ein Loch darin.
|
||
*
|
||
* DRITTER AUSGANG: Sagt ffprobe nichts (fehlt, oder die Datei gibt
|
||
* die Rahmenzahl nicht her), gilt die Fassung als tauglich. Eine
|
||
* Datei wegzuwerfen, weil man sie nicht beurteilen kann, waere die
|
||
* schlechtere Antwort — und ein Lauf, der bei jedem Durchgang alles
|
||
* neu wandelt, waere eine Warnung, die immer kommt. */
|
||
export function zweitfassungTaugt(ziel) {
|
||
const probe = ffprobePfad();
|
||
if (!probe) return true;
|
||
try {
|
||
/* ==== MIT NAMEN LESEN, NICHT NACH REIHENFOLGE =================
|
||
|
||
Hier stand `-of default=…:nokey=1` und danach
|
||
`const [rahmen, rate, dauer] = …`. ffprobe gibt die Werte aber
|
||
in SEINER Reihenfolge aus, nicht in meiner: erst
|
||
`sample_rate`, dann `nb_frames`. Die Rechnung lief damit mit
|
||
48000 Rahmen und 95 Hz — und kam auf eine „echte Laenge" von
|
||
einer halben Million Sekunden. Die Funktion haette JEDE Datei
|
||
fuer tauglich erklaert, immer, und nie etwas gemeldet.
|
||
|
||
Das ist genau die Sorte, vor der die Hausregel warnt: Sie war
|
||
gruen und wertlos. Aufgefallen ist es nicht beim Lesen,
|
||
sondern beim Bauen der Gegenprobe — die Frage „kann sie
|
||
ueberhaupt NEIN sagen?" hat sie gestellt. */
|
||
const roh = execFileSync(probe, ["-v", "error", "-select_streams", "a:0",
|
||
"-show_entries", "stream=nb_frames,sample_rate", "-show_entries", "format=duration",
|
||
"-of", "default=noprint_wrappers=1", ziel], { encoding: "utf8", timeout: 20_000 });
|
||
const wert = (name) => {
|
||
const t = roh.match(new RegExp(`^${name}=(.+)$`, "m"));
|
||
return t ? Number(t[1]) : NaN;
|
||
};
|
||
const rahmen = wert("nb_frames");
|
||
const rate = wert("sample_rate");
|
||
const dauer = wert("duration");
|
||
/* DRITTER AUSGANG: Fehlt eine der drei Zahlen, ist die Frage
|
||
nicht zu beantworten -- und „nicht beurteilbar" heisst hier
|
||
„in Ordnung lassen". Eine Datei wegzuwerfen, weil man sie
|
||
nicht messen kann, waere die schlechtere Antwort. */
|
||
if (!Number.isFinite(rahmen) || !rahmen) return true;
|
||
if (!Number.isFinite(rate) || !rate) return true;
|
||
if (!Number.isFinite(dauer)) return true;
|
||
/* AAC packt 1024 Abtastwerte in einen Rahmen. */
|
||
const echt = (rahmen * 1024) / rate;
|
||
return dauer <= echt + 1.5;
|
||
} catch { return true; }
|
||
}
|
||
|
||
/** Eine Tondatei in AAC/MP4 danebenlegen.
|
||
*
|
||
* Gibt ein Versprechen auf `true` (liegt jetzt da), `false` (ging
|
||
* nicht) oder `null` (ffmpeg fehlt — konnte nicht nachsehen).
|
||
*
|
||
* SIE WIRFT NIE. Der Aufrufer hängt an einem Hochladen, und ein
|
||
* Fehler beim Umwandeln darf die Nachricht nicht verhindern.
|
||
*
|
||
* `-movflags +faststart` IST NICHT KOSMETIK: Ohne es steht die
|
||
* Inhaltsübersicht einer MP4 am ENDE der Datei. Ein Abspielgerät
|
||
* muss dann erst bis ans Ende lesen, bevor es anfangen kann — bei
|
||
* einer Teilanfrage über eine mobile Verbindung ist das genau das
|
||
* „lädt die ganze Zeit", über das sich Miss beklagt hat. */
|
||
export function zweitfassungBauen(pfad) {
|
||
return new Promise((fertig) => {
|
||
const ff = ffmpegPfad();
|
||
if (!ff) return fertig(null);
|
||
const ziel = zweitfassungPfad(pfad);
|
||
/* SCHON DA HEISST FERTIG. Der Nachrüstlauf geht mehrmals über
|
||
denselben Ordner; ohne diese Zeile würde er jedes Mal alles
|
||
neu wandeln. */
|
||
if (existsSync(ziel)) return fertig(true);
|
||
|
||
/* ==== ERST FERTIG, DANN SICHTBAR (03.10.2026) ================
|
||
|
||
GEFUNDEN VON DER PRÜFUNG, nicht beim Nachdenken: Sie holte die
|
||
zweite Fassung ab und bekam 44 Bytes.
|
||
|
||
ffmpeg LEGT DIE ZIELDATEI SOFORT AN und füllt sie danach — bei
|
||
`+faststart` schreibt es sie am Ende sogar noch einmal um. Die
|
||
Stelle, die fragt „gibt es die zweite Fassung?", sieht in
|
||
dieser Zeit eine Datei, die es zwar gibt, die aber noch nichts
|
||
enthält. Wer dann zuhört, bekommt ein Bruchstück.
|
||
|
||
Deshalb entsteht sie unter einem ANDEREN Namen und wird erst
|
||
am Ende umbenannt. Umbenennen im selben Ordner ist EIN Schritt:
|
||
Entweder die Datei ist vollständig da oder gar nicht.
|
||
|
||
DER BRUCHSTÜCKNAME ENDET NICHT AUF `.m4a` — sonst hielte
|
||
`zweitfassungDa()` ihn für das Ergebnis, und die Lücke wäre
|
||
nur umbenannt. Dafür muss das Format ausdrücklich dastehen
|
||
(`-f mp4`): ffmpeg wählt es sonst an der Endung, und `.teil`
|
||
kennt es nicht. (Auch das hat die Prüfung gefunden — der erste
|
||
Versuch mit Umbenennen erzeugte gar keine Datei mehr.) */
|
||
const bruchstueck = ziel + ".teil";
|
||
try { if (existsSync(bruchstueck)) unlinkSync(bruchstueck); } catch { /* egal */ }
|
||
execFile(ff, [
|
||
"-nostdin", "-hide_banner", "-loglevel", "error",
|
||
"-i", pfad,
|
||
/* KEIN BILD. Eine WebM kann ein Titelbild enthalten; in einer
|
||
Audiodatei hätte es nichts verloren und bringt ffmpeg bei
|
||
manchen Dateien zum Stolpern. */
|
||
"-vn",
|
||
/* ==== DAS LOCH AM ANFANG (03.10.2026, gemessen) =============
|
||
|
||
An einer echten Sprachnachricht aus dem Haus nachgesehen
|
||
(`mumqbw6v…webm`, 29.09.): Sie enthält 46 Pakete mit
|
||
zusammen 2,76 Sekunden Ton — verteilt über eine Zeitachse
|
||
von 63,8 Sekunden. Das erste Paket steht bei 0,0 s, das
|
||
zweite bei 61,1 s. Dazwischen: nichts.
|
||
|
||
WAS DAS FÜR DEN HÖRER HEISST: Das Abspielgerät zeigt 1:03,
|
||
springt beim Antippen in eine Minute Stille und fängt erst
|
||
danach an. Auf einem Handy über Mobilfunk sieht genau das
|
||
aus wie „lädt die ganze Zeit" — Miss' Satz aus Runde 5.
|
||
|
||
DAS IST NICHT UNSER FEHLER UND TROTZDEM UNSER PROBLEM:
|
||
`MediaRecorder` setzt diesen Versatz selbst. Reparieren
|
||
lässt er sich nur hier, beim Umwandeln.
|
||
|
||
`asetpts=N/SR/TB` vergibt die Zeitstempel neu, fortlaufend
|
||
ab null. Nachgemessen bleibt der Toninhalt dabei
|
||
unverändert: 260 KiB dekodiert vorher wie nachher, nur die
|
||
Länge geht von 63,8 s auf 2,76 s. Es wird also nichts
|
||
abgeschnitten, sondern ein Loch geschlossen.
|
||
|
||
WAS ES KOSTET: Eine Aufnahme mit einer echten Pause in der
|
||
Mitte rückt zusammen. Bei einer Sprachnachricht ist das
|
||
richtig so — `MediaRecorder` nimmt während einer Pause
|
||
ohnehin nichts auf, es entstünde also auch dort nur ein
|
||
Loch aus Stille. */
|
||
"-af", "asetpts=N/SR/TB",
|
||
"-c:a", "aac", "-b:a", RATE,
|
||
"-movflags", "+faststart",
|
||
/* NICHT ÜBERSCHREIBEN. Eine halbe Datei von einem
|
||
abgebrochenen Lauf soll auffallen, nicht stillschweigend
|
||
ersetzt werden -- und `existsSync` oben hat ohnehin schon
|
||
gefragt. */
|
||
"-f", "mp4", "-n", bruchstueck,
|
||
], { timeout: FRIST_MS }, (fehler) => {
|
||
const weg = () => {
|
||
try { if (existsSync(bruchstueck)) unlinkSync(bruchstueck); } catch { /* egal */ }
|
||
};
|
||
if (fehler) {
|
||
console.error(`[ton] Zweitfassung fuer ${basename(pfad)} ging nicht:`,
|
||
String(fehler.message || fehler).split("\n")[0]);
|
||
weg();
|
||
return fertig(false);
|
||
}
|
||
/* DER EINE SCHRITT, DER SIE SICHTBAR MACHT. Siehe oben: Erst
|
||
fertig, dann unter dem Namen, nach dem gefragt wird. */
|
||
try { renameSync(bruchstueck, ziel); } catch (f) {
|
||
console.error(`[ton] Zweitfassung fuer ${basename(pfad)} nicht umbenannt:`,
|
||
f?.message);
|
||
weg();
|
||
return fertig(false);
|
||
}
|
||
fertig(true);
|
||
});
|
||
});
|
||
}
|