diff --git a/web/package-lock.json b/web/package-lock.json index faafb5a..4650a27 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -1,12 +1,12 @@ { "name": "vocalinux-website", - "version": "0.3.0", + "version": "0.6.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "vocalinux-website", - "version": "0.3.0", + "version": "0.6.0", "dependencies": { "@types/node": "^20.14.10", "@types/react": "^18.3.3", diff --git a/web/src/components/live-demo.tsx b/web/src/components/live-demo.tsx index 9959eca..0164abd 100644 --- a/web/src/components/live-demo.tsx +++ b/web/src/components/live-demo.tsx @@ -126,6 +126,8 @@ export function LiveDemo() { const [detectedLanguage, setDetectedLanguage] = useState("en-US"); const recognitionRef = useRef(null); const isListeningRef = useRef(false); + const processedResultsRef = useRef>(new Set()); + const lastTranscriptRef = useRef(""); // Keep ref in sync with state for use in event handlers useEffect(() => { @@ -163,19 +165,51 @@ export function LiveDemo() { }; recognition.onresult = (event: SpeechRecognitionEvent) => { - // On mobile browsers, the Web Speech API returns all previous results in the array - // with resultIndex staying at 0. Only process the LAST result which is the new one. - // This prevents the exponential duplication bug where text repeats like: - // "Hey Hey how Hey how are Hey how are you..." - const lastResultIndex = event.results.length - 1; - const result = event.results[lastResultIndex]; - const transcript = result[0].transcript; + // On mobile Chrome, the Web Speech API has specific quirks: + // 1. Interim results may be incorrectly marked as isFinal with confidence=0 + // 2. Final results may be emitted multiple times + // 3. resultIndex may stay at 0 + // Solution: Check isFinal AND confidence > 0, plus debounce duplicates + let interim = ""; + let final = ""; - if (result.isFinal) { - setTranscript((prev) => prev + transcript + " "); + for (let i = event.resultIndex; i < event.results.length; i++) { + const result = event.results[i]; + const transcript = result[0].transcript; + const confidence = result[0].confidence || 0; + + // On mobile Chrome, interim results can have isFinal=true but confidence=0 + // Real final results have confidence > 0 + const isReallyFinal = result.isFinal && confidence > 0; + + // Create a unique key for this result to prevent duplication + const resultKey = `${i}-${transcript}`; + + // Skip if we've already processed this exact result + if (processedResultsRef.current.has(resultKey)) { + continue; + } + + // Mark this result as processed + processedResultsRef.current.add(resultKey); + + if (isReallyFinal) { + // Debounce: skip if this is the same as the last final transcript + // Mobile Chrome sometimes emits the same final result twice + if (transcript !== lastTranscriptRef.current) { + final += transcript; + lastTranscriptRef.current = transcript; + } + } else { + interim += transcript; + } + } + + if (final) { + setTranscript((prev) => prev + final + " "); setInterimTranscript(""); } else { - setInterimTranscript(transcript); + setInterimTranscript(interim); } }; @@ -202,6 +236,9 @@ export function LiveDemo() { if (recognitionRef.current) { setTranscript(""); setInterimTranscript(""); + // Clear processed results tracking for new session + processedResultsRef.current.clear(); + lastTranscriptRef.current = ""; try { recognitionRef.current.start(); } catch (e) {