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
530
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
75
Build React system with ClojureScript (Squint)
kbaba1001
0
190
Lume: Static Site Generator
kbaba1001
0
720
React_2023
kbaba1001
0
210
Word Penne
kbaba1001
0
240
I live by using a minor language
kbaba1001
1
220
fast optical line
kbaba1001
0
420
ArtPosePro and Procreate
kbaba1001
1
260
How did Clojure change my life
kbaba1001
3
2k
Other Decks in Programming
See All in Programming
Apache Hive: そしてCloud Native Lakehouseへ
okumin
1
130
5分で問診!Composer セキュリティ健康診断
codmoninc
0
270
型も通る、synthも通る、それでも危ない 〜AIのCDKの権限とコストを機械で検証する〜 / It Passes Type Checks, It Passes Synth Checks, but It’s Still Risky — Automatically Verifying Permissions and Costs in AI’s CDK —
seike460
PRO
1
320
AI がコードを書く時代における新卒エンジニアの仕事風景 (2026) / New Graduate Engineers in the Era of AI Coding (2026)
sushichan044
0
220
SREの積み重ねがAI駆動開発のガードレールになった ― 7つの実践/SRE Guardrails The 7
tomoyakitaura
8
4.3k
地域 SRE コミュニティ最前線 - ホンマでっかSRE勉強会
tk3fftk
0
230
PHP初心者セッション2026 〜生成AIでは見えない裏側を知る:今だからLAMPを通して仕組みを学ぶ〜
kashioka
0
450
才能?センス?知らん、 続けたもん勝ちだ。-- 結婚・出産・癌を越えてなお、私がプロダクトを創り続ける理由
16bitidol
2
860
【やさしく解説 設計編 #1】「ドメイン駆動」と「実装駆動」ってなに? 〜設計の考え方を、たとえ話で学ぼう〜
panda728
PRO
1
120
AI駆動開発を妨げる技術的負債の解消アプローチ / ai-refactoring-approach
minodriven
17
9.1k
Terraform標準の組織で AWS CDKをどう使うか
mu7889yoon
0
200
共通化で考えるべきは、実装より公開する型だった
codeegg
0
240
Featured
See All Featured
brightonSEO & MeasureFest 2025 - Christian Goodrich - Winning strategies for Black Friday CRO & PPC
cargoodrich
3
750
YesSQL, Process and Tooling at Scale
rocio
174
15k
Marketing to machines
jonoalderson
1
5.6k
ReactJS: Keep Simple. Everything can be a component!
pedronauck
666
130k
The agentic SEO stack - context over prompts
schlessera
0
850
A Modern Web Designer's Workflow
chriscoyier
698
190k
Designing for Performance
lara
611
70k
How to optimise 3,500 product descriptions for ecommerce in one day using ChatGPT
katarinadahlin
PRO
1
3.7k
Navigating Weather and Climate Data
rabernat
0
360
Visual Storytelling: How to be a Superhuman Communicator
reverentgeek
2
590
Docker and Python
trallard
47
4k
The Organizational Zoo: Understanding Human Behavior Agility Through Metaphoric Constructive Conversations (based on the works of Arthur Shelley, Ph.D)
kimpetersen
PRO
0
390
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 を扱うことができる • 音声の読み上げはブラウザだけでも可能