#!/usr/bin/env python3 """헤이 자비스 — 호출어(openWakeWord/Porcupine) + Google STT + Gemini + TTS 호출어 ------ - WAKE_MODE=jarvis → openWakeWord (영어, jarvis.env OWW_WAKE_MODEL) 예: alexa(쉬움), hey_jarvis, hey_mycroft, timer, weather - WAKE_MODE=korean → Picovoice Porcupine 한국어 (로컬, STT 호출어 없음) 필요: jarvis.env 의 PICOVOICE_ACCESS_KEY 모델/키워드: ~/jarvis_porcupine/ (없으면 Access Key로 .ppn 자동 생성) 영구 메모리 (로컬 파일, 웹 제미나이 기억과 무관) ---------------------------------------------- 파일: ~/projects/jarvis/jarvis_memory.json 명령: 기억해 아들은 맵기 싫어해 → 한 줄 저장 (말투/사실 모두 가능) 잊어 맵기 → 그 단어 들어간 기억 삭제 기억 뭐 있어? / 기억 목록 → 저장된 목록 읽기 기억 다 지워 → 전체 삭제 질문할 때마다 system 지침에 붙여 보내므로 입력 토큰에 포함됨(짧게 유지). 대화 로그 (질문/답변 분리) -------------------------- - ~/jarvis_logs/questions.json - ~/jarvis_logs/answers.json 동일 turn_id 로 짝을 맞출 수 있음. API / 문서 --------- - Gemini: https://ai.google.dev/gemini-api/docs 키: https://aistudio.google.com/apikey 요금: https://ai.google.dev/gemini-api/docs/pricing - Porcupine: https://console.picovoice.ai/ (Access Key) - STT: SpeechRecognition Google Web Speech(무료) 또는 Cloud Speech-to-Text 전환: ~/projects/jarvis/jarvis_runtime.json 의 "stt_backend": "web" | "cloud" (재시작 불필요) - TTS: gemini-2.5-flash-preview-tts (기본) 또는 edge-tts (무료 백업) """ from __future__ import annotations import asyncio import audioop import base64 import contextlib import html import io import json import logging import os import re import subprocess import threading import time import uuid import urllib.error import urllib.parse import urllib.request import wave from concurrent.futures import Future, ThreadPoolExecutor from datetime import datetime, timezone from ctypes import CFUNCTYPE, c_char_p, c_int, cdll os.environ["PYGAME_HIDE_SUPPORT_PROMPT"] = "hide" import warnings warnings.filterwarnings("ignore", category=FutureWarning) warnings.filterwarnings("ignore", message=".*automatic function calling.*") import edge_tts import google.generativeai as genai from google import genai as genai_client from google.genai import types as genai_types import numpy as np import pyaudio import pygame import speech_recognition as sr from openwakeword.model import Model # ALSA 경고 숨기기 (콜백은 GC되면 segfault 나므로 전역으로 유지) ERROR_HANDLER_FUNC = CFUNCTYPE(None, c_char_p, c_int, c_char_p, c_int, c_char_p) _alsa_error_handler = None def _py_error_handler(filename, line, function, err, fmt): pass try: asound = cdll.LoadLibrary("libasound.so.2") _alsa_error_handler = ERROR_HANDLER_FUNC(_py_error_handler) asound.snd_lib_error_set_handler(_alsa_error_handler) except Exception: pass # ========================================== # 설정 (~/projects/jarvis/jarvis.env 가 우선) # ========================================== BASE_DIR = os.path.dirname(os.path.abspath(__file__)) or "." ENV_PATH = os.path.join(BASE_DIR, "jarvis.env") MEMORY_PATH = os.path.join(BASE_DIR, "jarvis_memory.json") MEMORY_MAX = 50 HOOKS_DIR = os.path.join(BASE_DIR, "jarvis_hooks") LOG_DIR = os.path.join(BASE_DIR, "jarvis_logs") QUESTIONS_LOG = os.path.join(LOG_DIR, "questions.json") ANSWERS_LOG = os.path.join(LOG_DIR, "answers.json") USAGE_SUMMARY_PATH = os.path.join(LOG_DIR, "usage_summary.json") RUNTIME_CONFIG_PATH = os.path.join(BASE_DIR, "jarvis_runtime.json") _runtime_mtime: float = 0.0 def load_env_file(path: str) -> None: """KEY=VALUE 형식. jarvis.env → os.environ.""" if not os.path.isfile(path): return with open(path, encoding="utf-8") as f: for raw in f: line = raw.strip() if not line or line.startswith("#") or "=" not in line: continue key, val = line.split("=", 1) key = key.strip() val = val.strip().strip('"').strip("'") if key: os.environ[key] = val load_env_file(ENV_PATH) def _runtime_defaults() -> dict: return { "_help": "수정 저장하면 재시작 없이 자동 적용됩니다. stt_backend: web(무료) | cloud(유료 Cloud STT).", "wake_threshold": float(os.environ.get("WAKE_THRESHOLD", "0.27")), "wake_min_frames": int(os.environ.get("WAKE_MIN_FRAMES", "2")), "wake_strong_score": float(os.environ.get("WAKE_STRONG_SCORE", "0.52")), "min_wake_interval_sec": float(os.environ.get("MIN_WAKE_INTERVAL_SEC", "10")), "stt_pause_threshold": float(os.environ.get("STT_PAUSE_THRESHOLD", "0.5")), "stt_non_speaking_sec": float(os.environ.get("STT_NON_SPEAKING_SEC", "0.5")), "stt_mic_gain": float(os.environ.get("STT_MIC_GAIN", "1.35")), "stt_energy_threshold": float(os.environ.get("STT_ENERGY_THRESHOLD", "280")), "stt_phrase_limit_sec": float(os.environ.get("STT_PHRASE_LIMIT_SEC", "12")), "stt_max_record_sec": float(os.environ.get("STT_MAX_RECORD_SEC", "15")), "stt_min_pause_sec": float(os.environ.get("STT_MIN_PAUSE_SEC", "1.0")), "stt_silence_peak_ratio": float(os.environ.get("STT_SILENCE_PEAK_RATIO", "0.85")), "stt_speech_start_ratio": float(os.environ.get("STT_SPEECH_START_RATIO", "1.35")), "stt_phrase_threshold": float(os.environ.get("STT_PHRASE_THRESHOLD", "0.05")), "stt_timeout_sec": float(os.environ.get("STT_TIMEOUT_SEC", "5")), "chat_history_max": int(os.environ.get("CHAT_HISTORY_MAX", "6")), "stt_backend": os.environ.get("STT_BACKEND", "web").strip().lower() or "web", } _RUNTIME_FLOAT = { "wake_threshold": "WAKE_THRESHOLD", "wake_strong_score": "WAKE_STRONG_SCORE", "min_wake_interval_sec": "MIN_WAKE_INTERVAL_SEC", "stt_pause_threshold": "STT_PAUSE_THRESHOLD", "stt_non_speaking_sec": "STT_NON_SPEAKING_SEC", "stt_mic_gain": "STT_MIC_GAIN", "stt_energy_threshold": "STT_ENERGY_THRESHOLD", "stt_phrase_limit_sec": "STT_PHRASE_LIMIT_SEC", "stt_max_record_sec": "STT_MAX_RECORD_SEC", "stt_min_pause_sec": "STT_MIN_PAUSE_SEC", "stt_silence_peak_ratio": "STT_SILENCE_PEAK_RATIO", "stt_speech_start_ratio": "STT_SPEECH_START_RATIO", "stt_phrase_threshold": "STT_PHRASE_THRESHOLD", "stt_timeout_sec": "STT_TIMEOUT_SEC", } _RUNTIME_INT = { "wake_min_frames": "WAKE_MIN_FRAMES", "chat_history_max": "CHAT_HISTORY_MAX", } _RUNTIME_STR = { "stt_backend": "STT_BACKEND", } def ensure_runtime_config_file() -> None: defaults = _runtime_defaults() if not os.path.isfile(RUNTIME_CONFIG_PATH): with open(RUNTIME_CONFIG_PATH, "w", encoding="utf-8") as f: json.dump(defaults, f, ensure_ascii=False, indent=2) print(f"실시간 설정 파일 생성: {RUNTIME_CONFIG_PATH}") return try: with open(RUNTIME_CONFIG_PATH, encoding="utf-8") as f: data = json.load(f) except (OSError, json.JSONDecodeError): return if not isinstance(data, dict): return missing = {k: v for k, v in defaults.items() if k not in data} if not missing: return data.update(missing) with open(RUNTIME_CONFIG_PATH, "w", encoding="utf-8") as f: json.dump(data, f, ensure_ascii=False, indent=2) print(f"실시간 설정 키 추가: {', '.join(missing)}") def load_runtime_config_if_changed(verbose: bool = False) -> bool: """jarvis_runtime.json 변경 시 즉시 적용 (재시작 불필요).""" global _runtime_mtime if not os.path.isfile(RUNTIME_CONFIG_PATH): return False try: mtime = os.path.getmtime(RUNTIME_CONFIG_PATH) except OSError: return False if mtime == _runtime_mtime: return False try: with open(RUNTIME_CONFIG_PATH, encoding="utf-8") as f: data = json.load(f) except (OSError, json.JSONDecodeError) as e: print(f"jarvis_runtime.json 읽기 실패: {e}") return False if not isinstance(data, dict): return False applied: list[str] = [] g = globals() for key, var in _RUNTIME_FLOAT.items(): if key in data and data[key] is not None: g[var] = float(data[key]) applied.append(f"{key}={g[var]}") for key, var in _RUNTIME_INT.items(): if key in data and data[key] is not None: g[var] = int(data[key]) applied.append(f"{key}={g[var]}") if "chat_history_max" in data and data["chat_history_max"] is not None: max_h = int(data["chat_history_max"]) if len(_session_history) > max_h: _session_history[:] = _session_history[-max_h:] for key, var in _RUNTIME_STR.items(): if key in data and data[key] is not None: val = str(data[key]).strip().lower() g[var] = val applied.append(f"{key}={val}") _runtime_mtime = mtime if verbose and applied: print(f"[실시간 설정 적용] {', '.join(applied)}") return True GOOGLE_API_KEY = os.environ.get( "GOOGLE_API_KEY", "AIzaSyBHMoObQ_yYW2njaGenc3_j4CuCIFAaadA", ) GEMINI_MODEL = os.environ.get("GEMINI_MODEL", "gemini-2.5-flash") GEMINI_MAX_OUTPUT_TOKENS = int(os.environ.get("GEMINI_MAX_OUTPUT_TOKENS", "512")) GEMINI_VOICE_MAX_TOKENS = int(os.environ.get("GEMINI_VOICE_MAX_TOKENS", "256")) GEMINI_STREAM = os.environ.get("GEMINI_STREAM", "1") == "1" TTS_CHUNK_CHARS = int(os.environ.get("TTS_CHUNK_CHARS", "180")) PRICE_INPUT_PER_M = 0.30 PRICE_OUTPUT_PER_M = 2.50 USD_KRW = float(os.environ.get("USD_KRW", "1400")) WAKE_MODE = os.environ.get("WAKE_MODE", "jarvis").strip().lower() LANG_MODE = os.environ.get("LANG_MODE", "ko").strip().lower() WAKE_THRESHOLD = float(os.environ.get("WAKE_THRESHOLD", "0.27")) WAKE_MIN_FRAMES = int(os.environ.get("WAKE_MIN_FRAMES", "2")) WAKE_STRONG_SCORE = float(os.environ.get("WAKE_STRONG_SCORE", "0.52")) def _is_oww_model_path(spec: str) -> bool: return "/" in spec or spec.endswith((".tflite", ".onnx")) _oww_raw = os.environ.get("OWW_WAKE_MODEL", "alexa").strip() _oww_parts = [p.strip() for p in _oww_raw.split(",") if p.strip()] OWW_WAKE_PATHS: list[str] = [] OWW_WAKE_LABELS: list[str] = [] for _spec in _oww_parts: if _is_oww_model_path(_spec): OWW_WAKE_PATHS.append(_spec) OWW_WAKE_LABELS.append(os.path.splitext(os.path.basename(_spec))[0]) else: name = _spec.lower() OWW_WAKE_PATHS.append(name) OWW_WAKE_LABELS.append(name) OWW_INFERENCE_FRAMEWORK = os.environ.get("OWW_INFERENCE_FRAMEWORK", "").strip().lower() if not OWW_INFERENCE_FRAMEWORK: OWW_INFERENCE_FRAMEWORK = ( "tflite" if any(s.endswith(".tflite") for s in _oww_parts if _is_oww_model_path(s)) else "onnx" ) OWW_WAKE_HINT = os.environ.get("OWW_WAKE_HINT", "").strip() _mic_env = os.environ.get("STT_MIC_INDEX", os.environ.get("MIC_INDEX", "")).strip() MIC_INDEX = int(_mic_env) if _mic_env else None STT_MIC_INDEX: int | None = MIC_INDEX SPEAKER_VOLUME = 100 WAKE_FRAME = 1280 WAKE_DEBUG = os.environ.get("WAKE_DEBUG", "0") == "1" WAKE_COOLDOWN_SEC = float(os.environ.get("WAKE_COOLDOWN_SEC", "3.0")) MIN_WAKE_INTERVAL_SEC = float(os.environ.get("MIN_WAKE_INTERVAL_SEC", "10.0")) LISTEN_FAIL_COOLDOWN_SEC = float(os.environ.get("LISTEN_FAIL_COOLDOWN_SEC", "5.0")) _last_wake_at = 0.0 STT_TIMEOUT_SEC = float(os.environ.get("STT_TIMEOUT_SEC", "5")) STT_PHRASE_LIMIT_SEC = float(os.environ.get("STT_PHRASE_LIMIT_SEC", "12")) STT_MAX_RECORD_SEC = float(os.environ.get("STT_MAX_RECORD_SEC", "15")) STT_MIN_PAUSE_SEC = float(os.environ.get("STT_MIN_PAUSE_SEC", "1.0")) STT_SILENCE_PEAK_RATIO = float(os.environ.get("STT_SILENCE_PEAK_RATIO", "0.85")) STT_SPEECH_START_RATIO = float(os.environ.get("STT_SPEECH_START_RATIO", "1.35")) STT_SMART_END = os.environ.get("STT_SMART_END", "1") == "1" STT_PHRASE_THRESHOLD = float(os.environ.get("STT_PHRASE_THRESHOLD", "0.05")) STT_PAUSE_THRESHOLD = float(os.environ.get("STT_PAUSE_THRESHOLD", "1.0")) STT_NON_SPEAKING_SEC = float(os.environ.get("STT_NON_SPEAKING_SEC", "0.55")) STT_NOISY_THRESHOLD = float(os.environ.get("STT_NOISY_THRESHOLD", "380")) STT_AMBIENT_SEC = float(os.environ.get("STT_AMBIENT_SEC", "0.3")) STT_POST_BEEP_DELAY_SEC = float(os.environ.get("STT_POST_BEEP_DELAY_SEC", "0.15")) STT_RECAL_BEFORE_LISTEN = os.environ.get("STT_RECAL_BEFORE_LISTEN", "0") == "1" STT_RETRY_MAX = int(os.environ.get("STT_RETRY_MAX", "1")) STT_MIC_GAIN = float(os.environ.get("STT_MIC_GAIN", "1.35")) STT_ENERGY_THRESHOLD = float(os.environ.get("STT_ENERGY_THRESHOLD", "280")) STT_QUIET_MODE = os.environ.get("STT_QUIET_MODE", "1") == "1" STT_BACKEND = os.environ.get("STT_BACKEND", "web").strip().lower() or "web" STT_CLOUD_MODEL = os.environ.get("STT_CLOUD_MODEL", "command_and_search").strip() STT_CLOUD_FALLBACK = os.environ.get("STT_CLOUD_FALLBACK", "1") == "1" CHAT_HISTORY_MAX = int(os.environ.get("CHAT_HISTORY_MAX", "6")) PULSE_SOURCE = os.environ.get("PULSE_SOURCE", "").strip() YT_DLP_BIN = os.environ.get( "YT_DLP_BIN", os.path.join(BASE_DIR, "jarvis_env", "bin", "yt-dlp") ) YOUTUBE_API_KEY = ( os.environ.get("YOUTUBE_API_KEY", "").strip() or GOOGLE_API_KEY.strip() ) YOUTUBE_SEARCH_MODE = os.environ.get("YOUTUBE_SEARCH_MODE", "auto").strip().lower() OWW_WAKE_HINTS = { "alexa": "「Alexa」(알렉사)", "hey_jarvis": "「Hey Jarvis」", "hey_mycroft": "「Hey Mycroft」", "hey_rhasspy": "「Hey Rhasspy」", "timer": "「timer」", "weather": "「weather」", } PICOVOICE_ACCESS_KEY = os.environ.get("PICOVOICE_ACCESS_KEY", "").strip() PORCUPINE_DIR = os.environ.get( "PORCUPINE_DIR", os.path.join(BASE_DIR, "jarvis_porcupine") ) PORCUPINE_MODEL_PATH = os.environ.get( "PORCUPINE_MODEL_PATH", os.path.join(PORCUPINE_DIR, "porcupine_params_ko.pv"), ) PORCUPINE_KEYWORD_PATH = os.environ.get("PORCUPINE_KEYWORD_PATH", "").strip() PORCUPINE_PHRASES = os.environ.get("PORCUPINE_PHRASES", "자비스,헤이 자비스") PORCUPINE_SENSITIVITY = float(os.environ.get("PORCUPINE_SENSITIVITY", "0.55")) PORCUPINE_LANGUAGE = os.environ.get("PORCUPINE_LANGUAGE", "ko").strip().lower() PORCUPINE_MODEL_URL = ( "https://github.com/Picovoice/porcupine/raw/master/lib/common/" "porcupine_params_ko.pv" ) if LANG_MODE.startswith("en"): STT_LANGUAGE = "en-US" DEFAULT_EDGE_TTS_VOICE = "en-US-JennyNeural" DEFAULT_GEMINI_TTS_VOICE = "Kore" BASE_INSTRUCTION = ( "You are Jarvis. Answer clearly and briefly in English. " "Help with cooking, schedule, and daily questions. " "Use [permanent memory] when present." ) else: STT_LANGUAGE = "ko-KR" DEFAULT_EDGE_TTS_VOICE = "ko-KR-SunHiNeural" DEFAULT_GEMINI_TTS_VOICE = "Kore" BASE_INSTRUCTION = ( "너는 자비스야. 사용자는 마이크로 말했고 입력은 음성인식 결과야. " "소리를 못 듣거나 글자로만 대화한다고 말하지 마. " "한국어로 짧고 명확하게 대답해. " "말투는 나긋하고 부드럽게. " "답변은 말로 읽기 좋게 2~4문장으로 끝맺음까지 완결하게. " "레시피도 4~5줄 이내지만 중간에 끊지 말고 끝까지. " "불필요한 인사·서론(알겠습니다 등) 생략하고 본론부터. " "날씨는 기온·맑음·비 정도만 한두 문장으로 끝까지. " "실시간 조회 불가 설명은 생략하고 추정만 짧게. " "요리·일정·일상 질문에 실용적으로 도와줘. " "유튜브·노래 재생은 로컬에서 처리된다. " "노래를 찾았다/못 찾았다거나 유튜브를 검색했다고 말하지 마. " "재생은 사용자가 「OOO 틀어줘」라고 말하면 된다고만 짧게 안내해. " "아래 [영구 기억]이 있으면 사실·말투·취향을 반드시 반영해. " "이전 대화 맥락이 함께 전달되면 반드시 참고해. " "집 기기는 도구로만 제어한다. 잡담·지식(사자 호랑이 등)은 도구 없이 답한다. " "한 문장에 장면이면 도구를 여러 개 호출해도 된다. " "드라마·영화 볼게 → 티비 ON + 거실/메인 조명 OFF. " "잘게·자야지 → 관련 조명 OFF, 켜져 있으면 티비도 OFF. " "더워·땀나·미치겠네(집 맥락) → 에어컨 turn_on 후 통보. " "절대 되묻지 마(켤까요 금지). 애매한 장난·투정은 기기 건드리지 말고 말로만. " "꺼·켜·맞춰면 명령이다. 방 이름 없으면 거실 기본 에어컨·조명·티비. " "스냅샷에 없거나 스위치 꺼진 기기는 만들지 말고 없다고만 말해. " "티비는 전원만(채널·앱 검색 금지). 실행 결과를 한두 문장으로 말해." ) if WAKE_MODE in ("korean", "ko", "hangul", "한글", "porcupine"): WAKE_PROMPT = "'자비스' 또는 '헤이 자비스'라고 불러보세요." else: if OWW_WAKE_HINT: wake_hint = f"「{OWW_WAKE_HINT}」" else: hints = [OWW_WAKE_HINTS.get(m, m) for m in OWW_WAKE_LABELS] wake_hint = " 또는 ".join(hints) WAKE_PROMPT = ( wake_hint + "라고 **한 번만** 불러요. 삐 소리 후 바로 질문. " "연속으로 부르면 삐만 여러 번 나와요." ) TTS_BACKEND = os.environ.get("TTS_BACKEND", "edge").strip().lower() TTS_MODEL = os.environ.get("TTS_MODEL", "gemini-2.5-flash-preview-tts") TTS_VOICE = os.environ.get( "TTS_VOICE", DEFAULT_GEMINI_TTS_VOICE if TTS_BACKEND == "gemini" else DEFAULT_EDGE_TTS_VOICE, ) TTS_RATE = os.environ.get("TTS_RATE", "+10%") TTS_PITCH = os.environ.get("TTS_PITCH", "+0Hz") TTS_STYLE = os.environ.get( "TTS_STYLE", "나긋하고 부드럽게, 조금 빠른 속도로 말해줘:", ) TTS_SAMPLE_RATE = int(os.environ.get("TTS_SAMPLE_RATE", "24000")) TTS_PLAY_LATENCY_MS = int(os.environ.get("TTS_PLAY_LATENCY_MS", "40")) _gemini_tts_client: genai_client.Client | None = None _gemini_text_client: genai_client.Client | None = None if not GOOGLE_API_KEY or "여기에_" in GOOGLE_API_KEY: raise SystemExit( "제미나이 API 키를 설정하세요.\n" " 1) jarvis.env 에 GOOGLE_API_KEY=... 또는\n" " 2) jarvis.py / export GOOGLE_API_KEY" ) genai.configure(api_key=GOOGLE_API_KEY) logging.getLogger("google.genai").setLevel(logging.ERROR) model = None # type: ignore chat = None # type: ignore _session_history: list[tuple[str, str]] = [] _stt_ambient_calibrated = False _stt_noisy_background = False _mixer_ready = False PULSE_SINK = os.environ.get("PULSE_SINK", "").strip() _media_proc: subprocess.Popen | None = None _media_lock = threading.RLock() _youtube_pending: tuple[str, str | None] | None = None _tts_executor = ThreadPoolExecutor(max_workers=1, thread_name_prefix="jarvis-tts") _speech_active = False _speech_interruptible = False _speech_interrupt = threading.Event() _pending_interrupt_listen = False _tts_play_proc: subprocess.Popen | None = None _tts_play_lock = threading.Lock() _wake_interrupt_ctx: dict = {} _interrupt_watcher_stop = threading.Event() _wake_mic_paused = False def _pause_wake_mic(mic_stream) -> bool: """STT용 마이크 전환 — wake 감시 스레드가 읽지 않게.""" global _wake_mic_paused try: if not mic_stream.is_stopped(): mic_stream.stop_stream() _wake_mic_paused = True return True except OSError: _wake_mic_paused = True return False def _restart_wake_mic(mic_stream) -> bool: """호출어·답변 중 끊기 감시용 wake 마이크 재개.""" global _wake_mic_paused try: if mic_stream.is_stopped(): mic_stream.start_stream() _wake_mic_paused = False return True except OSError as e: print(f"[마이크] wake 스트림 복구 실패: {e}") ctx = _wake_interrupt_ctx audio = ctx.get("audio") if audio is None: _wake_mic_paused = True return False try: try: mic_stream.close() except OSError: pass new_stream, new_rate = open_mic_stream(audio) ctx["mic_stream"] = new_stream ctx["mic_rate"] = new_rate ctx["chunk"] = max(512, int(new_rate * 0.08)) _wake_mic_paused = False print("[마이크] wake 스트림 재오픈") return True except OSError as e2: print(f"[마이크] wake 스트림 재오픈 실패: {e2}") _wake_mic_paused = True return False def _stop_tts_playback() -> None: """TTS paplay/pygame 중지.""" global _tts_play_proc with _tts_play_lock: proc = _tts_play_proc _tts_play_proc = None if proc is not None and proc.poll() is None: proc.terminate() try: proc.wait(timeout=0.5) except subprocess.TimeoutExpired: proc.kill() subprocess.run( ["pkill", "-x", "paplay"], check=False, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, ) try: if _mixer_ready: pygame.mixer.music.stop() except Exception: pass def request_speech_interrupt() -> None: _speech_interrupt.set() _stop_tts_playback() _stop_media_playback() def _begin_speech(interruptible: bool = True) -> None: global _speech_active, _speech_interruptible _speech_interrupt.clear() _speech_active = True _speech_interruptible = interruptible def _end_speech() -> None: global _speech_active, _speech_interruptible _speech_active = False _speech_interruptible = False _speech_interrupt.clear() def _play_audio_file(path: str) -> bool: """3.5mm 등 PULSE_SINK로 재생. 중단 시 False.""" if _speech_interrupt.is_set(): return False env = os.environ.copy() if PULSE_SINK: env["PULSE_SINK"] = PULSE_SINK paplay_cmd = [ "paplay", f"--latency-msec={max(10, TTS_PLAY_LATENCY_MS)}", path, ] try: proc = subprocess.Popen( paplay_cmd, env=env, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, ) with _tts_play_lock: global _tts_play_proc _tts_play_proc = proc while proc.poll() is None: if _speech_interrupt.is_set(): proc.terminate() try: proc.wait(timeout=0.5) except subprocess.TimeoutExpired: proc.kill() return False time.sleep(0.05) with _tts_play_lock: if _tts_play_proc is proc: _tts_play_proc = None if proc.returncode != 0: raise subprocess.CalledProcessError(proc.returncode, paplay_cmd) return not _speech_interrupt.is_set() except (OSError, subprocess.CalledProcessError) as e: print(f"paplay 실패, pygame으로 대체: {e}") if _speech_interrupt.is_set(): return False _ensure_mixer() pygame.mixer.music.set_volume(1.0) pygame.mixer.music.load(path) pygame.mixer.music.play() while pygame.mixer.music.get_busy(): if _speech_interrupt.is_set(): pygame.mixer.music.stop() pygame.mixer.music.unload() return False time.sleep(0.05) pygame.mixer.music.unload() return not _speech_interrupt.is_set() def _ensure_mixer() -> None: """pygame mixer는 마이크/ONNX와 충돌할 수 있어 TTS 직전에만 초기화.""" global _mixer_ready if _mixer_ready: return pygame.mixer.init() _mixer_ready = True # ---------- 대화 로그 (질문/답변 분리 JSON) ---------- def _read_json_list(path: str) -> list: if not os.path.isfile(path): return [] try: data = json.load(open(path, encoding="utf-8")) return data if isinstance(data, list) else [] except (OSError, json.JSONDecodeError, TypeError): return [] def _append_json_list(path: str, item: dict) -> None: os.makedirs(os.path.dirname(path), exist_ok=True) items = _read_json_list(path) items.append(item) tmp = path + ".tmp" with open(tmp, "w", encoding="utf-8") as f: json.dump(items, f, ensure_ascii=False, indent=2) f.write("\n") os.replace(tmp, path) def log_question( text: str, *, source: str = "voice", turn_id: str | None = None, stt_engine: str | None = None, ) -> str: """질문을 questions.json 에 추가. turn_id 반환.""" tid = turn_id or uuid.uuid4().hex[:12] row = { "id": tid, "time": datetime.now(timezone.utc).astimezone().isoformat(timespec="seconds"), "text": text, "source": source, } if stt_engine: row["stt_engine"] = stt_engine _append_json_list(QUESTIONS_LOG, row) return tid def log_answer( text: str, *, turn_id: str, kind: str = "gemini", extra: dict | None = None, usage: dict | None = None, ) -> None: """답변을 answers.json 에 추가 (질문 id 와 연결).""" row = { "id": uuid.uuid4().hex[:12], "turn_id": turn_id, "time": datetime.now(timezone.utc).astimezone().isoformat(timespec="seconds"), "text": text, "kind": kind, # gemini | memory | device } if usage: row["usage"] = usage if extra: row["extra"] = extra _append_json_list(ANSWERS_LOG, row) if kind == "gemini" and usage: _increment_gemini_turn_count() def _empty_usage_bucket() -> dict: return { "prompt_tokens": 0, "output_tokens": 0, "total_tokens": 0, "usd": 0.0, "krw": 0.0, "api_calls": 0, } def _usage_from_genai_response(response) -> dict | None: usage = getattr(response, "usage_metadata", None) if not usage: return None pin = int(getattr(usage, "prompt_token_count", 0) or 0) pout = int(getattr(usage, "candidates_token_count", 0) or 0) total = int(getattr(usage, "total_token_count", 0) or (pin + pout)) usd = estimate_cost_usd(pin, pout) krw = usd * USD_KRW return { "prompt_tokens": pin, "output_tokens": pout, "total_tokens": total, "usd": round(usd, 6), "krw": round(krw, 2), } def _merge_usage_bucket(dest: dict, src: dict) -> None: dest["prompt_tokens"] = int(dest.get("prompt_tokens", 0)) + int(src.get("prompt_tokens", 0)) dest["output_tokens"] = int(dest.get("output_tokens", 0)) + int(src.get("output_tokens", 0)) dest["total_tokens"] = int(dest.get("total_tokens", 0)) + int(src.get("total_tokens", 0)) dest["usd"] = round(float(dest.get("usd", 0)) + float(src.get("usd", 0)), 6) dest["krw"] = round(float(dest.get("krw", 0)) + float(src.get("krw", 0)), 2) dest["api_calls"] = int(dest.get("api_calls", 0)) + int(src.get("api_calls", 1)) class _TurnUsage: """한 턴(질문 1개) 안의 API 호출 usage 합산 — answers.json 1행에 저장.""" def __init__(self) -> None: self._calls: list[dict] = [] def add(self, usage: dict | None, *, label: str = "") -> None: if not usage: return row = dict(usage) if label: row["label"] = label self._calls.append(row) def merged(self) -> dict | None: if not self._calls: return None out = _empty_usage_bucket() for call in self._calls: _merge_usage_bucket(out, call) out["api_calls"] = len(self._calls) if len(self._calls) > 1: out["calls"] = self._calls return out def load_usage_summary() -> dict: if not os.path.isfile(USAGE_SUMMARY_PATH): return { "updated": None, "totals": _empty_usage_bucket(), "totals_extra": {"gemini_turns": 0}, "today": None, } try: data = json.load(open(USAGE_SUMMARY_PATH, encoding="utf-8")) if not isinstance(data, dict): raise TypeError("usage summary not dict") except (OSError, json.JSONDecodeError, TypeError): return { "updated": None, "totals": _empty_usage_bucket(), "totals_extra": {"gemini_turns": 0}, "today": None, } data.setdefault("totals", _empty_usage_bucket()) data.setdefault("totals_extra", {"gemini_turns": 0}) return data def save_usage_summary(summary: dict) -> None: summary["updated"] = datetime.now(timezone.utc).astimezone().isoformat( timespec="seconds" ) os.makedirs(os.path.dirname(USAGE_SUMMARY_PATH), exist_ok=True) tmp = USAGE_SUMMARY_PATH + ".tmp" with open(tmp, "w", encoding="utf-8") as f: json.dump(summary, f, ensure_ascii=False, indent=2) f.write("\n") os.replace(tmp, USAGE_SUMMARY_PATH) def _today_bucket(summary: dict) -> dict: today_str = datetime.now(timezone.utc).astimezone().date().isoformat() today = summary.get("today") if not isinstance(today, dict) or today.get("date") != today_str: today = {"date": today_str, **_empty_usage_bucket()} today["gemini_turns"] = 0 summary["today"] = today return today def record_api_usage(usage: dict | None) -> dict: """API 1회 usage를 summary에 반영하고 전체 totals 반환.""" if not usage: return load_usage_summary()["totals"] summary = load_usage_summary() u = dict(usage) u.setdefault("api_calls", 1) _merge_usage_bucket(summary["totals"], u) _merge_usage_bucket(_today_bucket(summary), u) save_usage_summary(summary) return summary["totals"] def _increment_gemini_turn_count() -> None: summary = load_usage_summary() extra = summary.setdefault("totals_extra", {"gemini_turns": 0}) extra["gemini_turns"] = int(extra.get("gemini_turns", 0)) + 1 today = _today_bucket(summary) today["gemini_turns"] = int(today.get("gemini_turns", 0)) + 1 save_usage_summary(summary) def rebuild_usage_summary_from_logs() -> dict: """answers.json 의 usage 필드에서 summary 재계산 (마이그레이션·복구용).""" totals = _empty_usage_bucket() totals_extra = {"gemini_turns": 0} today_str = datetime.now(timezone.utc).astimezone().date().isoformat() today = {"date": today_str, **_empty_usage_bucket(), "gemini_turns": 0} for row in _read_json_list(ANSWERS_LOG): if row.get("kind") != "gemini": continue totals_extra["gemini_turns"] += 1 if str(row.get("time", "")).startswith(today_str): today["gemini_turns"] += 1 usage = row.get("usage") if not isinstance(usage, dict): continue _merge_usage_bucket(totals, usage) if str(row.get("time", "")).startswith(today_str): _merge_usage_bucket(today, usage) summary = { "updated": None, "totals": totals, "totals_extra": totals_extra, "today": today, } save_usage_summary(summary) return summary def format_usage_totals_line(totals: dict, *, prefix: str = "누적") -> str: return ( f"{prefix}: 토큰 {int(totals.get('total_tokens', 0))} " f"(입력 {int(totals.get('prompt_tokens', 0))} / 출력 {int(totals.get('output_tokens', 0))}) " f"| ≈ ${float(totals.get('usd', 0)):.4f} (약 {float(totals.get('krw', 0)):.2f}원) " f"| API {int(totals.get('api_calls', 0))}회" ) def print_usage_summary_brief(summary: dict | None = None) -> None: summary = summary or load_usage_summary() totals = summary.get("totals", _empty_usage_bucket()) extra = summary.get("totals_extra", {}) turns = int(extra.get("gemini_turns", 0)) print(format_usage_totals_line(totals, prefix="API 전체")) today = summary.get("today") if isinstance(today, dict) and today.get("total_tokens", 0) > 0: print( format_usage_totals_line(today, prefix=f"오늘({today.get('date', '?')})") + f" | 대화 {int(today.get('gemini_turns', 0))}턴" ) if turns: print(f"Gemini 대화 누적: {turns}턴") # ---------- 영구 메모리 ---------- def load_memories() -> list[str]: if not os.path.isfile(MEMORY_PATH): return [] try: data = json.load(open(MEMORY_PATH, encoding="utf-8")) items = data if isinstance(data, list) else data.get("items", []) return [str(x).strip() for x in items if str(x).strip()] except (OSError, json.JSONDecodeError, TypeError): return [] def save_memories(items: list[str]) -> None: items = items[-MEMORY_MAX:] with open(MEMORY_PATH, "w", encoding="utf-8") as f: json.dump(items, f, ensure_ascii=False, indent=2) def build_system_instruction(memories: list[str] | None = None) -> str: memories = load_memories() if memories is None else memories if not memories: return BASE_INSTRUCTION lines = "\n".join(f"- {m}" for m in memories) return f"{BASE_INSTRUCTION}\n\n[영구 기억]\n{lines}" def load_session_history_from_logs(max_pairs: int) -> list[tuple[str, str]]: """최근 질문·답변 로그에서 대화 맥락 복원.""" if max_pairs <= 0: return [] questions = _read_json_list(QUESTIONS_LOG) answers = _read_json_list(ANSWERS_LOG) by_turn: dict[str, str] = {} for row in answers: if row.get("kind") != "gemini": continue tid = row.get("turn_id") text = str(row.get("text", "")).strip() if tid and text: by_turn[tid] = text pairs: list[tuple[str, str]] = [] for q in questions: tid = q.get("id") qtext = str(q.get("text", "")).strip() if tid and qtext and tid in by_turn: pairs.append((qtext, by_turn[tid])) return pairs[-max_pairs:] def append_session_turn(user_text: str, answer: str) -> None: global _session_history _session_history.append((user_text.strip(), answer.strip())) if len(_session_history) > CHAT_HISTORY_MAX: _session_history = _session_history[-CHAT_HISTORY_MAX:] def rebuild_chat(*, load_logs: bool = False) -> None: """메모리 반영된 새 모델/채팅 세션. load_logs=True면 로그에서 맥락 복원.""" global model, chat, _session_history model = genai.GenerativeModel( GEMINI_MODEL, system_instruction=build_system_instruction(), ) chat = model.start_chat(history=[]) if load_logs: _session_history = load_session_history_from_logs(CHAT_HISTORY_MAX) elif not _session_history: _session_history = [] def _compact(text: str) -> str: return re.sub(r"\s+", "", text.strip()) def handle_memory_command(user_text: str) -> str | None: """기억 관련 로컬 명령이면 응답 문자열, 아니면 None (제미나이로 전달).""" t = user_text.strip() c = _compact(t) # 목록 list_keys = ( "기억뭐있", "기억목록", "뭐기억", "기억보여", "기억확인", "기억내용", "기억읽어", ) if any(k in c for k in list_keys): items = load_memories() if not items: return "저장된 기억이 없습니다." body = ", ".join(f"{i + 1}번 {m}" for i, m in enumerate(items)) return f"기억 {len(items)}개 있습니다. {body}" # 전체 삭제 clear_keys = ( "기억다지워", "기억전부삭제", "기억초기화", "잊어전부", "기억모두지워", "기억다삭제", ) if any(k in c for k in clear_keys): save_memories([]) rebuild_chat() return "기억을 모두 지웠습니다." # 기억해 … for prefix in ("기억해줘", "기억해줄래", "기억해", "기억할것"): if c.startswith(_compact(prefix)) or t.startswith(prefix): content = t for p in ("기억해줘", "기억해줄래", "기억해", "기억할 것", "기억할것"): if content.startswith(p): content = content[len(p) :].lstrip(" .,'\"") break else: # compact 매칭만 된 경우 content = re.sub( r"^\s*기억해(줘|줄래)?\s*", "", t ).strip(" .,'\"") if not content: return "무엇을 기억할까요? 예를 들어, 기억해 반말로 짧게 답해." items = load_memories() if content in items: return "이미 같은 내용이 기억되어 있습니다." items.append(content) save_memories(items) rebuild_chat() return f"기억했습니다. {content}" # 잊어 … / 지워 … for prefix in ("잊어줘", "잊어", "지워줘", "삭제해줘", "삭제해"): if c.startswith(_compact(prefix)) or t.startswith(prefix): keyword = t for p in ("잊어줘", "잊어", "지워줘", "삭제해줘", "삭제해"): if keyword.startswith(p): keyword = keyword[len(p) :].lstrip(" .,'\"") break if not keyword: return "무엇을 잊을까요? 예를 들어, 잊어 맵기." items = load_memories() kept = [m for m in items if keyword not in m] removed = len(items) - len(kept) if removed == 0: return f"'{keyword}'가 들어간 기억이 없습니다." save_memories(kept) rebuild_chat() return f"{removed}개 기억을 지웠습니다." return None rebuild_chat() # ---------- 화이트리스트 장치 명령 (셸 임의 실행 금지) ---------- def get_speaker_volume_percent() -> int | None: try: r = subprocess.run( ["wpctl", "get-volume", "@DEFAULT_AUDIO_SINK@"], capture_output=True, text=True, timeout=5, ) if r.returncode == 0: # "Volume: 0.80" or "Volume: 1.00 [MUTED]" m = re.search(r"Volume:\s*([0-9.]+)", r.stdout) if m: return int(round(float(m.group(1)) * 100)) except (FileNotFoundError, subprocess.TimeoutExpired, ValueError): pass return None def run_hook(name: str, *args: str) -> tuple[bool, str]: """jarvis_hooks/.sh 만 실행 (화이트리스트). 추가 인자는 스크립트로 전달.""" path = os.path.join(HOOKS_DIR, f"{name}.sh") if not os.path.isfile(path): return False, f"{name} 스크립트가 없습니다." try: r = subprocess.run( ["/bin/bash", path, *args], capture_output=True, text=True, timeout=15, ) if r.returncode == 0: return True, (r.stdout or "").strip() or "완료" return False, (r.stderr or r.stdout or "실패").strip() except subprocess.TimeoutExpired: return False, "시간 초과" except OSError as e: return False, str(e) def _ha_api(): """jarvis_hooks/ha_api.py 로드.""" import importlib.util path = os.path.join(HOOKS_DIR, "ha_api.py") spec = importlib.util.spec_from_file_location("jarvis_ha_api", path) if spec is None or spec.loader is None: raise RuntimeError("ha_api.py 로드 실패") mod = importlib.util.module_from_spec(spec) spec.loader.exec_module(mod) return mod _ha_tool_calls: list[str] = [] def _reset_ha_tool_calls() -> None: _ha_tool_calls.clear() def get_home_snapshot() -> str: """지금 집안에서 자비스가 조작 가능한(enabled) 기기 목록과 상태를 조회한다.""" _ha_tool_calls.append("get_home_snapshot") print("[HA tool] get_home_snapshot") return _ha_api().tool_get_home_snapshot() def control_light(name: str, action: str) -> str: """조명을 켠다/끈다. name은 방/조명 이름(예: 거실, 메인등, 아기방). action은 on 또는 off.""" _ha_tool_calls.append("control_light") print(f"[HA tool] control_light name={name!r} action={action!r}") return _ha_api().tool_control_light(name, action) def control_climate( name: str = "", action: str = "turn_on", temperature: int = 24 ) -> str: """에어컨을 켠다/끈다/온도를 맞춘다. action: turn_on, turn_off, set. temperature 기본 24.""" _ha_tool_calls.append("control_climate") print( f"[HA tool] control_climate name={name!r} action={action!r} temp={temperature}" ) return _ha_api().tool_control_climate(name, action, temperature) def control_tv(name: str = "", action: str = "turn_on") -> str: """티비를 켠다/끈다. name 생략 시 거실 티비. action: on 또는 off.""" _ha_tool_calls.append("control_tv") print(f"[HA tool] control_tv name={name!r} action={action!r}") return _ha_api().tool_control_tv(name, action) def get_washer_status() -> str: """세탁기 남은 시간·전원·완료 여부를 조회한다.""" _ha_tool_calls.append("get_washer_status") print("[HA tool] get_washer_status") return _ha_api().tool_get_washer_status() HA_GEMINI_TOOLS = [ get_home_snapshot, control_light, control_climate, control_tv, get_washer_status, ] def ensure_ha_entities_file() -> None: """엔티티 JSON 없으면 HA에서 동기화.""" path = os.path.join(BASE_DIR, "jarvis_ha_entities.json") if os.path.isfile(path): return try: _ha_api().sync_entities_file() except Exception as e: print(f"[ha_entities] 최초 sync 실패: {e}") MEDIA_PID_PATH = os.path.join(LOG_DIR, "media.pid") def _is_media_playing() -> bool: """유튜브/VLC 재생 중인지.""" with _media_lock: if _media_proc is not None and _media_proc.poll() is None: return True if os.path.isfile(MEDIA_PID_PATH): try: with open(MEDIA_PID_PATH, encoding="utf-8") as f: pid = int(f.read().strip()) os.kill(pid, 0) return True except (OSError, ValueError): pass return False def _wake_hit_thresholds(media_on: bool) -> tuple[float, float]: """재생 중엔 호출어 임계를 조금 낮춤 (스피커 소리 속에서도 듣기).""" if media_on: return ( max(0.22, WAKE_THRESHOLD - 0.06), max(0.38, WAKE_STRONG_SCORE - 0.08), ) return WAKE_THRESHOLD, WAKE_STRONG_SCORE def _stop_media_playback() -> None: """유튜브/VLC 재생 중지.""" global _media_proc, _youtube_pending with _media_lock: _youtube_pending = None if os.path.isfile(MEDIA_PID_PATH): try: with open(MEDIA_PID_PATH, encoding="utf-8") as f: pid = int(f.read().strip()) os.kill(pid, 15) time.sleep(0.2) os.kill(pid, 9) except (OSError, ValueError): pass try: os.remove(MEDIA_PID_PATH) except OSError: pass if _media_proc is not None and _media_proc.poll() is None: _media_proc.terminate() try: _media_proc.wait(timeout=3) except subprocess.TimeoutExpired: _media_proc.kill() _media_proc = None subprocess.run( ["pkill", "-x", "cvlc"], check=False, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, ) _YT_PLAY_STRIP_PATS = ( r"유튜브에서?", r"유튜브로", r"youtube에서?", r"youtube로", r"노래", r"음악", r"뮤직", r"틀어달라", r"틀어\s*달라", r"틀어\s*줘?", r"틀어\s*봐", r"재생해?\s*줘?", r"들려\s*줘?", r"플레이", r"해\s*줘", r"줘", r"봐", r"달라", r"이라는", r"라는", ) def _normalize_youtube_query(query: str) -> str: """검색용 — 곡 제목 바꾸지 않고 STT 흔한 오인식만 (출출해↔추출해).""" q = re.sub(r"\s+", " ", query.strip()) if re.search(r"매[점장]", q) and "추출" in q: q = re.sub(r"추출", "출출", q) if re.search(r"매[점장]", q) and "수출" in q: q = re.sub(r"수출", "출출", q) return q.strip() def _strip_youtube_play_words(text: str) -> str: q = text for pat in _YT_PLAY_STRIP_PATS: q = re.sub(pat, "", q, flags=re.IGNORECASE) return re.sub(r"\s+", " ", q).strip(" .,'\"!?") def _extract_youtube_query(text: str) -> str | None: """「유튜브에서 OOO 틀어줘」 / 「OOO 틀어줘」 → OOO. 빈 제목은 ''.""" t = text.strip() c = _compact(t) play_words = ("틀어", "재생", "들려", "플레이", "play", "틀어줘", "틀어줄래") media_words = ( "유튜브", "youtube", "노래", "음악", "뮤직", "song", "music", ) has_play = any(w in c for w in play_words) has_media = any(k in c for k in media_words) if not has_play and not has_media: return None q = _strip_youtube_play_words(t) if len(q) >= 2: return _normalize_youtube_query(q) if has_play or has_media: return "" return None def _youtube_music_search_query(query: str) -> str: """API/yt-dlp 검색어 — 노래 의도면 '노래' 보강 (쇼핑·리뷰 영상 방지).""" q = re.sub(r"\s+", " ", query.strip()) c = _compact(q) if not any( k in c for k in ( "노래", "음악", "뮤직", "mv", "official", "뮤직비디오", "music", "song", "가사", ) ): return f"{q} 노래" return q def _youtube_api_search(query: str) -> tuple[str, str] | None: """YouTube Data API v3 검색 → (watch_url, 제목). 키 없거나 실패 시 None.""" key = YOUTUBE_API_KEY if not key or "여기에_" in key: return None try: from googleapiclient.discovery import build youtube = build("youtube", "v3", developerKey=key, cache_discovery=False) search_q = _youtube_music_search_query(query) res = ( youtube.search() .list( part="id,snippet", q=search_q, type="video", maxResults=1, videoCategoryId="10", safeSearch="none", ) .execute() ) items = res.get("items") or [] if not items: res = ( youtube.search() .list( part="id,snippet", q=search_q, type="video", maxResults=1, safeSearch="none", ) .execute() ) items = res.get("items") or [] if not items: print(f"[유튜브 API] 결과 없음: {query}") return None item = items[0] vid = item.get("id", {}).get("videoId") title = html.unescape(str(item.get("snippet", {}).get("title", "")).strip()) if not vid: return None url = f"https://www.youtube.com/watch?v={vid}" print(f"[유튜브 API] {title} ({vid})") return url, title or query except Exception as e: err = str(e) if "403" in err and "blocked" in err.lower(): print( "[유튜브 API] 사용 차단 — Google Cloud에서 " "YouTube Data API v3 활성화·키 제한 확인 필요. yt-dlp로 대체." ) else: print(f"[유튜브 API] 검색 실패: {e}") return None def _resolve_youtube_watch_url(query: str) -> tuple[str | None, str, str]: """검색어 → (재생 URL, 표시 제목, api|yt-dlp|none).""" mode = YOUTUBE_SEARCH_MODE if mode not in ("api", "yt-dlp", "auto"): mode = "auto" if mode in ("api", "auto"): api_hit = _youtube_api_search(query) if api_hit: return api_hit[0], api_hit[1], "api" if mode == "api": return None, query, "none" if not os.path.isfile(YT_DLP_BIN): return None, query, "none" search_q = _youtube_music_search_query(query) try: res = subprocess.run( [ YT_DLP_BIN, "-f", "bestaudio/best", "--no-playlist", "-g", f"ytsearch1:{search_q}", ], capture_output=True, text=True, timeout=90, check=False, ) url = (res.stdout or "").strip().split("\n")[0].strip() if url.startswith("http"): print(f"[유튜브 yt-dlp] {query}") return url, query, "yt-dlp" except (OSError, subprocess.TimeoutExpired) as e: print(f"[유튜브 yt-dlp] 검색 실패: {e}") return None, query, "none" def _start_youtube_play(query: str) -> str: """검색만 하고 재생은 짧은 TTS 후 (_commit_youtube_play).""" global _youtube_pending if not os.path.isfile(YT_DLP_BIN): print(f"[유튜브] yt-dlp 없음: {YT_DLP_BIN}") return "유튜브 검색 프로그램이 없어요." watch_url, title, src = _resolve_youtube_watch_url(query) if not watch_url: _youtube_pending = None return f"'{query}' 검색이 안 됐어요. 제목을 다시 말해 주세요." _youtube_pending = (query, watch_url) print(f"[유튜브] 예약: {title} ({src})") say_q = query if len(query) <= 36 else query[:33] + "..." return f"네, '{say_q}' 틀어줄게요." def _commit_youtube_play() -> None: """예약된 유튜브 재생 시작 (TTS 확인 후).""" global _media_proc, _youtube_pending if not _youtube_pending: return query, watch_url = _youtube_pending _youtube_pending = None script = os.path.join(BASE_DIR, "jarvis_yt_play.sh") if not os.path.isfile(script): print(f"[유튜브] 스크립트 없음: {script}") return env = os.environ.copy() sink = os.environ.get("PULSE_SINK", "").strip() or PULSE_SINK if sink: env["PULSE_SINK"] = sink with _media_lock: _stop_media_playback() cmd = ["/bin/bash", script, query] if watch_url: cmd.append(watch_url) _media_proc = subprocess.Popen( cmd, env=env, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, start_new_session=True, ) print(f"[유튜브] 재생 시작: {query} (pid={_media_proc.pid})") def handle_device_command(user_text: str) -> str | None: """볼륨/조명/가전 등 허용된 로컬 명령. 해당 없으면 None.""" c = _compact(user_text) # --- 유튜브 / 음악 재생 --- if any( k in c for k in ( "노래꺼", "음악꺼", "노래멈춰", "음악멈춰", "재생멈춰", "재생꺼", "플레이멈춰", "노래끄", "음악끄", ) ): _stop_media_playback() return "재생을 멈췄습니다." yt_q = _extract_youtube_query(user_text) if yt_q is not None: if not yt_q.strip(): return "어떤 노래를 틀어줄까요? 제목을 말해 주세요." return _start_youtube_play(yt_q) # --- 세탁기 (HA 실시간) --- if any( k in c for k in ( "세탁기얼마", "세탁얼마", "세탁남은", "세탁기남은", "세탁기상태", "세탁상태", "세탁완료됐", ) ) or ( "세탁" in c and any(k in c for k in ("얼마", "남", "몇분", "상태", "돌아가", "돌고")) ): try: return _ha_api().washer_status() except Exception as e: return f"세탁기 상태를 못 읽었습니다. {e}" # --- 티비 (media_player, 단답) --- has_tv = any(k in c for k in ("티비", "티브이", "텔레비전")) or "tv" in c.lower() if has_tv and any(k in c for k in ("켜", "꺼", "온", "오프")): turn_on = not any(k in c for k in ("꺼", "오프", "off")) try: ha = _ha_api() hit = ha.resolve_tv(user_text) if not hit: return "티비를 HA에서 못 찾았어요. jarvis_ha_entities.json 의 media_player enabled를 확인하세요." fn, eid = hit ha.tv_service(eid, turn_on) return f"{fn}을 켰습니다." if turn_on else f"{fn}을 껐습니다." except Exception as e: return f"티비 조작 실패. {e}" # --- 불 (HA light.* 실시간 검색 — json 일일이 안 넣어도 됨) --- is_on = any( k in c for k in ("불켜", "라이트온", "조명켜", "등켜", "불켜줘", "불켜봐") ) or ("켜" in c and any(x in c for x in ("방", "등", "라이트", "조명", "메인"))) is_off = any( k in c for k in ("불꺼", "라이트오프", "조명꺼", "등꺼", "불꺼줘") ) or ("꺼" in c and any(x in c for x in ("방", "등", "라이트", "조명", "메인"))) if is_on or is_off: try: ha = _ha_api() hit = ha.resolve_light(user_text) if not hit: return ( "어떤 불인지 HA에서 못 찾았어요. " "웹에 보이는 조명 이름으로 말해 주세요." ) fn, eid = hit ha.light_service(eid, is_on) return f"{fn}을 켰습니다." if is_on else f"{fn}을 껐습니다." except Exception as e: return f"조명 조작 실패. {e}" # --- 음소거 --- if any(k in c for k in ("음소거", "소리꺼", "뮤트")) and "해제" not in c: for cmd in ( ["wpctl", "set-mute", "@DEFAULT_AUDIO_SINK@", "1"], ["pactl", "set-sink-mute", "@DEFAULT_SINK@", "1"], ): subprocess.run(cmd, capture_output=True, timeout=5) return "음소거 했습니다." if any(k in c for k in ("음소거해제", "소리켜", "뮤트해제")): for cmd in ( ["wpctl", "set-mute", "@DEFAULT_AUDIO_SINK@", "0"], ["pactl", "set-sink-mute", "@DEFAULT_SINK@", "0"], ): subprocess.run(cmd, capture_output=True, timeout=5) return "음소거를 해제했습니다." # --- 볼륨 절대값: "볼륨 50", "볼륨50퍼" --- m = re.search(r"볼륨\s*(\d{1,3})", user_text) if not m: m = re.search(r"소리\s*(\d{1,3})\s*%?", user_text) if m and not any(k in c for k in ("올려", "높여", "키워", "내려", "줄여", "낮춰")): pct = max(0, min(150, int(m.group(1)))) set_speaker_volume(pct) return f"볼륨을 {pct}%로 맞췄습니다." if any(k in c for k in ("볼륨최대", "소리최대", "볼륨최대로")): set_speaker_volume(100) return "볼륨을 최대로 올렸습니다." # --- 볼륨 상대 --- cur = get_speaker_volume_percent() if any(k in c for k in ("볼륨올려", "소리키워", "소리크게", "볼륨크게", "볼륨높여")): nxt = min(150, (cur if cur is not None else 70) + 15) set_speaker_volume(nxt) return f"볼륨을 {nxt}%로 올렸습니다." if any(k in c for k in ("볼륨내려", "소리줄여", "소리작게", "볼륨작게", "볼륨낮춰")): nxt = max(0, (cur if cur is not None else 70) - 15) set_speaker_volume(nxt) return f"볼륨을 {nxt}%로 내렸습니다." if any(k in c for k in ("볼륨몇", "지금볼륨", "볼륨얼마", "소리크기")): if cur is None: return "지금 볼륨을 읽지 못했습니다." return f"지금 볼륨은 {cur}%입니다." return None # ---------- 오디오 / Gemini 유틸 ---------- def set_speaker_volume(percent: int) -> None: percent = max(0, min(150, int(percent))) for cmd in ( ["wpctl", "set-volume", "@DEFAULT_AUDIO_SINK@", f"{percent / 100:.2f}"], ["pactl", "set-sink-volume", "@DEFAULT_SINK@", f"{percent}%"], ["amixer", "-q", "sset", "Master", f"{min(percent, 100)}%"], ["wpctl", "set-mute", "@DEFAULT_AUDIO_SINK@", "0"], ): try: subprocess.run(cmd, capture_output=True, timeout=5) except (FileNotFoundError, subprocess.TimeoutExpired): pass def estimate_cost_usd(prompt_tokens: int, output_tokens: int) -> float: return ( prompt_tokens / 1_000_000 * PRICE_INPUT_PER_M + output_tokens / 1_000_000 * PRICE_OUTPUT_PER_M ) def print_usage(response, label: str = "") -> None: usage = getattr(response, "usage_metadata", None) if not usage: print("토큰 정보 없음") return pin = int(getattr(usage, "prompt_token_count", 0) or 0) pout = int(getattr(usage, "candidates_token_count", 0) or 0) total = int(getattr(usage, "total_token_count", 0) or (pin + pout)) billed_out = max(pout, total - pin) usd = estimate_cost_usd(pin, billed_out) krw = usd * USD_KRW tag = f"[{label}] " if label else "" print( f"{tag}토큰: 입력={pin} 출력(후보)={pout} 합계={total} " f"| 유료환산 ≈ ${usd:.6f} (약 {krw:.2f}원) / Free면 0원" ) def _get_gemini_text_client() -> genai_client.Client: global _gemini_text_client if _gemini_text_client is None: _gemini_text_client = genai_client.Client(api_key=GOOGLE_API_KEY) return _gemini_text_client def print_usage_genai(response, label: str = "") -> dict | None: usage = _usage_from_genai_response(response) if not usage: return None tag = f"[{label}] " if label else "" print( f"{tag}토큰: 입력={usage['prompt_tokens']} 출력={usage['output_tokens']} " f"합계={usage['total_tokens']} " f"| 유료환산 ≈ ${usage['usd']:.6f} (약 {usage['krw']:.2f}원) / Free면 0원" ) totals = record_api_usage(usage) print(f" ↳ {format_usage_totals_line(totals)}") return usage def _build_gemini_contents(user_text: str, use_session_history: bool = True) -> list | str: if use_session_history and _session_history: contents: list = [] for u, a in _session_history: contents.append( genai_types.Content( role="user", parts=[genai_types.Part(text=u)] ) ) contents.append( genai_types.Content( role="model", parts=[genai_types.Part(text=a)] ) ) contents.append( genai_types.Content( role="user", parts=[genai_types.Part(text=user_text)] ) ) return contents return user_text def _gemini_generate_config( *, max_tokens: int, system: str | None = None, with_ha_tools: bool = False, ) -> genai_types.GenerateContentConfig: kwargs: dict = { "system_instruction": system or build_system_instruction(), "max_output_tokens": max_tokens, "thinking_config": genai_types.ThinkingConfig(thinking_budget=0), } if with_ha_tools: kwargs["tools"] = HA_GEMINI_TOOLS kwargs["automatic_function_calling"] = ( genai_types.AutomaticFunctionCallingConfig(maximum_remote_calls=3) ) return genai_types.GenerateContentConfig(**kwargs) def _genai_generate_text( user_text: str, *, max_tokens: int, system: str | None = None, use_session_history: bool = True, with_ha_tools: bool = False, ): """thinking 비활성 — 답 중간 끊김 방지. use_session_history로 직전 대화 맥락 전달.""" client = _get_gemini_text_client() cfg = _gemini_generate_config( max_tokens=max_tokens, system=system, with_ha_tools=with_ha_tools ) contents = _build_gemini_contents(user_text, use_session_history) with warnings.catch_warnings(): warnings.simplefilter("ignore") with open(os.devnull, "w") as devnull: with contextlib.redirect_stderr(devnull): return client.models.generate_content( model=GEMINI_MODEL, contents=contents, config=cfg, ) def _genai_generate_text_stream( user_text: str, *, max_tokens: int, system: str | None = None, use_session_history: bool = True, ): """스트리밍 텍스트 생성 — 첫 문장 TTS를 빨리 시작.""" client = _get_gemini_text_client() cfg = _gemini_generate_config(max_tokens=max_tokens, system=system) contents = _build_gemini_contents(user_text, use_session_history) with warnings.catch_warnings(): warnings.simplefilter("ignore") with open(os.devnull, "w") as devnull: with contextlib.redirect_stderr(devnull): return client.models.generate_content_stream( model=GEMINI_MODEL, contents=contents, config=cfg, ) def _get_gemini_tts_client() -> genai_client.Client: global _gemini_tts_client if _gemini_tts_client is None: _gemini_tts_client = genai_client.Client(api_key=GOOGLE_API_KEY) return _gemini_tts_client def _synthesize_gemini(text: str, path: str) -> None: client = _get_gemini_tts_client() prefix = TTS_STYLE.strip() contents = f"{prefix} {text}" if prefix and not text.startswith(prefix) else text with warnings.catch_warnings(): warnings.simplefilter("ignore") with open(os.devnull, "w") as devnull: with contextlib.redirect_stderr(devnull): response = client.models.generate_content( model=TTS_MODEL, contents=contents, config=genai_types.GenerateContentConfig( response_modalities=["AUDIO"], speech_config=genai_types.SpeechConfig( voice_config=genai_types.VoiceConfig( prebuilt_voice_config=genai_types.PrebuiltVoiceConfig( voice_name=TTS_VOICE ) ) ), ), ) part = response.candidates[0].content.parts[0] pcm = part.inline_data.data with wave.open(path, "wb") as wf: wf.setnchannels(1) wf.setsampwidth(2) wf.setframerate(TTS_SAMPLE_RATE) wf.writeframes(pcm) async def _synthesize_edge(text: str, path: str) -> None: voice = TTS_VOICE if voice in ("Kore", "Leda", "Zephyr", "Puck", "Charon"): voice = DEFAULT_EDGE_TTS_VOICE communicate = edge_tts.Communicate( text, voice=voice, rate=TTS_RATE, pitch=TTS_PITCH ) await communicate.save(path) def synthesize_speech(text: str, base_path: str) -> str: """음성 합성. 실제 저장 경로 반환.""" root, ext = os.path.splitext(base_path) if not ext: root = base_path min_pcm_bytes = max(8000, len(text) * 120) if TTS_BACKEND == "gemini": wav_path = f"{root}.wav" try: _synthesize_gemini(text, wav_path) if os.path.getsize(wav_path) >= min_pcm_bytes: return wav_path print( f"Gemini TTS 오디오 짧음 ({os.path.getsize(wav_path)}B), " "edge-tts로 대체" ) except Exception as e: print(f"Gemini TTS 실패, edge-tts로 대체: {e}") mp3_path = f"{root}.mp3" asyncio.run(_synthesize_edge(text, mp3_path)) return mp3_path def play_beep(freq: float = 880.0, ms: int = 160, volume: float = 0.4) -> None: """입력 대기 진입 알림용 짧은 비프 (3.5mm/기본 싱크).""" try: rate = 24000 n = max(1, int(rate * ms / 1000)) t = np.arange(n, dtype=np.float32) / rate wave_arr = np.sin(2 * np.pi * freq * t) fade = np.linspace(1.0, 0.0, n, dtype=np.float32) mono = (wave_arr * fade * 32767 * volume).astype(np.int16) beep_path = os.path.join(BASE_DIR, "beep.wav") with wave.open(beep_path, "wb") as wf: wf.setnchannels(1) wf.setsampwidth(2) wf.setframerate(rate) wf.writeframes(mono.tobytes()) _play_audio_file(beep_path) except Exception as e: print(f"비프 재생 실패: {e}") def _clean_speech_text(text: str) -> str: clean = re.sub(r"[*_`#>•]+", " ", text) return re.sub(r"\s+", " ", clean).strip() def _synth_chunk_path(base: str, save_as: str | None, index: int, spoken: str) -> str: chunk_base = base if save_as else f"{base}_{index}" return synthesize_speech(spoken, chunk_base) def _remove_tts_file(path: str, save_as: str | None) -> None: if save_as is None: try: os.remove(path) except OSError: pass class _TtsPrefetch: """답변 텍스트 → 첫 TTS 청크 합성을 백그라운드에서 선행.""" def __init__(self, text: str, save_as: str | None = None) -> None: self.clean = _clean_speech_text(text) self.chunks = _chunks_for_speech(self.clean) self.base = save_as or os.path.join(BASE_DIR, "reply") self.save_as = save_as self._ready = threading.Event() self._first_spoken: str | None = None self._first_path: str | None = None self._error: BaseException | None = None if not self.chunks: self._ready.set() return _tts_executor.submit(self._run) def _run(self) -> None: try: spoken = self.chunks[0] path = _synth_chunk_path(self.base, self.save_as, 0, spoken) self._first_spoken = spoken self._first_path = path except Exception as e: self._error = e finally: self._ready.set() def wait_first(self, timeout: float = 90.0) -> bool: self._ready.wait(timeout) return self._first_path is not None and self._error is None def warmup_tts() -> None: """TTS·API 미리 준비 — 첫 재생 지연 줄임.""" try: _get_gemini_text_client() if TTS_BACKEND == "gemini": _get_gemini_tts_client() tmp = os.path.join(BASE_DIR, "tts_warmup.wav") _synthesize_gemini("음", tmp) if os.path.isfile(tmp): os.remove(tmp) else: tmp = os.path.join(BASE_DIR, "tts_warmup.mp3") asyncio.run(_synthesize_edge("테스트", tmp)) if os.path.isfile(tmp): os.remove(tmp) except Exception: pass def _chunks_for_speech(text: str) -> list[str]: """긴 답은 문장 단위로 나눠 TTS — 중간에 잘리지 않게.""" t = text.strip() if not t: return [] if TTS_CHUNK_CHARS <= 0 or len(t) <= TTS_CHUNK_CHARS: return [t] sentences = re.split(r"(?<=[.!?。])\s+", t) chunks: list[str] = [] buf = "" for sentence in sentences: s = sentence.strip() if not s: continue if len(s) > TTS_CHUNK_CHARS: if buf: chunks.append(buf.strip()) buf = "" for i in range(0, len(s), TTS_CHUNK_CHARS): part = s[i:i + TTS_CHUNK_CHARS].strip() if part: chunks.append(part) continue candidate = f"{buf} {s}".strip() if buf else s if len(candidate) <= TTS_CHUNK_CHARS: buf = candidate else: if buf: chunks.append(buf) buf = s if buf: chunks.append(buf) return chunks or [t[:TTS_CHUNK_CHARS]] def speak( text: str, save_as: str | None = None, prefetch: _TtsPrefetch | None = None, interruptible: bool = True, ) -> None: """TTS 합성 후 재생 — 로그는 재생 직전에 출력.""" _begin_speech(interruptible=interruptible) try: if prefetch is None: prefetch = _TtsPrefetch(text, save_as) if not prefetch.wait_first(): if prefetch._error: print(f"TTS 오류: {prefetch._error}") return clean = prefetch.clean chunks = prefetch.chunks base = prefetch.base save_as = prefetch.save_as if len(chunks) == 1: print(f"자비스: {prefetch._first_spoken}") else: print(f"자비스(전체): {clean}") path_future: Future | None = None if len(chunks) > 1: path_future = _tts_executor.submit( _synth_chunk_path, base, save_as, 1, chunks[1] ) if not _play_audio_file(prefetch._first_path): return _remove_tts_file(prefetch._first_path, save_as) for i in range(1, len(chunks)): if _speech_interrupt.is_set(): break spoken = chunks[i] if path_future is not None: path = path_future.result() path_future = None else: path = _synth_chunk_path(base, save_as, i, spoken) print(f"자비스 [{i + 1}/{len(chunks)}]: {spoken}") if i + 1 < len(chunks): path_future = _tts_executor.submit( _synth_chunk_path, base, save_as, i + 1, chunks[i + 1] ) if not _play_audio_file(path): break _remove_tts_file(path, save_as) finally: _end_speech() def open_mic_stream(audio: pyaudio.PyAudio): """가능하면 Pulse(공유) 우선 — USB hw 독점은 디버그/녹음과 충돌.""" candidates = [] if MIC_INDEX is not None: candidates.append(MIC_INDEX) else: pulse_idxs = [] usb_idxs = [] other = [] for i in range(audio.get_device_count()): d = audio.get_device_info_by_index(i) if d["maxInputChannels"] <= 0: continue name = d["name"] if name in ("pulse", "default") or "pulse" in name.lower(): pulse_idxs.append(i) elif "USB" in name or "usb" in name: usb_idxs.append(i) else: other.append(i) candidates.extend(pulse_idxs) candidates.extend(usb_idxs) candidates.extend(other) candidates.append(None) last_err = None for idx in candidates: for rate in (16000, 48000, 44100): kwargs = dict( format=pyaudio.paInt16, channels=1, rate=rate, input=True, frames_per_buffer=1280 if rate == 16000 else max(1024, int(rate * 0.08)), ) if idx is not None: kwargs["input_device_index"] = idx try: stream = audio.open(**kwargs) name = "default" if idx is not None: name = audio.get_device_info_by_index(idx).get("name", str(idx)) print(f"마이크: index={idx} ({name}) rate={rate}") return stream, rate except OSError as e: last_err = e raise RuntimeError(f"마이크를 열 수 없습니다: {last_err}") def to_16k(pcm: np.ndarray, rate: int) -> np.ndarray: if rate == 16000: return pcm.astype(np.int16, copy=False) # 선형 보간 리샘플 (단순 [::n] 은 44100에서 부정확할 수 있음) target_len = max(1, int(round(len(pcm) * 16000 / rate))) if target_len == len(pcm): return pcm.astype(np.int16, copy=False) x_old = np.linspace(0.0, 1.0, num=len(pcm), endpoint=False) x_new = np.linspace(0.0, 1.0, num=target_len, endpoint=False) out = np.interp(x_new, x_old, pcm.astype(np.float32)) return np.clip(out, -32768, 32767).astype(np.int16) def resolve_stt_mic_index(audio: pyaudio.PyAudio) -> int | None: """STT용 마이크 = 호출어와 같은 USB/Pulse 우선.""" if MIC_INDEX is not None: return MIC_INDEX pulse_idxs: list[int] = [] usb_idxs: list[int] = [] for i in range(audio.get_device_count()): d = audio.get_device_info_by_index(i) if d["maxInputChannels"] <= 0: continue name = str(d.get("name", "")).lower() if "pulse" in name or name in ("default", "pulse"): pulse_idxs.append(i) elif "usb" in name: usb_idxs.append(i) for group in (pulse_idxs, usb_idxs): if group: return group[0] return None def configure_recognizer(recognizer: sr.Recognizer) -> None: """질문 듣기 — 작은 소리·속도 균형.""" if STT_QUIET_MODE or STT_ENERGY_THRESHOLD > 0: recognizer.dynamic_energy_threshold = False recognizer.energy_threshold = STT_ENERGY_THRESHOLD if STT_ENERGY_THRESHOLD > 0 else 260 else: recognizer.dynamic_energy_threshold = True recognizer.dynamic_energy_adjustment_damping = 0.1 recognizer.dynamic_energy_ratio = 1.12 recognizer.pause_threshold = STT_PAUSE_THRESHOLD recognizer.non_speaking_duration = STT_NON_SPEAKING_SEC recognizer.phrase_threshold = STT_PHRASE_THRESHOLD recognizer.operation_timeout = None def _stt_record_limit_sec() -> float: """녹음 최대 길이 — phrase와 max_record 중 작은 값.""" return min(STT_PHRASE_LIMIT_SEC, STT_MAX_RECORD_SEC) def _prepare_recognizer_for_listen(recognizer: sr.Recognizer) -> None: """듣기 직전 — 고정 감도, 말 중간 멈춤에 잘리지 않게 pause 여유.""" configure_recognizer(recognizer) recognizer.dynamic_energy_threshold = False recognizer.pause_threshold = max(STT_PAUSE_THRESHOLD, STT_MIN_PAUSE_SEC) recognizer.non_speaking_duration = max(STT_NON_SPEAKING_SEC, 0.45) def _apply_stt_energy_after_ambient(recognizer: sr.Recognizer, noisy_warn: bool = False) -> None: """주변소음 보정 후 에너지 임계값 확정.""" global _stt_noisy_background if recognizer.energy_threshold >= STT_NOISY_THRESHOLD: _stt_noisy_background = True cap = STT_ENERGY_THRESHOLD if STT_ENERGY_THRESHOLD > 0 else 400 # 배경음이 크면 상한만 쓰고 최소값으로 올리지 않음 — 말 끝 감지가 늦어지는 주원인 recognizer.energy_threshold = min(recognizer.energy_threshold, cap) if noisy_warn: print( "⚠ 배경 소리가 큽니다 (노래/TV). " "음악 줄이거나 마이크 15cm 안에서 또렷하게 말하세요." ) elif STT_QUIET_MODE and STT_ENERGY_THRESHOLD > 0: cap = STT_ENERGY_THRESHOLD recognizer.energy_threshold = min(recognizer.energy_threshold, cap) _stt_noisy_background = False else: _stt_noisy_background = False if not _stt_noisy_background: recognizer.dynamic_energy_threshold = False def calibrate_stt_ambient(recognizer: sr.Recognizer) -> None: """시작 시 주변 소음 보정. 배경음악 크면 동적 감도로 전환.""" global _stt_ambient_calibrated if _stt_ambient_calibrated or STT_AMBIENT_SEC <= 0: _stt_ambient_calibrated = True if STT_QUIET_MODE and STT_ENERGY_THRESHOLD > 0 and not _stt_noisy_background: recognizer.energy_threshold = STT_ENERGY_THRESHOLD return mic_kwargs: dict = {} if STT_MIC_INDEX is not None: mic_kwargs["device_index"] = STT_MIC_INDEX print(f"STT 주변소음 보정 ({STT_AMBIENT_SEC}s)...") with sr.Microphone(**mic_kwargs) as source: recognizer.adjust_for_ambient_noise(source, duration=STT_AMBIENT_SEC) _apply_stt_energy_after_ambient(recognizer, noisy_warn=True) _stt_ambient_calibrated = True def recalibrate_stt_before_listen(recognizer: sr.Recognizer, source) -> None: """질문 듣기 직전 짧게 재보정 — 같은 마이크 소스에서 수행.""" dur = min(STT_AMBIENT_SEC, 0.18) if dur <= 0: return recognizer.adjust_for_ambient_noise(source, duration=dur) _apply_stt_energy_after_ambient(recognizer, noisy_warn=False) def _listen_smart_end(recognizer: sr.Recognizer, source) -> sr.AudioData: """ 주변소음(팬) baseline 추적 → 말하기 시작/끝 감지. 고정 임계값만 쓰면 팬이 계속 '말하는 중'으로 잡힘. """ import math pause_sec = max(STT_PAUSE_THRESHOLD, STT_NON_SPEAKING_SEC) record_limit = _stt_record_limit_sec() energy_threshold = recognizer.energy_threshold chunk = source.CHUNK sample_width = source.SAMPLE_WIDTH sample_rate = source.SAMPLE_RATE seconds_per_buffer = float(chunk) / sample_rate pause_buffers = max(1, int(math.ceil(pause_sec / seconds_per_buffer))) min_speech_buffers = max(1, int(math.ceil(0.2 / seconds_per_buffer))) elapsed = 0.0 deadline = STT_TIMEOUT_SEC if STT_TIMEOUT_SEC > 0 else float("inf") ambient = float(energy_threshold) ambient_alpha = 0.4 def _rms(buf: bytes) -> int: return audioop.rms(buf, sample_width) def _speech_start_thr() -> float: return max(energy_threshold, ambient * STT_SPEECH_START_RATIO) def _speech_end_thr(peak: float) -> float: return max(ambient * 1.15, peak * STT_SILENCE_PEAK_RATIO) # 말 시작 대기 — 주변소음 baseline 학습 pre_frames: list[bytes] = [] while True: if elapsed > deadline: raise sr.WaitTimeoutError() buffer = source.stream.read(chunk) if not buffer: break elapsed += seconds_per_buffer energy = _rms(buffer) ambient = ambient * (1 - ambient_alpha) + energy * ambient_alpha pre_frames.append(buffer) if energy >= _speech_start_thr(): break frames = list(pre_frames) peak = max(_rms(b) for b in pre_frames) if pre_frames else 0 record_t = 0.0 pause_count = 0 speech_buffers = 0 while record_t < record_limit: buffer = source.stream.read(chunk) if not buffer: break frames.append(buffer) record_t += seconds_per_buffer energy = _rms(buffer) peak = max(peak, energy) if energy < peak * 0.92: ambient = ambient * 0.88 + energy * 0.12 if energy >= _speech_start_thr() * 0.92: speech_buffers += 1 quiet = energy < _speech_end_thr(peak) if quiet and speech_buffers >= min_speech_buffers: pause_count += 1 if pause_count >= pause_buffers: break else: pause_count = 0 return sr.AudioData(b"".join(frames), sample_rate, sample_width) def _listen_for_speech(recognizer: sr.Recognizer, source) -> sr.AudioData: """말 끝날 때까지 녹음.""" if STT_SMART_END: return _listen_smart_end(recognizer, source) return recognizer.listen( source, timeout=STT_TIMEOUT_SEC, phrase_time_limit=_stt_record_limit_sec(), ) def _listen_with_feedback(recognizer: sr.Recognizer, source) -> sr.AudioData: """듣는 동안 피드백 (· = 듣는 중).""" stop = threading.Event() def _timer() -> None: while not stop.wait(0.4): print("·", end="", flush=True) t = threading.Thread(target=_timer, daemon=True) t.start() try: return _listen_for_speech(recognizer, source) finally: stop.set() t.join(timeout=0.5) _last_stt_engine: str = "web" def _stt_cloud_api_key() -> str: return ( os.environ.get("STT_CLOUD_API_KEY", "").strip() or os.environ.get("YOUTUBE_API_KEY", "").strip() or GOOGLE_API_KEY.strip() ) def _stt_effective_backend() -> str: backend = (STT_BACKEND or "web").strip().lower() return backend if backend in ("web", "cloud") else "web" def _stt_phrase_hints() -> list[str]: """Cloud STT speechContexts — 집 기기 이름 위주.""" hints = [ "자비스", "메인등", "거실", "아기방", "안방", "주방", "티비", "텔레비전", "에어컨", "스탠드형", "세탁기", "불 켜", "불 꺼", "드라마", ] try: ha = _ha_api() for eid, meta in ha.load_entities_map().items(): if not meta.get("enabled"): continue name = str(meta.get("name") or "").strip() if name and name not in hints: hints.append(name) except Exception: pass seen: set[str] = set() out: list[str] = [] for h in hints: h = h.strip() if h and h not in seen: seen.add(h) out.append(h) return out[:100] def _recognize_web(recognizer: sr.Recognizer, audio_data: sr.AudioData) -> str: return recognizer.recognize_google(audio_data, language=STT_LANGUAGE) def _recognize_cloud(recognizer: sr.Recognizer, audio_data: sr.AudioData) -> str: key = _stt_cloud_api_key() if not key: raise RuntimeError( "Cloud STT API 키 없음 (jarvis.env STT_CLOUD_API_KEY 또는 GOOGLE_API_KEY)" ) raw = audio_data.get_raw_data(convert_rate=16000, convert_width=2) config: dict = { "encoding": "LINEAR16", "sampleRateHertz": 16000, "languageCode": STT_LANGUAGE, "model": STT_CLOUD_MODEL or "command_and_search", "enableAutomaticPunctuation": False, } phrases = _stt_phrase_hints() if phrases: config["speechContexts"] = [{"phrases": phrases, "boost": 10.0}] payload = { "config": config, "audio": {"content": base64.b64encode(raw).decode("ascii")}, } url = ( "https://speech.googleapis.com/v1/speech:recognize?key=" + urllib.parse.quote(key, safe="") ) req = urllib.request.Request( url, data=json.dumps(payload).encode(), method="POST", headers={"Content-Type": "application/json"}, ) try: with urllib.request.urlopen(req, timeout=20) as resp: data = json.loads(resp.read().decode()) except urllib.error.HTTPError as e: body = e.read()[:400].decode(errors="replace") raise RuntimeError(f"Cloud STT HTTP {e.code}: {body}") from e results = data.get("results") or [] if not results: raise sr.UnknownValueError("Cloud STT: 결과 없음") alts = results[0].get("alternatives") or [] text = (alts[0].get("transcript") if alts else "") or "" text = str(text).strip() if not text: raise sr.UnknownValueError("Cloud STT: transcript 비어 있음") return text def transcribe_audio( recognizer: sr.Recognizer, audio_data: sr.AudioData ) -> tuple[str, str]: """음성 → 텍스트. (text, engine_label) — web | cloud | web(fallback).""" global _last_stt_engine backend = _stt_effective_backend() if backend == "cloud": try: text = _recognize_cloud(recognizer, audio_data) _last_stt_engine = "cloud" return text, "cloud" except Exception as e: print(f"[STT cloud 실패] {e}") if not STT_CLOUD_FALLBACK: raise text = _recognize_web(recognizer, audio_data) _last_stt_engine = "web(fallback)" return text, "web(fallback)" text = _recognize_web(recognizer, audio_data) _last_stt_engine = "web" return text, "web" def _mic_gain_pactl_arg(gain: float) -> str: """STT_MIC_GAIN 1.0=100%, 1.3=130%, 2.0=200% (PulseAudio % 형식).""" pct = max(50, min(200, int(round(gain * 100)))) return f"{pct}%" def prepare_mic_for_listen() -> None: """USB 마이크 감도 올리기.""" if not PULSE_SOURCE: return subprocess.run( ["pactl", "set-source-mute", PULSE_SOURCE, "0"], check=False, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, ) if STT_MIC_GAIN > 0: subprocess.run( [ "pactl", "set-source-volume", PULSE_SOURCE, _mic_gain_pactl_arg(STT_MIC_GAIN), ], check=False, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, ) def _is_wake_echo(text: str) -> bool: """호출어만 말한 경우 질문으로 보지 않음.""" c = _compact(text).lower() noise = ("alexa", "알렉사", "alex", "timer", "타이머", "weather", "웨더") return any(n in c for n in noise) and len(c) <= 12 def _answer_looks_cut_off(text: str) -> bool: """Gemini가 중간에 끊은 답(실시간→실) 감지.""" t = text.strip() if len(t) < 8: return True if t[-1] in ".!?。)" or t[-1].isdigit() or t[-1] in "도%℃": return False return True def _take_complete_sentences(buffer: str) -> tuple[list[str], str]: """스트리밍 버퍼에서 완성된 문장만 분리.""" sentences: list[str] = [] rest = buffer while True: m = re.search(r"(?<=[.!?。])(?:\s+|$)", rest) if not m: break sent = rest[:m.end()].strip() rest = rest[m.end():] if sent: sentences.append(sent) return sentences, rest async def _play_audio_async(path: str) -> bool: """paplay 비동기 — 중단 시 False.""" if _speech_interrupt.is_set(): return False env = os.environ.copy() if PULSE_SINK: env["PULSE_SINK"] = PULSE_SINK cmd = [ "paplay", f"--latency-msec={max(10, TTS_PLAY_LATENCY_MS)}", path, ] try: proc = await asyncio.create_subprocess_exec( *cmd, env=env, stdout=asyncio.subprocess.DEVNULL, stderr=asyncio.subprocess.DEVNULL, ) with _tts_play_lock: global _tts_play_proc _tts_play_proc = proc while proc.returncode is None: if _speech_interrupt.is_set(): proc.terminate() await proc.wait() return False await asyncio.sleep(0.05) with _tts_play_lock: if _tts_play_proc is proc: _tts_play_proc = None if proc.returncode == 0 and not _speech_interrupt.is_set(): return True except OSError as e: print(f"paplay 실패, pygame으로 대체: {e}") return await asyncio.to_thread(_play_audio_file, path) async def _async_synth_to_path(text: str) -> str: """문장 하나 → 오디오 파일 (비동기).""" root = os.path.join(BASE_DIR, f"reply_a_{uuid.uuid4().hex[:8]}") if TTS_BACKEND == "gemini": wav_path = f"{root}.wav" await asyncio.to_thread(_synthesize_gemini, text, wav_path) return wav_path mp3_path = f"{root}.mp3" await _synthesize_edge(text, mp3_path) return mp3_path async def _async_gemini_voice_reply(user_text: str) -> tuple[str, _TurnUsage]: """Gemini 스트리밍 + TTS 파이프라인: 재생 중 다음 문장 합성.""" _begin_speech(interruptible=True) try: max_tokens = GEMINI_VOICE_MAX_TOKENS sentence_q: asyncio.Queue[str | None] = asyncio.Queue() answer_parts: list[str] = [] last_chunk = None async def producer() -> None: nonlocal last_chunk buffer = "" client = _get_gemini_text_client() cfg = _gemini_generate_config(max_tokens=max_tokens) contents = _build_gemini_contents(user_text) with warnings.catch_warnings(): warnings.simplefilter("ignore") with open(os.devnull, "w") as devnull: with contextlib.redirect_stderr(devnull): stream = await client.aio.models.generate_content_stream( model=GEMINI_MODEL, contents=contents, config=cfg, ) async for chunk in stream: if _speech_interrupt.is_set(): break last_chunk = chunk if not chunk.text: continue answer_parts.append(chunk.text) buffer += chunk.text done, buffer = _take_complete_sentences(buffer) for sent in done: await sentence_q.put(sent) if buffer.strip() and not _speech_interrupt.is_set(): await sentence_q.put(buffer.strip()) await sentence_q.put(None) async def consumer() -> None: pending: asyncio.Task[str] | None = None while not _speech_interrupt.is_set(): try: sent = await asyncio.wait_for(sentence_q.get(), timeout=0.05) except asyncio.TimeoutError: continue if sent is None: break clean = _clean_speech_text(sent) if not clean: continue print(f"자비스: {clean}", flush=True) synth_task = asyncio.create_task(_async_synth_to_path(clean)) if pending is not None: path = await pending if not await _play_audio_async(path): synth_task.cancel() break _remove_tts_file(path, None) pending = synth_task if pending is not None and not _speech_interrupt.is_set(): path = await pending if await _play_audio_async(path): _remove_tts_file(path, None) elif pending is not None: pending.cancel() await asyncio.gather(producer(), consumer()) turn_u = _TurnUsage() if last_chunk is not None: turn_u.add(print_usage_genai(last_chunk)) answer = "".join(answer_parts).strip() return answer, turn_u finally: _end_speech() def _run_async_gemini_voice_reply(user_text: str) -> tuple[str, _TurnUsage]: return asyncio.run(_async_gemini_voice_reply(user_text)) def _speak_quick(text: str) -> None: """한 문장 빠른 재생 (스트리밍 답변용).""" clean = _clean_speech_text(text) if not clean: return _begin_speech(interruptible=True) try: print(f"자비스: {clean}") path = _synth_chunk_path(os.path.join(BASE_DIR, "reply_stream"), None, 0, clean) _play_audio_file(path) _remove_tts_file(path, None) finally: _end_speech() async def _speak_quick_async(text: str) -> None: clean = _clean_speech_text(text) if not clean: return _begin_speech(interruptible=True) try: print(f"자비스: {clean}", flush=True) path = await _async_synth_to_path(clean) await _play_audio_async(path) _remove_tts_file(path, None) finally: _end_speech() def _gemini_reply( user_text: str, *, speak_while_streaming: bool = False ) -> tuple[str, _TurnUsage]: """HA Function Calling(비스트림). 도구를 썼으면 TTS만 재생. 잡담은 받은 답을 바로 말한다.""" max_tokens = GEMINI_VOICE_MAX_TOKENS turn_u = _TurnUsage() _reset_ha_tool_calls() try: response = _genai_generate_text( user_text, max_tokens=max(max_tokens, 384), with_ha_tools=True, ) turn_u.add(print_usage_genai(response, label="ha-tools")) used_tools = bool(_ha_tool_calls) answer = (response.text or "").strip() if response else "" except Exception as e: print(f"[HA tools] 실패, 일반 대화로 폴백: {e}") used_tools = False answer = "" if GEMINI_STREAM and speak_while_streaming: return _run_async_gemini_voice_reply(user_text) response = _genai_generate_text(user_text, max_tokens=max_tokens) turn_u.add(print_usage_genai(response)) answer = (response.text or "").strip() if used_tools and not answer: answer = "기기 조작은 했는데 답을 못 만들었어요." if not answer: return "답을 만들지 못했습니다.", turn_u if _answer_looks_cut_off(answer) and not used_tools: print("(답이 끊긴 것 같아 다시 생성)") retry = _genai_generate_text( f"질문: {user_text}\n한두 문장으로 끝까지 완결하게 답해.", max_tokens=160, system="자비스. 짧고 완결된 한국어 한두 문장.", use_session_history=False, ) turn_u.add(print_usage_genai(retry, label="retry")) if retry.text and len(retry.text.strip()) > len(answer): answer = retry.text.strip() if speak_while_streaming: asyncio.run(_speak_quick_async(answer)) return answer, turn_u def process_user_text(user_text: str, *, stt_engine: str | None = None) -> None: """음성/텍스트 공통: 메모리 → 장치 명령 → 제미나이. 질문/답변 로그 분리 저장.""" turn_id = log_question(user_text, source="voice", stt_engine=stt_engine) local = handle_memory_command(user_text) if local is not None: print("(로컬 메모리, API 호출 없음)") log_answer(local, turn_id=turn_id, kind="memory") speak(local, interruptible=False) return device = handle_device_command(user_text) if device is not None: print("(로컬 장치 명령, API 호출 없음)") log_answer(device, turn_id=turn_id, kind="device") speak(device, interruptible=False) _commit_youtube_play() return print("생각 중...") answer, turn_u = _gemini_reply( user_text, speak_while_streaming=GEMINI_STREAM ) usage = turn_u.merged() append_session_turn(user_text, answer) log_answer(answer, turn_id=turn_id, kind="gemini", usage=usage) if GEMINI_STREAM: pass # 이미 스트리밍 중 재생 완료 else: speak(answer, prefetch=_TtsPrefetch(answer)) def listen_command( recognizer: sr.Recognizer, mic_stream=None, ) -> bool: """호출어 이후 명령 듣기 → 처리. 질문 인식 성공 시 True.""" global _pending_interrupt_listen load_runtime_config_if_changed(verbose=True) paused_wake_mic = mic_stream is not None if paused_wake_mic: _pause_wake_mic(mic_stream) heard_success = False try: prepare_mic_for_listen() mic_kwargs: dict = {} if STT_MIC_INDEX is not None: mic_kwargs["device_index"] = STT_MIC_INDEX with sr.Microphone(**mic_kwargs) as source: interrupt_round = 0 while interrupt_round < 4: interrupt_round += 1 if interrupt_round > 1: print("답변을 멈췄어요. 새 질문 말해 주세요.") if STT_RECAL_BEFORE_LISTEN and interrupt_round == 1: recalibrate_stt_before_listen(recognizer, source) _prepare_recognizer_for_listen(recognizer) play_beep() if STT_POST_BEEP_DELAY_SEC > 0: time.sleep(STT_POST_BEEP_DELAY_SEC) record_limit = _stt_record_limit_sec() effective_pause = max(STT_PAUSE_THRESHOLD, STT_NON_SPEAKING_SEC) print( f"[STT 준비] backend={_stt_effective_backend()} " f"energy={recognizer.energy_threshold:.0f} " f"pause={effective_pause}s 녹음≤{record_limit:.0f}s " f"smart=on start×{STT_SPEECH_START_RATIO} peak×{STT_SILENCE_PEAK_RATIO} " f"mic={_mic_gain_pactl_arg(STT_MIC_GAIN)}" ) round_heard = False for attempt in range(max(1, STT_RETRY_MAX)): if attempt == 0: print("지금 말하세요", end="", flush=True) else: print("다시 말하세요", end="", flush=True) try: if paused_wake_mic: _pause_wake_mic(mic_stream) audio_data = _listen_with_feedback(recognizer, source) if paused_wake_mic: _restart_wake_mic(mic_stream) print(" 인식 중…", flush=True) t_stt = time.time() user_text, stt_engine = asyncio.run( asyncio.to_thread( transcribe_audio, recognizer, audio_data ) ) print( f"(STT {time.time() - t_stt:.1f}s engine={stt_engine})", flush=True, ) if _is_wake_echo(user_text): print( f"(호출어만 들림: {user_text}) — 질문을 말씀해 주세요." ) continue print(f"나: {user_text}") process_user_text(user_text, stt_engine=stt_engine) heard_success = True round_heard = True break except sr.WaitTimeoutError: print("아무 말씀도 없으셔서 대기 모드로 돌아갑니다.") break except sr.UnknownValueError: print("목소리를 제대로 듣지 못했습니다.") except Exception as e: print(f"오류 발생: {e}") break if _pending_interrupt_listen: _pending_interrupt_listen = False continue if round_heard: break break return heard_success finally: if paused_wake_mic: _restart_wake_mic(mic_stream) def _porcupine_phrase_filename(phrase: str) -> str: safe = re.sub(r"[^\w가-힣]+", "_", phrase.strip(), flags=re.UNICODE) safe = safe.strip("_") or "keyword" return f"{safe}_raspberry-pi.ppn" def _ensure_porcupine_model() -> str: path = PORCUPINE_MODEL_PATH if os.path.isfile(path) and os.path.getsize(path) > 1000: return path os.makedirs(os.path.dirname(path) or ".", exist_ok=True) print(f"한국어 Porcupine 모델 다운로드 중 → {path}") import urllib.request urllib.request.urlretrieve(PORCUPINE_MODEL_URL, path) if not os.path.isfile(path) or os.path.getsize(path) < 1000: raise SystemExit(f"Porcupine 한국어 모델 다운로드 실패: {path}") return path def resolve_porcupine_keyword_paths() -> list[str]: """환경에 지정된 .ppn 또는 문구로 학습/확보한 경로 목록.""" if PORCUPINE_KEYWORD_PATH: paths = [p.strip() for p in PORCUPINE_KEYWORD_PATH.split(",") if p.strip()] missing = [p for p in paths if not os.path.isfile(p)] if missing: raise SystemExit( "PORCUPINE_KEYWORD_PATH 파일을 찾을 수 없습니다:\n " + "\n ".join(missing) ) return paths phrases = [p.strip() for p in PORCUPINE_PHRASES.split(",") if p.strip()] if not phrases: raise SystemExit("PORCUPINE_PHRASES 가 비어 있습니다.") os.makedirs(PORCUPINE_DIR, exist_ok=True) paths: list[str] = [] need_train: list[tuple[str, str]] = [] for phrase in phrases: out = os.path.join(PORCUPINE_DIR, _porcupine_phrase_filename(phrase)) paths.append(out) if not os.path.isfile(out) or os.path.getsize(out) < 100: need_train.append((phrase, out)) if not need_train: return paths if not PICOVOICE_ACCESS_KEY: raise SystemExit( "한국어 호출어(.ppn)가 없습니다. jarvis.env 에 다음을 넣으세요:\n" " PICOVOICE_ACCESS_KEY=... (https://console.picovoice.ai/)\n" "또는 Console에서 만든 .ppn 경로를:\n" " PORCUPINE_KEYWORD_PATH=/path/to/keyword_raspberry-pi.ppn" ) from pvporcupine._util import pv_train_model for phrase, out in need_train: print(f"Porcupine 키워드 학습 중: 「{phrase}」 → {out}") pv_train_model( access_key=PICOVOICE_ACCESS_KEY, output_path=out, language=PORCUPINE_LANGUAGE, phrase=phrase, platform="raspberry-pi", ) if not os.path.isfile(out) or os.path.getsize(out) < 100: raise SystemExit(f"키워드 파일 생성 실패: {out}") return paths def create_porcupine(): """한국어 Porcupine 엔진.""" import pvporcupine if not PICOVOICE_ACCESS_KEY: raise SystemExit( "WAKE_MODE=korean 에는 Picovoice Access Key가 필요합니다.\n" " https://console.picovoice.ai/ 에서 발급 후\n" " jarvis.env 에 PICOVOICE_ACCESS_KEY=... 를 넣고\n" " systemctl --user restart jarvis" ) model_path = _ensure_porcupine_model() keyword_paths = resolve_porcupine_keyword_paths() sensitivities = [PORCUPINE_SENSITIVITY] * len(keyword_paths) print(f"Porcupine 모델={model_path}") print(f"Porcupine 키워드={keyword_paths}") print(f"Porcupine sensitivity={PORCUPINE_SENSITIVITY}") return pvporcupine.create( access_key=PICOVOICE_ACCESS_KEY, model_path=model_path, keyword_paths=keyword_paths, sensitivities=sensitivities, ) def _wake_interrupt_worker() -> None: """TTS/답변 중 호출어 → 말 끊고 새 질문 대기 (대기 중 연속 호출 거부는 wait_wake가 담당).""" global _pending_interrupt_listen, _last_wake_at wake_buf = np.zeros(0, dtype=np.int16) hit_frames = 0 while not _interrupt_watcher_stop.is_set(): if not _speech_active or not _speech_interruptible or _wake_mic_paused: time.sleep(0.05) wake_buf = np.zeros(0, dtype=np.int16) hit_frames = 0 continue load_runtime_config_if_changed() ctx = _wake_interrupt_ctx if not ctx: time.sleep(0.1) continue mic_stream = ctx.get("mic_stream") mic_rate = ctx.get("mic_rate", 16000) chunk = ctx.get("chunk", 1280) oww_model = ctx.get("oww_model") if mic_stream is None or oww_model is None: time.sleep(0.1) continue if mic_stream.is_stopped(): time.sleep(0.05) continue try: pcm = np.frombuffer( mic_stream.read(chunk, exception_on_overflow=False), dtype=np.int16, ) except OSError: time.sleep(0.1) continue pcm16 = to_16k(pcm, mic_rate) wake_buf = np.concatenate([wake_buf, pcm16]) while len(wake_buf) >= WAKE_FRAME: frame = wake_buf[:WAKE_FRAME] wake_buf = wake_buf[WAKE_FRAME:] prediction = oww_model.predict(frame) scored = [ (model_name, float(prediction.get(model_name, 0.0) or 0.0)) for model_name in OWW_WAKE_LABELS ] winner, score = max(scored, key=lambda x: x[1]) if score >= WAKE_STRONG_SCORE: hit_frames = WAKE_MIN_FRAMES elif score > WAKE_THRESHOLD: hit_frames += 1 else: hit_frames = 0 if hit_frames < WAKE_MIN_FRAMES: continue detail = " ".join(f"{n}={v:.3f}" for n, v in scored) print( f"\n[대답 중 호출!] winner={winner} score={score:.3f} " f"({detail}) — 끊고 새 질문 받기", flush=True, ) _pending_interrupt_listen = True _last_wake_at = time.time() request_speech_interrupt() try: oww_model.reset() except Exception: pass hit_frames = 0 wake_buf = np.zeros(0, dtype=np.int16) break def _start_interrupt_watcher() -> None: threading.Thread( target=_wake_interrupt_worker, daemon=True, name="jarvis-wake-int", ).start() def wait_wake_jarvis( mic_stream, mic_rate: int, chunk: int, recognizer: sr.Recognizer, oww_model: Model, ) -> None: """영어 openWakeWord 호출어.""" global _last_wake_at wake_buf = np.zeros(0, dtype=np.int16) last_dbg = time.time() dbg_max = 0.0 hit_frames = 0 print(f"모드=openWakeWord models={OWW_WAKE_LABELS} 임계={WAKE_THRESHOLD} 연속={WAKE_MIN_FRAMES}") print(f"완료! {WAKE_PROMPT} (종료: Ctrl+C)") while True: load_runtime_config_if_changed() mic_stream = _wake_interrupt_ctx.get("mic_stream", mic_stream) chunk = _wake_interrupt_ctx.get("chunk", chunk) mic_rate = _wake_interrupt_ctx.get("mic_rate", mic_rate) try: pcm = np.frombuffer( mic_stream.read(chunk, exception_on_overflow=False), dtype=np.int16, ) except OSError as e: print(f"[wake] 마이크 읽기 오류: {e} — 복구 시도") if not _restart_wake_mic(mic_stream): raise continue pcm16 = to_16k(pcm, mic_rate) wake_buf = np.concatenate([wake_buf, pcm16]) while len(wake_buf) >= WAKE_FRAME: frame = wake_buf[:WAKE_FRAME] wake_buf = wake_buf[WAKE_FRAME:] prediction = oww_model.predict(frame) scored = [ (model_name, float(prediction.get(model_name, 0.0) or 0.0)) for model_name in OWW_WAKE_LABELS ] winner, score = max(scored, key=lambda x: x[1]) dbg_max = max(dbg_max, score) media_on = _is_media_playing() thr, strong = _wake_hit_thresholds(media_on) if score >= strong: hit_frames = WAKE_MIN_FRAMES elif score > thr: hit_frames += 1 else: hit_frames = 0 if hit_frames < WAKE_MIN_FRAMES: continue now = time.time() if not media_on and now - _last_wake_at < MIN_WAKE_INTERVAL_SEC: if WAKE_DEBUG: print( f"[wake 무시] {MIN_WAKE_INTERVAL_SEC:.0f}초 이내 재호출 " f"({now - _last_wake_at:.1f}초)" ) hit_frames = 0 continue _last_wake_at = now detail = " ".join(f"{n}={v:.3f}" for n, v in scored) if media_on: print( f"\n[재생 중 호출!] winner={winner} score={score:.3f} " f"({detail}) — 음악 끊고 질문 받기", flush=True, ) _stop_media_playback() time.sleep(0.35) else: print( f"\n[감지됨!] winner={winner} score={score:.3f} ({detail})", flush=True, ) wake_buf = np.zeros(0, dtype=np.int16) hit_frames = 0 dbg_max = 0.0 heard = listen_command(recognizer, mic_stream) if not heard: print( "※ 호출어를 연속으로 부르지 마세요. " "한 번 부르고 삐 소리 뒤에 질문하세요." ) print(f"\n다시 대기 — {WAKE_PROMPT}") oww_model.reset() cooldown = WAKE_COOLDOWN_SEC if not heard: cooldown += LISTEN_FAIL_COOLDOWN_SEC time.sleep(cooldown) last_dbg = time.time() break if WAKE_DEBUG and time.time() - last_dbg >= 3.0: rms = float(np.sqrt(np.mean(pcm.astype(np.float64) ** 2))) if len(pcm) else 0.0 print(f"[wake 디버그] 최근최대={dbg_max:.3f} mic_rms={rms:.0f}") dbg_max = 0.0 last_dbg = time.time() def wait_wake_korean( mic_stream, mic_rate: int, chunk: int, recognizer: sr.Recognizer, ) -> None: """한글 호출어: Picovoice Porcupine (로컬, STT 없음).""" porcupine = create_porcupine() frame_len = int(porcupine.frame_length) phrases = [p.strip() for p in PORCUPINE_PHRASES.split(",") if p.strip()] print( f"모드=korean Porcupine frame={frame_len} " f"sensitivity={PORCUPINE_SENSITIVITY}" ) print(f"완료! {WAKE_PROMPT} (종료: Ctrl+C)") wake_buf = np.zeros(0, dtype=np.int16) last_dbg = time.time() try: while True: pcm = np.frombuffer( mic_stream.read(chunk, exception_on_overflow=False), dtype=np.int16, ) pcm16 = to_16k(pcm, mic_rate) wake_buf = np.concatenate([wake_buf, pcm16]) while len(wake_buf) >= frame_len: frame = wake_buf[:frame_len] wake_buf = wake_buf[frame_len:] result = int(porcupine.process(frame)) if result < 0: continue name = ( phrases[result] if result < len(phrases) else f"keyword[{result}]" ) print(f"\n[감지됨!] Porcupine: {name}") wake_buf = np.zeros(0, dtype=np.int16) listen_command(recognizer, mic_stream) print(f"\n다시 대기 — {WAKE_PROMPT}") time.sleep(WAKE_COOLDOWN_SEC) last_dbg = time.time() break if WAKE_DEBUG and time.time() - last_dbg >= 3.0: rms = ( float(np.sqrt(np.mean(pcm.astype(np.float64) ** 2))) if len(pcm) else 0.0 ) print(f"[wake 디버그] porcupine 대기 mic_rms={rms:.0f}") last_dbg = time.time() finally: porcupine.delete() def run_self_test() -> None: """호출어 없이 대화+TTS만 검증 (--test).""" print("=== 자비스 자가 테스트 (호출어 생략) ===") set_speaker_volume(SPEAKER_VOLUME) rebuild_chat() play_beep() print("비프음 재생 완료") process_user_text("테스트입니다. 한 줄로 대답해줘.") print("=== 테스트 완료 ===") def main() -> None: set_speaker_volume(SPEAKER_VOLUME) prepare_mic_for_listen() mem_n = len(load_memories()) print(f"설정파일: {ENV_PATH}") ensure_runtime_config_file() load_runtime_config_if_changed(verbose=True) print(f"실시간 설정: {RUNTIME_CONFIG_PATH} (수정 시 재시작 불필요)") ensure_ha_entities_file() print( "HA 엔티티 스위치: " f"{os.path.join(BASE_DIR, 'jarvis_ha_entities.json')} " "(enabled 저장 즉시 반영)" ) print(f"WAKE_MODE={WAKE_MODE} LANG_MODE={LANG_MODE} STT={STT_LANGUAGE}") print(f"스피커 볼륨 ≈ {SPEAKER_VOLUME}%") print( f"TTS: backend={TTS_BACKEND} model={TTS_MODEL} " f"voice={TTS_VOICE} style={TTS_STYLE!r}" ) if WAKE_MODE not in ("korean", "ko", "hangul", "한글", "porcupine"): print( f"호출어(openWakeWord): {OWW_WAKE_LABELS} " f"framework={OWW_INFERENCE_FRAMEWORK} 임계={WAKE_THRESHOLD}" ) print(f"영구 기억: {mem_n}개 ({MEMORY_PATH})") print(f"대화 로그: {QUESTIONS_LOG} / {ANSWERS_LOG}") if not os.path.isfile(USAGE_SUMMARY_PATH): rebuild_usage_summary_from_logs() print_usage_summary_brief() rebuild_chat(load_logs=True) if _session_history: print(f"대화 맥락: 최근 {len(_session_history)}턴 로그에서 복원") print("TTS 워밍업…", flush=True) warmup_tts() audio = pyaudio.PyAudio() global STT_MIC_INDEX STT_MIC_INDEX = resolve_stt_mic_index(audio) mic_stream, mic_rate = open_mic_stream(audio) chunk = max(512, int(mic_rate * 0.08)) global _wake_mic_paused _wake_mic_paused = False if STT_MIC_INDEX is not None: stt_name = audio.get_device_info_by_index(STT_MIC_INDEX).get("name", "?") print(f"STT 마이크: index={STT_MIC_INDEX} ({stt_name})") print( f"STT: backend={STT_BACKEND} model={STT_CLOUD_MODEL} " f"fallback={STT_CLOUD_FALLBACK} lang={STT_LANGUAGE} " f"timeout={STT_TIMEOUT_SEC}s record≤{_stt_record_limit_sec():.0f}s " f"mic_gain={STT_MIC_GAIN} quiet={STT_QUIET_MODE} " f"energy={STT_ENERGY_THRESHOLD}" ) print(f"STT 실시간 전환: {RUNTIME_CONFIG_PATH} → stt_backend: web | cloud") recognizer = sr.Recognizer() configure_recognizer(recognizer) calibrate_stt_ambient(recognizer) try: if WAKE_MODE in ("korean", "ko", "hangul", "한글", "porcupine"): wait_wake_korean(mic_stream, mic_rate, chunk, recognizer) else: print("호출어 엔진(openWakeWord) 로딩 중...") oww_model = Model( wakeword_models=list(OWW_WAKE_PATHS), inference_framework=OWW_INFERENCE_FRAMEWORK, ) _wake_interrupt_ctx.update( audio=audio, mic_stream=mic_stream, mic_rate=mic_rate, chunk=chunk, oww_model=oww_model, ) _start_interrupt_watcher() wait_wake_jarvis(mic_stream, mic_rate, chunk, recognizer, oww_model) except KeyboardInterrupt: print("\n프로그램을 종료합니다.") finally: mic_stream.stop_stream() mic_stream.close() audio.terminate() if __name__ == "__main__": import sys if "--test" in sys.argv: run_self_test() else: main()