All checks were successful
build-and-deploy / build-and-deploy (push) Successful in 1m11s
- Preferir grabacion en ogg/opus en el navegador - Forzar mimetype a audio/ogg; codecs=opus para PTT en servidor - WhatsApp requiere ogg para notas de voz
249 lines
6.3 KiB
TypeScript
249 lines
6.3 KiB
TypeScript
/**
|
|
* Composable for recording audio (voice messages)
|
|
* Uses MediaRecorder API to capture audio from microphone
|
|
*/
|
|
|
|
interface AudioRecorderState {
|
|
isRecording: boolean
|
|
isPaused: boolean
|
|
duration: number
|
|
audioBlob: Blob | null
|
|
audioUrl: string | null
|
|
error: string | null
|
|
}
|
|
|
|
export function useAudioRecorder() {
|
|
const state = reactive<AudioRecorderState>({
|
|
isRecording: false,
|
|
isPaused: false,
|
|
duration: 0,
|
|
audioBlob: null,
|
|
audioUrl: null,
|
|
error: null
|
|
})
|
|
|
|
let mediaRecorder: MediaRecorder | null = null
|
|
let audioChunks: Blob[] = []
|
|
let stream: MediaStream | null = null
|
|
let durationInterval: NodeJS.Timeout | null = null
|
|
let startTime: number = 0
|
|
|
|
// Check if browser supports audio recording
|
|
const isSupported = computed(() => {
|
|
return typeof navigator !== 'undefined' &&
|
|
navigator.mediaDevices &&
|
|
typeof navigator.mediaDevices.getUserMedia === 'function'
|
|
})
|
|
|
|
// Get preferred MIME type for recording
|
|
// Prefer ogg/opus as it's required by WhatsApp for voice notes
|
|
const getMimeType = (): string => {
|
|
const types = [
|
|
'audio/ogg;codecs=opus', // Preferred for WhatsApp PTT
|
|
'audio/ogg',
|
|
'audio/webm;codecs=opus',
|
|
'audio/webm',
|
|
'audio/mp4',
|
|
'audio/mpeg'
|
|
]
|
|
|
|
for (const type of types) {
|
|
if (MediaRecorder.isTypeSupported(type)) {
|
|
console.log('[AudioRecorder] Using MIME type:', type)
|
|
return type
|
|
}
|
|
}
|
|
|
|
console.warn('[AudioRecorder] No preferred type supported, using webm fallback')
|
|
return 'audio/webm' // fallback
|
|
}
|
|
|
|
// Start recording
|
|
const startRecording = async (): Promise<boolean> => {
|
|
if (!isSupported.value) {
|
|
state.error = 'La grabación de audio no está soportada en este navegador'
|
|
return false
|
|
}
|
|
|
|
try {
|
|
// Reset state
|
|
state.error = null
|
|
state.audioBlob = null
|
|
state.audioUrl = null
|
|
audioChunks = []
|
|
|
|
// Request microphone access
|
|
stream = await navigator.mediaDevices.getUserMedia({
|
|
audio: {
|
|
echoCancellation: true,
|
|
noiseSuppression: true,
|
|
autoGainControl: true
|
|
}
|
|
})
|
|
|
|
// Create MediaRecorder
|
|
const mimeType = getMimeType()
|
|
mediaRecorder = new MediaRecorder(stream, { mimeType })
|
|
|
|
// Handle data available
|
|
mediaRecorder.ondataavailable = (event) => {
|
|
if (event.data.size > 0) {
|
|
audioChunks.push(event.data)
|
|
}
|
|
}
|
|
|
|
// Handle recording stop
|
|
mediaRecorder.onstop = () => {
|
|
// Create blob from chunks
|
|
const mimeType = mediaRecorder?.mimeType || 'audio/webm'
|
|
state.audioBlob = new Blob(audioChunks, { type: mimeType })
|
|
state.audioUrl = URL.createObjectURL(state.audioBlob)
|
|
|
|
// Stop all tracks
|
|
if (stream) {
|
|
stream.getTracks().forEach(track => track.stop())
|
|
stream = null
|
|
}
|
|
|
|
// Clear interval
|
|
if (durationInterval) {
|
|
clearInterval(durationInterval)
|
|
durationInterval = null
|
|
}
|
|
|
|
state.isRecording = false
|
|
state.isPaused = false
|
|
}
|
|
|
|
// Handle errors
|
|
mediaRecorder.onerror = (event: any) => {
|
|
console.error('MediaRecorder error:', event.error)
|
|
state.error = 'Error durante la grabación'
|
|
stopRecording()
|
|
}
|
|
|
|
// Start recording
|
|
mediaRecorder.start(100) // Collect data every 100ms
|
|
state.isRecording = true
|
|
startTime = Date.now()
|
|
|
|
// Update duration every 100ms
|
|
durationInterval = setInterval(() => {
|
|
if (state.isRecording && !state.isPaused) {
|
|
state.duration = Math.floor((Date.now() - startTime) / 1000)
|
|
}
|
|
}, 100)
|
|
|
|
return true
|
|
} catch (error: any) {
|
|
console.error('Error starting recording:', error)
|
|
|
|
if (error.name === 'NotAllowedError') {
|
|
state.error = 'Permiso de micrófono denegado'
|
|
} else if (error.name === 'NotFoundError') {
|
|
state.error = 'No se encontró micrófono'
|
|
} else {
|
|
state.error = 'Error al iniciar la grabación'
|
|
}
|
|
|
|
return false
|
|
}
|
|
}
|
|
|
|
// Stop recording and finalize
|
|
const stopRecording = (): void => {
|
|
if (mediaRecorder && state.isRecording) {
|
|
mediaRecorder.stop()
|
|
}
|
|
}
|
|
|
|
// Cancel recording without saving
|
|
const cancelRecording = (): void => {
|
|
if (mediaRecorder && state.isRecording) {
|
|
mediaRecorder.stop()
|
|
}
|
|
|
|
// Clean up without keeping the audio
|
|
state.audioBlob = null
|
|
state.audioUrl = null
|
|
state.duration = 0
|
|
|
|
if (stream) {
|
|
stream.getTracks().forEach(track => track.stop())
|
|
stream = null
|
|
}
|
|
|
|
if (durationInterval) {
|
|
clearInterval(durationInterval)
|
|
durationInterval = null
|
|
}
|
|
|
|
audioChunks = []
|
|
state.isRecording = false
|
|
state.isPaused = false
|
|
}
|
|
|
|
// Pause recording
|
|
const pauseRecording = (): void => {
|
|
if (mediaRecorder && mediaRecorder.state === 'recording') {
|
|
mediaRecorder.pause()
|
|
state.isPaused = true
|
|
}
|
|
}
|
|
|
|
// Resume recording
|
|
const resumeRecording = (): void => {
|
|
if (mediaRecorder && mediaRecorder.state === 'paused') {
|
|
mediaRecorder.resume()
|
|
state.isPaused = false
|
|
}
|
|
}
|
|
|
|
// Convert blob to File for upload
|
|
const getAudioFile = (filename?: string): File | null => {
|
|
if (!state.audioBlob) return null
|
|
|
|
const extension = state.audioBlob.type.includes('ogg') ? 'ogg' : 'webm'
|
|
const name = filename || `audio-${Date.now()}.${extension}`
|
|
|
|
return new File([state.audioBlob], name, { type: state.audioBlob.type })
|
|
}
|
|
|
|
// Clear recorded audio
|
|
const clearAudio = (): void => {
|
|
if (state.audioUrl) {
|
|
URL.revokeObjectURL(state.audioUrl)
|
|
}
|
|
state.audioBlob = null
|
|
state.audioUrl = null
|
|
state.duration = 0
|
|
state.error = null
|
|
}
|
|
|
|
// Cleanup on unmount
|
|
onUnmounted(() => {
|
|
cancelRecording()
|
|
clearAudio()
|
|
})
|
|
|
|
return {
|
|
// State
|
|
isRecording: computed(() => state.isRecording),
|
|
isPaused: computed(() => state.isPaused),
|
|
duration: computed(() => state.duration),
|
|
audioBlob: computed(() => state.audioBlob),
|
|
audioUrl: computed(() => state.audioUrl),
|
|
error: computed(() => state.error),
|
|
isSupported,
|
|
|
|
// Methods
|
|
startRecording,
|
|
stopRecording,
|
|
cancelRecording,
|
|
pauseRecording,
|
|
resumeRecording,
|
|
getAudioFile,
|
|
clearAudio
|
|
}
|
|
}
|