feat: voice chat v3 — AudioWorklet, Opus/PCM, per-speaker, spatial audio, VAD
This commit is contained in:
parent
980ba6a541
commit
0a31f92af8
732
game.js
732
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 voiceSocket = null;
|
||||||
let voiceStream = null;
|
let voiceStream = null;
|
||||||
let audioCtx = null;
|
let audioCtx = null;
|
||||||
let voiceProcessor = null;
|
|
||||||
let voiceActive = false;
|
let voiceActive = false;
|
||||||
let voiceMode = 'near'; // 'near' or 'world'
|
let voiceMode = 'near';
|
||||||
let voiceDebugCount = 0;
|
|
||||||
const VOICE_SERVER = 'https://voicegrech.mkn8n.ru';
|
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');
|
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.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.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;';
|
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);
|
document.querySelector('.ui').appendChild(voiceBtn);
|
||||||
|
|
||||||
// Кнопка режима голоса (близко / весь мир)
|
|
||||||
const voiceModeBtn = document.createElement('div');
|
const voiceModeBtn = document.createElement('div');
|
||||||
voiceModeBtn.innerHTML = '📢';
|
voiceModeBtn.innerHTML = '📢';
|
||||||
voiceModeBtn.title = 'Режим: рядом (600px)';
|
voiceModeBtn.title = 'Режим: рядом (600px)';
|
||||||
|
|
@ -1486,177 +1862,269 @@ function customConfirm(msg, onYes) {
|
||||||
if (voiceSocket && voiceSocket.connected) {
|
if (voiceSocket && voiceSocket.connected) {
|
||||||
voiceSocket.emit('voice_mode', { mode: voiceMode });
|
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');
|
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.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;';
|
||||||
speakingIndicator.textContent = '🔊';
|
|
||||||
document.querySelector('.ui').appendChild(speakingIndicator);
|
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 () => {
|
voiceBtn.onclick = async () => {
|
||||||
if (voiceActive) {
|
if (voiceActive) {
|
||||||
// Выключить
|
// === DISABLE ===
|
||||||
voiceActive = false;
|
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.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';
|
voiceBtn.style.background = '#555';
|
||||||
if (voiceStream) {
|
codecIndicator.textContent = '';
|
||||||
voiceStream.getTracks().forEach(t => t.stop());
|
|
||||||
voiceStream = null;
|
// 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 (audioCtx) { audioCtx.close(); audioCtx = null; }
|
||||||
if (voiceSocket) { voiceSocket.disconnect(); voiceSocket = null; }
|
if (voiceSocket) { voiceSocket.disconnect(); voiceSocket = null; }
|
||||||
|
speakingIndicator.style.display = 'none';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Включить
|
// === ENABLE ===
|
||||||
try {
|
try {
|
||||||
voiceStream = await navigator.mediaDevices.getUserMedia({ audio: { echoCancellation: true, noiseSuppression: true } });
|
voiceStream = await navigator.mediaDevices.getUserMedia({
|
||||||
audioCtx = new AudioContext({ sampleRate: 24000 });
|
audio: {
|
||||||
if (audioCtx.state === 'suspended') await audioCtx.resume();
|
echoCancellation: true,
|
||||||
console.log('[voice] AudioContext state:', audioCtx.state, 'sampleRate:', audioCtx.sampleRate);
|
noiseSuppression: true,
|
||||||
|
autoGainControl: true,
|
||||||
const source = audioCtx.createMediaStreamSource(voiceStream);
|
echoCancellationType: 'browser',
|
||||||
voiceProcessor = audioCtx.createScriptProcessor(2048, 1, 1);
|
noiseSuppressionType: 'browser',
|
||||||
console.log('[voice] ScriptProcessor created, bufferSize=2048');
|
sampleRate: { ideal: VC.sampleRate },
|
||||||
|
channelCount: { ideal: 1 }
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
if (!voiceSocket || !voiceSocket.connected) return;
|
});
|
||||||
const pcm = e.inputBuffer.getChannelData(0);
|
|
||||||
const int16 = new Int16Array(pcm.length);
|
audioCtx = new AudioContext({ sampleRate: VC.sampleRate });
|
||||||
for (let i = 0; i < pcm.length; i++) {
|
if (audioCtx.state === 'suspended') await audioCtx.resume();
|
||||||
const s = Math.max(-1, Math.min(1, pcm[i]));
|
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;
|
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
|
// Initialize encoder
|
||||||
// ScriptProcessor MUST reach destination to fire onaudioprocess
|
await initVoiceEncoder();
|
||||||
const silentGain = audioCtx.createGain();
|
codecIndicator.textContent = voiceCodec === 'opus' ? '🔊 Opus' : '🔊 PCM';
|
||||||
silentGain.gain.value = 0;
|
|
||||||
source.connect(voiceProcessor);
|
|
||||||
voiceProcessor.connect(silentGain);
|
|
||||||
silentGain.connect(audioCtx.destination);
|
|
||||||
console.log('[voice] Audio chain: source → processor → silentGain(0) → destination');
|
|
||||||
|
|
||||||
// Подключаемся к голосовому серверу
|
// Connect to voice server
|
||||||
voiceSocket = io(VOICE_SERVER, { transports: ['websocket'] });
|
voiceSocket = io(VOICE_SERVER, { transports: ['websocket'] });
|
||||||
|
|
||||||
voiceSocket.on('connect', () => {
|
voiceSocket.on('connect', () => {
|
||||||
console.log('[voice] Socket connected, id:', voiceSocket.id);
|
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 });
|
voiceSocket.emit('voice_join', {
|
||||||
|
world_id: worldId,
|
||||||
|
x: player.x, y: player.y,
|
||||||
|
name: playerName || 'Игрок',
|
||||||
|
mode: voiceMode,
|
||||||
|
codec: voiceCodec
|
||||||
});
|
});
|
||||||
|
});
|
||||||
|
|
||||||
voiceSocket.on('connect_error', (err) => {
|
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) => {
|
voiceSocket.on('voice_in', (payload) => {
|
||||||
// Пишем входящий голос в ring buffer
|
const { data, meta } = payload;
|
||||||
const { data, meta, volume } = payload;
|
|
||||||
if (!audioCtx || audioCtx.state === 'closed') return;
|
if (!audioCtx || audioCtx.state === 'closed') return;
|
||||||
|
|
||||||
const int16 = new Int16Array(data);
|
let sp = remoteSpeakers.get(meta.from);
|
||||||
const vol = Math.min(1.4, (volume || 1) * 1.5);
|
if (!sp) {
|
||||||
for (let i = 0; i < int16.length; i++) {
|
// New speaker — create per-speaker audio nodes
|
||||||
const sample = (int16[i] / (int16[i] < 0 ? 0x8000 : 0x7FFF)) * vol;
|
sp = createRemoteSpeaker(meta.from, meta.name, meta.codec || 'pcm');
|
||||||
ringBuf[ringWrite] = sample;
|
|
||||||
ringWrite = (ringWrite + 1) % RING_SIZE;
|
|
||||||
ringReady = Math.min(ringReady + 1, RING_SIZE);
|
|
||||||
}
|
}
|
||||||
// Сброс 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.style.display = 'block';
|
||||||
speakingIndicator.textContent = '🔊 ' + lastVoiceFrom;
|
speakingIndicator.textContent = '🔊 ' + sp.name;
|
||||||
clearTimeout(speakingTimeout);
|
clearTimeout(speakingTimeouts.get(meta.from));
|
||||||
speakingTimeout = setTimeout(() => {
|
speakingTimeouts.set(meta.from, setTimeout(() => {
|
||||||
speakingIndicator.style.display = 'none';
|
sp.speaking = false;
|
||||||
voicePlayActive = false; // сброс при паузе
|
// Check if anyone is still speaking
|
||||||
ringReady = 0; // очистить буфер
|
let anyoneSpeaking = false;
|
||||||
ringRead = ringWrite; // синхронизировать
|
for (const [, s] of remoteSpeakers) { if (s.speaking) { anyoneSpeaking = true; break; } }
|
||||||
}, 1500);
|
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;
|
voiceActive = true;
|
||||||
voiceBtn.textContent = '🎤';
|
voiceBtn.textContent = '🎤';
|
||||||
voiceBtn.style.background = '#2ecc71';
|
voiceBtn.style.background = '#2ecc71';
|
||||||
console.log('[voice] Voice chat ACTIVE');
|
console.log('[voice] Voice chat ACTIVE, codec:', voiceCodec);
|
||||||
|
|
||||||
} catch(e) {
|
} catch(e) {
|
||||||
console.error('[voice] Error:', e);
|
console.error('[voice] Error:', e);
|
||||||
voiceBtn.style.background = '#e74c3c';
|
voiceBtn.style.background = '#e74c3c';
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Обновляем позицию для voice server
|
// ==================== PCM decode helper ====================
|
||||||
const origPlayerMove = () => {};
|
|
||||||
// Хук в главный цикл — обновляем позицию каждые ~500ms
|
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;
|
let voicePosT = 0;
|
||||||
|
|
||||||
// Клик на часы для включения ночи
|
// Клик на часы для включения ночи
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -93,6 +93,6 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script src="game.js?v=23"></script>
|
<script src="game.js?v=24"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue