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
+5
View File
@@ -1,3 +1,8 @@
### 2026-02-08: 1.1.1
- Add an optional "Real WPM" indicator beneath the WPM control (computed from the last 60 seconds of reading)
- Pause "Real WPM" updates while paused, add an adaptive timing toggle, and make the WPM setting reflect actual reading throughput
### 2026-02-07: 1.1.0
- Split the big app file into smaller TypeScript modules and add `tsc` typechecking
+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)}
/>
)}
+11
View File
@@ -17,6 +17,8 @@ type Props = {
wpm: number;
onAdjustWpm: (delta: number) => void;
showRealWpm: boolean;
realWpm: number | null;
onToggleSettings: () => void;
onToggleShortcuts: () => void;
@@ -35,6 +37,8 @@ export function TopBar({
onToggleFullscreen,
wpm,
onAdjustWpm,
showRealWpm,
realWpm,
onToggleSettings,
onToggleShortcuts,
onToggleInfo,
@@ -89,6 +93,7 @@ export function TopBar({
</div>
<div style={styles.topCenter}>
<div style={styles.wpmStack}>
<div style={styles.wpmControl} className="wpm-control">
<button
onClick={() => onAdjustWpm(-25)}
@@ -111,6 +116,12 @@ export function TopBar({
<Plus size={16} />
</button>
</div>
{showRealWpm && (
<div style={styles.realWpmText} aria-label="Real words per minute">
Real: {realWpm === null ? "--" : realWpm} WPM
</div>
)}
</div>
</div>
<div style={styles.topRight}>
+58 -1
View File
@@ -5,9 +5,13 @@ type Props = {
wordAmount: number;
sideOpacity: number;
fetchMetadataOnline: boolean;
showRealWpm: boolean;
punctuationPauses: boolean;
onChangeWordAmount: (next: number) => void;
onChangeSideOpacity: (next: number) => void;
onToggleFetchMetadataOnline: () => void;
onToggleShowRealWpm: () => void;
onTogglePunctuationPauses: () => void;
onClose: () => void;
};
@@ -15,9 +19,13 @@ export function SettingsModal({
wordAmount,
sideOpacity,
fetchMetadataOnline,
showRealWpm,
punctuationPauses,
onChangeWordAmount,
onChangeSideOpacity,
onToggleFetchMetadataOnline,
onToggleShowRealWpm,
onTogglePunctuationPauses,
onClose,
}: Props) {
return (
@@ -102,9 +110,58 @@ export function SettingsModal({
</button>
</div>
</div>
<div style={styles.settingRow}>
<label style={styles.settingLabel}>
<span>Show real WPM</span>
<span style={styles.settingHint}>Updates every 1s from last 60s</span>
</label>
<div style={styles.settingControl}>
<button
onClick={onToggleShowRealWpm}
style={{
...styles.toggleBtn,
backgroundColor: showRealWpm ? "#ff6b6b" : "#333",
}}
aria-pressed={showRealWpm}
>
<span
style={{
...styles.toggleKnob,
transform: showRealWpm
? "translateX(16px)"
: "translateX(0)",
}}
/>
</button>
</div>
</div>
<div style={styles.settingRow}>
<label style={styles.settingLabel}>
<span>Adaptive timing</span>
<span style={styles.settingHint}>Slows for long words and punctuation</span>
</label>
<div style={styles.settingControl}>
<button
onClick={onTogglePunctuationPauses}
style={{
...styles.toggleBtn,
backgroundColor: punctuationPauses ? "#ff6b6b" : "#333",
}}
aria-pressed={punctuationPauses}
>
<span
style={{
...styles.toggleKnob,
transform: punctuationPauses
? "translateX(16px)"
: "translateX(0)",
}}
/>
</button>
</div>
</div>
</div>
</div>
</div>
);
}
+32 -8
View File
@@ -9,15 +9,39 @@ export function getORPIndex(wordLength: number): number {
return 4; // 14+ chars: 5th letter
}
export function getWordDelay(word: string, baseDelayMs: number): number {
let multiplier = 1;
multiplier += Math.sqrt(word.length) * 0.04;
if (/[.!?]$/.test(word)) {
multiplier = 2.5;
} else if (/[,;:]$/.test(word)) {
multiplier = 1.8;
function stripTrailingClosers(s: string): string {
// Handle punctuation followed by quotes/brackets like `word."` or `word.)`.
return s.replace(/[)"'\]]+$/g, "");
}
return baseDelayMs * multiplier;
export function getTimingMultiplier(text: string, adaptiveTiming: boolean): number {
if (!adaptiveTiming) return 1;
const tokens = text.trim().split(/\s+/).filter(Boolean);
if (tokens.length === 0) return 1;
// Length-based slowdown: use the longest token in the display chunk.
let maxLen = 0;
for (const t of tokens) maxLen = Math.max(maxLen, t.length);
let multiplier = 1 + Math.sqrt(maxLen) * 0.04;
// Punctuation-based pauses: based on the last token.
const last = stripTrailingClosers(tokens[tokens.length - 1]);
if (/[.!?]$/.test(last)) {
multiplier = Math.max(multiplier, 2.5);
} else if (/[,;:]$/.test(last)) {
multiplier = Math.max(multiplier, 1.8);
}
return multiplier;
}
export function getWordDelay(
text: string,
baseDelayMs: number,
adaptiveTiming: boolean,
): number {
return baseDelayMs * getTimingMultiplier(text, adaptiveTiming);
}
export function formatReadingTime(minutes: number): string {
+11
View File
@@ -66,6 +66,12 @@ export const styles: Record<string, CSSProperties> =
color: "#fff",
backgroundColor: "#1a1a1a",
},
wpmStack: {
display: "flex",
flexDirection: "column",
alignItems: "center",
gap: "6px",
},
wpmControl: {
display: "flex",
alignItems: "center",
@@ -99,6 +105,11 @@ export const styles: Record<string, CSSProperties> =
textTransform: "uppercase",
letterSpacing: "0.08em",
},
realWpmText: {
fontSize: "0.7rem",
color: "#666",
letterSpacing: "0.02em",
},
// Text input panel
textInputOverlay: {
+2 -1
View File
@@ -14,10 +14,11 @@ export type ReaderSettings = {
wordAmount: number;
bookMetadata: BookMetadata | null;
fetchMetadataOnline: boolean;
showRealWpm: boolean;
punctuationPauses: boolean;
};
export type EpubParseResult = {
text: string;
metadata: BookMetadata;
};