[音频底座重构]:完成AudioHub和WebRTC音频处理,包含AEC参考流和环形缓冲测试

This commit is contained in:
mkbk
2026-06-19 12:24:29 +08:00
parent 504dd2cbf2
commit af2de5ec66
7 changed files with 396 additions and 18 deletions
+64
View File
@@ -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()