Add ZIP-based user data download endpoint, enable AES-256 server-side S3 encryption for all stored documents and audiobooks. Rewrite privacy policy with detailed CCPA categories, service provider disclosures (Vercel/Neon/Railway), and user rights sections. Replace privacy modal with checkbox agreement flow.
1853 lines
59 KiB
TypeScript
1853 lines
59 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/dexie';
|
|
import { getDocumentProgress, scheduleDocumentProgressSync } from '@/lib/client-user-state';
|
|
import { useBackgroundState } from '@/hooks/audio/useBackgroundState';
|
|
import { withRetry, generateTTS, alignAudio } from '@/lib/client';
|
|
import { preprocessSentenceForAudio, splitTextToTtsBlocks, splitTextToTtsBlocksEPUB } from '@/lib/nlp';
|
|
import { isKokoroModel } from '@/lib/kokoro';
|
|
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
|
|
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 SENTENCE_ENDING = /[.?!…]["'”’)\]]*\s*$/;
|
|
const wordHighlightFeatureEnabled = process.env.NEXT_PUBLIC_ENABLE_WORD_HIGHLIGHT === 'true';
|
|
|
|
// 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 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 addition = rawSlice.trim();
|
|
|
|
if (!addition) {
|
|
return null;
|
|
}
|
|
|
|
return {
|
|
text: addition,
|
|
carried: rawSlice,
|
|
};
|
|
};
|
|
|
|
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);
|
|
|
|
// 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());
|
|
// 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 sentenceAlignmentCacheRef = useRef<Map<string, TTSSentenceAlignment>>(new Map());
|
|
const [currentSentenceAlignment, setCurrentSentenceAlignment] = useState<TTSSentenceAlignment | undefined>();
|
|
const [currentWordIndex, setCurrentWordIndex] = useState<number | null>(null);
|
|
const sentencesRef = useRef<string[]>([]);
|
|
const currentIndexRef = useRef(0);
|
|
|
|
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]);
|
|
|
|
/**
|
|
* 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
|
|
* Used for external control of playback state
|
|
*/
|
|
const pause = useCallback(() => {
|
|
abortAudio();
|
|
setIsPlaying(false);
|
|
}, [abortAudio]);
|
|
|
|
/**
|
|
* 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);
|
|
|
|
// Handle within current page bounds
|
|
if (nextIndex < sentences.length && nextIndex >= 0) {
|
|
setCurrentIndex(nextIndex);
|
|
return;
|
|
}
|
|
|
|
// For EPUB documents, always try to advance to next/prev section
|
|
if (isEPUB && locationChangeHandlerRef.current) {
|
|
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)) {
|
|
// Pass wasPlaying to maintain playback state during page turn
|
|
skipToLocation(currDocPageNumber + (nextIndex >= sentences.length ? 1 : -1));
|
|
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;
|
|
}
|
|
|
|
// 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;
|
|
}
|
|
|
|
// 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]);
|
|
|
|
/**
|
|
* Toggles the playback state between playing and paused
|
|
*/
|
|
const togglePlay = useCallback(() => {
|
|
if (isPlaying) {
|
|
abortAudio();
|
|
setIsPlaying(false);
|
|
return;
|
|
}
|
|
|
|
// Ensure audio is unlocked while we're still in the click/tap handler.
|
|
unlockPlaybackOnUserGesture();
|
|
setIsPlaying(true);
|
|
}, [abortAudio, isPlaying, 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);
|
|
|
|
if (isKokoro) {
|
|
// If Kokoro and we have any voice string (including plus/weights), don't override it.
|
|
// Only default when voice is empty.
|
|
if (!voice) {
|
|
setVoice(availableVoices[0]);
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (!voice || !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, 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,
|
|
speed,
|
|
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,
|
|
speed,
|
|
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,
|
|
model: ttsModel,
|
|
instructions: ttsModel === 'gpt-4o-mini-tts' ? ttsInstructions : undefined,
|
|
};
|
|
|
|
const retryOptions: TTSRetryOptions = {
|
|
maxRetries: 3,
|
|
initialDelay: 1000,
|
|
maxDelay: 5000,
|
|
backoffFactor: 2
|
|
};
|
|
|
|
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,
|
|
speed,
|
|
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');
|
|
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();
|
|
}
|
|
|
|
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';
|
|
}
|
|
},
|
|
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)) {
|
|
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) {
|
|
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.
|
|
setIsProcessing(false);
|
|
setActiveHowl(null);
|
|
setIsPlaying(false);
|
|
}
|
|
} catch (err) {
|
|
console.error('Error creating Howl instance:', err);
|
|
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');
|
|
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) {
|
|
clearRateWatchdog();
|
|
this.unload();
|
|
setActiveHowl(null);
|
|
if (pageTurnTimeoutRef.current) {
|
|
clearTimeout(pageTurnTimeoutRef.current);
|
|
pageTurnTimeoutRef.current = null;
|
|
}
|
|
if (isPlaying) {
|
|
advance();
|
|
}
|
|
},
|
|
onstop: function (this: Howl) {
|
|
clearRateWatchdog();
|
|
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.
|
|
setActiveHowl(null);
|
|
setIsProcessing(false);
|
|
setIsPlaying(false);
|
|
return null;
|
|
}
|
|
|
|
setActiveHowl(howl);
|
|
return howl;
|
|
} catch (error) {
|
|
console.error('Error playing TTS:', error);
|
|
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,
|
|
speed,
|
|
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) {
|
|
howl.play();
|
|
}
|
|
}, [sentences, currentIndex, playSentenceWithHowl, voice, speed, 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]);
|
|
|
|
// Place useBackgroundState after playAudio is defined
|
|
const isBackgrounded = useBackgroundState({
|
|
activeHowl,
|
|
isPlaying,
|
|
playAudio,
|
|
});
|
|
|
|
// 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 the next sentence's audio
|
|
*/
|
|
const preloadNextAudio = useCallback(async () => {
|
|
if (isAtLimit) return;
|
|
try {
|
|
const nextSentence = sentences[currentIndex + 1];
|
|
if (nextSentence) {
|
|
const nextKey = buildCacheKey(
|
|
nextSentence,
|
|
voice,
|
|
speed,
|
|
configTTSProvider,
|
|
ttsModel,
|
|
);
|
|
|
|
if (!audioCache.has(nextKey) && !preloadRequests.current.has(nextSentence)) {
|
|
// Start preloading but don't wait for it to complete
|
|
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 next sentence:', error);
|
|
}
|
|
});
|
|
}
|
|
}
|
|
} catch (error) {
|
|
console.error('Error initiating preload:', error);
|
|
}
|
|
}, [isAtLimit, currentIndex, sentences, audioCache, processSentence, voice, speed, configTTSProvider, ttsModel]);
|
|
|
|
/**
|
|
* Main Playback Driver
|
|
* Controls the flow of audio playback and sentence processing
|
|
*/
|
|
useEffect(() => {
|
|
if (!isPlaying) return; // Don't proceed if stopped
|
|
if (isProcessing) return; // Don't proceed if processing audio
|
|
if (!sentences[currentIndex]) return; // Don't proceed if no sentence to play
|
|
if (activeHowl) return; // Don't proceed if audio is already playing
|
|
if (isBackgrounded) return; // Don't proceed if backgrounded
|
|
|
|
// Start playing current sentence
|
|
playAudio();
|
|
|
|
// Start preloading next sentence in parallel
|
|
preloadNextAudio();
|
|
|
|
return () => {
|
|
// Only abort if we're actually stopping playback
|
|
if (!isPlaying) {
|
|
abortAudio();
|
|
}
|
|
};
|
|
}, [
|
|
isPlaying,
|
|
isProcessing,
|
|
currentIndex,
|
|
sentences,
|
|
activeHowl,
|
|
isBackgrounded,
|
|
playAudio,
|
|
preloadNextAudio,
|
|
abortAudio
|
|
]);
|
|
|
|
/**
|
|
* Stops the current audio playback and resets all state
|
|
*/
|
|
const stop = useCallback(() => {
|
|
// Cancel any ongoing request
|
|
abortAudio();
|
|
locationChangeHandlerRef.current = null;
|
|
epubContinuationRef.current = null;
|
|
continuationCarryRef.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,
|
|
isBackgrounded,
|
|
currentSentence: sentences[currentIndex] || '',
|
|
currentSentenceAlignment,
|
|
currentWordIndex,
|
|
currDocPage,
|
|
currDocPageNumber,
|
|
currDocPages,
|
|
availableVoices,
|
|
togglePlay,
|
|
skipForward,
|
|
skipBackward,
|
|
stop,
|
|
pause,
|
|
stopAndPlayFromIndex,
|
|
setText,
|
|
setCurrDocPages,
|
|
setSpeedAndRestart,
|
|
setAudioPlayerSpeedAndRestart,
|
|
setVoiceAndRestart,
|
|
skipToLocation,
|
|
registerLocationChangeHandler,
|
|
registerVisualPageChangeHandler,
|
|
setIsEPUB
|
|
}), [
|
|
isPlaying,
|
|
isProcessing,
|
|
isBackgrounded,
|
|
sentences,
|
|
currentIndex,
|
|
currDocPage,
|
|
currDocPageNumber,
|
|
currDocPages,
|
|
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;
|
|
}
|