Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
197 changes: 44 additions & 153 deletions packages/core/src/stores/tts-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import {
OpenAICompatibleTTSPlayer,
XiaomiTTSPlayer,
} from "../tts/tts-players";
import { LegacyPlayerProvider, TTSCoordinator } from "../tts/coordinator";
import type { ITTSPlayer, TTSConfig, TTSProfile } from "../tts/types";
import { DEFAULT_TTS_CONFIG, normalizeTTSConfig } from "../tts/types";
import { withPersist } from "./persist";
Expand Down Expand Up @@ -78,11 +79,9 @@ let _edgeTTS: ITTSPlayer | null = null;
let _dashscopeTTS: ITTSPlayer | null = null;
let _xiaomiTTS: ITTSPlayer | null = null;
let _openAICompatibleTTS: ITTSPlayer | null = null;
let _activeTTS: ITTSPlayer | null = null;
let _coordinator: TTSCoordinator | null = null;
let _sessionSegments: string[] = [];
let _sessionCurrentIndex = 0;
/** Generation counter — incremented on every play/jumpToChunk to invalidate stale callbacks */
let _sessionGeneration = 0;
let _sleepTimerHandle: ReturnType<typeof setTimeout> | null = null;
/** Voice the active DashScope run is synthesizing with; lets resume() decide whether
* it can true-resume (voice unchanged) or must re-speak (voice changed). */
Expand Down Expand Up @@ -167,7 +166,8 @@ function syncProfileUpdatesFromLegacyFields(
} else if (targetProvider === "openai-compatible") {
if (updates.openaiTtsBaseUrl !== undefined) profileUpdates.baseUrl = updates.openaiTtsBaseUrl;
if (updates.openaiTtsApiKey !== undefined) profileUpdates.apiKey = updates.openaiTtsApiKey;
if (updates.openaiTtsEndpoint !== undefined) profileUpdates.endpoint = updates.openaiTtsEndpoint;
if (updates.openaiTtsEndpoint !== undefined)
profileUpdates.endpoint = updates.openaiTtsEndpoint;
if (updates.openaiTtsModel !== undefined) profileUpdates.model = updates.openaiTtsModel;
if (updates.openaiTtsVoice !== undefined) profileUpdates.voice = updates.openaiTtsVoice;
if (updates.openaiTtsFormat !== undefined) profileUpdates.format = updates.openaiTtsFormat;
Expand All @@ -193,27 +193,6 @@ function syncProfileUpdatesFromLegacyFields(
return { ...updates, profiles };
}

function detachAndStopPlayer(player: ITTSPlayer | null): void {
if (!player) return;
player.onStateChange = undefined;
player.onChunkChange = undefined;
player.onEnd = undefined;
try {
player.stop();
} catch (err) {
console.warn("[TTS] Failed to stop player:", err);
}
}

function detachAndStopAllPlayers(): void {
_activeTTS = null;
detachAndStopPlayer(_systemTTS);
detachAndStopPlayer(_edgeTTS);
detachAndStopPlayer(_dashscopeTTS);
detachAndStopPlayer(_xiaomiTTS);
detachAndStopPlayer(_openAICompatibleTTS);
}

function getPlayerForConfig(config: TTSConfig): ITTSPlayer {
if (config.engine === "dashscope" && config.dashscopeApiKey) {
return getDashScopeTTS();
Expand All @@ -230,70 +209,6 @@ function getPlayerForConfig(config: TTSConfig): ITTSPlayer {
return getSystemTTS();
}

function startPlayback(
segments: string[],
config: TTSConfig,
startIndex: number,
set: (partial: Partial<TTSState>) => void,
get: () => TTSState,
): void {
const player = getPlayerForConfig(config);
const gen = _sessionGeneration;
let isStarting = true;
_activeTTS = player;

player.onStateChange = (playState) => {
if (gen !== _sessionGeneration) return;
if (isStarting && playState === "stopped") return;
if (playState === "stopped") {
_activeTTS = null;
}
set({ playState });
};

player.onChunkChange = (chunkIndex, total) => {
if (gen !== _sessionGeneration) return;
const absoluteIndex = startIndex + chunkIndex;
_sessionCurrentIndex = absoluteIndex;
set({
currentChunkIndex: absoluteIndex,
totalChunks: Math.max(_sessionSegments.length, total),
});
};

player.onEnd = () => {
if (gen !== _sessionGeneration) return;
_activeTTS = null;
const lastIndex = Math.max(0, _sessionSegments.length - 1);
_sessionCurrentIndex = lastIndex;
set({
playState: "stopped",
currentChunkIndex: lastIndex,
totalChunks: _sessionSegments.length,
});
get().onEnd?.();
};

let playback: void | Promise<void>;
try {
playback = player.speak(segments, config);
} catch (error) {
isStarting = false;
if (gen !== _sessionGeneration) return;
console.error("[TTS] play failed:", error);
_activeTTS = null;
set({ playState: "stopped" });
return;
}
isStarting = false;
void Promise.resolve(playback).catch((error) => {
if (gen !== _sessionGeneration) return;
console.error("[TTS] play failed:", error);
_activeTTS = null;
set({ playState: "stopped" });
});
}

export interface TTSState {
/** Current playback state */
playState: TTSPlayState;
Expand Down Expand Up @@ -368,75 +283,59 @@ export const useTTSStore = create<TTSState>()(
: [Array.isArray(text) ? text.join(" ").trim() : text.trim()].filter(Boolean);
_sessionSegments = sessionSegments;
_sessionCurrentIndex = 0;
_sessionGeneration += 1;
detachAndStopAllPlayers();
set({
playState: "loading",
currentText: sessionSegments.join(" "),
currentChunkIndex: 0,
totalChunks: sessionSegments.length,
});

startPlayback(sessionSegments, config, 0, set, get);
const player = getPlayerForConfig(config);
_coordinator = new TTSCoordinator(config, undefined, {
onStateChange: (state) =>
set({
playState:
state.status === "playing"
? "playing"
: state.status === "paused"
? "paused"
: state.status === "loading"
? "loading"
: "stopped",
}),
onSegment: (index, total) => {
_sessionCurrentIndex = index;
set({ currentChunkIndex: index, totalChunks: total });
},
onEnd: () => {
get().onEnd?.();
},
onError: (error) => {
console.error("[TTS] coordinator error", error);
set({ playState: "stopped" });
},
});
_coordinator.play(text, new LegacyPlayerProvider(player, config.engine));
},

pause: () => {
clearRespeakTimer();
const { playState } = get();
if (playState !== "playing" && playState !== "loading") return;
_activeTTS?.pause();
set({ playState: "paused" });
_coordinator?.pause();
},

resume: () => {
const config = normalizeTTSConfig(get().config);
const { playState } = get();
if (playState !== "paused") return;

// DashScope supports true suspend/resume and derives progress from the audio
// clock (#358), so if it is actually suspended, continue exactly where paused —
// no re-synthesis, no API re-call, no jump. Do NOT bump generation or rebind
// callbacks; the original speak()'s callbacks keep driving progress.
// Edge is intentionally NOT true-resumed here: its highlight notifications are
// wall-clock timers cleared on pause and not rescheduled on resume, so a true
// resume would skip highlights — it stays on the re-speak path below (its main behavior).
if (config.engine === "dashscope" && config.dashscopeApiKey) {
const player = getDashScopeTTS();
if (player.paused && config.dashscopeVoice === _dashscopeActiveVoice) {
player.resume();
set({ playState: "playing" });
return;
}
}

if (_sessionSegments.length > 0) {
const nextIndex = Math.max(
0,
Math.min(_sessionCurrentIndex, _sessionSegments.length - 1),
);
const remainingSegments = _sessionSegments.slice(nextIndex);
if (remainingSegments.length > 0) {
_sessionGeneration += 1;
detachAndStopAllPlayers();
_sessionCurrentIndex = nextIndex;
_dashscopeActiveVoice = config.dashscopeVoice;
set({
playState: "loading",
currentChunkIndex: nextIndex,
totalChunks: _sessionSegments.length,
});
startPlayback(remainingSegments, config, nextIndex, set, get);
return;
}
}
set({ playState: "stopped" });
_coordinator?.resume();
},

stop: () => {
clearSleepTimerHandle();
clearRespeakTimer();
_sessionGeneration += 1;
detachAndStopAllPlayers();
_coordinator?.stop();
_coordinator = null;
_sessionSegments = [];
_sessionCurrentIndex = 0;
_dashscopeActiveVoice = undefined;
Expand Down Expand Up @@ -476,11 +375,17 @@ export const useTTSStore = create<TTSState>()(
updates.engine !== undefined && nextConfig.engine !== previousConfig.engine;
const wasPlaying = isActivePlay(get().playState);
set({ config: nextConfig });
_coordinator?.updateConfig(nextConfig);

if (engineChanged && wasPlaying) {
clearRespeakTimer();
_sessionGeneration += 1;
detachAndStopAllPlayers();
const activePlayer = getPlayerForConfig(previousConfig);
activePlayer.onStateChange = undefined;
activePlayer.onChunkChange = undefined;
activePlayer.onEnd = undefined;
activePlayer.onError = undefined;
activePlayer.stop();
_coordinator = null;
_dashscopeActiveVoice = undefined;
set({ playState: "stopped" });
return;
Expand Down Expand Up @@ -512,24 +417,10 @@ export const useTTSStore = create<TTSState>()(
jumpToChunk: (index: number) => {
clearRespeakTimer();
if (index < 0 || index >= _sessionSegments.length) return;
const config = normalizeTTSConfig(get().config);
const remainingSegments = _sessionSegments.slice(index);
if (remainingSegments.length === 0) {
set({ playState: "stopped" });
if (_coordinator) {
_coordinator.jumpTo({ offset: _sessionSegments.slice(0, index).join(" ").length });
return;
}

_sessionGeneration += 1;
detachAndStopAllPlayers();
_dashscopeActiveVoice = config.dashscopeVoice;
_sessionCurrentIndex = index;
set({
playState: "loading",
currentChunkIndex: index,
totalChunks: _sessionSegments.length,
});

startPlayback(remainingSegments, config, index, set, get);
},

setSleepTimer: (minutes: number) => {
Expand Down
47 changes: 47 additions & 0 deletions packages/core/src/tts/coordinator.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import { describe, expect, it, vi } from "vitest";
import { LegacyPlayerProvider, TTSCoordinator } from "./coordinator";
import { DEFAULT_TTS_CONFIG, type ITTSPlayer } from "./types";

function fakePlayer() {
let end: (() => void) | undefined;
const player: ITTSPlayer = {
speak: vi.fn(async () => undefined),
pause: vi.fn(),
resume: vi.fn(),
stop: vi.fn(),
set onEnd(value: (() => void) | undefined) {
end = value;
},
get onEnd() {
return end;
},
};
return { player, finish: () => end?.() };
}

describe("TTSCoordinator", () => {
it("transitions through loading and advances segments", async () => {
const fake = fakePlayer();
const states: string[] = [];
const coordinator = new TTSCoordinator(DEFAULT_TTS_CONFIG, undefined, {
onStateChange: (s) => states.push(s.status),
});
coordinator.play(["one", "two"], new LegacyPlayerProvider(fake.player, "system"));
expect(coordinator.getState().status).toBe("loading");
fake.player.onStateChange?.("playing");
expect(coordinator.getState().status).toBe("playing");
fake.finish();
expect((fake.player.speak as ReturnType<typeof vi.fn>).mock.calls).toHaveLength(1);
expect(states).toContain("loading");
});

it("ignores callbacks from a cancelled session", () => {
const first = fakePlayer();
const second = fakePlayer();
const coordinator = new TTSCoordinator(DEFAULT_TTS_CONFIG);
coordinator.play("first", new LegacyPlayerProvider(first.player, "system"));
coordinator.play("second", new LegacyPlayerProvider(second.player, "system"));
first.finish();
expect((second.player.speak as ReturnType<typeof vi.fn>).mock.calls).toHaveLength(1);
});
});
Loading