[TTS] リアルタイムLipsyncの実現方法

– TTS APIから得られるVisemeデータとVRMモデルがあれば、リップシンクデモをリアルタイムで実現できる
– Rhubarbで生成されたJSON(Visemeデータ)を、TTS APIがリアルタイムで出力するVisemeデータに置き換えることで、既存のThree.js/VRMのロジックを応用できる

### TTS API連携の場合(実現したいこと)
– TTS APIからのVisemeイベントストリーム
– 再生時間と**Visemeイベントのoffset**を比較し、Viseme IDに対応するVRMブレンドシェイプを適用する.
– TTS API固有のViseme IDをVRMのブレンドシェイプ名 (A, I, U, E, O, NEUTRAL) にマッピングする.

1. TTS API呼び出しとデータ取得
テキスト入力: ユーザーがテキストを入力するためのUI(textareaやinput)と、API呼び出し用のボタンを追加します。
API接続: 選択したTTS API(例: Azure Neural TTS)のエンドポイントに、入力テキストを含むリクエストを送信するJavaScriptコードを実装します.
データ受信: APIから返される合成音声データとVisemeイベントデータ(タイムスタンプ付きの口形情報)を同時に受け取るロジックを構築します.

2. リアルタイム再生とVisemeキューの処理
音声再生: 受信した音声データをWeb Audio APIでリアルタイムにデコード・再生します。
Visemeキューリストの作成: TTS APIから受け取ったVisemeイベントを、あなたのlipsyncData.mouthCuesと同様の形式(start時刻とvalue)のキューリストとしてメモリに保持します。

3. updateLipsync()関数の調整
あなたの既存のupdateLipsync()関数はそのまま使えますが、RhubarbのViseme ID(A, B, C, …)ではなく、TTS APIが使用するViseme IDに合わせてmouthShapeMapのキーを更新する必要があります.
例: Azure TTSはviseme id=”1″(唇を閉じている状態)、viseme id=”2″(Aの音)などの数値IDを使う場合があります.

Lipsyncの環境を作る

ripsyncに必要な rhubarb というファイルをgithubからダウンロードする
OSに合わせる必要があるため、rhubarb の mac osをダウンロードする
https://github.com/DanielSWolf/rhubarb-lip-sync/releases

$ mkdir vrm-project

この vrm-project に rhubarb を配置する

$ chmod +x rhubarb

<!DOCTYPE html>
<html lang="ja">
<head>
    <meta charset="UTF-8">
    <title>Simple VRM Lipsync (Final)</title>
    <style>
        body { margin: 0; overflow: hidden; }
        canvas { display: block; }
        #playButton { 
            position: absolute; 
            top: 20px; 
            left: 20px; 
            padding: 10px 20px;
            font-size: 16px;
            z-index: 10;
        }
    </style>
</head>
<body>
    <button id="playButton" disabled>VRMとデータをロード中...</button>

    <script src="https://unpkg.com/three@0.158.0/build/three.min.js"></script>

    <script src="https://unpkg.com/three@0.158.0/examples/js/controls/OrbitControls.js"></script>

    <script src="https://unpkg.com/three@0.158.0/examples/js/loaders/GLTFLoader.js"></script>
    
    <script>
        // 1. OrbitControls の定義をコピー
        if (typeof window.OrbitControls !== 'undefined' && typeof THREE.OrbitControls === 'undefined') {
            THREE.OrbitControls = window.OrbitControls;
        }
        
        // 2. GLTFLoader の定義をコピー
        if (typeof window.GLTFLoader !== 'undefined' && typeof THREE.GLTFLoader === 'undefined') {
            THREE.GLTFLoader = window.GLTFLoader;
        }
    </script>
    
    <script src="https://unpkg.com/@pixiv/three-vrm@2.0.0/lib/three-vrm.min.js"></script>
    
    <script>
        // --- 設定値 ---
        const VRM_MODEL_PATH = './28538335112454854.vrm'; 
        const AUDIO_PATH = './output.wav'; 
        const LIPSYNC_JSON_PATH = './output_lipsync.json';
        
        let renderer, scene, camera, clock, vrm, audio, lipsyncData;
        
        let vrmLoaded = false;
        let jsonLoaded = false;
        let currentVisemeIndex = 0;

        function checkReady() {
            if (vrmLoaded && jsonLoaded) {
                 const button = document.getElementById('playButton');
                 button.innerHTML = '音声再生&リップシンク開始';
                 button.disabled = false;
            }
        }
        
        // --- 初期化処理 ---
        function init() {
            // 1. レンダラーのセットアップ
            renderer = new THREE.WebGLRenderer({ antialias: true });
            renderer.setSize(window.innerWidth, window.innerHeight);
            renderer.setClearColor(0xeeeeee);
            document.body.appendChild(renderer.domElement);

            // 2. シーンとカメラのセットアップ
            scene = new THREE.Scene();
            camera = new THREE.PerspectiveCamera(45, window.innerWidth / window.innerHeight, 0.1, 100);
            camera.position.set(0, 1.3, 1.5); 
            
            // 3. ライト
            scene.add(new THREE.AmbientLight(0xffffff, 1.0));

            // 4. コントロール (OrbitControlsが見つからない場合はスキップ)
            let OrbitControlsClass = THREE.OrbitControls;
            
            if (!OrbitControlsClass) {
                console.error("致命的エラー: OrbitControlsが見つかりません。カメラ操作はできませんが、続行します。");
            } else {
                const controls = new OrbitControlsClass(camera, renderer.domElement);
                controls.target.set(0, 1.3, 0); 
            }

            // 5. アニメーションクロック
            clock = new THREE.Clock();
            
            // 6. VRMとJSONデータのロード
            loadVRM();
            loadLipsyncData();
            
            // 7. リップシンク開始ボタン
            document.getElementById('playButton').addEventListener('click', startLipsync);

            // 8. ウィンドウリサイズ対応
            window.addEventListener('resize', onWindowResize);
            
            // 9. アニメーションループ開始
            animate();
        }

        // --- VRMモデルのロード ---
        function loadVRM() {
            const loader = new THREE.GLTFLoader(); // ★修正されたGLTFLoaderを参照
            loader.crossOrigin = 'anonymous';

            loader.load(
                VRM_MODEL_PATH,
                (gltf) => {
                    THREE.VRM.from(gltf).then((vrmInstance) => {
                        vrm = vrmInstance;
                        scene.add(vrm.scene);
                        
                        VRMUtils.rotateVRM0(vrm); 
                        vrm.scene.rotation.y = Math.PI;
                        
                        resetMouth(); 
                        
                        vrmLoaded = true;
                        checkReady();
                    })
                    .catch(e => {
                         console.error("VRMインスタンス生成中にエラーが発生しました(互換性問題の可能性):", e);
                    });
                },
                undefined,
                (error) => {
                    console.error("GLTFファイルのロード中にエラーが発生しました。", error);
                }
            );
        }

        // --- リップシンクデータのロード (省略) ---
        function loadLipsyncData() {
            fetch(LIPSYNC_JSON_PATH)
                .then(response => {
                    if (!response.ok) {
                        throw new Error(`HTTP error! status: ${response.status}`);
                    }
                    return response.json();
                })
                .then(data => {
                    lipsyncData = data;
                    jsonLoaded = true;
                    checkReady();
                })
                .catch(error => {
                    console.error('Error loading lipsync JSON:', error);
                });
        }

        // --- 口のブレンドシェイプをリセットする関数 (省略) ---
        function resetMouth() {
            if (!vrm) return;
            const blendShapeProxy = vrm.humanoid.getBlendShapeProxy();
            blendShapeProxy.setValue('vrc.v_a', 0.0);
            blendShapeProxy.setValue('vrc.v_i', 0.0);
            blendShapeProxy.setValue('vrc.v_u', 0.0);
            blendShapeProxy.setValue('vrc.v_e', 0.0);
            blendShapeProxy.setValue('vrc.v_o', 0.0);
        }

        // --- リップシンク開始 (省略) ---
        function startLipsync() {
            if (!vrm || !lipsyncData || document.getElementById('playButton').disabled) {
                return;
            }
            
            document.getElementById('playButton').disabled = true;
            document.getElementById('playButton').innerHTML = '再生中...';

            audio = new Audio(AUDIO_PATH);
            audio.play();
            
            audio.onended = () => {
                resetMouth();
                currentVisemeIndex = 0;
                document.getElementById('playButton').innerHTML = '音声再生&リップシンク開始';
                document.getElementById('playButton').disabled = false;
            };
        }
        
        // --- リップシンク実行関数 (省略) ---
        function performLipsync() {
            if (!audio || audio.paused || audio.ended || !vrm) return;

            const currentTime = audio.currentTime;
            const visemes = lipsyncData.mouthCues;
            const blendShapeProxy = vrm.humanoid.getBlendShapeProxy();
            
            while (currentVisemeIndex < visemes.length) {
                const currentCue = visemes[currentVisemeIndex];
                
                if (currentTime < currentCue.start) {
                    break; 
                }
                
                if (currentTime >= currentCue.end) {
                    currentVisemeIndex++;
                    continue;
                }

                resetMouth(); 

                const rhubardValue = currentCue.value;

                // RhubarbのVisemeとVRMキーのマッピング
                const visemeKey = {
                    'A': 'vrc.v_a', 'B': 'vrc.v_o', 'C': 'vrc.v_i', 
                    'D': 'vrc.v_e', 'E': 'vrc.v_e', 'F': 'vrc.v_o', 
                    'G': 'vrc.v_e', 'H': 'vrc.v_a', 'X': null
                }[rhubardValue];

                if (visemeKey) {
                    blendShapeProxy.setValue(visemeKey, 1.0);
                }
                
                break; 
            }
        }

        // --- アニメーションループ (省略) ---
        function animate() {
            requestAnimationFrame(animate);

            const deltaTime = clock.getDelta();
            
            if (vrm) {
                vrm.update(deltaTime);
                performLipsync();
            }

            renderer.render(scene, camera);
        }

        // --- リサイズ処理 (省略) ---
        function onWindowResize() {
            camera.aspect = window.innerWidth / window.innerHeight;
            camera.updateProjectionMatrix();
            renderer.setSize(window.innerWidth, window.innerHeight);
        }

        // アプリケーション開始
        init();
    </script>
</body>
</html>

GoogleのText-to-Speech APIを使ってみる

1. Google Cloud プロジェクト作成
2. Text-to-Speech API を有効化
3. サービスアカウントキー(JSON)をダウンロード
4. 必要なライブラリをインストール
$ pip3 install google-cloud-texttospeech

from google.cloud import texttospeech
from google.oauth2 import service_account

# 認証キー読み込み
credentials = service_account.Credentials.from_service_account_file("gcp-test.json")
client = texttospeech.TextToSpeechClient(credentials=credentials)

# 読み上げるテキスト
input_text = texttospeech.SynthesisInput(text="こんにちは、これはテストです。")

# 日本語のWavenet音声
voice = texttospeech.VoiceSelectionParams(
    language_code="ja-JP",
    name="ja-JP-Wavenet-B"
)

audio_config = texttospeech.AudioConfig(
    audio_encoding=texttospeech.AudioEncoding.MP3
)

# 音声生成
response = client.synthesize_speech(
    input=input_text,
    voice=voice,
    audio_config=audio_config
)

# 保存
with open("output.mp3", "wb") as out:
    out.write(response.audio_content)
    print("✅ output.mp3 を作成しました")

そりゃGoogleもやってるわな… という感想しか出てこない

[TTS] ひろゆき氏の発言を音声にする

おしゃべりひろゆきメーカーというサイトでひろゆき氏の発言を音声にしてmp4でDLできるサービスがあるようです。
https://coefont.cloud/maker/hiroyuki

たまに見ますけど、面白いですよね

APIのような形で外部からひろゆき氏のAI音声にすることもできるようです。

TTSを自分のサーバで利用する場合は、GPUが必要なようです。

[TTS] OpenAIのAPIでTTSを実行する

import os
from openai import OpenAI
from dotenv import load_dotenv
load_dotenv()

# クライアント作成
client = OpenAI(api_key=os.getenv("OPENAI_API_KEY"))

# 音声生成
response = client.audio.speech.create(
    model="gpt-4o-mini-tts",   # 高品質・軽量 TTS モデル
    voice="alloy",             # 声の種類(alloy / verse / aria 等)
    input="Hello, world! This is a text-to-speech test."
)

# mp3として保存
output_path = "hello_world_tts.mp3"
with open(output_path, "wb") as f:
    f.write(response.read())

print(f"✅ 音声ファイルを保存しました → {output_path}")

声は変えられる。
voice=”alloy” # 落ち着いた声
voice=”aria” # 明るい声
voice=”verse” # 自然でニュートラル

[LLM] Flaskで簡易 /speak APIを作成

from flask import Flask, request, send_file, jsonify
from gtts import gTTS
import os
import tempfile
from datetime import datetime

app = Flask(__name__)

@app.route('/speak', methods=['POST'])
def speak():
    """
    テキストを音声に変換するAPI
    
    リクエスト例:
    {
        "text": "こんにちは、世界",
        "lang": "ja",
        "slow": false
    }
    """
    try:
        # リクエストからJSONデータを取得
        data = request.get_json()
        
        if not data or 'text' not in data:
            return jsonify({'error': 'text パラメータが必要です'}), 400
        
        text = data['text']
        lang = data.get('lang', 'ja')  # デフォルトは日本語
        slow = data.get('slow', False)  # ゆっくり話すかどうか
        
        if not text.strip():
            return jsonify({'error': 'テキストが空です'}), 400
        
        # 一時ファイルを作成
        temp_file = tempfile.NamedTemporaryFile(delete=False, suffix='.mp3')
        temp_filename = temp_file.name
        temp_file.close()
        
        # TTSで音声ファイルを生成
        tts = gTTS(text=text, lang=lang, slow=slow)
        tts.save(temp_filename)
        
        # 音声ファイルを返す
        response = send_file(
            temp_filename,
            mimetype='audio/mpeg',
            as_attachment=True,
            download_name=f'speech_{datetime.now().strftime("%Y%m%d_%H%M%S")}.mp3'
        )
        
        # レスポンス送信後にファイルを削除
        @response.call_on_close
        def cleanup():
            try:
                os.unlink(temp_filename)
            except Exception as e:
                print(f"一時ファイルの削除に失敗: {e}")
        
        return response
        
    except Exception as e:
        return jsonify({'error': str(e)}), 500


@app.route('/speak/languages', methods=['GET'])
def get_languages():
    """サポートされている言語のリストを返す"""
    languages = {
        'ja': '日本語',
        'en': '英語',
        'zh-cn': '中国語(簡体字)',
        'zh-tw': '中国語(繁体字)',
        'ko': '韓国語',
        'es': 'スペイン語',
        'fr': 'フランス語',
        'de': 'ドイツ語',
        'it': 'イタリア語',
        'pt': 'ポルトガル語',
    }
    return jsonify(languages)


@app.route('/health', methods=['GET'])
def health_check():
    """ヘルスチェック用エンドポイント"""
    return jsonify({'status': 'ok'})


if __name__ == '__main__':
    app.run(debug=True, host='0.0.0.0', port=5000)

$ curl -X POST http://localhost:5000/speak \ \
-H “Content-Type: application/json” \
-d ‘{“text”:”こんにちは、世界”, “lang”:”ja”}’ \
–output speech.mp3
% Total % Received % Xferd Average Speed Time Time Time Current
Dload Upload Total Spent Left Speed
100 18096 100 18048 100 48 34083 90 –:–:– –:–:– –:–:– 34207

なるほど。textをPOSTしていますが、gptからのレスポンスをそのまま出力しても良さそうですね。。

[TTS] MaAI

$ sudo apt install portaudio19-dev
$ pip3 install maai

import time
from maai import Maai, MaaiOutput, MaaiInput 
import numpy as np

# 1. 設定
# マイクを使用するため、ファイルパスはコメントアウト
# AUDIO_FILE_PATH = "sample.wav" 

# maai の初期化設定
# 安定動作が確認された "vap" モードを使用
maai = Maai(
    mode="vap", 
    lang="jp",  # 日本語設定
    frame_rate=10, 
    context_len_sec=5,
    
    # マイク入力を使用
    audio_ch1=MaaiInput.Mic(), 
    audio_ch2=MaaiInput.Zero(), 
    device="cpu"
)

# 2. 結果出力設定(ConsoleBarは使用せず、直接値を表示)
print("--- maai リアルタイム予測開始 (マイク入力) ---")
print("マイクに向かって何か話してください。予測結果が直接出力されます。Ctrl+Cで停止します。")
print("---------------------------------------------")
maai.start()

start_time = time.time() # 時間計測開始

# 3. 処理ループ
try:
    while True:
        # 結果を待機して取得
        result = maai.get_result()
        
        if result is None:
            continue
            
        # 💡 リスト型対応の処理: result の値がリストの場合、最初の要素 [0] を抽出する
        processed_result = {}
        for key, value in result.items():
            if isinstance(value, list) and value:
                # リストの最初の要素を抽出
                processed_result[key] = value[0]
            else:
                # リストではないか、空のリストの場合はそのまま使用
                processed_result[key] = value

        # 頷き/相槌の値を取得 (キーが "nod" や "aizuchi" になっていることを期待)
        nod_val = processed_result.get('nod', processed_result.get('p_nod', -1)) 
        aizuchi_val = processed_result.get('aizuchi', processed_result.get('p_aizuchi', -1)) 
        
        # ターンテイキングの値を取得
        p_now_val = processed_result.get('p_now', -1)
        p_future_val = processed_result.get('p_future', -1)
        
        output_line = f"Time: {time.time() - start_time:.2f}s | "
        
        # 頷き/相槌の予測値が含まれているか確認
        if nod_val != -1 or aizuchi_val != -1:
             # 頷き/相槌の予測がある場合の出力
            output_line += f"Nod: {nod_val:.3f} | Aizuchi: {aizuchi_val:.3f} | P_Now: {p_now_val:.3f}"
        else:
            # 含まれていない場合のデフォルト出力(ターンテイキング予測のみ)
            output_line += f"P_Now: {p_now_val:.3f} | P_Future: {p_future_val:.3f}"
            
        print(output_line)
        
        time.sleep(0.1) # 100ms ごとに結果を出力

except KeyboardInterrupt:
    print("\n処理を中断しました。")

# 4. 終了処理
maai.stop()
print("\n--- maai 処理終了 ---")

画面収録 2025-10-25 午前11.38.11

[TTS] Rule-basedとNeural TTSの違い

TTS(Text-to-Speech)は大きく分けると、Rule-based(ルールベース)TTS と Neural(ニューラル)TTS の2種類に分類される

全体像:TTS技術の進化段階
世代 技術名 代表方式 特徴
第1世代 Rule-based TTS 記号変換・音声ルール合成 機械的・不自然だが制御しやすい
第2世代 Statistical Parametric TTS HMMなど 統計的だが声がややロボット的
第3世代 Neural TTS(Deep Learning TTS) Tacotron, WaveNetなど 人間のように自然な音声

① Rule-based TTS(ルールベース音声合成)
人間が手作業で定義した「発音ルール」「音声単位(音素)」をもとに、
音を組み合わせて音声を作る仕組みです。
テキスト → 発音記号(ルールで変換) → 音声単位をつなげて波形生成

方式 説明
Formant synthesis(フォルマント合成) 声帯や口の共鳴特性を数式モデル化(例:Bell Labsの技術)
Concatenative synthesis(連結合成) 実際の録音音声(単語や音素)を切り貼りして繋ぐ方式(例:初期のナビ音声)

🎙 特徴
✅ メモリ・計算コストが低い
✅ 特定の発音やイントネーションを細かく制御できる
❌ 音のつなぎ目が不自然(滑らかさがない)
❌ 抑揚や感情表現が単調・ロボット的

② Neural TTS(ニューラル音声合成)
🧠 仕組み
深層学習モデル(ディープニューラルネットワーク)が
テキスト→音声波形 の変換を「学習」

主な代表モデル
モデル 内容
Tacotron / Tacotron2 テキスト→メルスペクトログラムをSeq2Seqで生成。自然なイントネーション。
FastSpeech / FastSpeech2 Tacotronを改良し、高速かつ安定。
WaveNet / HiFi-GAN / DiffWave 高品質なボコーダ(波形生成)。人間に近い音質。

🎙 特徴
✅ 自然な抑揚・滑らかさ・感情表現
✅ 大量データを使えば「人の声をそっくり再現」できる
✅ マルチスピーカー・多言語対応が容易
❌ 計算コストが高く、学習に大規模データが必要
❌ 「声のなりすまし」などの倫理リスクがある

🎯 Rule-based vs Neural TTS 比較表
比較項目 Rule-based TTS Neural TTS
生成方法 手作りルール・辞書ベース ディープラーニングによる学習ベース
音声の自然さ 機械的・単調 滑らかで人間的
柔軟性(声質・感情) 制御しやすいが不自然 データ次第で柔軟・感情表現可
開発コスト 小規模でも可能 大量データとGPUが必要
リアルタイム性 軽量・高速 モデルによる(最近はリアルタイム可能)
代表的技術 Formant, Concatenative Tacotron, FastSpeech, WaveNet
応用例 初期のカーナビ、読み上げ機器 音声アシスタント、AIナレーション、音声翻訳

[TTS] Text Normalization → Acoustic Model → Vocoder

[全体像]
テキスト(文字列)

① Text Normalization(前処理)

② Acoustic Model(音響モデル)

③ Vocoder(ボコーダ)

音声波形(実際に聞こえる音)

① Text Normalization(テキスト正規化・前処理)
🔍 目的:
文字データを「読み上げ可能な形」に整えること。
人間が“読む”ときに自然になるよう、TTSに理解できる形式へ変換します。

数字の読み方変換 「2025年」 「にせんにじゅうごねん」
記号・略語展開 「Dr.」 「ドクター」
文の区切り付け 「こんにちは。元気?」 (文境界を明示)
発音表記への変換(G2P: Grapheme→Phoneme) 「東京」 /toːkjoː/

② Acoustic Model(音響モデル)
🔍 目的:
「この文をどう発音するか(音の特徴)」を予測する。
つまり、テキスト(音素列)→メルスペクトログラム を生成します。
📘 メルスペクトログラムとは?
時間 × 周波数の2次元表現で、音の「強さ」「高さ」「声質」を可視化したもの。

音声波形を直接作る代わりに、この中間表現を生成することで、
音の自然さや安定性を確保できます。
💡 Acoustic Modelの役割
要素 内容
発音タイミング 各音素の長さや間(Duration)を決定
抑揚(イントネーション) ピッチ(F0)の変化を予測
音量・エネルギー 声の強弱を再現
声質・感情 声のトーンや感情表現を生成

⚙️ 代表的なモデル構造
モデル名 特徴
Tacotron / Tacotron2 Seq2Seqでメルスペクトログラムを直接生成。自然な抑揚。
FastSpeech / FastSpeech2 Duration情報を明示的に予測し、高速かつ安定。
Glow-TTS / Grad-TTS 正規化フローや拡散モデルで高品質生成。

③ Vocoder(ボコーダ)
🔍 目的:
メルスペクトログラムなどの中間表現を、
実際の波形(音声)に変換 する。
🎧 どうやって波形を作るの?

スペクトログラムを入力として、時間ドメインの音波をサンプル単位で生成。
深層学習モデルが「周波数特性」を復元し、自然な音を再構成します。
💡 代表的なニューラルボコーダ
モデル名 特徴
WaveNet (Google) 最初の高品質ニューラルボコーダ。非常に自然だが遅い。
WaveRNN 軽量・高速でリアルタイム合成可能。
Parallel WaveGAN / HiFi-GAN GANベースで高音質&超高速。現行主流。
DiffWave / WaveGrad 拡散モデルを応用。高品質で滑らか。

補足:3ステップの関係性
ステップ 入力 出力 技術の主役
Text Normalization 文字列 発音記号 or 音素列 言語処理(NLP)
Acoustic Model 音素列 メルスペクトログラム 深層学習(Seq2Seq, Transformerなど)
Vocoder メルスペクトログラム 波形データ 深層生成モデル(WaveNet, GAN, Diffusionなど)