You cannot select more than 25 topics
			Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
		
		
		
		
		
			
		
			
	
	
		
			201 lines
		
	
	
		
			6.7 KiB
		
	
	
	
		
			TypeScript
		
	
		
		
			
		
	
	
			201 lines
		
	
	
		
			6.7 KiB
		
	
	
	
		
			TypeScript
		
	
| 
											9 months ago
										 | 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(); | ||
|  |   } | ||
|  | } |