dictation tap in

This commit is contained in:
Nate Kelley 2025-09-30 12:18:25 -06:00
parent 92c526a3a5
commit 376703335a
No known key found for this signature in database
GPG Key ID: FD90372AB8D98B4F
3 changed files with 254 additions and 11 deletions

View File

@ -1,5 +1,4 @@
import React, { useEffect } from 'react';
import SpeechRecognition, { useSpeechRecognition } from 'react-speech-recognition';
import { Button } from '@/components/ui/buttons';
import { ArrowUp, Magnifier, Sparkle2 } from '@/components/ui/icons';
import Atom from '@/components/ui/icons/NucleoIconOutlined/atom';
@ -16,6 +15,7 @@ import { AppTooltip } from '@/components/ui/tooltip';
import { Text } from '@/components/ui/typography';
import { cn } from '@/lib/utils';
import { useMicrophonePermission } from './hooks/useMicrophonePermission';
import { useSpeechRecognition } from '@/hooks/useSpeechRecognition';
export type BusterChatInputMode = 'auto' | 'research' | 'deep-research';
@ -42,21 +42,14 @@ export const BusterChatInputButtons = React.memo(
onDictateListeningChange,
}: BusterChatInputButtons) => {
const hasGrantedPermissions = useMicrophonePermission();
const { transcript, listening, browserSupportsSpeechRecognition } = useSpeechRecognition();
const { transcript, listening, browserSupportsSpeechRecognition, onStartListening, onStopListening } =
useSpeechRecognition();
const hasValue = useMentionInputHasValue();
const onChangeValue = useMentionInputSuggestionsOnChangeValue();
const getValue = useMentionInputSuggestionsGetValue();
const disableSubmit = !hasValue;
const startListening = async () => {
SpeechRecognition.startListening({ continuous: true });
};
const stopListening = () => {
SpeechRecognition.stopListening();
};
useEffect(() => {
if (listening && transcript) {
onDictate?.(transcript);
@ -87,7 +80,7 @@ export const BusterChatInputButtons = React.memo(
rounding={'large'}
variant={'ghost'}
prefix={<Microphone />}
onClick={listening ? stopListening : startListening}
onClick={listening ? onStopListening : onStartListening}
loading={false}
disabled={disabled}
className={cn(

View File

@ -0,0 +1,109 @@
import type { Meta, StoryObj } from '@storybook/react-vite';
import { useSpeechRecognition } from './useSpeechRecognition';
function SpeechRecognitionDemo() {
const {
onStartListening,
onStopListening,
listening,
transcript,
browserSupportsSpeechRecognition,
error,
} = useSpeechRecognition();
if (!browserSupportsSpeechRecognition) {
return (
<div style={{ padding: '2rem', fontFamily: 'sans-serif' }}>
<h2>Speech Recognition Not Supported</h2>
<p>Your browser does not support speech recognition.</p>
</div>
);
}
return (
<div style={{ padding: '2rem', fontFamily: 'sans-serif' }}>
<h2>Speech Recognition Demo</h2>
<div style={{ marginBottom: '1rem' }}>
<button
type="button"
onClick={onStartListening}
disabled={listening}
style={{
padding: '0.5rem 1rem',
marginRight: '0.5rem',
backgroundColor: listening ? '#ccc' : '#007bff',
color: 'white',
border: 'none',
borderRadius: '4px',
cursor: listening ? 'not-allowed' : 'pointer',
}}
>
Start Listening
</button>
<button
type="button"
onClick={onStopListening}
disabled={!listening}
style={{
padding: '0.5rem 1rem',
backgroundColor: !listening ? '#ccc' : '#dc3545',
color: 'white',
border: 'none',
borderRadius: '4px',
cursor: !listening ? 'not-allowed' : 'pointer',
}}
>
Stop Listening
</button>
</div>
<div style={{ marginBottom: '1rem' }}>
<strong>Status:</strong>{' '}
<span style={{ color: listening ? '#28a745' : '#6c757d' }}>
{listening ? 'Listening...' : 'Not listening'}
</span>
</div>
{error && (
<div
style={{
marginBottom: '1rem',
padding: '0.75rem',
backgroundColor: '#f8d7da',
color: '#721c24',
border: '1px solid #f5c6cb',
borderRadius: '4px',
}}
>
<strong>Error:</strong> {error}
</div>
)}
<div>
<strong>Transcript:</strong>
<div
style={{
marginTop: '0.5rem',
padding: '1rem',
border: '1px solid #ddd',
borderRadius: '4px',
minHeight: '100px',
backgroundColor: '#f8f9fa',
}}
>
{transcript || 'No speech detected yet...'}
</div>
</div>
</div>
);
}
const meta = {
title: 'Hooks/useSpeechRecognition',
component: SpeechRecognitionDemo,
parameters: {
layout: 'centered',
},
} satisfies Meta<typeof SpeechRecognitionDemo>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Default: Story = {};

View File

@ -0,0 +1,141 @@
import { useCallback, useEffect, useRef, useState } from 'react';
// Type definitions for Web Speech API
interface SpeechRecognitionErrorEvent extends Event {
error: string;
message: string;
}
interface SpeechRecognitionEvent extends Event {
resultIndex: number;
results: SpeechRecognitionResultList;
}
interface SpeechRecognition extends EventTarget {
continuous: boolean;
interimResults: boolean;
lang: string;
onstart: ((this: SpeechRecognition, ev: Event) => void) | null;
onresult: ((this: SpeechRecognition, ev: SpeechRecognitionEvent) => void) | null;
onerror: ((this: SpeechRecognition, ev: SpeechRecognitionErrorEvent) => void) | null;
onend: ((this: SpeechRecognition, ev: Event) => void) | null;
start(): void;
stop(): void;
abort(): void;
}
interface SpeechRecognitionConstructor {
new (): SpeechRecognition;
}
// Extend Window interface to include webkit speech recognition
declare global {
interface Window {
SpeechRecognition: SpeechRecognitionConstructor;
webkitSpeechRecognition: SpeechRecognitionConstructor;
}
}
interface UseSpeechRecognitionReturn {
onStartListening: () => void;
onStopListening: () => void;
listening: boolean;
transcript: string;
browserSupportsSpeechRecognition: boolean;
error: string | null;
}
export function useSpeechRecognition(): UseSpeechRecognitionReturn {
const [listening, setListening] = useState(false);
const [transcript, setTranscript] = useState('');
const recognitionRef = useRef<SpeechRecognition | null>(null);
const [error, setError] = useState<string | null>(null);
// Check browser support
const browserSupportsSpeechRecognition =
typeof window !== 'undefined' && (window.SpeechRecognition || window.webkitSpeechRecognition);
// Initialize speech recognition
useEffect(() => {
if (!browserSupportsSpeechRecognition) {
return;
}
const SpeechRecognitionAPI = window.SpeechRecognition || window.webkitSpeechRecognition;
const recognition = new SpeechRecognitionAPI();
recognition.continuous = true;
recognition.interimResults = true;
recognition.lang = 'en-US';
recognition.onstart = () => {
setListening(true);
};
recognition.onresult = (event: SpeechRecognitionEvent) => {
let interimTranscript = '';
let finalTranscript = '';
for (let i = event.resultIndex; i < event.results.length; i++) {
const transcriptPiece = event.results[i][0].transcript;
if (event.results[i].isFinal) {
finalTranscript += transcriptPiece;
} else {
interimTranscript += transcriptPiece;
}
}
setTranscript(finalTranscript || interimTranscript);
};
recognition.onerror = (event: SpeechRecognitionErrorEvent) => {
console.error('Speech recognition error:', event.error);
if (event.error.includes('language-not-supported')) {
setError('Browser does not support dictation');
} else {
setError(event.error);
}
onStopListening();
};
recognition.onend = () => {
setListening(false);
};
recognitionRef.current = recognition;
return () => {
recognition.stop();
};
}, [browserSupportsSpeechRecognition]);
const onStartListening = useCallback(async () => {
if (recognitionRef.current && !listening) {
try {
// Request microphone permission
await navigator.mediaDevices.getUserMedia({ audio: true });
setTranscript('');
recognitionRef.current.start();
} catch (error) {
console.error('Microphone permission denied:', error);
}
}
}, [listening]);
const onStopListening = useCallback(() => {
if (recognitionRef.current) {
recognitionRef.current.stop();
setListening(false);
}
}, [listening]);
return {
onStartListening,
onStopListening,
listening,
error,
transcript,
browserSupportsSpeechRecognition: Boolean(browserSupportsSpeechRecognition),
};
}