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
Sponsored
·
SiteGround - Reliable hosting with speed, security, and support you can count on.
→
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
73
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
Semantic Version 単位で戦略を柔軟に変えて、パッケージアップデートを自動化する
daitasu
1
300
Snowflake Summitでの新機能 CoCo / CoWork / snowflake-summit-2026-overall-what-new-coco
tatsuhiro
1
180
Contextとはなにか
chiroruxx
1
370
TypeScript+Orvalで実現する型安全かつ堅牢でスケーラブルなマルチチャネル通知基盤 / TSKaigi Night talks ~after conference~
d0riven
0
360
ADKを使って簡単にAIエージェントを作ってみよう
k1mu21
0
280
エンジニアと一緒にテストコードの設計と実装を改善した話
mototakatsu
0
220
才能?センス?知らん、 続けたもん勝ちだ。-- 結婚・出産・癌を越えてなお、私がプロダクトを創り続ける理由
16bitidol
1
270
セキュリティの専門家じゃなくてもできる。「セキュリティ意識」をアップデートして サプライチェーン攻撃への耐性を高めよう。
tk3fftk
5
920
フロントエンドとバックエンドで「1文字」を揃えよう
youkidearitai
PRO
0
740
Oxcを導入して開発体験が向上した話
yug1224
4
340
Observability in Practice:Grafana 與 Edge Device SRE 的那些事
blueswen
0
170
Language Server 使ってる? 〜VSCode と Zed の場合〜 / Are you using a Language Server? ~For VS Code and Zed~
handlename
0
800
Featured
See All Featured
Connecting the Dots Between Site Speed, User Experience & Your Business [WebExpo 2025]
tammyeverts
11
950
How Software Deployment tools have changed in the past 20 years
geshan
0
34k
Groundhog Day: Seeking Process in Gaming for Health
codingconduct
0
220
DBのスキルで生き残る技術 - AI時代におけるテーブル設計の勘所
soudai
PRO
66
55k
Producing Creativity
orderedlist
PRO
348
40k
Distributed Sagas: A Protocol for Coordinating Microservices
caitiem20
333
23k
The Illustrated Guide to Node.js - THAT Conference 2024
reverentgeek
1
390
HTML-Aware ERB: The Path to Reactive Rendering @ RubyCon 2026, Rimini, Italy
marcoroth
1
230
Intergalactic Javascript Robots from Outer Space
tanoku
273
27k
How People are Using Generative and Agentic AI to Supercharge Their Products, Projects, Services and Value Streams Today
helenjbeal
1
220
Art, The Web, and Tiny UX
lynnandtonic
304
22k
[SF Ruby Conf 2025] Rails X
palkan
2
1.1k
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 を扱うことができる • 音声の読み上げはブラウザだけでも可能