2102 lines
68 KiB
TypeScript
2102 lines
68 KiB
TypeScript
/**
|
||
* Text-to-Speech (TTS) Context Provider
|
||
*
|
||
* This module provides a React context for managing text-to-speech functionality.
|
||
* It handles audio playback, sentence processing, and integration with OpenAI's TTS API.
|
||
*
|
||
* Key features:
|
||
* - Audio playback control (play/pause/skip)
|
||
* - Sentence-by-sentence processing
|
||
* - Audio caching for better performance
|
||
* - Voice and speed control
|
||
* - Document navigation
|
||
*/
|
||
|
||
'use client';
|
||
|
||
import {
|
||
createContext,
|
||
useContext,
|
||
useState,
|
||
useCallback,
|
||
useEffect,
|
||
useRef,
|
||
useMemo,
|
||
ReactNode,
|
||
ReactElement
|
||
} from 'react';
|
||
import { Howl } from 'howler';
|
||
import toast from 'react-hot-toast';
|
||
import { useParams, usePathname } from 'next/navigation';
|
||
|
||
import { useConfig } from '@/contexts/ConfigContext';
|
||
import { useAudioCache } from '@/hooks/audio/useAudioCache';
|
||
import { useVoiceManagement } from '@/hooks/audio/useVoiceManagement';
|
||
import { useMediaSession } from '@/hooks/audio/useMediaSession';
|
||
import { useAudioContext } from '@/hooks/audio/useAudioContext';
|
||
import { getLastDocumentLocation, setLastDocumentLocation } from '@/lib/client/dexie';
|
||
import { getDocumentProgress, scheduleDocumentProgressSync } from '@/lib/client/api/user-state';
|
||
import { withRetry, generateTTS, alignAudio } from '@/lib/client/api/audiobooks';
|
||
import { preprocessSentenceForAudio, splitTextToTtsBlocks, splitTextToTtsBlocksEPUB } from '@/lib/shared/nlp';
|
||
import { isKokoroModel } from '@/lib/shared/kokoro';
|
||
import { supportsNativeModelSpeed, supportsTtsInstructions } from '@/lib/shared/tts-provider-catalog';
|
||
import { useAuthRateLimit } from '@/contexts/AuthRateLimitContext';
|
||
import type {
|
||
TTSLocation,
|
||
TTSSmartMergeResult,
|
||
TTSPageTurnEstimate,
|
||
TTSPlaybackState,
|
||
TTSSentenceAlignment,
|
||
TTSAudioBuffer,
|
||
} from '@/types/tts';
|
||
import type {
|
||
TTSRequestPayload,
|
||
TTSRequestHeaders,
|
||
TTSRetryOptions,
|
||
} from '@/types/client';
|
||
import type { ReaderType } from '@/types/user-state';
|
||
|
||
// Media globals
|
||
declare global {
|
||
interface Window {
|
||
webkitAudioContext: typeof AudioContext;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Interface defining all available methods and properties in the TTS context
|
||
*/
|
||
interface TTSContextType extends TTSPlaybackState {
|
||
// Voice settings
|
||
voice: string;
|
||
availableVoices: string[];
|
||
|
||
// Alignment metadata for the current sentence
|
||
currentSentenceAlignment?: TTSSentenceAlignment;
|
||
currentWordIndex?: number | null;
|
||
|
||
// Control functions
|
||
togglePlay: () => void;
|
||
skipForward: () => void;
|
||
skipBackward: () => void;
|
||
pause: () => void;
|
||
stop: () => void;
|
||
stopAndPlayFromIndex: (index: number) => void;
|
||
setText: (text: string, options?: boolean | SetTextOptions) => void;
|
||
setCurrDocPages: (num: number | undefined) => void;
|
||
setSpeedAndRestart: (speed: number) => void;
|
||
setAudioPlayerSpeedAndRestart: (speed: number) => void;
|
||
setVoiceAndRestart: (voice: string) => void;
|
||
skipToLocation: (location: TTSLocation, shouldPause?: boolean) => void;
|
||
registerLocationChangeHandler: (handler: (location: TTSLocation) => void) => void; // EPUB-only: Handles chapter navigation
|
||
registerVisualPageChangeHandler: (handler: (location: TTSLocation) => void) => void;
|
||
setIsEPUB: (isEPUB: boolean) => void;
|
||
}
|
||
|
||
interface SetTextOptions {
|
||
shouldPause?: boolean;
|
||
location?: TTSLocation;
|
||
nextLocation?: TTSLocation;
|
||
nextText?: string;
|
||
}
|
||
|
||
const CONTINUATION_LOOKAHEAD = 600;
|
||
const MAX_CONTINUATION_CARRY_CHARS = 220;
|
||
const MAX_CONTINUATION_CARRY_WORDS = 40;
|
||
const LOOP_GUARD_MIN_INDEX = 2;
|
||
const LOOP_GUARD_MIN_PROGRESS = 0.6;
|
||
const SENTENCE_ENDING = /[.?!…]["'”’)\]]*\s*$/;
|
||
const wordHighlightFeatureEnabled =
|
||
process.env.NEXT_PUBLIC_ENABLE_WORD_HIGHLIGHT?.toLowerCase() !== 'false';
|
||
|
||
// Tiny silent WAV used to unlock HTML5 audio on iOS/Safari.
|
||
const SILENT_WAV_DATA_URI =
|
||
'data:audio/wav;base64,UklGRkQDAABXQVZFZm10IBAAAAABAAEAQB8AAIA+AAACABAAZGF0YSADAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA==';
|
||
|
||
const normalizeLocationKey = (location: TTSLocation) =>
|
||
typeof location === 'number' ? `num:${location}` : `str:${location}`;
|
||
|
||
const normalizeBlockFingerprint = (text: string): string => {
|
||
const normalized = preprocessSentenceForAudio(text)
|
||
.toLowerCase()
|
||
.replace(/[^a-z0-9\s]/g, ' ')
|
||
.replace(/\s+/g, ' ')
|
||
.trim();
|
||
return normalized.slice(0, 200);
|
||
};
|
||
|
||
const isWhitespaceChar = (char: string) => /\s/.test(char);
|
||
|
||
const skipWhitespace = (source: string, start: number) => {
|
||
let index = start;
|
||
while (index < source.length && isWhitespaceChar(source[index])) {
|
||
index++;
|
||
}
|
||
return index;
|
||
};
|
||
|
||
const matchNormalizedPrefixLength = (text: string, prefix: string): number | null => {
|
||
let textIndex = 0;
|
||
let prefixIndex = 0;
|
||
|
||
while (prefixIndex < prefix.length) {
|
||
const prefixChar = prefix[prefixIndex];
|
||
|
||
if (isWhitespaceChar(prefixChar)) {
|
||
prefixIndex = skipWhitespace(prefix, prefixIndex);
|
||
textIndex = skipWhitespace(text, textIndex);
|
||
continue;
|
||
}
|
||
|
||
if (textIndex >= text.length) {
|
||
return null;
|
||
}
|
||
|
||
const textChar = text[textIndex];
|
||
|
||
if (textChar === prefixChar || textChar.toLowerCase() === prefixChar.toLowerCase()) {
|
||
textIndex++;
|
||
prefixIndex++;
|
||
continue;
|
||
}
|
||
|
||
return null;
|
||
}
|
||
|
||
return textIndex;
|
||
};
|
||
|
||
const needsSentenceContinuation = (text: string) => {
|
||
const trimmed = text.trim();
|
||
if (!trimmed) {
|
||
return false;
|
||
}
|
||
return !SENTENCE_ENDING.test(trimmed);
|
||
};
|
||
|
||
const stripContinuationPrefix = (text: string, prefix: string) => {
|
||
if (!prefix) return { text, removed: false };
|
||
|
||
// Try literal match first since PDF text is normalized already
|
||
if (text.startsWith(prefix)) {
|
||
return {
|
||
text: text.slice(prefix.length).trimStart(),
|
||
removed: true,
|
||
};
|
||
}
|
||
|
||
const trimmedPrefix = prefix.trimStart();
|
||
const trimmedText = text.trimStart();
|
||
|
||
if (trimmedText.startsWith(trimmedPrefix)) {
|
||
const offset = text.length - trimmedText.length;
|
||
return {
|
||
text: text.slice(offset + trimmedPrefix.length).trimStart(),
|
||
removed: true,
|
||
};
|
||
}
|
||
|
||
const matchedLength = matchNormalizedPrefixLength(text, prefix);
|
||
if (matchedLength !== null) {
|
||
return {
|
||
text: text.slice(matchedLength).trimStart(),
|
||
removed: true,
|
||
};
|
||
}
|
||
|
||
return { text, removed: false };
|
||
};
|
||
|
||
const extractContinuationSlice = (nextText: string): TTSSmartMergeResult | null => {
|
||
if (!nextText?.trim()) {
|
||
return null;
|
||
}
|
||
|
||
const snippet = nextText.trim().slice(0, CONTINUATION_LOOKAHEAD);
|
||
let boundaryIndex = -1;
|
||
|
||
for (let i = 0; i < snippet.length; i++) {
|
||
const char = snippet[i];
|
||
if (/[.?!…]/.test(char)) {
|
||
let j = i + 1;
|
||
while (j < snippet.length && /["'”’)\]]/.test(snippet[j])) {
|
||
j++;
|
||
}
|
||
while (j < snippet.length && /\s/.test(snippet[j])) {
|
||
j++;
|
||
}
|
||
boundaryIndex = j;
|
||
break;
|
||
}
|
||
}
|
||
|
||
if (boundaryIndex === -1) {
|
||
return null;
|
||
}
|
||
|
||
const rawSlice = snippet.slice(0, boundaryIndex);
|
||
const charsCappedSlice = rawSlice.slice(0, MAX_CONTINUATION_CARRY_CHARS);
|
||
const words = charsCappedSlice.trim().split(/\s+/).filter(Boolean);
|
||
const wordCapped = words.slice(0, MAX_CONTINUATION_CARRY_WORDS).join(' ');
|
||
const addition = wordCapped.trim();
|
||
|
||
if (!addition) {
|
||
return null;
|
||
}
|
||
|
||
return {
|
||
text: addition,
|
||
carried: addition,
|
||
};
|
||
};
|
||
|
||
const mergeContinuation = (text: string, nextText: string): TTSSmartMergeResult | null => {
|
||
if (!needsSentenceContinuation(text)) {
|
||
return null;
|
||
}
|
||
|
||
const slice = extractContinuationSlice(nextText);
|
||
if (!slice) {
|
||
return null;
|
||
}
|
||
|
||
const trimmed = text.trimEnd();
|
||
const endsWithHyphen = trimmed.endsWith('-');
|
||
const base = endsWithHyphen ? trimmed.slice(0, -1) : trimmed;
|
||
const joiner = endsWithHyphen ? '' : (base ? ' ' : '');
|
||
const mergedText = `${base}${joiner}${slice.text}`.trim();
|
||
|
||
return {
|
||
text: mergedText,
|
||
carried: slice.carried,
|
||
};
|
||
};
|
||
|
||
const buildCacheKey = (
|
||
sentence: string,
|
||
voice: string,
|
||
speed: number,
|
||
provider: string,
|
||
model: string,
|
||
) => {
|
||
return [
|
||
`provider=${provider || ''}`,
|
||
`model=${model || ''}`,
|
||
`voice=${voice || ''}`,
|
||
`speed=${Number.isFinite(speed) ? speed : ''}`,
|
||
`text=${sentence}`,
|
||
].join('|');
|
||
};
|
||
|
||
// Create the context
|
||
const TTSContext = createContext<TTSContextType | undefined>(undefined);
|
||
|
||
/**
|
||
* Main provider component that manages the TTS state and functionality.
|
||
* Handles initialization of OpenAI client, audio context, and media session.
|
||
*
|
||
* @param {Object} props - Component props
|
||
* @param {ReactNode} props.children - Child components to be wrapped by the provider
|
||
* @returns {JSX.Element} TTSProvider component
|
||
*/
|
||
export function TTSProvider({ children }: { children: ReactNode }): ReactElement {
|
||
// Configuration context consumption
|
||
const {
|
||
apiKey: openApiKey,
|
||
baseUrl: openApiBaseUrl,
|
||
isLoading: configIsLoading,
|
||
voiceSpeed,
|
||
audioPlayerSpeed,
|
||
voice: configVoice,
|
||
ttsProvider: configTTSProvider,
|
||
ttsModel: configTTSModel,
|
||
ttsInstructions: configTTSInstructions,
|
||
updateConfigKey,
|
||
skipBlank,
|
||
smartSentenceSplitting,
|
||
pdfHighlightEnabled,
|
||
pdfWordHighlightEnabled,
|
||
epubHighlightEnabled,
|
||
epubWordHighlightEnabled,
|
||
} = useConfig();
|
||
|
||
// Audio and voice management hooks
|
||
const audioContext = useAudioContext();
|
||
const audioCache = useAudioCache(25);
|
||
const { availableVoices, fetchVoices } = useVoiceManagement(openApiKey, openApiBaseUrl, configTTSProvider, configTTSModel);
|
||
const {
|
||
authEnabled,
|
||
onTTSStart,
|
||
onTTSComplete,
|
||
refresh: refreshRateLimit,
|
||
triggerRateLimit,
|
||
isAtLimit,
|
||
} = useAuthRateLimit();
|
||
|
||
// Add ref for location change handler
|
||
const locationChangeHandlerRef = useRef<((location: TTSLocation) => void) | null>(null);
|
||
const visualPageChangeHandlerRef = useRef<((location: TTSLocation) => void) | null>(null);
|
||
|
||
/**
|
||
* Registers a handler function for location changes in EPUB documents
|
||
* This is only used for EPUB documents to handle chapter navigation
|
||
*
|
||
* @param {Function} handler - Function to handle location changes
|
||
*/
|
||
const registerLocationChangeHandler = useCallback((handler: (location: TTSLocation) => void) => {
|
||
locationChangeHandlerRef.current = handler;
|
||
}, []);
|
||
|
||
/**
|
||
* Registers a handler function for visual page changes in EPUB documents
|
||
* This is only used for EPUB documents to handle visual page navigation
|
||
*
|
||
* @param {Function} handler - Function to handle visual page changes
|
||
*/
|
||
const registerVisualPageChangeHandler = useCallback((handler: (location: TTSLocation) => void) => {
|
||
visualPageChangeHandlerRef.current = handler;
|
||
}, []);
|
||
|
||
// Get document ID from URL params
|
||
const { id } = useParams();
|
||
const pathname = usePathname();
|
||
|
||
const currentReaderType: ReaderType = useMemo(() => {
|
||
if (pathname.startsWith('/epub/')) return 'epub';
|
||
if (pathname.startsWith('/html/')) return 'html';
|
||
return 'pdf';
|
||
}, [pathname]);
|
||
|
||
/**
|
||
* State Management
|
||
*/
|
||
const [isPlaying, setIsPlaying] = useState(false);
|
||
const [isEPUB, setIsEPUB] = useState(false);
|
||
const [isProcessing, setIsProcessing] = useState(false);
|
||
|
||
const [currDocPage, setCurrDocPage] = useState<TTSLocation>(1);
|
||
const currDocPageNumber = (!isEPUB ? parseInt(currDocPage.toString()) : 1); // PDF uses numbers only
|
||
const [currDocPages, setCurrDocPages] = useState<number>();
|
||
|
||
const [sentences, setSentences] = useState<string[]>([]);
|
||
const [currentIndex, setCurrentIndex] = useState(0);
|
||
const [activeHowl, setActiveHowl] = useState<Howl | null>(null);
|
||
const [speed, setSpeed] = useState(voiceSpeed);
|
||
const [audioSpeed, setAudioSpeed] = useState(audioPlayerSpeed);
|
||
const [voice, setVoice] = useState(configVoice);
|
||
const [ttsModel, setTTSModel] = useState(configTTSModel);
|
||
const [ttsInstructions, setTTSInstructions] = useState(configTTSInstructions);
|
||
const effectiveNativeSpeed = useMemo(
|
||
() => (supportsNativeModelSpeed(configTTSProvider, ttsModel) ? speed : 1),
|
||
[configTTSProvider, ttsModel, speed],
|
||
);
|
||
|
||
// Track pending preload requests
|
||
const preloadRequests = useRef<Map<string, Promise<string>>>(new Map());
|
||
// Track active abort controllers for TTS requests
|
||
const activeAbortControllers = useRef<Set<AbortController>>(new Set());
|
||
// Synchronous guard to prevent duplicate playAudio calls from the main playback effect.
|
||
// React state updates (isProcessing, activeHowl) are async, so between the effect firing
|
||
// and those guards taking effect, the effect can re-fire and start duplicate playback.
|
||
// This is especially problematic in Firefox where HTML5 Audio events can cause extra renders.
|
||
const playbackInFlightRef = useRef(false);
|
||
// Track if we're restoring from a saved position
|
||
const [pendingRestoreIndex, setPendingRestoreIndex] = useState<number | null>(null);
|
||
// Guard to coalesce rapid restarts and only resume the latest change
|
||
const restartSeqRef = useRef(0);
|
||
// Track continuation slices for PDF/EPUB page transitions
|
||
const continuationCarryRef = useRef<Map<string, string>>(new Map());
|
||
const epubContinuationRef = useRef<string | null>(null);
|
||
const pageTurnEstimateRef = useRef<TTSPageTurnEstimate | null>(null);
|
||
const pageTurnTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||
const pageFirstBlockFingerprintRef = useRef<Map<string, string>>(new Map());
|
||
const sentenceAlignmentCacheRef = useRef<Map<string, TTSSentenceAlignment>>(new Map());
|
||
const [currentSentenceAlignment, setCurrentSentenceAlignment] = useState<TTSSentenceAlignment | undefined>();
|
||
const [currentWordIndex, setCurrentWordIndex] = useState<number | null>(null);
|
||
const isPlayingRef = useRef(false);
|
||
const sentencesRef = useRef<string[]>([]);
|
||
const currentIndexRef = useRef(0);
|
||
const setTextRef = useRef<(text: string, options?: boolean | SetTextOptions) => void>(() => { });
|
||
const prefetchedLocationTextRef = useRef<Map<string, string>>(new Map());
|
||
const pendingNextLocationRef = useRef<TTSLocation | undefined>(undefined);
|
||
|
||
const audioUnlockAttemptRef = useRef(0);
|
||
|
||
// Safari/iOS (HTML5 audio) can spontaneously reset playbackRate to 1. Keep re-applying
|
||
// the desired rate while a sentence is playing.
|
||
const rateWatchdogIntervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||
|
||
const clearRateWatchdog = useCallback(() => {
|
||
if (!rateWatchdogIntervalRef.current) return;
|
||
clearInterval(rateWatchdogIntervalRef.current);
|
||
rateWatchdogIntervalRef.current = null;
|
||
}, []);
|
||
|
||
const applyPlaybackRateToHowl = useCallback((howl: Howl | null) => {
|
||
if (!howl) return;
|
||
|
||
try {
|
||
howl.rate(audioSpeed);
|
||
} catch {
|
||
// ignore
|
||
}
|
||
|
||
// Best-effort: Howler doesn't expose the underlying HTMLAudioElement publicly.
|
||
// This helps on browsers that reset playbackRate/defaultPlaybackRate.
|
||
try {
|
||
const sounds = (howl as unknown as { _sounds?: Array<{ _node?: unknown }> })._sounds;
|
||
const node = sounds?.[0]?._node as unknown;
|
||
if (node && typeof node === 'object') {
|
||
const anyNode = node as { playbackRate?: number; defaultPlaybackRate?: number };
|
||
if (typeof anyNode.playbackRate === 'number') anyNode.playbackRate = audioSpeed;
|
||
if (typeof anyNode.defaultPlaybackRate === 'number') anyNode.defaultPlaybackRate = audioSpeed;
|
||
}
|
||
} catch {
|
||
// ignore
|
||
}
|
||
}, [audioSpeed]);
|
||
|
||
const startRateWatchdog = useCallback((howl: Howl | null) => {
|
||
if (!howl) return;
|
||
clearRateWatchdog();
|
||
|
||
// Apply immediately + keep applying while playback is active.
|
||
applyPlaybackRateToHowl(howl);
|
||
rateWatchdogIntervalRef.current = setInterval(() => {
|
||
applyPlaybackRateToHowl(howl);
|
||
}, 250);
|
||
}, [applyPlaybackRateToHowl, clearRateWatchdog]);
|
||
|
||
const unlockPlaybackOnUserGesture = useCallback(() => {
|
||
// Best-effort; safe to call multiple times.
|
||
audioUnlockAttemptRef.current += 1;
|
||
const attempt = audioUnlockAttemptRef.current;
|
||
|
||
try {
|
||
void audioContext?.resume();
|
||
} catch {
|
||
// ignore
|
||
}
|
||
|
||
try {
|
||
const el = new Audio(SILENT_WAV_DATA_URI);
|
||
try {
|
||
el.setAttribute('playsinline', 'true');
|
||
} catch {
|
||
// ignore
|
||
}
|
||
el.preload = 'auto';
|
||
el.volume = 0;
|
||
|
||
const p = el.play();
|
||
if (p && typeof (p as Promise<void>).then === 'function') {
|
||
void (p as Promise<void>)
|
||
.then(() => {
|
||
if (audioUnlockAttemptRef.current !== attempt) return;
|
||
try {
|
||
el.pause();
|
||
el.currentTime = 0;
|
||
} catch {
|
||
// ignore
|
||
}
|
||
})
|
||
.catch(() => {
|
||
// ignore
|
||
});
|
||
}
|
||
} catch {
|
||
// ignore
|
||
}
|
||
}, [audioContext]);
|
||
|
||
const isAutoplayBlockedError = useCallback((err: unknown) => {
|
||
const msg = (() => {
|
||
if (typeof err === 'string') return err;
|
||
if (err instanceof Error) return err.message;
|
||
if (typeof err === 'object' && err !== null && 'message' in err) {
|
||
const maybe = (err as { message?: unknown }).message;
|
||
if (typeof maybe === 'string') return maybe;
|
||
}
|
||
return '';
|
||
})();
|
||
|
||
return /notallowed|not allowed|user gesture|interaction|autoplay|play\(\) failed/i.test(msg);
|
||
}, []);
|
||
|
||
useEffect(() => {
|
||
sentencesRef.current = sentences;
|
||
}, [sentences]);
|
||
|
||
useEffect(() => {
|
||
currentIndexRef.current = currentIndex;
|
||
}, [currentIndex]);
|
||
|
||
useEffect(() => {
|
||
isPlayingRef.current = isPlaying;
|
||
}, [isPlaying]);
|
||
|
||
/**
|
||
* Processes text into sentences using the shared NLP utility
|
||
*
|
||
* @param {string} text - The text to be processed
|
||
* @returns {Promise<string[]>} Array of processed sentences
|
||
*/
|
||
const splitTextToTtsBlocksLocal = useCallback(async (text: string): Promise<string[]> => {
|
||
if (text.length < 1) {
|
||
return [];
|
||
}
|
||
|
||
// Use the shared utility directly instead of making an API call
|
||
return isEPUB ? splitTextToTtsBlocksEPUB(text) : splitTextToTtsBlocks(text);
|
||
}, [isEPUB]);
|
||
|
||
/**
|
||
* Stops the current audio playback and clears the active Howl instance
|
||
* @param {boolean} [clearPending=false] - Whether to clear pending requests
|
||
*/
|
||
const abortAudio = useCallback((clearPending = false) => {
|
||
clearRateWatchdog();
|
||
if (activeHowl) {
|
||
activeHowl.stop();
|
||
activeHowl.unload();
|
||
setActiveHowl(null);
|
||
}
|
||
|
||
if (clearPending) {
|
||
activeAbortControllers.current.forEach(controller => {
|
||
controller.abort();
|
||
});
|
||
activeAbortControllers.current.clear();
|
||
preloadRequests.current.clear();
|
||
}
|
||
|
||
if (pageTurnTimeoutRef.current) {
|
||
clearTimeout(pageTurnTimeoutRef.current);
|
||
pageTurnTimeoutRef.current = null;
|
||
}
|
||
setCurrentWordIndex(null);
|
||
}, [activeHowl, clearRateWatchdog]);
|
||
|
||
/**
|
||
* Pauses the current audio playback while preserving seek position.
|
||
*/
|
||
const pauseActiveHowl = useCallback(() => {
|
||
clearRateWatchdog();
|
||
if (activeHowl) {
|
||
try {
|
||
activeHowl.pause();
|
||
} catch (error) {
|
||
console.warn('Error pausing audio:', error);
|
||
}
|
||
}
|
||
|
||
if (pageTurnTimeoutRef.current) {
|
||
clearTimeout(pageTurnTimeoutRef.current);
|
||
pageTurnTimeoutRef.current = null;
|
||
}
|
||
|
||
playbackInFlightRef.current = false;
|
||
setIsProcessing(false);
|
||
|
||
if ('mediaSession' in navigator) {
|
||
navigator.mediaSession.playbackState = 'paused';
|
||
}
|
||
}, [activeHowl, clearRateWatchdog]);
|
||
|
||
/**
|
||
* Pauses the current audio playback
|
||
* Used for external control of playback state
|
||
*/
|
||
const pause = useCallback(() => {
|
||
pauseActiveHowl();
|
||
setIsPlaying(false);
|
||
}, [pauseActiveHowl]);
|
||
|
||
/**
|
||
* Navigates to a specific location in the document
|
||
* Works for both PDF pages and EPUB locations
|
||
*
|
||
* @param {string | number} location - The target location to navigate to
|
||
* @param {boolean} shouldPause - Whether to pause playback
|
||
*/
|
||
const skipToLocation = useCallback((location: TTSLocation, shouldPause = false) => {
|
||
// Reset state for new content in correct order
|
||
abortAudio();
|
||
if (shouldPause) setIsPlaying(false);
|
||
setCurrentIndex(0);
|
||
setSentences([]);
|
||
setCurrDocPage(location);
|
||
|
||
}, [abortAudio]);
|
||
|
||
/**
|
||
* Moves to the next or previous sentence
|
||
*
|
||
* @param {boolean} [backwards=false] - Whether to move backwards
|
||
*/
|
||
const advance = useCallback(async (backwards = false) => {
|
||
const nextIndex = currentIndex + (backwards ? -1 : 1);
|
||
const movingForward = !backwards && nextIndex >= sentences.length;
|
||
|
||
// Handle within current page bounds
|
||
if (nextIndex < sentences.length && nextIndex >= 0) {
|
||
if (
|
||
!isEPUB &&
|
||
!backwards &&
|
||
currDocPages !== undefined &&
|
||
currDocPageNumber < currDocPages &&
|
||
nextIndex >= LOOP_GUARD_MIN_INDEX &&
|
||
sentences.length > 0
|
||
) {
|
||
const locationKey = normalizeLocationKey(currDocPageNumber);
|
||
const cachedFirstFingerprint = pageFirstBlockFingerprintRef.current.get(locationKey)
|
||
?? normalizeBlockFingerprint(sentences[0] || '');
|
||
const nextFingerprint = normalizeBlockFingerprint(sentences[nextIndex] || '');
|
||
const progress = sentences.length > 1 ? nextIndex / (sentences.length - 1) : 0;
|
||
|
||
if (
|
||
cachedFirstFingerprint &&
|
||
nextFingerprint &&
|
||
cachedFirstFingerprint === nextFingerprint &&
|
||
progress >= LOOP_GUARD_MIN_PROGRESS
|
||
) {
|
||
const targetLocation = currDocPageNumber + 1;
|
||
prefetchedLocationTextRef.current.delete(normalizeLocationKey(targetLocation));
|
||
pendingNextLocationRef.current = targetLocation;
|
||
continuationCarryRef.current.delete(normalizeLocationKey(targetLocation));
|
||
skipToLocation(targetLocation);
|
||
return;
|
||
}
|
||
}
|
||
|
||
setCurrentIndex(nextIndex);
|
||
return;
|
||
}
|
||
|
||
// For EPUB documents, always try to advance to next/prev section
|
||
if (isEPUB && locationChangeHandlerRef.current) {
|
||
if (movingForward && typeof document !== 'undefined' && document.hidden) {
|
||
const targetLocation = pendingNextLocationRef.current;
|
||
if (targetLocation !== undefined) {
|
||
const bufferKey = normalizeLocationKey(targetLocation);
|
||
const prefetchedText = prefetchedLocationTextRef.current.get(bufferKey);
|
||
if (prefetchedText?.trim()) {
|
||
prefetchedLocationTextRef.current.delete(bufferKey);
|
||
pendingNextLocationRef.current = undefined;
|
||
setCurrDocPage(targetLocation);
|
||
setTextRef.current(prefetchedText, {
|
||
shouldPause: false,
|
||
location: targetLocation,
|
||
});
|
||
// Ask the viewer to continue turning pages/sections; this may be deferred while hidden.
|
||
locationChangeHandlerRef.current('next');
|
||
return;
|
||
}
|
||
}
|
||
}
|
||
locationChangeHandlerRef.current(nextIndex >= sentences.length ? 'next' : 'prev');
|
||
return;
|
||
}
|
||
|
||
// For PDFs and other documents, check page bounds
|
||
if (!isEPUB) {
|
||
// Handle next/previous page transitions
|
||
if ((nextIndex >= sentences.length && currDocPageNumber < currDocPages!) ||
|
||
(nextIndex < 0 && currDocPageNumber > 1)) {
|
||
const targetLocation = currDocPageNumber + (nextIndex >= sentences.length ? 1 : -1);
|
||
|
||
// In background tabs, page text extraction can be delayed. If we already have
|
||
// prefetched text for the target page, keep speaking without waiting for viewer callbacks.
|
||
if (movingForward && typeof document !== 'undefined' && document.hidden) {
|
||
const bufferKey = normalizeLocationKey(targetLocation);
|
||
const prefetchedText = prefetchedLocationTextRef.current.get(bufferKey);
|
||
if (prefetchedText?.trim()) {
|
||
prefetchedLocationTextRef.current.delete(bufferKey);
|
||
pendingNextLocationRef.current = undefined;
|
||
setCurrDocPage(targetLocation);
|
||
setTextRef.current(prefetchedText, {
|
||
shouldPause: false,
|
||
location: targetLocation,
|
||
});
|
||
return;
|
||
}
|
||
}
|
||
|
||
// Pass wasPlaying to maintain playback state during page turn
|
||
skipToLocation(targetLocation);
|
||
return;
|
||
}
|
||
|
||
// Handle end of document (PDF only)
|
||
if (nextIndex >= sentences.length && currDocPageNumber >= currDocPages!) {
|
||
setIsPlaying(false);
|
||
}
|
||
}
|
||
}, [currentIndex, sentences, currDocPageNumber, currDocPages, isEPUB, skipToLocation]);
|
||
|
||
/**
|
||
* Handles blank text sections based on document type
|
||
*
|
||
* @param {string[]} sentences - Array of processed sentences
|
||
* @returns {boolean} - True if blank section was handled
|
||
*/
|
||
const handleBlankSection = useCallback((text: string): boolean => {
|
||
if (!isPlaying || !skipBlank || text.length > 0) {
|
||
return false;
|
||
}
|
||
|
||
// Use advance to handle navigation for both EPUB and PDF
|
||
advance();
|
||
|
||
toast.success(isEPUB ? 'Skipping blank section' : `Skipping blank page ${currDocPageNumber}`, {
|
||
id: isEPUB ? `epub-section-skip` : `page-${currDocPageNumber}`,
|
||
style: {
|
||
background: 'var(--background)',
|
||
color: 'var(--accent)',
|
||
},
|
||
duration: 1000,
|
||
position: 'top-center',
|
||
});
|
||
|
||
return true;
|
||
}, [isPlaying, skipBlank, advance, isEPUB, currDocPageNumber]);
|
||
|
||
/**
|
||
* Sets the current text and splits it into sentences
|
||
*
|
||
* @param {string} text - The text to be processed
|
||
*/
|
||
const setText = useCallback((text: string, options?: boolean | SetTextOptions) => {
|
||
const normalizedOptions: SetTextOptions = typeof options === 'boolean'
|
||
? { shouldPause: options }
|
||
: (options || {});
|
||
|
||
let workingText = text;
|
||
|
||
// Apply or clear sentence continuation logic based on config
|
||
let continuationCarried: string | undefined;
|
||
if (smartSentenceSplitting) {
|
||
if (isEPUB && epubContinuationRef.current) {
|
||
const { text: strippedText, removed } = stripContinuationPrefix(workingText, epubContinuationRef.current);
|
||
workingText = strippedText;
|
||
if (removed) {
|
||
epubContinuationRef.current = null;
|
||
}
|
||
}
|
||
|
||
if (!isEPUB && normalizedOptions.location !== undefined) {
|
||
const key = normalizeLocationKey(normalizedOptions.location);
|
||
const carried = continuationCarryRef.current.get(key);
|
||
if (carried) {
|
||
const { text: strippedText, removed } = stripContinuationPrefix(workingText, carried);
|
||
workingText = strippedText;
|
||
if (removed) {
|
||
continuationCarryRef.current.delete(key);
|
||
}
|
||
}
|
||
}
|
||
|
||
if (normalizedOptions.nextText) {
|
||
const merged = mergeContinuation(workingText, normalizedOptions.nextText);
|
||
if (merged) {
|
||
workingText = merged.text;
|
||
continuationCarried = merged.carried;
|
||
}
|
||
}
|
||
|
||
if (continuationCarried) {
|
||
if (isEPUB) {
|
||
epubContinuationRef.current = continuationCarried;
|
||
} else if (normalizedOptions.nextLocation !== undefined) {
|
||
continuationCarryRef.current.set(
|
||
normalizeLocationKey(normalizedOptions.nextLocation),
|
||
continuationCarried
|
||
);
|
||
}
|
||
}
|
||
} else {
|
||
// When disabled, clear any stale continuation state
|
||
epubContinuationRef.current = null;
|
||
continuationCarryRef.current.clear();
|
||
pageTurnEstimateRef.current = null;
|
||
}
|
||
|
||
// Keep the next-location text around so background page turns can continue when
|
||
// viewer/location callbacks are throttled by the browser.
|
||
prefetchedLocationTextRef.current.clear();
|
||
pendingNextLocationRef.current = normalizedOptions.nextLocation;
|
||
if (normalizedOptions.nextLocation !== undefined && normalizedOptions.nextText?.trim()) {
|
||
prefetchedLocationTextRef.current.set(
|
||
normalizeLocationKey(normalizedOptions.nextLocation),
|
||
normalizedOptions.nextText
|
||
);
|
||
}
|
||
|
||
// Check for blank section after adjustments
|
||
if (handleBlankSection(workingText)) return;
|
||
|
||
const shouldPause = normalizedOptions.shouldPause ?? false;
|
||
|
||
// Keep track of previous state and pause playback
|
||
const wasPlaying = isPlaying;
|
||
setIsPlaying(false);
|
||
abortAudio(true); // Clear pending requests since text is changing
|
||
setIsProcessing(true); // Set processing state before text processing starts
|
||
|
||
splitTextToTtsBlocksLocal(workingText)
|
||
.then(newSentences => {
|
||
if (newSentences.length === 0) {
|
||
console.warn('No sentences found in text');
|
||
setIsProcessing(false);
|
||
return;
|
||
}
|
||
|
||
if (!isEPUB && typeof normalizedOptions.location === 'number') {
|
||
const firstFingerprint = normalizeBlockFingerprint(newSentences[0] || '');
|
||
if (firstFingerprint) {
|
||
pageFirstBlockFingerprintRef.current.set(
|
||
normalizeLocationKey(normalizedOptions.location),
|
||
firstFingerprint
|
||
);
|
||
}
|
||
}
|
||
|
||
// Set all state updates in a predictable order
|
||
setSentences(newSentences);
|
||
|
||
// Check if we have a pending restore index for PDF
|
||
if (pendingRestoreIndex !== null && !isEPUB) {
|
||
const restoreIndex = Math.min(pendingRestoreIndex, newSentences.length - 1);
|
||
console.log(`Restoring sentence index: ${restoreIndex}`);
|
||
setCurrentIndex(restoreIndex);
|
||
setPendingRestoreIndex(null); // Clear the pending restore
|
||
} else {
|
||
setCurrentIndex(0);
|
||
}
|
||
|
||
// Reset alignment state whenever the text block changes
|
||
sentenceAlignmentCacheRef.current.clear();
|
||
setCurrentSentenceAlignment(undefined);
|
||
setCurrentWordIndex(null);
|
||
|
||
// Compute auto page-turn estimate for PDFs when we have a continuation
|
||
if (smartSentenceSplitting && !isEPUB && continuationCarried && normalizedOptions.nextLocation !== undefined) {
|
||
const continuationNormalized = preprocessSentenceForAudio(continuationCarried);
|
||
if (continuationNormalized) {
|
||
let bestEstimate: TTSPageTurnEstimate | null = null;
|
||
|
||
newSentences.forEach((sentence, index) => {
|
||
const normalizedSentence = preprocessSentenceForAudio(sentence);
|
||
if (!normalizedSentence) return;
|
||
|
||
if (!normalizedSentence.toLowerCase().endsWith(continuationNormalized.toLowerCase())) return;
|
||
|
||
const totalLength = normalizedSentence.length;
|
||
const continuationLength = continuationNormalized.length;
|
||
if (totalLength <= continuationLength) return;
|
||
|
||
const baseLength = totalLength - continuationLength;
|
||
const fraction = baseLength / totalLength;
|
||
if (fraction <= 0 || fraction >= 1) return;
|
||
|
||
bestEstimate = {
|
||
location: normalizedOptions.nextLocation!,
|
||
sentenceIndex: index,
|
||
fraction,
|
||
};
|
||
});
|
||
|
||
pageTurnEstimateRef.current = bestEstimate;
|
||
} else {
|
||
pageTurnEstimateRef.current = null;
|
||
}
|
||
} else {
|
||
pageTurnEstimateRef.current = null;
|
||
}
|
||
|
||
setIsProcessing(false);
|
||
|
||
// Restore playback state if needed
|
||
if (!shouldPause && wasPlaying) {
|
||
setIsPlaying(true);
|
||
}
|
||
})
|
||
.catch(error => {
|
||
console.warn('Error processing text:', error);
|
||
setIsProcessing(false);
|
||
toast.error('Failed to process text', {
|
||
duration: 3000,
|
||
});
|
||
});
|
||
}, [isPlaying, handleBlankSection, abortAudio, splitTextToTtsBlocksLocal, pendingRestoreIndex, isEPUB, smartSentenceSplitting]);
|
||
|
||
useEffect(() => {
|
||
setTextRef.current = setText;
|
||
}, [setText]);
|
||
|
||
/**
|
||
* Toggles the playback state between playing and paused
|
||
*/
|
||
const togglePlay = useCallback(() => {
|
||
if (isPlaying) {
|
||
pauseActiveHowl();
|
||
setIsPlaying(false);
|
||
return;
|
||
}
|
||
|
||
// Ensure audio is unlocked while we're still in the click/tap handler.
|
||
unlockPlaybackOnUserGesture();
|
||
|
||
// Resume current sentence if we already have a paused Howl.
|
||
if (activeHowl) {
|
||
applyPlaybackRateToHowl(activeHowl);
|
||
playbackInFlightRef.current = true;
|
||
try {
|
||
activeHowl.play();
|
||
setIsPlaying(true);
|
||
return;
|
||
} catch (error) {
|
||
console.warn('Error resuming audio:', error);
|
||
playbackInFlightRef.current = false;
|
||
setActiveHowl(null);
|
||
}
|
||
}
|
||
|
||
setIsPlaying(true);
|
||
}, [activeHowl, applyPlaybackRateToHowl, isPlaying, pauseActiveHowl, unlockPlaybackOnUserGesture]);
|
||
|
||
|
||
/**
|
||
* Moves forward one sentence in the text
|
||
*/
|
||
const skipForward = useCallback(async () => {
|
||
// Only show processing state if we're currently playing
|
||
if (isPlaying) {
|
||
setIsProcessing(true);
|
||
}
|
||
abortAudio(false); // Don't clear pending requests
|
||
await advance();
|
||
}, [isPlaying, abortAudio, advance]);
|
||
|
||
/**
|
||
* Moves backward one sentence in the text
|
||
*/
|
||
const skipBackward = useCallback(async () => {
|
||
// Only show processing state if we're currently playing
|
||
if (isPlaying) {
|
||
setIsProcessing(true);
|
||
}
|
||
abortAudio(false); // Don't clear pending requests
|
||
await advance(true);
|
||
}, [isPlaying, abortAudio, advance]);
|
||
|
||
/**
|
||
* Updates the voice and speed settings from the configuration
|
||
*/
|
||
const updateVoiceAndSpeed = useCallback(() => {
|
||
setVoice(configVoice);
|
||
setSpeed(voiceSpeed);
|
||
setAudioSpeed(audioPlayerSpeed);
|
||
}, [configVoice, voiceSpeed, audioPlayerSpeed]);
|
||
|
||
/**
|
||
* Initializes configuration and fetches available voices
|
||
*/
|
||
useEffect(() => {
|
||
if (!configIsLoading) {
|
||
fetchVoices();
|
||
updateVoiceAndSpeed();
|
||
setTTSModel(configTTSModel);
|
||
setTTSInstructions(configTTSInstructions);
|
||
}
|
||
}, [configIsLoading, openApiKey, openApiBaseUrl, updateVoiceAndSpeed, fetchVoices, configTTSModel, configTTSInstructions]);
|
||
|
||
/**
|
||
* Validates that the current voice is in the available voices list
|
||
* If voice is empty or invalid, use the first available voice (only in local state, don't save)
|
||
*/
|
||
useEffect(() => {
|
||
if (availableVoices.length > 0) {
|
||
// Allow Kokoro multi-voice strings (e.g., "voice1(0.5)+voice2(0.5)") for any provider
|
||
const isKokoro = isKokoroModel(configTTSModel);
|
||
const fallbackVoice = configVoice || availableVoices[0];
|
||
|
||
if (isKokoro) {
|
||
// If Kokoro and we have any voice string (including plus/weights), don't override it.
|
||
// Only default when local voice is empty.
|
||
if (!voice) {
|
||
setVoice(fallbackVoice);
|
||
}
|
||
return;
|
||
}
|
||
|
||
// For non-Kokoro, only force a fallback when there is no active local voice.
|
||
// If a persisted config voice exists, keep it rather than overriding from a
|
||
// potentially stale in-flight voices response during reload.
|
||
if (!voice) {
|
||
console.log(`Voice "${voice || '(empty)'}" not set. Using "${fallbackVoice}"`);
|
||
setVoice(fallbackVoice);
|
||
return;
|
||
}
|
||
|
||
if (!configVoice && !availableVoices.includes(voice)) {
|
||
console.log(`Voice "${voice || '(empty)'}" not found in available voices. Using "${availableVoices[0]}"`);
|
||
setVoice(availableVoices[0]);
|
||
// Don't save to config - just use it temporarily until user explicitly selects one
|
||
}
|
||
}
|
||
}, [availableVoices, voice, configVoice, configTTSModel]);
|
||
|
||
/**
|
||
* Generates and plays audio for the current sentence
|
||
*
|
||
* @param {string} sentence - The sentence to generate audio for
|
||
* @returns {Promise<TTSAudioBuffer | undefined>} The generated audio buffer
|
||
*/
|
||
const getAudio = useCallback(async (sentence: string, preload = false): Promise<TTSAudioBuffer | undefined> => {
|
||
const alignmentEnabledForCurrentDoc =
|
||
wordHighlightFeatureEnabled &&
|
||
((!isEPUB && pdfHighlightEnabled && pdfWordHighlightEnabled) ||
|
||
(isEPUB && epubHighlightEnabled && epubWordHighlightEnabled));
|
||
// Helper to ensure we have an alignment for a given
|
||
// sentence/audio pair, even when the audio itself is
|
||
// served from the local cache.
|
||
const ensureAlignment = (arrayBuffer: TTSAudioBuffer) => {
|
||
if (!alignmentEnabledForCurrentDoc) return;
|
||
const alignmentKey = buildCacheKey(
|
||
sentence,
|
||
voice,
|
||
effectiveNativeSpeed,
|
||
configTTSProvider,
|
||
ttsModel,
|
||
);
|
||
if (sentenceAlignmentCacheRef.current.has(alignmentKey)) return;
|
||
|
||
try {
|
||
const audioBytes = Array.from(new Uint8Array(arrayBuffer));
|
||
const alignmentBody = {
|
||
text: sentence,
|
||
audio: audioBytes,
|
||
};
|
||
|
||
void alignAudio(alignmentBody)
|
||
.then(async (data) => {
|
||
if (!data || !Array.isArray(data.alignments) || !data.alignments[0]) {
|
||
return;
|
||
}
|
||
const alignment = data.alignments[0] as TTSSentenceAlignment;
|
||
sentenceAlignmentCacheRef.current.set(alignmentKey, alignment);
|
||
|
||
const currentSentence = sentencesRef.current[currentIndexRef.current];
|
||
if (currentSentence === sentence) {
|
||
setCurrentSentenceAlignment(alignment);
|
||
setCurrentWordIndex(null);
|
||
}
|
||
})
|
||
.catch((err) => {
|
||
console.warn('Alignment request failed:', err);
|
||
});
|
||
} catch (err) {
|
||
console.warn('Failed to start alignment request:', err);
|
||
}
|
||
};
|
||
|
||
const audioCacheKey = buildCacheKey(
|
||
sentence,
|
||
voice,
|
||
effectiveNativeSpeed,
|
||
configTTSProvider,
|
||
ttsModel,
|
||
);
|
||
|
||
// Check if the audio is already cached
|
||
const cachedAudio = audioCache.get(audioCacheKey);
|
||
if (cachedAudio) {
|
||
console.log('Using cached audio for sentence:', sentence.substring(0, 20));
|
||
// If we have audio but no alignment (e.g. after a
|
||
// navigation or TTS reset), kick off a fresh alignment
|
||
// request using the cached audio buffer.
|
||
ensureAlignment(cachedAudio);
|
||
return cachedAudio;
|
||
}
|
||
|
||
// If the user is already out of quota, avoid spamming requests.
|
||
// Cached audio above is still allowed to play.
|
||
if (isAtLimit) {
|
||
if (!preload) {
|
||
setIsPlaying(false);
|
||
}
|
||
return undefined;
|
||
}
|
||
|
||
try {
|
||
console.log('Requesting audio for sentence:', sentence);
|
||
|
||
// Create an AbortController for this request
|
||
const controller = new AbortController();
|
||
activeAbortControllers.current.add(controller);
|
||
|
||
const reqHeaders: TTSRequestHeaders = {
|
||
'Content-Type': 'application/json',
|
||
'x-openai-key': openApiKey || '',
|
||
'x-tts-provider': configTTSProvider,
|
||
};
|
||
if (openApiBaseUrl) {
|
||
reqHeaders['x-openai-base-url'] = openApiBaseUrl;
|
||
}
|
||
|
||
const reqBody: TTSRequestPayload = {
|
||
text: sentence,
|
||
voice,
|
||
speed: effectiveNativeSpeed,
|
||
format: 'mp3',
|
||
model: ttsModel,
|
||
instructions: supportsTtsInstructions(ttsModel) ? ttsInstructions : undefined,
|
||
};
|
||
|
||
// Allow one narrow client retry for transient browser->/api/tts transport failures.
|
||
// HTTP failures are not retried client-side.
|
||
const retryOptions: TTSRetryOptions = {
|
||
maxRetries: 2,
|
||
initialDelay: 300,
|
||
maxDelay: 300,
|
||
};
|
||
|
||
onTTSStart();
|
||
let arrayBuffer: TTSAudioBuffer;
|
||
try {
|
||
arrayBuffer = await withRetry(
|
||
async () => {
|
||
return await generateTTS(reqBody, reqHeaders, controller.signal);
|
||
},
|
||
retryOptions
|
||
);
|
||
} finally {
|
||
onTTSComplete();
|
||
}
|
||
|
||
// Remove the controller once the request is complete
|
||
activeAbortControllers.current.delete(controller);
|
||
|
||
// Cache the array buffer
|
||
audioCache.set(audioCacheKey, arrayBuffer);
|
||
|
||
// Fire-and-forget alignment request; do not block audio playback
|
||
ensureAlignment(arrayBuffer);
|
||
|
||
return arrayBuffer;
|
||
} catch (error) {
|
||
const status = (() => {
|
||
if (typeof error === 'object' && error !== null && 'status' in error) {
|
||
const maybe = (error as { status?: unknown }).status;
|
||
return typeof maybe === 'number' ? maybe : undefined;
|
||
}
|
||
return undefined;
|
||
})();
|
||
|
||
const code = (() => {
|
||
if (typeof error === 'object' && error !== null && 'code' in error) {
|
||
const maybe = (error as { code?: unknown }).code;
|
||
return typeof maybe === 'string' ? maybe : undefined;
|
||
}
|
||
return undefined;
|
||
})();
|
||
|
||
// Check if this was an abort error
|
||
if (error instanceof Error && error.name === 'AbortError') {
|
||
console.log('TTS request aborted:', sentence.substring(0, 20));
|
||
return;
|
||
}
|
||
|
||
// If a preload request fails, we should not flip the global playback state.
|
||
// Otherwise the UI can lose the pause button while the current sentence
|
||
// continues playing.
|
||
if (!preload) {
|
||
setIsPlaying(false);
|
||
}
|
||
|
||
// Handle daily quota exceeded (429 + Problem Details code)
|
||
if (status === 429 && code === 'USER_DAILY_QUOTA_EXCEEDED') {
|
||
// Avoid noisy toasts from background preloading; keep the user-facing error for active playback.
|
||
if (!preload) {
|
||
toast.error('Daily TTS limit reached.', {
|
||
id: 'tts-limit-error',
|
||
duration: 5000,
|
||
});
|
||
}
|
||
triggerRateLimit();
|
||
refreshRateLimit().catch(console.error);
|
||
// Do NOT re-throw, just return undefined to stop playback gracefully
|
||
return undefined;
|
||
}
|
||
|
||
// Avoid noisy toasts from background preloading.
|
||
if (!preload) {
|
||
toast.error('TTS failed. Skipped sentence and paused.', {
|
||
id: 'tts-api-error',
|
||
duration: 7000,
|
||
});
|
||
}
|
||
throw error;
|
||
}
|
||
}, [
|
||
voice,
|
||
effectiveNativeSpeed,
|
||
ttsModel,
|
||
ttsInstructions,
|
||
audioCache,
|
||
openApiKey,
|
||
openApiBaseUrl,
|
||
configTTSProvider,
|
||
isEPUB,
|
||
pdfHighlightEnabled,
|
||
pdfWordHighlightEnabled,
|
||
epubHighlightEnabled,
|
||
epubWordHighlightEnabled,
|
||
triggerRateLimit,
|
||
refreshRateLimit,
|
||
onTTSComplete,
|
||
onTTSStart,
|
||
isAtLimit
|
||
]);
|
||
|
||
/**
|
||
* Processes and plays the current sentence
|
||
*
|
||
* @param {string} sentence - The sentence to process
|
||
* @param {boolean} [preload=false] - Whether this is a preload request
|
||
* @returns {Promise<string>} The URL of the processed audio
|
||
*/
|
||
const processSentence = useCallback(async (sentence: string, preload = false): Promise<string> => {
|
||
if (!audioContext) throw new Error('Audio context not initialized');
|
||
|
||
// Check if there's a pending preload request for this sentence
|
||
const pendingRequest = preloadRequests.current.get(sentence);
|
||
if (pendingRequest) {
|
||
console.log('Using pending preload request for:', sentence.substring(0, 20));
|
||
setIsProcessing(true); // Show processing state when using pending request
|
||
// If this is not a preload request, remove it from the pending map
|
||
if (!preload) {
|
||
preloadRequests.current.delete(sentence);
|
||
}
|
||
return pendingRequest;
|
||
}
|
||
|
||
// Only set processing state if not preloading
|
||
if (!preload) setIsProcessing(true);
|
||
|
||
// Create the audio processing promise
|
||
const processPromise = (async () => {
|
||
try {
|
||
const audioBuffer = await getAudio(sentence, preload);
|
||
if (!audioBuffer) {
|
||
// If quota or other handled error returns undefined, ensure we don't throw "No audio data"
|
||
// Just return empty string to signal graceful failure/skip
|
||
return '';
|
||
}
|
||
|
||
// Convert to base64 data URI
|
||
const bytes = new Uint8Array(audioBuffer);
|
||
const binaryString = bytes.reduce((acc, byte) => acc + String.fromCharCode(byte), '');
|
||
const base64String = btoa(binaryString);
|
||
return `data:audio/mp3;base64,${base64String}`;
|
||
} catch (error) {
|
||
setIsProcessing(false);
|
||
throw error;
|
||
}
|
||
})();
|
||
|
||
// If this is a preload request, store it in the map
|
||
if (preload) {
|
||
preloadRequests.current.set(sentence, processPromise);
|
||
// Clean up the map entry once the promise resolves or rejects
|
||
void processPromise
|
||
.finally(() => {
|
||
preloadRequests.current.delete(sentence);
|
||
})
|
||
.catch(() => {
|
||
// Prevent unhandled rejections from the cleanup-only chained promise.
|
||
});
|
||
}
|
||
|
||
return processPromise;
|
||
}, [audioContext, getAudio]);
|
||
|
||
/**
|
||
* Plays the current sentence with Howl
|
||
*
|
||
* @param {string} sentence - The sentence to play
|
||
*/
|
||
const playSentenceWithHowl = useCallback(async (sentence: string, sentenceIndex: number) => {
|
||
if (!sentence) {
|
||
console.log('No sentence to play');
|
||
playbackInFlightRef.current = false;
|
||
setIsProcessing(false);
|
||
return;
|
||
}
|
||
|
||
const MAX_RETRIES = 3;
|
||
const INITIAL_RETRY_DELAY = 1000; // 1 second
|
||
|
||
const createHowl = async (retryCount = 0): Promise<Howl | null> => {
|
||
let playErrorAttempts = 0;
|
||
// Get the processed audio data URI directly from processSentence
|
||
const audioDataUri = await processSentence(sentence);
|
||
if (!audioDataUri) {
|
||
// Graceful exit for rate limit / abort / intentionally skipped sentence
|
||
console.log('Skipping playback for sentence (no audio generated)');
|
||
return null;
|
||
}
|
||
|
||
// Force unload any previous Howl instance to free up resources
|
||
if (activeHowl) {
|
||
activeHowl.unload();
|
||
}
|
||
|
||
// Guard against Firefox firing onend/onstop multiple times for the same Audio element.
|
||
let howlFinished = false;
|
||
|
||
return new Howl({
|
||
src: [audioDataUri],
|
||
format: ['mp3', 'mpeg'],
|
||
html5: true,
|
||
preload: true,
|
||
// We never need overlapping playback for a single sentence. Keeping this low avoids
|
||
// Safari/HTML5 Audio pool exhaustion when retries happen.
|
||
pool: 1,
|
||
rate: audioSpeed,
|
||
onload: function (this: Howl) {
|
||
applyPlaybackRateToHowl(this);
|
||
const estimate = pageTurnEstimateRef.current;
|
||
if (!estimate || estimate.sentenceIndex !== sentenceIndex) return;
|
||
if (!visualPageChangeHandlerRef.current) return;
|
||
|
||
const duration = this.duration();
|
||
if (!duration || !Number.isFinite(duration)) return;
|
||
|
||
const delayMs = duration * estimate.fraction * 1000;
|
||
if (delayMs <= 0 || delayMs >= duration * 1000) return;
|
||
|
||
if (pageTurnTimeoutRef.current) {
|
||
clearTimeout(pageTurnTimeoutRef.current);
|
||
}
|
||
|
||
pageTurnTimeoutRef.current = setTimeout(() => {
|
||
if (!isPlaying) return;
|
||
const currentEstimate = pageTurnEstimateRef.current;
|
||
if (!currentEstimate || currentEstimate.sentenceIndex !== sentenceIndex) return;
|
||
visualPageChangeHandlerRef.current?.(currentEstimate.location);
|
||
}, delayMs);
|
||
},
|
||
onplay: function (this: Howl) {
|
||
setIsProcessing(false);
|
||
startRateWatchdog(this);
|
||
if ('mediaSession' in navigator) {
|
||
navigator.mediaSession.playbackState = 'playing';
|
||
}
|
||
},
|
||
onpause: function () {
|
||
clearRateWatchdog();
|
||
playbackInFlightRef.current = false;
|
||
setIsProcessing(false);
|
||
if (pageTurnTimeoutRef.current) {
|
||
clearTimeout(pageTurnTimeoutRef.current);
|
||
pageTurnTimeoutRef.current = null;
|
||
}
|
||
setIsPlaying(false);
|
||
if ('mediaSession' in navigator) {
|
||
navigator.mediaSession.playbackState = 'paused';
|
||
}
|
||
},
|
||
onplayerror: function (this: Howl, soundId, error) {
|
||
const actualError = error ?? soundId;
|
||
console.warn('Howl playback error:', actualError);
|
||
|
||
// Common on iOS/Safari when the actual play() call happens after awaiting TTS.
|
||
// Do not skip/advance in this case; just pause and tell the user to tap play again.
|
||
if (isAutoplayBlockedError(actualError)) {
|
||
howlFinished = true;
|
||
playbackInFlightRef.current = false;
|
||
setIsProcessing(false);
|
||
setActiveHowl(null);
|
||
try {
|
||
this.unload();
|
||
} catch {
|
||
// ignore unload errors
|
||
}
|
||
setIsPlaying(false);
|
||
|
||
toast.error('Playback was blocked by your browser. Tap play again to start.', {
|
||
id: 'tts-playback-blocked',
|
||
duration: 4000,
|
||
});
|
||
return;
|
||
}
|
||
|
||
playErrorAttempts += 1;
|
||
|
||
// Avoid looping for many seconds on Safari: if playback still fails after a single
|
||
// recovery attempt, skip the sentence and pause.
|
||
if (playErrorAttempts > 1) {
|
||
howlFinished = true;
|
||
playbackInFlightRef.current = false;
|
||
setIsProcessing(false);
|
||
setActiveHowl(null);
|
||
this.unload();
|
||
setIsPlaying(false);
|
||
|
||
toast.error('Audio playback failed. Skipped sentence and paused.', {
|
||
id: 'tts-playback-error',
|
||
duration: 4000,
|
||
});
|
||
|
||
advance();
|
||
return;
|
||
}
|
||
|
||
// Try to recover by reloading once.
|
||
if (this.state() === 'loaded') {
|
||
this.unload();
|
||
this.once('load', () => this.play());
|
||
this.load();
|
||
}
|
||
},
|
||
onloaderror: async function (this: Howl, soundId, error) {
|
||
const actualError = error ?? soundId;
|
||
console.warn(`Error loading audio (attempt ${retryCount + 1}/${MAX_RETRIES}):`, actualError);
|
||
|
||
if (retryCount < MAX_RETRIES) {
|
||
// Calculate exponential backoff delay
|
||
const delay = INITIAL_RETRY_DELAY * Math.pow(2, retryCount);
|
||
console.log(`Retrying in ${delay}ms...`);
|
||
|
||
// Free the current Howl/audio objects before retrying to avoid pool exhaustion.
|
||
try {
|
||
this.unload();
|
||
} catch {
|
||
// ignore unload errors
|
||
}
|
||
|
||
// Wait for the delay
|
||
await new Promise(resolve => setTimeout(resolve, delay));
|
||
|
||
// Try to create a new Howl instance
|
||
try {
|
||
const retryHowl = await createHowl(retryCount + 1);
|
||
if (retryHowl) {
|
||
setActiveHowl(retryHowl);
|
||
retryHowl.play();
|
||
} else {
|
||
// No audio generated (quota/abort). Stop cleanly without spamming errors.
|
||
howlFinished = true;
|
||
playbackInFlightRef.current = false;
|
||
setIsProcessing(false);
|
||
setActiveHowl(null);
|
||
setIsPlaying(false);
|
||
}
|
||
} catch (err) {
|
||
console.error('Error creating Howl instance:', err);
|
||
howlFinished = true;
|
||
playbackInFlightRef.current = false;
|
||
setIsProcessing(false);
|
||
setActiveHowl(null);
|
||
setIsPlaying(false);
|
||
|
||
toast.error('Audio loading failed after retries. Moving to next sentence...', {
|
||
id: 'audio-load-error',
|
||
duration: 2000,
|
||
});
|
||
|
||
advance();
|
||
}
|
||
} else {
|
||
console.error('Max retries reached, moving to next sentence');
|
||
howlFinished = true;
|
||
playbackInFlightRef.current = false;
|
||
setIsProcessing(false);
|
||
setActiveHowl(null);
|
||
this.unload();
|
||
setIsPlaying(false);
|
||
|
||
toast.error('Audio loading failed after retries. Moving to next sentence...', {
|
||
id: 'audio-load-error',
|
||
duration: 2000,
|
||
});
|
||
|
||
advance();
|
||
}
|
||
},
|
||
onend: function (this: Howl) {
|
||
if (howlFinished) return; // Deduplicate – Firefox can fire ended twice
|
||
howlFinished = true;
|
||
clearRateWatchdog();
|
||
this.unload();
|
||
playbackInFlightRef.current = false;
|
||
setActiveHowl(null);
|
||
if (pageTurnTimeoutRef.current) {
|
||
clearTimeout(pageTurnTimeoutRef.current);
|
||
pageTurnTimeoutRef.current = null;
|
||
}
|
||
if (isPlaying) {
|
||
advance();
|
||
}
|
||
},
|
||
onstop: function (this: Howl) {
|
||
if (howlFinished) return;
|
||
howlFinished = true;
|
||
clearRateWatchdog();
|
||
playbackInFlightRef.current = false;
|
||
setIsProcessing(false);
|
||
this.unload();
|
||
}
|
||
});
|
||
};
|
||
|
||
try {
|
||
const howl = await createHowl();
|
||
if (!howl) {
|
||
// No audio generated (quota hit / aborted / intentionally skipped). Stop cleanly without
|
||
// advancing or spamming errors.
|
||
playbackInFlightRef.current = false;
|
||
setActiveHowl(null);
|
||
setIsProcessing(false);
|
||
setIsPlaying(false);
|
||
return null;
|
||
}
|
||
|
||
setActiveHowl(howl);
|
||
return howl;
|
||
} catch (error) {
|
||
console.error('Error playing TTS:', error);
|
||
playbackInFlightRef.current = false;
|
||
setActiveHowl(null);
|
||
setIsProcessing(false);
|
||
|
||
// Skip the sentence but pause playback (user can resume manually).
|
||
abortAudio(true);
|
||
setIsPlaying(false);
|
||
advance();
|
||
return null;
|
||
}
|
||
}, [
|
||
abortAudio,
|
||
isPlaying,
|
||
advance,
|
||
activeHowl,
|
||
processSentence,
|
||
audioSpeed,
|
||
isAutoplayBlockedError,
|
||
applyPlaybackRateToHowl,
|
||
startRateWatchdog,
|
||
clearRateWatchdog,
|
||
]);
|
||
|
||
const playAudio = useCallback(async () => {
|
||
const sentence = sentences[currentIndex];
|
||
const alignmentKey = buildCacheKey(
|
||
sentence,
|
||
voice,
|
||
effectiveNativeSpeed,
|
||
configTTSProvider,
|
||
ttsModel,
|
||
);
|
||
const cachedAlignment = sentenceAlignmentCacheRef.current.get(alignmentKey);
|
||
if (cachedAlignment) {
|
||
setCurrentSentenceAlignment(cachedAlignment);
|
||
setCurrentWordIndex(null);
|
||
} else {
|
||
setCurrentSentenceAlignment(undefined);
|
||
setCurrentWordIndex(null);
|
||
}
|
||
|
||
const howl = await playSentenceWithHowl(sentence, currentIndex);
|
||
if (howl) {
|
||
if (!isPlayingRef.current) {
|
||
playbackInFlightRef.current = false;
|
||
return;
|
||
}
|
||
howl.play();
|
||
}
|
||
}, [sentences, currentIndex, playSentenceWithHowl, voice, effectiveNativeSpeed, configTTSProvider, ttsModel]);
|
||
|
||
// Keep the current playback rate applied to the active Howl. Some browsers (notably
|
||
// iOS Safari with HTML5 audio) can reset playbackRate after initial load/play.
|
||
useEffect(() => {
|
||
if (!activeHowl) return;
|
||
applyPlaybackRateToHowl(activeHowl);
|
||
if (isPlaying) {
|
||
startRateWatchdog(activeHowl);
|
||
}
|
||
}, [activeHowl, audioSpeed, applyPlaybackRateToHowl, isPlaying, startRateWatchdog]);
|
||
|
||
// Track the current word index during playback using Howler's seek position
|
||
useEffect(() => {
|
||
if (!activeHowl || !isPlaying || !currentSentenceAlignment || !currentSentenceAlignment.words.length) {
|
||
setCurrentWordIndex(null);
|
||
return;
|
||
}
|
||
|
||
let frameId: number;
|
||
|
||
const tick = () => {
|
||
try {
|
||
const pos = activeHowl.seek() as number;
|
||
if (typeof pos === 'number' && Number.isFinite(pos)) {
|
||
const words = currentSentenceAlignment.words;
|
||
let idx = -1;
|
||
for (let i = 0; i < words.length; i++) {
|
||
const w = words[i];
|
||
if (pos >= w.startSec && pos < w.endSec) {
|
||
idx = i;
|
||
break;
|
||
}
|
||
}
|
||
if (idx !== -1) {
|
||
setCurrentWordIndex((prev) => (prev === idx ? prev : idx));
|
||
}
|
||
}
|
||
} catch {
|
||
// ignore seek errors
|
||
}
|
||
frameId = requestAnimationFrame(tick);
|
||
};
|
||
|
||
frameId = requestAnimationFrame(tick);
|
||
|
||
return () => {
|
||
if (frameId) {
|
||
cancelAnimationFrame(frameId);
|
||
}
|
||
};
|
||
}, [activeHowl, isPlaying, currentSentenceAlignment]);
|
||
|
||
/**
|
||
* Preloads upcoming sentences sequentially.
|
||
* As soon as one preload finishes, the next one starts (up to lookahead window).
|
||
*/
|
||
const preloadNextAudio = useCallback(() => {
|
||
const PRELOAD_LOOKAHEAD = 3;
|
||
if (isAtLimit) return;
|
||
|
||
const preloadFromOffset = (offset: number) => {
|
||
if (offset > PRELOAD_LOOKAHEAD) return;
|
||
|
||
const sentenceIndex = currentIndex + offset;
|
||
const nextSentence = sentences[sentenceIndex];
|
||
if (!nextSentence) return;
|
||
|
||
const nextKey = buildCacheKey(
|
||
nextSentence,
|
||
voice,
|
||
effectiveNativeSpeed,
|
||
configTTSProvider,
|
||
ttsModel,
|
||
);
|
||
|
||
if (audioCache.has(nextKey)) {
|
||
preloadFromOffset(offset + 1);
|
||
return;
|
||
}
|
||
|
||
const pending = preloadRequests.current.get(nextSentence);
|
||
if (pending) {
|
||
void pending
|
||
.finally(() => {
|
||
preloadFromOffset(offset + 1);
|
||
})
|
||
.catch(() => {
|
||
// Prevent unhandled rejections from the chained preload progression.
|
||
});
|
||
return;
|
||
}
|
||
|
||
void processSentence(nextSentence, true)
|
||
.catch((error) => {
|
||
const status = (() => {
|
||
if (typeof error === 'object' && error !== null && 'status' in error) {
|
||
const maybe = (error as { status?: unknown }).status;
|
||
return typeof maybe === 'number' ? maybe : undefined;
|
||
}
|
||
return undefined;
|
||
})();
|
||
const code = (() => {
|
||
if (typeof error === 'object' && error !== null && 'code' in error) {
|
||
const maybe = (error as { code?: unknown }).code;
|
||
return typeof maybe === 'string' ? maybe : undefined;
|
||
}
|
||
return undefined;
|
||
})();
|
||
// Ignore quota errors during preload.
|
||
if (!(status === 429 && code === 'USER_DAILY_QUOTA_EXCEEDED')) {
|
||
console.error(`Error preloading sentence at offset ${offset}:`, error);
|
||
}
|
||
})
|
||
.finally(() => {
|
||
preloadFromOffset(offset + 1);
|
||
});
|
||
};
|
||
|
||
try {
|
||
preloadFromOffset(1);
|
||
} catch (error) {
|
||
console.error('Error initiating preload:', error);
|
||
}
|
||
}, [isAtLimit, currentIndex, sentences, audioCache, processSentence, voice, effectiveNativeSpeed, configTTSProvider, ttsModel]);
|
||
|
||
/**
|
||
* Main Playback Driver
|
||
* Controls the flow of audio playback and sentence processing
|
||
*/
|
||
useEffect(() => {
|
||
if (!isPlaying) {
|
||
playbackInFlightRef.current = false;
|
||
return;
|
||
}
|
||
if (!sentences[currentIndex]) return; // Don't proceed if no sentence to play
|
||
// Single synchronous guard: covers the entire window from when playAudio() is
|
||
// called until the Howl finishes (onend/onstop/error). React state guards
|
||
// (isProcessing, activeHowl) are async and leave a micro-task gap that allows
|
||
// the effect to re-fire and start duplicate playback — especially in Firefox
|
||
// where HTML5 Audio events can trigger extra renders.
|
||
if (playbackInFlightRef.current) return;
|
||
playbackInFlightRef.current = true;
|
||
|
||
// Start playing current sentence
|
||
playAudio();
|
||
|
||
// Start background lookahead preloading for upcoming sentences.
|
||
preloadNextAudio();
|
||
|
||
return () => {
|
||
// Only abort if we're actually stopping playback
|
||
if (!isPlaying) {
|
||
abortAudio();
|
||
}
|
||
};
|
||
}, [
|
||
isPlaying,
|
||
currentIndex,
|
||
sentences,
|
||
playAudio,
|
||
preloadNextAudio,
|
||
abortAudio
|
||
]);
|
||
|
||
/**
|
||
* Stops the current audio playback and resets all state
|
||
*/
|
||
const stop = useCallback(() => {
|
||
// Cancel any ongoing request
|
||
abortAudio();
|
||
playbackInFlightRef.current = false;
|
||
locationChangeHandlerRef.current = null;
|
||
epubContinuationRef.current = null;
|
||
continuationCarryRef.current.clear();
|
||
pageFirstBlockFingerprintRef.current.clear();
|
||
setIsPlaying(false);
|
||
setCurrentIndex(0);
|
||
setSentences([]);
|
||
setCurrDocPage(1);
|
||
setCurrDocPages(undefined);
|
||
setIsProcessing(false);
|
||
setIsEPUB(false);
|
||
sentenceAlignmentCacheRef.current.clear();
|
||
setCurrentSentenceAlignment(undefined);
|
||
setCurrentWordIndex(null);
|
||
}, [abortAudio]);
|
||
|
||
/**
|
||
* Stops the current audio playback and starts playing from a specified index
|
||
*
|
||
* @param {number} index - The index to start playing from
|
||
*/
|
||
const stopAndPlayFromIndex = useCallback((index: number) => {
|
||
abortAudio();
|
||
|
||
// Same autoplay-unlock issue as togglePlay when starting from a fresh load.
|
||
unlockPlaybackOnUserGesture();
|
||
|
||
setCurrentIndex(index);
|
||
setIsPlaying(true);
|
||
}, [abortAudio, unlockPlaybackOnUserGesture]);
|
||
|
||
/**
|
||
* Sets the speed and restarts the playback
|
||
*
|
||
* @param {number} newSpeed - The new speed to set
|
||
*/
|
||
const setSpeedAndRestart = useCallback((newSpeed: number) => {
|
||
const wasPlaying = isPlaying;
|
||
|
||
// Bump restart sequence to invalidate older restarts
|
||
const mySeq = ++restartSeqRef.current;
|
||
|
||
// Set a flag to prevent double audio requests during config update
|
||
setIsProcessing(true);
|
||
|
||
// First stop any current playback
|
||
setIsPlaying(false);
|
||
abortAudio(true); // Clear pending requests since speed changed
|
||
setActiveHowl(null);
|
||
|
||
// Update speed and config
|
||
setSpeed(newSpeed);
|
||
|
||
// Update config after state changes
|
||
updateConfigKey('voiceSpeed', newSpeed).then(() => {
|
||
setIsProcessing(false);
|
||
// Resume playback if it was playing before and this is the latest restart
|
||
if (wasPlaying && mySeq === restartSeqRef.current) {
|
||
setIsPlaying(true);
|
||
}
|
||
});
|
||
}, [abortAudio, updateConfigKey, isPlaying]);
|
||
|
||
/**
|
||
* Sets the voice and restarts the playback
|
||
*
|
||
* @param {string} newVoice - The new voice to set
|
||
*/
|
||
const setVoiceAndRestart = useCallback((newVoice: string) => {
|
||
const wasPlaying = isPlaying;
|
||
|
||
// Bump restart sequence to invalidate older restarts
|
||
const mySeq = ++restartSeqRef.current;
|
||
|
||
// Set a flag to prevent double audio requests during config update
|
||
setIsProcessing(true);
|
||
|
||
// First stop any current playback
|
||
setIsPlaying(false);
|
||
abortAudio(true); // Clear pending requests since voice changed
|
||
setActiveHowl(null);
|
||
|
||
// Update voice and config
|
||
setVoice(newVoice);
|
||
|
||
// Update config after state changes
|
||
updateConfigKey('voice', newVoice).then(() => {
|
||
setIsProcessing(false);
|
||
// Resume playback if it was playing before and this is the latest restart
|
||
if (wasPlaying && mySeq === restartSeqRef.current) {
|
||
setIsPlaying(true);
|
||
}
|
||
});
|
||
}, [abortAudio, updateConfigKey, isPlaying]);
|
||
|
||
/**
|
||
* Sets the audio player speed and restarts the playback
|
||
*
|
||
* @param {number} newSpeed - The new audio player speed to set
|
||
*/
|
||
const setAudioPlayerSpeedAndRestart = useCallback((newSpeed: number) => {
|
||
const wasPlaying = isPlaying;
|
||
|
||
// Bump restart sequence to invalidate older restarts
|
||
const mySeq = ++restartSeqRef.current;
|
||
|
||
// Set a flag to prevent double audio requests during config update
|
||
setIsProcessing(true);
|
||
|
||
// First stop any current playback
|
||
setIsPlaying(false);
|
||
abortAudio(true); // Clear pending requests since speed changed
|
||
setActiveHowl(null);
|
||
|
||
// Update audio speed and config
|
||
setAudioSpeed(newSpeed);
|
||
|
||
// Update config after state changes
|
||
updateConfigKey('audioPlayerSpeed', newSpeed).then(() => {
|
||
setIsProcessing(false);
|
||
// Resume playback if it was playing before and this is the latest restart
|
||
if (wasPlaying && mySeq === restartSeqRef.current) {
|
||
setIsPlaying(true);
|
||
}
|
||
});
|
||
}, [abortAudio, updateConfigKey, isPlaying]);
|
||
|
||
/**
|
||
* Provides the TTS context value to child components
|
||
*/
|
||
const value = useMemo(() => ({
|
||
isPlaying,
|
||
isProcessing,
|
||
currentSentence: sentences[currentIndex] || '',
|
||
currentSentenceAlignment,
|
||
currentWordIndex,
|
||
currDocPage,
|
||
currDocPageNumber,
|
||
currDocPages,
|
||
voice,
|
||
availableVoices,
|
||
togglePlay,
|
||
skipForward,
|
||
skipBackward,
|
||
stop,
|
||
pause,
|
||
stopAndPlayFromIndex,
|
||
setText,
|
||
setCurrDocPages,
|
||
setSpeedAndRestart,
|
||
setAudioPlayerSpeedAndRestart,
|
||
setVoiceAndRestart,
|
||
skipToLocation,
|
||
registerLocationChangeHandler,
|
||
registerVisualPageChangeHandler,
|
||
setIsEPUB
|
||
}), [
|
||
isPlaying,
|
||
isProcessing,
|
||
sentences,
|
||
currentIndex,
|
||
currDocPage,
|
||
currDocPageNumber,
|
||
currDocPages,
|
||
voice,
|
||
availableVoices,
|
||
togglePlay,
|
||
skipForward,
|
||
skipBackward,
|
||
stop,
|
||
pause,
|
||
stopAndPlayFromIndex,
|
||
setText,
|
||
setCurrDocPages,
|
||
setSpeedAndRestart,
|
||
setAudioPlayerSpeedAndRestart,
|
||
setVoiceAndRestart,
|
||
skipToLocation,
|
||
registerLocationChangeHandler,
|
||
registerVisualPageChangeHandler,
|
||
setIsEPUB,
|
||
currentSentenceAlignment,
|
||
currentWordIndex
|
||
]);
|
||
|
||
// Use media session hook
|
||
useMediaSession({
|
||
togglePlay,
|
||
skipForward,
|
||
skipBackward,
|
||
});
|
||
|
||
// Load last location on mount for both EPUB and PDF.
|
||
// Prefer server-backed progress when available, then fall back to local Dexie.
|
||
useEffect(() => {
|
||
if (!id) return;
|
||
|
||
let cancelled = false;
|
||
const docId = id as string;
|
||
|
||
const applyLocation = (lastLocation: string) => {
|
||
console.log('Setting last location:', lastLocation);
|
||
|
||
if (isEPUB && locationChangeHandlerRef.current) {
|
||
// For EPUB documents, use the location change handler
|
||
locationChangeHandlerRef.current(lastLocation);
|
||
return;
|
||
}
|
||
|
||
if (!isEPUB) {
|
||
// For PDF documents, parse the location as "page:sentence"
|
||
try {
|
||
const [pageStr, sentenceIndexStr] = lastLocation.split(':');
|
||
const page = parseInt(pageStr, 10);
|
||
const sentenceIndex = parseInt(sentenceIndexStr, 10);
|
||
|
||
if (!isNaN(page) && !isNaN(sentenceIndex)) {
|
||
console.log(`Restoring PDF position: page ${page}, sentence ${sentenceIndex}`);
|
||
// Skip to the page first, then the sentence index will be restored when setText is called
|
||
setCurrDocPage(page);
|
||
// Store the sentence index to be used when text is loaded
|
||
setPendingRestoreIndex(sentenceIndex);
|
||
}
|
||
} catch (error) {
|
||
console.warn('Error parsing PDF location:', error);
|
||
}
|
||
}
|
||
};
|
||
|
||
const load = async () => {
|
||
if (authEnabled) {
|
||
try {
|
||
const remote = await getDocumentProgress(docId);
|
||
if (!cancelled && remote?.location) {
|
||
await setLastDocumentLocation(docId, remote.location).catch((error) => {
|
||
console.warn('Error caching remote location locally:', error);
|
||
});
|
||
applyLocation(remote.location);
|
||
return;
|
||
}
|
||
} catch (error) {
|
||
console.warn('Error loading remote progress:', error);
|
||
}
|
||
}
|
||
|
||
try {
|
||
const local = await getLastDocumentLocation(docId);
|
||
if (!cancelled && local) {
|
||
applyLocation(local);
|
||
}
|
||
} catch (error) {
|
||
console.warn('Error loading local last location:', error);
|
||
}
|
||
};
|
||
|
||
load();
|
||
|
||
return () => {
|
||
cancelled = true;
|
||
};
|
||
}, [id, isEPUB, currentReaderType, authEnabled]);
|
||
|
||
// Save current position periodically for PDFs
|
||
useEffect(() => {
|
||
if (id && !isEPUB && sentences.length > 0) {
|
||
const location = `${currDocPageNumber}:${currentIndex}`;
|
||
const timeoutId = setTimeout(() => {
|
||
console.log(`Saving PDF position: ${location}`);
|
||
setLastDocumentLocation(id as string, location).catch(error => {
|
||
console.warn('Error saving PDF location:', error);
|
||
});
|
||
if (authEnabled) {
|
||
scheduleDocumentProgressSync({
|
||
documentId: id as string,
|
||
readerType: currentReaderType,
|
||
location,
|
||
});
|
||
}
|
||
}, 1000); // Debounce saves by 1 second
|
||
|
||
return () => clearTimeout(timeoutId);
|
||
}
|
||
}, [id, isEPUB, currDocPageNumber, currentIndex, sentences.length, currentReaderType, authEnabled]);
|
||
|
||
/**
|
||
* Renders the TTS context provider with its children
|
||
*
|
||
* @param {ReactNode} children - Child components to be wrapped
|
||
* @returns {JSX.Element}
|
||
*/
|
||
return (
|
||
<TTSContext.Provider value={value}>
|
||
{children}
|
||
</TTSContext.Provider>
|
||
);
|
||
}
|
||
|
||
/**
|
||
* Custom hook to consume the TTS context
|
||
* Ensures the context is used within a TTSProvider
|
||
*
|
||
* @throws {Error} If used outside of TTSProvider
|
||
* @returns {TTSContextType} The TTS context value
|
||
*/
|
||
export function useTTS() {
|
||
const context = useContext(TTSContext);
|
||
if (context === undefined) {
|
||
throw new Error('useTTS must be used within a TTSProvider');
|
||
}
|
||
return context;
|
||
}
|