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:
@@ -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';
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user