Si bien OpenAI está retrasando el lanzamiento de los modos de voz avanzados para ChatGPT, quiero compartir cómo creamos nuestra aplicación de voz LLM y la integramos en un stand interactivo.
A finales de febrero, Bali acogió el festival Lampu , organizado según los principios del famoso Burning Man. Según su tradición, los participantes crean sus propias instalaciones y objetos de arte.
Mis amigos del Campamento 19:19 y yo, inspirados por la idea de los confesionarios católicos y las capacidades de los LLM actuales, se nos ocurrió la idea de construir nuestro propio confesionario de IA, donde cualquiera pudiera hablar con una inteligencia artificial.
Así es como lo imaginamos desde el principio:
Para probar el concepto y comenzar a experimentar con un mensaje para el LLM, creé una implementación sencilla en una noche:
Para implementar esta demostración, confié completamente en los modelos de nube de OpenAI: Whisper , GPT-4 y TTS . Gracias a la excelente biblioteca Speech_recognition , construí la demostración en solo unas pocas docenas de líneas 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())
Los problemas que teníamos que resolver se hicieron evidentes inmediatamente después de las primeras pruebas de esta demostración:
Teníamos la opción de resolver estos problemas: buscando una solución de ingeniería o de producto adecuada.
Antes incluso de empezar a codificar, tuvimos que decidir cómo interactuaría el usuario con el stand:
Para detectar un nuevo usuario en el stand, consideramos varias opciones: sensores de apertura de puertas, sensores de peso del piso, sensores de distancia y un modelo cámara + YOLO. El sensor de distancia detrás de la espalda nos pareció el más fiable, ya que excluía activaciones accidentales, como cuando la puerta no estaba lo suficientemente cerrada, y no requería una instalación complicada, a diferencia del sensor de peso.
Para evitar el desafío de reconocer el principio y el final de un diálogo, decidimos agregar un gran botón rojo para controlar el micrófono. Esta solución también permitió al usuario interrumpir la IA en cualquier momento.
Teníamos muchas ideas diferentes sobre cómo implementar comentarios al procesar una solicitud. Nos decidimos por una opción con una pantalla que muestra lo que está haciendo el sistema: escuchar el micrófono, procesar una pregunta o responder.
También consideramos una opción bastante inteligente con un teléfono fijo antiguo. La sesión comenzaría cuando el usuario levantara el teléfono y el sistema escucharía al usuario hasta que colgara. Sin embargo, decidimos que es más auténtico cuando el usuario es "respondido" por la cabina en lugar de por una voz desde el teléfono.
Al final, el flujo de usuarios final quedó así:
Arduino monitorea el estado del sensor de distancia y del botón rojo. Envía todos los cambios a nuestro backend vía HTTP API, lo que permite al sistema determinar si el usuario ha entrado o salido de la cabina y si es necesario activar la escucha del micrófono o comenzar a generar una respuesta.
La interfaz de usuario web es solo una página web abierta en un navegador que recibe continuamente el estado actual del sistema desde el backend y se lo muestra al usuario.
El backend controla el micrófono, interactúa con todos los modelos de IA necesarios y expresa las respuestas del LLM. Contiene la lógica central de la aplicación.
Cómo codificar un boceto para Arduino, conectar correctamente el sensor de distancia y el botón y ensamblarlo todo en la cabina es un tema para un artículo aparte. Repasemos brevemente lo que obtuvimos sin entrar en detalles técnicos.
Usamos un Arduino, más precisamente, el modelo ESP32 con un módulo Wi-Fi incorporado. El microcontrolador estaba conectado a la misma red Wi-Fi que la computadora portátil, que ejecutaba el backend.
Lista completa de hardware que utilizamos:
Los componentes principales del canal son Speech-To-Text (STT), LLM y Text-To-Speech (TTS). Para cada tarea, hay muchos modelos diferentes disponibles tanto localmente como a través de la nube.
Como no teníamos una GPU potente a mano, decidimos optar por versiones de los modelos basadas en la nube. La debilidad de este enfoque es la necesidad de una buena conexión a Internet. Sin embargo, la velocidad de interacción después de todas las optimizaciones fue aceptable, incluso con el Internet móvil que teníamos en el festival.
Ahora, echemos un vistazo más de cerca a cada componente de la tubería.
Muchos dispositivos modernos admiten desde hace mucho tiempo el reconocimiento de voz. Por ejemplo, Apple Speech API está disponible para iOS y macOS, y Web Speech API está disponible para navegadores.
Desafortunadamente, son de calidad muy inferior a Whisper o Deepgram y no pueden detectar automáticamente el idioma.
Para reducir el tiempo de procesamiento, la mejor opción es reconocer el habla en tiempo real mientras el usuario habla. A continuación se muestran algunos proyectos con ejemplos de cómo implementarlos:
Con nuestra computadora portátil, la velocidad del reconocimiento de voz utilizando este enfoque resultó estar lejos del tiempo real. Después de varios experimentos, nos decidimos por el modelo Whisper de OpenAI basado en la nube.
El resultado del modelo Speech To Text del paso anterior es el texto que enviamos al LLM con el historial de diálogo.
Al elegir un LLM, comparamos GPT-3.5. GPT-4 y Claude. Resultó que el factor clave no era tanto el modelo específico sino su configuración. Al final, nos decidimos por GPT-4, cuyas respuestas nos gustaron más que las demás.
La personalización del mensaje para los modelos LLM se ha convertido en una forma de arte separada. Hay muchas guías en Internet sobre cómo ajustar su modelo según sus necesidades:
Tuvimos que experimentar extensamente con los ajustes de temperatura y avisos para que el modelo respondiera de manera atractiva, concisa y con humor.
Expresamos la respuesta recibida del LLM utilizando el modelo Text-To-Speech y se la reproducimos al usuario. Este paso fue la principal fuente de retrasos en nuestra demostración.
Los LLM tardan bastante en responder. Sin embargo, admiten la generación de respuestas en modo streaming, token por token. Podemos utilizar esta función para optimizar el tiempo de espera expresando frases individuales a medida que se reciben sin esperar una respuesta completa del LLM.
Usamos el tiempo mientras el usuario escucha el fragmento inicial para ocultar el retraso en el procesamiento de las partes restantes de la respuesta del LLM. Gracias a este enfoque, el retraso de respuesta se produce sólo al principio y es de ~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; }
Incluso con todas nuestras optimizaciones, un retraso de 3 a 4 segundos sigue siendo significativo. Decidimos cuidar la interfaz de usuario con comentarios para evitar que el usuario tenga la sensación de que la respuesta está bloqueada. Analizamos varios enfoques:
Nos decidimos por la última opción con una página web simple que sondea el backend y muestra animaciones según el estado actual.
Nuestra sala de confesión de IA funcionó durante cuatro días y atrajo a cientos de asistentes. Gastamos alrededor de $50 en API de OpenAI. A cambio recibimos numerosos comentarios positivos y valiosas impresiones.
Este pequeño experimento demostró que es posible agregar una interfaz de voz intuitiva y eficiente a un LLM incluso con recursos limitados y condiciones externas desafiantes.
Por cierto, las fuentes backend disponibles en GitHub