diff --git a/README.md b/README.md index 2187b12e46130bd7f47b8d3fe8bd5bf51356e65a..c6565876d8f11fb56c1a1d9010a7b06e1b5bf029 100644 --- a/README.md +++ b/README.md @@ -10,9 +10,9 @@ cd /workspace/ultravox-pipeline ./scripts/setup_background.sh # Or start individual services: -cd tts-service && ./start.sh # TTS on port 50054 -cd services/orchestrator && ./start.sh # Orchestrator on port 50053 -cd services/webrtc_gateway && ./start.sh # WebRTC on port 8081 +cd ultravox && python server.py # Ultravox on port 50051 +python3 tts_server_gtts.py # TTS on port 50054 +cd services/webrtc_gateway && npm start # WebRTC on port 8082 ``` ## 📊 Current Status (September 2025) @@ -21,7 +21,7 @@ cd services/webrtc_gateway && ./start.sh # WebRTC on port 8081 |---------|---------|----------|--------| | **TTS Service** | ~91ms | Kokoro v1.0, streaming | ✅ Production | | **Ultravox STT+LLM** | ~180ms | vLLM, custom prompts | ✅ Production | -| **Orchestrator** | ~15ms | Session mgmt, health check | ✅ Production | +| **WebRTC Gateway** | ~20ms | Browser interface | ✅ Production | | **End-to-End** | ~286ms | Full pipeline | ✅ Achieved | ### ✨ New Features (September 2025) @@ -35,17 +35,15 @@ cd services/webrtc_gateway && ./start.sh # WebRTC on port 8081 ```mermaid graph TB - Browser[🌐 Browser] -->|WebSocket| WRG[WebRTC Gateway :8081] - WRG -->|gRPC| ORCH[Orchestrator :50053] - ORCH -->|gRPC| UV[Ultravox :50051
STT + LLM] - ORCH -->|gRPC| TTS[TTS Service :50054
Kokoro Engine] + Browser[🌐 Browser] -->|WebSocket| WRG[WebRTC Gateway :8082] + WRG -->|gRPC| UV[Ultravox :50051
STT + LLM] + WRG -->|gRPC| TTS[TTS Service :50054
gTTS Engine] ``` ### Service Responsibilities -- **WebRTC Gateway**: Browser interface, WebSocket signaling -- **Orchestrator**: Pipeline coordination, session management, buffering +- **WebRTC Gateway**: Browser interface, WebSocket signaling, pipeline coordination - **Ultravox**: Multimodal Speech-to-Text + LLM (fixie-ai/ultravox-v0_5-llama-3_2-1b) -- **TTS Service**: Text-to-speech with Kokoro v1.0 engine +- **TTS Service**: Text-to-speech with gTTS engine ## 🔧 Configuration @@ -66,9 +64,8 @@ services: port: 50051 model: "fixie-ai/ultravox-v0_5" - orchestrator: - port: 50053 - buffer_size_ms: 100 + webrtc_gateway: + port: 8082 ``` ## 📄 Technical References @@ -103,7 +100,6 @@ ultravox-pipeline/ ├── ultravox/ # Speech-to-Text + LLM (submodule ready) ├── tts-service/ # Unified TTS Service with Kokoro ├── services/ -│ ├── orchestrator/ # Central pipeline coordinator │ └── webrtc_gateway/ # Browser WebRTC interface ├── config/ # Centralized YAML configuration ├── protos/ # gRPC protocol definitions @@ -117,7 +113,7 @@ ultravox-pipeline/ ```bash # Test gRPC connections -grpcurl -plaintext localhost:50053 orchestrator.OrchestratorService/HealthCheck +grpcurl -plaintext localhost:50051 speech.SpeechService/HealthCheck # Run integration tests cd tests/integration @@ -133,6 +129,89 @@ python benchmark_latency.py - **[gRPC Integration Guide](docs/GRPC_INTEGRATION_GUIDE.md)** - Complete service integration details - **[Context Window Analysis](docs/CONTEXT_WINDOW_ANALYSIS.md)** - Streaming TTS research +## 🐛 Troubleshooting & Solutions + +### Ultravox Audio Processing Issues + +#### Problem: Model returning garbage responses ("???", "!!!", random characters) +**Root Cause**: Incorrect audio format being sent to vLLM + +**Solution**: +```python +# ❌ WRONG - Sending raw array +vllm_input = { + "prompt": prompt, + "multi_modal_data": { + "audio": audio_array # This doesn't work! + } +} + +# ✅ CORRECT - Send as tuple (audio, sample_rate) +audio_tuple = (audio_array, 16000) # Must be 16kHz +vllm_input = { + "prompt": formatted_prompt, + "multi_modal_data": { + "audio": [audio_tuple] # List of tuples! + } +} +``` + +#### Problem: Model not understanding audio content +**Root Cause**: Missing chat template and tokenizer formatting + +**Solution**: +```python +# Import tokenizer for proper formatting +from transformers import AutoTokenizer +tokenizer = AutoTokenizer.from_pretrained(model_name) + +# Format messages with audio token +messages = [{"role": "user", "content": f"<|audio|>\n{prompt}"}] + +# Apply chat template +formatted_prompt = tokenizer.apply_chat_template( + messages, + tokenize=False, + add_generation_prompt=True +) +``` + +#### Optimal vLLM Configuration +```python +# Best parameters for Ultravox v0.5 +sampling_params = SamplingParams( + temperature=0.2, # Low temperature for accurate responses + max_tokens=64 # Sufficient for complete answers +) + +# vLLM initialization +llm = LLM( + model="fixie-ai/ultravox-v0_5-llama-3_2-1b", + trust_remote_code=True, + enforce_eager=True, + max_model_len=4096, + gpu_memory_utilization=0.3 +) +``` + +#### Audio Format Requirements +- **Sample Rate**: Must be 16kHz +- **Format**: Float32 normalized between -1 and 1 +- **Recommended Library**: Use `librosa` for loading audio +```python +import librosa +# Librosa automatically normalizes to [-1, 1] +audio, sr = librosa.load(audio_file, sr=16000) +``` + +### GPU Memory Issues + +#### Problem: "No available memory for the cache blocks" +**Solution**: Run the cleanup script +```bash +bash /workspace/ultravox-pipeline/scripts/cleanup_gpu.sh +``` + ## 🤝 Contributing Focus areas: diff --git a/manage_services.sh b/manage_services.sh new file mode 100755 index 0000000000000000000000000000000000000000..5f4f33f1d9d41336710d00d9c595d6933a7fdfcc --- /dev/null +++ b/manage_services.sh @@ -0,0 +1,282 @@ +#!/bin/bash + +# Script mestre para gerenciar todos os serviços do Ultravox Pipeline +# Inclui limpeza de processos órfãos e verificação de recursos + +# Cores para output +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +BLUE='\033[0;34m' +NC='\033[0m' # No Color + +# Diretórios +ULTRAVOX_DIR="/workspace/ultravox-pipeline/ultravox" +WEBRTC_DIR="/workspace/ultravox-pipeline/services/webrtc_gateway" +TTS_DIR="/workspace/tts-service-kokoro" + +# Função para imprimir com cor +print_colored() { + echo -e "${2}${1}${NC}" +} + +# Função para verificar status da GPU +check_gpu() { + print_colored "📊 Status da GPU:" "$BLUE" + GPU_INFO=$(nvidia-smi --query-gpu=memory.used,memory.free,memory.total --format=csv,noheader,nounits 2>/dev/null | head -1) + if [ -n "$GPU_INFO" ]; then + IFS=',' read -r USED FREE TOTAL <<< "$GPU_INFO" + echo " Usado: ${USED}MB | Livre: ${FREE}MB | Total: ${TOTAL}MB" + + if [ "$FREE" -lt "20000" ]; then + print_colored " ⚠️ AVISO: Menos de 20GB livres!" "$YELLOW" + return 1 + fi + else + print_colored " ❌ Não foi possível verificar GPU" "$RED" + return 1 + fi + return 0 +} + +# Função para limpar processos órfãos +cleanup_orphans() { + print_colored "🧹 Limpando processos órfãos..." "$YELLOW" + + # Limpar vLLM e EngineCore + pkill -f "VLLM::EngineCore" 2>/dev/null + pkill -f "vllm.*engine" 2>/dev/null + pkill -f "multiprocessing.resource_tracker" 2>/dev/null + + sleep 2 + + # Verificar se limpou + REMAINING=$(ps aux | grep -E "vllm|EngineCore" | grep -v grep | wc -l) + if [ "$REMAINING" -eq "0" ]; then + print_colored " ✅ Processos órfãos limpos" "$GREEN" + else + print_colored " ⚠️ Ainda existem $REMAINING processos órfãos" "$YELLOW" + pkill -9 -f "vllm" 2>/dev/null + pkill -9 -f "EngineCore" 2>/dev/null + fi +} + +# Função para iniciar Ultravox +start_ultravox() { + print_colored "\n🚀 Iniciando Ultravox..." "$BLUE" + + # Limpar antes de iniciar + cleanup_orphans + + # Verificar GPU + if ! check_gpu; then + print_colored " Tentando liberar GPU..." "$YELLOW" + cleanup_orphans + sleep 3 + check_gpu + fi + + # Iniciar servidor + cd "$ULTRAVOX_DIR" + if [ -f "start_ultravox.sh" ]; then + nohup bash start_ultravox.sh > ultravox.log 2>&1 & + print_colored " ✅ Ultravox iniciado (PID: $!)" "$GREEN" + echo $! > ultravox.pid + else + print_colored " ❌ Script start_ultravox.sh não encontrado" "$RED" + fi +} + +# Função para parar Ultravox +stop_ultravox() { + print_colored "\n🛑 Parando Ultravox..." "$YELLOW" + + cd "$ULTRAVOX_DIR" + if [ -f "stop_ultravox.sh" ]; then + bash stop_ultravox.sh + else + pkill -f "python.*server.py" 2>/dev/null + cleanup_orphans + fi + + if [ -f "ultravox.pid" ]; then + kill -9 $(cat ultravox.pid) 2>/dev/null + rm ultravox.pid + fi +} + +# Função para iniciar WebRTC Gateway +start_webrtc() { + print_colored "\n🌐 Iniciando WebRTC Gateway..." "$BLUE" + + cd "$WEBRTC_DIR" + nohup npm start > webrtc.log 2>&1 & + print_colored " ✅ WebRTC Gateway iniciado (PID: $!)" "$GREEN" + echo $! > webrtc.pid +} + +# Função para parar WebRTC Gateway +stop_webrtc() { + print_colored "\n🛑 Parando WebRTC Gateway..." "$YELLOW" + + pkill -f "node.*ultravox-chat-server" 2>/dev/null + + cd "$WEBRTC_DIR" + if [ -f "webrtc.pid" ]; then + kill -9 $(cat webrtc.pid) 2>/dev/null + rm webrtc.pid + fi +} + +# Função para iniciar TTS +start_tts() { + print_colored "\n🔊 Iniciando TTS Service..." "$BLUE" + + cd "$TTS_DIR" + + # Verificar se venv existe, senão criar + if [ ! -d "venv" ]; then + print_colored " Criando ambiente virtual..." "$YELLOW" + python3 -m venv venv + fi + + source venv/bin/activate 2>/dev/null + nohup python3 server.py > tts.log 2>&1 & + print_colored " ✅ TTS Service iniciado (PID: $!)" "$GREEN" + echo $! > tts.pid +} + +# Função para parar TTS +stop_tts() { + print_colored "\n🛑 Parando TTS Service..." "$YELLOW" + + pkill -f "tts.*server.py" 2>/dev/null + + cd "$TTS_DIR" + if [ -f "tts.pid" ]; then + kill -9 $(cat tts.pid) 2>/dev/null + rm tts.pid + fi +} + +# Função para verificar status dos serviços +check_status() { + print_colored "\n📊 Status dos Serviços:" "$BLUE" + echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" + + # Ultravox + if lsof -i :50051 >/dev/null 2>&1; then + print_colored "✅ Ultravox: RODANDO (porta 50051)" "$GREEN" + else + print_colored "❌ Ultravox: PARADO" "$RED" + fi + + # WebRTC + if lsof -i :8082 >/dev/null 2>&1; then + print_colored "✅ WebRTC Gateway: RODANDO (porta 8082)" "$GREEN" + else + print_colored "❌ WebRTC Gateway: PARADO" "$RED" + fi + + # TTS + if lsof -i :50054 >/dev/null 2>&1; then + print_colored "✅ TTS Service: RODANDO (porta 50054)" "$GREEN" + else + print_colored "❌ TTS Service: PARADO" "$RED" + fi + + echo "" + check_gpu + echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" +} + +# Menu principal +case "$1" in + start) + print_colored "🚀 Iniciando todos os serviços..." "$BLUE" + cleanup_orphans + start_ultravox + sleep 10 # Aguardar Ultravox carregar + start_webrtc + start_tts + check_status + ;; + + stop) + print_colored "🛑 Parando todos os serviços..." "$YELLOW" + stop_webrtc + stop_tts + stop_ultravox + cleanup_orphans + check_status + ;; + + restart) + print_colored "🔄 Reiniciando todos os serviços..." "$BLUE" + $0 stop + sleep 5 + $0 start + ;; + + status) + check_status + ;; + + cleanup) + cleanup_orphans + check_gpu + ;; + + ultravox-start) + start_ultravox + ;; + + ultravox-stop) + stop_ultravox + ;; + + ultravox-restart) + stop_ultravox + sleep 3 + start_ultravox + ;; + + webrtc-start) + start_webrtc + ;; + + webrtc-stop) + stop_webrtc + ;; + + tts-start) + start_tts + ;; + + tts-stop) + stop_tts + ;; + + *) + echo "Uso: $0 {start|stop|restart|status|cleanup}" + echo "" + echo "Comandos disponíveis:" + echo " start - Inicia todos os serviços" + echo " stop - Para todos os serviços" + echo " restart - Reinicia todos os serviços" + echo " status - Verifica status dos serviços" + echo " cleanup - Limpa processos órfãos" + echo "" + echo "Comandos específicos:" + echo " ultravox-start - Inicia apenas Ultravox" + echo " ultravox-stop - Para apenas Ultravox" + echo " ultravox-restart- Reinicia apenas Ultravox" + echo " webrtc-start - Inicia apenas WebRTC Gateway" + echo " webrtc-stop - Para apenas WebRTC Gateway" + echo " tts-start - Inicia apenas TTS Service" + echo " tts-stop - Para apenas TTS Service" + exit 1 + ;; +esac + +exit 0 \ No newline at end of file diff --git a/services/webrtc_gateway/conversation-memory.js b/services/webrtc_gateway/conversation-memory.js new file mode 100644 index 0000000000000000000000000000000000000000..166b829bcf96b4fda71b703e1b0a88f4ded15eb3 --- /dev/null +++ b/services/webrtc_gateway/conversation-memory.js @@ -0,0 +1,290 @@ +/** + * Sistema de Memória em Processo para Conversações + * Mantém contexto de conversas com limite de mensagens + */ + +const crypto = require('crypto'); + +class ConversationMemory { + constructor() { + // Armazena conversações por ID + this.conversations = new Map(); + + // Configurações + this.config = { + maxMessagesPerConversation: 10, // Máximo de mensagens por conversa + maxConversations: 100, // Máximo de conversas em memória + ttlMinutes: 60, // Tempo de vida em minutos + cleanupIntervalMinutes: 10 // Intervalo de limpeza + }; + + // Estatísticas + this.stats = { + totalMessages: 0, + totalConversations: 0, + activeConversations: 0 + }; + + // Iniciar limpeza automática + this.startCleanup(); + } + + /** + * Gera ID único para conversação + */ + generateConversationId() { + return `conv_${Date.now()}_${crypto.randomBytes(8).toString('hex')}`; + } + + /** + * Cria nova conversação + */ + createConversation(conversationId = null, metadata = {}) { + const id = conversationId || this.generateConversationId(); + + // Verificar limite de conversações + if (this.conversations.size >= this.config.maxConversations) { + this.removeOldestConversation(); + } + + const conversation = { + id, + createdAt: Date.now(), + lastActivity: Date.now(), + messages: [], + metadata: { + ...metadata, + messageCount: 0, + userAgent: metadata.userAgent || 'unknown' + } + }; + + this.conversations.set(id, conversation); + this.stats.totalConversations++; + this.stats.activeConversations = this.conversations.size; + + console.log(`📝 Nova conversação criada: ${id}`); + return conversation; + } + + /** + * Recupera conversação existente + */ + getConversation(conversationId) { + const conversation = this.conversations.get(conversationId); + if (conversation) { + conversation.lastActivity = Date.now(); + } + return conversation; + } + + /** + * Adiciona mensagem à conversação + */ + addMessage(conversationId, message) { + let conversation = this.getConversation(conversationId); + + // Criar conversação se não existir + if (!conversation) { + conversation = this.createConversation(conversationId); + } + + // Estrutura da mensagem + const msg = { + id: `msg_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`, + timestamp: Date.now(), + role: message.role || 'user', + content: message.content || '', + metadata: { + audioSize: message.audioSize || 0, + latency: message.latency || 0, + ...message.metadata + } + }; + + // Adicionar mensagem + conversation.messages.push(msg); + conversation.metadata.messageCount++; + conversation.lastActivity = Date.now(); + + // Limitar número de mensagens + if (conversation.messages.length > this.config.maxMessagesPerConversation) { + conversation.messages.shift(); // Remove a mais antiga + } + + this.stats.totalMessages++; + + console.log(`💬 Mensagem adicionada: ${conversationId} - ${msg.role}: ${msg.content.substring(0, 50)}...`); + return msg; + } + + /** + * Constrói contexto para Ultravox + */ + buildContext(conversationId, maxMessages = 5) { + const conversation = this.getConversation(conversationId); + if (!conversation || conversation.messages.length === 0) { + return ''; + } + + // Pegar últimas N mensagens + const recentMessages = conversation.messages.slice(-maxMessages); + + // Formatar contexto de forma simples + const context = recentMessages + .map(msg => `${msg.role === 'user' ? 'Usuário' : 'Assistente'}: ${msg.content}`) + .join('\n'); + + return context; + } + + /** + * Recupera histórico de mensagens + */ + getMessages(conversationId, limit = 10, offset = 0) { + const conversation = this.getConversation(conversationId); + if (!conversation) { + return []; + } + + const start = Math.max(0, conversation.messages.length - offset - limit); + const end = conversation.messages.length - offset; + + return conversation.messages.slice(start, end); + } + + /** + * Remove conversação mais antiga + */ + removeOldestConversation() { + let oldest = null; + let oldestTime = Date.now(); + + for (const [id, conv] of this.conversations) { + if (conv.lastActivity < oldestTime) { + oldest = id; + oldestTime = conv.lastActivity; + } + } + + if (oldest) { + this.conversations.delete(oldest); + console.log(`🗑️ Conversação removida (limite atingido): ${oldest}`); + } + } + + /** + * Limpa conversações expiradas + */ + cleanupExpired() { + const now = Date.now(); + const ttlMs = this.config.ttlMinutes * 60 * 1000; + let removed = 0; + + for (const [id, conv] of this.conversations) { + if (now - conv.lastActivity > ttlMs) { + this.conversations.delete(id); + removed++; + } + } + + if (removed > 0) { + console.log(`🧹 ${removed} conversações expiradas removidas`); + this.stats.activeConversations = this.conversations.size; + } + } + + /** + * Inicia limpeza automática + */ + startCleanup() { + setInterval(() => { + this.cleanupExpired(); + }, this.config.cleanupIntervalMinutes * 60 * 1000); + } + + /** + * Retorna estatísticas + */ + getStats() { + return { + ...this.stats, + conversationsInMemory: this.conversations.size, + memoryUsage: this.getMemoryUsage() + }; + } + + /** + * Estima uso de memória + */ + getMemoryUsage() { + let totalSize = 0; + + for (const conv of this.conversations.values()) { + // Estimar tamanho aproximado + totalSize += JSON.stringify(conv).length; + } + + return { + bytes: totalSize, + kb: (totalSize / 1024).toFixed(2), + mb: (totalSize / 1024 / 1024).toFixed(2) + }; + } + + /** + * Lista conversações ativas + */ + listConversations() { + const list = []; + + for (const [id, conv] of this.conversations) { + list.push({ + id: conv.id, + createdAt: new Date(conv.createdAt).toISOString(), + lastActivity: new Date(conv.lastActivity).toISOString(), + messageCount: conv.metadata.messageCount, + metadata: conv.metadata + }); + } + + return list.sort((a, b) => b.lastActivity - a.lastActivity); + } + + /** + * Exporta conversação (para backup futuro) + */ + exportConversation(conversationId) { + const conversation = this.getConversation(conversationId); + if (!conversation) { + return null; + } + + return { + ...conversation, + exported: new Date().toISOString(), + version: '1.0' + }; + } + + /** + * Importa conversação (de backup) + */ + importConversation(data) { + if (!data || !data.id) { + throw new Error('Dados de conversação inválidos'); + } + + this.conversations.set(data.id, { + ...data, + lastActivity: Date.now() // Atualizar última atividade + }); + + this.stats.activeConversations = this.conversations.size; + console.log(`📥 Conversação importada: ${data.id}`); + + return data.id; + } +} + +module.exports = ConversationMemory; \ No newline at end of file diff --git a/services/webrtc_gateway/favicon.ico b/services/webrtc_gateway/favicon.ico new file mode 100644 index 0000000000000000000000000000000000000000..fb4c60c11e4af5dcc739b9d28d134be650d58729 --- /dev/null +++ b/services/webrtc_gateway/favicon.ico @@ -0,0 +1 @@ +🎤 \ No newline at end of file diff --git a/services/webrtc_gateway/opus-decoder.js b/services/webrtc_gateway/opus-decoder.js new file mode 100644 index 0000000000000000000000000000000000000000..663cab3cf4273f0ca73890866dff23b6f40cb9a3 --- /dev/null +++ b/services/webrtc_gateway/opus-decoder.js @@ -0,0 +1,150 @@ +/** + * Opus Decoder para navegador + * Usa Web Audio API para decodificar Opus + */ + +class OpusDecoder { + constructor() { + this.audioContext = null; + this.isInitialized = false; + } + + async init(sampleRate = 24000) { + if (this.isInitialized) return; + + try { + // Criar AudioContext com taxa específica + this.audioContext = new (window.AudioContext || window.webkitAudioContext)({ + sampleRate: sampleRate + }); + + this.isInitialized = true; + console.log(`✅ OpusDecoder inicializado @ ${sampleRate}Hz`); + } catch (error) { + console.error('❌ Erro ao inicializar OpusDecoder:', error); + throw error; + } + } + + /** + * Decodifica Opus para PCM usando Web Audio API + * @param {ArrayBuffer} opusData - Dados Opus comprimidos + * @returns {Promise} - PCM decodificado + */ + async decode(opusData) { + if (!this.isInitialized) { + await this.init(); + } + + try { + // Web Audio API pode decodificar Opus nativamente se embrulhado em container + // Para Opus puro, precisamos criar um container WebM mínimo + const webmContainer = this.wrapOpusInWebM(opusData); + + // Decodificar usando Web Audio API + const audioBuffer = await this.audioContext.decodeAudioData(webmContainer); + + // Converter AudioBuffer para PCM Int16 + const pcmData = this.audioBufferToPCM(audioBuffer); + + console.log(`🔊 Opus decodificado: ${opusData.byteLength} bytes → ${pcmData.byteLength} bytes PCM`); + + return pcmData; + } catch (error) { + console.error('❌ Erro ao decodificar Opus:', error); + // Fallback: retornar dados originais se não conseguir decodificar + return opusData; + } + } + + /** + * Envolve dados Opus em container WebM mínimo + * @param {ArrayBuffer} opusData - Dados Opus puros + * @returns {ArrayBuffer} - WebM container com Opus + */ + wrapOpusInWebM(opusData) { + // Implementação simplificada - na prática, usaria uma biblioteca + // Por enquanto, assumimos que o navegador pode processar Opus diretamente + // se fornecido com headers apropriados + + // Para implementação real, considerar usar: + // - libopus.js (porta WASM do libopus) + // - opus-recorder (biblioteca JS para Opus) + + return opusData; // Placeholder + } + + /** + * Converte AudioBuffer para PCM Int16 + * @param {AudioBuffer} audioBuffer + * @returns {ArrayBuffer} PCM Int16 data + */ + audioBufferToPCM(audioBuffer) { + const length = audioBuffer.length; + const pcmData = new Int16Array(length); + const channelData = audioBuffer.getChannelData(0); // Mono + + // Converter Float32 para Int16 + for (let i = 0; i < length; i++) { + const sample = Math.max(-1, Math.min(1, channelData[i])); + pcmData[i] = sample * 0x7FFF; + } + + return pcmData.buffer; + } +} + +/** + * Alternativa: Usar biblioteca opus-decoder (mais robusta) + * npm install opus-decoder + */ +class OpusDecoderWASM { + constructor() { + this.decoder = null; + this.ready = false; + } + + async init(sampleRate = 24000, channels = 1) { + if (this.ready) return; + + try { + // Carregar opus-decoder WASM se disponível + if (typeof OpusDecoderWebAssembly !== 'undefined') { + const { OpusDecoderWebAssembly } = await import('opus-decoder'); + this.decoder = new OpusDecoderWebAssembly({ + channels: channels, + sampleRate: sampleRate + }); + await this.decoder.ready; + this.ready = true; + console.log('✅ OpusDecoderWASM pronto'); + } else { + throw new Error('opus-decoder não disponível'); + } + } catch (error) { + console.warn('⚠️ WASM decoder não disponível, usando fallback'); + // Fallback para decoder básico + this.decoder = new OpusDecoder(); + await this.decoder.init(sampleRate); + this.ready = true; + } + } + + async decode(opusData) { + if (!this.ready) { + await this.init(); + } + + if (this.decoder.decode) { + // Usar WASM decoder se disponível + return await this.decoder.decode(opusData); + } else { + // Fallback + return opusData; + } + } +} + +// Exportar para uso global +window.OpusDecoder = OpusDecoder; +window.OpusDecoderWASM = OpusDecoderWASM; \ No newline at end of file diff --git a/services/webrtc_gateway/package-lock.json b/services/webrtc_gateway/package-lock.json index 6e4c51bba3c8269877a37803b3a4a885dfc5747f..f0b84749d8eb5edd6d9dc0139cb0bed46905c74d 100644 --- a/services/webrtc_gateway/package-lock.json +++ b/services/webrtc_gateway/package-lock.json @@ -9,6 +9,7 @@ "version": "1.0.0", "license": "ISC", "dependencies": { + "@discordjs/opus": "^0.10.0", "@grpc/grpc-js": "^1.9.11", "@grpc/proto-loader": "^0.7.10", "express": "^5.1.0", @@ -18,6 +19,40 @@ "nodemon": "^3.0.1" } }, + "node_modules/@discordjs/node-pre-gyp": { + "version": "0.4.5", + "resolved": "https://registry.npmjs.org/@discordjs/node-pre-gyp/-/node-pre-gyp-0.4.5.tgz", + "integrity": "sha512-YJOVVZ545x24mHzANfYoy0BJX5PDyeZlpiJjDkUBM/V/Ao7TFX9lcUvCN4nr0tbr5ubeaXxtEBILUrHtTphVeQ==", + "license": "BSD-3-Clause", + "dependencies": { + "detect-libc": "^2.0.0", + "https-proxy-agent": "^5.0.0", + "make-dir": "^3.1.0", + "node-fetch": "^2.6.7", + "nopt": "^5.0.0", + "npmlog": "^5.0.1", + "rimraf": "^3.0.2", + "semver": "^7.3.5", + "tar": "^6.1.11" + }, + "bin": { + "node-pre-gyp": "bin/node-pre-gyp" + } + }, + "node_modules/@discordjs/opus": { + "version": "0.10.0", + "resolved": "https://registry.npmjs.org/@discordjs/opus/-/opus-0.10.0.tgz", + "integrity": "sha512-HHEnSNrSPmFEyndRdQBJN2YE6egyXS9JUnJWyP6jficK0Y+qKMEZXyYTgmzpjrxXP1exM/hKaNP7BRBUEWkU5w==", + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "@discordjs/node-pre-gyp": "^0.4.5", + "node-addon-api": "^8.1.0" + }, + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/@grpc/grpc-js": { "version": "1.13.4", "resolved": "https://registry.npmjs.org/@grpc/grpc-js/-/grpc-js-1.13.4.tgz", @@ -132,6 +167,12 @@ "undici-types": "~7.10.0" } }, + "node_modules/abbrev": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-1.1.1.tgz", + "integrity": "sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q==", + "license": "ISC" + }, "node_modules/accepts": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", @@ -145,6 +186,18 @@ "node": ">= 0.6" } }, + "node_modules/agent-base": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", + "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", + "license": "MIT", + "dependencies": { + "debug": "4" + }, + "engines": { + "node": ">= 6.0.0" + } + }, "node_modules/ansi-regex": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", @@ -183,11 +236,30 @@ "node": ">= 8" } }, + "node_modules/aproba": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/aproba/-/aproba-2.1.0.tgz", + "integrity": "sha512-tLIEcj5GuR2RSTnxNKdkK0dJ/GrC7P38sUkiDmDuHfsHmbagTFAxDVIBltoklXEVIQ/f14IL8IMJ5pn9Hez1Ew==", + "license": "ISC" + }, + "node_modules/are-we-there-yet": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/are-we-there-yet/-/are-we-there-yet-2.0.0.tgz", + "integrity": "sha512-Ci/qENmwHnsYo9xKIcUJN5LeDKdJ6R1Z1j9V/J5wyq8nh/mYPEpIKJbBZXtZjG04HiK7zV/p6Vs9952MrMeUIw==", + "deprecated": "This package is no longer supported.", + "license": "ISC", + "dependencies": { + "delegates": "^1.0.0", + "readable-stream": "^3.6.0" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/balanced-match": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", - "dev": true, "license": "MIT" }, "node_modules/binary-extensions": { @@ -227,7 +299,6 @@ "version": "1.1.12", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", - "dev": true, "license": "MIT", "dependencies": { "balanced-match": "^1.0.0", @@ -310,6 +381,15 @@ "fsevents": "~2.3.2" } }, + "node_modules/chownr": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-2.0.0.tgz", + "integrity": "sha512-bIomtDF5KGpdogkLd9VspvFzk9KfpyyGlS8YFVZl7TGPBHL5snIOnxeshwVgPteQ9b4Eydl+pVbIyE1DcvCWgQ==", + "license": "ISC", + "engines": { + "node": ">=10" + } + }, "node_modules/cliui": { "version": "8.0.1", "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", @@ -342,13 +422,27 @@ "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", "license": "MIT" }, + "node_modules/color-support": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/color-support/-/color-support-1.1.3.tgz", + "integrity": "sha512-qiBjkpbMLO/HL68y+lh4q0/O1MZFj2RX6X/KmMa3+gJD3z+WwI1ZzDHysvqHGS3mP6mznPckpXmw1nI9cJjyRg==", + "license": "ISC", + "bin": { + "color-support": "bin.js" + } + }, "node_modules/concat-map": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", - "dev": true, "license": "MIT" }, + "node_modules/console-control-strings": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/console-control-strings/-/console-control-strings-1.1.0.tgz", + "integrity": "sha512-ty/fTekppD2fIwRvnZAVdeOiGd1c7YXEixbgJTNzqcxJWKQnjJ/V1bNEEE6hygpM3WjwHFUVK6HTjWSzV4a8sQ==", + "license": "ISC" + }, "node_modules/content-disposition": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.0.0.tgz", @@ -405,6 +499,12 @@ } } }, + "node_modules/delegates": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delegates/-/delegates-1.0.0.tgz", + "integrity": "sha512-bd2L678uiWATM6m5Z1VzNCErI3jiGzt6HGY8OVICs40JQq/HALfbyNJmp0UDakEY4pMMaN0Ly5om/B1VI/+xfQ==", + "license": "MIT" + }, "node_modules/depd": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", @@ -414,6 +514,15 @@ "node": ">= 0.8" } }, + "node_modules/detect-libc": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.0.4.tgz", + "integrity": "sha512-3UDv+G9CsCKO1WKMGw9fwq/SWJYbI0c5Y7LU1AXYoDdbhE2AHQ6N6Nb34sG8Fj7T5APy8qXDCKuuIHd1BR0tVA==", + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, "node_modules/dunder-proto": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", @@ -593,6 +702,36 @@ "node": ">= 0.8" } }, + "node_modules/fs-minipass": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fs-minipass/-/fs-minipass-2.1.0.tgz", + "integrity": "sha512-V/JgOLFCS+R6Vcq0slCuaeWEdNC3ouDlJMNIsacH2VtALiu9mV4LPrHc5cDl8k5aw6J8jwgWWpiTo5RYhmIzvg==", + "license": "ISC", + "dependencies": { + "minipass": "^3.0.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/fs-minipass/node_modules/minipass": { + "version": "3.3.6", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", + "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", + "license": "ISC", + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/fs.realpath": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", + "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", + "license": "ISC" + }, "node_modules/fsevents": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", @@ -617,6 +756,27 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/gauge": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/gauge/-/gauge-3.0.2.tgz", + "integrity": "sha512-+5J6MS/5XksCuXq++uFRsnUd7Ovu1XenbeuIuNRJxYWjgQbPuFhT14lAvsWfqfAmnwluf1OwMjz39HjfLPci0Q==", + "deprecated": "This package is no longer supported.", + "license": "ISC", + "dependencies": { + "aproba": "^1.0.3 || ^2.0.0", + "color-support": "^1.1.2", + "console-control-strings": "^1.0.0", + "has-unicode": "^2.0.1", + "object-assign": "^4.1.1", + "signal-exit": "^3.0.0", + "string-width": "^4.2.3", + "strip-ansi": "^6.0.1", + "wide-align": "^1.1.2" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/get-caller-file": { "version": "2.0.5", "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", @@ -663,6 +823,27 @@ "node": ">= 0.4" } }, + "node_modules/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "deprecated": "Glob versions prior to v9 are no longer supported", + "license": "ISC", + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/glob-parent": { "version": "5.1.2", "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", @@ -710,6 +891,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/has-unicode": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/has-unicode/-/has-unicode-2.0.1.tgz", + "integrity": "sha512-8Rf9Y83NBReMnx0gFzA8JImQACstCYWUplepDa9xprwwtmgEZUF0h/i5xSA625zB/I37EtrswSST6OXxwaaIJQ==", + "license": "ISC" + }, "node_modules/hasown": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", @@ -747,6 +934,19 @@ "node": ">= 0.8" } }, + "node_modules/https-proxy-agent": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", + "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", + "license": "MIT", + "dependencies": { + "agent-base": "6", + "debug": "4" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/iconv-lite": { "version": "0.6.3", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", @@ -766,6 +966,17 @@ "dev": true, "license": "ISC" }, + "node_modules/inflight": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", + "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", + "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.", + "license": "ISC", + "dependencies": { + "once": "^1.3.0", + "wrappy": "1" + } + }, "node_modules/inherits": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", @@ -854,6 +1065,30 @@ "integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==", "license": "Apache-2.0" }, + "node_modules/make-dir": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-3.1.0.tgz", + "integrity": "sha512-g3FeP20LNwhALb/6Cz6Dd4F2ngze0jz7tbzrD2wAV+o9FeNHe4rL+yK2md0J/fiSf1sa1ADhXqi5+oVwOM/eGw==", + "license": "MIT", + "dependencies": { + "semver": "^6.0.0" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/make-dir/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, "node_modules/math-intrinsics": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", @@ -909,7 +1144,6 @@ "version": "3.1.2", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", - "dev": true, "license": "ISC", "dependencies": { "brace-expansion": "^1.1.7" @@ -918,6 +1152,52 @@ "node": "*" } }, + "node_modules/minipass": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-5.0.0.tgz", + "integrity": "sha512-3FnjYuehv9k6ovOEbyOswadCDPX1piCfhV8ncmYtHOjuPwylVWsghTLo7rabjC3Rx5xD4HDx8Wm1xnMF7S5qFQ==", + "license": "ISC", + "engines": { + "node": ">=8" + } + }, + "node_modules/minizlib": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-2.1.2.tgz", + "integrity": "sha512-bAxsR8BVfj60DWXHE3u30oHzfl4G7khkSuPW+qvpd7jFRHm7dLxOjUk1EHACJ/hxLY8phGJ0YhYHZo7jil7Qdg==", + "license": "MIT", + "dependencies": { + "minipass": "^3.0.0", + "yallist": "^4.0.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/minizlib/node_modules/minipass": { + "version": "3.3.6", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", + "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", + "license": "ISC", + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/mkdirp": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-1.0.4.tgz", + "integrity": "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==", + "license": "MIT", + "bin": { + "mkdirp": "bin/cmd.js" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -933,6 +1213,35 @@ "node": ">= 0.6" } }, + "node_modules/node-addon-api": { + "version": "8.5.0", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-8.5.0.tgz", + "integrity": "sha512-/bRZty2mXUIFY/xU5HLvveNHlswNJej+RnxBjOMkidWfwZzgTbPG1E3K5TOxRLOR+5hX7bSofy8yf1hZevMS8A==", + "license": "MIT", + "engines": { + "node": "^18 || ^20 || >= 21" + } + }, + "node_modules/node-fetch": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", + "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", + "license": "MIT", + "dependencies": { + "whatwg-url": "^5.0.0" + }, + "engines": { + "node": "4.x || >=6.0.0" + }, + "peerDependencies": { + "encoding": "^0.1.0" + }, + "peerDependenciesMeta": { + "encoding": { + "optional": true + } + } + }, "node_modules/nodemon": { "version": "3.1.10", "resolved": "https://registry.npmjs.org/nodemon/-/nodemon-3.1.10.tgz", @@ -962,6 +1271,21 @@ "url": "https://opencollective.com/nodemon" } }, + "node_modules/nopt": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/nopt/-/nopt-5.0.0.tgz", + "integrity": "sha512-Tbj67rffqceeLpcRXrT7vKAN8CwfPeIBgM7E6iBkmKLV7bEMwpGgYLGv0jACUsECaa/vuxP0IjEont6umdMgtQ==", + "license": "ISC", + "dependencies": { + "abbrev": "1" + }, + "bin": { + "nopt": "bin/nopt.js" + }, + "engines": { + "node": ">=6" + } + }, "node_modules/normalize-path": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", @@ -972,6 +1296,28 @@ "node": ">=0.10.0" } }, + "node_modules/npmlog": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/npmlog/-/npmlog-5.0.1.tgz", + "integrity": "sha512-AqZtDUWOMKs1G/8lwylVjrdYgqA4d9nu8hc+0gzRxlDb1I10+FHBGMXs6aiQHFdCUUlqH99MUMuLfzWDNDtfxw==", + "deprecated": "This package is no longer supported.", + "license": "ISC", + "dependencies": { + "are-we-there-yet": "^2.0.0", + "console-control-strings": "^1.1.0", + "gauge": "^3.0.0", + "set-blocking": "^2.0.0" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/object-inspect": { "version": "1.13.4", "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", @@ -1014,6 +1360,15 @@ "node": ">= 0.8" } }, + "node_modules/path-is-absolute": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", + "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/path-to-regexp": { "version": "8.3.0", "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.3.0.tgz", @@ -1136,6 +1491,20 @@ "url": "https://opencollective.com/express" } }, + "node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/readdirp": { "version": "3.6.0", "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", @@ -1158,6 +1527,22 @@ "node": ">=0.10.0" } }, + "node_modules/rimraf": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", + "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==", + "deprecated": "Rimraf versions prior to v4 are no longer supported", + "license": "ISC", + "dependencies": { + "glob": "^7.1.3" + }, + "bin": { + "rimraf": "bin.js" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/router": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", @@ -1204,7 +1589,6 @@ "version": "7.7.2", "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz", "integrity": "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==", - "dev": true, "license": "ISC", "bin": { "semver": "bin/semver.js" @@ -1250,6 +1634,12 @@ "node": ">= 18" } }, + "node_modules/set-blocking": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz", + "integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==", + "license": "ISC" + }, "node_modules/setprototypeof": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", @@ -1328,6 +1718,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/signal-exit": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", + "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", + "license": "ISC" + }, "node_modules/simple-update-notifier": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/simple-update-notifier/-/simple-update-notifier-2.0.0.tgz", @@ -1350,6 +1746,15 @@ "node": ">= 0.8" } }, + "node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, "node_modules/string-width": { "version": "4.2.3", "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", @@ -1389,6 +1794,23 @@ "node": ">=4" } }, + "node_modules/tar": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/tar/-/tar-6.2.1.tgz", + "integrity": "sha512-DZ4yORTwrbTj/7MZYq2w+/ZFdI6OZ/f9SFHR+71gIVUZhOQPHzVCLpvRnPgyaMpfWxxk/4ONva3GQSyNIKRv6A==", + "license": "ISC", + "dependencies": { + "chownr": "^2.0.0", + "fs-minipass": "^2.0.0", + "minipass": "^5.0.0", + "minizlib": "^2.1.1", + "mkdirp": "^1.0.3", + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/to-regex-range": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", @@ -1421,6 +1843,12 @@ "nodetouch": "bin/nodetouch.js" } }, + "node_modules/tr46": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", + "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", + "license": "MIT" + }, "node_modules/type-is": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.0.1.tgz", @@ -1457,6 +1885,12 @@ "node": ">= 0.8" } }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "license": "MIT" + }, "node_modules/vary": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", @@ -1466,6 +1900,31 @@ "node": ">= 0.8" } }, + "node_modules/webidl-conversions": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", + "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", + "license": "BSD-2-Clause" + }, + "node_modules/whatwg-url": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", + "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", + "license": "MIT", + "dependencies": { + "tr46": "~0.0.3", + "webidl-conversions": "^3.0.0" + } + }, + "node_modules/wide-align": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/wide-align/-/wide-align-1.1.5.tgz", + "integrity": "sha512-eDMORYaPNZ4sQIuuYPDHdQvf4gyCF9rEEV/yPxGfwPkRodwEgiMUUXTx/dex+Me0wxx53S+NgUHaP7y3MGlDmg==", + "license": "ISC", + "dependencies": { + "string-width": "^1.0.2 || 2 || 3 || 4" + } + }, "node_modules/wrap-ansi": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", @@ -1519,6 +1978,12 @@ "node": ">=10" } }, + "node_modules/yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", + "license": "ISC" + }, "node_modules/yargs": { "version": "17.7.2", "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", diff --git a/services/webrtc_gateway/package.json b/services/webrtc_gateway/package.json index a45c8e17b7622f0e35ecdbe53967950d1f5d0645..703ee2910c174c1b50d9b2aa829abfeb7bc84be9 100644 --- a/services/webrtc_gateway/package.json +++ b/services/webrtc_gateway/package.json @@ -12,10 +12,11 @@ "license": "ISC", "description": "Servidor WebRTC unificado com Simple Peer conectando ao Ultravox/TTS", "dependencies": { - "express": "^5.1.0", - "ws": "^8.18.3", + "@discordjs/opus": "^0.10.0", "@grpc/grpc-js": "^1.9.11", - "@grpc/proto-loader": "^0.7.10" + "@grpc/proto-loader": "^0.7.10", + "express": "^5.1.0", + "ws": "^8.18.3" }, "devDependencies": { "nodemon": "^3.0.1" diff --git a/services/webrtc_gateway/response_1757390722112.pcm b/services/webrtc_gateway/response_1757390722112.pcm new file mode 100644 index 0000000000000000000000000000000000000000..51696308f5d605c43054938b293836a472bc2284 --- /dev/null +++ b/services/webrtc_gateway/response_1757390722112.pcm @@ -0,0 +1 @@ +{"type":"init","clientId":"yi5gt94jz1c5n6ky7t844","conversationId":"conv_1757390722110_31ca908303358733"} \ No newline at end of file diff --git a/services/webrtc_gateway/response_1757391966860.pcm b/services/webrtc_gateway/response_1757391966860.pcm new file mode 100644 index 0000000000000000000000000000000000000000..4ef2f8b02ae0ff7f1c99f20361ae54a81aa48963 --- /dev/null +++ b/services/webrtc_gateway/response_1757391966860.pcm @@ -0,0 +1 @@ +{"type":"init","clientId":"knc8cmsgwqddnn3diqw3do","conversationId":"conv_1757391966858_5d93e75e246743a2"} \ No newline at end of file diff --git a/services/webrtc_gateway/start.sh b/services/webrtc_gateway/start.sh index 642a3a57308dbe6fbe8a6d58e067ce12d120f411..19cfa3d94061b6f4bd1fa98d25a2d8503af866d6 100755 --- a/services/webrtc_gateway/start.sh +++ b/services/webrtc_gateway/start.sh @@ -60,8 +60,6 @@ source venv/bin/activate # Configurar variáveis de ambiente export PYTHONPATH=/workspace/ultravox-pipeline:/workspace/ultravox-pipeline/protos/generated export WEBRTC_PORT=$PORT -export ORCHESTRATOR_HOST=localhost -export ORCHESTRATOR_PORT=50053 echo -e "${YELLOW}Porta: $PORT${NC}" echo -e "${YELLOW}Log: $LOG_FILE${NC}" diff --git a/services/webrtc_gateway/test-audio-cli.js b/services/webrtc_gateway/test-audio-cli.js new file mode 100644 index 0000000000000000000000000000000000000000..94ef3ee5aeb224fe6e00b35f9385f54a3d2cbdb7 --- /dev/null +++ b/services/webrtc_gateway/test-audio-cli.js @@ -0,0 +1,178 @@ +#!/usr/bin/env node + +/** + * Teste CLI para simular envio de áudio PCM ao servidor + * Similar ao que o navegador faz, mas via linha de comando + */ + +const WebSocket = require('ws'); +const fs = require('fs'); +const path = require('path'); + +const WS_URL = 'ws://localhost:8082/ws'; + +class AudioTester { + constructor() { + this.ws = null; + this.conversationId = null; + this.clientId = null; + } + + connect() { + return new Promise((resolve, reject) => { + console.log('🔌 Conectando ao WebSocket...'); + + this.ws = new WebSocket(WS_URL); + + this.ws.on('open', () => { + console.log('✅ Conectado ao servidor'); + resolve(); + }); + + this.ws.on('error', (error) => { + console.error('❌ Erro:', error.message); + reject(error); + }); + + this.ws.on('message', (data) => { + // Verificar se é binário (áudio) ou JSON (mensagem) + if (data instanceof Buffer) { + console.log(`🔊 Áudio recebido: ${(data.length / 1024).toFixed(1)}KB`); + // Salvar áudio para análise + const filename = `response_${Date.now()}.pcm`; + fs.writeFileSync(filename, data); + console.log(` Salvo como: ${filename}`); + } else { + try { + const msg = JSON.parse(data); + console.log('📨 Mensagem recebida:', msg); + + if (msg.type === 'init') { + this.clientId = msg.clientId; + this.conversationId = msg.conversationId; + console.log(`🔑 Client ID: ${this.clientId}`); + console.log(`🔑 Conversation ID: ${this.conversationId}`); + } else if (msg.type === 'metrics') { + console.log(`📊 Resposta: "${msg.response}" (${msg.latency}ms)`); + } + } catch (e) { + console.log('📨 Dados recebidos:', data.toString()); + } + } + }); + }); + } + + /** + * Gera áudio PCM sintético com tom de 440Hz (nota Lá) + * @param {number} durationMs - Duração em milissegundos + * @returns {Buffer} - Buffer PCM 16-bit @ 16kHz + */ + generateTestAudio(durationMs = 2000) { + const sampleRate = 16000; + const frequency = 440; // Hz (nota Lá) + const samples = Math.floor(sampleRate * durationMs / 1000); + const buffer = Buffer.alloc(samples * 2); // 16-bit = 2 bytes por sample + + for (let i = 0; i < samples; i++) { + // Gerar onda senoidal + const t = i / sampleRate; + const value = Math.sin(2 * Math.PI * frequency * t); + + // Converter para int16 + const int16Value = Math.floor(value * 32767); + + // Escrever no buffer (little-endian) + buffer.writeInt16LE(int16Value, i * 2); + } + + return buffer; + } + + /** + * Gera áudio de fala real usando espeak (se disponível) + */ + async generateSpeechAudio(text = "Olá, este é um teste de áudio") { + const { execSync } = require('child_process'); + const tempFile = `/tmp/test_audio_${Date.now()}.raw`; + + try { + // Usar espeak para gerar áudio + console.log(`🎤 Gerando áudio de fala: "${text}"`); + execSync(`espeak -s 150 -v pt-br "${text}" --stdout | sox - -r 16000 -b 16 -e signed-integer ${tempFile}`); + + const audioBuffer = fs.readFileSync(tempFile); + fs.unlinkSync(tempFile); // Limpar arquivo temporário + + return audioBuffer; + } catch (error) { + console.warn('⚠️ espeak/sox não disponível, usando áudio sintético'); + return this.generateTestAudio(2000); + } + } + + async sendAudio(audioBuffer) { + console.log(`\n📤 Enviando áudio PCM: ${(audioBuffer.length / 1024).toFixed(1)}KB`); + + // Enviar como dados binários diretos (como o navegador faz) + this.ws.send(audioBuffer); + + console.log('✅ Áudio enviado'); + } + + async testConversation() { + console.log('\n=== Iniciando teste de conversação ===\n'); + + // Teste 1: Enviar tom sintético + console.log('1️⃣ Teste com tom sintético (440Hz por 2s)'); + const syntheticAudio = this.generateTestAudio(2000); + await this.sendAudio(syntheticAudio); + await this.wait(5000); // Aguardar resposta + + // Teste 2: Enviar áudio de fala (se possível) + console.log('\n2️⃣ Teste com fala sintetizada'); + const speechAudio = await this.generateSpeechAudio("Qual é o seu nome?"); + await this.sendAudio(speechAudio); + await this.wait(5000); // Aguardar resposta + + // Teste 3: Enviar silêncio + console.log('\n3️⃣ Teste com silêncio'); + const silentAudio = Buffer.alloc(32000); // 1 segundo de silêncio + await this.sendAudio(silentAudio); + await this.wait(5000); // Aguardar resposta + } + + wait(ms) { + return new Promise(resolve => setTimeout(resolve, ms)); + } + + disconnect() { + if (this.ws) { + console.log('\n👋 Desconectando...'); + this.ws.close(); + } + } +} + +async function main() { + const tester = new AudioTester(); + + try { + await tester.connect(); + await tester.wait(500); + await tester.testConversation(); + await tester.wait(2000); // Aguardar últimas respostas + } catch (error) { + console.error('Erro fatal:', error); + } finally { + tester.disconnect(); + } +} + +console.log('╔═══════════════════════════════════════╗'); +console.log('║ Teste CLI de Áudio PCM ║'); +console.log('╚═══════════════════════════════════════╝\n'); +console.log('Este teste simula o envio de áudio PCM'); +console.log('como o navegador faz, mas via CLI.\n'); + +main().catch(console.error); \ No newline at end of file diff --git a/services/webrtc_gateway/test-memory.js b/services/webrtc_gateway/test-memory.js new file mode 100644 index 0000000000000000000000000000000000000000..06970194c95792c6914682d3674d7f2b90e0d973 --- /dev/null +++ b/services/webrtc_gateway/test-memory.js @@ -0,0 +1,108 @@ +#!/usr/bin/env node + +/** + * Teste do sistema de memória de conversações + */ + +const WebSocket = require('ws'); + +const WS_URL = 'ws://localhost:8082/ws'; + +class MemoryTester { + constructor() { + this.ws = null; + this.conversationId = null; + } + + connect() { + return new Promise((resolve, reject) => { + console.log('🔌 Conectando ao WebSocket...'); + + this.ws = new WebSocket(WS_URL); + + this.ws.on('open', () => { + console.log('✅ Conectado'); + resolve(); + }); + + this.ws.on('error', (error) => { + console.error('❌ Erro:', error.message); + reject(error); + }); + + this.ws.on('message', (data) => { + const msg = JSON.parse(data); + console.log('📨 Mensagem recebida:', msg); + + if (msg.type === 'init' && msg.conversationId) { + this.conversationId = msg.conversationId; + console.log(`🔑 Conversation ID: ${this.conversationId}`); + } + }); + }); + } + + async testMemoryOperations() { + console.log('\n=== Testando Operações de Memória ===\n'); + + // 1. Obter conversação atual + console.log('1. Obtendo conversação atual...'); + this.ws.send(JSON.stringify({ type: 'get-conversation' })); + await this.wait(1000); + + // 2. Listar conversações + console.log('\n2. Listando conversações...'); + this.ws.send(JSON.stringify({ type: 'list-conversations' })); + await this.wait(1000); + + // 3. Obter estatísticas + console.log('\n3. Obtendo estatísticas de memória...'); + this.ws.send(JSON.stringify({ type: 'get-stats' })); + await this.wait(1000); + + // 4. Simular mensagem de áudio + console.log('\n4. Simulando processamento de áudio...'); + const audioData = Buffer.alloc(1000); // Buffer vazio para teste + this.ws.send(JSON.stringify({ + type: 'audio', + data: audioData.toString('base64') + })); + await this.wait(2000); + + // 5. Verificar se mensagens foram armazenadas + console.log('\n5. Verificando mensagens armazenadas...'); + this.ws.send(JSON.stringify({ type: 'get-conversation' })); + await this.wait(1000); + } + + wait(ms) { + return new Promise(resolve => setTimeout(resolve, ms)); + } + + disconnect() { + if (this.ws) { + console.log('\n👋 Desconectando...'); + this.ws.close(); + } + } +} + +async function main() { + const tester = new MemoryTester(); + + try { + await tester.connect(); + await tester.wait(500); + await tester.testMemoryOperations(); + } catch (error) { + console.error('Erro fatal:', error); + } finally { + tester.disconnect(); + } +} + +console.log('╔═══════════════════════════════════════╗'); +console.log('║ Teste do Sistema de Memória ║'); +console.log('╚═══════════════════════════════════════╝\n'); + +main().catch(console.error); \ No newline at end of file diff --git a/services/webrtc_gateway/test-portuguese-audio.js b/services/webrtc_gateway/test-portuguese-audio.js new file mode 100644 index 0000000000000000000000000000000000000000..e9de52ac7fc5f98181a2227a0fbc27974cd1353c --- /dev/null +++ b/services/webrtc_gateway/test-portuguese-audio.js @@ -0,0 +1,410 @@ +#!/usr/bin/env node + +/** + * Teste com áudio real em português usando gTTS + * Gera perguntas faladas e verifica coerência das respostas + */ + +const WebSocket = require('ws'); +const fs = require('fs'); +const { exec, execSync } = require('child_process'); +const path = require('path'); +const util = require('util'); +const execPromise = util.promisify(exec); + +const WS_URL = 'ws://localhost:8082/ws'; + +// Cores para output +const colors = { + reset: '\x1b[0m', + bright: '\x1b[1m', + green: '\x1b[32m', + red: '\x1b[31m', + yellow: '\x1b[33m', + blue: '\x1b[34m', + cyan: '\x1b[36m', + magenta: '\x1b[35m' +}; + +class PortugueseAudioTester { + constructor() { + this.ws = null; + this.testResults = []; + this.currentTest = null; + this.responseBuffer = ''; + } + + async connect() { + return new Promise((resolve, reject) => { + console.log(`${colors.cyan}🔌 Conectando ao WebSocket...${colors.reset}`); + + this.ws = new WebSocket(WS_URL); + + this.ws.on('open', () => { + console.log(`${colors.green}✅ Conectado ao servidor${colors.reset}`); + resolve(); + }); + + this.ws.on('error', (error) => { + console.error(`${colors.red}❌ Erro:${colors.reset}`, error.message); + reject(error); + }); + + this.ws.on('message', (data) => { + this.handleMessage(data); + }); + }); + } + + handleMessage(data) { + // Verificar se é binário (áudio) ou JSON (mensagem) + if (Buffer.isBuffer(data)) { + console.log(`${colors.green}🔊 Áudio de resposta recebido: ${data.length} bytes${colors.reset}`); + if (this.currentTest) { + this.currentTest.audioReceived = true; + this.currentTest.audioSize = data.length; + } + return; + } + + try { + const msg = JSON.parse(data); + + switch (msg.type) { + case 'init': + case 'welcome': + console.log(`${colors.blue}🔑 Sessão iniciada: ${msg.clientId}${colors.reset}`); + break; + + case 'metrics': + console.log(`${colors.yellow}📝 Resposta do sistema: "${msg.response}"${colors.reset}`); + if (this.currentTest) { + this.currentTest.response = msg.response; + this.currentTest.latency = msg.latency; + this.responseBuffer = msg.response; + } + break; + + case 'response': + case 'transcription': + // Adicionar suporte para outros formatos de resposta + const text = msg.text || msg.response || msg.message; + if (text) { + console.log(`${colors.yellow}📝 Resposta: "${text}"${colors.reset}`); + if (this.currentTest) { + this.currentTest.response = text; + this.currentTest.latency = msg.latency || 0; + } + } + break; + + case 'error': + console.error(`${colors.red}❌ Erro: ${msg.message}${colors.reset}`); + break; + } + } catch (error) { + // Dados de texto simples + const text = data.toString(); + if (text.length > 0 && text.length < 200) { + console.log(`${colors.cyan}📨 Mensagem: ${text}${colors.reset}`); + } + } + } + + /** + * Gera áudio MP3 usando gTTS e converte para PCM + * @param {string} text - Texto em português para converter + * @param {string} outputFile - Nome do arquivo de saída + */ + async generatePortugueseAudio(text, outputFile) { + console.log(`${colors.magenta}🎤 Gerando áudio: "${text}"${colors.reset}`); + + const mp3File = outputFile.replace('.pcm', '.mp3'); + + try { + // Gerar MP3 com gTTS em português brasileiro + const gttsCommand = `gtts-cli "${text}" -l pt-br -o ${mp3File}`; + await execPromise(gttsCommand); + console.log(` ✅ MP3 gerado: ${mp3File}`); + + // Converter MP3 para PCM 16-bit @ 16kHz + const ffmpegCommand = `ffmpeg -i ${mp3File} -f s16le -acodec pcm_s16le -ar 16000 -ac 1 ${outputFile} -y`; + await execPromise(ffmpegCommand); + console.log(` ✅ PCM gerado: ${outputFile}`); + + // Limpar arquivo MP3 temporário + fs.unlinkSync(mp3File); + + // Ler arquivo PCM + const pcmBuffer = fs.readFileSync(outputFile); + console.log(` 📊 Tamanho PCM: ${pcmBuffer.length} bytes`); + + return pcmBuffer; + } catch (error) { + console.error(`${colors.red}❌ Erro gerando áudio: ${error.message}${colors.reset}`); + throw error; + } + } + + async sendPortugueseQuestion(question, expectedContext) { + console.log(`\n${colors.bright}=== Teste: ${question} ===${colors.reset}`); + + this.currentTest = { + question: question, + expectedContext: expectedContext, + startTime: Date.now(), + response: null, + audioReceived: false + }; + + try { + // Gerar áudio da pergunta + const audioFile = `/tmp/question_${Date.now()}.pcm`; + const pcmAudio = await this.generatePortugueseAudio(question, audioFile); + + // Enviar áudio PCM diretamente + console.log(`${colors.cyan}📤 Enviando áudio PCM: ${pcmAudio.length} bytes${colors.reset}`); + this.ws.send(pcmAudio); + + // Aguardar resposta + await this.waitForResponse(8000); + + // Limpar arquivo temporário + if (fs.existsSync(audioFile)) { + fs.unlinkSync(audioFile); + } + + // Avaliar resultado + this.evaluateTest(); + + } catch (error) { + console.error(`${colors.red}❌ Erro no teste: ${error.message}${colors.reset}`); + this.currentTest.error = error.message; + } + } + + waitForResponse(timeoutMs) { + return new Promise((resolve) => { + const startTime = Date.now(); + + const checkInterval = setInterval(() => { + const elapsed = Date.now() - startTime; + + // Verificar se recebemos resposta + if (this.currentTest.response || this.currentTest.audioReceived) { + clearInterval(checkInterval); + resolve(); + } else if (elapsed > timeoutMs) { + clearInterval(checkInterval); + console.log(`${colors.yellow}⏱️ Timeout aguardando resposta${colors.reset}`); + resolve(); + } + }, 100); + }); + } + + evaluateTest() { + const test = this.currentTest; + const responseTime = Date.now() - test.startTime; + + console.log(`\n${colors.bright}📊 Resultado do Teste:${colors.reset}`); + console.log(` Pergunta: "${test.question}"`); + console.log(` Tempo de resposta: ${responseTime}ms`); + console.log(` Resposta recebida: ${test.response ? '✅' : '❌'}`); + console.log(` Áudio recebido: ${test.audioReceived ? '✅' : '❌'}`); + + if (test.response) { + console.log(` Resposta: "${test.response}"`); + + // Verificar coerência + const response = test.response.toLowerCase(); + let isCoherent = false; + let coherenceReason = ''; + + // Verificar se a resposta contém palavras-chave esperadas + test.expectedContext.forEach(keyword => { + if (response.includes(keyword.toLowerCase())) { + isCoherent = true; + coherenceReason = `contém "${keyword}"`; + } + }); + + // Verificar se é uma resposta genérica válida + const validGenericResponses = [ + 'olá', 'oi', 'bom dia', 'boa tarde', 'boa noite', + 'ajudar', 'assistente', 'posso', 'como', + 'brasil', 'brasileiro', 'portuguesa', + 'você', 'seu', 'sua', 'nome', 'chamar' + ]; + + if (!isCoherent) { + validGenericResponses.forEach(word => { + if (response.includes(word)) { + isCoherent = true; + coherenceReason = `resposta válida com "${word}"`; + } + }); + } + + // Verificar se é uma resposta muito curta ou sem sentido + if (response.length < 5 || response.match(/^[0-9\s]+$/)) { + isCoherent = false; + coherenceReason = 'resposta muito curta ou inválida'; + } + + if (isCoherent) { + console.log(` ${colors.green}✅ Resposta COERENTE (${coherenceReason})${colors.reset}`); + } else { + console.log(` ${colors.red}❌ Resposta INCOERENTE (${coherenceReason})${colors.reset}`); + } + + test.isCoherent = isCoherent; + } else { + test.isCoherent = false; + } + + test.responseTime = responseTime; + test.passed = test.response && test.isCoherent; + + this.testResults.push(test); + } + + async runAllTests() { + console.log(`\n${colors.bright}${colors.cyan}🚀 Iniciando testes com áudio em português${colors.reset}\n`); + + // Teste 1: Saudação + await this.sendPortugueseQuestion( + "Olá, bom dia", + ['olá', 'oi', 'bom dia', 'prazer', 'ajudar'] + ); + await this.wait(2000); + + // Teste 2: Pergunta sobre nome + await this.sendPortugueseQuestion( + "Qual é o seu nome?", + ['nome', 'chamo', 'sou', 'assistente', 'ultravox'] + ); + await this.wait(2000); + + // Teste 3: Pergunta sobre Brasil + await this.sendPortugueseQuestion( + "Qual é a capital do Brasil?", + ['brasília', 'capital', 'brasil', 'distrito federal'] + ); + await this.wait(2000); + + // Teste 4: Pergunta sobre ajuda + await this.sendPortugueseQuestion( + "Você pode me ajudar?", + ['sim', 'posso', 'ajudar', 'claro', 'certamente', 'como'] + ); + await this.wait(2000); + + // Teste 5: Pergunta sobre o dia + await this.sendPortugueseQuestion( + "Como está o dia hoje?", + ['dia', 'hoje', 'tempo', 'clima', 'está'] + ); + + // Mostrar resumo + this.showSummary(); + } + + showSummary() { + console.log(`\n${colors.bright}${colors.cyan}📈 RESUMO DOS TESTES${colors.reset}`); + console.log('═'.repeat(70)); + + let passed = 0; + let failed = 0; + + this.testResults.forEach((test, index) => { + const status = test.passed ? + `${colors.green}✅ PASSOU${colors.reset}` : + `${colors.red}❌ FALHOU${colors.reset}`; + + console.log(`\n${index + 1}. "${test.question}": ${status}`); + console.log(` Tempo: ${test.responseTime}ms`); + console.log(` Coerente: ${test.isCoherent ? 'Sim' : 'Não'}`); + + if (test.response) { + const preview = test.response.substring(0, 100); + console.log(` Resposta: "${preview}${test.response.length > 100 ? '...' : ''}"`); + } + + if (test.passed) passed++; + else failed++; + }); + + console.log('\n' + '═'.repeat(70)); + console.log(`${colors.bright}Total: ${passed} passou, ${failed} falhou${colors.reset}`); + + const successRate = (passed / this.testResults.length * 100).toFixed(1); + const rateColor = successRate >= 80 ? colors.green : + successRate >= 50 ? colors.yellow : + colors.red; + + console.log(`${rateColor}Taxa de sucesso: ${successRate}%${colors.reset}\n`); + } + + wait(ms) { + return new Promise(resolve => setTimeout(resolve, ms)); + } + + disconnect() { + if (this.ws) { + console.log(`${colors.cyan}👋 Desconectando...${colors.reset}`); + this.ws.close(); + } + } +} + +// Verificar dependências +function checkDependencies() { + try { + // Verificar gTTS + execSync('which gtts-cli', { stdio: 'ignore' }); + console.log(`${colors.green}✅ gTTS instalado${colors.reset}`); + } catch { + console.error(`${colors.red}❌ gTTS não instalado!${colors.reset}`); + console.log(`${colors.yellow}Instale com: pip install gtts${colors.reset}`); + process.exit(1); + } + + try { + // Verificar ffmpeg + execSync('which ffmpeg', { stdio: 'ignore' }); + console.log(`${colors.green}✅ ffmpeg instalado${colors.reset}`); + } catch { + console.error(`${colors.red}❌ ffmpeg não instalado!${colors.reset}`); + console.log(`${colors.yellow}Instale com: sudo apt install ffmpeg${colors.reset}`); + process.exit(1); + } +} + +// Executar testes +async function main() { + console.log(`${colors.bright}${colors.blue}╔═══════════════════════════════════════════════╗${colors.reset}`); + console.log(`${colors.bright}${colors.blue}║ Teste Ultravox - Áudio Português (gTTS) ║${colors.reset}`); + console.log(`${colors.bright}${colors.blue}╚═══════════════════════════════════════════════╝${colors.reset}\n`); + + // Verificar dependências + checkDependencies(); + console.log(''); + + const tester = new PortugueseAudioTester(); + + try { + await tester.connect(); + await tester.wait(500); + await tester.runAllTests(); + await tester.wait(2000); // Aguardar últimas respostas + } catch (error) { + console.error(`${colors.red}Erro fatal:${colors.reset}`, error); + } finally { + tester.disconnect(); + process.exit(0); + } +} + +// Iniciar +main().catch(console.error); \ No newline at end of file diff --git a/services/webrtc_gateway/test-websocket-speech.js b/services/webrtc_gateway/test-websocket-speech.js new file mode 100755 index 0000000000000000000000000000000000000000..8cb30bb23836b3e9cc1611094d6b57cb46b5050b --- /dev/null +++ b/services/webrtc_gateway/test-websocket-speech.js @@ -0,0 +1,184 @@ +#!/usr/bin/env node +/** + * Teste automatizado de Speech-to-Speech via WebSocket + * Simula exatamente o que a página web deveria fazer + */ + +const WebSocket = require('ws'); +const fs = require('fs'); +const path = require('path'); +const { spawn } = require('child_process'); + +// Configuração +const WS_URL = 'ws://localhost:8082/ws'; +const TEST_AUDIO_TEXT = "Quanto é dois mais dois?"; + +// Função para gerar áudio de teste usando gtts-cli +async function generateTestAudio(text) { + return new Promise((resolve, reject) => { + const tempFile = `/tmp/test_audio_${Date.now()}.mp3`; + const wavFile = `/tmp/test_audio_${Date.now()}.wav`; + + console.log(`🎤 Gerando áudio de teste: "${text}"`); + + // Gerar MP3 com gTTS + const gtts = spawn('gtts-cli', [text, '--lang', 'pt-br', '--output', tempFile]); + + gtts.on('close', (code) => { + if (code !== 0) { + reject(new Error(`gTTS falhou com código ${code}`)); + return; + } + + // Converter MP3 para WAV PCM 16-bit @ 16kHz + const ffmpeg = spawn('ffmpeg', [ + '-i', tempFile, + '-ar', '16000', // 16kHz + '-ac', '1', // Mono + '-c:a', 'pcm_s16le', // PCM 16-bit + wavFile, + '-y' + ]); + + ffmpeg.on('close', (code) => { + if (code !== 0) { + reject(new Error(`ffmpeg falhou com código ${code}`)); + return; + } + + // Ler o arquivo WAV + const audioBuffer = fs.readFileSync(wavFile); + + // Remover header WAV (44 bytes) + const pcmData = audioBuffer.slice(44); + + // Converter PCM int16 para Float32 + const pcmInt16 = new Int16Array(pcmData.buffer, pcmData.byteOffset, pcmData.length / 2); + const pcmFloat32 = new Float32Array(pcmInt16.length); + + for (let i = 0; i < pcmInt16.length; i++) { + pcmFloat32[i] = pcmInt16[i] / 32768.0; // Normalizar para -1.0 a 1.0 + } + + // Limpar arquivos temporários + fs.unlinkSync(tempFile); + fs.unlinkSync(wavFile); + + console.log(`✅ Áudio gerado: ${pcmFloat32.length} amostras Float32`); + resolve(Buffer.from(pcmFloat32.buffer)); + }); + }); + }); +} + +// Função principal do teste +async function testSpeechToSpeech() { + console.log('='.repeat(60)); + console.log('🚀 TESTE AUTOMATIZADO SPEECH-TO-SPEECH VIA WEBSOCKET'); + console.log('='.repeat(60)); + + try { + // Gerar áudio de teste + const audioBuffer = await generateTestAudio(TEST_AUDIO_TEXT); + + // Conectar ao WebSocket + console.log(`\n📡 Conectando ao servidor: ${WS_URL}`); + const ws = new WebSocket(WS_URL); + + return new Promise((resolve, reject) => { + let responseReceived = false; + let audioChunks = []; + + ws.on('open', () => { + console.log('✅ Conectado ao servidor WebSocket'); + + // Enviar mensagem de tipo 'audio' + const message = { + type: 'audio', + data: audioBuffer.toString('base64'), + format: 'float32', + sampleRate: 16000, + sessionId: `test_${Date.now()}` + }; + + console.log(`📤 Enviando áudio: ${audioBuffer.length} bytes`); + ws.send(JSON.stringify(message)); + }); + + ws.on('message', (data) => { + try { + const message = JSON.parse(data); + + if (message.type === 'transcription') { + console.log(`📝 Transcrição recebida: "${message.text}"`); + responseReceived = true; + } else if (message.type === 'audio') { + // Áudio de resposta do TTS + const audioData = Buffer.from(message.data, 'base64'); + audioChunks.push(audioData); + console.log(`🔊 Chunk de áudio recebido: ${audioData.length} bytes`); + + if (message.isFinal) { + console.log('✅ Áudio completo recebido'); + + // Salvar áudio para verificação (opcional) + const outputFile = '/tmp/response_audio.pcm'; + const fullAudio = Buffer.concat(audioChunks); + fs.writeFileSync(outputFile, fullAudio); + console.log(`💾 Áudio salvo em: ${outputFile}`); + + ws.close(); + resolve(); + } + } else if (message.type === 'error') { + console.error(`❌ Erro do servidor: ${message.message}`); + ws.close(); + reject(new Error(message.message)); + } + } catch (error) { + console.error('❌ Erro ao processar mensagem:', error); + } + }); + + ws.on('error', (error) => { + console.error('❌ Erro WebSocket:', error); + reject(error); + }); + + ws.on('close', () => { + console.log('🔌 Conexão fechada'); + if (!responseReceived) { + reject(new Error('Conexão fechada sem receber resposta')); + } + }); + + // Timeout + setTimeout(() => { + if (ws.readyState === WebSocket.OPEN) { + console.log('⏱️ Timeout - fechando conexão'); + ws.close(); + reject(new Error('Timeout na resposta')); + } + }, 30000); + }); + + } catch (error) { + console.error('❌ Erro no teste:', error); + throw error; + } +} + +// Executar teste +testSpeechToSpeech() + .then(() => { + console.log('\n' + '='.repeat(60)); + console.log('✅ TESTE CONCLUÍDO COM SUCESSO!'); + console.log('='.repeat(60)); + process.exit(0); + }) + .catch((error) => { + console.error('\n' + '='.repeat(60)); + console.error('❌ TESTE FALHOU:', error.message); + console.error('='.repeat(60)); + process.exit(1); + }); \ No newline at end of file diff --git a/services/webrtc_gateway/test-websocket.js b/services/webrtc_gateway/test-websocket.js new file mode 100755 index 0000000000000000000000000000000000000000..ccc7d97aec3aa5369ec92e1ed4586bf30d8b0ebe --- /dev/null +++ b/services/webrtc_gateway/test-websocket.js @@ -0,0 +1,317 @@ +#!/usr/bin/env node + +/** + * Teste automatizado de WebSocket para validar respostas do Ultravox + * Simula conexões WebRTC e envia áudio de teste + */ + +const WebSocket = require('ws'); +const fs = require('fs'); +const path = require('path'); + +// Configuração +const WS_URL = 'ws://localhost:8082/ws'; +const SAMPLE_RATE = 16000; +const BITS_PER_SAMPLE = 16; +const CHANNELS = 1; + +// Cores para output +const colors = { + reset: '\x1b[0m', + bright: '\x1b[1m', + green: '\x1b[32m', + red: '\x1b[31m', + yellow: '\x1b[33m', + blue: '\x1b[34m', + cyan: '\x1b[36m' +}; + +// Função para gerar áudio de teste (silêncio com alguns pulsos) +function generateTestAudio(durationMs = 1000) { + const samples = Math.floor((SAMPLE_RATE * durationMs) / 1000); + const buffer = Buffer.alloc(samples * 2); // 16-bit = 2 bytes per sample + + // Adicionar alguns pulsos para simular fala + for (let i = 0; i < samples; i++) { + let value = 0; + + // Criar padrão de "fala" simulada + if (i % 100 < 50) { + value = Math.sin(2 * Math.PI * 440 * i / SAMPLE_RATE) * 1000; + value += Math.sin(2 * Math.PI * 880 * i / SAMPLE_RATE) * 500; + value += (Math.random() - 0.5) * 200; // Adicionar ruído + } + + // Converter para int16 + const int16Value = Math.max(-32768, Math.min(32767, Math.floor(value))); + buffer.writeInt16LE(int16Value, i * 2); + } + + return buffer; +} + +// Classe de teste +class WebSocketTester { + constructor() { + this.ws = null; + this.testResults = []; + this.currentTest = null; + } + + connect() { + return new Promise((resolve, reject) => { + console.log(`${colors.cyan}🔌 Conectando ao WebSocket...${colors.reset}`); + + this.ws = new WebSocket(WS_URL); + + this.ws.on('open', () => { + console.log(`${colors.green}✅ Conectado ao servidor${colors.reset}`); + resolve(); + }); + + this.ws.on('error', (error) => { + console.error(`${colors.red}❌ Erro de conexão:${colors.reset}`, error.message); + reject(error); + }); + + this.ws.on('message', (data) => { + this.handleMessage(data); + }); + }); + } + + handleMessage(data) { + // Verificar se é binário (áudio) ou JSON (mensagem) + if (Buffer.isBuffer(data)) { + console.log(`${colors.green}🔊 Áudio binário recebido: ${data.length} bytes${colors.reset}`); + if (this.currentTest) { + this.currentTest.audioReceived = true; + this.currentTest.audioSize = data.length; + // Assumir que o áudio contém a resposta + this.currentTest.transcription = '[Resposta de áudio recebida]'; + } + return; + } + + try { + const msg = JSON.parse(data); + + switch (msg.type) { + case 'init': + case 'welcome': + console.log(`${colors.blue}👋 Cliente ID: ${msg.clientId}${colors.reset}`); + break; + + case 'metrics': + console.log(`${colors.yellow}📝 Resposta: "${msg.response}"${colors.reset}`); + if (this.currentTest) { + this.currentTest.transcription = msg.response; + this.currentTest.latency = msg.latency; + } + break; + + case 'transcription': + console.log(`${colors.yellow}📝 Transcrição: "${msg.text}"${colors.reset}`); + if (this.currentTest) { + this.currentTest.transcription = msg.text; + this.currentTest.latency = msg.latency; + } + break; + + case 'audio': + console.log(`${colors.green}🔊 Áudio recebido: ${msg.size} bytes${colors.reset}`); + if (this.currentTest) { + this.currentTest.audioReceived = true; + this.currentTest.audioSize = msg.size; + } + break; + + case 'error': + console.error(`${colors.red}❌ Erro do servidor: ${msg.message}${colors.reset}`); + if (this.currentTest) { + this.currentTest.error = msg.message; + } + break; + } + } catch (error) { + console.log(`${colors.cyan}📨 Dados recebidos: ${data.toString().substring(0, 100)}...${colors.reset}`); + } + } + + async sendAudioTest(testName, systemPrompt = '') { + console.log(`\n${colors.bright}=== Teste: ${testName} ===${colors.reset}`); + + this.currentTest = { + name: testName, + systemPrompt: systemPrompt, + startTime: Date.now(), + transcription: null, + audioReceived: false + }; + + // Enviar áudio de teste + const audioData = generateTestAudio(1500); // 1.5 segundos + + console.log(`${colors.cyan}📤 Enviando áudio PCM direto: ${audioData.length} bytes${colors.reset}`); + + // Enviar dados binários PCM diretamente (como o navegador faz) + this.ws.send(audioData); + + // Aguardar resposta + await this.waitForResponse(5000); + + // Avaliar resultado + this.evaluateTest(); + } + + waitForResponse(timeoutMs) { + return new Promise((resolve) => { + const startTime = Date.now(); + + const checkInterval = setInterval(() => { + const elapsed = Date.now() - startTime; + + // Verificar se recebemos resposta completa + if (this.currentTest.transcription && this.currentTest.audioReceived) { + clearInterval(checkInterval); + resolve(); + } else if (elapsed > timeoutMs) { + clearInterval(checkInterval); + console.log(`${colors.yellow}⏱️ Timeout aguardando resposta${colors.reset}`); + resolve(); + } + }, 100); + }); + } + + evaluateTest() { + const test = this.currentTest; + const responseTime = Date.now() - test.startTime; + + console.log(`\n${colors.bright}📊 Resultado do Teste:${colors.reset}`); + console.log(` Tempo de resposta: ${responseTime}ms`); + console.log(` Transcrição recebida: ${test.transcription ? '✅' : '❌'}`); + console.log(` Áudio recebido: ${test.audioReceived ? '✅' : '❌'}`); + + // Verificar coerência da resposta + let isCoherent = false; + if (test.transcription) { + // Verificar se não contém "Brasília" ou respostas aleatórias + const problematicPhrases = [ + 'capital do brasil', + 'brasília', + 'cidade mais populosa', + 'região centro-oeste', + 'rio de janeiro', + 'são paulo' + ]; + + const lowerTranscription = test.transcription.toLowerCase(); + const hasProblematicContent = problematicPhrases.some(phrase => + lowerTranscription.includes(phrase) + ); + + if (hasProblematicContent) { + console.log(` ${colors.red}⚠️ Resposta contém conteúdo problemático${colors.reset}`); + isCoherent = false; + } else { + console.log(` ${colors.green}✅ Resposta parece coerente${colors.reset}`); + isCoherent = true; + } + } + + test.responseTime = responseTime; + test.isCoherent = isCoherent; + test.passed = test.transcription && test.audioReceived && isCoherent; + + this.testResults.push(test); + } + + async runAllTests() { + console.log(`\n${colors.bright}${colors.cyan}🚀 Iniciando bateria de testes${colors.reset}\n`); + + // Teste 1: Sem prompt de sistema + await this.sendAudioTest('Sem prompt de sistema', ''); + await this.wait(1000); + + // Teste 2: Com prompt simples + await this.sendAudioTest('Prompt simples', 'Você é um assistente útil'); + await this.wait(1000); + + // Teste 3: Prompt vazio explícito + await this.sendAudioTest('Prompt vazio explícito', ''); + await this.wait(1000); + + // Mostrar resumo + this.showSummary(); + } + + showSummary() { + console.log(`\n${colors.bright}${colors.cyan}📈 RESUMO DOS TESTES${colors.reset}`); + console.log('═'.repeat(60)); + + let passed = 0; + let failed = 0; + + this.testResults.forEach((test, index) => { + const status = test.passed ? + `${colors.green}✅ PASSOU${colors.reset}` : + `${colors.red}❌ FALHOU${colors.reset}`; + + console.log(`\n${index + 1}. ${test.name}: ${status}`); + console.log(` Tempo: ${test.responseTime}ms`); + console.log(` Coerente: ${test.isCoherent ? 'Sim' : 'Não'}`); + + if (test.transcription) { + console.log(` Resposta: "${test.transcription.substring(0, 80)}..."`); + } + + if (test.passed) passed++; + else failed++; + }); + + console.log('\n' + '═'.repeat(60)); + console.log(`${colors.bright}Total: ${passed} passou, ${failed} falhou${colors.reset}`); + + const successRate = (passed / this.testResults.length * 100).toFixed(1); + const rateColor = successRate >= 80 ? colors.green : + successRate >= 50 ? colors.yellow : + colors.red; + + console.log(`${rateColor}Taxa de sucesso: ${successRate}%${colors.reset}\n`); + } + + wait(ms) { + return new Promise(resolve => setTimeout(resolve, ms)); + } + + disconnect() { + if (this.ws) { + console.log(`${colors.cyan}👋 Desconectando...${colors.reset}`); + this.ws.close(); + } + } +} + +// Executar testes +async function main() { + const tester = new WebSocketTester(); + + try { + await tester.connect(); + await tester.wait(500); // Dar tempo para estabilizar + await tester.runAllTests(); + } catch (error) { + console.error(`${colors.red}Erro fatal:${colors.reset}`, error); + } finally { + tester.disconnect(); + process.exit(0); + } +} + +// Iniciar +console.log(`${colors.bright}${colors.blue}╔═══════════════════════════════════════╗${colors.reset}`); +console.log(`${colors.bright}${colors.blue}║ Teste WebSocket - Ultravox Chat ║${colors.reset}`); +console.log(`${colors.bright}${colors.blue}╚═══════════════════════════════════════╝${colors.reset}\n`); + +main().catch(console.error); \ No newline at end of file diff --git a/services/webrtc_gateway/ultravox-chat-backup.html b/services/webrtc_gateway/ultravox-chat-backup.html new file mode 100644 index 0000000000000000000000000000000000000000..68652be3c9780fb9d983203c9971ddffd00d2aaa --- /dev/null +++ b/services/webrtc_gateway/ultravox-chat-backup.html @@ -0,0 +1,964 @@ + + + + + + Ultravox Chat PCM - Otimizado + + + + +
+

🚀 Ultravox PCM - Otimizado

+ +
+
+ + Desconectado +
+ Latência: --ms +
+ +
+ + +
+ +
+ + +
+ +
+
+
Enviado
+
0 KB
+
+
+
Recebido
+
0 KB
+
+
+
Formato
+
PCM
+
+
+
🎤 Voz
+
pf_dora
+
+
+ +
+
+ + +
+

🎵 Text-to-Speech Direto

+

Digite ou edite o texto abaixo e escolha uma voz para converter em áudio

+ +
+ +
+ +
+ + + + +
+ + + + +
+ + + + \ No newline at end of file diff --git a/services/webrtc_gateway/ultravox-chat-ios.html b/services/webrtc_gateway/ultravox-chat-ios.html new file mode 100644 index 0000000000000000000000000000000000000000..b95806d2fc02c6ea57ccf3364bc22d1e11b338bc --- /dev/null +++ b/services/webrtc_gateway/ultravox-chat-ios.html @@ -0,0 +1,1843 @@ + + + + + + + + Ultravox AI Assistant + + + + + + + + + + + + +
+
+ Refreshing... +
+ +
+ + + + +
+ + +
+ +
+ + +

Push to Talk

+ +
+ + Disconnected +
+
+ + +
+
+ +
+ +
+ info +

Connect to start using voice assistant

+

Click the connect button below to begin

+
+ + +
+ +
+ + +
+ + + +
+ + +
+
+
+
0
+
KB Sent
+
+
+
+
0
+
KB Received
+
+
+
+
--
+
MS Latency
+
+
+
+ + +
+
+

📝 Conversation History

+ +
+
+
+

No messages yet. Connect and start talking!

+
+
+
+ + +
+
+

Ready to connect

+
+
+ + + + + + +
+
+
+ + +
+
+

Text to Speech

+ +
+ +
+ +
+ +
+ + + +
+ + +
+
+ + +
+
+

Console Output

+
+ +
+ + +
+
+
+ + +
+
+

Voice Settings

+ +
+
Default Voice
+ +
+ +
+
Audio Settings
+
+ Auto-play responses +
+
+
+ Echo cancellation +
+
+
+ Noise suppression +
+
+
+
+ +
+

About

+

+ Ultravox AI Assistant v1.0
+ Powered by advanced speech recognition and synthesis.
+
+ Format: PCM 16-bit @ 24kHz
+ Protocol: WebSocket + gRPC +

+
+
+
+
+ + + + + + + \ No newline at end of file diff --git a/services/webrtc_gateway/ultravox-chat-material.html b/services/webrtc_gateway/ultravox-chat-material.html new file mode 100644 index 0000000000000000000000000000000000000000..afa130eb925ef3f2068eb757c0761b7a5d56adbb --- /dev/null +++ b/services/webrtc_gateway/ultravox-chat-material.html @@ -0,0 +1,1116 @@ + + + + + + Ultravox Chat PCM - Material Design + + + + + + + + + + + + +
+ +
+

+ rocket_launch + Ultravox PCM - Otimizado +

+ + +
+ + Desconectado + Latência: --ms +
+ + +
+
+ +
+
    +
  • + + 🇧🇷 [pf_dora] Português Feminino (Dora) +
  • +
  • + + 🇧🇷 [pm_alex] Português Masculino (Alex) +
  • +
  • + + 🌍 [af_heart] Alternativa Feminina (Heart) +
  • +
  • + + 🌍 [af_bella] Alternativa Feminina (Bella) +
  • +
+
+
+ + +
+ + +
+ + + +
+ + +
+
+
Enviado
+
0 KB
+
+
+
Recebido
+
0 KB
+
+
+
Formato
+
PCM
+
+
+
🎤 Voz
+
pf_dora
+
+
+ + +
+
+ + +
+

+ record_voice_over + Text-to-Speech Direto +

+

Digite ou edite o texto abaixo e escolha uma voz para converter em áudio

+ + + + + +
+
+ +
+
    + + +
  • + [pf_dora] Feminino - Dora +
  • +
  • + [pm_alex] Masculino - Alex +
  • +
  • + [pm_santa] Masculino - Santa +
  • + + +
  • + Feminino - Alloy +
  • +
  • + Feminino - Bella +
  • +
  • + Feminino - Heart +
  • +
  • + Masculino - Adam +
  • +
  • + Masculino - Echo +
  • +
+
+
+ + + + + +
+ + + + + + +
+
+ + + + + + + + \ No newline at end of file diff --git a/services/webrtc_gateway/ultravox-chat-opus.html b/services/webrtc_gateway/ultravox-chat-opus.html new file mode 100644 index 0000000000000000000000000000000000000000..20a53f44244604c831fdf60df8052acc05b7e3f2 --- /dev/null +++ b/services/webrtc_gateway/ultravox-chat-opus.html @@ -0,0 +1,581 @@ + + + + + + Ultravox Chat - Opus Edition + + + + +
+
+
+
+
+

+ 🎙️ Ultravox Chat - WebRTC Pipeline + OPUS +

+ Gravação e envio em Opus codec (compressão eficiente) +
+
+
+
+ + Desconectado +
+ +
+ +
+ +
Segure para falar
+
+ +
+ + +
+ +
+ +
+
+
+
+
+ +
+
+
📊 Métricas
+
+ Codec: + Opus +
+
+ Bitrate: + 32 kbps +
+
+ Taxa de Compressão: + - +
+
+ Latência Total: + - +
+
+ Tempo de Gravação: + - +
+
+ Taxa de Áudio: + 48 kHz +
+
+ Tamanho do Áudio: + - +
+
+ +
+
+
🐛 Debug Log
+
+
+
+
+
+
+ + + + \ No newline at end of file diff --git a/services/webrtc_gateway/ultravox-chat-original.html b/services/webrtc_gateway/ultravox-chat-original.html new file mode 100644 index 0000000000000000000000000000000000000000..68652be3c9780fb9d983203c9971ddffd00d2aaa --- /dev/null +++ b/services/webrtc_gateway/ultravox-chat-original.html @@ -0,0 +1,964 @@ + + + + + + Ultravox Chat PCM - Otimizado + + + + +
+

🚀 Ultravox PCM - Otimizado

+ +
+
+ + Desconectado +
+ Latência: --ms +
+ +
+ + +
+ +
+ + +
+ +
+
+
Enviado
+
0 KB
+
+
+
Recebido
+
0 KB
+
+
+
Formato
+
PCM
+
+
+
🎤 Voz
+
pf_dora
+
+
+ +
+
+ + +
+

🎵 Text-to-Speech Direto

+

Digite ou edite o texto abaixo e escolha uma voz para converter em áudio

+ +
+ +
+ +
+ + + + +
+ + + + +
+ + + + \ No newline at end of file diff --git a/services/webrtc_gateway/ultravox-chat-server.js b/services/webrtc_gateway/ultravox-chat-server.js index 65b1f9f4f14bfd4eb7e96c1be8825c0a9e2156b0..86a5919cb6c5083e4c51605bc9c7be9545075231 100644 --- a/services/webrtc_gateway/ultravox-chat-server.js +++ b/services/webrtc_gateway/ultravox-chat-server.js @@ -317,6 +317,22 @@ function handleMessage(clientId, data) { handleAudioData(clientId, data.audio); break; + case 'audio': + // Processar áudio enviado em formato JSON (como no teste) + if (data.data && data.format) { + const audioBuffer = Buffer.from(data.data, 'base64'); + console.log(`🎤 Received audio JSON: ${audioBuffer.length} bytes, format: ${data.format}`); + + if (data.format === 'float32') { + // Áudio já está em Float32, processar diretamente sem conversão + handleFloat32Audio(clientId, audioBuffer); + } else { + // Processar como PCM int16 + handlePCMData(clientId, audioBuffer); + } + } + break; + case 'broadcast': handleBroadcast(clientId, data.message); break; @@ -561,27 +577,146 @@ const pcmBuffers = new Map(); function handleBinaryMessage(clientId, buffer) { // Verificar se é header ou dados if (buffer.length === 8) { - // Header PCM + // Header PCM ou Opus const view = new DataView(buffer.buffer, buffer.byteOffset, buffer.length); const magic = view.getUint32(0); const size = view.getUint32(4); if (magic === 0x50434D16) { // "PCM16" console.log(`🎤 PCM header: ${size} bytes esperados`); - pcmBuffers.set(clientId, { expectedSize: size, data: Buffer.alloc(0) }); + pcmBuffers.set(clientId, { expectedSize: size, data: Buffer.alloc(0), type: 'pcm' }); + } else if (magic === 0x4F505553) { // "OPUS" + console.log(`🎵 Opus header: ${size} bytes esperados`); + pcmBuffers.set(clientId, { expectedSize: size, data: Buffer.alloc(0), type: 'opus' }); } } else { - // Processar PCM diretamente (com ou sem header prévio) - console.log(`🎵 Processando PCM direto: ${buffer.length} bytes`); - handlePCMData(clientId, buffer); + // Verificar se temos um buffer esperando dados + const bufferInfo = pcmBuffers.get(clientId); - // Limpar buffer info se existir - if (pcmBuffers.has(clientId)) { - pcmBuffers.delete(clientId); + if (bufferInfo) { + // Adicionar dados ao buffer + bufferInfo.data = Buffer.concat([bufferInfo.data, buffer]); + console.log(`📦 Buffer acumulado: ${bufferInfo.data.length}/${bufferInfo.expectedSize} bytes`); + + // Se recebemos todos os dados esperados + if (bufferInfo.data.length >= bufferInfo.expectedSize) { + if (bufferInfo.type === 'opus') { + console.log(`🎵 Processando Opus: ${bufferInfo.data.length} bytes`); + handleOpusData(clientId, bufferInfo.data); + } else { + console.log(`🎤 Processando PCM: ${bufferInfo.data.length} bytes`); + handlePCMData(clientId, bufferInfo.data); + } + pcmBuffers.delete(clientId); + } + } else { + // Processar PCM diretamente (sem header) + console.log(`🎵 Processando PCM direto: ${buffer.length} bytes`); + handlePCMData(clientId, buffer); } } } +// Processar dados Opus +async function handleOpusData(clientId, opusBuffer) { + try { + // Descomprimir Opus para PCM + const pcmBuffer = decompressOpusToPCM(opusBuffer); + console.log(`🎵 Opus descomprimido: ${opusBuffer.length} bytes -> ${pcmBuffer.length} bytes PCM`); + + // Processar como PCM + await handlePCMData(clientId, pcmBuffer); + } catch (error) { + console.error(`❌ Erro ao processar Opus: ${error.message}`); + } +} + +// Processar áudio que já está em Float32 +async function handleFloat32Audio(clientId, float32Buffer) { + const client = clients.get(clientId); + const session = sessions.get(clientId); + + if (!client || !session) return; + + if (client.isProcessing) { + console.log('⚠️ Já processando áudio, ignorando...'); + return; + } + + client.isProcessing = true; + const startTime = Date.now(); + + try { + console.log(`\n🎤 FLOAT32 AUDIO RECEBIDO [${clientId}]`); + console.log(` Tamanho: ${float32Buffer.length} bytes`); + console.log(` Formato: Float32 normalizado`); + + // Áudio já está em Float32, apenas passar adiante + console.log(` 📊 Áudio Float32 pronto: ${float32Buffer.length} bytes`); + + // Processar com Ultravox + const response = await processWithUltravox(clientId, float32Buffer, session); + console.log(` 📝 Resposta: "${response}"`); + + // Armazenar na memória de conversação + const conversationId = client.conversationId; + if (conversationId) { + conversationMemory.addMessage(conversationId, { + role: 'user', + content: '[Áudio processado]', + audioSize: float32Buffer.length, + timestamp: startTime + }); + + conversationMemory.addMessage(conversationId, { + role: 'assistant', + content: response, + latency: Date.now() - startTime + }); + } + + // Enviar transcrição primeiro + client.ws.send(JSON.stringify({ + type: 'transcription', + text: response, + timestamp: Date.now() + })); + + // Sintetizar áudio com TTS + const ttsResult = await synthesizeWithTTS(clientId, response, session); + const responseAudio = ttsResult.audioData; + console.log(` 🔊 Áudio sintetizado: ${responseAudio.length} bytes @ ${ttsResult.sampleRate}Hz`); + + // Enviar áudio como JSON + client.ws.send(JSON.stringify({ + type: 'audio', + data: responseAudio.toString('base64'), + format: 'pcm', + sampleRate: ttsResult.sampleRate || 16000, + isFinal: true + })); + + const totalLatency = Date.now() - startTime; + console.log(`⏱️ Latência total: ${totalLatency}ms`); + + // Enviar métricas + client.ws.send(JSON.stringify({ + type: 'metrics', + latency: totalLatency, + response: response + })); + + } catch (error) { + console.error('❌ Erro ao processar áudio Float32:', error); + client.ws.send(JSON.stringify({ + type: 'error', + message: error.message + })); + } finally { + client.isProcessing = false; + } +} + // Processar dados PCM direto (sem conversão!) async function handlePCMData(clientId, pcmBuffer) { const client = clients.get(clientId); @@ -655,13 +790,26 @@ async function handlePCMData(clientId, pcmBuffer) { }); } + // Enviar transcrição primeiro + client.ws.send(JSON.stringify({ + type: 'transcription', + text: response, + timestamp: Date.now() + })); + // Sintetizar áudio com TTS const ttsResult = await synthesizeWithTTS(clientId, response, session); const responseAudio = ttsResult.audioData; console.log(` 🔊 Áudio sintetizado: ${responseAudio.length} bytes @ ${ttsResult.sampleRate}Hz`); - // Enviar PCM direto (sem conversão para WebM!) - client.ws.send(responseAudio); + // Enviar áudio como JSON + client.ws.send(JSON.stringify({ + type: 'audio', + data: responseAudio.toString('base64'), + format: 'pcm', + sampleRate: ttsResult.sampleRate || 16000, + isFinal: true + })); const totalLatency = Date.now() - startTime; console.log(`⏱️ Latência total: ${totalLatency}ms`); diff --git a/services/webrtc_gateway/ultravox-chat-tailwind.html b/services/webrtc_gateway/ultravox-chat-tailwind.html new file mode 100644 index 0000000000000000000000000000000000000000..399283dc5922d91cb852b03d578a8d02dea858ba --- /dev/null +++ b/services/webrtc_gateway/ultravox-chat-tailwind.html @@ -0,0 +1,393 @@ + + + + + + Ultravox Chat - Real-time Voice Assistant + + + + + +
+ +
+

+ Ultravox Chat +

+

Real-time Voice Assistant

+
+ + +
+
+ Connection Status + + Disconnected + +
+ + +
+
+ + +
+
+
+ + +
+ + + + + +
+ + +
+

+ + + + Activity Log +

+
+
Waiting for connection...
+
+
+ + +
+ + Debug Information + +
+
Sample Rate: 24000 Hz
+
Buffer Size: 4096
+
Latency: --
+
+
+
+ + + + \ No newline at end of file diff --git a/services/webrtc_gateway/ultravox-chat.html b/services/webrtc_gateway/ultravox-chat.html new file mode 100644 index 0000000000000000000000000000000000000000..68652be3c9780fb9d983203c9971ddffd00d2aaa --- /dev/null +++ b/services/webrtc_gateway/ultravox-chat.html @@ -0,0 +1,964 @@ + + + + + + Ultravox Chat PCM - Otimizado + + + + +
+

🚀 Ultravox PCM - Otimizado

+ +
+
+ + Desconectado +
+ Latência: --ms +
+ +
+ + +
+ +
+ + +
+ +
+
+
Enviado
+
0 KB
+
+
+
Recebido
+
0 KB
+
+
+
Formato
+
PCM
+
+
+
🎤 Voz
+
pf_dora
+
+
+ +
+
+ + +
+

🎵 Text-to-Speech Direto

+

Digite ou edite o texto abaixo e escolha uma voz para converter em áudio

+ +
+ +
+ +
+ + + + +
+ + + + +
+ + + + \ No newline at end of file diff --git a/services/webrtc_gateway/webrtc.pid b/services/webrtc_gateway/webrtc.pid new file mode 100644 index 0000000000000000000000000000000000000000..50f2d183613dbcf7bfeb6659e1f165b0fcc29d30 --- /dev/null +++ b/services/webrtc_gateway/webrtc.pid @@ -0,0 +1 @@ +5415 diff --git a/test-24khz-support.html b/test-24khz-support.html new file mode 100644 index 0000000000000000000000000000000000000000..78562427b3e7dd0f1b42b09245099e4b3c3a7de8 --- /dev/null +++ b/test-24khz-support.html @@ -0,0 +1,243 @@ + + + + + Teste: Suporte 24kHz vs 16kHz no Navegador + + + +

🎵 Teste de Qualidade: 24kHz vs 16kHz

+ +
+

📊 Capacidades do Navegador

+
+
+ +
+

🎤 Teste de Reprodução

+ + + + +
+
+ +
+

📈 Análise de Banda

+
+
+ +
+

💡 Recomendação

+
+
+ + + + \ No newline at end of file diff --git a/test-audio-cli.js b/test-audio-cli.js new file mode 100644 index 0000000000000000000000000000000000000000..94ef3ee5aeb224fe6e00b35f9385f54a3d2cbdb7 --- /dev/null +++ b/test-audio-cli.js @@ -0,0 +1,178 @@ +#!/usr/bin/env node + +/** + * Teste CLI para simular envio de áudio PCM ao servidor + * Similar ao que o navegador faz, mas via linha de comando + */ + +const WebSocket = require('ws'); +const fs = require('fs'); +const path = require('path'); + +const WS_URL = 'ws://localhost:8082/ws'; + +class AudioTester { + constructor() { + this.ws = null; + this.conversationId = null; + this.clientId = null; + } + + connect() { + return new Promise((resolve, reject) => { + console.log('🔌 Conectando ao WebSocket...'); + + this.ws = new WebSocket(WS_URL); + + this.ws.on('open', () => { + console.log('✅ Conectado ao servidor'); + resolve(); + }); + + this.ws.on('error', (error) => { + console.error('❌ Erro:', error.message); + reject(error); + }); + + this.ws.on('message', (data) => { + // Verificar se é binário (áudio) ou JSON (mensagem) + if (data instanceof Buffer) { + console.log(`🔊 Áudio recebido: ${(data.length / 1024).toFixed(1)}KB`); + // Salvar áudio para análise + const filename = `response_${Date.now()}.pcm`; + fs.writeFileSync(filename, data); + console.log(` Salvo como: ${filename}`); + } else { + try { + const msg = JSON.parse(data); + console.log('📨 Mensagem recebida:', msg); + + if (msg.type === 'init') { + this.clientId = msg.clientId; + this.conversationId = msg.conversationId; + console.log(`🔑 Client ID: ${this.clientId}`); + console.log(`🔑 Conversation ID: ${this.conversationId}`); + } else if (msg.type === 'metrics') { + console.log(`📊 Resposta: "${msg.response}" (${msg.latency}ms)`); + } + } catch (e) { + console.log('📨 Dados recebidos:', data.toString()); + } + } + }); + }); + } + + /** + * Gera áudio PCM sintético com tom de 440Hz (nota Lá) + * @param {number} durationMs - Duração em milissegundos + * @returns {Buffer} - Buffer PCM 16-bit @ 16kHz + */ + generateTestAudio(durationMs = 2000) { + const sampleRate = 16000; + const frequency = 440; // Hz (nota Lá) + const samples = Math.floor(sampleRate * durationMs / 1000); + const buffer = Buffer.alloc(samples * 2); // 16-bit = 2 bytes por sample + + for (let i = 0; i < samples; i++) { + // Gerar onda senoidal + const t = i / sampleRate; + const value = Math.sin(2 * Math.PI * frequency * t); + + // Converter para int16 + const int16Value = Math.floor(value * 32767); + + // Escrever no buffer (little-endian) + buffer.writeInt16LE(int16Value, i * 2); + } + + return buffer; + } + + /** + * Gera áudio de fala real usando espeak (se disponível) + */ + async generateSpeechAudio(text = "Olá, este é um teste de áudio") { + const { execSync } = require('child_process'); + const tempFile = `/tmp/test_audio_${Date.now()}.raw`; + + try { + // Usar espeak para gerar áudio + console.log(`🎤 Gerando áudio de fala: "${text}"`); + execSync(`espeak -s 150 -v pt-br "${text}" --stdout | sox - -r 16000 -b 16 -e signed-integer ${tempFile}`); + + const audioBuffer = fs.readFileSync(tempFile); + fs.unlinkSync(tempFile); // Limpar arquivo temporário + + return audioBuffer; + } catch (error) { + console.warn('⚠️ espeak/sox não disponível, usando áudio sintético'); + return this.generateTestAudio(2000); + } + } + + async sendAudio(audioBuffer) { + console.log(`\n📤 Enviando áudio PCM: ${(audioBuffer.length / 1024).toFixed(1)}KB`); + + // Enviar como dados binários diretos (como o navegador faz) + this.ws.send(audioBuffer); + + console.log('✅ Áudio enviado'); + } + + async testConversation() { + console.log('\n=== Iniciando teste de conversação ===\n'); + + // Teste 1: Enviar tom sintético + console.log('1️⃣ Teste com tom sintético (440Hz por 2s)'); + const syntheticAudio = this.generateTestAudio(2000); + await this.sendAudio(syntheticAudio); + await this.wait(5000); // Aguardar resposta + + // Teste 2: Enviar áudio de fala (se possível) + console.log('\n2️⃣ Teste com fala sintetizada'); + const speechAudio = await this.generateSpeechAudio("Qual é o seu nome?"); + await this.sendAudio(speechAudio); + await this.wait(5000); // Aguardar resposta + + // Teste 3: Enviar silêncio + console.log('\n3️⃣ Teste com silêncio'); + const silentAudio = Buffer.alloc(32000); // 1 segundo de silêncio + await this.sendAudio(silentAudio); + await this.wait(5000); // Aguardar resposta + } + + wait(ms) { + return new Promise(resolve => setTimeout(resolve, ms)); + } + + disconnect() { + if (this.ws) { + console.log('\n👋 Desconectando...'); + this.ws.close(); + } + } +} + +async function main() { + const tester = new AudioTester(); + + try { + await tester.connect(); + await tester.wait(500); + await tester.testConversation(); + await tester.wait(2000); // Aguardar últimas respostas + } catch (error) { + console.error('Erro fatal:', error); + } finally { + tester.disconnect(); + } +} + +console.log('╔═══════════════════════════════════════╗'); +console.log('║ Teste CLI de Áudio PCM ║'); +console.log('╚═══════════════════════════════════════╝\n'); +console.log('Este teste simula o envio de áudio PCM'); +console.log('como o navegador faz, mas via CLI.\n'); + +main().catch(console.error); \ No newline at end of file diff --git a/test-grpc-updated.py b/test-grpc-updated.py new file mode 100644 index 0000000000000000000000000000000000000000..7d808485be3f7bdaa913aebbce213d8282744229 --- /dev/null +++ b/test-grpc-updated.py @@ -0,0 +1,161 @@ +#!/usr/bin/env python3 +""" +Teste do servidor Ultravox via gRPC com formato de áudio atualizado +""" + +import grpc +import numpy as np +import librosa +import tempfile +from gtts import gTTS +import sys +import os +import time + +# Adicionar paths para protos +sys.path.append('/workspace/ultravox-pipeline/services/ultravox') +sys.path.append('/workspace/ultravox-pipeline/protos/generated') + +import speech_pb2 +import speech_pb2_grpc + + +def generate_audio_for_grpc(text, lang='pt-br'): + """Gera áudio TTS e retorna como bytes float32 para gRPC""" + print(f"🔊 Gerando TTS: '{text}'") + + # Criar arquivo temporário para o TTS + with tempfile.NamedTemporaryFile(suffix='.mp3', delete=False) as tmp_file: + tmp_path = tmp_file.name + + try: + # Gerar TTS como MP3 + tts = gTTS(text=text, lang=lang) + tts.save(tmp_path) + + # Carregar com librosa (converte automaticamente para float32 normalizado) + audio, sr = librosa.load(tmp_path, sr=16000) + + print(f"📊 Áudio carregado:") + print(f" - Shape: {audio.shape}") + print(f" - Dtype: {audio.dtype}") + print(f" - Min: {audio.min():.3f}, Max: {audio.max():.3f}") + print(f" - Sample rate: {sr} Hz") + + # Converter para bytes para enviar via gRPC + audio_bytes = audio.tobytes() + + return audio_bytes, sr + + finally: + # Limpar arquivo temporário + if os.path.exists(tmp_path): + os.unlink(tmp_path) + + +async def test_ultravox_grpc(): + """Testa o servidor Ultravox via gRPC""" + + print("=" * 60) + print("🚀 TESTE ULTRAVOX gRPC COM FORMATO ATUALIZADO") + print("=" * 60) + + # Conectar ao servidor gRPC + channel = grpc.aio.insecure_channel('localhost:50051') + stub = speech_pb2_grpc.SpeechServiceStub(channel) + + # Lista de testes + tests = [ + { + "audio_text": "Quanto é dois mais dois?", + "prompt": "Responda em português:", + "lang": "pt-br", + "expected": ["quatro", "4", "dois mais dois"] + }, + { + "audio_text": "Qual é a capital do Brasil?", + "prompt": "", # Testar sem prompt customizado + "lang": "pt-br", + "expected": ["Brasília", "capital"] + }, + { + "audio_text": "What is the capital of France?", + "prompt": "Answer the question:", + "lang": "en", + "expected": ["Paris", "capital", "France"] + } + ] + + for i, test in enumerate(tests, 1): + print(f"\n{'='*50}") + print(f"📝 Teste {i}: {test['audio_text']}") + if test['prompt']: + print(f" Prompt: {test['prompt']}") + print(f" Esperado: {', '.join(test['expected'])}") + + # Gerar áudio + audio_bytes, sample_rate = generate_audio_for_grpc(test['audio_text'], test['lang']) + + # Criar requisição gRPC + async def generate_requests(): + # Primeiro chunk com metadados + chunk = speech_pb2.AudioChunk() + chunk.session_id = f"test_{i}" + chunk.audio_data = audio_bytes[:len(audio_bytes)//2] # Primeira metade + chunk.sample_rate = sample_rate + chunk.is_final_chunk = False + if test['prompt']: + chunk.system_prompt = test['prompt'] + yield chunk + + # Segundo chunk com resto do áudio + chunk = speech_pb2.AudioChunk() + chunk.session_id = f"test_{i}" + chunk.audio_data = audio_bytes[len(audio_bytes)//2:] # Segunda metade + chunk.sample_rate = sample_rate + chunk.is_final_chunk = True + yield chunk + + # Enviar e receber resposta + print("⏳ Enviando para servidor...") + start_time = time.time() + + try: + response_text = "" + token_count = 0 + + async for token in stub.StreamingRecognize(generate_requests()): + if token.text: + response_text += token.text + token_count += 1 + + if token.is_final: + break + + elapsed = time.time() - start_time + + # Verificar resposta + success = any(exp.lower() in response_text.lower() for exp in test['expected']) + + print(f"💬 Resposta: '{response_text.strip()}'") + print(f"📊 Tokens: {token_count}") + print(f"⏱️ Tempo: {elapsed:.2f}s") + + if success: + print(f"✅ SUCESSO! Resposta reconhecida") + else: + print(f"⚠️ Resposta não reconhecida") + + except Exception as e: + print(f"❌ Erro: {e}") + + await channel.close() + + print("\n" + "=" * 60) + print("📊 TESTE CONCLUÍDO") + print("=" * 60) + + +if __name__ == "__main__": + import asyncio + asyncio.run(test_ultravox_grpc()) \ No newline at end of file diff --git a/test-opus-support.html b/test-opus-support.html new file mode 100644 index 0000000000000000000000000000000000000000..72a79d4292f97a98d347b7b4f0c774fd2c18ba07 --- /dev/null +++ b/test-opus-support.html @@ -0,0 +1,337 @@ + + + + + + Opus Codec Test + + + +
+

🎵 Opus Codec Support Test

+ +
+

Codec Support Detection:

+
+
+ +
+

🎤 Recording Test

+ + + + +
+ +
+

📊 Recording Info

+
+

Format: -

+

Size: -

+

Duration: -

+
+
+ +
+
+ + + + \ No newline at end of file diff --git a/test-simple.py b/test-simple.py new file mode 100644 index 0000000000000000000000000000000000000000..9e3657ce3737945e6e8d129d1da929b053a31d72 --- /dev/null +++ b/test-simple.py @@ -0,0 +1,70 @@ +#!/usr/bin/env python3 +""" +Teste simples do Ultravox com prompt básico +""" + +import grpc +import numpy as np +import time +import sys + +sys.path.append('/workspace/ultravox-pipeline/ultravox') +sys.path.append('/workspace/ultravox-pipeline/protos') + +import speech_pb2 +import speech_pb2_grpc + +def test_ultravox(): + """Testa o Ultravox com áudio simples""" + + print("📡 Conectando ao Ultravox...") + channel = grpc.insecure_channel('localhost:50051') + stub = speech_pb2_grpc.SpeechServiceStub(channel) + + # Criar áudio simples de silêncio + # O modelo deveria processar mesmo sem áudio real + audio = np.zeros(16000, dtype=np.float32) # 1 segundo de silêncio + + print(f"🎵 Áudio: {len(audio)} samples @ 16kHz") + + # Criar requisição simples + def audio_generator(): + chunk = speech_pb2.AudioChunk() + chunk.audio_data = audio.tobytes() + chunk.sample_rate = 16000 + chunk.is_final_chunk = True + chunk.session_id = f"test_{int(time.time())}" + # Não enviar prompt - usar padrão <|audio|> + yield chunk + + print("⏳ Processando...") + start_time = time.time() + + try: + response_text = "" + token_count = 0 + + for response in stub.StreamingRecognize(audio_generator()): + if response.text: + response_text += response.text + token_count += 1 + print(f" Token {token_count}: '{response.text.strip()}'") + + if response.is_final: + print(" [FINAL]") + break + + elapsed = time.time() - start_time + + print(f"\n📊 Resultado:") + print(f" - Resposta: '{response_text.strip()}'") + print(f" - Tempo: {elapsed:.2f}s") + print(f" - Tokens: {token_count}") + + except grpc.RpcError as e: + print(f"❌ Erro gRPC: {e.code()} - {e.details()}") + except Exception as e: + print(f"❌ Erro: {e}") + +if __name__ == "__main__": + test_ultravox() \ No newline at end of file diff --git a/test-tts-button.html b/test-tts-button.html new file mode 100644 index 0000000000000000000000000000000000000000..d1ad5abe9957ac1ec18de2b777d87212ef3a2b67 --- /dev/null +++ b/test-tts-button.html @@ -0,0 +1,65 @@ + + + + Test TTS Button + + +

Test TTS WebSocket

+ + +
+ + + + \ No newline at end of file diff --git a/test-ultravox-auto.py b/test-ultravox-auto.py new file mode 100644 index 0000000000000000000000000000000000000000..99bfee202575dbcd09af9573184f184067c4e621 --- /dev/null +++ b/test-ultravox-auto.py @@ -0,0 +1,172 @@ +#!/usr/bin/env python3 +""" +Teste automatizado do Ultravox com TTS +""" + +import grpc +import numpy as np +import time +import sys +import os +from gtts import gTTS +from pydub import AudioSegment +import io + +# Adicionar paths +sys.path.append('/workspace/ultravox-pipeline/ultravox') +sys.path.append('/workspace/ultravox-pipeline/protos') + +# Importar os protobuffers compilados +import speech_pb2 +import speech_pb2_grpc + +def generate_tts_audio(text, lang='pt-br'): + """Gera áudio TTS a partir de texto""" + print(f"🔊 Gerando TTS: '{text}'") + + tts = gTTS(text=text, lang=lang) + mp3_buffer = io.BytesIO() + tts.write_to_fp(mp3_buffer) + mp3_buffer.seek(0) + + # Converter MP3 para PCM 16kHz + audio = AudioSegment.from_mp3(mp3_buffer) + audio = audio.set_frame_rate(16000).set_channels(1).set_sample_width(2) + + # Converter para numpy float32 + samples = np.array(audio.get_array_of_samples()).astype(np.float32) / 32768.0 + + return samples + +def test_ultravox(question, expected_answer=None): + """Testa o Ultravox com uma pergunta""" + + print(f"\n{'='*60}") + print(f"📝 Pergunta: {question}") + if expected_answer: + print(f"✅ Resposta esperada: {expected_answer}") + print(f"{'='*60}") + + # Gerar áudio da pergunta + audio = generate_tts_audio(question) + print(f"🎵 Áudio gerado: {len(audio)} samples @ 16kHz ({len(audio)/16000:.2f}s)") + + # Conectar ao Ultravox + print("📡 Conectando ao Ultravox...") + channel = grpc.insecure_channel('localhost:50051') + stub = speech_pb2_grpc.SpeechServiceStub(channel) + + # Criar requisição + def audio_generator(): + chunk = speech_pb2.AudioChunk() + chunk.audio_data = audio.tobytes() + chunk.sample_rate = 16000 + chunk.is_final_chunk = True + chunk.session_id = f"test_{int(time.time())}" + # Não enviar system_prompt - deixar o servidor usar o padrão com <|audio|> + # chunk.system_prompt = "" + yield chunk + + # Enviar e receber resposta + print("⏳ Processando...") + start_time = time.time() + + try: + response_text = "" + token_count = 0 + + for response in stub.StreamingRecognize(audio_generator()): + if response.text: + response_text += response.text + token_count += 1 + print(f" Token {token_count}: '{response.text.strip()}'", end="") + + if response.is_final: + print(" [FINAL]") + break + else: + print() + + elapsed = time.time() - start_time + + print(f"\n📊 Estatísticas:") + print(f" - Resposta: '{response_text.strip()}'") + print(f" - Tempo: {elapsed:.2f}s") + print(f" - Tokens: {token_count}") + + # Verificar resposta esperada + if expected_answer: + if expected_answer.lower() in response_text.lower(): + print(f" ✅ SUCESSO! Resposta contém '{expected_answer}'") + return True + else: + print(f" ⚠️ AVISO: Resposta não contém '{expected_answer}'") + return False + + return True + + except grpc.RpcError as e: + print(f"❌ Erro gRPC: {e.code()} - {e.details()}") + return False + except Exception as e: + print(f"❌ Erro: {e}") + return False + +def main(): + """Executa bateria de testes""" + + print("\n" + "="*60) + print("🚀 TESTE AUTOMATIZADO DO ULTRAVOX") + print("="*60) + + # Lista de testes + tests = [ + { + "question": "Quanto é dois mais dois?", + "expected": "quatro" + }, + { + "question": "Qual é a capital do Brasil?", + "expected": "Brasília" + }, + { + "question": "Que dia é hoje?", + "expected": None # Resposta variável + }, + { + "question": "Olá, como você está?", + "expected": None # Resposta variável + } + ] + + # Executar testes + results = [] + for i, test in enumerate(tests, 1): + print(f"\n🧪 TESTE {i}/{len(tests)}") + success = test_ultravox(test["question"], test.get("expected")) + results.append(success) + time.sleep(2) # Pausa entre testes + + # Resumo + print("\n" + "="*60) + print("📊 RESUMO DOS TESTES") + print("="*60) + + total = len(results) + passed = sum(1 for r in results if r) + failed = total - passed + + print(f"Total: {total}") + print(f"✅ Passou: {passed}") + print(f"❌ Falhou: {failed}") + print(f"Taxa de sucesso: {(passed/total)*100:.1f}%") + + if passed == total: + print("\n🎉 TODOS OS TESTES PASSARAM!") + return 0 + else: + print(f"\n⚠️ {failed} teste(s) falharam") + return 1 + +if __name__ == "__main__": + sys.exit(main()) \ No newline at end of file diff --git a/test-ultravox-librosa.py b/test-ultravox-librosa.py new file mode 100644 index 0000000000000000000000000000000000000000..188ab9a05c170f107d082ac8bbf56922c977686f --- /dev/null +++ b/test-ultravox-librosa.py @@ -0,0 +1,166 @@ +#!/usr/bin/env python3 +""" +Teste do Ultravox com formato de áudio correto usando librosa +""" + +import sys +sys.path.append('/workspace/ultravox-pipeline/ultravox') + +from vllm import LLM, SamplingParams +import numpy as np +import librosa +import soundfile as sf +import tempfile +from gtts import gTTS +import time +import os + +def generate_audio_librosa(text, lang='pt-br'): + """Gera áudio TTS e converte para formato esperado pelo Ultravox""" + print(f"🔊 Gerando TTS: '{text}'") + + # Criar arquivo temporário para o TTS + with tempfile.NamedTemporaryFile(suffix='.mp3', delete=False) as tmp_file: + tmp_path = tmp_file.name + + try: + # Gerar TTS como MP3 + tts = gTTS(text=text, lang=lang) + tts.save(tmp_path) + + # Carregar com librosa (converte automaticamente para float32 normalizado) + # librosa normaliza entre -1 e 1 automaticamente + audio, sr = librosa.load(tmp_path, sr=16000) + + print(f"📊 Áudio carregado com librosa:") + print(f" - Shape: {audio.shape}") + print(f" - Dtype: {audio.dtype}") + print(f" - Min: {audio.min():.3f}, Max: {audio.max():.3f}") + print(f" - Sample rate: {sr} Hz") + + return audio, sr + + finally: + # Limpar arquivo temporário + if os.path.exists(tmp_path): + os.unlink(tmp_path) + +def test_ultravox_librosa(): + """Testa Ultravox com formato de áudio correto""" + + print("=" * 60) + print("🚀 TESTE ULTRAVOX COM LIBROSA (FORMATO CORRETO)") + print("=" * 60) + + # Configurar modelo + model_name = "fixie-ai/ultravox-v0_5-llama-3_2-1b" + + # Inicializar LLM + print(f"📡 Inicializando {model_name}...") + llm = LLM( + model=model_name, + trust_remote_code=True, + enforce_eager=True, + max_model_len=256, + gpu_memory_utilization=0.3 + ) + + # Parâmetros de sampling + sampling_params = SamplingParams( + temperature=0.3, + max_tokens=50, + repetition_penalty=1.1 + ) + + # Lista de testes + tests = [ + ("Quanto é dois mais dois?", "pt-br", "quatro"), + ("Qual é a capital do Brasil?", "pt-br", "Brasília"), + ("What is two plus two?", "en", "four"), + ] + + results = [] + + for question, lang, expected in tests: + print(f"\n{'='*50}") + print(f"📝 Pergunta: {question}") + print(f"✅ Esperado: {expected}") + + # Gerar áudio com librosa + audio, sr = generate_audio_librosa(question, lang) + + # Preparar prompt com token de áudio + prompt = "<|audio|>" + + # Preparar entrada com áudio + llm_input = { + "prompt": prompt, + "multi_modal_data": { + "audio": audio # Agora no formato correto do librosa + } + } + + # Fazer inferência + print("⏳ Processando...") + start_time = time.time() + + try: + outputs = llm.generate( + prompts=[llm_input], + sampling_params=sampling_params + ) + + elapsed = time.time() - start_time + + # Extrair resposta + response = outputs[0].outputs[0].text.strip() + + # Verificar se a resposta contém o esperado + success = expected.lower() in response.lower() if expected else False + + print(f"💬 Resposta: '{response}'") + print(f"⏱️ Tempo: {elapsed:.2f}s") + + if success: + print(f"✅ SUCESSO! Resposta contém '{expected}'") + else: + print(f"⚠️ Resposta não contém '{expected}'") + + results.append({ + 'question': question, + 'expected': expected, + 'response': response, + 'success': success, + 'time': elapsed + }) + + except Exception as e: + print(f"❌ Erro: {e}") + results.append({ + 'question': question, + 'expected': expected, + 'response': str(e), + 'success': False, + 'time': 0 + }) + + # Resumo + print("\n" + "=" * 60) + print("📊 RESUMO DOS TESTES") + print("=" * 60) + + total = len(results) + passed = sum(1 for r in results if r['success']) + + for i, result in enumerate(results, 1): + status = "✅" if result['success'] else "❌" + print(f"{status} Teste {i}: {result['question'][:30]}...") + print(f" Resposta: {result['response'][:50]}...") + + print(f"\nTotal: {total}") + print(f"✅ Passou: {passed}") + print(f"❌ Falhou: {total - passed}") + print(f"Taxa de sucesso: {(passed/total)*100:.1f}%") + +if __name__ == "__main__": + test_ultravox_librosa() \ No newline at end of file diff --git a/test-ultravox-simple-prompt.py b/test-ultravox-simple-prompt.py new file mode 100644 index 0000000000000000000000000000000000000000..8f7032cbb1b319edb6e6ab494b27d0435693333d --- /dev/null +++ b/test-ultravox-simple-prompt.py @@ -0,0 +1,206 @@ +#!/usr/bin/env python3 +""" +Teste do Ultravox com prompt simples sem chat template +""" + +import sys +sys.path.append('/workspace/ultravox-pipeline/ultravox') + +from vllm import LLM, SamplingParams +import numpy as np +import librosa +import tempfile +from gtts import gTTS +import time +import os + +def generate_audio_tuple(text, lang='pt-br'): + """Gera áudio TTS e retorna como tupla (audio, sample_rate)""" + print(f"🔊 Gerando TTS: '{text}'") + + # Criar arquivo temporário para o TTS + with tempfile.NamedTemporaryFile(suffix='.mp3', delete=False) as tmp_file: + tmp_path = tmp_file.name + + try: + # Gerar TTS como MP3 + tts = gTTS(text=text, lang=lang) + tts.save(tmp_path) + + # Carregar com librosa (converte automaticamente para float32 normalizado) + audio, sr = librosa.load(tmp_path, sr=16000) + + print(f"📊 Áudio carregado:") + print(f" - Shape: {audio.shape}") + print(f" - Dtype: {audio.dtype}") + print(f" - Min: {audio.min():.3f}, Max: {audio.max():.3f}") + print(f" - Sample rate: {sr} Hz") + + # Retornar como tupla (audio, sample_rate) - formato esperado pelo vLLM + return (audio, sr) + + finally: + # Limpar arquivo temporário + if os.path.exists(tmp_path): + os.unlink(tmp_path) + +def test_ultravox_simple(): + """Testa Ultravox com prompt simples""" + + print("=" * 60) + print("🚀 TESTE ULTRAVOX COM PROMPT SIMPLES") + print("=" * 60) + + # Configurar modelo + model_name = "fixie-ai/ultravox-v0_5-llama-3_2-1b" + + # Inicializar LLM + print(f"📡 Inicializando {model_name}...") + llm = LLM( + model=model_name, + trust_remote_code=True, + enforce_eager=True, + max_model_len=4096, + gpu_memory_utilization=0.3 + ) + + # Parâmetros de sampling + sampling_params = SamplingParams( + temperature=0.2, + max_tokens=64 + ) + + # Lista de testes com diferentes formatos de prompt + tests = [ + { + "audio_text": "Quanto é dois mais dois?", + "prompts": [ + "<|audio|>", # Apenas o token + "<|audio|>\nResponda em português:", # Com instrução + "<|audio|>\nO que foi perguntado no áudio?", # Com pergunta + ], + "lang": "pt-br", + "expected": ["quatro", "4", "dois mais dois", "2+2"] + }, + { + "audio_text": "What is the capital of France?", + "prompts": [ + "<|audio|>", + "<|audio|>\nAnswer the question:", + "<|audio|>\nWhat did you hear?", + ], + "lang": "en", + "expected": ["Paris", "capital", "France"] + } + ] + + results = [] + + for test in tests: + audio_tuple = generate_audio_tuple(test['audio_text'], test['lang']) + + for prompt in test['prompts']: + print(f"\n{'='*50}") + print(f"📝 Áudio: {test['audio_text']}") + print(f"📝 Prompt: {prompt[:50]}...") + print(f"✅ Esperado: {', '.join(test['expected'])}") + + # Preparar entrada com áudio no formato de tupla + llm_input = { + "prompt": prompt, + "multi_modal_data": { + "audio": [audio_tuple] # Lista de tuplas (audio, sample_rate) + } + } + + # Fazer inferência + print("⏳ Processando...") + start_time = time.time() + + try: + outputs = llm.generate( + prompts=[llm_input], + sampling_params=sampling_params + ) + + elapsed = time.time() - start_time + + # Extrair resposta + response = outputs[0].outputs[0].text.strip() + + # Verificar se a resposta contém algum dos esperados + success = any(exp.lower() in response.lower() for exp in test['expected']) + + print(f"💬 Resposta: '{response[:100]}...'") + print(f"⏱️ Tempo: {elapsed:.2f}s") + + if success: + print(f"✅ SUCESSO! Resposta reconhecida") + else: + print(f"⚠️ Resposta não reconhecida") + + results.append({ + 'audio': test['audio_text'], + 'prompt': prompt[:30], + 'response': response, + 'success': success, + 'time': elapsed + }) + + except Exception as e: + print(f"❌ Erro: {e}") + results.append({ + 'audio': test['audio_text'], + 'prompt': prompt[:30], + 'response': str(e), + 'success': False, + 'time': 0 + }) + + # Resumo + print("\n" + "=" * 60) + print("📊 RESUMO DOS TESTES") + print("=" * 60) + + total = len(results) + passed = sum(1 for r in results if r['success']) + + # Agrupar por áudio + audio_groups = {} + for result in results: + if result['audio'] not in audio_groups: + audio_groups[result['audio']] = [] + audio_groups[result['audio']].append(result) + + for audio, group in audio_groups.items(): + print(f"\n📝 Áudio: {audio}") + for result in group: + status = "✅" if result['success'] else "❌" + print(f" {status} Prompt: {result['prompt']}...") + print(f" Resposta: {result['response'][:60]}...") + + print(f"\n📊 Estatísticas:") + print(f"Total de testes: {total}") + print(f"✅ Passou: {passed}") + print(f"❌ Falhou: {total - passed}") + print(f"Taxa de sucesso: {(passed/total)*100:.1f}%") + + # Encontrar o melhor prompt + prompt_success = {} + for result in results: + prompt_key = result['prompt'] + if prompt_key not in prompt_success: + prompt_success[prompt_key] = {'success': 0, 'total': 0} + prompt_success[prompt_key]['total'] += 1 + if result['success']: + prompt_success[prompt_key]['success'] += 1 + + print(f"\n🏆 Melhor formato de prompt:") + for prompt, stats in sorted(prompt_success.items(), + key=lambda x: x[1]['success']/x[1]['total'], + reverse=True): + rate = (stats['success']/stats['total'])*100 + print(f" {rate:.0f}% - {prompt}...") + +if __name__ == "__main__": + test_ultravox_simple() \ No newline at end of file diff --git a/test-ultravox-tts.py b/test-ultravox-tts.py new file mode 100644 index 0000000000000000000000000000000000000000..17eb0c7afc87ce58670109929b98c6f0c001e97f --- /dev/null +++ b/test-ultravox-tts.py @@ -0,0 +1,121 @@ +#!/usr/bin/env python3 +""" +Script de teste para Ultravox com TTS +Envia uma pergunta via áudio sintetizado e verifica a resposta +""" + +import grpc +import numpy as np +import asyncio +import time +from gtts import gTTS +from pydub import AudioSegment +import io +import sys +import os + +# Adicionar o path para os protobuffers +sys.path.append('/workspace/ultravox-pipeline/ultravox') +import speech_pb2 +import speech_pb2_grpc + +async def test_ultravox_with_tts(): + """Testa o Ultravox enviando áudio TTS com a pergunta 'Quanto é 2 + 2?'""" + + print("🎤 Iniciando teste do Ultravox com TTS...") + + # 1. Gerar áudio TTS com a pergunta + print("🔊 Gerando áudio TTS: 'Quanto é dois mais dois?'") + tts = gTTS(text="Quanto é dois mais dois?", lang='pt-br') + + # Salvar em buffer de memória + mp3_buffer = io.BytesIO() + tts.write_to_fp(mp3_buffer) + mp3_buffer.seek(0) + + # Converter MP3 para PCM 16kHz + audio = AudioSegment.from_mp3(mp3_buffer) + audio = audio.set_frame_rate(16000).set_channels(1).set_sample_width(2) + + # Converter para numpy array float32 + samples = np.array(audio.get_array_of_samples()).astype(np.float32) / 32768.0 + + print(f"✅ Áudio gerado: {len(samples)} samples @ 16kHz") + print(f" Duração: {len(samples)/16000:.2f} segundos") + + # 2. Conectar ao servidor Ultravox + print("\n📡 Conectando ao Ultravox na porta 50051...") + + try: + channel = grpc.aio.insecure_channel('localhost:50051') + stub = speech_pb2_grpc.UltravoxServiceStub(channel) + + # 3. Criar request com o áudio + session_id = f"test_{int(time.time())}" + + async def audio_generator(): + """Gera chunks de áudio para enviar""" + request = speech_pb2.AudioRequest() + request.session_id = session_id + request.audio_data = samples.tobytes() + request.sample_rate = 16000 + request.is_final_chunk = True + request.system_prompt = "Responda em português de forma simples e direta" + + print(f"📤 Enviando áudio para sessão: {session_id}") + yield request + + # 4. Enviar e receber resposta + print("\n⏳ Aguardando resposta do Ultravox...") + start_time = time.time() + + response_text = "" + token_count = 0 + + async for response in stub.TranscribeStream(audio_generator()): + if response.text: + response_text += response.text + token_count += 1 + print(f" Token {token_count}: '{response.text.strip()}'") + + if response.is_final: + break + + elapsed = time.time() - start_time + + # 5. Verificar resposta + print(f"\n📝 Resposta completa: '{response_text.strip()}'") + print(f"⏱️ Tempo de resposta: {elapsed:.2f}s") + print(f"📊 Tokens recebidos: {token_count}") + + # Verificar se a resposta contém "4" ou "quatro" + if "4" in response_text.lower() or "quatro" in response_text.lower(): + print("\n✅ SUCESSO! O Ultravox respondeu corretamente!") + else: + print("\n⚠️ AVISO: A resposta não contém '4' ou 'quatro'") + + await channel.close() + + except grpc.RpcError as e: + print(f"\n❌ Erro gRPC: {e.code()} - {e.details()}") + return False + except Exception as e: + print(f"\n❌ Erro: {e}") + return False + + return True + +if __name__ == "__main__": + print("=" * 60) + print("TESTE ULTRAVOX COM TTS") + print("=" * 60) + + # Executar teste + success = asyncio.run(test_ultravox_with_tts()) + + if success: + print("\n🎉 Teste concluído com sucesso!") + else: + print("\n❌ Teste falhou!") + + print("=" * 60) \ No newline at end of file diff --git a/test-ultravox-tuple.py b/test-ultravox-tuple.py new file mode 100644 index 0000000000000000000000000000000000000000..de54e41b09d10f361844ab84ad6b41f42885272d --- /dev/null +++ b/test-ultravox-tuple.py @@ -0,0 +1,202 @@ +#!/usr/bin/env python3 +""" +Teste do Ultravox com formato correto de tupla (audio, sample_rate) +Baseado no exemplo oficial do vLLM +""" + +import sys +sys.path.append('/workspace/ultravox-pipeline/ultravox') + +from vllm import LLM, SamplingParams +import numpy as np +import librosa +import tempfile +from gtts import gTTS +import time +import os +from transformers import AutoTokenizer + +def generate_audio_tuple(text, lang='pt-br'): + """Gera áudio TTS e retorna como tupla (audio, sample_rate)""" + print(f"🔊 Gerando TTS: '{text}'") + + # Criar arquivo temporário para o TTS + with tempfile.NamedTemporaryFile(suffix='.mp3', delete=False) as tmp_file: + tmp_path = tmp_file.name + + try: + # Gerar TTS como MP3 + tts = gTTS(text=text, lang=lang) + tts.save(tmp_path) + + # Carregar com librosa (converte automaticamente para float32 normalizado) + audio, sr = librosa.load(tmp_path, sr=16000) + + print(f"📊 Áudio carregado:") + print(f" - Shape: {audio.shape}") + print(f" - Dtype: {audio.dtype}") + print(f" - Min: {audio.min():.3f}, Max: {audio.max():.3f}") + print(f" - Sample rate: {sr} Hz") + + # Retornar como tupla (audio, sample_rate) - formato esperado pelo vLLM + return (audio, sr) + + finally: + # Limpar arquivo temporário + if os.path.exists(tmp_path): + os.unlink(tmp_path) + +def test_ultravox_tuple(): + """Testa Ultravox com formato de tupla correto""" + + print("=" * 60) + print("🚀 TESTE ULTRAVOX COM FORMATO DE TUPLA") + print("=" * 60) + + # Configurar modelo + model_name = "fixie-ai/ultravox-v0_5-llama-3_2-1b" + + # Inicializar tokenizer + print(f"📡 Inicializando tokenizer...") + tokenizer = AutoTokenizer.from_pretrained(model_name) + + # Inicializar LLM + print(f"📡 Inicializando {model_name}...") + llm = LLM( + model=model_name, + trust_remote_code=True, + enforce_eager=True, + max_model_len=4096, # Aumentando para 4096 como no exemplo oficial + gpu_memory_utilization=0.3 + ) + + # Parâmetros de sampling + sampling_params = SamplingParams( + temperature=0.2, # Usando 0.2 como no exemplo oficial + max_tokens=64 # Usando 64 como no exemplo oficial + ) + + # Lista de testes + tests = [ + { + "audio_text": "Quanto é dois mais dois?", + "question": "O que foi perguntado no áudio?", + "lang": "pt-br", + "expected": ["quatro", "2+2", "dois mais dois"] + }, + { + "audio_text": "Qual é a capital do Brasil?", + "question": "Responda a pergunta que você ouviu.", + "lang": "pt-br", + "expected": ["Brasília", "capital", "Brasil"] + }, + { + "audio_text": "What is two plus two?", + "question": "Answer the question you heard.", + "lang": "en", + "expected": ["four", "4", "two plus two"] + } + ] + + results = [] + + for test in tests: + print(f"\n{'='*50}") + print(f"📝 Áudio: {test['audio_text']}") + print(f"❓ Pergunta: {test['question']}") + print(f"✅ Esperado: {', '.join(test['expected'])}") + + # Gerar áudio como tupla + audio_tuple = generate_audio_tuple(test['audio_text'], test['lang']) + + # Criar mensagem com token de áudio + messages = [{ + "role": "user", + "content": f"<|audio|>\n{test['question']}" + }] + + # Aplicar chat template + prompt = tokenizer.apply_chat_template( + messages, + tokenize=False, + add_generation_prompt=True + ) + + print(f"📝 Prompt gerado: {prompt[:100]}...") + + # Preparar entrada com áudio no formato de tupla + llm_input = { + "prompt": prompt, + "multi_modal_data": { + "audio": [audio_tuple] # Lista de tuplas (audio, sample_rate) + } + } + + # Fazer inferência + print("⏳ Processando...") + start_time = time.time() + + try: + outputs = llm.generate( + prompts=[llm_input], + sampling_params=sampling_params + ) + + elapsed = time.time() - start_time + + # Extrair resposta + response = outputs[0].outputs[0].text.strip() + + # Verificar se a resposta contém algum dos esperados + success = any(exp.lower() in response.lower() for exp in test['expected']) + + print(f"💬 Resposta: '{response}'") + print(f"⏱️ Tempo: {elapsed:.2f}s") + + if success: + print(f"✅ SUCESSO! Resposta reconhecida") + else: + print(f"⚠️ Resposta não reconhecida") + + results.append({ + 'audio': test['audio_text'], + 'question': test['question'], + 'expected': test['expected'], + 'response': response, + 'success': success, + 'time': elapsed + }) + + except Exception as e: + print(f"❌ Erro: {e}") + import traceback + traceback.print_exc() + results.append({ + 'audio': test['audio_text'], + 'question': test['question'], + 'expected': test['expected'], + 'response': str(e), + 'success': False, + 'time': 0 + }) + + # Resumo + print("\n" + "=" * 60) + print("📊 RESUMO DOS TESTES") + print("=" * 60) + + total = len(results) + passed = sum(1 for r in results if r['success']) + + for i, result in enumerate(results, 1): + status = "✅" if result['success'] else "❌" + print(f"{status} Teste {i}: {result['audio'][:30]}...") + print(f" Resposta: {result['response'][:80]}...") + + print(f"\nTotal: {total}") + print(f"✅ Passou: {passed}") + print(f"❌ Falhou: {total - passed}") + print(f"Taxa de sucesso: {(passed/total)*100:.1f}%") + +if __name__ == "__main__": + test_ultravox_tuple() \ No newline at end of file diff --git a/test-ultravox-vllm.py b/test-ultravox-vllm.py new file mode 100644 index 0000000000000000000000000000000000000000..1ae4c7ed47ad05617393b11a6523e7bcc1a1219f --- /dev/null +++ b/test-ultravox-vllm.py @@ -0,0 +1,113 @@ +#!/usr/bin/env python3 +""" +Teste do Ultravox usando vLLM diretamente +Baseado no exemplo oficial +""" + +import sys +sys.path.append('/workspace/ultravox-pipeline/ultravox') + +from vllm import LLM, SamplingParams +import numpy as np +from gtts import gTTS +from pydub import AudioSegment +import io +import time + +def generate_audio(text, lang='pt-br'): + """Gera áudio TTS""" + print(f"🔊 Gerando TTS: '{text}'") + + tts = gTTS(text=text, lang=lang) + mp3_buffer = io.BytesIO() + tts.write_to_fp(mp3_buffer) + mp3_buffer.seek(0) + + # Converter MP3 para PCM 16kHz + audio = AudioSegment.from_mp3(mp3_buffer) + audio = audio.set_frame_rate(16000).set_channels(1).set_sample_width(2) + + # Converter para numpy float32 + samples = np.array(audio.get_array_of_samples()).astype(np.float32) / 32768.0 + + return samples + +def test_ultravox(): + """Testa Ultravox diretamente com vLLM""" + + print("=" * 60) + print("🚀 TESTE DIRETO DO ULTRAVOX COM vLLM") + print("=" * 60) + + # Configurar modelo + model_name = "fixie-ai/ultravox-v0_5-llama-3_2-1b" + + # Inicializar LLM + print(f"📡 Inicializando {model_name}...") + llm = LLM( + model=model_name, + trust_remote_code=True, + enforce_eager=True, + max_model_len=256, + gpu_memory_utilization=0.3 + ) + + # Parâmetros de sampling + sampling_params = SamplingParams( + temperature=0.3, + max_tokens=50, + repetition_penalty=1.1 + ) + + # Lista de testes + tests = [ + ("What is 2 + 2?", "en"), + ("Quanto é dois mais dois?", "pt-br"), + ("What is the capital of Brazil?", "en") + ] + + for question, lang in tests: + print(f"\n📝 Pergunta: {question}") + + # Gerar áudio + audio = generate_audio(question, lang) + print(f"🎵 Áudio: {len(audio)} samples @ 16kHz") + + # Preparar prompt com token de áudio + prompt = "<|audio|>" + + # Preparar entrada com áudio + llm_input = { + "prompt": prompt, + "multi_modal_data": { + "audio": audio + } + } + + # Fazer inferência + print("⏳ Processando...") + start_time = time.time() + + try: + outputs = llm.generate( + prompts=[llm_input], + sampling_params=sampling_params + ) + + elapsed = time.time() - start_time + + # Extrair resposta + response = outputs[0].outputs[0].text + + print(f"✅ Resposta: '{response}'") + print(f"⏱️ Tempo: {elapsed:.2f}s") + + except Exception as e: + print(f"❌ Erro: {e}") + + print("\n" + "=" * 60) + print("✅ TESTE CONCLUÍDO") + print("=" * 60) + +if __name__ == "__main__": + test_ultravox() \ No newline at end of file diff --git a/test-vllm-openai.py b/test-vllm-openai.py new file mode 100644 index 0000000000000000000000000000000000000000..29c3ba7f7df43a47e23ec5fa57166ca2c881fa33 --- /dev/null +++ b/test-vllm-openai.py @@ -0,0 +1,90 @@ +#!/usr/bin/env python3 +""" +Teste do Ultravox usando vLLM OpenAI API +Baseado no exemplo oficial +""" + +import requests +import json +import numpy as np +import base64 +from gtts import gTTS +from pydub import AudioSegment +import io + +def generate_audio(text): + """Gera áudio TTS""" + print(f"🔊 Gerando TTS: '{text}'") + + tts = gTTS(text=text, lang='pt-br') + mp3_buffer = io.BytesIO() + tts.write_to_fp(mp3_buffer) + mp3_buffer.seek(0) + + # Converter MP3 para PCM 16kHz + audio = AudioSegment.from_mp3(mp3_buffer) + audio = audio.set_frame_rate(16000).set_channels(1).set_sample_width(2) + + # Converter para numpy float32 + samples = np.array(audio.get_array_of_samples()).astype(np.float32) / 32768.0 + + return samples + +def test_vllm_api(): + """Testa usando a API OpenAI do vLLM""" + + # Gerar áudio de teste + audio = generate_audio("Quanto é dois mais dois?") + print(f"🎵 Áudio: {len(audio)} samples @ 16kHz") + + # Codificar áudio em base64 + audio_bytes = audio.tobytes() + audio_b64 = base64.b64encode(audio_bytes).decode('utf-8') + + # Criar mensagem no formato OpenAI com áudio + messages = [ + { + "role": "user", + "content": [ + { + "type": "audio", + "audio": { + "data": audio_b64, + "format": "pcm16" + } + }, + { + "type": "text", + "text": "What did you hear?" + } + ] + } + ] + + # Fazer requisição para vLLM OpenAI API + url = "http://localhost:8000/v1/chat/completions" + + payload = { + "model": "fixie-ai/ultravox-v0_5-llama-3_2-1b", + "messages": messages, + "temperature": 0.3, + "max_tokens": 50 + } + + print("📡 Enviando para vLLM API...") + + try: + response = requests.post(url, json=payload) + + if response.status_code == 200: + result = response.json() + print("✅ Resposta:", result['choices'][0]['message']['content']) + else: + print(f"❌ Erro: {response.status_code}") + print(response.text) + + except Exception as e: + print(f"❌ Erro: {e}") + +if __name__ == "__main__": + test_vllm_api() \ No newline at end of file diff --git a/tts_server_kokoro.py b/tts_server_kokoro.py new file mode 100644 index 0000000000000000000000000000000000000000..9cabcd27dcfd14941517dfbc311836af8b5d1389 --- /dev/null +++ b/tts_server_kokoro.py @@ -0,0 +1,255 @@ +#!/usr/bin/env python3 +""" +TTS Server usando Kokoro para baixa latência +Retorna PCM direto sem conversões MP3/WAV +""" + +import grpc +import asyncio +import sys +import os +import time +import logging +import numpy as np +from concurrent import futures +from pathlib import Path +import importlib.util + +# Adicionar paths +sys.path.append('/workspace/ultravox-pipeline') +sys.path.append('/workspace/ultravox-pipeline/protos/generated') +sys.path.append('/workspace/tts-service-kokoro/engines/kokoro') + +import tts_pb2 +import tts_pb2_grpc + +# Logging +logging.basicConfig( + level=logging.INFO, + format='%(asctime)s - %(name)s - %(levelname)s - %(message)s' +) +logger = logging.getLogger(__name__) + +class KokoroTTSService(tts_pb2_grpc.TTSServiceServicer): + """Servidor TTS usando Kokoro para síntese de voz em português""" + + def __init__(self): + logger.info("🚀 Inicializando Kokoro TTS Service...") + self.pipeline = None + self.is_loaded = False + self.total_requests = 0 + self.load_model() + + def load_model(self): + """Carrega o modelo Kokoro uma vez e mantém em memória""" + if self.is_loaded: + return True + + try: + logger.info("📚 Carregando modelo Kokoro...") + start_time = time.time() + + # Importar módulo Kokoro dinamicamente + kokoro_path = Path('/workspace/tts-service-kokoro/engines/kokoro/gerar_audio.py') + + if not kokoro_path.exists(): + # Fallback para implementação simplificada + logger.warning("⚠️ Kokoro não encontrado, usando TTS simplificado") + self.use_simple_tts = True + self.is_loaded = True + return True + + spec = importlib.util.spec_from_file_location("gerar_audio", kokoro_path) + gerar_audio = importlib.util.module_from_spec(spec) + spec.loader.exec_module(gerar_audio) + + # Criar pipeline Kokoro + KPipeline = gerar_audio.KPipeline + self.pipeline = KPipeline(lang_code='p') # Português + self.use_simple_tts = False + + load_time = time.time() - start_time + logger.info(f"✅ Kokoro carregado em {load_time:.2f}s") + + self.is_loaded = True + + # Warm-up + self.warmup() + + return True + + except Exception as e: + logger.error(f"❌ Erro ao carregar Kokoro: {e}") + logger.info("📌 Usando TTS simplificado como fallback") + self.use_simple_tts = True + self.is_loaded = True + return True + + def warmup(self): + """Aquece o modelo com uma síntese teste""" + try: + if not self.use_simple_tts: + logger.info("🔥 Aquecendo modelo Kokoro...") + start = time.time() + _ = self.synthesize_text("Teste") + logger.info(f"✅ Warm-up completo em {time.time() - start:.2f}s") + except Exception as e: + logger.error(f"⚠️ Erro no warm-up: {e}") + + def synthesize_text(self, text: str) -> bytes: + """Sintetiza texto para áudio PCM""" + try: + if self.use_simple_tts or not self.pipeline: + # Fallback para síntese simples + return self._generate_simple_pcm(text) + + # Usar Kokoro + start = time.time() + + # Gerar áudio com Kokoro (retorna numpy array) + audio_array = self.pipeline.generate( + text, + voice='p_gemidao', # Voz portuguesa + speed=1.0 + ) + + # Converter para PCM 16-bit + if audio_array.dtype != np.int16: + # Normalizar e converter + audio_array = np.clip(audio_array * 32767, -32768, 32767).astype(np.int16) + + synthesis_time = time.time() - start + logger.info(f"🎵 Kokoro synthesis: {synthesis_time*1000:.1f}ms") + + return audio_array.tobytes() + + except Exception as e: + logger.error(f"❌ Erro na síntese Kokoro: {e}") + # Fallback para síntese simples + return self._generate_simple_pcm(text) + + def _generate_simple_pcm(self, text: str) -> bytes: + """Gera PCM sintético simples como fallback""" + try: + # Parâmetros de áudio + sample_rate = 16000 + duration = max(0.5, len(text) * 0.08) # ~80ms por caractere + + # Gerar samples + num_samples = int(sample_rate * duration) + t = np.linspace(0, duration, num_samples) + + # Frequência base (voz feminina) + base_freq = 220 + (hash(text) % 50) + + # Gerar onda com harmônicos para som mais natural + signal = np.sin(2 * np.pi * base_freq * t) * 0.5 + signal += np.sin(2 * np.pi * base_freq * 2 * t) * 0.3 # 2º harmônico + signal += np.sin(2 * np.pi * base_freq * 3 * t) * 0.2 # 3º harmônico + + # Adicionar modulação para variação natural + modulation = np.sin(2 * np.pi * 3 * t) * 0.2 + signal = signal * (0.8 + modulation) + + # Envelope ADSR + fade_samples = int(0.02 * sample_rate) # 20ms fade + signal[:fade_samples] *= np.linspace(0, 1, fade_samples) + signal[-fade_samples:] *= np.linspace(1, 0, fade_samples) + + # Converter para PCM 16-bit + pcm_data = np.clip(signal * 32767, -32768, 32767).astype(np.int16) + + return pcm_data.tobytes() + + except Exception as e: + logger.error(f"❌ Erro no TTS simples: {e}") + # Retornar silêncio + return np.zeros(16000, dtype=np.int16).tobytes() + + def StreamingSynthesize(self, request, context): + """ + Implementação de streaming synthesis + Retorna PCM 16-bit @ 16kHz direto, sem conversões + """ + try: + text = request.text + voice_id = request.voice_id or "kokoro_pt" + + logger.info(f"🎤 TTS Request: '{text}' [{len(text)} chars]") + start_time = time.time() + + # Sintetizar áudio + pcm_data = self.synthesize_text(text) + + # Enviar em chunks para streaming + chunk_size = 4096 # 4KB chunks + total_chunks = len(pcm_data) // chunk_size + 1 + + for i in range(total_chunks): + start_idx = i * chunk_size + end_idx = min((i + 1) * chunk_size, len(pcm_data)) + + if start_idx < len(pcm_data): + chunk_data = pcm_data[start_idx:end_idx] + + response = tts_pb2.AudioResponse( + audio_data=chunk_data, + samples_count=len(chunk_data) // 2, # int16 = 2 bytes + is_final_chunk=(i == total_chunks - 1), + timestamp_ms=int(time.time() * 1000) + ) + + yield response + + # Simular streaming realista (sem await pois não é async) + if not self.use_simple_tts: + time.sleep(0.001) # 1ms entre chunks + + total_time = (time.time() - start_time) * 1000 + self.total_requests += 1 + + logger.info(f"✅ TTS completo: {total_time:.1f}ms, {len(pcm_data)/1024:.1f}KB") + logger.info(f"📊 Total requests: {self.total_requests}") + + except Exception as e: + logger.error(f"❌ TTS Synthesis error: {e}") + context.set_code(grpc.StatusCode.INTERNAL) + context.set_details(f"Synthesis failed: {e}") + +async def serve(): + """Iniciar servidor TTS com Kokoro""" + + logger.info("🚀 Iniciando Kokoro TTS Server...") + + # Criar servidor gRPC assíncrono + server = grpc.aio.server( + futures.ThreadPoolExecutor(max_workers=10), + options=[ + ('grpc.max_send_message_length', 50 * 1024 * 1024), # 50MB + ('grpc.max_receive_message_length', 50 * 1024 * 1024), + ] + ) + + # Adicionar serviço + tts_service = KokoroTTSService() + tts_pb2_grpc.add_TTSServiceServicer_to_server(tts_service, server) + + # Configurar porta + listen_addr = '[::]:50054' + server.add_insecure_port(listen_addr) + + # Iniciar servidor + await server.start() + logger.info(f"🎵 Kokoro TTS Server rodando em {listen_addr}") + logger.info("💡 Latência esperada: <100ms para síntese") + logger.info("🔊 Formato: PCM 16-bit @ 16kHz (sem conversões!)") + + # Manter rodando + try: + await server.wait_for_termination() + except KeyboardInterrupt: + logger.info("🛑 Parando servidor...") + await server.stop(5) + +if __name__ == '__main__': + asyncio.run(serve()) \ No newline at end of file diff --git a/tunnel-macbook.sh b/tunnel-macbook.sh new file mode 100755 index 0000000000000000000000000000000000000000..57c99210b9a030531fa5741364553a5edb11c767 --- /dev/null +++ b/tunnel-macbook.sh @@ -0,0 +1,70 @@ +#!/bin/bash + +# Script simplificado para MacBook - SSH Tunnel para Ultravox WebRTC +# Copie este script para seu MacBook e execute localmente + +# Configurações - EDITE ESTAS VARIÁVEIS +REMOTE_HOST="SEU_SERVIDOR_AQUI" # Coloque o IP ou hostname do servidor +REMOTE_USER="ubuntu" # Seu usuário SSH +SSH_KEY="~/.ssh/id_rsa" # Caminho da sua chave SSH (opcional) + +# Portas (não precisa mudar) +WEBRTC_PORT=8082 +ULTRAVOX_PORT=50051 +TTS_PORT=50054 + +# Cores +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +RED='\033[0;31m' +BLUE='\033[0;34m' +NC='\033[0m' + +clear +echo -e "${BLUE}╔═══════════════════════════════════════════════════════╗${NC}" +echo -e "${BLUE}║ 🚇 Ultravox WebRTC - Túnel SSH para MacBook ║${NC}" +echo -e "${BLUE}╚═══════════════════════════════════════════════════════╝${NC}" +echo + +# Verificar se o host foi configurado +if [ "$REMOTE_HOST" = "SEU_SERVIDOR_AQUI" ]; then + echo -e "${RED}❌ Erro: Configure o REMOTE_HOST no script primeiro!${NC}" + echo -e "${YELLOW} Edite a linha 6 e coloque o IP ou hostname do seu servidor${NC}" + exit 1 +fi + +# Matar túneis existentes +echo -e "${YELLOW}🔍 Verificando túneis existentes...${NC}" +pkill -f "ssh.*$REMOTE_HOST.*8082:localhost:8082" 2>/dev/null +sleep 1 + +echo -e "${YELLOW}📡 Criando túnel SSH...${NC}" +echo -e " Servidor: ${GREEN}$REMOTE_USER@$REMOTE_HOST${NC}" +echo + +# Criar túnel SSH (encaminha apenas a porta do WebRTC) +if [ -f "$SSH_KEY" ]; then + ssh -f -N -L 8082:localhost:8082 -i "$SSH_KEY" $REMOTE_USER@$REMOTE_HOST +else + ssh -f -N -L 8082:localhost:8082 $REMOTE_USER@$REMOTE_HOST +fi + +if [ $? -eq 0 ]; then + echo -e "${GREEN}✅ Túnel SSH criado com sucesso!${NC}" + echo + echo -e "${BLUE}╔═══════════════════════════════════════════════════════╗${NC}" + echo -e "${BLUE}║ ACESSE NO SEU MACBOOK ║${NC}" + echo -e "${BLUE}╚═══════════════════════════════════════════════════════╝${NC}" + echo + echo -e " ${GREEN}➜ http://localhost:8082${NC}" + echo -e " ${GREEN}➜ http://localhost:8082/ultravox-chat.html${NC}" + echo -e " ${GREEN}➜ http://localhost:8082/ultravox-chat-ios.html${NC}" + echo + echo -e "${YELLOW}Para fechar o túnel:${NC}" + echo -e " ${BLUE}pkill -f 'ssh.*8082:localhost:8082'${NC}" + echo +else + echo -e "${RED}❌ Erro ao criar túnel SSH${NC}" + echo -e "${YELLOW}Verifique suas credenciais SSH e conexão${NC}" + exit 1 +fi \ No newline at end of file diff --git a/tunnel.sh b/tunnel.sh new file mode 100755 index 0000000000000000000000000000000000000000..d943d5b1b900c8b50d4cc954ecb69d3b35fe7590 --- /dev/null +++ b/tunnel.sh @@ -0,0 +1,95 @@ +#!/bin/bash + +# SSH Tunnel Script para acessar Ultravox WebRTC do MacBook local +# Este script cria um túnel SSH para encaminhar a porta 8082 do servidor remoto para sua máquina local + +# Cores para output +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +RED='\033[0;31m' +BLUE='\033[0;34m' +NC='\033[0m' # No Color + +# Configurações +REMOTE_HOST="${SSH_HOST:-seu-servidor.com}" # Substitua com o endereço do seu servidor +REMOTE_USER="${SSH_USER:-ubuntu}" # Substitua com seu usuário SSH +REMOTE_PORT="${REMOTE_PORT:-8082}" # Porta do WebRTC no servidor remoto +LOCAL_PORT="${LOCAL_PORT:-8082}" # Porta local no seu MacBook +SSH_KEY="${SSH_KEY:-~/.ssh/id_rsa}" # Caminho para sua chave SSH + +echo -e "${BLUE}═══════════════════════════════════════════════════════${NC}" +echo -e "${BLUE} 🚇 Ultravox WebRTC SSH Tunnel - MacBook Access${NC}" +echo -e "${BLUE}═══════════════════════════════════════════════════════${NC}" +echo + +# Verificar se já existe um túnel na porta +if lsof -Pi :$LOCAL_PORT -sTCP:LISTEN -t >/dev/null 2>&1; then + echo -e "${YELLOW}⚠️ Porta $LOCAL_PORT já está em uso${NC}" + echo -e "${YELLOW}Matando processo existente...${NC}" + lsof -ti:$LOCAL_PORT | xargs kill -9 2>/dev/null + sleep 1 +fi + +# Função para limpar ao sair +cleanup() { + echo + echo -e "${YELLOW}Fechando túnel SSH...${NC}" + exit 0 +} + +# Capturar Ctrl+C +trap cleanup INT + +echo -e "${YELLOW}📡 Configuração do Túnel:${NC}" +echo -e " Servidor Remoto: ${GREEN}$REMOTE_USER@$REMOTE_HOST${NC}" +echo -e " Porta Remota: ${GREEN}$REMOTE_PORT${NC}" +echo -e " Porta Local: ${GREEN}$LOCAL_PORT${NC}" +echo + +echo -e "${YELLOW}🔗 Estabelecendo túnel SSH...${NC}" + +# Criar o túnel SSH +# -N: Não executar comando remoto +# -L: Port forwarding local +# -o: Opções SSH para reconexão automática +ssh -N \ + -L $LOCAL_PORT:localhost:$REMOTE_PORT \ + -o ServerAliveInterval=60 \ + -o ServerAliveCountMax=3 \ + -o ExitOnForwardFailure=yes \ + -o StrictHostKeyChecking=no \ + -i $SSH_KEY \ + $REMOTE_USER@$REMOTE_HOST & + +SSH_PID=$! + +# Aguardar conexão +sleep 2 + +# Verificar se o túnel foi estabelecido +if kill -0 $SSH_PID 2>/dev/null; then + echo -e "${GREEN}✅ Túnel SSH estabelecido com sucesso!${NC}" + echo + echo -e "${BLUE}═══════════════════════════════════════════════════════${NC}" + echo -e "${GREEN}🎉 Acesse o Ultravox Chat no seu MacBook:${NC}" + echo + echo -e " ${BLUE}➜${NC} ${GREEN}http://localhost:$LOCAL_PORT${NC}" + echo -e " ${BLUE}➜${NC} ${GREEN}http://localhost:$LOCAL_PORT/ultravox-chat.html${NC}" + echo -e " ${BLUE}➜${NC} ${GREEN}http://localhost:$LOCAL_PORT/ultravox-chat-ios.html${NC}" + echo + echo -e "${BLUE}═══════════════════════════════════════════════════════${NC}" + echo + echo -e "${YELLOW}📌 Pressione Ctrl+C para fechar o túnel${NC}" + echo + + # Manter o túnel aberto + wait $SSH_PID +else + echo -e "${RED}❌ Falha ao estabelecer túnel SSH${NC}" + echo -e "${YELLOW}Verifique:${NC}" + echo -e " 1. O endereço do servidor está correto: $REMOTE_HOST" + echo -e " 2. Suas credenciais SSH estão configuradas" + echo -e " 3. O servidor está acessível" + echo -e " 4. A porta $REMOTE_PORT está ativa no servidor" + exit 1 +fi \ No newline at end of file diff --git a/ultravox/restart_ultravox.sh b/ultravox/restart_ultravox.sh new file mode 100755 index 0000000000000000000000000000000000000000..0349fb57bb46e18e4f996a520d122c5f6d1d4f4b --- /dev/null +++ b/ultravox/restart_ultravox.sh @@ -0,0 +1,39 @@ +#!/bin/bash + +# Script para reiniciar o servidor Ultravox com limpeza completa + +echo "🔄 Reiniciando servidor Ultravox..." +echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" +echo "" + +# 1. Executar script de parada +SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )" +echo "📍 Parando servidor atual..." +bash "$SCRIPT_DIR/stop_ultravox.sh" + +# 2. Aguardar um pouco mais para garantir liberação completa +echo "" +echo "⏳ Aguardando liberação completa de recursos..." +sleep 5 + +# 3. Verificar se realmente liberou +echo "🔍 Verificando liberação..." +if lsof -i :50051 >/dev/null 2>&1; then + echo " ⚠️ Porta 50051 ainda ocupada, forçando limpeza..." + kill -9 $(lsof -t -i:50051) 2>/dev/null + sleep 2 +fi + +# 4. Verificar GPU uma última vez +GPU_FREE=$(nvidia-smi --query-gpu=memory.free --format=csv,noheader,nounits 2>/dev/null | head -1) +if [ -n "$GPU_FREE" ] && [ "$GPU_FREE" -lt "20000" ]; then + echo " ⚠️ GPU com menos de 20GB livres, limpeza adicional..." + pkill -9 -f "python" 2>/dev/null + sleep 3 +fi + +# 5. Iniciar servidor +echo "" +echo "🚀 Iniciando novo servidor..." +echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" +bash "$SCRIPT_DIR/start_ultravox.sh" \ No newline at end of file diff --git a/ultravox/server.py b/ultravox/server.py index 59ca5bbe02714a5194813cb269ddd65bec445faf..5e34d0a2940ea162ee8eb98a83711ade3eba02ac 100644 --- a/ultravox/server.py +++ b/ultravox/server.py @@ -118,12 +118,10 @@ class UltravoxServicer(speech_pb2_grpc.SpeechServiceServicer): enforce_eager=True, # Desabilitar CUDA graphs para modelos customizados enable_prefix_caching=False, # Desabilitar cache de prefixo ) - # Parâmetros otimizados baseados nos testes + # Parâmetros otimizados baseados nos testes bem-sucedidos self.sampling_params = SamplingParams( - temperature=0.3, # Mais conservador para respostas consistentes - max_tokens=50, # Respostas mais concisas - repetition_penalty=1.1, # Evitar repetições - stop=[".", "!", "?", "\n\n"] # Parar em pontuação natural + temperature=0.2, # Temperatura baixa para respostas mais precisas + max_tokens=64 # Tokens suficientes para respostas completas ) self.pipeline = None # Não usar pipeline do Transformers @@ -216,11 +214,14 @@ class UltravoxServicer(speech_pb2_grpc.SpeechServiceServicer): logger.warning(f"Nenhum áudio recebido para sessão {session_id}") return - # Usar prompt padrão otimizado (formato que funciona!) + # SEMPRE incluir o token de áudio no prompt if not prompt: - # IMPORTANTE: Incluir o token <|audio|> que o Ultravox espera - prompt = "Você é um assistente brasileiro. <|audio|>\nResponda à pergunta que ouviu em português:" - logger.info("Usando prompt padrão com token <|audio|>") + prompt = "<|audio|>" + logger.info("Usando prompt simples com token de áudio") + elif "<|audio|>" not in prompt: + # Se tem prompt mas não tem o token de áudio, adicionar + prompt = f"{prompt}\n<|audio|>" + logger.info(f"Adicionando token <|audio|> ao prompt customizado") # Concatenar todo o áudio full_audio = np.concatenate(audio_chunks) @@ -244,26 +245,43 @@ class UltravoxServicer(speech_pb2_grpc.SpeechServiceServicer): from vllm import SamplingParams # Preparar entrada para vLLM com áudio - # Formato otimizado que funciona com Ultravox v0.5 - # GARANTIR que o prompt tenha o token <|audio|> - if "<|audio|>" not in prompt: - # Adicionar o token se não estiver presente - vllm_prompt = prompt.rstrip() + " <|audio|>\nResponda em português:" - logger.warning(f"Token <|audio|> não encontrado no prompt, adicionando automaticamente") + # Importar tokenizer para chat template + from transformers import AutoTokenizer + model_name = self.model_config['model_path'] + tokenizer = AutoTokenizer.from_pretrained(model_name) + + # Criar mensagem com token de áudio + if prompt and "<|audio|>" not in prompt: + user_content = f"<|audio|>\n{prompt}" + elif not prompt: + user_content = "<|audio|>\nResponda em português:" else: - vllm_prompt = prompt + user_content = prompt + + messages = [{"role": "user", "content": user_content}] + + # Aplicar chat template + formatted_prompt = tokenizer.apply_chat_template( + messages, + tokenize=False, + add_generation_prompt=True + ) + + # Criar tupla (audio, sample_rate) - formato esperado pelo vLLM + audio_tuple = (full_audio, sample_rate) # 🔍 LOG DETALHADO DO PROMPT PARA DEBUG logger.info(f"🔍 PROMPT COMPLETO enviado para vLLM:") - logger.info(f" 📝 Prompt original recebido: '{prompt[:200]}...'") - logger.info(f" 🎯 Prompt formatado final: '{vllm_prompt[:200]}...'") + logger.info(f" 📝 Prompt original: '{prompt[:100]}...'") + logger.info(f" 🎯 Prompt formatado: '{formatted_prompt[:100]}...'") logger.info(f" 🎵 Áudio shape: {full_audio.shape}, dtype: {full_audio.dtype}") logger.info(f" 📊 Áudio stats: min={full_audio.min():.3f}, max={full_audio.max():.3f}") logger.info("=" * 80) + vllm_input = { - "prompt": vllm_prompt, + "prompt": formatted_prompt, "multi_modal_data": { - "audio": full_audio # numpy array já em 16kHz + "audio": [audio_tuple] # Lista de tuplas (audio, sample_rate) } } diff --git a/ultravox/server_backup.py b/ultravox/server_backup.py new file mode 100644 index 0000000000000000000000000000000000000000..a245ab6447ebcd2cbb8cd0afa460e7e298c68a57 --- /dev/null +++ b/ultravox/server_backup.py @@ -0,0 +1,446 @@ +#!/usr/bin/env python3 +""" +Servidor Ultravox gRPC - Implementação com vLLM para aceleração +Usa vLLM quando disponível, fallback para Transformers +""" + +import grpc +import asyncio +import logging +import numpy as np +import time +import sys +import os +import torch +import transformers +from typing import Iterator, Optional +from concurrent import futures + +# Tentar importar vLLM +try: + from vllm import LLM, SamplingParams + VLLM_AVAILABLE = True + logger_vllm = logging.getLogger("vllm") + logger_vllm.info("✅ vLLM disponível - usando inferência acelerada") +except ImportError: + VLLM_AVAILABLE = False + logger_vllm = logging.getLogger("vllm") + logger_vllm.warning("⚠️ vLLM não disponível - usando Transformers padrão") + +# Adicionar paths para protos +sys.path.append('/workspace/ultravox-pipeline/services/ultravox') +sys.path.append('/workspace/ultravox-pipeline/protos/generated') + +import speech_pb2 +import speech_pb2_grpc + +logging.basicConfig( + level=logging.INFO, + format='%(asctime)s - %(name)s - %(levelname)s - %(message)s' +) +logger = logging.getLogger(__name__) + + +class UltravoxServicer(speech_pb2_grpc.SpeechServiceServicer): + """Implementação gRPC do Ultravox usando a arquitetura correta""" + + def __init__(self): + """Inicializa o serviço""" + logger.info("Inicializando Ultravox Service...") + + # Verificar GPU antes de inicializar + if not torch.cuda.is_available(): + logger.error("❌ GPU não disponível! Ultravox requer GPU para funcionar.") + logger.error("Verifique se CUDA está instalado e funcionando.") + raise RuntimeError("GPU não disponível. Ultravox não pode funcionar sem GPU.") + + # Forçar uso da GPU com mais memória livre + best_gpu = 0 + best_free = 0 + for i in range(torch.cuda.device_count()): + total = torch.cuda.get_device_properties(i).total_memory / (1024**3) + allocated = torch.cuda.memory_allocated(i) / (1024**3) + free = total - allocated + logger.info(f"GPU {i}: {torch.cuda.get_device_name(i)} - {free:.1f}GB livre de {total:.1f}GB") + if free > best_free: + best_free = free + best_gpu = i + + torch.cuda.set_device(best_gpu) + logger.info(f"✅ Usando GPU {best_gpu}: {torch.cuda.get_device_name(best_gpu)}") + logger.info(f" Memória livre: {best_free:.1f}GB") + + if best_free < 3.0: # Ultravox 1B precisa ~3GB + logger.warning(f"⚠️ Pouca memória GPU disponível ({best_free:.1f}GB). Recomendado: 3GB+") + + # Configuração do modelo usando Transformers Pipeline + self.model_config = { + 'model_path': "fixie-ai/ultravox-v0_5-llama-3_2-1b", # Modelo v0.5 com Llama-3.2-1B (funcionando com vLLM) + 'device': f"cuda:{best_gpu}", # GPU específica + 'max_new_tokens': 200, + 'temperature': 0.7, # Temperatura para respostas mais naturais + 'token': os.getenv('HF_TOKEN', '') # Token HuggingFace via env var + } + + # Pipeline de transformers (API estável) + self.pipeline = None + self.conversation_states = {} # Estado por sessão + + # Métricas + self.total_requests = 0 + self.active_sessions = 0 + self.total_tokens_generated = 0 + self._start_time = time.time() + + # Inicializar modelo + self._initialize_model() + + def _initialize_model(self): + """Inicializa o modelo Ultravox usando vLLM ou Transformers""" + try: + start_time = time.time() + + if not VLLM_AVAILABLE: + logger.error("❌ vLLM NÃO está instalado! Este servidor REQUER vLLM.") + logger.error("Instale com: pip install vllm") + raise RuntimeError("vLLM é obrigatório para este servidor") + + # USAR APENAS vLLM - SEM FALLBACK + logger.info("🚀 Carregando modelo Ultravox via vLLM (OBRIGATÓRIO)...") + + # vLLM para modelos multimodais + self.vllm_model = LLM( + model=self.model_config['model_path'], + trust_remote_code=True, + dtype="bfloat16", + gpu_memory_utilization=0.30, # 30% (~7.2GB) para ter memória suficiente + max_model_len=128, # Reduzir contexto para 128 tokens para economizar memória + enforce_eager=True, # Desabilitar CUDA graphs para modelos customizados + enable_prefix_caching=False, # Desabilitar cache de prefixo + ) + # Parâmetros otimizados baseados nos testes + self.sampling_params = SamplingParams( + temperature=0.3, # Mais conservador para respostas consistentes + max_tokens=50, # Respostas mais concisas + repetition_penalty=1.1, # Evitar repetições + stop=[".", "!", "?", "\n\n"] # Parar em pontuação natural + ) + self.pipeline = None # Não usar pipeline do Transformers + + load_time = time.time() - start_time + logger.info(f"✅ Modelo carregado em {load_time:.2f}s via vLLM") + logger.info("🎯 Usando vLLM para inferência acelerada!") + + except Exception as e: + logger.error(f"Erro ao carregar modelo: {e}") + raise + + def _get_conversation_state(self, session_id: str): + """Obtém ou cria estado de conversação para sessão""" + if session_id not in self.conversation_states: + self.conversation_states[session_id] = { + 'created_at': time.time(), + 'turn_count': 0, + 'conversation_history': [] + } + logger.info(f"Estado de conversação criado para sessão: {session_id}") + + return self.conversation_states[session_id] + + def _cleanup_old_sessions(self, max_age: int = 1800): # 30 minutos + """Remove sessões antigas""" + current_time = time.time() + expired_sessions = [ + sid for sid, state in self.conversation_states.items() + if current_time - state['created_at'] > max_age + ] + + for sid in expired_sessions: + del self.conversation_states[sid] + logger.info(f"Sessão expirada removida: {sid}") + + async def StreamingRecognize(self, + request_iterator, + context: grpc.ServicerContext) -> Iterator[speech_pb2.TranscriptToken]: + """ + Processa stream de áudio usando a arquitetura Ultravox completa + + Args: + request_iterator: Iterator de chunks de áudio + context: Contexto gRPC + + Yields: + Tokens de transcrição + resposta do LLM + """ + session_id = None + start_time = time.time() + self.total_requests += 1 + + try: + # Coletar todo o áudio primeiro (como no Gradio) + audio_chunks = [] + sample_rate = 16000 + prompt = None # Será obtido do metadata ou usado padrão + + # Processar chunks de entrada + async for audio_chunk in request_iterator: + if not session_id: + session_id = audio_chunk.session_id or f"session_{self.total_requests}" + logger.info(f"Nova sessão Ultravox: {session_id}") + self.active_sessions += 1 + + # DEBUG: Log todos os campos recebidos + logger.info(f"DEBUG - Chunk recebido para {session_id}:") + logger.info(f" - audio_data: {len(audio_chunk.audio_data)} bytes") + logger.info(f" - sample_rate: {audio_chunk.sample_rate}") + logger.info(f" - is_final_chunk: {audio_chunk.is_final_chunk}") + + # Obter prompt do campo system_prompt + if not prompt and audio_chunk.system_prompt: + prompt = audio_chunk.system_prompt + logger.info(f"✅ PROMPT DINÂMICO recebido: {prompt[:100]}...") + elif not audio_chunk.system_prompt: + logger.info(f"DEBUG - Sem system_prompt no chunk") + + sample_rate = audio_chunk.sample_rate or 16000 + + # CRUCIAL: Converter de bytes para numpy float32 (como descoberto no Gradio) + audio_data = np.frombuffer(audio_chunk.audio_data, dtype=np.float32) + audio_chunks.append(audio_data) + + # Se é chunk final, processar + if audio_chunk.is_final_chunk: + break + + if not audio_chunks: + logger.warning(f"Nenhum áudio recebido para sessão {session_id}") + return + + # SEMPRE incluir o token de áudio, mesmo com system_prompt + if prompt and "<|audio|>" not in prompt: + # Se tem prompt mas não tem o token de áudio, adicionar + prompt = f"{prompt}\n<|audio|>" + logger.info(f"Adicionando token <|audio|> ao prompt customizado") + elif not prompt: + # ⚠️ FORMATO SIMPLES QUE FUNCIONA COM ULTRAVOX v0.5! ⚠️ + # O token <|audio|> é substituído pelo áudio automaticamente + prompt = "<|audio|>" + logger.info("Usando prompt simples com apenas token de áudio") + + # Concatenar todo o áudio + full_audio = np.concatenate(audio_chunks) + logger.info(f"Áudio processado: {len(full_audio)} samples @ {sample_rate}Hz para sessão {session_id}") + + # Obter estado de conversação + conv_state = self._get_conversation_state(session_id) + conv_state['turn_count'] += 1 + + # Processar com vLLM ou Transformers + backend = "vLLM" if self.vllm_model else "Transformers" + logger.info(f"Iniciando inferência {backend} para sessão {session_id}") + inference_start = time.time() + + try: + # USAR APENAS vLLM - SEM FALLBACK + if not self.vllm_model: + raise RuntimeError("vLLM não está carregado! Este servidor REQUER vLLM.") + + # Usar vLLM para inferência acelerada (v0.10+ suporta Ultravox!) + from vllm import SamplingParams + + # USAR PROMPT DIRETO - Ultravox v0.5 com vLLM funciona melhor assim + # O token <|audio|> é substituído automaticamente pelo áudio + vllm_prompt = prompt + + # 🔍 LOG DETALHADO DO PROMPT PARA DEBUG + logger.info(f"🔍 PROMPT COMPLETO enviado para vLLM:") + logger.info(f" 🎯 Prompt: '{vllm_prompt[:200]}...'") + logger.info(f" 🎵 Áudio shape: {full_audio.shape}, dtype: {full_audio.dtype}") + logger.info(f" 📊 Áudio stats: min={full_audio.min():.3f}, max={full_audio.max():.3f}") + logger.info("=" * 80) + + vllm_input = { + "prompt": vllm_prompt, + "multi_modal_data": { + "audio": full_audio # numpy array já em 16kHz + } + } + + # Fazer inferência com vLLM + outputs = self.vllm_model.generate( + prompts=[vllm_input], + sampling_params=self.sampling_params + ) + + inference_time = time.time() - inference_start + logger.info(f"⚡ Inferência vLLM concluída em {inference_time*1000:.0f}ms") + + # 🔍 LOG DETALHADO DA RESPOSTA vLLM + logger.info(f"🔍 RESPOSTA DETALHADA do vLLM:") + logger.info(f" 📤 Outputs count: {len(outputs)}") + logger.info(f" 📤 Outputs[0].outputs count: {len(outputs[0].outputs)}") + + # Extrair resposta + response_text = outputs[0].outputs[0].text + logger.info(f" 📝 Resposta RAW: '{response_text}'") + logger.info(f" 📏 Tamanho resposta: {len(response_text)} chars") + + if not response_text: + response_text = "Desculpe, não consegui processar o áudio. Poderia repetir?" + logger.info(f" ⚠️ Resposta vazia, usando fallback") + else: + logger.info(f" ✅ Resposta válida recebida") + + logger.info(f" 🎯 Resposta final: '{response_text[:100]}...'") + logger.info("=" * 80) + + # Sem else - SEMPRE usar vLLM + + # Simular streaming dividindo a resposta em tokens + words = response_text.split() + token_count = 0 + + for word in words: + # Criar token de resposta + token = speech_pb2.TranscriptToken() + token.text = word + " " + token.confidence = 0.95 + token.is_final = False + token.timestamp_ms = int((time.time() - start_time) * 1000) + + # Metadados de emoção + token.emotion.emotion = speech_pb2.EmotionMetadata.NEUTRAL + token.emotion.confidence = 0.8 + + # Metadados de prosódia + token.prosody.speech_rate = 120.0 + token.prosody.pitch_mean = 150.0 + token.prosody.energy = -20.0 + token.prosody.pitch_variance = 50.0 + + token_count += 1 + self.total_tokens_generated += 1 + + logger.debug(f"Token {token_count}: '{word}' para sessão {session_id}") + + yield token + + # Pequena pausa para simular streaming + await asyncio.sleep(0.05) + + # Token final + final_token = speech_pb2.TranscriptToken() + final_token.text = "" # Token vazio indica fim + final_token.confidence = 1.0 + final_token.is_final = True + final_token.timestamp_ms = int((time.time() - start_time) * 1000) + + logger.info(f"✅ Processamento completo: {token_count} tokens, {inference_time*1000:.0f}ms") + + yield final_token + + except Exception as model_error: + logger.error(f"Erro no modelo Transformers: {model_error}") + # Retornar erro como token + error_token = speech_pb2.TranscriptToken() + error_token.text = f"Erro no processamento: {str(model_error)}" + error_token.confidence = 0.0 + error_token.is_final = True + error_token.timestamp_ms = int((time.time() - start_time) * 1000) + + yield error_token + + # Limpar sessões antigas periodicamente + if self.total_requests % 10 == 0: + self._cleanup_old_sessions() + + except Exception as e: + logger.error(f"Erro na transcrição para sessão {session_id}: {e}") + # Enviar token de erro + error_token = speech_pb2.TranscriptToken() + error_token.text = "" + error_token.confidence = 0.0 + error_token.is_final = True + error_token.timestamp_ms = int((time.time() - start_time) * 1000) + yield error_token + + finally: + if session_id: + self.active_sessions = max(0, self.active_sessions - 1) + processing_time = time.time() - start_time + logger.info(f"Sessão {session_id} concluída. Latência: {processing_time*1000:.2f}ms") + + async def GetMetrics(self, request: speech_pb2.Empty, + context: grpc.ServicerContext) -> speech_pb2.Metrics: + """Retorna métricas do serviço""" + import psutil + import torch + + metrics = speech_pb2.Metrics() + metrics.total_requests = self.total_requests + metrics.active_sessions = self.active_sessions + + # Latência média (placeholder) + metrics.average_latency_ms = 500.0 + + # Uso de GPU (sempre GPU conforme solicitado) + try: + metrics.gpu_usage_percent = float(torch.cuda.utilization()) + metrics.memory_usage_mb = float(torch.cuda.memory_allocated() / (1024 * 1024)) + except: + metrics.gpu_usage_percent = 0.0 + metrics.memory_usage_mb = 0.0 + + # Tokens por segundo (deve ser int64 conforme protobuf) + metrics.tokens_per_second = int(self.total_tokens_generated / max(1, time.time() - self._start_time)) + + return metrics + + +async def serve(): + """Inicia servidor gRPC""" + # Configurar servidor + server = grpc.aio.server( + futures.ThreadPoolExecutor(max_workers=10), + options=[ + ('grpc.max_send_message_length', 10 * 1024 * 1024), + ('grpc.max_receive_message_length', 10 * 1024 * 1024), + ('grpc.keepalive_time_ms', 30000), + ('grpc.keepalive_timeout_ms', 10000), + ('grpc.http2.min_time_between_pings_ms', 30000), + ] + ) + + # Adicionar serviço + speech_pb2_grpc.add_SpeechServiceServicer_to_server( + UltravoxServicer(), server + ) + + # Configurar porta + port = os.getenv('ULTRAVOX_PORT', '50051') + # Bind dual stack - IPv4 e IPv6 para compatibilidade + server.add_insecure_port(f'0.0.0.0:{port}') # IPv4 + server.add_insecure_port(f'[::]:{port}') # IPv6 + + logger.info(f"Ultravox Server iniciando na porta {port}...") + await server.start() + logger.info(f"Ultravox Server rodando na porta {port}") + + try: + await server.wait_for_termination() + except KeyboardInterrupt: + logger.info("Parando servidor...") + await server.stop(grace_period=5) + + +def main(): + """Função principal""" + try: + asyncio.run(serve()) + except Exception as e: + logger.error(f"Erro fatal: {e}") + sys.exit(1) + + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/ultravox/server_vllm_090_broken.py b/ultravox/server_vllm_090_broken.py new file mode 100644 index 0000000000000000000000000000000000000000..406247ea37801304667db89fa7d2f74e0c5646e0 --- /dev/null +++ b/ultravox/server_vllm_090_broken.py @@ -0,0 +1,447 @@ +#!/usr/bin/env python3 +""" +Servidor Ultravox gRPC - Implementação com vLLM para aceleração +Usa vLLM quando disponível, fallback para Transformers +""" + +import grpc +import asyncio +import logging +import numpy as np +import time +import sys +import os +import torch +import transformers +from typing import Iterator, Optional +from concurrent import futures + +# Tentar importar vLLM +try: + from vllm import LLM, SamplingParams + VLLM_AVAILABLE = True + logger_vllm = logging.getLogger("vllm") + logger_vllm.info("✅ vLLM disponível - usando inferência acelerada") +except ImportError: + VLLM_AVAILABLE = False + logger_vllm = logging.getLogger("vllm") + logger_vllm.warning("⚠️ vLLM não disponível - usando Transformers padrão") + +# Adicionar paths para protos +sys.path.append('/workspace/ultravox-pipeline/services/ultravox') +sys.path.append('/workspace/ultravox-pipeline/protos/generated') + +import speech_pb2 +import speech_pb2_grpc + +logging.basicConfig( + level=logging.INFO, + format='%(asctime)s - %(name)s - %(levelname)s - %(message)s' +) +logger = logging.getLogger(__name__) + + +class UltravoxServicer(speech_pb2_grpc.SpeechServiceServicer): + """Implementação gRPC do Ultravox usando a arquitetura correta""" + + def __init__(self): + """Inicializa o serviço""" + logger.info("Inicializando Ultravox Service...") + + # Verificar GPU antes de inicializar + if not torch.cuda.is_available(): + logger.error("❌ GPU não disponível! Ultravox requer GPU para funcionar.") + logger.error("Verifique se CUDA está instalado e funcionando.") + raise RuntimeError("GPU não disponível. Ultravox não pode funcionar sem GPU.") + + # Forçar uso da GPU com mais memória livre + best_gpu = 0 + best_free = 0 + for i in range(torch.cuda.device_count()): + total = torch.cuda.get_device_properties(i).total_memory / (1024**3) + allocated = torch.cuda.memory_allocated(i) / (1024**3) + free = total - allocated + logger.info(f"GPU {i}: {torch.cuda.get_device_name(i)} - {free:.1f}GB livre de {total:.1f}GB") + if free > best_free: + best_free = free + best_gpu = i + + torch.cuda.set_device(best_gpu) + logger.info(f"✅ Usando GPU {best_gpu}: {torch.cuda.get_device_name(best_gpu)}") + logger.info(f" Memória livre: {best_free:.1f}GB") + + if best_free < 3.0: # Ultravox 1B precisa ~3GB + logger.warning(f"⚠️ Pouca memória GPU disponível ({best_free:.1f}GB). Recomendado: 3GB+") + + # Configuração do modelo usando Transformers Pipeline + self.model_config = { + 'model_path': "fixie-ai/ultravox-v0_5-llama-3_2-1b", # Modelo v0.5 com Llama-3.2-1B + 'device': f"cuda:{best_gpu}", # GPU específica + 'max_new_tokens': 200, + 'temperature': 0.7, # Temperatura para respostas mais naturais + 'token': os.getenv('HF_TOKEN', '') # Token HuggingFace via env var + } + + # Pipeline de transformers (API estável) + self.pipeline = None + self.conversation_states = {} # Estado por sessão + + # Métricas + self.total_requests = 0 + self.active_sessions = 0 + self.total_tokens_generated = 0 + self._start_time = time.time() + + # Inicializar modelo + self._initialize_model() + + def _initialize_model(self): + """Inicializa o modelo Ultravox usando vLLM ou Transformers""" + try: + start_time = time.time() + + if not VLLM_AVAILABLE: + logger.error("❌ vLLM NÃO está instalado! Este servidor REQUER vLLM.") + logger.error("Instale com: pip install vllm") + raise RuntimeError("vLLM é obrigatório para este servidor") + + # USAR APENAS vLLM - SEM FALLBACK + logger.info("🚀 Carregando modelo Ultravox via vLLM (OBRIGATÓRIO)...") + + # vLLM para modelos multimodais - Gemma 3 27B com quantização INT4 + self.vllm_model = LLM( + model=self.model_config['model_path'], + trust_remote_code=True, + dtype="bfloat16", + gpu_memory_utilization=0.60, # Usar 60% da GPU para o modelo 27B quantizado + max_model_len=256, # Reduzir contexto para 256 tokens + enforce_eager=True, # Desabilitar CUDA graphs para modelos customizados + enable_prefix_caching=False, # Desabilitar cache de prefixo + ) + # Parâmetros otimizados baseados nos testes + self.sampling_params = SamplingParams( + temperature=0.3, # Mais conservador para respostas consistentes + max_tokens=50, # Respostas mais concisas + repetition_penalty=1.1, # Evitar repetições + stop=[".", "!", "?", "\n\n"] # Parar em pontuação natural + ) + self.pipeline = None # Não usar pipeline do Transformers + + load_time = time.time() - start_time + logger.info(f"✅ Modelo carregado em {load_time:.2f}s via vLLM") + logger.info("🎯 Usando vLLM para inferência acelerada!") + + except Exception as e: + logger.error(f"Erro ao carregar modelo: {e}") + raise + + def _get_conversation_state(self, session_id: str): + """Obtém ou cria estado de conversação para sessão""" + if session_id not in self.conversation_states: + self.conversation_states[session_id] = { + 'created_at': time.time(), + 'turn_count': 0, + 'conversation_history': [] + } + logger.info(f"Estado de conversação criado para sessão: {session_id}") + + return self.conversation_states[session_id] + + def _cleanup_old_sessions(self, max_age: int = 1800): # 30 minutos + """Remove sessões antigas""" + current_time = time.time() + expired_sessions = [ + sid for sid, state in self.conversation_states.items() + if current_time - state['created_at'] > max_age + ] + + for sid in expired_sessions: + del self.conversation_states[sid] + logger.info(f"Sessão expirada removida: {sid}") + + async def StreamingRecognize(self, + request_iterator, + context: grpc.ServicerContext) -> Iterator[speech_pb2.TranscriptToken]: + """ + Processa stream de áudio usando a arquitetura Ultravox completa + + Args: + request_iterator: Iterator de chunks de áudio + context: Contexto gRPC + + Yields: + Tokens de transcrição + resposta do LLM + """ + session_id = None + start_time = time.time() + self.total_requests += 1 + + try: + # Coletar todo o áudio primeiro (como no Gradio) + audio_chunks = [] + sample_rate = 16000 + prompt = None # Será obtido do metadata ou usado padrão + + # Processar chunks de entrada + async for audio_chunk in request_iterator: + if not session_id: + session_id = audio_chunk.session_id or f"session_{self.total_requests}" + logger.info(f"Nova sessão Ultravox: {session_id}") + self.active_sessions += 1 + + # DEBUG: Log todos os campos recebidos + logger.info(f"DEBUG - Chunk recebido para {session_id}:") + logger.info(f" - audio_data: {len(audio_chunk.audio_data)} bytes") + logger.info(f" - sample_rate: {audio_chunk.sample_rate}") + logger.info(f" - is_final_chunk: {audio_chunk.is_final_chunk}") + + # Obter prompt do campo system_prompt + if not prompt and audio_chunk.system_prompt: + prompt = audio_chunk.system_prompt + logger.info(f"✅ PROMPT DINÂMICO recebido: {prompt[:100]}...") + elif not audio_chunk.system_prompt: + logger.info(f"DEBUG - Sem system_prompt no chunk") + + sample_rate = audio_chunk.sample_rate or 16000 + + # CRUCIAL: Converter de bytes para numpy float32 (como descoberto no Gradio) + audio_data = np.frombuffer(audio_chunk.audio_data, dtype=np.float32) + audio_chunks.append(audio_data) + + # Se é chunk final, processar + if audio_chunk.is_final_chunk: + break + + if not audio_chunks: + logger.warning(f"Nenhum áudio recebido para sessão {session_id}") + return + + # Usar prompt padrão otimizado (formato que funciona!) + if not prompt: + # IMPORTANTE: Incluir o token <|audio|> que o Ultravox espera + # FALLBACK: Usar inglês simples que o modelo entende bem + prompt = "You are a helpful assistant. <|audio|>\nRespond in Portuguese:" + logger.info("Usando prompt SIMPLES em inglês com instrução para responder em português") + + # Concatenar todo o áudio + full_audio = np.concatenate(audio_chunks) + logger.info(f"Áudio processado: {len(full_audio)} samples @ {sample_rate}Hz para sessão {session_id}") + + # Obter estado de conversação + conv_state = self._get_conversation_state(session_id) + conv_state['turn_count'] += 1 + + # Processar com vLLM ou Transformers + backend = "vLLM" if self.vllm_model else "Transformers" + logger.info(f"Iniciando inferência {backend} para sessão {session_id}") + inference_start = time.time() + + try: + # USAR APENAS vLLM - SEM FALLBACK + if not self.vllm_model: + raise RuntimeError("vLLM não está carregado! Este servidor REQUER vLLM.") + + # Usar vLLM para inferência acelerada (v0.10+ suporta Ultravox!) + from vllm import SamplingParams + + # Preparar entrada para vLLM com áudio + # Formato otimizado que funciona com Ultravox v0.5 + # GARANTIR que o prompt tenha o token <|audio|> + if "<|audio|>" not in prompt: + # Adicionar o token se não estiver presente + vllm_prompt = prompt.rstrip() + " <|audio|>\nResponda em português:" + logger.warning(f"Token <|audio|> não encontrado no prompt, adicionando automaticamente") + else: + vllm_prompt = prompt + + # 🔍 LOG DETALHADO DO PROMPT PARA DEBUG + logger.info(f"🔍 PROMPT COMPLETO enviado para vLLM:") + logger.info(f" 📝 Prompt original recebido: '{prompt[:200]}...'") + logger.info(f" 🎯 Prompt formatado final: '{vllm_prompt[:200]}...'") + logger.info(f" 🎵 Áudio shape: {full_audio.shape}, dtype: {full_audio.dtype}") + logger.info(f" 📊 Áudio stats: min={full_audio.min():.3f}, max={full_audio.max():.3f}") + logger.info("=" * 80) + vllm_input = { + "prompt": vllm_prompt, + "multi_modal_data": { + "audio": full_audio # numpy array já em 16kHz + } + } + + # Fazer inferência com vLLM + outputs = self.vllm_model.generate( + prompts=[vllm_input], + sampling_params=self.sampling_params + ) + + inference_time = time.time() - inference_start + logger.info(f"⚡ Inferência vLLM concluída em {inference_time*1000:.0f}ms") + + # 🔍 LOG DETALHADO DA RESPOSTA vLLM + logger.info(f"🔍 RESPOSTA DETALHADA do vLLM:") + logger.info(f" 📤 Outputs count: {len(outputs)}") + logger.info(f" 📤 Outputs[0].outputs count: {len(outputs[0].outputs)}") + + # Extrair resposta + response_text = outputs[0].outputs[0].text + logger.info(f" 📝 Resposta RAW: '{response_text}'") + logger.info(f" 📏 Tamanho resposta: {len(response_text)} chars") + + if not response_text: + response_text = "Desculpe, não consegui processar o áudio. Poderia repetir?" + logger.info(f" ⚠️ Resposta vazia, usando fallback") + else: + logger.info(f" ✅ Resposta válida recebida") + + logger.info(f" 🎯 Resposta final: '{response_text[:100]}...'") + logger.info("=" * 80) + + # Sem else - SEMPRE usar vLLM + + # Simular streaming dividindo a resposta em tokens + words = response_text.split() + token_count = 0 + + for word in words: + # Criar token de resposta + token = speech_pb2.TranscriptToken() + token.text = word + " " + token.confidence = 0.95 + token.is_final = False + token.timestamp_ms = int((time.time() - start_time) * 1000) + + # Metadados de emoção + token.emotion.emotion = speech_pb2.EmotionMetadata.NEUTRAL + token.emotion.confidence = 0.8 + + # Metadados de prosódia + token.prosody.speech_rate = 120.0 + token.prosody.pitch_mean = 150.0 + token.prosody.energy = -20.0 + token.prosody.pitch_variance = 50.0 + + token_count += 1 + self.total_tokens_generated += 1 + + logger.debug(f"Token {token_count}: '{word}' para sessão {session_id}") + + yield token + + # Pequena pausa para simular streaming + await asyncio.sleep(0.05) + + # Token final + final_token = speech_pb2.TranscriptToken() + final_token.text = "" # Token vazio indica fim + final_token.confidence = 1.0 + final_token.is_final = True + final_token.timestamp_ms = int((time.time() - start_time) * 1000) + + logger.info(f"✅ Processamento completo: {token_count} tokens, {inference_time*1000:.0f}ms") + + yield final_token + + except Exception as model_error: + logger.error(f"Erro no modelo Transformers: {model_error}") + # Retornar erro como token + error_token = speech_pb2.TranscriptToken() + error_token.text = f"Erro no processamento: {str(model_error)}" + error_token.confidence = 0.0 + error_token.is_final = True + error_token.timestamp_ms = int((time.time() - start_time) * 1000) + + yield error_token + + # Limpar sessões antigas periodicamente + if self.total_requests % 10 == 0: + self._cleanup_old_sessions() + + except Exception as e: + logger.error(f"Erro na transcrição para sessão {session_id}: {e}") + # Enviar token de erro + error_token = speech_pb2.TranscriptToken() + error_token.text = "" + error_token.confidence = 0.0 + error_token.is_final = True + error_token.timestamp_ms = int((time.time() - start_time) * 1000) + yield error_token + + finally: + if session_id: + self.active_sessions = max(0, self.active_sessions - 1) + processing_time = time.time() - start_time + logger.info(f"Sessão {session_id} concluída. Latência: {processing_time*1000:.2f}ms") + + async def GetMetrics(self, request: speech_pb2.Empty, + context: grpc.ServicerContext) -> speech_pb2.Metrics: + """Retorna métricas do serviço""" + import psutil + import torch + + metrics = speech_pb2.Metrics() + metrics.total_requests = self.total_requests + metrics.active_sessions = self.active_sessions + + # Latência média (placeholder) + metrics.average_latency_ms = 500.0 + + # Uso de GPU (sempre GPU conforme solicitado) + try: + metrics.gpu_usage_percent = float(torch.cuda.utilization()) + metrics.memory_usage_mb = float(torch.cuda.memory_allocated() / (1024 * 1024)) + except: + metrics.gpu_usage_percent = 0.0 + metrics.memory_usage_mb = 0.0 + + # Tokens por segundo (deve ser int64 conforme protobuf) + metrics.tokens_per_second = int(self.total_tokens_generated / max(1, time.time() - self._start_time)) + + return metrics + + +async def serve(): + """Inicia servidor gRPC""" + # Configurar servidor + server = grpc.aio.server( + futures.ThreadPoolExecutor(max_workers=10), + options=[ + ('grpc.max_send_message_length', 10 * 1024 * 1024), + ('grpc.max_receive_message_length', 10 * 1024 * 1024), + ('grpc.keepalive_time_ms', 30000), + ('grpc.keepalive_timeout_ms', 10000), + ('grpc.http2.min_time_between_pings_ms', 30000), + ] + ) + + # Adicionar serviço + speech_pb2_grpc.add_SpeechServiceServicer_to_server( + UltravoxServicer(), server + ) + + # Configurar porta (IPv4 e IPv6) + port = os.getenv('ULTRAVOX_PORT', '50051') + server.add_insecure_port(f'0.0.0.0:{port}') # IPv4 + server.add_insecure_port(f'[::]:{port}') # IPv6 + + logger.info(f"Ultravox Server iniciando na porta {port}...") + await server.start() + logger.info(f"Ultravox Server rodando na porta {port}") + + try: + await server.wait_for_termination() + except KeyboardInterrupt: + logger.info("Parando servidor...") + await server.stop(grace_period=5) + + +def main(): + """Função principal""" + try: + asyncio.run(serve()) + except Exception as e: + logger.error(f"Erro fatal: {e}") + sys.exit(1) + + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/ultravox/server_working_original.py b/ultravox/server_working_original.py new file mode 100644 index 0000000000000000000000000000000000000000..18abf06e20d8fb03ae797a5f63f80626a1436419 --- /dev/null +++ b/ultravox/server_working_original.py @@ -0,0 +1,440 @@ +#!/usr/bin/env python3 +""" +Servidor Ultravox gRPC - Implementação com vLLM para aceleração +Usa vLLM quando disponível, fallback para Transformers +""" + +import grpc +import asyncio +import logging +import numpy as np +import time +import sys +import os +import torch +import transformers +from typing import Iterator, Optional +from concurrent import futures + +# Tentar importar vLLM +try: + from vllm import LLM, SamplingParams + VLLM_AVAILABLE = True + logger_vllm = logging.getLogger("vllm") + logger_vllm.info("✅ vLLM disponível - usando inferência acelerada") +except ImportError: + VLLM_AVAILABLE = False + logger_vllm = logging.getLogger("vllm") + logger_vllm.warning("⚠️ vLLM não disponível - usando Transformers padrão") + +# Adicionar paths para protos +sys.path.append('/workspace/ultravox-pipeline/services/ultravox') +sys.path.append('/workspace/ultravox-pipeline/protos/generated') + +import speech_pb2 +import speech_pb2_grpc + +logging.basicConfig( + level=logging.INFO, + format='%(asctime)s - %(name)s - %(levelname)s - %(message)s' +) +logger = logging.getLogger(__name__) + + +class UltravoxServicer(speech_pb2_grpc.SpeechServiceServicer): + """Implementação gRPC do Ultravox usando a arquitetura correta""" + + def __init__(self): + """Inicializa o serviço""" + logger.info("Inicializando Ultravox Service...") + + # Verificar GPU antes de inicializar + if not torch.cuda.is_available(): + logger.error("❌ GPU não disponível! Ultravox requer GPU para funcionar.") + logger.error("Verifique se CUDA está instalado e funcionando.") + raise RuntimeError("GPU não disponível. Ultravox não pode funcionar sem GPU.") + + # Forçar uso da GPU com mais memória livre + best_gpu = 0 + best_free = 0 + for i in range(torch.cuda.device_count()): + total = torch.cuda.get_device_properties(i).total_memory / (1024**3) + allocated = torch.cuda.memory_allocated(i) / (1024**3) + free = total - allocated + logger.info(f"GPU {i}: {torch.cuda.get_device_name(i)} - {free:.1f}GB livre de {total:.1f}GB") + if free > best_free: + best_free = free + best_gpu = i + + torch.cuda.set_device(best_gpu) + logger.info(f"✅ Usando GPU {best_gpu}: {torch.cuda.get_device_name(best_gpu)}") + logger.info(f" Memória livre: {best_free:.1f}GB") + + if best_free < 3.0: # Ultravox 1B precisa ~3GB + logger.warning(f"⚠️ Pouca memória GPU disponível ({best_free:.1f}GB). Recomendado: 3GB+") + + # Configuração do modelo usando Transformers Pipeline + self.model_config = { + 'model_path': "fixie-ai/ultravox-v0_5-llama-3_2-1b", # Modelo v0.5 com Llama-3.2-1B (funcionando com vLLM) + 'device': f"cuda:{best_gpu}", # GPU específica + 'max_new_tokens': 200, + 'temperature': 0.7, # Temperatura para respostas mais naturais + 'token': os.getenv('HF_TOKEN', '') # Token HuggingFace via env var + } + + # Pipeline de transformers (API estável) + self.pipeline = None + self.conversation_states = {} # Estado por sessão + + # Métricas + self.total_requests = 0 + self.active_sessions = 0 + self.total_tokens_generated = 0 + self._start_time = time.time() + + # Inicializar modelo + self._initialize_model() + + def _initialize_model(self): + """Inicializa o modelo Ultravox usando vLLM ou Transformers""" + try: + start_time = time.time() + + if not VLLM_AVAILABLE: + logger.error("❌ vLLM NÃO está instalado! Este servidor REQUER vLLM.") + logger.error("Instale com: pip install vllm") + raise RuntimeError("vLLM é obrigatório para este servidor") + + # USAR APENAS vLLM - SEM FALLBACK + logger.info("🚀 Carregando modelo Ultravox via vLLM (OBRIGATÓRIO)...") + + # vLLM para modelos multimodais + self.vllm_model = LLM( + model=self.model_config['model_path'], + trust_remote_code=True, + dtype="bfloat16", + gpu_memory_utilization=0.30, # Aumentar para 30% (~7.2GB de 24GB) + max_model_len=256, # Reduzir contexto para 256 tokens + enforce_eager=True, # Desabilitar CUDA graphs para modelos customizados + enable_prefix_caching=False, # Desabilitar cache de prefixo + ) + # Parâmetros otimizados baseados nos testes + self.sampling_params = SamplingParams( + temperature=0.3, # Mais conservador para respostas consistentes + max_tokens=50, # Respostas mais concisas + repetition_penalty=1.1, # Evitar repetições + stop=[".", "!", "?", "\n\n"] # Parar em pontuação natural + ) + self.pipeline = None # Não usar pipeline do Transformers + + load_time = time.time() - start_time + logger.info(f"✅ Modelo carregado em {load_time:.2f}s via vLLM") + logger.info("🎯 Usando vLLM para inferência acelerada!") + + except Exception as e: + logger.error(f"Erro ao carregar modelo: {e}") + raise + + def _get_conversation_state(self, session_id: str): + """Obtém ou cria estado de conversação para sessão""" + if session_id not in self.conversation_states: + self.conversation_states[session_id] = { + 'created_at': time.time(), + 'turn_count': 0, + 'conversation_history': [] + } + logger.info(f"Estado de conversação criado para sessão: {session_id}") + + return self.conversation_states[session_id] + + def _cleanup_old_sessions(self, max_age: int = 1800): # 30 minutos + """Remove sessões antigas""" + current_time = time.time() + expired_sessions = [ + sid for sid, state in self.conversation_states.items() + if current_time - state['created_at'] > max_age + ] + + for sid in expired_sessions: + del self.conversation_states[sid] + logger.info(f"Sessão expirada removida: {sid}") + + async def StreamingRecognize(self, + request_iterator, + context: grpc.ServicerContext) -> Iterator[speech_pb2.TranscriptToken]: + """ + Processa stream de áudio usando a arquitetura Ultravox completa + + Args: + request_iterator: Iterator de chunks de áudio + context: Contexto gRPC + + Yields: + Tokens de transcrição + resposta do LLM + """ + session_id = None + start_time = time.time() + self.total_requests += 1 + + try: + # Coletar todo o áudio primeiro (como no Gradio) + audio_chunks = [] + sample_rate = 16000 + prompt = None # Será obtido do metadata ou usado padrão + + # Processar chunks de entrada + async for audio_chunk in request_iterator: + if not session_id: + session_id = audio_chunk.session_id or f"session_{self.total_requests}" + logger.info(f"Nova sessão Ultravox: {session_id}") + self.active_sessions += 1 + + # DEBUG: Log todos os campos recebidos + logger.info(f"DEBUG - Chunk recebido para {session_id}:") + logger.info(f" - audio_data: {len(audio_chunk.audio_data)} bytes") + logger.info(f" - sample_rate: {audio_chunk.sample_rate}") + logger.info(f" - is_final_chunk: {audio_chunk.is_final_chunk}") + + # Obter prompt do campo system_prompt + if not prompt and audio_chunk.system_prompt: + prompt = audio_chunk.system_prompt + logger.info(f"✅ PROMPT DINÂMICO recebido: {prompt[:100]}...") + elif not audio_chunk.system_prompt: + logger.info(f"DEBUG - Sem system_prompt no chunk") + + sample_rate = audio_chunk.sample_rate or 16000 + + # CRUCIAL: Converter de bytes para numpy float32 (como descoberto no Gradio) + audio_data = np.frombuffer(audio_chunk.audio_data, dtype=np.float32) + audio_chunks.append(audio_data) + + # Se é chunk final, processar + if audio_chunk.is_final_chunk: + break + + if not audio_chunks: + logger.warning(f"Nenhum áudio recebido para sessão {session_id}") + return + + # Usar prompt padrão otimizado (formato que funciona!) + if not prompt: + prompt = """Você é um assistente brasileiro útil e conversacional. + Responda à pergunta que ouviu em português de forma natural e direta.""" + logger.info("Usando prompt padrão") + + # Concatenar todo o áudio + full_audio = np.concatenate(audio_chunks) + logger.info(f"Áudio processado: {len(full_audio)} samples @ {sample_rate}Hz para sessão {session_id}") + + # Obter estado de conversação + conv_state = self._get_conversation_state(session_id) + conv_state['turn_count'] += 1 + + # Processar com vLLM ou Transformers + backend = "vLLM" if self.vllm_model else "Transformers" + logger.info(f"Iniciando inferência {backend} para sessão {session_id}") + inference_start = time.time() + + try: + # USAR APENAS vLLM - SEM FALLBACK + if not self.vllm_model: + raise RuntimeError("vLLM não está carregado! Este servidor REQUER vLLM.") + + # Usar vLLM para inferência acelerada (v0.10+ suporta Ultravox!) + from vllm import SamplingParams + + # Preparar entrada para vLLM com áudio + # Formato otimizado que funciona com Ultravox v0.5 + # O prompt já vem formatado do cliente, usar diretamente + vllm_prompt = prompt + + # 🔍 LOG DETALHADO DO PROMPT PARA DEBUG + logger.info(f"🔍 PROMPT COMPLETO enviado para vLLM:") + logger.info(f" 📝 Prompt original recebido: '{prompt[:200]}...'") + logger.info(f" 🎯 Prompt formatado final: '{vllm_prompt[:200]}...'") + logger.info(f" 🎵 Áudio shape: {full_audio.shape}, dtype: {full_audio.dtype}") + logger.info(f" 📊 Áudio stats: min={full_audio.min():.3f}, max={full_audio.max():.3f}") + logger.info("=" * 80) + vllm_input = { + "prompt": vllm_prompt, + "multi_modal_data": { + "audio": full_audio # numpy array já em 16kHz + } + } + + # Fazer inferência com vLLM + outputs = self.vllm_model.generate( + prompts=[vllm_input], + sampling_params=self.sampling_params + ) + + inference_time = time.time() - inference_start + logger.info(f"⚡ Inferência vLLM concluída em {inference_time*1000:.0f}ms") + + # 🔍 LOG DETALHADO DA RESPOSTA vLLM + logger.info(f"🔍 RESPOSTA DETALHADA do vLLM:") + logger.info(f" 📤 Outputs count: {len(outputs)}") + logger.info(f" 📤 Outputs[0].outputs count: {len(outputs[0].outputs)}") + + # Extrair resposta + response_text = outputs[0].outputs[0].text + logger.info(f" 📝 Resposta RAW: '{response_text}'") + logger.info(f" 📏 Tamanho resposta: {len(response_text)} chars") + + if not response_text: + response_text = "Desculpe, não consegui processar o áudio. Poderia repetir?" + logger.info(f" ⚠️ Resposta vazia, usando fallback") + else: + logger.info(f" ✅ Resposta válida recebida") + + logger.info(f" 🎯 Resposta final: '{response_text[:100]}...'") + logger.info("=" * 80) + + # Sem else - SEMPRE usar vLLM + + # Simular streaming dividindo a resposta em tokens + words = response_text.split() + token_count = 0 + + for word in words: + # Criar token de resposta + token = speech_pb2.TranscriptToken() + token.text = word + " " + token.confidence = 0.95 + token.is_final = False + token.timestamp_ms = int((time.time() - start_time) * 1000) + + # Metadados de emoção + token.emotion.emotion = speech_pb2.EmotionMetadata.NEUTRAL + token.emotion.confidence = 0.8 + + # Metadados de prosódia + token.prosody.speech_rate = 120.0 + token.prosody.pitch_mean = 150.0 + token.prosody.energy = -20.0 + token.prosody.pitch_variance = 50.0 + + token_count += 1 + self.total_tokens_generated += 1 + + logger.debug(f"Token {token_count}: '{word}' para sessão {session_id}") + + yield token + + # Pequena pausa para simular streaming + await asyncio.sleep(0.05) + + # Token final + final_token = speech_pb2.TranscriptToken() + final_token.text = "" # Token vazio indica fim + final_token.confidence = 1.0 + final_token.is_final = True + final_token.timestamp_ms = int((time.time() - start_time) * 1000) + + logger.info(f"✅ Processamento completo: {token_count} tokens, {inference_time*1000:.0f}ms") + + yield final_token + + except Exception as model_error: + logger.error(f"Erro no modelo Transformers: {model_error}") + # Retornar erro como token + error_token = speech_pb2.TranscriptToken() + error_token.text = f"Erro no processamento: {str(model_error)}" + error_token.confidence = 0.0 + error_token.is_final = True + error_token.timestamp_ms = int((time.time() - start_time) * 1000) + + yield error_token + + # Limpar sessões antigas periodicamente + if self.total_requests % 10 == 0: + self._cleanup_old_sessions() + + except Exception as e: + logger.error(f"Erro na transcrição para sessão {session_id}: {e}") + # Enviar token de erro + error_token = speech_pb2.TranscriptToken() + error_token.text = "" + error_token.confidence = 0.0 + error_token.is_final = True + error_token.timestamp_ms = int((time.time() - start_time) * 1000) + yield error_token + + finally: + if session_id: + self.active_sessions = max(0, self.active_sessions - 1) + processing_time = time.time() - start_time + logger.info(f"Sessão {session_id} concluída. Latência: {processing_time*1000:.2f}ms") + + async def GetMetrics(self, request: speech_pb2.Empty, + context: grpc.ServicerContext) -> speech_pb2.Metrics: + """Retorna métricas do serviço""" + import psutil + import torch + + metrics = speech_pb2.Metrics() + metrics.total_requests = self.total_requests + metrics.active_sessions = self.active_sessions + + # Latência média (placeholder) + metrics.average_latency_ms = 500.0 + + # Uso de GPU (sempre GPU conforme solicitado) + try: + metrics.gpu_usage_percent = float(torch.cuda.utilization()) + metrics.memory_usage_mb = float(torch.cuda.memory_allocated() / (1024 * 1024)) + except: + metrics.gpu_usage_percent = 0.0 + metrics.memory_usage_mb = 0.0 + + # Tokens por segundo (deve ser int64 conforme protobuf) + metrics.tokens_per_second = int(self.total_tokens_generated / max(1, time.time() - self._start_time)) + + return metrics + + +async def serve(): + """Inicia servidor gRPC""" + # Configurar servidor + server = grpc.aio.server( + futures.ThreadPoolExecutor(max_workers=10), + options=[ + ('grpc.max_send_message_length', 10 * 1024 * 1024), + ('grpc.max_receive_message_length', 10 * 1024 * 1024), + ('grpc.keepalive_time_ms', 30000), + ('grpc.keepalive_timeout_ms', 10000), + ('grpc.http2.min_time_between_pings_ms', 30000), + ] + ) + + # Adicionar serviço + speech_pb2_grpc.add_SpeechServiceServicer_to_server( + UltravoxServicer(), server + ) + + # Configurar porta + port = os.getenv('ULTRAVOX_PORT', '50051') + server.add_insecure_port(f'[::]:{port}') + + logger.info(f"Ultravox Server iniciando na porta {port}...") + await server.start() + logger.info(f"Ultravox Server rodando na porta {port}") + + try: + await server.wait_for_termination() + except KeyboardInterrupt: + logger.info("Parando servidor...") + await server.stop(grace_period=5) + + +def main(): + """Função principal""" + try: + asyncio.run(serve()) + except Exception as e: + logger.error(f"Erro fatal: {e}") + sys.exit(1) + + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/ultravox/speech.proto b/ultravox/speech.proto new file mode 100644 index 0000000000000000000000000000000000000000..25150d90d83d35d7b740e9be4b83e79e3551e9a2 --- /dev/null +++ b/ultravox/speech.proto @@ -0,0 +1,94 @@ +syntax = "proto3"; + +package speech; + +service SpeechService { + // Streaming bidirecional para reconhecimento de fala + rpc StreamingRecognize(stream AudioChunk) returns (stream TranscriptToken); + + // Endpoint para métricas + rpc GetMetrics(Empty) returns (Metrics); +} + +// Chunk de áudio enviado pelo cliente +message AudioChunk { + bytes audio_data = 1; // PCM float32 + int32 sample_rate = 2; // Taxa de amostragem (16000) + int64 timestamp_ms = 3; // Timestamp em millisegundos + int32 sequence_number = 4; // Número de sequência + bool is_final_chunk = 5; // Indica fim do áudio + + // Metadados opcionais + float voice_activity_probability = 6; // Probabilidade de atividade de voz + string session_id = 7; // ID da sessão + string system_prompt = 8; // Prompt do sistema para contexto dinâmico + string user_prompt = 9; // Prompt do usuário (instrução específica) +} + +// Token de transcrição retornado +message TranscriptToken { + string text = 1; // Texto transcrito + float confidence = 2; // Confiança da transcrição + bool is_final = 3; // Token final da frase + int64 timestamp_ms = 4; // Timestamp + + // Metadados contextuais + EmotionMetadata emotion = 5; // Emoção detectada + ProsodyMetadata prosody = 6; // Prosódia detectada + + // Validação e diagnóstico + ValidationResult validation = 7; // Resultado da validação +} + +// Resultado da validação com erros específicos +message ValidationResult { + enum ValidationStatus { + VALID = 0; // Resposta válida + EMPTY_RESPONSE = 1; // Resposta vazia ou muito curta + GENERIC_ERROR = 2; // Resposta genérica de erro + AUDIO_QUALITY_ISSUE = 3; // Problemas de qualidade do áudio + PROMPT_FORMAT_ERROR = 4; // Formato de prompt inválido + MODEL_ERROR = 5; // Erro interno do modelo + RETRY_SUCCESSFUL = 6; // Retry foi bem-sucedido + } + + ValidationStatus status = 1; // Status da validação + string error_message = 2; // Mensagem de erro específica + string diagnostic_info = 3; // Informações técnicas de diagnóstico + bool retry_attempted = 4; // Se foi tentado um retry +} + +// Metadados de emoção +message EmotionMetadata { + enum Emotion { + NEUTRAL = 0; + HAPPY = 1; + SAD = 2; + ANGRY = 3; + SURPRISED = 4; + FEARFUL = 5; + } + Emotion emotion = 1; + float confidence = 2; +} + +// Metadados de prosódia +message ProsodyMetadata { + float speech_rate = 1; // Palavras por minuto + float pitch_mean = 2; // Pitch médio em Hz + float energy = 3; // Energia em dB + float pitch_variance = 4; // Variância do pitch +} + +// Métricas do serviço +message Metrics { + int64 total_requests = 1; + int64 active_sessions = 2; + float average_latency_ms = 3; + float gpu_usage_percent = 4; + float memory_usage_mb = 5; + int64 tokens_per_second = 6; +} + +// Mensagem vazia +message Empty {} \ No newline at end of file diff --git a/ultravox/start_ultravox.sh b/ultravox/start_ultravox.sh new file mode 100755 index 0000000000000000000000000000000000000000..5b5cacc79bad502e8697e2cd8e5c4c3e5a8c87af --- /dev/null +++ b/ultravox/start_ultravox.sh @@ -0,0 +1,67 @@ +#!/bin/bash + +# Script para iniciar o servidor Ultravox com limpeza de processos órfãos +# Evita problemas de memória GPU ocupada por processos vLLM antigos + +echo "🔧 Iniciando servidor Ultravox..." +echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" + +# 1. Limpar processos órfãos vLLM/EngineCore +echo "🧹 Limpando processos órfãos..." +pkill -f "VLLM::EngineCore" 2>/dev/null +pkill -f "vllm.*engine" 2>/dev/null +pkill -f "multiprocessing.resource_tracker.*ultravox" 2>/dev/null +pkill -f "python.*server.py" 2>/dev/null +sleep 2 + +# 2. Verificar memória GPU antes de iniciar +echo "📊 Verificando GPU..." +GPU_FREE=$(nvidia-smi --query-gpu=memory.free --format=csv,noheader,nounits 2>/dev/null | head -1) +GPU_TOTAL=$(nvidia-smi --query-gpu=memory.total --format=csv,noheader,nounits 2>/dev/null | head -1) + +if [ -n "$GPU_FREE" ] && [ -n "$GPU_TOTAL" ]; then + echo " GPU: ${GPU_FREE}MB livres de ${GPU_TOTAL}MB" + + # Verificar se tem pelo menos 20GB livres + if [ "$GPU_FREE" -lt "20000" ]; then + echo "⚠️ AVISO: Menos de 20GB livres na GPU!" + echo " Tentando limpar mais processos..." + + # Limpar mais agressivamente + pkill -9 -f "vllm" 2>/dev/null + pkill -9 -f "EngineCore" 2>/dev/null + sleep 3 + + # Verificar novamente + GPU_FREE=$(nvidia-smi --query-gpu=memory.free --format=csv,noheader,nounits 2>/dev/null | head -1) + echo " GPU após limpeza: ${GPU_FREE}MB livres" + fi +fi + +# 3. Verificar se a porta está livre +if lsof -i :50051 >/dev/null 2>&1; then + echo "⚠️ Porta 50051 em uso. Matando processo..." + kill -9 $(lsof -t -i:50051) 2>/dev/null + sleep 2 +fi + +# 4. Ativar ambiente virtual +echo "🐍 Ativando ambiente Python..." +cd /workspace/ultravox-pipeline/ultravox +source venv/bin/activate + +# 5. Iniciar servidor +echo "🚀 Iniciando servidor Ultravox..." +echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" +echo " Modelo: Ultravox v0.5 Llama 3.1-8B" +echo " Porta: 50051" +echo " GPU: 90% utilization" +echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" +echo "" +echo "📝 Logs do servidor:" +echo "" + +# Executar servidor com trap para limpeza ao sair +trap 'echo "🛑 Parando servidor..."; pkill -f "VLLM::EngineCore"; pkill -f "python.*server.py"' INT TERM + +python server.py \ No newline at end of file diff --git a/ultravox/stop_ultravox.sh b/ultravox/stop_ultravox.sh new file mode 100755 index 0000000000000000000000000000000000000000..f35d70eb2ea2b5b82e43a43606f02785a4e894cb --- /dev/null +++ b/ultravox/stop_ultravox.sh @@ -0,0 +1,60 @@ +#!/bin/bash + +# Script para parar o servidor Ultravox e limpar todos os processos relacionados + +echo "🛑 Parando servidor Ultravox..." +echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" + +# 1. Parar servidor principal +echo "📍 Parando processo principal..." +pkill -f "python.*server.py" 2>/dev/null + +# 2. Limpar processos vLLM +echo "🧹 Limpando processos vLLM..." +pkill -f "VLLM::EngineCore" 2>/dev/null +pkill -f "vllm.*engine" 2>/dev/null +pkill -f "multiprocessing.resource_tracker.*ultravox" 2>/dev/null + +# 3. Verificar porta 50051 +echo "🔍 Verificando porta 50051..." +if lsof -i :50051 >/dev/null 2>&1; then + echo " ⚠️ Porta ainda em uso, forçando encerramento..." + kill -9 $(lsof -t -i:50051) 2>/dev/null +fi + +# 4. Limpar processos órfãos mais agressivamente +echo "🔨 Limpeza final de processos..." +pkill -9 -f "VLLM::EngineCore" 2>/dev/null +pkill -9 -f "vllm" 2>/dev/null +pkill -9 -f "ultravox.*python" 2>/dev/null + +# 5. Aguardar liberação +sleep 3 + +# 6. Verificar memória GPU +echo "" +echo "📊 Status da GPU após limpeza:" +GPU_FREE=$(nvidia-smi --query-gpu=memory.free --format=csv,noheader,nounits 2>/dev/null | head -1) +GPU_TOTAL=$(nvidia-smi --query-gpu=memory.total --format=csv,noheader,nounits 2>/dev/null | head -1) +GPU_USED=$(nvidia-smi --query-gpu=memory.used --format=csv,noheader,nounits 2>/dev/null | head -1) + +if [ -n "$GPU_FREE" ]; then + echo " ✅ GPU: ${GPU_FREE}MB livres / ${GPU_USED}MB usados / ${GPU_TOTAL}MB total" +else + echo " ❌ Não foi possível verificar GPU" +fi + +# 7. Verificar processos restantes +echo "" +echo "🔍 Verificando processos restantes..." +REMAINING=$(ps aux | grep -E "vllm|ultravox|EngineCore" | grep -v grep | wc -l) +if [ "$REMAINING" -eq "0" ]; then + echo " ✅ Todos os processos foram encerrados" +else + echo " ⚠️ Ainda existem $REMAINING processos relacionados:" + ps aux | grep -E "vllm|ultravox|EngineCore" | grep -v grep +fi + +echo "" +echo "✅ Servidor Ultravox parado!" +echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" \ No newline at end of file diff --git a/ultravox/test-tts.py b/ultravox/test-tts.py new file mode 100644 index 0000000000000000000000000000000000000000..17eb0c7afc87ce58670109929b98c6f0c001e97f --- /dev/null +++ b/ultravox/test-tts.py @@ -0,0 +1,121 @@ +#!/usr/bin/env python3 +""" +Script de teste para Ultravox com TTS +Envia uma pergunta via áudio sintetizado e verifica a resposta +""" + +import grpc +import numpy as np +import asyncio +import time +from gtts import gTTS +from pydub import AudioSegment +import io +import sys +import os + +# Adicionar o path para os protobuffers +sys.path.append('/workspace/ultravox-pipeline/ultravox') +import speech_pb2 +import speech_pb2_grpc + +async def test_ultravox_with_tts(): + """Testa o Ultravox enviando áudio TTS com a pergunta 'Quanto é 2 + 2?'""" + + print("🎤 Iniciando teste do Ultravox com TTS...") + + # 1. Gerar áudio TTS com a pergunta + print("🔊 Gerando áudio TTS: 'Quanto é dois mais dois?'") + tts = gTTS(text="Quanto é dois mais dois?", lang='pt-br') + + # Salvar em buffer de memória + mp3_buffer = io.BytesIO() + tts.write_to_fp(mp3_buffer) + mp3_buffer.seek(0) + + # Converter MP3 para PCM 16kHz + audio = AudioSegment.from_mp3(mp3_buffer) + audio = audio.set_frame_rate(16000).set_channels(1).set_sample_width(2) + + # Converter para numpy array float32 + samples = np.array(audio.get_array_of_samples()).astype(np.float32) / 32768.0 + + print(f"✅ Áudio gerado: {len(samples)} samples @ 16kHz") + print(f" Duração: {len(samples)/16000:.2f} segundos") + + # 2. Conectar ao servidor Ultravox + print("\n📡 Conectando ao Ultravox na porta 50051...") + + try: + channel = grpc.aio.insecure_channel('localhost:50051') + stub = speech_pb2_grpc.UltravoxServiceStub(channel) + + # 3. Criar request com o áudio + session_id = f"test_{int(time.time())}" + + async def audio_generator(): + """Gera chunks de áudio para enviar""" + request = speech_pb2.AudioRequest() + request.session_id = session_id + request.audio_data = samples.tobytes() + request.sample_rate = 16000 + request.is_final_chunk = True + request.system_prompt = "Responda em português de forma simples e direta" + + print(f"📤 Enviando áudio para sessão: {session_id}") + yield request + + # 4. Enviar e receber resposta + print("\n⏳ Aguardando resposta do Ultravox...") + start_time = time.time() + + response_text = "" + token_count = 0 + + async for response in stub.TranscribeStream(audio_generator()): + if response.text: + response_text += response.text + token_count += 1 + print(f" Token {token_count}: '{response.text.strip()}'") + + if response.is_final: + break + + elapsed = time.time() - start_time + + # 5. Verificar resposta + print(f"\n📝 Resposta completa: '{response_text.strip()}'") + print(f"⏱️ Tempo de resposta: {elapsed:.2f}s") + print(f"📊 Tokens recebidos: {token_count}") + + # Verificar se a resposta contém "4" ou "quatro" + if "4" in response_text.lower() or "quatro" in response_text.lower(): + print("\n✅ SUCESSO! O Ultravox respondeu corretamente!") + else: + print("\n⚠️ AVISO: A resposta não contém '4' ou 'quatro'") + + await channel.close() + + except grpc.RpcError as e: + print(f"\n❌ Erro gRPC: {e.code()} - {e.details()}") + return False + except Exception as e: + print(f"\n❌ Erro: {e}") + return False + + return True + +if __name__ == "__main__": + print("=" * 60) + print("TESTE ULTRAVOX COM TTS") + print("=" * 60) + + # Executar teste + success = asyncio.run(test_ultravox_with_tts()) + + if success: + print("\n🎉 Teste concluído com sucesso!") + else: + print("\n❌ Teste falhou!") + + print("=" * 60) \ No newline at end of file diff --git a/ultravox/test_audio_coherence.py b/ultravox/test_audio_coherence.py new file mode 100644 index 0000000000000000000000000000000000000000..e9d33100f323badb6f6d3fda4512165747070f09 --- /dev/null +++ b/ultravox/test_audio_coherence.py @@ -0,0 +1,193 @@ +#!/usr/bin/env python3 +""" +Script de teste para verificar coerência das respostas do Ultravox +Envia áudio sintético com perguntas específicas e verifica as respostas +""" + +import grpc +import numpy as np +import sys +import time +from pathlib import Path + +# Adiciona o diretório ao path +sys.path.append(str(Path(__file__).parent)) + +import ultravox_service_pb2 +import ultravox_service_pb2_grpc + +def create_test_audio(text_prompt, duration=2.0, sample_rate=16000): + """ + Cria áudio de teste sintético simulando fala + Em produção, isso seria áudio real gravado + """ + # Simula padrão de fala com modulação + t = np.linspace(0, duration, int(sample_rate * duration)) + + # Frequências típicas da voz humana (100-300 Hz fundamental) + base_freq = 150 + 50 * np.sin(2 * np.pi * 0.5 * t) # Modulação lenta + + # Gera sinal complexo simulando voz + audio = np.zeros_like(t) + + # Adiciona harmônicos + for harmonic in range(1, 8): + freq = base_freq * harmonic + amplitude = 1.0 / harmonic # Harmônicos mais altos têm menor amplitude + audio += amplitude * np.sin(2 * np.pi * freq * t) + + # Adiciona envelope de amplitude (simula palavras) + envelope = 0.5 + 0.5 * np.sin(2 * np.pi * 2 * t) + audio *= envelope + + # Normaliza para float32 entre -1 e 1 + audio = audio / (np.max(np.abs(audio)) + 1e-10) + + return audio.astype(np.float32) + +def test_ultravox_coherence(): + """Testa a coerência das respostas do Ultravox""" + + print("=" * 60) + print("🎯 TESTE DE COERÊNCIA DO ULTRAVOX") + print("=" * 60) + + # Conecta ao servidor + try: + channel = grpc.insecure_channel('localhost:50051') + stub = ultravox_service_pb2_grpc.UltravoxServiceStub(channel) + print("✅ Conectado ao Ultravox em localhost:50051") + except Exception as e: + print(f"❌ Erro ao conectar: {e}") + return False + + # Define perguntas de teste e respostas esperadas (palavras-chave) + test_cases = [ + { + "pergunta": "Qual é o seu nome?", + "audio_duration": 1.5, + "keywords_pt": ["nome", "assistente", "sou", "chamo"], + "keywords_wrong": ["今天", "আজ", "weather", "time"] # Chinês, Bengali, Inglês + }, + { + "pergunta": "Que horas são agora?", + "audio_duration": 1.8, + "keywords_pt": ["hora", "tempo", "agora", "momento"], + "keywords_wrong": ["名字", "নাম", "name", "call"] + }, + { + "pergunta": "O que você fez hoje?", + "audio_duration": 2.0, + "keywords_pt": ["hoje", "fiz", "fez", "dia"], + "keywords_wrong": ["明天", "আগামীকাল", "tomorrow", "yesterday"] + } + ] + + results = [] + session_id = f"test_{int(time.time())}" + + for i, test in enumerate(test_cases, 1): + print(f"\n📝 Teste {i}: '{test['pergunta']}'") + print("-" * 40) + + # Cria áudio sintético + audio = create_test_audio(test['pergunta'], test['audio_duration']) + print(f" 🎤 Áudio criado: {len(audio)} samples @ 16kHz") + + # Prepara requisição + request = ultravox_service_pb2.ProcessRequest( + session_id=session_id, + audio_data=audio.tobytes(), + system_prompt="" # Deixa vazio para usar o prompt padrão + ) + + try: + # Envia e recebe resposta + response_text = "" + start_time = time.time() + + for response in stub.ProcessAudioStream([request]): + if response.token: + response_text += response.token + + latency = (time.time() - start_time) * 1000 + + print(f" 📝 Resposta: '{response_text}'") + print(f" ⏱️ Latência: {latency:.0f}ms") + + # Analisa a resposta + response_lower = response_text.lower() + + # Verifica se está em português + has_portuguese = any(kw in response_lower for kw in test['keywords_pt']) + has_wrong_lang = any(kw in response_text for kw in test['keywords_wrong']) + + # Detecta idioma pela presença de caracteres específicos + has_chinese = any('\u4e00' <= char <= '\u9fff' for char in response_text) + has_bengali = any('\u0980' <= char <= '\u09ff' for char in response_text) + + # Resultado do teste + if has_chinese: + status = "❌ FALHOU - Resposta em CHINÊS" + success = False + elif has_bengali: + status = "❌ FALHOU - Resposta em BENGALI" + success = False + elif not response_text: + status = "❌ FALHOU - Resposta vazia" + success = False + elif has_portuguese: + status = "✅ PASSOU - Resposta coerente em português" + success = True + else: + status = "⚠️ INCERTO - Resposta não identificada" + success = False + + print(f" {status}") + + results.append({ + "pergunta": test['pergunta'], + "resposta": response_text, + "success": success, + "status": status, + "latency": latency + }) + + except Exception as e: + print(f" ❌ Erro no teste: {e}") + results.append({ + "pergunta": test['pergunta'], + "resposta": f"ERRO: {e}", + "success": False, + "status": "❌ ERRO", + "latency": 0 + }) + + # Resumo dos resultados + print("\n" + "=" * 60) + print("📊 RESUMO DOS TESTES") + print("=" * 60) + + passed = sum(1 for r in results if r['success']) + total = len(results) + + for r in results: + emoji = "✅" if r['success'] else "❌" + print(f"{emoji} '{r['pergunta']}' -> {r['status']}") + if r['resposta'] and not r['success']: + print(f" Resposta recebida: '{r['resposta'][:100]}...'") + + print(f"\n📈 Taxa de sucesso: {passed}/{total} ({100*passed/total:.0f}%)") + + if passed == total: + print("🎉 TODOS OS TESTES PASSARAM! Ultravox respondendo coerentemente em português!") + elif passed > 0: + print("⚠️ PARCIAL: Alguns testes passaram, mas ainda há problemas de idioma") + else: + print("❌ FALHA TOTAL: Nenhum teste passou - respostas em idioma incorreto") + + return passed == total + +if __name__ == "__main__": + success = test_ultravox_coherence() + sys.exit(0 if success else 1) \ No newline at end of file diff --git a/ultravox/ultravox.pid b/ultravox/ultravox.pid new file mode 100644 index 0000000000000000000000000000000000000000..0f77d17cb1754fc75ab3a1e5b9455edeaad9fd9b --- /dev/null +++ b/ultravox/ultravox.pid @@ -0,0 +1 @@ +5179 diff --git a/web-interface/opus-decoder.js b/web-interface/opus-decoder.js new file mode 100644 index 0000000000000000000000000000000000000000..663cab3cf4273f0ca73890866dff23b6f40cb9a3 --- /dev/null +++ b/web-interface/opus-decoder.js @@ -0,0 +1,150 @@ +/** + * Opus Decoder para navegador + * Usa Web Audio API para decodificar Opus + */ + +class OpusDecoder { + constructor() { + this.audioContext = null; + this.isInitialized = false; + } + + async init(sampleRate = 24000) { + if (this.isInitialized) return; + + try { + // Criar AudioContext com taxa específica + this.audioContext = new (window.AudioContext || window.webkitAudioContext)({ + sampleRate: sampleRate + }); + + this.isInitialized = true; + console.log(`✅ OpusDecoder inicializado @ ${sampleRate}Hz`); + } catch (error) { + console.error('❌ Erro ao inicializar OpusDecoder:', error); + throw error; + } + } + + /** + * Decodifica Opus para PCM usando Web Audio API + * @param {ArrayBuffer} opusData - Dados Opus comprimidos + * @returns {Promise} - PCM decodificado + */ + async decode(opusData) { + if (!this.isInitialized) { + await this.init(); + } + + try { + // Web Audio API pode decodificar Opus nativamente se embrulhado em container + // Para Opus puro, precisamos criar um container WebM mínimo + const webmContainer = this.wrapOpusInWebM(opusData); + + // Decodificar usando Web Audio API + const audioBuffer = await this.audioContext.decodeAudioData(webmContainer); + + // Converter AudioBuffer para PCM Int16 + const pcmData = this.audioBufferToPCM(audioBuffer); + + console.log(`🔊 Opus decodificado: ${opusData.byteLength} bytes → ${pcmData.byteLength} bytes PCM`); + + return pcmData; + } catch (error) { + console.error('❌ Erro ao decodificar Opus:', error); + // Fallback: retornar dados originais se não conseguir decodificar + return opusData; + } + } + + /** + * Envolve dados Opus em container WebM mínimo + * @param {ArrayBuffer} opusData - Dados Opus puros + * @returns {ArrayBuffer} - WebM container com Opus + */ + wrapOpusInWebM(opusData) { + // Implementação simplificada - na prática, usaria uma biblioteca + // Por enquanto, assumimos que o navegador pode processar Opus diretamente + // se fornecido com headers apropriados + + // Para implementação real, considerar usar: + // - libopus.js (porta WASM do libopus) + // - opus-recorder (biblioteca JS para Opus) + + return opusData; // Placeholder + } + + /** + * Converte AudioBuffer para PCM Int16 + * @param {AudioBuffer} audioBuffer + * @returns {ArrayBuffer} PCM Int16 data + */ + audioBufferToPCM(audioBuffer) { + const length = audioBuffer.length; + const pcmData = new Int16Array(length); + const channelData = audioBuffer.getChannelData(0); // Mono + + // Converter Float32 para Int16 + for (let i = 0; i < length; i++) { + const sample = Math.max(-1, Math.min(1, channelData[i])); + pcmData[i] = sample * 0x7FFF; + } + + return pcmData.buffer; + } +} + +/** + * Alternativa: Usar biblioteca opus-decoder (mais robusta) + * npm install opus-decoder + */ +class OpusDecoderWASM { + constructor() { + this.decoder = null; + this.ready = false; + } + + async init(sampleRate = 24000, channels = 1) { + if (this.ready) return; + + try { + // Carregar opus-decoder WASM se disponível + if (typeof OpusDecoderWebAssembly !== 'undefined') { + const { OpusDecoderWebAssembly } = await import('opus-decoder'); + this.decoder = new OpusDecoderWebAssembly({ + channels: channels, + sampleRate: sampleRate + }); + await this.decoder.ready; + this.ready = true; + console.log('✅ OpusDecoderWASM pronto'); + } else { + throw new Error('opus-decoder não disponível'); + } + } catch (error) { + console.warn('⚠️ WASM decoder não disponível, usando fallback'); + // Fallback para decoder básico + this.decoder = new OpusDecoder(); + await this.decoder.init(sampleRate); + this.ready = true; + } + } + + async decode(opusData) { + if (!this.ready) { + await this.init(); + } + + if (this.decoder.decode) { + // Usar WASM decoder se disponível + return await this.decoder.decode(opusData); + } else { + // Fallback + return opusData; + } + } +} + +// Exportar para uso global +window.OpusDecoder = OpusDecoder; +window.OpusDecoderWASM = OpusDecoderWASM; \ No newline at end of file diff --git a/web-interface/ultravox-chat-backup.html b/web-interface/ultravox-chat-backup.html index fd228b18036734874085917eaca917ea2eaa14fb..68652be3c9780fb9d983203c9971ddffd00d2aaa 100644 --- a/web-interface/ultravox-chat-backup.html +++ b/web-interface/ultravox-chat-backup.html @@ -3,7 +3,8 @@ - Ultravox Chat - Assistente de Voz IA + Ultravox Chat PCM - Otimizado +
-

🎤 Simple Peer Echo Test

+

🚀 Ultravox PCM - Otimizado

- + Desconectado
- - + Latência: --ms +
+ +
+ +
- - + +
-
- 📤 Enviado: - 0 bytes +
+
Enviado
+
0 KB
-
- 📥 Recebido: - 0 bytes +
+
Recebido
+
0 KB
-
- ⏱️ Latência: - -- ms +
+
Formato
+
PCM
+
+
+
🎤 Voz
+
pf_dora
-
- -
-
- - - + +
+

🎵 Text-to-Speech Direto

+

Digite ou edite o texto abaixo e escolha uma voz para converter em áudio

+ +
+ +
+ +
+ + + + +
+ + + + +
+ \ No newline at end of file diff --git a/web-interface/ultravox-chat-ios.html b/web-interface/ultravox-chat-ios.html new file mode 100644 index 0000000000000000000000000000000000000000..b95806d2fc02c6ea57ccf3364bc22d1e11b338bc --- /dev/null +++ b/web-interface/ultravox-chat-ios.html @@ -0,0 +1,1843 @@ + + + + + + + + Ultravox AI Assistant + + + + + + + + + + + + +
+
+ Refreshing... +
+ +
+ + + + +
+ + +
+ +
+ + +

Push to Talk

+ +
+ + Disconnected +
+
+ + +
+
+ +
+ +
+ info +

Connect to start using voice assistant

+

Click the connect button below to begin

+
+ + +
+ +
+ + +
+ + + +
+ + +
+
+
+
0
+
KB Sent
+
+
+
+
0
+
KB Received
+
+
+
+
--
+
MS Latency
+
+
+
+ + +
+
+

📝 Conversation History

+ +
+
+
+

No messages yet. Connect and start talking!

+
+
+
+ + +
+
+

Ready to connect

+
+
+ + + + + + +
+
+
+ + +
+
+

Text to Speech

+ +
+ +
+ +
+ +
+ + + +
+ + +
+
+ + +
+
+

Console Output

+
+ +
+ + +
+
+
+ + +
+
+

Voice Settings

+ +
+
Default Voice
+ +
+ +
+
Audio Settings
+
+ Auto-play responses +
+
+
+ Echo cancellation +
+
+
+ Noise suppression +
+
+
+
+ +
+

About

+

+ Ultravox AI Assistant v1.0
+ Powered by advanced speech recognition and synthesis.
+
+ Format: PCM 16-bit @ 24kHz
+ Protocol: WebSocket + gRPC +

+
+
+
+
+ + + + + + + \ No newline at end of file diff --git a/web-interface/ultravox-chat-material.html b/web-interface/ultravox-chat-material.html new file mode 100644 index 0000000000000000000000000000000000000000..afa130eb925ef3f2068eb757c0761b7a5d56adbb --- /dev/null +++ b/web-interface/ultravox-chat-material.html @@ -0,0 +1,1116 @@ + + + + + + Ultravox Chat PCM - Material Design + + + + + + + + + + + + +
+ +
+

+ rocket_launch + Ultravox PCM - Otimizado +

+ + +
+ + Desconectado + Latência: --ms +
+ + +
+
+ +
+
    +
  • + + 🇧🇷 [pf_dora] Português Feminino (Dora) +
  • +
  • + + 🇧🇷 [pm_alex] Português Masculino (Alex) +
  • +
  • + + 🌍 [af_heart] Alternativa Feminina (Heart) +
  • +
  • + + 🌍 [af_bella] Alternativa Feminina (Bella) +
  • +
+
+
+ + +
+ + +
+ + + +
+ + +
+
+
Enviado
+
0 KB
+
+
+
Recebido
+
0 KB
+
+
+
Formato
+
PCM
+
+
+
🎤 Voz
+
pf_dora
+
+
+ + +
+
+ + +
+

+ record_voice_over + Text-to-Speech Direto +

+

Digite ou edite o texto abaixo e escolha uma voz para converter em áudio

+ + + + + +
+
+ +
+
    + + +
  • + [pf_dora] Feminino - Dora +
  • +
  • + [pm_alex] Masculino - Alex +
  • +
  • + [pm_santa] Masculino - Santa +
  • + + +
  • + Feminino - Alloy +
  • +
  • + Feminino - Bella +
  • +
  • + Feminino - Heart +
  • +
  • + Masculino - Adam +
  • +
  • + Masculino - Echo +
  • +
+
+
+ + + + + +
+ + + + + + +
+
+ + + + + + + + \ No newline at end of file diff --git a/web-interface/ultravox-chat-opus.html b/web-interface/ultravox-chat-opus.html new file mode 100644 index 0000000000000000000000000000000000000000..20a53f44244604c831fdf60df8052acc05b7e3f2 --- /dev/null +++ b/web-interface/ultravox-chat-opus.html @@ -0,0 +1,581 @@ + + + + + + Ultravox Chat - Opus Edition + + + + +
+
+
+
+
+

+ 🎙️ Ultravox Chat - WebRTC Pipeline + OPUS +

+ Gravação e envio em Opus codec (compressão eficiente) +
+
+
+
+ + Desconectado +
+ +
+ +
+ +
Segure para falar
+
+ +
+ + +
+ +
+ +
+
+
+
+
+ +
+
+
📊 Métricas
+
+ Codec: + Opus +
+
+ Bitrate: + 32 kbps +
+
+ Taxa de Compressão: + - +
+
+ Latência Total: + - +
+
+ Tempo de Gravação: + - +
+
+ Taxa de Áudio: + 48 kHz +
+
+ Tamanho do Áudio: + - +
+
+ +
+
+
🐛 Debug Log
+
+
+
+
+
+
+ + + + \ No newline at end of file diff --git a/web-interface/ultravox-chat-original.html b/web-interface/ultravox-chat-original.html new file mode 100644 index 0000000000000000000000000000000000000000..68652be3c9780fb9d983203c9971ddffd00d2aaa --- /dev/null +++ b/web-interface/ultravox-chat-original.html @@ -0,0 +1,964 @@ + + + + + + Ultravox Chat PCM - Otimizado + + + + +
+

🚀 Ultravox PCM - Otimizado

+ +
+
+ + Desconectado +
+ Latência: --ms +
+ +
+ + +
+ +
+ + +
+ +
+
+
Enviado
+
0 KB
+
+
+
Recebido
+
0 KB
+
+
+
Formato
+
PCM
+
+
+
🎤 Voz
+
pf_dora
+
+
+ +
+
+ + +
+

🎵 Text-to-Speech Direto

+

Digite ou edite o texto abaixo e escolha uma voz para converter em áudio

+ +
+ +
+ +
+ + + + +
+ + + + +
+ + + + \ No newline at end of file diff --git a/web-interface/ultravox-chat-tailwind.html b/web-interface/ultravox-chat-tailwind.html new file mode 100644 index 0000000000000000000000000000000000000000..399283dc5922d91cb852b03d578a8d02dea858ba --- /dev/null +++ b/web-interface/ultravox-chat-tailwind.html @@ -0,0 +1,393 @@ + + + + + + Ultravox Chat - Real-time Voice Assistant + + + + + +
+ +
+

+ Ultravox Chat +

+

Real-time Voice Assistant

+
+ + +
+
+ Connection Status + + Disconnected + +
+ + +
+
+ + +
+
+
+ + +
+ + + + + +
+ + +
+

+ + + + Activity Log +

+
+
Waiting for connection...
+
+
+ + +
+ + Debug Information + +
+
Sample Rate: 24000 Hz
+
Buffer Size: 4096
+
Latency: --
+
+
+
+ + + + \ No newline at end of file diff --git a/web-interface/ultravox-chat.html b/web-interface/ultravox-chat.html index 8132b67970a9be37d323244a1560689690d37914..68652be3c9780fb9d983203c9971ddffd00d2aaa 100644 --- a/web-interface/ultravox-chat.html +++ b/web-interface/ultravox-chat.html @@ -4,6 +4,7 @@ Ultravox Chat PCM - Otimizado +