@bandwidth-labs/bw-stt
v0.2.0
Published
TypeScript SDK for the Bandwidth Labs streaming speech-to-text API
Maintainers
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 SessionClosedUse 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.wavPII 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.
