Skip to content

[refactor] make thinking sound not repeat - #4

Closed
yui0303 wants to merge 252 commits into
mainfrom
pathors
Closed

yui0303 wants to merge 252 commits into
mainfrom
pathors

Conversation

@yui0303

@yui0303 yui0303 commented Oct 5, 2026

Copy link
Copy Markdown

AS IS

  • BackgroundAudioPlayer can pick the same thinking sound from a list twice
    in a row, so repeated thinking states play an identical filler.

TO BE

  • _select_sound_from_list / _normalize_sound_source / play gain an exclude
    arg; _agent_state_changed normalizes once excluding the last played sound,
    plays it, and records it in _last_thinking_sound.

Reimplemented on upstream 1.6.3 (not cherry-picked) from CeranaStudio/agents
f700a5d (orig author Lanznx); adapted to 1.6.3 _normalize_sound_source
returning AudioConfig instead of a (source, volume) tuple.

Co-Authored-By: Claude Opus 4.8 (1M context) noreply@anthropic.com

chenghao-mou and others added 30 commits September 23, 2026 14:24
Co-authored-by: Long Chen <longch1024@gmail.com>
Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
…ivekit#7388)

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
Co-authored-by: rosetta-livekit-bot[bot] <282703043+rosetta-livekit-bot[bot]@users.noreply.github.com>
Co-authored-by: Chenghao Mou <chenghao.mou@livekit.io>
Co-authored-by: devin-ai-integration[bot] <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…it#7309)

Co-authored-by: Chenghao Mou <chenghao.mou@livekit.io>
…ock (livekit#7414)

Co-authored-by: Claude <noreply@anthropic.com>
…tas (livekit#7416)

Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: DeepSeek <noreply@deepseek.com>
…ivekit#7367)

Co-authored-by: sxh313 <250161920+sxh313@users.noreply.github.com>
…ivekit#7471)

Co-authored-by: sxh313 <250161920+sxh313@users.noreply.github.com>
Co-authored-by: Mir <7480806+mirimadahmed@users.noreply.github.com>
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
kun654 and others added 26 commits October 22, 2026 21:38
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
## AS IS
- BackgroundAudioPlayer can pick the same thinking sound from a list twice
  in a row, so repeated thinking states play an identical filler.

## TO BE
- _select_sound_from_list / _normalize_sound_source / play gain an `exclude`
  arg; _agent_state_changed normalizes once excluding the last played sound,
  plays it, and records it in _last_thinking_sound.

Reimplemented on upstream 1.6.3 (not cherry-picked) from CeranaStudio/agents
f700a5d (orig author Lanznx); adapted to 1.6.3 _normalize_sound_source
returning AudioConfig instead of a (source, volume) tuple.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- BackgroundAudioPlayer only supports ambient + thinking sounds; there is no
  filler when the user finishes speaking, leaving a silent gap before the
  agent's first audio.

- New `eos_sound` plays once on user speaking->listening, skipped while the
  agent is already thinking/speaking, non-repeating (excludes last EOS sound),
  fired ~50% of the time, and stopped the moment the agent starts speaking.
- Subscribes/unsubscribes `user_state_changed` in start()/aclose().

Reimplemented on upstream 1.6.3 (not cherry-picked) from CeranaStudio/agents
a045cca (orig author yui0303); adapted to 1.6.3 _normalize_sound_source
returning AudioConfig. Builds on the thinking-sound exclude mechanism.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
## AS IS
- gemini-3.1-flash-live-preview 不理會 input_audio_transcription=None,
  server 每個 turn 照樣送 input_transcription;plugin 收到就累積、發
  input_audio_transcription_completed、把 user message 寫進 chat context,
  蓋掉外接 STT 的逐字稿,也可能開出幽靈 generation

## TO BE
- input_audio_transcription 明確設為 None 時,client 端忠實執行設定:
  丟棄 server 送來的 input_transcription(不累積、不發事件、不進 chat
  context、不觸發 new generation),user 逐字稿完全交給外接 STT

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- Soniox TTS plugin has no way to tag requests, so usage-log entries cannot be attributed to a caller-defined id

- TTS(client_reference_id=...) is recorded verbatim in Soniox usage logs (mirrors the existing STT param), enabling per-session cost reconciliation

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…rdown

- Gemini Live 只在 generation 結束時送 usageMetadata;caller 在第一句話
  講完前後掛斷時,close_on_disconnect 的 teardown 立刻 off 掉
  metrics_collected listener 並 cancel receive loop,in-flight 的 usage
  event 永遠收不到 —— 單 turn 通話的 LLM token usage 直接歸零
- 每通 realtime 通話的最後一個 turn 都有同樣的 race

- RealtimeSession 新增 drain_pending_metrics() hook(base 預設 no-op,
  fallback adapter 委派給 active child)
- AgentActivity._close_session 在拆 listener 之前先 await 這個 hook,
  drain 出來的 usage event 仍會進 session.usage
- Gemini plugin 實作:當前 generation 尚未收到 usageMetadata 時,最多等
  LK_GOOGLE_USAGE_DRAIN_TIMEOUT(預設 2s);已收到 / 未連線 / 正在關閉
  時不等,正常結束的通話零額外延遲

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LLsDzcMV2NVcF9o8rN52u4
## AS IS
- client_reference_id 進 TTS config 的行為沒有測試

## TO BE
- 有設時 config 帶 client_reference_id,沒設時不帶

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
## AS IS
- 1.8.2 時代我們自己在框架加了 `STTCapabilities.vad_finalize`,VAD 一偵測到
  end of speech 就送 FlushSentinel,Soniox plugin 收到後送 `{"type": "finalize"}`
- upstream livekit#7285(1.8.9)加了同概念的 `STTCapabilities.manual_flush`,由框架在
  manual commit 與 turn boundary 時 flush;Soniox plugin 那段在還沒 merge 的 livekit#7989

## TO BE
- 框架層的 vad_finalize 整套拿掉,改用 upstream 的 manual_flush(換 STT 時
  upstream 每次即時讀 capability,所以 a417497 的 refresh 修正也不需要了)
- Soniox plugin 保留我們的部分:`STTOptions.manual_flush`(預設 True,原
  vad_finalize)宣告 capability;收到 _FlushSentinel 時把 finalize 排進
  audio_queue(排在既有音訊之後);`enable_endpoint_detection` 可設定,兩者都關
  在建構時就報錯;endpoint 到達時仍有 non-final token 會 warning
- flush 時機改跟 upstream:有 turn detector 時 EOT positive 立刻 flush,
  negative 時等 max endpointing delay 的一半

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
## AS IS
- background_audio.py 與 Gemini realtime_api.py 過不了 ruff format --check

## TO BE
- ruff format 跑過

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@github-actions

Copy link
Copy Markdown

STT Test Results

Status: ✗ Some tests failed

Metric Count
✓ Passed 0
✗ Failed 5
× Errors 0
→ Skipped 40
▣ Total 45
⏱ Duration 129.4s
Failed Tests
  • tests.test_stt::test_recognize[livekit.plugins.openai]
    stt_factory = <function parameter_factory.<locals>.<lambda> at 0x7fa9df42fce0>
    request = <FixtureRequest for <Coroutine test_recognize[livekit.plugins.openai]>>
    
        @pytest.mark.usefixtures("job_process")
        @pytest.mark.parametrize("stt_factory", STTs)
        async def test_recognize(stt_factory: Callable[[], stt.STT], request):
            plugin_id = request.node.callspec.id.split("-")[0]  # e.g., "livekit.plugins.deepgram"
            sample_rate = SAMPLE_RATE
            frames, transcript, duration = await make_test_speech(sample_rate=sample_rate)
      
            # TODO: differentiate missing key vs other errors
            try:
    >           stt_instance = stt_factory()
                               ^^^^^^^^^^^^^
    
    tests/test_stt.py:153: 
    _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ 
    
    >   return pytest.param(lambda: plugin.STT(), id=plugin.__name__)
                                    ^^^^^^^^^^^^
    
    tests/test_stt.py:48: 
    _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ 
    
    self = <livekit.plugins.openai.stt.STT object at 0x7fa9df415070>
    language = 'en', detect_language = False, model = 'gpt-4o-mini-transcribe'
    prompt = NOT_GIVEN, keywords = NOT_GIVEN
    turn_detection = {'type': 'server_vad', 'threshold': 0.5, 'prefix_padding_ms': 600, 'silence_duration_ms': 350}
    noise_reduction_type = NOT_GIVEN, temperature = NOT_GIVEN, base_url = NOT_GIVEN
    api_key = NOT_GIVEN, client = None, use_realtime = False, vad = NOT_GIVEN
    
        def __init__(
            self,
            *,
            language: str | list[str] = "en",
            detect_language: bool = False,
            model: STTModels | str = "gpt-4o-mini-transcribe",
            prompt: NotGivenOr[str] = NOT_GIVEN,
            keywords: NotGivenOr[list[str]] = NOT_GIVEN,
            turn_detection: NotGivenOr[SessionTurnDetection] = NOT_GIVEN,
            noise_reduction_type: NotGivenOr[str] = NOT_GIVEN,
            temperature: NotGivenOr[float] = NOT_GIVEN,
            base_url: NotGivenOr[str] = NOT_GIVEN,
            api_ke
    
  • tests.test_stt::test_stream[livekit.plugins.fireworksai]
    self = <livekit.plugins.fireworksai.stt.SpeechStream object at 0x7fe894141250>
    
        async def __anext__(self) -> SpeechEvent:
            try:
    >           val = await self._event_aiter.__anext__()
                      ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
    E           StopAsyncIteration
    
    livekit-agents/livekit/agents/stt/stt.py:613: StopAsyncIteration
    
    During handling of the above exception, another exception occurred:
    
    stt_factory = <function parameter_factory.<locals>.<lambda> at 0x7fe886d64900>
    request = <FixtureRequest for <Coroutine test_stream[livekit.plugins.fireworksai]>>
    
        @pytest.mark.usefixtures("job_process")
        @pytest.mark.parametrize("stt_factory", STTs + STREAM_ONLY_STTs)
        async def test_stream(stt_factory: Callable[[], STT], request):
            sample_rate = SAMPLE_RATE
            plugin_id = request.node.callspec.id.split("-")[0]
            frames, transcript, _ = await make_test_speech(
                chunk_duration_ms=STREAM_CHUNK_DURATION_MS, sample_rate=sample_rate
            )
      
            # TODO: differentiate missing key vs other errors
            try:
                stt_instance: STT = stt_factory()
            except ValueError as e:
                pytest.skip(f"{plugin_id}: {e}")
      
            async with stt_instance as stt:
                label = f"{stt.model}@{stt.provider}"
                if not stt.capabilities.streaming:
                    pytest.skip(f"{label} does not support streaming")
      
                for attempt in range(MAX_RETRIES):
                    try:
                        state = {"closing": False}
      
                        async def _stream_input(
                            frames: list[rtc.AudioFrame], stream: RecognizeStream, state: dict = state
                        ):
                            for frame in frames:
                                stream.push_frame(frame)
                                await asyncio.sleep(frame.duration)
      
                            silence = rtc.AudioFrame.create(
                                sample_rate=sample_rate,
    
  • tests.test_stt::test_stream[livekit.plugins.openai]
    stt_factory = <function parameter_factory.<locals>.<lambda> at 0x7fa9df42fce0>
    request = <FixtureRequest for <Coroutine test_stream[livekit.plugins.openai]>>
    
        @pytest.mark.usefixtures("job_process")
        @pytest.mark.parametrize("stt_factory", STTs + STREAM_ONLY_STTs)
        async def test_stream(stt_factory: Callable[[], STT], request):
            sample_rate = SAMPLE_RATE
            plugin_id = request.node.callspec.id.split("-")[0]
            frames, transcript, _ = await make_test_speech(
                chunk_duration_ms=STREAM_CHUNK_DURATION_MS, sample_rate=sample_rate
            )
      
            # TODO: differentiate missing key vs other errors
            try:
    >           stt_instance: STT = stt_factory()
                                    ^^^^^^^^^^^^^
    
    tests/test_stt.py:208: 
    _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ 
    
    >   return pytest.param(lambda: plugin.STT(), id=plugin.__name__)
                                    ^^^^^^^^^^^^
    
    tests/test_stt.py:48: 
    _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ 
    
    self = <livekit.plugins.openai.stt.STT object at 0x7fa9ec2b4830>
    language = 'en', detect_language = False, model = 'gpt-4o-mini-transcribe'
    prompt = NOT_GIVEN, keywords = NOT_GIVEN
    turn_detection = {'type': 'server_vad', 'threshold': 0.5, 'prefix_padding_ms': 600, 'silence_duration_ms': 350}
    noise_reduction_type = NOT_GIVEN, temperature = NOT_GIVEN, base_url = NOT_GIVEN
    api_key = NOT_GIVEN, client = None, use_realtime = False, vad = NOT_GIVEN
    
        def __init__(
            self,
            *,
            language: str | list[str] = "en",
            detect_language: bool = False,
            model: STTModels | str = "gpt-4o-mini-transcribe",
            prompt: NotGivenOr[str] = NOT_GIVEN,
            keywords: NotGivenOr[list[str]] = NOT_GIVEN,
            turn_detection: NotGivenOr[SessionTurnDetection] = NOT_GIVEN,
            noise_reduction_type: NotGivenOr[str] = NOT_GIVEN,
            temperature: NotGivenOr[float] = NOT_GIVEN,
            base_url: N
    
  • tests.test_stt::test_stream[livekit.plugins.aws]
    stt_factory = <function parameter_factory.<locals>.<lambda> at 0x7f0fe6c387c0>
    request = <FixtureRequest for <Coroutine test_stream[livekit.plugins.aws]>>
    
        @pytest.mark.usefixtures("job_process")
        @pytest.mark.parametrize("stt_factory", STTs + STREAM_ONLY_STTs)
        async def test_stream(stt_factory: Callable[[], STT], request):
            sample_rate = SAMPLE_RATE
            plugin_id = request.node.callspec.id.split("-")[0]
            frames, transcript, _ = await make_test_speech(
                chunk_duration_ms=STREAM_CHUNK_DURATION_MS, sample_rate=sample_rate
            )
      
            # TODO: differentiate missing key vs other errors
            try:
                stt_instance: STT = stt_factory()
            except ValueError as e:
                pytest.skip(f"{plugin_id}: {e}")
      
            async with stt_instance as stt:
                label = f"{stt.model}@{stt.provider}"
                if not stt.capabilities.streaming:
                    pytest.skip(f"{label} does not support streaming")
      
                for attempt in range(MAX_RETRIES):
                    try:
                        state = {"closing": False}
      
                        async def _stream_input(
                            frames: list[rtc.AudioFrame], stream: RecognizeStream, state: dict = state
                        ):
                            for frame in frames:
                                stream.push_frame(frame)
                                await asyncio.sleep(frame.duration)
      
                            silence = rtc.AudioFrame.create(
                                sample_rate=sample_rate,
                                num_channels=1,
                                samples_per_channel=sample_rate * STREAM_CHUNK_DURATION_MS // 1000,
                            )
                            for _ in range(TRAILING_SILENCE_DURATION_MS // STREAM_CHUNK_DURATION_MS):
                                stream.push_frame(silence)
                                await asyncio.sleep(silence.duration)
      
                            stream.end_input()
    
  • tests.test_stt::test_stream[livekit.plugins.openai.realtime]
    stt_factory = <function <lambda> at 0x7fa9df4344a0>
    request = <FixtureRequest for <Coroutine test_stream[livekit.plugins.openai.realtime]>>
    
        @pytest.mark.usefixtures("job_process")
        @pytest.mark.parametrize("stt_factory", STTs + STREAM_ONLY_STTs)
        async def test_stream(stt_factory: Callable[[], STT], request):
            sample_rate = SAMPLE_RATE
            plugin_id = request.node.callspec.id.split("-")[0]
            frames, transcript, _ = await make_test_speech(
                chunk_duration_ms=STREAM_CHUNK_DURATION_MS, sample_rate=sample_rate
            )
      
            # TODO: differentiate missing key vs other errors
            try:
    >           stt_instance: STT = stt_factory()
                                    ^^^^^^^^^^^^^
    
    tests/test_stt.py:208: 
    _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ 
    
    >       pytest.param(lambda: openai.STT(use_realtime=True), id="livekit.plugins.openai.realtime"),
                                 ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
            pytest.param(
                lambda: inference.STT(model="deepgram/nova-3"), id="livekit.agents.inference.deepgram"
            ),
            pytest.param(
                lambda: inference.STT(model="cartesia/ink-whisper"),
                id="livekit.agents.inference.cartesia",
            ),
            pytest.param(
                lambda: inference.STT(
                    model="assemblyai/universal-streaming", extra_kwargs={"format_turns": True}
                ),
                id="livekit.agents.inference.assemblyai",
            ),
            pytest.param(lambda: inference.STT(model="xai/stt-1"), id="livekit.agents.inference.xai"),
        ]
    
    tests/test_stt.py:88: 
    _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ 
    
    self = <livekit.plugins.openai.stt.STT object at 0x7fa9dc644200>
    language = 'en', detect_language = False, model = 'gpt-4o-mini-transcribe'
    prompt = NOT_GIVEN, keywords = NOT_GIVEN
    turn_detection = {'type': 'server_vad', 'threshold': 0.5, 'prefix_padding_ms': 600, 'silence_duration_m
    
Skipped Tests
Test Reason
tests.test_stt::test_recognize[livekit.plugins.deepgram] livekit.plugins.deepgram: Deepgram API key is required, either as argument or set DEEPGRAM_API_KEY environment variable
tests.test_stt::test_recognize[livekit.plugins.elevenlabs] livekit.plugins.elevenlabs: ElevenLabs API key is required, either as argument or set ELEVEN_API_KEY environmental variable
tests.test_stt::test_recognize[livekit.plugins.fal] livekit.plugins.fal: fal AI API key is required. It should be set with env FAL_KEY
tests.test_stt::test_recognize[livekit.plugins.mistralai] livekit.plugins.mistralai: Mistral AI API key is required. Set MISTRAL_API_KEY or pass api_key
tests.test_stt::test_recognize[livekit.plugins.assemblyai] livekit.plugins.assemblyai: AssemblyAI API key is required. Pass one in via the api_key parameter, or set it as the ASSEMBLYAI_API_KEY environment variable
tests.test_stt::test_recognize[livekit.plugins.fireworksai] unknown@FireworksAI does not support batch recognition
tests.test_stt::test_recognize[livekit.plugins.nvidia] livekit.plugins.nvidia: NVIDIA_API_KEY is not set while using SSL. Either pass api_key parameter, set NVIDIA_API_KEY environment variable or disable SSL and use a locally hosted Riva NIM service.
tests.test_stt::test_recognize[livekit.plugins.speechmatics] livekit.plugins.speechmatics: Missing Speechmatics API key
tests.test_stt::test_recognize[livekit.plugins.gladia] livekit.plugins.gladia: Gladia API key is required. Set GLADIA_API_KEY or pass api_key
tests.test_stt::test_recognize[livekit.plugins.deepgram.STTv2] livekit.plugins.deepgram.STTv2: Deepgram API key is required
tests.test_stt::test_recognize[livekit.plugins.azure] livekit.plugins.azure: AZURE_SPEECH_HOST or AZURE_SPEECH_KEY and AZURE_SPEECH_REGION or speech_auth_token and AZURE_SPEECH_REGION or AZURE_SPEECH_KEY and speech_endpoint must be set
tests.test_stt::test_recognize[livekit.plugins.gradium.STT] livekit.plugins.gradium.STT: Gradium API key is required. Pass one in via the api_key parameter, or set it as the GRADIUM_API_KEY environment variable
tests.test_stt::test_recognize[livekit.plugins.aws] unknown@Amazon Transcribe does not support batch recognition
tests.test_stt::test_stream[livekit.plugins.deepgram] livekit.plugins.deepgram: Deepgram API key is required, either as argument or set DEEPGRAM_API_KEY environment variable
tests.test_stt::test_recognize[livekit.plugins.sarvam] livekit.plugins.sarvam: Sarvam API key is required. Provide it directly or set SARVAM_API_KEY environment variable.
tests.test_stt::test_stream[livekit.plugins.assemblyai] livekit.plugins.assemblyai: AssemblyAI API key is required. Pass one in via the api_key parameter, or set it as the ASSEMBLYAI_API_KEY environment variable
tests.test_stt::test_recognize[livekit.plugins.cartesia] livekit.plugins.cartesia: Cartesia API key is required, either as argument or set CARTESIA_API_KEY environment variable
tests.test_stt::test_recognize[livekit.plugins.cartesia._legacy] livekit.plugins.cartesia._legacy: Cartesia API key is required, either as argument or set CARTESIA_API_KEY environment variable
tests.test_stt::test_stream[livekit.plugins.speechmatics] livekit.plugins.speechmatics: Missing Speechmatics API key
tests.test_stt::test_recognize[livekit.plugins.soniox] livekit.plugins.soniox: Soniox API key is required. Set SONIOX_API_KEY or pass api_key
... ... and 20 more

Triggered by workflow run #1

@grace0950

Copy link
Copy Markdown

關閉:這張是舊的 pathors → main。fork 的慣例是 main 只當 upstream 鏡像、我們的改動都在 pathors,不會 merge 回 main。pathors 已於 2026-10-29 升到 livekit-agents 1.8.9(pathorsAI/pathors#4095)。

@grace0950 grace0950 closed this Oct 29, 2026
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.