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:
1 parent
6801272f51
commit
08fe4f51f0
3 files changed
+60
-30
No files matched your search
@@ -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
@@ -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(){
|
||||
|
||||
@@ -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()
|
||||
Reference in new issue
Block a user