[打断低延迟修复]:完成真实播报打断门控修正,包含短保护窗口、用户音色优先和诊断输出
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Protocol
|
||||
|
||||
@@ -124,6 +125,7 @@ class TurnController:
|
||||
self._pending_capture_frames: list[AudioFrame] = []
|
||||
self._cached_ack_text: str | None = None
|
||||
self._cached_ack_segment: AudioSegment | None = None
|
||||
self._barge_in_reply_guard_used = True
|
||||
self._barge_in_gate = BargeInSpeakerGate(
|
||||
enabled=config.barge_in_speaker_gate_enabled,
|
||||
user_similarity_threshold=config.barge_in_user_similarity_threshold,
|
||||
@@ -244,10 +246,23 @@ class TurnController:
|
||||
self.audio_preprocessor.reset()
|
||||
realtime_session = self._start_realtime_transcript()
|
||||
last_partial_ms: int | None = None
|
||||
capture_started_at = time.monotonic()
|
||||
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:
|
||||
if (
|
||||
no_speech_timeout_ms is not None
|
||||
and not self.vad_recorder.started
|
||||
and (time.monotonic() - capture_started_at) * 1000 >= no_speech_timeout_ms
|
||||
):
|
||||
return ProviderError(
|
||||
ErrorCode.VAD_TIMEOUT_NO_SPEECH,
|
||||
"no speech detected before timeout",
|
||||
True,
|
||||
"voice-assistant-pipeline",
|
||||
"vad",
|
||||
)
|
||||
continue
|
||||
for frame in frames:
|
||||
try:
|
||||
@@ -402,6 +417,7 @@ class TurnController:
|
||||
assistant_text = ""
|
||||
spoken_parts: list[str] = []
|
||||
interrupted = False
|
||||
self._barge_in_reply_guard_used = False
|
||||
try:
|
||||
for delta in self.llm.stream_reply(self.context.build_llm_messages()):
|
||||
assistant_text += delta.text_delta
|
||||
@@ -543,27 +559,61 @@ class TurnController:
|
||||
self._event(TTS_STARTED, PipelineState.SPEAKING, "播放中:正在播报回复", turn_id=turn_id)
|
||||
segment = self.tts.synthesize(spoken_sentence)
|
||||
interruptible_segment = ensure_interruptible_pcm(segment)
|
||||
if not self._can_interrupt_playback(interruptible_segment):
|
||||
echo_guard_ms = self._effective_barge_in_echo_guard_ms(interruptible_segment)
|
||||
min_speech_ms = self._effective_barge_in_min_speech_ms()
|
||||
if not self._can_interrupt_playback(interruptible_segment, min_speech_ms=min_speech_ms):
|
||||
self._barge_in_debug(
|
||||
turn_id,
|
||||
"not_interruptible",
|
||||
{
|
||||
"segment_ready": interruptible_segment is not None,
|
||||
"barge_in_enabled": self.config.barge_in_enabled,
|
||||
"segment_duration_ms": interruptible_segment.duration_ms if interruptible_segment else None,
|
||||
"min_speech_ms": min_speech_ms,
|
||||
},
|
||||
)
|
||||
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(spoken_sentence)
|
||||
if self._play_interruptible(interruptible_segment, turn_id=turn_id):
|
||||
if self._play_interruptible(
|
||||
interruptible_segment,
|
||||
turn_id=turn_id,
|
||||
echo_guard_ms=echo_guard_ms,
|
||||
min_speech_ms=min_speech_ms,
|
||||
):
|
||||
return SpeakResult("", interrupted=True)
|
||||
self._event(PLAYBACK_FINISHED, PipelineState.SPEAKING, "", turn_id=turn_id)
|
||||
self._drain_input_after_playback()
|
||||
return SpeakResult(spoken_sentence)
|
||||
|
||||
def _can_interrupt_playback(self, segment: AudioSegment | None) -> bool:
|
||||
def _can_interrupt_playback(self, segment: AudioSegment | None, *, min_speech_ms: int) -> bool:
|
||||
return (
|
||||
segment is not None
|
||||
and self.config.barge_in_enabled
|
||||
and segment.duration_ms > self.config.barge_in_echo_guard_ms
|
||||
and segment.duration_ms >= max(self.config.barge_in_chunk_ms, min_speech_ms)
|
||||
)
|
||||
|
||||
def _play_interruptible(self, segment: AudioSegment, *, turn_id: int) -> bool:
|
||||
def _play_interruptible(
|
||||
self,
|
||||
segment: AudioSegment,
|
||||
*,
|
||||
turn_id: int,
|
||||
echo_guard_ms: int,
|
||||
min_speech_ms: int,
|
||||
) -> bool:
|
||||
self._barge_in_debug(
|
||||
turn_id,
|
||||
"monitor_starting",
|
||||
{
|
||||
"segment_duration_ms": segment.duration_ms,
|
||||
"echo_guard_ms": echo_guard_ms,
|
||||
"min_speech_ms": min_speech_ms,
|
||||
"chunk_ms": self.config.barge_in_chunk_ms,
|
||||
},
|
||||
)
|
||||
monitor = AsyncBargeInMonitor(
|
||||
transport=self.transport,
|
||||
vad_provider=self.vad_recorder.provider,
|
||||
@@ -571,9 +621,14 @@ class TurnController:
|
||||
speaker_gate=self._barge_in_gate,
|
||||
assistant_profile=self._barge_in_gate.assistant_profile(segment),
|
||||
assistant_reference=segment,
|
||||
echo_guard_ms=self.config.barge_in_echo_guard_ms,
|
||||
min_speech_ms=self.config.barge_in_min_speech_ms,
|
||||
echo_guard_ms=echo_guard_ms,
|
||||
min_speech_ms=min_speech_ms,
|
||||
listen_interval_ms=self.config.barge_in_listen_interval_ms,
|
||||
debug_callback=(
|
||||
lambda reason, payload: self._barge_in_debug(turn_id, reason, payload)
|
||||
if self.config.barge_in_debug
|
||||
else None
|
||||
),
|
||||
)
|
||||
monitor.start()
|
||||
playback = self.transport.play_pcm_chunks(
|
||||
@@ -591,6 +646,23 @@ class TurnController:
|
||||
return True
|
||||
return False
|
||||
|
||||
def _effective_barge_in_echo_guard_ms(self, segment: AudioSegment | None) -> int:
|
||||
if segment is None or self._barge_in_reply_guard_used:
|
||||
return 0
|
||||
self._barge_in_reply_guard_used = True
|
||||
return max(0, min(self.config.barge_in_echo_guard_ms, 120))
|
||||
|
||||
def _effective_barge_in_min_speech_ms(self) -> int:
|
||||
latency_budget = max(60, self.config.interrupt_target_latency_ms - self.config.barge_in_chunk_ms)
|
||||
return max(40, min(self.config.barge_in_min_speech_ms, latency_budget))
|
||||
|
||||
def _barge_in_debug(self, turn_id: int, reason: str, payload: dict[str, object]) -> None:
|
||||
if not self.config.barge_in_debug:
|
||||
return
|
||||
details = " ".join(f"{key}={value}" for key, value in sorted(payload.items()))
|
||||
message = f"打断诊断:{reason}" + (f" {details}" if details else "")
|
||||
self._event("barge_in_debug", PipelineState.SPEAKING, message, turn_id=turn_id, payload=payload)
|
||||
|
||||
def _drain_input_after_playback(self) -> None:
|
||||
self.transport.flush_input()
|
||||
if self.config.post_playback_drain_ms <= 0:
|
||||
|
||||
@@ -9,7 +9,7 @@ import time
|
||||
import wave
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from typing import Any, Callable
|
||||
|
||||
from .models import AudioFrame, AudioSegment, ProviderError
|
||||
from .protocols import AudioTransport, RealtimeSttProvider
|
||||
@@ -59,24 +59,37 @@ class BargeInSpeakerGate:
|
||||
assistant_reference: AudioSegment | None = None,
|
||||
reference_elapsed_ms: int | None = None,
|
||||
) -> bool:
|
||||
accepted, _reason = self.evaluate_candidate(
|
||||
frame,
|
||||
assistant_profile,
|
||||
assistant_reference,
|
||||
reference_elapsed_ms=reference_elapsed_ms,
|
||||
)
|
||||
return accepted
|
||||
|
||||
def evaluate_candidate(
|
||||
self,
|
||||
frame: AudioFrame,
|
||||
assistant_profile: TimbreProfile,
|
||||
assistant_reference: AudioSegment | None = None,
|
||||
reference_elapsed_ms: int | None = None,
|
||||
) -> tuple[bool, str]:
|
||||
if not self.enabled:
|
||||
return True
|
||||
return True, "gate_disabled"
|
||||
candidate = self._profile_from_frame(frame)
|
||||
if self._user_profile.ready and self._matches(candidate, self._user_profile, self.user_similarity_threshold):
|
||||
return True, "user_profile_match"
|
||||
if assistant_reference is not None and _looks_like_render_echo(
|
||||
frame,
|
||||
assistant_reference,
|
||||
reference_elapsed_ms=reference_elapsed_ms,
|
||||
):
|
||||
return False
|
||||
candidate = self._profile_from_frame(frame)
|
||||
return False, "render_echo"
|
||||
if not candidate.ready:
|
||||
return True
|
||||
return True, "candidate_profile_unready"
|
||||
if self._matches(candidate, assistant_profile, self.assistant_reject_threshold):
|
||||
return False
|
||||
if self._user_profile.ready:
|
||||
if self._matches(candidate, self._user_profile, self.user_similarity_threshold):
|
||||
return True
|
||||
return True
|
||||
return True
|
||||
return False, "assistant_profile_match"
|
||||
return True, "non_assistant"
|
||||
|
||||
def _profile_from_segment(self, segment: AudioSegment) -> TimbreProfile:
|
||||
frame = AudioFrame(
|
||||
@@ -117,6 +130,7 @@ class AsyncBargeInMonitor:
|
||||
echo_guard_ms: int,
|
||||
min_speech_ms: int,
|
||||
listen_interval_ms: int,
|
||||
debug_callback: Callable[[str, dict[str, Any]], None] | None = None,
|
||||
) -> None:
|
||||
self.transport = transport
|
||||
self.vad_provider = vad_provider
|
||||
@@ -127,6 +141,7 @@ class AsyncBargeInMonitor:
|
||||
self.echo_guard_ms = max(0, echo_guard_ms)
|
||||
self.min_speech_ms = max(0, min_speech_ms)
|
||||
self.listen_interval_ms = max(1, listen_interval_ms)
|
||||
self.debug_callback = debug_callback
|
||||
self.stop_event = threading.Event()
|
||||
self._shutdown_event = threading.Event()
|
||||
self._thread: threading.Thread | None = None
|
||||
@@ -137,6 +152,14 @@ class AsyncBargeInMonitor:
|
||||
|
||||
def start(self) -> None:
|
||||
self.vad_provider.reset()
|
||||
self._debug(
|
||||
"started",
|
||||
{
|
||||
"echo_guard_ms": self.echo_guard_ms,
|
||||
"min_speech_ms": self.min_speech_ms,
|
||||
"listen_interval_ms": self.listen_interval_ms,
|
||||
},
|
||||
)
|
||||
self._thread = threading.Thread(target=self._run, name="owner-voice-barge-in", daemon=True)
|
||||
self._thread.start()
|
||||
|
||||
@@ -160,6 +183,8 @@ class AsyncBargeInMonitor:
|
||||
started_at = time.monotonic()
|
||||
speech_ms = 0
|
||||
candidate_frames: list[AudioFrame] = []
|
||||
saw_frames = False
|
||||
last_debug_at = 0.0
|
||||
try:
|
||||
while not self._shutdown_event.is_set() and not self.stop_event.is_set():
|
||||
frames = self.transport.read_frames(timeout_ms=self.listen_interval_ms)
|
||||
@@ -169,32 +194,53 @@ class AsyncBargeInMonitor:
|
||||
if not frames:
|
||||
time.sleep(self.listen_interval_ms / 1000)
|
||||
continue
|
||||
if not saw_frames:
|
||||
saw_frames = True
|
||||
self._debug("frames_seen", {"elapsed_ms": elapsed_ms, "frame_count": len(frames)})
|
||||
for frame in frames:
|
||||
result = self.vad_provider.analyze(frame)
|
||||
if not result.is_speech:
|
||||
speech_ms = 0
|
||||
candidate_frames = []
|
||||
continue
|
||||
if not self.speaker_gate.accepts_candidate(
|
||||
accepted, reason = self.speaker_gate.evaluate_candidate(
|
||||
frame,
|
||||
self.assistant_profile,
|
||||
self.assistant_reference,
|
||||
reference_elapsed_ms=elapsed_ms,
|
||||
):
|
||||
)
|
||||
if not accepted:
|
||||
now = time.monotonic()
|
||||
if now - last_debug_at >= 0.25:
|
||||
self._debug("gate_rejected", {"elapsed_ms": elapsed_ms, "reason": reason})
|
||||
last_debug_at = now
|
||||
speech_ms = 0
|
||||
candidate_frames = []
|
||||
continue
|
||||
frame_ms = int(frame.metadata.get("duration_ms", 20))
|
||||
speech_ms += frame_ms
|
||||
candidate_frames.append(frame)
|
||||
now = time.monotonic()
|
||||
if now - last_debug_at >= 0.25:
|
||||
self._debug(
|
||||
"speech_accumulating",
|
||||
{"elapsed_ms": elapsed_ms, "speech_ms": speech_ms, "reason": reason},
|
||||
)
|
||||
last_debug_at = now
|
||||
if speech_ms >= self.min_speech_ms:
|
||||
with self._lock:
|
||||
self._pending_frames = list(candidate_frames)
|
||||
self._interrupted = True
|
||||
self._debug("triggered", {"elapsed_ms": elapsed_ms, "speech_ms": speech_ms})
|
||||
self.stop_event.set()
|
||||
return
|
||||
except ProviderError as exc:
|
||||
self.error = exc
|
||||
self._debug("error", {"message": exc.message, "code": exc.code.value})
|
||||
|
||||
def _debug(self, reason: str, payload: dict[str, Any]) -> None:
|
||||
if self.debug_callback is not None:
|
||||
self.debug_callback(reason, payload)
|
||||
|
||||
|
||||
def _looks_like_render_echo(
|
||||
@@ -226,11 +272,9 @@ def _looks_like_render_echo(
|
||||
candidate_window = candidate[-width:]
|
||||
render_window = _reference_window(render, width, reference.sample_rate, reference_elapsed_ms)
|
||||
corr = _normalized_corr(candidate_window, render_window)
|
||||
if corr >= 0.92:
|
||||
if corr >= 0.97:
|
||||
return True
|
||||
render_peak = float(np.max(np.abs(render_window))) if render_window.size else 0.0
|
||||
candidate_peak = float(np.max(np.abs(candidate_window))) if candidate_window.size else 0.0
|
||||
return corr >= 0.82 and candidate_peak <= render_peak * 1.15
|
||||
return False
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
@@ -65,6 +65,7 @@ class AppConfig:
|
||||
barge_in_assistant_reject_threshold: float = 0.72
|
||||
barge_in_listen_interval_ms: int = 20
|
||||
barge_in_chunk_ms: int = 30
|
||||
barge_in_debug: bool = False
|
||||
end_chime_enabled: bool = True
|
||||
end_chime_file: Path = Path("assets/sounds/codex-notification.wav")
|
||||
end_chime_frequency_hz: int = 880
|
||||
@@ -185,6 +186,7 @@ class AppConfig:
|
||||
),
|
||||
barge_in_listen_interval_ms=int(get("BARGE_IN_LISTEN_INTERVAL_MS", "20") or "20"),
|
||||
barge_in_chunk_ms=int(get("BARGE_IN_CHUNK_MS", "30") or "30"),
|
||||
barge_in_debug=(get("BARGE_IN_DEBUG", "0") or "0").lower() not in {"0", "false", "no"},
|
||||
end_chime_enabled=(get("END_CHIME_ENABLED", "1") or "1").lower() not in {"0", "false", "no"},
|
||||
end_chime_file=Path(
|
||||
get("END_CHIME_FILE", "assets/sounds/codex-notification.wav")
|
||||
|
||||
@@ -133,6 +133,7 @@ def run_simulated_live(
|
||||
vad_end_silence_ms=350,
|
||||
vad_no_speech_timeout_ms=3000,
|
||||
vad_max_recording_ms=6000,
|
||||
barge_in_enabled=False,
|
||||
)
|
||||
pipeline = VoiceAssistantPipeline(
|
||||
config=config,
|
||||
|
||||
Reference in New Issue
Block a user