1import { MimeTypeAudio } from '$lib/enums';
2
3/**
4 * AudioRecorder - Browser-based audio recording with MediaRecorder API
5 *
6 * This class provides a complete audio recording solution using the browser's MediaRecorder API.
7 * It handles microphone access, recording state management, and audio format optimization.
8 *
9 * **Features:**
10 * - Automatic microphone permission handling
11 * - Audio enhancement (echo cancellation, noise suppression, auto gain)
12 * - Multiple format support with fallback (WAV, WebM, MP4, AAC)
13 * - Real-time recording state tracking
14 * - Proper cleanup and resource management
15 */
16export class AudioRecorder {
17 private mediaRecorder: MediaRecorder | null = null;
18 private audioChunks: Blob[] = [];
19 private stream: MediaStream | null = null;
20 private recordingState: boolean = false;
21
22 async startRecording(): Promise<void> {
23 try {
24 this.stream = await navigator.mediaDevices.getUserMedia({
25 audio: {
26 echoCancellation: true,
27 noiseSuppression: true,
28 autoGainControl: true
29 }
30 });
31
32 this.initializeRecorder(this.stream);
33
34 this.audioChunks = [];
35 // Start recording with a small timeslice to ensure we get data
36 this.mediaRecorder!.start(100);
37 this.recordingState = true;
38 } catch (error) {
39 console.error('Failed to start recording:', error);
40 throw new Error('Failed to access microphone. Please check permissions.');
41 }
42 }
43
44 async stopRecording(): Promise<Blob> {
45 return new Promise((resolve, reject) => {
46 if (!this.mediaRecorder || this.mediaRecorder.state === 'inactive') {
47 reject(new Error('No active recording to stop'));
48 return;
49 }
50
51 this.mediaRecorder.onstop = () => {
52 const mimeType = this.mediaRecorder?.mimeType || MimeTypeAudio.WAV;
53 const audioBlob = new Blob(this.audioChunks, { type: mimeType });
54
55 this.cleanup();
56
57 resolve(audioBlob);
58 };
59
60 this.mediaRecorder.onerror = (event) => {
61 console.error('Recording error:', event);
62 this.cleanup();
63 reject(new Error('Recording failed'));
64 };
65
66 this.mediaRecorder.stop();
67 });
68 }
69
70 isRecording(): boolean {
71 return this.recordingState;
72 }
73
74 cancelRecording(): void {
75 if (this.mediaRecorder && this.mediaRecorder.state !== 'inactive') {
76 this.mediaRecorder.stop();
77 }
78 this.cleanup();
79 }
80
81 private initializeRecorder(stream: MediaStream): void {
82 const options: MediaRecorderOptions = {};
83
84 if (MediaRecorder.isTypeSupported(MimeTypeAudio.WAV)) {
85 options.mimeType = MimeTypeAudio.WAV;
86 } else if (MediaRecorder.isTypeSupported(MimeTypeAudio.WEBM_OPUS)) {
87 options.mimeType = MimeTypeAudio.WEBM_OPUS;
88 } else if (MediaRecorder.isTypeSupported(MimeTypeAudio.WEBM)) {
89 options.mimeType = MimeTypeAudio.WEBM;
90 } else if (MediaRecorder.isTypeSupported(MimeTypeAudio.MP4)) {
91 options.mimeType = MimeTypeAudio.MP4;
92 } else {
93 console.warn('No preferred audio format supported, using default');
94 }
95
96 this.mediaRecorder = new MediaRecorder(stream, options);
97
98 this.mediaRecorder.ondataavailable = (event) => {
99 if (event.data.size > 0) {
100 this.audioChunks.push(event.data);
101 }
102 };
103
104 this.mediaRecorder.onstop = () => {
105 this.recordingState = false;
106 };
107
108 this.mediaRecorder.onerror = (event) => {
109 console.error('MediaRecorder error:', event);
110 this.recordingState = false;
111 };
112 }
113
114 private cleanup(): void {
115 if (this.stream) {
116 for (const track of this.stream.getTracks()) {
117 track.stop();
118 }
119
120 this.stream = null;
121 }
122 this.mediaRecorder = null;
123 this.audioChunks = [];
124 this.recordingState = false;
125 }
126}
127
128export async function convertToWav(audioBlob: Blob): Promise<Blob> {
129 try {
130 if (audioBlob.type.includes('wav')) {
131 return audioBlob;
132 }
133
134 const arrayBuffer = await audioBlob.arrayBuffer();
135
136 // eslint-disable-next-line @typescript-eslint/no-explicit-any
137 const audioContext = new (window.AudioContext || (window as any).webkitAudioContext)();
138
139 const audioBuffer = await audioContext.decodeAudioData(arrayBuffer);
140
141 const wavBlob = audioBufferToWav(audioBuffer);
142
143 audioContext.close();
144
145 return wavBlob;
146 } catch (error) {
147 console.error('Failed to convert audio to WAV:', error);
148 return audioBlob;
149 }
150}
151
152function audioBufferToWav(buffer: AudioBuffer): Blob {
153 const length = buffer.length;
154 const numberOfChannels = buffer.numberOfChannels;
155 const sampleRate = buffer.sampleRate;
156 const bytesPerSample = 2; // 16-bit
157 const blockAlign = numberOfChannels * bytesPerSample;
158 const byteRate = sampleRate * blockAlign;
159 const dataSize = length * blockAlign;
160 const bufferSize = 44 + dataSize;
161
162 const arrayBuffer = new ArrayBuffer(bufferSize);
163 const view = new DataView(arrayBuffer);
164
165 const writeString = (offset: number, string: string) => {
166 for (let i = 0; i < string.length; i++) {
167 view.setUint8(offset + i, string.charCodeAt(i));
168 }
169 };
170
171 writeString(0, 'RIFF'); // ChunkID
172 view.setUint32(4, bufferSize - 8, true); // ChunkSize
173 writeString(8, 'WAVE'); // Format
174 writeString(12, 'fmt '); // Subchunk1ID
175 view.setUint32(16, 16, true); // Subchunk1Size
176 view.setUint16(20, 1, true); // AudioFormat (PCM)
177 view.setUint16(22, numberOfChannels, true); // NumChannels
178 view.setUint32(24, sampleRate, true); // SampleRate
179 view.setUint32(28, byteRate, true); // ByteRate
180 view.setUint16(32, blockAlign, true); // BlockAlign
181 view.setUint16(34, 16, true); // BitsPerSample
182 writeString(36, 'data'); // Subchunk2ID
183 view.setUint32(40, dataSize, true); // Subchunk2Size
184
185 let offset = 44;
186 for (let i = 0; i < length; i++) {
187 for (let channel = 0; channel < numberOfChannels; channel++) {
188 const sample = Math.max(-1, Math.min(1, buffer.getChannelData(channel)[i]));
189 view.setInt16(offset, sample * 0x7fff, true);
190 offset += 2;
191 }
192 }
193
194 return new Blob([arrayBuffer], { type: MimeTypeAudio.WAV });
195}
196
197/**
198 * Create a File object from audio blob with timestamp-based naming
199 * @param audioBlob - The audio blob to wrap
200 * @param filename - Optional custom filename
201 * @returns File object with appropriate name and metadata
202 */
203export function createAudioFile(audioBlob: Blob, filename?: string): File {
204 const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
205 const extension = audioBlob.type.includes('wav') ? 'wav' : 'mp3';
206 const defaultFilename = `recording-${timestamp}.${extension}`;
207
208 return new File([audioBlob], filename || defaultFilename, {
209 type: audioBlob.type,
210 lastModified: Date.now()
211 });
212}
213
214/**
215 * Check if audio recording is supported in the current browser
216 * @returns True if MediaRecorder and getUserMedia are available
217 */
218export function isAudioRecordingSupported(): boolean {
219 return !!(
220 typeof navigator !== 'undefined' &&
221 navigator.mediaDevices &&
222 typeof navigator.mediaDevices.getUserMedia === 'function' &&
223 typeof window !== 'undefined' &&
224 window.MediaRecorder
225 );
226}