Upgrade to Pro
— share decks privately, control downloads, hide ads and more …
Speaker Deck
Features
Speaker Deck
PRO
Sign in
Sign up for free
Search
Search
talk-with-local-llm-with-web-streams-api
Search
kbaba1001
December 04, 2024
Programming
520
0
Share
Embed
Copy iframe code
Copy JS code
Copy link
Start on current slide
talk-with-local-llm-with-web-streams-api
kbaba1001
December 04, 2024
More Decks by kbaba1001
See All by kbaba1001
How to build a video conferencing system that no one has ever told you about
kbaba1001
0
72
Build React system with ClojureScript (Squint)
kbaba1001
0
190
Lume: Static Site Generator
kbaba1001
0
710
React_2023
kbaba1001
0
200
Word Penne
kbaba1001
0
240
I live by using a minor language
kbaba1001
1
210
fast optical line
kbaba1001
0
420
ArtPosePro and Procreate
kbaba1001
1
250
How did Clojure change my life
kbaba1001
3
2k
Other Decks in Programming
See All in Programming
Honoでのサプライチェーン侵害対策 〜 3つのライブラリに学ぶ
yusukebe
6
1.3k
依存関係から依存物へ―Dependencyという言葉の歴史をひも解く
j_lee
0
120
net-httpのHTTP/2対応について
naruse
0
500
Composerを使ったサプライチェーン攻撃の様子を眺めてみる #phpstudy
o0h
PRO
2
250
「エンジニアインターン、どうやって取った?」準備のリアルを語るLT会 Progate BAR
akiomatic
0
140
Vite+ Unified Toolchain for the Web
naokihaba
0
320
Snowflake Summitでの新機能 CoCo / CoWork / snowflake-summit-2026-overall-what-new-coco
tatsuhiro
1
150
生成AI時代にこそ効くGo | Why Go Works in the Age of Generative AI
mom0tomo
8
3.3k
肥大化するレガシーコードに立ち向かうためのインターフェース分離と依存の逆転 / JJUG CCC 2026 Spring
hirokunimaeta
0
570
The ROI of Quarkus for Spring Boot Applications
hollycummins
0
120
並列実装の現場、2ヶ月間実務でAIを使い倒したAIもPCも私も限界が近い
ming_ayami
0
130
JavaDoc 再入門
nagise
1
370
Featured
See All Featured
Max Prin - Stacking Signals: How International SEO Comes Together (And Falls Apart)
techseoconnect
PRO
0
180
The browser strikes back
jonoalderson
0
1.3k
How to build an LLM SEO readiness audit: a practical framework
nmsamuel
1
780
A better future with KSS
kneath
240
18k
The Curse of the Amulet
leimatthew05
1
13k
Building Better People: How to give real-time feedback that sticks.
wjessup
370
20k
SEO in 2025: How to Prepare for the Future of Search
ipullrank
3
3.5k
JAMstack: Web Apps at Ludicrous Speed - All Things Open 2022
reverentgeek
1
480
Designing Powerful Visuals for Engaging Learning
tmiket
1
420
Leading Effective Engineering Teams in the AI Era
addyosmani
9
2.1k
Bioeconomy Workshop: Dr. Julius Ecuru, Opportunities for a Bioeconomy in West Africa
akademiya2063
PRO
1
150
First, design no harm
axbom
PRO
2
1.2k
Transcript
Local LLMと会話できるWebアプリを作ってみた 〜Web Streams API しか勝たん〜 2024/12/4 馬場一樹(kbaba1001)
デモムービー
つくったもの • 音声でAIと話せるやつ • 特徴:外部APIを使わずすべてサーバー内で実行している • Backend: Deno, Hono, Kysely,
PostgreSQL • Frontend: Node, Vite, React •
コンセプト • 外部APIを使わずローカルGPUを活用してシステムを構築する • ストリーミング処理を活用して早く動いているように見せる • Web Socket を使わず、 Web
Streams API を活用する
ストリーミング処理について
ストリーミング処理 • データをChunkという単位で少しずつフロントに送るような処理 • 例えばストリーミング配信: ◦ 動画データを少しずつフロントエンドに送っている • 例えばGoogleDocs: ◦
少しずつデータをサーバーに送ることで同期をとって同時編集ができる
ストリーミング処理でググると。。。 • WebSocket や WebRTC の話がたくさん出てくる
WebSocket のメリット • Socket io などのライブラリを使えば簡単に実装できる • 古いブラウザでも動く •
WebSocketのデメリット • 双方向通信のため通信量が多い • HTTP/HTTPS ではなく ws/wss というプロトコルで通信するため、HTTPハンドラと は別の設計が必要 •
おまけ: WebRTC • クライアントとクライアントの通信なので微妙 ◦ 例えばミーティングツールを WebRTCで作った場合に仮想背景機能を実装しようとしたら WebGPU などを使うしかない
Web Streams しか勝たん
Web Streams API とはなにか? https://developer.mozilla.org/en-US/docs/Web/API/Streams_API
Readable Stream https://developer.mozilla.org/en-US/docs/Web/API/Streams_API/Concepts
Writable Stream https://developer.mozilla.org/en-US/docs/Web/API/Streams_API/Concepts
Pipe https://developer.mozilla.org/en-US/docs/Web/API/Streams_API/Concepts
どこで使われているのか?: fetchのResponse body
どこで使われているのか?: Hono の Streaming Helper
どこで使われているのか?: 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 を扱える • 通信が単方向なので軽め
Web Streams のデメリット • 知名度が低いのかドキュメントが少ない ◦ ほとんどMDN読むしかない ◦ ただし、ChatGPTなどに聞くと詳しく教えてくれる •
AIとの会話システムの実装
概要 何か話す ブラウザで音声デー タにする 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 次はここの話
つまりこの部分を作る
テキスト化したデータのクライアントでの表示 • Whisperでテキスト化した文字データをクライアントでどうやって取得するか? • 先ほどのPOSTハンドラのレスポンスをStreamにする手もあるが、もう少し汎用的 にしたい
GET通信でStreamデータを読み込み続ける GET talks/${talkId}/stream クライアント (ブラウザ) Streamでテキスト を送る PostgreSQL DBのtalks テーブルに新しいデータが入ったら、それをHTTP
ハンドラからクライアントに送りつけてやればいい ロングポーリング
GET通信でStreamデータを読み込み続ける GET talks/${talkId}/stream クライアント (ブラウザ) Streamでテキスト を送る PostgreSQL DBのtalks テーブルに新しいデータが入ったら、それをHTTP
ハンドラからクライアントに送りつけてやればいい ロングポーリング どうやってこれを検知すればいいか?
方針 • Whisperのレスポンスのテキストデータを新規作成したら、それをChannel (Queue) のようなものに入れて通知してやれば楽そう
DenoにおけるChannel • MessageChannel • node:diagnostics_channel • など
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を呼び出すトリガー 現状ボタンにしている。 (将来的にはユーザーの声が無音になったときを トリガーにしたい)
「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文字ずつ読み上げてるだけ
今後の展望 • TextをStreamで渡したら合成音声をStreamで返してくれるサーバーを作って、音 声データをフロントに送るようにしたい • RealtimeTTS が良さそうだと思って試したけど、イマイチだったので自作することに した。 •
まとめ
まとめ • Web Streams API を活用することで同期処理で音声→テキスト、テキスト→音声 の変換処理を行えるようにした • DenoはWeb標準を大切にしているので Web
Streams API と相性が良い • PostgreSQL の Notify/Listen を活用することでサーバーのプロセスから分離され た Channel を扱うことができる • 音声の読み上げはブラウザだけでも可能