Jede Sprachnachricht in einer Fassung, die JEDES Geraet abspielt

Miss im Support, Meldung #12 -- fuenf Runden seit dem 23.09.: „Bei
mir laesst sich die Nachricht nicht abspielen." Zuletzt: „Jetzt laedt
es die ganze Zeit." Bei allen anderen ging es.

GEMESSEN, BEVOR GEBAUT WURDE

  · Alle sechs Sprachnachrichten im Haus sind audio/webm (Opus).
  · Miss' Geraet: iPhone, iOS 18.7, WebKit.
  · Seit der Umstellung am 02.10. (neue Aufnahmen bevorzugen MP4)
    wurde KEINE EINZIGE neue aufgenommen. Ihr Problem betrifft also
    ausschliesslich die sechs alten Dateien -- und jede kuenftige aus
    einem Firefox, der nichts anderes kann.

WAS ICH NICHT MESSEN KONNTE, und das gehoert dazu: Ob WebKit
WebM/Opus abspielen kann, laesst sich auf diesem Rechner nicht
nachsehen -- Playwrights WebKit startet hier nicht (libegl.dll
fehlt). Die Vermutung „iPhones koennen kein WebM" ist begruendet,
aber von mir nicht gemessen.

GENAU DESHALB STELLT DIE LOESUNG DIE FRAGE NICHT. Statt zu raten,
welches Geraet welchen Behaelter kann, legt der Server neben jede
Sprachnachricht eine zweite Fassung in dem Format, bei dem sich seit
zwanzig Jahren alle einig sind: AAC in MP4. Gibt es sie, wird sie
abgespielt -- bei jedem, nicht nur auf iPhones. Kein Geraetename im
Code, keine Liste, die altert.

  helfer-ffmpeg.mjs     ffmpeg finden, umwandeln, drei Ausgaenge
  beim Hochladen        nebenher, nicht davor: Die Nachricht wartet
                        nicht auf ffmpeg
  toeneNachruesten()    das Netz darunter -- fuer die sechs von
                        frueher und fuer den Fall, dass eine
                        Umwandlung einmal nicht geklappt hat
  ?form=mp4             dieselbe Route, dieselben Rechte; eine
                        zweite haette dieselbe Sichtbarkeitspruefung
                        ein zweites Mal gebraucht

ffmpeg IST ABGESPROCHEN INSTALLIERT (Debian 7.1.5, auf Nachfrage
freigegeben). Fehlt es, passiert nichts Schlimmes: Die
Sprachnachricht geht wie bisher im Originalformat hinaus, und es
steht EINMAL eine Zeile im Protokoll -- nicht bei jedem Hochladen.

DREI ENTSCHEIDUNGEN, DIE NICHT NAHELIEGEND WAREN

 1. `-movflags +faststart` IST NICHT KOSMETIK. Ohne es steht die
    Inhaltsuebersicht einer MP4 am ENDE. Das Abspielgeraet muss dann
    erst bis ans Ende lesen, bevor es anfangen kann -- genau das
    „laedt die ganze Zeit" aus Miss' Runde 5. Geprueft wird die
    Reihenfolge der Kaesten in der Datei, nicht die Zeile im Aufruf.

 2. DAS ORIGINAL BLEIBT LIEGEN. Ohne `?form=mp4` kommt weiter die
    WebM. Sie ist das, was aufgenommen wurde; sie durch eine
    Umrechnung zu ersetzen hiesse, das Original wegzuwerfen.

 3. EIN GEMEINSAMER LOESCHER. Zwei Stellen entfernen Anhaenge
    (Gespraech wegraeumen, Nachricht zuruecknehmen). Beide nahmen
    genau eine Datei -- ab heute waere bei jeder geloeschten
    Sprachnachricht eine m4a liegengeblieben, ohne Zeile, die auf
    sie zeigt. Dieselbe Luecke hatte ich gestern bei den
    Supportbildern gefunden; hier steht sie von Anfang an an EINER
    Stelle.

ZWEI FUNDE DER PRUEFUNG, BEIDE MEINE EIGENEN

 · `anhang_datei` STAND NICHT IN DER ABFRAGE der Nachrichtenliste.
   Die Funktion, die auf der Platte nachsieht, bekam deshalb nichts
   und sagte brav „gibt es keine zweite Fassung" -- fuer JEDE
   Nachricht. Alles war gebaut, nichts kam an. Gefunden hat das die
   Pruefung, nicht das Lesen.
 · Mein erster Anlauf mass die Aufnahme ueber den Knopf -- und die
   ist seit dem 02.10. schon MP4, braucht also gar keine zweite
   Fassung. Die Pruefung wurde rot und hatte recht: Gemessen werden
   muss der Fall, den es bei Miss gibt. Jetzt nimmt sie ausdruecklich
   eine WebM auf.

Dazu zweimal derselbe alte Tritt: ein Gegen-Apostroph in einem
Kommentar INNERHALB eines Template-Literals. Der Server startet dann
gar nicht. Steht jetzt als Warnung an beiden Stellen.

GEPRUEFT -- pruef-chat-anhaenge 146 -> 163 ok

  Eine echte WebM/Opus-Aufnahme (31 972 Bytes) wird hochgeladen; die
  zweite Fassung entsteht von selbst und kommt als audio/mp4 heraus,
  mit „ftyp"-Marke, mit Accept-Ranges. ffprobe sagt: Original Opus,
  Zweitfassung AAC, 2,40 s gegen 2,46 s. Die Inhaltsuebersicht steht
  bei Byte 32, die Tondaten ab 1270 -- also vorne. Beim Loeschen
  gehen beide Dateien. Gegenproben: ein Bild bekommt keine zweite
  Fassung; eine halbierte Laenge waere aufgefallen; das Original
  bleibt unter seiner eigenen Adresse abrufbar.

  Der dritte Ausgang hat einen eigenen Zaehler: Fehlt ffmpeg, steht
  „KONNTE NICHT NACHSEHEN" mit Anleitung da -- nicht gruen und nicht
  rot. Und der Block raeumt hinter sich auf: Mein Probebild liess
  eine spaetere Pruefung rot werden, weil es die Gespraechsliste
  veraendert hatte. Eine Pruefung, die den Stand fuer die naechste
  verschiebt, ist schlimmer als keine.

  Dazu gruen: pruef-struktur 102 (92 Module) · pruef-ports 10 ·
  pruef-gifs 17 · pruef-treffchat 114 · pruef-chat-optik ·
  pruef-aufbewahrung 45.

NUR DAS AGENTURHAUS? NEIN -- der Chat gehoert beiden Haeusern, und
die Aenderung gilt fuer beide gleich. Sie aendert nichts an
Sichtbarkeit oder Rechten: Wer eine Nachricht hoeren darf, hoert sie
jetzt in einem Format, das sein Geraet kann.

OFFEN BLEIBT DIE ANTWORT VON MISS. Ob es bei ihr jetzt laeuft, weiss
nur sie -- deshalb steht ihre Meldung weiter offen und nicht auf
erledigt.

Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
2026-10-03 01:41:34 +02:00
co-authored by Claude Opus 5
parent 1ce2c3785e
commit d70d00cedc
51 changed files with 1368 additions and 698 deletions
+260 -1
View File
@@ -105,6 +105,11 @@ const PROTOKOLL = join(process.cwd(), "pruef-chat-anhaenge-lauf.txt");
try { rmSync(PROTOKOLL, { force: true }); } catch { /* gab es noch nicht */ }
const melde = (z) => { console.log(z); try { appendFileSync(PROTOKOLL, z + "\n"); } catch { /* Zugabe */ } };
let fehler = 0, geprueft = 0;
/* DER DRITTE AUSGANG BRAUCHT EINEN ZAEHLER (03.10.2026). „Konnte
nicht nachsehen" ist weder in Ordnung noch kaputt -- ohne eigene
Zahl verschwaende es zwischen den gruenen Zeilen, und am Ende
stuende „alles in Ordnung" ueber etwas, das niemand gemessen hat. */
let nichtNachsehbar = 0;
const ok = (b, t) => { geprueft++; melde((b ? " ok " : " FEHL ") + t); if (!b) fehler++; };
/* ---- Bestand ------------------------------------------------------------ */
@@ -1063,6 +1068,259 @@ async function tonAufnehmen(seite, ms) {
ok((zeile?.anhang_dauer || 0) >= 2000 && (zeile?.anhang_dauer || 0) < 20000,
` und die Länge steht dabei (${zeile?.anhang_dauer} ms)`);
/* ==== DIE ZWEITE FASSUNG, DIE JEDES GERAET ABSPIELT (03.10.2026)
==================================================================
Miss im Support, Meldung #12, fuenf Runden: „Bei mir laesst sich
die Nachricht nicht abspielen." Zuletzt: „Jetzt laedt es die
ganze Zeit." Alle sechs Sprachnachrichten im Haus waren WebM.
AUSDRUECKLICH EINE WEBM, und das ist hier der Punkt. Die
Aufnahme ueber den Knopf weiter oben liefert seit dem 02.10.
eine MP4 -- die braucht keine zweite Fassung, und der erste
Anlauf dieser Pruefung mass deshalb NICHTS und wurde rot. Die
Pruefung hatte recht, die Erwartung war falsch: Gemessen werden
muss der Fall, den es bei Miss gibt, und das ist die WebM von
frueher (oder von einem Firefox, der nichts anderes kann).
WAS GEMESSEN WIRD, IST NICHT „die Datei ist da". Das waere ein
gruener Haken ueber etwas, das niemand abspielen kann. Mit
ffprobe wird gefragt, WAS drinsteckt: AAC, in einem MP4, so lang
wie das Original -- und mit der Inhaltsuebersicht VORNE.
DER DRITTE AUSGANG: Ohne ffmpeg gibt es keine zweite Fassung.
Das ist kein Fehler des Programms und wird auch nicht
stillschweigend uebersprungen -- es steht als eigene Zeile da,
mit der Anleitung dazu.
================================================================== */
{
const { ffmpegPfad, ffprobePfad, zweitfassungPfad } =
await import("./helfer-ffmpeg.mjs");
const { execFileSync } = await import("node:child_process");
const { readFileSync: lies, statSync: wiegt } = await import("node:fs");
if (!ffmpegPfad()) {
melde(" -- KONNTE NICHT NACHSEHEN: ffmpeg fehlt auf diesem Rechner.");
melde(" Ohne ffmpeg bekommt keine Sprachnachricht eine zweite");
melde(" Fassung -- die Pruefung kann nicht sagen, ob sie");
melde(" richtig waere. Auf Debian: sudo apt-get install -y ffmpeg");
nichtNachsehbar++;
} else {
/* EINE ECHTE WEBM, vom Browser aufgenommen -- nicht
zusammengebaut. `tonAufnehmen` fragt der Reihe nach und nimmt
das erste Format, das geht; die Liste beginnt mit WebM. */
const webmAuf = await dogi.seite.evaluate(async (ms) => {
const spur = await navigator.mediaDevices.getUserMedia({ audio: true });
if (!MediaRecorder.isTypeSupported("audio/webm;codecs=opus")) {
spur.getTracks().forEach((t) => t.stop());
return { typ: "", b64: "", bytes: 0 };
}
const r = new MediaRecorder(spur, { mimeType: "audio/webm;codecs=opus" });
const teile = [];
r.ondataavailable = (e) => { if (e.data.size) teile.push(e.data); };
const fertig = new Promise((f) => { r.onstop = f; });
r.start();
await new Promise((f) => setTimeout(f, ms));
r.stop();
await fertig;
spur.getTracks().forEach((t) => t.stop());
const blob = new Blob(teile, { type: "audio/webm" });
const puffer = new Uint8Array(await blob.arrayBuffer());
let roh = "";
for (const b of puffer) roh += String.fromCharCode(b);
return { typ: "audio/webm", b64: btoa(roh), bytes: puffer.length };
}, 2500);
ok(webmAuf.bytes > 500,
`eine echte WebM-Sprachnachricht aufgenommen (${webmAuf.bytes} Bytes)`);
const geschickt = await dogi.seite.evaluate(async ([r, b64]) => {
const roh = atob(b64);
const puffer = new Uint8Array(roh.length);
for (let i = 0; i < roh.length; i++) puffer[i] = roh.charCodeAt(i);
const a = await fetch(`/workspace/api/chat/raeume/${r}/anhang`, {
method: "POST",
headers: { "Content-Type": "audio/webm",
"X-Dateiname": "Sprachnachricht.webm", "X-Dauer": "2500" },
body: puffer,
});
const d = await a.json().catch(() => ({}));
return { status: a.status, id: d?.nachricht?.id || 0 };
}, [raumDogi, webmAuf.b64]);
ok(geschickt.status === 201 && geschickt.id > 0,
`sie geht durch (${geschickt.status}, #${geschickt.id})`);
/* AUF DIE UMWANDLUNG WARTEN. Sie laeuft absichtlich NEBEN dem
Hochladen -- die Nachricht soll nicht auf ffmpeg warten.
Deshalb wird hier nachgesehen, statt es anzunehmen, und zwar
mit einer Frist: Ein festes `waitForTimeout` waere entweder
zu kurz (rot ohne Grund) oder zu lang (jeder Lauf zahlt). */
let weg2 = null;
for (let i = 0; i < 40 && !weg2; i++) {
// eslint-disable-next-line no-await-in-loop
weg2 = await dogi.seite.evaluate(async ([r, id]) => {
const a = await fetch(`/workspace/api/chat/raeume/${r}/nachrichten`);
const d = await a.json();
const n = (d.nachrichten || []).find((x) => x.id === id);
return n?.anhang?.weg_auch || null;
}, [raumDogi, geschickt.id]);
// eslint-disable-next-line no-await-in-loop
if (!weg2) await new Promise((r) => setTimeout(r, 500));
}
ok(!!weg2, `die zweite Fassung entsteht von selbst (${weg2 || "kam nicht"})`);
ok(/\?form=mp4$/.test(weg2 || ""), ` und sie hat einen eigenen Weg (${weg2})`);
if (weg2) {
/* ---- Was kommt ueber die Leitung? ------------------------- */
const kopf = await dogi.seite.evaluate(async (w) => {
const a = await fetch(w);
const b = new Uint8Array(await a.arrayBuffer());
return {
code: a.status,
typ: a.headers.get("content-type"),
bereiche: a.headers.get("accept-ranges"),
bytes: b.length,
/* Die ersten zwoelf Bytes einer MP4: vier Byte Laenge,
dann „ftyp". Daran erkennt man den Behaelter, ohne ihn
zu glauben. */
marke: String.fromCharCode(...b.slice(4, 8)),
};
}, weg2);
ok(kopf.code === 200 && kopf.typ === "audio/mp4",
`sie kommt als audio/mp4 heraus (${kopf.code}, ${kopf.typ})`);
ok(kopf.marke === "ftyp",
` und ist wirklich ein MP4-Behaelter (Marke „${kopf.marke}")`);
ok(kopf.bereiche === "bytes",
` ueber denselben Ausliefer-Weg wie alles andere (${kopf.bereiche})`);
ok(kopf.bytes > 2000, ` mit Inhalt (${kopf.bytes} Bytes)`);
/* ---- DAS ORIGINAL BLEIBT ABRUFBAR -------------------------
Ohne `?form=mp4` kommt weiter die WebM. Sie ist das, was
hochgeladen wurde; sie zu ersetzen hiesse, die Aufnahme
durch eine Umrechnung davon auszutauschen. */
const original = await dogi.seite.evaluate(async (w) => {
const a = await fetch(w.replace("?form=mp4", ""));
return { code: a.status, typ: a.headers.get("content-type") };
}, weg2);
ok(original.code === 200 && original.typ === "audio/webm",
`das Original bleibt daneben liegen (${original.typ})`);
/* ---- UND WAS STECKT WIRKLICH DRIN? ------------------------ */
const db4 = new DatabaseSync(process.env.WORKSPACE_DB);
const tonZeile = db4.prepare(
"SELECT anhang_datei FROM chat_nachrichten WHERE id = ?").get(geschickt.id);
db4.close();
const { DATEN_ORDNER } = await import("./workspace.js");
const quelle = join(DATEN_ORDNER, "chat-anhaenge", tonZeile.anhang_datei);
const ziel = zweitfassungPfad(quelle);
const probe = ffprobePfad();
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"));
const dauerAlt = Number(frag(quelle, "format=duration"));
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 INHALTSUEBERSICHT STEHT VORNE --------------------
Ohne `+faststart` liegt `moov` am Dateiende. Das
Abspielgeraet muss dann erst bis ans Ende lesen, bevor es
anfangen kann -- genau das „laedt die ganze Zeit", das Miss
in Runde 5 beschrieben hat. Gemessen wird die Reihenfolge
der Kaesten in der Datei, nicht die Zeile im Aufruf. */
const roh = lies(ziel);
const wo = (name) => roh.indexOf(Buffer.from(name, "latin1"));
const moov = wo("moov");
const mdat = wo("mdat");
ok(moov > 0 && mdat > 0 && moov < mdat,
`die Inhaltsuebersicht steht VOR den Tondaten (moov ${moov}, mdat ${mdat})`);
ok(wiegt(ziel).size === kopf.bytes,
` und was auf der Platte liegt, kommt auch heraus (${wiegt(ziel).size} Bytes)`);
/* ---- UND BEIM LOESCHEN GEHEN BEIDE ------------------------
Sonst bliebe bei jeder zurueckgenommenen Sprachnachricht
eine m4a liegen -- ohne Zeile, die auf sie zeigt, und damit
fuer immer unzuordenbar. Genau diese Luecke gab es gestern
bei den Supportbildern. */
const weggenommen = await dogi.seite.evaluate(async (id) => {
const a = await fetch(`/workspace/api/chat/nachrichten/${id}`, { method: "DELETE" });
return a.status;
}, geschickt.id);
await new Promise((r) => setTimeout(r, 600));
const nochDa = [quelle, ziel].filter((f) => { try { wiegt(f); return true; } catch { return false; } });
ok(weggenommen < 400 && nochDa.length === 0,
`beim Loeschen gehen BEIDE Dateien (${weggenommen}, ${nochDa.length} liegen noch)`);
}
/* ---- GEGENPROBE: EIN BILD BEKOMMT KEINE ZWEITE FASSUNG -----
Sonst liefe ffmpeg ueber jedes Foto und jedes PDF -- Arbeit
fuer nichts, und eine Tuer, nach der niemand gefragt hat.
Das Bild wird hier selbst geschickt und nicht im Verlauf
gesucht: Mein erster Anlauf suchte eines, fand keines (an
dieser Stelle gab es noch keins) und meldete `undefined`
statt `null` -- gruen waere das nie gewesen, aber es hat
auch nichts geprueft. */
const einBild = await dogi.seite.evaluate(async (r) => {
const png = Uint8Array.from(atob(
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg=="),
(c) => c.charCodeAt(0));
const a = await fetch(`/workspace/api/chat/raeume/${r}/anhang`, {
method: "POST",
headers: { "Content-Type": "image/png", "X-Dateiname": "punkt.png" },
body: png,
});
const d = await a.json().catch(() => ({}));
return { status: a.status, auch: d?.nachricht?.anhang?.weg_auch };
}, raumDogi);
ok(einBild.status === 201 && einBild.auch === null,
`Gegenprobe: ein Bild bekommt keine zweite Fassung `
+ `(${einBild.status}, ${JSON.stringify(einBild.auch)})`);
/* ==== DIESER BLOCK HINTERLAESST NICHTS (03.10.2026) =========
Die Zeilen weiter unten fragen die Gespraechsliste und
erwarten dort das Wort „Sprachnachricht" -- also das, was
ZULETZT in diesem Raum stand. Mein Probebild liess sie rot
werden, und zwar zu Recht: Nicht die Liste war kaputt,
sondern diese Pruefung hatte den Raum veraendert.
Eine Pruefung, die den Stand fuer die naechste verschiebt,
ist schlimmer als keine: Der Fehler taucht woanders auf, und
dort sucht man ihn dann. Also wird aufgeraeumt. */
await dogi.seite.evaluate(async (r) => {
const a = await fetch(`/workspace/api/chat/raeume/${r}/nachrichten`);
const d = await a.json();
const bild = (d.nachrichten || []).filter((n) => n.anhang?.art === "bild").pop();
if (bild) {
await fetch(`/workspace/api/chat/nachrichten/${bild.id}`, { method: "DELETE" });
}
}, raumDogi);
await new Promise((r) => setTimeout(r, 400));
}
}
/* ---- In der Gesprächsliste steht ein Wort dafür ------------------ */
const vorschau = await dogi.seite.evaluate(async () => {
const a = await fetch("/workspace/api/chat/raeume");
@@ -1453,6 +1711,7 @@ await dogi.ctx.close();
await browser.close();
try { rmSync(ordner, { recursive: true, force: true }); } catch { /* egal */ }
melde(`\n${geprueft} Prüfungen`);
melde(`\n${geprueft} Prüfungen`
+ (nichtNachsehbar ? `, ${nichtNachsehbar} konnten nicht nachsehen` : ""));
melde(fehler === 0 ? "ALLES IN ORDNUNG" : `${fehler} FEHLER`);
process.exit(fehler ? 1 : 0);