- Ne plus auto-scroller vers Memory Echo : pastille bas de page + reset scroll au titre - Badges notes/carnets générés par l’IA dans la sidebar - Wizard : langue du contenu = langue de la requête ; refresh carnet après création - Voix : erreurs not-allowed/no-speech moins bruyantes
102 lines
3.0 KiB
TypeScript
102 lines
3.0 KiB
TypeScript
/// <reference types="@types/dom-speech-recognition" />
|
|
'use client'
|
|
|
|
import { useCallback, useEffect, useRef, useState } from 'react'
|
|
|
|
type VoiceState = 'idle' | 'listening' | 'processing' | 'error'
|
|
|
|
interface UseVoiceTranscriptionOptions {
|
|
onTranscript: (text: string) => void
|
|
onError?: (message: string) => void
|
|
lang?: string
|
|
}
|
|
|
|
export function useVoiceTranscription({ onTranscript, onError, lang = 'fr-FR' }: UseVoiceTranscriptionOptions) {
|
|
const [state, setState] = useState<VoiceState>('idle')
|
|
const recognitionRef = useRef<SpeechRecognition | null>(null)
|
|
const accumulatedRef = useRef<string>('')
|
|
|
|
const isSupported =
|
|
typeof window !== 'undefined' &&
|
|
('SpeechRecognition' in window || 'webkitSpeechRecognition' in window)
|
|
|
|
const start = useCallback(() => {
|
|
if (!isSupported) {
|
|
onError?.('La reconnaissance vocale n\'est pas disponible sur ce navigateur.')
|
|
setState('error')
|
|
return
|
|
}
|
|
|
|
const SR = (window as any).SpeechRecognition || (window as any).webkitSpeechRecognition
|
|
const rec: SpeechRecognition = new SR()
|
|
rec.lang = lang
|
|
rec.continuous = true
|
|
rec.interimResults = false
|
|
accumulatedRef.current = ''
|
|
|
|
rec.onstart = () => setState('listening')
|
|
|
|
rec.onresult = (event: SpeechRecognitionEvent) => {
|
|
let transcript = ''
|
|
for (let i = event.resultIndex; i < event.results.length; i++) {
|
|
if (event.results[i].isFinal) {
|
|
transcript += event.results[i][0].transcript
|
|
}
|
|
}
|
|
if (transcript) accumulatedRef.current += (accumulatedRef.current ? ' ' : '') + transcript
|
|
}
|
|
|
|
rec.onerror = (event: SpeechRecognitionErrorEvent) => {
|
|
// "not-allowed" = permission micro refusée — attendu, pas une erreur applicative
|
|
// "aborted" / "no-speech" = fin normale / silence — ne pas alarmer
|
|
const benign = event.error === 'not-allowed' || event.error === 'aborted' || event.error === 'no-speech'
|
|
if (!benign) {
|
|
console.warn('[voice] SpeechRecognition error:', event.error)
|
|
}
|
|
if (event.error === 'not-allowed') {
|
|
onError?.('Microphone non autorisé. Autorisez le micro dans le navigateur pour dicter.')
|
|
setState('idle')
|
|
return
|
|
}
|
|
if (event.error === 'aborted' || event.error === 'no-speech') {
|
|
setState('idle')
|
|
return
|
|
}
|
|
onError?.(`Erreur: ${event.error}`)
|
|
setState('error')
|
|
}
|
|
|
|
rec.onend = () => {
|
|
setState('idle')
|
|
if (accumulatedRef.current.trim()) {
|
|
onTranscript(accumulatedRef.current.trim())
|
|
accumulatedRef.current = ''
|
|
}
|
|
}
|
|
|
|
recognitionRef.current = rec
|
|
rec.start()
|
|
}, [isSupported, lang, onTranscript, onError])
|
|
|
|
const stop = useCallback(() => {
|
|
recognitionRef.current?.stop()
|
|
recognitionRef.current = null
|
|
}, [])
|
|
|
|
const toggle = useCallback(() => {
|
|
if (state === 'listening') {
|
|
stop()
|
|
} else {
|
|
start()
|
|
}
|
|
}, [state, start, stop])
|
|
|
|
useEffect(() => {
|
|
return () => {
|
|
recognitionRef.current?.stop()
|
|
}
|
|
}, [])
|
|
|
|
return { state, toggle, start, stop, isSupported }
|
|
}
|