Embora a OpenAI esteja atrasando o lançamento dos modos de voz avançados para ChatGPT, quero compartilhar como construímos nosso aplicativo de voz LLM e o integramos em uma cabine interativa.
No final de fevereiro, Bali sediou o festival Lampu , organizado de acordo com os princípios do famoso Burning Man. Seguindo sua tradição, os participantes criam suas próprias instalações e objetos de arte.
Meus amigos do Camp 19:19 e eu, inspirados pela ideia dos confessionários católicos e pelas capacidades dos atuais LLMs, tivemos a ideia de construir nosso próprio confessionário de IA, onde qualquer pessoa pudesse conversar com uma inteligência artificial.
Veja como imaginamos isso no início:
Para testar o conceito e começar a experimentar um prompt para o LLM, criei uma implementação ingênua em uma noite:
Para implementar esta demonstração, confiei inteiramente nos modelos de nuvem da OpenAI: Whisper , GPT-4 e TTS . Graças à excelente biblioteca speech_recognition , construí a demonstração em apenas algumas dezenas de linhas de código.
import os import asyncio from dotenv import load_dotenv from io import BytesIO from openai import AsyncOpenAI from soundfile import SoundFile import sounddevice as sd import speech_recognition as sr load_dotenv() aiclient = AsyncOpenAI( api_key=os.environ.get("OPENAI_API_KEY") ) SYSTEM_PROMPT = """ You are helpfull assistant. """ async def listen_mic(recognizer: sr.Recognizer, microphone: sr.Microphone): audio_data = recognizer.listen(microphone) wav_data = BytesIO(audio_data.get_wav_data()) wav_data.name = "SpeechRecognition_audio.wav" return wav_data async def say(text: str): res = await aiclient.audio.speech.create( model="tts-1", voice="alloy", response_format="opus", input=text ) buffer = BytesIO() for chunk in res.iter_bytes(chunk_size=4096): buffer.write(chunk) buffer.seek(0) with SoundFile(buffer, 'r') as sound_file: data = sound_file.read(dtype='int16') sd.play(data, sound_file.samplerate) sd.wait() async def respond(text: str, history): history.append({"role": "user", "content": text}) completion = await aiclient.chat.completions.create( model="gpt-4", temperature=0.5, messages=history, ) response = completion.choices[0].message.content await say(response) history.append({"role": "assistant", "content": response}) async def main() -> None: m = sr.Microphone() r = sr.Recognizer() messages = [{"role": "system", "content": SYSTEM_PROMPT}] with m as source: r.adjust_for_ambient_noise(source) while True: wav_data = await listen_mic(r, source) transcript = await aiclient.audio.transcriptions.create( model="whisper-1", temperature=0.5, file=wav_data, response_format="verbose_json", ) if transcript.text == '' or transcript.text is None: continue await respond(transcript.text, messages) if __name__ == '__main__': asyncio.run(main())
Os problemas que tivemos que resolver tornaram-se imediatamente aparentes após os primeiros testes desta demonstração:
Tivemos que escolher como resolver esses problemas: procurando uma solução de engenharia ou de produto adequada.
Antes mesmo de começarmos a codificar, tivemos que decidir como o usuário interagiria com o estande:
Para detectar um novo usuário no estande, consideramos diversas opções: sensores de abertura de portas, sensores de peso do piso, sensores de distância e modelo câmera + YOLO. O sensor de distância atrás nos pareceu o mais confiável, pois excluía acionamentos acidentais, como quando a porta não estava bem fechada, e não exigia instalação complicada, ao contrário do sensor de peso.
Para evitar o desafio de reconhecer o início e o fim de um diálogo, decidimos adicionar um grande botão vermelho para controlar o microfone. Esta solução também permitiu ao usuário interromper a IA a qualquer momento.
Tínhamos muitas ideias diferentes sobre a implementação de feedback no processamento de uma solicitação. Optamos por uma opção com tela que mostra o que o sistema está fazendo: ouvindo o microfone, processando uma pergunta ou respondendo.
Também consideramos uma opção bastante inteligente com um telefone fixo antigo. A sessão começaria quando o usuário atendesse o telefone e o sistema ouviria o usuário até que ele desligasse. No entanto, decidimos que é mais autêntico quando o usuário é “atendido” pela cabine do que por uma voz do telefone.
No final, o fluxo final do usuário ficou assim:
O Arduino monitora o estado do sensor de distância e do botão vermelho. Ele envia todas as alterações para nosso backend via API HTTP, o que permite ao sistema determinar se o usuário entrou ou saiu do estande e se é necessário ativar a escuta do microfone ou começar a gerar uma resposta.
A UI da web é apenas uma página da web aberta em um navegador que recebe continuamente o estado atual do sistema do backend e o exibe ao usuário.
O backend controla o microfone, interage com todos os modelos de IA necessários e dá voz às respostas do LLM. Ele contém a lógica central do aplicativo.
Como codificar um esboço para Arduino, conectar corretamente o sensor de distância e o botão e montar tudo no estande é assunto para um artigo à parte. Vamos revisar brevemente o que obtivemos sem entrar em detalhes técnicos.
Utilizamos um Arduino, mais precisamente, o modelo ESP32 com módulo Wi-Fi embutido. O microcontrolador estava conectado à mesma rede Wi-Fi do laptop, que executava o backend.
Lista completa de hardware que usamos:
Os principais componentes do pipeline são Speech-To-Text (STT), LLM e Text-To-Speech (TTS). Para cada tarefa, muitos modelos diferentes estão disponíveis localmente e através da nuvem.
Como não tínhamos uma GPU poderosa em mãos, decidimos optar por versões dos modelos baseadas em nuvem. O ponto fraco desta abordagem é a necessidade de uma boa conexão com a Internet. Apesar disso, a velocidade de interação após todas as otimizações foi aceitável, mesmo com a Internet móvel que tivemos no festival.
Agora, vamos dar uma olhada mais de perto em cada componente do pipeline.
Muitos dispositivos modernos há muito suportam o reconhecimento de fala. Por exemplo, a Apple Speech API está disponível para iOS e macOS, e a Web Speech API está disponível para navegadores.
Infelizmente, eles são de qualidade muito inferior ao Whisper ou Deepgram e não conseguem detectar o idioma automaticamente.
Para reduzir o tempo de processamento, a melhor opção é reconhecer a fala em tempo real enquanto o usuário fala. Aqui estão alguns projetos com exemplos de como implementá-los:
Com nosso laptop, a velocidade de reconhecimento de fala usando essa abordagem ficou longe de ser em tempo real. Após vários experimentos, decidimos pelo modelo Whisper baseado em nuvem da OpenAI.
O resultado do modelo Speech To Text da etapa anterior é o texto que enviamos para o LLM com o histórico do diálogo.
Ao escolher um LLM, comparamos o GPT-3.5. GPT-4 e Cláudio. Descobriu-se que o fator chave não era tanto o modelo específico, mas sim a sua configuração. No final das contas, optamos pelo GPT-4, cujas respostas gostamos mais do que as outras.
A personalização do prompt para modelos LLM tornou-se uma forma de arte separada. Existem muitos guias na Internet sobre como ajustar seu modelo conforme necessário:
Tivemos que experimentar extensivamente as configurações de prompt e temperatura para fazer o modelo responder de forma envolvente, concisa e bem-humorada.
Expressamos a resposta recebida do LLM usando o modelo Text-To-Speech e a reproduzimos para o usuário. Esta etapa foi a principal fonte de atrasos em nossa demonstração.
Os LLMs demoram muito para responder. No entanto, eles suportam a geração de resposta em modo streaming – token por token. Podemos usar esse recurso para otimizar o tempo de espera, expressando frases individuais à medida que são recebidas, sem esperar por uma resposta completa do LLM.
Usamos o tempo enquanto o usuário escuta o fragmento inicial para ocultar o atraso no processamento das partes restantes da resposta do LLM. Graças a esta abordagem, o atraso de resposta ocorre apenas no início e é de aproximadamente 3 segundos.
async generateResponse(history) { const completion = await this.ai.completion(history); const chunks = new DialogChunks(); for await (const chunk of completion) { const delta = chunk.choices[0]?.delta?.content; if (delta) { chunks.push(delta); if (chunks.hasCompleteSentence()) { const sentence = chunks.popSentence(); this.voice.ttsAndPlay(sentence); } } } const sentence = chunks.popSentence(); if (sentence) { this.voice.say(sentence); } return chunks.text; }
Mesmo com todas as nossas otimizações, um atraso de 3 a 4 segundos ainda é significativo. Decidimos cuidar da IU com feedback para evitar que o usuário sinta que a resposta está travada. Vimos várias abordagens:
Optamos pela última opção com uma página web simples que pesquisa o backend e mostra animações de acordo com o estado atual.
Nossa sala de confissão de IA funcionou durante quatro dias e atraiu centenas de participantes. Gastamos cerca de US$ 50 em APIs OpenAI. Em troca, recebemos feedback positivo substancial e impressões valiosas.
Este pequeno experimento mostrou que é possível adicionar uma interface de voz intuitiva e eficiente a um LLM mesmo com recursos limitados e condições externas desafiadoras.
A propósito, as fontes de back-end disponíveis no GitHub