diff --git a/App.tsx b/App.tsx index 1304b33..d2fb53b 100644 --- a/App.tsx +++ b/App.tsx @@ -86,15 +86,17 @@ export default function App() { }, []); /** - * Manages the buffer. ensure current segment + next 2 are ready. + * Manages the buffer. ensure current segment + next N are ready. + * We buffer 5 segments ahead because the first few are very small (fast), + * so we need to be fetching the larger later ones while the small ones play. */ const manageBuffer = useCallback(async (article: Article) => { const currentIndex = article.currentSegmentIndex; - const segmentsToBuffer = article.segments.slice(currentIndex, currentIndex + 3); + const segmentsToBuffer = article.segments.slice(currentIndex, currentIndex + 5); for (const seg of segmentsToBuffer) { if (!seg.audioUrl && !seg.isLoading && !seg.hasError) { - // No await here - we want them to fire in parallel/background + // No await here - fire in background processSegmentAudio(article.id, seg.id, seg.text, playerState.selectedVoice); } } @@ -123,9 +125,15 @@ export default function App() { try { const { title, text } = await extractArticleContent(newArticle.url); - // 1. Split text into segments immediately + // 1. Split text into segments (Progressive: Small -> Large) const segments = segmentText(text); + // Add title as the very first segment (Super fast interaction) + if (title) { + const titleSegment = segmentText(title)[0]; // Re-use segment logic for title + if (titleSegment) segments.unshift(titleSegment); + } + updateArticle(id, { title, text, @@ -133,19 +141,20 @@ export default function App() { status: PlaybackStatus.LOADING_AUDIO }); - // 2. Trigger audio for the first segment immediately + // 2. Trigger audio for the first batch immediately if (segments.length > 0) { - // We manually call the processor for the first one to ensure fast start - const firstSeg = segments[0]; - await processSegmentAudio(id, firstSeg.id, firstSeg.text, playerState.selectedVoice); + const initialLoadCount = Math.min(segments.length, 5); + + for(let i = 0; i < initialLoadCount; i++) { + processSegmentAudio(id, segments[i].id, segments[i].text, playerState.selectedVoice); + } - // Once first segment is ready, we are effectively ready to play updateArticle(id, { status: PlaybackStatus.READY }); - // If nothing else is playing, auto-play this + // Auto-play logic setQueue(prev => { const current = prev.find(a => a.id === id); - if (current && current.segments[0].audioUrl && !playerState.isPlaying) { + if (current && !playerState.isPlaying) { playArticle(id); } return prev; @@ -168,8 +177,6 @@ export default function App() { setPlayerState(prev => ({ ...prev, currentArticleId: id, isPlaying: true })); setViewId(id); updateArticle(id, { status: PlaybackStatus.PLAYING }); - - // The actual audio switching is handled by the useEffect monitoring currentArticleId + currentSegmentIndex }, []); const pausePlayback = useCallback(() => { @@ -183,7 +190,6 @@ export default function App() { // -- Effects -- // 1. Audio Player Loop - // This effect reacts to changes in article/segment index and loads the correct audio source useEffect(() => { const article = queue.find(a => a.id === playerState.currentArticleId); if (!article || !playerState.isPlaying) return; @@ -199,8 +205,6 @@ export default function App() { // Check if audio is ready if (currentSegment.audioUrl) { - // Only switch src if we aren't already playing it - // We use a custom attribute on the audio element to track current url to avoid state race conditions const audioEl = audioRef.current; const currentSrc = audioEl.getAttribute('data-current-src'); @@ -213,8 +217,7 @@ export default function App() { audioEl.play().catch(e => console.warn("Resume failed", e)); } } else { - // Buffer stalled? - // If it's loading, show loading state. If not loading and no url, trigger load. + // If current segment is missing, ensure it's loading if (!currentSegment.isLoading && !currentSegment.hasError) { processSegmentAudio(article.id, currentSegment.id, currentSegment.text, playerState.selectedVoice); } @@ -232,10 +235,6 @@ export default function App() { const handleEnded = () => { const currentId = playerState.currentArticleId; - // We need to get the *latest* queue state to find the current index - // Since we can't easily access 'queue' inside this event listener without recreating the listener constantly, - // we rely on the state setters functional update or a ref. - // However, for simplicity in this specific React structure, let's use the state update pattern. setQueue(prevQueue => { const article = prevQueue.find(a => a.id === currentId); @@ -248,14 +247,11 @@ export default function App() { return prevQueue.map(a => a.id === currentId ? { ...a, currentSegmentIndex: nextIndex } : a); } else { // Article finished - // Try to play next article in queue const artIndex = prevQueue.findIndex(a => a.id === currentId); if (artIndex !== -1 && artIndex < prevQueue.length - 1) { - // Queue next article - setTimeout(() => playArticle(prevQueue[artIndex + 1].id), 100); // Small delay to let state settle + setTimeout(() => playArticle(prevQueue[artIndex + 1].id), 100); return prevQueue.map(a => a.id === currentId ? { ...a, status: PlaybackStatus.COMPLETED } : a); } else { - // End of queue setPlayerState(ps => ({ ...ps, isPlaying: false })); return prevQueue.map(a => a.id === currentId ? { ...a, status: PlaybackStatus.COMPLETED } : a); } @@ -265,7 +261,7 @@ export default function App() { audio.addEventListener('ended', handleEnded); return () => audio.removeEventListener('ended', handleEnded); - }, [playerState.currentArticleId, playArticle]); // removed 'queue' from dependency to avoid re-attaching listener on every segment update + }, [playerState.currentArticleId, playArticle]); // 3. Handle Speed Change const handleSpeedChange = (newSpeed: number) => { diff --git a/components/ReaderView.tsx b/components/ReaderView.tsx index 0ff7695..8754714 100644 --- a/components/ReaderView.tsx +++ b/components/ReaderView.tsx @@ -51,17 +51,17 @@ export const ReaderView: React.FC = ({ article }) => { article.segments.map((segment, idx) => { const isActive = article.currentSegmentIndex === idx; return ( -

{segment.text} -

+ ); }) ) : ( diff --git a/constants.ts b/constants.ts index 7625d56..4706278 100644 --- a/constants.ts +++ b/constants.ts @@ -1,15 +1,16 @@ + import { VoiceName } from './types'; export const AVAILABLE_VOICES = [ - { name: VoiceName.Puck, label: 'Puck (Male, Standard)' }, - { name: VoiceName.Charon, label: 'Charon (Male, Deep)' }, - { name: VoiceName.Kore, label: 'Kore (Female, Soothing)' }, - { name: VoiceName.Fenrir, label: 'Fenrir (Male, Energetic)' }, - { name: VoiceName.Zephyr, label: 'Zephyr (Female, Clear)' }, + { name: VoiceName.Puck, label: 'Puck (Standard American, Male)' }, + { name: VoiceName.Charon, label: 'Charon (Deep, Authoritative Male)' }, + { name: VoiceName.Kore, label: 'Kore (Soft, Calm Female)' }, + { name: VoiceName.Fenrir, label: 'Fenrir (Energetic, Mid-Atlantic Male)' }, + { name: VoiceName.Zephyr, label: 'Zephyr (Clear, Professional Female)' }, ]; export const MIN_SPEED = 0.5; export const MAX_SPEED = 3.5; export const SPEED_STEP = 0.5; -export const SAMPLE_RATE = 24000; \ No newline at end of file +export const SAMPLE_RATE = 24000; diff --git a/services/textUtils.ts b/services/textUtils.ts index 21a169e..73417bd 100644 --- a/services/textUtils.ts +++ b/services/textUtils.ts @@ -2,59 +2,68 @@ import { v4 as uuidv4 } from 'uuid'; import { AudioSegment } from '../types'; +// Progressive chunking constants +const CHUNK_SIZE_START = 150; // Very small for first few segments (fast gen) +const CHUNK_SIZE_RAMP = 400; // Medium size +const CHUNK_SIZE_FULL = 1000; // Full size for efficiency +const RAMP_UP_COUNT = 2; // How many small segments before ramping up + /** - * Splits a long text string into manageable segments for audio generation. - * It prioritizes splitting by newlines (paragraphs), then by sentence endings - * if a paragraph is too long. + * Splits text into segments that start small and get larger. + * This allows playback to begin almost immediately (streaming feel) + * while maintaining efficiency for the rest of the article. */ export const segmentText = (fullText: string): AudioSegment[] => { if (!fullText) return []; - // 1. Split by double newlines (paragraphs) - const rawParagraphs = fullText.split(/\n\s*\n/); - const segments: AudioSegment[] = []; + + // 1. Split by rough sentence structure first to avoid breaking mid-sentence + // We match delimiters but keep them attached to the previous sentence + const rawSentences = fullText.match(/[^.!?]+[.!?]+["']?|.+/g) || [fullText]; + + let currentBuffer = ""; + let segmentCount = 0; - for (const rawPara of rawParagraphs) { - const cleanPara = rawPara.trim(); - if (!cleanPara) continue; + for (const sentence of rawSentences) { + const cleanSentence = sentence.trim(); + if (!cleanSentence) continue; - // If paragraph is reasonably sized (< 500 chars), keep it as one - if (cleanPara.length < 500) { - segments.push({ - id: uuidv4(), - text: cleanPara, - isLoading: false, - hasError: false - }); - } else { - // If paragraph is huge, split by sentences to avoid timeouts - const sentences = cleanPara.match(/[^.!?]+[.!?]+["']?|.+/g) || [cleanPara]; - let currentChunk = ""; + // Determine target size based on how far into the article we are + let targetSize = CHUNK_SIZE_FULL; + if (segmentCount === 0) targetSize = CHUNK_SIZE_START; // First segment: Super fast + else if (segmentCount <= RAMP_UP_COUNT) targetSize = CHUNK_SIZE_RAMP; // Next few: Medium - for (const sentence of sentences) { - if (currentChunk.length + sentence.length > 400) { - segments.push({ - id: uuidv4(), - text: currentChunk.trim(), - isLoading: false, - hasError: false - }); - currentChunk = sentence; - } else { - currentChunk += " " + sentence; - } - } - if (currentChunk.trim()) { - segments.push({ - id: uuidv4(), - text: currentChunk.trim(), - isLoading: false, - hasError: false - }); - } + // Predicted size if we add this sentence + const predictedSize = currentBuffer.length + cleanSentence.length; + + // If buffer is empty, just add it + if (currentBuffer.length === 0) { + currentBuffer = cleanSentence; + } + // If adding makes it too big, flush current buffer + else if (predictedSize > targetSize) { + segments.push(createSegment(currentBuffer)); + segmentCount++; + currentBuffer = cleanSentence; + } + // Otherwise, append + else { + currentBuffer += " " + cleanSentence; } } + // Flush remaining + if (currentBuffer) { + segments.push(createSegment(currentBuffer)); + } + return segments; }; + +const createSegment = (text: string): AudioSegment => ({ + id: uuidv4(), + text: text.trim(), + isLoading: false, + hasError: false +});