class AudioHandler { constructor() { this.mediaRecorder = null; this.audioChunks = []; this.isRecording = false; this.audioContext = null; this.selectedMicId = localStorage.getItem('selectedMicId') || null; this.selectedSpeakerId = localStorage.getItem('selectedSpeakerId') || null; this.micGain = parseFloat(localStorage.getItem('micGain') || '3.0'); } async init() { const constraints = { audio: this.selectedMicId ? { deviceId: { exact: this.selectedMicId }, autoGainControl: true, noiseSuppression: true, echoCancellation: true, } : { autoGainControl: true, noiseSuppression: true, echoCancellation: true, }, }; const stream = await navigator.mediaDevices.getUserMedia(constraints); // Apply gain boost through Web Audio API const ctx = new AudioContext(); const source = ctx.createMediaStreamSource(stream); const gainNode = ctx.createGain(); gainNode.gain.value = this.micGain; const dest = ctx.createMediaStreamDestination(); source.connect(gainNode); gainNode.connect(dest); this._liveAudioCtx = ctx; this._liveGainNode = gainNode; this.mediaRecorder = new MediaRecorder(dest.stream, { mimeType: 'audio/webm' }); this.mediaRecorder.ondataavailable = (event) => { this.audioChunks.push(event.data); }; } setMic(deviceId) { this.selectedMicId = deviceId; localStorage.setItem('selectedMicId', deviceId); } setSpeaker(deviceId) { this.selectedSpeakerId = deviceId; localStorage.setItem('selectedSpeakerId', deviceId); } setGain(value) { this.micGain = value; localStorage.setItem('micGain', value.toString()); if (this._liveGainNode) { this._liveGainNode.gain.value = value; } } startRecording() { this.audioChunks = []; this.mediaRecorder.start(); this.isRecording = true; } stopRecording() { return new Promise((resolve) => { this.mediaRecorder.onstop = async () => { const blob = new Blob(this.audioChunks, { type: 'audio/webm' }); const wavBlob = await this.convertToWav(blob); resolve(wavBlob); }; this.mediaRecorder.stop(); this.isRecording = false; }); } async convertToWav(blob) { if (!this.audioContext) { this.audioContext = new AudioContext({ sampleRate: 16000 }); } const arrayBuffer = await blob.arrayBuffer(); const audioBuffer = await this.audioContext.decodeAudioData(arrayBuffer); const numChannels = 1; const sampleRate = 16000; const bitsPerSample = 16; const offlineCtx = new OfflineAudioContext(numChannels, audioBuffer.duration * sampleRate, sampleRate); const source = offlineCtx.createBufferSource(); source.buffer = audioBuffer; source.connect(offlineCtx.destination); source.start(); const resampled = await offlineCtx.startRendering(); const samples = resampled.getChannelData(0); // Normalize audio - find peak and scale up let peak = 0; for (let i = 0; i < samples.length; i++) { const abs = Math.abs(samples[i]); if (abs > peak) peak = abs; } const normalizeGain = peak > 0.01 ? 0.9 / peak : 1; const dataLength = samples.length * (bitsPerSample / 8); const buffer = new ArrayBuffer(44 + dataLength); const view = new DataView(buffer); this.writeString(view, 0, 'RIFF'); view.setUint32(4, 36 + dataLength, true); this.writeString(view, 8, 'WAVE'); this.writeString(view, 12, 'fmt '); view.setUint32(16, 16, true); view.setUint16(20, 1, true); view.setUint16(22, numChannels, true); view.setUint32(24, sampleRate, true); view.setUint32(28, sampleRate * numChannels * (bitsPerSample / 8), true); view.setUint16(32, numChannels * (bitsPerSample / 8), true); view.setUint16(34, bitsPerSample, true); this.writeString(view, 36, 'data'); view.setUint32(40, dataLength, true); let offset = 44; for (let i = 0; i < samples.length; i++) { const amplified = samples[i] * normalizeGain; const s = Math.max(-1, Math.min(1, amplified)); view.setInt16(offset, s < 0 ? s * 0x8000 : s * 0x7FFF, true); offset += 2; } return new Blob([buffer], { type: 'audio/wav' }); } writeString(view, offset, string) { for (let i = 0; i < string.length; i++) { view.setUint8(offset + i, string.charCodeAt(i)); } } playAudioFromBase64(base64Data, mimeType = 'audio/mpeg') { return new Promise((resolve) => { const byteChars = atob(base64Data); const byteArray = new Uint8Array(byteChars.length); for (let i = 0; i < byteChars.length; i++) { byteArray[i] = byteChars.charCodeAt(i); } const blob = new Blob([byteArray], { type: mimeType }); const url = URL.createObjectURL(blob); const audio = new Audio(url); if (this.selectedSpeakerId && audio.setSinkId) { audio.setSinkId(this.selectedSpeakerId).catch(() => {}); } audio.onended = () => { URL.revokeObjectURL(url); resolve(); }; audio.play(); }); } }