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

@bandwidth-labs/bw-stt

v0.2.0

Published

TypeScript SDK for the Bandwidth Labs streaming speech-to-text API

Readme

@bandwidth-labs/bw-stt

TypeScript SDK for the Bandwidth Labs streaming speech-to-text API. Requires Node >=22. Browser support is separate: use a modern browser with fetch, AbortController and WebSocket support. File-path APIs are Node-only.

Full protocol details are in the API reference.

Install

After 0.2.0 is published, install it from the package registry:

npm install @bandwidth-labs/[email protected]

For source setup, see Contributing.

Quickstart

import { BwSttClient } from "@bandwidth-labs/bw-stt";

const client = new BwSttClient(); // reads BW_STT_API_KEY from the environment
const session = await client.connect({ encoding: "linear16", sampleRate: 16000 });

session.on("segment", (segment) => process.stdout.write(segment.text));

for await (const segment of session.streamFile("call.wav")) void segment;

const closed = await session.closeStream();
console.log(`\naudio seconds: ${closed.audioDurationSeconds.toFixed(2)}`);

In browsers, pass the key explicitly: new BwSttClient({ apiKey }). Browsers cannot set WebSocket headers, so the SDK automatically carries the key as an api_key query parameter there. In Node it uses the X-BW-LABS-API-KEY header. Override with authCarrier: "header" | "query" if needed.

Pointing at another endpoint

baseUrl accepts ws, wss, http, or https; http(s) is converted to ws(s) for streaming and back to http(s) for transcribe. A baseUrl without a path gets the standard endpoint paths appended (/audio/v1/listen, /audio/v1/transcribe). A custom path is used verbatim for streaming; for transcribe, a trailing /listen is replaced with /transcribe, and any other custom path gets /transcribe appended.

Listen modes and Transcribe

Instant and demand are modes of the /audio/v1/listen WebSocket endpoint. Transcribe uses POST /audio/v1/transcribe over HTTP and is not a WebSocket session mode.

| Mode | Delivery | Best for | |---|---|---| | instant (default) | Final Segment events arrive as audio is decoded. | Live captions and continuously updating displays | | demand | Each Finalize gets one Transcript per channel, including empty transcripts. CloseStream delivers the remainder the same way, then SessionClosed. | Voice-agent turns and application-controlled boundaries |

Instant

The default. Segment events stream back the moment text is decoded. Every segment is final and append-only; there are no interim results to reconcile.

const session = await client.connect(); // mode: "instant" is the server default
session.on("segment", (segment) => render(segment));

Demand

Demand buffers finalized results server-side. Each Finalize gets one Transcript per channel, even when that channel is empty. CloseStream delivers the remainder as Transcript events, then sends SessionClosed. Demand never sends Segment events.

const session = await client.connect({ mode: "demand" });
for (const turnFrames of callerTurns) {
  for (const frame of turnFrames) session.sendAudio(frame);
  const transcripts = await session.finalizeTranscript();
  const turnText = transcripts.map((transcript) => transcript.text).join(" ");
  answerTurn(turnText);
}

const closed = await session.closeStream(); // remainder Transcripts, then SessionClosed

Use finalize() instead when the control message should be fire-and-forget. For an instant versus demand comparison, use Segment callbacks for continuously arriving final pieces, or Transcript callbacks and finalizeTranscript() for application-controlled voice-agent turns.

Transcribe

Whole-recording transcription (up to five minutes) in one HTTP call. No session to manage.

const result = await client.transcribeFile("call.wav");
console.log(result.text, result.audioDurationSeconds);

// or from bytes you already have:
const result2 = await client.transcribe(rawLinear16, { encoding: "linear16", sampleRate: 16000 });

transcribeFile uploads a WAV path as audio/wav, preserving its container and using the header's sample rate and channel count. transcribe and transcribeFile(..., { raw: true }) upload headerless linear16 bytes as application/octet-stream with encoding=linear16 and sample_rate; channels may be 1 or 2, with 2 selecting downmix.

A successful response has this shape:

{
  "request_id": "6f58c1c6-7e0c-4bb8-9d72-3fb3d4c5c1aa",
  "text": "i need a dry van",
  "words": [
    {"word": "i", "start": 0.00, "end": 0.12},
    {"word": "need", "start": 0.16, "end": 0.20}
  ],
  "segments": [
    {"start": 0.00, "end": 0.72, "text": "i need a dry van"}
  ],
  "audio_duration_seconds": 0.72,
  "model_info": {"name": "bw-listen-en", "version": "current"}
}

words is a timestamped word list and may be empty. segments is a typed list with start, end, and text fields.

For raw audio, pass channels: 2 explicitly with multichannel: true because the client cannot infer the channel count. WAV uploads and URL submissions let the server infer the channel count. A stereo recording with multichannel: true returns an independent typed result for each channel. The single-channel result keeps the same shape when multichannel is omitted:

const stereo = await client.transcribe(rawLinear16, {
  channels: 2,
  multichannel: true,
});
for (const channel of stereo.channels ?? []) console.log(channel.channel, channel.text);

Asynchronous transcription jobs

Use the transcriptions namespace for recordings that should be processed as jobs. Byte uploads use raw linear16 by default. Set raw: false when the bytes are a complete WAV container, which is sent as audio/wav without raw-only format parameters. URL submissions send the location in JSON:

const job = await client.transcriptions.submit({ audio: rawLinear16 });
const result = await client.transcriptions.wait(job.id);
console.log(result.text);

const urlJob = await client.transcriptions.submit({
  audioUrl: "https://media.example.com/call.wav",
  callbackUrl: "https://hooks.example.com/stt",
  callbackAuthHeaderName: "X-Callback-Key",
  callbackAuthHeaderValue: "callback-secret",
});
console.log(urlJob.status);

For raw stereo jobs, pass channels: 2, multichannel: true explicitly. WAV and URL jobs let the server infer the channel count. Pass channels: 2, multichannel: true when the client should include the stereo declaration. For upload callbacks, callbackUrl is a query parameter and callbackAuthHeaderName and callbackAuthHeaderValue travel in the X-Callback-Auth-Name and X-Callback-Auth-Value request headers. URL submissions put all three callback options in the JSON callback object. The SDK never sends callback credentials in the query. wait(id) defaults to a 600 second overall timeout and polls every 2 seconds. Pass timeoutMs and pollIntervalMs to change them, and use signal to cancel a wait while it is polling. A wait timeout throws TranscriptionTimeoutError. The namespace also provides get(id) and delete(id). Uploads are fully buffered in memory, up to 512 MiB, including audio downloaded from a URL.

Job lifecycle

The per-key job limit returns Retry-After: 30; a busy submission returns Retry-After: 5 (seconds). The SDK surfaces these errors without retrying. The service follows at most three redirects when fetching audio_url, with a 60 s whole-download limit. Authenticated SDK API calls reject redirects. Uploads are limited to 512 MiB and job audio to 1800 s. These job limits are separate from the synchronous transcription endpoint's limits.

Callbacks are delivered at least once. Retries occur no sooner than 30 s after a failure, with up to ten recorded failures. Deduplicate callbacks by job id. Completed status and callbacks describe the transcript only.

The job record expires seven days after its last update. Each stored object expires seven days after it was written. DELETE removes the job record and stored audio/results. It does not remove separately retained captures or usage, and cancels an unfinished platform capture. Local timeout or cancellation does not delete an accepted server job; call delete explicitly when needed.

Streaming audio

sendAudio sends one binary frame per call and validates it: 20 to 1000 ms of complete interleaved samples. For Opus, send exactly one raw packet per call; the duration rule does not apply.

streamChunks accepts chunks of any size and re-cuts them into exact 160 ms frames with a final 20 to 160 ms tail:

for await (const segment of session.streamChunks(microphoneChunks)) {
  process.stdout.write(segment.text);
}

streamFile (Node only) streams a 16-bit PCM WAV file whose format must match the session, or headerless audio with { raw: true }. File streaming is not paced to realtime: the file is sent as fast as the socket drains, so large files buffer according to socket drain rather than playing out at audio speed.

During quiet periods the session sends KeepAlive automatically every 25 seconds of send-side silence, well inside the server's 60 second idle deadline. Tune with keepAliveIntervalMs; 0 or null disables it.

Displaying words live

Segments carry raw decoded text deltas, often subword pieces. Two helpers turn them into display text. TranscriptAssembler builds the full transcript by plain concatenation. WordAssembler maintains a live word list: a piece starting with a space begins a new word, and a piece without one grows the previous word in place, so "dr" appears instantly and becomes "dry" when the next piece arrives.

import { TranscriptAssembler, WordAssembler } from "@bandwidth-labs/bw-stt";

const transcript = new TranscriptAssembler();
const words = new WordAssembler();
session.on("segment", (segment) => {
  transcript.push(segment);
  const line = words.push(segment).map((word) => word.text).join(" ");
  redraw(line); // "i need a dr" then "i need a dry van"
});

See examples/transcribe-wav.mts for a complete CLI:

BW_STT_API_KEY=bwa_key_... node --import tsx examples/transcribe-wav.mts call.wav

PII redaction

Ask the service to redact common US PII categories in results:

| Option | Type | Default | Description | |---|---|---|---| | redactPii | boolean | false | Redact personally identifiable information. | | redactPiiSub | "entity_name" \| "hash" | unset | Choose the replacement style. | | redactPiiReturn | boolean | false | Return redacted entity spans. Requires redactPii: true and hash substitution. |

const session = await client.connect({
  mode: "demand",
  redactPii: true,
  redactPiiReturn: true,                      // omit redactPiiSub for the server's hash default
});
const transcripts = await session.finalizeTranscript();
for (const transcript of transcripts) {
  const entitiesByToken = new Map(
    (transcript.redactedEntities ?? []).map((entity) => [entity.token, entity]),
  );
  for (const token of transcript.text.split(/\s+/)) {
    const entity = entitiesByToken.get(token);
    if (entity !== undefined) console.log(token, "maps to", entity.text, entity.kind);
  }
}

This synthetic demand Transcript uses an invalid SSN placeholder:

{
  "type": "Transcript",
  "channel": 0,
  "text": "my number is hash:v1:9f2c41d08ab37e15",
  "words": [],
  "redaction": {
    "applied": true,
    "entities_redacted": 1
  },
  "redacted_entities": [
    {
      "token": "hash:v1:9f2c41d08ab37e15",
      "kind": "pii",
      "text": "000-00-0000",
      "start": 2.10,
      "end": 2.45
    }
  ]
}

redactedEntities is undefined when the server omits the field and an empty array when the server sends an empty array. Each token is the exact hash token in the redacted text, so it can be joined to the corresponding entity. start and end are null when the server has no timestamps. The same options work on transcribe and transcribeFile. When redactPiiReturn: true, redactPiiSub: "entity_name" is invalid. Redaction covers common US PII categories selected by the service.

Keyword boosting

Boost recognition of up to 100 domain terms with a combined limit of 4096 UTF-8 bytes:

const session = await client.connect({ keywords: ["dry van", "reefer", "backhaul"] });

Error handling

Connection-time and transcribe failures reject with typed errors: AuthenticationError (401/403), RateLimitError with retryAfterSeconds (429), InvalidRequestError (400, 413, and other unexpected 4xx on transcribe), and ServiceUnavailableError for 5xx and transport-level failures, including network errors and request timeouts. TranscriptionTimeoutError represents an expired asynchronous job wait() deadline. ConnectionClosedError covers a WebSocket that drops mid-session or an upgrade rejection the transport cannot classify (browsers only expose a generic close).

Job requests use JobLimitError for job_limit_reached and job_submission_busy; both expose retryAfterSeconds when the server sends Retry-After. JobPlatformUnavailableError represents job_platform_unavailable, and TranscriptionNotFoundError represents an unknown or inaccessible job id.

In-band Error events, including transcript_too_large, do not throw; they arrive through session.on("error", ...) and session.events(). If the connection then drops before SessionClosed, pending iterators and closeStream() reject with a ConnectionClosedError whose lastErrorEvent carries that event. A failed final delivery is reported by SessionClosed.deliveryFailed.

import { RateLimitError } from "@bandwidth-labs/bw-stt";

try {
  const session = await client.connect();
} catch (error) {
  if (error instanceof RateLimitError) scheduleRetry(error.retryAfterSeconds);
  else throw error;
}

There is no resume protocol: after an unexpected close, connect again and decide what audio to resend.

Invalid local input (bad frame sizes, misaligned samples, too many keywords, or more than 4096 combined keyword bytes) throws plain RangeError or TypeError at the call site.

Events

All server events are available as a typed union via session.events() or session.on("event", ...). Demand Transcript events are also available via session.on("transcript", ...). events() does not yield SessionOpened: it is consumed by the connect handshake and available as session.opened. Field names are camelCase mappings of the wire names (audio_duration_seconds becomes audioDurationSeconds), and every event keeps the original payload on .raw. Event types this SDK does not know yet are surfaced as UnknownEvent rather than dropped.

License

MIT. See LICENSE.

Fetch rejects redirects with ProtocolError when the runtime identifies the redirect failure. Fetch does not expose the rejected redirect status. Browsers may report an indistinguishable network failure as ServiceUnavailableError.