feat(#4982): support OpenAI-compatible TTS backend in WebUI (#4982)

This commit is contained in:
Rod Boev committed 2026-06-28 09:50:51 -04:00
1 parent e24e6c54a6
commit 21e229cb0f
7 files changed
+348 -3

No files matched your search

+73 -2
View File
@@ -14765,7 +14765,7 @@ def _normalize_tts_prosody(value, *, unit: str) -> str | None:
def _handle_tts(handler, parsed): 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): Design note addressing deep review blocker #4 (synchronous I/O):
The server uses ThreadingHTTPServer (see server.py:173), so each request The server uses ThreadingHTTPServer (see server.py:173), so each request
@@ -14785,7 +14785,7 @@ def _handle_tts(handler, parsed):
voice = "zh-CN-XiaoxiaoNeural" voice = "zh-CN-XiaoxiaoNeural"
rate_str = "" rate_str = ""
pitch_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": if handler.command != "POST":
from api.helpers import bad as _bad from api.helpers import bad as _bad
@@ -14953,6 +14953,77 @@ def _handle_tts(handler, parsed):
pass pass
return True 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 ──────────────────────────────────────────────────────── # ── Edge TTS ────────────────────────────────────────────────────────
allowed = { allowed = {
"zh-CN-XiaoxiaoNeural", "zh-CN-XiaoyiNeural", "zh-CN-YunxiNeural", "zh-CN-XiaoxiaoNeural", "zh-CN-XiaoyiNeural", "zh-CN-YunxiNeural",
+40
View File
@@ -1290,6 +1290,46 @@ window._hermesTtsSynth=function(id, text, opts){
}); });
return; 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"){ if(engine==="edge"){
const voice=localStorage.getItem("hermes-tts-voice")||"zh-CN-XiaoxiaoNeural"; const voice=localStorage.getItem("hermes-tts-voice")||"zh-CN-XiaoxiaoNeural";
const savedRate=parseFloat(localStorage.getItem("hermes-tts-rate")); const savedRate=parseFloat(localStorage.getItem("hermes-tts-rate"));
+1 -1
View File
@@ -1209,7 +1209,7 @@
<div style="font-size:11px;color:var(--muted);margin-top:4px" data-i18n="settings_desc_raw_audio">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.</div> <div style="font-size:11px;color:var(--muted);margin-top:4px" data-i18n="settings_desc_raw_audio">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.</div>
</div> </div>
<div class="settings-field"> <div class="settings-field">
<label for="settingsTtsEngine" data-i18n="settings_label_tts_engine">TTS Engine</label><select id="settingsTtsEngine" style="width:100%;padding:8px;background:var(--code-bg);color:var(--text);border:1px solid var(--border2);border-radius:6px"><option value="browser">Browser speech synthesis</option><option value="edge">Edge TTS (server)</option><option value="elevenlabs">ElevenLabs (server)</option></select><div style="font-size:11px;color:var(--muted);margin-top:4px" data-i18n="settings_desc_tts_engine">Choose speech engine. Edge TTS uses Microsoft neural voices via the server.</div></div><div class="settings-field"><label for="settingsTtsVoice" data-i18n="settings_label_tts_voice">Voice</label> <label for="settingsTtsEngine" data-i18n="settings_label_tts_engine">TTS Engine</label><select id="settingsTtsEngine" style="width:100%;padding:8px;background:var(--code-bg);color:var(--text);border:1px solid var(--border2);border-radius:6px"><option value="browser">Browser speech synthesis</option><option value="edge">Edge TTS (server)</option><option value="elevenlabs">ElevenLabs (server)</option><option value="openai">OpenAI TTS (server)</option></select><div style="font-size:11px;color:var(--muted);margin-top:4px" data-i18n="settings_desc_tts_engine">Choose speech engine. Edge TTS uses Microsoft neural voices via the server.</div></div><div class="settings-field"><label for="settingsTtsVoice" data-i18n="settings_label_tts_voice">Voice</label>
<select id="settingsTtsVoice" style="width:100%;padding:8px;background:var(--code-bg);color:var(--text);border:1px solid var(--border2);border-radius:6px"> <select id="settingsTtsVoice" style="width:100%;padding:8px;background:var(--code-bg);color:var(--text);border:1px solid var(--border2);border-radius:6px">
<option value="">Default system voice</option> <option value="">Default system voice</option>
</select> </select>
+2
View File
@@ -7977,6 +7977,8 @@ async function loadSettingsPanel(){
const current=localStorage.getItem('hermes-tts-voice')||''; const current=localStorage.getItem('hermes-tts-voice')||'';
if(engine==='elevenlabs'){ if(engine==='elevenlabs'){
ttsVoiceSel.innerHTML='<option value="">Hermy — ElevenLabs (server-configured)</option>'; ttsVoiceSel.innerHTML='<option value="">Hermy — ElevenLabs (server-configured)</option>';
} else if(engine==='openai'){
ttsVoiceSel.innerHTML='<option value="">OpenAI voice (server-configured)</option>';
} else if(engine==='edge'){ } else if(engine==='edge'){
const edgeVoices=[ const edgeVoices=[
{value:'zh-CN-XiaoxiaoNeural',label:'Xiaoxiao (Chinese, Female)'}, {value:'zh-CN-XiaoxiaoNeural',label:'Xiaoxiao (Chinese, Female)'},
+35
View File
@@ -6719,6 +6719,10 @@ function speakMessage(btn){
if(!clean) return; if(!clean) return;
const engine=localStorage.getItem('hermes-tts-engine')||'browser'; const engine=localStorage.getItem('hermes-tts-engine')||'browser';
if(engine==='openai'){
_playOpenaiTts(clean, btn);
return;
}
if(engine==='elevenlabs'){ if(engine==='elevenlabs'){
_playElevenLabsTts(clean, btn); _playElevenLabsTts(clean, btn);
return; return;
@@ -6791,6 +6795,33 @@ function _playElevenLabsTts(text, btn){
.catch(function(e){ _fail((e&&e.message)||'ElevenLabs TTS failed'); }); .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) ── // ── Shared AudioContext for TTS playback (no blob URLs needed) ──
let _ttsAudioCtx=null; let _ttsAudioCtx=null;
function _getTtsAudioCtx(){ function _getTtsAudioCtx(){
@@ -6870,6 +6901,10 @@ function autoReadLastAssistant(){
if(!text.trim()) return; if(!text.trim()) return;
const clean=_stripForTTS(text); const clean=_stripForTTS(text);
if(!clean) return; if(!clean) return;
if(engine==='openai'){
_playOpenaiTts(clean, null);
return;
}
if(engine==='elevenlabs'){ if(engine==='elevenlabs'){
_playElevenLabsTts(clean, null); _playElevenLabsTts(clean, null);
return; return;
+24
View File
@@ -57,6 +57,18 @@ class TestTtsUtilityFunctions:
assert 'speechSynthesis.speak' in src, \ assert 'speechSynthesis.speak' in src, \
"speakMessage must call speechSynthesis.speak" "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: class TestTtsSpeakerButton:
"""Speaker button is rendered on assistant messages.""" """Speaker button is rendered on assistant messages."""
@@ -109,6 +121,11 @@ class TestTtsSettings:
assert 'settingsTtsVoice' in src, \ assert 'settingsTtsVoice' in src, \
"TTS voice selector not found in index.html" "TTS voice selector not found in index.html"
def test_tts_engine_includes_openai_option(self):
src = _read('index.html')
assert '<option value="openai">OpenAI TTS (server)</option>' in src, \
"settingsTtsEngine must expose the OpenAI server TTS option"
def test_tts_rate_slider(self): def test_tts_rate_slider(self):
src = _read('index.html') src = _read('index.html')
assert 'settingsTtsRate' in src, \ assert 'settingsTtsRate' in src, \
@@ -133,6 +150,13 @@ class TestTtsSettings:
assert 'function _applyTtsEnabled(' in src, \ assert 'function _applyTtsEnabled(' in src, \
"_applyTtsEnabled function not found in panels.js" "_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: class TestTtsI18n:
"""TTS i18n keys exist in the English locale.""" """TTS i18n keys exist in the English locale."""
+173
View File
@@ -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 '<option value="openai">OpenAI TTS (server)</option>' 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