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:
ZeasyAlexandClaude Opus 5 committed 2026-09-09 10:55:54 +02:00
1 parent 0e020f3073
commit 974dfd075b
11 files changed
+824 -33

No files matched your search

+2
View File
@@ -37,6 +37,7 @@ export type Config = {
tailSeconds: number;
audio: { transcribeTrack: number; outputTrack: number };
ffmpeg: { encoder: string; preset: string; crf: number; niceness: number };
whisper: { model: string; language: string; threads: number };
};
ingestServer: { port: number };
dashboard: { port: number };
@@ -71,6 +72,7 @@ const DEFAULTS = {
// Hier zaehlen die Positionen unter den Audiospuren, also 0-basiert.
audio: { transcribeTrack: 1, outputTrack: 0 },
ffmpeg: { encoder: 'libx264', preset: 'veryfast', crf: 20, niceness: 15 },
whisper: { model: 'ggml-small.bin', language: 'de', threads: 4 },
},
ingestServer: { port: 4790 },
dashboard: { port: 4792 },
+154
View File
@@ -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);
}
+223
View File
@@ -0,0 +1,223 @@
// Untertitel im ASS-Format mit wortweisem Hervorheben.
//
// Warum ASS und nicht SRT: SRT kann nur ganze Blöcke ein- und ausblenden. Der
// Stil, der auf Shorts funktioniert, hebt das gerade gesprochene Wort hervor -
// dafuer gibt es in ASS die Karaoke-Marke \k. Sie faerbt eine Silbe zum
// richtigen Zeitpunkt von SecondaryColour nach PrimaryColour um.
//
// Der Sicherheitsabstand nach unten ist keine Kosmetik: TikTok, Reels und
// Shorts legen ihre eigene Bedienoberflaeche ueber das untere Bilddrittel.
import type { Word } from './whisper.ts';
export type SubtitleStyle = {
fontName: string;
fontSize: number;
/** Farbe, sobald das Wort gesprochen ist (RGB). */
highlight: string;
/** Farbe, solange das Wort noch nicht dran ist (RGB). */
base: string;
outline: number;
shadow: number;
/** Abstand zum unteren Bildrand in Pixeln. */
marginBottom: number;
marginSide: number;
};
export const DEFAULT_STYLE: SubtitleStyle = {
fontName: 'DejaVu Sans',
fontSize: 76,
highlight: 'FFD400',
base: 'FFFFFF',
outline: 6,
shadow: 3,
// 1920 hoch, unten bleiben 340 px frei - dort sitzt die Bedienoberflaeche
// der Plattformen (Beschreibung, Knoepfe, Musiktitel).
marginBottom: 340,
marginSide: 70,
};
export type LineOptions = {
maxWords: number;
maxSeconds: number;
maxChars: number;
};
// maxSeconds war anfangs 2,2 s. An echtem Material zeigte sich, dass bei
// langsamem oder zoegerndem Sprechen dann Zeilen mit einem einzigen Wort
// entstehen ("Dank" / "es," / "Savina,"). 3,2 s plus das Nachverdichten
// weiter unten ergibt lesbare Zeilen.
export const DEFAULT_LINES: LineOptions = { maxWords: 5, maxSeconds: 3.2, maxChars: 30 };
export type Line = { words: Word[]; start: number; end: number };
/** Geraeuschbeschreibungen von Whisper: *Lachen*, [Musik], (Applaus). */
const NOISE = /^[*[(].*[*\])]$/;
export function isNoise(text: string): boolean {
return NOISE.test(text.trim());
}
/** `RRGGBB` -> `&HAABBGGRR` (ASS speichert Farben umgedreht und mit Alpha). */
export function assColor(rgb: string, alpha = 0): string {
const v = rgb.replace('#', '').padStart(6, '0');
const r = v.slice(0, 2), g = v.slice(2, 4), b = v.slice(4, 6);
return `&H${alpha.toString(16).padStart(2, '0').toUpperCase()}${b}${g}${r}`.toUpperCase();
}
/**
* Sekunden -> `H:MM:SS.cc`
*
* Erst auf Hundertstel runden, DANN zerlegen. Andersherum kann die Rundung
* 100 Hundertstel ergeben, und der Uebertrag laeuft nicht in die Minuten -
* aus 59,999 s wurde `0:00:60.00`.
*/
export function assTime(seconds: number): string {
const totalCs = Math.max(0, Math.round(seconds * 100));
const cs = totalCs % 100;
const totalSec = (totalCs - cs) / 100;
const s = totalSec % 60;
const m = Math.floor(totalSec / 60) % 60;
const h = Math.floor(totalSec / 3600);
return `${h}:${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}.` +
String(cs).padStart(2, '0');
}
/**
* Bricht den Wortstrom in kurze Zeilen. Kurz ist hier wichtiger als
* grammatisch sauber - auf einem Handy liest niemand fuenf Woerter in
* anderthalb Sekunden.
*/
export function toLines(words: Word[], opts: LineOptions = DEFAULT_LINES): Line[] {
const lines: Line[] = [];
let current: Word[] = [];
const flush = () => {
if (!current.length) return;
lines.push({ words: current, start: current[0]!.start, end: current[current.length - 1]!.end });
current = [];
};
for (const w of words.filter((x) => !isNoise(x.text))) {
const wouldBeChars = current.reduce((n, x) => n + x.text.length + 1, 0) + w.text.length;
const wouldBeSeconds = current.length ? w.end - current[0]!.start : 0;
if (current.length && (
current.length >= opts.maxWords ||
wouldBeChars > opts.maxChars ||
wouldBeSeconds > opts.maxSeconds ||
// Eine Pause von mehr als einer halben Sekunde ist ein Satzende,
// egal was die Zeichensetzung sagt
w.start - current[current.length - 1]!.end > 0.5
)) flush();
current.push(w);
if (/[.!?]$/.test(w.text)) flush();
}
flush();
return mergeStragglers(lines, opts);
}
/**
* Einzelne Woerter, die allein auf einer Zeile stehen, an den Nachbarn haengen.
* Eine Zeile mit einem Wort steht anderthalb Sekunden im Bild und wirkt wie ein
* Fehler - selbst wenn sie dadurch etwas laenger wird als die Obergrenze.
*/
function mergeStragglers(lines: Line[], opts: LineOptions): Line[] {
const out: Line[] = [];
for (const line of lines) {
const prev = out[out.length - 1];
const chars = (l: Line) => l.words.reduce((n, w) => n + w.text.length + 1, 0);
const soloAndClose = prev && line.words.length === 1 &&
line.start - prev.end < 0.6 &&
prev.words.length + 1 <= opts.maxWords + 1 &&
chars(prev) + chars(line) <= opts.maxChars + 8;
if (soloAndClose) {
prev.words.push(...line.words);
prev.end = line.end;
} else {
out.push(line);
}
}
return out;
}
export function buildAss(
lines: Line[],
opts: { width: number; height: number; style?: Partial<SubtitleStyle> }
): string {
const s: SubtitleStyle = { ...DEFAULT_STYLE, ...opts.style };
const header = [
'[Script Info]',
'ScriptType: v4.00+',
`PlayResX: ${opts.width}`,
`PlayResY: ${opts.height}`,
'WrapStyle: 2',
'ScaledBorderAndShadow: yes',
'',
'[V4+ Styles]',
'Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, ' +
'BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, ' +
'BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding',
[
'Style: Short', s.fontName, s.fontSize,
assColor(s.highlight), // PrimaryColour: schon gesprochen
assColor(s.base), // SecondaryColour: noch nicht dran
assColor('000000'), // Outline
assColor('000000', 0x80), // BackColour
-1, 0, 0, 0, // Bold, Italic, Underline, StrikeOut
100, 100, 0, 0, // ScaleX, ScaleY, Spacing, Angle
1, s.outline, s.shadow, // BorderStyle, Outline, Shadow
2, // Alignment: unten mittig
s.marginSide, s.marginSide, s.marginBottom,
1,
].join(','),
'',
'[Events]',
'Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text',
];
const events = lines.map((line, li) => {
// Nachlauf, damit die Zeile nicht im Moment des letzten Lautes verschwindet -
// aber nie in die naechste Zeile hinein. Zwei gleichzeitig sichtbare
// Dialoge stapelt ASS uebereinander, und genau das sieht kaputt aus.
const next = lines[li + 1];
const end = next ? Math.min(line.end + 0.25, next.start) : line.end + 0.4;
const text = line.words.map((w, i) => {
const nextWord = line.words[i + 1];
// Die Marke laeuft bis zum Beginn des naechsten Wortes, damit Pausen
// innerhalb der Zeile nicht als Sprung wirken. Das letzte Wort haelt
// bis zum Zeilenende.
const until = nextWord ? nextWord.start : Math.max(w.end, end);
const cs = Math.max(1, Math.round((until - w.start) * 100));
return `{\\k${cs}}${escapeAss(w.text)}`;
}).join(' ');
return `Dialogue: 0,${assTime(line.start)},${assTime(Math.max(end, line.start + 0.3))},` +
`Short,,0,0,0,,${text}`;
});
return [...header, ...events, ''].join('\n');
}
function escapeAss(text: string): string {
return text.replace(/\\/g, '\\\\').replace(/\{/g, '\\{').replace(/\}/g, '\\}');
}
/** Zusaetzlich eine SRT schreiben - praktisch fuer die Upload-Beschreibung. */
export function buildSrt(lines: Line[]): string {
return lines.map((line, i) => {
const t = (sec: number) => {
const ms = Math.round(sec * 1000);
const h = Math.floor(ms / 3600000);
const m = Math.floor((ms % 3600000) / 60000);
const s = Math.floor((ms % 60000) / 1000);
return `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}:` +
`${String(s).padStart(2, '0')},${String(ms % 1000).padStart(3, '0')}`;
};
return `${i + 1}\n${t(line.start)} --> ${t(line.end)}\n` +
`${line.words.map((w) => w.text).join(' ')}\n`;
}).join('\n');
}
+11 -5
View File
@@ -67,12 +67,18 @@ export function thumbnailArgs(input: string, output: string, atSeconds = 1): str
}
/** Mikrofonspur als 16 kHz Mono WAV - genau das erwartet whisper.cpp (S4). */
export function micTrackArgs(input: string, output: string, audioTrack: number): string[] {
return [
'-y', '-hide_banner', '-loglevel', 'error',
export function micTrackArgs(
input: string, output: string, audioTrack: number, tailSeconds?: number
): string[] {
const args = ['-y', '-hide_banner', '-loglevel', 'error'];
// Denselben Ausschnitt transkribieren, der auch im Video landet -
// muss wie beim Bild vor -i stehen.
if (tailSeconds && tailSeconds > 0) args.push('-sseof', `-${tailSeconds}`);
args.push(
'-i', input,
'-map', `0:a:${audioTrack}`,
'-ac', '1', '-ar', '16000', '-c:a', 'pcm_s16le',
output,
];
output
);
return args;
}
+106
View File
@@ -0,0 +1,106 @@
// whisper.cpp aufrufen und die Ausgabe in Woerter mit Zeitstempeln umwandeln.
//
// Wir nehmen `-ojf` (JSON mit Tokens), nicht die fertige SRT: fuer das
// wortweise Hervorheben brauchen wir Zeitstempel je Wort, und die stehen nur
// in den Tokens. Tokens sind Wortstuecke - ein neues Wort beginnt dort, wo der
// Tokentext mit einem Leerzeichen anfaengt.
import fs from 'node:fs/promises';
import path from 'node:path';
import { logger } from '#core/logger.ts';
import { run } from './ffmpeg.ts';
const log = logger('whisper');
export type Word = { text: string; start: number; end: number };
export type WhisperOptions = {
binary?: string;
model: string;
language?: string;
threads?: number;
timeoutMs?: number;
};
/** Sondertokens von whisper.cpp, die nicht in den Text gehoeren. */
const SPECIAL = /^\[_[A-Z]+_?[^\]]*\]$/;
type WhisperJson = {
transcription?: Array<{
offsets?: { from: number; to: number };
text?: string;
tokens?: Array<{ text: string; offsets?: { from: number; to: number } }>;
}>;
};
export function wordsFromJson(raw: string): Word[] {
const data = JSON.parse(raw) as WhisperJson;
const words: Word[] = [];
for (const seg of data.transcription ?? []) {
const tokens = seg.tokens ?? [];
if (!tokens.length) {
// Kein Token-Detail: dann eben das ganze Segment als ein "Wort"
const text = (seg.text ?? '').trim();
if (text && seg.offsets) {
words.push({ text, start: seg.offsets.from / 1000, end: seg.offsets.to / 1000 });
}
continue;
}
let current: Word | null = null;
for (const t of tokens) {
const raw = t.text ?? '';
if (SPECIAL.test(raw.trim())) continue;
const startsWord = raw.startsWith(' ');
const piece = raw.trim();
if (!piece) continue;
const from = (t.offsets?.from ?? 0) / 1000;
const to = (t.offsets?.to ?? 0) / 1000;
if (!current || startsWord) {
if (current) words.push(current);
current = { text: piece, start: from, end: to };
} else {
current.text += piece;
current.end = to;
}
}
if (current) words.push(current);
}
// Zeitstempel monoton ziehen - vereinzelt liefert whisper.cpp Rueckspruenge
for (let i = 1; i < words.length; i++) {
const prev = words[i - 1]!;
const cur = words[i]!;
if (cur.start < prev.end) cur.start = prev.end;
if (cur.end <= cur.start) cur.end = cur.start + 0.08;
}
return words;
}
export async function transcribe(wav: string, opts: WhisperOptions): Promise<Word[]> {
const prefix = wav.replace(/\.wav$/i, '');
const args = [
'-m', opts.model,
'-f', wav,
'-l', opts.language ?? 'de',
'-t', String(opts.threads ?? 4),
'-ojf', // JSON mit Tokens
'-of', prefix,
'-np', // keine Fortschrittsausgabe
];
const started = Date.now();
await run(opts.binary ?? 'whisper-cli', args, {
timeoutMs: opts.timeoutMs ?? 15 * 60_000,
niceness: 15,
});
const jsonFile = `${prefix}.json`;
const words = wordsFromJson(await fs.readFile(jsonFile, 'utf8'));
log.info(`${words.length} Woerter erkannt in ${((Date.now() - started) / 1000).toFixed(1)} s ` +
`(${path.basename(wav)})`);
return words;
}