diff --git a/api/routes.py b/api/routes.py index 279b3ca0..8fce6563 100644 --- a/api/routes.py +++ b/api/routes.py @@ -14765,7 +14765,7 @@ def _normalize_tts_prosody(value, *, unit: str) -> str | None: def _handle_tts(handler, parsed): - """Generate TTS audio via Edge TTS. POST JSON body only. + """Generate TTS audio via supported server TTS engines. POST JSON body only. Design note addressing deep review blocker #4 (synchronous I/O): The server uses ThreadingHTTPServer (see server.py:173), so each request @@ -14785,7 +14785,7 @@ def _handle_tts(handler, parsed): voice = "zh-CN-XiaoxiaoNeural" rate_str = "" pitch_str = "" - engine = "edge" # "edge" | "elevenlabs" | "browser" (browser is client-side only) + engine = "edge" # "edge" | "elevenlabs" | "openai" | "browser" (browser is client-side only) if handler.command != "POST": from api.helpers import bad as _bad @@ -14953,6 +14953,77 @@ def _handle_tts(handler, parsed): pass return True + # ── OpenAI-compatible TTS ────────────────────────────────────────── + if engine == "openai": + api_key = os.getenv("VOICE_TOOLS_OPENAI_KEY", "").strip() + if not api_key: + api_key = os.getenv("OPENAI_API_KEY", "").strip() + if not api_key: + try: + from api.onboarding import _load_env_file + from api.profiles import get_active_hermes_home + env_cfg = _load_env_file(get_active_hermes_home() / ".env") + api_key = env_cfg.get("VOICE_TOOLS_OPENAI_KEY", "") or env_cfg.get("OPENAI_API_KEY", "") + except Exception: + pass + if not api_key: + from api.helpers import bad as _bad + return _bad(handler, "OpenAI API key not configured", 503) + + base_url = "https://api.openai.com/v1" + model = "gpt-4o-mini-tts" + oai_voice = "alloy" + try: + from api.config import get_config + tts_cfg = (get_config() or {}).get("tts", {}) + if isinstance(tts_cfg, dict): + oai_cfg = tts_cfg.get("openai", {}) + if isinstance(oai_cfg, dict): + base_url = (oai_cfg.get("base_url") or base_url).rstrip("/") + model = oai_cfg.get("model") or model + oai_voice = oai_cfg.get("voice") or oai_voice + except Exception: + pass + + url = f"{base_url}/audio/speech" + req_body = json.dumps({ + "model": model, + "input": text, + "voice": oai_voice, + }).encode("utf-8") + + from urllib.request import Request, urlopen as _urlopen + + req = Request(url, data=req_body, headers={ + "Authorization": f"Bearer {api_key}", + "Content-Type": "application/json", + "Accept": "audio/mpeg", + }) + + audio_data = b"" + try: + with _urlopen(req, timeout=30) as resp: + while True: + chunk = resp.read(65536) + if not chunk: + break + audio_data += chunk + except Exception: + logger.exception("OpenAI TTS generation failed") + from api.helpers import bad as _bad + return _bad(handler, "OpenAI TTS generation failed", 500) + + handler.send_response(200) + handler.send_header("Content-Type", "audio/mpeg") + handler.send_header("Cache-Control", "no-store") + handler.send_header("Content-Length", str(len(audio_data))) + handler.end_headers() + try: + handler.wfile.write(audio_data) + except (BrokenPipeError, ConnectionResetError): + pass + return True + # ── Edge TTS ──────────────────────────────────────────────────────── allowed = { "zh-CN-XiaoxiaoNeural", "zh-CN-XiaoyiNeural", "zh-CN-YunxiNeural", diff --git a/static/boot.js b/static/boot.js index 850d4e8b..a91bdac3 100644 --- a/static/boot.js +++ b/static/boot.js @@ -1290,6 +1290,46 @@ window._hermesTtsSynth=function(id, text, opts){ }); return; } + if(engine==="openai"){ + _ttsSpeaking=true; + fetch(new URL('api/tts', document.baseURI || location.href).href, { + method: 'POST', + headers: {'Content-Type': 'application/json'}, + body: JSON.stringify({text: clean, engine: 'openai'}) + }) + .then(r => { + if(!r.ok) throw new Error('TTS request failed: ' + r.status); + return r.blob(); + }) + .then(blob => { + const url = URL.createObjectURL(blob); + const audio = new Audio(url); + _playingEdgeAudio=audio; + audio.onended = () => { + _ttsSpeaking=false; + if(_playingEdgeAudio===audio) _playingEdgeAudio=null; + URL.revokeObjectURL(url); + if(_voiceModeActive) setTimeout(()=>_startListening(),500); + }; + audio.onerror = () => { + _ttsSpeaking=false; + if(_playingEdgeAudio===audio) _playingEdgeAudio=null; + URL.revokeObjectURL(url); + if(_voiceModeActive) setTimeout(()=>_startListening(),1000); + }; + audio.play().catch(() => { + _ttsSpeaking=false; + if(_playingEdgeAudio===audio) _playingEdgeAudio=null; + URL.revokeObjectURL(url); + if(_voiceModeActive) setTimeout(()=>_startListening(),1000); + }); + }) + .catch(() => { + _ttsSpeaking=false; + if(_voiceModeActive) setTimeout(()=>_startListening(),1000); + }); + return; + } if(engine==="edge"){ const voice=localStorage.getItem("hermes-tts-voice")||"zh-CN-XiaoxiaoNeural"; const savedRate=parseFloat(localStorage.getItem("hermes-tts-rate")); diff --git a/static/index.html b/static/index.html index 8287fc6a..34590dfb 100644 --- a/static/index.html +++ b/static/index.html @@ -1209,7 +1209,7 @@
Record and send the original audio file to the agent instead of converting it to text first. The agent can then transcribe it or process the raw audio (emotion, background noise, custom STT). Like Telegram's voice message behavior.
-
Choose speech engine. Edge TTS uses Microsoft neural voices via the server.
+
Choose speech engine. Edge TTS uses Microsoft neural voices via the server.
diff --git a/static/panels.js b/static/panels.js index 397ce14f..5b7cb883 100644 --- a/static/panels.js +++ b/static/panels.js @@ -7977,6 +7977,8 @@ async function loadSettingsPanel(){ const current=localStorage.getItem('hermes-tts-voice')||''; if(engine==='elevenlabs'){ ttsVoiceSel.innerHTML=''; + } else if(engine==='openai'){ + ttsVoiceSel.innerHTML=''; } else if(engine==='edge'){ const edgeVoices=[ {value:'zh-CN-XiaoxiaoNeural',label:'Xiaoxiao (Chinese, Female)'}, diff --git a/static/ui.js b/static/ui.js index 2d8cc95f..2cd33bc8 100644 --- a/static/ui.js +++ b/static/ui.js @@ -6719,6 +6719,10 @@ function speakMessage(btn){ if(!clean) return; const engine=localStorage.getItem('hermes-tts-engine')||'browser'; + if(engine==='openai'){ + _playOpenaiTts(clean, btn); + return; + } if(engine==='elevenlabs'){ _playElevenLabsTts(clean, btn); return; @@ -6791,6 +6795,33 @@ function _playElevenLabsTts(text, btn){ .catch(function(e){ _fail((e&&e.message)||'ElevenLabs TTS failed'); }); } +function _playOpenaiTts(text, btn){ + if(btn) btn.dataset.speaking='1'; + _ttsSpeaking=true; + const _fail=function(msg){ + _ttsSpeaking=false;_playingEdgeAudio=null; + if(btn)btn.dataset.speaking='0'; + if(msg&&typeof showToast==='function') showToast(msg,4000,'error'); + }; + fetch(new URL('api/tts', document.baseURI || location.href).href, { + method:'POST', + headers:{'Content-Type':'application/json'}, + body:JSON.stringify({text:text, engine:'openai'}) + }) + .then(function(r){ + if(!r.ok){ + return r.json().catch(function(){return {};}).then(function(j){ + throw new Error((j&&j.error)||('TTS request failed: '+r.status)); + }); + } + return r.arrayBuffer(); + }) + .then(function(buf){ + return _playAudioBuf(buf, btn, 'OpenAI TTS'); + }) + .catch(function(e){ _fail((e&&e.message)||'OpenAI TTS failed'); }); +} + // ── Shared AudioContext for TTS playback (no blob URLs needed) ── let _ttsAudioCtx=null; function _getTtsAudioCtx(){ @@ -6870,6 +6901,10 @@ function autoReadLastAssistant(){ if(!text.trim()) return; const clean=_stripForTTS(text); if(!clean) return; + if(engine==='openai'){ + _playOpenaiTts(clean, null); + return; + } if(engine==='elevenlabs'){ _playElevenLabsTts(clean, null); return; diff --git a/tests/test_499_tts_playback.py b/tests/test_499_tts_playback.py index efbc1933..bef8390e 100644 --- a/tests/test_499_tts_playback.py +++ b/tests/test_499_tts_playback.py @@ -57,6 +57,18 @@ class TestTtsUtilityFunctions: assert 'speechSynthesis.speak' in src, \ "speakMessage must call speechSynthesis.speak" + def test_speak_message_routes_openai(self): + src = _read('ui.js') + assert "if(engine==='openai')" in src, \ + "speakMessage must branch for the OpenAI TTS engine" + assert '_playOpenaiTts(clean, btn);' in src, \ + "speakMessage must route OpenAI TTS through _playOpenaiTts" + + def test_auto_read_routes_openai(self): + src = _read('ui.js') + assert '_playOpenaiTts(clean, null);' in src, \ + "autoReadLastAssistant must route OpenAI TTS through _playOpenaiTts" + class TestTtsSpeakerButton: """Speaker button is rendered on assistant messages.""" @@ -109,6 +121,11 @@ class TestTtsSettings: assert 'settingsTtsVoice' in src, \ "TTS voice selector not found in index.html" + def test_tts_engine_includes_openai_option(self): + src = _read('index.html') + assert '' in src, \ + "settingsTtsEngine must expose the OpenAI server TTS option" + def test_tts_rate_slider(self): src = _read('index.html') assert 'settingsTtsRate' in src, \ @@ -133,6 +150,13 @@ class TestTtsSettings: assert 'function _applyTtsEnabled(' in src, \ "_applyTtsEnabled function not found in panels.js" + def test_openai_voice_placeholder_in_panels(self): + src = _read('panels.js') + assert "engine==='openai'" in src, \ + "panels.js must branch for the OpenAI TTS engine" + assert 'OpenAI voice (server-configured)' in src, \ + "OpenAI TTS must present a server-configured voice placeholder" + class TestTtsI18n: """TTS i18n keys exist in the English locale.""" diff --git a/tests/test_issue4982_openai_tts.py b/tests/test_issue4982_openai_tts.py new file mode 100644 index 00000000..25aecb2f --- /dev/null +++ b/tests/test_issue4982_openai_tts.py @@ -0,0 +1,173 @@ +"""OpenAI-compatible TTS endpoint and UI wiring coverage for #4982.""" +import io +import json +from pathlib import Path + +import pytest + +import api.routes as routes + + +STATIC_DIR = Path(__file__).resolve().parent.parent / "static" + + +class _FakeHandler: + def __init__(self, body: bytes, command: str = "POST", headers=None, client="1.2.3.4"): + self.command = command + self.rfile = io.BytesIO(body) + self.wfile = io.BytesIO() + self.headers = headers or {} + self.headers.setdefault("Content-Length", str(len(body))) + self.client_address = (client, 12345) + self.status = None + self.sent_headers = {} + + def send_response(self, status): + self.status = status + + def send_header(self, key, value): + self.sent_headers[key] = value + + def end_headers(self): + pass + + def payload(self): + try: + return json.loads(self.wfile.getvalue().decode("utf-8")) + except Exception: + return None + + +class _StreamOnceResponse: + def __init__(self, chunks): + self._chunks = list(chunks) + + def __enter__(self): + return self + + def __exit__(self, exc_type, exc, tb): + return False + + def read(self, _size=-1): + if self._chunks: + return self._chunks.pop(0) + return b"" + + +def _post(body_dict, **kw): + body = json.dumps(body_dict).encode() + return _FakeHandler(body, **kw) + + +def _reset_limiter(): + if hasattr(routes._handle_tts, "_tts_limiter"): + del routes._handle_tts._tts_limiter + + +@pytest.fixture(autouse=True) +def _fresh_tts_limiter(monkeypatch): + import api.auth as _auth + + monkeypatch.setattr(_auth, "is_auth_enabled", lambda: False) + monkeypatch.setattr(routes, "is_auth_enabled", lambda: False, raising=False) + monkeypatch.delenv("HERMES_WEBUI_TRUST_FORWARDED_FOR", raising=False) + monkeypatch.delenv("VOICE_TOOLS_OPENAI_KEY", raising=False) + monkeypatch.delenv("OPENAI_API_KEY", raising=False) + _reset_limiter() + yield + _reset_limiter() + + +def test_openai_tts_no_key_returns_503(monkeypatch): + import api.onboarding as onboarding + + monkeypatch.setattr(onboarding, "_load_env_file", lambda *_args, **_kwargs: {}) + h = _post({"text": "Hello", "engine": "openai"}, client="10.82.0.1") + routes._handle_tts(h, None) + assert h.status == 503 + assert "OpenAI API key not configured" in (h.payload() or {}).get("error", "") + + +def test_openai_tts_success_returns_audio(monkeypatch): + captured = {} + + def _fake_urlopen(req, timeout=0): + captured["auth"] = req.headers.get("Authorization") + captured["url"] = req.full_url + captured["timeout"] = timeout + captured["body"] = json.loads(req.data.decode("utf-8")) + return _StreamOnceResponse([b"audio-openai"]) + + monkeypatch.setenv("OPENAI_API_KEY", "sk-openai") + monkeypatch.setattr("urllib.request.urlopen", _fake_urlopen) + h = _post({"text": "Hello", "engine": "openai"}, client="10.82.0.2") + routes._handle_tts(h, None) + + assert h.status == 200 + assert h.sent_headers["Content-Type"] == "audio/mpeg" + assert h.wfile.getvalue() == b"audio-openai" + assert captured["auth"] == "Bearer sk-openai" + assert captured["url"] == "https://api.openai.com/v1/audio/speech" + assert captured["body"] == {"model": "gpt-4o-mini-tts", "input": "Hello", "voice": "alloy"} + + +def test_openai_tts_prefers_voice_tools_key_over_openai_key(monkeypatch): + captured = {} + + def _fake_urlopen(req, timeout=0): + captured["auth"] = req.headers.get("Authorization") + return _StreamOnceResponse([b"preferred"]) + + monkeypatch.setenv("VOICE_TOOLS_OPENAI_KEY", "sk-voice-tools") + monkeypatch.setenv("OPENAI_API_KEY", "sk-openai") + monkeypatch.setattr("urllib.request.urlopen", _fake_urlopen) + h = _post({"text": "Hello", "engine": "openai"}, client="10.82.0.3") + routes._handle_tts(h, None) + + assert h.status == 200 + assert captured["auth"] == "Bearer sk-voice-tools" + + +def test_openai_tts_config_overrides(monkeypatch): + captured = {} + import api.config as config + + def _fake_urlopen(req, timeout=0): + captured["url"] = req.full_url + captured["body"] = json.loads(req.data.decode("utf-8")) + return _StreamOnceResponse([b"custom"]) + + monkeypatch.setenv("OPENAI_API_KEY", "sk-openai") + monkeypatch.setattr(config, "get_config", lambda: { + "tts": {"openai": {"base_url": "https://custom.example.com/v1/", "model": "tts-custom", "voice": "nova"}} + }) + monkeypatch.setattr("urllib.request.urlopen", _fake_urlopen) + h = _post({"text": "Hello", "engine": "openai"}, client="10.82.0.4") + routes._handle_tts(h, None) + + assert h.status == 200 + assert captured["url"] == "https://custom.example.com/v1/audio/speech" + assert captured["body"] == {"model": "tts-custom", "input": "Hello", "voice": "nova"} + + +def test_openai_option_in_html(): + src = (STATIC_DIR / "index.html").read_text(encoding="utf-8") + assert '' in src + + +def test_openai_voice_placeholder_in_panels(): + src = (STATIC_DIR / "panels.js").read_text(encoding="utf-8") + assert "engine==='openai'" in src + assert 'OpenAI voice (server-configured)' in src + + +def test_play_openai_tts_exists_in_ui_js(): + src = (STATIC_DIR / "ui.js").read_text(encoding="utf-8") + assert 'function _playOpenaiTts(text, btn)' in src + assert "body:JSON.stringify({text:text, engine:'openai'})" in src + + +def test_boot_js_handles_openai_engine(): + src = (STATIC_DIR / "boot.js").read_text(encoding="utf-8") + assert 'if(engine==="openai")' in src + assert "body: JSON.stringify({text: clean, engine: 'openai'})" in src