[真实全双工运行时]:完成run-agent-live可打断语音闭环,包含持续监听、软件回声抑制和播放取消验证
This commit is contained in:
+77
-7
@@ -10,19 +10,21 @@ from owner_voice_pet.transport import MemoryAudioTransport
|
||||
from owner_voice_pet.vad import EnergyVadProvider
|
||||
|
||||
|
||||
def speech_frame(idx: int, speaker_id: str, partial: str = "等一下") -> AudioFrame:
|
||||
def speech_frame(idx: int, speaker_id: str, partial: str | None = "等一下") -> AudioFrame:
|
||||
metadata = {
|
||||
"duration_ms": 20,
|
||||
"speech": True,
|
||||
"speaker_id": speaker_id,
|
||||
}
|
||||
if partial is not None:
|
||||
metadata["partial_transcript"] = partial
|
||||
return AudioFrame(
|
||||
b"\xff\x7f" * 320,
|
||||
16000,
|
||||
1,
|
||||
idx * 20,
|
||||
idx,
|
||||
{
|
||||
"duration_ms": 20,
|
||||
"speech": True,
|
||||
"speaker_id": speaker_id,
|
||||
"partial_transcript": partial,
|
||||
},
|
||||
metadata,
|
||||
)
|
||||
|
||||
|
||||
@@ -127,6 +129,74 @@ class BargeInTests(unittest.TestCase):
|
||||
|
||||
self.assertTrue(monitor.interrupted)
|
||||
|
||||
def test_async_monitor_interrupts_without_realtime_partial(self) -> None:
|
||||
gate = BargeInSpeakerGate(
|
||||
enabled=True,
|
||||
user_similarity_threshold=0.62,
|
||||
assistant_reject_threshold=0.72,
|
||||
min_rms=0.001,
|
||||
)
|
||||
gate.remember_user_segment(segment_for_speaker("owner"))
|
||||
transport = MemoryAudioTransport([speech_frame(1, "owner", None), speech_frame(2, "owner", None)])
|
||||
transport.start_input()
|
||||
monitor = AsyncBargeInMonitor(
|
||||
transport=transport,
|
||||
vad_provider=EnergyVadProvider(threshold=1),
|
||||
realtime_stt=None,
|
||||
speaker_gate=gate,
|
||||
assistant_profile=gate.assistant_profile(segment_for_speaker("assistant")),
|
||||
echo_guard_ms=0,
|
||||
min_speech_ms=40,
|
||||
listen_interval_ms=1,
|
||||
)
|
||||
monitor.vad_provider.load()
|
||||
|
||||
monitor.start()
|
||||
deadline = time.monotonic() + 1
|
||||
while not monitor.interrupted and time.monotonic() < deadline:
|
||||
time.sleep(0.005)
|
||||
monitor.stop()
|
||||
|
||||
self.assertTrue(monitor.interrupted)
|
||||
self.assertEqual(len(monitor.pending_frames()), 2)
|
||||
|
||||
def test_render_reference_echo_does_not_interrupt(self) -> None:
|
||||
gate = BargeInSpeakerGate(
|
||||
enabled=True,
|
||||
user_similarity_threshold=0.62,
|
||||
assistant_reject_threshold=0.72,
|
||||
min_rms=0.001,
|
||||
)
|
||||
assistant = AudioSegment(b"\x01\x20\x02\x20" * 320, 16000, 1, 0, 40)
|
||||
echo_frame = AudioFrame(
|
||||
assistant.pcm[: 640 * 2],
|
||||
16000,
|
||||
1,
|
||||
20,
|
||||
1,
|
||||
{"duration_ms": 40, "speech": True},
|
||||
)
|
||||
transport = MemoryAudioTransport([echo_frame])
|
||||
transport.start_input()
|
||||
monitor = AsyncBargeInMonitor(
|
||||
transport=transport,
|
||||
vad_provider=EnergyVadProvider(threshold=1),
|
||||
realtime_stt=None,
|
||||
speaker_gate=gate,
|
||||
assistant_profile=gate.assistant_profile(assistant),
|
||||
assistant_reference=assistant,
|
||||
echo_guard_ms=0,
|
||||
min_speech_ms=40,
|
||||
listen_interval_ms=1,
|
||||
)
|
||||
monitor.vad_provider.load()
|
||||
|
||||
monitor.start()
|
||||
time.sleep(0.05)
|
||||
monitor.stop()
|
||||
|
||||
self.assertFalse(monitor.interrupted)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
@@ -83,14 +83,20 @@ class CliAcceptanceTests(unittest.TestCase):
|
||||
self.assertTrue(data["success"])
|
||||
self.assertEqual(data["command"], "run-agent-live")
|
||||
self.assertEqual(data["assistant_mode"], "full_duplex_agent")
|
||||
self.assertFalse(data["full_duplex_runtime_ready"])
|
||||
self.assertTrue(data["full_duplex_runtime_ready"])
|
||||
self.assertEqual(data["turn_based_entry"], "run-live")
|
||||
|
||||
def test_run_agent_live_without_runtime_returns_explicit_not_implemented(self) -> None:
|
||||
code, data = self.call("run-agent-live")
|
||||
self.assertEqual(code, 1)
|
||||
self.assertFalse(data["success"])
|
||||
self.assertEqual(data["code"], "FULL_DUPLEX_RUNTIME_NOT_IMPLEMENTED")
|
||||
def test_run_agent_live_once_invokes_agent_runtime(self) -> None:
|
||||
class FakeRuntime:
|
||||
def run_agent(self, *, once: bool = False):
|
||||
self.once = once
|
||||
return type("Summary", (), {"completed_turns": 1, "interrupted": False})()
|
||||
|
||||
fake_runtime = FakeRuntime()
|
||||
with patch("owner_voice_pet.cli.build_live_runtime", return_value=fake_runtime):
|
||||
code = main(["run-agent-live", "--once"])
|
||||
self.assertEqual(code, 0)
|
||||
self.assertTrue(fake_runtime.once)
|
||||
|
||||
def test_security_check_command_has_no_leaks(self) -> None:
|
||||
code, data = self.call("security-check")
|
||||
|
||||
@@ -261,6 +261,40 @@ def silence_frames(start_id: int, start_ms: int, count: int) -> list[AudioFrame]
|
||||
|
||||
|
||||
class LiveRuntimeTests(unittest.TestCase):
|
||||
def test_agent_runtime_listens_without_wake_word(self) -> None:
|
||||
frames = segment_frames(1, 20, partials=["直接提问", "直接提问"])
|
||||
transport = MemoryAudioTransport(frames, flush_clears_input=False)
|
||||
stt = QueueSttProvider(["直接提问"])
|
||||
llm = QueueLlmProvider([["这是全双工回答。"]])
|
||||
reporter = RecordingReporter()
|
||||
runtime = VoiceAssistantPipeline(
|
||||
config=AppConfig(
|
||||
assistant_mode="full_duplex_agent",
|
||||
llm_api_key="secret",
|
||||
speech_provider="cloud",
|
||||
wake_ack_text="",
|
||||
),
|
||||
transport=transport,
|
||||
wakeword=KeywordWakeWordProvider(),
|
||||
vad_recorder=VadRecorder(EnergyVadProvider(), min_duration_ms=40, end_silence_ms=40),
|
||||
stt=stt,
|
||||
realtime_stt=MetadataSttProvider(),
|
||||
llm=llm,
|
||||
tts=SineTtsProvider(),
|
||||
context=ConversationContext(),
|
||||
reporter=reporter,
|
||||
event_bus=PipelineEventBus(),
|
||||
)
|
||||
|
||||
summary = runtime.run_agent(once=True)
|
||||
|
||||
self.assertEqual(summary.completed_turns, 1)
|
||||
self.assertEqual(stt.calls[0].metadata["end_reason"], "silence")
|
||||
self.assertEqual(reporter.transcripts, ["直接提问"])
|
||||
self.assertIn("监听中:请直接说话", reporter.statuses)
|
||||
self.assertNotIn("唤醒命中", reporter.statuses)
|
||||
self.assertIn("恢复监听:可直接说话", reporter.statuses)
|
||||
|
||||
def test_repeated_runtime_runs_two_turns_and_returns_to_standby(self) -> None:
|
||||
runtime, stt, llm, transport, reporter = make_runtime(["第一问", "第二问"])
|
||||
self.assertIsInstance(runtime, VoiceAssistantPipeline)
|
||||
|
||||
Reference in New Issue
Block a user