Skip to content

feat(transcribe): add BW Labs STT as optional provider alongside ElevenLabs - #152

Open
isweluiz wants to merge 6 commits into
browser-use:mainfrom
isweluiz:feat/bw-stt-provider
Open

feat(transcribe): add BW Labs STT as optional provider alongside ElevenLabs#152
isweluiz wants to merge 6 commits into
browser-use:mainfrom
isweluiz:feat/bw-stt-provider

Conversation

@isweluiz

@isweluiz isweluiz commented Sep 3, 2026

Copy link
Copy Markdown

Summary

Adds BW Labs STT as an optional second transcription provider alongside ElevenLabs Scribe.

  • ElevenLabs remains the default — zero breaking changes for existing users
  • Calls the BW Labs WebSocket API directly — the only new dependency is websockets (PyPI), declared as an optional extra
  • Provider selected via --provider {elevenlabs,bw_stt}, TRANSCRIBE_PROVIDER env var, or auto-detected from whichever API key is present (ElevenLabs wins if both are set)

Usage

pip install websockets            # or: pip install "video-use[bw-stt]"
export BW_STT_API_KEY=bwa_key_... # get one at https://labs.bandwidth.com/
python helpers/transcribe.py clip.mp4 --provider bw_stt
python helpers/transcribe_batch.py raw_videos/ --provider bw_stt

Differences vs ElevenLabs

ElevenLabs Scribe BW Labs STT
Pricing Paid (per-minute credits) Free
Transport HTTP multipart WebSocket streaming
Duration limit ~2h None
Speaker diarization Yes No (all words → speaker_0)
Audio events Yes ((laughter), …) No
Language selection Yes Auto-detect only
Docs elevenlabs.io labs.bandwidth.com/docs/speech-to-text

--language / --num-speakers still work for ElevenLabs and print a note when ignored by BW STT.

Implementation notes

  • _call_bw_stt() streams raw PCM over the socket from a background thread while the main thread drains Segment messages — reading only after sending would stall long files once the client receive buffer fills
  • Final audio chunk is padded with silence to the API's 20ms frame minimum
  • websockets is lazy-imported with a clear install hint, so ElevenLabs users are unaffected
  • Output JSON is normalized to the same schema pack_transcripts.py already consumes (type/text/start/end/speaker_id)

Testing

  • Existing test suite: 16 passed, no regressions
  • Live transcription of a 5-minute clip: 1200 words, timestamps covering full duration, packed cleanly by pack_transcripts.py
  • Verified provider auto-detection, TRANSCRIBE_PROVIDER override, and CLI flags on both helpers
  • Verified helpers/transcribe.py imports fine without websockets installed (ElevenLabs path untouched)

Known API quirk

BW STT occasionally emits sub-word tokens in words[] (e.g. "bel" + "ieve"). Timestamps remain correct so phrase-boundary detection works; noted here for anyone doing exact word matching.

Files changed

  • helpers/transcribe.py — provider dispatch (resolve_provider, detect_provider), _call_bw_stt()
  • helpers/transcribe_batch.py--provider flag, passed through to workers
  • pyproject.tomlbw-stt = ["websockets>=12"] optional extra
  • .env.example — documents BW_STT_API_KEY and TRANSCRIBE_PROVIDER

Summary by cubic

Adds BW Labs STT as an optional transcription provider alongside ElevenLabs, which remains the default; existing ElevenLabs users are unaffected.

Behavior changes

  • Provider is chosen via --provider {elevenlabs,bw_stt}, TRANSCRIBE_PROVIDER, or auto-detected from whichever API key is present (ElevenLabs wins if both are set).
  • BW STT streams over WebSocket with no duration limit, but skips speaker diarization (all words tagged speaker_0), audio events, and language selection (auto-detect only).
  • --language and --num-speakers still work for ElevenLabs and print a note when BW STT ignores them.
  • Output JSON is normalized to the same schema pack_transcripts.py already consumes; BW STT may emit sub-word tokens in words[] with correct timestamps.
  • If a BW STT WebSocket session closes before the server sends SessionClosed, the run raises an error instead of caching a partial transcript.
  • Transcripts now record the provider that produced them; cached transcripts from a different provider are re-transcribed, legacy files without the field count as ElevenLabs, and files with unknown, corrupt, or unreadable provider values are re-transcribed. Batch mode treats any readable transcript as cached when no provider is explicitly set, so an all-cached rerun never needs an API key.

Dependencies

  • Adds optional extra bw-stt = ["websockets>=12"]; websockets is lazy-imported, so ElevenLabs users don't need it.

Written for commit e1c754b. Summary will update on new commits.

Review in cubic

…enLabs

ElevenLabs Scribe remains the default — zero breaking changes for existing
users. BW Labs STT (https://labs.bandwidth.com/) is now available as a
second backend via --provider bw_stt, TRANSCRIBE_PROVIDER env var, or
auto-detection from BW_STT_API_KEY.

Uses the BW Labs STT WebSocket API directly (wss://api.labs.bandwidth.com/
audio/v1/listen) — no proprietary SDK required. Only dependency is
websockets (pip install websockets), which is on PyPI.

Key differences vs ElevenLabs:
- WebSocket streaming (no 5-min HTTP upload limit)
- No speaker diarization (all words tagged speaker_0)
- No audio events (laughter, applause, etc.)
- No language selection (auto-detect only)

Provider selection priority:
  1. --provider {elevenlabs,bw_stt} CLI flag
  2. TRANSCRIBE_PROVIDER in .env or environment
  3. Auto-detect from available API key (ElevenLabs wins if both set)

Changes:
- helpers/transcribe.py: add _find_env_value(), detect_provider(),
  resolve_provider(), _call_bw_stt() (direct WebSocket, lazy websockets
  import); restore call_scribe() and --language/--num-speakers flags;
  add provider param to transcribe_one() and main()
- helpers/transcribe_batch.py: add --provider flag, pass through to workers
- pyproject.toml: add bw-stt optional dep (websockets>=12, on PyPI)
- .env.example: document BW_STT_API_KEY, TRANSCRIBE_PROVIDER, install step

API docs: https://labs.bandwidth.com/docs/speech-to-text

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All reported issues were addressed across 4 files

Reply with feedback, questions, or to request a fix.

Fix all with cubic | Re-trigger cubic

Comment thread helpers/transcribe.py
Comment thread helpers/transcribe.py
Addresses review feedback: a clean WebSocket close before the server sends
SessionClosed ends the receive iterator without raising (websockets only
raises on abnormal closures), so a truncated session returned a partial
transcript that transcribe_one() then cached as final output — and the
never-re-transcribe cache rule made the corruption permanent.

Track whether SessionClosed was received and raise RuntimeError on an
incomplete session, so nothing is written and the file can be retried.
Sender-thread errors still take precedence as the root cause when present.
Addresses review feedback: the cache accepted any existing transcript JSON
regardless of which backend produced it, so --provider bw_stt silently
returned an ElevenLabs transcript and vice versa (e.g. re-running with
--provider elevenlabs --num-speakers 2 for diarization would return the
diarization-free BW file).

Record "provider" in the transcript payload and validate it on cache hit:
- same provider → cached, as before
- different provider → note printed, re-transcribed, file overwritten
- missing field (legacy files) → treated as elevenlabs, the only backend
  that existed before this field
- unreadable/corrupt file → never matches, gets re-transcribed

The filename stays provider-agnostic — render.py and pack_transcripts.py
expect one <stem>.json per source, so provider identity lives inside the
file rather than in the name.

transcribe_batch.py applies the same check in its cache pre-filter so batch
and single-file semantics agree; provider resolution now happens before the
cache scan since it defines what counts as cached.

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All reported issues were addressed across 2 files (changes from recent commits).

Tip: Review your code locally with the cubic CLI to iterate faster.

Fix all with cubic | Re-trigger cubic

Comment thread helpers/transcribe_batch.py Outdated
Comment thread helpers/transcribe.py Outdated
…_provider

Addresses review feedback: read_text() raises UnicodeDecodeError (a
ValueError, caught by neither OSError nor json.JSONDecodeError) on invalid
UTF-8, so a binary-garbage cache file crashed transcription instead of
falling through to "" and being re-transcribed as the docstring promises.
Add UnicodeDecodeError to the except tuple.
Addresses review feedback: moving resolve_provider() ahead of the cache
scan meant its auto-detection exited on a missing API key even when every
transcript was already cached — a regression from the original behavior
where a fully-cached rerun printed "nothing to do" and exited 0.

Split provider resolution into two steps:
- explicit_provider() (new, keyless): returns the provider requested via
  --provider or TRANSCRIBE_PROVIDER, or None. resolve_provider() now
  delegates to it before falling back to key auto-detection.
- Batch checks the cache against the explicit provider when one is given
  (mismatches re-transcribe, as before). Without one, any readable
  transcript counts as cached — matching the original semantics and the
  never-re-transcribe rule — and auto-detection (with its key requirement)
  runs only once pending work exists.

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

1 issue found across 2 files (changes from recent commits).

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="helpers/transcribe_batch.py">

<violation number="1" location="helpers/transcribe_batch.py:106">
P2: When a cache JSON contains a non-string provider such as `null`, auto mode marks it cached because `src != ""` is true, so the batch never repairs the malformed or stale file. Require a nonempty string before accepting an auto-mode cache.</violation>
</file>

Tip: Review your code locally with the cubic CLI to iterate faster.

Fix all with cubic | Re-trigger cubic

Comment thread helpers/transcribe_batch.py
…ipts

Addresses review feedback: cached_provider() returned the "provider" value
verbatim, so a malformed file with "provider": null (or any non-string /
unknown value) passed batch auto mode's src != "" check and was never
repaired — and the function violated its own -> str annotation by
returning None or an int.

Validate the value against PROVIDERS: known name → returned, anything else
→ "" (treated as corrupt, re-transcribed). Legacy files with no provider
field still default to "elevenlabs".
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant