use-groq-dictation.ts4.1 KBView on GitHub import { useState, useRef, useCallback, useEffect } from 'react';
import { useTRPC } from '@/providers/query-provider';
import { useMutation } from '@tanstack/react-query';
import { toast } from 'sonner';
export function useGroqDictation(onTranscript: (text: string) => void, options?: { format?: boolean }) {
const [isRecording, setIsRecording] = useState(false);
const [isProcessing, setIsProcessing] = useState(false);
const [isFormatting, setIsFormatting] = useState(false);
const recorderRef = useRef<MediaRecorder | null>(null);
const chunksRef = useRef<Blob[]>([]);
const streamRef = useRef<MediaStream | null>(null);
const onTranscriptRef = useRef(onTranscript);
const formatRef = useRef(options?.format ?? false);
useEffect(() => {
onTranscriptRef.current = onTranscript;
}, [onTranscript]);
const trpc = useTRPC();
const { mutateAsync: transcribeAudio } = useMutation(trpc.transcription.transcribeAudio.mutationOptions());
const { mutateAsync: formatTranscription } = useMutation(trpc.transcription.formatTranscription.mutationOptions());
const transcribeAudioRef = useRef(transcribeAudio);
const formatTranscriptionRef = useRef(formatTranscription);
useEffect(() => {
transcribeAudioRef.current = transcribeAudio;
}, [transcribeAudio]);
useEffect(() => {
formatTranscriptionRef.current = formatTranscription;
}, [formatTranscription]);
const start = useCallback(async () => {
try {
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
streamRef.current = stream;
const mimeType = MediaRecorder.isTypeSupported('audio/webm;codecs=opus')
? 'audio/webm;codecs=opus'
: MediaRecorder.isTypeSupported('audio/webm')
? 'audio/webm'
: 'audio/ogg';
const recorder = new MediaRecorder(stream, { mimeType });
recorderRef.current = recorder;
chunksRef.current = [];
recorder.ondataavailable = (e) => {
if (e.data.size > 0) chunksRef.current.push(e.data);
};
recorder.onstop = async () => {
stream.getTracks().forEach((t) => t.stop());
streamRef.current = null;
const blob = new Blob(chunksRef.current, { type: mimeType });
if (blob.size < 1000) {
setIsRecording(false);
return;
}
setIsProcessing(true);
setIsRecording(false);
try {
const arrayBuffer = await blob.arrayBuffer();
const audio = btoa(
new Uint8Array(arrayBuffer).reduce(
(data, byte) => data + String.fromCharCode(byte),
'',
),
);
const data = await transcribeAudioRef.current({ audio, mimeType });
if (data.text) {
if (formatRef.current) {
setIsProcessing(false);
setIsFormatting(true);
const formatted = await formatTranscriptionRef.current({ text: data.text });
console.log('[dictation] raw:', data.text);
console.log('[dictation] formatted:', formatted.html);
onTranscriptRef.current(formatted.html);
} else {
onTranscriptRef.current(data.text);
}
}
} catch (err) {
toast.error(err instanceof Error ? err.message : 'Transcription failed');
} finally {
setIsProcessing(false);
setIsFormatting(false);
}
};
recorder.start(250);
setIsRecording(true);
} catch (err) {
toast.error(err instanceof Error ? err.message : 'Could not access microphone');
}
}, []);
const stop = useCallback(() => {
if (recorderRef.current && recorderRef.current.state !== 'inactive') {
recorderRef.current.stop();
}
}, []);
const toggle = useCallback(() => {
if (isRecording) stop();
else void start();
}, [isRecording, start, stop]);
// Cleanup on unmount
useEffect(() => {
return () => {
if (recorderRef.current && recorderRef.current.state !== 'inactive') {
recorderRef.current.stop();
}
streamRef.current?.getTracks().forEach((t) => t.stop());
};
}, []);
return { isRecording, isProcessing, isFormatting, toggle };
}