diff --git a/static/boot.js b/static/boot.js index 9bcd1578..d3629ee8 100644 --- a/static/boot.js +++ b/static/boot.js @@ -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); }); }) diff --git a/static/ui.js b/static/ui.js index 64f505ea..6466ccae 100644 --- a/static/ui.js +++ b/static/ui.js @@ -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(){ diff --git a/tests/test_issue2931_edge_tts_endpoint.py b/tests/test_issue2931_edge_tts_endpoint.py index a1b7f2f6..23ae1cf2 100644 --- a/tests/test_issue2931_edge_tts_endpoint.py +++ b/tests/test_issue2931_edge_tts_endpoint.py @@ -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()