Skip to content

feat(fishaudio): add realtime STT - #2940

Open
rosetta-livekit-bot[bot] wants to merge 1 commit into
mainfrom
readable-skyward-stimuli
Open

rosetta-livekit-bot[bot] wants to merge 1 commit into
mainfrom
readable-skyward-stimuli

Conversation

@rosetta-livekit-bot

@rosetta-livekit-bot rosetta-livekit-bot Bot commented Nov 4, 2026 •

Copy link
Copy Markdown
Contributor

Summary

Ports livekit/agents#8357 to add Fish Audio realtime streaming STT (transcribe-stream-1).

  • supports Fish Audio auto/VAD/manual turn detection, interim/preflight/final transcripts, and word timings
  • carries keyterms and assistant replies as recognition context
  • flushes with the Agents voice pipeline and reconnects on server restarts or maximum session duration
  • adds liveness monitoring and awaited stream shutdown infrastructure required for source parity

Source: livekit/agents#8357

Source diff coverage
  • Adapted livekit-plugins/livekit-plugins-fishaudio/README.md -> plugins/fishaudio/README.md: ported STT overview and usage to TypeScript, npm, and agents-js session APIs.
  • Adapted livekit-plugins/livekit-plugins-fishaudio/livekit/plugins/fishaudio/__init__.py -> plugins/fishaudio/src/index.ts: exports STT, SpeechStream, and their public options from the TypeScript barrel.
  • Adapted livekit-plugins/livekit-plugins-fishaudio/livekit/plugins/fishaudio/models.py -> plugins/fishaudio/src/models.ts: added TypeScript STTModels and TurnDetectionMode unions.
  • Adapted livekit-plugins/livekit-plugins-fishaudio/livekit/plugins/fishaudio/stt.py -> plugins/fishaudio/src/stt.ts: ported the MessagePack WebSocket protocol, options, turn events, timestamps, context/keyterms, usage reporting, lifecycle, and reconnect behavior to agents-js STT primitives.
  • Adapted livekit-plugins/livekit-plugins-fishaudio/pyproject.toml -> plugins/fishaudio/package.json: updated package description and keywords; existing @msgpack/msgpack and ws dependencies cover the implementation.
  • Adapted tests/test_plugin_fishaudio_stt.py -> plugins/fishaudio/src/stt.test.ts: ported all 36 source fake-WebSocket/unit tests as distinct Vitest cases.
  • Adapted tests/test_stt.py -> plugins/fishaudio/src/stt.test.ts: agents-js has no central provider STT matrix, so the Fish Audio stream-only registration is an environment-gated plugin-local generic STT integration suite.
  • Ported infrastructure agents/src/types.ts and agents/src/stt/stt.ts: the target lacked the source framework's configurable WebSocket liveness timeout and awaitable speech-stream settlement; both are required for Fish Audio half-open recovery and STT.close() parity.
  • Not applicable: none.

Verification

  • pnpm exec vitest run agents/src/stt plugins/fishaudio/src/stt.test.ts: 99 passed, 1 skipped (requires FISH_API_KEY)
  • pnpm --filter @livekit/agents build: passed
  • pnpm --filter @livekit/agents-plugin-fishaudio build: passed
  • package lint: passed (existing warnings only)
  • pnpm format:check: passed
  • API update/check for both touched packages: passed
  • full agents suite: 4303 passed, 5 skipped, 4 unrelated baseline failures (three loop-monitor assertions require per-thread CPU accounting unavailable in this runtime; one existing TCP backpressure timing assertion)
  • full Fish Audio suite: 62 passed, 2 skipped, 1 unrelated existing TTS terminal-frame assertion failure at src/tts.test.ts:747; all STT tests pass
  • cue-cli: not available in this environment, so runtime voice-agent validation could not be run

Changeset

Minor bumps for @livekit/agents and @livekit/agents-plugin-fishaudio for the new public APIs.


Ported from livekit/agents#8357

Original PR description

summary

Adds fishaudio.STT: streaming STT on Fish Audio's realtime ASR (transcribe-stream-1). Reads FISH_API_KEY; no new dependencies.

from livekit.agents import AgentSession, TurnHandlingOptions
from livekit.plugins import fishaudio

session = AgentSession(stt=fishaudio.STT(), ...)  # LiveKit's turn detection
session = AgentSession(
    stt=fishaudio.STT(),
    turn_handling=TurnHandlingOptions(turn_detection="stt"),  # Fish Audio's
    ...
)
  • Works with LiveKit's VAD and turn detector: it declares manual_flush, so the session finalizes Fish Audio's turn when it ends the user's turn.
  • With turn_detection="stt", Fish Audio ends each turn. Its default auto mode ignores speech that isn't the user's (noise, other speakers); vad and manual modes are available too.
  • Interim, preflight (eager end of turn, on by default) and final transcripts, with word timings.
  • Keyterms, and the agent's replies as recognition context (chat_context).
  • Reconnects when the server restarts or a session reaches its maximum duration.
  • Streaming only. The language is detected automatically.

test plan

  • tests/test_plugin_fishaudio_stt.py (unit, fake WebSocket): 36 passed.
  • tests/test_stt.py --stt -k fishaudio: passed.
  • ruff and mypy clean.
  • Live voice sessions under LiveKit's audio and text turn detectors and turn_detection="stt".

If #8147 lands first, I'll port this to _run_connection_tasks.

@rosetta-livekit-bot
rosetta-livekit-bot Bot requested a review from a team as a code owner November 4, 2026 20:04
@changeset-bot

changeset-bot Bot commented Nov 4, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: a980d35

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 42 packages
Name Type
@livekit/agents Minor
@livekit/agents-plugin-fishaudio Minor
@livekit/agents-plugin-anam Minor
@livekit/agents-plugin-anthropic Minor
@livekit/agents-plugin-assemblyai Minor
@livekit/agents-plugin-azure Minor
@livekit/agents-plugin-baseten Minor
@livekit/agents-plugin-bey Minor
@livekit/agents-plugin-cartesia Minor
@livekit/agents-plugin-cerebras Minor
@livekit/agents-plugin-deepgram Minor
@livekit/agents-plugin-did Minor
@livekit/agents-plugin-elevenlabs Minor
@livekit/agents-plugin-google Minor
@livekit/agents-plugin-hume Minor
@livekit/agents-plugin-inworld Minor
@livekit/agents-plugin-krisp Minor
@livekit/agents-plugin-lemonslice Minor
@livekit/agents-plugin-liveavatar Minor
@livekit/agents-plugin-livekit Minor
@livekit/agents-plugin-meta Minor
@livekit/agents-plugin-minimax Minor
@livekit/agents-plugin-mistral Minor
@livekit/agents-plugin-mistralai Minor
@livekit/agents-plugin-neuphonic Minor
@livekit/agents-plugin-nvidia Minor
@livekit/agents-plugin-openai Minor
@livekit/agents-plugin-perplexity Minor
@livekit/agents-plugin-phonic Minor
@livekit/agents-plugin-protoface Minor
@livekit/agents-plugin-resemble Minor
@livekit/agents-plugin-rime Minor
@livekit/agents-plugin-runway Minor
@livekit/agents-plugin-sarvam Minor
@livekit/agents-plugin-silero Minor
@livekit/agents-plugin-soniox Minor
@livekit/agents-plugin-speechify Minor
@livekit/agents-plugin-synthesia Minor
@livekit/agents-plugin-tavus Minor
@livekit/agents-plugins-test Minor
@livekit/agents-plugin-trugen Minor
@livekit/agents-plugin-xai Minor

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@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 4 potential issues.

Devin Review

Comment on lines +797 to +807
#onCommitted(msg: WireMessage): void {
if (this.#currentTurn(msg)) this.#turn = null;
if (msg.user_turn === false) {
this.#logger.debug(
{ turnKind: msg.turn_kind, sessionId: this.#sessionId },
"ignoring a turn that isn't the user's",
);
this.#endSpeech([]);
return;
}
this.#startSpeech(numberValue(msg.audio_start_ms));

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.

🔴 Stale commits close the active turn

When an older committed arrives during a new turn, #onCommitted emits its transcript and ends the new speech. #currentTurn only guards clearing the turn, not processing the stale commit.

Learn more

A stream holds its active provider turn in #turn. Draft and preflight events require matching turn indexes via #currentTurn, but a committed event always emits a final transcript and ends speech even when its index differs. A delayed old commit therefore changes the active turn's output and speech boundary. The existing stale-message test only covers a non-user commit and does not assert that the active turn remains open.

Example: After user_turn_taken for index 1, a late user committed for index 0 produces index 0's final transcript and END_OF_SPEECH for index 1. The subsequent index 1 draft can no longer describe the correct open speech.

Recommended fix: Ignore committed messages whose turn_index does not match an active #turn, except for a genuinely standalone commit when no turn exists. Cover both user and non-user stale commits while another turn is speaking.

Devin Review


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

Comment on lines +327 to +329
this.#ws = null;
this.#reportUsage();
closeWebSocket(ws);

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.

🟡 Shutdown loses final recognition usage

On normal STT.close(), #reportUsage runs after the base stream closes its queue. #put discards the remaining audio duration, so short sessions report no recognition usage.

Learn more

The sender accumulates audio duration and reports it at five-second intervals. The remainder is reported when the provider task exits, but SpeechStream.close immediately closes the event queue. The normal STT.close path calls that close before waiting for the provider task, and #put refuses to enqueue once the queue is closed. Thus closing an active stream drops the final usage event.

Example: A caller sends three seconds of audio and calls fish.close(). No five-second usage event has fired, and the final three seconds are discarded instead of being published.

Recommended fix: Flush pending usage before the base stream closes the queue, or provide an awaited shutdown path that keeps event emission open until the provider task reports usage.

Devin Review


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

Comment on lines +911 to +913
#addUsage(durationSeconds: number): void {
this.#unreportedAudioDuration += durationSeconds;
if (this.#unreportedAudioDuration >= USAGE_REPORT_INTERVAL_SECONDS) this.#reportUsage();

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.

🟡 Usage duration uses unnamed seconds

#unreportedAudioDuration stores seconds without the required InS suffix. The repository requires milliseconds by default, so this field violates its time-unit contract.

Learn more

The repository's time-unit rule requires milliseconds by default and reserves seconds for names ending in InS. The usage accumulator adds seconds from the sender, compares against a five-second threshold, and emits seconds in recognitionUsage.audioDuration. Those provider-facing seconds are valid, but the local accumulator's name does not disclose its units.

Example: Twenty milliseconds of PCM adds 0.02 to #unreportedAudioDuration, not 20 as the field name implies under the project convention.

Recommended fix: Rename the seconds-valued accumulator and associated local seconds variables to end in InS, or accumulate milliseconds and convert to seconds only when emitting recognition usage.

Devin Review


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

Comment on lines +847 to +850
this.#logger.warn(
{ errorType, requestId: msg.request_id },
`fish audio stt error: ${message}`,
);

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.

🟥 Provider error text bypasses log redaction

For recoverable errors, #onError embeds the provider's message directly in a log message. If that message contains speech, redaction cannot remove it.

Devin Review


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

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.

0 participants