Skip to content

feat: fish audio tts ttfb - #2356

Merged
wangyoucao577 merged 15 commits into
mainfrom
dev/fish_audio_tts_ttfb
Oct 16, 2026
Merged

wangyoucao577 merged 15 commits into
mainfrom
dev/fish_audio_tts_ttfb

Conversation

@YiminW

@YiminW YiminW commented Oct 14, 2026

Copy link
Copy Markdown
Contributor

No description provided.

Add a top-level flush_mode config with three modes. first_chunk (the
default) flushes once after the first non-empty text chunk, off never
sends a protocol-level flush, and every_sentence flushes when a chunk
ends with a sentence terminator (. ? ! 。 ? !), matching the
elevenlabs per_sentence_flush behavior.

flush_mode is read from params in update_params() and removed from
params so it is never merged into the vendor start request body.
Invalid values raise ValueError listing the allowed modes.

The final chunk (text_input_end) is followed by stop, which already
flushes server-side, so it skips the flush. Blank text never flushes.
The reconnect and get() replay paths apply the same rules.

Also fix the mocked-client tests to invoke the on_text_send callback,
correct a mock signature, and drop a redundant CancelledError re-raise
so the extension lints at 10.00/10.
Add a regression test asserting the real FishAudioTTSClient fires
on_text_send immediately before each text payload is written to the
socket.

The mocked-client tests replace the client entirely, so nothing
exercised this callback. When the TTFB anchor moved to text-send time,
those mocks silently kept their old shape and the tests stopped
covering the audio_start and TTFB events, failing only on their
assertions. This test pins the contract the extension relies on.
Log an error when the first audio chunk arrives without a text-send
timestamp. In that state tts_audio_start, the TTFB metric, and the audio
frame metadata registered by audio_start are all dropped with no signal
at all, so the degradation is invisible from the outside.

The extension always wires on_text_send, so this branch should never run
in production; the log is there to make a future regression loud.

Also tighten the mocked-client test helper so a dropped on_text_send
fails with a clear contract message instead of a misleading "Did not
receive tts_audio_start" assertion, keep the dump test double
consistent, and cover the new log with a unit test.
@github-actions

Copy link
Copy Markdown

[P1] Stop reconnecting after a completed vendor session

fish_audio_tts.py:271

After receiving finish, the receive loop queues EVENT_TTS_END and continues reading. Fish Audio's documented protocol closes the connection after that event, so normal completion enters this reconnect path. _reconnect_loop() then resends the completed request's text. The extension retains current_request_id, and _on_audio_chunk() checks only that ID, so replayed PCM can be emitted after tts_audio_end; the additional end event also remains in the shared queue for a later request. Retire the socket on a terminal event and restrict reconnect to an unfinished session.

[P2] Avoid replaying text whose audio has already been emitted

fish_audio_tts.py:308–311

If the socket drops after the first PCM chunks, reconnect unconditionally resends every entry in _session_texts. Unlike the other recovery path in _ensure_connection(), this path never checks _session_audio_seen. The replacement synthesizes the spoken prefix again, and its callback forwards it under the same request ID, producing repeated speech and counting it again in the duration. Once audio has been emitted, fail the interrupted request unless the protocol supplies a proven resume boundary; limit full-text replay to requests that have emitted no audio.

[P2] Preserve errors carried by the vendor's finish event

fish_audio_tts.py:257–259

Fish Audio reports synthesis failure with {"event": "finish", "reason": "error"}, which the previous SDK raised as an error. This branch ignores reason and always emits EVENT_TTS_END, so a final input is completed with REQUEST_END and no error even when synthesis failed or produced only partial audio. Inspect the finish reason and route error completion through the existing error event handling, retaining available vendor diagnostics.

[P2] Exercise the real client's required reconnect ownership cases

tests/test_robustness.py:136

The reconnect test replaces FishAudioTTSClient entirely, so it never exercises the new _reconnect_loop(). The added socket tests also omit dropped-connection recovery, replacement-connection ownership, failure while reporting disconnect, and cancellation during reconnect. These are mandatory WebSocket cases in .agents/skills/ten-tts-review/references/tests.md. Add deterministic fake-socket tests through the real client and extension callbacks, asserting that old connections cannot emit or close a replacement, no accepted audio is replayed, and a subsequent request completes.

[P2] Add the required exact audio-end accounting assertions

tests/test_request_completion.py:50

The updated suite uses real clock values and only asserts that request_event_interval_ms is nonnegative. It has no controlled-clock assertion distinguishing the audio-start/end interval from text-send latency, and no partial-audio-then-error case checking the exact emitted PCM duration. The flush test also allows a 10 ms duration discrepancy. The TTS review guide requires exact local coverage for success, partial error, interruption, and no-audio termination for every touched extension. Add controlled-clock and known-PCM cases that assert both fields exactly, including a zero interval when no audio-start event was emitted.

@github-actions

Copy link
Copy Markdown

[P2] Preserve the pending stop flag across reconnects

fish_audio_tts.py:337

For a final input, get() sets _session_stop_requested = True. On reconnect, _start_session() resets that field to False; this branch sends the saved stop but never restores the field. If the replacement connection drops again before finish, the next reconnect snapshots False and replays the text without sending stop. The vendor then waits for more input while the final get() waits indefinitely for a terminal event, blocking subsequent requests. Preserve the finalization state on the replacement session and cover two successive drops before the first audio with a deterministic socket test.

[P2] Keep user text out of vendor diagnostic logs

fish_audio_tts.py:211

Every text event now logs the complete text in payload={message} at INFO under LOG_CATEGORY_VENDOR. Consequently, a handler configured to collect vendor diagnostics without collecting LOG_CATEGORY_TRANSCRIPTS still persists conversation content. This bypasses the transcript category that request_tts() now correctly uses and exposes potentially private utterances in operational logs. Keep vendor logs to event names, sizes, and sanitized configuration; put any explicitly enabled text logging under the transcript category. Add an assertion that vendor log messages omit the supplied text.

[P2] Select the response-header log in the observability test

tests/test_observability.py:122

The normal get("hello") path now makes five unconditional log_info() calls: handshake, response headers, and the start, text, and stop sends. Therefore assert_called_once() necessarily fails. Merely changing the count would also leave the following assertions inspecting the last stop log instead of the response headers. Select the response-header entry from call_args_list and retain the datacenter, trace ID, and request ID assertions so the extension-local suite can pass with the new logging.

The five findings in the earlier review also remain unresolved at a26ac60, including the P1 replay after normal completion and the mandatory reconnect and exact audio-end accounting test gaps.

@YiminW YiminW changed the title Dev/fish audio tts ttfb feat: fish audio tts ttfb Oct 14, 2026
@YiminW
YiminW force-pushed the dev/fish_audio_tts_ttfb branch from adbe0c1 to 5f274ee Compare October 14, 2026 06:58
@github-actions

Copy link
Copy Markdown

[P1] Retire completed sessions before handling the socket close

fish_audio_tts.py:282–284

After queuing EVENT_TTS_END, the receiver continues reading. Fish Audio's protocol closes the connection after finish, so successful completion then calls _schedule_reconnect(). The reconnect loop retains and resends the completed session's text. _on_audio_chunk() still accepts its request ID, and the base audio helper does not suppress frames after completion, allowing PCM after tts_audio_end; another end event can also remain in the shared queue for the next request. Retire the terminal session and restrict recovery to unfinished work.

[P2] Prevent full-text replay after audio has been emitted

fish_audio_tts.py:336–340

When a connection drops during synthesis after some PCM has reached TEN, this loop replays every saved text chunk without checking _session_audio_seen. The replacement synthesizes the spoken prefix again, and its callback forwards it under the same request ID, producing repeated speech. Restrict full replay to sessions with no emitted audio; otherwise terminate the interrupted request with an error unless the vendor provides a verified resume boundary.

[P2] Preserve synthesis errors reported in the finish reason

fish_audio_tts.py:282–284

The documented failure response is {"event":"finish","reason":"error"}, but this branch ignores reason and always emits EVENT_TTS_END. For final input, the extension consequently emits REQUEST_END without an error even if synthesis failed before audio or after a partial response. Route an error finish through the error completion path and retain available vendor diagnostics.

[P2] Keep the retry budget across repeated receive failures

fish_audio_tts.py:344

The reconnect counter resets as soon as the handshake and replay sends succeed. If the upstream repeatedly accepts those sends and then closes before audio or finish, each receiver failure starts another recovery with the counter back at zero. MAX_RECONNECT_ATTEMPTS is never reached, while the final get() remains blocked on _events.get() and subsequent requests cannot progress. Preserve the request's retry budget until synthesis demonstrates recovery, and test repeated successful upgrades followed by receive failures.

[P2] Exercise the real client's required reconnect ownership cases

tests/test_robustness.py:136

The reconnect test replaces FishAudioTTSClient entirely, so it never exercises the new recovery implementation. The socket tests also omit replacement-connection ownership, failure while reporting disconnect, and cancellation during reconnect. These are mandatory cases in the repository's TTS test baseline. Add deterministic tests through the real client and extension callbacks, asserting that an old connection cannot publish stale audio or close its replacement, cancellation terminates recovery, and the next request completes.

[P2] Add exact audio-end duration and interval assertions

tests/test_request_completion.py:41

The updated suite uses the real clock and only checks that the event interval is nonnegative. It has no controlled-clock assertion distinguishing the audio-start/end interval from text-send latency, and no partial-audio-then-error case asserting exact emitted PCM duration; the flush test still permits a 10 ms discrepancy. The TTS test baseline requires exact coverage for success, partial error, interruption, and no-audio termination. Use known PCM and controlled clocks to assert both fields, including a zero interval when no audio-start event was emitted.

@wangyoucao577
wangyoucao577 merged commit ff0f29b into main Oct 16, 2026
46 of 49 checks passed
@wangyoucao577
wangyoucao577 deleted the dev/fish_audio_tts_ttfb branch October 16, 2026 10:52
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