[实时字幕端点]:完成无新文字快速结束录音,包含1.5秒停滞配置、Capture端点和回归测试
This commit is contained in:
@@ -3,6 +3,7 @@ OWNER_LLM_API_KEY=
|
|||||||
OWNER_LLM_MODEL=mimo-v2.5
|
OWNER_LLM_MODEL=mimo-v2.5
|
||||||
OWNER_LLM_API_STYLE=chat_completions
|
OWNER_LLM_API_STYLE=chat_completions
|
||||||
OWNER_REALTIME_TRANSCRIPT_ENABLED=1
|
OWNER_REALTIME_TRANSCRIPT_ENABLED=1
|
||||||
|
OWNER_REALTIME_TRANSCRIPT_IDLE_TIMEOUT_MS=1500
|
||||||
OWNER_AUDIO_INPUT_DEVICE=
|
OWNER_AUDIO_INPUT_DEVICE=
|
||||||
OWNER_AUDIO_OUTPUT_DEVICE=
|
OWNER_AUDIO_OUTPUT_DEVICE=
|
||||||
OWNER_ASSET_DIR=assets/pet
|
OWNER_ASSET_DIR=assets/pet
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ OWNER_LLM_API_KEY=
|
|||||||
OWNER_LLM_MODEL=mimo-v2.5
|
OWNER_LLM_MODEL=mimo-v2.5
|
||||||
OWNER_LLM_API_STYLE=chat_completions
|
OWNER_LLM_API_STYLE=chat_completions
|
||||||
OWNER_REALTIME_TRANSCRIPT_ENABLED=1
|
OWNER_REALTIME_TRANSCRIPT_ENABLED=1
|
||||||
|
OWNER_REALTIME_TRANSCRIPT_IDLE_TIMEOUT_MS=1500
|
||||||
OWNER_WAKE_PROVIDER=local_kws
|
OWNER_WAKE_PROVIDER=local_kws
|
||||||
OWNER_WAKE_KEYWORDS_FILE=
|
OWNER_WAKE_KEYWORDS_FILE=
|
||||||
OWNER_WAKE_KWS_THRESHOLD=0.15
|
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_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` 可以临时关闭实时显示。
|
||||||
|
|
||||||
## 本地模型
|
## 本地模型
|
||||||
|
|
||||||
|
|||||||
@@ -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,避免刷屏。
|
4. `VoiceAssistantPipeline` 在 `speech_started` 后把 capture 阶段已开始录音的帧 feed 给 realtime STT session;当 partial 文本变化时才 emit,避免刷屏。
|
||||||
5. `OWNER_REALTIME_TRANSCRIPT_ENABLED=1` 默认启用;设置为 `0` 可临时回退到只显示 final transcript。
|
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
|
## Model Files
|
||||||
|
|
||||||
```text
|
```text
|
||||||
|
|||||||
@@ -107,6 +107,7 @@
|
|||||||
42. `real-live-check --no-playback` SHALL 仍执行本地 TTS 合成和 pipeline 播放事件记录,但不实际向扬声器发声,便于自动化测试和无声环境排查。
|
42. `real-live-check --no-playback` SHALL 仍执行本地 TTS 合成和 pipeline 播放事件记录,但不实际向扬声器发声,便于自动化测试和无声环境排查。
|
||||||
43. `real-live-check` SHALL 输出命令级开始/结束时间、总耗时、阶段耗时、pipeline 事件时间线和按 turn/stage 聚合的响应耗时。
|
43. `real-live-check` SHALL 输出命令级开始/结束时间、总耗时、阶段耗时、pipeline 事件时间线和按 turn/stage 聚合的响应耗时。
|
||||||
44. `real-live-check` SHALL 输出每次云端 LLM 请求真正发出的 `sent_at`、首个文本回复耗时和请求总耗时,且不得泄露 API key。
|
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`:正式问题阶段默认启用本地降噪。
|
17. `OWNER_NOISE_FILTER_ENABLED=1`:正式问题阶段默认启用本地降噪。
|
||||||
18. `OWNER_WAKE_DENOISE_ENABLED=0`:wake 阶段默认不启用降噪。
|
18. `OWNER_WAKE_DENOISE_ENABLED=0`:wake 阶段默认不启用降噪。
|
||||||
19. `OWNER_NOISE_FILTER_PROVIDER=sherpa_onnx_gtcrn`:第一版本地降噪 provider。
|
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 的文本直接忽略;若新文本比上一次已显示文本短且不构成稳定前缀推进,也忽略,避免把瞬时噪声显示给用户。
|
12. partial 稳定过滤:实时字幕会先去除空白和标点,计算有效字符数;有效字符数小于 2 的文本直接忽略;若新文本比上一次已显示文本短且不构成稳定前缀推进,也忽略,避免把瞬时噪声显示给用户。
|
||||||
13. 零时长播放后清理:`_drain_input_after_playback()` 先执行一次 `transport.flush_input()`;若 `post_playback_drain_ms <= 0` 立即返回;若配置为正数,才按用户显式配置继续 read/drop drain window 并最终再次 flush。
|
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 序列混淆。
|
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.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 分钟。
|
- [ ] 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
|
## Spec Deltas
|
||||||
|
|
||||||
### 新增能力
|
### 新增能力
|
||||||
@@ -474,6 +484,7 @@ standby
|
|||||||
13. `Real provider live fixture acceptance`:新增真实 provider 脚本化闭环验收和 ACK/TTS 播放后 0ms flush 语义,确保真实本地模型、云端 LLM、本地 TTS/播放在同一 pipeline 中可连续运行。
|
13. `Real provider live fixture acceptance`:新增真实 provider 脚本化闭环验收和 ACK/TTS 播放后 0ms flush 语义,确保真实本地模型、云端 LLM、本地 TTS/播放在同一 pipeline 中可连续运行。
|
||||||
14. `Real live check CLI`:新增 `real-live-check` 可重复命令,把完整真实 Provider 链路验收从一次性脚本升级为仓库内正式工具。
|
14. `Real live check CLI`:新增 `real-live-check` 可重复命令,把完整真实 Provider 链路验收从一次性脚本升级为仓库内正式工具。
|
||||||
15. `Real live check timing`:新增真实流程命令计时输出,覆盖命令发起时间、LLM 请求发送时间、各阶段响应耗时和 pipeline 事件时间线。
|
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` 体验验收,不在用户确认前归档。
|
11. M11:真实 provider 脚本化闭环和 ACK 缓冲修正完成后提交;仍保留用户真人 `run-live` 体验验收,不在用户确认前归档。
|
||||||
12. M12:`real-live-check` 正式 CLI、文档、测试和真实命令验收完成后提交;仍保留物理麦克风 `run-live` 真人体验验收。
|
12. M12:`real-live-check` 正式 CLI、文档、测试和真实命令验收完成后提交;仍保留物理麦克风 `run-live` 真人体验验收。
|
||||||
13. M13:`real-live-check` 计时输出完成后提交,使后续排查可以直接看到每段耗时。
|
13. M13:`real-live-check` 计时输出完成后提交,使后续排查可以直接看到每段耗时。
|
||||||
|
14. M14:实时字幕停滞端点完成后提交,用户真实 `run-live` 若已有 partial 后 1.5 秒无新文字,应快速进入 final STT。
|
||||||
|
|
||||||
估时:
|
估时:
|
||||||
|
|
||||||
|
|||||||
+5
@@ -56,6 +56,11 @@ The live runtime SHALL display recognized user utterance text in the terminal bo
|
|||||||
- **WHEN** a partial transcript is emitted
|
- **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
|
- **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
|
#### Scenario: User utterance is transcribed
|
||||||
- **WHEN** a live turn captures a user utterance and STT returns non-empty text
|
- **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
|
- **THEN** the terminal output SHALL include a transcript message containing the recognized text before the thinking/LLM status is emitted
|
||||||
|
|||||||
@@ -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.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.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 分钟。
|
- [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 分钟。
|
||||||
|
|||||||
@@ -163,6 +163,7 @@ class TurnController:
|
|||||||
self.vad_recorder.provider.reset()
|
self.vad_recorder.provider.reset()
|
||||||
self.audio_preprocessor.reset()
|
self.audio_preprocessor.reset()
|
||||||
realtime_session = self._start_realtime_transcript()
|
realtime_session = self._start_realtime_transcript()
|
||||||
|
last_partial_ms: int | None = None
|
||||||
self._event(CAPTURE_STARTED, PipelineState.RECORDING, state_message, turn_id=turn_id)
|
self._event(CAPTURE_STARTED, PipelineState.RECORDING, state_message, turn_id=turn_id)
|
||||||
while True:
|
while True:
|
||||||
frames = self.transport.read_frames(timeout_ms=100)
|
frames = self.transport.read_frames(timeout_ms=100)
|
||||||
@@ -180,7 +181,8 @@ class TurnController:
|
|||||||
if isinstance(result, ProviderError):
|
if isinstance(result, ProviderError):
|
||||||
return result
|
return result
|
||||||
if self.vad_recorder.started and realtime_session is not None:
|
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 isinstance(result, AudioSegment):
|
||||||
if realtime_session is not None:
|
if realtime_session is not None:
|
||||||
self._finish_realtime_transcript(realtime_session, turn_id)
|
self._finish_realtime_transcript(realtime_session, turn_id)
|
||||||
@@ -192,6 +194,18 @@ class TurnController:
|
|||||||
payload={"end_reason": result.metadata.get("end_reason", "")},
|
payload={"end_reason": result.metadata.get("end_reason", "")},
|
||||||
)
|
)
|
||||||
return result
|
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:
|
def _start_realtime_transcript(self) -> RealtimeTranscriptSession | None:
|
||||||
if not self.config.realtime_transcript_enabled or self.realtime_stt is None:
|
if not self.config.realtime_transcript_enabled or self.realtime_stt is None:
|
||||||
@@ -203,14 +217,19 @@ class TurnController:
|
|||||||
realtime_session: RealtimeTranscriptSession,
|
realtime_session: RealtimeTranscriptSession,
|
||||||
frame: AudioFrame,
|
frame: AudioFrame,
|
||||||
turn_id: int,
|
turn_id: int,
|
||||||
) -> None:
|
) -> bool:
|
||||||
transcript = realtime_session.accept_frame(frame)
|
transcript = realtime_session.accept_frame(frame)
|
||||||
if transcript is None:
|
if transcript is None:
|
||||||
return
|
return False
|
||||||
text = transcript.normalized_text
|
text = transcript.normalized_text
|
||||||
if not is_valid_transcript_text(text):
|
if not is_valid_transcript_text(text):
|
||||||
return
|
return False
|
||||||
self._event(TRANSCRIPT_PARTIAL, PipelineState.RECORDING, "", turn_id=turn_id, payload={"text": text})
|
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(
|
def _finish_realtime_transcript(
|
||||||
self,
|
self,
|
||||||
|
|||||||
@@ -65,6 +65,7 @@ def main(argv: list[str] | None = None) -> int:
|
|||||||
"llm_api_style": config.llm_api_style,
|
"llm_api_style": config.llm_api_style,
|
||||||
"llm_stream": config.llm_stream,
|
"llm_stream": config.llm_stream,
|
||||||
"realtime_transcript_enabled": config.realtime_transcript_enabled,
|
"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),
|
"llm_api_key_present": bool(config.llm_api_key),
|
||||||
"asset_dir": str(config.asset_dir),
|
"asset_dir": str(config.asset_dir),
|
||||||
"wake_provider": config.wake_provider,
|
"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_api_style=config.llm_api_style,
|
||||||
llm_stream=False,
|
llm_stream=False,
|
||||||
realtime_transcript_enabled=config.realtime_transcript_enabled,
|
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_input_device=config.audio_input_device,
|
||||||
audio_output_device=config.audio_output_device,
|
audio_output_device=config.audio_output_device,
|
||||||
asset_dir=config.asset_dir,
|
asset_dir=config.asset_dir,
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ class AppConfig:
|
|||||||
llm_api_style: str = "chat_completions"
|
llm_api_style: str = "chat_completions"
|
||||||
llm_stream: bool = True
|
llm_stream: bool = True
|
||||||
realtime_transcript_enabled: bool = True
|
realtime_transcript_enabled: bool = True
|
||||||
|
realtime_transcript_idle_timeout_ms: int = 1500
|
||||||
audio_input_device: str | None = None
|
audio_input_device: str | None = None
|
||||||
audio_output_device: str | None = None
|
audio_output_device: str | None = None
|
||||||
asset_dir: Path = Path("assets/pet")
|
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"},
|
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()
|
realtime_transcript_enabled=(get("REALTIME_TRANSCRIPT_ENABLED", "1") or "1").lower()
|
||||||
not in {"0", "false", "no"},
|
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_input_device=get("AUDIO_INPUT_DEVICE"),
|
||||||
audio_output_device=get("AUDIO_OUTPUT_DEVICE"),
|
audio_output_device=get("AUDIO_OUTPUT_DEVICE"),
|
||||||
asset_dir=Path(get("ASSET_DIR", "assets/pet") or "assets/pet"),
|
asset_dir=Path(get("ASSET_DIR", "assets/pet") or "assets/pet"),
|
||||||
@@ -165,6 +169,16 @@ class AppConfig:
|
|||||||
"startup",
|
"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"}:
|
if self.wake_provider not in {"local_kws"}:
|
||||||
errors.append(
|
errors.append(
|
||||||
ProviderError(
|
ProviderError(
|
||||||
|
|||||||
@@ -283,6 +283,9 @@ class VadRecorder:
|
|||||||
self.provider.reset()
|
self.provider.reset()
|
||||||
return segment
|
return segment
|
||||||
|
|
||||||
|
def finish(self, end_reason: str) -> AudioSegment:
|
||||||
|
return self._build_segment(end_reason)
|
||||||
|
|
||||||
|
|
||||||
@dataclass(slots=True)
|
@dataclass(slots=True)
|
||||||
class PrimarySpeakerVadRecorder(VadRecorder):
|
class PrimarySpeakerVadRecorder(VadRecorder):
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ class CliAcceptanceTests(unittest.TestCase):
|
|||||||
self.assertEqual(data["llm_model"], "file-model")
|
self.assertEqual(data["llm_model"], "file-model")
|
||||||
self.assertEqual(data["pipeline_mode"], "live_turn_based")
|
self.assertEqual(data["pipeline_mode"], "live_turn_based")
|
||||||
self.assertEqual(data["endpoint_mode"], "primary_speaker")
|
self.assertEqual(data["endpoint_mode"], "primary_speaker")
|
||||||
|
self.assertEqual(data["realtime_transcript_idle_timeout_ms"], 1500)
|
||||||
self.assertEqual(data["context_mode"], "session_memory")
|
self.assertEqual(data["context_mode"], "session_memory")
|
||||||
self.assertNotIn("secret-value", str(data))
|
self.assertNotIn("secret-value", str(data))
|
||||||
|
|
||||||
|
|||||||
@@ -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:
|
def wake_frame(frame_id: int, timestamp_ms: int) -> AudioFrame:
|
||||||
return AudioFrame(
|
return AudioFrame(
|
||||||
b"\xff\x7f",
|
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(SPEECH_ENDED))
|
||||||
self.assertLess(event_types.index(TRANSCRIPT_PARTIAL), event_types.index(TRANSCRIPT_FINAL))
|
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:
|
def test_capture_uses_denoised_frames_for_partial_and_final_stt(self) -> None:
|
||||||
preprocessor = MarkerAudioPreprocessor(partial_text="降噪后问题")
|
preprocessor = MarkerAudioPreprocessor(partial_text="降噪后问题")
|
||||||
runtime, stt, _, _, reporter = make_runtime(
|
runtime, stt, _, _, reporter = make_runtime(
|
||||||
|
|||||||
@@ -78,6 +78,7 @@ class ModelsConfigTests(unittest.TestCase):
|
|||||||
self.assertEqual(config.llm_api_key, "secret-value")
|
self.assertEqual(config.llm_api_key, "secret-value")
|
||||||
self.assertEqual(config.llm_model, "test-model")
|
self.assertEqual(config.llm_model, "test-model")
|
||||||
self.assertTrue(config.realtime_transcript_enabled)
|
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_provider, "local_kws")
|
||||||
self.assertEqual(config.wake_kws_threshold, 0.15)
|
self.assertEqual(config.wake_kws_threshold, 0.15)
|
||||||
self.assertEqual(config.wake_kws_score, 1.0)
|
self.assertEqual(config.wake_kws_score, 1.0)
|
||||||
@@ -113,6 +114,11 @@ class ModelsConfigTests(unittest.TestCase):
|
|||||||
errors = config.validate_basic()
|
errors = config.validate_basic()
|
||||||
self.assertTrue(any("OWNER_SPEECH_PROVIDER" in error.message for error in errors))
|
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:
|
def test_wake_provider_must_be_local_kws(self) -> None:
|
||||||
config = AppConfig(wake_provider="cloud_asr")
|
config = AppConfig(wake_provider="cloud_asr")
|
||||||
errors = config.validate_basic()
|
errors = config.validate_basic()
|
||||||
|
|||||||
Reference in New Issue
Block a user