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
		
	
| 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();
 | |
|   }
 | |
| }
 |