integratedhub/islands/VoiceRecorder.tsx
2026-01-20 09:02:26 -06:00

264 lines
7.6 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { useEffect, useRef, useState } from "preact/hooks";
interface VoiceRecorderProps {
text: string;
audio_file: string;
role: string;
recordingCallback: (data: Blob | null) => void;
}
export default function VoiceRecorder(
{ text, audio_file, role, recordingCallback }: VoiceRecorderProps,
) {
const [recording, setRecording] = useState(false);
const [audioUrl, setAudioUrl] = useState<string | null>(null);
const [initialAudioUrl, setInitialAudioUrl] = useState<string | null>(null);
const [audioBlob, setAudioBlob] = useState<Blob | null>(null);
const mediaRecorderRef = useRef<MediaRecorder | null>(null);
const chunksRef = useRef<Blob[]>([]);
const selectedMimeTypeRef = useRef<string | undefined>(undefined);
const initialAudioRef = useRef<HTMLAudioElement>(null);
const pickBestAudioMimeType = (): string | undefined => {
if (typeof MediaRecorder === "undefined") return undefined;
if (typeof MediaRecorder.isTypeSupported !== "function") return undefined;
const candidates = [
"audio/mp4",
"audio/mp4;codecs=mp4a.40.2",
"audio/webm;codecs=opus",
"audio/webm",
"audio/ogg;codecs=opus",
"audio/ogg",
];
return candidates.find((t) => MediaRecorder.isTypeSupported(t));
};
// Fetch and play the initial audio_file when the component mounts
useEffect(() => {
if (!audio_file) return;
const fetchAudio = async () => {
try {
const isDirectUrl = audio_file.startsWith("blob:") ||
audio_file.startsWith("http") ||
audio_file.startsWith("/");
if (isDirectUrl) {
setInitialAudioUrl(audio_file);
return;
}
const response = await fetch(
`/api/audio-blob/${encodeURIComponent(audio_file)}`,
{ credentials: "include" },
);
if (!response.ok) {
console.error("Failed to fetch audio file:", response.statusText);
return;
}
const blob = await response.blob();
const url = URL.createObjectURL(blob);
setInitialAudioUrl(url);
} catch (err) {
console.error("Error fetching audio file:", err);
}
};
fetchAudio();
// Cleanup blob URL on unmount
return () => {
if (initialAudioUrl) {
URL.revokeObjectURL(initialAudioUrl);
}
};
}, [audio_file]);
// Auto-play when initial audio is loaded
useEffect(() => {
if (initialAudioUrl && initialAudioRef.current) {
initialAudioRef.current.play().catch(() => {
// Autoplay might be blocked, user must interact
});
}
}, [initialAudioUrl]);
const startRecording = async () => {
if (audioUrl) {
URL.revokeObjectURL(audioUrl);
}
setAudioUrl(null);
setAudioBlob(null);
try {
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
if (typeof MediaRecorder === "undefined") {
alert("Audio recording is not supported in this browser.");
stream.getTracks().forEach((t) => t.stop());
return;
}
const bestMimeType = pickBestAudioMimeType();
const mimeTypeCandidates = [
bestMimeType,
"audio/mp4",
"audio/mp4;codecs=mp4a.40.2",
"audio/webm;codecs=opus",
"audio/webm",
"audio/ogg;codecs=opus",
"audio/ogg",
undefined,
].filter((t, i, arr) => arr.indexOf(t) === i);
let mediaRecorder: MediaRecorder | null = null;
let lastError: unknown = undefined;
for (const candidate of mimeTypeCandidates) {
try {
mediaRecorder = candidate
? new MediaRecorder(stream, { mimeType: candidate })
: new MediaRecorder(stream);
selectedMimeTypeRef.current = candidate;
break;
} catch (err) {
lastError = err;
mediaRecorder = null;
continue;
}
}
if (!mediaRecorder) {
console.error("Failed to create MediaRecorder", lastError);
alert("Audio recording is not supported in this browser.");
stream.getTracks().forEach((t) => t.stop());
return;
}
mediaRecorderRef.current = mediaRecorder;
mediaRecorder.onerror = (e) => {
const recErr = (e as ErrorEvent | Event) as unknown as {
error?: DOMException;
};
const err = recErr?.error;
console.error("MediaRecorder error", err || e);
stream.getTracks().forEach((t) => t.stop());
setRecording(false);
alert(
"Recording failed. This Safari version may not support MediaRecorder with audio-only streams.",
);
};
mediaRecorder.ondataavailable = (e) => {
if (e.data.size > 0) {
chunksRef.current.push(e.data);
}
};
mediaRecorder.onstop = () => {
const fallbackType = chunksRef.current[0]?.type ||
mediaRecorder.mimeType ||
selectedMimeTypeRef.current ||
"";
const blob = new Blob(
chunksRef.current,
fallbackType ? { type: fallbackType } : undefined,
);
chunksRef.current = [];
setAudioBlob(blob);
setAudioUrl(URL.createObjectURL(blob));
stream.getTracks().forEach((t) => t.stop());
};
mediaRecorder.start();
setRecording(true);
} catch (err) {
alert("Microphone access denied or not supported.");
}
};
const stopRecording = () => {
mediaRecorderRef.current?.stop();
setRecording(false);
};
const handleSave = () => {
if (audioBlob) {
recordingCallback(audioBlob);
}
};
const handleClose = () => {
recordingCallback(null);
};
return (
<div className="theme-popup-overlay">
<div
className="theme-recorder-container"
onClick={(e) => e.stopPropagation()}
>
<button
onClick={handleClose}
aria-label="Close"
className="theme-recorder-close"
>
×
</button>
<div className="flex-1 flex items-center justify-center w-full mb-6">
<div className="text-center w-full">{text}</div>
</div>
{/* Initial audio playback */}
<div className="mb-4 w-full flex justify-center">
{initialAudioUrl && (
<audio
ref={initialAudioRef}
src={initialAudioUrl}
autoPlay
controls
className="w-full"
/>
)}
</div>
{role === "admin" && (
<div className="flex gap-4 justify-center w-full mb-4">
<button
className={`theme-recorder-button theme-recorder-start ${
recording ? "opacity-60 pointer-events-none" : ""
}`}
onClick={startRecording}
disabled={recording}
>
🎤 Start
</button>
<button
className={`theme-recorder-button theme-recorder-stop ${
!recording ? "opacity-50 cursor-not-allowed" : ""
}`}
onClick={stopRecording}
disabled={!recording}
>
⏹ Stop
</button>
<button
className={`theme-recorder-button theme-recorder-save ${
!audioUrl ? "opacity-50 cursor-not-allowed" : ""
}`}
onClick={handleSave}
disabled={!audioUrl}
>
💾 Save
</button>
</div>
)}
<div className="mt-4 h-[30px] w-full flex justify-center items-center">
{audioUrl && <audio controls src={audioUrl}></audio>}
</div>
</div>
</div>
);
}