Skip to content

feat(rime): support WebSocket v1 streaming - #2450

Open
naszzz wants to merge 1 commit into
livekit:mainfrom
naszzz:nastassy/rime-websocket-v1
Open

feat(rime): support WebSocket v1 streaming#2450
naszzz wants to merge 1 commit into
livekit:mainfrom
naszzz:nastassy/rime-websocket-v1

Conversation

@naszzz

@naszzz naszzz commented Sep 8, 2026

Copy link
Copy Markdown
Contributor

Summary

  • Add Rime WebSocket v1 streaming through websocketURL, with binary protobuf and canonical protobuf JSON from @rimelabs/api@0.0.1.
  • Select the model from the endpoint. Send complete sentences in one synthesis context, with local flush, cancellation, connection reuse, and all six audio formats.
  • Keep HTTP synthesis and legacy WS3 with aligned timestamps. Set consistent sample rates, validate endpoints, and remove private provider and transport details from errors.
  • Keep each Rime stream's options, connection pool, and metrics model fixed after updateOptions(). Handle terminal HTTP TTS errors through the existing error event without an unhandled background rejection.
  • Update the README, API reports, patch changeset, and tests.

Python counterpart: livekit/agents#6978

Interface and data flow

flowchart TB
    subgraph MAIN["Before this PR"]
        M_TTS["rime.TTS"]
        M_MODE{"useWebsocket / baseURL"}
        M_HTTP["HTTP synthesis"]
        M_WS3["Legacy WS3<br/>JSON messages + aligned timestamps"]

        M_TTS --> M_MODE
        M_MODE -->|"HTTP mode"| M_HTTP
        M_MODE -->|"WebSocket mode"| M_WS3
    end

    subgraph BRANCH["This PR"]
        TTS["rime.TTS"]
        SELECT{"Endpoint configuration"}
        TTS --> SELECT

        SELECT -->|"No websocketURL"| LEGACY{"Legacy mode"}
        LEGACY -->|"HTTP URL"| HTTP["ChunkedStream<br/>HTTP synthesis"]
        LEGACY -->|"WebSocket baseURL or<br/>useWebsocket=true"| WS3["SynthesizeStream + RimePool<br/>WS3 JSON + aligned timestamps"]

        SELECT -->|"websocketURL"| MODEL{"resolveOptions<br/>model resolution"}
        MODEL -->|"/coda/ws"| CODA["modelId = coda"]
        MODEL -->|"/mist/ws"| MIST["modelId = mistv3"]
        MODEL -->|"/ws"| DEDICATED["Dedicated endpoint<br/>explicit modelId required"]
        CODA --> V1
        MIST --> V1
        DEDICATED --> V1

        V1["SynthesizeStream + RimePool<br/>sentence tokenizer + connection reuse"]
        V1 --> INPUT["pushText: send complete sentences<br/>flush: release buffered text locally<br/>endInput: send end<br/>close: cancel active context"]
        INPUT --> PROTOCOL{"websocketProtocol"}
        PROTOCOL -->|"binary, default"| BINARY["rime.v1.binary<br/>protobuf frames"]
        PROTOCOL -->|"json"| JSON["rime.v1.json<br/>canonical protobuf JSON"]
        BINARY --> WIRE
        JSON --> WIRE
        WIRE["RimeConnection<br/>ready, start, text*, end or cancel<br/>started, audio*, done"]
        WIRE --> AUDIO["RimeAudio<br/>PCM, PCMU, WAV, MP3, Ogg Opus, WebM Opus<br/>mono PCM frames at samplingRate"]
    end

    subgraph MODEL_STATE["Endpoint and model safety"]
        UPDATE["updateOptions"]
        UPDATE --> IDENTITY{"Model changed on the same<br/>normalized model endpoint?"}
        IDENTITY -->|"yes"| REJECT["Reject update"]
        IDENTITY -->|"no"| ALLOW["Accept valid options<br/>transport mode stays fixed"]
        ALLOW --> CONNECTION{"Full URL, API key,<br/>or protocol changed?"}
        CONNECTION -->|"yes"| POOL["Replace RimePool<br/>retire old pool after its streams finish"]
        CONNECTION -->|"no"| KEEP["Keep RimePool"]
        NORMAL["Model endpoint identity ignores query and trailing slash<br/>Normalizes scheme, host, and effective port<br/>Endpoint validation rejects fragments and user information"]
        NORMAL -.-> IDENTITY
    end

    subgraph STREAM_STATE["Per-stream state"]
        CURRENT["Current TTS options and RimePool"]
        CURRENT --> NEW["Create SynthesizeStream"]
        NEW --> SNAPSHOT["Copy options and retain pool<br/>metricsModel reads the copied modelId"]
        SNAPSHOT --> METRICS["Existing streams keep their model and endpoint<br/>after later TTS updates"]
        BASE["Base SynthesizeStream.metricsModel<br/>reads the current TTS model by default"]
        BASE --> INFERENCE["Inference stream metrics<br/>continue to follow model updates"]
    end

    TTS -.-> UPDATE
    TTS -.-> CURRENT
Loading

The JS implementation uses one SynthesizeStream for v1 and WS3, with RimeConnection for transport and RimeAudio for decoding. V1 always uses sentence input. Its flush() keeps the synthesis context open. V1 does not provide aligned timestamps.

Testing

  • pnpm exec vitest run plugins/rime/src agents/src/tts/tts.test.ts agents/src/inference/tts.test.ts: 206 passed, 2 skipped.
  • pnpm --filter @livekit/agents-plugin-rime exec tsc --noEmit: passed.
  • Live provider behavior was not verified.
  • pnpm --filter @livekit/agents-plugin-rime lint: passed.
  • Prettier check for the changed TypeScript files and Rime JSON fixtures: passed.

@naszzz
naszzz requested a review from a team as a code owner September 8, 2026 16:06
@changeset-bot

changeset-bot Bot commented Sep 8, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: e5584b1

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

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

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

@CLAassistant

Copy link
Copy Markdown

CLA assistant check
Thank you for your submission! We really appreciate it. Like many open source projects, we ask that you sign our Contributor License Agreement before we can accept your contribution.
You have signed the CLA already but the status is still pending? Let us recheck it.

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

2 flags not posted on this PR by your GitHub settings — view them in Devin Review. (Configure)

Devin Review

Comment on lines +50 to +53
const pool = connectionPools.get(parent);
if (!pool) throw new Error('Rime connection pool is not initialized');
this.pool = pool;
this.pool.retain();

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.

🔴 Updates redirect existing streams

Calling updateOptions() immediately after stream() makes the existing stream retain the replacement pool. Its copied options still describe the old endpoint, so synthesis reaches the new endpoint with stale configuration.

Prompt for agents
Capture and retain the exact RimePool associated with a stream synchronously when TTS.stream() is called. Do not look it up later through the mutable connectionPools WeakMap in SynthesizeStream's deferred construction path. Ensure pool retirement waits for every stream created against that pool, including a stream followed immediately by updateOptions(). Add a regression test that calls stream(), updates the endpoint before the next event-loop turn, then verifies the old stream connects to the old endpoint and the new stream connects to the new endpoint.
Devin Review

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

Comment thread plugins/rime/src/tts.ts
}
reader = response.body.getReader();
while (true) {
const result = await bounded(reader.read(), signal, this.requestOptions.timeoutMs);

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.

🔴 HTTP retries overlap timed-out requests

When bounded() times out, the pending body read continues while the retry loop starts another request. Cleanup waits on reader.cancel(), so retries can overlap or stall behind the abandoned response.

Prompt for agents
Make each HTTP body-read timeout actively abort and dispose that attempt before returning a retryable error. Ensure run() does not resolve or reject until the pending read and response body are settled, and avoid awaiting cancellation indefinitely. Add a test with a body whose read never settles, verify timeout closes the first request, and verify only then a retry can begin.
Devin Review

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

statusCode: known ? statuses[error.kind] : 500,
requestId: error.requestId ?? requestId,
body: { kind: known ? error.kind : 'unknown' },
retryable: error.kind !== 'unimplemented',

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.

🟡 Unknown provider errors trigger retries

An unrecognized provider error maps to status 500 and remains retryable. Deterministic unsupported or malformed requests then repeat until the retry budget expires.

Suggested change
retryable: error.kind !== 'unimplemented',
retryable: known && !['invalid_input', 'unauthenticated', 'permission_denied', 'not_found', 'unimplemented'].includes(error.kind),
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.

2 participants