diff --git a/CHANGELOG.md b/CHANGELOG.md index 01b54b3..015e5fb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/src/App.tsx b/src/App.tsx index 3baf60c..dbc0159 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -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(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(null); const [textEditorSession, setTextEditorSession] = useState(0); + const wpmEventsRef = useRef>([]); + const lastIndexRef = useRef(currentIndex); + const speedScaleRef = useRef(1); + const observedWpmRef = useRef(null); + const observedWpmEmaRef = useRef(null); + const lastControlTickRef = useRef(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 (
@@ -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)} /> )} diff --git a/src/components/TopBar.tsx b/src/components/TopBar.tsx index 9a4ad4f..134ad5d 100644 --- a/src/components/TopBar.tsx +++ b/src/components/TopBar.tsx @@ -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,27 +93,34 @@ export function TopBar({
-
- -
- - {wpm} - - WPM +
+
+ +
+ + {wpm} + + WPM +
+
- + {showRealWpm && ( +
+ Real: {realWpm === null ? "--" : realWpm} WPM +
+ )}
diff --git a/src/components/modals/SettingsModal.tsx b/src/components/modals/SettingsModal.tsx index a9aa989..d447bd6 100644 --- a/src/components/modals/SettingsModal.tsx +++ b/src/components/modals/SettingsModal.tsx @@ -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({
+
+ +
+ +
+
+
+ +
+ +
+
); } - diff --git a/src/lib/reading.ts b/src/lib/reading.ts index 07a00a6..2459372 100644 --- a/src/lib/reading.ts +++ b/src/lib/reading.ts @@ -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 { diff --git a/src/styles.ts b/src/styles.ts index 05303c9..433f186 100644 --- a/src/styles.ts +++ b/src/styles.ts @@ -66,6 +66,12 @@ export const styles: Record = 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 = textTransform: "uppercase", letterSpacing: "0.08em", }, + realWpmText: { + fontSize: "0.7rem", + color: "#666", + letterSpacing: "0.02em", + }, // Text input panel textInputOverlay: { diff --git a/src/types.ts b/src/types.ts index 92fd4db..2fa4fd3 100644 --- a/src/types.ts +++ b/src/types.ts @@ -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; }; -