Realtime Transcription
When a video has no usable captions, or you have your own audio stream, @xbbg/xnews/asr transcribes it. Two backends implement the same RealtimeAsrBackend interface:
| Backend | Runs | Output | Trade-offs |
|---|---|---|---|
createMoonshineAsrBackend | Locally, as a Python sidecar process | Incremental partial events plus committed final events | Audio stays on your machine; uses local CPU/GPU; downloads model weights on first use |
createOpenRouterAsrBackend | OpenRouter's hosted transcription API | final events after each uploaded window | Low setup; near-live (chunked), not a streaming connection; audio leaves the machine and incurs provider charges |
The same APIs are exported from the package root; importing @xbbg/xnews/asr keeps feed and catalog code out of a transcription-only bundle.
Requirements
- YouTube input:
yt-dlpand FFmpeg onPATH, or passytDlpCommand/ffmpegCommand. xnews spawns them to decode audio to 16 kHz mono PCM. - Moonshine: Python with the
moonshine-voicepackage importable by the configured command. xnews bundles the sidecar worker script, not Python, the package, or model weights. For an isolated on-demand environment, run it throughuv:command: "uv",commandArgs: ["run", "--with", "moonshine-voice", "python"]. - OpenRouter: an API key.
Transcribe a YouTube video with Moonshine
import { createMoonshineAsrBackend, transcribeYoutubeRealtime } from "@xbbg/xnews/asr";
const backend = createMoonshineAsrBackend({
modelArch: "medium-streaming", // the default
language: "en",
});
const controller = new AbortController();
const videoUrl = "https://www.youtube.com/watch?v=VIDEO_ID";
for await (const event of transcribeYoutubeRealtime(videoUrl, { backend, signal: controller.signal })) {
switch (event.type) {
case "status":
console.log("backend", event.state); // "ready", once per session
break;
case "partial":
process.stdout.write(`\r${event.text}`);
break;
case "final":
console.log(`\n[${event.startMs} ms] ${event.text}`);
break;
case "gap":
console.warn("gap", event.reason, event.message, event.recoverable);
break;
}
}Moonshine defaults: command "python", language "en", modelArch "medium-streaming" (also base, base-streaming, small-streaming, tiny, tiny-streaming), updateIntervalMs 500, word timestamps on, speaker diarization off, and a 10-minute startup timeout to allow the first model download.
Use OpenRouter instead
import { createOpenRouterAsrBackend, transcribeYoutubeRealtime } from "@xbbg/xnews/asr";
const backend = createOpenRouterAsrBackend({
apiKey: process.env.OPENROUTER_API_KEY!,
model: "deepgram/nova-3", // the default
windowMs: 15_000,
overlapMs: 2_000,
// responseFormat: "verbose_json", // required when you request timestampGranularities
});
const videoUrl = "https://www.youtube.com/watch?v=VIDEO_ID";
for await (const event of transcribeYoutubeRealtime(videoUrl, { backend })) {
if (event.type === "final") console.log(event.segmentId, event.revision, event.startMs, event.text);
}OpenRouter uploads overlapping WAV windows (default 15 s windows with 1.5 s overlap, at most 2 pending, 2 retries, 30 s request timeout) and emits committed results per request. Credentials are only ever sent to https://openrouter.ai; an injected fetch is supported for testing and metering without changing that destination. responseFormat is limited to "json" or "verbose_json", and timestampGranularities requires "verbose_json". Set failureMode: "gap" to turn a failed window into a gap event instead of an error.
Events
| Event | Fields | Meaning |
|---|---|---|
status | state: "ready", message? | Emitted once per session before any transcript event |
partial | segmentId, revision, text, startMs, durationMs, timing, words?, speakers?, language?, usage? | Provisional text for a segment (Moonshine) |
final | Same as partial | Committed text for a segment |
gap | reason: "source" | "backend", message, recoverable, startMs, durationMs | A discontinuity, such as a live-stream reconnect |
Every event carries backend, sequence, and generation. Reconcile transcript text by segmentId and revision; use sequence only for delivery order. timing says whether offsets are "model" timestamps or "estimated".
Lifecycle and safety
- Cancellation. Aborting
signal, or breaking out of thefor awaitloop, cancels the backend, stops the decoder, and cancels pending requests. YouTube cancellation waits for decoder shutdown and terminates child processes started by wrapper commands such asuv. - Backpressure. Event and request queues are bounded, so a slow consumer slows decoding instead of letting memory grow.
- Live streams. Reconnects emit explicit
gapevents (default 3 reconnect attempts, 2 s apart). Finite videos do not reconnect at end of file. - Failures. On a terminal FFmpeg failure, already-decoded audio is finalized before the error is thrown, so you keep the valid prefix of the transcript.
Bring your own audio
transcribePcmStream accepts any AsyncIterable<Uint8Array> of signed 16-bit little-endian PCM at 16 kHz, mono (REALTIME_ASR_SAMPLE_RATE, REALTIME_ASR_CHANNELS, REALTIME_ASR_BYTES_PER_SAMPLE):
import { createReadStream } from "node:fs";
import { createMoonshineAsrBackend, transcribePcmStream } from "@xbbg/xnews/asr";
const backend = createMoonshineAsrBackend({ language: "en" });
const pcm = createReadStream("call-audio.s16le"); // e.g. from: ffmpeg -i call.mp3 -f s16le -ac 1 -ar 16000 call-audio.s16le
for await (const event of transcribePcmStream(pcm, { backend })) {
if (event.type === "final") console.log(event.text);
}Related
- YouTube Channels & Captions: try captions first; they are free and instant.
- LangGraph Tools:
createXnewsRealtimeAsrNodedrains checkpointed PCM chunks in a graph.
