mirror of
https://github.com/siteboon/claudecodeui.git
synced 2026-07-06 21:52:43 +08:00
fix(voice): harden timeout parsing, tts input check, and player abort
- fall back to the default when VOICE_TIMEOUT_MS is non-numeric or <= 0, so a bad override can't make the abort fire immediately - type-check the tts `text` before calling .trim() so a non-string body returns 400 instead of throwing - abort the in-flight TTS fetch on stop() and on a superseding play, so tapping read-aloud repeatedly doesn't leave orphaned requests generating audio
This commit is contained in:
@@ -39,7 +39,12 @@ function resolveConfig(req) {
|
|||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
|
||||||
// Generous by default — local TTS can synthesize long messages at ~real-time on CPU.
|
// 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
|
* 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 (!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.' });
|
if (!isAllowedBackendUrl(cfg.baseUrl)) return res.status(400).json({ error: 'Invalid voice backend URL.' });
|
||||||
const text = req.body?.text;
|
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 {
|
try {
|
||||||
const r = await fetchWithTimeout(`${cfg.baseUrl}/audio/speech`, {
|
const r = await fetchWithTimeout(`${cfg.baseUrl}/audio/speech`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
|
|||||||
@@ -30,7 +30,8 @@ class VoicePlayer {
|
|||||||
private state: VoicePlayState = 'idle';
|
private state: VoicePlayState = 'idle';
|
||||||
private errorId: string | null = null;
|
private errorId: string | null = null;
|
||||||
private errorMsg: 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<typeof setTimeout> | null = null;
|
private errorTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
private listeners = new Set<() => void>();
|
private listeners = new Set<() => void>();
|
||||||
|
|
||||||
@@ -89,13 +90,21 @@ class VoicePlayer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
stop() {
|
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();
|
if (this.audio) this.audio.pause();
|
||||||
this.state = 'idle';
|
this.state = 'idle';
|
||||||
this.currentId = null;
|
this.currentId = null;
|
||||||
this.emit();
|
this.emit();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private abortActive() {
|
||||||
|
if (this.activeController) {
|
||||||
|
this.activeController.abort();
|
||||||
|
this.activeController = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private onEnded() {
|
private onEnded() {
|
||||||
this.state = 'idle';
|
this.state = 'idle';
|
||||||
this.currentId = null;
|
this.currentId = null;
|
||||||
@@ -130,18 +139,23 @@ class VoicePlayer {
|
|||||||
this.emit();
|
this.emit();
|
||||||
|
|
||||||
const myToken = ++this.token;
|
const myToken = ++this.token;
|
||||||
|
this.abortActive(); // cancel any request this play supersedes
|
||||||
|
|
||||||
try {
|
try {
|
||||||
let url = this.cache.get(id);
|
let url = this.cache.get(id);
|
||||||
if (!url) {
|
if (!url) {
|
||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
|
this.activeController = controller;
|
||||||
const timer = setTimeout(() => controller.abort(), CLIENT_TIMEOUT_MS);
|
const timer = setTimeout(() => controller.abort(), CLIENT_TIMEOUT_MS);
|
||||||
const res = await authenticatedFetch('/api/voice/tts', {
|
const res = await authenticatedFetch('/api/voice/tts', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify({ text: content }),
|
body: JSON.stringify({ text: content }),
|
||||||
headers: voiceConfigHeaders(),
|
headers: voiceConfigHeaders(),
|
||||||
signal: controller.signal,
|
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 (myToken !== this.token) return; // superseded by another play/stop
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
let msg = `Read-aloud failed (${res.status})`;
|
let msg = `Read-aloud failed (${res.status})`;
|
||||||
|
|||||||
Reference in New Issue
Block a user