diff --git a/server/helfer-ffmpeg.mjs b/server/helfer-ffmpeg.mjs index a1328b47..d385b604 100644 --- a/server/helfer-ffmpeg.mjs +++ b/server/helfer-ffmpeg.mjs @@ -44,7 +44,7 @@ ===================================================================== */ import { existsSync, renameSync, unlinkSync } from "node:fs"; -import { execFile } from "node:child_process"; +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 @@ -139,6 +139,73 @@ 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 diff --git a/server/pruef-chat-anhaenge.mjs b/server/pruef-chat-anhaenge.mjs index 880ed0fd..1ae524c7 100644 --- a/server/pruef-chat-anhaenge.mjs +++ b/server/pruef-chat-anhaenge.mjs @@ -1214,17 +1214,23 @@ async function tonAufnehmen(seite, ms) { const ziel = zweitfassungPfad(quelle); const probe = ffprobePfad(); + /* `frag` UND `spawnSyncFuerProbe` STEHEN HIER DRAUSSEN, weil + der Abschnitt „untaugliche Fassung" weiter unten sie auch + braucht. Innerhalb des else-Zweigs waeren sie dort nicht + sichtbar -- und das faellt erst beim Laufen auf. */ + const frag = (datei, was) => { + if (!probe) return ""; + try { + return execFileSync(probe, ["-v", "error", "-show_entries", was, + "-of", "default=noprint_wrappers=1:nokey=1", datei], + { encoding: "utf8" }).trim(); + } catch { return ""; } + }; + const { spawnSync: spawnSyncFuerProbe } = await import("node:child_process"); if (!probe) { melde(" -- KONNTE NICHT NACHSEHEN: ffprobe fehlt neben ffmpeg."); nichtNachsehbar++; } else { - const frag = (datei, was) => { - try { - return execFileSync(probe, ["-v", "error", "-show_entries", was, - "-of", "default=noprint_wrappers=1:nokey=1", datei], - { encoding: "utf8" }).trim(); - } catch { return ""; } - }; const codec = frag(ziel, "stream=codec_name"); const codecAlt = frag(quelle, "stream=codec_name"); const dauerNeu = Number(frag(ziel, "format=duration")); @@ -1245,7 +1251,6 @@ async function tonAufnehmen(seite, ms) { 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 @@ -1253,7 +1258,7 @@ async function tonAufnehmen(seite, ms) { Dateien 0 KiB -- die Pruefung wurde rot und hatte recht: Sie hat nichts gemessen. */ const tonBytes = (datei) => { - const r = spawnSync(ffmpegPfad(), + const r = spawnSyncFuerProbe(ffmpegPfad(), ["-nostdin", "-hide_banner", "-i", datei, "-f", "null", "-"], { encoding: "utf8" }); return String(r.stderr || ""); @@ -1295,6 +1300,56 @@ async function tonAufnehmen(seite, ms) { ok(wiegt(ziel).size === kopf.bytes, ` und was auf der Platte liegt, kommt auch heraus (${wiegt(ziel).size} Bytes)`); + /* ---- EINE UNTAUGLICHE FASSUNG WIRD ERSETZT --------------- + + Die ersten sechs Zweitfassungen im Haus sind entstanden, + BEVOR das Loch am Anfang bekannt war -- vier davon tragen + es. Mit „gibt es schon, dann weiter" waeren sie fuer immer + so geblieben. Geprueft wird deshalb, dass der Nachruestlauf + eine Fassung, deren Zeitachse nicht zum Ton passt, ERSETZT. + + NACHGESTELLT MIT EINER ECHTEN: Dieselbe Aufnahme wird von + Hand mit dem alten Aufruf (ohne `asetpts`) und einem + kuenstlichen Versatz gebaut -- so sieht sie aus wie die + vier auf dem Server. */ + const { toeneNachruesten } = await import("./workspace-chat.js"); + const { zweitfassungTaugt } = await import("./helfer-ffmpeg.mjs"); + /* NICHT `weg2` NENNEN -- so hiess oben schon die Adresse der + Zweitfassung, und `const` im selben Block macht daraus + einen Absturz, BEVOR die obere Zeile ueberhaupt gelesen + wird. Gefunden von der Pruefung selbst. */ + const { unlinkSync: dateiLoeschen } = await import("node:fs"); + dateiLoeschen(ziel); + /* WIE MAN EINE DATEI MIT LOCH BAUT -- und wie nicht. + `-itsoffset 40` war mein erster Versuch und ergab 2,5 s: + Der MP4-Baukasten rechnet einen reinen Anfangsversatz + wieder heraus. Was bleibt, ist eine Zeitachse, die laenger + ist als der Ton, den sie traegt -- und genau das stellt + eine stumme Bildspur von 40 Sekunden her. Der Weg dorthin + ist ein anderer als bei `MediaRecorder`; die ZAHLEN, die + `zweitfassungTaugt` liest, sind dieselben, und darum geht + es hier. */ + spawnSyncFuerProbe(ffmpegPfad(), ["-nostdin", "-hide_banner", "-loglevel", "error", + "-f", "lavfi", "-i", "color=black:s=32x32:r=1:d=40", "-i", quelle, + "-c:v", "libx264", "-preset", "ultrafast", "-c:a", "aac", "-b:a", "96k", + "-f", "mp4", "-y", ziel]); + const kaputtDauer = Number(frag(ziel, "format=duration")); + ok(kaputtDauer > 30, + `eine Fassung mit zu langer Zeitachse nachgestellt ` + + `(${kaputtDauer.toFixed(1)} s)`); + ok(zweitfassungTaugt(ziel) === false, + " und sie wird als untauglich erkannt"); + await toeneNachruesten(); + const heilDauer = Number(frag(ziel, "format=duration")); + ok(heilDauer > 0 && heilDauer < 10, + `der Nachruestlauf ersetzt sie (${kaputtDauer.toFixed(1)} s → ` + + `${heilDauer.toFixed(1)} s)`); + /* GEGENPROBE: Die GUTE Fassung laesst er in Ruhe. Ohne sie + koennte der Lauf bei jedem Durchgang alles neu wandeln -- + eine Warnung, die immer kommt. */ + ok(zweitfassungTaugt(ziel) === true, + " und die neue gilt als tauglich — er wandelt sie nicht ewig weiter"); + /* ---- UND BEIM LOESCHEN GEHEN BEIDE ------------------------ Sonst bliebe bei jeder zurueckgenommenen Sprachnachricht eine m4a liegen -- ohne Zeile, die auf sie zeigt, und damit diff --git a/server/workspace-chat.js b/server/workspace-chat.js index b744b0d7..1f349311 100644 --- a/server/workspace-chat.js +++ b/server/workspace-chat.js @@ -47,7 +47,8 @@ import { liefereDatei } from "./helfer-ausliefern.mjs"; helfer-ffmpeg.mjs. Sie ist der Grund, warum Miss sie ab heute hoeren kann (Support-Meldung #12). */ import { - zweitfassungBauen, zweitfassungPfad, brauchtZweitfassung, ZWEITFASSUNG_TYP, + zweitfassungBauen, zweitfassungPfad, brauchtZweitfassung, zweitfassungTaugt, + ZWEITFASSUNG_TYP, } from "./helfer-ffmpeg.mjs"; /** Eine Anhangsdatei entfernen -- MITSAMT ihrer zweiten Fassung. @@ -124,9 +125,32 @@ export async function toeneNachruesten(hoechstens = 20) { for (const z of zeilen) { if (gebaut >= hoechstens) break; if (!brauchtZweitfassung(z.anhang_datei)) continue; - if (zweitfassungDa("ton", z.anhang_datei)) continue; const pfad = join(ANHANG_ORDNER, z.anhang_datei); try { statSync(pfad); } catch { continue; } + /* ==== EINE UNTAUGLICHE FASSUNG WIRD ERSETZT (03.10.2026) === + + Hier stand nur „gibt es schon? dann weiter". Die ersten + sechs Zweitfassungen sind aber entstanden, BEVOR das Loch am + Anfang bekannt war -- vier davon tragen es (387 s, 164 s, + 134 s, 63,8 s Zeitachse fuer wenige Sekunden Ton). Mit „gibt + es schon" waeren sie fuer immer so geblieben. + + `zweitfassungTaugt` fragt die Datei selbst, nicht eine + Versionsnummer: Passt ihre Zeitachse zu der Tonmenge, die + sie traegt? Was nicht passt, wird weggeraeumt und neu + gebaut -- einmal, denn danach passt es. */ + const ziel = zweitfassungPfad(pfad); + let daUndGut = false; + try { + statSync(ziel); + daUndGut = zweitfassungTaugt(ziel); + if (!daUndGut) { + unlinkSync(ziel); + console.log(`[ton] Zweitfassung von ${z.anhang_datei} war unbrauchbar ` + + "(Zeitachse passt nicht zum Ton) -- wird neu gebaut."); + } + } catch { /* es gibt sie noch nicht */ } + if (daUndGut) continue; /* NACHEINANDER, nicht alle auf einmal: Zwanzig ffmpeg-Laeufe gleichzeitig nehmen dem Server genau die Rechenzeit weg, die er fuer die Seite braucht. */