fix(#2931): Edge TTS playback path (GET->POST), stoppable hands-free audio, test isolation

Both advisors (Opus MUST-FIX + Codex SHIP-ONLY-WITH-FIXES) caught that the Edge
TTS playback was broken despite the settings selector working:

1. ui.js _playEdgeTts used new Audio('/api/tts?text=...') — a GET — but /api/tts
   is POST-only (405) and registered only in handle_post. The per-message speaker
   button + auto-read silently failed in edge mode, and the GET leaked message
   text into the query string/access log. Rewritten to POST JSON + blob object
   URL (mirrors the working boot.js path), and now surfaces server errors
   (503 not-installed, 429 rate-limit) via toast instead of silent dead air.
2. boot.js hands-free Edge audio was a local var never assigned to the shared
   _playingEdgeAudio handle, so stopTTS() (from _deactivate) couldn't stop it.
   Now registered + cleared on end/error.
3. Test isolation: the _tts_limiter function-attribute singleton persisted across
   the whole suite, flaking 2 of my endpoint tests in the full run. Converted the
   reset to an autouse fixture (before+after each) + unique per-test client IPs.
This commit is contained in:
nesquena-hermes committed 2026-06-02 01:58:13 +00:00
1 parent 6801272f51
commit 08fe4f51f0
3 files changed
+60 -30

No files matched your search

+8
View File
@@ -913,18 +913,26 @@ window._micPendingSend=window._micPendingSend||false;
.then(blob => {
const url = URL.createObjectURL(blob);
const audio = new Audio(url);
// Register with the shared handle (declared in ui.js, same global scope;
// both scripts are fully evaluated before any voice interaction) so
// stopTTS() — called from _deactivate() — can actually pause hands-free
// Edge playback. Without this the audio is local here and unstoppable.
_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(e => {
_ttsSpeaking=false;
if(_playingEdgeAudio===audio) _playingEdgeAudio=null;
if(_voiceModeActive) setTimeout(()=>_startListening(),1000);
});
})
+34 -19
View File
@@ -4421,29 +4421,44 @@ function _playEdgeTts(text, btn){
const voice=localStorage.getItem('hermes-tts-voice')||'zh-CN-XiaoxiaoNeural';
const savedRate=parseFloat(localStorage.getItem('hermes-tts-rate'));
const savedPitch=parseFloat(localStorage.getItem('hermes-tts-pitch'));
let rateParam='', pitchParam='';
if(!isNaN(savedRate)){
const pct=Math.round((savedRate-1)*100);
const sign=pct>=0?'+':'';
rateParam='&rate='+encodeURIComponent(sign+pct+'%');
}
if(!isNaN(savedPitch)){
const hz=Math.round((savedPitch-1)*50);
const sign=hz>=0?'+':'';
pitchParam='&pitch='+encodeURIComponent(sign+hz+'Hz');
}
const url='/api/tts?text='+encodeURIComponent(text)+'&voice='+encodeURIComponent(voice)+rateParam+pitchParam;
let rate='', pitch='';
if(!isNaN(savedRate)){const pct=Math.round((savedRate-1)*100);const sign=pct>=0?'+':'';rate=sign+pct+'%';}
if(!isNaN(savedPitch)){const hz=Math.round((savedPitch-1)*50);const sign=hz>=0?'+':'';pitch=sign+hz+'Hz';}
if(btn) btn.dataset.speaking='1';
_ttsSpeaking=true;
const audio=new Audio(url);
_playingEdgeAudio=audio;
audio.onended=function(){_ttsSpeaking=false;_playingEdgeAudio=null;if(btn)btn.dataset.speaking='0';};
audio.onerror=function(){_ttsSpeaking=false;_playingEdgeAudio=null;if(btn)btn.dataset.speaking='0';};
audio.play().catch(function(e){
// /api/tts is POST-only (and behind the same-origin CSRF gate); GET via
// new Audio(url) would 405 and silently fail, and would also leak the message
// text into the query string / access log. POST the JSON body, then play the
// returned audio via an object URL — mirrors the working boot.js edge path.
const _fail=function(msg){
_ttsSpeaking=false;_playingEdgeAudio=null;
if(btn)btn.dataset.speaking='0';
showToast('Edge TTS error: '+e.message);
});
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, voice:voice, rate:rate, pitch:pitch})
})
.then(function(r){
if(!r.ok){
// Surface the server error (e.g. 503 "edge-tts not installed", 429 rate limit)
return r.json().catch(function(){return {};}).then(function(j){
throw new Error((j&&j.error)||('TTS request failed: '+r.status));
});
}
return r.blob();
})
.then(function(blob){
const url=URL.createObjectURL(blob);
const audio=new Audio(url);
_playingEdgeAudio=audio;
const _cleanup=function(){_ttsSpeaking=false;_playingEdgeAudio=null;if(btn)btn.dataset.speaking='0';try{URL.revokeObjectURL(url);}catch(_){}};
audio.onended=_cleanup;
audio.onerror=function(){_cleanup();};
audio.play().catch(function(e){_cleanup();showToast('Edge TTS error: '+(e&&e.message||e));});
})
.catch(function(e){ _fail((e&&e.message)||'Edge TTS failed'); });
}
function stopTTS(){
+18 -11
View File
@@ -8,6 +8,8 @@ edge_tts import / Communicate call.
import io
import json
import pytest
import api.routes as routes
@@ -50,15 +52,23 @@ def _reset_limiter():
del routes._handle_tts._tts_limiter
def test_tts_requires_post():
@pytest.fixture(autouse=True)
def _fresh_tts_limiter():
# The limiter is a function-attribute singleton that persists across the
# whole test session; reset it before AND after every test in this module so
# neither prior suite state nor these tests leak rate-limit state.
_reset_limiter()
yield
_reset_limiter()
def test_tts_requires_post():
h = _post({"text": "hello"}, command="GET")
routes._handle_tts(h, None)
assert h.status == 405
def test_tts_requires_text():
_reset_limiter()
h = _post({"text": " "})
routes._handle_tts(h, None)
assert h.status == 400
@@ -66,32 +76,29 @@ def test_tts_requires_text():
def test_tts_rejects_overlong_text():
_reset_limiter()
h = _post({"text": "x" * 5001})
h = _post({"text": "x" * 5001}, client="10.0.0.1")
routes._handle_tts(h, None)
assert h.status == 400
assert "too long" in (h.payload() or {}).get("error", "")
def test_tts_rejects_unknown_voice():
_reset_limiter()
h = _post({"text": "hello", "voice": "evil-voice-injection"}, client="5.6.7.8")
h = _post({"text": "hello", "voice": "evil-voice-injection"}, client="10.0.0.2")
routes._handle_tts(h, None)
assert h.status == 400
assert "invalid voice" in (h.payload() or {}).get("error", "")
def test_tts_rate_limits_second_immediate_request():
_reset_limiter()
# The limiter runs (and records the client) BEFORE the voice allowlist and
# before any edge-tts synthesis. Use an invalid voice so the first request
# still registers with the limiter but returns at the allowlist (400) without
# making a real network call; the second immediate request from the SAME
# client is then throttled (429).
h1 = _post({"text": "hello", "voice": "not-a-real-voice"}, client="9.9.9.9")
# client is then throttled (429). Unique client IP avoids any cross-test key
# collision (the autouse fixture also resets the limiter each test).
h1 = _post({"text": "hello", "voice": "not-a-real-voice"}, client="10.0.0.3")
routes._handle_tts(h1, None)
assert h1.status == 400 # rejected at allowlist, limiter recorded the client
h2 = _post({"text": "hello", "voice": "not-a-real-voice"}, client="9.9.9.9")
h2 = _post({"text": "hello", "voice": "not-a-real-voice"}, client="10.0.0.3")
routes._handle_tts(h2, None)
assert h2.status == 429
_reset_limiter()