Share
Embed
Transcript
58 slides
-
つくったもの • 音声でAIと話せるやつ • 特徴:外部APIを使わずすべてサーバー内で実行している • Backend: Deno, Hono, Kysely,
PostgreSQL • Frontend: Node, Vite, React • -
ストリーミング処理 • データをChunkという単位で少しずつフロントに送るような処理 • 例えばストリーミング配信: ◦ 動画データを少しずつフロントエンドに送っている • 例えばGoogleDocs: ◦
少しずつデータをサーバーに送ることで同期をとって同時編集ができる -
どこで使われているのか?: Deno の Request Body app.post("/:id/stream", async (c) => {
const stream = c.req.raw.body as ReadableStream; -
Denoの良さ • Web 標準の機能がサーバーで動くこと • つまりブラウザと同じものがサーバーで動くので楽 • Nodeだと Web Streams
まわりがぐちゃぐちゃでよくわからないことになっている -
Web Streams のメリット • fetch 関数の中で自然と使える • つまりHTTPハンドラでStream処理ができる • (バックエンドがDenoの場合)フロントと同じようにWeb
Streams を扱える • 通信が単方向なので軽め -
概要 何か話す ブラウザで音声デー タにする WhisperStreamingで 音声をテキスト化 音声バイナリを WebStreamsで サーバーに送る フロントエンド
バックエンド 会話テキストを WebStreamsでポー リング PostgreSQL から データ取得 テキスト表示 Notify 「AIを呼び出す」 ボタンを押下 Ollamaに会話データを 入力して出力をNotifyし テーブルにも保存する Notify Listen Post Request -
概要 何か話す ブラウザで音声デー タにする WhisperStreamingで 音声をテキスト化 音声バイナリを WebStreamsで サーバーに送る フロントエンド
バックエンド 会話テキストを WebStreamsでポー リング PostgreSQL から データ取得 テキスト表示 Notify 「AIを呼び出す」 ボタンを押下 Ollamaに会話データを 入力して出力をNotifyし テーブルにも保存する Notify Listen Post Request まずここの話 -
音声データの取得 const mediaRecorderRef = useRef<MediaRecorder | null>(null); const streamControllerRef =
useRef<ReadableStreamDefaultController<Uint8Array> | null>(null); -
音声データの取得 const startRecording = async () => { // マイクへのアクセスをリクエスト
const stream = await navigator.mediaDevices.getUserMedia({ audio: true }); // MediaRecorderの作成 const options = { mimeType: "audio/webm; codecs=opus" , // サポートされているmimeTypeを指定 }; const mediaRecorder = new MediaRecorder(stream, options); mediaRecorderRef.current = mediaRecorder; // ReadableStreamの作成 const audioStream = new ReadableStream<Uint8Array>({ start(controller) { // コントローラを保存して、後でデータをエンキューする streamControllerRef.current = controller; }, }); -
音声データの取得 // dataavailableイベントのハンドリング mediaRecorder.addEventListener("dataavailable", async (event) => { if (event.data
&& event.data.size > 0) { // BlobをArrayBufferに変換 const arrayBuffer = await event.data.arrayBuffer(); // ArrayBufferをUint8Arrayに変換 const chunk = new Uint8Array(arrayBuffer); // ReadableStreamにチャンクをエンキュー streamControllerRef .current?.enqueue(chunk); } }); // stopイベントのハンドリング mediaRecorder.addEventListener("stop", () => { // ReadableStreamをクローズ streamControllerRef .current?.close(); }); // 録音開始(100msごとにデータを収集) mediaRecorder.start(100); -
音声のバイナリデータをサーバーにStreaming送信 // 先程の関数の中で次を実行する mutate(audioStream) // mutate の中身 const { mutate,
isPending } = useMutation({ mutationFn: async (audioStream: ReadableStream<Uint8Array>) => { return await httpClient({ jwtToken }) // ky のラッパー .post(`talks/${talkId}/stream`, { body: audioStream, // ReadableStream をそのままBodyに入れてリクエストする timeout: false, }) .text(); } }); -
バックエンドでの処理 • 音声のバイナリデータをReadableStreamで取得 • ffmpeg に pipe で渡してサンプリングレートなどを調整 • Whisper
Streaming に pipe で渡してテキスト化 -
Whisper Streaming • Faster Whisper などをバックにして Stream 処理でテキスト化してくれる • whisper_online_server.py
を使えばサーバー 化できる • これはTCPで動作するので Deno.connect で 接続できる • 入力できる音声データのサンプリングレートな どに指定がある(のでffmpegでの変換が必要) -
バックエンド:ReadableStreamの受け取り app.post("/:id/stream", permissionChecker("talks"), async (c) => { const stream =
c.req.raw.body as ReadableStream; const talkId = Number(c.req.param("id")); const user = c.get("currentUser"); -
ffmpeg に pipe で音声データを渡す // FFmpegコマンドを設定 const ffmpeg = new
Deno.Command("ffmpeg", { // Deno.Command も Stream を返す args: [ "-i", "pipe:0", "-ar", "16000", "-ac", "1", "-sample_fmt", "s16", "-f", "wav", "pipe:1", ], stdin: "piped", stdout: "piped", stderr: "piped", }); const ffmpegProcess = ffmpeg.spawn(); stream.pipeTo(ffmpegProcess.stdin); // Web Streams の Pipe で音声データをそのままffmpeg にわたす -
Whisper Streaming に pipe でデータを渡す const convertedAudioStream = ffmpegProcess.stdout; //
Deno.connect も Stream を返す const whisper = await Deno.connect({ hostname: Deno.env.get("WHISPER_HOST") || "localhost", port: Number(Deno.env.get("WHISPER_PORT")) || 43001, }); convertedAudioStream.pipeTo(whisper.writable); -
Deno の場合、以下がすべてWeb Streams で扱える • HTTP の Request Body •
ファイルの読み書き • コマンドの実行 • Deno.connect や fetch による他サーバーとの通信 便利! -
Streamからテキストデータの取得 const reader = whisper.readable.getReader(); const decoder = new TextDecoder("utf-8");
while (true) { const { done, value } = await reader.read(); if (done) { break; } const text = decoder.decode(value); // text を DB に保存したりクライアントに返送したりすればいい } -
概要 何か話す ブラウザで音声デー タにする WhisperStreamingで 音声をテキスト化 音声バイナリを WebStreamsで サーバーに送る フロントエンド
バックエンド 会話テキストを WebStreamsでポー リング PostgreSQL から データ取得 テキスト表示 Notify 「AIを呼び出す」 ボタンを押下 Ollamaに会話データを 入力して出力をNotifyし テーブルにも保存する Notify Listen Post Request ここの話おわり -
概要 何か話す ブラウザで音声デー タにする WhisperStreamingで 音声をテキスト化 音声バイナリを WebStreamsで サーバーに送る フロントエンド
バックエンド 会話テキストを WebStreamsでポー リング PostgreSQL から データ取得 テキスト表示 Notify 「AIを呼び出す」 ボタンを押下 Ollamaに会話データを 入力して出力をNotifyし テーブルにも保存する Notify Listen Post Request 次はここの話 -
GET通信でStreamデータを読み込み続ける GET talks/${talkId}/stream クライアント (ブラウザ) Streamでテキスト を送る PostgreSQL DBのtalks テーブルに新しいデータが入ったら、それをHTTP
ハンドラからクライアントに送りつけてやればいい ロングポーリング -
GET通信でStreamデータを読み込み続ける GET talks/${talkId}/stream クライアント (ブラウザ) Streamでテキスト を送る PostgreSQL DBのtalks テーブルに新しいデータが入ったら、それをHTTP
ハンドラからクライアントに送りつけてやればいい ロングポーリング どうやってこれを検知すればいいか? -
DenoでのChannelのデメリット • 当然ながらDenoプロセス内で実行されるものなので、サーバーが複数台になった 場合などの対応が面倒くさそう • Channelに相当する部分を外部に出す ◦ Redis の Pub/Sub
や AWS SQS みたいなものを使う ◦ いや、僕らには PostgreSQL がいる! -
PostgreSQL の Channel • Notify/Listen という機能がある • LISTEN foo_channel; •
• NOTIFY foo_channel, 'Hello!'; • • ; • -- Asynchronous notification "foo_channel" with payload "Hello!" received from server process with PID 5963. -
ブラウザ システムへの応用 GET /talks/${talkId}/stream DB中で Listen talks_1; して おく。 通知を受け取ったら
Stream でフロントにデータを送る POST /talks/${talkId}/stream Notify talks_1 ‘{“a”: 1}’ 通知を送る -
DenoにおけるNotify/Listenの実装 export async function notify(channel: string, obj: object) { return
await sql`select pg_notify(${channel}, ${JSON.stringify(obj)})`.execute( db, ); } export async function listen( listenChannel: string, callback: (msg: { channel: string; payload: string }) => void, ) { const pgClient = await pool.connect(); pgClient.on("notification", callback); return await pgClient.query(listenChannel); } -
概要 何か話す ブラウザで音声デー タにする WhisperStreamingで 音声をテキスト化 音声バイナリを WebStreamsで サーバーに送る フロントエンド
バックエンド 会話テキストを WebStreamsでポー リング PostgreSQL から データ取得 テキスト表示 Notify 「AIを呼び出す」 ボタンを押下 Ollamaに会話データを 入力して出力をNotifyし テーブルにも保存する Notify Listen Post Request ここの話おわり -
概要 何か話す ブラウザで音声デー タにする WhisperStreamingで 音声をテキスト化 音声バイナリを WebStreamsで サーバーに送る フロントエンド
バックエンド 会話テキストを WebStreamsでポー リング PostgreSQL から データ取得 テキスト表示 Notify 「AIを呼び出す」 ボタンを押下 Ollamaに会話データを 入力して出力をNotifyし テーブルにも保存する Notify Listen Post Request 次はここの話 -
「AIを呼び出す」ボタンの挙動 • サーバー側にPOSTリクエストを投げる • サーバー側で今までの会話の最新3件をDBから取得(a) • AIのキャラクター設定をDBから取得(b) • (a)(b) をもとにしてLLMのプロンプトを作成
• ローカルLLMにプロンプトを送信 • ローカルLLMからのレスポンスをStreamで受け取る • 上記をフロント側にStreamで返す -
ローカルLLMサーバー • Ollama を使用 ◦ Ollama はローカルLLMを実行しやすくするツール ◦ ChatGPT互換APIなどを提供 •
モデルは Llama-3-ELYZA-JP-8B を使用 ◦ Meta社の llama-3.0 を Elyza 社が日本語化したもの -
「AIを呼び出す」ボタンを押したときの挙動 バックエンド Ollamaサーバー フロントエンド AIを呼び出す DBから必要な データを取得 POST LLM プロンプトを
入力 Notify Streamでテキストを返 す メッセージの表 示 読み上げ (Text to speech) GET Listen -
Text to Speech について • 現状ブラウザ(OS) の機能で読み上げてるだけ function speak(text: string)
{ // SpeechSynthesisUtteranceのインスタンスを作成 const utterance = new SpeechSynthesisUtterance(); utterance.text = text; utterance.lang = "ja-JP"; // 音声を再生 window.speechSynthesis.speak(utterance); } -
ブラウザでの Text to Speech の課題 • 棒読み • 声を選ぶなど自由度が少ない •
フロントの環境依存 ◦ フロント側で日本語の読み上げ機能が有効になっている必要がある • リアルタイム性に欠ける ◦ 現状は10文字ずつ読み上げてるだけ -
まとめ • Web Streams API を活用することで同期処理で音声→テキスト、テキスト→音声 の変換処理を行えるようにした • DenoはWeb標準を大切にしているので Web
Streams API と相性が良い • PostgreSQL の Notify/Listen を活用することでサーバーのプロセスから分離され た Channel を扱うことができる • 音声の読み上げはブラウザだけでも可能