[连续对话判断]:完成自动持续对话和播报打断,包含回复意图判断、免唤醒追问和打断回归测试
This commit is contained in:
@@ -5,12 +5,20 @@ from typing import Protocol
|
||||
|
||||
from .audio_preprocess import NoopAudioPreprocessor
|
||||
from .config import AppConfig
|
||||
from .continuation import ContinuationDecision, ContinuationDecisionProvider, build_continuation_decider
|
||||
from .conversation import ConversationContext
|
||||
from .events import (
|
||||
ACK_STARTED,
|
||||
BARGE_IN_DETECTED,
|
||||
CAPTURE_STARTED,
|
||||
CONTINUATION_DECISION_MADE,
|
||||
CONTINUATION_DECISION_STARTED,
|
||||
CONTINUOUS_SESSION_ENDED,
|
||||
FOLLOWUP_LISTENING,
|
||||
FOLLOWUP_TIMEOUT,
|
||||
LLM_STARTED,
|
||||
PLAYBACK_FINISHED,
|
||||
PLAYBACK_INTERRUPTED,
|
||||
QUESTION_PROMPT,
|
||||
RECOVERING,
|
||||
SPEECH_ENDED,
|
||||
@@ -61,6 +69,8 @@ class TurnResult:
|
||||
assistant_text: str = ""
|
||||
error: ProviderError | None = None
|
||||
states: list[PipelineState] = field(default_factory=list)
|
||||
completed_turns: int = 0
|
||||
failed_turns: int = 0
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
@@ -71,6 +81,12 @@ class RuntimeSummary:
|
||||
last_error: ProviderError | None = None
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class SpeakResult:
|
||||
spoken_text: str
|
||||
interrupted: bool = False
|
||||
|
||||
|
||||
class TurnController:
|
||||
def __init__(
|
||||
self,
|
||||
@@ -87,6 +103,7 @@ class TurnController:
|
||||
ack_tts: TtsProvider,
|
||||
context: ConversationContext,
|
||||
event_bus: PipelineEventBus,
|
||||
continuation_decider: ContinuationDecisionProvider,
|
||||
sentence_buffer: SentenceBuffer | None = None,
|
||||
) -> None:
|
||||
self.config = config
|
||||
@@ -101,8 +118,10 @@ class TurnController:
|
||||
self.ack_tts = ack_tts
|
||||
self.context = context
|
||||
self.event_bus = event_bus
|
||||
self.continuation_decider = continuation_decider
|
||||
self.sentence_buffer = sentence_buffer or SentenceBuffer()
|
||||
self._states: list[PipelineState] = []
|
||||
self._pending_capture_frames: list[AudioFrame] = []
|
||||
|
||||
def run_turn(self, turn_id: int) -> TurnResult:
|
||||
self._states = []
|
||||
@@ -129,7 +148,20 @@ class TurnController:
|
||||
if ack_error is not None:
|
||||
return ack_error
|
||||
self._event(QUESTION_PROMPT, PipelineState.SPEECH_DETECTING, "请说出问题", turn_id=turn_id)
|
||||
user_segment = self._capture_segment(turn_id, state_message="录音中:正在听取问题")
|
||||
return self._capture_and_transcribe(turn_id, state_message="录音中:正在听取问题")
|
||||
|
||||
def _capture_and_transcribe(
|
||||
self,
|
||||
turn_id: int,
|
||||
*,
|
||||
state_message: str,
|
||||
no_speech_timeout_ms: int | None = None,
|
||||
) -> str | ProviderError:
|
||||
user_segment = self._capture_segment(
|
||||
turn_id,
|
||||
state_message=state_message,
|
||||
no_speech_timeout_ms=no_speech_timeout_ms,
|
||||
)
|
||||
if isinstance(user_segment, ProviderError):
|
||||
return user_segment
|
||||
self._event(STT_STARTED, PipelineState.TRANSCRIBING, "转写中:正在识别问题", turn_id=turn_id)
|
||||
@@ -158,54 +190,73 @@ class TurnController:
|
||||
self.wakeword.reset()
|
||||
return None
|
||||
|
||||
def _capture_segment(self, turn_id: int, *, state_message: str) -> AudioSegment | ProviderError:
|
||||
self.vad_recorder.reset()
|
||||
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)
|
||||
if not frames:
|
||||
continue
|
||||
for frame in frames:
|
||||
try:
|
||||
frame = self.audio_preprocessor.process_frame(frame)
|
||||
except ProviderError as exc:
|
||||
return exc
|
||||
was_started = self.vad_recorder.started
|
||||
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):
|
||||
return result
|
||||
if self.vad_recorder.started and realtime_session is not None:
|
||||
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)
|
||||
self._event(
|
||||
SPEECH_ENDED,
|
||||
PipelineState.RECORDING,
|
||||
"用户语音结束",
|
||||
turn_id=turn_id,
|
||||
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 _capture_segment(
|
||||
self,
|
||||
turn_id: int,
|
||||
*,
|
||||
state_message: str,
|
||||
no_speech_timeout_ms: int | None = None,
|
||||
) -> AudioSegment | ProviderError:
|
||||
original_no_speech_timeout_ms = self.vad_recorder.no_speech_timeout_ms
|
||||
if no_speech_timeout_ms is not None:
|
||||
self.vad_recorder.no_speech_timeout_ms = no_speech_timeout_ms
|
||||
try:
|
||||
self.vad_recorder.reset()
|
||||
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._read_capture_frames(timeout_ms=100)
|
||||
if not frames:
|
||||
continue
|
||||
for frame in frames:
|
||||
try:
|
||||
frame = self.audio_preprocessor.process_frame(frame)
|
||||
except ProviderError as exc:
|
||||
return exc
|
||||
was_started = self.vad_recorder.started
|
||||
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):
|
||||
return result
|
||||
if self.vad_recorder.started and realtime_session is not None:
|
||||
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)
|
||||
self._event(
|
||||
SPEECH_ENDED,
|
||||
PipelineState.RECORDING,
|
||||
"用户语音结束",
|
||||
turn_id=turn_id,
|
||||
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
|
||||
finally:
|
||||
self.vad_recorder.no_speech_timeout_ms = original_no_speech_timeout_ms
|
||||
|
||||
def _read_capture_frames(self, *, timeout_ms: int) -> list[AudioFrame]:
|
||||
if self._pending_capture_frames:
|
||||
frames = list(self._pending_capture_frames)
|
||||
self._pending_capture_frames.clear()
|
||||
return frames
|
||||
return self.transport.read_frames(timeout_ms=timeout_ms)
|
||||
|
||||
def _start_realtime_transcript(self) -> RealtimeTranscriptSession | None:
|
||||
if not self.config.realtime_transcript_enabled or self.realtime_stt is None:
|
||||
@@ -259,19 +310,86 @@ class TurnController:
|
||||
return exc
|
||||
|
||||
def _reply_to_user(self, user_text: str, turn_id: int) -> TurnResult:
|
||||
completed_turns = 0
|
||||
current_user_text = user_text
|
||||
current_turn_id = turn_id
|
||||
last_assistant_text = ""
|
||||
while True:
|
||||
reply_result = self._reply_once(current_user_text, current_turn_id, completed_turns=completed_turns)
|
||||
completed_turns += reply_result.completed_turns
|
||||
if not reply_result.success:
|
||||
return reply_result
|
||||
last_assistant_text = reply_result.assistant_text
|
||||
if reply_result.error is not None:
|
||||
return reply_result
|
||||
if reply_result.states and reply_result.states[-1] == PipelineState.INTERRUPTED:
|
||||
followup = self._listen_for_followup(current_turn_id + 1, interrupted=True)
|
||||
else:
|
||||
decision = self._decide_continuation(current_user_text, last_assistant_text, current_turn_id)
|
||||
if not decision.should_continue:
|
||||
self._event(
|
||||
CONTINUOUS_SESSION_ENDED,
|
||||
PipelineState.WAKE_LISTENING,
|
||||
"",
|
||||
turn_id=current_turn_id,
|
||||
payload={"decision": decision.action, "reason": decision.reason},
|
||||
)
|
||||
self._event(
|
||||
STANDBY_RESUMED,
|
||||
PipelineState.WAKE_LISTENING,
|
||||
"恢复待机:可继续唤醒",
|
||||
turn_id=current_turn_id,
|
||||
)
|
||||
return TurnResult(
|
||||
True,
|
||||
current_user_text,
|
||||
last_assistant_text,
|
||||
states=list(self._states),
|
||||
completed_turns=completed_turns,
|
||||
)
|
||||
followup = self._listen_for_followup(current_turn_id + 1, interrupted=False)
|
||||
if followup is None:
|
||||
return TurnResult(
|
||||
True,
|
||||
current_user_text,
|
||||
last_assistant_text,
|
||||
states=list(self._states),
|
||||
completed_turns=completed_turns,
|
||||
)
|
||||
if isinstance(followup, ProviderError):
|
||||
return self._recover(followup, current_turn_id + 1, completed_turns=completed_turns)
|
||||
current_turn_id += 1
|
||||
current_user_text = followup
|
||||
|
||||
def _reply_once(self, user_text: str, turn_id: int, *, completed_turns: int) -> TurnResult:
|
||||
self.context.append_user(user_text)
|
||||
self._event(LLM_STARTED, PipelineState.THINKING, "思考中:正在生成回复", turn_id=turn_id)
|
||||
assistant_text = ""
|
||||
spoken_parts: list[str] = []
|
||||
interrupted = False
|
||||
try:
|
||||
for delta in self.llm.stream_reply(self.context.build_llm_messages()):
|
||||
assistant_text += delta.text_delta
|
||||
for sentence in self.sentence_buffer.feed(delta.text_delta, bool(delta.finish_reason)):
|
||||
self._speak(sentence, turn_id)
|
||||
for sentence in self.sentence_buffer.flush():
|
||||
self._speak(sentence, turn_id)
|
||||
speak_result = self._speak(sentence, turn_id)
|
||||
if speak_result.interrupted:
|
||||
interrupted = True
|
||||
break
|
||||
spoken_parts.append(speak_result.spoken_text)
|
||||
if interrupted:
|
||||
break
|
||||
if interrupted:
|
||||
self.sentence_buffer.flush()
|
||||
else:
|
||||
for sentence in self.sentence_buffer.flush():
|
||||
speak_result = self._speak(sentence, turn_id)
|
||||
if speak_result.interrupted:
|
||||
interrupted = True
|
||||
break
|
||||
spoken_parts.append(speak_result.spoken_text)
|
||||
except ProviderError as exc:
|
||||
return self._recover(exc, turn_id)
|
||||
if not assistant_text.strip():
|
||||
return self._recover(exc, turn_id, completed_turns=completed_turns)
|
||||
if not assistant_text.strip() and not "".join(spoken_parts).strip():
|
||||
return self._recover(
|
||||
ProviderError(
|
||||
ErrorCode.LLM_EMPTY_REPLY,
|
||||
@@ -281,19 +399,153 @@ class TurnController:
|
||||
"llm",
|
||||
),
|
||||
turn_id,
|
||||
completed_turns=completed_turns,
|
||||
)
|
||||
self.context.append_assistant(assistant_text)
|
||||
self._event(STANDBY_RESUMED, PipelineState.WAKE_LISTENING, "恢复待机:可继续唤醒", turn_id=turn_id)
|
||||
return TurnResult(True, user_text, assistant_text, states=list(self._states))
|
||||
spoken_text = "".join(spoken_parts)
|
||||
if spoken_text.strip():
|
||||
self.context.append_assistant(spoken_text)
|
||||
states = list(self._states)
|
||||
if interrupted:
|
||||
states.append(PipelineState.INTERRUPTED)
|
||||
return TurnResult(
|
||||
True,
|
||||
user_text,
|
||||
spoken_text or assistant_text,
|
||||
states=states,
|
||||
completed_turns=1,
|
||||
)
|
||||
|
||||
def _speak(self, sentence: str, turn_id: int) -> None:
|
||||
def _decide_continuation(self, user_text: str, assistant_text: str, turn_id: int) -> ContinuationDecision:
|
||||
if not self.config.continuous_dialog_enabled:
|
||||
return ContinuationDecision("standby", 1.0, "continuous dialog disabled", "config")
|
||||
self._event(CONTINUATION_DECISION_STARTED, PipelineState.THINKING, "", turn_id=turn_id)
|
||||
decision = self.continuation_decider.decide(
|
||||
user_text=user_text,
|
||||
assistant_text=assistant_text,
|
||||
history=self.context.messages(),
|
||||
)
|
||||
self._event(
|
||||
CONTINUATION_DECISION_MADE,
|
||||
PipelineState.THINKING,
|
||||
"",
|
||||
turn_id=turn_id,
|
||||
payload={
|
||||
"action": decision.action,
|
||||
"confidence": decision.confidence,
|
||||
"reason": decision.reason,
|
||||
"provider": decision.provider,
|
||||
},
|
||||
)
|
||||
return decision
|
||||
|
||||
def _listen_for_followup(self, turn_id: int, *, interrupted: bool) -> str | ProviderError | None:
|
||||
if not interrupted:
|
||||
seconds = max(1, round(self.config.followup_listen_timeout_ms / 1000))
|
||||
self._event(
|
||||
FOLLOWUP_LISTENING,
|
||||
PipelineState.RECORDING,
|
||||
f"继续对话:{seconds}秒内可直接回答",
|
||||
turn_id=turn_id,
|
||||
)
|
||||
user_text = self._capture_and_transcribe(
|
||||
turn_id,
|
||||
state_message="录音中:正在听取追问" if not interrupted else "录音中:正在听取打断内容",
|
||||
no_speech_timeout_ms=self.config.followup_listen_timeout_ms,
|
||||
)
|
||||
if isinstance(user_text, ProviderError) and user_text.code == ErrorCode.VAD_TIMEOUT_NO_SPEECH:
|
||||
self._event(
|
||||
FOLLOWUP_TIMEOUT,
|
||||
PipelineState.WAKE_LISTENING,
|
||||
"追问超时:未检测到用户回答",
|
||||
turn_id=turn_id,
|
||||
)
|
||||
self._event(CONTINUOUS_SESSION_ENDED, PipelineState.WAKE_LISTENING, "", turn_id=turn_id)
|
||||
self._event(STANDBY_RESUMED, PipelineState.WAKE_LISTENING, "恢复待机:可继续唤醒", turn_id=turn_id)
|
||||
return None
|
||||
return user_text
|
||||
|
||||
def _speak(self, sentence: str, turn_id: int) -> SpeakResult:
|
||||
self._event(TTS_STARTED, PipelineState.SPEAKING, "播放中:正在播报回复", turn_id=turn_id)
|
||||
segment = self.tts.synthesize(sentence)
|
||||
playback = self.transport.play_pcm(segment)
|
||||
if playback.error:
|
||||
raise playback.error
|
||||
if not self._can_interrupt_playback(segment):
|
||||
playback = self.transport.play_pcm(segment)
|
||||
if playback.error:
|
||||
raise playback.error
|
||||
self._event(PLAYBACK_FINISHED, PipelineState.SPEAKING, "", turn_id=turn_id)
|
||||
self._drain_input_after_playback()
|
||||
return SpeakResult(sentence)
|
||||
if self._play_interruptible(segment, turn_id=turn_id):
|
||||
return SpeakResult("", interrupted=True)
|
||||
self._event(PLAYBACK_FINISHED, PipelineState.SPEAKING, "", turn_id=turn_id)
|
||||
self._drain_input_after_playback()
|
||||
return SpeakResult(sentence)
|
||||
|
||||
def _can_interrupt_playback(self, segment: AudioSegment) -> bool:
|
||||
return (
|
||||
self.config.barge_in_enabled
|
||||
and self.realtime_stt is not None
|
||||
and segment.duration_ms > self.config.barge_in_echo_guard_ms
|
||||
and not segment.metadata.get("format")
|
||||
)
|
||||
|
||||
def _play_interruptible(self, segment: AudioSegment, *, turn_id: int) -> bool:
|
||||
elapsed_ms = 0
|
||||
guard_cleared = False
|
||||
self.vad_recorder.provider.reset()
|
||||
realtime_session = self._start_realtime_transcript()
|
||||
speech_ms = 0
|
||||
partial_seen = False
|
||||
pending_frames: list[AudioFrame] = []
|
||||
for chunk in _audio_chunks(segment, chunk_ms=100):
|
||||
playback = self.transport.play_pcm(chunk)
|
||||
if playback.error:
|
||||
raise playback.error
|
||||
elapsed_ms += chunk.duration_ms
|
||||
if elapsed_ms < self.config.barge_in_echo_guard_ms:
|
||||
continue
|
||||
if not guard_cleared:
|
||||
self.transport.flush_input()
|
||||
guard_cleared = True
|
||||
continue
|
||||
detected, speech_ms, partial_seen, new_frames = self._detect_barge_in(
|
||||
realtime_session,
|
||||
turn_id=turn_id,
|
||||
speech_ms=speech_ms,
|
||||
partial_seen=partial_seen,
|
||||
)
|
||||
pending_frames.extend(new_frames)
|
||||
if detected:
|
||||
self._pending_capture_frames.extend(pending_frames)
|
||||
self._event(BARGE_IN_DETECTED, PipelineState.INTERRUPTED, "检测到用户打断", turn_id=turn_id)
|
||||
self._event(PLAYBACK_INTERRUPTED, PipelineState.INTERRUPTED, "播报已打断", turn_id=turn_id)
|
||||
if realtime_session is not None:
|
||||
realtime_session.finish()
|
||||
return True
|
||||
if realtime_session is not None:
|
||||
realtime_session.finish()
|
||||
return False
|
||||
|
||||
def _detect_barge_in(
|
||||
self,
|
||||
realtime_session: RealtimeTranscriptSession | None,
|
||||
*,
|
||||
turn_id: int,
|
||||
speech_ms: int,
|
||||
partial_seen: bool,
|
||||
) -> tuple[bool, int, bool, list[AudioFrame]]:
|
||||
frames = self.transport.read_frames(timeout_ms=0)
|
||||
if not frames:
|
||||
return False, speech_ms, partial_seen, []
|
||||
for frame in frames:
|
||||
result = self.vad_recorder.provider.analyze(frame)
|
||||
if result.is_speech:
|
||||
speech_ms += int(frame.metadata.get("duration_ms", 20))
|
||||
if realtime_session is not None and self._emit_realtime_transcript(realtime_session, frame, turn_id):
|
||||
partial_seen = True
|
||||
else:
|
||||
speech_ms = 0
|
||||
detected = speech_ms >= self.config.barge_in_min_speech_ms and partial_seen
|
||||
return detected, speech_ms, partial_seen, frames
|
||||
|
||||
def _drain_input_after_playback(self) -> None:
|
||||
self.transport.flush_input()
|
||||
@@ -306,11 +558,11 @@ class TurnController:
|
||||
remaining_ms -= timeout_ms
|
||||
self.transport.flush_input()
|
||||
|
||||
def _recover(self, error: ProviderError, turn_id: int) -> TurnResult:
|
||||
def _recover(self, error: ProviderError, turn_id: int, *, completed_turns: int = 0) -> TurnResult:
|
||||
self._event(STAGE_ERROR, PipelineState.ERROR_RECOVERING, error.message, turn_id=turn_id, payload={"error": error})
|
||||
self._event(RECOVERING, PipelineState.ERROR_RECOVERING, "恢复待机:本轮已结束", 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), completed_turns=completed_turns, failed_turns=1)
|
||||
|
||||
def _event(
|
||||
self,
|
||||
@@ -342,6 +594,7 @@ class VoiceAssistantPipeline:
|
||||
ack_tts: TtsProvider | None = None,
|
||||
reporter: RuntimeReporter | None = None,
|
||||
event_bus: PipelineEventBus | None = None,
|
||||
continuation_decider: ContinuationDecisionProvider | None = None,
|
||||
sentence_buffer: SentenceBuffer | None = None,
|
||||
) -> None:
|
||||
self.config = config
|
||||
@@ -357,6 +610,11 @@ class VoiceAssistantPipeline:
|
||||
self.context = context
|
||||
self.reporter = reporter
|
||||
self.event_bus = event_bus or PipelineEventBus()
|
||||
self.continuation_decider = continuation_decider or build_continuation_decider(
|
||||
config.continuation_decision_provider,
|
||||
llm,
|
||||
threshold=config.continuation_confidence_threshold,
|
||||
)
|
||||
if reporter is not None:
|
||||
self.event_bus.subscribe(self._report_event)
|
||||
self.sentence_buffer = sentence_buffer or SentenceBuffer()
|
||||
@@ -373,6 +631,7 @@ class VoiceAssistantPipeline:
|
||||
ack_tts=self.ack_tts,
|
||||
context=context,
|
||||
event_bus=self.event_bus,
|
||||
continuation_decider=self.continuation_decider,
|
||||
sentence_buffer=self.sentence_buffer,
|
||||
)
|
||||
|
||||
@@ -401,10 +660,11 @@ class VoiceAssistantPipeline:
|
||||
while True:
|
||||
turn_id = completed + failed + 1
|
||||
result = self.run_turn(turn_id)
|
||||
completed += result.completed_turns
|
||||
if result.success:
|
||||
completed += 1
|
||||
completed += 0 if result.completed_turns else 1
|
||||
else:
|
||||
failed += 1
|
||||
failed += result.failed_turns or 1
|
||||
last_error = result.error
|
||||
if once:
|
||||
break
|
||||
@@ -432,3 +692,30 @@ class VoiceAssistantPipeline:
|
||||
handler(event)
|
||||
return
|
||||
dispatch_pipeline_event(self.reporter, event)
|
||||
|
||||
|
||||
def _audio_chunks(segment: AudioSegment, *, chunk_ms: int) -> list[AudioSegment]:
|
||||
if chunk_ms <= 0 or segment.duration_ms <= chunk_ms:
|
||||
return [segment]
|
||||
bytes_per_ms = max(1, int(segment.sample_rate * segment.channels * 2 / 1000))
|
||||
chunk_bytes = max(2 * segment.channels, bytes_per_ms * chunk_ms)
|
||||
chunk_bytes -= chunk_bytes % (2 * segment.channels)
|
||||
chunks: list[AudioSegment] = []
|
||||
offset = 0
|
||||
start_ms = segment.start_time_ms
|
||||
while offset < len(segment.pcm):
|
||||
data = segment.pcm[offset : offset + chunk_bytes]
|
||||
duration_ms = max(1, int(len(data) / bytes_per_ms))
|
||||
chunks.append(
|
||||
AudioSegment(
|
||||
data,
|
||||
segment.sample_rate,
|
||||
segment.channels,
|
||||
start_ms,
|
||||
start_ms + duration_ms,
|
||||
dict(segment.metadata),
|
||||
)
|
||||
)
|
||||
offset += len(data)
|
||||
start_ms += duration_ms
|
||||
return chunks
|
||||
|
||||
@@ -4,6 +4,7 @@ import argparse
|
||||
import json
|
||||
import re
|
||||
import subprocess
|
||||
from dataclasses import replace
|
||||
from pathlib import Path
|
||||
|
||||
from .audio_preprocess import SherpaOnnxDenoiserPreprocessor
|
||||
@@ -96,6 +97,13 @@ def main(argv: list[str] | None = None) -> int:
|
||||
"tts_voice": config.tts_voice,
|
||||
"speech_models_dir": str(config.speech_models_dir),
|
||||
"context_mode": config.context_mode,
|
||||
"continuous_dialog_enabled": config.continuous_dialog_enabled,
|
||||
"continuation_decision_provider": config.continuation_decision_provider,
|
||||
"continuation_confidence_threshold": config.continuation_confidence_threshold,
|
||||
"followup_listen_timeout_ms": config.followup_listen_timeout_ms,
|
||||
"barge_in_enabled": config.barge_in_enabled,
|
||||
"barge_in_min_speech_ms": config.barge_in_min_speech_ms,
|
||||
"barge_in_echo_guard_ms": config.barge_in_echo_guard_ms,
|
||||
},
|
||||
ensure_ascii=False,
|
||||
sort_keys=True,
|
||||
@@ -192,52 +200,7 @@ def main(argv: list[str] | None = None) -> int:
|
||||
if args.command == "llm-smoke":
|
||||
config = AppConfig.from_dotenv(args.env_file)
|
||||
if args.no_stream:
|
||||
config = AppConfig(
|
||||
wake_word=config.wake_word,
|
||||
sample_rate=config.sample_rate,
|
||||
channels=config.channels,
|
||||
llm_base_url=config.llm_base_url,
|
||||
llm_api_key=config.llm_api_key,
|
||||
llm_model=config.llm_model,
|
||||
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,
|
||||
log_dir=config.log_dir,
|
||||
wake_provider=config.wake_provider,
|
||||
wake_keywords_file=config.wake_keywords_file,
|
||||
wake_kws_threshold=config.wake_kws_threshold,
|
||||
wake_kws_score=config.wake_kws_score,
|
||||
wake_ack_text=config.wake_ack_text,
|
||||
post_playback_drain_ms=config.post_playback_drain_ms,
|
||||
pipeline_mode=config.pipeline_mode,
|
||||
endpoint_mode=config.endpoint_mode,
|
||||
noise_filter_enabled=config.noise_filter_enabled,
|
||||
noise_filter_provider=config.noise_filter_provider,
|
||||
wake_denoise_enabled=config.wake_denoise_enabled,
|
||||
speaker_profile_ms=config.speaker_profile_ms,
|
||||
speaker_profile_min_ms=config.speaker_profile_min_ms,
|
||||
speaker_absent_ms=config.speaker_absent_ms,
|
||||
speaker_similarity_threshold=config.speaker_similarity_threshold,
|
||||
speaker_min_rms=config.speaker_min_rms,
|
||||
vad_provider=config.vad_provider,
|
||||
vad_threshold=config.vad_threshold,
|
||||
vad_min_duration_ms=config.vad_min_duration_ms,
|
||||
vad_end_silence_ms=config.vad_end_silence_ms,
|
||||
vad_no_speech_timeout_ms=config.vad_no_speech_timeout_ms,
|
||||
vad_max_recording_ms=config.vad_max_recording_ms,
|
||||
speech_provider=config.speech_provider,
|
||||
asr_model=config.asr_model,
|
||||
tts_model=config.tts_model,
|
||||
tts_voice=config.tts_voice,
|
||||
speech_models_dir=config.speech_models_dir,
|
||||
context_mode=config.context_mode,
|
||||
context_max_messages=config.context_max_messages,
|
||||
context_max_chars=config.context_max_chars,
|
||||
)
|
||||
config = replace(config, llm_stream=False)
|
||||
try:
|
||||
provider = OpenAICompatibleLlmProvider(config, timeout_s=30)
|
||||
messages = [ConversationContext().build_llm_messages()[0]]
|
||||
|
||||
@@ -52,6 +52,13 @@ class AppConfig:
|
||||
context_mode: str = "session_memory"
|
||||
context_max_messages: int = 12
|
||||
context_max_chars: int = 12000
|
||||
continuous_dialog_enabled: bool = True
|
||||
continuation_decision_provider: str = "hybrid"
|
||||
continuation_confidence_threshold: float = 0.65
|
||||
followup_listen_timeout_ms: int = 3000
|
||||
barge_in_enabled: bool = True
|
||||
barge_in_min_speech_ms: int = 250
|
||||
barge_in_echo_guard_ms: int = 500
|
||||
|
||||
@classmethod
|
||||
def from_dotenv(cls, path: str | Path = ".env", prefix: str = "OWNER_") -> "AppConfig":
|
||||
@@ -111,6 +118,18 @@ class AppConfig:
|
||||
context_mode=(get("CONTEXT_MODE", "session_memory") or "session_memory").lower(),
|
||||
context_max_messages=int(get("CONTEXT_MAX_MESSAGES", "12") or "12"),
|
||||
context_max_chars=int(get("CONTEXT_MAX_CHARS", "12000") or "12000"),
|
||||
continuous_dialog_enabled=(get("CONTINUOUS_DIALOG_ENABLED", "1") or "1").lower()
|
||||
not in {"0", "false", "no"},
|
||||
continuation_decision_provider=(
|
||||
get("CONTINUATION_DECISION_PROVIDER", "hybrid") or "hybrid"
|
||||
).lower(),
|
||||
continuation_confidence_threshold=float(
|
||||
get("CONTINUATION_CONFIDENCE_THRESHOLD", "0.65") or "0.65"
|
||||
),
|
||||
followup_listen_timeout_ms=int(get("FOLLOWUP_LISTEN_TIMEOUT_MS", "3000") or "3000"),
|
||||
barge_in_enabled=(get("BARGE_IN_ENABLED", "1") or "1").lower() not in {"0", "false", "no"},
|
||||
barge_in_min_speech_ms=int(get("BARGE_IN_MIN_SPEECH_MS", "250") or "250"),
|
||||
barge_in_echo_guard_ms=int(get("BARGE_IN_ECHO_GUARD_MS", "500") or "500"),
|
||||
)
|
||||
|
||||
@classmethod
|
||||
@@ -340,6 +359,41 @@ class AppConfig:
|
||||
"startup",
|
||||
)
|
||||
)
|
||||
if self.continuation_decision_provider not in {"hybrid", "rule", "llm"}:
|
||||
errors.append(
|
||||
ProviderError(
|
||||
ErrorCode.CONFIG_MISSING_VALUE,
|
||||
"OWNER_CONTINUATION_DECISION_PROVIDER must be hybrid, rule, or llm",
|
||||
False,
|
||||
"config",
|
||||
"startup",
|
||||
)
|
||||
)
|
||||
if not 0 < self.continuation_confidence_threshold <= 1:
|
||||
errors.append(
|
||||
ProviderError(
|
||||
ErrorCode.CONFIG_MISSING_VALUE,
|
||||
"OWNER_CONTINUATION_CONFIDENCE_THRESHOLD must be in (0, 1]",
|
||||
False,
|
||||
"config",
|
||||
"startup",
|
||||
)
|
||||
)
|
||||
for name, value in {
|
||||
"OWNER_FOLLOWUP_LISTEN_TIMEOUT_MS": self.followup_listen_timeout_ms,
|
||||
"OWNER_BARGE_IN_MIN_SPEECH_MS": self.barge_in_min_speech_ms,
|
||||
"OWNER_BARGE_IN_ECHO_GUARD_MS": self.barge_in_echo_guard_ms,
|
||||
}.items():
|
||||
if value < 0:
|
||||
errors.append(
|
||||
ProviderError(
|
||||
ErrorCode.CONFIG_MISSING_VALUE,
|
||||
f"{name} must be non-negative",
|
||||
False,
|
||||
"config",
|
||||
"startup",
|
||||
)
|
||||
)
|
||||
return errors
|
||||
|
||||
def api_url(self, path: str) -> str:
|
||||
|
||||
@@ -0,0 +1,205 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
from collections.abc import Sequence
|
||||
from dataclasses import dataclass
|
||||
from typing import Protocol
|
||||
|
||||
from .models import Message
|
||||
from .protocols import LlmProvider
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ContinuationDecision:
|
||||
action: str
|
||||
confidence: float
|
||||
reason: str
|
||||
provider: str
|
||||
|
||||
@property
|
||||
def should_continue(self) -> bool:
|
||||
return self.action == "continue"
|
||||
|
||||
|
||||
class ContinuationDecisionProvider(Protocol):
|
||||
def decide(
|
||||
self,
|
||||
*,
|
||||
user_text: str,
|
||||
assistant_text: str,
|
||||
history: Sequence[Message],
|
||||
) -> ContinuationDecision:
|
||||
...
|
||||
|
||||
|
||||
class RuleContinuationDecisionProvider:
|
||||
_continue_patterns = (
|
||||
"你想",
|
||||
"你要",
|
||||
"你需要",
|
||||
"需要我",
|
||||
"要不要",
|
||||
"是否需要",
|
||||
"可以告诉我",
|
||||
"告诉我",
|
||||
"请告诉我",
|
||||
"你希望",
|
||||
"你更想",
|
||||
"哪一个",
|
||||
"哪一部分",
|
||||
"哪种",
|
||||
"哪个",
|
||||
"请选择",
|
||||
"选一个",
|
||||
"请补充",
|
||||
"需要补充",
|
||||
"补充一下",
|
||||
"继续吗",
|
||||
)
|
||||
_standby_patterns = (
|
||||
"这是",
|
||||
"已经",
|
||||
"完成",
|
||||
"好了",
|
||||
"好的",
|
||||
"没问题",
|
||||
"不客气",
|
||||
"无法",
|
||||
"不能",
|
||||
"抱歉",
|
||||
"出错",
|
||||
"失败",
|
||||
)
|
||||
|
||||
def decide(
|
||||
self,
|
||||
*,
|
||||
user_text: str,
|
||||
assistant_text: str,
|
||||
history: Sequence[Message],
|
||||
) -> ContinuationDecision:
|
||||
text = _normalize(assistant_text)
|
||||
if not text:
|
||||
return ContinuationDecision("standby", 1.0, "empty assistant reply", "rule")
|
||||
if _has_direct_question(text) or any(pattern in text for pattern in self._continue_patterns):
|
||||
return ContinuationDecision("continue", 0.86, "assistant asks for user input", "rule")
|
||||
if any(pattern in text for pattern in self._standby_patterns):
|
||||
return ContinuationDecision("standby", 0.82, "assistant appears to finish the answer", "rule")
|
||||
if len(text) <= 18 and not text.endswith(("?", "?", "吗", "呢")):
|
||||
return ContinuationDecision("standby", 0.72, "short non-question reply", "rule")
|
||||
return ContinuationDecision("unknown", 0.0, "rule uncertain", "rule")
|
||||
|
||||
|
||||
class LlmContinuationDecisionProvider:
|
||||
def __init__(self, llm: LlmProvider, *, threshold: float = 0.65) -> None:
|
||||
self.llm = llm
|
||||
self.threshold = threshold
|
||||
|
||||
def decide(
|
||||
self,
|
||||
*,
|
||||
user_text: str,
|
||||
assistant_text: str,
|
||||
history: Sequence[Message],
|
||||
) -> ContinuationDecision:
|
||||
prompt = (
|
||||
"你是语音助手的持续对话分类器。判断助手刚才的回复是否需要用户继续直接回答。\n"
|
||||
"只允许输出 JSON: {\"action\":\"continue|standby\",\"confidence\":0到1,\"reason\":\"短原因\"}。\n"
|
||||
"如果不确定,action 必须是 standby。\n"
|
||||
f"用户上一句: {user_text}\n"
|
||||
f"助手回复: {assistant_text}\n"
|
||||
)
|
||||
messages = [
|
||||
Message("system", "你只做二分类,不生成正常对话回复。", 0.0),
|
||||
Message("user", prompt, 0.0),
|
||||
]
|
||||
try:
|
||||
raw = "".join(delta.text_delta for delta in self.llm.stream_reply(messages)).strip()
|
||||
except Exception as exc:
|
||||
return ContinuationDecision("standby", 0.0, f"classifier failed: {exc}", "llm")
|
||||
parsed = _parse_classifier_output(raw)
|
||||
if parsed is None:
|
||||
return ContinuationDecision("standby", 0.0, "classifier output malformed", "llm")
|
||||
action, confidence, reason = parsed
|
||||
if action not in {"continue", "standby"}:
|
||||
return ContinuationDecision("standby", 0.0, "classifier action invalid", "llm")
|
||||
if confidence < self.threshold:
|
||||
return ContinuationDecision("standby", confidence, "classifier confidence below threshold", "llm")
|
||||
return ContinuationDecision(action, confidence, reason or "classifier decision", "llm")
|
||||
|
||||
|
||||
class HybridContinuationDecisionProvider:
|
||||
def __init__(
|
||||
self,
|
||||
rule_provider: RuleContinuationDecisionProvider,
|
||||
llm_provider: LlmContinuationDecisionProvider | None,
|
||||
*,
|
||||
threshold: float = 0.65,
|
||||
) -> None:
|
||||
self.rule_provider = rule_provider
|
||||
self.llm_provider = llm_provider
|
||||
self.threshold = threshold
|
||||
|
||||
def decide(
|
||||
self,
|
||||
*,
|
||||
user_text: str,
|
||||
assistant_text: str,
|
||||
history: Sequence[Message],
|
||||
) -> ContinuationDecision:
|
||||
rule = self.rule_provider.decide(user_text=user_text, assistant_text=assistant_text, history=history)
|
||||
if rule.action in {"continue", "standby"} and rule.confidence >= self.threshold:
|
||||
return rule
|
||||
if self.llm_provider is None:
|
||||
return ContinuationDecision("standby", rule.confidence, "rule uncertain and no llm fallback", "hybrid")
|
||||
llm = self.llm_provider.decide(user_text=user_text, assistant_text=assistant_text, history=history)
|
||||
if llm.action == "continue" and llm.confidence >= self.threshold:
|
||||
return ContinuationDecision("continue", llm.confidence, llm.reason, "hybrid")
|
||||
return ContinuationDecision("standby", llm.confidence, llm.reason, "hybrid")
|
||||
|
||||
|
||||
def build_continuation_decider(
|
||||
provider_name: str,
|
||||
llm: LlmProvider,
|
||||
*,
|
||||
threshold: float,
|
||||
) -> ContinuationDecisionProvider:
|
||||
rule = RuleContinuationDecisionProvider()
|
||||
if provider_name == "rule":
|
||||
return rule
|
||||
llm_provider = LlmContinuationDecisionProvider(llm, threshold=threshold)
|
||||
if provider_name == "llm":
|
||||
return llm_provider
|
||||
return HybridContinuationDecisionProvider(rule, llm_provider, threshold=threshold)
|
||||
|
||||
|
||||
def _normalize(text: str) -> str:
|
||||
return re.sub(r"\s+", "", text.strip())
|
||||
|
||||
|
||||
def _has_direct_question(text: str) -> bool:
|
||||
if text.endswith(("?", "?")):
|
||||
return True
|
||||
return bool(re.search(r"(你|您).{0,12}(吗|呢|么|什么|哪|是否|要不要|需要不需要)", text))
|
||||
|
||||
|
||||
def _parse_classifier_output(raw: str) -> tuple[str, float, str] | None:
|
||||
clean = raw.strip()
|
||||
if not clean:
|
||||
return None
|
||||
try:
|
||||
data = json.loads(clean)
|
||||
action = str(data.get("action", "")).strip().lower()
|
||||
confidence = float(data.get("confidence", 1.0 if action in {"continue", "standby"} else 0.0))
|
||||
reason = str(data.get("reason", "")).strip()
|
||||
return action, max(0.0, min(1.0, confidence)), reason
|
||||
except (ValueError, TypeError, json.JSONDecodeError):
|
||||
lowered = clean.lower()
|
||||
if lowered in {"continue", "standby"}:
|
||||
return lowered, 1.0, "plain classifier output"
|
||||
if "continue" in lowered and "standby" not in lowered:
|
||||
return "continue", 1.0, "plain classifier output"
|
||||
if "standby" in lowered and "continue" not in lowered:
|
||||
return "standby", 1.0, "plain classifier output"
|
||||
return None
|
||||
@@ -21,6 +21,13 @@ LLM_STARTED = "llm_started"
|
||||
TTS_STARTED = "tts_started"
|
||||
PLAYBACK_FINISHED = "playback_finished"
|
||||
STANDBY_RESUMED = "standby_resumed"
|
||||
CONTINUATION_DECISION_STARTED = "continuation_decision_started"
|
||||
CONTINUATION_DECISION_MADE = "continuation_decision_made"
|
||||
FOLLOWUP_LISTENING = "followup_listening"
|
||||
FOLLOWUP_TIMEOUT = "followup_timeout"
|
||||
BARGE_IN_DETECTED = "barge_in_detected"
|
||||
PLAYBACK_INTERRUPTED = "playback_interrupted"
|
||||
CONTINUOUS_SESSION_ENDED = "continuous_session_ended"
|
||||
STAGE_ERROR = "stage_error"
|
||||
RECOVERING = "recovering"
|
||||
|
||||
|
||||
@@ -229,6 +229,8 @@ def run_real_live_check(
|
||||
speech_provider="local",
|
||||
noise_filter_enabled=True,
|
||||
realtime_transcript_enabled=True,
|
||||
continuous_dialog_enabled=False,
|
||||
barge_in_enabled=False,
|
||||
llm_stream=False,
|
||||
post_playback_drain_ms=0,
|
||||
)
|
||||
|
||||
@@ -9,6 +9,7 @@ import subprocess
|
||||
import tempfile
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
import wave
|
||||
from collections.abc import Callable
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
@@ -104,8 +105,9 @@ class MacSayTtsProvider:
|
||||
"tts",
|
||||
)
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
output = Path(tmp) / "speech.aiff"
|
||||
command = ["say", "-o", str(output)]
|
||||
aiff_output = Path(tmp) / "speech.aiff"
|
||||
wav_output = Path(tmp) / "speech.wav"
|
||||
command = ["say", "-o", str(aiff_output)]
|
||||
if self.voice:
|
||||
command.extend(["-v", self.voice])
|
||||
command.append(clean)
|
||||
@@ -119,7 +121,23 @@ class MacSayTtsProvider:
|
||||
"macos-say",
|
||||
"tts",
|
||||
) from exc
|
||||
data = output.read_bytes()
|
||||
try:
|
||||
subprocess.run(
|
||||
["afconvert", "-f", "WAVE", "-d", "LEI16@16000", "-c", "1", str(aiff_output), str(wav_output)],
|
||||
check=True,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
)
|
||||
with wave.open(str(wav_output), "rb") as handle:
|
||||
sample_rate = handle.getframerate()
|
||||
channels = handle.getnchannels()
|
||||
data = handle.readframes(handle.getnframes())
|
||||
metadata = {"text": clean}
|
||||
except (FileNotFoundError, subprocess.CalledProcessError, wave.Error):
|
||||
data = aiff_output.read_bytes()
|
||||
sample_rate = 16000
|
||||
channels = 1
|
||||
metadata = {"text": clean, "format": "aiff"}
|
||||
if not data:
|
||||
raise ProviderError(
|
||||
ErrorCode.TTS_EMPTY_AUDIO,
|
||||
@@ -128,7 +146,8 @@ class MacSayTtsProvider:
|
||||
"macos-say",
|
||||
"tts",
|
||||
)
|
||||
return AudioSegment(data, 16000, 1, 0, max(120, len(clean) * 45), {"text": clean, "format": "aiff"})
|
||||
duration_ms = int(len(data) / max(1, sample_rate * channels * 2) * 1000)
|
||||
return AudioSegment(data, sample_rate, channels, 0, max(120, duration_ms, len(clean) * 45), metadata)
|
||||
|
||||
|
||||
class CloudTtsProvider:
|
||||
|
||||
Reference in New Issue
Block a user