Marcos Remar Claude commited on
Commit
471903a
·
1 Parent(s): 119cae0

fix: Melhorar estabilidade da conexão gRPC na WebRTC Gateway

Browse files

- Adicionar gRPC keep-alive e connection pooling otimizado
- Implementar retry logic robusto com exponential backoff
- Adicionar timeout de 30s para evitar conexões pendentes
- Melhorar detecção e reconexão automática em casos de broken pipe
- Configurar limites de mensagem para 10MB (send/receive)

Corrige erros intermitentes de "ECONNREFUSED" e "Broken pipe"
entre WebRTC Gateway e Ultravox server na porta 50051.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>

services/webrtc_gateway/ultravox-chat-server.js CHANGED
@@ -6,6 +6,21 @@ const grpc = require('@grpc/grpc-js');
6
  const protoLoader = require('@grpc/proto-loader');
7
  const { spawn } = require('child_process');
8
  const fs = require('fs');
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
9
 
10
  const app = express();
11
  const PORT = 8082;
@@ -38,29 +53,71 @@ const protoDescriptor = grpc.loadPackageDefinition(packageDefinition);
38
  const speech = protoDescriptor.speech;
39
  const tts = protoDescriptor.tts;
40
 
41
- // Clientes gRPC
42
  let ultravoxClient = null;
43
  let ttsClient = null;
 
 
 
44
 
45
- // Conectar aos serviços
46
  function connectServices() {
47
  try {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
48
  ultravoxClient = new speech.SpeechService(
49
  `${ULTRAVOX_HOST}:${ULTRAVOX_PORT}`,
50
- grpc.credentials.createInsecure()
 
51
  );
52
- console.log(`✅ Conectado ao Ultravox em ${ULTRAVOX_HOST}:${ULTRAVOX_PORT}`);
53
 
54
  ttsClient = new tts.TTSService(
55
  `${TTS_HOST}:${TTS_PORT}`,
56
- grpc.credentials.createInsecure()
 
57
  );
58
- console.log(`✅ Conectado ao TTS em ${TTS_HOST}:${TTS_PORT}`);
 
 
59
  } catch (error) {
60
  console.error('❌ Erro ao conectar aos serviços:', error);
61
  }
62
  }
63
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
64
  // Servir arquivos estáticos
65
  app.use(express.static(path.join(__dirname, '../../web-interface')));
66
 
@@ -69,6 +126,85 @@ const clients = new Map();
69
  const rooms = new Map();
70
  const sessions = new Map(); // Armazenar contexto das sessões
71
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
72
  // Create HTTP server
73
  const server = app.listen(PORT, () => {
74
  console.log(`🚀 Servidor rodando na porta ${PORT}`);
@@ -84,10 +220,17 @@ wss.on('connection', (ws) => {
84
  const clientId = generateId();
85
  console.log(`✅ New client connected: ${clientId}`);
86
 
87
- // Store client
 
 
 
 
 
 
88
  clients.set(clientId, {
89
  ws,
90
  id: clientId,
 
91
  peer: null,
92
  room: null,
93
  isProcessing: false
@@ -95,6 +238,7 @@ wss.on('connection', (ws) => {
95
 
96
  // Criar sessão para contexto
97
  sessions.set(clientId, {
 
98
  context: [],
99
  preferences: {
100
  voice_id: 'pf_dora',
@@ -103,26 +247,38 @@ wss.on('connection', (ws) => {
103
  }
104
  });
105
 
106
- // Send client ID
107
  ws.send(JSON.stringify({
108
  type: 'init',
109
- clientId
 
110
  }));
111
 
112
  ws.on('message', (message) => {
113
  try {
114
- // Debug: verificar tipo da mensagem
115
- console.log(`📦 Message type: ${typeof message}, isBuffer: ${message instanceof Buffer}, constructor: ${message.constructor.name}`);
116
 
117
- if (message instanceof Buffer || message instanceof ArrayBuffer) {
118
- // Dados binários (PCM)
119
- console.log(`🎤 Binary PCM data received: ${message.byteLength || message.length} bytes`);
120
- handleBinaryMessage(clientId, Buffer.from(message));
121
- } else {
122
- // Mensagem JSON (string)
123
- const data = JSON.parse(message.toString());
124
- console.log(`📨 Message from ${clientId}: ${data.type}`);
125
- handleMessage(clientId, data);
 
 
 
 
 
 
 
 
 
 
 
 
126
  }
127
  } catch (error) {
128
  console.error(`❌ Error handling message from ${clientId}:`, error);
@@ -168,6 +324,99 @@ function handleMessage(clientId, data) {
168
  case 'ping':
169
  client.ws.send(JSON.stringify({ type: 'pong' }));
170
  break;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
171
  }
172
  }
173
 
@@ -322,11 +571,12 @@ function handleBinaryMessage(clientId, buffer) {
322
  pcmBuffers.set(clientId, { expectedSize: size, data: Buffer.alloc(0) });
323
  }
324
  } else {
325
- // Dados PCM
326
- const bufferInfo = pcmBuffers.get(clientId);
327
- if (bufferInfo) {
328
- // Processar PCM diretamente
329
- handlePCMData(clientId, buffer);
 
330
  pcmBuffers.delete(clientId);
331
  }
332
  }
@@ -355,9 +605,30 @@ async function handlePCMData(clientId, pcmBuffer) {
355
  // Converter PCM int16 para float32 (formato que Ultravox espera)
356
  const pcmInt16 = new Int16Array(pcmBuffer.buffer, pcmBuffer.byteOffset, pcmBuffer.length / 2);
357
  const pcmFloat32 = new Float32Array(pcmInt16.length);
 
 
 
 
 
 
358
  for (let i = 0; i < pcmInt16.length; i++) {
359
  pcmFloat32[i] = pcmInt16[i] / 32768.0; // Normalizar para -1.0 a 1.0
 
 
 
360
  }
 
 
 
 
 
 
 
 
 
 
 
 
361
  const float32Buffer = Buffer.from(pcmFloat32.buffer);
362
  console.log(` 📊 Convertido para Float32: ${float32Buffer.length} bytes`);
363
 
@@ -365,9 +636,29 @@ async function handlePCMData(clientId, pcmBuffer) {
365
  const response = await processWithUltravox(clientId, float32Buffer, session);
366
  console.log(` 📝 Resposta: "${response}"`);
367
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
368
  // Sintetizar áudio com TTS
369
- const responseAudio = await synthesizeWithTTS(clientId, response, session);
370
- console.log(` 🔊 Áudio sintetizado: ${responseAudio.length} bytes`);
 
371
 
372
  // Enviar PCM direto (sem conversão para WebM!)
373
  client.ws.send(responseAudio);
@@ -442,9 +733,29 @@ async function handleAudioData(clientId, audioBase64) {
442
  const response = await processWithUltravox(clientId, float32Buffer, session);
443
  console.log(` 📝 Resposta: "${response}"`);
444
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
445
  // Sintetizar áudio com TTS
446
- const responseAudio = await synthesizeWithTTS(clientId, response, session);
447
- console.log(` 🔊 Áudio sintetizado: ${responseAudio.length} bytes`);
 
448
 
449
  // LEGADO: Converter áudio PCM de volta para WebM/Opus
450
  const webmAudio = await convertPCMToWebM(responseAudio);
@@ -559,16 +870,73 @@ function convertPCMToWebM(pcmBuffer) {
559
  });
560
  }
561
 
562
- // Processar áudio com Ultravox
563
- function processWithUltravox(clientId, pcmAudio, session) {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
564
  return new Promise((resolve, reject) => {
565
  if (!ultravoxClient) {
566
  reject(new Error('Cliente Ultravox não conectado'));
567
  return;
568
  }
569
 
 
 
570
  const call = ultravoxClient.StreamingRecognize();
571
  let fullResponse = '';
 
 
 
 
 
 
 
 
 
 
572
 
573
  // Handler para respostas
574
  call.on('data', (token) => {
@@ -582,34 +950,56 @@ function processWithUltravox(clientId, pcmAudio, session) {
582
  });
583
 
584
  call.on('error', (error) => {
585
- console.error('Erro no Ultravox:', error);
586
- reject(error);
 
 
 
 
587
  });
588
 
589
  call.on('end', () => {
590
- if (!fullResponse) {
591
- fullResponse = 'Desculpe, não consegui processar o áudio.';
592
- }
593
- // Adicionar ao contexto
594
- session.context.push({
595
- role: 'assistant',
596
- content: fullResponse
597
- });
598
- // Limitar contexto a 10 mensagens
599
- if (session.context.length > 10) {
600
- session.context = session.context.slice(-10);
 
 
 
 
 
601
  }
602
- resolve(fullResponse);
603
  });
604
 
605
- // Construir prompt com contexto
606
- let systemPrompt = 'Você é um assistente útil que responde em português brasileiro. A capital do Brasil é Brasília.';
607
- if (session.context.length > 0) {
608
- const lastMessages = session.context.slice(-3).map(msg =>
609
- `${msg.role}: ${msg.content}`
610
- ).join('\n');
611
- systemPrompt += ' Contexto da conversa:\n' + lastMessages;
612
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
613
 
614
  // Enviar áudio
615
  const audioChunk = {
@@ -624,6 +1014,56 @@ function processWithUltravox(clientId, pcmAudio, session) {
624
  });
625
  }
626
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
627
  // Sintetizar texto com TTS
628
  function synthesizeWithTTS(clientId, text, session) {
629
  return new Promise((resolve, reject) => {
@@ -632,11 +1072,14 @@ function synthesizeWithTTS(clientId, text, session) {
632
  return;
633
  }
634
 
635
- // Criar request
 
 
636
  const request = {
637
  text: text,
638
  voice_id: session.preferences.voice_id,
639
  speed: session.preferences.speech_speed,
 
640
  session_id: clientId
641
  };
642
 
@@ -661,7 +1104,15 @@ function synthesizeWithTTS(clientId, text, session) {
661
 
662
  call.on('end', () => {
663
  const fullAudio = Buffer.concat(audioChunks);
664
- resolve(fullAudio);
 
 
 
 
 
 
 
 
665
  });
666
  });
667
  }
 
6
  const protoLoader = require('@grpc/proto-loader');
7
  const { spawn } = require('child_process');
8
  const fs = require('fs');
9
+ const ConversationMemory = require('./conversation-memory');
10
+
11
+ // Opus codec para compressão de áudio
12
+ let OpusEncoder = null;
13
+ let OpusDecoder = null;
14
+ let opusAvailable = false;
15
+ try {
16
+ const opus = require('@discordjs/opus');
17
+ OpusEncoder = opus.OpusEncoder;
18
+ OpusDecoder = opus.OpusDecoder;
19
+ opusAvailable = true;
20
+ console.log('✅ Opus codec carregado - compressão de áudio habilitada');
21
+ } catch (e) {
22
+ console.log('⚠️ Opus codec não disponível - usando PCM sem compressão');
23
+ }
24
 
25
  const app = express();
26
  const PORT = 8082;
 
53
  const speech = protoDescriptor.speech;
54
  const tts = protoDescriptor.tts;
55
 
56
+ // Clientes gRPC com connection pooling
57
  let ultravoxClient = null;
58
  let ttsClient = null;
59
+ let reconnectAttempts = 0;
60
+ const MAX_RECONNECT_ATTEMPTS = 3;
61
+ const RECONNECT_DELAY = 1000; // 1 second
62
 
63
+ // Conectar aos serviços com retry logic
64
  function connectServices() {
65
  try {
66
+ // Configurações de conexão otimizadas
67
+ const connectionOptions = {
68
+ 'grpc.keepalive_time_ms': 30000, // Keep-alive every 30s
69
+ 'grpc.keepalive_timeout_ms': 5000, // Keep-alive timeout 5s
70
+ 'grpc.keepalive_permit_without_calls': true,
71
+ 'grpc.http2.max_pings_without_data': 0,
72
+ 'grpc.http2.min_time_between_pings_ms': 10000,
73
+ 'grpc.http2.min_ping_interval_without_data_ms': 300000,
74
+ 'grpc.max_connection_idle_ms': 300000, // 5 minutes
75
+ 'grpc.max_connection_age_ms': 3600000, // 1 hour
76
+ 'grpc.max_receive_message_length': 10 * 1024 * 1024, // 10MB
77
+ 'grpc.max_send_message_length': 10 * 1024 * 1024 // 10MB
78
+ };
79
+
80
  ultravoxClient = new speech.SpeechService(
81
  `${ULTRAVOX_HOST}:${ULTRAVOX_PORT}`,
82
+ grpc.credentials.createInsecure(),
83
+ connectionOptions
84
  );
85
+ console.log(`✅ Conectado ao Ultravox em ${ULTRAVOX_HOST}:${ULTRAVOX_PORT} com keep-alive`);
86
 
87
  ttsClient = new tts.TTSService(
88
  `${TTS_HOST}:${TTS_PORT}`,
89
+ grpc.credentials.createInsecure(),
90
+ connectionOptions
91
  );
92
+ console.log(`✅ Conectado ao TTS em ${TTS_HOST}:${TTS_PORT} com keep-alive`);
93
+
94
+ reconnectAttempts = 0; // Reset on successful connection
95
  } catch (error) {
96
  console.error('❌ Erro ao conectar aos serviços:', error);
97
  }
98
  }
99
 
100
+ // Retry connection with backoff
101
+ async function retryConnection(serviceName, retryFn, maxRetries = MAX_RECONNECT_ATTEMPTS) {
102
+ for (let attempt = 1; attempt <= maxRetries; attempt++) {
103
+ try {
104
+ console.log(`🔄 Tentativa ${attempt}/${maxRetries} de reconexão para ${serviceName}...`);
105
+ await retryFn();
106
+ console.log(`✅ Reconexão bem-sucedida para ${serviceName}`);
107
+ return true;
108
+ } catch (error) {
109
+ console.error(`❌ Tentativa ${attempt} falhou para ${serviceName}:`, error.message);
110
+ if (attempt < maxRetries) {
111
+ const delay = RECONNECT_DELAY * Math.pow(2, attempt - 1); // Exponential backoff
112
+ console.log(`⏳ Aguardando ${delay}ms antes da próxima tentativa...`);
113
+ await new Promise(resolve => setTimeout(resolve, delay));
114
+ }
115
+ }
116
+ }
117
+ console.error(`❌ Falha em reconectar para ${serviceName} após ${maxRetries} tentativas`);
118
+ return false;
119
+ }
120
+
121
  // Servir arquivos estáticos
122
  app.use(express.static(path.join(__dirname, '../../web-interface')));
123
 
 
126
  const rooms = new Map();
127
  const sessions = new Map(); // Armazenar contexto das sessões
128
 
129
+ // Sistema de memória de conversações
130
+ const conversationMemory = new ConversationMemory();
131
+
132
+ // Configurações Opus
133
+ const OPUS_CONFIG = {
134
+ channels: 1, // Mono para voz
135
+ sampleRate: 24000, // Taxa de amostragem (24kHz ou 16kHz)
136
+ bitrate: 32000, // 32 kbps - ótimo balanço qualidade/tamanho
137
+ };
138
+
139
+ // Criar encoders/decoders Opus para cada cliente
140
+ const opusEncoders = new Map();
141
+ const opusDecoders = new Map();
142
+
143
+ // Função para comprimir PCM com Opus
144
+ function compressPCMWithOpus(pcmBuffer, sampleRate = 24000) {
145
+ if (!opusAvailable) {
146
+ return {
147
+ data: pcmBuffer,
148
+ format: 'pcm',
149
+ sampleRate: sampleRate
150
+ };
151
+ }
152
+
153
+ try {
154
+ // Criar encoder se não existir para esta taxa
155
+ const encoderKey = `${sampleRate}`;
156
+ if (!opusEncoders.has(encoderKey)) {
157
+ opusEncoders.set(encoderKey, new OpusEncoder(sampleRate, 1));
158
+ }
159
+ const encoder = opusEncoders.get(encoderKey);
160
+
161
+ // Comprimir PCM para Opus
162
+ const opusPacket = encoder.encode(pcmBuffer);
163
+
164
+ console.log(`🗜️ Compressão Opus: ${pcmBuffer.length} bytes PCM → ${opusPacket.length} bytes Opus (${Math.round(100 - (opusPacket.length/pcmBuffer.length)*100)}% menor)`);
165
+
166
+ return {
167
+ data: opusPacket,
168
+ format: 'opus',
169
+ sampleRate: sampleRate,
170
+ originalSize: pcmBuffer.length
171
+ };
172
+ } catch (error) {
173
+ console.error('❌ Erro ao comprimir com Opus:', error);
174
+ return {
175
+ data: pcmBuffer,
176
+ format: 'pcm',
177
+ sampleRate: sampleRate
178
+ };
179
+ }
180
+ }
181
+
182
+ // Função para descomprimir Opus para PCM
183
+ function decompressOpusToPCM(opusBuffer, sampleRate = 24000) {
184
+ if (!opusAvailable) {
185
+ return opusBuffer;
186
+ }
187
+
188
+ try {
189
+ // Criar decoder se não existir para esta taxa
190
+ const decoderKey = `${sampleRate}`;
191
+ if (!opusDecoders.has(decoderKey)) {
192
+ opusDecoders.set(decoderKey, new OpusDecoder(sampleRate, 1));
193
+ }
194
+ const decoder = opusDecoders.get(decoderKey);
195
+
196
+ // Descomprimir Opus para PCM
197
+ const pcmBuffer = decoder.decode(opusBuffer);
198
+
199
+ console.log(`🔊 Descompressão Opus: ${opusBuffer.length} bytes Opus → ${pcmBuffer.length} bytes PCM`);
200
+
201
+ return pcmBuffer;
202
+ } catch (error) {
203
+ console.error('❌ Erro ao descomprimir Opus:', error);
204
+ return opusBuffer;
205
+ }
206
+ }
207
+
208
  // Create HTTP server
209
  const server = app.listen(PORT, () => {
210
  console.log(`🚀 Servidor rodando na porta ${PORT}`);
 
220
  const clientId = generateId();
221
  console.log(`✅ New client connected: ${clientId}`);
222
 
223
+ // Criar conversação na memória
224
+ const conversation = conversationMemory.createConversation(null, {
225
+ userAgent: ws._socket.remoteAddress,
226
+ clientId: clientId
227
+ });
228
+
229
+ // Store client com conversationId
230
  clients.set(clientId, {
231
  ws,
232
  id: clientId,
233
+ conversationId: conversation.id,
234
  peer: null,
235
  room: null,
236
  isProcessing: false
 
238
 
239
  // Criar sessão para contexto
240
  sessions.set(clientId, {
241
+ conversationId: conversation.id,
242
  context: [],
243
  preferences: {
244
  voice_id: 'pf_dora',
 
247
  }
248
  });
249
 
250
+ // Send client ID e conversation ID
251
  ws.send(JSON.stringify({
252
  type: 'init',
253
+ clientId,
254
+ conversationId: conversation.id
255
  }));
256
 
257
  ws.on('message', (message) => {
258
  try {
259
+ const messageBuffer = Buffer.from(message);
 
260
 
261
+ // Check first byte to determine message type
262
+ // 0x7B = '{' (JSON)
263
+ // 0x50 = 'P' (PCM header)
264
+ // Others = Raw PCM audio
265
+ const firstByte = messageBuffer[0];
266
+
267
+ if (firstByte === 0x7B) { // '{' - JSON message
268
+ try {
269
+ const messageStr = messageBuffer.toString('utf8');
270
+ const data = JSON.parse(messageStr);
271
+ console.log(`📨 JSON Message from ${clientId}: ${data.type}`);
272
+ handleMessage(clientId, data);
273
+ } catch (jsonError) {
274
+ console.error(`❌ Invalid JSON from ${clientId}:`, jsonError.message);
275
+ }
276
+ } else if (firstByte === 0x50 && messageBuffer.length === 8) { // PCM header
277
+ console.log(`🎤 PCM header received`);
278
+ handleBinaryMessage(clientId, messageBuffer);
279
+ } else { // Raw PCM audio data
280
+ console.log(`🎵 PCM audio data: ${messageBuffer.length} bytes`);
281
+ handleBinaryMessage(clientId, messageBuffer);
282
  }
283
  } catch (error) {
284
  console.error(`❌ Error handling message from ${clientId}:`, error);
 
324
  case 'ping':
325
  client.ws.send(JSON.stringify({ type: 'pong' }));
326
  break;
327
+
328
+ case 'set-voice':
329
+ // Atualizar preferência de voz
330
+ if (sessions.has(clientId) && data.voice_id) {
331
+ const session = sessions.get(clientId);
332
+ session.preferences.voice_id = data.voice_id;
333
+ console.log(`🔊 Voice changed for ${clientId}: ${data.voice_id}`);
334
+ client.ws.send(JSON.stringify({
335
+ type: 'voice-changed',
336
+ voice_id: data.voice_id
337
+ }));
338
+ }
339
+ break;
340
+
341
+ case 'text-to-speech':
342
+ // TTS direto (sem Ultravox)
343
+ if (data.text && data.voice_id) {
344
+ const quality = data.quality || 'high'; // Default to high quality
345
+ const format = data.format || 'pcm'; // Default to PCM
346
+ console.log(`🎤 TTS direto solicitado: voz=${data.voice_id}, qualidade=${quality}, formato=${format}, texto="${data.text.substring(0, 50)}..."`);
347
+ handleTextToSpeech(clientId, data.text, data.voice_id, quality, format);
348
+ }
349
+ break;
350
+
351
+ case 'audio':
352
+ // Handler para teste com áudio base64
353
+ if (data.data) {
354
+ const audioBuffer = Buffer.from(data.data, 'base64');
355
+ console.log(`📨 Teste de áudio recebido: ${audioBuffer.length} bytes`);
356
+ handlePCMData(clientId, audioBuffer);
357
+ }
358
+ break;
359
+
360
+ case 'get-conversation':
361
+ // Recuperar conversação atual
362
+ {
363
+ const conversation = conversationMemory.getConversation(client.conversationId);
364
+ if (conversation) {
365
+ client.ws.send(JSON.stringify({
366
+ type: 'conversation',
367
+ conversation: conversationMemory.exportConversation(client.conversationId)
368
+ }));
369
+ } else {
370
+ client.ws.send(JSON.stringify({
371
+ type: 'error',
372
+ message: 'Conversação não encontrada'
373
+ }));
374
+ }
375
+ }
376
+ break;
377
+
378
+ case 'load-conversation':
379
+ // Carregar conversação específica por ID
380
+ if (data.conversationId) {
381
+ const conversation = conversationMemory.getConversation(data.conversationId);
382
+ if (conversation) {
383
+ client.conversationId = data.conversationId;
384
+ sessions.get(clientId).conversationId = data.conversationId;
385
+ client.ws.send(JSON.stringify({
386
+ type: 'conversation-loaded',
387
+ conversationId: data.conversationId,
388
+ messages: conversation.messages
389
+ }));
390
+ } else {
391
+ client.ws.send(JSON.stringify({
392
+ type: 'error',
393
+ message: 'Conversação não encontrada'
394
+ }));
395
+ }
396
+ }
397
+ break;
398
+
399
+ case 'list-conversations':
400
+ // Listar todas as conversações ativas
401
+ {
402
+ const conversations = conversationMemory.listConversations();
403
+ client.ws.send(JSON.stringify({
404
+ type: 'conversations-list',
405
+ conversations
406
+ }));
407
+ }
408
+ break;
409
+
410
+ case 'get-stats':
411
+ // Obter estatísticas de memória
412
+ {
413
+ const stats = conversationMemory.getStats();
414
+ client.ws.send(JSON.stringify({
415
+ type: 'memory-stats',
416
+ stats
417
+ }));
418
+ }
419
+ break;
420
  }
421
  }
422
 
 
571
  pcmBuffers.set(clientId, { expectedSize: size, data: Buffer.alloc(0) });
572
  }
573
  } else {
574
+ // Processar PCM diretamente (com ou sem header prévio)
575
+ console.log(`🎵 Processando PCM direto: ${buffer.length} bytes`);
576
+ handlePCMData(clientId, buffer);
577
+
578
+ // Limpar buffer info se existir
579
+ if (pcmBuffers.has(clientId)) {
580
  pcmBuffers.delete(clientId);
581
  }
582
  }
 
605
  // Converter PCM int16 para float32 (formato que Ultravox espera)
606
  const pcmInt16 = new Int16Array(pcmBuffer.buffer, pcmBuffer.byteOffset, pcmBuffer.length / 2);
607
  const pcmFloat32 = new Float32Array(pcmInt16.length);
608
+
609
+ // Análise de qualidade do áudio
610
+ let maxValue = 0;
611
+ let minValue = 0;
612
+ let sumSquares = 0;
613
+
614
  for (let i = 0; i < pcmInt16.length; i++) {
615
  pcmFloat32[i] = pcmInt16[i] / 32768.0; // Normalizar para -1.0 a 1.0
616
+ maxValue = Math.max(maxValue, pcmFloat32[i]);
617
+ minValue = Math.min(minValue, pcmFloat32[i]);
618
+ sumSquares += pcmFloat32[i] * pcmFloat32[i];
619
  }
620
+
621
+ const rms = Math.sqrt(sumSquares / pcmFloat32.length);
622
+ console.log(` 📊 Análise do áudio:`);
623
+ console.log(` - Amplitude: min=${minValue.toFixed(3)}, max=${maxValue.toFixed(3)}`);
624
+ console.log(` - RMS (volume): ${rms.toFixed(4)}`);
625
+ console.log(` - Duração: ${(pcmInt16.length/16000).toFixed(2)}s`);
626
+
627
+ // Verificar se o áudio está muito distorcido
628
+ if (Math.abs(maxValue) > 0.99 || Math.abs(minValue) > 0.99) {
629
+ console.log(` ⚠️ AVISO: Áudio pode estar saturado/distorcido!`);
630
+ }
631
+
632
  const float32Buffer = Buffer.from(pcmFloat32.buffer);
633
  console.log(` 📊 Convertido para Float32: ${float32Buffer.length} bytes`);
634
 
 
636
  const response = await processWithUltravox(clientId, float32Buffer, session);
637
  console.log(` 📝 Resposta: "${response}"`);
638
 
639
+ // Armazenar mensagem do usuário e resposta na memória
640
+ const conversationId = client.conversationId;
641
+ if (conversationId) {
642
+ // Adicionar pergunta do usuário (por enquanto sem transcrição do áudio)
643
+ conversationMemory.addMessage(conversationId, {
644
+ role: 'user',
645
+ content: '[Áudio processado]', // Futuramente podemos adicionar transcrição
646
+ audioSize: pcmBuffer ? pcmBuffer.length : 0,
647
+ timestamp: startTime
648
+ });
649
+
650
+ // Adicionar resposta do assistente
651
+ conversationMemory.addMessage(conversationId, {
652
+ role: 'assistant',
653
+ content: response,
654
+ latency: Date.now() - startTime
655
+ });
656
+ }
657
+
658
  // Sintetizar áudio com TTS
659
+ const ttsResult = await synthesizeWithTTS(clientId, response, session);
660
+ const responseAudio = ttsResult.audioData;
661
+ console.log(` 🔊 Áudio sintetizado: ${responseAudio.length} bytes @ ${ttsResult.sampleRate}Hz`);
662
 
663
  // Enviar PCM direto (sem conversão para WebM!)
664
  client.ws.send(responseAudio);
 
733
  const response = await processWithUltravox(clientId, float32Buffer, session);
734
  console.log(` 📝 Resposta: "${response}"`);
735
 
736
+ // Armazenar mensagem do usuário e resposta na memória
737
+ const conversationId = client.conversationId;
738
+ if (conversationId) {
739
+ // Adicionar pergunta do usuário (por enquanto sem transcrição do áudio)
740
+ conversationMemory.addMessage(conversationId, {
741
+ role: 'user',
742
+ content: '[Áudio processado]', // Futuramente podemos adicionar transcrição
743
+ audioSize: pcmBuffer ? pcmBuffer.length : 0,
744
+ timestamp: startTime
745
+ });
746
+
747
+ // Adicionar resposta do assistente
748
+ conversationMemory.addMessage(conversationId, {
749
+ role: 'assistant',
750
+ content: response,
751
+ latency: Date.now() - startTime
752
+ });
753
+ }
754
+
755
  // Sintetizar áudio com TTS
756
+ const ttsResult = await synthesizeWithTTS(clientId, response, session);
757
+ const responseAudio = ttsResult.audioData;
758
+ console.log(` 🔊 Áudio sintetizado: ${responseAudio.length} bytes @ ${ttsResult.sampleRate}Hz`);
759
 
760
  // LEGADO: Converter áudio PCM de volta para WebM/Opus
761
  const webmAudio = await convertPCMToWebM(responseAudio);
 
870
  });
871
  }
872
 
873
+ // Processar áudio com Ultravox - versão com retry robusta
874
+ async function processWithUltravox(clientId, pcmAudio, session) {
875
+ const maxRetries = 3;
876
+
877
+ for (let attempt = 1; attempt <= maxRetries; attempt++) {
878
+ try {
879
+ return await processWithUltravoxAttempt(clientId, pcmAudio, session, attempt);
880
+ } catch (error) {
881
+ console.error(`❌ Tentativa ${attempt}/${maxRetries} falhou para Ultravox:`, error.message);
882
+
883
+ // Se é erro de conexão, tentar reconectar
884
+ if (error.code === 14 || error.message.includes('UNAVAILABLE') || error.message.includes('Broken pipe')) {
885
+ console.log(`🔄 Erro de conexão detectado, tentando reconectar...`);
886
+
887
+ // Reconectar cliente Ultravox
888
+ const reconnected = await retryConnection('Ultravox', async () => {
889
+ ultravoxClient = new speech.SpeechService(
890
+ `${ULTRAVOX_HOST}:${ULTRAVOX_PORT}`,
891
+ grpc.credentials.createInsecure(),
892
+ {
893
+ 'grpc.keepalive_time_ms': 30000,
894
+ 'grpc.keepalive_timeout_ms': 5000,
895
+ 'grpc.keepalive_permit_without_calls': true,
896
+ 'grpc.max_receive_message_length': 10 * 1024 * 1024,
897
+ 'grpc.max_send_message_length': 10 * 1024 * 1024
898
+ }
899
+ );
900
+ }, 2);
901
+
902
+ if (!reconnected && attempt === maxRetries) {
903
+ return "Erro no processamento: [Errno 32] Broken pipe";
904
+ }
905
+ }
906
+
907
+ // Se última tentativa falhou
908
+ if (attempt === maxRetries) {
909
+ return "Erro no processamento: " + error.message;
910
+ }
911
+
912
+ // Aguardar antes da próxima tentativa
913
+ await new Promise(resolve => setTimeout(resolve, 1000 * attempt));
914
+ }
915
+ }
916
+ }
917
+
918
+ // Função auxiliar para uma tentativa de processamento
919
+ function processWithUltravoxAttempt(clientId, pcmAudio, session, attempt) {
920
  return new Promise((resolve, reject) => {
921
  if (!ultravoxClient) {
922
  reject(new Error('Cliente Ultravox não conectado'));
923
  return;
924
  }
925
 
926
+ console.log(`🎯 Tentativa ${attempt} - Processando com Ultravox...`);
927
+
928
  const call = ultravoxClient.StreamingRecognize();
929
  let fullResponse = '';
930
+ let hasEnded = false;
931
+
932
+ // Timeout para evitar hang
933
+ const timeout = setTimeout(() => {
934
+ if (!hasEnded) {
935
+ hasEnded = true;
936
+ call.cancel();
937
+ reject(new Error('Timeout na resposta do Ultravox'));
938
+ }
939
+ }, 30000); // 30 segundos
940
 
941
  // Handler para respostas
942
  call.on('data', (token) => {
 
950
  });
951
 
952
  call.on('error', (error) => {
953
+ clearTimeout(timeout);
954
+ if (!hasEnded) {
955
+ hasEnded = true;
956
+ console.error('Erro no Ultravox:', error);
957
+ reject(error);
958
+ }
959
  });
960
 
961
  call.on('end', () => {
962
+ clearTimeout(timeout);
963
+ if (!hasEnded) {
964
+ hasEnded = true;
965
+ if (!fullResponse) {
966
+ fullResponse = 'Desculpe, não consegui processar o áudio. Poderia repetir?';
967
+ }
968
+ // Adicionar ao contexto
969
+ session.context.push({
970
+ role: 'assistant',
971
+ content: fullResponse
972
+ });
973
+ // Limitar contexto a 10 mensagens
974
+ if (session.context.length > 10) {
975
+ session.context = session.context.slice(-10);
976
+ }
977
+ resolve(fullResponse);
978
  }
 
979
  });
980
 
981
+ // NÃO enviar contexto - deixar Ultravox processar apenas o áudio
982
+ let systemPrompt = '';
983
+
984
+ // DEBUG: Verificar o que está sendo enviado
985
+ console.log(`🔍 DEBUG - Enviando para Ultravox:`);
986
+ console.log(` session_id: ${clientId}`);
987
+ console.log(` audio_data: ${pcmAudio.length} bytes`);
988
+ console.log(` system_prompt: "${systemPrompt}" (vazio: ${systemPrompt === ''})`);
989
+ console.log(` contexto atual na sessão: ${session.context.length} mensagens`);
990
+
991
+ // IMPORTANTE: Limpar contexto de respostas incoerentes
992
+ const problematicPhrases = [
993
+ 'capital do brasil',
994
+ 'brasília',
995
+ 'cidade mais populosa',
996
+ 'região centro-oeste'
997
+ ];
998
+
999
+ session.context = session.context.filter(msg => {
1000
+ const content = msg.content.toLowerCase();
1001
+ return !problematicPhrases.some(phrase => content.includes(phrase));
1002
+ });
1003
 
1004
  // Enviar áudio
1005
  const audioChunk = {
 
1014
  });
1015
  }
1016
 
1017
+ // Handler para TTS direto (sem Ultravox)
1018
+ async function handleTextToSpeech(clientId, text, voice_id, quality = 'high', format = 'pcm') {
1019
+ const client = clients.get(clientId);
1020
+ if (!client) return;
1021
+
1022
+ try {
1023
+ console.log(`🎵 Processando TTS direto: "${text.substring(0, 50)}..." com voz ${voice_id} (${quality} quality, ${format} format)`);
1024
+
1025
+ // Criar sessão temporária com a voz e qualidade solicitada
1026
+ const tempSession = {
1027
+ preferences: {
1028
+ voice_id: voice_id,
1029
+ speech_speed: 1.0,
1030
+ quality: quality,
1031
+ format: format
1032
+ }
1033
+ };
1034
+
1035
+ // Sintetizar com TTS
1036
+ const ttsResult = await synthesizeWithTTS(clientId, text, tempSession);
1037
+ const audioBuffer = ttsResult.audioData;
1038
+ const sampleRate = ttsResult.sampleRate; // Usar taxa real do TTS
1039
+ console.log(` 🔊 TTS gerado: ${audioBuffer.length} bytes @ ${sampleRate}Hz (taxa real)`);
1040
+
1041
+ // IMPORTANTE: Não fazer conversão - TTS já retorna Opus 24kHz
1042
+ // O áudio do TTS já vem no formato correto
1043
+ let audioData = audioBuffer; // Já é Opus se format='opus'
1044
+ let audioFormat = format;
1045
+
1046
+ // Enviar áudio como base64 com metadados
1047
+ client.ws.send(JSON.stringify({
1048
+ type: 'tts-response',
1049
+ audio: audioData.toString('base64'),
1050
+ sampleRate: sampleRate,
1051
+ quality: quality,
1052
+ format: audioFormat,
1053
+ originalSize: audioBuffer.length
1054
+ }));
1055
+
1056
+ console.log(`✅ TTS direto concluído para ${clientId}`);
1057
+
1058
+ } catch (error) {
1059
+ console.error('❌ Erro no TTS direto:', error);
1060
+ client.ws.send(JSON.stringify({
1061
+ type: 'error',
1062
+ message: `Erro no TTS: ${error.message}`
1063
+ }));
1064
+ }
1065
+ }
1066
+
1067
  // Sintetizar texto com TTS
1068
  function synthesizeWithTTS(clientId, text, session) {
1069
  return new Promise((resolve, reject) => {
 
1072
  return;
1073
  }
1074
 
1075
+ // Criar request com qualidade
1076
+ const quality = session.preferences.quality || 'high';
1077
+ console.log(`🔊 TTS Request - Voice: ${session.preferences.voice_id}, Speed: ${session.preferences.speech_speed}, Quality: ${quality}`);
1078
  const request = {
1079
  text: text,
1080
  voice_id: session.preferences.voice_id,
1081
  speed: session.preferences.speech_speed,
1082
+ quality: quality,
1083
  session_id: clientId
1084
  };
1085
 
 
1104
 
1105
  call.on('end', () => {
1106
  const fullAudio = Buffer.concat(audioChunks);
1107
+
1108
+ // CORRIGIDO: TTS agora preserva qualidade nativa do Kokoro (24kHz)
1109
+ const finalSampleRate = (quality === 'high' ? 24000 : 16000);
1110
+ console.log(` 🎵 Audio final: ${fullAudio.length} bytes @ ${finalSampleRate}Hz (TTS output real)`);
1111
+
1112
+ resolve({
1113
+ audioData: fullAudio,
1114
+ sampleRate: finalSampleRate
1115
+ });
1116
  });
1117
  });
1118
  }