このチュートリアルでは、あらゆる Web サイトに追加できるシンプルなポップアップ AI チャットを作成する方法を紹介します。クライアントは、ボットに入力したり話しかけたりすることで、チャットに返信できます。
AI 機能には OpenAI のツールを使用します。チャットには ChatGPT、STT (音声テキスト変換) には Whisper、TTS (テキスト読み上げ) には TTS を使用します。
単純な、または基本的な方法から、より優れた、しかしより複雑な方法まで、アプリを実装するための複数の方法を紹介します。
アプリケーションは JavaScript (ECMAScript) で実装されます。ただし、他の言語での実装に興味がある場合は、最後の章をお読みください。
すべてのコードは私の GitHub リポジトリで入手できます。
この章では、アプリケーションの基本、つまりプロジェクト構造と使用したパッケージについて説明します。
このプロジェクトでは次のパッケージを使用します。
パッケージ名 | 説明 |
---|---|
| HTTPサーバーとルーティング |
| OpenAIに関するすべての情報 |
| SASSスタイルファイルをCSSファイルに変換するには |
| WebSocketの場合 |
プロジェクトの構造は次のとおりです。
パス | 説明 |
---|---|
| |
| 公開オーディオファイルを含むディレクトリ |
| 公開画像を含むディレクトリ |
| エントリーポイント |
| ページのスタイルを含むディレクトリ |
| 素朴な実装ソースコードディレクトリ |
| より優れた実装ソースコードディレクトリ |
プロジェクトはここで確認でき、コードがリストされる場所には、そのコードが見つかる場所への相対パスも含まれます。
npm install
実行し、続いてnpm run build
して SASS ファイルを CSS に変換すれば、準備完了です。
単純な実装を開始するには、 npm run start-v1
を実行します。より優れた実装を実行するには、 npm run start-v2
実行します。環境変数OPENAI_API_KEY
を定義することを忘れないでください。
UNIX システムでは以下を実行できます:
OPENAI_API_KEY=YOU_API_KEY npm run start-v1`
Windows の場合:
set OPENAI_API_KEY=YOU_API_KEY npm run start-v1
このページにアクセスすると、次のページが表示されます。
単純な実装では、サーバーとの間でデータを送受信するために HTTP リクエストと応答を使用します。
重要な機能を一つずつ見ていきます。すべてのコードは上記のリンクからご覧いただけます。
アプリがどのように動作するかを示すアクティビティ図を以下に示します。
ユーザーがテキスト入力要素で Enter キーを押したときに何が起こるかを見てみましょう。
/* 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; });
ユーザーが Enter キーを押し、入力が空でない場合は、テキスト入力とオーディオ ボタンの両方を無効にして、前のメッセージへの応答を受信している間にユーザーが別のメッセージを送信しないようにします。回答を取得したら、機能を復元します。
入力を無効にした後、メイン関数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); } }
この関数は、新しいメッセージ用の新しいHTMLDivElement
を作成し、メッセージの種類に基づいて CSS クラスを追加します。
それが完了すると、メッセージはクライアント側のチャット履歴に保存されます。
次に、追加するメッセージがボットからのものである場合は、「書き込み効果」を使用してメッセージを表示します。オーディオが存在する場合は、オーディオの長さをメッセージの文字数で割って、オーディオをタイピング速度に同期させようとします。
追加されたメッセージがユーザーからのものである場合、関数sendMessage
を呼び出してメッセージをサーバーに送信し、AI からの応答を取得します。
sendMessage
関数は、 fetch
使用してサーバーに HTTP リクエストを送信するだけです。
1 つ言及しておくべき点: 各メッセージで送信する各クライアントに対してランダム ID を生成し、サーバーがチャット履歴をどこから取得するかを認識できるようにします。
識別 ID をサーバーに送信する代わりに、毎回履歴全体を送信するという方法もありますが、メッセージごとに送信する必要があるデータが増加します。
/* 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); } }
サーバー側でリクエストがどのように処理されるかを確認する前に、ユーザーがオーディオ ボタンをクリックしたときに何が起こるかを見てみましょう。
/* 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; });
これはテキスト入力の処理と非常によく似ています。関数recordUserAudio
base64 でエンコードされたオーディオを返しますが、 addMessage
に送信する前にそのヘッダーを切り取るだけです。
recordUserAudio
関数は、ユーザーからオーディオを録音する許可を得ようとし、許可された場合はMediaRecorder
を作成して録音を開始します。また、音声を録音していることをユーザーに知らせるための UI 要素と、録音が完了したら録音を停止するためのボタンも表示します。
停止ボタンが押されると、オーディオ チャンクをBlob
オブジェクトに変換し、Blob を base64 でエンコードされた文字列に変換して返します。
また、各オーディオ トラックを調べて停止し、削除します。少なくとも Chrome では、 mediaRecorder.stop()
を呼び出してもマイクの「リスニング」状態は停止されないため、これが必要になります。
オーディオをbase64
にエンコードするのは、オーディオをサーバーに送信する方法としてはあまり効率的ではありませんが、非常に簡単な方法です。 「より優れた実装」セクションでは、オーディオをサーバーに送信する別の方法について説明します。
/* 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); }) }
次に、サーバー上でリクエストがどのように処理されるかを見てみましょう。
/* 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 }); });
クライアントが必要なデータを送信したことを確認した後、指定された ID のチャット履歴を取得します (または新しいチャット履歴を作成します)。
/* 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]; }
次に、受信したメッセージが音声ではなくテキストの場合、メッセージをチャット履歴に追加します。受信したメッセージが音声の場合、OpenAI のWhisperを使用して音声テキスト変換アクションを実行する関数stt
を呼び出します。
この関数はopenai.audio.transcriptions.create
メソッドを使用します。このメソッドの主なパラメータはfile
で、これはオーディオ データを表す必要があります。パッケージopenai/uploads
のtoFile
関数を使用して、base64 でエンコードされたオーディオ ファイルを OpenAI が読み取れるファイルに変換します。この関数は、指定されたオーディオの文字起こしを返します。
/* 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; }
メッセージが取得できたので、チャットを ChatGPT に送信し、 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; }
最後の部分は、オーディオ ファイルを作成するためにメソッドopenai.audio.speech.create
を使用する関数tts
を使用して、AI からの応答をオーディオに変換することです。OpenAI の TTS は複数の形式をサポートしていますが、このチュートリアルではmp3
を選択しました。
オーディオ データが取得されると、それをBuffer
に変換し、そこから base64 でエンコードされたオーディオ文字列に変換してクライアントに送り返します。
/* 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'); }
しかし、これをさらに改善することはできるでしょうか? 可能です。HTTP リクエストを使用する代わりに、WebSocket を使用してクライアントとサーバー間の通信を行い、ChatGPT に結果をストリームとして返すように指示することができます。このようにして、ChatGPT からクライアントに結果をリアルタイムでストリーミングするため、リアルタイムの書き込み効果を生み出すことができます。
この実装には欠点がありますが、これは OpenAI の TTS を使用しており、1 分あたり最大 3 件のリクエストしか受け付けないからです。したがって、この実装ではテキスト読み上げ機能は削除しますが、再実装の方法と再実装時に注意すべき点についてヒントを紹介します。
それでは、コードを見てみましょう。以前の実装から始めて、WebSocket をサポートするために必要なものを変更しました。
/* 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); });
クライアント コードのこのセクションでは、WebSocket サーバーに接続します。接続が開かれると、サーバーがユーザーを認識できるように、チャット ID を最初のメッセージとして送信します。
クライアントとサーバー間で送信されるデータ/メッセージの構造は次の形式に従います。
最初のバイトは送信するメッセージの種類を表し、これによりサーバーは送信されたデータ内の後続のバイトによって表されるペイロードをどのように処理するかを知ることができます。
WebSocket サーバーは、バイナリ データのみを受け入れて送信するように構成されていることに注意してください。これが、クライアント側からは常にUint8Array
送信し、サーバー側からは常にBuffer
送信する理由です。バイナリのみを送信するのは、必要なものだけをテキストに変換し、残りはバイナリのままにしておく (オーディオ チャンクなど、バイナリのままにしておく必要のあるもの) 方が効率的だからです。
次のコードでは、サーバー側から受信したメッセージを処理します。
/* 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.`); } } } });
サーバー側から受信するメッセージはすべてテキストであることがわかっているので、 TextDecoder
を使用してペイロード全体を安全にString
に変換できます: new TextDecoder().decode(data.slice(1));
。
まず、送信されたチャット ID が有効であることを示す、サーバーからの最初のServerMessageID.OK
を待機します。
柔軟性を確保するために、サーバーから受信したメッセージのリスナーを表す関数の配列を使用します。これにより、モジュール化されたアプローチが可能になります。各関数はtrue
またはfalse
を返す必要があります。true true
メッセージが処理されたことを意味し、サブスクライブされている残りの関数を呼び出す必要はありません。
サブスクライバーの追加と削除を簡単にするために、 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); }
次に、 ws
オブジェクトをさらに 3 つのメソッドで拡張します。
sendTextMessage
。
sendAudioChunk
。
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); };
sendTextMessage
メソッドは、サーバーに送信する必要のあるメッセージと、ChatGPT から受信したデータ ストリームで複数回呼び出される関数を受け入れます。
このメソッドでは、メッセージをサーバーに送信する前に、ボットからの応答を処理するために新しいメッセージをリッスンするためのサブスクリプションの作成と追加を処理する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); }
subscribed 関数は、サーバーから受信したメッセージにボットの応答に必要なメッセージ タイプ ( ServerMessageID.TextChunk
) があるかどうかを確認します。必要なメッセージ タイプがある場合は、テキスト チャンクを使用して received 関数を呼び出し、そのチャンクをチャット内の現在のボット応答に追加します。
ボットが応答を完了すると、サーバーは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])); };
次の 2 つのメソッド、 sendAudioChunk
とsendAudioEnd
は、ユーザーの録音された音声をサーバーに送信するためのものです。最初のメソッド、 sendAudioChunk
は、受信したバイトをサーバーに送信し、もう 1 つのメソッド、 sendAudioEnd
、オーディオが終了したことを示すメッセージをサーバーに送信し、 sendTextMessage
メソッドと同様に、 createSubscriptionForBotResponse
を呼び出してボットからの応答をリッスンします。
次に、 sendTextMessage
とsendAudioEnd
メソッドからのonNewMessageContent
パラメータがどのように送信されるかを見ていきます。
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; }
addBotMessageInChunks
関数は、指定されたテキスト/コンテンツを現在のボット メッセージに追加する関数を作成して返す役割を担います。
ボット メッセージが届いたら書き込み効果を適用したいので、すべてを同期する方法が必要です。サーバーはテキストを受け取ったらすぐに送信しますが、書き込み効果を作成するaddContentToMessage
関数は、次に受信したテキストを処理する準備が間に合わない可能性があります。
そこで、私たちはシンプルな同期メカニズムを思いつきました。2 つのカウンターと、Promise を保持する変数を作成します。返された関数が呼び出されるたびに、その呼び出しに次のインデックスを割り当て (行 39)、カウンターを増やします。関数は、Promise が解決されるのを待つことで順番を待ち、順番が来たら、書き込み効果が完了するのを待つだけの新しい Promise で Promise 変数を上書きし (行 47)、カウンターを増やします。
/* 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); }) }
recordUserAudio
関数も若干変更されました。
1000
を指定してmediaRecorder.start()
を呼び出すと、ユーザーのオーディオが1 秒のチャンクに分割され、 dataavailable
イベントのハンドラーで受信されます。
dataavailable
イベントのハンドラーは、 ws.sendAudioChunk
の呼び出しによって返された promise を配列に追加し、 MediaRecorder
インスタンスのstop
イベントのハンドラーでそれらすべてが完了するまで待機できるようにします。
クライアント側についてはこれでほぼ完了です。
次に、サーバー側に切り替えて何が追加されたかを確認しましょう。
/* 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) => { // ... }); });
定義したポートを使用して WebSocket サーバー ( ws
パッケージを使用) を作成します。接続したら、オーディオ バッファー チャンクを保持するaudioChunks
という空の配列をクライアント ソケットに追加します。
ユーザーがメッセージを送信すると、次の処理が行われます。
/* 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.')])); } } // ...
まず、受信したメッセージがバイナリ形式かどうかを確認します。その後、メッセージ タイプ ( messageType
) を残りのデータ ( payload
) から分離します。クライアントがまだチャット ID を送信しておらず、メッセージ タイプがこれに該当しない場合は、エラーを返します。それ以外の場合は、クライアント ソケット内にチャット ID が正しければそれを保存します。
/* 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; } } // ...
クライアントがClientMessageID.UserTextMessage
またはClientMessageID.UserAudioEnd
タイプのメッセージを送信すると、前と同じようにチャットのメッセージが取得されます。メッセージがClientMessageID.UserTextMessage
タイプの場合、受信したデータ ( payload
) をString
に変換します。
メッセージのタイプがClientMessageID.UserAudioEnd
の場合、すべてのオーディオ バッファー チャンクを 1 つのチャンクに結合し、チャンクの配列をリセットし、オーディオに対して音声テキスト変換アクションを実行して、テキストを返します。
次のステップは、ChatGPT が受け入れる形式で新しいメッセージを作成し、ChatGPT に応答を問い合わせることです。
/* version-2/server.js */ // ... } else if (messageType === ClientMessageID.UserAudioChunk) { clientWS.audioChunks.push(payload); } // ...
最後に処理するメッセージ タイプはオーディオ チャンク用で、受信したデータをクライアント ソケットのオーディオ チャンク配列に追加するだけです。
ここで、ストリームをサポートするために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])); }
ChatGPT に引数として送信されるオブジェクトにstream: true
追加するだけで、ループできるストリーム オブジェクトが返されます。空でないデータ チャンクごとに、それをクライアントに送り返します。ストリームが完了したら、応答が完了したことをクライアントに通知する必要があります。
わかりました、わかりました。しかし、ストリーミングをサポートしたり、多くのリクエストを受け入れて高速に処理される TTS サービスがあったらどうなるでしょうか?
問題ありません。サーバー側とクライアント側でいくつか調整する必要があるだけです。
サーバー側では、AI から回答の一部を受け取ったら ( getAnswer
関数内)、TTS サービスを呼び出して、受信したオーディオ データを応答としてクライアント側に送信する必要があります。
クライアント側では、さらに変更が必要です。
この投稿/実装では、コードのさまざまな部分でのエラー処理など、省略された側面があります。
Go または Rust でのサーバーの実装をご覧になりたい場合は、 [email protected]までご連絡ください。
図はDraw.ioを使用して生成され、文法チェックはChatGPTによって行われました。
リソース: