Add Initial Voice Input

This commit is contained in:
Mo Tarbin
2026-07-20 01:41:42 -04:00
parent 65c774d0d0
commit e0257445ef
17 changed files with 1747 additions and 17 deletions

View File

@@ -0,0 +1,121 @@
.voice-mic-btn {
position: relative;
width: 72px;
height: 72px;
border-radius: 50%;
border: none;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
background: var(--joy-palette-primary-solidBg, #0b6bcb);
color: #fff;
transition:
transform 0.15s ease,
background 0.2s ease,
box-shadow 0.2s ease;
touch-action: none;
user-select: none;
-webkit-user-select: none;
-webkit-tap-highlight-color: transparent;
}
.voice-mic-btn:active {
transform: scale(0.94);
}
.voice-mic-btn.listening {
background: var(--joy-palette-danger-solidBg, #c41c1c);
box-shadow: 0 4px 18px rgba(196, 28, 28, 0.35);
}
.voice-pulse-ring {
position: absolute;
inset: 0;
border-radius: 50%;
pointer-events: none;
opacity: 0;
}
.voice-mic-btn.listening .voice-pulse-ring {
opacity: 1;
animation: voice-pulse 1.8s ease-out infinite;
}
.voice-mic-btn.listening .voice-pulse-ring:nth-child(2) {
animation-delay: 0.6s;
}
@keyframes voice-pulse {
0% {
box-shadow: 0 0 0 0 rgba(196, 28, 28, 0.4);
}
100% {
box-shadow: 0 0 0 26px rgba(196, 28, 28, 0);
}
}
/* Faux equalizer shown while listening */
.voice-eq {
display: flex;
gap: 3px;
align-items: center;
height: 28px;
}
.voice-eq span {
width: 4px;
border-radius: 2px;
background: var(--joy-palette-danger-solidBg, #c41c1c);
animation: voice-eq-wave 1.1s ease-in-out infinite;
}
.voice-eq span:nth-child(1) {
animation-delay: 0s;
}
.voice-eq span:nth-child(2) {
animation-delay: 0.18s;
}
.voice-eq span:nth-child(3) {
animation-delay: 0.32s;
}
.voice-eq span:nth-child(4) {
animation-delay: 0.12s;
}
.voice-eq span:nth-child(5) {
animation-delay: 0.26s;
}
@keyframes voice-eq-wave {
0%,
100% {
height: 6px;
}
50% {
height: 24px;
}
}
/* Committed task cards slide in as segments are captured */
.voice-task-card {
animation: voice-card-in 0.25s ease-out;
}
@keyframes voice-card-in {
from {
opacity: 0;
transform: translateY(6px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@media (prefers-reduced-motion: reduce) {
.voice-mic-btn.listening .voice-pulse-ring,
.voice-eq span,
.voice-task-card {
animation: none;
}
}

View File

@@ -0,0 +1,493 @@
import {
CalendarMonth,
Close,
Flag,
GraphicEq,
Lock,
Mic,
Person,
Repeat,
Sell,
Toll,
WarningAmber,
} from '@mui/icons-material'
import { Box, Button, Chip, IconButton, Input, Typography } from '@mui/joy'
import moment from 'moment'
import { useMemo, useState } from 'react'
import { parseVoiceTask } from './parseVoiceTask'
import { useVoiceToTask } from './useVoiceToTask'
import './VoicePanel.css'
const HIGHLIGHT_CLASS = {
repeat: 'highlight-repeat',
priority: 'highlight-priority',
points: 'highlight-points',
assignee: 'highlight-assignee',
label: 'highlight-label',
dueDate: 'highlight-date',
}
const renderTranscript = (text, highlights) => {
const parts = []
let lastIndex = 0
for (const h of highlights) {
if (h.start > lastIndex) parts.push(text.substring(lastIndex, h.start))
parts.push(
<span
key={h.start}
className={HIGHLIGHT_CLASS[h.type]}
style={{
textDecoration: 'underline',
textDecorationThickness: '2px',
textDecorationStyle: 'dashed',
}}
>
{text.substring(h.start, h.end)}
</span>,
)
lastIndex = h.end
}
if (lastIndex < text.length) parts.push(text.substring(lastIndex))
return parts
}
const formatDue = dueDate => {
const m = moment(dueDate)
return m.format('HH:mm') === '23:59'
? m.format('MMM D')
: m.format('MMM D, h:mm A')
}
const buildChips = (parsed, { members, currentUserId }) => {
const chips = []
if (parsed.dueDate) {
chips.push({
key: 'due',
color: 'warning',
icon: <CalendarMonth sx={{ fontSize: 12 }} />,
label: formatDue(parsed.dueDate),
})
}
if (parsed.frequencyName) {
chips.push({
key: 'repeat',
color: 'success',
icon: <Repeat sx={{ fontSize: 12 }} />,
label: parsed.frequencyName,
})
}
if (parsed.priority > 0) {
chips.push({
key: 'priority',
color: 'danger',
icon: <Flag sx={{ fontSize: 12 }} />,
label: `P${parsed.priority}`,
})
}
if (parsed.points != null) {
chips.push({
key: 'points',
color: 'primary',
icon: <Toll sx={{ fontSize: 12 }} />,
label: `${parsed.points} pts`,
})
}
parsed.labelNames.forEach(name => {
chips.push({
key: `label-${name}`,
color: 'primary',
icon: <Sell sx={{ fontSize: 12 }} />,
label: name,
})
})
if (parsed.isAnyone) {
chips.push({
key: 'assignee',
color: 'neutral',
icon: <Person sx={{ fontSize: 12 }} />,
label: 'Anyone',
})
} else if (
parsed.assignees.length > 0 &&
parsed.assignees[0].userId !== currentUserId
) {
const member = members.find(m => m.userId === parsed.assignees[0].userId)
if (member) {
chips.push({
key: 'assignee',
color: 'neutral',
icon: <Person sx={{ fontSize: 12 }} />,
label: member.displayName,
})
}
}
return chips
}
const TaskPreviewCard = ({ segment, parseCtx, onRemove, onUpdate }) => {
const [editing, setEditing] = useState(false)
const [draft, setDraft] = useState(segment.text)
const parsed = useMemo(
() => parseVoiceTask(segment.text, parseCtx),
[segment.text, parseCtx],
)
const chips = useMemo(() => buildChips(parsed, parseCtx), [parsed, parseCtx])
const commitEdit = () => {
setEditing(false)
if (draft.trim() !== segment.text) onUpdate(draft)
}
return (
<Box
className='voice-task-card'
sx={{
borderRadius: 'md',
border: '1px solid',
borderColor: 'divider',
bgcolor: 'background.surface',
p: 1.25,
display: 'flex',
flexDirection: 'column',
gap: 0.75,
}}
>
<Box sx={{ display: 'flex', alignItems: 'flex-start', gap: 1 }}>
{editing ? (
<Input
size='sm'
autoFocus
value={draft}
onChange={e => setDraft(e.target.value)}
onKeyDown={e => {
if (e.key === 'Enter') commitEdit()
if (e.key === 'Escape') {
setDraft(segment.text)
setEditing(false)
}
}}
onBlur={commitEdit}
sx={{ flex: 1 }}
/>
) : (
<Typography
level='title-sm'
sx={{ flex: 1, cursor: 'text', wordBreak: 'break-word' }}
onClick={() => {
setDraft(segment.text)
setEditing(true)
}}
>
{parsed.title || segment.text}
</Typography>
)}
<IconButton
size='sm'
variant='plain'
color='neutral'
onClick={onRemove}
sx={{ '--IconButton-size': '28px' }}
>
<Close fontSize='small' />
</IconButton>
</Box>
{chips.length > 0 && (
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.5 }}>
{chips.map(chip => (
<Chip
key={chip.key}
size='sm'
variant='soft'
color={chip.color}
startDecorator={chip.icon}
>
{chip.label}
</Chip>
))}
</Box>
)}
</Box>
)
}
/**
* Inline voice-to-task panel. Mounts inside AddTaskModal — no second modal.
*
* Hold the mic to speak, or tap once for hands-free. Pauses and spoken
* separators ("also") split the transcript into task cards. A single captured
* task lands in the smart input for review; multiple tasks are created
* directly from the review list.
*/
const VoicePanel = ({
open,
userLabels = [],
members = [],
userProfile,
onClose,
onUseSingle,
onCreateMany,
}) => {
const {
phase,
isLocked,
partialText,
segments,
micPressDown,
micPressUp,
startListening,
removeSegment,
updateSegment,
reset,
isNative,
} = useVoiceToTask({ members })
const [creating, setCreating] = useState(false)
const parseCtx = useMemo(
() => ({ userLabels, members, currentUserId: userProfile?.id }),
[userLabels, members, userProfile?.id],
)
const partialParsed = useMemo(
() => (partialText ? parseVoiceTask(partialText, parseCtx) : null),
[partialText, parseCtx],
)
if (!open) return null
const isListening = phase === 'listening'
const showActions = segments.length > 0 && !isListening && !creating
const handleCancel = () => {
reset()
onClose()
}
const handleCreateAll = async () => {
setCreating(true)
try {
await onCreateMany(segments.map(s => parseVoiceTask(s.text, parseCtx)))
} finally {
setCreating(false)
}
}
const micCaption = isListening
? isLocked
? 'Listening — tap to stop'
: 'Release to finish · quick tap locks hands-free'
: segments.length > 0
? 'Hold to add another task'
: 'Hold to speak · quick tap for hands-free'
return (
<Box
sx={{
borderRadius: 'md',
border: '1px solid',
borderColor: 'primary.outlinedBorder',
overflow: 'hidden',
bgcolor: 'background.level1',
}}
>
{/* ── Header ── */}
<Box
sx={{
px: 1.5,
pt: 1.25,
display: 'flex',
alignItems: 'center',
gap: 1,
}}
>
<GraphicEq color='primary' fontSize='small' />
<Typography level='title-sm'>Speak your tasks</Typography>
{isNative && (
<Chip
size='sm'
variant='soft'
color='success'
startDecorator={<Lock sx={{ fontSize: 12 }} />}
sx={{ ml: 'auto' }}
>
On-device
</Chip>
)}
</Box>
{/* ── Permission denied ── */}
{phase === 'denied' && (
<Box sx={{ p: 2 }}>
<Box
sx={{ display: 'flex', alignItems: 'flex-start', gap: 1, mb: 1.5 }}
>
<WarningAmber color='warning' sx={{ mt: 0.25, flexShrink: 0 }} />
<Typography level='body-sm'>
Microphone access is needed for voice capture. Enable it in your
device settings and try again.
</Typography>
</Box>
<Button
size='sm'
variant='outlined'
color='neutral'
onClick={startListening}
>
Try Again
</Button>
</Box>
)}
{/* ── Captured task cards ── */}
{segments.length > 0 && (
<Box
sx={{
px: 1.5,
pt: 1.25,
display: 'flex',
flexDirection: 'column',
gap: 0.75,
maxHeight: 260,
overflowY: 'auto',
}}
>
{segments.map(segment => (
<TaskPreviewCard
key={segment.id}
segment={segment}
parseCtx={parseCtx}
onRemove={() => removeSegment(segment.id)}
onUpdate={text => updateSegment(segment.id, text)}
/>
))}
</Box>
)}
{/* ── Live transcript ── */}
{isListening && (
<Box sx={{ px: 1.5, pt: 1.25 }}>
<Box
sx={{
minHeight: 44,
borderRadius: 'md',
border: '1px dashed',
borderColor: 'neutral.outlinedBorder',
bgcolor: 'background.surface',
px: 1.25,
py: 1,
}}
>
{partialText ? (
<Typography level='body-md' sx={{ wordBreak: 'break-word' }}>
{renderTranscript(partialText, partialParsed?.highlights || [])}
</Typography>
) : (
<Typography level='body-sm' sx={{ opacity: 0.5 }}>
Listening
</Typography>
)}
</Box>
</Box>
)}
{/* ── Mic stage ── */}
{phase !== 'denied' && (
<Box
sx={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
py: 2,
gap: 1.25,
}}
>
<div className='voice-eq' style={{ opacity: isListening ? 1 : 0 }}>
<span />
<span />
<span />
<span />
<span />
</div>
<button
type='button'
aria-label={isListening ? 'Stop listening' : 'Start voice capture'}
className={`voice-mic-btn${isListening ? ' listening' : ''}`}
onPointerDown={e => {
e.preventDefault()
e.currentTarget.setPointerCapture?.(e.pointerId)
micPressDown()
}}
onPointerUp={micPressUp}
onPointerCancel={micPressUp}
onContextMenu={e => e.preventDefault()}
>
<span className='voice-pulse-ring' />
<span className='voice-pulse-ring' />
<Mic sx={{ fontSize: 32 }} />
</button>
<Typography level='body-xs' sx={{ opacity: 0.7 }}>
{micCaption}
</Typography>
<Typography
level='body-xs'
sx={{ opacity: 0.5, px: 2, textAlign: 'center' }}
>
Pause or say &ldquo;also&rdquo; between tasks &middot; say
&ldquo;scratch that&rdquo; to remove the last one
</Typography>
</Box>
)}
{/* ── Footer ── */}
<Box
sx={{
px: 1.5,
py: 1,
display: 'flex',
alignItems: 'center',
gap: 1,
borderTop: '1px solid',
borderColor: 'divider',
}}
>
<Button
size='sm'
variant='plain'
color='neutral'
onClick={handleCancel}
>
Cancel
</Button>
<Box sx={{ ml: 'auto', display: 'flex', gap: 1 }}>
{creating && (
<Button size='sm' variant='solid' color='primary' loading>
Creating
</Button>
)}
{showActions &&
(segments.length === 1 ? (
<Button
size='sm'
variant='solid'
color='primary'
onClick={() => onUseSingle(segments[0].text)}
>
Use Task
</Button>
) : (
<Button
size='sm'
variant='solid'
color='primary'
onClick={handleCreateAll}
>
Create {segments.length} Tasks
</Button>
))}
</Box>
</Box>
</Box>
)
}
export default VoicePanel

View File

@@ -0,0 +1,201 @@
import * as chrono from 'chrono-node'
import moment from 'moment'
import { isPlusAccount } from '../../../utils/Helpers'
import { generateUUID } from '../../../utils/UUID'
import {
parseAssignees,
parseDueDate,
parseLabels,
parsePoints,
parsePriority,
parseRepeatV2,
} from '../CustomParsers'
// Pure equivalent of AddTaskModal.processText — parses one sentence into a
// structured task (no state setters), preserving the same parser order and
// sequential-cleanup behavior so voice and typed input stay consistent.
const mapMembersForParsing = members =>
members.map(member => ({
userId: member.userId,
username:
member.username || member.displayName?.toLowerCase().replace(/\s+/g, ''),
displayName: member.displayName,
name: member.displayName,
id: member.userId,
}))
// Merge overlapping highlight ranges, higher parser priority wins — same
// resolution rules as AddTaskModal.renderHighlightedSentence.
const resolveHighlights = ({
repeat,
priority,
points,
assignees,
labels,
dueDate,
}) => {
const all = []
repeat?.forEach(h => all.push({ ...h, type: 'repeat', rank: 60 }))
priority?.forEach(h => all.push({ ...h, type: 'priority', rank: 50 }))
points?.forEach(h => all.push({ ...h, type: 'points', rank: 45 }))
assignees?.forEach(h => all.push({ ...h, type: 'assignee', rank: 40 }))
labels?.forEach(h => all.push({ ...h, type: 'label', rank: 30 }))
if (dueDate) all.push({ ...dueDate, type: 'dueDate', rank: 20 })
all.sort((a, b) => a.start - b.start)
const resolved = []
for (const current of all) {
const previous = resolved[resolved.length - 1]
if (previous && current.start < previous.end) {
if (current.rank > previous.rank) {
resolved.pop()
resolved.push(current)
}
} else {
resolved.push(current)
}
}
return resolved
}
export const parseVoiceTask = (
sentence,
{ userLabels = [], members = [], currentUserId = null } = {},
) => {
const assigneesForParsing = mapMembersForParsing(members)
const priority = parsePriority(sentence)
const points = parsePoints(sentence)
const labels = parseLabels(sentence, userLabels)
const assigneesResult = parseAssignees(sentence, assigneesForParsing)
const repeat = parseRepeatV2(sentence)
const dueDateParsed = parseDueDate(sentence, chrono)
// Sequential cleanup — identical chain to AddTaskModal.processText
let cleaned = sentence
if (priority.result) cleaned = priority.cleanedSentence
if (points.result) {
const reparse = parsePoints(cleaned)
if (reparse.result) cleaned = reparse.cleanedSentence
}
if (labels.result) {
const reparse = parseLabels(cleaned, userLabels)
if (reparse.result) cleaned = reparse.cleanedSentence
}
if (assigneesResult.result) {
const reparse = parseAssignees(cleaned, assigneesForParsing)
if (reparse.result) cleaned = reparse.cleanedSentence
}
if (repeat.result) {
const reparse = parseRepeatV2(cleaned)
if (reparse.result) cleaned = reparse.cleanedSentence
}
if (dueDateParsed.result) {
const reparse = parseDueDate(cleaned, chrono)
if (reparse.result) cleaned = reparse.cleanedSentence
}
let dueDate = null
if (dueDateParsed.result) {
dueDate = moment(dueDateParsed.result).format('YYYY-MM-DDTHH:mm:ss')
} else if (repeat.dueDate) {
dueDate = moment(repeat.dueDate).format('YYYY-MM-DDTHH:mm:ss')
}
let assignees = []
const isAnyone = !!assigneesResult.isAnyone
if (!isAnyone) {
if (assigneesResult.result?.length > 0) {
assignees = assigneesResult.result.map(a => ({ userId: a.userId }))
} else if (currentUserId) {
assignees = [{ userId: currentUserId }]
}
}
const labelIds = (labels.result || [])
.filter(label => label.id)
.map(label => label.id)
return {
raw: sentence,
title: cleaned.replace(/\s+/g, ' ').trim(),
priority: priority.result ? parseInt(priority.result, 10) : 0,
points: points.result ?? null,
labelIds,
labelNames: (labels.result || []).map(label => label.name),
assignees,
isAnyone,
frequency: repeat.result,
frequencyName: repeat.name,
dueDate,
highlights: resolveHighlights({
repeat: repeat.highlight,
priority: priority.highlight,
points: points.highlight,
assignees: assigneesResult.highlight,
labels: labels.highlight,
dueDate: dueDateParsed.result ? dueDateParsed.highlight[0] : null,
}),
}
}
// Builds the same chore payload shape AddTaskModal.createChore submits.
export const buildChorePayload = (
parsed,
{ userProfile, projectId, notificationTemplates },
) => {
let finalAssignees = parsed.assignees
let finalAssignedTo = null
let finalAssignStrategy = 'keep_last_assigned'
if (parsed.isAnyone) {
finalAssignees = []
finalAssignStrategy = 'no_assignee'
} else if (finalAssignees.length === 0) {
finalAssignees = [{ userId: userProfile?.id }]
finalAssignedTo = userProfile?.id
} else {
finalAssignedTo = finalAssignees[0].userId
}
const chore = {
name: parsed.title,
description: null,
assignees: finalAssignees,
dueDate: parsed.dueDate ? new Date(parsed.dueDate).toISOString() : null,
assignedTo: finalAssignedTo,
assignStrategy: finalAssignStrategy,
isRolling: false,
labelsV2: parsed.labelIds,
priority: parsed.priority || 0,
points: parsed.points ?? null,
deadlineOffset: null,
completionWindow: null,
requireApproval: false,
isPrivate: false,
status: 0,
frequencyType: 'once',
frequencyMetadata: {},
notificationMetadata: {},
subTasks: null,
projectId: projectId === 'default' ? null : projectId,
draftId: generateUUID(),
}
if (parsed.frequency) {
chore.frequencyType = parsed.frequency.frequencyType
chore.frequencyMetadata = parsed.frequency.frequencyMetadata
chore.frequency = parsed.frequency.frequency
if (isPlusAccount(userProfile)) {
chore.notification = true
chore.notificationMetadata = { templates: notificationTemplates }
}
}
if (!parsed.frequency && parsed.dueDate) {
chore.nextDueDate = new Date(parsed.dueDate).toISOString()
chore.notificationMetadata = { templates: notificationTemplates }
}
return chore
}

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

View File

@@ -0,0 +1,129 @@
// Deterministic transforms that turn spoken language into the typed syntax
// CustomParsers understands. No LLM — instant, predictable, fully offline.
//
// "label groceries" → "#groceries"
// "assign to Sarah" → "@Sarah" (only when Sarah is a circle member)
// "worth five points" → "*5"
// "p one" / "top priority" → "priority 1" (parsePriority already handles that)
const FILLER_REGEX = /(?:^|\s)(?:um+|uh+|erm+|hmm+|mmm+)(?=[\s,.!?]|$)[,.]?/gi
const NUMBER_WORDS = {
one: 1,
two: 2,
three: 3,
four: 4,
five: 5,
six: 6,
seven: 7,
eight: 8,
nine: 9,
ten: 10,
fifteen: 15,
twenty: 20,
'twenty five': 25,
'twenty-five': 25,
fifty: 50,
hundred: 100,
'one hundred': 100,
}
const NUMBER_WORD_PATTERN = Object.keys(NUMBER_WORDS)
// Longest first so "twenty five" wins over "five"
.sort((a, b) => b.length - a.length)
.join('|')
const escapeRegex = s => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
export const stripFillers = text =>
text.replace(FILLER_REGEX, ' ').replace(/\s+/g, ' ').trim()
const normalizePriority = text =>
text
.replace(/\b(?:top|highest)\s+priority\b/gi, 'priority 1')
.replace(
/\bp[\s-]?(one|two|three|four|[1-4])\b/gi,
(_, n) => `priority ${NUMBER_WORDS[n.toLowerCase()] || n}`,
)
const normalizePoints = text =>
text.replace(
new RegExp(
`\\b(?:worth\\s+)?(\\d+|${NUMBER_WORD_PATTERN})\\s+points?\\b`,
'gi',
),
(_, n) => `*${NUMBER_WORDS[n.toLowerCase()] || n} points`,
)
const normalizeLabels = text =>
text.replace(
/\b(?:with\s+)?(?:hash\s?tag|labell?ed(?:\s+as)?|label|tagged(?:\s+as)?|tag)\s+([\p{L}\p{N}_]+)/giu,
'#$1',
)
const normalizeAssignees = (text, members = []) => {
const assignVerb = '(?:assign(?:ed)?\\s+(?:this\\s+|it\\s+)?to|for)'
let out = text.replace(
new RegExp(`\\b${assignVerb}\\s+(?:anyone|anybody|everyone)\\b`, 'gi'),
'@Anyone',
)
for (const member of members) {
const displayName = member.displayName
if (!displayName) continue
const firstName = displayName.split(/\s+/)[0]
// Full display name first so "assign to Mo Tarbin" doesn't leave "Tarbin"
const names = [...new Set([displayName, firstName])].filter(
n => n.length > 1,
)
for (const name of names) {
out = out.replace(
new RegExp(`\\b${assignVerb}\\s+${escapeRegex(name)}\\b`, 'gi'),
`@${displayName}`,
)
}
}
return out
}
export const normalizeSpokenText = (text, { members = [] } = {}) => {
let out = stripFillers(text)
out = normalizePriority(out)
out = normalizePoints(out)
out = normalizeLabels(out)
out = normalizeAssignees(out, members)
return out.replace(/\s+/g, ' ').trim()
}
// ── Multi-task segmentation ─────────────────────────────────────────────────
// A pause (utterance boundary) always splits — that's handled upstream by the
// recognizer. These spoken separators additionally split within one utterance.
// Deliberately conservative: "and then" is NOT a separator ("wash and then
// fold laundry" is one task).
const SEPARATOR_REGEX =
/\s*\b(?:and\s+also|also|next\s+task|new\s+task|another\s+task)\b[,.]?\s*/gi
export const splitSpokenSegments = text =>
text
.split(SEPARATOR_REGEX)
.map(s => s.trim().replace(/^[,.]\s*/, ''))
.filter(Boolean)
// ── "Scratch that" correction ───────────────────────────────────────────────
// Everything spoken before the command dies. If the command opens the
// utterance ("…pause… scratch that"), the previously committed task dies
// instead. Words after the command carry on as the replacement.
const SCRATCH_REGEX =
/\s*\b(?:(?:scratch|forget|delete|remove|cancel)\s+(?:that|this|it|last(?:\s+one)?)|never\s?mind)\b[,.]?\s*/gi
export const applyScratchThat = text => {
const parts = text.split(SCRATCH_REGEX)
if (parts.length === 1) {
return { text: text.trim(), dropPrevious: false }
}
const before = parts.slice(0, -1).join(' ').trim()
const after = parts[parts.length - 1].trim()
return { text: after, dropPrevious: before.length === 0 }
}