import { test } from 'node:test'; import assert from 'node:assert/strict'; import type { Word } from './whisper.ts'; import { wordsFromJson } from './whisper.ts'; import { assColor, assTime, buildAss, buildSrt, isNoise, toLines } from './subtitles.ts'; function w(text: string, start: number, end: number): Word { return { text, start, end }; } // ------------------------------------------------------------ Wortzerlegung test('setzt Wortstuecke wieder zusammen', () => { // So sieht whisper.cpp wirklich aus: Tokens mit fuehrendem Leerzeichen // beginnen ein Wort, alles andere haengt sich an. const json = JSON.stringify({ transcription: [{ offsets: { from: 0, to: 4200 }, tokens: [ { text: '[_BEG_]', offsets: { from: 0, to: 0 } }, { text: ' Sav', offsets: { from: 2280, to: 3120 } }, { text: 'ina', offsets: { from: 3280, to: 3880 } }, { text: ',', offsets: { from: 3990, to: 4140 } }, ], }], }); const words = wordsFromJson(json); assert.deepEqual(words.map((x) => x.text), ['Savina,']); assert.equal(words[0]?.start, 2.28); }); test('wirft Sondertokens weg', () => { const json = JSON.stringify({ transcription: [{ tokens: [ { text: '[_BEG_]', offsets: { from: 0, to: 0 } }, { text: ' hallo', offsets: { from: 100, to: 400 } }, { text: '[_TT_120]', offsets: { from: 400, to: 400 } }, ] }], }); assert.deepEqual(wordsFromJson(json).map((x) => x.text), ['hallo']); }); test('zieht rueckwaerts laufende Zeitstempel gerade', () => { const json = JSON.stringify({ transcription: [{ tokens: [ { text: ' eins', offsets: { from: 1000, to: 2000 } }, { text: ' zwei', offsets: { from: 1500, to: 1800 } }, ] }], }); const words = wordsFromJson(json); assert.ok(words[1]!.start >= words[0]!.end, 'zweites Wort darf nicht vor dem ersten enden'); assert.ok(words[1]!.end > words[1]!.start); }); // ------------------------------------------------------------ Zeilenbildung test('bricht lange Rede in kurze Zeilen', () => { const words = ['eins', 'zwei', 'drei', 'vier', 'fuenf', 'sechs', 'sieben', 'acht'] .map((t, i) => w(t, i * 0.4, i * 0.4 + 0.35)); const lines = toLines(words); assert.ok(lines.length >= 2, 'acht Woerter gehoeren nicht auf eine Zeile'); for (const l of lines) assert.ok(l.words.length <= 5, 'Zeile zu lang'); }); test('haengt einzeln stehende Woerter an den Nachbarn', () => { // Genau der Fall aus dem echten Clip: langsames Sprechen erzeugte // Zeilen mit einem Wort ("Dank", "es,", "Savina,") const words = [w('Dank', 0, 1.14), w('es,', 1.14, 2.28), w('Savina,', 2.28, 4.14)]; const lines = toLines(words); assert.ok(lines.length < 3, `immer noch zersplittert: ${JSON.stringify(lines.map(l => l.words.map(x => x.text)))}`); for (const l of lines) { assert.ok(l.words.length > 1 || lines.length === 1, 'Zeile mit nur einem Wort uebrig'); } }); test('trennt bei laengeren Sprechpausen', () => { const words = [w('vor', 0, 0.4), w('der', 0.4, 0.8), w('Pause', 4.0, 4.4)]; const lines = toLines(words); assert.equal(lines.length, 2); assert.equal(lines[1]?.words[0]?.text, 'Pause'); }); test('filtert Geraeuschbeschreibungen', () => { assert.equal(isNoise('*Lachen*'), true); assert.equal(isNoise('[Musik]'), true); assert.equal(isNoise('(Applaus)'), true); assert.equal(isNoise('Lachen'), false); const lines = toLines([w('hallo', 0, 0.5), w('*Lachen*', 0.6, 1.2)]); assert.deepEqual(lines.flatMap((l) => l.words.map((x) => x.text)), ['hallo']); }); // ------------------------------------------------------------ ASS-Ausgabe test('Zeilen ueberlappen sich nie', () => { // Zwei gleichzeitig sichtbare Dialoge stapelt ASS uebereinander const words = Array.from({ length: 12 }, (_, i) => w(`w${i}`, i * 0.5, i * 0.5 + 0.45)); const ass = buildAss(toLines(words), { width: 1080, height: 1920 }); const times = [...ass.matchAll(/^Dialogue: 0,([\d:.]+),([\d:.]+),/gm)] .map((m) => [toSeconds(m[1]!), toSeconds(m[2]!)] as const); assert.ok(times.length >= 2); for (let i = 1; i < times.length; i++) { assert.ok(times[i]![0] >= times[i - 1]![1] - 0.011, `Zeile ${i} beginnt bei ${times[i]![0]}, vorherige endet erst bei ${times[i - 1]![1]}`); } }); test('jede Zeile bleibt mindestens kurz stehen', () => { const ass = buildAss(toLines([w('kurz', 0, 0.05)]), { width: 1080, height: 1920 }); const m = /^Dialogue: 0,([\d:.]+),([\d:.]+),/m.exec(ass); assert.ok(m); assert.ok(toSeconds(m[2]!) - toSeconds(m[1]!) >= 0.29); }); test('Kopf enthaelt Aufloesung, Stil und Sicherheitsabstand', () => { const ass = buildAss(toLines([w('test', 0, 1)]), { width: 1080, height: 1920 }); assert.match(ass, /PlayResX: 1080/); assert.match(ass, /PlayResY: 1920/); // Alignment 2 = unten mittig, MarginV 340 haelt die Bedienoberflaeche frei assert.match(ass, /,2,70,70,340,1$/m); }); test('Farben werden in die ASS-Reihenfolge gedreht', () => { // RRGGBB -> &HAABBGGRR assert.equal(assColor('FFD400'), '&H0000D4FF'); assert.equal(assColor('FFFFFF'), '&H00FFFFFF'); assert.equal(assColor('000000', 0x80), '&H80000000'); }); test('Zeitformat entspricht ASS', () => { assert.equal(assTime(0), '0:00:00.00'); assert.equal(assTime(61.5), '0:01:01.50'); assert.equal(assTime(3661.239), '1:01:01.24'); // Aufrundung auf 100 Hundertstel darf nicht ".100" ergeben assert.equal(assTime(59.999), '0:01:00.00'); }); test('jedes Wort bekommt eine Karaoke-Marke', () => { const ass = buildAss(toLines([w('eins', 0, 0.5), w('zwei', 0.5, 1.0)]), { width: 1080, height: 1920 }); const line = /^Dialogue:.*,,(.*)$/m.exec(ass)?.[1] ?? ''; assert.equal((line.match(/\{\\k\d+\}/g) ?? []).length, 2); }); test('SRT enthaelt denselben Text', () => { const lines = toLines([w('hallo', 0, 0.5), w('welt', 0.5, 1.0)]); const srt = buildSrt(lines); assert.match(srt, /hallo welt/); assert.match(srt, /00:00:00,000 --> /); }); function toSeconds(t: string): number { const [h, m, s] = t.split(':'); return Number(h) * 3600 + Number(m) * 60 + Number(s); }