/* ===================================================================== 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 } from "node:fs"; import { execFile } 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; } /** 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); 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", "-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, ], { timeout: FRIST_MS }, (fehler) => { if (fehler) { console.error(`[ton] Zweitfassung fuer ${basename(pfad)} ging nicht:`, String(fehler.message || fehler).split("\n")[0]); return fertig(false); } fertig(true); }); }); }