218 lines
8.0 KiB
JavaScript
218 lines
8.0 KiB
JavaScript
/**
|
|
* Audio Recorder composable
|
|
* Handles audio recording from microphone and/or system audio
|
|
*/
|
|
|
|
import { ref, computed } from 'vue';
|
|
|
|
export function useAudioRecorder() {
|
|
// State
|
|
const isRecording = ref(false);
|
|
const isPaused = ref(false);
|
|
const audioChunks = ref([]);
|
|
const audioBlobURL = ref(null);
|
|
const recordingMode = ref('microphone');
|
|
const mediaRecorder = ref(null);
|
|
const audioContext = ref(null);
|
|
const activeStreams = ref([]);
|
|
const recordingDuration = ref(0);
|
|
const recordingSize = ref(0);
|
|
const actualBitrate = ref(128000);
|
|
const recordingTimer = ref(null);
|
|
const recordingNotes = ref('');
|
|
const showRecordingDisclaimerModal = ref(false);
|
|
const pendingRecordingMode = ref(null);
|
|
const recordingDisclaimer = ref('');
|
|
|
|
// Computed
|
|
const canRecordAudio = computed(() => navigator.mediaDevices && navigator.mediaDevices.getUserMedia);
|
|
const canRecordSystemAudio = computed(() => navigator.mediaDevices && navigator.mediaDevices.getDisplayMedia);
|
|
|
|
const recordingTimeFormatted = computed(() => {
|
|
const hours = Math.floor(recordingDuration.value / 3600);
|
|
const mins = Math.floor((recordingDuration.value % 3600) / 60);
|
|
const secs = recordingDuration.value % 60;
|
|
if (hours > 0) {
|
|
return hours + ':' + String(mins).padStart(2, '0') + ':' + String(secs).padStart(2, '0');
|
|
}
|
|
return mins + ':' + String(secs).padStart(2, '0');
|
|
});
|
|
|
|
// Methods
|
|
const startRecording = async (mode = 'microphone') => {
|
|
if (recordingDisclaimer.value && recordingDisclaimer.value.trim()) {
|
|
pendingRecordingMode.value = mode;
|
|
showRecordingDisclaimerModal.value = true;
|
|
return;
|
|
}
|
|
await startRecordingActual(mode);
|
|
};
|
|
|
|
const acceptDisclaimer = async () => {
|
|
showRecordingDisclaimerModal.value = false;
|
|
if (pendingRecordingMode.value) {
|
|
await startRecordingActual(pendingRecordingMode.value);
|
|
pendingRecordingMode.value = null;
|
|
}
|
|
};
|
|
|
|
const cancelDisclaimer = () => {
|
|
showRecordingDisclaimerModal.value = false;
|
|
pendingRecordingMode.value = null;
|
|
};
|
|
|
|
const startRecordingActual = async (mode = 'microphone') => {
|
|
recordingMode.value = mode;
|
|
audioChunks.value = [];
|
|
audioBlobURL.value = null;
|
|
recordingNotes.value = '';
|
|
activeStreams.value = [];
|
|
recordingDuration.value = 0;
|
|
recordingSize.value = 0;
|
|
|
|
try {
|
|
let combinedStream = null;
|
|
let micStream = null;
|
|
let systemStream = null;
|
|
|
|
if (mode === 'microphone' || mode === 'both') {
|
|
if (!canRecordAudio.value) throw new Error('Microphone not supported');
|
|
micStream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
|
activeStreams.value.push(micStream);
|
|
}
|
|
|
|
if (mode === 'system' || mode === 'both') {
|
|
if (!canRecordSystemAudio.value) throw new Error('System audio not supported');
|
|
try {
|
|
systemStream = await navigator.mediaDevices.getDisplayMedia({ audio: true, video: true });
|
|
if (systemStream.getAudioTracks().length === 0) {
|
|
systemStream.getVideoTracks().forEach(track => track.stop());
|
|
throw new Error('System audio permission not granted');
|
|
}
|
|
activeStreams.value.push(systemStream);
|
|
} catch (err) {
|
|
if (mode === 'system') throw err;
|
|
systemStream = null;
|
|
}
|
|
}
|
|
|
|
// Combine streams
|
|
if (micStream && systemStream) {
|
|
audioContext.value = new (window.AudioContext || window.webkitAudioContext)();
|
|
const micSource = audioContext.value.createMediaStreamSource(micStream);
|
|
const systemSource = audioContext.value.createMediaStreamSource(systemStream);
|
|
const destination = audioContext.value.createMediaStreamDestination();
|
|
micSource.connect(destination);
|
|
systemSource.connect(destination);
|
|
combinedStream = new MediaStream([destination.stream.getAudioTracks()[0]]);
|
|
} else if (systemStream) {
|
|
combinedStream = new MediaStream(systemStream.getAudioTracks());
|
|
} else if (micStream) {
|
|
combinedStream = micStream;
|
|
}
|
|
|
|
if (!combinedStream) throw new Error('No audio streams available');
|
|
|
|
// Create MediaRecorder
|
|
const options = { mimeType: 'audio/webm;codecs=opus', audioBitsPerSecond: 32000 };
|
|
if (MediaRecorder.isTypeSupported(options.mimeType)) {
|
|
mediaRecorder.value = new MediaRecorder(combinedStream, options);
|
|
actualBitrate.value = 32000;
|
|
} else {
|
|
mediaRecorder.value = new MediaRecorder(combinedStream);
|
|
actualBitrate.value = 128000;
|
|
}
|
|
|
|
mediaRecorder.value.ondataavailable = (event) => {
|
|
if (event.data && event.data.size > 0) {
|
|
audioChunks.value.push(event.data);
|
|
recordingSize.value += event.data.size;
|
|
}
|
|
};
|
|
|
|
mediaRecorder.value.onstop = () => {
|
|
const audioBlob = new Blob(audioChunks.value, { type: mediaRecorder.value.mimeType });
|
|
audioBlobURL.value = URL.createObjectURL(audioBlob);
|
|
};
|
|
|
|
mediaRecorder.value.start(1000);
|
|
isRecording.value = true;
|
|
|
|
recordingTimer.value = setInterval(() => {
|
|
recordingDuration.value++;
|
|
}, 1000);
|
|
|
|
} catch (error) {
|
|
stopAllStreams();
|
|
throw error;
|
|
}
|
|
};
|
|
|
|
const stopRecording = () => {
|
|
if (mediaRecorder.value && isRecording.value) {
|
|
mediaRecorder.value.stop();
|
|
isRecording.value = false;
|
|
isPaused.value = false;
|
|
|
|
if (recordingTimer.value) {
|
|
clearInterval(recordingTimer.value);
|
|
recordingTimer.value = null;
|
|
}
|
|
stopAllStreams();
|
|
}
|
|
};
|
|
|
|
const pauseRecording = () => {
|
|
if (mediaRecorder.value && isRecording.value && !isPaused.value) {
|
|
mediaRecorder.value.pause();
|
|
isPaused.value = true;
|
|
if (recordingTimer.value) {
|
|
clearInterval(recordingTimer.value);
|
|
recordingTimer.value = null;
|
|
}
|
|
}
|
|
};
|
|
|
|
const resumeRecording = () => {
|
|
if (mediaRecorder.value && isRecording.value && isPaused.value) {
|
|
mediaRecorder.value.resume();
|
|
isPaused.value = false;
|
|
recordingTimer.value = setInterval(() => {
|
|
recordingDuration.value++;
|
|
}, 1000);
|
|
}
|
|
};
|
|
|
|
const stopAllStreams = () => {
|
|
activeStreams.value.forEach(stream => {
|
|
stream.getTracks().forEach(track => track.stop());
|
|
});
|
|
activeStreams.value = [];
|
|
|
|
if (audioContext.value) {
|
|
audioContext.value.close().catch(e => console.error("Error closing AudioContext:", e));
|
|
audioContext.value = null;
|
|
}
|
|
};
|
|
|
|
const resetRecording = () => {
|
|
stopRecording();
|
|
audioChunks.value = [];
|
|
audioBlobURL.value = null;
|
|
recordingDuration.value = 0;
|
|
recordingSize.value = 0;
|
|
recordingNotes.value = '';
|
|
};
|
|
|
|
const getRecordingBlob = () => {
|
|
if (audioChunks.value.length === 0) return null;
|
|
return new Blob(audioChunks.value, { type: 'audio/webm' });
|
|
};
|
|
|
|
return {
|
|
isRecording, isPaused, audioBlobURL, recordingMode, recordingDuration, recordingSize, recordingNotes,
|
|
showRecordingDisclaimerModal, recordingDisclaimer, canRecordAudio, canRecordSystemAudio, recordingTimeFormatted,
|
|
startRecording, stopRecording, pauseRecording, resumeRecording, resetRecording, acceptDisclaimer, cancelDisclaimer, getRecordingBlob
|
|
};
|
|
}
|