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>
This commit is contained in:
javis-bot
2026-06-23 03:44:15 +09:00
parent 11c3621093
commit f64d76e737
8 changed files with 115 additions and 96 deletions

View File

@@ -21,7 +21,11 @@ nvidia-cudnn-cu12
# --- Bridge HTTP service ---
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
# --- 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.
STT_LANGUAGE = os.environ.get("STT_LANGUAGE", "ko").strip() or None
# TTS engine: "melo" (MeloTTS Korean speaker, the warm worker) is the primary
# voice; Piper is kept as a fallback if the worker is unreachable. Set
# TTS_ENGINE=piper to disable MeloTTS entirely.
# TTS engine: "edge" (Microsoft Edge TTS, natural Korean neural voice) is the
# primary voice. "melo" (a warm MeloTTS worker) and "piper" remain selectable.
def _tts_engine_setting() -> str:
"""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:
_cp = os.environ.get("JARVIS_CONFIG_PATH", "/app/config/jarvis.json")
_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()
except Exception:
pass
return os.environ.get("TTS_ENGINE", "melo").strip().lower()
return os.environ.get("TTS_ENGINE", "edge").strip().lower()
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_TIMEOUT = float(os.environ.get("MELO_TIMEOUT", "30"))
# When MeloTTS is the engine, do NOT silently fall back to the English Piper
# voice on failure: speaking Korean text through an English voice produces
# mangled audio. Default is melo-only (return no audio on failure); set
# MELO_FALLBACK_PIPER=1 to opt into the Piper fallback.
# Do NOT silently fall back to the English Piper voice on a neural-voice failure:
# speaking Korean through an English voice produces mangled audio. Default is
# neural-only (return no audio on failure); set MELO_FALLBACK_PIPER=1 to opt in.
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")
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]:
"""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
@@ -361,20 +414,22 @@ def _tts_ready() -> bool:
def synthesize(text: str) -> Optional[bytes]:
"""Synthesize text to a 16-bit PCM WAV. The primary voice is MeloTTS
(Korean speaker, speed 1.5) served by the warm melo worker; Piper is a
fallback if the worker is unavailable. Returns None if TTS is off."""
"""Synthesize text to a 16-bit PCM WAV. The primary voice is Edge TTS (a
natural Korean neural voice); "melo" uses the warm MeloTTS worker. For a
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():
return None
if TTS_ENGINE == "melo":
audio = _melo_synthesize(text)
_neural = {"edge": _edge_synthesize, "melo": _melo_synthesize}.get(TTS_ENGINE)
if _neural is not None:
audio = _neural(text)
if audio:
return audio
if not MELO_FALLBACK_PIPER:
# Melo-only: better silent than mangled English for Korean text.
print("[bridge] melo synth failed; no audio (Piper fallback disabled)", flush=True)
# Neural-only: better silent than mangled English for Korean text.
print(f"[bridge] {TTS_ENGINE} synth failed; no audio (Piper fallback disabled)", flush=True)
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)

View File

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