94 lines
2.7 KiB
TypeScript
94 lines
2.7 KiB
TypeScript
import { ref, readonly, type Ref } from 'vue'
|
|
|
|
/**
|
|
* Push-to-talk recorder wrapping the browser MediaRecorder API.
|
|
*
|
|
* Usage:
|
|
* const { recording, error, isSupported, startRecording, stopRecording } = useVoiceRecorder()
|
|
* await startRecording()
|
|
* const blob = await stopRecording() // resolves with the recorded audio Blob
|
|
*/
|
|
export function useVoiceRecorder() {
|
|
const recording = ref(false)
|
|
const error = ref<string | null>(null)
|
|
const isSupported = typeof MediaRecorder !== 'undefined' && !!navigator.mediaDevices?.getUserMedia
|
|
|
|
let mediaRecorder: MediaRecorder | null = null
|
|
let chunks: Blob[] = []
|
|
const streamRef = ref<MediaStream | null>(null)
|
|
let resolveStop: ((blob: Blob) => void) | null = null
|
|
let rejectStop: ((err: Error) => void) | null = null
|
|
|
|
async function startRecording(): Promise<void> {
|
|
error.value = null
|
|
if (!isSupported) {
|
|
error.value = 'Audio recording is not supported in this browser'
|
|
return
|
|
}
|
|
if (recording.value) return
|
|
|
|
try {
|
|
streamRef.value = await navigator.mediaDevices.getUserMedia({ audio: true })
|
|
} catch (e) {
|
|
error.value = 'Microphone access denied'
|
|
return
|
|
}
|
|
|
|
chunks = []
|
|
const mimeType = MediaRecorder.isTypeSupported('audio/webm;codecs=opus')
|
|
? 'audio/webm;codecs=opus'
|
|
: MediaRecorder.isTypeSupported('audio/webm')
|
|
? 'audio/webm'
|
|
: ''
|
|
|
|
mediaRecorder = mimeType ? new MediaRecorder(streamRef.value!, { mimeType }) : new MediaRecorder(streamRef.value!)
|
|
|
|
mediaRecorder.ondataavailable = (e) => {
|
|
if (e.data.size > 0) chunks.push(e.data)
|
|
}
|
|
|
|
mediaRecorder.onstop = () => {
|
|
const blob = new Blob(chunks, { type: mediaRecorder?.mimeType ?? 'audio/webm' })
|
|
chunks = []
|
|
streamRef.value?.getTracks().forEach((t) => t.stop())
|
|
streamRef.value = null
|
|
recording.value = false
|
|
resolveStop?.(blob)
|
|
resolveStop = null
|
|
rejectStop = null
|
|
}
|
|
|
|
mediaRecorder.onerror = () => {
|
|
recording.value = false
|
|
error.value = 'Recording error'
|
|
rejectStop?.(new Error('MediaRecorder error'))
|
|
resolveStop = null
|
|
rejectStop = null
|
|
}
|
|
|
|
mediaRecorder.start(100) // collect in 100ms chunks
|
|
recording.value = true
|
|
}
|
|
|
|
function stopRecording(): Promise<Blob> {
|
|
return new Promise((resolve, reject) => {
|
|
if (!mediaRecorder || !recording.value) {
|
|
reject(new Error('Not recording'))
|
|
return
|
|
}
|
|
resolveStop = resolve
|
|
rejectStop = reject
|
|
mediaRecorder.stop()
|
|
})
|
|
}
|
|
|
|
return {
|
|
recording: readonly(recording),
|
|
error: readonly(error),
|
|
isSupported,
|
|
startRecording,
|
|
stopRecording,
|
|
stream: readonly(streamRef) as Readonly<Ref<MediaStream | null>>,
|
|
}
|
|
}
|