mirror of
https://github.com/jamiepine/voicebox.git
synced 2026-09-17 22:00:40 -07:00
- Introduced functionality to record audio directly from the microphone, allowing users to switch between uploading files and recording. - Implemented state management for recording status and duration, with user feedback through toast notifications. - Updated Info.plist to include microphone usage description for better user transparency. - Enhanced the UI with tabs for selecting between upload and record modes, improving user experience.
190 lines
5.9 KiB
TypeScript
190 lines
5.9 KiB
TypeScript
import { useState, useRef, useCallback, useEffect } from 'react';
|
|
import { isTauri } from '@/lib/tauri';
|
|
|
|
interface UseAudioRecordingOptions {
|
|
maxDurationSeconds?: number;
|
|
onRecordingComplete?: (blob: Blob) => void;
|
|
}
|
|
|
|
export function useAudioRecording({
|
|
maxDurationSeconds = 30,
|
|
onRecordingComplete,
|
|
}: UseAudioRecordingOptions = {}) {
|
|
const [isRecording, setIsRecording] = useState(false);
|
|
const [duration, setDuration] = useState(0);
|
|
const [error, setError] = useState<string | null>(null);
|
|
const mediaRecorderRef = useRef<MediaRecorder | null>(null);
|
|
const chunksRef = useRef<Blob[]>([]);
|
|
const streamRef = useRef<MediaStream | null>(null);
|
|
const timerRef = useRef<number | null>(null);
|
|
const startTimeRef = useRef<number | null>(null);
|
|
|
|
const startRecording = useCallback(async () => {
|
|
try {
|
|
setError(null);
|
|
chunksRef.current = [];
|
|
setDuration(0);
|
|
|
|
// Check if getUserMedia is available
|
|
// In Tauri, navigator.mediaDevices might not be available immediately
|
|
if (typeof navigator === 'undefined') {
|
|
const errorMsg = 'Navigator API is not available. This might be a Tauri configuration issue.';
|
|
setError(errorMsg);
|
|
throw new Error(errorMsg);
|
|
}
|
|
|
|
if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) {
|
|
// Try waiting a bit for Tauri webview to initialize
|
|
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
|
|
if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) {
|
|
const isTauriEnv = isTauri();
|
|
console.error('MediaDevices check:', {
|
|
hasNavigator: typeof navigator !== 'undefined',
|
|
hasMediaDevices: !!navigator?.mediaDevices,
|
|
hasGetUserMedia: !!navigator?.mediaDevices?.getUserMedia,
|
|
isTauri: isTauriEnv,
|
|
});
|
|
|
|
const errorMsg = isTauriEnv
|
|
? 'Microphone access is not available. Please ensure:\n1. The app has microphone permissions in System Settings (macOS: System Settings > Privacy & Security > Microphone)\n2. You restart the app after granting permissions\n3. You are using Tauri v2 with a webview that supports getUserMedia'
|
|
: 'Microphone access is not available. Please ensure you are using a secure context (HTTPS or localhost) and that your browser has microphone permissions enabled.';
|
|
setError(errorMsg);
|
|
throw new Error(errorMsg);
|
|
}
|
|
}
|
|
|
|
// Request microphone access
|
|
const stream = await navigator.mediaDevices.getUserMedia({
|
|
audio: {
|
|
echoCancellation: true,
|
|
noiseSuppression: true,
|
|
autoGainControl: true,
|
|
},
|
|
});
|
|
|
|
streamRef.current = stream;
|
|
|
|
// Create MediaRecorder with preferred MIME type
|
|
const options: MediaRecorderOptions = {
|
|
mimeType: 'audio/webm;codecs=opus',
|
|
};
|
|
|
|
// Fallback to default if webm not supported
|
|
if (!MediaRecorder.isTypeSupported(options.mimeType!)) {
|
|
delete options.mimeType;
|
|
}
|
|
|
|
const mediaRecorder = new MediaRecorder(stream, options);
|
|
mediaRecorderRef.current = mediaRecorder;
|
|
|
|
mediaRecorder.ondataavailable = (event) => {
|
|
if (event.data.size > 0) {
|
|
chunksRef.current.push(event.data);
|
|
}
|
|
};
|
|
|
|
mediaRecorder.onstop = () => {
|
|
const blob = new Blob(chunksRef.current, { type: 'audio/webm' });
|
|
onRecordingComplete?.(blob);
|
|
|
|
// Stop all tracks
|
|
streamRef.current?.getTracks().forEach((track) => {
|
|
track.stop();
|
|
});
|
|
streamRef.current = null;
|
|
};
|
|
|
|
mediaRecorder.onerror = (event) => {
|
|
setError('Recording error occurred');
|
|
console.error('MediaRecorder error:', event);
|
|
};
|
|
|
|
// Start recording
|
|
mediaRecorder.start(100); // Collect data every 100ms
|
|
setIsRecording(true);
|
|
startTimeRef.current = Date.now();
|
|
|
|
// Start timer
|
|
timerRef.current = window.setInterval(() => {
|
|
if (startTimeRef.current) {
|
|
const elapsed = (Date.now() - startTimeRef.current) / 1000;
|
|
setDuration(elapsed);
|
|
|
|
// Auto-stop at max duration
|
|
if (elapsed >= maxDurationSeconds) {
|
|
if (mediaRecorderRef.current && mediaRecorderRef.current.state !== 'inactive') {
|
|
mediaRecorderRef.current.stop();
|
|
setIsRecording(false);
|
|
if (timerRef.current !== null) {
|
|
clearInterval(timerRef.current);
|
|
timerRef.current = null;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}, 100);
|
|
} catch (err) {
|
|
const errorMessage =
|
|
err instanceof Error
|
|
? err.message
|
|
: 'Failed to access microphone. Please check permissions.';
|
|
setError(errorMessage);
|
|
setIsRecording(false);
|
|
}
|
|
}, [maxDurationSeconds, onRecordingComplete]);
|
|
|
|
const stopRecording = useCallback(() => {
|
|
if (mediaRecorderRef.current && isRecording) {
|
|
mediaRecorderRef.current.stop();
|
|
setIsRecording(false);
|
|
|
|
if (timerRef.current !== null) {
|
|
clearInterval(timerRef.current);
|
|
timerRef.current = null;
|
|
}
|
|
}
|
|
}, [isRecording]);
|
|
|
|
const cancelRecording = useCallback(() => {
|
|
if (mediaRecorderRef.current) {
|
|
mediaRecorderRef.current.stop();
|
|
setIsRecording(false);
|
|
chunksRef.current = [];
|
|
setDuration(0);
|
|
}
|
|
|
|
// Stop all tracks
|
|
streamRef.current?.getTracks().forEach((track) => {
|
|
track.stop();
|
|
});
|
|
streamRef.current = null;
|
|
|
|
if (timerRef.current !== null) {
|
|
clearInterval(timerRef.current);
|
|
timerRef.current = null;
|
|
}
|
|
}, []);
|
|
|
|
// Cleanup on unmount
|
|
useEffect(() => {
|
|
return () => {
|
|
if (timerRef.current !== null) {
|
|
clearInterval(timerRef.current);
|
|
}
|
|
streamRef.current?.getTracks().forEach((track) => {
|
|
track.stop();
|
|
});
|
|
};
|
|
}, []);
|
|
|
|
return {
|
|
isRecording,
|
|
duration,
|
|
error,
|
|
startRecording,
|
|
stopRecording,
|
|
cancelRecording,
|
|
};
|
|
}
|