En este tutorial, presentaré cómo crear un chat emergente de IA simple que se puede agregar a cualquier sitio web. El cliente podrá responder al chat escribiendo y hablando con el bot.
Usaremos herramientas de OpenAI para las funcionalidades de IA. Para el chat usaremos ChatGPT, para el STT (voz a texto), Whisper, y para el TTS (texto a voz), su TTS.
Mostraré varios métodos para implementar la aplicación, desde un método ingenuo o básico hasta un método mejor pero también más complejo.
La aplicación se implementará en JavaScript (ECMAScript). Sin embargo, lea el capítulo final si está interesado en implementaciones en otros idiomas.
Todo el código está disponible en mi repositorio de GitHub .
En este capítulo, veremos los conceptos básicos de la aplicación: la estructura del proyecto y los paquetes que utilizamos.
El proyecto utilizará los siguientes paquetes:
Nombre del paquete | Descripción |
---|---|
| Para el servidor HTTP y el enrutamiento |
| Para todas las cosas de OpenAI |
| Para convertir los archivos de estilo SASS a archivos CSS |
| Para los WebSockets |
La estructura del proyecto es la siguiente:
Camino | Descripción |
---|---|
| El directorio expuesto a Internet con el nombre |
| El directorio que contiene archivos de audio públicos. |
| El directorio que contiene imágenes públicas. |
| El punto de entrada |
| El directorio que contiene el estilo de la página. |
| El directorio de código fuente de implementación ingenuo |
| El mejor directorio de código fuente de implementación |
El proyecto se puede ver aquí , y donde se enumerará un código también contendrá la ruta relativa a donde se puede encontrar ese código.
Ejecute npm install
seguido de npm run build
para convertir el archivo SASS a CSS y estará listo para comenzar.
Para iniciar la implementación ingenua, ejecute npm run start-v1
, o para ejecutar la mejor implementación, ejecute npm run start-v2
. No olvide definir la variable de entorno OPENAI_API_KEY
.
En sistemas UNIX puedes ejecutar:
OPENAI_API_KEY=YOU_API_KEY npm run start-v1`
Y en Windows:
set OPENAI_API_KEY=YOU_API_KEY npm run start-v1
Esta es la página que deberías ver cuando accedas a la página:
La implementación ingenua utiliza solicitudes y respuestas HTTP para enviar y recibir datos del servidor.
Revisaremos cada función importante. Todo el código se puede encontrar en el enlace mencionado anteriormente.
Aquí hay un diagrama de actividades de cómo funcionará la aplicación:
Veamos qué sucede cuando el usuario presiona Intro en el elemento de entrada de texto:
/* version-1/client.js */ inputTextElement.addEventListener('keydown', async (event) => { if (event.code !== 'Enter') return; if (!inputTextElement.value) return; const message = inputTextElement.value; inputTextElement.value = ""; inputTextElement.disabled = true; inputSpeechElement.disabled = true; await addMessage('user', message); inputTextElement.disabled = false; inputSpeechElement.disabled = false; });
Una vez que el usuario presiona la tecla Intro y la entrada no está vacía, desactivaremos tanto la entrada de texto como el botón de audio para que el usuario no envíe otro mensaje mientras recibimos una respuesta al mensaje anterior. Una vez que obtengamos la respuesta, restauramos la funcionalidad.
Después de desactivar la entrada, llamamos a la función principal, addMessage
, que hace la magia. Veámoslo:
/* version-1/client.js */ /** * Add a new message to the chat. * @async * * @param {MessageType} type the type of message * @param {String|Audio} message the data of the message * @param {Object} [settings] additional settings * @param {Number} [settings.audioLength] the length of the audio in seconds * @returns {Promise} the promise resolved when all is done */ async function addMessage(type, message, settings = {}) { const newMsg = document.createElement('div'); newMsg.classList.add('message'); if (type === MessageType.User) { newMsg.classList.add('user'); newMsg.innerHTML = message; } else if (type === MessageType.UserAudio) { newMsg.classList.add('user', 'audio'); newMsg.innerHTML = 'Audio message'; } else { newMsg.classList.add(MessageType.Bot); } const msgsCnt = document.getElementById('friendly-bot-container-msgs'); msgsCnt.appendChild(newMsg); // Keeping own history log if (type === MessageType.User || type === MessageType.Bot) { messageHistory.push({ role: type === MessageType.User ? 'user' : 'assistant', content: message }); } if (type === MessageType.Bot) { if (Settings.UseWriteEffect) { // Create a write effect when the bot responds let speed = Settings.DefaultTypingSpeed; if (settings.audioLength) { const ms = settings.audioLength * 1000 + ((message.match(/,/g) || []).length * 40) + ((message.match(/\./g) || []).length * 70); speed = ms / message.length; } for (let i=0, length=message.length; i < length; i += 1) { newMsg.innerHTML += message.charAt(i); await sleep(speed); } } else { newMsg.innerHTML = message; } } else if (type === MessageType.User || type === MessageType.UserAudio) { let response; if (type === MessageType.User) { response = await sendMessage({ message }); } else if (type === MessageType.UserAudio) { response = await sendMessage({ audio: message }); } if (response.audio) { const audio = convertBase64ToAudio(response.audio); playAudio(audio); } return addMessage(MessageType.Bot, response.answer); } }
La función creará un nuevo HTMLDivElement
para el nuevo mensaje y agregará la clase CSS según el tipo de mensaje.
Una vez hecho esto, almacenamos el mensaje en nuestro historial de chat del lado del cliente.
A continuación, si el mensaje a agregar es del bot, lo mostramos usando un "efecto de escritura". Intentamos sincronizar el audio, si existe, con la velocidad de escritura dividiendo la duración del audio por el número de caracteres del mensaje.
Si el mensaje agregado es del usuario, lo enviamos al servidor para obtener la respuesta de AI llamando a la función sendMessage
.
La función sendMessage
simplemente realiza una solicitud HTTP mediante fetch
en nuestro servidor.
Una cosa para mencionar: generamos una identificación aleatoria para cada cliente que enviamos con cada mensaje para que el servidor sepa de dónde obtener el historial de chat.
La alternativa a enviar un ID de identificación al servidor sería enviar el historial completo cada vez, pero con cada mensaje aumentan los datos que deben enviarse.
/* version-1/client.js */ /** * Create a random ID of given length. * Taken from https://stackoverflow.com/a/1349426 * * @param {Number} length the length of the generated ID * @returns {String} the generated ID */ function makeID(length) { let result = ''; const characters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'; const charactersLength = characters.length; let counter = 0; while (counter < length) { result += characters.charAt(Math.floor(Math.random() * charactersLength)); counter += 1; } return result; } const ChatID = makeID(10); // ... /** * Send a message to the server and return the JSON back. * @async * * @param {Object} data the data to send * @returns {Promise<Object>} the result from the server */ async function sendMessage(data = {}) { try { const response = await fetch(Settings.APISendMessage, { method: "POST", headers: { "Content-Type": "application/json", }, body: JSON.stringify({ id: ChatID, ...data }), }); return response.json(); } catch (error) { console.error("Error:", error); } }
Antes de ir al lado del servidor para ver cómo maneja la solicitud, veamos qué sucede cuando el usuario hace clic en el botón de audio:
/* version-1/client.js */ inputSpeechElement.addEventListener('click', async (_event) => { inputTextElement.value = ""; inputTextElement.disabled = true; inputSpeechElement.disabled = true; const stopRecordButtonElement = document.getElementById('friendly-bot-container-stop-record'); const base64Audio = await recordUserAudio(stopRecordButtonElement); await addMessage(MessageType.UserAudio, base64Audio.substring(`data:${Settings.ClientAudioMimeType};base64,`.length)); inputTextElement.disabled = false; inputSpeechElement.disabled = false; });
Es muy similar al manejo de entrada de texto. La función recordUserAudio
devolverá un audio codificado en base64, y simplemente cortamos el encabezado antes de enviarlo a addMessage
.
La función recordUserAudio
intentará obtener permiso del usuario para grabar audio y, si se le concede, creará un MediaRecorder
y comenzará a grabar. También mostraremos algunos elementos de la interfaz de usuario para informarle al usuario que estamos grabando su voz y un botón para detener la grabación cuando termine.
Una vez que se presiona el botón de detener, convertimos los fragmentos de audio en un objeto Blob
y el blob en una cadena codificada en base64 y lo devolvemos.
También revisamos cada pista de audio, las detenemos y luego las eliminamos. Esto es necesario porque, al menos en Chrome, llamar mediaRecorder.stop()
no detendrá el estado de "escucha" del micrófono.
Codificar el audio en base64
no es un método muy eficiente para enviar el audio al servidor, pero es un método muy sencillo. Veremos otro método para enviar el audio al servidor en la sección La mejor implementación .
/* version-1/client.js */ /** * Record the user and return it as an base64 encoded audio. * @async * * @param {HTMLElement} stopRecordButtonElement the stop button element * @returns {Promise<String>} the base64 encoded audio */ async function recordUserAudio(stopRecordButtonElement) { let stream; try { stream = await navigator.mediaDevices.getUserMedia({ audio: true }); } catch (error) { console.error(`The following getUserMedia error occurred: ${error}`); return; } let chunks = []; const mediaRecorder = new MediaRecorder(stream, { mimeType: Settings.ClientAudioMimeType }); return new Promise((resolve, reject) => { const onStopClick = () => { mediaRecorder.stop(); stopRecordButtonElement.classList.remove('show'); }; mediaRecorder.addEventListener('dataavailable', (event) => { chunks.push(event.data); }); mediaRecorder.addEventListener('stop', (_event) => { const blob = new Blob(chunks, { type: Settings.ClientAudioMimeType }); chunks = []; const base64AudioPromise = blobToBase64(blob); stopRecordButtonElement.removeEventListener('click', onStopClick); // Stop the audio listening stream.getAudioTracks().forEach((track) => { track.stop() stream.removeTrack(track); }); base64AudioPromise.then(resolve).catch(reject); }); stopRecordButtonElement.classList.add('show'); mediaRecorder.start(); stopRecordButtonElement.addEventListener('click', onStopClick); }) }
Veamos ahora cómo se maneja la solicitud en el servidor:
/* version-1/server.js */ app.post('/api/message', async (req, res) => { if (!req.body.message && !req.body.audio) { res.status(400).send('Missing "message" or "audio"'); } if (req.body.message && req.body.audio) { res.status(400).send('Cannot be both "message" and "audio"'); } if (!req.body.id) { res.status(400).send('Missing "id"'); } const messages = getChatMessages(chatHistory, req.body.id); if (req.body.message) { messages.push({ role: "user", content: req.body.message }); } else { let content; try { content = await stt(openai, req.body.audio); } catch (error) { console.error(`(ChatID: ${req.body.id}) Error when trying to convert the user's audio to text:`); console.error(error); res.status(500).end(); return; } messages.push({ role: "user", content }); } let answer; try { answer = await getAnswer(openai, messages); } catch (error) { console.error(`(ChatID: ${req.body.id}) Error when trying to get an answer from ChatGPT:`); console.error(error); res.status(500).end(); return; } let audio; if (Settings.CreateAudioOfAnswer) { try { audio = await tts(openai, answer); } catch (error) { console.error(`(ChatID: ${req.body.id}) Error when trying to convert the ChatGPT's answer to audio:`); console.error(error); res.status(500).end(); return; } } messages.push({ role: "assistant", content: answer }); res.json({ answer, audio }); });
Después de verificar que el cliente envió los datos requeridos, obtenemos el historial de chat para la ID dada (o creamos un nuevo historial de chat):
/* version-1/server.js */ /** * Get the chat history, or create a new one or the given ID. * * @param {Object} chatHistory the global chat history object containing all the chats * @param {String} id the ID of the chat to retrieve * @returns {Object} the chat history for the given `id` */ function getChatMessages(chatHistory, id) { if (!chatHistory[id]) { chatHistory[id] = [ { role: "system", content: Settings.AISystemContent }, { role: "assistant", content: Settings.WelcomeMessage } ]; } return chatHistory[id]; }
Luego, si el mensaje recibido es un texto, no un audio, agregamos el mensaje al historial de chat. Si el mensaje recibido es un audio, llamamos a la función stt
que realizará la acción de conversión de voz a texto usando Whisper de OpenAI.
La función utilizará el método openai.audio.transcriptions.create
. El parámetro principal de este método es file
, que debe representar nuestros datos de audio. Usamos la función toFile
del paquete openai/uploads
para convertir nuestro archivo de audio codificado en base64 en un archivo que OpenAI pueda leer. La función devolverá la transcripción del audio dado.
/* version-1/server.js */ /** * Convert speech to text using OpenAI. * @async * * @param {OpenAI} openai the OpenAI instance * @param {String} audio the base64 encoded audio * @returns {Promise<String>} the text */ async function stt(openai, audio) { // Documentation https://platform.openai.com/docs/api-reference/audio/createTranscription const transcription = await openai.audio.transcriptions.create({ file: await toFile(Buffer.from(audio, 'base64'), `audio.${Settings.ClientAudioExtension}`), model: Settings.STTModel, language: Settings.ClientAudioLanguage, // this is optional but helps the model }); return transcription.text; }
Ahora que tenemos el mensaje, enviamos el chat a ChatGPT y esperamos una respuesta llamando a la función getAnswer
.
/* version-1/server.js */ /** * * @param {*} openai * @param {*} messages * @returns */ async function getAnswer(openai, messages) { // Documentation https://platform.openai.com/docs/api-reference/chat/create const completion = await openai.chat.completions.create({ messages, model: Settings.ChatGPTModel, }); return completion.choices[0].message.content; }
La última parte trata de convertir la respuesta de la IA a audio usando la función tts
que usa el método openai.audio.speech.create
para crear un archivo de audio. El TTS de OpenAI admite múltiples formatos, pero hemos elegido mp3
para este tutorial.
Una vez obtenidos los datos de audio, los convertimos en un Buffer
y, desde allí, en una cadena de audio codificada en base64 para enviarla al cliente.
/* version-1/server.js */ /** * Convert text to speech. * @async * * @param {*} openai * @param {*} input * @returns */ async function tts(openai, input) { // Documentation https://platform.openai.com/docs/api-reference/audio/createSpeech const mp3 = await openai.audio.speech.create({ model: Settings.TTSModel, voice: Settings.TTSVoice, input, response_format: Settings.TTSFormat }); return Buffer.from(await mp3.arrayBuffer()).toString('base64'); }
¿Pero podemos mejorarlo? Bueno, sí. En lugar de utilizar solicitudes HTTP, podemos utilizar WebSockets para comunicarnos entre el cliente y el servidor y decirle a ChatGPT que devuelva los resultados como una secuencia. De esta manera, podemos crear un efecto de escritura en tiempo real porque transmitiremos el resultado desde ChatGPT al cliente en tiempo real.
Esta implementación tiene un inconveniente, pero sólo porque utilizamos el TTS de OpenAI, que acepta un máximo de 3 solicitudes por minuto. Por lo tanto, para esta implementación, eliminaremos la función de texto a voz, pero daré consejos sobre cómo volver a implementarla y qué buscar al hacerlo.
Así que veamos algo de código. Comenzamos desde la implementación anterior y cambiamos lo que se necesitaba para admitir WebSockets.
/* version-2/client.js */ const ws = new WebSocket(Settings.WSAddress); const ChatID = makeID(10); // When the connection to the server is made send the chat ID ws.addEventListener('open', () => { const idMessage = new Uint8Array([ClientMessageID.SetClientID, ...new TextEncoder().encode(ChatID)]); ws.send(idMessage); });
En esta sección del código del cliente, nos conectamos al servidor WebSocket. Cuando se abre la conexión, enviamos el ID del chat como primer mensaje para que el servidor sepa quiénes somos.
La estructura de los datos/mensajes enviados entre el cliente y el servidor sigue este formato:
El primer byte representa el tipo de mensaje que estamos enviando, permitiendo al servidor saber cómo manejar la carga útil representada por los siguientes bytes en los datos enviados.
Tenga en cuenta que configuramos el servidor WebSocket para aceptar y enviar solo datos binarios. Es por eso que siempre enviaremos un Uint8Array
desde el lado del cliente y un Buffer
desde el lado del servidor. Estamos enviando iy solo en binario porque es más eficiente, convirtiendo a texto solo lo que necesitamos y quedando en binario (como los fragmentos de audio, lo que debe permanecer en binario).
En el siguiente código, manejamos los mensajes recibidos del lado del servidor:
/* version-2/client.js */ const subscriptionsToWSMessages = []; ws.addEventListener('message', async (event) => { const data = new Uint8Array(await event.data.arrayBuffer()); const messageType = data[0]; // Because we know all the possible messages are all strings we can convert all the payloads to string const content = new TextDecoder().decode(data.slice(1)); if (!ws.allGood && messageType !== ServerMessageID.OK) { if (messageType === ServerMessageID.Error) { console.error('Something wrong sending the chat ID:', content); } } else if (messageType === ServerMessageID.OK) { ws.allGood = true; } else { let done; for (let i=0, length=subscriptionsToWSMessages.length; i < length; i += 1) { done = await subscriptionsToWSMessages[i](messageType, content); if (done === true) return; } if (!done) { if (messageType === ServerMessageID.Error) { console.error('Unhandled error received from server:', content); } else { console.log(`Unknown message type "${messageType}" received.`); } } } });
Como sabemos que todos los mensajes que recibimos del lado del servidor son textos, podemos convertir de forma segura toda la carga útil en una String
usando TextDecoder
: new TextDecoder().decode(data.slice(1));
.
Primero, esperaremos el primer ServerMessageID.OK
del servidor, que representa que la ID del chat enviada es válida.
Para ser flexibles, utilizamos una serie de funciones que representan oyentes de los mensajes recibidos del servidor. Esto nos permite ser modulares en nuestro enfoque. Cada función debe devolver true
o false
: true
significa que el mensaje fue procesado y no es necesario llamar al resto de funciones suscritas.
Para que sea más fácil agregar y eliminar suscriptores, ampliamos nuestro objeto ws
con lo siguiente:
/* version-2/client.js */ /** * Add a function to the list of functions to be called when the socket receives * a new message. The function must return a boolean: if `true` is returned then * is considered that the message was handled and will stop the exection of the * rest of the subscribers in the list. * * @param {Function} fn the function to be added */ ws.subscribeToWSMessage = (fn) => { subscriptionsToWSMessages.push(fn); } /** * Remove an added function from the list of subscribers. * * @param {Function} fn the function to be removed */ ws.unsubscribeToWSMessage = (fn) => { subscriptionsToWSMessages.splice(subscriptionsToWSMessages.indexOf(fn), 1); }
A continuación, ampliamos nuevamente el objeto ws
con 3 métodos más:
sendTextMessage
para enviar el mensaje de texto del usuario;
sendAudioChunk
para enviar un fragmento de audio de la grabación de voz del usuario;
sendAudioEnd
para decirle al servidor que el audio está listo.
/* version-2/client.js */ /** * Send a text message to the server. * @async * * @param {String} message the message to send * @param {Function} onNewMessageContent the function to be called with the new answer from bot as it sent from the server */ ws.sendTextMessage = async (message, onNewMessageContent) => { ws.createSubscriptionForBotResponse(onNewMessageContent); const wsMessage = new Uint8Array([ClientMessageID.UserTextMessage, ...new TextEncoder().encode(message)]); ws.send(wsMessage); };
El método sendTextMessage
acepta el mensaje que debe enviarse al servidor y una función que se llamará varias veces con el flujo de datos recibido de ChatGPT.
En este método, antes de enviar el mensaje al servidor, llamamos al método createSubscriptionForBotResponse
, que se encarga de crear y agregar una suscripción para escuchar nuevos mensajes y manejar la respuesta del bot.
/* version-2/client.js */ /** * Create and add a subscription to listen for the response of the bot to our sent message * * @param {Function} onNewMessageContent the function to be called with the new answer from bot as it sent from the server */ ws.createSubscriptionForBotResponse = (onNewMessageContent) => { const wsMessagesHandler = (messageType, content) => { if (messageType === ServerMessageID.TextChunk) { onNewMessageContent(content); return true; } else if (messageType === ServerMessageID.TextEnd) { ws.unsubscribeToWSMessage(wsMessagesHandler); return true; } return false; } ws.subscribeToWSMessage(wsMessagesHandler); }
La función suscrita verificará si el mensaje recibido del servidor tiene el tipo de mensaje requerido para la respuesta del bot ( ServerMessageID.TextChunk
). Si es así, llamamos a la función recibida con el fragmento de texto, lo que agregará el fragmento a la respuesta actual del bot en el chat.
Cuando el bot termine con la respuesta, el servidor nos enviará un mensaje de tipo ServerMessageID.TextEnd
, indicándonos que podemos dejar de escuchar, momento en el que nos daremos de baja para no escuchar nuevos mensajes.
/* version-2/client.js */ /** * Send an audio chunk to the server. * @async * * @param {Blob} blobChunk the audio blob chunk */ ws.sendAudioChunk = async (blobChunk) => { const wsMessage = new Uint8Array([ClientMessageID.UserAudioChunk, ...new Uint8Array(await blobChunk.arrayBuffer())]); ws.send(wsMessage); }; /** * Tell the server that the audio is done. * * @param {Function} onNewMessageContent the function to be called with the new answer from bot as it sent from the server */ ws.sendAudioEnd = (onNewMessageContent) => { ws.createSubscriptionForBotResponse(onNewMessageContent); ws.send(new Uint8Array([ClientMessageID.UserAudioEnd])); };
Los siguientes 2 métodos, sendAudioChunk
y sendAudioEnd
, sirven para enviar la voz grabada del usuario al servidor. El primero, sendAudioChunk
, enviará los bytes recibidos al servidor, mientras que el otro, sendAudioEnd
, enviará un mensaje al servidor indicando que el audio está listo y, al igual que el método sendTextMessage
, llamará a createSubscriptionForBotResponse
para escuchar la respuesta. del robot.
A continuación, veremos cómo se envía el parámetro onNewMessageContent
de los métodos sendTextMessage
y sendAudioEnd
.
Modificamos ligeramente la función addMessage
dividiéndola en addUserMessage
y addBotMessage
. Solo veremos addUserMessage
:
/* version-2/client.js */ /** * Add a new message to the chat. * @async * * @param {WebSocket} ws the WebSocket * @param {MessageType} type the type of message * @param {String|Audio} message the data of the message * @returns {Promise} the promise resolved when all is done */ async function addUserMessage(ws, type, message) { createMessageHTMLElement(type, type === MessageType.User ? message : 'Audio message'); // Keeping own history log if (type === MessageType.User) { messageHistory.push({ role: type === MessageType.User ? 'user' : 'assistant', content: message }); } if (type === MessageType.User) { await ws.sendTextMessage(message, addBotMessageInChunks()); } else { await ws.sendAudioEnd(addBotMessageInChunks()); } } /** * Add bot message in chunks. The functions returns another function that when called with * the argument will add that argument to the bot message. * * @returns {Function} the function accept a parameter `content`; when called the `content` is added to the message */ function addBotMessageInChunks() { const newMsg = createMessageHTMLElement(MessageType.Bot); let nextContentIndex = 0; let currentContentIndex = 0; let currentContentPromise; const onNewMessageContent = async (content) => { const thisContentIndex = nextContentIndex; nextContentIndex += 1; while (thisContentIndex !== currentContentIndex) { await currentContentPromise; } currentContentPromise = new Promise(async resolve => { await addContentToMessage(newMsg, content); currentContentIndex += 1; resolve(); }); } return onNewMessageContent; }
La función addBotMessageInChunks
es responsable de crear y devolver la función que agregará el texto/contenido dado al mensaje del bot actual.
Como queremos tener un efecto de escritura en el mensaje del bot a medida que llega, necesitamos tener un método para sincronizar todo. El servidor enviará el texto tal como viene y es posible que la función addContentToMessage
, que es responsable de crear el efecto de escritura, no esté lista a tiempo para manejar el siguiente texto recibido.
Entonces, se nos ocurrió un mecanismo de sincronización simple: creamos 2 contadores y una variable que contendrá una promesa. Cada vez que se llama a la función devuelta, asignamos a esa llamada el siguiente índice (línea 39) y luego aumentamos el contador. La función esperará su turno esperando a que se resuelva la promesa, y cuando llegue su turno, sobrescribirá la variable de promesa con una nueva promesa que solo esperará a que se haga el efecto de escritura (línea 47) y luego aumentar el contador.
/* version-2/client.js */ /** * Record the user and send the chunks to the server and on end wait for all the chunks to be sent. * @async * * @param {WebSocket} ws the WebSocket * @param {HTMLElement} stopRecordButtonElement the stop button element * @returns {Promise} */ async function recordUserAudio(ws, stopRecordButtonElement) { let stream; try { stream = await navigator.mediaDevices.getUserMedia({ audio: true }); } catch (error) { console.error(`The following getUserMedia error occurred: ${error}`); return; } const mediaRecorder = new MediaRecorder(stream, { mimeType: Settings.ClientAudioMimeType }); return new Promise((resolve, _reject) => { const onStopClick = () => { mediaRecorder.stop(); stopRecordButtonElement.classList.remove('show'); }; // Create an array to store the promises of the sent audio chunks so we can make sure that // when the user hit the stop button all the audio chunks are sent const sentAudioChunksPromises = []; mediaRecorder.addEventListener('dataavailable', (event) => { sentAudioChunksPromises.push(ws.sendAudioChunk(event.data)); }); mediaRecorder.addEventListener('stop', async (_event) => { await Promise.all(sentAudioChunksPromises); // Stop the audio listening stream.getAudioTracks().forEach((track) => { track.stop() stream.removeTrack(track); }); resolve(); }); stopRecordButtonElement.classList.add('show'); // The parameter of `start` is called `timeslice` and define how often, in milliseconds, // to fire the `dataavailable` event with the audio chunk mediaRecorder.start(1000); stopRecordButtonElement.addEventListener('click', onStopClick); }) }
La función recordUserAudio
también se modificó ligeramente:
mediaRecorder.start()
con el argumento 1000
dividirá el audio del usuario en fragmentos de 1 segundo , que se recibirán en el controlador del evento dataavailable
;
dataavailable
agregará la promesa devuelta por la llamada de ws.sendAudioChunk
en una matriz para que podamos esperar a que todos terminen en el controlador del evento stop
de nuestra instancia MediaRecorder
.
Esto es prácticamente todo para el lado del cliente.
Ahora, pasemos al lado del servidor para ver qué se agregó:
/* version-2/server.js */ const webSocketServer = new WebSocketServer({ port: Settings.WSPort }); webSocketServer.on('connection', function connection(clientWS) { // Array to keep all the audio chunks until the user is done talking clientWS.audioChunks = []; clientWS.on('error', console.error); clientWS.on('message', async (data, isBinary) => { // ... }); });
Estamos creando el servidor WebSocket (usando el paquete ws
) con nuestro puerto definido. Una vez que tenemos una conexión, agregamos una matriz vacía llamada audioChunks
al socket del cliente, que contendrá los fragmentos del búfer de audio.
Cuando el usuario envía un mensaje, hacemos lo siguiente:
/* version-2/server.js */ // ... // If the message is non-binary then reject it. // If the user did not already set the chatID then we close the socket. if (!isBinary) { const errorMsg = 'Only binary messages are supported.'; clientWS.send(Buffer.from([ServerMessageID.Error, errorMsg])); console.error(`(ChatID: ${clientWS.chatID}) Non-binary message received.`); if (!clientWS.chatID) { clientWS.close(1003, errorMsg); } return; } const messageType = data[0]; const payload = data.slice(1); if (!clientWS.chatID && messageType !== ClientMessageID.SetClientID) { clientWS.send(Buffer.from('Error! Please send first your ID')); } else if (messageType === ClientMessageID.SetClientID) { const id = payload.toString('utf8'); if (typeof id === 'string' && id.trim() !== '') { clientWS.chatID = id; clientWS.send(Buffer.from([ServerMessageID.OK])); } else { clientWS.send(Buffer.from([ServerMessageID.Error, ...Buffer.from('Error! Invalid ID. Please send a valid string ID.')])); } } // ...
Primero, verificamos si el mensaje recibido está en binario. Después de eso, separamos el tipo de mensaje ( messageType
) del resto de los datos ( payload
). Si el cliente aún no ha enviado la ID del chat y el tipo de mensaje no es para esto, devolverá un error. De lo contrario, almacenamos la ID del chat si es correcta dentro del socket del cliente.
/* version-2/server.js */ // ... } else if (messageType === ClientMessageID.UserTextMessage || messageType === ClientMessageID.UserAudioEnd) { const messages = getChatMessages(chatHistory, clientWS.chatID); let messageContent; if (messageType === ClientMessageID.UserTextMessage) { messageContent = payload.toString('utf8'); } else if (messageType === ClientMessageID.UserAudioEnd) { // When the client send the `ClientMessageID.UserAudioEnd` message type it means it clicked the STOP button // Concat all the buffers into a single one const buffer = Buffer.concat(clientWS.audioChunks); // Reset the chunks array clientWS.audioChunks = []; // Send audio to OpenAI to perform the speech-to-text messageContent = await stt(openai, buffer); } messages.push({ role: "user", content: messageContent }); try { await getAnswer(openai, messages, clientWS); } catch (error) { console.error(`(ChatID: ${clientWS.chatID}) Error when trying to get an answer from ChatGPT:`); console.error(error); clientWS.send(Buffer.from([ServerMessageID.Error, ...Buffer.from('Error!')])); return; } } // ...
Una vez que el cliente envía un mensaje de tipo ClientMessageID.UserTextMessage
o ClientMessageID.UserAudioEnd
, recuperamos, como antes, los mensajes del chat. Si el mensaje es del tipo ClientMessageID.UserTextMessage
, convertiremos los datos recibidos ( payload
) a una String
.
Si el mensaje es del tipo ClientMessageID.UserAudioEnd
, combinaremos todos los fragmentos del búfer de audio en un solo fragmento, restableceremos la matriz de fragmentos y realizaremos la acción de conversión de voz a texto en el audio, lo que devolverá el texto.
El siguiente paso es crear el nuevo mensaje en el formato aceptado por ChatGPT y solicitar una respuesta a ChatGPT.
/* version-2/server.js */ // ... } else if (messageType === ClientMessageID.UserAudioChunk) { clientWS.audioChunks.push(payload); } // ...
El último tipo de mensaje que manejamos es para los fragmentos de audio, que simplemente agrega los datos recibidos a la matriz de fragmentos de audio del socket del cliente.
Ahora, veamos cómo se cambió la función getAnswer
para admitir transmisiones:
/* version-2/server.js */ /** * Get the next message in the conversation * @async * * @param {OpenAI} openai the OpenAI instance * @param {String[]} messages the messages in the OpenAI format * @returns {String} the response from ChatGPT */ async function getAnswer(openai, messages, clientWS) { // Documentation https://platform.openai.com/docs/api-reference/chat/create const stream = await openai.chat.completions.create({ model: Settings.ChatGPTModel, messages, stream: true, }); for await (const chunk of stream) { const content = chunk.choices[0]?.delta?.content; if (!content) continue; clientWS.send(Buffer.from([ServerMessageID.TextChunk, ...Buffer.from(content || "")])); } clientWS.send(Buffer.from([ServerMessageID.TextEnd])); }
Simplemente agregando stream: true
al objeto enviado como argumento a ChatGPT, devolverá un objeto de flujo que podemos recorrer. Por cada fragmento de datos que no esté vacío, lo enviaremos de vuelta al cliente. Una vez finalizada la transmisión, debemos notificar al cliente que la respuesta está completa.
Vale, vale, pero ¿y si tenemos un servicio TTS que admita streaming o acepte muchas solicitudes que se procesen rápido?
No hay problema: sólo necesitamos ajustar algunas cosas en el lado del servidor y del cliente.
En el lado del servidor, una vez que recibimos una parte de la respuesta de la IA (en la función getAnswer
), debemos llamar a nuestro servicio TTS y enviar los datos de audio recibidos como respuesta al lado del cliente.
Del lado del cliente, se necesitan más cambios:
Hay aspectos que se omitieron en esta publicación/implementación, como el manejo de errores para diferentes partes del código.
Si desea ver una implementación del servidor en Go o Rust, escríbame a [email protected] .
Los diagramas se generaron usando Draw.io y la revisión gramatical la realizó ChatGPT.
Recursos: