diff --git a/packages/core/src/stores/tts-store.ts b/packages/core/src/stores/tts-store.ts index 20a442132..8179efe6a 100644 --- a/packages/core/src/stores/tts-store.ts +++ b/packages/core/src/stores/tts-store.ts @@ -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"; @@ -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 | 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). */ @@ -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; @@ -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(); @@ -230,70 +209,6 @@ function getPlayerForConfig(config: TTSConfig): ITTSPlayer { return getSystemTTS(); } -function startPlayback( - segments: string[], - config: TTSConfig, - startIndex: number, - set: (partial: Partial) => 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; - 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; @@ -368,8 +283,6 @@ export const useTTSStore = create()( : [Array.isArray(text) ? text.join(" ").trim() : text.trim()].filter(Boolean); _sessionSegments = sessionSegments; _sessionCurrentIndex = 0; - _sessionGeneration += 1; - detachAndStopAllPlayers(); set({ playState: "loading", currentText: sessionSegments.join(" "), @@ -377,66 +290,52 @@ export const useTTSStore = create()( 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; @@ -476,11 +375,17 @@ export const useTTSStore = create()( 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; @@ -512,24 +417,10 @@ export const useTTSStore = create()( 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) => { diff --git a/packages/core/src/tts/coordinator.test.ts b/packages/core/src/tts/coordinator.test.ts new file mode 100644 index 000000000..6bc874e7a --- /dev/null +++ b/packages/core/src/tts/coordinator.test.ts @@ -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).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).mock.calls).toHaveLength(1); + }); +}); diff --git a/packages/core/src/tts/coordinator.ts b/packages/core/src/tts/coordinator.ts new file mode 100644 index 000000000..3645cb357 --- /dev/null +++ b/packages/core/src/tts/coordinator.ts @@ -0,0 +1,238 @@ +import { SegmentPlanner, type Segment, type SegmentPosition } from "./segment-planner"; +import type { ITTSPlayer, TTSConfig } from "./types"; + +export type TTSCoordinatorStatus = "idle" | "loading" | "playing" | "paused" | "stopping" | "error"; +export interface TTSCoordinatorState { + status: TTSCoordinatorStatus; + sessionId: string | null; + segmentIndex: number; + totalSegments: number; + error?: TTSCoordinatorError; +} +export interface TTSCoordinatorError { + sessionId: string; + provider: string; + segmentIndex: number; + state: TTSCoordinatorStatus; + cause: Error; +} +export interface TTSCoordinatorCallbacks { + onStateChange?(state: TTSCoordinatorState): void; + onSegment?(index: number, total: number): void; + onEnd?(): void; + onError?(error: TTSCoordinatorError): void; +} + +export function normalizeTTSError( + error: unknown, + context: Omit, +): TTSCoordinatorError { + return { ...context, cause: error instanceof Error ? error : new Error(String(error)) }; +} + +export class LegacyPlayerProvider { + constructor( + readonly player: ITTSPlayer, + readonly id: string, + ) {} + async play( + segment: Segment, + config: TTSConfig, + callbacks: { onStart(): void; onEnd(): void; onError(error: unknown): void }, + ): Promise { + this.player.onEnd = callbacks.onEnd; + this.player.onError = callbacks.onError; + let started = false; + let invoking = true; + this.player.onStateChange = (state) => { + if (state === "playing") { + started = true; + callbacks.onStart(); + } else if (state === "stopped" && (started || !invoking)) callbacks.onEnd(); + }; + const result = this.player.speak(segment.text, config); + invoking = false; + await result; + } + async playQueue( + segments: Segment[], + config: TTSConfig, + startIndex: number, + callbacks: { + onStart(): void; + onChunk(index: number, total: number): void; + onEnd(): void; + onError(error: unknown): void; + }, + ): Promise { + let started = false; + let invoking = true; + this.player.onStateChange = (state) => { + if (state === "playing") { + started = true; + callbacks.onStart(); + } else if (state === "stopped" && (started || !invoking)) callbacks.onEnd(); + }; + this.player.onChunkChange = (index, total) => + callbacks.onChunk( + startIndex + index, + Math.max(total + startIndex, segments.length + startIndex), + ); + this.player.onEnd = callbacks.onEnd; + this.player.onError = callbacks.onError; + const result = this.player.speak( + segments.map((segment) => segment.text), + config, + ); + invoking = false; + await result; + } + stop(): void { + this.player.stop(); + } + pause(): void { + this.player.pause(); + } + resume(): void { + this.player.resume(); + } +} + +export class TTSCoordinator { + private state: TTSCoordinatorState = { + status: "idle", + sessionId: null, + segmentIndex: 0, + totalSegments: 0, + }; + private session: { id: string; abort: AbortController } | null = null; + private segments: Segment[] = []; + private currentProvider: LegacyPlayerProvider | null = null; + private readonly planner: SegmentPlanner; + constructor( + private config: TTSConfig, + planner = new SegmentPlanner(), + private readonly callbacks: TTSCoordinatorCallbacks = {}, + ) { + this.planner = planner; + } + getState(): TTSCoordinatorState { + return this.state; + } + updateConfig(config: TTSConfig): void { + this.config = config; + } + play(text: string | string[], provider: LegacyPlayerProvider): void { + this.cancelSession(); + this.segments = this.planner.plan(text); + this.currentProvider = provider; + const session = this.startSession(); + this.state = { + status: "loading", + sessionId: session.id, + segmentIndex: 0, + totalSegments: this.segments.length, + }; + this.emit(); + this.playCurrent(session.id); + } + pause(): void { + if (this.state.status === "playing" || this.state.status === "loading") { + this.currentProvider?.pause(); + this.state = { ...this.state, status: "paused" }; + this.emit(); + } + } + resume(): void { + if (this.state.status === "paused") { + this.currentProvider?.resume(); + this.state = { ...this.state, status: "playing" }; + this.emit(); + } + } + stop(): void { + this.cancelSession(); + this.state = { status: "idle", sessionId: null, segmentIndex: 0, totalSegments: 0 }; + this.emit(); + } + jumpTo(position: SegmentPosition): void { + const index = this.planner.findStart(this.segments, position); + if (index >= 0 && index < this.segments.length) { + this.cancelSession(); + this.state = { ...this.state, status: "loading", segmentIndex: index }; + const session = this.startSession(); + this.state.sessionId = session.id; + this.emit(); + this.playCurrent(session.id); + } + } + private playCurrent(sessionId: string): void { + const segment = this.segments[this.state.segmentIndex]; + const provider = this.currentProvider; + if (!segment || !provider) { + this.stop(); + this.callbacks.onEnd?.(); + return; + } + this.callbacks.onSegment?.(segment.index, this.segments.length); + const callbacks = { + onStart: () => { + if (!this.isCurrent(sessionId)) return; + this.state = { ...this.state, status: "playing" }; + this.emit(); + }, + onChunk: (index: number, total: number) => { + if (!this.isCurrent(sessionId)) return; + this.state = { ...this.state, segmentIndex: index, totalSegments: total }; + this.callbacks.onSegment?.(index, total); + this.emit(); + }, + onEnd: () => { + if (!this.isCurrent(sessionId)) return; + this.stop(); + this.callbacks.onEnd?.(); + }, + onError: (error: unknown) => { + if (!this.isCurrent(sessionId)) return; + const normalized = normalizeTTSError(error, { + sessionId, + provider: provider.id, + segmentIndex: this.state.segmentIndex, + state: this.state.status, + }); + this.state = { ...this.state, status: "error", error: normalized }; + this.emit(); + this.callbacks.onError?.(normalized); + }, + }; + Promise.resolve( + provider.playQueue( + this.segments.slice(this.state.segmentIndex), + this.config, + this.state.segmentIndex, + callbacks, + ), + ).catch(callbacks.onError); + // Providers report the actual start through their player callbacks. Keep + // loading until then so legacy players preserve their startup semantics. + } + private startSession() { + const session = { + id: `${Date.now()}-${Math.random().toString(36).slice(2)}`, + abort: new AbortController(), + }; + this.session = session; + return session; + } + private cancelSession() { + this.session?.abort.abort(); + this.currentProvider?.stop(); + this.session = null; + } + private isCurrent(id: string) { + return this.session?.id === id && !this.session.abort.signal.aborted; + } + private emit() { + this.callbacks.onStateChange?.(this.state); + } +} diff --git a/packages/core/src/tts/index.ts b/packages/core/src/tts/index.ts index 8b0749492..2e6d9e4de 100644 --- a/packages/core/src/tts/index.ts +++ b/packages/core/src/tts/index.ts @@ -39,6 +39,19 @@ export { } from "./text-utils"; export { buildNarrationPreview, getTTSVoiceLabel, splitNarrationText } from "./display"; export { compareVoiceLanguage, getLocaleDisplayLabel, groupEdgeTTSVoices } from "./voice-groups"; +export { SegmentPlanner } from "./segment-planner"; +export type { Segment, SegmentPosition, SegmentPlannerOptions } from "./segment-planner"; +export { + LegacyPlayerProvider, + TTSCoordinator, + normalizeTTSError, +} from "./coordinator"; +export type { + TTSCoordinatorCallbacks, + TTSCoordinatorError, + TTSCoordinatorState, + TTSCoordinatorStatus, +} from "./coordinator"; // Edge TTS export { fetchEdgeTTSAudio, EDGE_TTS_VOICES } from "./edge-tts"; diff --git a/packages/core/src/tts/segment-planner.test.ts b/packages/core/src/tts/segment-planner.test.ts new file mode 100644 index 000000000..c8ca66123 --- /dev/null +++ b/packages/core/src/tts/segment-planner.test.ts @@ -0,0 +1,18 @@ +import { describe, expect, it } from "vitest"; +import { SegmentPlanner } from "./segment-planner"; + +describe("SegmentPlanner", () => { + it("splits text and maps offsets", () => { + const segments = new SegmentPlanner({ maxCharacters: 5 }).plan("abcdef"); + expect(segments.map((segment) => segment.text)).toEqual(["abcde", "f"]); + expect(segments[1].startOffset).toBe(5); + }); + + it("finds a segment by offset or CFI", () => { + const planner = new SegmentPlanner({ maxCharacters: 5 }); + const segments = planner.plan("abcdefghij"); + segments[1].cfi = "epubcfi(/6/2)"; + expect(planner.findStart(segments, { offset: 6 })).toBe(1); + expect(planner.findStart(segments, { cfi: "epubcfi(/6/2)" })).toBe(1); + }); +}); diff --git a/packages/core/src/tts/segment-planner.ts b/packages/core/src/tts/segment-planner.ts new file mode 100644 index 000000000..449943d50 --- /dev/null +++ b/packages/core/src/tts/segment-planner.ts @@ -0,0 +1,73 @@ +export interface Segment { + index: number; + text: string; + startOffset: number; + endOffset: number; + cfi?: string; + chapterIndex?: number; +} + +export interface SegmentPosition { + cfi?: string; + offset?: number; +} + +export interface SegmentPlannerOptions { + maxCharacters?: number; +} + +export class SegmentPlanner { + constructor(private readonly options: SegmentPlannerOptions = {}) {} + + plan(text: string | string[], options: SegmentPlannerOptions = {}): Segment[] { + const maxCharacters = options.maxCharacters ?? this.options.maxCharacters ?? 500; + if (Array.isArray(text)) { + return text + .map((value) => value.trim()) + .filter(Boolean) + .map((value, index) => ({ + index, + text: value, + startOffset: text.slice(0, index).join(" ").length, + endOffset: text.slice(0, index + 1).join(" ").length, + })); + } + const source = text.replace(/\s+/g, " ").trim(); + if (!source) return []; + const result: Segment[] = []; + let offset = 0; + for (const paragraph of source.split(/(?<=[.!?。!?;;])\s+/u)) { + let start = 0; + while (start < paragraph.length) { + const end = Math.min(start + maxCharacters, paragraph.length); + const value = paragraph.slice(start, end).trim(); + if (value) { + const startOffset = offset + start; + result.push({ + index: result.length, + text: value, + startOffset, + endOffset: startOffset + value.length, + }); + } + start = end; + } + offset += paragraph.length + 1; + } + return result; + } + + findStart(segments: Segment[], position: SegmentPosition): number { + if (!segments.length) return -1; + if (position.cfi) { + const match = segments.find((segment) => segment.cfi === position.cfi); + if (match) return match.index; + } + if (position.offset != null) { + const offset = position.offset; + const match = segments.find((segment) => offset < segment.endOffset); + if (match) return match.index; + } + return 0; + } +}