Skip to content

feat(saidwire): add Saidwire STT plugin - #8457

Open
Hudson6124 wants to merge 3 commits into
livekit:mainfrom
SaidwireLLC:add-saidwire-stt
Open

Hudson6124 wants to merge 3 commits into
livekit:mainfrom
SaidwireLLC:add-saidwire-stt

Conversation

@Hudson6124

Copy link
Copy Markdown

Adds livekit-plugins-saidwire: streaming speech-to-text with Saidwire (real-time English), from the Saidwire team.

from livekit.plugins import saidwire

session = AgentSession(stt=saidwire.STT(), vad=silero.VAD.load(), ...)  # reads SAIDWIRE_API_KEY
  • Streaming over WebSocket: interim and final transcripts with word timings, START_OF_SPEECH / END_OF_SPEECH (the EOS carries the last final's end time on the audio timeline), RECOGNITION_USAGE.
  • Flush: the session's end-of-turn flush sends Finalize; the server answers right away with the final transcript (an empty final if nothing is pending), so the session never waits on a timeout.
  • recognize() for pre-recorded audio (POST /v1/listen).
  • KeepAlive while no audio flows, update_options() with reconnect, _mark_provider_progress() on results.
  • A bad or revoked key, or used-up credit, raises a non-retryable APIStatusError; the key never appears in error text. While the service answers HTTP 503 / 429 the stream waits for Retry-After, bounded by warmup_timeout.
  • Optional early_word events. Standard plugin layout and a livekit-agents[saidwire] extra; uv.lock updated with uv lock.

Testing: tests/test_plugin_saidwire_stt.py, 17 unit tests (pytest.mark.unit) against a fake server on 127.0.0.1, no network and no key: endpointing and speech-end timing, flush/Finalize, empty flush, early words, bad key, warm-up wait and timeout, server errors and reconnects, quota errors, keepalive, 48 kHz resampling, 8 kHz telephony, update_options and recognize(). Passing against livekit-agents 1.8.11, plus ruff check, ruff format --check and mypy --strict.

The plugin is built and maintained by Saidwire, the company that runs the service. The livekit-plugins-saidwire name on PyPI is held by Saidwire; on merge we'll add your publisher so the release pipeline can publish it. Contact: support@saidwire.com.

@Hudson6124
Hudson6124 requested a review from a team as a code owner November 8, 2026 22:12
@CLAassistant

CLAassistant commented Nov 8, 2026 •

Copy link
Copy Markdown

CLA assistant check
All committers have signed the CLA.

devin-ai-integration[bot]

This comment was marked as resolved.

…nc capabilities

- STT.aclose() closes every stream it opened (WebSocket and keepalive included)
- update_options() during speech sends the buffered audio and Finalize on the old
  connection and waits (bounded) for its final before reconnecting; the item read
  after the drain began carries over to the new connection
- update_options() with no options, or only unchanged values, does not reconnect
- update_options(interim_results=...) updates capabilities.interim_results
- tests for all four

@devin-ai-integration devin-ai-integration Bot left a comment •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Note

Newer findings are available below. Devin Review posted a newer report on this PR, in addition to the findings presented here.

Devin Review found 1 new potential issue.

Devin Review

Comment on lines +458 to +460
if self._carry is not None:
data, self._carry = self._carry, None
return data

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 Reconnected speech gets incorrect timestamps

When update_options() carries a frame into the next connection, its transcripts use the previous connection's time offset. restart_provider_clock() cannot re-anchor time because _carry bypasses the channel read that anchors each connection.

Learn more

The input channel records the capture time of each queued audio frame when it is read. restart_provider_clock resets that anchor at the start of each provider connection. A frame read while reconnect starts is saved in _carry, then sent on the new connection without another channel read. Its original capture timestamp has already been removed from the channel's queue, so the new connection retains the old offset until a later frame arrives. The resulting word timestamps can point earlier than the audio they describe.

Example: A stream sends two seconds of audio, then reads the next frame just as update_options() starts reconnecting. The next server connection starts counting at zero, but that carried frame keeps the offset from the first connection; its words appear near the start of the session instead of after two seconds.

Recommended fix: Preserve the carried frame's capture anchor and explicitly re-anchor start_time_offset when it is the first frame sent on a new connection. Keep the queued-frame accounting in _InputChan consistent when carrying a frame between connections.

Devin Review


Was this helpful? React with 👍 or 👎 to provide feedback.

A Finalize that lands mid-word can make the server repeat the word at the cut
("coffee shops", "shop", "shops near me"), and a new segment can replay the
previous segment's tail. Finals and interims now skip words whose timings fall
inside audio an earlier final on the same connection covered, plus the re-decoded
cut word. The mark resets on every new connection. Tests included.

@devin-ai-integration devin-ai-integration Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Devin Review found 1 new potential issue.

Devin Review

Comment on lines +850 to +856
if (
not kept
and last_word
and norm
and start < last_end + _DEDUPE_CUT_S
and (norm.startswith(last_word) or last_word.startswith(norm))
):

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🔴 Distinct adjacent words vanish from transcripts

When consecutive finals contain “go” then “good,” _dedupe_words discards “good” if it starts within 350 ms. Prefix matching ignores that the words occupy separate audio intervals, so the transcript loses spoken content.

Learn more

The stream records the end and normalized text of the last word in a final. The next result can contain a new word that begins soon afterward. The prefix check removes that word even when its audio starts after the prior word ends. This changes the transcript presented to the agent, including short common phrases, rather than just removing repeated decoding.

Example: A first final delivers go with end 0.40; a second final delivers good at 0.50–0.80. The second word starts after the first finishes, but good.startswith(go) and 0.50 < 0.40 + 0.35, so the second final emits no good.

Recommended fix: Require actual overlap of the candidate's timing with the last delivered word before treating prefix similarity as evidence of a cut-word replay, or track the original word's start and a reliable replay boundary. Add adjacent-word tests across finals such as go good and no no with short pauses.

Devin Review


Was this helpful? React with 👍 or 👎 to provide feedback.

This branch has not been deployed

No deployments
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.

2 participants