feat(whisper): language detection, speech onset, contraction merging

- Auto-detect language and switch from .en to multilingual model when needed
- Detect speech onset in WAV to strip hallucinated words before speech begins
- Merge whisper-cpp token fragments: contractions (didn + 't → didn't),
  split capitals (C + aught → Caught), dropped-g (shin + in' → shinin')
- Interpolate zero-duration word clusters for reliable karaoke timing
- Replace non-null assertions with optional chaining in tests

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Vance Ingalls
2026-04-01 22:28:05 -07:00
co-authored by Claude Opus 4.6
parent afa5021b02
commit 4b0ce61438
4 changed files with 510 additions and 22 deletions
+23 -5
View File
@@ -103,7 +103,8 @@ async function transcribeAudio(
opts: { model?: string; language?: string; json?: boolean },
): Promise<void> {
const { transcribe } = await import("../whisper/transcribe.js");
const { loadTranscript, patchCaptionHtml } = await import("../whisper/normalize.js");
const { loadTranscript, patchCaptionHtml, stripBeforeOnset } =
await import("../whisper/normalize.js");
const model = opts.model ?? DEFAULT_MODEL;
const spin = opts.json ? null : clack.spinner();
@@ -116,7 +117,19 @@ async function transcribeAudio(
onProgress: spin ? (msg) => spin.message(msg) : undefined,
});
const { words } = loadTranscript(result.transcriptPath);
let { words } = loadTranscript(result.transcriptPath);
if (result.speechOnsetSeconds != null) {
const before = words.length;
words = stripBeforeOnset(words, result.speechOnsetSeconds);
const stripped = before - words.length;
if (stripped > 0 && !opts.json) {
spin?.message(
`Stripped ${stripped} words before speech onset at ${result.speechOnsetSeconds.toFixed(1)}s`,
);
}
}
writeFileSync(result.transcriptPath, JSON.stringify(words, null, 2));
patchCaptionHtml(dir, words);
@@ -127,13 +140,18 @@ async function transcribeAudio(
model,
wordCount: words.length,
durationSeconds: result.durationSeconds,
speechOnsetSeconds: result.speechOnsetSeconds,
transcriptPath: result.transcriptPath,
}),
);
} else {
spin!.stop(
const onsetNote =
result.speechOnsetSeconds != null
? ` — speech detected at ${result.speechOnsetSeconds.toFixed(1)}s`
: "";
spin?.stop(
c.success(
`Transcribed ${c.accent(String(words.length))} words (${result.durationSeconds.toFixed(1)}s)`,
`Transcribed ${c.accent(String(words.length))} words (${result.durationSeconds.toFixed(1)}s${onsetNote})`,
),
);
}
@@ -142,7 +160,7 @@ async function transcribeAudio(
if (opts.json) {
console.log(JSON.stringify({ ok: false, error: message }));
} else {
spin!.stop(c.error(`Transcription failed: ${message}`));
spin?.stop(c.error(`Transcription failed: ${message}`));
}
process.exit(1);
}
+274 -5
View File
@@ -2,7 +2,8 @@ import { describe, it, expect, afterEach } from "vitest";
import { writeFileSync, readFileSync, mkdirSync, rmSync } from "node:fs";
import { join } from "node:path";
import { tmpdir } from "node:os";
import { loadTranscript, detectFormat, patchCaptionHtml } from "./normalize.js";
import { loadTranscript, detectFormat, patchCaptionHtml, stripBeforeOnset } from "./normalize.js";
import { detectSpeechOnset } from "./transcribe.js";
function tmpFile(name: string, content: string): string {
const dir = join(tmpdir(), `hf-normalize-test-${Date.now()}`);
@@ -117,7 +118,7 @@ describe("loadTranscript", () => {
);
const { words } = loadTranscript(path);
expect(words).toHaveLength(1);
expect(words[0]!.text).toBe("Hello");
expect(words[0]?.text).toBe("Hello");
});
it("parses OpenAI Whisper API response", () => {
@@ -183,8 +184,8 @@ Short format
`;
const path = tmpFile("short.vtt", vtt);
const { words } = loadTranscript(path);
expect(words[0]!.start).toBeCloseTo(83.456, 2);
expect(words[0]!.end).toBe(120.0);
expect(words[0]?.start).toBeCloseTo(83.456, 2);
expect(words[0]?.end).toBe(120.0);
});
it("strips HTML tags from SRT/VTT", () => {
@@ -194,7 +195,7 @@ Short format
`;
const path = tmpFile("tags.srt", srt);
const { words } = loadTranscript(path);
expect(words[0]!.text).toBe("Bold and italic");
expect(words[0]?.text).toBe("Bold and italic");
});
it("passes through normalized word arrays", () => {
@@ -209,6 +210,172 @@ Short format
});
});
describe("whisper-cpp contraction merging", () => {
it("merges didn + 't into didn't", () => {
const path = tmpFile(
"contractions.json",
JSON.stringify({
transcription: [
{
tokens: [
{ text: " I", offsets: { from: 0, to: 200 } },
{ text: " didn", offsets: { from: 200, to: 500 } },
{ text: "'t", offsets: { from: 500, to: 700 } },
{ text: " know", offsets: { from: 700, to: 1000 } },
],
},
],
}),
);
const { words } = loadTranscript(path);
expect(words).toEqual([
{ text: "I", start: 0, end: 0.2 },
{ text: "didn't", start: 0.2, end: 0.7 },
{ text: "know", start: 0.7, end: 1 },
]);
});
it("merges I + 'm into I'm", () => {
const path = tmpFile(
"im.json",
JSON.stringify({
transcription: [
{
tokens: [
{ text: " I", offsets: { from: 0, to: 100 } },
{ text: "'m", offsets: { from: 100, to: 300 } },
{ text: " done", offsets: { from: 300, to: 600 } },
],
},
],
}),
);
const { words } = loadTranscript(path);
expect(words[0]?.text).toBe("I'm");
expect(words[0]?.end).toBe(0.3);
});
it("merges could + 've into could've", () => {
const path = tmpFile(
"couldve.json",
JSON.stringify({
transcription: [
{
tokens: [
{ text: " could", offsets: { from: 0, to: 400 } },
{ text: "'ve", offsets: { from: 400, to: 600 } },
{ text: " been", offsets: { from: 600, to: 900 } },
],
},
],
}),
);
const { words } = loadTranscript(path);
expect(words[0]?.text).toBe("could've");
});
});
describe("whisper-cpp fragment merging", () => {
it("merges single capital + lowercase: C + aught -> Caught", () => {
const path = tmpFile(
"fragments.json",
JSON.stringify({
transcription: [
{
tokens: [
{ text: " C", offsets: { from: 0, to: 100 } },
{ text: "aught", offsets: { from: 100, to: 500 } },
{ text: " a", offsets: { from: 500, to: 600 } },
],
},
],
}),
);
const { words } = loadTranscript(path);
expect(words[0]?.text).toBe("Caught");
expect(words[0]?.end).toBe(0.5);
expect(words).toHaveLength(2);
});
it("merges consonant + in': shin + in' -> shinin'", () => {
const path = tmpFile(
"dropg.json",
JSON.stringify({
transcription: [
{
tokens: [
{ text: " shin", offsets: { from: 0, to: 300 } },
{ text: "in'", offsets: { from: 300, to: 500 } },
],
},
],
}),
);
const { words } = loadTranscript(path);
expect(words).toHaveLength(1);
expect(words[0]?.text).toBe("shinin'");
});
});
describe("whisper-cpp zero-duration interpolation", () => {
it("interpolates a cluster of zero-duration words", () => {
const path = tmpFile(
"zerodur.json",
JSON.stringify({
transcription: [
{
tokens: [
{ text: " hello", offsets: { from: 0, to: 500 } },
{ text: " we", offsets: { from: 1000, to: 1000 } },
{ text: " are", offsets: { from: 1000, to: 1000 } },
{ text: " here", offsets: { from: 1000, to: 1000 } },
{ text: " now", offsets: { from: 1500, to: 2000 } },
],
},
],
}),
);
const { words } = loadTranscript(path);
expect(words).toHaveLength(5);
// The three zero-duration words should be spread between 0.5 and 1.5
const we = words[1] ?? { start: 0, end: 0, text: "" };
const are = words[2] ?? { start: 0, end: 0, text: "" };
const here = words[3] ?? { start: 0, end: 0, text: "" };
expect(we.start).toBeCloseTo(0.5, 1);
expect(we.end).toBeCloseTo(0.833, 1);
expect(are.start).toBeCloseTo(0.833, 1);
expect(are.end).toBeCloseTo(1.167, 1);
expect(here.start).toBeCloseTo(1.167, 1);
expect(here.end).toBeCloseTo(1.5, 1);
// Each should have positive duration
expect(we.end).toBeGreaterThan(we.start);
expect(are.end).toBeGreaterThan(are.start);
expect(here.end).toBeGreaterThan(here.start);
});
it("handles isolated zero-duration word", () => {
const path = tmpFile(
"singlezero.json",
JSON.stringify({
transcription: [
{
tokens: [
{ text: " hello", offsets: { from: 0, to: 500 } },
{ text: " I", offsets: { from: 800, to: 800 } },
{ text: " know", offsets: { from: 1000, to: 1500 } },
],
},
],
}),
);
const { words } = loadTranscript(path);
const iWord = words[1] ?? { start: 0, end: 0, text: "" };
expect(iWord.end).toBeGreaterThan(iWord.start);
expect(iWord.start).toBeCloseTo(0.5, 1);
expect(iWord.end).toBeCloseTo(1, 1);
});
});
describe("patchCaptionHtml", () => {
it("replaces const script = [] in HTML files", () => {
const dir = join(tmpdir(), `hf-patch-test-${Date.now()}`);
@@ -276,3 +443,105 @@ describe("patchCaptionHtml", () => {
expect(result).toBe(html);
});
});
describe("detectSpeechOnset", () => {
function makeSyntheticWav(
sampleRate: number,
durationSeconds: number,
energyFn: (t: number) => number,
): string {
const numSamples = Math.floor(sampleRate * durationSeconds);
const dataSize = numSamples * 2;
const buf = Buffer.alloc(44 + dataSize);
// RIFF header
buf.write("RIFF", 0);
buf.writeUInt32LE(36 + dataSize, 4);
buf.write("WAVE", 8);
buf.write("fmt ", 12);
buf.writeUInt32LE(16, 16); // chunk size
buf.writeUInt16LE(1, 20); // PCM
buf.writeUInt16LE(1, 22); // mono
buf.writeUInt32LE(sampleRate, 24);
buf.writeUInt32LE(sampleRate * 2, 28); // byte rate
buf.writeUInt16LE(2, 32); // block align
buf.writeUInt16LE(16, 34); // bits per sample
buf.write("data", 36);
buf.writeUInt32LE(dataSize, 40);
for (let i = 0; i < numSamples; i++) {
const t = i / sampleRate;
const amplitude = energyFn(t);
buf.writeInt16LE(Math.round(amplitude * 32767), 44 + i * 2);
}
const path = join(tmpdir(), `hf-wav-test-${Date.now()}-${Math.floor(Math.random() * 1e6)}.wav`);
writeFileSync(path, buf);
dirs.push(path);
return path;
}
it("detects onset when silence transitions to loud", () => {
const wavPath = makeSyntheticWav(16000, 15, (t) => (t < 5 ? 0.01 : 0.8));
const onset = detectSpeechOnset(wavPath);
expect(onset).not.toBeNull();
expect(onset!).toBeGreaterThanOrEqual(4);
expect(onset!).toBeLessThanOrEqual(7);
});
it("returns null for consistent energy throughout", () => {
const wavPath = makeSyntheticWav(16000, 10, () => 0.5);
const onset = detectSpeechOnset(wavPath);
expect(onset).toBeNull();
});
it("returns null for very short audio", () => {
const wavPath = makeSyntheticWav(16000, 2, () => 0.5);
const onset = detectSpeechOnset(wavPath);
expect(onset).toBeNull();
});
it("returns null when onset is too early (< 3s)", () => {
const wavPath = makeSyntheticWav(16000, 10, (t) => (t < 1 ? 0.01 : 0.8));
const onset = detectSpeechOnset(wavPath);
expect(onset).toBeNull();
});
});
describe("stripBeforeOnset", () => {
it("removes words before onset time", () => {
const words = [
{ text: "ghost", start: 0.5, end: 2.0 },
{ text: "alone", start: 3.0, end: 5.0 },
{ text: "Given", start: 19.0, end: 19.5 },
{ text: "the", start: 19.5, end: 20.0 },
];
const result = stripBeforeOnset(words, 18.5);
expect(result).toHaveLength(2);
expect(result[0]!.text).toBe("Given");
});
it("keeps words within 0.5s tolerance of onset", () => {
const words = [
{ text: "hello", start: 18.2, end: 18.8 },
{ text: "world", start: 19.0, end: 19.5 },
];
const result = stripBeforeOnset(words, 18.5);
expect(result).toHaveLength(2);
});
it("keeps everything when onset is 0", () => {
const words = [
{ text: "hello", start: 0.1, end: 0.5 },
{ text: "world", start: 0.6, end: 1.0 },
];
const result = stripBeforeOnset(words, 0);
expect(result).toHaveLength(2);
});
it("returns empty array when all words are before onset", () => {
const words = [
{ text: "ghost", start: 0.5, end: 2.0 },
{ text: "alone", start: 3.0, end: 5.0 },
];
const result = stripBeforeOnset(words, 20.0);
expect(result).toHaveLength(0);
});
});
+85 -6
View File
@@ -43,6 +43,67 @@ function detectJsonFormat(raw: unknown): TranscriptFormat {
// Parsers
// ---------------------------------------------------------------------------
/**
* Rejoin word fragments that whisper splits across tokens:
* - Single capital + lowercase continuation: C + aught -> Caught, G + onna -> Gonna
* - Word ending in consonant + in': shin + in' -> shinin', hid + in' -> hidin'
*/
function mergeFragments(words: Word[]): void {
for (let i = 0; i < words.length - 1; i++) {
const curr = words[i];
const next = words[i + 1];
if (!curr || !next) continue;
const isSingleLetterFragment =
curr.text.length === 1 &&
/^[A-Z]$/.test(curr.text) &&
!/^[IAO]$/.test(curr.text) &&
/^[a-z]/.test(next.text);
const shouldMerge =
isSingleLetterFragment || (/[a-z]$/.test(curr.text) && /^in'$/i.test(next.text));
if (shouldMerge) {
curr.text += next.text;
curr.end = next.end;
words.splice(i + 1, 1);
i--;
}
}
}
/**
* Distribute timestamps evenly across zero-duration word clusters.
* Whisper sometimes assigns identical start/end to sequences of words,
* making karaoke highlights flash through them instantly.
*
* Also handles malformed timestamps where start > end — these are treated
* the same as zero-duration and get interpolated from surrounding words.
*/
function interpolateZeroDuration(words: Word[]): void {
for (let i = 0; i < words.length; i++) {
const wi = words[i];
if (!wi || wi.start < wi.end) continue;
let j = i;
while (j < words.length) {
const wj = words[j];
if (!wj || wj.start < wj.end) break;
j++;
}
const clusterLen = j - i;
const prev = i > 0 ? words[i - 1] : undefined;
const prevEnd = prev ? prev.end : wi.start;
const nextWord = j < words.length ? words[j] : undefined;
const nextStart = nextWord ? nextWord.start : prevEnd + clusterLen * 0.3;
const span = nextStart - prevEnd;
const perWord = span / clusterLen;
for (let k = i; k < j; k++) {
const wk = words[k];
if (!wk) continue;
wk.start = round3(prevEnd + (k - i) * perWord);
wk.end = round3(prevEnd + (k - i + 1) * perWord);
}
i = j - 1;
}
}
function parseWhisperCpp(data: Record<string, unknown>): Word[] {
const words: Word[] = [];
const transcription = data.transcription as Array<{
@@ -54,13 +115,21 @@ function parseWhisperCpp(data: Record<string, unknown>): Word[] {
for (const seg of transcription ?? []) {
for (const token of seg.tokens ?? []) {
const text = (token.text ?? "").trim();
const rawText = token.text ?? "";
const text = rawText.trim();
if (!text || text.startsWith("[_") || text.startsWith("[BLANK")) continue;
// Merge punctuation with the previous word
const isPunctuation = /^[.,!?;:'")\]}>…–—-]+$/.test(text);
const lastWord = words[words.length - 1];
if (isPunctuation && lastWord) {
// Merge into previous word when the token is a sub-word continuation,
// trailing punctuation, or a contraction suffix.
// Whisper uses leading spaces to mark word boundaries in all languages.
const shouldMerge =
lastWord &&
(!rawText.startsWith(" ") ||
/^[.,!?;:'")\]}>…–—¡¿-]+$/.test(text) ||
/^'(t|m|s|ve|re|ll|d)$/i.test(text));
if (shouldMerge) {
lastWord.text += text;
lastWord.end = round3((token.offsets?.to ?? 0) / 1000);
continue;
@@ -73,6 +142,10 @@ function parseWhisperCpp(data: Record<string, unknown>): Word[] {
});
}
}
mergeFragments(words);
interpolateZeroDuration(words);
return words;
}
@@ -229,9 +302,15 @@ export function loadTranscript(filePath: string): { words: Word[]; format: Trans
}
/**
* Patch caption HTML files in a project directory with transcript words.
* Replaces `const script = [...]` or `const TRANSCRIPT = [...]` in <script> blocks.
* Remove words that fall before the detected speech onset.
* Whisper can hallucinate words over non-speech sections at the start of audio.
*/
export function stripBeforeOnset(words: Word[], onsetSeconds: number): Word[] {
// 0.5s tolerance: keep words whose timestamps straddle the onset boundary,
// since whisper may assign a slightly early start to the first spoken word.
return words.filter((w) => w.start >= onsetSeconds - 0.5);
}
export function patchCaptionHtml(dir: string, words: Word[]): void {
if (words.length === 0) return;
+128 -6
View File
@@ -4,6 +4,98 @@ import { join, extname } from "node:path";
import { tmpdir } from "node:os";
import { ensureWhisper, ensureModel, hasFFmpeg, DEFAULT_MODEL } from "./manager.js";
/**
* Detect the language of a WAV file using whisper's built-in language detection.
* Returns an ISO 639-1 code (e.g. "en", "es", "hi") or null if detection fails.
*/
function detectLanguage(whisperPath: string, modelPath: string, wavPath: string): string | null {
try {
const output = execFileSync(whisperPath, ["--model", modelPath, "--detect-language", wavPath], {
encoding: "utf-8",
timeout: 30_000,
stdio: ["ignore", "pipe", "pipe"],
});
const match = output.match(/auto-detected language:\s*(\w+)/);
return match?.[1] ?? null;
} catch {
return null;
}
}
function findWavDataChunk(buf: Buffer): { offset: number; size: number } | null {
if (buf.length < 12) return null;
let pos = 12; // skip RIFF header
while (pos + 8 < buf.length) {
const id = buf.toString("ascii", pos, pos + 4);
const size = buf.readUInt32LE(pos + 4);
if (id === "data") return { offset: pos + 8, size: Math.min(size, buf.length - pos - 8) };
pos += 8 + size;
if (size % 2 !== 0) pos++; // RIFF chunks are word-aligned
}
return null;
}
/**
* Detect when speech begins in a 16kHz mono WAV by finding the first
* sustained energy jump above the track's median RMS. Returns onset time in
* seconds, or null if the track has consistent energy throughout.
*/
export function detectSpeechOnset(wavPath: string): number | null {
const SAMPLE_RATE = 16000;
const WINDOW_SECONDS = 0.5;
const WINDOW_SAMPLES = SAMPLE_RATE * WINDOW_SECONDS;
const SUSTAINED_WINDOWS = 3; // 1.5s above threshold to count as onset
const SILENCE_THRESHOLD_RATIO = 0.6;
const MIN_INTRO_SECONDS = 3; // don't strip if onset is very early
try {
const buf = readFileSync(wavPath);
const dataChunk = findWavDataChunk(buf);
if (!dataChunk) return null;
const pcm = new Int16Array(buf.buffer, buf.byteOffset + dataChunk.offset, dataChunk.size / 2);
const totalWindows = Math.floor(pcm.length / WINDOW_SAMPLES);
if (totalWindows < 10) return null;
const rmsValues: number[] = [];
for (let i = 0; i < totalWindows; i++) {
const start = i * WINDOW_SAMPLES;
let sumSq = 0;
for (let j = start; j < start + WINDOW_SAMPLES; j++) {
const sample = pcm[j] ?? 0;
sumSq += sample * sample;
}
rmsValues.push(Math.sqrt(sumSq / WINDOW_SAMPLES));
}
const sorted = [...rmsValues].sort((a, b) => a - b);
const median = sorted[Math.floor(sorted.length / 2)] ?? 0;
const threshold = median * SILENCE_THRESHOLD_RATIO;
// Check if energy is fairly consistent (no clear intro) — ratio of
// first 10s average to median. If it's already close, no onset to detect.
const introAvg =
rmsValues.slice(0, Math.min(20, rmsValues.length)).reduce((a, b) => a + b, 0) /
Math.min(20, rmsValues.length);
if (introAvg >= threshold) return null;
let consecutive = 0;
for (let i = 0; i < rmsValues.length; i++) {
if ((rmsValues[i] ?? 0) >= threshold) {
consecutive++;
if (consecutive >= SUSTAINED_WINDOWS) {
const onsetSeconds = (i - SUSTAINED_WINDOWS + 1) * WINDOW_SECONDS;
return onsetSeconds >= MIN_INTRO_SECONDS ? onsetSeconds : null;
}
} else {
consecutive = 0;
}
}
} catch {
// Can't read WAV — skip onset detection
}
return null;
}
const AUDIO_EXTENSIONS = new Set([".mp3", ".wav", ".m4a", ".aac", ".ogg", ".flac"]);
const VIDEO_EXTENSIONS = new Set([".mp4", ".webm", ".mov", ".mkv", ".avi"]);
@@ -17,6 +109,7 @@ export interface TranscribeResult {
transcriptPath: string;
wordCount: number;
durationSeconds: number;
speechOnsetSeconds: number | null;
}
function isAudioFile(filePath: string): boolean {
@@ -121,29 +214,53 @@ export async function transcribe(
throw new Error(`Unsupported file type: ${ext}`);
}
// 4. Run whisper
// 4. Detect language and ensure correct model
let effectiveModel = model;
let effectiveModelPath = modelPath;
let detectedLanguage = options?.language ?? null;
// Only auto-detect language when using a multilingual model.
// .en models always report "en" regardless of actual language, so detection
// would be a no-op. If the user chose .en, they want English.
if (!detectedLanguage && !effectiveModel.endsWith(".en")) {
options?.onProgress?.("Detecting language...");
detectedLanguage = detectLanguage(whisper.executablePath, effectiveModelPath, wavPath);
}
if (detectedLanguage && detectedLanguage !== "en" && effectiveModel.endsWith(".en")) {
const multilingualModel = effectiveModel.replace(/\.en$/, "");
options?.onProgress?.(
`Detected ${detectedLanguage} — switching to ${multilingualModel} model...`,
);
effectiveModelPath = await ensureModel(multilingualModel, {
onProgress: options?.onProgress,
});
effectiveModel = multilingualModel;
}
// 5. Run whisper
options?.onProgress?.("Transcribing...");
const outputBase = join(outputDir, "transcript");
mkdirSync(outputDir, { recursive: true });
const whisperArgs = [
"--model",
modelPath,
effectiveModelPath,
"--output-json-full",
"--output-file",
outputBase,
"--dtw",
model,
effectiveModel,
"--suppress-nst",
];
if (options?.language) {
whisperArgs.push("--language", options.language);
if (detectedLanguage) {
whisperArgs.push("--language", detectedLanguage);
}
whisperArgs.push(wavPath);
execFileSync(whisper.executablePath, whisperArgs, { stdio: "ignore", timeout: 300_000 });
// 5. Read and validate output
// 6. Read and validate output
const transcriptPath = `${outputBase}.json`;
if (!existsSync(transcriptPath)) {
throw new Error("Whisper did not produce output. Check the input file.");
@@ -162,6 +279,10 @@ export async function transcribe(
}
}
// 7. Detect speech onset before cleaning up the WAV
options?.onProgress?.("Detecting speech onset...");
const speechOnsetSeconds = detectSpeechOnset(wavPath);
// Clean up temp WAV if we created one
if (wavPath !== inputPath) {
try {
@@ -175,6 +296,7 @@ export async function transcribe(
transcriptPath,
wordCount,
durationSeconds: maxEnd / 1000,
speechOnsetSeconds,
};
}