Skip to content

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:

BackendRunsOutputTrade-offs
createMoonshineAsrBackendLocally, as a Python sidecar processIncremental partial events plus committed final eventsAudio stays on your machine; uses local CPU/GPU; downloads model weights on first use
createOpenRouterAsrBackendOpenRouter's hosted transcription APIfinal events after each uploaded windowLow 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-dlp and FFmpeg on PATH, or pass ytDlpCommand / ffmpegCommand. xnews spawns them to decode audio to 16 kHz mono PCM.
  • Moonshine: Python with the moonshine-voice package 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 through uv: command: "uv", commandArgs: ["run", "--with", "moonshine-voice", "python"].
  • OpenRouter: an API key.

Transcribe a YouTube video with Moonshine ​

ts
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 ​

ts
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 ​

EventFieldsMeaning
statusstate: "ready", message?Emitted once per session before any transcript event
partialsegmentId, revision, text, startMs, durationMs, timing, words?, speakers?, language?, usage?Provisional text for a segment (Moonshine)
finalSame as partialCommitted text for a segment
gapreason: "source" | "backend", message, recoverable, startMs, durationMsA 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 the for await loop, 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 as uv.
  • Backpressure. Event and request queues are bounded, so a slow consumer slows decoding instead of letting memory grow.
  • Live streams. Reconnects emit explicit gap events (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):

ts
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);
}

xbbg is independent open-source software and is not affiliated with, endorsed by, sponsored by, or approved by Bloomberg Finance L.P.