Spaces:
Paused
Paused
| export class AudioHandler { | |
| private context: AudioContext; | |
| private mergeNode: ChannelMergerNode; | |
| private analyserData: Uint8Array; | |
| public analyser: AnalyserNode; | |
| private workletNode: AudioWorkletNode | null = null; | |
| private stream: MediaStream | null = null; | |
| private source: MediaStreamAudioSourceNode | null = null; | |
| private recordBuffer: Int16Array[] = []; | |
| private readonly sampleRate = 24000; | |
| private nextPlayTime: number = 0; | |
| private isPlaying: boolean = false; | |
| private playbackQueue: AudioBufferSourceNode[] = []; | |
| private playBuffer: Int16Array[] = []; | |
| constructor() { | |
| this.context = new AudioContext({ sampleRate: this.sampleRate }); | |
| // using ChannelMergerNode to get merged audio data, and then get analyser data. | |
| this.mergeNode = new ChannelMergerNode(this.context, { numberOfInputs: 2 }); | |
| this.analyser = new AnalyserNode(this.context, { fftSize: 256 }); | |
| this.analyserData = new Uint8Array(this.analyser.frequencyBinCount); | |
| this.mergeNode.connect(this.analyser); | |
| } | |
| getByteFrequencyData() { | |
| this.analyser.getByteFrequencyData(this.analyserData); | |
| return this.analyserData; | |
| } | |
| async initialize() { | |
| await this.context.audioWorklet.addModule("/audio-processor.js"); | |
| } | |
| async startRecording(onChunk: (chunk: Uint8Array) => void) { | |
| try { | |
| if (!this.workletNode) { | |
| await this.initialize(); | |
| } | |
| this.stream = await navigator.mediaDevices.getUserMedia({ | |
| audio: { | |
| channelCount: 1, | |
| sampleRate: this.sampleRate, | |
| echoCancellation: true, | |
| noiseSuppression: true, | |
| }, | |
| }); | |
| await this.context.resume(); | |
| this.source = this.context.createMediaStreamSource(this.stream); | |
| this.workletNode = new AudioWorkletNode( | |
| this.context, | |
| "audio-recorder-processor", | |
| ); | |
| this.workletNode.port.onmessage = (event) => { | |
| if (event.data.eventType === "audio") { | |
| const float32Data = event.data.audioData; | |
| const int16Data = new Int16Array(float32Data.length); | |
| for (let i = 0; i < float32Data.length; i++) { | |
| const s = Math.max(-1, Math.min(1, float32Data[i])); | |
| int16Data[i] = s < 0 ? s * 0x8000 : s * 0x7fff; | |
| } | |
| const uint8Data = new Uint8Array(int16Data.buffer); | |
| onChunk(uint8Data); | |
| // save recordBuffer | |
| // @ts-ignore | |
| this.recordBuffer.push.apply(this.recordBuffer, int16Data); | |
| } | |
| }; | |
| this.source.connect(this.workletNode); | |
| this.source.connect(this.mergeNode, 0, 0); | |
| this.workletNode.connect(this.context.destination); | |
| this.workletNode.port.postMessage({ command: "START_RECORDING" }); | |
| } catch (error) { | |
| console.error("Error starting recording:", error); | |
| throw error; | |
| } | |
| } | |
| stopRecording() { | |
| if (!this.workletNode || !this.source || !this.stream) { | |
| throw new Error("Recording not started"); | |
| } | |
| this.workletNode.port.postMessage({ command: "STOP_RECORDING" }); | |
| this.workletNode.disconnect(); | |
| this.source.disconnect(); | |
| this.stream.getTracks().forEach((track) => track.stop()); | |
| } | |
| startStreamingPlayback() { | |
| this.isPlaying = true; | |
| this.nextPlayTime = this.context.currentTime; | |
| } | |
| stopStreamingPlayback() { | |
| this.isPlaying = false; | |
| this.playbackQueue.forEach((source) => source.stop()); | |
| this.playbackQueue = []; | |
| this.playBuffer = []; | |
| } | |
| playChunk(chunk: Uint8Array) { | |
| if (!this.isPlaying) return; | |
| const int16Data = new Int16Array(chunk.buffer); | |
| // @ts-ignore | |
| this.playBuffer.push.apply(this.playBuffer, int16Data); // save playBuffer | |
| const float32Data = new Float32Array(int16Data.length); | |
| for (let i = 0; i < int16Data.length; i++) { | |
| float32Data[i] = int16Data[i] / (int16Data[i] < 0 ? 0x8000 : 0x7fff); | |
| } | |
| const audioBuffer = this.context.createBuffer( | |
| 1, | |
| float32Data.length, | |
| this.sampleRate, | |
| ); | |
| audioBuffer.getChannelData(0).set(float32Data); | |
| const source = this.context.createBufferSource(); | |
| source.buffer = audioBuffer; | |
| source.connect(this.context.destination); | |
| source.connect(this.mergeNode, 0, 1); | |
| const chunkDuration = audioBuffer.length / this.sampleRate; | |
| source.start(this.nextPlayTime); | |
| this.playbackQueue.push(source); | |
| source.onended = () => { | |
| const index = this.playbackQueue.indexOf(source); | |
| if (index > -1) { | |
| this.playbackQueue.splice(index, 1); | |
| } | |
| }; | |
| this.nextPlayTime += chunkDuration; | |
| if (this.nextPlayTime < this.context.currentTime) { | |
| this.nextPlayTime = this.context.currentTime; | |
| } | |
| } | |
| _saveData(data: Int16Array, bytesPerSample = 16): Blob { | |
| const headerLength = 44; | |
| const numberOfChannels = 1; | |
| const byteLength = data.buffer.byteLength; | |
| const header = new Uint8Array(headerLength); | |
| const view = new DataView(header.buffer); | |
| view.setUint32(0, 1380533830, false); // RIFF identifier 'RIFF' | |
| view.setUint32(4, 36 + byteLength, true); // file length minus RIFF identifier length and file description length | |
| view.setUint32(8, 1463899717, false); // RIFF type 'WAVE' | |
| view.setUint32(12, 1718449184, false); // format chunk identifier 'fmt ' | |
| view.setUint32(16, 16, true); // format chunk length | |
| view.setUint16(20, 1, true); // sample format (raw) | |
| view.setUint16(22, numberOfChannels, true); // channel count | |
| view.setUint32(24, this.sampleRate, true); // sample rate | |
| view.setUint32(28, this.sampleRate * 4, true); // byte rate (sample rate * block align) | |
| view.setUint16(32, numberOfChannels * 2, true); // block align (channel count * bytes per sample) | |
| view.setUint16(34, bytesPerSample, true); // bits per sample | |
| view.setUint32(36, 1684108385, false); // data chunk identifier 'data' | |
| view.setUint32(40, byteLength, true); // data chunk length | |
| // using data.buffer, so no need to setUint16 to view. | |
| return new Blob([view, data.buffer], { type: "audio/mpeg" }); | |
| } | |
| savePlayFile() { | |
| // @ts-ignore | |
| return this._saveData(new Int16Array(this.playBuffer)); | |
| } | |
| saveRecordFile( | |
| audioStartMillis: number | undefined, | |
| audioEndMillis: number | undefined, | |
| ) { | |
| const startIndex = audioStartMillis | |
| ? Math.floor((audioStartMillis * this.sampleRate) / 1000) | |
| : 0; | |
| const endIndex = audioEndMillis | |
| ? Math.floor((audioEndMillis * this.sampleRate) / 1000) | |
| : this.recordBuffer.length; | |
| return this._saveData( | |
| // @ts-ignore | |
| new Int16Array(this.recordBuffer.slice(startIndex, endIndex)), | |
| ); | |
| } | |
| async close() { | |
| this.recordBuffer = []; | |
| this.workletNode?.disconnect(); | |
| this.source?.disconnect(); | |
| this.stream?.getTracks().forEach((track) => track.stop()); | |
| await this.context.close(); | |
| } | |
| } | |