[OpenSpec 与项目骨架]:完成实施型变更与 Python 基础骨架,包含 OpenSpec 工件、核心模型、配置边界和基础测试

This commit is contained in:
mkbk
2026-06-17 18:12:58 +08:00
commit 86ad0c86f2
21 changed files with 2410 additions and 0 deletions
+33
View File
@@ -0,0 +1,33 @@
"""Owner voice pet pipeline package."""
from .config import AppConfig
from .models import (
AudioFrame,
AudioSegment,
ErrorCode,
Message,
PipelineState,
PlaybackResult,
ProviderError,
ReplyDelta,
Transcript,
TransportHealth,
VadResult,
WakeEvent,
)
__all__ = [
"AppConfig",
"AudioFrame",
"AudioSegment",
"ErrorCode",
"Message",
"PipelineState",
"PlaybackResult",
"ProviderError",
"ReplyDelta",
"Transcript",
"TransportHealth",
"VadResult",
"WakeEvent",
]
+4
View File
@@ -0,0 +1,4 @@
from .cli import main
if __name__ == "__main__":
raise SystemExit(main())
+35
View File
@@ -0,0 +1,35 @@
from __future__ import annotations
import argparse
import json
from .config import AppConfig
def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(prog="owner-voice-pet")
parser.add_argument("--show-config", action="store_true", help="Print non-secret config summary")
args = parser.parse_args(argv)
if args.show_config:
config = AppConfig.from_env()
print(
json.dumps(
{
"wake_word": config.wake_word,
"sample_rate": config.sample_rate,
"channels": config.channels,
"llm_base_url": config.llm_base_url,
"llm_model": config.llm_model,
"llm_api_style": config.llm_api_style,
"llm_api_key_present": bool(config.llm_api_key),
"asset_dir": str(config.asset_dir),
},
ensure_ascii=False,
sort_keys=True,
)
)
return 0
parser.print_help()
return 0
+90
View File
@@ -0,0 +1,90 @@
from __future__ import annotations
import os
from dataclasses import dataclass
from pathlib import Path
from .models import ErrorCode, ProviderError
@dataclass(frozen=True, slots=True)
class AppConfig:
wake_word: str = "小杰小杰"
sample_rate: int = 16000
channels: int = 1
llm_base_url: str = "https://api.openai.com"
llm_api_key: str | None = None
llm_model: str = "gpt-4o-mini"
llm_api_style: str = "chat_completions"
audio_input_device: str | None = None
audio_output_device: str | None = None
asset_dir: Path = Path("assets/pet")
log_dir: Path = Path("logs")
context_max_messages: int = 12
context_max_chars: int = 12000
@classmethod
def from_env(cls, prefix: str = "OWNER_") -> "AppConfig":
def get(name: str, default: str | None = None) -> str | None:
value = os.environ.get(f"{prefix}{name}")
return default if value is None or value == "" else value
return cls(
wake_word=get("WAKE_WORD", "小杰小杰") or "小杰小杰",
sample_rate=int(get("SAMPLE_RATE", "16000") or "16000"),
channels=int(get("CHANNELS", "1") or "1"),
llm_base_url=(get("LLM_BASE_URL", "https://api.openai.com") or "").rstrip("/"),
llm_api_key=get("LLM_API_KEY"),
llm_model=get("LLM_MODEL", "gpt-4o-mini") or "gpt-4o-mini",
llm_api_style=get("LLM_API_STYLE", "chat_completions") or "chat_completions",
audio_input_device=get("AUDIO_INPUT_DEVICE"),
audio_output_device=get("AUDIO_OUTPUT_DEVICE"),
asset_dir=Path(get("ASSET_DIR", "assets/pet") or "assets/pet"),
log_dir=Path(get("LOG_DIR", "logs") or "logs"),
context_max_messages=int(get("CONTEXT_MAX_MESSAGES", "12") or "12"),
context_max_chars=int(get("CONTEXT_MAX_CHARS", "12000") or "12000"),
)
def require_llm_credentials(self) -> None:
if not self.llm_api_key:
raise ProviderError(
code=ErrorCode.LLM_API_KEY_MISSING,
message="OWNER_LLM_API_KEY is required for cloud LLM calls",
retryable=False,
provider="openai-compatible",
stage="llm",
)
def validate_basic(self) -> list[ProviderError]:
errors: list[ProviderError] = []
if self.sample_rate <= 0:
errors.append(
ProviderError(
ErrorCode.CONFIG_MISSING_VALUE,
"sample_rate must be positive",
False,
"config",
"startup",
)
)
if self.channels <= 0:
errors.append(
ProviderError(
ErrorCode.CONFIG_MISSING_VALUE,
"channels must be positive",
False,
"config",
"startup",
)
)
if self.llm_api_style not in {"chat_completions", "responses"}:
errors.append(
ProviderError(
ErrorCode.CONFIG_MISSING_VALUE,
"OWNER_LLM_API_STYLE must be chat_completions or responses",
False,
"config",
"startup",
)
)
return errors
+159
View File
@@ -0,0 +1,159 @@
from __future__ import annotations
from dataclasses import dataclass, field
from enum import Enum
from typing import Any, Mapping
class PipelineState(str, Enum):
IDLE = "idle"
WAKE_LISTENING = "wake_listening"
SPEECH_DETECTING = "speech_detecting"
RECORDING = "recording"
TRANSCRIBING = "transcribing"
THINKING = "thinking"
SPEAKING = "speaking"
INTERRUPTED = "interrupted"
ERROR_RECOVERING = "error_recovering"
class ErrorCode(str, Enum):
AUDIO_INPUT_DEVICE_MISSING = "AUDIO_INPUT_DEVICE_MISSING"
AUDIO_OUTPUT_DEVICE_MISSING = "AUDIO_OUTPUT_DEVICE_MISSING"
AUDIO_PERMISSION_DENIED = "AUDIO_PERMISSION_DENIED"
AUDIO_STREAM_UNDERRUN = "AUDIO_STREAM_UNDERRUN"
AUDIO_FORMAT_UNSUPPORTED = "AUDIO_FORMAT_UNSUPPORTED"
CONFIG_MISSING_VALUE = "CONFIG_MISSING_VALUE"
WAKE_MODEL_MISSING = "WAKE_MODEL_MISSING"
WAKE_MODEL_LOAD_FAILED = "WAKE_MODEL_LOAD_FAILED"
WAKE_AUDIO_FORMAT_INVALID = "WAKE_AUDIO_FORMAT_INVALID"
VAD_MODEL_LOAD_FAILED = "VAD_MODEL_LOAD_FAILED"
VAD_TIMEOUT_NO_SPEECH = "VAD_TIMEOUT_NO_SPEECH"
VAD_MAX_RECORDING_REACHED = "VAD_MAX_RECORDING_REACHED"
STT_MODEL_MISSING = "STT_MODEL_MISSING"
STT_TRANSCRIBE_FAILED = "STT_TRANSCRIBE_FAILED"
STT_EMPTY_TRANSCRIPT = "STT_EMPTY_TRANSCRIPT"
LLM_API_KEY_MISSING = "LLM_API_KEY_MISSING"
LLM_REQUEST_TIMEOUT = "LLM_REQUEST_TIMEOUT"
LLM_RATE_LIMITED = "LLM_RATE_LIMITED"
LLM_NETWORK_ERROR = "LLM_NETWORK_ERROR"
LLM_EMPTY_REPLY = "LLM_EMPTY_REPLY"
TTS_MODEL_MISSING = "TTS_MODEL_MISSING"
TTS_SYNTHESIS_FAILED = "TTS_SYNTHESIS_FAILED"
TTS_EMPTY_AUDIO = "TTS_EMPTY_AUDIO"
ASSET_MISSING = "ASSET_MISSING"
VALIDATION_FAILED = "VALIDATION_FAILED"
@dataclass(slots=True)
class ProviderError(Exception):
code: ErrorCode
message: str
retryable: bool
provider: str
stage: str
def __str__(self) -> str:
return f"{self.code.value} [{self.stage}/{self.provider}]: {self.message}"
@dataclass(frozen=True, slots=True)
class AudioFrame:
pcm: bytes
sample_rate: int
channels: int
timestamp_ms: int
frame_id: int
metadata: Mapping[str, Any] = field(default_factory=dict)
def __post_init__(self) -> None:
if self.sample_rate <= 0:
raise ValueError("sample_rate must be positive")
if self.channels <= 0:
raise ValueError("channels must be positive")
if self.timestamp_ms < 0:
raise ValueError("timestamp_ms must be non-negative")
if self.frame_id < 0:
raise ValueError("frame_id must be non-negative")
@dataclass(frozen=True, slots=True)
class AudioSegment:
pcm: bytes
sample_rate: int
channels: int
start_time_ms: int
end_time_ms: int
metadata: Mapping[str, Any] = field(default_factory=dict)
def __post_init__(self) -> None:
if self.sample_rate <= 0:
raise ValueError("sample_rate must be positive")
if self.channels <= 0:
raise ValueError("channels must be positive")
if self.start_time_ms < 0:
raise ValueError("start_time_ms must be non-negative")
if self.end_time_ms < self.start_time_ms:
raise ValueError("end_time_ms must be >= start_time_ms")
@property
def duration_ms(self) -> int:
return self.end_time_ms - self.start_time_ms
@dataclass(frozen=True, slots=True)
class WakeEvent:
keyword: str
confidence: float
timestamp_ms: int
@dataclass(frozen=True, slots=True)
class VadResult:
is_speech: bool
confidence: float
speech_ms: int
silence_ms: int
end_reason: str | None = None
@dataclass(frozen=True, slots=True)
class Transcript:
text: str
language: str
confidence: float | None
duration_ms: int
provider: str
raw_metadata: Mapping[str, Any] = field(default_factory=dict)
@property
def normalized_text(self) -> str:
return self.text.strip()
@dataclass(frozen=True, slots=True)
class Message:
role: str
content: str
created_at: float
@dataclass(frozen=True, slots=True)
class ReplyDelta:
text_delta: str
is_sentence_boundary: bool = False
finish_reason: str | None = None
@dataclass(frozen=True, slots=True)
class PlaybackResult:
played: bool
duration_ms: int
error: ProviderError | None = None
@dataclass(frozen=True, slots=True)
class TransportHealth:
input_available: bool
output_available: bool
message: str = ""
+78
View File
@@ -0,0 +1,78 @@
from __future__ import annotations
from collections.abc import Iterable, Sequence
from typing import Protocol
from .models import (
AudioFrame,
AudioSegment,
Message,
PlaybackResult,
ReplyDelta,
Transcript,
TransportHealth,
VadResult,
WakeEvent,
)
class AudioTransport(Protocol):
def start_input(
self, device_id: str | None = None, sample_rate: int = 16000, channels: int = 1
) -> None:
...
def read_frames(self, timeout_ms: int) -> list[AudioFrame]:
...
def play_pcm(self, segment: AudioSegment, interrupt: bool = False) -> PlaybackResult:
...
def stop(self) -> None:
...
def health(self) -> TransportHealth:
...
class WakeWordProvider(Protocol):
def load(self) -> None:
...
def detect(self, frame: AudioFrame) -> WakeEvent | None:
...
def reset(self) -> None:
...
class VadProvider(Protocol):
def load(self) -> None:
...
def analyze(self, frame: AudioFrame) -> VadResult:
...
def reset(self) -> None:
...
class SttProvider(Protocol):
def load(self) -> None:
...
def transcribe(self, segment: AudioSegment) -> Transcript:
...
class LlmProvider(Protocol):
def stream_reply(self, messages: Sequence[Message]) -> Iterable[ReplyDelta]:
...
class TtsProvider(Protocol):
def load(self) -> None:
...
def synthesize(self, text: str) -> AudioSegment:
...