diff --git a/server/voice-proxy.js b/server/voice-proxy.js index 93d003d7..e1ae5e36 100644 --- a/server/voice-proxy.js +++ b/server/voice-proxy.js @@ -39,7 +39,12 @@ function resolveConfig(req) { const router = express.Router(); // Generous by default — local TTS can synthesize long messages at ~real-time on CPU. -const VOICE_TIMEOUT_MS = Number(process.env.VOICE_TIMEOUT_MS || 300000); +// Guard against a non-numeric/zero override that would make setTimeout fire immediately. +const DEFAULT_VOICE_TIMEOUT_MS = 300000; +const _parsedTimeout = Number(process.env.VOICE_TIMEOUT_MS); +const VOICE_TIMEOUT_MS = Number.isFinite(_parsedTimeout) && _parsedTimeout > 0 + ? _parsedTimeout + : DEFAULT_VOICE_TIMEOUT_MS; /** * fetch() with an AbortController timeout so a stalled backend can't hold the @@ -183,7 +188,7 @@ router.post('/tts', async (req, res) => { if (!cfg.baseUrl) return res.status(503).json({ error: 'No voice backend configured' }); if (!isAllowedBackendUrl(cfg.baseUrl)) return res.status(400).json({ error: 'Invalid voice backend URL.' }); const text = req.body?.text; - if (!text || !text.trim()) return res.status(400).json({ error: 'text required' }); + if (typeof text !== 'string' || !text.trim()) return res.status(400).json({ error: 'text required' }); try { const r = await fetchWithTimeout(`${cfg.baseUrl}/audio/speech`, { method: 'POST', diff --git a/src/lib/voicePlayer.ts b/src/lib/voicePlayer.ts index 7a16a194..a51700d3 100644 --- a/src/lib/voicePlayer.ts +++ b/src/lib/voicePlayer.ts @@ -30,7 +30,8 @@ class VoicePlayer { private state: VoicePlayState = 'idle'; private errorId: string | null = null; private errorMsg: string | null = null; - private token = 0; // bumps to cancel in-flight fetches + private token = 0; // bumps to ignore stale in-flight results + private activeController: AbortController | null = null; // aborts the in-flight TTS fetch private errorTimer: ReturnType | null = null; private listeners = new Set<() => void>(); @@ -89,13 +90,21 @@ class VoicePlayer { } stop() { - this.token++; // cancel any in-flight fetch + this.token++; // ignore any stale in-flight result + this.abortActive(); // and actually cancel the network request if (this.audio) this.audio.pause(); this.state = 'idle'; this.currentId = null; this.emit(); } + private abortActive() { + if (this.activeController) { + this.activeController.abort(); + this.activeController = null; + } + } + private onEnded() { this.state = 'idle'; this.currentId = null; @@ -130,18 +139,23 @@ class VoicePlayer { this.emit(); const myToken = ++this.token; + this.abortActive(); // cancel any request this play supersedes try { let url = this.cache.get(id); if (!url) { const controller = new AbortController(); + this.activeController = controller; const timer = setTimeout(() => controller.abort(), CLIENT_TIMEOUT_MS); const res = await authenticatedFetch('/api/voice/tts', { method: 'POST', body: JSON.stringify({ text: content }), headers: voiceConfigHeaders(), signal: controller.signal, - }).finally(() => clearTimeout(timer)); + }).finally(() => { + clearTimeout(timer); + if (this.activeController === controller) this.activeController = null; + }); if (myToken !== this.token) return; // superseded by another play/stop if (!res.ok) { let msg = `Read-aloud failed (${res.status})`;