Files
jarvis/jarvis.py
2026-09-01 01:25:09 +09:00

3231 lines
113 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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/<name>.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()