[Pipeline 事件总线]:完成语音助手事件模型,包含stage事件、终端reporter和测试覆盖

This commit is contained in:
mkbk
2026-06-17 21:26:30 +08:00
parent 1ee9f8ba95
commit a251ff379d
5 changed files with 187 additions and 16 deletions
@@ -49,7 +49,7 @@
## 7. 开源语音助手式 Pipeline 重构 ## 7. 开源语音助手式 Pipeline 重构
- [x] 7.1 更新 OpenSpec 以描述 stage 化 pipeline、事件总线、TurnController 和主说话人端点;前置条件:公开参考已确认;验收标准:proposal/design/spec/tasks 覆盖新架构和任务;测试要点:OpenSpec strict;优先级:P0;预计:45 分钟。 - [x] 7.1 更新 OpenSpec 以描述 stage 化 pipeline、事件总线、TurnController 和主说话人端点;前置条件:公开参考已确认;验收标准:proposal/design/spec/tasks 覆盖新架构和任务;测试要点:OpenSpec strict;优先级:P0;预计:45 分钟。
- [ ] 7.2 实现 pipeline event bus 和终端事件映射;前置条件:7.1 完成;验收标准:所有 live 用户可见状态由事件产生;测试要点:事件顺序和终端文案测试;优先级:P0;预计:60 分钟。 - [x] 7.2 实现 pipeline event bus 和终端事件映射;前置条件:7.1 完成;验收标准:所有 live 用户可见状态由事件产生;测试要点:事件顺序和终端文案测试;优先级:P0;预计:60 分钟。
- [ ] 7.3 实现 `TurnController``VoiceAssistantPipeline`;前置条件:7.2 完成;验收标准:`run-live` 使用统一 pipeline,成功/失败 turn 均恢复待机;测试要点:两轮 fake runtime、错误恢复、上下文回归;优先级:P0;预计:60 分钟。 - [ ] 7.3 实现 `TurnController``VoiceAssistantPipeline`;前置条件:7.2 完成;验收标准:`run-live` 使用统一 pipeline,成功/失败 turn 均恢复待机;测试要点:两轮 fake runtime、错误恢复、上下文回归;优先级:P0;预计:60 分钟。
- [ ] 7.4 实现本轮主说话人端点;前置条件:7.3 完成;验收标准:主说话人音色消失约 300 ms 后结束采集;测试要点:一次提问后背景噪声不拖尾、短暂停顿不断句、画像不足回退;优先级:P0;预计:60 分钟。 - [ ] 7.4 实现本轮主说话人端点;前置条件:7.3 完成;验收标准:主说话人音色消失约 300 ms 后结束采集;测试要点:一次提问后背景噪声不拖尾、短暂停顿不断句、画像不足回退;优先级:P0;预计:60 分钟。
- [ ] 7.5 更新 README、`.env.example`、本地 `.env` 非密钥配置;前置条件:7.2 至 7.4 完成;验收标准:运行说明匹配新 pipeline;测试要点:`--show-config` 不泄露 key;优先级:P0;预计:30 分钟。 - [ ] 7.5 更新 README、`.env.example`、本地 `.env` 非密钥配置;前置条件:7.2 至 7.4 完成;验收标准:运行说明匹配新 pipeline;测试要点:`--show-config` 不泄露 key;优先级:P0;预计:30 分钟。
+3
View File
@@ -1,6 +1,7 @@
"""Owner voice pet pipeline package.""" """Owner voice pet pipeline package."""
from .config import AppConfig from .config import AppConfig
from .events import PipelineEvent, PipelineEventBus
from .models import ( from .models import (
AudioFrame, AudioFrame,
AudioSegment, AudioSegment,
@@ -29,6 +30,8 @@ from .ui import ConsolePetWindow, PetStateController, PetVisualState
__all__ = [ __all__ = [
"AppConfig", "AppConfig",
"PipelineEvent",
"PipelineEventBus",
"AudioFrame", "AudioFrame",
"AudioSegment", "AudioSegment",
"AudioRingBuffer", "AudioRingBuffer",
+77
View File
@@ -0,0 +1,77 @@
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Any, Callable
from .models import PipelineState, ProviderError
PIPELINE_STARTED = "pipeline_started"
WAKE_LISTENING = "wake_listening"
WAKE_DETECTED = "wake_detected"
ACK_STARTED = "ack_started"
QUESTION_PROMPT = "question_prompt"
CAPTURE_STARTED = "capture_started"
SPEECH_STARTED = "speech_started"
SPEECH_ENDED = "speech_ended"
STT_STARTED = "stt_started"
TRANSCRIPT_FINAL = "transcript_final"
LLM_STARTED = "llm_started"
TTS_STARTED = "tts_started"
PLAYBACK_FINISHED = "playback_finished"
STANDBY_RESUMED = "standby_resumed"
STAGE_ERROR = "stage_error"
RECOVERING = "recovering"
@dataclass(frozen=True, slots=True)
class PipelineEvent:
type: str
turn_id: int | None = None
state: PipelineState | None = None
message: str = ""
payload: dict[str, Any] = field(default_factory=dict)
class PipelineEventBus:
def __init__(self) -> None:
self.events: list[PipelineEvent] = []
self._listeners: list[Callable[[PipelineEvent], None]] = []
def subscribe(self, listener: Callable[[PipelineEvent], None]) -> None:
self._listeners.append(listener)
def emit(
self,
event_type: str,
*,
turn_id: int | None = None,
state: PipelineState | None = None,
message: str = "",
payload: dict[str, Any] | None = None,
) -> PipelineEvent:
event = PipelineEvent(event_type, turn_id=turn_id, state=state, message=message, payload=payload or {})
self.events.append(event)
for listener in list(self._listeners):
listener(event)
return event
def dispatch_pipeline_event(reporter: Any, event: PipelineEvent) -> None:
if event.type == TRANSCRIPT_FINAL:
reporter.transcript(str(event.payload.get("text", event.message)), final=True, turn_id=event.turn_id)
return
if event.type == STAGE_ERROR:
error = event.payload.get("error")
if isinstance(error, ProviderError):
reporter.error(error.stage, error.code.value, error.message, turn_id=event.turn_id)
return
reporter.error(
str(event.payload.get("stage", "pipeline")),
str(event.payload.get("code", "STAGE_ERROR")),
event.message,
turn_id=event.turn_id,
)
return
if event.message:
reporter.status((event.state.value if event.state else event.type), event.message, turn_id=event.turn_id)
+72 -15
View File
@@ -6,6 +6,26 @@ from typing import Protocol
from .config import AppConfig from .config import AppConfig
from .conversation import ConversationContext from .conversation import ConversationContext
from .events import (
ACK_STARTED,
CAPTURE_STARTED,
LLM_STARTED,
PipelineEvent,
PipelineEventBus,
PLAYBACK_FINISHED,
QUESTION_PROMPT,
RECOVERING,
SPEECH_ENDED,
SPEECH_STARTED,
STAGE_ERROR,
STANDBY_RESUMED,
STT_STARTED,
TRANSCRIPT_FINAL,
TTS_STARTED,
WAKE_DETECTED,
WAKE_LISTENING,
dispatch_pipeline_event,
)
from .llm import OpenAICompatibleLlmProvider from .llm import OpenAICompatibleLlmProvider
from .models import AudioSegment, ErrorCode, PipelineState, ProviderError from .models import AudioSegment, ErrorCode, PipelineState, ProviderError
from .protocols import AudioTransport, LlmProvider, SttProvider, TtsProvider, WakeWordProvider from .protocols import AudioTransport, LlmProvider, SttProvider, TtsProvider, WakeWordProvider
@@ -28,6 +48,9 @@ class RuntimeReporter(Protocol):
class TerminalRuntimeReporter: class TerminalRuntimeReporter:
def handle_event(self, event: PipelineEvent) -> None:
dispatch_pipeline_event(self, event)
def status(self, state: str, message: str, *, turn_id: int | None = None) -> None: def status(self, state: str, message: str, *, turn_id: int | None = None) -> None:
prefix = f"[第{turn_id}轮] " if turn_id is not None else "" prefix = f"[第{turn_id}轮] " if turn_id is not None else ""
print(f"{prefix}{message}", flush=True) print(f"{prefix}{message}", flush=True)
@@ -73,6 +96,7 @@ class LiveVoiceRuntime:
context: ConversationContext, context: ConversationContext,
ack_tts: TtsProvider | None = None, ack_tts: TtsProvider | None = None,
reporter: RuntimeReporter | None = None, reporter: RuntimeReporter | None = None,
event_bus: PipelineEventBus | None = None,
sentence_buffer: SentenceBuffer | None = None, sentence_buffer: SentenceBuffer | None = None,
) -> None: ) -> None:
self.config = config self.config = config
@@ -85,6 +109,8 @@ class LiveVoiceRuntime:
self.ack_tts = ack_tts or tts self.ack_tts = ack_tts or tts
self.context = context self.context = context
self.reporter = reporter or TerminalRuntimeReporter() self.reporter = reporter or TerminalRuntimeReporter()
self.event_bus = event_bus or PipelineEventBus()
self.event_bus.subscribe(self._report_event)
self.sentence_buffer = sentence_buffer or SentenceBuffer() self.sentence_buffer = sentence_buffer or SentenceBuffer()
self._states: list[PipelineState] = [] self._states: list[PipelineState] = []
@@ -130,7 +156,12 @@ class LiveVoiceRuntime:
def run_turn(self, turn_id: int) -> TurnResult: def run_turn(self, turn_id: int) -> TurnResult:
self._states = [] self._states = []
try: try:
self._state(PipelineState.WAKE_LISTENING, "待机:等待唤醒词“小杰小杰”", turn_id=turn_id) self._event(
WAKE_LISTENING,
PipelineState.WAKE_LISTENING,
"待机:等待唤醒词“小杰小杰”",
turn_id=turn_id,
)
user_text = self._wait_for_wake_and_user_text(turn_id) user_text = self._wait_for_wake_and_user_text(turn_id)
if isinstance(user_text, ProviderError): if isinstance(user_text, ProviderError):
return self._recover(user_text, turn_id) return self._recover(user_text, turn_id)
@@ -145,15 +176,15 @@ class LiveVoiceRuntime:
wake_error = self._wait_for_local_wake(turn_id) wake_error = self._wait_for_local_wake(turn_id)
if wake_error is not None: if wake_error is not None:
return wake_error return wake_error
self._state(PipelineState.SPEECH_DETECTING, "唤醒命中", turn_id=turn_id) self._event(WAKE_DETECTED, PipelineState.SPEECH_DETECTING, "唤醒命中", turn_id=turn_id)
ack_error = self._acknowledge_wake(turn_id) ack_error = self._acknowledge_wake(turn_id)
if ack_error is not None: if ack_error is not None:
return ack_error return ack_error
self._state(PipelineState.SPEECH_DETECTING, "请说出问题", turn_id=turn_id) self._event(QUESTION_PROMPT, PipelineState.SPEECH_DETECTING, "请说出问题", turn_id=turn_id)
user_segment = self._capture_segment(turn_id, state_message="录音中:正在听取问题") user_segment = self._capture_segment(turn_id, state_message="录音中:正在听取问题")
if isinstance(user_segment, ProviderError): if isinstance(user_segment, ProviderError):
return user_segment return user_segment
self._state(PipelineState.TRANSCRIBING, "转写中:正在识别问题", turn_id=turn_id) self._event(STT_STARTED, PipelineState.TRANSCRIBING, "转写中:正在识别问题", turn_id=turn_id)
transcript = self.stt.transcribe(user_segment) transcript = self.stt.transcribe(user_segment)
user_text = transcript.normalized_text user_text = transcript.normalized_text
if not is_valid_transcript_text(user_text): if not is_valid_transcript_text(user_text):
@@ -164,7 +195,7 @@ class LiveVoiceRuntime:
"live-runtime", "live-runtime",
"stt", "stt",
) )
self.reporter.transcript(user_text, final=True, turn_id=turn_id) self._event(TRANSCRIPT_FINAL, PipelineState.TRANSCRIBING, "", turn_id=turn_id, payload={"text": user_text})
return user_text return user_text
def _wait_for_local_wake(self, turn_id: int) -> ProviderError | None: def _wait_for_local_wake(self, turn_id: int) -> ProviderError | None:
@@ -182,16 +213,26 @@ class LiveVoiceRuntime:
def _capture_segment(self, turn_id: int, *, state_message: str) -> AudioSegment | ProviderError: def _capture_segment(self, turn_id: int, *, state_message: str) -> AudioSegment | ProviderError:
self.vad_recorder.reset() self.vad_recorder.reset()
self.vad_recorder.provider.reset() self.vad_recorder.provider.reset()
self._state(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)
if not frames: if not frames:
continue continue
for frame in frames: for frame in frames:
was_started = self.vad_recorder.started
result = self.vad_recorder.feed(frame) result = self.vad_recorder.feed(frame)
if not was_started and self.vad_recorder.started:
self._event(SPEECH_STARTED, PipelineState.RECORDING, "检测到用户语音", turn_id=turn_id)
if isinstance(result, ProviderError): if isinstance(result, ProviderError):
return result return result
if isinstance(result, AudioSegment): if isinstance(result, AudioSegment):
self._event(
SPEECH_ENDED,
PipelineState.RECORDING,
"用户语音结束",
turn_id=turn_id,
payload={"end_reason": result.metadata.get("end_reason", "")},
)
return result return result
def _acknowledge_wake(self, turn_id: int) -> ProviderError | None: def _acknowledge_wake(self, turn_id: int) -> ProviderError | None:
@@ -200,7 +241,7 @@ class LiveVoiceRuntime:
self._drain_input_after_playback() self._drain_input_after_playback()
return None return None
try: try:
self._state(PipelineState.SPEAKING, f"应答中:{text}", turn_id=turn_id) self._event(ACK_STARTED, PipelineState.SPEAKING, f"应答中:{text}", turn_id=turn_id)
segment = self.ack_tts.synthesize(text) segment = self.ack_tts.synthesize(text)
playback = self.transport.play_pcm(segment) playback = self.transport.play_pcm(segment)
if playback.error: if playback.error:
@@ -223,7 +264,7 @@ class LiveVoiceRuntime:
def _reply_to_user(self, user_text: str, turn_id: int) -> TurnResult: def _reply_to_user(self, user_text: str, turn_id: int) -> TurnResult:
self.context.append_user(user_text) self.context.append_user(user_text)
self._state(PipelineState.THINKING, "思考中:正在生成回复", turn_id=turn_id) self._event(LLM_STARTED, PipelineState.THINKING, "思考中:正在生成回复", turn_id=turn_id)
assistant_text = "" assistant_text = ""
try: try:
for delta in self.llm.stream_reply(self.context.build_llm_messages()): for delta in self.llm.stream_reply(self.context.build_llm_messages()):
@@ -246,26 +287,42 @@ class LiveVoiceRuntime:
turn_id, turn_id,
) )
self.context.append_assistant(assistant_text) self.context.append_assistant(assistant_text)
self._state(PipelineState.WAKE_LISTENING, "恢复待机:可继续唤醒", turn_id=turn_id) self._event(STANDBY_RESUMED, PipelineState.WAKE_LISTENING, "恢复待机:可继续唤醒", turn_id=turn_id)
return TurnResult(True, user_text, assistant_text, states=list(self._states)) return TurnResult(True, user_text, assistant_text, states=list(self._states))
def _speak(self, sentence: str, turn_id: int) -> None: def _speak(self, sentence: str, turn_id: int) -> None:
self._state(PipelineState.SPEAKING, "播放中:正在播报回复", turn_id=turn_id) self._event(TTS_STARTED, PipelineState.SPEAKING, "播放中:正在播报回复", turn_id=turn_id)
segment = self.tts.synthesize(sentence) segment = self.tts.synthesize(sentence)
playback = self.transport.play_pcm(segment) playback = self.transport.play_pcm(segment)
if playback.error: if playback.error:
raise playback.error raise playback.error
self._event(PLAYBACK_FINISHED, PipelineState.SPEAKING, "播放完成", turn_id=turn_id)
self._drain_input_after_playback() self._drain_input_after_playback()
def _recover(self, error: ProviderError, turn_id: int) -> TurnResult: def _recover(self, error: ProviderError, turn_id: int) -> TurnResult:
self.reporter.error(error.stage, error.code.value, error.message, turn_id=turn_id) self._event(STAGE_ERROR, PipelineState.ERROR_RECOVERING, error.message, turn_id=turn_id, payload={"error": error})
self._state(PipelineState.ERROR_RECOVERING, "恢复待机:本轮已结束", turn_id=turn_id) self._event(RECOVERING, PipelineState.ERROR_RECOVERING, "恢复待机:本轮已结束", turn_id=turn_id)
self._state(PipelineState.WAKE_LISTENING, "恢复待机:可继续唤醒", turn_id=turn_id) self._event(STANDBY_RESUMED, PipelineState.WAKE_LISTENING, "恢复待机:可继续唤醒", turn_id=turn_id)
return TurnResult(False, error=error, states=list(self._states)) return TurnResult(False, error=error, states=list(self._states))
def _state(self, state: PipelineState, message: str, *, turn_id: int) -> None: def _event(
self,
event_type: str,
state: PipelineState,
message: str,
*,
turn_id: int,
payload: dict[str, object] | None = None,
) -> None:
self._states.append(state) self._states.append(state)
self.reporter.status(state.value, message, turn_id=turn_id) self.event_bus.emit(event_type, turn_id=turn_id, state=state, message=message, payload=payload)
def _report_event(self, event: PipelineEvent) -> None:
handler = getattr(self.reporter, "handle_event", None)
if callable(handler):
handler(event)
return
dispatch_pipeline_event(self.reporter, event)
def build_live_runtime(config: AppConfig, reporter: RuntimeReporter | None = None) -> LiveVoiceRuntime: def build_live_runtime(config: AppConfig, reporter: RuntimeReporter | None = None) -> LiveVoiceRuntime:
+34
View File
@@ -4,6 +4,21 @@ import unittest
from owner_voice_pet.config import AppConfig from owner_voice_pet.config import AppConfig
from owner_voice_pet.conversation import ConversationContext from owner_voice_pet.conversation import ConversationContext
from owner_voice_pet.events import (
ACK_STARTED,
CAPTURE_STARTED,
LLM_STARTED,
PLAYBACK_FINISHED,
SPEECH_ENDED,
SPEECH_STARTED,
STANDBY_RESUMED,
STT_STARTED,
TRANSCRIPT_FINAL,
TTS_STARTED,
WAKE_DETECTED,
WAKE_LISTENING,
PipelineEventBus,
)
from owner_voice_pet.llm import MockLlmProvider from owner_voice_pet.llm import MockLlmProvider
from owner_voice_pet.models import AudioFrame, AudioSegment, Transcript from owner_voice_pet.models import AudioFrame, AudioSegment, Transcript
from owner_voice_pet.runtime import LiveVoiceRuntime from owner_voice_pet.runtime import LiveVoiceRuntime
@@ -79,6 +94,7 @@ def make_runtime(texts: list[str], context: ConversationContext | None = None) -
llm = MockLlmProvider(["这是答复。"]) llm = MockLlmProvider(["这是答复。"])
tts = SineTtsProvider() tts = SineTtsProvider()
reporter = RecordingReporter() reporter = RecordingReporter()
event_bus = PipelineEventBus()
runtime = LiveVoiceRuntime( runtime = LiveVoiceRuntime(
config=AppConfig(llm_api_key="secret", speech_provider="cloud", post_playback_drain_ms=0), config=AppConfig(llm_api_key="secret", speech_provider="cloud", post_playback_drain_ms=0),
transport=transport, transport=transport,
@@ -89,6 +105,7 @@ def make_runtime(texts: list[str], context: ConversationContext | None = None) -
tts=tts, tts=tts,
context=context or ConversationContext(), context=context or ConversationContext(),
reporter=reporter, reporter=reporter,
event_bus=event_bus,
) )
return runtime, stt, llm, transport, reporter return runtime, stt, llm, transport, reporter
@@ -108,6 +125,23 @@ class LiveRuntimeTests(unittest.TestCase):
self.assertLess(reporter.statuses.index("应答中:我在"), reporter.statuses.index("请说出问题")) self.assertLess(reporter.statuses.index("应答中:我在"), reporter.statuses.index("请说出问题"))
self.assertLess(reporter.statuses.index("请说出问题"), reporter.statuses.index("录音中:正在听取问题")) self.assertLess(reporter.statuses.index("请说出问题"), reporter.statuses.index("录音中:正在听取问题"))
self.assertIn("恢复待机:可继续唤醒", reporter.statuses[-1]) self.assertIn("恢复待机:可继续唤醒", reporter.statuses[-1])
event_types = [event.type for event in runtime.event_bus.events]
expected_order = [
WAKE_LISTENING,
WAKE_DETECTED,
ACK_STARTED,
CAPTURE_STARTED,
SPEECH_STARTED,
SPEECH_ENDED,
STT_STARTED,
TRANSCRIPT_FINAL,
LLM_STARTED,
TTS_STARTED,
PLAYBACK_FINISHED,
STANDBY_RESUMED,
]
positions = [event_types.index(item) for item in expected_order]
self.assertEqual(positions, sorted(positions))
def test_temporary_context_is_sent_to_second_llm_call(self) -> None: def test_temporary_context_is_sent_to_second_llm_call(self) -> None:
runtime, _, llm, _, _ = make_runtime(["第一问", "第二问"]) runtime, _, llm, _, _ = make_runtime(["第一问", "第二问"])