Files
abdulive/packages/media/src/whisper.ts
T
ZeasyAlexandClaude Opus 5 974dfd075b 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
2026-09-09 10:55:54 +02:00

107 lines
3.2 KiB
TypeScript

// 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;
}