218 lines
6.1 KiB
JavaScript
218 lines
6.1 KiB
JavaScript
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,
|
|
}
|
|
}
|