from __future__ import annotations from dataclasses import dataclass from pathlib import Path from .models import ErrorCode, ProviderError @dataclass(frozen=True, slots=True) class AppConfig: assistant_mode: str = "turn_based_voice_pet" wake_word: str = "小杰小杰" sample_rate: int = 16000 channels: int = 1 llm_base_url: str = "https://token-plan-cn.xiaomimimo.com/v1" llm_api_key: str | None = None llm_model: str = "mimo-v2.5" llm_api_style: str = "chat_completions" llm_stream: bool = True realtime_transcript_enabled: bool = True realtime_transcript_idle_timeout_ms: int = 1500 audio_input_device: str | None = None audio_output_device: str | None = None asset_dir: Path = Path("assets/pet") log_dir: Path = Path("logs") wake_provider: str = "local_kws" wake_keywords_file: Path | None = None wake_kws_threshold: float = 0.15 wake_kws_score: float = 1.0 wake_ack_text: str = "我在" post_playback_drain_ms: int = 0 pipeline_mode: str = "live_turn_based" endpoint_mode: str = "primary_speaker" noise_filter_enabled: bool = True noise_filter_provider: str = "sherpa_onnx_gtcrn" wake_denoise_enabled: bool = False speaker_profile_ms: int = 600 speaker_profile_min_ms: int = 120 speaker_absent_ms: int = 300 speaker_similarity_threshold: float = 0.70 speaker_min_rms: float = 0.012 vad_provider: str = "hybrid" vad_threshold: float = 0.5 vad_min_duration_ms: int = 250 vad_end_silence_ms: int = 350 vad_no_speech_timeout_ms: int = 5000 vad_max_recording_ms: int = 12000 speech_provider: str = "local" asr_model: str = "mimo-v2.5-asr" tts_model: str = "mimo-v2.5-tts" tts_voice: str = "mimo_default" speech_models_dir: Path = Path("models") context_mode: str = "session_memory" context_max_messages: int = 12 context_max_chars: int = 12000 continuous_dialog_enabled: bool = True continuation_decision_provider: str = "hybrid" continuation_confidence_threshold: float = 0.65 followup_listen_timeout_ms: int = 3000 barge_in_enabled: bool = True barge_in_min_speech_ms: int = 250 barge_in_echo_guard_ms: int = 500 barge_in_speaker_gate_enabled: bool = True barge_in_user_similarity_threshold: float = 0.62 barge_in_assistant_reject_threshold: float = 0.72 barge_in_listen_interval_ms: int = 20 barge_in_chunk_ms: int = 30 barge_in_debug: bool = False end_chime_enabled: bool = True end_chime_file: Path = Path("assets/sounds/codex-notification.wav") end_chime_frequency_hz: int = 880 end_chime_duration_ms: int = 140 audio_apm_provider: str = "webrtc" audio_aec_enabled: bool = True audio_ns_enabled: bool = True audio_agc_enabled: bool = True audio_apm_required: bool = True audio_frame_ms: int = 20 audio_ring_buffer_ms: int = 3000 interrupt_enabled: bool = True interrupt_target_latency_ms: int = 200 streaming_stt_provider: str = "faster_whisper" streaming_stt_product_candidate: str = "sensevoice" streaming_tts_provider: str = "cosyvoice" memory_enabled: bool = True memory_provider: str = "faiss_sqlite" memory_top_k: int = 5 memory_auto_save_sensitive: bool = False tool_router_enabled: bool = True tool_max_calls_per_turn: int = 5 tool_timeout_ms: int = 30000 openinterpreter_enabled: bool = False openinterpreter_command: str = "openinterpreter" browser_playwright_enabled: bool = False computer_control_enabled: bool = False @classmethod def from_dotenv(cls, path: str | Path = ".env", prefix: str = "OWNER_") -> "AppConfig": values = parse_dotenv(Path(path)) def get(name: str, default: str | None = None) -> str | None: value = values.get(f"{prefix}{name}") return default if value is None or value == "" else value def get_bool(name: str, default: str = "0") -> bool: return (get(name, default) or default).lower() not in {"0", "false", "no"} def get_bool_compat(name: str, legacy_name: str, default: str = "0") -> bool: value = get(name) if value is None: value = get(legacy_name, default) return (value or default).lower() not in {"0", "false", "no"} return cls( assistant_mode=( get("ASSISTANT_MODE", "turn_based_voice_pet") or "turn_based_voice_pet" ).lower(), 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://token-plan-cn.xiaomimimo.com/v1") or "").rstrip("/"), llm_api_key=get("LLM_API_KEY"), llm_model=get("LLM_MODEL", "mimo-v2.5") or "mimo-v2.5", llm_api_style=get("LLM_API_STYLE", "chat_completions") or "chat_completions", llm_stream=get_bool_compat("LLM_STREAMING_ENABLED", "LLM_STREAM", "1"), realtime_transcript_enabled=(get("REALTIME_TRANSCRIPT_ENABLED", "1") or "1").lower() not in {"0", "false", "no"}, realtime_transcript_idle_timeout_ms=int( get("REALTIME_TRANSCRIPT_IDLE_TIMEOUT_MS", "1500") or "1500" ), 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"), wake_provider=(get("WAKE_PROVIDER", "local_kws") or "local_kws").lower(), wake_keywords_file=Path(value) if (value := get("WAKE_KEYWORDS_FILE")) else None, wake_kws_threshold=float(get("WAKE_KWS_THRESHOLD", "0.15") or "0.15"), wake_kws_score=float(get("WAKE_KWS_SCORE", "1.0") or "1.0"), wake_ack_text=get("WAKE_ACK_TEXT", "我在") or "我在", post_playback_drain_ms=int(get("POST_PLAYBACK_DRAIN_MS", "0") or "0"), pipeline_mode=(get("PIPELINE_MODE", "live_turn_based") or "live_turn_based").lower(), endpoint_mode=(get("ENDPOINT_MODE", "primary_speaker") or "primary_speaker").lower(), noise_filter_enabled=(get("NOISE_FILTER_ENABLED", "1") or "1").lower() not in {"0", "false", "no"}, noise_filter_provider=(get("NOISE_FILTER_PROVIDER", "sherpa_onnx_gtcrn") or "sherpa_onnx_gtcrn").lower(), wake_denoise_enabled=(get("WAKE_DENOISE_ENABLED", "0") or "0").lower() in {"1", "true", "yes"}, speaker_profile_ms=int(get("SPEAKER_PROFILE_MS", "600") or "600"), speaker_profile_min_ms=int(get("SPEAKER_PROFILE_MIN_MS", "120") or "120"), speaker_absent_ms=int(get("SPEAKER_ABSENT_MS", "300") or "300"), speaker_similarity_threshold=float( get("SPEAKER_SIMILARITY_THRESHOLD", "0.70") or "0.70" ), speaker_min_rms=float(get("SPEAKER_MIN_RMS", "0.012") or "0.012"), vad_provider=(get("VAD_PROVIDER", "hybrid") or "hybrid").lower(), vad_threshold=float(get("VAD_THRESHOLD", "0.5") or "0.5"), vad_min_duration_ms=int(get("VAD_MIN_DURATION_MS", "250") or "250"), vad_end_silence_ms=int(get("VAD_END_SILENCE_MS", "350") or "350"), vad_no_speech_timeout_ms=int(get("VAD_NO_SPEECH_TIMEOUT_MS", "5000") or "5000"), vad_max_recording_ms=int(get("VAD_MAX_RECORDING_MS", "12000") or "12000"), speech_provider=(get("SPEECH_PROVIDER", "local") or "local").lower(), asr_model=get("ASR_MODEL", "mimo-v2.5-asr") or "mimo-v2.5-asr", tts_model=get("TTS_MODEL", "mimo-v2.5-tts") or "mimo-v2.5-tts", tts_voice=get("TTS_VOICE", "mimo_default") or "mimo_default", speech_models_dir=Path(get("SPEECH_MODELS_DIR", "models") or "models"), context_mode=(get("CONTEXT_MODE", "session_memory") or "session_memory").lower(), context_max_messages=int(get("CONTEXT_MAX_MESSAGES", "12") or "12"), context_max_chars=int(get("CONTEXT_MAX_CHARS", "12000") or "12000"), continuous_dialog_enabled=(get("CONTINUOUS_DIALOG_ENABLED", "1") or "1").lower() not in {"0", "false", "no"}, continuation_decision_provider=( get("CONTINUATION_DECISION_PROVIDER", "hybrid") or "hybrid" ).lower(), continuation_confidence_threshold=float( get("CONTINUATION_CONFIDENCE_THRESHOLD", "0.65") or "0.65" ), followup_listen_timeout_ms=int(get("FOLLOWUP_LISTEN_TIMEOUT_MS", "3000") or "3000"), barge_in_enabled=(get("BARGE_IN_ENABLED", "1") or "1").lower() not in {"0", "false", "no"}, barge_in_min_speech_ms=int(get("BARGE_IN_MIN_SPEECH_MS", "250") or "250"), barge_in_echo_guard_ms=int(get("BARGE_IN_ECHO_GUARD_MS", "500") or "500"), barge_in_speaker_gate_enabled=(get("BARGE_IN_SPEAKER_GATE_ENABLED", "1") or "1").lower() not in {"0", "false", "no"}, barge_in_user_similarity_threshold=float( get("BARGE_IN_USER_SIMILARITY_THRESHOLD", "0.62") or "0.62" ), barge_in_assistant_reject_threshold=float( get("BARGE_IN_ASSISTANT_REJECT_THRESHOLD", "0.72") or "0.72" ), barge_in_listen_interval_ms=int(get("BARGE_IN_LISTEN_INTERVAL_MS", "20") or "20"), barge_in_chunk_ms=int(get("BARGE_IN_CHUNK_MS", "30") or "30"), barge_in_debug=(get("BARGE_IN_DEBUG", "0") or "0").lower() not in {"0", "false", "no"}, end_chime_enabled=(get("END_CHIME_ENABLED", "1") or "1").lower() not in {"0", "false", "no"}, end_chime_file=Path( get("END_CHIME_FILE", "assets/sounds/codex-notification.wav") or "assets/sounds/codex-notification.wav" ), end_chime_frequency_hz=int(get("END_CHIME_FREQUENCY_HZ", "880") or "880"), end_chime_duration_ms=int(get("END_CHIME_DURATION_MS", "140") or "140"), audio_apm_provider=(get("AUDIO_APM_PROVIDER", "webrtc") or "webrtc").lower(), audio_aec_enabled=get_bool("AUDIO_AEC_ENABLED", "1"), audio_ns_enabled=get_bool("AUDIO_NS_ENABLED", "1"), audio_agc_enabled=get_bool("AUDIO_AGC_ENABLED", "1"), audio_apm_required=get_bool("AUDIO_APM_REQUIRED", "1"), audio_frame_ms=int(get("AUDIO_FRAME_MS", "20") or "20"), audio_ring_buffer_ms=int(get("AUDIO_RING_BUFFER_MS", "3000") or "3000"), interrupt_enabled=get_bool("INTERRUPT_ENABLED", "1"), interrupt_target_latency_ms=int(get("INTERRUPT_TARGET_LATENCY_MS", "200") or "200"), streaming_stt_provider=( get("STREAMING_STT_PROVIDER", "faster_whisper") or "faster_whisper" ).lower(), streaming_stt_product_candidate=( get("STREAMING_STT_PRODUCT_CANDIDATE", "sensevoice") or "sensevoice" ).lower(), streaming_tts_provider=( get("STREAMING_TTS_PROVIDER", "cosyvoice") or "cosyvoice" ).lower(), memory_enabled=get_bool("MEMORY_ENABLED", "1"), memory_provider=(get("MEMORY_PROVIDER", "faiss_sqlite") or "faiss_sqlite").lower(), memory_top_k=int(get("MEMORY_TOP_K", "5") or "5"), memory_auto_save_sensitive=get_bool("MEMORY_AUTO_SAVE_SENSITIVE", "0"), tool_router_enabled=get_bool("TOOL_ROUTER_ENABLED", "1"), tool_max_calls_per_turn=int(get("TOOL_MAX_CALLS_PER_TURN", "5") or "5"), tool_timeout_ms=int(get("TOOL_TIMEOUT_MS", "30000") or "30000"), openinterpreter_enabled=get_bool("OPENINTERPRETER_ENABLED", "0"), openinterpreter_command=( get("OPENINTERPRETER_COMMAND", "openinterpreter") or "openinterpreter" ), browser_playwright_enabled=get_bool("BROWSER_PLAYWRIGHT_ENABLED", "0"), computer_control_enabled=get_bool("COMPUTER_CONTROL_ENABLED", "0"), ) @classmethod def from_env(cls, prefix: str = "OWNER_") -> "AppConfig": return cls.from_dotenv(".env", prefix=prefix) 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.assistant_mode not in {"turn_based_voice_pet", "full_duplex_agent"}: errors.append( ProviderError( ErrorCode.CONFIG_MISSING_VALUE, "OWNER_ASSISTANT_MODE must be turn_based_voice_pet or full_duplex_agent", False, "config", "startup", ) ) 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", ) ) if self.speech_provider not in {"cloud", "local"}: errors.append( ProviderError( ErrorCode.CONFIG_MISSING_VALUE, "OWNER_SPEECH_PROVIDER must be cloud or local", False, "config", "startup", ) ) if self.realtime_transcript_idle_timeout_ms < 0: errors.append( ProviderError( ErrorCode.CONFIG_MISSING_VALUE, "OWNER_REALTIME_TRANSCRIPT_IDLE_TIMEOUT_MS must be non-negative", False, "config", "startup", ) ) if self.wake_provider not in {"local_kws"}: errors.append( ProviderError( ErrorCode.CONFIG_MISSING_VALUE, "OWNER_WAKE_PROVIDER must be local_kws", False, "config", "startup", ) ) if self.wake_kws_threshold <= 0: errors.append( ProviderError( ErrorCode.CONFIG_MISSING_VALUE, "OWNER_WAKE_KWS_THRESHOLD must be positive", False, "config", "startup", ) ) if self.wake_kws_score <= 0: errors.append( ProviderError( ErrorCode.CONFIG_MISSING_VALUE, "OWNER_WAKE_KWS_SCORE must be positive", False, "config", "startup", ) ) if self.noise_filter_provider not in {"sherpa_onnx_gtcrn"}: errors.append( ProviderError( ErrorCode.CONFIG_MISSING_VALUE, "OWNER_NOISE_FILTER_PROVIDER must be sherpa_onnx_gtcrn", False, "config", "startup", ) ) if self.post_playback_drain_ms < 0: errors.append( ProviderError( ErrorCode.CONFIG_MISSING_VALUE, "OWNER_POST_PLAYBACK_DRAIN_MS must be non-negative", False, "config", "startup", ) ) if self.pipeline_mode not in {"live_turn_based"}: errors.append( ProviderError( ErrorCode.CONFIG_MISSING_VALUE, "OWNER_PIPELINE_MODE must be live_turn_based", False, "config", "startup", ) ) if self.endpoint_mode not in {"primary_speaker", "vad"}: errors.append( ProviderError( ErrorCode.CONFIG_MISSING_VALUE, "OWNER_ENDPOINT_MODE must be primary_speaker or vad", False, "config", "startup", ) ) for name, value in { "OWNER_SPEAKER_PROFILE_MS": self.speaker_profile_ms, "OWNER_SPEAKER_PROFILE_MIN_MS": self.speaker_profile_min_ms, "OWNER_SPEAKER_ABSENT_MS": self.speaker_absent_ms, }.items(): if value <= 0: errors.append( ProviderError( ErrorCode.CONFIG_MISSING_VALUE, f"{name} must be positive", False, "config", "startup", ) ) if not 0 < self.speaker_similarity_threshold <= 1: errors.append( ProviderError( ErrorCode.CONFIG_MISSING_VALUE, "OWNER_SPEAKER_SIMILARITY_THRESHOLD must be in (0, 1]", False, "config", "startup", ) ) if self.speaker_min_rms <= 0: errors.append( ProviderError( ErrorCode.CONFIG_MISSING_VALUE, "OWNER_SPEAKER_MIN_RMS must be positive", False, "config", "startup", ) ) if self.vad_provider not in {"hybrid", "local", "energy"}: errors.append( ProviderError( ErrorCode.CONFIG_MISSING_VALUE, "OWNER_VAD_PROVIDER must be hybrid, local, or energy", False, "config", "startup", ) ) if self.vad_threshold <= 0: errors.append( ProviderError( ErrorCode.CONFIG_MISSING_VALUE, "OWNER_VAD_THRESHOLD must be positive", False, "config", "startup", ) ) for name, value in { "OWNER_VAD_MIN_DURATION_MS": self.vad_min_duration_ms, "OWNER_VAD_END_SILENCE_MS": self.vad_end_silence_ms, "OWNER_VAD_NO_SPEECH_TIMEOUT_MS": self.vad_no_speech_timeout_ms, "OWNER_VAD_MAX_RECORDING_MS": self.vad_max_recording_ms, }.items(): if value <= 0: errors.append( ProviderError( ErrorCode.CONFIG_MISSING_VALUE, f"{name} must be positive", False, "config", "startup", ) ) if not self.llm_base_url.startswith(("http://", "https://")): errors.append( ProviderError( ErrorCode.CONFIG_MISSING_VALUE, "OWNER_LLM_BASE_URL must start with http:// or https://", False, "config", "startup", ) ) if self.context_mode not in {"session_memory"}: errors.append( ProviderError( ErrorCode.CONFIG_MISSING_VALUE, "OWNER_CONTEXT_MODE must be session_memory", False, "config", "startup", ) ) if self.continuation_decision_provider not in {"hybrid", "rule", "llm"}: errors.append( ProviderError( ErrorCode.CONFIG_MISSING_VALUE, "OWNER_CONTINUATION_DECISION_PROVIDER must be hybrid, rule, or llm", False, "config", "startup", ) ) if not 0 < self.continuation_confidence_threshold <= 1: errors.append( ProviderError( ErrorCode.CONFIG_MISSING_VALUE, "OWNER_CONTINUATION_CONFIDENCE_THRESHOLD must be in (0, 1]", False, "config", "startup", ) ) for name, value in { "OWNER_FOLLOWUP_LISTEN_TIMEOUT_MS": self.followup_listen_timeout_ms, "OWNER_BARGE_IN_MIN_SPEECH_MS": self.barge_in_min_speech_ms, "OWNER_BARGE_IN_ECHO_GUARD_MS": self.barge_in_echo_guard_ms, }.items(): if value < 0: errors.append( ProviderError( ErrorCode.CONFIG_MISSING_VALUE, f"{name} must be non-negative", False, "config", "startup", ) ) for name, value in { "OWNER_BARGE_IN_LISTEN_INTERVAL_MS": self.barge_in_listen_interval_ms, "OWNER_BARGE_IN_CHUNK_MS": self.barge_in_chunk_ms, }.items(): if value <= 0: errors.append( ProviderError( ErrorCode.CONFIG_MISSING_VALUE, f"{name} must be positive", False, "config", "startup", ) ) for name, value in { "OWNER_BARGE_IN_USER_SIMILARITY_THRESHOLD": self.barge_in_user_similarity_threshold, "OWNER_BARGE_IN_ASSISTANT_REJECT_THRESHOLD": self.barge_in_assistant_reject_threshold, }.items(): if not 0 < value <= 1: errors.append( ProviderError( ErrorCode.CONFIG_MISSING_VALUE, f"{name} must be in (0, 1]", False, "config", "startup", ) ) for name, value in { "OWNER_END_CHIME_FREQUENCY_HZ": self.end_chime_frequency_hz, "OWNER_END_CHIME_DURATION_MS": self.end_chime_duration_ms, "OWNER_AUDIO_FRAME_MS": self.audio_frame_ms, "OWNER_AUDIO_RING_BUFFER_MS": self.audio_ring_buffer_ms, "OWNER_INTERRUPT_TARGET_LATENCY_MS": self.interrupt_target_latency_ms, "OWNER_MEMORY_TOP_K": self.memory_top_k, "OWNER_TOOL_MAX_CALLS_PER_TURN": self.tool_max_calls_per_turn, "OWNER_TOOL_TIMEOUT_MS": self.tool_timeout_ms, }.items(): if value <= 0: errors.append( ProviderError( ErrorCode.CONFIG_MISSING_VALUE, f"{name} must be positive", False, "config", "startup", ) ) if self.audio_apm_provider not in {"webrtc", "fake", "disabled"}: errors.append( ProviderError( ErrorCode.CONFIG_MISSING_VALUE, "OWNER_AUDIO_APM_PROVIDER must be webrtc, fake, or disabled", False, "config", "startup", ) ) if self.streaming_stt_provider not in {"faster_whisper", "sensevoice", "sherpa_onnx"}: errors.append( ProviderError( ErrorCode.CONFIG_MISSING_VALUE, "OWNER_STREAMING_STT_PROVIDER must be faster_whisper, sensevoice, or sherpa_onnx", False, "config", "startup", ) ) if self.streaming_stt_product_candidate not in {"sensevoice", "faster_whisper", "sherpa_onnx"}: errors.append( ProviderError( ErrorCode.CONFIG_MISSING_VALUE, "OWNER_STREAMING_STT_PRODUCT_CANDIDATE must be sensevoice, faster_whisper, or sherpa_onnx", False, "config", "startup", ) ) if self.streaming_tts_provider not in {"cosyvoice", "macos_say"}: errors.append( ProviderError( ErrorCode.CONFIG_MISSING_VALUE, "OWNER_STREAMING_TTS_PROVIDER must be cosyvoice or macos_say", False, "config", "startup", ) ) if self.memory_provider not in {"faiss_sqlite"}: errors.append( ProviderError( ErrorCode.CONFIG_MISSING_VALUE, "OWNER_MEMORY_PROVIDER must be faiss_sqlite", False, "config", "startup", ) ) if self.openinterpreter_enabled and not self.openinterpreter_command.strip(): errors.append( ProviderError( ErrorCode.CONFIG_MISSING_VALUE, "OWNER_OPENINTERPRETER_COMMAND must be non-empty when Open Interpreter is enabled", False, "config", "startup", ) ) return errors def api_url(self, path: str) -> str: normalized = path if path.startswith("/") else f"/{path}" base = self.llm_base_url.rstrip("/") if base.endswith("/v1") and normalized.startswith("/v1/"): return base + normalized[3:] return base + normalized def parse_dotenv(path: Path) -> dict[str, str]: if not path.exists(): return {} values: dict[str, str] = {} for line_no, raw_line in enumerate(path.read_text(encoding="utf-8").splitlines(), start=1): line = raw_line.strip() if not line or line.startswith("#"): continue if line.startswith("export "): line = line.removeprefix("export ").strip() if "=" not in line: raise ValueError(f"invalid .env line {line_no}: missing '='") key, value = line.split("=", 1) key = key.strip() value = _strip_dotenv_value(value.strip()) if not key: raise ValueError(f"invalid .env line {line_no}: empty key") values[key] = value return values def _strip_dotenv_value(value: str) -> str: if len(value) >= 2 and value[0] == value[-1] and value[0] in {"'", '"'}: return value[1:-1] if " #" in value: return value.split(" #", 1)[0].rstrip() return value