Skip to content
47 changes: 43 additions & 4 deletions packages/app-expo/src/stores/tts-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,10 @@ import {
DEFAULT_TTS_CONFIG,
type ITTSPlayer,
type TTSConfig,
VOICE_RESPEAK_DEBOUNCE_MS,
isActivePlay,
normalizeTTSConfig,
shouldRespeakForSynthChange,
splitNarrationText,
} from "@readany/core/tts";
import TrackPlayer from "react-native-track-player";
Expand Down Expand Up @@ -59,6 +62,26 @@ function clearSleepTimerHandle(): void {
}
}

let _respeakTimer: ReturnType<typeof setTimeout> | null = null;

function clearRespeakTimer(): void {
if (_respeakTimer) {
clearTimeout(_respeakTimer);
_respeakTimer = null;
}
}

function scheduleRespeak(): void {
clearRespeakTimer();
_respeakTimer = setTimeout(() => {
_respeakTimer = null;
const { playState, jumpToChunk } = useTTSStore.getState();
if (isActivePlay(playState)) {
jumpToChunk(_sessionCurrentIndex);
}
}, VOICE_RESPEAK_DEBOUNCE_MS);
}

function detachAndStopPlayer(player: ITTSPlayer | null): void {
if (!player) return;
player.onStateChange = undefined;
Expand Down Expand Up @@ -257,6 +280,7 @@ export const useTTSStore = create<TTSState>()(
sleepTimerDurationMinutes: null,

play: (text: string | string[]) => {
clearRespeakTimer();
const segments = normalizeSegments(text);
const joinedText = segments.join(" ").trim();
if (!joinedText) {
Expand Down Expand Up @@ -327,6 +351,7 @@ export const useTTSStore = create<TTSState>()(

pause: () => {
console.log("[TTSStore] pause called");
clearRespeakTimer();
const { playState } = get();
if (playState !== "playing" && playState !== "loading") return;
_activeTTS?.pause();
Expand Down Expand Up @@ -371,6 +396,7 @@ export const useTTSStore = create<TTSState>()(
stop: () => {
console.log("[TTSStore] stop called");
clearSleepTimerHandle();
clearRespeakTimer();
_sessionGeneration += 1;
detachAndStopAllPlayers();
_sessionSegments = [];
Expand Down Expand Up @@ -405,10 +431,22 @@ export const useTTSStore = create<TTSState>()(
}
},

updateConfig: (updates) =>
set((state) => ({
config: normalizeTTSConfig({ ...state.config, ...updates }),
})),
updateConfig: (updates) => {
const previousConfig = normalizeTTSConfig(get().config);
const nextConfig = normalizeTTSConfig({ ...previousConfig, ...updates });
set({ config: nextConfig });

if (
shouldRespeakForSynthChange(previousConfig, nextConfig) &&
isActivePlay(get().playState)
) {
scheduleRespeak();
} else {
// 非重读变更(切引擎、或改了当前引擎不关心的字段)必须取消上一次合成变更排下的
// 待执行 respeak,否则陈旧防抖定时器会 fire 并强制重启播放。
clearRespeakTimer();
}
},

setPlayState: (playState) => set({ playState }),

Expand Down Expand Up @@ -448,6 +486,7 @@ export const useTTSStore = create<TTSState>()(
}),

jumpToChunk: (index: number) => {
clearRespeakTimer();
if (index < 0 || index >= _sessionSegments.length) return;

const config = normalizeTTSConfig(get().config);
Expand Down
205 changes: 205 additions & 0 deletions packages/core/src/stores/tts-store.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,205 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";

import { DEFAULT_TTS_CONFIG, type ITTSPlayer, type TTSConfig } from "../tts/types";

vi.mock("./persist", () => ({
withPersist: (_key: string, creator: unknown) => creator,
}));

const { setTTSPlayerFactories, useTTSStore } = await import("./tts-store");

type MockTTSPlayer = ITTSPlayer & {
speak: ReturnType<typeof vi.fn>;
pause: ReturnType<typeof vi.fn>;
resume: ReturnType<typeof vi.fn>;
stop: ReturnType<typeof vi.fn>;
};

function createMockPlayer(): MockTTSPlayer {
const player = { paused: false } as MockTTSPlayer;
player.speak = vi.fn(() => {
player.onStateChange?.("playing");
});
player.pause = vi.fn(() => {
player.onStateChange?.("paused");
});
player.resume = vi.fn(() => {
player.onStateChange?.("playing");
});
player.stop = vi.fn(() => {
player.onStateChange?.("stopped");
});
return player;
}

function resetStore(config: TTSConfig = DEFAULT_TTS_CONFIG) {
useTTSStore.setState({
playState: "stopped",
currentText: "",
config,
onEnd: null,
currentChunkIndex: 0,
totalChunks: 0,
currentBookTitle: "",
currentChapterTitle: "",
currentBookId: "",
currentLocationCfi: "",
sleepTimerEndsAt: null,
sleepTimerDurationMinutes: null,
});
}

let systemPlayer: MockTTSPlayer;
let edgePlayer: MockTTSPlayer;
let dashscopePlayer: MockTTSPlayer;

function startDashScope(voice = "Cherry") {
useTTSStore
.getState()
.updateConfig({ engine: "dashscope", dashscopeApiKey: "key", dashscopeVoice: voice });
useTTSStore.getState().play(["s0", "s1", "s2"]);
}
function startEdge() {
useTTSStore
.getState()
.updateConfig({ engine: "edge", edgeVoice: "zh-CN-XiaoxiaoNeural", rate: 1.0, pitch: 1.0 });
useTTSStore.getState().play(["s0", "s1"]);
}

describe("useTTSStore — re-speak on synth change (#370)", () => {
beforeEach(() => {
vi.useFakeTimers();
systemPlayer = createMockPlayer();
edgePlayer = createMockPlayer();
dashscopePlayer = createMockPlayer();
setTTSPlayerFactories({
createSystemTTS: () => systemPlayer,
createEdgeTTS: () => edgePlayer,
createDashScopeTTS: () => dashscopePlayer,
});
resetStore();
useTTSStore.getState().stop();
vi.clearAllMocks();
});

afterEach(() => {
vi.useRealTimers();
});

it("dashscope: re-speaks from current sentence with new voice after debounce", () => {
startDashScope("Cherry");
expect(dashscopePlayer.speak).toHaveBeenCalledTimes(1);
useTTSStore.getState().updateConfig({ dashscopeVoice: "Ethan" });
expect(dashscopePlayer.speak).toHaveBeenCalledTimes(1);
vi.advanceTimersByTime(250);
expect(dashscopePlayer.speak).toHaveBeenCalledTimes(2);
const [segments, config] = dashscopePlayer.speak.mock.calls[1];
expect(segments).toEqual(["s0", "s1", "s2"]);
expect((config as TTSConfig).dashscopeVoice).toBe("Ethan");
});

it("edge: re-speaks on edge voice change", () => {
startEdge();
useTTSStore.getState().updateConfig({ edgeVoice: "zh-CN-YunxiNeural" });
vi.advanceTimersByTime(250);
expect(edgePlayer.speak).toHaveBeenCalledTimes(2);
expect((edgePlayer.speak.mock.calls[1][1] as TTSConfig).edgeVoice).toBe("zh-CN-YunxiNeural");
});

it("debounces rapid switches into one re-speak with the last voice", () => {
startDashScope("Cherry");
useTTSStore.getState().updateConfig({ dashscopeVoice: "Ethan" });
vi.advanceTimersByTime(100);
useTTSStore.getState().updateConfig({ dashscopeVoice: "Serena" });
vi.advanceTimersByTime(100);
useTTSStore.getState().updateConfig({ dashscopeVoice: "Dylan" });
expect(dashscopePlayer.speak).toHaveBeenCalledTimes(1);
vi.advanceTimersByTime(250);
expect(dashscopePlayer.speak).toHaveBeenCalledTimes(2);
expect((dashscopePlayer.speak.mock.calls[1][1] as TTSConfig).dashscopeVoice).toBe("Dylan");
});

it("[cleanup] new play() during a pending re-speak adds no spurious speak", () => {
startDashScope("Cherry");
useTTSStore.getState().updateConfig({ dashscopeVoice: "Ethan" });
vi.clearAllMocks();
useTTSStore.getState().play(["n0", "n1"]);
expect(dashscopePlayer.speak).toHaveBeenCalledTimes(1);
vi.advanceTimersByTime(250);
expect(dashscopePlayer.speak).toHaveBeenCalledTimes(1);
});

it("[cleanup] manual jumpToChunk during a pending re-speak does not double-fire", () => {
startDashScope("Cherry");
useTTSStore.getState().updateConfig({ dashscopeVoice: "Ethan" });
vi.clearAllMocks();
useTTSStore.getState().jumpToChunk(1);
const callsAfterJump = dashscopePlayer.speak.mock.calls.length;
vi.advanceTimersByTime(250);
expect(dashscopePlayer.speak).toHaveBeenCalledTimes(callsAfterJump);
});

it("[loading] triggers re-speak while in loading state", () => {
startDashScope("Cherry");
useTTSStore.setState({ playState: "loading" });
vi.clearAllMocks();
useTTSStore.getState().updateConfig({ dashscopeVoice: "Ethan" });
vi.advanceTimersByTime(250);
expect(dashscopePlayer.speak).toHaveBeenCalledTimes(1);
expect((dashscopePlayer.speak.mock.calls[0][1] as TTSConfig).dashscopeVoice).toBe("Ethan");
});

it("does not re-speak when stopped", () => {
useTTSStore
.getState()
.updateConfig({ engine: "dashscope", dashscopeApiKey: "key", dashscopeVoice: "Cherry" });
useTTSStore.getState().updateConfig({ dashscopeVoice: "Ethan" });
vi.advanceTimersByTime(250);
expect(dashscopePlayer.speak).not.toHaveBeenCalled();
});

it("does not re-speak when voice unchanged", () => {
startDashScope("Cherry");
useTTSStore.getState().updateConfig({ dashscopeVoice: "Cherry" });
vi.advanceTimersByTime(250);
expect(dashscopePlayer.speak).toHaveBeenCalledTimes(1);
});

it("does not re-speak when dashscope api key missing", () => {
useTTSStore
.getState()
.updateConfig({ engine: "dashscope", dashscopeApiKey: "", dashscopeVoice: "Cherry" });
useTTSStore.getState().play(["s0", "s1"]);
vi.clearAllMocks();
useTTSStore.getState().updateConfig({ dashscopeVoice: "Ethan" });
vi.advanceTimersByTime(250);
expect(dashscopePlayer.speak).not.toHaveBeenCalled();
});

it("cancels pending re-speak on stop", () => {
startDashScope("Cherry");
useTTSStore.getState().updateConfig({ dashscopeVoice: "Ethan" });
useTTSStore.getState().stop();
vi.advanceTimersByTime(250);
expect(dashscopePlayer.speak).toHaveBeenCalledTimes(1);
});

it("cancels pending re-speak on pause", () => {
startDashScope("Cherry");
useTTSStore.getState().updateConfig({ dashscopeVoice: "Ethan" });
useTTSStore.getState().pause();
vi.advanceTimersByTime(250);
expect(dashscopePlayer.speak).toHaveBeenCalledTimes(1);
});

it("[cleanup] 非重读配置变更取消待执行的 respeak(不残留重启)", () => {
startEdge();
useTTSStore.getState().updateConfig({ edgeVoice: "zh-CN-YunxiNeural" }); // 排下 respeak 定时器
vi.advanceTimersByTime(100); // 防抖窗口内
useTTSStore.getState().updateConfig({ engine: "system" }); // 非重读变更 → 应取消定时器
vi.clearAllMocks();
vi.advanceTimersByTime(250); // 让任何残留定时器有机会 fire
expect(edgePlayer.speak).not.toHaveBeenCalled();
expect(systemPlayer.speak).not.toHaveBeenCalled();
});
});
45 changes: 41 additions & 4 deletions packages/core/src/stores/tts-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
* (e.g. React Native) can override via `setTTSPlayerFactories()`.
*/
import { create } from "zustand";
import { VOICE_RESPEAK_DEBOUNCE_MS, isActivePlay, shouldRespeakForSynthChange } from "../tts/respeak";
import { BrowserTTSPlayer, DashScopeTTSPlayer, EdgeTTSPlayer } from "../tts/tts-players";
import type { ITTSPlayer, TTSConfig } from "../tts/types";
import { DEFAULT_TTS_CONFIG, normalizeTTSConfig } from "../tts/types";
Expand Down Expand Up @@ -90,6 +91,26 @@ function clearSleepTimerHandle(): void {
}
}

let _respeakTimer: ReturnType<typeof setTimeout> | null = null;

function clearRespeakTimer(): void {
if (_respeakTimer) {
clearTimeout(_respeakTimer);
_respeakTimer = null;
}
}

function scheduleRespeak(): void {
clearRespeakTimer();
_respeakTimer = setTimeout(() => {
_respeakTimer = null;
const { playState, jumpToChunk } = useTTSStore.getState();
if (isActivePlay(playState)) {
jumpToChunk(_sessionCurrentIndex);
}
}, VOICE_RESPEAK_DEBOUNCE_MS);
}

export interface TTSState {
/** Current playback state */
playState: TTSPlayState;
Expand Down Expand Up @@ -150,6 +171,7 @@ export const useTTSStore = create<TTSState>()(
sleepTimerDurationMinutes: null,

play: (text: string | string[]) => {
clearRespeakTimer();
const config = normalizeTTSConfig(get().config);
_dashscopeActiveVoice = config.dashscopeVoice;
const segments = Array.isArray(text) ? text.map((item) => item.trim()).filter(Boolean) : [text.trim()].filter(Boolean);
Expand Down Expand Up @@ -204,6 +226,7 @@ export const useTTSStore = create<TTSState>()(
},

pause: () => {
clearRespeakTimer();
const config = normalizeTTSConfig(get().config);
const { playState } = get();
if (playState !== "playing") return;
Expand Down Expand Up @@ -293,6 +316,7 @@ export const useTTSStore = create<TTSState>()(

stop: () => {
clearSleepTimerHandle();
clearRespeakTimer();
const system = getSystemTTS();
const edge = getEdgeTTS();
const dashscope = getDashScopeTTS();
Expand Down Expand Up @@ -333,10 +357,22 @@ export const useTTSStore = create<TTSState>()(
}
},

updateConfig: (updates) =>
set((s) => ({
config: normalizeTTSConfig({ ...s.config, ...updates }),
})),
updateConfig: (updates) => {
const previousConfig = normalizeTTSConfig(get().config);
const nextConfig = normalizeTTSConfig({ ...previousConfig, ...updates });
set({ config: nextConfig });

// [占位 · #427/#349] engine 变化 + 播放中 → 停播。合并 #427 时在此插入,
// 并在该分支内调用 clearRespeakTimer()。

if (shouldRespeakForSynthChange(previousConfig, nextConfig) && isActivePlay(get().playState)) {
scheduleRespeak();
} else {
// 非重读变更(切引擎、或改了当前引擎不关心的字段)必须取消上一次合成变更排下的
// 待执行 respeak,否则陈旧防抖定时器会 fire 并强制重启播放。
clearRespeakTimer();
}
},

setPlayState: (playState) => set({ playState }),

Expand All @@ -350,6 +386,7 @@ export const useTTSStore = create<TTSState>()(
setChunkProgress: (index, total) => set({ currentChunkIndex: index, totalChunks: total }),

jumpToChunk: (index: number) => {
clearRespeakTimer();
if (index < 0 || index >= _sessionSegments.length) return;
const config = normalizeTTSConfig(get().config);
_dashscopeActiveVoice = config.dashscopeVoice;
Expand Down
Loading