Add real WPM and honest adaptive timing
This commit is contained in:
@@ -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
@@ -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)}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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
@@ -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, "");
|
||||
}
|
||||
|
||||
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 baseDelayMs * multiplier;
|
||||
|
||||
return multiplier;
|
||||
}
|
||||
|
||||
export function getWordDelay(
|
||||
text: string,
|
||||
baseDelayMs: number,
|
||||
adaptiveTiming: boolean,
|
||||
): number {
|
||||
return baseDelayMs * getTimingMultiplier(text, adaptiveTiming);
|
||||
}
|
||||
|
||||
export function formatReadingTime(minutes: number): string {
|
||||
|
||||
@@ -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
@@ -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;
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user