[音频底座重构]:完成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. AudioHub 与 WebRTC APM
- [ ] 2.1 实现 `AudioHub` 和独立订阅;前置条件:Phase 1;优先级:P0;验收标准:多消费者不抢帧;测试要点:两个订阅读取相同 frame 序列。 - [x] 2.1 实现 `AudioHub` 和独立订阅;前置条件:Phase 1;优先级:P0;验收标准:多消费者不抢帧;测试要点:两个订阅读取相同 frame 序列。
- [ ] 2.2 实现 capture/render ring buffer 溢出诊断;前置条件:2.1;优先级:P0;验收标准:容量上限、丢旧帧、事件记录;测试要点:overflow fixture。 - [x] 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。 - [x] 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。 - [x] 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.5 Phase 2 提交;前置条件:2.1-2.4;优先级:P0;验收标准:中文提交 `[音频底座重构]...`;测试要点:compileall、相关单测。
## 3. Continuous VAD/STT 与打断 ## 3. Continuous VAD/STT 与打断
+19
View File
@@ -29,6 +29,16 @@ from .full_duplex_control import (
RecoveryCoordinator, RecoveryCoordinator,
StateTransition, 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 ( from .full_duplex_speech import (
FakeStreamingSttProvider, FakeStreamingSttProvider,
FakeVadProvider, FakeVadProvider,
@@ -116,6 +126,15 @@ __all__ = [
"InvalidStateTransition", "InvalidStateTransition",
"RecoveryCoordinator", "RecoveryCoordinator",
"StateTransition", "StateTransition",
"AudioHub",
"AudioHubDiagnostic",
"AudioProcessingHealth",
"FakeWebRtcAudioProcessingProvider",
"NoopAudioProcessingProvider",
"build_audio_processing_provider",
"webrtc_apm_probe",
"FullDuplexAgentRuntime",
"FullDuplexRuntimeHealth",
"FakeStreamingSttProvider", "FakeStreamingSttProvider",
"FakeVadProvider", "FakeVadProvider",
"InterruptionDecision", "InterruptionDecision",
+24 -2
View File
@@ -11,6 +11,8 @@ from .audio_preprocess import SherpaOnnxDenoiserPreprocessor
from .assets import validate_pet_assets from .assets import validate_pet_assets
from .config import AppConfig from .config import AppConfig
from .conversation import ConversationContext 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 .llm import MockLlmProvider, OpenAICompatibleLlmProvider
from .models import AudioFrame, ProviderError from .models import AudioFrame, ProviderError
from .pipeline import VoicePipeline from .pipeline import VoicePipeline
@@ -217,11 +219,31 @@ def main(argv: list[str] | None = None) -> int:
) )
) )
return 1 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 = { data = {
"success": bool(args.check_config), "success": bool(args.check_config),
"command": "run-agent-live", "command": "run-agent-live",
"assistant_mode": config.assistant_mode, "assistant_mode": config.assistant_mode,
"audio_apm_provider": config.audio_apm_provider, "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_stt_provider": config.streaming_stt_provider,
"streaming_tts_provider": config.streaming_tts_provider, "streaming_tts_provider": config.streaming_tts_provider,
"llm_streaming_enabled": config.llm_stream, "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, "browser_playwright_enabled": config.browser_playwright_enabled,
"computer_control_enabled": config.computer_control_enabled, "computer_control_enabled": config.computer_control_enabled,
"turn_based_entry": "run-live", "turn_based_entry": "run-live",
"full_duplex_runtime_ready": True, "full_duplex_runtime_ready": full_duplex_runtime_ready,
} }
if args.check_config: if args.check_config:
print(json.dumps(data, ensure_ascii=False, sort_keys=True)) print(json.dumps(data, ensure_ascii=False, sort_keys=True))
return 0 return 0
try: try:
summary = build_live_runtime(config).run_agent(once=args.once) summary = FullDuplexAgentRuntime(config=config).run(once=args.once)
except ProviderError as exc: except ProviderError as exc:
print(json.dumps({"ok": False, "code": exc.code.value, "message": exc.message}, ensure_ascii=False, sort_keys=True)) print(json.dumps({"ok": False, "code": exc.code.value, "message": exc.message}, ensure_ascii=False, sort_keys=True))
return 1 return 1
+160 -4
View File
@@ -3,6 +3,7 @@ from __future__ import annotations
import importlib.util import importlib.util
from collections import deque from collections import deque
from dataclasses import dataclass from dataclasses import dataclass
from threading import RLock
from typing import Protocol from typing import Protocol
from .config import AppConfig from .config import AppConfig
@@ -16,6 +17,15 @@ class AudioBufferWriteResult:
overrun: bool = False 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: class AudioRingBuffer:
def __init__(self, *, capacity_ms: int, name: str) -> None: def __init__(self, *, capacity_ms: int, name: str) -> None:
if capacity_ms <= 0: if capacity_ms <= 0:
@@ -75,6 +85,146 @@ class RenderReferenceRingBuffer(AudioRingBuffer):
super().__init__(capacity_ms=capacity_ms, name="render_reference") 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) @dataclass(frozen=True, slots=True)
class AudioProcessingHealth: class AudioProcessingHealth:
provider: str provider: str
@@ -237,10 +387,16 @@ def build_audio_processing_provider(config: AppConfig) -> AudioProcessingProvide
health = webrtc_apm_probe() health = webrtc_apm_probe()
if health.available: if health.available:
return NoopAudioProcessingProvider( message = "WebRTC APM binding is detected but native processing is not wired yet"
fallback_active=True, if config.audio_apm_required:
message="WebRTC APM binding is detected but native processing is not wired yet", raise ProviderError(
) ErrorCode.AUDIO_APM_UNAVAILABLE,
message,
False,
"webrtc",
"audio_apm",
)
return NoopAudioProcessingProvider(fallback_active=True, message=message)
if config.audio_apm_required: if config.audio_apm_required:
raise ProviderError( raise ProviderError(
ErrorCode.AUDIO_APM_UNAVAILABLE, 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()) self.assertIn("run-agent-live", buffer.getvalue())
def test_run_agent_live_check_config_reports_reserved_entrypoint(self) -> None: 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.assertEqual(code, 0)
self.assertTrue(data["success"]) self.assertTrue(data["success"])
self.assertEqual(data["command"], "run-agent-live") self.assertEqual(data["command"], "run-agent-live")
self.assertEqual(data["assistant_mode"], "full_duplex_agent") 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") self.assertEqual(data["turn_based_entry"], "run-live")
def test_run_agent_live_once_invokes_agent_runtime(self) -> None: def test_run_agent_live_once_invokes_agent_runtime(self) -> None:
class FakeRuntime: 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 self.once = once
return type("Summary", (), {"completed_turns": 1, "interrupted": False})() return type("Summary", (), {"completed_turns": 1, "interrupted": False})()
fake_runtime = FakeRuntime() created: list[FakeRuntime] = []
with patch("owner_voice_pet.cli.build_live_runtime", return_value=fake_runtime):
code = main(["run-agent-live", "--once"]) 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.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: def test_security_check_command_has_no_leaks(self) -> None:
code, data = self.call("security-check") 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.config import AppConfig
from owner_voice_pet.full_duplex_audio import ( from owner_voice_pet.full_duplex_audio import (
AudioHub,
CaptureRingBuffer, CaptureRingBuffer,
FakeWebRtcAudioProcessingProvider, FakeWebRtcAudioProcessingProvider,
NoopAudioProcessingProvider, NoopAudioProcessingProvider,
@@ -81,6 +82,55 @@ class FullDuplexAudioTests(unittest.TestCase):
self.assertTrue(processed.metadata["echo_suppressed"]) self.assertTrue(processed.metadata["echo_suppressed"])
self.assertFalse(processed.metadata["speech"]) 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: def test_fake_webrtc_apm_rejects_format_mismatch(self) -> None:
provider = FakeWebRtcAudioProcessingProvider(sample_rate=16000, channels=1) provider = FakeWebRtcAudioProcessingProvider(sample_rate=16000, channels=1)
@@ -131,6 +181,20 @@ class FullDuplexAudioTests(unittest.TestCase):
self.assertIsInstance(provider, NoopAudioProcessingProvider) self.assertIsInstance(provider, NoopAudioProcessingProvider)
self.assertTrue(provider.health_check().fallback_active) 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: def test_webrtc_probe_reports_unavailable_without_binding(self) -> None:
with patch("importlib.util.find_spec", return_value=None): with patch("importlib.util.find_spec", return_value=None):
health = webrtc_apm_probe() health = webrtc_apm_probe()