| | |
| | |
| | |
| | |
| | |
| |
|
| | const WHISPER_SAMPLING_RATE = 16000; |
| |
|
| | export class AudioRecorder { |
| | constructor(onDataAvailable) { |
| | this.onDataAvailable = onDataAvailable; |
| | this.audioContext = null; |
| | this.stream = null; |
| | this.source = null; |
| | this.processor = null; |
| | this.isRecording = false; |
| | this.audioChunks = []; |
| | } |
| |
|
| | async start(deviceId = null) { |
| | |
| | |
| | |
| | |
| | try { |
| | |
| | |
| | |
| | const audioConstraints = { |
| | channelCount: 1, |
| | echoCancellation: false, |
| | noiseSuppression: false, |
| | autoGainControl: false, |
| | }; |
| |
|
| | |
| | if (deviceId) { |
| | audioConstraints.deviceId = { exact: deviceId }; |
| | } |
| |
|
| | this.stream = await navigator.mediaDevices.getUserMedia({ |
| | audio: audioConstraints |
| | }); |
| |
|
| | |
| | this.audioContext = new AudioContext(); |
| | const nativeSampleRate = this.audioContext.sampleRate; |
| |
|
| | |
| | if (this.audioContext.state === 'suspended') { |
| | await this.audioContext.resume(); |
| | } |
| |
|
| | |
| | this.source = this.audioContext.createMediaStreamSource(this.stream); |
| |
|
| | |
| | |
| | const bufferSize = 4096; |
| | this.processor = this.audioContext.createScriptProcessor(bufferSize, 1, 1); |
| |
|
| | this.processor.onaudioprocess = (event) => { |
| | if (!this.isRecording) return; |
| |
|
| | const inputData = event.inputBuffer.getChannelData(0); |
| |
|
| | |
| | const resampled = this.resample(inputData, nativeSampleRate, WHISPER_SAMPLING_RATE); |
| |
|
| | this.audioChunks.push(resampled); |
| |
|
| | if (this.onDataAvailable) { |
| | this.onDataAvailable(resampled); |
| | } |
| | }; |
| |
|
| | |
| | this.source.connect(this.processor); |
| | this.processor.connect(this.audioContext.destination); |
| |
|
| | this.isRecording = true; |
| |
|
| | return true; |
| | } catch (error) { |
| | console.error('Failed to start recording:', error); |
| | throw error; |
| | } |
| | } |
| |
|
| | resample(audioData, sourceSampleRate, targetSampleRate) { |
| | |
| | |
| | |
| | |
| | if (sourceSampleRate === targetSampleRate) { |
| | return new Float32Array(audioData); |
| | } |
| |
|
| | const ratio = sourceSampleRate / targetSampleRate; |
| | const newLength = Math.round(audioData.length / ratio); |
| | const result = new Float32Array(newLength); |
| |
|
| | for (let i = 0; i < newLength; i++) { |
| | const srcIndex = i * ratio; |
| | const srcIndexFloor = Math.floor(srcIndex); |
| | const srcIndexCeil = Math.min(srcIndexFloor + 1, audioData.length - 1); |
| | const t = srcIndex - srcIndexFloor; |
| |
|
| | |
| | result[i] = audioData[srcIndexFloor] * (1 - t) + audioData[srcIndexCeil] * t; |
| | } |
| |
|
| | return result; |
| | } |
| |
|
| | requestData() { |
| | |
| | |
| | |
| | |
| | } |
| |
|
| | async stop() { |
| | |
| | |
| | |
| | return new Promise((resolve) => { |
| | this.isRecording = false; |
| |
|
| | |
| | if (this.processor) { |
| | this.processor.disconnect(); |
| | this.processor = null; |
| | } |
| |
|
| | if (this.source) { |
| | this.source.disconnect(); |
| | this.source = null; |
| | } |
| |
|
| | |
| | let totalLength = 0; |
| | for (const chunk of this.audioChunks) { |
| | totalLength += chunk.length; |
| | } |
| |
|
| | const completeAudio = new Float32Array(totalLength); |
| | let offset = 0; |
| | for (const chunk of this.audioChunks) { |
| | completeAudio.set(chunk, offset); |
| | offset += chunk.length; |
| | } |
| |
|
| | |
| | this.cleanup(); |
| |
|
| | resolve(completeAudio); |
| | }); |
| | } |
| |
|
| | cleanup() { |
| | |
| | |
| | |
| | if (this.stream) { |
| | this.stream.getTracks().forEach(track => track.stop()); |
| | this.stream = null; |
| | } |
| |
|
| | if (this.audioContext && this.audioContext.state !== 'closed') { |
| | this.audioContext.close(); |
| | this.audioContext = null; |
| | } |
| |
|
| | this.audioChunks = []; |
| | this.isRecording = false; |
| | } |
| | } |
| |
|
| | export class AudioProcessor { |
| | |
| | |
| | |
| | constructor(sampleRate = WHISPER_SAMPLING_RATE) { |
| | this.sampleRate = sampleRate; |
| | this.audioBuffer = new Float32Array(0); |
| | } |
| |
|
| | appendChunk(chunk) { |
| | |
| | |
| | |
| | const newBuffer = new Float32Array(this.audioBuffer.length + chunk.length); |
| | newBuffer.set(this.audioBuffer); |
| | newBuffer.set(chunk, this.audioBuffer.length); |
| | this.audioBuffer = newBuffer; |
| | } |
| |
|
| | getBuffer() { |
| | |
| | |
| | |
| | return this.audioBuffer; |
| | } |
| |
|
| | getDuration() { |
| | |
| | |
| | |
| | return this.audioBuffer.length / this.sampleRate; |
| | } |
| |
|
| | reset() { |
| | |
| | |
| | |
| | this.audioBuffer = new Float32Array(0); |
| | } |
| |
|
| | trimToSize(maxDuration) { |
| | |
| | |
| | |
| | const maxSamples = Math.floor(maxDuration * this.sampleRate); |
| | if (this.audioBuffer.length > maxSamples) { |
| | this.audioBuffer = this.audioBuffer.slice(-maxSamples); |
| | } |
| | } |
| | } |
| |
|
| | export { WHISPER_SAMPLING_RATE }; |
| |
|