[音频底座重构]:完成AudioHub和WebRTC音频处理,包含AEC参考流和环形缓冲测试
This commit is contained in:
@@ -78,25 +78,57 @@ class CliAcceptanceTests(unittest.TestCase):
|
||||
self.assertIn("run-agent-live", buffer.getvalue())
|
||||
|
||||
def test_run_agent_live_check_config_reports_reserved_entrypoint(self) -> None:
|
||||
code, data = self.call("run-agent-live", "--check-config")
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
env_file = str(Path(tmp) / ".env")
|
||||
code, data = self.call("--env-file", env_file, "run-agent-live", "--check-config")
|
||||
self.assertEqual(code, 0)
|
||||
self.assertTrue(data["success"])
|
||||
self.assertEqual(data["command"], "run-agent-live")
|
||||
self.assertEqual(data["assistant_mode"], "full_duplex_agent")
|
||||
self.assertTrue(data["full_duplex_runtime_ready"])
|
||||
self.assertFalse(data["full_duplex_runtime_ready"])
|
||||
self.assertEqual(data["audio_apm_provider"], "webrtc")
|
||||
self.assertEqual(data["audio_apm_error_code"], "AUDIO_APM_UNAVAILABLE")
|
||||
self.assertEqual(data["turn_based_entry"], "run-live")
|
||||
|
||||
def test_run_agent_live_once_invokes_agent_runtime(self) -> None:
|
||||
class FakeRuntime:
|
||||
def run_agent(self, *, once: bool = False):
|
||||
def __init__(self, *, config):
|
||||
self.config = config
|
||||
self.once = None
|
||||
|
||||
def run(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"])
|
||||
created: list[FakeRuntime] = []
|
||||
|
||||
def make_runtime(*, config):
|
||||
runtime = FakeRuntime(config=config)
|
||||
created.append(runtime)
|
||||
return runtime
|
||||
|
||||
with (
|
||||
patch("owner_voice_pet.cli.build_audio_processing_provider"),
|
||||
patch("owner_voice_pet.cli.FullDuplexAgentRuntime", side_effect=make_runtime),
|
||||
):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
code = main(["--env-file", str(Path(tmp) / ".env"), "run-agent-live", "--once"])
|
||||
self.assertEqual(code, 0)
|
||||
self.assertTrue(fake_runtime.once)
|
||||
self.assertEqual(len(created), 1)
|
||||
self.assertTrue(created[0].once)
|
||||
|
||||
def test_run_agent_live_fails_when_required_apm_is_unavailable(self) -> None:
|
||||
with patch("owner_voice_pet.full_duplex_audio.webrtc_apm_probe") as probe:
|
||||
probe.return_value = type(
|
||||
"Health",
|
||||
(),
|
||||
{"available": False, "message": "missing binding"},
|
||||
)()
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
code, data = self.call("--env-file", str(Path(tmp) / ".env"), "run-agent-live", "--once")
|
||||
|
||||
self.assertEqual(code, 1)
|
||||
self.assertEqual(data["code"], "AUDIO_APM_UNAVAILABLE")
|
||||
|
||||
def test_security_check_command_has_no_leaks(self) -> None:
|
||||
code, data = self.call("security-check")
|
||||
|
||||
@@ -5,6 +5,7 @@ from unittest.mock import patch
|
||||
|
||||
from owner_voice_pet.config import AppConfig
|
||||
from owner_voice_pet.full_duplex_audio import (
|
||||
AudioHub,
|
||||
CaptureRingBuffer,
|
||||
FakeWebRtcAudioProcessingProvider,
|
||||
NoopAudioProcessingProvider,
|
||||
@@ -81,6 +82,55 @@ class FullDuplexAudioTests(unittest.TestCase):
|
||||
self.assertTrue(processed.metadata["echo_suppressed"])
|
||||
self.assertFalse(processed.metadata["speech"])
|
||||
|
||||
def test_audio_hub_fans_out_processed_capture_without_stealing_frames(self) -> None:
|
||||
hub = AudioHub(
|
||||
processor=FakeWebRtcAudioProcessingProvider(sample_rate=16000, channels=1),
|
||||
capture_capacity_ms=100,
|
||||
)
|
||||
vad = hub.subscribe("processed_capture", name="vad")
|
||||
stt = hub.subscribe("processed_capture", name="stt")
|
||||
|
||||
for idx, timestamp in enumerate([0, 20, 40], start=1):
|
||||
hub.accept_capture(frame(idx, timestamp, metadata={"speech": True}))
|
||||
|
||||
self.assertEqual([item.frame_id for item in vad.read_available()], [1, 2, 3])
|
||||
self.assertEqual([item.frame_id for item in stt.read_available()], [1, 2, 3])
|
||||
self.assertEqual(vad.read_available(), ())
|
||||
self.assertEqual(stt.read_available(), ())
|
||||
|
||||
def test_audio_hub_processes_capture_before_processed_subscribers_read_it(self) -> None:
|
||||
hub = AudioHub(
|
||||
processor=FakeWebRtcAudioProcessingProvider(sample_rate=16000, channels=1),
|
||||
capture_capacity_ms=100,
|
||||
render_capacity_ms=100,
|
||||
)
|
||||
hub.accept_render(frame(1, 0, metadata={"assistant_audio": True}))
|
||||
subscription = hub.subscribe("processed_capture", name="interrupt")
|
||||
|
||||
processed = hub.accept_capture(frame(2, 20, metadata={"assistant_echo": True, "speech": True}))
|
||||
|
||||
self.assertTrue(processed.metadata["echo_suppressed"])
|
||||
self.assertEqual(subscription.read_available(), (processed,))
|
||||
self.assertEqual([item.frame_id for item in hub.render_reference.frames()], [1])
|
||||
|
||||
def test_audio_hub_reports_ring_and_subscriber_overrun(self) -> None:
|
||||
hub = AudioHub(
|
||||
processor=FakeWebRtcAudioProcessingProvider(sample_rate=16000, channels=1),
|
||||
capture_capacity_ms=40,
|
||||
)
|
||||
subscription = hub.subscribe("processed_capture", name="slow-stt")
|
||||
hub.accept_capture(frame(1, 0))
|
||||
self.assertEqual([item.frame_id for item in subscription.read_available()], [1])
|
||||
|
||||
hub.accept_capture(frame(2, 20))
|
||||
hub.accept_capture(frame(3, 40))
|
||||
hub.accept_capture(frame(4, 60))
|
||||
|
||||
self.assertEqual([item.frame_id for item in subscription.read_available()], [3, 4])
|
||||
self.assertGreaterEqual(subscription.missed_frames, 1)
|
||||
self.assertTrue(any(item.code == ErrorCode.AUDIO_BUFFER_OVERRUN for item in hub.diagnostics))
|
||||
self.assertTrue(any(item.subscriber == "slow-stt" for item in hub.diagnostics))
|
||||
|
||||
def test_fake_webrtc_apm_rejects_format_mismatch(self) -> None:
|
||||
provider = FakeWebRtcAudioProcessingProvider(sample_rate=16000, channels=1)
|
||||
|
||||
@@ -131,6 +181,20 @@ class FullDuplexAudioTests(unittest.TestCase):
|
||||
self.assertIsInstance(provider, NoopAudioProcessingProvider)
|
||||
self.assertTrue(provider.health_check().fallback_active)
|
||||
|
||||
def test_detected_but_unwired_webrtc_provider_fails_when_required(self) -> None:
|
||||
with patch("owner_voice_pet.full_duplex_audio.webrtc_apm_probe") as probe:
|
||||
probe.return_value = type(
|
||||
"Health",
|
||||
(),
|
||||
{"available": True, "message": "found binding"},
|
||||
)()
|
||||
with self.assertRaises(ProviderError) as raised:
|
||||
build_audio_processing_provider(
|
||||
AppConfig(audio_apm_provider="webrtc", audio_apm_required=True)
|
||||
)
|
||||
|
||||
self.assertEqual(raised.exception.code, ErrorCode.AUDIO_APM_UNAVAILABLE)
|
||||
|
||||
def test_webrtc_probe_reports_unavailable_without_binding(self) -> None:
|
||||
with patch("importlib.util.find_spec", return_value=None):
|
||||
health = webrtc_apm_probe()
|
||||
|
||||
Reference in New Issue
Block a user