fix(tts): preserve state on AbortError to keep rate changes effective on iOS, closes #3949 (#3988)

iOS WKWebView's in-flight audio.play() rejects with AbortError after audio.src is reset
during a stop+restart cycle. That rejection leaks through one of the .catch chains into
TTSController.error(), which unconditionally flipped state to 'stopped' — desyncing the
state machine so subsequent rate changes fell into the no-op else branch and #speak's
auto-forward gate stopped firing at paragraph end.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Huang Xin
2026-04-29 01:45:05 +08:00
committed by GitHub
parent 34f19fd148
commit ad55375f89
2 changed files with 31 additions and 0 deletions
@@ -473,6 +473,27 @@ describe('TTSController', () => {
expect(controller.state).toBe('stopped');
});
test('error preserves state for AbortError (DOMException-style)', () => {
// iOS audio.play() and AbortSignal-aware fetches reject with a DOMException
// whose name is 'AbortError'. Treating it as a real error desyncs the state
// machine: subsequent rate changes see state !== 'playing' and skip the
// stop+start cycle, and #speak's auto-forward gate fails.
controller.state = 'playing';
const abort = new Error('The operation was aborted.');
abort.name = 'AbortError';
controller.error(abort);
expect(controller.state).toBe('playing');
});
test('error preserves state for our internal Aborted message', () => {
// EdgeTTSClient and NativeTTSClient resolve the inner promise with
// { code: 'error', message: 'Aborted' } on signal abort; if that bubbles
// through any catch path it must not flip state to 'stopped'.
controller.state = 'playing';
controller.error(new Error('Aborted'));
expect(controller.state).toBe('playing');
});
test('play calls start when not playing', () => {
controller.state = 'stopped';
const startSpy = vi.spyOn(controller, 'start').mockResolvedValue();
@@ -558,6 +558,16 @@ export class TTSController extends EventTarget {
}
error(e: unknown) {
// AbortError is expected during normal stop/restart cycles (rate change,
// forward/backward, voice change) — on iOS especially, the in-flight
// audio.play() promise rejects with AbortError after audio.src is reset,
// and that rejection can leak through one of the .catch chains. Letting it
// flip state to 'stopped' desyncs the state machine: handleSetRate's
// `state === 'playing'` check then falls through to a no-op, and #speak's
// auto-forward gate skips advancing to the next paragraph.
if (e instanceof Error && (e.name === 'AbortError' || e.message === 'Aborted')) {
return;
}
console.error(e);
this.state = 'stopped';
}