[音频 Transport]:完成本机音频抽象与回放测试通道,包含内存 Transport、文件回放、可选 sounddevice 适配和环形缓冲
This commit is contained in:
@@ -10,11 +10,11 @@
|
|||||||
|
|
||||||
## 2. 音频 Transport
|
## 2. 音频 Transport
|
||||||
|
|
||||||
- [ ] 2.1 实现 `AudioTransport` 协议和本机音频错误对象;前置条件:核心模型已提交;验收标准:协议包含输入启动、帧读取、PCM 播放、停止、健康检查;测试要点:协议和错误码可导入;优先级:P0;预计:45 分钟。
|
- [x] 2.1 实现 `AudioTransport` 协议和本机音频错误对象;前置条件:核心模型已提交;验收标准:协议包含输入启动、帧读取、PCM 播放、停止、健康检查;测试要点:协议和错误码可导入;优先级:P0;预计:45 分钟。
|
||||||
- [ ] 2.2 实现内存/文件回放 Transport;前置条件:AudioTransport 协议已定义;验收标准:测试可注入音频帧并捕获播放结果,不依赖真实麦克风;测试要点:回放帧顺序、超时和播放队列通过单元测试;优先级:P0;预计:60 分钟。
|
- [x] 2.2 实现内存/文件回放 Transport;前置条件:AudioTransport 协议已定义;验收标准:测试可注入音频帧并捕获播放结果,不依赖真实麦克风;测试要点:回放帧顺序、超时和播放队列通过单元测试;优先级:P0;预计:60 分钟。
|
||||||
- [ ] 2.3 实现可选本机 Transport 适配层;前置条件:Transport 协议已定义;验收标准:未安装音频依赖或无设备时返回结构化错误,不影响测试模式;测试要点:无依赖环境下导入不崩溃;优先级:P1;预计:60 分钟。
|
- [x] 2.3 实现可选本机 Transport 适配层;前置条件:Transport 协议已定义;验收标准:未安装音频依赖或无设备时返回结构化错误,不影响测试模式;测试要点:无依赖环境下导入不崩溃;优先级:P1;预计:60 分钟。
|
||||||
- [ ] 2.4 实现音频环形缓冲和格式校验;前置条件:音频模型和 Transport 已定义;验收标准:保留最近音频帧并校验采样率、声道、时间戳;测试要点:缓冲容量、顺序和非法帧测试通过;优先级:P0;预计:60 分钟。
|
- [x] 2.4 实现音频环形缓冲和格式校验;前置条件:音频模型和 Transport 已定义;验收标准:保留最近音频帧并校验采样率、声道、时间戳;测试要点:缓冲容量、顺序和非法帧测试通过;优先级:P0;预计:60 分钟。
|
||||||
- [ ] 2.5 完成“音频 Transport”模块提交;前置条件:2.1 至 2.4 已完成;验收标准:先通过相关测试、compileall 和 OpenSpec strict 校验,再立即执行 Git commit;测试要点:提交信息使用“`[音频 Transport]:完成[具体功能描述],包含[关键变更]`”格式;优先级:P0;预计:20 分钟。
|
- [x] 2.5 完成“音频 Transport”模块提交;前置条件:2.1 至 2.4 已完成;验收标准:先通过相关测试、compileall 和 OpenSpec strict 校验,再立即执行 Git commit;测试要点:提交信息使用“`[音频 Transport]:完成[具体功能描述],包含[关键变更]`”格式;优先级:P0;预计:20 分钟。
|
||||||
|
|
||||||
## 3. Wake/VAD/STT
|
## 3. Wake/VAD/STT
|
||||||
|
|
||||||
|
|||||||
@@ -15,12 +15,16 @@ from .models import (
|
|||||||
VadResult,
|
VadResult,
|
||||||
WakeEvent,
|
WakeEvent,
|
||||||
)
|
)
|
||||||
|
from .transport import AudioRingBuffer, FileReplayTransport, MemoryAudioTransport
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"AppConfig",
|
"AppConfig",
|
||||||
"AudioFrame",
|
"AudioFrame",
|
||||||
"AudioSegment",
|
"AudioSegment",
|
||||||
|
"AudioRingBuffer",
|
||||||
"ErrorCode",
|
"ErrorCode",
|
||||||
|
"FileReplayTransport",
|
||||||
|
"MemoryAudioTransport",
|
||||||
"Message",
|
"Message",
|
||||||
"PipelineState",
|
"PipelineState",
|
||||||
"PlaybackResult",
|
"PlaybackResult",
|
||||||
|
|||||||
@@ -0,0 +1,220 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from collections import deque
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from .models import (
|
||||||
|
AudioFrame,
|
||||||
|
AudioSegment,
|
||||||
|
ErrorCode,
|
||||||
|
PlaybackResult,
|
||||||
|
ProviderError,
|
||||||
|
TransportHealth,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class AudioRingBuffer:
|
||||||
|
def __init__(self, max_duration_ms: int = 3000) -> None:
|
||||||
|
if max_duration_ms <= 0:
|
||||||
|
raise ValueError("max_duration_ms must be positive")
|
||||||
|
self.max_duration_ms = max_duration_ms
|
||||||
|
self._frames: deque[AudioFrame] = deque()
|
||||||
|
|
||||||
|
def append(self, frame: AudioFrame) -> None:
|
||||||
|
if self._frames and frame.timestamp_ms < self._frames[-1].timestamp_ms:
|
||||||
|
raise ValueError("audio frame timestamps must be non-decreasing")
|
||||||
|
self._frames.append(frame)
|
||||||
|
self._trim()
|
||||||
|
|
||||||
|
def extend(self, frames: list[AudioFrame]) -> None:
|
||||||
|
for frame in frames:
|
||||||
|
self.append(frame)
|
||||||
|
|
||||||
|
def frames(self) -> list[AudioFrame]:
|
||||||
|
return list(self._frames)
|
||||||
|
|
||||||
|
def clear(self) -> None:
|
||||||
|
self._frames.clear()
|
||||||
|
|
||||||
|
def _trim(self) -> None:
|
||||||
|
if not self._frames:
|
||||||
|
return
|
||||||
|
latest = self._frames[-1].timestamp_ms
|
||||||
|
cutoff = latest - self.max_duration_ms
|
||||||
|
while self._frames and self._frames[0].timestamp_ms < cutoff:
|
||||||
|
self._frames.popleft()
|
||||||
|
|
||||||
|
|
||||||
|
class MemoryAudioTransport:
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
frames: list[AudioFrame] | None = None,
|
||||||
|
input_available: bool = True,
|
||||||
|
output_available: bool = True,
|
||||||
|
) -> None:
|
||||||
|
self._frames: deque[AudioFrame] = deque(frames or [])
|
||||||
|
self.played_segments: list[AudioSegment] = []
|
||||||
|
self.started = False
|
||||||
|
self._input_available = input_available
|
||||||
|
self._output_available = output_available
|
||||||
|
|
||||||
|
def start_input(
|
||||||
|
self, device_id: str | None = None, sample_rate: int = 16000, channels: int = 1
|
||||||
|
) -> None:
|
||||||
|
if not self._input_available:
|
||||||
|
raise ProviderError(
|
||||||
|
ErrorCode.AUDIO_INPUT_DEVICE_MISSING,
|
||||||
|
"memory input transport is disabled",
|
||||||
|
False,
|
||||||
|
"memory-transport",
|
||||||
|
"transport",
|
||||||
|
)
|
||||||
|
self.started = True
|
||||||
|
|
||||||
|
def read_frames(self, timeout_ms: int) -> list[AudioFrame]:
|
||||||
|
if not self.started:
|
||||||
|
return []
|
||||||
|
if not self._frames:
|
||||||
|
return []
|
||||||
|
return [self._frames.popleft()]
|
||||||
|
|
||||||
|
def inject(self, frame: AudioFrame) -> None:
|
||||||
|
self._frames.append(frame)
|
||||||
|
|
||||||
|
def play_pcm(self, segment: AudioSegment, interrupt: bool = False) -> PlaybackResult:
|
||||||
|
if not self._output_available:
|
||||||
|
error = ProviderError(
|
||||||
|
ErrorCode.AUDIO_OUTPUT_DEVICE_MISSING,
|
||||||
|
"memory output transport is disabled",
|
||||||
|
False,
|
||||||
|
"memory-transport",
|
||||||
|
"transport",
|
||||||
|
)
|
||||||
|
return PlaybackResult(False, 0, error)
|
||||||
|
if interrupt:
|
||||||
|
self.played_segments.clear()
|
||||||
|
self.played_segments.append(segment)
|
||||||
|
return PlaybackResult(True, segment.duration_ms)
|
||||||
|
|
||||||
|
def stop(self) -> None:
|
||||||
|
self.started = False
|
||||||
|
|
||||||
|
def health(self) -> TransportHealth:
|
||||||
|
return TransportHealth(
|
||||||
|
input_available=self._input_available,
|
||||||
|
output_available=self._output_available,
|
||||||
|
message="memory transport",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class FileReplayTransport(MemoryAudioTransport):
|
||||||
|
@classmethod
|
||||||
|
def from_jsonl(cls, path: str | Path) -> "FileReplayTransport":
|
||||||
|
frames: list[AudioFrame] = []
|
||||||
|
with Path(path).open("r", encoding="utf-8") as handle:
|
||||||
|
for line_no, line in enumerate(handle, start=1):
|
||||||
|
if not line.strip():
|
||||||
|
continue
|
||||||
|
item: dict[str, Any] = json.loads(line)
|
||||||
|
try:
|
||||||
|
frames.append(
|
||||||
|
AudioFrame(
|
||||||
|
pcm=bytes.fromhex(item.get("pcm_hex", "")),
|
||||||
|
sample_rate=int(item.get("sample_rate", 16000)),
|
||||||
|
channels=int(item.get("channels", 1)),
|
||||||
|
timestamp_ms=int(item["timestamp_ms"]),
|
||||||
|
frame_id=int(item.get("frame_id", line_no - 1)),
|
||||||
|
metadata=dict(item.get("metadata", {})),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
except KeyError as exc:
|
||||||
|
raise ValueError(f"missing required fixture field on line {line_no}: {exc}") from exc
|
||||||
|
return cls(frames)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def write_jsonl(path: str | Path, frames: list[AudioFrame]) -> None:
|
||||||
|
with Path(path).open("w", encoding="utf-8") as handle:
|
||||||
|
for frame in frames:
|
||||||
|
handle.write(
|
||||||
|
json.dumps(
|
||||||
|
{
|
||||||
|
"pcm_hex": frame.pcm.hex(),
|
||||||
|
"sample_rate": frame.sample_rate,
|
||||||
|
"channels": frame.channels,
|
||||||
|
"timestamp_ms": frame.timestamp_ms,
|
||||||
|
"frame_id": frame.frame_id,
|
||||||
|
"metadata": dict(frame.metadata),
|
||||||
|
},
|
||||||
|
ensure_ascii=False,
|
||||||
|
)
|
||||||
|
+ "\n"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class SoundDeviceAudioTransport:
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self._sd: Any | None = None
|
||||||
|
self._load_error: Exception | None = None
|
||||||
|
try:
|
||||||
|
import sounddevice as sd # type: ignore[import-not-found]
|
||||||
|
|
||||||
|
self._sd = sd
|
||||||
|
except Exception as exc: # pragma: no cover - depends on optional system package
|
||||||
|
self._load_error = exc
|
||||||
|
|
||||||
|
def start_input(
|
||||||
|
self, device_id: str | None = None, sample_rate: int = 16000, channels: int = 1
|
||||||
|
) -> None:
|
||||||
|
if self._sd is None:
|
||||||
|
raise ProviderError(
|
||||||
|
ErrorCode.AUDIO_INPUT_DEVICE_MISSING,
|
||||||
|
"sounddevice is not installed or cannot be imported",
|
||||||
|
False,
|
||||||
|
"sounddevice-transport",
|
||||||
|
"transport",
|
||||||
|
)
|
||||||
|
raise ProviderError(
|
||||||
|
ErrorCode.AUDIO_FORMAT_UNSUPPORTED,
|
||||||
|
"live sounddevice streaming is reserved for the interactive runtime path",
|
||||||
|
False,
|
||||||
|
"sounddevice-transport",
|
||||||
|
"transport",
|
||||||
|
)
|
||||||
|
|
||||||
|
def read_frames(self, timeout_ms: int) -> list[AudioFrame]:
|
||||||
|
return []
|
||||||
|
|
||||||
|
def play_pcm(self, segment: AudioSegment, interrupt: bool = False) -> PlaybackResult:
|
||||||
|
if self._sd is None:
|
||||||
|
return PlaybackResult(
|
||||||
|
False,
|
||||||
|
0,
|
||||||
|
ProviderError(
|
||||||
|
ErrorCode.AUDIO_OUTPUT_DEVICE_MISSING,
|
||||||
|
"sounddevice is not installed or cannot be imported",
|
||||||
|
False,
|
||||||
|
"sounddevice-transport",
|
||||||
|
"transport",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
return PlaybackResult(
|
||||||
|
False,
|
||||||
|
0,
|
||||||
|
ProviderError(
|
||||||
|
ErrorCode.AUDIO_FORMAT_UNSUPPORTED,
|
||||||
|
"live sounddevice playback is reserved for the interactive runtime path",
|
||||||
|
False,
|
||||||
|
"sounddevice-transport",
|
||||||
|
"transport",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
def stop(self) -> None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
def health(self) -> TransportHealth:
|
||||||
|
if self._sd is None:
|
||||||
|
return TransportHealth(False, False, "sounddevice unavailable")
|
||||||
|
return TransportHealth(True, True, "sounddevice import available")
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from owner_voice_pet.models import AudioFrame, AudioSegment, ErrorCode
|
||||||
|
from owner_voice_pet.transport import (
|
||||||
|
AudioRingBuffer,
|
||||||
|
FileReplayTransport,
|
||||||
|
MemoryAudioTransport,
|
||||||
|
SoundDeviceAudioTransport,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def frame(idx: int, timestamp_ms: int, metadata: dict[str, object] | None = None) -> AudioFrame:
|
||||||
|
return AudioFrame(
|
||||||
|
pcm=idx.to_bytes(2, "little", signed=False),
|
||||||
|
sample_rate=16000,
|
||||||
|
channels=1,
|
||||||
|
timestamp_ms=timestamp_ms,
|
||||||
|
frame_id=idx,
|
||||||
|
metadata=metadata or {},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TransportTests(unittest.TestCase):
|
||||||
|
def test_memory_transport_replays_frames_and_captures_playback(self) -> None:
|
||||||
|
transport = MemoryAudioTransport([frame(1, 0), frame(2, 20)])
|
||||||
|
transport.start_input()
|
||||||
|
self.assertEqual([f.frame_id for f in transport.read_frames(10)], [1])
|
||||||
|
self.assertEqual([f.frame_id for f in transport.read_frames(10)], [2])
|
||||||
|
self.assertEqual(transport.read_frames(10), [])
|
||||||
|
|
||||||
|
result = transport.play_pcm(AudioSegment(b"\x00\x00", 16000, 1, 0, 100))
|
||||||
|
self.assertTrue(result.played)
|
||||||
|
self.assertEqual(len(transport.played_segments), 1)
|
||||||
|
|
||||||
|
def test_memory_transport_reports_missing_output(self) -> None:
|
||||||
|
transport = MemoryAudioTransport(output_available=False)
|
||||||
|
result = transport.play_pcm(AudioSegment(b"\x00\x00", 16000, 1, 0, 100))
|
||||||
|
self.assertFalse(result.played)
|
||||||
|
self.assertEqual(result.error.code, ErrorCode.AUDIO_OUTPUT_DEVICE_MISSING)
|
||||||
|
|
||||||
|
def test_file_replay_roundtrip(self) -> None:
|
||||||
|
frames = [frame(1, 0, {"wake": True}), frame(2, 20, {"speech": True})]
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
path = Path(tmp) / "fixture.jsonl"
|
||||||
|
FileReplayTransport.write_jsonl(path, frames)
|
||||||
|
transport = FileReplayTransport.from_jsonl(path)
|
||||||
|
transport.start_input()
|
||||||
|
self.assertTrue(transport.read_frames(10)[0].metadata["wake"])
|
||||||
|
self.assertTrue(transport.read_frames(10)[0].metadata["speech"])
|
||||||
|
|
||||||
|
def test_ring_buffer_trims_old_frames_and_requires_order(self) -> None:
|
||||||
|
buffer = AudioRingBuffer(max_duration_ms=40)
|
||||||
|
buffer.extend([frame(1, 0), frame(2, 20), frame(3, 50)])
|
||||||
|
self.assertEqual([f.frame_id for f in buffer.frames()], [2, 3])
|
||||||
|
with self.assertRaises(ValueError):
|
||||||
|
buffer.append(frame(4, 10))
|
||||||
|
|
||||||
|
def test_sounddevice_transport_is_safe_without_optional_dependency(self) -> None:
|
||||||
|
transport = SoundDeviceAudioTransport()
|
||||||
|
health = transport.health()
|
||||||
|
self.assertIsInstance(health.input_available, bool)
|
||||||
|
self.assertIsInstance(health.output_available, bool)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
Reference in New Issue
Block a user