- Add VoiceRecordButton with MediaRecorder API, recording/transcribing/idle states - Add POST /transcribe endpoint to chat-files.ts using execFileAsync (safe, no shell) - Tries whisper-cpp first, falls back to openai-whisper Python CLI - Returns 503 with helpful message if whisper is not installed
109 lines
3 KiB
TypeScript
109 lines
3 KiB
TypeScript
import { useState, useRef, useCallback } from "react";
|
|
import { Mic, Square, Loader2 } from "lucide-react";
|
|
import { Button } from "./ui/button";
|
|
|
|
interface VoiceRecordButtonProps {
|
|
onTranscription: (text: string) => void;
|
|
disabled?: boolean;
|
|
}
|
|
|
|
export function VoiceRecordButton({ onTranscription, disabled }: VoiceRecordButtonProps) {
|
|
const [recording, setRecording] = useState(false);
|
|
const [transcribing, setTranscribing] = useState(false);
|
|
const mediaRecorderRef = useRef<MediaRecorder | null>(null);
|
|
const chunksRef = useRef<Blob[]>([]);
|
|
|
|
const startRecording = useCallback(async () => {
|
|
try {
|
|
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
|
const mediaRecorder = new MediaRecorder(stream, {
|
|
mimeType: MediaRecorder.isTypeSupported("audio/webm;codecs=opus")
|
|
? "audio/webm;codecs=opus"
|
|
: "audio/webm",
|
|
});
|
|
|
|
chunksRef.current = [];
|
|
mediaRecorder.ondataavailable = (e) => {
|
|
if (e.data.size > 0) chunksRef.current.push(e.data);
|
|
};
|
|
|
|
mediaRecorder.onstop = async () => {
|
|
stream.getTracks().forEach((t) => t.stop());
|
|
const blob = new Blob(chunksRef.current, { type: "audio/webm" });
|
|
if (blob.size === 0) return;
|
|
|
|
setTranscribing(true);
|
|
try {
|
|
const formData = new FormData();
|
|
formData.append("audio", blob, "recording.webm");
|
|
|
|
const res = await fetch("/api/transcribe", {
|
|
method: "POST",
|
|
credentials: "include",
|
|
body: formData,
|
|
});
|
|
|
|
if (res.ok) {
|
|
const data = (await res.json()) as { text: string };
|
|
if (data.text?.trim()) {
|
|
onTranscription(data.text.trim());
|
|
}
|
|
}
|
|
} finally {
|
|
setTranscribing(false);
|
|
}
|
|
};
|
|
|
|
mediaRecorderRef.current = mediaRecorder;
|
|
mediaRecorder.start(250); // 250ms chunks
|
|
setRecording(true);
|
|
} catch {
|
|
// Microphone permission denied or unavailable
|
|
}
|
|
}, [onTranscription]);
|
|
|
|
const stopRecording = useCallback(() => {
|
|
if (mediaRecorderRef.current?.state === "recording") {
|
|
mediaRecorderRef.current.stop();
|
|
mediaRecorderRef.current = null;
|
|
}
|
|
setRecording(false);
|
|
}, []);
|
|
|
|
if (transcribing) {
|
|
return (
|
|
<Button variant="ghost" size="icon" className="h-8 w-8" disabled>
|
|
<Loader2 className="h-4 w-4 animate-spin" />
|
|
</Button>
|
|
);
|
|
}
|
|
|
|
if (recording) {
|
|
return (
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
className="h-8 w-8 text-destructive"
|
|
onClick={stopRecording}
|
|
aria-label="Stop recording"
|
|
title="Stop recording"
|
|
>
|
|
<Square className="h-4 w-4" />
|
|
</Button>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
className="h-8 w-8"
|
|
onClick={startRecording}
|
|
disabled={disabled}
|
|
aria-label="Voice input"
|
|
title="Voice input"
|
|
>
|
|
<Mic className="h-4 w-4" />
|
|
</Button>
|
|
);
|
|
}
|