feat: replace ScriptProcessorNode with AudioWorkletNode for transmit

- Create audio-worklet.js with MicrophoneProcessor for audio capture
- Implement noise gate and RMS calculation in worklet
- Send PCM data via MessagePort to main thread
- Update startStreaming to use AudioWorkletNode instead of deprecated ScriptProcessorNode
- Remove WebCodecs decoder complexity from listen
- Keep simple PCM playback for listen feature
This commit is contained in:
MythEclipse
2026-05-13 22:40:39 +07:00
parent 0f30a4aa67
commit bd8e5b78d8
2 changed files with 56 additions and 85 deletions

42
public/audio-worklet.js Normal file
View File

@@ -0,0 +1,42 @@
class MicrophoneProcessor extends AudioWorkletProcessor {
constructor() {
super();
this.noiseGateThreshold = 0.01;
this.noiseGateHoldFrames = 3;
this.noiseGateHold = 0;
}
process(inputs, outputs, parameters) {
const input = inputs[0];
if (!input || input.length === 0) return true;
const inputData = input[0];
const output = outputs[0];
if (output && output.length > 0) {
output[0].set(inputData);
}
let sum = 0;
for (let i = 0; i < inputData.length; i++) {
sum += inputData[i] * inputData[i];
}
const rms = Math.sqrt(sum / inputData.length);
if (rms < this.noiseGateThreshold && this.noiseGateHold <= 0) {
this.port.postMessage({ type: 'audio', rms: 0, data: null });
return true;
}
this.noiseGateHold = rms >= this.noiseGateThreshold ? this.noiseGateHoldFrames : this.noiseGateHold - 1;
const pcm = new Int16Array(inputData.length);
for (let i = 0; i < inputData.length; i++) {
pcm[i] = Math.max(-1, Math.min(1, inputData[i])) * 32767;
}
this.port.postMessage({ type: 'audio', rms, data: pcm.buffer }, [pcm.buffer]);
return true;
}
}
registerProcessor('microphone-processor', MicrophoneProcessor);

View File

@@ -356,23 +356,23 @@ const state = {
try { try {
const stream = await navigator.mediaDevices.getUserMedia({ audio: true }); const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
state.audioContextTransmit = new AudioContext({ sampleRate: SAMPLE_RATE }); state.audioContextTransmit = new AudioContext({ sampleRate: SAMPLE_RATE });
await state.audioContextTransmit.audioWorklet.addModule('/audio-worklet.js');
const source = state.audioContextTransmit.createMediaStreamSource(stream); const source = state.audioContextTransmit.createMediaStreamSource(stream);
state.processor = state.audioContextTransmit.createScriptProcessor(2048, 1, 1); state.processor = new AudioWorkletNode(state.audioContextTransmit, 'microphone-processor');
state.processor.port.onmessage = (event) => {
if (!state.isStreaming || state.socket?.readyState !== WebSocket.OPEN) return;
const { type, rms, data } = event.data;
if (type === 'audio' && data) {
state.socket.send(data);
updateVisualizer(rms);
}
};
source.connect(state.processor); source.connect(state.processor);
state.processor.connect(state.audioContextTransmit.destination); state.processor.connect(state.audioContextTransmit.destination);
state.processor.onaudioprocess = (event) => {
if (!state.isStreaming || state.socket?.readyState !== WebSocket.OPEN) return;
const input = event.inputBuffer.getChannelData(0);
let sum = 0;
for (let i = 0; i < input.length; i++) sum += input[i] * input[i];
const rms = Math.sqrt(sum / input.length);
if (rms < NOISE_GATE_THRESHOLD && state.noiseGateHold <= 0) return;
state.noiseGateHold = rms >= NOISE_GATE_THRESHOLD ? NOISE_GATE_HOLD_FRAMES : state.noiseGateHold - 1;
const pcm = new Int16Array(input.length);
for (let i = 0; i < input.length; i++) pcm[i] = Math.max(-1, Math.min(1, input[i])) * 32767;
state.socket.send(pcm.buffer);
updateVisualizer(rms);
};
state.isStreaming = true; state.isStreaming = true;
el.toggleBtn.textContent = 'Stop Transmitting'; el.toggleBtn.textContent = 'Stop Transmitting';
} catch (error) { } catch (error) {
@@ -395,87 +395,16 @@ const state = {
if (state.isListening) { if (state.isListening) {
state.audioContextListen = new AudioContext({ sampleRate: 24000 }); state.audioContextListen = new AudioContext({ sampleRate: 24000 });
state.nextStartTime = state.audioContextListen.currentTime; state.nextStartTime = state.audioContextListen.currentTime;
initOpusDecoder();
el.listenBtn.textContent = 'Leave Listen Channel'; el.listenBtn.textContent = 'Leave Listen Channel';
el.listenStatus.textContent = 'speaker on'; el.listenStatus.textContent = 'speaker on';
} else { } else {
state.audioContextListen?.close(); state.audioContextListen?.close();
state.audioContextListen = null; state.audioContextListen = null;
if (state.opusDecoder) {
state.opusDecoder.close();
}
state.opusDecoder = null;
state.opusDecoderReady = false;
state.opusDecodeQueue = [];
el.listenBtn.textContent = 'Join Listen Channel'; el.listenBtn.textContent = 'Join Listen Channel';
el.listenStatus.textContent = 'speaker off'; el.listenStatus.textContent = 'speaker off';
} }
} }
async function initOpusDecoder() {
if (!window.AudioDecoder) {
showError('WebCodecs AudioDecoder not supported in this browser');
state.isListening = false;
el.listenBtn.textContent = 'Join Listen Channel';
el.listenStatus.textContent = 'speaker off';
return;
}
try {
state.opusDecoder = new AudioDecoder({
output: (audioData) => playAudioDataDirect(audioData),
error: (error) => {
console.error('Opus decode error:', error);
showError(`Opus decode error: ${error.message}`);
},
});
state.opusDecoder.configure({
codec: 'opus',
sampleRate: 48000,
numberOfChannels: 2,
});
state.opusDecoderReady = true;
processOpusQueue();
} catch (error) {
showError(`Failed to init Opus decoder: ${error.message}`);
state.isListening = false;
el.listenBtn.textContent = 'Join Listen Channel';
el.listenStatus.textContent = 'speaker off';
}
}
function playAudioDataDirect(audioData) {
if (!state.audioContextListen || !state.isListening) {
audioData.close();
return;
}
try {
const sampleRate = audioData.sampleRate;
const frameCount = audioData.numberOfFrames;
const numberOfChannels = audioData.numberOfChannels;
const audioBuffer = state.audioContextListen.createBuffer(
numberOfChannels,
frameCount,
sampleRate
);
for (let ch = 0; ch < numberOfChannels; ch++) {
const channelData = audioBuffer.getChannelData(ch);
const tempArray = new Float32Array(frameCount);
audioData.copyTo(tempArray, { planeIndex: ch });
channelData.set(tempArray);
}
const source = state.audioContextListen.createBufferSource();
source.buffer = audioBuffer;
source.connect(state.audioContextListen.destination);
const startAt = Math.max(state.nextStartTime, state.audioContextListen.currentTime);
source.start(startAt);
state.nextStartTime = startAt + audioBuffer.duration;
} catch (error) {
console.error('Play audio error:', error);
} finally {
audioData.close();
}
}
function decodeOpus(opusBuffer) { function decodeOpus(opusBuffer) {
if (!state.isListening || !state.opusDecoderReady) { if (!state.isListening || !state.opusDecoderReady) {
if (state.isListening) { if (state.isListening) {