Files
alice-backend/server/turns.mjs
T

82 lines
3.7 KiB
JavaScript

import WebSocket from 'ws';
import { openStt, openTts, streamAnswer } from './providers.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;
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;
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));
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 + 65536) 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 (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' });
}