204 lines
7.3 KiB
Python
204 lines
7.3 KiB
Python
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()
|