Add real WPM and honest adaptive timing

This commit is contained in:
2026-02-08 12:30:26 +01:00
parent d0342589ac
commit 94e56eb5fb
7 changed files with 336 additions and 42 deletions
+197 -12
View File
@@ -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)}
/>
)}