commit 3c99ee6ff86fdddcf2e574cadb5ffbadd9a47edb Author: hoon Date: Tue Sep 1 01:25:09 2026 +0900 first commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..dfba346 --- /dev/null +++ b/.gitignore @@ -0,0 +1,25 @@ +# 비밀/로컬 설정 +jarvis.env +jarvis_hooks/ha.env +jarvis_audio.env + +# 가상환경·캐시 +jarvis_env/ +__pycache__/ +*.pyc +.pytest_cache/ + +# 런타임·로그·대용량 HA 덤프 +jarvis_logs/ +jarvis_ha_entities.json +jarvis_memory.json +*.pid + +# TTS/STT 임시·샘플 +reply*.wav +reply*.mp3 +tts_warmup.* +data/samples/ + +# Porcupine 학습 산출물(키워드 .ppn 은 커밋 가능) +jarvis_porcupine/*.ppn diff --git a/README.md b/README.md new file mode 100644 index 0000000..c22fb22 --- /dev/null +++ b/README.md @@ -0,0 +1,40 @@ +# Jarvis (라즈베리 Pi 음성 비서) + +호출어 + STT + Gemini + Home Assistant 연동. + +## 경로 + +``` +/home/hoon/projects/jarvis/ +├── jarvis.py # 메인 +├── jarvis.env # API 키·설정 (Git 제외) +├── jarvis_runtime.json # 실시간 튜닝 (stt_backend 등) +├── jarvis_hooks/ # HA API +├── jarvis_models/ # openWakeWord 모델 +├── jarvis_env/ # Python venv (Git 제외) +└── jarvis_logs/ # 질문/답변 로그 (Git 제외) +``` + +## 실행 + +```bash +systemctl --user restart jarvis +systemctl --user status jarvis +``` + +## STT 전환 + +`jarvis_runtime.json`: + +```json +"stt_backend": "web" +``` + +- `web` — 무료 Google Web Speech +- `cloud` — Google Cloud Speech-to-Text (유료) + +## HA 엔티티 동기화 + +```bash +python3 jarvis_hooks/ha_api.py sync +``` diff --git a/archive/claw_mic.py b/archive/claw_mic.py new file mode 100644 index 0000000..6ff8a3e --- /dev/null +++ b/archive/claw_mic.py @@ -0,0 +1,203 @@ +import os +import time +import logging +import requests +import speech_recognition as sr +import pygame +import io +from ctypes import * + +os.environ['PYGAME_HIDE_SUPPORT_PROMPT'] = "hide" + +# ALSA 에러 로그 숨기기 +ERROR_HANDLER_FUNC = CFUNCTYPE(None, c_char_p, c_int, c_char_p, c_int, c_char_p) +def py_error_handler(filename, line, function, err, fmt): pass +c_error_handler = ERROR_HANDLER_FUNC(py_error_handler) +try: + asound = cdll.LoadLibrary('libasound.so.2') + asound.snd_lib_error_set_handler(c_error_handler) +except: + pass + +logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(message)s') +logger = logging.getLogger(__name__) + +# ─── 환경 설정 ─────────────────────────────────────────────── +VM_IP = "192.168.0.149" +OPENCLAW_PORT = 18789 + +# 오픈클로 Gateway RPC 엔드포인트 +# chat.send: 텍스트 메시지 전송 +CHAT_URL = f"http://{VM_IP}:{OPENCLAW_PORT}/api/v1/chat/completions" +# 오디오 첨부 전송 (media.audio enabled 시 Gemini가 STT 처리) +AUDIO_URL = f"http://{VM_IP}:{OPENCLAW_PORT}/v1/audio/transcriptions" + +# TTS: openedai-speech 또는 오픈클로 내장 TTS +TTS_URL = f"http://{VM_IP}:{OPENCLAW_PORT}/v1/audio/speech" + +WAKE_WORD = "애미나이" +MIC_INDEX = 3 +# ───────────────────────────────────────────────────────────── + +pygame.mixer.init() + +def send_audio_to_openclaw(audio_data) -> str: + """ + WAV를 오픈클로에 전송. + media.audio.enabled + google provider 설정 시 + 오픈클로가 Gemini로 STT 후 LLM 답변까지 처리. + 반환값: 오픈클로 텍스트 응답 + """ + try: + wav_bytes = audio_data.get_wav_data() + files = { + 'audio': ('cmd.wav', io.BytesIO(wav_bytes), 'audio/wav') + } + logger.info("오픈클로로 오디오 전송 중...") + resp = requests.post(AUDIO_URL, files=files, timeout=30) + resp.raise_for_status() + logger.info(f"응답 코드: {resp.status_code}") + + # 오픈클로 응답이 JSON인 경우 + try: + data = resp.json() + # 오픈클로 응답 구조에 따라 키 조정 + return data.get("reply") or data.get("message") or data.get("text") or str(data) + except Exception: + # 텍스트 응답인 경우 + return resp.text + + except requests.exceptions.ConnectionError: + logger.error(f"연결 실패 - {VM_IP}:{OPENCLAW_PORT} 확인 필요") + return "" + except Exception as e: + logger.error(f"오디오 전송 실패: {e}") + return "" + +def send_text_to_openclaw(text: str) -> str: + """텍스트를 오픈클로 chat API로 전송 (오디오 전송 안 될 때 폴백)""" + try: + payload = {"message": text} + resp = requests.post(CHAT_URL, json=payload, timeout=30) + resp.raise_for_status() + data = resp.json() + return data.get("reply") or data.get("message") or data.get("text") or str(data) + except Exception as e: + logger.error(f"텍스트 전송 실패: {e}") + return "" + +def speak_edge_tts(text: str): + """ + 오픈클로 내장 TTS (Edge TTS, API 키 불필요) 사용. + 안 되면 Google gTTS로 폴백. + """ + try: + payload = { + "model": "tts-1", + "input": text, + "voice": "alloy", + "response_format": "mp3" + } + resp = requests.post(TTS_URL, json=payload, timeout=15) + if resp.status_code == 200: + audio_buf = io.BytesIO(resp.content) + pygame.mixer.music.load(audio_buf, "mp3") + pygame.mixer.music.play() + while pygame.mixer.music.get_busy(): + time.sleep(0.1) + logger.info("TTS 재생 완료") + return + except Exception as e: + logger.warning(f"오픈클로 TTS 실패, gTTS 시도: {e}") + + # gTTS 폴백 (로컬 설치: pip install gtts) + try: + from gtts import gTTS + tts = gTTS(text=text, lang='ko') + tts.save("/tmp/reply.mp3") + pygame.mixer.music.load("/tmp/reply.mp3") + pygame.mixer.music.play() + while pygame.mixer.music.get_busy(): + time.sleep(0.1) + except Exception as e: + logger.error(f"gTTS도 실패: {e}") + +def main(): + recognizer = sr.Recognizer() + recognizer.energy_threshold = 400 + recognizer.pause_threshold = 1.0 + + # 마이크 목록 출력 + mics = sr.Microphone.list_microphone_names() + logger.info(f"사용 가능한 마이크 ({len(mics)}개):") + for i, mic in enumerate(mics): + logger.info(f" {i}: {mic}") + + # 안전하게 마이크 열기 + mic = None + for test_index in [MIC_INDEX, 0, 1, 2, 3]: # 여러 인덱스 시도 + try: + logger.info(f"마이크 {test_index} 테스트 중...") + mic = sr.Microphone(device_index=test_index) + with mic as source: + test_audio = recognizer.listen(source, timeout=1, phrase_time_limit=1) + logger.info(f"✅ 마이크 {test_index} 성공!") + break + except Exception as e: + logger.warning(f"❌ 마이크 {test_index} 실패: {e}") + continue + + if mic is None: + logger.error("사용 가능한 마이크를 찾을 수 없습니다. 프로그램 종료.") + return + + # 소음 보정 (실패해도 무시) + try: + with mic as source: + recognizer.adjust_for_ambient_noise(source, duration=1) + logger.info(f"energy_threshold: {recognizer.energy_threshold:.0f}") + except Exception as e: + logger.warning(f"소음 보정 실패 (무시): {e}") + + logger.info(f"==== '{WAKE_WORD}' 대기 중 ====") + + while True: + try: + with mic as source: + logger.debug("마이크 듣는 중...") + audio = recognizer.listen(source, timeout=None, phrase_time_limit=3) + + try: + text = recognizer.recognize_google(audio, language='ko-KR') + logger.info(f"🎤 인식: '{text}'") + + if WAKE_WORD in text: + logger.info("🔔 호출어 감지!") + speak_edge_tts("네, 말씀하세요") + + # 명령어 녹음 + with mic as source: + logger.info("명령 녹음 중...") + cmd_audio = recognizer.listen(source, timeout=5, phrase_time_limit=10) + + # 오픈클로 전송 + reply = send_audio_to_openclaw(cmd_audio) + if reply: + speak_edge_tts(reply) + else: + speak_edge_tts("응답을 받지 못했어요.") + + except sr.UnknownValueError: + pass # 조용히 무시 + except sr.RequestError as e: + logger.error(f"Google STT 오류: {e}") + + except sr.WaitTimeoutError: + pass # 타임아웃 무시 + except Exception as e: + logger.error(f"루프 오류: {e}") + time.sleep(1) + + +if __name__ == "__main__": + main() diff --git a/archive/mictotxt.py b/archive/mictotxt.py new file mode 100644 index 0000000..0da683d --- /dev/null +++ b/archive/mictotxt.py @@ -0,0 +1,94 @@ +import os, time, logging, requests +import speech_recognition as sr +from ctypes import * + +# ALSA 에러 억제 +os.environ['PYGAME_HIDE_SUPPORT_PROMPT'] = "hide" +def py_error_handler(filename, line, function, err, fmt): pass +ERROR_HANDLER_FUNC = CFUNCTYPE(None, c_char_p, c_int, c_char_p, c_int, c_char_p) +c_error_handler = ERROR_HANDLER_FUNC(py_error_handler) +try: + asound = cdll.LoadLibrary('libasound.so.2') + asound.snd_lib_error_set_handler(c_error_handler) +except: pass + +logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(message)s') +logger = logging.getLogger(__name__) + +# ===== 설정 ===== +OPENWEBUI_URL = "http://192.168.0.149:18789" +API_KEY = "your-openwebui-api-key" # OpenWebUI → 설정 → API Key +MODEL = "gemini-2.0-flash" # 오픈클로에서 쓰는 모델명 +WAKE_WORD = "미나이" +MIC_INDEX = 3 +# ================ + +def stt_google(audio_data): + """Google STT로 음성 → 텍스트""" + recognizer = sr.Recognizer() + try: + return recognizer.recognize_google(audio_data, language='ko-KR') + except: + return None + +def send_to_openwebui(text): + """텍스트를 OpenWebUI 채팅 API로 전송""" + try: + headers = { + "Authorization": f"Bearer {API_KEY}", + "Content-Type": "application/json" + } + payload = { + "model": MODEL, + "messages": [{"role": "user", "content": text}] + } + # OpenAI 호환 엔드포인트 사용 + res = requests.post( + f"{OPENWEBUI_URL}/api/chat/completions", + headers=headers, + json=payload, + timeout=30 + ) + response_text = res.json()["choices"][0]["message"]["content"] + logger.info(f"AI 응답: {response_text}") + return response_text + except Exception as e: + logger.error(f"전송 실패: {e}") + return None + +def main(): + recognizer = sr.Recognizer() + recognizer.energy_threshold = 400 + recognizer.pause_threshold = 1.0 + mic = sr.Microphone(device_index=MIC_INDEX) + + logger.info(f"==== '{WAKE_WORD}' 대기 중 ====") + + while True: + try: + with mic as source: + audio = recognizer.listen(source, timeout=None, phrase_time_limit=3) + + text = stt_google(audio) + if not text: + continue + + if WAKE_WORD in text: + logger.info(f"웨이크워드 감지: {text}") + logger.info("명령 녹음 중...") + + with mic as source: + cmd_audio = recognizer.listen(source, timeout=5, phrase_time_limit=10) + + cmd_text = stt_google(cmd_audio) + if cmd_text: + logger.info(f"명령: {cmd_text}") + send_to_openwebui(cmd_text) + + except Exception as e: + logger.error(f"루프 오류: {e}") + time.sleep(1) + +if __name__ == "__main__": + main() + diff --git a/archive/openai.py b/archive/openai.py new file mode 100644 index 0000000..7125f62 --- /dev/null +++ b/archive/openai.py @@ -0,0 +1,14 @@ +import openai + +# API 키 설정 (환경 변수 등에서 가져옴) +openai.api_key = "YOUR_API_KEY" + +response = openai.ChatCompletion.create( + model="gpt-3.5-turbo", # 또는 "gpt-4" + messages=[ + {"role": "system", "content": "You are a helpful assistant."}, # 역할 설정 + {"role": "user", "content": "Hello!"} # 사용자 입력 + ] +) + +print(response.choices[0].message.content) \ No newline at end of file diff --git a/archive/smart_mic.py b/archive/smart_mic.py new file mode 100644 index 0000000..6d34ea7 --- /dev/null +++ b/archive/smart_mic.py @@ -0,0 +1,101 @@ +import os +import time +import logging +import requests +import speech_recognition as sr +import pygame +from ctypes import * + +# smart_mic.py 맨 위 import 아래 추가 +import os +# 불필요한 로그 억제 +os.environ['PYGAME_HIDE_SUPPORT_PROMPT'] = "hide" +from ctypes import * + +def py_error_handler(filename, line, function, err, fmt): pass +ERROR_HANDLER_FUNC = CFUNCTYPE(None, c_char_p, c_int, c_char_p, c_int, c_char_p) +c_error_handler = ERROR_HANDLER_FUNC(py_error_handler) +try: + asound = cdll.LoadLibrary('libasound.so.2') + asound.snd_lib_error_set_handler(c_error_handler) +except: pass + + + +# ALSA 에러 로그 숨기기 +ERROR_HANDLER_FUNC = CFUNCTYPE(None, c_char_p, c_int, c_char_p, c_int, c_char_p) +def py_error_handler(filename, line, function, err, fmt): pass +c_error_handler = ERROR_HANDLER_FUNC(py_error_handler) +try: + asound = cdll.LoadLibrary('libasound.so.2') + asound.snd_lib_error_set_handler(c_error_handler) +except: pass + +logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(message)s') +logger = logging.getLogger(__name__) + +# [환경 설정] +# VM_IP 자리에 오픈클로 VM의 실제 IP를 넣으세요. +OPENCLAW_API_URL = "http://192.168.0.149:18789/api/v1/chat" +WAKE_WORD = "애미나이" +MIC_INDEX = 3 # 현재 확인된 장치 번호 + +def send_to_openclaw(audio_data): + """녹음된 명령어를 오픈클로로 슛!""" + try: + # 파일 저장 확인을 위해 ls -al 에서 보일 이름을 고정합니다. + filename = "cmd.wav" + with open(filename, "wb") as f: + f.write(audio_data.get_wav_data()) + + logger.info(f">>> {filename} 생성 완료! 오픈클로 전송 시작...") + + with open(filename, "rb") as f: + files = {'file': (filename, f, 'audio/wav')} + response = requests.post(OPENCLAW_API_URL, files=files, timeout=20) + + logger.info(f"오픈클로 서버 응답: {response.status_code}") + except Exception as e: + logger.error(f"전송 실패: {e}") + +def main(): + recognizer = sr.Recognizer() + recognizer.energy_threshold = 400 + recognizer.pause_threshold = 1.0 # 말이 끝날 때까지 조금 더 넉넉히 기다림 + + mic = sr.Microphone(device_index=MIC_INDEX) + + logger.info(f"==== '{WAKE_WORD}' 대기 중 (포트 18789) ====") + + while True: + try: + with mic as source: + # 호출어 인식 단계 + audio = recognizer.listen(source, timeout=None, phrase_time_limit=3) + + try: + text = recognizer.recognize_google(audio, language='ko-KR') + + # "에미나이", "애미나이" 모두 대응 + if "미나이" in text: + logger.info(f"[{text}] 감지됨! 명령을 말씀하세요...") + + # 스피커로 "네" 출력 (연결된 스피커가 있다면) + # play_feedback("네") + + with mic as source: + logger.info("명령 녹음 중 (5초)...") + # 호출어 감지 후 5초간 집중 녹음 + cmd_audio = recognizer.listen(source, timeout=5, phrase_time_limit=10) + + # 녹음 끝나면 바로 전송 및 파일 생성 + send_to_openclaw(cmd_audio) + + except sr.UnknownValueError: + continue + except Exception as e: + logger.error(f"루프 오류: {e}") + time.sleep(1) + +if __name__ == "__main__": + main() diff --git a/archive/test_jarvis.py b/archive/test_jarvis.py new file mode 100644 index 0000000..68b1320 --- /dev/null +++ b/archive/test_jarvis.py @@ -0,0 +1,310 @@ +#!/usr/bin/env python3 +"""자비스 원격 테스트 — 볼륨 키우고 스피커/마이크/제미나이 확인. + +사용: + source ~/jarvis_env/bin/activate + python ~/test_jarvis.py # 전체 테스트 (큰 소리 재생) + python ~/test_jarvis.py --volume 100 # 볼륨 % + python ~/test_jarvis.py --speak-only # 스피커만 + python ~/test_jarvis.py --mic-only # 마이크 레벨만 + python ~/test_jarvis.py --gemini-only +""" + +from __future__ import annotations + +import argparse +import os +import subprocess +import sys +import time +from ctypes import CFUNCTYPE, c_char_p, c_int, cdll + +os.environ["PYGAME_HIDE_SUPPORT_PROMPT"] = "hide" + +ERROR_HANDLER_FUNC = CFUNCTYPE(None, c_char_p, c_int, c_char_p, c_int, c_char_p) + + +def _py_error_handler(filename, line, function, err, fmt): + pass + + +try: + asound = cdll.LoadLibrary("libasound.so.2") + asound.snd_lib_error_set_handler(ERROR_HANDLER_FUNC(_py_error_handler)) +except Exception: + pass + + +def set_volume(percent: int) -> None: + """시스템 스피커 볼륨을 percent%로 맞춤 (여러 방식 시도).""" + percent = max(0, min(150, int(percent))) + print(f"\n[볼륨] {percent}% 로 설정 시도...") + + cmds = [ + ["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)}%"], + ["amixer", "-q", "sset", "PCM", f"{min(percent, 100)}%"], + ["amixer", "-c", "0", "-q", "sset", "Headphone", f"{min(percent, 100)}%"], + ] + ok = False + for cmd in cmds: + try: + r = subprocess.run(cmd, capture_output=True, text=True, timeout=5) + if r.returncode == 0: + print(f" OK: {' '.join(cmd)}") + ok = True + except (FileNotFoundError, subprocess.TimeoutExpired): + pass + + # 음소거 해제 + for cmd in ( + ["wpctl", "set-mute", "@DEFAULT_AUDIO_SINK@", "0"], + ["pactl", "set-sink-mute", "@DEFAULT_SINK@", "0"], + ["amixer", "-q", "sset", "Master", "unmute"], + ): + try: + subprocess.run(cmd, capture_output=True, timeout=5) + except (FileNotFoundError, subprocess.TimeoutExpired): + pass + + if not ok: + print(" 경고: 볼륨 명령을 적용하지 못했습니다. 재생은 시도합니다.") + + +def show_volume() -> None: + for cmd in ( + ["wpctl", "get-volume", "@DEFAULT_AUDIO_SINK@"], + ["pactl", "get-sink-volume", "@DEFAULT_SINK@"], + ["amixer", "sget", "Master"], + ): + try: + r = subprocess.run(cmd, capture_output=True, text=True, timeout=5) + if r.returncode == 0 and r.stdout.strip(): + print(f" 현재: {r.stdout.strip().splitlines()[0]}") + return + except (FileNotFoundError, subprocess.TimeoutExpired): + pass + + +def list_devices() -> None: + import pyaudio + + pa = pyaudio.PyAudio() + print("\n[오디오 장치]") + for i in range(pa.get_device_count()): + d = pa.get_device_info_by_index(i) + if d["maxInputChannels"] > 0 or d["maxOutputChannels"] > 0: + print( + f" {i}: in={d['maxInputChannels']} out={d['maxOutputChannels']} " + f"{d['name']}" + ) + pa.terminate() + + +def load_api_key() -> str: + key = os.environ.get("GOOGLE_API_KEY", "") + if key and "여기에_" not in key: + return key + # jarvis.py 기본값에서 읽기 (이미 키가 들어있는 경우) + jarvis = os.path.join(os.path.dirname(os.path.abspath(__file__)), "jarvis.py") + if os.path.isfile(jarvis): + text = open(jarvis, encoding="utf-8").read() + import re + + m = re.search( + r'GOOGLE_API_KEY = os\.environ\.get\(\s*"GOOGLE_API_KEY",\s*"([^"]+)"\s*\)', + text, + ) + if not m: + m = re.search( + r'os\.environ\.get\(\s*"GOOGLE_API_KEY",\s*\n\s*"([^"]+)"', + text, + ) + if m and "여기에_" not in m.group(1): + return m.group(1) + raise SystemExit("GOOGLE_API_KEY 를 찾을 수 없습니다. jarvis.py 에 키를 넣으세요.") + + +def test_speak(volume_pygame: float = 1.0) -> None: + from gtts import gTTS + import pygame + + print("\n[스피커] TTS 재생 — 카메라로 들리는지 확인해 보세요.") + msg = ( + "안녕하세요. 자비스 스피커 테스트입니다. " + "지금 소리가 크게 나고 있습니다. 하나, 둘, 셋." + ) + path = "/tmp/jarvis_test_reply.mp3" + gTTS(text=msg, lang="ko").save(path) + + pygame.mixer.init() + pygame.mixer.music.set_volume(max(0.0, min(1.0, volume_pygame))) + pygame.mixer.music.load(path) + pygame.mixer.music.play() + while pygame.mixer.music.get_busy(): + time.sleep(0.1) + pygame.mixer.music.unload() + print(" 재생 완료.") + + +def test_beep() -> None: + """네트워크 없이도 스피커 확인용 짧은 비프.""" + print("\n[스피커] 비프음 재생...") + # sox/aplay 또는 speaker-test + for cmd in ( + ["speaker-test", "-t", "sine", "-f", "880", "-l", "1", "-c", "2"], + [ + "bash", + "-c", + "ffmpeg -f lavfi -i 'sine=frequency=880:duration=1' -f wav - " + "| aplay -q 2>/dev/null", + ], + ): + try: + r = subprocess.run(cmd, capture_output=True, timeout=8) + if r.returncode == 0: + print(" 비프 완료.") + return + except (FileNotFoundError, subprocess.TimeoutExpired): + pass + print(" 비프 도구 없음 — TTS만 사용합니다.") + + +def test_mic(seconds: float = 3.0, mic_index: int | None = None) -> None: + import numpy as np + import pyaudio + + print(f"\n[마이크] {seconds:.0f}초간 입력 레벨 측정 (밖에 있어도 OK)...") + pa = pyaudio.PyAudio() + + candidates: list[int | None] = [] + if mic_index is not None: + candidates.append(mic_index) + else: + for i in range(pa.get_device_count()): + d = pa.get_device_info_by_index(i) + if d["maxInputChannels"] <= 0: + continue + name = d["name"] + if "pulse" in name or name == "default": + candidates.append(i) + elif "USB" in name: + candidates.append(i) + candidates.append(None) # 시스템 기본 + + stream = None + rate = 16000 + used_index: int | None = None + for idx in candidates: + for try_rate in (16000, 44100, 48000): + kwargs = dict( + format=pyaudio.paInt16, + channels=1, + rate=try_rate, + input=True, + frames_per_buffer=1024, + ) + if idx is not None: + kwargs["input_device_index"] = idx + try: + stream = pa.open(**kwargs) + rate = try_rate + used_index = idx + name = ( + pa.get_device_info_by_index(idx)["name"] + if idx is not None + else "default" + ) + print(f" 열림: index={idx} ({name}) rate={rate}") + break + except OSError: + continue + if stream is not None: + break + + if stream is None: + pa.terminate() + print(" → 마이크를 열 수 없습니다.") + return + + peaks = [] + n = max(1, int(rate / 1024 * seconds)) + for _ in range(n): + data = stream.read(1024, exception_on_overflow=False) + arr = np.frombuffer(data, dtype=np.int16).astype(np.float32) + peaks.append(float(np.max(np.abs(arr)))) + stream.stop_stream() + stream.close() + pa.terminate() + + peak = max(peaks) if peaks else 0 + avg = sum(peaks) / len(peaks) if peaks else 0 + print(f" peak={peak:.0f} / 32767 avg={avg:.0f} (device={used_index})") + if peak < 200: + print(" → 거의 무음. 마이크 연결·권한·MIC_INDEX 확인 필요.") + elif peak < 2000: + print(" → 입력은 있으나 작음. 마이크 게인 올려보세요.") + else: + print(" → 마이크 입력 정상으로 보입니다.") + + +def test_gemini() -> None: + import google.generativeai as genai + + print("\n[제미나이] 짧은 텍스트 질의...") + key = load_api_key() + genai.configure(api_key=key) + model_name = os.environ.get("GEMINI_MODEL", "gemini-2.0-flash") + model = genai.GenerativeModel(model_name) + try: + r = model.generate_content("한 문장으로만 답해: 너는 누구야?") + print(f" 모델={model_name}") + print(f" 답: {(r.text or '').strip()}") + except Exception as e: + print(f" 실패: {e}") + print(" → 모델 이름을 gemini-1.5-flash 등으로 바꿔보세요.") + + +def main() -> None: + p = argparse.ArgumentParser(description="자비스 원격 테스트") + p.add_argument("--volume", type=int, default=100, help="시스템 볼륨 %% (기본 100)") + p.add_argument("--speak-only", action="store_true") + p.add_argument("--mic-only", action="store_true") + p.add_argument("--gemini-only", action="store_true") + p.add_argument("--mic-index", type=int, default=None) + p.add_argument("--no-beep", action="store_true") + args = p.parse_args() + + print("=== 자비스 원격 테스트 ===") + set_volume(args.volume) + show_volume() + + only = args.speak_only or args.mic_only or args.gemini_only + if not only: + list_devices() + + if args.mic_only: + test_mic(mic_index=args.mic_index) + return + if args.gemini_only: + test_gemini() + return + if args.speak_only: + if not args.no_beep: + test_beep() + test_speak(volume_pygame=1.0) + return + + if not args.no_beep: + test_beep() + test_speak(volume_pygame=1.0) + test_mic(mic_index=args.mic_index) + test_gemini() + print("\n=== 끝 ===") + print("카메라로 TTS/비프가 들렸는지 확인해 주세요.") + print("다시 크게: python ~/test_jarvis.py --speak-only --volume 100") + + +if __name__ == "__main__": + main() diff --git a/beep.wav b/beep.wav new file mode 100644 index 0000000..c2aed00 Binary files /dev/null and b/beep.wav differ diff --git a/jarvis.env.example b/jarvis.env.example new file mode 100644 index 0000000..acf90b8 --- /dev/null +++ b/jarvis.env.example @@ -0,0 +1,22 @@ +# jarvis.env.example — 복사: cp jarvis.env.example jarvis.env 후 키 입력 + +WAKE_MODE=jarvis +OWW_WAKE_MODEL=/home/hoon/projects/jarvis/jarvis_models/your_model.tflite +OWW_INFERENCE_FRAMEWORK=tflite +LANG_MODE=ko + +WAKE_THRESHOLD=0.27 +STT_BACKEND=web +STT_CLOUD_MODEL=command_and_search +STT_CLOUD_FALLBACK=1 + +TTS_BACKEND=edge +TTS_VOICE=ko-KR-SunHiNeural + +GEMINI_MODEL=gemini-2.5-flash +GOOGLE_API_KEY= +YOUTUBE_API_KEY= +YOUTUBE_SEARCH_MODE=auto + +PORCUPINE_DIR=/home/hoon/projects/jarvis/jarvis_porcupine +PICOVOICE_ACCESS_KEY= diff --git a/jarvis.py b/jarvis.py new file mode 100644 index 0000000..9a96d82 --- /dev/null +++ b/jarvis.py @@ -0,0 +1,3230 @@ +#!/usr/bin/env python3 +"""헤이 자비스 — 호출어(openWakeWord/Porcupine) + Google STT + Gemini + TTS + +호출어 +------ +- WAKE_MODE=jarvis → openWakeWord (영어, jarvis.env OWW_WAKE_MODEL) + 예: alexa(쉬움), hey_jarvis, hey_mycroft, timer, weather +- WAKE_MODE=korean → Picovoice Porcupine 한국어 (로컬, STT 호출어 없음) + 필요: jarvis.env 의 PICOVOICE_ACCESS_KEY + 모델/키워드: ~/jarvis_porcupine/ (없으면 Access Key로 .ppn 자동 생성) + +영구 메모리 (로컬 파일, 웹 제미나이 기억과 무관) +---------------------------------------------- +파일: ~/projects/jarvis/jarvis_memory.json +명령: + 기억해 아들은 맵기 싫어해 → 한 줄 저장 (말투/사실 모두 가능) + 잊어 맵기 → 그 단어 들어간 기억 삭제 + 기억 뭐 있어? / 기억 목록 → 저장된 목록 읽기 + 기억 다 지워 → 전체 삭제 +질문할 때마다 system 지침에 붙여 보내므로 입력 토큰에 포함됨(짧게 유지). + +대화 로그 (질문/답변 분리) +-------------------------- +- ~/jarvis_logs/questions.json +- ~/jarvis_logs/answers.json +동일 turn_id 로 짝을 맞출 수 있음. + +API / 문서 +--------- +- Gemini: https://ai.google.dev/gemini-api/docs + 키: https://aistudio.google.com/apikey + 요금: https://ai.google.dev/gemini-api/docs/pricing +- Porcupine: https://console.picovoice.ai/ (Access Key) +- STT: SpeechRecognition Google Web Speech(무료) 또는 Cloud Speech-to-Text + 전환: ~/projects/jarvis/jarvis_runtime.json 의 "stt_backend": "web" | "cloud" (재시작 불필요) +- TTS: gemini-2.5-flash-preview-tts (기본) 또는 edge-tts (무료 백업) +""" + +from __future__ import annotations + +import asyncio +import audioop +import base64 +import contextlib +import html +import io +import json +import logging +import os +import re +import subprocess +import threading +import time +import uuid +import urllib.error +import urllib.parse +import urllib.request +import wave +from concurrent.futures import Future, ThreadPoolExecutor +from datetime import datetime, timezone +from ctypes import CFUNCTYPE, c_char_p, c_int, cdll + +os.environ["PYGAME_HIDE_SUPPORT_PROMPT"] = "hide" + +import warnings + +warnings.filterwarnings("ignore", category=FutureWarning) +warnings.filterwarnings("ignore", message=".*automatic function calling.*") + +import edge_tts +import google.generativeai as genai +from google import genai as genai_client +from google.genai import types as genai_types +import numpy as np +import pyaudio +import pygame +import speech_recognition as sr +from openwakeword.model import Model + +# ALSA 경고 숨기기 (콜백은 GC되면 segfault 나므로 전역으로 유지) +ERROR_HANDLER_FUNC = CFUNCTYPE(None, c_char_p, c_int, c_char_p, c_int, c_char_p) +_alsa_error_handler = None + + +def _py_error_handler(filename, line, function, err, fmt): + pass + + +try: + asound = cdll.LoadLibrary("libasound.so.2") + _alsa_error_handler = ERROR_HANDLER_FUNC(_py_error_handler) + asound.snd_lib_error_set_handler(_alsa_error_handler) +except Exception: + pass + +# ========================================== +# 설정 (~/projects/jarvis/jarvis.env 가 우선) +# ========================================== +BASE_DIR = os.path.dirname(os.path.abspath(__file__)) or "." +ENV_PATH = os.path.join(BASE_DIR, "jarvis.env") +MEMORY_PATH = os.path.join(BASE_DIR, "jarvis_memory.json") +MEMORY_MAX = 50 +HOOKS_DIR = os.path.join(BASE_DIR, "jarvis_hooks") +LOG_DIR = os.path.join(BASE_DIR, "jarvis_logs") +QUESTIONS_LOG = os.path.join(LOG_DIR, "questions.json") +ANSWERS_LOG = os.path.join(LOG_DIR, "answers.json") +USAGE_SUMMARY_PATH = os.path.join(LOG_DIR, "usage_summary.json") +RUNTIME_CONFIG_PATH = os.path.join(BASE_DIR, "jarvis_runtime.json") +_runtime_mtime: float = 0.0 + + +def load_env_file(path: str) -> None: + """KEY=VALUE 형식. jarvis.env → os.environ.""" + if not os.path.isfile(path): + return + with open(path, encoding="utf-8") as f: + for raw in f: + line = raw.strip() + if not line or line.startswith("#") or "=" not in line: + continue + key, val = line.split("=", 1) + key = key.strip() + val = val.strip().strip('"').strip("'") + if key: + os.environ[key] = val + + +load_env_file(ENV_PATH) + + +def _runtime_defaults() -> dict: + return { + "_help": "수정 저장하면 재시작 없이 자동 적용됩니다. stt_backend: web(무료) | cloud(유료 Cloud STT).", + "wake_threshold": float(os.environ.get("WAKE_THRESHOLD", "0.27")), + "wake_min_frames": int(os.environ.get("WAKE_MIN_FRAMES", "2")), + "wake_strong_score": float(os.environ.get("WAKE_STRONG_SCORE", "0.52")), + "min_wake_interval_sec": float(os.environ.get("MIN_WAKE_INTERVAL_SEC", "10")), + "stt_pause_threshold": float(os.environ.get("STT_PAUSE_THRESHOLD", "0.5")), + "stt_non_speaking_sec": float(os.environ.get("STT_NON_SPEAKING_SEC", "0.5")), + "stt_mic_gain": float(os.environ.get("STT_MIC_GAIN", "1.35")), + "stt_energy_threshold": float(os.environ.get("STT_ENERGY_THRESHOLD", "280")), + "stt_phrase_limit_sec": float(os.environ.get("STT_PHRASE_LIMIT_SEC", "12")), + "stt_max_record_sec": float(os.environ.get("STT_MAX_RECORD_SEC", "15")), + "stt_min_pause_sec": float(os.environ.get("STT_MIN_PAUSE_SEC", "1.0")), + "stt_silence_peak_ratio": float(os.environ.get("STT_SILENCE_PEAK_RATIO", "0.85")), + "stt_speech_start_ratio": float(os.environ.get("STT_SPEECH_START_RATIO", "1.35")), + "stt_phrase_threshold": float(os.environ.get("STT_PHRASE_THRESHOLD", "0.05")), + "stt_timeout_sec": float(os.environ.get("STT_TIMEOUT_SEC", "5")), + "chat_history_max": int(os.environ.get("CHAT_HISTORY_MAX", "6")), + "stt_backend": os.environ.get("STT_BACKEND", "web").strip().lower() or "web", + } + + +_RUNTIME_FLOAT = { + "wake_threshold": "WAKE_THRESHOLD", + "wake_strong_score": "WAKE_STRONG_SCORE", + "min_wake_interval_sec": "MIN_WAKE_INTERVAL_SEC", + "stt_pause_threshold": "STT_PAUSE_THRESHOLD", + "stt_non_speaking_sec": "STT_NON_SPEAKING_SEC", + "stt_mic_gain": "STT_MIC_GAIN", + "stt_energy_threshold": "STT_ENERGY_THRESHOLD", + "stt_phrase_limit_sec": "STT_PHRASE_LIMIT_SEC", + "stt_max_record_sec": "STT_MAX_RECORD_SEC", + "stt_min_pause_sec": "STT_MIN_PAUSE_SEC", + "stt_silence_peak_ratio": "STT_SILENCE_PEAK_RATIO", + "stt_speech_start_ratio": "STT_SPEECH_START_RATIO", + "stt_phrase_threshold": "STT_PHRASE_THRESHOLD", + "stt_timeout_sec": "STT_TIMEOUT_SEC", +} +_RUNTIME_INT = { + "wake_min_frames": "WAKE_MIN_FRAMES", + "chat_history_max": "CHAT_HISTORY_MAX", +} +_RUNTIME_STR = { + "stt_backend": "STT_BACKEND", +} + + +def ensure_runtime_config_file() -> None: + defaults = _runtime_defaults() + if not os.path.isfile(RUNTIME_CONFIG_PATH): + with open(RUNTIME_CONFIG_PATH, "w", encoding="utf-8") as f: + json.dump(defaults, f, ensure_ascii=False, indent=2) + print(f"실시간 설정 파일 생성: {RUNTIME_CONFIG_PATH}") + return + try: + with open(RUNTIME_CONFIG_PATH, encoding="utf-8") as f: + data = json.load(f) + except (OSError, json.JSONDecodeError): + return + if not isinstance(data, dict): + return + missing = {k: v for k, v in defaults.items() if k not in data} + if not missing: + return + data.update(missing) + with open(RUNTIME_CONFIG_PATH, "w", encoding="utf-8") as f: + json.dump(data, f, ensure_ascii=False, indent=2) + print(f"실시간 설정 키 추가: {', '.join(missing)}") + + +def load_runtime_config_if_changed(verbose: bool = False) -> bool: + """jarvis_runtime.json 변경 시 즉시 적용 (재시작 불필요).""" + global _runtime_mtime + if not os.path.isfile(RUNTIME_CONFIG_PATH): + return False + try: + mtime = os.path.getmtime(RUNTIME_CONFIG_PATH) + except OSError: + return False + if mtime == _runtime_mtime: + return False + try: + with open(RUNTIME_CONFIG_PATH, encoding="utf-8") as f: + data = json.load(f) + except (OSError, json.JSONDecodeError) as e: + print(f"jarvis_runtime.json 읽기 실패: {e}") + return False + if not isinstance(data, dict): + return False + applied: list[str] = [] + g = globals() + for key, var in _RUNTIME_FLOAT.items(): + if key in data and data[key] is not None: + g[var] = float(data[key]) + applied.append(f"{key}={g[var]}") + for key, var in _RUNTIME_INT.items(): + if key in data and data[key] is not None: + g[var] = int(data[key]) + applied.append(f"{key}={g[var]}") + if "chat_history_max" in data and data["chat_history_max"] is not None: + max_h = int(data["chat_history_max"]) + if len(_session_history) > max_h: + _session_history[:] = _session_history[-max_h:] + for key, var in _RUNTIME_STR.items(): + if key in data and data[key] is not None: + val = str(data[key]).strip().lower() + g[var] = val + applied.append(f"{key}={val}") + _runtime_mtime = mtime + if verbose and applied: + print(f"[실시간 설정 적용] {', '.join(applied)}") + return True + + +GOOGLE_API_KEY = os.environ.get( + "GOOGLE_API_KEY", + "AIzaSyBHMoObQ_yYW2njaGenc3_j4CuCIFAaadA", +) + +GEMINI_MODEL = os.environ.get("GEMINI_MODEL", "gemini-2.5-flash") +GEMINI_MAX_OUTPUT_TOKENS = int(os.environ.get("GEMINI_MAX_OUTPUT_TOKENS", "512")) +GEMINI_VOICE_MAX_TOKENS = int(os.environ.get("GEMINI_VOICE_MAX_TOKENS", "256")) +GEMINI_STREAM = os.environ.get("GEMINI_STREAM", "1") == "1" +TTS_CHUNK_CHARS = int(os.environ.get("TTS_CHUNK_CHARS", "180")) +PRICE_INPUT_PER_M = 0.30 +PRICE_OUTPUT_PER_M = 2.50 +USD_KRW = float(os.environ.get("USD_KRW", "1400")) + +WAKE_MODE = os.environ.get("WAKE_MODE", "jarvis").strip().lower() +LANG_MODE = os.environ.get("LANG_MODE", "ko").strip().lower() + +WAKE_THRESHOLD = float(os.environ.get("WAKE_THRESHOLD", "0.27")) +WAKE_MIN_FRAMES = int(os.environ.get("WAKE_MIN_FRAMES", "2")) +WAKE_STRONG_SCORE = float(os.environ.get("WAKE_STRONG_SCORE", "0.52")) +def _is_oww_model_path(spec: str) -> bool: + return "/" in spec or spec.endswith((".tflite", ".onnx")) + + +_oww_raw = os.environ.get("OWW_WAKE_MODEL", "alexa").strip() +_oww_parts = [p.strip() for p in _oww_raw.split(",") if p.strip()] +OWW_WAKE_PATHS: list[str] = [] +OWW_WAKE_LABELS: list[str] = [] +for _spec in _oww_parts: + if _is_oww_model_path(_spec): + OWW_WAKE_PATHS.append(_spec) + OWW_WAKE_LABELS.append(os.path.splitext(os.path.basename(_spec))[0]) + else: + name = _spec.lower() + OWW_WAKE_PATHS.append(name) + OWW_WAKE_LABELS.append(name) + +OWW_INFERENCE_FRAMEWORK = os.environ.get("OWW_INFERENCE_FRAMEWORK", "").strip().lower() +if not OWW_INFERENCE_FRAMEWORK: + OWW_INFERENCE_FRAMEWORK = ( + "tflite" + if any(s.endswith(".tflite") for s in _oww_parts if _is_oww_model_path(s)) + else "onnx" + ) +OWW_WAKE_HINT = os.environ.get("OWW_WAKE_HINT", "").strip() +_mic_env = os.environ.get("STT_MIC_INDEX", os.environ.get("MIC_INDEX", "")).strip() +MIC_INDEX = int(_mic_env) if _mic_env else None +STT_MIC_INDEX: int | None = MIC_INDEX +SPEAKER_VOLUME = 100 +WAKE_FRAME = 1280 +WAKE_DEBUG = os.environ.get("WAKE_DEBUG", "0") == "1" +WAKE_COOLDOWN_SEC = float(os.environ.get("WAKE_COOLDOWN_SEC", "3.0")) +MIN_WAKE_INTERVAL_SEC = float(os.environ.get("MIN_WAKE_INTERVAL_SEC", "10.0")) +LISTEN_FAIL_COOLDOWN_SEC = float(os.environ.get("LISTEN_FAIL_COOLDOWN_SEC", "5.0")) +_last_wake_at = 0.0 +STT_TIMEOUT_SEC = float(os.environ.get("STT_TIMEOUT_SEC", "5")) +STT_PHRASE_LIMIT_SEC = float(os.environ.get("STT_PHRASE_LIMIT_SEC", "12")) +STT_MAX_RECORD_SEC = float(os.environ.get("STT_MAX_RECORD_SEC", "15")) +STT_MIN_PAUSE_SEC = float(os.environ.get("STT_MIN_PAUSE_SEC", "1.0")) +STT_SILENCE_PEAK_RATIO = float(os.environ.get("STT_SILENCE_PEAK_RATIO", "0.85")) +STT_SPEECH_START_RATIO = float(os.environ.get("STT_SPEECH_START_RATIO", "1.35")) +STT_SMART_END = os.environ.get("STT_SMART_END", "1") == "1" +STT_PHRASE_THRESHOLD = float(os.environ.get("STT_PHRASE_THRESHOLD", "0.05")) +STT_PAUSE_THRESHOLD = float(os.environ.get("STT_PAUSE_THRESHOLD", "1.0")) +STT_NON_SPEAKING_SEC = float(os.environ.get("STT_NON_SPEAKING_SEC", "0.55")) +STT_NOISY_THRESHOLD = float(os.environ.get("STT_NOISY_THRESHOLD", "380")) +STT_AMBIENT_SEC = float(os.environ.get("STT_AMBIENT_SEC", "0.3")) +STT_POST_BEEP_DELAY_SEC = float(os.environ.get("STT_POST_BEEP_DELAY_SEC", "0.15")) +STT_RECAL_BEFORE_LISTEN = os.environ.get("STT_RECAL_BEFORE_LISTEN", "0") == "1" +STT_RETRY_MAX = int(os.environ.get("STT_RETRY_MAX", "1")) +STT_MIC_GAIN = float(os.environ.get("STT_MIC_GAIN", "1.35")) +STT_ENERGY_THRESHOLD = float(os.environ.get("STT_ENERGY_THRESHOLD", "280")) +STT_QUIET_MODE = os.environ.get("STT_QUIET_MODE", "1") == "1" +STT_BACKEND = os.environ.get("STT_BACKEND", "web").strip().lower() or "web" +STT_CLOUD_MODEL = os.environ.get("STT_CLOUD_MODEL", "command_and_search").strip() +STT_CLOUD_FALLBACK = os.environ.get("STT_CLOUD_FALLBACK", "1") == "1" +CHAT_HISTORY_MAX = int(os.environ.get("CHAT_HISTORY_MAX", "6")) +PULSE_SOURCE = os.environ.get("PULSE_SOURCE", "").strip() +YT_DLP_BIN = os.environ.get( + "YT_DLP_BIN", os.path.join(BASE_DIR, "jarvis_env", "bin", "yt-dlp") +) +YOUTUBE_API_KEY = ( + os.environ.get("YOUTUBE_API_KEY", "").strip() or GOOGLE_API_KEY.strip() +) +YOUTUBE_SEARCH_MODE = os.environ.get("YOUTUBE_SEARCH_MODE", "auto").strip().lower() + +OWW_WAKE_HINTS = { + "alexa": "「Alexa」(알렉사)", + "hey_jarvis": "「Hey Jarvis」", + "hey_mycroft": "「Hey Mycroft」", + "hey_rhasspy": "「Hey Rhasspy」", + "timer": "「timer」", + "weather": "「weather」", +} + +PICOVOICE_ACCESS_KEY = os.environ.get("PICOVOICE_ACCESS_KEY", "").strip() +PORCUPINE_DIR = os.environ.get( + "PORCUPINE_DIR", os.path.join(BASE_DIR, "jarvis_porcupine") +) +PORCUPINE_MODEL_PATH = os.environ.get( + "PORCUPINE_MODEL_PATH", + os.path.join(PORCUPINE_DIR, "porcupine_params_ko.pv"), +) +PORCUPINE_KEYWORD_PATH = os.environ.get("PORCUPINE_KEYWORD_PATH", "").strip() +PORCUPINE_PHRASES = os.environ.get("PORCUPINE_PHRASES", "자비스,헤이 자비스") +PORCUPINE_SENSITIVITY = float(os.environ.get("PORCUPINE_SENSITIVITY", "0.55")) +PORCUPINE_LANGUAGE = os.environ.get("PORCUPINE_LANGUAGE", "ko").strip().lower() +PORCUPINE_MODEL_URL = ( + "https://github.com/Picovoice/porcupine/raw/master/lib/common/" + "porcupine_params_ko.pv" +) + +if LANG_MODE.startswith("en"): + STT_LANGUAGE = "en-US" + DEFAULT_EDGE_TTS_VOICE = "en-US-JennyNeural" + DEFAULT_GEMINI_TTS_VOICE = "Kore" + BASE_INSTRUCTION = ( + "You are Jarvis. Answer clearly and briefly in English. " + "Help with cooking, schedule, and daily questions. " + "Use [permanent memory] when present." + ) +else: + STT_LANGUAGE = "ko-KR" + DEFAULT_EDGE_TTS_VOICE = "ko-KR-SunHiNeural" + DEFAULT_GEMINI_TTS_VOICE = "Kore" + BASE_INSTRUCTION = ( + "너는 자비스야. 사용자는 마이크로 말했고 입력은 음성인식 결과야. " + "소리를 못 듣거나 글자로만 대화한다고 말하지 마. " + "한국어로 짧고 명확하게 대답해. " + "말투는 나긋하고 부드럽게. " + "답변은 말로 읽기 좋게 2~4문장으로 끝맺음까지 완결하게. " + "레시피도 4~5줄 이내지만 중간에 끊지 말고 끝까지. " + "불필요한 인사·서론(알겠습니다 등) 생략하고 본론부터. " + "날씨는 기온·맑음·비 정도만 한두 문장으로 끝까지. " + "실시간 조회 불가 설명은 생략하고 추정만 짧게. " + "요리·일정·일상 질문에 실용적으로 도와줘. " + "유튜브·노래 재생은 로컬에서 처리된다. " + "노래를 찾았다/못 찾았다거나 유튜브를 검색했다고 말하지 마. " + "재생은 사용자가 「OOO 틀어줘」라고 말하면 된다고만 짧게 안내해. " + "아래 [영구 기억]이 있으면 사실·말투·취향을 반드시 반영해. " + "이전 대화 맥락이 함께 전달되면 반드시 참고해. " + "집 기기는 도구로만 제어한다. 잡담·지식(사자 호랑이 등)은 도구 없이 답한다. " + "한 문장에 장면이면 도구를 여러 개 호출해도 된다. " + "드라마·영화 볼게 → 티비 ON + 거실/메인 조명 OFF. " + "잘게·자야지 → 관련 조명 OFF, 켜져 있으면 티비도 OFF. " + "더워·땀나·미치겠네(집 맥락) → 에어컨 turn_on 후 통보. " + "절대 되묻지 마(켤까요 금지). 애매한 장난·투정은 기기 건드리지 말고 말로만. " + "꺼·켜·맞춰면 명령이다. 방 이름 없으면 거실 기본 에어컨·조명·티비. " + "스냅샷에 없거나 스위치 꺼진 기기는 만들지 말고 없다고만 말해. " + "티비는 전원만(채널·앱 검색 금지). 실행 결과를 한두 문장으로 말해." + ) + +if WAKE_MODE in ("korean", "ko", "hangul", "한글", "porcupine"): + WAKE_PROMPT = "'자비스' 또는 '헤이 자비스'라고 불러보세요." +else: + if OWW_WAKE_HINT: + wake_hint = f"「{OWW_WAKE_HINT}」" + else: + hints = [OWW_WAKE_HINTS.get(m, m) for m in OWW_WAKE_LABELS] + wake_hint = " 또는 ".join(hints) + WAKE_PROMPT = ( + wake_hint + + "라고 **한 번만** 불러요. 삐 소리 후 바로 질문. " + "연속으로 부르면 삐만 여러 번 나와요." + ) + +TTS_BACKEND = os.environ.get("TTS_BACKEND", "edge").strip().lower() +TTS_MODEL = os.environ.get("TTS_MODEL", "gemini-2.5-flash-preview-tts") +TTS_VOICE = os.environ.get( + "TTS_VOICE", + DEFAULT_GEMINI_TTS_VOICE if TTS_BACKEND == "gemini" else DEFAULT_EDGE_TTS_VOICE, +) +TTS_RATE = os.environ.get("TTS_RATE", "+10%") +TTS_PITCH = os.environ.get("TTS_PITCH", "+0Hz") +TTS_STYLE = os.environ.get( + "TTS_STYLE", + "나긋하고 부드럽게, 조금 빠른 속도로 말해줘:", +) +TTS_SAMPLE_RATE = int(os.environ.get("TTS_SAMPLE_RATE", "24000")) +TTS_PLAY_LATENCY_MS = int(os.environ.get("TTS_PLAY_LATENCY_MS", "40")) + +_gemini_tts_client: genai_client.Client | None = None +_gemini_text_client: genai_client.Client | None = None + +if not GOOGLE_API_KEY or "여기에_" in GOOGLE_API_KEY: + raise SystemExit( + "제미나이 API 키를 설정하세요.\n" + " 1) jarvis.env 에 GOOGLE_API_KEY=... 또는\n" + " 2) jarvis.py / export GOOGLE_API_KEY" + ) + +genai.configure(api_key=GOOGLE_API_KEY) +logging.getLogger("google.genai").setLevel(logging.ERROR) + +model = None # type: ignore +chat = None # type: ignore +_session_history: list[tuple[str, str]] = [] +_stt_ambient_calibrated = False +_stt_noisy_background = False + +_mixer_ready = False +PULSE_SINK = os.environ.get("PULSE_SINK", "").strip() +_media_proc: subprocess.Popen | None = None +_media_lock = threading.RLock() +_youtube_pending: tuple[str, str | None] | None = None + + +_tts_executor = ThreadPoolExecutor(max_workers=1, thread_name_prefix="jarvis-tts") + +_speech_active = False +_speech_interruptible = False +_speech_interrupt = threading.Event() +_pending_interrupt_listen = False +_tts_play_proc: subprocess.Popen | None = None +_tts_play_lock = threading.Lock() +_wake_interrupt_ctx: dict = {} +_interrupt_watcher_stop = threading.Event() +_wake_mic_paused = False + + +def _pause_wake_mic(mic_stream) -> bool: + """STT용 마이크 전환 — wake 감시 스레드가 읽지 않게.""" + global _wake_mic_paused + try: + if not mic_stream.is_stopped(): + mic_stream.stop_stream() + _wake_mic_paused = True + return True + except OSError: + _wake_mic_paused = True + return False + + +def _restart_wake_mic(mic_stream) -> bool: + """호출어·답변 중 끊기 감시용 wake 마이크 재개.""" + global _wake_mic_paused + try: + if mic_stream.is_stopped(): + mic_stream.start_stream() + _wake_mic_paused = False + return True + except OSError as e: + print(f"[마이크] wake 스트림 복구 실패: {e}") + ctx = _wake_interrupt_ctx + audio = ctx.get("audio") + if audio is None: + _wake_mic_paused = True + return False + try: + try: + mic_stream.close() + except OSError: + pass + new_stream, new_rate = open_mic_stream(audio) + ctx["mic_stream"] = new_stream + ctx["mic_rate"] = new_rate + ctx["chunk"] = max(512, int(new_rate * 0.08)) + _wake_mic_paused = False + print("[마이크] wake 스트림 재오픈") + return True + except OSError as e2: + print(f"[마이크] wake 스트림 재오픈 실패: {e2}") + _wake_mic_paused = True + return False + + +def _stop_tts_playback() -> None: + """TTS paplay/pygame 중지.""" + global _tts_play_proc + with _tts_play_lock: + proc = _tts_play_proc + _tts_play_proc = None + if proc is not None and proc.poll() is None: + proc.terminate() + try: + proc.wait(timeout=0.5) + except subprocess.TimeoutExpired: + proc.kill() + subprocess.run( + ["pkill", "-x", "paplay"], + check=False, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ) + try: + if _mixer_ready: + pygame.mixer.music.stop() + except Exception: + pass + + +def request_speech_interrupt() -> None: + _speech_interrupt.set() + _stop_tts_playback() + _stop_media_playback() + + +def _begin_speech(interruptible: bool = True) -> None: + global _speech_active, _speech_interruptible + _speech_interrupt.clear() + _speech_active = True + _speech_interruptible = interruptible + + +def _end_speech() -> None: + global _speech_active, _speech_interruptible + _speech_active = False + _speech_interruptible = False + _speech_interrupt.clear() + + +def _play_audio_file(path: str) -> bool: + """3.5mm 등 PULSE_SINK로 재생. 중단 시 False.""" + if _speech_interrupt.is_set(): + return False + env = os.environ.copy() + if PULSE_SINK: + env["PULSE_SINK"] = PULSE_SINK + paplay_cmd = [ + "paplay", + f"--latency-msec={max(10, TTS_PLAY_LATENCY_MS)}", + path, + ] + try: + proc = subprocess.Popen( + paplay_cmd, + env=env, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ) + with _tts_play_lock: + global _tts_play_proc + _tts_play_proc = proc + while proc.poll() is None: + if _speech_interrupt.is_set(): + proc.terminate() + try: + proc.wait(timeout=0.5) + except subprocess.TimeoutExpired: + proc.kill() + return False + time.sleep(0.05) + with _tts_play_lock: + if _tts_play_proc is proc: + _tts_play_proc = None + if proc.returncode != 0: + raise subprocess.CalledProcessError(proc.returncode, paplay_cmd) + return not _speech_interrupt.is_set() + except (OSError, subprocess.CalledProcessError) as e: + print(f"paplay 실패, pygame으로 대체: {e}") + if _speech_interrupt.is_set(): + return False + _ensure_mixer() + pygame.mixer.music.set_volume(1.0) + pygame.mixer.music.load(path) + pygame.mixer.music.play() + while pygame.mixer.music.get_busy(): + if _speech_interrupt.is_set(): + pygame.mixer.music.stop() + pygame.mixer.music.unload() + return False + time.sleep(0.05) + pygame.mixer.music.unload() + return not _speech_interrupt.is_set() + + +def _ensure_mixer() -> None: + """pygame mixer는 마이크/ONNX와 충돌할 수 있어 TTS 직전에만 초기화.""" + global _mixer_ready + if _mixer_ready: + return + pygame.mixer.init() + _mixer_ready = True + + +# ---------- 대화 로그 (질문/답변 분리 JSON) ---------- +def _read_json_list(path: str) -> list: + if not os.path.isfile(path): + return [] + try: + data = json.load(open(path, encoding="utf-8")) + return data if isinstance(data, list) else [] + except (OSError, json.JSONDecodeError, TypeError): + return [] + + +def _append_json_list(path: str, item: dict) -> None: + os.makedirs(os.path.dirname(path), exist_ok=True) + items = _read_json_list(path) + items.append(item) + tmp = path + ".tmp" + with open(tmp, "w", encoding="utf-8") as f: + json.dump(items, f, ensure_ascii=False, indent=2) + f.write("\n") + os.replace(tmp, path) + + +def log_question( + text: str, + *, + source: str = "voice", + turn_id: str | None = None, + stt_engine: str | None = None, +) -> str: + """질문을 questions.json 에 추가. turn_id 반환.""" + tid = turn_id or uuid.uuid4().hex[:12] + row = { + "id": tid, + "time": datetime.now(timezone.utc).astimezone().isoformat(timespec="seconds"), + "text": text, + "source": source, + } + if stt_engine: + row["stt_engine"] = stt_engine + _append_json_list(QUESTIONS_LOG, row) + return tid + + +def log_answer( + text: str, + *, + turn_id: str, + kind: str = "gemini", + extra: dict | None = None, + usage: dict | None = None, +) -> None: + """답변을 answers.json 에 추가 (질문 id 와 연결).""" + row = { + "id": uuid.uuid4().hex[:12], + "turn_id": turn_id, + "time": datetime.now(timezone.utc).astimezone().isoformat(timespec="seconds"), + "text": text, + "kind": kind, # gemini | memory | device + } + if usage: + row["usage"] = usage + if extra: + row["extra"] = extra + _append_json_list(ANSWERS_LOG, row) + if kind == "gemini" and usage: + _increment_gemini_turn_count() + + +def _empty_usage_bucket() -> dict: + return { + "prompt_tokens": 0, + "output_tokens": 0, + "total_tokens": 0, + "usd": 0.0, + "krw": 0.0, + "api_calls": 0, + } + + +def _usage_from_genai_response(response) -> dict | None: + usage = getattr(response, "usage_metadata", None) + if not usage: + return None + pin = int(getattr(usage, "prompt_token_count", 0) or 0) + pout = int(getattr(usage, "candidates_token_count", 0) or 0) + total = int(getattr(usage, "total_token_count", 0) or (pin + pout)) + usd = estimate_cost_usd(pin, pout) + krw = usd * USD_KRW + return { + "prompt_tokens": pin, + "output_tokens": pout, + "total_tokens": total, + "usd": round(usd, 6), + "krw": round(krw, 2), + } + + +def _merge_usage_bucket(dest: dict, src: dict) -> None: + dest["prompt_tokens"] = int(dest.get("prompt_tokens", 0)) + int(src.get("prompt_tokens", 0)) + dest["output_tokens"] = int(dest.get("output_tokens", 0)) + int(src.get("output_tokens", 0)) + dest["total_tokens"] = int(dest.get("total_tokens", 0)) + int(src.get("total_tokens", 0)) + dest["usd"] = round(float(dest.get("usd", 0)) + float(src.get("usd", 0)), 6) + dest["krw"] = round(float(dest.get("krw", 0)) + float(src.get("krw", 0)), 2) + dest["api_calls"] = int(dest.get("api_calls", 0)) + int(src.get("api_calls", 1)) + + +class _TurnUsage: + """한 턴(질문 1개) 안의 API 호출 usage 합산 — answers.json 1행에 저장.""" + + def __init__(self) -> None: + self._calls: list[dict] = [] + + def add(self, usage: dict | None, *, label: str = "") -> None: + if not usage: + return + row = dict(usage) + if label: + row["label"] = label + self._calls.append(row) + + def merged(self) -> dict | None: + if not self._calls: + return None + out = _empty_usage_bucket() + for call in self._calls: + _merge_usage_bucket(out, call) + out["api_calls"] = len(self._calls) + if len(self._calls) > 1: + out["calls"] = self._calls + return out + + +def load_usage_summary() -> dict: + if not os.path.isfile(USAGE_SUMMARY_PATH): + return { + "updated": None, + "totals": _empty_usage_bucket(), + "totals_extra": {"gemini_turns": 0}, + "today": None, + } + try: + data = json.load(open(USAGE_SUMMARY_PATH, encoding="utf-8")) + if not isinstance(data, dict): + raise TypeError("usage summary not dict") + except (OSError, json.JSONDecodeError, TypeError): + return { + "updated": None, + "totals": _empty_usage_bucket(), + "totals_extra": {"gemini_turns": 0}, + "today": None, + } + data.setdefault("totals", _empty_usage_bucket()) + data.setdefault("totals_extra", {"gemini_turns": 0}) + return data + + +def save_usage_summary(summary: dict) -> None: + summary["updated"] = datetime.now(timezone.utc).astimezone().isoformat( + timespec="seconds" + ) + os.makedirs(os.path.dirname(USAGE_SUMMARY_PATH), exist_ok=True) + tmp = USAGE_SUMMARY_PATH + ".tmp" + with open(tmp, "w", encoding="utf-8") as f: + json.dump(summary, f, ensure_ascii=False, indent=2) + f.write("\n") + os.replace(tmp, USAGE_SUMMARY_PATH) + + +def _today_bucket(summary: dict) -> dict: + today_str = datetime.now(timezone.utc).astimezone().date().isoformat() + today = summary.get("today") + if not isinstance(today, dict) or today.get("date") != today_str: + today = {"date": today_str, **_empty_usage_bucket()} + today["gemini_turns"] = 0 + summary["today"] = today + return today + + +def record_api_usage(usage: dict | None) -> dict: + """API 1회 usage를 summary에 반영하고 전체 totals 반환.""" + if not usage: + return load_usage_summary()["totals"] + summary = load_usage_summary() + u = dict(usage) + u.setdefault("api_calls", 1) + _merge_usage_bucket(summary["totals"], u) + _merge_usage_bucket(_today_bucket(summary), u) + save_usage_summary(summary) + return summary["totals"] + + +def _increment_gemini_turn_count() -> None: + summary = load_usage_summary() + extra = summary.setdefault("totals_extra", {"gemini_turns": 0}) + extra["gemini_turns"] = int(extra.get("gemini_turns", 0)) + 1 + today = _today_bucket(summary) + today["gemini_turns"] = int(today.get("gemini_turns", 0)) + 1 + save_usage_summary(summary) + + +def rebuild_usage_summary_from_logs() -> dict: + """answers.json 의 usage 필드에서 summary 재계산 (마이그레이션·복구용).""" + totals = _empty_usage_bucket() + totals_extra = {"gemini_turns": 0} + today_str = datetime.now(timezone.utc).astimezone().date().isoformat() + today = {"date": today_str, **_empty_usage_bucket(), "gemini_turns": 0} + for row in _read_json_list(ANSWERS_LOG): + if row.get("kind") != "gemini": + continue + totals_extra["gemini_turns"] += 1 + if str(row.get("time", "")).startswith(today_str): + today["gemini_turns"] += 1 + usage = row.get("usage") + if not isinstance(usage, dict): + continue + _merge_usage_bucket(totals, usage) + if str(row.get("time", "")).startswith(today_str): + _merge_usage_bucket(today, usage) + summary = { + "updated": None, + "totals": totals, + "totals_extra": totals_extra, + "today": today, + } + save_usage_summary(summary) + return summary + + +def format_usage_totals_line(totals: dict, *, prefix: str = "누적") -> str: + return ( + f"{prefix}: 토큰 {int(totals.get('total_tokens', 0))} " + f"(입력 {int(totals.get('prompt_tokens', 0))} / 출력 {int(totals.get('output_tokens', 0))}) " + f"| ≈ ${float(totals.get('usd', 0)):.4f} (약 {float(totals.get('krw', 0)):.2f}원) " + f"| API {int(totals.get('api_calls', 0))}회" + ) + + +def print_usage_summary_brief(summary: dict | None = None) -> None: + summary = summary or load_usage_summary() + totals = summary.get("totals", _empty_usage_bucket()) + extra = summary.get("totals_extra", {}) + turns = int(extra.get("gemini_turns", 0)) + print(format_usage_totals_line(totals, prefix="API 전체")) + today = summary.get("today") + if isinstance(today, dict) and today.get("total_tokens", 0) > 0: + print( + format_usage_totals_line(today, prefix=f"오늘({today.get('date', '?')})") + + f" | 대화 {int(today.get('gemini_turns', 0))}턴" + ) + if turns: + print(f"Gemini 대화 누적: {turns}턴") + + +# ---------- 영구 메모리 ---------- +def load_memories() -> list[str]: + if not os.path.isfile(MEMORY_PATH): + return [] + try: + data = json.load(open(MEMORY_PATH, encoding="utf-8")) + items = data if isinstance(data, list) else data.get("items", []) + return [str(x).strip() for x in items if str(x).strip()] + except (OSError, json.JSONDecodeError, TypeError): + return [] + + +def save_memories(items: list[str]) -> None: + items = items[-MEMORY_MAX:] + with open(MEMORY_PATH, "w", encoding="utf-8") as f: + json.dump(items, f, ensure_ascii=False, indent=2) + + +def build_system_instruction(memories: list[str] | None = None) -> str: + memories = load_memories() if memories is None else memories + if not memories: + return BASE_INSTRUCTION + lines = "\n".join(f"- {m}" for m in memories) + return f"{BASE_INSTRUCTION}\n\n[영구 기억]\n{lines}" + + +def load_session_history_from_logs(max_pairs: int) -> list[tuple[str, str]]: + """최근 질문·답변 로그에서 대화 맥락 복원.""" + if max_pairs <= 0: + return [] + questions = _read_json_list(QUESTIONS_LOG) + answers = _read_json_list(ANSWERS_LOG) + by_turn: dict[str, str] = {} + for row in answers: + if row.get("kind") != "gemini": + continue + tid = row.get("turn_id") + text = str(row.get("text", "")).strip() + if tid and text: + by_turn[tid] = text + pairs: list[tuple[str, str]] = [] + for q in questions: + tid = q.get("id") + qtext = str(q.get("text", "")).strip() + if tid and qtext and tid in by_turn: + pairs.append((qtext, by_turn[tid])) + return pairs[-max_pairs:] + + +def append_session_turn(user_text: str, answer: str) -> None: + global _session_history + _session_history.append((user_text.strip(), answer.strip())) + if len(_session_history) > CHAT_HISTORY_MAX: + _session_history = _session_history[-CHAT_HISTORY_MAX:] + + +def rebuild_chat(*, load_logs: bool = False) -> None: + """메모리 반영된 새 모델/채팅 세션. load_logs=True면 로그에서 맥락 복원.""" + global model, chat, _session_history + model = genai.GenerativeModel( + GEMINI_MODEL, + system_instruction=build_system_instruction(), + ) + chat = model.start_chat(history=[]) + if load_logs: + _session_history = load_session_history_from_logs(CHAT_HISTORY_MAX) + elif not _session_history: + _session_history = [] + + +def _compact(text: str) -> str: + return re.sub(r"\s+", "", text.strip()) + + +def handle_memory_command(user_text: str) -> str | None: + """기억 관련 로컬 명령이면 응답 문자열, 아니면 None (제미나이로 전달).""" + t = user_text.strip() + c = _compact(t) + + # 목록 + list_keys = ( + "기억뭐있", + "기억목록", + "뭐기억", + "기억보여", + "기억확인", + "기억내용", + "기억읽어", + ) + if any(k in c for k in list_keys): + items = load_memories() + if not items: + return "저장된 기억이 없습니다." + body = ", ".join(f"{i + 1}번 {m}" for i, m in enumerate(items)) + return f"기억 {len(items)}개 있습니다. {body}" + + # 전체 삭제 + clear_keys = ( + "기억다지워", + "기억전부삭제", + "기억초기화", + "잊어전부", + "기억모두지워", + "기억다삭제", + ) + if any(k in c for k in clear_keys): + save_memories([]) + rebuild_chat() + return "기억을 모두 지웠습니다." + + # 기억해 … + for prefix in ("기억해줘", "기억해줄래", "기억해", "기억할것"): + if c.startswith(_compact(prefix)) or t.startswith(prefix): + content = t + for p in ("기억해줘", "기억해줄래", "기억해", "기억할 것", "기억할것"): + if content.startswith(p): + content = content[len(p) :].lstrip(" .,'\"") + break + else: + # compact 매칭만 된 경우 + content = re.sub( + r"^\s*기억해(줘|줄래)?\s*", "", t + ).strip(" .,'\"") + if not content: + return "무엇을 기억할까요? 예를 들어, 기억해 반말로 짧게 답해." + items = load_memories() + if content in items: + return "이미 같은 내용이 기억되어 있습니다." + items.append(content) + save_memories(items) + rebuild_chat() + return f"기억했습니다. {content}" + + # 잊어 … / 지워 … + for prefix in ("잊어줘", "잊어", "지워줘", "삭제해줘", "삭제해"): + if c.startswith(_compact(prefix)) or t.startswith(prefix): + keyword = t + for p in ("잊어줘", "잊어", "지워줘", "삭제해줘", "삭제해"): + if keyword.startswith(p): + keyword = keyword[len(p) :].lstrip(" .,'\"") + break + if not keyword: + return "무엇을 잊을까요? 예를 들어, 잊어 맵기." + items = load_memories() + kept = [m for m in items if keyword not in m] + removed = len(items) - len(kept) + if removed == 0: + return f"'{keyword}'가 들어간 기억이 없습니다." + save_memories(kept) + rebuild_chat() + return f"{removed}개 기억을 지웠습니다." + + return None + + +rebuild_chat() + + +# ---------- 화이트리스트 장치 명령 (셸 임의 실행 금지) ---------- +def get_speaker_volume_percent() -> int | None: + try: + r = subprocess.run( + ["wpctl", "get-volume", "@DEFAULT_AUDIO_SINK@"], + capture_output=True, + text=True, + timeout=5, + ) + if r.returncode == 0: + # "Volume: 0.80" or "Volume: 1.00 [MUTED]" + m = re.search(r"Volume:\s*([0-9.]+)", r.stdout) + if m: + return int(round(float(m.group(1)) * 100)) + except (FileNotFoundError, subprocess.TimeoutExpired, ValueError): + pass + return None + + +def run_hook(name: str, *args: str) -> tuple[bool, str]: + """jarvis_hooks/.sh 만 실행 (화이트리스트). 추가 인자는 스크립트로 전달.""" + path = os.path.join(HOOKS_DIR, f"{name}.sh") + if not os.path.isfile(path): + return False, f"{name} 스크립트가 없습니다." + try: + r = subprocess.run( + ["/bin/bash", path, *args], + capture_output=True, + text=True, + timeout=15, + ) + if r.returncode == 0: + return True, (r.stdout or "").strip() or "완료" + return False, (r.stderr or r.stdout or "실패").strip() + except subprocess.TimeoutExpired: + return False, "시간 초과" + except OSError as e: + return False, str(e) + + +def _ha_api(): + """jarvis_hooks/ha_api.py 로드.""" + import importlib.util + + path = os.path.join(HOOKS_DIR, "ha_api.py") + spec = importlib.util.spec_from_file_location("jarvis_ha_api", path) + if spec is None or spec.loader is None: + raise RuntimeError("ha_api.py 로드 실패") + mod = importlib.util.module_from_spec(spec) + spec.loader.exec_module(mod) + return mod + + +_ha_tool_calls: list[str] = [] + + +def _reset_ha_tool_calls() -> None: + _ha_tool_calls.clear() + + +def get_home_snapshot() -> str: + """지금 집안에서 자비스가 조작 가능한(enabled) 기기 목록과 상태를 조회한다.""" + _ha_tool_calls.append("get_home_snapshot") + print("[HA tool] get_home_snapshot") + return _ha_api().tool_get_home_snapshot() + + +def control_light(name: str, action: str) -> str: + """조명을 켠다/끈다. name은 방/조명 이름(예: 거실, 메인등, 아기방). action은 on 또는 off.""" + _ha_tool_calls.append("control_light") + print(f"[HA tool] control_light name={name!r} action={action!r}") + return _ha_api().tool_control_light(name, action) + + +def control_climate( + name: str = "", action: str = "turn_on", temperature: int = 24 +) -> str: + """에어컨을 켠다/끈다/온도를 맞춘다. action: turn_on, turn_off, set. temperature 기본 24.""" + _ha_tool_calls.append("control_climate") + print( + f"[HA tool] control_climate name={name!r} action={action!r} temp={temperature}" + ) + return _ha_api().tool_control_climate(name, action, temperature) + + +def control_tv(name: str = "", action: str = "turn_on") -> str: + """티비를 켠다/끈다. name 생략 시 거실 티비. action: on 또는 off.""" + _ha_tool_calls.append("control_tv") + print(f"[HA tool] control_tv name={name!r} action={action!r}") + return _ha_api().tool_control_tv(name, action) + + +def get_washer_status() -> str: + """세탁기 남은 시간·전원·완료 여부를 조회한다.""" + _ha_tool_calls.append("get_washer_status") + print("[HA tool] get_washer_status") + return _ha_api().tool_get_washer_status() + + +HA_GEMINI_TOOLS = [ + get_home_snapshot, + control_light, + control_climate, + control_tv, + get_washer_status, +] + + +def ensure_ha_entities_file() -> None: + """엔티티 JSON 없으면 HA에서 동기화.""" + path = os.path.join(BASE_DIR, "jarvis_ha_entities.json") + if os.path.isfile(path): + return + try: + _ha_api().sync_entities_file() + except Exception as e: + print(f"[ha_entities] 최초 sync 실패: {e}") + + +MEDIA_PID_PATH = os.path.join(LOG_DIR, "media.pid") + + +def _is_media_playing() -> bool: + """유튜브/VLC 재생 중인지.""" + with _media_lock: + if _media_proc is not None and _media_proc.poll() is None: + return True + if os.path.isfile(MEDIA_PID_PATH): + try: + with open(MEDIA_PID_PATH, encoding="utf-8") as f: + pid = int(f.read().strip()) + os.kill(pid, 0) + return True + except (OSError, ValueError): + pass + return False + + +def _wake_hit_thresholds(media_on: bool) -> tuple[float, float]: + """재생 중엔 호출어 임계를 조금 낮춤 (스피커 소리 속에서도 듣기).""" + if media_on: + return ( + max(0.22, WAKE_THRESHOLD - 0.06), + max(0.38, WAKE_STRONG_SCORE - 0.08), + ) + return WAKE_THRESHOLD, WAKE_STRONG_SCORE + + +def _stop_media_playback() -> None: + """유튜브/VLC 재생 중지.""" + global _media_proc, _youtube_pending + with _media_lock: + _youtube_pending = None + if os.path.isfile(MEDIA_PID_PATH): + try: + with open(MEDIA_PID_PATH, encoding="utf-8") as f: + pid = int(f.read().strip()) + os.kill(pid, 15) + time.sleep(0.2) + os.kill(pid, 9) + except (OSError, ValueError): + pass + try: + os.remove(MEDIA_PID_PATH) + except OSError: + pass + if _media_proc is not None and _media_proc.poll() is None: + _media_proc.terminate() + try: + _media_proc.wait(timeout=3) + except subprocess.TimeoutExpired: + _media_proc.kill() + _media_proc = None + subprocess.run( + ["pkill", "-x", "cvlc"], + check=False, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ) + + +_YT_PLAY_STRIP_PATS = ( + r"유튜브에서?", + r"유튜브로", + r"youtube에서?", + r"youtube로", + r"노래", + r"음악", + r"뮤직", + r"틀어달라", + r"틀어\s*달라", + r"틀어\s*줘?", + r"틀어\s*봐", + r"재생해?\s*줘?", + r"들려\s*줘?", + r"플레이", + r"해\s*줘", + r"줘", + r"봐", + r"달라", + r"이라는", + r"라는", +) + + +def _normalize_youtube_query(query: str) -> str: + """검색용 — 곡 제목 바꾸지 않고 STT 흔한 오인식만 (출출해↔추출해).""" + q = re.sub(r"\s+", " ", query.strip()) + if re.search(r"매[점장]", q) and "추출" in q: + q = re.sub(r"추출", "출출", q) + if re.search(r"매[점장]", q) and "수출" in q: + q = re.sub(r"수출", "출출", q) + return q.strip() + + +def _strip_youtube_play_words(text: str) -> str: + q = text + for pat in _YT_PLAY_STRIP_PATS: + q = re.sub(pat, "", q, flags=re.IGNORECASE) + return re.sub(r"\s+", " ", q).strip(" .,'\"!?") + + +def _extract_youtube_query(text: str) -> str | None: + """「유튜브에서 OOO 틀어줘」 / 「OOO 틀어줘」 → OOO. 빈 제목은 ''.""" + t = text.strip() + c = _compact(t) + play_words = ("틀어", "재생", "들려", "플레이", "play", "틀어줘", "틀어줄래") + media_words = ( + "유튜브", + "youtube", + "노래", + "음악", + "뮤직", + "song", + "music", + ) + has_play = any(w in c for w in play_words) + has_media = any(k in c for k in media_words) + if not has_play and not has_media: + return None + + q = _strip_youtube_play_words(t) + if len(q) >= 2: + return _normalize_youtube_query(q) + if has_play or has_media: + return "" + return None + + +def _youtube_music_search_query(query: str) -> str: + """API/yt-dlp 검색어 — 노래 의도면 '노래' 보강 (쇼핑·리뷰 영상 방지).""" + q = re.sub(r"\s+", " ", query.strip()) + c = _compact(q) + if not any( + k in c + for k in ( + "노래", + "음악", + "뮤직", + "mv", + "official", + "뮤직비디오", + "music", + "song", + "가사", + ) + ): + return f"{q} 노래" + return q + + +def _youtube_api_search(query: str) -> tuple[str, str] | None: + """YouTube Data API v3 검색 → (watch_url, 제목). 키 없거나 실패 시 None.""" + key = YOUTUBE_API_KEY + if not key or "여기에_" in key: + return None + try: + from googleapiclient.discovery import build + + youtube = build("youtube", "v3", developerKey=key, cache_discovery=False) + search_q = _youtube_music_search_query(query) + res = ( + youtube.search() + .list( + part="id,snippet", + q=search_q, + type="video", + maxResults=1, + videoCategoryId="10", + safeSearch="none", + ) + .execute() + ) + items = res.get("items") or [] + if not items: + res = ( + youtube.search() + .list( + part="id,snippet", + q=search_q, + type="video", + maxResults=1, + safeSearch="none", + ) + .execute() + ) + items = res.get("items") or [] + if not items: + print(f"[유튜브 API] 결과 없음: {query}") + return None + item = items[0] + vid = item.get("id", {}).get("videoId") + title = html.unescape(str(item.get("snippet", {}).get("title", "")).strip()) + if not vid: + return None + url = f"https://www.youtube.com/watch?v={vid}" + print(f"[유튜브 API] {title} ({vid})") + return url, title or query + except Exception as e: + err = str(e) + if "403" in err and "blocked" in err.lower(): + print( + "[유튜브 API] 사용 차단 — Google Cloud에서 " + "YouTube Data API v3 활성화·키 제한 확인 필요. yt-dlp로 대체." + ) + else: + print(f"[유튜브 API] 검색 실패: {e}") + return None + + +def _resolve_youtube_watch_url(query: str) -> tuple[str | None, str, str]: + """검색어 → (재생 URL, 표시 제목, api|yt-dlp|none).""" + mode = YOUTUBE_SEARCH_MODE + if mode not in ("api", "yt-dlp", "auto"): + mode = "auto" + + if mode in ("api", "auto"): + api_hit = _youtube_api_search(query) + if api_hit: + return api_hit[0], api_hit[1], "api" + + if mode == "api": + return None, query, "none" + + if not os.path.isfile(YT_DLP_BIN): + return None, query, "none" + search_q = _youtube_music_search_query(query) + try: + res = subprocess.run( + [ + YT_DLP_BIN, + "-f", + "bestaudio/best", + "--no-playlist", + "-g", + f"ytsearch1:{search_q}", + ], + capture_output=True, + text=True, + timeout=90, + check=False, + ) + url = (res.stdout or "").strip().split("\n")[0].strip() + if url.startswith("http"): + print(f"[유튜브 yt-dlp] {query}") + return url, query, "yt-dlp" + except (OSError, subprocess.TimeoutExpired) as e: + print(f"[유튜브 yt-dlp] 검색 실패: {e}") + return None, query, "none" + + +def _start_youtube_play(query: str) -> str: + """검색만 하고 재생은 짧은 TTS 후 (_commit_youtube_play).""" + global _youtube_pending + if not os.path.isfile(YT_DLP_BIN): + print(f"[유튜브] yt-dlp 없음: {YT_DLP_BIN}") + return "유튜브 검색 프로그램이 없어요." + watch_url, title, src = _resolve_youtube_watch_url(query) + if not watch_url: + _youtube_pending = None + return f"'{query}' 검색이 안 됐어요. 제목을 다시 말해 주세요." + _youtube_pending = (query, watch_url) + print(f"[유튜브] 예약: {title} ({src})") + say_q = query if len(query) <= 36 else query[:33] + "..." + return f"네, '{say_q}' 틀어줄게요." + + +def _commit_youtube_play() -> None: + """예약된 유튜브 재생 시작 (TTS 확인 후).""" + global _media_proc, _youtube_pending + if not _youtube_pending: + return + query, watch_url = _youtube_pending + _youtube_pending = None + script = os.path.join(BASE_DIR, "jarvis_yt_play.sh") + if not os.path.isfile(script): + print(f"[유튜브] 스크립트 없음: {script}") + return + env = os.environ.copy() + sink = os.environ.get("PULSE_SINK", "").strip() or PULSE_SINK + if sink: + env["PULSE_SINK"] = sink + with _media_lock: + _stop_media_playback() + cmd = ["/bin/bash", script, query] + if watch_url: + cmd.append(watch_url) + _media_proc = subprocess.Popen( + cmd, + env=env, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + start_new_session=True, + ) + print(f"[유튜브] 재생 시작: {query} (pid={_media_proc.pid})") + + +def handle_device_command(user_text: str) -> str | None: + """볼륨/조명/가전 등 허용된 로컬 명령. 해당 없으면 None.""" + c = _compact(user_text) + + # --- 유튜브 / 음악 재생 --- + if any( + k in c + for k in ( + "노래꺼", + "음악꺼", + "노래멈춰", + "음악멈춰", + "재생멈춰", + "재생꺼", + "플레이멈춰", + "노래끄", + "음악끄", + ) + ): + _stop_media_playback() + return "재생을 멈췄습니다." + + yt_q = _extract_youtube_query(user_text) + if yt_q is not None: + if not yt_q.strip(): + return "어떤 노래를 틀어줄까요? 제목을 말해 주세요." + return _start_youtube_play(yt_q) + + # --- 세탁기 (HA 실시간) --- + if any( + k in c + for k in ( + "세탁기얼마", + "세탁얼마", + "세탁남은", + "세탁기남은", + "세탁기상태", + "세탁상태", + "세탁완료됐", + ) + ) or ( + "세탁" in c + and any(k in c for k in ("얼마", "남", "몇분", "상태", "돌아가", "돌고")) + ): + try: + return _ha_api().washer_status() + except Exception as e: + return f"세탁기 상태를 못 읽었습니다. {e}" + + # --- 티비 (media_player, 단답) --- + has_tv = any(k in c for k in ("티비", "티브이", "텔레비전")) or "tv" in c.lower() + if has_tv and any(k in c for k in ("켜", "꺼", "온", "오프")): + turn_on = not any(k in c for k in ("꺼", "오프", "off")) + try: + ha = _ha_api() + hit = ha.resolve_tv(user_text) + if not hit: + return "티비를 HA에서 못 찾았어요. jarvis_ha_entities.json 의 media_player enabled를 확인하세요." + fn, eid = hit + ha.tv_service(eid, turn_on) + return f"{fn}을 켰습니다." if turn_on else f"{fn}을 껐습니다." + except Exception as e: + return f"티비 조작 실패. {e}" + + # --- 불 (HA light.* 실시간 검색 — json 일일이 안 넣어도 됨) --- + is_on = any( + k in c + for k in ("불켜", "라이트온", "조명켜", "등켜", "불켜줘", "불켜봐") + ) or ("켜" in c and any(x in c for x in ("방", "등", "라이트", "조명", "메인"))) + is_off = any( + k in c + for k in ("불꺼", "라이트오프", "조명꺼", "등꺼", "불꺼줘") + ) or ("꺼" in c and any(x in c for x in ("방", "등", "라이트", "조명", "메인"))) + + if is_on or is_off: + try: + ha = _ha_api() + hit = ha.resolve_light(user_text) + if not hit: + return ( + "어떤 불인지 HA에서 못 찾았어요. " + "웹에 보이는 조명 이름으로 말해 주세요." + ) + fn, eid = hit + ha.light_service(eid, is_on) + return f"{fn}을 켰습니다." if is_on else f"{fn}을 껐습니다." + except Exception as e: + return f"조명 조작 실패. {e}" + + # --- 음소거 --- + if any(k in c for k in ("음소거", "소리꺼", "뮤트")) and "해제" not in c: + for cmd in ( + ["wpctl", "set-mute", "@DEFAULT_AUDIO_SINK@", "1"], + ["pactl", "set-sink-mute", "@DEFAULT_SINK@", "1"], + ): + subprocess.run(cmd, capture_output=True, timeout=5) + return "음소거 했습니다." + if any(k in c for k in ("음소거해제", "소리켜", "뮤트해제")): + for cmd in ( + ["wpctl", "set-mute", "@DEFAULT_AUDIO_SINK@", "0"], + ["pactl", "set-sink-mute", "@DEFAULT_SINK@", "0"], + ): + subprocess.run(cmd, capture_output=True, timeout=5) + return "음소거를 해제했습니다." + + # --- 볼륨 절대값: "볼륨 50", "볼륨50퍼" --- + m = re.search(r"볼륨\s*(\d{1,3})", user_text) + if not m: + m = re.search(r"소리\s*(\d{1,3})\s*%?", user_text) + if m and not any(k in c for k in ("올려", "높여", "키워", "내려", "줄여", "낮춰")): + pct = max(0, min(150, int(m.group(1)))) + set_speaker_volume(pct) + return f"볼륨을 {pct}%로 맞췄습니다." + + if any(k in c for k in ("볼륨최대", "소리최대", "볼륨최대로")): + set_speaker_volume(100) + return "볼륨을 최대로 올렸습니다." + + # --- 볼륨 상대 --- + cur = get_speaker_volume_percent() + if any(k in c for k in ("볼륨올려", "소리키워", "소리크게", "볼륨크게", "볼륨높여")): + nxt = min(150, (cur if cur is not None else 70) + 15) + set_speaker_volume(nxt) + return f"볼륨을 {nxt}%로 올렸습니다." + if any(k in c for k in ("볼륨내려", "소리줄여", "소리작게", "볼륨작게", "볼륨낮춰")): + nxt = max(0, (cur if cur is not None else 70) - 15) + set_speaker_volume(nxt) + return f"볼륨을 {nxt}%로 내렸습니다." + + if any(k in c for k in ("볼륨몇", "지금볼륨", "볼륨얼마", "소리크기")): + if cur is None: + return "지금 볼륨을 읽지 못했습니다." + return f"지금 볼륨은 {cur}%입니다." + + return None + + +# ---------- 오디오 / Gemini 유틸 ---------- +def set_speaker_volume(percent: int) -> None: + percent = max(0, min(150, int(percent))) + for cmd in ( + ["wpctl", "set-volume", "@DEFAULT_AUDIO_SINK@", f"{percent / 100:.2f}"], + ["pactl", "set-sink-volume", "@DEFAULT_SINK@", f"{percent}%"], + ["amixer", "-q", "sset", "Master", f"{min(percent, 100)}%"], + ["wpctl", "set-mute", "@DEFAULT_AUDIO_SINK@", "0"], + ): + try: + subprocess.run(cmd, capture_output=True, timeout=5) + except (FileNotFoundError, subprocess.TimeoutExpired): + pass + + +def estimate_cost_usd(prompt_tokens: int, output_tokens: int) -> float: + return ( + prompt_tokens / 1_000_000 * PRICE_INPUT_PER_M + + output_tokens / 1_000_000 * PRICE_OUTPUT_PER_M + ) + + +def print_usage(response, label: str = "") -> None: + usage = getattr(response, "usage_metadata", None) + if not usage: + print("토큰 정보 없음") + return + pin = int(getattr(usage, "prompt_token_count", 0) or 0) + pout = int(getattr(usage, "candidates_token_count", 0) or 0) + total = int(getattr(usage, "total_token_count", 0) or (pin + pout)) + billed_out = max(pout, total - pin) + usd = estimate_cost_usd(pin, billed_out) + krw = usd * USD_KRW + tag = f"[{label}] " if label else "" + print( + f"{tag}토큰: 입력={pin} 출력(후보)={pout} 합계={total} " + f"| 유료환산 ≈ ${usd:.6f} (약 {krw:.2f}원) / Free면 0원" + ) + + +def _get_gemini_text_client() -> genai_client.Client: + global _gemini_text_client + if _gemini_text_client is None: + _gemini_text_client = genai_client.Client(api_key=GOOGLE_API_KEY) + return _gemini_text_client + + +def print_usage_genai(response, label: str = "") -> dict | None: + usage = _usage_from_genai_response(response) + if not usage: + return None + tag = f"[{label}] " if label else "" + print( + f"{tag}토큰: 입력={usage['prompt_tokens']} 출력={usage['output_tokens']} " + f"합계={usage['total_tokens']} " + f"| 유료환산 ≈ ${usage['usd']:.6f} (약 {usage['krw']:.2f}원) / Free면 0원" + ) + totals = record_api_usage(usage) + print(f" ↳ {format_usage_totals_line(totals)}") + return usage + + +def _build_gemini_contents(user_text: str, use_session_history: bool = True) -> list | str: + if use_session_history and _session_history: + contents: list = [] + for u, a in _session_history: + contents.append( + genai_types.Content( + role="user", parts=[genai_types.Part(text=u)] + ) + ) + contents.append( + genai_types.Content( + role="model", parts=[genai_types.Part(text=a)] + ) + ) + contents.append( + genai_types.Content( + role="user", parts=[genai_types.Part(text=user_text)] + ) + ) + return contents + return user_text + + +def _gemini_generate_config( + *, + max_tokens: int, + system: str | None = None, + with_ha_tools: bool = False, +) -> genai_types.GenerateContentConfig: + kwargs: dict = { + "system_instruction": system or build_system_instruction(), + "max_output_tokens": max_tokens, + "thinking_config": genai_types.ThinkingConfig(thinking_budget=0), + } + if with_ha_tools: + kwargs["tools"] = HA_GEMINI_TOOLS + kwargs["automatic_function_calling"] = ( + genai_types.AutomaticFunctionCallingConfig(maximum_remote_calls=3) + ) + return genai_types.GenerateContentConfig(**kwargs) + + +def _genai_generate_text( + user_text: str, + *, + max_tokens: int, + system: str | None = None, + use_session_history: bool = True, + with_ha_tools: bool = False, +): + """thinking 비활성 — 답 중간 끊김 방지. use_session_history로 직전 대화 맥락 전달.""" + client = _get_gemini_text_client() + cfg = _gemini_generate_config( + max_tokens=max_tokens, system=system, with_ha_tools=with_ha_tools + ) + contents = _build_gemini_contents(user_text, use_session_history) + with warnings.catch_warnings(): + warnings.simplefilter("ignore") + with open(os.devnull, "w") as devnull: + with contextlib.redirect_stderr(devnull): + return client.models.generate_content( + model=GEMINI_MODEL, + contents=contents, + config=cfg, + ) + + +def _genai_generate_text_stream( + user_text: str, + *, + max_tokens: int, + system: str | None = None, + use_session_history: bool = True, +): + """스트리밍 텍스트 생성 — 첫 문장 TTS를 빨리 시작.""" + client = _get_gemini_text_client() + cfg = _gemini_generate_config(max_tokens=max_tokens, system=system) + contents = _build_gemini_contents(user_text, use_session_history) + with warnings.catch_warnings(): + warnings.simplefilter("ignore") + with open(os.devnull, "w") as devnull: + with contextlib.redirect_stderr(devnull): + return client.models.generate_content_stream( + model=GEMINI_MODEL, + contents=contents, + config=cfg, + ) + + +def _get_gemini_tts_client() -> genai_client.Client: + global _gemini_tts_client + if _gemini_tts_client is None: + _gemini_tts_client = genai_client.Client(api_key=GOOGLE_API_KEY) + return _gemini_tts_client + + +def _synthesize_gemini(text: str, path: str) -> None: + client = _get_gemini_tts_client() + prefix = TTS_STYLE.strip() + contents = f"{prefix} {text}" if prefix and not text.startswith(prefix) else text + with warnings.catch_warnings(): + warnings.simplefilter("ignore") + with open(os.devnull, "w") as devnull: + with contextlib.redirect_stderr(devnull): + response = client.models.generate_content( + model=TTS_MODEL, + contents=contents, + config=genai_types.GenerateContentConfig( + response_modalities=["AUDIO"], + speech_config=genai_types.SpeechConfig( + voice_config=genai_types.VoiceConfig( + prebuilt_voice_config=genai_types.PrebuiltVoiceConfig( + voice_name=TTS_VOICE + ) + ) + ), + ), + ) + part = response.candidates[0].content.parts[0] + pcm = part.inline_data.data + with wave.open(path, "wb") as wf: + wf.setnchannels(1) + wf.setsampwidth(2) + wf.setframerate(TTS_SAMPLE_RATE) + wf.writeframes(pcm) + + +async def _synthesize_edge(text: str, path: str) -> None: + voice = TTS_VOICE + if voice in ("Kore", "Leda", "Zephyr", "Puck", "Charon"): + voice = DEFAULT_EDGE_TTS_VOICE + communicate = edge_tts.Communicate( + text, voice=voice, rate=TTS_RATE, pitch=TTS_PITCH + ) + await communicate.save(path) + + +def synthesize_speech(text: str, base_path: str) -> str: + """음성 합성. 실제 저장 경로 반환.""" + root, ext = os.path.splitext(base_path) + if not ext: + root = base_path + min_pcm_bytes = max(8000, len(text) * 120) + if TTS_BACKEND == "gemini": + wav_path = f"{root}.wav" + try: + _synthesize_gemini(text, wav_path) + if os.path.getsize(wav_path) >= min_pcm_bytes: + return wav_path + print( + f"Gemini TTS 오디오 짧음 ({os.path.getsize(wav_path)}B), " + "edge-tts로 대체" + ) + except Exception as e: + print(f"Gemini TTS 실패, edge-tts로 대체: {e}") + mp3_path = f"{root}.mp3" + asyncio.run(_synthesize_edge(text, mp3_path)) + return mp3_path + + +def play_beep(freq: float = 880.0, ms: int = 160, volume: float = 0.4) -> None: + """입력 대기 진입 알림용 짧은 비프 (3.5mm/기본 싱크).""" + try: + rate = 24000 + n = max(1, int(rate * ms / 1000)) + t = np.arange(n, dtype=np.float32) / rate + wave_arr = np.sin(2 * np.pi * freq * t) + fade = np.linspace(1.0, 0.0, n, dtype=np.float32) + mono = (wave_arr * fade * 32767 * volume).astype(np.int16) + beep_path = os.path.join(BASE_DIR, "beep.wav") + with wave.open(beep_path, "wb") as wf: + wf.setnchannels(1) + wf.setsampwidth(2) + wf.setframerate(rate) + wf.writeframes(mono.tobytes()) + _play_audio_file(beep_path) + except Exception as e: + print(f"비프 재생 실패: {e}") + + +def _clean_speech_text(text: str) -> str: + clean = re.sub(r"[*_`#>•]+", " ", text) + return re.sub(r"\s+", " ", clean).strip() + + +def _synth_chunk_path(base: str, save_as: str | None, index: int, spoken: str) -> str: + chunk_base = base if save_as else f"{base}_{index}" + return synthesize_speech(spoken, chunk_base) + + +def _remove_tts_file(path: str, save_as: str | None) -> None: + if save_as is None: + try: + os.remove(path) + except OSError: + pass + + +class _TtsPrefetch: + """답변 텍스트 → 첫 TTS 청크 합성을 백그라운드에서 선행.""" + + def __init__(self, text: str, save_as: str | None = None) -> None: + self.clean = _clean_speech_text(text) + self.chunks = _chunks_for_speech(self.clean) + self.base = save_as or os.path.join(BASE_DIR, "reply") + self.save_as = save_as + self._ready = threading.Event() + self._first_spoken: str | None = None + self._first_path: str | None = None + self._error: BaseException | None = None + if not self.chunks: + self._ready.set() + return + _tts_executor.submit(self._run) + + def _run(self) -> None: + try: + spoken = self.chunks[0] + path = _synth_chunk_path(self.base, self.save_as, 0, spoken) + self._first_spoken = spoken + self._first_path = path + except Exception as e: + self._error = e + finally: + self._ready.set() + + def wait_first(self, timeout: float = 90.0) -> bool: + self._ready.wait(timeout) + return self._first_path is not None and self._error is None + + +def warmup_tts() -> None: + """TTS·API 미리 준비 — 첫 재생 지연 줄임.""" + try: + _get_gemini_text_client() + if TTS_BACKEND == "gemini": + _get_gemini_tts_client() + tmp = os.path.join(BASE_DIR, "tts_warmup.wav") + _synthesize_gemini("음", tmp) + if os.path.isfile(tmp): + os.remove(tmp) + else: + tmp = os.path.join(BASE_DIR, "tts_warmup.mp3") + asyncio.run(_synthesize_edge("테스트", tmp)) + if os.path.isfile(tmp): + os.remove(tmp) + except Exception: + pass + + +def _chunks_for_speech(text: str) -> list[str]: + """긴 답은 문장 단위로 나눠 TTS — 중간에 잘리지 않게.""" + t = text.strip() + if not t: + return [] + if TTS_CHUNK_CHARS <= 0 or len(t) <= TTS_CHUNK_CHARS: + return [t] + + sentences = re.split(r"(?<=[.!?。])\s+", t) + chunks: list[str] = [] + buf = "" + for sentence in sentences: + s = sentence.strip() + if not s: + continue + if len(s) > TTS_CHUNK_CHARS: + if buf: + chunks.append(buf.strip()) + buf = "" + for i in range(0, len(s), TTS_CHUNK_CHARS): + part = s[i:i + TTS_CHUNK_CHARS].strip() + if part: + chunks.append(part) + continue + candidate = f"{buf} {s}".strip() if buf else s + if len(candidate) <= TTS_CHUNK_CHARS: + buf = candidate + else: + if buf: + chunks.append(buf) + buf = s + if buf: + chunks.append(buf) + return chunks or [t[:TTS_CHUNK_CHARS]] + + +def speak( + text: str, + save_as: str | None = None, + prefetch: _TtsPrefetch | None = None, + interruptible: bool = True, +) -> None: + """TTS 합성 후 재생 — 로그는 재생 직전에 출력.""" + _begin_speech(interruptible=interruptible) + try: + if prefetch is None: + prefetch = _TtsPrefetch(text, save_as) + + if not prefetch.wait_first(): + if prefetch._error: + print(f"TTS 오류: {prefetch._error}") + return + + clean = prefetch.clean + chunks = prefetch.chunks + base = prefetch.base + save_as = prefetch.save_as + + if len(chunks) == 1: + print(f"자비스: {prefetch._first_spoken}") + else: + print(f"자비스(전체): {clean}") + + path_future: Future | None = None + if len(chunks) > 1: + path_future = _tts_executor.submit( + _synth_chunk_path, base, save_as, 1, chunks[1] + ) + + if not _play_audio_file(prefetch._first_path): + return + _remove_tts_file(prefetch._first_path, save_as) + + for i in range(1, len(chunks)): + if _speech_interrupt.is_set(): + break + spoken = chunks[i] + if path_future is not None: + path = path_future.result() + path_future = None + else: + path = _synth_chunk_path(base, save_as, i, spoken) + + print(f"자비스 [{i + 1}/{len(chunks)}]: {spoken}") + if i + 1 < len(chunks): + path_future = _tts_executor.submit( + _synth_chunk_path, base, save_as, i + 1, chunks[i + 1] + ) + if not _play_audio_file(path): + break + _remove_tts_file(path, save_as) + finally: + _end_speech() + + +def open_mic_stream(audio: pyaudio.PyAudio): + """가능하면 Pulse(공유) 우선 — USB hw 독점은 디버그/녹음과 충돌.""" + candidates = [] + if MIC_INDEX is not None: + candidates.append(MIC_INDEX) + else: + pulse_idxs = [] + usb_idxs = [] + other = [] + for i in range(audio.get_device_count()): + d = audio.get_device_info_by_index(i) + if d["maxInputChannels"] <= 0: + continue + name = d["name"] + if name in ("pulse", "default") or "pulse" in name.lower(): + pulse_idxs.append(i) + elif "USB" in name or "usb" in name: + usb_idxs.append(i) + else: + other.append(i) + candidates.extend(pulse_idxs) + candidates.extend(usb_idxs) + candidates.extend(other) + candidates.append(None) + + last_err = None + for idx in candidates: + for rate in (16000, 48000, 44100): + kwargs = dict( + format=pyaudio.paInt16, + channels=1, + rate=rate, + input=True, + frames_per_buffer=1280 if rate == 16000 else max(1024, int(rate * 0.08)), + ) + if idx is not None: + kwargs["input_device_index"] = idx + try: + stream = audio.open(**kwargs) + name = "default" + if idx is not None: + name = audio.get_device_info_by_index(idx).get("name", str(idx)) + print(f"마이크: index={idx} ({name}) rate={rate}") + return stream, rate + except OSError as e: + last_err = e + raise RuntimeError(f"마이크를 열 수 없습니다: {last_err}") + + +def to_16k(pcm: np.ndarray, rate: int) -> np.ndarray: + if rate == 16000: + return pcm.astype(np.int16, copy=False) + # 선형 보간 리샘플 (단순 [::n] 은 44100에서 부정확할 수 있음) + target_len = max(1, int(round(len(pcm) * 16000 / rate))) + if target_len == len(pcm): + return pcm.astype(np.int16, copy=False) + x_old = np.linspace(0.0, 1.0, num=len(pcm), endpoint=False) + x_new = np.linspace(0.0, 1.0, num=target_len, endpoint=False) + out = np.interp(x_new, x_old, pcm.astype(np.float32)) + return np.clip(out, -32768, 32767).astype(np.int16) + + +def resolve_stt_mic_index(audio: pyaudio.PyAudio) -> int | None: + """STT용 마이크 = 호출어와 같은 USB/Pulse 우선.""" + if MIC_INDEX is not None: + return MIC_INDEX + pulse_idxs: list[int] = [] + usb_idxs: list[int] = [] + for i in range(audio.get_device_count()): + d = audio.get_device_info_by_index(i) + if d["maxInputChannels"] <= 0: + continue + name = str(d.get("name", "")).lower() + if "pulse" in name or name in ("default", "pulse"): + pulse_idxs.append(i) + elif "usb" in name: + usb_idxs.append(i) + for group in (pulse_idxs, usb_idxs): + if group: + return group[0] + return None + + +def configure_recognizer(recognizer: sr.Recognizer) -> None: + """질문 듣기 — 작은 소리·속도 균형.""" + if STT_QUIET_MODE or STT_ENERGY_THRESHOLD > 0: + recognizer.dynamic_energy_threshold = False + recognizer.energy_threshold = STT_ENERGY_THRESHOLD if STT_ENERGY_THRESHOLD > 0 else 260 + else: + recognizer.dynamic_energy_threshold = True + recognizer.dynamic_energy_adjustment_damping = 0.1 + recognizer.dynamic_energy_ratio = 1.12 + recognizer.pause_threshold = STT_PAUSE_THRESHOLD + recognizer.non_speaking_duration = STT_NON_SPEAKING_SEC + recognizer.phrase_threshold = STT_PHRASE_THRESHOLD + recognizer.operation_timeout = None + + +def _stt_record_limit_sec() -> float: + """녹음 최대 길이 — phrase와 max_record 중 작은 값.""" + return min(STT_PHRASE_LIMIT_SEC, STT_MAX_RECORD_SEC) + + +def _prepare_recognizer_for_listen(recognizer: sr.Recognizer) -> None: + """듣기 직전 — 고정 감도, 말 중간 멈춤에 잘리지 않게 pause 여유.""" + configure_recognizer(recognizer) + recognizer.dynamic_energy_threshold = False + recognizer.pause_threshold = max(STT_PAUSE_THRESHOLD, STT_MIN_PAUSE_SEC) + recognizer.non_speaking_duration = max(STT_NON_SPEAKING_SEC, 0.45) + + +def _apply_stt_energy_after_ambient(recognizer: sr.Recognizer, noisy_warn: bool = False) -> None: + """주변소음 보정 후 에너지 임계값 확정.""" + global _stt_noisy_background + if recognizer.energy_threshold >= STT_NOISY_THRESHOLD: + _stt_noisy_background = True + cap = STT_ENERGY_THRESHOLD if STT_ENERGY_THRESHOLD > 0 else 400 + # 배경음이 크면 상한만 쓰고 최소값으로 올리지 않음 — 말 끝 감지가 늦어지는 주원인 + recognizer.energy_threshold = min(recognizer.energy_threshold, cap) + if noisy_warn: + print( + "⚠ 배경 소리가 큽니다 (노래/TV). " + "음악 줄이거나 마이크 15cm 안에서 또렷하게 말하세요." + ) + elif STT_QUIET_MODE and STT_ENERGY_THRESHOLD > 0: + cap = STT_ENERGY_THRESHOLD + recognizer.energy_threshold = min(recognizer.energy_threshold, cap) + _stt_noisy_background = False + else: + _stt_noisy_background = False + if not _stt_noisy_background: + recognizer.dynamic_energy_threshold = False + + +def calibrate_stt_ambient(recognizer: sr.Recognizer) -> None: + """시작 시 주변 소음 보정. 배경음악 크면 동적 감도로 전환.""" + global _stt_ambient_calibrated + if _stt_ambient_calibrated or STT_AMBIENT_SEC <= 0: + _stt_ambient_calibrated = True + if STT_QUIET_MODE and STT_ENERGY_THRESHOLD > 0 and not _stt_noisy_background: + recognizer.energy_threshold = STT_ENERGY_THRESHOLD + return + mic_kwargs: dict = {} + if STT_MIC_INDEX is not None: + mic_kwargs["device_index"] = STT_MIC_INDEX + print(f"STT 주변소음 보정 ({STT_AMBIENT_SEC}s)...") + with sr.Microphone(**mic_kwargs) as source: + recognizer.adjust_for_ambient_noise(source, duration=STT_AMBIENT_SEC) + _apply_stt_energy_after_ambient(recognizer, noisy_warn=True) + _stt_ambient_calibrated = True + + +def recalibrate_stt_before_listen(recognizer: sr.Recognizer, source) -> None: + """질문 듣기 직전 짧게 재보정 — 같은 마이크 소스에서 수행.""" + dur = min(STT_AMBIENT_SEC, 0.18) + if dur <= 0: + return + recognizer.adjust_for_ambient_noise(source, duration=dur) + _apply_stt_energy_after_ambient(recognizer, noisy_warn=False) + + +def _listen_smart_end(recognizer: sr.Recognizer, source) -> sr.AudioData: + """ + 주변소음(팬) baseline 추적 → 말하기 시작/끝 감지. + 고정 임계값만 쓰면 팬이 계속 '말하는 중'으로 잡힘. + """ + import math + + pause_sec = max(STT_PAUSE_THRESHOLD, STT_NON_SPEAKING_SEC) + record_limit = _stt_record_limit_sec() + energy_threshold = recognizer.energy_threshold + chunk = source.CHUNK + sample_width = source.SAMPLE_WIDTH + sample_rate = source.SAMPLE_RATE + seconds_per_buffer = float(chunk) / sample_rate + pause_buffers = max(1, int(math.ceil(pause_sec / seconds_per_buffer))) + min_speech_buffers = max(1, int(math.ceil(0.2 / seconds_per_buffer))) + + elapsed = 0.0 + deadline = STT_TIMEOUT_SEC if STT_TIMEOUT_SEC > 0 else float("inf") + ambient = float(energy_threshold) + ambient_alpha = 0.4 + + def _rms(buf: bytes) -> int: + return audioop.rms(buf, sample_width) + + def _speech_start_thr() -> float: + return max(energy_threshold, ambient * STT_SPEECH_START_RATIO) + + def _speech_end_thr(peak: float) -> float: + return max(ambient * 1.15, peak * STT_SILENCE_PEAK_RATIO) + + # 말 시작 대기 — 주변소음 baseline 학습 + pre_frames: list[bytes] = [] + while True: + if elapsed > deadline: + raise sr.WaitTimeoutError() + buffer = source.stream.read(chunk) + if not buffer: + break + elapsed += seconds_per_buffer + energy = _rms(buffer) + ambient = ambient * (1 - ambient_alpha) + energy * ambient_alpha + pre_frames.append(buffer) + if energy >= _speech_start_thr(): + break + + frames = list(pre_frames) + peak = max(_rms(b) for b in pre_frames) if pre_frames else 0 + record_t = 0.0 + pause_count = 0 + speech_buffers = 0 + + while record_t < record_limit: + buffer = source.stream.read(chunk) + if not buffer: + break + frames.append(buffer) + record_t += seconds_per_buffer + energy = _rms(buffer) + peak = max(peak, energy) + if energy < peak * 0.92: + ambient = ambient * 0.88 + energy * 0.12 + + if energy >= _speech_start_thr() * 0.92: + speech_buffers += 1 + + quiet = energy < _speech_end_thr(peak) + if quiet and speech_buffers >= min_speech_buffers: + pause_count += 1 + if pause_count >= pause_buffers: + break + else: + pause_count = 0 + + return sr.AudioData(b"".join(frames), sample_rate, sample_width) + + +def _listen_for_speech(recognizer: sr.Recognizer, source) -> sr.AudioData: + """말 끝날 때까지 녹음.""" + if STT_SMART_END: + return _listen_smart_end(recognizer, source) + return recognizer.listen( + source, + timeout=STT_TIMEOUT_SEC, + phrase_time_limit=_stt_record_limit_sec(), + ) + + +def _listen_with_feedback(recognizer: sr.Recognizer, source) -> sr.AudioData: + """듣는 동안 피드백 (· = 듣는 중).""" + stop = threading.Event() + + def _timer() -> None: + while not stop.wait(0.4): + print("·", end="", flush=True) + + t = threading.Thread(target=_timer, daemon=True) + t.start() + try: + return _listen_for_speech(recognizer, source) + finally: + stop.set() + t.join(timeout=0.5) + + +_last_stt_engine: str = "web" + + +def _stt_cloud_api_key() -> str: + return ( + os.environ.get("STT_CLOUD_API_KEY", "").strip() + or os.environ.get("YOUTUBE_API_KEY", "").strip() + or GOOGLE_API_KEY.strip() + ) + + +def _stt_effective_backend() -> str: + backend = (STT_BACKEND or "web").strip().lower() + return backend if backend in ("web", "cloud") else "web" + + +def _stt_phrase_hints() -> list[str]: + """Cloud STT speechContexts — 집 기기 이름 위주.""" + hints = [ + "자비스", + "메인등", + "거실", + "아기방", + "안방", + "주방", + "티비", + "텔레비전", + "에어컨", + "스탠드형", + "세탁기", + "불 켜", + "불 꺼", + "드라마", + ] + try: + ha = _ha_api() + for eid, meta in ha.load_entities_map().items(): + if not meta.get("enabled"): + continue + name = str(meta.get("name") or "").strip() + if name and name not in hints: + hints.append(name) + except Exception: + pass + seen: set[str] = set() + out: list[str] = [] + for h in hints: + h = h.strip() + if h and h not in seen: + seen.add(h) + out.append(h) + return out[:100] + + +def _recognize_web(recognizer: sr.Recognizer, audio_data: sr.AudioData) -> str: + return recognizer.recognize_google(audio_data, language=STT_LANGUAGE) + + +def _recognize_cloud(recognizer: sr.Recognizer, audio_data: sr.AudioData) -> str: + key = _stt_cloud_api_key() + if not key: + raise RuntimeError( + "Cloud STT API 키 없음 (jarvis.env STT_CLOUD_API_KEY 또는 GOOGLE_API_KEY)" + ) + raw = audio_data.get_raw_data(convert_rate=16000, convert_width=2) + config: dict = { + "encoding": "LINEAR16", + "sampleRateHertz": 16000, + "languageCode": STT_LANGUAGE, + "model": STT_CLOUD_MODEL or "command_and_search", + "enableAutomaticPunctuation": False, + } + phrases = _stt_phrase_hints() + if phrases: + config["speechContexts"] = [{"phrases": phrases, "boost": 10.0}] + payload = { + "config": config, + "audio": {"content": base64.b64encode(raw).decode("ascii")}, + } + url = ( + "https://speech.googleapis.com/v1/speech:recognize?key=" + + urllib.parse.quote(key, safe="") + ) + req = urllib.request.Request( + url, + data=json.dumps(payload).encode(), + method="POST", + headers={"Content-Type": "application/json"}, + ) + try: + with urllib.request.urlopen(req, timeout=20) as resp: + data = json.loads(resp.read().decode()) + except urllib.error.HTTPError as e: + body = e.read()[:400].decode(errors="replace") + raise RuntimeError(f"Cloud STT HTTP {e.code}: {body}") from e + results = data.get("results") or [] + if not results: + raise sr.UnknownValueError("Cloud STT: 결과 없음") + alts = results[0].get("alternatives") or [] + text = (alts[0].get("transcript") if alts else "") or "" + text = str(text).strip() + if not text: + raise sr.UnknownValueError("Cloud STT: transcript 비어 있음") + return text + + +def transcribe_audio( + recognizer: sr.Recognizer, audio_data: sr.AudioData +) -> tuple[str, str]: + """음성 → 텍스트. (text, engine_label) — web | cloud | web(fallback).""" + global _last_stt_engine + backend = _stt_effective_backend() + if backend == "cloud": + try: + text = _recognize_cloud(recognizer, audio_data) + _last_stt_engine = "cloud" + return text, "cloud" + except Exception as e: + print(f"[STT cloud 실패] {e}") + if not STT_CLOUD_FALLBACK: + raise + text = _recognize_web(recognizer, audio_data) + _last_stt_engine = "web(fallback)" + return text, "web(fallback)" + text = _recognize_web(recognizer, audio_data) + _last_stt_engine = "web" + return text, "web" + + +def _mic_gain_pactl_arg(gain: float) -> str: + """STT_MIC_GAIN 1.0=100%, 1.3=130%, 2.0=200% (PulseAudio % 형식).""" + pct = max(50, min(200, int(round(gain * 100)))) + return f"{pct}%" + + +def prepare_mic_for_listen() -> None: + """USB 마이크 감도 올리기.""" + if not PULSE_SOURCE: + return + subprocess.run( + ["pactl", "set-source-mute", PULSE_SOURCE, "0"], + check=False, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ) + if STT_MIC_GAIN > 0: + subprocess.run( + [ + "pactl", + "set-source-volume", + PULSE_SOURCE, + _mic_gain_pactl_arg(STT_MIC_GAIN), + ], + check=False, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ) + + +def _is_wake_echo(text: str) -> bool: + """호출어만 말한 경우 질문으로 보지 않음.""" + c = _compact(text).lower() + noise = ("alexa", "알렉사", "alex", "timer", "타이머", "weather", "웨더") + return any(n in c for n in noise) and len(c) <= 12 + + +def _answer_looks_cut_off(text: str) -> bool: + """Gemini가 중간에 끊은 답(실시간→실) 감지.""" + t = text.strip() + if len(t) < 8: + return True + if t[-1] in ".!?。)" or t[-1].isdigit() or t[-1] in "도%℃": + return False + return True + + +def _take_complete_sentences(buffer: str) -> tuple[list[str], str]: + """스트리밍 버퍼에서 완성된 문장만 분리.""" + sentences: list[str] = [] + rest = buffer + while True: + m = re.search(r"(?<=[.!?。])(?:\s+|$)", rest) + if not m: + break + sent = rest[:m.end()].strip() + rest = rest[m.end():] + if sent: + sentences.append(sent) + return sentences, rest + + +async def _play_audio_async(path: str) -> bool: + """paplay 비동기 — 중단 시 False.""" + if _speech_interrupt.is_set(): + return False + env = os.environ.copy() + if PULSE_SINK: + env["PULSE_SINK"] = PULSE_SINK + cmd = [ + "paplay", + f"--latency-msec={max(10, TTS_PLAY_LATENCY_MS)}", + path, + ] + try: + proc = await asyncio.create_subprocess_exec( + *cmd, + env=env, + stdout=asyncio.subprocess.DEVNULL, + stderr=asyncio.subprocess.DEVNULL, + ) + with _tts_play_lock: + global _tts_play_proc + _tts_play_proc = proc + while proc.returncode is None: + if _speech_interrupt.is_set(): + proc.terminate() + await proc.wait() + return False + await asyncio.sleep(0.05) + with _tts_play_lock: + if _tts_play_proc is proc: + _tts_play_proc = None + if proc.returncode == 0 and not _speech_interrupt.is_set(): + return True + except OSError as e: + print(f"paplay 실패, pygame으로 대체: {e}") + return await asyncio.to_thread(_play_audio_file, path) + + +async def _async_synth_to_path(text: str) -> str: + """문장 하나 → 오디오 파일 (비동기).""" + root = os.path.join(BASE_DIR, f"reply_a_{uuid.uuid4().hex[:8]}") + if TTS_BACKEND == "gemini": + wav_path = f"{root}.wav" + await asyncio.to_thread(_synthesize_gemini, text, wav_path) + return wav_path + mp3_path = f"{root}.mp3" + await _synthesize_edge(text, mp3_path) + return mp3_path + + +async def _async_gemini_voice_reply(user_text: str) -> tuple[str, _TurnUsage]: + """Gemini 스트리밍 + TTS 파이프라인: 재생 중 다음 문장 합성.""" + _begin_speech(interruptible=True) + try: + max_tokens = GEMINI_VOICE_MAX_TOKENS + sentence_q: asyncio.Queue[str | None] = asyncio.Queue() + answer_parts: list[str] = [] + last_chunk = None + + async def producer() -> None: + nonlocal last_chunk + buffer = "" + client = _get_gemini_text_client() + cfg = _gemini_generate_config(max_tokens=max_tokens) + contents = _build_gemini_contents(user_text) + with warnings.catch_warnings(): + warnings.simplefilter("ignore") + with open(os.devnull, "w") as devnull: + with contextlib.redirect_stderr(devnull): + stream = await client.aio.models.generate_content_stream( + model=GEMINI_MODEL, + contents=contents, + config=cfg, + ) + async for chunk in stream: + if _speech_interrupt.is_set(): + break + last_chunk = chunk + if not chunk.text: + continue + answer_parts.append(chunk.text) + buffer += chunk.text + done, buffer = _take_complete_sentences(buffer) + for sent in done: + await sentence_q.put(sent) + if buffer.strip() and not _speech_interrupt.is_set(): + await sentence_q.put(buffer.strip()) + await sentence_q.put(None) + + async def consumer() -> None: + pending: asyncio.Task[str] | None = None + while not _speech_interrupt.is_set(): + try: + sent = await asyncio.wait_for(sentence_q.get(), timeout=0.05) + except asyncio.TimeoutError: + continue + if sent is None: + break + clean = _clean_speech_text(sent) + if not clean: + continue + print(f"자비스: {clean}", flush=True) + synth_task = asyncio.create_task(_async_synth_to_path(clean)) + if pending is not None: + path = await pending + if not await _play_audio_async(path): + synth_task.cancel() + break + _remove_tts_file(path, None) + pending = synth_task + if pending is not None and not _speech_interrupt.is_set(): + path = await pending + if await _play_audio_async(path): + _remove_tts_file(path, None) + elif pending is not None: + pending.cancel() + + await asyncio.gather(producer(), consumer()) + turn_u = _TurnUsage() + if last_chunk is not None: + turn_u.add(print_usage_genai(last_chunk)) + answer = "".join(answer_parts).strip() + return answer, turn_u + finally: + _end_speech() + + +def _run_async_gemini_voice_reply(user_text: str) -> tuple[str, _TurnUsage]: + return asyncio.run(_async_gemini_voice_reply(user_text)) + + +def _speak_quick(text: str) -> None: + """한 문장 빠른 재생 (스트리밍 답변용).""" + clean = _clean_speech_text(text) + if not clean: + return + _begin_speech(interruptible=True) + try: + print(f"자비스: {clean}") + path = _synth_chunk_path(os.path.join(BASE_DIR, "reply_stream"), None, 0, clean) + _play_audio_file(path) + _remove_tts_file(path, None) + finally: + _end_speech() + + +async def _speak_quick_async(text: str) -> None: + clean = _clean_speech_text(text) + if not clean: + return + _begin_speech(interruptible=True) + try: + print(f"자비스: {clean}", flush=True) + path = await _async_synth_to_path(clean) + await _play_audio_async(path) + _remove_tts_file(path, None) + finally: + _end_speech() + + +def _gemini_reply( + user_text: str, *, speak_while_streaming: bool = False +) -> tuple[str, _TurnUsage]: + """HA Function Calling(비스트림). 도구를 썼으면 TTS만 재생. 잡담은 받은 답을 바로 말한다.""" + max_tokens = GEMINI_VOICE_MAX_TOKENS + turn_u = _TurnUsage() + + _reset_ha_tool_calls() + try: + response = _genai_generate_text( + user_text, + max_tokens=max(max_tokens, 384), + with_ha_tools=True, + ) + turn_u.add(print_usage_genai(response, label="ha-tools")) + used_tools = bool(_ha_tool_calls) + answer = (response.text or "").strip() if response else "" + except Exception as e: + print(f"[HA tools] 실패, 일반 대화로 폴백: {e}") + used_tools = False + answer = "" + if GEMINI_STREAM and speak_while_streaming: + return _run_async_gemini_voice_reply(user_text) + response = _genai_generate_text(user_text, max_tokens=max_tokens) + turn_u.add(print_usage_genai(response)) + answer = (response.text or "").strip() + + if used_tools and not answer: + answer = "기기 조작은 했는데 답을 못 만들었어요." + + if not answer: + return "답을 만들지 못했습니다.", turn_u + + if _answer_looks_cut_off(answer) and not used_tools: + print("(답이 끊긴 것 같아 다시 생성)") + retry = _genai_generate_text( + f"질문: {user_text}\n한두 문장으로 끝까지 완결하게 답해.", + max_tokens=160, + system="자비스. 짧고 완결된 한국어 한두 문장.", + use_session_history=False, + ) + turn_u.add(print_usage_genai(retry, label="retry")) + if retry.text and len(retry.text.strip()) > len(answer): + answer = retry.text.strip() + + if speak_while_streaming: + asyncio.run(_speak_quick_async(answer)) + return answer, turn_u + + +def process_user_text(user_text: str, *, stt_engine: str | None = None) -> None: + """음성/텍스트 공통: 메모리 → 장치 명령 → 제미나이. 질문/답변 로그 분리 저장.""" + turn_id = log_question(user_text, source="voice", stt_engine=stt_engine) + + local = handle_memory_command(user_text) + if local is not None: + print("(로컬 메모리, API 호출 없음)") + log_answer(local, turn_id=turn_id, kind="memory") + speak(local, interruptible=False) + return + + device = handle_device_command(user_text) + if device is not None: + print("(로컬 장치 명령, API 호출 없음)") + log_answer(device, turn_id=turn_id, kind="device") + speak(device, interruptible=False) + _commit_youtube_play() + return + + print("생각 중...") + answer, turn_u = _gemini_reply( + user_text, speak_while_streaming=GEMINI_STREAM + ) + usage = turn_u.merged() + append_session_turn(user_text, answer) + log_answer(answer, turn_id=turn_id, kind="gemini", usage=usage) + if GEMINI_STREAM: + pass # 이미 스트리밍 중 재생 완료 + else: + speak(answer, prefetch=_TtsPrefetch(answer)) + + +def listen_command( + recognizer: sr.Recognizer, + mic_stream=None, +) -> bool: + """호출어 이후 명령 듣기 → 처리. 질문 인식 성공 시 True.""" + global _pending_interrupt_listen + load_runtime_config_if_changed(verbose=True) + + paused_wake_mic = mic_stream is not None + if paused_wake_mic: + _pause_wake_mic(mic_stream) + + heard_success = False + try: + prepare_mic_for_listen() + + mic_kwargs: dict = {} + if STT_MIC_INDEX is not None: + mic_kwargs["device_index"] = STT_MIC_INDEX + + with sr.Microphone(**mic_kwargs) as source: + interrupt_round = 0 + while interrupt_round < 4: + interrupt_round += 1 + if interrupt_round > 1: + print("답변을 멈췄어요. 새 질문 말해 주세요.") + if STT_RECAL_BEFORE_LISTEN and interrupt_round == 1: + recalibrate_stt_before_listen(recognizer, source) + _prepare_recognizer_for_listen(recognizer) + play_beep() + if STT_POST_BEEP_DELAY_SEC > 0: + time.sleep(STT_POST_BEEP_DELAY_SEC) + + record_limit = _stt_record_limit_sec() + effective_pause = max(STT_PAUSE_THRESHOLD, STT_NON_SPEAKING_SEC) + print( + f"[STT 준비] backend={_stt_effective_backend()} " + f"energy={recognizer.energy_threshold:.0f} " + f"pause={effective_pause}s 녹음≤{record_limit:.0f}s " + f"smart=on start×{STT_SPEECH_START_RATIO} peak×{STT_SILENCE_PEAK_RATIO} " + f"mic={_mic_gain_pactl_arg(STT_MIC_GAIN)}" + ) + + round_heard = False + for attempt in range(max(1, STT_RETRY_MAX)): + if attempt == 0: + print("지금 말하세요", end="", flush=True) + else: + print("다시 말하세요", end="", flush=True) + try: + if paused_wake_mic: + _pause_wake_mic(mic_stream) + audio_data = _listen_with_feedback(recognizer, source) + if paused_wake_mic: + _restart_wake_mic(mic_stream) + print(" 인식 중…", flush=True) + t_stt = time.time() + user_text, stt_engine = asyncio.run( + asyncio.to_thread( + transcribe_audio, recognizer, audio_data + ) + ) + print( + f"(STT {time.time() - t_stt:.1f}s engine={stt_engine})", + flush=True, + ) + if _is_wake_echo(user_text): + print( + f"(호출어만 들림: {user_text}) — 질문을 말씀해 주세요." + ) + continue + print(f"나: {user_text}") + process_user_text(user_text, stt_engine=stt_engine) + heard_success = True + round_heard = True + break + except sr.WaitTimeoutError: + print("아무 말씀도 없으셔서 대기 모드로 돌아갑니다.") + break + except sr.UnknownValueError: + print("목소리를 제대로 듣지 못했습니다.") + except Exception as e: + print(f"오류 발생: {e}") + break + + if _pending_interrupt_listen: + _pending_interrupt_listen = False + continue + if round_heard: + break + break + return heard_success + finally: + if paused_wake_mic: + _restart_wake_mic(mic_stream) + + +def _porcupine_phrase_filename(phrase: str) -> str: + safe = re.sub(r"[^\w가-힣]+", "_", phrase.strip(), flags=re.UNICODE) + safe = safe.strip("_") or "keyword" + return f"{safe}_raspberry-pi.ppn" + + +def _ensure_porcupine_model() -> str: + path = PORCUPINE_MODEL_PATH + if os.path.isfile(path) and os.path.getsize(path) > 1000: + return path + os.makedirs(os.path.dirname(path) or ".", exist_ok=True) + print(f"한국어 Porcupine 모델 다운로드 중 → {path}") + import urllib.request + + urllib.request.urlretrieve(PORCUPINE_MODEL_URL, path) + if not os.path.isfile(path) or os.path.getsize(path) < 1000: + raise SystemExit(f"Porcupine 한국어 모델 다운로드 실패: {path}") + return path + + +def resolve_porcupine_keyword_paths() -> list[str]: + """환경에 지정된 .ppn 또는 문구로 학습/확보한 경로 목록.""" + if PORCUPINE_KEYWORD_PATH: + paths = [p.strip() for p in PORCUPINE_KEYWORD_PATH.split(",") if p.strip()] + missing = [p for p in paths if not os.path.isfile(p)] + if missing: + raise SystemExit( + "PORCUPINE_KEYWORD_PATH 파일을 찾을 수 없습니다:\n " + + "\n ".join(missing) + ) + return paths + + phrases = [p.strip() for p in PORCUPINE_PHRASES.split(",") if p.strip()] + if not phrases: + raise SystemExit("PORCUPINE_PHRASES 가 비어 있습니다.") + + os.makedirs(PORCUPINE_DIR, exist_ok=True) + paths: list[str] = [] + need_train: list[tuple[str, str]] = [] + for phrase in phrases: + out = os.path.join(PORCUPINE_DIR, _porcupine_phrase_filename(phrase)) + paths.append(out) + if not os.path.isfile(out) or os.path.getsize(out) < 100: + need_train.append((phrase, out)) + + if not need_train: + return paths + + if not PICOVOICE_ACCESS_KEY: + raise SystemExit( + "한국어 호출어(.ppn)가 없습니다. jarvis.env 에 다음을 넣으세요:\n" + " PICOVOICE_ACCESS_KEY=... (https://console.picovoice.ai/)\n" + "또는 Console에서 만든 .ppn 경로를:\n" + " PORCUPINE_KEYWORD_PATH=/path/to/keyword_raspberry-pi.ppn" + ) + + from pvporcupine._util import pv_train_model + + for phrase, out in need_train: + print(f"Porcupine 키워드 학습 중: 「{phrase}」 → {out}") + pv_train_model( + access_key=PICOVOICE_ACCESS_KEY, + output_path=out, + language=PORCUPINE_LANGUAGE, + phrase=phrase, + platform="raspberry-pi", + ) + if not os.path.isfile(out) or os.path.getsize(out) < 100: + raise SystemExit(f"키워드 파일 생성 실패: {out}") + return paths + + +def create_porcupine(): + """한국어 Porcupine 엔진.""" + import pvporcupine + + if not PICOVOICE_ACCESS_KEY: + raise SystemExit( + "WAKE_MODE=korean 에는 Picovoice Access Key가 필요합니다.\n" + " https://console.picovoice.ai/ 에서 발급 후\n" + " jarvis.env 에 PICOVOICE_ACCESS_KEY=... 를 넣고\n" + " systemctl --user restart jarvis" + ) + model_path = _ensure_porcupine_model() + keyword_paths = resolve_porcupine_keyword_paths() + sensitivities = [PORCUPINE_SENSITIVITY] * len(keyword_paths) + print(f"Porcupine 모델={model_path}") + print(f"Porcupine 키워드={keyword_paths}") + print(f"Porcupine sensitivity={PORCUPINE_SENSITIVITY}") + return pvporcupine.create( + access_key=PICOVOICE_ACCESS_KEY, + model_path=model_path, + keyword_paths=keyword_paths, + sensitivities=sensitivities, + ) + + +def _wake_interrupt_worker() -> None: + """TTS/답변 중 호출어 → 말 끊고 새 질문 대기 (대기 중 연속 호출 거부는 wait_wake가 담당).""" + global _pending_interrupt_listen, _last_wake_at + wake_buf = np.zeros(0, dtype=np.int16) + hit_frames = 0 + while not _interrupt_watcher_stop.is_set(): + if not _speech_active or not _speech_interruptible or _wake_mic_paused: + time.sleep(0.05) + wake_buf = np.zeros(0, dtype=np.int16) + hit_frames = 0 + continue + load_runtime_config_if_changed() + ctx = _wake_interrupt_ctx + if not ctx: + time.sleep(0.1) + continue + mic_stream = ctx.get("mic_stream") + mic_rate = ctx.get("mic_rate", 16000) + chunk = ctx.get("chunk", 1280) + oww_model = ctx.get("oww_model") + if mic_stream is None or oww_model is None: + time.sleep(0.1) + continue + if mic_stream.is_stopped(): + time.sleep(0.05) + continue + try: + pcm = np.frombuffer( + mic_stream.read(chunk, exception_on_overflow=False), + dtype=np.int16, + ) + except OSError: + time.sleep(0.1) + continue + pcm16 = to_16k(pcm, mic_rate) + wake_buf = np.concatenate([wake_buf, pcm16]) + while len(wake_buf) >= WAKE_FRAME: + frame = wake_buf[:WAKE_FRAME] + wake_buf = wake_buf[WAKE_FRAME:] + prediction = oww_model.predict(frame) + scored = [ + (model_name, float(prediction.get(model_name, 0.0) or 0.0)) + for model_name in OWW_WAKE_LABELS + ] + winner, score = max(scored, key=lambda x: x[1]) + if score >= WAKE_STRONG_SCORE: + hit_frames = WAKE_MIN_FRAMES + elif score > WAKE_THRESHOLD: + hit_frames += 1 + else: + hit_frames = 0 + if hit_frames < WAKE_MIN_FRAMES: + continue + detail = " ".join(f"{n}={v:.3f}" for n, v in scored) + print( + f"\n[대답 중 호출!] winner={winner} score={score:.3f} " + f"({detail}) — 끊고 새 질문 받기", + flush=True, + ) + _pending_interrupt_listen = True + _last_wake_at = time.time() + request_speech_interrupt() + try: + oww_model.reset() + except Exception: + pass + hit_frames = 0 + wake_buf = np.zeros(0, dtype=np.int16) + break + + +def _start_interrupt_watcher() -> None: + threading.Thread( + target=_wake_interrupt_worker, + daemon=True, + name="jarvis-wake-int", + ).start() + + +def wait_wake_jarvis( + mic_stream, + mic_rate: int, + chunk: int, + recognizer: sr.Recognizer, + oww_model: Model, +) -> None: + """영어 openWakeWord 호출어.""" + global _last_wake_at + wake_buf = np.zeros(0, dtype=np.int16) + last_dbg = time.time() + dbg_max = 0.0 + hit_frames = 0 + print(f"모드=openWakeWord models={OWW_WAKE_LABELS} 임계={WAKE_THRESHOLD} 연속={WAKE_MIN_FRAMES}") + print(f"완료! {WAKE_PROMPT} (종료: Ctrl+C)") + + while True: + load_runtime_config_if_changed() + mic_stream = _wake_interrupt_ctx.get("mic_stream", mic_stream) + chunk = _wake_interrupt_ctx.get("chunk", chunk) + mic_rate = _wake_interrupt_ctx.get("mic_rate", mic_rate) + try: + pcm = np.frombuffer( + mic_stream.read(chunk, exception_on_overflow=False), + dtype=np.int16, + ) + except OSError as e: + print(f"[wake] 마이크 읽기 오류: {e} — 복구 시도") + if not _restart_wake_mic(mic_stream): + raise + continue + pcm16 = to_16k(pcm, mic_rate) + wake_buf = np.concatenate([wake_buf, pcm16]) + + while len(wake_buf) >= WAKE_FRAME: + frame = wake_buf[:WAKE_FRAME] + wake_buf = wake_buf[WAKE_FRAME:] + prediction = oww_model.predict(frame) + scored = [ + (model_name, float(prediction.get(model_name, 0.0) or 0.0)) + for model_name in OWW_WAKE_LABELS + ] + winner, score = max(scored, key=lambda x: x[1]) + dbg_max = max(dbg_max, score) + media_on = _is_media_playing() + thr, strong = _wake_hit_thresholds(media_on) + if score >= strong: + hit_frames = WAKE_MIN_FRAMES + elif score > thr: + hit_frames += 1 + else: + hit_frames = 0 + if hit_frames < WAKE_MIN_FRAMES: + continue + + now = time.time() + if not media_on and now - _last_wake_at < MIN_WAKE_INTERVAL_SEC: + if WAKE_DEBUG: + print( + f"[wake 무시] {MIN_WAKE_INTERVAL_SEC:.0f}초 이내 재호출 " + f"({now - _last_wake_at:.1f}초)" + ) + hit_frames = 0 + continue + + _last_wake_at = now + detail = " ".join(f"{n}={v:.3f}" for n, v in scored) + if media_on: + print( + f"\n[재생 중 호출!] winner={winner} score={score:.3f} " + f"({detail}) — 음악 끊고 질문 받기", + flush=True, + ) + _stop_media_playback() + time.sleep(0.35) + else: + print( + f"\n[감지됨!] winner={winner} score={score:.3f} ({detail})", + flush=True, + ) + wake_buf = np.zeros(0, dtype=np.int16) + hit_frames = 0 + dbg_max = 0.0 + heard = listen_command(recognizer, mic_stream) + if not heard: + print( + "※ 호출어를 연속으로 부르지 마세요. " + "한 번 부르고 삐 소리 뒤에 질문하세요." + ) + print(f"\n다시 대기 — {WAKE_PROMPT}") + oww_model.reset() + cooldown = WAKE_COOLDOWN_SEC + if not heard: + cooldown += LISTEN_FAIL_COOLDOWN_SEC + time.sleep(cooldown) + last_dbg = time.time() + break + + if WAKE_DEBUG and time.time() - last_dbg >= 3.0: + rms = float(np.sqrt(np.mean(pcm.astype(np.float64) ** 2))) if len(pcm) else 0.0 + print(f"[wake 디버그] 최근최대={dbg_max:.3f} mic_rms={rms:.0f}") + dbg_max = 0.0 + last_dbg = time.time() + + +def wait_wake_korean( + mic_stream, + mic_rate: int, + chunk: int, + recognizer: sr.Recognizer, +) -> None: + """한글 호출어: Picovoice Porcupine (로컬, STT 없음).""" + porcupine = create_porcupine() + frame_len = int(porcupine.frame_length) + phrases = [p.strip() for p in PORCUPINE_PHRASES.split(",") if p.strip()] + print( + f"모드=korean Porcupine frame={frame_len} " + f"sensitivity={PORCUPINE_SENSITIVITY}" + ) + print(f"완료! {WAKE_PROMPT} (종료: Ctrl+C)") + wake_buf = np.zeros(0, dtype=np.int16) + last_dbg = time.time() + + try: + while True: + pcm = np.frombuffer( + mic_stream.read(chunk, exception_on_overflow=False), + dtype=np.int16, + ) + pcm16 = to_16k(pcm, mic_rate) + wake_buf = np.concatenate([wake_buf, pcm16]) + + while len(wake_buf) >= frame_len: + frame = wake_buf[:frame_len] + wake_buf = wake_buf[frame_len:] + result = int(porcupine.process(frame)) + if result < 0: + continue + + name = ( + phrases[result] + if result < len(phrases) + else f"keyword[{result}]" + ) + print(f"\n[감지됨!] Porcupine: {name}") + wake_buf = np.zeros(0, dtype=np.int16) + listen_command(recognizer, mic_stream) + print(f"\n다시 대기 — {WAKE_PROMPT}") + time.sleep(WAKE_COOLDOWN_SEC) + last_dbg = time.time() + break + + if WAKE_DEBUG and time.time() - last_dbg >= 3.0: + rms = ( + float(np.sqrt(np.mean(pcm.astype(np.float64) ** 2))) + if len(pcm) + else 0.0 + ) + print(f"[wake 디버그] porcupine 대기 mic_rms={rms:.0f}") + last_dbg = time.time() + finally: + porcupine.delete() + + +def run_self_test() -> None: + """호출어 없이 대화+TTS만 검증 (--test).""" + print("=== 자비스 자가 테스트 (호출어 생략) ===") + set_speaker_volume(SPEAKER_VOLUME) + rebuild_chat() + play_beep() + print("비프음 재생 완료") + process_user_text("테스트입니다. 한 줄로 대답해줘.") + print("=== 테스트 완료 ===") + + +def main() -> None: + set_speaker_volume(SPEAKER_VOLUME) + prepare_mic_for_listen() + mem_n = len(load_memories()) + print(f"설정파일: {ENV_PATH}") + ensure_runtime_config_file() + load_runtime_config_if_changed(verbose=True) + print(f"실시간 설정: {RUNTIME_CONFIG_PATH} (수정 시 재시작 불필요)") + ensure_ha_entities_file() + print( + "HA 엔티티 스위치: " + f"{os.path.join(BASE_DIR, 'jarvis_ha_entities.json')} " + "(enabled 저장 즉시 반영)" + ) + print(f"WAKE_MODE={WAKE_MODE} LANG_MODE={LANG_MODE} STT={STT_LANGUAGE}") + print(f"스피커 볼륨 ≈ {SPEAKER_VOLUME}%") + print( + f"TTS: backend={TTS_BACKEND} model={TTS_MODEL} " + f"voice={TTS_VOICE} style={TTS_STYLE!r}" + ) + if WAKE_MODE not in ("korean", "ko", "hangul", "한글", "porcupine"): + print( + f"호출어(openWakeWord): {OWW_WAKE_LABELS} " + f"framework={OWW_INFERENCE_FRAMEWORK} 임계={WAKE_THRESHOLD}" + ) + print(f"영구 기억: {mem_n}개 ({MEMORY_PATH})") + print(f"대화 로그: {QUESTIONS_LOG} / {ANSWERS_LOG}") + if not os.path.isfile(USAGE_SUMMARY_PATH): + rebuild_usage_summary_from_logs() + print_usage_summary_brief() + rebuild_chat(load_logs=True) + if _session_history: + print(f"대화 맥락: 최근 {len(_session_history)}턴 로그에서 복원") + print("TTS 워밍업…", flush=True) + warmup_tts() + + audio = pyaudio.PyAudio() + global STT_MIC_INDEX + STT_MIC_INDEX = resolve_stt_mic_index(audio) + mic_stream, mic_rate = open_mic_stream(audio) + chunk = max(512, int(mic_rate * 0.08)) + global _wake_mic_paused + _wake_mic_paused = False + if STT_MIC_INDEX is not None: + stt_name = audio.get_device_info_by_index(STT_MIC_INDEX).get("name", "?") + print(f"STT 마이크: index={STT_MIC_INDEX} ({stt_name})") + print( + f"STT: backend={STT_BACKEND} model={STT_CLOUD_MODEL} " + f"fallback={STT_CLOUD_FALLBACK} lang={STT_LANGUAGE} " + f"timeout={STT_TIMEOUT_SEC}s record≤{_stt_record_limit_sec():.0f}s " + f"mic_gain={STT_MIC_GAIN} quiet={STT_QUIET_MODE} " + f"energy={STT_ENERGY_THRESHOLD}" + ) + print(f"STT 실시간 전환: {RUNTIME_CONFIG_PATH} → stt_backend: web | cloud") + recognizer = sr.Recognizer() + configure_recognizer(recognizer) + calibrate_stt_ambient(recognizer) + + try: + if WAKE_MODE in ("korean", "ko", "hangul", "한글", "porcupine"): + wait_wake_korean(mic_stream, mic_rate, chunk, recognizer) + else: + print("호출어 엔진(openWakeWord) 로딩 중...") + oww_model = Model( + wakeword_models=list(OWW_WAKE_PATHS), + inference_framework=OWW_INFERENCE_FRAMEWORK, + ) + _wake_interrupt_ctx.update( + audio=audio, + mic_stream=mic_stream, + mic_rate=mic_rate, + chunk=chunk, + oww_model=oww_model, + ) + _start_interrupt_watcher() + wait_wake_jarvis(mic_stream, mic_rate, chunk, recognizer, oww_model) + except KeyboardInterrupt: + print("\n프로그램을 종료합니다.") + finally: + mic_stream.stop_stream() + mic_stream.close() + audio.terminate() + + +if __name__ == "__main__": + import sys + + if "--test" in sys.argv: + run_self_test() + else: + main() diff --git a/jarvis_hooks/ha.env.example b/jarvis_hooks/ha.env.example new file mode 100644 index 0000000..54cd0db --- /dev/null +++ b/jarvis_hooks/ha.env.example @@ -0,0 +1,3 @@ +# ha.env.example — 복사: cp ha.env.example ha.env 후 토큰 입력 +HA_URL=http://192.168.0.148:8123 +HA_TOKEN= diff --git a/jarvis_hooks/ha_api.py b/jarvis_hooks/ha_api.py new file mode 100644 index 0000000..a8bb90d --- /dev/null +++ b/jarvis_hooks/ha_api.py @@ -0,0 +1,710 @@ +#!/usr/bin/env python3 +"""Home Assistant 연동 — 엔티티 스위치 JSON + light/climate/TV/washer.""" + +from __future__ import annotations + +import json +import os +import re +import urllib.error +import urllib.request +from pathlib import Path + +HOOKS = Path(__file__).resolve().parent +BASE_DIR = HOOKS.parent +ENV_PATH = HOOKS / "ha.env" +ALIAS_PATH = HOOKS / "aliases.json" +ENTITIES_PATH = Path( + os.environ.get("JARVIS_HA_ENTITIES", str(BASE_DIR / "jarvis_ha_entities.json")) +) + +_entities_mtime: float = -1.0 +_entities_cache: dict[str, dict] = {} + +DEFAULT_ON_DOMAINS = frozenset({"light", "climate"}) +DEFAULT_OFF_DOMAINS = frozenset( + { + "lock", + "alarm_control_panel", + "cover", + "person", + "device_tracker", + "camera", + "image", + "update", + "automation", + "script", + "scene", + "button", + "event", + "notify", + "tts", + "stt", + "conversation", + "ai_task", + "todo", + "calendar", + "zone", + } +) +TV_NAME_KEYS = ("tv", "webos", "티비", "텔레비전", "television", "엘지티비") + + +def load_env() -> dict[str, str]: + env: dict[str, str] = {} + if not ENV_PATH.is_file(): + raise SystemExit(f"ha.env 없음: {ENV_PATH}") + for line in ENV_PATH.read_text(encoding="utf-8").splitlines(): + line = line.strip() + if not line or line.startswith("#") or "=" not in line: + continue + k, v = line.split("=", 1) + env[k.strip()] = v.strip() + if not env.get("HA_TOKEN"): + raise SystemExit("HA_TOKEN 비어 있음 (ha.env 저장 확인)") + env["HA_URL"] = env.get("HA_URL", "http://192.168.0.148:8123").rstrip("/") + return env + + +def ha_request(method: str, path: str, data: dict | None = None) -> object: + env = load_env() + body = None if data is None else json.dumps(data).encode() + req = urllib.request.Request( + env["HA_URL"] + path, + data=body, + method=method, + headers={ + "Authorization": f"Bearer {env['HA_TOKEN']}", + "Content-Type": "application/json", + }, + ) + try: + with urllib.request.urlopen(req, timeout=15) as resp: + raw = resp.read() + if not raw: + return None + return json.loads(raw.decode()) + except urllib.error.HTTPError as e: + raise RuntimeError(f"HA HTTP {e.code}: {e.read()[:200]!r}") from e + + +def get_states() -> list[dict]: + data = ha_request("GET", "/api/states") + if not isinstance(data, list): + raise RuntimeError(f"states 이상: {data!r}"[:200]) + return data + + +def _compact(s: str) -> str: + return re.sub(r"\s+", "", s) + + +def _looks_like_tv(entity_id: str, friendly_name: str) -> bool: + blob = f"{entity_id} {friendly_name}".lower() + return any(k in blob for k in TV_NAME_KEYS) + + +def _default_enabled(entity_id: str, friendly_name: str) -> bool: + domain = entity_id.split(".", 1)[0] + if domain in DEFAULT_ON_DOMAINS: + return True + if entity_id.startswith(("sensor.setaggi", "binary_sensor.setaggi")): + return True + if "setaggi" in entity_id: + return True + if domain == "media_player": + return _looks_like_tv(entity_id, friendly_name) + if domain in DEFAULT_OFF_DOMAINS: + return False + return False + + +def load_aliases() -> dict[str, str]: + if not ALIAS_PATH.is_file(): + return {} + try: + data = json.loads(ALIAS_PATH.read_text(encoding="utf-8")) + return { + str(k): str(v) + for k, v in (data or {}).items() + if not str(k).startswith("_") + } + except (OSError, json.JSONDecodeError, TypeError): + return {} + + +def load_entities_map(*, force: bool = False) -> dict[str, dict]: + """jarvis_ha_entities.json — mtime 변경 시 재로드.""" + global _entities_mtime, _entities_cache + if not ENTITIES_PATH.is_file(): + _entities_mtime = -1.0 + _entities_cache = {} + return {} + try: + mtime = ENTITIES_PATH.stat().st_mtime + except OSError: + return _entities_cache + if not force and mtime == _entities_mtime and _entities_cache: + return _entities_cache + try: + data = json.loads(ENTITIES_PATH.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError) as e: + print(f"[ha_entities] 읽기 실패: {e}") + return _entities_cache + raw = data.get("entities") if isinstance(data, dict) else None + if not isinstance(raw, dict): + return _entities_cache + cleaned: dict[str, dict] = {} + for eid, meta in raw.items(): + if not isinstance(meta, dict): + continue + cleaned[str(eid)] = { + "name": str(meta.get("name") or eid), + "enabled": bool(meta.get("enabled", False)), + } + _entities_cache = cleaned + _entities_mtime = mtime + return _entities_cache + + +def is_enabled(entity_id: str) -> bool: + ents = load_entities_map() + if not ents: + domain = entity_id.split(".", 1)[0] + return domain in DEFAULT_ON_DOMAINS or "setaggi" in entity_id + meta = ents.get(entity_id) + return bool(meta and meta.get("enabled")) + + +def assert_enabled(entity_id: str) -> str | None: + """허용이면 None, 아니면 거부 메시지.""" + ents = load_entities_map() + if not ents: + return None + if entity_id not in ents: + return f"{entity_id} 는 엔티티 JSON에 없습니다. sync 후 enabled를 확인하세요." + if not ents[entity_id].get("enabled"): + name = ents[entity_id].get("name") or entity_id + return f"{name}({entity_id}) 는 스위치가 꺼져 있어 조작할 수 없습니다." + return None + + +def sync_entities_file(*, path: Path | None = None) -> Path: + """HA 전 엔티티를 JSON에 넣는다. 기존 enabled는 유지, 신규만 기본값.""" + out = path or ENTITIES_PATH + states = get_states() + existing: dict[str, dict] = {} + if out.is_file(): + try: + prev = json.loads(out.read_text(encoding="utf-8")) + raw = prev.get("entities") if isinstance(prev, dict) else {} + if isinstance(raw, dict): + for eid, meta in raw.items(): + if isinstance(meta, dict): + existing[str(eid)] = { + "name": str(meta.get("name") or eid), + "enabled": bool(meta.get("enabled", False)), + } + except (OSError, json.JSONDecodeError): + pass + + entities: dict[str, dict] = {} + for st in states: + eid = str(st.get("entity_id") or "") + if not eid or "." not in eid: + continue + fn = str((st.get("attributes") or {}).get("friendly_name") or eid) + if eid in existing: + entities[eid] = { + "name": existing[eid].get("name") or fn, + "enabled": bool(existing[eid].get("enabled")), + } + else: + entities[eid] = { + "name": fn, + "enabled": _default_enabled(eid, fn), + } + + for eid, meta in existing.items(): + if eid not in entities: + entities[eid] = meta + + payload = { + "_help": ( + "enabled true만 자비스가 읽고 조작. 저장 즉시 반영(재시작 불필요). " + "동기화: python3 jarvis_hooks/ha_api.py sync" + ), + "entities": dict(sorted(entities.items())), + } + out.write_text( + json.dumps(payload, ensure_ascii=False, indent=2) + "\n", + encoding="utf-8", + ) + load_entities_map(force=True) + enabled_n = sum(1 for m in entities.values() if m.get("enabled")) + print(f"[ha_entities] {out} — 전체 {len(entities)} / enabled {enabled_n}") + return out + + +def _display_name(entity_id: str, friendly_name: str) -> str: + ents = load_entities_map() + meta = ents.get(entity_id) + if meta and meta.get("name"): + return str(meta["name"]) + return friendly_name or entity_id + + +def _apply_aliases(utterance: str) -> str: + c = _compact(utterance) + aliases = load_aliases() + for alias, target in sorted(aliases.items(), key=lambda x: -len(x[0])): + if _compact(alias) and _compact(alias) in c: + return target + return utterance + + +def _match_by_name( + utterance: str, + candidates: list[tuple[str, str, str]], + suffixes: tuple[str, ...] = (), +) -> tuple[str, str] | None: + """candidates: (display_name, entity_id, state) — 긴 이름 우선.""" + text = _apply_aliases(utterance) + c = _compact(text) + scored: list[tuple[int, str, str]] = [] + for fn, eid, _ in candidates: + cf = _compact(fn) + if not cf: + continue + if cf in c or (len(c) >= 2 and c in cf): + scored.append((len(cf), fn, eid)) + continue + for suffix in suffixes: + if cf + suffix in c or cf in c.replace(suffix, ""): + scored.append((len(cf), fn, eid)) + break + ce = _compact(eid.split(".", 1)[-1]) + if ce and len(ce) >= 3 and ce in c: + scored.append((len(ce), fn, eid)) + if scored: + scored.sort(reverse=True) + return scored[0][1], scored[0][2] + return None + + +def list_lights(states: list[dict] | None = None) -> list[tuple[str, str, str]]: + """(friendly_name, entity_id, state) — enabled만.""" + states = states or get_states() + out = [] + for st in states: + eid = st.get("entity_id") or "" + if not eid.startswith("light.") or not is_enabled(eid): + continue + fn = (st.get("attributes") or {}).get("friendly_name") or eid + out.append((_display_name(eid, fn), eid, st.get("state") or "unknown")) + return out + + +def resolve_light( + utterance: str, states: list[dict] | None = None +) -> tuple[str, str] | None: + states = states or get_states() + lights = list_lights(states) + hit = _match_by_name( + utterance, lights, suffixes=("불", "등", "조명", "라이트") + ) + if hit: + return hit + c = _compact(_apply_aliases(utterance)) + prefers: list[str] = [] + for room in ("거실", "아기방", "안방", "주방", "화장실"): + if room in c: + prefers.append(room) + prefers.extend(("메인등", "거실", "아기방")) + if any( + k in c + for k in ("불켜", "불꺼", "라이트온", "라이트오프", "조명켜", "조명꺼") + ) or prefers: + seen: set[str] = set() + for prefer in prefers: + if prefer in seen: + continue + seen.add(prefer) + for fn, eid, _ in lights: + if prefer in fn: + return fn, eid + if lights: + return lights[0][0], lights[0][1] + return None + + +def light_service(entity_id: str, turn_on: bool) -> None: + err = assert_enabled(entity_id) + if err: + raise RuntimeError(err) + svc = "turn_on" if turn_on else "turn_off" + ha_request("POST", f"/api/services/light/{svc}", {"entity_id": entity_id}) + + +def list_climates( + states: list[dict] | None = None, +) -> list[tuple[str, str, str, float | None]]: + """(name, entity_id, state, temperature).""" + states = states or get_states() + out = [] + for st in states: + eid = st.get("entity_id") or "" + if not eid.startswith("climate.") or not is_enabled(eid): + continue + attrs = st.get("attributes") or {} + fn = attrs.get("friendly_name") or eid + temp = attrs.get("temperature") + try: + temp_f = float(temp) if temp is not None else None + except (TypeError, ValueError): + temp_f = None + out.append( + (_display_name(eid, fn), eid, st.get("state") or "unknown", temp_f) + ) + return out + + +def resolve_climate( + utterance: str, states: list[dict] | None = None +) -> tuple[str, str] | None: + climates = [(n, e, s) for n, e, s, _ in list_climates(states)] + hit = _match_by_name( + utterance, climates, suffixes=("에어컨", "냉방", "공조") + ) + if hit: + return hit + c = _compact(_apply_aliases(utterance)) + if any(k in c for k in ("에어컨", "더워", "추워", "냉방", "난방")): + for prefer in ("스탠드", "거실", "이문동"): + for fn, eid, _ in climates: + if prefer in fn: + return fn, eid + if climates: + return climates[0][0], climates[0][1] + return None + + +def climate_service( + entity_id: str, + action: str, + temperature: int | float | None = None, +) -> str: + err = assert_enabled(entity_id) + if err: + return err + action = (action or "").strip().lower() + if action in ("on", "turn_on", "켜", "켜줘"): + ha_request( + "POST", + "/api/services/climate/turn_on", + {"entity_id": entity_id}, + ) + temp = 24 if temperature is None else int(temperature) + ha_request( + "POST", + "/api/services/climate/set_temperature", + {"entity_id": entity_id, "temperature": temp}, + ) + return f"{entity_id} 를 {temp}도로 켰습니다." + if action in ("off", "turn_off", "꺼", "꺼줘"): + ha_request( + "POST", + "/api/services/climate/turn_off", + {"entity_id": entity_id}, + ) + return f"{entity_id} 를 껐습니다." + if action in ("set", "set_temperature", "온도"): + if temperature is None: + return "온도 숫자가 필요합니다." + temp = int(temperature) + ha_request( + "POST", + "/api/services/climate/set_temperature", + {"entity_id": entity_id, "temperature": temp}, + ) + return f"{entity_id} 온도를 {temp}도로 맞췄습니다." + return f"알 수 없는 climate action: {action}" + + +def list_tvs(states: list[dict] | None = None) -> list[tuple[str, str, str]]: + states = states or get_states() + out = [] + for st in states: + eid = st.get("entity_id") or "" + if not eid.startswith("media_player.") or not is_enabled(eid): + continue + fn = (st.get("attributes") or {}).get("friendly_name") or eid + out.append((_display_name(eid, fn), eid, st.get("state") or "unknown")) + return out + + +def resolve_tv( + utterance: str = "", states: list[dict] | None = None +) -> tuple[str, str] | None: + tvs = list_tvs(states) + if not tvs: + return None + text = utterance or "티비" + hit = _match_by_name( + text, tvs, suffixes=("티비", "TV", "텔레비전", "티브이") + ) + if hit: + return hit + for fn, eid, _ in tvs: + if _looks_like_tv(eid, fn): + return fn, eid + return tvs[0][0], tvs[0][1] + + +def tv_service(entity_id: str, turn_on: bool) -> None: + err = assert_enabled(entity_id) + if err: + raise RuntimeError(err) + svc = "turn_on" if turn_on else "turn_off" + ha_request( + "POST", f"/api/services/media_player/{svc}", {"entity_id": entity_id} + ) + + +def home_snapshot(states: list[dict] | None = None) -> str: + """enabled 기기만 짧게. 제미나이 컨텍스트용.""" + states = states or get_states() + by_id = {st.get("entity_id"): st for st in states} + lines: list[str] = [] + + for fn, eid, st in list_lights(states): + lines.append(f"light | {fn} | {eid} | {st}") + for fn, eid, st, temp in list_climates(states): + extra = f", set={temp}" if temp is not None else "" + cur = (by_id.get(eid) or {}).get("attributes", {}).get( + "current_temperature" + ) + if cur is not None: + extra += f", now={cur}" + lines.append(f"climate | {fn} | {eid} | {st}{extra}") + for fn, eid, st in list_tvs(states): + lines.append(f"tv/media | {fn} | {eid} | {st}") + + washer_bits = [] + for eid, st in by_id.items(): + if not eid or "setaggi" not in eid: + continue + if not eid.startswith(("sensor.", "binary_sensor.")): + continue + if not is_enabled(eid): + continue + washer_bits.append(f"{eid}={st.get('state')}") + if washer_bits: + lines.append("washer | " + ", ".join(washer_bits[:12])) + + if not lines: + return "enabled 기기가 없습니다. jarvis_ha_entities.json 을 확인하세요." + return "\n".join(lines) + + +def washer_status(states: list[dict] | None = None) -> str: + states = states or get_states() + by_id = {st.get("entity_id"): st for st in states} + + remain = None + power = None + run_state = None + pre = None + completed = None + + if "sensor.setaggi" in by_id: + st = by_id["sensor.setaggi"] + attrs = st.get("attributes") or {} + power = st.get("state") + remain = attrs.get("remain_time") + run_state = attrs.get("run_state") + completed = attrs.get("run_completed") + + if "sensor.setaggi_remaining_time" in by_id: + remain = remain or by_id["sensor.setaggi_remaining_time"].get("state") + if "sensor.setaggi_remaining_time_2" in by_id: + r2 = by_id["sensor.setaggi_remaining_time_2"].get("state") + if r2 and r2 not in ("unknown", "unavailable", "none"): + remain = remain or r2 + if "sensor.setaggi_current_status" in by_id: + power = power or by_id["sensor.setaggi_current_status"].get("state") + if "sensor.setaggi_run_state" in by_id: + run_state = run_state or by_id["sensor.setaggi_run_state"].get("state") + if "sensor.setaggi_pre_state" in by_id: + pre = by_id["sensor.setaggi_pre_state"].get("state") + if "binary_sensor.setaggi_run_completed" in by_id: + completed = completed or by_id["binary_sensor.setaggi_run_completed"].get( + "state" + ) + + def fmt_remain(v) -> str | None: + if v is None: + return None + s = str(v).strip() + if s in ("", "unknown", "unavailable", "none", "-", "0:00:00", "00:00:00"): + return None + m = re.match(r"^(\d+):(\d+):(\d+)$", s) + if m: + h, mi, sec = map(int, m.groups()) + total = h * 60 + mi + (1 if sec else 0) + if total <= 0: + return None + if h > 0: + return f"{h}시간 {mi}분" + return f"{mi}분" + return s + + remain_s = fmt_remain(remain) + off_like = str(power).lower() in ( + "off", + "power_off", + "unavailable", + "unknown", + "", + ) + + if remain_s: + extra = ( + f", 상태 {run_state}" + if run_state and run_state not in ("-", "unknown") + else "" + ) + return f"세탁기 남은 시간은 약 {remain_s}입니다{extra}." + + if completed in ("on", "True", True) or (pre and "완료" in str(pre)): + return ( + f"세탁기는 지금 꺼져 있고, 세탁은 끝난 상태입니다. " + f"{('이전 상태: ' + str(pre)) if pre else ''}" + ).strip() + + if off_like: + return "세탁기는 지금 전원 꺼짐 상태라 남은 시간이 없습니다." + + return f"세탁기 상태: 전원 {power}, 남은 시간 정보 없음." + + +def tool_get_home_snapshot() -> str: + """지금 집안에서 자비스가 조작 가능한(enabled) 기기 목록과 상태를 조회한다.""" + try: + return home_snapshot() + except Exception as e: + return f"스냅샷 실패: {e}" + + +def tool_control_light(name: str, action: str) -> str: + """조명을 켠다/끈다. name은 방/조명 이름(예: 거실, 메인등, 아기방). action은 on 또는 off.""" + try: + turn_on = str(action).strip().lower() in ( + "on", + "turn_on", + "켜", + "켜줘", + "켜라", + ) + hit = resolve_light(name or "") + if not hit: + return f"조명을 찾지 못했습니다: {name}" + fn, eid = hit + light_service(eid, turn_on) + return f"{fn}을 {'켰습니다' if turn_on else '껐습니다'}." + except Exception as e: + return f"조명 제어 실패: {e}" + + +def tool_control_climate( + name: str = "", + action: str = "turn_on", + temperature: int = 24, +) -> str: + """에어컨/난방을 켠다/끈다/온도를 맞춘다. action: turn_on, turn_off, set. temperature 기본 24.""" + try: + hit = resolve_climate(name or "에어컨") + if not hit: + return f"에어컨을 찾지 못했습니다: {name or '(기본)'}" + fn, eid = hit + msg = climate_service(eid, action, temperature) + return f"{fn}: {msg}" + except Exception as e: + return f"에어컨 제어 실패: {e}" + + +def tool_control_tv(name: str = "", action: str = "turn_on") -> str: + """티비를 켠다/끈다. name 생략 시 거실 LG 티비. action: on/off.""" + try: + turn_on = str(action).strip().lower() in ( + "on", + "turn_on", + "켜", + "켜줘", + "켜라", + ) + hit = resolve_tv(name or "티비") + if not hit: + return ( + "티비(media_player)를 찾지 못했습니다. " + "entities JSON에서 enabled를 확인하세요." + ) + fn, eid = hit + tv_service(eid, turn_on) + return f"{fn}을 {'켰습니다' if turn_on else '껐습니다'}." + except Exception as e: + return f"티비 제어 실패: {e}" + + +def tool_get_washer_status() -> str: + """세탁기 남은 시간·전원·완료 여부를 조회한다.""" + try: + return washer_status() + except Exception as e: + return f"세탁기 조회 실패: {e}" + + +if __name__ == "__main__": + import sys + + cmd = sys.argv[1] if len(sys.argv) > 1 else "washer" + if cmd == "sync": + sync_entities_file() + elif cmd == "snapshot": + if not ENTITIES_PATH.is_file(): + sync_entities_file() + print(home_snapshot()) + elif cmd == "washer": + print(washer_status()) + elif cmd == "lights": + for fn, eid, st in list_lights(): + print(f"{fn}\t{eid}\t{st}") + elif cmd == "tvs": + for fn, eid, st in list_tvs(): + print(f"{fn}\t{eid}\t{st}") + elif cmd == "climates": + for fn, eid, st, temp in list_climates(): + print(f"{fn}\t{eid}\t{st}\t{temp}") + elif cmd in ("on", "off") and len(sys.argv) > 2: + name = " ".join(sys.argv[2:]) + hit = resolve_light(name) + if not hit: + print("FAIL no light", name) + sys.exit(1) + fn, eid = hit + light_service(eid, cmd == "on") + print(f"OK {cmd} {fn} {eid}") + elif cmd in ("tv_on", "tv_off"): + name = " ".join(sys.argv[2:]) if len(sys.argv) > 2 else "티비" + hit = resolve_tv(name) + if not hit: + print("FAIL no tv", name) + sys.exit(1) + fn, eid = hit + tv_service(eid, cmd == "tv_on") + print(f"OK {cmd} {fn} {eid}") + else: + print( + "usage: ha_api.py sync|snapshot|washer|lights|tvs|climates|" + "on <이름>|off <이름>|tv_on [이름]|tv_off [이름]" + ) diff --git a/jarvis_models/Hey_Bee_moh_20260824_184317.tflite b/jarvis_models/Hey_Bee_moh_20260824_184317.tflite new file mode 100644 index 0000000..40c36ff Binary files /dev/null and b/jarvis_models/Hey_Bee_moh_20260824_184317.tflite differ diff --git a/jarvis_models/Jah-nee_20260826_170048.tflite b/jarvis_models/Jah-nee_20260826_170048.tflite new file mode 100644 index 0000000..e5b9da5 Binary files /dev/null and b/jarvis_models/Jah-nee_20260826_170048.tflite differ diff --git a/jarvis_models/Pico_20260429_202937.tflite b/jarvis_models/Pico_20260429_202937.tflite new file mode 100644 index 0000000..d1a56a7 Binary files /dev/null and b/jarvis_models/Pico_20260429_202937.tflite differ diff --git a/jarvis_porcupine/porcupine_params_ko.pv b/jarvis_porcupine/porcupine_params_ko.pv new file mode 100644 index 0000000..3e9b62e Binary files /dev/null and b/jarvis_porcupine/porcupine_params_ko.pv differ diff --git a/jarvis_runtime.json b/jarvis_runtime.json new file mode 100644 index 0000000..3e0a9ae --- /dev/null +++ b/jarvis_runtime.json @@ -0,0 +1,20 @@ +{ + "_help": "말 중간 잘리면: pause↑ peak_ratio↑ (0.9~0.95). 녹음 한도: phrase_limit/max_record. start_ratio↑=말 시작 느림.", + "wake_threshold": 0.30, + "wake_min_frames": 2, + "wake_strong_score": 0.52, + "min_wake_interval_sec": 5.0, + "stt_pause_threshold": 1.0, + "stt_min_pause_sec": 1.2, + "stt_silence_peak_ratio": 0.92, + "stt_speech_start_ratio": 1.35, + "stt_non_speaking_sec": 0.7, + "stt_mic_gain": 1.35, + "stt_energy_threshold": 280, + "stt_phrase_limit_sec": 15, + "stt_max_record_sec": 18, + "stt_phrase_threshold": 0.05, + "stt_timeout_sec": 8, + "chat_history_max": 6, + "stt_backend": "web" +} diff --git a/jarvis_yt_play.sh b/jarvis_yt_play.sh new file mode 100755 index 0000000..c4c460d --- /dev/null +++ b/jarvis_yt_play.sh @@ -0,0 +1,53 @@ +#!/bin/bash +# 유튜브 재생 (자비스 메인 프로세스 밖 — fork 데드락 방지) +# 사용: jarvis_yt_play.sh "검색어" ["https://youtube.com/watch?v=..."] +set -euo pipefail + +QUERY="${1:-}" +WATCH_URL="${2:-}" +BASE="/home/hoon/projects/jarvis" +YT="${BASE}/jarvis_env/bin/yt-dlp" +LOG="${BASE}/jarvis_logs/yt.log" +PIDFILE="${BASE}/jarvis_logs/media.pid" + +log() { echo "[$(date '+%Y-%m-%d %H:%M:%S')] $*" >>"$LOG"; } + +if [[ -z "$QUERY" ]]; then + log "빈 검색어" + exit 1 +fi + +if [[ -f "$PIDFILE" ]]; then + old_pid="$(cat "$PIDFILE" 2>/dev/null || true)" + if [[ -n "$old_pid" ]]; then + kill "$old_pid" 2>/dev/null || true + sleep 0.2 + kill -9 "$old_pid" 2>/dev/null || true + fi + rm -f "$PIDFILE" +fi + +log "검색: $QUERY" + +URL="" +if [[ -x "$YT" ]]; then + if [[ -n "$WATCH_URL" && "$WATCH_URL" =~ ^https?:// ]]; then + log "API/사전확정 URL: $WATCH_URL" + URL="$("$YT" -f bestaudio/best --no-playlist -g "$WATCH_URL" 2>>"$LOG" | head -1 || true)" + else + URL="$("$YT" -f bestaudio/best --no-playlist -g "ytsearch1:${QUERY}" 2>>"$LOG" | head -1 || true)" + fi +fi + +if [[ ! "$URL" =~ ^https?:// ]]; then + log "URL 실패: $QUERY" + exit 1 +fi + +log "재생 시작: $QUERY" + +cvlc --play-and-exit --no-video --quiet --no-repeat "$URL" & +echo $! >"$PIDFILE" +wait +rm -f "$PIDFILE" +log "재생 종료: $QUERY"