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}