npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

transcribe-cpp

v0.4.1

Published

TypeScript/Node.js bindings for transcribe.cpp — a C/C++ speech-to-text library built on ggml

Readme

transcribe-cpp

TypeScript/Node.js bindings for transcribe.cpp, a C/C++ speech-to-text library built on ggml — Whisper, Parakeet, Moonshine, Voxtral, and more, on CPU, Metal, CUDA, and Vulkan.

npm install transcribe-cpp

A matching prebuilt native package is selected automatically for your platform (@transcribe-cpp/<platform>); there is nothing to compile and no environment variables to set.

On Windows, the prebuilt libraries are built with the Visual C++ 2022 toolset and require the Microsoft Visual C++ 2015–2022 Redistributable (x64) version 14.40 or newer. Older runtimes (for example the 2021-era 14.29 redistributable) crash with a native access violation inside std::mutex during backend initialization.

Upgrading from 0.1? See the 0.2 migration guide, including the replacement of gpuDevice with exact device objects.

Quickstart

import { TranscribeModel } from "transcribe-cpp";

const model = await TranscribeModel.load("whisper-tiny-Q5_K_M.gguf");

// 16 kHz mono float32 PCM (bring your own decoder).
const result = await model.transcribe(pcm, { timestamps: "segment" });

console.log(result.text);
console.log(result.language); // model-detected, or "" when unavailable/a hint was supplied
for (const seg of result.segments) {
  console.log(`[${seg.t0Ms}–${seg.t1Ms}ms] ${seg.text}`);
}

model.dispose();

Punctuation, capitalization, and text normalization

pnc and itn default to "default", preserving each model family's shipped behavior. Probe model.supports("pnc") or model.supports("itn") before selecting "off"/"on". Both options are available on single runs, batches, and streams.

const result = await model.transcribe(pcm, { pnc: "off", itn: "on" });

Prompting

vocabulary (custom terms), prompt (context, or the instruction under task: "instruct") and prefix (text the model continues from) take effect where model.supports() reports "vocabulary", "context_prompt", "instruct" or "transcript_prefix".

const result = await model.transcribe(pcm, { vocabulary: ["Kubernetes", "gRPC"] });

Streaming

const session = model.createSession();
const stream = await session.stream({ commitPolicy: "stable_prefix" });

for (const chunk of pcmChunks) {
  await stream.feed(chunk);
  const { committed, tentative } = stream.text; // committed is append-only/stable
  render(committed, tentative);
}
await stream.finalize();
const snapshot = stream.snapshot; // text, language, segments, words, tokens, timings
stream.reset();

Batch

const items = await session.runBatch([pcmA, pcmB]);
for (const item of items) {
  if (item.ok) console.log(item.result.text);
  else console.error(item.error.message); // per-utterance failure
}

Cancellation

const ac = new AbortController();
setTimeout(() => ac.abort(), 5000);
try {
  await model.transcribe(pcm, { signal: ac.signal });
} catch (e) {
  if (e instanceof Aborted) console.log("partial:", e.partialResult?.text);
}

Family extensions

Per-family options are a typed, discriminated union passed as family:

// Whisper is a run-slot extension:
await model.transcribe(pcm, { family: { kind: "whisper", initialPrompt: "…" } });

// Streaming families (moonshine, parakeet, voxtral) are stream-slot:
const stream = await session.stream({ family: { kind: "moonshine" } });

model.accepts({ kind: "whisper" }); // does this model take that extension?

Diarization (DIARIZE role)

model.roles lists what a model serves ("asr", "diarize", "langid", "vad"; Sortformer is diarize-only). Calls for a role the model lacks throw UnsupportedRole.

const { sampleRate, maxSpeakers } = model.diarizeInfo;
using diarizer = model.createDiarizeSession();
const turns = await diarizer.run(pcm, {
  family: { kind: "sortformer_diarize", preset: "very_high_latency" },
});
for (const t of turns) console.log(t.speakerId, t.t0Ms, t.t1Ms);

run accepts a signal; cancelling throws Aborted (with no partial result). diarizer.timings reports the last run. Diarize runs wait on the same model-wide lock as other compute calls (see below).

Language ID (LANGID role)

A "langid" model (VoxLingua107 ECAPA-TDNN) ranks its own label codes; match result.code against an ASR model's capabilities.languages yourself. Every allowed label is returned, ranked by p. Omitting allowed scores every label, [] throws InvalidArgument, clips under model.langidInfo.minAudioMs throw InputTooShort, and input longer than maxAudioMs is scored on its first maxAudioMs.

using lid = model.createLangIdSession();
const result = await lid.run(pcm, { allowed: ["en", "de", "fr"] });
console.log(result.code, result.candidates[0].p, result.allowedMass);

Voice activity detection (VAD role)

A "vad" model (Silero VAD) scores model.vadInfo.frameSamples-sample frames (512 = 32 ms at 16 kHz) with a speech probability. run scores a whole clip and segments it with Silero's get_speech_timestamps rules (options such as threshold, minSilenceMs and maxSpeechMs; samples are 16 kHz indices):

using vad = model.createVadSession();
const { segments, probs } = await vad.run(pcm, { threshold: 0.5 });
for (const s of segments) console.log(s.startSample, s.endSample);

For live audio, streamFeed(chunk) returns the probabilities of the frames each chunk completes (firstFrame is the index of probs[0]), streamFlush() scores the zero-padded remainder and ends the stream, and streamReset() drops it. The next feed after either starts at frame 0, as does the next feed after an abort or processing error.

for await (const chunk of mic) {
  const { firstFrame, probs } = await vad.streamFeed(chunk);
  probs.forEach((p, i) => console.log(firstFrame + i, p));
}
const tail = await vad.streamFlush();

A VAD stream is session state, not a stream lease: VAD calls raise Busy only while an ASR stream on the same model holds the lease.

Resource management

TranscribeModel, Session, DiarizeSession, LangIdSession, VadSession, and Stream all implement Symbol.dispose, so using works (TypeScript 5.2+ / Node 22+):

using model = await TranscribeModel.load("model.gguf");
using session = model.createSession();
// disposed automatically at block exit

Disposing a model disposes its sessions; disposing a stream resets it (releasing the model lease). Disposal is idempotent and order-independent.

Startup and UI responsiveness

Initializing compute backends can take seconds (on Metal it compiles the GPU shader library). TranscribeModel.load() does this on a worker thread, so the event loop keeps running. To pay the cost up front, call await initialize() at startup; backendState() reports its progress.

Use getAvailableBackendsAsync() / backendAvailableAsync() in UI processes. The sync variants initialize backends on the calling thread if needed, and throw BackendInitializing while initialize() is running. A failed backend initialization is permanent for the process; later calls rethrow its BackendError.

Backend selection

import { getAvailableBackendsAsync, backendAvailableAsync } from "transcribe-cpp";

const devices = await getAvailableBackendsAsync();
await backendAvailableAsync("rocm"); // boolean

// Policy selection: first matching ROCm device.
const automatic = await TranscribeModel.load("model.gguf", { backend: "rocm" });
// Exact selection: use this process-local CPU device or fail without fallback.
const cpu = devices.find((device) => device.deviceType === "cpu");
if (!cpu) throw new Error("CPU device is not registered");
const exact = await TranscribeModel.load("model.gguf", { device: cpu });

backend defaults to "auto" (best accelerator, else CPU). A missing Vulkan loader/driver degrades silently to CPU; an explicit backend that cannot be satisfied raises a BackendError.

Where the device can use host memory (CPU, Metal on Apple silicon), weights are memory-mapped from the model file and served from the page cache, which the OS can reclaim, instead of being copied into process memory. Don't truncate or rewrite a model file in place while it is loaded; write a new file and rename it over the old one. { noMmap: true } (or TRANSCRIBE_NO_MMAP=1) copies the weights instead.

Thread-safety

Your JavaScript runs on one thread, but run/feed/finalize dispatch the native compute to a libuv worker thread (via koffi's async calls), so the event loop stays responsive while inference runs.

The C library allows one compute in flight per model — a run, a runBatch, a diarize run, or an active stream — across all of its sessions. The binding enforces this: every compute call serializes through an internal model-wide mutex, and an active stream holds a model-wide lease for its whole lifetime. While a stream is active (after stream(), before finalize()/reset()), a run/runBatch/stream on any session of that model is refused with a Busy error rather than allowed to race. So to parallelize, load one model per worker; to share a model, finalize or reset the stream first. A single Session is single-use-at-a-time — don't call run/feed on the same session concurrently.

Hand-offs are ordered, not racy: finalize()/reset()/dispose() release the lease only after the native teardown runs on the shared queue, so the slot is never freed early. Issue the teardown before the next stream()/run() and it is correctly serialized — stream.reset(); const next = await session.stream() works without awaiting the (void) reset(). The reverse order — beginning before the teardown — is refused with Busy, by design.

A feed() rejected up front (e.g. NaN samples) leaves the stream "active" and the lease held; a failure inside the model makes it "failed" and frees the lease.

Because the compute is genuinely on another thread, do not touch a session while a call against it is in flight — it is single-threaded in the C library:

  • Reading a stream's text/snapshot/state/revision/lastStatus, a session's limits/wasAborted, or a DiarizeSession's timings, during an un-awaited feed/finalize/run/runBatch throws.
  • reset() and dispose() are safe to call any time: the native teardown is deferred behind any in-flight call, so it never frees a session mid-compute.
  • Disposing a Session or TranscribeModel while a stream is still active releases the lease and invalidates the stream — its later calls throw rather than touch the freed handle.
  • The input PCM is borrowed, not copied: run/runBatch/feed (and DiarizeSession.run) hand the buffer to native code that reads it on the worker thread, so do not mutate it (e.g. reuse a scratch/capture buffer) until the returned promise resolves. Pass a fresh buffer per call, or await before overwriting.

The normal pattern is safe — await first, then read:

await stream.feed(chunk);
const { committed, tentative } = stream.text; // ✓ feed has completed

const p = stream.feed(chunk);
stream.text;                                  // ✗ throws: read while feed in flight
await p;

Since JavaScript has no destructors, a stream holds the model's lease until you finalize(), reset(), or let using dispose it — drop the reference without one and the model stays Busy until you dispose the session or model. Always finalize/reset (or use using).

Result strings are copied at the boundary; the objects you get back are fully owned and outlive the model.

Runtime support

The binding is one native artifact (koffi, an N-API addon) plus plain ESM, so any runtime with N-API support runs the same package — there is no per-runtime build.

  • Node ≥ 22 — the primary, fully tested target.
  • Deno ≥ 2 — runs the package unmodified via deno run (use --allow-ffi --allow-read --allow-env, or -A). Note: deno compile does not bundle native addons, so the compiled-binary path is unsupported.
  • Bun — not yet. Bun loads the addon and runs, but currently crashes in its own N-API finalizer (napi_reference_unref); this is an upstream Bun bug, fixed on Bun's side, not here.

Building from source

The universal fallback when no prebuilt package exists for your platform (a new arch, a custom backend, or a single self-contained library):

# from a transcribe.cpp checkout:
npm run build:native -- --source /path/to/transcribe.cpp
# add --self-contained to link ggml into one libtranscribe

# from a consumer project (the script ships in the package):
node node_modules/transcribe-cpp/scripts/build-from-source.mjs --source /path/to/transcribe.cpp

This drives CMake directly (the binding is FFI over a shared library, not an N-API addon, so cmake-js does not apply) and installs into prebuilds/<tuple>/, which the loader finds automatically. Requires cmake and a C/C++ toolchain. You can also point TRANSCRIBE_LIBRARY at any libtranscribe you built.

Packaging an app (Electron / Tauri)

The native code ships as a self-contained @transcribe-cpp/<platform> package (the shared library plus its sibling ggml libs / backend modules, in one directory). Because npm resolution is transitive, your app can locate that directory at build time — there is nothing special to wire through. artifactDir() returns it without loading the library (no dlopen), which is exactly what a bundler/pack step needs:

import { artifactDir } from "transcribe-cpp";
import * as fs from "node:fs";

const dir = artifactDir();
const lib =
  process.platform === "win32" ? "transcribe.dll"
  : process.platform === "darwin" ? "libtranscribe.dylib"
  : "libtranscribe.so";

// HARD-FAIL if the artifact is missing/empty: better to break the build than
// ship a silently broken installer that crashes at first transcribe() call.
if (!fs.existsSync(`${dir}/${lib}`)) {
  throw new Error(`native library not found in ${dir}; the platform package is not bundled`);
}
// copy `dir` into your app resources, or feed it to your bundler config…

The native files are real binaries, so they must not be packed into an asar archive. With electron-builder, unpack the platform packages:

// package.json → "build"
{ "asarUnpack": ["node_modules/@transcribe-cpp/**"] }

For a Tauri app whose native bits come from the Rust crate instead, see that crate's "Packaging a distributable" section — there the artifact dir is exposed to your build.rs as DEP_TRANSCRIBE_CPP_RUNTIME_DIR.

Waived requirements

  • Per-field ABI layout is not waived (unlike Rust/Swift): TypeScript has no compiler reading the C headers, so — like the Python ctypes binding — the generated struct layout is verified at load against both the captured C-compiler layout and the live library (transcribe_abi_struct_size/_align).
  • Buffer model load awaits transcribe_model_load_buffer in the C API; only file load is exposed today.

License

MIT. Bundled native packages include third-party license texts (ggml, miniz, Silero VAD, and any bundled backend runtimes).