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
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:
@@ -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)
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user