172 lines
5.7 KiB
JavaScript
172 lines
5.7 KiB
JavaScript
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();
|
|
});
|
|
}
|
|
}
|