5
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

人工無脳Eliちゃん(16)二号機を作る 〜非同期で高速化とアニメーション追加〜

Last updated at Posted at 2025-12-14

人工無脳Eliちゃん(16)初号機はレスポンスがよくなかった

前回の課題

  • 同期処理の為、LLMと音声処理の終了後にしゃべり出す為レスポンスが悪い
  • アバターを見て処理をしているのかわからない
  • Ubuntuしか対応してない

その他の課題もこちら

この中から解決できる物を今回解消する

今回の実行結果

今回解決したいことの前提知識

apleyを廃止をしてブラウザーで音声を再生

LiveViewで非同期処理

アバターを動かす

それ以外にも解決したいこと

  • LLMを停止
    • Process.exit
  • 口パクを音量に合わせる
    • 俺俺ライブラリーset_blend_shapeとhandle_event("voice_volume"
  • 目もアニメーションする
    • 俺俺ライブラリーset_blend_shape

これらが今回の対象です

プログラムを書く

コメントに説明書きます

lib/llm_async_web/live/index.ex
defmodule LlmAsyncWeb.Index do
  use LlmAsyncWeb, :live_view
  import ThreeWeb.Cg.CgHelper

  def mount(_params, _session, socket) do
    pid = self()

    socket =
      assign(socket, text: "実行ボタンを押してください")
      |> assign(input_text: "Elixirについ2文で教えてください")
      |> assign(btn: true)
      |> assign(old_sentence_count: 1)
      |> assign(sentences: [])
      |> assign(talking_no: 0)
      |> assign(talking: false)
      |> assign(task_pid: nil)
      |> assign(data: 0)
      |> assign(pid: pid)
      |> load_model("test", "images/test.vrm")

    {:ok, socket}
  end

  # LLM開始ボタン
  def handle_event("start", _, socket) do

    # LiveViewの本体のプロセスIDです
    # これは、非同期にした時にLiveViewの本体にメッセージを送る為に必要です
    pid_liveview = self()
    input_text = socket.assigns.input_text

    socket =
      assign(socket, btn: false)
      |> assign(text: "")
      |> assign(sentences: [])
      |> assign(old_sentence_count: 1)
      |> assign(talking_no: 0)
      # LLMを非同期処理をします
      |> assign_async(:ret, fn -> run(pid_liveview, input_text) end)

    {:noreply, socket}
  end

  # LLMを停止
  def handle_event("stop", _, socket) do
    if socket.assigns.task_pid do
      # LLMのプロセスを停止 強制終了することでollamaが停止できます
      # 強制停止しないとGPUの負荷が下がりません
      Process.exit(socket.assigns.task_pid, :kill)
    end

    socket =
      assign(socket, btn: true)
      |> assign(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

  # 入力した文字をアサインして保持する
  def handle_event("update_text", %{"text" => new_text}, socket) do
    {:noreply, assign(socket, input_text: new_text)}
  end

  # jsから「ずんだもん」の終了通知を受け取る
  def handle_event("voice_playback_finished", _, %{assigns: assigns} = socket) do
    # 次のしゃべる内容の為カウントアップ
    talking_no = assigns.talking_no + 1
    sentences = assigns.sentences
    # ずんだもんがしゃべる内容を取得
    text = Enum.at(sentences, talking_no)
    # 最後は"\n"であるため -1
    max_talking_no = Enum.count(sentences) - 1

    # 次ずんだもんがしゃべる内容を指示
    socket = speak_next(socket, talking_no, max_talking_no, text)
    {:noreply, socket}
  end

  # 今ずんだもんが喋っている音量をjsから取得
  def handle_event("voice_volume", %{"volume" => v}, socket) do
    volume = voice_volume(socket.assigns.talking, v)

    # アバターの「あの口」の音量によって開きを変える
    socket = set_blend_shape(socket, "test", "aa", volume)
    {:noreply, socket}
  end

  # アバターがロードされた時にアバターの初期設定をする
  def handle_event("load_model", %{"name" => "test", "status" => "completion"}, socket) do
    socket =
      socket
      # アバターの位置
      |> position("test", 0, -1.45, 4.63)
      # アバターの角度
      |> rotation("test", 0, 3.1, 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.5)
      # 口を閉じる
      |> set_blend_shape("test", "aa", 0)

    # 非同期で目をアニメーションする
    Task.start_link(fn -> blink(socket.assigns.pid) end)
    # 非同期で体の揺れをアニメーションする
    Task.start_link(fn -> move(socket.assigns.pid, 1) end)

    {:noreply, socket}
  end

  # 体の揺れをアニメーション
  def handle_info({:move, v}, socket) do
    # sinを使うことによって反復運動になる
    sin = :math.sin(v) * 0.02
    # アバターの角度
    socket = rotation(socket, "test", 0.01, 3.2, sin)
    {:noreply, socket}
  end

  # 目のアニメーション
  def handle_info({:blink, v}, socket) do
    # blinkで目の操作ができる vで目の開きを操作
    socket = set_blend_shape(socket, "test", "blink", v)
    {:noreply, socket}
  end

  # LLMのプロセスIDを取得してアサインする
  def handle_info({:task_pid, pid}, socket) do
    {:noreply, assign(socket, task_pid: pid)}
  end

  # LLMをチャンクごと受信
  def handle_info(%{"done" => false, "response" => response}, %{assigns: assigns} = socket) do
    old_sentence_count = assigns.old_sentence_count
    # 今まで受信したチャンクを連結する
    text = assigns.text <> response
    # 「。」「、」単位で分割する これは、「ずんだもん」が話す単位になる
    sentences = String.split(text, ["。", "、"])
    new_sentence_count = Enum.count(sentences)

    socket =
      assign(socket, sentences: sentences)
      |> assign(old_sentence_count: new_sentence_count)
      |> assign(text: text)
      # 「ずんだもん」が初めてしゃべる時に実行する
      |> speak_first(old_sentence_count, new_sentence_count, sentences)

    {:noreply, socket}
  end

  # LLMが完了時
  def handle_info(%{"done" => true}, socket) do
    {:noreply, assign(socket, btn: true)}
  end

  # 「ずんだもん」にjs経由で話す内容を伝える
  defp synthesize_and_play(text, socket) do
    push_event(socket, "synthesize_and_play", %{
      "text" => text,
      "speaker_id" => "1"
    })
  end

  # 「ずんだもん」にjs経由で停止を指示する
  defp stop_voice_playback(socket) do
    push_event(socket, "stop_voice_playback", %{})
  end

  # 「ずんだもん」に話すことを指示する(LLMから初回1文のみ)
  defp speak_first(socket, _old_sentence_count = 1, _new_sentence_count = 2, sentences) do
    sentences
    |> hd()
    |> synthesize_and_play(socket)
    |> assign(talking: true)
  end

  # 2文以降はスキップ
  defp speak_first(socket, _, _, _sentences), do: socket

  # 「ずんだもん」に話すことを指示する(2文以降の処理)
  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(btn: true)
    |> assign(talking: false)
  end

  # LLM実行(非同期処理を設定)
  defp run(pid_liveview, text) do
    # 非同期でLLM実行しプロセスIDを取得してアサインする
    # アサインする理由は、途中で停止したい時にこのプロセスIDを使って停止する
    {_, task_pid} = Task.start_link(fn -> run_ollama(pid_liveview, text) end)
    send(pid_liveview, {:task_pid, task_pid})
    {:ok, %{ret: :ok}}
  end

  # LLM実行(本体)
  def run_ollama(pid_liveview, text) do
    client = Ollama.init()

    {:ok, stream} =
      Ollama.completion(client,
        model: "gemma3:1b",
        prompt: text,
        stream: true
      )

    stream
    # sendの結果は handle_info(%{"done"で取得できる
    |> Stream.each(&send(pid_liveview, &1))
    |> Stream.run()

    send(pid_liveview, %{"done" => true})
  end

  defp voice_volume(true, volume), do: volume * 10
  defp voice_volume(false, _), do: 0

  # 体のアニメーション無限ループ(非同期なので他の処理をブロックしない)
  def move(pid, i) do
    send(pid, {:move, i})
    Process.sleep(5)
    move(pid, i + 0.005)
  end

  # 目のアニメーション無限ループ(非同期なので他の処理をブロックしない)
  # 3秒に1回目を100ミリ秒閉じる
  def blink(pid) do
    Process.sleep(100)
    send(pid, {:blink, 1})
    Process.sleep(100)
    send(pid, {:blink, 0})
    Process.sleep(3000)
    blink(pid)
  end

  def render(assigns) do
    ~H"""
    <Layouts.app flash={@flash}>
      <div class="flex h-screen">
        <div id="voicex" class="p-5 w-[800px] h-[800px] overflow-y-auto" phx-hook="Voicex">
          <form>
            <textarea id="text_input" name="text" phx-change="update_text" class="input w-[400px]">{@input_text}</textarea>
          </form>

          <button disabled={!@btn} class="btn" phx-click="start">実行</button>
          <button class="btn btn-error" phx-click="stop">停止</button>

          <div :for={s <- @sentences}>
            {s}
          </div>
        </div>
        <div id="threejs" phx-hook="threejs" phx-update="ignore" data-data={@data}></div>
      </div>
    </Layouts.app>
    """
  end
end

ここからは俺俺ライブラリー

参考までに
これは今後バージョンアップして使い回す想定
もし、仕組みに興味があれば・・・
公開しておきます

3Dヘルパー

lib/llm_async_web/live/cg_helper.ex
defmodule ThreeWeb.Cg.CgHelper do
  use Phoenix.LiveView

  def add_cube(socket, name, x, y, z, color) do
    push_event(socket, "addCube", %{name: name, x: x, y: y, z: z, color: color})
  end

  def add_plane(socket, name, x, y, color) do
    push_event(socket, "addPlane", %{name: name, x: x, y: y, color: color})
  end

  def rotation(socket, name, x, y, z) do
    push_event(socket, "rotation", %{name: name, x: x, y: y, z: z})
  end

  def position(socket, name, x, y, z) do
    push_event(socket, "position", %{name: name, x: x, y: y, z: z})
  end

  def load_model(socket, name, path) do
    push_event(socket, "loadModel", %{name: name, path: path})
  end

  def get_bone(socket, name) do
    push_event(socket, "getBone", %{name: name})
  end

  def set_blend_shape(socket, name, key, value) do
    push_event(socket, "setBlendShape", %{name: name, key: key, value: value})
  end

  def rotation_bone(socket, name, bone_name, x, y, z) do
    push_event(socket, "rotationBone", %{name: name, bone_name: bone_name, x: x, y: y, z: z})
  end

  def load_texture(socket, name, path) do
    push_event(socket, "loadTexture", %{name: name, path: path})
  end

  def set_texture(socket, obj_name, texture_name) do
    push_event(socket, "setTexture", %{obj_name: obj_name, texture_name: texture_name})
  end

  @doc """
  Three.jsシーンにCanvasテクスチャでテキストを表示する平面オブジェクトを追加します。
  """
  def add_text_plane(socket, name, text_content, font_size, text_color) do
    push_event(socket, "addTextPlane", %{
      name: name,
      textContent: text_content,
      fontSize: font_size,
      textColor: text_color
    })
  end

  @doc """
  既存のテキスト平面オブジェクトの文字内容とスタイルを更新します。
  """
  def set_text_plane_text(socket, name, new_text_content, font_size, text_color) do
    push_event(socket, "setTextPlaneText", %{
      name: name,
      newTextContent: new_text_content,
      fontSize: font_size,
      textColor: text_color
    })
  end

  @doc """
  Three.jsシーンから指定された名前のオブジェクトを削除します。
  """
  def remove_object(socket, name) do
    push_event(socket, "removeObject", %{name: name})
  end

  def set_size(socket) do
    push_event(socket, "setSize", %{})
  end
end

hook
3D

assets/js/hooks/three.js
import * as THREE from 'three';
import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader.js';
import { VRMLoaderPlugin } from '@pixiv/three-vrm'; // プラグインを登録して GLTFLoader で VRM を扱う


Threejs = {
  v: {},
  mounted() {
    console.log("mounted");
    this.init(this.el);
    this.handleEventInit();
  },
  addCube(name, x, y, z, color) {
    const geometry = new THREE.BoxGeometry(x, y, z);

    // マテリアルを作成
    const material = new THREE.MeshBasicMaterial({ color: color });

    // メッシュ(ジオメトリとマテリアルを組み合わせたもの)を作成
    const cube = new THREE.Mesh(geometry, material);
    this.v.scene.add(cube);
    this.v[name] = cube;
  },
  addPlane(name, x, y, color) {
    const geometry = new THREE.PlaneGeometry(x, y)

    // マテリアルを作成
    // const material = new THREE.MeshBasicMaterial({ color: color });
    const material = new THREE.MeshStandardMaterial({ color: color, side: THREE.DoubleSide }); // ライトに反応する

    // メッシュ(ジオメトリとマテリアルを組み合わせたもの)を作成
    const cube = new THREE.Mesh(geometry, material);
    this.v.scene.add(cube);
    this.v[name] = cube;
  },
  position(name, x, y, z) {
    if (this.v[name] == undefined) return;
    var target = this.v[name];

    // VRM インスタンスなら .scene を使う
    if (target.scene && target.scene instanceof THREE.Object3D) {
      target = target.scene;
    }

    var position = target.position;
    if (!position) return;
    if (x != null) position.x = x;
    if (y != null) position.y = y;
    if (z != null) position.z = z;
  },
  rotation(name, x, y, z) {
    if (this.v[name] == undefined) return;
    var target = this.v[name];

    if (target.scene && target.scene instanceof THREE.Object3D) {
      target = target.scene;
    }

    var rot = target.rotation;
    if (!rot) return;
    if (x != null) rot.x = x;
    if (y != null) rot.y = y;
    if (z != null) rot.z = z;
  },
  rotationBone(name, boneName, x, y, z) {
    if (this.v[name] == undefined) return;
    var modelOrVrm = this.v[name];

    // VRM の場合は .scene に実体がある
    var root = (modelOrVrm.scene && modelOrVrm.scene instanceof THREE.Object3D) ? modelOrVrm.scene : modelOrVrm;

    var bone = root.getObjectByName(boneName);
    if (!bone) {
      console.warn("bone '" + boneName + "' not found on model '" + name + "'");
      return;
    }
    if (x != null) bone.rotation.x = x;
    if (y != null) bone.rotation.y = y;
    if (z != null) bone.rotation.z = z;
  },
  loadModel(name, path) {
    var loader = new GLTFLoader();
    // register VRM plugin (function form to avoid =>)
    loader.register(function (parser) {
      return new VRMLoaderPlugin(parser);
    });

    var self = this;
    var v = this.v;
    var t = this;

    loader.load(
      path,
      function (gltf) {
        // three-vrm は gltf.userData.vrm に VRM オブジェクトを格納する
        var vrm = gltf.userData && gltf.userData.vrm;
        // var vrm = gltf.userData.vrm;
        if (!vrm) {
          console.error('Loaded file is not a VRM or vrm not found in gltf.userData.');
          t.pushEvent('load_model', { status: 'error', name: name, message: 'not a VRM' });
          return;
        }

        // optional: VRM の向き調整
        vrm.scene.rotation.y = Math.PI;

        // シーンへ追加 & キャッシュ
        v.scene.add(vrm.scene);
        v[name] = vrm; // VRM オブジェクトを保存(expressionManager 等を使えるように)
        v[name + '_gltf'] = gltf;

        // レンダーループで vrm.update を呼ぶためにキャッシュ
        if (!v._vrms) v._vrms = [];
        v._vrms.push(vrm);
        t.pushEvent('load_model', { status: 'completion', name: name });

      },
      function (xhr) {
        if (xhr && xhr.total) {
          console.log(Math.floor((xhr.loaded / xhr.total) * 100) + '% loaded');
        }
      },
      function (error) {
        console.error('An error happened while loading VRM', error);
        t.pushEvent('load_model', { status: 'error', name: name, error: String(error) });
      }
    );
  },
  getBone(name) {
    if (this.v[name] == undefined) return;
    const t = this
    const model = this.v[name];
    model.traverse((obj) => { if (obj.isBone) t.pushEvent('get_bone', { name: obj.name }) });
  },
  setBlendShape(name, key, value) {
    const vrm = this.v[name];
    vrm.expressionManager.setValue(key, value);
    vrm.expressionManager.update();
  },
  loadTexture(name, path) {
    const v = this.v
    const t = this;
    const textureLoader = new THREE.TextureLoader();
    textureLoader.load(path,
      // 読み込み成功時のコールバック
      function (texture) {
        v[name] = new THREE.MeshBasicMaterial({ map: texture });
        t.pushEvent('load_texture', { status: "completion", name: name })
      },
      // 読み込み進捗時のコールバック (オプション)
      undefined,
      // 読み込みエラー時のコールバック
      undefined
    );
  },
  setTexture(objName, textureName) {
    if (this.v[objName] == undefined) return;
    const obj = this.v[objName];
    if (!obj || !obj.material) {
      console.warn(`オブジェクト '${objName}' またはそのマテリアルが見つかりません。`);
      console.log(obj)
      return;
    }

    const material = obj.material;

    const newMaterialWithTexture = this.v[textureName];
    if (!newMaterialWithTexture || !(newMaterialWithTexture instanceof THREE.MeshBasicMaterial)) {
      console.warn(`テクスチャ '${textureName}' に対応する有効なマテリアルが見つかりません。`);
      return;
    }
    const texture = newMaterialWithTexture.map; // 読み込まれたテクスチャを取得

    if (material instanceof THREE.MeshBasicMaterial || material instanceof THREE.MeshStandardMaterial) {
      material.map = texture;
      material.needsUpdate = true; // マテリアルの更新をThree.jsに通知
    } else {
      console.warn(`オブジェクト '${objName}' のマテリアルはテクスチャマップをサポートしていません。`);
    }
  },
  /**
 * Canvasテクスチャを使ってテキストを表示する平面オブジェクトを追加
 * @param {string} name - オブジェクトの識別名
 * @param {string} textContent - 表示するテキスト
 * @param {number} fontSize - フォントサイズ (例: 80)
 * @param {string} textColor - テキストの色 (例: 'white', '#FF0000')
 */
  addTextPlane(name, textContent, fontSize, textColor) {
    // 内部的なデフォルト値
    const fontFamily = 'Arial'; // またはお好みの汎用フォント
    const padding = 20; // テキスト周りの余白
    const planeScale = 100; // Three.jsのワールド単位への変換スケール

    const canvas = document.createElement('canvas');
    const context = canvas.getContext('2d');

    context.font = `Bold ${fontSize}px ${fontFamily}`;

    // テキストの幅を測定
    const textMetrics = context.measureText(textContent);
    const textWidth = textMetrics.width;
    const textHeight = fontSize; // おおよその高さ

    // Canvasのサイズを計算 (パディングを含む)
    canvas.width = textWidth + padding * 2;
    canvas.height = textHeight + padding * 2;

    // Canvasの描画設定を再適用 (width/height変更でリセットされるため)
    context.font = `Bold ${fontSize}px ${fontFamily}`;
    context.fillStyle = textColor;
    context.textAlign = 'center';
    context.textBaseline = 'middle';

    // テキストを描画
    context.fillText(textContent, canvas.width / 2, canvas.height / 2);

    // Canvasをテクスチャとして使用
    const texture = new THREE.CanvasTexture(canvas);
    texture.needsUpdate = true;

    // マテリアルを作成 (両面表示をデフォルト)
    const material = new THREE.MeshBasicMaterial({
      map: texture,
      transparent: true,
      side: THREE.DoubleSide, // 両面表示
    });

    // 平面ジオメトリのサイズをCanvasのアスペクト比に合わせて調整
    const planeWidth = canvas.width / planeScale;
    const planeHeight = canvas.height / planeScale;
    const planeGeometry = new THREE.PlaneGeometry(planeWidth, planeHeight);
    const textMesh = new THREE.Mesh(planeGeometry, material);

    this.v.scene.add(textMesh);
    this.v[name] = textMesh; // シーンに追加したメッシュをvに保存
  },
  /**
   * 既存のテキスト平面オブジェクトの文字内容とスタイルを更新
   * @param {string} name - 更新するオブジェクトの識別名
   * @param {string} newTextContent - 新しいテキスト内容
   * @param {number} fontSize - 新しいフォントサイズ
   * @param {string} textColor - 新しいテキストの色
   */
  setTextPlaneText(name, newTextContent, fontSize, textColor) {
    const textMesh = this.v[name];
    if (!textMesh || !textMesh.material || !textMesh.material.map || !(textMesh.material.map instanceof THREE.CanvasTexture)) {
      console.warn(`オブジェクト '${name}' は有効なテキスト平面ではありません。`);
      return;
    }

    // 内部的なデフォルト値 (addTextPlaneと合わせる)
    const fontFamily = 'Arial';
    const padding = 20;
    const planeScale = 100;

    const texture = textMesh.material.map;
    const canvas = texture.image;
    const context = canvas.getContext('2d');

    // Canvasをクリア
    context.clearRect(0, 0, canvas.width, canvas.height);

    // 新しいテキストのサイズを測定し、必要に応じてCanvasサイズとジオメトリを更新
    const textMetrics = context.measureText(newTextContent);
    const newTextWidth = textMetrics.width;
    const newTextHeight = fontSize;

    const newCanvasWidth = newTextWidth + padding * 2;
    const newCanvasHeight = newTextHeight + padding * 2;

    // Canvasのサイズが変更された場合、ジオメトリも更新する必要がある
    if (canvas.width !== newCanvasWidth || canvas.height !== newCanvasHeight) {
      canvas.width = newCanvasWidth;
      canvas.height = newCanvasHeight;
      // ジオメトリを再生成(古いジオメトリを破棄)
      if (textMesh.geometry) {
        textMesh.geometry.dispose();
      }
      const newPlaneWidth = canvas.width / planeScale;
      const newPlaneHeight = canvas.height / planeScale;
      textMesh.geometry = new THREE.PlaneGeometry(newPlaneWidth, newPlaneHeight);
    }

    // Canvasの描画設定を再適用し、テキストを描画
    // この部分をif文の外に出すことで、Canvasサイズが変わらない場合でもテキストが再描画されるようにします。
    context.font = `Bold ${fontSize}px ${fontFamily}`;
    context.fillStyle = textColor;
    context.textAlign = 'center';
    context.textBaseline = 'middle';
    context.fillText(newTextContent, canvas.width / 2, canvas.height / 2);

    // テクスチャの更新をThree.jsに通知
    texture.needsUpdate = true;
  },
  /**
   * Three.jsシーンからオブジェクトを削除する関数
   * @param {string} name - 削除するオブジェクトの識別名
   */
  removeObject(name) {
    const objectToRemove = this.v[name];

    if (!objectToRemove) {
      console.warn(`オブジェクト '${name}' は見つかりませんでした。`);
      return;
    }

    // シーンからオブジェクトを削除
    this.v.scene.remove(objectToRemove);

    // ジオメトリとマテリアルを破棄してメモリを解放
    if (objectToRemove.geometry) {
      objectToRemove.geometry.dispose();
    }
    if (objectToRemove.material) {
      // マテリアルが配列の場合を考慮
      if (Array.isArray(objectToRemove.material)) {
        objectToRemove.material.forEach(material => material.dispose());
      } else {
        objectToRemove.material.dispose();
      }
    }

    // this.v からも参照を削除
    delete this.v[name];
    console.log(`オブジェクト '${name}' がシーンから削除されました。`);
  },
  setSize() {
    let v = this.v;
    v["camera"] = new THREE.PerspectiveCamera(75, window.innerWidth / window.innerHeight, 0.1, 1000);
    v["camera"].position.z = 5;
    v["renderer"].setSize(window.innerWidth, window.innerHeight);
  },
  handleEventInit() {
    this.handleEvent("addCube", data => {
      this.addCube(data.name, data.x, data.y, data.z, data.color)
    });

    this.handleEvent("addPlane", data => {
      this.addPlane(data.name, data.x, data.y, data.color)
    });

    this.handleEvent("rotation", data => {
      this.rotation(data.name, data.x, data.y, data.z)
    });

    this.handleEvent("position", data => {
      this.position(data.name, data.x, data.y, data.z)
    });

    this.handleEvent("loadModel", data => {
      this.loadModel(data.name, data.path)
    });

    this.handleEvent("getBone", data => {
      this.getBone(data.name)
    });
    this.handleEvent("setBlendShape", data => {
      this.setBlendShape(data.name, data.key, data.value)
    });
    this.handleEvent("rotationBone", data => {
      this.rotationBone(data.name, data.bone_name, data.x, data.y, data.z)
    });

    this.handleEvent("loadTexture", data => {
      this.loadTexture(data.name, data.path)
    });

    this.handleEvent("setTexture", data => {
      this.setTexture(data.obj_name, data.texture_name)
    });

    this.handleEvent("addTextPlane", data => {
      this.addTextPlane(data.name, data.textContent, data.fontSize, data.textColor);
    });

    this.handleEvent("setTextPlaneText", data => {
      this.setTextPlaneText(data.name, data.newTextContent, data.fontSize, data.textColor);
    });
    this.handleEvent("removeObject", data => {
      this.removeObject(data.name);
    });
    this.handleEvent("setSize", () => {
      this.setSize();
    });
  },
  init(el) {
    // シーンの作成
    let v = this.v;
    const scene = new THREE.Scene();

    // 環境光の追加 (シーン全体を均一に照らす)
    const ambientLight = new THREE.AmbientLight(0xffffff, 1.0); // 色と強度
    scene.add(ambientLight);

    // 平行光源の追加 (太陽のように特定方向から照らす)
    const directionalLight = new THREE.DirectionalLight(0xffffff, 1.5); // 色と強度
    directionalLight.position.set(0, 10, 5); // 光源の位置
    scene.add(directionalLight);

    // カメラの作成
    v["camera"] = new THREE.PerspectiveCamera(75, 1000 / 800, 0.1, 1000);
    v["camera"].position.z = 5;

    // レンダラーの作成
    v["renderer"] = new THREE.WebGLRenderer();
    v["renderer"].setSize(1000, 800);
    el.appendChild(v["renderer"].domElement);

    function render() {
      requestAnimationFrame(render);
      v["renderer"].render(scene, v["camera"]);
    }

    render();
    v["scene"] = scene;
  }
}

export default Threejs

ずんだもん制御

assets/js/hooks/voicex.js
// VOICEVOX EngineのURL
const VOICEVOX_URL = "http://localhost:50021";

Voicex = {
    currentAudioPlayer: null,
    currentAudioUrl: null,

    // Web Audio API 用
    audioContext: null,
    analyser: null,
    volumeCheckId: null, // requestAnimationFrame の ID

    mounted() {
        this.handleEvent("synthesize_and_play", ({ text, speaker_id }) => {
            this.stopPlayback();
            this.speakText(text, speaker_id);
        });

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

    async fetchAudioQuery(text, speakerId) {
        const queryParams = new URLSearchParams({ text, speaker: speakerId });
        const queryUrl = `${VOICEVOX_URL}/audio_query?${queryParams}`;

        const queryResponse = await fetch(queryUrl, {
            method: 'POST',
            headers: { 'Content-Type': 'application/json' }
        });

        if (!queryResponse.ok) {
            throw new Error(`audio_query failed with status ${queryResponse.status}`);
        }
        return await queryResponse.json();
    },

    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;
    },

    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;
        }
    },

    // -------------------------------
    // 🔊 ボリューム測定ループ
    // -------------------------------
    startVolumeMonitor() {
        if (!this.analyser) return;

        const bufferLength = this.analyser.frequencyBinCount;
        const dataArray = new Uint8Array(bufferLength);

        const loop = () => {
            this.analyser.getByteTimeDomainData(dataArray);

            // RMSを計算
            let sum = 0;
            for (let i = 0; i < bufferLength; i++) {
                const v = (dataArray[i] - 128) / 128;
                sum += v * v;
            }
            const rms = Math.sqrt(sum / bufferLength);

            // 0〜1 の値で LiveView に送信
            this.pushEvent("voice_volume", { volume: rms });

            this.volumeCheckId = requestAnimationFrame(loop);
        };

        loop();
    },

    stopVolumeMonitor() {
        if (this.volumeCheckId) {
            cancelAnimationFrame(this.volumeCheckId);
            this.volumeCheckId = null;
        }
    },

    stopPlayback() {
        if (this.currentAudioPlayer) {
            this.currentAudioPlayer.pause();
            this.currentAudioPlayer.currentTime = 0;

            if (this.currentAudioUrl) {
                URL.revokeObjectURL(this.currentAudioUrl);
            }

            this.stopVolumeMonitor();

            this.currentAudioPlayer = null;
            this.currentAudioUrl = null;

            console.log("音声再生を停止しました。");
            return true;
        }
        return false;
    }
};

export default Voicex;

こんな感じでElixirでもAIアバターを作ることができました

ソース

5
0
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
5
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?