feat: voice chat v3 — AudioWorklet, Opus/PCM, per-speaker, spatial audio, VAD
This commit is contained in:
parent
980ba6a541
commit
0a31f92af8
754
game.js
754
game.js
|
|
@ -1448,24 +1448,400 @@ function customConfirm(msg, onYes) {
|
|||
}
|
||||
}
|
||||
|
||||
// ==================== ГОЛОСОВОЙ ЧАТ ====================
|
||||
|
||||
|
||||
// ==================== ГОЛОСОВОЙ ЧАТ v3 ====================
|
||||
// ==================== ГОЛОСОВОЙ ЧАТ v3 ====================
|
||||
// Per-speaker architecture, AudioWorklet, VAD, Opus/PCM, spatial audio
|
||||
// Replaces lines 1449-1665 in game.js
|
||||
|
||||
let voiceSocket = null;
|
||||
let voiceStream = null;
|
||||
let audioCtx = null;
|
||||
let voiceProcessor = null;
|
||||
let voiceActive = false;
|
||||
let voiceMode = 'near'; // 'near' or 'world'
|
||||
let voiceDebugCount = 0;
|
||||
let voiceMode = 'near';
|
||||
const VOICE_SERVER = 'https://voicegrech.mkn8n.ru';
|
||||
|
||||
// Voice config
|
||||
const VC = {
|
||||
sampleRate: 16000, // 16kHz — sufficient for voice, saves 33% bandwidth
|
||||
frameMs: 20, // 20ms frames = 320 samples @ 16kHz
|
||||
samplesPerFrame: 320, // 16000 * 0.02
|
||||
vadThreshold: 0.008, // RMS threshold for voice detection
|
||||
vadHangover: 5, // 100ms hangover after speech ends
|
||||
jbufTargetMs: 80, // Target jitter: 80ms (was 200ms)
|
||||
jbufMinMs: 40,
|
||||
jbufMaxMs: 200,
|
||||
maxSpeakers: 6,
|
||||
voiceRadius: 600,
|
||||
opusBitrate: 16000,
|
||||
posUpdateMs: 200, // Update position every 200ms (was 500ms)
|
||||
};
|
||||
|
||||
// Codec state
|
||||
let voiceCodec = 'pcm'; // 'opus' or 'pcm'
|
||||
let voiceEncoder = null; // WebCodecs AudioEncoder
|
||||
let voiceSeq = 0;
|
||||
let voiceTimestamp = 0;
|
||||
let wasSpeaking = false;
|
||||
let silenceFrames = 0;
|
||||
let captureNode = null;
|
||||
let playbackNode = null;
|
||||
|
||||
// Per-speaker map
|
||||
const remoteSpeakers = new Map(); // socketId → { jitterBuf, lastFrame, gain, panner, lowpass, decoder, codec, x, y, name, mode, speaking, lastActive }
|
||||
|
||||
// ==================== WORKLET CODE (inline strings) ====================
|
||||
|
||||
const voiceCaptureWorkletCode = `
|
||||
class VoiceCaptureProcessor extends AudioWorkletProcessor {
|
||||
constructor() {
|
||||
super();
|
||||
this._buf = new Float32Array(320); // 20ms @ 16kHz
|
||||
this._pos = 0;
|
||||
this._speaking = false;
|
||||
this._silenceFrames = 0;
|
||||
this._hangover = 5; // 100ms
|
||||
this._vadThreshold = 0.008;
|
||||
this._lastRms = 0;
|
||||
}
|
||||
process(inputs) {
|
||||
const input = inputs[0];
|
||||
if (!input || !input[0]) return true;
|
||||
const ch = input[0];
|
||||
let i = 0;
|
||||
while (i < ch.length) {
|
||||
const rem = this._buf.length - this._pos;
|
||||
const n = Math.min(rem, ch.length - i);
|
||||
this._buf.set(ch.subarray(i, i + n), this._pos);
|
||||
this._pos += n;
|
||||
i += n;
|
||||
if (this._pos >= this._buf.length) {
|
||||
this._processFrame();
|
||||
this._pos = 0;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
_processFrame() {
|
||||
let sum = 0;
|
||||
for (let i = 0; i < this._buf.length; i++) sum += this._buf[i] * this._buf[i];
|
||||
const rms = Math.sqrt(sum / this._buf.length);
|
||||
this._lastRms = rms;
|
||||
|
||||
if (rms > this._vadThreshold) {
|
||||
this._speaking = true;
|
||||
this._silenceFrames = 0;
|
||||
} else {
|
||||
this._silenceFrames++;
|
||||
if (this._silenceFrames > this._hangover) this._speaking = false;
|
||||
}
|
||||
|
||||
this.port.postMessage({
|
||||
type: 'frame',
|
||||
samples: this._buf.slice(),
|
||||
speaking: this._speaking,
|
||||
rms: rms
|
||||
});
|
||||
}
|
||||
}
|
||||
registerProcessor('voice-capture', VoiceCaptureProcessor);
|
||||
`;
|
||||
|
||||
// Кнопка микрофона
|
||||
const voicePlaybackWorkletCode = `
|
||||
class VoicePlaybackProcessor extends AudioWorkletProcessor {
|
||||
constructor() {
|
||||
super();
|
||||
this.speakers = new Map(); // id → { ringBuf, readPos, writePos, ready, gain, pan, lastSample, active }
|
||||
this.RING_SIZE = 48000; // 3 seconds @ 16kHz
|
||||
this.port.onmessage = (e) => {
|
||||
const d = e.data;
|
||||
if (d.type === 'addSpeaker') {
|
||||
this.speakers.set(d.id, {
|
||||
ringBuf: new Float32Array(this.RING_SIZE),
|
||||
readPos: 0, writePos: 0, ready: 0,
|
||||
gain: d.gain || 1, pan: d.pan || 0,
|
||||
lastSample: 0, active: false,
|
||||
jbufTarget: 320 * 4, // ~80ms in samples
|
||||
fadeOut: 0
|
||||
});
|
||||
} else if (d.type === 'removeSpeaker') {
|
||||
this.speakers.delete(d.id);
|
||||
} else if (d.type === 'pushFrames') {
|
||||
const sp = this.speakers.get(d.id);
|
||||
if (!sp) return;
|
||||
const frames = d.samples; // Float32Array
|
||||
for (let i = 0; i < frames.length; i++) {
|
||||
sp.ringBuf[sp.writePos] = frames[i];
|
||||
sp.writePos = (sp.writePos + 1) % this.RING_SIZE;
|
||||
if (sp.ready < this.RING_SIZE) sp.ready++;
|
||||
}
|
||||
sp.active = true;
|
||||
sp.fadeOut = 0;
|
||||
} else if (d.type === 'updateSpatial') {
|
||||
const sp = this.speakers.get(d.id);
|
||||
if (sp) {
|
||||
sp.gain = d.gain;
|
||||
sp.pan = d.pan;
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
process(inputs, outputs) {
|
||||
const output = outputs[0];
|
||||
if (!output || !output[0]) return true;
|
||||
const left = output[0];
|
||||
const right = output[1] || output[0]; // mono fallback
|
||||
|
||||
// Clear output
|
||||
for (let i = 0; i < left.length; i++) { left[i] = 0; right[i] = 0; }
|
||||
|
||||
for (const [id, sp] of this.speakers) {
|
||||
if (sp.ready < 1 && sp.active) {
|
||||
// Fade out over ~20ms (128 samples)
|
||||
sp.fadeOut++;
|
||||
if (sp.fadeOut > 128) { sp.active = false; continue; }
|
||||
const fade = 1 - (sp.fadeOut / 128);
|
||||
const g = sp.gain * fade;
|
||||
const lg = g * Math.cos((sp.pan + 1) * Math.PI / 4);
|
||||
const rg = g * Math.sin((sp.pan + 1) * Math.PI / 4);
|
||||
// Repeat last sample with fade
|
||||
for (let i = 0; i < left.length; i++) {
|
||||
left[i] += sp.lastSample * lg;
|
||||
right[i] += sp.lastSample * rg;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (!sp.active && sp.ready < sp.jbufTarget) continue; // Wait for jitter buffer to fill
|
||||
if (!sp.active && sp.ready >= sp.jbufTarget) sp.active = true;
|
||||
if (!sp.active) continue;
|
||||
|
||||
sp.fadeOut = 0;
|
||||
const g = sp.gain;
|
||||
// Constant-power pan law
|
||||
const lg = g * Math.cos((sp.pan + 1) * Math.PI / 4);
|
||||
const rg = g * Math.sin((sp.pan + 1) * Math.PI / 4);
|
||||
|
||||
for (let i = 0; i < left.length; i++) {
|
||||
if (sp.ready > 0) {
|
||||
const s = sp.ringBuf[sp.readPos];
|
||||
sp.lastSample = s;
|
||||
left[i] += s * lg;
|
||||
right[i] += s * rg;
|
||||
sp.readPos = (sp.readPos + 1) % this.RING_SIZE;
|
||||
sp.ready--;
|
||||
} else {
|
||||
// Underrun — fade from last sample
|
||||
sp.lastSample *= 0.85;
|
||||
left[i] += sp.lastSample * lg;
|
||||
right[i] += sp.lastSample * rg;
|
||||
}
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
registerProcessor('voice-playback', VoicePlaybackProcessor);
|
||||
`;
|
||||
|
||||
// ==================== Opus ENCODE/DECODE ====================
|
||||
|
||||
async function initVoiceEncoder() {
|
||||
if ('AudioEncoder' in window) {
|
||||
try {
|
||||
// Check if Opus is supported
|
||||
const support = await AudioEncoder.isConfigSupported({
|
||||
codec: 'opus',
|
||||
sampleRate: VC.sampleRate,
|
||||
numberOfChannels: 1,
|
||||
bitrate: VC.opusBitrate
|
||||
});
|
||||
if (!support.supported) throw new Error('Opus not supported');
|
||||
|
||||
voiceEncoder = new AudioEncoder({
|
||||
output: (chunk) => {
|
||||
// chunk: EncodedAudioChunk with Opus data
|
||||
const data = new Uint8Array(chunk.byteLength);
|
||||
chunk.copyTo(data);
|
||||
// Send as binary frame via Socket.IO
|
||||
voiceSocket.emit('voice_data', {
|
||||
codec: 'opus',
|
||||
data: data.buffer,
|
||||
seq: voiceSeq++,
|
||||
ts: chunk.timestamp,
|
||||
speaking: true
|
||||
});
|
||||
},
|
||||
error: (e) => {
|
||||
console.error('[voice] encoder error:', e);
|
||||
voiceCodec = 'pcm';
|
||||
voiceEncoder = null;
|
||||
}
|
||||
});
|
||||
|
||||
await voiceEncoder.configure({
|
||||
codec: 'opus',
|
||||
sampleRate: VC.sampleRate,
|
||||
numberOfChannels: 1,
|
||||
bitrate: VC.opusBitrate
|
||||
});
|
||||
|
||||
voiceCodec = 'opus';
|
||||
console.log('[voice] WebCodecs Opus encoder initialized @', VC.opusBitrate, 'bps');
|
||||
return;
|
||||
} catch (e) {
|
||||
console.warn('[voice] WebCodecs Opus not available, PCM fallback:', e.message);
|
||||
}
|
||||
}
|
||||
voiceCodec = 'pcm';
|
||||
console.log('[voice] Using PCM @', VC.sampleRate, 'Hz');
|
||||
}
|
||||
|
||||
async function initDecoderForSpeaker(speakerId, codec) {
|
||||
const sp = remoteSpeakers.get(speakerId);
|
||||
if (!sp) return;
|
||||
|
||||
if (codec === 'opus' && 'AudioDecoder' in window) {
|
||||
try {
|
||||
const decoder = new AudioDecoder({
|
||||
output: (audioData) => {
|
||||
const samples = new Float32Array(audioData.numberOfFrames);
|
||||
audioData.copyTo(samples, { planeIndex: 0 });
|
||||
audioData.close();
|
||||
// Push decoded PCM to worklet
|
||||
if (playbackNode) {
|
||||
playbackNode.port.postMessage({
|
||||
type: 'pushFrames',
|
||||
id: speakerId,
|
||||
samples: samples
|
||||
});
|
||||
}
|
||||
},
|
||||
error: (e) => {
|
||||
console.error('[voice] decoder error for', speakerId, e);
|
||||
sp.codec = 'pcm'; // Fallback to PCM
|
||||
}
|
||||
});
|
||||
|
||||
await decoder.configure({
|
||||
codec: 'opus',
|
||||
sampleRate: VC.sampleRate,
|
||||
numberOfChannels: 1
|
||||
});
|
||||
|
||||
sp.decoder = decoder;
|
||||
sp.codec = 'opus';
|
||||
console.log('[voice] Opus decoder initialized for', speakerId);
|
||||
} catch (e) {
|
||||
console.warn('[voice] Opus decoder failed, PCM fallback:', e.message);
|
||||
sp.codec = 'pcm';
|
||||
}
|
||||
} else {
|
||||
sp.codec = 'pcm';
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== RemoteSpeaker helper ====================
|
||||
|
||||
function createRemoteSpeaker(socketId, name, codec) {
|
||||
const sp = {
|
||||
id: socketId,
|
||||
name: name || '???',
|
||||
codec: codec || 'pcm',
|
||||
decoder: null,
|
||||
x: 0, y: 0,
|
||||
mode: 'near',
|
||||
speaking: false,
|
||||
lastActive: Date.now(),
|
||||
// Audio nodes per speaker (managed on main thread for smooth ramps)
|
||||
gainNode: audioCtx.createGain(),
|
||||
pannerNode: audioCtx.createStereoPanner(),
|
||||
lowpassNode: audioCtx.createBiquadFilter(),
|
||||
};
|
||||
// lowpass for distance muffling
|
||||
sp.lowpassNode.type = 'lowpass';
|
||||
sp.lowpassNode.frequency.value = 4000;
|
||||
sp.lowpassNode.Q.value = 0.7;
|
||||
// Connect: lowpass → panner → gain → (connected to mixer later)
|
||||
sp.lowpassNode.connect(sp.pannerNode);
|
||||
sp.pannerNode.connect(sp.gainNode);
|
||||
sp.gainNode.gain.value = 0; // Start silent
|
||||
|
||||
remoteSpeakers.set(socketId, sp);
|
||||
initDecoderForSpeaker(socketId, codec);
|
||||
return sp;
|
||||
}
|
||||
|
||||
function removeRemoteSpeaker(socketId) {
|
||||
const sp = remoteSpeakers.get(socketId);
|
||||
if (!sp) return;
|
||||
try { sp.gainNode.disconnect(); } catch(e) {}
|
||||
try { sp.pannerNode.disconnect(); } catch(e) {}
|
||||
try { sp.lowpassNode.disconnect(); } catch(e) {}
|
||||
if (sp.decoder) { try { sp.decoder.close(); } catch(e) {} }
|
||||
remoteSpeakers.delete(socketId);
|
||||
if (playbackNode) {
|
||||
playbackNode.port.postMessage({ type: 'removeSpeaker', id: socketId });
|
||||
}
|
||||
}
|
||||
|
||||
function updateSpeakerSpatial(sp) {
|
||||
if (!audioCtx) return;
|
||||
// Calculate distance-based audio
|
||||
const dx = sp.x - player.x;
|
||||
const dy = sp.y - player.y;
|
||||
const dist = Math.sqrt(dx * dx + dy * dy);
|
||||
|
||||
const now = audioCtx.currentTime;
|
||||
const rampTime = 0.05; // 50ms smooth ramp
|
||||
|
||||
if (sp.mode === 'world' && voiceMode === 'world') {
|
||||
// World mode: full volume, no panning, no distance filter
|
||||
sp.gainNode.gain.cancelScheduledValues(now);
|
||||
sp.gainNode.gain.linearRampToValueAtTime(1.0, now + rampTime);
|
||||
sp.pannerNode.pan.cancelScheduledValues(now);
|
||||
sp.pannerNode.pan.linearRampToValueAtTime(0, now + rampTime);
|
||||
sp.lowpassNode.frequency.cancelScheduledValues(now);
|
||||
sp.lowpassNode.frequency.linearRampToValueAtTime(4000, now + rampTime);
|
||||
} else if (dist > VC.voiceRadius) {
|
||||
// Out of range
|
||||
sp.gainNode.gain.cancelScheduledValues(now);
|
||||
sp.gainNode.gain.linearRampToValueAtTime(0, now + rampTime);
|
||||
} else {
|
||||
// Near mode with spatial audio
|
||||
const volume = 1 / (1 + dist / 150); // Perceptual falloff
|
||||
const pan = Math.max(-1, Math.min(1, dx / 300)); // Stereo pan based on X
|
||||
const cutoff = 4000 - (3800 * (dist / VC.voiceRadius)); // Muffle at distance
|
||||
|
||||
sp.gainNode.gain.cancelScheduledValues(now);
|
||||
sp.gainNode.gain.linearRampToValueAtTime(volume, now + rampTime);
|
||||
sp.pannerNode.pan.cancelScheduledValues(now);
|
||||
sp.pannerNode.pan.linearRampToValueAtTime(pan, now + rampTime);
|
||||
sp.lowpassNode.frequency.cancelScheduledValues(now);
|
||||
sp.lowpassNode.frequency.linearRampToValueAtTime(Math.max(200, cutoff), now + rampTime);
|
||||
}
|
||||
|
||||
// Update worklet spatial params
|
||||
if (playbackNode) {
|
||||
const currentGain = sp.gainNode.gain.value;
|
||||
const currentPan = sp.pannerNode.pan.value;
|
||||
playbackNode.port.postMessage({
|
||||
type: 'updateSpatial',
|
||||
id: sp.id,
|
||||
gain: currentGain,
|
||||
pan: currentPan
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== UI ====================
|
||||
|
||||
const voiceBtn = document.createElement('div');
|
||||
voiceBtn.innerHTML = '🎤<span style="position:absolute;inset:0;display:flex;align-items:center;justify-content:center;font-size:36px;color:#e74c3c;font-weight:bold;">/</span>';
|
||||
voiceBtn.title = 'Голосовой чат (выкл)';
|
||||
voiceBtn.style.cssText = 'position:absolute;top:74px;right:130px;width:52px;height:52px;border-radius:12px;background:#555;z-index:200;display:flex;align-items:center;justify-content:center;font-size:24px;cursor:pointer;border:2px solid rgba(255,255,255,0.9);box-shadow:0 4px 0 rgba(0,0,0,0.5);pointer-events:auto;';
|
||||
document.querySelector('.ui').appendChild(voiceBtn);
|
||||
|
||||
// Кнопка режима голоса (близко / весь мир)
|
||||
|
||||
const voiceModeBtn = document.createElement('div');
|
||||
voiceModeBtn.innerHTML = '📢';
|
||||
voiceModeBtn.title = 'Режим: рядом (600px)';
|
||||
|
|
@ -1486,177 +1862,269 @@ function customConfirm(msg, onYes) {
|
|||
if (voiceSocket && voiceSocket.connected) {
|
||||
voiceSocket.emit('voice_mode', { mode: voiceMode });
|
||||
}
|
||||
// Update spatial for all speakers
|
||||
for (const [id, sp] of remoteSpeakers) updateSpeakerSpatial(sp);
|
||||
};
|
||||
|
||||
// Индикатор говорящего
|
||||
|
||||
// Speaking indicators per players
|
||||
const speakingIndicator = document.createElement('div');
|
||||
speakingIndicator.style.cssText = 'position:absolute;top:130px;right:10px;z-index:200;display:none;background:rgba(0,0,0,0.7);color:#2ecc71;padding:4px 8px;border-radius:6px;font-size:12px;';
|
||||
speakingIndicator.textContent = '🔊';
|
||||
speakingIndicator.style.cssText = 'position:absolute;top:130px;right:10px;z-index:200;display:none;background:rgba(0,0,0,0.7);color:#2ecc71;padding:4px 8px;border-radius:6px;font-size:12px;max-width:200px;line-height:1.4;';
|
||||
document.querySelector('.ui').appendChild(speakingIndicator);
|
||||
let speakingTimeout = null;
|
||||
|
||||
let speakingTimeouts = new Map(); // socketId → timeout
|
||||
|
||||
// Codec indicator
|
||||
const codecIndicator = document.createElement('div');
|
||||
codecIndicator.style.cssText = 'position:absolute;top:180px;right:10px;z-index:200;font-size:10px;color:rgba(255,255,255,0.5);pointer-events:none;';
|
||||
codecIndicator.textContent = '';
|
||||
document.querySelector('.ui').appendChild(codecIndicator);
|
||||
|
||||
// ==================== VOICE ON/OFF ====================
|
||||
|
||||
voiceBtn.onclick = async () => {
|
||||
if (voiceActive) {
|
||||
// Выключить
|
||||
// === DISABLE ===
|
||||
voiceActive = false;
|
||||
ringReady = 0; ringRead = ringWrite; voicePlayActive = false;
|
||||
voiceBtn.innerHTML = '🎤<span style="position:absolute;inset:0;display:flex;align-items:center;justify-content:center;font-size:36px;color:#e74c3c;font-weight:bold;">/</span>';
|
||||
voiceBtn.style.background = '#555';
|
||||
if (voiceStream) {
|
||||
voiceStream.getTracks().forEach(t => t.stop());
|
||||
voiceStream = null;
|
||||
codecIndicator.textContent = '';
|
||||
|
||||
// Clean up capture
|
||||
if (captureNode) { captureNode.disconnect(); captureNode = null; }
|
||||
if (voiceStream) { voiceStream.getTracks().forEach(t => t.stop()); voiceStream = null; }
|
||||
|
||||
// Clean up playback
|
||||
if (playbackNode) { playbackNode.disconnect(); playbackNode = null; }
|
||||
|
||||
// Clean up per-speaker nodes
|
||||
for (const [id, sp] of remoteSpeakers) {
|
||||
try { sp.gainNode.disconnect(); } catch(e) {}
|
||||
try { sp.pannerNode.disconnect(); } catch(e) {}
|
||||
try { sp.lowpassNode.disconnect(); } catch(e) {}
|
||||
if (sp.decoder) { try { sp.decoder.close(); } catch(e) {} }
|
||||
}
|
||||
if (voiceProcessor) { voiceProcessor.disconnect(); voiceProcessor = null; }
|
||||
remoteSpeakers.clear();
|
||||
|
||||
// Clean up encoder
|
||||
if (voiceEncoder) { try { voiceEncoder.close(); } catch(e) {} voiceEncoder = null; }
|
||||
|
||||
if (audioCtx) { audioCtx.close(); audioCtx = null; }
|
||||
if (voiceSocket) { voiceSocket.disconnect(); voiceSocket = null; }
|
||||
speakingIndicator.style.display = 'none';
|
||||
return;
|
||||
}
|
||||
|
||||
// Включить
|
||||
|
||||
// === ENABLE ===
|
||||
try {
|
||||
voiceStream = await navigator.mediaDevices.getUserMedia({ audio: { echoCancellation: true, noiseSuppression: true } });
|
||||
audioCtx = new AudioContext({ sampleRate: 24000 });
|
||||
if (audioCtx.state === 'suspended') await audioCtx.resume();
|
||||
console.log('[voice] AudioContext state:', audioCtx.state, 'sampleRate:', audioCtx.sampleRate);
|
||||
|
||||
const source = audioCtx.createMediaStreamSource(voiceStream);
|
||||
voiceProcessor = audioCtx.createScriptProcessor(2048, 1, 1);
|
||||
console.log('[voice] ScriptProcessor created, bufferSize=2048');
|
||||
|
||||
voiceProcessor.onaudioprocess = (e) => {
|
||||
if (!voiceActive) return;
|
||||
voiceDebugCount++;
|
||||
if (voiceDebugCount <= 5) {
|
||||
const pcm = e.inputBuffer.getChannelData(0);
|
||||
console.log('[voice] onaudioprocess #' + voiceDebugCount, 'samples:', pcm.length, 'max:', Math.max(...pcm.map(Math.abs)).toFixed(4), 'socket:', !!voiceSocket, 'connected:', voiceSocket?.connected);
|
||||
voiceStream = await navigator.mediaDevices.getUserMedia({
|
||||
audio: {
|
||||
echoCancellation: true,
|
||||
noiseSuppression: true,
|
||||
autoGainControl: true,
|
||||
echoCancellationType: 'browser',
|
||||
noiseSuppressionType: 'browser',
|
||||
sampleRate: { ideal: VC.sampleRate },
|
||||
channelCount: { ideal: 1 }
|
||||
}
|
||||
if (!voiceSocket || !voiceSocket.connected) return;
|
||||
const pcm = e.inputBuffer.getChannelData(0);
|
||||
const int16 = new Int16Array(pcm.length);
|
||||
for (let i = 0; i < pcm.length; i++) {
|
||||
const s = Math.max(-1, Math.min(1, pcm[i]));
|
||||
});
|
||||
|
||||
audioCtx = new AudioContext({ sampleRate: VC.sampleRate });
|
||||
if (audioCtx.state === 'suspended') await audioCtx.resume();
|
||||
console.log('[voice] AudioContext:', audioCtx.state, 'sampleRate:', audioCtx.sampleRate);
|
||||
|
||||
// Register worklets via Blob URLs
|
||||
const captureURL = URL.createObjectURL(new Blob([voiceCaptureWorkletCode], { type: 'application/javascript' }));
|
||||
const playbackURL = URL.createObjectURL(new Blob([voicePlaybackWorkletCode], { type: 'application/javascript' }));
|
||||
|
||||
await audioCtx.audioWorklet.addModule(captureURL);
|
||||
await audioCtx.audioWorklet.addModule(playbackURL);
|
||||
URL.revokeObjectURL(captureURL);
|
||||
URL.revokeObjectURL(playbackURL);
|
||||
console.log('[voice] AudioWorklets registered');
|
||||
|
||||
// Connect mic → capture worklet
|
||||
const micSource = audioCtx.createMediaStreamSource(voiceStream);
|
||||
captureNode = new AudioWorkletNode(audioCtx, 'voice-capture');
|
||||
micSource.connect(captureNode);
|
||||
// Do NOT connect captureNode to destination — we don't hear ourselves
|
||||
|
||||
// Connect playback worklet → per-speaker gain nodes → destination
|
||||
playbackNode = new AudioWorkletNode(audioCtx, 'voice-playback', { numberOfOutputs: 1, outputChannelCount: [2] });
|
||||
playbackNode.connect(audioCtx.destination);
|
||||
// Per-speaker nodes will connect between playback worklet output and destination
|
||||
// Actually: playback worklet mixes internally → stereo output → destination
|
||||
// Per-speaker Web Audio nodes (lowpass, pan, gain) used for spatial UPDATES only (values sent to worklet via messages)
|
||||
// The actual mixing happens inside the worklet
|
||||
|
||||
// Handle capture frames
|
||||
voiceSeq = 0;
|
||||
voiceTimestamp = 0;
|
||||
wasSpeaking = false;
|
||||
captureNode.port.onmessage = (e) => {
|
||||
const { type, samples, speaking, rms } = e.data;
|
||||
if (type !== 'frame') return;
|
||||
if (!voiceActive || !voiceSocket || !voiceSocket.connected) return;
|
||||
|
||||
voiceTimestamp += VC.samplesPerFrame; // 320 samples * (1/16000) = 20ms per frame
|
||||
|
||||
if (!speaking && !wasSpeaking) {
|
||||
// Complete silence — don't transmit
|
||||
silenceFrames++;
|
||||
return;
|
||||
}
|
||||
|
||||
if (voiceCodec === 'opus' && voiceEncoder) {
|
||||
// Encode with Opus via WebCodecs
|
||||
try {
|
||||
const audioData = new AudioData({
|
||||
format: 'float32-planar',
|
||||
sampleRate: VC.sampleRate,
|
||||
numberOfFrames: samples.length,
|
||||
numberOfChannels: 1,
|
||||
timestamp: voiceTimestamp * (1000000 / VC.sampleRate), // microseconds
|
||||
data: samples
|
||||
});
|
||||
voiceEncoder.encode(audioData);
|
||||
audioData.close();
|
||||
} catch (err) {
|
||||
// Fallback: send as PCM
|
||||
sendPCMFrame(samples);
|
||||
}
|
||||
} else {
|
||||
sendPCMFrame(samples);
|
||||
}
|
||||
|
||||
wasSpeaking = speaking;
|
||||
silenceFrames = 0;
|
||||
};
|
||||
|
||||
function sendPCMFrame(samples) {
|
||||
const int16 = new Int16Array(samples.length);
|
||||
for (let i = 0; i < samples.length; i++) {
|
||||
const s = Math.max(-1, Math.min(1, samples[i]));
|
||||
int16[i] = s < 0 ? s * 0x8000 : s * 0x7FFF;
|
||||
}
|
||||
voiceSocket.emit('voice_data', int16.buffer);
|
||||
};
|
||||
|
||||
// Chain: source → processor → gain(0) → destination
|
||||
// ScriptProcessor MUST reach destination to fire onaudioprocess
|
||||
const silentGain = audioCtx.createGain();
|
||||
silentGain.gain.value = 0;
|
||||
source.connect(voiceProcessor);
|
||||
voiceProcessor.connect(silentGain);
|
||||
silentGain.connect(audioCtx.destination);
|
||||
console.log('[voice] Audio chain: source → processor → silentGain(0) → destination');
|
||||
|
||||
// Подключаемся к голосовому серверу
|
||||
voiceSocket.emit('voice_data', {
|
||||
codec: 'pcm',
|
||||
data: int16.buffer,
|
||||
seq: voiceSeq++,
|
||||
ts: performance.now(),
|
||||
speaking: wasSpeaking || speaking
|
||||
});
|
||||
}
|
||||
|
||||
// Initialize encoder
|
||||
await initVoiceEncoder();
|
||||
codecIndicator.textContent = voiceCodec === 'opus' ? '🔊 Opus' : '🔊 PCM';
|
||||
|
||||
// Connect to voice server
|
||||
voiceSocket = io(VOICE_SERVER, { transports: ['websocket'] });
|
||||
|
||||
voiceSocket.on('connect', () => {
|
||||
console.log('[voice] Socket connected, id:', voiceSocket.id);
|
||||
voiceSocket.emit('voice_join', { world_id: worldId, x: player.x, y: player.y, name: playerName || 'Игрок', mode: voiceMode });
|
||||
console.log('[voice] Connected, id:', voiceSocket.id, 'codec:', voiceCodec);
|
||||
voiceSocket.emit('voice_join', {
|
||||
world_id: worldId,
|
||||
x: player.x, y: player.y,
|
||||
name: playerName || 'Игрок',
|
||||
mode: voiceMode,
|
||||
codec: voiceCodec
|
||||
});
|
||||
});
|
||||
|
||||
voiceSocket.on('connect_error', (err) => {
|
||||
console.error('[voice] Socket connect error:', err.message);
|
||||
console.error('[voice] Connect error:', err.message);
|
||||
});
|
||||
|
||||
// === Ring Buffer + ScriptProcessor приём голоса ===
|
||||
// Единый непрерывный поток вместо отдельных BufferSource на чанк
|
||||
const RING_SIZE = 24000 * 3; // 3 секунды ring buffer
|
||||
const ringBuf = new Float32Array(RING_SIZE);
|
||||
let ringWrite = 0; // позиция записи
|
||||
let ringRead = 0; // позиция чтения
|
||||
let ringReady = 0; // сколько сэмплов готово
|
||||
let voicePlayActive = false;
|
||||
const JBUF_TARGET = 4800; // целевой jitter buffer: 200мс при 24kHz
|
||||
let jbufFill = 0; // текущее заполнение
|
||||
let lastVoiceFrom = ''; // кто говорит (для индикатора)
|
||||
|
||||
// Воспроизводящий ScriptProcessor — читает из ring buffer
|
||||
const playProcessor = audioCtx.createScriptProcessor(2048, 1, 1);
|
||||
let lastSample = 0; // для плавного fade при underrun
|
||||
playProcessor.onaudioprocess = (e) => {
|
||||
const out = e.outputBuffer.getChannelData(0);
|
||||
if (ringReady < 1) {
|
||||
// Плавный fade-out от последнего сэмпла к тишине
|
||||
for (let i = 0; i < out.length; i++) {
|
||||
lastSample *= 0.9;
|
||||
out[i] = lastSample;
|
||||
}
|
||||
voicePlayActive = false;
|
||||
return;
|
||||
}
|
||||
// Ждём накопления jitter buffer перед стартом
|
||||
if (!voicePlayActive && ringReady >= JBUF_TARGET) {
|
||||
voicePlayActive = true;
|
||||
}
|
||||
if (!voicePlayActive) {
|
||||
// Плавно затихаем пока буфер копится
|
||||
for (let i = 0; i < out.length; i++) {
|
||||
lastSample *= 0.95;
|
||||
out[i] = lastSample;
|
||||
}
|
||||
return;
|
||||
}
|
||||
// Читаем из ring buffer с плавным fade-in на старте
|
||||
for (let i = 0; i < out.length; i++) {
|
||||
if (ringReady > 0) {
|
||||
out[i] = ringBuf[ringRead];
|
||||
lastSample = out[i]; // запоминаем для fade-out
|
||||
ringRead = (ringRead + 1) % RING_SIZE;
|
||||
ringReady--;
|
||||
} else {
|
||||
// Underrun — плавно затихаем
|
||||
lastSample *= 0.85;
|
||||
out[i] = lastSample;
|
||||
}
|
||||
}
|
||||
};
|
||||
const playGain = audioCtx.createGain();
|
||||
playGain.gain.value = 1.0;
|
||||
playProcessor.connect(playGain).connect(audioCtx.destination);
|
||||
|
||||
|
||||
voiceSocket.on('voice_in', (payload) => {
|
||||
// Пишем входящий голос в ring buffer
|
||||
const { data, meta, volume } = payload;
|
||||
const { data, meta } = payload;
|
||||
if (!audioCtx || audioCtx.state === 'closed') return;
|
||||
|
||||
const int16 = new Int16Array(data);
|
||||
const vol = Math.min(1.4, (volume || 1) * 1.5);
|
||||
for (let i = 0; i < int16.length; i++) {
|
||||
const sample = (int16[i] / (int16[i] < 0 ? 0x8000 : 0x7FFF)) * vol;
|
||||
ringBuf[ringWrite] = sample;
|
||||
ringWrite = (ringWrite + 1) % RING_SIZE;
|
||||
ringReady = Math.min(ringReady + 1, RING_SIZE);
|
||||
|
||||
let sp = remoteSpeakers.get(meta.from);
|
||||
if (!sp) {
|
||||
// New speaker — create per-speaker audio nodes
|
||||
sp = createRemoteSpeaker(meta.from, meta.name, meta.codec || 'pcm');
|
||||
}
|
||||
// Сброс jitter fill если пауза была
|
||||
jbufFill = ringReady;
|
||||
lastVoiceFrom = meta.name || '???';
|
||||
|
||||
// Индикатор
|
||||
|
||||
// Update position
|
||||
sp.x = meta.x || 0;
|
||||
sp.y = meta.y || 0;
|
||||
sp.mode = meta.mode || 'near';
|
||||
sp.lastActive = Date.now();
|
||||
updateSpeakerSpatial(sp);
|
||||
|
||||
// Decode and push to worklet
|
||||
if ((meta.codec || 'pcm') === 'opus' && sp.decoder) {
|
||||
// Opus decode
|
||||
try {
|
||||
const uint8 = new Uint8Array(data);
|
||||
const chunk = new EncodedAudioChunk({
|
||||
type: 'key', // Opus frames are self-decodable
|
||||
timestamp: performance.now() * 1000, // microseconds
|
||||
data: uint8
|
||||
});
|
||||
sp.decoder.decode(chunk);
|
||||
} catch (err) {
|
||||
// Opus decode failed, try PCM fallback
|
||||
decodeAndPushPCM(sp.id, data);
|
||||
}
|
||||
} else {
|
||||
decodeAndPushPCM(sp.id, data);
|
||||
}
|
||||
|
||||
// Update speaking indicator
|
||||
sp.speaking = true;
|
||||
speakingIndicator.style.display = 'block';
|
||||
speakingIndicator.textContent = '🔊 ' + lastVoiceFrom;
|
||||
clearTimeout(speakingTimeout);
|
||||
speakingTimeout = setTimeout(() => {
|
||||
speakingIndicator.style.display = 'none';
|
||||
voicePlayActive = false; // сброс при паузе
|
||||
ringReady = 0; // очистить буфер
|
||||
ringRead = ringWrite; // синхронизировать
|
||||
}, 1500);
|
||||
speakingIndicator.textContent = '🔊 ' + sp.name;
|
||||
clearTimeout(speakingTimeouts.get(meta.from));
|
||||
speakingTimeouts.set(meta.from, setTimeout(() => {
|
||||
sp.speaking = false;
|
||||
// Check if anyone is still speaking
|
||||
let anyoneSpeaking = false;
|
||||
for (const [, s] of remoteSpeakers) { if (s.speaking) { anyoneSpeaking = true; break; } }
|
||||
if (!anyoneSpeaking) speakingIndicator.style.display = 'none';
|
||||
speakingTimeouts.delete(meta.from);
|
||||
}, 1500));
|
||||
});
|
||||
|
||||
|
||||
voiceSocket.on('voice_leave', (data) => {
|
||||
removeRemoteSpeaker(data.id);
|
||||
});
|
||||
|
||||
voiceSocket.on('disconnect', () => {
|
||||
console.log('[voice] Disconnected');
|
||||
// Clear all speakers on disconnect
|
||||
for (const [id] of remoteSpeakers) removeRemoteSpeaker(id);
|
||||
});
|
||||
|
||||
voiceActive = true;
|
||||
voiceBtn.textContent = '🎤';
|
||||
voiceBtn.style.background = '#2ecc71';
|
||||
console.log('[voice] Voice chat ACTIVE');
|
||||
console.log('[voice] Voice chat ACTIVE, codec:', voiceCodec);
|
||||
|
||||
} catch(e) {
|
||||
console.error('[voice] Error:', e);
|
||||
voiceBtn.style.background = '#e74c3c';
|
||||
}
|
||||
};
|
||||
|
||||
// Обновляем позицию для voice server
|
||||
const origPlayerMove = () => {};
|
||||
// Хук в главный цикл — обновляем позицию каждые ~500ms
|
||||
|
||||
// ==================== PCM decode helper ====================
|
||||
|
||||
function decodeAndPushPCM(speakerId, buffer) {
|
||||
const int16 = new Int16Array(buffer);
|
||||
const float32 = new Float32Array(int16.length);
|
||||
for (let i = 0; i < int16.length; i++) {
|
||||
float32[i] = int16[i] / (int16[i] < 0 ? 0x8000 : 0x7FFF);
|
||||
}
|
||||
if (playbackNode) {
|
||||
playbackNode.port.postMessage({
|
||||
type: 'pushFrames',
|
||||
id: speakerId,
|
||||
samples: float32
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== Voice position update ====================
|
||||
|
||||
let voicePosT = 0;
|
||||
|
||||
// Клик на часы для включения ночи
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -93,6 +93,6 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<script src="game.js?v=23"></script>
|
||||
<script src="game.js?v=24"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
|||
Loading…
Reference in New Issue