|
|
|
@@ -1,3 +1,5 @@
|
|
|
|
|
import * as Tone from 'tone';
|
|
|
|
|
|
|
|
|
|
const bootstrapData = JSON.parse(document.getElementById('__DASHBOARD_DATA__')?.textContent || '{}');
|
|
|
|
|
const state = {
|
|
|
|
|
socket: null,
|
|
|
|
@@ -6,16 +8,15 @@ const state = {
|
|
|
|
|
text: bootstrapData.messages || [],
|
|
|
|
|
isStreaming: false,
|
|
|
|
|
isListening: false,
|
|
|
|
|
audioContextTransmit: null,
|
|
|
|
|
audioContextListen: null,
|
|
|
|
|
processor: null,
|
|
|
|
|
mic: null,
|
|
|
|
|
analyser: null,
|
|
|
|
|
meter: null,
|
|
|
|
|
synth: null,
|
|
|
|
|
nextStartTime: 0,
|
|
|
|
|
noiseGateHold: 0,
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const SAMPLE_RATE = 24000;
|
|
|
|
|
const NOISE_GATE_THRESHOLD = 0.01;
|
|
|
|
|
const NOISE_GATE_HOLD_FRAMES = 3;
|
|
|
|
|
|
|
|
|
|
const el = {
|
|
|
|
|
wsDot: document.getElementById('wsDot'),
|
|
|
|
@@ -354,25 +355,35 @@ const state = {
|
|
|
|
|
|
|
|
|
|
async function startStreaming() {
|
|
|
|
|
try {
|
|
|
|
|
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
|
|
|
|
state.audioContextTransmit = new AudioContext({ sampleRate: SAMPLE_RATE });
|
|
|
|
|
const source = state.audioContextTransmit.createMediaStreamSource(stream);
|
|
|
|
|
state.processor = state.audioContextTransmit.createScriptProcessor(2048, 1, 1);
|
|
|
|
|
source.connect(state.processor);
|
|
|
|
|
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;
|
|
|
|
|
await Tone.start();
|
|
|
|
|
state.mic = new Tone.UserMedia();
|
|
|
|
|
state.analyser = new Tone.Analyser('waveform');
|
|
|
|
|
state.meter = new Tone.Meter();
|
|
|
|
|
|
|
|
|
|
state.mic.connect(state.analyser);
|
|
|
|
|
state.mic.connect(state.meter);
|
|
|
|
|
|
|
|
|
|
await state.mic.open();
|
|
|
|
|
|
|
|
|
|
const analyzeInterval = setInterval(() => {
|
|
|
|
|
if (!state.isStreaming) {
|
|
|
|
|
clearInterval(analyzeInterval);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const level = state.meter.getValue();
|
|
|
|
|
updateVisualizer(Math.max(0, level + 100) / 100);
|
|
|
|
|
|
|
|
|
|
const waveform = state.analyser.getValue();
|
|
|
|
|
if (waveform && state.socket?.readyState === WebSocket.OPEN) {
|
|
|
|
|
const pcm = new Int16Array(waveform.length);
|
|
|
|
|
for (let i = 0; i < waveform.length; i++) {
|
|
|
|
|
pcm[i] = Math.max(-1, Math.min(1, waveform[i])) * 32767;
|
|
|
|
|
}
|
|
|
|
|
state.socket.send(pcm.buffer);
|
|
|
|
|
updateVisualizer(rms);
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
}, 50);
|
|
|
|
|
|
|
|
|
|
state.isStreaming = true;
|
|
|
|
|
el.toggleBtn.textContent = 'Stop Transmitting';
|
|
|
|
|
} catch (error) {
|
|
|
|
@@ -382,10 +393,10 @@ const state = {
|
|
|
|
|
|
|
|
|
|
function stopStreaming() {
|
|
|
|
|
state.isStreaming = false;
|
|
|
|
|
state.processor?.disconnect();
|
|
|
|
|
state.audioContextTransmit?.close();
|
|
|
|
|
state.processor = null;
|
|
|
|
|
state.audioContextTransmit = null;
|
|
|
|
|
state.mic?.close();
|
|
|
|
|
state.mic = null;
|
|
|
|
|
state.analyser = null;
|
|
|
|
|
state.meter = null;
|
|
|
|
|
el.toggleBtn.textContent = 'Start Transmitting';
|
|
|
|
|
updateVisualizer(0);
|
|
|
|
|
}
|
|
|
|
@@ -393,129 +404,28 @@ const state = {
|
|
|
|
|
function toggleListen() {
|
|
|
|
|
state.isListening = !state.isListening;
|
|
|
|
|
if (state.isListening) {
|
|
|
|
|
state.audioContextListen = new AudioContext({ sampleRate: 24000 });
|
|
|
|
|
state.nextStartTime = state.audioContextListen.currentTime;
|
|
|
|
|
initOpusDecoder();
|
|
|
|
|
el.listenBtn.textContent = 'Leave Listen Channel';
|
|
|
|
|
el.listenStatus.textContent = 'speaker on';
|
|
|
|
|
} else {
|
|
|
|
|
state.audioContextListen?.close();
|
|
|
|
|
state.audioContextListen = null;
|
|
|
|
|
if (state.opusDecoder) {
|
|
|
|
|
state.opusDecoder.close();
|
|
|
|
|
}
|
|
|
|
|
state.opusDecoder = null;
|
|
|
|
|
state.opusDecoderReady = false;
|
|
|
|
|
state.opusDecodeQueue = [];
|
|
|
|
|
el.listenBtn.textContent = 'Join Listen Channel';
|
|
|
|
|
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) {
|
|
|
|
|
if (!state.isListening || !state.opusDecoderReady) {
|
|
|
|
|
if (state.isListening) {
|
|
|
|
|
state.opusDecodeQueue.push(opusBuffer);
|
|
|
|
|
}
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
try {
|
|
|
|
|
const chunk = new EncodedAudioChunk({
|
|
|
|
|
type: 'key',
|
|
|
|
|
timestamp: 0,
|
|
|
|
|
data: opusBuffer,
|
|
|
|
|
});
|
|
|
|
|
state.opusDecoder.decode(chunk);
|
|
|
|
|
} catch (error) {
|
|
|
|
|
console.error('Opus decode chunk error:', error);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function processOpusQueue() {
|
|
|
|
|
while (state.opusDecodeQueue.length > 0 && state.opusDecoderReady) {
|
|
|
|
|
const buffer = state.opusDecodeQueue.shift();
|
|
|
|
|
decodeOpus(buffer);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function playPcm(arrayBuffer) {
|
|
|
|
|
if (!state.audioContextListen) return;
|
|
|
|
|
if (!state.isListening) return;
|
|
|
|
|
const bytes = new Uint8Array(arrayBuffer);
|
|
|
|
|
if (bytes.byteLength <= 4) return;
|
|
|
|
|
const pcm = new Int16Array(bytes.buffer, bytes.byteOffset + 4, (bytes.byteLength - 4) / 2);
|
|
|
|
|
const audioBuffer = state.audioContextListen.createBuffer(1, pcm.length, 24000);
|
|
|
|
|
|
|
|
|
|
const audioBuffer = Tone.context.createBuffer(1, pcm.length, 24000);
|
|
|
|
|
const channel = audioBuffer.getChannelData(0);
|
|
|
|
|
for (let i = 0; i < pcm.length; i++) channel[i] = pcm[i] / 32768;
|
|
|
|
|
const source = state.audioContextListen.createBufferSource();
|
|
|
|
|
|
|
|
|
|
const source = Tone.context.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;
|
|
|
|
|
source.connect(Tone.context.destination);
|
|
|
|
|
source.start(Tone.now());
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function updateVisualizer(level) {
|
|
|
|
|