The single-file transcribe() lesson handles short clips. A 90-minute podcast or a two-hour meeting is too long for one inference pass: the engine's working memory budget is fixed, and the audio path is just bytes. Long audio has to be split.
Splitting by fixed time intervals places cuts in the middle of sentences. Cutting at silence boundaries keeps each chunk a coherent unit of speech. ffmpeg's silencedetect filter finds them; we read the silence timestamps, pick cuts near our target length, and run transcribe() once per chunk.
Keep the file lesson's Parakeet load. The same checkpoint and addon setup carries over:
const modelId = await loadModel({
modelSrc: PARAKEET_TDT_0_6B_V3_Q8_0,
modelType: "parakeet-transcription",
});The audio path comes from process.argv[2] with a fallback to the repo's sample WAV, and the script exits with a usage hint if the file is missing:
const audioFilePath =
process.argv[2] ?? "./examples/qvac/transcription/input/sample-16khz.wav";
if (!fs.existsSync(audioFilePath)) {
console.error(`✖ ${audioFilePath} does not exist`);
console.error(`Usage: tsx <file>.ts <audio-file>`);
process.exit(1);
}
// Gate on ffmpeg and ffprobe at startup. spawnSync's error.code is the
// canonical check for "not on PATH"; a non-zero exit is a different problem.
for (const tool of ["ffmpeg", "ffprobe"]) {
const check = spawnSync(tool, ["-version"], { stdio: "ignore" });
if (check.error?.code === "ENOENT") {
console.error(`✖ ${tool} not found on PATH`);
process.exit(1);
}
}With the audio path in hand, the next step is figuring out where to cut. ffmpeg's silencedetect filter writes silence_start and silence_end lines to stderr as it scans the file; pick those up and turn them into { start, end } intervals. The detection pass looks like so:
const result = await runCommand("ffmpeg", [
"-hide_banner", "-nostats", "-i", audioPath,
"-vn", "-af", `silencedetect=noise=${SILENCE_NOISE_DB}dB:d=${SILENCE_DURATION_S}`,
"-f", "null", "-",
]);
const silences: SilenceInterval[] = [];
let currentStart: number | null = null;
for (const line of result.stderr.split("\n")) {
const startMatch = line.match(/silence_start:\s*([0-9.]+)/);
if (startMatch?.[1]) currentStart = Number.parseFloat(startMatch[1]);
const endMatch = line.match(/silence_end:\s*([0-9.]+)/);
if (endMatch?.[1] && currentStart !== null) {
const end = Number.parseFloat(endMatch[1]);
if (Number.isFinite(end) && end > currentStart) {
silences.push({ start: currentStart, end });
}
currentStart = null;
}
}noise=-35dB is the threshold below which ffmpeg counts audio as silence; d=0.5 requires at least half a second of silence to flag an interval. Both constants tune how chatty the cuts become.
Use those intervals to keep every segment under 60 seconds while cutting near the 45-second target. The 60 s cap bounds the inference memory; the 45 s target is the largest cut the loop can usually make before the silence search has to backtrack. The result is the ranges transcribe() can process independently:
const TARGET_SEGMENT_S = 45;
const MAX_SEGMENT_S = 60;
const MIN_SEGMENT_S = 10;
function createAudioSegments(duration: number, silences: SilenceInterval[]) {
const silenceMidpoints = silences.map(({ start, end }) => start + (end - start) / 2);
const segments: AudioSegment[] = [];
let start = 0;
while (duration - start > MAX_SEGMENT_S) {
const target = start + TARGET_SEGMENT_S;
const minimumCut = start + MIN_SEGMENT_S;
const maximumCut = start + MAX_SEGMENT_S;
const candidates = silenceMidpoints.filter(
(c) => c >= minimumCut && c <= maximumCut,
);
const first = candidates[0];
const cut =
first === undefined
? Math.min(target, duration - MIN_SEGMENT_S)
: candidates.slice(1).reduce((best, c) =>
Math.abs(c - target) < Math.abs(best - target) ? c : best,
first);
segments.push({ start, end: cut });
start = cut;
}
if (duration > start) segments.push({ start, end: duration });
return segments;
}If no silence falls inside the window, fall back to a 45 s cut and let the next pass cover the rest. The MIN_SEGMENT_S lower bound stops a cut from ending up before the engine has enough audio to recognize speech.
With the ranges fixed, the next step is decoding each one to the bytes the model consumes. ffmpeg's -ss selects the start, -t selects the duration, and the output pipe carries s16le 16 kHz mono PCM straight to transcribe(). The per-segment call looks like so:
async function decodeSegment(audioPath: string, segment: AudioSegment) {
return (await runCommand("ffmpeg", [
"-hide_banner", "-loglevel", "error",
"-ss", segment.start.toFixed(3),
"-i", audioPath,
"-t", (segment.end - segment.start).toFixed(3),
"-vn", "-ar", "16000", "-ac", "1",
"-sample_fmt", "s16", "-f", "s16le", "pipe:1",
])).stdout;
}
for (const [index, segment] of segments.entries()) {
console.log(
`▸ Segment ${index + 1}/${segments.length}: ${formatTimestamp(segment.start)} - ${formatTimestamp(segment.end)}`,
);
const audioChunk = await decodeSegment(audioFilePath, segment);
const text = await transcribe({ modelId, audioChunk });
const normalized = text.trim();
if (normalized.length > 0 && !normalized.includes("[No speech detected]")) {
transcript.push(normalized);
console.log(normalized);
}
}Each -ss / -t pair bounds one decode, so the returned 16 kHz mono PCM matches transcribe(). Join the per-segment text only after every range has completed:
console.log("\n▸ Complete transcript");
console.log(transcript.join(" "));
await unloadModel({ modelId });unloadModel runs after the loop, so the model stays loaded across all segment calls. Each segment reuses the same modelId, no reload between calls.
Note: the audio format passed to ffmpeg is whatever the source file is. ffmpeg normalizes anything it understands to the requested
-ar 16000 -ac 1 -sample_fmt s16output. If the input is a WAV with a 44-byte header, the-ssflag seeks into the PCM range correctly; if it is a compressed format like MP3, ffmpeg's seek accuracy drops slightly. For transcript-quality boundaries the 0.3 s rounding fromtoFixed(3)is fine either way.
Question 1 of 2
What is the primary advantage of cutting long audio at silence boundaries instead of fixed time intervals?
Question 2 of 2
What happens if no detected silence falls inside the window between minimumCut and maximumCut?
Run your code, check your answer, or ask a question. It all shows up here.