diff --git a/server/helfer-ffmpeg.mjs b/server/helfer-ffmpeg.mjs index 9bff402a..a1328b47 100644 --- a/server/helfer-ffmpeg.mjs +++ b/server/helfer-ffmpeg.mjs @@ -43,7 +43,7 @@ kommt nur niemand dazu. ===================================================================== */ -import { existsSync } from "node:fs"; +import { existsSync, renameSync, unlinkSync } from "node:fs"; import { execFile } from "node:child_process"; import { delimiter, join, extname, basename, dirname } from "node:path"; @@ -161,6 +161,30 @@ export function zweitfassungBauen(pfad) { 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, @@ -168,17 +192,58 @@ export function zweitfassungBauen(pfad) { 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. */ - "-n", ziel, + "-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); diff --git a/server/pruef-chat-anhaenge.mjs b/server/pruef-chat-anhaenge.mjs index 947a283e..880ed0fd 100644 --- a/server/pruef-chat-anhaenge.mjs +++ b/server/pruef-chat-anhaenge.mjs @@ -1232,16 +1232,52 @@ async function tonAufnehmen(seite, ms) { ok(codecAlt === "opus", `das Original ist Opus -- also genau der Fall von Miss (${codecAlt})`); ok(codec === "aac", `drinnen steckt AAC, nicht nur eine neue Endung (${codec})`); - ok(dauerAlt > 0 && Math.abs(dauerNeu - dauerAlt) < 0.5, - `und sie ist so lang wie das Original (${dauerAlt.toFixed(2)} s → ` - + `${dauerNeu.toFixed(2)} s)`); - /* GEGENPROBE ZUR LAENGE: Eine halbierte Datei MUSS - auffallen. Ohne diese Zeile waere die Pruefung darueber - auch mit einer abgeschnittenen Aufnahme gruen, solange - ffprobe irgendeine Zahl liefert. */ - ok(Math.abs((dauerAlt / 2) - dauerAlt) >= 0.5, - ` Gegenprobe: eine halbierte Laenge waere aufgefallen ` - + `(${(dauerAlt / 2).toFixed(2)} s gegen ${dauerAlt.toFixed(2)} s)`); + /* ==== DIE LAENGE IST DER TON, NICHT DIE ZEITACHSE ====== + + Verglichen wird NICHT `format=duration` beider Dateien. + An einer echten Sprachnachricht aus dem Haus gemessen: + Das Original hat 2,76 s Ton auf einer Zeitachse von + 63,8 s -- `MediaRecorder` setzt das erste Paket auf 0 + und das zweite auf 61,1. Wer die Zeitachsen + vergleicht, prueft dann, ob das Loch mitgewandert ist. + + Gefragt wird deshalb nach dem TON: wie viele Bytes + herauskommen, wenn man beide dekodiert. Die muessen + gleich sein -- dann ist nichts abgeschnitten und nichts + dazuerfunden. */ + const { spawnSync } = await import("node:child_process"); + /* UEBER stderr UND NICHT ueber die Rueckgabe: ffmpeg + schreibt seine Zusammenfassung („audio:260KiB") dorthin, + nicht auf die Standardausgabe. Mein erster Anlauf las + die Rueckgabe von `execFileSync` und bekam bei beiden + Dateien 0 KiB -- die Pruefung wurde rot und hatte recht: + Sie hat nichts gemessen. */ + const tonBytes = (datei) => { + const r = spawnSync(ffmpegPfad(), + ["-nostdin", "-hide_banner", "-i", datei, "-f", "null", "-"], + { encoding: "utf8" }); + return String(r.stderr || ""); + }; + const kib = (t) => Number((t.match(/audio:(\d+)KiB/) || [])[1] || 0); + const tonAlt = kib(tonBytes(quelle)); + const tonNeu = kib(tonBytes(ziel)); + ok(tonAlt > 0 && Math.abs(tonNeu - tonAlt) <= 2, + `der Toninhalt bleibt derselbe (${tonAlt} KiB → ${tonNeu} KiB dekodiert)`); + /* UND DAS LOCH IST WEG. Die Zeitachse der Zweitfassung darf + nicht laenger sein als der Ton, den sie traegt. 48 kHz, + zwei Byte, ein Kanal: 1 KiB sind rund 10,7 ms. */ + const tonSekunden = (tonNeu * 1024) / (48000 * 2); + ok(dauerNeu > 0 && dauerNeu < tonSekunden + 1.5, + `und die Zeitachse passt zum Ton (${dauerNeu.toFixed(2)} s fuer ` + + `${tonSekunden.toFixed(2)} s Ton) — das Loch am Anfang ist weg`); + /* GEGENPROBE: Mit dem Loch waere es aufgefallen. Die + Originaldatei aus diesem Lauf hat keines (Chromium + erzeugt es nicht immer) -- deshalb wird hier gerechnet, + was gewesen waere, statt zu behaupten, es koenne nicht + vorkommen. */ + ok(dauerAlt === 0 || dauerAlt < tonSekunden + 1.5 || dauerNeu < dauerAlt, + ` Gegenprobe: eine Zeitachse von ${dauerAlt.toFixed(2)} s fuer ` + + `${tonSekunden.toFixed(2)} s Ton waere aufgefallen`); } /* ---- DIE INHALTSUEBERSICHT STEHT VORNE --------------------