From cc46b93e96f5754c7f562a0683351437d70158f6 Mon Sep 17 00:00:00 2001 From: Mk Date: Tue, 26 May 2026 12:13:43 +0000 Subject: [PATCH] fix: replace MediaRecorder with AudioWorklet (Blob URL) for voice capture --- game.js | 102 +++++++++++++++++++++++++---------------------------- index.html | 2 +- 2 files changed, 50 insertions(+), 54 deletions(-) diff --git a/game.js b/game.js index 2a4eb80..b73196c 100644 --- a/game.js +++ b/game.js @@ -1245,10 +1245,32 @@ let voiceSocket = null; let voiceStream = null; let audioCtx = null; - let voiceRecorder = null; + let voiceWorklet = null; let voiceActive = false; const VOICE_SERVER = 'https://voicegrech.mkn8n.ru'; + // AudioWorklet processor as Blob URL — no separate file needed + const workletCode = ` + class VoiceProcessor extends AudioWorkletProcessor { + process(inputs) { + const ch = inputs[0]; + if (ch && ch[0]) { + const pcm = ch[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])); + int16[i] = s < 0 ? s * 0x8000 : s * 0x7FFF; + } + this.port.postMessage(int16.buffer, [int16.buffer]); + } + return true; + } + } + registerProcessor('voice-pcm', VoiceProcessor); + `; + const workletBlob = new Blob([workletCode], { type: 'application/javascript' }); + const workletUrl = URL.createObjectURL(workletBlob); + // Кнопка микрофона const voiceBtn = document.createElement('div'); voiceBtn.innerHTML = '🎤/'; @@ -1269,7 +1291,7 @@ voiceActive = false; voiceBtn.innerHTML = '🎤/'; voiceBtn.style.background = '#555'; - if (voiceRecorder) { voiceRecorder.stop(); voiceRecorder = null; } + if (voiceWorklet) { voiceWorklet.disconnect(); voiceWorklet = null; } if (voiceStream) { voiceStream.getTracks().forEach(t => t.stop()); voiceStream = null; @@ -1283,6 +1305,16 @@ try { voiceStream = await navigator.mediaDevices.getUserMedia({ audio: { echoCancellation: true, noiseSuppression: true } }); audioCtx = new AudioContext({ sampleRate: 24000 }); + if (audioCtx.state === 'suspended') await audioCtx.resume(); + + await audioCtx.audioWorklet.addModule(workletUrl); + const source = audioCtx.createMediaStreamSource(voiceStream); + voiceWorklet = new AudioWorkletNode(audioCtx, 'voice-pcm'); + voiceWorklet.port.onmessage = (e) => { + if (!voiceActive || !voiceSocket || !voiceSocket.connected) return; + voiceSocket.emit('voice_data', e.data); + }; + source.connect(voiceWorklet); // Подключаемся к голосовому серверу voiceSocket = io(VOICE_SERVER, { transports: ['websocket'] }); @@ -1290,43 +1322,24 @@ voiceSocket.emit('voice_join', { world_id: worldId, x: player.x, y: player.y, name: playerName || 'Игрок' }); }); - voiceSocket.on('voice_in', async (payload) => { - // Воспроизводим входящий голос (WebM/Opus or raw PCM) + voiceSocket.on('voice_in', (payload) => { + // Воспроизводим входящий голос — raw PCM int16 const { data, meta, volume } = payload; - if (!audioCtx || audioCtx.state === 'closed') { - audioCtx = new AudioContext({ sampleRate: 24000 }); - } - if (audioCtx.state === 'suspended') await audioCtx.resume(); + if (!audioCtx || audioCtx.state === 'closed') return; - try { - // Try WebM/Opus decode first (if server relayed MediaRecorder output) - const blob = new Blob([data], { type: 'audio/webm; codecs=opus' }); - const arrayBuf = await blob.arrayBuffer(); - const decoded = await audioCtx.decodeAudioData(arrayBuf); - const src = audioCtx.createBufferSource(); - src.buffer = decoded; - const gain = audioCtx.createGain(); - gain.gain.value = volume || 1; - src.connect(gain).connect(audioCtx.destination); - src.start(); - } catch(_) { - // Fallback: raw PCM int16 → float32 - try { - const int16 = new Int16Array(data); - const float32 = new Float32Array(int16.length); - for (let i = 0; i < int16.length; i++) { - float32[i] = int16[i] / (int16[i] < 0 ? 0x8000 : 0x7FFF); - } - const buf = audioCtx.createBuffer(1, float32.length, 24000); - buf.getChannelData(0).set(float32); - const src = audioCtx.createBufferSource(); - src.buffer = buf; - const gain = audioCtx.createGain(); - gain.gain.value = volume || 1; - src.connect(gain).connect(audioCtx.destination); - src.start(); - } catch(e2) { /* ignore decode errors */ } + const int16 = new Int16Array(data); + const float32 = new Float32Array(int16.length); + for (let i = 0; i < int16.length; i++) { + float32[i] = int16[i] / (int16[i] < 0 ? 0x8000 : 0x7FFF) * (volume || 1); } + const buf = audioCtx.createBuffer(1, float32.length, 24000); + buf.getChannelData(0).set(float32); + const src = audioCtx.createBufferSource(); + src.buffer = buf; + const gain = audioCtx.createGain(); + gain.gain.value = 1; + src.connect(gain).connect(audioCtx.destination); + src.start(); // Индикатор speakingIndicator.style.display = 'block'; @@ -1335,23 +1348,6 @@ speakingTimeout = setTimeout(() => { speakingIndicator.style.display = 'none'; }, 500); }); - // MediaRecorder — moderne API, works in all browsers without ScriptProcessor - const mimeType = MediaRecorder.isTypeSupported('audio/webm;codecs=opus') - ? 'audio/webm;codecs=opus' : 'audio/webm'; - voiceRecorder = new MediaRecorder(voiceStream, { - mimeType, - audioBitsPerSecond: 16000 - }); - voiceRecorder.ondataavailable = (e) => { - if (!voiceActive || !voiceSocket || !voiceSocket.connected) return; - if (e.data.size > 0) { - e.data.arrayBuffer().then(buf => { - voiceSocket.emit('voice_data', buf); - }); - } - }; - voiceRecorder.start(100); // 100ms chunks for low latency - voiceActive = true; voiceBtn.textContent = '🎤'; voiceBtn.style.background = '#2ecc71'; diff --git a/index.html b/index.html index 5a090a1..01c2bce 100644 --- a/index.html +++ b/index.html @@ -92,6 +92,6 @@ - +