[Astro #25] ReactとVOICEVOXを利用したWired風ターミナルと音声UIの実装
はじめに
本記事では、AstroとReactを用いた環境下で、ローカルで稼働するVOICEVOXエンジンと連携し、音声合成およびリップシンクのための音量解析を行うターミナルUIの実装手順を記録する。
音声の取得および再生処理は、過去に実装したChrome拡張機能(Gemini VOICEVOX Reader)のコードを流用・拡張している。
前回の記事:
[Astro #24] 1,600ノードの情報を幾何学的秩序で制圧 — 3D Force-Directed Graphの再構築 // PROTOCOL.LAIN
500件超の記事から抽出された膨大なタグネットワークを、Three.jsと物理演算を用いて可視化する実装解説。幾何学的な初期配置と摩擦制御による安定化プロセス。
lain-lab.comGemini VOICEVOX Reader (v1.5):
[Chrome Add-on] Gemini VOICEVOX Reader // PROTOCOL.LAIN
Gemini VOICEVOX Reader は、Gemini の回答をリアルタイムで検知し、ローカルの VOICEVOX エンジンを通じて好きなキャラクターの声で読み上げる Chrome 拡張機能です。
lain-lab.com1. ログ送信ユーティリティの実装
ターミナルへのログ出力は、カスタムイベントを用いてグローバルに発火させる構成とする。これにより、アプリケーションのどこからでもターミナルへメッセージを送信できる。
// 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>
);
};
実装における注意点
- AudioContextの自動再生ポリシー: ユーザーインターフェースからの明示的な操作なしに
AudioContextを開始しようとすると、ブラウザにブロックされる。そのため、SYNC_VOICE [OFF]ボタンのクリックイベントにフックし、resume()を実行することで制限を回避している。 - ローカルエンジンの状態検知:
fetchリクエストが失敗した場合、ローカルでVOICEVOXが起動していないと判定し、UI上でインストールと起動を促すフォールバックダイアログを表示する。これにより、UXの低下を防ぐ。