From 1e956f5eb6f949a910a5068e386358bf0ac13e2d Mon Sep 17 00:00:00 2001 From: mkbk Date: Thu, 18 Jun 2026 11:23:20 +0800 Subject: [PATCH] =?UTF-8?q?[=E5=AE=9E=E6=97=B6=E5=AD=97=E5=B9=95=E7=AB=AF?= =?UTF-8?q?=E7=82=B9]=EF=BC=9A=E5=AE=8C=E6=88=90=E6=97=A0=E6=96=B0?= =?UTF-8?q?=E6=96=87=E5=AD=97=E5=BF=AB=E9=80=9F=E7=BB=93=E6=9D=9F=E5=BD=95?= =?UTF-8?q?=E9=9F=B3=EF=BC=8C=E5=8C=85=E5=90=AB1.5=E7=A7=92=E5=81=9C?= =?UTF-8?q?=E6=BB=9E=E9=85=8D=E7=BD=AE=E3=80=81Capture=E7=AB=AF=E7=82=B9?= =?UTF-8?q?=E5=92=8C=E5=9B=9E=E5=BD=92=E6=B5=8B=E8=AF=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env.example | 1 + README.md | 3 +- .../design.md | 12 ++++ .../proposal.md | 12 ++++ .../specs/voice-pet-pipeline/spec.md | 5 ++ .../tasks.md | 7 +++ src/owner_voice_pet/assistant_pipeline.py | 27 ++++++-- src/owner_voice_pet/cli.py | 2 + src/owner_voice_pet/config.py | 14 +++++ src/owner_voice_pet/vad.py | 3 + tests/test_cli_acceptance.py | 1 + tests/test_live_runtime.py | 63 +++++++++++++++++++ tests/test_models_config.py | 6 ++ 13 files changed, 151 insertions(+), 5 deletions(-) diff --git a/.env.example b/.env.example index 563864d..1e03f3f 100644 --- a/.env.example +++ b/.env.example @@ -3,6 +3,7 @@ OWNER_LLM_API_KEY= OWNER_LLM_MODEL=mimo-v2.5 OWNER_LLM_API_STYLE=chat_completions OWNER_REALTIME_TRANSCRIPT_ENABLED=1 +OWNER_REALTIME_TRANSCRIPT_IDLE_TIMEOUT_MS=1500 OWNER_AUDIO_INPUT_DEVICE= OWNER_AUDIO_OUTPUT_DEVICE= OWNER_ASSET_DIR=assets/pet diff --git a/README.md b/README.md index 9ccfc90..b8925b3 100644 --- a/README.md +++ b/README.md @@ -35,6 +35,7 @@ OWNER_LLM_API_KEY= OWNER_LLM_MODEL=mimo-v2.5 OWNER_LLM_API_STYLE=chat_completions OWNER_REALTIME_TRANSCRIPT_ENABLED=1 +OWNER_REALTIME_TRANSCRIPT_IDLE_TIMEOUT_MS=1500 OWNER_WAKE_PROVIDER=local_kws OWNER_WAKE_KEYWORDS_FILE= OWNER_WAKE_KWS_THRESHOLD=0.15 @@ -68,7 +69,7 @@ OWNER_CONTEXT_MODE=session_memory `OWNER_NOISE_FILTER_ENABLED=1` 表示唤醒后的正式问题阶段默认启用本地 GTCRN 降噪。降噪后的同一份音频会进入 VAD、实时字幕和 final STT;wake 阶段默认保持原始音频,`OWNER_WAKE_DENOISE_ENABLED=0` 可以避免降噪影响“小杰小杰”的 KWS 特征。 -`OWNER_REALTIME_TRANSCRIPT_ENABLED=1` 表示录音期间会使用本地 streaming STT 实时显示中间转写,终端会输出 `实时转写:...`。为了过滤噪音,实时字幕默认不会显示 `家`、`家确` 这类很短的瞬时误识别;最终发送给 LLM 的内容仍只以 `转写结果:...` 为准。设置为 `0` 可以临时关闭实时显示。 +`OWNER_REALTIME_TRANSCRIPT_ENABLED=1` 表示录音期间会使用本地 streaming STT 实时显示中间转写,终端会输出 `实时转写:...`。为了过滤噪音,实时字幕默认不会显示 `家`、`家确` 这类很短的瞬时误识别;最终发送给 LLM 的内容仍只以 `转写结果:...` 为准。`OWNER_REALTIME_TRANSCRIPT_IDLE_TIMEOUT_MS=1500` 表示已有实时字幕后,如果 1.5 秒内没有新的文字输出,就直接结束本轮录音进入 final STT;设置为 `0` 可以关闭这个停滞端点。`OWNER_REALTIME_TRANSCRIPT_ENABLED=0` 可以临时关闭实时显示。 ## 本地模型 diff --git a/openspec/changes/separate-wake-and-realtime-transcript/design.md b/openspec/changes/separate-wake-and-realtime-transcript/design.md index ba4bf82..a34ab54 100644 --- a/openspec/changes/separate-wake-and-realtime-transcript/design.md +++ b/openspec/changes/separate-wake-and-realtime-transcript/design.md @@ -147,6 +147,18 @@ The first implementation is a per-turn heuristic endpoint, not persistent voicep 4. `VoiceAssistantPipeline` 在 `speech_started` 后把 capture 阶段已开始录音的帧 feed 给 realtime STT session;当 partial 文本变化时才 emit,避免刷屏。 5. `OWNER_REALTIME_TRANSCRIPT_ENABLED=1` 默认启用;设置为 `0` 可临时回退到只显示 final transcript。 +## Realtime Transcript Idle Endpoint + +真人 `run-live` 日志显示,用户已看到实时字幕完整推进后,capture 仍可能继续等待 VAD 或主说话人端点。新增 `OWNER_REALTIME_TRANSCRIPT_IDLE_TIMEOUT_MS` 作为文本级端点,默认 `1500` ms: + +1. CaptureStage 只在已经输出过至少一次有效 `transcript_partial` 后启用该端点。 +2. 每次 `transcript_partial` 真实发出时,记录该帧的音频时间戳为 `last_partial_ms`。 +3. 后续已开始录音但没有新的 partial 文本输出时,如果当前帧时间戳与 `last_partial_ms` 的差值达到配置阈值,调用 `VadRecorder.finish("partial_transcript_idle")` 构建当前录音片段。 +4. 构建出的 segment 继续走 final STT、LLM、TTS,不把 partial 文本写入上下文。 +5. 配置为 `0` 时关闭该端点,保留只依赖主说话人/VAD 的旧行为。 + +该端点使用音频帧时间戳而不是 wall-clock,避免批量读帧或 CPU 抖动导致误判。它不会在没有 partial 的场景提前结束,避免 STT 尚未给出第一段文本时切掉用户开头。 + ## Model Files ```text diff --git a/openspec/changes/separate-wake-and-realtime-transcript/proposal.md b/openspec/changes/separate-wake-and-realtime-transcript/proposal.md index c2410f3..02c3805 100644 --- a/openspec/changes/separate-wake-and-realtime-transcript/proposal.md +++ b/openspec/changes/separate-wake-and-realtime-transcript/proposal.md @@ -107,6 +107,7 @@ 42. `real-live-check --no-playback` SHALL 仍执行本地 TTS 合成和 pipeline 播放事件记录,但不实际向扬声器发声,便于自动化测试和无声环境排查。 43. `real-live-check` SHALL 输出命令级开始/结束时间、总耗时、阶段耗时、pipeline 事件时间线和按 turn/stage 聚合的响应耗时。 44. `real-live-check` SHALL 输出每次云端 LLM 请求真正发出的 `sent_at`、首个文本回复耗时和请求总耗时,且不得泄露 API key。 +45. `run-live` capture 阶段 SHALL 支持实时字幕停滞端点:当已经输出过 `transcript_partial` 后,如果 `OWNER_REALTIME_TRANSCRIPT_IDLE_TIMEOUT_MS` 时间内没有新的实时字幕文本输出,当前正式问题采集必须立即结束并进入 final STT。 ### 非功能需求 @@ -163,6 +164,7 @@ 17. `OWNER_NOISE_FILTER_ENABLED=1`:正式问题阶段默认启用本地降噪。 18. `OWNER_WAKE_DENOISE_ENABLED=0`:wake 阶段默认不启用降噪。 19. `OWNER_NOISE_FILTER_PROVIDER=sherpa_onnx_gtcrn`:第一版本地降噪 provider。 +20. `OWNER_REALTIME_TRANSCRIPT_IDLE_TIMEOUT_MS=1500`:已有实时字幕后,若 1.5 秒没有新的文字输出,则结束本轮录音;设置为 `0` 可关闭该端点。 终端输出: @@ -304,6 +306,7 @@ standby 12. partial 稳定过滤:实时字幕会先去除空白和标点,计算有效字符数;有效字符数小于 2 的文本直接忽略;若新文本比上一次已显示文本短且不构成稳定前缀推进,也忽略,避免把瞬时噪声显示给用户。 13. 零时长播放后清理:`_drain_input_after_playback()` 先执行一次 `transport.flush_input()`;若 `post_playback_drain_ms <= 0` 立即返回;若配置为正数,才按用户显式配置继续 read/drop drain window 并最终再次 flush。 14. 模拟 flush 隔离:`MemoryAudioTransport` 默认保持 flush 清空语义;模拟 live pipeline 显式设置 `flush_clears_input=False`,只记录 flush 调用,不删除预排帧,避免将真实时间队列与离线 fixture 序列混淆。 +15. 实时字幕停滞端点:CaptureStage 记录最后一次成功输出 `transcript_partial` 的音频时间戳;后续已开始录音但没有新的 partial 文本推进时,若当前帧时间戳与最后 partial 输出时间差达到 `OWNER_REALTIME_TRANSCRIPT_IDLE_TIMEOUT_MS`,调用 recorder `finish("partial_transcript_idle")` 构建当前片段并发出 `speech_ended`。 ### 数据库/状态管理变更 @@ -451,6 +454,13 @@ standby - [ ] 14.3 实现 LLM 请求发送时间和响应耗时;前置条件:14.2 完成;验收标准:每次 LLM 调用记录 `sent_at`、`first_delta_ms`、`duration_ms`、最后 user 预览,且不包含 key;测试要点:RecordingLlmProvider 单测;优先级:P0;预计:35 分钟。 - [ ] 14.4 执行真实命令验收和全量门禁并提交;前置条件:14.1 至 14.3 完成;验收标准:`real-live-check --turns 2 --no-playback` 和默认播放版本均输出 timing 且成功,全量门禁通过后中文提交;优先级:P0;预计:60 分钟。 +### 15. 实时字幕停滞端点 + +- [ ] 15.1 更新 OpenSpec 和 README 描述 1.5 秒无新实时字幕即结束录音;前置条件:真人 `run-live` 日志确认 final STT 前仍等待过久;验收标准:proposal/design/spec/tasks/README 明确 `OWNER_REALTIME_TRANSCRIPT_IDLE_TIMEOUT_MS=1500`、关闭方式和 end_reason;测试要点:OpenSpec strict;优先级:P0;预计:30 分钟。 +- [ ] 15.2 实现配置与 capture 端点;前置条件:15.1 完成;验收标准:`AppConfig`、`.env.example`、`--show-config` 支持新配置,`VoiceAssistantPipeline` 在已有 partial 后 1500 ms 无新文本时调用 recorder finish;测试要点:配置解析和 show-config 单测;优先级:P0;预计:45 分钟。 +- [ ] 15.3 补充 recorder finish 和回归测试;前置条件:15.2 完成;验收标准:持续有语音但 partial 不再推进时,segment end_reason 为 `partial_transcript_idle`,final STT 仍执行且 partial 不进上下文;测试要点:live runtime 单测;优先级:P0;预计:45 分钟。 +- [ ] 15.4 执行门禁并提交“实时字幕端点”模块;前置条件:15.1 至 15.3 完成;验收标准:compileall、unittest、simulate-live、security-check、OpenSpec strict、git diff check 通过后中文提交;优先级:P0;预计:45 分钟。 + ## Spec Deltas ### 新增能力 @@ -474,6 +484,7 @@ standby 13. `Real provider live fixture acceptance`:新增真实 provider 脚本化闭环验收和 ACK/TTS 播放后 0ms flush 语义,确保真实本地模型、云端 LLM、本地 TTS/播放在同一 pipeline 中可连续运行。 14. `Real live check CLI`:新增 `real-live-check` 可重复命令,把完整真实 Provider 链路验收从一次性脚本升级为仓库内正式工具。 15. `Real live check timing`:新增真实流程命令计时输出,覆盖命令发起时间、LLM 请求发送时间、各阶段响应耗时和 pipeline 事件时间线。 +16. `Realtime transcript idle endpoint`:新增已有实时字幕后 1.5 秒无新文字即结束录音的端点要求,避免用户已停说但 capture 继续等待 VAD/音色端点。 ### 删除项 @@ -498,6 +509,7 @@ standby 11. M11:真实 provider 脚本化闭环和 ACK 缓冲修正完成后提交;仍保留用户真人 `run-live` 体验验收,不在用户确认前归档。 12. M12:`real-live-check` 正式 CLI、文档、测试和真实命令验收完成后提交;仍保留物理麦克风 `run-live` 真人体验验收。 13. M13:`real-live-check` 计时输出完成后提交,使后续排查可以直接看到每段耗时。 +14. M14:实时字幕停滞端点完成后提交,用户真实 `run-live` 若已有 partial 后 1.5 秒无新文字,应快速进入 final STT。 估时: diff --git a/openspec/changes/separate-wake-and-realtime-transcript/specs/voice-pet-pipeline/spec.md b/openspec/changes/separate-wake-and-realtime-transcript/specs/voice-pet-pipeline/spec.md index ca51428..9543857 100644 --- a/openspec/changes/separate-wake-and-realtime-transcript/specs/voice-pet-pipeline/spec.md +++ b/openspec/changes/separate-wake-and-realtime-transcript/specs/voice-pet-pipeline/spec.md @@ -56,6 +56,11 @@ The live runtime SHALL display recognized user utterance text in the terminal bo - **WHEN** a partial transcript is emitted - **THEN** it SHALL be treated as user-visible feedback only and SHALL NOT be appended to the conversation context or sent to the LLM +#### Scenario: Realtime transcript stops advancing +- **WHEN** the user utterance has started, at least one partial transcript has been displayed, and no new partial transcript text is emitted for `OWNER_REALTIME_TRANSCRIPT_IDLE_TIMEOUT_MS` +- **THEN** capture SHALL end the current utterance with end reason `partial_transcript_idle` +- **AND** the runtime SHALL proceed to final STT for the captured audio instead of waiting for VAD maximum recording duration + #### Scenario: User utterance is transcribed - **WHEN** a live turn captures a user utterance and STT returns non-empty text - **THEN** the terminal output SHALL include a transcript message containing the recognized text before the thinking/LLM status is emitted diff --git a/openspec/changes/separate-wake-and-realtime-transcript/tasks.md b/openspec/changes/separate-wake-and-realtime-transcript/tasks.md index 51ccbfe..536d0d1 100644 --- a/openspec/changes/separate-wake-and-realtime-transcript/tasks.md +++ b/openspec/changes/separate-wake-and-realtime-transcript/tasks.md @@ -111,3 +111,10 @@ - [x] 14.2 实现命令级和事件级计时;前置条件:14.1 完成;验收标准:`real-live-check` JSON 包含命令总耗时、生成音频/build/pipeline phase 耗时、每轮 wake/ACK/capture/STT/LLM/TTS/turn_total 耗时;测试要点:stage timing 单测;优先级:P0;预计:45 分钟。 - [x] 14.3 实现 LLM 请求发送时间和响应耗时;前置条件:14.2 完成;验收标准:每次 LLM 调用记录 `sent_at`、`first_delta_ms`、`duration_ms`、最后 user 预览,且不包含 key;测试要点:RecordingLlmProvider 单测;优先级:P0;预计:35 分钟。 - [x] 14.4 执行真实命令验收和全量门禁并提交;前置条件:14.1 至 14.3 完成;验收标准:`real-live-check --turns 2 --no-playback` 和默认播放版本均输出 timing 且成功,全量门禁通过后中文提交;优先级:P0;预计:60 分钟。 + +## 15. 实时字幕停滞端点 + +- [x] 15.1 更新 OpenSpec 和 README 描述 1.5 秒无新实时字幕即结束录音;前置条件:真人 `run-live` 日志确认 final STT 前仍等待过久;验收标准:proposal/design/spec/tasks/README 明确 `OWNER_REALTIME_TRANSCRIPT_IDLE_TIMEOUT_MS=1500`、关闭方式和 end_reason;测试要点:OpenSpec strict;优先级:P0;预计:30 分钟。 +- [x] 15.2 实现配置与 capture 端点;前置条件:15.1 完成;验收标准:`AppConfig`、`.env.example`、`--show-config` 支持新配置,`VoiceAssistantPipeline` 在已有 partial 后 1500 ms 无新文本时调用 recorder finish;测试要点:配置解析和 show-config 单测;优先级:P0;预计:45 分钟。 +- [x] 15.3 补充 recorder finish 和回归测试;前置条件:15.2 完成;验收标准:持续有语音但 partial 不再推进时,segment end_reason 为 `partial_transcript_idle`,final STT 仍执行且 partial 不进上下文;测试要点:live runtime 单测;优先级:P0;预计:45 分钟。 +- [x] 15.4 执行门禁并提交“实时字幕端点”模块;前置条件:15.1 至 15.3 完成;验收标准:compileall、unittest、simulate-live、security-check、OpenSpec strict、git diff check 通过后中文提交;优先级:P0;预计:45 分钟。 diff --git a/src/owner_voice_pet/assistant_pipeline.py b/src/owner_voice_pet/assistant_pipeline.py index 64f2209..a9b30aa 100644 --- a/src/owner_voice_pet/assistant_pipeline.py +++ b/src/owner_voice_pet/assistant_pipeline.py @@ -163,6 +163,7 @@ class TurnController: self.vad_recorder.provider.reset() self.audio_preprocessor.reset() realtime_session = self._start_realtime_transcript() + last_partial_ms: int | None = None self._event(CAPTURE_STARTED, PipelineState.RECORDING, state_message, turn_id=turn_id) while True: frames = self.transport.read_frames(timeout_ms=100) @@ -180,7 +181,8 @@ class TurnController: if isinstance(result, ProviderError): return result if self.vad_recorder.started and realtime_session is not None: - self._emit_realtime_transcript(realtime_session, frame, turn_id) + if self._emit_realtime_transcript(realtime_session, frame, turn_id): + last_partial_ms = frame.timestamp_ms if isinstance(result, AudioSegment): if realtime_session is not None: self._finish_realtime_transcript(realtime_session, turn_id) @@ -192,6 +194,18 @@ class TurnController: payload={"end_reason": result.metadata.get("end_reason", "")}, ) return result + if self._should_end_after_realtime_idle(last_partial_ms, frame.timestamp_ms): + if realtime_session is not None: + self._finish_realtime_transcript(realtime_session, turn_id) + result = self.vad_recorder.finish("partial_transcript_idle") + self._event( + SPEECH_ENDED, + PipelineState.RECORDING, + "用户语音结束", + turn_id=turn_id, + payload={"end_reason": result.metadata.get("end_reason", "")}, + ) + return result def _start_realtime_transcript(self) -> RealtimeTranscriptSession | None: if not self.config.realtime_transcript_enabled or self.realtime_stt is None: @@ -203,14 +217,19 @@ class TurnController: realtime_session: RealtimeTranscriptSession, frame: AudioFrame, turn_id: int, - ) -> None: + ) -> bool: transcript = realtime_session.accept_frame(frame) if transcript is None: - return + return False text = transcript.normalized_text if not is_valid_transcript_text(text): - return + return False self._event(TRANSCRIPT_PARTIAL, PipelineState.RECORDING, "", turn_id=turn_id, payload={"text": text}) + return True + + def _should_end_after_realtime_idle(self, last_partial_ms: int | None, current_ms: int) -> bool: + timeout_ms = self.config.realtime_transcript_idle_timeout_ms + return timeout_ms > 0 and last_partial_ms is not None and current_ms - last_partial_ms >= timeout_ms def _finish_realtime_transcript( self, diff --git a/src/owner_voice_pet/cli.py b/src/owner_voice_pet/cli.py index f2c4a34..77733a4 100644 --- a/src/owner_voice_pet/cli.py +++ b/src/owner_voice_pet/cli.py @@ -65,6 +65,7 @@ def main(argv: list[str] | None = None) -> int: "llm_api_style": config.llm_api_style, "llm_stream": config.llm_stream, "realtime_transcript_enabled": config.realtime_transcript_enabled, + "realtime_transcript_idle_timeout_ms": config.realtime_transcript_idle_timeout_ms, "llm_api_key_present": bool(config.llm_api_key), "asset_dir": str(config.asset_dir), "wake_provider": config.wake_provider, @@ -201,6 +202,7 @@ def main(argv: list[str] | None = None) -> int: llm_api_style=config.llm_api_style, llm_stream=False, realtime_transcript_enabled=config.realtime_transcript_enabled, + realtime_transcript_idle_timeout_ms=config.realtime_transcript_idle_timeout_ms, audio_input_device=config.audio_input_device, audio_output_device=config.audio_output_device, asset_dir=config.asset_dir, diff --git a/src/owner_voice_pet/config.py b/src/owner_voice_pet/config.py index eecc0cb..340f31c 100644 --- a/src/owner_voice_pet/config.py +++ b/src/owner_voice_pet/config.py @@ -17,6 +17,7 @@ class AppConfig: llm_api_style: str = "chat_completions" llm_stream: bool = True realtime_transcript_enabled: bool = True + realtime_transcript_idle_timeout_ms: int = 1500 audio_input_device: str | None = None audio_output_device: str | None = None asset_dir: Path = Path("assets/pet") @@ -71,6 +72,9 @@ class AppConfig: llm_stream=(get("LLM_STREAM", "1") or "1").lower() not in {"0", "false", "no"}, realtime_transcript_enabled=(get("REALTIME_TRANSCRIPT_ENABLED", "1") or "1").lower() not in {"0", "false", "no"}, + realtime_transcript_idle_timeout_ms=int( + get("REALTIME_TRANSCRIPT_IDLE_TIMEOUT_MS", "1500") or "1500" + ), audio_input_device=get("AUDIO_INPUT_DEVICE"), audio_output_device=get("AUDIO_OUTPUT_DEVICE"), asset_dir=Path(get("ASSET_DIR", "assets/pet") or "assets/pet"), @@ -165,6 +169,16 @@ class AppConfig: "startup", ) ) + if self.realtime_transcript_idle_timeout_ms < 0: + errors.append( + ProviderError( + ErrorCode.CONFIG_MISSING_VALUE, + "OWNER_REALTIME_TRANSCRIPT_IDLE_TIMEOUT_MS must be non-negative", + False, + "config", + "startup", + ) + ) if self.wake_provider not in {"local_kws"}: errors.append( ProviderError( diff --git a/src/owner_voice_pet/vad.py b/src/owner_voice_pet/vad.py index 93ef2d3..d288044 100644 --- a/src/owner_voice_pet/vad.py +++ b/src/owner_voice_pet/vad.py @@ -283,6 +283,9 @@ class VadRecorder: self.provider.reset() return segment + def finish(self, end_reason: str) -> AudioSegment: + return self._build_segment(end_reason) + @dataclass(slots=True) class PrimarySpeakerVadRecorder(VadRecorder): diff --git a/tests/test_cli_acceptance.py b/tests/test_cli_acceptance.py index b9b3791..68e6b30 100644 --- a/tests/test_cli_acceptance.py +++ b/tests/test_cli_acceptance.py @@ -44,6 +44,7 @@ class CliAcceptanceTests(unittest.TestCase): self.assertEqual(data["llm_model"], "file-model") self.assertEqual(data["pipeline_mode"], "live_turn_based") self.assertEqual(data["endpoint_mode"], "primary_speaker") + self.assertEqual(data["realtime_transcript_idle_timeout_ms"], 1500) self.assertEqual(data["context_mode"], "session_memory") self.assertNotIn("secret-value", str(data)) diff --git a/tests/test_live_runtime.py b/tests/test_live_runtime.py index 05b825e..9cc2d96 100644 --- a/tests/test_live_runtime.py +++ b/tests/test_live_runtime.py @@ -47,6 +47,28 @@ def segment_frames(start_id: int, start_ms: int, partials: list[str] | None = No ] +def long_speech_frames_with_stale_partial(start_id: int, start_ms: int, duration_ms: int) -> list[AudioFrame]: + frames: list[AudioFrame] = [] + for index in range(duration_ms // 20): + metadata: dict[str, object] = { + "duration_ms": 20, + "speech": True, + "partial_transcript": "你知道", + "transcript": "你知道我在说什么吗", + } + frames.append( + AudioFrame( + b"\xff\x7f", + 16000, + 1, + start_ms + index * 20, + start_id + index, + metadata, + ) + ) + return frames + + def wake_frame(frame_id: int, timestamp_ms: int) -> AudioFrame: return AudioFrame( b"\xff\x7f", @@ -266,6 +288,47 @@ class LiveRuntimeTests(unittest.TestCase): self.assertLess(event_types.index(TRANSCRIPT_PARTIAL), event_types.index(SPEECH_ENDED)) self.assertLess(event_types.index(TRANSCRIPT_PARTIAL), event_types.index(TRANSCRIPT_FINAL)) + def test_realtime_transcript_idle_ends_current_utterance(self) -> None: + frames = [wake_frame(0, 0)] + frames.extend(long_speech_frames_with_stale_partial(1, 20, 2200)) + transport = MemoryAudioTransport(frames, flush_clears_input=False) + stt = QueueSttProvider(["你知道我在说什么吗"]) + llm = MockLlmProvider(["这是答复。"]) + reporter = RecordingReporter() + runtime = VoiceAssistantPipeline( + config=AppConfig( + llm_api_key="secret", + speech_provider="cloud", + wake_ack_text="", + realtime_transcript_idle_timeout_ms=1500, + ), + transport=transport, + wakeword=KeywordWakeWordProvider(), + vad_recorder=VadRecorder( + EnergyVadProvider(), + min_duration_ms=40, + end_silence_ms=5000, + max_recording_ms=10000, + ), + stt=stt, + audio_preprocessor=MarkerAudioPreprocessor(partial_text="你知道"), + realtime_stt=MetadataSttProvider(), + llm=llm, + tts=SineTtsProvider(), + context=ConversationContext(), + reporter=reporter, + event_bus=PipelineEventBus(), + ) + + summary = runtime.run(max_turns=1) + + self.assertEqual(summary.completed_turns, 1) + self.assertEqual(reporter.partials, ["你知道"]) + self.assertEqual(reporter.transcripts, ["你知道我在说什么吗"]) + self.assertEqual(len(stt.calls), 1) + self.assertEqual(stt.calls[0].metadata["end_reason"], "partial_transcript_idle") + self.assertLess(stt.calls[0].duration_ms, 1700) + def test_capture_uses_denoised_frames_for_partial_and_final_stt(self) -> None: preprocessor = MarkerAudioPreprocessor(partial_text="降噪后问题") runtime, stt, _, _, reporter = make_runtime( diff --git a/tests/test_models_config.py b/tests/test_models_config.py index 5e962ab..000b3b8 100644 --- a/tests/test_models_config.py +++ b/tests/test_models_config.py @@ -78,6 +78,7 @@ class ModelsConfigTests(unittest.TestCase): self.assertEqual(config.llm_api_key, "secret-value") self.assertEqual(config.llm_model, "test-model") self.assertTrue(config.realtime_transcript_enabled) + self.assertEqual(config.realtime_transcript_idle_timeout_ms, 1500) self.assertEqual(config.wake_provider, "local_kws") self.assertEqual(config.wake_kws_threshold, 0.15) self.assertEqual(config.wake_kws_score, 1.0) @@ -113,6 +114,11 @@ class ModelsConfigTests(unittest.TestCase): errors = config.validate_basic() self.assertTrue(any("OWNER_SPEECH_PROVIDER" in error.message for error in errors)) + def test_realtime_transcript_idle_timeout_must_be_non_negative(self) -> None: + config = AppConfig(realtime_transcript_idle_timeout_ms=-1) + errors = config.validate_basic() + self.assertTrue(any("OWNER_REALTIME_TRANSCRIPT_IDLE_TIMEOUT_MS" in error.message for error in errors)) + def test_wake_provider_must_be_local_kws(self) -> None: config = AppConfig(wake_provider="cloud_asr") errors = config.validate_basic()