Add real WPM and honest adaptive timing
This commit is contained in:
+197
-12
@@ -85,6 +85,13 @@ export default function App() {
|
||||
const [fetchMetadataOnline, setFetchMetadataOnline] = useState(
|
||||
() => savedSettings?.fetchMetadataOnline ?? false,
|
||||
);
|
||||
const [showRealWpm, setShowRealWpm] = useState(
|
||||
() => savedSettings?.showRealWpm ?? false,
|
||||
);
|
||||
const [punctuationPauses, setPunctuationPauses] = useState(
|
||||
() => savedSettings?.punctuationPauses ?? true,
|
||||
);
|
||||
const [realWpm, setRealWpm] = useState<number | null>(null);
|
||||
const [linkCopied, setLinkCopied] = useState(false);
|
||||
const [isFullscreen, setIsFullscreen] = useState(false);
|
||||
const [showIOSBanner, setShowIOSBanner] = useState(() => {
|
||||
@@ -100,6 +107,38 @@ export default function App() {
|
||||
const prevTextRef = useRef(text);
|
||||
const fileInputRef = useRef<HTMLInputElement | null>(null);
|
||||
const [textEditorSession, setTextEditorSession] = useState(0);
|
||||
const wpmEventsRef = useRef<Array<{ t: number; words: number }>>([]);
|
||||
const lastIndexRef = useRef<number>(currentIndex);
|
||||
const speedScaleRef = useRef(1);
|
||||
const observedWpmRef = useRef<number | null>(null);
|
||||
const observedWpmEmaRef = useRef<number | null>(null);
|
||||
const lastControlTickRef = useRef<number | null>(null);
|
||||
const wasPlayingRef = useRef(false);
|
||||
|
||||
const computeWindowStats = useCallback((now: number, windowMs: number) => {
|
||||
const events = wpmEventsRef.current;
|
||||
if (events.length === 0) return null;
|
||||
|
||||
const cutoff = now - windowMs;
|
||||
let first = 0;
|
||||
while (first < events.length && events[first].t < cutoff) first++;
|
||||
if (first >= events.length) return null;
|
||||
|
||||
const spanMs = Math.max(1, now - events[first].t);
|
||||
let wordsRead = 0;
|
||||
for (let i = first; i < events.length; i++) wordsRead += events[i].words;
|
||||
const wpmNow = Math.max(0, Math.round((wordsRead / (spanMs / 1000)) * 60));
|
||||
|
||||
return { wpm: wpmNow, wordsRead, spanMs };
|
||||
}, []);
|
||||
|
||||
const pruneWpmEvents = useCallback((now: number, keepMs: number) => {
|
||||
const cutoff = now - keepMs;
|
||||
const events = wpmEventsRef.current;
|
||||
let first = 0;
|
||||
while (first < events.length && events[first].t < cutoff) first++;
|
||||
if (first > 0) wpmEventsRef.current = events.slice(first);
|
||||
}, []);
|
||||
|
||||
const toggleTextInput = useCallback(() => {
|
||||
setShowTextInput((prev) => {
|
||||
@@ -119,6 +158,13 @@ export default function App() {
|
||||
const reset = useCallback(() => {
|
||||
setIsPlaying(false);
|
||||
setCurrentIndex(0);
|
||||
wpmEventsRef.current = [];
|
||||
lastIndexRef.current = 0;
|
||||
speedScaleRef.current = 1;
|
||||
observedWpmRef.current = null;
|
||||
observedWpmEmaRef.current = null;
|
||||
lastControlTickRef.current = null;
|
||||
setRealWpm(null);
|
||||
}, []);
|
||||
|
||||
const adjustWpm = useCallback((delta: number) => {
|
||||
@@ -187,11 +233,20 @@ export default function App() {
|
||||
setWords(parsed);
|
||||
// Text changed, load position for new text.
|
||||
const savedPos = getPositionForText(text, positionsRef.current);
|
||||
setCurrentIndex(
|
||||
Math.min(Math.max(0, savedPos), Math.max(0, parsed.length - 1)),
|
||||
const nextIndex = Math.min(
|
||||
Math.max(0, savedPos),
|
||||
Math.max(0, parsed.length - 1),
|
||||
);
|
||||
setCurrentIndex(nextIndex);
|
||||
prevTextRef.current = text;
|
||||
setIsPlaying(false);
|
||||
wpmEventsRef.current = [];
|
||||
lastIndexRef.current = nextIndex;
|
||||
speedScaleRef.current = 1;
|
||||
observedWpmRef.current = null;
|
||||
observedWpmEmaRef.current = null;
|
||||
lastControlTickRef.current = null;
|
||||
setRealWpm(null);
|
||||
}
|
||||
}, [text]);
|
||||
|
||||
@@ -210,6 +265,8 @@ export default function App() {
|
||||
wordAmount,
|
||||
bookMetadata,
|
||||
fetchMetadataOnline,
|
||||
showRealWpm,
|
||||
punctuationPauses,
|
||||
};
|
||||
saveSettings(next);
|
||||
}, [
|
||||
@@ -220,16 +277,72 @@ export default function App() {
|
||||
wordAmount,
|
||||
bookMetadata,
|
||||
fetchMetadataOnline,
|
||||
showRealWpm,
|
||||
punctuationPauses,
|
||||
]);
|
||||
|
||||
const getBaseDelay = useCallback(() => {
|
||||
return (60 / wpm) * 1000;
|
||||
}, [wpm]);
|
||||
const getTargetStepDelayMs = useCallback(() => {
|
||||
// WPM is "real" words-per-minute, even when showing multiple words per display.
|
||||
return (60_000 * wordAmount) / wpm;
|
||||
}, [wpm, wordAmount]);
|
||||
|
||||
const estimateAverageMultiplier = useCallback(
|
||||
(startIndex: number) => {
|
||||
if (!punctuationPauses) return 1;
|
||||
if (words.length === 0) return 1;
|
||||
|
||||
// Look ahead a bit to estimate typical slowdown so we can seed the scale quickly.
|
||||
const steps = 120;
|
||||
let sum = 0;
|
||||
let count = 0;
|
||||
for (let i = 0; i < steps; i++) {
|
||||
const idx = startIndex + i * wordAmount;
|
||||
if (idx >= words.length) break;
|
||||
const end = Math.min(idx + wordAmount, words.length);
|
||||
const chunk = words.slice(idx, end).join(" ");
|
||||
// getWordDelay uses baseDelay * multiplier; pass base=1 to get multiplier back.
|
||||
const m = getWordDelay(chunk, 1, true);
|
||||
sum += m;
|
||||
count++;
|
||||
}
|
||||
return count === 0 ? 1 : sum / count;
|
||||
},
|
||||
[punctuationPauses, wordAmount, words],
|
||||
);
|
||||
|
||||
// Seed/Reset the scaling factor when starting playback or when timing settings change.
|
||||
useEffect(() => {
|
||||
const startedPlaying = isPlaying && !wasPlayingRef.current;
|
||||
wasPlayingRef.current = isPlaying;
|
||||
if (!startedPlaying) return;
|
||||
|
||||
const avgMult = estimateAverageMultiplier(currentIndex);
|
||||
speedScaleRef.current = avgMult > 0 ? 1 / avgMult : 1;
|
||||
wpmEventsRef.current = [];
|
||||
observedWpmRef.current = null;
|
||||
observedWpmEmaRef.current = null;
|
||||
lastControlTickRef.current = null;
|
||||
if (showRealWpm) setRealWpm(null);
|
||||
}, [currentIndex, estimateAverageMultiplier, isPlaying, showRealWpm]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isPlaying) return;
|
||||
const idx = lastIndexRef.current;
|
||||
const avgMult = estimateAverageMultiplier(idx);
|
||||
speedScaleRef.current = avgMult > 0 ? 1 / avgMult : 1;
|
||||
wpmEventsRef.current = [];
|
||||
observedWpmRef.current = null;
|
||||
observedWpmEmaRef.current = null;
|
||||
lastControlTickRef.current = null;
|
||||
if (showRealWpm) setRealWpm(null);
|
||||
}, [estimateAverageMultiplier, isPlaying, punctuationPauses, showRealWpm, wordAmount]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isPlaying && words.length > 0 && currentIndex < words.length) {
|
||||
const currentWord = words[currentIndex];
|
||||
const delay = getWordDelay(currentWord, getBaseDelay());
|
||||
const endIndex = Math.min(currentIndex + wordAmount, words.length);
|
||||
const chunk = words.slice(currentIndex, endIndex).join(" ");
|
||||
const baseDelay = getTargetStepDelayMs() * speedScaleRef.current;
|
||||
const delay = getWordDelay(chunk, baseDelay, punctuationPauses);
|
||||
|
||||
timeoutRef.current = window.setTimeout(() => {
|
||||
setCurrentIndex((prev) => {
|
||||
@@ -245,7 +358,68 @@ export default function App() {
|
||||
return () => {
|
||||
if (timeoutRef.current) window.clearTimeout(timeoutRef.current);
|
||||
};
|
||||
}, [isPlaying, currentIndex, words, getBaseDelay, wordAmount]);
|
||||
}, [isPlaying, currentIndex, words, getTargetStepDelayMs, wordAmount, punctuationPauses]);
|
||||
|
||||
// Track forward progress for real WPM calculations.
|
||||
useEffect(() => {
|
||||
const prev = lastIndexRef.current;
|
||||
lastIndexRef.current = currentIndex;
|
||||
if (!isPlaying) return;
|
||||
|
||||
const delta = currentIndex - prev;
|
||||
if (delta <= 0) return;
|
||||
wpmEventsRef.current.push({ t: Date.now(), words: delta });
|
||||
}, [currentIndex, isPlaying]);
|
||||
|
||||
// Control loop (every 1s):
|
||||
// - Display "Real WPM" from a longer window for readability
|
||||
// - Drive calibration from a shorter window + EWMA smoothing to avoid oscillation
|
||||
useEffect(() => {
|
||||
if (!isPlaying) return;
|
||||
|
||||
const tick = () => {
|
||||
const now = Date.now();
|
||||
pruneWpmEvents(now, 60_000);
|
||||
|
||||
const displayStats = computeWindowStats(now, 60_000);
|
||||
const controlStats = computeWindowStats(now, 15_000);
|
||||
|
||||
if (showRealWpm) setRealWpm(displayStats?.wpm ?? null);
|
||||
|
||||
const prevTick = lastControlTickRef.current ?? now;
|
||||
const dt = Math.max(1, now - prevTick);
|
||||
lastControlTickRef.current = now;
|
||||
|
||||
if (controlStats && controlStats.spanMs >= 4000 && controlStats.wordsRead >= 12) {
|
||||
const alpha = 1 - Math.exp(-dt / 12_000);
|
||||
const prev = observedWpmEmaRef.current;
|
||||
observedWpmEmaRef.current =
|
||||
prev === null
|
||||
? controlStats.wpm
|
||||
: prev + alpha * (controlStats.wpm - prev);
|
||||
}
|
||||
|
||||
const wpmForEta =
|
||||
observedWpmEmaRef.current ?? displayStats?.wpm ?? null;
|
||||
observedWpmRef.current = wpmForEta;
|
||||
|
||||
// Closed-loop: adjust timing so observed WPM approaches the requested WPM.
|
||||
// Use a smaller/faster signal (EWMA of last ~15s) with a capped adjustment per tick.
|
||||
const observed = observedWpmEmaRef.current;
|
||||
if (punctuationPauses && observed !== null && observed > 0) {
|
||||
const ratio = observed / wpm;
|
||||
const rawAdj = Math.pow(ratio, 0.12);
|
||||
const adj = Math.min(1.015, Math.max(0.985, rawAdj));
|
||||
speedScaleRef.current = Math.min(
|
||||
4,
|
||||
Math.max(0.25, speedScaleRef.current * adj),
|
||||
);
|
||||
}
|
||||
};
|
||||
tick();
|
||||
const id = window.setInterval(tick, 1000);
|
||||
return () => window.clearInterval(id);
|
||||
}, [computeWindowStats, isPlaying, pruneWpmEvents, punctuationPauses, showRealWpm, wpm]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
@@ -389,10 +563,13 @@ export default function App() {
|
||||
const orpChar = currentText[orpIndex] || "";
|
||||
const afterORP = currentText.slice(orpIndex + 1);
|
||||
|
||||
const bookStatsText =
|
||||
bookMetadata && words.length > 0
|
||||
? `${formatReadingTime((words.length - currentIndex) / wpm)} left`
|
||||
: null;
|
||||
const bookStatsText = (() => {
|
||||
if (!bookMetadata || words.length === 0) return null;
|
||||
const wordsLeft = Math.max(0, words.length - currentIndex);
|
||||
const effectiveWpm = observedWpmRef.current ?? wpm;
|
||||
const minutesLeft = wordsLeft / Math.max(1, effectiveWpm);
|
||||
return `${formatReadingTime(minutesLeft)} left`;
|
||||
})();
|
||||
|
||||
return (
|
||||
<div style={styles.container}>
|
||||
@@ -408,6 +585,8 @@ export default function App() {
|
||||
onToggleFullscreen={toggleFullscreen}
|
||||
wpm={wpm}
|
||||
onAdjustWpm={adjustWpm}
|
||||
showRealWpm={showRealWpm}
|
||||
realWpm={realWpm}
|
||||
onToggleSettings={() => setShowSettings((prev) => !prev)}
|
||||
onToggleShortcuts={() => setShowShortcuts((prev) => !prev)}
|
||||
onToggleInfo={() => setShowInfo((prev) => !prev)}
|
||||
@@ -457,11 +636,17 @@ export default function App() {
|
||||
wordAmount={wordAmount}
|
||||
sideOpacity={sideOpacity}
|
||||
fetchMetadataOnline={fetchMetadataOnline}
|
||||
showRealWpm={showRealWpm}
|
||||
punctuationPauses={punctuationPauses}
|
||||
onChangeWordAmount={setWordAmount}
|
||||
onChangeSideOpacity={setSideOpacity}
|
||||
onToggleFetchMetadataOnline={() =>
|
||||
setFetchMetadataOnline((prev) => !prev)
|
||||
}
|
||||
onToggleShowRealWpm={() => setShowRealWpm((prev) => !prev)}
|
||||
onTogglePunctuationPauses={() =>
|
||||
setPunctuationPauses((prev) => !prev)
|
||||
}
|
||||
onClose={() => setShowSettings(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user