The Parakeet batch lesson waits for fixed-size windows before each transcribe() call. The streaming version of Parakeet uses a duplex transcribeStream() session instead, the same pattern as the Whisper microphone lesson, but with a Parakeet-specific config and an end-of-turn model underneath.
Use the EOU checkpoint when the stream itself should mark turn boundaries. Pair it with parakeetStreamingConfig so one session produces both text and endOfTurn events:
const modelId = await loadModel({ modelSrc: PARAKEET_EOU_120M_V1_Q8_0 });
const session = await transcribeStream({
modelId,
parakeetStreamingConfig: {
chunkMs: 1000,
emitPartials: true,
},
});chunkMs: 1000 is the model's lookahead window. The EOU checkpoint decides whether to commit the buffered audio as a turn or hold it open for more speech. emitPartials: true makes the session produce in-progress text chunks before the final commit; set it to false to wait until each turn closes.
Keep the batch lesson's ffmpeg setup, but send each 16 kHz mono s16le chunk into the open streaming session:
ffmpeg.stdout.on("data", (chunk: Buffer) => {
try {
session.write(chunk);
} catch {
// session.write throws during teardown; swallow it.
}
});The try/catch around session.write ignores the abort when the worker is being torn down at shutdown. Without it, a Ctrl+C raises an unhandled stream error.
Read the session as an async iterable and branch on its event type. Parakeet returns text events and turn-boundary events, unlike Whisper's TranscribeSegment objects. The model emits a stream of partials while the user is still talking; each partial is a candidate for the final text, and the next partial supersedes it. Writing each partial straight to stdout races the next one and the user sees interleaved characters with stray whitespace. Debounce the writes instead: hold each partial for a short window, replace it if a new one comes through, then write the latest version once. The EOU boundary flushes whatever is pending so the final partial of the turn ends up on its own line before the [endOfTurn] marker. The tokenizer also leaves raw whitespace runs in partials, so collapse them before writing. The event loop looks as follows:
const PARTIAL_DEBOUNCE_MS = 80;
let pendingText: string | null = null;
let pendingTimer: NodeJS.Timeout | null = null;
// Collapse runs of whitespace the tokenizer leaves in raw partials.
function collapseSpacing(s: string): string {
return s.replace(/\s+/g, " ").trim();
}
function flushPending() {
if (pendingTimer) {
clearTimeout(pendingTimer);
pendingTimer = null;
}
if (pendingText !== null) {
process.stdout.write(`\n${collapseSpacing(pendingText)}`);
pendingText = null;
}
}
function schedulePartial(text: string) {
const cleaned = collapseSpacing(text);
if (cleaned.length === 0) return;
pendingText = cleaned;
if (pendingTimer) return;
pendingTimer = setTimeout(() => {
pendingTimer = null;
if (pendingText !== null) {
process.stdout.write(`\n${pendingText}`);
pendingText = null;
}
}, PARTIAL_DEBOUNCE_MS);
}
for await (const event of session) {
if (event.type === "text") {
const trimmed = event.text.trim();
if (trimmed.length > 0) {
schedulePartial(event.text);
}
} else if (event.type === "endOfTurn") {
flushPending();
console.log("\n▸ [endOfTurn] turn boundary detected");
}
}The text events stream in as partials (when emitPartials: true) or as the final committed string (when false). The endOfTurn event fires once per utterance the EOU model commits. Pair the two: schedule each partial on its own line, then log the turn boundary so the UI knows to insert a hard line break.
A live microphone needs a single exit path. Let cleanup() kill ffmpeg, end the session, and unload the model when SIGINT or SIGTERM fires. The SDK worker logs teardown chatter to stderr after unloadModel ("SDK is shutting down", "Transcription failed: Model was unloaded", and similar). Wrap process.stderr.write at the top of the script so every teardown path is covered, not just cleanup():
// Drop the SDK's teardown chatter from stderr.
const realStderrWrite = process.stderr.write.bind(process.stderr);
(process.stderr.write as unknown) = (chunk: string | Buffer, ...rest: unknown[]) => {
const s = typeof chunk === "string" ? chunk : chunk.toString("utf8");
if (/SDK is shutting down|is shutting down soon/i.test(s)) return true;
if (/in-flight rpc|worker exited mid-request/i.test(s)) return true;
if (/model was unloaded|model.*unloaded/i.test(s)) return true;
if (/transcription failed|translation failed|tts failed|text-to-speech failed/i.test(s)) return true;
if (/stream aborted|stream.*aborted/i.test(s)) return true;
return realStderrWrite(chunk, ...(rest as []));
};
async function cleanup() {
if (shuttingDown) return;
shuttingDown = true;
ffmpeg.kill();
try { session.end(); } catch {}
await unloadModel({ modelId }).catch(() => {});
process.exit(0);
}
process.on("SIGINT", () => void cleanup());
process.on("SIGTERM", () => void cleanup());session.end() is fire-and-forget; it doesn't await drain. The stderr filter is global so anything the SDK writes during teardown is dropped. unloadModel().catch(() => {}) swallows the WorkerShutdownError that fires on the in-flight RPC drain after Ctrl+C.
Note: Parakeet's streaming path does not produce standalone VAD events the way Whisper's session does. Voice-activity detection is folded into the EOU model, and turn boundaries come back as
endOfTurnevents instead. If you need explicit VAD events, pairPARAKEET_EOU_120M_V1_Q8_0withVAD_SILERO_5_1_2and the session will emit both.
Question 1 of 2
What is the primary advantage of debouncing partial text events instead of writing each one straight to stdout?
Question 2 of 2
Which parakeetStreamingConfig field sets the model's lookahead window?
Run your code, check your answer, or ask a question. It all shows up here.