feat(tts): add native local iOS TTS (AVSpeechSynthesizer) (#4697)

Implement on-device iOS text-to-speech using AVSpeechSynthesizer,
mirroring the Android native TextToSpeech plugin so the shared
NativeTTSClient drives both platforms through the same command and
tts_events contract.

- Swift NativeTTSPlugin: speak/stop/pause/resume/rate/pitch/voice and
  voice enumeration, with region-disambiguated duplicate voice names and
  a small preUtteranceDelay to avoid first-word clipping.
- Enable the native TTS client on iOS in TTSController.
- Make TTS teardown resilient: reset UI state up front and tear down the
  controller, media session, and background audio in parallel so a slow
  native shutdown can never leave the TTS icon or lock-screen session
  stuck on.
- Keep iOS on navigator.mediaSession for the lock screen (Android uses
  the native foreground service), which restores the Edge TTS cover and
  current-sentence metadata.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Huang Xin
2026-06-21 15:53:23 +08:00
committed by GitHub
parent ab935f8510
commit 96d65d9960
9 changed files with 772 additions and 32 deletions
@@ -198,19 +198,31 @@ vi.mock('@/utils/ttsTime', () => ({
}),
}));
const { mockDeinitMediaSession } = vi.hoisted(() => ({
mockDeinitMediaSession: vi.fn(() => Promise.resolve()),
}));
vi.mock('@/app/reader/hooks/useTTSMediaSession', () => ({
useTTSMediaSession: () => ({
mediaSessionRef: { current: null },
unblockAudio: vi.fn(),
releaseUnblockAudio: vi.fn(),
initMediaSession: vi.fn().mockResolvedValue(undefined),
deinitMediaSession: vi.fn().mockResolvedValue(undefined),
deinitMediaSession: mockDeinitMediaSession,
}),
}));
// Imports must come AFTER vi.mock calls so they pick up the mocked modules.
import { useTTSControl } from '@/app/reader/hooks/useTTSControl';
import { eventDispatcher } from '@/utils/event';
import { useReaderStore } from '@/store/readerStore';
const getSetTTSEnabledMock = () =>
(
useReaderStore as unknown as {
getState: () => { setTTSEnabled: ReturnType<typeof vi.fn> };
}
).getState().setTTSEnabled;
const Harness = () => {
useTTSControl({ bookKey: 'book-1' });
@@ -324,6 +336,82 @@ describe('useTTSControl tts-sync-request (mode-entry replay)', () => {
});
});
describe('useTTSControl handleStop resilience (#4676)', () => {
beforeEach(() => {
ttsControllerInstances.length = 0;
pendingInitResolvers.length = 0;
mockDeinitMediaSession.mockReset();
mockDeinitMediaSession.mockResolvedValue(undefined);
});
afterEach(() => {
cleanup();
});
const startSession = async () => {
render(<Harness />);
await act(async () => {
const p = eventDispatcher.dispatch('tts-speak', { bookKey: 'book-1' });
for (let i = 0; i < 10; i++) await Promise.resolve();
while (pendingInitResolvers.length > 0) pendingInitResolvers.shift()!();
await p;
});
await act(async () => {
for (let i = 0; i < 5; i++) await Promise.resolve();
});
return ttsControllerInstances[0] as { shutdown: ReturnType<typeof vi.fn> };
};
it('disables TTS even when controller.shutdown rejects', async () => {
// Regression: a native teardown that throws (observed with iOS system TTS)
// must not skip the state resets that turn the TTS icon off.
const controller = await startSession();
const setTTSEnabled = getSetTTSEnabledMock();
setTTSEnabled.mockClear();
controller.shutdown.mockRejectedValueOnce(new Error('native teardown failed'));
await act(async () => {
await eventDispatcher.dispatch('tts-stop', { bookKey: 'book-1' });
for (let i = 0; i < 5; i++) await Promise.resolve();
});
expect(setTTSEnabled).toHaveBeenCalledWith('book-1', false);
});
it('disables TTS even when controller.shutdown never resolves', async () => {
// The state resets must run before (not after) the teardown await, so a
// hung native teardown can never leave the TTS icon stuck on.
const controller = await startSession();
const setTTSEnabled = getSetTTSEnabledMock();
setTTSEnabled.mockClear();
controller.shutdown.mockReturnValueOnce(new Promise<void>(() => {}));
await act(async () => {
// Do not await the dispatch: handleStop intentionally never settles here.
eventDispatcher.dispatch('tts-stop', { bookKey: 'book-1' });
for (let i = 0; i < 5; i++) await Promise.resolve();
});
expect(setTTSEnabled).toHaveBeenCalledWith('book-1', false);
});
it('tears down the media session even when controller.shutdown never resolves', async () => {
// Regression for the lock-screen Now Playing lingering with iOS system TTS:
// the media-session teardown must not be gated behind the controller's own
// shutdown, which can stall.
const controller = await startSession();
mockDeinitMediaSession.mockClear();
controller.shutdown.mockReturnValueOnce(new Promise<void>(() => {}));
await act(async () => {
eventDispatcher.dispatch('tts-stop', { bookKey: 'book-1' });
for (let i = 0; i < 5; i++) await Promise.resolve();
});
expect(mockDeinitMediaSession).toHaveBeenCalled();
});
});
describe('useTTSControl handleHighlightMark cross-section navigation', () => {
beforeEach(() => {
ttsControllerInstances.length = 0;
@@ -0,0 +1,85 @@
import { describe, test, expect, vi, beforeEach, afterEach } from 'vitest';
// getMediaSession() consults the platform helpers; mock them so each test can
// pin a platform without touching the real user agent / env.
vi.mock('@/utils/misc', () => ({
getOSPlatform: vi.fn(),
}));
vi.mock('@/services/environment', () => ({
isTauriAppPlatform: vi.fn(),
}));
// mediaSession.ts imports these at module load; TauriMediaSession construction
// does not call them, but the imports must resolve in jsdom.
vi.mock('@tauri-apps/api/core', () => ({
invoke: vi.fn(),
addPluginListener: vi.fn(),
}));
import { getMediaSession, TauriMediaSession } from '@/libs/mediaSession';
import { getOSPlatform } from '@/utils/misc';
import { isTauriAppPlatform } from '@/services/environment';
const setNavigatorMediaSession = (present: boolean) => {
if (present) {
Object.defineProperty(navigator, 'mediaSession', {
value: { metadata: null, setActionHandler: vi.fn() },
configurable: true,
});
} else if ('mediaSession' in navigator) {
delete (navigator as unknown as { mediaSession?: unknown }).mediaSession;
}
};
describe('getMediaSession', () => {
beforeEach(() => {
vi.clearAllMocks();
});
afterEach(() => {
setNavigatorMediaSession(false);
});
test('uses navigator.mediaSession on iOS, NOT the native plugin', () => {
// iOS audio plays through the WebView (Edge TTS media element, or the silent
// keep-alive element during system TTS), so navigator.mediaSession is what
// surfaces the lock-screen cover + sentence + controls. Routing iOS through
// the native plugin hid the Edge cover/sentence and gave system TTS no
// controls (AVSpeechSynthesizer can't be surfaced that way). See #4676.
vi.mocked(getOSPlatform).mockReturnValue('ios');
vi.mocked(isTauriAppPlatform).mockReturnValue(true);
setNavigatorMediaSession(true);
const result = getMediaSession();
expect(result).not.toBeInstanceOf(TauriMediaSession);
expect(result).toBe(navigator.mediaSession);
});
test('returns TauriMediaSession on Android Tauri (native foreground service)', () => {
// Android is checked first, so it uses the native session even though the
// WebView may also expose navigator.mediaSession.
vi.mocked(getOSPlatform).mockReturnValue('android');
vi.mocked(isTauriAppPlatform).mockReturnValue(true);
setNavigatorMediaSession(true);
expect(getMediaSession()).toBeInstanceOf(TauriMediaSession);
});
test('falls back to navigator.mediaSession on the web', () => {
vi.mocked(getOSPlatform).mockReturnValue('macos');
vi.mocked(isTauriAppPlatform).mockReturnValue(false);
setNavigatorMediaSession(true);
const result = getMediaSession();
expect(result).not.toBeInstanceOf(TauriMediaSession);
expect(result).toBe(navigator.mediaSession);
});
test('returns null when neither a native nor a web media session is available', () => {
vi.mocked(getOSPlatform).mockReturnValue('linux');
vi.mocked(isTauriAppPlatform).mockReturnValue(false);
setNavigatorMediaSession(false);
expect(getMediaSession()).toBeNull();
});
});
@@ -0,0 +1,53 @@
import { describe, test, expect, vi, beforeEach, afterEach } from 'vitest';
vi.mock('@tauri-apps/api/core', () => ({
invoke: vi.fn(),
addPluginListener: vi.fn().mockResolvedValue({ unregister: vi.fn() }),
}));
// Avoid pulling in the heavy TTSController module graph (foliate-js, etc.) — the
// native client only references it as a type.
vi.mock('@/services/tts/TTSController', () => ({ TTSController: class {} }));
import { NativeTTSClient } from '@/services/tts/NativeTTSClient';
import { invoke } from '@tauri-apps/api/core';
describe('NativeTTSClient.stop', () => {
beforeEach(() => {
vi.clearAllMocks();
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
});
test('resolves promptly when the native stop resolves', async () => {
vi.mocked(invoke).mockResolvedValue(undefined);
const client = new NativeTTSClient();
await client.stop();
expect(invoke).toHaveBeenCalledWith('plugin:native-tts|stop');
});
test('still resolves (bounded) when the native stop never resolves', async () => {
// Regression for #4676: a hung native stop must not hang teardown
// (controller.stop / shutdown), which would leave the TTS icon stuck.
vi.mocked(invoke).mockReturnValue(new Promise(() => {}));
const client = new NativeTTSClient();
let settled = false;
const p = client.stop().then(() => {
settled = true;
});
// Pending until the timeout fires.
await Promise.resolve();
expect(settled).toBe(false);
await vi.advanceTimersByTimeAsync(1500);
await p;
expect(settled).toBe(true);
});
});
@@ -142,9 +142,10 @@ function createMockView(): FoliateView {
// --- Helper: create mock AppService ---
function createMockAppService(isAndroid = false): AppService {
function createMockAppService(isAndroid = false, isIOS = false): AppService {
return {
isAndroidApp: isAndroid,
isIOSApp: isIOS,
} as unknown as AppService;
}
@@ -215,7 +216,13 @@ describe('TTSController', () => {
expect(c.ttsNativeClient).not.toBeNull();
});
test('does not create native client when not Android', () => {
test('creates native client when isIOSApp', () => {
const iosService = createMockAppService(false, true);
const c = new TTSController(iosService, mockView);
expect(c.ttsNativeClient).not.toBeNull();
});
test('does not create native client when neither Android nor iOS', () => {
expect(controller.ttsNativeClient).toBeNull();
});
@@ -535,26 +535,42 @@ export const useTTSControl = ({ bookKey, onRequestHidePanel }: UseTTSControlProp
const handleStop = useCallback(
async (bookKey: string) => {
const ttsController = ttsControllerRef.current;
if (ttsController) {
await ttsController.shutdown();
ttsControllerRef.current = null;
setTtsController(null);
getView(bookKey)?.deselect();
setIsPlaying(false);
emitPlaybackState('stopped');
onRequestHidePanel?.();
setShowIndicator(false);
setShowBackToCurrentTTSLocation(false);
}
// Reset all UI/session state up front — including the TTS toggle
// (ttsEnabled) and indicator that color the TTS icon — so disabling TTS
// always takes effect immediately. The teardown below is best-effort and
// must never block or skip these resets if it hangs or throws, which was
// observed with iOS system TTS (Edge TTS was unaffected). See #4676.
ttsControllerRef.current = null;
setTtsController(null);
setIsPlaying(false);
emitPlaybackState('stopped');
onRequestHidePanel?.();
setShowIndicator(false);
setShowBackToCurrentTTSLocation(false);
previousSectionLabelRef.current = undefined;
if (appService?.isIOSApp) {
await invokeUseBackgroundAudio({ enabled: false });
}
setTTSEnabled(bookKey, false);
getView(bookKey)?.deselect();
if (appService?.isMobile) {
releaseUnblockAudio();
}
await deinitMediaSession();
setTTSEnabled(bookKey, false);
// Tear down the controller, the lock-screen media session, and the
// background-audio session best-effort and IN PARALLEL. The controller's
// own shutdown can stall on iOS system TTS, and it must NOT gate the media
// session / background-audio teardown — otherwise the lock-screen Now
// Playing keeps running after TTS is disabled (Edge TTS was unaffected
// because it never hits the stalling native path). See #4676.
await Promise.all([
ttsController
? Promise.resolve()
.then(() => ttsController.shutdown())
.catch((error) => console.warn('TTS shutdown failed:', error))
: Promise.resolve(),
appService?.isIOSApp
? invokeUseBackgroundAudio({ enabled: false }).catch(() => {})
: Promise.resolve(),
deinitMediaSession().catch(() => {}),
]);
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[appService],
+15 -2
View File
@@ -140,10 +140,23 @@ export class TauriMediaSession {
}
export function getMediaSession() {
const platform = getOSPlatform();
// Android: the native foreground-service media session (TextToSpeech and
// WebAudio both run with the app as the media owner; the Android WebView
// doesn't expose a usable Media Session here).
if (platform === 'android' && isTauriAppPlatform()) {
return new TauriMediaSession();
}
// iOS (and web): the audio always plays through the WebView — Edge TTS's media
// element, or the silent keep-alive element (`unblockAudio`) that runs during
// system TTS — so navigator.mediaSession, driven by that element, is what
// surfaces the lock-screen card with the cover + current sentence and the
// transport controls. AVSpeechSynthesizer is NOT a WebView media element and
// can't be surfaced via the native MPNowPlayingInfo path, so iOS must NOT be
// routed through the native plugin (doing so both hid the Edge cover/sentence
// and left system TTS with no controls). See #4676.
if ('mediaSession' in navigator) {
return navigator.mediaSession;
} else if (getOSPlatform() === 'android' && isTauriAppPlatform()) {
return new TauriMediaSession();
}
return null;
}
@@ -214,7 +214,17 @@ export class NativeTTSClient implements TTSClient {
}
async stop() {
await invoke('plugin:native-tts|stop');
// Bound the native stop so teardown (controller.stop / shutdown, and thus
// the TTS icon turning off) can never hang if the native side is slow to
// resolve — e.g. iOS AVSpeechSynthesizer / audio-session teardown. See #4676.
try {
await Promise.race([
invoke('plugin:native-tts|stop'),
new Promise<void>((resolve) => setTimeout(resolve, 1500)),
]);
} catch (error) {
console.warn('Native TTS stop failed:', error);
}
this.#activeUtterances.clear();
}
@@ -87,8 +87,9 @@ export class TTSController extends EventTarget {
super();
this.ttsWebClient = new WebSpeechClient(this);
this.ttsEdgeClient = new EdgeTTSClient(this, appService);
// TODO: implement native TTS client for iOS and PC
if (appService?.isAndroidApp) {
// Native TTS is backed by Android TextToSpeech and iOS AVSpeechSynthesizer.
// TODO: implement native TTS client for desktop platforms.
if (appService?.isAndroidApp || appService?.isIOSApp) {
this.ttsNativeClient = new NativeTTSClient(this);
}
this.ttsClient = this.ttsWebClient;