feat: Improve text segmentation for faster playback

Implement a progressive text segmentation strategy. The first few segments are intentionally kept very short to allow playback to start almost immediately, creating a more responsive feel. As more segments are processed, their length gradually increases to optimize audio generation efficiency for the remainder of the article. Additionally, the title is now prepended as the very first segment. The buffer ahead is also increased to 5 segments to ensure content is ready.

Further refinements include:
- Enhanced voice descriptions in constants.
- Improved segment styling in ReaderView for better visual active state indication.
This commit is contained in:
Anthony
2025-11-19 20:15:39 +08:00
parent 78f1e0e93c
commit 417d48ffdf
4 changed files with 85 additions and 79 deletions

50
App.tsx
View File

@@ -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) => {