feat: voice chat v3 — AudioWorklet, Opus/PCM, per-speaker, spatial audio, VAD

This commit is contained in:
Mk 2026-05-26 17:48:01 +00:00
parent 980ba6a541
commit 0a31f92af8
3 changed files with 5180 additions and 144 deletions

732
game.js
View File

@ -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);
};
voiceSocket.emit('voice_data', {
codec: 'pcm',
data: int16.buffer,
seq: voiceSeq++,
ts: performance.now(),
speaking: wasSpeaking || speaking
});
}
// 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');
// 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;
// Клик на часы для включения ночи

4568
game.js.bak.v23 Normal file

File diff suppressed because it is too large Load Diff

View File

@ -93,6 +93,6 @@
</div>
</div>
<script src="game.js?v=23"></script>
<script src="game.js?v=24"></script>
</body>
</html>