import WebSocket from 'ws'; import { openStt, openTts, streamAnswer } from './providers.mjs'; import { deviceTools } from './device.mjs'; const MAX_AUDIO = 60 * 16000 * 2; const MAX_BUFFERED = 2 * 1024 * 1024; export function handleTurn(client, store) { const controller = new AbortController(); const { signal } = controller; const device = deviceTools(client, signal); let state = 'waiting', stt, tts, pending, bytes = 0, received = 0, messages = 0, ended = false, terminal = false; const timeout = setTimeout(() => fail('timeout'), 120000); const send = obj => { if (client.readyState === WebSocket.OPEN) client.send(JSON.stringify(obj)); }; function cleanup() { clearTimeout(timeout); controller.abort(); if (stt && stt.readyState < WebSocket.CLOSING) stt.terminate(); if (tts?.ws && tts.ws.readyState < WebSocket.CLOSING) tts.ws.terminate(); } function fail(code) { if (terminal) return; terminal = true; send({ type: 'error', code }); cleanup(); if (client.readyState === WebSocket.OPEN) client.close(); } function sendChunk(chunk, commit = false) { if (!stt || stt.readyState !== WebSocket.OPEN || stt.bufferedAmount > MAX_BUFFERED) return fail('stt_unavailable'); stt.send(JSON.stringify({ message_type: 'input_audio_chunk', audio_base_64: chunk.toString('base64'), ...(commit ? { commit: true } : {}) })); } async function respond(text, settings) { if (state !== 'processing' || terminal) return; state = 'responding'; text = text.trim(); if (!text) return fail('empty_transcript'); send({ type: 'transcript', text }); try { tts = await openTts(settings, signal, audio => { if (terminal || client.readyState !== WebSocket.OPEN) return; if (client.bufferedAmount > MAX_BUFFERED) return fail('client_too_slow'); client.send(audio, { binary: true }); }); if (terminal) return; const answer = await streamAnswer(settings, store.getHistory(), text, signal, part => tts.text(part), device.execute); tts.finish(); await tts.done; if (terminal) return; store.recordTurn(text, answer); terminal = true; send({ type: 'done' }); cleanup(); if (client.readyState === WebSocket.OPEN) client.close(1000); } catch { fail('upstream_error'); } } let settings; let chain = Promise.resolve(); client.on('message', (data, binary) => { if (++messages > 10000 || (received += data.length) > MAX_AUDIO + 131072) return fail('input_limit'); chain = chain.then(async () => { if (terminal) return; if (binary) { if (state !== 'recording') return fail('invalid_state'); const audio = Buffer.from(data); if (!audio.length || audio.length % 2 || (bytes += audio.length) > MAX_AUDIO) return fail('invalid_audio'); if (pending) sendChunk(pending); pending = audio; return; } let msg; try { msg = JSON.parse(data.toString()); } catch { return fail('invalid_message'); } if (device.accept(msg)) return; if (msg?.type === 'start' && state === 'waiting') { state = 'connecting'; settings = store.getSettings(); try { stt = await openStt(settings, signal, text => { if (!terminal && state === 'recording') send({ type: 'partial', text }); }, text => { if (!ended || terminal) return; respond(text, settings); }, () => fail('stt_error')); if (terminal) return; state = 'recording'; } catch { fail('stt_error'); } } else if (msg?.type === 'end' && state === 'recording') { if (!pending) return fail('empty_audio'); ended = true; state = 'processing'; sendChunk(pending, true); pending = null; } else fail('invalid_state'); }).catch(() => fail('internal_error')); }); client.on('error', () => { if (!terminal) { terminal = true; cleanup(); } }); client.on('close', () => { if (!terminal) { terminal = true; cleanup(); } }); send({ type: 'ready' }); }