3231 lines
113 KiB
Python
3231 lines
113 KiB
Python
#!/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()
|