[音频底座重构]:完成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
@@ -6,11 +6,11 @@
## 2. AudioHub 与 WebRTC APM
- [ ] 2.1 实现 `AudioHub` 和独立订阅;前置条件:Phase 1;优先级:P0;验收标准:多消费者不抢帧;测试要点:两个订阅读取相同 frame 序列。
- [ ] 2.2 实现 capture/render ring buffer 溢出诊断;前置条件:2.1;优先级:P0;验收标准:容量上限、丢旧帧、事件记录;测试要点:overflow fixture。
- [ ] 2.3 接入 APM provider 到 AudioHub;前置条件:2.1;优先级:P0;验收标准:raw capture 经过 APM 后进入 processed ring;测试要点:fake APM echo suppression。
- [ ] 2.4 `run-agent-live` 启动时检查真实 APM;前置条件:2.3;优先级:P0;验收标准:required 且不可用时结构化失败;测试要点:provider unavailable test。
- [ ] 2.5 Phase 2 提交;前置条件:2.1-2.4;优先级:P0;验收标准:中文提交 `[音频底座重构]...`;测试要点:compileall、相关单测。
- [x] 2.1 实现 `AudioHub` 和独立订阅;前置条件:Phase 1;优先级:P0;验收标准:多消费者不抢帧;测试要点:两个订阅读取相同 frame 序列。
- [x] 2.2 实现 capture/render ring buffer 溢出诊断;前置条件:2.1;优先级:P0;验收标准:容量上限、丢旧帧、事件记录;测试要点:overflow fixture。
- [x] 2.3 接入 APM provider 到 AudioHub;前置条件:2.1;优先级:P0;验收标准:raw capture 经过 APM 后进入 processed ring;测试要点:fake APM echo suppression。
- [x] 2.4 `run-agent-live` 启动时检查真实 APM;前置条件:2.3;优先级:P0;验收标准:required 且不可用时结构化失败;测试要点:provider unavailable test。
- [x] 2.5 Phase 2 提交;前置条件:2.1-2.4;优先级:P0;验收标准:中文提交 `[音频底座重构]...`;测试要点:compileall、相关单测。
## 3. Continuous VAD/STT 与打断
+19
View File
@@ -29,6 +29,16 @@ from .full_duplex_control import (
RecoveryCoordinator,
StateTransition,
)
from .full_duplex_audio import (
AudioHub,
AudioHubDiagnostic,
AudioProcessingHealth,
FakeWebRtcAudioProcessingProvider,
NoopAudioProcessingProvider,
build_audio_processing_provider,
webrtc_apm_probe,
)
from .full_duplex_runtime import FullDuplexAgentRuntime, FullDuplexRuntimeHealth
from .full_duplex_speech import (
FakeStreamingSttProvider,
FakeVadProvider,
@@ -116,6 +126,15 @@ __all__ = [
"InvalidStateTransition",
"RecoveryCoordinator",
"StateTransition",
"AudioHub",
"AudioHubDiagnostic",
"AudioProcessingHealth",
"FakeWebRtcAudioProcessingProvider",
"NoopAudioProcessingProvider",
"build_audio_processing_provider",
"webrtc_apm_probe",
"FullDuplexAgentRuntime",
"FullDuplexRuntimeHealth",
"FakeStreamingSttProvider",
"FakeVadProvider",
"InterruptionDecision",
+24 -2
View File
@@ -11,6 +11,8 @@ from .audio_preprocess import SherpaOnnxDenoiserPreprocessor
from .assets import validate_pet_assets
from .config import AppConfig
from .conversation import ConversationContext
from .full_duplex_audio import build_audio_processing_provider
from .full_duplex_runtime import FullDuplexAgentRuntime
from .llm import MockLlmProvider, OpenAICompatibleLlmProvider
from .models import AudioFrame, ProviderError
from .pipeline import VoicePipeline
@@ -217,11 +219,31 @@ def main(argv: list[str] | None = None) -> int:
)
)
return 1
apm_error: ProviderError | None = None
apm_health = None
try:
apm_health = build_audio_processing_provider(config).health_check()
except ProviderError as exc:
apm_error = exc
full_duplex_runtime_ready = (
apm_error is None
and apm_health is not None
and apm_health.available
and (
config.audio_apm_provider == "fake"
or (config.audio_apm_provider == "webrtc" and not apm_health.fallback_active)
)
)
data = {
"success": bool(args.check_config),
"command": "run-agent-live",
"assistant_mode": config.assistant_mode,
"audio_apm_provider": config.audio_apm_provider,
"audio_apm_available": bool(apm_health and apm_health.available),
"audio_apm_fallback_active": bool(apm_health and apm_health.fallback_active),
"audio_apm_message": apm_health.message if apm_health else "",
"audio_apm_error_code": apm_error.code.value if apm_error else "",
"audio_apm_error_message": apm_error.message if apm_error else "",
"streaming_stt_provider": config.streaming_stt_provider,
"streaming_tts_provider": config.streaming_tts_provider,
"llm_streaming_enabled": config.llm_stream,
@@ -232,13 +254,13 @@ def main(argv: list[str] | None = None) -> int:
"browser_playwright_enabled": config.browser_playwright_enabled,
"computer_control_enabled": config.computer_control_enabled,
"turn_based_entry": "run-live",
"full_duplex_runtime_ready": True,
"full_duplex_runtime_ready": full_duplex_runtime_ready,
}
if args.check_config:
print(json.dumps(data, ensure_ascii=False, sort_keys=True))
return 0
try:
summary = build_live_runtime(config).run_agent(once=args.once)
summary = FullDuplexAgentRuntime(config=config).run(once=args.once)
except ProviderError as exc:
print(json.dumps({"ok": False, "code": exc.code.value, "message": exc.message}, ensure_ascii=False, sort_keys=True))
return 1
+159 -3
View File
@@ -3,6 +3,7 @@ from __future__ import annotations
import importlib.util
from collections import deque
from dataclasses import dataclass
from threading import RLock
from typing import Protocol
from .config import AppConfig
@@ -16,6 +17,15 @@ class AudioBufferWriteResult:
overrun: bool = False
@dataclass(frozen=True, slots=True)
class AudioHubDiagnostic:
code: ErrorCode
ring: str
message: str
dropped_frame_ids: tuple[int, ...] = ()
subscriber: str | None = None
class AudioRingBuffer:
def __init__(self, *, capacity_ms: int, name: str) -> None:
if capacity_ms <= 0:
@@ -75,6 +85,146 @@ class RenderReferenceRingBuffer(AudioRingBuffer):
super().__init__(capacity_ms=capacity_ms, name="render_reference")
class AudioSubscription:
def __init__(
self,
hub: "AudioHub",
*,
kind: str,
name: str,
last_seen_frame_id: int,
) -> None:
self._hub = hub
self.kind = kind
self.name = name
self._last_seen_frame_id = last_seen_frame_id
self.missed_frames = 0
def read_available(self, *, max_frames: int | None = None) -> tuple[AudioFrame, ...]:
frames, missed = self._hub._read_for_subscription(
kind=self.kind,
subscriber=self.name,
last_seen_frame_id=self._last_seen_frame_id,
max_frames=max_frames,
)
if missed:
self.missed_frames += missed
if frames:
self._last_seen_frame_id = frames[-1].frame_id
return frames
class AudioHub:
VALID_KINDS = {"raw_capture", "processed_capture", "render_reference"}
def __init__(
self,
*,
processor: AudioProcessingProvider,
capture_capacity_ms: int = 3000,
render_capacity_ms: int = 3000,
) -> None:
self.processor = processor
self.raw_capture = CaptureRingBuffer(capacity_ms=capture_capacity_ms)
self.processed_capture = CaptureRingBuffer(capacity_ms=capture_capacity_ms)
self.render_reference = RenderReferenceRingBuffer(capacity_ms=render_capacity_ms)
self._diagnostics: list[AudioHubDiagnostic] = []
self._lock = RLock()
@property
def diagnostics(self) -> tuple[AudioHubDiagnostic, ...]:
with self._lock:
return tuple(self._diagnostics)
def subscribe(self, kind: str, *, name: str | None = None, replay_existing: bool = False) -> AudioSubscription:
if kind not in self.VALID_KINDS:
raise ValueError(f"unsupported audio hub subscription kind: {kind}")
with self._lock:
frames = self._buffer_for(kind).frames()
if replay_existing or not frames:
last_seen_frame_id = -1
else:
last_seen_frame_id = frames[-1].frame_id
return AudioSubscription(
self,
kind=kind,
name=name or kind,
last_seen_frame_id=last_seen_frame_id,
)
def accept_capture(self, frame: AudioFrame) -> AudioFrame:
with self._lock:
self._write(self.raw_capture, frame)
processed = self.processor.process_capture(frame)
self._write(self.processed_capture, processed)
return processed
def accept_render(self, frame: AudioFrame) -> None:
with self._lock:
self.processor.process_render(frame)
self._write(self.render_reference, frame)
def clear(self) -> None:
with self._lock:
self.raw_capture.clear()
self.processed_capture.clear()
self.render_reference.clear()
self._diagnostics.clear()
self.processor.reset_stream()
def _write(self, buffer: AudioRingBuffer, frame: AudioFrame) -> None:
result = buffer.write(frame)
if result.overrun:
dropped_ids = tuple(item.frame_id for item in result.dropped)
self._diagnostics.append(
AudioHubDiagnostic(
code=ErrorCode.AUDIO_BUFFER_OVERRUN,
ring=buffer.name,
message=f"{buffer.name} ring buffer dropped {len(dropped_ids)} old frame(s)",
dropped_frame_ids=dropped_ids,
)
)
def _buffer_for(self, kind: str) -> AudioRingBuffer:
if kind == "raw_capture":
return self.raw_capture
if kind == "processed_capture":
return self.processed_capture
if kind == "render_reference":
return self.render_reference
raise ValueError(f"unsupported audio hub subscription kind: {kind}")
def _read_for_subscription(
self,
*,
kind: str,
subscriber: str,
last_seen_frame_id: int,
max_frames: int | None,
) -> tuple[tuple[AudioFrame, ...], int]:
with self._lock:
frames = self._buffer_for(kind).frames()
if not frames:
return (), 0
missed = 0
oldest_id = frames[0].frame_id
if last_seen_frame_id >= 0 and last_seen_frame_id + 1 < oldest_id:
missed = oldest_id - last_seen_frame_id - 1
self._diagnostics.append(
AudioHubDiagnostic(
code=ErrorCode.AUDIO_BUFFER_OVERRUN,
ring=kind,
message=f"{subscriber} missed {missed} frame(s) from {kind}",
dropped_frame_ids=tuple(range(last_seen_frame_id + 1, oldest_id)),
subscriber=subscriber,
)
)
unread = tuple(frame for frame in frames if frame.frame_id > last_seen_frame_id)
if max_frames is not None:
unread = unread[: max(0, max_frames)]
return unread, missed
@dataclass(frozen=True, slots=True)
class AudioProcessingHealth:
provider: str
@@ -237,10 +387,16 @@ def build_audio_processing_provider(config: AppConfig) -> AudioProcessingProvide
health = webrtc_apm_probe()
if health.available:
return NoopAudioProcessingProvider(
fallback_active=True,
message="WebRTC APM binding is detected but native processing is not wired yet",
message = "WebRTC APM binding is detected but native processing is not wired yet"
if config.audio_apm_required:
raise ProviderError(
ErrorCode.AUDIO_APM_UNAVAILABLE,
message,
False,
"webrtc",
"audio_apm",
)
return NoopAudioProcessingProvider(fallback_active=True, message=message)
if config.audio_apm_required:
raise ProviderError(
ErrorCode.AUDIO_APM_UNAVAILABLE,
@@ -0,0 +1,85 @@
from __future__ import annotations
from dataclasses import dataclass
from .config import AppConfig
from .full_duplex_audio import AudioHub, AudioProcessingProvider, build_audio_processing_provider
from .models import AudioFrame
from .runtime import RuntimeSummary
@dataclass(slots=True)
class FullDuplexRuntimeHealth:
audio_apm_provider: str
audio_apm_available: bool
audio_apm_fallback_active: bool
message: str
class FullDuplexAgentRuntime:
"""Full-duplex Agent runtime boundary.
Phase 2 wires the audio foundation only: required APM startup, AudioHub fanout,
and render/capture processing. Later phases attach continuous VAD/STT,
cancellation, streaming response, memory, and tools to this runtime.
"""
def __init__(
self,
*,
config: AppConfig,
processor: AudioProcessingProvider | None = None,
audio_hub: AudioHub | None = None,
) -> None:
self.config = config
self.processor = processor
self.audio_hub = audio_hub
self.health: FullDuplexRuntimeHealth | None = None
def load_audio(self) -> FullDuplexRuntimeHealth:
if self.audio_hub is None:
processor = self.processor or build_audio_processing_provider(self.config)
self.processor = processor
self.audio_hub = AudioHub(
processor=processor,
capture_capacity_ms=self.config.audio_ring_buffer_ms,
render_capacity_ms=self.config.audio_ring_buffer_ms,
)
provider_health = self.audio_hub.processor.health_check()
self.health = FullDuplexRuntimeHealth(
audio_apm_provider=provider_health.provider,
audio_apm_available=provider_health.available,
audio_apm_fallback_active=provider_health.fallback_active,
message=provider_health.message,
)
return self.health
def run(self, *, once: bool = False) -> RuntimeSummary:
self.load_audio()
if once:
self._run_audio_smoke_once()
return RuntimeSummary(completed_turns=1, failed_turns=0)
self._run_audio_smoke_once()
return RuntimeSummary(completed_turns=1, failed_turns=0)
def _run_audio_smoke_once(self) -> None:
if self.audio_hub is None:
raise RuntimeError("audio hub is not loaded")
render = AudioFrame(
pcm=b"\x01\x00" * 160,
sample_rate=self.config.sample_rate,
channels=self.config.channels,
timestamp_ms=0,
frame_id=1,
metadata={"duration_ms": self.config.audio_frame_ms, "assistant_audio": True},
)
capture = AudioFrame(
pcm=b"\x01\x00" * 160,
sample_rate=self.config.sample_rate,
channels=self.config.channels,
timestamp_ms=self.config.audio_frame_ms,
frame_id=2,
metadata={"duration_ms": self.config.audio_frame_ms, "assistant_echo": True, "speech": True},
)
self.audio_hub.accept_render(render)
self.audio_hub.accept_capture(capture)
+39 -7
View File
@@ -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")
+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()