From 29464fa8742493dd15be86c3cbfa22228aa43f2b Mon Sep 17 00:00:00 2001 From: Murdawk Media Date: Fri, 24 Apr 2026 11:33:06 -0600 Subject: [PATCH] Fix SwiftVoice privacy and speech robustness --- .github/workflows/ci.yml | 41 + .gitignore | 2 + App.tsx | 592 ++++--- README.md | 47 +- components/StatsOverlay.tsx | 25 +- components/TypingArea.tsx | 29 +- index.css | 49 + index.html | 82 +- index.tsx | 1 + lib/speech.test.ts | 89 + lib/speech.ts | 91 ++ metadata.json | 4 +- package-lock.json | 2979 +++++++++++++++++++++++++++++----- package.json | 17 +- playwright.config.ts | 23 + postcss.config.cjs | 6 + tailwind.config.ts | 9 + test/setup.ts | 1 + tests/e2e/global.d.ts | 8 + tests/e2e/swiftvoice.spec.ts | 165 ++ types.ts | 33 +- vite.config.ts | 37 +- 22 files changed, 3566 insertions(+), 764 deletions(-) create mode 100644 .github/workflows/ci.yml create mode 100644 index.css create mode 100644 lib/speech.test.ts create mode 100644 lib/speech.ts create mode 100644 playwright.config.ts create mode 100644 postcss.config.cjs create mode 100644 tailwind.config.ts create mode 100644 test/setup.ts create mode 100644 tests/e2e/global.d.ts create mode 100644 tests/e2e/swiftvoice.spec.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..0c5704c --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,41 @@ +name: CI + +on: + pull_request: + push: + branches: + - master + +env: + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true + +jobs: + test: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: 24 + cache: npm + + - name: Install dependencies + run: npm ci + + - name: Typecheck + run: npm run typecheck + + - name: Unit tests + run: npm test + + - name: Build + run: npm run build + + - name: Install Playwright + run: npx playwright install --with-deps chromium + + - name: E2E tests + run: npm run test:e2e diff --git a/.gitignore b/.gitignore index a547bf3..cab93ad 100644 --- a/.gitignore +++ b/.gitignore @@ -10,6 +10,8 @@ lerna-debug.log* node_modules dist dist-ssr +playwright-report +test-results *.local # Editor directories and files diff --git a/App.tsx b/App.tsx index 519a5ea..74404df 100644 --- a/App.tsx +++ b/App.tsx @@ -1,20 +1,42 @@ - -import React, { useState, useEffect, useCallback, useRef } from 'react'; +import React, { useCallback, useEffect, useRef, useState } from 'react'; import TypingArea from './components/TypingArea'; import StatsOverlay from './components/StatsOverlay'; -import { TestStatus, TypingStats, TestConfig, Quote } from './types'; import { DEFAULT_QUOTES, DURATIONS } from './constants'; - -// Add type definition for Web Speech API -declare global { - interface Window { - SpeechRecognition: any; - webkitSpeechRecognition: any; +import { + assembleTranscript, + calculateWpm, + createSpeechRecognition, + getRandomQuote, + getSpeechRecognitionFactory, + matchSpeechToQuote, +} from './lib/speech'; +import { TestStatus, TypingStats, TestConfig, Quote, SpeechRecognitionLike } from './types'; + +type MicState = 'idle' | 'requesting' | 'ready' | 'denied' | 'unavailable' | 'error'; + +const initialStats = (): TypingStats => ({ + wpm: 0, + accuracy: 100, + charactersTyped: 0, + wordsAttempted: 0, + incorrectWords: 0, + timeTaken: 0, +}); + +const isMobile = (): boolean => /Mobi|Android|iPhone|iPad|iPod/i.test(navigator.userAgent); + +const getUnsupportedMessage = (): string => { + const userAgent = navigator.userAgent; + if (/Firefox/i.test(userAgent)) { + return 'Speech recognition is not available in Firefox. SwiftVoice works best in Chrome or Edge.'; } -} + if (/Safari/i.test(userAgent) && !/(Chrome|CriOS|Edg)/i.test(userAgent)) { + return 'Speech recognition is not available in this Safari session. Try Chrome or Edge, or enable Safari speech recognition support if your version provides it.'; + } + return 'Speech recognition is not available in this browser. SwiftVoice works best in Chrome or Edge.'; +}; const App: React.FC = () => { - // State const [isDark, setIsDark] = useState(true); const [status, setStatus] = useState(TestStatus.IDLE); const [config, setConfig] = useState({ duration: 30 }); @@ -22,300 +44,323 @@ const App: React.FC = () => { const [userInput, setUserInput] = useState(''); const [timeLeft, setTimeLeft] = useState(config.duration); const [isListening, setIsListening] = useState(false); + const [micState, setMicState] = useState(() => + getSpeechRecognitionFactory() ? 'idle' : 'unavailable', + ); + const [message, setMessage] = useState(() => + getSpeechRecognitionFactory() ? null : getUnsupportedMessage(), + ); + const [sessionStats, setSessionStats] = useState(initialStats); - // Cumulative stats for the whole test session - const [sessionStats, setSessionStats] = useState({ - wpm: 0, - accuracy: 100, - charactersTyped: 0, - totalKeystrokes: 0, - incorrectKeystrokes: 0, - timeTaken: 0 - }); - - // Refs const timerRef = useRef(null); const startTimeRef = useRef(null); + const configRef = useRef(config); const statsRef = useRef(sessionStats); - const recognitionRef = useRef(null); + const recognitionRef = useRef(null); + const shouldRestartRef = useRef(false); + const mobileRef = useRef(null); - // Ref to hold latest state/handlers to avoid stale closures in event listeners const latestRef = useRef({ - status, - isListening, quote, - sessionStats, - handleSpeechInput: (val: string) => { } // Placeholder + status, }); - // Keep ref in sync for interval access useEffect(() => { statsRef.current = sessionStats; }, [sessionStats]); - const getRandomQuote = (currentQuoteText?: string): Quote => { - let filtered = DEFAULT_QUOTES; - if (currentQuoteText) { - filtered = DEFAULT_QUOTES.filter(q => q.text !== currentQuoteText); - } - return filtered[Math.floor(Math.random() * filtered.length)]; - }; + useEffect(() => { + configRef.current = config; + }, [config]); - const finishTest = useCallback(() => { - if (timerRef.current) { - clearInterval(timerRef.current); - timerRef.current = null; - } + useEffect(() => { + latestRef.current = { + quote, + status, + }; + }); + + const stopRecognition = useCallback(() => { + shouldRestartRef.current = false; if (recognitionRef.current) { - recognitionRef.current.stop(); + try { + recognitionRef.current.stop(); + } catch { + // Browser recognition objects can throw when already stopped. + } } - setIsListening(false); - setStatus(TestStatus.FINISHED); }, []); - const resetTest = useCallback((newDuration?: number) => { + const clearTimer = useCallback(() => { if (timerRef.current) { - clearInterval(timerRef.current); + window.clearInterval(timerRef.current); timerRef.current = null; } - if (recognitionRef.current) { - recognitionRef.current.stop(); - } + }, []); - const d = newDuration ?? config.duration; - setStatus(TestStatus.IDLE); - setUserInput(''); + const finishTest = useCallback(() => { + shouldRestartRef.current = false; + clearTimer(); + stopRecognition(); setIsListening(false); - setTimeLeft(d); - setQuote(getRandomQuote()); - setSessionStats({ - wpm: 0, - accuracy: 100, - charactersTyped: 0, - totalKeystrokes: 0, - incorrectKeystrokes: 0, - timeTaken: 0 - }); - startTimeRef.current = null; - }, [config.duration]); - - // Handle Speech Input Definition - const handleSpeechInput = (value: string) => { - if (status === TestStatus.FINISHED) return; + setStatus(TestStatus.FINISHED); + }, [clearTimer, stopRecognition]); - // Normalization helper (lowercase, remove punctuation) - const normalize = (str: string) => str.toLowerCase().replace(/[^\w\s]/g, ''); + const startTimer = useCallback(() => { + if (timerRef.current) return; - // Split target into words, keeping their original punctuation for reconstruction if needed - // But simply, we want to match against the *words* of the target. - const targetWords = quote.text.split(' '); - const spokenWords = value.trim().split(' '); + startTimeRef.current = Date.now(); + setStatus(TestStatus.RUNNING); + const duration = configRef.current.duration; + setTimeLeft(duration); - let matchedWordCount = 0; + timerRef.current = window.setInterval(() => { + setTimeLeft((previous) => { + if (previous <= 1) { + finishTest(); + return 0; + } + return previous - 1; + }); + }, 1000); + }, [finishTest]); - // Check how many words match from the beginning - // We compare normalized spoken words against normalized target words - for (let i = 0; i < spokenWords.length && i < targetWords.length; i++) { - const spoken = normalize(spokenWords[i]); - const target = normalize(targetWords[i]); + const resetTest = useCallback( + (newDuration?: number, currentQuoteText?: string) => { + clearTimer(); + stopRecognition(); - if (spoken === target) { - matchedWordCount++; - } else { - break; - } - } + const duration = newDuration ?? config.duration; + setStatus(TestStatus.IDLE); + setUserInput(''); + setIsListening(false); + setTimeLeft(duration); + setQuote(getRandomQuote(DEFAULT_QUOTES, currentQuoteText)); + setMessage(getSpeechRecognitionFactory() ? null : getUnsupportedMessage()); + setMicState(getSpeechRecognitionFactory() ? 'idle' : 'unavailable'); + setSessionStats(initialStats()); + startTimeRef.current = null; + }, + [clearTimer, config.duration, stopRecognition], + ); - // Reconstruction: - let constructedInput = ''; - if (matchedWordCount > 0) { - constructedInput = targetWords.slice(0, matchedWordCount).join(' '); - if (matchedWordCount < targetWords.length) { - constructedInput += ' '; - } - } + const handleSpeechInput = useCallback((transcript: string) => { + const currentQuote = latestRef.current.quote; + if (latestRef.current.status === TestStatus.FINISHED) return; - const accuracy = 100; + const result = matchSpeechToQuote(currentQuote.text, transcript); - if (matchedWordCount > 0) { - setSessionStats(prev => ({ - ...prev, - wpm: prev.wpm, - accuracy: accuracy, - totalKeystrokes: constructedInput.length, - incorrectKeystrokes: 0 - })); + setSessionStats((previous) => ({ + ...previous, + accuracy: result.accuracy, + wordsAttempted: result.attemptedWords, + incorrectWords: result.missedWords, + })); - setUserInput(constructedInput); - } + setUserInput(result.inputText); - // Check if we matched all words - if (matchedWordCount === targetWords.length) { - setSessionStats(prev => ({ - ...prev, - charactersTyped: prev.charactersTyped + constructedInput.length - })); + if (!result.completed) return; - setUserInput(''); + setSessionStats((previous) => ({ + ...previous, + charactersTyped: previous.charactersTyped + result.inputText.length, + accuracy: result.accuracy, + wordsAttempted: result.attemptedWords, + incorrectWords: result.missedWords, + })); + setUserInput(''); - // Stop recognition to clear buffer - if (recognitionRef.current) { + shouldRestartRef.current = false; + if (recognitionRef.current) { + try { recognitionRef.current.stop(); + } catch { + // Safe to ignore a stopped recognizer when advancing quotes. } - - setQuote(getRandomQuote(quote.text)); } - }; - // Update latestRef on every render so callbacks see fresh data/handlers - useEffect(() => { - latestRef.current = { - status, - isListening, - quote, - sessionStats, - handleSpeechInput - }; - }, [status, isListening, quote, sessionStats, handleSpeechInput]); // handleSpeechInput is constant if defined outside or depends on these + setQuote(getRandomQuote(DEFAULT_QUOTES, currentQuote.text)); + + window.setTimeout(() => { + if (latestRef.current.status !== TestStatus.RUNNING || !recognitionRef.current) return; + shouldRestartRef.current = true; + try { + recognitionRef.current.start(); + } catch { + // Some browsers briefly reject start while the previous session settles. + } + }, 150); + }, []); + + const failStart = useCallback((nextMessage: string, state: MicState = 'error') => { + clearTimer(); + stopRecognition(); + setStatus(TestStatus.IDLE); + setIsListening(false); + setMicState(state); + setMessage(nextMessage); + startTimeRef.current = null; + }, [clearTimer, stopRecognition]); const startTest = () => { - setStatus(TestStatus.RUNNING); - startTimeRef.current = Date.now(); + const recognition = createSpeechRecognition(); + if (!recognition) { + failStart(getUnsupportedMessage(), 'unavailable'); + return; + } + + setMessage(null); + setMicState('requesting'); + setStatus(TestStatus.STARTING); setIsListening(true); + shouldRestartRef.current = true; - // Start Speech Recognition - const SpeechRecognition = window.SpeechRecognition || window.webkitSpeechRecognition; - if (SpeechRecognition) { - const recognition = new SpeechRecognition(); - recognition.continuous = true; - recognition.interimResults = true; - recognition.lang = 'en-US'; - - recognition.onresult = (event: any) => { - const currentTranscript = Array.from(event.results) - .map((result: any) => result[0].transcript) - .join(''); - - // Use latestRef to call the fresh handler - latestRef.current.handleSpeechInput(currentTranscript); - }; - - recognition.onerror = (event: any) => { - console.error('Speech recognition error', event.error); - if (event.error === 'not-allowed') { - alert('Microphone access denied. Please enable microphone permissions.'); - } - }; - - recognition.onend = () => { - // Use latestRef to check fresh state - const { status, isListening } = latestRef.current; - - // Restart if still running (handles silence timeouts) - if (status === TestStatus.RUNNING && isListening) { - try { - recognition.start(); - } catch (e) { - // ignore - } - } - }; + recognition.continuous = true; + recognition.interimResults = true; + recognition.lang = 'en-US'; - try { - recognition.start(); - recognitionRef.current = recognition; - } catch (e) { - console.error('Failed to start recognition', e); + recognition.onresult = (event: unknown) => { + handleSpeechInput(assembleTranscript(event as Parameters[0])); + }; + + recognition.onerror = (event) => { + switch (event.error) { + case 'not-allowed': + case 'permission-denied': + failStart( + 'Microphone access was denied. Allow microphone access in your browser settings and try again.', + 'denied', + ); + break; + case 'audio-capture': + failStart('No microphone was detected. Connect a microphone and try again.', 'error'); + break; + case 'network': + setMessage( + 'The browser speech service reported a network error. Some browsers use provider-hosted recognition.', + ); + break; + case 'no-speech': + case 'aborted': + break; + default: + setMessage('Speech recognition stopped unexpectedly. Try restarting the test.'); + break; } - } else { - alert('Web Speech API not supported in this browser.'); - } + }; - timerRef.current = window.setInterval(() => { - setTimeLeft((prev) => { - if (prev <= 1) { - finishTest(); - return 0; + recognition.onstart = () => { + setMicState('ready'); + startTimer(); + }; + + recognition.onend = () => { + if (shouldRestartRef.current && latestRef.current.status === TestStatus.RUNNING) { + try { + recognition.start(); + } catch { + // Already starting or stopped. } - return prev - 1; - }); - }, 1000); + } + }; + + try { + recognition.start(); + recognitionRef.current = recognition; + } catch { + failStart('Failed to start speech recognition. Reload the page and try again.'); + } }; - // Clean up on unmount useEffect(() => { return () => { - if (recognitionRef.current) recognitionRef.current.stop(); - if (timerRef.current) clearInterval(timerRef.current); - } - }, []); + clearTimer(); + stopRecognition(); + }; + }, [clearTimer, stopRecognition]); - // Real-time WPM calculation useEffect(() => { if (status !== TestStatus.RUNNING || !startTimeRef.current) return; - const updateWpm = () => { - const timeElapsedSec = (Date.now() - startTimeRef.current!) / 1000; - const timeElapsedMin = timeElapsedSec / 60; - - // WPM = (all previously finished quotes chars + current input chars) / 5 / minutes - const totalChars = sessionStats.charactersTyped + userInput.length; - const currentWpm = timeElapsedMin > 0 ? (totalChars / 5) / timeElapsedMin : 0; - - setSessionStats(prev => ({ - ...prev, - wpm: currentWpm, - timeTaken: timeElapsedMin + const interval = window.setInterval(() => { + const elapsedMs = Date.now() - startTimeRef.current!; + setSessionStats((previous) => ({ + ...previous, + timeTaken: elapsedMs / 1000 / 60, + wpm: calculateWpm(statsRef.current.charactersTyped, userInput.length, elapsedMs), })); - }; + }, 250); - const interval = setInterval(updateWpm, 100); - return () => clearInterval(interval); - }, [status, userInput.length, sessionStats.charactersTyped]); + return () => window.clearInterval(interval); + }, [status, userInput.length]); + if (mobileRef.current === null) { + mobileRef.current = isMobile(); + } + + const isUnsupported = micState === 'unavailable'; + const isBusy = status === TestStatus.STARTING || status === TestStatus.RUNNING; const themeClass = isDark ? 'bg-[#161617] text-[#f5f5f7]' : 'bg-[#fbfbfd] text-[#1d1d1f]'; const navClass = isDark ? 'bg-black/70 border-white/10' : 'bg-white/70 border-gray-200/50'; + const messageClass = + micState === 'unavailable' + ? 'border-amber-500/30 bg-amber-500/10 text-amber-300' + : 'border-red-500/30 bg-red-500/10 text-red-300'; return (
-