fix: ring buffer voice playback — continuous stream, no BufferSource per chunk

This commit is contained in:
Mk 2026-05-26 13:52:18 +00:00
parent 233ff02976
commit 0e12ed7a18
2 changed files with 59 additions and 37 deletions

92
game.js
View File

@ -1343,7 +1343,7 @@ function customConfirm(msg, onYes) {
if (voiceActive) { if (voiceActive) {
// Выключить // Выключить
voiceActive = false; voiceActive = false;
jBufNextTime = 0; 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) { if (voiceStream) {
@ -1403,55 +1403,77 @@ function customConfirm(msg, onYes) {
console.error('[voice] Socket connect error:', err.message); console.error('[voice] Socket connect error:', err.message);
}); });
// === Jitter Buffer для голоса === // === Ring Buffer + ScriptProcessor приём голоса ===
// Накапливаем чанки, затем льём непрерывно через один ScriptProcessor // Единый непрерывный поток вместо отдельных BufferSource на чанк
const jBuf = []; // очередь { float32, volume } const RING_SIZE = 24000 * 3; // 3 секунды ring buffer
const JBUF_DELAY = 0.12; // начальная задержка 120мс — буфер против джиттера const ringBuf = new Float32Array(RING_SIZE);
let jBufNextTime = 0; // когда следующий чанк должен стартовать let ringWrite = 0; // позиция записи
let jBufDrift = 0; // коррекция дрифта let ringRead = 0; // позиция чтения
let ringReady = 0; // сколько сэмплов готово
let voicePlayActive = false;
const JBUF_TARGET = 2400; // целевой jitter buffer: 100мс при 24kHz
let jbufFill = 0; // текущее заполнение
let lastVoiceFrom = ''; // кто говорит (для индикатора)
function scheduleVoiceChunk(float32, volume) { // Воспроизводящий ScriptProcessor — читает из ring buffer
const now = audioCtx.currentTime; const playProcessor = audioCtx.createScriptProcessor(2048, 1, 1);
// Первый чанк — добавляем задержку playProcessor.onaudioprocess = (e) => {
if (jBufNextTime === 0) { const out = e.outputBuffer.getChannelData(0);
jBufNextTime = now + JBUF_DELAY; if (ringReady < 1) {
// Тишина — нет голоса
out.fill(0);
return;
} }
// Если чанк пришёл с опозданием (разрыв сети) — не делаем промежуток // Ждём накопления jitter buffer перед стартом
if (jBufNextTime < now) { if (!voicePlayActive && ringReady >= JBUF_TARGET) {
jBufNextTime = now + 0.005; // минимальный зазор 5мс voicePlayActive = true;
} }
const buf = audioCtx.createBuffer(1, float32.length, 24000); if (!voicePlayActive) {
buf.getChannelData(0).set(float32); out.fill(0);
const src = audioCtx.createBufferSource(); return;
src.buffer = buf; }
const gain = audioCtx.createGain(); // Читаем из ring buffer
const vol = Math.min(1.4, volume * 1.5); // усиление, макс 1.4 for (let i = 0; i < out.length; i++) {
gain.gain.setValueAtTime(vol, jBufNextTime); if (ringReady > 0) {
// Лёгкий fade в конце чанка (последние 128 сэмплов) для склейки out[i] = ringBuf[ringRead];
gain.gain.linearRampToValueAtTime(vol * 0.3, jBufNextTime + float32.length / 24000 - 0.005); ringRead = (ringRead + 1) % RING_SIZE;
gain.gain.linearRampToValueAtTime(0, jBufNextTime + float32.length / 24000); ringReady--;
src.connect(gain).connect(audioCtx.destination); } else {
src.start(jBufNextTime); out[i] = 0;
jBufNextTime += float32.length / 24000; // время звучания этого чанка }
} }
};
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) => {
// Воспроизводим входящий голос — raw PCM int16 через jitter buffer // Пишем входящий голос в ring buffer
const { data, meta, volume } = payload; const { data, meta, volume } = payload;
if (!audioCtx || audioCtx.state === 'closed') return; if (!audioCtx || audioCtx.state === 'closed') return;
const int16 = new Int16Array(data); const int16 = new Int16Array(data);
const float32 = new Float32Array(int16.length); const vol = Math.min(1.4, (volume || 1) * 1.5);
for (let i = 0; i < int16.length; i++) { for (let i = 0; i < int16.length; i++) {
float32[i] = int16[i] / (int16[i] < 0 ? 0x8000 : 0x7FFF); 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);
} }
scheduleVoiceChunk(float32, volume || 1); // Сброс jitter fill если пауза была
jbufFill = ringReady;
lastVoiceFrom = meta.name || '???';
// Индикатор // Индикатор
speakingIndicator.style.display = 'block'; speakingIndicator.style.display = 'block';
speakingIndicator.textContent = '🔊 ' + (meta.name || '???'); speakingIndicator.textContent = '🔊 ' + lastVoiceFrom;
clearTimeout(speakingTimeout); clearTimeout(speakingTimeout);
speakingTimeout = setTimeout(() => { speakingIndicator.style.display = 'none'; }, 500); speakingTimeout = setTimeout(() => {
speakingIndicator.style.display = 'none';
voicePlayActive = false; // сброс при паузе
ringReady = 0; // очистить буфер
ringRead = ringWrite; // синхронизировать
}, 600);
}); });
voiceActive = true; voiceActive = true;

View File

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