[OpenSpec 与项目骨架]:完成实施型变更与 Python 基础骨架,包含 OpenSpec 工件、核心模型、配置边界和基础测试
This commit is contained in:
@@ -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",
|
||||
]
|
||||
@@ -0,0 +1,4 @@
|
||||
from .cli import main
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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 = ""
|
||||
@@ -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:
|
||||
...
|
||||
Reference in New Issue
Block a user