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