9
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

非力なノートPCで動くVRローカルLLM会話エージェント「piaちゃん」と一緒に登壇する

9
Last updated at Posted at 2025-12-22

この記事は、Elixir Advent Calendar 2025その1 の22日目です

昨日は @t-yamanashi さんで 「Hugging Face形式をgguf形式にしてElixirでOllamaを使う」 でした


piacere です、ご覧いただいてありがとございます :bow:

先日、非力なノートPCで動くVRローカルLLM会話エージェント「piaちゃん」と一緒に登壇してきましたが、同じものを作るノウハウをコラム化しました

Ollama+Web Speech API+VOICEVOXによる会話AIエージェントを下記コラムにて作りましたが、そこにVRアバター「piaちゃん」によるビジュアル/挙動と、LINEやChatGPT、Geminiのような会話履歴(最新会話への追従スクロールも)を追加したものとなります

Elixirアドベントカレンダー、応援お願いします :bow:

今年もやっています

全体像

下記をLiveView上で実装します

  • ①VRアバター「piaちゃん」がゆらゆら揺れながら待ち受ける
  • ②Web Speech APIで音声入力を受け付け、喋りかけられるようにする
  • ③喋りかけた内容をローカルLLMエンジン「Ollama」にて推論し、回答文を作る
  • ④音声合成エンジン「VOICEVOX」でAI推論結果を喋らす
  • ⑤喋る内容に合わせて、piaちゃんの口を動かす
  • ⑥やり取りした履歴を会話形式UIで残す

コラムで使った環境は、下記CPU/GPU/メモリ/OSがセットアップされたノートPCです

  • Ryzen 7 6800U with Redeon
  • Windows 11
  • WSL2 Ubuntu 24.04
  • Erlang 28.1.1
  • Elixir 1.19.3-otp-28
  • Phoenix 1.8.1
  • Chrome ※ブラウザがChromeかEdgeで無いと動きません

上記中、2025年11月時点の最新Elixir/ErlangとPhoenixのインストール方法は、下記コラムで解説しています

①VRアバター「piaちゃん」のロード/待ち受け

Resonite で使っているのと同じVRアバター「piaちゃん」のVRMデータをLiveView上でロードし、@t-yamanashi さん作のThree.jsとLiveViewを接続するJavaScript Hook(phx-hook)でHTML上描画や位置(position)と回転(rotation)の調整、ボーン(rotation_bone)の調整、口(set_blend_shape)の調整を行っています

なお、ロード時にpiaちゃんを常時ゆらゆら揺らしたり、瞬きさせるためのバックグラウンドタスク2つも起動します

basic/lib/basic_web/live/buddy_live.html.heex
  <div id="threejs" phx-hook="threejs" phx-update="ignore" data-data={@data}></div>
basic/lib/basic_web/live/buddy_live.ex
  @impl true
  def mount(_params, _session, socket) do
    pid = self()
    socket = socket
      |> assign(conversations: [])
      |> assign(text: "")
      |> assign(is_recording: false)
      |> assign(prompt: "")
      |> assign(old_sentence_count: 1)
      |> assign(sentences: [])
      |> assign(old_sentences: [])
      |> assign(talking_no: 0)
      |> assign(talking: false)
      |> assign(max_talking_no: 0)
      |> assign(pid: pid)
      |> assign(data: 0)
      |> load_model("test", "images/meiden.vrm")
    {:ok, socket}
  end

  @impl true
  def handle_event("load_model", %{"name" => "test", "status" => "completion"}, socket) do
    socket =
      socket
      |> position("test", 0.0, -1.2, 4.85)
      |> rotation("test", -0.1, 3.2, 0.0)
      |> rotation_bone("test", "J_Bip_R_UpperArm", -1.0, 1.2, 0.5)
      |> rotation_bone("test", "J_Bip_L_UpperArm", -1.0, -1.2, -0.3)
      |> set_blend_shape("test", "aa", 0)  # aa/ih/ou/ee/ohで口を開く、blinkで目を閉じる、happy、angry、sad、relaxed

      Task.start_link(fn -> blink(socket.assigns.pid) end)
      Task.start_link(fn -> move(socket.assigns.pid, 1) end)

      {:noreply, socket}
  end

piaちゃんを常時ゆらゆら揺らすのは下記コードで、:math.sin によるサインカーブでアバターを回転させることで実現しています

basic/lib/basic_web/live/buddy_live.ex
  def move(pid, i) do
    send(pid, {:move, i})
    Process.sleep(50)
    move(pid, i + 0.1)
  end

  @impl true
  def handle_info({:move, v}, socket) do
    sin = :math.sin(v) * 0.005
    socket = socket
      |> rotation("test", -0.1, 3.2, sin)
    {:noreply, socket}
  end

また瞬きも3秒に1回、行っています

basic/lib/basic_web/live/buddy_live.ex
  def blink(pid) do
    Process.sleep(100)
    send(pid, {:blink, 1})
    Process.sleep(100)
    send(pid, {:blink, 0})
    Process.sleep(3000)
    blink(pid)
  end

  @impl true
  def handle_info({:blink, v}, socket) do
    socket = set_blend_shape(socket, "test", "blink", v)
    {:noreply, socket}
  end

②Web Speech APIで音声入力を受け付ける

ボタンを押すと、handle_event("start"(LiveView)recognize(JS)recognition.start(JS)recognition.onresult(JS)handle_event("finish"(LiveView) の順で処理されて、Web Speech APIでの音声入力を受け付け、後述する「③喋りかけた内容をOllamaで推論し、回答文を作る」の handle_info({"predict" に処理を渡します

なお、私のノートPCのマイク性能が悪いせいか、handle_event("finish" で音声認識結果の拾い間違えを補正しています

basic/lib/basic_web/live/buddy_live.html.heex
          <button 
	          class="btn btn-success text-white"
	          id="start" phx-click="start" phx-hook="Speech" disabled={@is_recording}>
	        <%= if @is_recording do %>
	          🔴 音声認識中…
	        <% else %>
	          🎙️ ここを押して喋ってください
	        <% end %>
	      </button>
basic/lib/basic_web/live/buddy_live.ex
  @impl true
  def handle_event("start", _value, socket) do
    socket = assign(socket, is_recording: true, prompt: "", sentences: [])
    {:noreply, push_event(socket, "recognize", %{})}
  end

  @impl true
  def handle_event("finish", %{"transcript" => transcript}, %{assigns: assigns} = socket) do
    transcript = transcript
      |> String.replace("ひなちゃん", "piaちゃん")
      |> String.replace("ひやちゃん", "piaちゃん")
      |> String.replace("ぴあちゃん", "piaちゃん")
      |> String.replace("里奈ちゃん", "piaちゃん")
      |> String.replace("ヒロちゃん", "piaちゃん")
      |> String.replace("いや、ちゃん", "piaちゃん")
      |> String.replace("で、ちゃん", "piaちゃん")
      |> String.replace("いやちゃん", "piaちゃん")
      |> String.replace("リアンちゃん", "piaちゃん")

      |> String.replace("平成", "生成")

      |> String.replace("えっ。ずっ", "エッジ")
      |> String.replace("ヘッジ", "エッジ")
      |> String.replace("エッチ", "エッジ")
      |> String.replace("レッド", "エッジ")
      |> String.replace("ベッジ", "エッジ")
      |> String.replace("れっき", "エッジ")

      |> String.replace("えっと、", "エッジと")
      |> String.replace("えっ?自分、", "エッジと")
      |> String.replace("一度", "エッジと")
      |> String.replace("一と", "エッジと")
      |> String.replace("内と", "エッジと")
      |> String.replace("ちょっと。", "エッジと")

      |> String.replace("生成愛", "生成AI")
      |> String.replace("性提案", "生成AI")
      |> String.replace("性成員", "生成AI")
      |> String.replace("性愛", "生成AI")
      |> String.replace("訂正、", "生成AI")
      |> String.replace("性性", "生成AI")
      |> String.replace("性西安", "生成AI")
      |> String.replace("性生成AI", "生成AI")

      |> String.replace("分散性", "分散生成AI")
      |> String.replace("分散性AI", "分散生成AI")
      |> String.replace("分散1000円", "分散生成AI")
      |> String.replace("物産生成AI", "分散生成AI")

      |> String.replace("分散性やで。", "分散生成AIで")
      |> String.replace("分散性。", "分散生成AIで")

      |> String.replace("相談", "登壇")

      |> String.replace("のこと", "の登壇")

      |> String.replace("ローカルm", "ローカルLLM")

      |> String.replace("料金", "領域")
      |> String.replace("病気", "領域")
      |> String.replace("努力", "領域")

      |> String.replace("興味?", "興味ある?")

      |> String.replace("邪魔。スリー", "Gemma3")
      |> String.replace("エリクサー", "Elixir")
      |> String.replace("ラグ", "RAG")
      |> String.replace("恋愛", "AI")

    socket = socket
      |> assign(is_recording: false)
      |> assign(talking_no: 0)
      |> assign(conversations: assigns.conversations ++ [%{"prompt" => transcript, "answer" => "AI処理中…"}])

    send(self(), {"predict", transcript})
    {:noreply, socket}
  end
assets/js/speech.js
import { LiveSocket } from "phoenix_live_view";

let Hooks = {};
Hooks.Speech = {
  mounted() {
    const SpeechRecognition = window.SpeechRecognition || window.webkitSpeechRecognition;
    this.recognition = new SpeechRecognition();
    this.recognition.lang = 'ja-JP';
    this.recognition.continuous = false;
    this.recognition.interimResults = false;
    const liveHook = this; 

    this.handleEvent("recognize", () => {
      try {
        this.recognition.start();
      } catch (e) {
        liveHook.pushEvent("error", {error: "すでに音声認識が実行中です"});
      }
    });

    this.recognition.onresult = (event) => {
      const finalTranscript = event.results[0][0].transcript;
      liveHook.pushEvent("finish", {transcript: finalTranscript});
    };

    this.recognition.onerror = (event) => {
      liveHook.pushEvent("error", {error: event.error});
    };
  }
};

export default Hooks;

③喋りかけた内容をOllamaで推論し、回答文を作る

下記コード群でOllamaによって Gemma3:1b での推論を行い、回答文を作っています

Ollama.completion 実行時、stream: true を指定すると、回答チャンク生成の度に "done" が非同期で返ってくるので、それを handle_info(%{"done" で受け取るようにし、都度、後述の「④VOICEVOXでAI推論結果を喋らす」の speak_first に処理を回すことでスムースな受け答えを実現しています

なお、system_prefixuser_prefix によるプロンプト制御と、handle_info(%{"done" による回答文の調整が、会話に丁度良い回答を生み出すコツです

basic/lib/basic_web/live/buddy_live.ex
  @impl true
  def handle_info({"predict", transcript}, %{assigns: assigns} = socket) do
    results = Enum.join(assigns.old_sentences, "、")  #TODO: old_sentencesをold_textにすれば

    system_prefix  =
      "あなたはpiaちゃんという名前です(名乗る必要はありません、挨拶も省略して)。" <>
      "親切に語るアシスタントです。\n" <>
      "前回のpiaちゃんの返答は以下の通りです。\n"
    system_postfix = "答えは丁寧な口語で、2~3文程度で簡潔に答えてください。\n"

    user_prefix    = ""
    user_postfix   = "答えは丁寧な口語で、2~3文程度で簡潔に答えてください。\n"

    system = system_prefix <> system_postfix
    user = user_prefix  <> "## コンテキスト情報\n" <> results <> "## ユーザーの質問\n" <> transcript <> user_postfix

    pid = self()
    socket = socket
      |> assign(text: "")
      |> assign(is_recording: false)
      |> assign(prompt: system <> user)
      |> assign_async(:ret, fn -> run(pid, user, system) end)

    {:noreply, socket}
  end

  def run(pid_liveview, user, system) do
    {_, task_pid} = Task.start_link(fn -> run_ollama(pid_liveview, user, system) end)
    send(pid_liveview, {:task_pid, task_pid})
    {:ok, %{ret: :ok}}
  end

  def run_ollama(pid_liveview, user, system) do
    client = Ollama.init()
    {:ok, stream} = Ollama.completion(client, model: "gemma3:1b", system: system, prompt: user, stream: true)

    stream
    |> Stream.each(&Process.send(pid_liveview, &1, []))  # 単語ごとに区切って"done" => falseを返し続ける
    |> Stream.run()
  end

  @impl true
  def handle_info(%{"done" => false, "response" => response}, %{assigns: assigns} = socket) do
    old_sentence_count = assigns.old_sentence_count
    text = assigns.text <> response
      |> String.replace("PIAちゃんです。", "")
      |> String.replace("PIAです。", "")
      |> String.replace("こんにちは", "")
      |> String.replace("こんにちは、", "")
      |> String.replace("こんにちは!", "")
      |> String.replace("こんにちは。", "")
      |> String.replace("こんにちは!", "")
      |> String.replace("お疲れ様です。", "")
      |> String.replace("お疲れ様です!", "")
      |> String.replace("エリクサー", "Elixir")
      |> String.trim_leading("!")
      |> String.trim_leading("。")
      |> String.trim_leading("、")

    sentences = String.split(text, ["。", "、", "?", "!"])
    new_sentence_count = Enum.count(sentences)

    socket = socket
      |> assign(is_recording: false)
      |> assign(sentences: sentences)
      |> assign(old_sentences: assigns.sentences)
      |> assign(old_sentence_count: new_sentence_count)
      |> assign(conversations: List.update_at(assigns.conversations, -1, fn map ->
          %{map | "answer" => String.replace(map["answer"], "AI処理中…", "") <> hd(sentences)} end))
      |> speak_first(old_sentence_count, new_sentence_count, sentences)

    {:noreply, socket}
  end

  @impl true
  def handle_info(%{"done" => true}, %{assigns: assigns} = socket) do
    max_talking_no = Enum.count(assigns.sentences) - 1
    socket = socket
      |> assign(max_talking_no: max_talking_no)
    {:noreply, socket}
  end

④VOICEVOXでAI推論結果を喋らす

下記コード群でVOICEVOXによるAI推論結果の再生を行っていますが、推論結果をチャンク化した文章を speak_first(LiveView)synthesize_and_play(LiveView)synthesize_and_play(JS)speakText(JS)handle_event("voice_playback_finished"(LiveView)speak_next(LiveView)synthesize_and_play(LiveView)synthesize_and_play(JS)speakText(JS)handle_event("voice_playback_finished"(LiveView) → (以降、チャンクが無くなるまで speak_next を繰り返す)で順々に再生することで、細切れにVOICEVOX音声生成と再生を繰り返すことで、大きなVOICEVOX音声生成によるブロックを避け、滑らかな喋りを実現しています

なお、speakText(JS) では、再生内容のボリューム測定も行っており、後述の「⑤喋る内容でpiaちゃんの口を動かす」の元データも取得しています

basic/lib/basic_web/live/buddy_live.html.heex
    <div class="p-4 w-[500px] h-[800px] overflow-y-auto" id="Voicex" phx-hook="Voicex">

    </div>
basic/lib/basic_web/live/buddy_live.ex
  defp speak_first(%{assigns: assigns} = socket, _old_sentence_count = 1, _new_sentence_count = 2, sentences) do
    sentences
    |> hd()
    |> synthesize_and_play(socket)
    |> assign(talking: true)
  end

  defp speak_first(socket, _, _, _sentences), do: socket

  @impl true
  def handle_event("voice_playback_finished", _, %{assigns: assigns} = socket) do
    socket = socket
      |> assign(old_sentences: assigns.sentences)
    talking_no = assigns.talking_no + 1
    sentences = assigns.sentences
    text = Enum.at(sentences, talking_no)

    socket = speak_next(socket, talking_no, 100, text)
    {:noreply, socket}
  end

  defp speak_next(socket, talking_no, max_talking_no, text) when talking_no <= max_talking_no do
    synthesize_and_play(text, socket)
    |> assign(talking_no: talking_no)
    |> assign(talking: true)
  end

  defp speak_next(socket, _talking_no, _max_talking_no, _text) do
    assign(socket, talking_no: 0)
    |> assign(max_talking_no: 0)
    |> assign(talking: false)
  end

  defp synthesize_and_play(text, socket) do
    push_event(socket, "synthesize_and_play", %{
      "text" => text,
      "speaker_id" => "20"
    })
  end

  defp voice_volume(true, volume), do: volume * 10

  defp voice_volume(false, _), do: 0
assets/js/voicex.js
const VOICEVOX_URL = "http://localhost:50021";

Voicex = {
    currentAudioPlayer: null,
    currentAudioUrl: null,

    audioContext: null,
    analyser: null,
    volumeCheckId: null, // requestAnimationFrame の ID
    _volumeCount: 0, 

    mounted() {
        this.handleEvent("synthesize_and_play", ({ text, speaker_id }) => {
            const el = document.getElementById("Voicex");
            el.scrollTop = el.scrollHeight;
            this.stopPlayback();
            this.speakText(text, speaker_id);
        });

    },

    async speakText(text, speakerId) {
        try {
            const wavBlob = await this.synthesizeTextToBlob(text, speakerId);

            const audioPlayer = new Audio();
            const audioUrl = URL.createObjectURL(wavBlob);
            audioPlayer.src = audioUrl;

            this.currentAudioPlayer = audioPlayer;
            this.currentAudioUrl = audioUrl;

            // --- Web Audio API 初期化 ---
            if (!this.audioContext) {
                this.audioContext = new (window.AudioContext || window.webkitAudioContext)();
            }

            const source = this.audioContext.createMediaElementSource(audioPlayer);
            this.analyser = this.audioContext.createAnalyser();
            this.analyser.fftSize = 512;

            source.connect(this.analyser);
            this.analyser.connect(this.audioContext.destination);

            // --- ボリューム測定開始 ---
            this.startVolumeMonitor();

            await audioPlayer.play();

            const cleanup = () => {
                if (this.currentAudioPlayer === audioPlayer) {
                    this.stopVolumeMonitor();
                    URL.revokeObjectURL(audioUrl);
                    this.currentAudioPlayer = null;
                    this.currentAudioUrl = null;
                    this.pushEvent("voice_playback_finished", { status: "ok" });
                }
            };

            audioPlayer.onended = cleanup;
            audioPlayer.onerror = cleanup;

        } catch (error) {
            console.error("致命的なエラー:", error.message, error);

            this.stopVolumeMonitor();
            this.currentAudioPlayer = null;
            this.currentAudioUrl = null;
        }
    },

    async fetchSynthesis(audioQuery, speakerId) {
        const synthesisParams = new URLSearchParams({ speaker: speakerId });
        const synthesisUrl = `${VOICEVOX_URL}/synthesis?${synthesisParams}`;

        const synthesisResponse = await fetch(synthesisUrl, {
            method: 'POST',
            headers: { 'Content-Type': 'application/json' },
            body: JSON.stringify(audioQuery)
        });

        if (!synthesisResponse.ok) {
            throw new Error(`synthesis failed with status ${synthesisResponse.status}`);
        }
        return await synthesisResponse.blob();
    },

    async synthesizeTextToBlob(text, speakerId) {
        const trimmedText = text.trim();
        if (!trimmedText) throw new Error("Text input is empty.");

        const audioQuery = await this.fetchAudioQuery(trimmedText, speakerId);

        audioQuery.speedScale = 1.5;

        const wavBlob = await this.fetchSynthesis(audioQuery, speakerId);
        return wavBlob;
    },

なお、再生中でも下記コード群により中断可能としており、ここの task_pid はOllamaタスクのPIDで、前述の run の中で取得したPIDを連携し、Process.exit で推論タスク毎、止めています

音声再生は stop_voice_playback でJS hookを呼び、止めています

basic/lib/basic_web/live/buddy_live.html.heex
	      <button class="btn btn-error" phx-click="stop">回答の中断</button>
basic/lib/basic_web/live/buddy_live.ex
  @impl true
  def handle_info({:task_pid, pid}, socket) do
    {:noreply, assign(socket, task_pid: pid)}
  end

  @impl true
  def handle_event("stop", _, %{assigns: assigns} = socket) do
    if socket.assigns.task_pid do
      Process.exit(socket.assigns.task_pid, :kill)
    end

    socket = socket
      |> assign(is_recording: false)
      |> assign(prompt: "")
      |> assign(sentences: [])
      |> assign(old_sentences: [assigns.prompt] ++ assigns.sentences)
      |> assign(old_sentence_count: 1)
      |> assign(talking_no: 0)
      |> assign(task_pid: nil)
      |> assign(talking: false)
      |> stop_voice_playback()
      |> set_blend_shape("test", "aa", 0)
    {:noreply, socket}
  end

  defp stop_voice_playback(socket) do
    push_event(socket, "stop_voice_playback", %{})
  end
assets/js/voicex.js
    mounted() {

        this.handleEvent("stop_voice_playback", () => {
            this.stopPlayback();
        });

また、会話文脈のリセットも下記コード群によって行っています

basic/lib/basic_web/live/buddy_live.html.heex
	      <button class="btn btn-accent" phx-click="reset">会話をリセット</button>
basic/lib/basic_web/live/buddy_live.ex
  @impl true
  def handle_event("reset", _, %{assigns: _assigns} = socket) do
    socket = socket
      |> assign(is_recording: false)
      |> assign(prompt: "")
      |> assign(sentences: [])
      |> assign(old_sentences: [])
      |> assign(old_sentence_count: 1)
      |> assign(talking_no: 0)
      |> assign(task_pid: nil)
      |> assign(talking: false)
      |> set_blend_shape("test", "aa", 0)
    {:noreply, socket}
  end

⑤喋る内容でpiaちゃんの口を動かす

上記で取得済みの再生内容ボリューム測定から、VRアバターの口の開きを制御しています

basic/lib/basic_web/live/buddy_live.ex
  @impl true
  def handle_event("voice_volume", %{"volume" => v}, socket) do
    volume = voice_volume(socket.assigns.talking, v) * 7
    socket = socket
      |> set_blend_shape("test", "aa", volume)
    {:noreply, socket}
  end

⑥やり取りした履歴を会話形式UIで残す

assigns.conversations に、各会話回ごとの %{"prompt" => transcript, "answer" => "AI処理中…"} をリストし、それをHTML側で :for を使ってループ表示しています

なお、handle_info(%{"done" でOllamaからストリーミングされるたびに、発声する1文単位である hd(sentence) で会話履歴の回答文章をストリーミング更新し、el.scrollTop = el.scrollHeight により会話履歴の最新を追従スクロールします

basic/lib/basic_web/live/buddy_live.html.heex
    <div class="p-4 w-[500px] h-[800px] overflow-y-auto" id="Voicex" phx-hook="Voicex">
      <div class="chat-body" :for={conversation <- @conversations}>

        <div>
          <div class="message user" id="prompt" disabled={@is_recording}>{conversation["prompt"]}</div>
        </div>

        <div>
          <div class="message assistant">{Phoenix.HTML.raw(conversation["answer"])}</div>
        </div>

      </div>
basic/lib/basic_web/live/buddy_live.ex
  @impl true
  def handle_info(%{"done" => false, "response" => response}, %{assigns: assigns} = socket) do

    socket = socket
      
      |> assign(conversations: List.update_at(assigns.conversations, -1, fn map ->
          %{map | "answer" => 
            String.replace(map["answer"], "AI処理中…", "") <> hd(sentences)} end))
assets/js/voicex.js
    mounted() {
        this.handleEvent("synthesize_and_play", ({ text, speaker_id }) => {
            const el = document.getElementById("Voicex");
            el.scrollTop = el.scrollHeight;

会話UIの吹き出しは、下記CSSで形成しており、buddy_live.html.heex に追加してください(ココはChatGPTのプレビュー付きプロンプトで作りました)

basic/lib/basic_web/live/buddy_live.html.heex
<style>
  :root {
    --bg: #f5f7fb;
    --panel: #ffffff;
    --user: #00BAA6;
    --user-text: #ffffff;
    --assistant: #e5e7eb;
    --assistant-text: #111827;
    --muted: #6b7280;
    --radius: 14px;
  }

  * { box-sizing: border-box; }

  .chat {
    width: 100%;
    max-width: 420px;
    height: 80vh;
    background: var(--panel);
    border-radius: 20px;
    box-shadow: 0 20px 40px rgba(0,0,0,.08);
    display: flex;
    flex-direction: column;
    overflow: hidden;
  }

  .chat-header {
    padding: 14px 18px;
    border-bottom: 1px solid #e5e7eb;
    font-weight: 600;
  }

  .chat-body {
    flex: 1;
    padding: 16px;
    overflow-y: auto;
    display: flex;
    flex-direction: column;
    gap: 10px;
  }

  .chat-body > div {
    display: flex;
    flex-direction: column;
  }

  .message {
    max-width: 80%;
    padding: 10px 14px;
    border-radius: var(--radius);
    font-size: 14px;
    line-height: 1.6;
    word-wrap: break-word;
  }

  .assistant {
    background: var(--assistant);
    color: var(--assistant-text);
    align-self: flex-start;
    border-top-left-radius: 0;
    position: relative;
    margin-left: 8px; /* 尖り分 */
  }

  .assistant::before {
    content: "";
    position: absolute;
    left: -8px;
    top: 0;
    width: 8px;
    height: 9px;
    background: var(--assistant);
    clip-path: polygon(0 0, 100% 0, 100% 100%);
  }

  .user {
    background: var(--user);
    color: var(--user-text);
    align-self: flex-end;
    border-top-right-radius: 0;
    position: relative;
    margin-right: 8px; /* 尖り分 */
  }

  .user::after {
    content: "";
    position: absolute;
    right: -8px;
    top: 0;
    width: 8px;
    height: 9px;
    background: var(--user);
    clip-path: polygon(100% 0, 0 0, 0 100%);
  }

  .timestamp {
    font-size: 11px;
    color: var(--muted);
    margin-top: 2px;
  }

  .input-area {
    display: flex;
    gap: 8px;
    padding: 12px;
  }

  .input-area input {
    flex: 1;
    border: 1px solid #d1d5db;
    border-radius: 999px;
    padding: 10px 14px;
    font-size: 14px;
    outline: none;
  }

  .input-area button {
    border: none;
    background: var(--user);
    color: #fff;
    border-radius: 999px;
    padding: 0 16px;
    font-size: 14px;
    cursor: pointer;
  }

  .input-area button:hover {
    opacity: .9;
  }
</style>

更なる高速化

  • 音声生成の並行化(バックプレッシャー化)
    • Broadway を使うとイイ感じにできそう

終わりに

非力なノートPCで動くVRローカルLLM会話エージェント「piaちゃん」の作り方について解説しました

下記のような様々な技術/可能性として未来感を感じていただけたら幸いです

  • 非力なPCやエッジデバイスでも動くElixir AIのサンプルとして
  • 今年、充実してきたローカルLLMを使ったAIエージェントのサンプルとして
  • Web Speech API+VOICEVOXを使った会話エージェントのサンプルとして
  • LiveViewでVRアバターによるプレゼンテーションを行うのサンプルとして

明日は @kikuyuta さんで 「Elixirのプロセスを止める」 です

9
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
9
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?