Add Initial Voice Input
This commit is contained in:
217
src/views/components/VoiceToTask/useVoiceToTask.js
Normal file
217
src/views/components/VoiceToTask/useVoiceToTask.js
Normal file
@@ -0,0 +1,217 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { voiceInputService } from '../../../service/VoiceInputService'
|
||||
import { generateUUID } from '../../../utils/UUID'
|
||||
import {
|
||||
applyScratchThat,
|
||||
normalizeSpokenText,
|
||||
splitSpokenSegments,
|
||||
} from './voiceNormalizer'
|
||||
|
||||
// Mic gesture: hold = push-to-talk (release stops), quick tap = hands-free
|
||||
// lock (tap again to stop). In hands-free mode, sustained silence auto-stops
|
||||
// into review so the user is never stuck watching a live mic.
|
||||
|
||||
const TAP_THRESHOLD_MS = 400
|
||||
const HANDS_FREE_SILENCE_STOP_MS = 8000
|
||||
const HANDS_FREE_EMPTY_STOP_MS = 20000
|
||||
|
||||
const haptic = async kind => {
|
||||
try {
|
||||
const { Haptics, ImpactStyle, NotificationType } = await import(
|
||||
'@capacitor/haptics'
|
||||
)
|
||||
if (kind === 'notification') {
|
||||
await Haptics.notification({ type: NotificationType.Success })
|
||||
} else if (kind === 'medium') {
|
||||
await Haptics.impact({ style: ImpactStyle.Medium })
|
||||
} else {
|
||||
await Haptics.impact({ style: ImpactStyle.Light })
|
||||
}
|
||||
} catch {
|
||||
// no haptics on this platform
|
||||
}
|
||||
}
|
||||
|
||||
// phases: idle | listening | review | denied
|
||||
export function useVoiceToTask({ members = [] } = {}) {
|
||||
const [phase, setPhase] = useState('idle')
|
||||
const [isLocked, setIsLocked] = useState(false)
|
||||
const [partialText, setPartialText] = useState('')
|
||||
const [segments, setSegments] = useState([])
|
||||
|
||||
// Kept in sync manually (not via render) so segment commits that happen
|
||||
// inside voiceInputService.stop() are visible immediately afterwards.
|
||||
const segmentsRef = useRef([])
|
||||
const membersRef = useRef(members)
|
||||
membersRef.current = members
|
||||
|
||||
const phaseRef = useRef(phase)
|
||||
phaseRef.current = phase
|
||||
const lockedRef = useRef(isLocked)
|
||||
lockedRef.current = isLocked
|
||||
|
||||
const pressStartedAtRef = useRef(0)
|
||||
const pressStartedListeningRef = useRef(false)
|
||||
const lastActivityRef = useRef(0)
|
||||
const watchdogRef = useRef(null)
|
||||
|
||||
const applySegments = useCallback(next => {
|
||||
segmentsRef.current = next
|
||||
setSegments(next)
|
||||
}, [])
|
||||
|
||||
const commitSegment = useCallback(
|
||||
rawText => {
|
||||
const normalized = normalizeSpokenText(rawText, {
|
||||
members: membersRef.current,
|
||||
})
|
||||
const { text, dropPrevious } = applyScratchThat(normalized)
|
||||
const pieces = splitSpokenSegments(text)
|
||||
if (!dropPrevious && pieces.length === 0) return
|
||||
|
||||
let base = segmentsRef.current
|
||||
if (dropPrevious && base.length > 0) {
|
||||
base = base.slice(0, -1)
|
||||
haptic('medium')
|
||||
}
|
||||
if (pieces.length > 0) haptic('light')
|
||||
applySegments([
|
||||
...base,
|
||||
...pieces.map(piece => ({ id: generateUUID(), text: piece })),
|
||||
])
|
||||
},
|
||||
[applySegments],
|
||||
)
|
||||
|
||||
const stopListening = useCallback(async () => {
|
||||
if (watchdogRef.current) {
|
||||
clearInterval(watchdogRef.current)
|
||||
watchdogRef.current = null
|
||||
}
|
||||
await voiceInputService.stop()
|
||||
setPartialText('')
|
||||
setIsLocked(false)
|
||||
// stop() commits any buffered partial synchronously through onSegment,
|
||||
// so the ref is up to date by the time we read it
|
||||
setPhase(segmentsRef.current.length > 0 ? 'review' : 'idle')
|
||||
haptic('light')
|
||||
}, [])
|
||||
|
||||
const startListening = useCallback(async () => {
|
||||
const permission = await voiceInputService.requestPermission()
|
||||
if (permission !== 'granted') {
|
||||
setPhase('denied')
|
||||
return false
|
||||
}
|
||||
lastActivityRef.current = Date.now()
|
||||
await voiceInputService.start({
|
||||
onPartial: text => {
|
||||
lastActivityRef.current = Date.now()
|
||||
setPartialText(
|
||||
normalizeSpokenText(text, { members: membersRef.current }),
|
||||
)
|
||||
},
|
||||
onSegment: commitSegment,
|
||||
onError: () => {
|
||||
setPhase('denied')
|
||||
},
|
||||
onStateChange: () => {},
|
||||
})
|
||||
setPhase('listening')
|
||||
haptic('medium')
|
||||
|
||||
// Hands-free: auto-stop into review after sustained silence
|
||||
watchdogRef.current = setInterval(() => {
|
||||
if (phaseRef.current !== 'listening' || !lockedRef.current) return
|
||||
const idleFor = Date.now() - lastActivityRef.current
|
||||
const limit =
|
||||
segmentsRef.current.length > 0
|
||||
? HANDS_FREE_SILENCE_STOP_MS
|
||||
: HANDS_FREE_EMPTY_STOP_MS
|
||||
if (idleFor > limit) {
|
||||
stopListening()
|
||||
}
|
||||
}, 1000)
|
||||
return true
|
||||
}, [commitSegment, stopListening])
|
||||
|
||||
const micPressDown = useCallback(() => {
|
||||
pressStartedAtRef.current = Date.now()
|
||||
if (phaseRef.current === 'listening') {
|
||||
pressStartedListeningRef.current = false
|
||||
return
|
||||
}
|
||||
pressStartedListeningRef.current = true
|
||||
startListening()
|
||||
}, [startListening])
|
||||
|
||||
const micPressUp = useCallback(() => {
|
||||
const held = Date.now() - pressStartedAtRef.current
|
||||
if (pressStartedListeningRef.current) {
|
||||
if (held < TAP_THRESHOLD_MS) {
|
||||
// Quick tap → hands-free lock
|
||||
setIsLocked(true)
|
||||
} else {
|
||||
// Hold-to-talk → release ends the capture
|
||||
stopListening()
|
||||
}
|
||||
} else if (phaseRef.current === 'listening') {
|
||||
// Tap while already listening (locked mode) → stop
|
||||
stopListening()
|
||||
}
|
||||
pressStartedListeningRef.current = false
|
||||
}, [stopListening])
|
||||
|
||||
const removeSegment = useCallback(
|
||||
id => {
|
||||
applySegments(segmentsRef.current.filter(s => s.id !== id))
|
||||
},
|
||||
[applySegments],
|
||||
)
|
||||
|
||||
const updateSegment = useCallback(
|
||||
(id, text) => {
|
||||
applySegments(
|
||||
segmentsRef.current
|
||||
.map(s => (s.id === id ? { ...s, text: text.trim() } : s))
|
||||
.filter(s => s.text),
|
||||
)
|
||||
},
|
||||
[applySegments],
|
||||
)
|
||||
|
||||
const reset = useCallback(() => {
|
||||
voiceInputService.stop()
|
||||
if (watchdogRef.current) {
|
||||
clearInterval(watchdogRef.current)
|
||||
watchdogRef.current = null
|
||||
}
|
||||
applySegments([])
|
||||
setPartialText('')
|
||||
setIsLocked(false)
|
||||
setPhase('idle')
|
||||
}, [applySegments])
|
||||
|
||||
// Stop the recognizer if the panel unmounts mid-capture
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
voiceInputService.stop()
|
||||
if (watchdogRef.current) clearInterval(watchdogRef.current)
|
||||
}
|
||||
}, [])
|
||||
|
||||
return {
|
||||
phase,
|
||||
isLocked,
|
||||
partialText,
|
||||
segments,
|
||||
micPressDown,
|
||||
micPressUp,
|
||||
startListening,
|
||||
stopListening,
|
||||
removeSegment,
|
||||
updateSegment,
|
||||
reset,
|
||||
isNative: voiceInputService.isNative,
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user