S4: Untertitel aus der Mikrofonspur, plus vorgegebenes OBS-Layout
whisper.cpp laeuft ueber die Tonspur und liefert Tokens; daraus werden Woerter mit Zeitstempeln (Wortstuecke wie "Sav"+"ina" wieder zusammengesetzt) und daraus kurze Zeilen im ASS-Format mit wortweiser Hervorhebung. SRT und Transkript fallen nebenbei ab. An einem echten deutschen Clip geprueft. Drei Fehler kamen erst dabei heraus: Zeilen mit einem einzigen Wort bei zoegerndem Sprechen, ein Nachlauf, der in die naechste Zeile ragte (ASS stapelt gleichzeitige Zeilen uebereinander), und ein Uebertragsfehler im Zeitformat bei 59,999 s. Layout wird jetzt VORGEGEBEN statt nachgemessen: ein Vergleich zweier Aufnahmen zeigte, dass die Facecam von Stream zu Stream anders liegt. Das Profil "default" beschreibt die Vorgabe (Facecam 480x270 oben links, Chat ab x=1405, Gameplay-Band 890 breit mittig auf x=960), "bestand-2026-09" haelt den alten Zustand fuer altes Material fest. Dazu eine Ausrichtungsvorlage als Bild, die in OBS ueber die Szene gelegt wird, und docs/obs-layout.md. Befund am echten Clip: im Streambild sind bereits zwei Textebenen eingebrannt (Spieluntertitel und ein Live-Caption-Overlay). Beides ist im Layout-Dokument behandelt. 53 Tests gruen. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013VTGjd92LsDDWF3frd9FTB
This commit is contained in:
1 parent
0e020f3073
commit
974dfd075b
11 files changed
+824
-33
No files matched your search
@@ -0,0 +1,154 @@
|
||||
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);
|
||||
}
|
||||
Reference in new issue
Block a user