Add the long-form transcription pipeline behind transcribeLongForm
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
ad2eb10a28
commit
19318a56f2
4 files changed
+491
No files matched your search
@@ -0,0 +1,12 @@
|
||||
/** Long-form file transcription pipeline (D-WS3a). See pipeline.ts. */
|
||||
export { DEFAULT_GUARD, transcribeLongForm } from "./pipeline";
|
||||
export { DEFAULT_FALLBACK, type FallbackOptions } from "./quality";
|
||||
export type {
|
||||
DecodeRequest,
|
||||
DecodeResult,
|
||||
LongFormDeps,
|
||||
LongFormOptions,
|
||||
LongFormResult,
|
||||
RepetitionGuard,
|
||||
} from "./types";
|
||||
export { DEFAULT_VAD_OPTIONS, type VadOptions } from "./vad";
|
||||
@@ -0,0 +1,166 @@
|
||||
/**
|
||||
* Long-form file transcription (D-WS3a), mirroring faster-whisper's batched
|
||||
* pipeline without the batching: Silero VAD drops silence and cuts the audio
|
||||
* on speech boundaries into chunks ≤ 30 s; each chunk is one Whisper decode
|
||||
* (no overlapping windows to stitch); the language is detected once on the
|
||||
* first speech chunk and pinned; an optional quality check retries a bad
|
||||
* decode at higher temperatures.
|
||||
*
|
||||
* Memory (plan 001-0 Review Focus 1): VAD reads the caller's buffer in
|
||||
* place; only the chunk being decoded is copied (≤ 30 s), and the only state
|
||||
* that grows with the file is the transcript itself.
|
||||
*/
|
||||
|
||||
import type { ProgressWindow, TranscriptChunk } from "../protocol";
|
||||
import { TranscribeProgressTracker } from "../protocol";
|
||||
import { compressionRatio, DEFAULT_FALLBACK, needsFallback, type FallbackOptions } from "./quality";
|
||||
import type {
|
||||
DecodeRequest,
|
||||
DecodeResult,
|
||||
LongFormDeps,
|
||||
LongFormOptions,
|
||||
LongFormResult,
|
||||
RepetitionGuard,
|
||||
} from "./types";
|
||||
import {
|
||||
chunkAudio,
|
||||
chunkTimeToOriginal,
|
||||
collectChunks,
|
||||
getSpeechTimestamps,
|
||||
SAMPLE_RATE,
|
||||
type SpeechChunk,
|
||||
} from "./vad";
|
||||
|
||||
/** Whisper's window: no chunk is longer. */
|
||||
const MAX_CHUNK_SECONDS = 30;
|
||||
|
||||
/** No guard: Whisper's own decoding (the measured default, D-WS3a). */
|
||||
export const DEFAULT_GUARD: RepetitionGuard = { noRepeatNgramSize: 0, repetitionPenalty: 1 };
|
||||
|
||||
export async function transcribeLongForm(
|
||||
audio: Float32Array,
|
||||
deps: LongFormDeps,
|
||||
options: LongFormOptions = {},
|
||||
): Promise<LongFormResult> {
|
||||
const totalSeconds = audio.length / SAMPLE_RATE;
|
||||
options.onProgress?.(0, totalSeconds);
|
||||
|
||||
const probs = await deps.speechProbabilities(audio);
|
||||
const spans = getSpeechTimestamps(probs, audio.length, {
|
||||
...options.vad,
|
||||
maxSpeechDurationS: Math.min(options.vad?.maxSpeechDurationS ?? MAX_CHUNK_SECONDS, MAX_CHUNK_SECONDS),
|
||||
});
|
||||
const chunks = collectChunks(spans, MAX_CHUNK_SECONDS * SAMPLE_RATE);
|
||||
|
||||
const tracker = TranscribeProgressTracker.forWindows(totalSeconds, chunks.map(progressWindow(chunks, totalSeconds)));
|
||||
const report = (processed: number | null) => {
|
||||
if (processed !== null) options.onProgress?.(processed, totalSeconds);
|
||||
};
|
||||
|
||||
const guard = options.guard ?? DEFAULT_GUARD;
|
||||
const fallback: FallbackOptions | null =
|
||||
options.fallback === false || options.fallback === undefined
|
||||
? null
|
||||
: { ...DEFAULT_FALLBACK, ...options.fallback };
|
||||
let language =
|
||||
options.language === undefined || options.language === "auto" ? undefined : options.language;
|
||||
|
||||
const texts: string[] = [];
|
||||
const outChunks: TranscriptChunk[] = [];
|
||||
let decodeCalls = 0;
|
||||
let generatedTokens = 0;
|
||||
let speechSamples = 0;
|
||||
|
||||
for (const chunk of chunks) {
|
||||
const chunkPcm = chunkAudio(audio, chunk);
|
||||
speechSamples += chunk.samples;
|
||||
if (language === undefined && deps.detectLanguage) {
|
||||
language = await deps.detectLanguage(chunkPcm);
|
||||
}
|
||||
const base: DecodeRequest = {
|
||||
...(language !== undefined && { language }),
|
||||
...(options.task !== undefined && { task: options.task }),
|
||||
...(options.prompt !== undefined && { prompt: options.prompt }),
|
||||
temperature: 0,
|
||||
noRepeatNgramSize: guard.noRepeatNgramSize,
|
||||
repetitionPenalty: guard.repetitionPenalty,
|
||||
...(options.onPartial && { onPartial: options.onPartial }),
|
||||
onTimestamp: (t) => report(tracker.timestamp(t)),
|
||||
};
|
||||
const attempts = fallback?.temperatures ?? [0];
|
||||
const tried: { result: DecodeResult; ratio: number }[] = [];
|
||||
let chosen: DecodeResult | null = null;
|
||||
for (const temperature of attempts) {
|
||||
const result = await deps.decode(chunkPcm, { ...base, temperature });
|
||||
decodeCalls += 1;
|
||||
generatedTokens += result.generatedTokens;
|
||||
if (!fallback) {
|
||||
chosen = result;
|
||||
break;
|
||||
}
|
||||
const ratio = await compressionRatio(result.text);
|
||||
if (!needsFallback({ compressionRatio: ratio, avgLogProb: result.avgLogProb }, fallback)) {
|
||||
chosen = result;
|
||||
break;
|
||||
}
|
||||
tried.push({ result, ratio });
|
||||
}
|
||||
chosen ??= bestAttempt(tried, fallback!);
|
||||
|
||||
const text = chosen.text.trim();
|
||||
if (text.length > 0) texts.push(text);
|
||||
for (const segment of chosen.chunks) {
|
||||
const [start, end] = segment.timestamp;
|
||||
outChunks.push({
|
||||
text: segment.text,
|
||||
timestamp: [
|
||||
chunkTimeToOriginal(chunk, start),
|
||||
end === null ? null : chunkTimeToOriginal(chunk, end),
|
||||
],
|
||||
});
|
||||
}
|
||||
report(tracker.finalizeWindow());
|
||||
}
|
||||
if (chunks.length === 0) report(tracker.finalizeWindow());
|
||||
|
||||
return {
|
||||
text: texts.join(" "),
|
||||
chunks: outChunks,
|
||||
...(language !== undefined && { language }),
|
||||
noSpeech: chunks.length === 0,
|
||||
speechSeconds: speechSamples / SAMPLE_RATE,
|
||||
decodeCalls,
|
||||
generatedTokens,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Every temperature failed: like faster-whisper, prefer attempts under the
|
||||
* compression threshold, then the highest average log-prob (unknown counts
|
||||
* as lowest), then the lowest compression ratio.
|
||||
*/
|
||||
function bestAttempt(
|
||||
tried: { result: DecodeResult; ratio: number }[],
|
||||
fallback: FallbackOptions,
|
||||
): DecodeResult {
|
||||
const underThreshold = tried.filter((t) => t.ratio <= fallback.compressionRatioThreshold);
|
||||
const pool = underThreshold.length > 0 ? underThreshold : tried;
|
||||
return pool.reduce((best, t) => {
|
||||
const a = best.result.avgLogProb ?? -Infinity;
|
||||
const b = t.result.avgLogProb ?? -Infinity;
|
||||
if (b > a || (b === a && t.ratio < best.ratio)) return t;
|
||||
return best;
|
||||
}).result;
|
||||
}
|
||||
|
||||
function progressWindow(chunks: SpeechChunk[], totalSeconds: number) {
|
||||
return (chunk: SpeechChunk, index: number): ProgressWindow => {
|
||||
const next = chunks[index + 1];
|
||||
const last = chunk.spans[chunk.spans.length - 1]!;
|
||||
return {
|
||||
toAbsolute: (t) => chunkTimeToOriginal(chunk, t),
|
||||
endSeconds: last.end / SAMPLE_RATE,
|
||||
nextStartSeconds: next ? next.spans[0]!.start / SAMPLE_RATE : totalSeconds,
|
||||
};
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
/**
|
||||
* The long-form pipeline's interface (D-WS3a, coordination plan 001-0). The
|
||||
* pipeline is model-agnostic: everything that runs a model comes in through
|
||||
* `LongFormDeps`, so a different implementation (a future Rust→WASM build,
|
||||
* or batched decoding) can replace either side without touching the other.
|
||||
*/
|
||||
|
||||
import type { TranscriptChunk, WhisperTask } from "../protocol";
|
||||
import type { FallbackOptions } from "./quality";
|
||||
import type { VadOptions } from "./vad";
|
||||
|
||||
/** One Whisper decode of a ≤ 30 s chunk. */
|
||||
export interface DecodeRequest {
|
||||
/** Pinned language code; undefined for English-only models. */
|
||||
language?: string;
|
||||
task?: WhisperTask;
|
||||
/** The user's names-and-terms hint (workstream 2); undefined = none. */
|
||||
prompt?: string;
|
||||
/** 0 = greedy; > 0 = sampling (temperature fallback). */
|
||||
temperature: number;
|
||||
/** 0 = off. */
|
||||
noRepeatNgramSize: number;
|
||||
/** 1 = off. */
|
||||
repetitionPenalty: number;
|
||||
/** Streamed text fragments. */
|
||||
onPartial?(text: string): void;
|
||||
/** Whisper timestamp tokens as they are generated, chunk-relative seconds. */
|
||||
onTimestamp?(chunkSeconds: number): void;
|
||||
}
|
||||
|
||||
export interface DecodeResult {
|
||||
text: string;
|
||||
/** Segments with chunk-relative timestamps. */
|
||||
chunks: TranscriptChunk[];
|
||||
/** Tokens the decoder generated (prompt excluded). */
|
||||
generatedTokens: number;
|
||||
/** Mean log-probability of the generated tokens; null when not measured. */
|
||||
avgLogProb: number | null;
|
||||
}
|
||||
|
||||
export interface LongFormDeps {
|
||||
/** Silero VAD speech probability per 512-sample window of `audio`. */
|
||||
speechProbabilities(audio: Float32Array): Promise<Float32Array>;
|
||||
decode(audio: Float32Array, request: DecodeRequest): Promise<DecodeResult>;
|
||||
/** Language code for `audio`; absent for English-only models. */
|
||||
detectLanguage?(audio: Float32Array): Promise<string>;
|
||||
}
|
||||
|
||||
/** Decoder-side repetition guard (D-035's proposal; D-WS3a measured it). */
|
||||
export interface RepetitionGuard {
|
||||
noRepeatNgramSize: number;
|
||||
repetitionPenalty: number;
|
||||
}
|
||||
|
||||
export interface LongFormOptions {
|
||||
/** Whisper language code; undefined/"auto" detects once, then pins it. */
|
||||
language?: string;
|
||||
task?: WhisperTask;
|
||||
/** Passed to every decode; a no-op when undefined. */
|
||||
prompt?: string;
|
||||
vad?: Partial<VadOptions>;
|
||||
guard?: RepetitionGuard;
|
||||
/** Temperature fallback; `false` disables it, `{}` uses the defaults. */
|
||||
fallback?: Partial<FallbackOptions> | false;
|
||||
onPartial?(text: string): void;
|
||||
onProgress?(processedSeconds: number, totalSeconds: number): void;
|
||||
}
|
||||
|
||||
export interface LongFormResult {
|
||||
text: string;
|
||||
/** Segments with timestamps on the original file's timeline. */
|
||||
chunks: TranscriptChunk[];
|
||||
/** The language every chunk was decoded in (detected or given). */
|
||||
language?: string;
|
||||
/** VAD found no speech at all — nothing was decoded. */
|
||||
noSpeech: boolean;
|
||||
/** Seconds of audio actually decoded (speech + padding). */
|
||||
speechSeconds: number;
|
||||
/** Decoder calls, fallback retries included. */
|
||||
decodeCalls: number;
|
||||
/** Generated tokens, fallback retries included. */
|
||||
generatedTokens: number;
|
||||
}
|
||||
Reference in new issue
Block a user