mirror of
https://github.com/Tony0410/News-reader-pro.git
synced 2026-05-25 05:41:40 +08:00
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:
50
App.tsx
50
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 manageBuffer = useCallback(async (article: Article) => {
|
||||||
const currentIndex = article.currentSegmentIndex;
|
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) {
|
for (const seg of segmentsToBuffer) {
|
||||||
if (!seg.audioUrl && !seg.isLoading && !seg.hasError) {
|
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);
|
processSegmentAudio(article.id, seg.id, seg.text, playerState.selectedVoice);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -123,9 +125,15 @@ export default function App() {
|
|||||||
try {
|
try {
|
||||||
const { title, text } = await extractArticleContent(newArticle.url);
|
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);
|
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, {
|
updateArticle(id, {
|
||||||
title,
|
title,
|
||||||
text,
|
text,
|
||||||
@@ -133,19 +141,20 @@ export default function App() {
|
|||||||
status: PlaybackStatus.LOADING_AUDIO
|
status: PlaybackStatus.LOADING_AUDIO
|
||||||
});
|
});
|
||||||
|
|
||||||
// 2. Trigger audio for the first segment immediately
|
// 2. Trigger audio for the first batch immediately
|
||||||
if (segments.length > 0) {
|
if (segments.length > 0) {
|
||||||
// We manually call the processor for the first one to ensure fast start
|
const initialLoadCount = Math.min(segments.length, 5);
|
||||||
const firstSeg = segments[0];
|
|
||||||
await processSegmentAudio(id, firstSeg.id, firstSeg.text, playerState.selectedVoice);
|
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 });
|
updateArticle(id, { status: PlaybackStatus.READY });
|
||||||
|
|
||||||
// If nothing else is playing, auto-play this
|
// Auto-play logic
|
||||||
setQueue(prev => {
|
setQueue(prev => {
|
||||||
const current = prev.find(a => a.id === id);
|
const current = prev.find(a => a.id === id);
|
||||||
if (current && current.segments[0].audioUrl && !playerState.isPlaying) {
|
if (current && !playerState.isPlaying) {
|
||||||
playArticle(id);
|
playArticle(id);
|
||||||
}
|
}
|
||||||
return prev;
|
return prev;
|
||||||
@@ -168,8 +177,6 @@ export default function App() {
|
|||||||
setPlayerState(prev => ({ ...prev, currentArticleId: id, isPlaying: true }));
|
setPlayerState(prev => ({ ...prev, currentArticleId: id, isPlaying: true }));
|
||||||
setViewId(id);
|
setViewId(id);
|
||||||
updateArticle(id, { status: PlaybackStatus.PLAYING });
|
updateArticle(id, { status: PlaybackStatus.PLAYING });
|
||||||
|
|
||||||
// The actual audio switching is handled by the useEffect monitoring currentArticleId + currentSegmentIndex
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const pausePlayback = useCallback(() => {
|
const pausePlayback = useCallback(() => {
|
||||||
@@ -183,7 +190,6 @@ export default function App() {
|
|||||||
// -- Effects --
|
// -- Effects --
|
||||||
|
|
||||||
// 1. Audio Player Loop
|
// 1. Audio Player Loop
|
||||||
// This effect reacts to changes in article/segment index and loads the correct audio source
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const article = queue.find(a => a.id === playerState.currentArticleId);
|
const article = queue.find(a => a.id === playerState.currentArticleId);
|
||||||
if (!article || !playerState.isPlaying) return;
|
if (!article || !playerState.isPlaying) return;
|
||||||
@@ -199,8 +205,6 @@ export default function App() {
|
|||||||
|
|
||||||
// Check if audio is ready
|
// Check if audio is ready
|
||||||
if (currentSegment.audioUrl) {
|
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 audioEl = audioRef.current;
|
||||||
const currentSrc = audioEl.getAttribute('data-current-src');
|
const currentSrc = audioEl.getAttribute('data-current-src');
|
||||||
|
|
||||||
@@ -213,8 +217,7 @@ export default function App() {
|
|||||||
audioEl.play().catch(e => console.warn("Resume failed", e));
|
audioEl.play().catch(e => console.warn("Resume failed", e));
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Buffer stalled?
|
// If current segment is missing, ensure it's loading
|
||||||
// If it's loading, show loading state. If not loading and no url, trigger load.
|
|
||||||
if (!currentSegment.isLoading && !currentSegment.hasError) {
|
if (!currentSegment.isLoading && !currentSegment.hasError) {
|
||||||
processSegmentAudio(article.id, currentSegment.id, currentSegment.text, playerState.selectedVoice);
|
processSegmentAudio(article.id, currentSegment.id, currentSegment.text, playerState.selectedVoice);
|
||||||
}
|
}
|
||||||
@@ -232,10 +235,6 @@ export default function App() {
|
|||||||
|
|
||||||
const handleEnded = () => {
|
const handleEnded = () => {
|
||||||
const currentId = playerState.currentArticleId;
|
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 => {
|
setQueue(prevQueue => {
|
||||||
const article = prevQueue.find(a => a.id === currentId);
|
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);
|
return prevQueue.map(a => a.id === currentId ? { ...a, currentSegmentIndex: nextIndex } : a);
|
||||||
} else {
|
} else {
|
||||||
// Article finished
|
// Article finished
|
||||||
// Try to play next article in queue
|
|
||||||
const artIndex = prevQueue.findIndex(a => a.id === currentId);
|
const artIndex = prevQueue.findIndex(a => a.id === currentId);
|
||||||
if (artIndex !== -1 && artIndex < prevQueue.length - 1) {
|
if (artIndex !== -1 && artIndex < prevQueue.length - 1) {
|
||||||
// Queue next article
|
setTimeout(() => playArticle(prevQueue[artIndex + 1].id), 100);
|
||||||
setTimeout(() => playArticle(prevQueue[artIndex + 1].id), 100); // Small delay to let state settle
|
|
||||||
return prevQueue.map(a => a.id === currentId ? { ...a, status: PlaybackStatus.COMPLETED } : a);
|
return prevQueue.map(a => a.id === currentId ? { ...a, status: PlaybackStatus.COMPLETED } : a);
|
||||||
} else {
|
} else {
|
||||||
// End of queue
|
|
||||||
setPlayerState(ps => ({ ...ps, isPlaying: false }));
|
setPlayerState(ps => ({ ...ps, isPlaying: false }));
|
||||||
return prevQueue.map(a => a.id === currentId ? { ...a, status: PlaybackStatus.COMPLETED } : a);
|
return prevQueue.map(a => a.id === currentId ? { ...a, status: PlaybackStatus.COMPLETED } : a);
|
||||||
}
|
}
|
||||||
@@ -265,7 +261,7 @@ export default function App() {
|
|||||||
|
|
||||||
audio.addEventListener('ended', handleEnded);
|
audio.addEventListener('ended', handleEnded);
|
||||||
return () => audio.removeEventListener('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
|
// 3. Handle Speed Change
|
||||||
const handleSpeedChange = (newSpeed: number) => {
|
const handleSpeedChange = (newSpeed: number) => {
|
||||||
|
|||||||
@@ -51,17 +51,17 @@ export const ReaderView: React.FC<ReaderViewProps> = ({ article }) => {
|
|||||||
article.segments.map((segment, idx) => {
|
article.segments.map((segment, idx) => {
|
||||||
const isActive = article.currentSegmentIndex === idx;
|
const isActive = article.currentSegmentIndex === idx;
|
||||||
return (
|
return (
|
||||||
<p
|
<div
|
||||||
key={segment.id}
|
key={segment.id}
|
||||||
id={`segment-${idx}`}
|
id={`segment-${idx}`}
|
||||||
className={`text-lg leading-relaxed font-serif transition-colors duration-300 ${
|
className={`text-lg leading-relaxed font-serif transition-colors duration-300 whitespace-pre-wrap ${
|
||||||
isActive
|
isActive
|
||||||
? 'text-slate-900 bg-blue-50 p-2 rounded-lg -mx-2 border-l-4 border-blue-500'
|
? 'text-slate-900 bg-blue-50 p-4 rounded-lg -mx-4 border-l-4 border-blue-500'
|
||||||
: 'text-slate-700'
|
: 'text-slate-700'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{segment.text}
|
{segment.text}
|
||||||
</p>
|
</div>
|
||||||
);
|
);
|
||||||
})
|
})
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
11
constants.ts
11
constants.ts
@@ -1,11 +1,12 @@
|
|||||||
|
|
||||||
import { VoiceName } from './types';
|
import { VoiceName } from './types';
|
||||||
|
|
||||||
export const AVAILABLE_VOICES = [
|
export const AVAILABLE_VOICES = [
|
||||||
{ name: VoiceName.Puck, label: 'Puck (Male, Standard)' },
|
{ name: VoiceName.Puck, label: 'Puck (Standard American, Male)' },
|
||||||
{ name: VoiceName.Charon, label: 'Charon (Male, Deep)' },
|
{ name: VoiceName.Charon, label: 'Charon (Deep, Authoritative Male)' },
|
||||||
{ name: VoiceName.Kore, label: 'Kore (Female, Soothing)' },
|
{ name: VoiceName.Kore, label: 'Kore (Soft, Calm Female)' },
|
||||||
{ name: VoiceName.Fenrir, label: 'Fenrir (Male, Energetic)' },
|
{ name: VoiceName.Fenrir, label: 'Fenrir (Energetic, Mid-Atlantic Male)' },
|
||||||
{ name: VoiceName.Zephyr, label: 'Zephyr (Female, Clear)' },
|
{ name: VoiceName.Zephyr, label: 'Zephyr (Clear, Professional Female)' },
|
||||||
];
|
];
|
||||||
|
|
||||||
export const MIN_SPEED = 0.5;
|
export const MIN_SPEED = 0.5;
|
||||||
|
|||||||
@@ -2,59 +2,68 @@
|
|||||||
import { v4 as uuidv4 } from 'uuid';
|
import { v4 as uuidv4 } from 'uuid';
|
||||||
import { AudioSegment } from '../types';
|
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.
|
* Splits text into segments that start small and get larger.
|
||||||
* It prioritizes splitting by newlines (paragraphs), then by sentence endings
|
* This allows playback to begin almost immediately (streaming feel)
|
||||||
* if a paragraph is too long.
|
* while maintaining efficiency for the rest of the article.
|
||||||
*/
|
*/
|
||||||
export const segmentText = (fullText: string): AudioSegment[] => {
|
export const segmentText = (fullText: string): AudioSegment[] => {
|
||||||
if (!fullText) return [];
|
if (!fullText) return [];
|
||||||
|
|
||||||
// 1. Split by double newlines (paragraphs)
|
|
||||||
const rawParagraphs = fullText.split(/\n\s*\n/);
|
|
||||||
|
|
||||||
const segments: AudioSegment[] = [];
|
const segments: AudioSegment[] = [];
|
||||||
|
|
||||||
for (const rawPara of rawParagraphs) {
|
// 1. Split by rough sentence structure first to avoid breaking mid-sentence
|
||||||
const cleanPara = rawPara.trim();
|
// We match delimiters but keep them attached to the previous sentence
|
||||||
if (!cleanPara) continue;
|
const rawSentences = fullText.match(/[^.!?]+[.!?]+["']?|.+/g) || [fullText];
|
||||||
|
|
||||||
// If paragraph is reasonably sized (< 500 chars), keep it as one
|
let currentBuffer = "";
|
||||||
if (cleanPara.length < 500) {
|
let segmentCount = 0;
|
||||||
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 = "";
|
|
||||||
|
|
||||||
for (const sentence of sentences) {
|
for (const sentence of rawSentences) {
|
||||||
if (currentChunk.length + sentence.length > 400) {
|
const cleanSentence = sentence.trim();
|
||||||
segments.push({
|
if (!cleanSentence) continue;
|
||||||
id: uuidv4(),
|
|
||||||
text: currentChunk.trim(),
|
// Determine target size based on how far into the article we are
|
||||||
isLoading: false,
|
let targetSize = CHUNK_SIZE_FULL;
|
||||||
hasError: false
|
if (segmentCount === 0) targetSize = CHUNK_SIZE_START; // First segment: Super fast
|
||||||
});
|
else if (segmentCount <= RAMP_UP_COUNT) targetSize = CHUNK_SIZE_RAMP; // Next few: Medium
|
||||||
currentChunk = sentence;
|
|
||||||
} else {
|
// Predicted size if we add this sentence
|
||||||
currentChunk += " " + sentence;
|
const predictedSize = currentBuffer.length + cleanSentence.length;
|
||||||
}
|
|
||||||
}
|
// If buffer is empty, just add it
|
||||||
if (currentChunk.trim()) {
|
if (currentBuffer.length === 0) {
|
||||||
segments.push({
|
currentBuffer = cleanSentence;
|
||||||
id: uuidv4(),
|
}
|
||||||
text: currentChunk.trim(),
|
// If adding makes it too big, flush current buffer
|
||||||
isLoading: false,
|
else if (predictedSize > targetSize) {
|
||||||
hasError: false
|
segments.push(createSegment(currentBuffer));
|
||||||
});
|
segmentCount++;
|
||||||
|
currentBuffer = cleanSentence;
|
||||||
|
}
|
||||||
|
// Otherwise, append
|
||||||
|
else {
|
||||||
|
currentBuffer += " " + cleanSentence;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Flush remaining
|
||||||
|
if (currentBuffer) {
|
||||||
|
segments.push(createSegment(currentBuffer));
|
||||||
}
|
}
|
||||||
|
|
||||||
return segments;
|
return segments;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const createSegment = (text: string): AudioSegment => ({
|
||||||
|
id: uuidv4(),
|
||||||
|
text: text.trim(),
|
||||||
|
isLoading: false,
|
||||||
|
hasError: false
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user