[Astro #25] ReactとVOICEVOXを利用したWired風ターミナルと音声UIの実装

[Astro #25] ReactとVOICEVOXを利用したWired風ターミナルと音声UIの実装

はじめに

本記事では、AstroとReactを用いた環境下で、ローカルで稼働するVOICEVOXエンジンと連携し、音声合成およびリップシンクのための音量解析を行うターミナルUIの実装手順を記録する。

音声の取得および再生処理は、過去に実装したChrome拡張機能(Gemini VOICEVOX Reader)のコードを流用・拡張している。

[Astro #25] ReactとVOICEVOXを利用したWired風ターミナルと音声UIの実装

前回の記事:

Gemini VOICEVOX Reader (v1.5):

1. ログ送信ユーティリティの実装

ターミナルへのログ出力は、カスタムイベントを用いてグローバルに発火させる構成とする。これにより、アプリケーションのどこからでもターミナルへメッセージを送信できる。

// src/utils/terminal.ts
// トップページ:ログ送信ユーティリティ
export const sendWiredLog = (msg: string, type = 'system', color?: string) => {
  if (typeof window !== 'undefined') {
    //console.log(`[TERMINAL_EMIT] ${msg}`);// デバッグ用
    const event = new CustomEvent('wired-log', {
      detail: { msg, type, color, id: Date.now() + Math.random() }
    });
    window.dispatchEvent(event);
  }
};

2. 音声合成と解析フック (Web Audio API)

ローカルAPI(http://127.0.0.1:50021)へリクエストを送り、取得した音声を再生するフック。 再生と同時に AnalyserNode を用いて音量を取得し、グローバル変数 window.wiredVoiceVolume へ渡す。この値は Three.js(VRM側)で毎フレーム参照され、アバターのリップシンク(口パク)制御に使用される。

話者ID(speakerId)によって音量取得をスキップする分岐を入れ、システムボイス発話時はアバターの口が動かないよう制限をかけている。

// src/hooks/useWiredVoice.ts
import { useRef } from 'react';
import { sendWiredLog } from '../utils/terminal';

const BASE_URL = "[http://127.0.0.1:50021](http://127.0.0.1:50021)";
const HIMARI_ID = 14; // アバターの人格ID

declare global {
  interface Window {
    wiredVoiceVolume: number;
  }
}

export const useWiredVoice = () => {
  const queue = useRef(Promise.resolve());
  const audioContext = useRef<AudioContext | null>(null);
  const analyser = useRef<AnalyserNode | null>(null);

  const initAudio = async () => {
    if (!audioContext.current) {
      audioContext.current = new (window.AudioContext || (window as any).webkitAudioContext)();
      analyser.current = audioContext.current.createAnalyser();
      analyser.current.fftSize = 256;
      analyser.current.connect(audioContext.current.destination);
    }
    if (audioContext.current.state === "suspended") {
      await audioContext.current.resume();
    }
  };

  const speak = async (text: string, speakerId: number) => {
    queue.current = queue.current.then(async () => {
      try {
        await initAudio();

        const queryRes = await fetch(`${BASE_URL}/audio_query?text=${encodeURIComponent(text)}&speaker=${speakerId}`, { method: "POST" });
        if (!queryRes.ok) throw new Error("Offline");
        const query = await queryRes.json();

        const synthRes = await fetch(`${BASE_URL}/synthesis?speaker=${speakerId}`, {
          method: "POST",
          headers: { "Content-Type": "application/json" },
          body: JSON.stringify(query),
        });
        const arrayBuffer = await synthRes.arrayBuffer();

        const audioBuffer = await audioContext.current!.decodeAudioData(arrayBuffer);
        const source = audioContext.current!.createBufferSource();
        source.buffer = audioBuffer;
        source.connect(analyser.current!);

        const dataArray = new Uint8Array(analyser.current!.frequencyBinCount);
        let isPlaying = true;

        const analyze = () => {
          if (!isPlaying || !analyser.current) return;

          // speakerId を判定し、特定のアバターのみリップシンクの数値を算出
          if (speakerId === HIMARI_ID) {
            analyser.current.getByteFrequencyData(dataArray);
            const average = dataArray.reduce((a, b) => a + b) / dataArray.length;
            window.wiredVoiceVolume = average / 255;
          } else {
            // VOIDOLL(システム)の時は、音量は無視して口を閉じたままにする
            window.wiredVoiceVolume = 0;
          }

          requestAnimationFrame(analyze);
        };

        return new Promise<void>((resolve) => {
          source.onended = () => {
            isPlaying = false;
            window.wiredVoiceVolume = 0;
            resolve();
          };
          source.start(0);
          analyze();
        });

      } catch (err) {
        console.error("VOICEVOX Connection Error:", err);
        sendWiredLog("VOICE_OFFLINE: CHECK_LOCAL_ENGINE", "warning", "#ff3030");
      }
    });
    return queue.current;
  };

  return { speak };
};

3. ターミナルUIコンポーネント

ターミナルの入出力および、VOICEVOX未起動時のフォールバックダイアログを管理する。コマンド入力による分岐処理と音声再生を同期させる。

// src/components/WiredTerminal.tsx
import React, { useState, useEffect, useRef } from 'react';
import { sendWiredLog } from '../utils/terminal';
import { useWiredVoice } from '../hooks/useWiredVoice';

export const WiredTerminal = () => {
  const { speak } = useWiredVoice();
  const [logs, setLogs] = useState<{msg: string, type: string, color?: string, id: any}[]>([]);
  const [inputValue, setInputValue] = useState("");
  const [isAudioEnabled, setIsAudioEnabled] = useState(false);
  const [showInstallDialog, setShowInstallDialog] = useState(false);
  const scrollRef = useRef<HTMLDivElement>(null);

  const VOIDOLL_ID = 89;
  const HIMARI_ID = 14;

  // 1. ログリスナー設定
  useEffect(() => {
    const handleLog = (e: any) => {
      setLogs(prev => [...prev.slice(-49), e.detail]);
    };
    window.addEventListener('wired-log', handleLog);

    // デフォルトでログを流し始める
    const bootSequence = [
      { m: "SYSTEM BOOT...", t: "system", d: 1000 },
      { m: "STATUS: ARCHIVED", t: "system", d: 1600 },
      { m: "DATA: THE EXISTENTIAL WIRED", t: "info", d: 2200 },
      { m: "OBSERVER: lain", t: "system", d: 2800 },
      { m: "RAYMARCHING ENGINE ONLINE...", t: "warning", d: 3400 }
    ];

    bootSequence.forEach(s => {
      setTimeout(() => sendWiredLog(s.m, s.t, s.t === 'info' ? '#00f2fe' : (s.t === 'warning' ? '#ff0055' : undefined)), s.d);
    });

    return () => window.removeEventListener('wired-log', handleLog);
  }, []);

  // 2. 音声の有効化(ボタン押下時)
  const handleEnableAudio = async () => {
    try {
      const res = await fetch("[http://127.0.0.1:50021/speakers](http://127.0.0.1:50021/speakers)");
      if (!res.ok) throw new Error();

      setIsAudioEnabled(true);
      sendWiredLog("AUDIO_PROTOCOL: SYNCHRONIZED", "info", "#00f2fe");
      await speak("音声を、オンにしました。ワイヤード、同期完了。", VOIDOLL_ID);
      sendWiredLog("LOCAL_OS: COPLAND_v7.2.1", "system");
      await speak("コープランド、バージョン、なな", VOIDOLL_ID);
    } catch (e) {
      setShowInstallDialog(true);
    }
  };

  useEffect(() => {
    if (scrollRef.current) {
      scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
    }
  }, [logs]);

  // 3. コマンド処理
  const handleCommand = async (e: React.FormEvent) => {
    e.preventDefault();
    if (!inputValue.trim()) return;

    const cmd = inputValue.toLowerCase().trim();
    sendWiredLog(cmd, 'user', '#fff');
    setInputValue("");

    switch (cmd) {
      case 'help':
        sendWiredLog("AVAILABLE: lain, time, status, clear", "system");
        if (isAudioEnabled) await speak("使用可能なコマンドを表示します。", VOIDOLL_ID);
        break;
      case 'lain':
        sendWiredLog("...Who are you?", "warning", "#ff0055");
        if (isAudioEnabled) await speak("あなたは、だれ", HIMARI_ID);
        break;
      case 'time':
        const timeStr = new Date().toLocaleTimeString();
        sendWiredLog(timeStr, "info");
        if (isAudioEnabled) await speak(`現在の時刻は、${timeStr}です。`, VOIDOLL_ID);
        break;
      case 'status':
        sendWiredLog("WIRED_SYNC: 98.4%", "system");
        if (isAudioEnabled) await speak("ワイヤードとの同期率は、九十八点四パーセントです。", VOIDOLL_ID);
        break;
      case 'clear':
        setLogs([]);
        break;
      default:
        sendWiredLog(`ERROR: UNKNOWN COMMAND '${cmd}'`, "system", "#555");
        if (isAudioEnabled) await speak("不明な、コマンドです。", VOIDOLL_ID);
    }
  };

  return (
    <div className="terminal-container">
      {/* 音声切り替えスイッチ */}
      <div className="terminal-controls">
        {!isAudioEnabled ? (
          <button className="nav-btn voice-off-btn" onClick={handleEnableAudio}>
            SYNC_VOICE [OFF]
          </button>
        ) : (
          <span className="voice-on-label">SYNC_VOICE [ON]</span>
        )}
      </div>

      {showInstallDialog && (
        <div className="terminal-modal">
          <div className="modal-content">
            <h3 className="warning">VOICEVOX_NOT_FOUND</h3>
            <p>音声プロトコルを開始するには、VOICEVOXの起動が必要です。</p>
            <ol>
              <li>公式サイトからアプリをダウンロード</li>
              <li>アプリを起動し、外部連携を許可</li>
            </ol>
            <div className="modal-actions">
              <a href="[https://voicevox.hiroshiba.jp/](https://voicevox.hiroshiba.jp/)" target="_blank" className="nav-btn small">DOWNLOAD</a>
              <button onClick={() => setShowInstallDialog(false)} className="nav-btn small">CLOSE</button>
            </div>
          </div>
        </div>
      )}

      <div className="terminal-scroll-area" ref={scrollRef}>
        {logs.map((log) => (
          <div key={log.id} className={`log-line type-${log.type}`} style={{ color: log.color }}>
            <span className="prompt">{log.type === 'user' ? "> guest: " : "> "}</span>
            <span className="message-text">{log.msg}</span>
          </div>
        ))}
      </div>

      <form className="terminal-input-form" onSubmit={handleCommand}>
        <span className="prompt" style={{color: '#aaa'}}>{">"}</span>
        <input
          className="terminal-input"
          type="text"
          value={inputValue}
          onChange={(e) => setInputValue(e.target.value)}
          autoFocus
          spellCheck="false"
          placeholder="..."
        />
      </form>
    </div>
  );
};

実装における注意点

  1. AudioContextの自動再生ポリシー: ユーザーインターフェースからの明示的な操作なしに AudioContext を開始しようとすると、ブラウザにブロックされる。そのため、SYNC_VOICE [OFF] ボタンのクリックイベントにフックし、resume() を実行することで制限を回避している。
  2. ローカルエンジンの状態検知: fetch リクエストが失敗した場合、ローカルでVOICEVOXが起動していないと判定し、UI上でインストールと起動を促すフォールバックダイアログを表示する。これにより、UXの低下を防ぐ。