Files
donetick/src/views/components/VoiceToTask/useVoiceToTask.js
2026-07-20 01:41:42 -04:00

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,
}
}