Neste tutorial, apresentarei como criar um chat pop-up simples de IA que pode ser adicionado a qualquer site. O cliente poderá responder ao chat digitando e falando com o bot. Estaremos usando ferramentas da OpenAI para as funcionalidades de IA. Para o chat usaremos o ChatGPT, para o STT (speech-to-text), Whisper, e para o TTS (text-to-speech), o seu TTS. Mostrarei vários métodos para implementar o aplicativo, começando com um método ingênuo ou básico até um método melhor, mas também mais complexo. A aplicação será implementada em JavaScript (ECMAScript). Entretanto, leia o capítulo final se estiver interessado em implementações em outras linguagens. Todo o código está disponível . em meu repositório GitHub O básico Neste capítulo, iremos ao básico da aplicação: a estrutura do projeto e os pacotes que utilizamos. O projeto usará os seguintes pacotes: Nome do pacote Descrição express Para o servidor HTTP e roteamento openai Para todas as coisas do OpenAI sass Para converter os arquivos de estilo SASS em arquivos CSS ws Para os WebSockets A estrutura do projeto é a seguinte: Caminho Descrição public O diretório exposto na Internet sob o nome static public/audio O diretório que contém arquivos de áudio públicos public/img O diretório que contém imagens públicas public/index.html O ponto de entrada style O diretório que contém o estilo da página version-1 O diretório do código-fonte da implementação ingênua version-2 O melhor diretório de código-fonte de implementação O projeto pode ser visto , e onde um código será listado também conterá o caminho relativo para onde esse código pode ser encontrado. aqui Execute seguido de para converter o arquivo SASS em CSS e você estará pronto para começar. npm install npm run build Para iniciar a implementação ingênua, execute ou, para executar a implementação melhor, execute . Não esqueça de definir a variável de ambiente . npm run start-v1 npm run start-v2 OPENAI_API_KEY Em sistemas UNIX você pode executar: OPENAI_API_KEY=YOU_API_KEY npm run start-v1` E no Windows: set OPENAI_API_KEY=YOU_API_KEY npm run start-v1 Esta é a página que você deve ver ao acessar a página: A implementação ingênua/simples A implementação ingênua usa solicitações e respostas HTTP para enviar e receber dados do servidor. Examinaremos cada função importante. Todo o código pode ser encontrado no link mencionado acima. Aqui está um diagrama de atividades de como o aplicativo funcionará: Vejamos o que acontece quando o usuário pressiona Enter no 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; }); Assim que o usuário pressionar a tecla Enter e a entrada não estiver vazia, desabilitaremos a entrada de texto e o botão de áudio para que o usuário não envie outra mensagem enquanto obtivermos uma resposta à mensagem anterior. Assim que obtivermos a resposta, restauramos a funcionalidade. Depois de desabilitarmos a entrada, chamamos a função principal , que faz a mágica. Vejamos: addMessage /* 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); } } A função criará um novo para a nova mensagem e adicionará a classe CSS com base no tipo da mensagem. HTMLDivElement Feito isso, armazenamos a mensagem em nosso histórico de chat do cliente. A seguir, se a mensagem a ser adicionada for do bot, nós a exibimos usando um “efeito de escrita”. Tentamos sincronizar o áudio, se existir, com a velocidade de digitação dividindo a duração do áudio pelo número de caracteres da mensagem. Se a mensagem adicionada for do usuário, então a enviamos ao servidor para obter a resposta da IA chamando a função . sendMessage A função apenas faz uma solicitação HTTP usando para nosso servidor. sendMessage fetch Uma coisa a mencionar: geramos um ID aleatório para cada cliente que enviamos com cada mensagem para que o servidor saiba de onde obter o histórico do chat. A alternativa ao envio de um ID de identificação ao servidor seria enviar todo o histórico a cada vez, mas a cada mensagem os dados que precisam ser enviados aumentam. /* 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 irmos para o lado do servidor para ver como ele trata a solicitação, vamos ver o que acontece quando o usuário clica no botão de áudio: /* 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; }); É muito semelhante ao tratamento de entrada de texto. A função retornará um áudio codificado em base64, e apenas cortamos o cabeçalho dele antes de enviá-lo para . recordUserAudio addMessage A função tentará obter permissão do usuário para gravar áudio e, se concedida, criará um e iniciará a gravação. Também mostraremos alguns elementos da interface do usuário para informar ao usuário que estamos gravando sua voz e um botão para interromper a gravação quando terminar. recordUserAudio MediaRecorder Assim que o botão parar é pressionado, convertemos os pedaços de áudio em um objeto e o blob em uma string codificada em base64 e o retornamos. Blob Também examinamos cada faixa de áudio, interrompemos-as e depois as removemos. Isso é necessário porque, pelo menos no Chrome, chamar não interromperá o estado de “escuta” do microfone. mediaRecorder.stop() Codificar o áudio para não é um método muito eficiente de enviar o áudio para o servidor, mas é um método muito fácil. Veremos outro método para enviar o áudio ao servidor na seção . base64 A melhor implementação /* 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); }) } Vejamos agora como a solicitação é tratada no 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 }); }); Depois de verificarmos se o cliente enviou os dados necessários, obtemos o histórico de chat para o ID fornecido (ou criamos um novo histórico 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]; } Então, se a mensagem recebida for um texto e não um áudio, adicionamos a mensagem ao histórico do chat. Se a mensagem recebida for um áudio, chamamos a função que realizará a ação de fala para texto usando da OpenAI. stt o Whisper A função usará o método . O principal parâmetro deste método é , que deve representar nossos dados de áudio. Usamos a função do pacote para converter nosso arquivo de áudio codificado em base64 em um arquivo que o OpenAI possa ler. A função retornará a transcrição do áudio fornecido. openai.audio.transcriptions.create file toFile openai/uploads /* 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; } Agora que temos a mensagem, enviamos o chat para ChatGPT e aguardamos resposta chamando a função . 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; } A última parte é sobre como converter a resposta da IA em áudio usando a função que utiliza o método para criar um arquivo de áudio. O TTS do OpenAI suporta vários formatos, mas escolhemos para este tutorial. tts openai.audio.speech.create mp3 Uma vez obtidos os dados de áudio, nós os convertemos em um e, a partir daí, em uma string de áudio codificada em base64 para enviar de volta ao cliente. Buffer /* 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'); } A melhor implementação Mas podemos melhorar isso? Bem, sim. Em vez de usar solicitações HTTP, podemos usar WebSockets para comunicação entre o cliente e o servidor e dizer ao ChatGPT para retornar os resultados como um fluxo. Desta forma, podemos criar um efeito de escrita em tempo real, pois transmitiremos o resultado do ChatGPT para o cliente em tempo real. Esta implementação tem uma desvantagem, mas apenas porque estamos utilizando o TTS da OpenAI, que aceita no máximo 3 solicitações por minuto. Portanto, para esta implementação, abandonaremos o recurso de conversão de texto em fala, mas darei dicas de como reimplementá-lo e o que procurar ao fazê-lo. Então, vamos dar uma olhada em algum código. Partimos da implementação anterior e alteramos o que era necessário para oferecer suporte a 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); }); Nesta seção do código do cliente, nos conectamos ao servidor WebSocket. Quando a conexão é aberta, enviamos o ID do chat como primeira mensagem para que o servidor saiba quem somos. A estrutura dos dados/mensagens enviadas entre o cliente e o servidor segue este formato: O primeiro byte representa o tipo de mensagem que estamos enviando, permitindo ao servidor saber como lidar com o payload representado pelos bytes seguintes nos dados enviados. Observe que configuramos o servidor WebSocket para aceitar e enviar apenas dados binários. É por isso que sempre enviaremos um do lado do cliente e um do lado do servidor. Estamos enviando iy apenas em binário porque é mais eficiente, convertendo para texto apenas o que precisamos e permanecendo em binário (como os pedaços de áudio, o que precisa permanecer em binário). Uint8Array Buffer No código a seguir, tratamos das mensagens recebidas do lado do 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 todas as mensagens que recebemos do lado do servidor são textos, podemos converter com segurança toda a carga útil em uma usando : . String TextDecoder new TextDecoder().decode(data.slice(1)); Primeiramente, aguardaremos o primeiro do servidor, que representa que o ID do chat enviado é válido. ServerMessageID.OK Para sermos flexíveis, usamos um conjunto de funções que representam ouvintes das mensagens recebidas do servidor. Isso nos permite ser modulares em nossa abordagem. Cada função deve retornar ou : significa que a mensagem foi processada e não é necessário chamar o restante das funções inscritas. true false true Para facilitar a adição e remoção de assinantes, estendemos nosso objeto com o seguinte: ws /* 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 seguir, estendemos novamente o objeto com mais 3 métodos: ws para enviar a mensagem de texto do usuário; sendTextMessage para enviar um trecho de áudio da gravação de voz do usuário; sendAudioChunk para informar ao servidor que o áudio foi concluído. sendAudioEnd /* 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); }; O método aceita a mensagem que precisa ser enviada ao servidor e uma função que será chamada diversas vezes com o fluxo de dados recebidos do ChatGPT. sendTextMessage Neste método, antes de enviar a mensagem ao servidor, chamamos o método , que trata da criação e adição de uma assinatura para escutar novas mensagens para lidar com a resposta do bot. createSubscriptionForBotResponse /* 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); } A função inscrita verificará se a mensagem recebida do servidor possui o tipo de mensagem necessário para a resposta do bot ( ). Se isso acontecer, chamamos a função recebida com o pedaço de texto, que adicionará o pedaço à resposta atual do bot no chat. ServerMessageID.TextChunk Quando o bot terminar a resposta, o servidor nos enviará uma mensagem do tipo , indicando que podemos parar de ouvir, momento em que cancelaremos a assinatura de ouvir novas mensagens. ServerMessageID.TextEnd /* 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])); }; Os próximos 2 métodos, e , servem para enviar a voz gravada do usuário ao servidor. O primeiro, , enviará os bytes recebidos ao servidor, enquanto o outro, , enviará uma mensagem ao servidor indicando que o áudio foi finalizado e, assim como o método , chamará para ouvir a resposta do bot. sendAudioChunk sendAudioEnd sendAudioChunk sendAudioEnd sendTextMessage createSubscriptionForBotResponse A seguir, veremos como o parâmetro dos métodos e é enviado. onNewMessageContent sendTextMessage sendAudioEnd Modificamos ligeiramente a função dividindo-a em e . Veremos apenas : addMessage addUserMessage addBotMessage 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; } A função é responsável por criar e retornar a função que anexará o texto/conteúdo fornecido à mensagem do bot atual. addBotMessageInChunks Como queremos ter um efeito de escrita na mensagem do bot à medida que ela chega, precisamos de um método para sincronizar tudo. O servidor enviará o texto conforme ele chegar, e a função , responsável por criar o efeito de escrita, pode não estar pronta a tempo de lidar com o próximo texto recebido. addContentToMessage Então, criamos um mecanismo de sincronização simples: criamos 2 contadores e uma variável que conterá uma promessa. Cada vez que a função retornada é chamada, atribuímos a essa chamada o próximo índice (linha 39) e então aumentamos o contador. A função irá aguardar a sua vez aguardando a resolução da promessa, e quando chegar a sua vez, irá sobrescrever a variável da promessa por uma nova promessa que irá apenas esperar que o efeito de escrita seja feito (linha 47) e então aumentar o 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); }) } A função também foi ligeiramente alterada: recordUserAudio Chamar com o argumento dividirá o áudio do usuário em pedaços de , que serão recebidos no manipulador para o evento ; mediaRecorder.start() 1000 1 segundo dataavailable O manipulador para o evento adicionará a promessa retornada pela chamada de em um array para que possamos esperar que todos eles terminem no manipulador para o evento de nossa instância . dataavailable ws.sendAudioChunk stop MediaRecorder Isso é basicamente tudo para o lado do cliente. Agora, vamos mudar para o lado do servidor para ver o que foi adicionado: /* 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 criando o servidor WebSocket (usando o pacote ) com nossa porta definida. Assim que tivermos uma conexão, adicionamos um array vazio chamado ao soquete do cliente, que conterá os pedaços do buffer de áudio. ws audioChunks Quando o usuário envia uma mensagem, fazemos o seguinte: /* 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.')])); } } // ... Primeiro, verificamos se a mensagem recebida está em binário. Depois disso, separamos o tipo de mensagem ( ) do restante dos dados ( ). Caso o cliente ainda não tenha enviado o ID do chat e o tipo de mensagem não seja para isso, retorne um erro. Caso contrário, armazenamos o ID do chat se estiver correto dentro do soquete do cliente. messageType payload /* 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; } } // ... Assim que o cliente envia uma mensagem do tipo ou , recuperamos, como antes, as mensagens do chat. Se a mensagem for do tipo , converteremos os dados recebidos ( ) em . ClientMessageID.UserTextMessage ClientMessageID.UserAudioEnd ClientMessageID.UserTextMessage payload String Se a mensagem for do tipo , combinaremos todos os pedaços do buffer de áudio em um único pedaço, redefiniremos a matriz de pedaços e executaremos a ação de conversão de fala em texto no áudio, que retornará o texto. ClientMessageID.UserAudioEnd A próxima etapa é criar a nova mensagem no formato aceito pelo ChatGPT e consultar o ChatGPT para obter uma resposta. /* version-2/server.js */ // ... } else if (messageType === ClientMessageID.UserAudioChunk) { clientWS.audioChunks.push(payload); } // ... O último tipo de mensagem que tratamos é para os pedaços de áudio, que apenas adiciona os dados recebidos ao array de pedaços de áudio do soquete do cliente. Agora, vamos ver como a função foi alterada para oferecer suporte a streams: getAnswer /* 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])); } Simplesmente adicionando ao objeto enviado como argumento para ChatGPT, ele retornará um objeto stream pelo qual podemos percorrer. Para cada pedaço de dados não vazio, nós o enviaremos de volta ao cliente. Após a conclusão do stream, precisamos notificar o cliente de que a resposta foi concluída. stream: true Dicas para adicionar a funcionalidade TTS de volta Ok, ok, mas e se tivermos um serviço TTS que suporte streaming ou aceite muitas solicitações que são processadas rapidamente? Não tem problema: só precisamos ajustar algumas coisas no lado do servidor e do cliente. No lado do servidor, assim que recebermos uma parte da resposta da IA (na função ), precisamos chamar nosso serviço TTS e enviar os dados de áudio recebidos como resposta ao lado do cliente. getAnswer Do lado do cliente, são necessárias mais mudanças: Não podemos mais transformar os dados recebidos em texto porque agora podemos receber dados de áudio; Como podemos receber os próximos dados de áudio antes que o áudio anterior termine de ser reproduzido, precisamos introduzir um método de sincronização para controlar qual áudio precisa ser reproduzido em seguida. Palavras Finais Existem aspectos que foram omitidos nesta postagem/implementação, como tratamento de erros para diferentes partes do código. Se você gostaria de ver uma implementação do servidor em Go ou Rust, escreva para mim em . alex@alexprisacariu.dev Os diagramas foram gerados no Draw.io e a verificação gramatical foi feita no ChatGPT. Recursos: ; Documentação OpenAI . Documentos da Web do MDN