2 Commits

Author SHA1 Message Date
javis-bot
f64d76e737 feat: use Edge TTS (Korean Hyunsu voice @ +45%) as the default voice
Some checks failed
Release / semantic-release (push) Successful in 31s
Release / build-windows (push) Has been cancelled
Release / build-macos (arm64, macos-latest) (push) Has been cancelled
Release / build-macos (x64, macos-15-intel) (push) Has been cancelled
Release / build-linux (push) Has been cancelled
Release / release-main (push) Has been cancelled
Release / release-develop (push) Has been cancelled
tests / Unit tests (Linux, Python 3.11) (push) Has been cancelled
The user chose Microsoft Edge TTS, voice ko-KR-HyunsuMultilingualNeural at rate
+45% (~1.45x), as the natural Korean voice. Wire it into the bridge and make it
the default engine.

- bridge/server.py: _edge_synthesize() calls edge-tts and transcodes the MP3 to
  PCM16 mono WAV with the system ffmpeg (temp file for a correct header);
  TTS_ENGINE default -> edge; EDGE_TTS_VOICE / EDGE_TTS_RATE env-driven
- requirements-bridge.txt: add edge-tts (lightweight; httpx)
- compose/.env.example/README: TTS_ENGINE=edge + EDGE_TTS_* knobs; note the
  online/privacy trade-off (reply text is sent to Microsoft, needs internet)
- drop the now-unused MeloTTS build layer (Dockerfile) and melo-worker
  (supervisord) — edge synthesises in-process, no model/worker baked, slimmer
  and faster image; settings UI engine list -> edge/piper, restart only bridge

Verified on host: edge-tts -> ffmpeg yields a valid 16-bit mono 24kHz WAV;
envsubst renders tts_engine=edge; docker build --check + 26 tests pass.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-23 03:44:15 +09:00
javis-bot
11c3621093 fix: make container TTS engine env-driven so melo isn't overridden by piper
Some checks failed
Release / semantic-release (push) Successful in 30s
tests / Unit tests (Linux, Python 3.11) (push) Failing after 5m17s
Release / build-linux (push) Failing after 7m3s
Release / build-windows (push) Has been cancelled
Release / build-macos (arm64, macos-latest) (push) Has been cancelled
Release / build-macos (x64, macos-15-intel) (push) Has been cancelled
Release / release-main (push) Has been cancelled
Release / release-develop (push) Has been cancelled
docker/jarvis-config.template.json hardcoded "tts_engine": "piper". entrypoint
renders it into /app/config/jarvis.json, and bridge _tts_engine_setting() reads
that JSON BEFORE the env — so TTS_ENGINE=melo in .env was ignored and the bot
synthesised Korean with the English Piper voice (the "foreign accent" the user
heard); the warm melo-worker sat unused.

Template now carries ${TTS_ENGINE}; compose sets TTS_ENGINE=${TTS_ENGINE:-melo}
so envsubst renders the real engine. Verified: envsubst with TTS_ENGINE=melo
yields "tts_engine": "melo", and `docker compose config` passes TTS_ENGINE=melo.
Added a regression test that the template stays env-driven and renders the
configured engine.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-23 03:27:33 +09:00
10 changed files with 153 additions and 94 deletions

View File

@@ -34,18 +34,18 @@ WHISPER_DEVICE=cuda
WHISPER_COMPUTE_TYPE=float16 WHISPER_COMPUTE_TYPE=float16
# Optional explicit Piper voice model (.onnx). If empty, the jarvis default is used. # Optional explicit Piper voice model (.onnx). If empty, the jarvis default is used.
TTS_PIPER_MODEL_PATH= TTS_PIPER_MODEL_PATH=
# TTS engine: "melo" (default) uses the MeloTTS Korean voice served by the warm # TTS engine: "edge" (default) uses Microsoft Edge TTS, a natural Korean neural
# melo-worker (Korean speaker, speed 1.5). Set to "piper" to use Piper directly. # voice. Set to "piper" for the offline English voice. NOTE: edge is ONLINE —
TTS_ENGINE=melo # reply text is sent to Microsoft's servers and needs internet.
# Melo-only by default: if MeloTTS synthesis fails the bridge returns no audio TTS_ENGINE=edge
# rather than speaking Korean through the English Piper voice (which mangles it). # Edge voice + speaking rate. Rate is a percentage (+45% ≈ 1.45×). Korean voices:
# Set to 1 only if you explicitly want the Piper fallback. # ko-KR-HyunsuMultilingualNeural (M), ko-KR-InJoonNeural (M), ko-KR-SunHiNeural (F).
EDGE_TTS_VOICE=ko-KR-HyunsuMultilingualNeural
EDGE_TTS_RATE=+45%
# Neural-only by default: if synthesis fails the bridge returns no audio rather
# than speaking Korean through the English Piper voice. Set to 1 to allow the
# Piper fallback.
MELO_FALLBACK_PIPER=0 MELO_FALLBACK_PIPER=0
# Where the bridge reaches the in-container MeloTTS worker, and how long it
# waits for a synthesis. Speaking rate is set on the worker via MELO_SPEED.
MELO_WORKER_URL=http://127.0.0.1:8770
MELO_TIMEOUT=30
MELO_SPEED=1.5
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Jarvis brain (Ollama-backed). In Docker these populate the rendered # Jarvis brain (Ollama-backed). In Docker these populate the rendered

View File

@@ -65,20 +65,14 @@ RUN ls -d /opt/venv/lib/python*/site-packages/nvidia/cublas/lib \
> /etc/ld.so.conf.d/nvidia-cu12.conf 2>/dev/null \ > /etc/ld.so.conf.d/nvidia-cu12.conf 2>/dev/null \
&& /sbin/ldconfig || true && /sbin/ldconfig || true
# --- MeloTTS Korean voice (separate /opt/melo py3.11 venv; see setup-melo.sh). # --- Korean voice: Microsoft Edge TTS (online neural). No model is baked — the
# Heavy layer (torch CPU + transformers + MeCab); placed before the app # `edge-tts` pip package (in requirements-bridge.txt) calls the MS service at
# COPY so it stays cached across source-only changes. --- # runtime and the bridge transcodes the MP3 to PCM16 with ffmpeg. No heavy
COPY docker/setup-melo.sh /app/docker/setup-melo.sh # TTS build layer is needed. ---
# Strip CR before running: a Windows checkout (autocrlf) yields CRLF, which makes
# bash read line 18 as `set -euxo pipefail\r` and abort with
# "set: pipefail: invalid option name". .gitattributes pins *.sh to LF, but this
# keeps the build working even on a not-yet-renormalised working tree.
RUN sed -i 's/\r$//' /app/docker/setup-melo.sh && bash /app/docker/setup-melo.sh
# --- Human input + window management for the on-screen Chrome control tool. # --- Human input + window management for the on-screen Chrome control tool.
# Placed AFTER the heavy melo layer so it doesn't bust that cache. xdotool # xdotool injects real X pointer/keyboard events (visible cursor,
# injects real X pointer/keyboard events (visible cursor, char-by-char # char-by-char typing) into the broadcast; wmctrl lists/moves windows. ---
# typing) into the broadcast; wmctrl lists/moves windows. ---
RUN apt-get update && apt-get install -y --no-install-recommends \ RUN apt-get update && apt-get install -y --no-install-recommends \
xdotool wmctrl \ xdotool wmctrl \
&& rm -rf /var/lib/apt/lists/* && rm -rf /var/lib/apt/lists/*

View File

@@ -69,7 +69,7 @@ docker compose -f docker-compose.yml -f docker-compose.gpu-linux.yml up -d --bui
docker compose -f docker-compose.yml -f docker-compose.gpu-windows.yml up -d --build docker compose -f docker-compose.yml -f docker-compose.gpu-windows.yml up -d --build
# ── GPU 없이 (CPU 전용 호스트) ── # ── GPU 없이 (CPU 전용 호스트) ──
# .env 에 WHISPER_DEVICE=cpu, MELO_DEVICE=cpu 를 넣고 베이스만 사용 # .env 에 WHISPER_DEVICE=cpu 를 넣고 베이스만 사용
docker compose up -d --build docker compose up -d --build
``` ```
@@ -113,7 +113,7 @@ docker compose up -d # 유저봇이 로그인해 지정 음성채널에
### GPU 가속 (OS별) ### GPU 가속 (OS별)
LLM(Ollama), Whisper STT, MeloTTS가 GPU에서 돕니다(env 기본 `WHISPER_DEVICE=cuda`, `MELO_DEVICE=cuda`). NVIDIA Blackwell(sm_120, 예: RTX 5050/5070Ti)에서 검증: 컨테이너 내 torch cu128 CUDA 동작, Ollama GPU 오프로드, faster-whisper float16, MeloTTS GPU 합성 모두 확인. LLM(Ollama) Whisper STT가 GPU에서 돕니다(env 기본 `WHISPER_DEVICE=cuda`). TTS는 기본값이 Edge TTS(온라인 한국어 음성)라 GPU를 쓰지 않습니다. NVIDIA Blackwell(sm_120, 예: RTX 5050/5070Ti)에서 검증: 컨테이너 내 torch cu128 CUDA 동작, Ollama GPU 오프로드, faster-whisper float16 모두 확인.
GPU는 위 "실행 — Docker"의 OS별 override 파일로 켜집니다. 호스트 사전 준비는 OS마다 다릅니다: GPU는 위 "실행 — Docker"의 OS별 override 파일로 켜집니다. 호스트 사전 준비는 OS마다 다릅니다:
@@ -137,7 +137,7 @@ docker run --rm --device nvidia.com/gpu=all ubuntu nvidia-smi -L # GPU 보이
**공통:** **공통:**
- 모델: 베이스 compose 기본은 `qwen2.5:3b`(8GB VRAM에서 도구호출 안정적). 더 무겁게(`qwen2.5:7b`, `qwen3:8b` 등) 쓰려면 `.env``OLLAMA_CHAT_MODEL` 변경. - 모델: 베이스 compose 기본은 `qwen2.5:3b`(8GB VRAM에서 도구호출 안정적). 더 무겁게(`qwen2.5:7b`, `qwen3:8b` 등) 쓰려면 `.env``OLLAMA_CHAT_MODEL` 변경.
- **GPU가 없거나 인식 실패 시 자동으로 CPU 폴백**(Whisper)하므로 안전합니다. 명시적으로 CPU만 쓰려면 override 파일 없이 베이스만 올리고 `.env``WHISPER_DEVICE=cpu`, `MELO_DEVICE=cpu`를 두세요. - **GPU가 없거나 인식 실패 시 자동으로 CPU 폴백**(Whisper)하므로 안전합니다. 명시적으로 CPU만 쓰려면 override 파일 없이 베이스만 올리고 `.env``WHISPER_DEVICE=cpu`를 두세요.
- 데이터(메모리 DB), Whisper 캐시, Piper 음성은 named volume에 영속됩니다. - 데이터(메모리 DB), Whisper 캐시, Piper 음성은 named volume에 영속됩니다.
- 셀프봇 영상 송출 의존성은 이미지에 기본 포함하지 않습니다. 쓰려면 컨테이너에서 `cd /app/bot && bun add discord.js-selfbot-v13 @dank074/discord-video-stream` 후 재시작(또는 Dockerfile에 추가). - 셀프봇 영상 송출 의존성은 이미지에 기본 포함하지 않습니다. 쓰려면 컨테이너에서 `cd /app/bot && bun add discord.js-selfbot-v13 @dank074/discord-video-stream` 후 재시작(또는 Dockerfile에 추가).
@@ -243,7 +243,7 @@ cd bot; bun run register; bun run start # 창 2: (일반 봇이면) 슬래시
- `BRIDGE_URL` — 봇이 호출할 브릿지 주소 (기본 `http://127.0.0.1:8765`) - `BRIDGE_URL` — 봇이 호출할 브릿지 주소 (기본 `http://127.0.0.1:8765`)
- `STREAM_BACKEND`, `DISCORD_SELFBOT_TOKEN`, `NOVNC_URL` — 화면 송출 - `STREAM_BACKEND`, `DISCORD_SELFBOT_TOKEN`, `NOVNC_URL` — 화면 송출
- `VNC_DISPLAY=:1`, `VNC_RESOLUTION`, `VNC_FRAMERATE`, `VNC_BITRATE_KBPS` — 캡처 - `VNC_DISPLAY=:1`, `VNC_RESOLUTION`, `VNC_FRAMERATE`, `VNC_BITRATE_KBPS` — 캡처
- `WHISPER_DEVICE/COMPUTE_TYPE`, `MELO_DEVICE` — GPU 호스트면 `cuda`/`float16`, CPU 전용이면 `cpu`(GPU 자체는 OS별 override compose 파일로 켬) - `WHISPER_DEVICE/COMPUTE_TYPE` — GPU 호스트면 `cuda`/`float16`, CPU 전용이면 `cpu`(GPU 자체는 OS별 override compose 파일로 켬)
- `OLLAMA_CHAT_MODEL` — 두뇌 LLM (기본 `qwen2.5:3b`) - `OLLAMA_CHAT_MODEL` — 두뇌 LLM (기본 `qwen2.5:3b`)
- `COMPOSE_FILE` — OS별 GPU override를 매번 `-f`로 안 치고 자동 적용 (위 "실행 — Docker" 참고) - `COMPOSE_FILE` — OS별 GPU override를 매번 `-f`로 안 치고 자동 적용 (위 "실행 — Docker" 참고)
- `output_language` — 출력 언어 고정(비우면 사용자 언어). 설정 웹 UI(`/settings`)에서 바꾸면 env 기본값보다 우선하며 컨테이너 재생성 후에도 유지됩니다. - `output_language` — 출력 언어 고정(비우면 사용자 언어). 설정 웹 UI(`/settings`)에서 바꾸면 env 기본값보다 우선하며 컨테이너 재생성 후에도 유지됩니다.

View File

@@ -21,7 +21,11 @@ nvidia-cudnn-cu12
# --- Bridge HTTP service --- # --- Bridge HTTP service ---
flask>=3.0.0 flask>=3.0.0
# --- Text-to-speech (Piper) --- # --- Text-to-speech ---
# Edge TTS: the primary Korean voice (online MS neural). Lightweight (httpx);
# emits MP3, transcoded to PCM16 by the system ffmpeg in the bridge.
edge-tts>=6.1.0
# Piper: offline English fallback.
piper-tts>=1.3.0 piper-tts>=1.3.0
# --- Built-in tools (lazily imported; needed for full functionality) --- # --- Built-in tools (lazily imported; needed for full functionality) ---

View File

@@ -87,12 +87,11 @@ VAD_MIN_SPEECH_MS = int(os.environ.get("VAD_MIN_SPEECH_MS", "200"))
# Korean phrase decoded as Chinese) and shaves a little latency. Empty = auto. # Korean phrase decoded as Chinese) and shaves a little latency. Empty = auto.
STT_LANGUAGE = os.environ.get("STT_LANGUAGE", "ko").strip() or None STT_LANGUAGE = os.environ.get("STT_LANGUAGE", "ko").strip() or None
# TTS engine: "melo" (MeloTTS Korean speaker, the warm worker) is the primary # TTS engine: "edge" (Microsoft Edge TTS, natural Korean neural voice) is the
# voice; Piper is kept as a fallback if the worker is unreachable. Set # primary voice. "melo" (a warm MeloTTS worker) and "piper" remain selectable.
# TTS_ENGINE=piper to disable MeloTTS entirely.
def _tts_engine_setting() -> str: def _tts_engine_setting() -> str:
"""TTS engine: settings-UI value (runtime config JSON) wins, else env, else """TTS engine: settings-UI value (runtime config JSON) wins, else env, else
melo. Read at startup; the settings UI restarts the bridge on apply.""" edge. Read at startup; the settings UI restarts the bridge on apply."""
try: try:
_cp = os.environ.get("JARVIS_CONFIG_PATH", "/app/config/jarvis.json") _cp = os.environ.get("JARVIS_CONFIG_PATH", "/app/config/jarvis.json")
_v = json.loads(open(_cp, encoding="utf-8").read()).get("tts_engine") _v = json.loads(open(_cp, encoding="utf-8").read()).get("tts_engine")
@@ -100,16 +99,22 @@ def _tts_engine_setting() -> str:
return str(_v).strip().lower() return str(_v).strip().lower()
except Exception: except Exception:
pass pass
return os.environ.get("TTS_ENGINE", "melo").strip().lower() return os.environ.get("TTS_ENGINE", "edge").strip().lower()
TTS_ENGINE = _tts_engine_setting() TTS_ENGINE = _tts_engine_setting()
# Edge TTS (online MS neural voice). Voice + rate are env-driven so they can be
# changed without code. Default: Korean "Hyunsu" multilingual voice at +45%
# (≈1.45×), the chosen settings. NOTE: edge synthesis sends the reply TEXT to
# Microsoft's servers and needs internet — an intentional privacy trade-off for
# the more natural voice.
EDGE_TTS_VOICE = os.environ.get("EDGE_TTS_VOICE", "ko-KR-HyunsuMultilingualNeural").strip()
EDGE_TTS_RATE = os.environ.get("EDGE_TTS_RATE", "+45%").strip()
MELO_WORKER_URL = os.environ.get("MELO_WORKER_URL", "http://127.0.0.1:8770") MELO_WORKER_URL = os.environ.get("MELO_WORKER_URL", "http://127.0.0.1:8770")
MELO_TIMEOUT = float(os.environ.get("MELO_TIMEOUT", "30")) MELO_TIMEOUT = float(os.environ.get("MELO_TIMEOUT", "30"))
# When MeloTTS is the engine, do NOT silently fall back to the English Piper # Do NOT silently fall back to the English Piper voice on a neural-voice failure:
# voice on failure: speaking Korean text through an English voice produces # speaking Korean through an English voice produces mangled audio. Default is
# mangled audio. Default is melo-only (return no audio on failure); set # neural-only (return no audio on failure); set MELO_FALLBACK_PIPER=1 to opt in.
# MELO_FALLBACK_PIPER=1 to opt into the Piper fallback.
MELO_FALLBACK_PIPER = os.environ.get("MELO_FALLBACK_PIPER", "0") in ("1", "true", "True", "yes", "on") MELO_FALLBACK_PIPER = os.environ.get("MELO_FALLBACK_PIPER", "0") in ("1", "true", "True", "yes", "on")
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -302,6 +307,54 @@ def _coerce_bool(value) -> Optional[bool]:
return str(value).strip().lower() in ("1", "true", "yes", "on") return str(value).strip().lower() in ("1", "true", "yes", "on")
def _edge_synthesize(text: str) -> Optional[bytes]:
"""Synthesise via Microsoft Edge TTS (online neural voice) and return a
16-bit PCM WAV, or None on any failure. Edge emits MP3; we transcode to
PCM16 mono with the system ffmpeg, writing to a temp file (seekable) so the
WAV header carries a correct length. Needs internet."""
import asyncio
import subprocess
import tempfile
try:
import edge_tts # type: ignore
async def _gen() -> bytes:
comm = edge_tts.Communicate(text, EDGE_TTS_VOICE, rate=EDGE_TTS_RATE)
buf = bytearray()
async for chunk in comm.stream():
if chunk.get("type") == "audio":
buf.extend(chunk["data"])
return bytes(buf)
mp3 = asyncio.run(_gen())
if not mp3:
print("[bridge] edge TTS returned no audio", flush=True)
return None
with tempfile.NamedTemporaryFile(suffix=".wav", delete=False) as t:
out_path = t.name
try:
proc = subprocess.run(
["ffmpeg", "-hide_banner", "-loglevel", "error", "-y",
"-i", "pipe:0", "-ac", "1", "-ar", "24000",
"-acodec", "pcm_s16le", out_path],
input=mp3, capture_output=True,
)
if proc.returncode != 0:
print(f"[bridge] edge ffmpeg transcode failed: {proc.stderr.decode('utf-8','ignore')[:200]}", flush=True)
return None
with open(out_path, "rb") as f:
return f.read()
finally:
try:
os.unlink(out_path)
except OSError:
pass
except Exception as e: # pragma: no cover - network / dep dependent
print(f"[bridge] edge synth failed: {e}", flush=True)
return None
def _melo_synthesize(text: str) -> Optional[bytes]: def _melo_synthesize(text: str) -> Optional[bytes]:
"""Synthesise via the warm MeloTTS worker (separate /opt/melo venv, Korean """Synthesise via the warm MeloTTS worker (separate /opt/melo venv, Korean
speaker @ speed 1.5). Returns a 16-bit PCM WAV, or None on any failure so speaker @ speed 1.5). Returns a 16-bit PCM WAV, or None on any failure so
@@ -361,20 +414,22 @@ def _tts_ready() -> bool:
def synthesize(text: str) -> Optional[bytes]: def synthesize(text: str) -> Optional[bytes]:
"""Synthesize text to a 16-bit PCM WAV. The primary voice is MeloTTS """Synthesize text to a 16-bit PCM WAV. The primary voice is Edge TTS (a
(Korean speaker, speed 1.5) served by the warm melo worker; Piper is a natural Korean neural voice); "melo" uses the warm MeloTTS worker. For a
fallback if the worker is unavailable. Returns None if TTS is off.""" neural engine, Piper (English) is only used if explicitly enabled, since
speaking Korean through an English voice mangles it. Returns None if off."""
if not TTS_ENABLED or not text.strip(): if not TTS_ENABLED or not text.strip():
return None return None
if TTS_ENGINE == "melo": _neural = {"edge": _edge_synthesize, "melo": _melo_synthesize}.get(TTS_ENGINE)
audio = _melo_synthesize(text) if _neural is not None:
audio = _neural(text)
if audio: if audio:
return audio return audio
if not MELO_FALLBACK_PIPER: if not MELO_FALLBACK_PIPER:
# Melo-only: better silent than mangled English for Korean text. # Neural-only: better silent than mangled English for Korean text.
print("[bridge] melo synth failed; no audio (Piper fallback disabled)", flush=True) print(f"[bridge] {TTS_ENGINE} synth failed; no audio (Piper fallback disabled)", flush=True)
return None return None
print("[bridge] melo synth failed; falling back to Piper", flush=True) print(f"[bridge] {TTS_ENGINE} synth failed; falling back to Piper", flush=True)
return _piper_synthesize(text) return _piper_synthesize(text)

View File

@@ -22,8 +22,7 @@ from typing import Any, Dict
FIELDS = [ FIELDS = [
("ollama_chat_model", "LLM 모델", "model"), ("ollama_chat_model", "LLM 모델", "model"),
("whisper_model", "STT(Whisper) 모델", "select:tiny,base,small,medium,large,large-v3"), ("whisper_model", "STT(Whisper) 모델", "select:tiny,base,small,medium,large,large-v3"),
("tts_engine", "TTS 엔진", "select:melo,piper"), ("tts_engine", "TTS 엔진", "select:edge,piper"),
("melo_speed", "TTS 속도 (MeloTTS)", "number:0.5:2.5:0.1"),
("output_language", "출력 언어 (비우면 사용자 언어)", "text"), ("output_language", "출력 언어 (비우면 사용자 언어)", "text"),
("llm_thinking_enabled", "LLM 사고(thinking) 모드", "bool"), ("llm_thinking_enabled", "LLM 사고(thinking) 모드", "bool"),
("agentic_max_turns", "에이전트 최대 반복", "number:1:12:1"), ("agentic_max_turns", "에이전트 최대 반복", "number:1:12:1"),
@@ -54,9 +53,7 @@ def _current() -> Dict[str, Any]:
cfg = _read_config() cfg = _read_config()
out: Dict[str, Any] = {} out: Dict[str, Any] = {}
for k in _KEYS: for k in _KEYS:
if k == "melo_speed": if k == "output_language":
out[k] = cfg.get("melo_speed", os.environ.get("MELO_SPEED", "1.5"))
elif k == "output_language":
out[k] = cfg.get("output_language", os.environ.get("OUTPUT_LANGUAGE", "")) out[k] = cfg.get("output_language", os.environ.get("OUTPUT_LANGUAGE", ""))
else: else:
out[k] = cfg.get(k, "") out[k] = cfg.get(k, "")
@@ -78,12 +75,7 @@ def _coerce(updates: Dict[str, Any]) -> Dict[str, Any]:
for k, v in updates.items(): for k, v in updates.items():
if k not in _KEYS: if k not in _KEYS:
continue continue
if k == "melo_speed": if k == "agentic_max_turns":
try:
v = float(v)
except (TypeError, ValueError):
continue
elif k == "agentic_max_turns":
try: try:
v = int(v) v = int(v)
except (TypeError, ValueError): except (TypeError, ValueError):
@@ -114,15 +106,15 @@ def _save(updates: Dict[str, Any]) -> None:
def _apply() -> str: def _apply() -> str:
# Restart melo + bridge AFTER this response is sent. Detached (new session) # Restart the bridge AFTER this response is sent. Detached (new session) so
# so the bridge being killed mid-restart doesn't drop the restart itself, # the bridge being killed mid-restart doesn't drop the restart itself, and
# and the HTTP client still receives this response. # the HTTP client still receives this response. (Edge TTS has no worker.)
try: try:
subprocess.Popen( subprocess.Popen(
["sh", "-c", "sleep 1; supervisorctl restart melo-worker bridge"], ["sh", "-c", "sleep 1; supervisorctl restart bridge"],
start_new_session=True, start_new_session=True,
) )
return "1초 후 브리지/TTS 워커가 재시작되어 반영됩니다." return "1초 후 브리지가 재시작되어 반영됩니다."
except Exception as e: # pragma: no cover except Exception as e: # pragma: no cover
return str(e) return str(e)

View File

@@ -66,13 +66,15 @@ services:
WHISPER_MODEL: ${WHISPER_MODEL:-medium} WHISPER_MODEL: ${WHISPER_MODEL:-medium}
WHISPER_DEVICE: ${WHISPER_DEVICE:-cuda} WHISPER_DEVICE: ${WHISPER_DEVICE:-cuda}
WHISPER_COMPUTE_TYPE: ${WHISPER_COMPUTE_TYPE:-float16} WHISPER_COMPUTE_TYPE: ${WHISPER_COMPUTE_TYPE:-float16}
# MeloTTS on the GPU (cu128 torch baked by docker/setup-melo.sh). CPU synth # TTS engine. Rendered into /app/config/jarvis.json via envsubst (the
# serialised under load and pushed TTS to 7-8s; GPU does ~0.3s/sentence. # bridge reads that JSON BEFORE the env, so it must carry the real engine,
MELO_DEVICE: ${MELO_DEVICE:-cuda} # not a hardcoded one — otherwise Korean text is read by the English Piper
# Speaking rate for MeloTTS. Set here (with a default) so supervisord's # voice). Default edge; .env can override (e.g. piper for offline).
# %(ENV_MELO_SPEED)s passthrough always resolves and an .env override TTS_ENGINE: ${TTS_ENGINE:-edge}
# actually reaches the melo-worker. Lower it (e.g. 1.1) for a calmer pace. # Edge TTS voice + rate (the chosen natural Korean voice). NOTE: edge is an
MELO_SPEED: ${MELO_SPEED:-1.5} # ONLINE engine — reply text is sent to Microsoft and needs internet.
EDGE_TTS_VOICE: ${EDGE_TTS_VOICE:-ko-KR-HyunsuMultilingualNeural}
EDGE_TTS_RATE: ${EDGE_TTS_RATE:-+45%}
# Optional single-language lock for replies (empty = user's own language). # Optional single-language lock for replies (empty = user's own language).
OUTPUT_LANGUAGE: ${OUTPUT_LANGUAGE:-ko} OUTPUT_LANGUAGE: ${OUTPUT_LANGUAGE:-ko}
# Drop the pre-loop planner LLM call to cut voice-reply latency on small # Drop the pre-loop planner LLM call to cut voice-reply latency on small

View File

@@ -6,7 +6,7 @@
"ollama_chat_model": "${OLLAMA_CHAT_MODEL}", "ollama_chat_model": "${OLLAMA_CHAT_MODEL}",
"intent_judge_model": "${OLLAMA_INTENT_MODEL}", "intent_judge_model": "${OLLAMA_INTENT_MODEL}",
"tts_enabled": true, "tts_enabled": true,
"tts_engine": "piper", "tts_engine": "${TTS_ENGINE}",
"tts_piper_model_path": "${TTS_PIPER_MODEL_PATH}", "tts_piper_model_path": "${TTS_PIPER_MODEL_PATH}",
"whisper_model": "${WHISPER_MODEL}", "whisper_model": "${WHISPER_MODEL}",
"whisper_backend": "faster-whisper", "whisper_backend": "faster-whisper",

View File

@@ -49,31 +49,8 @@ stdout_logfile_maxbytes=0
stderr_logfile=/dev/stderr stderr_logfile=/dev/stderr
stderr_logfile_maxbytes=0 stderr_logfile_maxbytes=0
[program:melo-worker] # (No TTS worker program: the default Edge TTS engine synthesises in-process in
; Warm MeloTTS Korean voice (speed 1.5) in its own py3.11 venv. The bridge's # the bridge via the `edge-tts` package — no warm model/worker is needed.)
; synthesize() POSTs here; if this is down the bridge falls back to Piper.
command=/app/docker/run-if-role.sh full,bot /opt/melo/bin/python /app/bridge/melo_worker.py
directory=/app
; HF_HOME points at the dedicated, image-baked melo cache (warmed in
; setup-melo.sh). The brain's whisper_cache volume is mounted over
; /root/.cache/huggingface, so without this the pre-cached BERT + KR checkpoint
; would be shadowed and re-downloaded (and would fail if the host is offline).
; HF_HUB_OFFLINE/TRANSFORMERS_OFFLINE force pure-cache reads: the pinned old
; transformers/huggingface_hub otherwise retry the network on every load and
; error out instead of falling back to the (complete) baked cache.
; MELO_DEVICE and MELO_SPEED inherit from the container env (compose sets both
; with defaults: cuda / 1.5) so the worker runs MeloTTS on the GPU at the
; configured rate. supervisord interpolates %(ENV_x)s from its own environment,
; which is the container's — so MELO_SPEED must always be set in the env
; (compose guarantees it) or this expansion fails at startup. Hardcoding 1.5
; here previously shadowed the .env value, so lowering MELO_SPEED had no effect.
environment=MELO_LANGUAGE="KR",MELO_SPEED="%(ENV_MELO_SPEED)s",MELO_DEVICE="%(ENV_MELO_DEVICE)s",MELO_WORKER_HOST="127.0.0.1",MELO_WORKER_PORT="8770",HF_HOME="/opt/melo-cache",HF_HUB_OFFLINE="1",TRANSFORMERS_OFFLINE="1"
priority=280
autorestart=true
stdout_logfile=/dev/stdout
stdout_logfile_maxbytes=0
stderr_logfile=/dev/stderr
stderr_logfile_maxbytes=0
[program:bridge] [program:bridge]
command=/app/docker/run-if-role.sh full,bot /opt/venv/bin/python -m bridge.server command=/app/docker/run-if-role.sh full,bot /opt/venv/bin/python -m bridge.server

View File

@@ -0,0 +1,35 @@
"""The container's TTS engine must be env-driven, not hardcoded.
Regression for a bug where docker/jarvis-config.template.json hardcoded
`"tts_engine": "piper"`. The bridge reads the rendered /app/config/jarvis.json
*before* the environment, so a hardcoded "piper" overrode `TTS_ENGINE=melo` in
.env and the bot read Korean text with the English Piper voice ("foreign
accent"). The template must carry `${TTS_ENGINE}` so envsubst (entrypoint.sh)
renders whatever engine the deployment configured.
"""
import json
import string
from pathlib import Path
TEMPLATE = Path(__file__).resolve().parent.parent / "docker" / "jarvis-config.template.json"
def _render(**env) -> dict:
"""Mimic entrypoint.sh `envsubst < template`: substitute env vars, leaving
any unset ones as literal text (valid JSON string values)."""
raw = TEMPLATE.read_text(encoding="utf-8")
return json.loads(string.Template(raw).safe_substitute(**env))
def test_template_does_not_hardcode_an_engine():
raw = TEMPLATE.read_text(encoding="utf-8")
assert '"tts_engine": "${TTS_ENGINE}"' in raw
assert '"tts_engine": "piper"' not in raw
assert '"tts_engine": "melo"' not in raw
def test_rendered_engine_follows_env():
assert _render(TTS_ENGINE="melo")["tts_engine"] == "melo"
assert _render(TTS_ENGINE="piper")["tts_engine"] == "piper"
assert _render(TTS_ENGINE="xtts")["tts_engine"] == "xtts"