Skip to main content

bw-stt

Python SDK for the Bandwidth Labs speech-to-text API. Stream audio over a WebSocket and receive final transcript segments in real time, or send a whole recording for whole-file transcription.

Install

The Python SDK is distributed from this repository; it is not published to PyPI yet. Install the 0.2.0 release directly from GitHub:

pip install "git+https://github.com/Bandwidth/bw_labs_sdks.git@v0.2.0#subdirectory=python"

To track the default branch instead of a release tag, replace @v0.2.0 with @main. For a local checkout and development setup, see Contributing.

Requires Python 3.10 or later. Set your API key once:

export BW_STT_API_KEY=bwa_key_...

Quickstart

Synchronous:

from bw_stt import BwSttClient, TranscriptAssembler

client = BwSttClient()
transcript = TranscriptAssembler()
with client.connect(encoding="linear16", sample_rate=16000) as session:
    session.on_segment(transcript.push)
    for _segment in session.stream_file("call.wav"):
        print("\r" + transcript.text, end="", flush=True)
    closed = session.close_stream()
print("\r" + transcript.text)
print(f"audio seconds: {closed.audio_duration_seconds:.2f}")

Asynchronous:

from bw_stt import AsyncBwSttClient, TranscriptAssembler

client = AsyncBwSttClient()
transcript = TranscriptAssembler()
async with await client.connect() as session:
    session.on_segment(transcript.push)
    async for _segment in session.stream_file("call.wav"):
        print("\r" + transcript.text, end="", flush=True)
    closed = await session.close_stream()
print("\r" + transcript.text)
print(f"audio seconds: {closed.audio_duration_seconds:.2f}")

Every segment is final. Text concatenates verbatim across segments: a segment whose text starts with a space begins a new word, one without a leading space continues the previous word. TranscriptAssembler applies that rule for you.

Iteration over stream_file() yields only the segments that arrive while audio is still being sent; the server flushes the rest in response to close_stream(). The drain dispatches those final segments to on_segment callbacks and keeps every drained event available from events() afterwards, so assemble the transcript through a callback (as above) or by consuming events(), not from the send-side iteration alone.

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

In instant mode, register on_segment or consume events() as audio is sent:

session = client.connect(mode="instant")  # or omit mode
session.on_segment(lambda segment: render(segment.text))

In demand mode, a Finalize is a turn boundary. finalize() only sends the control message and returns immediately. Use finalize_transcript() when the voice-agent turn needs its per-channel Transcript response:

session = client.connect(mode="demand")
for turn_frames in caller_turns:
    for frame in turn_frames:
        session.send_audio(frame)
    transcripts = session.finalize_transcript()  # one Transcript per channel
    turn_text = " ".join(transcript.text for transcript in transcripts)
    answer_turn(turn_text)

closed = session.close_stream()  # remainder Transcripts, then SessionClosed

Each demand Transcript has channel, text, timestamped words, and a redaction summary. Demand mode never sends Segment events.

Transcribe is non-streaming: one HTTP request for a whole recording of up to five minutes, one result back. No session to manage:

result = client.transcribe("call.wav")
print(result.text, result.audio_duration_seconds)

transcribe uploads a WAV path as audio/wav, preserving its container and using the header's sample rate and channel count. Raw bytes and paths with raw=True use application/octet-stream with encoding=linear16 and an explicit sample_rate; channels may be 1 or 2, with 2 selecting downmix. The async client has the same method.

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 always carries the typed segment list with start, end, and text.

For raw audio, set 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 response shape does not change when this option is omitted:

result = client.transcribe("stereo.wav", channels=2, multichannel=True)
for channel in result.channels or ():
    print(channel.channel, channel.text)

Asynchronous transcription jobs

Use client.transcriptions when a recording should be processed as a job. Uploads accept bytes, a WAV path, or a binary file object. URL submissions send the audio location in JSON. For submit(), raw=False treats bytes and binary file objects beginning with a RIFF/WAVE header as WAV and takes their sample rate and channel count from that header. Other bytes are treated as raw linear16, and raw=True always forces raw treatment. wait() polls until the job has a completed typed result or raises TranscriptionJobError with the service error code:

job = client.transcriptions.submit("call.wav")
result = client.transcriptions.wait(job.id)
print(result.text)

url_job = client.transcriptions.submit_url(
    "https://media.example.com/call.wav",
    callback_url="https://hooks.example.com/stt",
    callback_auth_header_name="X-Callback-Key",
    callback_auth_header_value="callback-secret",
)
print(url_job.id)

For raw stereo jobs, pass channels=2, multichannel=True explicitly. WAV and URL jobs let the server infer the channel count. Upload callbacks keep callback_url in the query and send the credential metadata in the X-Callback-Auth-Name and X-Callback-Auth-Value request headers. URL submissions put the URL and credential metadata in the JSON callback object. The SDK never sends callback credentials in the query. wait() defaults to a 600 second overall timeout and polls every 2 seconds; pass timeout and poll_interval to change them. A timeout raises TranscriptionTimeoutError, which is also a built-in TimeoutError. The async client exposes the same operations through awaited methods. Uploads are fully buffered in memory; URL downloads happen on the service.

import asyncio
from bw_stt import AsyncBwSttClient


async def main():
    client = AsyncBwSttClient()
    job = await client.transcriptions.submit("call.wav")
    result = await client.transcriptions.wait(job.id)
    print(result.text)


asyncio.run(main())

HTTP timeouts use a monotonic budget covering connect, upload, headers and body. The in-process transport recomputes socket timeouts from the remaining budget. Async cancellation closes the active socket and waits for the request thread to exit. System DNS resolution cannot be interrupted; its elapsed time consumes the budget, but a stalled system resolver can delay timeout or cancellation. The HTTP transport connects directly and does not use environment proxy variables.

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.

Live word display

For word-by-word rendering, WordAssembler merges subword pieces into display words as they grow. With segments "i need", " a dr", "y van" it shows dr the instant it is decoded and grows it into dry:

from bw_stt import BwSttClient, WordAssembler

client = BwSttClient()
words = WordAssembler()
with client.connect() as session:
    session.on_segment(words.push)
    for _ in session.stream_file("call.wav"):
        print("\r" + " ".join(w.text for w in words.words), end="")
    session.close_stream()

Each DisplayWord carries start and end timestamps spanning its first piece to its last. See examples/transcribe_wav.py for a complete CLI.

PII redaction

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

Option Type Default Description
redact_pii bool False Redact personally identifiable information.
redact_pii_sub str unset Use entity_name or hash for replacements.
redact_pii_return bool False Return redacted entity spans. Requires redact_pii=True and hash substitution.
session = client.connect(
    mode="demand",
    redact_pii=True,
    redact_pii_return=True,  # omit redact_pii_sub to use the server's hash default
)
transcripts = session.finalize_transcript()
for transcript in transcripts:
    entities_by_token = {entity.token: entity for entity in transcript.redacted_entities or ()}
    for token in transcript.text.split():
        entity = entities_by_token.get(token)
        if entity is not None:
            print(token, "maps to", entity.text, entity.kind, entity.start, entity.end)

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
    }
  ]
}

redacted_entities is None when the server omits the field and an empty tuple when the server sends an empty array. Each returned token is the exact hash token in the redacted text, so it can be joined to the corresponding entity. start and end are None when the server has no timestamps. The same options apply to transcribe(). When redact_pii_return=True, redact_pii_sub="entity_name" is invalid. Redaction covers common US PII categories selected by the service.

Keyword boosting

Bias recognition toward domain terms by passing up to 100 keywords with a combined limit of 4096 UTF-8 bytes:

session = client.connect(keywords=["dry van", "reefer", "LTL"])
result = client.transcribe("call.wav", keywords=["dry van"])

Error handling

Failures before the WebSocket upgrade raise typed exceptions: AuthenticationError (401/403), RateLimitError (429, with retry_after when the server supplies it), ServiceUnavailableError (5xx and transport failures, including connect and transcribe timeouts). In-band protocol errors, including transcript_too_large, are delivered as ErrorEvent values from events(), not raised; when the server closes after one, the SDK raises ConnectionClosedError carrying that event. events() does not yield SessionOpened; it is available as session.opened:

The SDK preserves the server error code as a string. Published codes include invalid_params, invalid_message, invalid_frame, idle_timeout, identity_revalidation_failed, upstream_unavailable, transcript_too_large, and internal_error.

Job requests additionally use JobLimitError for job_limit_reached and job_submission_busy; both expose the server's retry_after value when present. JobPlatformUnavailableError represents job_platform_unavailable, and TranscriptionNotFoundError represents an unknown or inaccessible job id. TranscriptionTimeoutError represents an expired wait() deadline.

from bw_stt import ConnectionClosedError, ErrorEvent, RateLimitError

try:
    with client.connect() as session:
        for event in session.events():
            if isinstance(event, ErrorEvent):
                print("server error:", event.code, event.message)
except RateLimitError as exc:
    print("retry after", exc.retry_after)
except ConnectionClosedError as exc:
    print("closed:", exc.error_event)

There is no resume protocol: after an unexpected close, reconnecting starts a new session, and you decide what audio to send again.

The SDK requires websockets 14.2 or newer. When the gateway rejects the upgrade (401, 403, 503) it sends the error body with chunked transfer encoding, and websockets releases before 14.2 raise a bare NotImplementedError("transfer codings aren't supported") instead of reporting the status. If you see that error, upgrade websockets.

Sessions and framing

Audio frames must be 20 to 1000 ms of complete samples; stream_chunks() and stream_file() handle the 160 ms framing for you from arbitrary chunk sizes. Opus is different: send exactly one raw encoder packet per send_audio() call. The SDK sends KeepAlive automatically during send-side quiet (keepalive_interval, default 25 s) to stay inside the 60 s idle deadline; pass None or 0 to disable it. connect() waits up to connect_timeout seconds (default 15) for the session to open. A failed final delivery is reported by SessionClosed.delivery_failed.

Overriding the endpoint

base_url accepts ws, wss, http, or https URLs; http(s) is normalized to ws(s) for streaming and ws(s) to http(s) for transcribe() and job requests. A base URL without a path gets the standard paths appended (/audio/v1/listen, /audio/v1/transcribe, and /audio/v1/transcriptions). A custom path is used verbatim for streaming; transcribe() derives /transcribe, while job requests derive /transcriptions.

client = BwSttClient(base_url="wss://gateway.example.com")

API reference

Full protocol documentation: Bandwidth Labs speech-to-text API reference.

License

MIT. See LICENSE.

Metadata

Release files for bw-stt 0.2.0

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for bw-stt 0.2.0
File Size Uploaded
bw_stt-0.2.0.tar.gz 34.7 kB Details

Built distribution (wheel)

Table of built distributions (wheels) for bw-stt 0.2.0
File Interpreter ABI Platform
bw_stt-0.2.0-py3-none-any.whl Python 3 none any Details

Total release size: 75.1 kB

Release files / bw_stt-0.2.0.tar.gz

Download URL bw_stt-0.2.0.tar.gz
Size 34.7 kB
Tags Source
SHA-256 checksum
How to use checksums
a5563ad69d6e4a791051eb8b4c4a84944b3e040ed2664d5d4fbb990d062ab5be
BLAKE2b-256 checksum
How to use checksums
a078ae1268db47138eedb9d6abf691e87564b07acb879c17411ec329bfc8fe8e
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Oct 12, 2026.

Transparency log

Release files / bw_stt-0.2.0-py3-none-any.whl

Download URL bw_stt-0.2.0-py3-none-any.whl
Size 40.4 kB
Tags Python 3
SHA-256 checksum
How to use checksums
d25a64b0fb7e5d2395445cc10fe89806e005ce3430c24057deb62e7789671cd9
BLAKE2b-256 checksum
How to use checksums
667d54f6943f1309cd30836a77d6760becbe25c2f3a6cf2bc1c92040c837be11
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Oct 12, 2026.

Transparency log

Release history Release notifications | RSS feed

This release

0.2.0 This release

2 release files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page