[WebRTC音频底座]:完成全双工音频基础模块,包含环形缓冲、APM接口和fake回声抑制测试
This commit is contained in:
@@ -0,0 +1,255 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib.util
|
||||
from collections import deque
|
||||
from dataclasses import dataclass
|
||||
from typing import Protocol
|
||||
|
||||
from .config import AppConfig
|
||||
from .models import AudioFrame, ErrorCode, ProviderError
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class AudioBufferWriteResult:
|
||||
accepted: AudioFrame
|
||||
dropped: tuple[AudioFrame, ...] = ()
|
||||
overrun: bool = False
|
||||
|
||||
|
||||
class AudioRingBuffer:
|
||||
def __init__(self, *, capacity_ms: int, name: str) -> None:
|
||||
if capacity_ms <= 0:
|
||||
raise ValueError("capacity_ms must be positive")
|
||||
self.capacity_ms = capacity_ms
|
||||
self.name = name
|
||||
self._frames: deque[AudioFrame] = deque()
|
||||
self._duration_ms = 0
|
||||
|
||||
@property
|
||||
def duration_ms(self) -> int:
|
||||
return self._duration_ms
|
||||
|
||||
@property
|
||||
def frame_count(self) -> int:
|
||||
return len(self._frames)
|
||||
|
||||
def clear(self) -> None:
|
||||
self._frames.clear()
|
||||
self._duration_ms = 0
|
||||
|
||||
def write(self, frame: AudioFrame) -> AudioBufferWriteResult:
|
||||
self._frames.append(frame)
|
||||
self._duration_ms += frame.duration_ms
|
||||
dropped: list[AudioFrame] = []
|
||||
while self._duration_ms > self.capacity_ms and self._frames:
|
||||
oldest = self._frames.popleft()
|
||||
dropped.append(oldest)
|
||||
self._duration_ms -= oldest.duration_ms
|
||||
return AudioBufferWriteResult(
|
||||
accepted=frame,
|
||||
dropped=tuple(dropped),
|
||||
overrun=bool(dropped),
|
||||
)
|
||||
|
||||
def frames(self) -> tuple[AudioFrame, ...]:
|
||||
return tuple(self._frames)
|
||||
|
||||
def latest_window(self, *, timestamp_ms: int, window_ms: int) -> tuple[AudioFrame, ...]:
|
||||
if window_ms <= 0:
|
||||
raise ValueError("window_ms must be positive")
|
||||
start_ms = max(0, timestamp_ms - window_ms)
|
||||
return tuple(
|
||||
frame
|
||||
for frame in self._frames
|
||||
if start_ms <= frame.timestamp_ms <= timestamp_ms
|
||||
)
|
||||
|
||||
|
||||
class CaptureRingBuffer(AudioRingBuffer):
|
||||
def __init__(self, *, capacity_ms: int) -> None:
|
||||
super().__init__(capacity_ms=capacity_ms, name="capture")
|
||||
|
||||
|
||||
class RenderReferenceRingBuffer(AudioRingBuffer):
|
||||
def __init__(self, *, capacity_ms: int) -> None:
|
||||
super().__init__(capacity_ms=capacity_ms, name="render_reference")
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class AudioProcessingHealth:
|
||||
provider: str
|
||||
available: bool
|
||||
fallback_active: bool = False
|
||||
message: str = ""
|
||||
|
||||
|
||||
class AudioProcessingProvider(Protocol):
|
||||
name: str
|
||||
|
||||
def process_capture(self, frame: AudioFrame) -> AudioFrame:
|
||||
...
|
||||
|
||||
def process_render(self, frame: AudioFrame) -> None:
|
||||
...
|
||||
|
||||
def reset_stream(self) -> None:
|
||||
...
|
||||
|
||||
def health_check(self) -> AudioProcessingHealth:
|
||||
...
|
||||
|
||||
|
||||
class NoopAudioProcessingProvider:
|
||||
name = "noop"
|
||||
|
||||
def __init__(self, *, fallback_active: bool = False, message: str = "") -> None:
|
||||
self.fallback_active = fallback_active
|
||||
self.message = message
|
||||
self.render_frames: list[AudioFrame] = []
|
||||
|
||||
def process_capture(self, frame: AudioFrame) -> AudioFrame:
|
||||
metadata = dict(frame.metadata)
|
||||
if self.fallback_active:
|
||||
metadata["apm_fallback"] = True
|
||||
return AudioFrame(
|
||||
frame.pcm,
|
||||
frame.sample_rate,
|
||||
frame.channels,
|
||||
frame.timestamp_ms,
|
||||
frame.frame_id,
|
||||
metadata,
|
||||
)
|
||||
|
||||
def process_render(self, frame: AudioFrame) -> None:
|
||||
self.render_frames.append(frame)
|
||||
|
||||
def reset_stream(self) -> None:
|
||||
self.render_frames.clear()
|
||||
|
||||
def health_check(self) -> AudioProcessingHealth:
|
||||
return AudioProcessingHealth(
|
||||
provider=self.name,
|
||||
available=True,
|
||||
fallback_active=self.fallback_active,
|
||||
message=self.message,
|
||||
)
|
||||
|
||||
|
||||
class FakeWebRtcAudioProcessingProvider:
|
||||
name = "fake_webrtc"
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
sample_rate: int = 16000,
|
||||
channels: int = 1,
|
||||
fail_processing: bool = False,
|
||||
) -> None:
|
||||
self.sample_rate = sample_rate
|
||||
self.channels = channels
|
||||
self.fail_processing = fail_processing
|
||||
self.render_frames: list[AudioFrame] = []
|
||||
|
||||
def process_capture(self, frame: AudioFrame) -> AudioFrame:
|
||||
self._validate_format(frame)
|
||||
if self.fail_processing:
|
||||
raise ProviderError(
|
||||
ErrorCode.AUDIO_APM_PROCESS_FAILED,
|
||||
"fake WebRTC APM was configured to fail",
|
||||
True,
|
||||
self.name,
|
||||
"audio_apm",
|
||||
)
|
||||
metadata = dict(frame.metadata)
|
||||
pcm = frame.pcm
|
||||
if metadata.get("assistant_echo") and self.render_frames:
|
||||
metadata["echo_suppressed"] = True
|
||||
metadata["speech"] = False
|
||||
pcm = b"\x00" * len(frame.pcm)
|
||||
return AudioFrame(
|
||||
pcm,
|
||||
frame.sample_rate,
|
||||
frame.channels,
|
||||
frame.timestamp_ms,
|
||||
frame.frame_id,
|
||||
metadata,
|
||||
)
|
||||
|
||||
def process_render(self, frame: AudioFrame) -> None:
|
||||
self._validate_format(frame)
|
||||
self.render_frames.append(frame)
|
||||
|
||||
def reset_stream(self) -> None:
|
||||
self.render_frames.clear()
|
||||
|
||||
def health_check(self) -> AudioProcessingHealth:
|
||||
return AudioProcessingHealth(provider=self.name, available=True)
|
||||
|
||||
def _validate_format(self, frame: AudioFrame) -> None:
|
||||
if frame.sample_rate != self.sample_rate or frame.channels != self.channels:
|
||||
raise ProviderError(
|
||||
ErrorCode.AUDIO_APM_FORMAT_MISMATCH,
|
||||
"audio frame format does not match fake WebRTC APM configuration",
|
||||
False,
|
||||
self.name,
|
||||
"audio_apm",
|
||||
)
|
||||
|
||||
|
||||
def webrtc_apm_probe() -> AudioProcessingHealth:
|
||||
candidates = (
|
||||
"webrtc_audio_processing",
|
||||
"webrtc_audio_processing_module",
|
||||
)
|
||||
for module_name in candidates:
|
||||
if importlib.util.find_spec(module_name) is not None:
|
||||
return AudioProcessingHealth(
|
||||
provider="webrtc",
|
||||
available=True,
|
||||
message=f"found {module_name}",
|
||||
)
|
||||
return AudioProcessingHealth(
|
||||
provider="webrtc",
|
||||
available=False,
|
||||
message="no supported WebRTC APM Python binding found",
|
||||
)
|
||||
|
||||
|
||||
def build_audio_processing_provider(config: AppConfig) -> AudioProcessingProvider:
|
||||
if config.audio_apm_provider == "fake":
|
||||
return FakeWebRtcAudioProcessingProvider(
|
||||
sample_rate=config.sample_rate,
|
||||
channels=config.channels,
|
||||
)
|
||||
if config.audio_apm_provider == "disabled":
|
||||
return NoopAudioProcessingProvider(
|
||||
fallback_active=True,
|
||||
message="WebRTC APM disabled by configuration",
|
||||
)
|
||||
if config.audio_apm_provider != "webrtc":
|
||||
raise ProviderError(
|
||||
ErrorCode.CONFIG_MISSING_VALUE,
|
||||
f"unsupported audio APM provider: {config.audio_apm_provider}",
|
||||
False,
|
||||
"config",
|
||||
"audio_apm",
|
||||
)
|
||||
|
||||
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",
|
||||
)
|
||||
if config.audio_apm_required:
|
||||
raise ProviderError(
|
||||
ErrorCode.AUDIO_APM_UNAVAILABLE,
|
||||
health.message,
|
||||
False,
|
||||
"webrtc",
|
||||
"audio_apm",
|
||||
)
|
||||
return NoopAudioProcessingProvider(
|
||||
fallback_active=True,
|
||||
message=health.message,
|
||||
)
|
||||
@@ -23,6 +23,10 @@ class ErrorCode(str, Enum):
|
||||
AUDIO_PERMISSION_DENIED = "AUDIO_PERMISSION_DENIED"
|
||||
AUDIO_STREAM_UNDERRUN = "AUDIO_STREAM_UNDERRUN"
|
||||
AUDIO_FORMAT_UNSUPPORTED = "AUDIO_FORMAT_UNSUPPORTED"
|
||||
AUDIO_APM_UNAVAILABLE = "AUDIO_APM_UNAVAILABLE"
|
||||
AUDIO_APM_FORMAT_MISMATCH = "AUDIO_APM_FORMAT_MISMATCH"
|
||||
AUDIO_APM_PROCESS_FAILED = "AUDIO_APM_PROCESS_FAILED"
|
||||
AUDIO_BUFFER_OVERRUN = "AUDIO_BUFFER_OVERRUN"
|
||||
CONFIG_MISSING_VALUE = "CONFIG_MISSING_VALUE"
|
||||
WAKE_MODEL_MISSING = "WAKE_MODEL_MISSING"
|
||||
WAKE_MODEL_LOAD_FAILED = "WAKE_MODEL_LOAD_FAILED"
|
||||
@@ -78,6 +82,38 @@ class AudioFrame:
|
||||
if self.frame_id < 0:
|
||||
raise ValueError("frame_id must be non-negative")
|
||||
|
||||
@property
|
||||
def duration_ms(self) -> int:
|
||||
metadata_duration = self.metadata.get("duration_ms")
|
||||
if isinstance(metadata_duration, int):
|
||||
return metadata_duration
|
||||
bytes_per_sample = 2
|
||||
if self.channels <= 0:
|
||||
return 0
|
||||
sample_count = len(self.pcm) // (bytes_per_sample * self.channels)
|
||||
return round(sample_count * 1000 / self.sample_rate)
|
||||
|
||||
def to_fixture(self) -> dict[str, Any]:
|
||||
return {
|
||||
"pcm_hex": self.pcm.hex(),
|
||||
"sample_rate": self.sample_rate,
|
||||
"channels": self.channels,
|
||||
"timestamp_ms": self.timestamp_ms,
|
||||
"frame_id": self.frame_id,
|
||||
"metadata": dict(self.metadata),
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def from_fixture(cls, data: Mapping[str, Any]) -> "AudioFrame":
|
||||
return cls(
|
||||
pcm=bytes.fromhex(str(data["pcm_hex"])),
|
||||
sample_rate=int(data["sample_rate"]),
|
||||
channels=int(data["channels"]),
|
||||
timestamp_ms=int(data["timestamp_ms"]),
|
||||
frame_id=int(data["frame_id"]),
|
||||
metadata=dict(data.get("metadata") or {}),
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class AudioSegment:
|
||||
|
||||
Reference in New Issue
Block a user