diff --git a/AI_Meeting_App/stt_stream.py b/AI_Meeting_App/stt_stream.py new file mode 100644 index 0000000..da120fb --- /dev/null +++ b/AI_Meeting_App/stt_stream.py @@ -0,0 +1,1063 @@ +# -*- coding: utf-8 -*- +""" +Gemini Live API ベースの会議アシスタント(Function Callingハイブリッド方式) +- 短い応答 → Live API(低遅延) +- 長い応答(要約・質問・検索・資料参照)→ REST API + TTS +- Function Callingで明示的に切り替え +""" + +import pyaudio +import os +import argparse +import asyncio +import struct +import math +import re +from datetime import datetime + +from google import genai +from google.genai import types +from google.cloud import texttospeech + +# ============================================================ +# 設定 +# ============================================================ + +# Gemini API Key +GEMINI_API_KEY_OVERRIDE = "AIzaSyCwwDBpyk2K6btr5TOQBU60FUgDvPitnMg" + +# GCP Project ID(TTS用) +GCP_PROJECT_ID = "ai-meet-486502" + +# Voicemeeter デバイス設定 +INPUT_DEVICE_NAME = "Voicemeeter Out B1 (VB-Audio Vo" +TTS_OUTPUT_DEVICE_NAME = "Voicemeeter AUX Input (VB-Audio" + +# Live API 設定 +LIVE_API_MODEL = "gemini-2.5-flash-native-audio-preview-12-2025" +REST_API_MODEL = "gemini-2.5-flash" +SEND_SAMPLE_RATE = 16000 +RECEIVE_SAMPLE_RATE = 24000 +TTS_SAMPLE_RATE = 24000 +CHUNK_SIZE = 1024 +CHANNELS = 1 +FORMAT = pyaudio.paInt16 + +# ファイルパス +INTERVIEW_SCRIPT_FILE_PATH = "interview_script.txt" +MEETING_SUMMARY_FILE_PATH = "meeting_summary.txt" +REFERENCE_PDF_FILE_PATH = "reference.pdf" +TRANSCRIPT_FILE_PATH = "meeting_transcript_log.md" + +# ============================================================ +# Function Calling ツール定義 +# ============================================================ + +def get_interview_tools(): + """Live API用のツール定義 - 一旦無効化""" + # ポリシーエラーが発生するため、ツールを無効化 + return [] + +# ============================================================ +# システムインストラクション(モード別) +# ============================================================ + +STANDARD_SYSTEM_INSTRUCTION = """あなたは会議をサポートするフレンドリーなAIアシスタントです。 +必ず日本語で話してください。 + +【役割】 +- 会議の流れを温かくサポートする +- 参加者の発言を尊重し、必要な時だけ介入する + +【重要:発話の長さ制限】 +あなたの音声出力には厳しい制限があります。 +1回の発話は2〜3文以内、10秒以内に収めてください。 + +【発話の例】 +- 「なるほど、そうなんですね。」 +- 「はい、承知しました。」 +- 「ええ、よくわかります。」 +""" + +SILENT_SYSTEM_INSTRUCTION = """あなたは会議の書記役AIアシスタントです。 +必ず日本語で話してください。 + +【重要】 +- 基本的に発言しない(黙って聞いている) +- 「アシスタントさん」と呼ばれた時だけ応答する +- 呼ばれたら「はい、何でしょうか?」と応答し、指示を待つ +- 応答は短く簡潔に(2〜3文以内) +""" + +INTERVIEW_SYSTEM_INSTRUCTION = """あなたはプロのインタビュアーです。 +必ず日本語で話してください。 + +【あなたの役割】 +インタビュースクリプトに従って、話者に質問をしていきます。 + +【インタビュー開始時】 +1. 話者を紹介する(「本日は〜様にお話を伺います」) +2. 自己紹介を依頼する(「まずは自己紹介をお願いします」) +3. 話者の発言を待つ + +【通常のインタビュー進行】 +発話パターン:相槌 + 次の質問 +「〜ですね。では、〜でしょうか?」 + +【重要:話者から説明・検索を依頼されたら】 +話者が「説明してもらえますか?」「調べてもらえますか?」と依頼したら: + +1. まず即答する(これが最優先!) + 「はい、承知しました。」 + 「はい、確認しますので少々お待ちください。」 + 「はい、お調べします。」 + +2. その後、4〜6文程度でしっかり説明する + - 資料やスクリプトの内容を参考に + - 具体的な数字や特徴を含める + - 聞き手にわかりやすく + +3. 説明が終わったら「以上です」で締める + +4. 話者の反応を待ってから次の質問へ + +【説明の例】 +話者「ポイントモールについて説明してもらえますか?」 + +良い例: +「はい、承知しました。ポイントモールは、ウェルテクトを購入した金額と同額のポイントがもらえる仕組みです。このポイントで日用品を卸価格で購入できます。企業にとっては福利厚生費として計上でき、従業員にとっては健康管理をしながらお得に買い物ができるメリットがあります。健康経営を促進する狙いで作られました。以上です。」 + +悪い例(短すぎ): +「ポイントモールは購入金額と同額のポイントがもらえます。以上です。」 + +【禁止事項】 +- 説明後すぐに次の質問をすること(話者の反応を待つ) +- 説明を2文以内で終わらせること(しっかり説明する) +""" + +# REST API用のシステムインストラクション +REST_API_SYSTEM_INSTRUCTION = """あなたはプロのインタビュアーです。 +必ず日本語で回答してください。 + +【役割】 +- ユーザーの発言を簡潔に要約する +- インタビュースクリプトに沿った次の質問を行う +- 資料を参照して詳しく説明する + +【回答スタイル】 +- 音声で読み上げられることを意識して、聞きやすい表現を使う +- マークダウン記法(**太字**、# 見出し、- リストなど)は使わない +- 質問は必ず「〜でしょうか?」「〜ですか?」で丁寧に終える + +【フォーマット】 +1. まず短い相槌(省略可) +2. ユーザーの発言の簡潔な要約(1文) +3. 次の質問(1〜2文) +""" + +# ============================================================ +# 効果音・つなぎ音声生成 +# ============================================================ + +def generate_beep_sound(frequency=800, duration=0.3, sample_rate=24000): + """ビープ音を生成""" + num_samples = int(sample_rate * duration) + audio_data = [] + for i in range(num_samples): + value = int(16000 * math.sin(2 * math.pi * frequency * i / sample_rate)) + if i < num_samples * 0.1: + value = int(value * (i / (num_samples * 0.1))) + elif i > num_samples * 0.9: + value = int(value * ((num_samples - i) / (num_samples * 0.1))) + audio_data.append(struct.pack(' list: + """テキストを文単位で分割""" + sentences = re.split(r'(?<=[。!?])', text) + result = [] + current = "" + + for sentence in sentences: + if len(current) + len(sentence) > max_length: + if current: + result.append(current) + current = sentence + else: + current += sentence + + if current: + result.append(current) + + return result + +# ============================================================ +# REST API処理(検索・資料参照用) +# ============================================================ + +class RestAPIHandler: + """REST APIで検索・資料参照を処理""" + + def __init__(self, mode: str): + self.mode = mode + api_key = os.getenv("GEMINI_API_KEY") or GEMINI_API_KEY_OVERRIDE + self.client = genai.Client(api_key=api_key) + self.chat = None + self.pdf_file = None + self._init_chat() + + def _init_chat(self): + """チャットセッションを初期化""" + # PDFがあればアップロード + if os.path.exists(REFERENCE_PDF_FILE_PATH): + try: + self.pdf_file = self.client.files.upload(file=REFERENCE_PDF_FILE_PATH) + print(f"📄 参照PDF読み込み完了: {REFERENCE_PDF_FILE_PATH}") + except Exception as e: + print(f"⚠️ PDF読み込みエラー: {e}") + + # チャットセッション作成 + system_instruction = REST_API_SYSTEM_INSTRUCTION + + # モード別の追加コンテキスト + if self.mode == 'interview' and os.path.exists(INTERVIEW_SCRIPT_FILE_PATH): + with open(INTERVIEW_SCRIPT_FILE_PATH, 'r', encoding='utf-8') as f: + script = f.read() + system_instruction += f"\n\n--- インタビュースクリプト ---\n{script}\n---" + + config = types.GenerateContentConfig( + system_instruction=system_instruction, + tools=[types.Tool(google_search=types.GoogleSearch())], + ) + + self.chat = self.client.chats.create( + model=REST_API_MODEL, + config=config, + ) + + def query(self, prompt: str) -> str: + """REST APIでクエリを実行""" + try: + # PDFがある場合は参照として追加 + if self.pdf_file: + prompt += "\n\n※参考資料(PDF)も参照して回答してください。" + + response = self.chat.send_message(prompt) + + if response and response.text: + return response.text.strip() + return "申し訳ありません、回答を生成できませんでした。" + + except Exception as e: + print(f"❌ REST APIエラー: {e}") + return f"エラーが発生しました: {e}" + +# ============================================================ +# Gemini Live API アプリケーション(Function Callingハイブリッド) +# ============================================================ + +class GeminiLiveApp: + """Gemini Live APIを使用した会議アシスタント""" + + # セッション再接続の閾値(大幅に緩和) + MAX_AI_CHARS_BEFORE_RECONNECT = 800 # 累積上限を大幅に上げる + LONG_SPEECH_THRESHOLD = 500 # 長い発話の閾値も上げる + + def __init__(self, mode: str, input_device_index: int, output_device_index: int): + self.mode = mode + self.input_device_index = input_device_index + self.output_device_index = output_device_index + + self.p = pyaudio.PyAudio() + self.audio_queue_output = None + self.audio_queue_mic = None + self.mic_stream = None + self.speaker_stream = None + + # トランスクリプトバッファ + self.user_transcript_buffer = "" + self.ai_transcript_buffer = "" + self.conversation_history = [] # 会話履歴 + + # セッション再接続用カウンター + self.ai_char_count = 0 # AI発話文字数の累積 + self.needs_reconnect = False # 再接続フラグ + self.session_count = 0 # セッション番号 + + # Gen AI クライアント + api_key = os.getenv("GEMINI_API_KEY") or GEMINI_API_KEY_OVERRIDE + self.client = genai.Client(api_key=api_key) + + # REST APIハンドラとTTSプレイヤー + self.rest_handler = RestAPIHandler(mode) + self.tts_player = TTSPlayer(output_device_index) + + # システムインストラクション構築 + self.system_instruction = self._build_system_instruction() + + # Live API 設定(Function Calling有効) + self.config = self._build_config() + + def _build_config(self, with_context: str = None) -> dict: + """Live API設定を構築""" + instruction = self.system_instruction + + # 再接続時は会話履歴を追加し、相槌を指示 + if with_context: + # 直前のユーザー発言を抽出 + last_user_message = "" + for h in reversed(self.conversation_history): + if h['role'] == 'ユーザー': + last_user_message = h['text'][:100] + break + + # 次の質問を取得 + next_q = self._get_next_question_from_script() + + instruction += f""" + +【これまでの会話の要約】 +{with_context} + +【重要:必ず守ること】 +1. 直前の話者の発言「{last_user_message}」に対して短い相槌を入れる +2. 既に聞いた質問は絶対に繰り返さない +3. 次に聞くべき質問:「{next_q[:100]}」 + +【応答パターン】 +「〜ということですね。では、{next_q[:50]}」 +""" + + config = { + "response_modalities": ["AUDIO"], + "system_instruction": instruction, + "input_audio_transcription": {}, + "output_audio_transcription": {}, + "speech_config": { + "language_code": "ja-JP", + }, + "realtime_input_config": { + "automatic_activity_detection": { + "disabled": False, + "start_of_speech_sensitivity": "START_SENSITIVITY_HIGH", + "end_of_speech_sensitivity": "END_SENSITIVITY_HIGH", + "prefix_padding_ms": 100, + "silence_duration_ms": 500, + } + }, + "context_window_compression": { + "sliding_window": { + "target_tokens": 32000, + } + }, + } + + # ツールがある場合のみ追加 + tools = get_interview_tools() + if tools: + config["tools"] = tools + + return config + + def _build_system_instruction(self) -> str: + """モードに応じたシステムインストラクションを構築""" + if self.mode == 'interview': + instruction = INTERVIEW_SYSTEM_INSTRUCTION + if os.path.exists(INTERVIEW_SCRIPT_FILE_PATH): + with open(INTERVIEW_SCRIPT_FILE_PATH, 'r', encoding='utf-8') as f: + script = f.read() + instruction += f"\n\n--- インタビュースクリプト ---\n{script}\n---" + return instruction + elif self.mode == 'silent': + return SILENT_SYSTEM_INSTRUCTION + else: + instruction = STANDARD_SYSTEM_INSTRUCTION + if os.path.exists(MEETING_SUMMARY_FILE_PATH): + with open(MEETING_SUMMARY_FILE_PATH, 'r', encoding='utf-8') as f: + summary = f.read() + instruction += f"\n\n--- 会議の背景情報 ---\n{summary}\n---" + return instruction + + def _add_to_history(self, role: str, text: str): + """会話履歴に追加""" + self.conversation_history.append({"role": role, "text": text}) + # 直近20ターンを保持 + if len(self.conversation_history) > 20: + self.conversation_history = self.conversation_history[-20:] + + def _get_history_string(self) -> str: + """会話履歴を文字列で取得""" + return "\n".join([f"{h['role']}: {h['text']}" for h in self.conversation_history]) + + def _is_speech_incomplete(self, text: str) -> bool: + """発言が途中で切れているかチェック""" + if not text: + return False + + text = text.strip() + + # 正常な終わり方のパターン + normal_endings = ['。', '?', '?', '!', '!', 'か?', 'か?', 'ます', 'です', 'ね', 'よ', 'した', 'ください'] + + for ending in normal_endings: + if text.endswith(ending): + return False + + # 途中で切れている可能性が高いパターン + incomplete_patterns = ['、', 'の', 'を', 'が', 'は', 'に', 'で', 'と', 'も', 'や'] + + for pattern in incomplete_patterns: + if text.endswith(pattern): + return True + + # 最後の文字がひらがな・カタカナで、文末っぽくない場合 + last_char = text[-1] + if last_char in 'あいうえおかきくけこさしすせそたちつてとなにぬねのはひふへほまみむめもやゆよらりるれろわをんアイウエオカキクケコサシスセソタチツテトナニヌネノハヒフヘホマミムメモヤユヨラリルレロワヲン': + # 「ね」「よ」「か」などは正常 + if last_char not in 'ねよかなわ': + return True + + return False + + def _get_next_question_from_script(self) -> str: + """インタビュースクリプトから、まだ聞いていない次の質問を取得""" + try: + if not os.path.exists(INTERVIEW_SCRIPT_FILE_PATH): + return "次の質問に進んでください" + + with open(INTERVIEW_SCRIPT_FILE_PATH, 'r', encoding='utf-8') as f: + script = f.read() + + # 質問を抽出([質問N]パターン) + import re + questions = re.findall(r'\[質問\d+\]\s*\n([^\[]+)', script) + + if not questions: + return "次の質問に進んでください" + + # 会話履歴から既に聞いた質問のキーワードを抽出 + history_text = self._get_history_string() + + for q in questions: + q_clean = q.strip() + if not q_clean: + continue + + # 質問の主要キーワードを抽出(最初の名詞・キーワード) + keywords = [] + for word in ['健康診断', '健康経営', 'ウェルテクト', '常温', 'ビッグデータ', + 'ポイントモール', '導入', 'メリット', '経営者', '人事', '締めくくり']: + if word in q_clean: + keywords.append(word) + + # キーワードが履歴に含まれているかチェック + found_in_history = False + for kw in keywords: + if kw in history_text: + found_in_history = True + break + + if not found_in_history: + return q_clean + + # 全部聞いた場合 + return "本日のインタビューはこれで終了です。ありがとうございました。" + + except Exception as e: + print(f"⚠️ スクリプト読み込みエラー: {e}") + return "次の質問に進んでください" + + async def receive_audio(self, session): + """Live APIからの応答を受信""" + while not self.needs_reconnect: + turn = session.receive() + async for response in turn: + # 再接続フラグが立ったら終了 + if self.needs_reconnect: + return + + # tool_callイベントを検知(Live API形式) + if hasattr(response, 'tool_call') and response.tool_call: + await self._handle_tool_call(response.tool_call, session) + continue + + if response.server_content: + sc = response.server_content + + # model_turn内のfunction_callは無視(tool_callで処理する) + # ※ tool_callにはidがあるが、function_callにはない場合があるため + + # ターン完了 + if hasattr(sc, 'turn_complete') and sc.turn_complete: + if self.user_transcript_buffer.strip(): + user_text = self.user_transcript_buffer.strip() + print(f"👤 ユーザー: {user_text}") + log_transcript(f"[ユーザー] {user_text}", TRANSCRIPT_FILE_PATH) + self._add_to_history("ユーザー", user_text) + self.user_transcript_buffer = "" + + # ユーザー発言後に再接続が必要なら実行 + if self.needs_reconnect: + print("🔄 再接続を実行します...") + return + + if self.ai_transcript_buffer.strip(): + ai_text = self.ai_transcript_buffer.strip() + print(f"🤖 AI(Live): {ai_text}") + log_transcript(f"[AI] {ai_text}", TRANSCRIPT_FILE_PATH) + self._add_to_history("AI", ai_text) + + # 発言が途中で切れているかチェック + is_incomplete = self._is_speech_incomplete(ai_text) + if is_incomplete: + print(" ⚠️ 発言が途中で切れた可能性あり") + + # 文字数をカウント + char_count = len(ai_text) + self.ai_char_count += char_count + remaining = self.MAX_AI_CHARS_BEFORE_RECONNECT - self.ai_char_count + print(f" (累積: {self.ai_char_count}文字 / 残り: {remaining}文字)") + + self.ai_transcript_buffer = "" + + # 発言が途切れた場合は即座に再接続 + if is_incomplete: + print("🔄 発言途切れのため即時再接続します...") + self.needs_reconnect = True + # 長い発話(80文字以上)をした場合、次で途切れるリスクが高いので再接続 + elif char_count >= self.LONG_SPEECH_THRESHOLD: + print(f"🔄 長い発話({char_count}文字)のため次のターン前に再接続します。") + self.needs_reconnect = True + # 累積が上限に近づいた場合 + elif self.ai_char_count >= self.MAX_AI_CHARS_BEFORE_RECONNECT: + print("🔄 累積制限に近づいています。再接続します。") + self.needs_reconnect = True + + print("--- ターン完了 ---") + + if hasattr(sc, 'generation_complete') and sc.generation_complete: + print("--- 生成完了 ---") + + # 割り込み検知 + if response.server_content and hasattr(response.server_content, 'interrupted'): + if response.server_content.interrupted: + print("🚨 割り込み検知!") + if self.ai_transcript_buffer.strip(): + print(f"🤖 AI(中断): {self.ai_transcript_buffer.strip()}") + self.ai_transcript_buffer = "" + while not self.audio_queue_output.empty(): + try: + self.audio_queue_output.get_nowait() + except asyncio.QueueEmpty: + break + continue + + # 入力トランスクリプション(バッファに蓄積、最終的にまとめて表示) + if response.server_content and hasattr(response.server_content, 'input_transcription'): + if response.server_content.input_transcription: + user_text = response.server_content.input_transcription.text + if user_text: + self.user_transcript_buffer += user_text + + # 出力トランスクリプション + if response.server_content and hasattr(response.server_content, 'output_transcription'): + if response.server_content.output_transcription: + ai_text = response.server_content.output_transcription.text + if ai_text: + self.ai_transcript_buffer += ai_text + + # 音声データを受信 + if response.server_content and response.server_content.model_turn: + for part in response.server_content.model_turn.parts: + if hasattr(part, 'inline_data') and part.inline_data: + if isinstance(part.inline_data.data, bytes): + self.audio_queue_output.put_nowait(part.inline_data.data) + + async def _handle_tool_call(self, tool_call, session): + """Function Call(ツール呼び出し)を処理""" + for fc in tool_call.function_calls: + if fc.name == "request_explanation": + topic = fc.args.get("topic", "") if fc.args else "" + print(f"🔧 説明依頼検知: {topic}") + + # ツール結果を返す(説明だけ、次の質問はしない) + instruction = f"「{topic}」について2〜3文で簡潔に説明してください。説明が終わったら「以上です」と言って止まってください。次の質問はしないでください。" + + try: + await session.send_tool_response( + function_responses=[ + types.FunctionResponse( + name=fc.name, + id=fc.id, + response={ + "status": "ready", + "instruction": instruction + } + ) + ] + ) + print(" ✅ ツール応答送信完了、Live APIの説明を待機中...") + # ※ ここでは再接続フラグを立てない! + # Live APIが説明を完了した後、通常のターン完了で累積制限により再接続される + except Exception as e: + print(f"⚠️ ツール応答エラー: {e}") + + async def run(self): + """メインループ(再接続対応)""" + self.audio_queue_output = asyncio.Queue() + self.audio_queue_mic = asyncio.Queue(maxsize=5) + + initialize_transcript(TRANSCRIPT_FILE_PATH, self.mode) + + mode_names = { + 'standard': 'スタンダードモード', + 'silent': 'サイレントモード', + 'interview': 'インタビューモード', + } + + print("\n" + "=" * 60) + print(f"🎙️ Gemini Live API - {mode_names.get(self.mode, self.mode)}") + print(" (Live API統一方式 + 自動再接続)") + print("=" * 60) + print("⚠️ ヘッドセット推奨(エコー防止のため)") + print("💡 全てLive APIで処理") + print(f"💡 累積{self.MAX_AI_CHARS_BEFORE_RECONNECT}文字で自動再接続") + print("💡 Ctrl+C で終了") + print("=" * 60 + "\n") + + # マイク・スピーカーの初期化 + await self._init_audio_streams() + + try: + while True: + self.session_count += 1 + self.ai_char_count = 0 # 文字数カウントをリセット + self.needs_reconnect = False + + # 再接続時は会話履歴を引き継ぐ + context = None + if self.session_count > 1: + context = self._get_context_summary() + print(f"\n🔄 セッション再接続 (#{self.session_count})") + if context: + print(f" 引き継ぎコンテキスト: {context[:80]}...") + + config = self._build_config(with_context=context) + + try: + async with self.client.aio.live.connect( + model=LIVE_API_MODEL, + config=config + ) as session: + if self.session_count == 1: + print("✅ Gemini Live API に接続しました。話しかけてください!\n") + else: + print("✅ 再接続完了。続けてください。\n") + # 再接続時は、まずテキストで挨拶を送って応答を促す + try: + await session.send_client_content( + turns=types.Content( + role="user", + parts=[types.Part(text="続きをお願いします")] + ), + turn_complete=True + ) + print(" 📤 再接続通知を送信しました") + except Exception as e: + print(f" ⚠️ 再接続通知送信エラー: {e}") + + # セッションループ + await self._session_loop(session) + + # 再接続が必要な場合はループを継続 + if not self.needs_reconnect: + break + + except Exception as e: + error_msg = str(e).lower() + print(f"❌ セッションエラー: {e}") + + # 再接続可能なエラーかどうか判定 + if any(keyword in error_msg for keyword in ["1011", "internal error", "disconnected", "closed", "websocket"]): + print("🔄 接続エラー。3秒後に再接続します...") + await asyncio.sleep(3) + self.needs_reconnect = True + continue + else: + raise + + except asyncio.CancelledError: + pass + except KeyboardInterrupt: + pass + finally: + self.cleanup() + + async def _init_audio_streams(self): + """マイクとスピーカーのストリームを初期化""" + device_info = self.p.get_device_info_by_index(self.input_device_index) + print(f"🎤 入力: {device_info['name']}") + + self.mic_stream = await asyncio.to_thread( + self.p.open, + format=FORMAT, + channels=CHANNELS, + rate=SEND_SAMPLE_RATE, + input=True, + input_device_index=self.input_device_index, + frames_per_buffer=CHUNK_SIZE, + ) + + device_info = self.p.get_device_info_by_index(self.output_device_index) + print(f"🔊 出力: {device_info['name']}") + + self.speaker_stream = await asyncio.to_thread( + self.p.open, + format=FORMAT, + channels=CHANNELS, + rate=RECEIVE_SAMPLE_RATE, + output=True, + output_device_index=self.output_device_index, + ) + + async def _session_loop(self, session): + """1つのセッション内のメインループ""" + + async def listen_audio(): + """マイクから音声を取得してキューに入れる""" + kwargs = {"exception_on_overflow": False} + send_count = 0 + while not self.needs_reconnect: + try: + data = await asyncio.to_thread(self.mic_stream.read, CHUNK_SIZE, **kwargs) + try: + self.audio_queue_mic.put_nowait({"data": data, "mime_type": "audio/pcm"}) + send_count += 1 + if send_count <= 3: + print(f" 🎤 音声送信中... ({send_count})") + except asyncio.QueueFull: + pass + except Exception as e: + if self.needs_reconnect: + return + print(f"⚠️ マイク読み取りエラー: {e}") + self.needs_reconnect = True + return + + async def send_audio(): + """キューから音声を取得してLive APIに送信""" + send_count = 0 + while not self.needs_reconnect: + try: + msg = await asyncio.wait_for( + self.audio_queue_mic.get(), + timeout=0.1 + ) + await session.send_realtime_input(audio=msg) + send_count += 1 + # 定期的に送信確認 + if send_count % 500 == 0: + print(f" 📡 音声送信継続中... ({send_count})") + except asyncio.TimeoutError: + continue + except Exception as e: + if self.needs_reconnect: + return + print(f"⚠️ 送信エラー: {e}") + self.needs_reconnect = True + return + + async def play_audio(): + while not self.needs_reconnect: + try: + audio_data = await asyncio.wait_for( + self.audio_queue_output.get(), + timeout=0.1 + ) + await asyncio.to_thread(self.speaker_stream.write, audio_data) + except asyncio.TimeoutError: + continue + except Exception as e: + if self.needs_reconnect: + return + print(f"⚠️ 再生エラー: {e}") + return + + async def receive(): + try: + await self.receive_audio(session) + except Exception as e: + if self.needs_reconnect: + return + error_msg = str(e).lower() + if any(keyword in error_msg for keyword in ["1011", "1008", "internal error", "closed", "deadline", "policy"]): + if "deadline" in error_msg: + print(f"⏱️ サーバータイムアウト。再接続します...") + elif "1008" in error_msg or "policy" in error_msg: + print(f"⚠️ ポリシーエラー。再接続します...") + else: + print(f"⚠️ 受信エラー(再接続します): {e}") + self.needs_reconnect = True + else: + raise + + # キューをクリア(再接続時に古いデータが残らないように) + while not self.audio_queue_mic.empty(): + try: + self.audio_queue_mic.get_nowait() + except: + break + while not self.audio_queue_output.empty(): + try: + self.audio_queue_output.get_nowait() + except: + break + + try: + async with asyncio.TaskGroup() as tg: + tg.create_task(listen_audio()) + tg.create_task(send_audio()) + tg.create_task(receive()) + tg.create_task(play_audio()) + except* Exception as eg: + if not self.needs_reconnect: + for e in eg.exceptions: + error_msg = str(e).lower() + if any(keyword in error_msg for keyword in ["1011", "internal error", "closed", "websocket"]): + self.needs_reconnect = True + else: + print(f"タスクエラー: {e}") + + def _get_context_summary(self) -> str: + """会話履歴の要約を取得""" + if not self.conversation_history: + return "" + + # 直近10ターンを取得 + recent = self.conversation_history[-10:] + summary_parts = [] + + for h in recent: + role = h['role'] + text = h['text'][:150] # 150文字まで + summary_parts.append(f"{role}: {text}") + + summary = "\n".join(summary_parts) + + # 最後のAI発言が質問なら強調 + last_ai = None + for h in reversed(self.conversation_history): + if h['role'] == 'AI': + last_ai = h['text'] + break + + if last_ai and ('?' in last_ai or '?' in last_ai or 'か?' in last_ai or 'か?' in last_ai): + summary += f"\n\n【直前の質問(これに対する回答を待っています)】\n{last_ai[:200]}" + + return summary + + def cleanup(self): + """リソース解放""" + if self.mic_stream: + self.mic_stream.close() + if self.speaker_stream: + self.speaker_stream.close() + self.p.terminate() + print("\n👋 接続を終了しました。") + +# ============================================================ +# メイン +# ============================================================ + +def find_device_index(p: pyaudio.PyAudio, device_name: str, is_input: bool) -> int: + """デバイス名からインデックスを検索""" + for i in range(p.get_device_count()): + info = p.get_device_info_by_index(i) + if device_name in info.get('name', ''): + if is_input and info.get('maxInputChannels', 0) > 0: + return i + elif not is_input and info.get('maxOutputChannels', 0) > 0: + return i + return -1 + + +def main(): + parser = argparse.ArgumentParser(description='Gemini Live API Meeting Assistant (Hybrid)') + parser.add_argument('--silent', action='store_true', help='サイレントモード') + parser.add_argument('--interview', action='store_true', help='インタビューモード') + args = parser.parse_args() + + # モード決定 + if args.interview: + mode = 'interview' + elif args.silent: + mode = 'silent' + else: + mode = 'standard' + + mode_names = { + 'standard': 'スタンダードモード (アクティブ介入)', + 'silent': 'サイレントモード (書記専念)', + 'interview': 'インタビューモード (進行支援)', + } + + print(f"\n{'='*60}") + print(f"★ 起動モード: {mode_names[mode]}") + print(f"{'='*60}\n") + + # GCP Project設定 + if GCP_PROJECT_ID: + os.environ['GOOGLE_CLOUD_PROJECT'] = GCP_PROJECT_ID + + # PyAudio初期化 + p = pyaudio.PyAudio() + + # デバイス検索 + input_device_index = find_device_index(p, INPUT_DEVICE_NAME, is_input=True) + output_device_index = find_device_index(p, TTS_OUTPUT_DEVICE_NAME, is_input=False) + + if input_device_index == -1: + print(f"❌ 入力デバイスが見つかりません: {INPUT_DEVICE_NAME}") + print("\n利用可能なデバイス:") + for i in range(p.get_device_count()): + info = p.get_device_info_by_index(i) + if info.get('maxInputChannels', 0) > 0: + print(f" [{i}] {info['name']}") + p.terminate() + return + + if output_device_index == -1: + print(f"❌ 出力デバイスが見つかりません: {TTS_OUTPUT_DEVICE_NAME}") + print("\n利用可能なデバイス:") + for i in range(p.get_device_count()): + info = p.get_device_info_by_index(i) + if info.get('maxOutputChannels', 0) > 0: + print(f" [{i}] {info['name']}") + p.terminate() + return + + print(f"✓ 入力デバイス: [{input_device_index}] {INPUT_DEVICE_NAME}") + print(f"✓ 出力デバイス: [{output_device_index}] {TTS_OUTPUT_DEVICE_NAME}") + + p.terminate() + + # アプリ起動 + app = GeminiLiveApp(mode, input_device_index, output_device_index) + + try: + asyncio.run(app.run()) + except KeyboardInterrupt: + print("\n⏹️ ユーザーによる中断") + + +if __name__ == "__main__": + main() diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..cba0d93 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,18 @@ +# Project Notes - 引継ぎ文書 + +## 現在の状況: audio2exp-service デプロイ(完了) + +### やったこと +1. audio2exp-service を修正し、再ビルド・再デプロイを実施 +2. `--memory 2Gi` ではメモリ不足で3回失敗 → `4Gi` に増やして完走 +3. デプロイ完走後のヘルスチェックで **NG** → 原因調査・対処 +4. ヘルスチェック **OK** 確認済み (status: healthy, engine_ready: True, device: cpu, mode: fallback) + +### 現在のステータス +- **デプロイ**: 完走済み(メモリ4Gi) +- **ヘルスチェック**: OK(解決済み) +- **次のアクション**: パイプライン全体の結合テスト(TTS → A2E → アバター描画) + +### ルール +- 推測で回答せず、必ず会話ログ・ファイル・記録を確認してから回答すること +- 確定していない中途半端な情報を書き出さないこと diff --git a/docs/DESIGN_REQUEST.md b/docs/DESIGN_REQUEST.md new file mode 100644 index 0000000..2a60cc1 --- /dev/null +++ b/docs/DESIGN_REQUEST.md @@ -0,0 +1,375 @@ +# プラットフォーム設計書 作成依頼 + +> **作成日**: 2026-02-26 +> **目的**: 別のAI(またはエンジニア)に対して、プラットフォーム設計書を一から作り直してもらうための指示書 +> **背景**: 前回AIが作成した設計書(`docs/PLATFORM_DESIGN.md`)は、コードの推測補完・未検証の設計判断が混在しており信頼性に問題がある。事実ベースで再設計が必要。 + +--- + +## 注意事項(設計担当者へ) + +1. **推測で設計するな。** 実際のコードを読んでから設計すること。特に gourmet-support / gourmet-sp は別リポジトリにあり、このリポジトリには含まれていない。パッチファイルのみ存在する。 +2. **未確認事項は「未確認」と明記せよ。** わからないことを埋めて書くな。 +3. **前回の設計書 `docs/PLATFORM_DESIGN.md` はたたき台としてのみ参照。** 内容をそのまま信頼しないこと。後述の信頼性評価を参照。 + +--- + +## 0. なぜプラットフォーム化するのか — 目的と直近のゴール + +### 0.1 背景 + +現在、3つの独立したコンポーネントが存在する: + +1. **gourmet-sp / gourmet-support** — グルメコンシェルジュ専用のWebアプリ(Astro + Flask)。REST API経由のテキスト対話 + TTS + 3Dアバター +2. **AI_Meeting_App/stt_stream.py** — デスクトップ専用の会議アシスタント。Gemini Live API によるリアルタイム音声対話。PyAudio前提 +3. **audio2exp-service** — 音声→表情変換マイクロサービス。Cloud Runデプロイ済み + +これらは**それぞれ別々に開発された**ため、以下の問題がある: +- グルメサポートは「グルメ」にハードコードされており、他の用途(カスタマーサポート、インタビュー等)に流用できない +- Live API(低遅延音声対話)はデスクトップ版にしか実装されておらず、Webアプリでは使えない +- 記憶機能(短期・長期)が別々の場所に別々の方式で実装されている(後述 §0.4) + +### 0.2 プラットフォーム化の目的 + +**「3Dアバター × AI対話」の共通基盤を作り、モード(用途)を差し替えるだけで異なるAIアプリを素早く立ち上げられるようにする。** + +具体的には: +- **共通基盤(Platform Core)**: セッション管理、LLM対話(REST + Live API)、TTS/STT、A2E連携、記憶管理、アバターレンダリング +- **モードプラグイン**: グルメコンシェルジュ、カスタマーサポート、インタビュー等。システムプロンプト・外部API・UI部品だけを差し替える + +### 0.3 直近のゴール(α版) + +| 優先度 | ゴール | 具体的な完了条件 | +|--------|--------|----------------| +| **1** | グルメコンシェルジュのα版を壊さず動かし続ける | 既存エンドポイント一切変更なし | +| **2** | Live API をWebプラットフォームに統合する | ブラウザからリアルタイム音声対話ができる | +| **3** | モード追加が容易な構造にする | 新モード追加時に変更するファイルが最小限 | +| **4** | 記憶機能を統一仕様にする | 短期記憶・長期記憶を共通サービスとして提供 | + +### 0.4 記憶機能の現状と統一仕様化の必要性 + +**LLMの弱点である記憶の問題を補完する2つのロジック**が、別々に開発されたため、別々の場所・方式で存在する。プラットフォーム化でこれらを**統一仕様**にすることが重要な要件。 + +#### 短期記憶(会話コンテキストの維持) + +**課題**: LLMはセッション内でもコンテキストウィンドウを超えると過去の会話を忘れる。特にLive APIはFLASH版(`gemini-2.5-flash-native-audio-preview`)を使用しており、累積トークン制限がある。 + +**現在の実装** (`AI_Meeting_App/stt_stream.py`): +- `conversation_history` — 直近20ターンのリスト保持(`_add_to_history()`: L490-495) +- `_get_context_summary()` — 再接続時に直近10ターンを要約(L940-966) +- `context_window_compression.sliding_window` — Gemini API側のスライディングウィンドウ設定(target_tokens: 32000)(L457-461) +- 累積800文字で自動再接続時にsystem_instructionに会話要約を注入(`_build_config(with_context=...)`: L410-438) + +**問題**: この短期記憶ロジックは `stt_stream.py` のPyAudioデスクトップアプリ内にベタ書きされており、Web版では使えない。 + +#### 長期記憶(ユーザーへのパーソナライゼーション) + +**課題**: ユーザーの好み・過去のやりとりをセッションを超えて記憶し、パーソナライズされた応対を行う。 + +**現在の実装** (`gourmet-support` リポジトリ): +- `long_term_memory.py` (~200行) — Firestore に長期記憶を永続化 +- `/api/session/start` — セッション開始時に長期記憶から挨拶文を生成(「前回はイタリアンを探してましたよね」等) +- `support_core.py` の対話フロー内で長期記憶更新(ユーザーの好み・過去のやりとり) +- フロントエンド (`concierge-controller.ts` L191, L480-489) で長期記憶対応済み挨拶を表示・保持 + +**問題**: この長期記憶ロジックは `gourmet-support` のグルメコンシェルジュ専用コードに埋め込まれており、他のモードでは使えない。また、データスキーマもグルメ固有(「好きな料理」「エリア」等)。 + +#### 統一仕様化の要件 + +| 項目 | 短期記憶 | 長期記憶 | +|------|---------|---------| +| **現在の所在** | `stt_stream.py` (デスクトップ) | `long_term_memory.py` (gourmet-support) | +| **ストレージ** | インメモリ (セッション内) | Firestore/Supabase (永続) | +| **データ構造** | `[{role, text}]` リスト | グルメ固有スキーマ(要確認) | +| **統一後のあるべき姿** | 共通 `SessionMemory` サービスとして、モード非依存で提供 | 共通 `LongTermMemory` サービスとして、モード別スキーマを拡張可能に | +| **プラットフォームでの要件** | Live API再接続時のコンテキスト引き継ぎをWebでも動くようにする | 任意のモードからユーザープロファイルを読み書きできるようにする | + +--- + +## 1. 現状の構成と課題 + +### 1.1 確定事実(コードで確認済み) + +#### サービス構成(3サービス) + +| サービス | デプロイ先 | ソースコード所在 | +|---------|-----------|----------------| +| **gourmet-sp** (フロントエンド) | Vercel | **別リポジトリ** (このリポにはパッチファイルのみ) | +| **gourmet-support** (バックエンド) | Cloud Run (us-central1) | **別リポジトリ** (このリポにはなし) | +| **audio2exp-service** | Cloud Run (us-central1) | `services/audio2exp-service/` | + +**重要**: gourmet-sp と gourmet-support の実ソースはこのリポジトリにない。設計書作成時にはそれらのリポジトリも必ず参照すること。 + +#### audio2exp-service(このリポジトリにある、確認済み) + +- `services/audio2exp-service/app.py` — Flask API (port 8081) +- `services/audio2exp-service/a2e_engine.py` — 推論エンジン +- エンドポイント: `POST /api/audio2expression`, `GET /health` +- パイプライン: 音声(base64) → Wav2Vec2(768dim) → A2Eデコーダー → 52次元ARKitブレンドシェイプ @30fps +- フォールバック: A2Eデコーダー未ロード時はエネルギーベースの近似生成 +- デプロイ状態: ヘルスチェックOK (status: healthy, engine_ready: True, device: cpu, mode: fallback) + +#### フロントエンドパッチ(このリポジトリにある、確認済み) + +`services/frontend-patches/` にあるファイル: +- `concierge-controller.ts` — A2E統合済みのコントローラー(gourmet-sp にパッチとして適用する前提) +- `vrm-expression-manager.ts` — 52次元ARKit → mouthOpenness変換 +- `LAMAvatar.astro` — LAMアバター統合コンポーネント(OpenAvatarChat連携、WebSocket通信) +- `FRONTEND_INTEGRATION.md` — 統合ガイド + +#### AI_Meeting_App(このリポジトリにある、確認済み) + +- `AI_Meeting_App/stt_stream.py` — デスクトップ専用のスタンドアロンアプリ +- Gemini Live API (`gemini-2.5-flash-native-audio-preview-12-2025`) を使用 +- PyAudio直接入出力(ブラウザ非対応) +- Google Cloud TTS (`TTSPlayer` クラス, ja-JP-Wavenet-D) +- 3モード: standard / silent / interview +- 自動再接続: 累積800文字で再接続、会話履歴20ターン保持 +- Voicemeeterデバイス連携(Windows環境前提) + +#### gourmet-support バックエンドの構成(パッチファイル・ドキュメントからの推定) + +以下は `docs/SYSTEM_ARCHITECTURE.md` と `services/frontend-patches/concierge-controller.ts` から推定した情報。**実コードは別リポジトリにあり、直接確認していない。** + +- `app_customer_support.py` — Flask + Socket.IO、APIエンドポイント提供 +- `support_core.py` — Gemini LLM 対話ロジック +- `api_integrations.py` — HotPepper API等 +- `long_term_memory.py` — 長期記憶(Firestore or Supabase) +- エンドポイント: `/api/session/start`, `/api/chat`, `/api/tts/synthesize`, `/api/stt/transcribe` 等 +- TTS + A2E 統合: `/api/tts/synthesize` でTTS合成後に audio2exp-service を呼び、音声+表情データを同梱返却 + +#### gourmet-sp フロントエンドの構成(パッチファイルからの推定) + +- Astro + TypeScript +- クラス階層: `CoreController` → `ConciergeController` / `ChatController` +- `AudioManager` — マイク入力 (48kHz→16kHz, Socket.IO streaming) +- GVRM — Gaussian Splatting 3Dアバターレンダラー +- リップシンク: FFTベース(デフォルト)、A2Eブレンドシェイプ(パッチ適用時) +- `window.lamAvatarController` を介した LAMAvatar 連携 + +### 1.2 現状の課題 + +1. **グルメサポート専用の密結合**: フロントエンド・バックエンドがグルメコンシェルジュ専用にハードコードされている +2. **Live API がプラットフォームに統合されていない**: `stt_stream.py` はデスクトップ専用のスタンドアロンアプリ。PyAudio前提でブラウザから使えない +3. **モード追加が困難**: 新モード(カスタマーサポート、インタビュー等)を追加するには、ページ・コントローラー・ルートをハードコードで追加する必要がある +4. **A2Eパッチが未適用**: `services/frontend-patches/` のファイルは作成済みだが、gourmet-sp への適用・結合テストが未実施 + +### 1.3 前回設計書の信頼性評価 + +前回の `docs/PLATFORM_DESIGN.md` の内容を、事実/推測で分類: + +| セクション | 信頼性 | 理由 | +|-----------|--------|------| +| 2. 現状のシステム構成 | **高** | 実コードのパッチファイル・ドキュメントと整合 | +| 3. プラットフォーム全体設計 (図) | **中** | 構想としては妥当だが、実装可能性の検証なし | +| 4. 共通基盤 vs モード固有の仕分け | **中** | 分類は合理的だが、実コードの依存関係を精査していない | +| 5. Live API 統合設計 | **中〜低** | stt_stream.py の読解は正確だが、Web移植の設計は未検証の構想 | +| 6. バックエンド設計 (ディレクトリ/クラス設計) | **低** | gourmet-support の実コードを読まずに設計している。依存関係の分解が実現可能か不明 | +| 7. フロントエンド設計 | **低** | gourmet-sp の実コードを読まずに設計している | +| 8. モード別仕様 | **中** | 要件定義としては参考になるが、voice設定等は推測 | +| 9. 開発ロードマップ | **低** | 工数・難易度の見積もりなし。実現可能性が未検証 | +| 10. 移行戦略 | **中** | 方針は妥当だが、実装詳細は未検証 | + +--- + +## 2. 新しいプラットフォーム化の要件・要望 + +### 2.1 オーナーの最上位ゴール + +**論文超えクオリティの3D対話アバターを、バックエンドGPUなしで、iPhone SE単体で軽く動かす。即実用のアルファ版。** + +| # | 要件 | 詳細 | +|---|------|------| +| 1 | **論文超えの自然さ** | 口元だけでなく、表情・頭の動き・セリフとの連動が自然。低遅延 | +| 2 | **スマホ単体完結** | バックエンドGPU一切不要。推論もレンダリングも全てオンデバイス | +| 3 | **iPhone SEで軽く動く** | 最も制約の厳しいデバイスが動作基準 | +| 4 | **技術スタックに固執しない** | 動くものを即テスト→見極め→次へ。理論より実証 | + +### 2.2 プラットフォーム化の要件 + +1. **マルチモード対応**: 単一基盤で複数のAIアプリケーション(グルメコンシェルジュ、カスタマーサポート、インタビュー等)を運用できること +2. **Live API 統合**: Gemini Live API(ネイティブオーディオ)をWebプラットフォームの標準機能として組み込む。現在 `stt_stream.py` にあるデスクトップ版の機能をWeb版に移植する +3. **既存サービス温存**: α版テスト中のグルメサポートAI(gourmet-sp + gourmet-support)を中断しない。既存エンドポイントは一切変更しない +4. **段階的移行**: 既存と新プラットフォームを並行稼働させ、段階的に移行する +5. **モード追加の容易さ**: 新モード追加時にハードコードの変更を最小限にする。プラグイン的なアーキテクチャ + +### 2.3 Live API 導入の理由と背景 + +#### なぜ Live API を導入するのか + +既存の gourmet-support は **REST API ベースのテキスト対話**(ユーザー入力→LLM応答→TTS読み上げ→アバター口パク)であり、以下の体験上の問題がある: + +1. **往復レイテンシ**: ユーザーが話す → STTで文字起こし → REST APIでLLM応答 → TTSで合成 → 再生。**5〜10秒のラグ**が発生 +2. **割り込み不可**: LLMが回答中にユーザーが割り込めない(従来の「話す→待つ→聞く」の交互方式) +3. **相槌なし**: 「へぇ」「なるほど」等のリアルタイムな応答ができない + +**Gemini Live API** は音声→音声のストリーミング対話を提供し、上記を根本的に解決する: +- **超低遅延**: 音声入力→音声出力が数百ms +- **割り込み対応**: ユーザーの発話を検知してAIが自動的に応答を中断 +- **ネイティブ音声生成**: TTS不要、AIが直接音声を生成 + +#### FLASH版の制約と累積文字数制限の回避ロジック + +**重要な留意点**: 現在使用しているLive APIモデルは**FLASH版(`gemini-2.5-flash-native-audio-preview-12-2025`)**であり、以下の制約がある: + +1. **累積トークン制限**: FLASH版にはセッション内の累積入出力トークンに制限がある。長時間対話するとAPIエラー(1011/1008)が発生しセッションが切断される +2. **音声出力の途切れ**: 累積制限に近づくとAIの発話が途中で切れる現象が発生する + +**この制約への回避ロジック** が `stt_stream.py` の `GeminiLiveApp` クラスに実装済み: + +``` +[回避ロジックの全体像] + +AI発話のたびに文字数を累積カウント (ai_char_count) + │ + ├── 累積800文字超過 → 再接続フラグ ON + │ (MAX_AI_CHARS_BEFORE_RECONNECT = 800) + │ + ├── 1回の発話が500文字超 → 次のターン前に再接続 + │ (LONG_SPEECH_THRESHOLD = 500) + │ + ├── 発話が途中で切れた → 即時再接続 + │ (_is_speech_incomplete(): 「が」「で」「を」等で終わる) + │ + └── API側エラー (1011/1008) → 3秒後に自動再接続 + +再接続時の処理: + 1. conversation_history から直近10ターンを取得 + 2. _get_context_summary() で要約を生成 + 3. 新セッションの system_instruction に要約を注入 + 4. 「続きをお願いします」を送信して再開 + 5. ai_char_count をリセット +``` + +**コード上の対応箇所** (`AI_Meeting_App/stt_stream.py`): +- L372: `MAX_AI_CHARS_BEFORE_RECONNECT = 800` — 累積文字数閾値 +- L373: `LONG_SPEECH_THRESHOLD = 500` — 長文発話閾値 +- L392-394: `ai_char_count`, `needs_reconnect`, `session_count` — 再接続管理変数 +- L410-468: `_build_config(with_context=)` — 再接続時のコンテキスト注入 + `context_window_compression.sliding_window` +- L501-529: `_is_speech_incomplete()` — 日本語の文末パターンで発話途切れ検知 +- L624-643: 累積カウント+再接続判定ロジック +- L714-796: `run()` — メインの再接続ループ +- L940-966: `_get_context_summary()` — 会話履歴要約生成 + +**プラットフォーム化での要件**: この回避ロジック全体をWeb版でも動作するように移植する。将来FLASH版の制限が緩和されても、安全策として保持する設計にすること。 + +#### Live API + REST API ハイブリッド方式 + +`stt_stream.py` は **Live API と REST API を使い分けるハイブリッド方式** を採用している: + +- **Live API**: 短い応答(相槌、確認、質問)→ 低遅延、ネイティブ音声 +- **REST API + TTS**: 長い応答(要約、検索結果、資料説明)→ 正確な長文生成 + Google Cloud TTS (Wavenet) + +この切り替えは `RestAPIHandler` クラス(L307-362)と Function Calling で実現。REST API側は `gemini-2.5-flash`(非Live版、通常のテキストAPI)を使用。 + +### 2.3.1 Live API 統合の要件(stt_stream.py から移植すべき機能) + +`AI_Meeting_App/stt_stream.py` に実装済みの以下の機能をWeb版に移植する: + +| 機能 | stt_stream.py での実装箇所 | 備考 | +|------|---------------------------|------| +| Live API 接続・音声送受信 | `GeminiLiveApp.run()` (L714-796) | PyAudio→WebSocket経由に変更が必要 | +| **累積文字数制限の回避** | `MAX_AI_CHARS_BEFORE_RECONNECT` (L372), 再接続ループ (L714-796) | **FLASH版の最重要制約。必ず移植** | +| 自動再接続(累積800文字) | `ai_char_count` (L624-643) | Geminiのコンテキストウィンドウ制限への対処 | +| **コンテキスト引き継ぎ(短期記憶)** | `_get_context_summary()` (L940-966), `_build_config(with_context=)` (L410-438) | **再接続時に会話の文脈を失わないためのロジック** | +| 発話途切れ検知 | `_is_speech_incomplete()` (L501-529) | 文末の「が」「で」「けど」等を検出 | +| REST API ハイブリッド | `RestAPIHandler` (L307-362) | 長文はREST API + TTS、短文はLive API | +| 会話履歴管理 | `conversation_history` (L389, L490-499) | 直近20ターン保持、再接続時のコンテキスト源 | +| **スライディングウィンドウ圧縮** | `context_window_compression` (L457-461) | Gemini API側のコンテキスト圧縮設定 | +| モード別システムプロンプト | `_build_system_instruction()` (L471-488) | standard/silent/interview で切替 | +| スクリプト進行管理 | `_get_next_question_from_script()` (L531-577) | interview モード用 | +| 議事録保存 | `log_transcript()` (L203-207) | Markdown形式 | + +### 2.4 対話方式の要件 + +| モード | Live API(低遅延対話) | REST API(長文生成) | +|--------|----------------------|---------------------| +| グルメコンシェルジュ | 好みヒアリング、相槌、確認 | ショップカード説明、詳細レビュー | +| カスタマーサポート | 状況ヒアリング、共感、確認 | FAQ回答、手順説明 | +| インタビュー | 質問、相槌、進行(メイン) | 資料参照の長文説明時のみ | + +### 2.5 技術的制約 + +- **A2E推論はサーバー側(CPUで動く)**: Wav2Vec2 (95Mパラメータ) はサーバーで推論。結果の52次元係数(~10KB/sec)をクライアントに送る +- **レンダリングはクライアント側**: LAM WebGL SDK (Gaussian Splatting) または Three.js + GLBメッシュ +- **iPhone SEが動作基準**: A13/A15チップ、3-4GB RAM。81,424 Gaussianが30FPSで回るかは**未検証**(最重要の技術リスク) +- **gourmet-sp / gourmet-support は別リポジトリ**: プラットフォーム化の際、既存コードの改修範囲を正確に把握するには両リポジトリの精査が必要 + +--- + +## 3. 参考にすべきリポジトリ・リソース + +### 3.1 このリポジトリ内の参考ファイル + +| ファイル | 内容 | 信頼性 | +|---------|------|--------| +| `AI_Meeting_App/stt_stream.py` | Live API 実装の実コード(デスクトップ版) | **高** — 実動作するコード | +| `services/audio2exp-service/` | A2Eマイクロサービス一式 | **高** — デプロイ済み・ヘルスチェックOK | +| `services/frontend-patches/` | フロントエンドパッチ(A2E統合) | **高** — 実コード。ただし未適用・未テスト | +| `docs/SYSTEM_ARCHITECTURE.md` | 現状システムの全体設計書 | **中** — 構成は正確だが、別リポジトリの内容はドキュメントベース | +| `docs/SESSION_HANDOFF.md` | 引き継ぎドキュメント | **中** — 経緯と判断の記録として有用 | +| `docs/PLATFORM_DESIGN.md` | 前回のプラットフォーム設計書 | **低〜中** — **たたき台としてのみ参照**。推測部分あり | +| `tests/a2e_japanese/` | A2E日本語テストスイート | **高** — 実コード。ただし未実行 | + +### 3.2 外部リポジトリ(設計時に必ず参照すべき) + +| リポジトリ | URL | 参照すべき内容 | +|-----------|-----|---------------| +| **LAM公式** | https://github.com/aigc3d/LAM | アバター生成パイプライン、FLAME モデル、論文の実装 | +| **LAM_Audio2Expression** | https://github.com/aigc3d/LAM_Audio2Expression | A2Eモデルのアーキテクチャ、推論コード | +| **LAM_WebRender** | https://github.com/aigc3d/LAM_WebRender | WebGL SDK の API、npmパッケージ `gaussian-splat-renderer-for-lam` | +| **OpenAvatarChat** | https://github.com/HumanAIGC-Engineering/OpenAvatarChat | LLM + ASR + TTS + Avatar 対話SDK。統合の参考アーキテクチャ | +| **gourmet-sp** | (オーナーに確認) | フロントエンド実コード。Astro + TypeScript | +| **gourmet-support** | (オーナーに確認) | バックエンド実コード。Flask + Socket.IO | + +### 3.3 論文・技術資料 + +| 資料 | URL | 参照すべき内容 | +|------|-----|---------------| +| LAM論文 | https://arxiv.org/abs/2502.17796 | SIGGRAPH 2025。アバター生成・アニメーション・レンダリングの技術詳細 | +| PanoLAM論文 | https://arxiv.org/abs/2509.07552 | LAMの拡張。coarse-to-fine、合成データ訓練 | +| LAMプロジェクトページ | https://aigc3d.github.io/projects/LAM/ | デモ、ベンチマーク (iPhone 16で35FPS等) | +| ModelScope Space | https://www.modelscope.cn/studios/Damo_XR_Lab/LAM_Large_Avatar_Model | アバターZIP生成(実際に生成可能) | + +### 3.4 参考OSS(アーキテクチャの参考) + +| プロジェクト | URL | 参考になる点 | +|------------|-----|-------------| +| **TalkingHead** | https://github.com/met4citizen/TalkingHead | ブラウザで動く対話アバター。Three.js + ブレンドシェイプ。iPhone SEでも動く軽量アプローチ | +| **NVIDIA Audio2Face-3D** | https://huggingface.co/nvidia/Audio2Face-3D-v2.3-Mark | NVIDIA の A2E モデル。品質の参考 | + +--- + +## 4. 設計書に含めるべき内容 + +### 必須セクション + +1. **現状分析**: 各リポジトリの実コードを読んだ上での正確な現状把握 +2. **アーキテクチャ設計**: マルチモード対応のバックエンド・フロントエンド設計 +3. **Live API 統合設計**: stt_stream.py の機能をWeb版に移植する具体設計。特に**FLASH版の累積文字数制限の回避ロジック**(§2.3参照)の移植方法を明示すること +4. **記憶機能の統一設計**: 短期記憶(セッション内コンテキスト)と長期記憶(ユーザーパーソナライゼーション)を**モード非依存の共通サービス**として設計(§0.4参照)。現在は別々の場所に別々の方式で実装されている問題を解決する +5. **既存サービスとの共存戦略**: α版を壊さずに新プラットフォームを構築する方法 +6. **データフロー**: 音声入力→STT→LLM→TTS→A2E→アバターレンダリングの全体フロー(Live API経路とREST API経路の両方を明示) +7. **API設計**: 新エンドポイントの仕様 +8. **iPhone SE対応戦略**: レンダリング方式の選択(LAM WebGL vs Three.js vs ハイブリッド)と判断基準 +9. **開発ロードマップ**: フェーズ分け、各フェーズの成果物と検証基準 + +### 各セクションで守るべきルール + +- **「確認済み」と「未確認・推定」を必ず区別** して記載すること +- 実コードを読んでいないモジュールの内部設計は「要確認」と書くこと +- 設計判断には**根拠(なぜその選択か)**を必ず付けること +- 工数・スケジュールの見積もりは、根拠がなければ「見積もり不可」と書くこと + +--- + +## 5. 前回の設計書で参考にしてよい部分 + +以下は前回の `docs/PLATFORM_DESIGN.md` で、方向性として妥当と判断できる部分: + +- **マルチモード・プラグインアーキテクチャの基本方針** (セクション3, 4): モード固有ロジックを分離する方針自体は妥当 +- **Live API の Live/REST ハイブリッド方式** (セクション5.4): stt_stream.py の実装に基づいており合理的 +- **既存エンドポイント温存方針** (セクション10): α版を壊さない方針は正しい +- **stt_stream.py からの移植対象一覧** (セクション2.3の表): 実コードの読解に基づいており正確 + +**ただし、これらも実コードとの突合なしに信頼しないこと。** diff --git a/docs/PLATFORM_ARCHITECTURE.md b/docs/PLATFORM_ARCHITECTURE.md new file mode 100644 index 0000000..312a1f4 --- /dev/null +++ b/docs/PLATFORM_ARCHITECTURE.md @@ -0,0 +1,1387 @@ +# プラットフォーム設計書 + +> **文書ID**: ARCH-PLATFORM-001 +> **作成日**: 2026-02-26 +> **ステータス**: Draft +> **根拠文書**: `docs/DESIGN_REQUEST.md`, `docs/PLATFORM_REQUIREMENTS.md` +> **前提**: 本文書は `LAM_gpro` リポジトリ内の実コードを読解した上で作成。gourmet-sp / gourmet-support は別リポジトリのため、パッチファイルとドキュメントからの推定箇所は明記。 + +--- + +## 目次 + +1. [設計方針](#1-設計方針) +2. [全体アーキテクチャ](#2-全体アーキテクチャ) +3. [データフロー](#3-データフロー) +4. [バックエンド設計](#4-バックエンド設計) +5. [Live API 統合設計](#5-live-api-統合設計) +6. [記憶機能の統一設計](#6-記憶機能の統一設計) +7. [多言語対応設計](#7-多言語対応設計) +8. [フロントエンド設計](#8-フロントエンド設計) +9. [API設計](#9-api設計) +10. [既存サービスとの共存戦略](#10-既存サービスとの共存戦略) +11. [iPhone SE 対応戦略](#11-iphone-se-対応戦略) +12. [開発ロードマップ](#12-開発ロードマップ) + +--- + +## 1. 設計方針 + +### 1.1 基本原則 + +| # | 原則 | 根拠 | +|---|------|------| +| P1 | **既存を壊さない** | α版テスト中のグルメサポートAIを中断しない(DESIGN_REQUEST §0.3 優先度1) | +| P2 | **事実ベースで設計する** | 推測部分は「未確認」と明記。gourmet-sp/gourmet-support の内部構造は推定として扱う | +| P3 | **最小限の抽象化** | 動くものを作ってから抽象化する。過剰設計を避ける | +| P4 | **段階的移行** | 既存と新プラットフォームを並行稼働させ、検証しながら段階的に移行する | + +### 1.2 確認済み / 未確認の分類基準 + +本文書では以下の表記を使用する: + +- **[確認済み]** — このリポジトリ内の実コードを読解して確認した事実 +- **[推定]** — パッチファイル・ドキュメントから推定した内容(gourmet-sp/gourmet-support の内部構造等) +- **[設計判断]** — 本設計書で新たに行う設計上の判断。根拠を併記 +- **[未検証]** — 技術的実現可能性が未検証の項目 + +--- + +## 2. 全体アーキテクチャ + +### 2.1 現状構成(確認済み) + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ 現状の3コンポーネント │ +├─────────────────┬──────────────────┬────────────────────────────┤ +│ gourmet-sp │ gourmet-support │ audio2exp-service │ +│ (フロントエンド)│ (バックエンド) │ (A2Eマイクロサービス) │ +│ Astro+TS │ Flask+Socket.IO │ Flask │ +│ Vercel │ Cloud Run │ Cloud Run │ +│ [別リポジトリ] │ [別リポジトリ] │ [確認済み] │ +├─────────────────┴──────────────────┴────────────────────────────┤ +│ AI_Meeting_App/stt_stream.py │ +│ (デスクトップ専用 Live API アプリ — Web統合なし) [確認済み] │ +└─────────────────────────────────────────────────────────────────┘ +``` + +### 2.2 プラットフォーム化後の構成(設計判断) + +``` +┌──────────────────────────────────────────────────────────────────────┐ +│ ブラウザ │ +│ ┌──────────────────────────────────────────────────────────────┐ │ +│ │ Platform Frontend (Astro + TypeScript) │ │ +│ │ ┌──────────────┐ ┌───────────────┐ ┌──────────────────┐ │ │ +│ │ │ ModeRouter │ │ AvatarRenderer│ │ AudioIO │ │ │ +│ │ │ (モード切替) │ │ (3D描画) │ │ (WebAudio入出力) │ │ │ +│ │ └──────┬───────┘ └───────┬───────┘ └──────┬───────────┘ │ │ +│ │ │ │ │ │ │ +│ │ ┌──────┴──────────────────┴──────────────────┴──────────┐ │ │ +│ │ │ DialogueManager │ │ │ +│ │ │ (REST対話 / Live API対話 の共通インターフェース) │ │ │ +│ │ └──────────────────────┬─────────────────────────────────┘ │ │ +│ └─────────────────────────┼────────────────────────────────────┘ │ +│ │ WebSocket / REST │ +└────────────────────────────┼─────────────────────────────────────────┘ + │ +┌────────────────────────────┼─────────────────────────────────────────┐ +│ Platform Backend (Python) │ +│ ┌─────────────────────────┴────────────────────────────────────┐ │ +│ │ Gateway Layer │ │ +│ │ /api/v2/* (新プラットフォーム) │ │ +│ │ /api/* (既存 gourmet-support 互換 — Phase 1ではプロキシ) │ │ +│ └──────┬──────────────┬──────────────┬────────────────────┬────┘ │ +│ │ │ │ │ │ +│ ┌──────┴──────┐ ┌─────┴──────┐ ┌────┴─────┐ ┌──────────┴──────┐ │ +│ │ SessionMgr │ │ LiveRelay │ │ MemoryMgr│ │ ModeRegistry │ │ +│ │ (セッション) │ │ (Live API │ │ (短期+ │ │ (モード │ │ +│ │ │ │ WS中継) │ │ 長期記憶)│ │ プラグイン) │ │ +│ └─────────────┘ └────────────┘ └──────────┘ └─────────────────┘ │ +│ │ +│ ┌──────────────────────────────────────────────────────────────┐ │ +│ │ Service Connectors │ │ +│ │ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌────────────┐ │ │ +│ │ │ Gemini │ │ GCP TTS │ │ A2E │ │ External │ │ │ +│ │ │ REST/Live│ │ │ │ Client │ │ APIs │ │ │ +│ │ └──────────┘ └──────────┘ └──────────┘ └────────────┘ │ │ +│ └──────────────────────────────────────────────────────────────┘ │ +└──────────────────────────────────────────────────────────────────────┘ + │ + ┌────────────┴────────────┐ + │ audio2exp-service │ + │ Cloud Run (既存) │ + │ [確認済み・変更なし] │ + └─────────────────────────┘ +``` + +**設計判断の根拠**: + +1. **Gateway Layer で新旧エンドポイントを共存** — 既存の `/api/*` を温存しつつ、新プラットフォームは `/api/v2/*` で提供。Phase 1 では既存バックエンドへのプロキシも可能 +2. **LiveRelay をサーバーサイドに配置** — Gemini API キーをクライアントに露出しないため(TC-06)。ブラウザ ↔ サーバー は WebSocket、サーバー ↔ Gemini は Live API WebSocket +3. **ModeRegistry でプラグイン管理** — モード追加時は設定 + プラグインモジュールの追加のみ +4. **audio2exp-service は変更しない** — 既にデプロイ済みで動作確認済み。A2E Client 経由で呼び出す + +--- + +## 3. データフロー + +### 3.1 REST API 経路(既存方式の拡張) + +``` +[ユーザー発話] + │ + ▼ (1) ブラウザ AudioIO: WebAudio → PCM 16kHz + │ + ▼ (2) Socket.IO streaming → バックエンド STT + │ + ▼ (3) テキスト → Gemini REST API (gemini-2.5-flash) + │ + ▼ (4) LLM応答テキスト + │ + ├──▶ (5a) GCP TTS → 音声(MP3 base64) + │ │ + │ ├──▶ (5b) audio2exp-service → 52次元 ARKit @30fps + │ │ + │ └──▶ (5c) 音声 + Expression を同梱してクライアントに返却 + │ + ▼ (6) ブラウザ: 音声再生 + Expression フレーム同期 → アバター描画 + + レイテンシ見積: (2)~500ms + (3)~2s + (5a)~1s + (5b)~2s = 約5-6秒 + [確認済み: この経路は gourmet-support で実装済み、パッチファイルで確認] +``` + +### 3.2 Live API 経路(新規) + +``` +[ユーザー発話] + │ + ▼ (1) ブラウザ AudioIO: WebAudio → PCM 16kHz + │ + ▼ (2) WebSocket → バックエンド LiveRelay + │ + ▼ (3) LiveRelay → Gemini Live API (gemini-2.5-flash-native-audio-preview) + │ │ + │ ├── 入力: PCM 16kHz (リアルタイムストリーミング) + │ ├── 出力: PCM 24kHz (AI音声) + transcription + │ └── VAD: Gemini側で発話区間検出 + │ + ▼ (4) AI音声(PCM 24kHz) → クライアントに中継 + │ │ + │ └──▶ (4b) 並行: AI音声 → audio2exp-service → Expression + │ [設計判断: チャンク単位で非同期送信] + │ + ▼ (5) ブラウザ: PCM再生 + Expression フレーム同期 → アバター描画 + + レイテンシ見積: (2)~50ms + (3)~300ms + (4)~50ms = 約400ms + ※ A2Eは並行処理のため音声再生を遅延させない + [未検証: WebSocket中継のレイテンシは実測が必要] +``` + +### 3.3 Live API + A2E 同期の詳細設計 + +Live API 経路では、音声出力とA2E推論を並行して行う必要がある: + +``` +時間軸 → + +LiveRelay: + AI音声チャンク受信 ──┬── チャンク1(1秒分PCM) ── チャンク2 ── チャンク3 ── + │ +クライアント送信: ├── 音声チャンク1送信 ──── 音声チャンク2 ──── ... + │ (即時、遅延なし) + │ +A2Eリクエスト: └── A2E(チャンク1) ──── A2E(チャンク2) ──── ... + │ │ + ▼ ▼ +Expression送信: Exp1をWS送信 ──── Exp2をWS送信 ──── ... +``` + +**設計判断**: A2E推論(~2秒/文)は音声再生と並行で行い、Expression はできた分からクライアントに送信する。音声チャンクは即時転送し、Expression は少し遅れて到着する。クライアント側でフレームバッファリングして再生時刻に同期する。 + +**根拠**: +- [確認済み] `LAMAvatar.astro` L581-637 にフレームバッファ + `ttsPlayer.currentTime` 同期の仕組みが既に実装されている +- [確認済み] `concierge-controller.ts` L392-465 で Expression をフレームバッファに投入する `applyExpressionFromTts()` が実装済み + +--- + +## 4. バックエンド設計 + +### 4.1 ディレクトリ構成 + +``` +platform/ +├── server.py # エントリーポイント (FastAPI or Flask) +├── config/ +│ ├── __init__.py +│ └── settings.py # 環境変数・設定 +│ +├── gateway/ +│ ├── __init__.py +│ ├── router.py # /api/v2/* ルーティング +│ └── legacy_proxy.py # /api/* 既存互換プロキシ (Phase 1) +│ +├── session/ +│ ├── __init__.py +│ └── manager.py # セッション管理 (SessionManager) +│ +├── live/ +│ ├── __init__.py +│ ├── relay.py # Live API WebSocket中継 (LiveRelay) +│ ├── reconnect.py # 累積制限回避・自動再接続ロジック +│ └── speech_detector.py # 発話途切れ検知 +│ +├── memory/ +│ ├── __init__.py +│ ├── session_memory.py # 短期記憶 (SessionMemory) +│ └── long_term_memory.py # 長期記憶 (LongTermMemory) +│ +├── dialogue/ +│ ├── __init__.py +│ ├── rest_dialogue.py # REST API対話 +│ └── hybrid_dialogue.py # Live/REST ハイブリッド切替 +│ +├── i18n/ +│ ├── __init__.py +│ ├── language_config.py # 言語マスター (LANGUAGE_CODE_MAP 相当) +│ ├── speech_rules.py # 言語別文分割・途切れ検知ルール +│ └── translations/ # UI翻訳ファイル +│ ├── ja.json +│ ├── en.json +│ ├── ko.json +│ └── zh.json +│ +├── services/ +│ ├── __init__.py +│ ├── gemini_client.py # Gemini REST/Live API クライアント +│ ├── tts_client.py # Google Cloud TTS クライアント +│ └── a2e_client.py # audio2exp-service クライアント +│ +└── modes/ + ├── __init__.py + ├── registry.py # ModeRegistry (モードプラグイン管理) + ├── base_mode.py # BaseModePlugin (基底クラス) + ├── gourmet/ + │ ├── __init__.py + │ └── plugin.py # グルメコンシェルジュモード + ├── support/ + │ ├── __init__.py + │ └── plugin.py # カスタマーサポートモード + └── interview/ + ├── __init__.py + └── plugin.py # インタビューモード +``` + +### 4.2 コア設計: SessionManager + +**責務**: セッションのライフサイクル管理。モードの割り当て、短期記憶の保持、接続状態の追跡。 + +```python +# platform/session/manager.py + +class Session: + """1つの対話セッションを表現""" + session_id: str + user_id: str | None + mode: str # "gourmet", "support", "interview" + dialogue_type: str # "rest", "live", "hybrid" + created_at: datetime + memory: SessionMemory # 短期記憶 + live_state: LiveSessionState | None # Live API 接続状態 + +class SessionManager: + """セッション管理""" + _sessions: dict[str, Session] # session_id → Session + + def create_session(self, mode: str, user_id: str = None) -> Session: ... + def get_session(self, session_id: str) -> Session | None: ... + def end_session(self, session_id: str) -> None: ... +``` + +### 4.3 コア設計: ModeRegistry + +**責務**: モードプラグインの登録・取得。新モード追加時はプラグインファイルと設定の追加のみ。 + +```python +# platform/modes/registry.py + +class ModeRegistry: + """モードプラグインのレジストリ""" + _modes: dict[str, BaseModePlugin] + + def register(self, name: str, plugin: BaseModePlugin) -> None: ... + def get(self, name: str) -> BaseModePlugin: ... + def list_modes(self) -> list[str]: ... + +# platform/modes/base_mode.py + +class BaseModePlugin: + """モードプラグインの基底クラス""" + name: str + display_name: str + dialogue_type: str # "rest" | "live" | "hybrid" + + def get_system_prompt(self, context: dict = None) -> str: ... + def get_live_api_config(self) -> dict: ... + def get_tools(self) -> list: ... # Function Calling 定義 + def get_memory_schema(self) -> dict: ... # 長期記憶のモード別スキーマ + def on_session_start(self, session: Session) -> str | None: ... # 初回挨拶 + def on_session_end(self, session: Session) -> None: ... +``` + +**設計判断の根拠**: +- [確認済み] `stt_stream.py` L471-488 で `_build_system_instruction()` がモード別プロンプトを切り替えている。これをプラグインに抽出 +- [確認済み] `stt_stream.py` L440-468 で `_build_config()` がモード別 Live API 設定を構築している。これもプラグインに抽出 +- [推定] gourmet-support の `support_core.py` にグルメ固有の対話ロジックがあるが、内部構造は未確認 + +### 4.4 コア設計: A2E Client + +**責務**: audio2exp-service への HTTP リクエスト。REST 経路と Live API 経路の両方で使用。 + +```python +# platform/services/a2e_client.py + +class A2EClient: + """audio2exp-service クライアント""" + + def __init__(self, base_url: str = None): + # [確認済み] エンドポイント: POST /api/audio2expression + self.base_url = base_url or os.getenv("A2E_SERVICE_URL") + + async def process_audio( + self, + audio_base64: str, + session_id: str, + audio_format: str = "mp3" + ) -> dict: + """ + 音声 → 52次元 ARKit ブレンドシェイプ + + Returns: + { + "names": list[str], # 52個のARKit名 + "frames": list[list[float]], # N×52 + "frame_rate": 30 + } + + [確認済み] a2e_engine.py L381-401: process() の入出力仕様 + """ + ... + + async def health_check(self) -> dict: + """[確認済み] GET /health""" + ... +``` + +### 4.5 Web フレームワーク選定 + +**設計判断**: **FastAPI** を採用する。 + +**根拠**: +- Live API の WebSocket 中継には非同期 I/O が必須。FastAPI は ASGI ベースで WebSocket をネイティブサポート +- [確認済み] `stt_stream.py` は `asyncio` ベースで書かれており、FastAPI への移植が自然 +- [推定] 既存の gourmet-support は Flask(同期)だが、新プラットフォームは別プロセスで稼働させるため、フレームワークの不一致は問題にならない +- Flask + Socket.IO で WebSocket を扱うことも可能だが、Live API の非同期ストリーミングには asyncio ネイティブの方が扱いやすい + +--- + +## 5. Live API 統合設計 + +### 5.1 移植対象(stt_stream.py からの機能抽出) + +[確認済み] `stt_stream.py` から移植すべき機能とその対応先: + +| stt_stream.py の機能 | コード箇所 | 移植先モジュール | 移植の難易度 | +|---------------------|-----------|----------------|------------| +| Live API 接続 | `run()` L714-796 | `live/relay.py` | 中(PyAudio→WebSocket) | +| 累積文字数制限回避 | L372-373, L624-643 | `live/reconnect.py` | 低(ロジックそのまま) | +| 自動再接続ループ | `run()` L741-796 | `live/relay.py` | 低(ロジックそのまま) | +| コンテキスト引き継ぎ | `_get_context_summary()` L940-966 | `memory/session_memory.py` | 低(ロジックそのまま) | +| 再接続時config構築 | `_build_config()` L410-468 | `live/relay.py` + `modes/` | 低 | +| 発話途切れ検知 | `_is_speech_incomplete()` L501-529 | `live/speech_detector.py` | 低(ロジックそのまま) | +| 音声送受信 | `listen_audio()`, `send_audio()`, `receive_audio()`, `play_audio()` | `live/relay.py` | 高(PyAudio→WebSocket中継) | +| 割り込み処理 | L650-662 | `live/relay.py` | 中 | +| VAD設定 | L448-456 | `live/relay.py` config | 低 | +| REST API ハイブリッド | `RestAPIHandler` L307-362 | `dialogue/hybrid_dialogue.py` | 中 | +| 会話履歴管理 | L389, L490-499 | `memory/session_memory.py` | 低 | +| スライディングウィンドウ | L457-461 | `live/relay.py` config | 低(設定値のみ) | + +### 5.2 LiveRelay 設計 + +**責務**: ブラウザとGemini Live APIの間でWebSocket中継を行う。累積文字数制限の回避、自動再接続、コンテキスト引き継ぎを管理。 + +```python +# platform/live/relay.py + +class LiveRelay: + """Gemini Live API WebSocket 中継""" + + def __init__(self, session: Session, mode_plugin: BaseModePlugin): + self.session = session + self.mode_plugin = mode_plugin + self.reconnect_mgr = ReconnectManager( + max_chars=800, # [確認済み] L372 + long_speech_threshold=500 # [確認済み] L373 + ) + + async def handle_client_ws(self, websocket: WebSocket): + """ + クライアント WebSocket ハンドラ + + プロトコル: + クライアント → サーバー: + { "type": "audio", "data": "" } + { "type": "text", "data": "テキスト入力" } + + サーバー → クライアント: + { "type": "audio", "data": "" } + { "type": "transcription", "role": "user"|"ai", "text": "..." } + { "type": "interrupted" } + { "type": "reconnecting", "reason": "char_limit"|"incomplete"|"error" } + { "type": "expression", "data": { names, frames, frame_rate } } + """ + while True: + try: + await self._run_session(websocket) + if not self.reconnect_mgr.needs_reconnect: + break + except Exception as e: + if self.reconnect_mgr.is_retriable_error(e): + await asyncio.sleep(3) # [確認済み] L791 + continue + raise + + async def _run_session(self, client_ws: WebSocket): + """1つのGeminiセッションを実行""" + # コンテキスト引き継ぎ + context = None + if self.reconnect_mgr.session_count > 0: + context = self.session.memory.get_context_summary() + + config = self._build_live_config(context) + self.reconnect_mgr.reset_for_new_session() + + async with gemini_client.live.connect( + model="gemini-2.5-flash-native-audio-preview", + config=config + ) as gemini_session: + + if self.reconnect_mgr.session_count > 0: + # [確認済み] L766-776: 再接続時に「続きをお願いします」送信 + await gemini_session.send_client_content( + turns=Content(role="user", parts=[Part(text="続きをお願いします")]), + turn_complete=True + ) + + # 3つの非同期タスクを並行実行 + # [確認済み] L908-930: asyncio.TaskGroup で並行処理 + async with asyncio.TaskGroup() as tg: + tg.create_task(self._relay_client_to_gemini(client_ws, gemini_session)) + tg.create_task(self._relay_gemini_to_client(gemini_session, client_ws)) + tg.create_task(self._process_a2e(client_ws)) +``` + +### 5.3 ReconnectManager 設計 + +```python +# platform/live/reconnect.py + +class ReconnectManager: + """累積文字数制限の回避ロジック""" + + # [確認済み] stt_stream.py L372-373 + def __init__(self, max_chars: int = 800, long_speech_threshold: int = 500): + self.max_chars = max_chars + self.long_speech_threshold = long_speech_threshold + self.ai_char_count = 0 + self.needs_reconnect = False + self.session_count = 0 + + def on_ai_speech_complete(self, text: str) -> None: + """ + AI発話完了時に呼び出す。再接続判定を行う。 + + [確認済み] stt_stream.py L624-643 のロジックを移植: + 1. 累積文字数を加算 + 2. 発話途切れ → 即時再接続 + 3. 長文発話(500文字超) → 次ターン前に再接続 + 4. 累積800文字超 → 再接続 + """ + char_count = len(text) + self.ai_char_count += char_count + + if SpeechDetector.is_incomplete(text): + self.needs_reconnect = True + self.reconnect_reason = "incomplete" + elif char_count >= self.long_speech_threshold: + self.needs_reconnect = True + self.reconnect_reason = "long_speech" + elif self.ai_char_count >= self.max_chars: + self.needs_reconnect = True + self.reconnect_reason = "char_limit" + + def reset_for_new_session(self) -> None: + self.ai_char_count = 0 + self.needs_reconnect = False + self.session_count += 1 + + @staticmethod + def is_retriable_error(error: Exception) -> bool: + """[確認済み] stt_stream.py L786-796""" + msg = str(error).lower() + return any(kw in msg for kw in ["1011", "internal error", "disconnected", "closed", "websocket"]) +``` + +### 5.4 SpeechDetector 設計 + +```python +# platform/live/speech_detector.py + +class SpeechDetector: + """発話途切れ検知""" + + # [確認済み] stt_stream.py L501-529 をそのまま移植 + NORMAL_ENDINGS = ['。', '?', '?', '!', '!', 'か?', 'か?', 'ます', 'です', 'ね', 'よ', 'した', 'ください'] + INCOMPLETE_PATTERNS = ['、', 'の', 'を', 'が', 'は', 'に', 'で', 'と', 'も', 'や'] + + @staticmethod + def is_incomplete(text: str) -> bool: + """発言が途中で切れているかチェック""" + if not text: + return False + text = text.strip() + + for ending in SpeechDetector.NORMAL_ENDINGS: + if text.endswith(ending): + return False + + for pattern in SpeechDetector.INCOMPLETE_PATTERNS: + if text.endswith(pattern): + return True + + # 最後の文字がひらがな・カタカナで文末パターンでない場合 + last_char = text[-1] + if last_char in 'あいうえおかきくけこさしすせそたちつてとなにぬねのはひふへほまみむめもやゆよらりるれろわをん': + if last_char not in 'ねよかなわ': + return True + + return False +``` + +### 5.5 Live API 経路のブラウザ音声入出力 + +**設計判断**: ブラウザ側で WebAudio API を使用し、PCM 16kHz でマイク入力を取得、PCM 24kHz で音声出力を再生する。 + +``` +ブラウザ側の音声処理: + +[マイク入力] + │ + ▼ navigator.mediaDevices.getUserMedia() + │ + ▼ AudioWorklet: 48kHz → 16kHz ダウンサンプリング + │ [推定] 既存の AudioManager が 48kHz→16kHz 変換を実装済み + │ + ▼ PCM 16kHz chunks (100ms単位) → WebSocket 送信 + +[音声出力] + │ + ▼ WebSocket 受信: PCM 24kHz chunks + │ + ▼ AudioWorklet: PCM → AudioBuffer → AudioContext.destination + │ + ▼ スピーカー出力 +``` + +**注意**: [推定] 既存の gourmet-sp の `AudioManager` は Socket.IO 経由で STT バックエンドに音声を送信している(`concierge-controller.ts` L17 `new AudioManager(8000)` から推定)。新プラットフォームでは WebSocket 送信先を LiveRelay に切り替える。 + +--- + +## 6. 記憶機能の統一設計 + +### 6.1 短期記憶 (SessionMemory) + +**責務**: セッション内の会話コンテキスト維持。Live API 再接続時のコンテキスト引き継ぎ。 + +```python +# platform/memory/session_memory.py + +class SessionMemory: + """短期記憶 — セッション内インメモリ""" + + MAX_HISTORY = 20 # [確認済み] stt_stream.py L493-495 + CONTEXT_SUMMARY_TURNS = 10 # [確認済み] stt_stream.py L946 + + def __init__(self): + self.history: list[dict] = [] # [{role: str, text: str, timestamp: datetime}] + + def add(self, role: str, text: str) -> None: + """会話ターンを追加(直近20ターン保持)""" + # [確認済み] stt_stream.py L490-495 + self.history.append({"role": role, "text": text, "timestamp": datetime.now()}) + if len(self.history) > self.MAX_HISTORY: + self.history = self.history[-self.MAX_HISTORY:] + + def get_context_summary(self) -> str: + """ + 再接続時のコンテキスト要約を生成 + + [確認済み] stt_stream.py L940-966 を移植: + - 直近10ターンを取得 + - 各ターンの先頭150文字を要約 + - 最後のAI発言が質問なら強調 + """ + recent = self.history[-self.CONTEXT_SUMMARY_TURNS:] + parts = [f"{h['role']}: {h['text'][:150]}" for h in recent] + summary = "\n".join(parts) + + # 最後のAI発言が質問なら強調 + for h in reversed(self.history): + if h['role'] == 'AI': + if any(q in h['text'] for q in ['?', '?']): + summary += f"\n\n【直前の質問】\n{h['text'][:200]}" + break + + return summary + + def get_history_for_prompt(self) -> str: + """LLMプロンプト注入用の履歴文字列""" + return "\n".join([f"{h['role']}: {h['text']}" for h in self.history]) +``` + +### 6.2 長期記憶 (LongTermMemory) + +**設計判断**: モード非依存のコアスキーマ + モード別拡張スキーマ。 + +```python +# platform/memory/long_term_memory.py + +class LongTermMemory: + """長期記憶 — セッションを超えたユーザー情報の永続化""" + + def __init__(self, storage_backend): + """ + storage_backend: FirestoreBackend or SupabaseBackend + [未確認] gourmet-support の実装が Firestore か Supabase かは要確認 + """ + self.storage = storage_backend + + async def get_user_profile(self, user_id: str) -> UserProfile: + """ユーザープロファイル取得""" + ... + + async def update_user_profile(self, user_id: str, updates: dict) -> None: + """ユーザープロファイル更新""" + ... + + async def get_mode_data(self, user_id: str, mode: str) -> dict: + """モード固有データ取得""" + ... + + async def update_mode_data(self, user_id: str, mode: str, data: dict) -> None: + """モード固有データ更新""" + ... + + +class UserProfile: + """ユーザープロファイル(モード非依存)""" + user_id: str + display_name: str | None + language: str # "ja", "en", "ko", "zh" + created_at: datetime + last_session_at: datetime + session_count: int + mode_data: dict[str, dict] # mode名 → モード固有データ +``` + +**データスキーマ**: + +```json +{ + "user_id": "u_abc123", + "display_name": null, + "language": "ja", + "created_at": "2026-02-20T10:00:00Z", + "last_session_at": "2026-02-26T14:30:00Z", + "session_count": 5, + "mode_data": { + "gourmet": { + "favorite_cuisines": ["イタリアン", "和食"], + "preferred_area": "渋谷", + "budget_range": "3000-5000", + "past_searches": [...] + }, + "support": { + "issue_history": [...], + "satisfaction_scores": [...] + }, + "interview": { + "completed_topics": [...], + "preferences": {} + } + } +} +``` + +**設計判断の根拠**: +- [推定] gourmet-support の `long_term_memory.py` はグルメ固有スキーマ(好きな料理、エリア等)を持つ +- [確認済み] `concierge-controller.ts` L191 で `data.initial_message` としてバックエンドから長期記憶ベースの挨拶を受け取っている +- モード非依存の `UserProfile` + モード別 `mode_data` で拡張性を確保 + +### 6.3 ストレージバックエンド + +**未確認事項**: gourmet-support の長期記憶ストレージが Firestore か Supabase かは文書間で矛盾がある: +- `docs/SYSTEM_ARCHITECTURE.md` → Firestore +- 前回の `docs/PLATFORM_DESIGN.md` → Supabase + +**設計判断**: ストレージバックエンドをインターフェースで抽象化し、実装を差し替え可能にする。初期実装では Firestore を使用(Cloud Run との親和性が高い)。 + +```python +class StorageBackend(ABC): + @abstractmethod + async def get(self, collection: str, doc_id: str) -> dict | None: ... + @abstractmethod + async def set(self, collection: str, doc_id: str, data: dict) -> None: ... + @abstractmethod + async def update(self, collection: str, doc_id: str, updates: dict) -> None: ... + +class FirestoreBackend(StorageBackend): ... +class SupabaseBackend(StorageBackend): ... # 必要に応じて実装 +``` + +--- + +## 7. 多言語対応設計 + +### 7.1 現状の多言語実装の整理 + +gourmet-sp / gourmet-support には4言語対応(ja/en/ko/zh)が既に実装されている。プラットフォーム化でこれを共通基盤に組み込む。 + +**既存実装の所在と状態**: + +| レイヤー | 機能 | 実装箇所 | 多言語対応状態 | +|---------|------|---------|-------------| +| フロントエンド | UI翻訳 | `CoreController.t()` [推定] | 4言語対応 (ja/en/ko/zh) | +| フロントエンド | TTS言語マッピング | `CoreController.LANGUAGE_CODE_MAP` [推定] | 4言語対応 | +| フロントエンド | 文分割 | `ConciergeController.splitIntoSentences()` [確認済み] | ja/zh と en/ko の2パターン | +| バックエンド | 対話言語 | `support_core.process_message(language=)` [推定] | パラメータとして受け取り | +| バックエンド | TTS合成 | `/api/tts/synthesize` [確認済み: SYSTEM_ARCHITECTURE.md] | `language_code`, `voice_name` 指定可能 | +| Live API | 音声言語 | `stt_stream.py` L446 | **日本語のみ** (`ja-JP` ハードコード) | +| Live API | 発話途切れ検知 | `stt_stream.py` L501-529 | **日本語のみ** (日本語文末パターン) | + +### 7.2 バックエンド i18n 設計 + +#### LanguageConfig(言語マスター) + +```python +# platform/i18n/language_config.py + +@dataclass +class LanguageProfile: + """1言語の設定プロファイル""" + code: str # "ja", "en", "ko", "zh" + tts_language_code: str # "ja-JP", "en-US", "ko-KR", "cmn-CN" + tts_voice_name: str # "ja-JP-Wavenet-D", etc. + live_api_language_code: str # Gemini Live API の speech_config.language_code + sentence_splitter: str # "cjk" (。で分割) or "latin" (. で分割) + +# [確認済み] concierge-controller.ts L526-546 の splitIntoSentences() から +# ja/zh → 。分割、en/ko → . 分割 の2パターンが判明 +LANGUAGE_PROFILES: dict[str, LanguageProfile] = { + "ja": LanguageProfile( + code="ja", + tts_language_code="ja-JP", + tts_voice_name="ja-JP-Wavenet-D", # [確認済み] stt_stream.py L221 + live_api_language_code="ja-JP", # [確認済み] stt_stream.py L446 + sentence_splitter="cjk", + ), + "en": LanguageProfile( + code="en", + tts_language_code="en-US", # [推定] LANGUAGE_CODE_MAP の値 + tts_voice_name="en-US-Wavenet-D", # [推定] + live_api_language_code="en-US", + sentence_splitter="latin", + ), + "ko": LanguageProfile( + code="ko", + tts_language_code="ko-KR", # [推定] + tts_voice_name="ko-KR-Wavenet-D", # [推定] + live_api_language_code="ko-KR", + sentence_splitter="latin", + ), + "zh": LanguageProfile( + code="zh", + tts_language_code="cmn-CN", # [推定] + tts_voice_name="cmn-CN-Wavenet-D", # [推定] + live_api_language_code="cmn-CN", + sentence_splitter="cjk", + ), +} +``` + +> **注意**: `tts_voice_name` の正確な値は `CoreController.LANGUAGE_CODE_MAP`(gourmet-sp リポジトリ)の確認が必要。上記は推定値。 + +#### SpeechRules(言語別文分割・途切れ検知) + +```python +# platform/i18n/speech_rules.py + +class SpeechRules: + """言語別の文分割・発話途切れ検知ルール""" + + # [確認済み] concierge-controller.ts L526-546 + @staticmethod + def split_sentences(text: str, language: str) -> list[str]: + profile = LANGUAGE_PROFILES.get(language) + if not profile: + return [text] + + if profile.sentence_splitter == "cjk": + # 日本語・中国語: 。で分割 + return [s + "。" for s in text.split("。") if s.strip()] + else: + # 英語・韓国語: . で分割 + import re + parts = re.split(r'\.\s+', text) + return [s + ". " for s in parts if s.strip()] + + # [確認済み] stt_stream.py L501-529 + # 現状は日本語のみ。他言語は段階的に追加 + INCOMPLETE_RULES: dict[str, dict] = { + "ja": { + "normal_endings": ['。', '?', '?', '!', '!', 'ます', 'です', 'ね', 'よ', 'した', 'ください'], + "incomplete_patterns": ['、', 'の', 'を', 'が', 'は', 'に', 'で', 'と', 'も', 'や'], + }, + # TODO: 他言語ルールを追加 + # "en": { "normal_endings": ['.', '?', '!'], "incomplete_patterns": [',', 'and', 'but', 'or'] }, + # "ko": { ... }, + # "zh": { ... }, + } + + @staticmethod + def is_speech_incomplete(text: str, language: str) -> bool: + """発話が途中で切れているかチェック(言語対応版)""" + rules = SpeechRules.INCOMPLETE_RULES.get(language) + if not rules: + return False # ルール未定義の言語はfalse(安全側) + + text = text.strip() + if not text: + return False + + for ending in rules["normal_endings"]: + if text.endswith(ending): + return False + + for pattern in rules["incomplete_patterns"]: + if text.endswith(pattern): + return True + + return False +``` + +### 7.3 Live API の多言語対応 + +**設計判断**: Live API の `speech_config.language_code` をセッション開始時の言語パラメータに連動させる。 + +```python +# platform/live/relay.py 内 + +def _build_live_config(self, context: str = None) -> dict: + """Live API設定を構築(多言語対応)""" + # セッションの言語からプロファイルを取得 + lang_profile = LANGUAGE_PROFILES.get(self.session.language, LANGUAGE_PROFILES["ja"]) + + config = { + "response_modalities": ["AUDIO"], + "system_instruction": self.mode_plugin.get_system_prompt(context), + "input_audio_transcription": {}, + "output_audio_transcription": {}, + "speech_config": { + "language_code": lang_profile.live_api_language_code, # 動的に設定 + }, + "realtime_input_config": { + "automatic_activity_detection": { + "disabled": False, + "start_of_speech_sensitivity": "START_SENSITIVITY_HIGH", + "end_of_speech_sensitivity": "END_SENSITIVITY_HIGH", + "prefix_padding_ms": 100, + "silence_duration_ms": 500, + } + }, + "context_window_compression": { + "sliding_window": { + "target_tokens": 32000, + } + }, + } + return config +``` + +### 7.4 フロントエンド i18n 設計 + +```typescript +// src/scripts/platform/i18n.ts + +interface LanguageConfig { + code: string; // "ja", "en", "ko", "zh" + tts: string; // "ja-JP" + voice: string; // "ja-JP-Wavenet-D" + sentenceSplitter: 'cjk' | 'latin'; +} + +// [確認済み] CoreController.LANGUAGE_CODE_MAP の構造を再現 +// 値は gourmet-sp リポジトリの確認後に正確な値で埋める +const LANGUAGE_CONFIG_MAP: Record = { + ja: { code: 'ja', tts: 'ja-JP', voice: 'ja-JP-Wavenet-D', sentenceSplitter: 'cjk' }, + en: { code: 'en', tts: 'en-US', voice: 'en-US-Wavenet-D', sentenceSplitter: 'latin' }, + ko: { code: 'ko', tts: 'ko-KR', voice: 'ko-KR-Wavenet-D', sentenceSplitter: 'latin' }, + zh: { code: 'zh', tts: 'cmn-CN', voice: 'cmn-CN-Wavenet-D', sentenceSplitter: 'cjk' }, +}; + +// [確認済み] CoreController.t() 相当の翻訳関数 +// 翻訳データは JSON ファイルから読み込み +class I18n { + private translations: Record = {}; + private currentLang: string = 'ja'; + + async loadLanguage(lang: string): Promise { + const res = await fetch(`/i18n/${lang}.json`); + this.translations = await res.json(); + this.currentLang = lang; + } + + t(key: string): string { + return this.translations[key] || key; + } + + getLanguageConfig(): LanguageConfig { + return LANGUAGE_CONFIG_MAP[this.currentLang] || LANGUAGE_CONFIG_MAP['ja']; + } +} +``` + +### 7.5 Session への言語統合 + +```python +# platform/session/manager.py(Session クラスに language フィールド追加) + +class Session: + session_id: str + user_id: str | None + mode: str + language: str # ★ "ja", "en", "ko", "zh" + dialogue_type: str + created_at: datetime + memory: SessionMemory + live_state: LiveSessionState | None +``` + +**影響箇所**: セッション開始時に `language` を受け取り、以下に伝播させる: +1. Live API 設定の `speech_config.language_code` +2. TTS 合成の `language_code`, `voice_name` +3. 発話途切れ検知の言語別ルール選択 +4. 文分割ロジックの言語別パターン選択 +5. モードプラグインのシステムプロンプト(言語に応じた指示) + +--- + +## 8. フロントエンド設計 + +### 7.1 現状の構成(確認済み + 推定) + +[確認済み] パッチファイルから判明しているクラス階層: + +``` +CoreController (基底クラス) [推定: gourmet-sp に存在] + ├── ConciergeController [確認済み: concierge-controller.ts] + │ ├── A2E統合 (applyExpressionFromTts) + │ ├── TTS並行処理 (speakResponseInChunks) + │ ├── 即答処理 (handleStreamingSTTComplete) + │ └── リップシンク診断 (runLipSyncDiagnostic) + └── ChatController [推定: gourmet-sp に存在] + +LAMAvatarController [確認済み: LAMAvatar.astro] + ├── Gaussian Splatting レンダラー管理 + ├── フレームバッファ + TTS同期再生 + ├── WebSocket Manager (OpenAvatarChat連携) + └── Expression data のサニタイズ + SDK汚染対策 + +ExpressionManager [確認済み: vrm-expression-manager.ts] + └── 52次元ARKit → mouthOpenness 変換 (GVRMボーンシステム用) +``` + +### 7.2 新プラットフォームのフロントエンド構成 + +**設計判断**: 既存の `CoreController` → `ConciergeController` 階層を参考に、モード非依存の共通層を抽出する。 + +``` +platform-frontend/ +├── src/ +│ ├── components/ +│ │ ├── PlatformApp.astro # メインアプリコンポーネント +│ │ ├── AvatarRenderer.astro # LAMAvatar.astro をベースに汎用化 +│ │ └── ChatPanel.astro # チャットUI +│ │ +│ ├── scripts/ +│ │ ├── platform/ +│ │ │ ├── platform-controller.ts # CoreController 相当の共通基盤 +│ │ │ ├── dialogue-manager.ts # REST/Live/Hybrid 切替 +│ │ │ ├── audio-io.ts # WebAudio 入出力 (マイク + スピーカー) +│ │ │ └── live-ws-client.ts # LiveRelay WebSocket クライアント +│ │ │ +│ │ ├── avatar/ +│ │ │ ├── avatar-controller.ts # LAMAvatarController ベース +│ │ │ ├── expression-sync.ts # フレームバッファ + TTS同期 +│ │ │ └── expression-manager.ts # ExpressionManager ベース +│ │ │ +│ │ └── modes/ +│ │ ├── gourmet-mode.ts # グルメモード固有UI/ロジック +│ │ ├── support-mode.ts # サポートモード固有UI/ロジック +│ │ └── interview-mode.ts # インタビューモード固有UI/ロジック +│ │ +│ └── pages/ +│ ├── index.astro # メインページ +│ └── [...mode].astro # モード別ルーティング +``` + +### 7.3 DialogueManager 設計 + +**責務**: REST対話とLive API対話の共通インターフェース。モードに応じて対話方式を切り替える。 + +```typescript +// src/scripts/platform/dialogue-manager.ts + +interface DialogueManager { + /** 対話を開始 */ + startSession(mode: string, userId?: string): Promise; + + /** テキスト送信(REST経路) */ + sendText(text: string): Promise; + + /** 音声ストリーミング開始(Live API経路) */ + startLiveStream(): Promise; + + /** 音声ストリーミング停止 */ + stopLiveStream(): Promise; + + /** セッション終了 */ + endSession(): Promise; + + /** イベントリスナー */ + on(event: 'ai_audio', handler: (data: ArrayBuffer) => void): void; + on(event: 'ai_text', handler: (text: string) => void): void; + on(event: 'expression', handler: (data: ExpressionData) => void): void; + on(event: 'interrupted', handler: () => void): void; + on(event: 'reconnecting', handler: (reason: string) => void): void; +} +``` + +### 7.4 ExpressionSync 設計 + +[確認済み] 既存の `LAMAvatar.astro` L581-676 のフレームバッファ同期ロジックを抽出・汎用化する。 + +```typescript +// src/scripts/avatar/expression-sync.ts + +class ExpressionSync { + private frameBuffer: ExpressionData[] = []; + private frameRate: number = 30; + private audioElement: HTMLAudioElement | null = null; + + // [確認済み] LAMAvatar.astro L698-720 + queueFrames(frames: ExpressionData[], frameRate: number): void { ... } + clearBuffer(): void { ... } + + // [確認済み] LAMAvatar.astro L552-676 + // SDK呼び出し(~60fps)で現在の再生時刻に対応するフレームを返す + getFrameAtTime(currentTime: number): ExpressionData { ... } + + // [確認済み] LAMAvatar.astro L592-596: fade-in + // [確認済み] LAMAvatar.astro L642-666: fade-out + private applyFade(data: ExpressionData, alpha: number): ExpressionData { ... } +} +``` + +### 7.5 既存パッチとの関係 + +[確認済み] `services/frontend-patches/` のファイルは、新プラットフォームの以下のモジュールのベースとなる: + +| パッチファイル | 新プラットフォームでの位置 | 変更点 | +|--------------|------------------------|--------| +| `concierge-controller.ts` | `modes/gourmet-mode.ts` + `platform/platform-controller.ts` | モード固有部分とプラットフォーム共通部分を分離 | +| `vrm-expression-manager.ts` | `avatar/expression-manager.ts` | そのまま使用可能 | +| `LAMAvatar.astro` | `components/AvatarRenderer.astro` + `avatar/avatar-controller.ts` | WebSocket Manager 部分を DialogueManager に統合 | + +--- + +## 9. API設計 + +### 9.1 新プラットフォーム API (`/api/v2/`) + +#### セッション管理 + +``` +POST /api/v2/session/start +Request: + { + "mode": "gourmet" | "support" | "interview", + "user_id": "u_abc123", // optional + "language": "ja", + "dialogue_type": "rest" | "live" | "hybrid" // optional, default from mode config + } +Response: + { + "session_id": "sess_xyz789", + "mode": "gourmet", + "language": "ja", // セッション言語 + "dialogue_type": "live", + "initial_message": "いらっしゃいませ!前回はイタリアンを...", // 長期記憶ベース + "ws_url": "wss://host/api/v2/live/sess_xyz789", // Live API用WebSocket URL + "supported_languages": ["ja", "en", "ko", "zh"] // このモードの対応言語 + } + +POST /api/v2/session/end +Request: { "session_id": "sess_xyz789" } +Response: { "success": true } +``` + +#### REST 対話 + +``` +POST /api/v2/chat +Request: + { + "session_id": "sess_xyz789", + "message": "渋谷でイタリアンを探して", + "language": "ja" + } +Response: + { + "response": "渋谷エリアのイタリアンですね...", + "audio": "", // TTS音声 + "expression": { // A2E結果(同梱) + "names": ["eyeBlinkLeft", ...], + "frames": [[0.1, ...], ...], + "frame_rate": 30 + }, + "shops": [...], // モード固有データ(グルメの場合) + "metadata": { + "tts_duration_ms": 1200, + "a2e_duration_ms": 800, + "llm_duration_ms": 1500 + } + } +``` + +#### Live API WebSocket + +``` +WebSocket /api/v2/live/{session_id} + +# クライアント → サーバー +{ "type": "audio", "data": "" } +{ "type": "text", "data": "テキスト入力" } +{ "type": "stop" } + +# サーバー → クライアント +{ "type": "audio", "data": "" } +{ "type": "transcription", "role": "user" | "ai", "text": "..." } +{ "type": "expression", "data": { "names": [...], "frames": [...], "frame_rate": 30 } } +{ "type": "interrupted" } +{ "type": "reconnecting", "reason": "char_limit" | "incomplete" | "error" } +{ "type": "reconnected", "session_count": 2 } +{ "type": "tool_result", "data": { ... } } // Function Calling結果 +``` + +### 9.2 既存互換 API (`/api/`) + +Phase 1 では、既存の gourmet-support エンドポイントをそのまま維持する。 + +[推定] 既存エンドポイント(パッチファイルの API 呼び出しから推定): + +``` +POST /api/session/start → 既存 gourmet-support にプロキシ +POST /api/session/end → 既存 gourmet-support にプロキシ +POST /api/chat → 既存 gourmet-support にプロキシ +POST /api/tts/synthesize → 既存 gourmet-support にプロキシ(A2E同梱返却) +GET /api/health → 既存 gourmet-support にプロキシ +``` + +**注意**: 実際のエンドポイント一覧は gourmet-support リポジトリのコード確認が必要。上記はパッチファイルから推定したもの。 + +--- + +## 10. 既存サービスとの共存戦略 + +### 10.1 Phase 1: 並行稼働 + +``` + ┌─────────────────────────┐ + │ ロードバランサー │ + └──────┬──────────┬────────┘ + │ │ + /api/* │ │ /api/v2/* + (既存) │ │ (新プラットフォーム) + ▼ ▼ + ┌──────────┐ ┌──────────┐ + │gourmet- │ │platform- │ + │support │ │backend │ + │(既存) │ │(新規) │ + └──────────┘ └──────────┘ + │ │ + └────┬─────┘ + │ + ┌──────┴──────┐ + │audio2exp- │ + │service(共用)│ + └─────────────┘ +``` + +**ポイント**: +- 既存の gourmet-support は**一切変更しない** +- 新プラットフォームバックエンドは別プロセス・別 Cloud Run サービスとしてデプロイ +- audio2exp-service は両方から呼び出される(変更なし) +- フロントエンドは URL パスまたはサブドメインで切り分け + +### 10.2 Phase 2: 段階的移行 + +``` +1. グルメモードを新プラットフォーム上で再現 + → 既存 gourmet-support と同等の動作を確認 +2. 新プラットフォーム側にトラフィックを徐々に切り替え + → A/B テスト、既存が問題なく動作することを常時確認 +3. 全トラフィック移行完了後、既存 gourmet-support を退役 +``` + +### 10.3 フロントエンドの共存 + +**設計判断**: 既存の gourmet-sp と新フロントエンドは別デプロイとする。 + +| 項目 | 既存 (gourmet-sp) | 新プラットフォーム | +|------|-------------------|-------------------| +| URL | `gourmet.example.com` | `platform.example.com` | +| デプロイ先 | Vercel | Vercel (別プロジェクト) | +| バックエンド | gourmet-support | platform-backend | +| 変更 | **なし** | 新規開発 | + +--- + +## 11. iPhone SE 対応戦略 + +### 11.1 レンダリング方式の選択肢 + +| 方式 | 技術 | 品質 | iPhone SE 動作 | 状態 | +|------|------|------|----------------|------| +| A | LAM WebGL SDK (Gaussian Splatting) | 最高(論文品質) | **[未検証]** 81,424 Gaussians | 現在の実装 | +| B | Three.js + GLB メッシュ + ブレンドシェイプ | 中〜高 | 動作実績あり(TalkingHead等) | 未実装 | +| C | 2Dアニメーション + 口パク | 低〜中 | 軽量 | フォールバック | + +### 11.2 判断基準 + +**設計判断**: 方式A を第一候補とし、iPhone SE 実機テストの結果で判断する。 + +``` +iPhone SE 実機テスト結果 + │ + ├── 30fps 以上 → 方式A を採用 + │ + ├── 15-30fps → Gaussian 数を削減 (LOD) して再テスト + │ │ + │ ├── 30fps 以上 → 方式A (LOD版) を採用 + │ │ + │ └── 15fps 未満 → 方式B にフォールバック + │ + └── 15fps 未満 → 方式B を採用 +``` + +**根拠**: +- [確認済み] LAM プロジェクトページによると iPhone 16 で 35FPS の実績あり +- iPhone SE (A13/A15) は iPhone 16 (A18) より GPU性能が低い +- [確認済み] `LAMAvatar.astro` L337-339 にフォールバック画像表示の仕組みが既にある + +### 11.3 方式B の実装指針(フォールバック) + +方式B を採用する場合: +1. LAM で生成したアバターの FLAME メッシュを GLB 形式でエクスポート +2. Three.js で GLB をロードし、52次元ブレンドシェイプを直接適用 +3. [確認済み] `vrm-expression-manager.ts` の `ExpressionData` インターフェースはそのまま使用可能 +4. GPU 負荷が Gaussian Splatting の1/10以下になるため、iPhone SE でも問題なく動作する見込み + +**注意**: GLB メッシュの品質は Gaussian Splatting より劣る。トレードオフの判断はオーナーが実機で確認して決定する。 + +--- + +## 12. 開発ロードマップ + +### Phase 0: 技術検証(前提条件の確認) + +| タスク | 成果物 | 検証基準 | 見積もり | +|--------|--------|---------|---------| +| iPhone SE 実機テスト | FPSベンチマーク結果 | 30fps 以上で方式A採用決定 | 見積もり不可(実機入手・テスト環境構築に依存) | +| gourmet-support リポジトリ精査 | 実コード読解結果 | 長期記憶スキーマ・エンドポイント一覧の確定 | 見積もり不可(リポジトリの規模に依存) | +| gourmet-sp リポジトリ精査 | 実コード読解結果 | CoreController の完全なインターフェース確定 | 同上 | +| Live API WebSocket 中継レイテンシ計測 | プロトタイプ + 計測結果 | 中継込み往復 500ms 以内 | 見積もり不可 | + +**重要**: Phase 0 の結果により、Phase 1 以降の設計が変更される可能性がある。特に: +- iPhone SE テスト結果 → レンダリング方式の決定(方式A or B) +- gourmet-support 精査結果 → 記憶機能の詳細設計、既存互換APIの完全な仕様 + +### Phase 1: プラットフォーム基盤 + Live API MVP + +| タスク | 成果物 | 検証基準 | +|--------|--------|---------| +| バックエンド骨格構築 | `platform/` ディレクトリ、FastAPI サーバー | ヘルスチェック通過 | +| SessionManager 実装 | セッション CRUD | 単体テスト通過 | +| ModeRegistry + グルメモードプラグイン | モード切替動作 | グルメモードでセッション開始・終了 | +| REST 対話経路の実装 | `/api/v2/chat` | テキスト送信→LLM応答→TTS+A2E同梱返却 | +| LiveRelay 実装 | WebSocket 中継 | ブラウザから Live API 音声対話が動作 | +| ReconnectManager 実装 | 累積制限回避 | 800文字超で自動再接続、コンテキスト引き継ぎ | +| SessionMemory 実装 | 短期記憶 | 20ターン保持、再接続時コンテキスト要約 | +| フロントエンド MVP | 最小限の対話UI | テキスト入力 + 音声入力 でAI応答 | + +### Phase 2: アバター統合 + 記憶機能 + +| タスク | 成果物 | 検証基準 | +|--------|--------|---------| +| AvatarRenderer 統合 | LAMAvatar ベースの汎用コンポーネント | 3D アバターが表情アニメーション付きで動作 | +| ExpressionSync 実装 | TTS同期フレーム再生 | 音声と口パクが同期 | +| Live API 経路の A2E 統合 | 音声チャンク→A2E→Expression WS送信 | Live API 対話中にリップシンク動作 | +| LongTermMemory 実装 | Firestore 永続化 | セッション跨ぎでユーザー情報が保持される | +| グルメモード完全移植 | 既存 gourmet-support 同等の動作 | E2E テスト通過 | + +### Phase 3: マルチモード + 最適化 + +| タスク | 成果物 | 検証基準 | +|--------|--------|---------| +| カスタマーサポートモード | support プラグイン | モード切替で異なる対話体験 | +| インタビューモード | interview プラグイン | スクリプト進行管理が動作 | +| iPhone SE 最適化 | LOD / フォールバック実装 | iPhone SE で 30fps | +| 既存 gourmet-support からの移行 | トラフィック切り替え | 全ユーザーが新プラットフォーム経由 | + +--- + +## 付録A: 未確認事項一覧 + +| # | 項目 | 影響範囲 | 確認方法 | 確認優先度 | +|---|------|---------|---------|-----------| +| 1 | iPhone SE で LAM WebGL SDK が 30fps 出るか | レンダリング方式決定 | 実機テスト | **最高** | +| 2 | gourmet-support の長期記憶ストレージ (Firestore or Supabase) | 記憶機能設計 | gourmet-support リポジトリ確認 | 高 | +| 3 | gourmet-support の `long_term_memory.py` データスキーマ | 長期記憶の統一スキーマ設計 | gourmet-support リポジトリ確認 | 高 | +| 4 | gourmet-sp の `CoreController` 完全インターフェース | フロントエンド共通基盤設計 | gourmet-sp リポジトリ確認 | 高 | +| 5 | WebSocket 中継 (ブラウザ→サーバー→Gemini) のレイテンシ | Live API 経路の UX 品質 | プロトタイプ計測 | 高 | +| 6 | REST API ハイブリッド方式の実効性 | Live/REST 切替設計 | stt_stream.py でのツール定義復活・検証 | 中 | +| 7 | gourmet-support の完全なエンドポイント一覧 | 既存互換 API 設計 | gourmet-support リポジトリ確認 | 中 | +| 8 | AudioManager の内部実装(48kHz→16kHz変換の詳細) | Live API の音声入力設計 | gourmet-sp リポジトリ確認 | 中 | + +## 付録B: 設計判断ログ + +| # | 判断 | 選択肢 | 選択理由 | +|---|------|--------|---------| +| D1 | Web フレームワーク: FastAPI | Flask, FastAPI, Starlette | Live API の WebSocket 中継に非同期 I/O が必須。stt_stream.py が asyncio ベース | +| D2 | 新旧 API 共存: URL プレフィックス分離 (/api/ vs /api/v2/) | サブドメイン分離, ヘッダ分離, URL分離 | 最もシンプルでロードバランサー設定が容易 | +| D3 | Live API 中継: サーバーサイド | クライアント直接接続, サーバー中継 | API キー保護が必須。中継レイテンシは要計測だが、セキュリティ優先 | +| D4 | 記憶ストレージ: Firestore (初期) | Firestore, Supabase, Redis | Cloud Run との親和性。インターフェース抽象化で後から変更可能 | +| D5 | フロントエンド: 新規デプロイ (既存と別) | 既存改修, 新規デプロイ | 既存を壊さない原則 (P1) に従う | +| D6 | レンダリング: 方式A優先 + 方式Bフォールバック | A固定, B固定, A+Bハイブリッド | 論文超え品質が最上位ゴール。未検証リスクをフォールバックで担保 | diff --git a/docs/PLATFORM_DESIGN.md b/docs/PLATFORM_DESIGN.md new file mode 100644 index 0000000..c8c622e --- /dev/null +++ b/docs/PLATFORM_DESIGN.md @@ -0,0 +1,668 @@ +# プラットフォーム設計書 — Live API 統合 & マルチモード化 + +> **作成日**: 2026-02-25 +> **対象**: gourmet-support バックエンド / gourmet-sp フロントエンド / audio2exp-service / AI_Meeting_App +> **目的**: 現在のグルメサポートAIを汎用プラットフォームに進化させ、複数のAIアプリケーションを単一基盤で運用する + +--- + +## 目次 + +1. [背景と目的](#1-背景と目的) +2. [現状のシステム構成](#2-現状のシステム構成) +3. [プラットフォーム全体設計](#3-プラットフォーム全体設計) +4. [共通基盤 vs モード固有の仕分け](#4-共通基盤-vs-モード固有の仕分け) +5. [Live API 統合設計](#5-live-api-統合設計) +6. [バックエンド設計](#6-バックエンド設計) +7. [フロントエンド設計](#7-フロントエンド設計) +8. [モード別仕様](#8-モード別仕様) +9. [開発ロードマップ](#9-開発ロードマップ) +10. [移行戦略と既存エンドポイント温存方針](#10-移行戦略と既存エンドポイント温存方針) + +--- + +## 1. 背景と目的 + +### 1.1 現在の課題 + +- フロントエンド(gourmet-sp)とバックエンド(gourmet-support)がグルメサポート専用に密結合 +- モード追加のたびにハードコードが必要(ページ、コントローラ、ルート) +- `stt_stream.py`(インタビューモード)がデスクトップ専用のスタンドアロンアプリで、Webプラットフォームと統合されていない +- Live API(Gemini Native Audio)の能力がプラットフォームに組み込まれていない + +### 1.2 ゴール + +1. **プラットフォーム化**: フロントエンド・バックエンドを汎用基盤として再構成し、モード追加を容易にする +2. **Live API 統合**: Gemini Live API をプラットフォームの標準機能として組み込む +3. **既存エンドポイント温存**: α版テスト中のグルメサポートAIを中断しない +4. **段階的移植**: グルメコンシェルジュ → カスタマーサポート → インタビューの順で展開 + +--- + +## 2. 現状のシステム構成 + +### 2.1 gourmet-support バックエンド(4モジュール構成) + +``` +gourmet-support/ +├── app_customer_support.py # Flask routes, TTS, STT, A2E統合, Socket.IO +├── support_core.py # SupportSession, SupportAssistant (Gemini LLM) +├── api_integrations.py # HotPepper, Places, TripAdvisor API +├── long_term_memory.py # Supabase ユーザープロフィール・長期記憶 +└── prompts/ # モード別×言語別プロンプト +``` + +**既存エンドポイント(温存対象)**: + +| エンドポイント | メソッド | 機能 | +|---------------|---------|------| +| `/api/session/start` | POST | セッション開始、mode: chat/concierge | +| `/api/session/end` | POST | セッション終了 | +| `/api/chat` | POST | LLM応答(Gemini 2.0 Flash) | +| `/api/tts/synthesize` | POST | TTS + A2E バンドル応答 | +| `/api/stt/transcribe` | POST | STT(単発) | +| `audio_chunk` (WS) | Socket.IO | ストリーミングSTT | +| `/api/finalize` | POST | セッション最終化 | +| `/health` | GET | ヘルスチェック | + +### 2.2 gourmet-sp フロントエンド + +``` +CoreController (base-controller.ts) +├── ConciergeController (concierge-controller.ts) +│ └── LAMAvatarController (LAMAvatar.astro) — 3D Gaussian Splatting +└── ChatController (chat-controller.ts) — テキストのみ +``` + +- **モード切替**: ページ遷移(`/concierge` ↔ `/`)、ハードコード +- **プラグイン機構**: なし + +### 2.3 AI_Meeting_App/stt_stream.py(スタンドアロン) + +``` +GeminiLiveApp +├── Live API 接続管理(自動再接続、累積文字数制限) +├── 会話履歴管理(直近20ターン保持) +├── 発話途切れ検知(_is_speech_incomplete) +├── REST API フォールバック(長文処理) +├── TTS Player(GCP TTS + PyAudio直接再生) +└── 3モード: standard / silent / interview +``` + +**stt_stream.py から移植可能な機能**: + +| 機能 | stt_stream.py 実装 | プラットフォーム移植先 | +|------|-------------------|---------------------| +| Live API 接続・再接続 | `GeminiLiveApp.run()` | `platform/live_api_proxy.py` | +| 会話履歴管理 | `conversation_history` | `platform/base_session.py` | +| 自動再接続(累積文字数) | `MAX_AI_CHARS_BEFORE_RECONNECT=800` | `platform/live_api_proxy.py` | +| 発話途切れ検知 | `_is_speech_incomplete()` | `platform/live_api_proxy.py` | +| コンテキスト要約 | `_get_context_summary()` | `platform/base_session.py` | +| モード別システムプロンプト | `_build_system_instruction()` | `modes/*/config.py` | +| スクリプト進行管理 | `_get_next_question_from_script()` | `modes/interview/script_manager.py` | +| 議事録 | `log_transcript()` | `modes/interview/transcript.py` | +| REST API ハイブリッド | `RestAPIHandler` | `platform/base_assistant.py` | + +--- + +## 3. プラットフォーム全体設計 + +``` +┌──────────────────────────────────────────────────────────────────┐ +│ Platform Core(共通基盤) │ +├──────────────────────────────────────────────────────────────────┤ +│ │ +│ Frontend (Astro + TypeScript) │ +│ ┌──────────────────────────────────────────────────────────┐ │ +│ │ CoreController: セッション, TTS再生, マイク, UI共通 │ │ +│ │ LiveAPIClient: WebSocket経由のLive API通信 │ │ +│ │ LAMAvatarController: 3Dアバター + A2E blendshape │ │ +│ │ AudioManager: マイク入力パイプライン (16kHz PCM, VAD) │ │ +│ └──────────────────────────────────────────────────────────┘ │ +│ │ +│ Backend (Flask + Socket.IO) │ +│ ┌──────────────────────────────────────────────────────────┐ │ +│ │ ModeRegistry: モード登録・取得・一覧 │ │ +│ │ BaseSession: セッション管理, 会話履歴 │ │ +│ │ BaseAssistant: Gemini REST API ラッパー │ │ +│ │ TTSService: Google Cloud TTS 合成 │ │ +│ │ STTService: Google Cloud STT (Chirp2) │ │ +│ │ LiveAPIProxy: Gemini Live API WebSocket 中継 │ │ +│ │ MemoryService: Supabase 長期記憶 │ │ +│ │ A2EClient: audio2exp-service 連携 │ │ +│ └──────────────────────────────────────────────────────────┘ │ +│ │ +│ External Services │ +│ ┌──────────────────────────────────────────────────────────┐ │ +│ │ audio2exp-service (Cloud Run): Wav2Vec2 → ARKit 52ch │ │ +│ │ Google Cloud TTS / STT │ │ +│ │ Gemini 2.0/2.5 Flash (REST + Live API) │ │ +│ │ Supabase (長期記憶) │ │ +│ └──────────────────────────────────────────────────────────┘ │ +│ │ +└──────────────────────────────────────────────────────────────────┘ + │ + ▼ +┌──────────────────────────────────────────────────────────────────┐ +│ Mode Plugins(モード固有ロジック) │ +├────────────┬────────────┬──────────────┬─────────────────────────┤ +│ Gourmet │ Customer │ Interview │ 将来の新モード │ +│ Concierge │ Support │ │ │ +├────────────┼────────────┼──────────────┼─────────────────────────┤ +│ system │ system │ system │ system prompt │ +│ prompt │ prompt │ prompt │ │ +│ (グルメ │ (CS対応 │ (インタビュー │ │ +│ コンシェ │ FAQ回答) │ 進行) │ │ +│ ルジュ) │ │ │ │ +├────────────┼────────────┼──────────────┼─────────────────────────┤ +│ HotPepper │ FAQ DB │ script │ 外部API / データソース │ +│ Places │ Ticket Sys │ PDF参照 │ │ +│ TripAdvisor│ │ │ │ +├────────────┼────────────┼──────────────┼─────────────────────────┤ +│ ショップ │ チケット │ 録音 │ モード固有UI │ +│ カードUI │ UI │ 議事録UI │ コンポーネント │ +├────────────┼────────────┼──────────────┼─────────────────────────┤ +│ 対話: Live │ 対話: Live │ 全部: Live │ Live / REST 配分 │ +│ 説明: REST │ 回答: REST │ 長文のみREST │ │ +└────────────┴────────────┴──────────────┴─────────────────────────┘ +``` + +--- + +## 4. 共通基盤 vs モード固有の仕分け + +| レイヤー | 共通(Platform Core) | モード固有(Plugin) | +|---------|----------------------|---------------------| +| **セッション** | session管理, 会話履歴, タイムアウト | 初期化パラメータ, greeting | +| **LLM** | Gemini接続, REST/Live切替, retry | system prompt, tools, function calling | +| **TTS** | GCP TTS合成, A2E連携, 音声配信 | voice設定(声種, 速度, ピッチ) | +| **STT** | GCP STT, WebSocket streaming | 言語設定, 認識パラメータ | +| **A2E** | audio2exp連携, frame配信 | (共通、モード依存なし) | +| **アバター** | GVRM renderer, blendshape適用 | (共通、モード依存なし) | +| **記憶** | Supabase CRUD, session storage | collection名, schema, 保存項目 | +| **Live API** | WebSocket proxy, 再接続, VAD | context window設定, speech config | +| **外部API** | HTTP client共通 | HotPepper, FAQ DB, スクリプトファイル | +| **UI** | チャットUI, マイクボタン, TTS再生 | ショップカード, チケットUI, 議事録UI | + +--- + +## 5. Live API 統合設計 + +### 5.1 アーキテクチャ + +``` +Browser Backend Gemini +┌────────┐ WebSocket ┌──────────────┐ WebSocket ┌──────────┐ +│ Audio │ ──────────────►│ LiveAPI │ ────────────────►│ Gemini │ +│ Manager│ PCM 16kHz │ Proxy │ audio/pcm │ Live API │ +│ │◄──────────────│ │◄────────────────│ │ +│ │ audio chunks │ │ audio + text │ │ +└────────┘ │ │ └──────────┘ + │ │ + │ ┌──────────┤ + │ │ Session │ ← 会話履歴, 再接続コンテキスト + │ │ Manager │ + │ └──────────┤ + │ │ A2E │ ← TTS音声 → 表情データ生成 + │ │ Client │ + │ └──────────┘ + └──────────────┘ +``` + +### 5.2 Live API Proxy の責務 + +`stt_stream.py` の `GeminiLiveApp` をWebサーバー向けに再設計: + +| stt_stream.py(デスクトップ) | live_api_proxy.py(Web) | +|------------------------------|-------------------------| +| PyAudio 直接入力 | WebSocket 経由で受信 | +| PyAudio 直接出力 | WebSocket 経由で配信 | +| asyncio.Queue | Socket.IO rooms | +| 単一ユーザー | マルチセッション対応 | +| ローカルファイル議事録 | DB/メモリ内議事録 | +| 環境変数でモード切替 | ModeRegistry から動的取得 | + +### 5.3 Live API 設定(モード共通) + +```python +# stt_stream.py から移植する設定 +LIVE_API_CONFIG = { + "response_modalities": ["AUDIO"], + "input_audio_transcription": {}, + "output_audio_transcription": {}, + "speech_config": { + "language_code": "ja-JP", + }, + "realtime_input_config": { + "automatic_activity_detection": { + "disabled": False, + "start_of_speech_sensitivity": "START_SENSITIVITY_HIGH", + "end_of_speech_sensitivity": "END_SENSITIVITY_HIGH", + "prefix_padding_ms": 100, + "silence_duration_ms": 500, + } + }, + "context_window_compression": { + "sliding_window": { + "target_tokens": 32000, + } + }, +} +``` + +### 5.4 モード別の Live/REST 使い分け + +| モード | Live API(低遅延対話) | REST API(長文生成) | +|--------|----------------------|---------------------| +| **グルメコンシェルジュ** | 好みヒアリング、相槌、確認 | ショップカード説明、詳細レビュー | +| **カスタマーサポート** | 状況ヒアリング、共感、確認 | FAQ回答、手順説明、チケット作成 | +| **インタビュー** | 質問、相槌、進行(メイン) | 資料参照の長文説明時のみ | + +### 5.5 自動再接続メカニズム(stt_stream.py から移植) + +``` +セッション開始 + │ + ▼ +Live API 接続 + │ + ├── 音声送受信ループ + │ │ + │ ├── AI発話 → 累積文字数カウント + │ │ └── 800文字超過 → 再接続フラグ ON + │ │ + │ ├── 発話途切れ検知 → 即時再接続フラグ ON + │ │ + │ └── 長い発話(500文字超) → 再接続フラグ ON + │ + ▼ +再接続フラグ ON + │ + ├── 会話履歴からコンテキスト要約を生成 + ├── system_instruction に要約を追加 + ├── Live API 再接続 + └── 「続きをお願いします」で再開 +``` + +--- + +## 6. バックエンド設計 + +### 6.1 ディレクトリ構成 + +``` +gourmet-support/ +│ +├── app_customer_support.py # ★既存(温存、一切変更しない) +├── support_core.py # ★既存(温存) +├── api_integrations.py # ★既存(温存) +├── long_term_memory.py # ★既存(温存) +├── prompts/ # ★既存(温存) +│ +├── app_platform.py # 新規: プラットフォーム用 Flask app +│ +├── platform/ # 新規: 共通基盤モジュール +│ ├── __init__.py +│ ├── mode_registry.py # モード登録・取得・一覧 +│ ├── base_session.py # 共通セッション管理 +│ ├── base_assistant.py # 共通LLMラッパー (REST API) +│ ├── tts_service.py # TTS共通化 +│ ├── stt_service.py # STT共通化 +│ ├── live_api_proxy.py # Live API WebSocket proxy +│ ├── memory_service.py # 長期記憶共通化 +│ └── a2e_client.py # A2E連携クライアント +│ +└── modes/ # 新規: モードプラグイン + ├── __init__.py + ├── base_mode.py # モード基底クラス + ├── gourmet/ + │ ├── __init__.py + │ ├── config.py # プロンプト, voice設定, API keys + │ ├── assistant.py # グルメ固有ロジック (support_core.pyベース) + │ └── apis.py # HotPepper, Places (api_integrations.pyベース) + ├── customer_support/ + │ ├── __init__.py + │ ├── config.py + │ └── assistant.py + └── interview/ + ├── __init__.py + ├── config.py # stt_stream.py のプロンプト移植 + ├── assistant.py + ├── script_manager.py # stt_stream.py のスクリプト管理移植 + └── transcript.py # 議事録管理 +``` + +### 6.2 ModeRegistry + +```python +class ModeConfig: + """モード設定の定義""" + mode_id: str # "gourmet", "support", "interview" + display_name: str # "グルメコンシェルジュ" + system_prompt: str # LLM用システムプロンプト + live_api_instruction: str # Live API用システムインストラクション + voice_config: dict # {"language_code": "ja-JP", "name": "ja-JP-Neural2-B", ...} + live_api_enabled: bool # Live APIを使うか + live_rest_split: str # "live_primary" | "rest_primary" | "live_only" + tools: list # Function Calling ツール定義 + external_apis: list # 外部API設定 + memory_collection: str # Supabase collection名 + ui_components: list # フロントエンドで表示するUIコンポーネント + greeting: str # 初回挨拶 + +class ModeRegistry: + """モード登録・管理""" + _modes: dict[str, ModeConfig] + + def register(mode_config: ModeConfig) -> None + def get(mode_id: str) -> ModeConfig + def list_modes() -> list[ModeConfig] + def get_default() -> ModeConfig +``` + +### 6.3 プラットフォーム用エンドポイント + +```python +# app_platform.py — 既存エンドポイントとは別ポート or URL prefix で共存 + +# === モード管理 === +GET /api/v2/modes # 利用可能モード一覧 +GET /api/v2/modes/{mode_id}/config # モード設定取得 + +# === セッション === +POST /api/v2/session/start # { mode: "gourmet" | "support" | "interview" } +POST /api/v2/session/end + +# === REST API チャット === +POST /api/v2/chat # 既存互換 + モード自動切替 + +# === TTS === +POST /api/v2/tts/synthesize # TTS + A2E バンドル + +# === STT === +POST /api/v2/stt/transcribe +WS /api/v2/stt/stream # ストリーミングSTT(Socket.IO) + +# === Live API(新規)=== +WS /api/v2/live/connect # Live API WebSocket proxy +WS /api/v2/live/audio # 音声ストリーム(上り/下り) + +# === ヘルスチェック === +GET /health +``` + +### 6.4 Live API Proxy 詳細設計 + +```python +class LiveAPIProxy: + """ + Gemini Live API の WebSocket 中継サーバー + stt_stream.py の GeminiLiveApp をWebサーバー向けに再設計 + """ + + # セッションごとの状態 + sessions: dict[str, LiveSession] + + class LiveSession: + session_id: str + mode_config: ModeConfig + gemini_session: object # Gemini Live API session + conversation_history: list # 会話履歴(直近20ターン) + ai_char_count: int # 累積文字数(再接続判定用) + session_count: int # 再接続回数 + transcript_buffer: dict # user/ai のトランスクリプトバッファ + + async def connect(session_id: str, mode_id: str) -> None + """Live APIセッション開始""" + + async def send_audio(session_id: str, audio_chunk: bytes) -> None + """クライアント→Live APIに音声送信""" + + async def receive_loop(session_id: str) -> AsyncGenerator + """Live API→クライアントに音声・テキスト配信""" + + async def reconnect(session_id: str) -> None + """コンテキスト引き継ぎで再接続(stt_stream.py方式)""" + + def is_speech_incomplete(text: str) -> bool + """発話途切れ検知(stt_stream.py から移植)""" + + def get_context_summary(session_id: str) -> str + """会話履歴の要約(stt_stream.py から移植)""" +``` + +--- + +## 7. フロントエンド設計 + +### 7.1 クラス階層の変更 + +**現状(ハードコード)**: +``` +CoreController +├── ConciergeController (ページ固定) +└── ChatController (ページ固定) +``` + +**プラットフォーム化後(動的モード)**: +``` +PlatformController (旧CoreController拡張) +├── ModeManager ← モード動的切替 +├── LiveAPIClient ← Live API WebSocket通信(新規) +├── RESTClient ← REST API通信(既存) +├── TTSPlayer ← TTS再生(既存) +├── AudioManager ← マイク入力(既存) +└── LAMAvatarController ← 3Dアバター(既存) + +ModePlugin (interface) +├── GourmetMode ← ショップカード、店舗検索UI +├── CustomerSupportMode ← チケットUI、FAQ表示 +└── InterviewMode ← スクリプト表示、録音、議事録 +``` + +### 7.2 ModePlugin インターフェース + +```typescript +interface ModePlugin { + modeId: string; + displayName: string; + + // UI + renderCustomUI(container: HTMLElement): void; + destroyCustomUI(): void; + + // メッセージ処理 + onAssistantMessage(message: AssistantMessage): void; + onUserMessage(message: string): void; + + // Live API イベント + onLiveAudioReceived?(audioData: ArrayBuffer): void; + onLiveTranscript?(text: string, role: 'user' | 'ai'): void; + + // ライフサイクル + onActivate(): void; + onDeactivate(): void; +} +``` + +### 7.3 LiveAPIClient(新規) + +```typescript +class LiveAPIClient { + private ws: WebSocket; + private sessionId: string; + + // 接続 + connect(sessionId: string, modeId: string): Promise; + disconnect(): void; + + // 音声送受信 + sendAudio(pcmData: ArrayBuffer): void; + onAudioReceived(callback: (data: ArrayBuffer) => void): void; + + // トランスクリプト + onTranscript(callback: (text: string, role: string) => void): void; + + // 状態 + onReconnecting(callback: () => void): void; + onReconnected(callback: () => void): void; +} +``` + +--- + +## 8. モード別仕様 + +### 8.1 グルメコンシェルジュ + +| 項目 | 仕様 | +|------|------| +| **mode_id** | `gourmet` | +| **対話方式** | Live API(ヒアリング) + REST(店舗説明) | +| **外部API** | HotPepper, Google Places, TripAdvisor | +| **固有UI** | ショップカード、マップ、レビュー表示 | +| **記憶** | Supabase(ユーザー好み、過去の検索履歴) | +| **アバター** | 3D Gaussian Splatting + A2E blendshape | +| **voice** | ja-JP-Neural2-B(女性、明るい) | + +### 8.2 カスタマーサポート + +| 項目 | 仕様 | +|------|------| +| **mode_id** | `support` | +| **対話方式** | Live API(状況ヒアリング) + REST(FAQ回答、手順説明) | +| **外部API** | FAQ DB, チケットシステム | +| **固有UI** | FAQ検索結果、チケット作成フォーム、ステータス表示 | +| **記憶** | Supabase(問い合わせ履歴、顧客情報) | +| **アバター** | 3D Gaussian Splatting + A2E blendshape | +| **voice** | ja-JP-Neural2-C(女性、落ち着いた) | + +### 8.3 インタビュー + +| 項目 | 仕様 | +|------|------| +| **mode_id** | `interview` | +| **対話方式** | Live API主体(全対話)、長文説明時のみREST | +| **外部API** | なし(スクリプトファイル、参照PDF) | +| **固有UI** | スクリプト表示、録音コントロール、議事録ダウンロード | +| **記憶** | セッション内のみ(議事録はファイル保存) | +| **アバター** | 3D Gaussian Splatting + A2E blendshape | +| **voice** | ja-JP-Neural2-D(男性、落ち着いた) | +| **stt_stream.py からの移植** | Live API接続管理、自動再接続、発話途切れ検知、スクリプト進行、議事録 | + +--- + +## 9. 開発ロードマップ + +### Phase 0: 設計・準備 + +- [x] プラットフォーム設計書(本ドキュメント) +- [ ] gourmet-support の既存コード精査・依存関係整理 +- [ ] ModeConfig / ModeRegistry のインターフェース確定 + +### Phase 1: バックエンド Platform Core + +``` +目標: platform/ + modes/gourmet/ + app_platform.py を構築し、 + 既存 gourmet-support と並行稼働できることを確認 +``` + +**1-1. platform/ 共通基盤** +- [ ] `mode_registry.py` — モード登録・取得 +- [ ] `base_session.py` — 共通セッション管理(support_core.py からリファクタ) +- [ ] `base_assistant.py` — 共通LLMラッパー(support_core.py からリファクタ) +- [ ] `tts_service.py` — TTS共通化(app_customer_support.py から抽出) +- [ ] `stt_service.py` — STT共通化(app_customer_support.py から抽出) +- [ ] `memory_service.py` — 長期記憶共通化(long_term_memory.py からリファクタ) +- [ ] `a2e_client.py` — A2E連携(app_customer_support.py から抽出) + +**1-2. live_api_proxy.py(stt_stream.py 移植)** +- [ ] WebSocket中継サーバー(マルチセッション対応) +- [ ] 自動再接続メカニズム(累積文字数 + 発話途切れ) +- [ ] コンテキスト要約・引き継ぎ +- [ ] Live API 設定のモード別カスタマイズ + +**1-3. modes/gourmet/** +- [ ] `config.py` — プロンプト、voice設定、API設定 +- [ ] `assistant.py` — グルメ固有ロジック +- [ ] `apis.py` — HotPepper, Places連携 + +**1-4. app_platform.py** +- [ ] /api/v2/ エンドポイント実装 +- [ ] Socket.IO Live API ルーム +- [ ] 既存 app_customer_support.py との共存確認 + +**テスト**: platform経由でグルメコンシェルジュが動作すること + +### Phase 2: フロントエンド Platform Core + +**2-1. PlatformController** +- [ ] CoreController を PlatformController に拡張 +- [ ] ModeManager — 動的モード切替(ページ遷移→SPA内切替) +- [ ] ModePlugin インターフェース定義 + +**2-2. LiveAPIClient** +- [ ] WebSocket 接続管理 +- [ ] 音声送受信パイプライン +- [ ] 再接続UI(インジケータ表示) + +**2-3. GourmetMode プラグイン** +- [ ] 既存 ConciergeController のロジックを ModePlugin に移行 +- [ ] ショップカードUI +- [ ] Live API ↔ REST 自動切替 + +**テスト**: 新フロントエンド → app_platform.py で動作確認 + +### Phase 3: カスタマーサポートモード + +- [ ] `modes/customer_support/config.py` — CS用プロンプト +- [ ] `modes/customer_support/assistant.py` — FAQ検索、チケット作成 +- [ ] フロントエンド `CustomerSupportMode` プラグイン +- [ ] FAQ DB 連携(Supabase or 外部API) + +### Phase 4: インタビューモード移植 + +- [ ] `modes/interview/config.py` — stt_stream.py のプロンプト移植 +- [ ] `modes/interview/script_manager.py` — スクリプト進行管理 +- [ ] `modes/interview/transcript.py` — 議事録管理 +- [ ] フロントエンド `InterviewMode` プラグイン +- [ ] スクリプト表示UI、録音コントロール、議事録ダウンロード + +--- + +## 10. 移行戦略と既存エンドポイント温存方針 + +### 10.1 共存アーキテクチャ + +``` + ┌─────────────────────────┐ + │ Cloud Run │ + │ │ +α版フロントエンド ──►│ app_customer_support.py │◄── 既存 :5001 +(gourmet-sp) │ /api/session/start │ + │ /api/chat │ + │ /api/tts/synthesize │ + │ │ +新フロントエンド ──►│ app_platform.py │◄── 新規 :5002 +(platform-sp) │ /api/v2/session/start │ + │ /api/v2/chat │ + │ /api/v2/live/connect │ + │ /api/v2/modes │ + └─────────────────────────┘ +``` + +### 10.2 温存ルール + +1. **`app_customer_support.py` は一切変更しない** — α版テストに影響を与えない +2. **`support_core.py`, `api_integrations.py`, `long_term_memory.py` も変更しない** +3. `platform/` は既存コードからの **コピー&リファクタ**(import依存にしない) +4. 将来、α版テスト完了後に既存エンドポイントを deprecate → platform に一本化 + +### 10.3 デプロイ戦略 + +| フェーズ | app_customer_support.py | app_platform.py | +|---------|------------------------|----------------| +| Phase 1 | Cloud Run(現行) | ローカル開発 | +| Phase 2 | Cloud Run(現行) | Cloud Run(別サービス or 別ポート) | +| Phase 3 | Cloud Run(現行) | Cloud Run(本番) | +| 統合後 | 廃止 | Cloud Run(全モード統合) | diff --git a/docs/PLATFORM_REQUIREMENTS.md b/docs/PLATFORM_REQUIREMENTS.md new file mode 100644 index 0000000..97e2551 --- /dev/null +++ b/docs/PLATFORM_REQUIREMENTS.md @@ -0,0 +1,421 @@ +# プラットフォーム化 要件定義書 + +> **文書ID**: REQ-PLATFORM-001 +> **作成日**: 2026-02-26 +> **ステータス**: Draft +> **根拠文書**: `docs/DESIGN_REQUEST.md`, `docs/SYSTEM_ARCHITECTURE.md`, `docs/SESSION_HANDOFF.md` + +--- + +## 目次 + +1. [プロジェクト概要](#1-プロジェクト概要) +2. [現状分析(確認済み事実)](#2-現状分析確認済み事実) +3. [ビジネス要件](#3-ビジネス要件) +4. [機能要件](#4-機能要件) +5. [非機能要件](#5-非機能要件) +6. [技術的制約](#6-技術的制約) +7. [未確認事項・リスク](#7-未確認事項リスク) +8. [用語集](#8-用語集) + +--- + +## 1. プロジェクト概要 + +### 1.1 背景 + +現在、3つの独立したコンポーネントが存在する: + +| コンポーネント | 概要 | 所在 | +|--------------|------|------| +| **gourmet-sp / gourmet-support** | グルメコンシェルジュ専用Webアプリ(Astro + Flask)。REST API経由のテキスト対話 + TTS + 3Dアバター | **別リポジトリ**(LAM_gpro にはパッチファイルのみ) | +| **AI_Meeting_App/stt_stream.py** | デスクトップ専用の会議アシスタント。Gemini Live API によるリアルタイム音声対話。PyAudio前提 | LAM_gpro リポジトリ内 | +| **audio2exp-service** | 音声→表情変換マイクロサービス。Cloud Run デプロイ済み | LAM_gpro リポジトリ内 | + +これらが別々に開発されたため、以下の問題がある: + +- グルメサポートは「グルメ」にハードコードされており、他の用途に流用できない +- Live API(低遅延音声対話)はデスクトップ版にしか実装されておらず、Webアプリでは使えない +- 記憶機能(短期・長期)が別々の場所に別々の方式で実装されている + +### 1.2 目的 + +**「3Dアバター × AI対話」の共通基盤を作り、モード(用途)を差し替えるだけで異なるAIアプリを素早く立ち上げられるようにする。** + +### 1.3 スコープ + +| 対象 | スコープ内/外 | +|------|-------------| +| バックエンド共通基盤(セッション管理、LLM対話、TTS/STT、A2E連携、記憶管理) | **スコープ内** | +| Live API のWeb統合 | **スコープ内** | +| モードプラグインアーキテクチャ | **スコープ内** | +| フロントエンド共通基盤(アバターレンダリング、音声入出力) | **スコープ内** | +| 記憶機能の統一(短期・長期) | **スコープ内** | +| gourmet-sp / gourmet-support の実コード改修 | **スコープ外**(別リポジトリ。設計・インターフェース定義のみ) | +| LAMモデルの再学習・ファインチューニング | **スコープ外** | +| iPhone SE での WebGL パフォーマンス検証 | **スコープ外**(別途技術検証タスク) | + +--- + +## 2. 現状分析(確認済み事実) + +### 2.1 audio2exp-service(確認済み — コード読解完了) + +**ソース**: `services/audio2exp-service/app.py`, `services/audio2exp-service/a2e_engine.py` + +- Flask API(port 8081)、CORS有効 +- エンドポイント: `POST /api/audio2expression`, `GET /health` +- パイプライン: 音声(base64) → PCM 16kHz → Wav2Vec2(768dim) → A2Eデコーダー → 52次元ARKitブレンドシェイプ @30fps +- 2段階ロード: INFER パイプライン(LAM_Audio2Expression モジュール使用)を優先、未インストール時は Wav2Vec2 エネルギーベースのフォールバック +- デプロイ状態: Cloud Run(us-central1)、ヘルスチェック OK(status: healthy, engine_ready: True, device: cpu, mode: fallback) +- メモリ要件: 4Gi(2Gi では不足で3回失敗した実績あり) + +**API仕様(確認済み)**: +``` +POST /api/audio2expression +Request: { audio_base64: string, session_id: string, audio_format: "mp3"|"wav"|"pcm" } +Response: { names: string[52], frames: number[N][52], frame_rate: 30 } + +GET /health +Response: { status: "healthy"|"loading", engine_ready: bool, mode: "infer"|"fallback", device: string } +``` + +### 2.2 AI_Meeting_App/stt_stream.py(確認済み — コード読解完了) + +**ソース**: `AI_Meeting_App/stt_stream.py`(約1,063行) + +| 機能 | 実装箇所 | 詳細 | +|------|---------|------| +| Live API 接続 | `GeminiLiveApp.run()` L714-796 | `gemini-2.5-flash-native-audio-preview-12-2025` モデル使用 | +| 音声送受信 | `listen_audio()`, `send_audio()`, `receive_audio()`, `play_audio()` | PCM 16kHz入力 / 24kHz出力、asyncio TaskGroup で4タスク並行 | +| 累積文字数制限回避 | `MAX_AI_CHARS_BEFORE_RECONNECT=800`, `LONG_SPEECH_THRESHOLD=500` | FLASH版の累積トークン制限への対処 | +| 自動再接続 | `run()` L714-796 | 累積800文字 / 長文500文字 / 発話途切れ / APIエラー(1011/1008)で再接続 | +| コンテキスト引き継ぎ | `_get_context_summary()` L940-966, `_build_config(with_context=)` L410-438 | 直近10ターンの要約を新セッションの system_instruction に注入 | +| 発話途切れ検知 | `_is_speech_incomplete()` L501-529 | 文末パターン(「が」「で」「を」等)で日本語の途切れを検出 | +| 会話履歴 | `conversation_history` L389, L490-499 | 直近20ターン保持、`[{role, text}]` リスト | +| スライディングウィンドウ | `context_window_compression` L457-461 | target_tokens: 32000 | +| REST APIハイブリッド | `RestAPIHandler` L307-362 | `gemini-2.5-flash` + Google Search。ただし**現在未使用**(ツール定義が空リスト) | +| モード切替 | standard / silent / interview | モード別 system_instruction | +| VAD設定 | `automatic_activity_detection` | start/end sensitivity: HIGH, silence_duration: 500ms | +| 割り込み処理 | `response.server_content.interrupted` | 出力キューをフラッシュし再生停止 | +| TTS | `TTSPlayer` L213-301 | Google Cloud TTS `ja-JP-Wavenet-D`、200文字チャンク分割 | +| 議事録保存 | `log_transcript()` L203-207 | Markdown形式でタイムスタンプ付き記録 | + +**重要な制約**: PyAudio 直接入出力のため、ブラウザ非対応。Voicemeeter デバイス連携(Windows環境前提)。 + +### 2.3 フロントエンドパッチ(確認済み — コード読解完了) + +**ソース**: `services/frontend-patches/` + +| ファイル | 内容 | 状態 | +|---------|------|------| +| `concierge-controller.ts` | `CoreController` を継承。A2E統合済みコントローラー。`window.lamAvatarController` との連携、TTS プレーヤーリンク、リップシンク診断テスト | 作成済み・未適用 | +| `vrm-expression-manager.ts` | 52次元ARKit → mouthOpenness 変換。フレームバッファリング、30fps 再生タイマー | 作成済み・未適用 | +| `LAMAvatar.astro` | LAMアバター統合 Astro コンポーネント。WebSocket 通信、OpenAvatarChat 連携 | 作成済み・未適用 | +| `FRONTEND_INTEGRATION.md` | 統合ガイド | 作成済み | + +**重要**: これらは gourmet-sp に適用するパッチとして作成されたが、**結合テスト未実施**。 + +### 2.4 gourmet-support バックエンド(推定 — 別リポジトリ、直接確認不可) + +以下は `docs/SYSTEM_ARCHITECTURE.md` とフロントエンドパッチの API 呼び出しから推定した構成: + +| ファイル | 推定内容 | +|---------|---------| +| `app_customer_support.py` | Flask + Socket.IO、APIエンドポイント提供 | +| `support_core.py` | Gemini LLM 対話ロジック | +| `api_integrations.py` | HotPepper API 等の外部連携 | +| `long_term_memory.py` | 長期記憶(Firestore に永続化) | + +推定エンドポイント: `/api/session/start`, `/api/session/end`, `/api/chat`, `/api/tts/synthesize`, `/api/health` + +> **注意**: 上記は推定であり、実コードの確認が必要。特に長期記憶のストレージ(Firestore vs Supabase)は文書間で矛盾がある(`SYSTEM_ARCHITECTURE.md` は Firestore、前回の `PLATFORM_DESIGN.md` は Supabase と記載)。 + +### 2.5 gourmet-sp フロントエンド(推定 — 別リポジトリ、直接確認不可) + +パッチファイルの import 文と `SYSTEM_ARCHITECTURE.md` から推定: + +- Astro + TypeScript +- クラス階層: `CoreController` → `ConciergeController` / `ChatController` +- `AudioManager` — マイク入力(48kHz→16kHz、Socket.IO streaming) +- GVRM — Gaussian Splatting 3Dアバターレンダラー +- リップシンク: FFTベース(デフォルト)、A2Eブレンドシェイプ(パッチ適用時) + +--- + +## 3. ビジネス要件 + +### 3.1 最上位ゴール + +**論文超えクオリティの3D対話アバターを、バックエンドGPUなしで、iPhone SE単体で軽く動かす。即実用のアルファ版。** + +| # | 要件 | 詳細 | +|---|------|------| +| BR-01 | **論文超えの自然さ** | 口元だけでなく、表情・頭の動き・セリフとの連動が自然。低遅延 | +| BR-02 | **スマホ単体完結** | バックエンドGPU一切不要。推論もレンダリングも全てオンデバイス(A2Eを除く) | +| BR-03 | **iPhone SEで軽く動く** | 最も制約の厳しいデバイスが動作基準 | +| BR-04 | **技術スタックに固執しない** | 動くものを即テスト→見極め→次へ。理論より実証 | + +### 3.2 直近のゴール(α版) + +| 優先度 | ゴール | 完了条件 | +|--------|--------|---------| +| **P1** | グルメコンシェルジュのα版を壊さず動かし続ける | 既存エンドポイント一切変更なし | +| **P2** | Live API をWebプラットフォームに統合する | ブラウザからリアルタイム音声対話ができる | +| **P3** | モード追加が容易な構造にする | 新モード追加時に変更するファイルが最小限 | +| **P4** | 記憶機能を統一仕様にする | 短期記憶・長期記憶を共通サービスとして提供 | + +--- + +## 4. 機能要件 + +### 4.1 マルチモード対応 + +| ID | 要件 | 優先度 | 備考 | +|----|------|--------|------| +| FR-MODE-01 | 単一基盤で複数のAIアプリケーション(グルメコンシェルジュ、カスタマーサポート、インタビュー等)を運用できる | P3 | プラグインアーキテクチャ | +| FR-MODE-02 | モードごとにシステムプロンプト、外部API連携、UI部品を差し替え可能 | P3 | | +| FR-MODE-03 | 新モード追加時にハードコード変更を最小限にする | P3 | 設定ファイルとモードプラグインの追加のみ | +| FR-MODE-04 | 既存のグルメコンシェルジュモードが新基盤上で動作する | P1 | 既存エンドポイント温存 | + +### 4.2 Live API 統合 + +| ID | 要件 | 優先度 | 移植元 | +|----|------|--------|--------| +| FR-LIVE-01 | ブラウザからGemini Live APIによるリアルタイム音声対話ができる | P2 | `stt_stream.py` 全体 | +| FR-LIVE-02 | 累積文字数制限の回避(自動再接続) | P2 | `MAX_AI_CHARS_BEFORE_RECONNECT=800`, L624-643 | +| FR-LIVE-03 | 再接続時のコンテキスト引き継ぎ(短期記憶) | P2 | `_get_context_summary()` L940-966, `_build_config()` L410-438 | +| FR-LIVE-04 | 発話途切れ検知と即時再接続 | P2 | `_is_speech_incomplete()` L501-529 | +| FR-LIVE-05 | ユーザー割り込み(barge-in)対応 | P2 | `response.server_content.interrupted` | +| FR-LIVE-06 | スライディングウィンドウによるコンテキスト圧縮 | P2 | `context_window_compression` L457-461 | +| FR-LIVE-07 | VAD(音声区間検出)の設定可能化 | P3 | `automatic_activity_detection` 設定 | +| FR-LIVE-08 | Live API + REST API のハイブリッド対話(短文はLive、長文はREST+TTS) | P3 | `RestAPIHandler` L307-362 | + +### 4.3 対話方式 + +| ID | 要件 | 優先度 | +|----|------|--------| +| FR-DIAL-01 | REST APIベースのテキスト対話(既存方式の温存) | P1 | +| FR-DIAL-02 | Live APIベースのリアルタイム音声対話 | P2 | +| FR-DIAL-03 | モードごとにLive/REST/ハイブリッドを選択可能 | P3 | + +**モード別対話方式マトリクス**: + +| モード | Live API(低遅延対話) | REST API(長文生成) | +|--------|----------------------|---------------------| +| グルメコンシェルジュ | 好みヒアリング、相槌、確認 | ショップカード説明、詳細レビュー | +| カスタマーサポート | 状況ヒアリング、共感、確認 | FAQ回答、手順説明 | +| インタビュー | 質問、相槌、進行(メイン) | 資料参照の長文説明時のみ | + +### 4.4 記憶機能 + +| ID | 要件 | 優先度 | 現状の実装箇所 | +|----|------|--------|--------------| +| FR-MEM-01 | 短期記憶: セッション内の会話コンテキストを維持 | P2 | `stt_stream.py` の `conversation_history`(直近20ターン) | +| FR-MEM-02 | 短期記憶: Live API再接続時にコンテキストを引き継ぎ | P2 | `_get_context_summary()` | +| FR-MEM-03 | 長期記憶: セッションを超えたユーザー好みの永続化 | P4 | `gourmet-support` の `long_term_memory.py`(Firestore) | +| FR-MEM-04 | 長期記憶: モード非依存のデータスキーマ(モード別拡張可能) | P4 | 現状はグルメ固有スキーマ | +| FR-MEM-05 | 短期記憶を共通 `SessionMemory` サービスとして提供 | P4 | 現状は `stt_stream.py` にベタ書き | +| FR-MEM-06 | 長期記憶を共通 `LongTermMemory` サービスとして提供 | P4 | 現状は `gourmet-support` に埋め込み | + +### 4.5 音声処理 + +| ID | 要件 | 優先度 | +|----|------|--------| +| FR-AUD-01 | TTS: テキスト→音声合成(Google Cloud TTS または Live APIネイティブ音声) | P1 | +| FR-AUD-02 | STT: ブラウザマイク入力の音声認識(Live API transcription または専用STT) | P2 | +| FR-AUD-03 | A2E: 音声→52次元ARKitブレンドシェイプ変換(audio2exp-service 経由) | P1 | +| FR-AUD-04 | A2E結果のクライアントへのストリーミング配信(~10KB/sec) | P2 | + +### 4.6 アバターレンダリング + +| ID | 要件 | 優先度 | +|----|------|--------| +| FR-AVT-01 | 52次元ARKitブレンドシェイプによる表情アニメーション | P1 | +| FR-AVT-02 | 30fps でのスムーズなアニメーション再生 | P1 | +| FR-AVT-03 | LAM WebGL SDK(Gaussian Splatting)によるレンダリング | P2 | +| FR-AVT-04 | Three.js + GLBメッシュによる軽量レンダリング(フォールバック) | P3 | +| FR-AVT-05 | レンダリング方式の動的切替(デバイス性能に応じて) | P3 | + +### 4.7 多言語対応 + +#### 4.7.1 現状の実装状況(確認済み) + +gourmet-sp / gourmet-support には多言語対応が既に実装されている: + +**フロントエンド(`concierge-controller.ts` から確認済み)**: + +| 機能 | 実装箇所 | 詳細 | +|------|---------|------| +| 言語状態管理 | `this.currentLanguage` | 現在の表示・対話言語を保持 | +| UI翻訳 | `this.t('key')` | `CoreController` 基底クラスの翻訳関数。UIラベル・メッセージを多言語化 | +| TTS言語マッピング | `this.LANGUAGE_CODE_MAP[this.currentLanguage]` | 言語→TTS設定(`langConfig.tts`: 言語コード, `langConfig.voice`: 音声名)のマッピング | +| 言語別文分割 | `splitIntoSentences(text, language)` L526-546 | 日本語・中国語は`。`、英語・韓国語は`. `で分割 | +| UI言語動的切替 | `updateUILanguage()` L479-497 | 言語切替時にUIラベルを再描画 | +| セッション開始 | `initializeSession()` L177-182 | `language: this.currentLanguage` をバックエンドに送信 | +| チャット送信 | `sendMessage()` L844-846 | `language: this.currentLanguage` をバックエンドに送信 | +| TTS合成 | `speakTextGCP()` L285-296 | `language_code`, `voice_name` をバックエンドに送信 | +| ショップ表示 | `displayShops` イベント | `language` を渡して多言語表示に対応 | + +**対応言語(`splitIntoSentences` と `LANGUAGE_CODE_MAP` から推定)**: + +| 言語 | コード | 文分割ルール | TTS設定 | +|------|--------|------------|---------| +| 日本語 | `ja` | `。`で分割 | [推定] `ja-JP`, `ja-JP-Wavenet-D` 等 | +| 英語 | `en` | `. `で分割 | [推定] `en-US`, Wavenet系 | +| 韓国語 | `ko` | `. `で分割 | [推定] `ko-KR`, Wavenet系 | +| 中国語 | `zh` | `。`で分割 | [推定] `cmn-CN`, Wavenet系 | + +> **注意**: `LANGUAGE_CODE_MAP` の完全な定義は `CoreController`(gourmet-sp リポジトリ)にあり、直接確認できていない。上記の言語リストは `splitIntoSentences()` の分岐条件から推定。 + +**バックエンド(`SYSTEM_ARCHITECTURE.md` から確認済み)**: + +| 機能 | 実装箇所 | 詳細 | +|------|---------|------| +| 対話言語 | `support_core.process_message()` | `language` パラメータを受け取りLLMに渡す | +| TTS合成 | `/api/tts/synthesize` | `language_code`, `voice_name` で言語・音声を指定 | +| セッション開始 | `/api/session/start` | `language` パラメータでセッション言語を設定 | + +**stt_stream.py(確認済み — 日本語のみ)**: + +| 機能 | 実装箇所 | 詳細 | +|------|---------|------| +| Live API 音声言語 | `_build_config()` L446 | `language_code: "ja-JP"` にハードコード | +| TTS音声 | `TTSPlayer.__init__()` L220-222 | `ja-JP-Wavenet-D` にハードコード | +| 発話途切れ検知 | `_is_speech_incomplete()` L501-529 | 日本語パターンのみ(「が」「で」「を」等) | + +#### 4.7.2 機能要件 + +| ID | 要件 | 優先度 | 備考 | +|----|------|--------|------| +| FR-I18N-01 | 既存の4言語対応(ja/en/ko/zh)をプラットフォームでも維持する | P1 | 既存機能の温存 | +| FR-I18N-02 | UI翻訳機能を共通基盤に組み込む(`t()` 関数相当) | P1 | `CoreController` から抽出 | +| FR-I18N-03 | TTS言語マッピングをプラットフォーム設定に統合する | P1 | `LANGUAGE_CODE_MAP` 相当 | +| FR-I18N-04 | Live API の音声言語をセッション言語に連動させる | P2 | 現状は `ja-JP` ハードコード | +| FR-I18N-05 | 発話途切れ検知を多言語対応にする | P3 | 現状は日本語パターンのみ | +| FR-I18N-06 | 文分割ロジックを言語別に拡張可能にする | P3 | 現状は ja/zh と en/ko の2パターン | +| FR-I18N-07 | 新言語の追加が設定ファイルの追加のみで完了する | P4 | 言語マスター + 翻訳ファイル | + +### 4.8 既存サービス共存 + +| ID | 要件 | 優先度 | +|----|------|--------| +| FR-COMPAT-01 | 既存の gourmet-support エンドポイントを一切変更しない | P1 | +| FR-COMPAT-02 | 既存と新プラットフォームを並行稼働させる | P1 | +| FR-COMPAT-03 | 段階的に既存モジュールを新基盤に移行する | P3 | + +--- + +## 5. 非機能要件 + +### 5.1 パフォーマンス + +| ID | 要件 | 目標値 | 備考 | +|----|------|--------|------| +| NFR-PERF-01 | Live API 応答遅延 | < 500ms(音声入力→音声出力) | Gemini側の仕様に依存 | +| NFR-PERF-02 | REST API 応答遅延 | < 3秒(テキスト入力→テキスト出力) | | +| NFR-PERF-03 | TTS 合成遅延 | < 1秒/文 | Google Cloud TTS | +| NFR-PERF-04 | A2E 推論遅延 | < 2秒/文 | CPU推論 | +| NFR-PERF-05 | エンドツーエンド遅延(REST経路) | < 6秒(発話→アバター応答開始) | TTS + A2E + レンダリング | +| NFR-PERF-06 | アバターレンダリング FPS | >= 30fps(iPhone SE) | **未検証 — 最重要技術リスク** | + +### 5.2 可用性 + +| ID | 要件 | 目標値 | +|----|------|--------| +| NFR-AVAIL-01 | Live API セッション中断からの自動復帰 | 再接続成功率 > 95% | +| NFR-AVAIL-02 | audio2exp-service のヘルスチェック応答 | 常時200応答 | +| NFR-AVAIL-03 | A2Eフォールバック(INFERパイプライン障害時) | エネルギーベース近似に自動切替 | + +### 5.3 拡張性 + +| ID | 要件 | +|----|------| +| NFR-EXT-01 | 新モード追加が設定ファイル + プラグインモジュール追加のみで完了する | +| NFR-EXT-02 | 長期記憶のデータスキーマがモード別に拡張可能 | +| NFR-EXT-03 | 対話方式(Live/REST/ハイブリッド)がモード単位で選択可能 | + +### 5.4 セキュリティ + +| ID | 要件 | +|----|------| +| NFR-SEC-01 | APIキー(Gemini等)をサーバーサイドで管理し、クライアントに露出しない | +| NFR-SEC-02 | セッションIDによるアクセス制御 | +| NFR-SEC-03 | 長期記憶データの暗号化(個人情報を含む可能性) | + +### 5.5 デバイス対応 + +| ID | 要件 | 備考 | +|----|------|------| +| NFR-DEV-01 | iPhone SE(A13/A15, 3-4GB RAM)で30fps描画 | **未検証** | +| NFR-DEV-02 | モダンブラウザ対応(Chrome, Safari, Firefox の最新2バージョン) | | +| NFR-DEV-03 | WebGL 2.0 または WebGPU 対応 | LAM WebGL SDK の要件 | + +--- + +## 6. 技術的制約 + +### 6.1 確定制約 + +| # | 制約 | 根拠 | +|---|------|------| +| TC-01 | A2E推論はサーバー側(CPU)で実行 | Wav2Vec2(95Mパラメータ)はモバイルでは重すぎる。audio2exp-service として Cloud Run デプロイ済み | +| TC-02 | レンダリングはクライアント側 | ユーザー体験(遅延)とサーバーコスト(GPU不要)の両面 | +| TC-03 | Live API は FLASH版(`gemini-2.5-flash-native-audio-preview`)を使用 | 累積トークン制限あり。回避ロジック必須 | +| TC-04 | gourmet-sp / gourmet-support は別リポジトリ | プラットフォーム側でインターフェースを定義し、既存コードの改修は最小限にする | +| TC-05 | audio2exp-service は 4Gi メモリが必要 | 2Gi では OOM で3回失敗した実績 | +| TC-06 | Live API の WebSocket 接続は**サーバーサイド**で管理 | APIキー保護のため。ブラウザ → サーバー → Gemini の中継が必要 | + +### 6.2 技術選定の制約 + +| # | 制約 | 理由 | +|---|------|------| +| TC-07 | バックエンドは Python(Flask or FastAPI) | 既存の gourmet-support が Flask、stt_stream.py が Python。統合コストを最小化 | +| TC-08 | フロントエンドは Astro + TypeScript | 既存の gourmet-sp の技術スタック | +| TC-09 | LLM は Gemini ファミリー | Live API が Gemini 固有機能 | +| TC-10 | TTS は Google Cloud TTS(REST経路)または Live API ネイティブ音声 | 既存実装との整合性 | + +--- + +## 7. 未確認事項・リスク + +### 7.1 未確認事項 + +| # | 項目 | 影響 | 確認方法 | +|---|------|------|---------| +| UNC-01 | iPhone SE で LAM WebGL SDK(81,424 Gaussians)が 30fps で動作するか | **致命的** — レンダリング方式の根本的判断に影響 | 実機テスト | +| UNC-02 | gourmet-support の長期記憶ストレージが Firestore か Supabase か | 記憶機能統一設計に影響 | gourmet-support リポジトリの実コード確認 | +| UNC-03 | gourmet-support の `long_term_memory.py` のデータスキーマ詳細 | モード非依存化の設計に影響 | gourmet-support リポジトリの実コード確認 | +| UNC-04 | gourmet-sp の `CoreController` の完全なインターフェース | フロントエンド共通基盤の設計に影響 | gourmet-sp リポジトリの実コード確認 | +| UNC-05 | REST APIハイブリッド方式の実効性(stt_stream.py では現在未使用) | Live/REST切替設計に影響 | stt_stream.py でのツール定義復活と動作検証 | +| UNC-06 | Live API を WebSocket 経由でブラウザに中継する際のレイテンシ | UX品質に影響 | プロトタイプでの計測 | + +### 7.2 リスク + +| # | リスク | 発生確率 | 影響度 | 対策 | +|---|--------|---------|--------|------| +| RISK-01 | iPhone SE で Gaussian Splatting が 30fps 出ない | **高** | **致命的** | Three.js + GLB メッシュへのフォールバック経路を設計段階で用意 | +| RISK-02 | Live API の FLASH版制限が将来変更される | 中 | 中 | 回避ロジックを抽象化し、設定で ON/OFF 可能にする | +| RISK-03 | gourmet-support の実コード構造が推定と異なる | 中 | 高 | プラットフォームのインターフェースを先に定義し、既存コードへの依存を最小化 | +| RISK-04 | WebSocket 中継(ブラウザ→サーバー→Gemini)のレイテンシが許容範囲を超える | 低〜中 | 高 | 早期プロトタイプで計測。不可の場合はクライアント直接接続 + トークン制限方式を検討 | +| RISK-05 | 記憶機能の統一により既存グルメモードのパーソナライゼーション品質が低下 | 低 | 中 | 既存の長期記憶ロジックは温存し、新基盤では並行して新スキーマを構築 | + +--- + +## 8. 用語集 + +| 用語 | 定義 | +|------|------| +| **A2E (Audio2Expression)** | 音声から52次元ARKitブレンドシェイプ係数を生成する技術・サービス | +| **ARKit ブレンドシェイプ** | Apple ARKit 準拠の52次元顔表情パラメータ。jawOpen, eyeBlinkLeft 等 | +| **Live API** | Google Gemini の Native Audio 対話 API。音声→音声のストリーミング対話 | +| **FLASH版** | `gemini-2.5-flash-native-audio-preview` モデル。累積トークン制限あり | +| **REST API** | 従来のテキストベース LLM API(`gemini-2.5-flash`) | +| **LAM (Large Avatar Model)** | SIGGRAPH 2025 論文。FLAME ベースの Gaussian Splatting アバター生成・アニメーション技術 | +| **GVRM** | Gaussian Splatting ベースのアバターレンダラー | +| **INFER パイプライン** | LAM_Audio2Expression モジュールによる完全な A2E 推論パイプライン | +| **フォールバック** | INFER パイプライン未使用時の、Wav2Vec2 エネルギーベース近似生成 | +| **短期記憶** | セッション内の会話コンテキスト維持。インメモリ | +| **長期記憶** | セッションを超えたユーザー情報の永続化。Firestore/Supabase | +| **モード** | プラットフォーム上の用途別アプリケーション(グルメ、サポート、インタビュー等) | +| **モードプラグイン** | モード固有のロジック・設定をカプセル化したモジュール | diff --git a/docs/PLATFORM_SPEC_v2.md b/docs/PLATFORM_SPEC_v2.md new file mode 100644 index 0000000..66fc473 --- /dev/null +++ b/docs/PLATFORM_SPEC_v2.md @@ -0,0 +1,1066 @@ +# グルメポートAI プラットフォーム仕様書 v2 + +> **文書ID**: SPEC-PLATFORM-002 +> **作成日**: 2026-03-02 +> **ステータス**: Draft +> **前提**: `support_base/` および `docs/` 既存ドキュメントの実コード読解に基づき作成 + +--- + +## 目次 + +1. [目的と背景](#1-目的と背景) +2. [プラットフォーム目標](#2-プラットフォーム目標) +3. [システム構成](#3-システム構成) +4. [LiveAPI + REST API ハイブリッド仕様](#4-liveapi--rest-api-ハイブリッド仕様) +5. [プロンプト外部管理 (GCS)](#5-プロンプト外部管理-gcs) +6. [記憶機能 (短期・長期)](#6-記憶機能-短期長期) +7. [LLM 検索機能 SDK 対応](#7-llm-検索機能-sdk-対応) +8. [多言語対応](#8-多言語対応) +9. [マルチデバイス対応](#9-マルチデバイス対応) +10. [リップシンク実写アバター](#10-リップシンク実写アバター) +11. [API 仕様](#11-api-仕様) +12. [データフロー](#12-データフロー) +13. [実装ステータスと残作業](#13-実装ステータスと残作業) + +--- + +## 1. 目的と背景 + +### 1.1 目的 + +**グルメポートAIアプリのコンシェルジュモードとグルメモードの両方を LiveAPI 仕様に変更する。** + +現行の gourmet-support は REST API ベースのテキスト対話(ユーザー入力 → LLM応答 → TTS読み上げ → アバター口パク)であり、以下の体験上の問題がある: + +| 課題 | 詳細 | +|------|------| +| **往復レイテンシ** | STT → REST API → TTS の直列処理で **5〜10秒のラグ** | +| **割り込み不可** | 「話す → 待つ → 聞く」の交互方式。AIの回答中にユーザーが割り込めない | +| **相槌なし** | 「へぇ」「なるほど」等のリアルタイム応答ができない | + +Gemini Live API はこれらを根本的に解決する: + +- **超低遅延**: 音声入力 → 音声出力が数百ms +- **割り込み対応**: ユーザーの発話を検知してAIが応答を中断 +- **ネイティブ音声生成**: TTS不要、AIが直接音声を生成 + +### 1.2 対象モード + +| モード | 現行方式 | 変更後 | +|--------|---------|--------| +| **コンシェルジュモード** (concierge) | REST API + TTS | **Live API** (ヒアリング・相槌) + REST API (店舗説明・詳細レビュー) | +| **グルメモード** (chat) | REST API + TTS | **Live API** (対話) + REST API (検索結果説明) | + +--- + +## 2. プラットフォーム目標 + +本アプリを様々なAIアプリに流用するためのプラットフォーム化を行う。 + +### 2.1 7つの柱 + +| # | 柱 | 概要 | +|---|-----|------| +| 1 | **LiveAPI + REST API ハイブリッド** | 短文・相槌は Live API、長文・検索結果は REST API + TTS | +| 2 | **プロンプト GCS 外部保存** | 流用・メンテ・チューニングの利便性 | +| 3 | **短期記憶 + 長期記憶** | LLMの弱点を補完。セッション内コンテキスト + ユーザーパーソナライゼーション | +| 4 | **LLM 検索機能 SDK 対応** | Google Search Grounding による最新情報取得 | +| 5 | **多言語対応** | 多言語ファイルによる ja/en/ko/zh 対応 | +| 6 | **マルチデバイス** | スマホ (iPhone/Android) + Web アプリ | +| 7 | **リップシンク実写アバター** | スマホ単体で軽量に動く実写アバター | + +--- + +## 3. システム構成 + +### 3.1 リポジトリ構成 + +| レイヤー | リポジトリ | 技術スタック | デプロイ先 | 状態 | +|---------|-----------|-------------|-----------|------| +| **フロントエンド** | [mirai-gpro/gourmet-sp2](https://github.com/mirai-gpro/gourmet-sp2) | Astro + TypeScript | Vercel (**未連携**) | アバターリップシンク実装・テスト済 | +| **バックエンド** | [mirai-gpro/support-base](https://github.com/mirai-gpro/support-base) | FastAPI + Python | Cloud Run | LiveAPI対応・プラットフォーム化済 | +| **A2Eサービス** | LAM_gpro/services/audio2exp-service | Flask + PyTorch | Cloud Run | デプロイ済・ヘルスチェックOK | + +> **注意**: `gourmet-sp` (旧リポジトリ) はアバター対応なし。アバター付きリップシンクの実装・テストは `gourmet-sp2` で実施済み。今回のプラットフォーム化は `gourmet-sp2` をベースとする。 + +### 3.2 バックエンド (support-base) モジュール構成 [確認済み] + +``` +support_base/ +├── server.py # FastAPI エントリーポイント (uvicorn) +├── config/ +│ └── settings.py # 環境変数ベース設定 +├── core/ +│ ├── support_core.py # ビジネスロジック (SupportSession, SupportAssistant) +│ │ # GCS/ローカルプロンプト読み込み +│ │ # Gemini REST API (gemini-2.5-flash) 対話 +│ │ # Google Search Grounding 対応 +│ ├── api_integrations.py # HotPepper / Google Places / TripAdvisor API +│ └── long_term_memory.py # 長期記憶 (Supabase user_profiles テーブル) +├── live/ +│ ├── relay.py # Live API WebSocket 中継 (LiveRelay) +│ ├── reconnect.py # 累積文字数制限 回避ロジック (ReconnectManager) +│ └── speech_detector.py # 発話途切れ検知 (多言語対応) +├── memory/ +│ └── session_memory.py # 短期記憶 (SessionMemory, 直近20ターン) +├── session/ +│ └── manager.py # セッション管理 (Session, SessionManager) +├── modes/ +│ ├── base_mode.py # モードプラグイン基底クラス (BaseModePlugin) +│ ├── registry.py # モードレジストリ (ModeRegistry) +│ └── gourmet/ +│ └── plugin.py # グルメコンシェルジュ プラグイン +├── rest/ +│ └── router.py # REST API ルーター (gourmet-support 互換) +├── services/ +│ └── a2e_client.py # audio2exp-service HTTP クライアント +└── i18n/ + └── language_config.py # 言語マスター設定 (LanguageProfile) +``` + +### 3.3 フロントエンド (gourmet-sp2) モジュール構成 [確認済み] + +``` +gourmet-sp2/ +├── astro.config.mjs # Astro設定 (SSG, PWA, COOP/COEP, WASM対応) +├── package.json # 依存: three, gaussian-splat-renderer-for-lam, onnxruntime-web +├── gs.ts # Gaussian Splatting ビューアー (Three.js, LBS skinning) +├── gvrm.ts # GVRM アバターシステム (bone texture, lip-sync API) +├── src/ +│ ├── pages/ +│ │ ├── index.astro # グルメモード (Chat) ページ +│ │ ├── concierge.astro # コンシェルジュモード (3Dアバター) ページ +│ │ └── 404.astro +│ ├── components/ +│ │ ├── GourmetChat.astro # チャットUIコンポーネント +│ │ ├── Concierge.astro # コンシェルジュUIコンポーネント (アバターステージ+チャット) +│ │ ├── LAMAvatar.astro # LAM 3Dアバター (Gaussian Splatting WebGL レンダリング) +│ │ ├── ShopCardList.astro # 店舗カードリスト +│ │ ├── ReservationModal.astro # 予約モーダル +│ │ ├── ProposalCard.astro # 提案カード +│ │ └── InstallPrompt.astro # PWA インストールプロンプト (iOS/Android対応) +│ ├── scripts/ +│ │ ├── chat/ +│ │ │ ├── core-controller.ts # 基底コントローラー (セッション, TTS, STT, 多言語) +│ │ │ ├── chat-controller.ts # グルメモード (テキスト/音声チャット) +│ │ │ ├── concierge-controller.ts # コンシェルジュモード (アバター+A2E+並行TTS) +│ │ │ └── audio-manager.ts # マイク入力 (iOS/Android/PC分岐, AudioWorklet, VAD) +│ │ ├── lam/ +│ │ │ ├── lam-websocket-manager.ts # OpenAvatarChat WebSocket (JBIN形式受信) +│ │ │ └── audio-sync-player.ts # 音声再生 精密タイミング同期 +│ │ └── avatar/ +│ │ └── concierge-interface.ts # アバターインターフェース +│ ├── constants/ +│ │ └── i18n.ts # 多言語定義 (ja/en/zh/ko) + LANGUAGE_CODE_MAP +│ ├── styles/ +│ └── layouts/ +│ └── Layout.astro +├── public/ +│ ├── avatar/ +│ │ └── concierge/ # LAMアバターアセット (skin.glb, offset.ply) +│ ├── ort-wasm/ # ONNX Runtime WASM +│ └── manifest.webmanifest # PWA マニフェスト +└── docs/ + └── backend-integration.md # API統合ドキュメント +``` + +### 3.4 コントローラー階層 [確認済み] + +``` +CoreController (core-controller.ts) +├── セッション管理, TTS再生, Socket.IO STT, 多言語切替, ショップカード表示 +│ +├── ChatController (chat-controller.ts) +│ └── グルメモード。テキスト/音声チャット。モード切替トグル +│ +└── ConciergeController (concierge-controller.ts) + ├── LAMAvatar との TTS プレーヤー連携 + ├── applyExpressionFromTts() — 52次元ブレンドシェイプのキュー投入 + ├── speakResponseInChunks() — 文分割→並行TTS合成→順次再生 + ├── __testLipSync() — リップシンク診断テスト (ブラウザコンソール) + └── 無音検出タイムアウト: 8000ms (chatモードは4500ms) +``` + +### 3.5 全体アーキテクチャ図 + +``` +┌──────────────────────────────────────────────────────────────────────┐ +│ フロントエンド (gourmet-sp2) │ +│ Astro + TypeScript + Vercel (予定) │ +│ ┌────────────────┐ ┌──────────────┐ ┌──────────────────────────┐ │ +│ │ ModeRouter │ │ AudioIO │ │ AvatarRenderer │ │ +│ │ (モード切替) │ │ (WebAudio) │ │ (LAM WebGL / Three.js) │ │ +│ └──────┬─────────┘ └──────┬───────┘ └──────────┬───────────────┘ │ +│ │ │ │ │ +│ ┌──────┴───────────────────┴──────────────────────┴───────────────┐ │ +│ │ DialogueManager │ │ +│ │ ┌─── LiveAPIClient (WebSocket) ───┐ ┌── RESTClient (HTTP) ──┐│ │ +│ │ │ PCM 16kHz 送信 / 24kHz 受信 │ │ /api/v2/rest/* ││ │ +│ │ └────────────────────────────────┘ └────────────────────────┘│ │ +│ └──────────────────────┬─────────────────────────────────────────┘ │ +└─────────────────────────┼────────────────────────────────────────────┘ + │ WebSocket / HTTPS +┌─────────────────────────┼────────────────────────────────────────────┐ +│ バックエンド (support-base) Cloud Run │ +│ ┌──────────────────────┴────────────────────────────────────────┐ │ +│ │ FastAPI Gateway │ │ +│ │ WS /api/v2/live/{session_id} │ POST /api/v2/rest/* │ │ +│ └──────┬────────────────────────────────────────┬───────────────┘ │ +│ │ │ │ +│ ┌──────┴──────┐ ┌─────────────┐ ┌────────────┴──────┐ │ +│ │ LiveRelay │ │ SessionMgr │ │ REST Router │ │ +│ │ (WS中継) │ │ + Memory │ │ (gourmet互換) │ │ +│ └──────┬──────┘ └──────┬──────┘ └────────┬──────────┘ │ +│ │ │ │ │ +│ ┌──────┴────────────────┴───────────────────┴──────────────────┐ │ +│ │ 共通サービス層 │ │ +│ │ ModeRegistry │ SessionMemory │ LongTermMemory │ A2EClient │ │ +│ └──────────────────────────────────────────────────────────────┘ │ +└──────────────────────────────────────────────────────────────────────┘ + │ │ │ + ▼ ▼ ▼ +┌──────────────┐ ┌──────────────┐ ┌──────────────────┐ +│ Gemini │ │ Google Cloud │ │ audio2exp-service│ +│ Live API │ │ TTS / STT │ │ (Cloud Run) │ +│ (WS) │ │ │ │ Wav2Vec2→ARKit52 │ +└──────────────┘ └──────────────┘ └──────────────────┘ + │ + ┌──────┴──────┐ + │ GCS │ + │ (プロンプト) │ + └─────────────┘ + │ + ┌──────┴──────┐ + │ Supabase │ + │ (長期記憶) │ + └─────────────┘ +``` + +--- + +## 4. LiveAPI + REST API ハイブリッド仕様 + +### 4.1 対話経路の使い分け + +| 経路 | 用途 | LLMモデル | 音声生成 | +|------|------|----------|---------| +| **Live API** | 短文対話 (ヒアリング・相槌・確認) | `gemini-2.5-flash-native-audio-preview` | AIネイティブ音声 (TTS不要) | +| **REST API** | 長文生成 (検索結果・詳細説明・FAQ回答) | `gemini-2.5-flash` | Google Cloud TTS (Wavenet/Chirp3) | + +### 4.2 モード別対話方式 + +| モード | Live API | REST API | +|--------|---------|----------| +| **コンシェルジュ** (concierge) | 好みヒアリング、シーン確認、相槌 | ショップカード説明、詳細レビュー、エリア案内 | +| **グルメ** (chat) | 対話、質問応答、相槌 | 検索結果一覧、店舗詳細、おすすめ理由 | + +### 4.3 Live API 接続アーキテクチャ [確認済み] + +``` +ブラウザ バックエンド Gemini +┌────────┐ WebSocket ┌──────────────┐ WebSocket ┌──────────┐ +│ Audio │──────────────►│ LiveRelay │──────────────►│ Gemini │ +│ IO │ PCM 16kHz │ │ PCM 16kHz │ Live API │ +│ │◄──────────────│ │◄──────────────│ │ +│ │ PCM 24kHz │ │ PCM 24kHz │ │ +└────────┘ + transcript │ ┌─────────┐│ + transcript └──────────┘ + │ │Reconnect││ + │ │Manager ││ ← 累積文字数制限回避 + │ └─────────┘│ + │ ┌─────────┐│ + │ │Session ││ ← 短期記憶 (20ターン) + │ │Memory ││ + │ └─────────┘│ + │ ┌─────────┐│ + │ │A2E ││ ← 表情データ生成 + │ │Client ││ + │ └─────────┘│ + └──────────────┘ +``` + +### 4.4 WebSocket プロトコル [確認済み] + +**クライアント → サーバー:** + +| type | data | 説明 | +|------|------|------| +| `audio` | base64 PCM 16kHz | マイク音声チャンク | +| `text` | テキスト文字列 | テキスト入力 | +| `stop` | — | セッション終了 | + +**サーバー → クライアント:** + +| type | フィールド | 説明 | +|------|-----------|------| +| `audio` | `data`: base64 PCM 24kHz | AI音声チャンク | +| `transcription` | `role`: user/ai, `text`, `is_partial` | 音声テキスト化 | +| `expression` | `data`: {names, frames, frame_rate} | 52次元ARKitブレンドシェイプ | +| `interrupted` | — | ユーザー割り込み検知 | +| `reconnecting` | `reason` | 再接続開始通知 | +| `reconnected` | `session_count` | 再接続完了通知 | +| `error` | `message` | エラー通知 | + +### 4.5 FLASH版 累積文字数制限の回避 [確認済み] + +Gemini FLASH版 (`gemini-2.5-flash-native-audio-preview`) にはセッション内の累積トークン制限がある。`support_base/live/reconnect.py` の `ReconnectManager` で以下のロジックを実装済み: + +``` +AI発話のたびに文字数を累積カウント (ai_char_count) + │ + ├── 累積 800文字超過 → 再接続フラグ ON + │ (MAX_AI_CHARS_BEFORE_RECONNECT = 800) + │ + ├── 1回の発話が 500文字超 → 次のターン前に再接続 + │ (LONG_SPEECH_THRESHOLD = 500) + │ + ├── 発話が途中で切れた → 即時再接続 + │ (SpeechDetector.is_incomplete(): 多言語対応) + │ + └── API側エラー (1011/1008) → 3秒後に自動再接続 + +再接続時の処理 (LiveRelay._run_gemini_session): + 1. SessionMemory から直近10ターンの要約を生成 + 2. 新セッションの system_instruction に要約を注入 + 3. 「続きをお願いします」を送信して再開 + 4. ai_char_count をリセット +``` + +### 4.6 Live API 設定 [確認済み] + +```python +# support_base/live/relay.py _build_live_config() +config = { + "response_modalities": ["AUDIO"], + "system_instruction": <モードプラグインから取得>, + "input_audio_transcription": {}, + "output_audio_transcription": {}, + "speech_config": { + "language_code": , + }, + "realtime_input_config": { + "automatic_activity_detection": { + "disabled": False, + "start_of_speech_sensitivity": "START_SENSITIVITY_HIGH", + "end_of_speech_sensitivity": "END_SENSITIVITY_HIGH", + "prefix_padding_ms": 100, + "silence_duration_ms": 500, + } + }, + "context_window_compression": { + "sliding_window": { "target_tokens": 32000 } + }, +} +``` + +--- + +## 5. プロンプト外部管理 (GCS) + +### 5.1 GCS ストレージ構成 [確認済み] + +``` +gs://{PROMPTS_BUCKET_NAME}/ +└── prompts/ + ├── support_system_ja.txt # グルメ(chat)モード 日本語 + ├── support_system_en.txt # グルメ(chat)モード 英語 + ├── support_system_zh.txt # グルメ(chat)モード 中国語 + ├── support_system_ko.txt # グルメ(chat)モード 韓国語 + ├── concierge_ja.txt # コンシェルジュモード 日本語 + ├── concierge_en.txt # コンシェルジュモード 英語 + ├── concierge_zh.txt # コンシェルジュモード 中国語 + └── concierge_ko.txt # コンシェルジュモード 韓国語 +``` + +### 5.2 読み込み優先度 [確認済み] + +``` +1. GCS (PROMPTS_BUCKET_NAME が設定されている場合) + ↓ 失敗時 +2. ローカルファイル (prompts/ ディレクトリ) + ↓ 失敗時 +3. ハードコードのフォールバック (GourmetModePlugin._fallback_prompt()) +``` + +**実装箇所**: `support_base/core/support_core.py` の `load_system_prompts()` + +### 5.3 プロンプト利用経路 + +| 経路 | プロンプト取得元 | 利用モジュール | +|------|----------------|---------------| +| **Live API** | `GourmetModePlugin.get_system_prompt()` → `LOADED_PROMPTS` (GCS/ローカル) | `LiveRelay._build_live_config()` | +| **REST API** | `SupportAssistant.__init__()` → `SYSTEM_PROMPTS` (GCS/ローカル) | `rest/router.py` の各エンドポイント | + +### 5.4 プラットフォーム化での拡張方針 + +新モード追加時は以下のみ: + +1. GCS に `prompts/{mode_name}_{lang}.txt` を追加 +2. `modes/{mode_name}/plugin.py` を作成し、`BaseModePlugin` を継承 +3. `server.py` で `mode_registry.register()` に登録 + +--- + +## 6. 記憶機能 (短期・長期) + +### 6.1 短期記憶 [確認済み] + +**クラス**: `support_base/memory/session_memory.py` の `SessionMemory` + +| 項目 | 仕様 | +|------|------| +| **ストレージ** | インメモリ (セッション単位) | +| **保持ターン数** | 直近 20 ターン | +| **データ構造** | `[{role: str, text: str, timestamp: str}]` | +| **コンテキスト要約** | 直近 10 ターン、各150文字上限 | +| **用途** | Live API 再接続時のコンテキスト引き継ぎ | + +**主要メソッド:** + +| メソッド | 説明 | +|---------|------| +| `add(role, text)` | 会話ターンを追加 (20ターン超は古い方を削除) | +| `get_context_summary()` | 再接続用の会話要約を生成 | +| `get_last_user_message()` | 直前のユーザー発言を取得 | + +### 6.2 長期記憶 [確認済み] + +**クラス**: `support_base/core/long_term_memory.py` の `LongTermMemory` + +| 項目 | 仕様 | +|------|------| +| **ストレージ** | **Supabase** (`user_profiles` テーブル) | +| **キー** | `user_id` (PRIMARY KEY) | +| **スキーマ** | preferred_name, name_honorific, visit_count, conversation_summary, default_language, preferred_mode, first_visit_at, last_visit_at | +| **用途** | ユーザーパーソナライゼーション (名前での呼びかけ、訪問回数、過去の会話記録) | + +**主要メソッド:** + +| メソッド | 説明 | +|---------|------| +| `get_profile_basic(user_id)` | 軽量プロファイル取得 (名前・訪問回数) | +| `update_profile(user_id, updates)` | UPSERT (存在すれば更新、なければ新規作成) | +| `increment_visit_count(user_id)` | 訪問回数インクリメント | +| `append_conversation_summary(user_id, summary)` | 会話サマリー追記 | +| `generate_system_prompt_context(user_id, language)` | システムプロンプト注入用コンテキスト生成 | + +### 6.3 記憶の連携フロー + +``` +セッション開始 + │ + ├── [長期記憶] user_id でプロファイル取得 + │ ├── リピーター → 名前で呼びかけ + 訪問回数表示 + │ └── 新規 → 名前を聞く + │ + ├── [短期記憶] SessionMemory 初期化 + │ + ├── Live API 対話中 + │ ├── [短期記憶] 各ターンを add() + │ ├── 再接続時 → get_context_summary() で文脈引き継ぎ + │ └── LLM action → [長期記憶] update_profile() + │ + └── セッション終了 + └── [長期記憶] append_conversation_summary() +``` + +--- + +## 7. LLM 検索機能 SDK 対応 + +### 7.1 Google Search Grounding [確認済み] + +REST API 経路では Gemini の Google Search Grounding を有効化。 + +**実装箇所**: `support_base/core/support_core.py` の `SupportAssistant.process_user_message()` + +```python +# フォローアップ質問でない場合、Google検索を有効化 +tools = [types.Tool(google_search=types.GoogleSearch())] + +config = types.GenerateContentConfig( + system_instruction=system_prompt, + tools=tools, +) + +response = gemini_client.models.generate_content( + model="gemini-2.5-flash", + contents=history, + config=config, +) +``` + +### 7.2 検索利用シーン + +| シーン | 検索利用 | 理由 | +|--------|---------|------| +| 初回質問 (店舗検索) | **有効** | 最新の店舗情報・レビューを取得 | +| フォローアップ質問 | **無効** | 既に提案済みの店舗情報を参照して回答 | +| Live API 経路 | **将来対応** | Live API の Function Calling で対応予定 | + +### 7.3 Live API でのツール定義 + +`BaseModePlugin.get_live_api_tools()` で各モードがツール定義を返す。現状は空リスト(将来拡張ポイント)。 + +--- + +## 8. 多言語対応 + +### 8.1 言語マスター設定 [確認済み] + +**ファイル**: `support_base/i18n/language_config.py` + +| 言語 | コード | TTS | Live API | 文分割 | +|------|--------|-----|----------|--------| +| 日本語 | `ja` | `ja-JP` / `ja-JP-Wavenet-D` | `ja-JP` | CJK (`。`) | +| 英語 | `en` | `en-US` / `en-US-Wavenet-D` | `en-US` | Latin (`. `) | +| 韓国語 | `ko` | `ko-KR` / `ko-KR-Wavenet-D` | `ko-KR` | Latin (`. `) | +| 中国語 | `zh` | `cmn-CN` / `cmn-CN-Wavenet-D` | `cmn-CN` | CJK (`。`) | + +### 8.2 多言語対応箇所 + +| 対応箇所 | 実装状態 | ファイル | +|---------|---------|---------| +| プロンプト (GCS) | [確認済み] | `core/support_core.py` | +| 初回挨拶 | [確認済み] | `modes/gourmet/plugin.py`, `core/support_core.py` | +| 発話途切れ検知 | [確認済み] ja/en/ko/zh | `live/speech_detector.py` | +| TTS 言語・音声選択 | [確認済み] | `i18n/language_config.py` | +| Live API speech_config | [確認済み] | `live/relay.py` | +| REST API レスポンスメッセージ | [確認済み] | `rest/router.py` | +| 長期記憶コンテキスト生成 | [確認済み] ja/en/ko/zh | `core/long_term_memory.py` | +| 会話要約テンプレート | [確認済み] ja/en/ko/zh | `core/support_core.py` | + +### 8.3 新言語追加手順 + +1. `i18n/language_config.py` の `LANGUAGE_PROFILES` に `LanguageProfile` を追加 +2. `live/speech_detector.py` の `RULES` に発話途切れパターンを追加 +3. GCS に `prompts/{mode}_{lang}.txt` を追加 +4. `core/support_core.py` の各テンプレート辞書に言語エントリを追加 + +--- + +## 9. マルチデバイス対応 + +### 9.1 対象デバイス + +| デバイス | OS | ブラウザ | 備考 | +|---------|-----|---------|------| +| iPhone (SE以降) | iOS | Safari | **最小動作基準** | +| Android スマホ | Android 10+ | Chrome | | +| PC | Windows/Mac | Chrome / Safari / Firefox | | + +### 9.2 フロントエンド技術スタック [確認済み — gourmet-sp2] + +| 技術 | 用途 | 状態 | +|------|------|------| +| **Astro 4.0** | SSG フレームワーク (`output: 'static'`) | 実装済 | +| **TypeScript** | アプリロジック | 実装済 | +| **Three.js** (v0.182) | 3D レンダリング | 実装済 | +| **gaussian-splat-renderer-for-lam** (v0.0.9-alpha) | LAM アバター SDK | 実装済 | +| **onnxruntime-web** (v1.23) | ニューラルネット推論 (DINOv2等) | 実装済 | +| **Socket.IO** | STT ストリーミング | 実装済 | +| **WebSocket API** | Live API 中継接続 | **未実装 (今回追加)** | +| **Web Audio API** | マイク入力 (AudioWorklet → PCM 16kHz) + 音声再生 | 実装済 | +| **WebGL 2.0** | Gaussian Splatting レンダリング | 実装済 | +| **PWA** | ホーム画面追加 (iOS手動ガイド / Android自動プロンプト) | 実装済 | + +**主要依存パッケージ (package.json):** +```json +{ + "@huggingface/transformers": "^3.8.1", + "@mkkellogg/gaussian-splats-3d": "^0.4.7", + "gaussian-splat-renderer-for-lam": "^0.0.9-alpha.1", + "gsplat": "^1.2.9", + "onnxruntime-web": "^1.23.2", + "three": "^0.182.0", + "@vite-pwa/astro": "^1.2.0", + "astro": "^4.0.0" +} +``` + +### 9.3 Vercel デプロイ設定 [要実施] + +`gourmet-sp2` は現在ローカルホストでテスト中。スマホテストのため Vercel 連携を新規設定する。 + +**必要な設定:** + +| 項目 | 設定内容 | +|------|---------| +| **Framework** | Astro | +| **Build Command** | `npm run build` | +| **Output Directory** | `dist/` | +| **Node.js** | 18.x 以上 | +| **環境変数** | `PUBLIC_API_URL` = Cloud Run バックエンド URL | +| **カスタムヘッダー** | `Cross-Origin-Embedder-Policy: require-corp`
`Cross-Origin-Opener-Policy: same-origin`
(WebAssembly ONNX Runtime 用, astro.config.mjs で設定済み) | + +**Vercel 連携手順:** + +``` +1. Vercel ダッシュボード → New Project → Import Git Repository + https://github.com/mirai-gpro/gourmet-sp2 + +2. Framework Preset: Astro を選択 + +3. 環境変数を設定: + PUBLIC_API_URL = https://.run.app + +4. Deploy +``` + +### 9.4 モバイル固有の考慮事項 [確認済み — gourmet-sp2 で対応済] + +| 項目 | 対策 | 実装状態 | +|------|------|---------| +| **マイク権限** | ユーザー操作起点で `getUserMedia()` を呼ぶ (iOS Safari 制約) | 実装済 | +| **AudioContext** | ユーザー操作後に `resume()` (iOS Safari autoplay policy) | 実装済 | +| **iOS AudioWorklet** | iOS専用パス: 8192バッファ, 500msフラッシュ, サーバー待機最大500ms | 実装済 | +| **Android AudioWorklet** | デフォルトパス: 16000バッファ, サーバー待機最大700ms, VAD チェック100ms毎 | 実装済 | +| **バックグラウンド復帰** | 120秒以上バックグラウンド → ソフトリセット | 実装済 | +| **メモリ制約** | iPhone SE (3-4GB RAM) でのアバターレンダリング最適化 | **要実機テスト** | +| **PWA インストール** | iOS: 3ステップ手動ガイド, Android: `beforeinstallprompt` 自動プロンプト | 実装済 | + +--- + +## 10. リップシンク実写アバター + +### 10.1 実装状態 [確認済み — gourmet-sp2 でテスト完了] + +**gourmet-sp2 のコンシェルジュモード (`/concierge`) でリップシンクアバターは実装・テスト済み。** + +### 10.2 パイプライン概要 + +``` +1枚の顔写真 + ↓ [オフライン] LAM (Large Avatar Model) — HF Spaces / ModelScope +3D Gaussian Splatting アバター (skin.glb + offset.ply + animation.glb) + ↓ [アプリ起動時] GaussianSplatRenderer でロード + ↓ +AI音声出力 (TTS / Live API) + ↓ [サーバー] audio2exp-service (Cloud Run, 4Gi) + │ Wav2Vec2 (95M params) → A2E Decoder + ↓ +52次元 ARKit ブレンドシェイプ @30fps + ↓ [クライアント] LAMAvatar.astro + │ frameBuffer にキュー → ttsPlayer.currentTime 同期 + │ 30fps → 60fps フレーム補間 + ↓ +Gaussian Splatting WebGL レンダリング + ↓ カメラ: pos(0, 1.72, 0.55), FOV 38°, target(0, 1.66, 0) +リアルタイムリップシンク + 表情アニメーション +``` + +### 10.3 LAMAvatar コンポーネント [確認済み — gourmet-sp2] + +**ファイル**: `gourmet-sp2/src/components/LAMAvatar.astro` + +| 機能 | 詳細 | +|------|------| +| **SDK** | `gaussian-splat-renderer-for-lam` (v0.0.9-alpha) | +| **フレームバッファ** | Expression frames を時系列でキュー管理 | +| **TTS同期** | `ttsPlayer.currentTime` (ms) → `frameBuffer[frameIndex]` | +| **フレーム補間** | 30fps A2E → 60fps レンダリング (スムーズ化) | +| **フェードイン/アウト** | 200ms スムーズトランジション | +| **ブレンドシェイプ増幅** | 口元の動きをスケーリングして視認性を向上 | +| **FLAME LBS制約** | 値を 0.7 でクランプ (数値安定性) | +| **フォールバック** | SDK ロード失敗時 → 静止画表示 | + +### 10.4 A2E サービス仕様 [確認済み] + +**デプロイ**: Cloud Run (us-central1), CPU, メモリ 4Gi + +``` +POST /api/audio2expression +Request: { audio_base64: string, session_id: string, audio_format: "mp3"|"wav"|"pcm" } +Response: { names: string[52], frames: number[N][52], frame_rate: 30 } + +GET /health +Response: { status: "healthy", engine_ready: bool, mode: "infer"|"fallback", device: "cpu" } +``` + +### 10.5 表情データの伝送経路 + +| 経路 | トリガー | A2E入力 | Expression送信先 | +|------|---------|---------|-----------------| +| **Live API** | AIターン完了時 | PCM 24kHz (ai_audio_buffer) | WebSocket `{"type": "expression"}` | +| **REST API** | TTS合成時 | MP3 base64 | REST レスポンス `{expression: {...}}` | + +### 10.6 TTS + A2E 同期フロー [確認済み — gourmet-sp2] + +``` +ConciergeController.speakResponseInChunks(response) + ↓ 文分割 (。 or .) + ↓ 各文を並行 TTS 合成 +POST /api/tts/synthesize { text, language_code, voice_name, session_id } + ↓ バックエンド + ├── Google Cloud TTS → MP3 base64 + └── audio2exp-service → { names[52], frames[N][52], frame_rate: 30 } + ↓ レスポンス +ConciergeController.applyExpressionFromTts(expression) + ↓ フレーム変換: {names, frames[{weights}]} → {name: weight}[] + ↓ lamController.clearFrameBuffer() + ↓ lamController.queueExpressionFrames(frames, 30) + ↓ +ttsPlayer.play() → 'play' イベント発火 + ↓ LAMAvatar 内部 +getExpressionData() [16ms間隔, ~60fps] + ↓ frameIndex = ttsPlayer.currentTime × frame_rate + ↓ frameBuffer[frameIndex] から 52次元係数を読出 + ↓ Gaussian Splatting レンダラーに適用 + ↓ +ttsPlayer.ended → フェードアウト (200ms) → アイドル状態 +``` + +### 10.7 GS レンダリング (gs.ts / gvrm.ts) [確認済み — gourmet-sp2] + +**gs.ts — Gaussian Splatting ビューアー:** +- PLY アバターメッシュのロード +- 頂点シェーダー: LBS (Linear Blend Skinning) + ボーンマトリクス +- Jaw アニメーション: 口の開閉制御 +- インスタンスドレンダリング: パフォーマンス最適化 +- Sigmoid 活性化: 不透明度のリアルな合成 + +**gvrm.ts — GVRM アバターシステム:** +- Three.js シーン管理 +- ボーンテクスチャ (64マトリクス × 4×4) +- `updateLipSync(level)` — jawOpen の直接制御 +- `setPose(matrices)` — 全スケルトンポーズの適用 + +### 10.8 リップシンク診断テスト [確認済み — gourmet-sp2] + +ブラウザコンソールから実行可能: + +```javascript +// コンシェルジュページ (/concierge) で: +__testLipSync() + +// 5つの日本語母音 (あいうえお) を順次合成 +// 各母音の既知ブレンドシェイプパターンで検証: +// あ: jawOpen高, mouthSmile低 +// い: jawOpen低, mouthSmile高 +// う: mouthFunnel高, mouthPucker高 +// え: jawOpen中, mouthSmile中 +// お: jawOpen高, mouthFunnel高 +``` + +### 10.9 スマホ軽量化戦略 + +| 項目 | 方針 | 実装状態 | +|------|------|---------| +| **A2E推論** | サーバー側 (CPU) で実行。52次元係数のみ送信 (~10KB/sec) | 実装済 | +| **レンダリング** | クライアント側 WebGL。LAM Gaussian Splatting SDK | 実装済 | +| **フォールバック** | SDK ロード失敗時 → 静止画表示 | 実装済 | +| **動作基準** | iPhone SE (A13/A15, 3-4GB RAM) で 30fps | **要実機テスト** | + +--- + +## 11. API 仕様 + +### 11.1 エンドポイント一覧 [確認済み] + +#### Live API 系 + +| メソッド | パス | 説明 | +|---------|------|------| +| POST | `/api/v2/session/start` | セッション開始 (Live API用) | +| POST | `/api/v2/session/end` | セッション終了 | +| WS | `/api/v2/live/{session_id}` | Live API WebSocket 中継 | +| GET | `/api/v2/modes` | 利用可能モード一覧 | +| GET | `/api/v2/health` | ヘルスチェック | + +#### REST API 系 (gourmet-support 互換) + +| メソッド | パス | 説明 | +|---------|------|------| +| POST | `/api/v2/rest/session/start` | REST セッション開始 | +| POST | `/api/v2/rest/chat` | チャット処理 (Gemini + Google Search) | +| POST | `/api/v2/rest/finalize` | セッション完了 (最終要約生成) | +| POST | `/api/v2/rest/cancel` | 処理中止 | +| POST | `/api/v2/rest/tts/synthesize` | TTS合成 + A2E表情データ | +| POST | `/api/v2/rest/stt/transcribe` | 音声認識 (単発) | +| POST | `/api/v2/rest/stt/stream` | 音声認識 (ストリーミング) | +| GET | `/api/v2/rest/session/{id}` | セッション情報取得 | + +### 11.2 セッション開始 API + +**POST `/api/v2/session/start`** + +```json +// Request +{ + "mode": "gourmet", // "gourmet" | "concierge" | (将来の新モード) + "language": "ja", // "ja" | "en" | "ko" | "zh" + "dialogue_type": "live", // "live" | "rest" | "hybrid" + "user_id": "uuid-string" // 長期記憶用 (オプション) +} + +// Response +{ + "session_id": "sess_xxxxxxxxxxxx", + "mode": "gourmet", + "language": "ja", + "dialogue_type": "live", + "greeting": "いらっしゃいませ!今日はどんなお食事をお探しですか?", + "ws_url": "/api/v2/live/sess_xxxxxxxxxxxx" +} +``` + +### 11.3 REST チャット API + +**POST `/api/v2/rest/chat`** + +```json +// Request +{ + "session_id": "sess_xxxxxxxxxxxx", + "message": "新宿で美味しいイタリアン", + "stage": "conversation", + "language": "ja", + "mode": "chat" +} + +// Response +{ + "response": "ご希望に合うお店を3件ご紹介します。...", + "summary": "3軒のお店を提案しました。", + "shops": [ + { + "name": "トラットリア XX", + "area": "新宿", + "description": "..." + } + ], + "should_confirm": true, + "is_followup": false +} +``` + +### 11.4 TTS + A2E API + +**POST `/api/v2/rest/tts/synthesize`** + +```json +// Request +{ + "text": "こんにちは、お元気ですか?", + "language_code": "ja-JP", + "voice_name": "ja-JP-Chirp3-HD-Leda", + "speaking_rate": 1.0, + "pitch": 0.0, + "session_id": "sess_xxxxxxxxxxxx" +} + +// Response +{ + "success": true, + "audio": "", + "expression": { + "names": ["eyeBlinkLeft", ..., "tongueOut"], // 52個 + "frames": [[0.0, ...], [0.1, ...], ...], // N×52 + "frame_rate": 30 + } +} +``` + +--- + +## 12. データフロー + +### 12.1 Live API 経路 (コンシェルジュ/グルメ — ヒアリング・対話) + +``` +┌──────────────────────────────────────────────────────────────────┐ +│ Phase 1: マイク入力 → Live API │ +├──────────────────────────────────────────────────────────────────┤ +│ マイクタップ → getUserMedia() │ +│ ↓ AudioWorkletProcessor │ +│ 48kHz/44.1kHz → 16kHz Int16 PCM │ +│ ↓ base64 │ +│ WebSocket send: {"type": "audio", "data": ""} │ +│ ↓ LiveRelay │ +│ Gemini Live API に PCM 16kHz 転送 │ +└──────────────────────────────────────────────────────────────────┘ + ↓ +┌──────────────────────────────────────────────────────────────────┐ +│ Phase 2: Gemini AI 応答 → ブラウザ │ +├──────────────────────────────────────────────────────────────────┤ +│ Gemini → PCM 24kHz 音声 + transcription │ +│ ↓ LiveRelay │ +│ WebSocket send: {"type": "audio"} + {"type": "transcription"} │ +│ ↓ ブラウザ │ +│ AudioContext で PCM 24kHz を再生 │ +│ transcription をチャットUIに表示 │ +└──────────────────────────────────────────────────────────────────┘ + ↓ +┌──────────────────────────────────────────────────────────────────┐ +│ Phase 3: A2E → アバターアニメーション │ +├──────────────────────────────────────────────────────────────────┤ +│ AI音声バッファ (ai_audio_buffer) │ +│ ↓ ターン完了時 │ +│ A2EClient.process_audio() → audio2exp-service │ +│ ↓ 52次元 ARKit ブレンドシェイプ @30fps │ +│ WebSocket send: {"type": "expression"} │ +│ ↓ ブラウザ │ +│ LAMAvatarController.queueExpressionFrames() │ +│ ↓ 音声再生と同期 │ +│ WebGL アバター リップシンク + 表情アニメーション │ +└──────────────────────────────────────────────────────────────────┘ +``` + +### 12.2 REST API 経路 (店舗説明・詳細レビュー) + +``` +┌──────────────────────────────────────────────────────────────────┐ +│ Phase 1: テキスト入力 → LLM 応答 │ +├──────────────────────────────────────────────────────────────────┤ +│ POST /api/v2/rest/chat │ +│ ↓ SupportAssistant.process_user_message() │ +│ Gemini 2.5 Flash + Google Search Grounding │ +│ ↓ JSON パース (message, shops, action) │ +│ shops → HotPepper / Google Places でエンリッチ │ +│ ↓ │ +│ Response: {response, shops, summary} │ +└──────────────────────────────────────────────────────────────────┘ + ↓ +┌──────────────────────────────────────────────────────────────────┐ +│ Phase 2: TTS + A2E → アバター応答 │ +├──────────────────────────────────────────────────────────────────┤ +│ 文分割 (。 or .) → 並行 TTS 合成 │ +│ POST /api/v2/rest/tts/synthesize (文ごと) │ +│ ↓ Google Cloud TTS → MP3 │ +│ ↓ audio2exp-service → 52次元ブレンドシェイプ │ +│ Response: {audio: base64, expression: {names, frames}} │ +│ ↓ ブラウザ │ +│ 順次再生: 文1 → 文2 → ... + Expression 同期適用 │ +│ ↓ │ +│ WebGL アバター リップシンク + 表情アニメーション │ +└──────────────────────────────────────────────────────────────────┘ +``` + +--- + +## 13. 実装ステータスと残作業 + +### 13.1 バックエンド (support-base) 実装ステータス + +| 機能 | ステータス | ファイル | +|------|-----------|---------| +| FastAPI サーバー | **実装済み** | `server.py` | +| Live API WebSocket 中継 | **実装済み** | `live/relay.py` | +| 累積文字数制限 回避 | **実装済み** | `live/reconnect.py` | +| 発話途切れ検知 (多言語) | **実装済み** | `live/speech_detector.py` | +| 短期記憶 (SessionMemory) | **実装済み** | `memory/session_memory.py` | +| 長期記憶 (Supabase) | **実装済み** | `core/long_term_memory.py` | +| GCS プロンプト読み込み | **実装済み** | `core/support_core.py` | +| REST API ルーター | **実装済み** | `rest/router.py` | +| モードプラグインアーキテクチャ | **実装済み** | `modes/base_mode.py`, `modes/registry.py` | +| グルメモード プラグイン | **実装済み** | `modes/gourmet/plugin.py` | +| A2E クライアント | **実装済み** | `services/a2e_client.py` | +| 言語マスター設定 | **実装済み** | `i18n/language_config.py` | +| Google Search Grounding | **実装済み** | `core/support_core.py` | +| A2E Live API 連携 (Expression WS送信) | **実装済み** | `live/relay.py` | +| セッション管理 | **実装済み** | `session/manager.py` | + +### 13.2 フロントエンド (gourmet-sp2) 実装ステータス + +| 機能 | ステータス | ファイル | +|------|-----------|---------| +| Astro SSG + PWA | **実装済み** | `astro.config.mjs`, `manifest.webmanifest` | +| CoreController (基底) | **実装済み** | `src/scripts/chat/core-controller.ts` | +| ChatController (グルメモード) | **実装済み** | `src/scripts/chat/chat-controller.ts` | +| ConciergeController (コンシェルジュ) | **実装済み** | `src/scripts/chat/concierge-controller.ts` | +| AudioManager (iOS/Android/PC対応) | **実装済み** | `src/scripts/chat/audio-manager.ts` | +| LAMAvatar (Gaussian Splatting) | **実装済み** | `src/components/LAMAvatar.astro` | +| GS レンダラー (Three.js + LBS) | **実装済み** | `gs.ts`, `gvrm.ts` | +| A2E 統合 (TTS同期リップシンク) | **実装済み** | `concierge-controller.ts` | +| リップシンク診断テスト | **実装済み** | `__testLipSync()` | +| 多言語 UI (ja/en/zh/ko) | **実装済み** | `src/constants/i18n.ts` | +| Socket.IO STT ストリーミング | **実装済み** | `audio-manager.ts` | +| ショップカード + 予約モーダル | **実装済み** | `ShopCardList.astro`, `ReservationModal.astro` | +| PWA インストールプロンプト | **実装済み** | `InstallPrompt.astro` | +| REST API 連携 (/api/chat, /api/tts) | **実装済み** | `core-controller.ts`, `concierge-controller.ts` | + +### 13.3 フロントエンド (gourmet-sp2) 残作業 + +| タスク | 優先度 | 説明 | +|--------|--------|------| +| **Vercel 連携** | **P1** | gourmet-sp2 を Vercel に接続。スマホテストに必須 | +| **LiveAPIClient 実装** | **P1** | WebSocket 接続 (`/api/v2/live/{session_id}`)、PCM 16kHz 送信 / 24kHz 受信 | +| **Live API 音声再生** | **P1** | PCM 24kHz → AudioContext で再生 (現行は MP3 TTS 再生のみ) | +| **DialogueManager 実装** | **P1** | Live API / REST API のモード別切替管理 | +| **Expression WS 受信** | **P2** | `{"type": "expression"}` → LAMAvatar.frameBuffer にキュー | +| **Transcription 表示** | **P2** | `{"type": "transcription"}` → チャット UI にリアルタイム表示 | +| **割り込み (barge-in) UI** | **P2** | `{"type": "interrupted"}` → TTS停止 + アバター即停止 | +| **再接続 UI** | **P3** | `reconnecting` / `reconnected` のインジケータ表示 | +| **API エンドポイント切替** | **P1** | 既存 `/api/*` → 新 `/api/v2/*` (support-base) への接続先変更 | + +### 13.4 インフラ・デプロイ残作業 + +| タスク | 優先度 | 説明 | +|--------|--------|------| +| **gourmet-sp2 Vercel デプロイ** | **P1** | GitHub 連携 + 環境変数 (PUBLIC_API_URL) 設定 | +| support-base Cloud Run デプロイ | **P1** | FastAPI + uvicorn、WebSocket 対応 | +| GCS プロンプトバケット設定 | **P1** | 4言語 × 2モードのプロンプトファイル配置 | +| Supabase user_profiles テーブル | **P1** | スキーマ作成、RLS ポリシー設定 | +| 環境変数設定 | **P1** | GEMINI_API_KEY, PROMPTS_BUCKET_NAME, SUPABASE_URL/KEY 等 | +| iPhone SE 実機テスト | **P2** | LAM WebGL SDK の 30fps 動作検証 (Vercel デプロイ後) | + +### 13.5 未確認事項・リスク + +| # | 項目 | 影響度 | ステータス | +|---|------|--------|-----------| +| 1 | iPhone SE で Gaussian Splatting が 30fps 出るか | **致命的** | **未検証** | +| 2 | WebSocket 中継 (ブラウザ→サーバー→Gemini) のレイテンシ | **高** | **未検証** | +| 3 | Live API の Function Calling (店舗検索等) | **中** | **未実装** | +| 4 | ハイブリッド方式での Live→REST 切替トリガー | **中** | **未設計** | +| 5 | PWA 対応 | **低** | **実装済み** (gourmet-sp2 で PWA 対応済) | + +--- + +## 付録A: 環境変数一覧 + +| 変数名 | 必須 | 説明 | デフォルト | +|--------|------|------|-----------| +| `GEMINI_API_KEY` | Yes | Gemini API キー | — | +| `PROMPTS_BUCKET_NAME` | No | GCS プロンプトバケット名 | — (ローカルファイル使用) | +| `A2E_SERVICE_URL` | No | audio2exp-service URL | — (Expression 無効) | +| `GCP_PROJECT_ID` | No | GCP プロジェクトID (TTS/STT用) | — | +| `SUPABASE_URL` | No | Supabase プロジェクト URL | — (長期記憶無効) | +| `SUPABASE_KEY` | No | Supabase Anon Key | — | +| `HOTPEPPER_API_KEY` | No | HotPepper API キー | — | +| `GOOGLE_PLACES_API_KEY` | No | Google Places API キー | — | +| `TRIPADVISOR_API_KEY` | No | TripAdvisor API キー | — | +| `HOST` | No | サーバーホスト | `0.0.0.0` | +| `PORT` | No | サーバーポート | `8080` | +| `CORS_ORIGINS` | No | CORS 許可オリジン (カンマ区切り) | `*` | +| `LEGACY_BACKEND_URL` | No | 既存 gourmet-support URL (プロキシ用) | — | + +**フロントエンド (gourmet-sp2) 環境変数:** + +| 変数名 | 必須 | 説明 | デフォルト | +|--------|------|------|-----------| +| `PUBLIC_API_URL` | Yes | バックエンド (support-base) の URL | — | + +## 付録B: モードプラグイン追加手順 + +新しいモード(例: カスタマーサポート)を追加する場合: + +``` +1. GCS にプロンプトを追加 + gs://{BUCKET}/prompts/customer_support_ja.txt + gs://{BUCKET}/prompts/customer_support_en.txt + ... + +2. モードプラグインを作成 + support_base/modes/customer_support/ + ├── __init__.py + └── plugin.py # BaseModePlugin を継承 + +3. server.py に登録 + from support_base.modes.customer_support.plugin import CustomerSupportPlugin + mode_registry.register(CustomerSupportPlugin()) + +4. (オプション) フロントエンドにモード固有UIを追加 + gourmet-sp2/src/pages/customer-support.astro +``` + +以上の手順で、コア基盤のコードを変更することなく新モードを追加できる。 diff --git a/docs/SESSION_HANDOFF.md b/docs/SESSION_HANDOFF.md new file mode 100644 index 0000000..b5b7b55 --- /dev/null +++ b/docs/SESSION_HANDOFF.md @@ -0,0 +1,377 @@ +# セッション引き継ぎドキュメント + +> **作成日**: 2026-02-22 +> **対象セッション**: claude/test-a2e-japanese-audio-j9VBT +> **作成経緯**: 20+セッションでの作業蓄積を次セッションに引き継ぐため + +--- + +## 0. オーナーの真のゴール(最重要 — 必ず最初に読め) + +**論文超えクオリティの3D対話アバターを、バックエンドGPUなしで、iPhone SE単体で軽く動かす。即実用のアルファ版。** + +| # | 要件 | 詳細 | +|---|------|------| +| 1 | **論文超えの自然さ** | 口元だけでなく、表情・頭の動き・セリフとの連動が自然。低遅延 | +| 2 | **スマホ単体完結** | バックエンドGPU一切不要。推論もレンダリングも全てオンデバイス | +| 3 | **iPhone SEで軽く動く** | 最も制約の厳しいデバイスが動作基準 | +| 4 | **技術スタックに固執しない** | 動くものを即テスト→見極め→次へ。理論より実証 | + +### 過去セッションの反省(次のAIへの警告) + +- **論文を読め。上辺の字面を舐めて古い知識で推論するな。** LAMの論文(arXiv:2502.17796, SIGGRAPH 2025)とWebGL SDKは2025年5月以降の最新技術。Claudeの学習データにない内容が多い。 +- **「検証」や「調査」をゴールにするな。** オーナーのゴールは動くプロダクト。検証はゴールへの通過点に過ぎない。 +- **冗長な説明をするな。** オーナーは技術に精通している。わかりきったことの長い説明は不要。 +- **推測で回答するな。** 知らないなら「知らない、今から調べる」と言え。 + +--- + +## 1. LAM とは何か(公式情報ベース) + +**LAM (Large Avatar Model)** — SIGGRAPH 2025, Alibaba Tongyi Lab + +> "Build 3D Interactive Chatting Avatar with One Image in Seconds!" + +### 1.1 公式エコシステム + +| コンポーネント | 説明 | リポジトリ | +|--------------|------|-----------| +| **LAM本体** | 写真1枚 → 81,424個の3D Gaussian Head Avatar (1.4秒) | [aigc3d/LAM](https://github.com/aigc3d/LAM) | +| **LAM-A2E** | 音声 → 52次元ARKitブレンドシェイプ (リアルタイム) | [aigc3d/LAM_Audio2Expression](https://github.com/aigc3d/LAM_Audio2Expression) | +| **LAM_WebRender** | WebGL 2.0 Gaussian Splatting レンダラー (npmパッケージ) | [aigc3d/LAM_WebRender](https://github.com/aigc3d/LAM_WebRender) | +| **OpenAvatarChat** | LLM + ASR + TTS + Avatar 対話SDK | [HumanAIGC-Engineering/OpenAvatarChat](https://github.com/HumanAIGC-Engineering/OpenAvatarChat) | +| **PanoLAM** | LAMの拡張 (coarse-to-fine, synthetic training data) | arXiv:2509.07552 | + +### 1.2 論文の核心技術 + +**アバター生成 (サーバー側1回のみ)**: +- 入力: 顔写真1枚 +- FlameTracking → DINOv2マルチスケール特徴 → Transformer → canonical Gaussian属性生成 +- FLAME canonical点(5,023頂点 → 2回サブディバイド → 81,424 Gaussian)をクエリとして使用 +- 出力: position, opacity, rotation, scale, SH色係数 + +**アニメーション (クライアント側、毎フレーム)**: +- **ニューラルネットワーク不要** — 純粋な行列演算 +- `T_G(θ,φ) = G_bar + B_P(θ;P) + B_E(φ;E)` +- `Animated_G = S(T_G, J_bar, θ, W)` (標準Linear Blend Skinning) +- 52次元ARKitブレンドシェイプ係数で表情駆動 +- FLAME準拠のpose blendshapes + expression blendshapes + LBS + +**WebGLレンダリング (クライアント側)**: +- **Pass 1**: Transform Feedback — ブレンドシェイプ係数+LBSウェイトをGPUテクスチャに格納、頂点シェーダーで全Gaussianを変形 +- **Pass 2**: Gaussian Splatting — 変形済みGaussianをスクリーンに投影、α合成 +- npmパッケージ `gaussian-splat-renderer-for-lam` (クローズドソース) + +**公式ベンチマーク**: + +| デバイス | FPS | +|---------|-----| +| A100 (サーバー) | 280.96 | +| MacBook M1 Pro | 120 | +| iPhone 16 | 35 | +| Xiaomi 14 | 26 | + +### 1.3 重要な認識ギャップ + +過去セッションで誤認していた点: +- ❌ 「LAMはサーバーGPU前提」→ ⭕ **アバター生成だけがGPU。アニメーション+レンダリングはWebGL SDKでスマホ完結** +- ❌ 「Gaussian SplattingはiPhoneで動かない」→ ⭕ **iPhone 16で35FPS実証済み** (iPhone SEは未検証) +- ❌ 「A2EはWav2Vec2(95M)がサーバー前提」→ ⭕ A2E推論はサーバー側だが、**結果の52次元係数(~10KB/sec)をクライアントに送るだけ**。レンダリング自体はオンデバイス + +**未解決の技術的問題**: iPhone SE (A13/A15, 3-4GB RAM) で81,424 Gaussianのソートと描画が30FPSで回るか。iPhone 16 (A18)で35FPSなので、SE世代ではさらに厳しい可能性がある。 + +--- + +## 2. リポジトリ構成 + +### 2.1 ブランチ + +| ブランチ | 説明 | +|---------|------| +| `master` | LAM公式コード + 初期カスタマイズ | +| `claude/test-a2e-japanese-audio-j9VBT` | **現在のメインブランチ** — A2Eサービス、フロントエンドパッチ、テストスイート | +| `claude/gradio-concierge-ui-4gev2` | Modal/HF Spacesデプロイ (Gradio UI) | +| `claude/test-concierge-modal-rewGs` | Modal GPU上でのアバター生成テスト | + +### 2.2 ディレクトリ構成(カスタム部分のみ) + +``` +LAM_gpro/ +├── services/ +│ ├── audio2exp-service/ # A2Eマイクロサービス (Flask) +│ │ ├── app.py # APIサーバー (port 8081) +│ │ ├── a2e_engine.py # 推論エンジン (Wav2Vec2 + A2Eデコーダー) +│ │ ├── Dockerfile +│ │ ├── LAM_Audio2Expression/ # 公式A2Eモジュール (git clone) +│ │ └── models/ # モデルファイル (gitignore) +│ ├── frontend-patches/ # gourmet-sp フロントエンドパッチ +│ │ ├── concierge-controller.ts # A2E統合済みコントローラー +│ │ ├── vrm-expression-manager.ts # 52dim→ボーンマッピング +│ │ └── FRONTEND_INTEGRATION.md +│ └── DEPLOYMENT_GUIDE.md +├── tests/ +│ └── a2e_japanese/ # 日本語A2Eテストスイート +│ ├── generate_test_audio.py +│ ├── test_a2e_cpu.py +│ ├── analyze_blendshapes.py +│ ├── patch_*.py # OpenAvatarChat バグ修正パッチ群 +│ ├── chat_with_lam_jp.yaml # 日本語設定 +│ └── TEST_PROCEDURE.md +├── docs/ +│ ├── SYSTEM_ARCHITECTURE.md # 全体設計書 (詳細) +│ └── SESSION_HANDOFF.md # ← このファイル +└── (LAM公式コード一式) +``` + +--- + +## 3. 現在のシステム構成(クラウド版 — 動作する版) + +``` +┌──────────────────┐ REST ┌────────────────────┐ REST ┌──────────────────┐ +│ gourmet-sp │◄──────►│ gourmet-support │◄──────►│ audio2exp-service│ +│ (Astro + TS) │ │ (Flask + SocketIO) │ │ (Flask) │ +│ Vercel │ │ Cloud Run │ │ Cloud Run │ +│ │ │ │ │ 2vCPU, 2GB RAM │ +│ ・3D avatar │ │ ・Gemini 2.0 Flash │ │ │ +│ ・FFT lipsync │ │ ・Google Cloud TTS │ │ Wav2Vec2 (360MB) │ +│ ・A2E lipsync │ │ ・Google Cloud STT │ │ + A2E Dec (50MB) │ +│ (パッチ適用時) │ │ ・HotPepper API │ │ → 52dim @30fps │ +│ │ │ ・Firestore │ │ │ +└──────────────────┘ └────────────────────┘ └──────────────────┘ +``` + +### 3.1 外部サービス依存 + +| サービス | 用途 | 代替不可 | +|---------|------|---------| +| Google Cloud TTS | 音声合成 (ja-JP) | TTSは必須、ベンダーは変更可 | +| Google Cloud STT (Chirp2) | 音声認識 | STTは必須、ベンダーは変更可 | +| Gemini 2.0 Flash | LLM対話 | LLMは必須、モデルは変更可 | +| HotPepper API | グルメ検索 | ドメイン固有 | +| Firestore | 長期記憶 | 任意のKVSで代替可 | + +### 3.2 gourmet-sp / gourmet-support は別リポジトリ + +**重要**: gourmet-sp (フロントエンド) と gourmet-support (バックエンド) のソースコードはこのリポジトリにはない。`services/frontend-patches/` にあるのはパッチファイルのみ。本体は別のGitリポジトリ。 + +--- + +## 4. 完了済みの作業 + +### 4.1 audio2exp-service (完成・Cloud Runデプロイ可能) + +- Flask REST API (`/api/audio2expression`, `/health`) +- Wav2Vec2 + LAM A2Eデコーダーの推論パイプライン +- INFER パイプライン (公式LAM_Audio2Expression使用) 優先、エネルギーフォールバック +- Docker化、Cloud Runデプロイ設定 +- 1秒チャンクのストリーミング推論、コンテキスト引き継ぎ + +### 4.2 フロントエンドパッチ (完成・未適用) + +- `concierge-controller.ts`: TTS応答に同梱されたA2Eデータを使ったリップシンク +- `vrm-expression-manager.ts`: 52次元ARKit → 1次元mouthOpenness変換 +- 2つの統合方式: ExpressionManager方式 (GVRM直接) / LAMAvatar方式 (外部コントローラー) +- FFTフォールバック機能 + +### 4.3 日本語テストスイート (完成・未実行) + +- EdgeTTSでの日本語テスト音声生成 (母音、会話、長文、英語/中国語比較) +- A2E CPU推論テスト +- ブレンドシェイプ分析・可視化 +- OpenAvatarChatバグ修正パッチ群 (ASR言語、VAD dtype、LLM Gemini対応) +- 日本語OpenAvatarChat設定ファイル + +### 4.4 Modal/HF Spacesデプロイ (別ブランチ、多数のバグ修正) + +- `claude/gradio-concierge-ui-4gev2`: Gradio UI + GPU推論 +- bird monsterバグ(vertex_order.json上書き問題)の修正 +- nvdiffrast JITプリコンパイル +- xformersバージョン整合 + +### 4.5 バグ修正履歴 (主要なもの) + +| コミット | 問題 | 修正 | +|---------|------|------| +| `a58395b` | ASR 2回目推論が24倍遅延 → システムフリーズ | パフォーマンスパッチ | +| `2e16f78` | テキスト入力時にTTS再生されない | concierge-controller修正 | +| `4332c8f` | autoplay deadlock → STT停止 | play-and-waitパターン修正 | +| `e1b8d30` | Flask dotenv自動読み込みでエンコーディングエラー | 自動ロード無効化 | +| `8f99c70` | INFER パイプライン起動エラー | DDP環境変数設定 | + +--- + +## 5. 未完了・未検証の作業 + +### 5.1 最重要(ゴール直結) + +| 項目 | 状態 | 詳細 | +|------|------|------| +| **iPhone SEでのWebGLレンダリング検証** | 未着手 | 81,424 Gaussianが30FPSで回るか。`gaussian-splat-renderer-for-lam` npmパッケージで検証 | +| **A2Eのオンデバイス化** | 未着手 | 現在はサーバー側Wav2Vec2(95M)。MFCC + 軽量モデル or ONNX量子化 | +| **表情・頭の動きの自然さ向上** | 未着手 | 現在A2Eは口元のみ。頭の動き、瞬き、眉の動きはプロシージャル生成が必要 | +| **エンドツーエンド統合テスト** | 未実行 | gourmet-sp + gourmet-support + audio2exp-service の結合テスト | + +### 5.2 テスト未実行 + +| テスト | 理由 | +|--------|------| +| 日本語A2Eテストスイート | ローカルWindows環境(C:\Users\hamad\OpenAvatarChat)で実行する前提。Claude Codeからは実行不可 | +| OpenAvatarChat統合テスト | 同上 | +| Cloud Runデプロイ | GCPプロジェクトへのアクセスが必要 | + +### 5.3 アーキテクチャ未決定 + +オーナーのゴール「iPhone SE単体、バックエンドGPU不要」に対して、以下のアプローチが候補: + +**A. LAM WebGL SDK + サーバーA2E** +- 現在のアーキテクチャの延長 +- レンダリングはWebGL SDK (クライアント)、A2E推論はサーバー +- A2Eサーバーは**CPUで動く** (GPU不要) — 2vCPU Cloud Runで2秒/文 +- 課題: iPhone SEでGaussian Splattingが30FPS出るか + +**B. Three.js + GLBメッシュ + 軽量オーディオ分析** +- Gaussian Splattingを捨てて、通常のメッシュ(20-50kポリゴン) + 52 ARKitブレンドシェイプ +- MFCC + 軽量CNN (1-5Mパラメータ、CoreML/ONNX) でオンデバイスA2E +- Three.jsで60FPS確実 +- 参考: [TalkingHead](https://github.com/met4citizen/TalkingHead) (ブラウザで動くOSS) +- 課題: LAMの超リアルなGaussian品質を失う + +**C. ネイティブiOSアプリ (SceneKit/RealityKit)** +- GLBメッシュ + CoreMLで完全オンデバイス +- A15 Neural Engine: 15.8 TOPS → 小型モデルなら余裕 +- 課題: Web版が不要になる、開発コスト + +**D. ハイブリッド: LAM WebGL + TTS事前生成A2E** +- アバター生成: サーバー (1回のみ) +- A2E推論: TTS合成時にサーバーで事前計算、結果(~10KB/sec)をクライアントに送信 +- レンダリング: LAM WebGL SDK (クライアント) +- iPhone SEで動くかがボトルネック + +--- + +## 6. 重要なファイルパス + +### 6.1 このリポジトリ + +| ファイル | 説明 | +|---------|------| +| `docs/SYSTEM_ARCHITECTURE.md` | 全体設計書(最も詳細) | +| `services/audio2exp-service/a2e_engine.py` | A2E推論エンジン | +| `services/audio2exp-service/app.py` | A2E Flask API | +| `services/frontend-patches/concierge-controller.ts` | A2E統合フロントエンド | +| `services/frontend-patches/vrm-expression-manager.ts` | ブレンドシェイプ変換 | +| `services/DEPLOYMENT_GUIDE.md` | デプロイ手順 | +| `tests/a2e_japanese/TEST_PROCEDURE.md` | 日本語テスト手順 | +| `tests/a2e_japanese/test_a2e_cpu.py` | A2Eテスト本体 | +| `tests/a2e_japanese/analyze_blendshapes.py` | 出力分析 | +| `lam/models/rendering/flame_model/` | FLAMEモデル実装 | +| `lam/models/rendering/gs_renderer.py` | Gaussian Splattingレンダラー (Python/CUDA) | +| `tools/generateARKITGLBWithBlender.py` | ZIP生成パイプライン | + +### 6.2 外部リポジトリ (参照のみ) + +| リポジトリ | URL | +|-----------|-----| +| LAM公式 | https://github.com/aigc3d/LAM | +| LAM_Audio2Expression | https://github.com/aigc3d/LAM_Audio2Expression | +| LAM_WebRender | https://github.com/aigc3d/LAM_WebRender | +| OpenAvatarChat | https://github.com/HumanAIGC-Engineering/OpenAvatarChat | +| TalkingHead (参考OSS) | https://github.com/met4citizen/TalkingHead | + +### 6.3 外部リソース + +| リソース | URL | +|---------|-----| +| LAM論文 | https://arxiv.org/abs/2502.17796 | +| PanoLAM論文 | https://arxiv.org/abs/2509.07552 | +| LAMプロジェクトページ | https://aigc3d.github.io/projects/LAM/ | +| ModelScope Space (ZIP生成可) | https://www.modelscope.cn/studios/Damo_XR_Lab/LAM_Large_Avatar_Model | +| npm WebGLレンダラー | gaussian-splat-renderer-for-lam (クローズドソース) | +| NVIDIA Audio2Face-3D | https://huggingface.co/nvidia/Audio2Face-3D-v2.3-Mark | + +--- + +## 7. WebGLレンダリングの技術詳細 + +### 7.1 LAM_WebRender SDK の使い方 + +```typescript +import { GaussianAvatar } from './gaussianAvatar'; + +// アバターZIP (skin.glb + offset.ply + animation.glb) を指定 +const avatar = new GaussianAvatar(containerDiv, './asset/arkit/avatar.zip'); +avatar.start(); +``` + +SDK API: +```typescript +GaussianSplatRenderer.getInstance(container, assetPath, { + getChatState: () => "Idle" | "Listening" | "Thinking" | "Responding", + getExpressionData: () => ({ jawOpen: 0.5, mouthFunnel: 0.2, ... }), // 毎フレーム呼ばれる + backgroundColor: "0xff0000", + alpha: 0.2 +}); +``` + +### 7.2 A2E → レンダラーのデータフロー + +``` +A2Eサーバー応答: +{ + names: ["browDownLeft", ..., "tongueOut"], // 52個 + frames: [[0.0, 0.1, ...], ...], // 各フレーム52次元 + frame_rate: 30 +} + +↓ フロントエンドで変換 + +getExpressionData() が毎フレーム返す: +{ + "jawOpen": 0.45, + "mouthFunnel": 0.12, + "mouthPucker": 0.08, + "eyeBlinkLeft": 0.0, + ... +} + +↓ WebGLレンダラー内部 + +GPUテクスチャにパック → 頂点シェーダーでLBS計算 → Transform Feedback → Gaussian Splatting描画 +``` + +--- + +## 8. 次のセッションでやるべきこと + +### 最優先: iPhone SEでの実機検証 + +1. `gaussian-splat-renderer-for-lam` をnpm installしてミニマルHTML作成 +2. ModelScope SpaceでアバターZIP生成 +3. iPhone SE実機 (Safari) でFPS計測 +4. → 30FPS出るなら Approach A (LAM WebGL SDK) +5. → 出ないなら Approach B (Three.js + GLBメッシュ) に切り替え + +### 並行: 日本語A2Eテスト実行 + +オーナーのローカル環境 (`C:\Users\hamad\OpenAvatarChat`) で: +```powershell +conda activate oac +python tests/a2e_japanese/run_all_tests.py +``` + +### その後: 技術スタック決定 → アルファ版実装 + +ゴールは「動くもの」。調査や検証で止まるな。 + +--- + +## 9. コミット履歴サマリー (113コミット) + +| フェーズ | コミット範囲 | 内容 | +|---------|-------------|------| +| LAM公式 | `5c204d4`〜`f8187a7` | 公式リリース、README更新、PanoLAMレポート | +| Modal/GPU格闘 | `f7cc25f`〜`006213f` | Modal L4/A10G GPU、bird monsterバグ、VHAP timeout、ZIP生成 | +| OpenAvatarChat日本語化 | `3003c1b`〜`a58395b` | パッチ群、テストスイート、ASR性能修正 | +| A2Eサービス構築 | `0875af7`〜`8f99c70` | マイクロサービス、INFER パイプライン、Docker | +| フロントエンド統合 | `cde7c54`〜`2e16f78` | A2Eリップシンク統合、TTS修正、データ形式修正 | diff --git a/docs/SYSTEM_ARCHITECTURE.md b/docs/SYSTEM_ARCHITECTURE.md new file mode 100644 index 0000000..7c5a39e --- /dev/null +++ b/docs/SYSTEM_ARCHITECTURE.md @@ -0,0 +1,855 @@ +# LAM_gpro システム全体設計書 + +> **最終更新**: 2026-02-21 +> **対象**: gourmet-support バックエンド / gourmet-sp フロントエンド / audio2exp-service / LAM公式ツール + +--- + +## 目次 + +1. [全体アーキテクチャ](#1-全体アーキテクチャ) +2. [バックエンド (gourmet-support)](#2-バックエンド-gourmet-support) +3. [フロントエンド (gourmet-sp)](#3-フロントエンド-gourmet-sp) +4. [Audio2Expression サービス](#4-audio2expression-サービス) +5. [A2E フロントエンド統合パッチ](#5-a2e-フロントエンド統合パッチ) +6. [公式HF SpacesでカスタムZIPを生成する手順](#6-公式hf-spacesでカスタムzipを生成する手順) +7. [テストスイート (tests/a2e_japanese)](#7-テストスイート-testsa2e_japanese) +8. [デプロイ構成](#8-デプロイ構成) +9. [データフロー全体図](#9-データフロー全体図) + +--- + +## 1. 全体アーキテクチャ + +``` +┌─────────────────────┐ REST ┌─────────────────────────┐ REST ┌─────────────────────┐ +│ gourmet-sp │ ◄──────────► │ gourmet-support │ ◄──────────► │ audio2exp-service │ +│ (Astro + TS) │ │ (Flask + SocketIO) │ │ (Flask) │ +│ Vercel │ │ Cloud Run │ │ Cloud Run │ +├──────────────────────┤ ├──────────────────────────┤ ├──────────────────────┤ +│ concierge-controller │ │ app_customer_support.py │ │ app.py │ +│ core-controller │ │ support_core.py │ │ a2e_engine.py │ +│ audio-manager │ │ api_integrations.py │ │ ├ Wav2Vec2 │ +│ gvrm (3D avatar) │ │ long_term_memory.py │ │ └ A2E Decoder │ +│ lipsync │ │ │ │ │ +└──────────────────────┘ └──────────────────────────┘ └──────────────────────┘ + │ + ├── Google Cloud TTS + ├── Google Cloud STT (Chirp2) + ├── Gemini 2.0 Flash (LLM) + ├── HotPepper API + └── Firestore (長期記憶) +``` + +``` +┌──────────────────────────────────────────────────────────────────────────┐ +│ 公式LAMツールチェーン (別系統 — アバター生成用) │ +├──────────────────────────────────────────────────────────────────────────┤ +│ │ +│ [HF Spaces / ModelScope / ローカルGradio] │ +│ app_hf_space.py / app_lam.py │ +│ ↓ │ +│ 1枚の顔画像 → FlameTracking → LAM-20K推論 → 3Dアバター生成 │ +│ ↓ │ +│ 「Export ZIP for Chatting Avatar」チェックボックス │ +│ ↓ │ +│ ZIP出力: skin.glb + offset.ply + animation.glb │ +│ ↓ │ +│ OpenAvatarChat / gourmet-sp で使用可能 │ +│ │ +└──────────────────────────────────────────────────────────────────────────┘ +``` + +--- + +## 2. バックエンド (gourmet-support) + +### 2.1 ファイル構成 + +| ファイル | 行数 | 役割 | +|----------|------|------| +| `app_customer_support.py` | ~450行 | Flaskアプリ本体、全APIエンドポイント | +| `support_core.py` | ~350行 | Gemini LLM対話ロジック、プロンプト管理 | +| `api_integrations.py` | ~250行 | HotPepper API、場所検索 | +| `long_term_memory.py` | ~200行 | Firestore長期記憶 | + +### 2.2 APIエンドポイント一覧 + +| エンドポイント | メソッド | 説明 | +|---------------|---------|------| +| `/api/session/start` | POST | セッション開始。長期記憶から挨拶文を生成 | +| `/api/session/end` | POST | セッション終了 | +| `/api/chat` | POST | LLMチャット。Gemini 2.0 Flashで応答生成 | +| `/api/tts/synthesize` | POST | Google Cloud TTS + A2E表情データ生成 | +| `/health` | GET | ヘルスチェック | + +### 2.3 TTS + A2E 統合フロー (`app_customer_support.py`) + +```python +@app.route('/api/tts/synthesize', methods=['POST']) +def synthesize(): + text = request.json['text'] + language_code = request.json['language_code'] + voice_name = request.json['voice_name'] + session_id = request.json.get('session_id') + + # 1. Google Cloud TTS で MP3 生成 + audio_base64 = synthesize_with_gcp(text, language_code, voice_name) + + # 2. A2E表情データ生成 (AUDIO2EXP_SERVICE_URL が設定されている場合) + expression = None + if AUDIO2EXP_SERVICE_URL and audio_base64: + expression = get_expression_frames(audio_base64, session_id) + + # 3. 音声 + 表情データを同梱して返却 + return jsonify({ + 'success': True, + 'audio': audio_base64, + 'expression': expression # {names, frames, frame_rate} or None + }) +``` + +`get_expression_frames()` は内部で `audio2exp-service` の `/api/audio2expression` を呼ぶ。 +タイムアウト10秒。失敗時は `expression=None` でフォールバック。 + +### 2.4 LLM対話フロー (`support_core.py`) + +``` +ユーザー入力 + ↓ +support_core.process_message(session_id, message, stage, language, mode) + ↓ +1. Gemini 2.0 Flash に送信 (system prompt + 会話履歴 + ユーザー入力) + ↓ +2. レスポンス解析: + - shops データあり → HotPepper URL付きで返却 + - shops なし → テキストのみ返却 + ↓ +3. 長期記憶更新 (ユーザーの好み・過去のやりとり) +``` + +### 2.5 環境変数 + +| 変数 | 必須 | 説明 | +|------|------|------| +| `GOOGLE_CLOUD_PROJECT` | Yes | GCPプロジェクトID | +| `GEMINI_API_KEY` | Yes | Gemini API キー | +| `HOTPEPPER_API_KEY` | Yes | HotPepper APIキー | +| `AUDIO2EXP_SERVICE_URL` | No | A2Eサービスの URL (未設定時はFFTフォールバック) | +| `FIRESTORE_COLLECTION` | No | 長期記憶のコレクション名 | + +--- + +## 3. フロントエンド (gourmet-sp) + +### 3.1 ファイル構成 + +| ファイル | 行数 | 役割 | +|----------|------|------| +| `core-controller.ts` | ~1040行 | 基底コントローラー。セッション管理、TTS再生、STT、UI | +| `concierge-controller.ts` | ~812行 | コンシェルジュモード。GVRM 3Dアバター + リップシンク | +| `chat-controller.ts` | ~45行 | チャットモード。テキストのみ | +| `audio-manager.ts` | ~733行 | マイク入力、AudioWorklet、VAD | +| `gvrm.ts` | ~353行 | Gaussian Splatting 3Dアバターレンダラー | +| `lipsync.ts` | ~61行 | FFTベースリップシンク解析 | +| `concierge.astro` | ~559行 | コンシェルジュモードのページ | +| `index.astro` | ~572行 | チャットモードのページ | +| `Concierge.astro` | ~329行 | コンシェルジュUIコンポーネント | + +### 3.2 クラス継承 + +``` +CoreController (core-controller.ts) +├── ConciergeController (concierge-controller.ts) +│ └── GVRM 3Dアバター + リップシンク +└── ChatController (chat-controller.ts) + └── テキストのみ +``` + +### 3.3 CoreController 主要メソッド + +| メソッド | 説明 | +|----------|------| +| `init()` | 初期化。イベントバインド、Socket.IO、セッション開始 | +| `initializeSession()` | `/api/session/start` → 挨拶音声 + ACK事前生成 | +| `toggleRecording()` | マイク ON/OFF | +| `handleStreamingSTTComplete()` | STT完了 → エコー判定 → ACK再生 → `sendMessage()` | +| `sendMessage()` | `/api/chat` → レスポンス表示 + TTS再生 | +| `speakTextGCP()` | `/api/tts/synthesize` → `ttsPlayer` で再生 | +| `extractShopsFromResponse()` | Markdownレスポンスからショップ情報を抽出 | + +### 3.4 ConciergeController 追加機能 + +| メソッド | 説明 | +|----------|------| +| `setupAudioAnalysis()` | FFT解析用 AudioContext + AnalyserNode 作成 | +| `startLipSyncLoop()` | requestAnimationFrame で FFT → `gvrm.updateLipSync(level)` | +| `stopAvatarAnimation()` | 口を閉じる + animationFrame キャンセル | +| `speakResponseInChunks()` | 文単位で分割 → 並行TTS合成 → 順次再生 | + +### 3.5 現在のリップシンク方式 (FFTベース) + +``` +ttsPlayer (HTMLAudioElement) + ↓ MediaElementAudioSource +AnalyserNode (fftSize=256) + ↓ getByteFrequencyData() +全周波数ビンの平均値 + ↓ Math.min(1.0, (average/255) * 2.5) +gvrm.updateLipSync(0.0 ~ 1.0) + ↓ VRMManager.setLipSync(level) +Jaw/Mouthボーン回転 +``` + +- 更新レート: ~60Hz (requestAnimationFrame) +- ノイズゲート: average < 0.02 → 0 +- 感度: ×2.5 で増幅、1.0でクリップ +- 制限: 音量ベースのため母音の区別不可 + +### 3.6 AudioManager 音声入力パイプライン + +``` +マイク → MediaStream (48kHz/44.1kHz) + ↓ AudioWorkletProcessor +ダウンサンプリング → 16kHz Int16 PCM + ↓ base64エンコード +Socket.IO emit('audio_chunk') + ↓ +サーバー: Google Cloud STT (Chirp2) + ↓ transcript イベント +handleStreamingSTTComplete() +``` + +| 設定 | Chat | Concierge | +|------|------|-----------| +| 無音検出タイムアウト | 4500ms | 8000ms | +| 無音閾値 | 35 (dB相当) | 35 | +| 最小録音時間 | 3秒 | 3秒 | +| 最大録音時間 | 60秒 | 60秒 | +| バッファ上限 | 48チャンク (3秒) | 48チャンク (3秒) | + +### 3.7 GVRM レンダリングパイプライン (`gvrm.ts`) + +``` +loadAssets(): + PLYLoader → 頂点位置データ + TemplateDecoder → 変形テンプレート + ImageEncoder (DINOv2) → ID特徴量抽出 + vertex_mapping.json → PLY↔テンプレート対応 + GSViewer → Gaussian Splatting レンダラー + +animate() (毎フレーム): + VRM.update() → ボーンポーズ更新 + 8回のLatentタイルパス (32ch / 4×2グリッド) + → 256×256 RenderTarget + → Float32Array 読み出し + NeuralRefiner.process(coarseFm, idEmbedding) + → 512×512 RGB 生成 + WebGLDisplay.display(refinedRgb) + → Canvas表示 +``` + +--- + +## 4. Audio2Expression サービス + +### 4.1 ファイル構成 + +``` +services/audio2exp-service/ +├── app.py # Flask API サーバー (port 8081) +├── a2e_engine.py # 推論エンジン本体 +├── requirements.txt # Python依存関係 +├── Dockerfile # コンテナビルド +├── start.sh # 起動スクリプト +└── models/ # モデルファイル (gitignore) + ├── wav2vec2-base-960h/ + │ ├── config.json + │ ├── pytorch_model.bin + │ └── ... + └── LAM_audio2exp_streaming.tar +``` + +### 4.2 推論パイプライン (`a2e_engine.py`) + +``` +音声 (base64 MP3/WAV) + ↓ pydub デコード +PCM float32 @ 16kHz + ↓ +Wav2Vec2 (facebook/wav2vec2-base-960h) + ↓ 音響特徴量 (1, T, 768) + ↓ +A2Eデコーダー (3DAIGC/LAM_audio2exp) ← 存在する場合 + ↓ 52次元 ARKit ブレンドシェイプ (T', 52) + ↓ +リサンプリング → 30fps + ↓ +{names: [52 strings], frames: [[52 floats], ...], frame_rate: 30} +``` + +### 4.3 フォールバック (A2Eデコーダーなし) + +A2Eデコーダーが見つからない場合、Wav2Vec2の768次元特徴量から +エネルギーベースでブレンドシェイプを近似生成: + +``` +features (T, 768) +├── 低周波帯 [0:256] → jawOpen (母音の開き) +├── 中周波帯 [256:512] → mouthFunnel/Pucker (う/お) +└── 高周波帯 [512:768] → mouthSmile (い/え) + ↓ +スムージング (3フレーム移動平均) + ↓ +無音マスク (speech_activity < 0.1 → ×0.1) +``` + +### 4.4 52次元ARKitブレンドシェイプ + +``` +Index Name リップシンクへの影響 +───── ────────────────────── ────────────────── + 17 jawOpen ★★★ メイン (口の開閉) + 18 mouthClose ★★ jawOpenの逆 + 19 mouthFunnel ★★ 「う」「お」 + 20 mouthPucker ★ 「う」すぼめ + 23 mouthSmileLeft ★★ 「い」「え」横開き + 24 mouthSmileRight ★★ 「い」「え」横開き + 37 mouthLowerDownLeft ★ 下唇の下がり + 38 mouthLowerDownRight ★ 下唇の下がり + 39 mouthUpperUpLeft ★ 上唇の上がり + 40 mouthUpperUpRight ★ 上唇の上がり +``` + +### 4.5 APIリファレンス + +#### POST `/api/audio2expression` + +**Request:** +```json +{ + "audio_base64": "", + "session_id": "uuid-string", + "audio_format": "mp3" +} +``` + +**Response:** +```json +{ + "names": ["eyeBlinkLeft", "eyeLookDownLeft", ..., "tongueOut"], + "frames": [ + {"weights": [0.0, 0.0, ..., 0.0]}, + {"weights": [0.1, 0.0, ..., 0.0]} + ], + "frame_rate": 30 +} +``` + +#### GET `/health` + +```json +{ + "status": "healthy", + "engine_ready": true, + "device": "cpu", + "model_dir": "/app/models" +} +``` + +### 4.6 モデルダウンロード + +```bash +# Wav2Vec2 (~360MB) +git lfs install +git clone https://huggingface.co/facebook/wav2vec2-base-960h models/wav2vec2-base-960h + +# LAM A2E Decoder (~50MB) +wget -O models/LAM_audio2exp_streaming.tar \ + https://huggingface.co/3DAIGC/LAM_audio2exp/resolve/main/LAM_audio2exp_streaming.tar +``` + +--- + +## 5. A2E フロントエンド統合パッチ + +### 5.1 パッチファイル一覧 + +``` +services/frontend-patches/ +├── FRONTEND_INTEGRATION.md # 統合ガイド +├── vrm-expression-manager.ts # A2Eブレンドシェイプ→ボーン変換 +└── concierge-controller.ts # パッチ適用済みコントローラー +``` + +### 5.2 ExpressionManager (`vrm-expression-manager.ts`) + +A2Eの52次元ARKitブレンドシェイプをGVRMのボーンシステムにマッピングするクラス。 + +```typescript +class ExpressionManager { + constructor(renderer: GVRM); + + // A2Eフレームデータを音声に同期して再生 + playExpressionFrames(expression: ExpressionData, audioElement: HTMLAudioElement): void; + + // 停止 + stop(): void; + + // バリデーション + static isValid(expression: any): expression is ExpressionData; +} +``` + +**マッピングロジック:** +``` +jawOpen × 0.6 ++ (mouthLowerDownL + mouthLowerDownR) / 2 × 0.2 ++ (mouthUpperUpL + mouthUpperUpR) / 2 × 0.1 ++ mouthFunnel × 0.05 ++ mouthPucker × 0.05 += mouthOpenness (0.0 ~ 1.0) +→ gvrm.updateLipSync(mouthOpenness) +``` + +### 5.3 パッチ版 concierge-controller.ts の主な変更点 + +現在のgourmet-spの `concierge-controller.ts` との差分: + +| 項目 | 現行 (gourmet-sp) | パッチ版 | +|------|-------------------|----------| +| リップシンク | FFT音量ベース | A2E 52次元ブレンドシェイプ | +| 3Dアバター | GVRM直接制御 | `window.lamAvatarController` 経由 | +| TTS応答処理 | `setupAudioAnalysis()` + FFTループ | `applyExpressionFromTts()` でバッファ投入 | +| ACK処理 | スマートACK選択 | 「はい」のみに簡略化 | +| 挨拶文 | 固定テキスト | バックエンドからの長期記憶対応挨拶 | +| 並行処理 | 文分割 + 並行TTS | 同様 + Expression同梱処理 | + +**`applyExpressionFromTts()` の動作:** +```typescript +private applyExpressionFromTts(expression: any): void { + const lamController = (window as any).lamAvatarController; + if (!lamController) return; + + // バッファクリア (前セグメントの残りフレーム防止) + lamController.clearFrameBuffer(); + + // フレーム変換: {names, frames[{weights}]} → {name: weight} の配列 + const frames = expression.frames.map(f => { + const frame = {}; + expression.names.forEach((name, i) => { frame[name] = f.weights[i]; }); + return frame; + }); + + // LAMAvatarのキューにフレームを投入 + lamController.queueExpressionFrames(frames, expression.frame_rate || 30); +} +``` + +### 5.4 2つの統合方式 + +**方式A: ExpressionManager方式 (GVRM直接)** +- `FRONTEND_INTEGRATION.md` に記載 +- `ExpressionManager` が `gvrm.updateLipSync(level)` を直接呼ぶ +- 現行のGVRMレンダラーを維持 + +**方式B: LAMAvatar方式 (外部コントローラー)** +- パッチ版 `concierge-controller.ts` で実装 +- `window.lamAvatarController` にフレームをキュー投入 +- LAMAvatarが独自にレンダリング + +--- + +## 6. 公式HF SpacesでカスタムZIPを生成する手順 + +### 6.1 概要 + +LAM公式が提供するGradio UIを使い、1枚の顔画像から +OpenAvatarChat互換のアバターZIPファイルを生成する手順。 + +生成されたZIPは以下で利用可能: +- OpenAvatarChat (公式チャットSDK) +- gourmet-sp (当プロジェクトのフロントエンド) + +### 6.2 方法一覧 + +| 方法 | URL / コマンド | ZIP出力 | GPU必要 | +|------|---------------|---------|---------| +| **ModelScope Space** | https://www.modelscope.cn/studios/Damo_XR_Lab/LAM_Large_Avatar_Model | Yes (2025/5/10〜対応) | 不要 (クラウドGPU) | +| **HuggingFace Space** | https://huggingface.co/spaces/3DAIGC/LAM | 動画のみ (ZIP非対応) | 不要 (ZeroGPU) | +| **ローカルGradio** | `python app_lam.py --blender_path ...` | Yes | 必要 (CUDA) | + +### 6.3 方法A: ModelScope Space (推奨 — 環境構築不要) + +> **[2025/5/10更新]** ModelScope DemoがOpenAvatarChat用ZIPの直接エクスポートに対応。 + +1. ブラウザで以下を開く: + https://www.modelscope.cn/studios/Damo_XR_Lab/LAM_Large_Avatar_Model + +2. **Input Image** に正面顔画像をアップロード + - 正面向きが最良の結果を得る + - 解像度: 特に制限なし(内部で自動リサイズ) + +3. **Input Video** にドライビング動画を選択 + - サンプル動画が複数用意されている + - 音声付き動画の場合、音声もアバターに適用される + +4. **「Export ZIP file for Chatting Avatar」** チェックボックスを **ON** + +5. **Generate** をクリック + +6. 処理完了後、**Export ZIP File Path** にZIPファイルのパスが表示される + +7. ZIPをダウンロード + +### 6.4 方法B: ローカルGradio (GPU環境がある場合) + +#### 前提条件 + +``` +- Python 3.10 +- CUDA 12.1 or 11.8 +- Blender >= 4.0.0 +- Python FBX SDK 2020.2+ +- VRAM: 8GB以上推奨 +``` + +#### Step 1: 環境セットアップ + +```bash +git clone https://github.com/aigc3d/LAM.git +cd LAM + +# CUDA 12.1の場合 +sh ./scripts/install/install_cu121.sh + +# モデルウェイトのダウンロード +huggingface-cli download 3DAIGC/LAM-assets --local-dir ./tmp +tar -xf ./tmp/LAM_assets.tar && rm ./tmp/LAM_assets.tar +tar -xf ./tmp/thirdparty_models.tar && rm -r ./tmp/ +huggingface-cli download 3DAIGC/LAM-20K \ + --local-dir ./model_zoo/lam_models/releases/lam/lam-20k/step_045500/ +``` + +#### Step 2: FBX SDK + Blender インストール + +```bash +# FBX SDK (Linux) +wget https://virutalbuy-public.oss-cn-hangzhou.aliyuncs.com/share/aigc3d/data/LAM/fbx-2020.3.4-cp310-cp310-manylinux1_x86_64.whl +pip install fbx-2020.3.4-cp310-cp310-manylinux1_x86_64.whl +pip install pathlib patool + +# Blender (Linux) +wget https://download.blender.org/release/Blender4.0/blender-4.0.2-linux-x64.tar.xz +tar -xvf blender-4.0.2-linux-x64.tar.xz -C ~/software/ +``` + +#### Step 3: テンプレートファイルのダウンロード + +```bash +wget https://virutalbuy-public.oss-cn-hangzhou.aliyuncs.com/share/aigc3d/data/LAM/sample_oac.tar +tar -xf sample_oac.tar -C assets/ +``` + +#### Step 4: Gradio起動 + +```bash +python app_lam.py --blender_path ~/software/blender-4.0.2-linux-x64/blender +``` + +ブラウザで `http://localhost:7860` を開き: +1. **Input Image** に正面顔画像をアップロード +2. **Input Video** にドライビング動画を選択 +3. **「Export ZIP file for Chatting Avatar」** チェック ON +4. **Generate** をクリック +5. `output/open_avatar_chat/.zip` にZIPが生成される + +### 6.5 ZIP の中身 + +``` +/ +├── skin.glb # スキンメッシュ (GLBフォーマット、Blenderで生成) +├── offset.ply # 頂点オフセット (Gaussian Splatting用) +└── animation.glb # アニメーションデータ (テンプレートからコピー) +``` + +#### 各ファイルの役割 + +| ファイル | 説明 | 生成元 | +|----------|------|--------| +| `skin.glb` | ARKit互換のスキンメッシュ。FLAMEパラメトリックモデルから生成したヘッドメッシュを、テンプレートFBXのボーン構造にバインドしたもの | `tools/generateARKITGLBWithBlender.py` | +| `offset.ply` | canonical空間でのGaussian Splatting頂点オフセット。`rgb2sh=False, offset2xyz=True` で保存 | `lam.renderer.flame_model` → `cano_gs_lst[0].save_ply()` | +| `animation.glb` | 汎用アニメーションデータ。全アバター共通 | `assets/sample_oac/animation.glb` からコピー | + +#### ZIP生成の内部処理 (`app_lam.py` L304-344) + +```python +# 1. FLAMEモデルからシェイプメッシュを保存 +saved_head_path = lam.renderer.flame_model.save_shaped_mesh( + shape_param.unsqueeze(0).cuda(), fd=oac_dir +) + +# 2. Gaussian Splatting オフセットを保存 +res['cano_gs_lst'][0].save_ply( + os.path.join(oac_dir, "offset.ply"), rgb2sh=False, offset2xyz=True +) + +# 3. BlenderでGLBを生成 +generate_glb( + input_mesh=Path(saved_head_path), + template_fbx=Path("./assets/sample_oac/template_file.fbx"), + output_glb=Path(os.path.join(oac_dir, "skin.glb")), + blender_exec=Path(cfg.blender_path) +) + +# 4. アニメーションファイルをコピー +shutil.copy(src='./assets/sample_oac/animation.glb', + dst=os.path.join(oac_dir, 'animation.glb')) + +# 5. ZIPアーカイブ作成 +patoolib.create_archive(archive=output_zip_path, filenames=[base_iid_dir]) +``` + +### 6.6 h5_render_data.zip (旧形式 — 参考) + +`app_lam.py` / `app_hf_space.py` には `h5_rendering=True` 時に +別形式のZIPを生成する `create_zip_archive()` 関数もある: + +``` +h5_render_data/ +├── lbs_weight_20k.json # Linear Blend Skinning ウェイト +├── offset.ply # 頂点オフセット +├── skin.glb # スキンメッシュ +├── vertex_order.json # 頂点順序マッピング +├── bone_tree.json # ボーンツリー構造 +└── flame_params.json # FLAMEパラメータ +``` + +現在は `h5_rendering = False` がデフォルトのため、 +こちらの形式は通常使われない。 + +### 6.7 生成したZIPの使い方 + +#### OpenAvatarChatで使う場合 + +```bash +# ZIPを展開して所定のディレクトリに配置 +unzip .zip -d /path/to/OpenAvatarChat/assets/avatar/ + +# 設定ファイルでアバターパスを指定 +# config/chat_with_lam.yaml 内の avatar_path を更新 +``` + +#### gourmet-sp で使う場合 + +ZIPから `skin.glb` と `offset.ply` を取り出し、 +gourmet-sp の `public/assets/` に配置。 +`gvrm.ts` の `loadAssets()` でパスを指定する。 + +--- + +## 7. テストスイート (tests/a2e_japanese) + +### 7.1 目的 + +A2Eが日本語音声で十分なリップシンクを生成するか検証する。 +もし生成できるなら、公式HF SpacesのZIP(英語/中国語で作成)を +日本語コンシェルジュでもそのまま使える。 + +### 7.2 テストファイル + +``` +tests/a2e_japanese/ +├── generate_test_audio.py # EdgeTTSでテスト音声生成 +├── test_a2e_cpu.py # A2E推論テスト (CPU) +├── save_a2e_output.py # A2E出力をNPYで保存 +├── analyze_blendshapes.py # ブレンドシェイプ分析・可視化 +├── run_all_tests.py # 全テスト一括実行 +├── setup_oac_env.py # 環境チェック・修正 +├── patch_asr_language.py # ASR日本語強制パッチ +├── patch_vad_handler.py # VAD numpy dtype修正パッチ +├── patch_llm_handler.py # Gemini dict content修正パッチ +├── patch_config_japanese.py # 設定ファイル日本語化パッチ +├── patch_asr_perf_fix.py # ASRパフォーマンス修正パッチ +├── chat_with_lam_jp.yaml # OpenAvatarChat日本語設定 +├── diagnose_onnx_error.py # ONNX問題診断 +└── TEST_PROCEDURE.md # テスト手順書 +``` + +### 7.3 テスト音声 + +| ファイル | 内容 | 目的 | +|----------|------|------| +| `vowels_aiueo.wav` | あ、い、う、え、お | 母音のリップシェイプ | +| `greeting_konnichiwa.wav` | こんにちは、お元気ですか? | 自然な会話 | +| `long_sentence.wav` | AIコンシェルジュの定型文 | 長文テスト | +| `mixed_phonemes.wav` | さしすせそ、たちつてと | 子音+母音 | +| `english_compare.wav` | Hello, how are you? | 英語比較 | +| `chinese_compare.wav` | 你好,我是AI助手 | 中国語比較 | +| `silence_baseline.wav` | 無音 2秒 | ベースライン | + +### 7.4 判定基準 + +**A2Eが日本語で十分な場合 (ZIPそのまま使える):** +- jawOpen が発話時に適切に変動 +- mouthFunnel/Pucker が「う」「お」で活性化 +- mouthSmile系が「い」「え」で活性化 +- 無音時にリップが閉じる +- 英語テストとの品質差が小さい + +**A2Eが日本語で不十分な場合 (別途対応が必要):** +- リップが発話に追従しない +- 母音の区別ができない +- 英語と比べて明らかに品質が低い + +### 7.5 重要な技術的知見 + +Wav2Vec2 (`facebook/wav2vec2-base-960h`) は英語960時間で訓練されているが、 +**音響レベルで動作し、言語パラメータはゼロ**。 +理論上、どの言語の音声でもブレンドシェイプを生成可能。 +A2Eデコーダーも音響特徴量→表情の変換であり、 +言語依存ではなく音響依存のため、日本語でも機能する見込み。 + +--- + +## 8. デプロイ構成 + +### 8.1 サービス一覧 + +| サービス | デプロイ先 | 環境 | +|----------|-----------|------| +| gourmet-support | Cloud Run (us-central1) | Python 3.11, 2vCPU, 2GB RAM | +| audio2exp-service | Cloud Run (us-central1) | Python 3.10, 2vCPU, 2GB RAM, min-instances=1 | +| gourmet-sp | Vercel | Astro SSG | + +### 8.2 パフォーマンス目標 + +| 指標 | 目標値 | 備考 | +|------|--------|------| +| TTS合成 | < 1秒 | Google Cloud TTS | +| A2E推論 | < 2秒/文 | CPU, 2vCPU | +| TTS + A2E合計 | < 3秒 | 直列 (TTS→A2E) | +| LLMレスポンス | < 3秒 | Gemini 2.0 Flash | +| エンドツーエンド | < 6秒 | 音声入力→アバター応答 | + +### 8.3 フォールバック動作 + +`AUDIO2EXP_SERVICE_URL` が未設定/サービスダウン時: + +1. バックエンド: `expression` フィールドなしでレスポンス返却 +2. フロントエンド: 従来のFFTベースリップシンクで動作 +3. ユーザー体験への影響: リップシンクの精度が下がるのみ、音声再生は正常 + +--- + +## 9. データフロー全体図 + +### 9.1 音声入力 → アバター応答 (コンシェルジュモード) + +``` +┌──────────────────────────────────────────────────────────────────────┐ +│ Phase 1: ユーザー音声入力 │ +├──────────────────────────────────────────────────────────────────────┤ +│ │ +│ 🎤 タップ → toggleRecording() │ +│ ↓ │ +│ AudioWorkletProcessor (48kHz → 16kHz Int16 PCM) │ +│ ↓ base64チャンク │ +│ Socket.IO emit('audio_chunk') │ +│ ↓ │ +│ Google Cloud STT (Chirp2, ja-JP) │ +│ ↓ transcript │ +│ handleStreamingSTTComplete(text) │ +│ ↓ │ +│ エコー判定 → ACK「はい」再生 → sendMessage() │ +│ │ +└──────────────────────────────────────────────────────────────────────┘ + ↓ +┌──────────────────────────────────────────────────────────────────────┐ +│ Phase 2: LLM応答生成 │ +├──────────────────────────────────────────────────────────────────────┤ +│ │ +│ POST /api/chat { session_id, message, stage, language, mode } │ +│ ↓ │ +│ Gemini 2.0 Flash (system prompt + 会話履歴) │ +│ ↓ │ +│ { response: "...", shops?: [...], summary?: "..." } │ +│ ↓ │ +│ addMessage('assistant', response) → UIチャットバブル表示 │ +│ │ +└──────────────────────────────────────────────────────────────────────┘ + ↓ +┌──────────────────────────────────────────────────────────────────────┐ +│ Phase 3: TTS合成 + A2E表情生成 │ +├──────────────────────────────────────────────────────────────────────┤ +│ │ +│ speakResponseInChunks(response) │ +│ ↓ 文分割 (。で区切り) │ +│ ┌─ 文1: POST /api/tts/synthesize ─────────────────────────────┐ │ +│ │ ↓ Google Cloud TTS → MP3 base64 │ │ +│ │ ↓ audio2exp-service → 52次元ブレンドシェイプ │ │ +│ │ ↓ { audio, expression: {names, frames, frame_rate} } │ │ +│ └──────────────────────────────────────────────────────────────┘ │ +│ ┌─ 文2: POST /api/tts/synthesize (並行開始) ──────────────────┐ │ +│ │ ↓ 同上 │ │ +│ └──────────────────────────────────────────────────────────────┘ │ +│ │ +└──────────────────────────────────────────────────────────────────────┘ + ↓ +┌──────────────────────────────────────────────────────────────────────┐ +│ Phase 4: 音声再生 + アバターアニメーション │ +├──────────────────────────────────────────────────────────────────────┤ +│ │ +│ ■ A2Eデータあり (expression != null): │ +│ applyExpressionFromTts(expression) │ +│ ↓ lamController.queueExpressionFrames(frames, fps) │ +│ ↓ audioElement.currentTime に同期してフレーム選択 │ +│ ↓ jawOpen等 → mouthOpenness算出 → updateLipSync(level) │ +│ │ +│ ■ A2Eデータなし (フォールバック): │ +│ setupAudioAnalysis() → AnalyserNode (fftSize=256) │ +│ ↓ startLipSyncLoop() [requestAnimationFrame] │ +│ ↓ getByteFrequencyData → 平均値 → updateLipSync(level) │ +│ │ +│ 共通: gvrm.updateLipSync(0.0 ~ 1.0) │ +│ ↓ VRMManager.setLipSync(level) │ +│ ↓ Jaw/Mouthボーン回転 │ +│ ↓ GaussianSplatting レンダリング → Canvas表示 │ +│ │ +│ 文1再生完了 → 文2再生 → ... → stopAvatarAnimation() │ +│ │ +└──────────────────────────────────────────────────────────────────────┘ +``` + +### 9.2 公式ZIP生成フロー + +``` +┌──────────────────────────────────────────────────────────────────────┐ +│ HF Spaces / ModelScope / ローカルGradio (app_lam.py) │ +├──────────────────────────────────────────────────────────────────────┤ +│ │ +│ 顔画像 (1枚) │ +│ ↓ │ +│ FlameTracking (FaceBoxesV2 → VGGHead → FLAME最適化) │ +│ ↓ FLAME shape/expression パラメータ │ +│ ↓ セグメンテーションマスク │ +│ │ +│ LAM-20K 推論 (DINOv2 + Gaussian Splatting) │ +│ ↓ 3D Gaussian Head Avatar │ +│ ↓ canonical GS + shape param │ +│ │ +│ [Export ZIP for Chatting Avatar] チェック ON の場合: │ +│ ↓ │ +│ 1. save_shaped_mesh() → FLAME メッシュ (.obj) │ +│ 2. save_ply(offset2xyz=True) → offset.ply │ +│ 3. Blender → generateARKITGLBWithBlender.py → skin.glb │ +│ 4. animation.glb をコピー │ +│ 5. patoolib.create_archive() → .zip │ +│ │ +│ 出力: output/open_avatar_chat/.zip │ +│ ├── skin.glb │ +│ ├── offset.ply │ +│ └── animation.glb │ +│ │ +└──────────────────────────────────────────────────────────────────────┘ +``` diff --git a/docs/claude_log_20260224.txt b/docs/claude_log_20260224.txt new file mode 100644 index 0000000..74e8293 --- /dev/null +++ b/docs/claude_log_20260224.txt @@ -0,0 +1,6405 @@ +以下を引継いで、 +現在の状況: audio2exp-service デプロイ(進行中) +やったこと +audio2exp-service を修正し、再ビルド・再デプロイを実施 +--memory 2Gi ではメモリ不足で3回失敗 → 4Gi に増やして完走 +デプロイ完走後のヘルスチェックで NG → 原因調査・対処が必要 +現在のステータス +デプロイ: 完走済み(メモリ4Gi) +ヘルスチェック: NG(未解決) +次のアクション: ヘルスチェックNG原因の調査・修正・再デプロイ +ルール +推測で回答せず、必ず会話ログ・ファイル・記録を確認してから回答すること +確定していない中途半端な情報を書き出さないこと + + + +承知しました。audio2exp-service のヘルスチェック NG 原因の調査を開始します。まずリポジトリの現状を確認します。 + + +Read audio2exp inference engine implementation file +Read audio2exp inference engine implementation file +もっと表示 +もっと表示 + +引継ぎドキュメントと Cloud Run のデプロイ設定を確認します。 + + +Read session handoff and deployment guide documentation +Read session handoff and deployment guide documentation + +Cloud Run のヘルスチェック NG について、まずコードから原因を特定します。重要な問題を発見しました。 + + +ToDoリストを更新しました +ToDoリストを更新しました +Downloading cffi-2.0.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl.metadata (2.6 kB) +Collecting mpmath<1.4,>=1.1.0 (from sympy>=1.13.3->torch>=2.0.0->-r requirements.txt (line 5)) +Downloading mpmath-1.3.0-py3-none-any.whl.metadata (8.6 kB) +Collecting typer>=0.24.0 (from typer-slim->transformers>=4.30.0->-r requirements.txt (line 6)) +Downloading typer-0.24.1-py3-none-any.whl.metadata (16 kB) +Collecting pycparser (from cffi>=1.0->soundfile>=0.12.1->librosa>=0.10.0->-r requirements.txt (line 9)) +Downloading pycparser-3.0-py3-none-any.whl.metadata (8.2 kB) +Collecting anyio (from httpx<1,>=0.23.0->huggingface-hub<2.0,>=1.3.0->transformers>=4.30.0->-r requirements.txt (line 6)) +Downloading anyio-4.12.1-py3-none-any.whl.metadata (4.3 kB) +Collecting certifi (from httpx<1,>=0.23.0->huggingface-hub<2.0,>=1.3.0->transformers>=4.30.0->-r requirements.txt (line 6)) +Downloading certifi-2026.1.4-py3-none-any.whl.metadata (2.5 kB) +Collecting httpcore==1.* (from httpx<1,>=0.23.0->huggingface-hub<2.0,>=1.3.0->transformers>=4.30.0->-r requirements.txt (line 6)) +Downloading httpcore-1.0.9-py3-none-any.whl.metadata (21 kB) +Collecting idna (from httpx<1,>=0.23.0->huggingface-hub<2.0,>=1.3.0->transformers>=4.30.0->-r requirements.txt (line 6)) +Downloading idna-3.11-py3-none-any.whl.metadata (8.4 kB) +Collecting h11>=0.16 (from httpcore==1.*->httpx<1,>=0.23.0->huggingface-hub<2.0,>=1.3.0->transformers>=4.30.0->-r requirements.txt (line 6)) +Downloading h11-0.16.0-py3-none-any.whl.metadata (8.3 kB) +Collecting charset_normalizer<4,>=2 (from requests>=2.19.0->pooch>=1.1->librosa>=0.10.0->-r requirements.txt (line 9)) +Downloading charset_normalizer-3.4.4-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl.metadata (37 kB) +Collecting urllib3<3,>=1.21.1 (from requests>=2.19.0->pooch>=1.1->librosa>=0.10.0->-r requirements.txt (line 9)) +Downloading urllib3-2.6.3-py3-none-any.whl.metadata (6.9 kB) +Collecting rich>=12.3.0 (from typer>=0.24.0->typer-slim->transformers>=4.30.0->-r requirements.txt (line 6)) +Downloading rich-14.3.3-py3-none-any.whl.metadata (18 kB) +Collecting annotated-doc>=0.0.2 (from typer>=0.24.0->typer-slim->transformers>=4.30.0->-r requirements.txt (line 6)) +Downloading annotated_doc-0.0.4-py3-none-any.whl.metadata (6.6 kB) +Collecting markdown-it-py>=2.2.0 (from rich>=12.3.0->typer>=0.24.0->typer-slim->transformers>=4.30.0->-r requirements.txt (line 6)) +Downloading markdown_it_py-4.0.0-py3-none-any.whl.metadata (7.3 kB) +Collecting pygments<3.0.0,>=2.13.0 (from rich>=12.3.0->typer>=0.24.0->typer-slim->transformers>=4.30.0->-r requirements.txt (line 6)) +Downloading pygments-2.19.2-py3-none-any.whl.metadata (2.5 kB) +Collecting mdurl~=0.1 (from markdown-it-py>=2.2.0->rich>=12.3.0->typer>=0.24.0->typer-slim->transformers>=4.30.0->-r requirements.txt (line 6)) +Downloading mdurl-0.1.2-py3-none-any.whl.metadata (1.6 kB) +Downloading flask-3.1.3-py3-none-any.whl (103 kB) +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 103.4/103.4 kB 237.9 MB/s eta 0:00:00 +Downloading flask_cors-6.0.2-py3-none-any.whl (13 kB) +Downloading gunicorn-25.1.0-py3-none-any.whl (197 kB) +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 197.1/197.1 kB 142.0 MB/s eta 0:00:00 +Downloading numpy-2.4.2-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl (16.9 MB) +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 16.9/16.9 MB 220.2 MB/s eta 0:00:00 +Downloading torch-2.10.0-cp311-cp311-manylinux_2_28_x86_64.whl (915.6 MB) +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 915.6/915.6 MB 125.3 MB/s eta 0:00:00 +Downloading cuda_bindings-12.9.4-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl (12.2 MB) +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 12.2/12.2 MB 132.4 MB/s eta 0:00:00 +Downloading nvidia_cublas_cu12-12.8.4.1-py3-none-manylinux_2_27_x86_64.whl (594.3 MB) +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 594.3/594.3 MB 102.0 MB/s eta 0:00:00 +Downloading nvidia_cuda_cupti_cu12-12.8.90-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl (10.2 MB) +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 10.2/10.2 MB 110.1 MB/s eta 0:00:00 +Downloading nvidia_cuda_nvrtc_cu12-12.8.93-py3-none-manylinux2010_x86_64.manylinux_2_12_x86_64.whl (88.0 MB) +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 88.0/88.0 MB 79.8 MB/s eta 0:00:00 +Downloading nvidia_cuda_runtime_cu12-12.8.90-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl (954 kB) +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 954.8/954.8 kB 86.9 MB/s eta 0:00:00 +Downloading nvidia_cudnn_cu12-9.10.2.21-py3-none-manylinux_2_27_x86_64.whl (706.8 MB) +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 706.8/706.8 MB 71.0 MB/s eta 0:00:00 +Downloading nvidia_cufft_cu12-11.3.3.83-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl (193.1 MB) +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 193.1/193.1 MB 59.9 MB/s eta 0:00:00 +Downloading nvidia_cufile_cu12-1.13.1.3-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl (1.2 MB) +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 1.2/1.2 MB 68.1 MB/s eta 0:00:00 +Downloading nvidia_curand_cu12-10.3.9.90-py3-none-manylinux_2_27_x86_64.whl (63.6 MB) +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 63.6/63.6 MB 52.8 MB/s eta 0:00:00 +Downloading nvidia_cusolver_cu12-11.7.3.90-py3-none-manylinux_2_27_x86_64.whl (267.5 MB) +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 267.5/267.5 MB 94.4 MB/s eta 0:00:00 +Downloading nvidia_cusparse_cu12-12.5.8.93-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl (288.2 MB) +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 288.2/288.2 MB 129.5 MB/s eta 0:00:00 +Downloading nvidia_cusparselt_cu12-0.7.1-py3-none-manylinux2014_x86_64.whl (287.2 MB) +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 287.2/287.2 MB 153.1 MB/s eta 0:00:00 +Downloading nvidia_nccl_cu12-2.27.5-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl (322.3 MB) +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 322.3/322.3 MB 176.4 MB/s eta 0:00:00 +Downloading nvidia_nvjitlink_cu12-12.8.93-py3-none-manylinux2010_x86_64.manylinux_2_12_x86_64.whl (39.3 MB) +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 39.3/39.3 MB 189.9 MB/s eta 0:00:00 +Downloading nvidia_nvshmem_cu12-3.4.5-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl (139.1 MB) +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 139.1/139.1 MB 198.8 MB/s eta 0:00:00 +Downloading nvidia_nvtx_cu12-12.8.90-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl (89 kB) +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 90.0/90.0 kB 203.9 MB/s eta 0:00:00 +Downloading triton-3.6.0-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl (188.2 MB) +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 188.2/188.2 MB 190.6 MB/s eta 0:00:00 +Downloading transformers-5.2.0-py3-none-any.whl (10.4 MB) +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 10.4/10.4 MB 190.2 MB/s eta 0:00:00 +Downloading pydub-0.25.1-py2.py3-none-any.whl (32 kB) +Downloading librosa-0.11.0-py3-none-any.whl (260 kB) +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 260.7/260.7 kB 240.2 MB/s eta 0:00:00 +Downloading scipy-1.17.0-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl (35.1 MB) +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 35.1/35.1 MB 203.7 MB/s eta 0:00:00 +Downloading addict-2.4.0-py3-none-any.whl (3.8 kB) +Downloading yapf-0.43.0-py3-none-any.whl (256 kB) +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 256.2/256.2 kB 258.8 MB/s eta 0:00:00 +Downloading termcolor-3.3.0-py3-none-any.whl (7.7 kB) +Downloading audioread-3.1.0-py3-none-any.whl (23 kB) +Downloading blinker-1.9.0-py3-none-any.whl (8.5 kB) +Downloading click-8.3.1-py3-none-any.whl (108 kB) +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 108.3/108.3 kB 236.0 MB/s eta 0:00:00 +Downloading decorator-5.2.1-py3-none-any.whl (9.2 kB) +Downloading fsspec-2026.2.0-py3-none-any.whl (202 kB) +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 202.5/202.5 kB 247.8 MB/s eta 0:00:00 +Downloading huggingface_hub-1.4.1-py3-none-any.whl (553 kB) +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 553.3/553.3 kB 227.2 MB/s eta 0:00:00 +Downloading itsdangerous-2.2.0-py3-none-any.whl (16 kB) +Downloading jinja2-3.1.6-py3-none-any.whl (134 kB) +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 134.9/134.9 kB 190.0 MB/s eta 0:00:00 +Downloading joblib-1.5.3-py3-none-any.whl (309 kB) +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 309.1/309.1 kB 242.1 MB/s eta 0:00:00 +Downloading lazy_loader-0.4-py3-none-any.whl (12 kB) +Downloading markupsafe-3.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl (22 kB) +Downloading msgpack-1.1.2-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl (426 kB) +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 426.2/426.2 kB 280.4 MB/s eta 0:00:00 +Downloading networkx-3.6.1-py3-none-any.whl (2.1 MB) +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 2.1/2.1 MB 194.6 MB/s eta 0:00:00 +Downloading numba-0.64.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl (3.7 MB) +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 3.7/3.7 MB 174.0 MB/s eta 0:00:00 +Downloading packaging-26.0-py3-none-any.whl (74 kB) +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 74.4/74.4 kB 187.4 MB/s eta 0:00:00 +Downloading platformdirs-4.9.2-py3-none-any.whl (21 kB) +Downloading pooch-1.9.0-py3-none-any.whl (67 kB) +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 67.2/67.2 kB 205.4 MB/s eta 0:00:00 +Downloading pyyaml-6.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl (806 kB) +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 806.6/806.6 kB 255.1 MB/s eta 0:00:00 +Downloading regex-2026.2.19-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl (800 kB) +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 800.6/800.6 kB 199.1 MB/s eta 0:00:00 +Downloading safetensors-0.7.0-cp38-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl (507 kB) +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 507.2/507.2 kB 279.3 MB/s eta 0:00:00 +Downloading scikit_learn-1.8.0-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl (9.1 MB) +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 9.1/9.1 MB 151.3 MB/s eta 0:00:00 +Downloading soundfile-0.13.1-py2.py3-none-manylinux_2_28_x86_64.whl (1.3 MB) +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 1.3/1.3 MB 213.2 MB/s eta 0:00:00 +Downloading soxr-1.0.0-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl (242 kB) +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 242.6/242.6 kB 250.6 MB/s eta 0:00:00 +Downloading sympy-1.14.0-py3-none-any.whl (6.3 MB) +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 6.3/6.3 MB 191.5 MB/s eta 0:00:00 +Downloading tokenizers-0.22.2-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl (3.3 MB) +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 3.3/3.3 MB 214.4 MB/s eta 0:00:00 +Downloading tqdm-4.67.3-py3-none-any.whl (78 kB) +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 78.4/78.4 kB 228.8 MB/s eta 0:00:00 +Downloading typing_extensions-4.15.0-py3-none-any.whl (44 kB) +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 44.6/44.6 kB 186.2 MB/s eta 0:00:00 +Downloading werkzeug-3.1.6-py3-none-any.whl (225 kB) +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 225.2/225.2 kB 274.5 MB/s eta 0:00:00 +Downloading filelock-3.24.3-py3-none-any.whl (24 kB) +Downloading typer_slim-0.24.0-py3-none-any.whl (3.4 kB) +Downloading cffi-2.0.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl (215 kB) +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 215.6/215.6 kB 245.2 MB/s eta 0:00:00 +Downloading cuda_pathfinder-1.3.4-py3-none-any.whl (30 kB) +Downloading hf_xet-1.2.0-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl (3.3 MB) +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 3.3/3.3 MB 145.6 MB/s eta 0:00:00 +Downloading httpx-0.28.1-py3-none-any.whl (73 kB) +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 73.5/73.5 kB 211.9 MB/s eta 0:00:00 +Downloading httpcore-1.0.9-py3-none-any.whl (78 kB) +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 78.8/78.8 kB 235.5 MB/s eta 0:00:00 +Downloading llvmlite-0.46.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl (56.3 MB) +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 56.3/56.3 MB 151.3 MB/s eta 0:00:00 +Downloading mpmath-1.3.0-py3-none-any.whl (536 kB) +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 536.2/536.2 kB 241.4 MB/s eta 0:00:00 +Downloading requests-2.32.5-py3-none-any.whl (64 kB) +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 64.7/64.7 kB 180.6 MB/s eta 0:00:00 +Downloading threadpoolctl-3.6.0-py3-none-any.whl (18 kB) +Downloading typer-0.24.1-py3-none-any.whl (56 kB) +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 56.1/56.1 kB 201.8 MB/s eta 0:00:00 +Downloading shellingham-1.5.4-py2.py3-none-any.whl (9.8 kB) +Downloading annotated_doc-0.0.4-py3-none-any.whl (5.3 kB) +Downloading certifi-2026.1.4-py3-none-any.whl (152 kB) +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 152.9/152.9 kB 260.3 MB/s eta 0:00:00 +Downloading charset_normalizer-3.4.4-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl (151 kB) +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 151.6/151.6 kB 223.3 MB/s eta 0:00:00 +Downloading idna-3.11-py3-none-any.whl (71 kB) +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 71.0/71.0 kB 211.6 MB/s eta 0:00:00 +Downloading rich-14.3.3-py3-none-any.whl (310 kB) +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 310.5/310.5 kB 267.6 MB/s eta 0:00:00 +Downloading urllib3-2.6.3-py3-none-any.whl (131 kB) +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 131.6/131.6 kB 234.7 MB/s eta 0:00:00 +Downloading anyio-4.12.1-py3-none-any.whl (113 kB) +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 113.6/113.6 kB 244.1 MB/s eta 0:00:00 +Downloading pycparser-3.0-py3-none-any.whl (48 kB) +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 48.2/48.2 kB 184.0 MB/s eta 0:00:00 +Downloading h11-0.16.0-py3-none-any.whl (37 kB) +Downloading markdown_it_py-4.0.0-py3-none-any.whl (87 kB) +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 87.3/87.3 kB 228.8 MB/s eta 0:00:00 +Downloading pygments-2.19.2-py3-none-any.whl (1.2 MB) +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 1.2/1.2 MB 157.9 MB/s eta 0:00:00 +Downloading mdurl-0.1.2-py3-none-any.whl (10.0 kB) +Installing collected packages: pydub, nvidia-cusparselt-cu12, mpmath, addict, urllib3, typing-extensions, triton, tqdm, threadpoolctl, termcolor, sympy, shellingham, safetensors, regex, pyyaml, pygments, pycparser, platformdirs, packaging, nvidia-nvtx-cu12, nvidia-nvshmem-cu12, nvidia-nvjitlink-cu12, nvidia-nccl-cu12, nvidia-curand-cu12, nvidia-cufile-cu12, nvidia-cuda-runtime-cu12, nvidia-cuda-nvrtc-cu12, nvidia-cuda-cupti-cu12, nvidia-cublas-cu12, numpy, networkx, msgpack, mdurl, markupsafe, llvmlite, joblib, itsdangerous, idna, hf-xet, h11, fsspec, filelock, decorator, cuda-pathfinder, click, charset_normalizer, certifi, blinker, audioread, annotated-doc, yapf, werkzeug, soxr, scipy, requests, nvidia-cusparse-cu12, nvidia-cufft-cu12, nvidia-cudnn-cu12, numba, markdown-it-py, lazy_loader, jinja2, httpcore, gunicorn, cuda-bindings, cffi, anyio, soundfile, scikit-learn, rich, pooch, nvidia-cusolver-cu12, httpx, flask, typer, torch, librosa, flask-cors, typer-slim, huggingface-hub, tokenizers, transformers +Successfully installed addict-2.4.0 annotated-doc-0.0.4 anyio-4.12.1 audioread-3.1.0 blinker-1.9.0 certifi-2026.1.4 cffi-2.0.0 charset_normalizer-3.4.4 click-8.3.1 cuda-bindings-12.9.4 cuda-pathfinder-1.3.4 decorator-5.2.1 filelock-3.24.3 flask-3.1.3 flask-cors-6.0.2 fsspec-2026.2.0 gunicorn-25.1.0 h11-0.16.0 hf-xet-1.2.0 httpcore-1.0.9 httpx-0.28.1 huggingface-hub-1.4.1 idna-3.11 itsdangerous-2.2.0 jinja2-3.1.6 joblib-1.5.3 lazy_loader-0.4 librosa-0.11.0 llvmlite-0.46.0 markdown-it-py-4.0.0 markupsafe-3.0.3 mdurl-0.1.2 mpmath-1.3.0 msgpack-1.1.2 networkx-3.6.1 numba-0.64.0 numpy-2.4.2 nvidia-cublas-cu12-12.8.4.1 nvidia-cuda-cupti-cu12-12.8.90 nvidia-cuda-nvrtc-cu12-12.8.93 nvidia-cuda-runtime-cu12-12.8.90 nvidia-cudnn-cu12-9.10.2.21 nvidia-cufft-cu12-11.3.3.83 nvidia-cufile-cu12-1.13.1.3 nvidia-curand-cu12-10.3.9.90 nvidia-cusolver-cu12-11.7.3.90 nvidia-cusparse-cu12-12.5.8.93 nvidia-cusparselt-cu12-0.7.1 nvidia-nccl-cu12-2.27.5 nvidia-nvjitlink-cu12-12.8.93 nvidia-nvshmem-cu12-3.4.5 nvidia-nvtx-cu12-12.8.90 packaging-26.0 platformdirs-4.9.2 pooch-1.9.0 pycparser-3.0 pydub-0.25.1 pygments-2.19.2 pyyaml-6.0.3 regex-2026.2.19 requests-2.32.5 rich-14.3.3 safetensors-0.7.0 scikit-learn-1.8.0 scipy-1.17.0 shellingham-1.5.4 soundfile-0.13.1 soxr-1.0.0 sympy-1.14.0 termcolor-3.3.0 threadpoolctl-3.6.0 tokenizers-0.22.2 torch-2.10.0 tqdm-4.67.3 transformers-5.2.0 triton-3.6.0 typer-0.24.1 typer-slim-0.24.0 typing-extensions-4.15.0 urllib3-2.6.3 werkzeug-3.1.6 yapf-0.43.0 +WARNING: Running pip as the 'root' user can result in broken permissions and conflicting behaviour with the system package manager. It is recommended to use a virtual environment instead: https://pip.pypa.io/warnings/venv +[notice] A new release of pip is available: 24.0 -> 26.0.1 +[notice] To update, run: pip install --upgrade pip +Removing intermediate container daaceb51576a +f72dce82ea6f +Step 6/13 : COPY . . +b82300a87662 +Step 7/13 : RUN mkdir -p /app/models +Running in 8f498e12240c +Removing intermediate container 8f498e12240c +c8bba51d31f8 +Step 8/13 : RUN mkdir -p /tmp/audio2exp_logs/model +Running in 1b30b59417c7 +Removing intermediate container 1b30b59417c7 +604a123b4256 +Step 9/13 : ENV PORT=8080 +Running in 954f3d847efd +Removing intermediate container 954f3d847efd +894b7e4bd873 +Step 10/13 : ENV MODEL_DIR=/app/models +Running in d44f4b5ccc4a +Removing intermediate container d44f4b5ccc4a +4b4f648d16c4 +Step 11/13 : ENV DEVICE=cpu +Running in c68a1b4c905d +Removing intermediate container c68a1b4c905d +88ad4330e447 +Step 12/13 : EXPOSE 8080 +Running in 2710d2df74f8 +Removing intermediate container 2710d2df74f8 +9da2ecd299d7 +Step 13/13 : CMD gunicorn --bind "0.0.0.0:${PORT}" --timeout 120 --workers 1 --threads 4 app:app +Running in 0fbdd152d374 +Removing intermediate container 0fbdd152d374 +a3ea386e9406 +Successfully built a3ea386e9406 +Successfully tagged gcr.io/hp-support-477512/audio2exp-service:latest +PUSH +Pushing gcr.io/hp-support-477512/audio2exp-service +The push refers to repository [gcr.io/hp-support-477512/audio2exp-service] +7735788a6ea5: Preparing +937c069abe30: Preparing +d988fbc8ca05: Preparing +c98c17c70643: Preparing +2541b45b2244: Preparing +7a42e4dced7f: Preparing +b69aea4cac7d: Preparing +40b88e8d19a2: Preparing +dfd9efb4ec4c: Preparing +a8ff6f8cbdfd: Preparing +dfd9efb4ec4c: Layer already exists +40b88e8d19a2: Layer already exists +a8ff6f8cbdfd: Layer already exists +b69aea4cac7d: Layer already exists +7735788a6ea5: Pushed +2541b45b2244: Pushed +c98c17c70643: Pushed +7a42e4dced7f: Pushed +937c069abe30: Pushed +d988fbc8ca05: Pushed +latest: digest: sha256:92b2e372775ebe618fa8c0c4fe99f1ffb9b2fdf76e45449e4a11139410504b68 size: 2421 +DONE +ID: 961bb998-3b86-450f-b97d-297d3cbe2b6c +CREATE_TIME: 2026-02-22T18:35:39+00:00 +DURATION: 15M29S +SOURCE: gs://hp-support-477512_cloudbuild/source/1771783919.492838-80de3756d1914f64a7ca85835d59885e.tgz +IMAGES: gcr.io/hp-support-477512/audio2exp-service (+1 more) +STATUS: SUCCESS +Deploying container to Cloud Run service [audio2exp-service] in project [hp-support-477512] region [us-central1] +OK Deploying... Done. +OK Creating Revision... +OK Routing traffic... +OK Setting IAM Policy... +Done. +Service [audio2exp-service] revision [audio2exp-service-00021-vnq] has been deployed and is serving 100 percent of traffic. +Service URL: https://audio2exp-service-417509577941.us-central1.run.app +PS C:\Users\hamad\audio2exp-service> curl https://audio2exp-service-417509577941.us-central1.run.app/health + +{"engine_ready":false,"error":null,"model_dir":"/app/models","status":"loading"} +PS C:\Users\hamad\audio2exp-service> curl https://audio2exp-service-417509577941.us-central1.run.app/health + +{"engine_ready":false,"error":null,"model_dir":"/app/models","status":"loading"} +PS C:\Users\hamad\audio2exp-service> curl https://audio2exp-service-417509577941.us-central1.run.app/health + +{"engine_ready":false,"error":null,"model_dir":"/app/models","status":"loading"} +PS C:\Users\hamad\audio2exp-service> gcloud run services logs read audio2exp-service --region us-central1 --limit 50 + +PS C:\Users\hamad\audio2exp-service> https://console.cloud.google.com/run/detail/us-central1/audio2exp-service/logs?project=hp-support-477512 + +https://console.cloud.google.com/run/detail/us-central1/audio2exp-service/logs?project=hp-support-477512: The term 'https://console.cloud.google.com/run/detail/us-central1/audio2exp-service/logs?project=hp-support-477512' is not recognized as a name of a cmdlet, function, script file, or executable program. +Check the spelling of the name, or if a path was included, verify that the path is correct and try again. +PS C:\Users\hamad\audio2exp-service> dir C:\Users\hamad\audio2exp-service\models -Recurse + +Directory: C:\Users\hamad\audio2exp-service\models + +Mode LastWriteTime Length Name + +d---- 2026/02/22 15:37 .cache +d---- 2025/04/17 22:30 pretrained_models +d---- 2026/02/22 15:37 wav2vec2-base-960h +-a--- 2026/02/22 15:37 1519 .gitattributes +-a--- 2026/02/22 15:37 32798496 LAM_audio2exp_assets.tar +-a--- 2026/02/07 12:27 373377643 LAM_audio2exp_streaming.tar +-a--- 2026/02/22 15:37 31 README.md +Directory: C:\Users\hamad\audio2exp-service\models.cache +Mode LastWriteTime Length Name + +d---- 2026/02/22 15:37 huggingface +Directory: C:\Users\hamad\audio2exp-service\models.cache\huggingface +Mode LastWriteTime Length Name + +d---- 2026/02/22 15:37 download +-a--- 2026/02/22 15:37 1 .gitignore +Directory: C:\Users\hamad\audio2exp-service\models.cache\huggingface\download +Mode LastWriteTime Length Name + +-a--- 2026/02/22 15:37 102 .gitattributes.metadata +-a--- 2026/02/22 15:37 128 LAM_audio2exp_assets.tar.metadata +-a--- 2026/02/22 15:37 128 LAM_audio2exp_streaming.tar.metadata +-a--- 2026/02/22 15:37 104 README.md.metadata +Directory: C:\Users\hamad\audio2exp-service\models\pretrained_models +Mode LastWriteTime Length Name + +-a--- 2025/04/15 17:01 408538564 lam_audio2exp_streaming.tar +Directory: C:\Users\hamad\audio2exp-service\models\wav2vec2-base-960h +Mode LastWriteTime Length Name + +d---- 2026/02/22 15:37 .cache +-a--- 2026/02/22 15:37 790 .gitattributes +-a--- 2026/02/22 15:37 1596 config.json +-a--- 2026/02/06 2:44 65 configuration.json +-a--- 2026/02/22 15:37 158 feature_extractor_config.json +-a--- 2026/02/06 2:48 377607901 model.safetensors +-a--- 2026/02/22 15:37 159 preprocessor_config.json +-a--- 2026/02/06 2:46 377667514 pytorch_model.bin +-a--- 2026/02/22 15:37 4431 README.md +-a--- 2026/02/22 15:37 85 special_tokens_map.json +-a--- 2026/02/06 2:51 377840624 tf_model.h5 +-a--- 2026/02/22 15:37 163 tokenizer_config.json +-a--- 2026/02/22 15:37 291 vocab.json +Directory: C:\Users\hamad\audio2exp-service\models\wav2vec2-base-960h.cache +Mode LastWriteTime Length Name + +d---- 2026/02/22 15:37 huggingface +Directory: C:\Users\hamad\audio2exp-service\models\wav2vec2-base-960h.cache\huggingface +Mode LastWriteTime Length Name + +d---- 2026/02/22 15:37 download +-a--- 2026/02/22 15:37 1 .gitignore +Directory: C:\Users\hamad\audio2exp-service\models\wav2vec2-base-960h.cache\huggingface\download +Mode LastWriteTime Length Name + +-a--- 2026/02/22 15:37 104 .gitattributes.metadata +-a--- 2026/02/22 15:37 103 config.json.metadata +-a--- 2026/02/22 15:37 104 feature_extractor_config.json.metadata +-a--- 2026/02/22 15:37 128 model.safetensors.metadata +-a--- 2026/02/22 15:37 104 preprocessor_config.json.metadata +-a--- 2026/02/22 15:37 128 pytorch_model.bin.metadata +-a--- 2026/02/22 15:37 104 README.md.metadata +-a--- 2026/02/22 15:37 103 special_tokens_map.json.metadata +-a--- 2026/02/22 15:37 126 tf_model.h5.metadata +-a--- 2026/02/22 15:37 103 tokenizer_config.json.metadata +-a--- 2026/02/22 15:37 104 vocab.json.metadata +PS C:\Users\hamad\audio2exp-service> cat C:\Users\hamad\audio2exp-service.gcloudignore + +.gcloudignore - Cloud Build用の除外設定 +★ models/ は除外しない(Dockerイメージにベイクインするため) +pycache/ +*.pyc +.git +.gitignore +PS C:\Users\hamad\audio2exp-service> SOURCE: gs://hp-support-477512_cloudbuild/source/1771783919.492838-...tgz + +SOURCE:: The term 'SOURCE:' is not recognized as a name of a cmdlet, function, script file, or executable program. +Check the spelling of the name, or if a path was included, verify that the path is correct and try again. +PS C:\Users\hamad\audio2exp-service> cat C:\Users\hamad\audio2exp-service.dockerignore + +Get-Content: Cannot find path 'C:\Users\hamad\audio2exp-service.dockerignore' because it does not exist. +PS C:\Users\hamad\audio2exp-service> cat C:\Users\hamad\audio2exp-service.dockerignore + +Get-Content: Cannot find path 'C:\Users\hamad\audio2exp-service.dockerignore' because it does not exist. +PS C:\Users\hamad\audio2exp-service> gcloud run services describe audio2exp-service --region us-central1 --format "value(spec.template.spec.containers[0].resources.limits)" + +ERROR: (gcloud.run.services.describe) Cannot find service [audio2exp-service] +PS C:\Users\hamad\audio2exp-service> gcloud run services describe audio2exp-service --region us-central1 --project hp-support-477512 + +✔ Service audio2exp-service in region us-central1 +URL: https://audio2exp-service-417509577941.us-central1.run.app +Ingress: all +Traffic: +100% LATEST (currently audio2exp-service-00021-vnq) +Scaling: Auto (Min: 0, Max: 3) +Last updated on 2026-02-22T18:56:09.089689Z by gpro.mirai@gmail.com: +Revision audio2exp-service-00021-vnq +Container None +Image: gcr.io/hp-support-477512/audio2exp-service +Port: 8080 +Memory: 4Gi +CPU: 2 +Env vars: +DEVICE cpu +MODEL_DIR /app/models +Volume Mounts: +/mnt/models +name: models +type: cloud-storage +bucket: hp-support-477512-models +Startup Probe: +TCP every 240s +Port: 8080 +Initial delay: 0s +Timeout: 240s +Failure threshold: 1 +Type: Default +Service account: 417509577941-compute@developer.gserviceaccount.com +Concurrency: 10 +Max instances: 3 +Timeout: 300s +Execution Environment: Second Generation +Volumes: +models +type: cloud-storage +bucket: hp-support-477512-models +models-volume +type: cloud-storage +bucket: hp-support-477512-models +PS C:\Users\hamad\audio2exp-service> gcloud run services logs read audio2exp-service --region us-central1 --project hp-support-477512 --limit 50 + +2026-02-22 19:11:23 Loading weights: 99%|█████████▉| 208/210 [00:01<00:00, 54.72it/s, Materializing param=feature_projection.layer_norm.weight] +2026-02-22 19:11:23 Loading weights: 100%|█████████▉| 209/210 [00:01<00:00, 54.72it/s, Materializing param=feature_projection.projection.bias] +2026-02-22 19:11:23 Loading weights: 100%|█████████▉| 209/210 [00:01<00:00, 54.72it/s, Materializing param=feature_projection.projection.bias] +2026-02-22 19:11:23 Loading weights: 100%|██████████| 210/210 [00:01<00:00, 54.72it/s, Materializing param=feature_projection.projection.weight] +2026-02-22 19:11:23 Loading weights: 100%|██████████| 210/210 [00:01<00:00, 54.72it/s, Materializing param=feature_projection.projection.weight] +2026-02-22 19:11:23 Loading weights: 100%|██████████| 210/210 [00:01<00:00, 124.04it/s, Materializing param=feature_projection.projection.weight] +2026-02-22 19:11:23 Wav2Vec2Model LOAD REPORT from: /app/models/wav2vec2-base-960h +2026-02-22 19:11:23 Key | Status | +2026-02-22 19:11:23 ------------------+------------+- +2026-02-22 19:11:23 lm_head.bias | UNEXPECTED | +2026-02-22 19:11:23 lm_head.weight | UNEXPECTED | +2026-02-22 19:11:23 masked_spec_embed | MISSING | +2026-02-22 19:11:23 Notes: +2026-02-22 19:11:23 - UNEXPECTED :can be ignored when loading from different task/architecture; not ok if you expect identical arch. +2026-02-22 19:11:23 - MISSING :those params were newly initialized because missing from the checkpoint. Consider training on your downstream task. +2026-02-22 19:11:23 2026-02-22 19:11:23,681 [INFO] [A2E Engine] Wav2Vec2 loaded (fallback mode) +2026-02-22 19:11:23 2026-02-22 19:11:23,681 [INFO] [A2E Engine] Ready (Wav2Vec2 fallback mode) +2026-02-22 19:11:23 2026-02-22 19:11:23,681 [INFO] [Audio2Exp] Engine ready in 899.5s +2026-02-22 23:22:15 GET 200 https://audio2exp-service-417509577941.us-central1.run.app/health +2026-02-22 23:22:16 [2026-02-22 23:22:16 +0000] [10] [INFO] Starting gunicorn 25.1.0 +2026-02-22 23:22:16 [2026-02-22 23:22:16 +0000] [10] [INFO] Listening at: http://0.0.0.0:8080 (10) +2026-02-22 23:22:16 [2026-02-22 23:22:16 +0000] [10] [INFO] Using worker: gthread +2026-02-22 23:22:16 [2026-02-22 23:22:16 +0000] [10] [INFO] Control socket listening at /app/gunicorn.ctl +2026-02-22 23:22:16 [2026-02-22 23:22:16 +0000] [12] [INFO] Booting worker with pid: 12 +2026-02-22 23:22:17 2026-02-22 23:22:17,526 [INFO] [Audio2Exp] Server started, engine loading in background... +2026-02-22 23:22:21 2026-02-22 23:22:21,857 [INFO] [Audio2Exp] Loading engine: model_dir=/app/models, device=cpu +2026-02-22 23:23:25 GET 200 https://audio2exp-service-417509577941.us-central1.run.app/health +2026-02-22 23:25:07 GET 200 https://audio2exp-service-417509577941.us-central1.run.app/health +2026-02-22 23:28:47 2026-02-22 23:28:47,059 [INFO] [A2E Engine] Device: cpu +2026-02-22 23:28:47 2026-02-22 23:28:47,155 [INFO] [A2E Engine] Found LAM_Audio2Expression: /app/LAM_Audio2Expression +2026-02-22 23:28:47 2026-02-22 23:28:47,356 [INFO] [A2E Engine] Checkpoint: /app/models/pretrained_models/lam_audio2exp_streaming.tar +2026-02-22 23:28:47 2026-02-22 23:28:47,356 [INFO] [A2E Engine] Wav2Vec2: /app/models/wav2vec2-base-960h +2026-02-22 23:29:35 2026-02-22 23:29:35,556 [WARNING] [A2E Engine] INFER import failed: No module named 'torchaudio' +2026-02-22 23:29:35 Traceback (most recent call last): +File "/app/a2e_engine.py", line 251, in _try_load_infer_pipeline +from engines.infer import INFER +File "/app/LAM_Audio2Expression/engines/infer.py", line 31, in +from models import build_model +File "/app/LAM_Audio2Expression/models/init.py", line 6, in +from .network import Audio2Expression +File "/app/LAM_Audio2Expression/models/network.py", line 8, in +import torchaudio as ta +ModuleNotFoundError: No module named 'torchaudio' +2026-02-22 23:29:35 2026-02-22 23:29:35,756 [WARNING] [A2E Engine] INFER pipeline unavailable, loading Wav2Vec2 fallback +PS C:\Users\hamad\audio2exp-service> gcloud builds submit --tag "$IMAGE_NAME" --project "$PROJECT_ID" + +gcloud run deploy "$SERVICE_NAME" --image "$IMAGE_NAME" +--region "$REGION" --allow-unauthenticated +--memory 4Gi --cpu 2 +--timeout 300 --cpu-boost +--min-instances 0 --max-instances 3 +--set-env-vars "MODEL_DIR=/app/models,DEVICE=cpu" ` +--project "$PROJECT_ID" + +Creating temporary archive of 97 file(s) totalling 1.8 GiB before compression. +Uploading tarball of [.] to [gs://hp-support-477512_cloudbuild/source/1771803997.405528-0d213767221d4f1b8bf3aa4a8b1c61e9.tgz] +Created [https://cloudbuild.googleapis.com/v1/projects/hp-support-477512/locations/global/builds/74c0e980-00c8-4dba-b056-d21b2800cde4]. +Logs are available at [ https://console.cloud.google.com/cloud-build/builds/74c0e980-00c8-4dba-b056-d21b2800cde4?project=417509577941 ]. +Waiting for build to complete. Polling interval: 1 second(s). +------------------------------------------------- REMOTE BUILD OUTPUT -------------------------------------------------- +starting build "74c0e980-00c8-4dba-b056-d21b2800cde4" +FETCHSOURCE +Fetching storage object: gs://hp-support-477512_cloudbuild/source/1771803997.405528-0d213767221d4f1b8bf3aa4a8b1c61e9.tgz#1771804801631195 +Copying gs://hp-support-477512_cloudbuild/source/1771803997.405528-0d213767221d4f1b8bf3aa4a8b1c61e9.tgz#1771804801631195... +/ [1 files][ 1.4 GiB/ 1.4 GiB] 65.2 MiB/s +Operation completed over 1 objects/1.4 GiB. +BUILD +Already have image (with digest): gcr.io/cloud-builders/gcb-internal +Sending build context to Docker daemon 1.953GB +Step 1/13 : FROM python:3.11-slim +3.11-slim: Pulling from library/python +0c8d55a45c0d: Already exists +64faa99400e1: Pulling fs layer +8cbc47ff628d: Pulling fs layer +d85099f0969e: Pulling fs layer +d85099f0969e: Verifying Checksum +d85099f0969e: Download complete +64faa99400e1: Verifying Checksum +64faa99400e1: Download complete +8cbc47ff628d: Verifying Checksum +8cbc47ff628d: Download complete +64faa99400e1: Pull complete +8cbc47ff628d: Pull complete +d85099f0969e: Pull complete +Digest: sha256:0b23cfb7425d065008b778022a17b1551c82f8b4866ee5a7a200084b7e2eafbf +Status: Downloaded newer image for python:3.11-slim +466c0182639b +Step 2/13 : RUN apt-get update && apt-get install -y --no-install-recommends ffmpeg libsndfile1 && rm -rf /var/lib/apt/lists/* +Running in 260c6ab32750 +Hit:1 http://deb.debian.org/debian trixie InRelease +Get:2 http://deb.debian.org/debian trixie-updates InRelease [47.3 kB] +Get:3 http://deb.debian.org/debian-security trixie-security InRelease [43.4 kB] +Get:4 http://deb.debian.org/debian trixie/main amd64 Packages [9670 kB] +Get:5 http://deb.debian.org/debian trixie-updates/main amd64 Packages [5412 B] +Get:6 http://deb.debian.org/debian-security trixie-security/main amd64 Packages [112 kB] +Fetched 9879 kB in 1s (7922 kB/s) +Reading package lists... +Reading package lists... +Building dependency tree... +Reading state information... +The following additional packages will be installed: +fontconfig fontconfig-config fonts-dejavu-core fonts-dejavu-mono libaom3 +libasound2-data libasound2t64 libass9 libasyncns0 libatomic1 libavc1394-0 +libavcodec61 libavdevice61 libavfilter10 libavformat61 libavutil59 libblas3 +libbluray2 libbrotli1 libbs2b0 libcaca0 libcairo-gobject2 libcairo2 +libcdio-cdda2t64 libcdio-paranoia2t64 libcdio19t64 libchromaprint1 libcjson1 +libcodec2-1.2 libcom-err2 libdatrie1 libdav1d7 libdbus-1-3 libdc1394-25 +libdecor-0-0 libdeflate0 libdrm-amdgpu1 libdrm-common libdrm-intel1 libdrm2 +libdvdnav4 libdvdread8t64 libedit2 libelf1t64 libexpat1 libfftw3-double3 +libflac14 libflite1 libfontconfig1 libfreetype6 libfribidi0 libgbm1 +libgdk-pixbuf-2.0-0 libgdk-pixbuf2.0-common libgfortran5 libgl1 +libgl1-mesa-dri libglib2.0-0t64 libglvnd0 libglx-mesa0 libglx0 libgme0 +libgnutls30t64 libgomp1 libgraphite2-3 libgsm1 libgssapi-krb5-2 +libharfbuzz0b libhwy1t64 libidn2-0 libiec61883-0 libjack-jackd2-0 libjbig0 +libjpeg62-turbo libjxl0.11 libk5crypto3 libkeyutils1 libkrb5-3 +libkrb5support0 liblapack3 liblcms2-2 liblerc4 liblilv-0-0 libllvm19 +libmbedcrypto16 libmp3lame0 libmpg123-0t64 libmysofa1 libnorm1t64 libnuma1 +libogg0 libopenal-data libopenal1 libopenjp2-7 libopenmpt0t64 libopus0 +libp11-kit0 libpango-1.0-0 libpangocairo-1.0-0 libpangoft2-1.0-0 +libpciaccess0 libpgm-5.3-0t64 libpixman-1-0 libplacebo349 libpng16-16t64 +libpocketsphinx3 libpostproc58 libpulse0 librabbitmq4 librav1e0.7 +libraw1394-11 librist4 librsvg2-2 librubberband2 libsamplerate0 +libsdl2-2.0-0 libsensors-config libsensors5 libserd-0-0 libsharpyuv0 +libshine3 libslang2 libsnappy1v5 libsodium23 libsord-0-0 libsoxr0 libspeex1 +libsphinxbase3t64 libsratom-0-0 libsrt1.5-gnutls libssh-4 libsvtav1enc2 +libswresample5 libswscale8 libtasn1-6 libthai-data libthai0 libtheoradec1 +libtheoraenc1 libtiff6 libtwolame0 libudfread0 libunibreak6 libunistring5 +libusb-1.0-0 libva-drm2 libva-x11-2 libva2 libvdpau1 libvidstab1.1 +libvorbis0a libvorbisenc2 libvorbisfile3 libvpl2 libvpx9 libvulkan1 +libwayland-client0 libwayland-cursor0 libwayland-egl1 libwayland-server0 +libwebp7 libwebpmux3 libx11-6 libx11-data libx11-xcb1 libx264-164 +libx265-215 libxau6 libxcb-dri3-0 libxcb-glx0 libxcb-present0 libxcb-randr0 +libxcb-render0 libxcb-shape0 libxcb-shm0 libxcb-sync1 libxcb-xfixes0 libxcb1 +libxcursor1 libxdmcp6 libxext6 libxfixes3 libxi6 libxkbcommon0 libxml2 +libxrandr2 libxrender1 libxshmfence1 libxss1 libxv1 libxvidcore4 libxxf86vm1 +libz3-4 libzimg2 libzix-0-0 libzmq5 libzvbi-common libzvbi0t64 +mesa-libgallium ocl-icd-libopencl1 shared-mime-info x11-common xkb-data +Suggested packages: +ffmpeg-doc alsa-utils libasound2-plugins libcuda1 libnvcuvid1 +libnvidia-encode1 libbluray-bdj libdvdcss2 libfftw3-bin libfftw3-dev +low-memory-monitor gnutls-bin krb5-doc krb5-user jackd2 liblcms2-utils +libportaudio2 libsndio7.0 opus-tools pciutils pulseaudio libraw1394-doc +librsvg2-bin xdg-utils lm-sensors serdi sordi speex opencl-icd +Recommended packages: +alsa-ucm-conf alsa-topology-conf libaacs0 dbus default-libdecor-0-plugin-1 +| libdecor-0-plugin-1 libgdk-pixbuf2.0-bin libglib2.0-data xdg-user-dirs +krb5-locales pocketsphinx-en-us librsvg2-common va-driver-all | va-driver +vdpau-driver-all | vdpau-driver mesa-vulkan-drivers | vulkan-icd +The following NEW packages will be installed: +ffmpeg fontconfig fontconfig-config fonts-dejavu-core fonts-dejavu-mono +libaom3 libasound2-data libasound2t64 libass9 libasyncns0 libatomic1 +libavc1394-0 libavcodec61 libavdevice61 libavfilter10 libavformat61 +libavutil59 libblas3 libbluray2 libbrotli1 libbs2b0 libcaca0 +libcairo-gobject2 libcairo2 libcdio-cdda2t64 libcdio-paranoia2t64 +libcdio19t64 libchromaprint1 libcjson1 libcodec2-1.2 libcom-err2 libdatrie1 +libdav1d7 libdbus-1-3 libdc1394-25 libdecor-0-0 libdeflate0 libdrm-amdgpu1 +libdrm-common libdrm-intel1 libdrm2 libdvdnav4 libdvdread8t64 libedit2 +libelf1t64 libexpat1 libfftw3-double3 libflac14 libflite1 libfontconfig1 +libfreetype6 libfribidi0 libgbm1 libgdk-pixbuf-2.0-0 libgdk-pixbuf2.0-common +libgfortran5 libgl1 libgl1-mesa-dri libglib2.0-0t64 libglvnd0 libglx-mesa0 +libglx0 libgme0 libgnutls30t64 libgomp1 libgraphite2-3 libgsm1 +libgssapi-krb5-2 libharfbuzz0b libhwy1t64 libidn2-0 libiec61883-0 +libjack-jackd2-0 libjbig0 libjpeg62-turbo libjxl0.11 libk5crypto3 +libkeyutils1 libkrb5-3 libkrb5support0 liblapack3 liblcms2-2 liblerc4 +liblilv-0-0 libllvm19 libmbedcrypto16 libmp3lame0 libmpg123-0t64 libmysofa1 +libnorm1t64 libnuma1 libogg0 libopenal-data libopenal1 libopenjp2-7 +libopenmpt0t64 libopus0 libp11-kit0 libpango-1.0-0 libpangocairo-1.0-0 +libpangoft2-1.0-0 libpciaccess0 libpgm-5.3-0t64 libpixman-1-0 libplacebo349 +libpng16-16t64 libpocketsphinx3 libpostproc58 libpulse0 librabbitmq4 +librav1e0.7 libraw1394-11 librist4 librsvg2-2 librubberband2 libsamplerate0 +libsdl2-2.0-0 libsensors-config libsensors5 libserd-0-0 libsharpyuv0 +libshine3 libslang2 libsnappy1v5 libsndfile1 libsodium23 libsord-0-0 +libsoxr0 libspeex1 libsphinxbase3t64 libsratom-0-0 libsrt1.5-gnutls libssh-4 +libsvtav1enc2 libswresample5 libswscale8 libtasn1-6 libthai-data libthai0 +libtheoradec1 libtheoraenc1 libtiff6 libtwolame0 libudfread0 libunibreak6 +libunistring5 libusb-1.0-0 libva-drm2 libva-x11-2 libva2 libvdpau1 +libvidstab1.1 libvorbis0a libvorbisenc2 libvorbisfile3 libvpl2 libvpx9 +libvulkan1 libwayland-client0 libwayland-cursor0 libwayland-egl1 +libwayland-server0 libwebp7 libwebpmux3 libx11-6 libx11-data libx11-xcb1 +libx264-164 libx265-215 libxau6 libxcb-dri3-0 libxcb-glx0 libxcb-present0 +libxcb-randr0 libxcb-render0 libxcb-shape0 libxcb-shm0 libxcb-sync1 +libxcb-xfixes0 libxcb1 libxcursor1 libxdmcp6 libxext6 libxfixes3 libxi6 +libxkbcommon0 libxml2 libxrandr2 libxrender1 libxshmfence1 libxss1 libxv1 +libxvidcore4 libxxf86vm1 libz3-4 libzimg2 libzix-0-0 libzmq5 libzvbi-common +libzvbi0t64 mesa-libgallium ocl-icd-libopencl1 shared-mime-info x11-common +xkb-data +0 upgraded, 205 newly installed, 0 to remove and 0 not upgraded. +Need to get 133 MB of archives. +After this operation, 466 MB of additional disk space will be used. +Get:1 http://deb.debian.org/debian trixie/main amd64 libexpat1 amd64 2.7.1-2 [108 kB] +Get:2 http://deb.debian.org/debian trixie/main amd64 libaom3 amd64 3.12.1-1 [1871 kB] +Get:3 http://deb.debian.org/debian trixie/main amd64 libdrm-common all 2.4.124-2 [8288 B] +Get:4 http://deb.debian.org/debian trixie/main amd64 libdrm2 amd64 2.4.124-2 [39.0 kB] +Get:5 http://deb.debian.org/debian trixie/main amd64 libva2 amd64 2.22.0-3 [79.4 kB] +Get:6 http://deb.debian.org/debian trixie/main amd64 libva-drm2 amd64 2.22.0-3 [18.3 kB] +Get:7 http://deb.debian.org/debian trixie/main amd64 libxau6 amd64 1:1.0.11-1 [20.4 kB] +Get:8 http://deb.debian.org/debian trixie/main amd64 libxdmcp6 amd64 1:1.1.5-1 [27.8 kB] +Get:9 http://deb.debian.org/debian trixie/main amd64 libxcb1 amd64 1.17.0-2+b1 [144 kB] +Get:10 http://deb.debian.org/debian trixie/main amd64 libx11-data all 2:1.8.12-1 [343 kB] +Get:11 http://deb.debian.org/debian trixie/main amd64 libx11-6 amd64 2:1.8.12-1 [815 kB] +Get:12 http://deb.debian.org/debian trixie/main amd64 libx11-xcb1 amd64 2:1.8.12-1 [247 kB] +Get:13 http://deb.debian.org/debian trixie/main amd64 libxcb-dri3-0 amd64 1.17.0-2+b1 [107 kB] +Get:14 http://deb.debian.org/debian trixie/main amd64 libxext6 amd64 2:1.3.4-1+b3 [50.4 kB] +Get:15 http://deb.debian.org/debian trixie/main amd64 libxfixes3 amd64 1:6.0.0-2+b4 [20.2 kB] +Get:16 http://deb.debian.org/debian trixie/main amd64 libva-x11-2 amd64 2.22.0-3 [23.1 kB] +Get:17 http://deb.debian.org/debian trixie/main amd64 libvdpau1 amd64 1.5-3+b1 [27.2 kB] +Get:18 http://deb.debian.org/debian trixie/main amd64 libvpl2 amd64 1:2.14.0-1+b1 [129 kB] +Get:19 http://deb.debian.org/debian trixie/main amd64 ocl-icd-libopencl1 amd64 2.3.3-1 [42.9 kB] +Get:20 http://deb.debian.org/debian trixie/main amd64 libavutil59 amd64 7:7.1.3-0+deb13u1 [417 kB] +Get:21 http://deb.debian.org/debian trixie/main amd64 libbrotli1 amd64 1.1.0-2+b7 [307 kB] +Get:22 http://deb.debian.org/debian-security trixie-security/main amd64 libpng16-16t64 amd64 1.6.48-1+deb13u3 [283 kB] +Get:23 http://deb.debian.org/debian trixie/main amd64 libfreetype6 amd64 2.13.3+dfsg-1 [452 kB] +Get:24 http://deb.debian.org/debian trixie/main amd64 fonts-dejavu-mono all 2.37-8 [489 kB] +Get:25 http://deb.debian.org/debian trixie/main amd64 fonts-dejavu-core all 2.37-8 [840 kB] +Get:26 http://deb.debian.org/debian trixie/main amd64 fontconfig-config amd64 2.15.0-2.3 [318 kB] +Get:27 http://deb.debian.org/debian trixie/main amd64 libfontconfig1 amd64 2.15.0-2.3 [392 kB] +Get:28 http://deb.debian.org/debian trixie/main amd64 libpixman-1-0 amd64 0.44.0-3 [248 kB] +Get:29 http://deb.debian.org/debian trixie/main amd64 libxcb-render0 amd64 1.17.0-2+b1 [115 kB] +Get:30 http://deb.debian.org/debian trixie/main amd64 libxcb-shm0 amd64 1.17.0-2+b1 [105 kB] +Get:31 http://deb.debian.org/debian trixie/main amd64 libxrender1 amd64 1:0.9.12-1 [27.9 kB] +Get:32 http://deb.debian.org/debian trixie/main amd64 libcairo2 amd64 1.18.4-1+b1 [538 kB] +Get:33 http://deb.debian.org/debian trixie/main amd64 libcodec2-1.2 amd64 1.2.0-3 [8170 kB] +Get:34 http://deb.debian.org/debian trixie/main amd64 libdav1d7 amd64 1.5.1-1 [559 kB] +Get:35 http://deb.debian.org/debian trixie/main amd64 libatomic1 amd64 14.2.0-19 [9308 B] +Get:36 http://deb.debian.org/debian trixie/main amd64 libglib2.0-0t64 amd64 2.84.4-3deb13u2 [1518 kB] +Get:37 http://deb.debian.org/debian trixie/main amd64 libgsm1 amd64 1.0.22-1+b2 [29.3 kB] +Get:38 http://deb.debian.org/debian trixie/main amd64 libhwy1t64 amd64 1.2.0-2+b2 [676 kB] +Get:39 http://deb.debian.org/debian trixie/main amd64 liblcms2-2 amd64 2.16-2 [160 kB] +Get:40 http://deb.debian.org/debian trixie/main amd64 libjxl0.11 amd64 0.11.1-4 [1132 kB] +Get:41 http://deb.debian.org/debian trixie/main amd64 libmp3lame0 amd64 3.100-6+b3 [363 kB] +Get:42 http://deb.debian.org/debian trixie/main amd64 libopenjp2-7 amd64 2.5.3-2.1deb13u1 [205 kB] +Get:43 http://deb.debian.org/debian trixie/main amd64 libopus0 amd64 1.5.2-2 [2852 kB] +Get:44 http://deb.debian.org/debian trixie/main amd64 librav1e0.7 amd64 0.7.1-9+b2 [946 kB] +Get:45 http://deb.debian.org/debian trixie/main amd64 libcairo-gobject2 amd64 1.18.4-1+b1 [130 kB] +Get:46 http://deb.debian.org/debian trixie/main amd64 libgdk-pixbuf2.0-common all 2.42.12+dfsg-4 [311 kB] +Get:47 http://deb.debian.org/debian trixie/main amd64 libxml2 amd64 2.12.7+dfsg+really2.9.14-2.1+deb13u2 [698 kB] +Get:48 http://deb.debian.org/debian trixie/main amd64 shared-mime-info amd64 2.4-5+b2 [760 kB] +Get:49 http://deb.debian.org/debian trixie/main amd64 libjpeg62-turbo amd64 1:2.1.5-4 [168 kB] +Get:50 http://deb.debian.org/debian trixie/main amd64 libdeflate0 amd64 1.23-2 [47.3 kB] +Get:51 http://deb.debian.org/debian trixie/main amd64 libjbig0 amd64 2.1-6.1+b2 [32.1 kB] +Get:52 http://deb.debian.org/debian trixie/main amd64 liblerc4 amd64 4.0.0+ds-5 [183 kB] +Get:53 http://deb.debian.org/debian trixie/main amd64 libsharpyuv0 amd64 1.5.0-0.1 [116 kB] +Get:54 http://deb.debian.org/debian trixie/main amd64 libwebp7 amd64 1.5.0-0.1 [318 kB] +Get:55 http://deb.debian.org/debian trixie/main amd64 libtiff6 amd64 4.7.0-3+deb13u1 [346 kB] +Get:56 http://deb.debian.org/debian trixie/main amd64 libgdk-pixbuf-2.0-0 amd64 2.42.12+dfsg-4 [141 kB] +Get:57 http://deb.debian.org/debian trixie/main amd64 fontconfig amd64 2.15.0-2.3 [463 kB] +Get:58 http://deb.debian.org/debian trixie/main amd64 libfribidi0 amd64 1.0.16-1 [26.5 kB] +Get:59 http://deb.debian.org/debian trixie/main amd64 libgraphite2-3 amd64 1.3.14-2+b1 [75.4 kB] +Get:60 http://deb.debian.org/debian trixie/main amd64 libharfbuzz0b amd64 10.2.0-1+b1 [479 kB] +Get:61 http://deb.debian.org/debian trixie/main amd64 libthai-data all 0.1.29-2 [168 kB] +Get:62 http://deb.debian.org/debian trixie/main amd64 libdatrie1 amd64 0.2.13-3+b1 [38.1 kB] +Get:63 http://deb.debian.org/debian trixie/main amd64 libthai0 amd64 0.1.29-2+b1 [49.4 kB] +Get:64 http://deb.debian.org/debian trixie/main amd64 libpango-1.0-0 amd64 1.56.3-1 [226 kB] +Get:65 http://deb.debian.org/debian trixie/main amd64 libpangoft2-1.0-0 amd64 1.56.3-1 [55.6 kB] +Get:66 http://deb.debian.org/debian trixie/main amd64 libpangocairo-1.0-0 amd64 1.56.3-1 [35.7 kB] +Get:67 http://deb.debian.org/debian trixie/main amd64 librsvg2-2 amd64 2.60.0+dfsg-1 [1789 kB] +Get:68 http://deb.debian.org/debian trixie/main amd64 libshine3 amd64 3.1.1-2+b2 [23.1 kB] +Get:69 http://deb.debian.org/debian trixie/main amd64 libsnappy1v5 amd64 1.2.2-1 [29.3 kB] +Get:70 http://deb.debian.org/debian trixie/main amd64 libspeex1 amd64 1.2.1-3 [56.8 kB] +Get:71 http://deb.debian.org/debian trixie/main amd64 libsvtav1enc2 amd64 2.3.0+dfsg-1 [2489 kB] +Get:72 http://deb.debian.org/debian trixie/main amd64 libgomp1 amd64 14.2.0-19 [137 kB] +Get:73 http://deb.debian.org/debian trixie/main amd64 libsoxr0 amd64 0.1.3-4+b2 [81.0 kB] +Get:74 http://deb.debian.org/debian trixie/main amd64 libswresample5 amd64 7:7.1.3-0+deb13u1 [101 kB] +Get:75 http://deb.debian.org/debian trixie/main amd64 libtheoradec1 amd64 1.2.0alpha1+dfsg-6 [58.4 kB] +Get:76 http://deb.debian.org/debian trixie/main amd64 libogg0 amd64 1.3.5-3+b2 [23.8 kB] +Get:77 http://deb.debian.org/debian trixie/main amd64 libtheoraenc1 amd64 1.2.0alpha1+dfsg-6 [108 kB] +Get:78 http://deb.debian.org/debian trixie/main amd64 libtwolame0 amd64 0.4.0-2+b2 [51.3 kB] +Get:79 http://deb.debian.org/debian trixie/main amd64 libvorbis0a amd64 1.3.7-3 [90.0 kB] +Get:80 http://deb.debian.org/debian trixie/main amd64 libvorbisenc2 amd64 1.3.7-3 [75.4 kB] +Get:81 http://deb.debian.org/debian-security trixie-security/main amd64 libvpx9 amd64 1.15.0-2.1+deb13u1 [1115 kB] +Get:82 http://deb.debian.org/debian trixie/main amd64 libwebpmux3 amd64 1.5.0-0.1 [126 kB] +Get:83 http://deb.debian.org/debian trixie/main amd64 libx264-164 amd64 2:0.164.3108+git31e19f9-2+b1 [558 kB] +Get:84 http://deb.debian.org/debian trixie/main amd64 libnuma1 amd64 2.0.19-1 [22.2 kB] +Get:85 http://deb.debian.org/debian trixie/main amd64 libx265-215 amd64 4.1-2 [1237 kB] +Get:86 http://deb.debian.org/debian trixie/main amd64 libxvidcore4 amd64 2:1.3.7-1+b2 [252 kB] +Get:87 http://deb.debian.org/debian trixie/main amd64 libzvbi-common all 0.2.44-1 [71.4 kB] +Get:88 http://deb.debian.org/debian trixie/main amd64 libzvbi0t64 amd64 0.2.44-1 [278 kB] +Get:89 http://deb.debian.org/debian trixie/main amd64 libavcodec61 amd64 7:7.1.3-0+deb13u1 [5808 kB] +Get:90 http://deb.debian.org/debian trixie/main amd64 libasound2-data all 1.2.14-1 [21.1 kB] +Get:91 http://deb.debian.org/debian trixie/main amd64 libasound2t64 amd64 1.2.14-1 [381 kB] +Get:92 http://deb.debian.org/debian trixie/main amd64 libraw1394-11 amd64 2.1.2-2+b2 [38.8 kB] +Get:93 http://deb.debian.org/debian trixie/main amd64 libavc1394-0 amd64 0.5.4-5+b2 [18.2 kB] +Get:94 http://deb.debian.org/debian trixie/main amd64 libunibreak6 amd64 6.1-3 [21.9 kB] +Get:95 http://deb.debian.org/debian trixie/main amd64 libass9 amd64 1:0.17.3-1+b1 [114 kB] +Get:96 http://deb.debian.org/debian trixie/main amd64 libudfread0 amd64 1.1.2-1+b2 [17.7 kB] +Get:97 http://deb.debian.org/debian trixie/main amd64 libbluray2 amd64 1:1.3.4-1+b2 [138 kB] +Get:98 http://deb.debian.org/debian trixie/main amd64 libchromaprint1 amd64 1.5.1-7 [42.9 kB] +Get:99 http://deb.debian.org/debian trixie/main amd64 libdvdread8t64 amd64 6.1.3-2 [86.2 kB] +Get:100 http://deb.debian.org/debian trixie/main amd64 libdvdnav4 amd64 6.1.1-3+b1 [44.5 kB] +Get:101 http://deb.debian.org/debian trixie/main amd64 libgme0 amd64 0.6.3-7+b2 [131 kB] +Get:102 http://deb.debian.org/debian trixie/main amd64 libunistring5 amd64 1.3-2 [477 kB] +Get:103 http://deb.debian.org/debian trixie/main amd64 libidn2-0 amd64 2.3.8-2 [109 kB] +Get:104 http://deb.debian.org/debian trixie/main amd64 libp11-kit0 amd64 0.25.5-3 [425 kB] +Get:105 http://deb.debian.org/debian trixie/main amd64 libtasn1-6 amd64 4.20.0-2 [49.9 kB] +Get:106 http://deb.debian.org/debian-security trixie-security/main amd64 libgnutls30t64 amd64 3.8.9-3+deb13u2 [1468 kB] +Get:107 http://deb.debian.org/debian trixie/main amd64 libmpg123-0t64 amd64 1.32.10-1 [149 kB] +Get:108 http://deb.debian.org/debian trixie/main amd64 libvorbisfile3 amd64 1.3.7-3 [20.9 kB] +Get:109 http://deb.debian.org/debian trixie/main amd64 libopenmpt0t64 amd64 0.7.13-1+b1 [855 kB] +Get:110 http://deb.debian.org/debian trixie/main amd64 librabbitmq4 amd64 0.15.0-1 [41.8 kB] +Get:111 http://deb.debian.org/debian trixie/main amd64 libcjson1 amd64 1.7.18-3.1+deb13u1 [29.8 kB] +Get:112 http://deb.debian.org/debian trixie/main amd64 libmbedcrypto16 amd64 3.6.5-0.1deb13u1 [361 kB] +Get:113 http://deb.debian.org/debian trixie/main amd64 librist4 amd64 0.2.11+dfsg-1 [72.1 kB] +Get:114 http://deb.debian.org/debian trixie/main amd64 libsrt1.5-gnutls amd64 1.5.4-1 [345 kB] +Get:115 http://deb.debian.org/debian trixie/main amd64 libkrb5support0 amd64 1.21.3-5 [33.0 kB] +Get:116 http://deb.debian.org/debian trixie/main amd64 libcom-err2 amd64 1.47.2-3+b7 [25.0 kB] +Get:117 http://deb.debian.org/debian trixie/main amd64 libk5crypto3 amd64 1.21.3-5 [81.5 kB] +Get:118 http://deb.debian.org/debian trixie/main amd64 libkeyutils1 amd64 1.6.3-6 [9456 B] +Get:119 http://deb.debian.org/debian trixie/main amd64 libkrb5-3 amd64 1.21.3-5 [326 kB] +Get:120 http://deb.debian.org/debian trixie/main amd64 libgssapi-krb5-2 amd64 1.21.3-5 [138 kB] +Get:121 http://deb.debian.org/debian trixie/main amd64 libssh-4 amd64 0.11.2-1+deb13u1 [209 kB] +Get:122 http://deb.debian.org/debian trixie/main amd64 libnorm1t64 amd64 1.5.9+dfsg-3.1+b2 [221 kB] +Get:123 http://deb.debian.org/debian trixie/main amd64 libpgm-5.3-0t64 amd64 5.3.128dfsg-2.1+b1 [162 kB] +Get:124 http://deb.debian.org/debian-security trixie-security/main amd64 libsodium23 amd64 1.0.18-1+deb13u1 [165 kB] +Get:125 http://deb.debian.org/debian trixie/main amd64 libzmq5 amd64 4.3.5-1+b3 [283 kB] +Get:126 http://deb.debian.org/debian trixie/main amd64 libavformat61 amd64 7:7.1.3-0+deb13u1 [1193 kB] +Get:127 http://deb.debian.org/debian trixie/main amd64 libbs2b0 amd64 3.1.0+dfsg-8+b1 [12.5 kB] +Get:128 http://deb.debian.org/debian trixie/main amd64 libflite1 amd64 2.2-7 [12.8 MB] +Get:129 http://deb.debian.org/debian trixie/main amd64 libserd-0-0 amd64 0.32.4-1 [47.0 kB] +Get:130 http://deb.debian.org/debian trixie/main amd64 libzix-0-0 amd64 0.6.2-1 [23.1 kB] +Get:131 http://deb.debian.org/debian trixie/main amd64 libsord-0-0 amd64 0.16.18-1 [18.0 kB] +Get:132 http://deb.debian.org/debian trixie/main amd64 libsratom-0-0 amd64 0.6.18-1 [17.7 kB] +Get:133 http://deb.debian.org/debian trixie/main amd64 liblilv-0-0 amd64 0.24.26-1 [43.5 kB] +Get:134 http://deb.debian.org/debian trixie/main amd64 libmysofa1 amd64 1.3.3+dfsg-1 [1158 kB] +Get:135 http://deb.debian.org/debian trixie/main amd64 libvulkan1 amd64 1.4.309.0-1 [130 kB] +Get:136 http://deb.debian.org/debian trixie/main amd64 libplacebo349 amd64 7.349.0-3 [2542 kB] +Get:137 http://deb.debian.org/debian trixie/main amd64 libblas3 amd64 3.12.1-6 [160 kB] +Get:138 http://deb.debian.org/debian trixie/main amd64 libgfortran5 amd64 14.2.0-19 [836 kB] +Get:139 http://deb.debian.org/debian trixie/main amd64 liblapack3 amd64 3.12.1-6 [2447 kB] +Get:140 http://deb.debian.org/debian trixie/main amd64 libasyncns0 amd64 0.8-6+b5 [12.0 kB] +Get:141 http://deb.debian.org/debian trixie/main amd64 libdbus-1-3 amd64 1.16.2-2 [178 kB] +Get:142 http://deb.debian.org/debian trixie/main amd64 libflac14 amd64 1.5.0+ds-2 [210 kB] +Get:143 http://deb.debian.org/debian trixie/main amd64 libsndfile1 amd64 1.2.2-2+b1 [199 kB] +Get:144 http://deb.debian.org/debian trixie/main amd64 libpulse0 amd64 17.0+dfsg1-2+b1 [276 kB] +Get:145 http://deb.debian.org/debian trixie/main amd64 libsphinxbase3t64 amd64 0.8+5prealpha+1-21+b1 [121 kB] +Get:146 http://deb.debian.org/debian trixie/main amd64 libpocketsphinx3 amd64 0.8+5prealpha+1-15+b4 [126 kB] +Get:147 http://deb.debian.org/debian trixie/main amd64 libpostproc58 amd64 7:7.1.3-0+deb13u1 [88.3 kB] +Get:148 http://deb.debian.org/debian trixie/main amd64 libfftw3-double3 amd64 3.3.10-2+b1 [781 kB] +Get:149 http://deb.debian.org/debian trixie/main amd64 libsamplerate0 amd64 0.2.2-4+b2 [950 kB] +Get:150 http://deb.debian.org/debian trixie/main amd64 librubberband2 amd64 3.3.0+dfsg-2+b3 [142 kB] +Get:151 http://deb.debian.org/debian trixie/main amd64 libswscale8 amd64 7:7.1.3-0+deb13u1 [233 kB] +Get:152 http://deb.debian.org/debian trixie/main amd64 libvidstab1.1 amd64 1.1.0-2+b2 [38.9 kB] +Get:153 http://deb.debian.org/debian trixie/main amd64 libzimg2 amd64 3.0.5+ds1-1+b2 [244 kB] +Get:154 http://deb.debian.org/debian trixie/main amd64 libavfilter10 amd64 7:7.1.3-0+deb13u1 [4109 kB] +Get:155 http://deb.debian.org/debian trixie/main amd64 libslang2 amd64 2.3.3-5+b2 [549 kB] +Get:156 http://deb.debian.org/debian trixie/main amd64 libcaca0 amd64 0.99.beta20-5 [202 kB] +Get:157 http://deb.debian.org/debian trixie/main amd64 libcdio19t64 amd64 2.2.0-4 [61.3 kB] +Get:158 http://deb.debian.org/debian trixie/main amd64 libcdio-cdda2t64 amd64 10.2+2.0.2-1+b1 [17.7 kB] +Get:159 http://deb.debian.org/debian trixie/main amd64 libcdio-paranoia2t64 amd64 10.2+2.0.2-1+b1 [17.4 kB] +Get:160 http://deb.debian.org/debian trixie/main amd64 libusb-1.0-0 amd64 2:1.0.28-1 [59.6 kB] +Get:161 http://deb.debian.org/debian trixie/main amd64 libdc1394-25 amd64 2.2.6-5 [111 kB] +Get:162 http://deb.debian.org/debian trixie/main amd64 libglvnd0 amd64 1.7.0-1+b2 [52.0 kB] +Get:163 http://deb.debian.org/debian trixie/main amd64 libxcb-glx0 amd64 1.17.0-2+b1 [122 kB] +Get:164 http://deb.debian.org/debian trixie/main amd64 libxcb-present0 amd64 1.17.0-2+b1 [106 kB] +Get:165 http://deb.debian.org/debian trixie/main amd64 libxcb-xfixes0 amd64 1.17.0-2+b1 [109 kB] +Get:166 http://deb.debian.org/debian trixie/main amd64 libxxf86vm1 amd64 1:1.1.4-1+b4 [19.3 kB] +Get:167 http://deb.debian.org/debian trixie/main amd64 libdrm-amdgpu1 amd64 2.4.124-2 [22.6 kB] +Get:168 http://deb.debian.org/debian trixie/main amd64 libpciaccess0 amd64 0.17-3+b3 [51.9 kB] +Get:169 http://deb.debian.org/debian trixie/main amd64 libdrm-intel1 amd64 2.4.124-2 [64.1 kB] +Get:170 http://deb.debian.org/debian trixie/main amd64 libelf1t64 amd64 0.192-4 [189 kB] +Get:171 http://deb.debian.org/debian trixie/main amd64 libedit2 amd64 3.1-20250104-1 [93.8 kB] +Get:172 http://deb.debian.org/debian trixie/main amd64 libz3-4 amd64 4.13.3-1 [8560 kB] +Get:173 http://deb.debian.org/debian trixie/main amd64 libllvm19 amd64 1:19.1.7-3+b1 [26.0 MB] +Get:174 http://deb.debian.org/debian trixie/main amd64 libsensors-config all 1:3.6.2-2 [16.2 kB] +Get:175 http://deb.debian.org/debian trixie/main amd64 libsensors5 amd64 1:3.6.2-2 [37.5 kB] +Get:176 http://deb.debian.org/debian trixie/main amd64 libxcb-randr0 amd64 1.17.0-2+b1 [117 kB] +Get:177 http://deb.debian.org/debian trixie/main amd64 libxcb-sync1 amd64 1.17.0-2+b1 [109 kB] +Get:178 http://deb.debian.org/debian trixie/main amd64 libxshmfence1 amd64 1.3.3-1 [10.9 kB] +Get:179 http://deb.debian.org/debian trixie/main amd64 mesa-libgallium amd64 25.0.7-2 [9629 kB] +Get:180 http://deb.debian.org/debian trixie/main amd64 libwayland-server0 amd64 1.23.1-3 [34.4 kB] +Get:181 http://deb.debian.org/debian trixie/main amd64 libgbm1 amd64 25.0.7-2 [44.4 kB] +Get:182 http://deb.debian.org/debian trixie/main amd64 libgl1-mesa-dri amd64 25.0.7-2 [46.1 kB] +Get:183 http://deb.debian.org/debian trixie/main amd64 libglx-mesa0 amd64 25.0.7-2 [143 kB] +Get:184 http://deb.debian.org/debian trixie/main amd64 libglx0 amd64 1.7.0-1+b2 [34.9 kB] +Get:185 http://deb.debian.org/debian trixie/main amd64 libgl1 amd64 1.7.0-1+b2 [89.5 kB] +Get:186 http://deb.debian.org/debian trixie/main amd64 libiec61883-0 amd64 1.2.0-7 [30.6 kB] +Get:187 http://deb.debian.org/debian trixie/main amd64 libjack-jackd2-0 amd64 1.9.22dfsg-4 [287 kB] +Get:188 http://deb.debian.org/debian trixie/main amd64 libopenal-data all 1:1.24.2-1 [168 kB] +Get:189 http://deb.debian.org/debian trixie/main amd64 libopenal1 amd64 1:1.24.2-1 [637 kB] +Get:190 http://deb.debian.org/debian trixie/main amd64 libwayland-client0 amd64 1.23.1-3 [26.8 kB] +Get:191 http://deb.debian.org/debian trixie/main amd64 libdecor-0-0 amd64 0.2.2-2 [15.5 kB] +Get:192 http://deb.debian.org/debian trixie/main amd64 libwayland-cursor0 amd64 1.23.1-3 [11.9 kB] +Get:193 http://deb.debian.org/debian trixie/main amd64 libwayland-egl1 amd64 1.23.1-3 [5860 B] +Get:194 http://deb.debian.org/debian trixie/main amd64 libxcursor1 amd64 1:1.2.3-1 [39.7 kB] +Get:195 http://deb.debian.org/debian trixie/main amd64 libxi6 amd64 2:1.8.2-1 [78.9 kB] +Get:196 http://deb.debian.org/debian trixie/main amd64 xkb-data all 2.42-1 [790 kB] +Get:197 http://deb.debian.org/debian trixie/main amd64 libxkbcommon0 amd64 1.7.0-2 [113 kB] +Get:198 http://deb.debian.org/debian trixie/main amd64 libxrandr2 amd64 2:1.5.4-1+b3 [36.3 kB] +Get:199 http://deb.debian.org/debian trixie/main amd64 x11-common all 1:7.7+24+deb13u1 [217 kB] +Get:200 http://deb.debian.org/debian trixie/main amd64 libxss1 amd64 1:1.2.3-1+b3 [17.0 kB] +Get:201 http://deb.debian.org/debian trixie/main amd64 libsdl2-2.0-0 amd64 2.32.4+dfsg-1 [669 kB] +Get:202 http://deb.debian.org/debian trixie/main amd64 libxcb-shape0 amd64 1.17.0-2+b1 [106 kB] +Get:203 http://deb.debian.org/debian trixie/main amd64 libxv1 amd64 2:1.0.11-1.1+b3 [23.4 kB] +Get:204 http://deb.debian.org/debian trixie/main amd64 libavdevice61 amd64 7:7.1.3-0+deb13u1 [119 kB] +Get:205 http://deb.debian.org/debian trixie/main amd64 ffmpeg amd64 7:7.1.3-0+deb13u1 [1995 kB] +debconf: unable to initialize frontend: Dialog +debconf: (TERM is not set, so the dialog frontend is not usable.) +debconf: falling back to frontend: Readline +debconf: unable to initialize frontend: Readline +debconf: (Can't locate Term/ReadLine.pm in @INC (you may need to install the Term::ReadLine module) (@INC entries checked: /etc/perl /usr/local/lib/x86_64-linux-gnu/perl/5.40.1 /usr/local/share/perl/5.40.1 /usr/lib/x86_64-linux-gnu/perl5/5.40 /usr/share/perl5 /usr/lib/x86_64-linux-gnu/perl-base /usr/lib/x86_64-linux-gnu/perl/5.40 /usr/share/perl/5.40 /usr/local/lib/site_perl) at /usr/share/perl5/Debconf/FrontEnd/Readline.pm line 8, line 205.) +debconf: falling back to frontend: Teletype +debconf: unable to initialize frontend: Teletype +debconf: (This frontend requires a controlling tty.) +debconf: falling back to frontend: Noninteractive +Preconfiguring packages ... +Fetched 133 MB in 1s (101 MB/s) +Selecting previously unselected package libexpat1:amd64. +(Reading database ... 5645 files and directories currently installed.) +Preparing to unpack .../000-libexpat1_2.7.1-2_amd64.deb ... +Unpacking libexpat1:amd64 (2.7.1-2) ... +Selecting previously unselected package libaom3:amd64. +Preparing to unpack .../001-libaom3_3.12.1-1_amd64.deb ... +Unpacking libaom3:amd64 (3.12.1-1) ... +Selecting previously unselected package libdrm-common. +Preparing to unpack .../002-libdrm-common_2.4.124-2_all.deb ... +Unpacking libdrm-common (2.4.124-2) ... +Selecting previously unselected package libdrm2:amd64. +Preparing to unpack .../003-libdrm2_2.4.124-2_amd64.deb ... +Unpacking libdrm2:amd64 (2.4.124-2) ... +Selecting previously unselected package libva2:amd64. +Preparing to unpack .../004-libva2_2.22.0-3_amd64.deb ... +Unpacking libva2:amd64 (2.22.0-3) ... +Selecting previously unselected package libva-drm2:amd64. +Preparing to unpack .../005-libva-drm2_2.22.0-3_amd64.deb ... +Unpacking libva-drm2:amd64 (2.22.0-3) ... +Selecting previously unselected package libxau6:amd64. +Preparing to unpack .../006-libxau6_1%3a1.0.11-1_amd64.deb ... +Unpacking libxau6:amd64 (1:1.0.11-1) ... +Selecting previously unselected package libxdmcp6:amd64. +Preparing to unpack .../007-libxdmcp6_1%3a1.1.5-1_amd64.deb ... +Unpacking libxdmcp6:amd64 (1:1.1.5-1) ... +Selecting previously unselected package libxcb1:amd64. +Preparing to unpack .../008-libxcb1_1.17.0-2+b1_amd64.deb ... +Unpacking libxcb1:amd64 (1.17.0-2+b1) ... +Selecting previously unselected package libx11-data. +Preparing to unpack .../009-libx11-data_2%3a1.8.12-1_all.deb ... +Unpacking libx11-data (2:1.8.12-1) ... +Selecting previously unselected package libx11-6:amd64. +Preparing to unpack .../010-libx11-6_2%3a1.8.12-1_amd64.deb ... +Unpacking libx11-6:amd64 (2:1.8.12-1) ... +Selecting previously unselected package libx11-xcb1:amd64. +Preparing to unpack .../011-libx11-xcb1_2%3a1.8.12-1_amd64.deb ... +Unpacking libx11-xcb1:amd64 (2:1.8.12-1) ... +Selecting previously unselected package libxcb-dri3-0:amd64. +Preparing to unpack .../012-libxcb-dri3-0_1.17.0-2+b1_amd64.deb ... +Unpacking libxcb-dri3-0:amd64 (1.17.0-2+b1) ... +Selecting previously unselected package libxext6:amd64. +Preparing to unpack .../013-libxext6_2%3a1.3.4-1+b3_amd64.deb ... +Unpacking libxext6:amd64 (2:1.3.4-1+b3) ... +Selecting previously unselected package libxfixes3:amd64. +Preparing to unpack .../014-libxfixes3_1%3a6.0.0-2+b4_amd64.deb ... +Unpacking libxfixes3:amd64 (1:6.0.0-2+b4) ... +Selecting previously unselected package libva-x11-2:amd64. +Preparing to unpack .../015-libva-x11-2_2.22.0-3_amd64.deb ... +Unpacking libva-x11-2:amd64 (2.22.0-3) ... +Selecting previously unselected package libvdpau1:amd64. +Preparing to unpack .../016-libvdpau1_1.5-3+b1_amd64.deb ... +Unpacking libvdpau1:amd64 (1.5-3+b1) ... +Selecting previously unselected package libvpl2. +Preparing to unpack .../017-libvpl2_1%3a2.14.0-1+b1_amd64.deb ... +Unpacking libvpl2 (1:2.14.0-1+b1) ... +Selecting previously unselected package ocl-icd-libopencl1:amd64. +Preparing to unpack .../018-ocl-icd-libopencl1_2.3.3-1_amd64.deb ... +Unpacking ocl-icd-libopencl1:amd64 (2.3.3-1) ... +Selecting previously unselected package libavutil59:amd64. +Preparing to unpack .../019-libavutil59_7%3a7.1.3-0+deb13u1_amd64.deb ... +Unpacking libavutil59:amd64 (7:7.1.3-0+deb13u1) ... +Selecting previously unselected package libbrotli1:amd64. +Preparing to unpack .../020-libbrotli1_1.1.0-2+b7_amd64.deb ... +Unpacking libbrotli1:amd64 (1.1.0-2+b7) ... +Selecting previously unselected package libpng16-16t64:amd64. +Preparing to unpack .../021-libpng16-16t64_1.6.48-1+deb13u3_amd64.deb ... +Unpacking libpng16-16t64:amd64 (1.6.48-1+deb13u3) ... +Selecting previously unselected package libfreetype6:amd64. +Preparing to unpack .../022-libfreetype6_2.13.3+dfsg-1_amd64.deb ... +Unpacking libfreetype6:amd64 (2.13.3+dfsg-1) ... +Selecting previously unselected package fonts-dejavu-mono. +Preparing to unpack .../023-fonts-dejavu-mono_2.37-8_all.deb ... +Unpacking fonts-dejavu-mono (2.37-8) ... +Selecting previously unselected package fonts-dejavu-core. +Preparing to unpack .../024-fonts-dejavu-core_2.37-8_all.deb ... +Unpacking fonts-dejavu-core (2.37-8) ... +Selecting previously unselected package fontconfig-config. +Preparing to unpack .../025-fontconfig-config_2.15.0-2.3_amd64.deb ... +Unpacking fontconfig-config (2.15.0-2.3) ... +Selecting previously unselected package libfontconfig1:amd64. +Preparing to unpack .../026-libfontconfig1_2.15.0-2.3_amd64.deb ... +Unpacking libfontconfig1:amd64 (2.15.0-2.3) ... +Selecting previously unselected package libpixman-1-0:amd64. +Preparing to unpack .../027-libpixman-1-0_0.44.0-3_amd64.deb ... +Unpacking libpixman-1-0:amd64 (0.44.0-3) ... +Selecting previously unselected package libxcb-render0:amd64. +Preparing to unpack .../028-libxcb-render0_1.17.0-2+b1_amd64.deb ... +Unpacking libxcb-render0:amd64 (1.17.0-2+b1) ... +Selecting previously unselected package libxcb-shm0:amd64. +Preparing to unpack .../029-libxcb-shm0_1.17.0-2+b1_amd64.deb ... +Unpacking libxcb-shm0:amd64 (1.17.0-2+b1) ... +Selecting previously unselected package libxrender1:amd64. +Preparing to unpack .../030-libxrender1_1%3a0.9.12-1_amd64.deb ... +Unpacking libxrender1:amd64 (1:0.9.12-1) ... +Selecting previously unselected package libcairo2:amd64. +Preparing to unpack .../031-libcairo2_1.18.4-1+b1_amd64.deb ... +Unpacking libcairo2:amd64 (1.18.4-1+b1) ... +Selecting previously unselected package libcodec2-1.2:amd64. +Preparing to unpack .../032-libcodec2-1.2_1.2.0-3_amd64.deb ... +Unpacking libcodec2-1.2:amd64 (1.2.0-3) ... +Selecting previously unselected package libdav1d7:amd64. +Preparing to unpack .../033-libdav1d7_1.5.1-1_amd64.deb ... +Unpacking libdav1d7:amd64 (1.5.1-1) ... +Selecting previously unselected package libatomic1:amd64. +Preparing to unpack .../034-libatomic1_14.2.0-19_amd64.deb ... +Unpacking libatomic1:amd64 (14.2.0-19) ... +Selecting previously unselected package libglib2.0-0t64:amd64. +Preparing to unpack .../035-libglib2.0-0t64_2.84.4-3deb13u2_amd64.deb ... +Unpacking libglib2.0-0t64:amd64 (2.84.4-3deb13u2) ... +Selecting previously unselected package libgsm1:amd64. +Preparing to unpack .../036-libgsm1_1.0.22-1+b2_amd64.deb ... +Unpacking libgsm1:amd64 (1.0.22-1+b2) ... +Selecting previously unselected package libhwy1t64:amd64. +Preparing to unpack .../037-libhwy1t64_1.2.0-2+b2_amd64.deb ... +Unpacking libhwy1t64:amd64 (1.2.0-2+b2) ... +Selecting previously unselected package liblcms2-2:amd64. +Preparing to unpack .../038-liblcms2-2_2.16-2_amd64.deb ... +Unpacking liblcms2-2:amd64 (2.16-2) ... +Selecting previously unselected package libjxl0.11:amd64. +Preparing to unpack .../039-libjxl0.11_0.11.1-4_amd64.deb ... +Unpacking libjxl0.11:amd64 (0.11.1-4) ... +Selecting previously unselected package libmp3lame0:amd64. +Preparing to unpack .../040-libmp3lame0_3.100-6+b3_amd64.deb ... +Unpacking libmp3lame0:amd64 (3.100-6+b3) ... +Selecting previously unselected package libopenjp2-7:amd64. +Preparing to unpack .../041-libopenjp2-7_2.5.3-2.1deb13u1_amd64.deb ... +Unpacking libopenjp2-7:amd64 (2.5.3-2.1deb13u1) ... +Selecting previously unselected package libopus0:amd64. +Preparing to unpack .../042-libopus0_1.5.2-2_amd64.deb ... +Unpacking libopus0:amd64 (1.5.2-2) ... +Selecting previously unselected package librav1e0.7:amd64. +Preparing to unpack .../043-librav1e0.7_0.7.1-9+b2_amd64.deb ... +Unpacking librav1e0.7:amd64 (0.7.1-9+b2) ... +Selecting previously unselected package libcairo-gobject2:amd64. +Preparing to unpack .../044-libcairo-gobject2_1.18.4-1+b1_amd64.deb ... +Unpacking libcairo-gobject2:amd64 (1.18.4-1+b1) ... +Selecting previously unselected package libgdk-pixbuf2.0-common. +Preparing to unpack .../045-libgdk-pixbuf2.0-common_2.42.12+dfsg-4_all.deb ... +Unpacking libgdk-pixbuf2.0-common (2.42.12+dfsg-4) ... +Selecting previously unselected package libxml2:amd64. +Preparing to unpack .../046-libxml2_2.12.7+dfsg+really2.9.14-2.1+deb13u2_amd64.deb ... +Unpacking libxml2:amd64 (2.12.7+dfsg+really2.9.14-2.1+deb13u2) ... +Selecting previously unselected package shared-mime-info. +Preparing to unpack .../047-shared-mime-info_2.4-5+b2_amd64.deb ... +Unpacking shared-mime-info (2.4-5+b2) ... +Selecting previously unselected package libjpeg62-turbo:amd64. +Preparing to unpack .../048-libjpeg62-turbo_1%3a2.1.5-4_amd64.deb ... +Unpacking libjpeg62-turbo:amd64 (1:2.1.5-4) ... +Selecting previously unselected package libdeflate0:amd64. +Preparing to unpack .../049-libdeflate0_1.23-2_amd64.deb ... +Unpacking libdeflate0:amd64 (1.23-2) ... +Selecting previously unselected package libjbig0:amd64. +Preparing to unpack .../050-libjbig0_2.1-6.1+b2_amd64.deb ... +Unpacking libjbig0:amd64 (2.1-6.1+b2) ... +Selecting previously unselected package liblerc4:amd64. +Preparing to unpack .../051-liblerc4_4.0.0+ds-5_amd64.deb ... +Unpacking liblerc4:amd64 (4.0.0+ds-5) ... +Selecting previously unselected package libsharpyuv0:amd64. +Preparing to unpack .../052-libsharpyuv0_1.5.0-0.1_amd64.deb ... +Unpacking libsharpyuv0:amd64 (1.5.0-0.1) ... +Selecting previously unselected package libwebp7:amd64. +Preparing to unpack .../053-libwebp7_1.5.0-0.1_amd64.deb ... +Unpacking libwebp7:amd64 (1.5.0-0.1) ... +Selecting previously unselected package libtiff6:amd64. +Preparing to unpack .../054-libtiff6_4.7.0-3+deb13u1_amd64.deb ... +Unpacking libtiff6:amd64 (4.7.0-3+deb13u1) ... +Selecting previously unselected package libgdk-pixbuf-2.0-0:amd64. +Preparing to unpack .../055-libgdk-pixbuf-2.0-0_2.42.12+dfsg-4_amd64.deb ... +Unpacking libgdk-pixbuf-2.0-0:amd64 (2.42.12+dfsg-4) ... +Selecting previously unselected package fontconfig. +Preparing to unpack .../056-fontconfig_2.15.0-2.3_amd64.deb ... +Unpacking fontconfig (2.15.0-2.3) ... +Selecting previously unselected package libfribidi0:amd64. +Preparing to unpack .../057-libfribidi0_1.0.16-1_amd64.deb ... +Unpacking libfribidi0:amd64 (1.0.16-1) ... +Selecting previously unselected package libgraphite2-3:amd64. +Preparing to unpack .../058-libgraphite2-3_1.3.14-2+b1_amd64.deb ... +Unpacking libgraphite2-3:amd64 (1.3.14-2+b1) ... +Selecting previously unselected package libharfbuzz0b:amd64. +Preparing to unpack .../059-libharfbuzz0b_10.2.0-1+b1_amd64.deb ... +Unpacking libharfbuzz0b:amd64 (10.2.0-1+b1) ... +Selecting previously unselected package libthai-data. +Preparing to unpack .../060-libthai-data_0.1.29-2_all.deb ... +Unpacking libthai-data (0.1.29-2) ... +Selecting previously unselected package libdatrie1:amd64. +Preparing to unpack .../061-libdatrie1_0.2.13-3+b1_amd64.deb ... +Unpacking libdatrie1:amd64 (0.2.13-3+b1) ... +Selecting previously unselected package libthai0:amd64. +Preparing to unpack .../062-libthai0_0.1.29-2+b1_amd64.deb ... +Unpacking libthai0:amd64 (0.1.29-2+b1) ... +Selecting previously unselected package libpango-1.0-0:amd64. +Preparing to unpack .../063-libpango-1.0-0_1.56.3-1_amd64.deb ... +Unpacking libpango-1.0-0:amd64 (1.56.3-1) ... +Selecting previously unselected package libpangoft2-1.0-0:amd64. +Preparing to unpack .../064-libpangoft2-1.0-0_1.56.3-1_amd64.deb ... +Unpacking libpangoft2-1.0-0:amd64 (1.56.3-1) ... +Selecting previously unselected package libpangocairo-1.0-0:amd64. +Preparing to unpack .../065-libpangocairo-1.0-0_1.56.3-1_amd64.deb ... +Unpacking libpangocairo-1.0-0:amd64 (1.56.3-1) ... +Selecting previously unselected package librsvg2-2:amd64. +Preparing to unpack .../066-librsvg2-2_2.60.0+dfsg-1_amd64.deb ... +Unpacking librsvg2-2:amd64 (2.60.0+dfsg-1) ... +Selecting previously unselected package libshine3:amd64. +Preparing to unpack .../067-libshine3_3.1.1-2+b2_amd64.deb ... +Unpacking libshine3:amd64 (3.1.1-2+b2) ... +Selecting previously unselected package libsnappy1v5:amd64. +Preparing to unpack .../068-libsnappy1v5_1.2.2-1_amd64.deb ... +Unpacking libsnappy1v5:amd64 (1.2.2-1) ... +Selecting previously unselected package libspeex1:amd64. +Preparing to unpack .../069-libspeex1_1.2.1-3_amd64.deb ... +Unpacking libspeex1:amd64 (1.2.1-3) ... +Selecting previously unselected package libsvtav1enc2:amd64. +Preparing to unpack .../070-libsvtav1enc2_2.3.0+dfsg-1_amd64.deb ... +Unpacking libsvtav1enc2:amd64 (2.3.0+dfsg-1) ... +Selecting previously unselected package libgomp1:amd64. +Preparing to unpack .../071-libgomp1_14.2.0-19_amd64.deb ... +Unpacking libgomp1:amd64 (14.2.0-19) ... +Selecting previously unselected package libsoxr0:amd64. +Preparing to unpack .../072-libsoxr0_0.1.3-4+b2_amd64.deb ... +Unpacking libsoxr0:amd64 (0.1.3-4+b2) ... +Selecting previously unselected package libswresample5:amd64. +Preparing to unpack .../073-libswresample5_7%3a7.1.3-0+deb13u1_amd64.deb ... +Unpacking libswresample5:amd64 (7:7.1.3-0+deb13u1) ... +Selecting previously unselected package libtheoradec1:amd64. +Preparing to unpack .../074-libtheoradec1_1.2.0alpha1+dfsg-6_amd64.deb ... +Unpacking libtheoradec1:amd64 (1.2.0alpha1+dfsg-6) ... +Selecting previously unselected package libogg0:amd64. +Preparing to unpack .../075-libogg0_1.3.5-3+b2_amd64.deb ... +Unpacking libogg0:amd64 (1.3.5-3+b2) ... +Selecting previously unselected package libtheoraenc1:amd64. +Preparing to unpack .../076-libtheoraenc1_1.2.0alpha1+dfsg-6_amd64.deb ... +Unpacking libtheoraenc1:amd64 (1.2.0alpha1+dfsg-6) ... +Selecting previously unselected package libtwolame0:amd64. +Preparing to unpack .../077-libtwolame0_0.4.0-2+b2_amd64.deb ... +Unpacking libtwolame0:amd64 (0.4.0-2+b2) ... +Selecting previously unselected package libvorbis0a:amd64. +Preparing to unpack .../078-libvorbis0a_1.3.7-3_amd64.deb ... +Unpacking libvorbis0a:amd64 (1.3.7-3) ... +Selecting previously unselected package libvorbisenc2:amd64. +Preparing to unpack .../079-libvorbisenc2_1.3.7-3_amd64.deb ... +Unpacking libvorbisenc2:amd64 (1.3.7-3) ... +Selecting previously unselected package libvpx9:amd64. +Preparing to unpack .../080-libvpx9_1.15.0-2.1+deb13u1_amd64.deb ... +Unpacking libvpx9:amd64 (1.15.0-2.1+deb13u1) ... +Selecting previously unselected package libwebpmux3:amd64. +Preparing to unpack .../081-libwebpmux3_1.5.0-0.1_amd64.deb ... +Unpacking libwebpmux3:amd64 (1.5.0-0.1) ... +Selecting previously unselected package libx264-164:amd64. +Preparing to unpack .../082-libx264-164_2%3a0.164.3108+git31e19f9-2+b1_amd64.deb ... +Unpacking libx264-164:amd64 (2:0.164.3108+git31e19f9-2+b1) ... +Selecting previously unselected package libnuma1:amd64. +Preparing to unpack .../083-libnuma1_2.0.19-1_amd64.deb ... +Unpacking libnuma1:amd64 (2.0.19-1) ... +Selecting previously unselected package libx265-215:amd64. +Preparing to unpack .../084-libx265-215_4.1-2_amd64.deb ... +Unpacking libx265-215:amd64 (4.1-2) ... +Selecting previously unselected package libxvidcore4:amd64. +Preparing to unpack .../085-libxvidcore4_2%3a1.3.7-1+b2_amd64.deb ... +Unpacking libxvidcore4:amd64 (2:1.3.7-1+b2) ... +Selecting previously unselected package libzvbi-common. +Preparing to unpack .../086-libzvbi-common_0.2.44-1_all.deb ... +Unpacking libzvbi-common (0.2.44-1) ... +Selecting previously unselected package libzvbi0t64:amd64. +Preparing to unpack .../087-libzvbi0t64_0.2.44-1_amd64.deb ... +Unpacking libzvbi0t64:amd64 (0.2.44-1) ... +Selecting previously unselected package libavcodec61:amd64. +Preparing to unpack .../088-libavcodec61_7%3a7.1.3-0+deb13u1_amd64.deb ... +Unpacking libavcodec61:amd64 (7:7.1.3-0+deb13u1) ... +Selecting previously unselected package libasound2-data. +Preparing to unpack .../089-libasound2-data_1.2.14-1_all.deb ... +Unpacking libasound2-data (1.2.14-1) ... +Selecting previously unselected package libasound2t64:amd64. +Preparing to unpack .../090-libasound2t64_1.2.14-1_amd64.deb ... +Unpacking libasound2t64:amd64 (1.2.14-1) ... +Selecting previously unselected package libraw1394-11:amd64. +Preparing to unpack .../091-libraw1394-11_2.1.2-2+b2_amd64.deb ... +Unpacking libraw1394-11:amd64 (2.1.2-2+b2) ... +Selecting previously unselected package libavc1394-0:amd64. +Preparing to unpack .../092-libavc1394-0_0.5.4-5+b2_amd64.deb ... +Unpacking libavc1394-0:amd64 (0.5.4-5+b2) ... +Selecting previously unselected package libunibreak6:amd64. +Preparing to unpack .../093-libunibreak6_6.1-3_amd64.deb ... +Unpacking libunibreak6:amd64 (6.1-3) ... +Selecting previously unselected package libass9:amd64. +Preparing to unpack .../094-libass9_1%3a0.17.3-1+b1_amd64.deb ... +Unpacking libass9:amd64 (1:0.17.3-1+b1) ... +Selecting previously unselected package libudfread0:amd64. +Preparing to unpack .../095-libudfread0_1.1.2-1+b2_amd64.deb ... +Unpacking libudfread0:amd64 (1.1.2-1+b2) ... +Selecting previously unselected package libbluray2:amd64. +Preparing to unpack .../096-libbluray2_1%3a1.3.4-1+b2_amd64.deb ... +Unpacking libbluray2:amd64 (1:1.3.4-1+b2) ... +Selecting previously unselected package libchromaprint1:amd64. +Preparing to unpack .../097-libchromaprint1_1.5.1-7_amd64.deb ... +Unpacking libchromaprint1:amd64 (1.5.1-7) ... +Selecting previously unselected package libdvdread8t64:amd64. +Preparing to unpack .../098-libdvdread8t64_6.1.3-2_amd64.deb ... +Unpacking libdvdread8t64:amd64 (6.1.3-2) ... +Selecting previously unselected package libdvdnav4:amd64. +Preparing to unpack .../099-libdvdnav4_6.1.1-3+b1_amd64.deb ... +Unpacking libdvdnav4:amd64 (6.1.1-3+b1) ... +Selecting previously unselected package libgme0:amd64. +Preparing to unpack .../100-libgme0_0.6.3-7+b2_amd64.deb ... +Unpacking libgme0:amd64 (0.6.3-7+b2) ... +Selecting previously unselected package libunistring5:amd64. +Preparing to unpack .../101-libunistring5_1.3-2_amd64.deb ... +Unpacking libunistring5:amd64 (1.3-2) ... +Selecting previously unselected package libidn2-0:amd64. +Preparing to unpack .../102-libidn2-0_2.3.8-2_amd64.deb ... +Unpacking libidn2-0:amd64 (2.3.8-2) ... +Selecting previously unselected package libp11-kit0:amd64. +Preparing to unpack .../103-libp11-kit0_0.25.5-3_amd64.deb ... +Unpacking libp11-kit0:amd64 (0.25.5-3) ... +Selecting previously unselected package libtasn1-6:amd64. +Preparing to unpack .../104-libtasn1-6_4.20.0-2_amd64.deb ... +Unpacking libtasn1-6:amd64 (4.20.0-2) ... +Selecting previously unselected package libgnutls30t64:amd64. +Preparing to unpack .../105-libgnutls30t64_3.8.9-3+deb13u2_amd64.deb ... +Unpacking libgnutls30t64:amd64 (3.8.9-3+deb13u2) ... +Selecting previously unselected package libmpg123-0t64:amd64. +Preparing to unpack .../106-libmpg123-0t64_1.32.10-1_amd64.deb ... +Unpacking libmpg123-0t64:amd64 (1.32.10-1) ... +Selecting previously unselected package libvorbisfile3:amd64. +Preparing to unpack .../107-libvorbisfile3_1.3.7-3_amd64.deb ... +Unpacking libvorbisfile3:amd64 (1.3.7-3) ... +Selecting previously unselected package libopenmpt0t64:amd64. +Preparing to unpack .../108-libopenmpt0t64_0.7.13-1+b1_amd64.deb ... +Unpacking libopenmpt0t64:amd64 (0.7.13-1+b1) ... +Selecting previously unselected package librabbitmq4:amd64. +Preparing to unpack .../109-librabbitmq4_0.15.0-1_amd64.deb ... +Unpacking librabbitmq4:amd64 (0.15.0-1) ... +Selecting previously unselected package libcjson1:amd64. +Preparing to unpack .../110-libcjson1_1.7.18-3.1+deb13u1_amd64.deb ... +Unpacking libcjson1:amd64 (1.7.18-3.1+deb13u1) ... +Selecting previously unselected package libmbedcrypto16:amd64. +Preparing to unpack .../111-libmbedcrypto16_3.6.5-0.1deb13u1_amd64.deb ... +Unpacking libmbedcrypto16:amd64 (3.6.5-0.1deb13u1) ... +Selecting previously unselected package librist4:amd64. +Preparing to unpack .../112-librist4_0.2.11+dfsg-1_amd64.deb ... +Unpacking librist4:amd64 (0.2.11+dfsg-1) ... +Selecting previously unselected package libsrt1.5-gnutls:amd64. +Preparing to unpack .../113-libsrt1.5-gnutls_1.5.4-1_amd64.deb ... +Unpacking libsrt1.5-gnutls:amd64 (1.5.4-1) ... +Selecting previously unselected package libkrb5support0:amd64. +Preparing to unpack .../114-libkrb5support0_1.21.3-5_amd64.deb ... +Unpacking libkrb5support0:amd64 (1.21.3-5) ... +Selecting previously unselected package libcom-err2:amd64. +Preparing to unpack .../115-libcom-err2_1.47.2-3+b7_amd64.deb ... +Unpacking libcom-err2:amd64 (1.47.2-3+b7) ... +Selecting previously unselected package libk5crypto3:amd64. +Preparing to unpack .../116-libk5crypto3_1.21.3-5_amd64.deb ... +Unpacking libk5crypto3:amd64 (1.21.3-5) ... +Selecting previously unselected package libkeyutils1:amd64. +Preparing to unpack .../117-libkeyutils1_1.6.3-6_amd64.deb ... +Unpacking libkeyutils1:amd64 (1.6.3-6) ... +Selecting previously unselected package libkrb5-3:amd64. +Preparing to unpack .../118-libkrb5-3_1.21.3-5_amd64.deb ... +Unpacking libkrb5-3:amd64 (1.21.3-5) ... +Selecting previously unselected package libgssapi-krb5-2:amd64. +Preparing to unpack .../119-libgssapi-krb5-2_1.21.3-5_amd64.deb ... +Unpacking libgssapi-krb5-2:amd64 (1.21.3-5) ... +Selecting previously unselected package libssh-4:amd64. +Preparing to unpack .../120-libssh-4_0.11.2-1+deb13u1_amd64.deb ... +Unpacking libssh-4:amd64 (0.11.2-1+deb13u1) ... +Selecting previously unselected package libnorm1t64:amd64. +Preparing to unpack .../121-libnorm1t64_1.5.9+dfsg-3.1+b2_amd64.deb ... +Unpacking libnorm1t64:amd64 (1.5.9+dfsg-3.1+b2) ... +Selecting previously unselected package libpgm-5.3-0t64:amd64. +Preparing to unpack .../122-libpgm-5.3-0t64_5.3.128dfsg-2.1+b1_amd64.deb ... +Unpacking libpgm-5.3-0t64:amd64 (5.3.128dfsg-2.1+b1) ... +Selecting previously unselected package libsodium23:amd64. +Preparing to unpack .../123-libsodium23_1.0.18-1+deb13u1_amd64.deb ... +Unpacking libsodium23:amd64 (1.0.18-1+deb13u1) ... +Selecting previously unselected package libzmq5:amd64. +Preparing to unpack .../124-libzmq5_4.3.5-1+b3_amd64.deb ... +Unpacking libzmq5:amd64 (4.3.5-1+b3) ... +Selecting previously unselected package libavformat61:amd64. +Preparing to unpack .../125-libavformat61_7%3a7.1.3-0+deb13u1_amd64.deb ... +Unpacking libavformat61:amd64 (7:7.1.3-0+deb13u1) ... +Selecting previously unselected package libbs2b0:amd64. +Preparing to unpack .../126-libbs2b0_3.1.0+dfsg-8+b1_amd64.deb ... +Unpacking libbs2b0:amd64 (3.1.0+dfsg-8+b1) ... +Selecting previously unselected package libflite1:amd64. +Preparing to unpack .../127-libflite1_2.2-7_amd64.deb ... +Unpacking libflite1:amd64 (2.2-7) ... +Selecting previously unselected package libserd-0-0:amd64. +Preparing to unpack .../128-libserd-0-0_0.32.4-1_amd64.deb ... +Unpacking libserd-0-0:amd64 (0.32.4-1) ... +Selecting previously unselected package libzix-0-0:amd64. +Preparing to unpack .../129-libzix-0-0_0.6.2-1_amd64.deb ... +Unpacking libzix-0-0:amd64 (0.6.2-1) ... +Selecting previously unselected package libsord-0-0:amd64. +Preparing to unpack .../130-libsord-0-0_0.16.18-1_amd64.deb ... +Unpacking libsord-0-0:amd64 (0.16.18-1) ... +Selecting previously unselected package libsratom-0-0:amd64. +Preparing to unpack .../131-libsratom-0-0_0.6.18-1_amd64.deb ... +Unpacking libsratom-0-0:amd64 (0.6.18-1) ... +Selecting previously unselected package liblilv-0-0:amd64. +Preparing to unpack .../132-liblilv-0-0_0.24.26-1_amd64.deb ... +Unpacking liblilv-0-0:amd64 (0.24.26-1) ... +Selecting previously unselected package libmysofa1:amd64. +Preparing to unpack .../133-libmysofa1_1.3.3+dfsg-1_amd64.deb ... +Unpacking libmysofa1:amd64 (1.3.3+dfsg-1) ... +Selecting previously unselected package libvulkan1:amd64. +Preparing to unpack .../134-libvulkan1_1.4.309.0-1_amd64.deb ... +Unpacking libvulkan1:amd64 (1.4.309.0-1) ... +Selecting previously unselected package libplacebo349:amd64. +Preparing to unpack .../135-libplacebo349_7.349.0-3_amd64.deb ... +Unpacking libplacebo349:amd64 (7.349.0-3) ... +Selecting previously unselected package libblas3:amd64. +Preparing to unpack .../136-libblas3_3.12.1-6_amd64.deb ... +Unpacking libblas3:amd64 (3.12.1-6) ... +Selecting previously unselected package libgfortran5:amd64. +Preparing to unpack .../137-libgfortran5_14.2.0-19_amd64.deb ... +Unpacking libgfortran5:amd64 (14.2.0-19) ... +Selecting previously unselected package liblapack3:amd64. +Preparing to unpack .../138-liblapack3_3.12.1-6_amd64.deb ... +Unpacking liblapack3:amd64 (3.12.1-6) ... +Selecting previously unselected package libasyncns0:amd64. +Preparing to unpack .../139-libasyncns0_0.8-6+b5_amd64.deb ... +Unpacking libasyncns0:amd64 (0.8-6+b5) ... +Selecting previously unselected package libdbus-1-3:amd64. +Preparing to unpack .../140-libdbus-1-3_1.16.2-2_amd64.deb ... +Unpacking libdbus-1-3:amd64 (1.16.2-2) ... +Selecting previously unselected package libflac14:amd64. +Preparing to unpack .../141-libflac14_1.5.0+ds-2_amd64.deb ... +Unpacking libflac14:amd64 (1.5.0+ds-2) ... +Selecting previously unselected package libsndfile1:amd64. +Preparing to unpack .../142-libsndfile1_1.2.2-2+b1_amd64.deb ... +Unpacking libsndfile1:amd64 (1.2.2-2+b1) ... +Selecting previously unselected package libpulse0:amd64. +Preparing to unpack .../143-libpulse0_17.0+dfsg1-2+b1_amd64.deb ... +Unpacking libpulse0:amd64 (17.0+dfsg1-2+b1) ... +Selecting previously unselected package libsphinxbase3t64:amd64. +Preparing to unpack .../144-libsphinxbase3t64_0.8+5prealpha+1-21+b1_amd64.deb ... +Unpacking libsphinxbase3t64:amd64 (0.8+5prealpha+1-21+b1) ... +Selecting previously unselected package libpocketsphinx3:amd64. +Preparing to unpack .../145-libpocketsphinx3_0.8+5prealpha+1-15+b4_amd64.deb ... +Unpacking libpocketsphinx3:amd64 (0.8+5prealpha+1-15+b4) ... +Selecting previously unselected package libpostproc58:amd64. +Preparing to unpack .../146-libpostproc58_7%3a7.1.3-0+deb13u1_amd64.deb ... +Unpacking libpostproc58:amd64 (7:7.1.3-0+deb13u1) ... +Selecting previously unselected package libfftw3-double3:amd64. +Preparing to unpack .../147-libfftw3-double3_3.3.10-2+b1_amd64.deb ... +Unpacking libfftw3-double3:amd64 (3.3.10-2+b1) ... +Selecting previously unselected package libsamplerate0:amd64. +Preparing to unpack .../148-libsamplerate0_0.2.2-4+b2_amd64.deb ... +Unpacking libsamplerate0:amd64 (0.2.2-4+b2) ... +Selecting previously unselected package librubberband2:amd64. +Preparing to unpack .../149-librubberband2_3.3.0+dfsg-2+b3_amd64.deb ... +Unpacking librubberband2:amd64 (3.3.0+dfsg-2+b3) ... +Selecting previously unselected package libswscale8:amd64. +Preparing to unpack .../150-libswscale8_7%3a7.1.3-0+deb13u1_amd64.deb ... +Unpacking libswscale8:amd64 (7:7.1.3-0+deb13u1) ... +Selecting previously unselected package libvidstab1.1:amd64. +Preparing to unpack .../151-libvidstab1.1_1.1.0-2+b2_amd64.deb ... +Unpacking libvidstab1.1:amd64 (1.1.0-2+b2) ... +Selecting previously unselected package libzimg2:amd64. +Preparing to unpack .../152-libzimg2_3.0.5+ds1-1+b2_amd64.deb ... +Unpacking libzimg2:amd64 (3.0.5+ds1-1+b2) ... +Selecting previously unselected package libavfilter10:amd64. +Preparing to unpack .../153-libavfilter10_7%3a7.1.3-0+deb13u1_amd64.deb ... +Unpacking libavfilter10:amd64 (7:7.1.3-0+deb13u1) ... +Selecting previously unselected package libslang2:amd64. +Preparing to unpack .../154-libslang2_2.3.3-5+b2_amd64.deb ... +Unpacking libslang2:amd64 (2.3.3-5+b2) ... +Selecting previously unselected package libcaca0:amd64. +Preparing to unpack .../155-libcaca0_0.99.beta20-5_amd64.deb ... +Unpacking libcaca0:amd64 (0.99.beta20-5) ... +Selecting previously unselected package libcdio19t64:amd64. +Preparing to unpack .../156-libcdio19t64_2.2.0-4_amd64.deb ... +Unpacking libcdio19t64:amd64 (2.2.0-4) ... +Selecting previously unselected package libcdio-cdda2t64:amd64. +Preparing to unpack .../157-libcdio-cdda2t64_10.2+2.0.2-1+b1_amd64.deb ... +Unpacking libcdio-cdda2t64:amd64 (10.2+2.0.2-1+b1) ... +Selecting previously unselected package libcdio-paranoia2t64:amd64. +Preparing to unpack .../158-libcdio-paranoia2t64_10.2+2.0.2-1+b1_amd64.deb ... +Unpacking libcdio-paranoia2t64:amd64 (10.2+2.0.2-1+b1) ... +Selecting previously unselected package libusb-1.0-0:amd64. +Preparing to unpack .../159-libusb-1.0-0_2%3a1.0.28-1_amd64.deb ... +Unpacking libusb-1.0-0:amd64 (2:1.0.28-1) ... +Selecting previously unselected package libdc1394-25:amd64. +Preparing to unpack .../160-libdc1394-25_2.2.6-5_amd64.deb ... +Unpacking libdc1394-25:amd64 (2.2.6-5) ... +Selecting previously unselected package libglvnd0:amd64. +Preparing to unpack .../161-libglvnd0_1.7.0-1+b2_amd64.deb ... +Unpacking libglvnd0:amd64 (1.7.0-1+b2) ... +Selecting previously unselected package libxcb-glx0:amd64. +Preparing to unpack .../162-libxcb-glx0_1.17.0-2+b1_amd64.deb ... +Unpacking libxcb-glx0:amd64 (1.17.0-2+b1) ... +Selecting previously unselected package libxcb-present0:amd64. +Preparing to unpack .../163-libxcb-present0_1.17.0-2+b1_amd64.deb ... +Unpacking libxcb-present0:amd64 (1.17.0-2+b1) ... +Selecting previously unselected package libxcb-xfixes0:amd64. +Preparing to unpack .../164-libxcb-xfixes0_1.17.0-2+b1_amd64.deb ... +Unpacking libxcb-xfixes0:amd64 (1.17.0-2+b1) ... +Selecting previously unselected package libxxf86vm1:amd64. +Preparing to unpack .../165-libxxf86vm1_1%3a1.1.4-1+b4_amd64.deb ... +Unpacking libxxf86vm1:amd64 (1:1.1.4-1+b4) ... +Selecting previously unselected package libdrm-amdgpu1:amd64. +Preparing to unpack .../166-libdrm-amdgpu1_2.4.124-2_amd64.deb ... +Unpacking libdrm-amdgpu1:amd64 (2.4.124-2) ... +Selecting previously unselected package libpciaccess0:amd64. +Preparing to unpack .../167-libpciaccess0_0.17-3+b3_amd64.deb ... +Unpacking libpciaccess0:amd64 (0.17-3+b3) ... +Selecting previously unselected package libdrm-intel1:amd64. +Preparing to unpack .../168-libdrm-intel1_2.4.124-2_amd64.deb ... +Unpacking libdrm-intel1:amd64 (2.4.124-2) ... +Selecting previously unselected package libelf1t64:amd64. +Preparing to unpack .../169-libelf1t64_0.192-4_amd64.deb ... +Unpacking libelf1t64:amd64 (0.192-4) ... +Selecting previously unselected package libedit2:amd64. +Preparing to unpack .../170-libedit2_3.1-20250104-1_amd64.deb ... +Unpacking libedit2:amd64 (3.1-20250104-1) ... +Selecting previously unselected package libz3-4:amd64. +Preparing to unpack .../171-libz3-4_4.13.3-1_amd64.deb ... +Unpacking libz3-4:amd64 (4.13.3-1) ... +Selecting previously unselected package libllvm19:amd64. +Preparing to unpack .../172-libllvm19_1%3a19.1.7-3+b1_amd64.deb ... +Unpacking libllvm19:amd64 (1:19.1.7-3+b1) ... +Selecting previously unselected package libsensors-config. +Preparing to unpack .../173-libsensors-config_1%3a3.6.2-2_all.deb ... +Unpacking libsensors-config (1:3.6.2-2) ... +Selecting previously unselected package libsensors5:amd64. +Preparing to unpack .../174-libsensors5_1%3a3.6.2-2_amd64.deb ... +Unpacking libsensors5:amd64 (1:3.6.2-2) ... +Selecting previously unselected package libxcb-randr0:amd64. +Preparing to unpack .../175-libxcb-randr0_1.17.0-2+b1_amd64.deb ... +Unpacking libxcb-randr0:amd64 (1.17.0-2+b1) ... +Selecting previously unselected package libxcb-sync1:amd64. +Preparing to unpack .../176-libxcb-sync1_1.17.0-2+b1_amd64.deb ... +Unpacking libxcb-sync1:amd64 (1.17.0-2+b1) ... +Selecting previously unselected package libxshmfence1:amd64. +Preparing to unpack .../177-libxshmfence1_1.3.3-1_amd64.deb ... +Unpacking libxshmfence1:amd64 (1.3.3-1) ... +Selecting previously unselected package mesa-libgallium:amd64. +Preparing to unpack .../178-mesa-libgallium_25.0.7-2_amd64.deb ... +Unpacking mesa-libgallium:amd64 (25.0.7-2) ... +Selecting previously unselected package libwayland-server0:amd64. +Preparing to unpack .../179-libwayland-server0_1.23.1-3_amd64.deb ... +Unpacking libwayland-server0:amd64 (1.23.1-3) ... +Selecting previously unselected package libgbm1:amd64. +Preparing to unpack .../180-libgbm1_25.0.7-2_amd64.deb ... +Unpacking libgbm1:amd64 (25.0.7-2) ... +Selecting previously unselected package libgl1-mesa-dri:amd64. +Preparing to unpack .../181-libgl1-mesa-dri_25.0.7-2_amd64.deb ... +Unpacking libgl1-mesa-dri:amd64 (25.0.7-2) ... +Selecting previously unselected package libglx-mesa0:amd64. +Preparing to unpack .../182-libglx-mesa0_25.0.7-2_amd64.deb ... +Unpacking libglx-mesa0:amd64 (25.0.7-2) ... +Selecting previously unselected package libglx0:amd64. +Preparing to unpack .../183-libglx0_1.7.0-1+b2_amd64.deb ... +Unpacking libglx0:amd64 (1.7.0-1+b2) ... +Selecting previously unselected package libgl1:amd64. +Preparing to unpack .../184-libgl1_1.7.0-1+b2_amd64.deb ... +Unpacking libgl1:amd64 (1.7.0-1+b2) ... +Selecting previously unselected package libiec61883-0:amd64. +Preparing to unpack .../185-libiec61883-0_1.2.0-7_amd64.deb ... +Unpacking libiec61883-0:amd64 (1.2.0-7) ... +Selecting previously unselected package libjack-jackd2-0:amd64. +Preparing to unpack .../186-libjack-jackd2-0_1.9.22dfsg-4_amd64.deb ... +Unpacking libjack-jackd2-0:amd64 (1.9.22dfsg-4) ... +Selecting previously unselected package libopenal-data. +Preparing to unpack .../187-libopenal-data_1%3a1.24.2-1_all.deb ... +Unpacking libopenal-data (1:1.24.2-1) ... +Selecting previously unselected package libopenal1:amd64. +Preparing to unpack .../188-libopenal1_1%3a1.24.2-1_amd64.deb ... +Unpacking libopenal1:amd64 (1:1.24.2-1) ... +Selecting previously unselected package libwayland-client0:amd64. +Preparing to unpack .../189-libwayland-client0_1.23.1-3_amd64.deb ... +Unpacking libwayland-client0:amd64 (1.23.1-3) ... +Selecting previously unselected package libdecor-0-0:amd64. +Preparing to unpack .../190-libdecor-0-0_0.2.2-2_amd64.deb ... +Unpacking libdecor-0-0:amd64 (0.2.2-2) ... +Selecting previously unselected package libwayland-cursor0:amd64. +Preparing to unpack .../191-libwayland-cursor0_1.23.1-3_amd64.deb ... +Unpacking libwayland-cursor0:amd64 (1.23.1-3) ... +Selecting previously unselected package libwayland-egl1:amd64. +Preparing to unpack .../192-libwayland-egl1_1.23.1-3_amd64.deb ... +Unpacking libwayland-egl1:amd64 (1.23.1-3) ... +Selecting previously unselected package libxcursor1:amd64. +Preparing to unpack .../193-libxcursor1_1%3a1.2.3-1_amd64.deb ... +Unpacking libxcursor1:amd64 (1:1.2.3-1) ... +Selecting previously unselected package libxi6:amd64. +Preparing to unpack .../194-libxi6_2%3a1.8.2-1_amd64.deb ... +Unpacking libxi6:amd64 (2:1.8.2-1) ... +Selecting previously unselected package xkb-data. +Preparing to unpack .../195-xkb-data_2.42-1_all.deb ... +Unpacking xkb-data (2.42-1) ... +Selecting previously unselected package libxkbcommon0:amd64. +Preparing to unpack .../196-libxkbcommon0_1.7.0-2_amd64.deb ... +Unpacking libxkbcommon0:amd64 (1.7.0-2) ... +Selecting previously unselected package libxrandr2:amd64. +Preparing to unpack .../197-libxrandr2_2%3a1.5.4-1+b3_amd64.deb ... +Unpacking libxrandr2:amd64 (2:1.5.4-1+b3) ... +Selecting previously unselected package x11-common. +Preparing to unpack .../198-x11-common_1%3a7.7+24+deb13u1_all.deb ... +Unpacking x11-common (1:7.7+24+deb13u1) ... +Selecting previously unselected package libxss1:amd64. +Preparing to unpack .../199-libxss1_1%3a1.2.3-1+b3_amd64.deb ... +Unpacking libxss1:amd64 (1:1.2.3-1+b3) ... +Selecting previously unselected package libsdl2-2.0-0:amd64. +Preparing to unpack .../200-libsdl2-2.0-0_2.32.4+dfsg-1_amd64.deb ... +Unpacking libsdl2-2.0-0:amd64 (2.32.4+dfsg-1) ... +Selecting previously unselected package libxcb-shape0:amd64. +Preparing to unpack .../201-libxcb-shape0_1.17.0-2+b1_amd64.deb ... +Unpacking libxcb-shape0:amd64 (1.17.0-2+b1) ... +Selecting previously unselected package libxv1:amd64. +Preparing to unpack .../202-libxv1_2%3a1.0.11-1.1+b3_amd64.deb ... +Unpacking libxv1:amd64 (2:1.0.11-1.1+b3) ... +Selecting previously unselected package libavdevice61:amd64. +Preparing to unpack .../203-libavdevice61_7%3a7.1.3-0+deb13u1_amd64.deb ... +Unpacking libavdevice61:amd64 (7:7.1.3-0+deb13u1) ... +Selecting previously unselected package ffmpeg. +Preparing to unpack .../204-ffmpeg_7%3a7.1.3-0+deb13u1_amd64.deb ... +Unpacking ffmpeg (7:7.1.3-0+deb13u1) ... +Setting up libgme0:amd64 (0.6.3-7+b2) ... +Setting up libchromaprint1:amd64 (1.5.1-7) ... +Setting up libhwy1t64:amd64 (1.2.0-2+b2) ... +Setting up libexpat1:amd64 (2.7.1-2) ... +Setting up libgraphite2-3:amd64 (1.3.14-2+b1) ... +Setting up liblcms2-2:amd64 (2.16-2) ... +Setting up libpixman-1-0:amd64 (0.44.0-3) ... +Setting up libdvdread8t64:amd64 (6.1.3-2) ... +Setting up libudfread0:amd64 (1.1.2-1+b2) ... +Setting up libnorm1t64:amd64 (1.5.9+dfsg-3.1+b2) ... +Setting up libsharpyuv0:amd64 (1.5.0-0.1) ... +Setting up libwayland-server0:amd64 (1.23.1-3) ... +Setting up libaom3:amd64 (3.12.1-1) ... +Setting up libpciaccess0:amd64 (0.17-3+b3) ... +Setting up librabbitmq4:amd64 (0.15.0-1) ... +Setting up libxau6:amd64 (1:1.0.11-1) ... +Setting up libxdmcp6:amd64 (1:1.1.5-1) ... +Setting up libraw1394-11:amd64 (2.1.2-2+b2) ... +Setting up libkeyutils1:amd64 (1.6.3-6) ... +Setting up libxcb1:amd64 (1.17.0-2+b1) ... +Setting up libsodium23:amd64 (1.0.18-1+deb13u1) ... +Setting up libxcb-xfixes0:amd64 (1.17.0-2+b1) ... +Setting up libogg0:amd64 (1.3.5-3+b2) ... +Setting up liblerc4:amd64 (4.0.0+ds-5) ... +Setting up libspeex1:amd64 (1.2.1-3) ... +Setting up libshine3:amd64 (3.1.1-2+b2) ... +Setting up libvpl2 (1:2.14.0-1+b1) ... +Setting up libx264-164:amd64 (2:0.164.3108+git31e19f9-2+b1) ... +Setting up libtwolame0:amd64 (0.4.0-2+b2) ... +Setting up libdatrie1:amd64 (0.2.13-3+b1) ... +Setting up libgsm1:amd64 (1.0.22-1+b2) ... +Setting up libxcb-render0:amd64 (1.17.0-2+b1) ... +Setting up libzix-0-0:amd64 (0.6.2-1) ... +Setting up libglvnd0:amd64 (1.7.0-1+b2) ... +Setting up libcodec2-1.2:amd64 (1.2.0-3) ... +Setting up libxcb-glx0:amd64 (1.17.0-2+b1) ... +Setting up libbrotli1:amd64 (1.1.0-2+b7) ... +Setting up libedit2:amd64 (3.1-20250104-1) ... +Setting up libgdk-pixbuf2.0-common (2.42.12+dfsg-4) ... +Setting up libmysofa1:amd64 (1.3.3+dfsg-1) ... +Setting up libxcb-shape0:amd64 (1.17.0-2+b1) ... +Setting up x11-common (1:7.7+24+deb13u1) ... +debconf: unable to initialize frontend: Dialog +debconf: (TERM is not set, so the dialog frontend is not usable.) +debconf: falling back to frontend: Readline +debconf: unable to initialize frontend: Readline +debconf: (Can't locate Term/ReadLine.pm in @INC (you may need to install the Term::ReadLine module) (@INC entries checked: /etc/perl /usr/local/lib/x86_64-linux-gnu/perl/5.40.1 /usr/local/share/perl/5.40.1 /usr/lib/x86_64-linux-gnu/perl5/5.40 /usr/share/perl5 /usr/lib/x86_64-linux-gnu/perl-base /usr/lib/x86_64-linux-gnu/perl/5.40 /usr/share/perl/5.40 /usr/local/lib/site_perl) at /usr/share/perl5/Debconf/FrontEnd/Readline.pm line 8.) +debconf: falling back to frontend: Teletype +debconf: unable to initialize frontend: Teletype +debconf: (This frontend requires a controlling tty.) +debconf: falling back to frontend: Noninteractive +invoke-rc.d: could not determine current runlevel +invoke-rc.d: policy-rc.d denied execution of start. +Setting up libsensors-config (1:3.6.2-2) ... +Setting up libcdio19t64:amd64 (2.2.0-4) ... +Setting up libdeflate0:amd64 (1.23-2) ... +Setting up xkb-data (2.42-1) ... +Setting up libxcb-shm0:amd64 (1.17.0-2+b1) ... +Setting up libcom-err2:amd64 (1.47.2-3+b7) ... +Setting up libmpg123-0t64:amd64 (1.32.10-1) ... +Setting up libgomp1:amd64 (14.2.0-19) ... +Setting up libcjson1:amd64 (1.7.18-3.1+deb13u1) ... +Setting up libxvidcore4:amd64 (2:1.3.7-1+b2) ... +Setting up libjbig0:amd64 (2.1-6.1+b2) ... +Setting up libelf1t64:amd64 (0.192-4) ... +Setting up libsnappy1v5:amd64 (1.2.2-1) ... +Setting up libcdio-cdda2t64:amd64 (10.2+2.0.2-1+b1) ... +Setting up libkrb5support0:amd64 (1.21.3-5) ... +Setting up libxcb-present0:amd64 (1.17.0-2+b1) ... +Setting up libasound2-data (1.2.14-1) ... +Setting up libpgm-5.3-0t64:amd64 (5.3.128dfsg-2.1+b1) ... +Setting up libtheoraenc1:amd64 (1.2.0alpha1+dfsg-6) ... +Setting up libz3-4:amd64 (4.13.3-1) ... +Setting up libblas3:amd64 (3.12.1-6) ... +update-alternatives: using /usr/lib/x86_64-linux-gnu/blas/libblas.so.3 to provide /usr/lib/x86_64-linux-gnu/libblas.so.3 (libblas.so.3-x86_64-linux-gnu) in auto mode +Setting up libasound2t64:amd64 (1.2.14-1) ... +Setting up libjpeg62-turbo:amd64 (1:2.1.5-4) ... +Setting up libslang2:amd64 (2.3.3-5+b2) ... +Setting up libva2:amd64 (2.22.0-3) ... +Setting up libx11-data (2:1.8.12-1) ... +Setting up libsvtav1enc2:amd64 (2.3.0+dfsg-1) ... +Setting up libxcb-sync1:amd64 (1.17.0-2+b1) ... +Setting up libdbus-1-3:amd64 (1.16.2-2) ... +Setting up libfribidi0:amd64 (1.0.16-1) ... +Setting up libopus0:amd64 (1.5.2-2) ... +Setting up libp11-kit0:amd64 (0.25.5-3) ... +Setting up libcdio-paranoia2t64:amd64 (10.2+2.0.2-1+b1) ... +Setting up libunistring5:amd64 (1.3-2) ... +Setting up fonts-dejavu-mono (2.37-8) ... +Setting up libpng16-16t64:amd64 (1.6.48-1+deb13u3) ... +Setting up libatomic1:amd64 (14.2.0-19) ... +Setting up libvorbis0a:amd64 (1.3.7-3) ... +Setting up fonts-dejavu-core (2.37-8) ... +Setting up libflac14:amd64 (1.5.0+ds-2) ... +Setting up libsensors5:amd64 (1:3.6.2-2) ... +Setting up libk5crypto3:amd64 (1.21.3-5) ... +Setting up libfftw3-double3:amd64 (3.3.10-2+b1) ... +Setting up libgfortran5:amd64 (14.2.0-19) ... +Setting up libvulkan1:amd64 (1.4.309.0-1) ... +Setting up libwebp7:amd64 (1.5.0-0.1) ... +Setting up libnuma1:amd64 (2.0.19-1) ... +Setting up libvidstab1.1:amd64 (1.1.0-2+b2) ... +Setting up libvpx9:amd64 (1.15.0-2.1+deb13u1) ... +Setting up libflite1:amd64 (2.2-7) ... +Setting up libdav1d7:amd64 (1.5.1-1) ... +Setting up ocl-icd-libopencl1:amd64 (2.3.3-1) ... +Setting up libasyncns0:amd64 (0.8-6+b5) ... +Setting up libxshmfence1:amd64 (1.3.3-1) ... +Setting up libtiff6:amd64 (4.7.0-3+deb13u1) ... +Setting up libbs2b0:amd64 (3.1.0+dfsg-8+b1) ... +Setting up libxcb-randr0:amd64 (1.17.0-2+b1) ... +Setting up librav1e0.7:amd64 (0.7.1-9+b2) ... +Setting up libtasn1-6:amd64 (4.20.0-2) ... +Setting up libzimg2:amd64 (3.0.5+ds1-1+b2) ... +Setting up libopenjp2-7:amd64 (2.5.3-2.1deb13u1) ... +Setting up libx11-6:amd64 (2:1.8.12-1) ... +Setting up libopenal-data (1:1.24.2-1) ... +Setting up libthai-data (0.1.29-2) ... +Setting up libkrb5-3:amd64 (1.21.3-5) ... +Setting up libunibreak6:amd64 (6.1-3) ... +Setting up libwayland-egl1:amd64 (1.23.1-3) ... +Setting up libusb-1.0-0:amd64 (2:1.0.28-1) ... +Setting up libmbedcrypto16:amd64 (3.6.5-0.1deb13u1) ... +Setting up libx265-215:amd64 (4.1-2) ... +Setting up libsamplerate0:amd64 (0.2.2-4+b2) ... +Setting up libwebpmux3:amd64 (1.5.0-0.1) ... +Setting up libdrm-common (2.4.124-2) ... +Setting up libjxl0.11:amd64 (0.11.1-4) ... +Setting up libxml2:amd64 (2.12.7+dfsg+really2.9.14-2.1+deb13u2) ... +Setting up libzvbi-common (0.2.44-1) ... +Setting up libmp3lame0:amd64 (3.100-6+b3) ... +Setting up libvorbisenc2:amd64 (1.3.7-3) ... +Setting up libdvdnav4:amd64 (6.1.1-3+b1) ... +Setting up libiec61883-0:amd64 (1.2.0-7) ... +Setting up libserd-0-0:amd64 (0.32.4-1) ... +Setting up libxkbcommon0:amd64 (1.7.0-2) ... +Setting up libwayland-client0:amd64 (1.23.1-3) ... +Setting up libavc1394-0:amd64 (0.5.4-5+b2) ... +Setting up libxcb-dri3-0:amd64 (1.17.0-2+b1) ... +Setting up libllvm19:amd64 (1:19.1.7-3+b1) ... +Setting up libx11-xcb1:amd64 (2:1.8.12-1) ... +Setting up liblapack3:amd64 (3.12.1-6) ... +update-alternatives: using /usr/lib/x86_64-linux-gnu/lapack/liblapack.so.3 to provide /usr/lib/x86_64-linux-gnu/liblapack.so.3 (liblapack.so.3-x86_64-linux-gnu) in auto mode +Setting up libcaca0:amd64 (0.99.beta20-5) ... +Setting up libzvbi0t64:amd64 (0.2.44-1) ... +Setting up libxrender1:amd64 (1:0.9.12-1) ... +Setting up libsoxr0:amd64 (0.1.3-4+b2) ... +Setting up fontconfig-config (2.15.0-2.3) ... +debconf: unable to initialize frontend: Dialog +debconf: (TERM is not set, so the dialog frontend is not usable.) +debconf: falling back to frontend: Readline +debconf: unable to initialize frontend: Readline +debconf: (Can't locate Term/ReadLine.pm in @INC (you may need to install the Term::ReadLine module) (@INC entries checked: /etc/perl /usr/local/lib/x86_64-linux-gnu/perl/5.40.1 /usr/local/share/perl/5.40.1 /usr/lib/x86_64-linux-gnu/perl5/5.40 /usr/share/perl5 /usr/lib/x86_64-linux-gnu/perl-base /usr/lib/x86_64-linux-gnu/perl/5.40 /usr/share/perl/5.40 /usr/local/lib/site_perl) at /usr/share/perl5/Debconf/FrontEnd/Readline.pm line 8.) +debconf: falling back to frontend: Teletype +debconf: unable to initialize frontend: Teletype +debconf: (This frontend requires a controlling tty.) +debconf: falling back to frontend: Noninteractive +Setting up libxext6:amd64 (2:1.3.4-1+b3) ... +Setting up libidn2-0:amd64 (2.3.8-2) ... +Setting up libopenal1:amd64 (1:1.24.2-1) ... +Setting up libxxf86vm1:amd64 (1:1.1.4-1+b4) ... +Setting up librist4:amd64 (0.2.11+dfsg-1) ... +Setting up libthai0:amd64 (0.1.29-2+b1) ... +Setting up libvorbisfile3:amd64 (1.3.7-3) ... +Setting up libglib2.0-0t64:amd64 (2.84.4-3deb13u2) ... +No schema files found: doing nothing. +Setting up libfreetype6:amd64 (2.13.3+dfsg-1) ... +Setting up libxfixes3:amd64 (1:6.0.0-2+b4) ... +Setting up shared-mime-info (2.4-5+b2) ... +Setting up libplacebo349:amd64 (7.349.0-3) ... +Setting up libdc1394-25:amd64 (2.2.6-5) ... +Setting up libxv1:amd64 (2:1.0.11-1.1+b3) ... +Setting up libgssapi-krb5-2:amd64 (1.21.3-5) ... +Setting up libxrandr2:amd64 (2:1.5.4-1+b3) ... +Setting up libssh-4:amd64 (0.11.2-1+deb13u1) ... +Setting up librubberband2:amd64 (3.3.0+dfsg-2+b3) ... +Setting up libjack-jackd2-0:amd64 (1.9.22dfsg-4) ... +Setting up libdrm2:amd64 (2.4.124-2) ... +Setting up libva-drm2:amd64 (2.22.0-3) ... +Setting up libvdpau1:amd64 (1.5-3+b1) ... +Setting up libsord-0-0:amd64 (0.16.18-1) ... +Setting up libwayland-cursor0:amd64 (1.23.1-3) ... +Setting up libsratom-0-0:amd64 (0.6.18-1) ... +Setting up libdecor-0-0:amd64 (0.2.2-2) ... +Setting up libharfbuzz0b:amd64 (10.2.0-1+b1) ... +Setting up libgdk-pixbuf-2.0-0:amd64 (2.42.12+dfsg-4) ... +Setting up libxss1:amd64 (1:1.2.3-1+b3) ... +Setting up libfontconfig1:amd64 (2.15.0-2.3) ... +Setting up libsndfile1:amd64 (1.2.2-2+b1) ... +Setting up libbluray2:amd64 (1:1.3.4-1+b2) ... +Setting up libva-x11-2:amd64 (2.22.0-3) ... +Setting up liblilv-0-0:amd64 (0.24.26-1) ... +Setting up libopenmpt0t64:amd64 (0.7.13-1+b1) ... +Setting up libdrm-amdgpu1:amd64 (2.4.124-2) ... +Setting up libgnutls30t64:amd64 (3.8.9-3+deb13u2) ... +Setting up fontconfig (2.15.0-2.3) ... +Regenerating fonts cache... done. +Setting up libzmq5:amd64 (4.3.5-1+b3) ... +Setting up libxi6:amd64 (2:1.8.2-1) ... +Setting up libpulse0:amd64 (17.0+dfsg1-2+b1) ... +Setting up libxcursor1:amd64 (1:1.2.3-1) ... +Setting up libpango-1.0-0:amd64 (1.56.3-1) ... +Setting up libdrm-intel1:amd64 (2.4.124-2) ... +Setting up libavutil59:amd64 (7:7.1.3-0+deb13u1) ... +Setting up libcairo2:amd64 (1.18.4-1+b1) ... +Setting up libpostproc58:amd64 (7:7.1.3-0+deb13u1) ... +Setting up libsphinxbase3t64:amd64 (0.8+5prealpha+1-21+b1) ... +Setting up libswresample5:amd64 (7:7.1.3-0+deb13u1) ... +Setting up libswscale8:amd64 (7:7.1.3-0+deb13u1) ... +Setting up libass9:amd64 (1:0.17.3-1+b1) ... +Setting up libtheoradec1:amd64 (1.2.0alpha1+dfsg-6) ... +Setting up libsrt1.5-gnutls:amd64 (1.5.4-1) ... +Setting up libcairo-gobject2:amd64 (1.18.4-1+b1) ... +Setting up libpangoft2-1.0-0:amd64 (1.56.3-1) ... +Setting up libpangocairo-1.0-0:amd64 (1.56.3-1) ... +Setting up mesa-libgallium:amd64 (25.0.7-2) ... +Setting up libgbm1:amd64 (25.0.7-2) ... +Setting up libgl1-mesa-dri:amd64 (25.0.7-2) ... +Setting up librsvg2-2:amd64 (2.60.0+dfsg-1) ... +Setting up libpocketsphinx3:amd64 (0.8+5prealpha+1-15+b4) ... +Setting up libavcodec61:amd64 (7:7.1.3-0+deb13u1) ... +Setting up libsdl2-2.0-0:amd64 (2.32.4+dfsg-1) ... +Setting up libglx-mesa0:amd64 (25.0.7-2) ... +Setting up libglx0:amd64 (1.7.0-1+b2) ... +Setting up libavformat61:amd64 (7:7.1.3-0+deb13u1) ... +Setting up libgl1:amd64 (1.7.0-1+b2) ... +Setting up libavfilter10:amd64 (7:7.1.3-0+deb13u1) ... +Setting up libavdevice61:amd64 (7:7.1.3-0+deb13u1) ... +Setting up ffmpeg (7:7.1.3-0+deb13u1) ... +Processing triggers for libc-bin (2.41-12+deb13u1) ... +Removing intermediate container 260c6ab32750 +f1bf401f901b +Step 3/13 : WORKDIR /app +Running in 5fee62d4b8b3 +Removing intermediate container 5fee62d4b8b3 +d2b14bbb1741 +Step 4/13 : RUN pip install --no-cache-dir torch torchaudio --index-url https://download.pytorch.org/whl/cpu +Running in b6c087cbd2ee +Looking in indexes: https://download.pytorch.org/whl/cpu +Collecting torch +Downloading https://download.pytorch.org/whl/cpu/torch-2.10.0%2Bcpu-cp311-cp311-manylinux_2_28_x86_64.whl.metadata (29 kB) +Collecting torchaudio +Downloading https://download.pytorch.org/whl/cpu/torchaudio-2.10.0%2Bcpu-cp311-cp311-manylinux_2_28_x86_64.whl.metadata (6.9 kB) +Collecting filelock (from torch) +Downloading filelock-3.20.0-py3-none-any.whl.metadata (2.1 kB) +Collecting typing-extensions>=4.10.0 (from torch) +Downloading https://download.pytorch.org/whl/typing_extensions-4.15.0-py3-none-any.whl.metadata (3.3 kB) +Collecting sympy>=1.13.3 (from torch) +Downloading sympy-1.14.0-py3-none-any.whl.metadata (12 kB) +Collecting networkx>=2.5.1 (from torch) +Downloading networkx-3.6.1-py3-none-any.whl.metadata (6.8 kB) +Collecting jinja2 (from torch) +Downloading https://download.pytorch.org/whl/jinja2-3.1.6-py3-none-any.whl.metadata (2.9 kB) +Collecting fsspec>=0.8.5 (from torch) +Downloading fsspec-2025.12.0-py3-none-any.whl.metadata (10 kB) +Collecting mpmath<1.4,>=1.1.0 (from sympy>=1.13.3->torch) +Downloading mpmath-1.3.0-py3-none-any.whl.metadata (8.6 kB) +Collecting MarkupSafe>=2.0 (from jinja2->torch) +Downloading https://download.pytorch.org/whl/MarkupSafe-3.0.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl.metadata (4.0 kB) +Downloading https://download.pytorch.org/whl/cpu/torch-2.10.0%2Bcpu-cp311-cp311-manylinux_2_28_x86_64.whl (188.8 MB) +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 188.8/188.8 MB 242.9 MB/s eta 0:00:00 +Downloading https://download.pytorch.org/whl/cpu/torchaudio-2.10.0%2Bcpu-cp311-cp311-manylinux_2_28_x86_64.whl (412 kB) +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 412.7/412.7 kB 45.1 MB/s eta 0:00:00 +Downloading fsspec-2025.12.0-py3-none-any.whl (201 kB) +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 201.4/201.4 kB 12.3 MB/s eta 0:00:00 +Downloading networkx-3.6.1-py3-none-any.whl (2.1 MB) +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 2.1/2.1 MB 93.1 MB/s eta 0:00:00 +Downloading sympy-1.14.0-py3-none-any.whl (6.3 MB) +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 6.3/6.3 MB 238.0 MB/s eta 0:00:00 +Downloading https://download.pytorch.org/whl/typing_extensions-4.15.0-py3-none-any.whl (44 kB) +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 44.6/44.6 kB 156.2 MB/s eta 0:00:00 +Downloading filelock-3.20.0-py3-none-any.whl (16 kB) +Downloading https://download.pytorch.org/whl/jinja2-3.1.6-py3-none-any.whl (134 kB) +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 134.9/134.9 kB 248.4 MB/s eta 0:00:00 +Downloading https://download.pytorch.org/whl/MarkupSafe-3.0.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl (23 kB) +Downloading mpmath-1.3.0-py3-none-any.whl (536 kB) +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 536.2/536.2 kB 312.6 MB/s eta 0:00:00 +Installing collected packages: mpmath, typing-extensions, sympy, networkx, MarkupSafe, fsspec, filelock, jinja2, torch, torchaudio +Successfully installed MarkupSafe-3.0.2 filelock-3.20.0 fsspec-2025.12.0 jinja2-3.1.6 mpmath-1.3.0 networkx-3.6.1 sympy-1.14.0 torch-2.10.0+cpu torchaudio-2.10.0+cpu typing-extensions-4.15.0 +WARNING: Running pip as the 'root' user can result in broken permissions and conflicting behaviour with the system package manager. It is recommended to use a virtual environment instead: https://pip.pypa.io/warnings/venv +Removing intermediate container b6c087cbd2ee +64ee0f425591 +Step 5/13 : COPY requirements.txt . +7660a43ac475 +Step 6/13 : RUN pip install --no-cache-dir -r requirements.txt +Running in 7f1b251d78ff +Collecting flask>=3.0.0 (from -r requirements.txt (line 1)) +Downloading flask-3.1.3-py3-none-any.whl.metadata (3.2 kB) +Collecting flask-cors>=4.0.0 (from -r requirements.txt (line 2)) +Downloading flask_cors-6.0.2-py3-none-any.whl.metadata (5.3 kB) +Collecting gunicorn>=21.2.0 (from -r requirements.txt (line 3)) +Downloading gunicorn-25.1.0-py3-none-any.whl.metadata (5.5 kB) +Collecting numpy>=1.24.0 (from -r requirements.txt (line 4)) +Downloading numpy-2.4.2-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl.metadata (6.6 kB) +Collecting transformers>=4.30.0 (from -r requirements.txt (line 5)) +Downloading transformers-5.2.0-py3-none-any.whl.metadata (32 kB) +Collecting pydub>=0.25.1 (from -r requirements.txt (line 6)) +Downloading pydub-0.25.1-py2.py3-none-any.whl.metadata (1.4 kB) +Collecting librosa>=0.10.0 (from -r requirements.txt (line 7)) +Downloading librosa-0.11.0-py3-none-any.whl.metadata (8.7 kB) +Collecting scipy>=1.10.0 (from -r requirements.txt (line 8)) +Downloading scipy-1.17.0-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl.metadata (62 kB) +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 62.1/62.1 kB 216.7 MB/s eta 0:00:00 +Collecting addict>=2.4.0 (from -r requirements.txt (line 9)) +Downloading addict-2.4.0-py3-none-any.whl.metadata (1.0 kB) +Collecting yapf>=0.40.0 (from -r requirements.txt (line 10)) +Downloading yapf-0.43.0-py3-none-any.whl.metadata (46 kB) +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 46.8/46.8 kB 213.8 MB/s eta 0:00:00 +Collecting termcolor>=2.0.0 (from -r requirements.txt (line 11)) +Downloading termcolor-3.3.0-py3-none-any.whl.metadata (6.5 kB) +Collecting blinker>=1.9.0 (from flask>=3.0.0->-r requirements.txt (line 1)) +Downloading blinker-1.9.0-py3-none-any.whl.metadata (1.6 kB) +Collecting click>=8.1.3 (from flask>=3.0.0->-r requirements.txt (line 1)) +Downloading click-8.3.1-py3-none-any.whl.metadata (2.6 kB) +Collecting itsdangerous>=2.2.0 (from flask>=3.0.0->-r requirements.txt (line 1)) +Downloading itsdangerous-2.2.0-py3-none-any.whl.metadata (1.9 kB) +Requirement already satisfied: jinja2>=3.1.2 in /usr/local/lib/python3.11/site-packages (from flask>=3.0.0->-r requirements.txt (line 1)) (3.1.6) +Requirement already satisfied: markupsafe>=2.1.1 in /usr/local/lib/python3.11/site-packages (from flask>=3.0.0->-r requirements.txt (line 1)) (3.0.2) +Collecting werkzeug>=3.1.0 (from flask>=3.0.0->-r requirements.txt (line 1)) +Downloading werkzeug-3.1.6-py3-none-any.whl.metadata (4.0 kB) +Collecting packaging (from gunicorn>=21.2.0->-r requirements.txt (line 3)) +Downloading packaging-26.0-py3-none-any.whl.metadata (3.3 kB) +Collecting huggingface-hub<2.0,>=1.3.0 (from transformers>=4.30.0->-r requirements.txt (line 5)) +Downloading huggingface_hub-1.4.1-py3-none-any.whl.metadata (13 kB) +Collecting pyyaml>=5.1 (from transformers>=4.30.0->-r requirements.txt (line 5)) +Downloading pyyaml-6.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl.metadata (2.4 kB) +Collecting regex!=2019.12.17 (from transformers>=4.30.0->-r requirements.txt (line 5)) +Downloading regex-2026.2.19-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl.metadata (40 kB) +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 40.4/40.4 kB 214.8 MB/s eta 0:00:00 +Collecting tokenizers<=0.23.0,>=0.22.0 (from transformers>=4.30.0->-r requirements.txt (line 5)) +Downloading tokenizers-0.22.2-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl.metadata (7.3 kB) +Collecting typer-slim (from transformers>=4.30.0->-r requirements.txt (line 5)) +Downloading typer_slim-0.24.0-py3-none-any.whl.metadata (4.2 kB) +Collecting safetensors>=0.4.3 (from transformers>=4.30.0->-r requirements.txt (line 5)) +Downloading safetensors-0.7.0-cp38-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl.metadata (4.1 kB) +Collecting tqdm>=4.27 (from transformers>=4.30.0->-r requirements.txt (line 5)) +Downloading tqdm-4.67.3-py3-none-any.whl.metadata (57 kB) +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 57.7/57.7 kB 222.9 MB/s eta 0:00:00 +Collecting audioread>=2.1.9 (from librosa>=0.10.0->-r requirements.txt (line 7)) +Downloading audioread-3.1.0-py3-none-any.whl.metadata (9.0 kB) +Collecting numba>=0.51.0 (from librosa>=0.10.0->-r requirements.txt (line 7)) +Downloading numba-0.64.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl.metadata (2.9 kB) +Collecting scikit-learn>=1.1.0 (from librosa>=0.10.0->-r requirements.txt (line 7)) +Downloading scikit_learn-1.8.0-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl.metadata (11 kB) +Collecting joblib>=1.0 (from librosa>=0.10.0->-r requirements.txt (line 7)) +Downloading joblib-1.5.3-py3-none-any.whl.metadata (5.5 kB) +Collecting decorator>=4.3.0 (from librosa>=0.10.0->-r requirements.txt (line 7)) +Downloading decorator-5.2.1-py3-none-any.whl.metadata (3.9 kB) +Collecting soundfile>=0.12.1 (from librosa>=0.10.0->-r requirements.txt (line 7)) +Downloading soundfile-0.13.1-py2.py3-none-manylinux_2_28_x86_64.whl.metadata (16 kB) +Collecting pooch>=1.1 (from librosa>=0.10.0->-r requirements.txt (line 7)) +Downloading pooch-1.9.0-py3-none-any.whl.metadata (10 kB) +Collecting soxr>=0.3.2 (from librosa>=0.10.0->-r requirements.txt (line 7)) +Downloading soxr-1.0.0-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl.metadata (5.6 kB) +Requirement already satisfied: typing_extensions>=4.1.1 in /usr/local/lib/python3.11/site-packages (from librosa>=0.10.0->-r requirements.txt (line 7)) (4.15.0) +Collecting lazy_loader>=0.1 (from librosa>=0.10.0->-r requirements.txt (line 7)) +Downloading lazy_loader-0.4-py3-none-any.whl.metadata (7.6 kB) +Collecting msgpack>=1.0 (from librosa>=0.10.0->-r requirements.txt (line 7)) +Downloading msgpack-1.1.2-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl.metadata (8.1 kB) +Collecting platformdirs>=3.5.1 (from yapf>=0.40.0->-r requirements.txt (line 10)) +Downloading platformdirs-4.9.2-py3-none-any.whl.metadata (4.7 kB) +Requirement already satisfied: filelock in /usr/local/lib/python3.11/site-packages (from huggingface-hub<2.0,>=1.3.0->transformers>=4.30.0->-r requirements.txt (line 5)) (3.20.0) +Requirement already satisfied: fsspec>=2023.5.0 in /usr/local/lib/python3.11/site-packages (from huggingface-hub<2.0,>=1.3.0->transformers>=4.30.0->-r requirements.txt (line 5)) (2025.12.0) +Collecting hf-xet<2.0.0,>=1.2.0 (from huggingface-hub<2.0,>=1.3.0->transformers>=4.30.0->-r requirements.txt (line 5)) +Downloading hf_xet-1.2.0-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl.metadata (4.9 kB) +Collecting httpx<1,>=0.23.0 (from huggingface-hub<2.0,>=1.3.0->transformers>=4.30.0->-r requirements.txt (line 5)) +Downloading httpx-0.28.1-py3-none-any.whl.metadata (7.1 kB) +Collecting shellingham (from huggingface-hub<2.0,>=1.3.0->transformers>=4.30.0->-r requirements.txt (line 5)) +Downloading shellingham-1.5.4-py2.py3-none-any.whl.metadata (3.5 kB) +Collecting llvmlite<0.47,>=0.46.0dev0 (from numba>=0.51.0->librosa>=0.10.0->-r requirements.txt (line 7)) +Downloading llvmlite-0.46.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl.metadata (5.0 kB) +Collecting requests>=2.19.0 (from pooch>=1.1->librosa>=0.10.0->-r requirements.txt (line 7)) +Downloading requests-2.32.5-py3-none-any.whl.metadata (4.9 kB) +Collecting threadpoolctl>=3.2.0 (from scikit-learn>=1.1.0->librosa>=0.10.0->-r requirements.txt (line 7)) +Downloading threadpoolctl-3.6.0-py3-none-any.whl.metadata (13 kB) +Collecting cffi>=1.0 (from soundfile>=0.12.1->librosa>=0.10.0->-r requirements.txt (line 7)) +Downloading cffi-2.0.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl.metadata (2.6 kB) +Collecting typer>=0.24.0 (from typer-slim->transformers>=4.30.0->-r requirements.txt (line 5)) +Downloading typer-0.24.1-py3-none-any.whl.metadata (16 kB) +Collecting pycparser (from cffi>=1.0->soundfile>=0.12.1->librosa>=0.10.0->-r requirements.txt (line 7)) +Downloading pycparser-3.0-py3-none-any.whl.metadata (8.2 kB) +Collecting anyio (from httpx<1,>=0.23.0->huggingface-hub<2.0,>=1.3.0->transformers>=4.30.0->-r requirements.txt (line 5)) +Downloading anyio-4.12.1-py3-none-any.whl.metadata (4.3 kB) +Collecting certifi (from httpx<1,>=0.23.0->huggingface-hub<2.0,>=1.3.0->transformers>=4.30.0->-r requirements.txt (line 5)) +Downloading certifi-2026.1.4-py3-none-any.whl.metadata (2.5 kB) +Collecting httpcore==1.* (from httpx<1,>=0.23.0->huggingface-hub<2.0,>=1.3.0->transformers>=4.30.0->-r requirements.txt (line 5)) +Downloading httpcore-1.0.9-py3-none-any.whl.metadata (21 kB) +Collecting idna (from httpx<1,>=0.23.0->huggingface-hub<2.0,>=1.3.0->transformers>=4.30.0->-r requirements.txt (line 5)) +Downloading idna-3.11-py3-none-any.whl.metadata (8.4 kB) +Collecting h11>=0.16 (from httpcore==1.*->httpx<1,>=0.23.0->huggingface-hub<2.0,>=1.3.0->transformers>=4.30.0->-r requirements.txt (line 5)) +Downloading h11-0.16.0-py3-none-any.whl.metadata (8.3 kB) +Collecting charset_normalizer<4,>=2 (from requests>=2.19.0->pooch>=1.1->librosa>=0.10.0->-r requirements.txt (line 7)) +Downloading charset_normalizer-3.4.4-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl.metadata (37 kB) +Collecting urllib3<3,>=1.21.1 (from requests>=2.19.0->pooch>=1.1->librosa>=0.10.0->-r requirements.txt (line 7)) +Downloading urllib3-2.6.3-py3-none-any.whl.metadata (6.9 kB) +Collecting rich>=12.3.0 (from typer>=0.24.0->typer-slim->transformers>=4.30.0->-r requirements.txt (line 5)) +Downloading rich-14.3.3-py3-none-any.whl.metadata (18 kB) +Collecting annotated-doc>=0.0.2 (from typer>=0.24.0->typer-slim->transformers>=4.30.0->-r requirements.txt (line 5)) +Downloading annotated_doc-0.0.4-py3-none-any.whl.metadata (6.6 kB) +Collecting markdown-it-py>=2.2.0 (from rich>=12.3.0->typer>=0.24.0->typer-slim->transformers>=4.30.0->-r requirements.txt (line 5)) +Downloading markdown_it_py-4.0.0-py3-none-any.whl.metadata (7.3 kB) +Collecting pygments<3.0.0,>=2.13.0 (from rich>=12.3.0->typer>=0.24.0->typer-slim->transformers>=4.30.0->-r requirements.txt (line 5)) +Downloading pygments-2.19.2-py3-none-any.whl.metadata (2.5 kB) +Collecting mdurl~=0.1 (from markdown-it-py>=2.2.0->rich>=12.3.0->typer>=0.24.0->typer-slim->transformers>=4.30.0->-r requirements.txt (line 5)) +Downloading mdurl-0.1.2-py3-none-any.whl.metadata (1.6 kB) +Downloading flask-3.1.3-py3-none-any.whl (103 kB) +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 103.4/103.4 kB 254.7 MB/s eta 0:00:00 +Downloading flask_cors-6.0.2-py3-none-any.whl (13 kB) +Downloading gunicorn-25.1.0-py3-none-any.whl (197 kB) +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 197.1/197.1 kB 241.5 MB/s eta 0:00:00 +Downloading numpy-2.4.2-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl (16.9 MB) +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 16.9/16.9 MB 260.6 MB/s eta 0:00:00 +Downloading transformers-5.2.0-py3-none-any.whl (10.4 MB) +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 10.4/10.4 MB 201.7 MB/s eta 0:00:00 +Downloading pydub-0.25.1-py2.py3-none-any.whl (32 kB) +Downloading librosa-0.11.0-py3-none-any.whl (260 kB) +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 260.7/260.7 kB 303.3 MB/s eta 0:00:00 +Downloading scipy-1.17.0-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl (35.1 MB) +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 35.1/35.1 MB 250.6 MB/s eta 0:00:00 +Downloading addict-2.4.0-py3-none-any.whl (3.8 kB) +Downloading yapf-0.43.0-py3-none-any.whl (256 kB) +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 256.2/256.2 kB 297.3 MB/s eta 0:00:00 +Downloading termcolor-3.3.0-py3-none-any.whl (7.7 kB) +Downloading audioread-3.1.0-py3-none-any.whl (23 kB) +Downloading blinker-1.9.0-py3-none-any.whl (8.5 kB) +Downloading click-8.3.1-py3-none-any.whl (108 kB) +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 108.3/108.3 kB 263.5 MB/s eta 0:00:00 +Downloading decorator-5.2.1-py3-none-any.whl (9.2 kB) +Downloading huggingface_hub-1.4.1-py3-none-any.whl (553 kB) +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 553.3/553.3 kB 321.8 MB/s eta 0:00:00 +Downloading itsdangerous-2.2.0-py3-none-any.whl (16 kB) +Downloading joblib-1.5.3-py3-none-any.whl (309 kB) +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 309.1/309.1 kB 304.4 MB/s eta 0:00:00 +Downloading lazy_loader-0.4-py3-none-any.whl (12 kB) +Downloading msgpack-1.1.2-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl (426 kB) +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 426.2/426.2 kB 232.7 MB/s eta 0:00:00 +Downloading numba-0.64.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl (3.7 MB) +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 3.7/3.7 MB 139.6 MB/s eta 0:00:00 +Downloading packaging-26.0-py3-none-any.whl (74 kB) +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 74.4/74.4 kB 256.0 MB/s eta 0:00:00 +Downloading platformdirs-4.9.2-py3-none-any.whl (21 kB) +Downloading pooch-1.9.0-py3-none-any.whl (67 kB) +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 67.2/67.2 kB 237.2 MB/s eta 0:00:00 +Downloading pyyaml-6.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl (806 kB) +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 806.6/806.6 kB 314.8 MB/s eta 0:00:00 +Downloading regex-2026.2.19-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl (800 kB) +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 800.6/800.6 kB 116.4 MB/s eta 0:00:00 +Downloading safetensors-0.7.0-cp38-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl (507 kB) +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 507.2/507.2 kB 192.2 MB/s eta 0:00:00 +Downloading scikit_learn-1.8.0-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl (9.1 MB) +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 9.1/9.1 MB 169.3 MB/s eta 0:00:00 +Downloading soundfile-0.13.1-py2.py3-none-manylinux_2_28_x86_64.whl (1.3 MB) +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 1.3/1.3 MB 108.5 MB/s eta 0:00:00 +Downloading soxr-1.0.0-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl (242 kB) +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 242.6/242.6 kB 303.7 MB/s eta 0:00:00 +Downloading tokenizers-0.22.2-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl (3.3 MB) +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 3.3/3.3 MB 278.8 MB/s eta 0:00:00 +Downloading tqdm-4.67.3-py3-none-any.whl (78 kB) +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 78.4/78.4 kB 262.5 MB/s eta 0:00:00 +Downloading werkzeug-3.1.6-py3-none-any.whl (225 kB) +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 225.2/225.2 kB 303.7 MB/s eta 0:00:00 +Downloading typer_slim-0.24.0-py3-none-any.whl (3.4 kB) +Downloading cffi-2.0.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl (215 kB) +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 215.6/215.6 kB 298.7 MB/s eta 0:00:00 +Downloading hf_xet-1.2.0-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl (3.3 MB) +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 3.3/3.3 MB 265.9 MB/s eta 0:00:00 +Downloading httpx-0.28.1-py3-none-any.whl (73 kB) +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 73.5/73.5 kB 243.8 MB/s eta 0:00:00 +Downloading httpcore-1.0.9-py3-none-any.whl (78 kB) +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 78.8/78.8 kB 247.4 MB/s eta 0:00:00 +Downloading llvmlite-0.46.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl (56.3 MB) +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 56.3/56.3 MB 136.0 MB/s eta 0:00:00 +Downloading requests-2.32.5-py3-none-any.whl (64 kB) +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 64.7/64.7 kB 232.5 MB/s eta 0:00:00 +Downloading threadpoolctl-3.6.0-py3-none-any.whl (18 kB) +Downloading typer-0.24.1-py3-none-any.whl (56 kB) +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 56.1/56.1 kB 226.5 MB/s eta 0:00:00 +Downloading shellingham-1.5.4-py2.py3-none-any.whl (9.8 kB) +Downloading annotated_doc-0.0.4-py3-none-any.whl (5.3 kB) +Downloading certifi-2026.1.4-py3-none-any.whl (152 kB) +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 152.9/152.9 kB 298.3 MB/s eta 0:00:00 +Downloading charset_normalizer-3.4.4-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl (151 kB) +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 151.6/151.6 kB 287.6 MB/s eta 0:00:00 +Downloading idna-3.11-py3-none-any.whl (71 kB) +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 71.0/71.0 kB 251.1 MB/s eta 0:00:00 +Downloading rich-14.3.3-py3-none-any.whl (310 kB) +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 310.5/310.5 kB 311.4 MB/s eta 0:00:00 +Downloading urllib3-2.6.3-py3-none-any.whl (131 kB) +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 131.6/131.6 kB 244.4 MB/s eta 0:00:00 +Downloading anyio-4.12.1-py3-none-any.whl (113 kB) +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 113.6/113.6 kB 274.4 MB/s eta 0:00:00 +Downloading pycparser-3.0-py3-none-any.whl (48 kB) +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 48.2/48.2 kB 218.6 MB/s eta 0:00:00 +Downloading h11-0.16.0-py3-none-any.whl (37 kB) +Downloading markdown_it_py-4.0.0-py3-none-any.whl (87 kB) +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 87.3/87.3 kB 251.4 MB/s eta 0:00:00 +Downloading pygments-2.19.2-py3-none-any.whl (1.2 MB) +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 1.2/1.2 MB 295.2 MB/s eta 0:00:00 +Downloading mdurl-0.1.2-py3-none-any.whl (10.0 kB) +Installing collected packages: pydub, addict, werkzeug, urllib3, tqdm, threadpoolctl, termcolor, shellingham, safetensors, regex, pyyaml, pygments, pycparser, platformdirs, packaging, numpy, msgpack, mdurl, llvmlite, joblib, itsdangerous, idna, hf-xet, h11, decorator, click, charset_normalizer, certifi, blinker, audioread, annotated-doc, yapf, soxr, scipy, requests, numba, markdown-it-py, lazy_loader, httpcore, gunicorn, flask, cffi, anyio, soundfile, scikit-learn, rich, pooch, httpx, flask-cors, typer, librosa, typer-slim, huggingface-hub, tokenizers, transformers +Successfully installed addict-2.4.0 annotated-doc-0.0.4 anyio-4.12.1 audioread-3.1.0 blinker-1.9.0 certifi-2026.1.4 cffi-2.0.0 charset_normalizer-3.4.4 click-8.3.1 decorator-5.2.1 flask-3.1.3 flask-cors-6.0.2 gunicorn-25.1.0 h11-0.16.0 hf-xet-1.2.0 httpcore-1.0.9 httpx-0.28.1 huggingface-hub-1.4.1 idna-3.11 itsdangerous-2.2.0 joblib-1.5.3 lazy_loader-0.4 librosa-0.11.0 llvmlite-0.46.0 markdown-it-py-4.0.0 mdurl-0.1.2 msgpack-1.1.2 numba-0.64.0 numpy-2.4.2 packaging-26.0 platformdirs-4.9.2 pooch-1.9.0 pycparser-3.0 pydub-0.25.1 pygments-2.19.2 pyyaml-6.0.3 regex-2026.2.19 requests-2.32.5 rich-14.3.3 safetensors-0.7.0 scikit-learn-1.8.0 scipy-1.17.0 shellingham-1.5.4 soundfile-0.13.1 soxr-1.0.0 termcolor-3.3.0 threadpoolctl-3.6.0 tokenizers-0.22.2 tqdm-4.67.3 transformers-5.2.0 typer-0.24.1 typer-slim-0.24.0 urllib3-2.6.3 werkzeug-3.1.6 yapf-0.43.0 +WARNING: Running pip as the 'root' user can result in broken permissions and conflicting behaviour with the system package manager. It is recommended to use a virtual environment instead: https://pip.pypa.io/warnings/venv +[notice] A new release of pip is available: 24.0 -> 26.0.1 +[notice] To update, run: pip install --upgrade pip +Removing intermediate container 7f1b251d78ff +988029676e7d +Step 7/13 : COPY . . +32eea4dd8634 +Step 8/13 : RUN mkdir -p /tmp/audio2exp_logs/model +Running in 2f0673c4be47 +Removing intermediate container 2f0673c4be47 +91e09eb3ce79 +Step 9/13 : ENV PORT=8080 +Running in c2a9102e38ae +Removing intermediate container c2a9102e38ae +2d57b332543b +Step 10/13 : ENV MODEL_DIR=/app/models +Running in 7667ae5f798c +Removing intermediate container 7667ae5f798c +8c641c874d47 +Step 11/13 : ENV DEVICE=cpu +Running in 206b0c235204 +Removing intermediate container 206b0c235204 +3d31e6ee812d +Step 12/13 : EXPOSE 8080 +Running in 4a873fb1c572 +Removing intermediate container 4a873fb1c572 +019852cdc7e7 +Step 13/13 : CMD gunicorn --bind "0.0.0.0:${PORT}" --timeout 120 --workers 1 --threads 4 app:app +Running in ddaca56c82a9 +Removing intermediate container ddaca56c82a9 +cb69fb8db61b +Successfully built cb69fb8db61b +Successfully tagged gcr.io/hp-support-477512/audio2exp-service:latest +PUSH +Pushing gcr.io/hp-support-477512/audio2exp-service +The push refers to repository [gcr.io/hp-support-477512/audio2exp-service] +5757b0be363a: Preparing +4b49359c5b03: Preparing +5d55d9c1a7a1: Preparing +aed8a4253f51: Preparing +30e376cfee89: Preparing +5c304e0ad96f: Preparing +ddd5c3b94f8a: Preparing +b69aea4cac7d: Preparing +40b88e8d19a2: Preparing +dfd9efb4ec4c: Preparing +a8ff6f8cbdfd: Preparing +a8ff6f8cbdfd: Waiting +40b88e8d19a2: Layer already exists +b69aea4cac7d: Layer already exists +dfd9efb4ec4c: Layer already exists +a8ff6f8cbdfd: Layer already exists +5c304e0ad96f: Pushed +aed8a4253f51: Pushed +5757b0be363a: Pushed +ddd5c3b94f8a: Pushed +5d55d9c1a7a1: Pushed +30e376cfee89: Pushed +4b49359c5b03: Pushed +latest: digest: sha256:a9ee3f13b9df325dea6133f47291dddb0f84f383c98496575d97a230c8199c76 size: 2633 +DONE +ID: 74c0e980-00c8-4dba-b056-d21b2800cde4 +CREATE_TIME: 2026-02-23T00:00:02+00:00 +DURATION: 6M50S +SOURCE: gs://hp-support-477512_cloudbuild/source/1771803997.405528-0d213767221d4f1b8bf3aa4a8b1c61e9.tgz +IMAGES: gcr.io/hp-support-477512/audio2exp-service (+1 more) +STATUS: SUCCESS +Deploying container to Cloud Run service [audio2exp-service] in project [hp-support-477512] region [us-central1] +OK Deploying... Done. +OK Creating Revision... +OK Routing traffic... +OK Setting IAM Policy... +Done. +Service [audio2exp-service] revision [audio2exp-service-00022-wx2] has been deployed and is serving 100 percent of traffic. +Service URL: https://audio2exp-service-417509577941.us-central1.run.app +PS C:\Users\hamad\audio2exp-service> gcloud run services logs read audio2exp-service --region us-central1 --project hp-support-477512 --limit 30 + +2026-02-22 23:40:19 2026-02-22 23:40:19,243 [INFO] [A2E Engine] Wav2Vec2 loaded (fallback mode) +2026-02-22 23:40:19 2026-02-22 23:40:19,243 [INFO] [A2E Engine] Ready (Wav2Vec2 fallback mode) +2026-02-22 23:40:19 2026-02-22 23:40:19,243 [INFO] [Audio2Exp] Engine ready in 1077.4s +2026-02-23 00:08:34 [2026-02-23 00:08:34 +0000] [10] [INFO] Starting gunicorn 25.1.0 +2026-02-23 00:08:34 [2026-02-23 00:08:34 +0000] [10] [INFO] Listening at: http://0.0.0.0:8080 (10) +2026-02-23 00:08:34 [2026-02-23 00:08:34 +0000] [10] [INFO] Using worker: gthread +2026-02-23 00:08:34 [2026-02-23 00:08:34 +0000] [10] [INFO] Control socket listening at /app/gunicorn.ctl +2026-02-23 00:08:34 [2026-02-23 00:08:34 +0000] [12] [INFO] Booting worker with pid: 12 +2026-02-23 00:08:46 2026-02-23 00:08:46,538 [INFO] [Audio2Exp] Server started, engine loading in background... +2026-02-23 00:09:04 2026-02-23 00:09:04,838 [INFO] [Audio2Exp] Loading engine: model_dir=/app/models, device=cpu +2026-02-23 00:13:38 2026-02-23 00:13:38,042 [INFO] [A2E Engine] Device: cpu +2026-02-23 00:13:38 2026-02-23 00:13:38,237 [INFO] [A2E Engine] Found LAM_Audio2Expression: /app/LAM_Audio2Expression +2026-02-23 00:13:38 2026-02-23 00:13:38,238 [INFO] [A2E Engine] Checkpoint: /app/models/pretrained_models/lam_audio2exp_streaming.tar +2026-02-23 00:13:38 2026-02-23 00:13:38,238 [INFO] [A2E Engine] Wav2Vec2: /app/models/wav2vec2-base-960h +PS C:\Users\hamad\audio2exp-service> gcloud run services logs read audio2exp-service --region us-central1 --project hp-support-477512 --limit 20 + +2026-02-23 00:23:47 Loading weights: 99%|█████████▊| 209/212 [00:01<00:00, 70.57it/s, Materializing param=feature_projection.layer_norm.bias] +2026-02-23 00:23:47 Loading weights: 99%|█████████▉| 210/212 [00:01<00:00, 70.57it/s, Materializing param=feature_projection.layer_norm.weight] +2026-02-23 00:23:47 Loading weights: 99%|█████████▉| 210/212 [00:01<00:00, 70.57it/s, Materializing param=feature_projection.layer_norm.weight] +2026-02-23 00:23:47 Loading weights: 100%|█████████▉| 211/212 [00:01<00:00, 70.57it/s, Materializing param=feature_projection.projection.bias] +2026-02-23 00:23:47 Loading weights: 100%|█████████▉| 211/212 [00:01<00:00, 70.57it/s, Materializing param=feature_projection.projection.bias] +2026-02-23 00:23:47 Loading weights: 100%|██████████| 212/212 [00:01<00:00, 70.57it/s, Materializing param=feature_projection.projection.weight] +2026-02-23 00:23:47 Loading weights: 100%|██████████| 212/212 [00:01<00:00, 70.57it/s, Materializing param=feature_projection.projection.weight] +2026-02-23 00:23:47 Loading weights: 100%|██████████| 212/212 [00:01<00:00, 129.53it/s, Materializing param=feature_projection.projection.weight] +2026-02-23 00:23:47 Wav2Vec2Model LOAD REPORT from: /app/models/wav2vec2-base-960h +2026-02-23 00:23:47 Key | Status | +2026-02-23 00:23:47 ------------------+----------+------------------------------------------------------------------------------------------ +2026-02-23 00:23:47 masked_spec_embed | MISSING | +2026-02-23 00:23:47 lm_head.weight | MISMATCH | Reinit due to size mismatch - ckpt: torch.Size([32, 768]) vs model:torch.Size([32, 1024]) +2026-02-23 00:23:47 Notes: +2026-02-23 00:23:47 - MISSING :those params were newly initialized because missing from the checkpoint. Consider training on your downstream task. +2026-02-23 00:23:47 - MISMATCH :ckpt weights were loaded, but they did not match the original empty weight shapes. +2026-02-23 00:23:47 [2026-02-23 00:23:47,364 INFO infer.py line 76 12] Num params: 97912596 +2026-02-23 00:23:47 [2026-02-23 00:23:47,367 INFO infer.py line 83 12] Loading weight at: /app/models/pretrained_models/lam_audio2exp_streaming.tar +2026-02-23 00:23:50 [2026-02-23 00:23:50,534 INFO infer.py line 95 12] => Loaded weight '/app/models/pretrained_models/lam_audio2exp_streaming.tar' +2026-02-23 00:23:50 2026-02-23 00:23:50,545 [INFO] [A2E Engine] Running warmup inference... +PS C:\Users\hamad\audio2exp-service> gcloud run services logs read audio2exp-service --region us-central1 --project hp-support-477512 --limit 10 + +2026-02-23 00:23:47 ------------------+----------+------------------------------------------------------------------------------------------ +2026-02-23 00:23:47 masked_spec_embed | MISSING | +2026-02-23 00:23:47 lm_head.weight | MISMATCH | Reinit due to size mismatch - ckpt: torch.Size([32, 768]) vs model:torch.Size([32, 1024]) +2026-02-23 00:23:47 Notes: +2026-02-23 00:23:47 - MISSING :those params were newly initialized because missing from the checkpoint. Consider training on your downstream task. +2026-02-23 00:23:47 - MISMATCH :ckpt weights were loaded, but they did not match the original empty weight shapes. +2026-02-23 00:23:47 [2026-02-23 00:23:47,364 INFO infer.py line 76 12] Num params: 97912596 +2026-02-23 00:23:47 [2026-02-23 00:23:47,367 INFO infer.py line 83 12] Loading weight at: /app/models/pretrained_models/lam_audio2exp_streaming.tar +2026-02-23 00:23:50 [2026-02-23 00:23:50,534 INFO infer.py line 95 12] => Loaded weight '/app/models/pretrained_models/lam_audio2exp_streaming.tar' +2026-02-23 00:23:50 2026-02-23 00:23:50,545 [INFO] [A2E Engine] Running warmup inference... +PS C:\Users\hamad\audio2exp-service> gcloud run services logs read audio2exp-service --region us-central1 --project hp-support-477512 --limit 10 + +2026-02-23 00:23:47 ------------------+----------+------------------------------------------------------------------------------------------ +2026-02-23 00:23:47 masked_spec_embed | MISSING | +2026-02-23 00:23:47 lm_head.weight | MISMATCH | Reinit due to size mismatch - ckpt: torch.Size([32, 768]) vs model:torch.Size([32, 1024]) +2026-02-23 00:23:47 Notes: +2026-02-23 00:23:47 - MISSING :those params were newly initialized because missing from the checkpoint. Consider training on your downstream task. +2026-02-23 00:23:47 - MISMATCH :ckpt weights were loaded, but they did not match the original empty weight shapes. +2026-02-23 00:23:47 [2026-02-23 00:23:47,364 INFO infer.py line 76 12] Num params: 97912596 +2026-02-23 00:23:47 [2026-02-23 00:23:47,367 INFO infer.py line 83 12] Loading weight at: /app/models/pretrained_models/lam_audio2exp_streaming.tar +2026-02-23 00:23:50 [2026-02-23 00:23:50,534 INFO infer.py line 95 12] => Loaded weight '/app/models/pretrained_models/lam_audio2exp_streaming.tar' +2026-02-23 00:23:50 2026-02-23 00:23:50,545 [INFO] [A2E Engine] Running warmup inference... +PS C:\Users\hamad\audio2exp-service> gcloud run revisions list --service audio2exp-service --region us-central1 --project hp-support-477512 + +✔ +REVISION: audio2exp-service-00022-wx2 +ACTIVE: yes +SERVICE: audio2exp-service +DEPLOYED: 2026-02-23 00:07:01 UTC +DEPLOYED BY: gpro.mirai@gmail.com +✔ +REVISION: audio2exp-service-00021-vnq +ACTIVE: +SERVICE: audio2exp-service +DEPLOYED: 2026-02-22 18:51:16 UTC +DEPLOYED BY: gpro.mirai@gmail.com +✔ +REVISION: audio2exp-service-00020-xxq +ACTIVE: +SERVICE: audio2exp-service +DEPLOYED: 2026-02-22 17:37:08 UTC +DEPLOYED BY: gpro.mirai@gmail.com +✔ +REVISION: audio2exp-service-00019-t42 +ACTIVE: +SERVICE: audio2exp-service +DEPLOYED: 2026-02-22 13:00:38 UTC +DEPLOYED BY: gpro.mirai@gmail.com +✔ +REVISION: audio2exp-service-00018-sqv +ACTIVE: +SERVICE: audio2exp-service +DEPLOYED: 2026-02-21 09:06:47 UTC +DEPLOYED BY: gpro.mirai@gmail.com +✔ +REVISION: audio2exp-service-00017-xgt +ACTIVE: +SERVICE: audio2exp-service +DEPLOYED: 2026-02-21 06:01:57 UTC +DEPLOYED BY: gpro.mirai@gmail.com +✔ +REVISION: audio2exp-service-00016-4p4 +ACTIVE: +SERVICE: audio2exp-service +DEPLOYED: 2026-02-21 05:02:45 UTC +DEPLOYED BY: gpro.mirai@gmail.com +✔ +REVISION: audio2exp-service-00015-g77 +ACTIVE: +SERVICE: audio2exp-service +DEPLOYED: 2026-02-21 03:37:52 UTC +DEPLOYED BY: gpro.mirai@gmail.com +✔ +REVISION: audio2exp-service-00014-xqh +ACTIVE: +SERVICE: audio2exp-service +DEPLOYED: 2026-02-07 17:14:29 UTC +DEPLOYED BY: 417509577941-compute@developer.gserviceaccount.com +✔ +REVISION: audio2exp-service-00013-tz9 +ACTIVE: +SERVICE: audio2exp-service +DEPLOYED: 2026-02-07 13:55:26 UTC +DEPLOYED BY: 417509577941-compute@developer.gserviceaccount.com +✔ +REVISION: audio2exp-service-00012-42m +ACTIVE: +SERVICE: audio2exp-service +DEPLOYED: 2026-02-07 11:01:30 UTC +DEPLOYED BY: 417509577941-compute@developer.gserviceaccount.com +✔ +REVISION: audio2exp-service-00011-8q8 +ACTIVE: +SERVICE: audio2exp-service +DEPLOYED: 2026-02-07 08:31:56 UTC +DEPLOYED BY: 417509577941-compute@developer.gserviceaccount.com +✔ +REVISION: audio2exp-service-00010-md6 +ACTIVE: +SERVICE: audio2exp-service +DEPLOYED: 2026-02-07 07:22:18 UTC +DEPLOYED BY: 417509577941-compute@developer.gserviceaccount.com +✔ +REVISION: audio2exp-service-00009-ftm +ACTIVE: +SERVICE: audio2exp-service +DEPLOYED: 2026-02-07 05:50:08 UTC +DEPLOYED BY: gpro.mirai@gmail.com +✔ +REVISION: audio2exp-service-00008-tqs +ACTIVE: +SERVICE: audio2exp-service +DEPLOYED: 2026-02-07 02:15:51 UTC +DEPLOYED BY: 417509577941-compute@developer.gserviceaccount.com +✔ +REVISION: audio2exp-service-00007-x5f +ACTIVE: +SERVICE: audio2exp-service +DEPLOYED: 2026-02-07 00:57:42 UTC +DEPLOYED BY: 417509577941-compute@developer.gserviceaccount.com +✔ +REVISION: audio2exp-service-00006-f4g +ACTIVE: +SERVICE: audio2exp-service +DEPLOYED: 2026-02-06 14:00:55 UTC +DEPLOYED BY: 417509577941-compute@developer.gserviceaccount.com +✔ +REVISION: audio2exp-service-00005-5n2 +ACTIVE: +SERVICE: audio2exp-service +DEPLOYED: 2026-02-06 12:12:00 UTC +DEPLOYED BY: gpro.mirai@gmail.com +✔ +REVISION: audio2exp-service-00004-jk4 +ACTIVE: +SERVICE: audio2exp-service +DEPLOYED: 2026-02-06 11:50:59 UTC +DEPLOYED BY: 417509577941-compute@developer.gserviceaccount.com +✔ +REVISION: audio2exp-service-00003-gwx +ACTIVE: +SERVICE: audio2exp-service +DEPLOYED: 2026-02-06 08:43:45 UTC +DEPLOYED BY: gpro.mirai@gmail.com +✔ +REVISION: audio2exp-service-00002-6h4 +ACTIVE: +SERVICE: audio2exp-service +DEPLOYED: 2026-02-06 07:16:41 UTC +DEPLOYED BY: gpro.mirai@gmail.com +✔ +REVISION: audio2exp-service-00001-rq6 +ACTIVE: +SERVICE: audio2exp-service +DEPLOYED: 2026-02-06 06:23:39 UTC +DEPLOYED BY: gpro.mirai@gmail.com +PS C:\Users\hamad\audio2exp-service> gcloud run services describe audio2exp-service --region us-central1 --project hp-support-477512 --format "value(spec.template.spec.containers[0].resources.limits)" + +cpu=2;memory=4Gi +PS C:\Users\hamad\audio2exp-service> gcloud run services logs read audio2exp-service --region us-central1 --project hp-support-477512 --limit 50 + +2026-02-23 00:23:47 Loading weights: 92%|█████████▏| 194/212 [00:01<00:00, 70.57it/s, Materializing param=encoder.layers.11.final_layer_norm.weight] +2026-02-23 00:23:47 Loading weights: 92%|█████████▏| 195/212 [00:01<00:00, 70.57it/s, Materializing param=encoder.layers.11.layer_norm.bias] +2026-02-23 00:23:47 Loading weights: 92%|█████████▏| 195/212 [00:01<00:00, 70.57it/s, Materializing param=encoder.layers.11.layer_norm.bias] +2026-02-23 00:23:47 Loading weights: 92%|█████████▏| 196/212 [00:01<00:00, 70.57it/s, Materializing param=encoder.layers.11.layer_norm.weight] +2026-02-23 00:23:47 Loading weights: 92%|█████████▏| 196/212 [00:01<00:00, 70.57it/s, Materializing param=encoder.layers.11.layer_norm.weight] +2026-02-23 00:23:47 Loading weights: 93%|█████████▎| 197/212 [00:01<00:00, 70.57it/s, Materializing param=encoder.pos_conv_embed.conv.bias] +2026-02-23 00:23:47 Loading weights: 93%|█████████▎| 197/212 [00:01<00:00, 70.57it/s, Materializing param=encoder.pos_conv_embed.conv.bias] +2026-02-23 00:23:47 Loading weights: 93%|█████████▎| 198/212 [00:01<00:00, 70.57it/s, Materializing param=encoder.pos_conv_embed.conv.parametrizations.weight.original0] +2026-02-23 00:23:47 Loading weights: 93%|█████████▎| 198/212 [00:01<00:00, 70.57it/s, Materializing param=encoder.pos_conv_embed.conv.parametrizations.weight.original0] +2026-02-23 00:23:47 Loading weights: 94%|█████████▍| 199/212 [00:01<00:00, 70.57it/s, Materializing param=encoder.pos_conv_embed.conv.parametrizations.weight.original1] +2026-02-23 00:23:47 Loading weights: 94%|█████████▍| 199/212 [00:01<00:00, 70.57it/s, Materializing param=encoder.pos_conv_embed.conv.parametrizations.weight.original1] +2026-02-23 00:23:47 Loading weights: 94%|█████████▍| 200/212 [00:01<00:00, 70.57it/s, Materializing param=feature_extractor.conv_layers.0.conv.weight] +2026-02-23 00:23:47 Loading weights: 94%|█████████▍| 200/212 [00:01<00:00, 70.57it/s, Materializing param=feature_extractor.conv_layers.0.conv.weight] +2026-02-23 00:23:47 Loading weights: 95%|█████████▍| 201/212 [00:01<00:00, 70.57it/s, Materializing param=feature_extractor.conv_layers.0.layer_norm.bias] +2026-02-23 00:23:47 Loading weights: 95%|█████████▍| 201/212 [00:01<00:00, 70.57it/s, Materializing param=feature_extractor.conv_layers.0.layer_norm.bias] +2026-02-23 00:23:47 Loading weights: 95%|█████████▌| 202/212 [00:01<00:00, 70.57it/s, Materializing param=feature_extractor.conv_layers.0.layer_norm.weight] +2026-02-23 00:23:47 Loading weights: 95%|█████████▌| 202/212 [00:01<00:00, 70.57it/s, Materializing param=feature_extractor.conv_layers.0.layer_norm.weight] +2026-02-23 00:23:47 Loading weights: 96%|█████████▌| 203/212 [00:01<00:00, 70.57it/s, Materializing param=feature_extractor.conv_layers.1.conv.weight] +2026-02-23 00:23:47 Loading weights: 96%|█████████▌| 203/212 [00:01<00:00, 70.57it/s, Materializing param=feature_extractor.conv_layers.1.conv.weight] +2026-02-23 00:23:47 Loading weights: 96%|█████████▌| 204/212 [00:01<00:00, 70.57it/s, Materializing param=feature_extractor.conv_layers.2.conv.weight] +2026-02-23 00:23:47 Loading weights: 96%|█████████▌| 204/212 [00:01<00:00, 70.57it/s, Materializing param=feature_extractor.conv_layers.2.conv.weight] +2026-02-23 00:23:47 Loading weights: 97%|█████████▋| 205/212 [00:01<00:00, 70.57it/s, Materializing param=feature_extractor.conv_layers.3.conv.weight] +2026-02-23 00:23:47 Loading weights: 97%|█████████▋| 205/212 [00:01<00:00, 70.57it/s, Materializing param=feature_extractor.conv_layers.3.conv.weight] +2026-02-23 00:23:47 Loading weights: 97%|█████████▋| 206/212 [00:01<00:00, 70.57it/s, Materializing param=feature_extractor.conv_layers.4.conv.weight] +2026-02-23 00:23:47 Loading weights: 97%|█████████▋| 206/212 [00:01<00:00, 70.57it/s, Materializing param=feature_extractor.conv_layers.4.conv.weight] +2026-02-23 00:23:47 Loading weights: 98%|█████████▊| 207/212 [00:01<00:00, 70.57it/s, Materializing param=feature_extractor.conv_layers.5.conv.weight] +2026-02-23 00:23:47 Loading weights: 98%|█████████▊| 207/212 [00:01<00:00, 70.57it/s, Materializing param=feature_extractor.conv_layers.5.conv.weight] +2026-02-23 00:23:47 Loading weights: 98%|█████████▊| 208/212 [00:01<00:00, 70.57it/s, Materializing param=feature_extractor.conv_layers.6.conv.weight] +2026-02-23 00:23:47 Loading weights: 98%|█████████▊| 208/212 [00:01<00:00, 70.57it/s, Materializing param=feature_extractor.conv_layers.6.conv.weight] +2026-02-23 00:23:47 Loading weights: 99%|█████████▊| 209/212 [00:01<00:00, 70.57it/s, Materializing param=feature_projection.layer_norm.bias] +2026-02-23 00:23:47 Loading weights: 99%|█████████▊| 209/212 [00:01<00:00, 70.57it/s, Materializing param=feature_projection.layer_norm.bias] +2026-02-23 00:23:47 Loading weights: 99%|█████████▉| 210/212 [00:01<00:00, 70.57it/s, Materializing param=feature_projection.layer_norm.weight] +2026-02-23 00:23:47 Loading weights: 99%|█████████▉| 210/212 [00:01<00:00, 70.57it/s, Materializing param=feature_projection.layer_norm.weight] +2026-02-23 00:23:47 Loading weights: 100%|█████████▉| 211/212 [00:01<00:00, 70.57it/s, Materializing param=feature_projection.projection.bias] +2026-02-23 00:23:47 Loading weights: 100%|█████████▉| 211/212 [00:01<00:00, 70.57it/s, Materializing param=feature_projection.projection.bias] +2026-02-23 00:23:47 Loading weights: 100%|██████████| 212/212 [00:01<00:00, 70.57it/s, Materializing param=feature_projection.projection.weight] +2026-02-23 00:23:47 Loading weights: 100%|██████████| 212/212 [00:01<00:00, 70.57it/s, Materializing param=feature_projection.projection.weight] +2026-02-23 00:23:47 Loading weights: 100%|██████████| 212/212 [00:01<00:00, 129.53it/s, Materializing param=feature_projection.projection.weight] +2026-02-23 00:23:47 Wav2Vec2Model LOAD REPORT from: /app/models/wav2vec2-base-960h +2026-02-23 00:23:47 Key | Status | +2026-02-23 00:23:47 ------------------+----------+------------------------------------------------------------------------------------------ +2026-02-23 00:23:47 masked_spec_embed | MISSING | +2026-02-23 00:23:47 lm_head.weight | MISMATCH | Reinit due to size mismatch - ckpt: torch.Size([32, 768]) vs model:torch.Size([32, 1024]) +2026-02-23 00:23:47 Notes: +2026-02-23 00:23:47 - MISSING :those params were newly initialized because missing from the checkpoint. Consider training on your downstream task. +2026-02-23 00:23:47 - MISMATCH :ckpt weights were loaded, but they did not match the original empty weight shapes. +2026-02-23 00:23:47 [2026-02-23 00:23:47,364 INFO infer.py line 76 12] Num params: 97912596 +2026-02-23 00:23:47 [2026-02-23 00:23:47,367 INFO infer.py line 83 12] Loading weight at: /app/models/pretrained_models/lam_audio2exp_streaming.tar +2026-02-23 00:23:50 [2026-02-23 00:23:50,534 INFO infer.py line 95 12] => Loaded weight '/app/models/pretrained_models/lam_audio2exp_streaming.tar' +2026-02-23 00:23:50 2026-02-23 00:23:50,545 [INFO] [A2E Engine] Running warmup inference... +PS C:\Users\hamad\audio2exp-service> cd C:\Users\hamad\audio2exp-service + +gcloud builds submit --tag asia-northeast1-docker.pkg.dev/hp-support-477512/cloud-run-source-deploy/audio2exp-service --project hp-support-477512 --timeout=1800 + +Creating temporary archive of 97 file(s) totalling 1.8 GiB before compression. +Uploading tarball of [.] to [gs://hp-support-477512_cloudbuild/source/1771812917.931794-13c80fdd6faa4891b97c266ad5f5dc8d.tgz] +Created [https://cloudbuild.googleapis.com/v1/projects/hp-support-477512/locations/global/builds/914136ba-08c0-4603-8eec-197871482fc3]. +Logs are available at [ https://console.cloud.google.com/cloud-build/builds/914136ba-08c0-4603-8eec-197871482fc3?project=417509577941 ]. +Waiting for build to complete. Polling interval: 1 second(s). +------------------------------------------------- REMOTE BUILD OUTPUT -------------------------------------------------- +starting build "914136ba-08c0-4603-8eec-197871482fc3" +FETCHSOURCE +Fetching storage object: gs://hp-support-477512_cloudbuild/source/1771812917.931794-13c80fdd6faa4891b97c266ad5f5dc8d.tgz#1771814032994448 +Copying gs://hp-support-477512_cloudbuild/source/1771812917.931794-13c80fdd6faa4891b97c266ad5f5dc8d.tgz#1771814032994448... + +[1 files][ 1.4 GiB/ 1.4 GiB] 63.2 MiB/s +Operation completed over 1 objects/1.4 GiB. +BUILD +Already have image (with digest): gcr.io/cloud-builders/gcb-internal +Sending build context to Docker daemon 1.953GB +Step 1/13 : FROM python:3.11-slim +3.11-slim: Pulling from library/python +0c8d55a45c0d: Already exists +64faa99400e1: Pulling fs layer +8cbc47ff628d: Pulling fs layer +d85099f0969e: Pulling fs layer +64faa99400e1: Download complete +d85099f0969e: Verifying Checksum +d85099f0969e: Download complete +64faa99400e1: Pull complete +8cbc47ff628d: Verifying Checksum +8cbc47ff628d: Download complete +8cbc47ff628d: Pull complete +d85099f0969e: Pull complete +Digest: sha256:0b23cfb7425d065008b778022a17b1551c82f8b4866ee5a7a200084b7e2eafbf +Status: Downloaded newer image for python:3.11-slim +466c0182639b +Step 2/13 : RUN apt-get update && apt-get install -y --no-install-recommends ffmpeg libsndfile1 && rm -rf /var/lib/apt/lists/* +Running in 49b42f3bf5da +Hit:1 http://deb.debian.org/debian trixie InRelease +Get:2 http://deb.debian.org/debian trixie-updates InRelease [47.3 kB] +Get:3 http://deb.debian.org/debian-security trixie-security InRelease [43.4 kB] +Get:4 http://deb.debian.org/debian trixie/main amd64 Packages [9670 kB] +Get:5 http://deb.debian.org/debian trixie-updates/main amd64 Packages [5412 B] +Get:6 http://deb.debian.org/debian-security trixie-security/main amd64 Packages [112 kB] +Fetched 9879 kB in 1s (7577 kB/s) +Reading package lists... +Reading package lists... +Building dependency tree... +Reading state information... +The following additional packages will be installed: +fontconfig fontconfig-config fonts-dejavu-core fonts-dejavu-mono libaom3 +libasound2-data libasound2t64 libass9 libasyncns0 libatomic1 libavc1394-0 +libavcodec61 libavdevice61 libavfilter10 libavformat61 libavutil59 libblas3 +libbluray2 libbrotli1 libbs2b0 libcaca0 libcairo-gobject2 libcairo2 +libcdio-cdda2t64 libcdio-paranoia2t64 libcdio19t64 libchromaprint1 libcjson1 +libcodec2-1.2 libcom-err2 libdatrie1 libdav1d7 libdbus-1-3 libdc1394-25 +libdecor-0-0 libdeflate0 libdrm-amdgpu1 libdrm-common libdrm-intel1 libdrm2 +libdvdnav4 libdvdread8t64 libedit2 libelf1t64 libexpat1 libfftw3-double3 +libflac14 libflite1 libfontconfig1 libfreetype6 libfribidi0 libgbm1 +libgdk-pixbuf-2.0-0 libgdk-pixbuf2.0-common libgfortran5 libgl1 +libgl1-mesa-dri libglib2.0-0t64 libglvnd0 libglx-mesa0 libglx0 libgme0 +libgnutls30t64 libgomp1 libgraphite2-3 libgsm1 libgssapi-krb5-2 +libharfbuzz0b libhwy1t64 libidn2-0 libiec61883-0 libjack-jackd2-0 libjbig0 +libjpeg62-turbo libjxl0.11 libk5crypto3 libkeyutils1 libkrb5-3 +libkrb5support0 liblapack3 liblcms2-2 liblerc4 liblilv-0-0 libllvm19 +libmbedcrypto16 libmp3lame0 libmpg123-0t64 libmysofa1 libnorm1t64 libnuma1 +libogg0 libopenal-data libopenal1 libopenjp2-7 libopenmpt0t64 libopus0 +libp11-kit0 libpango-1.0-0 libpangocairo-1.0-0 libpangoft2-1.0-0 +libpciaccess0 libpgm-5.3-0t64 libpixman-1-0 libplacebo349 libpng16-16t64 +libpocketsphinx3 libpostproc58 libpulse0 librabbitmq4 librav1e0.7 +libraw1394-11 librist4 librsvg2-2 librubberband2 libsamplerate0 +libsdl2-2.0-0 libsensors-config libsensors5 libserd-0-0 libsharpyuv0 +libshine3 libslang2 libsnappy1v5 libsodium23 libsord-0-0 libsoxr0 libspeex1 +libsphinxbase3t64 libsratom-0-0 libsrt1.5-gnutls libssh-4 libsvtav1enc2 +libswresample5 libswscale8 libtasn1-6 libthai-data libthai0 libtheoradec1 +libtheoraenc1 libtiff6 libtwolame0 libudfread0 libunibreak6 libunistring5 +libusb-1.0-0 libva-drm2 libva-x11-2 libva2 libvdpau1 libvidstab1.1 +libvorbis0a libvorbisenc2 libvorbisfile3 libvpl2 libvpx9 libvulkan1 +libwayland-client0 libwayland-cursor0 libwayland-egl1 libwayland-server0 +libwebp7 libwebpmux3 libx11-6 libx11-data libx11-xcb1 libx264-164 +libx265-215 libxau6 libxcb-dri3-0 libxcb-glx0 libxcb-present0 libxcb-randr0 +libxcb-render0 libxcb-shape0 libxcb-shm0 libxcb-sync1 libxcb-xfixes0 libxcb1 +libxcursor1 libxdmcp6 libxext6 libxfixes3 libxi6 libxkbcommon0 libxml2 +libxrandr2 libxrender1 libxshmfence1 libxss1 libxv1 libxvidcore4 libxxf86vm1 +libz3-4 libzimg2 libzix-0-0 libzmq5 libzvbi-common libzvbi0t64 +mesa-libgallium ocl-icd-libopencl1 shared-mime-info x11-common xkb-data +Suggested packages: +ffmpeg-doc alsa-utils libasound2-plugins libcuda1 libnvcuvid1 +libnvidia-encode1 libbluray-bdj libdvdcss2 libfftw3-bin libfftw3-dev +low-memory-monitor gnutls-bin krb5-doc krb5-user jackd2 liblcms2-utils +libportaudio2 libsndio7.0 opus-tools pciutils pulseaudio libraw1394-doc +librsvg2-bin xdg-utils lm-sensors serdi sordi speex opencl-icd +Recommended packages: +alsa-ucm-conf alsa-topology-conf libaacs0 dbus default-libdecor-0-plugin-1 +| libdecor-0-plugin-1 libgdk-pixbuf2.0-bin libglib2.0-data xdg-user-dirs +krb5-locales pocketsphinx-en-us librsvg2-common va-driver-all | va-driver +vdpau-driver-all | vdpau-driver mesa-vulkan-drivers | vulkan-icd +The following NEW packages will be installed: +ffmpeg fontconfig fontconfig-config fonts-dejavu-core fonts-dejavu-mono +libaom3 libasound2-data libasound2t64 libass9 libasyncns0 libatomic1 +libavc1394-0 libavcodec61 libavdevice61 libavfilter10 libavformat61 +libavutil59 libblas3 libbluray2 libbrotli1 libbs2b0 libcaca0 +libcairo-gobject2 libcairo2 libcdio-cdda2t64 libcdio-paranoia2t64 +libcdio19t64 libchromaprint1 libcjson1 libcodec2-1.2 libcom-err2 libdatrie1 +libdav1d7 libdbus-1-3 libdc1394-25 libdecor-0-0 libdeflate0 libdrm-amdgpu1 +libdrm-common libdrm-intel1 libdrm2 libdvdnav4 libdvdread8t64 libedit2 +libelf1t64 libexpat1 libfftw3-double3 libflac14 libflite1 libfontconfig1 +libfreetype6 libfribidi0 libgbm1 libgdk-pixbuf-2.0-0 libgdk-pixbuf2.0-common +libgfortran5 libgl1 libgl1-mesa-dri libglib2.0-0t64 libglvnd0 libglx-mesa0 +libglx0 libgme0 libgnutls30t64 libgomp1 libgraphite2-3 libgsm1 +libgssapi-krb5-2 libharfbuzz0b libhwy1t64 libidn2-0 libiec61883-0 +libjack-jackd2-0 libjbig0 libjpeg62-turbo libjxl0.11 libk5crypto3 +libkeyutils1 libkrb5-3 libkrb5support0 liblapack3 liblcms2-2 liblerc4 +liblilv-0-0 libllvm19 libmbedcrypto16 libmp3lame0 libmpg123-0t64 libmysofa1 +libnorm1t64 libnuma1 libogg0 libopenal-data libopenal1 libopenjp2-7 +libopenmpt0t64 libopus0 libp11-kit0 libpango-1.0-0 libpangocairo-1.0-0 +libpangoft2-1.0-0 libpciaccess0 libpgm-5.3-0t64 libpixman-1-0 libplacebo349 +libpng16-16t64 libpocketsphinx3 libpostproc58 libpulse0 librabbitmq4 +librav1e0.7 libraw1394-11 librist4 librsvg2-2 librubberband2 libsamplerate0 +libsdl2-2.0-0 libsensors-config libsensors5 libserd-0-0 libsharpyuv0 +libshine3 libslang2 libsnappy1v5 libsndfile1 libsodium23 libsord-0-0 +libsoxr0 libspeex1 libsphinxbase3t64 libsratom-0-0 libsrt1.5-gnutls libssh-4 +libsvtav1enc2 libswresample5 libswscale8 libtasn1-6 libthai-data libthai0 +libtheoradec1 libtheoraenc1 libtiff6 libtwolame0 libudfread0 libunibreak6 +libunistring5 libusb-1.0-0 libva-drm2 libva-x11-2 libva2 libvdpau1 +libvidstab1.1 libvorbis0a libvorbisenc2 libvorbisfile3 libvpl2 libvpx9 +libvulkan1 libwayland-client0 libwayland-cursor0 libwayland-egl1 +libwayland-server0 libwebp7 libwebpmux3 libx11-6 libx11-data libx11-xcb1 +libx264-164 libx265-215 libxau6 libxcb-dri3-0 libxcb-glx0 libxcb-present0 +libxcb-randr0 libxcb-render0 libxcb-shape0 libxcb-shm0 libxcb-sync1 +libxcb-xfixes0 libxcb1 libxcursor1 libxdmcp6 libxext6 libxfixes3 libxi6 +libxkbcommon0 libxml2 libxrandr2 libxrender1 libxshmfence1 libxss1 libxv1 +libxvidcore4 libxxf86vm1 libz3-4 libzimg2 libzix-0-0 libzmq5 libzvbi-common +libzvbi0t64 mesa-libgallium ocl-icd-libopencl1 shared-mime-info x11-common +xkb-data +0 upgraded, 205 newly installed, 0 to remove and 0 not upgraded. +Need to get 133 MB of archives. +After this operation, 466 MB of additional disk space will be used. +Get:1 http://deb.debian.org/debian trixie/main amd64 libexpat1 amd64 2.7.1-2 [108 kB] +Get:2 http://deb.debian.org/debian trixie/main amd64 libaom3 amd64 3.12.1-1 [1871 kB] +Get:3 http://deb.debian.org/debian trixie/main amd64 libdrm-common all 2.4.124-2 [8288 B] +Get:4 http://deb.debian.org/debian trixie/main amd64 libdrm2 amd64 2.4.124-2 [39.0 kB] +Get:5 http://deb.debian.org/debian trixie/main amd64 libva2 amd64 2.22.0-3 [79.4 kB] +Get:6 http://deb.debian.org/debian trixie/main amd64 libva-drm2 amd64 2.22.0-3 [18.3 kB] +Get:7 http://deb.debian.org/debian trixie/main amd64 libxau6 amd64 1:1.0.11-1 [20.4 kB] +Get:8 http://deb.debian.org/debian trixie/main amd64 libxdmcp6 amd64 1:1.1.5-1 [27.8 kB] +Get:9 http://deb.debian.org/debian trixie/main amd64 libxcb1 amd64 1.17.0-2+b1 [144 kB] +Get:10 http://deb.debian.org/debian trixie/main amd64 libx11-data all 2:1.8.12-1 [343 kB] +Get:11 http://deb.debian.org/debian trixie/main amd64 libx11-6 amd64 2:1.8.12-1 [815 kB] +Get:12 http://deb.debian.org/debian trixie/main amd64 libx11-xcb1 amd64 2:1.8.12-1 [247 kB] +Get:13 http://deb.debian.org/debian trixie/main amd64 libxcb-dri3-0 amd64 1.17.0-2+b1 [107 kB] +Get:14 http://deb.debian.org/debian trixie/main amd64 libxext6 amd64 2:1.3.4-1+b3 [50.4 kB] +Get:15 http://deb.debian.org/debian trixie/main amd64 libxfixes3 amd64 1:6.0.0-2+b4 [20.2 kB] +Get:16 http://deb.debian.org/debian trixie/main amd64 libva-x11-2 amd64 2.22.0-3 [23.1 kB] +Get:17 http://deb.debian.org/debian trixie/main amd64 libvdpau1 amd64 1.5-3+b1 [27.2 kB] +Get:18 http://deb.debian.org/debian trixie/main amd64 libvpl2 amd64 1:2.14.0-1+b1 [129 kB] +Get:19 http://deb.debian.org/debian trixie/main amd64 ocl-icd-libopencl1 amd64 2.3.3-1 [42.9 kB] +Get:20 http://deb.debian.org/debian trixie/main amd64 libavutil59 amd64 7:7.1.3-0+deb13u1 [417 kB] +Get:21 http://deb.debian.org/debian trixie/main amd64 libbrotli1 amd64 1.1.0-2+b7 [307 kB] +Get:22 http://deb.debian.org/debian-security trixie-security/main amd64 libpng16-16t64 amd64 1.6.48-1+deb13u3 [283 kB] +Get:23 http://deb.debian.org/debian trixie/main amd64 libfreetype6 amd64 2.13.3+dfsg-1 [452 kB] +Get:24 http://deb.debian.org/debian trixie/main amd64 fonts-dejavu-mono all 2.37-8 [489 kB] +Get:25 http://deb.debian.org/debian trixie/main amd64 fonts-dejavu-core all 2.37-8 [840 kB] +Get:26 http://deb.debian.org/debian trixie/main amd64 fontconfig-config amd64 2.15.0-2.3 [318 kB] +Get:27 http://deb.debian.org/debian trixie/main amd64 libfontconfig1 amd64 2.15.0-2.3 [392 kB] +Get:28 http://deb.debian.org/debian trixie/main amd64 libpixman-1-0 amd64 0.44.0-3 [248 kB] +Get:29 http://deb.debian.org/debian trixie/main amd64 libxcb-render0 amd64 1.17.0-2+b1 [115 kB] +Get:30 http://deb.debian.org/debian trixie/main amd64 libxcb-shm0 amd64 1.17.0-2+b1 [105 kB] +Get:31 http://deb.debian.org/debian trixie/main amd64 libxrender1 amd64 1:0.9.12-1 [27.9 kB] +Get:32 http://deb.debian.org/debian trixie/main amd64 libcairo2 amd64 1.18.4-1+b1 [538 kB] +Get:33 http://deb.debian.org/debian trixie/main amd64 libcodec2-1.2 amd64 1.2.0-3 [8170 kB] +Get:34 http://deb.debian.org/debian trixie/main amd64 libdav1d7 amd64 1.5.1-1 [559 kB] +Get:35 http://deb.debian.org/debian trixie/main amd64 libatomic1 amd64 14.2.0-19 [9308 B] +Get:36 http://deb.debian.org/debian trixie/main amd64 libglib2.0-0t64 amd64 2.84.4-3deb13u2 [1518 kB] +Get:37 http://deb.debian.org/debian trixie/main amd64 libgsm1 amd64 1.0.22-1+b2 [29.3 kB] +Get:38 http://deb.debian.org/debian trixie/main amd64 libhwy1t64 amd64 1.2.0-2+b2 [676 kB] +Get:39 http://deb.debian.org/debian trixie/main amd64 liblcms2-2 amd64 2.16-2 [160 kB] +Get:40 http://deb.debian.org/debian trixie/main amd64 libjxl0.11 amd64 0.11.1-4 [1132 kB] +Get:41 http://deb.debian.org/debian trixie/main amd64 libmp3lame0 amd64 3.100-6+b3 [363 kB] +Get:42 http://deb.debian.org/debian trixie/main amd64 libopenjp2-7 amd64 2.5.3-2.1deb13u1 [205 kB] +Get:43 http://deb.debian.org/debian trixie/main amd64 libopus0 amd64 1.5.2-2 [2852 kB] +Get:44 http://deb.debian.org/debian trixie/main amd64 librav1e0.7 amd64 0.7.1-9+b2 [946 kB] +Get:45 http://deb.debian.org/debian trixie/main amd64 libcairo-gobject2 amd64 1.18.4-1+b1 [130 kB] +Get:46 http://deb.debian.org/debian trixie/main amd64 libgdk-pixbuf2.0-common all 2.42.12+dfsg-4 [311 kB] +Get:47 http://deb.debian.org/debian trixie/main amd64 libxml2 amd64 2.12.7+dfsg+really2.9.14-2.1+deb13u2 [698 kB] +Get:48 http://deb.debian.org/debian trixie/main amd64 shared-mime-info amd64 2.4-5+b2 [760 kB] +Get:49 http://deb.debian.org/debian trixie/main amd64 libjpeg62-turbo amd64 1:2.1.5-4 [168 kB] +Get:50 http://deb.debian.org/debian trixie/main amd64 libdeflate0 amd64 1.23-2 [47.3 kB] +Get:51 http://deb.debian.org/debian trixie/main amd64 libjbig0 amd64 2.1-6.1+b2 [32.1 kB] +Get:52 http://deb.debian.org/debian trixie/main amd64 liblerc4 amd64 4.0.0+ds-5 [183 kB] +Get:53 http://deb.debian.org/debian trixie/main amd64 libsharpyuv0 amd64 1.5.0-0.1 [116 kB] +Get:54 http://deb.debian.org/debian trixie/main amd64 libwebp7 amd64 1.5.0-0.1 [318 kB] +Get:55 http://deb.debian.org/debian trixie/main amd64 libtiff6 amd64 4.7.0-3+deb13u1 [346 kB] +Get:56 http://deb.debian.org/debian trixie/main amd64 libgdk-pixbuf-2.0-0 amd64 2.42.12+dfsg-4 [141 kB] +Get:57 http://deb.debian.org/debian trixie/main amd64 fontconfig amd64 2.15.0-2.3 [463 kB] +Get:58 http://deb.debian.org/debian trixie/main amd64 libfribidi0 amd64 1.0.16-1 [26.5 kB] +Get:59 http://deb.debian.org/debian trixie/main amd64 libgraphite2-3 amd64 1.3.14-2+b1 [75.4 kB] +Get:60 http://deb.debian.org/debian trixie/main amd64 libharfbuzz0b amd64 10.2.0-1+b1 [479 kB] +Get:61 http://deb.debian.org/debian trixie/main amd64 libthai-data all 0.1.29-2 [168 kB] +Get:62 http://deb.debian.org/debian trixie/main amd64 libdatrie1 amd64 0.2.13-3+b1 [38.1 kB] +Get:63 http://deb.debian.org/debian trixie/main amd64 libthai0 amd64 0.1.29-2+b1 [49.4 kB] +Get:64 http://deb.debian.org/debian trixie/main amd64 libpango-1.0-0 amd64 1.56.3-1 [226 kB] +Get:65 http://deb.debian.org/debian trixie/main amd64 libpangoft2-1.0-0 amd64 1.56.3-1 [55.6 kB] +Get:66 http://deb.debian.org/debian trixie/main amd64 libpangocairo-1.0-0 amd64 1.56.3-1 [35.7 kB] +Get:67 http://deb.debian.org/debian trixie/main amd64 librsvg2-2 amd64 2.60.0+dfsg-1 [1789 kB] +Get:68 http://deb.debian.org/debian trixie/main amd64 libshine3 amd64 3.1.1-2+b2 [23.1 kB] +Get:69 http://deb.debian.org/debian trixie/main amd64 libsnappy1v5 amd64 1.2.2-1 [29.3 kB] +Get:70 http://deb.debian.org/debian trixie/main amd64 libspeex1 amd64 1.2.1-3 [56.8 kB] +Get:71 http://deb.debian.org/debian trixie/main amd64 libsvtav1enc2 amd64 2.3.0+dfsg-1 [2489 kB] +Get:72 http://deb.debian.org/debian trixie/main amd64 libgomp1 amd64 14.2.0-19 [137 kB] +Get:73 http://deb.debian.org/debian trixie/main amd64 libsoxr0 amd64 0.1.3-4+b2 [81.0 kB] +Get:74 http://deb.debian.org/debian trixie/main amd64 libswresample5 amd64 7:7.1.3-0+deb13u1 [101 kB] +Get:75 http://deb.debian.org/debian trixie/main amd64 libtheoradec1 amd64 1.2.0alpha1+dfsg-6 [58.4 kB] +Get:76 http://deb.debian.org/debian trixie/main amd64 libogg0 amd64 1.3.5-3+b2 [23.8 kB] +Get:77 http://deb.debian.org/debian trixie/main amd64 libtheoraenc1 amd64 1.2.0alpha1+dfsg-6 [108 kB] +Get:78 http://deb.debian.org/debian trixie/main amd64 libtwolame0 amd64 0.4.0-2+b2 [51.3 kB] +Get:79 http://deb.debian.org/debian trixie/main amd64 libvorbis0a amd64 1.3.7-3 [90.0 kB] +Get:80 http://deb.debian.org/debian trixie/main amd64 libvorbisenc2 amd64 1.3.7-3 [75.4 kB] +Get:81 http://deb.debian.org/debian-security trixie-security/main amd64 libvpx9 amd64 1.15.0-2.1+deb13u1 [1115 kB] +Get:82 http://deb.debian.org/debian trixie/main amd64 libwebpmux3 amd64 1.5.0-0.1 [126 kB] +Get:83 http://deb.debian.org/debian trixie/main amd64 libx264-164 amd64 2:0.164.3108+git31e19f9-2+b1 [558 kB] +Get:84 http://deb.debian.org/debian trixie/main amd64 libnuma1 amd64 2.0.19-1 [22.2 kB] +Get:85 http://deb.debian.org/debian trixie/main amd64 libx265-215 amd64 4.1-2 [1237 kB] +Get:86 http://deb.debian.org/debian trixie/main amd64 libxvidcore4 amd64 2:1.3.7-1+b2 [252 kB] +Get:87 http://deb.debian.org/debian trixie/main amd64 libzvbi-common all 0.2.44-1 [71.4 kB] +Get:88 http://deb.debian.org/debian trixie/main amd64 libzvbi0t64 amd64 0.2.44-1 [278 kB] +Get:89 http://deb.debian.org/debian trixie/main amd64 libavcodec61 amd64 7:7.1.3-0+deb13u1 [5808 kB] +Get:90 http://deb.debian.org/debian trixie/main amd64 libasound2-data all 1.2.14-1 [21.1 kB] +Get:91 http://deb.debian.org/debian trixie/main amd64 libasound2t64 amd64 1.2.14-1 [381 kB] +Get:92 http://deb.debian.org/debian trixie/main amd64 libraw1394-11 amd64 2.1.2-2+b2 [38.8 kB] +Get:93 http://deb.debian.org/debian trixie/main amd64 libavc1394-0 amd64 0.5.4-5+b2 [18.2 kB] +Get:94 http://deb.debian.org/debian trixie/main amd64 libunibreak6 amd64 6.1-3 [21.9 kB] +Get:95 http://deb.debian.org/debian trixie/main amd64 libass9 amd64 1:0.17.3-1+b1 [114 kB] +Get:96 http://deb.debian.org/debian trixie/main amd64 libudfread0 amd64 1.1.2-1+b2 [17.7 kB] +Get:97 http://deb.debian.org/debian trixie/main amd64 libbluray2 amd64 1:1.3.4-1+b2 [138 kB] +Get:98 http://deb.debian.org/debian trixie/main amd64 libchromaprint1 amd64 1.5.1-7 [42.9 kB] +Get:99 http://deb.debian.org/debian trixie/main amd64 libdvdread8t64 amd64 6.1.3-2 [86.2 kB] +Get:100 http://deb.debian.org/debian trixie/main amd64 libdvdnav4 amd64 6.1.1-3+b1 [44.5 kB] +Get:101 http://deb.debian.org/debian trixie/main amd64 libgme0 amd64 0.6.3-7+b2 [131 kB] +Get:102 http://deb.debian.org/debian trixie/main amd64 libunistring5 amd64 1.3-2 [477 kB] +Get:103 http://deb.debian.org/debian trixie/main amd64 libidn2-0 amd64 2.3.8-2 [109 kB] +Get:104 http://deb.debian.org/debian trixie/main amd64 libp11-kit0 amd64 0.25.5-3 [425 kB] +Get:105 http://deb.debian.org/debian trixie/main amd64 libtasn1-6 amd64 4.20.0-2 [49.9 kB] +Get:106 http://deb.debian.org/debian-security trixie-security/main amd64 libgnutls30t64 amd64 3.8.9-3+deb13u2 [1468 kB] +Get:107 http://deb.debian.org/debian trixie/main amd64 libmpg123-0t64 amd64 1.32.10-1 [149 kB] +Get:108 http://deb.debian.org/debian trixie/main amd64 libvorbisfile3 amd64 1.3.7-3 [20.9 kB] +Get:109 http://deb.debian.org/debian trixie/main amd64 libopenmpt0t64 amd64 0.7.13-1+b1 [855 kB] +Get:110 http://deb.debian.org/debian trixie/main amd64 librabbitmq4 amd64 0.15.0-1 [41.8 kB] +Get:111 http://deb.debian.org/debian trixie/main amd64 libcjson1 amd64 1.7.18-3.1+deb13u1 [29.8 kB] +Get:112 http://deb.debian.org/debian trixie/main amd64 libmbedcrypto16 amd64 3.6.5-0.1deb13u1 [361 kB] +Get:113 http://deb.debian.org/debian trixie/main amd64 librist4 amd64 0.2.11+dfsg-1 [72.1 kB] +Get:114 http://deb.debian.org/debian trixie/main amd64 libsrt1.5-gnutls amd64 1.5.4-1 [345 kB] +Get:115 http://deb.debian.org/debian trixie/main amd64 libkrb5support0 amd64 1.21.3-5 [33.0 kB] +Get:116 http://deb.debian.org/debian trixie/main amd64 libcom-err2 amd64 1.47.2-3+b7 [25.0 kB] +Get:117 http://deb.debian.org/debian trixie/main amd64 libk5crypto3 amd64 1.21.3-5 [81.5 kB] +Get:118 http://deb.debian.org/debian trixie/main amd64 libkeyutils1 amd64 1.6.3-6 [9456 B] +Get:119 http://deb.debian.org/debian trixie/main amd64 libkrb5-3 amd64 1.21.3-5 [326 kB] +Get:120 http://deb.debian.org/debian trixie/main amd64 libgssapi-krb5-2 amd64 1.21.3-5 [138 kB] +Get:121 http://deb.debian.org/debian trixie/main amd64 libssh-4 amd64 0.11.2-1+deb13u1 [209 kB] +Get:122 http://deb.debian.org/debian trixie/main amd64 libnorm1t64 amd64 1.5.9+dfsg-3.1+b2 [221 kB] +Get:123 http://deb.debian.org/debian trixie/main amd64 libpgm-5.3-0t64 amd64 5.3.128dfsg-2.1+b1 [162 kB] +Get:124 http://deb.debian.org/debian-security trixie-security/main amd64 libsodium23 amd64 1.0.18-1+deb13u1 [165 kB] +Get:125 http://deb.debian.org/debian trixie/main amd64 libzmq5 amd64 4.3.5-1+b3 [283 kB] +Get:126 http://deb.debian.org/debian trixie/main amd64 libavformat61 amd64 7:7.1.3-0+deb13u1 [1193 kB] +Get:127 http://deb.debian.org/debian trixie/main amd64 libbs2b0 amd64 3.1.0+dfsg-8+b1 [12.5 kB] +Get:128 http://deb.debian.org/debian trixie/main amd64 libflite1 amd64 2.2-7 [12.8 MB] +Get:129 http://deb.debian.org/debian trixie/main amd64 libserd-0-0 amd64 0.32.4-1 [47.0 kB] +Get:130 http://deb.debian.org/debian trixie/main amd64 libzix-0-0 amd64 0.6.2-1 [23.1 kB] +Get:131 http://deb.debian.org/debian trixie/main amd64 libsord-0-0 amd64 0.16.18-1 [18.0 kB] +Get:132 http://deb.debian.org/debian trixie/main amd64 libsratom-0-0 amd64 0.6.18-1 [17.7 kB] +Get:133 http://deb.debian.org/debian trixie/main amd64 liblilv-0-0 amd64 0.24.26-1 [43.5 kB] +Get:134 http://deb.debian.org/debian trixie/main amd64 libmysofa1 amd64 1.3.3+dfsg-1 [1158 kB] +Get:135 http://deb.debian.org/debian trixie/main amd64 libvulkan1 amd64 1.4.309.0-1 [130 kB] +Get:136 http://deb.debian.org/debian trixie/main amd64 libplacebo349 amd64 7.349.0-3 [2542 kB] +Get:137 http://deb.debian.org/debian trixie/main amd64 libblas3 amd64 3.12.1-6 [160 kB] +Get:138 http://deb.debian.org/debian trixie/main amd64 libgfortran5 amd64 14.2.0-19 [836 kB] +Get:139 http://deb.debian.org/debian trixie/main amd64 liblapack3 amd64 3.12.1-6 [2447 kB] +Get:140 http://deb.debian.org/debian trixie/main amd64 libasyncns0 amd64 0.8-6+b5 [12.0 kB] +Get:141 http://deb.debian.org/debian trixie/main amd64 libdbus-1-3 amd64 1.16.2-2 [178 kB] +Get:142 http://deb.debian.org/debian trixie/main amd64 libflac14 amd64 1.5.0+ds-2 [210 kB] +Get:143 http://deb.debian.org/debian trixie/main amd64 libsndfile1 amd64 1.2.2-2+b1 [199 kB] +Get:144 http://deb.debian.org/debian trixie/main amd64 libpulse0 amd64 17.0+dfsg1-2+b1 [276 kB] +Get:145 http://deb.debian.org/debian trixie/main amd64 libsphinxbase3t64 amd64 0.8+5prealpha+1-21+b1 [121 kB] +Get:146 http://deb.debian.org/debian trixie/main amd64 libpocketsphinx3 amd64 0.8+5prealpha+1-15+b4 [126 kB] +Get:147 http://deb.debian.org/debian trixie/main amd64 libpostproc58 amd64 7:7.1.3-0+deb13u1 [88.3 kB] +Get:148 http://deb.debian.org/debian trixie/main amd64 libfftw3-double3 amd64 3.3.10-2+b1 [781 kB] +Get:149 http://deb.debian.org/debian trixie/main amd64 libsamplerate0 amd64 0.2.2-4+b2 [950 kB] +Get:150 http://deb.debian.org/debian trixie/main amd64 librubberband2 amd64 3.3.0+dfsg-2+b3 [142 kB] +Get:151 http://deb.debian.org/debian trixie/main amd64 libswscale8 amd64 7:7.1.3-0+deb13u1 [233 kB] +Get:152 http://deb.debian.org/debian trixie/main amd64 libvidstab1.1 amd64 1.1.0-2+b2 [38.9 kB] +Get:153 http://deb.debian.org/debian trixie/main amd64 libzimg2 amd64 3.0.5+ds1-1+b2 [244 kB] +Get:154 http://deb.debian.org/debian trixie/main amd64 libavfilter10 amd64 7:7.1.3-0+deb13u1 [4109 kB] +Get:155 http://deb.debian.org/debian trixie/main amd64 libslang2 amd64 2.3.3-5+b2 [549 kB] +Get:156 http://deb.debian.org/debian trixie/main amd64 libcaca0 amd64 0.99.beta20-5 [202 kB] +Get:157 http://deb.debian.org/debian trixie/main amd64 libcdio19t64 amd64 2.2.0-4 [61.3 kB] +Get:158 http://deb.debian.org/debian trixie/main amd64 libcdio-cdda2t64 amd64 10.2+2.0.2-1+b1 [17.7 kB] +Get:159 http://deb.debian.org/debian trixie/main amd64 libcdio-paranoia2t64 amd64 10.2+2.0.2-1+b1 [17.4 kB] +Get:160 http://deb.debian.org/debian trixie/main amd64 libusb-1.0-0 amd64 2:1.0.28-1 [59.6 kB] +Get:161 http://deb.debian.org/debian trixie/main amd64 libdc1394-25 amd64 2.2.6-5 [111 kB] +Get:162 http://deb.debian.org/debian trixie/main amd64 libglvnd0 amd64 1.7.0-1+b2 [52.0 kB] +Get:163 http://deb.debian.org/debian trixie/main amd64 libxcb-glx0 amd64 1.17.0-2+b1 [122 kB] +Get:164 http://deb.debian.org/debian trixie/main amd64 libxcb-present0 amd64 1.17.0-2+b1 [106 kB] +Get:165 http://deb.debian.org/debian trixie/main amd64 libxcb-xfixes0 amd64 1.17.0-2+b1 [109 kB] +Get:166 http://deb.debian.org/debian trixie/main amd64 libxxf86vm1 amd64 1:1.1.4-1+b4 [19.3 kB] +Get:167 http://deb.debian.org/debian trixie/main amd64 libdrm-amdgpu1 amd64 2.4.124-2 [22.6 kB] +Get:168 http://deb.debian.org/debian trixie/main amd64 libpciaccess0 amd64 0.17-3+b3 [51.9 kB] +Get:169 http://deb.debian.org/debian trixie/main amd64 libdrm-intel1 amd64 2.4.124-2 [64.1 kB] +Get:170 http://deb.debian.org/debian trixie/main amd64 libelf1t64 amd64 0.192-4 [189 kB] +Get:171 http://deb.debian.org/debian trixie/main amd64 libedit2 amd64 3.1-20250104-1 [93.8 kB] +Get:172 http://deb.debian.org/debian trixie/main amd64 libz3-4 amd64 4.13.3-1 [8560 kB] +Get:173 http://deb.debian.org/debian trixie/main amd64 libllvm19 amd64 1:19.1.7-3+b1 [26.0 MB] +Get:174 http://deb.debian.org/debian trixie/main amd64 libsensors-config all 1:3.6.2-2 [16.2 kB] +Get:175 http://deb.debian.org/debian trixie/main amd64 libsensors5 amd64 1:3.6.2-2 [37.5 kB] +Get:176 http://deb.debian.org/debian trixie/main amd64 libxcb-randr0 amd64 1.17.0-2+b1 [117 kB] +Get:177 http://deb.debian.org/debian trixie/main amd64 libxcb-sync1 amd64 1.17.0-2+b1 [109 kB] +Get:178 http://deb.debian.org/debian trixie/main amd64 libxshmfence1 amd64 1.3.3-1 [10.9 kB] +Get:179 http://deb.debian.org/debian trixie/main amd64 mesa-libgallium amd64 25.0.7-2 [9629 kB] +Get:180 http://deb.debian.org/debian trixie/main amd64 libwayland-server0 amd64 1.23.1-3 [34.4 kB] +Get:181 http://deb.debian.org/debian trixie/main amd64 libgbm1 amd64 25.0.7-2 [44.4 kB] +Get:182 http://deb.debian.org/debian trixie/main amd64 libgl1-mesa-dri amd64 25.0.7-2 [46.1 kB] +Get:183 http://deb.debian.org/debian trixie/main amd64 libglx-mesa0 amd64 25.0.7-2 [143 kB] +Get:184 http://deb.debian.org/debian trixie/main amd64 libglx0 amd64 1.7.0-1+b2 [34.9 kB] +Get:185 http://deb.debian.org/debian trixie/main amd64 libgl1 amd64 1.7.0-1+b2 [89.5 kB] +Get:186 http://deb.debian.org/debian trixie/main amd64 libiec61883-0 amd64 1.2.0-7 [30.6 kB] +Get:187 http://deb.debian.org/debian trixie/main amd64 libjack-jackd2-0 amd64 1.9.22dfsg-4 [287 kB] +Get:188 http://deb.debian.org/debian trixie/main amd64 libopenal-data all 1:1.24.2-1 [168 kB] +Get:189 http://deb.debian.org/debian trixie/main amd64 libopenal1 amd64 1:1.24.2-1 [637 kB] +Get:190 http://deb.debian.org/debian trixie/main amd64 libwayland-client0 amd64 1.23.1-3 [26.8 kB] +Get:191 http://deb.debian.org/debian trixie/main amd64 libdecor-0-0 amd64 0.2.2-2 [15.5 kB] +Get:192 http://deb.debian.org/debian trixie/main amd64 libwayland-cursor0 amd64 1.23.1-3 [11.9 kB] +Get:193 http://deb.debian.org/debian trixie/main amd64 libwayland-egl1 amd64 1.23.1-3 [5860 B] +Get:194 http://deb.debian.org/debian trixie/main amd64 libxcursor1 amd64 1:1.2.3-1 [39.7 kB] +Get:195 http://deb.debian.org/debian trixie/main amd64 libxi6 amd64 2:1.8.2-1 [78.9 kB] +Get:196 http://deb.debian.org/debian trixie/main amd64 xkb-data all 2.42-1 [790 kB] +Get:197 http://deb.debian.org/debian trixie/main amd64 libxkbcommon0 amd64 1.7.0-2 [113 kB] +Get:198 http://deb.debian.org/debian trixie/main amd64 libxrandr2 amd64 2:1.5.4-1+b3 [36.3 kB] +Get:199 http://deb.debian.org/debian trixie/main amd64 x11-common all 1:7.7+24+deb13u1 [217 kB] +Get:200 http://deb.debian.org/debian trixie/main amd64 libxss1 amd64 1:1.2.3-1+b3 [17.0 kB] +Get:201 http://deb.debian.org/debian trixie/main amd64 libsdl2-2.0-0 amd64 2.32.4+dfsg-1 [669 kB] +Get:202 http://deb.debian.org/debian trixie/main amd64 libxcb-shape0 amd64 1.17.0-2+b1 [106 kB] +Get:203 http://deb.debian.org/debian trixie/main amd64 libxv1 amd64 2:1.0.11-1.1+b3 [23.4 kB] +Get:204 http://deb.debian.org/debian trixie/main amd64 libavdevice61 amd64 7:7.1.3-0+deb13u1 [119 kB] +Get:205 http://deb.debian.org/debian trixie/main amd64 ffmpeg amd64 7:7.1.3-0+deb13u1 [1995 kB] +debconf: unable to initialize frontend: Dialog +debconf: (TERM is not set, so the dialog frontend is not usable.) +debconf: falling back to frontend: Readline +debconf: unable to initialize frontend: Readline +debconf: (Can't locate Term/ReadLine.pm in @INC (you may need to install the Term::ReadLine module) (@INC entries checked: /etc/perl /usr/local/lib/x86_64-linux-gnu/perl/5.40.1 /usr/local/share/perl/5.40.1 /usr/lib/x86_64-linux-gnu/perl5/5.40 /usr/share/perl5 /usr/lib/x86_64-linux-gnu/perl-base /usr/lib/x86_64-linux-gnu/perl/5.40 /usr/share/perl/5.40 /usr/local/lib/site_perl) at /usr/share/perl5/Debconf/FrontEnd/Readline.pm line 8, line 205.) +debconf: falling back to frontend: Teletype +debconf: unable to initialize frontend: Teletype +debconf: (This frontend requires a controlling tty.) +debconf: falling back to frontend: Noninteractive +Preconfiguring packages ... +Fetched 133 MB in 1s (103 MB/s) +Selecting previously unselected package libexpat1:amd64. +(Reading database ... 5645 files and directories currently installed.) +Preparing to unpack .../000-libexpat1_2.7.1-2_amd64.deb ... +Unpacking libexpat1:amd64 (2.7.1-2) ... +Selecting previously unselected package libaom3:amd64. +Preparing to unpack .../001-libaom3_3.12.1-1_amd64.deb ... +Unpacking libaom3:amd64 (3.12.1-1) ... +Selecting previously unselected package libdrm-common. +Preparing to unpack .../002-libdrm-common_2.4.124-2_all.deb ... +Unpacking libdrm-common (2.4.124-2) ... +Selecting previously unselected package libdrm2:amd64. +Preparing to unpack .../003-libdrm2_2.4.124-2_amd64.deb ... +Unpacking libdrm2:amd64 (2.4.124-2) ... +Selecting previously unselected package libva2:amd64. +Preparing to unpack .../004-libva2_2.22.0-3_amd64.deb ... +Unpacking libva2:amd64 (2.22.0-3) ... +Selecting previously unselected package libva-drm2:amd64. +Preparing to unpack .../005-libva-drm2_2.22.0-3_amd64.deb ... +Unpacking libva-drm2:amd64 (2.22.0-3) ... +Selecting previously unselected package libxau6:amd64. +Preparing to unpack .../006-libxau6_1%3a1.0.11-1_amd64.deb ... +Unpacking libxau6:amd64 (1:1.0.11-1) ... +Selecting previously unselected package libxdmcp6:amd64. +Preparing to unpack .../007-libxdmcp6_1%3a1.1.5-1_amd64.deb ... +Unpacking libxdmcp6:amd64 (1:1.1.5-1) ... +Selecting previously unselected package libxcb1:amd64. +Preparing to unpack .../008-libxcb1_1.17.0-2+b1_amd64.deb ... +Unpacking libxcb1:amd64 (1.17.0-2+b1) ... +Selecting previously unselected package libx11-data. +Preparing to unpack .../009-libx11-data_2%3a1.8.12-1_all.deb ... +Unpacking libx11-data (2:1.8.12-1) ... +Selecting previously unselected package libx11-6:amd64. +Preparing to unpack .../010-libx11-6_2%3a1.8.12-1_amd64.deb ... +Unpacking libx11-6:amd64 (2:1.8.12-1) ... +Selecting previously unselected package libx11-xcb1:amd64. +Preparing to unpack .../011-libx11-xcb1_2%3a1.8.12-1_amd64.deb ... +Unpacking libx11-xcb1:amd64 (2:1.8.12-1) ... +Selecting previously unselected package libxcb-dri3-0:amd64. +Preparing to unpack .../012-libxcb-dri3-0_1.17.0-2+b1_amd64.deb ... +Unpacking libxcb-dri3-0:amd64 (1.17.0-2+b1) ... +Selecting previously unselected package libxext6:amd64. +Preparing to unpack .../013-libxext6_2%3a1.3.4-1+b3_amd64.deb ... +Unpacking libxext6:amd64 (2:1.3.4-1+b3) ... +Selecting previously unselected package libxfixes3:amd64. +Preparing to unpack .../014-libxfixes3_1%3a6.0.0-2+b4_amd64.deb ... +Unpacking libxfixes3:amd64 (1:6.0.0-2+b4) ... +Selecting previously unselected package libva-x11-2:amd64. +Preparing to unpack .../015-libva-x11-2_2.22.0-3_amd64.deb ... +Unpacking libva-x11-2:amd64 (2.22.0-3) ... +Selecting previously unselected package libvdpau1:amd64. +Preparing to unpack .../016-libvdpau1_1.5-3+b1_amd64.deb ... +Unpacking libvdpau1:amd64 (1.5-3+b1) ... +Selecting previously unselected package libvpl2. +Preparing to unpack .../017-libvpl2_1%3a2.14.0-1+b1_amd64.deb ... +Unpacking libvpl2 (1:2.14.0-1+b1) ... +Selecting previously unselected package ocl-icd-libopencl1:amd64. +Preparing to unpack .../018-ocl-icd-libopencl1_2.3.3-1_amd64.deb ... +Unpacking ocl-icd-libopencl1:amd64 (2.3.3-1) ... +Selecting previously unselected package libavutil59:amd64. +Preparing to unpack .../019-libavutil59_7%3a7.1.3-0+deb13u1_amd64.deb ... +Unpacking libavutil59:amd64 (7:7.1.3-0+deb13u1) ... +Selecting previously unselected package libbrotli1:amd64. +Preparing to unpack .../020-libbrotli1_1.1.0-2+b7_amd64.deb ... +Unpacking libbrotli1:amd64 (1.1.0-2+b7) ... +Selecting previously unselected package libpng16-16t64:amd64. +Preparing to unpack .../021-libpng16-16t64_1.6.48-1+deb13u3_amd64.deb ... +Unpacking libpng16-16t64:amd64 (1.6.48-1+deb13u3) ... +Selecting previously unselected package libfreetype6:amd64. +Preparing to unpack .../022-libfreetype6_2.13.3+dfsg-1_amd64.deb ... +Unpacking libfreetype6:amd64 (2.13.3+dfsg-1) ... +Selecting previously unselected package fonts-dejavu-mono. +Preparing to unpack .../023-fonts-dejavu-mono_2.37-8_all.deb ... +Unpacking fonts-dejavu-mono (2.37-8) ... +Selecting previously unselected package fonts-dejavu-core. +Preparing to unpack .../024-fonts-dejavu-core_2.37-8_all.deb ... +Unpacking fonts-dejavu-core (2.37-8) ... +Selecting previously unselected package fontconfig-config. +Preparing to unpack .../025-fontconfig-config_2.15.0-2.3_amd64.deb ... +Unpacking fontconfig-config (2.15.0-2.3) ... +Selecting previously unselected package libfontconfig1:amd64. +Preparing to unpack .../026-libfontconfig1_2.15.0-2.3_amd64.deb ... +Unpacking libfontconfig1:amd64 (2.15.0-2.3) ... +Selecting previously unselected package libpixman-1-0:amd64. +Preparing to unpack .../027-libpixman-1-0_0.44.0-3_amd64.deb ... +Unpacking libpixman-1-0:amd64 (0.44.0-3) ... +Selecting previously unselected package libxcb-render0:amd64. +Preparing to unpack .../028-libxcb-render0_1.17.0-2+b1_amd64.deb ... +Unpacking libxcb-render0:amd64 (1.17.0-2+b1) ... +Selecting previously unselected package libxcb-shm0:amd64. +Preparing to unpack .../029-libxcb-shm0_1.17.0-2+b1_amd64.deb ... +Unpacking libxcb-shm0:amd64 (1.17.0-2+b1) ... +Selecting previously unselected package libxrender1:amd64. +Preparing to unpack .../030-libxrender1_1%3a0.9.12-1_amd64.deb ... +Unpacking libxrender1:amd64 (1:0.9.12-1) ... +Selecting previously unselected package libcairo2:amd64. +Preparing to unpack .../031-libcairo2_1.18.4-1+b1_amd64.deb ... +Unpacking libcairo2:amd64 (1.18.4-1+b1) ... +Selecting previously unselected package libcodec2-1.2:amd64. +Preparing to unpack .../032-libcodec2-1.2_1.2.0-3_amd64.deb ... +Unpacking libcodec2-1.2:amd64 (1.2.0-3) ... +Selecting previously unselected package libdav1d7:amd64. +Preparing to unpack .../033-libdav1d7_1.5.1-1_amd64.deb ... +Unpacking libdav1d7:amd64 (1.5.1-1) ... +Selecting previously unselected package libatomic1:amd64. +Preparing to unpack .../034-libatomic1_14.2.0-19_amd64.deb ... +Unpacking libatomic1:amd64 (14.2.0-19) ... +Selecting previously unselected package libglib2.0-0t64:amd64. +Preparing to unpack .../035-libglib2.0-0t64_2.84.4-3deb13u2_amd64.deb ... +Unpacking libglib2.0-0t64:amd64 (2.84.4-3deb13u2) ... +Selecting previously unselected package libgsm1:amd64. +Preparing to unpack .../036-libgsm1_1.0.22-1+b2_amd64.deb ... +Unpacking libgsm1:amd64 (1.0.22-1+b2) ... +Selecting previously unselected package libhwy1t64:amd64. +Preparing to unpack .../037-libhwy1t64_1.2.0-2+b2_amd64.deb ... +Unpacking libhwy1t64:amd64 (1.2.0-2+b2) ... +Selecting previously unselected package liblcms2-2:amd64. +Preparing to unpack .../038-liblcms2-2_2.16-2_amd64.deb ... +Unpacking liblcms2-2:amd64 (2.16-2) ... +Selecting previously unselected package libjxl0.11:amd64. +Preparing to unpack .../039-libjxl0.11_0.11.1-4_amd64.deb ... +Unpacking libjxl0.11:amd64 (0.11.1-4) ... +Selecting previously unselected package libmp3lame0:amd64. +Preparing to unpack .../040-libmp3lame0_3.100-6+b3_amd64.deb ... +Unpacking libmp3lame0:amd64 (3.100-6+b3) ... +Selecting previously unselected package libopenjp2-7:amd64. +Preparing to unpack .../041-libopenjp2-7_2.5.3-2.1deb13u1_amd64.deb ... +Unpacking libopenjp2-7:amd64 (2.5.3-2.1deb13u1) ... +Selecting previously unselected package libopus0:amd64. +Preparing to unpack .../042-libopus0_1.5.2-2_amd64.deb ... +Unpacking libopus0:amd64 (1.5.2-2) ... +Selecting previously unselected package librav1e0.7:amd64. +Preparing to unpack .../043-librav1e0.7_0.7.1-9+b2_amd64.deb ... +Unpacking librav1e0.7:amd64 (0.7.1-9+b2) ... +Selecting previously unselected package libcairo-gobject2:amd64. +Preparing to unpack .../044-libcairo-gobject2_1.18.4-1+b1_amd64.deb ... +Unpacking libcairo-gobject2:amd64 (1.18.4-1+b1) ... +Selecting previously unselected package libgdk-pixbuf2.0-common. +Preparing to unpack .../045-libgdk-pixbuf2.0-common_2.42.12+dfsg-4_all.deb ... +Unpacking libgdk-pixbuf2.0-common (2.42.12+dfsg-4) ... +Selecting previously unselected package libxml2:amd64. +Preparing to unpack .../046-libxml2_2.12.7+dfsg+really2.9.14-2.1+deb13u2_amd64.deb ... +Unpacking libxml2:amd64 (2.12.7+dfsg+really2.9.14-2.1+deb13u2) ... +Selecting previously unselected package shared-mime-info. +Preparing to unpack .../047-shared-mime-info_2.4-5+b2_amd64.deb ... +Unpacking shared-mime-info (2.4-5+b2) ... +Selecting previously unselected package libjpeg62-turbo:amd64. +Preparing to unpack .../048-libjpeg62-turbo_1%3a2.1.5-4_amd64.deb ... +Unpacking libjpeg62-turbo:amd64 (1:2.1.5-4) ... +Selecting previously unselected package libdeflate0:amd64. +Preparing to unpack .../049-libdeflate0_1.23-2_amd64.deb ... +Unpacking libdeflate0:amd64 (1.23-2) ... +Selecting previously unselected package libjbig0:amd64. +Preparing to unpack .../050-libjbig0_2.1-6.1+b2_amd64.deb ... +Unpacking libjbig0:amd64 (2.1-6.1+b2) ... +Selecting previously unselected package liblerc4:amd64. +Preparing to unpack .../051-liblerc4_4.0.0+ds-5_amd64.deb ... +Unpacking liblerc4:amd64 (4.0.0+ds-5) ... +Selecting previously unselected package libsharpyuv0:amd64. +Preparing to unpack .../052-libsharpyuv0_1.5.0-0.1_amd64.deb ... +Unpacking libsharpyuv0:amd64 (1.5.0-0.1) ... +Selecting previously unselected package libwebp7:amd64. +Preparing to unpack .../053-libwebp7_1.5.0-0.1_amd64.deb ... +Unpacking libwebp7:amd64 (1.5.0-0.1) ... +Selecting previously unselected package libtiff6:amd64. +Preparing to unpack .../054-libtiff6_4.7.0-3+deb13u1_amd64.deb ... +Unpacking libtiff6:amd64 (4.7.0-3+deb13u1) ... +Selecting previously unselected package libgdk-pixbuf-2.0-0:amd64. +Preparing to unpack .../055-libgdk-pixbuf-2.0-0_2.42.12+dfsg-4_amd64.deb ... +Unpacking libgdk-pixbuf-2.0-0:amd64 (2.42.12+dfsg-4) ... +Selecting previously unselected package fontconfig. +Preparing to unpack .../056-fontconfig_2.15.0-2.3_amd64.deb ... +Unpacking fontconfig (2.15.0-2.3) ... +Selecting previously unselected package libfribidi0:amd64. +Preparing to unpack .../057-libfribidi0_1.0.16-1_amd64.deb ... +Unpacking libfribidi0:amd64 (1.0.16-1) ... +Selecting previously unselected package libgraphite2-3:amd64. +Preparing to unpack .../058-libgraphite2-3_1.3.14-2+b1_amd64.deb ... +Unpacking libgraphite2-3:amd64 (1.3.14-2+b1) ... +Selecting previously unselected package libharfbuzz0b:amd64. +Preparing to unpack .../059-libharfbuzz0b_10.2.0-1+b1_amd64.deb ... +Unpacking libharfbuzz0b:amd64 (10.2.0-1+b1) ... +Selecting previously unselected package libthai-data. +Preparing to unpack .../060-libthai-data_0.1.29-2_all.deb ... +Unpacking libthai-data (0.1.29-2) ... +Selecting previously unselected package libdatrie1:amd64. +Preparing to unpack .../061-libdatrie1_0.2.13-3+b1_amd64.deb ... +Unpacking libdatrie1:amd64 (0.2.13-3+b1) ... +Selecting previously unselected package libthai0:amd64. +Preparing to unpack .../062-libthai0_0.1.29-2+b1_amd64.deb ... +Unpacking libthai0:amd64 (0.1.29-2+b1) ... +Selecting previously unselected package libpango-1.0-0:amd64. +Preparing to unpack .../063-libpango-1.0-0_1.56.3-1_amd64.deb ... +Unpacking libpango-1.0-0:amd64 (1.56.3-1) ... +Selecting previously unselected package libpangoft2-1.0-0:amd64. +Preparing to unpack .../064-libpangoft2-1.0-0_1.56.3-1_amd64.deb ... +Unpacking libpangoft2-1.0-0:amd64 (1.56.3-1) ... +Selecting previously unselected package libpangocairo-1.0-0:amd64. +Preparing to unpack .../065-libpangocairo-1.0-0_1.56.3-1_amd64.deb ... +Unpacking libpangocairo-1.0-0:amd64 (1.56.3-1) ... +Selecting previously unselected package librsvg2-2:amd64. +Preparing to unpack .../066-librsvg2-2_2.60.0+dfsg-1_amd64.deb ... +Unpacking librsvg2-2:amd64 (2.60.0+dfsg-1) ... +Selecting previously unselected package libshine3:amd64. +Preparing to unpack .../067-libshine3_3.1.1-2+b2_amd64.deb ... +Unpacking libshine3:amd64 (3.1.1-2+b2) ... +Selecting previously unselected package libsnappy1v5:amd64. +Preparing to unpack .../068-libsnappy1v5_1.2.2-1_amd64.deb ... +Unpacking libsnappy1v5:amd64 (1.2.2-1) ... +Selecting previously unselected package libspeex1:amd64. +Preparing to unpack .../069-libspeex1_1.2.1-3_amd64.deb ... +Unpacking libspeex1:amd64 (1.2.1-3) ... +Selecting previously unselected package libsvtav1enc2:amd64. +Preparing to unpack .../070-libsvtav1enc2_2.3.0+dfsg-1_amd64.deb ... +Unpacking libsvtav1enc2:amd64 (2.3.0+dfsg-1) ... +Selecting previously unselected package libgomp1:amd64. +Preparing to unpack .../071-libgomp1_14.2.0-19_amd64.deb ... +Unpacking libgomp1:amd64 (14.2.0-19) ... +Selecting previously unselected package libsoxr0:amd64. +Preparing to unpack .../072-libsoxr0_0.1.3-4+b2_amd64.deb ... +Unpacking libsoxr0:amd64 (0.1.3-4+b2) ... +Selecting previously unselected package libswresample5:amd64. +Preparing to unpack .../073-libswresample5_7%3a7.1.3-0+deb13u1_amd64.deb ... +Unpacking libswresample5:amd64 (7:7.1.3-0+deb13u1) ... +Selecting previously unselected package libtheoradec1:amd64. +Preparing to unpack .../074-libtheoradec1_1.2.0alpha1+dfsg-6_amd64.deb ... +Unpacking libtheoradec1:amd64 (1.2.0alpha1+dfsg-6) ... +Selecting previously unselected package libogg0:amd64. +Preparing to unpack .../075-libogg0_1.3.5-3+b2_amd64.deb ... +Unpacking libogg0:amd64 (1.3.5-3+b2) ... +Selecting previously unselected package libtheoraenc1:amd64. +Preparing to unpack .../076-libtheoraenc1_1.2.0alpha1+dfsg-6_amd64.deb ... +Unpacking libtheoraenc1:amd64 (1.2.0alpha1+dfsg-6) ... +Selecting previously unselected package libtwolame0:amd64. +Preparing to unpack .../077-libtwolame0_0.4.0-2+b2_amd64.deb ... +Unpacking libtwolame0:amd64 (0.4.0-2+b2) ... +Selecting previously unselected package libvorbis0a:amd64. +Preparing to unpack .../078-libvorbis0a_1.3.7-3_amd64.deb ... +Unpacking libvorbis0a:amd64 (1.3.7-3) ... +Selecting previously unselected package libvorbisenc2:amd64. +Preparing to unpack .../079-libvorbisenc2_1.3.7-3_amd64.deb ... +Unpacking libvorbisenc2:amd64 (1.3.7-3) ... +Selecting previously unselected package libvpx9:amd64. +Preparing to unpack .../080-libvpx9_1.15.0-2.1+deb13u1_amd64.deb ... +Unpacking libvpx9:amd64 (1.15.0-2.1+deb13u1) ... +Selecting previously unselected package libwebpmux3:amd64. +Preparing to unpack .../081-libwebpmux3_1.5.0-0.1_amd64.deb ... +Unpacking libwebpmux3:amd64 (1.5.0-0.1) ... +Selecting previously unselected package libx264-164:amd64. +Preparing to unpack .../082-libx264-164_2%3a0.164.3108+git31e19f9-2+b1_amd64.deb ... +Unpacking libx264-164:amd64 (2:0.164.3108+git31e19f9-2+b1) ... +Selecting previously unselected package libnuma1:amd64. +Preparing to unpack .../083-libnuma1_2.0.19-1_amd64.deb ... +Unpacking libnuma1:amd64 (2.0.19-1) ... +Selecting previously unselected package libx265-215:amd64. +Preparing to unpack .../084-libx265-215_4.1-2_amd64.deb ... +Unpacking libx265-215:amd64 (4.1-2) ... +Selecting previously unselected package libxvidcore4:amd64. +Preparing to unpack .../085-libxvidcore4_2%3a1.3.7-1+b2_amd64.deb ... +Unpacking libxvidcore4:amd64 (2:1.3.7-1+b2) ... +Selecting previously unselected package libzvbi-common. +Preparing to unpack .../086-libzvbi-common_0.2.44-1_all.deb ... +Unpacking libzvbi-common (0.2.44-1) ... +Selecting previously unselected package libzvbi0t64:amd64. +Preparing to unpack .../087-libzvbi0t64_0.2.44-1_amd64.deb ... +Unpacking libzvbi0t64:amd64 (0.2.44-1) ... +Selecting previously unselected package libavcodec61:amd64. +Preparing to unpack .../088-libavcodec61_7%3a7.1.3-0+deb13u1_amd64.deb ... +Unpacking libavcodec61:amd64 (7:7.1.3-0+deb13u1) ... +Selecting previously unselected package libasound2-data. +Preparing to unpack .../089-libasound2-data_1.2.14-1_all.deb ... +Unpacking libasound2-data (1.2.14-1) ... +Selecting previously unselected package libasound2t64:amd64. +Preparing to unpack .../090-libasound2t64_1.2.14-1_amd64.deb ... +Unpacking libasound2t64:amd64 (1.2.14-1) ... +Selecting previously unselected package libraw1394-11:amd64. +Preparing to unpack .../091-libraw1394-11_2.1.2-2+b2_amd64.deb ... +Unpacking libraw1394-11:amd64 (2.1.2-2+b2) ... +Selecting previously unselected package libavc1394-0:amd64. +Preparing to unpack .../092-libavc1394-0_0.5.4-5+b2_amd64.deb ... +Unpacking libavc1394-0:amd64 (0.5.4-5+b2) ... +Selecting previously unselected package libunibreak6:amd64. +Preparing to unpack .../093-libunibreak6_6.1-3_amd64.deb ... +Unpacking libunibreak6:amd64 (6.1-3) ... +Selecting previously unselected package libass9:amd64. +Preparing to unpack .../094-libass9_1%3a0.17.3-1+b1_amd64.deb ... +Unpacking libass9:amd64 (1:0.17.3-1+b1) ... +Selecting previously unselected package libudfread0:amd64. +Preparing to unpack .../095-libudfread0_1.1.2-1+b2_amd64.deb ... +Unpacking libudfread0:amd64 (1.1.2-1+b2) ... +Selecting previously unselected package libbluray2:amd64. +Preparing to unpack .../096-libbluray2_1%3a1.3.4-1+b2_amd64.deb ... +Unpacking libbluray2:amd64 (1:1.3.4-1+b2) ... +Selecting previously unselected package libchromaprint1:amd64. +Preparing to unpack .../097-libchromaprint1_1.5.1-7_amd64.deb ... +Unpacking libchromaprint1:amd64 (1.5.1-7) ... +Selecting previously unselected package libdvdread8t64:amd64. +Preparing to unpack .../098-libdvdread8t64_6.1.3-2_amd64.deb ... +Unpacking libdvdread8t64:amd64 (6.1.3-2) ... +Selecting previously unselected package libdvdnav4:amd64. +Preparing to unpack .../099-libdvdnav4_6.1.1-3+b1_amd64.deb ... +Unpacking libdvdnav4:amd64 (6.1.1-3+b1) ... +Selecting previously unselected package libgme0:amd64. +Preparing to unpack .../100-libgme0_0.6.3-7+b2_amd64.deb ... +Unpacking libgme0:amd64 (0.6.3-7+b2) ... +Selecting previously unselected package libunistring5:amd64. +Preparing to unpack .../101-libunistring5_1.3-2_amd64.deb ... +Unpacking libunistring5:amd64 (1.3-2) ... +Selecting previously unselected package libidn2-0:amd64. +Preparing to unpack .../102-libidn2-0_2.3.8-2_amd64.deb ... +Unpacking libidn2-0:amd64 (2.3.8-2) ... +Selecting previously unselected package libp11-kit0:amd64. +Preparing to unpack .../103-libp11-kit0_0.25.5-3_amd64.deb ... +Unpacking libp11-kit0:amd64 (0.25.5-3) ... +Selecting previously unselected package libtasn1-6:amd64. +Preparing to unpack .../104-libtasn1-6_4.20.0-2_amd64.deb ... +Unpacking libtasn1-6:amd64 (4.20.0-2) ... +Selecting previously unselected package libgnutls30t64:amd64. +Preparing to unpack .../105-libgnutls30t64_3.8.9-3+deb13u2_amd64.deb ... +Unpacking libgnutls30t64:amd64 (3.8.9-3+deb13u2) ... +Selecting previously unselected package libmpg123-0t64:amd64. +Preparing to unpack .../106-libmpg123-0t64_1.32.10-1_amd64.deb ... +Unpacking libmpg123-0t64:amd64 (1.32.10-1) ... +Selecting previously unselected package libvorbisfile3:amd64. +Preparing to unpack .../107-libvorbisfile3_1.3.7-3_amd64.deb ... +Unpacking libvorbisfile3:amd64 (1.3.7-3) ... +Selecting previously unselected package libopenmpt0t64:amd64. +Preparing to unpack .../108-libopenmpt0t64_0.7.13-1+b1_amd64.deb ... +Unpacking libopenmpt0t64:amd64 (0.7.13-1+b1) ... +Selecting previously unselected package librabbitmq4:amd64. +Preparing to unpack .../109-librabbitmq4_0.15.0-1_amd64.deb ... +Unpacking librabbitmq4:amd64 (0.15.0-1) ... +Selecting previously unselected package libcjson1:amd64. +Preparing to unpack .../110-libcjson1_1.7.18-3.1+deb13u1_amd64.deb ... +Unpacking libcjson1:amd64 (1.7.18-3.1+deb13u1) ... +Selecting previously unselected package libmbedcrypto16:amd64. +Preparing to unpack .../111-libmbedcrypto16_3.6.5-0.1deb13u1_amd64.deb ... +Unpacking libmbedcrypto16:amd64 (3.6.5-0.1deb13u1) ... +Selecting previously unselected package librist4:amd64. +Preparing to unpack .../112-librist4_0.2.11+dfsg-1_amd64.deb ... +Unpacking librist4:amd64 (0.2.11+dfsg-1) ... +Selecting previously unselected package libsrt1.5-gnutls:amd64. +Preparing to unpack .../113-libsrt1.5-gnutls_1.5.4-1_amd64.deb ... +Unpacking libsrt1.5-gnutls:amd64 (1.5.4-1) ... +Selecting previously unselected package libkrb5support0:amd64. +Preparing to unpack .../114-libkrb5support0_1.21.3-5_amd64.deb ... +Unpacking libkrb5support0:amd64 (1.21.3-5) ... +Selecting previously unselected package libcom-err2:amd64. +Preparing to unpack .../115-libcom-err2_1.47.2-3+b7_amd64.deb ... +Unpacking libcom-err2:amd64 (1.47.2-3+b7) ... +Selecting previously unselected package libk5crypto3:amd64. +Preparing to unpack .../116-libk5crypto3_1.21.3-5_amd64.deb ... +Unpacking libk5crypto3:amd64 (1.21.3-5) ... +Selecting previously unselected package libkeyutils1:amd64. +Preparing to unpack .../117-libkeyutils1_1.6.3-6_amd64.deb ... +Unpacking libkeyutils1:amd64 (1.6.3-6) ... +Selecting previously unselected package libkrb5-3:amd64. +Preparing to unpack .../118-libkrb5-3_1.21.3-5_amd64.deb ... +Unpacking libkrb5-3:amd64 (1.21.3-5) ... +Selecting previously unselected package libgssapi-krb5-2:amd64. +Preparing to unpack .../119-libgssapi-krb5-2_1.21.3-5_amd64.deb ... +Unpacking libgssapi-krb5-2:amd64 (1.21.3-5) ... +Selecting previously unselected package libssh-4:amd64. +Preparing to unpack .../120-libssh-4_0.11.2-1+deb13u1_amd64.deb ... +Unpacking libssh-4:amd64 (0.11.2-1+deb13u1) ... +Selecting previously unselected package libnorm1t64:amd64. +Preparing to unpack .../121-libnorm1t64_1.5.9+dfsg-3.1+b2_amd64.deb ... +Unpacking libnorm1t64:amd64 (1.5.9+dfsg-3.1+b2) ... +Selecting previously unselected package libpgm-5.3-0t64:amd64. +Preparing to unpack .../122-libpgm-5.3-0t64_5.3.128dfsg-2.1+b1_amd64.deb ... +Unpacking libpgm-5.3-0t64:amd64 (5.3.128dfsg-2.1+b1) ... +Selecting previously unselected package libsodium23:amd64. +Preparing to unpack .../123-libsodium23_1.0.18-1+deb13u1_amd64.deb ... +Unpacking libsodium23:amd64 (1.0.18-1+deb13u1) ... +Selecting previously unselected package libzmq5:amd64. +Preparing to unpack .../124-libzmq5_4.3.5-1+b3_amd64.deb ... +Unpacking libzmq5:amd64 (4.3.5-1+b3) ... +Selecting previously unselected package libavformat61:amd64. +Preparing to unpack .../125-libavformat61_7%3a7.1.3-0+deb13u1_amd64.deb ... +Unpacking libavformat61:amd64 (7:7.1.3-0+deb13u1) ... +Selecting previously unselected package libbs2b0:amd64. +Preparing to unpack .../126-libbs2b0_3.1.0+dfsg-8+b1_amd64.deb ... +Unpacking libbs2b0:amd64 (3.1.0+dfsg-8+b1) ... +Selecting previously unselected package libflite1:amd64. +Preparing to unpack .../127-libflite1_2.2-7_amd64.deb ... +Unpacking libflite1:amd64 (2.2-7) ... +Selecting previously unselected package libserd-0-0:amd64. +Preparing to unpack .../128-libserd-0-0_0.32.4-1_amd64.deb ... +Unpacking libserd-0-0:amd64 (0.32.4-1) ... +Selecting previously unselected package libzix-0-0:amd64. +Preparing to unpack .../129-libzix-0-0_0.6.2-1_amd64.deb ... +Unpacking libzix-0-0:amd64 (0.6.2-1) ... +Selecting previously unselected package libsord-0-0:amd64. +Preparing to unpack .../130-libsord-0-0_0.16.18-1_amd64.deb ... +Unpacking libsord-0-0:amd64 (0.16.18-1) ... +Selecting previously unselected package libsratom-0-0:amd64. +Preparing to unpack .../131-libsratom-0-0_0.6.18-1_amd64.deb ... +Unpacking libsratom-0-0:amd64 (0.6.18-1) ... +Selecting previously unselected package liblilv-0-0:amd64. +Preparing to unpack .../132-liblilv-0-0_0.24.26-1_amd64.deb ... +Unpacking liblilv-0-0:amd64 (0.24.26-1) ... +Selecting previously unselected package libmysofa1:amd64. +Preparing to unpack .../133-libmysofa1_1.3.3+dfsg-1_amd64.deb ... +Unpacking libmysofa1:amd64 (1.3.3+dfsg-1) ... +Selecting previously unselected package libvulkan1:amd64. +Preparing to unpack .../134-libvulkan1_1.4.309.0-1_amd64.deb ... +Unpacking libvulkan1:amd64 (1.4.309.0-1) ... +Selecting previously unselected package libplacebo349:amd64. +Preparing to unpack .../135-libplacebo349_7.349.0-3_amd64.deb ... +Unpacking libplacebo349:amd64 (7.349.0-3) ... +Selecting previously unselected package libblas3:amd64. +Preparing to unpack .../136-libblas3_3.12.1-6_amd64.deb ... +Unpacking libblas3:amd64 (3.12.1-6) ... +Selecting previously unselected package libgfortran5:amd64. +Preparing to unpack .../137-libgfortran5_14.2.0-19_amd64.deb ... +Unpacking libgfortran5:amd64 (14.2.0-19) ... +Selecting previously unselected package liblapack3:amd64. +Preparing to unpack .../138-liblapack3_3.12.1-6_amd64.deb ... +Unpacking liblapack3:amd64 (3.12.1-6) ... +Selecting previously unselected package libasyncns0:amd64. +Preparing to unpack .../139-libasyncns0_0.8-6+b5_amd64.deb ... +Unpacking libasyncns0:amd64 (0.8-6+b5) ... +Selecting previously unselected package libdbus-1-3:amd64. +Preparing to unpack .../140-libdbus-1-3_1.16.2-2_amd64.deb ... +Unpacking libdbus-1-3:amd64 (1.16.2-2) ... +Selecting previously unselected package libflac14:amd64. +Preparing to unpack .../141-libflac14_1.5.0+ds-2_amd64.deb ... +Unpacking libflac14:amd64 (1.5.0+ds-2) ... +Selecting previously unselected package libsndfile1:amd64. +Preparing to unpack .../142-libsndfile1_1.2.2-2+b1_amd64.deb ... +Unpacking libsndfile1:amd64 (1.2.2-2+b1) ... +Selecting previously unselected package libpulse0:amd64. +Preparing to unpack .../143-libpulse0_17.0+dfsg1-2+b1_amd64.deb ... +Unpacking libpulse0:amd64 (17.0+dfsg1-2+b1) ... +Selecting previously unselected package libsphinxbase3t64:amd64. +Preparing to unpack .../144-libsphinxbase3t64_0.8+5prealpha+1-21+b1_amd64.deb ... +Unpacking libsphinxbase3t64:amd64 (0.8+5prealpha+1-21+b1) ... +Selecting previously unselected package libpocketsphinx3:amd64. +Preparing to unpack .../145-libpocketsphinx3_0.8+5prealpha+1-15+b4_amd64.deb ... +Unpacking libpocketsphinx3:amd64 (0.8+5prealpha+1-15+b4) ... +Selecting previously unselected package libpostproc58:amd64. +Preparing to unpack .../146-libpostproc58_7%3a7.1.3-0+deb13u1_amd64.deb ... +Unpacking libpostproc58:amd64 (7:7.1.3-0+deb13u1) ... +Selecting previously unselected package libfftw3-double3:amd64. +Preparing to unpack .../147-libfftw3-double3_3.3.10-2+b1_amd64.deb ... +Unpacking libfftw3-double3:amd64 (3.3.10-2+b1) ... +Selecting previously unselected package libsamplerate0:amd64. +Preparing to unpack .../148-libsamplerate0_0.2.2-4+b2_amd64.deb ... +Unpacking libsamplerate0:amd64 (0.2.2-4+b2) ... +Selecting previously unselected package librubberband2:amd64. +Preparing to unpack .../149-librubberband2_3.3.0+dfsg-2+b3_amd64.deb ... +Unpacking librubberband2:amd64 (3.3.0+dfsg-2+b3) ... +Selecting previously unselected package libswscale8:amd64. +Preparing to unpack .../150-libswscale8_7%3a7.1.3-0+deb13u1_amd64.deb ... +Unpacking libswscale8:amd64 (7:7.1.3-0+deb13u1) ... +Selecting previously unselected package libvidstab1.1:amd64. +Preparing to unpack .../151-libvidstab1.1_1.1.0-2+b2_amd64.deb ... +Unpacking libvidstab1.1:amd64 (1.1.0-2+b2) ... +Selecting previously unselected package libzimg2:amd64. +Preparing to unpack .../152-libzimg2_3.0.5+ds1-1+b2_amd64.deb ... +Unpacking libzimg2:amd64 (3.0.5+ds1-1+b2) ... +Selecting previously unselected package libavfilter10:amd64. +Preparing to unpack .../153-libavfilter10_7%3a7.1.3-0+deb13u1_amd64.deb ... +Unpacking libavfilter10:amd64 (7:7.1.3-0+deb13u1) ... +Selecting previously unselected package libslang2:amd64. +Preparing to unpack .../154-libslang2_2.3.3-5+b2_amd64.deb ... +Unpacking libslang2:amd64 (2.3.3-5+b2) ... +Selecting previously unselected package libcaca0:amd64. +Preparing to unpack .../155-libcaca0_0.99.beta20-5_amd64.deb ... +Unpacking libcaca0:amd64 (0.99.beta20-5) ... +Selecting previously unselected package libcdio19t64:amd64. +Preparing to unpack .../156-libcdio19t64_2.2.0-4_amd64.deb ... +Unpacking libcdio19t64:amd64 (2.2.0-4) ... +Selecting previously unselected package libcdio-cdda2t64:amd64. +Preparing to unpack .../157-libcdio-cdda2t64_10.2+2.0.2-1+b1_amd64.deb ... +Unpacking libcdio-cdda2t64:amd64 (10.2+2.0.2-1+b1) ... +Selecting previously unselected package libcdio-paranoia2t64:amd64. +Preparing to unpack .../158-libcdio-paranoia2t64_10.2+2.0.2-1+b1_amd64.deb ... +Unpacking libcdio-paranoia2t64:amd64 (10.2+2.0.2-1+b1) ... +Selecting previously unselected package libusb-1.0-0:amd64. +Preparing to unpack .../159-libusb-1.0-0_2%3a1.0.28-1_amd64.deb ... +Unpacking libusb-1.0-0:amd64 (2:1.0.28-1) ... +Selecting previously unselected package libdc1394-25:amd64. +Preparing to unpack .../160-libdc1394-25_2.2.6-5_amd64.deb ... +Unpacking libdc1394-25:amd64 (2.2.6-5) ... +Selecting previously unselected package libglvnd0:amd64. +Preparing to unpack .../161-libglvnd0_1.7.0-1+b2_amd64.deb ... +Unpacking libglvnd0:amd64 (1.7.0-1+b2) ... +Selecting previously unselected package libxcb-glx0:amd64. +Preparing to unpack .../162-libxcb-glx0_1.17.0-2+b1_amd64.deb ... +Unpacking libxcb-glx0:amd64 (1.17.0-2+b1) ... +Selecting previously unselected package libxcb-present0:amd64. +Preparing to unpack .../163-libxcb-present0_1.17.0-2+b1_amd64.deb ... +Unpacking libxcb-present0:amd64 (1.17.0-2+b1) ... +Selecting previously unselected package libxcb-xfixes0:amd64. +Preparing to unpack .../164-libxcb-xfixes0_1.17.0-2+b1_amd64.deb ... +Unpacking libxcb-xfixes0:amd64 (1.17.0-2+b1) ... +Selecting previously unselected package libxxf86vm1:amd64. +Preparing to unpack .../165-libxxf86vm1_1%3a1.1.4-1+b4_amd64.deb ... +Unpacking libxxf86vm1:amd64 (1:1.1.4-1+b4) ... +Selecting previously unselected package libdrm-amdgpu1:amd64. +Preparing to unpack .../166-libdrm-amdgpu1_2.4.124-2_amd64.deb ... +Unpacking libdrm-amdgpu1:amd64 (2.4.124-2) ... +Selecting previously unselected package libpciaccess0:amd64. +Preparing to unpack .../167-libpciaccess0_0.17-3+b3_amd64.deb ... +Unpacking libpciaccess0:amd64 (0.17-3+b3) ... +Selecting previously unselected package libdrm-intel1:amd64. +Preparing to unpack .../168-libdrm-intel1_2.4.124-2_amd64.deb ... +Unpacking libdrm-intel1:amd64 (2.4.124-2) ... +Selecting previously unselected package libelf1t64:amd64. +Preparing to unpack .../169-libelf1t64_0.192-4_amd64.deb ... +Unpacking libelf1t64:amd64 (0.192-4) ... +Selecting previously unselected package libedit2:amd64. +Preparing to unpack .../170-libedit2_3.1-20250104-1_amd64.deb ... +Unpacking libedit2:amd64 (3.1-20250104-1) ... +Selecting previously unselected package libz3-4:amd64. +Preparing to unpack .../171-libz3-4_4.13.3-1_amd64.deb ... +Unpacking libz3-4:amd64 (4.13.3-1) ... +Selecting previously unselected package libllvm19:amd64. +Preparing to unpack .../172-libllvm19_1%3a19.1.7-3+b1_amd64.deb ... +Unpacking libllvm19:amd64 (1:19.1.7-3+b1) ... +Selecting previously unselected package libsensors-config. +Preparing to unpack .../173-libsensors-config_1%3a3.6.2-2_all.deb ... +Unpacking libsensors-config (1:3.6.2-2) ... +Selecting previously unselected package libsensors5:amd64. +Preparing to unpack .../174-libsensors5_1%3a3.6.2-2_amd64.deb ... +Unpacking libsensors5:amd64 (1:3.6.2-2) ... +Selecting previously unselected package libxcb-randr0:amd64. +Preparing to unpack .../175-libxcb-randr0_1.17.0-2+b1_amd64.deb ... +Unpacking libxcb-randr0:amd64 (1.17.0-2+b1) ... +Selecting previously unselected package libxcb-sync1:amd64. +Preparing to unpack .../176-libxcb-sync1_1.17.0-2+b1_amd64.deb ... +Unpacking libxcb-sync1:amd64 (1.17.0-2+b1) ... +Selecting previously unselected package libxshmfence1:amd64. +Preparing to unpack .../177-libxshmfence1_1.3.3-1_amd64.deb ... +Unpacking libxshmfence1:amd64 (1.3.3-1) ... +Selecting previously unselected package mesa-libgallium:amd64. +Preparing to unpack .../178-mesa-libgallium_25.0.7-2_amd64.deb ... +Unpacking mesa-libgallium:amd64 (25.0.7-2) ... +Selecting previously unselected package libwayland-server0:amd64. +Preparing to unpack .../179-libwayland-server0_1.23.1-3_amd64.deb ... +Unpacking libwayland-server0:amd64 (1.23.1-3) ... +Selecting previously unselected package libgbm1:amd64. +Preparing to unpack .../180-libgbm1_25.0.7-2_amd64.deb ... +Unpacking libgbm1:amd64 (25.0.7-2) ... +Selecting previously unselected package libgl1-mesa-dri:amd64. +Preparing to unpack .../181-libgl1-mesa-dri_25.0.7-2_amd64.deb ... +Unpacking libgl1-mesa-dri:amd64 (25.0.7-2) ... +Selecting previously unselected package libglx-mesa0:amd64. +Preparing to unpack .../182-libglx-mesa0_25.0.7-2_amd64.deb ... +Unpacking libglx-mesa0:amd64 (25.0.7-2) ... +Selecting previously unselected package libglx0:amd64. +Preparing to unpack .../183-libglx0_1.7.0-1+b2_amd64.deb ... +Unpacking libglx0:amd64 (1.7.0-1+b2) ... +Selecting previously unselected package libgl1:amd64. +Preparing to unpack .../184-libgl1_1.7.0-1+b2_amd64.deb ... +Unpacking libgl1:amd64 (1.7.0-1+b2) ... +Selecting previously unselected package libiec61883-0:amd64. +Preparing to unpack .../185-libiec61883-0_1.2.0-7_amd64.deb ... +Unpacking libiec61883-0:amd64 (1.2.0-7) ... +Selecting previously unselected package libjack-jackd2-0:amd64. +Preparing to unpack .../186-libjack-jackd2-0_1.9.22dfsg-4_amd64.deb ... +Unpacking libjack-jackd2-0:amd64 (1.9.22dfsg-4) ... +Selecting previously unselected package libopenal-data. +Preparing to unpack .../187-libopenal-data_1%3a1.24.2-1_all.deb ... +Unpacking libopenal-data (1:1.24.2-1) ... +Selecting previously unselected package libopenal1:amd64. +Preparing to unpack .../188-libopenal1_1%3a1.24.2-1_amd64.deb ... +Unpacking libopenal1:amd64 (1:1.24.2-1) ... +Selecting previously unselected package libwayland-client0:amd64. +Preparing to unpack .../189-libwayland-client0_1.23.1-3_amd64.deb ... +Unpacking libwayland-client0:amd64 (1.23.1-3) ... +Selecting previously unselected package libdecor-0-0:amd64. +Preparing to unpack .../190-libdecor-0-0_0.2.2-2_amd64.deb ... +Unpacking libdecor-0-0:amd64 (0.2.2-2) ... +Selecting previously unselected package libwayland-cursor0:amd64. +Preparing to unpack .../191-libwayland-cursor0_1.23.1-3_amd64.deb ... +Unpacking libwayland-cursor0:amd64 (1.23.1-3) ... +Selecting previously unselected package libwayland-egl1:amd64. +Preparing to unpack .../192-libwayland-egl1_1.23.1-3_amd64.deb ... +Unpacking libwayland-egl1:amd64 (1.23.1-3) ... +Selecting previously unselected package libxcursor1:amd64. +Preparing to unpack .../193-libxcursor1_1%3a1.2.3-1_amd64.deb ... +Unpacking libxcursor1:amd64 (1:1.2.3-1) ... +Selecting previously unselected package libxi6:amd64. +Preparing to unpack .../194-libxi6_2%3a1.8.2-1_amd64.deb ... +Unpacking libxi6:amd64 (2:1.8.2-1) ... +Selecting previously unselected package xkb-data. +Preparing to unpack .../195-xkb-data_2.42-1_all.deb ... +Unpacking xkb-data (2.42-1) ... +Selecting previously unselected package libxkbcommon0:amd64. +Preparing to unpack .../196-libxkbcommon0_1.7.0-2_amd64.deb ... +Unpacking libxkbcommon0:amd64 (1.7.0-2) ... +Selecting previously unselected package libxrandr2:amd64. +Preparing to unpack .../197-libxrandr2_2%3a1.5.4-1+b3_amd64.deb ... +Unpacking libxrandr2:amd64 (2:1.5.4-1+b3) ... +Selecting previously unselected package x11-common. +Preparing to unpack .../198-x11-common_1%3a7.7+24+deb13u1_all.deb ... +Unpacking x11-common (1:7.7+24+deb13u1) ... +Selecting previously unselected package libxss1:amd64. +Preparing to unpack .../199-libxss1_1%3a1.2.3-1+b3_amd64.deb ... +Unpacking libxss1:amd64 (1:1.2.3-1+b3) ... +Selecting previously unselected package libsdl2-2.0-0:amd64. +Preparing to unpack .../200-libsdl2-2.0-0_2.32.4+dfsg-1_amd64.deb ... +Unpacking libsdl2-2.0-0:amd64 (2.32.4+dfsg-1) ... +Selecting previously unselected package libxcb-shape0:amd64. +Preparing to unpack .../201-libxcb-shape0_1.17.0-2+b1_amd64.deb ... +Unpacking libxcb-shape0:amd64 (1.17.0-2+b1) ... +Selecting previously unselected package libxv1:amd64. +Preparing to unpack .../202-libxv1_2%3a1.0.11-1.1+b3_amd64.deb ... +Unpacking libxv1:amd64 (2:1.0.11-1.1+b3) ... +Selecting previously unselected package libavdevice61:amd64. +Preparing to unpack .../203-libavdevice61_7%3a7.1.3-0+deb13u1_amd64.deb ... +Unpacking libavdevice61:amd64 (7:7.1.3-0+deb13u1) ... +Selecting previously unselected package ffmpeg. +Preparing to unpack .../204-ffmpeg_7%3a7.1.3-0+deb13u1_amd64.deb ... +Unpacking ffmpeg (7:7.1.3-0+deb13u1) ... +Setting up libgme0:amd64 (0.6.3-7+b2) ... +Setting up libchromaprint1:amd64 (1.5.1-7) ... +Setting up libhwy1t64:amd64 (1.2.0-2+b2) ... +Setting up libexpat1:amd64 (2.7.1-2) ... +Setting up libgraphite2-3:amd64 (1.3.14-2+b1) ... +Setting up liblcms2-2:amd64 (2.16-2) ... +Setting up libpixman-1-0:amd64 (0.44.0-3) ... +Setting up libdvdread8t64:amd64 (6.1.3-2) ... +Setting up libudfread0:amd64 (1.1.2-1+b2) ... +Setting up libnorm1t64:amd64 (1.5.9+dfsg-3.1+b2) ... +Setting up libsharpyuv0:amd64 (1.5.0-0.1) ... +Setting up libwayland-server0:amd64 (1.23.1-3) ... +Setting up libaom3:amd64 (3.12.1-1) ... +Setting up libpciaccess0:amd64 (0.17-3+b3) ... +Setting up librabbitmq4:amd64 (0.15.0-1) ... +Setting up libxau6:amd64 (1:1.0.11-1) ... +Setting up libxdmcp6:amd64 (1:1.1.5-1) ... +Setting up libraw1394-11:amd64 (2.1.2-2+b2) ... +Setting up libkeyutils1:amd64 (1.6.3-6) ... +Setting up libxcb1:amd64 (1.17.0-2+b1) ... +Setting up libsodium23:amd64 (1.0.18-1+deb13u1) ... +Setting up libxcb-xfixes0:amd64 (1.17.0-2+b1) ... +Setting up libogg0:amd64 (1.3.5-3+b2) ... +Setting up liblerc4:amd64 (4.0.0+ds-5) ... +Setting up libspeex1:amd64 (1.2.1-3) ... +Setting up libshine3:amd64 (3.1.1-2+b2) ... +Setting up libvpl2 (1:2.14.0-1+b1) ... +Setting up libx264-164:amd64 (2:0.164.3108+git31e19f9-2+b1) ... +Setting up libtwolame0:amd64 (0.4.0-2+b2) ... +Setting up libdatrie1:amd64 (0.2.13-3+b1) ... +Setting up libgsm1:amd64 (1.0.22-1+b2) ... +Setting up libxcb-render0:amd64 (1.17.0-2+b1) ... +Setting up libzix-0-0:amd64 (0.6.2-1) ... +Setting up libglvnd0:amd64 (1.7.0-1+b2) ... +Setting up libcodec2-1.2:amd64 (1.2.0-3) ... +Setting up libxcb-glx0:amd64 (1.17.0-2+b1) ... +Setting up libbrotli1:amd64 (1.1.0-2+b7) ... +Setting up libedit2:amd64 (3.1-20250104-1) ... +Setting up libgdk-pixbuf2.0-common (2.42.12+dfsg-4) ... +Setting up libmysofa1:amd64 (1.3.3+dfsg-1) ... +Setting up libxcb-shape0:amd64 (1.17.0-2+b1) ... +Setting up x11-common (1:7.7+24+deb13u1) ... +debconf: unable to initialize frontend: Dialog +debconf: (TERM is not set, so the dialog frontend is not usable.) +debconf: falling back to frontend: Readline +debconf: unable to initialize frontend: Readline +debconf: (Can't locate Term/ReadLine.pm in @INC (you may need to install the Term::ReadLine module) (@INC entries checked: /etc/perl /usr/local/lib/x86_64-linux-gnu/perl/5.40.1 /usr/local/share/perl/5.40.1 /usr/lib/x86_64-linux-gnu/perl5/5.40 /usr/share/perl5 /usr/lib/x86_64-linux-gnu/perl-base /usr/lib/x86_64-linux-gnu/perl/5.40 /usr/share/perl/5.40 /usr/local/lib/site_perl) at /usr/share/perl5/Debconf/FrontEnd/Readline.pm line 8.) +debconf: falling back to frontend: Teletype +debconf: unable to initialize frontend: Teletype +debconf: (This frontend requires a controlling tty.) +debconf: falling back to frontend: Noninteractive +invoke-rc.d: could not determine current runlevel +invoke-rc.d: policy-rc.d denied execution of start. +Setting up libsensors-config (1:3.6.2-2) ... +Setting up libcdio19t64:amd64 (2.2.0-4) ... +Setting up libdeflate0:amd64 (1.23-2) ... +Setting up xkb-data (2.42-1) ... +Setting up libxcb-shm0:amd64 (1.17.0-2+b1) ... +Setting up libcom-err2:amd64 (1.47.2-3+b7) ... +Setting up libmpg123-0t64:amd64 (1.32.10-1) ... +Setting up libgomp1:amd64 (14.2.0-19) ... +Setting up libcjson1:amd64 (1.7.18-3.1+deb13u1) ... +Setting up libxvidcore4:amd64 (2:1.3.7-1+b2) ... +Setting up libjbig0:amd64 (2.1-6.1+b2) ... +Setting up libelf1t64:amd64 (0.192-4) ... +Setting up libsnappy1v5:amd64 (1.2.2-1) ... +Setting up libcdio-cdda2t64:amd64 (10.2+2.0.2-1+b1) ... +Setting up libkrb5support0:amd64 (1.21.3-5) ... +Setting up libxcb-present0:amd64 (1.17.0-2+b1) ... +Setting up libasound2-data (1.2.14-1) ... +Setting up libpgm-5.3-0t64:amd64 (5.3.128dfsg-2.1+b1) ... +Setting up libtheoraenc1:amd64 (1.2.0alpha1+dfsg-6) ... +Setting up libz3-4:amd64 (4.13.3-1) ... +Setting up libblas3:amd64 (3.12.1-6) ... +update-alternatives: using /usr/lib/x86_64-linux-gnu/blas/libblas.so.3 to provide /usr/lib/x86_64-linux-gnu/libblas.so.3 (libblas.so.3-x86_64-linux-gnu) in auto mode +Setting up libasound2t64:amd64 (1.2.14-1) ... +Setting up libjpeg62-turbo:amd64 (1:2.1.5-4) ... +Setting up libslang2:amd64 (2.3.3-5+b2) ... +Setting up libva2:amd64 (2.22.0-3) ... +Setting up libx11-data (2:1.8.12-1) ... +Setting up libsvtav1enc2:amd64 (2.3.0+dfsg-1) ... +Setting up libxcb-sync1:amd64 (1.17.0-2+b1) ... +Setting up libdbus-1-3:amd64 (1.16.2-2) ... +Setting up libfribidi0:amd64 (1.0.16-1) ... +Setting up libopus0:amd64 (1.5.2-2) ... +Setting up libp11-kit0:amd64 (0.25.5-3) ... +Setting up libcdio-paranoia2t64:amd64 (10.2+2.0.2-1+b1) ... +Setting up libunistring5:amd64 (1.3-2) ... +Setting up fonts-dejavu-mono (2.37-8) ... +Setting up libpng16-16t64:amd64 (1.6.48-1+deb13u3) ... +Setting up libatomic1:amd64 (14.2.0-19) ... +Setting up libvorbis0a:amd64 (1.3.7-3) ... +Setting up fonts-dejavu-core (2.37-8) ... +Setting up libflac14:amd64 (1.5.0+ds-2) ... +Setting up libsensors5:amd64 (1:3.6.2-2) ... +Setting up libk5crypto3:amd64 (1.21.3-5) ... +Setting up libfftw3-double3:amd64 (3.3.10-2+b1) ... +Setting up libgfortran5:amd64 (14.2.0-19) ... +Setting up libvulkan1:amd64 (1.4.309.0-1) ... +Setting up libwebp7:amd64 (1.5.0-0.1) ... +Setting up libnuma1:amd64 (2.0.19-1) ... +Setting up libvidstab1.1:amd64 (1.1.0-2+b2) ... +Setting up libvpx9:amd64 (1.15.0-2.1+deb13u1) ... +Setting up libflite1:amd64 (2.2-7) ... +Setting up libdav1d7:amd64 (1.5.1-1) ... +Setting up ocl-icd-libopencl1:amd64 (2.3.3-1) ... +Setting up libasyncns0:amd64 (0.8-6+b5) ... +Setting up libxshmfence1:amd64 (1.3.3-1) ... +Setting up libtiff6:amd64 (4.7.0-3+deb13u1) ... +Setting up libbs2b0:amd64 (3.1.0+dfsg-8+b1) ... +Setting up libxcb-randr0:amd64 (1.17.0-2+b1) ... +Setting up librav1e0.7:amd64 (0.7.1-9+b2) ... +Setting up libtasn1-6:amd64 (4.20.0-2) ... +Setting up libzimg2:amd64 (3.0.5+ds1-1+b2) ... +Setting up libopenjp2-7:amd64 (2.5.3-2.1deb13u1) ... +Setting up libx11-6:amd64 (2:1.8.12-1) ... +Setting up libopenal-data (1:1.24.2-1) ... +Setting up libthai-data (0.1.29-2) ... +Setting up libkrb5-3:amd64 (1.21.3-5) ... +Setting up libunibreak6:amd64 (6.1-3) ... +Setting up libwayland-egl1:amd64 (1.23.1-3) ... +Setting up libusb-1.0-0:amd64 (2:1.0.28-1) ... +Setting up libmbedcrypto16:amd64 (3.6.5-0.1deb13u1) ... +Setting up libx265-215:amd64 (4.1-2) ... +Setting up libsamplerate0:amd64 (0.2.2-4+b2) ... +Setting up libwebpmux3:amd64 (1.5.0-0.1) ... +Setting up libdrm-common (2.4.124-2) ... +Setting up libjxl0.11:amd64 (0.11.1-4) ... +Setting up libxml2:amd64 (2.12.7+dfsg+really2.9.14-2.1+deb13u2) ... +Setting up libzvbi-common (0.2.44-1) ... +Setting up libmp3lame0:amd64 (3.100-6+b3) ... +Setting up libvorbisenc2:amd64 (1.3.7-3) ... +Setting up libdvdnav4:amd64 (6.1.1-3+b1) ... +Setting up libiec61883-0:amd64 (1.2.0-7) ... +Setting up libserd-0-0:amd64 (0.32.4-1) ... +Setting up libxkbcommon0:amd64 (1.7.0-2) ... +Setting up libwayland-client0:amd64 (1.23.1-3) ... +Setting up libavc1394-0:amd64 (0.5.4-5+b2) ... +Setting up libxcb-dri3-0:amd64 (1.17.0-2+b1) ... +Setting up libllvm19:amd64 (1:19.1.7-3+b1) ... +Setting up libx11-xcb1:amd64 (2:1.8.12-1) ... +Setting up liblapack3:amd64 (3.12.1-6) ... +update-alternatives: using /usr/lib/x86_64-linux-gnu/lapack/liblapack.so.3 to provide /usr/lib/x86_64-linux-gnu/liblapack.so.3 (liblapack.so.3-x86_64-linux-gnu) in auto mode +Setting up libcaca0:amd64 (0.99.beta20-5) ... +Setting up libzvbi0t64:amd64 (0.2.44-1) ... +Setting up libxrender1:amd64 (1:0.9.12-1) ... +Setting up libsoxr0:amd64 (0.1.3-4+b2) ... +Setting up fontconfig-config (2.15.0-2.3) ... +debconf: unable to initialize frontend: Dialog +debconf: (TERM is not set, so the dialog frontend is not usable.) +debconf: falling back to frontend: Readline +debconf: unable to initialize frontend: Readline +debconf: (Can't locate Term/ReadLine.pm in @INC (you may need to install the Term::ReadLine module) (@INC entries checked: /etc/perl /usr/local/lib/x86_64-linux-gnu/perl/5.40.1 /usr/local/share/perl/5.40.1 /usr/lib/x86_64-linux-gnu/perl5/5.40 /usr/share/perl5 /usr/lib/x86_64-linux-gnu/perl-base /usr/lib/x86_64-linux-gnu/perl/5.40 /usr/share/perl/5.40 /usr/local/lib/site_perl) at /usr/share/perl5/Debconf/FrontEnd/Readline.pm line 8.) +debconf: falling back to frontend: Teletype +debconf: unable to initialize frontend: Teletype +debconf: (This frontend requires a controlling tty.) +debconf: falling back to frontend: Noninteractive +Setting up libxext6:amd64 (2:1.3.4-1+b3) ... +Setting up libidn2-0:amd64 (2.3.8-2) ... +Setting up libopenal1:amd64 (1:1.24.2-1) ... +Setting up libxxf86vm1:amd64 (1:1.1.4-1+b4) ... +Setting up librist4:amd64 (0.2.11+dfsg-1) ... +Setting up libthai0:amd64 (0.1.29-2+b1) ... +Setting up libvorbisfile3:amd64 (1.3.7-3) ... +Setting up libglib2.0-0t64:amd64 (2.84.4-3deb13u2) ... +No schema files found: doing nothing. +Setting up libfreetype6:amd64 (2.13.3+dfsg-1) ... +Setting up libxfixes3:amd64 (1:6.0.0-2+b4) ... +Setting up shared-mime-info (2.4-5+b2) ... +Setting up libplacebo349:amd64 (7.349.0-3) ... +Setting up libdc1394-25:amd64 (2.2.6-5) ... +Setting up libxv1:amd64 (2:1.0.11-1.1+b3) ... +Setting up libgssapi-krb5-2:amd64 (1.21.3-5) ... +Setting up libxrandr2:amd64 (2:1.5.4-1+b3) ... +Setting up libssh-4:amd64 (0.11.2-1+deb13u1) ... +Setting up librubberband2:amd64 (3.3.0+dfsg-2+b3) ... +Setting up libjack-jackd2-0:amd64 (1.9.22dfsg-4) ... +Setting up libdrm2:amd64 (2.4.124-2) ... +Setting up libva-drm2:amd64 (2.22.0-3) ... +Setting up libvdpau1:amd64 (1.5-3+b1) ... +Setting up libsord-0-0:amd64 (0.16.18-1) ... +Setting up libwayland-cursor0:amd64 (1.23.1-3) ... +Setting up libsratom-0-0:amd64 (0.6.18-1) ... +Setting up libdecor-0-0:amd64 (0.2.2-2) ... +Setting up libharfbuzz0b:amd64 (10.2.0-1+b1) ... +Setting up libgdk-pixbuf-2.0-0:amd64 (2.42.12+dfsg-4) ... +Setting up libxss1:amd64 (1:1.2.3-1+b3) ... +Setting up libfontconfig1:amd64 (2.15.0-2.3) ... +Setting up libsndfile1:amd64 (1.2.2-2+b1) ... +Setting up libbluray2:amd64 (1:1.3.4-1+b2) ... +Setting up libva-x11-2:amd64 (2.22.0-3) ... +Setting up liblilv-0-0:amd64 (0.24.26-1) ... +Setting up libopenmpt0t64:amd64 (0.7.13-1+b1) ... +Setting up libdrm-amdgpu1:amd64 (2.4.124-2) ... +Setting up libgnutls30t64:amd64 (3.8.9-3+deb13u2) ... +Setting up fontconfig (2.15.0-2.3) ... +Regenerating fonts cache... done. +Setting up libzmq5:amd64 (4.3.5-1+b3) ... +Setting up libxi6:amd64 (2:1.8.2-1) ... +Setting up libpulse0:amd64 (17.0+dfsg1-2+b1) ... +Setting up libxcursor1:amd64 (1:1.2.3-1) ... +Setting up libpango-1.0-0:amd64 (1.56.3-1) ... +Setting up libdrm-intel1:amd64 (2.4.124-2) ... +Setting up libavutil59:amd64 (7:7.1.3-0+deb13u1) ... +Setting up libcairo2:amd64 (1.18.4-1+b1) ... +Setting up libpostproc58:amd64 (7:7.1.3-0+deb13u1) ... +Setting up libsphinxbase3t64:amd64 (0.8+5prealpha+1-21+b1) ... +Setting up libswresample5:amd64 (7:7.1.3-0+deb13u1) ... +Setting up libswscale8:amd64 (7:7.1.3-0+deb13u1) ... +Setting up libass9:amd64 (1:0.17.3-1+b1) ... +Setting up libtheoradec1:amd64 (1.2.0alpha1+dfsg-6) ... +Setting up libsrt1.5-gnutls:amd64 (1.5.4-1) ... +Setting up libcairo-gobject2:amd64 (1.18.4-1+b1) ... +Setting up libpangoft2-1.0-0:amd64 (1.56.3-1) ... +Setting up libpangocairo-1.0-0:amd64 (1.56.3-1) ... +Setting up mesa-libgallium:amd64 (25.0.7-2) ... +Setting up libgbm1:amd64 (25.0.7-2) ... +Setting up libgl1-mesa-dri:amd64 (25.0.7-2) ... +Setting up librsvg2-2:amd64 (2.60.0+dfsg-1) ... +Setting up libpocketsphinx3:amd64 (0.8+5prealpha+1-15+b4) ... +Setting up libavcodec61:amd64 (7:7.1.3-0+deb13u1) ... +Setting up libsdl2-2.0-0:amd64 (2.32.4+dfsg-1) ... +Setting up libglx-mesa0:amd64 (25.0.7-2) ... +Setting up libglx0:amd64 (1.7.0-1+b2) ... +Setting up libavformat61:amd64 (7:7.1.3-0+deb13u1) ... +Setting up libgl1:amd64 (1.7.0-1+b2) ... +Setting up libavfilter10:amd64 (7:7.1.3-0+deb13u1) ... +Setting up libavdevice61:amd64 (7:7.1.3-0+deb13u1) ... +Setting up ffmpeg (7:7.1.3-0+deb13u1) ... +Processing triggers for libc-bin (2.41-12+deb13u1) ... +Removing intermediate container 49b42f3bf5da +dd945265156a +Step 3/13 : WORKDIR /app +Running in d9ac4ab0dfc4 +Removing intermediate container d9ac4ab0dfc4 +27f6f53fbebb +Step 4/13 : RUN pip install --no-cache-dir torch torchaudio --index-url https://download.pytorch.org/whl/cpu +Running in 173de0bbcef6 +Looking in indexes: https://download.pytorch.org/whl/cpu +Collecting torch +Downloading https://download.pytorch.org/whl/cpu/torch-2.10.0%2Bcpu-cp311-cp311-manylinux_2_28_x86_64.whl.metadata (29 kB) +Collecting torchaudio +Downloading https://download.pytorch.org/whl/cpu/torchaudio-2.10.0%2Bcpu-cp311-cp311-manylinux_2_28_x86_64.whl.metadata (6.9 kB) +Collecting filelock (from torch) +Downloading filelock-3.20.0-py3-none-any.whl.metadata (2.1 kB) +Collecting typing-extensions>=4.10.0 (from torch) +Downloading https://download.pytorch.org/whl/typing_extensions-4.15.0-py3-none-any.whl.metadata (3.3 kB) +Collecting sympy>=1.13.3 (from torch) +Downloading sympy-1.14.0-py3-none-any.whl.metadata (12 kB) +Collecting networkx>=2.5.1 (from torch) +Downloading networkx-3.6.1-py3-none-any.whl.metadata (6.8 kB) +Collecting jinja2 (from torch) +Downloading https://download.pytorch.org/whl/jinja2-3.1.6-py3-none-any.whl.metadata (2.9 kB) +Collecting fsspec>=0.8.5 (from torch) +Downloading fsspec-2025.12.0-py3-none-any.whl.metadata (10 kB) +Collecting mpmath<1.4,>=1.1.0 (from sympy>=1.13.3->torch) +Downloading mpmath-1.3.0-py3-none-any.whl.metadata (8.6 kB) +Collecting MarkupSafe>=2.0 (from jinja2->torch) +Downloading https://download.pytorch.org/whl/MarkupSafe-3.0.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl.metadata (4.0 kB) +Downloading https://download.pytorch.org/whl/cpu/torch-2.10.0%2Bcpu-cp311-cp311-manylinux_2_28_x86_64.whl (188.8 MB) +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 188.8/188.8 MB 222.8 MB/s eta 0:00:00 +Downloading https://download.pytorch.org/whl/cpu/torchaudio-2.10.0%2Bcpu-cp311-cp311-manylinux_2_28_x86_64.whl (412 kB) +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 412.7/412.7 kB 241.6 MB/s eta 0:00:00 +Downloading fsspec-2025.12.0-py3-none-any.whl (201 kB) +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 201.4/201.4 kB 12.6 MB/s eta 0:00:00 +Downloading networkx-3.6.1-py3-none-any.whl (2.1 MB) +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 2.1/2.1 MB 98.4 MB/s eta 0:00:00 +Downloading sympy-1.14.0-py3-none-any.whl (6.3 MB) +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 6.3/6.3 MB 244.7 MB/s eta 0:00:00 +Downloading https://download.pytorch.org/whl/typing_extensions-4.15.0-py3-none-any.whl (44 kB) +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 44.6/44.6 kB 159.2 MB/s eta 0:00:00 +Downloading filelock-3.20.0-py3-none-any.whl (16 kB) +Downloading https://download.pytorch.org/whl/jinja2-3.1.6-py3-none-any.whl (134 kB) +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 134.9/134.9 kB 244.2 MB/s eta 0:00:00 +Downloading https://download.pytorch.org/whl/MarkupSafe-3.0.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl (23 kB) +Downloading mpmath-1.3.0-py3-none-any.whl (536 kB) +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 536.2/536.2 kB 296.6 MB/s eta 0:00:00 +Installing collected packages: mpmath, typing-extensions, sympy, networkx, MarkupSafe, fsspec, filelock, jinja2, torch, torchaudio +Successfully installed MarkupSafe-3.0.2 filelock-3.20.0 fsspec-2025.12.0 jinja2-3.1.6 mpmath-1.3.0 networkx-3.6.1 sympy-1.14.0 torch-2.10.0+cpu torchaudio-2.10.0+cpu typing-extensions-4.15.0 +WARNING: Running pip as the 'root' user can result in broken permissions and conflicting behaviour with the system package manager. It is recommended to use a virtual environment instead: https://pip.pypa.io/warnings/venv +Removing intermediate container 173de0bbcef6 +fddc7ea23609 +Step 5/13 : COPY requirements.txt . +3b6f11a10d93 +Step 6/13 : RUN pip install --no-cache-dir -r requirements.txt +Running in 5f48660704b5 +Collecting flask>=3.0.0 (from -r requirements.txt (line 1)) +Downloading flask-3.1.3-py3-none-any.whl.metadata (3.2 kB) +Collecting flask-cors>=4.0.0 (from -r requirements.txt (line 2)) +Downloading flask_cors-6.0.2-py3-none-any.whl.metadata (5.3 kB) +Collecting gunicorn>=21.2.0 (from -r requirements.txt (line 3)) +Downloading gunicorn-25.1.0-py3-none-any.whl.metadata (5.5 kB) +Collecting numpy>=1.24.0 (from -r requirements.txt (line 4)) +Downloading numpy-2.4.2-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl.metadata (6.6 kB) +Collecting transformers>=4.30.0 (from -r requirements.txt (line 5)) +Downloading transformers-5.2.0-py3-none-any.whl.metadata (32 kB) +Collecting pydub>=0.25.1 (from -r requirements.txt (line 6)) +Downloading pydub-0.25.1-py2.py3-none-any.whl.metadata (1.4 kB) +Collecting librosa>=0.10.0 (from -r requirements.txt (line 7)) +Downloading librosa-0.11.0-py3-none-any.whl.metadata (8.7 kB) +Collecting scipy>=1.10.0 (from -r requirements.txt (line 8)) +Downloading scipy-1.17.1-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl.metadata (62 kB) +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 62.1/62.1 kB 42.5 MB/s eta 0:00:00 +Collecting addict>=2.4.0 (from -r requirements.txt (line 9)) +Downloading addict-2.4.0-py3-none-any.whl.metadata (1.0 kB) +Collecting yapf>=0.40.0 (from -r requirements.txt (line 10)) +Downloading yapf-0.43.0-py3-none-any.whl.metadata (46 kB) +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 46.8/46.8 kB 192.5 MB/s eta 0:00:00 +Collecting termcolor>=2.0.0 (from -r requirements.txt (line 11)) +Downloading termcolor-3.3.0-py3-none-any.whl.metadata (6.5 kB) +Collecting blinker>=1.9.0 (from flask>=3.0.0->-r requirements.txt (line 1)) +Downloading blinker-1.9.0-py3-none-any.whl.metadata (1.6 kB) +Collecting click>=8.1.3 (from flask>=3.0.0->-r requirements.txt (line 1)) +Downloading click-8.3.1-py3-none-any.whl.metadata (2.6 kB) +Collecting itsdangerous>=2.2.0 (from flask>=3.0.0->-r requirements.txt (line 1)) +Downloading itsdangerous-2.2.0-py3-none-any.whl.metadata (1.9 kB) +Requirement already satisfied: jinja2>=3.1.2 in /usr/local/lib/python3.11/site-packages (from flask>=3.0.0->-r requirements.txt (line 1)) (3.1.6) +Requirement already satisfied: markupsafe>=2.1.1 in /usr/local/lib/python3.11/site-packages (from flask>=3.0.0->-r requirements.txt (line 1)) (3.0.2) +Collecting werkzeug>=3.1.0 (from flask>=3.0.0->-r requirements.txt (line 1)) +Downloading werkzeug-3.1.6-py3-none-any.whl.metadata (4.0 kB) +Collecting packaging (from gunicorn>=21.2.0->-r requirements.txt (line 3)) +Downloading packaging-26.0-py3-none-any.whl.metadata (3.3 kB) +Collecting huggingface-hub<2.0,>=1.3.0 (from transformers>=4.30.0->-r requirements.txt (line 5)) +Downloading huggingface_hub-1.4.1-py3-none-any.whl.metadata (13 kB) +Collecting pyyaml>=5.1 (from transformers>=4.30.0->-r requirements.txt (line 5)) +Downloading pyyaml-6.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl.metadata (2.4 kB) +Collecting regex!=2019.12.17 (from transformers>=4.30.0->-r requirements.txt (line 5)) +Downloading regex-2026.2.19-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl.metadata (40 kB) +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 40.4/40.4 kB 197.9 MB/s eta 0:00:00 +Collecting tokenizers<=0.23.0,>=0.22.0 (from transformers>=4.30.0->-r requirements.txt (line 5)) +Downloading tokenizers-0.22.2-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl.metadata (7.3 kB) +Collecting typer-slim (from transformers>=4.30.0->-r requirements.txt (line 5)) +Downloading typer_slim-0.24.0-py3-none-any.whl.metadata (4.2 kB) +Collecting safetensors>=0.4.3 (from transformers>=4.30.0->-r requirements.txt (line 5)) +Downloading safetensors-0.7.0-cp38-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl.metadata (4.1 kB) +Collecting tqdm>=4.27 (from transformers>=4.30.0->-r requirements.txt (line 5)) +Downloading tqdm-4.67.3-py3-none-any.whl.metadata (57 kB) +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 57.7/57.7 kB 207.9 MB/s eta 0:00:00 +Collecting audioread>=2.1.9 (from librosa>=0.10.0->-r requirements.txt (line 7)) +Downloading audioread-3.1.0-py3-none-any.whl.metadata (9.0 kB) +Collecting numba>=0.51.0 (from librosa>=0.10.0->-r requirements.txt (line 7)) +Downloading numba-0.64.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl.metadata (2.9 kB) +Collecting scikit-learn>=1.1.0 (from librosa>=0.10.0->-r requirements.txt (line 7)) +Downloading scikit_learn-1.8.0-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl.metadata (11 kB) +Collecting joblib>=1.0 (from librosa>=0.10.0->-r requirements.txt (line 7)) +Downloading joblib-1.5.3-py3-none-any.whl.metadata (5.5 kB) +Collecting decorator>=4.3.0 (from librosa>=0.10.0->-r requirements.txt (line 7)) +Downloading decorator-5.2.1-py3-none-any.whl.metadata (3.9 kB) +Collecting soundfile>=0.12.1 (from librosa>=0.10.0->-r requirements.txt (line 7)) +Downloading soundfile-0.13.1-py2.py3-none-manylinux_2_28_x86_64.whl.metadata (16 kB) +Collecting pooch>=1.1 (from librosa>=0.10.0->-r requirements.txt (line 7)) +Downloading pooch-1.9.0-py3-none-any.whl.metadata (10 kB) +Collecting soxr>=0.3.2 (from librosa>=0.10.0->-r requirements.txt (line 7)) +Downloading soxr-1.0.0-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl.metadata (5.6 kB) +Requirement already satisfied: typing_extensions>=4.1.1 in /usr/local/lib/python3.11/site-packages (from librosa>=0.10.0->-r requirements.txt (line 7)) (4.15.0) +Collecting lazy_loader>=0.1 (from librosa>=0.10.0->-r requirements.txt (line 7)) +Downloading lazy_loader-0.4-py3-none-any.whl.metadata (7.6 kB) +Collecting msgpack>=1.0 (from librosa>=0.10.0->-r requirements.txt (line 7)) +Downloading msgpack-1.1.2-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl.metadata (8.1 kB) +Collecting platformdirs>=3.5.1 (from yapf>=0.40.0->-r requirements.txt (line 10)) +Downloading platformdirs-4.9.2-py3-none-any.whl.metadata (4.7 kB) +Requirement already satisfied: filelock in /usr/local/lib/python3.11/site-packages (from huggingface-hub<2.0,>=1.3.0->transformers>=4.30.0->-r requirements.txt (line 5)) (3.20.0) +Requirement already satisfied: fsspec>=2023.5.0 in /usr/local/lib/python3.11/site-packages (from huggingface-hub<2.0,>=1.3.0->transformers>=4.30.0->-r requirements.txt (line 5)) (2025.12.0) +Collecting hf-xet<2.0.0,>=1.2.0 (from huggingface-hub<2.0,>=1.3.0->transformers>=4.30.0->-r requirements.txt (line 5)) +Downloading hf_xet-1.2.0-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl.metadata (4.9 kB) +Collecting httpx<1,>=0.23.0 (from huggingface-hub<2.0,>=1.3.0->transformers>=4.30.0->-r requirements.txt (line 5)) +Downloading httpx-0.28.1-py3-none-any.whl.metadata (7.1 kB) +Collecting shellingham (from huggingface-hub<2.0,>=1.3.0->transformers>=4.30.0->-r requirements.txt (line 5)) +Downloading shellingham-1.5.4-py2.py3-none-any.whl.metadata (3.5 kB) +Collecting llvmlite<0.47,>=0.46.0dev0 (from numba>=0.51.0->librosa>=0.10.0->-r requirements.txt (line 7)) +Downloading llvmlite-0.46.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl.metadata (5.0 kB) +Collecting requests>=2.19.0 (from pooch>=1.1->librosa>=0.10.0->-r requirements.txt (line 7)) +Downloading requests-2.32.5-py3-none-any.whl.metadata (4.9 kB) +Collecting threadpoolctl>=3.2.0 (from scikit-learn>=1.1.0->librosa>=0.10.0->-r requirements.txt (line 7)) +Downloading threadpoolctl-3.6.0-py3-none-any.whl.metadata (13 kB) +Collecting cffi>=1.0 (from soundfile>=0.12.1->librosa>=0.10.0->-r requirements.txt (line 7)) +Downloading cffi-2.0.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl.metadata (2.6 kB) +Collecting typer>=0.24.0 (from typer-slim->transformers>=4.30.0->-r requirements.txt (line 5)) +Downloading typer-0.24.1-py3-none-any.whl.metadata (16 kB) +Collecting pycparser (from cffi>=1.0->soundfile>=0.12.1->librosa>=0.10.0->-r requirements.txt (line 7)) +Downloading pycparser-3.0-py3-none-any.whl.metadata (8.2 kB) +Collecting anyio (from httpx<1,>=0.23.0->huggingface-hub<2.0,>=1.3.0->transformers>=4.30.0->-r requirements.txt (line 5)) +Downloading anyio-4.12.1-py3-none-any.whl.metadata (4.3 kB) +Collecting certifi (from httpx<1,>=0.23.0->huggingface-hub<2.0,>=1.3.0->transformers>=4.30.0->-r requirements.txt (line 5)) +Downloading certifi-2026.1.4-py3-none-any.whl.metadata (2.5 kB) +Collecting httpcore==1.* (from httpx<1,>=0.23.0->huggingface-hub<2.0,>=1.3.0->transformers>=4.30.0->-r requirements.txt (line 5)) +Downloading httpcore-1.0.9-py3-none-any.whl.metadata (21 kB) +Collecting idna (from httpx<1,>=0.23.0->huggingface-hub<2.0,>=1.3.0->transformers>=4.30.0->-r requirements.txt (line 5)) +Downloading idna-3.11-py3-none-any.whl.metadata (8.4 kB) +Collecting h11>=0.16 (from httpcore==1.*->httpx<1,>=0.23.0->huggingface-hub<2.0,>=1.3.0->transformers>=4.30.0->-r requirements.txt (line 5)) +Downloading h11-0.16.0-py3-none-any.whl.metadata (8.3 kB) +Collecting charset_normalizer<4,>=2 (from requests>=2.19.0->pooch>=1.1->librosa>=0.10.0->-r requirements.txt (line 7)) +Downloading charset_normalizer-3.4.4-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl.metadata (37 kB) +Collecting urllib3<3,>=1.21.1 (from requests>=2.19.0->pooch>=1.1->librosa>=0.10.0->-r requirements.txt (line 7)) +Downloading urllib3-2.6.3-py3-none-any.whl.metadata (6.9 kB) +Collecting rich>=12.3.0 (from typer>=0.24.0->typer-slim->transformers>=4.30.0->-r requirements.txt (line 5)) +Downloading rich-14.3.3-py3-none-any.whl.metadata (18 kB) +Collecting annotated-doc>=0.0.2 (from typer>=0.24.0->typer-slim->transformers>=4.30.0->-r requirements.txt (line 5)) +Downloading annotated_doc-0.0.4-py3-none-any.whl.metadata (6.6 kB) +Collecting markdown-it-py>=2.2.0 (from rich>=12.3.0->typer>=0.24.0->typer-slim->transformers>=4.30.0->-r requirements.txt (line 5)) +Downloading markdown_it_py-4.0.0-py3-none-any.whl.metadata (7.3 kB) +Collecting pygments<3.0.0,>=2.13.0 (from rich>=12.3.0->typer>=0.24.0->typer-slim->transformers>=4.30.0->-r requirements.txt (line 5)) +Downloading pygments-2.19.2-py3-none-any.whl.metadata (2.5 kB) +Collecting mdurl~=0.1 (from markdown-it-py>=2.2.0->rich>=12.3.0->typer>=0.24.0->typer-slim->transformers>=4.30.0->-r requirements.txt (line 5)) +Downloading mdurl-0.1.2-py3-none-any.whl.metadata (1.6 kB) +Downloading flask-3.1.3-py3-none-any.whl (103 kB) +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 103.4/103.4 kB 201.8 MB/s eta 0:00:00 +Downloading flask_cors-6.0.2-py3-none-any.whl (13 kB) +Downloading gunicorn-25.1.0-py3-none-any.whl (197 kB) +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 197.1/197.1 kB 262.1 MB/s eta 0:00:00 +Downloading numpy-2.4.2-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl (16.9 MB) +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 16.9/16.9 MB 223.0 MB/s eta 0:00:00 +Downloading transformers-5.2.0-py3-none-any.whl (10.4 MB) +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 10.4/10.4 MB 112.8 MB/s eta 0:00:00 +Downloading pydub-0.25.1-py2.py3-none-any.whl (32 kB) +Downloading librosa-0.11.0-py3-none-any.whl (260 kB) +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 260.7/260.7 kB 281.3 MB/s eta 0:00:00 +Downloading scipy-1.17.1-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl (35.3 MB) +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 35.3/35.3 MB 195.6 MB/s eta 0:00:00 +Downloading addict-2.4.0-py3-none-any.whl (3.8 kB) +Downloading yapf-0.43.0-py3-none-any.whl (256 kB) +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 256.2/256.2 kB 282.8 MB/s eta 0:00:00 +Downloading termcolor-3.3.0-py3-none-any.whl (7.7 kB) +Downloading audioread-3.1.0-py3-none-any.whl (23 kB) +Downloading blinker-1.9.0-py3-none-any.whl (8.5 kB) +Downloading click-8.3.1-py3-none-any.whl (108 kB) +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 108.3/108.3 kB 254.2 MB/s eta 0:00:00 +Downloading decorator-5.2.1-py3-none-any.whl (9.2 kB) +Downloading huggingface_hub-1.4.1-py3-none-any.whl (553 kB) +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 553.3/553.3 kB 296.6 MB/s eta 0:00:00 +Downloading itsdangerous-2.2.0-py3-none-any.whl (16 kB) +Downloading joblib-1.5.3-py3-none-any.whl (309 kB) +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 309.1/309.1 kB 280.1 MB/s eta 0:00:00 +Downloading lazy_loader-0.4-py3-none-any.whl (12 kB) +Downloading msgpack-1.1.2-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl (426 kB) +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 426.2/426.2 kB 288.6 MB/s eta 0:00:00 +Downloading numba-0.64.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl (3.7 MB) +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 3.7/3.7 MB 222.1 MB/s eta 0:00:00 +Downloading packaging-26.0-py3-none-any.whl (74 kB) +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 74.4/74.4 kB 235.4 MB/s eta 0:00:00 +Downloading platformdirs-4.9.2-py3-none-any.whl (21 kB) +Downloading pooch-1.9.0-py3-none-any.whl (67 kB) +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 67.2/67.2 kB 216.7 MB/s eta 0:00:00 +Downloading pyyaml-6.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl (806 kB) +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 806.6/806.6 kB 276.6 MB/s eta 0:00:00 +Downloading regex-2026.2.19-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl (800 kB) +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 800.6/800.6 kB 238.8 MB/s eta 0:00:00 +Downloading safetensors-0.7.0-cp38-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl (507 kB) +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 507.2/507.2 kB 108.8 MB/s eta 0:00:00 +Downloading scikit_learn-1.8.0-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl (9.1 MB) +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 9.1/9.1 MB 112.5 MB/s eta 0:00:00 +Downloading soundfile-0.13.1-py2.py3-none-manylinux_2_28_x86_64.whl (1.3 MB) +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 1.3/1.3 MB 283.6 MB/s eta 0:00:00 +Downloading soxr-1.0.0-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl (242 kB) +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 242.6/242.6 kB 221.5 MB/s eta 0:00:00 +Downloading tokenizers-0.22.2-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl (3.3 MB) +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 3.3/3.3 MB 215.5 MB/s eta 0:00:00 +Downloading tqdm-4.67.3-py3-none-any.whl (78 kB) +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 78.4/78.4 kB 217.3 MB/s eta 0:00:00 +Downloading werkzeug-3.1.6-py3-none-any.whl (225 kB) +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 225.2/225.2 kB 273.0 MB/s eta 0:00:00 +Downloading typer_slim-0.24.0-py3-none-any.whl (3.4 kB) +Downloading cffi-2.0.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl (215 kB) +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 215.6/215.6 kB 254.4 MB/s eta 0:00:00 +Downloading hf_xet-1.2.0-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl (3.3 MB) +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 3.3/3.3 MB 248.7 MB/s eta 0:00:00 +Downloading httpx-0.28.1-py3-none-any.whl (73 kB) +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 73.5/73.5 kB 230.7 MB/s eta 0:00:00 +Downloading httpcore-1.0.9-py3-none-any.whl (78 kB) +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 78.8/78.8 kB 239.2 MB/s eta 0:00:00 +Downloading llvmlite-0.46.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl (56.3 MB) +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 56.3/56.3 MB 126.6 MB/s eta 0:00:00 +Downloading requests-2.32.5-py3-none-any.whl (64 kB) +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 64.7/64.7 kB 217.4 MB/s eta 0:00:00 +Downloading threadpoolctl-3.6.0-py3-none-any.whl (18 kB) +Downloading typer-0.24.1-py3-none-any.whl (56 kB) +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 56.1/56.1 kB 203.4 MB/s eta 0:00:00 +Downloading shellingham-1.5.4-py2.py3-none-any.whl (9.8 kB) +Downloading annotated_doc-0.0.4-py3-none-any.whl (5.3 kB) +Downloading certifi-2026.1.4-py3-none-any.whl (152 kB) +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 152.9/152.9 kB 241.3 MB/s eta 0:00:00 +Downloading charset_normalizer-3.4.4-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl (151 kB) +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 151.6/151.6 kB 270.0 MB/s eta 0:00:00 +Downloading idna-3.11-py3-none-any.whl (71 kB) +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 71.0/71.0 kB 234.0 MB/s eta 0:00:00 +Downloading rich-14.3.3-py3-none-any.whl (310 kB) +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 310.5/310.5 kB 281.4 MB/s eta 0:00:00 +Downloading urllib3-2.6.3-py3-none-any.whl (131 kB) +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 131.6/131.6 kB 249.0 MB/s eta 0:00:00 +Downloading anyio-4.12.1-py3-none-any.whl (113 kB) +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 113.6/113.6 kB 261.6 MB/s eta 0:00:00 +Downloading pycparser-3.0-py3-none-any.whl (48 kB) +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 48.2/48.2 kB 195.1 MB/s eta 0:00:00 +Downloading h11-0.16.0-py3-none-any.whl (37 kB) +Downloading markdown_it_py-4.0.0-py3-none-any.whl (87 kB) +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 87.3/87.3 kB 231.8 MB/s eta 0:00:00 +Downloading pygments-2.19.2-py3-none-any.whl (1.2 MB) +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 1.2/1.2 MB 263.3 MB/s eta 0:00:00 +Downloading mdurl-0.1.2-py3-none-any.whl (10.0 kB) +Installing collected packages: pydub, addict, werkzeug, urllib3, tqdm, threadpoolctl, termcolor, shellingham, safetensors, regex, pyyaml, pygments, pycparser, platformdirs, packaging, numpy, msgpack, mdurl, llvmlite, joblib, itsdangerous, idna, hf-xet, h11, decorator, click, charset_normalizer, certifi, blinker, audioread, annotated-doc, yapf, soxr, scipy, requests, numba, markdown-it-py, lazy_loader, httpcore, gunicorn, flask, cffi, anyio, soundfile, scikit-learn, rich, pooch, httpx, flask-cors, typer, librosa, typer-slim, huggingface-hub, tokenizers, transformers +Successfully installed addict-2.4.0 annotated-doc-0.0.4 anyio-4.12.1 audioread-3.1.0 blinker-1.9.0 certifi-2026.1.4 cffi-2.0.0 charset_normalizer-3.4.4 click-8.3.1 decorator-5.2.1 flask-3.1.3 flask-cors-6.0.2 gunicorn-25.1.0 h11-0.16.0 hf-xet-1.2.0 httpcore-1.0.9 httpx-0.28.1 huggingface-hub-1.4.1 idna-3.11 itsdangerous-2.2.0 joblib-1.5.3 lazy_loader-0.4 librosa-0.11.0 llvmlite-0.46.0 markdown-it-py-4.0.0 mdurl-0.1.2 msgpack-1.1.2 numba-0.64.0 numpy-2.4.2 packaging-26.0 platformdirs-4.9.2 pooch-1.9.0 pycparser-3.0 pydub-0.25.1 pygments-2.19.2 pyyaml-6.0.3 regex-2026.2.19 requests-2.32.5 rich-14.3.3 safetensors-0.7.0 scikit-learn-1.8.0 scipy-1.17.1 shellingham-1.5.4 soundfile-0.13.1 soxr-1.0.0 termcolor-3.3.0 threadpoolctl-3.6.0 tokenizers-0.22.2 tqdm-4.67.3 transformers-5.2.0 typer-0.24.1 typer-slim-0.24.0 urllib3-2.6.3 werkzeug-3.1.6 yapf-0.43.0 +WARNING: Running pip as the 'root' user can result in broken permissions and conflicting behaviour with the system package manager. It is recommended to use a virtual environment instead: https://pip.pypa.io/warnings/venv +[notice] A new release of pip is available: 24.0 -> 26.0.1 +[notice] To update, run: pip install --upgrade pip +Removing intermediate container 5f48660704b5 +dbe2de7f19ea +Step 7/13 : COPY . . +f3329982d236 +Step 8/13 : RUN mkdir -p /tmp/audio2exp_logs/model +Running in 407b5d1290cc +Removing intermediate container 407b5d1290cc +51fed517d8ce +Step 9/13 : ENV PORT=8080 +Running in 8b4fb79badac +Removing intermediate container 8b4fb79badac +cec5f9b971c1 +Step 10/13 : ENV MODEL_DIR=/app/models +Running in 5f1a538db543 +Removing intermediate container 5f1a538db543 +9726be746a7d +Step 11/13 : ENV DEVICE=cpu +Running in fbcac00f9b89 +Removing intermediate container fbcac00f9b89 +32c1139d4897 +Step 12/13 : EXPOSE 8080 +Running in 2cd283d95fd8 +Removing intermediate container 2cd283d95fd8 +532e18aa840a +Step 13/13 : CMD gunicorn --bind "0.0.0.0:${PORT}" --timeout 120 --workers 1 --threads 4 app:app +Running in a707288f0327 +Removing intermediate container a707288f0327 +01598402630c +Successfully built 01598402630c +Successfully tagged asia-northeast1-docker.pkg.dev/hp-support-477512/cloud-run-source-deploy/audio2exp-service:latest +PUSH +Pushing asia-northeast1-docker.pkg.dev/hp-support-477512/cloud-run-source-deploy/audio2exp-service +The push refers to repository [asia-northeast1-docker.pkg.dev/hp-support-477512/cloud-run-source-deploy/audio2exp-service] +2caef208a424: Preparing +2148c67bb51f: Preparing +7f7c80e6172b: Preparing +2469ed036030: Preparing +4f2d83b69374: Preparing +8620130a8c9f: Preparing +386258737996: Preparing +b69aea4cac7d: Preparing +40b88e8d19a2: Preparing +dfd9efb4ec4c: Preparing +a8ff6f8cbdfd: Preparing +a8ff6f8cbdfd: Waiting +name unknown: Repository "cloud-run-source-deploy" not found +ERROR: push attempt 1 detected failure, retrying: step exited with non-zero status: 1 +Pushing asia-northeast1-docker.pkg.dev/hp-support-477512/cloud-run-source-deploy/audio2exp-service +The push refers to repository [asia-northeast1-docker.pkg.dev/hp-support-477512/cloud-run-source-deploy/audio2exp-service] +2caef208a424: Preparing +2148c67bb51f: Preparing +7f7c80e6172b: Preparing +2469ed036030: Preparing +4f2d83b69374: Preparing +2469ed036030: Waiting +8620130a8c9f: Preparing +386258737996: Preparing +b69aea4cac7d: Preparing +40b88e8d19a2: Preparing +dfd9efb4ec4c: Preparing +a8ff6f8cbdfd: Preparing +a8ff6f8cbdfd: Waiting +name unknown: Repository "cloud-run-source-deploy" not found +ERROR: push attempt 2 detected failure, retrying: step exited with non-zero status: 1 +Pushing asia-northeast1-docker.pkg.dev/hp-support-477512/cloud-run-source-deploy/audio2exp-service +The push refers to repository [asia-northeast1-docker.pkg.dev/hp-support-477512/cloud-run-source-deploy/audio2exp-service] +2caef208a424: Preparing +2148c67bb51f: Preparing +7f7c80e6172b: Preparing +2469ed036030: Preparing +4f2d83b69374: Preparing +8620130a8c9f: Preparing +386258737996: Preparing +b69aea4cac7d: Preparing +40b88e8d19a2: Preparing +dfd9efb4ec4c: Preparing +40b88e8d19a2: Waiting +a8ff6f8cbdfd: Preparing +dfd9efb4ec4c: Waiting +name unknown: Repository "cloud-run-source-deploy" not found +ERROR: push attempt 3 detected failure, retrying: step exited with non-zero status: 1 +Pushing asia-northeast1-docker.pkg.dev/hp-support-477512/cloud-run-source-deploy/audio2exp-service +The push refers to repository [asia-northeast1-docker.pkg.dev/hp-support-477512/cloud-run-source-deploy/audio2exp-service] +2caef208a424: Preparing +2148c67bb51f: Preparing +7f7c80e6172b: Preparing +2469ed036030: Preparing +4f2d83b69374: Preparing +8620130a8c9f: Preparing +386258737996: Preparing +b69aea4cac7d: Preparing +40b88e8d19a2: Preparing +40b88e8d19a2: Waiting +b69aea4cac7d: Waiting +dfd9efb4ec4c: Preparing +dfd9efb4ec4c: Waiting +a8ff6f8cbdfd: Preparing +name unknown: Repository "cloud-run-source-deploy" not found +ERROR: push attempt 4 detected failure, retrying: step exited with non-zero status: 1 +Pushing asia-northeast1-docker.pkg.dev/hp-support-477512/cloud-run-source-deploy/audio2exp-service +The push refers to repository [asia-northeast1-docker.pkg.dev/hp-support-477512/cloud-run-source-deploy/audio2exp-service] +2caef208a424: Preparing +2148c67bb51f: Preparing +7f7c80e6172b: Preparing +2469ed036030: Preparing +4f2d83b69374: Preparing +8620130a8c9f: Preparing +386258737996: Preparing +b69aea4cac7d: Preparing +b69aea4cac7d: Waiting +386258737996: Waiting +40b88e8d19a2: Preparing +dfd9efb4ec4c: Preparing +a8ff6f8cbdfd: Preparing +name unknown: Repository "cloud-run-source-deploy" not found +ERROR: push attempt 5 detected failure, retrying: step exited with non-zero status: 1 +Pushing asia-northeast1-docker.pkg.dev/hp-support-477512/cloud-run-source-deploy/audio2exp-service +The push refers to repository [asia-northeast1-docker.pkg.dev/hp-support-477512/cloud-run-source-deploy/audio2exp-service] +2caef208a424: Preparing +2148c67bb51f: Preparing +7f7c80e6172b: Preparing +2469ed036030: Preparing +4f2d83b69374: Preparing +8620130a8c9f: Preparing +386258737996: Preparing +b69aea4cac7d: Preparing +40b88e8d19a2: Preparing +dfd9efb4ec4c: Preparing +40b88e8d19a2: Waiting +a8ff6f8cbdfd: Preparing +name unknown: Repository "cloud-run-source-deploy" not found +ERROR: push attempt 6 detected failure, retrying: step exited with non-zero status: 1 +Pushing asia-northeast1-docker.pkg.dev/hp-support-477512/cloud-run-source-deploy/audio2exp-service +The push refers to repository [asia-northeast1-docker.pkg.dev/hp-support-477512/cloud-run-source-deploy/audio2exp-service] +2caef208a424: Preparing +2148c67bb51f: Preparing +7f7c80e6172b: Preparing +2469ed036030: Preparing +4f2d83b69374: Preparing +8620130a8c9f: Preparing +386258737996: Preparing +b69aea4cac7d: Preparing +386258737996: Waiting +40b88e8d19a2: Preparing +40b88e8d19a2: Waiting +dfd9efb4ec4c: Preparing +a8ff6f8cbdfd: Preparing +name unknown: Repository "cloud-run-source-deploy" not found +ERROR: push attempt 7 detected failure, retrying: step exited with non-zero status: 1 +Pushing asia-northeast1-docker.pkg.dev/hp-support-477512/cloud-run-source-deploy/audio2exp-service +The push refers to repository [asia-northeast1-docker.pkg.dev/hp-support-477512/cloud-run-source-deploy/audio2exp-service] +2caef208a424: Preparing +2148c67bb51f: Preparing +7f7c80e6172b: Preparing +2469ed036030: Preparing +4f2d83b69374: Preparing +8620130a8c9f: Preparing +386258737996: Preparing +b69aea4cac7d: Preparing +40b88e8d19a2: Preparing +dfd9efb4ec4c: Preparing +a8ff6f8cbdfd: Preparing +name unknown: Repository "cloud-run-source-deploy" not found +ERROR: push attempt 8 detected failure, retrying: step exited with non-zero status: 1 +Pushing asia-northeast1-docker.pkg.dev/hp-support-477512/cloud-run-source-deploy/audio2exp-service +The push refers to repository [asia-northeast1-docker.pkg.dev/hp-support-477512/cloud-run-source-deploy/audio2exp-service] +2caef208a424: Preparing +2148c67bb51f: Preparing +7f7c80e6172b: Preparing +2469ed036030: Preparing +4f2d83b69374: Preparing +8620130a8c9f: Preparing +386258737996: Preparing +b69aea4cac7d: Preparing +40b88e8d19a2: Preparing +b69aea4cac7d: Waiting +dfd9efb4ec4c: Preparing +a8ff6f8cbdfd: Preparing +name unknown: Repository "cloud-run-source-deploy" not found +ERROR: push attempt 9 detected failure, retrying: step exited with non-zero status: 1 +Pushing asia-northeast1-docker.pkg.dev/hp-support-477512/cloud-run-source-deploy/audio2exp-service +The push refers to repository [asia-northeast1-docker.pkg.dev/hp-support-477512/cloud-run-source-deploy/audio2exp-service] +2caef208a424: Preparing +2148c67bb51f: Preparing +7f7c80e6172b: Preparing +2469ed036030: Preparing +4f2d83b69374: Preparing +8620130a8c9f: Preparing +386258737996: Preparing +b69aea4cac7d: Preparing +40b88e8d19a2: Preparing +dfd9efb4ec4c: Preparing +a8ff6f8cbdfd: Preparing +a8ff6f8cbdfd: Waiting +name unknown: Repository "cloud-run-source-deploy" not found +ERROR: push attempt 10 detected failure, retrying: step exited with non-zero status: 1 +ERROR: failed to push because we ran out of retries. +ERROR +ERROR: error pushing image "asia-northeast1-docker.pkg.dev/hp-support-477512/cloud-run-source-deploy/audio2exp-service": retry budget exhausted (10 attempts): step exited with non-zero status: 1 +BUILD FAILURE: Docker image push failure ("The push refers to repository [asia-northeast1-docker.pkg.dev/hp-support-477512/cloud-run-source-deploy/audio2exp-service] +2caef208a424: Preparing +2148c67bb51f: Preparing +7f7c80e6172b: Preparing +2469ed036030: Preparing +4f2d83b69374: Preparing +8620130a8c9f: Preparing +386258737996: Preparing +b69aea4cac7d: Preparing +40b88e8d19a2: Preparing +dfd9efb4ec4c: Preparing +a8ff6f8cbdfd: Preparing +a8ff6f8cbdfd: Waiting +name unknown: Repository "cloud-run-source-deploy" not found +"): Verify that the repositor(y|ies) asia-northeast1-docker.pkg.dev/hp-support-477512/cloud-run-source-deploy/audio2exp-service exists. +ERROR: (gcloud.builds.submit) build 914136ba-08c0-4603-8eec-197871482fc3 completed with status "FAILURE" + +Show more + + +会話ログを詳細に確認しました。以下が確定した事実です。 + +ログから確認した現状 +最新リビジョン: audio2exp-service-00022-wx2 (torchaudio追加済み) + +Cloud Run設定: + +Port: 8080, Memory: 4Gi, CPU: 2 +Startup Probe: TCP (デフォルト) → gunicorn起動直後にパス +min-instances: 0 +ログで確認した問題: + +gunicorn起動 00:08:34 → エンジンロード開始 00:09:04 → INFER warmup開始 00:23:50 → その後ログが途切れている +/health が {"engine_ready":false,"status":"loading"} を返し続ける +warmup推論がハングしている可能性大 (最後のログから新しいエントリなし) +根本原因: INFER pipeline の warmup (infer_streaming_audio で1秒のダミー音声を推論) がCPU環境で完了しない/極端に遅い。エンジンが _ready = True にならないため、ヘルスチェックが永久に loading のまま。 + +これから修正に入ります。 + + +Completed health check investigation and identified root cause in audio2exp-service +Completed health check investigation and identified root cause in audio2exp-service + +I'll continue implementing the fixes. Let me first re-read a2e_engine.py to refresh on the exact code, then make all three changes. + + +Read audio2expression inference engine implementation +Read audio2expression inference engine implementation + +Good, I have the full picture. Let me now implement all three fixes. The root cause is that the warmup inference at line 314-318 hangs indefinitely on CPU, preventing _ready from ever becoming True. + + +Reviewed repository structure, investigated health check code, identified health check failure causes +Reviewed repository structure, investigated health check code, identified health check failure causes + +Now let me implement all three file changes. Starting with app.py - adding background engine loading so gunicorn can respond to startup probes immediately. + + +Investigated repository structure, health check code, and identified root causes of failures +Investigated repository structure, health check code, and identified root causes of failures + +Now fix a2e_engine.py - add a timeout to the warmup inference so it doesn't hang forever. + + +Updated imports in audio2exp service engine file +Updated imports in audio2exp service engine file + +Now replace the warmup section with a timeout-protected version. + + +Updated warmup inference error handling in audio engine +Updated warmup inference error handling in audio engine +https://github.com/mirai-gpro/LAM_gpro/blob/claude/poc-testing-support-ny8D1/docs/SESSION_HANDOFF.md +https://github.com/mirai-gpro/LAM_gpro/blob/claude/poc-testing-support-ny8D1/docs/SYSTEM_ARCHITECTURE.md +読んで、プロジェクトの全体象を把握して + + + +Now fix the Dockerfile to match the deployed version - PORT 8080, shell form CMD, and add torchaudio. + + +Created Dockerfile for audio2exp service with Python 3.11 +Created Dockerfile for audio2exp service with Python 3.11 + +Also update start.sh to use the correct default port. + + +Reviewed repository structure, investigated health check code, identified NG cause, updated task tracking system +Reviewed repository structure, investigated health check code, identified NG cause, updated task tracking system + +Now let me commit and push these changes. + + +Reviewed git status, diffs, and recent commits for audio2exp-service changes +Reviewed git status, diffs, and recent commits for audio2exp-service changes +もっと表示 +もっと表示 +もっと表示 + +The diff looks correct. Let me commit and push. + + +Investigated audio2exp-service health check failures and identified root causes +Investigated audio2exp-service health check failures and identified root causes +もっと表示 + +Health check fix is committed and pushed. Now let me read the two documents you referenced. + + +Reviewed repository structure, investigated health check issues, identified root causes +Reviewed repository structure, investigated health check issues, identified root causes +もっと表示 +もっと表示 + +両ドキュメントを読みました。プロジェクト全体像をまとめます。 + +プロジェクト全体像 +ゴール +論文超えクオリティの3D対話アバターを、バックエンドGPUなしで、iPhone SE単体で軽く動かす。即実用のアルファ版。 + +システム構成 (3サービス) +gourmet-sp (Vercel) gourmet-support (Cloud Run) audio2exp-service (Cloud Run) +Astro + TypeScript Flask + SocketIO Flask +フロントエンド メインバックエンド A2E推論マイクロサービス +├ 3Dアバター (GVRM) ├ Gemini 2.0 Flash (LLM) ├ Wav2Vec2 (360MB) +├ リップシンク (FFT/A2E) ├ Google Cloud TTS └ A2E Decoder (50MB) +└ AudioManager (STT) ├ Google Cloud STT (Chirp2) → 52dim ARKit @30fps + ├ HotPepper API + └ Firestore (長期記憶) + +データフロー +音声入力: マイク → AudioWorklet (48kHz→16kHz) → STT (Chirp2) → テキスト +LLM応答: テキスト → Gemini 2.0 Flash → レスポンス +TTS + A2E: テキスト → Google TTS (MP3) → audio2exp-service → 52次元ブレンドシェイプ +アバター描画: MP3再生 + A2Eフレーム同期 → GVRM (Gaussian Splatting) → Canvas +LAM技術の核心 +アバター生成 (サーバー1回のみ): 写真1枚 → FLAME + DINOv2 + Transformer → 81,424 Gaussian Head Avatar +アニメーション (クライアント毎フレーム): ニューラルネットワーク不要。純粋な行列演算 (FLAME LBS) で52次元ARKit係数から表情駆動 +レンダリング (WebGL): gaussian-splat-renderer-for-lam npm パッケージ。iPhone 16で35FPS実証済み +完了済み +audio2exp-service (Flask API + Docker + Cloud Run設定) +フロントエンドパッチ (vrm-expression-manager.ts, concierge-controller.ts) — 未適用 +日本語テストスイート — 未実行 +今回のヘルスチェック修正 (warmupタイムアウト + バックグラウンドロード) +未完了・最重要 +項目 状態 +iPhone SEでのWebGLレンダリング検証 (81,424 Gaussianが30FPS出るか) 未着手 +A2Eのオンデバイス化 (MFCC + 軽量モデル or ONNX) 未着手 +表情・頭の動きの自然さ向上 (瞬き、眉、頭の動き) 未着手 +エンドツーエンド統合テスト 未実行 +アーキテクチャ候補 (未決定) +A: LAM WebGL SDK + サーバーA2E (現行延長) +B: Three.js + GLBメッシュ + オンデバイスA2E (Gaussian品質を犠牲) +C: ネイティブiOS (SceneKit/RealityKit + CoreML) +D: ハイブリッド (LAM WebGL + TTS事前A2E) +iPhone SEでの実機検証結果がアーキテクチャ選択の分岐点です。 + +先ほどのヘルスチェック修正はコミット 81ece38 でブランチ claude/fix-health-check-ap3De にプッシュ済みです。 + +ビルド、デプロイ コマンドを教えて + + + +Read and reviewed A2E deployment guide architecture +Read and reviewed A2E deployment guide architecture + +前回のデプロイ実績とDEPLOYMENT_GUIDEを踏まえて、実際に使うべきコマンドをまとめます。 + +ビルド & デプロイ手順 +ローカルPC(C:\Users\hamad\audio2exp-service)で実行してください。 + +Step 1: リポジトリの変更を取り込む +cd C:\Users\hamad\audio2exp-service +git pull origin claude/fix-health-check-ap3De + +または、変更済みファイル(app.py, a2e_engine.py, Dockerfile)を手動コピー。 + +Step 2: Docker ビルド +docker build -t audio2exp-service . + +Step 3: GCR にタグ付け & プッシュ +docker tag audio2exp-service gcr.io/hp-support-477512/audio2exp-service +docker push gcr.io/hp-support-477512/audio2exp-service + +注意: 前回 asia-northeast1-docker.pkg.dev へのプッシュは失敗しています。gcr.io が正しいレジストリです。 + +Step 4: Cloud Run デプロイ +gcloud run deploy audio2exp-service \ + --image gcr.io/hp-support-477512/audio2exp-service \ + --platform managed \ + --region us-central1 \ + --memory 4Gi \ + --cpu 2 \ + --timeout 120 \ + --min-instances 0 \ + --max-instances 3 \ + --set-env-vars "MODEL_DIR=/app/models,DEVICE=cpu,WARMUP_TIMEOUT=120" \ + --project hp-support-477512 + +Step 5: ヘルスチェック確認 +curl https://audio2exp-service-417509577941.us-central1.run.app/health + +期待されるレスポンス(ロード中): + +{"engine_ready": false, "model_dir": "/app/models", "status": "loading"} + +期待されるレスポンス(ロード完了後): + +{"engine_ready": true, "mode": "infer", "status": "healthy", ...} + +前回との主な変更点 +項目 前回(NG) 今回(修正後) +エンジンロード 同期(gunicorn起動をブロック) バックグラウンドスレッド +ヘルスチェック ロード完了まで応答不可 ロード中でも200 + "status":"loading" +warmup推論 タイムアウトなし(ハング) SIGALRM 120秒タイムアウト +Dockerfile PORT 8081(Cloud Runと不一致) 8080(一致) +torchaudio 別途手動追加が必要だった Dockerfile内で自動インストール +docker build -t audio2exp-service . + +docker: The term 'docker' is not recognized as a name of a cmdlet, function, script file, or executable program. +Check the spelling of the name, or if a path was included, verify that the path is correct and try again. +PS C:\Users\hamad\audio2exp-service> + + + +Docker が入っていないですね。前回のデプロイも Cloud Build(--sourceオプション) で成功しています。ローカルDockerは不要です。 + +方法: gcloud run deploy --source . を使う +cd C:\Users\hamad\audio2exp-service + +gcloud run deploy audio2exp-service \ + --source . \ + --project hp-support-477512 \ + --region us-central1 \ + --memory 4Gi \ + --cpu 2 \ + --timeout 120 \ + --min-instances 0 \ + --max-instances 3 \ + --set-env-vars "MODEL_DIR=/app/models,DEVICE=cpu,WARMUP_TIMEOUT=120" + +これで Google Cloud Build がクラウド上で Docker ビルド → GCR プッシュ → Cloud Run デプロイまで全部やってくれます。 + +注意: modelsディレクトリ(wav2vec2-base-960h等)がローカルにある場合、アップロードに時間がかかります(~1.8GB)。前回と同じ構成なら問題ないはずです。 + +cd C:\Users\hamad\audio2exp-service + +gcloud run deploy audio2exp-service +--source . +--project hp-support-477512 +--region us-central1 +--memory 4Gi +--cpu 2 +--timeout 120 +--min-instances 0 +--max-instances 3 +--set-env-vars "MODEL_DIR=/app/models,DEVICE=cpu,WARMUP_TIMEOUT=120" + +ParserError: +Line | +4 | --source . +| ~ +| Missing expression after unary operator '--'. +PS C:\Users\hamad\audio2exp-service> + +Show more + + +PowerShell では \ で改行できません。1行で実行してください: + +gcloud run deploy audio2exp-service --source . --project hp-support-477512 --region us-central1 --memory 4Gi --cpu 2 --timeout 120 --min-instances 0 --max-instances 3 --set-env-vars "MODEL_DIR=/app/models,DEVICE=cpu,WARMUP_TIMEOUT=120" + +gcloud run deploy audio2exp-service --source . --project hp-support-477512 --region us-central1 --memory 4Gi --cpu 2 --timeout 120 --min-instances 0 --max-instances 3 --set-env-vars "MODEL_DIR=/app/models,DEVICE=cpu,WARMUP_TIMEOUT=120" + +Building using Dockerfile and deploying container to Cloud Run service [audio2exp-service] in project [hp-support-477512] region [us-central1] +OK Building and deploying... Done. +OK Validating Service... +OK Uploading sources... +OK Building Container... Logs are available at [https://console.cloud.google.com/cloud-build/builds;region=us-central +1/0a7f8dc1-87aa-4f55-a1c5-6f8686c0d418?project=417509577941]. +OK Creating Revision... +OK Routing traffic... +Done. +Service [audio2exp-service] revision [audio2exp-service-00023-jmn] has been deployed and is serving 100 percent of traffic. +Service URL: https://audio2exp-service-417509577941.us-central1.run.app +PS C:\Users\hamad\audio2exp-service> + +Show more + + +デプロイ成功です!サービスが稼働しています: + +Service URL: https://audio2exp-service-417509577941.us-central1.run.app + +リビジョン audio2exp-service-00023-jmn がトラフィックの100%を処理中です。 + +ヘルスチェックやエンドポイントのテストをしたい場合は: + +curl https://audio2exp-service-417509577941.us-central1.run.app/health + +何か問題があれば教えてください。 + +curl https://audio2exp-service-417509577941.us-central1.run.app/health + +{"engine_ready":false,"model_dir":"/app/models","status":"loading"} +PS C:\Users\hamad\audio2exp-service> +さっきも、このloading状態が3h以上で、動いていないと判断した + + + +コードを確認して、なぜ永久に loading のままなのか原因を特定します。 + + +Launched async agent to diagnose Cloud Run service loading issue +Launched async agent to diagnose Cloud Run service loading issue +もっと表示 +もっと表示 +もっと表示 +もっと表示 +もっと表示 +もっと表示 +もっと表示 +もっと表示 +もっと表示 + +コードベースを調査中です。少々お待ちください... + + +Checked exploration agent progress output file +Checked exploration agent progress output file +もっと表示 + +まだ調査中です。もう少しお待ちください。 + + +Read Audio2Expression microservice implementation files +Read Audio2Expression microservice implementation files +もっと表示 +もっと表示 + +原因が分かりました。問題を整理します。 + + +Read .gitignore file in audio2exp-service directory +Read .gitignore file in audio2exp-service directory + +原因を特定しました。3つの問題があります: + +問題1: /app/models が空 + +.gitignore が models/ を除外 → gcloud run deploy --source . でもアップロードされない +コンテナ内の /app/models は常に空 +問題2: HuggingFace からのランタイムダウンロードがハング + +チェックポイントが無いので INFER パイプラインは失敗 +フォールバックで Wav2Vec2Model.from_pretrained("facebook/wav2vec2-base-960h") を実行 +Cloud Run 環境で HuggingFace からの ~360MB ダウンロードがハングまたは極端に遅い +問題3: signal.SIGALRM をバックグラウンドスレッドで使用 + +Python は signal.signal() をメインスレッド以外から呼べない → ValueError が発生 +修正します。 + + +Updated todo list with Dockerfile fix and signal handling task +Updated todo list with Dockerfile fix and signal handling task + +Now fix the signal.SIGALRM issue in a2e_engine.py - use threading.Timer instead since it runs in a background thread. + + +Updated imports in a2e_engine.py file +Updated imports in a2e_engine.py file + +Now fix the warmup section to use threading.Timer instead of signal.SIGALRM. + + +Updated warmup inference timeout handling in audio2exp engine +Updated warmup inference timeout handling in audio2exp engine + +Now update the wav2vec fallback to use the cached model from the Docker build. + + +Fixed Dockerfile and signal handling in a2e_engine.py +Fixed Dockerfile and signal handling in a2e_engine.py + +Now improve app.py - add a loading timeout and better health diagnostics. + + +Updated Docker configuration for audio2exp-service +Updated Docker configuration for audio2exp-service + +変更内容は良さそうです。コミットしてプッシュします。 + + +Updated todo list marking Dockerfile and signal handling fixes complete +Updated todo list marking Dockerfile and signal handling fixes complete + +修正をプッシュしました。変更の要約: + +原因 (3つ) +# 問題 影響 +1 .gitignore が models/ を除外 → コンテナ内 /app/models が空 フォールバックで HuggingFace から ~360MB のダウンロードを試行 → ハング +2 signal.SIGALRM をバックグラウンドスレッドで使用 Python は ValueError を出す (メインスレッド専用) +3 エンジンロードにタイムアウトなし ヘルスチェックが永遠に loading を返す +修正内容 +Dockerfile: wav2vec2-base-960h を ビルド時にダウンロード してイメージに焼き込み。HF_HUB_OFFLINE=1 でランタイムのHuggingFaceアクセスを無効化 +a2e_engine.py: signal.SIGALRM → threading.Event + ワーカースレッドに変更。wav2vec2 のキャッシュディレクトリ対応追加 +app.py: 300秒のロードタイムアウト追加。タイムアウト後は 503 エラーに切り替え +次のステップ +ローカルで再デプロイしてください: + +gcloud run deploy audio2exp-service --source . --project hp-support-477512 --region us-central1 --memory 4Gi --cpu 2 --timeout 120 --min-instances 0 --max-instances 3 --set-env-vars "MODEL_DIR=/app/models,DEVICE=cpu,WARMUP_TIMEOUT=120" + +ビルドに時間がかかりますが(wav2vec2 ダウンロード含む)、デプロイ後はヘルスチェックが数十秒で healthy になるはずです。 + +修正はすでにプッシュ済みです。ローカルPCで再デプロイしてください: + +gcloud run deploy audio2exp-service --source . --project hp-support-477512 --region us-central1 --memory 4Gi --cpu 2 --timeout 120 --min-instances 0 --max-instances 3 --set-env-vars "MODEL_DIR=/app/models,DEVICE=cpu,WARMUP_TIMEOUT=120" + +ビルドは wav2vec2 モデルのダウンロード分少し長くなりますが、デプロイ後はローカルキャッシュから読み込むため数十秒で healthy になるはずです。 + +問題1: /app/models が空 +問題2: HuggingFace からのランタイムダウンロードがハング +問題3: signal.SIGALRM をバックグラウンドスレッドで使用 +これは、さっきのセクションの同じ分析をして対処したはずだけど、どうなってる?? +まず、modelsは、C:\Users\hamad\audio2exp-service\models にすべて保存済。 +対応するように変更したはずだけど、対処できてない? + + + +現在のコード状態を確認します。 + + +Examined audio2exp-service files for current code state +Examined audio2exp-service files for current code state + +現状を確認しました。問題点が明確です。 + +signal.SIGALRM → 既に threading.Timer に修正済み(OK) + +しかし、以下2点が未修正: + +Dockerfile が models/ を COPY していない — RUN mkdir -p /app/models で空ディレクトリを作るだけ。wav2vec2 は HuggingFace からビルド時ダウンロードしているが、pretrained_models/lam_audio2exp_streaming.tar 等のローカルモデルは含まれていない。 + +wav2vec2 のキャッシュパスが不一致 — ビルド時に /app/models/wav2vec2-base-960h-cache にダウンロードするが、a2e_engine.py は最初に wav2vec2-base-960h(cacheなし)を探し、見つからないとフォールバックする。 + +ローカルの models/ ディレクトリの中身を確認します。 + + +Read audio2exp-service app.py file and checked for missing Docker/GCloud configuration files +Read audio2exp-service app.py file and checked for missing Docker/GCloud configuration files + +INFER パイプライン内部の wav2vec2 ロード処理も確認します。 + + +Analyzed wav2vec2 model loading in audio2exp-service inference engine +Analyzed wav2vec2 model loading in audio2exp-service inference engine +もっと表示 + +問題の全体像が見えました。修正します。 + +根本原因 3つ: + +# 問題 現在のコード 結果 +1 Dockerfile が wav2vec2 を cache 形式 で保存 cache_dir='/app/models/wav2vec2-base-960h-cache' _find_wav2vec_dir() が config.json を探すが、cache形式には直下に無い → 見つからない +2 wav2vec2 見つからない時 HuggingFace ID にフォールバック wav2vec_dir = "facebook/wav2vec2-base-960h" HF_HUB_OFFLINE=1 なのに DL 試行 → ハング +3 gunicorn --timeout 120 --threads 4 モデルロードに120秒以上 + マルチスレッド ワーカーキルされて無限再起動 + +Fixed Dockerfile for wav2vec2 model and gunicorn configuration +Fixed Dockerfile for wav2vec2 model and gunicorn configuration + +次に a2e_engine.py の HuggingFace フォールバックを除去します。 + + +Modified wav2vec2 directory discovery logic in a2e_engine.py +Modified wav2vec2 directory discovery logic in a2e_engine.py + +次に _load_wav2vec_fallback() からも HuggingFace ダウンロードのフォールバックを除去します。 + + +Fixed Dockerfile and a2e_engine.py configuration issues +Fixed Dockerfile and a2e_engine.py configuration issues + +修正内容を確認してからコミットします。 + + +Reviewed Docker and Python service configuration changes +Reviewed Docker and Python service configuration changes +もっと表示 +もっと表示 + +差分も正しいです。コミットしてプッシュします。 + + +Fixed Dockerfile and a2e_engine.py configuration issues +Fixed Dockerfile and a2e_engine.py configuration issues + +修正完了。変更内容のまとめ: + +修正した3つの根本原因 +1. Dockerfile — wav2vec2 の保存形式が不正 + +Before: cache_dir='/app/models/wav2vec2-base-960h-cache' → HF cache 形式(ハッシュ付きサブディレクトリ構造、config.json が直下にない) +After: save_pretrained('/app/models/wav2vec2-base-960h') → 標準形式(config.json が直下にある) +_find_wav2vec_dir() は (path / "config.json").exists() をチェックするので、これで発見される +2. a2e_engine.py — HuggingFace ランタイムダウンロードのフォールバック除去 + +Before: wav2vec2 見つからない → "facebook/wav2vec2-base-960h" にフォールバック → HF_HUB_OFFLINE=1 なのに DL 試行 → ハング +After: wav2vec2 見つからない → 即座に return False or raise RuntimeError → エラーとして報告 +3. Dockerfile CMD — gunicorn 設定修正 + +Before: --timeout 120 --threads 4 +After: --timeout 300 --threads 1 +再デプロイ +ローカル PC から: + +cd C:\Users\hamad\audio2exp-service +gcloud run deploy audio2exp-service --source . --project hp-support-477512 --region us-central1 --memory 4Gi --cpu 2 --timeout 300 --min-instances 0 --max-instances 3 --set-env-vars "MODEL_DIR=/app/models,DEVICE=cpu,WARMUP_TIMEOUT=240" + +curl https://audio2exp-service-417509577941.us-central1.run.app/health + +{"elapsed_seconds":0,"engine_ready":false,"model_dir":"/app/models","status":"loading","timeout_seconds":300} +PS C:\Users\hamad\audio2exp-service> + + + +elapsed_seconds: 0 なので、デプロイ直後でまだモデルロード中です。CPU上でのロードには1〜2分かかります。 + +30秒後にもう一度確認してください: + +# 30秒待ってから +curl https://audio2exp-service-417509577941.us-central1.run.app/health + +もしまだ loading のままなら、ログを確認しましょう: + +gcloud run services logs read audio2exp-service --project hp-support-477512 --region us-central1 --limit 50 + +ログに wav2vec2-base-960h not found locally や No A2E checkpoint found が出ていれば、Docker イメージ内の models/ ディレクトリの中身が不足していることが分かります。 + +gcloud run services logs read audio2exp-service --project hp-support-477512 --region us-central1 --limit 50 + +2026-02-23 07:14:30 [2026-02-23 07:14:30 +0000] [9] [INFO] Starting gunicorn 25.1.0 +2026-02-23 07:14:30 [2026-02-23 07:14:30 +0000] [9] [INFO] Listening at: http://0.0.0.0:8080 (9) +2026-02-23 07:14:30 [2026-02-23 07:14:30 +0000] [9] [INFO] Using worker: sync +2026-02-23 07:14:30 [2026-02-23 07:14:30 +0000] [9] [INFO] Control socket listening at /app/gunicorn.ctl +2026-02-23 07:14:30 [2026-02-23 07:14:30 +0000] [11] [INFO] Booting worker with pid: 11 +2026-02-23 07:15:00 2026-02-23 07:15:00,193 [INFO] [Audio2Exp] Server started, engine loading in background... +2026-02-23 07:15:24 2026-02-23 07:15:24,791 [INFO] [Audio2Exp] Initializing engine: model_dir=/app/models, device=cpu +2026-02-23 07:22:15 2026-02-23 07:22:15,894 [INFO] [A2E Engine] Device: cpu +2026-02-23 07:22:15 2026-02-23 07:22:15,895 [INFO] [A2E Engine] Found LAM_Audio2Expression: /app/LAM_Audio2Expression +2026-02-23 07:22:16 2026-02-23 07:22:16,089 [INFO] [A2E Engine] Checkpoint: /app/models/pretrained_models/lam_audio2exp_streaming.tar +2026-02-23 07:22:16 2026-02-23 07:22:16,089 [INFO] [A2E Engine] Wav2Vec2: /app/models/wav2vec2-base-960h +2026-02-23 07:31:07 GET 200 https://audio2exp-service-417509577941.us-central1.run.app/health +2026-02-23 07:31:09 [2026-02-23 07:31:09 +0000] [10] [INFO] Starting gunicorn 25.1.0 +2026-02-23 07:31:09 [2026-02-23 07:31:09 +0000] [10] [INFO] Listening at: http://0.0.0.0:8080 (10) +2026-02-23 07:31:09 [2026-02-23 07:31:09 +0000] [10] [INFO] Using worker: sync +2026-02-23 07:31:09 [2026-02-23 07:31:09 +0000] [10] [INFO] Control socket listening at /app/gunicorn.ctl +2026-02-23 07:31:09 [2026-02-23 07:31:09 +0000] [12] [INFO] Booting worker with pid: 12 +2026-02-23 07:31:10 2026-02-23 07:31:10,470 [INFO] [Audio2Exp] Server started, engine loading in background... +2026-02-23 07:31:24 2026-02-23 07:31:24,034 [INFO] [Audio2Exp] Initializing engine: model_dir=/app/models, device=cpu +PS C:\Users\hamad\audio2exp-service> + +Show more + + +ログから分かること: + +良いニュース — 3つの修正は効いています: + +Found LAM_Audio2Expression: /app/LAM_Audio2Expression ✓ モジュール発見 +Checkpoint: /app/models/pretrained_models/lam_audio2exp_streaming.tar ✓ チェックポイント発見 +Wav2Vec2: /app/models/wav2vec2-base-960h ✓ wav2vec2 発見(標準形式) + +問題 — CPU 上でのロードが遅すぎてコンテナが再起動される: + +07:15:24 Engine init 開始 +07:22:15 Device検出完了 ← 7分(import torch が重い) +07:22:16 モデルファイル発見 +... ← INFER.build() 実行中(9分+) +07:31:07 Health check → まだ loading +07:31:09 コンテナ再起動! ← 最初からやり直し → 無限ループ + +合計 16分以上かかっているのに、Cloud Run が min-instances=0 のためアイドル状態のコンテナを殺して再起動しています。永遠に完了しません。 + +2つ対策します。コードの最適化とデプロイ設定の変更。 + + +Read audio2exp service engine initialization code +Read audio2exp service engine initialization code + +ログの問題を分析しました。 + +タイムライン: + +07:15:24 __init__() 開始 +07:22:15 import torch 完了 ← 7分もかかっている(CPU 2コアで遅い) +07:22:16 INFER.build() 開始 + ... モデルロード + warmup ← 9分以上 +07:31:07 まだ loading +07:31:09 コンテナ再起動 → 最初からやり直し + +原因: sync ワーカー1スレッドでは、daemon スレッドの重い CPU 処理が GIL を握り、gunicorn のハートビートが止まる → ワーカーが殺される → マスターも終了 → Cloud Run がコンテナ再起動。 + +修正します。 + + +Updated Dockerfile environment variables and runtime configuration +Updated Dockerfile environment variables and runtime configuration + +次に a2e_engine.py のウォームアップ部分を、TIMEOUT=0 のときスキップするように修正します。 + + +Fixed gunicorn worker class, skipped warmup on CPU, committed and pushed changes +Fixed gunicorn worker class, skipped warmup on CPU, committed and pushed changes +もっと表示 + +プッシュ完了。今回の修正内容: + +修正1: gunicorn ワーカークラス変更 +Before: --workers 1 --threads 1 (sync ワーカー、1スレッド) +After: --worker-class gthread --workers 1 --threads 2 + +gthread ワーカーなら、daemon スレッドが GIL を握っていても別スレッドでハートビートとヘルスチェックに応答可能。 + +修正2: タイムアウト拡大 +Before: --timeout 300 (5分) +After: --timeout 600 (10分) + +import torch だけで 7 分かかっているので、余裕を持たせる。 + +修正3: warmup スキップ +ENV WARMUP_TIMEOUT=0 + +CPU 上のウォームアップ推論はさらに数分かかるので、起動時はスキップ。初回リクエスト時に初めて実行される。 + +再デプロイコマンド(CPU 増やす + 最小インスタンス1) +gcloud run deploy audio2exp-service ` + --source . ` + --project hp-support-477512 ` + --region us-central1 ` + --memory 4Gi ` + --cpu 4 ` + --timeout 300 ` + --min-instances 1 ` + --max-instances 3 ` + --startup-cpu-boost ` + --set-env-vars "MODEL_DIR=/app/models,DEVICE=cpu,WARMUP_TIMEOUT=0" + +変更点: + +--cpu 4 ← 2 → 4 (import torch の7分が大幅短縮される) +--min-instances 1 ← 0 → 1 (コンテナが殺されない) +--startup-cpu-boost (起動中に追加 CPU を自動付与) +gcloud run deploy audio2exp-service ` + +--source . --project hp-support-477512 +--region us-central1 --memory 4Gi +--cpu 4 --timeout 300 +--min-instances 1 --max-instances 3 +--startup-cpu-boost ` +--set-env-vars "MODEL_DIR=/app/models,DEVICE=cpu,WARMUP_TIMEOUT=0" + +ERROR: (gcloud.run.deploy) unrecognized arguments: --startup-cpu-boost (did you mean '--cpu-boost'?) +To search the help text of gcloud commands, run: +gcloud help -- SEARCH_TERMS +PS C:\Users\hamad\audio2exp-service> + + + +すみません、正しいフラグは --cpu-boost です。 + +gcloud run deploy audio2exp-service ` + --source . ` + --project hp-support-477512 ` + --region us-central1 ` + --memory 4Gi ` + --cpu 4 ` + --timeout 300 ` + --min-instances 1 ` + --max-instances 3 ` + --cpu-boost ` + --set-env-vars "MODEL_DIR=/app/models,DEVICE=cpu,WARMUP_TIMEOUT=0" + +gcloud run deploy audio2exp-service ` + +--source . --project hp-support-477512 +--region us-central1 --memory 4Gi +--cpu 4 --timeout 300 +--min-instances 1 --max-instances 3 +--cpu-boost ` +--set-env-vars "MODEL_DIR=/app/models,DEVICE=cpu,WARMUP_TIMEOUT=0" + +Building using Dockerfile and deploying container to Cloud Run service [audio2exp-service] in project [hp-support-477512] region [us-central1] +OK Building and deploying... Done. +OK Validating Service... +OK Uploading sources... +OK Building Container... Logs are available at [https://console.cloud.google.com/cloud-build/builds;region=us-central +1/fb9eead3-1c65-40c7-8fc0-73cb44c7b171?project=417509577941]. +OK Creating Revision... +OK Routing traffic... +Done. +Service [audio2exp-service] revision [audio2exp-service-00025-cjq] has been deployed and is serving 100 percent of traffic. +Service URL: https://audio2exp-service-417509577941.us-central1.run.app +PS C:\Users\hamad\audio2exp-service> curl https://audio2exp-service-417509577941.us-central1.run.app/health +{"elapsed_seconds":183,"engine_ready":false,"model_dir":"/app/models","status":"loading","timeout_seconds":300} +PS C:\Users\hamad\audio2exp-service> + +Show more + + +The service deployed successfully but the health check shows it's stuck in loading state after 183 seconds. Let me look at the code to understand what's happening. + + +Investigated Cloud Run service health check timeout issue and root causes +Investigated Cloud Run service health check timeout issue and root causes +もっと表示 +もっと表示 +もっと表示 +もっと表示 +もっと表示 +もっと表示 +もっと表示 +もっと表示 + +The service is stuck in loading because the engine initialization thread hasn't finished after 183 seconds. Here's what's likely happening: + +Root Cause +The Audio2ExpressionEngine in a2e_engine.py tries to load models in this order: + +Full INFER pipeline — requires: +LAM_Audio2Expression module + config +lam_audio2exp_streaming.tar checkpoint +wav2vec2-base-960h model +Fallback — wav2vec2 only (approximate blendshapes) +The 183s loading time suggests either: + +A model file is missing in /app/models and the loader is searching/failing slowly +CPU-bound loading is very slow — PyTorch + wav2vec2 on CPU with 4Gi memory can take a while +The loader thread may have crashed silently (daemon thread errors don't propagate) +Quick Diagnosis +Check the Cloud Run logs for the actual error: + +gcloud run services logs read audio2exp-service ` + --project hp-support-477512 ` + --region us-central1 ` + --limit 50 + +Or via the console: + +https://console.cloud.google.com/run/detail/us-central1/audio2exp-service/logs?project=hp-support-477512 + +Likely Fix +If the A2E checkpoint (lam_audio2exp_streaming.tar) is missing from your Docker image, the engine will fail to load the INFER pipeline. Make sure your Dockerfile copies or downloads all required models: + +/app/models/ +├── wav2vec2-base-960h/ # ~360MB (downloaded during build) +│ ├── config.json +│ ├── pytorch_model.bin +│ └── ... +└── lam_audio2exp_streaming.tar # A2E checkpoint (needed for full pipeline) + +Check the logs first — they'll tell you exactly which model file is missing or which step is failing. + +日本語で + + + +サービスのデプロイは成功しましたが、ヘルスチェックが183秒経過しても engine_ready: false のまま「loading」状態で止まっています。 + +原因 +a2e_engine.py の Audio2ExpressionEngine は以下の順番でモデルを読み込みます: + +完全なINFERパイプライン — 以下が必要: +LAM_Audio2Expression モジュール+設定ファイル +lam_audio2exp_streaming.tar チェックポイント +wav2vec2-base-960h モデル +フォールバック — wav2vec2のみ(近似ブレンドシェイプ) +183秒かかっている原因として考えられるのは: + +/app/models 内にモデルファイルが欠けている +CPUでのPyTorch + モデル読み込みが非常に遅い +ローダースレッドがサイレントにクラッシュした(daemonスレッドのエラーは伝播しない) +まず確認すべきこと +Cloud Runのログを確認してください: + +gcloud run services logs read audio2exp-service ` + --project hp-support-477512 ` + --region us-central1 ` + --limit 50 + +またはコンソールから直接確認: + +https://console.cloud.google.com/run/detail/us-central1/audio2exp-service/logs?project=hp-support-477512 + +必要なモデルファイル +Docker内の /app/models に以下が揃っている必要があります: + +/app/models/ +├── wav2vec2-base-960h/ # ~360MB(ビルド時にDL) +│ ├── config.json +│ ├── pytorch_model.bin +│ └── ... +└── lam_audio2exp_streaming.tar # A2Eチェックポイント + +まずログを確認してください。 どのモデルが見つからないか、どのステップで失敗しているかが正確にわかります。 + +gcloud run services logs read audio2exp-service ` + +--project hp-support-477512 --region us-central1 +--limit 50 + +2026-02-23 08:02:07 [2026-02-23 08:02:07 +0000] [12] [INFO] Starting gunicorn 25.1.0 +2026-02-23 08:02:07 [2026-02-23 08:02:07 +0000] [12] [INFO] Listening at: http://0.0.0.0:8080 (12) +2026-02-23 08:02:07 [2026-02-23 08:02:07 +0000] [12] [INFO] Using worker: gthread +2026-02-23 08:02:07 [2026-02-23 08:02:07 +0000] [12] [INFO] Control socket listening at /app/gunicorn.ctl +2026-02-23 08:02:08 [2026-02-23 08:02:08 +0000] [14] [INFO] Booting worker with pid: 14 +2026-02-23 08:02:09 [2026-02-23 08:02:09 +0000] [13] [INFO] Starting gunicorn 25.1.0 +2026-02-23 08:02:09 [2026-02-23 08:02:09 +0000] [13] [INFO] Listening at: http://0.0.0.0:8080 (13) +2026-02-23 08:02:09 [2026-02-23 08:02:09 +0000] [13] [INFO] Using worker: gthread +2026-02-23 08:02:09 [2026-02-23 08:02:09 +0000] [13] [INFO] Control socket listening at /app/gunicorn.ctl +2026-02-23 08:02:09 [2026-02-23 08:02:09 +0000] [15] [INFO] Booting worker with pid: 15 +2026-02-23 08:02:28 2026-02-23 08:02:28,902 [INFO] [Audio2Exp] Server started, engine loading in background... +2026-02-23 08:02:30 2026-02-23 08:02:30,064 [INFO] [Audio2Exp] Server started, engine loading in background... +2026-02-23 08:02:44 2026-02-23 08:02:44,801 [INFO] [Audio2Exp] Initializing engine: model_dir=/app/models, device=cpu +2026-02-23 08:02:45 2026-02-23 08:02:45,461 [INFO] [Audio2Exp] Initializing engine: model_dir=/app/models, device=cpu +2026-02-23 08:05:34 GET 200 https://audio2exp-service-417509577941.us-central1.run.app/health +2026-02-23 08:06:45 2026-02-23 08:06:45,103 [INFO] [A2E Engine] Device: cpu +2026-02-23 08:06:45 2026-02-23 08:06:45,201 [INFO] [A2E Engine] Found LAM_Audio2Expression: /app/LAM_Audio2Expression +2026-02-23 08:06:45 2026-02-23 08:06:45,301 [INFO] [A2E Engine] Checkpoint: /app/models/pretrained_models/lam_audio2exp_streaming.tar +2026-02-23 08:06:45 2026-02-23 08:06:45,301 [INFO] [A2E Engine] Wav2Vec2: /app/models/wav2vec2-base-960h +2026-02-23 08:07:16 2026-02-23 08:07:16,465 [INFO] [A2E Engine] Device: cpu +2026-02-23 08:07:16 2026-02-23 08:07:16,560 [INFO] [A2E Engine] Found LAM_Audio2Expression: /app/LAM_Audio2Expression +2026-02-23 08:07:16 2026-02-23 08:07:16,660 [INFO] [A2E Engine] Checkpoint: /app/models/pretrained_models/lam_audio2exp_streaming.tar +2026-02-23 08:07:16 2026-02-23 08:07:16,660 [INFO] [A2E Engine] Wav2Vec2: /app/models/wav2vec2-base-960h +PS C:\Users\hamad\audio2exp-service> + +Show more + + +ログから分かったことがあります。モデルファイルはすべて見つかっていますが、初期化が遅すぎて時間切れになっています。 + +タイムライン分析 +08:02:44 エンジン初期化開始 +08:06:45 モデルファイル発見(約4分後)← ここまでがimportだけ +08:07:16 2つ目のワーカーも同じ処理中 +... モデルの実ロードはまだ始まっていない → 300秒タイムアウト超過 + +問題点 +gunicornワーカーが2つ起動している → CPU/メモリを奪い合っている +CPUでのPyTorchインポート+モデル読み込みが非常に遅い(4分以上) +ファイル発見後の実際のモデルビルド(INFER.build)がまだ始まる前にタイムアウト +解決策 +ワーカー数を1に減らし、タイムアウトを延長してください: + +gcloud run deploy audio2exp-service ` + --source . ` + --project hp-support-477512 ` + --region us-central1 ` + --memory 4Gi ` + --cpu 4 ` + --timeout 300 ` + --min-instances 1 ` + --max-instances 3 ` + --cpu-boost ` + --set-env-vars "MODEL_DIR=/app/models,DEVICE=cpu,WARMUP_TIMEOUT=0,GUNICORN_WORKERS=1,ENGINE_LOAD_TIMEOUT=600" + +また、Dockerfile や gunicorn.conf.py でワーカー数を直接指定している場合は、workers=1 に変更してください。 + +ワーカー1つにすれば、CPU 4コアすべてをモデル読み込みに使えるので、大幅に速くなるはずです。 + +もう一つ確認ですが、gunicorn の起動コマンドはどうなっていますか?(Dockerfile の CMD 部分を見せてください) + +何度も言ってるけど、\modelsは。以下に設置!/app/modelsでは、ない! +C:\Users\hamad\audio2exp-service\models + + + +すみません!ローカルの models フォルダの構造を確認させてください。Dockerfileで正しくコピーされているかが重要です。 + +ローカルの C:\Users\hamad\audio2exp-service\models の中身を見せてもらえますか? + +dir C:\Users\hamad\audio2exp-service\models -Recurse + +また、Dockerfileの内容も確認したいです: + +cat C:\Users\hamad\audio2exp-service\Dockerfile + +ローカルの models フォルダが Docker ビルド時に正しくコンテナ内にコピーされているかを確認する必要があります。 + +これも、既に何度も貼っている! +dir C:\Users\hamad\audio2exp-service\models -Recurse + +Directory: C:\Users\hamad\audio2exp-service\models + +Mode LastWriteTime Length Name + +d---- 2026/02/22 15:37 .cache +d---- 2025/04/17 22:30 pretrained_models +d---- 2026/02/22 15:37 wav2vec2-base-960h +-a--- 2026/02/22 15:37 1519 .gitattributes +-a--- 2026/02/22 15:37 32798496 LAM_audio2exp_assets.tar +-a--- 2026/02/07 12:27 373377643 LAM_audio2exp_streaming.tar +-a--- 2026/02/22 15:37 31 README.md +Directory: C:\Users\hamad\audio2exp-service\models.cache +Mode LastWriteTime Length Name + +d---- 2026/02/22 15:37 huggingface +Directory: C:\Users\hamad\audio2exp-service\models.cache\huggingface +Mode LastWriteTime Length Name + +d---- 2026/02/22 15:37 download +-a--- 2026/02/22 15:37 1 .gitignore +Directory: C:\Users\hamad\audio2exp-service\models.cache\huggingface\download +Mode LastWriteTime Length Name + +-a--- 2026/02/22 15:37 102 .gitattributes.metadata +-a--- 2026/02/22 15:37 128 LAM_audio2exp_assets.tar.metadata +-a--- 2026/02/22 15:37 128 LAM_audio2exp_streaming.tar.metadata +-a--- 2026/02/22 15:37 104 README.md.metadata +Directory: C:\Users\hamad\audio2exp-service\models\pretrained_models +Mode LastWriteTime Length Name + +-a--- 2025/04/15 17:01 408538564 lam_audio2exp_streaming.tar +Directory: C:\Users\hamad\audio2exp-service\models\wav2vec2-base-960h +Mode LastWriteTime Length Name + +d---- 2026/02/22 15:37 .cache +-a--- 2026/02/22 15:37 790 .gitattributes +-a--- 2026/02/22 15:37 1596 config.json +-a--- 2026/02/06 2:44 65 configuration.json +-a--- 2026/02/22 15:37 158 feature_extractor_config.json +-a--- 2026/02/06 2:48 377607901 model.safetensors +-a--- 2026/02/22 15:37 159 preprocessor_config.json +-a--- 2026/02/06 2:46 377667514 pytorch_model.bin +-a--- 2026/02/22 15:37 4431 README.md +-a--- 2026/02/22 15:37 85 special_tokens_map.json +-a--- 2026/02/06 2:51 377840624 tf_model.h5 +-a--- 2026/02/22 15:37 163 tokenizer_config.json +-a--- 2026/02/22 15:37 291 vocab.json +Directory: C:\Users\hamad\audio2exp-service\models\wav2vec2-base-960h.cache +Mode LastWriteTime Length Name + +d---- 2026/02/22 15:37 huggingface +Directory: C:\Users\hamad\audio2exp-service\models\wav2vec2-base-960h.cache\huggingface +Mode LastWriteTime Length Name + +d---- 2026/02/22 15:37 download +-a--- 2026/02/22 15:37 1 .gitignore +Directory: C:\Users\hamad\audio2exp-service\models\wav2vec2-base-960h.cache\huggingface\download +Mode LastWriteTime Length Name + +-a--- 2026/02/22 15:37 104 .gitattributes.metadata +-a--- 2026/02/22 15:37 103 config.json.metadata +-a--- 2026/02/22 15:37 104 feature_extractor_config.json.metadata +-a--- 2026/02/22 15:37 128 model.safetensors.metadata +-a--- 2026/02/22 15:37 104 preprocessor_config.json.metadata +-a--- 2026/02/22 15:37 128 pytorch_model.bin.metadata +-a--- 2026/02/22 15:37 104 README.md.metadata +-a--- 2026/02/22 15:37 103 special_tokens_map.json.metadata +-a--- 2026/02/22 15:37 126 tf_model.h5.metadata +-a--- 2026/02/22 15:37 103 tokenizer_config.json.metadata +-a--- 2026/02/22 15:37 104 vocab.json.metadata +PS C:\Users\hamad\audio2exp-service> + +Show more + + +申し訳ありません!モデルファイルはちゃんとコンテナ内にコピーされていて、ログでも見つかっています。 + +問題は明確です:初期化が300秒のタイムアウトに間に合っていないことです。 + +ログを見ると: + +ワーカーが2つ同時にモデルを読み込んでいる(CPU/メモリの奪い合い) +ファイル発見まで約4分かかっている +その後のモデルビルドが完了する前にタイムアウト +Dockerfile と gunicorn の設定を確認させてください。 + +cat C:\Users\hamad\audio2exp-service\Dockerfile + +cat C:\Users\hamad\audio2exp-service\gunicorn.conf.py + +ワーカー数を1にして、タイムアウトを伸ばす修正が必要です。 + +cat C:\Users\hamad\audio2exp-service\Dockerfile + +FROM python:3.11-slim + +ffmpeg (pydub dependency), libsndfile (librosa dependency) +RUN apt-get update && apt-get install -y --no-install-recommends +ffmpeg +libsndfile1 +&& rm -rf /var/lib/apt/lists/* +WORKDIR /app + +CPU-only PyTorch を先にインストール (GPU版の無駄なダウンロードを回避) +RUN pip install --no-cache-dir torch torchaudio --index-url https://download.pytorch.org/whl/cpu +COPY requirements.txt . + +torch は既にインストール済みなので requirements.txt からは除外してインストール +RUN pip install --no-cache-dir -r requirements.txt +COPY . . + +モデルディレクトリ +RUN mkdir -p /app/models + +wav2vec2-base-960h をビルド時にダウンロードし、標準形式で保存 +cache_dir ではなく save_pretrained を使用 (config.json が直下に必要) +RUN python -c " +from transformers import Wav2Vec2Model, Wav2Vec2Processor; +p = Wav2Vec2Processor.from_pretrained('facebook/wav2vec2-base-960h'); +m = Wav2Vec2Model.from_pretrained('facebook/wav2vec2-base-960h'); +p.save_pretrained('/app/models/wav2vec2-base-960h'); +m.save_pretrained('/app/models/wav2vec2-base-960h'); +print('wav2vec2-base-960h saved to /app/models/wav2vec2-base-960h')" + +INFER ログ出力先 +RUN mkdir -p /tmp/audio2exp_logs/model +ENV PORT=8080 +ENV MODEL_DIR=/app/models +ENV DEVICE=cpu + +ランタイムで HuggingFace へのアクセスを無効化 (ハング防止) +ENV HF_HUB_OFFLINE=1 +ENV TRANSFORMERS_OFFLINE=1 + +CPU ではウォームアップ推論をスキップ (起動高速化) +ENV WARMUP_TIMEOUT=0 +EXPOSE 8080 + +gthread ワーカー: daemon スレッドが GIL を握ってもハートビートが止まらない +--timeout 600: モデルロードに十分な時間 (import torch だけで数分) +--threads 2: ヘルスチェック応答用に最低2スレッド +CMD gunicorn --bind "0.0.0.0:${PORT}" --timeout 600 --worker-class gthread --workers 1 --threads 2 app:app +PS C:\Users\hamad\audio2exp-service> + +Show more + + +Dockerfile は問題なさそうです(workers 1、timeout 600)。 + +しかし、ログで気になる点があります: + +timeout_seconds: 300 → これは ENGINE_LOAD_TIMEOUT の値で、gunicornの600秒ではない +ファイル発見後(08:06:45)の完了ログが一切ない +コード側の ENGINE_LOAD_TIMEOUT を確認させてください。 + +cat C:\Users\hamad\audio2exp-service\app.py + +また、もう少し待ってからヘルスチェックをもう一度叩いてみてください: + +curl https://audio2exp-service-417509577941.us-central1.run.app/health + +エンジンの読み込みに4分以上かかっているので、300秒のタイムアウトを超えて error に変わっている可能性があります。最新のステータスを見たいです。 + +cat C:\Users\hamad\audio2exp-service\app.py + +""" +Audio2Expression マイクロサービス +gourmet-support バックエンドから呼び出される A2E 推論サービス。 +MP3音声を受け取り、52次元ARKitブレンドシェイプ係数を返す。 +アーキテクチャ: +MP3 audio (base64) → PCM 16kHz → Wav2Vec2 → A2E Decoder → 52-dim ARKit blendshapes +エンドポイント: +POST /api/audio2expression +GET /health +環境変数: +MODEL_DIR: モデルディレクトリ (default: ./models) +PORT: サーバーポート (default: 8080) +DEVICE: cpu or cuda (default: auto) +""" +import os +import time +import logging +import threading +from flask import Flask, request, jsonify +from flask_cors import CORS +logging.basicConfig( +level=logging.INFO, +format='%(asctime)s [%(levelname)s] %(message)s' +) +logger = logging.getLogger(name) +app = Flask(name) +CORS(app) + +A2Eエンジンの設定 +MODEL_DIR = os.getenv("MODEL_DIR", "./models") +DEVICE = os.getenv("DEVICE", "auto") + +エンジン状態管理 +engine = None +engine_error = None +engine_loading = True +engine_load_start = time.time() + +エンジンロードの最大待機時間 (秒) +ENGINE_LOAD_TIMEOUT = int(os.getenv("ENGINE_LOAD_TIMEOUT", "300")) +def _load_engine(): +"""バックグラウンドでエンジンを初期化""" +global engine, engine_error, engine_loading +try: +from a2e_engine import Audio2ExpressionEngine +logger.info(f"[Audio2Exp] Initializing engine: model_dir={MODEL_DIR}, device={DEVICE}") +engine = Audio2ExpressionEngine(model_dir=MODEL_DIR, device=DEVICE) +engine_loading = False +logger.info("[Audio2Exp] Engine initialized successfully") +except Exception as e: +engine_error = str(e) +engine_loading = False +logger.error(f"[Audio2Exp] Engine initialization failed: {e}", exc_info=True) + +バックグラウンドスレッドでエンジンをロード +gunicorn がすぐにリクエストを受け付けられるようにする +logger.info("[Audio2Exp] Server started, engine loading in background...") +loader_thread = threading.Thread(target=_load_engine, daemon=True) +loader_thread.start() +@app.route('/api/audio2expression', methods=['POST']) +def audio2expression(): +""" +音声から表情係数を生成 +Request JSON: +{ +"audio_base64": "...", # base64エンコードされた音声データ +"session_id": "...", # セッションID (ログ用) +"is_start": true, # ストリームの開始フラグ +"is_final": true, # ストリームの終了フラグ +"audio_format": "mp3" # 音声フォーマット (mp3, wav, pcm) +} +Response JSON: +{ +"names": ["eyeBlinkLeft", ...], # 52個のARKitブレンドシェイプ名 +"frames": [[0.0, ...], ...], # フレームごとの52次元係数 +"frame_rate": 30 # フレームレート (fps) +} +""" +if engine_loading: +return jsonify({'error': 'Engine is still loading, please retry later'}), 503 +if engine is None: +return jsonify({'error': f'Engine failed to load: {engine_error}'}), 500 +try: +data = request.json +audio_base64 = data.get('audio_base64', '') +session_id = data.get('session_id', 'unknown') +audio_format = data.get('audio_format', 'mp3') +if not audio_base64: +return jsonify({'error': 'audio_base64 is required'}), 400 +logger.info(f"[Audio2Exp] Processing: session={session_id}, " +f"format={audio_format}, size={len(audio_base64)} bytes") +t0 = time.time() +result = engine.process(audio_base64, audio_format=audio_format) +elapsed = time.time() - t0 +frame_count = len(result.get('frames', [])) +logger.info(f"[Audio2Exp] Done: {frame_count} frames in {elapsed:.2f}s, " +f"session={session_id}") +return jsonify(result) +except Exception as e: +logger.error(f"[Audio2Exp] Error: {e}", exc_info=True) +return jsonify({'error': str(e)}), 500 +@app.route('/health', methods=['GET']) +def health(): +"""ヘルスチェック - エンジンロード中でも 200 を返す""" +global engine_loading, engine_error +if engine_loading: +elapsed = time.time() - engine_load_start +# タイムアウト検出: ロードが長すぎる場合はエラーに切り替え +if elapsed > ENGINE_LOAD_TIMEOUT: +engine_loading = False +engine_error = f"Engine loading timed out after {int(elapsed)}s" +logger.error(f"[Audio2Exp] {engine_error}") +else: +return jsonify({ +'status': 'loading', +'engine_ready': False, +'model_dir': MODEL_DIR, +'elapsed_seconds': int(elapsed), +'timeout_seconds': ENGINE_LOAD_TIMEOUT, +}) +if engine is None: +return jsonify({ +'status': 'error', +'engine_ready': False, +'error': engine_error, +'model_dir': MODEL_DIR +}), 503 +return jsonify({ +'status': 'healthy', +'engine_ready': engine.is_ready(), +'mode': engine.get_mode(), +'device': engine.device_name, +'model_dir': MODEL_DIR +}) +if name == 'main': +port = int(os.getenv('PORT', 8080)) +logger.info(f"[Audio2Exp] Starting on port {port}") +app.run(host='0.0.0.0', port=port, debug=False, load_dotenv=False) +PS C:\Users\hamad\audio2exp-service> + +Show more + + +原因がわかりました。 + +問題 +ENGINE_LOAD_TIMEOUT がデフォルトの 300秒 のままです。CPUでのモデル読み込みはインポートだけで約4分、その後のモデルビルドでさらに時間がかかるため、300秒では足りません。 + +デプロイ時に設定した環境変数: + +MODEL_DIR=/app/models,DEVICE=cpu,WARMUP_TIMEOUT=0 + +→ ENGINE_LOAD_TIMEOUT が未設定なので300秒でタイムアウト判定されています。 + +修正 +ENGINE_LOAD_TIMEOUT=600 を追加して再デプロイしてください: + +gcloud run deploy audio2exp-service ` + --source . ` + --project hp-support-477512 ` + --region us-central1 ` + --memory 4Gi ` + --cpu 4 ` + --timeout 300 ` + --min-instances 1 ` + --max-instances 3 ` + --cpu-boost ` + --set-env-vars "MODEL_DIR=/app/models,DEVICE=cpu,WARMUP_TIMEOUT=0,ENGINE_LOAD_TIMEOUT=600" + +gunicornのタイムアウトは既に600秒なので、ENGINE_LOAD_TIMEOUT もそれに合わせれば、エンジンの読み込みが完了するまで待てるはずです。 + +curl https://audio2exp-service-417509577941.us-central1.run.app/health + +{"engine_ready":false,"error":"Engine loading timed out after 922s","model_dir":"/app/models","status":"error"} +PS C:\Users\hamad\audio2exp-service> + + + +922秒(約15分)経ってもロードが完了していません。これはタイムアウトの問題ではなく、エンジンの初期化自体がハングしている可能性が高いです。 + +a2e_engine.py の中身を確認させてください。 + +cat C:\Users\hamad\audio2exp-service\a2e_engine.py + +モデルビルド(INFER.build)のどこで止まっているかを特定する必要があります。 + +cat C:\Users\hamad\audio2exp-service\a2e_engine.py + +""" +A2E (Audio2Expression) 推論エンジン +LAM Audio2Expression INFER パイプラインを使って、 +音声から52次元ARKitブレンドシェイプを生成。 +モデル構成: +- facebook/wav2vec2-base-960h: 音響特徴量抽出 (768次元) +- 3DAIGC/LAM_audio2exp: 表情デコーダー (768→52次元) +優先順位: +1. INFER パイプライン (LAM_Audio2Expression モジュール使用) +→ 完全な A2E 推論 + ポストプロセッシング +2. Wav2Vec2 エネルギーベースフォールバック +→ モジュール未インストール時の近似生成 +入出力: +Input: base64エンコードされた音声 (MP3/WAV/PCM) +Output: {names: [52 strings], frames: [[52 floats], ...], frame_rate: 30} +""" +import base64 +import io +import logging +import os +import sys +import threading +import traceback +from pathlib import Path +import numpy as np +logger = logging.getLogger(name) + +INFER パイプラインが使用する ARKit 52 ブレンドシェイプ名 +(LAM_Audio2Expression/models/utils.py の ARKitBlendShape と同じ順序) +ARKIT_BLENDSHAPE_NAMES_INFER = [ +"browDownLeft", "browDownRight", "browInnerUp", "browOuterUpLeft", "browOuterUpRight", +"cheekPuff", "cheekSquintLeft", "cheekSquintRight", +"eyeBlinkLeft", "eyeBlinkRight", "eyeLookDownLeft", "eyeLookDownRight", +"eyeLookInLeft", "eyeLookInRight", "eyeLookOutLeft", "eyeLookOutRight", +"eyeLookUpLeft", "eyeLookUpRight", "eyeSquintLeft", "eyeSquintRight", +"eyeWideLeft", "eyeWideRight", +"jawForward", "jawLeft", "jawOpen", "jawRight", +"mouthClose", "mouthDimpleLeft", "mouthDimpleRight", "mouthFrownLeft", "mouthFrownRight", +"mouthFunnel", "mouthLeft", "mouthLowerDownLeft", "mouthLowerDownRight", +"mouthPressLeft", "mouthPressRight", "mouthPucker", "mouthRight", +"mouthRollLower", "mouthRollUpper", "mouthShrugLower", "mouthShrugUpper", +"mouthSmileLeft", "mouthSmileRight", "mouthStretchLeft", "mouthStretchRight", +"mouthUpperUpLeft", "mouthUpperUpRight", +"noseSneerLeft", "noseSneerRight", +"tongueOut", +] + +フォールバック用の ARKit 名 (a2e_engine.py 独自の順序) +ARKIT_BLENDSHAPE_NAMES_FALLBACK = [ +"eyeBlinkLeft", "eyeLookDownLeft", "eyeLookInLeft", "eyeLookOutLeft", +"eyeLookUpLeft", "eyeSquintLeft", "eyeWideLeft", +"eyeBlinkRight", "eyeLookDownRight", "eyeLookInRight", "eyeLookOutRight", +"eyeLookUpRight", "eyeSquintRight", "eyeWideRight", +"jawForward", "jawLeft", "jawRight", "jawOpen", +"mouthClose", "mouthFunnel", "mouthPucker", "mouthLeft", "mouthRight", +"mouthSmileLeft", "mouthSmileRight", "mouthFrownLeft", "mouthFrownRight", +"mouthDimpleLeft", "mouthDimpleRight", "mouthStretchLeft", "mouthStretchRight", +"mouthRollLower", "mouthRollUpper", "mouthShrugLower", "mouthShrugUpper", +"mouthPressLeft", "mouthPressRight", "mouthLowerDownLeft", "mouthLowerDownRight", +"mouthUpperUpLeft", "mouthUpperUpRight", +"browDownLeft", "browDownRight", "browInnerUp", "browOuterUpLeft", "browOuterUpRight", +"cheekPuff", "cheekSquintLeft", "cheekSquintRight", +"noseSneerLeft", "noseSneerRight", +"tongueOut", +] + +A2E出力のFPS +A2E_OUTPUT_FPS = 30 + +INFER パイプライン用の入力サンプルレート +INFER_INPUT_SAMPLE_RATE = 16000 +class Audio2ExpressionEngine: +"""A2E推論エンジン - INFER パイプライン優先、Wav2Vec2 フォールバック""" +def init(self, model_dir: str = "./models", device: str = "auto"): +self.model_dir = Path(model_dir) +self._ready = False +self._use_infer = False # INFER パイプライン使用フラグ +self._infer = None # INFER パイプラインインスタンス +self._infer_context = None # ストリーミング推論のコンテキスト +# デバイス決定 +import torch +if device == "auto": +self.device = "cuda" if torch.cuda.is_available() else "cpu" +else: +self.device = device +self.device_name = self.device +logger.info(f"[A2E Engine] Device: {self.device}") +self._initialize() +def _initialize(self): +"""エンジン初期化 - INFER パイプラインを優先的にロード""" +# 1. INFER パイプラインを試行 +if self._try_load_infer_pipeline(): +self._use_infer = True +self._ready = True +logger.info("[A2E Engine] Ready (INFER pipeline mode)") +return +# 2. フォールバック: Wav2Vec2 のみ +logger.warning("[A2E Engine] INFER pipeline unavailable, loading Wav2Vec2 fallback") +self._load_wav2vec_fallback() +self._ready = True +logger.info("[A2E Engine] Ready (Wav2Vec2 fallback mode)") +def _find_lam_module(self) -> str: +"""LAM_Audio2Expression モジュールを探索して sys.path に追加""" +script_dir = Path(os.path.dirname(os.path.abspath(file))) +candidates = [ +# 環境変数で指定 +os.environ.get("LAM_A2E_PATH"), +# サービスディレクトリ直下 (Docker COPY) +str(script_dir / "LAM_Audio2Expression"), +# models ディレクトリ内 +str(self.model_dir / "LAM_Audio2Expression"), +str(self.model_dir / "LAM_audio2exp" / "LAM_Audio2Expression"), +# 親ディレクトリ +str(self.model_dir.parent / "LAM_Audio2Expression"), +] +for candidate in candidates: +if candidate and os.path.exists(candidate): +abs_path = os.path.abspath(candidate) +if abs_path not in sys.path: +sys.path.insert(0, abs_path) +logger.info(f"[A2E Engine] Found LAM_Audio2Expression: {abs_path}") +return abs_path +return None +def _find_checkpoint(self) -> str: +""" +A2E チェックポイントファイルを探索。 +HuggingFace からダウンロードした LAM_audio2exp_streaming.tar は +gzip 圧縮の tar アーカイブで、中に pretrained_models/lam_audio2exp_streaming.tar +(これが実際の PyTorch チェックポイント) が入っている。 +自動的に展開して内側のチェックポイントを返す。 +""" +import gzip +import tarfile +model_dir = self.model_dir +# 実際の PyTorch チェックポイント (展開済み) を優先検索 +search_patterns = [ +model_dir / "pretrained_models" / "lam_audio2exp_streaming.tar", +model_dir / "pretrained_models" / "LAM_audio2exp_streaming.tar", +model_dir / "lam_audio2exp_streaming.pth", +model_dir / "LAM_audio2exp_streaming.pth", +model_dir / "LAM_audio2exp" / "pretrained_models" / "lam_audio2exp_streaming.tar", +model_dir / "LAM_audio2exp" / "pretrained_models" / "LAM_audio2exp_streaming.tar", +] +for path in search_patterns: +if path.exists(): +return str(path) +# 外側の gzip tar を見つけたら自動展開 +outer_candidates = [ +model_dir / "LAM_audio2exp_streaming.tar", +model_dir / "lam_audio2exp_streaming.tar", +] +for outer_path in outer_candidates: +if outer_path.exists(): +try: +with tarfile.open(str(outer_path), "r:gz") as tf: +tf.extractall(path=str(model_dir)) +logger.info(f"[A2E Engine] Extracted {outer_path}") +# 展開後に内側のチェックポイントを探索 +inner = model_dir / "pretrained_models" / "lam_audio2exp_streaming.tar" +if inner.exists(): +return str(inner) +except Exception as e: +logger.warning(f"[A2E Engine] Failed to extract {outer_path}: {e}") +# ワイルドカード検索 +tar_files = list(model_dir.rglob("audio2exp.tar")) +# 外側の gzip tar は除外 +tar_files = [f for f in tar_files if f.stat().st_size < 400_000_000] +if tar_files: +return str(tar_files[0]) +pth_files = list(model_dir.rglob("audio2exp.pth")) +if pth_files: +return str(pth_files[0]) +return None +def _find_wav2vec_dir(self) -> str: +"""wav2vec2-base-960h モデルディレクトリを探索""" +candidates = [ +self.model_dir / "wav2vec2-base-960h", +] +# GCS FUSE mount +mount_path = os.environ.get("MODEL_MOUNT_PATH", "/mnt/models") +model_subdir = os.environ.get("MODEL_SUBDIR", "audio2exp") +candidates.append(Path(mount_path) / model_subdir / "wav2vec2-base-960h") +for path in candidates: +if path.exists() and (path / "config.json").exists(): +return str(path) +return None +def _try_load_infer_pipeline(self) -> bool: +""" +INFER パイプラインのロードを試行。 +old FastAPI app.py の実装をベースに: +1. LAM_Audio2Expression モジュールを見つけて sys.path に追加 +2. default_config_parser で streaming config をパース +3. INFER.build() でモデルをビルド +4. warmup 推論を実行 +""" +import torch +# 1. LAM_Audio2Expression モジュールを探索 +lam_path = self._find_lam_module() +if not lam_path: +logger.warning("[A2E Engine] LAM_Audio2Expression module not found") +return False +# 2. チェックポイントを探索 +checkpoint_path = self._find_checkpoint() +if not checkpoint_path: +logger.warning("[A2E Engine] No A2E checkpoint found") +return False +# 3. wav2vec2 ディレクトリを探索 (ローカルのみ、HuggingFace DL禁止) +wav2vec_dir = self._find_wav2vec_dir() +if not wav2vec_dir: +logger.warning("[A2E Engine] wav2vec2-base-960h not found locally, " +"INFER pipeline cannot load without it") +return False +logger.info(f"[A2E Engine] Checkpoint: {checkpoint_path}") +logger.info(f"[A2E Engine] Wav2Vec2: {wav2vec_dir}") +try: +from engines.defaults import default_config_parser +from engines.infer import INFER +# DDP 環境変数 (single-process 用) +os.environ.setdefault("WORLD_SIZE", "1") +os.environ.setdefault("RANK", "0") +os.environ.setdefault("MASTER_ADDR", "localhost") +os.environ.setdefault("MASTER_PORT", "12345") +# config ファイルのパス +config_file = os.path.join(lam_path, "configs", +"lam_audio2exp_config_streaming.py") +if not os.path.exists(config_file): +logger.warning(f"[A2E Engine] Config not found: {config_file}") +return False +# save_path (ログ出力先 - /tmp に設定) +save_path = "/tmp/audio2exp_logs" +os.makedirs(save_path, exist_ok=True) +os.makedirs(os.path.join(save_path, "model"), exist_ok=True) +# wav2vec2 config.json パスの解決 +if os.path.isdir(wav2vec_dir): +wav2vec_config = os.path.join(wav2vec_dir, "config.json") +else: +# HuggingFace ID の場合、LAM モジュール内蔵の config を使用 +wav2vec_config = os.path.join(lam_path, "configs", "wav2vec2_config.json") +# cfg_options: config のオーバーライド +cfg_options = { +"weight": checkpoint_path, +"save_path": save_path, +"model": { +"backbone": { +"wav2vec2_config_path": wav2vec_config, +"pretrained_encoder_path": wav2vec_dir, +} +}, +"num_worker": 0, +"batch_size": 1, +} +logger.info(f"[A2E Engine] Loading config: {config_file}") +cfg = default_config_parser(config_file, cfg_options) +# default_setup() をスキップ (DDP 関連の処理は不要) +# 必要な設定を手動で設定 +cfg.device = torch.device(self.device) +cfg.num_worker = 0 +cfg.num_worker_per_gpu = 0 +cfg.batch_size_per_gpu = 1 +cfg.batch_size_val_per_gpu = 1 +cfg.batch_size_test_per_gpu = 1 +logger.info("[A2E Engine] Building INFER model...") +self._infer = INFER.build(dict(type=cfg.infer.type, cfg=cfg)) +# CPU + eval mode +device = torch.device(self.device) +self._infer.model.to(device) +self._infer.model.eval() +# Warmup 推論 (失敗しても致命的ではない) +WARMUP_TIMEOUT = int(os.environ.get("WARMUP_TIMEOUT", "120")) +if WARMUP_TIMEOUT <= 0: +logger.info("[A2E Engine] Warmup skipped (WARMUP_TIMEOUT=0)") +else: +logger.info(f"[A2E Engine] Running warmup inference (timeout={WARMUP_TIMEOUT}s)...") +try: +warmup_done = threading.Event() +warmup_error = [None] +def _warmup_worker(): +try: +dummy_audio = np.zeros(INFER_INPUT_SAMPLE_RATE, dtype=np.float32) +self._infer.infer_streaming_audio( +audio=dummy_audio, ssr=INFER_INPUT_SAMPLE_RATE, context=None +) +except Exception as e: +warmup_error[0] = e +finally: +warmup_done.set() +warmup_thread = threading.Thread(target=_warmup_worker, daemon=True) +warmup_thread.start() +if warmup_done.wait(timeout=WARMUP_TIMEOUT): +if warmup_error[0]: +logger.warning(f"[A2E Engine] Warmup failed (non-fatal): {warmup_error[0]}") +else: +logger.info("[A2E Engine] Warmup succeeded") +else: +logger.warning(f"[A2E Engine] Warmup timed out after {WARMUP_TIMEOUT}s (non-fatal, skipping)") +except Exception as e: +logger.warning(f"[A2E Engine] Warmup failed (non-fatal): {e}") +logger.info("[A2E Engine] INFER pipeline loaded successfully!") +return True +except ImportError as e: +logger.warning(f"[A2E Engine] INFER import failed: {e}") +traceback.print_exc() +return False +except Exception as e: +logger.warning(f"[A2E Engine] INFER initialization failed: {e}") +traceback.print_exc() +return False +def _load_wav2vec_fallback(self): +"""Wav2Vec2 フォールバックモードのロード (ローカルのみ、HuggingFace DL禁止)""" +import torch +from transformers import Wav2Vec2Model, Wav2Vec2Processor +wav2vec_dir = self._find_wav2vec_dir() +if not wav2vec_dir: +raise RuntimeError( +"[A2E Engine] wav2vec2-base-960h not found locally. " +f"Expected at: {self.model_dir / 'wav2vec2-base-960h'}" +) +logger.info(f"[A2E Engine] Loading Wav2Vec2 from local: {wav2vec_dir}") +self.wav2vec_processor = Wav2Vec2Processor.from_pretrained(wav2vec_dir) +self.wav2vec_model = Wav2Vec2Model.from_pretrained(wav2vec_dir) +self.wav2vec_model.to(self.device) +self.wav2vec_model.eval() +logger.info("[A2E Engine] Wav2Vec2 loaded (fallback mode)") +def is_ready(self) -> bool: +return self._ready +def get_mode(self) -> str: +"""現在の推論モードを返す""" +return "infer" if self._use_infer else "fallback" +def process(self, audio_base64: str, audio_format: str = "mp3") -> dict: +""" +音声を処理してブレンドシェイプ係数を生成 +Args: +audio_base64: base64エンコードされた音声 +audio_format: 音声フォーマット (mp3, wav, pcm) +Returns: +{names: [52 strings], frames: [[52 floats], ...], frame_rate: int} +""" +# 1. 音声デコード → PCM 16kHz +audio_pcm = self._decode_audio(audio_base64, audio_format) +duration = len(audio_pcm) / INFER_INPUT_SAMPLE_RATE +logger.info(f"[A2E Engine] Audio decoded: {duration:.2f}s at 16kHz") +# 2. 推論実行 +if self._use_infer: +return self._process_with_infer(audio_pcm, duration) +else: +return self._process_with_fallback(audio_pcm, duration) +def _process_with_infer(self, audio_pcm: np.ndarray, duration: float) -> dict: +""" +INFER パイプラインで推論。 +infer_streaming_audio() を使用: +- 音声をチャンクに分割 +- チャンクごとに推論 (コンテキスト引き継ぎ) +- ポストプロセッシング込み (smooth_mouth, frame_blending, +savitzky_golay, symmetrize, eye_blinks) +""" +chunk_samples = INFER_INPUT_SAMPLE_RATE # 1秒チャンク +all_expressions = [] +context = None +try: +for start in range(0, len(audio_pcm), chunk_samples): +end = min(start + chunk_samples, len(audio_pcm)) +chunk = audio_pcm[start:end] +# 極端に短いチャンクはスキップ +if len(chunk) < INFER_INPUT_SAMPLE_RATE // 10: +continue +result, context = self._infer.infer_streaming_audio( +audio=chunk, ssr=INFER_INPUT_SAMPLE_RATE, context=context +) +expr = result.get("expression") +if expr is not None: +all_expressions.append(expr.astype(np.float32)) +if not all_expressions: +logger.warning("[A2E Engine] INFER produced no expression data") +num_frames = max(1, int(duration * A2E_OUTPUT_FPS)) +expression = np.zeros((num_frames, 52), dtype=np.float32) +else: +expression = np.concatenate(all_expressions, axis=0) +logger.info(f"[A2E Engine] INFER: {expression.shape[0]} frames, " +f"jawOpen range=[{expression[:, 24].min():.3f}, " +f"{expression[:, 24].max():.3f}]") # jawOpen = index 24 in INFER order +# フレームリストに変換 +frames = [frame.tolist() for frame in expression] +return { +"names": ARKIT_BLENDSHAPE_NAMES_INFER, +"frames": frames, +"frame_rate": A2E_OUTPUT_FPS, +} +except Exception as e: +logger.error(f"[A2E Engine] INFER inference error: {e}") +traceback.print_exc() +# エラー時はフォールバック +logger.warning("[A2E Engine] Falling back to Wav2Vec2 for this request") +if hasattr(self, 'wav2vec_model'): +return self._process_with_fallback(audio_pcm, duration) +# Wav2Vec2 もない場合は空フレームを返す +num_frames = max(1, int(duration * A2E_OUTPUT_FPS)) +return { +"names": ARKIT_BLENDSHAPE_NAMES_INFER, +"frames": [np.zeros(52).tolist()] * num_frames, +"frame_rate": A2E_OUTPUT_FPS, +} +def _process_with_fallback(self, audio_pcm: np.ndarray, duration: float) -> dict: +"""Wav2Vec2 フォールバックで推論""" +import torch +inputs = self.wav2vec_processor( +audio_pcm, sampling_rate=16000, return_tensors="pt", padding=True +) +input_values = inputs.input_values.to(self.device) +with torch.no_grad(): +outputs = self.wav2vec_model(input_values) +features = outputs.last_hidden_state # (1, T, 768) +logger.info(f"[A2E Engine] Wav2Vec2 features: {tuple(features.shape)}") +blendshapes = self._wav2vec_to_blendshapes_fallback(features, duration) +frames = self._resample_to_fps(blendshapes, duration, A2E_OUTPUT_FPS) +return { +"names": ARKIT_BLENDSHAPE_NAMES_FALLBACK, +"frames": frames, +"frame_rate": A2E_OUTPUT_FPS, +} +def _decode_audio(self, audio_base64: str, audio_format: str) -> np.ndarray: +"""base64音声をPCM float32 16kHzにデコード""" +audio_bytes = base64.b64decode(audio_base64) +if audio_format in ("mp3", "wav", "ogg", "flac"): +from pydub import AudioSegment +audio = AudioSegment.from_file(io.BytesIO(audio_bytes), format=audio_format) +audio = audio.set_frame_rate(16000).set_channels(1).set_sample_width(2) +samples = np.array(audio.get_array_of_samples(), dtype=np.float32) +samples = samples / 32768.0 +elif audio_format == "pcm": +samples = np.frombuffer(audio_bytes, dtype=np.int16).astype(np.float32) +samples = samples / 32768.0 +else: +raise ValueError(f"Unsupported audio format: {audio_format}") +return samples +def _wav2vec_to_blendshapes_fallback( +self, features, duration: float +) -> np.ndarray: +""" +A2Eデコーダーがない場合のフォールバック: +Wav2Vec2の特徴量からリップシンク関連のブレンドシェイプを近似生成。 +""" +features_np = features.squeeze(0).cpu().numpy() # (T, 768) +n_frames = features_np.shape[0] +blendshapes = np.zeros((n_frames, 52), dtype=np.float32) +low_energy = np.abs(features_np[:, :256]).mean(axis=1) +mid_energy = np.abs(features_np[:, 256:512]).mean(axis=1) +high_energy = np.abs(features_np[:, 512:]).mean(axis=1) +def normalize(x): +x_min = x.min() +x_max = x.max() +if x_max - x_min < 1e-6: +return np.zeros_like(x) +return (x - x_min) / (x_max - x_min) +low_norm = normalize(low_energy) +mid_norm = normalize(mid_energy) +high_norm = normalize(high_energy) +speech_activity = normalize(low_energy + mid_energy + high_energy) +idx = {name: i for i, name in enumerate(ARKIT_BLENDSHAPE_NAMES_FALLBACK)} +# リップシンク +blendshapes[:, idx["jawOpen"]] = np.clip(low_norm * 0.8, 0, 1) +blendshapes[:, idx["mouthClose"]] = np.clip(1.0 - low_norm * 0.8, 0, 1) * speech_activity +funnel = np.clip(mid_norm * 0.5 - low_norm * 0.2, 0, 1) +blendshapes[:, idx["mouthFunnel"]] = funnel +blendshapes[:, idx["mouthPucker"]] = np.clip(funnel * 0.7, 0, 1) +smile = np.clip(high_norm * 0.4 - mid_norm * 0.1, 0, 1) +blendshapes[:, idx["mouthSmileLeft"]] = smile +blendshapes[:, idx["mouthSmileRight"]] = smile +lower_down = np.clip(low_norm * 0.5, 0, 1) +blendshapes[:, idx["mouthLowerDownLeft"]] = lower_down +blendshapes[:, idx["mouthLowerDownRight"]] = lower_down +upper_up = np.clip(low_norm * 0.3, 0, 1) +blendshapes[:, idx["mouthUpperUpLeft"]] = upper_up +blendshapes[:, idx["mouthUpperUpRight"]] = upper_up +stretch = np.clip((mid_norm + high_norm) * 0.25, 0, 1) +blendshapes[:, idx["mouthStretchLeft"]] = stretch +blendshapes[:, idx["mouthStretchRight"]] = stretch +# 非リップ関連 +blendshapes[:, idx["browInnerUp"]] = np.clip(speech_activity * 0.15, 0, 1) +blendshapes[:, idx["cheekSquintLeft"]] = smile * 0.3 +blendshapes[:, idx["cheekSquintRight"]] = smile * 0.3 +nose = np.clip(speech_activity * 0.1, 0, 1) +blendshapes[:, idx["noseSneerLeft"]] = nose +blendshapes[:, idx["noseSneerRight"]] = nose +# 無音フレームは抑制 +silence_mask = speech_activity < 0.1 +blendshapes[silence_mask] *= 0.1 +# スムージング +if n_frames > 3: +kernel = np.ones(3) / 3 +for i in range(52): +blendshapes[:, i] = np.convolve(blendshapes[:, i], kernel, mode='same') +logger.info(f"[A2E Engine] Fallback: {n_frames} frames, " +f"jawOpen=[{blendshapes[:, idx['jawOpen']].min():.3f}, " +f"{blendshapes[:, idx['jawOpen']].max():.3f}]") +return blendshapes +def _resample_to_fps( +self, blendshapes: np.ndarray, duration: float, target_fps: int +) -> list: +"""ブレンドシェイプを目標FPSにリサンプリング""" +n_source = blendshapes.shape[0] +n_target = max(1, int(duration * target_fps)) +if n_source == n_target: +frames = blendshapes +else: +source_indices = np.linspace(0, n_source - 1, n_target) +frames = np.zeros((n_target, 52), dtype=np.float32) +for i in range(52): +frames[:, i] = np.interp( +source_indices, np.arange(n_source), blendshapes[:, i] +) +return [frame.tolist() for frame in frames] +PS C:\Users\hamad\audio2exp-service> + +Show more + + +922秒でもロードが終わらないのは、タイムアウトの問題ではなく初期化がハングしています。 + +ログを見ると、ファイル発見後のログがありません: + +08:06:45 [INFO] Checkpoint: /app/models/pretrained_models/lam_audio2exp_streaming.tar +08:06:45 [INFO] Wav2Vec2: /app/models/wav2vec2-base-960h +(ここで止まっている → "Loading config: ..." のログが出ていない) + +つまり、この部分でハングしている可能性が高い: + +from engines.defaults import default_config_parser +from engines.infer import INFER + +このインポートが torch.distributed の初期化を試みて、Cloud Run上でハングしている可能性があります。確認させてください。 + + +Analyzed LAM_Audio2Expression engines module for distributed training initialization +Analyzed LAM_Audio2Expression engines module for distributed training initialization +もっと表示 +もっと表示 + +コードの分析結果、DDP(分散学習)のハングではありませんでした。a2e_engine.py は正しく回避しています。 + +しかし、ログが Wav2Vec2 発見の後に途切れています。from engines.defaults import ... または INFER.build() のどこかでハングしています。 + +正確なハング箇所を特定するため、a2e_engine.py にデバッグログを追加しましょう。 + + +Read LAM Audio2Expression engine initialization code +Read LAM Audio2Expression engine initialization code + +ハング箇所を特定するためにデバッグログを追加します。_try_load_infer_pipeline のインポート〜ビルド部分を修正します: + + +Fixed model device placement and warmup inference logic in a2e_engine.py +Fixed model device placement and warmup inference logic in a2e_engine.py + +Now let me also add Step 6/7 and 7/7 labels to the remaining parts: + + +Read audio2exp-service engine warmup configuration code +Read audio2exp-service engine warmup configuration code + +修正完了です。これでデプロイすると、どのステップでハングしているか正確にわかります。 + +ローカルの a2e_engine.py にこの変更をコピーして再デプロイしてください: + +gcloud run deploy audio2exp-service ` + --source . ` + --project hp-support-477512 ` + --region us-central1 ` + --memory 4Gi ` + --cpu 4 ` + --timeout 300 ` + --min-instances 1 ` + --max-instances 3 ` + --cpu-boost ` + --set-env-vars "MODEL_DIR=/app/models,DEVICE=cpu,WARMUP_TIMEOUT=0,ENGINE_LOAD_TIMEOUT=900" + +デプロイ後、5〜10分待ってからログを確認: + +gcloud run services logs read audio2exp-service ` + --project hp-support-477512 ` + --region us-central1 ` + --limit 100 + +ログに以下のようなステップが出ます: + +Step 1/7: Importing engines.defaults... → Done (Xs) +Step 2/7: Importing engines.infer... → Done (Xs) +Step 3/7: Parsing config... → Done (Xs) +Step 4/7: Building INFER model... → Done (Xs) +Step 5/7: Moving model to device... → Done (Xs) + +どのステップで止まっているかを教えてください。 それに応じて修正します。 + + +Pushed health check fixes to remote branch +Pushed health check fixes to remote branch +もっと表示 +もっと表示 +もっと表示 + +コミット・プッシュ完了しました。 + +ローカルの a2e_engine.py にこの変更を反映して再デプロイし、ログでどのステップで止まっているかを教えてください。 + +gcloud run services logs read audio2exp-service ` + +--project hp-support-477512 --region us-central1 +--limit 100 + +2026-02-23 08:20:53 Loading weights: 89%|████████▉ | 188/211 [00:06<00:00, 28.97it/s, Materializing param=encoder.layers.11.feed_forward.intermediate_dense.weight] +2026-02-23 08:20:54 Loading weights: 89%|████████▉ | 188/211 [00:06<00:00, 28.97it/s, Materializing param=encoder.layers.11.feed_forward.intermediate_dense.weight] +2026-02-23 08:20:54 Loading weights: 90%|████████▉ | 189/211 [00:07<00:00, 28.03it/s, Materializing param=encoder.layers.11.feed_forward.intermediate_dense.weight] +2026-02-23 08:20:54 Loading weights: 90%|████████▉ | 189/211 [00:07<00:00, 28.03it/s, Materializing param=encoder.layers.11.feed_forward.output_dense.bias] +2026-02-23 08:20:54 Loading weights: 90%|████████▉ | 189/211 [00:07<00:00, 28.03it/s, Materializing param=encoder.layers.11.feed_forward.output_dense.bias] +2026-02-23 08:20:54 Loading weights: 90%|█████████ | 190/211 [00:07<00:00, 28.03it/s, Materializing param=encoder.layers.11.feed_forward.output_dense.weight] +2026-02-23 08:20:54 Loading weights: 90%|█████████ | 190/211 [00:07<00:00, 28.03it/s, Materializing param=encoder.layers.11.feed_forward.output_dense.weight] +2026-02-23 08:20:54 Loading weights: 91%|█████████ | 191/211 [00:07<00:00, 28.03it/s, Materializing param=encoder.layers.11.final_layer_norm.bias] +2026-02-23 08:20:54 Loading weights: 91%|█████████ | 191/211 [00:07<00:00, 28.03it/s, Materializing param=encoder.layers.11.final_layer_norm.bias] +2026-02-23 08:20:54 Loading weights: 91%|█████████ | 192/211 [00:07<00:00, 28.03it/s, Materializing param=encoder.layers.11.final_layer_norm.weight] +2026-02-23 08:20:54 Loading weights: 91%|█████████ | 192/211 [00:07<00:00, 28.03it/s, Materializing param=encoder.layers.11.final_layer_norm.weight] +2026-02-23 08:20:54 Loading weights: 91%|█████████▏| 193/211 [00:07<00:00, 28.03it/s, Materializing param=encoder.layers.11.layer_norm.bias] +2026-02-23 08:20:54 Loading weights: 91%|█████████▏| 193/211 [00:07<00:00, 28.03it/s, Materializing param=encoder.layers.11.layer_norm.bias] +2026-02-23 08:20:54 Loading weights: 92%|█████████▏| 194/211 [00:07<00:00, 28.03it/s, Materializing param=encoder.layers.11.layer_norm.weight] +2026-02-23 08:20:54 Loading weights: 92%|█████████▏| 194/211 [00:07<00:00, 28.03it/s, Materializing param=encoder.layers.11.layer_norm.weight] +2026-02-23 08:20:54 Loading weights: 92%|█████████▏| 195/211 [00:07<00:00, 33.25it/s, Materializing param=encoder.layers.11.layer_norm.weight] +2026-02-23 08:20:54 Loading weights: 92%|█████████▏| 195/211 [00:07<00:00, 33.25it/s, Materializing param=encoder.pos_conv_embed.conv.bias] +2026-02-23 08:20:54 Loading weights: 92%|█████████▏| 195/211 [00:07<00:00, 33.25it/s, Materializing param=encoder.pos_conv_embed.conv.bias] +2026-02-23 08:20:54 Loading weights: 93%|█████████▎| 196/211 [00:07<00:00, 33.25it/s, Materializing param=encoder.pos_conv_embed.conv.parametrizations.weight.original0] +2026-02-23 08:20:54 Loading weights: 93%|█████████▎| 196/211 [00:07<00:00, 33.25it/s, Materializing param=encoder.pos_conv_embed.conv.parametrizations.weight.original0] +2026-02-23 08:20:54 Loading weights: 93%|█████████▎| 197/211 [00:07<00:00, 33.25it/s, Materializing param=encoder.pos_conv_embed.conv.parametrizations.weight.original1] +2026-02-23 08:20:54 Loading weights: 93%|█████████▎| 197/211 [00:07<00:00, 33.25it/s, Materializing param=encoder.pos_conv_embed.conv.parametrizations.weight.original1] +2026-02-23 08:20:54 Loading weights: 94%|█████████▍| 198/211 [00:07<00:00, 33.25it/s, Materializing param=feature_extractor.conv_layers.0.conv.weight] +2026-02-23 08:20:54 Loading weights: 94%|█████████▍| 198/211 [00:07<00:00, 33.25it/s, Materializing param=feature_extractor.conv_layers.0.conv.weight] +2026-02-23 08:20:54 Loading weights: 94%|█████████▍| 199/211 [00:07<00:00, 33.25it/s, Materializing param=feature_extractor.conv_layers.0.layer_norm.bias] +2026-02-23 08:20:54 Loading weights: 94%|█████████▍| 199/211 [00:07<00:00, 33.25it/s, Materializing param=feature_extractor.conv_layers.0.layer_norm.bias] +2026-02-23 08:20:54 Loading weights: 95%|█████████▍| 200/211 [00:07<00:00, 30.63it/s, Materializing param=feature_extractor.conv_layers.0.layer_norm.bias] +2026-02-23 08:20:54 Loading weights: 95%|█████████▍| 200/211 [00:07<00:00, 30.63it/s, Materializing param=feature_extractor.conv_layers.0.layer_norm.weight] +2026-02-23 08:20:54 Loading weights: 95%|█████████▍| 200/211 [00:07<00:00, 30.63it/s, Materializing param=feature_extractor.conv_layers.0.layer_norm.weight] +2026-02-23 08:20:54 Loading weights: 95%|█████████▌| 201/211 [00:07<00:00, 30.63it/s, Materializing param=feature_extractor.conv_layers.1.conv.weight] +2026-02-23 08:20:54 Loading weights: 95%|█████████▌| 201/211 [00:07<00:00, 30.63it/s, Materializing param=feature_extractor.conv_layers.1.conv.weight] +2026-02-23 08:20:54 Loading weights: 96%|█████████▌| 202/211 [00:07<00:00, 30.63it/s, Materializing param=feature_extractor.conv_layers.2.conv.weight] +2026-02-23 08:20:54 Loading weights: 96%|█████████▌| 202/211 [00:07<00:00, 30.63it/s, Materializing param=feature_extractor.conv_layers.2.conv.weight] +2026-02-23 08:20:54 Loading weights: 96%|█████████▌| 203/211 [00:07<00:00, 30.63it/s, Materializing param=feature_extractor.conv_layers.3.conv.weight] +2026-02-23 08:20:54 Loading weights: 96%|█████████▌| 203/211 [00:07<00:00, 30.63it/s, Materializing param=feature_extractor.conv_layers.3.conv.weight] +2026-02-23 08:20:54 Loading weights: 97%|█████████▋| 204/211 [00:07<00:00, 30.63it/s, Materializing param=feature_extractor.conv_layers.4.conv.weight] +2026-02-23 08:20:54 Loading weights: 97%|█████████▋| 204/211 [00:07<00:00, 30.63it/s, Materializing param=feature_extractor.conv_layers.4.conv.weight] +2026-02-23 08:20:54 Loading weights: 97%|█████████▋| 205/211 [00:07<00:00, 28.85it/s, Materializing param=feature_extractor.conv_layers.4.conv.weight] +2026-02-23 08:20:54 Loading weights: 97%|█████████▋| 205/211 [00:07<00:00, 28.85it/s, Materializing param=feature_extractor.conv_layers.5.conv.weight] +2026-02-23 08:20:54 Loading weights: 97%|█████████▋| 205/211 [00:07<00:00, 28.85it/s, Materializing param=feature_extractor.conv_layers.5.conv.weight] +2026-02-23 08:20:54 Loading weights: 98%|█████████▊| 206/211 [00:07<00:00, 28.85it/s, Materializing param=feature_extractor.conv_layers.6.conv.weight] +2026-02-23 08:20:54 Loading weights: 98%|█████████▊| 206/211 [00:07<00:00, 28.85it/s, Materializing param=feature_extractor.conv_layers.6.conv.weight] +2026-02-23 08:20:54 Loading weights: 98%|█████████▊| 207/211 [00:07<00:00, 28.85it/s, Materializing param=feature_projection.layer_norm.bias] +2026-02-23 08:20:54 Loading weights: 98%|█████████▊| 207/211 [00:07<00:00, 28.85it/s, Materializing param=feature_projection.layer_norm.bias] +2026-02-23 08:20:54 Loading weights: 99%|█████████▊| 208/211 [00:07<00:00, 28.85it/s, Materializing param=feature_projection.layer_norm.weight] +2026-02-23 08:20:54 Loading weights: 99%|█████████▊| 208/211 [00:07<00:00, 28.85it/s, Materializing param=feature_projection.layer_norm.weight] +2026-02-23 08:20:54 Loading weights: 99%|█████████▉| 209/211 [00:07<00:00, 26.11it/s, Materializing param=feature_projection.layer_norm.weight] +2026-02-23 08:20:54 Loading weights: 99%|█████████▉| 209/211 [00:07<00:00, 26.11it/s, Materializing param=feature_projection.projection.bias] +2026-02-23 08:20:54 Loading weights: 99%|█████████▉| 209/211 [00:07<00:00, 26.11it/s, Materializing param=feature_projection.projection.bias] +2026-02-23 08:20:54 Loading weights: 100%|█████████▉| 210/211 [00:07<00:00, 26.11it/s, Materializing param=feature_projection.projection.weight] +2026-02-23 08:20:54 Loading weights: 100%|█████████▉| 210/211 [00:07<00:00, 26.11it/s, Materializing param=feature_projection.projection.weight] +2026-02-23 08:20:54 Loading weights: 100%|██████████| 211/211 [00:07<00:00, 26.11it/s, Materializing param=masked_spec_embed] +2026-02-23 08:20:54 Loading weights: 100%|██████████| 211/211 [00:07<00:00, 26.11it/s, Materializing param=masked_spec_embed] +2026-02-23 08:20:54 Loading weights: 100%|██████████| 211/211 [00:07<00:00, 27.05it/s, Materializing param=masked_spec_embed] +2026-02-23 08:21:14 Wav2Vec2Model LOAD REPORT from: /app/models/wav2vec2-base-960h +2026-02-23 08:21:14 Key | Status | +2026-02-23 08:21:14 ---------------+---------+- +2026-02-23 08:21:14 lm_head.weight | MISSING | +2026-02-23 08:21:14 lm_head.bias | MISSING | +2026-02-23 08:21:14 Notes: +2026-02-23 08:21:14 - MISSING :those params were newly initialized because missing from the checkpoint. Consider training on your downstream task. +2026-02-23 08:21:20 [2026-02-23 08:21:20,760 INFO infer.py line 76 15] Num params: 97912596 +2026-02-23 08:21:21 [2026-02-23 08:21:21,260 INFO infer.py line 83 15] Loading weight at: /app/models/pretrained_models/lam_audio2exp_streaming.tar +2026-02-23 08:24:47 [2026-02-23 08:24:47,160 INFO infer.py line 95 15] => Loaded weight '/app/models/pretrained_models/lam_audio2exp_streaming.tar' +2026-02-23 08:24:51 2026-02-23 08:24:51,859 [INFO] [A2E Engine] Warmup skipped (WARMUP_TIMEOUT=0) +2026-02-23 08:24:51 2026-02-23 08:24:51,860 [INFO] [A2E Engine] INFER pipeline loaded successfully! +2026-02-23 08:24:51 2026-02-23 08:24:51,860 [INFO] [A2E Engine] Ready (INFER pipeline mode) +2026-02-23 08:24:51 2026-02-23 08:24:51,860 [INFO] [Audio2Exp] Engine initialized successfully +2026-02-23 08:51:24 [2026-02-23 08:51:24 +0000] [13] [INFO] Starting gunicorn 25.1.0 +2026-02-23 08:51:24 [2026-02-23 08:51:24 +0000] [13] [INFO] Listening at: http://0.0.0.0:8080 (13) +2026-02-23 08:51:24 [2026-02-23 08:51:24 +0000] [13] [INFO] Using worker: gthread +2026-02-23 08:51:24 [2026-02-23 08:51:24 +0000] [13] [INFO] Control socket listening at /app/gunicorn.ctl +2026-02-23 08:51:24 [2026-02-23 08:51:24 +0000] [15] [INFO] Booting worker with pid: 15 +2026-02-23 08:51:36 2026-02-23 08:51:36,220 [INFO] [Audio2Exp] Server started, engine loading in background... +2026-02-23 08:51:51 2026-02-23 08:51:51,017 [INFO] [Audio2Exp] Initializing engine: model_dir=/app/models, device=cpu +PS C:\Users\hamad\audio2exp-service> + +Show more + + +素晴らしいニュースです! INFER パイプラインが正常にロードされています! + +ログのタイムライン分析: + +時刻 イベント 所要時間 +08:20:54 Wav2Vec2 weights ロード完了 (211/211) ~7s +08:21:14 Wav2Vec2Model レポート 20s +08:21:21 A2E チェックポイント読み込み開始 (408MB) - +08:24:47 A2E チェックポイント読み込み完了 3分26秒 +08:24:51 Ready (INFER pipeline mode) 合計 ~4分 +ボトルネックは408MBのチェックポイントファイルのCPU上でのロード(3分半)でしたが、正常に完了しています。 + +WARMUP_TIMEOUT=0 のおかげでウォームアップをスキップし、ロード時間が大幅に短縮されました。 + +下部のログは新しいインスタンスの起動です: + +08:51:24 Starting gunicorn +08:51:36 Server started, engine loading in background... +08:51:51 Initializing engine... + +これも同じ流れで4分程度で完了するはずです。 + +ヘルスチェックは通っていますか?確認してみてください: + +# サービスの状態確認 +gcloud run services describe audio2exp-service ` + --project hp-support-477512 ` + --region us-central1 ` + --format "value(status.conditions)" + +# 直接テスト +$URL = (gcloud run services describe audio2exp-service ` + --project hp-support-477512 ` + --region us-central1 ` + --format "value(status.url)") +Invoke-RestMethod "$URL/health" + +gcloud run services describe audio2exp-service ` + +--project hp-support-477512 --region us-central1 +--format "value(status.conditions)" +{'lastTransitionTime': '2026-02-23T08:51:26.623569Z', 'status': 'True', 'type': 'Ready'};{'lastTransitionTime': '2026-02-23T08:51:21.218107Z', 'status': 'True', 'type': 'ConfigurationsReady'};{'lastTransitionTime': '2026-02-23T08:51:26.594426Z', 'status': 'True', 'type': 'RoutesReady'} +PS C:\Users\hamad\audio2exp-service> $URL = (gcloud run services describe audio2exp-service --project hp-support-477512 +--region us-central1 ` +--format "value(status.url)") +Invoke-RestMethod "$URL/health" +elapsed_seconds : 342 +engine_ready : False +model_dir : /app/models +status : loading +timeout_seconds : 900 +PS C:\Users\hamad\audio2exp-service> + +Show more + + +Cloud Run のステータスは全て True で OK です。エンジンは 342秒(約5.7分)経過してまだロード中です。前回のインスタンスは約4分でロードできたので、もう少し待てば完了するはずです。 + +もう1〜2分待ってから再確認してみてください: + +# 最新のログで進捗確認 +gcloud run services logs read audio2exp-service ` + --project hp-support-477512 ` + --region us-central1 ` + --limit 30 + +# ヘルスチェック再確認 +Invoke-RestMethod "$URL/health" + +状況は変化ないね! +PS C:\Users\hamad\audio2exp-service> # 最新のログで進捗確認 + +gcloud run services logs read audio2exp-service --project hp-support-477512 +--region us-central1 ` +--limit 30 + +ヘルスチェック再確認 +Invoke-RestMethod "$URL/health" + +2026-02-23 08:51:24 [2026-02-23 08:51:24 +0000] [13] [INFO] Starting gunicorn 25.1.0 +2026-02-23 08:51:24 [2026-02-23 08:51:24 +0000] [13] [INFO] Listening at: http://0.0.0.0:8080 (13) +2026-02-23 08:51:24 [2026-02-23 08:51:24 +0000] [13] [INFO] Using worker: gthread +2026-02-23 08:51:24 [2026-02-23 08:51:24 +0000] [13] [INFO] Control socket listening at /app/gunicorn.ctl +2026-02-23 08:51:24 [2026-02-23 08:51:24 +0000] [15] [INFO] Booting worker with pid: 15 +2026-02-23 08:51:36 2026-02-23 08:51:36,220 [INFO] [Audio2Exp] Server started, engine loading in background... +2026-02-23 08:51:51 2026-02-23 08:51:51,017 [INFO] [Audio2Exp] Initializing engine: model_dir=/app/models, device=cpu +2026-02-23 08:55:46 2026-02-23 08:55:46,119 [INFO] [A2E Engine] Device: cpu +2026-02-23 08:55:46 2026-02-23 08:55:46,217 [INFO] [A2E Engine] Found LAM_Audio2Expression: /app/LAM_Audio2Expression +2026-02-23 08:55:46 2026-02-23 08:55:46,218 [INFO] [A2E Engine] Checkpoint: /app/models/pretrained_models/lam_audio2exp_streaming.tar +2026-02-23 08:55:46 2026-02-23 08:55:46,218 [INFO] [A2E Engine] Wav2Vec2: /app/models/wav2vec2-base-960h +2026-02-23 08:55:46 2026-02-23 08:55:46,218 [INFO] [A2E Engine] Step 1/7: Importing engines.defaults... +2026-02-23 08:55:57 2026-02-23 08:55:57,321 [INFO] [A2E Engine] Step 1/7: Done (11.1s) +2026-02-23 08:55:57 2026-02-23 08:55:57,322 [INFO] [A2E Engine] Step 2/7: Importing engines.infer... +2026-02-23 08:57:19 GET 200 https://audio2exp-service-6s2ds5mdba-uc.a.run.app/health +2026-02-23 08:58:42 GET 200 https://audio2exp-service-6s2ds5mdba-uc.a.run.app/health +elapsed_seconds : 588 +engine_ready : False +model_dir : /app/models +status : loading +timeout_seconds : 900 + +Show more + + +デバッグログが非常に有用です!ハング箇所が見えました: + +現在のタイムライン: + +時刻 ステップ 所要時間 +08:51:51 エンジン初期化開始 - +08:55:46 import torch 完了 4分 +08:55:57 Step 1/7: engines.defaults import 11秒 +08:55:57→ Step 2/7: engines.infer import... 2.5分以上、まだ実行中 +ハングではなくCPUインスタンスでの極端な遅延です。engines.infer のインポートは transformers ライブラリ (Wav2Vec2Model) をロードするため非常に重いです。 + +前回成功したインスタンス(08:20〜08:24)から逆算すると、全体で約10〜12分かかるはずです。900秒タイムアウトで間に合います。 + +もう少し待ってから確認してみてください(開始から約12分後 = 09:04頃): + +# 約5分後に再確認 +Invoke-RestMethod "$URL/health" + +ただし、毎回10分以上かかるのは問題なので、ロード完了後は min-instances 1 でインスタンスをホットに保つことが重要です。コールドスタートを避ければこの待ち時間は発生しません。 + +Invoke-RestMethod "$URL/health" +elapsed_seconds : 815 +engine_ready : False +model_dir : /app/models +status : loading +timeout_seconds : 900 + + + +815秒...あと85秒でタイムアウトします。Step 2/7のengines.inferインポートが9分以上かかっているのは異常です。メモリ不足でスワッピングしている可能性が高いです。 + +すぐにログを確認してください: + +gcloud run services logs read audio2exp-service ` + --project hp-support-477512 ` + --region us-central1 ` + --limit 20 + +そしてメモリを8GBに増やして再デプロイしましょう。4GBでは torch + transformers + LAMモデル(408MB) を同時にロードするのに不足しています: + +gcloud run deploy audio2exp-service ` + --source . ` + --project hp-support-477512 ` + --region us-central1 ` + --memory 8Gi ` + --cpu 4 ` + --timeout 300 ` + --min-instances 1 ` + --max-instances 3 ` + --cpu-boost ` + --set-env-vars "MODEL_DIR=/app/models,DEVICE=cpu,WARMUP_TIMEOUT=0,ENGINE_LOAD_TIMEOUT=900" + +4Gi → 8Gi でメモリスワッピングが解消されれば、前回の成功インスタンスのように4〜5分でロード完了するはずです。 + +gcloud run services logs read audio2exp-service ` + +--project hp-support-477512 --region us-central1 +--limit 20 + +2026-02-23 09:07:00 Loading weights: 98%|█████████▊| 207/211 [00:06<00:00, 39.12it/s, Materializing param=feature_projection.layer_norm.bias] +2026-02-23 09:07:00 Loading weights: 98%|█████████▊| 207/211 [00:06<00:00, 39.12it/s, Materializing param=feature_projection.layer_norm.bias] +2026-02-23 09:07:00 Loading weights: 99%|█████████▊| 208/211 [00:06<00:00, 39.12it/s, Materializing param=feature_projection.layer_norm.weight] +2026-02-23 09:07:00 Loading weights: 99%|█████████▊| 208/211 [00:06<00:00, 39.12it/s, Materializing param=feature_projection.layer_norm.weight] +2026-02-23 09:07:00 Loading weights: 99%|█████████▉| 209/211 [00:06<00:00, 39.12it/s, Materializing param=feature_projection.projection.bias] +2026-02-23 09:07:00 Loading weights: 99%|█████████▉| 209/211 [00:06<00:00, 39.12it/s, Materializing param=feature_projection.projection.bias] +2026-02-23 09:07:00 Loading weights: 100%|█████████▉| 210/211 [00:06<00:00, 39.12it/s, Materializing param=feature_projection.projection.weight] +2026-02-23 09:07:00 Loading weights: 100%|█████████▉| 210/211 [00:06<00:00, 39.12it/s, Materializing param=feature_projection.projection.weight] +2026-02-23 09:07:00 Loading weights: 100%|██████████| 211/211 [00:06<00:00, 39.12it/s, Materializing param=masked_spec_embed] +2026-02-23 09:07:00 Loading weights: 100%|██████████| 211/211 [00:06<00:00, 39.12it/s, Materializing param=masked_spec_embed] +2026-02-23 09:07:00 Loading weights: 100%|██████████| 211/211 [00:06<00:00, 31.02it/s, Materializing param=masked_spec_embed] +2026-02-23 09:07:28 Wav2Vec2Model LOAD REPORT from: /app/models/wav2vec2-base-960h +2026-02-23 09:07:28 Key | Status | +2026-02-23 09:07:28 ---------------+---------+- +2026-02-23 09:07:28 lm_head.weight | MISSING | +2026-02-23 09:07:28 lm_head.bias | MISSING | +2026-02-23 09:07:28 Notes: +2026-02-23 09:07:28 - MISSING :those params were newly initialized because missing from the checkpoint. Consider training on your downstream task. +2026-02-23 09:07:34 [2026-02-23 09:07:34,017 INFO infer.py line 76 15] Num params: 97912596 +2026-02-23 09:07:34 [2026-02-23 09:07:34,517 INFO infer.py line 83 15] Loading weight at: /app/models/pretrained_models/lam_audio2exp_streaming.tar +PS C:\Users\hamad\audio2exp-service> + +Show more + + +ロードは順調に進んでいます!ハングではありません。ただし、タイムラインを見ると合計約19分かかっています: + +時刻 ステップ 所要時間 +08:51:51 エンジン初期化開始 - +08:55:46 import torch 完了 4分 +08:55:57 Step 1/7 完了 11秒 +09:07:00 Step 2/7: Wav2Vec2 weights ロード 11分 +09:07:34 408MB チェックポイント読み込み開始 - +~09:11 チェックポイント完了(予測) ~3.5分 +合計 ~19分 = 1140秒 +問題: ENGINE_LOAD_TIMEOUT=900(15分)では足りません。 タイムアウトします。 + +2つの修正が必要です: + +ENGINE_LOAD_TIMEOUT を 1500に増加(25分、余裕あり) +メモリを 8Gi に増加(スワッピング解消で高速化の可能性) +gcloud run deploy audio2exp-service ` + --source . ` + --project hp-support-477512 ` + --region us-central1 ` + --memory 8Gi ` + --cpu 4 ` + --timeout 300 ` + --min-instances 1 ` + --max-instances 3 ` + --cpu-boost ` + --set-env-vars "MODEL_DIR=/app/models,DEVICE=cpu,WARMUP_TIMEOUT=0,ENGINE_LOAD_TIMEOUT=1500" + +ただし、現在のインスタンスがあと数分で完了する可能性があります。先にもう少し待って確認してみませんか? + +# 2-3分後に確認 +Invoke-RestMethod "$URL/health" + +もし engine_ready: True になれば、現在のインスタンスで動作確認してから、次回のデプロイでタイムアウトを修正できます。 + +2-3分後に確認 +Invoke-RestMethod "$URL/health" + +Invoke-RestMethod: +Line | +2 | Invoke-RestMethod "$URL/health" +| ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +| +{ +"engine_ready": false, +"error": "Engine loading timed out after 1105s", +"model_dir": "/app/models", +"status": "error" +} +PS C:\Users\hamad\audio2exp-service> + + + +1105秒でタイムアウトしました。チェックポイント読み込みがあと2分で完了するところだったはずです。 + +修正して再デプロイしましょう。メモリ増加 + タイムアウト延長: + +gcloud run deploy audio2exp-service ` + --source . ` + --project hp-support-477512 ` + --region us-central1 ` + --memory 8Gi ` + --cpu 4 ` + --timeout 300 ` + --min-instances 1 ` + --max-instances 3 ` + --cpu-boost ` + --set-env-vars "MODEL_DIR=/app/models,DEVICE=cpu,WARMUP_TIMEOUT=0,ENGINE_LOAD_TIMEOUT=1500" + +変更点: + +4Gi → 8Gi: メモリ増加でインポート・モデルロードが高速化する可能性 +ENGINE_LOAD_TIMEOUT=900 → 1500: 25分に延長(現在の19分+余裕) +デプロイ後、約20分待ってから /health で確認してください。 + +PS C:\Users\hamad\audio2exp-service> gcloud run deploy audio2exp-service ` + +--source . --project hp-support-477512 +--region us-central1 --memory 8Gi +--cpu 4 --timeout 300 +--min-instances 1 --max-instances 3 +--cpu-boost ` +--set-env-vars "MODEL_DIR=/app/models,DEVICE=cpu,WARMUP_TIMEOUT=0,ENGINE_LOAD_TIMEOUT=1500" + +Building using Dockerfile and deploying container to Cloud Run service [audio2exp-service] in project [hp-support-477512] region [us-central1] +OK Building and deploying... Done. +OK Validating Service... +OK Uploading sources... +OK Building Container... Logs are available at [https://console.cloud.google.com/cloud-build/builds;region=us-central +1/147ed4e2-c0b8-44b4-8f34-6dc16479a749?project=417509577941]. +OK Creating Revision... +OK Routing traffic... +Done. +Service [audio2exp-service] revision [audio2exp-service-00027-frw] has been deployed and is serving 100 percent of traffic. +Service URL: https://audio2exp-service-417509577941.us-central1.run.app +PS C:\Users\hamad\audio2exp-service> Invoke-RestMethod "$URL/health" +elapsed_seconds : 131 +engine_ready : False +model_dir : /app/models +status : loading +timeout_seconds : 1500 + +model_dir : /app/models これが気になる!  /app/modelsこれは存在しない + +Show more + + +Dockerfileとコードを確認して、モデルのパスが正しいか調べます。 + + +Reviewed audio2exp-service Dockerfile and app.py configuration files +Reviewed audio2exp-service Dockerfile and app.py configuration files +もっと表示 + +/app/models は存在します。心配不要です。 + +Dockerfile 21行目で: + +RUN mkdir -p /app/models + +さらに25-31行目で wav2vec2 モデルをビルド時にダウンロードして /app/models/wav2vec2-base-960h に保存しています: + +RUN python -c "\ +from transformers import Wav2Vec2Model, Wav2Vec2Processor; \ +p = Wav2Vec2Processor.from_pretrained('facebook/wav2vec2-base-960h'); \ +m = Wav2Vec2Model.from_pretrained('facebook/wav2vec2-base-960h'); \ +p.save_pretrained('/app/models/wav2vec2-base-960h'); \ +m.save_pretrained('/app/models/wav2vec2-base-960h')" + +model_dir: /app/models はDockerイメージ内に正しく存在するディレクトリです。ビルドログでも wav2vec2-base-960h saved to /app/models/wav2vec2-base-960h が出ているはずです。 + +現在の状況: + +status: loading / elapsed: 131秒 → まだロード中 +timeout: 1500秒 → 25分の猶予あり +あと15-20分待ってから再度 /health を確認してください。engine_ready: true になれば成功です。 + +Invoke-RestMethod "$URL/health" +device : cpu +engine_ready : True +mode : infer +model_dir : /app/models +status : healthy +PS C:\Users\hamad\audio2exp-service> + + + +成功です! サービスが完全に起動しました。 + +status : healthy +engine_ready : True +mode : infer +device : cpu + +エンジンのロードが完了し、推論リクエストを受け付けられる状態です。次のステップとして実際に /api/audio2expression にテストリクエストを送って動作確認してみましょう。 + +フロントエンドで即試せないの? + + + +フロントエンドのコードを確認します。 + + +Searched codebase and documented frontend audio2exp service integration points +Searched codebase and documented frontend audio2exp service integration points +もっと表示 +もっと表示 +もっと表示 +もっと表示 +もっと表示 +もっと表示 + +フロントエンドから直接 audio2exp-service を呼ぶ構成ではありません。 + +呼び出しフロー +フロントエンド (gourmet-sp / Vercel) + ↓ POST /api/tts/synthesize +バックエンド (gourmet-support) + ↓ 内部で audio2exp-service を呼ぶ + ↓ TTS音声 + 表情データをまとめて返す +フロントエンド ← { audio, expression } + +フロントエンドは バックエンド経由 で audio2exp を使います。 + +テストするには +バックエンド (gourmet-support) に audio2exp-service の URL を設定する必要があります: + +AUDIO2EXP_SERVICE_URL=https://audio2exp-service-417509577941.us-central1.run.app + +これをバックエンドの環境変数に設定して再デプロイすれば、フロントエンドからTTSリクエスト時に表情データも返ってくるようになります。 + +バックエンドにこの環境変数はもう設定済みですか? + +TTSが機能していない。チャット画面ではLLMからの返答はテキストで表示され、音声が再生されない +[Core] Starting initialization... +concierge.astro:391 ✅ beforeinstallprompt イベント発火 +concierge:1 Banner not shown: beforeinstallpromptevent.preventDefault() called. The page must call beforeinstallpromptevent.prompt() to show the banner. +gaussian-splat-renderer-for-lam.js?v=16fd8d4f:62550 download completed: ArrayBuffer(4094984) +gaussian-splat-renderer-for-lam.js?v=16fd8d4f:62588 _Vector3 12248829 0 +LAMAvatar.astro:294 [LAM Avatar] Camera adjusted: pos=(0,1.72,0.55), fov=38, target.y=1.66 +LAMAvatar.astro:300 LAM Avatar initialized successfully +2LAMAvatar.astro:509 [LAM Idle] buffer=0, ttsActive=false, ttsTime=N/As +LAMAvatar.astro:195 [LAM Health] state=Idle, jaw=0.000, mouth=0.000, buffer=0, ttsActive=false +LAMAvatar.astro:509 [LAM Idle] buffer=0, ttsActive=false, ttsTime=N/As +concierge-controller.ts:303 [Concierge] TTS response has NO expression data (session=d97cca99-acdf-40b7-ae2a-4c4f766d9c92) +speakTextGCP @ concierge-controller.ts:303Understand this warning +2LAMAvatar.astro:509 [LAM Idle] buffer=0, ttsActive=false, ttsTime=N/As +LAMAvatar.astro:195 [LAM Health] state=Idle, jaw=0.000, mouth=0.000, buffer=0, ttsActive=false +2LAMAvatar.astro:509 [LAM Idle] buffer=0, ttsActive=false, ttsTime=N/As +LAMAvatar.astro:195 [LAM Health] state=Idle, jaw=0.000, mouth=0.000, buffer=0, ttsActive=false +LAMAvatar.astro:509 [LAM Idle] buffer=0, ttsActive=false, ttsTime=N/As +LAMAvatar.astro:555 [LAM Avatar] Frame buffer cleared +LAMAvatar.astro:543 [LAM Avatar] Added 311 frames to buffer (total: 311) at 60fps +concierge-controller.ts:443 [Concierge] Expression: 156→311 frames (30→60fps) | jaw: max=0.422 avg=0.071 | funnel: max=0.296 | smile: max=0.122 +2LAMAvatar.astro:509 [LAM Idle] buffer=311, ttsActive=false, ttsTime=N/As +LAMAvatar.astro:195 [LAM Health] state=Idle, jaw=0.000, mouth=0.000, buffer=311, ttsActive=false +LAMAvatar.astro:509 [LAM Idle] buffer=311, ttsActive=false, ttsTime=N/As +LAMAvatar.astro:555 [LAM Avatar] Frame buffer cleared +LAMAvatar.astro:543 [LAM Avatar] Added 617 frames to buffer (total: 617) at 60fps +concierge-controller.ts:443 [Concierge] Expression: 309→617 frames (30→60fps) | jaw: max=0.456 avg=0.073 | funnel: max=0.107 | smile: max=0.183 +2LAMAvatar.astro:509 [LAM Idle] buffer=617, ttsActive=false, ttsTime=N/As +LAMAvatar.astro:195 [LAM Health] state=Idle, jaw=0.000, mouth=0.000, buffer=617, ttsActive=false +2LAMAvatar.astro:509 [LAM Idle] buffer=617, ttsActive=false, ttsTime=N/As +LAMAvatar.astro:509 [LAM Idle] buffer=617, ttsActive=false, ttsTime=N/As +LAMAvatar.astro:195 [LAM Health] state=Idle, jaw=0.000, mouth=0.000, buffer=617, ttsActive=false +2LAMAvatar.astro:509 [LAM Idle] buffer=617, ttsActive=false, ttsTime=N/As + +Show more + + +ログを分析すると問題が見えます: + +Expression データは届いている (2,3チャンク目で frames がバッファされている) +しかし ttsActive=false のまま → 音声が再生されていない +アバターが Idle 状態から変わらず、フレームバッファが消費されない +音声再生のロジックを確認します。 + + +Read concierge-controller.ts component structure +Read concierge-controller.ts component structure + +ログを分析すると、問題の構造が見えます。バックエンドのTTSエンドポイントを確認します。 + + +Searched codebase for TTS and audio synthesis implementations +Searched codebase for TTS and audio synthesis implementations +もっと表示 +もっと表示 + +ログを詳しく分析しました。いくつかの問題が見えます。 + + +Reviewed system architecture and deployment guide documentation for A2E integration +Reviewed system architecture and deployment guide documentation for A2E integration +もっと表示 + +ログを分析した結果、問題の構造が見えました。 + +分析結果 +✅ Expression データ: 届いている (311フレーム、617フレーム) +✅ audio2exp-service: 正常動作中 +❌ ttsActive=false: 音声が一切再生されていない +❌ ttsTime=N/As: TTS playerがLAMAvatarとリンクされていない + +Expression データがバッファに入っているのに音声が再生されていません。 原因を切り分ける必要があります。 + +デバッグ手順(ブラウザで確認してください) +1. コンソールのエラーフィルターを確認 +DevTools Console で 「All levels」 を選択(「Errors」のチェックが外れていないか確認)。play() のエラーが隠れている可能性があります。 + +2. Network タブで TTS レスポンスを確認 +DevTools → Network → メッセージ送信 → /api/tts/synthesize のレスポンスを開いて: + +success: true があるか? +audio フィールドに base64データがあるか?(長い文字列) +expression フィールドがあるか? +3. 手動で音声再生テスト +コンソールで以下を実行: + +// ttsPlayerの状態確認 +const p = document.querySelector('audio'); +console.log('src:', p?.src?.substring(0, 50), 'paused:', p?.paused, 'volume:', p?.volume); + +どの結果が出ますか? それによって次のアクションが変わります: + +audio フィールドが空 → バックエンド(gourmet-support)のTTS問題 +audio あるが play() エラー → ブラウザのAutoplay制限 +src が空 → フロントエンドのコード問題 +上記の3つの確認をお願いします。特に Network タブでの TTS レスポンス確認 が最も重要です。audio フィールドにデータがあるかどうかで原因が切り分けられます。 + +synthesize は200 + + + +OK, TTS の synthesize エンドポイントは 200 OK を返しているんですね。 + +次の確認をお願いします: + +Network タブで該当レスポンスのボディを確認してください: + +レスポンスの audio フィールドに base64文字列が入っているか(空文字やnullではないか) +success が true になっているか +expression フィールドの有無 +具体的には、Network タブで該当リクエストをクリック → Response タブ or Preview タブで JSON の中身を見てください。 + +data.audio が空だとフロントエンドで音声が再生されません。もし audio にデータが入っている場合は、フロントエンドの再生ロジック側の問題になります。 + +audio フィールド が見当たらない + + + +それが原因です。audio フィールドがないため音声が再生されません。 + +レスポンスのJSONにどのフィールドが含まれているか教えてください。 + +Response タブに表示されている JSON の内容(フィールド名と値の概要)を共有してもらえますか?例えば: + +success はあるか? +expression はあるか? +それ以外にどんなキーがあるか? +バックエンド(gourmet-support)側で Google Cloud TTS の呼び出しが失敗しているか、レスポンス構造が想定と異なっている可能性があります。 + diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..eb7be6a --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,12 @@ +[tool.pytest.ini_options] +testpaths = ["tests"] +python_files = ["test_*.py"] +python_classes = ["Test*"] +python_functions = ["test_*"] +markers = [ + "unit: Unit tests (no external dependencies)", + "api: API contract tests (Flask test client)", + "integration: Integration tests (requires models)", + "slow: Slow tests (model loading, inference)", +] +addopts = "-v --tb=short -m 'not integration and not slow'" diff --git a/scripts/push_support_base.sh b/scripts/push_support_base.sh new file mode 100755 index 0000000..f9227e3 --- /dev/null +++ b/scripts/push_support_base.sh @@ -0,0 +1,82 @@ +#!/bin/bash +# support-base リポへの初期プッシュスクリプト (Windows Git Bash 対応) +# +# 使い方: +# git clone -b claude/platform-design-docs-oEVkm https://github.com/mirai-gpro/LAM_gpro.git +# cd LAM_gpro +# bash scripts/push_support_base.sh + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" +WORK_DIR="${TMPDIR:-/tmp}/support-base-work-$$" +mkdir -p "$WORK_DIR" + +echo "=== support-base リポへプッシュ ===" + +# 1. support-base リポをクローン +echo "[1/4] support-base をクローン..." +git clone https://github.com/mirai-gpro/support-base.git "$WORK_DIR/support-base" + +# 2. ファイルをコピー +echo "[2/4] ファイルをコピー..." +TARGET="$WORK_DIR/support-base" + +# .gitignore +cat > "$TARGET/.gitignore" << 'GITIGNORE' +__pycache__/ +*.pyc +*.pyo +.env +.venv/ +*.egg-info/ +dist/ +build/ +GITIGNORE + +# Python パッケージをコピー(cp -r で、rsync 不要) +cp -r "$REPO_ROOT/support_base" "$TARGET/support_base" + +# __pycache__ を除去 +find "$TARGET/support_base" -name '__pycache__' -type d -exec rm -rf {} + 2>/dev/null || true +find "$TARGET/support_base" -name '*.pyc' -delete 2>/dev/null || true + +# ルートに移すべきファイルを移動 +mv "$TARGET/support_base/Dockerfile" "$TARGET/" +mv "$TARGET/support_base/requirements.txt" "$TARGET/" +mv "$TARGET/support_base/cloudbuild.yaml" "$TARGET/" + +# Dockerfile の COPY パスを修正 (sed -i はGit Bashで動作) +sed -i 's|^COPY \. \./support_base/$|COPY support_base/ ./support_base/|' "$TARGET/Dockerfile" + +# cloudbuild.yaml: dir 行を削除、トリガーコメントを更新 +sed -i "/^ dir: 'support_base'$/d" "$TARGET/cloudbuild.yaml" +sed -i 's|--repo-name=LAM_gpro|--repo-name=support-base|' "$TARGET/cloudbuild.yaml" +sed -i "s|--build-config=support_base/cloudbuild.yaml|--build-config=cloudbuild.yaml|" "$TARGET/cloudbuild.yaml" +sed -i '/--included-files=/d' "$TARGET/cloudbuild.yaml" +sed -i 's|cd support_base|# ルートから実行|' "$TARGET/cloudbuild.yaml" +sed -i 's|--config=cloudbuild.yaml \.|--config=cloudbuild.yaml .|' "$TARGET/cloudbuild.yaml" + +# 3. コミット +echo "[3/4] コミット..." +cd "$TARGET" +git add -A +git commit -m "feat: support-base プラットフォーム初期構成 + +LAM_gpro/support_base から独立リポジトリとして移植。 +- FastAPI + Gemini Live API WebSocket 中継 +- REST エンドポイント (Flask→FastAPI 変換済み) +- プラグインアーキテクチャ (gourmet モード) +- Cloud Run デプロイ設定" + +# 4. プッシュ +echo "[4/4] プッシュ..." +git push -u origin main + +echo "" +echo "=== 完了 ===" +echo "https://github.com/mirai-gpro/support-base" + +# 後片付け +rm -rf "$WORK_DIR" diff --git a/scripts/test_a2e_japanese_audio.py b/scripts/test_a2e_japanese_audio.py new file mode 100644 index 0000000..7f3f558 --- /dev/null +++ b/scripts/test_a2e_japanese_audio.py @@ -0,0 +1,271 @@ +""" +日本語音声 A2E テスト - 簡易スタンドアロン版 + +OpenAvatarChat で data_bundle.py の修正が正しく機能するかテストします。 + +使い方: + cd C:\Users\hamad\OpenAvatarChat + conda activate oac + python scripts/test_a2e_japanese_audio.py + +このスクリプトを C:\Users\hamad\OpenAvatarChat\scripts\ にコピーして実行してください。 +""" + +import sys +import os +import time +import traceback +from pathlib import Path + +# OpenAvatarChatのルートディレクトリを検出 +SCRIPT_DIR = Path(__file__).parent +OAC_DIR = SCRIPT_DIR.parent # scripts/ の親 = OpenAvatarChat/ + +def print_header(title): + print(f"\n{'='*60}") + print(f" {title}") + print(f"{'='*60}") + + +def test_1_environment(): + """テスト1: 環境チェック""" + print_header("TEST 1: Environment Check") + errors = [] + + # Python version + print(f" Python: {sys.version}") + + # NumPy + try: + import numpy as np + print(f" NumPy: {np.__version__}") + except ImportError: + errors.append("NumPy not installed") + + # PyTorch + try: + import torch + print(f" PyTorch: {torch.__version__}") + print(f" CUDA available: {torch.cuda.is_available()}") + except ImportError: + errors.append("PyTorch not installed") + + # transformers + try: + import transformers + print(f" Transformers: {transformers.__version__}") + except ImportError: + errors.append("transformers not installed") + + # onnxruntime + try: + import onnxruntime + print(f" ONNXRuntime: {onnxruntime.__version__}") + except ImportError: + print(" ONNXRuntime: not installed (optional)") + + if errors: + for e in errors: + print(f" [ERROR] {e}") + return False + + print(" [PASS] Environment OK") + return True + + +def test_2_model_files(): + """テスト2: モデルファイル存在確認""" + print_header("TEST 2: Model Files Check") + + checks = { + "LAM_audio2exp dir": OAC_DIR / "models" / "LAM_audio2exp", + "wav2vec2-base-960h dir": OAC_DIR / "models" / "wav2vec2-base-960h", + "pretrained_models dir": OAC_DIR / "models" / "LAM_audio2exp" / "pretrained_models", + } + + all_ok = True + for label, path in checks.items(): + exists = path.exists() + status = "OK" if exists else "MISSING" + print(f" [{status}] {label}: {path}") + if not exists: + all_ok = False + + if all_ok: + print(" [PASS] All model directories found") + else: + print(" [FAIL] Some model files missing") + return all_ok + + +def test_3_data_bundle_fix(): + """テスト3: data_bundle.py の list/tuple → ndarray 変換テスト""" + print_header("TEST 3: data_bundle.py Fix Verification") + + try: + import numpy as np + + # data_bundle.py のパスを確認 + db_path = OAC_DIR / "src" / "chat_engine" / "data_models" / "runtime_data" / "data_bundle.py" + if not db_path.exists(): + print(f" [SKIP] File not found: {db_path}") + return True # ファイルがなければスキップ + + # ファイル内容をチェック + content = db_path.read_text(encoding="utf-8") + if "isinstance(data, (list, tuple))" in content: + print(" [OK] list/tuple conversion patch found in data_bundle.py") + else: + print(" [WARN] list/tuple conversion patch NOT found in data_bundle.py") + print(" Add this before 'if isinstance(data, np.ndarray)'::") + print(" if isinstance(data, (list, tuple)):") + print(" data = np.array(data, dtype=np.float32)") + return False + + # 実際に変換が動作するかテスト + test_list = [0.1, 0.2, 0.3, 0.4, 0.5] + test_tuple = (0.1, 0.2, 0.3) + arr_from_list = np.array(test_list, dtype=np.float32) + arr_from_tuple = np.array(test_tuple, dtype=np.float32) + + assert isinstance(arr_from_list, np.ndarray), "list→ndarray conversion failed" + assert isinstance(arr_from_tuple, np.ndarray), "tuple→ndarray conversion failed" + assert arr_from_list.dtype == np.float32, "dtype should be float32" + print(f" [OK] list→ndarray: {test_list} → shape={arr_from_list.shape}") + print(f" [OK] tuple→ndarray: {test_tuple} → shape={arr_from_tuple.shape}") + + print(" [PASS] data_bundle.py fix is correct") + return True + + except Exception as e: + print(f" [FAIL] {e}") + traceback.print_exc() + return False + + +def test_4_wav2vec2_load(): + """テスト4: Wav2Vec2モデルの読み込みテスト""" + print_header("TEST 4: Wav2Vec2 Model Loading") + + try: + import torch + from transformers import Wav2Vec2Model, Wav2Vec2Processor + import numpy as np + + wav2vec_dir = OAC_DIR / "models" / "wav2vec2-base-960h" + if wav2vec_dir.exists() and (wav2vec_dir / "config.json").exists(): + model_path = str(wav2vec_dir) + print(f" Loading from local: {model_path}") + else: + model_path = "facebook/wav2vec2-base-960h" + print(f" Loading from HuggingFace: {model_path}") + + t0 = time.time() + model = Wav2Vec2Model.from_pretrained(model_path) + model.eval() + elapsed = time.time() - t0 + print(f" Model loaded in {elapsed:.1f}s") + + # ダミー音声でテスト (1秒の無音) + dummy_audio = np.zeros(16000, dtype=np.float32) + try: + processor = Wav2Vec2Processor.from_pretrained(model_path) + except Exception: + processor = Wav2Vec2Processor.from_pretrained("facebook/wav2vec2-base-960h") + + inputs = processor(dummy_audio, sampling_rate=16000, return_tensors="pt", padding=True) + with torch.no_grad(): + outputs = model(**inputs) + + features = outputs.last_hidden_state + print(f" Output shape: {tuple(features.shape)}") + print(f" [PASS] Wav2Vec2 working correctly") + return True + + except Exception as e: + print(f" [FAIL] {e}") + traceback.print_exc() + return False + + +def test_5_a2e_import(): + """テスト5: A2Eモジュールのインポートテスト""" + print_header("TEST 5: A2E Module Import") + + # sys.pathにOpenAvatarChatのパスを追加 + paths_to_add = [ + str(OAC_DIR / "src"), + str(OAC_DIR / "src" / "handlers"), + str(OAC_DIR / "src" / "handlers" / "avatar" / "lam"), + str(OAC_DIR / "src" / "handlers" / "avatar" / "lam" / "LAM_Audio2Expression"), + ] + for p in paths_to_add: + if p not in sys.path and os.path.exists(p): + sys.path.insert(0, p) + + imported = False + + # 方法1: A2E直接インポート + try: + from LAM_Audio2Expression.engines.infer import Audio2ExpressionInfer + print(" [OK] A2E infer module imported") + imported = True + except ImportError as e: + print(f" [INFO] Direct A2E import failed: {e}") + + # 方法2: handler経由 + if not imported: + try: + from avatar.lam.avatar_handler_lam_audio2expression import HandlerAvatarLAM + print(" [OK] A2E handler module imported") + imported = True + except ImportError as e: + print(f" [INFO] Handler import failed: {e}") + + if imported: + print(" [PASS] A2E module is importable") + else: + print(" [WARN] A2E module not importable (may need specific env)") + print(" This is OK if other tests pass") + + return True # インポート失敗でも致命的ではない + + +def main(): + print("=" * 60) + print(" A2E Japanese Audio Test - Standalone") + print(f" OAC Dir: {OAC_DIR}") + print(f" Time: {time.strftime('%Y-%m-%d %H:%M:%S')}") + print("=" * 60) + + results = {} + results["environment"] = test_1_environment() + results["model_files"] = test_2_model_files() + results["data_bundle_fix"] = test_3_data_bundle_fix() + results["wav2vec2"] = test_4_wav2vec2_load() + results["a2e_import"] = test_5_a2e_import() + + # サマリー + print_header("SUMMARY") + passed = 0 + total = len(results) + for name, ok in results.items(): + status = "PASS" if ok else "FAIL" + print(f" [{status}] {name}") + if ok: + passed += 1 + + print(f"\n Result: {passed}/{total} passed") + + if passed == total: + print("\n All tests passed!") + print(" Next step: Start OpenAvatarChat and test with Japanese voice:") + print(" python src/demo.py --config config/chat_with_lam_jp.yaml") + else: + print("\n Some tests failed. Fix the issues above and re-run.") + + return 0 if passed == total else 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/services/DEPLOYMENT_GUIDE.md b/services/DEPLOYMENT_GUIDE.md new file mode 100644 index 0000000..188df3b --- /dev/null +++ b/services/DEPLOYMENT_GUIDE.md @@ -0,0 +1,214 @@ +# A2E (Audio2Expression) 統合デプロイメントガイド + +## アーキテクチャ + +``` +[ブラウザ (gourmet-sp)] + ↕ REST API +[gourmet-support (Cloud Run)] + ├── /api/tts/synthesize → Google Cloud TTS → MP3 + │ ↓ (MP3 base64) + │ [audio2exp-service (Cloud Run)] + │ ↓ Wav2Vec2 → A2E Decoder + │ ↓ 52-dim ARKit blendshapes + │ ↓ + └── JSON Response: { audio: "mp3...", expression: {names, frames, frame_rate} } +``` + +## サービス構成 + +| サービス | 説明 | デプロイ先 | +|----------|------|-----------| +| gourmet-support | メインバックエンド | Cloud Run (既存) | +| audio2exp-service | A2E推論マイクロサービス | Cloud Run (新規) | +| gourmet-sp | フロントエンド | Vercel (既存) | + +## デプロイ手順 + +### 1. audio2exp-service のデプロイ + +#### 1a. モデルの準備 + +```bash +# LAM_audio2exp Streaming モデル (HuggingFace) +# ※ Non-Streaming フルモデルは未公開。Streaming + バッチ推論 + フルポストプロセッシングで運用。 +mkdir -p models +wget -O models/LAM_audio2exp_streaming.tar \ + https://huggingface.co/3DAIGC/LAM_audio2exp/resolve/main/LAM_audio2exp_streaming.tar + +# Wav2Vec2 モデル +git lfs install +git clone https://huggingface.co/facebook/wav2vec2-base-960h models/wav2vec2-base-960h +``` + +対応するディレクトリ構造(どちらでもOK): +``` +models/ +├── LAM_audio2exp_streaming.tar ← フラット配置(推奨) +└── wav2vec2-base-960h/ + +# または +models/ +├── pretrained_models/ +│ └── lam_audio2exp_streaming.tar ← 展開済み配置 +└── wav2vec2-base-960h/ +``` + +**品質向上ポイント**: Streaming モデルでも、バッチ推論(全音声一括入力)+ +ポストプロセッシング強化(movement_smooth, brow_movement)により高品質な出力を実現。 + +#### 1b. ローカルテスト + +```bash +cd services/audio2exp-service + +# 依存関係インストール +pip install -r requirements.txt + +# 起動 +MODEL_DIR=./models python app.py + +# ヘルスチェック +curl http://localhost:8081/health +``` + +#### 1c. Docker ビルド & Cloud Run デプロイ + +```bash +# Cloud Run デプロイ(--source 方式、推奨) +# ※ modelsディレクトリに Streaming モデルを配置してから実行 +gcloud run deploy audio2exp-service \ + --source . \ + --project hp-support-477512 \ + --region us-central1 \ + --memory 4Gi \ + --cpu 4 \ + --timeout 300 \ + --min-instances 1 \ + --max-instances 3 \ + --cpu-boost \ + --set-env-vars "MODEL_DIR=/app/models,DEVICE=cpu,WARMUP_TIMEOUT=0,ENGINE_LOAD_TIMEOUT=1500" +``` + +**成功パラメータの根拠:** +- `--memory 4Gi`: torch + transformers + LAM Streaming モデルの同時ロードに必要 +- `--cpu 4`: ロード高速化 +- `--cpu-boost`: 起動時のCPUブースト +- `ENGINE_LOAD_TIMEOUT=1500`: CPUでのモデルロードに約19分→25分の猶予 +- `WARMUP_TIMEOUT=0`: warmup(ダミー推論)をスキップ(CPUタイムアウト回避) +- `--min-instances 1`: コールドスタートを排除 + +### 2. gourmet-support の設定 + +```bash +# 環境変数に audio2exp-service のURLを設定 +gcloud run services update gourmet-support \ + --set-env-vars "AUDIO2EXP_SERVICE_URL=https://audio2exp-service-xxxxx.run.app" +``` + +`app_customer_support.py` は既に `AUDIO2EXP_SERVICE_URL` を参照済み。 + +### 3. フロントエンド (gourmet-sp) の更新 + +1. `services/frontend-patches/vrm-expression-manager.ts` を + `gourmet-sp/src/scripts/avatar/` にコピー + +2. `FRONTEND_INTEGRATION.md` に従って + `concierge-controller.ts` を修正 + +3. Vercel にデプロイ + +## モデルサイズ + +| モデル | サイズ | 用途 | +|--------|--------|------| +| wav2vec2-base-960h | ~360MB | 音響特徴量抽出 | +| LAM_audio2exp_streaming | ~373MB (圧縮) | 表情デコーダー (Streaming, 12 identity) | +| Total | ~733MB | | + +## API リファレンス + +### POST /api/audio2expression + +**Request:** +```json +{ + "audio_base64": "", + "session_id": "uuid-string", + "is_start": true, + "is_final": true, + "audio_format": "mp3" +} +``` + +**Response (成功):** +```json +{ + "names": [ + "eyeBlinkLeft", "eyeLookDownLeft", ..., "tongueOut" + ], + "frames": [ + [0.0, 0.0, ..., 0.0], + [0.1, 0.0, ..., 0.0], + ... + ], + "frame_rate": 30 +} +``` + +**Response (エラー):** +```json +{ + "error": "Error message" +} +``` + +### GET /health + +**Response:** +```json +{ + "status": "healthy", + "engine_ready": true, + "device": "cpu", + "model_dir": "/app/models" +} +``` + +## パフォーマンス目標 + +| 指標 | 目標値 | 備考 | +|------|--------|------| +| 推論レイテンシ | < 3秒 (1文あたり) | CPU, 4vCPU, バッチ推論 | +| TTS + A2E合計 | < 5秒 | 並列化不可 (TTS→A2E) | +| メモリ使用量 | < 3GB | Streaming モデル + ポストプロセッシング | +| 同時リクエスト | 3 | max-instances=3 | + +## フォールバック動作 + +`AUDIO2EXP_SERVICE_URL` が未設定、またはサービスがダウンしている場合: + +1. バックエンドは `expression` フィールドなしでレスポンスを返す +2. フロントエンドは従来のFFTベースリップシンクで動作(劣化なし) +3. ヘルスチェックで `audio2exp: "not configured"` が表示される + +## トラブルシューティング + +### A2Eサービスが応答しない +```bash +# ログ確認 +gcloud run services logs read audio2exp-service --limit 50 + +# ヘルスチェック +curl https://audio2exp-service-xxxxx.run.app/health +``` + +### expressionデータが空 +- `AUDIO2EXP_SERVICE_URL` が正しく設定されているか確認 +- gourmet-support のログで `[Audio2Exp]` を検索 +- タイムアウト(10秒)を超えていないか確認 + +### リップシンクがFFTと変わらない +- フロントエンドに `vrm-expression-manager.ts` が追加されているか +- `concierge-controller.ts` で `session_id` を送信しているか +- ブラウザのdevtoolsで `/api/tts/synthesize` のレスポンスに `expression` があるか diff --git a/services/audio2exp-service/.gcloudignore b/services/audio2exp-service/.gcloudignore new file mode 100644 index 0000000..cde32a5 --- /dev/null +++ b/services/audio2exp-service/.gcloudignore @@ -0,0 +1,7 @@ +# .gcloudignore - Cloud Build用の除外設定 +# ★ models/ は除外しない(Dockerイメージにベイクインするため) + +__pycache__/ +*.pyc +.git +.gitignore diff --git a/services/audio2exp-service/.gitignore b/services/audio2exp-service/.gitignore new file mode 100644 index 0000000..78510a0 --- /dev/null +++ b/services/audio2exp-service/.gitignore @@ -0,0 +1,4 @@ +# Model files (baked into Docker image via .gcloudignore, not committed to git) +models/ +__pycache__/ +*.pyc diff --git a/services/audio2exp-service/Dockerfile b/services/audio2exp-service/Dockerfile new file mode 100644 index 0000000..3725bc5 --- /dev/null +++ b/services/audio2exp-service/Dockerfile @@ -0,0 +1,30 @@ +FROM python:3.11-slim + +# ffmpeg (pydub dependency), libsndfile (librosa dependency) +RUN apt-get update && apt-get install -y --no-install-recommends \ + ffmpeg \ + libsndfile1 \ + && rm -rf /var/lib/apt/lists/* + +WORKDIR /app + +# CPU-only PyTorch + torchaudio (CUDA不要、イメージ ~700MB 軽量化、import 高速化) +RUN pip install --no-cache-dir \ + torch torchaudio --index-url https://download.pytorch.org/whl/cpu + +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +COPY . . + +# INFER ログ出力先 +RUN mkdir -p /tmp/audio2exp_logs/model + +ENV PORT=8080 +ENV MODEL_DIR=/app/models +ENV DEVICE=cpu + +EXPOSE 8080 + +# Shell form so $PORT is expanded at runtime (Cloud Run injects PORT=8080) +CMD gunicorn --bind "0.0.0.0:${PORT}" --timeout 120 --workers 1 --threads 4 app:app diff --git a/services/audio2exp-service/LAM_Audio2Expression/.gitignore b/services/audio2exp-service/LAM_Audio2Expression/.gitignore new file mode 100644 index 0000000..73c532f --- /dev/null +++ b/services/audio2exp-service/LAM_Audio2Expression/.gitignore @@ -0,0 +1,18 @@ +image/ +__pycache__ +**/build/ +**/*.egg-info/ +**/dist/ +*.so +exp +weights +data +log +outputs/ +.vscode +.idea +*/.DS_Store +TEMP/ +pretrained/ +**/*.out +Dockerfile \ No newline at end of file diff --git a/services/audio2exp-service/LAM_Audio2Expression/LICENSE b/services/audio2exp-service/LAM_Audio2Expression/LICENSE new file mode 100644 index 0000000..f49a4e1 --- /dev/null +++ b/services/audio2exp-service/LAM_Audio2Expression/LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. \ No newline at end of file diff --git a/services/audio2exp-service/LAM_Audio2Expression/README.md b/services/audio2exp-service/LAM_Audio2Expression/README.md new file mode 100644 index 0000000..7f9e2c2 --- /dev/null +++ b/services/audio2exp-service/LAM_Audio2Expression/README.md @@ -0,0 +1,123 @@ +# LAM-A2E: Audio to Expression + +[![Website](https://raw.githubusercontent.com/prs-eth/Marigold/main/doc/badges/badge-website.svg)](https://aigc3d.github.io/projects/LAM/) +[![Apache License](https://img.shields.io/badge/📃-Apache--2.0-929292)](https://www.apache.org/licenses/LICENSE-2.0) +[![ModelScope Demo](https://img.shields.io/badge/%20ModelScope%20-Space-blue)](https://www.modelscope.cn/studios/Damo_XR_Lab/LAM-A2E) + +## Description +#### This project leverages audio input to generate ARKit blendshapes-driven facial expressions in ⚡real-time⚡, powering ultra-realistic 3D avatars generated by [LAM](https://github.com/aigc3d/LAM). +To enable ARKit-driven animation of the LAM model, we adapted ARKit blendshapes to align with FLAME's facial topology through manual customization. The LAM-A2E network follows an encoder-decoder architecture, as shown below. We adopt the state-of-the-art pre-trained speech model Wav2Vec for the audio encoder. The features extracted from the raw audio waveform are combined with style features and fed into the decoder, which outputs stylized blendshape coefficients. + +
+Architecture +
+ +## Demo + +
+ +
+ +## 📢 News + +**[May 21, 2025]** We have released a [Avatar Export Feature](https://www.modelscope.cn/studios/Damo_XR_Lab/LAM_Large_Avatar_Model), enabling users to generate facial expressions from audio using any [LAM-generated](https://github.com/aigc3d/LAM) 3D digital humans.
+**[April 21, 2025]** We have released the [ModelScope](https://www.modelscope.cn/studios/Damo_XR_Lab/LAM-A2E) Space !
+**[April 21, 2025]** We have released the WebGL Interactive Chatting Avatar SDK on [OpenAvatarChat](https://github.com/HumanAIGC-Engineering/OpenAvatarChat) (including LLM, ASR, TTS, Avatar), with which you can freely chat with our generated 3D Digital Human ! 🔥
+ +### To do list +- [ ] Release Huggingface space. +- [x] Release [Modelscope demo space](https://www.modelscope.cn/studios/Damo_XR_Lab/LAM-A2E). You can try the demo or pull the demo source code and deploy it on your own machine. +- [ ] Release the LAM-A2E model based on the Flame expression. +- [x] Release Interactive Chatting Avatar SDK with [OpenAvatarChat](https://www.modelscope.cn/studios/Damo_XR_Lab/LAM-A2E), including LLM, ASR, TTS, LAM-Avatars. + + + +## 🚀 Get Started +### Environment Setup +```bash +git clone git@github.com:aigc3d/LAM_Audio2Expression.git +cd LAM_Audio2Expression +# Create conda environment (currently only supports Python 3.10) +conda create -n lam_a2e python=3.10 +# Activate the conda environment +conda activate lam_a2e +# Install with Cuda 12.1 +sh ./scripts/install/install_cu121.sh +# Or Install with Cuda 11.8 +sh ./scripts/install/install_cu118.sh +``` + + +### Download + +``` +# HuggingFace download +# Download Assets and Model Weights +huggingface-cli download 3DAIGC/LAM_audio2exp --local-dir ./ +tar -xzvf LAM_audio2exp_assets.tar && rm -f LAM_audio2exp_assets.tar +tar -xzvf LAM_audio2exp_streaming.tar && rm -f LAM_audio2exp_streaming.tar + +# Or OSS Download (In case of HuggingFace download failing) +# Download Assets +wget https://virutalbuy-public.oss-cn-hangzhou.aliyuncs.com/share/aigc3d/data/LAM/LAM_audio2exp_assets.tar +tar -xzvf LAM_audio2exp_assets.tar && rm -f LAM_audio2exp_assets.tar +# Download Model Weights +wget https://virutalbuy-public.oss-cn-hangzhou.aliyuncs.com/share/aigc3d/data/LAM/LAM_audio2exp_streaming.tar +tar -xzvf LAM_audio2exp_streaming.tar && rm -f LAM_audio2exp_streaming.tar + +Or Modelscope Download +git clone https://www.modelscope.cn/Damo_XR_Lab/LAM_audio2exp.git ./modelscope_download +``` + + +### Quick Start Guide +#### Using Gradio Interface: +We provide a simple Gradio demo with **WebGL Render**, and you can get rendering results by uploading audio in seconds. + +[//]: # (teaser) +
+ +
+ + +``` +python app_lam_audio2exp.py +``` + +### Inference +```bash +# example: python inference.py --config-file configs/lam_audio2exp_config_streaming.py --options save_path=exp/audio2exp weight=pretrained_models/lam_audio2exp_streaming.tar audio_input=./assets/sample_audio/BarackObama_english.wav +python inference.py --config-file ${CONFIG_PATH} --options save_path=${SAVE_PATH} weight=${CHECKPOINT_PATH} audio_input=${AUDIO_INPUT} +``` + +### Acknowledgement +This work is built on many amazing research works and open-source projects: +- [FLAME](https://flame.is.tue.mpg.de) +- [FaceFormer](https://github.com/EvelynFan/FaceFormer) +- [Meshtalk](https://github.com/facebookresearch/meshtalk) +- [Unitalker](https://github.com/X-niper/UniTalker) +- [Pointcept](https://github.com/Pointcept/Pointcept) + +Thanks for their excellent works and great contribution. + + +### Related Works +Welcome to follow our other interesting works: +- [LAM](https://github.com/aigc3d/LAM) +- [LHM](https://github.com/aigc3d/LHM) + + +### Citation +``` +@inproceedings{he2025LAM, + title={LAM: Large Avatar Model for One-shot Animatable Gaussian Head}, + author={ + Yisheng He and Xiaodong Gu and Xiaodan Ye and Chao Xu and Zhengyi Zhao and Yuan Dong and Weihao Yuan and Zilong Dong and Liefeng Bo + }, + booktitle={arXiv preprint arXiv:2502.17796}, + year={2025} +} +``` diff --git a/services/audio2exp-service/LAM_Audio2Expression/app_lam_audio2exp.py b/services/audio2exp-service/LAM_Audio2Expression/app_lam_audio2exp.py new file mode 100644 index 0000000..56c2339 --- /dev/null +++ b/services/audio2exp-service/LAM_Audio2Expression/app_lam_audio2exp.py @@ -0,0 +1,313 @@ +""" +Copyright 2024-2025 The Alibaba 3DAIGC Team Authors. All rights reserved. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + https://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +""" +import os +import base64 + +import gradio as gr +import argparse +from omegaconf import OmegaConf +from gradio_gaussian_render import gaussian_render + +from engines.defaults import ( + default_argument_parser, + default_config_parser, + default_setup, +) +from engines.infer import INFER +from pathlib import Path + +try: + import spaces +except: + pass + +import patoolib + +h5_rendering = True + + +def assert_input_image(input_image,input_zip_textbox): + if(os.path.exists(input_zip_textbox)): + return + if input_image is None: + raise gr.Error('No image selected or uploaded!') + + +def prepare_working_dir(): + import tempfile + working_dir = tempfile.TemporaryDirectory() + return working_dir + +def get_image_base64(path): + with open(path, 'rb') as image_file: + encoded_string = base64.b64encode(image_file.read()).decode() + return f'data:image/png;base64,{encoded_string}' + + +def do_render(): + print('WebGL rendering ....') + return + +def audio_loading(): + print("Audio loading ....") + return "None" + +def parse_configs(): + parser = argparse.ArgumentParser() + parser.add_argument("--config", type=str) + parser.add_argument("--infer", type=str) + args, unknown = parser.parse_known_args() + + cfg = OmegaConf.create() + cli_cfg = OmegaConf.from_cli(unknown) + + # parse from ENV + if os.environ.get("APP_INFER") is not None: + args.infer = os.environ.get("APP_INFER") + if os.environ.get("APP_MODEL_NAME") is not None: + cli_cfg.model_name = os.environ.get("APP_MODEL_NAME") + + args.config = args.infer if args.config is None else args.config + + if args.config is not None: + cfg_train = OmegaConf.load(args.config) + cfg.source_size = cfg_train.dataset.source_image_res + try: + cfg.src_head_size = cfg_train.dataset.src_head_size + except: + cfg.src_head_size = 112 + cfg.render_size = cfg_train.dataset.render_image.high + _relative_path = os.path.join( + cfg_train.experiment.parent, + cfg_train.experiment.child, + os.path.basename(cli_cfg.model_name).split("_")[-1], + ) + + cfg.save_tmp_dump = os.path.join("exps", "save_tmp", _relative_path) + cfg.image_dump = os.path.join("exps", "images", _relative_path) + cfg.video_dump = os.path.join("exps", "videos", _relative_path) # output path + + if args.infer is not None: + cfg_infer = OmegaConf.load(args.infer) + cfg.merge_with(cfg_infer) + cfg.setdefault( + "save_tmp_dump", os.path.join("exps", cli_cfg.model_name, "save_tmp") + ) + cfg.setdefault("image_dump", os.path.join("exps", cli_cfg.model_name, "images")) + cfg.setdefault( + "video_dump", os.path.join("dumps", cli_cfg.model_name, "videos") + ) + cfg.setdefault("mesh_dump", os.path.join("dumps", cli_cfg.model_name, "meshes")) + + cfg.motion_video_read_fps = 30 + cfg.merge_with(cli_cfg) + + cfg.setdefault("logger", "INFO") + + assert cfg.model_name is not None, "model_name is required" + + return cfg, cfg_train + + +def create_zip_archive(output_zip='assets/arkitWithBSData.zip', base_dir=""): + if os.path.exists(output_zip): + os.remove(output_zip) + print(f"Remove previous file: {output_zip}") + + try: + # 创建压缩包 + patoolib.create_archive( + archive=output_zip, + filenames=[base_dir], # 要压缩的目录 + verbosity=-1, # 静默模式 + program='zip' # 指定使用zip格式 + ) + print(f"Archive created successfully: {output_zip}") + except Exception as e: + raise ValueError(f"Archive creation failed: {str(e)}") + + +def demo_lam_audio2exp(infer, cfg): + def core_fn(image_path: str, audio_params, working_dir, input_zip_textbox): + + if(os.path.exists(input_zip_textbox)): + base_id = os.path.basename(input_zip_textbox).split(".")[0] + output_dir = os.path.join('assets', 'sample_lam', base_id) + # unzip_dir + if (not os.path.exists(os.path.join(output_dir, 'arkitWithBSData'))): + run_command = 'unzip -d '+output_dir+' '+input_zip_textbox + os.system(run_command) + rename_command = 'mv '+os.path.join(output_dir,base_id)+' '+os.path.join(output_dir,'arkitWithBSData') + os.system(rename_command) + else: + base_id = os.path.basename(image_path).split(".")[0] + + # set input audio + cfg.audio_input = audio_params + cfg.save_json_path = os.path.join("./assets/sample_lam", base_id, 'arkitWithBSData', 'bsData.json') + infer.infer() + + output_file_name = base_id+'_'+os.path.basename(audio_params).split(".")[0]+'.zip' + assetPrefix = 'gradio_api/file=assets/' + output_file_path = os.path.join('./assets',output_file_name) + + create_zip_archive(output_zip=output_file_path, base_dir=os.path.join("./assets/sample_lam", base_id)) + + return 'gradio_api/file='+audio_params, assetPrefix+output_file_name + + with gr.Blocks(analytics_enabled=False) as demo: + logo_url = './assets/images/logo.jpeg' + logo_base64 = get_image_base64(logo_url) + gr.HTML(f""" +
+
+

LAM-A2E: Audio to Expression

+
+
+ """) + + gr.HTML( + """

Notes: This project leverages audio input to generate ARKit blendshapes-driven facial expressions in ⚡real-time⚡, powering ultra-realistic 3D avatars generated by LAM.

""" + ) + + # DISPLAY + with gr.Row(): + with gr.Column(variant='panel', scale=1): + with gr.Tabs(elem_id='lam_input_image'): + with gr.TabItem('Input Image'): + with gr.Row(): + input_image = gr.Image(label='Input Image', + image_mode='RGB', + height=480, + width=270, + sources='upload', + type='filepath', # 'numpy', + elem_id='content_image', + interactive=False) + # EXAMPLES + with gr.Row(): + examples = [ + ['assets/sample_input/barbara.jpg'], + ['assets/sample_input/status.png'], + ['assets/sample_input/james.png'], + ['assets/sample_input/vfhq_case1.png'], + ] + gr.Examples( + examples=examples, + inputs=[input_image], + examples_per_page=20, + ) + + with gr.Column(): + with gr.Tabs(elem_id='lam_input_audio'): + with gr.TabItem('Input Audio'): + with gr.Row(): + audio_input = gr.Audio(label='Input Audio', + type='filepath', + waveform_options={ + 'sample_rate': 16000, + 'waveform_progress_color': '#4682b4' + }, + elem_id='content_audio') + + examples = [ + ['assets/sample_audio/Nangyanwen_chinese.wav'], + ['assets/sample_audio/LiBai_TTS_chinese.wav'], + ['assets/sample_audio/LinJing_TTS_chinese.wav'], + ['assets/sample_audio/BarackObama_english.wav'], + ['assets/sample_audio/HillaryClinton_english.wav'], + ['assets/sample_audio/XitongShi_japanese.wav'], + ['assets/sample_audio/FangXiao_japanese.wav'], + ] + gr.Examples( + examples=examples, + inputs=[audio_input], + examples_per_page=10, + ) + + # SETTING + with gr.Row(): + with gr.Column(variant='panel', scale=1): + input_zip_textbox = gr.Textbox( + label="Input Local Path to LAM-Generated ZIP File", + interactive=True, + placeholder="Input Local Path to LAM-Generated ZIP File", + visible=True + ) + submit = gr.Button('Generate', + elem_id='lam_generate', + variant='primary') + + if h5_rendering: + gr.set_static_paths(Path.cwd().absolute() / "assets/") + with gr.Row(): + gs = gaussian_render(width=380, height=680) + + working_dir = gr.State() + selected_audio = gr.Textbox(visible=False) + selected_render_file = gr.Textbox(visible=False) + + submit.click( + fn=assert_input_image, + inputs=[input_image,input_zip_textbox], + queue=False, + ).success( + fn=prepare_working_dir, + outputs=[working_dir], + queue=False, + ).success( + fn=core_fn, + inputs=[input_image, audio_input, + working_dir, input_zip_textbox], + outputs=[selected_audio, selected_render_file], + queue=False, + ).success( + fn=audio_loading, + outputs=[selected_audio], + js='''(output_component) => window.loadAudio(output_component)''' + ).success( + fn=do_render(), + outputs=[selected_render_file], + js='''(selected_render_file) => window.start(selected_render_file)''' + ) + + demo.queue() + demo.launch(inbrowser=True) + + + +def launch_gradio_app(): + os.environ.update({ + 'APP_ENABLED': '1', + 'APP_MODEL_NAME':'', + 'APP_INFER': 'configs/lam_audio2exp_streaming_config.py', + 'APP_TYPE': 'infer.audio2exp', + 'NUMBA_THREADING_LAYER': 'omp', + }) + + args = default_argument_parser().parse_args() + args.config_file = 'configs/lam_audio2exp_config_streaming.py' + cfg = default_config_parser(args.config_file, args.options) + cfg = default_setup(cfg) + + cfg.ex_vol = True + infer = INFER.build(dict(type=cfg.infer.type, cfg=cfg)) + + demo_lam_audio2exp(infer, cfg) + + +if __name__ == '__main__': + launch_gradio_app() diff --git a/services/audio2exp-service/LAM_Audio2Expression/assets/images/framework.png b/services/audio2exp-service/LAM_Audio2Expression/assets/images/framework.png new file mode 100644 index 0000000..210a975 Binary files /dev/null and b/services/audio2exp-service/LAM_Audio2Expression/assets/images/framework.png differ diff --git a/services/audio2exp-service/LAM_Audio2Expression/assets/images/logo.jpeg b/services/audio2exp-service/LAM_Audio2Expression/assets/images/logo.jpeg new file mode 100644 index 0000000..6fa8d78 Binary files /dev/null and b/services/audio2exp-service/LAM_Audio2Expression/assets/images/logo.jpeg differ diff --git a/services/audio2exp-service/LAM_Audio2Expression/assets/images/snapshot.png b/services/audio2exp-service/LAM_Audio2Expression/assets/images/snapshot.png new file mode 100644 index 0000000..8fc9bc9 Binary files /dev/null and b/services/audio2exp-service/LAM_Audio2Expression/assets/images/snapshot.png differ diff --git a/services/audio2exp-service/LAM_Audio2Expression/assets/images/teaser.jpg b/services/audio2exp-service/LAM_Audio2Expression/assets/images/teaser.jpg new file mode 100644 index 0000000..8c7c406 Binary files /dev/null and b/services/audio2exp-service/LAM_Audio2Expression/assets/images/teaser.jpg differ diff --git a/services/audio2exp-service/LAM_Audio2Expression/configs/lam_audio2exp_config.py b/services/audio2exp-service/LAM_Audio2Expression/configs/lam_audio2exp_config.py new file mode 100644 index 0000000..a1e4abb --- /dev/null +++ b/services/audio2exp-service/LAM_Audio2Expression/configs/lam_audio2exp_config.py @@ -0,0 +1,92 @@ +weight = 'pretrained_models/lam_audio2exp.tar' # path to model weight +ex_vol = True # Isolates vocal track from audio file +audio_input = './assets/sample_audio/BarackObama.wav' +save_json_path = 'bsData.json' + +audio_sr = 16000 +fps = 30.0 + +movement_smooth = True +brow_movement = True +id_idx = 153 + +resume = False # whether to resume training process +evaluate = True # evaluate after each epoch training process +test_only = False # test process + +seed = None # train process will init a random seed and record +save_path = "exp/audio2exp" +num_worker = 16 # total worker in all gpu +batch_size = 16 # total batch size in all gpu +batch_size_val = None # auto adapt to bs 1 for each gpu +batch_size_test = None # auto adapt to bs 1 for each gpu +epoch = 100 # total epoch, data loop = epoch // eval_epoch +eval_epoch = 100 # sche total eval & checkpoint epoch + +sync_bn = False +enable_amp = False +empty_cache = False +find_unused_parameters = False + +mix_prob = 0 +param_dicts = None # example: param_dicts = [dict(keyword="block", lr_scale=0.1)] + +# model settings +model = dict( + type="DefaultEstimator", + backbone=dict( + type="Audio2Expression", + pretrained_encoder_type='wav2vec', + pretrained_encoder_path='facebook/wav2vec2-base-960h', + wav2vec2_config_path = 'configs/wav2vec2_config.json', + num_identity_classes=5016, + identity_feat_dim=64, + hidden_dim=512, + expression_dim=52, + norm_type='ln', + use_transformer=True, + num_attention_heads=8, + num_transformer_layers=6, + ), + criteria=[dict(type="L1Loss", loss_weight=1.0, ignore_index=-1)], +) + +dataset_type = 'audio2exp' +data_root = './' +data = dict( + train=dict( + type=dataset_type, + split="train", + data_root=data_root, + test_mode=False, + ), + val=dict( + type=dataset_type, + split="val", + data_root=data_root, + test_mode=False, + ), + test=dict( + type=dataset_type, + split="val", + data_root=data_root, + test_mode=True + ), +) + +# hook +hooks = [ + dict(type="CheckpointLoader"), + dict(type="IterationTimer", warmup_iter=2), + dict(type="InformationWriter"), + dict(type="SemSegEvaluator"), + dict(type="CheckpointSaver", save_freq=None), + dict(type="PreciseEvaluator", test_last=False), +] + +# Trainer +train = dict(type="DefaultTrainer") + +# Tester +infer = dict(type="Audio2ExpressionInfer", + verbose=True) diff --git a/services/audio2exp-service/LAM_Audio2Expression/configs/lam_audio2exp_config_streaming.py b/services/audio2exp-service/LAM_Audio2Expression/configs/lam_audio2exp_config_streaming.py new file mode 100644 index 0000000..3f44b92 --- /dev/null +++ b/services/audio2exp-service/LAM_Audio2Expression/configs/lam_audio2exp_config_streaming.py @@ -0,0 +1,92 @@ +weight = 'pretrained_models/lam_audio2exp_streaming.tar' # path to model weight +ex_vol = True # extract +audio_input = './assets/sample_audio/BarackObama.wav' +save_json_path = 'bsData.json' + +audio_sr = 16000 +fps = 30.0 + +movement_smooth = False +brow_movement = False +id_idx = 0 + +resume = False # whether to resume training process +evaluate = True # evaluate after each epoch training process +test_only = False # test process + +seed = None # train process will init a random seed and record +save_path = "exp/audio2exp" +num_worker = 16 # total worker in all gpu +batch_size = 16 # total batch size in all gpu +batch_size_val = None # auto adapt to bs 1 for each gpu +batch_size_test = None # auto adapt to bs 1 for each gpu +epoch = 100 # total epoch, data loop = epoch // eval_epoch +eval_epoch = 100 # sche total eval & checkpoint epoch + +sync_bn = False +enable_amp = False +empty_cache = False +find_unused_parameters = False + +mix_prob = 0 +param_dicts = None # example: param_dicts = [dict(keyword="block", lr_scale=0.1)] + +# model settings +model = dict( + type="DefaultEstimator", + backbone=dict( + type="Audio2Expression", + pretrained_encoder_type='wav2vec', + pretrained_encoder_path='facebook/wav2vec2-base-960h', + wav2vec2_config_path = 'configs/wav2vec2_config.json', + num_identity_classes=12, + identity_feat_dim=64, + hidden_dim=512, + expression_dim=52, + norm_type='ln', + use_transformer=False, + num_attention_heads=8, + num_transformer_layers=6, + ), + criteria=[dict(type="L1Loss", loss_weight=1.0, ignore_index=-1)], +) + +dataset_type = 'audio2exp' +data_root = './' +data = dict( + train=dict( + type=dataset_type, + split="train", + data_root=data_root, + test_mode=False, + ), + val=dict( + type=dataset_type, + split="val", + data_root=data_root, + test_mode=False, + ), + test=dict( + type=dataset_type, + split="val", + data_root=data_root, + test_mode=True + ), +) + +# hook +hooks = [ + dict(type="CheckpointLoader"), + dict(type="IterationTimer", warmup_iter=2), + dict(type="InformationWriter"), + dict(type="SemSegEvaluator"), + dict(type="CheckpointSaver", save_freq=None), + dict(type="PreciseEvaluator", test_last=False), +] + +# Trainer +train = dict(type="DefaultTrainer") + +# Tester +infer = dict(type="Audio2ExpressionInfer", + verbose=True) diff --git a/services/audio2exp-service/LAM_Audio2Expression/configs/wav2vec2_config.json b/services/audio2exp-service/LAM_Audio2Expression/configs/wav2vec2_config.json new file mode 100644 index 0000000..8ca9cc7 --- /dev/null +++ b/services/audio2exp-service/LAM_Audio2Expression/configs/wav2vec2_config.json @@ -0,0 +1,77 @@ +{ + "_name_or_path": "facebook/wav2vec2-base-960h", + "activation_dropout": 0.1, + "apply_spec_augment": true, + "architectures": [ + "Wav2Vec2ForCTC" + ], + "attention_dropout": 0.1, + "bos_token_id": 1, + "codevector_dim": 256, + "contrastive_logits_temperature": 0.1, + "conv_bias": false, + "conv_dim": [ + 512, + 512, + 512, + 512, + 512, + 512, + 512 + ], + "conv_kernel": [ + 10, + 3, + 3, + 3, + 3, + 2, + 2 + ], + "conv_stride": [ + 5, + 2, + 2, + 2, + 2, + 2, + 2 + ], + "ctc_loss_reduction": "sum", + "ctc_zero_infinity": false, + "diversity_loss_weight": 0.1, + "do_stable_layer_norm": false, + "eos_token_id": 2, + "feat_extract_activation": "gelu", + "feat_extract_dropout": 0.0, + "feat_extract_norm": "group", + "feat_proj_dropout": 0.1, + "feat_quantizer_dropout": 0.0, + "final_dropout": 0.1, + "gradient_checkpointing": false, + "hidden_act": "gelu", + "hidden_dropout": 0.1, + "hidden_dropout_prob": 0.1, + "hidden_size": 768, + "initializer_range": 0.02, + "intermediate_size": 3072, + "layer_norm_eps": 1e-05, + "layerdrop": 0.1, + "mask_feature_length": 10, + "mask_feature_prob": 0.0, + "mask_time_length": 10, + "mask_time_prob": 0.05, + "model_type": "wav2vec2", + "num_attention_heads": 12, + "num_codevector_groups": 2, + "num_codevectors_per_group": 320, + "num_conv_pos_embedding_groups": 16, + "num_conv_pos_embeddings": 128, + "num_feat_extract_layers": 7, + "num_hidden_layers": 12, + "num_negatives": 100, + "pad_token_id": 0, + "proj_codevector_dim": 256, + "transformers_version": "4.7.0.dev0", + "vocab_size": 32 +} diff --git a/services/audio2exp-service/LAM_Audio2Expression/engines/__init__.py b/services/audio2exp-service/LAM_Audio2Expression/engines/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/services/audio2exp-service/LAM_Audio2Expression/engines/defaults.py b/services/audio2exp-service/LAM_Audio2Expression/engines/defaults.py new file mode 100644 index 0000000..488148b --- /dev/null +++ b/services/audio2exp-service/LAM_Audio2Expression/engines/defaults.py @@ -0,0 +1,147 @@ +""" +The code is base on https://github.com/Pointcept/Pointcept +""" + +import os +import sys +import argparse +import multiprocessing as mp +from torch.nn.parallel import DistributedDataParallel + + +import utils.comm as comm +from utils.env import get_random_seed, set_seed +from utils.config import Config, DictAction + + +def create_ddp_model(model, *, fp16_compression=False, **kwargs): + """ + Create a DistributedDataParallel model if there are >1 processes. + Args: + model: a torch.nn.Module + fp16_compression: add fp16 compression hooks to the ddp object. + See more at https://pytorch.org/docs/stable/ddp_comm_hooks.html#torch.distributed.algorithms.ddp_comm_hooks.default_hooks.fp16_compress_hook + kwargs: other arguments of :module:`torch.nn.parallel.DistributedDataParallel`. + """ + if comm.get_world_size() == 1: + return model + # kwargs['find_unused_parameters'] = True + if "device_ids" not in kwargs: + kwargs["device_ids"] = [comm.get_local_rank()] + if "output_device" not in kwargs: + kwargs["output_device"] = [comm.get_local_rank()] + ddp = DistributedDataParallel(model, **kwargs) + if fp16_compression: + from torch.distributed.algorithms.ddp_comm_hooks import default as comm_hooks + + ddp.register_comm_hook(state=None, hook=comm_hooks.fp16_compress_hook) + return ddp + + +def worker_init_fn(worker_id, num_workers, rank, seed): + """Worker init func for dataloader. + + The seed of each worker equals to num_worker * rank + worker_id + user_seed + + Args: + worker_id (int): Worker id. + num_workers (int): Number of workers. + rank (int): The rank of current process. + seed (int): The random seed to use. + """ + + worker_seed = num_workers * rank + worker_id + seed + set_seed(worker_seed) + + +def default_argument_parser(epilog=None): + parser = argparse.ArgumentParser( + epilog=epilog + or f""" + Examples: + Run on single machine: + $ {sys.argv[0]} --num-gpus 8 --config-file cfg.yaml + Change some config options: + $ {sys.argv[0]} --config-file cfg.yaml MODEL.WEIGHTS /path/to/weight.pth SOLVER.BASE_LR 0.001 + Run on multiple machines: + (machine0)$ {sys.argv[0]} --machine-rank 0 --num-machines 2 --dist-url [--other-flags] + (machine1)$ {sys.argv[0]} --machine-rank 1 --num-machines 2 --dist-url [--other-flags] + """, + formatter_class=argparse.RawDescriptionHelpFormatter, + ) + parser.add_argument( + "--config-file", default="", metavar="FILE", help="path to config file" + ) + parser.add_argument( + "--num-gpus", type=int, default=1, help="number of gpus *per machine*" + ) + parser.add_argument( + "--num-machines", type=int, default=1, help="total number of machines" + ) + parser.add_argument( + "--machine-rank", + type=int, + default=0, + help="the rank of this machine (unique per machine)", + ) + # PyTorch still may leave orphan processes in multi-gpu training. + # Therefore we use a deterministic way to obtain port, + # so that users are aware of orphan processes by seeing the port occupied. + # port = 2 ** 15 + 2 ** 14 + hash(os.getuid() if sys.platform != "win32" else 1) % 2 ** 14 + parser.add_argument( + "--dist-url", + # default="tcp://127.0.0.1:{}".format(port), + default="auto", + help="initialization URL for pytorch distributed backend. See " + "https://pytorch.org/docs/stable/distributed.html for details.", + ) + parser.add_argument( + "--options", nargs="+", action=DictAction, help="custom options" + ) + return parser + + +def default_config_parser(file_path, options): + # config name protocol: dataset_name/model_name-exp_name + if os.path.isfile(file_path): + cfg = Config.fromfile(file_path) + else: + sep = file_path.find("-") + cfg = Config.fromfile(os.path.join(file_path[:sep], file_path[sep + 1 :])) + + if options is not None: + cfg.merge_from_dict(options) + + if cfg.seed is None: + cfg.seed = get_random_seed() + + cfg.data.train.loop = cfg.epoch // cfg.eval_epoch + + os.makedirs(os.path.join(cfg.save_path, "model"), exist_ok=True) + if not cfg.resume: + cfg.dump(os.path.join(cfg.save_path, "config.py")) + return cfg + + +def default_setup(cfg): + # scalar by world size + world_size = comm.get_world_size() + cfg.num_worker = cfg.num_worker if cfg.num_worker is not None else mp.cpu_count() + cfg.num_worker_per_gpu = cfg.num_worker // world_size + assert cfg.batch_size % world_size == 0 + assert cfg.batch_size_val is None or cfg.batch_size_val % world_size == 0 + assert cfg.batch_size_test is None or cfg.batch_size_test % world_size == 0 + cfg.batch_size_per_gpu = cfg.batch_size // world_size + cfg.batch_size_val_per_gpu = ( + cfg.batch_size_val // world_size if cfg.batch_size_val is not None else 1 + ) + cfg.batch_size_test_per_gpu = ( + cfg.batch_size_test // world_size if cfg.batch_size_test is not None else 1 + ) + # update data loop + assert cfg.epoch % cfg.eval_epoch == 0 + # settle random seed + rank = comm.get_rank() + seed = None if cfg.seed is None else cfg.seed * cfg.num_worker_per_gpu + rank + set_seed(seed) + return cfg diff --git a/services/audio2exp-service/LAM_Audio2Expression/engines/hooks/__init__.py b/services/audio2exp-service/LAM_Audio2Expression/engines/hooks/__init__.py new file mode 100644 index 0000000..1ab2c4b --- /dev/null +++ b/services/audio2exp-service/LAM_Audio2Expression/engines/hooks/__init__.py @@ -0,0 +1,5 @@ +from .default import HookBase +from .misc import * +from .evaluator import * + +from .builder import build_hooks diff --git a/services/audio2exp-service/LAM_Audio2Expression/engines/hooks/builder.py b/services/audio2exp-service/LAM_Audio2Expression/engines/hooks/builder.py new file mode 100644 index 0000000..e0a121c --- /dev/null +++ b/services/audio2exp-service/LAM_Audio2Expression/engines/hooks/builder.py @@ -0,0 +1,15 @@ +""" +The code is base on https://github.com/Pointcept/Pointcept +""" + +from utils.registry import Registry + + +HOOKS = Registry("hooks") + + +def build_hooks(cfg): + hooks = [] + for hook_cfg in cfg: + hooks.append(HOOKS.build(hook_cfg)) + return hooks diff --git a/services/audio2exp-service/LAM_Audio2Expression/engines/hooks/default.py b/services/audio2exp-service/LAM_Audio2Expression/engines/hooks/default.py new file mode 100644 index 0000000..57150a7 --- /dev/null +++ b/services/audio2exp-service/LAM_Audio2Expression/engines/hooks/default.py @@ -0,0 +1,29 @@ +""" +The code is base on https://github.com/Pointcept/Pointcept +""" + + +class HookBase: + """ + Base class for hooks that can be registered with :class:`TrainerBase`. + """ + + trainer = None # A weak reference to the trainer object. + + def before_train(self): + pass + + def before_epoch(self): + pass + + def before_step(self): + pass + + def after_step(self): + pass + + def after_epoch(self): + pass + + def after_train(self): + pass diff --git a/services/audio2exp-service/LAM_Audio2Expression/engines/hooks/evaluator.py b/services/audio2exp-service/LAM_Audio2Expression/engines/hooks/evaluator.py new file mode 100644 index 0000000..c0d2717 --- /dev/null +++ b/services/audio2exp-service/LAM_Audio2Expression/engines/hooks/evaluator.py @@ -0,0 +1,577 @@ +""" +The code is base on https://github.com/Pointcept/Pointcept +""" + +import numpy as np +import torch +import torch.distributed as dist +from uuid import uuid4 + +import utils.comm as comm +from utils.misc import intersection_and_union_gpu + +from .default import HookBase +from .builder import HOOKS + + +@HOOKS.register_module() +class ClsEvaluator(HookBase): + def after_epoch(self): + if self.trainer.cfg.evaluate: + self.eval() + + def eval(self): + self.trainer.logger.info(">>>>>>>>>>>>>>>> Start Evaluation >>>>>>>>>>>>>>>>") + self.trainer.model.eval() + for i, input_dict in enumerate(self.trainer.val_loader): + for key in input_dict.keys(): + if isinstance(input_dict[key], torch.Tensor): + input_dict[key] = input_dict[key].cuda(non_blocking=True) + with torch.no_grad(): + output_dict = self.trainer.model(input_dict) + output = output_dict["cls_logits"] + loss = output_dict["loss"] + pred = output.max(1)[1] + label = input_dict["category"] + intersection, union, target = intersection_and_union_gpu( + pred, + label, + self.trainer.cfg.data.num_classes, + self.trainer.cfg.data.ignore_index, + ) + if comm.get_world_size() > 1: + dist.all_reduce(intersection), dist.all_reduce(union), dist.all_reduce( + target + ) + intersection, union, target = ( + intersection.cpu().numpy(), + union.cpu().numpy(), + target.cpu().numpy(), + ) + # Here there is no need to sync since sync happened in dist.all_reduce + self.trainer.storage.put_scalar("val_intersection", intersection) + self.trainer.storage.put_scalar("val_union", union) + self.trainer.storage.put_scalar("val_target", target) + self.trainer.storage.put_scalar("val_loss", loss.item()) + self.trainer.logger.info( + "Test: [{iter}/{max_iter}] " + "Loss {loss:.4f} ".format( + iter=i + 1, max_iter=len(self.trainer.val_loader), loss=loss.item() + ) + ) + loss_avg = self.trainer.storage.history("val_loss").avg + intersection = self.trainer.storage.history("val_intersection").total + union = self.trainer.storage.history("val_union").total + target = self.trainer.storage.history("val_target").total + iou_class = intersection / (union + 1e-10) + acc_class = intersection / (target + 1e-10) + m_iou = np.mean(iou_class) + m_acc = np.mean(acc_class) + all_acc = sum(intersection) / (sum(target) + 1e-10) + self.trainer.logger.info( + "Val result: mIoU/mAcc/allAcc {:.4f}/{:.4f}/{:.4f}.".format( + m_iou, m_acc, all_acc + ) + ) + for i in range(self.trainer.cfg.data.num_classes): + self.trainer.logger.info( + "Class_{idx}-{name} Result: iou/accuracy {iou:.4f}/{accuracy:.4f}".format( + idx=i, + name=self.trainer.cfg.data.names[i], + iou=iou_class[i], + accuracy=acc_class[i], + ) + ) + current_epoch = self.trainer.epoch + 1 + if self.trainer.writer is not None: + self.trainer.writer.add_scalar("val/loss", loss_avg, current_epoch) + self.trainer.writer.add_scalar("val/mIoU", m_iou, current_epoch) + self.trainer.writer.add_scalar("val/mAcc", m_acc, current_epoch) + self.trainer.writer.add_scalar("val/allAcc", all_acc, current_epoch) + self.trainer.logger.info("<<<<<<<<<<<<<<<<< End Evaluation <<<<<<<<<<<<<<<<<") + self.trainer.comm_info["current_metric_value"] = all_acc # save for saver + self.trainer.comm_info["current_metric_name"] = "allAcc" # save for saver + + def after_train(self): + self.trainer.logger.info( + "Best {}: {:.4f}".format("allAcc", self.trainer.best_metric_value) + ) + + +@HOOKS.register_module() +class SemSegEvaluator(HookBase): + def after_epoch(self): + if self.trainer.cfg.evaluate: + self.eval() + + def eval(self): + self.trainer.logger.info(">>>>>>>>>>>>>>>> Start Evaluation >>>>>>>>>>>>>>>>") + self.trainer.model.eval() + for i, input_dict in enumerate(self.trainer.val_loader): + for key in input_dict.keys(): + if isinstance(input_dict[key], torch.Tensor): + input_dict[key] = input_dict[key].cuda(non_blocking=True) + with torch.no_grad(): + output_dict = self.trainer.model(input_dict) + output = output_dict["seg_logits"] + loss = output_dict["loss"] + pred = output.max(1)[1] + segment = input_dict["segment"] + if "origin_coord" in input_dict.keys(): + idx, _ = pointops.knn_query( + 1, + input_dict["coord"].float(), + input_dict["offset"].int(), + input_dict["origin_coord"].float(), + input_dict["origin_offset"].int(), + ) + pred = pred[idx.flatten().long()] + segment = input_dict["origin_segment"] + intersection, union, target = intersection_and_union_gpu( + pred, + segment, + self.trainer.cfg.data.num_classes, + self.trainer.cfg.data.ignore_index, + ) + if comm.get_world_size() > 1: + dist.all_reduce(intersection), dist.all_reduce(union), dist.all_reduce( + target + ) + intersection, union, target = ( + intersection.cpu().numpy(), + union.cpu().numpy(), + target.cpu().numpy(), + ) + # Here there is no need to sync since sync happened in dist.all_reduce + self.trainer.storage.put_scalar("val_intersection", intersection) + self.trainer.storage.put_scalar("val_union", union) + self.trainer.storage.put_scalar("val_target", target) + self.trainer.storage.put_scalar("val_loss", loss.item()) + info = "Test: [{iter}/{max_iter}] ".format( + iter=i + 1, max_iter=len(self.trainer.val_loader) + ) + if "origin_coord" in input_dict.keys(): + info = "Interp. " + info + self.trainer.logger.info( + info + + "Loss {loss:.4f} ".format( + iter=i + 1, max_iter=len(self.trainer.val_loader), loss=loss.item() + ) + ) + loss_avg = self.trainer.storage.history("val_loss").avg + intersection = self.trainer.storage.history("val_intersection").total + union = self.trainer.storage.history("val_union").total + target = self.trainer.storage.history("val_target").total + iou_class = intersection / (union + 1e-10) + acc_class = intersection / (target + 1e-10) + m_iou = np.mean(iou_class) + m_acc = np.mean(acc_class) + all_acc = sum(intersection) / (sum(target) + 1e-10) + self.trainer.logger.info( + "Val result: mIoU/mAcc/allAcc {:.4f}/{:.4f}/{:.4f}.".format( + m_iou, m_acc, all_acc + ) + ) + for i in range(self.trainer.cfg.data.num_classes): + self.trainer.logger.info( + "Class_{idx}-{name} Result: iou/accuracy {iou:.4f}/{accuracy:.4f}".format( + idx=i, + name=self.trainer.cfg.data.names[i], + iou=iou_class[i], + accuracy=acc_class[i], + ) + ) + current_epoch = self.trainer.epoch + 1 + if self.trainer.writer is not None: + self.trainer.writer.add_scalar("val/loss", loss_avg, current_epoch) + self.trainer.writer.add_scalar("val/mIoU", m_iou, current_epoch) + self.trainer.writer.add_scalar("val/mAcc", m_acc, current_epoch) + self.trainer.writer.add_scalar("val/allAcc", all_acc, current_epoch) + self.trainer.logger.info("<<<<<<<<<<<<<<<<< End Evaluation <<<<<<<<<<<<<<<<<") + self.trainer.comm_info["current_metric_value"] = m_iou # save for saver + self.trainer.comm_info["current_metric_name"] = "mIoU" # save for saver + + def after_train(self): + self.trainer.logger.info( + "Best {}: {:.4f}".format("mIoU", self.trainer.best_metric_value) + ) + + +@HOOKS.register_module() +class InsSegEvaluator(HookBase): + def __init__(self, segment_ignore_index=(-1,), instance_ignore_index=-1): + self.segment_ignore_index = segment_ignore_index + self.instance_ignore_index = instance_ignore_index + + self.valid_class_names = None # update in before train + self.overlaps = np.append(np.arange(0.5, 0.95, 0.05), 0.25) + self.min_region_sizes = 100 + self.distance_threshes = float("inf") + self.distance_confs = -float("inf") + + def before_train(self): + self.valid_class_names = [ + self.trainer.cfg.data.names[i] + for i in range(self.trainer.cfg.data.num_classes) + if i not in self.segment_ignore_index + ] + + def after_epoch(self): + if self.trainer.cfg.evaluate: + self.eval() + + def associate_instances(self, pred, segment, instance): + segment = segment.cpu().numpy() + instance = instance.cpu().numpy() + void_mask = np.in1d(segment, self.segment_ignore_index) + + assert ( + pred["pred_classes"].shape[0] + == pred["pred_scores"].shape[0] + == pred["pred_masks"].shape[0] + ) + assert pred["pred_masks"].shape[1] == segment.shape[0] == instance.shape[0] + # get gt instances + gt_instances = dict() + for i in range(self.trainer.cfg.data.num_classes): + if i not in self.segment_ignore_index: + gt_instances[self.trainer.cfg.data.names[i]] = [] + instance_ids, idx, counts = np.unique( + instance, return_index=True, return_counts=True + ) + segment_ids = segment[idx] + for i in range(len(instance_ids)): + if instance_ids[i] == self.instance_ignore_index: + continue + if segment_ids[i] in self.segment_ignore_index: + continue + gt_inst = dict() + gt_inst["instance_id"] = instance_ids[i] + gt_inst["segment_id"] = segment_ids[i] + gt_inst["dist_conf"] = 0.0 + gt_inst["med_dist"] = -1.0 + gt_inst["vert_count"] = counts[i] + gt_inst["matched_pred"] = [] + gt_instances[self.trainer.cfg.data.names[segment_ids[i]]].append(gt_inst) + + # get pred instances and associate with gt + pred_instances = dict() + for i in range(self.trainer.cfg.data.num_classes): + if i not in self.segment_ignore_index: + pred_instances[self.trainer.cfg.data.names[i]] = [] + instance_id = 0 + for i in range(len(pred["pred_classes"])): + if pred["pred_classes"][i] in self.segment_ignore_index: + continue + pred_inst = dict() + pred_inst["uuid"] = uuid4() + pred_inst["instance_id"] = instance_id + pred_inst["segment_id"] = pred["pred_classes"][i] + pred_inst["confidence"] = pred["pred_scores"][i] + pred_inst["mask"] = np.not_equal(pred["pred_masks"][i], 0) + pred_inst["vert_count"] = np.count_nonzero(pred_inst["mask"]) + pred_inst["void_intersection"] = np.count_nonzero( + np.logical_and(void_mask, pred_inst["mask"]) + ) + if pred_inst["vert_count"] < self.min_region_sizes: + continue # skip if empty + segment_name = self.trainer.cfg.data.names[pred_inst["segment_id"]] + matched_gt = [] + for gt_idx, gt_inst in enumerate(gt_instances[segment_name]): + intersection = np.count_nonzero( + np.logical_and( + instance == gt_inst["instance_id"], pred_inst["mask"] + ) + ) + if intersection > 0: + gt_inst_ = gt_inst.copy() + pred_inst_ = pred_inst.copy() + gt_inst_["intersection"] = intersection + pred_inst_["intersection"] = intersection + matched_gt.append(gt_inst_) + gt_inst["matched_pred"].append(pred_inst_) + pred_inst["matched_gt"] = matched_gt + pred_instances[segment_name].append(pred_inst) + instance_id += 1 + return gt_instances, pred_instances + + def evaluate_matches(self, scenes): + overlaps = self.overlaps + min_region_sizes = [self.min_region_sizes] + dist_threshes = [self.distance_threshes] + dist_confs = [self.distance_confs] + + # results: class x overlap + ap_table = np.zeros( + (len(dist_threshes), len(self.valid_class_names), len(overlaps)), float + ) + for di, (min_region_size, distance_thresh, distance_conf) in enumerate( + zip(min_region_sizes, dist_threshes, dist_confs) + ): + for oi, overlap_th in enumerate(overlaps): + pred_visited = {} + for scene in scenes: + for _ in scene["pred"]: + for label_name in self.valid_class_names: + for p in scene["pred"][label_name]: + if "uuid" in p: + pred_visited[p["uuid"]] = False + for li, label_name in enumerate(self.valid_class_names): + y_true = np.empty(0) + y_score = np.empty(0) + hard_false_negatives = 0 + has_gt = False + has_pred = False + for scene in scenes: + pred_instances = scene["pred"][label_name] + gt_instances = scene["gt"][label_name] + # filter groups in ground truth + gt_instances = [ + gt + for gt in gt_instances + if gt["vert_count"] >= min_region_size + and gt["med_dist"] <= distance_thresh + and gt["dist_conf"] >= distance_conf + ] + if gt_instances: + has_gt = True + if pred_instances: + has_pred = True + + cur_true = np.ones(len(gt_instances)) + cur_score = np.ones(len(gt_instances)) * (-float("inf")) + cur_match = np.zeros(len(gt_instances), dtype=bool) + # collect matches + for gti, gt in enumerate(gt_instances): + found_match = False + for pred in gt["matched_pred"]: + # greedy assignments + if pred_visited[pred["uuid"]]: + continue + overlap = float(pred["intersection"]) / ( + gt["vert_count"] + + pred["vert_count"] + - pred["intersection"] + ) + if overlap > overlap_th: + confidence = pred["confidence"] + # if already have a prediction for this gt, + # the prediction with the lower score is automatically a false positive + if cur_match[gti]: + max_score = max(cur_score[gti], confidence) + min_score = min(cur_score[gti], confidence) + cur_score[gti] = max_score + # append false positive + cur_true = np.append(cur_true, 0) + cur_score = np.append(cur_score, min_score) + cur_match = np.append(cur_match, True) + # otherwise set score + else: + found_match = True + cur_match[gti] = True + cur_score[gti] = confidence + pred_visited[pred["uuid"]] = True + if not found_match: + hard_false_negatives += 1 + # remove non-matched ground truth instances + cur_true = cur_true[cur_match] + cur_score = cur_score[cur_match] + + # collect non-matched predictions as false positive + for pred in pred_instances: + found_gt = False + for gt in pred["matched_gt"]: + overlap = float(gt["intersection"]) / ( + gt["vert_count"] + + pred["vert_count"] + - gt["intersection"] + ) + if overlap > overlap_th: + found_gt = True + break + if not found_gt: + num_ignore = pred["void_intersection"] + for gt in pred["matched_gt"]: + if gt["segment_id"] in self.segment_ignore_index: + num_ignore += gt["intersection"] + # small ground truth instances + if ( + gt["vert_count"] < min_region_size + or gt["med_dist"] > distance_thresh + or gt["dist_conf"] < distance_conf + ): + num_ignore += gt["intersection"] + proportion_ignore = ( + float(num_ignore) / pred["vert_count"] + ) + # if not ignored append false positive + if proportion_ignore <= overlap_th: + cur_true = np.append(cur_true, 0) + confidence = pred["confidence"] + cur_score = np.append(cur_score, confidence) + + # append to overall results + y_true = np.append(y_true, cur_true) + y_score = np.append(y_score, cur_score) + + # compute average precision + if has_gt and has_pred: + # compute precision recall curve first + + # sorting and cumsum + score_arg_sort = np.argsort(y_score) + y_score_sorted = y_score[score_arg_sort] + y_true_sorted = y_true[score_arg_sort] + y_true_sorted_cumsum = np.cumsum(y_true_sorted) + + # unique thresholds + (thresholds, unique_indices) = np.unique( + y_score_sorted, return_index=True + ) + num_prec_recall = len(unique_indices) + 1 + + # prepare precision recall + num_examples = len(y_score_sorted) + # https://github.com/ScanNet/ScanNet/pull/26 + # all predictions are non-matched but also all of them are ignored and not counted as FP + # y_true_sorted_cumsum is empty + # num_true_examples = y_true_sorted_cumsum[-1] + num_true_examples = ( + y_true_sorted_cumsum[-1] + if len(y_true_sorted_cumsum) > 0 + else 0 + ) + precision = np.zeros(num_prec_recall) + recall = np.zeros(num_prec_recall) + + # deal with the first point + y_true_sorted_cumsum = np.append(y_true_sorted_cumsum, 0) + # deal with remaining + for idx_res, idx_scores in enumerate(unique_indices): + cumsum = y_true_sorted_cumsum[idx_scores - 1] + tp = num_true_examples - cumsum + fp = num_examples - idx_scores - tp + fn = cumsum + hard_false_negatives + p = float(tp) / (tp + fp) + r = float(tp) / (tp + fn) + precision[idx_res] = p + recall[idx_res] = r + + # first point in curve is artificial + precision[-1] = 1.0 + recall[-1] = 0.0 + + # compute average of precision-recall curve + recall_for_conv = np.copy(recall) + recall_for_conv = np.append(recall_for_conv[0], recall_for_conv) + recall_for_conv = np.append(recall_for_conv, 0.0) + + stepWidths = np.convolve( + recall_for_conv, [-0.5, 0, 0.5], "valid" + ) + # integrate is now simply a dot product + ap_current = np.dot(precision, stepWidths) + + elif has_gt: + ap_current = 0.0 + else: + ap_current = float("nan") + ap_table[di, li, oi] = ap_current + d_inf = 0 + o50 = np.where(np.isclose(self.overlaps, 0.5)) + o25 = np.where(np.isclose(self.overlaps, 0.25)) + oAllBut25 = np.where(np.logical_not(np.isclose(self.overlaps, 0.25))) + ap_scores = dict() + ap_scores["all_ap"] = np.nanmean(ap_table[d_inf, :, oAllBut25]) + ap_scores["all_ap_50%"] = np.nanmean(ap_table[d_inf, :, o50]) + ap_scores["all_ap_25%"] = np.nanmean(ap_table[d_inf, :, o25]) + ap_scores["classes"] = {} + for li, label_name in enumerate(self.valid_class_names): + ap_scores["classes"][label_name] = {} + ap_scores["classes"][label_name]["ap"] = np.average( + ap_table[d_inf, li, oAllBut25] + ) + ap_scores["classes"][label_name]["ap50%"] = np.average( + ap_table[d_inf, li, o50] + ) + ap_scores["classes"][label_name]["ap25%"] = np.average( + ap_table[d_inf, li, o25] + ) + return ap_scores + + def eval(self): + self.trainer.logger.info(">>>>>>>>>>>>>>>> Start Evaluation >>>>>>>>>>>>>>>>") + self.trainer.model.eval() + scenes = [] + for i, input_dict in enumerate(self.trainer.val_loader): + assert ( + len(input_dict["offset"]) == 1 + ) # currently only support bs 1 for each GPU + for key in input_dict.keys(): + if isinstance(input_dict[key], torch.Tensor): + input_dict[key] = input_dict[key].cuda(non_blocking=True) + with torch.no_grad(): + output_dict = self.trainer.model(input_dict) + + loss = output_dict["loss"] + + segment = input_dict["segment"] + instance = input_dict["instance"] + # map to origin + if "origin_coord" in input_dict.keys(): + idx, _ = pointops.knn_query( + 1, + input_dict["coord"].float(), + input_dict["offset"].int(), + input_dict["origin_coord"].float(), + input_dict["origin_offset"].int(), + ) + idx = idx.cpu().flatten().long() + output_dict["pred_masks"] = output_dict["pred_masks"][:, idx] + segment = input_dict["origin_segment"] + instance = input_dict["origin_instance"] + + gt_instances, pred_instance = self.associate_instances( + output_dict, segment, instance + ) + scenes.append(dict(gt=gt_instances, pred=pred_instance)) + + self.trainer.storage.put_scalar("val_loss", loss.item()) + self.trainer.logger.info( + "Test: [{iter}/{max_iter}] " + "Loss {loss:.4f} ".format( + iter=i + 1, max_iter=len(self.trainer.val_loader), loss=loss.item() + ) + ) + + loss_avg = self.trainer.storage.history("val_loss").avg + comm.synchronize() + scenes_sync = comm.gather(scenes, dst=0) + scenes = [scene for scenes_ in scenes_sync for scene in scenes_] + ap_scores = self.evaluate_matches(scenes) + all_ap = ap_scores["all_ap"] + all_ap_50 = ap_scores["all_ap_50%"] + all_ap_25 = ap_scores["all_ap_25%"] + self.trainer.logger.info( + "Val result: mAP/AP50/AP25 {:.4f}/{:.4f}/{:.4f}.".format( + all_ap, all_ap_50, all_ap_25 + ) + ) + for i, label_name in enumerate(self.valid_class_names): + ap = ap_scores["classes"][label_name]["ap"] + ap_50 = ap_scores["classes"][label_name]["ap50%"] + ap_25 = ap_scores["classes"][label_name]["ap25%"] + self.trainer.logger.info( + "Class_{idx}-{name} Result: AP/AP50/AP25 {AP:.4f}/{AP50:.4f}/{AP25:.4f}".format( + idx=i, name=label_name, AP=ap, AP50=ap_50, AP25=ap_25 + ) + ) + current_epoch = self.trainer.epoch + 1 + if self.trainer.writer is not None: + self.trainer.writer.add_scalar("val/loss", loss_avg, current_epoch) + self.trainer.writer.add_scalar("val/mAP", all_ap, current_epoch) + self.trainer.writer.add_scalar("val/AP50", all_ap_50, current_epoch) + self.trainer.writer.add_scalar("val/AP25", all_ap_25, current_epoch) + self.trainer.logger.info("<<<<<<<<<<<<<<<<< End Evaluation <<<<<<<<<<<<<<<<<") + self.trainer.comm_info["current_metric_value"] = all_ap_50 # save for saver + self.trainer.comm_info["current_metric_name"] = "AP50" # save for saver diff --git a/services/audio2exp-service/LAM_Audio2Expression/engines/hooks/misc.py b/services/audio2exp-service/LAM_Audio2Expression/engines/hooks/misc.py new file mode 100644 index 0000000..52b398e --- /dev/null +++ b/services/audio2exp-service/LAM_Audio2Expression/engines/hooks/misc.py @@ -0,0 +1,460 @@ +""" +The code is base on https://github.com/Pointcept/Pointcept +""" + +import sys +import glob +import os +import shutil +import time +import torch +import torch.utils.data +from collections import OrderedDict + +if sys.version_info >= (3, 10): + from collections.abc import Sequence +else: + from collections import Sequence +from utils.timer import Timer +from utils.comm import is_main_process, synchronize, get_world_size +from utils.cache import shared_dict + +import utils.comm as comm +from engines.test import TESTERS + +from .default import HookBase +from .builder import HOOKS + + +@HOOKS.register_module() +class IterationTimer(HookBase): + def __init__(self, warmup_iter=1): + self._warmup_iter = warmup_iter + self._start_time = time.perf_counter() + self._iter_timer = Timer() + self._remain_iter = 0 + + def before_train(self): + self._start_time = time.perf_counter() + self._remain_iter = self.trainer.max_epoch * len(self.trainer.train_loader) + + def before_epoch(self): + self._iter_timer.reset() + + def before_step(self): + data_time = self._iter_timer.seconds() + self.trainer.storage.put_scalar("data_time", data_time) + + def after_step(self): + batch_time = self._iter_timer.seconds() + self._iter_timer.reset() + self.trainer.storage.put_scalar("batch_time", batch_time) + self._remain_iter -= 1 + remain_time = self._remain_iter * self.trainer.storage.history("batch_time").avg + t_m, t_s = divmod(remain_time, 60) + t_h, t_m = divmod(t_m, 60) + remain_time = "{:02d}:{:02d}:{:02d}".format(int(t_h), int(t_m), int(t_s)) + if "iter_info" in self.trainer.comm_info.keys(): + info = ( + "Data {data_time_val:.3f} ({data_time_avg:.3f}) " + "Batch {batch_time_val:.3f} ({batch_time_avg:.3f}) " + "Remain {remain_time} ".format( + data_time_val=self.trainer.storage.history("data_time").val, + data_time_avg=self.trainer.storage.history("data_time").avg, + batch_time_val=self.trainer.storage.history("batch_time").val, + batch_time_avg=self.trainer.storage.history("batch_time").avg, + remain_time=remain_time, + ) + ) + self.trainer.comm_info["iter_info"] += info + if self.trainer.comm_info["iter"] <= self._warmup_iter: + self.trainer.storage.history("data_time").reset() + self.trainer.storage.history("batch_time").reset() + + +@HOOKS.register_module() +class InformationWriter(HookBase): + def __init__(self): + self.curr_iter = 0 + self.model_output_keys = [] + + def before_train(self): + self.trainer.comm_info["iter_info"] = "" + self.curr_iter = self.trainer.start_epoch * len(self.trainer.train_loader) + + def before_step(self): + self.curr_iter += 1 + # MSC pretrain do not have offset information. Comment the code for support MSC + # info = "Train: [{epoch}/{max_epoch}][{iter}/{max_iter}] " \ + # "Scan {batch_size} ({points_num}) ".format( + # epoch=self.trainer.epoch + 1, max_epoch=self.trainer.max_epoch, + # iter=self.trainer.comm_info["iter"], max_iter=len(self.trainer.train_loader), + # batch_size=len(self.trainer.comm_info["input_dict"]["offset"]), + # points_num=self.trainer.comm_info["input_dict"]["offset"][-1] + # ) + info = "Train: [{epoch}/{max_epoch}][{iter}/{max_iter}] ".format( + epoch=self.trainer.epoch + 1, + max_epoch=self.trainer.max_epoch, + iter=self.trainer.comm_info["iter"] + 1, + max_iter=len(self.trainer.train_loader), + ) + self.trainer.comm_info["iter_info"] += info + + def after_step(self): + if "model_output_dict" in self.trainer.comm_info.keys(): + model_output_dict = self.trainer.comm_info["model_output_dict"] + self.model_output_keys = model_output_dict.keys() + for key in self.model_output_keys: + self.trainer.storage.put_scalar(key, model_output_dict[key].item()) + + for key in self.model_output_keys: + self.trainer.comm_info["iter_info"] += "{key}: {value:.4f} ".format( + key=key, value=self.trainer.storage.history(key).val + ) + lr = self.trainer.optimizer.state_dict()["param_groups"][0]["lr"] + self.trainer.comm_info["iter_info"] += "Lr: {lr:.5f}".format(lr=lr) + self.trainer.logger.info(self.trainer.comm_info["iter_info"]) + self.trainer.comm_info["iter_info"] = "" # reset iter info + if self.trainer.writer is not None: + self.trainer.writer.add_scalar("lr", lr, self.curr_iter) + for key in self.model_output_keys: + self.trainer.writer.add_scalar( + "train_batch/" + key, + self.trainer.storage.history(key).val, + self.curr_iter, + ) + + def after_epoch(self): + epoch_info = "Train result: " + for key in self.model_output_keys: + epoch_info += "{key}: {value:.4f} ".format( + key=key, value=self.trainer.storage.history(key).avg + ) + self.trainer.logger.info(epoch_info) + if self.trainer.writer is not None: + for key in self.model_output_keys: + self.trainer.writer.add_scalar( + "train/" + key, + self.trainer.storage.history(key).avg, + self.trainer.epoch + 1, + ) + + +@HOOKS.register_module() +class CheckpointSaver(HookBase): + def __init__(self, save_freq=None): + self.save_freq = save_freq # None or int, None indicate only save model last + + def after_epoch(self): + if is_main_process(): + is_best = False + if self.trainer.cfg.evaluate: + current_metric_value = self.trainer.comm_info["current_metric_value"] + current_metric_name = self.trainer.comm_info["current_metric_name"] + if current_metric_value > self.trainer.best_metric_value: + self.trainer.best_metric_value = current_metric_value + is_best = True + self.trainer.logger.info( + "Best validation {} updated to: {:.4f}".format( + current_metric_name, current_metric_value + ) + ) + self.trainer.logger.info( + "Currently Best {}: {:.4f}".format( + current_metric_name, self.trainer.best_metric_value + ) + ) + + filename = os.path.join( + self.trainer.cfg.save_path, "model", "model_last.pth" + ) + self.trainer.logger.info("Saving checkpoint to: " + filename) + torch.save( + { + "epoch": self.trainer.epoch + 1, + "state_dict": self.trainer.model.state_dict(), + "optimizer": self.trainer.optimizer.state_dict(), + "scheduler": self.trainer.scheduler.state_dict(), + "scaler": self.trainer.scaler.state_dict() + if self.trainer.cfg.enable_amp + else None, + "best_metric_value": self.trainer.best_metric_value, + }, + filename + ".tmp", + ) + os.replace(filename + ".tmp", filename) + if is_best: + shutil.copyfile( + filename, + os.path.join(self.trainer.cfg.save_path, "model", "model_best.pth"), + ) + if self.save_freq and (self.trainer.epoch + 1) % self.save_freq == 0: + shutil.copyfile( + filename, + os.path.join( + self.trainer.cfg.save_path, + "model", + f"epoch_{self.trainer.epoch + 1}.pth", + ), + ) + + +@HOOKS.register_module() +class CheckpointLoader(HookBase): + def __init__(self, keywords="", replacement=None, strict=False): + self.keywords = keywords + self.replacement = replacement if replacement is not None else keywords + self.strict = strict + + def before_train(self): + self.trainer.logger.info("=> Loading checkpoint & weight ...") + if self.trainer.cfg.weight and os.path.isfile(self.trainer.cfg.weight): + self.trainer.logger.info(f"Loading weight at: {self.trainer.cfg.weight}") + checkpoint = torch.load( + self.trainer.cfg.weight, + map_location=lambda storage, loc: storage.cuda(), + ) + self.trainer.logger.info( + f"Loading layer weights with keyword: {self.keywords}, " + f"replace keyword with: {self.replacement}" + ) + weight = OrderedDict() + for key, value in checkpoint["state_dict"].items(): + if not key.startswith("module."): + if comm.get_world_size() > 1: + key = "module." + key # xxx.xxx -> module.xxx.xxx + # Now all keys contain "module." no matter DDP or not. + if self.keywords in key: + key = key.replace(self.keywords, self.replacement) + if comm.get_world_size() == 1: + key = key[7:] # module.xxx.xxx -> xxx.xxx + weight[key] = value + load_state_info = self.trainer.model.load_state_dict( + weight, strict=self.strict + ) + self.trainer.logger.info(f"Missing keys: {load_state_info[0]}") + if self.trainer.cfg.resume: + self.trainer.logger.info( + f"Resuming train at eval epoch: {checkpoint['epoch']}" + ) + self.trainer.start_epoch = checkpoint["epoch"] + self.trainer.best_metric_value = checkpoint["best_metric_value"] + self.trainer.optimizer.load_state_dict(checkpoint["optimizer"]) + self.trainer.scheduler.load_state_dict(checkpoint["scheduler"]) + if self.trainer.cfg.enable_amp: + self.trainer.scaler.load_state_dict(checkpoint["scaler"]) + else: + self.trainer.logger.info(f"No weight found at: {self.trainer.cfg.weight}") + + +@HOOKS.register_module() +class PreciseEvaluator(HookBase): + def __init__(self, test_last=False): + self.test_last = test_last + + def after_train(self): + self.trainer.logger.info( + ">>>>>>>>>>>>>>>> Start Precise Evaluation >>>>>>>>>>>>>>>>" + ) + torch.cuda.empty_cache() + cfg = self.trainer.cfg + tester = TESTERS.build( + dict(type=cfg.test.type, cfg=cfg, model=self.trainer.model) + ) + if self.test_last: + self.trainer.logger.info("=> Testing on model_last ...") + else: + self.trainer.logger.info("=> Testing on model_best ...") + best_path = os.path.join( + self.trainer.cfg.save_path, "model", "model_best.pth" + ) + checkpoint = torch.load(best_path) + state_dict = checkpoint["state_dict"] + tester.model.load_state_dict(state_dict, strict=True) + tester.test() + + +@HOOKS.register_module() +class DataCacheOperator(HookBase): + def __init__(self, data_root, split): + self.data_root = data_root + self.split = split + self.data_list = self.get_data_list() + + def get_data_list(self): + if isinstance(self.split, str): + data_list = glob.glob(os.path.join(self.data_root, self.split, "*.pth")) + elif isinstance(self.split, Sequence): + data_list = [] + for split in self.split: + data_list += glob.glob(os.path.join(self.data_root, split, "*.pth")) + else: + raise NotImplementedError + return data_list + + def get_cache_name(self, data_path): + data_name = data_path.replace(os.path.dirname(self.data_root), "").split(".")[0] + return "pointcept" + data_name.replace(os.path.sep, "-") + + def before_train(self): + self.trainer.logger.info( + f"=> Caching dataset: {self.data_root}, split: {self.split} ..." + ) + if is_main_process(): + for data_path in self.data_list: + cache_name = self.get_cache_name(data_path) + data = torch.load(data_path) + shared_dict(cache_name, data) + synchronize() + + +@HOOKS.register_module() +class RuntimeProfiler(HookBase): + def __init__( + self, + forward=True, + backward=True, + interrupt=False, + warm_up=2, + sort_by="cuda_time_total", + row_limit=30, + ): + self.forward = forward + self.backward = backward + self.interrupt = interrupt + self.warm_up = warm_up + self.sort_by = sort_by + self.row_limit = row_limit + + def before_train(self): + self.trainer.logger.info("Profiling runtime ...") + from torch.profiler import profile, record_function, ProfilerActivity + + for i, input_dict in enumerate(self.trainer.train_loader): + if i == self.warm_up + 1: + break + for key in input_dict.keys(): + if isinstance(input_dict[key], torch.Tensor): + input_dict[key] = input_dict[key].cuda(non_blocking=True) + if self.forward: + with profile( + activities=[ProfilerActivity.CPU, ProfilerActivity.CUDA], + record_shapes=True, + profile_memory=True, + with_stack=True, + ) as forward_prof: + with record_function("model_inference"): + output_dict = self.trainer.model(input_dict) + else: + output_dict = self.trainer.model(input_dict) + loss = output_dict["loss"] + if self.backward: + with profile( + activities=[ProfilerActivity.CPU, ProfilerActivity.CUDA], + record_shapes=True, + profile_memory=True, + with_stack=True, + ) as backward_prof: + with record_function("model_inference"): + loss.backward() + self.trainer.logger.info(f"Profile: [{i + 1}/{self.warm_up + 1}]") + if self.forward: + self.trainer.logger.info( + "Forward profile: \n" + + str( + forward_prof.key_averages().table( + sort_by=self.sort_by, row_limit=self.row_limit + ) + ) + ) + forward_prof.export_chrome_trace( + os.path.join(self.trainer.cfg.save_path, "forward_trace.json") + ) + + if self.backward: + self.trainer.logger.info( + "Backward profile: \n" + + str( + backward_prof.key_averages().table( + sort_by=self.sort_by, row_limit=self.row_limit + ) + ) + ) + backward_prof.export_chrome_trace( + os.path.join(self.trainer.cfg.save_path, "backward_trace.json") + ) + if self.interrupt: + sys.exit(0) + + +@HOOKS.register_module() +class RuntimeProfilerV2(HookBase): + def __init__( + self, + interrupt=False, + wait=1, + warmup=1, + active=10, + repeat=1, + sort_by="cuda_time_total", + row_limit=30, + ): + self.interrupt = interrupt + self.wait = wait + self.warmup = warmup + self.active = active + self.repeat = repeat + self.sort_by = sort_by + self.row_limit = row_limit + + def before_train(self): + self.trainer.logger.info("Profiling runtime ...") + from torch.profiler import ( + profile, + record_function, + ProfilerActivity, + schedule, + tensorboard_trace_handler, + ) + + prof = profile( + activities=[ProfilerActivity.CPU, ProfilerActivity.CUDA], + schedule=schedule( + wait=self.wait, + warmup=self.warmup, + active=self.active, + repeat=self.repeat, + ), + on_trace_ready=tensorboard_trace_handler(self.trainer.cfg.save_path), + record_shapes=True, + profile_memory=True, + with_stack=True, + ) + prof.start() + for i, input_dict in enumerate(self.trainer.train_loader): + if i >= (self.wait + self.warmup + self.active) * self.repeat: + break + for key in input_dict.keys(): + if isinstance(input_dict[key], torch.Tensor): + input_dict[key] = input_dict[key].cuda(non_blocking=True) + with record_function("model_forward"): + output_dict = self.trainer.model(input_dict) + loss = output_dict["loss"] + with record_function("model_backward"): + loss.backward() + prof.step() + self.trainer.logger.info( + f"Profile: [{i + 1}/{(self.wait + self.warmup + self.active) * self.repeat}]" + ) + self.trainer.logger.info( + "Profile: \n" + + str( + prof.key_averages().table( + sort_by=self.sort_by, row_limit=self.row_limit + ) + ) + ) + prof.stop() + + if self.interrupt: + sys.exit(0) diff --git a/services/audio2exp-service/LAM_Audio2Expression/engines/infer.py b/services/audio2exp-service/LAM_Audio2Expression/engines/infer.py new file mode 100644 index 0000000..42e4aef --- /dev/null +++ b/services/audio2exp-service/LAM_Audio2Expression/engines/infer.py @@ -0,0 +1,298 @@ +""" +Copyright 2024-2025 The Alibaba 3DAIGC Team Authors. All rights reserved. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + https://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. + +""" + +import os +import math +import time +import librosa +import numpy as np +from collections import OrderedDict + +import torch +import torch.utils.data +import torch.nn.functional as F + +from .defaults import create_ddp_model +import utils.comm as comm +from models import build_model +from utils.logger import get_root_logger +from utils.registry import Registry +from utils.misc import ( + AverageMeter, +) + +from models.utils import smooth_mouth_movements, apply_frame_blending, apply_savitzky_golay_smoothing, apply_random_brow_movement, \ + symmetrize_blendshapes, apply_random_eye_blinks, apply_random_eye_blinks_context, export_blendshape_animation, \ + RETURN_CODE, DEFAULT_CONTEXT, ARKitBlendShape + +INFER = Registry("infer") + +# Device detection for CPU/GPU support +def get_device(): + """Get the best available device (CUDA or CPU)""" + if torch.cuda.is_available(): + return torch.device('cuda') + else: + return torch.device('cpu') + +class InferBase: + def __init__(self, cfg, model=None, verbose=False) -> None: + torch.multiprocessing.set_sharing_strategy("file_system") + self.device = get_device() + self.logger = get_root_logger( + log_file=os.path.join(cfg.save_path, "infer.log"), + file_mode="a" if cfg.resume else "w", + ) + self.logger.info("=> Loading config ...") + self.logger.info(f"=> Using device: {self.device}") + self.cfg = cfg + self.verbose = verbose + if self.verbose: + self.logger.info(f"Save path: {cfg.save_path}") + self.logger.info(f"Config:\n{cfg.pretty_text}") + if model is None: + self.logger.info("=> Building model ...") + self.model = self.build_model() + else: + self.model = model + + def build_model(self): + model = build_model(self.cfg.model) + n_parameters = sum(p.numel() for p in model.parameters() if p.requires_grad) + self.logger.info(f"Num params: {n_parameters}") + model = create_ddp_model( + model.to(self.device), + broadcast_buffers=False, + find_unused_parameters=self.cfg.find_unused_parameters, + ) + if os.path.isfile(self.cfg.weight): + self.logger.info(f"Loading weight at: {self.cfg.weight}") + checkpoint = torch.load(self.cfg.weight, map_location=self.device, weights_only=False) + weight = OrderedDict() + for key, value in checkpoint["state_dict"].items(): + if key.startswith("module."): + if comm.get_world_size() == 1: + key = key[7:] # module.xxx.xxx -> xxx.xxx + else: + if comm.get_world_size() > 1: + key = "module." + key # xxx.xxx -> module.xxx.xxx + weight[key] = value + model.load_state_dict(weight, strict=True) + self.logger.info( + "=> Loaded weight '{}'".format( + self.cfg.weight + ) + ) + else: + raise RuntimeError("=> No checkpoint found at '{}'".format(self.cfg.weight)) + return model + + + def infer(self): + raise NotImplementedError + + + +@INFER.register_module() +class Audio2ExpressionInfer(InferBase): + def infer(self): + logger = get_root_logger() + logger.info(">>>>>>>>>>>>>>>> Start Inference >>>>>>>>>>>>>>>>") + batch_time = AverageMeter() + self.model.eval() + + # process audio-input + assert os.path.exists(self.cfg.audio_input) + if(self.cfg.ex_vol): + logger.info("Extract vocals ...") + vocal_path = self.extract_vocal_track(self.cfg.audio_input) + logger.info("=> Extract vocals at: {}".format(vocal_path if os.path.exists(vocal_path) else '... Failed')) + if(os.path.exists(vocal_path)): + self.cfg.audio_input = vocal_path + + with torch.no_grad(): + input_dict = {} + input_dict['id_idx'] = F.one_hot(torch.tensor(self.cfg.id_idx), + self.cfg.model.backbone.num_identity_classes).to(self.device)[None,...] + speech_array, ssr = librosa.load(self.cfg.audio_input, sr=16000) + input_dict['input_audio_array'] = torch.FloatTensor(speech_array).to(self.device)[None,...] + + end = time.time() + output_dict = self.model(input_dict) + batch_time.update(time.time() - end) + + logger.info( + "Infer: [{}] " + "Running Time: {batch_time.avg:.3f} ".format( + self.cfg.audio_input, + batch_time=batch_time, + ) + ) + + out_exp = output_dict['pred_exp'].squeeze().cpu().numpy() + + frame_length = math.ceil(speech_array.shape[0] / ssr * 30) + volume = librosa.feature.rms(y=speech_array, frame_length=int(1 / 30 * ssr), hop_length=int(1 / 30 * ssr))[0] + if (volume.shape[0] > frame_length): + volume = volume[:frame_length] + + if(self.cfg.movement_smooth): + out_exp = smooth_mouth_movements(out_exp, 0, volume) + + if (self.cfg.brow_movement): + out_exp = apply_random_brow_movement(out_exp, volume) + + pred_exp = self.blendshape_postprocess(out_exp) + + if(self.cfg.save_json_path is not None): + export_blendshape_animation(pred_exp, + self.cfg.save_json_path, + ARKitBlendShape, + fps=self.cfg.fps) + + logger.info("<<<<<<<<<<<<<<<<< End Evaluation <<<<<<<<<<<<<<<<<") + + def infer_streaming_audio(self, + audio: np.ndarray, + ssr: float, + context: dict): + + if (context is None): + context = DEFAULT_CONTEXT.copy() + max_frame_length = 64 + + frame_length = math.ceil(audio.shape[0] / ssr * 30) + output_context = DEFAULT_CONTEXT.copy() + + volume = librosa.feature.rms(y=audio, frame_length=min(int(1 / 30 * ssr), len(audio)), hop_length=int(1 / 30 * ssr))[0] + if (volume.shape[0] > frame_length): + volume = volume[:frame_length] + + # resample audio + if (ssr != self.cfg.audio_sr): + in_audio = librosa.resample(audio.astype(np.float32), orig_sr=ssr, target_sr=self.cfg.audio_sr) + else: + in_audio = audio.copy() + + start_frame = int(max_frame_length - in_audio.shape[0] / self.cfg.audio_sr * 30) + + if (context['is_initial_input'] or (context['previous_audio'] is None)): + blank_audio_length = self.cfg.audio_sr * max_frame_length // 30 - in_audio.shape[0] + blank_audio = np.zeros(blank_audio_length, dtype=np.float32) + + # pre-append + input_audio = np.concatenate([blank_audio, in_audio]) + output_context['previous_audio'] = input_audio + + else: + clip_pre_audio_length = self.cfg.audio_sr * max_frame_length // 30 - in_audio.shape[0] + clip_pre_audio = context['previous_audio'][-clip_pre_audio_length:] + input_audio = np.concatenate([clip_pre_audio, in_audio]) + output_context['previous_audio'] = input_audio + + with torch.no_grad(): + try: + input_dict = {} + input_dict['id_idx'] = F.one_hot(torch.tensor(self.cfg.id_idx), + self.cfg.model.backbone.num_identity_classes).to(self.device)[ + None, ...] + input_dict['input_audio_array'] = torch.FloatTensor(input_audio).to(self.device)[None, ...] + output_dict = self.model(input_dict) + out_exp = output_dict['pred_exp'].squeeze().cpu().numpy()[start_frame:, :] + except Exception as e: + self.logger.error(f'Error: failed to predict expression: {e}') + import traceback + traceback.print_exc() + output_dict = {} + output_dict['pred_exp'] = torch.zeros((1, max_frame_length, 52)).float() + out_exp = output_dict['pred_exp'].squeeze().cpu().numpy()[start_frame:, :] + + + # post-process + if (context['previous_expression'] is None): + out_exp = self.apply_expression_postprocessing(out_exp, audio_volume=volume) + else: + previous_length = context['previous_expression'].shape[0] + out_exp = self.apply_expression_postprocessing(expression_params = np.concatenate([context['previous_expression'], out_exp], axis=0), + audio_volume=np.concatenate([context['previous_volume'], volume], axis=0), + processed_frames=previous_length)[previous_length:, :] + + if (context['previous_expression'] is not None): + output_context['previous_expression'] = np.concatenate([context['previous_expression'], out_exp], axis=0)[ + -max_frame_length:, :] + output_context['previous_volume'] = np.concatenate([context['previous_volume'], volume], axis=0)[-max_frame_length:] + else: + output_context['previous_expression'] = out_exp.copy() + output_context['previous_volume'] = volume.copy() + + output_context['first_input_flag'] = False + + return {"code": RETURN_CODE['SUCCESS'], + "expression": out_exp, + "headpose": None}, output_context + def apply_expression_postprocessing( + self, + expression_params: np.ndarray, + processed_frames: int = 0, + audio_volume: np.ndarray = None + ) -> np.ndarray: + """Applies full post-processing pipeline to facial expression parameters. + + Args: + expression_params: Raw output from animation model [num_frames, num_parameters] + processed_frames: Number of frames already processed in previous batches + audio_volume: Optional volume array for audio-visual synchronization + + Returns: + Processed expression parameters ready for animation synthesis + """ + # Pipeline execution order matters - maintain sequence + expression_params = smooth_mouth_movements(expression_params, processed_frames, audio_volume) + expression_params = apply_frame_blending(expression_params, processed_frames) + expression_params, _ = apply_savitzky_golay_smoothing(expression_params, window_length=5) + expression_params = symmetrize_blendshapes(expression_params) + expression_params = apply_random_eye_blinks_context(expression_params, processed_frames=processed_frames) + + return expression_params + + def extract_vocal_track( + self, + input_audio_path: str + ) -> str: + """Isolates vocal track from audio file using source separation. + + Args: + input_audio_path: Path to input audio file containing vocals+accompaniment + + Returns: + Path to isolated vocal track in WAV format + """ + separation_command = f'spleeter separate -p spleeter:2stems -o {self.cfg.save_path} {input_audio_path}' + os.system(separation_command) + + base_name = os.path.splitext(os.path.basename(input_audio_path))[0] + return os.path.join(self.cfg.save_path, base_name, 'vocals.wav') + + def blendshape_postprocess(self, + bs_array: np.ndarray + )->np.array: + + bs_array, _ = apply_savitzky_golay_smoothing(bs_array, window_length=5) + bs_array = symmetrize_blendshapes(bs_array) + bs_array = apply_random_eye_blinks(bs_array) + + return bs_array diff --git a/services/audio2exp-service/LAM_Audio2Expression/engines/launch.py b/services/audio2exp-service/LAM_Audio2Expression/engines/launch.py new file mode 100644 index 0000000..05f5671 --- /dev/null +++ b/services/audio2exp-service/LAM_Audio2Expression/engines/launch.py @@ -0,0 +1,135 @@ +""" +Launcher + +modified from detectron2(https://github.com/facebookresearch/detectron2) + +""" + +import os +import logging +from datetime import timedelta +import torch +import torch.distributed as dist +import torch.multiprocessing as mp + +from utils import comm + +__all__ = ["DEFAULT_TIMEOUT", "launch"] + +DEFAULT_TIMEOUT = timedelta(minutes=30) + + +def _find_free_port(): + import socket + + sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + # Binding to port 0 will cause the OS to find an available port for us + sock.bind(("", 0)) + port = sock.getsockname()[1] + sock.close() + # NOTE: there is still a chance the port could be taken by other processes. + return port + + +def launch( + main_func, + num_gpus_per_machine, + num_machines=1, + machine_rank=0, + dist_url=None, + cfg=(), + timeout=DEFAULT_TIMEOUT, +): + """ + Launch multi-gpu or distributed training. + This function must be called on all machines involved in the training. + It will spawn child processes (defined by ``num_gpus_per_machine``) on each machine. + Args: + main_func: a function that will be called by `main_func(*args)` + num_gpus_per_machine (int): number of GPUs per machine + num_machines (int): the total number of machines + machine_rank (int): the rank of this machine + dist_url (str): url to connect to for distributed jobs, including protocol + e.g. "tcp://127.0.0.1:8686". + Can be set to "auto" to automatically select a free port on localhost + timeout (timedelta): timeout of the distributed workers + args (tuple): arguments passed to main_func + """ + world_size = num_machines * num_gpus_per_machine + if world_size > 1: + if dist_url == "auto": + assert ( + num_machines == 1 + ), "dist_url=auto not supported in multi-machine jobs." + port = _find_free_port() + dist_url = f"tcp://127.0.0.1:{port}" + if num_machines > 1 and dist_url.startswith("file://"): + logger = logging.getLogger(__name__) + logger.warning( + "file:// is not a reliable init_method in multi-machine jobs. Prefer tcp://" + ) + + mp.spawn( + _distributed_worker, + nprocs=num_gpus_per_machine, + args=( + main_func, + world_size, + num_gpus_per_machine, + machine_rank, + dist_url, + cfg, + timeout, + ), + daemon=False, + ) + else: + main_func(*cfg) + + +def _distributed_worker( + local_rank, + main_func, + world_size, + num_gpus_per_machine, + machine_rank, + dist_url, + cfg, + timeout=DEFAULT_TIMEOUT, +): + assert ( + torch.cuda.is_available() + ), "cuda is not available. Please check your installation." + global_rank = machine_rank * num_gpus_per_machine + local_rank + try: + dist.init_process_group( + backend="NCCL", + init_method=dist_url, + world_size=world_size, + rank=global_rank, + timeout=timeout, + ) + except Exception as e: + logger = logging.getLogger(__name__) + logger.error("Process group URL: {}".format(dist_url)) + raise e + + # Setup the local process group (which contains ranks within the same machine) + assert comm._LOCAL_PROCESS_GROUP is None + num_machines = world_size // num_gpus_per_machine + for i in range(num_machines): + ranks_on_i = list( + range(i * num_gpus_per_machine, (i + 1) * num_gpus_per_machine) + ) + pg = dist.new_group(ranks_on_i) + if i == machine_rank: + comm._LOCAL_PROCESS_GROUP = pg + + assert num_gpus_per_machine <= torch.cuda.device_count() + torch.cuda.set_device(local_rank) + + # synchronize is needed here to prevent a possible timeout after calling init_process_group + # See: https://github.com/facebookresearch/maskrcnn-benchmark/issues/172 + comm.synchronize() + + main_func(*cfg) diff --git a/services/audio2exp-service/LAM_Audio2Expression/engines/train.py b/services/audio2exp-service/LAM_Audio2Expression/engines/train.py new file mode 100644 index 0000000..7de2364 --- /dev/null +++ b/services/audio2exp-service/LAM_Audio2Expression/engines/train.py @@ -0,0 +1,299 @@ +""" +The code is base on https://github.com/Pointcept/Pointcept +""" + +import os +import sys +import weakref +import torch +import torch.nn as nn +import torch.utils.data +from functools import partial + +if sys.version_info >= (3, 10): + from collections.abc import Iterator +else: + from collections import Iterator +from tensorboardX import SummaryWriter + +from .defaults import create_ddp_model, worker_init_fn +from .hooks import HookBase, build_hooks +import utils.comm as comm +from datasets import build_dataset, point_collate_fn, collate_fn +from models import build_model +from utils.logger import get_root_logger +from utils.optimizer import build_optimizer +from utils.scheduler import build_scheduler +from utils.events import EventStorage +from utils.registry import Registry + + +TRAINERS = Registry("trainers") + + +class TrainerBase: + def __init__(self) -> None: + self.hooks = [] + self.epoch = 0 + self.start_epoch = 0 + self.max_epoch = 0 + self.max_iter = 0 + self.comm_info = dict() + self.data_iterator: Iterator = enumerate([]) + self.storage: EventStorage + self.writer: SummaryWriter + + def register_hooks(self, hooks) -> None: + hooks = build_hooks(hooks) + for h in hooks: + assert isinstance(h, HookBase) + # To avoid circular reference, hooks and trainer cannot own each other. + # This normally does not matter, but will cause memory leak if the + # involved objects contain __del__: + # See http://engineering.hearsaysocial.com/2013/06/16/circular-references-in-python/ + h.trainer = weakref.proxy(self) + self.hooks.extend(hooks) + + def train(self): + with EventStorage() as self.storage: + # => before train + self.before_train() + for self.epoch in range(self.start_epoch, self.max_epoch): + # => before epoch + self.before_epoch() + # => run_epoch + for ( + self.comm_info["iter"], + self.comm_info["input_dict"], + ) in self.data_iterator: + # => before_step + self.before_step() + # => run_step + self.run_step() + # => after_step + self.after_step() + # => after epoch + self.after_epoch() + # => after train + self.after_train() + + def before_train(self): + for h in self.hooks: + h.before_train() + + def before_epoch(self): + for h in self.hooks: + h.before_epoch() + + def before_step(self): + for h in self.hooks: + h.before_step() + + def run_step(self): + raise NotImplementedError + + def after_step(self): + for h in self.hooks: + h.after_step() + + def after_epoch(self): + for h in self.hooks: + h.after_epoch() + self.storage.reset_histories() + + def after_train(self): + # Sync GPU before running train hooks + comm.synchronize() + for h in self.hooks: + h.after_train() + if comm.is_main_process(): + self.writer.close() + + +@TRAINERS.register_module("DefaultTrainer") +class Trainer(TrainerBase): + def __init__(self, cfg): + super(Trainer, self).__init__() + self.epoch = 0 + self.start_epoch = 0 + self.max_epoch = cfg.eval_epoch + self.best_metric_value = -torch.inf + self.logger = get_root_logger( + log_file=os.path.join(cfg.save_path, "train.log"), + file_mode="a" if cfg.resume else "w", + ) + self.logger.info("=> Loading config ...") + self.cfg = cfg + self.logger.info(f"Save path: {cfg.save_path}") + self.logger.info(f"Config:\n{cfg.pretty_text}") + self.logger.info("=> Building model ...") + self.model = self.build_model() + self.logger.info("=> Building writer ...") + self.writer = self.build_writer() + self.logger.info("=> Building train dataset & dataloader ...") + self.train_loader = self.build_train_loader() + self.logger.info("=> Building val dataset & dataloader ...") + self.val_loader = self.build_val_loader() + self.logger.info("=> Building optimize, scheduler, scaler(amp) ...") + self.optimizer = self.build_optimizer() + self.scheduler = self.build_scheduler() + self.scaler = self.build_scaler() + self.logger.info("=> Building hooks ...") + self.register_hooks(self.cfg.hooks) + + def train(self): + with EventStorage() as self.storage: + # => before train + self.before_train() + self.logger.info(">>>>>>>>>>>>>>>> Start Training >>>>>>>>>>>>>>>>") + for self.epoch in range(self.start_epoch, self.max_epoch): + # => before epoch + # TODO: optimize to iteration based + if comm.get_world_size() > 1: + self.train_loader.sampler.set_epoch(self.epoch) + self.model.train() + self.data_iterator = enumerate(self.train_loader) + self.before_epoch() + # => run_epoch + for ( + self.comm_info["iter"], + self.comm_info["input_dict"], + ) in self.data_iterator: + # => before_step + self.before_step() + # => run_step + self.run_step() + # => after_step + self.after_step() + # => after epoch + self.after_epoch() + # => after train + self.after_train() + + def run_step(self): + input_dict = self.comm_info["input_dict"] + for key in input_dict.keys(): + if isinstance(input_dict[key], torch.Tensor): + input_dict[key] = input_dict[key].cuda(non_blocking=True) + with torch.cuda.amp.autocast(enabled=self.cfg.enable_amp): + output_dict = self.model(input_dict) + loss = output_dict["loss"] + self.optimizer.zero_grad() + if self.cfg.enable_amp: + self.scaler.scale(loss).backward() + self.scaler.step(self.optimizer) + + # When enable amp, optimizer.step call are skipped if the loss scaling factor is too large. + # Fix torch warning scheduler step before optimizer step. + scaler = self.scaler.get_scale() + self.scaler.update() + if scaler <= self.scaler.get_scale(): + self.scheduler.step() + else: + loss.backward() + self.optimizer.step() + self.scheduler.step() + if self.cfg.empty_cache: + torch.cuda.empty_cache() + self.comm_info["model_output_dict"] = output_dict + + def build_model(self): + model = build_model(self.cfg.model) + if self.cfg.sync_bn: + model = nn.SyncBatchNorm.convert_sync_batchnorm(model) + n_parameters = sum(p.numel() for p in model.parameters() if p.requires_grad) + # logger.info(f"Model: \n{self.model}") + self.logger.info(f"Num params: {n_parameters}") + model = create_ddp_model( + model.cuda(), + broadcast_buffers=False, + find_unused_parameters=self.cfg.find_unused_parameters, + ) + return model + + def build_writer(self): + writer = SummaryWriter(self.cfg.save_path) if comm.is_main_process() else None + self.logger.info(f"Tensorboard writer logging dir: {self.cfg.save_path}") + return writer + + def build_train_loader(self): + train_data = build_dataset(self.cfg.data.train) + + if comm.get_world_size() > 1: + train_sampler = torch.utils.data.distributed.DistributedSampler(train_data) + else: + train_sampler = None + + init_fn = ( + partial( + worker_init_fn, + num_workers=self.cfg.num_worker_per_gpu, + rank=comm.get_rank(), + seed=self.cfg.seed, + ) + if self.cfg.seed is not None + else None + ) + + train_loader = torch.utils.data.DataLoader( + train_data, + batch_size=self.cfg.batch_size_per_gpu, + shuffle=(train_sampler is None), + num_workers=0, + sampler=train_sampler, + collate_fn=partial(point_collate_fn, mix_prob=self.cfg.mix_prob), + pin_memory=True, + worker_init_fn=init_fn, + drop_last=True, + # persistent_workers=True, + ) + return train_loader + + def build_val_loader(self): + val_loader = None + if self.cfg.evaluate: + val_data = build_dataset(self.cfg.data.val) + if comm.get_world_size() > 1: + val_sampler = torch.utils.data.distributed.DistributedSampler(val_data) + else: + val_sampler = None + val_loader = torch.utils.data.DataLoader( + val_data, + batch_size=self.cfg.batch_size_val_per_gpu, + shuffle=False, + num_workers=self.cfg.num_worker_per_gpu, + pin_memory=True, + sampler=val_sampler, + collate_fn=collate_fn, + ) + return val_loader + + def build_optimizer(self): + return build_optimizer(self.cfg.optimizer, self.model, self.cfg.param_dicts) + + def build_scheduler(self): + assert hasattr(self, "optimizer") + assert hasattr(self, "train_loader") + self.cfg.scheduler.total_steps = len(self.train_loader) * self.cfg.eval_epoch + return build_scheduler(self.cfg.scheduler, self.optimizer) + + def build_scaler(self): + scaler = torch.cuda.amp.GradScaler() if self.cfg.enable_amp else None + return scaler + + +@TRAINERS.register_module("MultiDatasetTrainer") +class MultiDatasetTrainer(Trainer): + def build_train_loader(self): + from datasets import MultiDatasetDataloader + + train_data = build_dataset(self.cfg.data.train) + train_loader = MultiDatasetDataloader( + train_data, + self.cfg.batch_size_per_gpu, + self.cfg.num_worker_per_gpu, + self.cfg.mix_prob, + self.cfg.seed, + ) + self.comm_info["iter_per_epoch"] = len(train_loader) + return train_loader diff --git a/services/audio2exp-service/LAM_Audio2Expression/inference.py b/services/audio2exp-service/LAM_Audio2Expression/inference.py new file mode 100644 index 0000000..37ac22e --- /dev/null +++ b/services/audio2exp-service/LAM_Audio2Expression/inference.py @@ -0,0 +1,48 @@ +""" +# Copyright 2024-2025 The Alibaba 3DAIGC Team Authors. All rights reserved. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + https://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. + +""" + +from engines.defaults import ( + default_argument_parser, + default_config_parser, + default_setup, +) +from engines.infer import INFER +from engines.launch import launch + + +def main_worker(cfg): + cfg = default_setup(cfg) + infer = INFER.build(dict(type=cfg.infer.type, cfg=cfg)) + infer.infer() + + +def main(): + args = default_argument_parser().parse_args() + cfg = default_config_parser(args.config_file, args.options) + + launch( + main_worker, + num_gpus_per_machine=args.num_gpus, + num_machines=args.num_machines, + machine_rank=args.machine_rank, + dist_url=args.dist_url, + cfg=(cfg,), + ) + + +if __name__ == "__main__": + main() diff --git a/services/audio2exp-service/LAM_Audio2Expression/inference_streaming_audio.py b/services/audio2exp-service/LAM_Audio2Expression/inference_streaming_audio.py new file mode 100644 index 0000000..c14b084 --- /dev/null +++ b/services/audio2exp-service/LAM_Audio2Expression/inference_streaming_audio.py @@ -0,0 +1,60 @@ +""" +# Copyright 2024-2025 The Alibaba 3DAIGC Team Authors. All rights reserved. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + https://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. + +""" + +import numpy as np + +from engines.defaults import ( + default_argument_parser, + default_config_parser, + default_setup, +) +from engines.infer import INFER +import librosa +from tqdm import tqdm +import time + + +def export_json(bs_array, json_path): + from models.utils import export_blendshape_animation, ARKitBlendShape + export_blendshape_animation(bs_array, json_path, ARKitBlendShape, fps=30.0) + +if __name__ == '__main__': + args = default_argument_parser().parse_args() + args.config_file = 'configs/lam_audio2exp_config_streaming.py' + cfg = default_config_parser(args.config_file, args.options) + + + cfg = default_setup(cfg) + infer = INFER.build(dict(type=cfg.infer.type, cfg=cfg)) + infer.model.eval() + + audio, sample_rate = librosa.load(cfg.audio_input, sr=16000) + context = None + input_num = audio.shape[0]//16000+1 + gap = 16000 + all_exp = [] + for i in tqdm(range(input_num)): + + start = time.time() + output, context = infer.infer_streaming_audio(audio[i*gap:(i+1)*gap], sample_rate, context) + end = time.time() + print('Inference time {}'.format(end - start)) + all_exp.append(output['expression']) + + all_exp = np.concatenate(all_exp,axis=0) + + export_json(all_exp, cfg.save_json_path) \ No newline at end of file diff --git a/services/audio2exp-service/LAM_Audio2Expression/lam_modal.py b/services/audio2exp-service/LAM_Audio2Expression/lam_modal.py new file mode 100644 index 0000000..d50f746 --- /dev/null +++ b/services/audio2exp-service/LAM_Audio2Expression/lam_modal.py @@ -0,0 +1,189 @@ +import os +import sys +import subprocess +import time +import shutil +import modal +import base64 + +# アプリ名を変更 +app = modal.App("lam-final-v33-ui-fix-v2") + +# --- 事前チェック --- +local_assets_path = "./assets/human_parametric_models/flame_assets/flame/flame2023.pkl" +if __name__ == "__main__": + if not os.path.exists(local_assets_path): + print(f"❌ CRITICAL ERROR: Local asset not found at: {local_assets_path}") + sys.exit(1) + +# --- UI修復パッチ (Base64) --- +# 1. GradioのExamplesを無効化 +# 2. サーバーポートを8080に固定 +PATCH_SCRIPT = """ +import re +import os + +path = '/root/LAM/app_lam.py' +if os.path.exists(path): + print("🛠️ Applying UI patch...") + with open(path, 'r') as f: + code = f.read() + + # 1. Examples機能を無効化するコードを注入 + patch_code = ''' +import gradio as gr +# --- PATCH START --- +try: + class DummyExamples: + def __init__(self, *args, **kwargs): pass + def attach_load_event(self, *args, **kwargs): pass + def render(self): pass + gr.Examples = DummyExamples + print("✅ Gradio Examples disabled to prevent UI crash.") +except Exception as e: + print(f"⚠️ Failed to disable examples: {e}") +# --- PATCH END --- +''' + code = code.replace('import gradio as gr', patch_code) + + # 2. 起動設定の強制書き換え + if '.launch(' in code: + code = re.sub(r'\.launch\s*\(', ".launch(server_name='0.0.0.0', server_port=8080, ", code) + print("✅ Server port forced to 8080.") + + with open(path, 'w') as f: + f.write(code) + print("🚀 Patch applied successfully.") +""" + +# スクリプトをBase64化 +patch_b64 = base64.b64encode(PATCH_SCRIPT.encode('utf-8')).decode('utf-8') +patch_cmd = f"python -c \"import base64; exec(base64.b64decode('{patch_b64}'))\"" + + +# --- 1. 環境構築 --- +image = ( + modal.Image.from_registry("nvidia/cuda:11.8.0-devel-ubuntu22.04", add_python="3.10") + .apt_install( + "git", "libgl1-mesa-glx", "libglib2.0-0", "ffmpeg", "wget", "tree", + "libusb-1.0-0", "build-essential", "ninja-build", + "clang", "llvm", "libclang-dev" + ) + + # 1. Base setup + .run_commands( + "python -m pip install --upgrade pip setuptools wheel", + "pip install 'numpy==1.23.5'" + ) + # 2. PyTorch 2.2.0 + .run_commands( + "pip install torch==2.2.0 torchvision==0.17.0 torchaudio==2.2.0 --index-url https://download.pytorch.org/whl/cu118" + ) + + # 3. Build Environment + .env({ + "FORCE_CUDA": "1", + "CUDA_HOME": "/usr/local/cuda", + "MAX_JOBS": "4", + "TORCH_CUDA_ARCH_LIST": "8.6", + "CC": "clang", + "CXX": "clang++" + }) + + # 4. Critical Build (no-build-isolation) + .run_commands( + "pip install chumpy==0.70 --no-build-isolation", + "pip install git+https://github.com/facebookresearch/pytorch3d.git@v0.7.7 --no-build-isolation" + ) + + # 5. Dependencies + .pip_install( + "gradio==3.50.2", + "omegaconf==2.3.0", + "pandas", + "scipy<1.14.0", + "opencv-python-headless", + "imageio[ffmpeg]", + "moviepy==1.0.3", + "rembg[gpu]", + "scikit-image", + "pillow", + "onnxruntime-gpu", + "huggingface_hub>=0.24.0", + "filelock", + "typeguard", + + "transformers==4.44.2", + "diffusers==0.30.3", + "accelerate==0.34.2", + "tyro==0.8.0", + "mediapipe==0.10.21", + + "tensorboard", + "rich", + "loguru", + "Cython", + "PyMCubes", + "trimesh", + "einops", + "plyfile", + "jaxtyping", + "ninja", + "numpy==1.23.5" + ) + + # 6. LAM 3D Libs + .run_commands( + "pip install git+https://github.com/ashawkey/diff-gaussian-rasterization.git --no-build-isolation", + "pip install git+https://github.com/ShenhanQian/nvdiffrast.git@backface-culling --no-build-isolation" + ) + + # 7. LAM Setup with UI Patch + .run_commands( + "mkdir -p /root/LAM", + "rm -rf /root/LAM", + "git clone https://github.com/aigc3d/LAM.git /root/LAM", + + # cpu_nms ビルド + "cd /root/LAM/external/landmark_detection/FaceBoxesV2/utils/nms && " + "echo \"from setuptools import setup, Extension; from Cython.Build import cythonize; import numpy; setup(ext_modules=cythonize([Extension('cpu_nms', ['cpu_nms.pyx'])]), include_dirs=[numpy.get_include()])\" > setup.py && " + "python setup.py build_ext --inplace", + + # ★パッチ適用(UIのサンプル機能を無効化) + patch_cmd + ) +) + +# --- 2. サーバー準備 --- +def setup_server(): + from huggingface_hub import snapshot_download + print("📥 Downloading checkpoints...") + try: + snapshot_download( + repo_id="3DAIGC/LAM-20K", + local_dir="/root/LAM/model_zoo/lam_models/releases/lam/lam-20k/step_045500", + local_dir_use_symlinks=False + ) + except Exception as e: + print(f"Checkpoints download warning: {e}") + +image = ( + image + .run_function(setup_server) + .add_local_dir("./assets", remote_path="/root/LAM/model_zoo", copy=True) +) + +# --- 3. アプリ起動 --- +@app.function( + image=image, + gpu="A10G", + timeout=3600 +) +@modal.web_server(8080) +def ui(): + os.chdir("/root/LAM") + import sys + print(f"🚀 Launching LAM App (Python {sys.version})") + + cmd = "python -u app_lam.py" + subprocess.Popen(cmd, shell=True, stdout=sys.stdout, stderr=sys.stderr).wait() \ No newline at end of file diff --git a/services/audio2exp-service/LAM_Audio2Expression/models/__init__.py b/services/audio2exp-service/LAM_Audio2Expression/models/__init__.py new file mode 100644 index 0000000..f4beb83 --- /dev/null +++ b/services/audio2exp-service/LAM_Audio2Expression/models/__init__.py @@ -0,0 +1,7 @@ +from .builder import build_model + +from .default import DefaultEstimator + +# Backbones +from .network import Audio2Expression + diff --git a/services/audio2exp-service/LAM_Audio2Expression/models/builder.py b/services/audio2exp-service/LAM_Audio2Expression/models/builder.py new file mode 100644 index 0000000..eed2627 --- /dev/null +++ b/services/audio2exp-service/LAM_Audio2Expression/models/builder.py @@ -0,0 +1,13 @@ +""" +Modified by https://github.com/Pointcept/Pointcept +""" + +from utils.registry import Registry + +MODELS = Registry("models") +MODULES = Registry("modules") + + +def build_model(cfg): + """Build models.""" + return MODELS.build(cfg) diff --git a/services/audio2exp-service/LAM_Audio2Expression/models/default.py b/services/audio2exp-service/LAM_Audio2Expression/models/default.py new file mode 100644 index 0000000..07655f6 --- /dev/null +++ b/services/audio2exp-service/LAM_Audio2Expression/models/default.py @@ -0,0 +1,25 @@ +import torch.nn as nn + +from models.losses import build_criteria +from .builder import MODELS, build_model + +@MODELS.register_module() +class DefaultEstimator(nn.Module): + def __init__(self, backbone=None, criteria=None): + super().__init__() + self.backbone = build_model(backbone) + self.criteria = build_criteria(criteria) + + def forward(self, input_dict): + pred_exp = self.backbone(input_dict) + # train + if self.training: + loss = self.criteria(pred_exp, input_dict["gt_exp"]) + return dict(loss=loss) + # eval + elif "gt_exp" in input_dict.keys(): + loss = self.criteria(pred_exp, input_dict["gt_exp"]) + return dict(loss=loss, pred_exp=pred_exp) + # infer + else: + return dict(pred_exp=pred_exp) diff --git a/services/audio2exp-service/LAM_Audio2Expression/models/encoder/wav2vec.py b/services/audio2exp-service/LAM_Audio2Expression/models/encoder/wav2vec.py new file mode 100644 index 0000000..7e490ce --- /dev/null +++ b/services/audio2exp-service/LAM_Audio2Expression/models/encoder/wav2vec.py @@ -0,0 +1,261 @@ +import numpy as np +from typing import Optional, Tuple + +import torch +import torch.nn as nn +import torch.nn.functional as F +from torch.nn import BCEWithLogitsLoss, CrossEntropyLoss, MSELoss + +from dataclasses import dataclass +from transformers import Wav2Vec2Model, Wav2Vec2PreTrainedModel +from transformers.modeling_outputs import BaseModelOutput +from transformers.file_utils import ModelOutput + + +_CONFIG_FOR_DOC = "Wav2Vec2Config" +_HIDDEN_STATES_START_POSITION = 2 + + +# the implementation of Wav2Vec2Model is borrowed from https://huggingface.co/transformers/_modules/transformers/models/wav2vec2/modeling_wav2vec2.html#Wav2Vec2Model +# initialize our encoder with the pre-trained wav2vec 2.0 weights. +def _compute_mask_indices( + shape: Tuple[int, int], + mask_prob: float, + mask_length: int, + attention_mask: Optional[torch.Tensor] = None, + min_masks: int = 0, +) -> np.ndarray: + bsz, all_sz = shape + mask = np.full((bsz, all_sz), False) + + all_num_mask = int( + mask_prob * all_sz / float(mask_length) + + np.random.rand() + ) + all_num_mask = max(min_masks, all_num_mask) + mask_idcs = [] + padding_mask = attention_mask.ne(1) if attention_mask is not None else None + for i in range(bsz): + if padding_mask is not None: + sz = all_sz - padding_mask[i].long().sum().item() + num_mask = int( + mask_prob * sz / float(mask_length) + + np.random.rand() + ) + num_mask = max(min_masks, num_mask) + else: + sz = all_sz + num_mask = all_num_mask + + lengths = np.full(num_mask, mask_length) + + if sum(lengths) == 0: + lengths[0] = min(mask_length, sz - 1) + + min_len = min(lengths) + if sz - min_len <= num_mask: + min_len = sz - num_mask - 1 + + mask_idc = np.random.choice(sz - min_len, num_mask, replace=False) + mask_idc = np.asarray([mask_idc[j] + offset for j in range(len(mask_idc)) for offset in range(lengths[j])]) + mask_idcs.append(np.unique(mask_idc[mask_idc < sz])) + + min_len = min([len(m) for m in mask_idcs]) + for i, mask_idc in enumerate(mask_idcs): + if len(mask_idc) > min_len: + mask_idc = np.random.choice(mask_idc, min_len, replace=False) + mask[i, mask_idc] = True + return mask + + +# linear interpolation layer +def linear_interpolation(features, input_fps, output_fps, output_len=None): + features = features.transpose(1, 2) + seq_len = features.shape[2] / float(input_fps) + if output_len is None: + output_len = int(seq_len * output_fps) + output_features = F.interpolate(features, size=output_len, align_corners=True, mode='linear') + return output_features.transpose(1, 2) + + +class Wav2Vec2Model(Wav2Vec2Model): + def __init__(self, config): + super().__init__(config) + self.lm_head = nn.Linear(1024, 32) + + def forward( + self, + input_values, + attention_mask=None, + output_attentions=None, + output_hidden_states=None, + return_dict=None, + frame_num=None + ): + import time as _t + import logging as _lg + _log = _lg.getLogger(__name__) + + self.config.output_attentions = True + output_attentions = output_attentions if output_attentions is not None else self.config.output_attentions + output_hidden_states = ( + output_hidden_states if output_hidden_states is not None else self.config.output_hidden_states + ) + return_dict = return_dict if return_dict is not None else self.config.use_return_dict + + _s = _t.monotonic() + hidden_states = self.feature_extractor(input_values) + hidden_states = hidden_states.transpose(1, 2) + _log.info(f"[Wav2Vec2] feature_extractor: {_t.monotonic()-_s:.2f}s, shape={list(hidden_states.shape)}") + + _s = _t.monotonic() + hidden_states = linear_interpolation(hidden_states, 50, 30, output_len=frame_num) + _log.info(f"[Wav2Vec2] interpolation: {_t.monotonic()-_s:.2f}s, shape={list(hidden_states.shape)}") + + if attention_mask is not None: + output_lengths = self._get_feat_extract_output_lengths(attention_mask.sum(-1)) + attention_mask = torch.zeros( + hidden_states.shape[:2], dtype=hidden_states.dtype, device=hidden_states.device + ) + attention_mask[ + (torch.arange(attention_mask.shape[0], device=hidden_states.device), output_lengths - 1) + ] = 1 + attention_mask = attention_mask.flip([-1]).cumsum(-1).flip([-1]).bool() + + _s = _t.monotonic() + hidden_states = self.feature_projection(hidden_states)[0] + _log.info(f"[Wav2Vec2] feature_projection: {_t.monotonic()-_s:.2f}s") + + _s = _t.monotonic() + encoder_outputs = self.encoder( + hidden_states, + attention_mask=attention_mask, + output_attentions=output_attentions, + output_hidden_states=output_hidden_states, + return_dict=return_dict, + ) + _log.info(f"[Wav2Vec2] encoder (12 layers): {_t.monotonic()-_s:.2f}s") + + hidden_states = encoder_outputs[0] + if not return_dict: + return (hidden_states,) + encoder_outputs[1:] + + return BaseModelOutput( + last_hidden_state=hidden_states, + hidden_states=encoder_outputs.hidden_states, + attentions=encoder_outputs.attentions, + ) + + +@dataclass +class SpeechClassifierOutput(ModelOutput): + loss: Optional[torch.FloatTensor] = None + logits: torch.FloatTensor = None + hidden_states: Optional[Tuple[torch.FloatTensor]] = None + attentions: Optional[Tuple[torch.FloatTensor]] = None + + +class Wav2Vec2ClassificationHead(nn.Module): + """Head for wav2vec classification task.""" + + def __init__(self, config): + super().__init__() + self.dense = nn.Linear(config.hidden_size, config.hidden_size) + self.dropout = nn.Dropout(config.final_dropout) + self.out_proj = nn.Linear(config.hidden_size, config.num_labels) + + def forward(self, features, **kwargs): + x = features + x = self.dropout(x) + x = self.dense(x) + x = torch.tanh(x) + x = self.dropout(x) + x = self.out_proj(x) + return x + + +class Wav2Vec2ForSpeechClassification(Wav2Vec2PreTrainedModel): + def __init__(self, config): + super().__init__(config) + self.num_labels = config.num_labels + self.pooling_mode = config.pooling_mode + self.config = config + + self.wav2vec2 = Wav2Vec2Model(config) + self.classifier = Wav2Vec2ClassificationHead(config) + + self.init_weights() + + def freeze_feature_extractor(self): + self.wav2vec2.feature_extractor._freeze_parameters() + + def merged_strategy( + self, + hidden_states, + mode="mean" + ): + if mode == "mean": + outputs = torch.mean(hidden_states, dim=1) + elif mode == "sum": + outputs = torch.sum(hidden_states, dim=1) + elif mode == "max": + outputs = torch.max(hidden_states, dim=1)[0] + else: + raise Exception( + "The pooling method hasn't been defined! Your pooling mode must be one of these ['mean', 'sum', 'max']") + + return outputs + + def forward( + self, + input_values, + attention_mask=None, + output_attentions=None, + output_hidden_states=None, + return_dict=None, + labels=None, + frame_num=None, + ): + return_dict = return_dict if return_dict is not None else self.config.use_return_dict + outputs = self.wav2vec2( + input_values, + attention_mask=attention_mask, + output_attentions=output_attentions, + output_hidden_states=output_hidden_states, + return_dict=return_dict, + ) + hidden_states = outputs[0] + hidden_states1 = linear_interpolation(hidden_states, 50, 30, output_len=frame_num) + hidden_states = self.merged_strategy(hidden_states1, mode=self.pooling_mode) + logits = self.classifier(hidden_states) + + loss = None + if labels is not None: + if self.config.problem_type is None: + if self.num_labels == 1: + self.config.problem_type = "regression" + elif self.num_labels > 1 and (labels.dtype == torch.long or labels.dtype == torch.int): + self.config.problem_type = "single_label_classification" + else: + self.config.problem_type = "multi_label_classification" + + if self.config.problem_type == "regression": + loss_fct = MSELoss() + loss = loss_fct(logits.view(-1, self.num_labels), labels) + elif self.config.problem_type == "single_label_classification": + loss_fct = CrossEntropyLoss() + loss = loss_fct(logits.view(-1, self.num_labels), labels.view(-1)) + elif self.config.problem_type == "multi_label_classification": + loss_fct = BCEWithLogitsLoss() + loss = loss_fct(logits, labels) + + if not return_dict: + output = (logits,) + outputs[2:] + return ((loss,) + output) if loss is not None else output + + return SpeechClassifierOutput( + loss=loss, + logits=logits, + hidden_states=hidden_states1, + attentions=outputs.attentions, + ) diff --git a/services/audio2exp-service/LAM_Audio2Expression/models/encoder/wavlm.py b/services/audio2exp-service/LAM_Audio2Expression/models/encoder/wavlm.py new file mode 100644 index 0000000..0e39b9b --- /dev/null +++ b/services/audio2exp-service/LAM_Audio2Expression/models/encoder/wavlm.py @@ -0,0 +1,87 @@ +import numpy as np +import torch +from transformers import WavLMModel +from transformers.modeling_outputs import Wav2Vec2BaseModelOutput +from typing import Optional, Tuple, Union +import torch.nn.functional as F + +def linear_interpolation(features, output_len: int): + features = features.transpose(1, 2) + output_features = F.interpolate( + features, size=output_len, align_corners=True, mode='linear') + return output_features.transpose(1, 2) + +# the implementation of Wav2Vec2Model is borrowed from https://huggingface.co/transformers/_modules/transformers/models/wav2vec2/modeling_wav2vec2.html#Wav2Vec2Model # noqa: E501 +# initialize our encoder with the pre-trained wav2vec 2.0 weights. + + +class WavLMModel(WavLMModel): + def __init__(self, config): + super().__init__(config) + + def _freeze_wav2vec2_parameters(self, do_freeze: bool = True): + for param in self.parameters(): + param.requires_grad = (not do_freeze) + + def forward( + self, + input_values: Optional[torch.Tensor], + attention_mask: Optional[torch.Tensor] = None, + mask_time_indices: Optional[torch.FloatTensor] = None, + output_attentions: Optional[bool] = None, + output_hidden_states: Optional[bool] = None, + return_dict: Optional[bool] = None, + frame_num=None, + interpolate_pos: int = 0, + ) -> Union[Tuple, Wav2Vec2BaseModelOutput]: + + output_attentions = output_attentions if output_attentions is not None else self.config.output_attentions + output_hidden_states = ( + output_hidden_states if output_hidden_states is not None else self.config.output_hidden_states + ) + return_dict = return_dict if return_dict is not None else self.config.use_return_dict + + extract_features = self.feature_extractor(input_values) + extract_features = extract_features.transpose(1, 2) + + if interpolate_pos == 0: + extract_features = linear_interpolation( + extract_features, output_len=frame_num) + + if attention_mask is not None: + # compute reduced attention_mask corresponding to feature vectors + attention_mask = self._get_feature_vector_attention_mask( + extract_features.shape[1], attention_mask, add_adapter=False + ) + + hidden_states, extract_features = self.feature_projection(extract_features) + hidden_states = self._mask_hidden_states( + hidden_states, mask_time_indices=mask_time_indices, attention_mask=attention_mask + ) + + encoder_outputs = self.encoder( + hidden_states, + attention_mask=attention_mask, + output_attentions=output_attentions, + output_hidden_states=output_hidden_states, + return_dict=return_dict, + ) + + hidden_states = encoder_outputs[0] + + if interpolate_pos == 1: + hidden_states = linear_interpolation( + hidden_states, output_len=frame_num) + + if self.adapter is not None: + hidden_states = self.adapter(hidden_states) + + if not return_dict: + return (hidden_states, extract_features) + encoder_outputs[1:] + + return Wav2Vec2BaseModelOutput( + last_hidden_state=hidden_states, + extract_features=extract_features, + hidden_states=encoder_outputs.hidden_states, + attentions=encoder_outputs.attentions, + ) \ No newline at end of file diff --git a/services/audio2exp-service/LAM_Audio2Expression/models/losses/__init__.py b/services/audio2exp-service/LAM_Audio2Expression/models/losses/__init__.py new file mode 100644 index 0000000..782a0d3 --- /dev/null +++ b/services/audio2exp-service/LAM_Audio2Expression/models/losses/__init__.py @@ -0,0 +1,4 @@ +from .builder import build_criteria + +from .misc import CrossEntropyLoss, SmoothCELoss, DiceLoss, FocalLoss, BinaryFocalLoss, L1Loss +from .lovasz import LovaszLoss diff --git a/services/audio2exp-service/LAM_Audio2Expression/models/losses/builder.py b/services/audio2exp-service/LAM_Audio2Expression/models/losses/builder.py new file mode 100644 index 0000000..ec936be --- /dev/null +++ b/services/audio2exp-service/LAM_Audio2Expression/models/losses/builder.py @@ -0,0 +1,28 @@ +""" +The code is base on https://github.com/Pointcept/Pointcept +""" + +from utils.registry import Registry + +LOSSES = Registry("losses") + + +class Criteria(object): + def __init__(self, cfg=None): + self.cfg = cfg if cfg is not None else [] + self.criteria = [] + for loss_cfg in self.cfg: + self.criteria.append(LOSSES.build(cfg=loss_cfg)) + + def __call__(self, pred, target): + if len(self.criteria) == 0: + # loss computation occur in model + return pred + loss = 0 + for c in self.criteria: + loss += c(pred, target) + return loss + + +def build_criteria(cfg): + return Criteria(cfg) diff --git a/services/audio2exp-service/LAM_Audio2Expression/models/losses/lovasz.py b/services/audio2exp-service/LAM_Audio2Expression/models/losses/lovasz.py new file mode 100644 index 0000000..dbdb844 --- /dev/null +++ b/services/audio2exp-service/LAM_Audio2Expression/models/losses/lovasz.py @@ -0,0 +1,253 @@ +""" +The code is base on https://github.com/Pointcept/Pointcept +""" + +from typing import Optional +from itertools import filterfalse +import torch +import torch.nn.functional as F +from torch.nn.modules.loss import _Loss + +from .builder import LOSSES + +BINARY_MODE: str = "binary" +MULTICLASS_MODE: str = "multiclass" +MULTILABEL_MODE: str = "multilabel" + + +def _lovasz_grad(gt_sorted): + """Compute gradient of the Lovasz extension w.r.t sorted errors + See Alg. 1 in paper + """ + p = len(gt_sorted) + gts = gt_sorted.sum() + intersection = gts - gt_sorted.float().cumsum(0) + union = gts + (1 - gt_sorted).float().cumsum(0) + jaccard = 1.0 - intersection / union + if p > 1: # cover 1-pixel case + jaccard[1:p] = jaccard[1:p] - jaccard[0:-1] + return jaccard + + +def _lovasz_hinge(logits, labels, per_image=True, ignore=None): + """ + Binary Lovasz hinge loss + logits: [B, H, W] Logits at each pixel (between -infinity and +infinity) + labels: [B, H, W] Tensor, binary ground truth masks (0 or 1) + per_image: compute the loss per image instead of per batch + ignore: void class id + """ + if per_image: + loss = mean( + _lovasz_hinge_flat( + *_flatten_binary_scores(log.unsqueeze(0), lab.unsqueeze(0), ignore) + ) + for log, lab in zip(logits, labels) + ) + else: + loss = _lovasz_hinge_flat(*_flatten_binary_scores(logits, labels, ignore)) + return loss + + +def _lovasz_hinge_flat(logits, labels): + """Binary Lovasz hinge loss + Args: + logits: [P] Logits at each prediction (between -infinity and +infinity) + labels: [P] Tensor, binary ground truth labels (0 or 1) + """ + if len(labels) == 0: + # only void pixels, the gradients should be 0 + return logits.sum() * 0.0 + signs = 2.0 * labels.float() - 1.0 + errors = 1.0 - logits * signs + errors_sorted, perm = torch.sort(errors, dim=0, descending=True) + perm = perm.data + gt_sorted = labels[perm] + grad = _lovasz_grad(gt_sorted) + loss = torch.dot(F.relu(errors_sorted), grad) + return loss + + +def _flatten_binary_scores(scores, labels, ignore=None): + """Flattens predictions in the batch (binary case) + Remove labels equal to 'ignore' + """ + scores = scores.view(-1) + labels = labels.view(-1) + if ignore is None: + return scores, labels + valid = labels != ignore + vscores = scores[valid] + vlabels = labels[valid] + return vscores, vlabels + + +def _lovasz_softmax( + probas, labels, classes="present", class_seen=None, per_image=False, ignore=None +): + """Multi-class Lovasz-Softmax loss + Args: + @param probas: [B, C, H, W] Class probabilities at each prediction (between 0 and 1). + Interpreted as binary (sigmoid) output with outputs of size [B, H, W]. + @param labels: [B, H, W] Tensor, ground truth labels (between 0 and C - 1) + @param classes: 'all' for all, 'present' for classes present in labels, or a list of classes to average. + @param per_image: compute the loss per image instead of per batch + @param ignore: void class labels + """ + if per_image: + loss = mean( + _lovasz_softmax_flat( + *_flatten_probas(prob.unsqueeze(0), lab.unsqueeze(0), ignore), + classes=classes + ) + for prob, lab in zip(probas, labels) + ) + else: + loss = _lovasz_softmax_flat( + *_flatten_probas(probas, labels, ignore), + classes=classes, + class_seen=class_seen + ) + return loss + + +def _lovasz_softmax_flat(probas, labels, classes="present", class_seen=None): + """Multi-class Lovasz-Softmax loss + Args: + @param probas: [P, C] Class probabilities at each prediction (between 0 and 1) + @param labels: [P] Tensor, ground truth labels (between 0 and C - 1) + @param classes: 'all' for all, 'present' for classes present in labels, or a list of classes to average. + """ + if probas.numel() == 0: + # only void pixels, the gradients should be 0 + return probas * 0.0 + C = probas.size(1) + losses = [] + class_to_sum = list(range(C)) if classes in ["all", "present"] else classes + # for c in class_to_sum: + for c in labels.unique(): + if class_seen is None: + fg = (labels == c).type_as(probas) # foreground for class c + if classes == "present" and fg.sum() == 0: + continue + if C == 1: + if len(classes) > 1: + raise ValueError("Sigmoid output possible only with 1 class") + class_pred = probas[:, 0] + else: + class_pred = probas[:, c] + errors = (fg - class_pred).abs() + errors_sorted, perm = torch.sort(errors, 0, descending=True) + perm = perm.data + fg_sorted = fg[perm] + losses.append(torch.dot(errors_sorted, _lovasz_grad(fg_sorted))) + else: + if c in class_seen: + fg = (labels == c).type_as(probas) # foreground for class c + if classes == "present" and fg.sum() == 0: + continue + if C == 1: + if len(classes) > 1: + raise ValueError("Sigmoid output possible only with 1 class") + class_pred = probas[:, 0] + else: + class_pred = probas[:, c] + errors = (fg - class_pred).abs() + errors_sorted, perm = torch.sort(errors, 0, descending=True) + perm = perm.data + fg_sorted = fg[perm] + losses.append(torch.dot(errors_sorted, _lovasz_grad(fg_sorted))) + return mean(losses) + + +def _flatten_probas(probas, labels, ignore=None): + """Flattens predictions in the batch""" + if probas.dim() == 3: + # assumes output of a sigmoid layer + B, H, W = probas.size() + probas = probas.view(B, 1, H, W) + + C = probas.size(1) + probas = torch.movedim(probas, 1, -1) # [B, C, Di, Dj, ...] -> [B, Di, Dj, ..., C] + probas = probas.contiguous().view(-1, C) # [P, C] + + labels = labels.view(-1) + if ignore is None: + return probas, labels + valid = labels != ignore + vprobas = probas[valid] + vlabels = labels[valid] + return vprobas, vlabels + + +def isnan(x): + return x != x + + +def mean(values, ignore_nan=False, empty=0): + """Nan-mean compatible with generators.""" + values = iter(values) + if ignore_nan: + values = filterfalse(isnan, values) + try: + n = 1 + acc = next(values) + except StopIteration: + if empty == "raise": + raise ValueError("Empty mean") + return empty + for n, v in enumerate(values, 2): + acc += v + if n == 1: + return acc + return acc / n + + +@LOSSES.register_module() +class LovaszLoss(_Loss): + def __init__( + self, + mode: str, + class_seen: Optional[int] = None, + per_image: bool = False, + ignore_index: Optional[int] = None, + loss_weight: float = 1.0, + ): + """Lovasz loss for segmentation task. + It supports binary, multiclass and multilabel cases + Args: + mode: Loss mode 'binary', 'multiclass' or 'multilabel' + ignore_index: Label that indicates ignored pixels (does not contribute to loss) + per_image: If True loss computed per each image and then averaged, else computed per whole batch + Shape + - **y_pred** - torch.Tensor of shape (N, C, H, W) + - **y_true** - torch.Tensor of shape (N, H, W) or (N, C, H, W) + Reference + https://github.com/BloodAxe/pytorch-toolbelt + """ + assert mode in {BINARY_MODE, MULTILABEL_MODE, MULTICLASS_MODE} + super().__init__() + + self.mode = mode + self.ignore_index = ignore_index + self.per_image = per_image + self.class_seen = class_seen + self.loss_weight = loss_weight + + def forward(self, y_pred, y_true): + if self.mode in {BINARY_MODE, MULTILABEL_MODE}: + loss = _lovasz_hinge( + y_pred, y_true, per_image=self.per_image, ignore=self.ignore_index + ) + elif self.mode == MULTICLASS_MODE: + y_pred = y_pred.softmax(dim=1) + loss = _lovasz_softmax( + y_pred, + y_true, + class_seen=self.class_seen, + per_image=self.per_image, + ignore=self.ignore_index, + ) + else: + raise ValueError("Wrong mode {}.".format(self.mode)) + return loss * self.loss_weight diff --git a/services/audio2exp-service/LAM_Audio2Expression/models/losses/misc.py b/services/audio2exp-service/LAM_Audio2Expression/models/losses/misc.py new file mode 100644 index 0000000..48e26bb --- /dev/null +++ b/services/audio2exp-service/LAM_Audio2Expression/models/losses/misc.py @@ -0,0 +1,241 @@ +""" +The code is base on https://github.com/Pointcept/Pointcept +""" + +import torch +import torch.nn as nn +import torch.nn.functional as F +from .builder import LOSSES + + +@LOSSES.register_module() +class CrossEntropyLoss(nn.Module): + def __init__( + self, + weight=None, + size_average=None, + reduce=None, + reduction="mean", + label_smoothing=0.0, + loss_weight=1.0, + ignore_index=-1, + ): + super(CrossEntropyLoss, self).__init__() + weight = torch.tensor(weight).cuda() if weight is not None else None + self.loss_weight = loss_weight + self.loss = nn.CrossEntropyLoss( + weight=weight, + size_average=size_average, + ignore_index=ignore_index, + reduce=reduce, + reduction=reduction, + label_smoothing=label_smoothing, + ) + + def forward(self, pred, target): + return self.loss(pred, target) * self.loss_weight + + +@LOSSES.register_module() +class L1Loss(nn.Module): + def __init__( + self, + weight=None, + size_average=None, + reduce=None, + reduction="mean", + label_smoothing=0.0, + loss_weight=1.0, + ignore_index=-1, + ): + super(L1Loss, self).__init__() + weight = torch.tensor(weight).cuda() if weight is not None else None + self.loss_weight = loss_weight + self.loss = nn.L1Loss(reduction='mean') + + def forward(self, pred, target): + return self.loss(pred, target[:,None]) * self.loss_weight + + +@LOSSES.register_module() +class SmoothCELoss(nn.Module): + def __init__(self, smoothing_ratio=0.1): + super(SmoothCELoss, self).__init__() + self.smoothing_ratio = smoothing_ratio + + def forward(self, pred, target): + eps = self.smoothing_ratio + n_class = pred.size(1) + one_hot = torch.zeros_like(pred).scatter(1, target.view(-1, 1), 1) + one_hot = one_hot * (1 - eps) + (1 - one_hot) * eps / (n_class - 1) + log_prb = F.log_softmax(pred, dim=1) + loss = -(one_hot * log_prb).total(dim=1) + loss = loss[torch.isfinite(loss)].mean() + return loss + + +@LOSSES.register_module() +class BinaryFocalLoss(nn.Module): + def __init__(self, gamma=2.0, alpha=0.5, logits=True, reduce=True, loss_weight=1.0): + """Binary Focal Loss + ` + """ + super(BinaryFocalLoss, self).__init__() + assert 0 < alpha < 1 + self.gamma = gamma + self.alpha = alpha + self.logits = logits + self.reduce = reduce + self.loss_weight = loss_weight + + def forward(self, pred, target, **kwargs): + """Forward function. + Args: + pred (torch.Tensor): The prediction with shape (N) + target (torch.Tensor): The ground truth. If containing class + indices, shape (N) where each value is 0≤targets[i]≤1, If containing class probabilities, + same shape as the input. + Returns: + torch.Tensor: The calculated loss + """ + if self.logits: + bce = F.binary_cross_entropy_with_logits(pred, target, reduction="none") + else: + bce = F.binary_cross_entropy(pred, target, reduction="none") + pt = torch.exp(-bce) + alpha = self.alpha * target + (1 - self.alpha) * (1 - target) + focal_loss = alpha * (1 - pt) ** self.gamma * bce + + if self.reduce: + focal_loss = torch.mean(focal_loss) + return focal_loss * self.loss_weight + + +@LOSSES.register_module() +class FocalLoss(nn.Module): + def __init__( + self, gamma=2.0, alpha=0.5, reduction="mean", loss_weight=1.0, ignore_index=-1 + ): + """Focal Loss + ` + """ + super(FocalLoss, self).__init__() + assert reduction in ( + "mean", + "sum", + ), "AssertionError: reduction should be 'mean' or 'sum'" + assert isinstance( + alpha, (float, list) + ), "AssertionError: alpha should be of type float" + assert isinstance(gamma, float), "AssertionError: gamma should be of type float" + assert isinstance( + loss_weight, float + ), "AssertionError: loss_weight should be of type float" + assert isinstance(ignore_index, int), "ignore_index must be of type int" + self.gamma = gamma + self.alpha = alpha + self.reduction = reduction + self.loss_weight = loss_weight + self.ignore_index = ignore_index + + def forward(self, pred, target, **kwargs): + """Forward function. + Args: + pred (torch.Tensor): The prediction with shape (N, C) where C = number of classes. + target (torch.Tensor): The ground truth. If containing class + indices, shape (N) where each value is 0≤targets[i]≤C−1, If containing class probabilities, + same shape as the input. + Returns: + torch.Tensor: The calculated loss + """ + # [B, C, d_1, d_2, ..., d_k] -> [C, B, d_1, d_2, ..., d_k] + pred = pred.transpose(0, 1) + # [C, B, d_1, d_2, ..., d_k] -> [C, N] + pred = pred.reshape(pred.size(0), -1) + # [C, N] -> [N, C] + pred = pred.transpose(0, 1).contiguous() + # (B, d_1, d_2, ..., d_k) --> (B * d_1 * d_2 * ... * d_k,) + target = target.view(-1).contiguous() + assert pred.size(0) == target.size( + 0 + ), "The shape of pred doesn't match the shape of target" + valid_mask = target != self.ignore_index + target = target[valid_mask] + pred = pred[valid_mask] + + if len(target) == 0: + return 0.0 + + num_classes = pred.size(1) + target = F.one_hot(target, num_classes=num_classes) + + alpha = self.alpha + if isinstance(alpha, list): + alpha = pred.new_tensor(alpha) + pred_sigmoid = pred.sigmoid() + target = target.type_as(pred) + one_minus_pt = (1 - pred_sigmoid) * target + pred_sigmoid * (1 - target) + focal_weight = (alpha * target + (1 - alpha) * (1 - target)) * one_minus_pt.pow( + self.gamma + ) + + loss = ( + F.binary_cross_entropy_with_logits(pred, target, reduction="none") + * focal_weight + ) + if self.reduction == "mean": + loss = loss.mean() + elif self.reduction == "sum": + loss = loss.total() + return self.loss_weight * loss + + +@LOSSES.register_module() +class DiceLoss(nn.Module): + def __init__(self, smooth=1, exponent=2, loss_weight=1.0, ignore_index=-1): + """DiceLoss. + This loss is proposed in `V-Net: Fully Convolutional Neural Networks for + Volumetric Medical Image Segmentation `_. + """ + super(DiceLoss, self).__init__() + self.smooth = smooth + self.exponent = exponent + self.loss_weight = loss_weight + self.ignore_index = ignore_index + + def forward(self, pred, target, **kwargs): + # [B, C, d_1, d_2, ..., d_k] -> [C, B, d_1, d_2, ..., d_k] + pred = pred.transpose(0, 1) + # [C, B, d_1, d_2, ..., d_k] -> [C, N] + pred = pred.reshape(pred.size(0), -1) + # [C, N] -> [N, C] + pred = pred.transpose(0, 1).contiguous() + # (B, d_1, d_2, ..., d_k) --> (B * d_1 * d_2 * ... * d_k,) + target = target.view(-1).contiguous() + assert pred.size(0) == target.size( + 0 + ), "The shape of pred doesn't match the shape of target" + valid_mask = target != self.ignore_index + target = target[valid_mask] + pred = pred[valid_mask] + + pred = F.softmax(pred, dim=1) + num_classes = pred.shape[1] + target = F.one_hot( + torch.clamp(target.long(), 0, num_classes - 1), num_classes=num_classes + ) + + total_loss = 0 + for i in range(num_classes): + if i != self.ignore_index: + num = torch.sum(torch.mul(pred[:, i], target[:, i])) * 2 + self.smooth + den = ( + torch.sum( + pred[:, i].pow(self.exponent) + target[:, i].pow(self.exponent) + ) + + self.smooth + ) + dice_loss = 1 - num / den + total_loss += dice_loss + loss = total_loss / num_classes + return self.loss_weight * loss diff --git a/services/audio2exp-service/LAM_Audio2Expression/models/network.py b/services/audio2exp-service/LAM_Audio2Expression/models/network.py new file mode 100644 index 0000000..60d46fd --- /dev/null +++ b/services/audio2exp-service/LAM_Audio2Expression/models/network.py @@ -0,0 +1,663 @@ +import math +import os.path + +import torch + +import torch.nn as nn +import torch.nn.functional as F +import torchaudio as ta + +from models.encoder.wav2vec import Wav2Vec2Model +from models.encoder.wavlm import WavLMModel + +from models.builder import MODELS + +from transformers.models.wav2vec2.configuration_wav2vec2 import Wav2Vec2Config + +@MODELS.register_module("Audio2Expression") +class Audio2Expression(nn.Module): + def __init__(self, + device: torch.device = None, + pretrained_encoder_type: str = 'wav2vec', + pretrained_encoder_path: str = '', + wav2vec2_config_path: str = '', + num_identity_classes: int = 0, + identity_feat_dim: int = 64, + hidden_dim: int = 512, + expression_dim: int = 52, + norm_type: str = 'ln', + decoder_depth: int = 3, + use_transformer: bool = False, + num_attention_heads: int = 8, + num_transformer_layers: int = 6, + ): + super().__init__() + + self.device = device + + # Initialize audio feature encoder + if pretrained_encoder_type == 'wav2vec': + if os.path.exists(pretrained_encoder_path): + self.audio_encoder = Wav2Vec2Model.from_pretrained( + pretrained_encoder_path, + ignore_mismatched_sizes=True, + attn_implementation="eager", + ) + else: + config = Wav2Vec2Config.from_pretrained(wav2vec2_config_path) + self.audio_encoder = Wav2Vec2Model(config) + encoder_output_dim = 768 + elif pretrained_encoder_type == 'wavlm': + self.audio_encoder = WavLMModel.from_pretrained(pretrained_encoder_path) + encoder_output_dim = 768 + else: + raise NotImplementedError(f"Encoder type {pretrained_encoder_type} not supported") + + self.audio_encoder.feature_extractor._freeze_parameters() + self.feature_projection = nn.Linear(encoder_output_dim, hidden_dim) + + self.identity_encoder = AudioIdentityEncoder( + hidden_dim, + num_identity_classes, + identity_feat_dim, + use_transformer, + num_attention_heads, + num_transformer_layers + ) + + self.decoder = nn.ModuleList([ + nn.Sequential(*[ + ConvNormRelu(hidden_dim, hidden_dim, norm=norm_type) + for _ in range(decoder_depth) + ]) + ]) + + self.output_proj = nn.Linear(hidden_dim, expression_dim) + + def freeze_encoder_parameters(self, do_freeze=False): + + for name, param in self.audio_encoder.named_parameters(): + if('feature_extractor' in name): + param.requires_grad = False + else: + param.requires_grad = (not do_freeze) + + def forward(self, input_dict): + import time as _t + import logging as _lg + _log = _lg.getLogger(__name__) + + if 'time_steps' not in input_dict: + audio_length = input_dict['input_audio_array'].shape[1] + time_steps = math.ceil(audio_length / 16000 * 30) + else: + time_steps = input_dict['time_steps'] + + # Process audio through encoder + audio_input = input_dict['input_audio_array'].flatten(start_dim=1) + _log.info(f"[A2E forward] audio_input={list(audio_input.shape)}, time_steps={time_steps}") + + _s = _t.monotonic() + hidden_states = self.audio_encoder(audio_input, frame_num=time_steps).last_hidden_state + _log.info(f"[A2E forward] audio_encoder: {_t.monotonic()-_s:.2f}s, out={list(hidden_states.shape)}") + + # Project features to hidden dimension + _s = _t.monotonic() + audio_features = self.feature_projection(hidden_states).transpose(1, 2) + _log.info(f"[A2E forward] feature_proj: {_t.monotonic()-_s:.2f}s") + + # Process identity-conditioned features + _s = _t.monotonic() + audio_features = self.identity_encoder(audio_features, identity=input_dict['id_idx']) + _log.info(f"[A2E forward] identity_enc: {_t.monotonic()-_s:.2f}s") + + # Refine features through decoder + _s = _t.monotonic() + audio_features = self.decoder[0](audio_features) + _log.info(f"[A2E forward] decoder: {_t.monotonic()-_s:.2f}s") + + # Generate output parameters + audio_features = audio_features.permute(0, 2, 1) + expression_params = self.output_proj(audio_features) + + return torch.sigmoid(expression_params) + + +class AudioIdentityEncoder(nn.Module): + def __init__(self, + hidden_dim, + num_identity_classes=0, + identity_feat_dim=64, + use_transformer=False, + num_attention_heads = 8, + num_transformer_layers = 6, + dropout_ratio=0.1, + ): + super().__init__() + + in_dim = hidden_dim + identity_feat_dim + self.id_mlp = nn.Conv1d(num_identity_classes, identity_feat_dim, 1, 1) + self.first_net = SeqTranslator1D(in_dim, hidden_dim, + min_layers_num=3, + residual=True, + norm='ln' + ) + self.grus = nn.GRU(hidden_dim, hidden_dim, 1, batch_first=True) + self.dropout = nn.Dropout(dropout_ratio) + + self.use_transformer = use_transformer + if(self.use_transformer): + encoder_layer = nn.TransformerEncoderLayer(d_model=hidden_dim, nhead=num_attention_heads, dim_feedforward= 2 * hidden_dim, batch_first=True) + self.transformer_encoder = nn.TransformerEncoder(encoder_layer, num_layers=num_transformer_layers) + + def forward(self, + audio_features: torch.Tensor, + identity: torch.Tensor = None, + time_steps: int = None) -> tuple: + + audio_features = self.dropout(audio_features) + identity = identity.reshape(identity.shape[0], -1, 1).repeat(1, 1, audio_features.shape[2]).to(torch.float32) + identity = self.id_mlp(identity) + audio_features = torch.cat([audio_features, identity], dim=1) + + x = self.first_net(audio_features) + + if time_steps is not None: + x = F.interpolate(x, size=time_steps, align_corners=False, mode='linear') + + if(self.use_transformer): + x = x.permute(0, 2, 1) + x = self.transformer_encoder(x) + x = x.permute(0, 2, 1) + + return x + +class ConvNormRelu(nn.Module): + ''' + (B,C_in,H,W) -> (B, C_out, H, W) + there exist some kernel size that makes the result is not H/s + ''' + + def __init__(self, + in_channels, + out_channels, + type='1d', + leaky=False, + downsample=False, + kernel_size=None, + stride=None, + padding=None, + p=0, + groups=1, + residual=False, + norm='bn'): + ''' + conv-bn-relu + ''' + super(ConvNormRelu, self).__init__() + self.residual = residual + self.norm_type = norm + # kernel_size = k + # stride = s + + if kernel_size is None and stride is None: + if not downsample: + kernel_size = 3 + stride = 1 + else: + kernel_size = 4 + stride = 2 + + if padding is None: + if isinstance(kernel_size, int) and isinstance(stride, tuple): + padding = tuple(int((kernel_size - st) / 2) for st in stride) + elif isinstance(kernel_size, tuple) and isinstance(stride, int): + padding = tuple(int((ks - stride) / 2) for ks in kernel_size) + elif isinstance(kernel_size, tuple) and isinstance(stride, tuple): + padding = tuple(int((ks - st) / 2) for ks, st in zip(kernel_size, stride)) + else: + padding = int((kernel_size - stride) / 2) + + if self.residual: + if downsample: + if type == '1d': + self.residual_layer = nn.Sequential( + nn.Conv1d( + in_channels=in_channels, + out_channels=out_channels, + kernel_size=kernel_size, + stride=stride, + padding=padding + ) + ) + elif type == '2d': + self.residual_layer = nn.Sequential( + nn.Conv2d( + in_channels=in_channels, + out_channels=out_channels, + kernel_size=kernel_size, + stride=stride, + padding=padding + ) + ) + else: + if in_channels == out_channels: + self.residual_layer = nn.Identity() + else: + if type == '1d': + self.residual_layer = nn.Sequential( + nn.Conv1d( + in_channels=in_channels, + out_channels=out_channels, + kernel_size=kernel_size, + stride=stride, + padding=padding + ) + ) + elif type == '2d': + self.residual_layer = nn.Sequential( + nn.Conv2d( + in_channels=in_channels, + out_channels=out_channels, + kernel_size=kernel_size, + stride=stride, + padding=padding + ) + ) + + in_channels = in_channels * groups + out_channels = out_channels * groups + if type == '1d': + self.conv = nn.Conv1d(in_channels=in_channels, out_channels=out_channels, + kernel_size=kernel_size, stride=stride, padding=padding, + groups=groups) + self.norm = nn.BatchNorm1d(out_channels) + self.dropout = nn.Dropout(p=p) + elif type == '2d': + self.conv = nn.Conv2d(in_channels=in_channels, out_channels=out_channels, + kernel_size=kernel_size, stride=stride, padding=padding, + groups=groups) + self.norm = nn.BatchNorm2d(out_channels) + self.dropout = nn.Dropout2d(p=p) + if norm == 'gn': + self.norm = nn.GroupNorm(2, out_channels) + elif norm == 'ln': + self.norm = nn.LayerNorm(out_channels) + if leaky: + self.relu = nn.LeakyReLU(negative_slope=0.2) + else: + self.relu = nn.ReLU() + + def forward(self, x, **kwargs): + if self.norm_type == 'ln': + out = self.dropout(self.conv(x)) + out = self.norm(out.transpose(1,2)).transpose(1,2) + else: + out = self.norm(self.dropout(self.conv(x))) + if self.residual: + residual = self.residual_layer(x) + out += residual + return self.relu(out) + +""" from https://github.com/ai4r/Gesture-Generation-from-Trimodal-Context.git """ +class SeqTranslator1D(nn.Module): + ''' + (B, C, T)->(B, C_out, T) + ''' + def __init__(self, + C_in, + C_out, + kernel_size=None, + stride=None, + min_layers_num=None, + residual=True, + norm='bn' + ): + super(SeqTranslator1D, self).__init__() + + conv_layers = nn.ModuleList([]) + conv_layers.append(ConvNormRelu( + in_channels=C_in, + out_channels=C_out, + type='1d', + kernel_size=kernel_size, + stride=stride, + residual=residual, + norm=norm + )) + self.num_layers = 1 + if min_layers_num is not None and self.num_layers < min_layers_num: + while self.num_layers < min_layers_num: + conv_layers.append(ConvNormRelu( + in_channels=C_out, + out_channels=C_out, + type='1d', + kernel_size=kernel_size, + stride=stride, + residual=residual, + norm=norm + )) + self.num_layers += 1 + self.conv_layers = nn.Sequential(*conv_layers) + + def forward(self, x): + return self.conv_layers(x) + + +def audio_chunking(audio: torch.Tensor, frame_rate: int = 30, chunk_size: int = 16000): + """ + :param audio: 1 x T tensor containing a 16kHz audio signal + :param frame_rate: frame rate for video (we need one audio chunk per video frame) + :param chunk_size: number of audio samples per chunk + :return: num_chunks x chunk_size tensor containing sliced audio + """ + samples_per_frame = 16000 // frame_rate + padding = (chunk_size - samples_per_frame) // 2 + audio = torch.nn.functional.pad(audio.unsqueeze(0), pad=[padding, padding]).squeeze(0) + anchor_points = list(range(chunk_size//2, audio.shape[-1]-chunk_size//2, samples_per_frame)) + audio = torch.cat([audio[:, i-chunk_size//2:i+chunk_size//2] for i in anchor_points], dim=0) + return audio + +""" https://github.com/facebookresearch/meshtalk """ +class MeshtalkEncoder(nn.Module): + def __init__(self, latent_dim: int = 128, model_name: str = 'audio_encoder'): + """ + :param latent_dim: size of the latent audio embedding + :param model_name: name of the model, used to load and save the model + """ + super().__init__() + + self.melspec = ta.transforms.MelSpectrogram( + sample_rate=16000, n_fft=2048, win_length=800, hop_length=160, n_mels=80 + ) + + conv_len = 5 + self.convert_dimensions = torch.nn.Conv1d(80, 128, kernel_size=conv_len) + self.weights_init(self.convert_dimensions) + self.receptive_field = conv_len + + convs = [] + for i in range(6): + dilation = 2 * (i % 3 + 1) + self.receptive_field += (conv_len - 1) * dilation + convs += [torch.nn.Conv1d(128, 128, kernel_size=conv_len, dilation=dilation)] + self.weights_init(convs[-1]) + self.convs = torch.nn.ModuleList(convs) + self.code = torch.nn.Linear(128, latent_dim) + + self.apply(lambda x: self.weights_init(x)) + + def weights_init(self, m): + if isinstance(m, torch.nn.Conv1d): + torch.nn.init.xavier_uniform_(m.weight) + try: + torch.nn.init.constant_(m.bias, .01) + except: + pass + + def forward(self, audio: torch.Tensor): + """ + :param audio: B x T x 16000 Tensor containing 1 sec of audio centered around the current time frame + :return: code: B x T x latent_dim Tensor containing a latent audio code/embedding + """ + B, T = audio.shape[0], audio.shape[1] + x = self.melspec(audio).squeeze(1) + x = torch.log(x.clamp(min=1e-10, max=None)) + if T == 1: + x = x.unsqueeze(1) + + # Convert to the right dimensionality + x = x.view(-1, x.shape[2], x.shape[3]) + x = F.leaky_relu(self.convert_dimensions(x), .2) + + # Process stacks + for conv in self.convs: + x_ = F.leaky_relu(conv(x), .2) + if self.training: + x_ = F.dropout(x_, .2) + l = (x.shape[2] - x_.shape[2]) // 2 + x = (x[:, :, l:-l] + x_) / 2 + + x = torch.mean(x, dim=-1) + x = x.view(B, T, x.shape[-1]) + x = self.code(x) + + return {"code": x} + +class PeriodicPositionalEncoding(nn.Module): + def __init__(self, d_model, dropout=0.1, period=15, max_seq_len=64): + super(PeriodicPositionalEncoding, self).__init__() + self.dropout = nn.Dropout(p=dropout) + pe = torch.zeros(period, d_model) + position = torch.arange(0, period, dtype=torch.float).unsqueeze(1) + div_term = torch.exp(torch.arange(0, d_model, 2).float() * (-math.log(10000.0) / d_model)) + pe[:, 0::2] = torch.sin(position * div_term) + pe[:, 1::2] = torch.cos(position * div_term) + pe = pe.unsqueeze(0) # (1, period, d_model) + repeat_num = (max_seq_len//period) + 1 + pe = pe.repeat(1, repeat_num, 1) # (1, repeat_num, period, d_model) + self.register_buffer('pe', pe) + def forward(self, x): + # print(self.pe.shape, x.shape) + x = x + self.pe[:, :x.size(1), :] + return self.dropout(x) + + +class GeneratorTransformer(nn.Module): + def __init__(self, + n_poses, + each_dim: list, + dim_list: list, + training=True, + device=None, + identity=False, + num_classes=0, + ): + super().__init__() + + self.training = training + self.device = device + self.gen_length = n_poses + + norm = 'ln' + in_dim = 256 + out_dim = 256 + + self.encoder_choice = 'faceformer' + + self.audio_encoder = Wav2Vec2Model.from_pretrained("facebook/wav2vec2-base-960h") # "vitouphy/wav2vec2-xls-r-300m-phoneme""facebook/wav2vec2-base-960h" + self.audio_encoder.feature_extractor._freeze_parameters() + self.audio_feature_map = nn.Linear(768, in_dim) + + self.audio_middle = AudioEncoder(in_dim, out_dim, False, num_classes) + + self.dim_list = dim_list + + self.decoder = nn.ModuleList() + self.final_out = nn.ModuleList() + + self.hidden_size = 768 + self.transformer_de_layer = nn.TransformerDecoderLayer( + d_model=self.hidden_size, + nhead=4, + dim_feedforward=self.hidden_size*2, + batch_first=True + ) + self.face_decoder = nn.TransformerDecoder(self.transformer_de_layer, num_layers=4) + self.feature2face = nn.Linear(256, self.hidden_size) + + self.position_embeddings = PeriodicPositionalEncoding(self.hidden_size, period=64, max_seq_len=64) + self.id_maping = nn.Linear(12,self.hidden_size) + + + self.decoder.append(self.face_decoder) + self.final_out.append(nn.Linear(self.hidden_size, 32)) + + def forward(self, in_spec, gt_poses=None, id=None, pre_state=None, time_steps=None): + if gt_poses is None: + time_steps = 64 + else: + time_steps = gt_poses.shape[1] + + # vector, hidden_state = self.audio_encoder(in_spec, pre_state, time_steps=time_steps) + if self.encoder_choice == 'meshtalk': + in_spec = audio_chunking(in_spec.squeeze(-1), frame_rate=30, chunk_size=16000) + feature = self.audio_encoder(in_spec.unsqueeze(0))["code"].transpose(1, 2) + elif self.encoder_choice == 'faceformer': + hidden_states = self.audio_encoder(in_spec.reshape(in_spec.shape[0], -1), frame_num=time_steps).last_hidden_state + feature = self.audio_feature_map(hidden_states).transpose(1, 2) + else: + feature, hidden_state = self.audio_encoder(in_spec, pre_state, time_steps=time_steps) + + feature, _ = self.audio_middle(feature, id=None) + feature = self.feature2face(feature.permute(0,2,1)) + + id = id.unsqueeze(1).repeat(1,64,1).to(torch.float32) + id_feature = self.id_maping(id) + id_feature = self.position_embeddings(id_feature) + + for i in range(self.decoder.__len__()): + mid = self.decoder[i](tgt=id_feature, memory=feature) + out = self.final_out[i](mid) + + return out, None + +def linear_interpolation(features, output_len: int): + features = features.transpose(1, 2) + output_features = F.interpolate( + features, size=output_len, align_corners=True, mode='linear') + return output_features.transpose(1, 2) + +def init_biased_mask(n_head, max_seq_len, period): + + def get_slopes(n): + + def get_slopes_power_of_2(n): + start = (2**(-2**-(math.log2(n) - 3))) + ratio = start + return [start * ratio**i for i in range(n)] + + if math.log2(n).is_integer(): + return get_slopes_power_of_2(n) + else: + closest_power_of_2 = 2**math.floor(math.log2(n)) + return get_slopes_power_of_2(closest_power_of_2) + get_slopes( + 2 * closest_power_of_2)[0::2][:n - closest_power_of_2] + + slopes = torch.Tensor(get_slopes(n_head)) + bias = torch.div( + torch.arange(start=0, end=max_seq_len, + step=period).unsqueeze(1).repeat(1, period).view(-1), + period, + rounding_mode='floor') + bias = -torch.flip(bias, dims=[0]) + alibi = torch.zeros(max_seq_len, max_seq_len) + for i in range(max_seq_len): + alibi[i, :i + 1] = bias[-(i + 1):] + alibi = slopes.unsqueeze(1).unsqueeze(1) * alibi.unsqueeze(0) + mask = (torch.triu(torch.ones(max_seq_len, + max_seq_len)) == 1).transpose(0, 1) + mask = mask.float().masked_fill(mask == 0, float('-inf')).masked_fill( + mask == 1, float(0.0)) + mask = mask.unsqueeze(0) + alibi + return mask + + +# Alignment Bias +def enc_dec_mask(device, T, S): + mask = torch.ones(T, S) + for i in range(T): + mask[i, i] = 0 + return (mask == 1).to(device=device) + + +# Periodic Positional Encoding +class PeriodicPositionalEncoding(nn.Module): + + def __init__(self, d_model, dropout=0.1, period=25, max_seq_len=3000): + super(PeriodicPositionalEncoding, self).__init__() + self.dropout = nn.Dropout(p=dropout) + pe = torch.zeros(period, d_model) + position = torch.arange(0, period, dtype=torch.float).unsqueeze(1) + div_term = torch.exp( + torch.arange(0, d_model, 2).float() * + (-math.log(10000.0) / d_model)) + pe[:, 0::2] = torch.sin(position * div_term) + pe[:, 1::2] = torch.cos(position * div_term) + pe = pe.unsqueeze(0) # (1, period, d_model) + repeat_num = (max_seq_len // period) + 1 + pe = pe.repeat(1, repeat_num, 1) + self.register_buffer('pe', pe) + + def forward(self, x): + x = x + self.pe[:, :x.size(1), :] + return self.dropout(x) + + +class BaseModel(nn.Module): + """Base class for all models.""" + + def __init__(self): + super(BaseModel, self).__init__() + # self.logger = logging.getLogger(self.__class__.__name__) + + def forward(self, *x): + """Forward pass logic. + + :return: Model output + """ + raise NotImplementedError + + def freeze_model(self, do_freeze: bool = True): + for param in self.parameters(): + param.requires_grad = (not do_freeze) + + def summary(self, logger, writer=None): + """Model summary.""" + model_parameters = filter(lambda p: p.requires_grad, self.parameters()) + params = sum([np.prod(p.size()) + for p in model_parameters]) / 1e6 # Unit is Mega + logger.info('===>Trainable parameters: %.3f M' % params) + if writer is not None: + writer.add_text('Model Summary', + 'Trainable parameters: %.3f M' % params) + + +"""https://github.com/X-niper/UniTalker""" +class UniTalkerDecoderTransformer(BaseModel): + + def __init__(self, out_dim, identity_num, period=30, interpolate_pos=1) -> None: + super().__init__() + self.learnable_style_emb = nn.Embedding(identity_num, out_dim) + self.PPE = PeriodicPositionalEncoding( + out_dim, period=period, max_seq_len=3000) + self.biased_mask = init_biased_mask( + n_head=4, max_seq_len=3000, period=period) + decoder_layer = nn.TransformerDecoderLayer( + d_model=out_dim, + nhead=4, + dim_feedforward=2 * out_dim, + batch_first=True) + self.transformer_decoder = nn.TransformerDecoder( + decoder_layer, num_layers=1) + self.interpolate_pos = interpolate_pos + + def forward(self, hidden_states: torch.Tensor, style_idx: torch.Tensor, + frame_num: int): + style_idx = torch.argmax(style_idx, dim=1) + obj_embedding = self.learnable_style_emb(style_idx) + obj_embedding = obj_embedding.unsqueeze(1).repeat(1, frame_num, 1) + style_input = self.PPE(obj_embedding) + tgt_mask = self.biased_mask.repeat(style_idx.shape[0], 1, 1)[:, :style_input.shape[1], :style_input. + shape[1]].clone().detach().to( + device=style_input.device) + memory_mask = enc_dec_mask(hidden_states.device, style_input.shape[1], + frame_num) + feat_out = self.transformer_decoder( + style_input, + hidden_states, + tgt_mask=tgt_mask, + memory_mask=memory_mask) + if self.interpolate_pos == 2: + feat_out = linear_interpolation(feat_out, output_len=frame_num) + return feat_out \ No newline at end of file diff --git a/services/audio2exp-service/LAM_Audio2Expression/models/utils.py b/services/audio2exp-service/LAM_Audio2Expression/models/utils.py new file mode 100644 index 0000000..4b15130 --- /dev/null +++ b/services/audio2exp-service/LAM_Audio2Expression/models/utils.py @@ -0,0 +1,752 @@ +import json +import time +import warnings +import numpy as np +from typing import List, Optional,Tuple +from scipy.signal import savgol_filter + + +ARKitLeftRightPair = [ + ("jawLeft", "jawRight"), + ("mouthLeft", "mouthRight"), + ("mouthSmileLeft", "mouthSmileRight"), + ("mouthFrownLeft", "mouthFrownRight"), + ("mouthDimpleLeft", "mouthDimpleRight"), + ("mouthStretchLeft", "mouthStretchRight"), + ("mouthPressLeft", "mouthPressRight"), + ("mouthLowerDownLeft", "mouthLowerDownRight"), + ("mouthUpperUpLeft", "mouthUpperUpRight"), + ("cheekSquintLeft", "cheekSquintRight"), + ("noseSneerLeft", "noseSneerRight"), + ("browDownLeft", "browDownRight"), + ("browOuterUpLeft", "browOuterUpRight"), + ("eyeBlinkLeft","eyeBlinkRight"), + ("eyeLookDownLeft","eyeLookDownRight"), + ("eyeLookInLeft", "eyeLookInRight"), + ("eyeLookOutLeft","eyeLookOutRight"), + ("eyeLookUpLeft","eyeLookUpRight"), + ("eyeSquintLeft","eyeSquintRight"), + ("eyeWideLeft","eyeWideRight") + ] + +ARKitBlendShape =[ + "browDownLeft", + "browDownRight", + "browInnerUp", + "browOuterUpLeft", + "browOuterUpRight", + "cheekPuff", + "cheekSquintLeft", + "cheekSquintRight", + "eyeBlinkLeft", + "eyeBlinkRight", + "eyeLookDownLeft", + "eyeLookDownRight", + "eyeLookInLeft", + "eyeLookInRight", + "eyeLookOutLeft", + "eyeLookOutRight", + "eyeLookUpLeft", + "eyeLookUpRight", + "eyeSquintLeft", + "eyeSquintRight", + "eyeWideLeft", + "eyeWideRight", + "jawForward", + "jawLeft", + "jawOpen", + "jawRight", + "mouthClose", + "mouthDimpleLeft", + "mouthDimpleRight", + "mouthFrownLeft", + "mouthFrownRight", + "mouthFunnel", + "mouthLeft", + "mouthLowerDownLeft", + "mouthLowerDownRight", + "mouthPressLeft", + "mouthPressRight", + "mouthPucker", + "mouthRight", + "mouthRollLower", + "mouthRollUpper", + "mouthShrugLower", + "mouthShrugUpper", + "mouthSmileLeft", + "mouthSmileRight", + "mouthStretchLeft", + "mouthStretchRight", + "mouthUpperUpLeft", + "mouthUpperUpRight", + "noseSneerLeft", + "noseSneerRight", + "tongueOut" +] + +MOUTH_BLENDSHAPES = [ "mouthDimpleLeft", + "mouthDimpleRight", + "mouthFrownLeft", + "mouthFrownRight", + "mouthFunnel", + "mouthLeft", + "mouthLowerDownLeft", + "mouthLowerDownRight", + "mouthPressLeft", + "mouthPressRight", + "mouthPucker", + "mouthRight", + "mouthRollLower", + "mouthRollUpper", + "mouthShrugLower", + "mouthShrugUpper", + "mouthSmileLeft", + "mouthSmileRight", + "mouthStretchLeft", + "mouthStretchRight", + "mouthUpperUpLeft", + "mouthUpperUpRight", + "jawForward", + "jawLeft", + "jawOpen", + "jawRight", + "noseSneerLeft", + "noseSneerRight", + "cheekPuff", + ] + +DEFAULT_CONTEXT ={ + 'is_initial_input': True, + 'previous_audio': None, + 'previous_expression': None, + 'previous_volume': None, + 'previous_headpose': None, +} + +RETURN_CODE = { + "SUCCESS": 0, + "AUDIO_LENGTH_ERROR": 1, + "CHECKPOINT_PATH_ERROR":2, + "MODEL_INFERENCE_ERROR":3, +} + +DEFAULT_CONTEXTRETURN = { + "code": RETURN_CODE['SUCCESS'], + "expression": None, + "headpose": None, +} + +BLINK_PATTERNS = [ + np.array([0.365, 0.950, 0.956, 0.917, 0.367, 0.119, 0.025]), + np.array([0.235, 0.910, 0.945, 0.778, 0.191, 0.235, 0.089]), + np.array([0.870, 0.950, 0.949, 0.696, 0.191, 0.073, 0.007]), + np.array([0.000, 0.557, 0.953, 0.942, 0.426, 0.148, 0.018]) +] + +# Postprocess +def symmetrize_blendshapes( + bs_params: np.ndarray, + mode: str = "average", + symmetric_pairs: list = ARKitLeftRightPair +) -> np.ndarray: + """ + Apply symmetrization to ARKit blendshape parameters (batched version) + + Args: + bs_params: numpy array of shape (N, 52), batch of ARKit parameters + mode: symmetrization mode ["average", "max", "min", "left_dominant", "right_dominant"] + symmetric_pairs: list of left-right parameter pairs + + Returns: + Symmetrized parameters with same shape (N, 52) + """ + + name_to_idx = {name: i for i, name in enumerate(ARKitBlendShape)} + + # Input validation + if bs_params.ndim != 2 or bs_params.shape[1] != 52: + raise ValueError("Input must be of shape (N, 52)") + + symmetric_bs = bs_params.copy() # Shape (N, 52) + + # Precompute valid index pairs + valid_pairs = [] + for left, right in symmetric_pairs: + left_idx = name_to_idx.get(left) + right_idx = name_to_idx.get(right) + if None not in (left_idx, right_idx): + valid_pairs.append((left_idx, right_idx)) + + # Vectorized processing + for l_idx, r_idx in valid_pairs: + left_col = symmetric_bs[:, l_idx] + right_col = symmetric_bs[:, r_idx] + + if mode == "average": + new_vals = (left_col + right_col) / 2 + elif mode == "max": + new_vals = np.maximum(left_col, right_col) + elif mode == "min": + new_vals = np.minimum(left_col, right_col) + elif mode == "left_dominant": + new_vals = left_col + elif mode == "right_dominant": + new_vals = right_col + else: + raise ValueError(f"Invalid mode: {mode}") + + # Update both columns simultaneously + symmetric_bs[:, l_idx] = new_vals + symmetric_bs[:, r_idx] = new_vals + + return symmetric_bs + + +def apply_random_eye_blinks( + input: np.ndarray, + blink_scale: tuple = (0.8, 1.0), + blink_interval: tuple = (60, 120), + blink_duration: int = 7 +) -> np.ndarray: + """ + Apply randomized eye blinks to blendshape parameters + + Args: + output: Input array of shape (N, 52) containing blendshape parameters + blink_scale: Tuple (min, max) for random blink intensity scaling + blink_interval: Tuple (min, max) for random blink spacing in frames + blink_duration: Number of frames for blink animation (fixed) + + Returns: + None (modifies output array in-place) + """ + # Define eye blink patterns (normalized 0-1) + + # Initialize parameters + n_frames = input.shape[0] + input[:,8:10] = np.zeros((n_frames,2)) + current_frame = 0 + + # Main blink application loop + while current_frame < n_frames - blink_duration: + # Randomize blink parameters + scale = np.random.uniform(*blink_scale) + pattern = BLINK_PATTERNS[np.random.randint(0, 4)] + + # Apply blink animation + blink_values = pattern * scale + input[current_frame:current_frame + blink_duration, 8] = blink_values + input[current_frame:current_frame + blink_duration, 9] = blink_values + + # Advance to next blink position + current_frame += blink_duration + np.random.randint(*blink_interval) + + return input + + +def apply_random_eye_blinks_context( + animation_params: np.ndarray, + processed_frames: int = 0, + intensity_range: tuple = (0.8, 1.0) +) -> np.ndarray: + """Applies random eye blink patterns to facial animation parameters. + + Args: + animation_params: Input facial animation parameters array with shape [num_frames, num_features]. + Columns 8 and 9 typically represent left/right eye blink parameters. + processed_frames: Number of already processed frames that shouldn't be modified + intensity_range: Tuple defining (min, max) scaling for blink intensity + + Returns: + Modified animation parameters array with random eye blinks added to unprocessed frames + """ + remaining_frames = animation_params.shape[0] - processed_frames + + # Only apply blinks if there's enough remaining frames (blink pattern requires 7 frames) + if remaining_frames <= 7: + return animation_params + + # Configure blink timing parameters + min_blink_interval = 40 # Minimum frames between blinks + max_blink_interval = 100 # Maximum frames between blinks + + # Find last blink in previously processed frames (column 8 > 0.5 indicates blink) + previous_blink_indices = np.where(animation_params[:processed_frames, 8] > 0.5)[0] + last_processed_blink = previous_blink_indices[-1] - 7 if previous_blink_indices.size > 0 else processed_frames + + # Calculate first new blink position + blink_interval = np.random.randint(min_blink_interval, max_blink_interval) + first_blink_start = max(0, blink_interval - last_processed_blink) + + # Apply first blink if there's enough space + if first_blink_start <= (remaining_frames - 7): + # Randomly select blink pattern and intensity + blink_pattern = BLINK_PATTERNS[np.random.randint(0, 4)] + intensity = np.random.uniform(*intensity_range) + + # Calculate blink frame range + blink_start = processed_frames + first_blink_start + blink_end = blink_start + 7 + + # Apply pattern to both eyes + animation_params[blink_start:blink_end, 8] = blink_pattern * intensity + animation_params[blink_start:blink_end, 9] = blink_pattern * intensity + + # Check space for additional blink + remaining_after_blink = animation_params.shape[0] - blink_end + if remaining_after_blink > min_blink_interval: + # Calculate second blink position + second_intensity = np.random.uniform(*intensity_range) + second_interval = np.random.randint(min_blink_interval, max_blink_interval) + + if (remaining_after_blink - 7) > second_interval: + second_pattern = BLINK_PATTERNS[np.random.randint(0, 4)] + second_blink_start = blink_end + second_interval + second_blink_end = second_blink_start + 7 + + # Apply second blink + animation_params[second_blink_start:second_blink_end, 8] = second_pattern * second_intensity + animation_params[second_blink_start:second_blink_end, 9] = second_pattern * second_intensity + + return animation_params + + +def export_blendshape_animation( + blendshape_weights: np.ndarray, + output_path: str, + blendshape_names: List[str], + fps: float, + rotation_data: Optional[np.ndarray] = None +) -> None: + """ + Export blendshape animation data to JSON format compatible with ARKit. + + Args: + blendshape_weights: 2D numpy array of shape (N, 52) containing animation frames + output_path: Full path for output JSON file (including .json extension) + blendshape_names: Ordered list of 52 ARKit-standard blendshape names + fps: Frame rate for timing calculations (frames per second) + rotation_data: Optional 3D rotation data array of shape (N, 3) + + Raises: + ValueError: If input dimensions are incompatible + IOError: If file writing fails + """ + # Validate input dimensions + if blendshape_weights.shape[1] != 52: + raise ValueError(f"Expected 52 blendshapes, got {blendshape_weights.shape[1]}") + if len(blendshape_names) != 52: + raise ValueError(f"Requires 52 blendshape names, got {len(blendshape_names)}") + if rotation_data is not None and len(rotation_data) != len(blendshape_weights): + raise ValueError("Rotation data length must match animation frames") + + # Build animation data structure + animation_data = { + "names":blendshape_names, + "metadata": { + "fps": fps, + "frame_count": len(blendshape_weights), + "blendshape_names": blendshape_names + }, + "frames": [] + } + + # Convert numpy array to serializable format + for frame_idx in range(blendshape_weights.shape[0]): + frame_data = { + "weights": blendshape_weights[frame_idx].tolist(), + "time": frame_idx / fps, + "rotation": rotation_data[frame_idx].tolist() if rotation_data else [] + } + animation_data["frames"].append(frame_data) + + # Safeguard against data loss + if not output_path.endswith('.json'): + output_path += '.json' + + # Write to file with error handling + try: + with open(output_path, 'w', encoding='utf-8') as json_file: + json.dump(animation_data, json_file, indent=2, ensure_ascii=False) + except Exception as e: + raise IOError(f"Failed to write animation data: {str(e)}") from e + + +def apply_savitzky_golay_smoothing( + input_data: np.ndarray, + window_length: int = 5, + polyorder: int = 2, + axis: int = 0, + validate: bool = True +) -> Tuple[np.ndarray, Optional[float]]: + """ + Apply Savitzky-Golay filter smoothing along specified axis of input data. + + Args: + input_data: 2D numpy array of shape (n_samples, n_features) + window_length: Length of the filter window (must be odd and > polyorder) + polyorder: Order of the polynomial fit + axis: Axis along which to filter (0: column-wise, 1: row-wise) + validate: Enable input validation checks when True + + Returns: + tuple: (smoothed_data, processing_time) + - smoothed_data: Smoothed output array + - processing_time: Execution time in seconds (None in validation mode) + + Raises: + ValueError: For invalid input dimensions or filter parameters + """ + # Validation mode timing bypass + processing_time = None + + if validate: + # Input integrity checks + if input_data.ndim != 2: + raise ValueError(f"Expected 2D input, got {input_data.ndim}D array") + + if window_length % 2 == 0 or window_length < 3: + raise ValueError("Window length must be odd integer ≥ 3") + + if polyorder >= window_length: + raise ValueError("Polynomial order must be < window length") + + # Store original dtype and convert to float64 for numerical stability + original_dtype = input_data.dtype + working_data = input_data.astype(np.float64) + + # Start performance timer + timer_start = time.perf_counter() + + try: + # Vectorized Savitzky-Golay application + smoothed_data = savgol_filter(working_data, + window_length=window_length, + polyorder=polyorder, + axis=axis, + mode='mirror') + except Exception as e: + raise RuntimeError(f"Filtering failed: {str(e)}") from e + + # Stop timer and calculate duration + processing_time = time.perf_counter() - timer_start + + # Restore original data type with overflow protection + return ( + np.clip(smoothed_data, + 0.0, + 1.0 + ).astype(original_dtype), + processing_time + ) + + +def _blend_region_start( + array: np.ndarray, + region: np.ndarray, + processed_boundary: int, + blend_frames: int +) -> None: + """Applies linear blend between last active frame and silent region start.""" + blend_length = min(blend_frames, region[0] - processed_boundary) + if blend_length <= 0: + return + + pre_frame = array[region[0] - 1] + for i in range(blend_length): + weight = (i + 1) / (blend_length + 1) + array[region[0] + i] = pre_frame * (1 - weight) + array[region[0] + i] * weight + +def _blend_region_end( + array: np.ndarray, + region: np.ndarray, + blend_frames: int +) -> None: + """Applies linear blend between silent region end and next active frame.""" + blend_length = min(blend_frames, array.shape[0] - region[-1] - 1) + if blend_length <= 0: + return + + post_frame = array[region[-1] + 1] + for i in range(blend_length): + weight = (i + 1) / (blend_length + 1) + array[region[-1] - i] = post_frame * (1 - weight) + array[region[-1] - i] * weight + +def find_low_value_regions( + signal: np.ndarray, + threshold: float, + min_region_length: int = 5 +) -> list: + """Identifies contiguous regions in a signal where values fall below a threshold. + + Args: + signal: Input 1D array of numerical values + threshold: Value threshold for identifying low regions + min_region_length: Minimum consecutive samples required to qualify as a region + + Returns: + List of numpy arrays, each containing indices for a qualifying low-value region + """ + low_value_indices = np.where(signal < threshold)[0] + contiguous_regions = [] + current_region_length = 0 + region_start_idx = 0 + + for i in range(1, len(low_value_indices)): + # Check if current index continues a consecutive sequence + if low_value_indices[i] != low_value_indices[i - 1] + 1: + # Finalize previous region if it meets length requirement + if current_region_length >= min_region_length: + contiguous_regions.append(low_value_indices[region_start_idx:i]) + # Reset tracking for new potential region + region_start_idx = i + current_region_length = 0 + current_region_length += 1 + + # Add the final region if it qualifies + if current_region_length >= min_region_length: + contiguous_regions.append(low_value_indices[region_start_idx:]) + + return contiguous_regions + + +def smooth_mouth_movements( + blend_shapes: np.ndarray, + processed_frames: int, + volume: np.ndarray = None, + silence_threshold: float = 0.001, + min_silence_duration: int = 7, + blend_window: int = 3 +) -> np.ndarray: + """Reduces jaw movement artifacts during silent periods in audio-driven animation. + + Args: + blend_shapes: Array of facial blend shape weights [num_frames, num_blendshapes] + processed_frames: Number of already processed frames that shouldn't be modified + volume: Audio volume array used to detect silent periods + silence_threshold: Volume threshold for considering a frame silent + min_silence_duration: Minimum consecutive silent frames to qualify for processing + blend_window: Number of frames to smooth at region boundaries + + Returns: + Modified blend shape array with reduced mouth movements during silence + """ + if volume is None: + return blend_shapes + + # Detect silence periods using volume data + silent_regions = find_low_value_regions( + volume, + threshold=silence_threshold, + min_region_length=min_silence_duration + ) + + for region_indices in silent_regions: + # Reduce mouth blend shapes in silent region + mouth_blend_indices = [ARKitBlendShape.index(name) for name in MOUTH_BLENDSHAPES] + for region_indice in region_indices.tolist(): + blend_shapes[region_indice, mouth_blend_indices] *= 0.1 + + try: + # Smooth transition into silent region + _blend_region_start( + blend_shapes, + region_indices, + processed_frames, + blend_window + ) + + # Smooth transition out of silent region + _blend_region_end( + blend_shapes, + region_indices, + blend_window + ) + except IndexError as e: + warnings.warn(f"Edge blending skipped at region {region_indices}: {str(e)}") + + return blend_shapes + + +def apply_frame_blending( + blend_shapes: np.ndarray, + processed_frames: int, + initial_blend_window: int = 3, + subsequent_blend_window: int = 5 +) -> np.ndarray: + """Smooths transitions between processed and unprocessed animation frames using linear blending. + + Args: + blend_shapes: Array of facial blend shape weights [num_frames, num_blendshapes] + processed_frames: Number of already processed frames (0 means no previous processing) + initial_blend_window: Max frames to blend at sequence start + subsequent_blend_window: Max frames to blend between processed and new frames + + Returns: + Modified blend shape array with smoothed transitions + """ + if processed_frames > 0: + # Blend transition between existing and new animation + _blend_animation_segment( + blend_shapes, + transition_start=processed_frames, + blend_window=subsequent_blend_window, + reference_frame=blend_shapes[processed_frames - 1] + ) + else: + # Smooth initial frames from neutral expression (zeros) + _blend_animation_segment( + blend_shapes, + transition_start=0, + blend_window=initial_blend_window, + reference_frame=np.zeros_like(blend_shapes[0]) + ) + return blend_shapes + + +def _blend_animation_segment( + array: np.ndarray, + transition_start: int, + blend_window: int, + reference_frame: np.ndarray +) -> None: + """Applies linear interpolation between reference frame and target frames. + + Args: + array: Blend shape array to modify + transition_start: Starting index for blending + blend_window: Maximum number of frames to blend + reference_frame: The reference frame to blend from + """ + actual_blend_length = min(blend_window, array.shape[0] - transition_start) + + for frame_offset in range(actual_blend_length): + current_idx = transition_start + frame_offset + blend_weight = (frame_offset + 1) / (actual_blend_length + 1) + + # Linear interpolation: ref_frame * (1 - weight) + current_frame * weight + array[current_idx] = (reference_frame * (1 - blend_weight) + + array[current_idx] * blend_weight) + + +BROW1 = np.array([[0.05597309, 0.05727929, 0.07995935, 0. , 0. ], + [0.00757574, 0.00936678, 0.12242376, 0. , 0. ], + [0. , 0. , 0.14943372, 0.04535687, 0.04264118], + [0. , 0. , 0.18015374, 0.09019445, 0.08736137], + [0. , 0. , 0.20549579, 0.12802747, 0.12450772], + [0. , 0. , 0.21098022, 0.1369939 , 0.13343132], + [0. , 0. , 0.20904602, 0.13903855, 0.13562402], + [0. , 0. , 0.20365039, 0.13977394, 0.13653506], + [0. , 0. , 0.19714841, 0.14096624, 0.13805152], + [0. , 0. , 0.20325482, 0.17303431, 0.17028868], + [0. , 0. , 0.21990852, 0.20164253, 0.19818163], + [0. , 0. , 0.23858181, 0.21908803, 0.21540019], + [0. , 0. , 0.2567876 , 0.23762083, 0.23396946], + [0. , 0. , 0.34093422, 0.27898848, 0.27651772], + [0. , 0. , 0.45288125, 0.35008961, 0.34887788], + [0. , 0. , 0.48076251, 0.36878952, 0.36778417], + [0. , 0. , 0.47798249, 0.36362219, 0.36145973], + [0. , 0. , 0.46186113, 0.33865979, 0.33597934], + [0. , 0. , 0.45264384, 0.33152157, 0.32891783], + [0. , 0. , 0.40986338, 0.29646468, 0.2945672 ], + [0. , 0. , 0.35628179, 0.23356403, 0.23155804], + [0. , 0. , 0.30870566, 0.1780673 , 0.17637439], + [0. , 0. , 0.25293985, 0.10710219, 0.10622486], + [0. , 0. , 0.18743332, 0.03252602, 0.03244236], + [0.02340254, 0.02364671, 0.15736724, 0. , 0. ]]) + +BROW2 = np.array([ + [0. , 0. , 0.09799323, 0.05944436, 0.05002545], + [0. , 0. , 0.09780276, 0.07674237, 0.01636653], + [0. , 0. , 0.11136199, 0.1027964 , 0.04249811], + [0. , 0. , 0.26883412, 0.15861984, 0.15832305], + [0. , 0. , 0.42191629, 0.27038204, 0.27007768], + [0. , 0. , 0.3404977 , 0.21633868, 0.21597538], + [0. , 0. , 0.27301185, 0.17176409, 0.17134669], + [0. , 0. , 0.25960442, 0.15670464, 0.15622253], + [0. , 0. , 0.22877269, 0.11805892, 0.11754539], + [0. , 0. , 0.1451605 , 0.06389034, 0.0636282 ]]) + +BROW3 = np.array([ + [0. , 0. , 0.124 , 0.0295, 0.0295], + [0. , 0. , 0.267 , 0.184 , 0.184 ], + [0. , 0. , 0.359 , 0.2765, 0.2765], + [0. , 0. , 0.3945, 0.3125, 0.3125], + [0. , 0. , 0.4125, 0.331 , 0.331 ], + [0. , 0. , 0.4235, 0.3445, 0.3445], + [0. , 0. , 0.4085, 0.3305, 0.3305], + [0. , 0. , 0.3695, 0.294 , 0.294 ], + [0. , 0. , 0.2835, 0.213 , 0.213 ], + [0. , 0. , 0.1795, 0.1005, 0.1005], + [0. , 0. , 0.108 , 0.014 , 0.014 ]]) + + +import numpy as np +from scipy.ndimage import label + + +def apply_random_brow_movement(input_exp, volume): + FRAME_SEGMENT = 150 + HOLD_THRESHOLD = 10 + VOLUME_THRESHOLD = 0.08 + MIN_REGION_LENGTH = 6 + STRENGTH_RANGE = (0.7, 1.3) + + BROW_PEAKS = { + 0: np.argmax(BROW1[:, 2]), + 1: np.argmax(BROW2[:, 2]) + } + + for seg_start in range(0, len(volume), FRAME_SEGMENT): + seg_end = min(seg_start + FRAME_SEGMENT, len(volume)) + seg_volume = volume[seg_start:seg_end] + + candidate_regions = [] + + high_vol_mask = seg_volume > VOLUME_THRESHOLD + labeled_array, num_features = label(high_vol_mask) + + for i in range(1, num_features + 1): + region = (labeled_array == i) + region_indices = np.where(region)[0] + if len(region_indices) >= MIN_REGION_LENGTH: + candidate_regions.append(region_indices) + + if candidate_regions: + selected_region = candidate_regions[np.random.choice(len(candidate_regions))] + region_start = selected_region[0] + region_end = selected_region[-1] + region_length = region_end - region_start + 1 + + brow_idx = np.random.randint(0, 2) + base_brow = BROW1 if brow_idx == 0 else BROW2 + peak_idx = BROW_PEAKS[brow_idx] + + if region_length > HOLD_THRESHOLD: + local_max_pos = seg_volume[selected_region].argmax() + global_peak_frame = seg_start + selected_region[local_max_pos] + + rise_anim = base_brow[:peak_idx + 1] + hold_frame = base_brow[peak_idx:peak_idx + 1] + + insert_start = max(global_peak_frame - peak_idx, seg_start) + insert_end = min(global_peak_frame + (region_length - local_max_pos), seg_end) + + strength = np.random.uniform(*STRENGTH_RANGE) + + if insert_start + len(rise_anim) <= seg_end: + input_exp[insert_start:insert_start + len(rise_anim), :5] += rise_anim * strength + hold_duration = insert_end - (insert_start + len(rise_anim)) + if hold_duration > 0: + input_exp[insert_start + len(rise_anim):insert_end, :5] += np.tile(hold_frame * strength, + (hold_duration, 1)) + else: + anim_length = base_brow.shape[0] + insert_pos = seg_start + region_start + (region_length - anim_length) // 2 + insert_pos = max(seg_start, min(insert_pos, seg_end - anim_length)) + + if insert_pos + anim_length <= seg_end: + strength = np.random.uniform(*STRENGTH_RANGE) + input_exp[insert_pos:insert_pos + anim_length, :5] += base_brow * strength + + return np.clip(input_exp, 0, 1) \ No newline at end of file diff --git a/services/audio2exp-service/LAM_Audio2Expression/requirements.txt b/services/audio2exp-service/LAM_Audio2Expression/requirements.txt new file mode 100644 index 0000000..5e29d79 --- /dev/null +++ b/services/audio2exp-service/LAM_Audio2Expression/requirements.txt @@ -0,0 +1,11 @@ +#spleeter==2.4.0 +opencv_python_headless==4.11.0.86 +gradio==5.25.2 +omegaconf==2.3.0 +addict==2.4.0 +yapf==0.40.1 +librosa==0.11.0 +transformers==4.36.2 +termcolor==3.0.1 +numpy==1.26.3 +patool \ No newline at end of file diff --git a/services/audio2exp-service/LAM_Audio2Expression/scripts/install/install_cu118.sh b/services/audio2exp-service/LAM_Audio2Expression/scripts/install/install_cu118.sh new file mode 100644 index 0000000..c3cbc44 --- /dev/null +++ b/services/audio2exp-service/LAM_Audio2Expression/scripts/install/install_cu118.sh @@ -0,0 +1,9 @@ +# install torch 2.1.2 +# or conda install pytorch==2.1.2 torchvision==0.16.2 torchaudio==2.1.2 pytorch-cuda=11.8 -c pytorch -c nvidia +pip install torch==2.1.2 torchvision==0.16.2 torchaudio==2.1.2 --index-url https://download.pytorch.org/whl/cu118 + +# install dependencies +pip install -r requirements.txt + +# install H5-render +pip install wheels/gradio_gaussian_render-0.0.3-py3-none-any.whl \ No newline at end of file diff --git a/services/audio2exp-service/LAM_Audio2Expression/scripts/install/install_cu121.sh b/services/audio2exp-service/LAM_Audio2Expression/scripts/install/install_cu121.sh new file mode 100644 index 0000000..66a0f2c --- /dev/null +++ b/services/audio2exp-service/LAM_Audio2Expression/scripts/install/install_cu121.sh @@ -0,0 +1,9 @@ +# install torch 2.1.2 +# or conda install pytorch==2.1.2 torchvision==0.16.2 torchaudio==2.1.2 pytorch-cuda=12.1 -c pytorch -c nvidia +pip install torch==2.1.2 torchvision==0.16.2 torchaudio==2.1.2 --index-url https://download.pytorch.org/whl/cu121 + +# install dependencies +pip install -r requirements.txt + +# install H5-render +pip install wheels/gradio_gaussian_render-0.0.3-py3-none-any.whl \ No newline at end of file diff --git a/services/audio2exp-service/LAM_Audio2Expression/utils/__init__.py b/services/audio2exp-service/LAM_Audio2Expression/utils/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/services/audio2exp-service/LAM_Audio2Expression/utils/cache.py b/services/audio2exp-service/LAM_Audio2Expression/utils/cache.py new file mode 100644 index 0000000..ac8bc33 --- /dev/null +++ b/services/audio2exp-service/LAM_Audio2Expression/utils/cache.py @@ -0,0 +1,53 @@ +""" +The code is base on https://github.com/Pointcept/Pointcept +""" + +import os +import SharedArray + +try: + from multiprocessing.shared_memory import ShareableList +except ImportError: + import warnings + + warnings.warn("Please update python version >= 3.8 to enable shared_memory") +import numpy as np + + +def shared_array(name, var=None): + if var is not None: + # check exist + if os.path.exists(f"/dev/shm/{name}"): + return SharedArray.attach(f"shm://{name}") + # create shared_array + data = SharedArray.create(f"shm://{name}", var.shape, dtype=var.dtype) + data[...] = var[...] + data.flags.writeable = False + else: + data = SharedArray.attach(f"shm://{name}").copy() + return data + + +def shared_dict(name, var=None): + name = str(name) + assert "." not in name # '.' is used as sep flag + data = {} + if var is not None: + assert isinstance(var, dict) + keys = var.keys() + # current version only cache np.array + keys_valid = [] + for key in keys: + if isinstance(var[key], np.ndarray): + keys_valid.append(key) + keys = keys_valid + + ShareableList(sequence=keys, name=name + ".keys") + for key in keys: + if isinstance(var[key], np.ndarray): + data[key] = shared_array(name=f"{name}.{key}", var=var[key]) + else: + keys = list(ShareableList(name=name + ".keys")) + for key in keys: + data[key] = shared_array(name=f"{name}.{key}") + return data diff --git a/services/audio2exp-service/LAM_Audio2Expression/utils/comm.py b/services/audio2exp-service/LAM_Audio2Expression/utils/comm.py new file mode 100644 index 0000000..23bec8e --- /dev/null +++ b/services/audio2exp-service/LAM_Audio2Expression/utils/comm.py @@ -0,0 +1,192 @@ +""" +The code is base on https://github.com/Pointcept/Pointcept +""" + +import functools +import numpy as np +import torch +import torch.distributed as dist + +_LOCAL_PROCESS_GROUP = None +""" +A torch process group which only includes processes that on the same machine as the current process. +This variable is set when processes are spawned by `launch()` in "engine/launch.py". +""" + + +def get_world_size() -> int: + if not dist.is_available(): + return 1 + if not dist.is_initialized(): + return 1 + return dist.get_world_size() + + +def get_rank() -> int: + if not dist.is_available(): + return 0 + if not dist.is_initialized(): + return 0 + return dist.get_rank() + + +def get_local_rank() -> int: + """ + Returns: + The rank of the current process within the local (per-machine) process group. + """ + if not dist.is_available(): + return 0 + if not dist.is_initialized(): + return 0 + assert ( + _LOCAL_PROCESS_GROUP is not None + ), "Local process group is not created! Please use launch() to spawn processes!" + return dist.get_rank(group=_LOCAL_PROCESS_GROUP) + + +def get_local_size() -> int: + """ + Returns: + The size of the per-machine process group, + i.e. the number of processes per machine. + """ + if not dist.is_available(): + return 1 + if not dist.is_initialized(): + return 1 + return dist.get_world_size(group=_LOCAL_PROCESS_GROUP) + + +def is_main_process() -> bool: + return get_rank() == 0 + + +def synchronize(): + """ + Helper function to synchronize (barrier) among all processes when + using distributed training + """ + if not dist.is_available(): + return + if not dist.is_initialized(): + return + world_size = dist.get_world_size() + if world_size == 1: + return + if dist.get_backend() == dist.Backend.NCCL: + # This argument is needed to avoid warnings. + # It's valid only for NCCL backend. + dist.barrier(device_ids=[torch.cuda.current_device()]) + else: + dist.barrier() + + +@functools.lru_cache() +def _get_global_gloo_group(): + """ + Return a process group based on gloo backend, containing all the ranks + The result is cached. + """ + if dist.get_backend() == "nccl": + return dist.new_group(backend="gloo") + else: + return dist.group.WORLD + + +def all_gather(data, group=None): + """ + Run all_gather on arbitrary picklable data (not necessarily tensors). + Args: + data: any picklable object + group: a torch process group. By default, will use a group which + contains all ranks on gloo backend. + Returns: + list[data]: list of data gathered from each rank + """ + if get_world_size() == 1: + return [data] + if group is None: + group = ( + _get_global_gloo_group() + ) # use CPU group by default, to reduce GPU RAM usage. + world_size = dist.get_world_size(group) + if world_size == 1: + return [data] + + output = [None for _ in range(world_size)] + dist.all_gather_object(output, data, group=group) + return output + + +def gather(data, dst=0, group=None): + """ + Run gather on arbitrary picklable data (not necessarily tensors). + Args: + data: any picklable object + dst (int): destination rank + group: a torch process group. By default, will use a group which + contains all ranks on gloo backend. + Returns: + list[data]: on dst, a list of data gathered from each rank. Otherwise, + an empty list. + """ + if get_world_size() == 1: + return [data] + if group is None: + group = _get_global_gloo_group() + world_size = dist.get_world_size(group=group) + if world_size == 1: + return [data] + rank = dist.get_rank(group=group) + + if rank == dst: + output = [None for _ in range(world_size)] + dist.gather_object(data, output, dst=dst, group=group) + return output + else: + dist.gather_object(data, None, dst=dst, group=group) + return [] + + +def shared_random_seed(): + """ + Returns: + int: a random number that is the same across all workers. + If workers need a shared RNG, they can use this shared seed to + create one. + All workers must call this function, otherwise it will deadlock. + """ + ints = np.random.randint(2**31) + all_ints = all_gather(ints) + return all_ints[0] + + +def reduce_dict(input_dict, average=True): + """ + Reduce the values in the dictionary from all processes so that process with rank + 0 has the reduced results. + Args: + input_dict (dict): inputs to be reduced. All the values must be scalar CUDA Tensor. + average (bool): whether to do average or sum + Returns: + a dict with the same keys as input_dict, after reduction. + """ + world_size = get_world_size() + if world_size < 2: + return input_dict + with torch.no_grad(): + names = [] + values = [] + # sort the keys so that they are consistent across processes + for k in sorted(input_dict.keys()): + names.append(k) + values.append(input_dict[k]) + values = torch.stack(values, dim=0) + dist.reduce(values, dst=0) + if dist.get_rank() == 0 and average: + # only main process gets accumulated, so only divide by + # world_size in this case + values /= world_size + reduced_dict = {k: v for k, v in zip(names, values)} + return reduced_dict diff --git a/services/audio2exp-service/LAM_Audio2Expression/utils/config.py b/services/audio2exp-service/LAM_Audio2Expression/utils/config.py new file mode 100644 index 0000000..3782825 --- /dev/null +++ b/services/audio2exp-service/LAM_Audio2Expression/utils/config.py @@ -0,0 +1,696 @@ +""" +The code is base on https://github.com/Pointcept/Pointcept +""" +import ast +import copy +import os +import os.path as osp +import platform +import shutil +import sys +import tempfile +import uuid +import warnings +from argparse import Action, ArgumentParser +from collections import abc +from importlib import import_module + +from addict import Dict +from yapf.yapflib.yapf_api import FormatCode + +from .misc import import_modules_from_strings +from .path import check_file_exist + +if platform.system() == "Windows": + import regex as re +else: + import re + +BASE_KEY = "_base_" +DELETE_KEY = "_delete_" +DEPRECATION_KEY = "_deprecation_" +RESERVED_KEYS = ["filename", "text", "pretty_text"] + + +class ConfigDict(Dict): + def __missing__(self, name): + raise KeyError(name) + + def __getattr__(self, name): + try: + value = super(ConfigDict, self).__getattr__(name) + except KeyError: + ex = AttributeError( + f"'{self.__class__.__name__}' object has no " f"attribute '{name}'" + ) + except Exception as e: + ex = e + else: + return value + raise ex + + +def add_args(parser, cfg, prefix=""): + for k, v in cfg.items(): + if isinstance(v, str): + parser.add_argument("--" + prefix + k) + elif isinstance(v, int): + parser.add_argument("--" + prefix + k, type=int) + elif isinstance(v, float): + parser.add_argument("--" + prefix + k, type=float) + elif isinstance(v, bool): + parser.add_argument("--" + prefix + k, action="store_true") + elif isinstance(v, dict): + add_args(parser, v, prefix + k + ".") + elif isinstance(v, abc.Iterable): + parser.add_argument("--" + prefix + k, type=type(v[0]), nargs="+") + else: + print(f"cannot parse key {prefix + k} of type {type(v)}") + return parser + + +class Config: + """A facility for config and config files. + + It supports common file formats as configs: python/json/yaml. The interface + is the same as a dict object and also allows access config values as + attributes. + + Example: + >>> cfg = Config(dict(a=1, b=dict(b1=[0, 1]))) + >>> cfg.a + 1 + >>> cfg.b + {'b1': [0, 1]} + >>> cfg.b.b1 + [0, 1] + >>> cfg = Config.fromfile('tests/data/config/a.py') + >>> cfg.filename + "/home/kchen/projects/mmcv/tests/data/config/a.py" + >>> cfg.item4 + 'test' + >>> cfg + "Config [path: /home/kchen/projects/mmcv/tests/data/config/a.py]: " + "{'item1': [1, 2], 'item2': {'a': 0}, 'item3': True, 'item4': 'test'}" + """ + + @staticmethod + def _validate_py_syntax(filename): + with open(filename, "r", encoding="utf-8") as f: + # Setting encoding explicitly to resolve coding issue on windows + content = f.read() + try: + ast.parse(content) + except SyntaxError as e: + raise SyntaxError( + "There are syntax errors in config " f"file {filename}: {e}" + ) + + @staticmethod + def _substitute_predefined_vars(filename, temp_config_name): + file_dirname = osp.dirname(filename) + file_basename = osp.basename(filename) + file_basename_no_extension = osp.splitext(file_basename)[0] + file_extname = osp.splitext(filename)[1] + support_templates = dict( + fileDirname=file_dirname, + fileBasename=file_basename, + fileBasenameNoExtension=file_basename_no_extension, + fileExtname=file_extname, + ) + with open(filename, "r", encoding="utf-8") as f: + # Setting encoding explicitly to resolve coding issue on windows + config_file = f.read() + for key, value in support_templates.items(): + regexp = r"\{\{\s*" + str(key) + r"\s*\}\}" + value = value.replace("\\", "/") + config_file = re.sub(regexp, value, config_file) + with open(temp_config_name, "w", encoding="utf-8") as tmp_config_file: + tmp_config_file.write(config_file) + + @staticmethod + def _pre_substitute_base_vars(filename, temp_config_name): + """Substitute base variable placehoders to string, so that parsing + would work.""" + with open(filename, "r", encoding="utf-8") as f: + # Setting encoding explicitly to resolve coding issue on windows + config_file = f.read() + base_var_dict = {} + regexp = r"\{\{\s*" + BASE_KEY + r"\.([\w\.]+)\s*\}\}" + base_vars = set(re.findall(regexp, config_file)) + for base_var in base_vars: + randstr = f"_{base_var}_{uuid.uuid4().hex.lower()[:6]}" + base_var_dict[randstr] = base_var + regexp = r"\{\{\s*" + BASE_KEY + r"\." + base_var + r"\s*\}\}" + config_file = re.sub(regexp, f'"{randstr}"', config_file) + with open(temp_config_name, "w", encoding="utf-8") as tmp_config_file: + tmp_config_file.write(config_file) + return base_var_dict + + @staticmethod + def _substitute_base_vars(cfg, base_var_dict, base_cfg): + """Substitute variable strings to their actual values.""" + cfg = copy.deepcopy(cfg) + + if isinstance(cfg, dict): + for k, v in cfg.items(): + if isinstance(v, str) and v in base_var_dict: + new_v = base_cfg + for new_k in base_var_dict[v].split("."): + new_v = new_v[new_k] + cfg[k] = new_v + elif isinstance(v, (list, tuple, dict)): + cfg[k] = Config._substitute_base_vars(v, base_var_dict, base_cfg) + elif isinstance(cfg, tuple): + cfg = tuple( + Config._substitute_base_vars(c, base_var_dict, base_cfg) for c in cfg + ) + elif isinstance(cfg, list): + cfg = [ + Config._substitute_base_vars(c, base_var_dict, base_cfg) for c in cfg + ] + elif isinstance(cfg, str) and cfg in base_var_dict: + new_v = base_cfg + for new_k in base_var_dict[cfg].split("."): + new_v = new_v[new_k] + cfg = new_v + + return cfg + + @staticmethod + def _file2dict(filename, use_predefined_variables=True): + filename = osp.abspath(osp.expanduser(filename)) + check_file_exist(filename) + fileExtname = osp.splitext(filename)[1] + if fileExtname not in [".py", ".json", ".yaml", ".yml"]: + raise IOError("Only py/yml/yaml/json type are supported now!") + + with tempfile.TemporaryDirectory() as temp_config_dir: + temp_config_file = tempfile.NamedTemporaryFile( + dir=temp_config_dir, suffix=fileExtname + ) + if platform.system() == "Windows": + temp_config_file.close() + temp_config_name = osp.basename(temp_config_file.name) + # Substitute predefined variables + if use_predefined_variables: + Config._substitute_predefined_vars(filename, temp_config_file.name) + else: + shutil.copyfile(filename, temp_config_file.name) + # Substitute base variables from placeholders to strings + base_var_dict = Config._pre_substitute_base_vars( + temp_config_file.name, temp_config_file.name + ) + + if filename.endswith(".py"): + temp_module_name = osp.splitext(temp_config_name)[0] + sys.path.insert(0, temp_config_dir) + Config._validate_py_syntax(filename) + mod = import_module(temp_module_name) + sys.path.pop(0) + cfg_dict = { + name: value + for name, value in mod.__dict__.items() + if not name.startswith("__") + } + # delete imported module + del sys.modules[temp_module_name] + elif filename.endswith((".yml", ".yaml", ".json")): + raise NotImplementedError + # close temp file + temp_config_file.close() + + # check deprecation information + if DEPRECATION_KEY in cfg_dict: + deprecation_info = cfg_dict.pop(DEPRECATION_KEY) + warning_msg = ( + f"The config file {filename} will be deprecated " "in the future." + ) + if "expected" in deprecation_info: + warning_msg += f' Please use {deprecation_info["expected"]} ' "instead." + if "reference" in deprecation_info: + warning_msg += ( + " More information can be found at " + f'{deprecation_info["reference"]}' + ) + warnings.warn(warning_msg) + + cfg_text = filename + "\n" + with open(filename, "r", encoding="utf-8") as f: + # Setting encoding explicitly to resolve coding issue on windows + cfg_text += f.read() + + if BASE_KEY in cfg_dict: + cfg_dir = osp.dirname(filename) + base_filename = cfg_dict.pop(BASE_KEY) + base_filename = ( + base_filename if isinstance(base_filename, list) else [base_filename] + ) + + cfg_dict_list = list() + cfg_text_list = list() + for f in base_filename: + _cfg_dict, _cfg_text = Config._file2dict(osp.join(cfg_dir, f)) + cfg_dict_list.append(_cfg_dict) + cfg_text_list.append(_cfg_text) + + base_cfg_dict = dict() + for c in cfg_dict_list: + duplicate_keys = base_cfg_dict.keys() & c.keys() + if len(duplicate_keys) > 0: + raise KeyError( + "Duplicate key is not allowed among bases. " + f"Duplicate keys: {duplicate_keys}" + ) + base_cfg_dict.update(c) + + # Substitute base variables from strings to their actual values + cfg_dict = Config._substitute_base_vars( + cfg_dict, base_var_dict, base_cfg_dict + ) + + base_cfg_dict = Config._merge_a_into_b(cfg_dict, base_cfg_dict) + cfg_dict = base_cfg_dict + + # merge cfg_text + cfg_text_list.append(cfg_text) + cfg_text = "\n".join(cfg_text_list) + + return cfg_dict, cfg_text + + @staticmethod + def _merge_a_into_b(a, b, allow_list_keys=False): + """merge dict ``a`` into dict ``b`` (non-inplace). + + Values in ``a`` will overwrite ``b``. ``b`` is copied first to avoid + in-place modifications. + + Args: + a (dict): The source dict to be merged into ``b``. + b (dict): The origin dict to be fetch keys from ``a``. + allow_list_keys (bool): If True, int string keys (e.g. '0', '1') + are allowed in source ``a`` and will replace the element of the + corresponding index in b if b is a list. Default: False. + + Returns: + dict: The modified dict of ``b`` using ``a``. + + Examples: + # Normally merge a into b. + >>> Config._merge_a_into_b( + ... dict(obj=dict(a=2)), dict(obj=dict(a=1))) + {'obj': {'a': 2}} + + # Delete b first and merge a into b. + >>> Config._merge_a_into_b( + ... dict(obj=dict(_delete_=True, a=2)), dict(obj=dict(a=1))) + {'obj': {'a': 2}} + + # b is a list + >>> Config._merge_a_into_b( + ... {'0': dict(a=2)}, [dict(a=1), dict(b=2)], True) + [{'a': 2}, {'b': 2}] + """ + b = b.copy() + for k, v in a.items(): + if allow_list_keys and k.isdigit() and isinstance(b, list): + k = int(k) + if len(b) <= k: + raise KeyError(f"Index {k} exceeds the length of list {b}") + b[k] = Config._merge_a_into_b(v, b[k], allow_list_keys) + elif isinstance(v, dict) and k in b and not v.pop(DELETE_KEY, False): + allowed_types = (dict, list) if allow_list_keys else dict + if not isinstance(b[k], allowed_types): + raise TypeError( + f"{k}={v} in child config cannot inherit from base " + f"because {k} is a dict in the child config but is of " + f"type {type(b[k])} in base config. You may set " + f"`{DELETE_KEY}=True` to ignore the base config" + ) + b[k] = Config._merge_a_into_b(v, b[k], allow_list_keys) + else: + b[k] = v + return b + + @staticmethod + def fromfile(filename, use_predefined_variables=True, import_custom_modules=True): + cfg_dict, cfg_text = Config._file2dict(filename, use_predefined_variables) + if import_custom_modules and cfg_dict.get("custom_imports", None): + import_modules_from_strings(**cfg_dict["custom_imports"]) + return Config(cfg_dict, cfg_text=cfg_text, filename=filename) + + @staticmethod + def fromstring(cfg_str, file_format): + """Generate config from config str. + + Args: + cfg_str (str): Config str. + file_format (str): Config file format corresponding to the + config str. Only py/yml/yaml/json type are supported now! + + Returns: + obj:`Config`: Config obj. + """ + if file_format not in [".py", ".json", ".yaml", ".yml"]: + raise IOError("Only py/yml/yaml/json type are supported now!") + if file_format != ".py" and "dict(" in cfg_str: + # check if users specify a wrong suffix for python + warnings.warn('Please check "file_format", the file format may be .py') + with tempfile.NamedTemporaryFile( + "w", encoding="utf-8", suffix=file_format, delete=False + ) as temp_file: + temp_file.write(cfg_str) + # on windows, previous implementation cause error + # see PR 1077 for details + cfg = Config.fromfile(temp_file.name) + os.remove(temp_file.name) + return cfg + + @staticmethod + def auto_argparser(description=None): + """Generate argparser from config file automatically (experimental)""" + partial_parser = ArgumentParser(description=description) + partial_parser.add_argument("config", help="config file path") + cfg_file = partial_parser.parse_known_args()[0].config + cfg = Config.fromfile(cfg_file) + parser = ArgumentParser(description=description) + parser.add_argument("config", help="config file path") + add_args(parser, cfg) + return parser, cfg + + def __init__(self, cfg_dict=None, cfg_text=None, filename=None): + if cfg_dict is None: + cfg_dict = dict() + elif not isinstance(cfg_dict, dict): + raise TypeError("cfg_dict must be a dict, but " f"got {type(cfg_dict)}") + for key in cfg_dict: + if key in RESERVED_KEYS: + raise KeyError(f"{key} is reserved for config file") + + super(Config, self).__setattr__("_cfg_dict", ConfigDict(cfg_dict)) + super(Config, self).__setattr__("_filename", filename) + if cfg_text: + text = cfg_text + elif filename: + with open(filename, "r") as f: + text = f.read() + else: + text = "" + super(Config, self).__setattr__("_text", text) + + @property + def filename(self): + return self._filename + + @property + def text(self): + return self._text + + @property + def pretty_text(self): + indent = 4 + + def _indent(s_, num_spaces): + s = s_.split("\n") + if len(s) == 1: + return s_ + first = s.pop(0) + s = [(num_spaces * " ") + line for line in s] + s = "\n".join(s) + s = first + "\n" + s + return s + + def _format_basic_types(k, v, use_mapping=False): + if isinstance(v, str): + v_str = f"'{v}'" + else: + v_str = str(v) + + if use_mapping: + k_str = f"'{k}'" if isinstance(k, str) else str(k) + attr_str = f"{k_str}: {v_str}" + else: + attr_str = f"{str(k)}={v_str}" + attr_str = _indent(attr_str, indent) + + return attr_str + + def _format_list(k, v, use_mapping=False): + # check if all items in the list are dict + if all(isinstance(_, dict) for _ in v): + v_str = "[\n" + v_str += "\n".join( + f"dict({_indent(_format_dict(v_), indent)})," for v_ in v + ).rstrip(",") + if use_mapping: + k_str = f"'{k}'" if isinstance(k, str) else str(k) + attr_str = f"{k_str}: {v_str}" + else: + attr_str = f"{str(k)}={v_str}" + attr_str = _indent(attr_str, indent) + "]" + else: + attr_str = _format_basic_types(k, v, use_mapping) + return attr_str + + def _contain_invalid_identifier(dict_str): + contain_invalid_identifier = False + for key_name in dict_str: + contain_invalid_identifier |= not str(key_name).isidentifier() + return contain_invalid_identifier + + def _format_dict(input_dict, outest_level=False): + r = "" + s = [] + + use_mapping = _contain_invalid_identifier(input_dict) + if use_mapping: + r += "{" + for idx, (k, v) in enumerate(input_dict.items()): + is_last = idx >= len(input_dict) - 1 + end = "" if outest_level or is_last else "," + if isinstance(v, dict): + v_str = "\n" + _format_dict(v) + if use_mapping: + k_str = f"'{k}'" if isinstance(k, str) else str(k) + attr_str = f"{k_str}: dict({v_str}" + else: + attr_str = f"{str(k)}=dict({v_str}" + attr_str = _indent(attr_str, indent) + ")" + end + elif isinstance(v, list): + attr_str = _format_list(k, v, use_mapping) + end + else: + attr_str = _format_basic_types(k, v, use_mapping) + end + + s.append(attr_str) + r += "\n".join(s) + if use_mapping: + r += "}" + return r + + cfg_dict = self._cfg_dict.to_dict() + text = _format_dict(cfg_dict, outest_level=True) + # copied from setup.cfg + yapf_style = dict( + based_on_style="pep8", + blank_line_before_nested_class_or_def=True, + split_before_expression_after_opening_paren=True, + ) + text, _ = FormatCode(text, style_config=yapf_style) + + return text + + def __repr__(self): + return f"Config (path: {self.filename}): {self._cfg_dict.__repr__()}" + + def __len__(self): + return len(self._cfg_dict) + + def __getattr__(self, name): + return getattr(self._cfg_dict, name) + + def __getitem__(self, name): + return self._cfg_dict.__getitem__(name) + + def __setattr__(self, name, value): + if isinstance(value, dict): + value = ConfigDict(value) + self._cfg_dict.__setattr__(name, value) + + def __setitem__(self, name, value): + if isinstance(value, dict): + value = ConfigDict(value) + self._cfg_dict.__setitem__(name, value) + + def __iter__(self): + return iter(self._cfg_dict) + + def __getstate__(self): + return (self._cfg_dict, self._filename, self._text) + + def __setstate__(self, state): + _cfg_dict, _filename, _text = state + super(Config, self).__setattr__("_cfg_dict", _cfg_dict) + super(Config, self).__setattr__("_filename", _filename) + super(Config, self).__setattr__("_text", _text) + + def dump(self, file=None): + cfg_dict = super(Config, self).__getattribute__("_cfg_dict").to_dict() + if self.filename.endswith(".py"): + if file is None: + return self.pretty_text + else: + with open(file, "w", encoding="utf-8") as f: + f.write(self.pretty_text) + else: + import mmcv + + if file is None: + file_format = self.filename.split(".")[-1] + return mmcv.dump(cfg_dict, file_format=file_format) + else: + mmcv.dump(cfg_dict, file) + + def merge_from_dict(self, options, allow_list_keys=True): + """Merge list into cfg_dict. + + Merge the dict parsed by MultipleKVAction into this cfg. + + Examples: + >>> options = {'models.backbone.depth': 50, + ... 'models.backbone.with_cp':True} + >>> cfg = Config(dict(models=dict(backbone=dict(type='ResNet')))) + >>> cfg.merge_from_dict(options) + >>> cfg_dict = super(Config, self).__getattribute__('_cfg_dict') + >>> assert cfg_dict == dict( + ... models=dict(backbone=dict(depth=50, with_cp=True))) + + # Merge list element + >>> cfg = Config(dict(pipeline=[ + ... dict(type='LoadImage'), dict(type='LoadAnnotations')])) + >>> options = dict(pipeline={'0': dict(type='SelfLoadImage')}) + >>> cfg.merge_from_dict(options, allow_list_keys=True) + >>> cfg_dict = super(Config, self).__getattribute__('_cfg_dict') + >>> assert cfg_dict == dict(pipeline=[ + ... dict(type='SelfLoadImage'), dict(type='LoadAnnotations')]) + + Args: + options (dict): dict of configs to merge from. + allow_list_keys (bool): If True, int string keys (e.g. '0', '1') + are allowed in ``options`` and will replace the element of the + corresponding index in the config if the config is a list. + Default: True. + """ + option_cfg_dict = {} + for full_key, v in options.items(): + d = option_cfg_dict + key_list = full_key.split(".") + for subkey in key_list[:-1]: + d.setdefault(subkey, ConfigDict()) + d = d[subkey] + subkey = key_list[-1] + d[subkey] = v + + cfg_dict = super(Config, self).__getattribute__("_cfg_dict") + super(Config, self).__setattr__( + "_cfg_dict", + Config._merge_a_into_b( + option_cfg_dict, cfg_dict, allow_list_keys=allow_list_keys + ), + ) + + +class DictAction(Action): + """ + argparse action to split an argument into KEY=VALUE form + on the first = and append to a dictionary. List options can + be passed as comma separated values, i.e 'KEY=V1,V2,V3', or with explicit + brackets, i.e. 'KEY=[V1,V2,V3]'. It also support nested brackets to build + list/tuple values. e.g. 'KEY=[(V1,V2),(V3,V4)]' + """ + + @staticmethod + def _parse_int_float_bool(val): + try: + return int(val) + except ValueError: + pass + try: + return float(val) + except ValueError: + pass + if val.lower() in ["true", "false"]: + return True if val.lower() == "true" else False + return val + + @staticmethod + def _parse_iterable(val): + """Parse iterable values in the string. + + All elements inside '()' or '[]' are treated as iterable values. + + Args: + val (str): Value string. + + Returns: + list | tuple: The expanded list or tuple from the string. + + Examples: + >>> DictAction._parse_iterable('1,2,3') + [1, 2, 3] + >>> DictAction._parse_iterable('[a, b, c]') + ['a', 'b', 'c'] + >>> DictAction._parse_iterable('[(1, 2, 3), [a, b], c]') + [(1, 2, 3), ['a', 'b'], 'c'] + """ + + def find_next_comma(string): + """Find the position of next comma in the string. + + If no ',' is found in the string, return the string length. All + chars inside '()' and '[]' are treated as one element and thus ',' + inside these brackets are ignored. + """ + assert (string.count("(") == string.count(")")) and ( + string.count("[") == string.count("]") + ), f"Imbalanced brackets exist in {string}" + end = len(string) + for idx, char in enumerate(string): + pre = string[:idx] + # The string before this ',' is balanced + if ( + (char == ",") + and (pre.count("(") == pre.count(")")) + and (pre.count("[") == pre.count("]")) + ): + end = idx + break + return end + + # Strip ' and " characters and replace whitespace. + val = val.strip("'\"").replace(" ", "") + is_tuple = False + if val.startswith("(") and val.endswith(")"): + is_tuple = True + val = val[1:-1] + elif val.startswith("[") and val.endswith("]"): + val = val[1:-1] + elif "," not in val: + # val is a single value + return DictAction._parse_int_float_bool(val) + + values = [] + while len(val) > 0: + comma_idx = find_next_comma(val) + element = DictAction._parse_iterable(val[:comma_idx]) + values.append(element) + val = val[comma_idx + 1 :] + if is_tuple: + values = tuple(values) + return values + + def __call__(self, parser, namespace, values, option_string=None): + options = {} + for kv in values: + key, val = kv.split("=", maxsplit=1) + options[key] = self._parse_iterable(val) + setattr(namespace, self.dest, options) diff --git a/services/audio2exp-service/LAM_Audio2Expression/utils/env.py b/services/audio2exp-service/LAM_Audio2Expression/utils/env.py new file mode 100644 index 0000000..802ed90 --- /dev/null +++ b/services/audio2exp-service/LAM_Audio2Expression/utils/env.py @@ -0,0 +1,33 @@ +""" +The code is base on https://github.com/Pointcept/Pointcept +""" + +import os +import random +import numpy as np +import torch +import torch.backends.cudnn as cudnn + +from datetime import datetime + + +def get_random_seed(): + seed = ( + os.getpid() + + int(datetime.now().strftime("%S%f")) + + int.from_bytes(os.urandom(2), "big") + ) + return seed + + +def set_seed(seed=None): + if seed is None: + seed = get_random_seed() + random.seed(seed) + np.random.seed(seed) + torch.manual_seed(seed) + torch.cuda.manual_seed(seed) + torch.cuda.manual_seed_all(seed) + cudnn.benchmark = False + cudnn.deterministic = True + os.environ["PYTHONHASHSEED"] = str(seed) diff --git a/services/audio2exp-service/LAM_Audio2Expression/utils/events.py b/services/audio2exp-service/LAM_Audio2Expression/utils/events.py new file mode 100644 index 0000000..90412dd --- /dev/null +++ b/services/audio2exp-service/LAM_Audio2Expression/utils/events.py @@ -0,0 +1,585 @@ +""" +The code is base on https://github.com/Pointcept/Pointcept +""" + + +import datetime +import json +import logging +import os +import time +import torch +import numpy as np + +from typing import List, Optional, Tuple +from collections import defaultdict +from contextlib import contextmanager + +__all__ = [ + "get_event_storage", + "JSONWriter", + "TensorboardXWriter", + "CommonMetricPrinter", + "EventStorage", +] + +_CURRENT_STORAGE_STACK = [] + + +def get_event_storage(): + """ + Returns: + The :class:`EventStorage` object that's currently being used. + Throws an error if no :class:`EventStorage` is currently enabled. + """ + assert len( + _CURRENT_STORAGE_STACK + ), "get_event_storage() has to be called inside a 'with EventStorage(...)' context!" + return _CURRENT_STORAGE_STACK[-1] + + +class EventWriter: + """ + Base class for writers that obtain events from :class:`EventStorage` and process them. + """ + + def write(self): + raise NotImplementedError + + def close(self): + pass + + +class JSONWriter(EventWriter): + """ + Write scalars to a json file. + It saves scalars as one json per line (instead of a big json) for easy parsing. + Examples parsing such a json file: + :: + $ cat metrics.json | jq -s '.[0:2]' + [ + { + "data_time": 0.008433341979980469, + "iteration": 19, + "loss": 1.9228371381759644, + "loss_box_reg": 0.050025828182697296, + "loss_classifier": 0.5316952466964722, + "loss_mask": 0.7236229181289673, + "loss_rpn_box": 0.0856662318110466, + "loss_rpn_cls": 0.48198649287223816, + "lr": 0.007173333333333333, + "time": 0.25401854515075684 + }, + { + "data_time": 0.007216215133666992, + "iteration": 39, + "loss": 1.282649278640747, + "loss_box_reg": 0.06222952902317047, + "loss_classifier": 0.30682939291000366, + "loss_mask": 0.6970193982124329, + "loss_rpn_box": 0.038663312792778015, + "loss_rpn_cls": 0.1471673548221588, + "lr": 0.007706666666666667, + "time": 0.2490077018737793 + } + ] + $ cat metrics.json | jq '.loss_mask' + 0.7126231789588928 + 0.689423680305481 + 0.6776131987571716 + ... + """ + + def __init__(self, json_file, window_size=20): + """ + Args: + json_file (str): path to the json file. New data will be appended if the file exists. + window_size (int): the window size of median smoothing for the scalars whose + `smoothing_hint` are True. + """ + self._file_handle = open(json_file, "a") + self._window_size = window_size + self._last_write = -1 + + def write(self): + storage = get_event_storage() + to_save = defaultdict(dict) + + for k, (v, iter) in storage.latest_with_smoothing_hint( + self._window_size + ).items(): + # keep scalars that have not been written + if iter <= self._last_write: + continue + to_save[iter][k] = v + if len(to_save): + all_iters = sorted(to_save.keys()) + self._last_write = max(all_iters) + + for itr, scalars_per_iter in to_save.items(): + scalars_per_iter["iteration"] = itr + self._file_handle.write(json.dumps(scalars_per_iter, sort_keys=True) + "\n") + self._file_handle.flush() + try: + os.fsync(self._file_handle.fileno()) + except AttributeError: + pass + + def close(self): + self._file_handle.close() + + +class TensorboardXWriter(EventWriter): + """ + Write all scalars to a tensorboard file. + """ + + def __init__(self, log_dir: str, window_size: int = 20, **kwargs): + """ + Args: + log_dir (str): the directory to save the output events + window_size (int): the scalars will be median-smoothed by this window size + kwargs: other arguments passed to `torch.utils.tensorboard.SummaryWriter(...)` + """ + self._window_size = window_size + from torch.utils.tensorboard import SummaryWriter + + self._writer = SummaryWriter(log_dir, **kwargs) + self._last_write = -1 + + def write(self): + storage = get_event_storage() + new_last_write = self._last_write + for k, (v, iter) in storage.latest_with_smoothing_hint( + self._window_size + ).items(): + if iter > self._last_write: + self._writer.add_scalar(k, v, iter) + new_last_write = max(new_last_write, iter) + self._last_write = new_last_write + + # storage.put_{image,histogram} is only meant to be used by + # tensorboard writer. So we access its internal fields directly from here. + if len(storage._vis_data) >= 1: + for img_name, img, step_num in storage._vis_data: + self._writer.add_image(img_name, img, step_num) + # Storage stores all image data and rely on this writer to clear them. + # As a result it assumes only one writer will use its image data. + # An alternative design is to let storage store limited recent + # data (e.g. only the most recent image) that all writers can access. + # In that case a writer may not see all image data if its period is long. + storage.clear_images() + + if len(storage._histograms) >= 1: + for params in storage._histograms: + self._writer.add_histogram_raw(**params) + storage.clear_histograms() + + def close(self): + if hasattr(self, "_writer"): # doesn't exist when the code fails at import + self._writer.close() + + +class CommonMetricPrinter(EventWriter): + """ + Print **common** metrics to the terminal, including + iteration time, ETA, memory, all losses, and the learning rate. + It also applies smoothing using a window of 20 elements. + It's meant to print common metrics in common ways. + To print something in more customized ways, please implement a similar printer by yourself. + """ + + def __init__(self, max_iter: Optional[int] = None, window_size: int = 20): + """ + Args: + max_iter: the maximum number of iterations to train. + Used to compute ETA. If not given, ETA will not be printed. + window_size (int): the losses will be median-smoothed by this window size + """ + self.logger = logging.getLogger(__name__) + self._max_iter = max_iter + self._window_size = window_size + self._last_write = ( + None # (step, time) of last call to write(). Used to compute ETA + ) + + def _get_eta(self, storage) -> Optional[str]: + if self._max_iter is None: + return "" + iteration = storage.iter + try: + eta_seconds = storage.history("time").median(1000) * ( + self._max_iter - iteration - 1 + ) + storage.put_scalar("eta_seconds", eta_seconds, smoothing_hint=False) + return str(datetime.timedelta(seconds=int(eta_seconds))) + except KeyError: + # estimate eta on our own - more noisy + eta_string = None + if self._last_write is not None: + estimate_iter_time = (time.perf_counter() - self._last_write[1]) / ( + iteration - self._last_write[0] + ) + eta_seconds = estimate_iter_time * (self._max_iter - iteration - 1) + eta_string = str(datetime.timedelta(seconds=int(eta_seconds))) + self._last_write = (iteration, time.perf_counter()) + return eta_string + + def write(self): + storage = get_event_storage() + iteration = storage.iter + if iteration == self._max_iter: + # This hook only reports training progress (loss, ETA, etc) but not other data, + # therefore do not write anything after training succeeds, even if this method + # is called. + return + + try: + data_time = storage.history("data_time").avg(20) + except KeyError: + # they may not exist in the first few iterations (due to warmup) + # or when SimpleTrainer is not used + data_time = None + try: + iter_time = storage.history("time").global_avg() + except KeyError: + iter_time = None + try: + lr = "{:.5g}".format(storage.history("lr").latest()) + except KeyError: + lr = "N/A" + + eta_string = self._get_eta(storage) + + if torch.cuda.is_available(): + max_mem_mb = torch.cuda.max_memory_allocated() / 1024.0 / 1024.0 + else: + max_mem_mb = None + + # NOTE: max_mem is parsed by grep in "dev/parse_results.sh" + self.logger.info( + " {eta}iter: {iter} {losses} {time}{data_time}lr: {lr} {memory}".format( + eta=f"eta: {eta_string} " if eta_string else "", + iter=iteration, + losses=" ".join( + [ + "{}: {:.4g}".format(k, v.median(self._window_size)) + for k, v in storage.histories().items() + if "loss" in k + ] + ), + time="time: {:.4f} ".format(iter_time) + if iter_time is not None + else "", + data_time="data_time: {:.4f} ".format(data_time) + if data_time is not None + else "", + lr=lr, + memory="max_mem: {:.0f}M".format(max_mem_mb) + if max_mem_mb is not None + else "", + ) + ) + + +class EventStorage: + """ + The user-facing class that provides metric storage functionalities. + In the future we may add support for storing / logging other types of data if needed. + """ + + def __init__(self, start_iter=0): + """ + Args: + start_iter (int): the iteration number to start with + """ + self._history = defaultdict(AverageMeter) + self._smoothing_hints = {} + self._latest_scalars = {} + self._iter = start_iter + self._current_prefix = "" + self._vis_data = [] + self._histograms = [] + + # def put_image(self, img_name, img_tensor): + # """ + # Add an `img_tensor` associated with `img_name`, to be shown on + # tensorboard. + # Args: + # img_name (str): The name of the image to put into tensorboard. + # img_tensor (torch.Tensor or numpy.array): An `uint8` or `float` + # Tensor of shape `[channel, height, width]` where `channel` is + # 3. The image format should be RGB. The elements in img_tensor + # can either have values in [0, 1] (float32) or [0, 255] (uint8). + # The `img_tensor` will be visualized in tensorboard. + # """ + # self._vis_data.append((img_name, img_tensor, self._iter)) + + def put_scalar(self, name, value, n=1, smoothing_hint=False): + """ + Add a scalar `value` to the `HistoryBuffer` associated with `name`. + Args: + smoothing_hint (bool): a 'hint' on whether this scalar is noisy and should be + smoothed when logged. The hint will be accessible through + :meth:`EventStorage.smoothing_hints`. A writer may ignore the hint + and apply custom smoothing rule. + It defaults to True because most scalars we save need to be smoothed to + provide any useful signal. + """ + name = self._current_prefix + name + history = self._history[name] + history.update(value, n) + self._latest_scalars[name] = (value, self._iter) + + existing_hint = self._smoothing_hints.get(name) + if existing_hint is not None: + assert ( + existing_hint == smoothing_hint + ), "Scalar {} was put with a different smoothing_hint!".format(name) + else: + self._smoothing_hints[name] = smoothing_hint + + # def put_scalars(self, *, smoothing_hint=True, **kwargs): + # """ + # Put multiple scalars from keyword arguments. + # Examples: + # storage.put_scalars(loss=my_loss, accuracy=my_accuracy, smoothing_hint=True) + # """ + # for k, v in kwargs.items(): + # self.put_scalar(k, v, smoothing_hint=smoothing_hint) + # + # def put_histogram(self, hist_name, hist_tensor, bins=1000): + # """ + # Create a histogram from a tensor. + # Args: + # hist_name (str): The name of the histogram to put into tensorboard. + # hist_tensor (torch.Tensor): A Tensor of arbitrary shape to be converted + # into a histogram. + # bins (int): Number of histogram bins. + # """ + # ht_min, ht_max = hist_tensor.min().item(), hist_tensor.max().item() + # + # # Create a histogram with PyTorch + # hist_counts = torch.histc(hist_tensor, bins=bins) + # hist_edges = torch.linspace(start=ht_min, end=ht_max, steps=bins + 1, dtype=torch.float32) + # + # # Parameter for the add_histogram_raw function of SummaryWriter + # hist_params = dict( + # tag=hist_name, + # min=ht_min, + # max=ht_max, + # num=len(hist_tensor), + # sum=float(hist_tensor.sum()), + # sum_squares=float(torch.sum(hist_tensor**2)), + # bucket_limits=hist_edges[1:].tolist(), + # bucket_counts=hist_counts.tolist(), + # global_step=self._iter, + # ) + # self._histograms.append(hist_params) + + def history(self, name): + """ + Returns: + AverageMeter: the history for name + """ + ret = self._history.get(name, None) + if ret is None: + raise KeyError("No history metric available for {}!".format(name)) + return ret + + def histories(self): + """ + Returns: + dict[name -> HistoryBuffer]: the HistoryBuffer for all scalars + """ + return self._history + + def latest(self): + """ + Returns: + dict[str -> (float, int)]: mapping from the name of each scalar to the most + recent value and the iteration number its added. + """ + return self._latest_scalars + + def latest_with_smoothing_hint(self, window_size=20): + """ + Similar to :meth:`latest`, but the returned values + are either the un-smoothed original latest value, + or a median of the given window_size, + depend on whether the smoothing_hint is True. + This provides a default behavior that other writers can use. + """ + result = {} + for k, (v, itr) in self._latest_scalars.items(): + result[k] = ( + self._history[k].median(window_size) if self._smoothing_hints[k] else v, + itr, + ) + return result + + def smoothing_hints(self): + """ + Returns: + dict[name -> bool]: the user-provided hint on whether the scalar + is noisy and needs smoothing. + """ + return self._smoothing_hints + + def step(self): + """ + User should either: (1) Call this function to increment storage.iter when needed. Or + (2) Set `storage.iter` to the correct iteration number before each iteration. + The storage will then be able to associate the new data with an iteration number. + """ + self._iter += 1 + + @property + def iter(self): + """ + Returns: + int: The current iteration number. When used together with a trainer, + this is ensured to be the same as trainer.iter. + """ + return self._iter + + @iter.setter + def iter(self, val): + self._iter = int(val) + + @property + def iteration(self): + # for backward compatibility + return self._iter + + def __enter__(self): + _CURRENT_STORAGE_STACK.append(self) + return self + + def __exit__(self, exc_type, exc_val, exc_tb): + assert _CURRENT_STORAGE_STACK[-1] == self + _CURRENT_STORAGE_STACK.pop() + + @contextmanager + def name_scope(self, name): + """ + Yields: + A context within which all the events added to this storage + will be prefixed by the name scope. + """ + old_prefix = self._current_prefix + self._current_prefix = name.rstrip("/") + "/" + yield + self._current_prefix = old_prefix + + def clear_images(self): + """ + Delete all the stored images for visualization. This should be called + after images are written to tensorboard. + """ + self._vis_data = [] + + def clear_histograms(self): + """ + Delete all the stored histograms for visualization. + This should be called after histograms are written to tensorboard. + """ + self._histograms = [] + + def reset_history(self, name): + ret = self._history.get(name, None) + if ret is None: + raise KeyError("No history metric available for {}!".format(name)) + ret.reset() + + def reset_histories(self): + for name in self._history.keys(): + self._history[name].reset() + + +class AverageMeter: + """Computes and stores the average and current value""" + + def __init__(self): + self.val = 0 + self.avg = 0 + self.total = 0 + self.count = 0 + + def reset(self): + self.val = 0 + self.avg = 0 + self.total = 0 + self.count = 0 + + def update(self, val, n=1): + self.val = val + self.total += val * n + self.count += n + self.avg = self.total / self.count + + +class HistoryBuffer: + """ + Track a series of scalar values and provide access to smoothed values over a + window or the global average of the series. + """ + + def __init__(self, max_length: int = 1000000) -> None: + """ + Args: + max_length: maximal number of values that can be stored in the + buffer. When the capacity of the buffer is exhausted, old + values will be removed. + """ + self._max_length: int = max_length + self._data: List[Tuple[float, float]] = [] # (value, iteration) pairs + self._count: int = 0 + self._global_avg: float = 0 + + def update(self, value: float, iteration: Optional[float] = None) -> None: + """ + Add a new scalar value produced at certain iteration. If the length + of the buffer exceeds self._max_length, the oldest element will be + removed from the buffer. + """ + if iteration is None: + iteration = self._count + if len(self._data) == self._max_length: + self._data.pop(0) + self._data.append((value, iteration)) + + self._count += 1 + self._global_avg += (value - self._global_avg) / self._count + + def latest(self) -> float: + """ + Return the latest scalar value added to the buffer. + """ + return self._data[-1][0] + + def median(self, window_size: int) -> float: + """ + Return the median of the latest `window_size` values in the buffer. + """ + return np.median([x[0] for x in self._data[-window_size:]]) + + def avg(self, window_size: int) -> float: + """ + Return the mean of the latest `window_size` values in the buffer. + """ + return np.mean([x[0] for x in self._data[-window_size:]]) + + def global_avg(self) -> float: + """ + Return the mean of all the elements in the buffer. Note that this + includes those getting removed due to limited buffer storage. + """ + return self._global_avg + + def values(self) -> List[Tuple[float, float]]: + """ + Returns: + list[(number, iteration)]: content of the current buffer. + """ + return self._data diff --git a/services/audio2exp-service/LAM_Audio2Expression/utils/logger.py b/services/audio2exp-service/LAM_Audio2Expression/utils/logger.py new file mode 100644 index 0000000..6e30c5d --- /dev/null +++ b/services/audio2exp-service/LAM_Audio2Expression/utils/logger.py @@ -0,0 +1,167 @@ +""" +The code is base on https://github.com/Pointcept/Pointcept +""" + +import logging +import torch +import torch.distributed as dist + +from termcolor import colored + +logger_initialized = {} +root_status = 0 + + +class _ColorfulFormatter(logging.Formatter): + def __init__(self, *args, **kwargs): + self._root_name = kwargs.pop("root_name") + "." + super(_ColorfulFormatter, self).__init__(*args, **kwargs) + + def formatMessage(self, record): + log = super(_ColorfulFormatter, self).formatMessage(record) + if record.levelno == logging.WARNING: + prefix = colored("WARNING", "red", attrs=["blink"]) + elif record.levelno == logging.ERROR or record.levelno == logging.CRITICAL: + prefix = colored("ERROR", "red", attrs=["blink", "underline"]) + else: + return log + return prefix + " " + log + + +def get_logger(name, log_file=None, log_level=logging.INFO, file_mode="a", color=False): + """Initialize and get a logger by name. + + If the logger has not been initialized, this method will initialize the + logger by adding one or two handlers, otherwise the initialized logger will + be directly returned. During initialization, a StreamHandler will always be + added. If `log_file` is specified and the process rank is 0, a FileHandler + will also be added. + + Args: + name (str): Logger name. + log_file (str | None): The log filename. If specified, a FileHandler + will be added to the logger. + log_level (int): The logger level. Note that only the process of + rank 0 is affected, and other processes will set the level to + "Error" thus be silent most of the time. + file_mode (str): The file mode used in opening log file. + Defaults to 'a'. + color (bool): Colorful log output. Defaults to True + + Returns: + logging.Logger: The expected logger. + """ + logger = logging.getLogger(name) + + if name in logger_initialized: + return logger + # handle hierarchical names + # e.g., logger "a" is initialized, then logger "a.b" will skip the + # initialization since it is a child of "a". + for logger_name in logger_initialized: + if name.startswith(logger_name): + return logger + + logger.propagate = False + + stream_handler = logging.StreamHandler() + handlers = [stream_handler] + + if dist.is_available() and dist.is_initialized(): + rank = dist.get_rank() + else: + rank = 0 + + # only rank 0 will add a FileHandler + if rank == 0 and log_file is not None: + # Here, the default behaviour of the official logger is 'a'. Thus, we + # provide an interface to change the file mode to the default + # behaviour. + file_handler = logging.FileHandler(log_file, file_mode) + handlers.append(file_handler) + + plain_formatter = logging.Formatter( + "[%(asctime)s %(levelname)s %(filename)s line %(lineno)d %(process)d] %(message)s" + ) + if color: + formatter = _ColorfulFormatter( + colored("[%(asctime)s %(name)s]: ", "green") + "%(message)s", + datefmt="%m/%d %H:%M:%S", + root_name=name, + ) + else: + formatter = plain_formatter + for handler in handlers: + handler.setFormatter(formatter) + handler.setLevel(log_level) + logger.addHandler(handler) + + if rank == 0: + logger.setLevel(log_level) + else: + logger.setLevel(logging.ERROR) + + logger_initialized[name] = True + + return logger + + +def print_log(msg, logger=None, level=logging.INFO): + """Print a log message. + + Args: + msg (str): The message to be logged. + logger (logging.Logger | str | None): The logger to be used. + Some special loggers are: + - "silent": no message will be printed. + - other str: the logger obtained with `get_root_logger(logger)`. + - None: The `print()` method will be used to print log messages. + level (int): Logging level. Only available when `logger` is a Logger + object or "root". + """ + if logger is None: + print(msg) + elif isinstance(logger, logging.Logger): + logger.log(level, msg) + elif logger == "silent": + pass + elif isinstance(logger, str): + _logger = get_logger(logger) + _logger.log(level, msg) + else: + raise TypeError( + "logger should be either a logging.Logger object, str, " + f'"silent" or None, but got {type(logger)}' + ) + + +def get_root_logger(log_file=None, log_level=logging.INFO, file_mode="a"): + """Get the root logger. + + The logger will be initialized if it has not been initialized. By default a + StreamHandler will be added. If `log_file` is specified, a FileHandler will + also be added. The name of the root logger is the top-level package name. + + Args: + log_file (str | None): The log filename. If specified, a FileHandler + will be added to the root logger. + log_level (int): The root logger level. Note that only the process of + rank 0 is affected, while other processes will set the level to + "Error" and be silent most of the time. + file_mode (str): File Mode of logger. (w or a) + + Returns: + logging.Logger: The root logger. + """ + logger = get_logger( + name="pointcept", log_file=log_file, log_level=log_level, file_mode=file_mode + ) + return logger + + +def _log_api_usage(identifier: str): + """ + Internal function used to log the usage of different detectron2 components + inside facebook's infra. + """ + torch._C._log_api_usage_once("pointcept." + identifier) diff --git a/services/audio2exp-service/LAM_Audio2Expression/utils/misc.py b/services/audio2exp-service/LAM_Audio2Expression/utils/misc.py new file mode 100644 index 0000000..dbd257e --- /dev/null +++ b/services/audio2exp-service/LAM_Audio2Expression/utils/misc.py @@ -0,0 +1,156 @@ +""" +The code is base on https://github.com/Pointcept/Pointcept +""" + +import os +import warnings +from collections import abc +import numpy as np +import torch +from importlib import import_module + + +class AverageMeter(object): + """Computes and stores the average and current value""" + + def __init__(self): + self.val = 0 + self.avg = 0 + self.sum = 0 + self.count = 0 + + def reset(self): + self.val = 0 + self.avg = 0 + self.sum = 0 + self.count = 0 + + def update(self, val, n=1): + self.val = val + self.sum += val * n + self.count += n + self.avg = self.sum / self.count + + +def intersection_and_union(output, target, K, ignore_index=-1): + # 'K' classes, output and target sizes are N or N * L or N * H * W, each value in range 0 to K - 1. + assert output.ndim in [1, 2, 3] + assert output.shape == target.shape + output = output.reshape(output.size).copy() + target = target.reshape(target.size) + output[np.where(target == ignore_index)[0]] = ignore_index + intersection = output[np.where(output == target)[0]] + area_intersection, _ = np.histogram(intersection, bins=np.arange(K + 1)) + area_output, _ = np.histogram(output, bins=np.arange(K + 1)) + area_target, _ = np.histogram(target, bins=np.arange(K + 1)) + area_union = area_output + area_target - area_intersection + return area_intersection, area_union, area_target + + +def intersection_and_union_gpu(output, target, k, ignore_index=-1): + # 'K' classes, output and target sizes are N or N * L or N * H * W, each value in range 0 to K - 1. + assert output.dim() in [1, 2, 3] + assert output.shape == target.shape + output = output.view(-1) + target = target.view(-1) + output[target == ignore_index] = ignore_index + intersection = output[output == target] + area_intersection = torch.histc(intersection, bins=k, min=0, max=k - 1) + area_output = torch.histc(output, bins=k, min=0, max=k - 1) + area_target = torch.histc(target, bins=k, min=0, max=k - 1) + area_union = area_output + area_target - area_intersection + return area_intersection, area_union, area_target + + +def make_dirs(dir_name): + if not os.path.exists(dir_name): + os.makedirs(dir_name, exist_ok=True) + + +def find_free_port(): + import socket + + sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + # Binding to port 0 will cause the OS to find an available port for us + sock.bind(("", 0)) + port = sock.getsockname()[1] + sock.close() + # NOTE: there is still a chance the port could be taken by other processes. + return port + + +def is_seq_of(seq, expected_type, seq_type=None): + """Check whether it is a sequence of some type. + + Args: + seq (Sequence): The sequence to be checked. + expected_type (type): Expected type of sequence items. + seq_type (type, optional): Expected sequence type. + + Returns: + bool: Whether the sequence is valid. + """ + if seq_type is None: + exp_seq_type = abc.Sequence + else: + assert isinstance(seq_type, type) + exp_seq_type = seq_type + if not isinstance(seq, exp_seq_type): + return False + for item in seq: + if not isinstance(item, expected_type): + return False + return True + + +def is_str(x): + """Whether the input is an string instance. + + Note: This method is deprecated since python 2 is no longer supported. + """ + return isinstance(x, str) + + +def import_modules_from_strings(imports, allow_failed_imports=False): + """Import modules from the given list of strings. + + Args: + imports (list | str | None): The given module names to be imported. + allow_failed_imports (bool): If True, the failed imports will return + None. Otherwise, an ImportError is raise. Default: False. + + Returns: + list[module] | module | None: The imported modules. + + Examples: + >>> osp, sys = import_modules_from_strings( + ... ['os.path', 'sys']) + >>> import os.path as osp_ + >>> import sys as sys_ + >>> assert osp == osp_ + >>> assert sys == sys_ + """ + if not imports: + return + single_import = False + if isinstance(imports, str): + single_import = True + imports = [imports] + if not isinstance(imports, list): + raise TypeError(f"custom_imports must be a list but got type {type(imports)}") + imported = [] + for imp in imports: + if not isinstance(imp, str): + raise TypeError(f"{imp} is of type {type(imp)} and cannot be imported.") + try: + imported_tmp = import_module(imp) + except ImportError: + if allow_failed_imports: + warnings.warn(f"{imp} failed to import and is ignored.", UserWarning) + imported_tmp = None + else: + raise ImportError + imported.append(imported_tmp) + if single_import: + imported = imported[0] + return imported diff --git a/services/audio2exp-service/LAM_Audio2Expression/utils/optimizer.py b/services/audio2exp-service/LAM_Audio2Expression/utils/optimizer.py new file mode 100644 index 0000000..2eb70a3 --- /dev/null +++ b/services/audio2exp-service/LAM_Audio2Expression/utils/optimizer.py @@ -0,0 +1,52 @@ +""" +The code is base on https://github.com/Pointcept/Pointcept +""" + +import torch +from utils.logger import get_root_logger +from utils.registry import Registry + +OPTIMIZERS = Registry("optimizers") + + +OPTIMIZERS.register_module(module=torch.optim.SGD, name="SGD") +OPTIMIZERS.register_module(module=torch.optim.Adam, name="Adam") +OPTIMIZERS.register_module(module=torch.optim.AdamW, name="AdamW") + + +def build_optimizer(cfg, model, param_dicts=None): + if param_dicts is None: + cfg.params = model.parameters() + else: + cfg.params = [dict(names=[], params=[], lr=cfg.lr)] + for i in range(len(param_dicts)): + param_group = dict(names=[], params=[]) + if "lr" in param_dicts[i].keys(): + param_group["lr"] = param_dicts[i].lr + if "momentum" in param_dicts[i].keys(): + param_group["momentum"] = param_dicts[i].momentum + if "weight_decay" in param_dicts[i].keys(): + param_group["weight_decay"] = param_dicts[i].weight_decay + cfg.params.append(param_group) + + for n, p in model.named_parameters(): + flag = False + for i in range(len(param_dicts)): + if param_dicts[i].keyword in n: + cfg.params[i + 1]["names"].append(n) + cfg.params[i + 1]["params"].append(p) + flag = True + break + if not flag: + cfg.params[0]["names"].append(n) + cfg.params[0]["params"].append(p) + + logger = get_root_logger() + for i in range(len(cfg.params)): + param_names = cfg.params[i].pop("names") + message = "" + for key in cfg.params[i].keys(): + if key != "params": + message += f" {key}: {cfg.params[i][key]};" + logger.info(f"Params Group {i+1} -{message} Params: {param_names}.") + return OPTIMIZERS.build(cfg=cfg) diff --git a/services/audio2exp-service/LAM_Audio2Expression/utils/path.py b/services/audio2exp-service/LAM_Audio2Expression/utils/path.py new file mode 100644 index 0000000..5d1da76 --- /dev/null +++ b/services/audio2exp-service/LAM_Audio2Expression/utils/path.py @@ -0,0 +1,105 @@ +""" +The code is base on https://github.com/Pointcept/Pointcept +""" +import os +import os.path as osp +from pathlib import Path + +from .misc import is_str + + +def is_filepath(x): + return is_str(x) or isinstance(x, Path) + + +def fopen(filepath, *args, **kwargs): + if is_str(filepath): + return open(filepath, *args, **kwargs) + elif isinstance(filepath, Path): + return filepath.open(*args, **kwargs) + raise ValueError("`filepath` should be a string or a Path") + + +def check_file_exist(filename, msg_tmpl='file "{}" does not exist'): + if not osp.isfile(filename): + raise FileNotFoundError(msg_tmpl.format(filename)) + + +def mkdir_or_exist(dir_name, mode=0o777): + if dir_name == "": + return + dir_name = osp.expanduser(dir_name) + os.makedirs(dir_name, mode=mode, exist_ok=True) + + +def symlink(src, dst, overwrite=True, **kwargs): + if os.path.lexists(dst) and overwrite: + os.remove(dst) + os.symlink(src, dst, **kwargs) + + +def scandir(dir_path, suffix=None, recursive=False, case_sensitive=True): + """Scan a directory to find the interested files. + + Args: + dir_path (str | obj:`Path`): Path of the directory. + suffix (str | tuple(str), optional): File suffix that we are + interested in. Default: None. + recursive (bool, optional): If set to True, recursively scan the + directory. Default: False. + case_sensitive (bool, optional) : If set to False, ignore the case of + suffix. Default: True. + + Returns: + A generator for all the interested files with relative paths. + """ + if isinstance(dir_path, (str, Path)): + dir_path = str(dir_path) + else: + raise TypeError('"dir_path" must be a string or Path object') + + if (suffix is not None) and not isinstance(suffix, (str, tuple)): + raise TypeError('"suffix" must be a string or tuple of strings') + + if suffix is not None and not case_sensitive: + suffix = ( + suffix.lower() + if isinstance(suffix, str) + else tuple(item.lower() for item in suffix) + ) + + root = dir_path + + def _scandir(dir_path, suffix, recursive, case_sensitive): + for entry in os.scandir(dir_path): + if not entry.name.startswith(".") and entry.is_file(): + rel_path = osp.relpath(entry.path, root) + _rel_path = rel_path if case_sensitive else rel_path.lower() + if suffix is None or _rel_path.endswith(suffix): + yield rel_path + elif recursive and os.path.isdir(entry.path): + # scan recursively if entry.path is a directory + yield from _scandir(entry.path, suffix, recursive, case_sensitive) + + return _scandir(dir_path, suffix, recursive, case_sensitive) + + +def find_vcs_root(path, markers=(".git",)): + """Finds the root directory (including itself) of specified markers. + + Args: + path (str): Path of directory or file. + markers (list[str], optional): List of file or directory names. + + Returns: + The directory contained one of the markers or None if not found. + """ + if osp.isfile(path): + path = osp.dirname(path) + + prev, cur = None, osp.abspath(osp.expanduser(path)) + while cur != prev: + if any(osp.exists(osp.join(cur, marker)) for marker in markers): + return cur + prev, cur = cur, osp.split(cur)[0] + return None diff --git a/services/audio2exp-service/LAM_Audio2Expression/utils/registry.py b/services/audio2exp-service/LAM_Audio2Expression/utils/registry.py new file mode 100644 index 0000000..bd0e55c --- /dev/null +++ b/services/audio2exp-service/LAM_Audio2Expression/utils/registry.py @@ -0,0 +1,318 @@ +""" +The code is base on https://github.com/Pointcept/Pointcept +""" +import inspect +import warnings +from functools import partial + +from .misc import is_seq_of + + +def build_from_cfg(cfg, registry, default_args=None): + """Build a module from configs dict. + + Args: + cfg (dict): Config dict. It should at least contain the key "type". + registry (:obj:`Registry`): The registry to search the type from. + default_args (dict, optional): Default initialization arguments. + + Returns: + object: The constructed object. + """ + if not isinstance(cfg, dict): + raise TypeError(f"cfg must be a dict, but got {type(cfg)}") + if "type" not in cfg: + if default_args is None or "type" not in default_args: + raise KeyError( + '`cfg` or `default_args` must contain the key "type", ' + f"but got {cfg}\n{default_args}" + ) + if not isinstance(registry, Registry): + raise TypeError( + "registry must be an mmcv.Registry object, " f"but got {type(registry)}" + ) + if not (isinstance(default_args, dict) or default_args is None): + raise TypeError( + "default_args must be a dict or None, " f"but got {type(default_args)}" + ) + + args = cfg.copy() + + if default_args is not None: + for name, value in default_args.items(): + args.setdefault(name, value) + + obj_type = args.pop("type") + if isinstance(obj_type, str): + obj_cls = registry.get(obj_type) + if obj_cls is None: + raise KeyError(f"{obj_type} is not in the {registry.name} registry") + elif inspect.isclass(obj_type): + obj_cls = obj_type + else: + raise TypeError(f"type must be a str or valid type, but got {type(obj_type)}") + try: + return obj_cls(**args) + except Exception as e: + # Normal TypeError does not print class name. + raise type(e)(f"{obj_cls.__name__}: {e}") + + +class Registry: + """A registry to map strings to classes. + + Registered object could be built from registry. + Example: + >>> MODELS = Registry('models') + >>> @MODELS.register_module() + >>> class ResNet: + >>> pass + >>> resnet = MODELS.build(dict(type='ResNet')) + + Please refer to + https://mmcv.readthedocs.io/en/latest/understand_mmcv/registry.html for + advanced usage. + + Args: + name (str): Registry name. + build_func(func, optional): Build function to construct instance from + Registry, func:`build_from_cfg` is used if neither ``parent`` or + ``build_func`` is specified. If ``parent`` is specified and + ``build_func`` is not given, ``build_func`` will be inherited + from ``parent``. Default: None. + parent (Registry, optional): Parent registry. The class registered in + children registry could be built from parent. Default: None. + scope (str, optional): The scope of registry. It is the key to search + for children registry. If not specified, scope will be the name of + the package where class is defined, e.g. mmdet, mmcls, mmseg. + Default: None. + """ + + def __init__(self, name, build_func=None, parent=None, scope=None): + self._name = name + self._module_dict = dict() + self._children = dict() + self._scope = self.infer_scope() if scope is None else scope + + # self.build_func will be set with the following priority: + # 1. build_func + # 2. parent.build_func + # 3. build_from_cfg + if build_func is None: + if parent is not None: + self.build_func = parent.build_func + else: + self.build_func = build_from_cfg + else: + self.build_func = build_func + if parent is not None: + assert isinstance(parent, Registry) + parent._add_children(self) + self.parent = parent + else: + self.parent = None + + def __len__(self): + return len(self._module_dict) + + def __contains__(self, key): + return self.get(key) is not None + + def __repr__(self): + format_str = ( + self.__class__.__name__ + f"(name={self._name}, " + f"items={self._module_dict})" + ) + return format_str + + @staticmethod + def infer_scope(): + """Infer the scope of registry. + + The name of the package where registry is defined will be returned. + + Example: + # in mmdet/models/backbone/resnet.py + >>> MODELS = Registry('models') + >>> @MODELS.register_module() + >>> class ResNet: + >>> pass + The scope of ``ResNet`` will be ``mmdet``. + + + Returns: + scope (str): The inferred scope name. + """ + # inspect.stack() trace where this function is called, the index-2 + # indicates the frame where `infer_scope()` is called + filename = inspect.getmodule(inspect.stack()[2][0]).__name__ + split_filename = filename.split(".") + return split_filename[0] + + @staticmethod + def split_scope_key(key): + """Split scope and key. + + The first scope will be split from key. + + Examples: + >>> Registry.split_scope_key('mmdet.ResNet') + 'mmdet', 'ResNet' + >>> Registry.split_scope_key('ResNet') + None, 'ResNet' + + Return: + scope (str, None): The first scope. + key (str): The remaining key. + """ + split_index = key.find(".") + if split_index != -1: + return key[:split_index], key[split_index + 1 :] + else: + return None, key + + @property + def name(self): + return self._name + + @property + def scope(self): + return self._scope + + @property + def module_dict(self): + return self._module_dict + + @property + def children(self): + return self._children + + def get(self, key): + """Get the registry record. + + Args: + key (str): The class name in string format. + + Returns: + class: The corresponding class. + """ + scope, real_key = self.split_scope_key(key) + if scope is None or scope == self._scope: + # get from self + if real_key in self._module_dict: + return self._module_dict[real_key] + else: + # get from self._children + if scope in self._children: + return self._children[scope].get(real_key) + else: + # goto root + parent = self.parent + while parent.parent is not None: + parent = parent.parent + return parent.get(key) + + def build(self, *args, **kwargs): + return self.build_func(*args, **kwargs, registry=self) + + def _add_children(self, registry): + """Add children for a registry. + + The ``registry`` will be added as children based on its scope. + The parent registry could build objects from children registry. + + Example: + >>> models = Registry('models') + >>> mmdet_models = Registry('models', parent=models) + >>> @mmdet_models.register_module() + >>> class ResNet: + >>> pass + >>> resnet = models.build(dict(type='mmdet.ResNet')) + """ + + assert isinstance(registry, Registry) + assert registry.scope is not None + assert ( + registry.scope not in self.children + ), f"scope {registry.scope} exists in {self.name} registry" + self.children[registry.scope] = registry + + def _register_module(self, module_class, module_name=None, force=False): + if not inspect.isclass(module_class): + raise TypeError("module must be a class, " f"but got {type(module_class)}") + + if module_name is None: + module_name = module_class.__name__ + if isinstance(module_name, str): + module_name = [module_name] + for name in module_name: + if not force and name in self._module_dict: + raise KeyError(f"{name} is already registered " f"in {self.name}") + self._module_dict[name] = module_class + + def deprecated_register_module(self, cls=None, force=False): + warnings.warn( + "The old API of register_module(module, force=False) " + "is deprecated and will be removed, please use the new API " + "register_module(name=None, force=False, module=None) instead." + ) + if cls is None: + return partial(self.deprecated_register_module, force=force) + self._register_module(cls, force=force) + return cls + + def register_module(self, name=None, force=False, module=None): + """Register a module. + + A record will be added to `self._module_dict`, whose key is the class + name or the specified name, and value is the class itself. + It can be used as a decorator or a normal function. + + Example: + >>> backbones = Registry('backbone') + >>> @backbones.register_module() + >>> class ResNet: + >>> pass + + >>> backbones = Registry('backbone') + >>> @backbones.register_module(name='mnet') + >>> class MobileNet: + >>> pass + + >>> backbones = Registry('backbone') + >>> class ResNet: + >>> pass + >>> backbones.register_module(ResNet) + + Args: + name (str | None): The module name to be registered. If not + specified, the class name will be used. + force (bool, optional): Whether to override an existing class with + the same name. Default: False. + module (type): Module class to be registered. + """ + if not isinstance(force, bool): + raise TypeError(f"force must be a boolean, but got {type(force)}") + # NOTE: This is a walkaround to be compatible with the old api, + # while it may introduce unexpected bugs. + if isinstance(name, type): + return self.deprecated_register_module(name, force=force) + + # raise the error ahead of time + if not (name is None or isinstance(name, str) or is_seq_of(name, str)): + raise TypeError( + "name must be either of None, an instance of str or a sequence" + f" of str, but got {type(name)}" + ) + + # use it as a normal method: x.register_module(module=SomeClass) + if module is not None: + self._register_module(module_class=module, module_name=name, force=force) + return module + + # use it as a decorator: @x.register_module() + def _register(cls): + self._register_module(module_class=cls, module_name=name, force=force) + return cls + + return _register diff --git a/services/audio2exp-service/LAM_Audio2Expression/utils/scheduler.py b/services/audio2exp-service/LAM_Audio2Expression/utils/scheduler.py new file mode 100644 index 0000000..bb31459 --- /dev/null +++ b/services/audio2exp-service/LAM_Audio2Expression/utils/scheduler.py @@ -0,0 +1,144 @@ +""" +The code is base on https://github.com/Pointcept/Pointcept +""" + +import torch.optim.lr_scheduler as lr_scheduler +from .registry import Registry + +SCHEDULERS = Registry("schedulers") + + +@SCHEDULERS.register_module() +class MultiStepLR(lr_scheduler.MultiStepLR): + def __init__( + self, + optimizer, + milestones, + total_steps, + gamma=0.1, + last_epoch=-1, + verbose=False, + ): + super().__init__( + optimizer=optimizer, + milestones=[rate * total_steps for rate in milestones], + gamma=gamma, + last_epoch=last_epoch, + verbose=verbose, + ) + + +@SCHEDULERS.register_module() +class MultiStepWithWarmupLR(lr_scheduler.LambdaLR): + def __init__( + self, + optimizer, + milestones, + total_steps, + gamma=0.1, + warmup_rate=0.05, + warmup_scale=1e-6, + last_epoch=-1, + verbose=False, + ): + milestones = [rate * total_steps for rate in milestones] + + def multi_step_with_warmup(s): + factor = 1.0 + for i in range(len(milestones)): + if s < milestones[i]: + break + factor *= gamma + + if s <= warmup_rate * total_steps: + warmup_coefficient = 1 - (1 - s / warmup_rate / total_steps) * ( + 1 - warmup_scale + ) + else: + warmup_coefficient = 1.0 + return warmup_coefficient * factor + + super().__init__( + optimizer=optimizer, + lr_lambda=multi_step_with_warmup, + last_epoch=last_epoch, + verbose=verbose, + ) + + +@SCHEDULERS.register_module() +class PolyLR(lr_scheduler.LambdaLR): + def __init__(self, optimizer, total_steps, power=0.9, last_epoch=-1, verbose=False): + super().__init__( + optimizer=optimizer, + lr_lambda=lambda s: (1 - s / (total_steps + 1)) ** power, + last_epoch=last_epoch, + verbose=verbose, + ) + + +@SCHEDULERS.register_module() +class ExpLR(lr_scheduler.LambdaLR): + def __init__(self, optimizer, total_steps, gamma=0.9, last_epoch=-1, verbose=False): + super().__init__( + optimizer=optimizer, + lr_lambda=lambda s: gamma ** (s / total_steps), + last_epoch=last_epoch, + verbose=verbose, + ) + + +@SCHEDULERS.register_module() +class CosineAnnealingLR(lr_scheduler.CosineAnnealingLR): + def __init__(self, optimizer, total_steps, eta_min=0, last_epoch=-1, verbose=False): + super().__init__( + optimizer=optimizer, + T_max=total_steps, + eta_min=eta_min, + last_epoch=last_epoch, + verbose=verbose, + ) + + +@SCHEDULERS.register_module() +class OneCycleLR(lr_scheduler.OneCycleLR): + r""" + torch.optim.lr_scheduler.OneCycleLR, Block total_steps + """ + + def __init__( + self, + optimizer, + max_lr, + total_steps=None, + pct_start=0.3, + anneal_strategy="cos", + cycle_momentum=True, + base_momentum=0.85, + max_momentum=0.95, + div_factor=25.0, + final_div_factor=1e4, + three_phase=False, + last_epoch=-1, + verbose=False, + ): + super().__init__( + optimizer=optimizer, + max_lr=max_lr, + total_steps=total_steps, + pct_start=pct_start, + anneal_strategy=anneal_strategy, + cycle_momentum=cycle_momentum, + base_momentum=base_momentum, + max_momentum=max_momentum, + div_factor=div_factor, + final_div_factor=final_div_factor, + three_phase=three_phase, + last_epoch=last_epoch, + verbose=verbose, + ) + + +def build_scheduler(cfg, optimizer): + cfg.optimizer = optimizer + return SCHEDULERS.build(cfg=cfg) diff --git a/services/audio2exp-service/LAM_Audio2Expression/utils/timer.py b/services/audio2exp-service/LAM_Audio2Expression/utils/timer.py new file mode 100644 index 0000000..7b7e9cb --- /dev/null +++ b/services/audio2exp-service/LAM_Audio2Expression/utils/timer.py @@ -0,0 +1,71 @@ +""" +The code is base on https://github.com/Pointcept/Pointcept +""" + +from time import perf_counter +from typing import Optional + + +class Timer: + """ + A timer which computes the time elapsed since the start/reset of the timer. + """ + + def __init__(self) -> None: + self.reset() + + def reset(self) -> None: + """ + Reset the timer. + """ + self._start = perf_counter() + self._paused: Optional[float] = None + self._total_paused = 0 + self._count_start = 1 + + def pause(self) -> None: + """ + Pause the timer. + """ + if self._paused is not None: + raise ValueError("Trying to pause a Timer that is already paused!") + self._paused = perf_counter() + + def is_paused(self) -> bool: + """ + Returns: + bool: whether the timer is currently paused + """ + return self._paused is not None + + def resume(self) -> None: + """ + Resume the timer. + """ + if self._paused is None: + raise ValueError("Trying to resume a Timer that is not paused!") + # pyre-fixme[58]: `-` is not supported for operand types `float` and + # `Optional[float]`. + self._total_paused += perf_counter() - self._paused + self._paused = None + self._count_start += 1 + + def seconds(self) -> float: + """ + Returns: + (float): the total number of seconds since the start/reset of the + timer, excluding the time when the timer is paused. + """ + if self._paused is not None: + end_time: float = self._paused # type: ignore + else: + end_time = perf_counter() + return end_time - self._start - self._total_paused + + def avg_seconds(self) -> float: + """ + Returns: + (float): the average number of seconds between every start/reset and + pause. + """ + return self.seconds() / self._count_start diff --git a/services/audio2exp-service/LAM_Audio2Expression/utils/visualization.py b/services/audio2exp-service/LAM_Audio2Expression/utils/visualization.py new file mode 100644 index 0000000..053cb64 --- /dev/null +++ b/services/audio2exp-service/LAM_Audio2Expression/utils/visualization.py @@ -0,0 +1,86 @@ +""" +The code is base on https://github.com/Pointcept/Pointcept +""" + +import os +import open3d as o3d +import numpy as np +import torch + + +def to_numpy(x): + if isinstance(x, torch.Tensor): + x = x.clone().detach().cpu().numpy() + assert isinstance(x, np.ndarray) + return x + + +def save_point_cloud(coord, color=None, file_path="pc.ply", logger=None): + os.makedirs(os.path.dirname(file_path), exist_ok=True) + coord = to_numpy(coord) + if color is not None: + color = to_numpy(color) + pcd = o3d.geometry.PointCloud() + pcd.points = o3d.utility.Vector3dVector(coord) + pcd.colors = o3d.utility.Vector3dVector( + np.ones_like(coord) if color is None else color + ) + o3d.io.write_point_cloud(file_path, pcd) + if logger is not None: + logger.info(f"Save Point Cloud to: {file_path}") + + +def save_bounding_boxes( + bboxes_corners, color=(1.0, 0.0, 0.0), file_path="bbox.ply", logger=None +): + bboxes_corners = to_numpy(bboxes_corners) + # point list + points = bboxes_corners.reshape(-1, 3) + # line list + box_lines = np.array( + [ + [0, 1], + [1, 2], + [2, 3], + [3, 0], + [4, 5], + [5, 6], + [6, 7], + [7, 0], + [0, 4], + [1, 5], + [2, 6], + [3, 7], + ] + ) + lines = [] + for i, _ in enumerate(bboxes_corners): + lines.append(box_lines + i * 8) + lines = np.concatenate(lines) + # color list + color = np.array([color for _ in range(len(lines))]) + # generate line set + line_set = o3d.geometry.LineSet() + line_set.points = o3d.utility.Vector3dVector(points) + line_set.lines = o3d.utility.Vector2iVector(lines) + line_set.colors = o3d.utility.Vector3dVector(color) + o3d.io.write_line_set(file_path, line_set) + + if logger is not None: + logger.info(f"Save Boxes to: {file_path}") + + +def save_lines( + points, lines, color=(1.0, 0.0, 0.0), file_path="lines.ply", logger=None +): + points = to_numpy(points) + lines = to_numpy(lines) + colors = np.array([color for _ in range(len(lines))]) + line_set = o3d.geometry.LineSet() + line_set.points = o3d.utility.Vector3dVector(points) + line_set.lines = o3d.utility.Vector2iVector(lines) + line_set.colors = o3d.utility.Vector3dVector(colors) + o3d.io.write_line_set(file_path, line_set) + + if logger is not None: + logger.info(f"Save Lines to: {file_path}") diff --git a/services/audio2exp-service/a2e_engine.py b/services/audio2exp-service/a2e_engine.py new file mode 100644 index 0000000..30d3a30 --- /dev/null +++ b/services/audio2exp-service/a2e_engine.py @@ -0,0 +1,600 @@ +""" +A2E (Audio2Expression) 推論エンジン + +LAM Audio2Expression INFER パイプラインを使って、 +音声から52次元ARKitブレンドシェイプを生成。 + +モデル構成: + - facebook/wav2vec2-base-960h: 音響特徴量抽出 (768次元) + - 3DAIGC/LAM_audio2exp: 表情デコーダー (768→52次元) + +優先順位: + 1. INFER パイプライン (LAM_Audio2Expression モジュール使用) + → 完全な A2E 推論 + ポストプロセッシング + 2. Wav2Vec2 エネルギーベースフォールバック + → モジュール未インストール時の近似生成 + +入出力: + Input: base64エンコードされた音声 (MP3/WAV/PCM) + Output: {names: [52 strings], frames: [[52 floats], ...], frame_rate: 30} +""" + +import base64 +import io +import logging +import os +import sys +import traceback +from pathlib import Path + +import numpy as np + +logger = logging.getLogger(__name__) + +# INFER パイプラインが使用する ARKit 52 ブレンドシェイプ名 +# (LAM_Audio2Expression/models/utils.py の ARKitBlendShape と同じ順序) +ARKIT_BLENDSHAPE_NAMES_INFER = [ + "browDownLeft", "browDownRight", "browInnerUp", "browOuterUpLeft", "browOuterUpRight", + "cheekPuff", "cheekSquintLeft", "cheekSquintRight", + "eyeBlinkLeft", "eyeBlinkRight", "eyeLookDownLeft", "eyeLookDownRight", + "eyeLookInLeft", "eyeLookInRight", "eyeLookOutLeft", "eyeLookOutRight", + "eyeLookUpLeft", "eyeLookUpRight", "eyeSquintLeft", "eyeSquintRight", + "eyeWideLeft", "eyeWideRight", + "jawForward", "jawLeft", "jawOpen", "jawRight", + "mouthClose", "mouthDimpleLeft", "mouthDimpleRight", "mouthFrownLeft", "mouthFrownRight", + "mouthFunnel", "mouthLeft", "mouthLowerDownLeft", "mouthLowerDownRight", + "mouthPressLeft", "mouthPressRight", "mouthPucker", "mouthRight", + "mouthRollLower", "mouthRollUpper", "mouthShrugLower", "mouthShrugUpper", + "mouthSmileLeft", "mouthSmileRight", "mouthStretchLeft", "mouthStretchRight", + "mouthUpperUpLeft", "mouthUpperUpRight", + "noseSneerLeft", "noseSneerRight", + "tongueOut", +] + +# フォールバック用の ARKit 名 (a2e_engine.py 独自の順序) +ARKIT_BLENDSHAPE_NAMES_FALLBACK = [ + "eyeBlinkLeft", "eyeLookDownLeft", "eyeLookInLeft", "eyeLookOutLeft", + "eyeLookUpLeft", "eyeSquintLeft", "eyeWideLeft", + "eyeBlinkRight", "eyeLookDownRight", "eyeLookInRight", "eyeLookOutRight", + "eyeLookUpRight", "eyeSquintRight", "eyeWideRight", + "jawForward", "jawLeft", "jawRight", "jawOpen", + "mouthClose", "mouthFunnel", "mouthPucker", "mouthLeft", "mouthRight", + "mouthSmileLeft", "mouthSmileRight", "mouthFrownLeft", "mouthFrownRight", + "mouthDimpleLeft", "mouthDimpleRight", "mouthStretchLeft", "mouthStretchRight", + "mouthRollLower", "mouthRollUpper", "mouthShrugLower", "mouthShrugUpper", + "mouthPressLeft", "mouthPressRight", "mouthLowerDownLeft", "mouthLowerDownRight", + "mouthUpperUpLeft", "mouthUpperUpRight", + "browDownLeft", "browDownRight", "browInnerUp", "browOuterUpLeft", "browOuterUpRight", + "cheekPuff", "cheekSquintLeft", "cheekSquintRight", + "noseSneerLeft", "noseSneerRight", + "tongueOut", +] + +# A2E出力のFPS +A2E_OUTPUT_FPS = 30 + +# INFER パイプライン用の入力サンプルレート +INFER_INPUT_SAMPLE_RATE = 16000 + + +class Audio2ExpressionEngine: + """A2E推論エンジン - INFER パイプライン優先、Wav2Vec2 フォールバック""" + + def __init__(self, model_dir: str = "./models", device: str = "auto"): + self.model_dir = Path(model_dir) + self._ready = False + self._use_infer = False # INFER パイプライン使用フラグ + self._infer = None # INFER パイプラインインスタンス + self._infer_context = None # ストリーミング推論のコンテキスト + + # デバイス決定 + import torch + if device == "auto": + self.device = "cuda" if torch.cuda.is_available() else "cpu" + else: + self.device = device + self.device_name = self.device + + logger.info(f"[A2E Engine] Device: {self.device}") + + self._initialize() + + def _initialize(self): + """エンジン初期化 - INFER パイプラインを優先的にロード""" + # 1. INFER パイプラインを試行 + if self._try_load_infer_pipeline(): + self._use_infer = True + self._ready = True + logger.info("[A2E Engine] Ready (INFER pipeline mode)") + return + + # 2. フォールバック: Wav2Vec2 のみ + logger.warning("[A2E Engine] INFER pipeline unavailable, loading Wav2Vec2 fallback") + self._load_wav2vec_fallback() + self._ready = True + logger.info("[A2E Engine] Ready (Wav2Vec2 fallback mode)") + + def _find_lam_module(self) -> str: + """LAM_Audio2Expression モジュールを探索して sys.path に追加""" + script_dir = Path(os.path.dirname(os.path.abspath(__file__))) + candidates = [ + # 環境変数で指定 + os.environ.get("LAM_A2E_PATH"), + # サービスディレクトリ直下 (Docker COPY) + str(script_dir / "LAM_Audio2Expression"), + # models ディレクトリ内 + str(self.model_dir / "LAM_Audio2Expression"), + str(self.model_dir / "LAM_audio2exp" / "LAM_Audio2Expression"), + # 親ディレクトリ + str(self.model_dir.parent / "LAM_Audio2Expression"), + ] + + for candidate in candidates: + if candidate and os.path.exists(candidate): + abs_path = os.path.abspath(candidate) + if abs_path not in sys.path: + sys.path.insert(0, abs_path) + logger.info(f"[A2E Engine] Found LAM_Audio2Expression: {abs_path}") + return abs_path + + return None + + def _find_checkpoint(self) -> str: + """ + A2E チェックポイントファイルを探索。 + + HuggingFace からダウンロードした LAM_audio2exp_streaming.tar は + gzip 圧縮の tar アーカイブで、中に pretrained_models/lam_audio2exp_streaming.tar + (これが実際の PyTorch チェックポイント) が入っている。 + 自動的に展開して内側のチェックポイントを返す。 + """ + import gzip + import tarfile + + model_dir = self.model_dir + + # 実際の PyTorch チェックポイント (展開済み) を優先検索 + search_patterns = [ + model_dir / "pretrained_models" / "lam_audio2exp_streaming.tar", + model_dir / "pretrained_models" / "LAM_audio2exp_streaming.tar", + model_dir / "lam_audio2exp_streaming.pth", + model_dir / "LAM_audio2exp_streaming.pth", + model_dir / "LAM_audio2exp" / "pretrained_models" / "lam_audio2exp_streaming.tar", + model_dir / "LAM_audio2exp" / "pretrained_models" / "LAM_audio2exp_streaming.tar", + ] + + for path in search_patterns: + if path.exists(): + return str(path) + + # 外側の gzip tar を見つけたら自動展開 + outer_candidates = [ + model_dir / "LAM_audio2exp_streaming.tar", + model_dir / "lam_audio2exp_streaming.tar", + ] + for outer_path in outer_candidates: + if outer_path.exists(): + try: + with tarfile.open(str(outer_path), "r:gz") as tf: + tf.extractall(path=str(model_dir)) + logger.info(f"[A2E Engine] Extracted {outer_path}") + # 展開後に内側のチェックポイントを探索 + inner = model_dir / "pretrained_models" / "lam_audio2exp_streaming.tar" + if inner.exists(): + return str(inner) + except Exception as e: + logger.warning(f"[A2E Engine] Failed to extract {outer_path}: {e}") + + # ワイルドカード検索 + tar_files = list(model_dir.rglob("*audio2exp*.tar")) + # 外側の gzip tar は除外 + tar_files = [f for f in tar_files if f.stat().st_size < 400_000_000] + if tar_files: + return str(tar_files[0]) + pth_files = list(model_dir.rglob("*audio2exp*.pth")) + if pth_files: + return str(pth_files[0]) + + return None + + def _find_wav2vec_dir(self) -> str: + """wav2vec2-base-960h モデルディレクトリを探索""" + candidates = [ + self.model_dir / "wav2vec2-base-960h", + ] + # GCS FUSE mount + mount_path = os.environ.get("MODEL_MOUNT_PATH", "/mnt/models") + model_subdir = os.environ.get("MODEL_SUBDIR", "audio2exp") + candidates.append(Path(mount_path) / model_subdir / "wav2vec2-base-960h") + + for path in candidates: + if path.exists() and (path / "config.json").exists(): + return str(path) + return None + + def _try_load_infer_pipeline(self) -> bool: + """ + INFER パイプラインのロードを試行。 + + old FastAPI app.py の実装をベースに: + 1. LAM_Audio2Expression モジュールを見つけて sys.path に追加 + 2. default_config_parser で streaming config をパース + 3. INFER.build() でモデルをビルド + 4. warmup 推論を実行 + """ + import torch + + # 1. LAM_Audio2Expression モジュールを探索 + lam_path = self._find_lam_module() + if not lam_path: + logger.warning("[A2E Engine] LAM_Audio2Expression module not found") + return False + + # 2. チェックポイントを探索 + checkpoint_path = self._find_checkpoint() + if not checkpoint_path: + logger.warning("[A2E Engine] No A2E checkpoint found") + return False + + # 3. wav2vec2 ディレクトリを探索 + wav2vec_dir = self._find_wav2vec_dir() + if not wav2vec_dir: + logger.warning("[A2E Engine] wav2vec2-base-960h not found locally") + # HuggingFace からダウンロードさせるためにデフォルト値を使用 + wav2vec_dir = "facebook/wav2vec2-base-960h" + + logger.info(f"[A2E Engine] Checkpoint: {checkpoint_path}") + logger.info(f"[A2E Engine] Wav2Vec2: {wav2vec_dir}") + + try: + from engines.defaults import default_config_parser + from engines.infer import INFER + + # DDP 環境変数 (single-process 用) + os.environ.setdefault("WORLD_SIZE", "1") + os.environ.setdefault("RANK", "0") + os.environ.setdefault("MASTER_ADDR", "localhost") + os.environ.setdefault("MASTER_PORT", "12345") + + # config ファイルのパス + config_file = os.path.join(lam_path, "configs", + "lam_audio2exp_config_streaming.py") + if not os.path.exists(config_file): + logger.warning(f"[A2E Engine] Config not found: {config_file}") + return False + + # save_path (ログ出力先 - /tmp に設定) + save_path = "/tmp/audio2exp_logs" + os.makedirs(save_path, exist_ok=True) + os.makedirs(os.path.join(save_path, "model"), exist_ok=True) + + # wav2vec2 config.json パスの解決 + if os.path.isdir(wav2vec_dir): + wav2vec_config = os.path.join(wav2vec_dir, "config.json") + else: + # HuggingFace ID の場合、LAM モジュール内蔵の config を使用 + wav2vec_config = os.path.join(lam_path, "configs", "wav2vec2_config.json") + + # cfg_options: config のオーバーライド + cfg_options = { + "weight": checkpoint_path, + "save_path": save_path, + "model": { + "backbone": { + "wav2vec2_config_path": wav2vec_config, + "pretrained_encoder_path": wav2vec_dir, + } + }, + "num_worker": 0, + "batch_size": 1, + } + + logger.info(f"[A2E Engine] Loading config: {config_file}") + cfg = default_config_parser(config_file, cfg_options) + + # default_setup() をスキップ (DDP 関連の処理は不要) + # 必要な設定を手動で設定 + cfg.device = torch.device(self.device) + cfg.num_worker = 0 + cfg.num_worker_per_gpu = 0 + cfg.batch_size_per_gpu = 1 + cfg.batch_size_val_per_gpu = 1 + cfg.batch_size_test_per_gpu = 1 + + logger.info("[A2E Engine] Building INFER model...") + self._infer = INFER.build(dict(type=cfg.infer.type, cfg=cfg)) + + # CPU + eval mode + device = torch.device(self.device) + self._infer.model.to(device) + self._infer.model.eval() + + # Warmup 推論 (タイムアウト付き、失敗しても致命的ではない) + logger.info("[A2E Engine] Running warmup inference (timeout=120s)...") + import threading as _thr + warmup_result = [None] # [None]=running, [True]=ok, [Exception]=fail + + def _warmup(): + try: + dummy_audio = np.zeros(INFER_INPUT_SAMPLE_RATE, dtype=np.float32) + self._infer.infer_streaming_audio( + audio=dummy_audio, ssr=INFER_INPUT_SAMPLE_RATE, context=None + ) + warmup_result[0] = True + except Exception as exc: + warmup_result[0] = exc + + t = _thr.Thread(target=_warmup, daemon=True) + t.start() + t.join(timeout=120) + if t.is_alive(): + logger.warning("[A2E Engine] Warmup timed out after 120s (non-fatal, inference may be slow on CPU)") + elif isinstance(warmup_result[0], Exception): + logger.warning(f"[A2E Engine] Warmup failed (non-fatal): {warmup_result[0]}") + else: + logger.info("[A2E Engine] Warmup succeeded") + + logger.info("[A2E Engine] INFER pipeline loaded successfully!") + return True + + except ImportError as e: + logger.warning(f"[A2E Engine] INFER import failed: {e}") + traceback.print_exc() + return False + except Exception as e: + logger.warning(f"[A2E Engine] INFER initialization failed: {e}") + traceback.print_exc() + return False + + def _load_wav2vec_fallback(self): + """Wav2Vec2 フォールバックモードのロード""" + import torch + from transformers import Wav2Vec2Model, Wav2Vec2Processor + + wav2vec_dir = self._find_wav2vec_dir() + if wav2vec_dir: + wav2vec_path = wav2vec_dir + logger.info(f"[A2E Engine] Loading Wav2Vec2 from local: {wav2vec_path}") + else: + wav2vec_path = "facebook/wav2vec2-base-960h" + logger.info(f"[A2E Engine] Loading Wav2Vec2 from HuggingFace: {wav2vec_path}") + + try: + self.wav2vec_processor = Wav2Vec2Processor.from_pretrained(wav2vec_path) + except Exception: + self.wav2vec_processor = Wav2Vec2Processor.from_pretrained( + "facebook/wav2vec2-base-960h" + ) + + self.wav2vec_model = Wav2Vec2Model.from_pretrained(wav2vec_path) + self.wav2vec_model.to(self.device) + self.wav2vec_model.eval() + logger.info("[A2E Engine] Wav2Vec2 loaded (fallback mode)") + + def is_ready(self) -> bool: + return self._ready + + def get_mode(self) -> str: + """現在の推論モードを返す""" + return "infer" if self._use_infer else "fallback" + + def process(self, audio_base64: str, audio_format: str = "mp3") -> dict: + """ + 音声を処理してブレンドシェイプ係数を生成 + + Args: + audio_base64: base64エンコードされた音声 + audio_format: 音声フォーマット (mp3, wav, pcm) + + Returns: + {names: [52 strings], frames: [[52 floats], ...], frame_rate: int} + """ + # 1. 音声デコード → PCM 16kHz + audio_pcm = self._decode_audio(audio_base64, audio_format) + duration = len(audio_pcm) / INFER_INPUT_SAMPLE_RATE + logger.info(f"[A2E Engine] Audio decoded: {duration:.2f}s at 16kHz") + + # 2. 推論実行 + if self._use_infer: + return self._process_with_infer(audio_pcm, duration) + else: + return self._process_with_fallback(audio_pcm, duration) + + def _process_with_infer(self, audio_pcm: np.ndarray, duration: float) -> dict: + """ + INFER パイプラインで推論。 + + infer_streaming_audio() を使用: + - 音声をチャンクに分割 + - チャンクごとに推論 (コンテキスト引き継ぎ) + - ポストプロセッシング込み (smooth_mouth, frame_blending, + savitzky_golay, symmetrize, eye_blinks) + """ + chunk_samples = INFER_INPUT_SAMPLE_RATE # 1秒チャンク + all_expressions = [] + context = None + + try: + for start in range(0, len(audio_pcm), chunk_samples): + end = min(start + chunk_samples, len(audio_pcm)) + chunk = audio_pcm[start:end] + + # 極端に短いチャンクはスキップ + if len(chunk) < INFER_INPUT_SAMPLE_RATE // 10: + continue + + result, context = self._infer.infer_streaming_audio( + audio=chunk, ssr=INFER_INPUT_SAMPLE_RATE, context=context + ) + expr = result.get("expression") + if expr is not None: + all_expressions.append(expr.astype(np.float32)) + + if not all_expressions: + logger.warning("[A2E Engine] INFER produced no expression data") + num_frames = max(1, int(duration * A2E_OUTPUT_FPS)) + expression = np.zeros((num_frames, 52), dtype=np.float32) + else: + expression = np.concatenate(all_expressions, axis=0) + + logger.info(f"[A2E Engine] INFER: {expression.shape[0]} frames, " + f"jawOpen range=[{expression[:, 24].min():.3f}, " + f"{expression[:, 24].max():.3f}]") # jawOpen = index 24 in INFER order + + # フレームリストに変換 + frames = [frame.tolist() for frame in expression] + + return { + "names": ARKIT_BLENDSHAPE_NAMES_INFER, + "frames": frames, + "frame_rate": A2E_OUTPUT_FPS, + } + + except Exception as e: + logger.error(f"[A2E Engine] INFER inference error: {e}") + traceback.print_exc() + # エラー時はフォールバック + logger.warning("[A2E Engine] Falling back to Wav2Vec2 for this request") + if hasattr(self, 'wav2vec_model'): + return self._process_with_fallback(audio_pcm, duration) + # Wav2Vec2 もない場合は空フレームを返す + num_frames = max(1, int(duration * A2E_OUTPUT_FPS)) + return { + "names": ARKIT_BLENDSHAPE_NAMES_INFER, + "frames": [np.zeros(52).tolist()] * num_frames, + "frame_rate": A2E_OUTPUT_FPS, + } + + def _process_with_fallback(self, audio_pcm: np.ndarray, duration: float) -> dict: + """Wav2Vec2 フォールバックで推論""" + import torch + + inputs = self.wav2vec_processor( + audio_pcm, sampling_rate=16000, return_tensors="pt", padding=True + ) + input_values = inputs.input_values.to(self.device) + + with torch.no_grad(): + outputs = self.wav2vec_model(input_values) + features = outputs.last_hidden_state # (1, T, 768) + + logger.info(f"[A2E Engine] Wav2Vec2 features: {tuple(features.shape)}") + + blendshapes = self._wav2vec_to_blendshapes_fallback(features, duration) + frames = self._resample_to_fps(blendshapes, duration, A2E_OUTPUT_FPS) + + return { + "names": ARKIT_BLENDSHAPE_NAMES_FALLBACK, + "frames": frames, + "frame_rate": A2E_OUTPUT_FPS, + } + + def _decode_audio(self, audio_base64: str, audio_format: str) -> np.ndarray: + """base64音声をPCM float32 16kHzにデコード""" + audio_bytes = base64.b64decode(audio_base64) + + if audio_format in ("mp3", "wav", "ogg", "flac"): + from pydub import AudioSegment + audio = AudioSegment.from_file(io.BytesIO(audio_bytes), format=audio_format) + audio = audio.set_frame_rate(16000).set_channels(1).set_sample_width(2) + samples = np.array(audio.get_array_of_samples(), dtype=np.float32) + samples = samples / 32768.0 + elif audio_format == "pcm": + samples = np.frombuffer(audio_bytes, dtype=np.int16).astype(np.float32) + samples = samples / 32768.0 + else: + raise ValueError(f"Unsupported audio format: {audio_format}") + + return samples + + def _wav2vec_to_blendshapes_fallback( + self, features, duration: float + ) -> np.ndarray: + """ + A2Eデコーダーがない場合のフォールバック: + Wav2Vec2の特徴量からリップシンク関連のブレンドシェイプを近似生成。 + """ + features_np = features.squeeze(0).cpu().numpy() # (T, 768) + n_frames = features_np.shape[0] + + blendshapes = np.zeros((n_frames, 52), dtype=np.float32) + + low_energy = np.abs(features_np[:, :256]).mean(axis=1) + mid_energy = np.abs(features_np[:, 256:512]).mean(axis=1) + high_energy = np.abs(features_np[:, 512:]).mean(axis=1) + + def normalize(x): + x_min = x.min() + x_max = x.max() + if x_max - x_min < 1e-6: + return np.zeros_like(x) + return (x - x_min) / (x_max - x_min) + + low_norm = normalize(low_energy) + mid_norm = normalize(mid_energy) + high_norm = normalize(high_energy) + speech_activity = normalize(low_energy + mid_energy + high_energy) + + idx = {name: i for i, name in enumerate(ARKIT_BLENDSHAPE_NAMES_FALLBACK)} + + # リップシンク + blendshapes[:, idx["jawOpen"]] = np.clip(low_norm * 0.8, 0, 1) + blendshapes[:, idx["mouthClose"]] = np.clip(1.0 - low_norm * 0.8, 0, 1) * speech_activity + funnel = np.clip(mid_norm * 0.5 - low_norm * 0.2, 0, 1) + blendshapes[:, idx["mouthFunnel"]] = funnel + blendshapes[:, idx["mouthPucker"]] = np.clip(funnel * 0.7, 0, 1) + smile = np.clip(high_norm * 0.4 - mid_norm * 0.1, 0, 1) + blendshapes[:, idx["mouthSmileLeft"]] = smile + blendshapes[:, idx["mouthSmileRight"]] = smile + lower_down = np.clip(low_norm * 0.5, 0, 1) + blendshapes[:, idx["mouthLowerDownLeft"]] = lower_down + blendshapes[:, idx["mouthLowerDownRight"]] = lower_down + upper_up = np.clip(low_norm * 0.3, 0, 1) + blendshapes[:, idx["mouthUpperUpLeft"]] = upper_up + blendshapes[:, idx["mouthUpperUpRight"]] = upper_up + stretch = np.clip((mid_norm + high_norm) * 0.25, 0, 1) + blendshapes[:, idx["mouthStretchLeft"]] = stretch + blendshapes[:, idx["mouthStretchRight"]] = stretch + + # 非リップ関連 + blendshapes[:, idx["browInnerUp"]] = np.clip(speech_activity * 0.15, 0, 1) + blendshapes[:, idx["cheekSquintLeft"]] = smile * 0.3 + blendshapes[:, idx["cheekSquintRight"]] = smile * 0.3 + nose = np.clip(speech_activity * 0.1, 0, 1) + blendshapes[:, idx["noseSneerLeft"]] = nose + blendshapes[:, idx["noseSneerRight"]] = nose + + # 無音フレームは抑制 + silence_mask = speech_activity < 0.1 + blendshapes[silence_mask] *= 0.1 + + # スムージング + if n_frames > 3: + kernel = np.ones(3) / 3 + for i in range(52): + blendshapes[:, i] = np.convolve(blendshapes[:, i], kernel, mode='same') + + logger.info(f"[A2E Engine] Fallback: {n_frames} frames, " + f"jawOpen=[{blendshapes[:, idx['jawOpen']].min():.3f}, " + f"{blendshapes[:, idx['jawOpen']].max():.3f}]") + + return blendshapes + + def _resample_to_fps( + self, blendshapes: np.ndarray, duration: float, target_fps: int + ) -> list: + """ブレンドシェイプを目標FPSにリサンプリング""" + n_source = blendshapes.shape[0] + n_target = max(1, int(duration * target_fps)) + + if n_source == n_target: + frames = blendshapes + else: + source_indices = np.linspace(0, n_source - 1, n_target) + frames = np.zeros((n_target, 52), dtype=np.float32) + for i in range(52): + frames[:, i] = np.interp( + source_indices, np.arange(n_source), blendshapes[:, i] + ) + + return [frame.tolist() for frame in frames] diff --git a/services/audio2exp-service/app.py b/services/audio2exp-service/app.py new file mode 100644 index 0000000..ea8da0b --- /dev/null +++ b/services/audio2exp-service/app.py @@ -0,0 +1,144 @@ +""" +Audio2Expression マイクロサービス + +gourmet-support バックエンドから呼び出される A2E 推論サービス。 +MP3音声を受け取り、52次元ARKitブレンドシェイプ係数を返す。 + +アーキテクチャ: + MP3 audio (base64) → PCM 16kHz → Wav2Vec2 → A2E Decoder → 52-dim ARKit blendshapes + +エンドポイント: + POST /api/audio2expression + GET /health + +環境変数: + MODEL_DIR: モデルディレクトリ (default: ./models) + PORT: サーバーポート (default: 8081) + DEVICE: cpu or cuda (default: auto) +""" + +import os +import time +import logging +import threading +from flask import Flask, request, jsonify +from flask_cors import CORS + +logging.basicConfig( + level=logging.INFO, + format='%(asctime)s [%(levelname)s] %(message)s' +) +logger = logging.getLogger(__name__) + +app = Flask(__name__) +CORS(app) + +# A2Eエンジンの遅延初期化 +# gunicorn が即座にポートをバインドできるよう、モデルロードはバックグラウンドで実行 +MODEL_DIR = os.getenv("MODEL_DIR", "./models") +DEVICE = os.getenv("DEVICE", "auto") + +engine = None +_engine_error = None +_engine_lock = threading.Lock() + + +def _load_engine(): + """バックグラウンドスレッドでエンジンをロード""" + global engine, _engine_error + try: + from a2e_engine import Audio2ExpressionEngine + logger.info(f"[Audio2Exp] Loading engine: model_dir={MODEL_DIR}, device={DEVICE}") + t0 = time.time() + eng = Audio2ExpressionEngine(model_dir=MODEL_DIR, device=DEVICE) + elapsed = time.time() - t0 + with _engine_lock: + engine = eng + logger.info(f"[Audio2Exp] Engine ready in {elapsed:.1f}s") + except Exception as e: + with _engine_lock: + _engine_error = str(e) + logger.error(f"[Audio2Exp] Engine failed to load: {e}", exc_info=True) + + +_loader_thread = threading.Thread(target=_load_engine, daemon=True) +_loader_thread.start() +logger.info("[Audio2Exp] Server started, engine loading in background...") + + +@app.route('/api/audio2expression', methods=['POST']) +def audio2expression(): + """ + 音声から表情係数を生成 + + Request JSON: + { + "audio_base64": "...", # base64エンコードされた音声データ + "session_id": "...", # セッションID (ログ用) + "is_start": true, # ストリームの開始フラグ + "is_final": true, # ストリームの終了フラグ + "audio_format": "mp3" # 音声フォーマット (mp3, wav, pcm) + } + + Response JSON: + { + "names": ["eyeBlinkLeft", ...], # 52個のARKitブレンドシェイプ名 + "frames": [[0.0, ...], ...], # フレームごとの52次元係数 + "frame_rate": 30 # フレームレート (fps) + } + """ + if engine is None: + msg = _engine_error or 'Engine is still loading, please retry shortly' + status = 500 if _engine_error else 503 + return jsonify({'error': msg}), status + + try: + data = request.json + audio_base64 = data.get('audio_base64', '') + session_id = data.get('session_id', 'unknown') + audio_format = data.get('audio_format', 'mp3') + + if not audio_base64: + return jsonify({'error': 'audio_base64 is required'}), 400 + + logger.info(f"[Audio2Exp] Processing: session={session_id}, " + f"format={audio_format}, size={len(audio_base64)} bytes") + + t0 = time.time() + result = engine.process(audio_base64, audio_format=audio_format) + elapsed = time.time() - t0 + + frame_count = len(result.get('frames', [])) + logger.info(f"[Audio2Exp] Done: {frame_count} frames in {elapsed:.2f}s, " + f"session={session_id}") + + return jsonify(result) + + except Exception as e: + logger.error(f"[Audio2Exp] Error: {e}", exc_info=True) + return jsonify({'error': str(e)}), 500 + + +@app.route('/health', methods=['GET']) +def health(): + """ヘルスチェック - エンジンロード中でも200を返す(Cloud Run起動判定用)""" + if engine is None: + return jsonify({ + 'status': 'loading', + 'engine_ready': False, + 'error': _engine_error, + 'model_dir': MODEL_DIR + }) + return jsonify({ + 'status': 'healthy', + 'engine_ready': engine.is_ready(), + 'mode': engine.get_mode(), + 'device': engine.device_name, + 'model_dir': MODEL_DIR + }) + + +if __name__ == '__main__': + port = int(os.getenv('PORT', 8080)) + logger.info(f"[Audio2Exp] Starting on port {port}") + app.run(host='0.0.0.0', port=port, debug=False, load_dotenv=False) diff --git a/services/audio2exp-service/requirements.txt b/services/audio2exp-service/requirements.txt new file mode 100644 index 0000000..677c5dc --- /dev/null +++ b/services/audio2exp-service/requirements.txt @@ -0,0 +1,12 @@ +flask>=3.0.0 +flask-cors>=4.0.0 +gunicorn>=21.2.0 +numpy>=1.24.0 +transformers>=4.30.0 +pydub>=0.25.1 +# LAM_Audio2Expression INFER pipeline dependencies +librosa>=0.10.0 +scipy>=1.10.0 +addict>=2.4.0 +yapf>=0.40.0 +termcolor>=2.0.0 diff --git a/services/audio2exp-service/start.sh b/services/audio2exp-service/start.sh new file mode 100755 index 0000000..ea7d0cb --- /dev/null +++ b/services/audio2exp-service/start.sh @@ -0,0 +1,6 @@ +#!/bin/bash +set -e +echo "[Startup] Starting Audio2Expression service..." +echo "[Startup] Checking FUSE mount contents:" +ls -l /mnt/models/audio2exp/ || echo "[Startup] WARNING: FUSE mount not available" +exec gunicorn app:app --bind 0.0.0.0:${PORT:-8080} --timeout 120 --workers 1 --threads 4 diff --git a/services/frontend-patches/FRONTEND_INTEGRATION.md b/services/frontend-patches/FRONTEND_INTEGRATION.md new file mode 100644 index 0000000..13073a3 --- /dev/null +++ b/services/frontend-patches/FRONTEND_INTEGRATION.md @@ -0,0 +1,146 @@ +# フロントエンド A2E 統合ガイド + +## 概要 + +gourmet-support の `concierge-controller.ts` を修正して、 +バックエンドから返却される A2E expression データを使った +高精度リップシンクを実現する。 + +## 変更対象ファイル + +### 1. 新規ファイル追加 +``` +src/scripts/avatar/vrm-expression-manager.ts ← このディレクトリにコピー +``` + +### 2. concierge-controller.ts の変更 + +#### 2a. インポート追加 (ファイル先頭) +```typescript +import { ExpressionManager, ExpressionData } from '../avatar/vrm-expression-manager'; +``` + +#### 2b. プロパティ追加 (class ConciergeController内) +```typescript +private expressionManager: ExpressionManager | null = null; +``` + +#### 2c. init() メソッド内、GVRM初期化後に追加 +```typescript +// ★追加: ExpressionManager初期化 +if (this.guavaRenderer) { + this.expressionManager = new ExpressionManager(this.guavaRenderer); +} +``` + +#### 2d. TTS API呼び出し時に session_id を追加 + +**すべての `/api/tts/synthesize` リクエストに `session_id` を追加する。** + +変更前: +```typescript +body: JSON.stringify({ + text: cleanText, + language_code: langConfig.tts, + voice_name: langConfig.voice +}) +``` + +変更後: +```typescript +body: JSON.stringify({ + text: cleanText, + language_code: langConfig.tts, + voice_name: langConfig.voice, + session_id: this.sessionId // ★追加 +}) +``` + +#### 2e. TTS再生時にexpressionデータを使う + +音声再生ロジックを拡張して、expressionデータがある場合はExpressionManagerで再生する。 + +```typescript +// TTS APIレスポンス取得後 +const result = await response.json(); +if (result.success && result.audio) { + const audioSrc = `data:audio/mp3;base64,${result.audio}`; + + // ★ A2E expression データがある場合、ExpressionManagerで再生 + if (result.expression && ExpressionManager.isValid(result.expression) && this.expressionManager) { + // FFTベースのリップシンクではなく、A2Eベースを使用 + this.ttsPlayer.src = audioSrc; + + // ExpressionManagerで同期再生 + this.expressionManager.playExpressionFrames(result.expression, this.ttsPlayer); + + await new Promise((resolve) => { + this.ttsPlayer.onended = () => { + this.expressionManager?.stop(); + resolve(); + }; + this.ttsPlayer.play(); + }); + } else { + // フォールバック: 従来のFFTベースリップシンク + this.ttsPlayer.src = audioSrc; + this.setupAudioAnalysis(); + this.startLipSyncLoop(); + await new Promise((resolve) => { + this.ttsPlayer.onended = () => resolve(); + this.ttsPlayer.play(); + }); + } +} +``` + +#### 2f. stopAvatarAnimation() の修正 + +```typescript +private stopAvatarAnimation() { + if (this.els.avatarContainer) { + this.els.avatarContainer.classList.remove('speaking'); + } + // ★ ExpressionManager停止 + this.expressionManager?.stop(); + // フォールバック用 + this.guavaRenderer?.updateLipSync(0); + if (this.animationFrameId) { + cancelAnimationFrame(this.animationFrameId); + this.animationFrameId = null; + } +} +``` + +## 動作フロー + +``` +1. ユーザーが音声/テキスト入力 +2. バックエンドに /api/chat 送信 +3. レスポンステキストを /api/tts/synthesize に送信(session_id付き) +4. バックエンド: + a. Google Cloud TTS で MP3 生成 + b. MP3 を audio2exp-service に送信 + c. 52次元 ARKit blendshape フレーム取得 + d. JSON: { audio, expression: {names, frames, frame_rate} } 返却 +5. フロントエンド: + a. expression データがあれば ExpressionManager で再生 + b. なければ従来の FFT ベースリップシンク(フォールバック) + c. ExpressionManager: 音声の currentTime に同期してフレーム選択 + d. フレームの jawOpen 等 → GVRM.updateLipSync() にマッピング +``` + +## テスト方法 + +### ローカルテスト +1. audio2exp-service を起動: `python app.py` (port 8081) +2. gourmet-support の環境変数: `AUDIO2EXP_SERVICE_URL=http://localhost:8081` +3. gourmet-support を起動: `python app_customer_support.py` +4. フロントエンドでコンシェルジュモードを開く +5. 日本語で話しかけ、リップシンクの品質を確認 + +### 品質確認ポイント +- [ ] 口の開閉タイミングが発話と合っているか +- [ ] 無音時に口が閉じるか +- [ ] 「あ」(jawOpen大) と「い」(mouthSmile) の区別があるか +- [ ] FFTベースよりも自然に見えるか diff --git a/services/frontend-patches/LAMAvatar.astro b/services/frontend-patches/LAMAvatar.astro new file mode 100644 index 0000000..407097c --- /dev/null +++ b/services/frontend-patches/LAMAvatar.astro @@ -0,0 +1,746 @@ +--- +// LAMAvatar.astro - LAM 3D Avatar Component using gaussian-splat-renderer-for-lam +// This component renders a 3D avatar using WebGL Gaussian Splatting +// With OpenAvatarChat WebSocket integration for real-time lip sync + +export interface Props { + avatarPath?: string; // Path to avatar .zip file + width?: string; + height?: string; + wsUrl?: string; // WebSocket URL for OpenAvatarChat backend + autoConnect?: boolean; // Auto-connect to WebSocket on load +} + +const { + avatarPath = '/avatar/concierge.zip', + width = '100%', + height = '100%', + wsUrl = '', + autoConnect = false +} = Astro.props; +--- + +
+
+
+
+

Loading 3D Avatar...

+
+
+ AI Avatar +
+
+ + + + diff --git a/services/frontend-patches/concierge-controller.ts b/services/frontend-patches/concierge-controller.ts new file mode 100644 index 0000000..7f13ad2 --- /dev/null +++ b/services/frontend-patches/concierge-controller.ts @@ -0,0 +1,1042 @@ + + +// src/scripts/chat/concierge-controller.ts +import { CoreController } from './core-controller'; +import { AudioManager } from './audio-manager'; + +declare const io: any; + +export class ConciergeController extends CoreController { + // Audio2Expression はバックエンドTTSエンドポイント経由で統合済み + private pendingAckPromise: Promise | null = null; + + constructor(container: HTMLElement, apiBase: string) { + super(container, apiBase); + + // ★コンシェルジュモード用のAudioManagerを6.5秒設定で再初期化2 + this.audioManager = new AudioManager(8000); + + // コンシェルジュモードに設定 + this.currentMode = 'concierge'; + this.init(); + } + + // 初期化プロセスをオーバーライド + protected async init() { + // 親クラスの初期化を実行 + await super.init(); + + // コンシェルジュ固有の要素とイベントを追加 + const query = (sel: string) => this.container.querySelector(sel) as HTMLElement; + this.els.avatarContainer = query('.avatar-container'); + this.els.avatarImage = query('#avatarImage') as HTMLImageElement; + this.els.modeSwitch = query('#modeSwitch') as HTMLInputElement; + + // モードスイッチのイベントリスナー追加 + if (this.els.modeSwitch) { + this.els.modeSwitch.addEventListener('change', () => { + this.toggleMode(); + }); + } + + // ★ LAMAvatar との統合: 外部TTSプレーヤーをリンク + // LAMAvatar が後から初期化される可能性があるため、即時 + 遅延リトライでリンク + let linked = false; + let linkAttempts = 0; + const linkTtsPlayer = () => { + if (linked) return true; + linkAttempts++; + const lam = (window as any).lamAvatarController; + if (lam && typeof lam.setExternalTtsPlayer === 'function') { + lam.setExternalTtsPlayer(this.ttsPlayer); + linked = true; + console.log(`[Concierge] TTS player linked with LAMAvatar (attempt #${linkAttempts})`); + return true; + } + console.log(`[Concierge] LAMAvatar not ready yet (attempt #${linkAttempts})`); + return false; + }; + if (!linkTtsPlayer()) { + // 遅延リトライ: 500ms, 1000ms, 2000ms, 4000ms + const retryDelays = [500, 1000, 2000, 4000]; + retryDelays.forEach((delay) => { + setTimeout(() => linkTtsPlayer(), delay); + }); + } + + // ★ 診断用: ブラウザコンソールから __testLipSync() で呼び出し可能 + (window as any).__testLipSync = () => this.runLipSyncDiagnostic(); + } + + /** + * レンダラー診断テスト + * ブラウザコンソールから __testLipSync() で実行 + * + * 日本語5母音(あいうえお)の既知blendshapeパターンを + * 無音音声と同期再生し、レンダラーが52次元データを正しく描画できるか判定する + * + * 判定基準: + * - あ: 口が大きく開く (jawOpen高) + * - い: 口角が横に広がる (mouthSmile高) + * - う: 口がすぼまる (mouthFunnel/Pucker高) + * - え: 口が横に広がり中程度に開く (mouthStretch高) + * - お: 口が丸くなる (mouthFunnel高 + jawOpen中) + * + * 結果: + * ✓ 5母音で明らかに異なる口形状 → レンダラーは52次元対応 + * ✗ jawの開閉しか見えない → レンダラーはjawOpen単次元のみ + */ + private runLipSyncDiagnostic(): void { + const lam = (window as any).lamAvatarController; + if (!lam) { + console.error('[DIAG] lamAvatarController not found'); + return; + } + + // 日本語5母音のARKitブレンドシェイプパターン + const base: { [k: string]: number } = {}; // 全て0で初期化 + const vowelPatterns: { [vowel: string]: { [k: string]: number } } = { + 'あ(a)': { jawOpen: 0.7, mouthLowerDownLeft: 0.5, mouthLowerDownRight: 0.5, mouthUpperUpLeft: 0.2, mouthUpperUpRight: 0.2 }, + 'い(i)': { jawOpen: 0.2, mouthSmileLeft: 0.6, mouthSmileRight: 0.6, mouthStretchLeft: 0.4, mouthStretchRight: 0.4 }, + 'う(u)': { jawOpen: 0.15, mouthFunnel: 0.6, mouthPucker: 0.5 }, + 'え(e)': { jawOpen: 0.4, mouthStretchLeft: 0.5, mouthStretchRight: 0.5, mouthSmileLeft: 0.3, mouthSmileRight: 0.3, mouthLowerDownLeft: 0.3, mouthLowerDownRight: 0.3 }, + 'お(o)': { jawOpen: 0.5, mouthFunnel: 0.5, mouthPucker: 0.3, mouthLowerDownLeft: 0.2, mouthLowerDownRight: 0.2 }, + }; + + // フレーム生成: neutral(15) → 各母音(20frames=0.67s) → neutral(15) + const frameRate = 30; + const frames: { [k: string]: number }[] = []; + const addFrames = (pattern: { [k: string]: number }, count: number, label?: string) => { + for (let i = 0; i < count; i++) { + frames.push({ ...base, ...pattern }); + } + if (label) console.log(`[DIAG] ${label}: frames ${frames.length - count}-${frames.length - 1}`); + }; + + addFrames(base, 15, 'neutral (start)'); + for (const [vowel, pattern] of Object.entries(vowelPatterns)) { + addFrames(pattern, 20, vowel); + } + addFrames(base, 15, 'neutral (end)'); + + const totalFrames = frames.length; + const durationSec = totalFrames / frameRate + 0.5; + + // 無音WAVを生成(ttsPlayer経由で再生して同期トリガー) + const sampleRate = 8000; + const numSamples = Math.floor(durationSec * sampleRate); + const wavBuf = new ArrayBuffer(44 + numSamples * 2); + const dv = new DataView(wavBuf); + const ws = (off: number, s: string) => { for (let i = 0; i < s.length; i++) dv.setUint8(off + i, s.charCodeAt(i)); }; + ws(0, 'RIFF'); + dv.setUint32(4, 36 + numSamples * 2, true); + ws(8, 'WAVE'); ws(12, 'fmt '); + dv.setUint32(16, 16, true); + dv.setUint16(20, 1, true); dv.setUint16(22, 1, true); + dv.setUint32(24, sampleRate, true); dv.setUint32(28, sampleRate * 2, true); + dv.setUint16(32, 2, true); dv.setUint16(34, 16, true); + ws(36, 'data'); + dv.setUint32(40, numSamples * 2, true); + + const wavUrl = URL.createObjectURL(new Blob([wavBuf], { type: 'audio/wav' })); + + // LAMAvatarにフレーム投入 + 再生 + lam.clearFrameBuffer(); + lam.queueExpressionFrames(frames, frameRate); + + this.ttsPlayer.src = wavUrl; + this.ttsPlayer.play().then(() => { + console.log(`[DIAG] ▶ Playing: ${totalFrames} frames, ${durationSec.toFixed(1)}s`); + console.log('[DIAG] 0.5s neutral → 0.67s あ → 0.67s い → 0.67s う → 0.67s え → 0.67s お → 0.5s neutral'); + console.log('[DIAG] ✓ 5母音で口形状が変われば → レンダラーは52次元blendshape対応'); + console.log('[DIAG] ✗ jawの開閉のみ → レンダラーはjawOpen単次元'); + }).catch((e: any) => { + console.error('[DIAG] Play failed:', e); + console.log('[DIAG] ユーザー操作後に再試行してください(autoplay制限)'); + }); + } + + // ======================================== + // 🎯 セッション初期化をオーバーライド(挨拶文を変更) + // ======================================== + protected async initializeSession() { + try { + if (this.sessionId) { + try { + await fetch(`${this.apiBase}/api/session/end`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ session_id: this.sessionId }) + }); + } catch (e) {} + } + + // ★ user_id を取得(親クラスのメソッドを使用) + const userId = this.getUserId(); + + const res = await fetch(`${this.apiBase}/api/session/start`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + user_info: { user_id: userId }, + language: this.currentLanguage, + mode: 'concierge' + }) + }); + const data = await res.json(); + this.sessionId = data.session_id; + + // リップシンク: バックエンドTTSエンドポイント経由で表情データ取得(追加接続不要) + + // ✅ バックエンドからの初回メッセージを使用(長期記憶対応) + const greetingText = data.initial_message || this.t('initialGreetingConcierge'); + this.addMessage('assistant', greetingText, null, true); + + const ackTexts = [ + this.t('ackConfirm'), this.t('ackSearch'), this.t('ackUnderstood'), + this.t('ackYes'), this.t('ttsIntro') + ]; + const langConfig = this.LANGUAGE_CODE_MAP[this.currentLanguage]; + + const ackPromises = ackTexts.map(async (text) => { + try { + const ackResponse = await fetch(`${this.apiBase}/api/tts/synthesize`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + text: text, language_code: langConfig.tts, voice_name: langConfig.voice, + session_id: this.sessionId + }) + }); + const ackData = await ackResponse.json(); + if (ackData.success && ackData.audio) { + this.preGeneratedAcks.set(text, ackData.audio); + } + } catch (_e) { } + }); + + await Promise.all([ + this.speakTextGCP(greetingText), + ...ackPromises + ]); + + this.els.userInput.disabled = false; + this.els.sendBtn.disabled = false; + this.els.micBtn.disabled = false; + this.els.speakerBtn.disabled = false; + this.els.speakerBtn.classList.remove('disabled'); + this.els.reservationBtn.classList.remove('visible'); + + } catch (e) { + console.error('[Session] Initialization error:', e); + } + } + + // ======================================== + // 🔧 Socket.IOの初期化をオーバーライド + // ======================================== + protected initSocket() { + // @ts-ignore + this.socket = io(this.apiBase || window.location.origin); + + this.socket.on('connect', () => { }); + + // ✅ コンシェルジュ版のhandleStreamingSTTCompleteを呼ぶように再登録 + this.socket.on('transcript', (data: any) => { + const { text, is_final } = data; + if (this.isAISpeaking) return; + if (is_final) { + this.handleStreamingSTTComplete(text); // ← オーバーライド版が呼ばれる + this.currentAISpeech = ""; + } else { + this.els.userInput.value = text; + } + }); + + this.socket.on('error', (data: any) => { + this.addMessage('system', `${this.t('sttError')} ${data.message}`); + if (this.isRecording) this.stopStreamingSTT(); + }); + } + + // コンシェルジュモード固有: アバターアニメーション制御 + 公式リップシンク + protected async speakTextGCP(text: string, stopPrevious: boolean = true, autoRestartMic: boolean = false, skipAudio: boolean = false) { + if (skipAudio || !this.isTTSEnabled || !text) return Promise.resolve(); + + if (stopPrevious) { + this.ttsPlayer.pause(); + } + + // アバターアニメーションを開始 + if (this.els.avatarContainer) { + this.els.avatarContainer.classList.add('speaking'); + } + + // ★ 公式同期: TTS音声をaudio2exp-serviceに送信して表情を生成 + const cleanText = this.stripMarkdown(text); + try { + this.isAISpeaking = true; + if (this.isRecording && (this.isIOS || this.isAndroid)) { + this.stopStreamingSTT(); + } + + this.els.voiceStatus.innerHTML = this.t('voiceStatusSynthesizing'); + this.els.voiceStatus.className = 'voice-status speaking'; + const langConfig = this.LANGUAGE_CODE_MAP[this.currentLanguage]; + + // TTS音声を取得 + const response = await fetch(`${this.apiBase}/api/tts/synthesize`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + text: cleanText, language_code: langConfig.tts, voice_name: langConfig.voice, + session_id: this.sessionId + }) + }); + const data = await response.json(); + + if (data.success && data.audio) { + // ★ TTS応答に同梱されたExpressionを即バッファ投入(遅延ゼロ) + if (data.expression) { + this.applyExpressionFromTts(data.expression); + } else { + console.warn(`[Concierge] TTS response has NO expression data (session=${this.sessionId})`); + } + this.ttsPlayer.src = `data:audio/mp3;base64,${data.audio}`; + const playPromise = new Promise((resolve) => { + this.ttsPlayer.onended = async () => { + this.els.voiceStatus.innerHTML = this.t('voiceStatusStopped'); + this.els.voiceStatus.className = 'voice-status stopped'; + this.isAISpeaking = false; + this.stopAvatarAnimation(); + if (autoRestartMic) { + if (!this.isRecording) { + try { await this.toggleRecording(); } catch (_error) { this.showMicPrompt(); } + } + } + resolve(); + }; + this.ttsPlayer.onerror = () => { + this.isAISpeaking = false; + this.stopAvatarAnimation(); + resolve(); + }; + }); + + if (this.isUserInteracted) { + this.lastAISpeech = this.normalizeText(cleanText); + await this.ttsPlayer.play(); + await playPromise; + } else { + this.showClickPrompt(); + this.els.voiceStatus.innerHTML = this.t('voiceStatusStopped'); + this.els.voiceStatus.className = 'voice-status stopped'; + this.isAISpeaking = false; + this.stopAvatarAnimation(); + } + } else { + this.isAISpeaking = false; + this.stopAvatarAnimation(); + } + } catch (_error) { + this.els.voiceStatus.innerHTML = this.t('voiceStatusStopped'); + this.els.voiceStatus.className = 'voice-status stopped'; + this.isAISpeaking = false; + this.stopAvatarAnimation(); + } + } + + // ★ 口周りblendshapeのスケール係数(ニュートラル設定) + // + // A2E出力をそのまま使用。SDKは全52チャンネルを morphTargetDictionary + // 経由で boneTexture に書き込み、シェーダーが全ch適用する。 + // 全値は BLENDSHAPE_SAFE_MAX(0.7) でクランプ(FLAME LBS 数値安定のため)。 + // + // チューニング時はここの値を調整する: + // 1.0 = 増幅なし(A2E出力そのまま) + // >1.0 = 増幅(口の動きを強調) + // <1.0 = 抑制(口の動きを控えめに) + private static readonly MOUTH_AMPLIFY: { [key: string]: number } = { + 'jawOpen': 1.0, + 'mouthClose': 1.0, + 'mouthFunnel': 1.0, + 'mouthPucker': 1.0, + 'mouthSmileLeft': 1.0, + 'mouthSmileRight': 1.0, + 'mouthStretchLeft': 1.0, + 'mouthStretchRight': 1.0, + 'mouthLowerDownLeft': 1.0, + 'mouthLowerDownRight': 1.0, + 'mouthUpperUpLeft': 1.0, + 'mouthUpperUpRight': 1.0, + 'mouthDimpleLeft': 1.0, + 'mouthDimpleRight': 1.0, + 'mouthRollLower': 1.0, + 'mouthRollUpper': 1.0, + 'mouthShrugLower': 1.0, + 'mouthShrugUpper': 1.0, + }; + + // FLAME LBS の安全範囲。これを超えるとメッシュが破綻(数値爆発)する + private static readonly BLENDSHAPE_SAFE_MAX = 0.7; + + /** + * TTS応答に同梱されたExpressionデータをバッファに即投入(遅延ゼロ) + * 同期方式: バックエンドがTTS+audio2expを同期実行し、結果を同梱して返す + * + * ★品質改善: + * 1. 口周りblendshapeの増幅 → 日本語母音の可視性向上 + * 2. フレーム補間 (30fps→60fps) → レンダラーの60fps描画に滑らかに追従 + * 3. 診断ログ → jawOpen/mouthFunnel等の統計値で品質を確認可能 + */ + private applyExpressionFromTts(expression: any): void { + const lamController = (window as any).lamAvatarController; + if (!lamController) { + console.warn('[Concierge] lamAvatarController not found - expression data dropped'); + return; + } + + // 新セグメント開始時は必ずバッファクリア(前セグメントのフレーム混入防止) + if (typeof lamController.clearFrameBuffer === 'function') { + lamController.clearFrameBuffer(); + } + + if (expression?.names && expression?.frames?.length > 0) { + const srcFrameRate = expression.frame_rate || 30; + + // Step 1: バックエンド形式 → LAMAvatar形式に変換 + blendshape増幅 + // ★ 新旧両フォーマット対応: + // 旧 (FastAPI): frames = [{"weights": [0.1, ...]}, ...] + // 新 (Flask): frames = [[0.1, ...], ...] + const rawFrames = expression.frames.map((f: any) => { + const frame: { [key: string]: number } = {}; + // フレームがArrayなら直接使用、objectなら.weightsから取得 + const values: number[] = Array.isArray(f) ? f : (f.weights || []); + expression.names.forEach((name: string, i: number) => { + let val = values[i] || 0; + // 口周りblendshapeをスケール + const amp = ConciergeController.MOUTH_AMPLIFY[name]; + if (amp && amp !== 1.0) { + val = val * amp; + } + // FLAME LBS 安全範囲でクランプ(>0.7 で数値不安定→メッシュ破綻) + val = Math.min(ConciergeController.BLENDSHAPE_SAFE_MAX, val); + frame[name] = val; + }); + return frame; + }); + + // Step 2: フレーム補間 (30fps → 60fps) — 線形補間で滑らかに + const interpolatedFrames: { [key: string]: number }[] = []; + for (let i = 0; i < rawFrames.length; i++) { + interpolatedFrames.push(rawFrames[i]); + if (i < rawFrames.length - 1) { + const curr = rawFrames[i]; + const next = rawFrames[i + 1]; + const mid: { [key: string]: number } = {}; + for (const key of Object.keys(curr)) { + mid[key] = (curr[key] + next[key]) * 0.5; + } + interpolatedFrames.push(mid); + } + } + const outputFrameRate = srcFrameRate * 2; // 30→60fps + + // Step 3: LAMAvatarにキュー投入 + lamController.queueExpressionFrames(interpolatedFrames, outputFrameRate); + + // Step 4: 診断ログ(blendshape統計値) + const stat = (key: string) => { + const vals = rawFrames.map((f: { [k: string]: number }) => f[key] || 0); + return { max: Math.max(...vals), avg: vals.reduce((a: number, b: number) => a + b, 0) / vals.length }; + }; + const jaw = stat('jawOpen'); + const lowerDown = stat('mouthLowerDownLeft'); + const funnel = stat('mouthFunnel'); + const pucker = stat('mouthPucker'); + const smile = stat('mouthSmileLeft'); + const stretch = stat('mouthStretchLeft'); + console.log(`[Concierge] Expression: ${rawFrames.length}→${interpolatedFrames.length} frames (${srcFrameRate}→${outputFrameRate}fps)`); + console.log(` jaw: max=${jaw.max.toFixed(3)} avg=${jaw.avg.toFixed(3)} | lowerDown: max=${lowerDown.max.toFixed(3)}`); + console.log(` funnel: max=${funnel.max.toFixed(3)} | pucker: max=${pucker.max.toFixed(3)} | smile: max=${smile.max.toFixed(3)} | stretch: max=${stretch.max.toFixed(3)}`); + } else { + console.warn(`[Concierge] No expression frames in TTS response (names=${!!expression?.names}, frames=${expression?.frames?.length || 0})`); + } + } + + // アバターアニメーション停止 + private stopAvatarAnimation() { + if (this.els.avatarContainer) { + this.els.avatarContainer.classList.remove('speaking'); + } + // ※ LAMAvatar の状態は ttsPlayer イベント(ended/pause)で管理 + } + + + // ======================================== + // 🎯 UI言語更新をオーバーライド(挨拶文をコンシェルジュ用に) + // ======================================== + protected updateUILanguage() { + // ✅ バックエンドからの長期記憶対応済み挨拶を保持 + const initialMessage = this.els.chatArea.querySelector('.message.assistant[data-initial="true"] .message-text'); + const savedGreeting = initialMessage?.textContent; + + // 親クラスのupdateUILanguageを実行(UIラベル等を更新) + super.updateUILanguage(); + + // ✅ 長期記憶対応済み挨拶を復元(親が上書きしたものを戻す) + if (initialMessage && savedGreeting) { + initialMessage.textContent = savedGreeting; + } + + // ✅ ページタイトルをコンシェルジュ用に設定 + const pageTitle = document.getElementById('pageTitle'); + if (pageTitle) { + pageTitle.innerHTML = ` ${this.t('pageTitleConcierge')}`; + } + } + + // モード切り替え処理 - ページ遷移 + private toggleMode() { + const isChecked = this.els.modeSwitch?.checked; + if (!isChecked) { + // チャットモードへページ遷移 + console.log('[ConciergeController] Switching to Chat mode...'); + window.location.href = '/'; + } + // コンシェルジュモードは既に現在のページなので何もしない + } + + // すべての活動を停止(アバターアニメーションも含む) + protected stopAllActivities() { + super.stopAllActivities(); + this.stopAvatarAnimation(); + } + + // ======================================== + // 🎯 並行処理フロー: 応答を分割してTTS処理 + // ======================================== + + /** + * センテンス単位でテキストを分割 + * 日本語: 。で分割 + * 英語・韓国語: . で分割 + * 中国語: 。で分割 + */ + private splitIntoSentences(text: string, language: string): string[] { + let separator: RegExp; + + if (language === 'ja' || language === 'zh') { + // 日本語・中国語: 。で分割 + separator = /。/; + } else { + // 英語・韓国語: . で分割 + separator = /\.\s+/; + } + + const sentences = text.split(separator).filter(s => s.trim().length > 0); + + // 分割したセンテンスに句点を戻す + return sentences.map((s, idx) => { + if (idx < sentences.length - 1 || text.endsWith('。') || text.endsWith('. ')) { + return language === 'ja' || language === 'zh' ? s + '。' : s + '. '; + } + return s; + }); + } + + /** + * 応答を分割して並行処理でTTS生成・再生 + * チャットモードのお店紹介フローを参考に実装 + */ + private async speakResponseInChunks(response: string, isTextInput: boolean = false) { + // TTS無効の場合はスキップ(テキスト入力でもコンシェルジュモードではTTS再生する) + if (!this.isTTSEnabled) { + return; + } + + try { + // ★ ack再生中ならttsPlayer解放を待つ(並行処理の同期ポイント) + if (this.pendingAckPromise) { + await this.pendingAckPromise; + this.pendingAckPromise = null; + } + this.stopCurrentAudio(); // ttsPlayer確実解放 + + this.isAISpeaking = true; + if (this.isRecording) { + this.stopStreamingSTT(); + } + + // センテンス分割 + const sentences = this.splitIntoSentences(response, this.currentLanguage); + + // 1センテンスしかない場合は従来通り(skipAudio=false: コンシェルジュでは常に再生) + if (sentences.length <= 1) { + await this.speakTextGCP(response, true, false, false); + this.isAISpeaking = false; + return; + } + + // 最初のセンテンスと残りのセンテンスに分割 + const firstSentence = sentences[0]; + const remainingSentences = sentences.slice(1).join(''); + + const langConfig = this.LANGUAGE_CODE_MAP[this.currentLanguage]; + + // ★並行処理: TTS生成と表情生成を同時に実行して遅延を最小化 + if (this.isUserInteracted) { + const cleanFirst = this.stripMarkdown(firstSentence); + const cleanRemaining = remainingSentences.trim().length > 0 + ? this.stripMarkdown(remainingSentences) : null; + + // ★ 4つのAPIコールを可能な限り並行で開始 + // 1. 最初のセンテンスTTS + const firstTtsPromise = fetch(`${this.apiBase}/api/tts/synthesize`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + text: cleanFirst, language_code: langConfig.tts, + voice_name: langConfig.voice, session_id: this.sessionId + }) + }).then(r => r.json()); + + // 2. 残りのセンテンスTTS(あれば) + const remainingTtsPromise = cleanRemaining + ? fetch(`${this.apiBase}/api/tts/synthesize`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + text: cleanRemaining, language_code: langConfig.tts, + voice_name: langConfig.voice, session_id: this.sessionId + }) + }).then(r => r.json()) + : null; + + // ★ 最初のTTSが返ったら即再生(Expression同梱済み) + const firstTtsResult = await firstTtsPromise; + if (firstTtsResult.success && firstTtsResult.audio) { + // ★ TTS応答に同梱されたExpressionを即バッファ投入(遅延ゼロ) + if (firstTtsResult.expression) this.applyExpressionFromTts(firstTtsResult.expression); + + this.lastAISpeech = this.normalizeText(cleanFirst); + this.stopCurrentAudio(); + this.ttsPlayer.src = `data:audio/mp3;base64,${firstTtsResult.audio}`; + + // 残りのTTS結果を先に取得(TTS応答にExpression同梱済み) + let remainingTtsResult: any = null; + if (remainingTtsPromise) { + remainingTtsResult = await remainingTtsPromise; + } + + // 最初のセンテンス再生 + await new Promise((resolve) => { + this.ttsPlayer.onended = () => { + this.els.voiceStatus.innerHTML = this.t('voiceStatusStopped'); + this.els.voiceStatus.className = 'voice-status stopped'; + resolve(); + }; + this.ttsPlayer.onerror = () => { + console.error('[TTS] First sentence play error'); + resolve(); + }; + this.els.voiceStatus.innerHTML = this.t('voiceStatusSpeaking'); + this.els.voiceStatus.className = 'voice-status speaking'; + this.ttsPlayer.play().catch((e: any) => { + console.error('[TTS] First sentence play() rejected:', e); + resolve(); + }); + }); + + // ★ 残りのセンテンスを続けて再生(Expression同梱済み) + if (remainingTtsResult?.success && remainingTtsResult?.audio) { + this.lastAISpeech = this.normalizeText(cleanRemaining || ''); + + // ★ TTS応答に同梱されたExpressionを即バッファ投入 + if (remainingTtsResult.expression) this.applyExpressionFromTts(remainingTtsResult.expression); + + this.stopCurrentAudio(); + this.ttsPlayer.src = `data:audio/mp3;base64,${remainingTtsResult.audio}`; + + await new Promise((resolve) => { + this.ttsPlayer.onended = () => { + this.els.voiceStatus.innerHTML = this.t('voiceStatusStopped'); + this.els.voiceStatus.className = 'voice-status stopped'; + resolve(); + }; + this.ttsPlayer.onerror = () => { + console.error('[TTS] Remaining sentence play error'); + resolve(); + }; + this.els.voiceStatus.innerHTML = this.t('voiceStatusSpeaking'); + this.els.voiceStatus.className = 'voice-status speaking'; + this.ttsPlayer.play().catch((e: any) => { + console.error('[TTS] Remaining sentence play() rejected:', e); + resolve(); + }); + }); + } + } + } + + this.isAISpeaking = false; + } catch (error) { + console.error('[TTS並行処理エラー]', error); + this.isAISpeaking = false; + // エラー時はフォールバック(skipAudio=false: コンシェルジュでは常に再生) + await this.speakTextGCP(response, true, false, false); + } + } + + // ======================================== + // 🎯 コンシェルジュモード専用: 音声入力完了時の即答処理 + // ======================================== + protected async handleStreamingSTTComplete(transcript: string) { + this.stopStreamingSTT(); + + if ('mediaSession' in navigator) { + try { navigator.mediaSession.playbackState = 'playing'; } catch (e) {} + } + + this.els.voiceStatus.innerHTML = this.t('voiceStatusComplete'); + this.els.voiceStatus.className = 'voice-status'; + + // オウム返し判定(エコーバック防止) + const normTranscript = this.normalizeText(transcript); + if (this.isSemanticEcho(normTranscript, this.lastAISpeech)) { + this.els.voiceStatus.innerHTML = this.t('voiceStatusStopped'); + this.els.voiceStatus.className = 'voice-status stopped'; + this.lastAISpeech = ''; + return; + } + + this.els.userInput.value = transcript; + this.addMessage('user', transcript); + + // 短すぎる入力チェック + const textLength = transcript.trim().replace(/\s+/g, '').length; + if (textLength < 2) { + const msg = this.t('shortMsgWarning'); + this.addMessage('assistant', msg); + if (this.isTTSEnabled && this.isUserInteracted) { + await this.speakTextGCP(msg, true); + } else { + await new Promise(r => setTimeout(r, 2000)); + } + this.els.userInput.value = ''; + this.els.voiceStatus.innerHTML = this.t('voiceStatusStopped'); + this.els.voiceStatus.className = 'voice-status stopped'; + return; + } + + // ✅ 修正: 即答を「はい」だけに簡略化 + const ackText = this.t('ackYes'); // 「はい」のみ + const preGeneratedAudio = this.preGeneratedAcks.get(ackText); + + // 即答を再生(ttsPlayerで) + if (preGeneratedAudio && this.isTTSEnabled && this.isUserInteracted) { + this.pendingAckPromise = new Promise((resolve) => { + this.lastAISpeech = this.normalizeText(ackText); + this.ttsPlayer.src = `data:audio/mp3;base64,${preGeneratedAudio}`; + let resolved = false; + const done = () => { if (!resolved) { resolved = true; resolve(); } }; + this.ttsPlayer.onended = done; + this.ttsPlayer.onpause = done; // ★ pause時もresolve(src変更やstop時のデッドロック防止) + this.ttsPlayer.play().catch(_e => done()); + }); + } else if (this.isTTSEnabled) { + this.pendingAckPromise = this.speakTextGCP(ackText, false); + } + + this.addMessage('assistant', ackText); + + // ★ 並行処理: ack再生完了を待たず、即LLMリクエスト開始(~700ms短縮) + // pendingAckPromiseはsendMessage内でTTS再生前にawaitされる + if (this.els.userInput.value.trim()) { + this.isFromVoiceInput = true; + this.sendMessage(); + } + + this.els.voiceStatus.innerHTML = this.t('voiceStatusStopped'); + this.els.voiceStatus.className = 'voice-status stopped'; + } + + // ======================================== + // 🎯 コンシェルジュモード専用: メッセージ送信処理 + // ======================================== + protected async sendMessage() { + let firstAckPromise: Promise | null = null; + // ★ voice入力時はunlockAudioParamsスキップ(ack再生中のttsPlayerを中断させない) + if (!this.pendingAckPromise) { + this.unlockAudioParams(); + } + const message = this.els.userInput.value.trim(); + if (!message || this.isProcessing) return; + + const currentSessionId = this.sessionId; + const isTextInput = !this.isFromVoiceInput; + + this.isProcessing = true; + this.els.sendBtn.disabled = true; + this.els.micBtn.disabled = true; + this.els.userInput.disabled = true; + + // ✅ テキスト入力時も「はい」だけに簡略化 + if (!this.isFromVoiceInput) { + this.addMessage('user', message); + const textLength = message.trim().replace(/\s+/g, '').length; + if (textLength < 2) { + const msg = this.t('shortMsgWarning'); + this.addMessage('assistant', msg); + if (this.isTTSEnabled && this.isUserInteracted) await this.speakTextGCP(msg, true); + this.resetInputState(); + return; + } + + this.els.userInput.value = ''; + + // ✅ 修正: 即答を「はい」だけに + const ackText = this.t('ackYes'); + this.currentAISpeech = ackText; + this.addMessage('assistant', ackText); + + if (this.isTTSEnabled && !isTextInput) { + try { + const preGeneratedAudio = this.preGeneratedAcks.get(ackText); + if (preGeneratedAudio && this.isUserInteracted) { + firstAckPromise = new Promise((resolve) => { + this.lastAISpeech = this.normalizeText(ackText); + this.ttsPlayer.src = `data:audio/mp3;base64,${preGeneratedAudio}`; + this.ttsPlayer.onended = () => resolve(); + this.ttsPlayer.play().catch(_e => resolve()); + }); + } else { + firstAckPromise = this.speakTextGCP(ackText, false); + } + } catch (_e) {} + } + if (firstAckPromise) await firstAckPromise; + + // ✅ 修正: オウム返しパターンを削除 + // (generateFallbackResponse, additionalResponse の呼び出しを削除) + } + + this.isFromVoiceInput = false; + + // ✅ 待機アニメーションは6.5秒後に表示(LLM送信直前にタイマースタート) + if (this.waitOverlayTimer) clearTimeout(this.waitOverlayTimer); + let responseReceived = false; + + // タイマーセットをtry直前に移動(即答処理の後) + this.waitOverlayTimer = window.setTimeout(() => { + if (!responseReceived) { + this.showWaitOverlay(); + } + }, 6500); + + try { + const response = await fetch(`${this.apiBase}/api/chat`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + session_id: currentSessionId, + message: message, + stage: this.currentStage, + language: this.currentLanguage, + mode: this.currentMode + }) + }); + const data = await response.json(); + + // ✅ レスポンス到着フラグを立てる + responseReceived = true; + + if (this.sessionId !== currentSessionId) return; + + // ✅ タイマーをクリアしてアニメーションを非表示 + if (this.waitOverlayTimer) { + clearTimeout(this.waitOverlayTimer); + this.waitOverlayTimer = null; + } + this.hideWaitOverlay(); + this.currentAISpeech = data.response; + this.addMessage('assistant', data.response, data.summary); + + if (this.isTTSEnabled) { + this.stopCurrentAudio(); + } + + if (data.shops && data.shops.length > 0) { + this.currentShops = data.shops; + this.els.reservationBtn.classList.add('visible'); + this.els.userInput.value = ''; + document.dispatchEvent(new CustomEvent('displayShops', { + detail: { shops: data.shops, language: this.currentLanguage } + })); + + const section = document.getElementById('shopListSection'); + if (section) section.classList.add('has-shops'); + if (window.innerWidth < 1024) { + setTimeout(() => { + const shopSection = document.getElementById('shopListSection'); + if (shopSection) shopSection.scrollIntoView({ behavior: 'smooth', block: 'start' }); + }, 300); + } + + (async () => { + try { + // ★ ack再生中ならttsPlayer解放を待つ(並行処理の同期ポイント) + if (this.pendingAckPromise) { + await this.pendingAckPromise; + this.pendingAckPromise = null; + } + this.stopCurrentAudio(); // ttsPlayer確実解放 + + this.isAISpeaking = true; + if (this.isRecording) { this.stopStreamingSTT(); } + + await this.speakTextGCP(this.t('ttsIntro'), true, false, false); + + const lines = data.response.split('\n\n'); + let introText = ""; + let shopLines = lines; + if (lines[0].includes('ご希望に合うお店') && lines[0].includes('ご紹介します')) { + introText = lines[0]; + shopLines = lines.slice(1); + } + + let introPart2Promise: Promise | null = null; + if (introText && this.isTTSEnabled && this.isUserInteracted && !isTextInput) { + const preGeneratedIntro = this.preGeneratedAcks.get(introText); + if (preGeneratedIntro) { + introPart2Promise = new Promise((resolve) => { + this.lastAISpeech = this.normalizeText(introText); + this.ttsPlayer.src = `data:audio/mp3;base64,${preGeneratedIntro}`; + this.ttsPlayer.onended = () => resolve(); + this.ttsPlayer.play(); + }); + } else { + introPart2Promise = this.speakTextGCP(introText, false, false, false); + } + } + + let firstShopTtsPromise: Promise | null = null; + let remainingShopTtsPromise: Promise | null = null; + const shopLangConfig = this.LANGUAGE_CODE_MAP[this.currentLanguage]; + + if (shopLines.length > 0 && this.isTTSEnabled && this.isUserInteracted) { + const firstShop = shopLines[0]; + const restShops = shopLines.slice(1).join('\n\n'); + + // ★ 1行目先行: 最初のショップと残りのTTSを並行開始 + firstShopTtsPromise = fetch(`${this.apiBase}/api/tts/synthesize`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + text: this.stripMarkdown(firstShop), language_code: shopLangConfig.tts, + voice_name: shopLangConfig.voice, session_id: this.sessionId + }) + }).then(r => r.json()); + + if (restShops) { + remainingShopTtsPromise = fetch(`${this.apiBase}/api/tts/synthesize`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + text: this.stripMarkdown(restShops), language_code: shopLangConfig.tts, + voice_name: shopLangConfig.voice, session_id: this.sessionId + }) + }).then(r => r.json()); + } + } + + if (introPart2Promise) await introPart2Promise; + + if (firstShopTtsPromise) { + const firstResult = await firstShopTtsPromise; + if (firstResult?.success && firstResult?.audio) { + const firstShopText = this.stripMarkdown(shopLines[0]); + this.lastAISpeech = this.normalizeText(firstShopText); + + // ★ TTS応答に同梱されたExpressionを即バッファ投入 + if (firstResult.expression) this.applyExpressionFromTts(firstResult.expression); + + this.stopCurrentAudio(); + + this.ttsPlayer.src = `data:audio/mp3;base64,${firstResult.audio}`; + + // 残りのTTS結果を先に取得(Expression同梱済み) + let remainingResult: any = null; + if (remainingShopTtsPromise) { + remainingResult = await remainingShopTtsPromise; + } + + await new Promise((resolve) => { + this.ttsPlayer.onended = () => { + this.els.voiceStatus.innerHTML = this.t('voiceStatusStopped'); + this.els.voiceStatus.className = 'voice-status stopped'; + resolve(); + }; + this.ttsPlayer.onerror = () => resolve(); + this.els.voiceStatus.innerHTML = this.t('voiceStatusSpeaking'); + this.els.voiceStatus.className = 'voice-status speaking'; + this.ttsPlayer.play().catch(() => resolve()); + }); + + if (remainingResult?.success && remainingResult?.audio) { + const restShopsText = this.stripMarkdown(shopLines.slice(1).join('\n\n')); + this.lastAISpeech = this.normalizeText(restShopsText); + + // ★ TTS応答に同梱されたExpressionを即バッファ投入 + if (remainingResult.expression) this.applyExpressionFromTts(remainingResult.expression); + + this.stopCurrentAudio(); + + this.ttsPlayer.src = `data:audio/mp3;base64,${remainingResult.audio}`; + await new Promise((resolve) => { + this.ttsPlayer.onended = () => { + this.els.voiceStatus.innerHTML = this.t('voiceStatusStopped'); + this.els.voiceStatus.className = 'voice-status stopped'; + resolve(); + }; + this.ttsPlayer.onerror = () => resolve(); + this.els.voiceStatus.innerHTML = this.t('voiceStatusSpeaking'); + this.els.voiceStatus.className = 'voice-status speaking'; + this.ttsPlayer.play().catch(() => resolve()); + }); + } + } + } + this.isAISpeaking = false; + } catch (_e) { this.isAISpeaking = false; } + })(); + } else { + if (data.response) { + const extractedShops = this.extractShopsFromResponse(data.response); + if (extractedShops.length > 0) { + this.currentShops = extractedShops; + this.els.reservationBtn.classList.add('visible'); + document.dispatchEvent(new CustomEvent('displayShops', { + detail: { shops: extractedShops, language: this.currentLanguage } + })); + const section = document.getElementById('shopListSection'); + if (section) section.classList.add('has-shops'); + // ★並行処理フローを適用 + this.speakResponseInChunks(data.response, isTextInput); + } else { + // ★並行処理フローを適用 + this.speakResponseInChunks(data.response, isTextInput); + } + } + } + } catch (error) { + console.error('送信エラー:', error); + this.hideWaitOverlay(); + this.showError('メッセージの送信に失敗しました。'); + } finally { + this.resetInputState(); + this.els.userInput.blur(); + } + } + +} diff --git a/services/frontend-patches/vrm-expression-manager.ts b/services/frontend-patches/vrm-expression-manager.ts new file mode 100644 index 0000000..d4a36b9 --- /dev/null +++ b/services/frontend-patches/vrm-expression-manager.ts @@ -0,0 +1,198 @@ +/** + * VRM Expression Manager - A2Eブレンドシェイプ→ボーン変換 + * + * A2Eサービスから受け取った52次元ARKitブレンドシェイプ係数を + * GVRMのボーンシステムにマッピングする。 + * + * 現状のGVRMレンダラーはGaussian Splattingベースのボーン変形を使用: + * - Index 22: Jaw (口の開閉) + * - Index 15: Head (頭の微細な動き) + * - Index 9: Chest (呼吸) + * + * A2Eの52次元出力のうち、リップシンクに重要なブレンドシェイプを + * 既存のボーンシステムにマッピングして、従来のFFT音量ベースよりも + * 正確なリップシンクを実現する。 + * + * 使い方 (concierge-controller.ts): + * import { ExpressionManager } from './vrm-expression-manager'; + * const exprMgr = new ExpressionManager(this.guavaRenderer); + * exprMgr.playExpressionFrames(expressionData, audioElement); + */ + +// A2Eサービスからのレスポンス型 +export interface ExpressionData { + names: string[]; // 52個のARKitブレンドシェイプ名 + frames: number[][]; // フレームごとの52次元係数 + frame_rate: number; // fps (通常30) +} + +// ARKitブレンドシェイプ名→インデックスのマップ +const ARKIT_INDEX: Record = { + eyeBlinkLeft: 0, eyeLookDownLeft: 1, eyeLookInLeft: 2, eyeLookOutLeft: 3, + eyeLookUpLeft: 4, eyeSquintLeft: 5, eyeWideLeft: 6, + eyeBlinkRight: 7, eyeLookDownRight: 8, eyeLookInRight: 9, eyeLookOutRight: 10, + eyeLookUpRight: 11, eyeSquintRight: 12, eyeWideRight: 13, + jawForward: 14, jawLeft: 15, jawRight: 16, jawOpen: 17, + mouthClose: 18, mouthFunnel: 19, mouthPucker: 20, mouthLeft: 21, mouthRight: 22, + mouthSmileLeft: 23, mouthSmileRight: 24, mouthFrownLeft: 25, mouthFrownRight: 26, + mouthDimpleLeft: 27, mouthDimpleRight: 28, mouthStretchLeft: 29, mouthStretchRight: 30, + mouthRollLower: 31, mouthRollUpper: 32, mouthShrugLower: 33, mouthShrugUpper: 34, + mouthPressLeft: 35, mouthPressRight: 36, mouthLowerDownLeft: 37, mouthLowerDownRight: 38, + mouthUpperUpLeft: 39, mouthUpperUpRight: 40, + browDownLeft: 41, browDownRight: 42, browInnerUp: 43, browOuterUpLeft: 44, browOuterUpRight: 45, + cheekPuff: 46, cheekSquintLeft: 47, cheekSquintRight: 48, + noseSneerLeft: 49, noseSneerRight: 50, + tongueOut: 51, +}; + +export class ExpressionManager { + private renderer: any; // GVRM instance + private currentFrames: number[][] | null = null; + private frameRate: number = 30; + private frameIndex: number = 0; + private animationFrameId: number | null = null; + private startTime: number = 0; + private audioElement: HTMLAudioElement | null = null; + private isPlaying: boolean = false; + + constructor(renderer: any) { + this.renderer = renderer; + } + + /** + * A2E expressionデータを使って音声と同期したリップシンクを再生 + * + * @param expression A2Eサービスからのレスポンス + * @param audioElement 音声再生用のHTML Audio要素 + */ + public playExpressionFrames(expression: ExpressionData, audioElement: HTMLAudioElement) { + this.stop(); + + this.currentFrames = expression.frames; + this.frameRate = expression.frame_rate || 30; + this.frameIndex = 0; + this.audioElement = audioElement; + this.isPlaying = true; + + // 音声再生に同期 + this.startTime = performance.now(); + this.tick(); + } + + /** + * フレーム更新ループ + * 音声の現在の再生位置に合わせてフレームを選択 + */ + private tick = () => { + if (!this.isPlaying || !this.currentFrames || !this.audioElement) { + this.applyLipSyncLevel(0); + return; + } + + // 音声が終了した場合 + if (this.audioElement.paused || this.audioElement.ended) { + if (this.audioElement.ended) { + this.applyLipSyncLevel(0); + this.isPlaying = false; + return; + } + } + + // 音声の再生時間からフレームインデックスを計算 + const currentTime = this.audioElement.currentTime; + const frameIdx = Math.floor(currentTime * this.frameRate); + + if (frameIdx >= 0 && frameIdx < this.currentFrames.length) { + const coefficients = this.currentFrames[frameIdx]; + this.applyBlendshapes(coefficients); + } else if (frameIdx >= this.currentFrames.length) { + // フレーム切れ → 口を閉じる + this.applyLipSyncLevel(0); + } + + this.animationFrameId = requestAnimationFrame(this.tick); + }; + + /** + * 52次元ブレンドシェイプ係数をボーンシステムにマッピング + * + * 現状のGVRMは主にJawボーン(index 22)の回転でリップシンクを実現。 + * A2Eの詳細なブレンドシェイプを、このボーンの回転強度に変換する。 + * + * 将来的にGVRMがブレンドシェイプ対応すれば、より詳細なマッピングが可能。 + */ + private applyBlendshapes(coefficients: number[]) { + if (!this.renderer) return; + + // ======================================== + // Step 1: リップシンクレベルの合成 + // 複数のブレンドシェイプから統合的な口の開き度を計算 + // ======================================== + + const jawOpen = coefficients[ARKIT_INDEX.jawOpen] || 0; + const mouthFunnel = coefficients[ARKIT_INDEX.mouthFunnel] || 0; + const mouthPucker = coefficients[ARKIT_INDEX.mouthPucker] || 0; + const mouthLowerDownL = coefficients[ARKIT_INDEX.mouthLowerDownLeft] || 0; + const mouthLowerDownR = coefficients[ARKIT_INDEX.mouthLowerDownRight] || 0; + const mouthUpperUpL = coefficients[ARKIT_INDEX.mouthUpperUpLeft] || 0; + const mouthUpperUpR = coefficients[ARKIT_INDEX.mouthUpperUpRight] || 0; + + // 口の開き度 = jawOpen(メイン) + 補助ブレンドシェイプ + const mouthOpenness = Math.min(1.0, + jawOpen * 0.6 + + ((mouthLowerDownL + mouthLowerDownR) / 2) * 0.2 + + ((mouthUpperUpL + mouthUpperUpR) / 2) * 0.1 + + mouthFunnel * 0.05 + + mouthPucker * 0.05 + ); + + // GVRMのupdateLipSyncに渡す(0.0〜1.0) + this.renderer.updateLipSync(mouthOpenness); + + // ======================================== + // Step 2: (将来拡張) 追加ボーンマッピング + // 現在のVRMManagerにsetLipSync以外のAPIを追加すれば、 + // 以下の情報も活用できる: + // + // - mouthSmileLeft/Right → 口角の上げ (表情) + // - browInnerUp → 眉の動き + // - cheekPuff → 頬の膨らみ + // - eyeBlinkLeft/Right → 瞬き + // ======================================== + } + + /** + * シンプルなリップシンクレベル適用(フォールバック用) + */ + private applyLipSyncLevel(level: number) { + if (this.renderer) { + this.renderer.updateLipSync(level); + } + } + + /** + * 再生停止 + */ + public stop() { + this.isPlaying = false; + if (this.animationFrameId) { + cancelAnimationFrame(this.animationFrameId); + this.animationFrameId = null; + } + this.currentFrames = null; + this.applyLipSyncLevel(0); + } + + /** + * expressionデータが有効かどうか + */ + public static isValid(expression: any): expression is ExpressionData { + return ( + expression && + Array.isArray(expression.names) && + Array.isArray(expression.frames) && + expression.frames.length > 0 && + typeof expression.frame_rate === 'number' + ); + } +} diff --git a/support_base/.dockerignore b/support_base/.dockerignore new file mode 100644 index 0000000..02e2a7d --- /dev/null +++ b/support_base/.dockerignore @@ -0,0 +1,10 @@ +__pycache__/ +*.pyc +*.pyo +.git/ +.gitignore +*.md +.env +.env.* +Dockerfile +.dockerignore diff --git a/support_base/Dockerfile b/support_base/Dockerfile new file mode 100644 index 0000000..5481ecb --- /dev/null +++ b/support_base/Dockerfile @@ -0,0 +1,18 @@ +FROM python:3.11-slim + +WORKDIR /app + +# 依存パッケージ +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +# ソースコード (support_base パッケージとして配置) +COPY . ./support_base/ + +ENV PORT=8080 +ENV HOST=0.0.0.0 + +EXPOSE 8080 + +# uvicorn で起動(Cloud Run は PORT 環境変数を注入する) +CMD uvicorn support_base.server:app --host 0.0.0.0 --port ${PORT} diff --git a/support_base/__init__.py b/support_base/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/support_base/cloudbuild.yaml b/support_base/cloudbuild.yaml new file mode 100644 index 0000000..15d0c50 --- /dev/null +++ b/support_base/cloudbuild.yaml @@ -0,0 +1,60 @@ +# Cloud Build: support_base → Cloud Run デプロイ +# +# トリガー設定: +# gcloud builds triggers create github \ +# --repo-name=LAM_gpro --repo-owner=mirai-gpro \ +# --branch-pattern="^main$" \ +# --build-config=support_base/cloudbuild.yaml \ +# --included-files="support_base/**" +# +# 手動実行: +# cd support_base +# gcloud builds submit --config=cloudbuild.yaml . + +substitutions: + _SERVICE_NAME: support-base + _REGION: asia-northeast1 + _MEMORY: 1Gi + +steps: + # 1. Docker イメージビルド + - name: 'gcr.io/cloud-builders/docker' + args: + - 'build' + - '-t' + - 'gcr.io/$PROJECT_ID/${_SERVICE_NAME}:$COMMIT_SHA' + - '-t' + - 'gcr.io/$PROJECT_ID/${_SERVICE_NAME}:latest' + - '.' + dir: 'support_base' + + # 2. Container Registry にプッシュ + - name: 'gcr.io/cloud-builders/docker' + args: + - 'push' + - 'gcr.io/$PROJECT_ID/${_SERVICE_NAME}:$COMMIT_SHA' + + - name: 'gcr.io/cloud-builders/docker' + args: + - 'push' + - 'gcr.io/$PROJECT_ID/${_SERVICE_NAME}:latest' + + # 3. Cloud Run にデプロイ + - name: 'gcr.io/google.com/cloudsdktool/cloud-sdk' + entrypoint: 'gcloud' + args: + - 'run' + - 'deploy' + - '${_SERVICE_NAME}' + - '--image=gcr.io/$PROJECT_ID/${_SERVICE_NAME}:$COMMIT_SHA' + - '--region=${_REGION}' + - '--platform=managed' + - '--memory=${_MEMORY}' + - '--port=8080' + - '--allow-unauthenticated' + +images: + - 'gcr.io/$PROJECT_ID/${_SERVICE_NAME}:$COMMIT_SHA' + - 'gcr.io/$PROJECT_ID/${_SERVICE_NAME}:latest' + +timeout: '600s' diff --git a/support_base/config/__init__.py b/support_base/config/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/support_base/config/settings.py b/support_base/config/settings.py new file mode 100644 index 0000000..749a461 --- /dev/null +++ b/support_base/config/settings.py @@ -0,0 +1,50 @@ +""" +プラットフォーム設定 + +環境変数から読み込み。.env ファイルも対応。 +""" + +import os + +# Gemini API +GEMINI_API_KEY = os.getenv("GEMINI_API_KEY", "") +LIVE_API_MODEL = os.getenv("LIVE_API_MODEL", "gemini-2.5-flash-native-audio-preview-12-2025") +REST_API_MODEL = os.getenv("REST_API_MODEL", "gemini-2.5-flash") + +# Audio +SEND_SAMPLE_RATE = 16000 # マイク入力 (PCM 16kHz) +RECEIVE_SAMPLE_RATE = 24000 # Live API 出力 (PCM 24kHz) + +# Live API 再接続設定 (stt_stream.py L372-373 から移植) +MAX_AI_CHARS_BEFORE_RECONNECT = 800 +LONG_SPEECH_THRESHOLD = 500 +RECONNECT_DELAY_SECONDS = 3 + +# audio2exp-service +A2E_SERVICE_URL = os.getenv("A2E_SERVICE_URL", "https://audio2exp-service-XXXXX.run.app") +A2E_TIMEOUT_SECONDS = 10 + +# Google Cloud TTS +GCP_PROJECT_ID = os.getenv("GCP_PROJECT_ID", "") + +# Server +HOST = os.getenv("HOST", "0.0.0.0") +PORT = int(os.getenv("PORT", "8080")) +CORS_ORIGINS = os.getenv("CORS_ORIGINS", "*").split(",") + +# --- REST モード (gourmet-support 互換) --- +# GCS プロンプト +PROMPTS_BUCKET_NAME = os.getenv("PROMPTS_BUCKET_NAME", "") + +# 外部 API キー +GOOGLE_PLACES_API_KEY = os.getenv("GOOGLE_PLACES_API_KEY", "") +GOOGLE_GEOCODING_API_KEY = os.getenv("GOOGLE_GEOCODING_API_KEY", GOOGLE_PLACES_API_KEY) +HOTPEPPER_API_KEY = os.getenv("HOTPEPPER_API_KEY", "") +TRIPADVISOR_API_KEY = os.getenv("TRIPADVISOR_API_KEY", "") + +# Supabase (長期記憶) +SUPABASE_URL = os.getenv("SUPABASE_URL", "") +SUPABASE_KEY = os.getenv("SUPABASE_KEY", "") + +# 既存 gourmet-support (Phase 1 プロキシ用) +LEGACY_BACKEND_URL = os.getenv("LEGACY_BACKEND_URL", "") diff --git a/support_base/core/__init__.py b/support_base/core/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/support_base/core/api_integrations.py b/support_base/core/api_integrations.py new file mode 100644 index 0000000..a352d86 --- /dev/null +++ b/support_base/core/api_integrations.py @@ -0,0 +1,752 @@ +# -*- coding: utf-8 -*- +""" +外部API連携モジュール +- HotPepper API +- TripAdvisor API +- Google Geocoding API +- Google Places API +- ショップ情報エンリッチメント +""" +import os +import re +import logging +import requests + +# ロギング +logger = logging.getLogger(__name__) + +# ======================================== +# API Keys & Constants +# ======================================== + +# Google Places API +GOOGLE_PLACES_API_KEY = os.getenv('GOOGLE_PLACES_API_KEY', '') + +# Google Geocoding API(Places APIと同じキーを使用) +GOOGLE_GEOCODING_API_KEY = os.getenv('GOOGLE_GEOCODING_API_KEY', GOOGLE_PLACES_API_KEY) + +# ホットペッパーAPI +HOTPEPPER_API_KEY = os.getenv('HOTPEPPER_API_KEY', 'c22031a566715e40') + +# TripAdvisor Content API +TRIPADVISOR_API_KEY = os.getenv('TRIPADVISOR_API_KEY', '') +MY_DOMAIN_URL = "https://unfix.co.jp" +USER_AGENT = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36" + +# エリアコード +HOTPEPPER_AREA_CODES = { + '東京': 'Z011', + '神奈川': 'Z012', + '埼玉': 'Z013', + '千葉': 'Z014', + '大阪': 'Z023', + '京都': 'Z026', + '兵庫': 'Z024', + '愛知': 'Z033', + '福岡': 'Z091', + '北海道': 'Z011', +} + +# ======================================== +# ホットペッパーAPI 連携 +# ======================================== + +def search_hotpepper(shop_name: str, area: str = '', geo_info: dict = None) -> str: + """ + ホットペッパーAPIで店舗を検索して店舗ページURLを返す + """ + if not HOTPEPPER_API_KEY: + logger.warning("[Hotpepper API] APIキーが設定されていません") + return None + + # Geocoding APIの結果から都道府県を取得 + large_area = 'Z011' # デフォルト東京 + if geo_info: + region = geo_info.get('region', '') + # "東京都" → "東京" に変換してエリアコードを取得 + pref = region.rstrip('都道府県') if region else '' + large_area = HOTPEPPER_AREA_CODES.get(pref, 'Z011') + + try: + url = 'http://webservice.recruit.co.jp/hotpepper/gourmet/v1/' + params = { + 'key': HOTPEPPER_API_KEY, + 'keyword': shop_name, + 'large_area': large_area, + 'format': 'json', + 'count': 1 + } + + logger.info(f"[Hotpepper API] 検索: {shop_name} (エリア: {large_area})") + + response = requests.get(url, params=params, timeout=10) + data = response.json() + + results = data.get('results', {}) + shops = results.get('shop', []) + + if shops: + shop_url = shops[0].get('urls', {}).get('pc', '') + logger.info(f"[Hotpepper API] 取得成功: {shop_name} -> {shop_url}") + return shop_url + else: + logger.info(f"[Hotpepper API] 結果なし: {shop_name}") + return None + + except Exception as e: + logger.error(f"[Hotpepper API] エラー: {e}") + return None + +# ======================================== +# TripAdvisor Content API 連携 +# ======================================== +def search_tripadvisor_location(shop_name: str, lat: float = None, lng: float = None, language: str = 'en') -> dict: + """ + TripAdvisor Location Search APIで店舗のlocation_idを検索 + """ + if not TRIPADVISOR_API_KEY: + logger.warning("[TripAdvisor API] APIキーが設定されていません") + return None + + try: + url = 'https://api.content.tripadvisor.com/api/v1/location/search' + + params = { + 'key': TRIPADVISOR_API_KEY, + 'searchQuery': shop_name, + 'language': language + } + + # 座標がある場合は追加 + if lat is not None and lng is not None: + params['latLong'] = f"{lat},{lng}" + + # 【修正】Referer (https付き) と User-Agent (ブラウザ偽装) を指定 + headers = { + 'accept': 'application/json', + 'Referer': MY_DOMAIN_URL, + 'User-Agent': USER_AGENT + } + + logger.info(f"[TripAdvisor API] Location Search: {shop_name} ({language})") + + response = requests.get(url, params=params, headers=headers, timeout=10) + + if response.status_code == 200: + data = response.json() + if data.get('data') and len(data['data']) > 0: + location = data['data'][0] + location_id = location.get('location_id') + logger.info(f"[TripAdvisor API] Location found: {location_id}") + return { + 'location_id': location_id, + 'name': location.get('name'), + 'address': location.get('address_obj', {}).get('address_string', '') + } + else: + logger.info(f"[TripAdvisor API] Location not found for: {shop_name}") + return None + else: + logger.warning(f"[TripAdvisor API] Search failed: {response.status_code} - {response.text}") + return None + + except Exception as e: + logger.error(f"[TripAdvisor API] Error: {e}") + return None + + +def get_tripadvisor_details(location_id: str, language: str = 'en') -> dict: + """ + TripAdvisor Location Details APIで評価情報を取得 + """ + if not TRIPADVISOR_API_KEY or not location_id: + return None + + try: + url = f'https://api.content.tripadvisor.com/api/v1/location/{location_id}/details' + + params = { + 'key': TRIPADVISOR_API_KEY, + 'language': language + } + + # 【修正】ここにも User-Agent を追加 + headers = { + 'accept': 'application/json', + 'Referer': MY_DOMAIN_URL, + 'User-Agent': USER_AGENT + } + + logger.info(f"[TripAdvisor API] Getting details for location: {location_id}") + + response = requests.get(url, params=params, headers=headers, timeout=10) + + if response.status_code == 200: + data = response.json() + rating = data.get('rating') + num_reviews = data.get('num_reviews', 0) + web_url = data.get('web_url') + + logger.info(f"[TripAdvisor API] Details: rating={rating}, reviews={num_reviews}") + + return { + 'rating': float(rating) if rating else None, + 'num_reviews': num_reviews, + 'web_url': web_url, + 'location_id': location_id + } + else: + logger.warning(f"[TripAdvisor API] Details failed: {response.status_code} - {response.text}") + return None + + except Exception as e: + logger.error(f"[TripAdvisor API] Error: {e}") + return None + + +def get_tripadvisor_data(shop_name: str, lat: float = None, lng: float = None, language: str = 'en') -> dict: + """ + TripAdvisor APIで店舗情報を取得(検索 + 詳細) + """ + # Location IDを検索 + location_data = search_tripadvisor_location(shop_name, lat, lng, language) + if not location_data: + return None + + # 詳細情報を取得 + details = get_tripadvisor_details(location_data['location_id'], language) + if not details: + return None + + return { + 'rating': details['rating'], + 'num_reviews': details['num_reviews'], + 'web_url': details['web_url'], + 'location_id': details['location_id'] + } + +# ======================================== +# Google Geocoding API 連携 +# ======================================== + +def get_region_from_area(area: str, language: str = 'ja') -> dict: + """ + Geocoding APIでエリアの地域情報(国、都道府県/州、座標)を取得 + """ + if not area: + return None + + if not GOOGLE_GEOCODING_API_KEY: + logger.warning("[Geocoding API] APIキーが設定されていません") + return None + + try: + url = 'https://maps.googleapis.com/maps/api/geocode/json' + params = { + 'address': area, + 'key': GOOGLE_GEOCODING_API_KEY, + 'language': language + } + + logger.info(f"[Geocoding API] エリア検索: {area}") + + response = requests.get(url, params=params, timeout=10) + data = response.json() + + if data.get('status') != 'OK' or not data.get('results'): + logger.warning(f"[Geocoding API] 結果なし: {area} (status: {data.get('status')})") + return None + + result = data['results'][0] + address_components = result.get('address_components', []) + + # 国と都道府県/州を抽出 + country = None + country_code = None + region = None + + for component in address_components: + types = component.get('types', []) + + if 'country' in types: + country = component.get('long_name') + country_code = component.get('short_name') + + if 'administrative_area_level_1' in types: + region = component.get('long_name') + + # 座標を取得 + location = result.get('geometry', {}).get('location', {}) + lat = location.get('lat') + lng = location.get('lng') + + geo_result = { + 'country': country, + 'country_code': country_code, + 'region': region, + 'formatted_address': result.get('formatted_address', ''), + 'lat': lat, + 'lng': lng + } + + logger.info(f"[Geocoding API] 取得成功: {area} → country={country}, region={region}, lat={lat}, lng={lng}") + return geo_result + + except requests.exceptions.Timeout: + logger.error(f"[Geocoding API] タイムアウト: {area}") + return None + except Exception as e: + logger.error(f"[Geocoding API] エラー: {e}") + return None + + +# ======================================== +# Google Places API 連携 +# ======================================== + +def get_place_details(place_id: str, language: str = 'ja') -> dict: + """ + Place Details APIで電話番号と国コードを取得 + """ + if not GOOGLE_PLACES_API_KEY or not place_id: + return {'phone': None, 'country_code': None, 'photos': None, 'formatted_address': None} + + try: + details_url = 'https://maps.googleapis.com/maps/api/place/details/json' + params = { + 'place_id': place_id, + 'fields': 'formatted_phone_number,international_phone_number,address_components,photos,formatted_address', + 'key': GOOGLE_PLACES_API_KEY, + 'language': language + } + + response = requests.get(details_url, params=params, timeout=10) + data = response.json() + + if data.get('status') != 'OK': + logger.warning(f"[Place Details API] 取得失敗: {data.get('status')} - {place_id}") + return {'phone': None, 'country_code': None, 'photos': None, 'formatted_address': None} + + result = data.get('result', {}) + + # 電話番号取得(国内形式を優先、なければ国際形式) + phone = result.get('formatted_phone_number') or result.get('international_phone_number') + + # 国コード取得 + country_code = None + if result.get('address_components'): + for component in result['address_components']: + if 'country' in component.get('types', []): + country_code = component.get('short_name') + break + + # 写真取得 + photos = result.get('photos') + + # 住所取得 + formatted_address = result.get('formatted_address') + + if phone or photos or formatted_address: + logger.info(f"[Place Details API] 取得成功: 電話={phone}, 国={country_code}, 写真={'あり' if photos else 'なし'}, 住所={'あり' if formatted_address else 'なし'}") + + return { + 'phone': phone, + 'country_code': country_code, + 'photos': photos, + 'formatted_address': formatted_address + } + + + except requests.exceptions.Timeout: + logger.error(f"[Place Details API] タイムアウト: {place_id}") + return {'phone': None, 'country_code': None, 'photos': None, 'formatted_address': None} + except Exception as e: + logger.error(f"[Place Details API] エラー: {e}") + return {'phone': None, 'country_code': None, 'photos': None, 'formatted_address': None} + + +def search_place(shop_name: str, area: str = '', geo_info: dict = None, language: str = 'ja') -> dict: + """ + Google Places APIで店舗を検索(国コード検証付き) + """ + if not GOOGLE_PLACES_API_KEY: + logger.warning("[Places API] APIキーが設定されていません") + return None + + # Geocoding APIの結果から都道府県/州を取得 + region = geo_info.get('region', '') if geo_info else '' + expected_country = geo_info.get('country_code', 'JP') if geo_info else 'JP' + + # 検索クエリを構築 + if region: + query = f"{shop_name} {area} {region}".strip() + else: + query = f"{shop_name} {area}".strip() + logger.info(f"[Places API] 📍 検索開始: shop_name='{shop_name}', area='{area}', region='{region}', expected_country={expected_country}") + + try: + search_url = 'https://maps.googleapis.com/maps/api/place/textsearch/json' + params = { + 'query': query, + 'key': GOOGLE_PLACES_API_KEY, + 'language': language, + 'type': 'restaurant' + } + + # Geocoding APIの座標があれば位置バイアスを追加 + if geo_info and geo_info.get('lat') and geo_info.get('lng'): + params['location'] = f"{geo_info['lat']},{geo_info['lng']}" + + # 国によって検索半径を変える + if expected_country == 'JP': + params['radius'] = 3000 + params['region'] = 'jp' + else: + params['radius'] = 50000 + + logger.info(f"[Places API] 検索クエリ: {query}") + + response = requests.get(search_url, params=params, timeout=10) + data = response.json() + + if data.get('status') != 'OK': + logger.warning(f"[Places API] 検索失敗: {data.get('status')} - {query}") + return None + + if not data.get('results'): + logger.info(f"[Places API] 結果なし: {query}") + return None + + results_count = len(data.get('results', [])) + logger.info(f"[Places API] 📊 検索結果: {results_count}件ヒット") + + # ✅ business_status でフィルタリング(廃業・閉店を除外) + place = None + for candidate in data['results']: + business_status = candidate.get('business_status', 'OPERATIONAL') + candidate_name = candidate.get('name', '不明') + + if business_status == 'OPERATIONAL': + place = candidate + logger.info(f"[Places API] ✅ 営業中: {candidate_name}") + break + elif business_status == 'CLOSED_PERMANENTLY': + logger.warning(f"[Places API] ❌ 閉店・廃業のためスキップ: {candidate_name}") + elif business_status == 'CLOSED_TEMPORARILY': + logger.warning(f"[Places API] ⏸️ 一時休業のためスキップ: {candidate_name}") + else: + logger.warning(f"[Places API] ❓ 不明なステータス({business_status})のためスキップ: {candidate_name}") + + if not place: + logger.warning(f"[Places API] 営業中の店舗が見つかりません: {query}") + return None + + place_id = place['place_id'] + + logger.info(f"[Places API] 🏆 選択した店舗: name='{place.get('name')}', address='{place.get('formatted_address', '')[:50]}...'") + maps_url = f"https://www.google.com/maps/place/?q=place_id:{place_id}" + + # 座標を取得 + geometry = place.get('geometry', {}) + location = geometry.get('location', {}) + lat = location.get('lat') + lng = location.get('lng') + + # ✅ Place Details APIで電話番号、国コード、写真、住所を取得 + details = get_place_details(place_id, language) + actual_country = details.get('country_code') + + # 📷 画像URLを生成(Text Search API → Place Details API の順で試行) + photo_url = None + photos_source = place.get('photos') or details.get('photos') + if photos_source: + photo_reference = photos_source[0]['photo_reference'] + photo_url = ( + f"https://maps.googleapis.com/maps/api/place/photo" + f"?maxwidth=800" + f"&photo_reference={photo_reference}" + f"&key={GOOGLE_PLACES_API_KEY}" + ) + logger.info(f"[Places API] 📷 写真取得元: {'Text Search' if place.get('photos') else 'Place Details'}") + else: + logger.warning(f"[Places API] ⚠️ 写真データなし: {place.get('name')}") + + + + logger.info(f"[Places API] 🌍 国コード検証: expected={expected_country}, actual={actual_country}") + + # ✅ 国コード検証 + if actual_country and expected_country and actual_country != expected_country: + logger.warning(f"[Places API] 国コード不一致: {place.get('name')} " + f"(期待: {expected_country}, 実際: {actual_country}) - スキップ") + return None + + result = { + 'place_id': place_id, + 'name': place.get('name'), + 'rating': place.get('rating'), + 'user_ratings_total': place.get('user_ratings_total'), + 'formatted_address': place.get('formatted_address') or details.get('formatted_address'), + 'country_code': actual_country, + 'lat': lat, + 'lng': lng, + 'photo_url': photo_url, + 'maps_url': maps_url, + 'phone': details.get('phone') + } + + logger.info(f"[Places API] 取得成功: {result['name']} (国: {actual_country}, 電話: {result['phone']})") + return result + + except requests.exceptions.Timeout: + logger.error(f"[Places API] タイムアウト: {query}") + return None + except Exception as e: + logger.error(f"[Places API] エラー: {e}") + return None + +# ======================================== +# ショップ情報 拡張ロジック (刷新版) +# ======================================== + +def enrich_shops_with_photos(shops: list, area: str = '', language: str = 'ja') -> list: + """ + ショップリストに外部APIデータを追加 (place_id重複排除付き、国コード検証強化版) + - 基本: トリップアドバイザーを表示 + - 例外(日本語かつ日本国内): 国内3サイトを表示し、トリップアドバイザーは非表示 + """ + enriched_shops = [] + seen_place_ids = set() # ✅ 重複チェック用 + duplicate_count = 0 + validation_failed_count = 0 + + logger.info(f"[Enrich] é–‹å§‹: area='{area}', language={language}, shops={len(shops)}ä»¶") + + # Geocodingはã'ãã¾ã§è£œåŠ©æƒ…å ±ã¨ã—ã¦å–å¾—(失敗しても止まらない) + geo_info = None + if area: + try: + geo_info = get_region_from_area(area, language) + if geo_info: + logger.info(f"[Enrich] Geocoding成功: {geo_info.get('formatted_address', '')} " + f"(国: {geo_info.get('country_code', '')}, " + f"座標: {geo_info.get('lat', '')}, {geo_info.get('lng', '')})") + except Exception as e: + logger.error(f"[Enrich] Geocoding Error: {e}") + + # LLMが回答した店舗名をログ出力 + logger.info(f"[Enrich] LLMの回答店舗:") + for i, shop in enumerate(shops, 1): + logger.info(f"[Enrich] {i}. {shop.get('name', '')}") + + for i, shop in enumerate(shops, 1): + shop_name = shop.get('name', '') + if not shop_name: + continue + + logger.info(f"[Enrich] ----------") + logger.info(f"[Enrich] {i}/{len(shops)} 検索: '{shop_name}'") + + # ------------------------------------------------------- + # 1. Google Places APIで基本情報を取得(国コード検証付き) + # ------------------------------------------------------- + # 店舗ごとのエリアを使用(LLMのJSONから取得) + shop_area = shop.get('area', '') or area # LLMのareaを優先、なければグローバルのareaを使用 + logger.info(f"[Enrich] → 使用エリア: '{shop_area}'") + place_data = search_place(shop_name, shop_area, geo_info, language) + + if not place_data: + logger.warning(f"[Enrich] Places APIで見つからない。除外します: {shop_name}") + validation_failed_count += 1 + continue # ★append()せずにスキップ★ + + place_id = place_data.get('place_id') + place_name = place_data.get('name') + + logger.info(f"[Enrich] → 検索結果: '{place_name}'") + logger.info(f"[Enrich] → place_id: {place_id}") + logger.info(f"[Enrich] → photo_url: {place_data.get('photo_url', 'なし')}") + + # ✅ place_id重複チェック + if place_id in seen_place_ids: + duplicate_count += 1 + logger.warning(f"[Enrich] → ❌ 重複検出!æ—¢ã«è¿½åŠ æ¸ˆã¿(スキップ)") + logger.warning(f"[Enrich] LLM店舗名: '{shop_name}' → Google店舗名: '{place_name}'") + continue + + # ✅ place_idを記録 + seen_place_ids.add(place_id) + logger.info(f"[Enrich] → ✅ è¿½åŠ æ±ºå®š") + + # 国コードの取得 + shop_country = place_data.get('country_code', '') + + # ------------------------------------------------------- + # 2. ロジック判定(フラグ設定) + # ------------------------------------------------------- + # デフォルト設定 (基本はTripAdvisorを表示) + show_tripadvisor = True + show_domestic_sites = False + + # 【例外ルール】言語が日本語(ja) かつ 日本国内(JP) ã®å ´åˆ + if language == 'ja' and shop_country == 'JP': + show_tripadvisor = False # トリップアドバイザーは出さない + show_domestic_sites = True # 国内3サイトを出す + + # 将来的な拡張(例:台湾・韓国でも食べログを出す場合) + # if language == 'ja' and shop_country in ['TW', 'KR']: + # show_domestic_sites = True + + logger.info(f"[Enrich] 判定結果: {shop_name} (Country: {shop_country}, Lang: {language}) " + f"-> TripAdvisor: {show_tripadvisor}, Domestic: {show_domestic_sites}") + + # ------------------------------------------------------- + # 3. データの注入 + # ------------------------------------------------------- + # Google Placesの共通データ + if place_data.get('name'): + shop['name'] = place_data['name'] + if place_data.get('photo_url'): + shop['image'] = place_data['photo_url'] + if place_data.get('rating'): + shop['rating'] = place_data['rating'] + if place_data.get('user_ratings_total'): + shop['reviewCount'] = place_data['user_ratings_total'] + if place_data.get('formatted_address'): + shop['location'] = place_data['formatted_address'] + if place_data.get('maps_url'): + shop['maps_url'] = place_data['maps_url'] + if place_data.get('phone'): + shop['phone'] = place_data['phone'] + if place_data.get('place_id'): + shop['place_id'] = place_data['place_id'] + + # A. 国内3サイトのリンク生成 (例外ルール適用時) + if show_domestic_sites: + try: + # TripAdvisorフィールドを明示的に削除 + shop.pop('tripadvisor_url', None) + shop.pop('tripadvisor_rating', None) + shop.pop('tripadvisor_reviews', None) + + # ホットペッパー + hotpepper_url = None + try: + hotpepper_url = search_hotpepper(shop_name, area, geo_info) + if not hotpepper_url: + # 名前を変えて再トライ + places_name = place_data.get('name', '') + if places_name and places_name != shop_name: + hotpepper_url = search_hotpepper(places_name, area, geo_info) + except Exception: + pass + + shop['hotpepper_url'] = hotpepper_url if hotpepper_url else f"https://www.google.com/search?q={shop_name}+{area}+ホットペッパーグルメ" + + # 食べログ + try: + places_name = place_data.get('name', '') + region_name = geo_info.get('region', '') if geo_info else '東京' + # 都道府県コード変換(簡易版) + pref_code_map = {'東京': 'tokyo', '神奈川': 'kanagawa', '大阪': 'osaka', '京都': 'kyoto', '兵庫': 'hyogo', '北海道': 'hokkaido', '愛知': 'aichi', '福岡': 'fukuoka'} + pref = region_name.rstrip('都道府県') if region_name else '東京' + pref_code = pref_code_map.get(pref, 'tokyo') + + tabelog_search_query = requests.utils.quote(places_name if places_name else shop_name) + shop['tabelog_url'] = f"https://tabelog.com/{pref_code}/rstLst/?sw={tabelog_search_query}" + except Exception: + shop['tabelog_url'] = f"https://tabelog.com/tokyo/rstLst/?sw={shop_name}" + + # ぐるなび + shop['gnavi_url'] = f"https://www.google.com/search?q={shop_name}+{area}+ぐるなび" + + except Exception as e: + logger.error(f"[Enrich] Domestic Sites Error: {e}") + + # B. トリップアドバイザーのリンク生成 (デフォルト適用時) + if show_tripadvisor: + try: + lat = place_data.get('lat') + lng = place_data.get('lng') + + if TRIPADVISOR_API_KEY: + # 言語マッピング + tripadvisor_lang_map = {'ja': 'ja', 'en': 'en', 'zh': 'zh', 'ko': 'ko'} + search_lang = tripadvisor_lang_map.get(language, 'en') + + # 検索実行 + tripadvisor_data = get_tripadvisor_data(shop_name, lat, lng, search_lang) + + # 0件かつ日本語の場合、英語で再トライ(ヒット率向上策) + if not tripadvisor_data and search_lang == 'ja': + logger.info(f"[TripAdvisor] 日本語でヒットせず。英語で再検索: {shop_name}") + tripadvisor_data = get_tripadvisor_data(shop_name, lat, lng, 'en') + + if tripadvisor_data: + shop['tripadvisor_url'] = tripadvisor_data.get('web_url') + shop['tripadvisor_rating'] = tripadvisor_data.get('rating') + shop['tripadvisor_reviews'] = tripadvisor_data.get('num_reviews') + logger.info(f"[TripAdvisor] リンク生成成功: {shop_name}") + except Exception as e: + logger.error(f"[Enrich] TripAdvisor Error: {e}") + + enriched_shops.append(shop) + + logger.info(f"[Enrich] ========== 完了 ==========") + logger.info(f"[Enrich] 出力: {len(enriched_shops)}件") + logger.info(f"[Enrich] 重複除外: {duplicate_count}件") + logger.info(f"[Enrich] 検証失敗: {validation_failed_count}件") + logger.info(f"[Enrich] 合計入力: {len(shops)}件") + + return enriched_shops + + +def extract_area_from_text(text: str, language: str = 'ja') -> str: + """ + テキストからエリア名を抽出(Geocoding APIで動的に検証) + """ + jp_chars = r'[\u3040-\u309F\u30A0-\u30FF\u4E00-\u9FFF\uFF66-\uFF9Fa-zA-Z]' + patterns = [ + rf'({jp_chars}{{2,10}})の{jp_chars}', + rf'({jp_chars}{{2,10}})で{jp_chars}', + rf'({jp_chars}{{2,10}})にある', + rf'({jp_chars}{{2,10}})周辺', + ] + + for pattern in patterns: + match = re.search(pattern, text) + if match: + candidate = match.group(1) + geo_info = get_region_from_area(candidate, language) + if geo_info and geo_info.get('region'): + logger.info(f"[Extract Area] エリア抽出成功: '{candidate}' from '{text}'") + return candidate + + logger.info(f"[Extract Area] エリア抽出失敗: '{text}'") + return '' + + +def extract_shops_from_response(text: str) -> list: + """ + LLMの応答テキストからショップ情報を抽出 + """ + shops = [] + pattern = r'(\d+)\.\s*\*\*([^*]+)\*\*\s*(?:\([^)]+\))?\s*[-:]:]\s*([^\n]+)' + matches = re.findall(pattern, text) + + for match in matches: + full_name = match[1].strip() + description = match[2].strip() + + name = full_name + name_match = re.match(r'^([^(]+)[(]([^)]+)[)]', full_name) + if name_match: + name = name_match.group(1).strip() + + shops.append({ + 'name': name, + 'description': description, + 'category': 'レストラン' + }) + + logger.info(f"[Extract] {len(shops)}件のショップを抽出") diff --git a/support_base/core/long_term_memory.py b/support_base/core/long_term_memory.py new file mode 100644 index 0000000..e0588db --- /dev/null +++ b/support_base/core/long_term_memory.py @@ -0,0 +1,429 @@ +# -*- coding: utf-8 -*- +""" +長期記憶管理モジュール(新設計版) +- user_id をPRIMARY KEYとして使用 +- サマリーベースの記憶管理 +- LLMによる会話サマリー生成 +""" +import os +import json +import logging +from datetime import datetime +from typing import Optional, Dict, List, Any +from supabase import create_client, Client + +logger = logging.getLogger(__name__) + +# ======================================== +# Supabaseクライアント初期化 +# ======================================== + +_supabase_client: Optional[Client] = None + +def get_supabase_client() -> Client: + """Supabaseクライアントを取得(シングルトン)""" + global _supabase_client + + if _supabase_client is None: + supabase_url = os.getenv("SUPABASE_URL") + supabase_key = os.getenv("SUPABASE_KEY") + + if not supabase_url or not supabase_key: + logger.error("[LTM] SUPABASE_URL または SUPABASE_KEY が設定されていません") + raise ValueError("Supabase credentials not configured") + + _supabase_client = create_client(supabase_url, supabase_key) + logger.info("[LTM] Supabaseクライアント初期化完了") + + return _supabase_client + + +# ======================================== +# ユーザープロファイル管理(user_idベース) +# ======================================== + +class LongTermMemory: + """長期記憶管理クラス(新設計版)""" + + def __init__(self): + self.client = get_supabase_client() + self._cache = {} # プロファイルキャッシュ + + # ---------------------------------------- + # プロファイル操作(user_idベース) + # ---------------------------------------- + + def get_profile_basic(self, user_id: str) -> Optional[Dict[str, Any]]: + """ + 軽量プロファイル取得(名前のみ) + - 初期起動の高速化用 + - サマリーは含まない + """ + if not user_id: + logger.warning("[LTM] get_profile_basic: user_id が空です") + return None + + # キャッシュ確認 + if user_id in self._cache: + logger.info(f"[LTM] キャッシュからプロファイル取得: {user_id}") + return self._cache[user_id] + + try: + response = self.client.table('user_profiles').select( + 'user_id, preferred_name, name_honorific, visit_count' + ).eq('user_id', user_id).execute() + + if response.data and len(response.data) > 0: + profile = response.data[0] + self._cache[user_id] = profile # キャッシュに保存 + logger.info(f"[LTM] 軽量プロファイル取得成功: {user_id}") + return profile + else: + logger.info(f"[LTM] プロファイル未登録: {user_id}") + return None + except Exception as e: + logger.error(f"[LTM] 軽量プロファイル取得エラー: {e}") + return None + + def get_profile(self, user_id: str, use_cache: bool = True) -> Optional[Dict[str, Any]]: + """プロファイル取得(全カラム)""" + if not user_id: + logger.warning("[LTM] get_profile: user_id が空です") + return None + + try: + response = self.client.table('user_profiles').select('*').eq('user_id', user_id).execute() + + if response.data and len(response.data) > 0: + profile = response.data[0] + self._cache[user_id] = profile # キャッシュ更新 + logger.info(f"[LTM] プロファイル取得成功: {user_id}") + return profile + else: + logger.info(f"[LTM] プロファイル未登録: {user_id}") + return None + except Exception as e: + logger.error(f"[LTM] プロファイル取得エラー: {e}") + return None + + def get_summary(self, user_id: str) -> Optional[str]: + """サマリーのみ取得(遅延読み込み用)""" + if not user_id: + return None + + try: + response = self.client.table('user_profiles').select( + 'conversation_summary' + ).eq('user_id', user_id).execute() + + if response.data and len(response.data) > 0: + return response.data[0].get('conversation_summary') + return None + except Exception as e: + logger.error(f"[LTM] サマリー取得エラー: {e}") + return None + + def create_profile(self, user_id: str, data: Dict[str, Any] = None) -> Optional[Dict[str, Any]]: + """新規プロファイル作成""" + if not user_id: + logger.error("[LTM] create_profile: user_id が空です") + return None + + try: + now = datetime.now().isoformat() + profile_data = { + 'user_id': user_id, + 'preferred_name': data.get('preferred_name') if data else None, + 'name_honorific': data.get('name_honorific', '') if data else '', + 'conversation_summary': data.get('conversation_summary') if data else None, + 'default_language': data.get('language', 'ja') if data else 'ja', + 'preferred_mode': data.get('mode', 'chat') if data else 'chat', + 'first_visit_at': now, + 'last_visit_at': now, + 'visit_count': 1, + 'created_at': now, + 'updated_at': now + } + + response = self.client.table('user_profiles').insert(profile_data).execute() + + if response.data and len(response.data) > 0: + logger.info(f"[LTM] プロファイル作成成功: {user_id}") + return response.data[0] + else: + logger.error(f"[LTM] プロファイル作成失敗: {user_id}") + return None + except Exception as e: + logger.error(f"[LTM] プロファイル作成エラー: {e}") + return None + + def update_profile(self, user_id: str, updates: Dict[str, Any]) -> bool: + """ + プロファイル更新(UPSERT動作) + - レコードが存在すれば更新 + - レコードがなければ新規作成 + """ + if not user_id: + logger.error("[LTM] update_profile: user_id が空です") + return False + + try: + now = datetime.now().isoformat() + + # upsert用データを準備 + upsert_data = { + 'user_id': user_id, + 'updated_at': now, + 'last_visit_at': now, + **updates + } + + # 新規の場合のデフォルト値 + if 'visit_count' not in upsert_data: + upsert_data['visit_count'] = 1 + if 'created_at' not in upsert_data: + upsert_data['created_at'] = now + if 'first_visit_at' not in upsert_data: + upsert_data['first_visit_at'] = now + + # Supabase upsert (on_conflict で user_id を指定) + response = self.client.table('user_profiles').upsert( + upsert_data, + on_conflict='user_id' + ).execute() + + if response.data: + # キャッシュを更新 + self._cache[user_id] = response.data[0] + logger.info(f"[LTM] プロファイルupsert成功: {user_id}") + return True + else: + logger.error(f"[LTM] プロファイルupsert失敗: {user_id}") + return False + + except Exception as e: + logger.error(f"[LTM] プロファイル更新エラー: {e}") + return False + + def increment_visit_count(self, user_id: str, current_count: int = None) -> bool: + """ + 訪問回数をインクリメント + - current_count が渡されればそれを使用(DB照会を省略) + """ + if not user_id: + return False + + try: + if current_count is None: + # キャッシュから取得を試みる + cached = self._cache.get(user_id) + if cached: + current_count = cached.get('visit_count', 0) + else: + # キャッシュにない場合のみDB照会 + profile = self.get_profile_basic(user_id) + current_count = profile.get('visit_count', 0) if profile else 0 + + new_count = current_count + 1 + + # 直接UPDATE(upsertではなく) + response = self.client.table('user_profiles').update({ + 'visit_count': new_count, + 'last_visit_at': datetime.now().isoformat(), + 'updated_at': datetime.now().isoformat() + }).eq('user_id', user_id).execute() + + if response.data: + # キャッシュ更新 + if user_id in self._cache: + self._cache[user_id]['visit_count'] = new_count + logger.info(f"[LTM] 訪問回数更新成功: {user_id} -> {new_count}") + return True + return False + except Exception as e: + logger.error(f"[LTM] 訪問回数更新エラー: {e}") + return False + + def is_first_visit(self, user_id: str) -> bool: + """ + 初回訪問かどうか判定(軽量版) + - DBにレコードがなければ初回 + - レコードがあれば2回目以降 + """ + if not user_id: + return True + + profile = self.get_profile_basic(user_id) + return profile is None + + def append_conversation_summary(self, user_id: str, new_summary: str) -> bool: + """ + 会話サマリーを追記(マージ) + - 既存のサマリーがあれば、新しいサマリーを追記 + - なければ新規として保存 + """ + if not user_id or not new_summary: + return False + + try: + profile = self.get_profile(user_id) + if not profile: + logger.warning(f"[LTM] append_conversation_summary: プロファイルが見つかりません user_id={user_id}") + return False + + existing_summary = profile.get('conversation_summary', '') or '' + + # 既存サマリーがあればマージ(改行で区切る) + if existing_summary: + merged_summary = f"{existing_summary}\n\n---\n\n{new_summary}" + else: + merged_summary = new_summary + + return self.update_profile(user_id, {'conversation_summary': merged_summary}) + + except Exception as e: + logger.error(f"[LTM] サマリー追記エラー: {e}") + return False + + # ---------------------------------------- + # システムプロンプト用コンテキスト生成 + # ---------------------------------------- + + def generate_system_prompt_context(self, user_id: str, language: str = 'ja') -> str: + """システムプロンプトに注入するコンテキストを生成""" + if not user_id: + return "" + + profile = self.get_profile(user_id) + if not profile: + return "" + + # 言語別のテンプレート + if language == 'ja': + return self._generate_context_ja(profile) + elif language == 'en': + return self._generate_context_en(profile) + elif language == 'zh': + return self._generate_context_zh(profile) + elif language == 'ko': + return self._generate_context_ko(profile) + else: + return self._generate_context_ja(profile) + + def _generate_context_ja(self, profile: Dict) -> str: + """日本語コンテキスト生成""" + context_parts = [] + + # ユーザー情報 + context_parts.append("【ユーザー情報】") + preferred_name = profile.get('preferred_name', '') + name_honorific = profile.get('name_honorific', '') + if preferred_name: + context_parts.append(f"- 呼び方: {preferred_name}{name_honorific}") + context_parts.append(f"- 訪問回数: {profile.get('visit_count', 1)}回目") + + # 会話サマリー(存在する場合) + conversation_summary = profile.get('conversation_summary', '') + if conversation_summary: + context_parts.append("\n【過去の会話記録】") + context_parts.append(conversation_summary) + + return "\n".join(context_parts) + + def _generate_context_en(self, profile: Dict) -> str: + """英語コンテキスト生成""" + context_parts = [] + + context_parts.append("【User Information】") + preferred_name = profile.get('preferred_name', '') + name_honorific = profile.get('name_honorific', '') + if preferred_name: + context_parts.append(f"- Address as: {preferred_name}{name_honorific}") + context_parts.append(f"- Visit count: {profile.get('visit_count', 1)} visit(s)") + + conversation_summary = profile.get('conversation_summary', '') + if conversation_summary: + context_parts.append("\n【Past Conversation Records】") + context_parts.append(conversation_summary) + + return "\n".join(context_parts) + + def _generate_context_zh(self, profile: Dict) -> str: + """中国語コンテキスト生成""" + context_parts = [] + context_parts.append("【用户信息】") + preferred_name = profile.get('preferred_name', '') + name_honorific = profile.get('name_honorific', '') + if preferred_name: + context_parts.append(f"- 称呼: {preferred_name}{name_honorific}") + context_parts.append(f"- 访问次数: 第{profile.get('visit_count', 1)}次") + + conversation_summary = profile.get('conversation_summary', '') + if conversation_summary: + context_parts.append("\n【过去的对话记录】") + context_parts.append(conversation_summary) + + return "\n".join(context_parts) + + def _generate_context_ko(self, profile: Dict) -> str: + """韓国語コンテキスト生成""" + context_parts = [] + context_parts.append("【사용자 정보】") + preferred_name = profile.get('preferred_name', '') + name_honorific = profile.get('name_honorific', '') + if preferred_name: + context_parts.append(f"- 호칭: {preferred_name}{name_honorific}") + context_parts.append(f"- 방문 횟수: {profile.get('visit_count', 1)}회") + + conversation_summary = profile.get('conversation_summary', '') + if conversation_summary: + context_parts.append("\n【과거 대화 기록】") + context_parts.append(conversation_summary) + + return "\n".join(context_parts) + + +# ======================================== +# 後方互換性のためのダミークラス・関数 +# ======================================== + +class PreferenceExtractor: + """ + 後方互換性のためのダミークラス + 新設計ではLLMがサマリーを生成するため、正規表現ベースの抽出は廃止 + """ + + @staticmethod + def extract_from_text(text: str, language: str = 'ja') -> List[Dict[str, Any]]: + """ダミー: 常に空リストを返す""" + return [] + + @staticmethod + def extract_and_save(session_id: str, text: str, language: str = 'ja') -> int: + """ダミー: 何もしない(0を返す)""" + return 0 + + +def extract_name_from_text(text: str) -> Optional[str]: + """ + テキストから名前を抽出(後方互換性のため残す) + 新設計ではLLMが名前を抽出してactionで返すため、この関数は使用されない + """ + import re + + # パターン1: 「〜と呼んで」 + match = re.search(r'([^\s、。]+)(?:と|って)(?:呼んで|呼ばれ)', text) + if match: + return match.group(1) + + # パターン2: 「名前は〜」 + match = re.search(r'名前は([^\s、。]+)', text) + if match: + return match.group(1) + + # パターン3: 単独の名前らしき文字列(ひらがな・カタカナ2-10文字) + match = re.search(r'^([ぁ-んァ-ヶー]{2,10})$', text.strip()) + if match: + return match.group(1) + + return None diff --git a/support_base/core/support_core.py b/support_base/core/support_core.py new file mode 100644 index 0000000..5c9f99a --- /dev/null +++ b/support_base/core/support_core.py @@ -0,0 +1,805 @@ +# -*- coding: utf-8 -*- +""" +ビジネスロジック・コアクラス +- プロンプト管理 +- セッション管理 +- アシスタント(AI会話ロジック) +""" +import os +import json +import uuid +import logging +from datetime import datetime +from google import genai +from google.genai import types +import google.generativeai as genai_legacy + +# GCS (プロンプト読み込み用、オプション) +try: + from google.cloud import storage + _GCS_AVAILABLE = True +except ImportError: + storage = None + _GCS_AVAILABLE = False + +# api_integrations から必要な関数をインポート +from support_base.core.api_integrations import extract_shops_from_response + +logger = logging.getLogger(__name__) + +# 長期記憶モジュールをインポート +try: + from support_base.core.long_term_memory import LongTermMemory, PreferenceExtractor, extract_name_from_text + LONG_TERM_MEMORY_ENABLED = True +except Exception as e: + logger.warning(f"[LTM] 長期記憶モジュールのインポート失敗: {e}") + LONG_TERM_MEMORY_ENABLED = False + +# Gemini クライアント初期化 (API キーが未設定でも起動は可能にする) +_gemini_api_key = os.getenv("GEMINI_API_KEY", "") +gemini_client = None +model = None +try: + if _gemini_api_key: + gemini_client = genai.Client(api_key=_gemini_api_key) + genai_legacy.configure(api_key=_gemini_api_key) + model = genai_legacy.GenerativeModel('gemini-2.5-flash') + logger.info("[Core] Gemini クライアント初期化完了") + else: + logger.warning("[Core] GEMINI_API_KEY 未設定 — REST チャット機能は無効") +except Exception as e: + logger.error(f"[Core] Gemini クライアント初期化失敗: {e}") + +# ======================================== +# RAMベースのセッション管理 (Firestore完全廃止) +# ======================================== +_SESSION_CACHE = {} + +# ======================================== +# プロンプト読み込み (GCS優先、ローカルフォールバック) +# ======================================== + +def load_prompts_from_gcs(): + """ + GCSから2種類のプロンプトを読み込み + - support_system_{lang}.txt: チャットモード用 + - concierge_{lang}.txt: コンシェルジュモード用 + """ + try: + if not _GCS_AVAILABLE: + logger.warning("[Prompt] google-cloud-storage 未インストール。ローカルファイルを使用します。") + return None + + bucket_name = os.getenv('PROMPTS_BUCKET_NAME') + if not bucket_name: + logger.warning("[Prompt] PROMPTS_BUCKET_NAME が設定されていません。ローカルファイルを使用します。") + return None + + client = storage.Client() + bucket = client.bucket(bucket_name) + prompts = { + 'chat': {}, # チャットモード用 + 'concierge': {} # コンシェルジュモード用 + } + + for lang in ['ja', 'en', 'zh', 'ko']: + # チャットモード用プロンプト + chat_blob = bucket.blob(f'prompts/support_system_{lang}.txt') + if chat_blob.exists(): + prompts['chat'][lang] = chat_blob.download_as_text(encoding='utf-8') + logger.info(f"[Prompt] GCSから読み込み成功: support_system_{lang}.txt") + else: + logger.warning(f"[Prompt] GCSに見つかりません: support_system_{lang}.txt") + + # コンシェルジュモード用プロンプト + concierge_blob = bucket.blob(f'prompts/concierge_{lang}.txt') + if concierge_blob.exists(): + content = concierge_blob.download_as_text(encoding='utf-8') + try: + json_data = json.loads(content) + prompts['concierge'][lang] = json_data.get('concierge_system', content) + except json.JSONDecodeError: + prompts['concierge'][lang] = content + logger.info(f"[Prompt] GCSから読み込み成功: concierge_{lang}.txt") + else: + logger.warning(f"[Prompt] GCSに見つかりません: concierge_{lang}.txt") + + return prompts if (prompts['chat'] or prompts['concierge']) else None + + except Exception as e: + logger.error(f"[Prompt] GCS読み込み失敗: {e}") + return None + +def load_prompts_from_local(): + """ + ローカルファイルから2種類のプロンプトを読み込み (フォールバック) + """ + prompts = { + 'chat': {}, + 'concierge': {} + } + + for lang in ['ja', 'en', 'zh', 'ko']: + # チャットモード用 + chat_file = f'prompts/support_system_{lang}.txt' + try: + with open(chat_file, 'r', encoding='utf-8') as f: + prompts['chat'][lang] = f.read() + logger.info(f"[Prompt] ローカルから読み込み成功: support_system_{lang}.txt") + except FileNotFoundError: + logger.warning(f"[Prompt] ローカルファイルが見つかりません: {chat_file}") + except Exception as e: + logger.error(f"[Prompt] ローカル読み込みエラー (chat/{lang}): {e}") + + # コンシェルジュモード用 + concierge_file = f'prompts/concierge_{lang}.txt' + try: + with open(concierge_file, 'r', encoding='utf-8') as f: + content = f.read() + try: + json_data = json.loads(content) + prompts['concierge'][lang] = json_data.get('concierge_system', content) + except json.JSONDecodeError: + prompts['concierge'][lang] = content + logger.info(f"[Prompt] ローカルから読み込み成功: concierge_{lang}.txt") + except FileNotFoundError: + logger.warning(f"[Prompt] ローカルファイルが見つかりません: {concierge_file}") + except Exception as e: + logger.error(f"[Prompt] ローカル読み込みエラー (concierge/{lang}): {e}") + + return prompts if (prompts['chat'] or prompts['concierge']) else None + +def load_system_prompts(): + logger.info("[Prompt] プロンプト読み込み開始...") + prompts = load_prompts_from_gcs() + if not prompts: + logger.info("[Prompt] GCSから読み込めませんでした。ローカルファイルを使用します。") + prompts = load_prompts_from_local() + + if not prompts or (not prompts.get('chat') and not prompts.get('concierge')): + logger.error("[Prompt] プロンプトの読み込みに失敗しました!") + return { + 'chat': {'ja': 'エラー: チャットモードプロンプトが読み込めませんでした。'}, + 'concierge': {'ja': 'エラー: コンシェルジュモードプロンプトが読み込めませんでした。'} + } + + logger.info(f"[Prompt] プロンプト読み込み完了:") + logger.info(f" - チャットモード: {list(prompts.get('chat', {}).keys())}") + logger.info(f" - コンシェルジュモード: {list(prompts.get('concierge', {}).keys())}") + return prompts + +# プロンプト読み込み実行(モジュールロード時) +SYSTEM_PROMPTS = load_system_prompts() +INITIAL_GREETINGS = { + 'chat': { + 'ja': 'こんにちは!お店探しをお手伝いします。どのようなお店をお探しですか?(例:新宿で美味しいイタリアン、明日19時に予約できる焼肉店など)', + 'en': 'Hello! I\'m here to help you find restaurants. What kind of restaurant are you looking for?', + 'zh': '$60A8好!我来$5E2E$60A8找餐$5385。$60A8在$5BFB找什$4E48$6837的餐$5385?', + 'ko': '$C548$B155$D558$C138$C694! $B808$C2A4$D1A0$B791 $CC3E$AE30$B97C $B3C4$C640$B4DC$B9AC$ACA0$C2B5$B2C8$B2E4. $C5B4$B5A4 $B808$C2A4$D1A0$B791$C744 $CC3E$C73C$C2DC$B098$C694?' + }, + 'concierge': { + 'ja': 'いらっしゃいませ。グルメコンシェルジュです。今日はどのようなシーンでお店をお探しでしょうか?接待、デート、女子会など、お気軽にお聞かせください。', + 'en': 'Welcome! I\'m your gourmet concierge. What kind of dining experience are you looking for today? Business dinner, date, gathering with friends?', + 'zh': '$6B22迎光$4E34!我是$60A8的美食礼$5BBE$5458。今天$60A8想$5BFB找什$4E48$6837的用餐$573A景?商$52A1宴$8BF7、$7EA6会、朋友聚会?', + 'ko': '$C5B4$C11C$C624$C138$C694! $C800$B294 $ADC0$D558$C758 $BBF8$C2DD $CEE8$C2DC$C5B4$C9C0$C785$B2C8$B2E4. $C624$B298$C740 $C5B4$B5A4 $C2DD$C0AC $C7A5$BA74$C744 $CC3E$C73C$C2DC$B098$C694? $C811$B300, $B370$C774$D2B8, $BAA8$C784 $B4F1?' + } +} + +CONVERSATION_SUMMARY_TEMPLATES = { + 'ja': '以下の会話を1文で要約してください。\n\nユーザー: {user_message}\nアシスタント: {assistant_response}\n\n要約:', + 'en': 'Summarize the following conversation in one sentence.\n\nUser: {user_message}\nAssistant: {assistant_response}\n\nSummary:', + 'zh': '$8BF7用一句$8BDD$603B$7ED3以下$5BF9$8BDD。\n\n用$6237:{user_message}\n助手:{assistant_response}\n\n$603B$7ED3:', + 'ko': '$B2E4$C74C $B300$D654$B97C $D55C $BB38$C7A5$C73C$B85C $C694$C57D$D558$C138$C694.\n\n$C0AC$C6A9$C790: {user_message}\n$C5B4$C2DC$C2A4$D134$D2B8: {assistant_response}\n\n$C694$C57D:' +} + +FINAL_SUMMARY_TEMPLATES = { + 'ja': '以下の会話全体を要約し、問い合わせ内容をまとめてください。\n\n{conversation_text}\n\n作成日時: {timestamp}\n\n要約:', + 'en': 'Summarize the entire conversation below and organize the inquiry content.\n\n{conversation_text}\n\nCreated: {timestamp}\n\nSummary:', + 'zh': '$8BF7$603B$7ED3以下整个$5BF9$8BDD并整理咨$8BE2内容。\n\n{conversation_text}\n\n$521B建$65F6$95F4:{timestamp}\n\n$603B$7ED3:', + 'ko': '$B2E4$C74C $B300$D654$B97C $D55C $BB38$C7A5$C73C$B85C $C694$C57D$D558$C138$C694.\n\n$C0AC$C6A9$C790: {user_message}\n$C5B4$C2DC$C2A4$D134$D2B8: {assistant_response}\n\n$C694$C57D:' +} + +class SupportSession: + """サポートセッション管理 (RAM版)""" + + def __init__(self, session_id=None): + self.session_id = session_id or str(uuid.uuid4()) + + def initialize(self, user_info=None, language='ja', mode='chat'): + """ + 新規セッション初期化 - モード対応 + 長期記憶統合 + + 【新設計】効率化版: + - user_id はフロントエンドから user_info.user_id で受け取る + - チャットモードはDB読み込みをスキップ(高速化) + - コンシェルジュモードは軽量クエリ(名前のみ)で初期化 + - サマリーは初回メッセージ時に遅延読み込み + - 新規ユーザーはDB INSERTしない(名前登録時に初めてINSERT) + """ + # user_id を user_info から取得 + user_id = user_info.get('user_id') if user_info else None + + # 長期記憶から既存プロファイルを取得 + long_term_profile = None + is_first_visit = True + user_context = "" + + # チャットモードはDB読み込みをスキップ(高速化) + if mode == 'chat': + logger.info(f"[Session] チャットモード: DB読み込みスキップ") + elif LONG_TERM_MEMORY_ENABLED and user_id and mode == 'concierge': + try: + ltm = LongTermMemory() + + # 軽量クエリで名前のみ取得(1回のDB照会のみ) + profile_basic = ltm.get_profile_basic(user_id) + + if profile_basic: + # リピーター: プロファイルあり + is_first_visit = False + long_term_profile = profile_basic + + # 訪問回数インクリメント(キャッシュ済みなので追加DB照会なし) + current_count = profile_basic.get('visit_count', 0) + ltm.increment_visit_count(user_id, current_count) + + logger.info(f"[Session] リピーター: user_id={user_id}, 訪問={current_count + 1}回目") + else: + # 新規ユーザー: DBにはまだ書き込まない + # → 名前登録時(LLM action)に初めてINSERT + is_first_visit = True + long_term_profile = None + logger.info(f"[Session] 新規ユーザー: user_id={user_id}") + + except Exception as e: + logger.error(f"[Session] 長期記憶の読み込みエラー: {e}") + + data = { + 'session_id': self.session_id, + 'user_id': user_id, # ★ user_id を保存(DB操作に使用) + 'messages': [], # SDKネイティブのリスト形式用 + 'status': 'active', + 'user_info': user_info or {}, + 'language': language, + 'mode': mode, + 'summary': None, + 'inquiry_summary': None, + 'current_shops': [], + # 長期記憶関連の追加フィールド + 'is_first_visit': is_first_visit, + 'user_context': user_context, + 'long_term_profile': long_term_profile + } + _SESSION_CACHE[self.session_id] = data + logger.info(f"[Session] RAM作成: {self.session_id}, 言語: {language}, モード: {mode}, 初回: {is_first_visit}") + return data + + def add_message(self, role, content, message_type='chat'): + """メッセージを追加(役割(Role)$00E5$02C6$00A5$00E3$0081$00AE$00E6§$2039$00E9$20AC $00E3$0081§$00E4$00BF$009D$00E5$00AD$02DC$00EF$00BC‰""" + data = self.get_data() + if not data: + return None + + # genai SDKが理解できる構造で保存 + message = { + 'role': 'user' if role == 'user' else 'model', + 'parts': [content], + 'type': message_type, # 内部管理用 + 'timestamp': datetime.now().isoformat() + } + data['messages'].append(message) + logger.info(f"[Session] メッセージ追加: role={message['role']}, type={message_type}") + return message + + def get_history_for_api(self): + """SDKにそのまま渡せる形式のリストを返す(types.Contentオブジェクトのリスト)""" + data = self.get_data() + if not data: + return [] + + # 【重要】辞書ではなくtypes.Contentオブジェクトを作成 + history = [] + for m in data['messages']: + if m['type'] == 'chat': + # types.Contentオブジェクトを作成 + content = types.Content( + role=m['role'], + parts=[types.Part(text=m['parts'][0])] # partsは文字列のリストなので最初の要素を取得 + ) + history.append(content) + + logger.info(f"[Session] API用履歴生成: {len(history)}件のメッセージ") + return history + + def get_messages(self, include_types=None): + """メッセージ履歴を取得(互換性のため残す)""" + data = self.get_data() + if not data: + return [] + + messages = data.get('messages', []) + + if include_types: + messages = [m for m in messages if m.get('type') in include_types] + + return messages + + def save_current_shops(self, shops): + """現在の店舗リストを保存""" + data = self.get_data() + if data: + data['current_shops'] = shops + logger.info(f"[Session] 店舗リスト保存: {len(shops)}件") + + def get_current_shops(self): + """現在の店舗リストを取得""" + data = self.get_data() + return data.get('current_shops', []) if data else [] + + def update_status(self, status, **kwargs): + """ステータス更新""" + data = self.get_data() + if data: + data['status'] = status + data.update(kwargs) + logger.info(f"[Session] ステータス更新: {status}") + + def get_data(self): + """セッションデータ取得""" + return _SESSION_CACHE.get(self.session_id) + + def get_language(self): + """セッション言語を取得""" + data = self.get_data() + return data.get('language', 'ja') if data else 'ja' + + def get_mode(self): + """セッションモードを取得""" + data = self.get_data() + return data.get('mode', 'chat') if data else 'chat' + + def update_language(self, language: str): + """セッション言語を更新""" + data = self.get_data() + if data: + data['language'] = language + logger.info(f"[Session] 言語更新: {language}") + + def update_mode(self, mode: str): + """セッションモードを更新""" + data = self.get_data() + if data: + data['mode'] = mode + logger.info(f"[Session] モード更新: {mode}") + + +class SupportAssistant: + """サポートアシスタント - モード対応版""" + + def __init__(self, session: SupportSession, system_prompts: dict): + self.session = session + self.language = session.get_language() + self.mode = session.get_mode() # ★ モードを取得 + + # ★★★ モードに応じたプロンプトを選択 ★★★ + mode_prompts = system_prompts.get(self.mode, SYSTEM_PROMPTS.get('chat', {})) + self.system_prompt = mode_prompts.get(self.language, mode_prompts.get('ja', '')) + + # ★★★ 長期記憶のコンテキストをシステムプロンプトに追加(コンシェルジュモードのみ) ★★★ + session_data = session.get_data() + if self.mode == 'concierge' and session_data: + is_first_visit = session_data.get('is_first_visit', True) + profile = session_data.get('long_term_profile', {}) + + if is_first_visit: + # 初回訪問時は、名前登録の指示を追加 + first_visit_context = """ +【重要: 初回訪問ユーザー】 +このユーザーは初めての訪問です。 +- ユーザーが名前を教えてくれたら、必ず action フィールドを使って名前を登録してください +- action形式: {"type": "update_user_profile", "updates": {"preferred_name": "名前", "name_honorific": "様"}} +- 敬称はユーザーの希望がなければデフォルトで「様」を使用 +- ユーザーが名前を教えたくない場合は、名前なしで会話を続けてください +""" + self.system_prompt = f"{self.system_prompt}\n\n{first_visit_context}" + logger.info(f"[Assistant] 初回訪問コンテキストを注入") + elif profile: + # リピーター: プロファイル情報をコンテキストに注入 + preferred_name = profile.get('preferred_name', '') + name_honorific = profile.get('name_honorific', '') + visit_count = profile.get('visit_count', 1) + + if preferred_name: + user_context = f""" +【ユーザー情報】 +- 呼び方: {preferred_name}{name_honorific} +- 訪問回数: {visit_count}回目 +""" + else: + # 名前未登録のリピーター + user_context = f""" +【ユーザー情報】 +- 名前: 未登録(名前での呼びかけはしないでください) +- 訪問回数: {visit_count}回目 +""" + self.system_prompt = f"{self.system_prompt}\n\n{user_context}" + logger.info(f"[Assistant] ユーザーコンテキストを注入(リピーター)") + + logger.info(f"[Assistant] 初期化: mode={self.mode}, language={self.language}") + + def get_initial_message(self): + """初回メッセージ - モード別 + 初回訪問判定(コンシェルジュモードのみ)""" + session_data = self.session.get_data() + + # 通常の挨拶(デフォルト) + greetings = INITIAL_GREETINGS.get(self.mode, INITIAL_GREETINGS.get('chat', {})) + base_greeting = greetings.get(self.language, greetings.get('ja', '')) + + # チャットモードは常にシンプルな挨拶のみ + if self.mode != 'concierge': + logger.info(f"[Assistant] チャットモード: シンプルな挨拶") + return base_greeting + + # ======================================== + # 以下はコンシェルジュモードのみ + # ======================================== + + is_first_visit = session_data.get('is_first_visit', True) if session_data else True + + # デバッグログ + logger.info(f"[Assistant] コンシェルジュモード: is_first_visit={is_first_visit}") + if session_data: + profile = session_data.get('long_term_profile', {}) + logger.info(f"[Assistant] Profile: {profile}") + + # 初回訪問の場合、名前を聞く + if is_first_visit: + first_visit_greetings = { + 'ja': '初めまして、AIコンシェルジュです。\n宜しければ、あなたを何とお呼びすればいいか、教えて頂けますか?', + 'en': 'Nice to meet you! I am your AI Concierge.\nMay I ask what I should call you?', + 'zh': '$60A8好!我是AI礼$5BBE$5458。\n$8BF7$95EE我$5E94$8BE5怎$4E48称呼$60A8?', + 'ko': '$CC98$C74C $BD59$ACA0$C2B5$B2C8$B2E4! AI $CEE8$C2DC$C5B4$C9C0$C785$B2C8$B2E4.\n$C5B4$B5BB$AC8C $BD88$B7EC$B4DC$B9AC$BA74 $B420$AE4C$C694?' + } + return first_visit_greetings.get(self.language, first_visit_greetings['ja']) + + # 2回目以降は、名前を呼びかけてから質問 + profile = session_data.get('long_term_profile', {}) if session_data else {} + preferred_name = profile.get('preferred_name', '') if profile else '' + name_honorific = profile.get('name_honorific', '') if profile else '' + + # 質問部分(共通) + question_part = { + 'ja': '今日はどのようなシーンでお店をお探しでしょうか?接待、デート、女子会など、お気軽にお聞かせください。', + 'en': 'What kind of dining experience are you looking for today? Business dinner, date, gathering with friends?', + 'zh': '今天$60A8想$5BFB找什$4E48$6837的用餐$573A景?商$52A1宴$8BF7、$7EA6会、朋友聚会?', + 'ko': '$C624$B298$C740 $C5B4$B5A4 $C2DD$C0AC $C7A5$BA74$C744 $CC3E$C73C$C2DC$B098$C694? $C811$B300, $B370$C774$D2B8, $BAA8$C784 $B4F1?' + } + question = question_part.get(self.language, question_part['ja']) + + # 名前がある場合、個別挨拶に変更 + if preferred_name: + personalized_greetings = { + 'ja': f'お帰りなさいませ、{preferred_name}{name_honorific}。\n{question}', + 'en': f'Welcome back, {preferred_name}{name_honorific}!\n{question}', + 'zh': f'$6B22迎回来,{preferred_name}{name_honorific}!\n{question}', + 'ko': f'$B2E4$C2DC $C624$C2E0 $AC83$C744 $D658$C601$D569$B2C8$B2E4, {preferred_name}{name_honorific}!\n{question}' + } + return personalized_greetings.get(self.language, personalized_greetings['ja']) + + # 名前未登録のリピーター: シンプルな挨拶(名前呼びなし) + nameless_greetings = { + 'ja': f'いらっしゃいませ。\n{question}', + 'en': f'Welcome!\n{question}', + 'zh': f'$6B22迎光$4E34!\n{question}', + 'ko': f'$C5B4$C11C$C624$C138$C694!\n{question}' + } + return nameless_greetings.get(self.language, nameless_greetings['ja']) + + def is_followup_question(self, user_message, current_shops): + """深掘り質問かどうかを判定""" + if not current_shops: + return False + + # フォローアップ質問のパターン(料理名は除外 - 初回検索で誤判定されるため) + followup_patterns = [ + 'この中で', 'これらの中で', 'さっきの', '先ほどの', + 'どれが', 'どこが', 'どの店', '何番目', + '予約', '電話番号', '営業時間', 'アクセス', + '詳しく', 'もっと', 'について' + ] + + message_lower = user_message.lower() + return any(pattern in message_lower for pattern in followup_patterns) + + def process_user_message(self, user_message, conversation_stage='conversation'): + """ + ユーザーメッセージを処理 + + 【重要】改善されたフロー: + 1. 履歴を構造化リストで取得 + 2. 履歴には既に最新のユーザーメッセージが含まれている(add_message$00E3$0081§$00E8$00BF$00BD$00E5$0160 $00E6$00B8$02C6$00E3$0081$00BF$00EF$00BC‰ + 3. そのため、履歴をそのままGeminiに渡す + """ + # 履歴を構造化リストで取得(既に最新のユーザーメッセージを含む) + history = self.session.get_history_for_api() + current_shops = self.session.get_current_shops() + + is_followup = self.is_followup_question(user_message, current_shops) + + # フォローアップの場合は現在の店舗情報をシステムプロンプトに追加 + system_prompt = self.system_prompt + if is_followup and current_shops: + followup_messages = { + 'ja': { + 'header': '【現在提案中の店舗情報】', + 'footer': 'ユーザーは上記の店舗について質問しています。店舗情報を参照して回答してください。' + }, + 'en': { + 'header': '【Currently Proposed Restaurants】', + 'footer': 'The user is asking about the restaurants listed above. Please refer to the restaurant information when answering.' + }, + 'zh': { + 'header': '【当前推荐的餐$5385信息】', + 'footer': '用$6237正在$8BE2$95EE上述餐$5385的信息。$8BF7参考餐$5385信息$8FDB行回答。' + }, + 'ko': { + 'header': '【$D604$C7AC $C81C$C548 $C911$C778 $B808$C2A4$D1A0$B791 $C815$BCF4】', + 'footer': '$C0AC$C6A9$C790$B294 $C704 $B808$C2A4$D1A0$B791$C5D0 $B300$D574 $C9C8$BB38$D558$ACE0 $C788$C2B5$B2C8$B2E4. $B808$C2A4$D1A0$B791 $C815$BCF4$B97C $CC38$C870$D558$C5EC $B2F5$BCC0$D558$C138$C694.' + } + } + shop_context = f"\n\n{current_followup_msg['header']}\n{self._format_current_shops(current_shops)}\n\n{current_followup_msg['footer']}" + system_prompt = self.system_prompt + shop_context + logger.info("[Assistant] フォローアップ質問モード: 店舗情報をシステムプロンプトに追加") + + # ツール設定 + tools = None + if not is_followup: + tools = [types.Tool(google_search=types.GoogleSearch())] + logger.info("[Assistant] Google検索グラウンディングを有効化") + + try: + logger.info(f"[Assistant] Gemini API呼び出し開始: 履歴={len(history)}件") + + # --------------------------------------------------------- + # 【修正箇所】ここを書き換えてください + # --------------------------------------------------------- + # 【重要】configパラメータの設定 + # Google検索(tools)を使う場合は、response_mime_type="application/json" を + # 指定してはいけません(400エラーの原因になります)。 + config = types.GenerateContentConfig( + system_instruction=system_prompt if system_prompt else None, + tools=tools if tools else None, + # response_mime_type="application/json" # ← ★必ずコメントアウト(先頭に # をつける) + ) + + response = gemini_client.models.generate_content( + model="gemini-2.5-flash", + contents=history, + config=config + ) + # --------------------------------------------------------- + + logger.info("[Assistant] Gemini API呼び出し完了") + + # レスポンスからテキストを取得 + assistant_text = response.text + + if not assistant_text: + logger.error("[Assistant] Empty response from Gemini") + raise RuntimeError("Gemini returned empty response") + + logger.info(f"[Assistant] Gemini response received: {len(assistant_text)} chars") + + + # 【デバッグ】エンコーディング確認用ログ + logger.info(f"[DEBUG] Response encoding type: {type(assistant_text)}") + logger.info(f"[DEBUG] Response first 200 chars: {repr(assistant_text[:200])}") + + # UTF-8として正しくエンコードされているか確認 + try: + test_encode = assistant_text.encode('utf-8') + logger.info(f"[DEBUG] UTF-8 encoding test: OK ({len(test_encode)} bytes)") + except Exception as e: + logger.error(f"[DEBUG] UTF-8 encoding test: FAILED - {e}") + parsed_message, parsed_shops, parsed_action = self._parse_json_response(assistant_text) + + if parsed_shops: + self.session.save_current_shops(parsed_shops) + + summary = None + if conversation_stage == 'conversation': + if parsed_shops: + summary_messages = { + 'ja': lambda count: f"{count}軒のお店を提案しました。", + 'en': lambda count: f"Suggested {count} restaurants.", + 'zh': lambda count: f"推荐了{count}家餐$5385。", + 'ko': lambda count: f"{count}$AC1C$C758 $B808$C2A4$D1A0$B791$C744 $C81C$C548$D588$C2B5$B2C8$B2E4." + } + summary_func = summary_messages.get(self.language, summary_messages['ja']) + summary = summary_func(len(parsed_shops)) + else: + summary = self._generate_summary(user_message, parsed_message) + + return { + 'response': parsed_message, + 'summary': summary, + 'shops': parsed_shops, + 'should_confirm': conversation_stage == 'conversation', + 'is_followup': is_followup, + 'action': parsed_action + } + + except Exception as e: + logger.error(f"[Assistant] Gemini API error: {e}", exc_info=True) + error_messages = { + 'ja': 'エラーが発生しました。もう一度お試しください。', + 'en': 'An error occurred. Please try again.', + 'zh': '発生錯誤。請重試。', + 'ko': '$C624$B958$AC00 $BC1C$C0DD$D588$C2B5$B2C8$B2E4. $B2E4$C2DC $C2DC$B3C4$D574$C8FC$C138$C694.' + } + return { + 'response': error_messages.get(self.language, error_messages['ja']), + 'summary': None, + 'shops': [], + 'should_confirm': False, + 'is_followup': False + } + + def generate_final_summary(self): + """最終要約を生成""" + all_messages = self.session.get_history_for_api() + + # 会話テキストを整形 + # 【重要】all_messagesはtypes.Contentオブジェクトのリスト + conversation_lines = [] + for msg in all_messages: + role_name = 'ユーザー' if msg.role == 'user' else 'アシスタント' + # msg.partsはtypes.Partオブジェクトのリストなので、最初の要素のtextを取得 + conversation_lines.append(f"{role_name}: {msg.parts[0].text}") + conversation_text = '\n'.join(conversation_lines) + + template = FINAL_SUMMARY_TEMPLATES.get(self.language, FINAL_SUMMARY_TEMPLATES['ja']) + summary_prompt = template.format( + conversation_text=conversation_text, + timestamp=datetime.now().strftime('%Y-%m-%d %H:%M:%S') + ) + + try: + logger.info("[Assistant] Generating final summary") + response = gemini_client.models.generate_content( + model="gemini-2.5-flash", + contents=summary_prompt + ) + summary = response.text + + self.session.update_status( + 'completed', + inquiry_summary=summary + ) + + return summary + + except Exception as e: + logger.error(f"[Assistant] Final summary error: {e}", exc_info=True) + return "要約の生成中にエラーが発生しました。" + + def _format_current_shops(self, shops): + """店舗情報を整形してプロンプトに追加""" + # 多言語ラベル + shop_labels = { + 'ja': { + 'description': '説明', + 'specialty': '看板メニュー', + 'price': '予算', + 'atmosphere': '雰囲気', + 'features': '特色' + }, + 'en': { + 'description': 'Description', + 'specialty': 'Specialty', + 'price': 'Price Range', + 'atmosphere': 'Atmosphere', + 'features': 'Features' + }, + 'zh': { + 'description': '$8BF4明', + 'specialty': '招牌菜', + 'price': '$9884算', + 'atmosphere': '氛$56F4', + 'features': '特色' + }, + 'ko': { + 'description': '$C124$BA85', + 'specialty': '$B300$D45C $BA54$B274', + 'price': '$C608$C0B0', + 'atmosphere': '$BD84$C704$AE30', + 'features': '$D2B9$C9D5' + } + } + + current_shop_labels = shop_labels.get(self.language, shop_labels['ja']) + lines = [] + for i, shop in enumerate(shops, 1): + lines.append(f"{i}. {shop.get('name', '')} ({shop.get('area', '')})") + lines.append(f" - {current_shop_labels['description']}: {shop.get('description', '')}") + if shop.get('specialty'): + lines.append(f" - {current_shop_labels['specialty']}: {shop.get('specialty')}") + if shop.get('price_range'): + lines.append(f" - {current_shop_labels['price']}: {shop.get('price_range')}") + if shop.get('atmosphere'): + lines.append(f" - {current_shop_labels['atmosphere']}: {shop.get('atmosphere')}") + if shop.get('features'): + lines.append(f" - {current_shop_labels['features']}: {shop.get('features')}") + lines.append("") + return "\n".join(lines) + + def _parse_json_response(self, text: str) -> tuple: + """JSONレスポンスをパース - 最初のJSONオブジェクトのみ抽出""" + try: + # 【重要】最初の { から 対応する } までを抽出 + # 入れ子のJSONに対応するため、ブレースのカウントを行う + start_idx = text.find('{') + if start_idx == -1: + logger.warning("[JSON Parse] JSON形式が見つかりません") + shops = extract_shops_from_response(text) + return text, shops, None + + # ブレースのカウントで対応する閉じブレースを見つける + brace_count = 0 + end_idx = -1 + for i in range(start_idx, len(text)): + if text[i] == '{': + brace_count += 1 + elif text[i] == '}': + brace_count -= 1 + if brace_count == 0: + end_idx = i + 1 + break + + if end_idx == -1: + logger.warning("[JSON Parse] JSONの閉じブレースが見つかりません") + shops = extract_shops_from_response(text) + return text, shops, None + + json_str = text[start_idx:end_idx].strip() + logger.info(f"[JSON Parse] JSONオブジェクトを検出: {len(json_str)}文字") + + data = json.loads(json_str) + + message = data.get('message', text) + shops = data.get('shops', []) + action = data.get('action', None) + + logger.info(f"[JSON Parse] 成功: message={len(message)}文字, shops={len(shops)}件, action={action is not None}") + return message, shops, action + + except json.JSONDecodeError as e: + logger.warning(f"[JSON Parse] パース失敗: {e}") + shops = extract_shops_from_response(text) + return text, shops, None + + def _generate_summary(self, user_message, assistant_response): + """会話の要約を生成""" + template = CONVERSATION_SUMMARY_TEMPLATES.get(self.language, CONVERSATION_SUMMARY_TEMPLATES['ja']) + summary_prompt = template.format( + user_message=user_message, + assistant_response=assistant_response + ) + + try: + logger.info("[Assistant] Generating summary") + response = gemini_client.models.generate_content( + model="gemini-2.5-flash", + contents=summary_prompt + ) + return response.text + + except Exception as e: + logger.error(f"[Assistant] Summary generation error: {e}", exc_info=True) + return None + + +# ======================================== +# API エンドポイント +# ======================================== + diff --git a/support_base/frontend/HANDOVER-gourmet-sp2.md b/support_base/frontend/HANDOVER-gourmet-sp2.md new file mode 100644 index 0000000..5f61a89 --- /dev/null +++ b/support_base/frontend/HANDOVER-gourmet-sp2.md @@ -0,0 +1,156 @@ +# gourmet-sp2 引継ぎドキュメント + +> 前セッション (session_01E9rf3QsqK1jCcMpd5RR9f1) で実装済み・コミット済みだが、 +> Git プロキシの認可制限により **push できていない**。 +> このドキュメントは gourmet-sp2 リポジトリで新セッションを起動した際の引継ぎ用。 + +--- + +## 1. やるべきこと (最優先) + +### ブランチをプッシュする + +```bash +# gourmet-sp2 リポジトリ内で実行 +git checkout claude/platform-design-docs-oEVkm +git push -u origin claude/platform-design-docs-oEVkm +``` + +ブランチ `claude/platform-design-docs-oEVkm` にコミット済み (1コミット先行)。 +**main にマージ前にプッシュするだけでOK。** + +### 未ステージの変更もコミット&プッシュする + +`package.json` と `package-lock.json` に未ステージの変更あり: +- `@astrojs/check` (devDependency) 追加 +- `typescript` (devDependency) 追加 + +```bash +git add package.json package-lock.json +git commit -m "chore: add @astrojs/check and typescript devDependencies" +git push -u origin claude/platform-design-docs-oEVkm +``` + +--- + +## 2. 実装済みの内容 + +### コミット: `17a32a8` — feat: Live API platform integration + /api/v2/ migration + +**変更ファイル一覧 (7ファイル, +1142行, -183行):** + +| ファイル | 種別 | 説明 | +|---------|------|------| +| `src/scripts/platform/live-ws-client.ts` | **新規** | WebSocket client for support_base LiveRelay (relay.py) | +| `src/scripts/platform/live-audio-io.ts` | **新規** | PCM 16kHz mic capture + PCM 24kHz playback via AudioContext | +| `src/scripts/platform/dialogue-manager.ts` | **新規** | REST/Live API switching layer with unified session management | +| `src/scripts/chat/core-controller.ts` | **変更** | DialogueManager統合, Live APIイベントハンドラ, mic streaming, /api/v2/ 移行 | +| `src/scripts/chat/concierge-controller.ts` | **変更** | Live API expression→LAMAvatar, TTS via DialogueManager /api/v2/, session管理 | +| `vercel.json` | **新規** | COOP/COEP headers, API proxy rewrites, Astro config | +| `.env.example` | **新規** | PUBLIC_API_URL ドキュメント | + +### アーキテクチャ + +``` +[ブラウザ (gourmet-sp2 / Astro)] + ├── ConciergeController ← UIイベント・アバター描画制御 + │ └── CoreController ← 対話ロジック・Live API制御 + │ └── DialogueManager ← REST / Live API 切替レイヤー + │ ├── (REST mode) → /api/v2/* via fetch + │ └── (Live mode) → LiveWSClient (WebSocket) + │ LiveAudioIO (mic/speaker) + │ + ├── vercel.json rewrites → support_base Cloud Run + └── .env.example → PUBLIC_API_URL +``` + +### エンドポイント移行 + +全 API コールを `/api/*` → `/api/v2/*` に移行済み: +- `/api/v2/session/start` +- `/api/v2/session/end` +- `/api/v2/tts` +- `/api/v2/chat` +- `/socket.io/*` (WebSocket relay) + +--- + +## 3. vercel.json + +```json +{ + "framework": "astro", + "buildCommand": "npm run build", + "outputDirectory": "dist", + "headers": [ + { + "source": "/(.*)", + "headers": [ + { "key": "Cross-Origin-Opener-Policy", "value": "same-origin" }, + { "key": "Cross-Origin-Embedder-Policy", "value": "require-corp" } + ] + } + ], + "rewrites": [ + { "source": "/api/v2/:path*", "destination": "${PUBLIC_API_URL}/api/v2/:path*" }, + { "source": "/socket.io/:path*", "destination": "${PUBLIC_API_URL}/socket.io/:path*" } + ] +} +``` + +COOP/COEP は SharedArrayBuffer (AudioWorklet) に必要。 + +--- + +## 4. .env.example + +``` +PUBLIC_API_URL=https://support-base-xxxxx-an.a.run.app +``` + +Vercel デプロイ時に Project Settings > Environment Variables で設定する。 + +--- + +## 5. リモートURLの修正が必要 + +前セッションで remote URL が誤ったプロキシに変更されている: + +``` +origin → http://local_proxy@127.0.0.1:18638/git/mirai-gpro/gourmet-sp2 (誤り) +``` + +新セッションでは自動的に正しいプロキシが設定されるはずだが、 +もし GitHub URL に戻す必要がある場合: + +```bash +git remote set-url origin https://github.com/mirai-gpro/gourmet-sp2.git +``` + +--- + +## 6. ミラーコピー (LAM_gpro 側) + +全実装ファイルのコピーが LAM_gpro にも保存されている: + +``` +LAM_gpro/support_base/frontend/gourmet-sp2-impl/ + ├── live-ws-client.ts + ├── live-audio-io.ts + ├── dialogue-manager.ts + ├── core-controller.ts + ├── concierge-controller.ts + ├── vercel.json + └── .env.example +``` + +万が一 gourmet-sp2 のブランチが壊れた場合のバックアップ。 + +--- + +## 7. 次のステップ (プッシュ後) + +1. **PR 作成**: `claude/platform-design-docs-oEVkm` → `main` +2. **Vercel 環境変数設定**: `PUBLIC_API_URL` を Cloud Run URL に設定 +3. **結合テスト**: TTS → Audio2Exp → アバター描画のパイプライン全体テスト +4. **support_base 側**: LiveRelay (relay.py) と /api/v2/ エンドポイントの動作確認 diff --git a/support_base/frontend/astro.config.mjs b/support_base/frontend/astro.config.mjs new file mode 100644 index 0000000..89b419c --- /dev/null +++ b/support_base/frontend/astro.config.mjs @@ -0,0 +1,16 @@ +import { defineConfig } from 'astro/config'; + +export default defineConfig({ + // バックエンド API へのプロキシ(開発時) + vite: { + server: { + proxy: { + '/api': { + target: 'http://localhost:8080', + changeOrigin: true, + ws: true, // WebSocket プロキシ(Live API 用) + }, + }, + }, + }, +}); diff --git a/support_base/frontend/gourmet-sp2-impl/.env.example b/support_base/frontend/gourmet-sp2-impl/.env.example new file mode 100644 index 0000000..d735f9d --- /dev/null +++ b/support_base/frontend/gourmet-sp2-impl/.env.example @@ -0,0 +1,3 @@ +# Backend API URL (support_base on Cloud Run) +# Vercel: Set in Project Settings > Environment Variables +PUBLIC_API_URL=https://support-base-xxxxx-an.a.run.app diff --git a/support_base/frontend/gourmet-sp2-impl/concierge-controller.ts b/support_base/frontend/gourmet-sp2-impl/concierge-controller.ts new file mode 100644 index 0000000..e0b5f97 --- /dev/null +++ b/support_base/frontend/gourmet-sp2-impl/concierge-controller.ts @@ -0,0 +1,1027 @@ + + +// src/scripts/chat/concierge-controller.ts +import { CoreController } from './core-controller'; +import { AudioManager } from './audio-manager'; +import type { ExpressionData } from '../platform/dialogue-manager'; + +declare const io: any; + +export class ConciergeController extends CoreController { + // Audio2Expression はバックエンドTTSエンドポイント経由で統合済み + private pendingAckPromise: Promise | null = null; + + constructor(container: HTMLElement, apiBase: string) { + super(container, apiBase); + + // ★コンシェルジュモード用のAudioManagerを6.5秒設定で再初期化2 + this.audioManager = new AudioManager(8000); + + // コンシェルジュモードに設定 + this.currentMode = 'concierge'; + this.init(); + } + + // 初期化プロセスをオーバーライド + protected async init() { + // 親クラスの初期化を実行 + await super.init(); + + // コンシェルジュ固有の要素とイベントを追加 + const query = (sel: string) => this.container.querySelector(sel) as HTMLElement; + this.els.avatarContainer = query('.avatar-container'); + this.els.avatarImage = query('#avatarImage') as HTMLImageElement; + this.els.modeSwitch = query('#modeSwitch') as HTMLInputElement; + + // モードスイッチのイベントリスナー追加 + if (this.els.modeSwitch) { + this.els.modeSwitch.addEventListener('change', () => { + this.toggleMode(); + }); + } + + // ★ LAMAvatar との統合: 外部TTSプレーヤーをリンク + // LAMAvatar が後から初期化される可能性があるため、即時 + 遅延リトライでリンク + let linked = false; + let linkAttempts = 0; + const linkTtsPlayer = () => { + if (linked) return true; + linkAttempts++; + const lam = (window as any).lamAvatarController; + if (lam && typeof lam.setExternalTtsPlayer === 'function') { + lam.setExternalTtsPlayer(this.ttsPlayer); + linked = true; + console.log(`[Concierge] TTS player linked with LAMAvatar (attempt #${linkAttempts})`); + return true; + } + console.log(`[Concierge] LAMAvatar not ready yet (attempt #${linkAttempts})`); + return false; + }; + if (!linkTtsPlayer()) { + // 遅延リトライ: 500ms, 1000ms, 2000ms, 4000ms + const retryDelays = [500, 1000, 2000, 4000]; + retryDelays.forEach((delay) => { + setTimeout(() => linkTtsPlayer(), delay); + }); + } + + // ★ 診断用: ブラウザコンソールから __testLipSync() で呼び出し可能 + (window as any).__testLipSync = () => this.runLipSyncDiagnostic(); + } + + /** + * レンダラー診断テスト + * ブラウザコンソールから __testLipSync() で実行 + * + * 日本語5母音(あいうえお)の既知blendshapeパターンを + * 無音音声と同期再生し、レンダラーが52次元データを正しく描画できるか判定する + * + * 判定基準: + * - あ: 口が大きく開く (jawOpen高) + * - い: 口角が横に広がる (mouthSmile高) + * - う: 口がすぼまる (mouthFunnel/Pucker高) + * - え: 口が横に広がり中程度に開く (mouthStretch高) + * - お: 口が丸くなる (mouthFunnel高 + jawOpen中) + * + * 結果: + * ✓ 5母音で明らかに異なる口形状 → レンダラーは52次元対応 + * ✗ jawの開閉しか見えない → レンダラーはjawOpen単次元のみ + */ + private runLipSyncDiagnostic(): void { + const lam = (window as any).lamAvatarController; + if (!lam) { + console.error('[DIAG] lamAvatarController not found'); + return; + } + + // 日本語5母音のARKitブレンドシェイプパターン + const base: { [k: string]: number } = {}; // 全て0で初期化 + const vowelPatterns: { [vowel: string]: { [k: string]: number } } = { + 'あ(a)': { jawOpen: 0.7, mouthLowerDownLeft: 0.5, mouthLowerDownRight: 0.5, mouthUpperUpLeft: 0.2, mouthUpperUpRight: 0.2 }, + 'い(i)': { jawOpen: 0.2, mouthSmileLeft: 0.6, mouthSmileRight: 0.6, mouthStretchLeft: 0.4, mouthStretchRight: 0.4 }, + 'う(u)': { jawOpen: 0.15, mouthFunnel: 0.6, mouthPucker: 0.5 }, + 'え(e)': { jawOpen: 0.4, mouthStretchLeft: 0.5, mouthStretchRight: 0.5, mouthSmileLeft: 0.3, mouthSmileRight: 0.3, mouthLowerDownLeft: 0.3, mouthLowerDownRight: 0.3 }, + 'お(o)': { jawOpen: 0.5, mouthFunnel: 0.5, mouthPucker: 0.3, mouthLowerDownLeft: 0.2, mouthLowerDownRight: 0.2 }, + }; + + // フレーム生成: neutral(15) → 各母音(20frames=0.67s) → neutral(15) + const frameRate = 30; + const frames: { [k: string]: number }[] = []; + const addFrames = (pattern: { [k: string]: number }, count: number, label?: string) => { + for (let i = 0; i < count; i++) { + frames.push({ ...base, ...pattern }); + } + if (label) console.log(`[DIAG] ${label}: frames ${frames.length - count}-${frames.length - 1}`); + }; + + addFrames(base, 15, 'neutral (start)'); + for (const [vowel, pattern] of Object.entries(vowelPatterns)) { + addFrames(pattern, 20, vowel); + } + addFrames(base, 15, 'neutral (end)'); + + const totalFrames = frames.length; + const durationSec = totalFrames / frameRate + 0.5; + + // 無音WAVを生成(ttsPlayer経由で再生して同期トリガー) + const sampleRate = 8000; + const numSamples = Math.floor(durationSec * sampleRate); + const wavBuf = new ArrayBuffer(44 + numSamples * 2); + const dv = new DataView(wavBuf); + const ws = (off: number, s: string) => { for (let i = 0; i < s.length; i++) dv.setUint8(off + i, s.charCodeAt(i)); }; + ws(0, 'RIFF'); + dv.setUint32(4, 36 + numSamples * 2, true); + ws(8, 'WAVE'); ws(12, 'fmt '); + dv.setUint32(16, 16, true); + dv.setUint16(20, 1, true); dv.setUint16(22, 1, true); + dv.setUint32(24, sampleRate, true); dv.setUint32(28, sampleRate * 2, true); + dv.setUint16(32, 2, true); dv.setUint16(34, 16, true); + ws(36, 'data'); + dv.setUint32(40, numSamples * 2, true); + + const wavUrl = URL.createObjectURL(new Blob([wavBuf], { type: 'audio/wav' })); + + // LAMAvatarにフレーム投入 + 再生 + lam.clearFrameBuffer(); + lam.queueExpressionFrames(frames, frameRate); + + this.ttsPlayer.src = wavUrl; + this.ttsPlayer.play().then(() => { + console.log(`[DIAG] ▶ Playing: ${totalFrames} frames, ${durationSec.toFixed(1)}s`); + console.log('[DIAG] 0.5s neutral → 0.67s あ → 0.67s い → 0.67s う → 0.67s え → 0.67s お → 0.5s neutral'); + console.log('[DIAG] ✓ 5母音で口形状が変われば → レンダラーは52次元blendshape対応'); + console.log('[DIAG] ✗ jawの開閉のみ → レンダラーはjawOpen単次元'); + }).catch((e: any) => { + console.error('[DIAG] Play failed:', e); + console.log('[DIAG] ユーザー操作後に再試行してください(autoplay制限)'); + }); + } + + // ======================================== + // ★ Live API Expression 受信: LAMAvatar に投入 + // ======================================== + protected handleLiveExpression(data: ExpressionData): void { + const lamController = (window as any).lamAvatarController; + if (!lamController || !data?.names || !data?.frames?.length) return; + + const frameRate = data.frame_rate || 30; + + // Live API の expression は REST と同じフォーマット + // relay.py: { names, frames, frame_rate } + const frames = data.frames.map((f: any) => { + const frame: { [key: string]: number } = {}; + const values: number[] = Array.isArray(f) ? f : (f.weights || []); + data.names.forEach((name: string, i: number) => { + let val = values[i] || 0; + val = Math.min(ConciergeController.BLENDSHAPE_SAFE_MAX, val); + frame[name] = val; + }); + return frame; + }); + + // Live API はストリーミングなので append(clearFrameBuffer しない) + lamController.queueExpressionFrames(frames, frameRate); + } + + // ======================================== + // 🎯 セッション初期化をオーバーライド(挨拶文を変更) + // ======================================== + protected async initializeSession() { + try { + // 既存セッション終了 + if (this.sessionId) { + try { + await this.dialogueManager.endSession(); + } catch (e) {} + } + + const userId = this.getUserId(); + + // ★ support_base /api/v2/ 経由でセッション開始 + const sessionInfo = await this.dialogueManager.startSession({ + mode: 'concierge', + language: this.currentLanguage, + dialogueType: this.dialogueType, + userId: userId, + userInfo: { user_id: userId }, + }); + + this.sessionId = sessionInfo.sessionId; + this.dialogueManager.currentSessionId = this.sessionId; + + // ✅ バックエンドからの初回メッセージを使用(長期記憶対応) + const greetingText = sessionInfo.initialMessage || this.t('initialGreetingConcierge'); + this.addMessage('assistant', greetingText, null, true); + + const ackTexts = [ + this.t('ackConfirm'), this.t('ackSearch'), this.t('ackUnderstood'), + this.t('ackYes'), this.t('ttsIntro') + ]; + const langConfig = this.LANGUAGE_CODE_MAP[this.currentLanguage]; + + const ackPromises = ackTexts.map(async (text) => { + try { + const result = await this.dialogueManager.synthesizeTTS( + text, langConfig.tts, langConfig.voice, this.sessionId! + ); + if (result.success && result.audio) { + this.preGeneratedAcks.set(text, result.audio); + } + } catch (_e) { } + }); + + await Promise.all([ + this.speakTextGCP(greetingText), + ...ackPromises + ]); + + this.els.userInput.disabled = false; + this.els.sendBtn.disabled = false; + this.els.micBtn.disabled = false; + this.els.speakerBtn.disabled = false; + this.els.speakerBtn.classList.remove('disabled'); + this.els.reservationBtn.classList.remove('visible'); + + console.log(`[Concierge] Session started: ${this.sessionId} type=${this.dialogueType}`); + + } catch (e) { + console.error('[Session] Initialization error:', e); + } + } + + // ======================================== + // 🔧 Socket.IOの初期化をオーバーライド + // ======================================== + protected initSocket() { + // @ts-ignore + this.socket = io(this.apiBase || window.location.origin); + + this.socket.on('connect', () => { }); + + // ✅ コンシェルジュ版のhandleStreamingSTTCompleteを呼ぶように再登録 + this.socket.on('transcript', (data: any) => { + const { text, is_final } = data; + if (this.isAISpeaking) return; + if (is_final) { + this.handleStreamingSTTComplete(text); // ← オーバーライド版が呼ばれる + this.currentAISpeech = ""; + } else { + this.els.userInput.value = text; + } + }); + + this.socket.on('error', (data: any) => { + this.addMessage('system', `${this.t('sttError')} ${data.message}`); + if (this.isRecording) this.stopStreamingSTT(); + }); + } + + // コンシェルジュモード固有: アバターアニメーション制御 + 公式リップシンク + protected async speakTextGCP(text: string, stopPrevious: boolean = true, autoRestartMic: boolean = false, skipAudio: boolean = false) { + if (skipAudio || !this.isTTSEnabled || !text) return Promise.resolve(); + + if (stopPrevious) { + this.ttsPlayer.pause(); + } + + // アバターアニメーションを開始 + if (this.els.avatarContainer) { + this.els.avatarContainer.classList.add('speaking'); + } + + // ★ 公式同期: TTS音声をaudio2exp-serviceに送信して表情を生成 + const cleanText = this.stripMarkdown(text); + try { + this.isAISpeaking = true; + if (this.isRecording && (this.isIOS || this.isAndroid)) { + this.stopStreamingSTT(); + } + + this.els.voiceStatus.innerHTML = this.t('voiceStatusSynthesizing'); + this.els.voiceStatus.className = 'voice-status speaking'; + const langConfig = this.LANGUAGE_CODE_MAP[this.currentLanguage]; + + // ★ DialogueManager 経由 /api/v2/ TTS + const data = await this.dialogueManager.synthesizeTTS( + cleanText, langConfig.tts, langConfig.voice, this.sessionId! + ); + + if (data.success && data.audio) { + // ★ TTS応答に同梱されたExpressionを即バッファ投入(遅延ゼロ) + if (data.expression) { + this.applyExpressionFromTts(data.expression); + } else { + console.warn(`[Concierge] TTS response has NO expression data (session=${this.sessionId})`); + } + this.ttsPlayer.src = `data:audio/mp3;base64,${data.audio}`; + const playPromise = new Promise((resolve) => { + this.ttsPlayer.onended = async () => { + this.els.voiceStatus.innerHTML = this.t('voiceStatusStopped'); + this.els.voiceStatus.className = 'voice-status stopped'; + this.isAISpeaking = false; + this.stopAvatarAnimation(); + if (autoRestartMic) { + if (!this.isRecording) { + try { await this.toggleRecording(); } catch (_error) { this.showMicPrompt(); } + } + } + resolve(); + }; + this.ttsPlayer.onerror = () => { + this.isAISpeaking = false; + this.stopAvatarAnimation(); + resolve(); + }; + }); + + if (this.isUserInteracted) { + this.lastAISpeech = this.normalizeText(cleanText); + await this.ttsPlayer.play(); + await playPromise; + } else { + this.showClickPrompt(); + this.els.voiceStatus.innerHTML = this.t('voiceStatusStopped'); + this.els.voiceStatus.className = 'voice-status stopped'; + this.isAISpeaking = false; + this.stopAvatarAnimation(); + } + } else { + this.isAISpeaking = false; + this.stopAvatarAnimation(); + } + } catch (_error) { + this.els.voiceStatus.innerHTML = this.t('voiceStatusStopped'); + this.els.voiceStatus.className = 'voice-status stopped'; + this.isAISpeaking = false; + this.stopAvatarAnimation(); + } + } + + // ★ 口周りblendshapeのスケール係数(ニュートラル設定) + // + // A2E出力をそのまま使用。SDKは全52チャンネルを morphTargetDictionary + // 経由で boneTexture に書き込み、シェーダーが全ch適用する。 + // 全値は BLENDSHAPE_SAFE_MAX(0.7) でクランプ(FLAME LBS 数値安定のため)。 + // + // チューニング時はここの値を調整する: + // 1.0 = 増幅なし(A2E出力そのまま) + // >1.0 = 増幅(口の動きを強調) + // <1.0 = 抑制(口の動きを控えめに) + private static readonly MOUTH_AMPLIFY: { [key: string]: number } = { + 'jawOpen': 1.0, + 'mouthClose': 1.0, + 'mouthFunnel': 1.0, + 'mouthPucker': 1.0, + 'mouthSmileLeft': 1.0, + 'mouthSmileRight': 1.0, + 'mouthStretchLeft': 1.0, + 'mouthStretchRight': 1.0, + 'mouthLowerDownLeft': 1.0, + 'mouthLowerDownRight': 1.0, + 'mouthUpperUpLeft': 1.0, + 'mouthUpperUpRight': 1.0, + 'mouthDimpleLeft': 1.0, + 'mouthDimpleRight': 1.0, + 'mouthRollLower': 1.0, + 'mouthRollUpper': 1.0, + 'mouthShrugLower': 1.0, + 'mouthShrugUpper': 1.0, + }; + + // FLAME LBS の安全範囲。これを超えるとメッシュが破綻(数値爆発)する + private static readonly BLENDSHAPE_SAFE_MAX = 0.7; + + /** + * TTS応答に同梱されたExpressionデータをバッファに即投入(遅延ゼロ) + * 同期方式: バックエンドがTTS+audio2expを同期実行し、結果を同梱して返す + * + * ★品質改善: + * 1. 口周りblendshapeの増幅 → 日本語母音の可視性向上 + * 2. フレーム補間 (30fps→60fps) → レンダラーの60fps描画に滑らかに追従 + * 3. 診断ログ → jawOpen/mouthFunnel等の統計値で品質を確認可能 + */ + private applyExpressionFromTts(expression: any): void { + const lamController = (window as any).lamAvatarController; + if (!lamController) { + console.warn('[Concierge] lamAvatarController not found - expression data dropped'); + return; + } + + // 新セグメント開始時は必ずバッファクリア(前セグメントのフレーム混入防止) + if (typeof lamController.clearFrameBuffer === 'function') { + lamController.clearFrameBuffer(); + } + + if (expression?.names && expression?.frames?.length > 0) { + const srcFrameRate = expression.frame_rate || 30; + + // Step 1: バックエンド形式 → LAMAvatar形式に変換 + blendshape増幅 + // ★ 新旧両フォーマット対応: + // 旧 (FastAPI): frames = [{"weights": [0.1, ...]}, ...] + // 新 (Flask): frames = [[0.1, ...], ...] + const rawFrames = expression.frames.map((f: any) => { + const frame: { [key: string]: number } = {}; + // フレームがArrayなら直接使用、objectなら.weightsから取得 + const values: number[] = Array.isArray(f) ? f : (f.weights || []); + expression.names.forEach((name: string, i: number) => { + let val = values[i] || 0; + // 口周りblendshapeをスケール + const amp = ConciergeController.MOUTH_AMPLIFY[name]; + if (amp && amp !== 1.0) { + val = val * amp; + } + // FLAME LBS 安全範囲でクランプ(>0.7 で数値不安定→メッシュ破綻) + val = Math.min(ConciergeController.BLENDSHAPE_SAFE_MAX, val); + frame[name] = val; + }); + return frame; + }); + + // Step 2: フレーム補間 (30fps → 60fps) — 線形補間で滑らかに + const interpolatedFrames: { [key: string]: number }[] = []; + for (let i = 0; i < rawFrames.length; i++) { + interpolatedFrames.push(rawFrames[i]); + if (i < rawFrames.length - 1) { + const curr = rawFrames[i]; + const next = rawFrames[i + 1]; + const mid: { [key: string]: number } = {}; + for (const key of Object.keys(curr)) { + mid[key] = (curr[key] + next[key]) * 0.5; + } + interpolatedFrames.push(mid); + } + } + const outputFrameRate = srcFrameRate * 2; // 30→60fps + + // Step 3: LAMAvatarにキュー投入 + lamController.queueExpressionFrames(interpolatedFrames, outputFrameRate); + + // Step 4: 診断ログ(blendshape統計値) + const stat = (key: string) => { + const vals = rawFrames.map((f: { [k: string]: number }) => f[key] || 0); + return { max: Math.max(...vals), avg: vals.reduce((a: number, b: number) => a + b, 0) / vals.length }; + }; + const jaw = stat('jawOpen'); + const lowerDown = stat('mouthLowerDownLeft'); + const funnel = stat('mouthFunnel'); + const pucker = stat('mouthPucker'); + const smile = stat('mouthSmileLeft'); + const stretch = stat('mouthStretchLeft'); + console.log(`[Concierge] Expression: ${rawFrames.length}→${interpolatedFrames.length} frames (${srcFrameRate}→${outputFrameRate}fps)`); + console.log(` jaw: max=${jaw.max.toFixed(3)} avg=${jaw.avg.toFixed(3)} | lowerDown: max=${lowerDown.max.toFixed(3)}`); + console.log(` funnel: max=${funnel.max.toFixed(3)} | pucker: max=${pucker.max.toFixed(3)} | smile: max=${smile.max.toFixed(3)} | stretch: max=${stretch.max.toFixed(3)}`); + } else { + console.warn(`[Concierge] No expression frames in TTS response (names=${!!expression?.names}, frames=${expression?.frames?.length || 0})`); + } + } + + // アバターアニメーション停止 + private stopAvatarAnimation() { + if (this.els.avatarContainer) { + this.els.avatarContainer.classList.remove('speaking'); + } + // ※ LAMAvatar の状態は ttsPlayer イベント(ended/pause)で管理 + } + + + // ======================================== + // 🎯 UI言語更新をオーバーライド(挨拶文をコンシェルジュ用に) + // ======================================== + protected updateUILanguage() { + // ✅ バックエンドからの長期記憶対応済み挨拶を保持 + const initialMessage = this.els.chatArea.querySelector('.message.assistant[data-initial="true"] .message-text'); + const savedGreeting = initialMessage?.textContent; + + // 親クラスのupdateUILanguageを実行(UIラベル等を更新) + super.updateUILanguage(); + + // ✅ 長期記憶対応済み挨拶を復元(親が上書きしたものを戻す) + if (initialMessage && savedGreeting) { + initialMessage.textContent = savedGreeting; + } + + // ✅ ページタイトルをコンシェルジュ用に設定 + const pageTitle = document.getElementById('pageTitle'); + if (pageTitle) { + pageTitle.innerHTML = ` ${this.t('pageTitleConcierge')}`; + } + } + + // モード切り替え処理 - ページ遷移 + private toggleMode() { + const isChecked = this.els.modeSwitch?.checked; + if (!isChecked) { + // チャットモードへページ遷移 + console.log('[ConciergeController] Switching to Chat mode...'); + window.location.href = '/'; + } + // コンシェルジュモードは既に現在のページなので何もしない + } + + // すべての活動を停止(アバターアニメーションも含む) + protected stopAllActivities() { + super.stopAllActivities(); + this.stopAvatarAnimation(); + } + + // ======================================== + // 🎯 並行処理フロー: 応答を分割してTTS処理 + // ======================================== + + /** + * センテンス単位でテキストを分割 + * 日本語: 。で分割 + * 英語・韓国語: . で分割 + * 中国語: 。で分割 + */ + private splitIntoSentences(text: string, language: string): string[] { + let separator: RegExp; + + if (language === 'ja' || language === 'zh') { + // 日本語・中国語: 。で分割 + separator = /。/; + } else { + // 英語・韓国語: . で分割 + separator = /\.\s+/; + } + + const sentences = text.split(separator).filter(s => s.trim().length > 0); + + // 分割したセンテンスに句点を戻す + return sentences.map((s, idx) => { + if (idx < sentences.length - 1 || text.endsWith('。') || text.endsWith('. ')) { + return language === 'ja' || language === 'zh' ? s + '。' : s + '. '; + } + return s; + }); + } + + /** + * 応答を分割して並行処理でTTS生成・再生 + * チャットモードのお店紹介フローを参考に実装 + */ + private async speakResponseInChunks(response: string, isTextInput: boolean = false) { + // TTS無効の場合はスキップ(テキスト入力でもコンシェルジュモードではTTS再生する) + if (!this.isTTSEnabled) { + return; + } + + try { + // ★ ack再生中ならttsPlayer解放を待つ(並行処理の同期ポイント) + if (this.pendingAckPromise) { + await this.pendingAckPromise; + this.pendingAckPromise = null; + } + this.stopCurrentAudio(); // ttsPlayer確実解放 + + this.isAISpeaking = true; + if (this.isRecording) { + this.stopStreamingSTT(); + } + + // センテンス分割 + const sentences = this.splitIntoSentences(response, this.currentLanguage); + + // 1センテンスしかない場合は従来通り(skipAudio=false: コンシェルジュでは常に再生) + if (sentences.length <= 1) { + await this.speakTextGCP(response, true, false, false); + this.isAISpeaking = false; + return; + } + + // 最初のセンテンスと残りのセンテンスに分割 + const firstSentence = sentences[0]; + const remainingSentences = sentences.slice(1).join(''); + + const langConfig = this.LANGUAGE_CODE_MAP[this.currentLanguage]; + + // ★並行処理: TTS生成と表情生成を同時に実行して遅延を最小化 + if (this.isUserInteracted) { + const cleanFirst = this.stripMarkdown(firstSentence); + const cleanRemaining = remainingSentences.trim().length > 0 + ? this.stripMarkdown(remainingSentences) : null; + + // ★ 並行 TTS via DialogueManager (/api/v2/) + // 1. 最初のセンテンスTTS + const firstTtsPromise = this.dialogueManager.synthesizeTTS( + cleanFirst, langConfig.tts, langConfig.voice, this.sessionId! + ); + + // 2. 残りのセンテンスTTS(あれば) + const remainingTtsPromise = cleanRemaining + ? this.dialogueManager.synthesizeTTS( + cleanRemaining, langConfig.tts, langConfig.voice, this.sessionId! + ) + : null; + + // ★ 最初のTTSが返ったら即再生(Expression同梱済み) + const firstTtsResult = await firstTtsPromise; + if (firstTtsResult.success && firstTtsResult.audio) { + // ★ TTS応答に同梱されたExpressionを即バッファ投入(遅延ゼロ) + if (firstTtsResult.expression) this.applyExpressionFromTts(firstTtsResult.expression); + + this.lastAISpeech = this.normalizeText(cleanFirst); + this.stopCurrentAudio(); + this.ttsPlayer.src = `data:audio/mp3;base64,${firstTtsResult.audio}`; + + // 残りのTTS結果を先に取得(TTS応答にExpression同梱済み) + let remainingTtsResult: any = null; + if (remainingTtsPromise) { + remainingTtsResult = await remainingTtsPromise; + } + + // 最初のセンテンス再生 + await new Promise((resolve) => { + this.ttsPlayer.onended = () => { + this.els.voiceStatus.innerHTML = this.t('voiceStatusStopped'); + this.els.voiceStatus.className = 'voice-status stopped'; + resolve(); + }; + this.ttsPlayer.onerror = () => { + console.error('[TTS] First sentence play error'); + resolve(); + }; + this.els.voiceStatus.innerHTML = this.t('voiceStatusSpeaking'); + this.els.voiceStatus.className = 'voice-status speaking'; + this.ttsPlayer.play().catch((e: any) => { + console.error('[TTS] First sentence play() rejected:', e); + resolve(); + }); + }); + + // ★ 残りのセンテンスを続けて再生(Expression同梱済み) + if (remainingTtsResult?.success && remainingTtsResult?.audio) { + this.lastAISpeech = this.normalizeText(cleanRemaining || ''); + + // ★ TTS応答に同梱されたExpressionを即バッファ投入 + if (remainingTtsResult.expression) this.applyExpressionFromTts(remainingTtsResult.expression); + + this.stopCurrentAudio(); + this.ttsPlayer.src = `data:audio/mp3;base64,${remainingTtsResult.audio}`; + + await new Promise((resolve) => { + this.ttsPlayer.onended = () => { + this.els.voiceStatus.innerHTML = this.t('voiceStatusStopped'); + this.els.voiceStatus.className = 'voice-status stopped'; + resolve(); + }; + this.ttsPlayer.onerror = () => { + console.error('[TTS] Remaining sentence play error'); + resolve(); + }; + this.els.voiceStatus.innerHTML = this.t('voiceStatusSpeaking'); + this.els.voiceStatus.className = 'voice-status speaking'; + this.ttsPlayer.play().catch((e: any) => { + console.error('[TTS] Remaining sentence play() rejected:', e); + resolve(); + }); + }); + } + } + } + + this.isAISpeaking = false; + } catch (error) { + console.error('[TTS並行処理エラー]', error); + this.isAISpeaking = false; + // エラー時はフォールバック(skipAudio=false: コンシェルジュでは常に再生) + await this.speakTextGCP(response, true, false, false); + } + } + + // ======================================== + // 🎯 コンシェルジュモード専用: 音声入力完了時の即答処理 + // ======================================== + protected async handleStreamingSTTComplete(transcript: string) { + this.stopStreamingSTT(); + + if ('mediaSession' in navigator) { + try { navigator.mediaSession.playbackState = 'playing'; } catch (e) {} + } + + this.els.voiceStatus.innerHTML = this.t('voiceStatusComplete'); + this.els.voiceStatus.className = 'voice-status'; + + // オウム返し判定(エコーバック防止) + const normTranscript = this.normalizeText(transcript); + if (this.isSemanticEcho(normTranscript, this.lastAISpeech)) { + this.els.voiceStatus.innerHTML = this.t('voiceStatusStopped'); + this.els.voiceStatus.className = 'voice-status stopped'; + this.lastAISpeech = ''; + return; + } + + this.els.userInput.value = transcript; + this.addMessage('user', transcript); + + // 短すぎる入力チェック + const textLength = transcript.trim().replace(/\s+/g, '').length; + if (textLength < 2) { + const msg = this.t('shortMsgWarning'); + this.addMessage('assistant', msg); + if (this.isTTSEnabled && this.isUserInteracted) { + await this.speakTextGCP(msg, true); + } else { + await new Promise(r => setTimeout(r, 2000)); + } + this.els.userInput.value = ''; + this.els.voiceStatus.innerHTML = this.t('voiceStatusStopped'); + this.els.voiceStatus.className = 'voice-status stopped'; + return; + } + + // ✅ 修正: 即答を「はい」だけに簡略化 + const ackText = this.t('ackYes'); // 「はい」のみ + const preGeneratedAudio = this.preGeneratedAcks.get(ackText); + + // 即答を再生(ttsPlayerで) + if (preGeneratedAudio && this.isTTSEnabled && this.isUserInteracted) { + this.pendingAckPromise = new Promise((resolve) => { + this.lastAISpeech = this.normalizeText(ackText); + this.ttsPlayer.src = `data:audio/mp3;base64,${preGeneratedAudio}`; + let resolved = false; + const done = () => { if (!resolved) { resolved = true; resolve(); } }; + this.ttsPlayer.onended = done; + this.ttsPlayer.onpause = done; // ★ pause時もresolve(src変更やstop時のデッドロック防止) + this.ttsPlayer.play().catch(_e => done()); + }); + } else if (this.isTTSEnabled) { + this.pendingAckPromise = this.speakTextGCP(ackText, false); + } + + this.addMessage('assistant', ackText); + + // ★ 並行処理: ack再生完了を待たず、即LLMリクエスト開始(~700ms短縮) + // pendingAckPromiseはsendMessage内でTTS再生前にawaitされる + if (this.els.userInput.value.trim()) { + this.isFromVoiceInput = true; + this.sendMessage(); + } + + this.els.voiceStatus.innerHTML = this.t('voiceStatusStopped'); + this.els.voiceStatus.className = 'voice-status stopped'; + } + + // ======================================== + // 🎯 コンシェルジュモード専用: メッセージ送信処理 + // ======================================== + protected async sendMessage() { + let firstAckPromise: Promise | null = null; + // ★ voice入力時はunlockAudioParamsスキップ(ack再生中のttsPlayerを中断させない) + if (!this.pendingAckPromise) { + this.unlockAudioParams(); + } + const message = this.els.userInput.value.trim(); + if (!message || this.isProcessing) return; + + const currentSessionId = this.sessionId; + const isTextInput = !this.isFromVoiceInput; + + this.isProcessing = true; + this.els.sendBtn.disabled = true; + this.els.micBtn.disabled = true; + this.els.userInput.disabled = true; + + // ✅ テキスト入力時も「はい」だけに簡略化 + if (!this.isFromVoiceInput) { + this.addMessage('user', message); + const textLength = message.trim().replace(/\s+/g, '').length; + if (textLength < 2) { + const msg = this.t('shortMsgWarning'); + this.addMessage('assistant', msg); + if (this.isTTSEnabled && this.isUserInteracted) await this.speakTextGCP(msg, true); + this.resetInputState(); + return; + } + + this.els.userInput.value = ''; + + // ✅ 修正: 即答を「はい」だけに + const ackText = this.t('ackYes'); + this.currentAISpeech = ackText; + this.addMessage('assistant', ackText); + + if (this.isTTSEnabled && !isTextInput) { + try { + const preGeneratedAudio = this.preGeneratedAcks.get(ackText); + if (preGeneratedAudio && this.isUserInteracted) { + firstAckPromise = new Promise((resolve) => { + this.lastAISpeech = this.normalizeText(ackText); + this.ttsPlayer.src = `data:audio/mp3;base64,${preGeneratedAudio}`; + this.ttsPlayer.onended = () => resolve(); + this.ttsPlayer.play().catch(_e => resolve()); + }); + } else { + firstAckPromise = this.speakTextGCP(ackText, false); + } + } catch (_e) {} + } + if (firstAckPromise) await firstAckPromise; + + // ✅ 修正: オウム返しパターンを削除 + // (generateFallbackResponse, additionalResponse の呼び出しを削除) + } + + this.isFromVoiceInput = false; + + // ✅ 待機アニメーションは6.5秒後に表示(LLM送信直前にタイマースタート) + if (this.waitOverlayTimer) clearTimeout(this.waitOverlayTimer); + let responseReceived = false; + + // タイマーセットをtry直前に移動(即答処理の後) + this.waitOverlayTimer = window.setTimeout(() => { + if (!responseReceived) { + this.showWaitOverlay(); + } + }, 6500); + + try { + // ★ /api/v2/ 経由でチャット送信 + const data = await this.dialogueManager.sendChat( + message, this.currentStage, this.currentMode + ); + + // ✅ レスポンス到着フラグを立てる + responseReceived = true; + + if (this.sessionId !== currentSessionId) return; + + // ✅ タイマーをクリアしてアニメーションを非表示 + if (this.waitOverlayTimer) { + clearTimeout(this.waitOverlayTimer); + this.waitOverlayTimer = null; + } + this.hideWaitOverlay(); + this.currentAISpeech = data.response; + this.addMessage('assistant', data.response, data.summary); + + if (this.isTTSEnabled) { + this.stopCurrentAudio(); + } + + if (data.shops && data.shops.length > 0) { + this.currentShops = data.shops; + this.els.reservationBtn.classList.add('visible'); + this.els.userInput.value = ''; + document.dispatchEvent(new CustomEvent('displayShops', { + detail: { shops: data.shops, language: this.currentLanguage } + })); + + const section = document.getElementById('shopListSection'); + if (section) section.classList.add('has-shops'); + if (window.innerWidth < 1024) { + setTimeout(() => { + const shopSection = document.getElementById('shopListSection'); + if (shopSection) shopSection.scrollIntoView({ behavior: 'smooth', block: 'start' }); + }, 300); + } + + (async () => { + try { + // ★ ack再生中ならttsPlayer解放を待つ(並行処理の同期ポイント) + if (this.pendingAckPromise) { + await this.pendingAckPromise; + this.pendingAckPromise = null; + } + this.stopCurrentAudio(); // ttsPlayer確実解放 + + this.isAISpeaking = true; + if (this.isRecording) { this.stopStreamingSTT(); } + + await this.speakTextGCP(this.t('ttsIntro'), true, false, false); + + const lines = data.response.split('\n\n'); + let introText = ""; + let shopLines = lines; + if (lines[0].includes('ご希望に合うお店') && lines[0].includes('ご紹介します')) { + introText = lines[0]; + shopLines = lines.slice(1); + } + + let introPart2Promise: Promise | null = null; + if (introText && this.isTTSEnabled && this.isUserInteracted && !isTextInput) { + const preGeneratedIntro = this.preGeneratedAcks.get(introText); + if (preGeneratedIntro) { + introPart2Promise = new Promise((resolve) => { + this.lastAISpeech = this.normalizeText(introText); + this.ttsPlayer.src = `data:audio/mp3;base64,${preGeneratedIntro}`; + this.ttsPlayer.onended = () => resolve(); + this.ttsPlayer.play(); + }); + } else { + introPart2Promise = this.speakTextGCP(introText, false, false, false); + } + } + + let firstShopTtsPromise: Promise | null = null; + let remainingShopTtsPromise: Promise | null = null; + const shopLangConfig = this.LANGUAGE_CODE_MAP[this.currentLanguage]; + + if (shopLines.length > 0 && this.isTTSEnabled && this.isUserInteracted) { + const firstShop = shopLines[0]; + const restShops = shopLines.slice(1).join('\n\n'); + + // ★ 1行目先行: 最初のショップと残りのTTSを並行開始 (via DialogueManager) + firstShopTtsPromise = this.dialogueManager.synthesizeTTS( + this.stripMarkdown(firstShop), shopLangConfig.tts, + shopLangConfig.voice, this.sessionId! + ); + + if (restShops) { + remainingShopTtsPromise = this.dialogueManager.synthesizeTTS( + this.stripMarkdown(restShops), shopLangConfig.tts, + shopLangConfig.voice, this.sessionId! + ); + } + } + + if (introPart2Promise) await introPart2Promise; + + if (firstShopTtsPromise) { + const firstResult = await firstShopTtsPromise; + if (firstResult?.success && firstResult?.audio) { + const firstShopText = this.stripMarkdown(shopLines[0]); + this.lastAISpeech = this.normalizeText(firstShopText); + + // ★ TTS応答に同梱されたExpressionを即バッファ投入 + if (firstResult.expression) this.applyExpressionFromTts(firstResult.expression); + + this.stopCurrentAudio(); + + this.ttsPlayer.src = `data:audio/mp3;base64,${firstResult.audio}`; + + // 残りのTTS結果を先に取得(Expression同梱済み) + let remainingResult: any = null; + if (remainingShopTtsPromise) { + remainingResult = await remainingShopTtsPromise; + } + + await new Promise((resolve) => { + this.ttsPlayer.onended = () => { + this.els.voiceStatus.innerHTML = this.t('voiceStatusStopped'); + this.els.voiceStatus.className = 'voice-status stopped'; + resolve(); + }; + this.ttsPlayer.onerror = () => resolve(); + this.els.voiceStatus.innerHTML = this.t('voiceStatusSpeaking'); + this.els.voiceStatus.className = 'voice-status speaking'; + this.ttsPlayer.play().catch(() => resolve()); + }); + + if (remainingResult?.success && remainingResult?.audio) { + const restShopsText = this.stripMarkdown(shopLines.slice(1).join('\n\n')); + this.lastAISpeech = this.normalizeText(restShopsText); + + // ★ TTS応答に同梱されたExpressionを即バッファ投入 + if (remainingResult.expression) this.applyExpressionFromTts(remainingResult.expression); + + this.stopCurrentAudio(); + + this.ttsPlayer.src = `data:audio/mp3;base64,${remainingResult.audio}`; + await new Promise((resolve) => { + this.ttsPlayer.onended = () => { + this.els.voiceStatus.innerHTML = this.t('voiceStatusStopped'); + this.els.voiceStatus.className = 'voice-status stopped'; + resolve(); + }; + this.ttsPlayer.onerror = () => resolve(); + this.els.voiceStatus.innerHTML = this.t('voiceStatusSpeaking'); + this.els.voiceStatus.className = 'voice-status speaking'; + this.ttsPlayer.play().catch(() => resolve()); + }); + } + } + } + this.isAISpeaking = false; + } catch (_e) { this.isAISpeaking = false; } + })(); + } else { + if (data.response) { + const extractedShops = this.extractShopsFromResponse(data.response); + if (extractedShops.length > 0) { + this.currentShops = extractedShops; + this.els.reservationBtn.classList.add('visible'); + document.dispatchEvent(new CustomEvent('displayShops', { + detail: { shops: extractedShops, language: this.currentLanguage } + })); + const section = document.getElementById('shopListSection'); + if (section) section.classList.add('has-shops'); + // ★並行処理フローを適用 + this.speakResponseInChunks(data.response, isTextInput); + } else { + // ★並行処理フローを適用 + this.speakResponseInChunks(data.response, isTextInput); + } + } + } + } catch (error) { + console.error('送信エラー:', error); + this.hideWaitOverlay(); + this.showError('メッセージの送信に失敗しました。'); + } finally { + this.resetInputState(); + this.els.userInput.blur(); + } + } + +} diff --git a/support_base/frontend/gourmet-sp2-impl/core-controller.ts b/support_base/frontend/gourmet-sp2-impl/core-controller.ts new file mode 100644 index 0000000..c7e295a --- /dev/null +++ b/support_base/frontend/gourmet-sp2-impl/core-controller.ts @@ -0,0 +1,1134 @@ + +// src/scripts/chat/core-controller.ts +import { i18n } from '../../constants/i18n'; +import { AudioManager } from './audio-manager'; +import { DialogueManager, type DialogueType, type ExpressionData } from '../platform/dialogue-manager'; + +declare const io: any; + +export class CoreController { + protected container: HTMLElement; + protected apiBase: string; + protected audioManager: AudioManager; + protected socket: any = null; + + // ★ Live API 対話マネージャー + protected dialogueManager: DialogueManager; + protected dialogueType: DialogueType = 'live'; + protected isLiveStreaming = false; + + protected currentLanguage: 'ja' | 'en' | 'zh' | 'ko' = 'ja'; + protected sessionId: string | null = null; + protected isProcessing = false; + protected currentStage = 'conversation'; + protected isRecording = false; + protected waitOverlayTimer: number | null = null; + protected isTTSEnabled = true; + protected isUserInteracted = false; + protected currentShops: any[] = []; + protected isFromVoiceInput = false; + protected lastAISpeech = ''; + protected preGeneratedAcks: Map = new Map(); + protected isAISpeaking = false; + protected currentAISpeech = ""; + protected currentMode: 'chat' | 'concierge' = 'chat'; + + // ★追加: バックグラウンド状態の追跡 + protected isInBackground = false; + protected backgroundStartTime = 0; + protected readonly BACKGROUND_RESET_THRESHOLD = 120000; // 120秒 + + protected isIOS = /iPhone|iPad|iPod/i.test(navigator.userAgent); + protected isAndroid = /Android/i.test(navigator.userAgent); + + protected els: any = {}; + protected ttsPlayer: HTMLAudioElement; + + protected readonly LANGUAGE_CODE_MAP = { + ja: { tts: 'ja-JP', stt: 'ja-JP', voice: 'ja-JP-Chirp3-HD-Leda' }, + en: { tts: 'en-US', stt: 'en-US', voice: 'en-US-Studio-O' }, + zh: { tts: 'cmn-CN', stt: 'cmn-CN', voice: 'cmn-CN-Wavenet-A' }, + ko: { tts: 'ko-KR', stt: 'ko-KR', voice: 'ko-KR-Wavenet-A' } + }; + + constructor(container: HTMLElement, apiBase: string) { + this.container = container; + this.apiBase = apiBase; + this.audioManager = new AudioManager(); + this.dialogueManager = new DialogueManager(apiBase); + this.ttsPlayer = new Audio(); + + const query = (sel: string) => container.querySelector(sel) as HTMLElement; + this.els = { + chatArea: query('#chatArea'), + userInput: query('#userInput') as HTMLInputElement, + sendBtn: query('#sendBtn'), + micBtn: query('#micBtnFloat'), + speakerBtn: query('#speakerBtnFloat'), + voiceStatus: query('#voiceStatus'), + waitOverlay: query('#waitOverlay'), + waitVideo: query('#waitVideo') as HTMLVideoElement, + splashOverlay: query('#splashOverlay'), + splashVideo: query('#splashVideo') as HTMLVideoElement, + reservationBtn: query('#reservationBtnFloat'), + stopBtn: query('#stopBtn'), + languageSelect: query('#languageSelect') as HTMLSelectElement + }; + } + + protected async init() { + console.log('[Core] Starting initialization...'); + + this.bindEvents(); + this.initSocket(); + this.setupLiveAPIEvents(); + + setTimeout(() => { + if (this.els.splashVideo) this.els.splashVideo.loop = false; + if (this.els.splashOverlay) { + this.els.splashOverlay.classList.add('fade-out'); + setTimeout(() => this.els.splashOverlay.classList.add('hidden'), 800); + } + }, 10000); + + await this.initializeSession(); + this.updateUILanguage(); + + setTimeout(() => { + if (this.els.splashOverlay) { + this.els.splashOverlay.classList.add('fade-out'); + setTimeout(() => this.els.splashOverlay.classList.add('hidden'), 800); + } + }, 2000); + + console.log('[Core] Initialization completed'); + } + + /** + * ★ Live API イベントハンドラ設定 + * DialogueManager から受信するイベントを処理 + */ + protected setupLiveAPIEvents(): void { + // AI テキスト受信(Live API transcription) + this.dialogueManager.on('ai_text', (data: { text: string; isPartial: boolean }) => { + if (!data.isPartial) { + this.addMessage('assistant', data.text); + this.currentAISpeech = data.text; + } + }); + + // ユーザーテキスト受信(Live API transcription) + this.dialogueManager.on('user_text', (data: { text: string; isPartial: boolean }) => { + if (this.els.userInput) { + this.els.userInput.value = data.text; + } + if (!data.isPartial) { + this.addMessage('user', data.text); + if (this.els.userInput) this.els.userInput.value = ''; + } + }); + + // Expression 受信 (Live API 経路) — サブクラスでオーバーライド可能 + this.dialogueManager.on('expression', (data: ExpressionData) => { + this.handleLiveExpression(data); + }); + + // 割り込み(barge-in) + this.dialogueManager.on('interrupted', () => { + console.log('[Core] Barge-in detected'); + this.isAISpeaking = false; + this.ttsPlayer.pause(); + this.ttsPlayer.currentTime = 0; + }); + + // 再接続 + this.dialogueManager.on('reconnecting', (reason: string) => { + console.log(`[Core] Reconnecting: ${reason}`); + this.els.voiceStatus.innerHTML = '再接続中...'; + this.els.voiceStatus.className = 'voice-status speaking'; + }); + + this.dialogueManager.on('reconnected', (count: number) => { + console.log(`[Core] Reconnected: session #${count}`); + this.els.voiceStatus.innerHTML = this.t('voiceStatusStopped'); + this.els.voiceStatus.className = 'voice-status stopped'; + }); + + // エラー + this.dialogueManager.on('error', (message: string) => { + console.error('[Core] Live API error:', message); + this.addMessage('system', `Live API エラー: ${message}`); + }); + + // 接続状態 + this.dialogueManager.on('connection', (connected: boolean) => { + console.log(`[Core] Live API connection: ${connected}`); + }); + } + + /** + * ★ Live API Expression 受信ハンドラ(ConciergeController でオーバーライド) + */ + protected handleLiveExpression(_data: ExpressionData): void { + // ベースクラスでは何もしない(アバターなし) + // ConciergeController でオーバーライドして LAMAvatar に投入 + } + + protected getUserId(): string { + const STORAGE_KEY = 'gourmet_support_user_id'; + let userId = localStorage.getItem(STORAGE_KEY); + if (!userId) { + userId = 'user_' + Date.now() + '_' + Math.random().toString(36).substr(2, 9); + localStorage.setItem(STORAGE_KEY, userId); + console.log('[Core] 新規 user_id を生成:', userId); + } + return userId; + } + + protected async resetAppContent() { + console.log('[Reset] Starting soft reset...'); + const oldSessionId = this.sessionId; + this.stopAllActivities(); + + // Live API 切断 + if (this.isLiveStreaming) { + this.dialogueManager.stopLiveStream(); + this.isLiveStreaming = false; + } + + if (oldSessionId) { + try { + await this.dialogueManager.cancel(); + } catch (e) { console.log('[Reset] Cancel error:', e); } + } + + if (this.els.chatArea) this.els.chatArea.innerHTML = ''; + const shopCardList = document.getElementById('shopCardList'); + if (shopCardList) shopCardList.innerHTML = ''; + const shopListSection = document.getElementById('shopListSection'); + if (shopListSection) shopListSection.classList.remove('has-shops'); + const floatingButtons = document.querySelector('.floating-buttons'); + if (floatingButtons) floatingButtons.classList.remove('shop-card-active'); + + this.els.userInput.value = ''; + this.els.userInput.disabled = true; + this.els.sendBtn.disabled = true; + this.els.micBtn.disabled = true; + this.els.speakerBtn.disabled = true; + this.els.reservationBtn.classList.remove('visible'); + + this.currentShops = []; + this.sessionId = null; + this.lastAISpeech = ''; + this.preGeneratedAcks.clear(); + this.isProcessing = false; + this.isAISpeaking = false; + this.isFromVoiceInput = false; + + await new Promise(resolve => setTimeout(resolve, 300)); + await this.initializeSession(); + + // ★追加: スクロール位置をリセット(ヘッダーが隠れないように) + this.container.scrollIntoView({ behavior: 'smooth', block: 'start' }); + window.scrollTo({ top: 0, behavior: 'smooth' }); + + console.log('[Reset] Completed'); + } + + protected bindEvents() { + this.els.sendBtn?.addEventListener('click', () => this.sendMessage()); + + this.els.micBtn?.addEventListener('click', () => { + this.toggleRecording(); + }); + + this.els.speakerBtn?.addEventListener('click', () => this.toggleTTS()); + this.els.reservationBtn?.addEventListener('click', () => this.openReservationModal()); + this.els.stopBtn?.addEventListener('click', () => this.stopAllActivities()); + + this.els.userInput?.addEventListener('keypress', (e: KeyboardEvent) => { + if (e.key === 'Enter') this.sendMessage(); + }); + + this.els.languageSelect?.addEventListener('change', () => { + this.currentLanguage = this.els.languageSelect.value as any; + this.updateUILanguage(); + }); + + const floatingButtons = this.container.querySelector('.floating-buttons'); + this.els.userInput?.addEventListener('focus', () => { + setTimeout(() => { if (floatingButtons) floatingButtons.classList.add('keyboard-active'); }, 300); + }); + this.els.userInput?.addEventListener('blur', () => { + if (floatingButtons) floatingButtons.classList.remove('keyboard-active'); + }); + + const resetHandler = async () => { await this.resetAppContent(); }; + const resetWrapper = async () => { + await resetHandler(); + document.addEventListener('gourmet-app:reset', resetWrapper, { once: true }); + }; + document.addEventListener('gourmet-app:reset', resetWrapper, { once: true }); + + // ★追加: バックグラウンド復帰時の復旧処理 + document.addEventListener('visibilitychange', async () => { + if (document.hidden) { + this.isInBackground = true; + this.backgroundStartTime = Date.now(); + } else if (this.isInBackground) { + this.isInBackground = false; + const backgroundDuration = Date.now() - this.backgroundStartTime; + console.log(`[Foreground] Resuming from background (${Math.round(backgroundDuration / 1000)}s)`); + + // ★120秒以上バックグラウンドにいた場合はソフトリセット + if (backgroundDuration > this.BACKGROUND_RESET_THRESHOLD) { + console.log('[Foreground] Long background duration - triggering soft reset...'); + await this.resetAppContent(); + return; + } + + // 1. Socket.IO再接続(状態に関わらず試行) + if (this.socket && !this.socket.connected) { + console.log('[Foreground] Reconnecting socket...'); + this.socket.connect(); + } + + // 2. UI状態をリセット(操作可能にする) + this.isProcessing = false; + this.isAISpeaking = false; + this.hideWaitOverlay(); + + // 3. 要素が存在する場合のみ更新 + if (this.els.sendBtn) this.els.sendBtn.disabled = false; + if (this.els.micBtn) this.els.micBtn.disabled = false; + if (this.els.userInput) this.els.userInput.disabled = false; + if (this.els.voiceStatus) { + this.els.voiceStatus.innerHTML = this.t('voiceStatusStopped'); + this.els.voiceStatus.className = 'voice-status stopped'; + } + } + }); + } + + // ★修正: Socket.IO接続設定に再接続オプションを追加(transportsは削除) + protected initSocket() { + // @ts-ignore + this.socket = io(this.apiBase || window.location.origin, { + reconnection: true, + reconnectionDelay: 1000, + reconnectionAttempts: 5, + timeout: 10000 + }); + + this.socket.on('connect', () => { }); + + this.socket.on('transcript', (data: any) => { + const { text, is_final } = data; + if (this.isAISpeaking) return; + if (is_final) { + this.handleStreamingSTTComplete(text); + this.currentAISpeech = ""; + } else { + this.els.userInput.value = text; + } + }); + + this.socket.on('error', (data: any) => { + this.addMessage('system', `${this.t('sttError')} ${data.message}`); + if (this.isRecording) this.stopStreamingSTT(); + }); + } + + protected async initializeSession() { + try { + // 既存セッション終了 + if (this.sessionId) { + try { + await this.dialogueManager.endSession(); + } catch (e) {} + } + + // ★ support_base /api/v2/ 経由でセッション開始 + const sessionInfo = await this.dialogueManager.startSession({ + mode: this.currentMode, + language: this.currentLanguage, + dialogueType: this.dialogueType, + userId: this.getUserId(), + userInfo: {}, + }); + + this.sessionId = sessionInfo.sessionId; + this.dialogueManager.currentSessionId = this.sessionId; + + this.addMessage('assistant', this.t('initialGreeting'), null, true); + + // ★ ack TTS プリジェネレーション (REST 経路で使用) + const ackTexts = [ + this.t('ackConfirm'), this.t('ackSearch'), this.t('ackUnderstood'), + this.t('ackYes'), this.t('ttsIntro') + ]; + const langConfig = this.LANGUAGE_CODE_MAP[this.currentLanguage]; + + const ackPromises = ackTexts.map(async (text) => { + try { + const result = await this.dialogueManager.synthesizeTTS( + text, langConfig.tts, langConfig.voice + ); + if (result.success && result.audio) { + this.preGeneratedAcks.set(text, result.audio); + } + } catch (_e) { } + }); + + await Promise.all([ + this.speakTextGCP(this.t('initialGreeting')), + ...ackPromises + ]); + + this.els.userInput.disabled = false; + this.els.sendBtn.disabled = false; + this.els.micBtn.disabled = false; + this.els.speakerBtn.disabled = false; + this.els.speakerBtn.classList.remove('disabled'); + this.els.reservationBtn.classList.remove('visible'); + + console.log(`[Core] Session started: ${this.sessionId} mode=${this.currentMode} type=${this.dialogueType}`); + + } catch (e) { + console.error('[Session] Initialization error:', e); + } + } + + protected async toggleRecording() { + this.enableAudioPlayback(); + this.els.userInput.value = ''; + + // ★ Live API モード: マイクストリーミング ON/OFF + if (this.dialogueType === 'live' && this.dialogueManager.isLiveConnected) { + if (this.isLiveStreaming) { + this.dialogueManager.stopLiveStream(); + this.isLiveStreaming = false; + this.els.micBtn.classList.remove('recording'); + this.els.voiceStatus.innerHTML = this.t('voiceStatusStopped'); + this.els.voiceStatus.className = 'voice-status stopped'; + } else { + // 処理中なら中断 + if (this.isProcessing || this.isAISpeaking || !this.ttsPlayer.paused) { + if (this.isProcessing) { + this.dialogueManager.cancel().catch(err => console.error('中止リクエスト失敗:', err)); + } + this.stopCurrentAudio(); + this.dialogueManager.stopLivePlayback(); + this.hideWaitOverlay(); + this.isProcessing = false; + this.isAISpeaking = false; + this.resetInputState(); + } + + try { + await this.dialogueManager.startLiveStream(); + this.isLiveStreaming = true; + this.els.micBtn.classList.add('recording'); + this.els.voiceStatus.innerHTML = this.t('voiceStatusListening'); + this.els.voiceStatus.className = 'voice-status listening'; + } catch (error: any) { + console.error('[Core] Live mic start failed:', error); + this.els.micBtn.classList.remove('recording'); + this.showError(this.t('micAccessError')); + } + } + return; + } + + // ★ REST モード: 既存 Socket.IO STT フロー + if (this.isRecording) { + this.stopStreamingSTT(); + return; + } + + if (this.isProcessing || this.isAISpeaking || !this.ttsPlayer.paused) { + if (this.isProcessing) { + this.dialogueManager.cancel().catch(err => console.error('中止リクエスト失敗:', err)); + } + + this.stopCurrentAudio(); + this.hideWaitOverlay(); + this.isProcessing = false; + this.isAISpeaking = false; + this.resetInputState(); + } + + if (this.socket && this.socket.connected) { + this.isRecording = true; + this.els.micBtn.classList.add('recording'); + this.els.voiceStatus.innerHTML = this.t('voiceStatusListening'); + this.els.voiceStatus.className = 'voice-status listening'; + + try { + const langCode = this.LANGUAGE_CODE_MAP[this.currentLanguage].stt; + await this.audioManager.startStreaming( + this.socket, langCode, + () => { this.stopStreamingSTT(); }, + () => { this.els.voiceStatus.innerHTML = this.t('voiceStatusRecording'); } + ); + } catch (error: any) { + this.stopStreamingSTT(); + if (!error.message?.includes('マイク')) { + this.showError(this.t('micAccessError')); + } + } + } else { + await this.startLegacyRecording(); + } + } + + protected async startLegacyRecording() { + try { + this.isRecording = true; + this.els.micBtn.classList.add('recording'); + this.els.voiceStatus.innerHTML = this.t('voiceStatusListening'); + + await this.audioManager.startLegacyRecording( + async (audioBlob) => { + await this.transcribeAudio(audioBlob); + this.stopStreamingSTT(); + }, + () => { this.els.voiceStatus.innerHTML = this.t('voiceStatusRecording'); } + ); + } catch (error: any) { + this.addMessage('system', `${this.t('micAccessError')} ${error.message}`); + this.stopStreamingSTT(); + } + } + + protected async transcribeAudio(audioBlob: Blob) { + console.log('Legacy audio blob size:', audioBlob.size); + } + + protected stopStreamingSTT() { + this.audioManager.stopStreaming(); + if (this.socket && this.socket.connected) { + this.socket.emit('stop_stream'); + } + this.isRecording = false; + this.els.micBtn.classList.remove('recording'); + this.els.voiceStatus.innerHTML = this.t('voiceStatusStopped'); + this.els.voiceStatus.className = 'voice-status stopped'; + } + + protected async handleStreamingSTTComplete(transcript: string) { + this.stopStreamingSTT(); + + if ('mediaSession' in navigator) { + try { navigator.mediaSession.playbackState = 'playing'; } catch (e) {} + } + + this.els.voiceStatus.innerHTML = this.t('voiceStatusComplete'); + this.els.voiceStatus.className = 'voice-status'; + + const normTranscript = this.normalizeText(transcript); + if (this.isSemanticEcho(normTranscript, this.lastAISpeech)) { + this.els.voiceStatus.innerHTML = this.t('voiceStatusStopped'); + this.els.voiceStatus.className = 'voice-status stopped'; + this.lastAISpeech = ''; + return; + } + + this.els.userInput.value = transcript; + this.addMessage('user', transcript); + + const textLength = transcript.trim().replace(/\s+/g, '').length; + if (textLength < 2) { + const msg = this.t('shortMsgWarning'); + this.addMessage('assistant', msg); + if (this.isTTSEnabled && this.isUserInteracted) { + await this.speakTextGCP(msg, true); + } else { + await new Promise(r => setTimeout(r, 2000)); + } + this.els.userInput.value = ''; + this.els.voiceStatus.innerHTML = this.t('voiceStatusStopped'); + this.els.voiceStatus.className = 'voice-status stopped'; + return; + } + + const ack = this.selectSmartAcknowledgment(transcript); + const preGeneratedAudio = this.preGeneratedAcks.get(ack.text); + + let firstAckPromise: Promise | null = null; + if (preGeneratedAudio && this.isTTSEnabled && this.isUserInteracted) { + firstAckPromise = new Promise((resolve) => { + this.lastAISpeech = this.normalizeText(ack.text); + this.ttsPlayer.src = `data:audio/mp3;base64,${preGeneratedAudio}`; + this.ttsPlayer.onended = () => resolve(); + this.ttsPlayer.play().catch(_e => resolve()); + }); + } else if (this.isTTSEnabled) { + firstAckPromise = this.speakTextGCP(ack.text, false); + } + + this.addMessage('assistant', ack.text); + + (async () => { + try { + if (firstAckPromise) await firstAckPromise; + const cleanText = this.removeFillers(transcript); + const fallbackResponse = this.generateFallbackResponse(cleanText); + + if (this.isTTSEnabled && this.isUserInteracted) await this.speakTextGCP(fallbackResponse, false); + this.addMessage('assistant', fallbackResponse); + + setTimeout(async () => { + const additionalResponse = this.t('additionalResponse'); + if (this.isTTSEnabled && this.isUserInteracted) await this.speakTextGCP(additionalResponse, false); + this.addMessage('assistant', additionalResponse); + }, 3000); + + if (this.els.userInput.value.trim()) { + this.isFromVoiceInput = true; + this.sendMessage(); + } + } catch (_error) { + if (this.els.userInput.value.trim()) { + this.isFromVoiceInput = true; + this.sendMessage(); + } + } + })(); + + this.els.voiceStatus.innerHTML = this.t('voiceStatusStopped'); + this.els.voiceStatus.className = 'voice-status stopped'; + } + +// Part 1からの続き... + + protected async sendMessage() { + let firstAckPromise: Promise | null = null; + this.unlockAudioParams(); + const message = this.els.userInput.value.trim(); + if (!message || this.isProcessing) return; + + const currentSessionId = this.sessionId; + const isTextInput = !this.isFromVoiceInput; + + this.isProcessing = true; + this.els.sendBtn.disabled = true; + this.els.micBtn.disabled = true; + this.els.userInput.disabled = true; + + if (!this.isFromVoiceInput) { + this.addMessage('user', message); + const textLength = message.trim().replace(/\s+/g, '').length; + if (textLength < 2) { + const msg = this.t('shortMsgWarning'); + this.addMessage('assistant', msg); + if (this.isTTSEnabled && this.isUserInteracted) await this.speakTextGCP(msg, true); + this.resetInputState(); + return; + } + + this.els.userInput.value = ''; + + const ack = this.selectSmartAcknowledgment(message); + this.currentAISpeech = ack.text; + this.addMessage('assistant', ack.text); + + if (this.isTTSEnabled && !isTextInput) { + try { + const preGeneratedAudio = this.preGeneratedAcks.get(ack.text); + if (preGeneratedAudio && this.isUserInteracted) { + firstAckPromise = new Promise((resolve) => { + this.lastAISpeech = this.normalizeText(ack.text); + this.ttsPlayer.src = `data:audio/mp3;base64,${preGeneratedAudio}`; + this.ttsPlayer.onended = () => resolve(); + this.ttsPlayer.play().catch(_e => resolve()); + }); + } else { + firstAckPromise = this.speakTextGCP(ack.text, false); + } + } catch (_e) {} + } + if (firstAckPromise) await firstAckPromise; + + const cleanText = this.removeFillers(message); + const fallbackResponse = this.generateFallbackResponse(cleanText); + + if (this.isTTSEnabled && this.isUserInteracted) await this.speakTextGCP(fallbackResponse, false, false, isTextInput); + this.addMessage('assistant', fallbackResponse); + + setTimeout(async () => { + const additionalResponse = this.t('additionalResponse'); + if (this.isTTSEnabled && this.isUserInteracted) await this.speakTextGCP(additionalResponse, false, false, isTextInput); + this.addMessage('assistant', additionalResponse); + }, 3000); + } + + this.isFromVoiceInput = false; + + if (this.waitOverlayTimer) clearTimeout(this.waitOverlayTimer); + this.waitOverlayTimer = window.setTimeout(() => { this.showWaitOverlay(); }, 4000); + + try { + // ★ /api/v2/ 経由でチャット送信 + const data = await this.dialogueManager.sendChat( + message, this.currentStage, this.currentMode + ); + + if (this.sessionId !== currentSessionId) return; + + this.hideWaitOverlay(); + this.currentAISpeech = data.response; + this.addMessage('assistant', data.response, data.summary); + + if (!isTextInput && this.isTTSEnabled) { + this.stopCurrentAudio(); + } + + if (data.shops && data.shops.length > 0) { + this.currentShops = data.shops; + this.els.reservationBtn.classList.add('visible'); + this.els.userInput.value = ''; + document.dispatchEvent(new CustomEvent('displayShops', { + detail: { shops: data.shops, language: this.currentLanguage } + })); + + const section = document.getElementById('shopListSection'); + if (section) section.classList.add('has-shops'); + if (window.innerWidth < 1024) { + setTimeout(() => { + const shopSection = document.getElementById('shopListSection'); + if (shopSection) shopSection.scrollIntoView({ behavior: 'smooth', block: 'start' }); + }, 300); + } + + (async () => { + try { + this.isAISpeaking = true; + if (this.isRecording) { this.stopStreamingSTT(); } + + await this.speakTextGCP(this.t('ttsIntro'), true, false, isTextInput); + + const lines = data.response.split('\n\n'); + let introText = ""; + let shopLines = lines; + if (lines[0].includes('ご希望に合うお店') && lines[0].includes('ご紹介します')) { + introText = lines[0]; + shopLines = lines.slice(1); + } + + let introPart2Promise: Promise | null = null; + if (introText && this.isTTSEnabled && this.isUserInteracted && !isTextInput) { + const preGeneratedIntro = this.preGeneratedAcks.get(introText); + if (preGeneratedIntro) { + introPart2Promise = new Promise((resolve) => { + this.lastAISpeech = this.normalizeText(introText); + this.ttsPlayer.src = `data:audio/mp3;base64,${preGeneratedIntro}`; + this.ttsPlayer.onended = () => resolve(); + this.ttsPlayer.play(); + }); + } else { + introPart2Promise = this.speakTextGCP(introText, false, false, isTextInput); + } + } + + let firstShopAudioPromise: Promise | null = null; + let remainingAudioPromise: Promise | null = null; + const shopLangConfig = this.LANGUAGE_CODE_MAP[this.currentLanguage]; + + if (shopLines.length > 0 && this.isTTSEnabled && this.isUserInteracted && !isTextInput) { + const firstShop = shopLines[0]; + const restShops = shopLines.slice(1).join('\n\n'); + firstShopAudioPromise = (async () => { + const cleanText = this.stripMarkdown(firstShop); + const result = await this.dialogueManager.synthesizeTTS( + cleanText, shopLangConfig.tts, shopLangConfig.voice + ); + return result.success ? `data:audio/mp3;base64,${result.audio}` : null; + })(); + + if (restShops) { + remainingAudioPromise = (async () => { + const cleanText = this.stripMarkdown(restShops); + const result = await this.dialogueManager.synthesizeTTS( + cleanText, shopLangConfig.tts, shopLangConfig.voice + ); + return result.success ? `data:audio/mp3;base64,${result.audio}` : null; + })(); + } + } + + if (introPart2Promise) await introPart2Promise; + + if (firstShopAudioPromise) { + const firstShopAudio = await firstShopAudioPromise; + if (firstShopAudio) { + const firstShopText = this.stripMarkdown(shopLines[0]); + this.lastAISpeech = this.normalizeText(firstShopText); + + if (!isTextInput && this.isTTSEnabled) { + this.stopCurrentAudio(); + } + + this.ttsPlayer.src = firstShopAudio; + await new Promise((resolve) => { + this.ttsPlayer.onended = () => { + this.els.voiceStatus.innerHTML = this.t('voiceStatusStopped'); + this.els.voiceStatus.className = 'voice-status stopped'; + resolve(); + }; + this.els.voiceStatus.innerHTML = this.t('voiceStatusSpeaking'); + this.els.voiceStatus.className = 'voice-status speaking'; + this.ttsPlayer.play(); + }); + + if (remainingAudioPromise) { + const remainingAudio = await remainingAudioPromise; + if (remainingAudio) { + const restShopsText = this.stripMarkdown(shopLines.slice(1).join('\n\n')); + this.lastAISpeech = this.normalizeText(restShopsText); + await new Promise(r => setTimeout(r, 500)); + + if (!isTextInput && this.isTTSEnabled) { + this.stopCurrentAudio(); + } + + this.ttsPlayer.src = remainingAudio; + await new Promise((resolve) => { + this.ttsPlayer.onended = () => { + this.els.voiceStatus.innerHTML = this.t('voiceStatusStopped'); + this.els.voiceStatus.className = 'voice-status stopped'; + resolve(); + }; + this.els.voiceStatus.innerHTML = this.t('voiceStatusSpeaking'); + this.els.voiceStatus.className = 'voice-status speaking'; + this.ttsPlayer.play(); + }); + } + } + } + } + this.isAISpeaking = false; + } catch (_e) { this.isAISpeaking = false; } + })(); + } else { + if (data.response) { + const extractedShops = this.extractShopsFromResponse(data.response); + if (extractedShops.length > 0) { + this.currentShops = extractedShops; + this.els.reservationBtn.classList.add('visible'); + document.dispatchEvent(new CustomEvent('displayShops', { + detail: { shops: extractedShops, language: this.currentLanguage } + })); + const section = document.getElementById('shopListSection'); + if (section) section.classList.add('has-shops'); + this.speakTextGCP(data.response, true, false, isTextInput); + } else { + this.speakTextGCP(data.response, true, false, isTextInput); + } + } + } + } catch (error) { + console.error('送信エラー:', error); + this.hideWaitOverlay(); + this.showError('メッセージの送信に失敗しました。'); + } finally { + this.resetInputState(); + this.els.userInput.blur(); + } + } + + protected async speakTextGCP(text: string, stopPrevious: boolean = true, autoRestartMic: boolean = false, skipAudio: boolean = false) { + if (skipAudio) return Promise.resolve(); + if (!this.isTTSEnabled || !text) return Promise.resolve(); + + if (stopPrevious && this.isTTSEnabled) { + this.ttsPlayer.pause(); + } + + const cleanText = this.stripMarkdown(text); + try { + this.isAISpeaking = true; + if (this.isRecording && (this.isIOS || this.isAndroid)) { + this.stopStreamingSTT(); + } + + this.els.voiceStatus.innerHTML = this.t('voiceStatusSynthesizing'); + this.els.voiceStatus.className = 'voice-status speaking'; + const langConfig = this.LANGUAGE_CODE_MAP[this.currentLanguage]; + + // ★ DialogueManager 経由 /api/v2/ TTS + const data = await this.dialogueManager.synthesizeTTS( + cleanText, langConfig.tts, langConfig.voice + ); + if (data.success && data.audio) { + this.ttsPlayer.src = `data:audio/mp3;base64,${data.audio}`; + const playPromise = new Promise((resolve) => { + this.ttsPlayer.onended = async () => { + this.els.voiceStatus.innerHTML = this.t('voiceStatusStopped'); + this.els.voiceStatus.className = 'voice-status stopped'; + this.isAISpeaking = false; + if (autoRestartMic) { + if (!this.isRecording) { + try { await this.toggleRecording(); } catch (_error) { this.showMicPrompt(); } + } + } + resolve(); + }; + this.ttsPlayer.onerror = () => { + this.isAISpeaking = false; + resolve(); + }; + }); + + if (this.isUserInteracted) { + this.lastAISpeech = this.normalizeText(cleanText); + await this.ttsPlayer.play(); + await playPromise; + } else { + this.showClickPrompt(); + this.els.voiceStatus.innerHTML = this.t('voiceStatusStopped'); + this.els.voiceStatus.className = 'voice-status stopped'; + this.isAISpeaking = false; + } + } else { + this.isAISpeaking = false; + } + } catch (_error) { + this.els.voiceStatus.innerHTML = this.t('voiceStatusStopped'); + this.els.voiceStatus.className = 'voice-status stopped'; + this.isAISpeaking = false; + } + } + + protected showWaitOverlay() { + this.els.waitOverlay.classList.remove('hidden'); + this.els.waitVideo.currentTime = 0; + this.els.waitVideo.play().catch((e: any) => console.log('Video err', e)); + } + + protected hideWaitOverlay() { + if (this.waitOverlayTimer) { clearTimeout(this.waitOverlayTimer); this.waitOverlayTimer = null; } + this.els.waitOverlay.classList.add('hidden'); + setTimeout(() => this.els.waitVideo.pause(), 500); + } + + protected unlockAudioParams() { + this.audioManager.unlockAudioParams(this.ttsPlayer); + } + + protected enableAudioPlayback() { + if (!this.isUserInteracted) { + this.isUserInteracted = true; + const clickPrompt = this.container.querySelector('.click-prompt'); + if (clickPrompt) clickPrompt.remove(); + this.unlockAudioParams(); + } + } + + protected stopCurrentAudio() { + this.ttsPlayer.pause(); + this.ttsPlayer.currentTime = 0; + } + + protected showClickPrompt() { + const prompt = document.createElement('div'); + prompt.className = 'click-prompt'; + prompt.innerHTML = `

🔊

${this.t('clickPrompt')}

🔊

`; + prompt.addEventListener('click', () => this.enableAudioPlayback()); + this.container.style.position = 'relative'; + this.container.appendChild(prompt); + } + + protected showMicPrompt() { + const modal = document.createElement('div'); + modal.id = 'mic-prompt-modal'; + modal.style.cssText = `position: fixed; top: 0; left: 0; right: 0; bottom: 0; background: rgba(0, 0, 0, 0.8); display: flex; align-items: center; justify-content: center; z-index: 10000; animation: fadeIn 0.3s ease;`; + modal.innerHTML = ` +
+
🎤
+
マイクをONにしてください
+
AIの回答が終わりました。
続けて話すにはマイクボタンをタップしてください。
+ +
+ `; + const style = document.createElement('style'); + style.textContent = `@keyframes fadeIn { from { opacity: 0; } to { opacity: 1; } }`; + document.head.appendChild(style); + document.body.appendChild(modal); + + const btn = document.getElementById('mic-prompt-btn'); + btn?.addEventListener('click', async () => { + modal.remove(); + await this.toggleRecording(); + }); + setTimeout(() => { if (document.getElementById('mic-prompt-modal')) { modal.remove(); } }, 3000); + } + + protected stripMarkdown(text: string): string { + return text.replace(/\*\*([^*]+)\*\*/g, '$1').replace(/\*([^*]+)\*/g, '$1').replace(/__([^_]+)__/g, '$1').replace(/_([^_]+)_/g, '$1').replace(/^#+\s*/gm, '').replace(/\[([^\]]+)\]\([^)]+\)/g, '$1').replace(/`([^`]+)`/g, '$1').replace(/^(\d+)\.\s+/gm, '$1番目、').replace(/\s+/g, ' ').trim(); + } + + protected normalizeText(text: string): string { + return text.replace(/\s+/g, '').replace(/[、。!?,.!?]/g, '').toLowerCase(); + } + + protected removeFillers(text: string): string { + // @ts-ignore + const pattern = i18n[this.currentLanguage].patterns.fillers; + return text.replace(pattern, ''); + } + + protected generateFallbackResponse(text: string): string { + return this.t('fallbackResponse', text); + } + + protected selectSmartAcknowledgment(userMessage: string) { + const messageLower = userMessage.trim(); + // @ts-ignore + const p = i18n[this.currentLanguage].patterns; + if (p.ackQuestions.test(messageLower)) return { text: this.t('ackConfirm'), logText: `質問形式` }; + if (p.ackLocation.test(messageLower)) return { text: this.t('ackSearch'), logText: `場所` }; + if (p.ackSearch.test(messageLower)) return { text: this.t('ackUnderstood'), logText: `検索` }; + return { text: this.t('ackYes'), logText: `デフォルト` }; + } + + protected isSemanticEcho(transcript: string, aiText: string): boolean { + if (!aiText || !transcript) return false; + const normTranscript = this.normalizeText(transcript); + const normAI = this.normalizeText(aiText); + if (normAI === normTranscript) return true; + if (normAI.includes(normTranscript) && normTranscript.length > 5) return true; + return false; + } + + protected extractShopsFromResponse(text: string): any[] { + const shops: any[] = []; + const pattern = /(\d+)\.\s*\*\*([^*]+)\*\*[::\s]*([^\n]+)/g; + let match; + while ((match = pattern.exec(text)) !== null) { + const fullName = match[2].trim(); + const description = match[3].trim(); + let name = fullName; + const nameMatch = fullName.match(/^([^(]+)[(]([^)]+)[)]/); + if (nameMatch) name = nameMatch[1].trim(); + const encodedName = encodeURIComponent(name); + shops.push({ name: name, description: description, category: 'イタリアン', hotpepper_url: `https://www.hotpepper.jp/SA11/srchRS/?keyword=${encodedName}`, maps_url: `https://www.google.com/maps/search/${encodedName}`, tabelog_url: `https://tabelog.com/rstLst/?vs=1&sa=&sk=${encodedName}` }); + } + return shops; + } + + protected openReservationModal() { + if (this.currentShops.length === 0) { this.showError(this.t('searchError')); return; } + document.dispatchEvent(new CustomEvent('openReservationModal', { detail: { shops: this.currentShops } })); + } + + protected toggleTTS() { + if (!this.isUserInteracted) { this.enableAudioPlayback(); return; } + this.enableAudioPlayback(); + this.isTTSEnabled = !this.isTTSEnabled; + + this.els.speakerBtn.title = this.isTTSEnabled ? this.t('btnTTSOn') : this.t('btnTTSOff'); + if (this.isTTSEnabled) { + this.els.speakerBtn.classList.remove('disabled'); + } else { + this.els.speakerBtn.classList.add('disabled'); + } + + if (!this.isTTSEnabled) this.stopCurrentAudio(); + } + + protected stopAllActivities() { + if (this.isProcessing) { + this.dialogueManager.cancel().catch(err => console.error('中止リクエスト失敗:', err)); + } + + // Live API ストリーミング停止 + if (this.isLiveStreaming) { + this.dialogueManager.stopLiveStream(); + this.dialogueManager.stopLivePlayback(); + this.isLiveStreaming = false; + } + + this.audioManager.fullResetAudioResources(); + this.isRecording = false; + this.els.micBtn.classList.remove('recording'); + if (this.socket && this.socket.connected) { this.socket.emit('stop_stream'); } + this.stopCurrentAudio(); + this.hideWaitOverlay(); + this.isProcessing = false; + this.isAISpeaking = false; + this.els.voiceStatus.innerHTML = this.t('voiceStatusStopped'); + this.els.voiceStatus.className = 'voice-status stopped'; + this.els.userInput.value = ''; + + // ★修正: containerにスクロール(chat-header-controlsが隠れないように) + if (window.innerWidth < 1024) { + setTimeout(() => { this.container.scrollIntoView({ behavior: 'smooth', block: 'start' }); }, 100); + } + } + + protected addMessage(role: string, text: string, summary: string | null = null, isInitial: boolean = false) { + const div = document.createElement('div'); + div.className = `message ${role}`; + if (isInitial) div.setAttribute('data-initial', 'true'); + + let contentHtml = `
${text}
`; + div.innerHTML = `
${role === 'assistant' ? '🍽' : '👤'}
${contentHtml}`; + this.els.chatArea.appendChild(div); + this.els.chatArea.scrollTop = this.els.chatArea.scrollHeight; + } + + protected resetInputState() { + this.isProcessing = false; + this.els.sendBtn.disabled = false; + this.els.micBtn.disabled = false; + this.els.userInput.disabled = false; + } + + protected showError(msg: string) { + const div = document.createElement('div'); + div.className = 'error-message'; + div.innerText = msg; + this.els.chatArea.appendChild(div); + this.els.chatArea.scrollTop = this.els.chatArea.scrollHeight; + } + + protected t(key: string, ...args: any[]): string { + // @ts-ignore + const translation = i18n[this.currentLanguage][key]; + if (typeof translation === 'function') return translation(...args); + return translation || key; + } + + protected updateUILanguage() { + console.log('[Core] Updating UI language to:', this.currentLanguage); + + this.els.voiceStatus.innerHTML = this.t('voiceStatusStopped'); + this.els.userInput.placeholder = this.t('inputPlaceholder'); + this.els.micBtn.title = this.t('btnVoiceInput'); + this.els.speakerBtn.title = this.isTTSEnabled ? this.t('btnTTSOn') : this.t('btnTTSOff'); + this.els.sendBtn.textContent = this.t('btnSend'); + this.els.reservationBtn.innerHTML = this.t('btnReservation'); + + const pageTitle = document.getElementById('pageTitle'); + if (pageTitle) pageTitle.innerHTML = ` ${this.t('pageTitle')}`; + const pageSubtitle = document.getElementById('pageSubtitle'); + if (pageSubtitle) pageSubtitle.textContent = this.t('pageSubtitle'); + const shopListTitle = document.getElementById('shopListTitle'); + if (shopListTitle) shopListTitle.innerHTML = `🍽 ${this.t('shopListTitle')}`; + const shopListEmpty = document.getElementById('shopListEmpty'); + if (shopListEmpty) shopListEmpty.textContent = this.t('shopListEmpty'); + const pageFooter = document.getElementById('pageFooter'); + if (pageFooter) pageFooter.innerHTML = `${this.t('footerMessage')} ✨`; + + const initialMessage = this.els.chatArea.querySelector('.message.assistant[data-initial="true"] .message-text'); + if (initialMessage) { + initialMessage.textContent = this.t('initialGreeting'); + } + + const waitText = document.querySelector('.wait-text'); + if (waitText) waitText.textContent = this.t('waitMessage'); + + document.dispatchEvent(new CustomEvent('languageChange', { detail: { language: this.currentLanguage } })); + } +} diff --git a/support_base/frontend/gourmet-sp2-impl/dialogue-manager.ts b/support_base/frontend/gourmet-sp2-impl/dialogue-manager.ts new file mode 100644 index 0000000..8800aa3 --- /dev/null +++ b/support_base/frontend/gourmet-sp2-impl/dialogue-manager.ts @@ -0,0 +1,386 @@ +/** + * DialogueManager — REST/Live API 対話の共通インターフェース + * + * PLATFORM_SPEC_v2.md §4 の設計に準拠。 + * モード(gourmet, concierge)に依存しない対話管理レイヤー。 + * + * REST 経路: + * POST /api/v2/rest/session/start → session_id + * POST /api/v2/rest/chat → { response, audio, expression, shops } + * POST /api/v2/rest/tts/synthesize → { audio, expression } + * + * Live API 経路: + * POST /api/v2/session/start → { session_id, ws_url } + * WebSocket /api/v2/live/{session_id} → 音声ストリーミング + */ + +import { LiveWSClient, type LiveWSMessage } from './live-ws-client'; +import { LiveAudioIO } from './live-audio-io'; + +export type DialogueType = 'rest' | 'live'; + +export interface SessionStartParams { + mode?: string; + language?: string; + dialogueType?: DialogueType; + userId?: string; + userInfo?: any; +} + +export interface SessionInfo { + sessionId: string; + mode: string; + language: string; + dialogueType: DialogueType; + greeting: string; + initialMessage?: string; + wsUrl?: string; +} + +export interface ChatResponse { + response: string; + summary?: string; + shops?: any[]; + shouldConfirm?: boolean; + isFollowup?: boolean; +} + +export interface TTSResponse { + success: boolean; + audio?: string; + expression?: { + names: string[]; + frames: any[]; + frame_rate: number; + }; +} + +export interface ExpressionData { + names: string[]; + frames: any[]; + frame_rate: number; +} + +type EventHandler = (data: T) => void; + +export class DialogueManager { + private apiBase: string; + private sessionId: string | null = null; + private mode: string = 'gourmet'; + private language: string = 'ja'; + private dialogueType: DialogueType = 'live'; + + // Live API + private wsClient: LiveWSClient | null = null; + private audioIO: LiveAudioIO | null = null; + + // イベントハンドラ + private eventHandlers: Map = new Map(); + + constructor(apiBase: string) { + this.apiBase = apiBase; + } + + // ======================================== + // セッション管理 + // ======================================== + + /** + * セッション開始 + * support_base server.py: POST /api/v2/session/start + */ + async startSession(params: SessionStartParams = {}): Promise { + this.mode = params.mode ?? 'gourmet'; + this.language = params.language ?? 'ja'; + this.dialogueType = params.dialogueType ?? 'live'; + + const res = await fetch(`${this.apiBase}/api/v2/session/start`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + mode: this.mode, + language: this.language, + dialogue_type: this.dialogueType, + user_id: params.userId || null, + user_info: params.userInfo || {}, + }), + }); + + if (!res.ok) { + throw new Error(`Session start failed: ${res.status}`); + } + + const data = await res.json(); + this.sessionId = data.session_id; + + const info: SessionInfo = { + sessionId: data.session_id, + mode: data.mode || this.mode, + language: data.language || this.language, + dialogueType: data.dialogue_type || this.dialogueType, + greeting: data.greeting || data.initial_message || '', + initialMessage: data.initial_message, + wsUrl: data.ws_url, + }; + + // Live API モードの場合、WebSocket 接続を準備 + if (this.dialogueType === 'live' && info.wsUrl) { + await this.connectLive(info.wsUrl); + } + + return info; + } + + /** + * セッション終了 + */ + async endSession(): Promise { + this.disconnectLive(); + + if (this.sessionId) { + try { + await fetch(`${this.apiBase}/api/v2/session/end`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ session_id: this.sessionId }), + }); + } catch (e) { + console.warn('[DialogueManager] Session end failed:', e); + } + this.sessionId = null; + } + } + + // ======================================== + // REST 対話(既存 gourmet-support 互換) + // ======================================== + + /** + * REST チャット送信 + * rest/router.py: POST /api/v2/rest/chat + */ + async sendChat( + message: string, + stage: string = 'conversation', + mode?: string + ): Promise { + if (!this.sessionId) throw new Error('No active session'); + + const res = await fetch(`${this.apiBase}/api/v2/rest/chat`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + session_id: this.sessionId, + message, + stage, + language: this.language, + mode: mode || this.mode, + }), + }); + + if (!res.ok) throw new Error(`Chat failed: ${res.status}`); + return await res.json(); + } + + /** + * REST TTS 合成(Expression 同梱返却) + * rest/router.py: POST /api/v2/rest/tts/synthesize + */ + async synthesizeTTS( + text: string, + langCode: string = 'ja-JP', + voiceName: string = 'ja-JP-Chirp3-HD-Leda', + sessionId?: string + ): Promise { + const sid = sessionId || this.sessionId; + + const res = await fetch(`${this.apiBase}/api/v2/rest/tts/synthesize`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + text, + language_code: langCode, + voice_name: voiceName, + session_id: sid, + }), + }); + + if (!res.ok) throw new Error(`TTS failed: ${res.status}`); + return await res.json(); + } + + /** + * REST キャンセル + */ + async cancel(): Promise { + if (!this.sessionId) return; + try { + await fetch(`${this.apiBase}/api/v2/rest/cancel`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ session_id: this.sessionId }), + }); + } catch (e) { + console.warn('[DialogueManager] Cancel failed:', e); + } + } + + // ======================================== + // Live API 対話 + // ======================================== + + private async connectLive(wsUrl: string): Promise { + this.wsClient = new LiveWSClient({ wsUrl }); + + this.wsClient.on('audio', (msg: LiveWSMessage) => { + if (msg.data && this.audioIO) { + this.audioIO.queuePlayback(msg.data); + } + this.emit('ai_audio', msg.data); + }); + + this.wsClient.on('transcription', (msg: LiveWSMessage) => { + if (msg.role === 'user') { + this.emit('user_text', { text: msg.text, isPartial: msg.is_partial }); + } else if (msg.role === 'ai') { + this.emit('ai_text', { text: msg.text, isPartial: msg.is_partial }); + } + }); + + this.wsClient.on('expression', (msg: LiveWSMessage) => { + this.emit('expression', msg.data); + }); + + this.wsClient.on('interrupted', () => { + if (this.audioIO) { + this.audioIO.stopPlayback(); + } + this.emit('interrupted', null); + }); + + this.wsClient.on('reconnecting', (msg: LiveWSMessage) => { + this.emit('reconnecting', msg.reason); + }); + + this.wsClient.on('reconnected', (msg: LiveWSMessage) => { + this.emit('reconnected', msg.session_count); + }); + + this.wsClient.on('error', (msg: LiveWSMessage) => { + console.error('[DialogueManager] Live error:', msg.message); + this.emit('error', msg.message); + }); + + this.wsClient.on('connection', (connected: boolean) => { + this.emit('connection', connected); + }); + + await this.wsClient.connect(); + } + + private disconnectLive(): void { + if (this.audioIO) { + this.audioIO.destroy(); + this.audioIO = null; + } + if (this.wsClient) { + this.wsClient.disconnect(); + this.wsClient = null; + } + } + + /** + * Live API: マイク音声ストリーミング開始 + * ★ 必ずユーザーインタラクション(tap/click)のイベントハンドラ内から呼ぶこと + */ + async startLiveStream(): Promise { + if (!this.wsClient || !this.wsClient.isConnected) { + throw new Error('Live API not connected'); + } + + this.audioIO = new LiveAudioIO({ + wsClient: this.wsClient, + sendSampleRate: 16000, + receiveSampleRate: 24000, + }); + + await this.audioIO.startMic(); + console.log('[DialogueManager] Live stream started'); + } + + stopLiveStream(): void { + if (this.audioIO) { + this.audioIO.stopMic(); + } + console.log('[DialogueManager] Live stream stopped'); + } + + sendLiveText(text: string): void { + if (this.wsClient && this.wsClient.isConnected) { + this.wsClient.sendText(text); + } + } + + /** Live API 再生停止(barge-in) */ + stopLivePlayback(): void { + if (this.audioIO) { + this.audioIO.stopPlayback(); + } + } + + // ======================================== + // イベント管理 + // ======================================== + + on(event: string, handler: EventHandler): void { + const handlers = this.eventHandlers.get(event) || []; + handlers.push(handler); + this.eventHandlers.set(event, handlers); + } + + off(event: string, handler: EventHandler): void { + const handlers = this.eventHandlers.get(event) || []; + this.eventHandlers.set(event, handlers.filter((h) => h !== handler)); + } + + private emit(event: string, data: any): void { + const handlers = this.eventHandlers.get(event) || []; + handlers.forEach((h) => h(data)); + } + + // ======================================== + // アクセサ + // ======================================== + + get currentSessionId(): string | null { + return this.sessionId; + } + + set currentSessionId(id: string | null) { + this.sessionId = id; + } + + get currentMode(): string { + return this.mode; + } + + get currentLanguage(): string { + return this.language; + } + + get currentDialogueType(): DialogueType { + return this.dialogueType; + } + + get isLiveConnected(): boolean { + return this.wsClient?.isConnected ?? false; + } + + get isMicActive(): boolean { + return this.audioIO?.micActive ?? false; + } + + get liveAudioIO(): LiveAudioIO | null { + return this.audioIO; + } +} diff --git a/support_base/frontend/gourmet-sp2-impl/live-audio-io.ts b/support_base/frontend/gourmet-sp2-impl/live-audio-io.ts new file mode 100644 index 0000000..f992ad8 --- /dev/null +++ b/support_base/frontend/gourmet-sp2-impl/live-audio-io.ts @@ -0,0 +1,289 @@ +/** + * Live API 用オーディオ I/O + * + * ★★★ 重要 ★★★ + * マイク制御コード(getUserMedia, AudioContext, AudioWorklet)は + * iPhone 16/17 の iOS 18-19 セキュリティ制限への対策コードである。 + * 既存の AudioManager (gourmet-sp2) のパターンをそのまま踏襲すること。 + * + * Live API 経路のオーディオフロー: + * [マイク] → getUserMedia → AudioWorklet (48kHz→16kHz) → PCM base64 → WebSocket送信 + * [スピーカー] ← WebSocket受信 ← PCM 24kHz base64 → AudioBuffer → AudioContext.destination + * + * REST 経路のオーディオフロー (既存 AudioManager がそのまま担当): + * [マイク] → AudioManager → Socket.IO → STT + * [スピーカー] ← ttsPlayer (HTMLAudioElement) ← MP3 base64 ← TTS API + */ + +import type { LiveWSClient } from './live-ws-client'; + +export interface LiveAudioIOOptions { + wsClient: LiveWSClient; + sendSampleRate?: number; + receiveSampleRate?: number; + chunkDurationMs?: number; +} + +export class LiveAudioIO { + private wsClient: LiveWSClient; + private sendSampleRate: number; + private receiveSampleRate: number; + private chunkDurationMs: number; + + // マイク入力 + private audioContext: AudioContext | null = null; + private mediaStream: MediaStream | null = null; + private workletNode: AudioWorkletNode | null = null; + private isMicActive = false; + + // 音声出力 (PCM 24kHz) + private playbackContext: AudioContext | null = null; + private playbackQueue: ArrayBuffer[] = []; + private isPlaying = false; + private nextPlayTime = 0; + + // 再生中の currentTime を外部から参照可能にする(アバター同期用) + private _playbackStartTime = 0; + + private isIOS = /iPhone|iPad|iPod/i.test(navigator.userAgent); + + constructor(options: LiveAudioIOOptions) { + this.wsClient = options.wsClient; + this.sendSampleRate = options.sendSampleRate ?? 16000; + this.receiveSampleRate = options.receiveSampleRate ?? 24000; + this.chunkDurationMs = options.chunkDurationMs ?? 100; + } + + /** + * マイクを開始し、PCM 16kHz を WebSocket 経由で送信する + * + * ★★★ iPhone 対策注意事項 ★★★ + * - getUserMedia はユーザーインタラクション(tap/click)後にのみ呼ぶこと + * - AudioContext の resume() はユーザーインタラクション内で行うこと + * - 既存 AudioManager の startRecording() パターンを踏襲 + */ + async startMic(): Promise { + if (this.isMicActive) return; + + try { + // @ts-ignore + const AudioContextClass = window.AudioContext || window.webkitAudioContext; + this.audioContext = new AudioContextClass({ + latencyHint: 'interactive', + sampleRate: 48000, + }); + + if (this.audioContext.state === 'suspended') { + await this.audioContext.resume(); + } + + this.mediaStream = await navigator.mediaDevices.getUserMedia({ + audio: { + channelCount: 1, + sampleRate: 48000, + echoCancellation: true, + noiseSuppression: true, + autoGainControl: true, + }, + }); + + const nativeSampleRate = this.audioContext.sampleRate; + const downsampleRatio = nativeSampleRate / this.sendSampleRate; + const chunkSize = Math.floor(this.sendSampleRate * this.chunkDurationMs / 1000); + + // AudioWorklet でダウンサンプリング (48kHz → 16kHz) + // iOS固有のprocessor名にタイムスタンプを付加(AudioManager パターン踏襲) + const processorName = this.isIOS + ? 'live-downsample-ios-' + Date.now() + : 'live-downsample-processor'; + + const code = ` +class LiveDownsampleProcessor extends AudioWorkletProcessor { + constructor(options) { + super(); + const opts = options.processorOptions || {}; + this.ratio = ${downsampleRatio}; + this.chunkSize = ${chunkSize}; + this.buffer = new Int16Array(this.chunkSize); + this.bufferIndex = 0; + this.inputSampleCount = 0; + ${this.isIOS ? 'this.lastFlushTime = Date.now();' : ''} + } + + process(inputs) { + const input = inputs[0]; + if (!input || !input[0]) return true; + const samples = input[0]; + + for (let i = 0; i < samples.length; i++) { + this.inputSampleCount++; + if (this.inputSampleCount >= this.ratio) { + this.inputSampleCount -= this.ratio; + const val = Math.max(-1, Math.min(1, samples[i])); + this.buffer[this.bufferIndex++] = Math.floor(val * 32767); + + if (this.bufferIndex >= this.chunkSize${this.isIOS ? ' || (this.bufferIndex > 0 && Date.now() - this.lastFlushTime > 500)' : ''}) { + const chunk = this.buffer.slice(0, this.bufferIndex); + this.port.postMessage({ type: 'pcm-chunk', data: chunk }, [chunk.buffer]); + this.buffer = new Int16Array(this.chunkSize); + this.bufferIndex = 0; + ${this.isIOS ? 'this.lastFlushTime = Date.now();' : ''} + } + } + } + return true; + } +} +registerProcessor('${processorName}', LiveDownsampleProcessor); +`; + + const blob = new Blob([code], { type: 'application/javascript' }); + const processorUrl = URL.createObjectURL(blob); + await this.audioContext.audioWorklet.addModule(processorUrl); + URL.revokeObjectURL(processorUrl); + + this.workletNode = new AudioWorkletNode(this.audioContext, processorName); + + // Worklet → WebSocket 送信 + this.workletNode.port.onmessage = (event) => { + if (event.data.type === 'pcm-chunk') { + const pcmData: Int16Array = event.data.data; + const base64 = this.int16ArrayToBase64(pcmData); + this.wsClient.sendAudio(base64); + } + }; + + const source = this.audioContext.createMediaStreamSource(this.mediaStream); + source.connect(this.workletNode); + + this.isMicActive = true; + console.log('[LiveAudioIO] Mic started (48kHz → 16kHz)'); + } catch (e) { + console.error('[LiveAudioIO] Failed to start mic:', e); + this.stopMic(); + throw e; + } + } + + stopMic(): void { + if (this.workletNode) { + this.workletNode.port.onmessage = null; + this.workletNode.disconnect(); + this.workletNode = null; + } + if (this.mediaStream) { + this.mediaStream.getTracks().forEach((t) => t.stop()); + this.mediaStream = null; + } + if (this.audioContext) { + this.audioContext.close(); + this.audioContext = null; + } + this.isMicActive = false; + console.log('[LiveAudioIO] Mic stopped'); + } + + /** + * PCM 24kHz 音声をキューに追加して再生 + * relay.py から受信した base64 PCM をデコードして再生する + */ + queuePlayback(base64Pcm: string): void { + const pcmBytes = this.base64ToArrayBuffer(base64Pcm); + this.playbackQueue.push(pcmBytes); + + if (!this.isPlaying) { + this.processPlaybackQueue(); + } + } + + /** + * 再生を停止(barge-in / 割り込み時) + */ + stopPlayback(): void { + this.playbackQueue = []; + this.isPlaying = false; + this.nextPlayTime = 0; + console.log('[LiveAudioIO] Playback stopped (barge-in)'); + } + + destroy(): void { + this.stopMic(); + this.stopPlayback(); + if (this.playbackContext) { + this.playbackContext.close(); + this.playbackContext = null; + } + } + + get micActive(): boolean { + return this.isMicActive; + } + + /** 再生開始からの経過時間(アバター同期用) */ + get playbackCurrentTime(): number { + if (!this.playbackContext) return 0; + return this.playbackContext.currentTime - this._playbackStartTime; + } + + private async processPlaybackQueue(): Promise { + if (!this.playbackContext) { + // @ts-ignore + const AudioContextClass = window.AudioContext || window.webkitAudioContext; + this.playbackContext = new AudioContextClass({ sampleRate: this.receiveSampleRate }); + } + + if (this.playbackContext.state === 'suspended') { + await this.playbackContext.resume(); + } + + this.isPlaying = true; + this._playbackStartTime = this.playbackContext.currentTime; + + while (this.playbackQueue.length > 0) { + const pcmBytes = this.playbackQueue.shift(); + if (!pcmBytes) break; + + const int16 = new Int16Array(pcmBytes); + const float32 = new Float32Array(int16.length); + for (let i = 0; i < int16.length; i++) { + float32[i] = int16[i] / 32768; + } + + const buffer = this.playbackContext.createBuffer( + 1, + float32.length, + this.receiveSampleRate + ); + buffer.getChannelData(0).set(float32); + + const source = this.playbackContext.createBufferSource(); + source.buffer = buffer; + source.connect(this.playbackContext.destination); + + const now = this.playbackContext.currentTime; + const startAt = Math.max(now, this.nextPlayTime); + source.start(startAt); + this.nextPlayTime = startAt + buffer.duration; + } + + this.isPlaying = false; + } + + private int16ArrayToBase64(data: Int16Array): string { + const bytes = new Uint8Array(data.buffer, data.byteOffset, data.byteLength); + let binary = ''; + for (let i = 0; i < bytes.length; i++) { + binary += String.fromCharCode(bytes[i]); + } + return btoa(binary); + } + + private base64ToArrayBuffer(base64: string): ArrayBuffer { + const binary = atob(base64); + const bytes = new Uint8Array(binary.length); + for (let i = 0; i < binary.length; i++) { + bytes[i] = binary.charCodeAt(i); + } + return bytes.buffer; + } +} diff --git a/support_base/frontend/gourmet-sp2-impl/live-ws-client.ts b/support_base/frontend/gourmet-sp2-impl/live-ws-client.ts new file mode 100644 index 0000000..befc1d4 --- /dev/null +++ b/support_base/frontend/gourmet-sp2-impl/live-ws-client.ts @@ -0,0 +1,171 @@ +/** + * LiveRelay WebSocket クライアント + * + * support_base/live/relay.py の LiveRelay に接続する。 + * ブラウザ ↔ サーバー 間のプロトコルを実装。 + * + * プロトコル (relay.py): + * クライアント → サーバー: + * { "type": "audio", "data": "" } + * { "type": "text", "data": "テキスト入力" } + * { "type": "stop" } + * + * サーバー → クライアント: + * { "type": "audio", "data": "" } + * { "type": "transcription", "role": "user"|"ai", "text": "...", "is_partial": bool } + * { "type": "expression", "data": { names, frames, frame_rate } } + * { "type": "interrupted" } + * { "type": "reconnecting", "reason": "..." } + * { "type": "reconnected", "session_count": N } + * { "type": "error", "message": "..." } + */ + +export interface LiveWSClientOptions { + wsUrl: string; + connectTimeout?: number; +} + +export type LiveWSMessageType = + | 'audio' + | 'transcription' + | 'expression' + | 'interrupted' + | 'reconnecting' + | 'reconnected' + | 'error'; + +export interface LiveWSMessage { + type: LiveWSMessageType; + data?: any; + role?: 'user' | 'ai'; + text?: string; + is_partial?: boolean; + reason?: string; + session_count?: number; + message?: string; +} + +type LiveWSEventHandler = (msg: LiveWSMessage) => void; +type LiveWSConnectionHandler = (connected: boolean) => void; + +export class LiveWSClient { + private ws: WebSocket | null = null; + private wsUrl: string; + private connectTimeout: number; + private handlers: Map = new Map(); + private connectionHandlers: LiveWSConnectionHandler[] = []; + private _isConnected = false; + + constructor(options: LiveWSClientOptions) { + this.wsUrl = options.wsUrl; + this.connectTimeout = options.connectTimeout ?? 10000; + } + + async connect(): Promise { + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + reject(new Error('WebSocket connection timeout')); + }, this.connectTimeout); + + try { + const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; + const host = window.location.host; + const url = this.wsUrl.startsWith('ws') + ? this.wsUrl + : `${protocol}//${host}${this.wsUrl}`; + + this.ws = new WebSocket(url); + + this.ws.onopen = () => { + clearTimeout(timer); + this._isConnected = true; + this.notifyConnection(true); + console.log('[LiveWSClient] Connected:', url); + resolve(); + }; + + this.ws.onclose = (event) => { + clearTimeout(timer); + this._isConnected = false; + this.notifyConnection(false); + console.log(`[LiveWSClient] Closed: code=${event.code}, reason=${event.reason}`); + }; + + this.ws.onerror = (event) => { + clearTimeout(timer); + console.error('[LiveWSClient] Error:', event); + reject(new Error('WebSocket connection error')); + }; + + this.ws.onmessage = (event) => { + this.handleMessage(event.data); + }; + } catch (e) { + clearTimeout(timer); + reject(e); + } + }); + } + + disconnect(): void { + if (this.ws) { + this.sendJson({ type: 'stop' }); + this.ws.close(); + this.ws = null; + } + this._isConnected = false; + } + + sendAudio(base64Pcm: string): void { + this.sendJson({ type: 'audio', data: base64Pcm }); + } + + sendText(text: string): void { + this.sendJson({ type: 'text', data: text }); + } + + on(event: LiveWSMessageType | 'connection', handler: any): void { + if (event === 'connection') { + this.connectionHandlers.push(handler as LiveWSConnectionHandler); + return; + } + const existing = this.handlers.get(event) || []; + existing.push(handler as LiveWSEventHandler); + this.handlers.set(event, existing); + } + + off(event: LiveWSMessageType | 'connection', handler: any): void { + if (event === 'connection') { + this.connectionHandlers = this.connectionHandlers.filter(h => h !== handler); + return; + } + const existing = this.handlers.get(event) || []; + this.handlers.set(event, existing.filter(h => h !== handler)); + } + + get isConnected(): boolean { + return this._isConnected; + } + + private sendJson(obj: any): void { + if (this.ws && this.ws.readyState === WebSocket.OPEN) { + this.ws.send(JSON.stringify(obj)); + } + } + + private handleMessage(raw: string): void { + try { + const msg: LiveWSMessage = JSON.parse(raw); + const handlers = this.handlers.get(msg.type); + if (handlers) { + handlers.forEach(h => h(msg)); + } + } catch (e) { + console.warn('[LiveWSClient] Failed to parse message:', raw); + } + } + + private notifyConnection(connected: boolean): void { + this.connectionHandlers.forEach(h => h(connected)); + } +} diff --git a/support_base/frontend/gourmet-sp2-impl/vercel.json b/support_base/frontend/gourmet-sp2-impl/vercel.json new file mode 100644 index 0000000..3933bf5 --- /dev/null +++ b/support_base/frontend/gourmet-sp2-impl/vercel.json @@ -0,0 +1,31 @@ +{ + "$schema": "https://openapi.vercel.sh/vercel.json", + "framework": "astro", + "buildCommand": "npm run build", + "outputDirectory": "dist", + "headers": [ + { + "source": "/(.*)", + "headers": [ + { + "key": "Cross-Origin-Opener-Policy", + "value": "same-origin" + }, + { + "key": "Cross-Origin-Embedder-Policy", + "value": "require-corp" + } + ] + } + ], + "rewrites": [ + { + "source": "/api/v2/:path*", + "destination": "${PUBLIC_API_URL}/api/v2/:path*" + }, + { + "source": "/socket.io/:path*", + "destination": "${PUBLIC_API_URL}/socket.io/:path*" + } + ] +} diff --git a/support_base/frontend/package.json b/support_base/frontend/package.json new file mode 100644 index 0000000..d8599a1 --- /dev/null +++ b/support_base/frontend/package.json @@ -0,0 +1,16 @@ +{ + "name": "platform-frontend", + "type": "module", + "version": "0.1.0", + "scripts": { + "dev": "astro dev", + "build": "astro build", + "preview": "astro preview" + }, + "dependencies": { + "astro": "^5.0.0" + }, + "devDependencies": { + "typescript": "^5.7.0" + } +} diff --git a/support_base/frontend/public/i18n/ja.json b/support_base/frontend/public/i18n/ja.json new file mode 100644 index 0000000..69e845b --- /dev/null +++ b/support_base/frontend/public/i18n/ja.json @@ -0,0 +1,19 @@ +{ + "pageTitle": "グルメコンシェルジュ AI", + "greeting": "いらっしゃいませ!何をお探しですか?", + "inputPlaceholder": "メッセージを入力...", + "voiceStatusListening": "聞いています...", + "voiceStatusSpeaking": "話しています...", + "voiceStatusStopped": "", + "voiceStatusReconnecting": "再接続中...", + "voiceStatusSynthesizing": "音声を合成中...", + "voiceStatusComplete": "認識完了", + "errorSessionInit": "セッションの初期化に失敗しました。", + "errorSendMessage": "メッセージの送信に失敗しました。", + "shortMsgWarning": "もう少し詳しく教えていただけますか?", + "ackYes": "はい", + "ackConfirm": "承知しました", + "ackSearch": "お探しします", + "ackUnderstood": "かしこまりました", + "ttsIntro": "ご希望に合うお店をご紹介します。" +} diff --git a/support_base/frontend/src/pages/index.astro b/support_base/frontend/src/pages/index.astro new file mode 100644 index 0000000..4cb7b49 --- /dev/null +++ b/support_base/frontend/src/pages/index.astro @@ -0,0 +1,340 @@ +--- +/** + * メインページ — グルメコンシェルジュ (Live API 対応) + * + * レイアウト: + * 左: LAMAvatar (3D アバター) — LAMAvatar.astro をそのまま使用 + * 右: チャットパネル (テキスト + マイク入力) + * + * ★ LAMAvatar.astro は frontend-patches/ からコピーして使用。 + * 変更禁止(iPhone対策のオーディオコード含む)。 + */ +--- + + + + + + グルメコンシェルジュ AI + + + +
+ +
+
Live API
+
+ +
+
+
+
+

Loading 3D Avatar...

+
+
+ +
+
+
+ + +
+
+

グルメコンシェルジュ

+ +
+ +
+ +
+ +
+ + + +
+
+
+ + + + diff --git a/support_base/frontend/src/scripts/avatar/expression-manager.ts b/support_base/frontend/src/scripts/avatar/expression-manager.ts new file mode 100644 index 0000000..e31d1bf --- /dev/null +++ b/support_base/frontend/src/scripts/avatar/expression-manager.ts @@ -0,0 +1,15 @@ +/** + * ExpressionManager — vrm-expression-manager.ts そのままコピー + * + * services/frontend-patches/vrm-expression-manager.ts (198行) をそのまま使用。 + * A2E サービスから受け取った 52次元 ARKit ブレンドシェイプを + * GVRM のボーンシステムにマッピングする。 + * + * ★ このファイルは frontend-patches/vrm-expression-manager.ts のコピー。 + * 変更せずそのまま使用すること。 + */ + +export { ExpressionManager, type ExpressionData } from '../../../services/frontend-patches/vrm-expression-manager'; + +// ※ ビルド環境の都合でパスが通らない場合は、 +// vrm-expression-manager.ts をこのディレクトリに直接コピーする。 diff --git a/support_base/frontend/src/scripts/modes/gourmet-mode.ts b/support_base/frontend/src/scripts/modes/gourmet-mode.ts new file mode 100644 index 0000000..13fb322 --- /dev/null +++ b/support_base/frontend/src/scripts/modes/gourmet-mode.ts @@ -0,0 +1,81 @@ +/** + * グルメモード固有ロジック + * + * concierge-controller.ts のグルメ固有部分を抽出。 + * - ショップ表示イベント + * - センテンス分割(splitIntoSentences) + * - 並行TTS処理(speakResponseInChunks のパターン) + * + * PlatformController から呼び出される。 + */ + +export interface ShopData { + name: string; + area?: string; + description?: string; + photo_url?: string; + rating?: number; + place_id?: string; +} + +/** + * ショップリストをDOMにディスパッチ + * concierge-controller.ts L873-874 + */ +export function dispatchShopDisplay(shops: ShopData[], language: string): void { + document.dispatchEvent( + new CustomEvent('displayShops', { + detail: { shops, language }, + }) + ); +} + +/** + * センテンス単位でテキストを分割 + * concierge-controller.ts L526-546 をそのまま移植 + */ +export function splitIntoSentences(text: string, language: string): string[] { + let separator: RegExp; + + if (language === 'ja' || language === 'zh') { + separator = /。/; + } else { + separator = /\.\s+/; + } + + const sentences = text.split(separator).filter((s) => s.trim().length > 0); + + return sentences.map((s, idx) => { + if ( + idx < sentences.length - 1 || + text.endsWith('。') || + text.endsWith('. ') + ) { + return language === 'ja' || language === 'zh' ? s + '。' : s + '. '; + } + return s; + }); +} + +/** + * レスポンスからショップ情報を抽出(フォールバック) + * concierge-controller.ts L1015-1023 + */ +export function extractShopsFromResponse(response: string): ShopData[] { + const shops: ShopData[] = []; + const lines = response.split('\n'); + + for (const line of lines) { + // "1. **店名**(エリア): 説明" パターン + const match = line.match(/^\d+\.\s*\*\*(.+?)\*\*(?:\((.+?)\))?\s*[::]\s*(.+)/); + if (match) { + shops.push({ + name: match[1], + area: match[2] || undefined, + description: match[3], + }); + } + } + + return shops; +} diff --git a/support_base/frontend/src/scripts/platform/audio-io.ts b/support_base/frontend/src/scripts/platform/audio-io.ts new file mode 100644 index 0000000..ea003d5 --- /dev/null +++ b/support_base/frontend/src/scripts/platform/audio-io.ts @@ -0,0 +1,292 @@ +/** + * Live API 用オーディオ I/O + * + * ★★★ 重要 ★★★ + * このファイルのマイク制御コード(getUserMedia, AudioContext, AudioWorklet)は + * iPhone 16/17 の iOS 18-19 セキュリティ制限への対策コードである。 + * 既存の AudioManager (gourmet-sp) のパターンをそのまま踏襲すること。 + * Claude Code の知識ベースで書き換えると iPhone で動かなくなる(実証済み)。 + * + * Live API 経路のオーディオフロー: + * [マイク] → getUserMedia → AudioWorklet (48kHz→16kHz) → PCM base64 → WebSocket送信 + * [スピーカー] ← WebSocket受信 ← PCM 24kHz base64 → AudioBuffer → AudioContext.destination + * + * REST 経路のオーディオフロー (既存 AudioManager がそのまま担当): + * [マイク] → AudioManager → Socket.IO → STT + * [スピーカー] ← ttsPlayer (HTMLAudioElement) ← MP3 base64 ← TTS API + */ + +import type { LiveWSClient } from './live-ws-client'; + +export interface AudioIOOptions { + /** PCM 送信先の LiveWSClient */ + wsClient: LiveWSClient; + /** マイク入力のサンプルレート (デフォルト: 16000) */ + sendSampleRate?: number; + /** 受信音声のサンプルレート (デフォルト: 24000) */ + receiveSampleRate?: number; + /** 送信チャンクサイズ (ms) (デフォルト: 100) */ + chunkDurationMs?: number; +} + +/** + * Live API 用のオーディオ入出力マネージャー + * + * ★ マイク制御(startMic/stopMic)の内部実装は + * gourmet-sp の AudioManager を移植して使用すること。 + * 以下のスタブ実装は構造を示すためのもので、 + * iPhone 16/17 固有の対策コードは含まれていない。 + */ +export class LiveAudioIO { + private wsClient: LiveWSClient; + private sendSampleRate: number; + private receiveSampleRate: number; + private chunkDurationMs: number; + + // マイク入力 + private audioContext: AudioContext | null = null; + private mediaStream: MediaStream | null = null; + private workletNode: AudioWorkletNode | null = null; + private isMicActive = false; + + // 音声出力 (PCM 24kHz) + private playbackContext: AudioContext | null = null; + private playbackQueue: ArrayBuffer[] = []; + private isPlaying = false; + private nextPlayTime = 0; + + constructor(options: AudioIOOptions) { + this.wsClient = options.wsClient; + this.sendSampleRate = options.sendSampleRate ?? 16000; + this.receiveSampleRate = options.receiveSampleRate ?? 24000; + this.chunkDurationMs = options.chunkDurationMs ?? 100; + } + + /** + * マイクを開始し、PCM 16kHz を WebSocket 経由で送信する + * + * ★★★ iPhone 対策注意事項 ★★★ + * - getUserMedia はユーザーインタラクション(tap/click)後にのみ呼ぶこと + * - AudioContext の resume() はユーザーインタラクション内で行うこと + * - iOS の autoplay policy により、音声入出力は同一インタラクション起点が必要 + * - 既存 AudioManager の startRecording() パターンを必ず踏襲すること + */ + async startMic(): Promise { + if (this.isMicActive) return; + + try { + // AudioContext 初期化 + this.audioContext = new AudioContext({ sampleRate: 48000 }); + + // マイク取得 + this.mediaStream = await navigator.mediaDevices.getUserMedia({ + audio: { + channelCount: 1, + sampleRate: 48000, + echoCancellation: true, + noiseSuppression: true, + autoGainControl: true, + }, + }); + + // AudioWorklet でダウンサンプリング (48kHz → 16kHz) + await this.audioContext.audioWorklet.addModule( + this.createDownsampleWorkletUrl() + ); + + this.workletNode = new AudioWorkletNode( + this.audioContext, + 'downsample-processor', + { + processorOptions: { + targetSampleRate: this.sendSampleRate, + chunkDurationMs: this.chunkDurationMs, + }, + } + ); + + // Worklet → WebSocket 送信 + this.workletNode.port.onmessage = (event) => { + if (event.data.type === 'pcm-chunk') { + const pcmData: Int16Array = event.data.data; + const base64 = this.int16ArrayToBase64(pcmData); + this.wsClient.sendAudio(base64); + } + }; + + // マイクストリームを接続 + const source = this.audioContext.createMediaStreamSource(this.mediaStream); + source.connect(this.workletNode); + + this.isMicActive = true; + console.log('[LiveAudioIO] Mic started (48kHz → 16kHz)'); + } catch (e) { + console.error('[LiveAudioIO] Failed to start mic:', e); + this.stopMic(); + throw e; + } + } + + /** + * マイクを停止 + */ + stopMic(): void { + if (this.workletNode) { + this.workletNode.disconnect(); + this.workletNode = null; + } + if (this.mediaStream) { + this.mediaStream.getTracks().forEach((t) => t.stop()); + this.mediaStream = null; + } + if (this.audioContext) { + this.audioContext.close(); + this.audioContext = null; + } + this.isMicActive = false; + console.log('[LiveAudioIO] Mic stopped'); + } + + /** + * PCM 24kHz 音声をキューに追加して再生 + * relay.py から受信した base64 PCM をデコードして再生する + */ + queuePlayback(base64Pcm: string): void { + const pcmBytes = this.base64ToArrayBuffer(base64Pcm); + this.playbackQueue.push(pcmBytes); + + if (!this.isPlaying) { + this.processPlaybackQueue(); + } + } + + /** + * 再生を停止(barge-in / 割り込み時) + */ + stopPlayback(): void { + this.playbackQueue = []; + this.isPlaying = false; + this.nextPlayTime = 0; + // AudioContext は保持(次の再生で再利用) + console.log('[LiveAudioIO] Playback stopped (barge-in)'); + } + + /** + * 全リソース解放 + */ + destroy(): void { + this.stopMic(); + this.stopPlayback(); + if (this.playbackContext) { + this.playbackContext.close(); + this.playbackContext = null; + } + } + + get micActive(): boolean { + return this.isMicActive; + } + + // --- private --- + + private async processPlaybackQueue(): Promise { + if (!this.playbackContext) { + this.playbackContext = new AudioContext({ sampleRate: this.receiveSampleRate }); + } + + this.isPlaying = true; + + while (this.playbackQueue.length > 0) { + const pcmBytes = this.playbackQueue.shift(); + if (!pcmBytes) break; + + const int16 = new Int16Array(pcmBytes); + const float32 = new Float32Array(int16.length); + for (let i = 0; i < int16.length; i++) { + float32[i] = int16[i] / 32768; + } + + const buffer = this.playbackContext.createBuffer( + 1, + float32.length, + this.receiveSampleRate + ); + buffer.getChannelData(0).set(float32); + + const source = this.playbackContext.createBufferSource(); + source.buffer = buffer; + source.connect(this.playbackContext.destination); + + const now = this.playbackContext.currentTime; + const startAt = Math.max(now, this.nextPlayTime); + source.start(startAt); + this.nextPlayTime = startAt + buffer.duration; + } + + this.isPlaying = false; + } + + /** + * AudioWorklet のダウンサンプリングプロセッサーを Blob URL で生成 + * + * 48kHz → 16kHz のダウンサンプリング(1/3 間引き) + * 指定された chunkDurationMs ごとに PCM チャンクを main thread に送信 + */ + private createDownsampleWorkletUrl(): string { + const code = ` +class DownsampleProcessor extends AudioWorkletProcessor { + constructor(options) { + super(); + const opts = options.processorOptions || {}; + this.targetRate = opts.targetSampleRate || 16000; + this.chunkMs = opts.chunkDurationMs || 100; + this.ratio = Math.round(sampleRate / this.targetRate); + this.chunkSize = Math.floor(this.targetRate * this.chunkMs / 1000); + this.buffer = new Int16Array(this.chunkSize); + this.bufferIndex = 0; + } + + process(inputs) { + const input = inputs[0]; + if (!input || !input[0]) return true; + + const samples = input[0]; + for (let i = 0; i < samples.length; i += this.ratio) { + const val = Math.max(-1, Math.min(1, samples[i])); + this.buffer[this.bufferIndex++] = Math.floor(val * 32767); + + if (this.bufferIndex >= this.chunkSize) { + this.port.postMessage({ + type: 'pcm-chunk', + data: this.buffer.slice(0), + }); + this.bufferIndex = 0; + } + } + return true; + } +} +registerProcessor('downsample-processor', DownsampleProcessor); +`; + const blob = new Blob([code], { type: 'application/javascript' }); + return URL.createObjectURL(blob); + } + + private int16ArrayToBase64(data: Int16Array): string { + const bytes = new Uint8Array(data.buffer, data.byteOffset, data.byteLength); + let binary = ''; + for (let i = 0; i < bytes.length; i++) { + binary += String.fromCharCode(bytes[i]); + } + return btoa(binary); + } + + private base64ToArrayBuffer(base64: string): ArrayBuffer { + const binary = atob(base64); + const bytes = new Uint8Array(binary.length); + for (let i = 0; i < binary.length; i++) { + bytes[i] = binary.charCodeAt(i); + } + return bytes.buffer; + } +} diff --git a/support_base/frontend/src/scripts/platform/dialogue-manager.ts b/support_base/frontend/src/scripts/platform/dialogue-manager.ts new file mode 100644 index 0000000..49674bc --- /dev/null +++ b/support_base/frontend/src/scripts/platform/dialogue-manager.ts @@ -0,0 +1,362 @@ +/** + * DialogueManager — REST/Live API 対話の共通インターフェース + * + * PLATFORM_ARCHITECTURE.md §8.3 の設計に準拠。 + * モード(gourmet, support, interview)に依存しない対話管理レイヤー。 + * + * REST 経路: + * POST /api/v2/rest/session/start → session_id + * POST /api/v2/rest/chat → { response, audio, expression, shops } + * POST /api/v2/rest/tts/synthesize → { audio, expression } + * + * Live API 経路: + * POST /api/v2/session/start → { session_id, ws_url } + * WebSocket /api/v2/live/{session_id} → 音声ストリーミング + */ + +import { LiveWSClient, type LiveWSMessage } from './live-ws-client'; +import { LiveAudioIO } from './audio-io'; + +export type DialogueType = 'rest' | 'live'; + +export interface SessionInfo { + sessionId: string; + mode: string; + language: string; + dialogueType: DialogueType; + greeting: string; + wsUrl?: string; +} + +export interface ChatResponse { + response: string; + summary?: string; + shops?: any[]; + shouldConfirm?: boolean; + isFollowup?: boolean; +} + +export interface TTSResponse { + success: boolean; + audio?: string; + expression?: { + names: string[]; + frames: any[]; + frame_rate: number; + }; +} + +export interface ExpressionData { + names: string[]; + frames: any[]; + frame_rate: number; +} + +type EventHandler = (data: T) => void; + +export class DialogueManager { + private apiBase: string; + private sessionId: string | null = null; + private mode: string = 'gourmet'; + private language: string = 'ja'; + private dialogueType: DialogueType = 'live'; + + // Live API + private wsClient: LiveWSClient | null = null; + private audioIO: LiveAudioIO | null = null; + + // イベントハンドラ + private eventHandlers: Map = new Map(); + + constructor(apiBase: string) { + this.apiBase = apiBase; + } + + // ======================================== + // セッション管理 + // ======================================== + + /** + * セッション開始 + * server.py L134-172: POST /api/v2/session/start + */ + async startSession( + mode: string = 'gourmet', + language: string = 'ja', + dialogueType: DialogueType = 'live', + userId?: string + ): Promise { + this.mode = mode; + this.language = language; + this.dialogueType = dialogueType; + + const res = await fetch(`${this.apiBase}/api/v2/session/start`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + mode, + language, + dialogue_type: dialogueType, + user_id: userId || null, + }), + }); + + if (!res.ok) { + throw new Error(`Session start failed: ${res.status}`); + } + + const data = await res.json(); + this.sessionId = data.session_id; + + const info: SessionInfo = { + sessionId: data.session_id, + mode: data.mode, + language: data.language, + dialogueType: data.dialogue_type, + greeting: data.greeting, + wsUrl: data.ws_url, + }; + + // Live API モードの場合、WebSocket 接続を準備 + if (dialogueType === 'live' && info.wsUrl) { + await this.connectLive(info.wsUrl); + } + + return info; + } + + /** + * セッション終了 + */ + async endSession(): Promise { + // Live API 接続を切断 + this.disconnectLive(); + + if (this.sessionId) { + try { + await fetch(`${this.apiBase}/api/v2/session/end`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ session_id: this.sessionId }), + }); + } catch (e) { + console.warn('[DialogueManager] Session end failed:', e); + } + this.sessionId = null; + } + } + + // ======================================== + // REST 対話(既存 gourmet-support 互換) + // ======================================== + + /** + * REST チャット送信 + * rest/router.py L196-319: POST /api/v2/rest/chat + */ + async sendChat(message: string, stage: string = 'conversation'): Promise { + if (!this.sessionId) throw new Error('No active session'); + + const res = await fetch(`${this.apiBase}/api/v2/rest/chat`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + session_id: this.sessionId, + message, + stage, + language: this.language, + mode: this.mode === 'gourmet' ? 'concierge' : this.mode, + }), + }); + + if (!res.ok) throw new Error(`Chat failed: ${res.status}`); + return await res.json(); + } + + /** + * REST TTS 合成(Expression 同梱返却) + * rest/router.py L371-433: POST /api/v2/rest/tts/synthesize + */ + async synthesizeTTS( + text: string, + langCode: string = 'ja-JP', + voiceName: string = 'ja-JP-Chirp3-HD-Leda' + ): Promise { + if (!this.sessionId) throw new Error('No active session'); + + const res = await fetch(`${this.apiBase}/api/v2/rest/tts/synthesize`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + text, + language_code: langCode, + voice_name: voiceName, + session_id: this.sessionId, + }), + }); + + if (!res.ok) throw new Error(`TTS failed: ${res.status}`); + return await res.json(); + } + + // ======================================== + // Live API 対話 + // ======================================== + + /** + * Live API WebSocket に接続 + */ + private async connectLive(wsUrl: string): Promise { + this.wsClient = new LiveWSClient({ wsUrl }); + + // サーバーからのイベントをハンドリング + this.wsClient.on('audio', (msg: LiveWSMessage) => { + // PCM 24kHz 音声を再生キューに追加 + if (msg.data && this.audioIO) { + this.audioIO.queuePlayback(msg.data); + } + this.emit('ai_audio', msg.data); + }); + + this.wsClient.on('transcription', (msg: LiveWSMessage) => { + if (msg.role === 'user') { + this.emit('user_text', { text: msg.text, isPartial: msg.is_partial }); + } else if (msg.role === 'ai') { + this.emit('ai_text', { text: msg.text, isPartial: msg.is_partial }); + } + }); + + this.wsClient.on('expression', (msg: LiveWSMessage) => { + this.emit('expression', msg.data); + }); + + this.wsClient.on('interrupted', () => { + // barge-in: 再生中の音声を停止 + if (this.audioIO) { + this.audioIO.stopPlayback(); + } + this.emit('interrupted', null); + }); + + this.wsClient.on('reconnecting', (msg: LiveWSMessage) => { + this.emit('reconnecting', msg.reason); + }); + + this.wsClient.on('reconnected', (msg: LiveWSMessage) => { + this.emit('reconnected', msg.session_count); + }); + + this.wsClient.on('error', (msg: LiveWSMessage) => { + console.error('[DialogueManager] Live error:', msg.message); + this.emit('error', msg.message); + }); + + this.wsClient.on('connection', (connected: boolean) => { + this.emit('connection', connected); + }); + + await this.wsClient.connect(); + } + + /** + * Live API 接続を切断 + */ + private disconnectLive(): void { + if (this.audioIO) { + this.audioIO.destroy(); + this.audioIO = null; + } + if (this.wsClient) { + this.wsClient.disconnect(); + this.wsClient = null; + } + } + + /** + * Live API: マイク音声ストリーミング開始 + * + * ★ 必ずユーザーインタラクション(tap/click)のイベントハンドラ内から呼ぶこと + * iPhone の autoplay policy 回避のため + */ + async startLiveStream(): Promise { + if (!this.wsClient || !this.wsClient.isConnected) { + throw new Error('Live API not connected'); + } + + this.audioIO = new LiveAudioIO({ + wsClient: this.wsClient, + sendSampleRate: 16000, + receiveSampleRate: 24000, + }); + + await this.audioIO.startMic(); + console.log('[DialogueManager] Live stream started'); + } + + /** + * Live API: マイク音声ストリーミング停止 + */ + stopLiveStream(): void { + if (this.audioIO) { + this.audioIO.stopMic(); + } + console.log('[DialogueManager] Live stream stopped'); + } + + /** + * Live API: テキスト送信 + */ + sendLiveText(text: string): void { + if (this.wsClient && this.wsClient.isConnected) { + this.wsClient.sendText(text); + } + } + + // ======================================== + // イベント管理 + // ======================================== + + on(event: string, handler: EventHandler): void { + const handlers = this.eventHandlers.get(event) || []; + handlers.push(handler); + this.eventHandlers.set(event, handlers); + } + + off(event: string, handler: EventHandler): void { + const handlers = this.eventHandlers.get(event) || []; + this.eventHandlers.set(event, handlers.filter((h) => h !== handler)); + } + + private emit(event: string, data: any): void { + const handlers = this.eventHandlers.get(event) || []; + handlers.forEach((h) => h(data)); + } + + // ======================================== + // アクセサ + // ======================================== + + get currentSessionId(): string | null { + return this.sessionId; + } + + get currentMode(): string { + return this.mode; + } + + get currentLanguage(): string { + return this.language; + } + + get currentDialogueType(): DialogueType { + return this.dialogueType; + } + + get isLiveConnected(): boolean { + return this.wsClient?.isConnected ?? false; + } + + get isMicActive(): boolean { + return this.audioIO?.micActive ?? false; + } +} diff --git a/support_base/frontend/src/scripts/platform/live-ws-client.ts b/support_base/frontend/src/scripts/platform/live-ws-client.ts new file mode 100644 index 0000000..33e8014 --- /dev/null +++ b/support_base/frontend/src/scripts/platform/live-ws-client.ts @@ -0,0 +1,203 @@ +/** + * LiveRelay WebSocket クライアント + * + * support_base/live/relay.py の LiveRelay に接続する。 + * ブラウザ ↔ サーバー 間のプロトコルを実装。 + * + * プロトコル (relay.py L15-28): + * クライアント → サーバー: + * { "type": "audio", "data": "" } + * { "type": "text", "data": "テキスト入力" } + * { "type": "stop" } + * + * サーバー → クライアント: + * { "type": "audio", "data": "" } + * { "type": "transcription", "role": "user"|"ai", "text": "...", "is_partial": bool } + * { "type": "expression", "data": { names, frames, frame_rate } } + * { "type": "interrupted" } + * { "type": "reconnecting", "reason": "..." } + * { "type": "reconnected", "session_count": N } + * { "type": "error", "message": "..." } + */ + +export interface LiveWSClientOptions { + /** WebSocket URL (relay.py: /api/v2/live/{session_id}) */ + wsUrl: string; + /** 接続タイムアウト (ms) */ + connectTimeout?: number; +} + +export type LiveWSMessageType = + | 'audio' + | 'transcription' + | 'expression' + | 'interrupted' + | 'reconnecting' + | 'reconnected' + | 'error'; + +export interface LiveWSMessage { + type: LiveWSMessageType; + data?: any; + role?: 'user' | 'ai'; + text?: string; + is_partial?: boolean; + reason?: string; + session_count?: number; + message?: string; +} + +type LiveWSEventHandler = (msg: LiveWSMessage) => void; +type LiveWSConnectionHandler = (connected: boolean) => void; + +export class LiveWSClient { + private ws: WebSocket | null = null; + private wsUrl: string; + private connectTimeout: number; + private handlers: Map = new Map(); + private connectionHandlers: LiveWSConnectionHandler[] = []; + private _isConnected = false; + + constructor(options: LiveWSClientOptions) { + this.wsUrl = options.wsUrl; + this.connectTimeout = options.connectTimeout ?? 10000; + } + + /** + * WebSocket 接続を開始 + * relay.py L92: handle_client_ws → websocket.accept() + */ + async connect(): Promise { + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + reject(new Error('WebSocket connection timeout')); + }, this.connectTimeout); + + try { + // ws:// or wss:// のURLを構築 + const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; + const host = window.location.host; + const url = this.wsUrl.startsWith('ws') + ? this.wsUrl + : `${protocol}//${host}${this.wsUrl}`; + + this.ws = new WebSocket(url); + + this.ws.onopen = () => { + clearTimeout(timer); + this._isConnected = true; + this.notifyConnection(true); + console.log('[LiveWSClient] Connected:', url); + resolve(); + }; + + this.ws.onclose = (event) => { + clearTimeout(timer); + this._isConnected = false; + this.notifyConnection(false); + console.log(`[LiveWSClient] Closed: code=${event.code}, reason=${event.reason}`); + }; + + this.ws.onerror = (event) => { + clearTimeout(timer); + console.error('[LiveWSClient] Error:', event); + reject(new Error('WebSocket connection error')); + }; + + this.ws.onmessage = (event) => { + this.handleMessage(event.data); + }; + } catch (e) { + clearTimeout(timer); + reject(e); + } + }); + } + + /** + * 接続を閉じる + * relay.py L241-243: "stop" メッセージで正常終了 + */ + disconnect(): void { + if (this.ws) { + // サーバーに停止を通知 + this.sendJson({ type: 'stop' }); + this.ws.close(); + this.ws = null; + } + this._isConnected = false; + } + + /** + * 音声データ (PCM 16kHz base64) を送信 + * relay.py L221-226: { "type": "audio", "data": "" } + */ + sendAudio(base64Pcm: string): void { + this.sendJson({ type: 'audio', data: base64Pcm }); + } + + /** + * テキストメッセージを送信 + * relay.py L228-238: { "type": "text", "data": "テキスト入力" } + */ + sendText(text: string): void { + this.sendJson({ type: 'text', data: text }); + } + + /** + * イベントハンドラを登録 + */ + on(event: LiveWSMessageType | 'connection', handler: any): void { + if (event === 'connection') { + this.connectionHandlers.push(handler as LiveWSConnectionHandler); + return; + } + const existing = this.handlers.get(event) || []; + existing.push(handler as LiveWSEventHandler); + this.handlers.set(event, existing); + } + + /** + * イベントハンドラを解除 + */ + off(event: LiveWSMessageType | 'connection', handler: any): void { + if (event === 'connection') { + this.connectionHandlers = this.connectionHandlers.filter(h => h !== handler); + return; + } + const existing = this.handlers.get(event) || []; + this.handlers.set(event, existing.filter(h => h !== handler)); + } + + get isConnected(): boolean { + return this._isConnected; + } + + // --- private --- + + private sendJson(obj: any): void { + if (this.ws && this.ws.readyState === WebSocket.OPEN) { + this.ws.send(JSON.stringify(obj)); + } + } + + /** + * サーバーからのメッセージをパースしてハンドラに配信 + * relay.py のサーバー→クライアント全メッセージ型に対応 + */ + private handleMessage(raw: string): void { + try { + const msg: LiveWSMessage = JSON.parse(raw); + const handlers = this.handlers.get(msg.type); + if (handlers) { + handlers.forEach(h => h(msg)); + } + } catch (e) { + console.warn('[LiveWSClient] Failed to parse message:', raw); + } + } + + private notifyConnection(connected: boolean): void { + this.connectionHandlers.forEach(h => h(connected)); + } +} diff --git a/support_base/frontend/src/scripts/platform/platform-controller.ts b/support_base/frontend/src/scripts/platform/platform-controller.ts new file mode 100644 index 0000000..9d13072 --- /dev/null +++ b/support_base/frontend/src/scripts/platform/platform-controller.ts @@ -0,0 +1,549 @@ +/** + * PlatformController — プラットフォーム共通コントローラー + * + * concierge-controller.ts (CoreController → ConciergeController) の設計パターンを踏襲し、 + * REST / Live API 両対応のプラットフォーム共通基盤を提供する。 + * + * 設計原則 (PLATFORM_ARCHITECTURE.md §8): + * - モード非依存の共通層 → modes/gourmet-mode.ts 等で拡張 + * - REST 経路と Live API 経路を DialogueManager で切替 + * - LAMAvatar との統合は ConciergeController のパターンをそのまま踏襲 + * - オーディオ制御コード (AudioManager, ttsPlayer) は一切改変しない + * + * ★★★ 注意 ★★★ + * ttsPlayer, AudioManager 関連のコードは既存パッチから変更せずコピーすること。 + * iPhone 16/17 の iOS セキュリティ制限対策が含まれており、 + * 書き換えると動かなくなる(実証済み)。 + */ + +import { + DialogueManager, + type DialogueType, + type SessionInfo, + type ExpressionData, +} from './dialogue-manager'; + +// ======================================== +// 多言語設定 (PLATFORM_ARCHITECTURE.md §7.4) +// concierge-controller.ts L199, L285 の LANGUAGE_CODE_MAP を再現 +// ======================================== + +interface LanguageConfig { + code: string; + tts: string; + voice: string; + sentenceSplitter: 'cjk' | 'latin'; +} + +const LANGUAGE_CONFIG_MAP: Record = { + ja: { code: 'ja', tts: 'ja-JP', voice: 'ja-JP-Chirp3-HD-Leda', sentenceSplitter: 'cjk' }, + en: { code: 'en', tts: 'en-US', voice: 'en-US-Wavenet-D', sentenceSplitter: 'latin' }, + ko: { code: 'ko', tts: 'ko-KR', voice: 'ko-KR-Wavenet-D', sentenceSplitter: 'latin' }, + zh: { code: 'zh', tts: 'cmn-CN', voice: 'cmn-CN-Wavenet-D', sentenceSplitter: 'cjk' }, +}; + +// ======================================== +// DOM 要素インターフェース +// ======================================== + +interface PlatformElements { + chatArea: HTMLElement | null; + userInput: HTMLInputElement | null; + sendBtn: HTMLButtonElement | null; + micBtn: HTMLButtonElement | null; + voiceStatus: HTMLElement | null; + avatarContainer: HTMLElement | null; + modeIndicator: HTMLElement | null; +} + +// ======================================== +// FLAME LBS 安全上限 (concierge-controller.ts L381) +// ======================================== +const BLENDSHAPE_SAFE_MAX = 0.7; + +// 口周り blendshape スケール係数 (concierge-controller.ts L359-378) +// 全て 1.0(増幅なし)— チューニング時にここを調整 +const MOUTH_AMPLIFY: Record = { + jawOpen: 1.0, mouthClose: 1.0, mouthFunnel: 1.0, mouthPucker: 1.0, + mouthSmileLeft: 1.0, mouthSmileRight: 1.0, mouthStretchLeft: 1.0, mouthStretchRight: 1.0, + mouthLowerDownLeft: 1.0, mouthLowerDownRight: 1.0, mouthUpperUpLeft: 1.0, mouthUpperUpRight: 1.0, + mouthDimpleLeft: 1.0, mouthDimpleRight: 1.0, mouthRollLower: 1.0, mouthRollUpper: 1.0, + mouthShrugLower: 1.0, mouthShrugUpper: 1.0, +}; + +export class PlatformController { + private container: HTMLElement; + private apiBase: string; + private dialogueManager: DialogueManager; + private els: PlatformElements; + + // 状態 + private currentLanguage: string = 'ja'; + private currentMode: string = 'gourmet'; + private dialogueType: DialogueType = 'live'; + private sessionInfo: SessionInfo | null = null; + + // TTS プレーヤー(既存 ConciergeController と同一パターン) + // ★ このプレーヤーの制御パターンは変更禁止(iPhone対策済み) + private ttsPlayer: HTMLAudioElement; + + // Live API 状態 + private isLiveStreaming = false; + private isAISpeaking = false; + + // LAMAvatar 連携用フラグ + private lamLinked = false; + + constructor(container: HTMLElement, apiBase: string) { + this.container = container; + this.apiBase = apiBase; + this.dialogueManager = new DialogueManager(apiBase); + this.ttsPlayer = new Audio(); + + this.els = { + chatArea: container.querySelector('.chat-area'), + userInput: container.querySelector('#userInput') as HTMLInputElement, + sendBtn: container.querySelector('#sendBtn') as HTMLButtonElement, + micBtn: container.querySelector('#micBtn') as HTMLButtonElement, + voiceStatus: container.querySelector('.voice-status'), + avatarContainer: container.querySelector('.avatar-container'), + modeIndicator: container.querySelector('#modeIndicator'), + }; + + this.init(); + } + + // ======================================== + // 初期化 + // ======================================== + + private async init(): Promise { + this.setupEventListeners(); + this.linkLAMAvatar(); + this.setupDialogueEvents(); + await this.initializeSession(); + } + + /** + * UI イベントリスナー設定 + */ + private setupEventListeners(): void { + // テキスト送信 + this.els.sendBtn?.addEventListener('click', () => this.handleSendMessage()); + this.els.userInput?.addEventListener('keydown', (e) => { + if (e.key === 'Enter' && !e.shiftKey) { + e.preventDefault(); + this.handleSendMessage(); + } + }); + + // マイクボタン + // ★ ユーザーインタラクション起点で呼ぶこと(iPhone autoplay policy) + this.els.micBtn?.addEventListener('click', () => this.toggleMic()); + } + + /** + * LAMAvatar との統合 + * concierge-controller.ts L43-65 のパターンを踏襲 + */ + private linkLAMAvatar(): void { + const tryLink = (attempt: number): boolean => { + const lam = (window as any).lamAvatarController; + if (lam && typeof lam.setExternalTtsPlayer === 'function') { + lam.setExternalTtsPlayer(this.ttsPlayer); + this.lamLinked = true; + console.log(`[Platform] TTS player linked with LAMAvatar (attempt #${attempt})`); + return true; + } + return false; + }; + + if (!tryLink(1)) { + [500, 1000, 2000, 4000].forEach((delay, i) => { + setTimeout(() => tryLink(i + 2), delay); + }); + } + } + + /** + * DialogueManager のイベントハンドラ設定(Live API 経路) + */ + private setupDialogueEvents(): void { + // AI テキスト受信 + this.dialogueManager.on('ai_text', (data: { text: string; isPartial: boolean }) => { + if (!data.isPartial) { + this.addMessage('assistant', data.text); + } + }); + + // ユーザーテキスト受信(Live API transcription) + this.dialogueManager.on('user_text', (data: { text: string; isPartial: boolean }) => { + if (this.els.userInput) { + this.els.userInput.value = data.text; + } + if (!data.isPartial) { + this.addMessage('user', data.text); + if (this.els.userInput) this.els.userInput.value = ''; + } + }); + + // Expression 受信 (Live API 経路) + this.dialogueManager.on('expression', (data: ExpressionData) => { + this.applyExpressionFromLive(data); + }); + + // 割り込み + this.dialogueManager.on('interrupted', () => { + console.log('[Platform] Barge-in detected'); + this.isAISpeaking = false; + this.stopAvatarAnimation(); + }); + + // 再接続 + this.dialogueManager.on('reconnecting', (reason: string) => { + console.log(`[Platform] Reconnecting: ${reason}`); + this.setVoiceStatus('reconnecting'); + }); + + this.dialogueManager.on('reconnected', (count: number) => { + console.log(`[Platform] Reconnected: session #${count}`); + this.setVoiceStatus('connected'); + }); + } + + // ======================================== + // セッション管理 + // ======================================== + + /** + * セッション初期化 + * concierge-controller.ts L162-233 の initializeSession() に相当 + */ + private async initializeSession(): Promise { + try { + this.sessionInfo = await this.dialogueManager.startSession( + this.currentMode, + this.currentLanguage, + this.dialogueType + ); + + // 初回挨拶表示 + this.addMessage('assistant', this.sessionInfo.greeting); + + // Live API モードの場合、マイクボタンを有効化 + if (this.dialogueType === 'live') { + this.updateModeIndicator('Live API'); + } else { + this.updateModeIndicator('REST'); + } + + // UI 有効化 + this.enableInput(); + + console.log( + `[Platform] Session started: ${this.sessionInfo.sessionId} ` + + `mode=${this.currentMode} type=${this.dialogueType}` + ); + } catch (e) { + console.error('[Platform] Session init failed:', e); + this.addMessage('system', 'セッションの初期化に失敗しました。ページをリロードしてください。'); + } + } + + // ======================================== + // メッセージ送信 + // ======================================== + + /** + * テキストメッセージ送信 + */ + private async handleSendMessage(): Promise { + const text = this.els.userInput?.value.trim(); + if (!text) return; + + this.addMessage('user', text); + if (this.els.userInput) this.els.userInput.value = ''; + this.disableInput(); + + if (this.dialogueType === 'live') { + // Live API: テキスト入力を WebSocket 経由で送信 + this.dialogueManager.sendLiveText(text); + this.enableInput(); + } else { + // REST: 従来の chat API を使用 + await this.handleRestChat(text); + } + } + + /** + * REST 経路のチャット処理 + * concierge-controller.ts L767-1040 の sendMessage() のパターンを踏襲 + */ + private async handleRestChat(message: string): Promise { + try { + const result = await this.dialogueManager.sendChat(message); + this.addMessage('assistant', result.response); + + // TTS + Expression(REST 経路) + if (result.response) { + await this.speakTextRest(result.response); + } + } catch (e) { + console.error('[Platform] Chat error:', e); + this.addMessage('system', 'メッセージの送信に失敗しました。'); + } finally { + this.enableInput(); + } + } + + // ======================================== + // TTS 再生 (REST 経路) + // ★ ttsPlayer の制御パターンは concierge-controller.ts から変更禁止 + // ======================================== + + /** + * REST 経路の TTS 再生 + * concierge-controller.ts L263-347 の speakTextGCP() パターンを踏襲 + * + * ★ ttsPlayer.src, .play(), .onended のパターンは変更禁止 + * iPhone autoplay policy 対策済みのコードパス + */ + private async speakTextRest(text: string): Promise { + if (!text.trim()) return; + + const langConfig = LANGUAGE_CONFIG_MAP[this.currentLanguage] || LANGUAGE_CONFIG_MAP['ja']; + + try { + this.isAISpeaking = true; + this.startAvatarAnimation(); + + const result = await this.dialogueManager.synthesizeTTS( + text, + langConfig.tts, + langConfig.voice + ); + + if (result.success && result.audio) { + // Expression 同梱データをバッファ投入(concierge-controller.ts L300-304) + if (result.expression) { + this.applyExpressionFromTts(result.expression); + } + + // ★ ttsPlayer 再生パターン — 変更禁止 + this.ttsPlayer.src = `data:audio/mp3;base64,${result.audio}`; + await new Promise((resolve) => { + this.ttsPlayer.onended = () => { + this.isAISpeaking = false; + this.stopAvatarAnimation(); + resolve(); + }; + this.ttsPlayer.onerror = () => { + this.isAISpeaking = false; + this.stopAvatarAnimation(); + resolve(); + }; + this.ttsPlayer.play().catch(() => { + this.isAISpeaking = false; + this.stopAvatarAnimation(); + resolve(); + }); + }); + } else { + this.isAISpeaking = false; + this.stopAvatarAnimation(); + } + } catch (e) { + console.error('[Platform] TTS error:', e); + this.isAISpeaking = false; + this.stopAvatarAnimation(); + } + } + + // ======================================== + // Expression 適用 + // ======================================== + + /** + * REST 経路: TTS 応答に同梱された Expression を LAMAvatar に投入 + * concierge-controller.ts L392-465 の applyExpressionFromTts() をそのまま踏襲 + */ + private applyExpressionFromTts(expression: any): void { + const lamController = (window as any).lamAvatarController; + if (!lamController) return; + + if (typeof lamController.clearFrameBuffer === 'function') { + lamController.clearFrameBuffer(); + } + + if (expression?.names && expression?.frames?.length > 0) { + const srcFrameRate = expression.frame_rate || 30; + + // Step 1: フォーマット変換 + blendshape 増幅 + // concierge-controller.ts L411-427 + const rawFrames = expression.frames.map((f: any) => { + const frame: Record = {}; + const values: number[] = Array.isArray(f) ? f : (f.weights || []); + expression.names.forEach((name: string, i: number) => { + let val = values[i] || 0; + const amp = MOUTH_AMPLIFY[name]; + if (amp && amp !== 1.0) { + val = val * amp; + } + val = Math.min(BLENDSHAPE_SAFE_MAX, val); + frame[name] = val; + }); + return frame; + }); + + // Step 2: フレーム補間 (30fps → 60fps) + // concierge-controller.ts L430-443 + const interpolatedFrames: Record[] = []; + for (let i = 0; i < rawFrames.length; i++) { + interpolatedFrames.push(rawFrames[i]); + if (i < rawFrames.length - 1) { + const curr = rawFrames[i]; + const next = rawFrames[i + 1]; + const mid: Record = {}; + for (const key of Object.keys(curr)) { + mid[key] = (curr[key] + next[key]) * 0.5; + } + interpolatedFrames.push(mid); + } + } + const outputFrameRate = srcFrameRate * 2; + + // Step 3: LAMAvatar にキュー投入 + lamController.queueExpressionFrames(interpolatedFrames, outputFrameRate); + + console.log( + `[Platform] Expression: ${rawFrames.length}→${interpolatedFrames.length} frames ` + + `(${srcFrameRate}→${outputFrameRate}fps)` + ); + } + } + + /** + * Live API 経路: Expression データを LAMAvatar に投入 + * relay.py L397-404 から受信した expression を LAMAvatar のフレームバッファに追加 + */ + private applyExpressionFromLive(data: ExpressionData): void { + const lamController = (window as any).lamAvatarController; + if (!lamController || !data?.names || !data?.frames?.length) return; + + const frameRate = data.frame_rate || 30; + + // Live API の expression は REST と同じフォーマット + // relay.py L399-403: { names, frames, frame_rate } + const frames = data.frames.map((f: any) => { + const frame: Record = {}; + const values: number[] = Array.isArray(f) ? f : (f.weights || []); + data.names.forEach((name: string, i: number) => { + let val = values[i] || 0; + val = Math.min(BLENDSHAPE_SAFE_MAX, val); + frame[name] = val; + }); + return frame; + }); + + // Live API はストリーミングなので append(clearFrameBuffer しない) + lamController.queueExpressionFrames(frames, frameRate); + } + + // ======================================== + // マイク制御 (Live API) + // ======================================== + + /** + * マイクの ON/OFF 切替 + * ★ ユーザーインタラクション(click)起点で呼ばれる + */ + private async toggleMic(): Promise { + if (this.dialogueType !== 'live') return; + + if (this.isLiveStreaming) { + this.dialogueManager.stopLiveStream(); + this.isLiveStreaming = false; + this.setVoiceStatus('stopped'); + this.els.micBtn?.classList.remove('recording'); + } else { + try { + await this.dialogueManager.startLiveStream(); + this.isLiveStreaming = true; + this.setVoiceStatus('listening'); + this.els.micBtn?.classList.add('recording'); + } catch (e) { + console.error('[Platform] Mic start failed:', e); + this.setVoiceStatus('error'); + } + } + } + + // ======================================== + // UI ヘルパー + // ======================================== + + private addMessage(role: 'user' | 'assistant' | 'system', text: string): void { + if (!this.els.chatArea) return; + + const msgDiv = document.createElement('div'); + msgDiv.className = `message ${role}`; + + const textSpan = document.createElement('span'); + textSpan.className = 'message-text'; + textSpan.textContent = text; + msgDiv.appendChild(textSpan); + + this.els.chatArea.appendChild(msgDiv); + this.els.chatArea.scrollTop = this.els.chatArea.scrollHeight; + } + + private enableInput(): void { + if (this.els.userInput) this.els.userInput.disabled = false; + if (this.els.sendBtn) this.els.sendBtn.disabled = false; + if (this.els.micBtn) this.els.micBtn.disabled = false; + } + + private disableInput(): void { + if (this.els.sendBtn) this.els.sendBtn.disabled = true; + if (this.els.micBtn) this.els.micBtn.disabled = true; + } + + private setVoiceStatus(status: string): void { + if (!this.els.voiceStatus) return; + this.els.voiceStatus.className = `voice-status ${status}`; + + const labels: Record = { + listening: '聞いています...', + stopped: '', + speaking: '話しています...', + reconnecting: '再接続中...', + connected: '接続済み', + error: 'エラー', + }; + this.els.voiceStatus.textContent = labels[status] || ''; + } + + private startAvatarAnimation(): void { + this.els.avatarContainer?.classList.add('speaking'); + } + + private stopAvatarAnimation(): void { + this.els.avatarContainer?.classList.remove('speaking'); + } + + private updateModeIndicator(label: string): void { + if (this.els.modeIndicator) { + this.els.modeIndicator.textContent = label; + } + } + + // ======================================== + // ライフサイクル + // ======================================== + + async destroy(): Promise { + await this.dialogueManager.endSession(); + this.ttsPlayer.pause(); + this.ttsPlayer.src = ''; + } +} diff --git a/support_base/frontend/tsconfig.json b/support_base/frontend/tsconfig.json new file mode 100644 index 0000000..ebc6dd7 --- /dev/null +++ b/support_base/frontend/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "astro/tsconfigs/strict", + "compilerOptions": { + "baseUrl": ".", + "paths": { + "@scripts/*": ["src/scripts/*"], + "@components/*": ["src/components/*"] + } + } +} diff --git a/support_base/i18n/__init__.py b/support_base/i18n/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/support_base/i18n/language_config.py b/support_base/i18n/language_config.py new file mode 100644 index 0000000..f64b2a9 --- /dev/null +++ b/support_base/i18n/language_config.py @@ -0,0 +1,64 @@ +""" +言語マスター設定 + +gourmet-sp の CoreController.LANGUAGE_CODE_MAP に相当する設定を +プラットフォーム共通基盤として提供する。 + +[確認済み] concierge-controller.ts L526-546: ja/zh → 。分割、en/ko → . 分割 +[確認済み] stt_stream.py L221: ja-JP-Wavenet-D +[推定] 他言語のTTS voice名は gourmet-sp リポジトリ確認後に正確な値で更新 +""" + +from dataclasses import dataclass + + +@dataclass(frozen=True) +class LanguageProfile: + """1言語の設定プロファイル""" + code: str # "ja", "en", "ko", "zh" + tts_language_code: str # Google Cloud TTS の language_code + tts_voice_name: str # Google Cloud TTS の voice name + live_api_language_code: str # Gemini Live API の speech_config.language_code + sentence_splitter: str # "cjk" (。で分割) or "latin" (. で分割) + display_name: str # 表示用言語名 + + +LANGUAGE_PROFILES: dict[str, LanguageProfile] = { + "ja": LanguageProfile( + code="ja", + tts_language_code="ja-JP", + tts_voice_name="ja-JP-Wavenet-D", + live_api_language_code="ja-JP", + sentence_splitter="cjk", + display_name="日本語", + ), + "en": LanguageProfile( + code="en", + tts_language_code="en-US", + tts_voice_name="en-US-Wavenet-D", + live_api_language_code="en-US", + sentence_splitter="latin", + display_name="English", + ), + "ko": LanguageProfile( + code="ko", + tts_language_code="ko-KR", + tts_voice_name="ko-KR-Wavenet-D", + live_api_language_code="ko-KR", + sentence_splitter="latin", + display_name="한국어", + ), + "zh": LanguageProfile( + code="zh", + tts_language_code="cmn-CN", + tts_voice_name="cmn-CN-Wavenet-D", + live_api_language_code="cmn-CN", + sentence_splitter="cjk", + display_name="中文", + ), +} + + +def get_language_profile(code: str) -> LanguageProfile: + """言語プロファイルを取得(デフォルトは日本語)""" + return LANGUAGE_PROFILES.get(code, LANGUAGE_PROFILES["ja"]) diff --git a/support_base/live/__init__.py b/support_base/live/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/support_base/live/reconnect.py b/support_base/live/reconnect.py new file mode 100644 index 0000000..4f10a48 --- /dev/null +++ b/support_base/live/reconnect.py @@ -0,0 +1,106 @@ +""" +Live API 再接続管理 + +stt_stream.py L372-373, L624-643, L714-796 から移植。 +Gemini FLASH版の累積トークン制限を回避するための自動再接続ロジック。 +""" + +import logging + +from support_base.live.speech_detector import SpeechDetector +from support_base.config.settings import ( + MAX_AI_CHARS_BEFORE_RECONNECT, + LONG_SPEECH_THRESHOLD, +) + +logger = logging.getLogger(__name__) + + +class ReconnectManager: + """ + 累積文字数制限の回避ロジック + + FLASH版 (gemini-2.5-flash-native-audio-preview) には + セッション内の累積入出力トークンに制限がある。 + これを超えるとAPIエラー(1011/1008)が発生しセッションが切断される。 + + 回避策: + 1. AI発話の累積文字数を追跡 + 2. 閾値に達したら再接続フラグを立てる + 3. 発話途切れを検知したら即時再接続 + 4. 再接続時に会話コンテキストを引き継ぐ + """ + + def __init__( + self, + max_chars: int = MAX_AI_CHARS_BEFORE_RECONNECT, + long_speech_threshold: int = LONG_SPEECH_THRESHOLD, + ): + self.max_chars = max_chars + self.long_speech_threshold = long_speech_threshold + self.ai_char_count = 0 + self.needs_reconnect = False + self.reconnect_reason: str | None = None + self.session_count = 0 + + def on_ai_speech_complete(self, text: str, language: str = "ja") -> None: + """ + AI発話完了時に呼び出す。再接続判定を行う。 + + stt_stream.py L624-643 のロジックを移植: + 1. 発話途切れ → 即時再接続 + 2. 長文発話(500文字超) → 次ターン前に再接続 + 3. 累積800文字超 → 再接続 + """ + char_count = len(text) + self.ai_char_count += char_count + remaining = self.max_chars - self.ai_char_count + + logger.info( + f"[Reconnect] chars={char_count}, " + f"cumulative={self.ai_char_count}, remaining={remaining}" + ) + + # 1. 発話途切れ → 即時再接続 + if SpeechDetector.is_incomplete(text, language): + self.needs_reconnect = True + self.reconnect_reason = "incomplete" + logger.warning("[Reconnect] Speech incomplete, reconnecting") + return + + # 2. 長文発話 → 次ターン前に再接続 + if char_count >= self.long_speech_threshold: + self.needs_reconnect = True + self.reconnect_reason = "long_speech" + logger.info( + f"[Reconnect] Long speech ({char_count} chars), reconnecting" + ) + return + + # 3. 累積上限 → 再接続 + if self.ai_char_count >= self.max_chars: + self.needs_reconnect = True + self.reconnect_reason = "char_limit" + logger.info("[Reconnect] Char limit reached, reconnecting") + + def reset_for_new_session(self) -> None: + """新セッション開始時にカウンターをリセット""" + self.ai_char_count = 0 + self.needs_reconnect = False + self.reconnect_reason = None + self.session_count += 1 + logger.info(f"[Reconnect] Session #{self.session_count} started") + + @staticmethod + def is_retriable_error(error: Exception) -> bool: + """ + 再接続可能なエラーか判定 + + stt_stream.py L786-796, L902 から移植 + """ + msg = str(error).lower() + retriable_keywords = [ + "1011", "1008", "internal error", "disconnected", + "closed", "websocket", "deadline", "policy", + ] + return any(kw in msg for kw in retriable_keywords) diff --git a/support_base/live/relay.py b/support_base/live/relay.py new file mode 100644 index 0000000..f7a9b9b --- /dev/null +++ b/support_base/live/relay.py @@ -0,0 +1,465 @@ +""" +Live API WebSocket 中継 (LiveRelay) + +stt_stream.py の GeminiLiveApp を Web 向けに再構成。 +ブラウザ ↔ サーバー ↔ Gemini Live API の WebSocket 中継を行う。 + +主要な責務: +1. ブラウザからの PCM 16kHz 音声を Gemini に中継 +2. Gemini からの PCM 24kHz 音声をブラウザに中継 +3. 累積文字数制限の回避(自動再接続 + コンテキスト引き継ぎ) +4. AI音声を A2E サービスに送信し、Expression をブラウザに中継(アバター連携) +5. 割り込み(barge-in)処理 +6. transcription のブラウザ中継 + +プロトコル (クライアント ↔ サーバー WebSocket): + クライアント → サーバー: + { "type": "audio", "data": "" } + { "type": "text", "data": "テキスト入力" } + { "type": "stop" } + + サーバー → クライアント: + { "type": "audio", "data": "" } + { "type": "transcription", "role": "user"|"ai", "text": "..." } + { "type": "expression", "data": { names, frames, frame_rate } } + { "type": "interrupted" } + { "type": "reconnecting", "reason": "..." } + { "type": "reconnected", "session_count": N } + { "type": "error", "message": "..." } +""" + +import asyncio +import base64 +import logging +from dataclasses import dataclass, field + +from google import genai +from google.genai import types + +from fastapi import WebSocket, WebSocketDisconnect + +from support_base.config.settings import ( + GEMINI_API_KEY, + LIVE_API_MODEL, + RECONNECT_DELAY_SECONDS, +) +from support_base.i18n.language_config import get_language_profile +from support_base.live.reconnect import ReconnectManager +from support_base.modes.base_mode import BaseModePlugin +from support_base.services.a2e_client import A2EClient +from support_base.session.manager import Session + +logger = logging.getLogger(__name__) + + +@dataclass +class LiveRelayState: + """LiveRelay の内部状態""" + user_transcript_buffer: str = "" + ai_transcript_buffer: str = "" + ai_audio_buffer: bytearray = field(default_factory=bytearray) + is_running: bool = True + + +class LiveRelay: + """ + Gemini Live API WebSocket 中継 + + stt_stream.py の GeminiLiveApp.run() + _session_loop() + + receive_audio() を Web 向けに再構成。 + """ + + def __init__( + self, + session: Session, + mode_plugin: BaseModePlugin, + a2e_client: A2EClient | None = None, + ): + self.session = session + self.mode_plugin = mode_plugin + self.a2e_client = a2e_client + self.reconnect_mgr = ReconnectManager() + self.state = LiveRelayState() + self._gemini_client = genai.Client(api_key=GEMINI_API_KEY) + + async def handle_client_ws(self, websocket: WebSocket) -> None: + """ + クライアント WebSocket ハンドラ — メインエントリーポイント + + stt_stream.py L714-796 の run() に相当。 + 再接続ループを管理する。 + """ + await websocket.accept() + logger.info(f"[LiveRelay] Client connected: session={self.session.session_id}") + + try: + while self.state.is_running: + try: + await self._run_gemini_session(websocket) + if not self.reconnect_mgr.needs_reconnect: + break + except WebSocketDisconnect: + logger.info("[LiveRelay] Client disconnected") + break + except Exception as e: + if ReconnectManager.is_retriable_error(e): + logger.warning(f"[LiveRelay] Retriable error: {e}") + await self._send_json(websocket, { + "type": "reconnecting", + "reason": "error", + }) + await asyncio.sleep(RECONNECT_DELAY_SECONDS) + self.reconnect_mgr.needs_reconnect = True + continue + logger.error(f"[LiveRelay] Fatal error: {e}", exc_info=True) + await self._send_json(websocket, { + "type": "error", + "message": str(e), + }) + break + finally: + logger.info(f"[LiveRelay] Session ended: {self.session.session_id}") + + async def _run_gemini_session(self, client_ws: WebSocket) -> None: + """ + 1つの Gemini セッションを実行 + + stt_stream.py L741-783 の1ループ反復に相当。 + """ + # コンテキスト引き継ぎ (stt_stream.py L747-752) + context = None + if self.reconnect_mgr.session_count > 0: + context = self.session.memory.get_context_summary() + logger.info( + f"[LiveRelay] Reconnecting with context: " + f"{context[:80] if context else 'none'}..." + ) + + config = self._build_live_config(context) + self.reconnect_mgr.reset_for_new_session() + self.session.live_session_count = self.reconnect_mgr.session_count + + # 状態リセット + self.state.user_transcript_buffer = "" + self.state.ai_transcript_buffer = "" + self.state.ai_audio_buffer = bytearray() + + async with self._gemini_client.aio.live.connect( + model=LIVE_API_MODEL, + config=config, + ) as gemini_session: + + if self.reconnect_mgr.session_count > 1: + # 再接続通知 (stt_stream.py L766-776) + try: + await gemini_session.send_client_content( + turns=types.Content( + role="user", + parts=[types.Part(text="続きをお願いします")], + ), + turn_complete=True, + ) + logger.info("[LiveRelay] Reconnection prompt sent") + except Exception as e: + logger.warning(f"[LiveRelay] Reconnection prompt failed: {e}") + + await self._send_json(client_ws, { + "type": "reconnected", + "session_count": self.reconnect_mgr.session_count, + }) + + # 3つの非同期タスクを並行実行 (stt_stream.py L926-930) + try: + async with asyncio.TaskGroup() as tg: + tg.create_task( + self._relay_client_to_gemini(client_ws, gemini_session) + ) + tg.create_task( + self._relay_gemini_to_client(gemini_session, client_ws) + ) + except* WebSocketDisconnect: + raise + except* Exception as eg: + for e in eg.exceptions: + if isinstance(e, WebSocketDisconnect): + raise e + if ReconnectManager.is_retriable_error(e): + self.reconnect_mgr.needs_reconnect = True + logger.warning(f"[LiveRelay] Task error (retriable): {e}") + else: + raise e + + async def _relay_client_to_gemini( + self, client_ws: WebSocket, gemini_session + ) -> None: + """ + ブラウザ → Gemini 中継 + + stt_stream.py の listen_audio() + send_audio() に相当。 + ブラウザからは JSON メッセージで音声/テキストを受け取り、Gemini に転送。 + """ + while not self.reconnect_mgr.needs_reconnect: + try: + raw = await asyncio.wait_for( + client_ws.receive_text(), timeout=0.5 + ) + except asyncio.TimeoutError: + continue + except WebSocketDisconnect: + self.state.is_running = False + self.reconnect_mgr.needs_reconnect = True + raise + + try: + import json + msg = json.loads(raw) + except (json.JSONDecodeError, ValueError): + continue + + msg_type = msg.get("type") + + if msg_type == "audio": + # base64 PCM 16kHz → Gemini + audio_bytes = base64.b64decode(msg["data"]) + await gemini_session.send_realtime_input( + audio={"data": audio_bytes, "mime_type": "audio/pcm"} + ) + + elif msg_type == "text": + # テキスト入力 → Gemini + text = msg.get("data", "") + if text: + await gemini_session.send_client_content( + turns=types.Content( + role="user", + parts=[types.Part(text=text)], + ), + turn_complete=True, + ) + self.session.memory.add("ユーザー", text) + + elif msg_type == "stop": + self.state.is_running = False + self.reconnect_mgr.needs_reconnect = True + return + + async def _relay_gemini_to_client( + self, gemini_session, client_ws: WebSocket + ) -> None: + """ + Gemini → ブラウザ 中継 + + stt_stream.py の receive_audio() (L579-683) を Web向けに再構成。 + 音声データ、transcription、割り込みをブラウザに中継し、 + A2Eサービスに並行リクエストして Expression もブラウザに送信する。 + """ + while not self.reconnect_mgr.needs_reconnect: + turn = gemini_session.receive() + async for response in turn: + if self.reconnect_mgr.needs_reconnect: + return + + sc = response.server_content + if not sc: + # tool_call 等の処理 (将来拡張) + continue + + # --- 割り込み検知 (stt_stream.py L650-662) --- + if hasattr(sc, "interrupted") and sc.interrupted: + logger.info("[LiveRelay] Barge-in detected") + # AI音声バッファをフラッシュ + if self.state.ai_transcript_buffer.strip(): + self.session.memory.add( + "AI", self.state.ai_transcript_buffer.strip() + ) + self.state.ai_transcript_buffer = "" + self.state.ai_audio_buffer = bytearray() + await self._send_json(client_ws, {"type": "interrupted"}) + continue + + # --- 入力 transcription (stt_stream.py L665-669) --- + if hasattr(sc, "input_transcription") and sc.input_transcription: + user_text = sc.input_transcription.text + if user_text: + self.state.user_transcript_buffer += user_text + await self._send_json(client_ws, { + "type": "transcription", + "role": "user", + "text": user_text, + "is_partial": True, + }) + + # --- 出力 transcription (stt_stream.py L672-676) --- + if hasattr(sc, "output_transcription") and sc.output_transcription: + ai_text = sc.output_transcription.text + if ai_text: + self.state.ai_transcript_buffer += ai_text + await self._send_json(client_ws, { + "type": "transcription", + "role": "ai", + "text": ai_text, + "is_partial": True, + }) + + # --- 音声データ (stt_stream.py L679-683) --- + if sc.model_turn: + for part in sc.model_turn.parts: + if hasattr(part, "inline_data") and part.inline_data: + audio_data = part.inline_data.data + if isinstance(audio_data, bytes): + # ブラウザに音声を即時送信 + await self._send_json(client_ws, { + "type": "audio", + "data": base64.b64encode(audio_data).decode(), + }) + # A2E用にバッファに蓄積 + self.state.ai_audio_buffer.extend(audio_data) + + # --- ターン完了 (stt_stream.py L600-645) --- + if hasattr(sc, "turn_complete") and sc.turn_complete: + await self._on_turn_complete(client_ws) + + async def _on_turn_complete(self, client_ws: WebSocket) -> None: + """ + ターン完了時の処理 + + stt_stream.py L600-645 に相当: + 1. ユーザー transcription を確定 + 2. AI transcription を確定 + 3. 累積文字数チェック → 再接続判定 + 4. A2E → Expression をブラウザに送信(アバター連携) + """ + # ユーザー発言の確定 + user_text = self.state.user_transcript_buffer.strip() + if user_text: + self.session.memory.add("ユーザー", user_text) + await self._send_json(client_ws, { + "type": "transcription", + "role": "user", + "text": user_text, + "is_partial": False, + }) + self.state.user_transcript_buffer = "" + + # AI発言の確定 + ai_text = self.state.ai_transcript_buffer.strip() + if ai_text: + self.session.memory.add("AI", ai_text) + await self._send_json(client_ws, { + "type": "transcription", + "role": "ai", + "text": ai_text, + "is_partial": False, + }) + + # 累積文字数チェック → 再接続判定 (stt_stream.py L624-643) + self.reconnect_mgr.on_ai_speech_complete( + ai_text, self.session.language + ) + if self.reconnect_mgr.needs_reconnect: + await self._send_json(client_ws, { + "type": "reconnecting", + "reason": self.reconnect_mgr.reconnect_reason, + }) + self.state.ai_transcript_buffer = "" + + # --- A2E → Expression(アバター連携) --- + # AI音声をA2Eサービスに送信し、表情データをブラウザに中継 + # LAMAvatarController の frameBuffer に投入される + if self.a2e_client and len(self.state.ai_audio_buffer) > 0: + asyncio.create_task( + self._process_a2e_and_send( + client_ws, + bytes(self.state.ai_audio_buffer), + ) + ) + self.state.ai_audio_buffer = bytearray() + + async def _process_a2e_and_send( + self, client_ws: WebSocket, audio_pcm: bytes + ) -> None: + """ + A2E推論 + Expression送信(非同期・音声再生と並行) + + Live API 出力の PCM 24kHz 音声を A2E サービスに送信し、 + 52次元ARKitブレンドシェイプをブラウザに中継する。 + ブラウザ側の LAMAvatarController がこれを frameBuffer にキューして + 音声再生と同期してアバターを動かす。 + """ + try: + audio_b64 = base64.b64encode(audio_pcm).decode() + result = await self.a2e_client.process_audio( + audio_base64=audio_b64, + session_id=self.session.session_id, + audio_format="pcm", + ) + if result and result.frames: + await self._send_json(client_ws, { + "type": "expression", + "data": { + "names": result.names, + "frames": result.frames, + "frame_rate": result.frame_rate, + }, + }) + logger.info( + f"[LiveRelay] Expression sent: {len(result.frames)} frames" + ) + except Exception as e: + logger.warning(f"[LiveRelay] A2E failed (non-fatal): {e}") + + def _build_live_config(self, context: str | None = None) -> dict: + """ + Live API 設定を構築 + + stt_stream.py L410-468 の _build_config() を移植。 + モードプラグインからシステムプロンプトを取得し、 + 言語設定と再接続コンテキストを適用する。 + """ + lang_profile = get_language_profile(self.session.language) + + # モードプラグインからシステムプロンプト取得 + system_instruction = self.mode_plugin.get_system_prompt( + language=self.session.language, + context=context, + ) + + config = { + "response_modalities": ["AUDIO"], + "system_instruction": system_instruction, + "input_audio_transcription": {}, + "output_audio_transcription": {}, + "speech_config": { + "language_code": lang_profile.live_api_language_code, + }, + "realtime_input_config": { + "automatic_activity_detection": { + "disabled": False, + "start_of_speech_sensitivity": "START_SENSITIVITY_HIGH", + "end_of_speech_sensitivity": "END_SENSITIVITY_HIGH", + "prefix_padding_ms": 100, + "silence_duration_ms": 500, + } + }, + "context_window_compression": { + "sliding_window": { + "target_tokens": 32000, + } + }, + } + + # モード固有のツール定義 + tools = self.mode_plugin.get_live_api_tools() + if tools: + config["tools"] = tools + + return config + + @staticmethod + async def _send_json(ws: WebSocket, data: dict) -> None: + """WebSocket に JSON を送信(接続切れを安全に処理)""" + try: + import json + await ws.send_text(json.dumps(data, ensure_ascii=False)) + except Exception: + pass # クライアント切断時は無視 diff --git a/support_base/live/speech_detector.py b/support_base/live/speech_detector.py new file mode 100644 index 0000000..3797882 --- /dev/null +++ b/support_base/live/speech_detector.py @@ -0,0 +1,91 @@ +""" +発話途切れ検知 + +stt_stream.py L501-529 から移植。 +Live API の FLASH版で発話が途中で切れた場合を検知し、即時再接続を促す。 +""" + + +class SpeechDetector: + """発話途切れ検知(多言語対応)""" + + # 言語別ルール + # [確認済み] stt_stream.py L509-527 の日本語ルールを移植 + # 他言語は段階的に追加 + RULES: dict[str, dict] = { + "ja": { + "normal_endings": [ + "。", "?", "?", "!", "!", "ます", "です", + "ね", "よ", "した", "ください", + ], + "incomplete_patterns": [ + "、", "の", "を", "が", "は", "に", "で", "と", "も", "や", + ], + # ひらがな・カタカナのうち文末として不自然な文字 + "check_trailing_kana": True, + "safe_trailing": "ねよかなわ", + }, + "en": { + "normal_endings": [".", "?", "!", "right", "okay"], + "incomplete_patterns": [",", " and", " but", " or", " the", " a"], + "check_trailing_kana": False, + }, + "ko": { + "normal_endings": [".", "?", "!", "요", "다", "죠"], + "incomplete_patterns": [",", "는", "을", "를", "이", "가", "에"], + "check_trailing_kana": False, + }, + "zh": { + "normal_endings": ["。", "?", "!", "了", "吗", "呢"], + "incomplete_patterns": [",", "的", "和", "在", "是"], + "check_trailing_kana": False, + }, + } + + @staticmethod + def is_incomplete(text: str, language: str = "ja") -> bool: + """ + 発言が途中で切れているかチェック + + Returns: + True: 途中で切れている可能性が高い → 再接続推奨 + False: 正常に終了している + """ + if not text: + return False + + text = text.strip() + if not text: + return False + + rules = SpeechDetector.RULES.get(language) + if not rules: + return False # ルール未定義の言語は安全側(False) + + # 正常な終わり方チェック + for ending in rules["normal_endings"]: + if text.endswith(ending): + return False + + # 途中切れパターンチェック + for pattern in rules["incomplete_patterns"]: + if text.endswith(pattern): + return True + + # 日本語: ひらがな・カタカナの文末チェック + if rules.get("check_trailing_kana"): + last_char = text[-1] + kana = ( + "あいうえおかきくけこさしすせそたちつてと" + "なにぬねのはひふへほまみむめもやゆよ" + "らりるれろわをん" + "アイウエオカキクケコサシスセソタチツテト" + "ナニヌネノハヒフヘホマミムメモヤユヨ" + "ラリルレロワヲン" + ) + if last_char in kana: + safe = rules.get("safe_trailing", "") + if last_char not in safe: + return True + + return False diff --git a/support_base/memory/__init__.py b/support_base/memory/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/support_base/memory/session_memory.py b/support_base/memory/session_memory.py new file mode 100644 index 0000000..cbcf7d4 --- /dev/null +++ b/support_base/memory/session_memory.py @@ -0,0 +1,83 @@ +""" +短期記憶 (SessionMemory) + +stt_stream.py L389, L490-495, L940-966 から移植。 +セッション内の会話コンテキストを維持し、Live API再接続時のコンテキスト引き継ぎを担う。 +""" + +from datetime import datetime + + +class SessionMemory: + """短期記憶 — セッション内インメモリ""" + + MAX_HISTORY = 20 # stt_stream.py L493-495: 直近20ターン保持 + CONTEXT_SUMMARY_TURNS = 10 # stt_stream.py L946: 要約は直近10ターン + MAX_TEXT_IN_SUMMARY = 150 # stt_stream.py L951: 要約内テキスト上限 + + def __init__(self): + self.history: list[dict] = [] + + def add(self, role: str, text: str) -> None: + """ + 会話ターンを追加 + + stt_stream.py L490-495: + conversation_history.append({"role": role, "text": text}) + 直近20ターンを保持 + """ + self.history.append({ + "role": role, + "text": text, + "timestamp": datetime.now().isoformat(), + }) + if len(self.history) > self.MAX_HISTORY: + self.history = self.history[-self.MAX_HISTORY:] + + def get_context_summary(self) -> str: + """ + 再接続時のコンテキスト要約を生成 + + stt_stream.py L940-966 を移植: + - 直近10ターンを取得 + - 各ターンの先頭150文字を要約 + - 最後のAI発言が質問なら強調 + """ + if not self.history: + return "" + + recent = self.history[-self.CONTEXT_SUMMARY_TURNS:] + parts = [ + f"{h['role']}: {h['text'][:self.MAX_TEXT_IN_SUMMARY]}" + for h in recent + ] + summary = "\n".join(parts) + + # 最後のAI発言が質問なら強調 + for h in reversed(self.history): + if h["role"] == "AI": + if any(q in h["text"] for q in ["?", "?"]): + summary += ( + f"\n\n【直前の質問(これに対する回答を待っています)】\n" + f"{h['text'][:200]}" + ) + break + + return summary + + def get_history_string(self) -> str: + """会話履歴を文字列で取得 (stt_stream.py L497-499)""" + return "\n".join( + f"{h['role']}: {h['text']}" for h in self.history + ) + + def get_last_user_message(self, max_len: int = 100) -> str: + """直前のユーザー発言を取得 (stt_stream.py L417-420)""" + for h in reversed(self.history): + if h["role"] == "ユーザー": + return h["text"][:max_len] + return "" + + def clear(self) -> None: + """履歴をクリア""" + self.history.clear() diff --git a/support_base/modes/__init__.py b/support_base/modes/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/support_base/modes/base_mode.py b/support_base/modes/base_mode.py new file mode 100644 index 0000000..38da2a0 --- /dev/null +++ b/support_base/modes/base_mode.py @@ -0,0 +1,58 @@ +""" +モードプラグイン基底クラス + +各モード(グルメ、サポート、インタビュー等)はこの基底クラスを継承し、 +モード固有のシステムプロンプト、ツール定義、記憶スキーマを提供する。 +""" + +from abc import ABC, abstractmethod + + +class BaseModePlugin(ABC): + """モードプラグインの基底クラス""" + + @property + @abstractmethod + def name(self) -> str: + """モード識別名 (例: "gourmet")""" + ... + + @property + @abstractmethod + def display_name(self) -> str: + """表示用モード名 (例: "グルメコンシェルジュ")""" + ... + + @property + def default_dialogue_type(self) -> str: + """デフォルトの対話方式 ("rest" | "live" | "hybrid")""" + return "live" + + @abstractmethod + def get_system_prompt(self, language: str = "ja", context: str | None = None) -> str: + """ + システムプロンプトを生成 + + Args: + language: セッション言語 + context: 再接続時のコンテキスト要約 (None=初回接続) + """ + ... + + def get_live_api_tools(self) -> list: + """Live API用のFunction Callingツール定義""" + return [] + + def get_memory_schema(self) -> dict: + """長期記憶のモード別スキーマ定義""" + return {} + + def get_initial_greeting(self, language: str = "ja", user_profile: dict | None = None) -> str: + """ + 初回挨拶メッセージを生成 + + Args: + language: セッション言語 + user_profile: 長期記憶のユーザープロファイル (パーソナライズ用) + """ + return "" diff --git a/support_base/modes/gourmet/__init__.py b/support_base/modes/gourmet/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/support_base/modes/gourmet/plugin.py b/support_base/modes/gourmet/plugin.py new file mode 100644 index 0000000..9902b65 --- /dev/null +++ b/support_base/modes/gourmet/plugin.py @@ -0,0 +1,139 @@ +""" +グルメコンシェルジュ モードプラグイン + +GCS/ローカルから読み込んだプロンプトを使用。 +Live API / REST 両方の経路で使用。 + +プロンプトソース: + - GCS: gs://{PROMPTS_BUCKET_NAME}/prompts/concierge_{lang}.txt + - ローカル: prompts/concierge_{lang}.txt + - フォールバック: ハードコードされた最小プロンプト +""" + +import logging + +from support_base.modes.base_mode import BaseModePlugin + +logger = logging.getLogger(__name__) + +# GCS/ローカルから読み込んだプロンプトを取得 +try: + from support_base.core.support_core import SYSTEM_PROMPTS as LOADED_PROMPTS + from support_base.core.support_core import INITIAL_GREETINGS as LOADED_GREETINGS + _PROMPTS_LOADED = True + logger.info("[GourmetPlugin] GCS/ローカルプロンプト読み込み成功") +except Exception as e: + logger.warning(f"[GourmetPlugin] プロンプト読み込み失敗 (フォールバック使用): {e}") + LOADED_PROMPTS = {} + LOADED_GREETINGS = {} + _PROMPTS_LOADED = False + + +class GourmetModePlugin(BaseModePlugin): + """グルメコンシェルジュモード""" + + @property + def name(self) -> str: + return "gourmet" + + @property + def display_name(self) -> str: + return "グルメコンシェルジュ" + + @property + def default_dialogue_type(self) -> str: + return "live" + + def get_system_prompt(self, language: str = "ja", context: str | None = None) -> str: + """ + GCS から読み込んだプロンプトを優先使用。 + 読み込み失敗時はハードコードのフォールバック。 + """ + prompt = "" + + # GCS/ローカルから読み込んだプロンプトを使用 + if _PROMPTS_LOADED: + # Live API では concierge プロンプトを使用 + concierge_prompts = LOADED_PROMPTS.get("concierge", {}) + prompt = concierge_prompts.get(language, concierge_prompts.get("ja", "")) + + # concierge プロンプトがなければ chat プロンプトを試す + if not prompt: + chat_prompts = LOADED_PROMPTS.get("chat", {}) + prompt = chat_prompts.get(language, chat_prompts.get("ja", "")) + + # フォールバック + if not prompt: + prompt = self._fallback_prompt(language) + + # 再接続コンテキスト追加 + if context: + prompt += f"\n\n【これまでの会話の要約】\n{context}\n" + prompt += ( + "\n【重要:必ず守ること】\n" + "1. 直前の話者の発言に対して短い相槌を入れる\n" + "2. 既に聞いた質問は絶対に繰り返さない\n" + "3. 会話の流れを自然に引き継ぐ\n" + ) + + return prompt + + def _fallback_prompt(self, language: str) -> str: + """ハードコードのフォールバック (GCS 読み込み失敗時)""" + prompts = { + "ja": ( + "あなたはグルメコンシェルジュAIです。\n" + "ユーザーの食の好み・気分・シチュエーションをヒアリングし、最適なレストランを提案します。\n\n" + "【対話スタイル】\n" + "- 親しみやすく、でも丁寧な口調で話してください\n" + "- 短く簡潔に応答してください(1-2文程度)\n" + "- ユーザーの好みを引き出す質問を積極的にしてください\n" + "- 一度に複数の質問をしないこと(1つずつ聞く)\n" + ), + "en": ( + "You are a Gourmet Concierge AI.\n" + "Help users find the perfect restaurant by understanding their preferences, mood, and occasion.\n\n" + "Keep responses short (1-2 sentences). Ask questions to understand preferences.\n" + ), + "ko": ( + "당신은 맛집 컨시어지 AI입니다.\n" + "사용자의 음식 취향과 상황을 파악하여 최적의 레스토랑을 추천합니다.\n\n" + "짧고 간결하게 응답하세요 (1-2문장).\n" + ), + "zh": ( + "你是一个美食顾问AI。\n" + "了解用户的饮食偏好、心情和场合,推荐最合适的餐厅。\n\n" + "简短回复(1-2句)。\n" + ), + } + return prompts.get(language, prompts["ja"]) + + def get_initial_greeting(self, language: str = "ja", user_profile: dict | None = None) -> str: + """ + 初回挨拶。GCS から読み込んだ INITIAL_GREETINGS を優先使用。 + """ + # GCS から読み込んだ挨拶を使用 + if _PROMPTS_LOADED and LOADED_GREETINGS: + concierge_greetings = LOADED_GREETINGS.get("concierge", {}) + greeting = concierge_greetings.get(language) + if greeting: + return greeting + + # フォールバック + greetings = { + "ja": "いらっしゃいませ!今日はどんなお食事をお探しですか?", + "en": "Welcome! What kind of dining experience are you looking for today?", + "ko": "어서오세요! 오늘은 어떤 식사를 찾고 계신가요?", + "zh": "欢迎!今天想找什么样的餐厅呢?", + } + return greetings.get(language, greetings["ja"]) + + def get_memory_schema(self) -> dict: + """グルメモード固有の長期記憶スキーマ""" + return { + "favorite_cuisines": [], + "preferred_area": "", + "budget_range": "", + "dietary_restrictions": [], + "past_searches": [], + } diff --git a/support_base/modes/registry.py b/support_base/modes/registry.py new file mode 100644 index 0000000..c9ad34b --- /dev/null +++ b/support_base/modes/registry.py @@ -0,0 +1,39 @@ +""" +モードレジストリ + +モードプラグインの登録・取得を管理する。 +新モード追加時はプラグインクラスを作成してここに登録するだけ。 +""" + +import logging + +from support_base.modes.base_mode import BaseModePlugin + +logger = logging.getLogger(__name__) + + +class ModeRegistry: + """モードプラグインのレジストリ""" + + def __init__(self): + self._modes: dict[str, BaseModePlugin] = {} + + def register(self, plugin: BaseModePlugin) -> None: + """モードプラグインを登録""" + self._modes[plugin.name] = plugin + logger.info(f"[ModeRegistry] Registered: {plugin.name} ({plugin.display_name})") + + def get(self, name: str) -> BaseModePlugin | None: + """モードプラグインを取得""" + return self._modes.get(name) + + def list_modes(self) -> list[dict]: + """登録済みモードのリスト""" + return [ + {"name": m.name, "display_name": m.display_name} + for m in self._modes.values() + ] + + def has(self, name: str) -> bool: + """指定モードが登録済みか""" + return name in self._modes diff --git a/support_base/requirements.txt b/support_base/requirements.txt new file mode 100644 index 0000000..f561a4f --- /dev/null +++ b/support_base/requirements.txt @@ -0,0 +1,29 @@ +# LAM Platform - Python依存パッケージ +# Python 3.11+ + +# Web フレームワーク +fastapi>=0.115.0 +uvicorn[standard]>=0.32.0 +websockets>=13.0 + +# HTTP クライアント +httpx>=0.27.0 +requests>=2.31.0 + +# Gemini API (Live API + REST) +google-genai>=1.0.0 +google-generativeai>=0.8.0 + +# Google Cloud (TTS/STT/Storage) +google-cloud-texttospeech>=2.14.0 +google-cloud-speech>=2.21.0 +google-cloud-storage>=2.18.0 + +# バリデーション (FastAPI 依存) +pydantic>=2.0.0 + +# 環境変数 +python-dotenv>=1.0.0 + +# 長期記憶 (Supabase) +supabase>=2.0.0 diff --git a/support_base/rest/__init__.py b/support_base/rest/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/support_base/rest/router.py b/support_base/rest/router.py new file mode 100644 index 0000000..293ce83 --- /dev/null +++ b/support_base/rest/router.py @@ -0,0 +1,545 @@ +# -*- coding: utf-8 -*- +""" +REST API ルーター (gourmet-support 互換) + +既存の gourmet-support (Flask) の REST エンドポイントを FastAPI ルーターに変換。 +dialogue_type="rest" のセッション向け。 + +エンドポイント: + POST /api/v2/rest/session/start - セッション開始 + POST /api/v2/rest/chat - チャット処理 + POST /api/v2/rest/finalize - セッション完了 + POST /api/v2/rest/cancel - 処理中止 + POST /api/v2/rest/tts/synthesize - 音声合成 (TTS) + POST /api/v2/rest/stt/transcribe - 音声認識 (STT) + POST /api/v2/rest/stt/stream - 音声認識 (Streaming STT) + GET /api/v2/rest/session/{id} - セッション取得 +""" + +import os +import time +import base64 +import logging +from datetime import datetime + +import requests as http_requests +from fastapi import APIRouter, HTTPException +from pydantic import BaseModel + +from support_base.core.support_core import ( + SYSTEM_PROMPTS, + INITIAL_GREETINGS, + SupportSession, + SupportAssistant, +) +from support_base.core.api_integrations import ( + enrich_shops_with_photos, + extract_area_from_text, + GOOGLE_PLACES_API_KEY, +) + +logger = logging.getLogger(__name__) + +# --- オプション依存: 長期記憶 --- +try: + from support_base.core.long_term_memory import LongTermMemory + LONG_TERM_MEMORY_ENABLED = True +except Exception as e: + logger.warning(f"[REST] Long term memory not available: {e}") + LONG_TERM_MEMORY_ENABLED = False + +# --- オプション依存: Google Cloud TTS/STT --- +try: + from google.cloud import texttospeech, speech + tts_client = texttospeech.TextToSpeechClient() + stt_client = speech.SpeechClient() + TTS_STT_ENABLED = True +except Exception as e: + logger.warning(f"[REST] Google Cloud TTS/STT not available: {e}") + tts_client = None + stt_client = None + TTS_STT_ENABLED = False + +# --- Audio2Expression --- +AUDIO2EXP_SERVICE_URL = os.getenv("AUDIO2EXP_SERVICE_URL", "") + + +# === Pydantic モデル === + +class RestSessionStartRequest(BaseModel): + user_info: dict = {} + language: str = "ja" + mode: str = "chat" # "chat" or "concierge" + + +class RestSessionStartResponse(BaseModel): + session_id: str + initial_message: str + user_profile: dict | None = None + + +class ChatRequest(BaseModel): + session_id: str + message: str + stage: str = "conversation" + language: str = "ja" + mode: str = "chat" + + +class FinalizeRequest(BaseModel): + session_id: str + + +class CancelRequest(BaseModel): + session_id: str + + +class TTSRequest(BaseModel): + text: str + language_code: str = "ja-JP" + voice_name: str = "ja-JP-Chirp3-HD-Leda" + speaking_rate: float = 1.0 + pitch: float = 0.0 + session_id: str = "" + + +class STTRequest(BaseModel): + audio: str # base64 + language_code: str = "ja-JP" + + +# === ルーター === + +router = APIRouter(prefix="/api/v2/rest", tags=["REST API"]) + + +# === ヘルパー === + +def _get_expression_frames(audio_base64: str, session_id: str, audio_format: str = "mp3"): + """Audio2Expression サービスから表情フレームを取得""" + if not AUDIO2EXP_SERVICE_URL or not session_id: + return None + try: + resp = http_requests.post( + f"{AUDIO2EXP_SERVICE_URL}/api/audio2expression", + json={ + "audio_base64": audio_base64, + "session_id": session_id, + "is_start": True, + "is_final": True, + "audio_format": audio_format, + }, + timeout=10, + ) + if resp.status_code == 200: + result = resp.json() + logger.info(f"[Audio2Exp] OK: {len(result.get('frames', []))} frames") + return result + logger.warning(f"[Audio2Exp] Failed: status={resp.status_code}") + return None + except Exception as e: + logger.warning(f"[Audio2Exp] Error: {e}") + return None + + +# === エンドポイント === + +@router.post("/session/start", response_model=RestSessionStartResponse) +async def rest_start_session(req: RestSessionStartRequest): + """ + REST セッション開始 + + gourmet-support の /api/session/start と互換。 + SupportSession + SupportAssistant を使ってセッションを初期化する。 + """ + try: + # 1. セッション初期化 + session = SupportSession() + session.initialize(req.user_info, language=req.language, mode=req.mode) + + # 2. アシスタント作成 + assistant = SupportAssistant(session, SYSTEM_PROMPTS) + + # 3. 初回メッセージ生成 + initial_message = assistant.get_initial_message() + + # 4. 履歴に追加 + session.add_message("model", initial_message, "chat") + + logger.info( + f"[REST] Session started: {session.session_id}, " + f"lang={req.language}, mode={req.mode}" + ) + + # コンシェルジュモードのみ: プロファイル情報を返す + user_profile = None + if req.mode == "concierge": + session_data = session.get_data() + profile = session_data.get("long_term_profile") if session_data else None + if profile: + user_profile = { + "preferred_name": profile.get("preferred_name"), + "name_honorific": profile.get("name_honorific"), + } + + return RestSessionStartResponse( + session_id=session.session_id, + initial_message=initial_message, + user_profile=user_profile, + ) + + except Exception as e: + logger.error(f"[REST] Session start error: {e}", exc_info=True) + raise HTTPException(status_code=500, detail=str(e)) + + +@router.post("/chat") +async def rest_chat(req: ChatRequest): + """ + チャット処理 + + gourmet-support の /api/chat と互換。 + 5ステップフロー: 状態更新 → 記録 → アシスタント生成 → Gemini → 記録 + """ + try: + session = SupportSession(req.session_id) + session_data = session.get_data() + + if not session_data: + raise HTTPException(status_code=404, detail="Session not found") + + # 1. 状態確定 + session.update_language(req.language) + session.update_mode(req.mode) + + # 2. ユーザー入力を記録 + session.add_message("user", req.message, "chat") + + # 3. アシスタント作成 + assistant = SupportAssistant(session, SYSTEM_PROMPTS) + + # 4. 推論開始 + result = assistant.process_user_message(req.message, req.stage) + + # 5. アシスタント応答を記録 + session.add_message("model", result["response"], "chat") + if result["summary"]: + session.add_message("model", result["summary"], "summary") + + # ショップデータ処理 + shops = result.get("shops") or [] + response_text = result["response"] + is_followup = result.get("is_followup", False) + + # 多言語メッセージ + shop_messages = { + "ja": { + "intro": lambda c: f"ご希望に合うお店を{c}件ご紹介します。\n\n", + "not_found": "申し訳ございません。条件に合うお店が見つかりませんでした。別の条件でお探しいただけますか?", + }, + "en": { + "intro": lambda c: f"Here are {c} restaurant recommendations for you.\n\n", + "not_found": "Sorry, we couldn't find any restaurants matching your criteria. Would you like to search with different conditions?", + }, + "zh": { + "intro": lambda c: f"为您推荐{c}家餐厅。\n\n", + "not_found": "很抱歉,没有找到符合条件的餐厅。要用其他条件搜索吗?", + }, + "ko": { + "intro": lambda c: f"고객님께 {c}개의 식당을 추천합니다.\n\n", + "not_found": "죄송합니다. 조건에 맞는 식당을 찾을 수 없었습니다. 다른 조건으로 찾으시겠습니까?", + }, + } + current_messages = shop_messages.get(req.language, shop_messages["ja"]) + + if shops and not is_followup: + original_count = len(shops) + area = extract_area_from_text(req.message, req.language) + + # Places API でエンリッチ + shops = enrich_shops_with_photos(shops, area, req.language) or [] + + if shops: + shop_list = [] + for i, shop in enumerate(shops, 1): + name = shop.get("name", "") + shop_area = shop.get("area", "") + description = shop.get("description", "") + if shop_area: + shop_list.append(f"{i}. **{name}**({shop_area}): {description}") + else: + shop_list.append(f"{i}. **{name}**: {description}") + response_text = current_messages["intro"](len(shops)) + "\n\n".join(shop_list) + else: + response_text = current_messages["not_found"] + + # 長期記憶: action 処理 + if LONG_TERM_MEMORY_ENABLED: + try: + user_id = session_data.get("user_id") + + # LLM action 処理 + action = result.get("action") + if action and action.get("type") == "update_user_profile": + updates = action.get("updates", {}) + if updates and user_id: + ltm = LongTermMemory() + ltm.update_profile(user_id, updates) + logger.info(f"[LTM] Profile updated via action: {updates}") + + # ショップ提案サマリー保存 (concierge のみ) + if shops and not is_followup and user_id and req.mode == "concierge": + try: + shop_names = [s.get("name", "") for s in shops] + timestamp = datetime.now().strftime("%Y-%m-%d %H:%M") + shop_summary = ( + f"[{timestamp}] 検索条件: {req.message[:100]}\n" + f"提案店舗: {', '.join(shop_names)}" + ) + ltm = LongTermMemory() + ltm.append_conversation_summary(user_id, shop_summary) + except Exception as e: + logger.error(f"[LTM] Shop summary save error: {e}") + + except Exception as e: + logger.error(f"[LTM] Processing error: {e}") + + return { + "response": response_text, + "summary": result["summary"], + "shops": shops, + "should_confirm": result["should_confirm"], + "is_followup": is_followup, + } + + except HTTPException: + raise + except Exception as e: + logger.error(f"[REST] Chat error: {e}", exc_info=True) + raise HTTPException(status_code=500, detail=str(e)) + + +@router.post("/finalize") +async def rest_finalize(req: FinalizeRequest): + """セッション完了""" + try: + session = SupportSession(req.session_id) + session_data = session.get_data() + + if not session_data: + raise HTTPException(status_code=404, detail="Session not found") + + assistant = SupportAssistant(session, SYSTEM_PROMPTS) + final_summary = assistant.generate_final_summary() + + # 長期記憶: セッション終了サマリー追記 (concierge のみ) + if LONG_TERM_MEMORY_ENABLED and session_data.get("mode") == "concierge": + user_id = session_data.get("user_id") + if user_id and final_summary: + try: + ltm = LongTermMemory() + ltm.append_conversation_summary(user_id, final_summary) + logger.info(f"[LTM] Final summary appended: user_id={user_id}") + except Exception as e: + logger.error(f"[LTM] Summary save error: {e}") + + return {"summary": final_summary, "session_id": req.session_id} + + except HTTPException: + raise + except Exception as e: + logger.error(f"[REST] Finalize error: {e}", exc_info=True) + raise HTTPException(status_code=500, detail=str(e)) + + +@router.post("/cancel") +async def rest_cancel(req: CancelRequest): + """処理中止""" + try: + session = SupportSession(req.session_id) + session_data = session.get_data() + if session_data: + session.update_status("cancelled") + + return {"success": True, "message": "処理を中止しました"} + + except Exception as e: + logger.error(f"[REST] Cancel error: {e}", exc_info=True) + raise HTTPException(status_code=500, detail=str(e)) + + +@router.post("/tts/synthesize") +async def rest_tts_synthesize(req: TTSRequest): + """ + 音声合成 (Google Cloud TTS) + Audio2Expression + + gourmet-support の /api/tts/synthesize と互換。 + """ + if not TTS_STT_ENABLED: + raise HTTPException(status_code=503, detail="TTS service not available") + + try: + text = req.text + if not text: + raise HTTPException(status_code=400, detail="text is required") + + MAX_CHARS = 1000 + if len(text) > MAX_CHARS: + text = text[:MAX_CHARS] + "..." + + synthesis_input = texttospeech.SynthesisInput(text=text) + + try: + voice = texttospeech.VoiceSelectionParams( + language_code=req.language_code, name=req.voice_name + ) + except Exception: + voice = texttospeech.VoiceSelectionParams( + language_code=req.language_code, name="ja-JP-Neural2-B" + ) + + audio_config = texttospeech.AudioConfig( + audio_encoding=texttospeech.AudioEncoding.MP3, + speaking_rate=req.speaking_rate, + pitch=req.pitch, + ) + + response = tts_client.synthesize_speech( + input=synthesis_input, voice=voice, audio_config=audio_config + ) + + audio_base64 = base64.b64encode(response.audio_content).decode("utf-8") + + # Audio2Expression (同期) + expression_data = None + if AUDIO2EXP_SERVICE_URL and req.session_id: + try: + expression_data = _get_expression_frames( + audio_base64, req.session_id, "mp3" + ) + except Exception as e: + logger.warning(f"[Audio2Exp] Error: {e}") + + result = {"success": True, "audio": audio_base64} + if expression_data: + result["expression"] = expression_data + + return result + + except HTTPException: + raise + except Exception as e: + logger.error(f"[REST] TTS error: {e}", exc_info=True) + raise HTTPException(status_code=500, detail=str(e)) + + +@router.post("/stt/transcribe") +async def rest_stt_transcribe(req: STTRequest): + """音声認識 (Google Cloud STT)""" + if not TTS_STT_ENABLED: + raise HTTPException(status_code=503, detail="STT service not available") + + try: + if not req.audio: + raise HTTPException(status_code=400, detail="audio is required") + + audio_content = base64.b64decode(req.audio) + audio = speech.RecognitionAudio(content=audio_content) + + config = speech.RecognitionConfig( + encoding=speech.RecognitionConfig.AudioEncoding.WEBM_OPUS, + sample_rate_hertz=48000, + language_code=req.language_code, + enable_automatic_punctuation=True, + model="default", + ) + + response = stt_client.recognize(config=config, audio=audio) + + transcript = "" + if response.results: + transcript = response.results[0].alternatives[0].transcript + + return {"success": True, "transcript": transcript} + + except HTTPException: + raise + except Exception as e: + logger.error(f"[REST] STT error: {e}", exc_info=True) + raise HTTPException(status_code=500, detail=str(e)) + + +@router.post("/stt/stream") +async def rest_stt_stream(req: STTRequest): + """音声認識 (Streaming STT)""" + if not TTS_STT_ENABLED: + raise HTTPException(status_code=503, detail="STT service not available") + + try: + if not req.audio: + raise HTTPException(status_code=400, detail="audio is required") + + audio_content = base64.b64decode(req.audio) + + recognition_config = speech.RecognitionConfig( + encoding=speech.RecognitionConfig.AudioEncoding.WEBM_OPUS, + sample_rate_hertz=48000, + language_code=req.language_code, + enable_automatic_punctuation=True, + model="default", + ) + + streaming_config = speech.StreamingRecognitionConfig( + config=recognition_config, + interim_results=False, + single_utterance=True, + ) + + CHUNK_SIZE = 1024 * 16 + + def audio_generator(): + for i in range(0, len(audio_content), CHUNK_SIZE): + chunk = audio_content[i : i + CHUNK_SIZE] + yield speech.StreamingRecognizeRequest(audio_content=chunk) + + responses = stt_client.streaming_recognize(streaming_config, audio_generator()) + + transcript = "" + confidence = 0.0 + for response in responses: + if not response.results: + continue + for result in response.results: + if result.is_final and result.alternatives: + transcript = result.alternatives[0].transcript + confidence = result.alternatives[0].confidence + break + if transcript: + break + + return {"success": True, "transcript": transcript, "confidence": confidence} + + except HTTPException: + raise + except Exception as e: + logger.error(f"[REST] Streaming STT error: {e}", exc_info=True) + raise HTTPException(status_code=500, detail=str(e)) + + +@router.get("/session/{session_id}") +async def rest_get_session(session_id: str): + """セッション情報取得""" + try: + session = SupportSession(session_id) + data = session.get_data() + + if not data: + raise HTTPException(status_code=404, detail="Session not found") + + return data + + except HTTPException: + raise + except Exception as e: + logger.error(f"[REST] Session get error: {e}", exc_info=True) + raise HTTPException(status_code=500, detail=str(e)) diff --git a/support_base/server.py b/support_base/server.py new file mode 100644 index 0000000..cc6ff61 --- /dev/null +++ b/support_base/server.py @@ -0,0 +1,258 @@ +""" +プラットフォーム FastAPI サーバー + +エントリーポイント。WebSocket (Live API中継) と REST エンドポイントを提供する。 + +エンドポイント: + POST /api/v2/session/start - セッション開始 (Live API 用) + POST /api/v2/session/end - セッション終了 + WS /api/v2/live/{session_id} - Live API WebSocket 中継 + GET /api/v2/modes - 利用可能モード一覧 + GET /api/v2/health - ヘルスチェック + + --- REST API (gourmet-support 互換) --- + POST /api/v2/rest/session/start - REST セッション開始 + POST /api/v2/rest/chat - チャット処理 + POST /api/v2/rest/finalize - セッション完了 + POST /api/v2/rest/cancel - 処理中止 + POST /api/v2/rest/tts/synthesize - 音声合成 + POST /api/v2/rest/stt/transcribe - 音声認識 + POST /api/v2/rest/stt/stream - 音声認識 (Streaming) + GET /api/v2/rest/session/{id} - セッション取得 +""" + +import logging + +from contextlib import asynccontextmanager + +from fastapi import FastAPI, WebSocket, HTTPException +from fastapi.middleware.cors import CORSMiddleware +from pydantic import BaseModel + +from support_base.config.settings import HOST, PORT, CORS_ORIGINS, A2E_SERVICE_URL +from support_base.modes.registry import ModeRegistry +from support_base.modes.gourmet.plugin import GourmetModePlugin +from support_base.services.a2e_client import A2EClient +from support_base.session.manager import SessionManager +from support_base.live.relay import LiveRelay +from support_base.rest.router import router as rest_router + +logger = logging.getLogger(__name__) + +# --- グローバルインスタンス --- +mode_registry = ModeRegistry() +session_manager = SessionManager() +a2e_client: A2EClient | None = None + + +@asynccontextmanager +async def lifespan(app: FastAPI): + """アプリケーション起動・終了処理""" + global a2e_client + + # --- 起動 --- + logging.basicConfig( + level=logging.INFO, + format="%(asctime)s [%(name)s] %(levelname)s: %(message)s", + ) + + # モードプラグイン登録 + mode_registry.register(GourmetModePlugin()) + logger.info(f"[Server] Modes registered: {mode_registry.list_modes()}") + + # A2E クライアント初期化 + if A2E_SERVICE_URL and not A2E_SERVICE_URL.endswith("XXXXX.run.app"): + a2e_client = A2EClient() + health = await a2e_client.health_check() + if health: + logger.info(f"[Server] A2E service healthy: {health}") + else: + logger.warning("[Server] A2E service unreachable (avatar expressions disabled)") + else: + logger.info("[Server] A2E service not configured (avatar expressions disabled)") + + logger.info(f"[Server] Platform ready on {HOST}:{PORT}") + + yield + + # --- 終了 --- + if a2e_client: + await a2e_client.close() + logger.info("[Server] Shutdown complete") + + +app = FastAPI( + title="LAM Platform API", + version="0.1.0", + lifespan=lifespan, +) + +app.add_middleware( + CORSMiddleware, + allow_origins=CORS_ORIGINS, + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) + +# REST API ルーター (gourmet-support 互換) +app.include_router(rest_router) + + +# === リクエスト/レスポンスモデル === + +class SessionStartRequest(BaseModel): + mode: str = "gourmet" + language: str = "ja" + dialogue_type: str = "live" + user_id: str | None = None + + +class SessionStartResponse(BaseModel): + session_id: str + mode: str + language: str + dialogue_type: str + greeting: str + ws_url: str + + +class SessionEndResponse(BaseModel): + session_id: str + ended: bool + + +class HealthResponse(BaseModel): + status: str + modes: list[dict] + a2e_available: bool + active_sessions: int + + +# === REST エンドポイント === + +@app.post("/api/v2/session/start", response_model=SessionStartResponse) +async def start_session(req: SessionStartRequest): + """ + セッション開始 + + モードプラグインを検証し、新しいセッションを作成。 + 初回挨拶メッセージとWebSocket URLを返す。 + """ + # モード検証 + plugin = mode_registry.get(req.mode) + if not plugin: + available = [m["name"] for m in mode_registry.list_modes()] + raise HTTPException( + status_code=400, + detail=f"Unknown mode: '{req.mode}'. Available: {available}", + ) + + # dialogue_type はモードのデフォルトを尊重 + dialogue_type = req.dialogue_type or plugin.default_dialogue_type + + # セッション作成 + session = session_manager.create_session( + mode=req.mode, + language=req.language, + dialogue_type=dialogue_type, + user_id=req.user_id, + ) + + # 初回挨拶 + greeting = plugin.get_initial_greeting(language=req.language) + + return SessionStartResponse( + session_id=session.session_id, + mode=req.mode, + language=req.language, + dialogue_type=dialogue_type, + greeting=greeting, + ws_url=f"/api/v2/live/{session.session_id}", + ) + + +@app.post("/api/v2/session/end", response_model=SessionEndResponse) +async def end_session(session_id: str): + """セッション終了""" + ended = session_manager.end_session(session_id) + if not ended: + raise HTTPException(status_code=404, detail=f"Session not found: {session_id}") + return SessionEndResponse(session_id=session_id, ended=True) + + +@app.get("/api/v2/modes") +async def list_modes(): + """利用可能モード一覧""" + return {"modes": mode_registry.list_modes()} + + +@app.get("/api/v2/health", response_model=HealthResponse) +async def health_check(): + """ヘルスチェック""" + a2e_available = False + if a2e_client: + result = await a2e_client.health_check() + a2e_available = result is not None + + return HealthResponse( + status="healthy", + modes=mode_registry.list_modes(), + a2e_available=a2e_available, + active_sessions=len(session_manager.list_sessions()), + ) + + +# === WebSocket エンドポイント === + +@app.websocket("/api/v2/live/{session_id}") +async def live_websocket(websocket: WebSocket, session_id: str): + """ + Live API WebSocket 中継 + + ブラウザ ↔ サーバー ↔ Gemini Live API の3者間を中継する。 + セッション開始後、クライアントはこのエンドポイントに WebSocket 接続する。 + """ + # セッション検証 + session = session_manager.get_session(session_id) + if not session: + await websocket.close(code=4004, reason=f"Session not found: {session_id}") + return + + # モードプラグイン取得 + plugin = mode_registry.get(session.mode) + if not plugin: + await websocket.close(code=4005, reason=f"Mode not found: {session.mode}") + return + + # LiveRelay 生成・実行 + relay = LiveRelay( + session=session, + mode_plugin=plugin, + a2e_client=a2e_client, + ) + + logger.info( + f"[Server] WebSocket /api/v2/live/{session_id} " + f"mode={session.mode} lang={session.language}" + ) + + await relay.handle_client_ws(websocket) + + +# === エントリーポイント === + +def main(): + """uvicorn で起動""" + import uvicorn + + uvicorn.run( + "support_base.server:app", + host=HOST, + port=PORT, + log_level="info", + ) + + +if __name__ == "__main__": + main() diff --git a/support_base/services/__init__.py b/support_base/services/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/support_base/services/a2e_client.py b/support_base/services/a2e_client.py new file mode 100644 index 0000000..504849a --- /dev/null +++ b/support_base/services/a2e_client.py @@ -0,0 +1,101 @@ +""" +audio2exp-service クライアント + +audio2exp-service (Cloud Run) への HTTP リクエスト。 +音声データを送信し、52次元ARKitブレンドシェイプ係数を取得する。 +REST経路とLive API経路の両方で使用。 +""" + +import logging +from dataclasses import dataclass + +import httpx + +from support_base.config.settings import A2E_SERVICE_URL, A2E_TIMEOUT_SECONDS + +logger = logging.getLogger(__name__) + + +@dataclass +class A2EResult: + """A2E推論結果""" + names: list[str] # 52個のARKit名 + frames: list[list[float]] # N×52 + frame_rate: int # 通常30 + + +class A2EClient: + """ + audio2exp-service クライアント + + 確認済みAPI仕様 (a2e_engine.py L381-401): + POST /api/audio2expression + Request: { audio_base64, session_id, audio_format } + Response: { names: [52], frames: [N][52], frame_rate: 30 } + """ + + def __init__(self, base_url: str | None = None): + self.base_url = (base_url or A2E_SERVICE_URL).rstrip("/") + self._client = httpx.AsyncClient(timeout=A2E_TIMEOUT_SECONDS) + + async def process_audio( + self, + audio_base64: str, + session_id: str = "unknown", + audio_format: str = "mp3", + ) -> A2EResult | None: + """ + 音声 → 52次元ARKitブレンドシェイプ + + Args: + audio_base64: base64エンコードされた音声データ + session_id: セッションID(ログ用) + audio_format: 音声フォーマット (mp3, wav, pcm) + + Returns: + A2EResult or None (エラー時) + """ + url = f"{self.base_url}/api/audio2expression" + payload = { + "audio_base64": audio_base64, + "session_id": session_id, + "audio_format": audio_format, + } + + try: + response = await self._client.post(url, json=payload) + response.raise_for_status() + data = response.json() + + result = A2EResult( + names=data["names"], + frames=data["frames"], + frame_rate=data.get("frame_rate", 30), + ) + + frame_count = len(result.frames) + logger.info( + f"[A2E] OK: {frame_count} frames, " + f"session={session_id}" + ) + return result + + except httpx.TimeoutException: + logger.warning(f"[A2E] Timeout: session={session_id}") + return None + except Exception as e: + logger.error(f"[A2E] Error: {e}, session={session_id}") + return None + + async def health_check(self) -> dict | None: + """ヘルスチェック (GET /health)""" + try: + response = await self._client.get(f"{self.base_url}/health") + return response.json() + except Exception as e: + logger.error(f"[A2E] Health check failed: {e}") + return None + + async def close(self): + """HTTPクライアントのクローズ""" + await self._client.aclose() diff --git a/support_base/session/__init__.py b/support_base/session/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/support_base/session/manager.py b/support_base/session/manager.py new file mode 100644 index 0000000..757b278 --- /dev/null +++ b/support_base/session/manager.py @@ -0,0 +1,74 @@ +""" +セッション管理 + +セッションのライフサイクル管理。モード割当、短期記憶保持、接続状態追跡。 +""" + +import uuid +import logging +from datetime import datetime +from dataclasses import dataclass, field + +from support_base.memory.session_memory import SessionMemory + +logger = logging.getLogger(__name__) + + +@dataclass +class Session: + """1つの対話セッションを表現""" + + session_id: str + mode: str # "gourmet", "support", "interview" + language: str # "ja", "en", "ko", "zh" + dialogue_type: str = "live" # "rest", "live", "hybrid" + user_id: str | None = None + created_at: datetime = field(default_factory=datetime.now) + memory: SessionMemory = field(default_factory=SessionMemory) + live_session_count: int = 0 # Live API の再接続回数 + + +class SessionManager: + """セッション管理""" + + def __init__(self): + self._sessions: dict[str, Session] = {} + + def create_session( + self, + mode: str, + language: str = "ja", + dialogue_type: str = "live", + user_id: str | None = None, + ) -> Session: + """新しいセッションを作成""" + session_id = f"sess_{uuid.uuid4().hex[:12]}" + session = Session( + session_id=session_id, + mode=mode, + language=language, + dialogue_type=dialogue_type, + user_id=user_id, + ) + self._sessions[session_id] = session + logger.info( + f"[Session] Created: {session_id}, mode={mode}, " + f"lang={language}, type={dialogue_type}" + ) + return session + + def get_session(self, session_id: str) -> Session | None: + """セッションを取得""" + return self._sessions.get(session_id) + + def end_session(self, session_id: str) -> bool: + """セッションを終了""" + session = self._sessions.pop(session_id, None) + if session: + logger.info(f"[Session] Ended: {session_id}") + return True + return False + + def list_sessions(self) -> list[str]: + """アクティブなセッションIDのリスト""" + return list(self._sessions.keys()) diff --git a/tests/a2e_japanese/.gitignore b/tests/a2e_japanese/.gitignore new file mode 100644 index 0000000..13e88d3 --- /dev/null +++ b/tests/a2e_japanese/.gitignore @@ -0,0 +1,10 @@ +# Generated audio samples +audio_samples/ + +# A2E inference outputs +blendshape_outputs/ + +# Test reports +test_report.json +analysis_results.csv +analysis_results.json diff --git a/tests/a2e_japanese/TEST_PROCEDURE.md b/tests/a2e_japanese/TEST_PROCEDURE.md new file mode 100644 index 0000000..5383000 --- /dev/null +++ b/tests/a2e_japanese/TEST_PROCEDURE.md @@ -0,0 +1,183 @@ +# A2E + 日本語音声テスト手順 + +## 目的 + +A2E (Audio2Expression) が日本語音声で十分なリップシンクを生成するか検証する。 +もし生成できるなら、公式HF SpacesのZIP(英語/中国語参照)をそのまま使え、 +ZIPのmotion差し替えやVHAP、Modal問題を全てスキップできる。 + +## 前提条件 + +| 項目 | 状態 | +|------|------| +| OpenAvatarChat | `C:\Users\hamad\OpenAvatarChat` にインストール済み | +| conda環境 | `oac` (Python 3.11) | +| Gemini API | 設定済み | +| EdgeTTS | `ja-JP-NanamiNeural` | +| LAM_audio2exp モデル | ダウンロード済み | +| wav2vec2-base-960h | ダウンロード済み | +| SenseVoiceSmall | ダウンロード済み | +| GPU | なし(CPU mode) | +| 公式HF Spaces ZIP | `lam_samples/concierge.zip` | + +## テスト手順 + +### Step 0: 環境チェック + +```powershell +cd C:\Users\hamad\OpenAvatarChat +conda activate oac +python tests/a2e_japanese/setup_oac_env.py +``` + +問題がある場合は指示に従って修正。 + +### Step 1: テスト音声生成 + +```powershell +python tests/a2e_japanese/generate_test_audio.py +``` + +以下のWAVファイルが `tests/a2e_japanese/audio_samples/` に生成される: + +| ファイル | 内容 | 目的 | +|----------|------|------| +| `vowels_aiueo.wav` | あ、い、う、え、お | 母音のリップシェイプ | +| `greeting_konnichiwa.wav` | こんにちは、お元気ですか? | 自然な会話 | +| `long_sentence.wav` | AIコンシェルジュの定型文 | 長文テスト | +| `mixed_phonemes.wav` | さしすせそ、たちつてと... | 子音+母音 | +| `numbers_and_names.wav` | 東京タワー、富士山 | 固有名詞 | +| `english_compare.wav` | Hello, how are you? | 英語比較 | +| `chinese_compare.wav` | 你好,我是AI助手 | 中国語比較 | +| `silence_baseline.wav` | 無音 2秒 | ベースライン | +| `tone_440hz.wav` | 440Hz正弦波 1秒 | 非音声参照 | + +### Step 2: A2Eテスト実行 + +```powershell +python tests/a2e_japanese/test_a2e_cpu.py +``` + +テスト内容: +1. **モデルロード確認** - 全モデルファイルの存在チェック +2. **Wav2Vec2特徴量抽出** - 日本語音声からの特徴量生成 +3. **A2E推論** - 52次元ARKitブレンドシェイプ出力 +4. **ブレンドシェイプ分析** - リップ関連の活性度 +5. **ZIP構造検証** - 公式ZIPの整合性 + +### Step 3: ブレンドシェイプ出力保存 + +```powershell +python tests/a2e_japanese/save_a2e_output.py +``` + +### Step 4: 出力分析 + +```powershell +python tests/a2e_japanese/analyze_blendshapes.py --input-dir tests/a2e_japanese/blendshape_outputs/ +``` + +### Step 4.5: パッチ適用(初回のみ) + +OpenAvatarChatのハンドラーにバグ修正・日本語対応パッチを適用する。 + +```powershell +# ASR: 日本語言語強制(中国語誤検出の修正) +python tests/a2e_japanese/patch_asr_language.py + +# VAD/ASR: numpy dtype修正 +python tests/a2e_japanese/patch_vad_handler.py + +# LLM: Gemini dict content修正 +python tests/a2e_japanese/patch_llm_handler.py +``` + +パッチが自動適用できない場合は `--help` で手動修正ガイドを表示: +```powershell +python tests/a2e_japanese/patch_asr_language.py --help +``` + +### Step 5: OpenAvatarChatでの統合テスト + +```powershell +# configをコピー +copy tests\a2e_japanese\chat_with_lam_jp.yaml config\chat_with_lam_jp.yaml + +# Gemini APIキーを設定(既に設定済みの場合はスキップ) +# config/chat_with_lam_jp.yaml の api_key を編集 + +# 起動(※ chat_with_lam.yaml ではなく _jp.yaml を指定) +python src/demo.py --config config/chat_with_lam_jp.yaml +``` + +ブラウザで `https://localhost:8282` を開き、以下をテスト: + +| テスト | 操作 | 観察ポイント | +|--------|------|-------------| +| テストA | 英語参照ZIP + 日本語で話す | 口の動きが日本語の母音に合うか | +| テストB | 中国語参照ZIP + 日本語で話す | テストAと差があるか | +| テストC | 同じZIPで英語で話す | 日本語との差があるか | + +## 全テスト一括実行 + +```powershell +python tests/a2e_japanese/run_all_tests.py +``` + +## 判定基準 + +### A2Eが日本語で十分な場合(Step 2へ進む必要なし) +- jawOpen が発話時に適切に変動 +- mouthFunnel/mouthPucker が「う」「お」で活性化 +- mouthSmile系が「い」「え」で活性化 +- 無音時にリップが閉じる +- 英語テストとの品質差が小さい + +### A2Eが日本語で不十分な場合(Step 2: ZIP解析 + VHAPへ) +- リップが発話に追従しない +- 母音の区別ができない +- 英語と比べて明らかに品質が低い + +## ファイル構成 + +``` +tests/a2e_japanese/ +├── __init__.py +├── TEST_PROCEDURE.md # この文書 +├── chat_with_lam_jp.yaml # OpenAvatarChat設定ファイル +├── generate_test_audio.py # テスト音声生成 +├── test_a2e_cpu.py # A2Eテストスイート +├── save_a2e_output.py # A2E推論出力保存 +├── analyze_blendshapes.py # ブレンドシェイプ分析 +├── setup_oac_env.py # 環境チェック・修正 +├── run_all_tests.py # 全テスト一括実行 +├── audio_samples/ # 生成されたテスト音声 (gitignore) +│ ├── vowels_aiueo.wav +│ ├── greeting_konnichiwa.wav +│ └── ... +└── blendshape_outputs/ # A2E出力 (gitignore) + ├── vowels_aiueo.npy + └── ... +``` + +## A2Eアーキテクチャ(参考) + +``` +音声入力 (WAV, 24kHz) + ↓ +[Wav2Vec2] (facebook/wav2vec2-base-960h) + ↓ 音響特徴量 (T, 768) + ↓ ※言語パラメータなし、音響レベルで動作 + ↓ +[A2Eデコーダー] (LAM_audio2exp) + ↓ 52次元 ARKit ブレンドシェイプ (T', 52) + ↓ +[OpenAvatarChat WebGL Renderer] + ↓ skin.glb の頂点を変形 + ↓ vertex_order.json でマッピング + ↓ +アバター表示 +``` + +重要: Wav2Vec2は音響レベルで動作し、言語パラメータはゼロ。 +理論上、どの言語の音声でもブレンドシェイプを生成可能。 diff --git a/tests/a2e_japanese/__init__.py b/tests/a2e_japanese/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/a2e_japanese/analyze_blendshapes.py b/tests/a2e_japanese/analyze_blendshapes.py new file mode 100644 index 0000000..e9b20d7 --- /dev/null +++ b/tests/a2e_japanese/analyze_blendshapes.py @@ -0,0 +1,347 @@ +""" +A2Eブレンドシェイプ出力分析ツール + +A2E推論結果(52次元ARKitブレンドシェイプ)を分析し、 +日本語音声に対するリップシンク品質を評価する。 + +使い方: + # A2E推論後に出力されたnpyファイルを分析 + python analyze_blendshapes.py --input blendshape_outputs/vowels_aiueo.npy + + # 複数ファイルを比較 + python analyze_blendshapes.py --input-dir blendshape_outputs/ + + # CSVエクスポート + python analyze_blendshapes.py --input-dir blendshape_outputs/ --export-csv +""" + +import argparse +import json +import os +import sys +from pathlib import Path + +import numpy as np + +# ARKit 52 ブレンドシェイプ名 +ARKIT_NAMES = [ + "eyeBlinkLeft", "eyeLookDownLeft", "eyeLookInLeft", "eyeLookOutLeft", + "eyeLookUpLeft", "eyeSquintLeft", "eyeWideLeft", + "eyeBlinkRight", "eyeLookDownRight", "eyeLookInRight", "eyeLookOutRight", + "eyeLookUpRight", "eyeSquintRight", "eyeWideRight", + "jawForward", "jawLeft", "jawRight", "jawOpen", + "mouthClose", "mouthFunnel", "mouthPucker", "mouthLeft", "mouthRight", + "mouthSmileLeft", "mouthSmileRight", "mouthFrownLeft", "mouthFrownRight", + "mouthDimpleLeft", "mouthDimpleRight", "mouthStretchLeft", "mouthStretchRight", + "mouthRollLower", "mouthRollUpper", "mouthShrugLower", "mouthShrugUpper", + "mouthPressLeft", "mouthPressRight", "mouthLowerDownLeft", "mouthLowerDownRight", + "mouthUpperUpLeft", "mouthUpperUpRight", + "browDownLeft", "browDownRight", "browInnerUp", "browOuterUpLeft", "browOuterUpRight", + "cheekPuff", "cheekSquintLeft", "cheekSquintRight", + "noseSneerLeft", "noseSneerRight", + "tongueOut", +] + +# カテゴリ分け +CATEGORIES = { + "jaw": [i for i, n in enumerate(ARKIT_NAMES) if n.startswith("jaw")], + "mouth": [i for i, n in enumerate(ARKIT_NAMES) if n.startswith("mouth")], + "eye": [i for i, n in enumerate(ARKIT_NAMES) if n.startswith("eye")], + "brow": [i for i, n in enumerate(ARKIT_NAMES) if n.startswith("brow")], + "cheek": [i for i, n in enumerate(ARKIT_NAMES) if n.startswith("cheek")], + "nose": [i for i, n in enumerate(ARKIT_NAMES) if n.startswith("nose")], + "tongue": [i for i, n in enumerate(ARKIT_NAMES) if n.startswith("tongue")], +} + +# リップシンクに重要なブレンドシェイプ +LIP_SYNC_CRITICAL = { + "jawOpen": ARKIT_NAMES.index("jawOpen"), + "mouthClose": ARKIT_NAMES.index("mouthClose"), + "mouthFunnel": ARKIT_NAMES.index("mouthFunnel"), + "mouthPucker": ARKIT_NAMES.index("mouthPucker"), + "mouthSmileLeft": ARKIT_NAMES.index("mouthSmileLeft"), + "mouthSmileRight": ARKIT_NAMES.index("mouthSmileRight"), + "mouthLowerDownLeft": ARKIT_NAMES.index("mouthLowerDownLeft"), + "mouthLowerDownRight": ARKIT_NAMES.index("mouthLowerDownRight"), + "mouthUpperUpLeft": ARKIT_NAMES.index("mouthUpperUpLeft"), + "mouthUpperUpRight": ARKIT_NAMES.index("mouthUpperUpRight"), +} + + +def analyze_single(data: np.ndarray, name: str, fps: float = 30.0) -> dict: + """単一ブレンドシェイプ出力の分析""" + if data.ndim != 2 or data.shape[1] != 52: + raise ValueError(f"Expected shape (N, 52), got {data.shape}") + + num_frames = data.shape[0] + duration = num_frames / fps + + result = { + "name": name, + "num_frames": num_frames, + "duration_s": round(duration, 2), + "fps": fps, + } + + # 全体統計 + result["global"] = { + "mean": round(float(data.mean()), 6), + "std": round(float(data.std()), 6), + "min": round(float(data.min()), 6), + "max": round(float(data.max()), 6), + "abs_mean": round(float(np.abs(data).mean()), 6), + } + + # カテゴリ別統計 + result["categories"] = {} + for cat_name, indices in CATEGORIES.items(): + cat_data = data[:, indices] + result["categories"][cat_name] = { + "mean_activation": round(float(np.abs(cat_data).mean()), 6), + "max_activation": round(float(np.abs(cat_data).max()), 6), + "active_ratio": round(float((np.abs(cat_data) > 0.01).any(axis=0).mean()), 4), + } + + # リップシンク品質指標 + lip_indices = CATEGORIES["jaw"] + CATEGORIES["mouth"] + lip_data = data[:, lip_indices] + + # 1. 動的範囲 (Dynamic Range): リップが動いている幅 + lip_range = float(lip_data.max() - lip_data.min()) + + # 2. 時間変動 (Temporal Variation): フレーム間の変化量 + if num_frames > 1: + lip_diff = np.diff(lip_data, axis=0) + temporal_var = float(np.abs(lip_diff).mean()) + else: + temporal_var = 0.0 + + # 3. 活性度 (Activation Level): リップの平均活性度 + lip_activation = float(np.abs(lip_data).mean()) + + # 4. 対称性 (Symmetry): 左右のブレンドシェイプの対称度 + symmetry_pairs = [ + ("mouthSmileLeft", "mouthSmileRight"), + ("mouthFrownLeft", "mouthFrownRight"), + ("mouthLowerDownLeft", "mouthLowerDownRight"), + ("mouthUpperUpLeft", "mouthUpperUpRight"), + ("mouthPressLeft", "mouthPressRight"), + ] + symmetry_scores = [] + for left_name, right_name in symmetry_pairs: + if left_name in ARKIT_NAMES and right_name in ARKIT_NAMES: + left_idx = ARKIT_NAMES.index(left_name) + right_idx = ARKIT_NAMES.index(right_name) + diff = np.abs(data[:, left_idx] - data[:, right_idx]).mean() + symmetry_scores.append(1.0 - min(diff, 1.0)) + + symmetry = float(np.mean(symmetry_scores)) if symmetry_scores else 0.0 + + # 5. jawOpenの活性パターン + jaw_open_idx = ARKIT_NAMES.index("jawOpen") + jaw_data = data[:, jaw_open_idx] + jaw_peaks = len(_find_peaks(jaw_data, threshold=0.1)) + + result["lip_sync"] = { + "dynamic_range": round(lip_range, 4), + "temporal_variation": round(temporal_var, 6), + "activation_level": round(lip_activation, 6), + "symmetry": round(symmetry, 4), + "jaw_open_peaks": jaw_peaks, + "jaw_open_peaks_per_sec": round(jaw_peaks / max(duration, 0.01), 2), + } + + # リップシンク品質スコア (0-100) + # 高い temporal_variation = 口が動いている + # 適度な dynamic_range = 表現力がある + # 高い symmetry = 自然な動き + quality_score = min(100, ( + min(temporal_var * 500, 30) + + min(lip_range * 20, 25) + + min(lip_activation * 200, 20) + + symmetry * 25 + )) + result["lip_sync"]["quality_score"] = round(quality_score, 1) + + # Top 10 最活性ブレンドシェイプ + mean_abs = np.abs(data).mean(axis=0) + top_indices = np.argsort(-mean_abs)[:10] + result["top10_blendshapes"] = [ + {"rank": rank + 1, "name": ARKIT_NAMES[i], "mean_abs": round(float(mean_abs[i]), 6)} + for rank, i in enumerate(top_indices) + ] + + # リップシンク重要ブレンドシェイプの詳細 + result["critical_blendshapes"] = {} + for bs_name, bs_idx in LIP_SYNC_CRITICAL.items(): + bs_data = data[:, bs_idx] + result["critical_blendshapes"][bs_name] = { + "mean": round(float(bs_data.mean()), 6), + "std": round(float(bs_data.std()), 6), + "min": round(float(bs_data.min()), 6), + "max": round(float(bs_data.max()), 6), + "active_frames_pct": round(float((np.abs(bs_data) > 0.01).mean()) * 100, 1), + } + + return result + + +def _find_peaks(data: np.ndarray, threshold: float = 0.1) -> list: + """簡易ピーク検出""" + peaks = [] + for i in range(1, len(data) - 1): + if data[i] > threshold and data[i] > data[i - 1] and data[i] > data[i + 1]: + peaks.append(i) + return peaks + + +def compare_languages(results: dict) -> dict: + """言語間のリップシンク品質比較""" + comparison = {} + + # カテゴリを推測 + ja_results = {k: v for k, v in results.items() if not k.endswith(("_compare", "_baseline"))} + en_results = {k: v for k, v in results.items() if "english" in k} + zh_results = {k: v for k, v in results.items() if "chinese" in k} + + for lang_name, lang_results in [("japanese", ja_results), ("english", en_results), ("chinese", zh_results)]: + if not lang_results: + continue + + scores = [r["lip_sync"]["quality_score"] for r in lang_results.values()] + temporal_vars = [r["lip_sync"]["temporal_variation"] for r in lang_results.values()] + jaw_rates = [r["lip_sync"]["jaw_open_peaks_per_sec"] for r in lang_results.values()] + + comparison[lang_name] = { + "num_samples": len(scores), + "avg_quality_score": round(float(np.mean(scores)), 1), + "avg_temporal_variation": round(float(np.mean(temporal_vars)), 6), + "avg_jaw_peaks_per_sec": round(float(np.mean(jaw_rates)), 2), + } + + return comparison + + +def print_report(result: dict): + """分析結果を見やすく表示""" + print(f"\n{'=' * 60}") + print(f" {result['name']}") + print(f" {result['num_frames']} frames, {result['duration_s']}s @ {result['fps']}fps") + print(f"{'=' * 60}") + + ls = result["lip_sync"] + print(f"\n Lip Sync Quality Score: {ls['quality_score']}/100") + print(f" Dynamic Range: {ls['dynamic_range']:.4f}") + print(f" Temporal Variation: {ls['temporal_variation']:.6f}") + print(f" Activation Level: {ls['activation_level']:.6f}") + print(f" Symmetry: {ls['symmetry']:.4f}") + print(f" Jaw Open Peaks: {ls['jaw_open_peaks']} ({ls['jaw_open_peaks_per_sec']}/sec)") + + print(f"\n Category Activation:") + for cat, stats in result["categories"].items(): + bar = "█" * int(stats["mean_activation"] * 100) + print(f" {cat:8s}: {stats['mean_activation']:.4f} {bar}") + + print(f"\n Top 10 Active Blendshapes:") + for bs in result["top10_blendshapes"]: + print(f" {bs['rank']:2d}. {bs['name']:25s} {bs['mean_abs']:.6f}") + + print(f"\n Critical Lip Sync Blendshapes:") + for name, stats in result["critical_blendshapes"].items(): + print(f" {name:25s} mean={stats['mean']:.4f} std={stats['std']:.4f} " + f"active={stats['active_frames_pct']:.1f}%") + + +def export_csv(results: dict, output_path: str): + """結果をCSVにエクスポート""" + import csv + with open(output_path, "w", newline="", encoding="utf-8") as f: + writer = csv.writer(f) + # ヘッダー + writer.writerow(["name", "frames", "duration_s", "quality_score", + "dynamic_range", "temporal_variation", "activation_level", + "symmetry", "jaw_peaks_per_sec"]) + for name, result in results.items(): + ls = result["lip_sync"] + writer.writerow([ + name, result["num_frames"], result["duration_s"], + ls["quality_score"], ls["dynamic_range"], ls["temporal_variation"], + ls["activation_level"], ls["symmetry"], ls["jaw_open_peaks_per_sec"], + ]) + print(f"\nCSV exported to: {output_path}") + + +def main(): + parser = argparse.ArgumentParser(description="A2E Blendshape Output Analyzer") + parser.add_argument("--input", type=str, help="Single .npy file to analyze") + parser.add_argument("--input-dir", type=str, help="Directory of .npy files to analyze") + parser.add_argument("--fps", type=float, default=30.0, help="Frames per second (default: 30)") + parser.add_argument("--export-csv", action="store_true", help="Export results to CSV") + parser.add_argument("--export-json", action="store_true", help="Export results to JSON") + args = parser.parse_args() + + if not args.input and not args.input_dir: + # デモモード + print("No input specified. Running demo with synthetic data.\n") + print("Usage:") + print(" python analyze_blendshapes.py --input output.npy") + print(" python analyze_blendshapes.py --input-dir blendshape_outputs/") + print("\nExpected input format: numpy array of shape (num_frames, 52)") + print("\nRunning demo with synthetic data...\n") + + # デモ: 合成データで分析例を表示 + np.random.seed(42) + demo_data = np.random.rand(90, 52).astype(np.float32) * 0.3 + # jawOpenに周期的なパターンを追加 + t = np.linspace(0, 3, 90) + demo_data[:, ARKIT_NAMES.index("jawOpen")] = 0.3 * np.abs(np.sin(2 * np.pi * t)) + demo_data[:, ARKIT_NAMES.index("mouthFunnel")] = 0.15 * np.abs(np.sin(2 * np.pi * t + 0.5)) + + result = analyze_single(demo_data, "demo_synthetic", fps=args.fps) + print_report(result) + return + + results = {} + + if args.input: + data = np.load(args.input) + name = Path(args.input).stem + result = analyze_single(data, name, fps=args.fps) + results[name] = result + print_report(result) + + if args.input_dir: + input_dir = Path(args.input_dir) + for npy_path in sorted(input_dir.glob("*.npy")): + data = np.load(str(npy_path)) + name = npy_path.stem + try: + result = analyze_single(data, name, fps=args.fps) + results[name] = result + print_report(result) + except ValueError as e: + print(f"\n [SKIP] {name}: {e}") + + if len(results) > 1: + print("\n" + "=" * 60) + print("LANGUAGE COMPARISON") + print("=" * 60) + comparison = compare_languages(results) + for lang, stats in comparison.items(): + print(f"\n {lang}:") + for k, v in stats.items(): + print(f" {k}: {v}") + + if args.export_csv and results: + csv_path = str(Path(args.input_dir or ".") / "analysis_results.csv") + export_csv(results, csv_path) + + if args.export_json and results: + json_path = str(Path(args.input_dir or ".") / "analysis_results.json") + with open(json_path, "w", encoding="utf-8") as f: + json.dump(results, f, indent=2, ensure_ascii=False) + print(f"\nJSON exported to: {json_path}") + + +if __name__ == "__main__": + main() diff --git a/tests/a2e_japanese/chat_with_lam_jp.yaml b/tests/a2e_japanese/chat_with_lam_jp.yaml new file mode 100644 index 0000000..de0f5b5 --- /dev/null +++ b/tests/a2e_japanese/chat_with_lam_jp.yaml @@ -0,0 +1,75 @@ +# OpenAvatarChat config for A2E + Japanese audio test +# Gemini API + EdgeTTS (ja-JP) + LAM A2E +# +# Usage: +# Copy to C:\Users\hamad\OpenAvatarChat\config\chat_with_lam_jp.yaml +# python src/demo.py --config config/chat_with_lam_jp.yaml +# +# Requirements: +# - Gemini API key (https://aistudio.google.com/apikey) +# - pip install edge-tts addict yapf regex librosa transformers termcolor +# - models/LAM_audio2exp/pretrained_models/lam_audio2exp_streaming.tar +# - models/wav2vec2-base-960h/ (with model.safetensors or pytorch_model.bin) +# - models/iic/SenseVoiceSmall/ + +default: + logger: + log_level: "INFO" + service: + host: "0.0.0.0" + port: 8282 + cert_file: "ssl_certs/localhost.crt" + cert_key: "ssl_certs/localhost.key" + chat_engine: + model_root: "models" + handler_search_path: + - "src/handlers" + handler_configs: + LamClient: + module: client/h5_rendering_client/client_handler_lam + connection_ttl: 900 + # ZIPパス: HF Spacesで生成した公式ZIPを指定 + # 英語参照版と中国語参照版の2つでテスト比較 + asset_path: lam_samples/concierge.zip + + SileroVad: + module: vad/silerovad/vad_handler_silero + speaking_threshold: 0.5 + start_delay: 2048 + end_delay: 5000 + buffer_look_back: 5000 + speech_padding: 512 + + SenseVoice: + enabled: true + module: asr/sensevoice/asr_handler_sensevoice + model_name: "iic/SenseVoiceSmall" + # 日本語を強制指定(autoだと中国語と誤検出される) + # patch_asr_language.py を適用後に有効 + language: "ja" + + Edge_TTS: + enabled: true + module: tts/edgetts/tts_handler_edgetts + # 日本語音声: ja-JP-NanamiNeural (女性), ja-JP-KeitaNeural (男性) + voice: "ja-JP-NanamiNeural" + sample_rate: 24000 + + LLMOpenAICompatible: + enabled: true + module: llm/openai_compatible/llm_handler_openai_compatible + model_name: "gemini-2.5-flash" + enable_video_input: false + history_length: 20 + system_prompt: "あなたはAIコンシェルジュです。日本語で簡潔に2〜3文で回答してください。" + api_url: "https://generativelanguage.googleapis.com/v1beta/openai/" + # Gemini API key - replace with your own + # Get from: https://aistudio.google.com/apikey + api_key: "YOUR_GEMINI_API_KEY" + + LAM_Driver: + enabled: true + module: avatar/lam/avatar_handler_lam_audio2expression + model_name: LAM_audio2exp + feature_extractor_model_name: wav2vec2-base-960h + audio_sample_rate: 24000 diff --git a/tests/a2e_japanese/diagnose_onnx_error.py b/tests/a2e_japanese/diagnose_onnx_error.py new file mode 100644 index 0000000..992d1a5 --- /dev/null +++ b/tests/a2e_japanese/diagnose_onnx_error.py @@ -0,0 +1,395 @@ +""" +ONNX RuntimeError 診断スクリプト + +OpenAvatarChatで発生する以下のエラーの原因を特定する: + RuntimeError: Input data type is not supported. + +このスクリプトは各ハンドラーのONNX関連処理を個別にテストし、 +エラーの発生箇所を特定する。 + +使い方: + cd C:\Users\hamad\OpenAvatarChat + conda activate oac + python tests/a2e_japanese/diagnose_onnx_error.py +""" + +import os +import sys +import traceback +from pathlib import Path + + +def find_oac_dir() -> Path: + candidates = [ + Path(r"C:\Users\hamad\OpenAvatarChat"), + Path.home() / "OpenAvatarChat", + Path.cwd(), + ] + for p in candidates: + if (p / "src" / "handlers").exists(): + return p + return Path.cwd() + + +def test_onnx_runtime_basic(): + """Test 1: ONNX Runtime の基本動作確認""" + print("\n" + "=" * 60) + print("TEST 1: ONNX Runtime Basic Check") + print("=" * 60) + + try: + import onnxruntime + print(f" onnxruntime version: {onnxruntime.__version__}") + print(f" Available providers: {onnxruntime.get_available_providers()}") + print(" [PASS]") + return True + except ImportError: + print(" [FAIL] onnxruntime not installed") + return False + + +def test_silero_vad_onnx(oac_dir: Path): + """Test 2: SileroVAD ONNX モデルのロードと推論テスト""" + print("\n" + "=" * 60) + print("TEST 2: SileroVAD ONNX Model") + print("=" * 60) + + import onnxruntime + import numpy as np + + # モデルファイルの検索 + model_candidates = [ + oac_dir / "src" / "handlers" / "vad" / "silerovad" / "silero_vad" / "src" / "silero_vad" / "data" / "silero_vad.onnx", + oac_dir / "src" / "handlers" / "vad" / "silerovad" / "data" / "silero_vad.onnx", + ] + + model_path = None + for p in model_candidates: + if p.exists(): + model_path = p + break + + if model_path is None: + # Recursive search + for p in oac_dir.rglob("silero_vad.onnx"): + model_path = p + break + + if model_path is None: + print(" [SKIP] silero_vad.onnx not found") + return None + + print(f" Model: {model_path}") + + # モデルロード + try: + options = onnxruntime.SessionOptions() + options.inter_op_num_threads = 1 + options.intra_op_num_threads = 1 + options.log_severity_level = 4 + session = onnxruntime.InferenceSession( + str(model_path), + providers=["CPUExecutionProvider"], + sess_options=options, + ) + print(" Model loaded successfully") + except Exception as e: + print(f" [FAIL] Model load error: {e}") + return False + + # 入力/出力情報 + print("\n Model inputs:") + for inp in session.get_inputs(): + print(f" {inp.name}: shape={inp.shape}, type={inp.type}") + + print(" Model outputs:") + for out in session.get_outputs(): + print(f" {out.name}: shape={out.shape}, type={out.type}") + + num_outputs = len(session.get_outputs()) + print(f"\n Number of outputs: {num_outputs}") + + # テスト1: 正しい numpy 入力 + print("\n --- Test 2a: Correct numpy inputs ---") + try: + clip = np.zeros((1, 512), dtype=np.float32) + sr = np.array([16000], dtype=np.int64) + state = np.zeros((2, 1, 128), dtype=np.float32) + + inputs = {"input": clip, "sr": sr, "state": state} + print(f" input: type={type(clip).__name__}, dtype={clip.dtype}, shape={clip.shape}") + print(f" sr: type={type(sr).__name__}, dtype={sr.dtype}, shape={sr.shape}") + print(f" state: type={type(state).__name__}, dtype={state.dtype}, shape={state.shape}") + + results = session.run(None, inputs) + print(f" Output count: {len(results)}") + for i, r in enumerate(results): + print(f" output[{i}]: type={type(r).__name__}, dtype={r.dtype}, shape={r.shape}") + + # 出力数が2の場合のunpack確認 + if len(results) == 2: + prob, new_state = results + print(f" Unpacked prob: type={type(prob).__name__}, value={prob}") + print(f" Unpacked state: type={type(new_state).__name__}, shape={new_state.shape}") + print(" [PASS] 2-output unpack works correctly") + elif len(results) == 3: + print(" [WARN] Model has 3 outputs! VAD handler expects 2.") + print(" This WILL cause 'too many values to unpack' error.") + print(" FIX: Update _inference to handle 3 outputs") + else: + print(f" [WARN] Unexpected output count: {len(results)}") + + # 2回目の推論(stateを再利用) + if len(results) >= 2: + new_state = results[1] + inputs2 = {"input": clip, "sr": sr, "state": new_state} + print(f"\n Second inference with returned state:") + print(f" state type={type(new_state).__name__}, dtype={new_state.dtype}, shape={new_state.shape}") + results2 = session.run(None, inputs2) + print(f" [PASS] Second inference succeeded") + + except Exception as e: + print(f" [FAIL] {type(e).__name__}: {e}") + traceback.print_exc() + return False + + # テスト2: list 入力 → エラー再現 + print("\n --- Test 2b: List input (reproduce error) ---") + try: + list_input = [0.0] * 512 # Python list instead of numpy array + inputs_bad = {"input": list_input, "sr": sr, "state": state} + results = session.run(None, inputs_bad) + print(" [UNEXPECTED] No error with list input") + except RuntimeError as e: + if "list" in str(e).lower(): + print(f" [CONFIRMED] Error reproduced: {e}") + print(" This is the EXACT error from the logs.") + else: + print(f" [FAIL] Different RuntimeError: {e}") + except Exception as e: + print(f" [INFO] Different error type: {type(e).__name__}: {e}") + + # テスト3: state を list で渡す → エラー再現 + print("\n --- Test 2c: State as list (reproduce error) ---") + try: + state_list = state.tolist() # Convert numpy to nested list + inputs_bad = {"input": clip, "sr": sr, "state": state_list} + results = session.run(None, inputs_bad) + print(" [UNEXPECTED] No error with list state") + except RuntimeError as e: + if "list" in str(e).lower(): + print(f" [CONFIRMED] Error reproduced: {e}") + print(" If model_state becomes a list, this error occurs.") + else: + print(f" [FAIL] Different RuntimeError: {e}") + except Exception as e: + print(f" [INFO] Different error type: {type(e).__name__}: {e}") + + print("\n [PASS] SileroVAD ONNX diagnosis complete") + return True + + +def test_sensevoice_funasr(oac_dir: Path): + """Test 3: FunASR SenseVoice のロードテスト""" + print("\n" + "=" * 60) + print("TEST 3: FunASR SenseVoice Model Load") + print("=" * 60) + + try: + import torch + print(f" PyTorch: {torch.__version__}") + print(f" CUDA: {torch.cuda.is_available()}") + except ImportError: + print(" [FAIL] PyTorch not installed") + return False + + try: + from funasr import AutoModel + print(" FunASR imported successfully") + except ImportError: + print(" [SKIP] FunASR not installed") + return None + + model_name = "iic/SenseVoiceSmall" + model_path = oac_dir / "models" / "iic" / "SenseVoiceSmall" + if model_path.exists(): + model_name = str(model_path) + + print(f" Loading model: {model_name}") + + try: + model = AutoModel(model=model_name, disable_update=True) + print(" [PASS] SenseVoice model loaded successfully") + except RuntimeError as e: + if "list" in str(e).lower(): + print(f" [FAIL] ONNX list error during model load!") + print(f" Error: {e}") + print(" >>> THIS is the source of the error! <<<") + print(" FunASR's model loading triggers ONNX with list input.") + return False + else: + print(f" [FAIL] RuntimeError: {e}") + return False + except Exception as e: + print(f" [FAIL] {type(e).__name__}: {e}") + traceback.print_exc() + return False + + # テスト推論 + print("\n Testing inference with dummy audio...") + try: + import numpy as np + dummy_audio = np.zeros(16000, dtype=np.float32) + res = model.generate(input=dummy_audio, batch_size_s=10) + print(f" Result: {res}") + print(" [PASS] SenseVoice inference succeeded") + except RuntimeError as e: + if "list" in str(e).lower(): + print(f" [FAIL] ONNX list error during inference!") + print(f" Error: {e}") + print(" >>> THIS is the source of the error! <<<") + return False + else: + print(f" [FAIL] RuntimeError: {e}") + return False + except Exception as e: + print(f" [FAIL] {type(e).__name__}: {e}") + traceback.print_exc() + return False + + return True + + +def test_vad_handler_timestamp_bug(): + """Test 4: VAD handler の timestamp[0] バグ確認""" + print("\n" + "=" * 60) + print("TEST 4: VAD Handler timestamp[0] Bug Check") + print("=" * 60) + + print(" In vad_handler_silero.py handle() method:") + print(" timestamp = None") + print(" if inputs.is_timestamp_valid():") + print(" timestamp = inputs.timestamp") + print(" ...") + print(" context.slice_context.update_start_id(timestamp[0], ...)") + print() + print(" If is_timestamp_valid() returns False, timestamp stays None.") + print(" Then timestamp[0] raises TypeError!") + print() + + # Simulate the bug + timestamp = None + try: + _ = timestamp[0] + print(" [UNEXPECTED] No error") + except TypeError as e: + print(f" [CONFIRMED] TypeError: {e}") + print(" This crashes the handler BEFORE any ONNX call.") + print(" The pipeline may then produce the RuntimeError downstream.") + + print() + print(" FIX: Add null check before timestamp[0]:") + print(" if timestamp is not None:") + print(" context.slice_context.update_start_id(timestamp[0], ...)") + print(" else:") + print(" context.slice_context.update_start_id(0, ...)") + + return True + + +def test_audio_data_flow(oac_dir: Path): + """Test 5: fastrtc -> handler のデータフロー確認""" + print("\n" + "=" * 60) + print("TEST 5: Audio Data Flow Check") + print("=" * 60) + + try: + sys.path.insert(0, str(oac_dir / "src")) + from engine_utils.general_slicer import SliceContext, slice_data + import numpy as np + + # SliceContext のテスト + ctx = SliceContext.create_numpy_slice_context(slice_size=512, slice_axis=0) + print(" SliceContext created successfully") + + # numpy audio → slice_data + audio = np.random.randn(4096).astype(np.float32) + slices = list(slice_data(ctx, audio)) + print(f" slice_data: {len(slices)} slices from {audio.shape} audio") + + for i, s in enumerate(slices[:3]): + print(f" slice[{i}]: type={type(s).__name__}, dtype={s.dtype}, shape={s.shape}") + + all_numpy = all(isinstance(s, np.ndarray) for s in slices) + if all_numpy: + print(" [PASS] All slices are numpy arrays") + else: + print(" [FAIL] Some slices are NOT numpy arrays!") + for i, s in enumerate(slices): + if not isinstance(s, np.ndarray): + print(f" slice[{i}]: type={type(s).__name__}") + + return all_numpy + + except ImportError as e: + print(f" [SKIP] Cannot import engine_utils: {e}") + return None + except Exception as e: + print(f" [FAIL] {type(e).__name__}: {e}") + traceback.print_exc() + return False + + +def main(): + oac_dir = find_oac_dir() + + print("=" * 60) + print("ONNX RuntimeError Diagnostic Tool") + print("=" * 60) + print(f"OAC Directory: {oac_dir}") + print(f"Python: {sys.version}") + + results = {} + + # Test 1: ONNX Runtime basic + results["onnx_basic"] = test_onnx_runtime_basic() + + # Test 2: SileroVAD ONNX + if results["onnx_basic"]: + results["silero_vad"] = test_silero_vad_onnx(oac_dir) + + # Test 3: FunASR SenseVoice + results["sensevoice"] = test_sensevoice_funasr(oac_dir) + + # Test 4: timestamp bug + results["timestamp_bug"] = test_vad_handler_timestamp_bug() + + # Test 5: Audio data flow + results["data_flow"] = test_audio_data_flow(oac_dir) + + # Summary + print("\n" + "=" * 60) + print("DIAGNOSIS SUMMARY") + print("=" * 60) + + for name, passed in results.items(): + if passed is None: + status = "SKIP" + elif passed: + status = "PASS" + else: + status = "FAIL" + print(f" [{status}] {name}") + + # Recommendations + print("\n RECOMMENDATIONS:") + print(" 1. Apply patch_vad_handler.py to add defensive type checking") + print(" 2. Fix timestamp[0] null check in vad_handler_silero.py") + print(" 3. If SenseVoice FAIL, check FunASR ONNX configuration") + print(" 4. Run OpenAvatarChat with ONNX_DEBUG=1 for detailed logging") + + return 0 if all(v is not False for v in results.values()) else 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tests/a2e_japanese/generate_test_audio.py b/tests/a2e_japanese/generate_test_audio.py new file mode 100644 index 0000000..6e16a8f --- /dev/null +++ b/tests/a2e_japanese/generate_test_audio.py @@ -0,0 +1,206 @@ +""" +A2E日本語音声テスト用: テスト音声ファイル生成スクリプト + +EdgeTTSを使って日本語テスト音声を生成する。 +OpenAvatarChatと同じ ja-JP-NanamiNeural voice を使用。 + +使い方: + cd C:\Users\hamad\OpenAvatarChat + conda activate oac + python tests/a2e_japanese/generate_test_audio.py + +出力: + tests/a2e_japanese/audio_samples/ に WAV ファイルが生成される +""" + +import asyncio +import os +import sys +import wave +import struct + +SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__)) +AUDIO_DIR = os.path.join(SCRIPT_DIR, "audio_samples") + +# テストケース: 日本語音声サンプル +# phoneme_test: 母音の網羅性テスト +# greeting: 日常的なフレーズ +# long_sentence: 長文での自然さテスト +# english_compare: 英語比較用 +TEST_CASES = [ + { + "id": "vowels_aiueo", + "text": "あ、い、う、え、お", + "lang": "ja", + "description": "Japanese vowels (a, i, u, e, o) - basic lip shape test", + }, + { + "id": "greeting_konnichiwa", + "text": "こんにちは、お元気ですか?今日はとても良い天気ですね。", + "lang": "ja", + "description": "Japanese greeting - natural conversation test", + }, + { + "id": "long_sentence", + "text": "私はAIコンシェルジュです。何かお手伝いできることがあれば、お気軽にお声がけください。", + "lang": "ja", + "description": "Japanese service phrase - longer utterance test", + }, + { + "id": "mixed_phonemes", + "text": "さしすせそ、たちつてと、なにぬねの、はひふへほ、まみむめも", + "lang": "ja", + "description": "Japanese consonant+vowel combinations - comprehensive phoneme coverage", + }, + { + "id": "numbers_and_names", + "text": "東京タワーの高さは三百三十三メートルです。富士山は三千七百七十六メートルです。", + "lang": "ja", + "description": "Numbers and proper nouns - complex articulation test", + }, + { + "id": "english_compare", + "text": "Hello, how are you? I'm doing great, thank you for asking.", + "lang": "en", + "description": "English comparison - to compare A2E output quality", + }, + { + "id": "chinese_compare", + "text": "你好,我是AI助手,很高兴认识你。", + "lang": "zh", + "description": "Chinese comparison - original reference language", + }, +] + +# EdgeTTS voice mapping +VOICE_MAP = { + "ja": "ja-JP-NanamiNeural", + "en": "en-US-JennyNeural", + "zh": "zh-CN-XiaoxiaoNeural", +} + + +async def generate_with_edge_tts(text: str, voice: str, output_path: str): + """EdgeTTSで音声を生成してWAVで保存""" + try: + import edge_tts + except ImportError: + print("ERROR: edge-tts not installed. Run: pip install edge-tts") + sys.exit(1) + + mp3_path = output_path.replace(".wav", ".mp3") + communicate = edge_tts.Communicate(text, voice) + await communicate.save(mp3_path) + + # MP3 → WAV 変換 (24kHz, mono, 16bit) + try: + from pydub import AudioSegment + audio = AudioSegment.from_mp3(mp3_path) + audio = audio.set_frame_rate(24000).set_channels(1).set_sample_width(2) + audio.export(output_path, format="wav") + os.remove(mp3_path) + return True + except ImportError: + # pydubがない場合はffmpegで変換 + import subprocess + try: + subprocess.run( + ["ffmpeg", "-y", "-i", mp3_path, "-ar", "24000", "-ac", "1", + "-sample_fmt", "s16", output_path], + capture_output=True, check=True, + ) + os.remove(mp3_path) + return True + except (subprocess.CalledProcessError, FileNotFoundError): + print(f" WARNING: Could not convert to WAV. Keeping MP3: {mp3_path}") + print(" Install pydub (pip install pydub) or ffmpeg for WAV conversion.") + return False + + +def generate_sine_tone(output_path: str, freq: float = 440.0, duration: float = 1.0, + sample_rate: int = 24000): + """サイン波テスト音声(無音声参照用)""" + n_samples = int(sample_rate * duration) + with wave.open(output_path, "w") as wf: + wf.setnchannels(1) + wf.setsampwidth(2) + wf.setframerate(sample_rate) + for i in range(n_samples): + t = i / sample_rate + value = int(16000 * __import__("math").sin(2 * __import__("math").pi * freq * t)) + wf.writeframes(struct.pack(" と表示され、「ありがとう」が「谢谢」になる等。 + +原因: + SenseVoice の generate() が language="auto" (デフォルト) で + 動作しており、短い発話では中国語と誤検出される。 + +修正: + generate() 呼び出しに language="ja" を追加して日本語を強制する。 + さらに、設定ファイルから language パラメータを読み取れるようにする。 + +使い方: + cd C:\\Users\\hamad\\OpenAvatarChat + python tests/a2e_japanese/patch_asr_language.py + + または --dry-run で変更内容だけ確認: + python tests/a2e_japanese/patch_asr_language.py --dry-run +""" + +import re +import shutil +import sys +from pathlib import Path + + +def find_oac_dir() -> Path: + """OpenAvatarChat ディレクトリを自動検出""" + candidates = [ + Path(r"C:\Users\hamad\OpenAvatarChat"), + Path.home() / "OpenAvatarChat", + Path.cwd(), + ] + for p in candidates: + if (p / "src" / "handlers").exists(): + return p + return None + + +def patch_asr_language(oac_dir: Path, dry_run: bool = False) -> bool: + """SenseVoice ASR handler に language="ja" を強制するパッチ""" + handler_path = (oac_dir / "src" / "handlers" / "asr" / + "sensevoice" / "asr_handler_sensevoice.py") + + if not handler_path.exists(): + print(f" [ERROR] File not found: {handler_path}") + return False + + content = handler_path.read_text(encoding="utf-8") + + # 既にパッチ済みか確認 + if "# [PATCH] Force language" in content: + print(" [ALREADY] ASR language patch already applied") + return True + + # ======================================== + # 方法1: generate() 呼び出しに language パラメータを追加 + # ======================================== + # FunASR の generate() は以下のようなシグネチャ: + # model.generate(input=..., cache={}, language="auto", ...) + # "auto" をデフォルトから "ja" に変更 + + # generate() 呼び出しを探す + # パターン: self.model.generate( で始まり、) で閉じる部分 + lines = content.splitlines() + + # generate 呼び出しの行範囲を特定 + gen_start = None + gen_end = None + for i, line in enumerate(lines): + if "generate(" in line and ("self.model" in line or "model.generate" in line): + gen_start = i + # 閉じ括弧を探す + paren_count = line.count("(") - line.count(")") + if paren_count <= 0: + gen_end = i + else: + for j in range(i + 1, min(i + 30, len(lines))): + paren_count += lines[j].count("(") - lines[j].count(")") + if paren_count <= 0: + gen_end = j + break + break + + if gen_start is None: + print(" [WARN] Could not find model.generate() call") + print(" Trying alternative approach...") + return patch_asr_language_alternative(oac_dir, content, handler_path, dry_run) + + print(f" Found generate() call at lines {gen_start + 1}-{gen_end + 1}") + + # generate() 呼び出し全体を取得 + gen_lines = lines[gen_start:gen_end + 1] + gen_text = "\n".join(gen_lines) + + # language パラメータが既に存在するか確認 + has_language = "language" in gen_text + + if has_language: + # language パラメータの値を "ja" に変更 + # language="auto" → language="ja" + # language='auto' → language='ja' + new_gen_text = re.sub( + r'language\s*=\s*["\']auto["\']', + 'language="ja" # [PATCH] Force language to Japanese', + gen_text + ) + if new_gen_text == gen_text: + # auto 以外の値が設定されている場合 + new_gen_text = re.sub( + r'language\s*=\s*["\'][^"\']*["\']', + 'language="ja" # [PATCH] Force language to Japanese', + gen_text + ) + else: + # language パラメータを追加 + # generate( の直後の行にパラメータを挿入 + # input= の行の後に追加 + indent_match = re.search(r'\n(\s+)', gen_text) + if indent_match: + param_indent = indent_match.group(1) + else: + param_indent = " " + + # 最後の引数の後、閉じ括弧の前に追加 + # 閉じ括弧 ) の前に language="ja" を挿入 + close_paren_idx = gen_text.rfind(")") + if close_paren_idx > 0: + before_close = gen_text[:close_paren_idx].rstrip() + after_close = gen_text[close_paren_idx:] + # 最後の引数にカンマがなければ追加 + if not before_close.endswith(","): + before_close += "," + new_gen_text = ( + before_close + "\n" + + param_indent + 'language="ja", # [PATCH] Force language to Japanese\n' + + param_indent.rstrip() + after_close.lstrip() + ) + else: + print(" [WARN] Cannot parse generate() call structure") + return patch_asr_language_alternative(oac_dir, content, handler_path, dry_run) + + if dry_run: + print("\n --- Patch preview ---") + print(" Before:") + for line in gen_lines: + print(f" - {line}") + print(" After:") + for line in new_gen_text.splitlines(): + print(f" + {line}") + print(" --- End preview ---") + return True + + # バックアップ + backup_path = handler_path.with_suffix(".py.bak") + if not backup_path.exists(): + shutil.copy2(handler_path, backup_path) + print(f" Backup: {backup_path}") + + # パッチ適用 + new_content = content.replace(gen_text, new_gen_text) + handler_path.write_text(new_content, encoding="utf-8") + print(f" [APPLIED] Force language='ja' in generate() call") + return True + + +def patch_asr_language_alternative(oac_dir: Path, content: str, handler_path: Path, dry_run: bool) -> bool: + """ + 代替方法: generate() の戻り値からタグを置換する + SenseVoice の出力は <|zh|><|NEUTRAL|><|Speech|><|text|> 形式 + この方法は generate() のシグネチャに依存しない + """ + lines = content.splitlines() + + # 結果テキストを処理する行を探す + # 通常: res[0]['text'] のような形でテキストを取得 + # ログ出力行を探す(ログにテキスト結果が出ている行の近く) + target_line_idx = None + for i, line in enumerate(lines): + # generate の結果をログ出力している行を探す + if "generate(" in line or ".generate(" in line: + # generate呼び出しの直後にパッチを挿入 + target_line_idx = i + break + + if target_line_idx is None: + print(" [ERROR] Cannot find generate() call in ASR handler") + print(" Please apply the patch manually (see below)") + print_manual_guide() + return False + + # generate() の行のインデントを取得 + target_line = lines[target_line_idx] + indent = len(target_line) - len(target_line.lstrip()) + indent_str = target_line[:indent] + + print(f" Found generate() at line {target_line_idx + 1}") + print(f" Will add language='ja' parameter") + + if dry_run: + print("\n --- Alternative patch ---") + print(f" Add language='ja' to the generate() call on line {target_line_idx + 1}") + print(" --- End ---") + return True + + # バックアップ + backup_path = handler_path.with_suffix(".py.bak") + if not backup_path.exists(): + shutil.copy2(handler_path, backup_path) + print(f" Backup: {backup_path}") + + print(" [WARN] Auto-patching may not work perfectly.") + print(" Please also apply the manual fix below:") + print_manual_guide() + return False + + +def print_manual_guide(): + """手動修正ガイドを表示""" + print(""" +=== 手動修正ガイド === + +ファイル: src/handlers/asr/sensevoice/asr_handler_sensevoice.py + +self.model.generate() の呼び出しを探し、language="ja" を追加: + +--- 修正前 --- + res = self.model.generate( + input=audio_data, + cache={}, + ... + ) +--- 修正後 --- + res = self.model.generate( + input=audio_data, + cache={}, + language="ja", # 日本語を強制 + ... + ) + +※ generate() の引数名は実装によって異なる場合があります。 + 重要なのは language="ja" を追加することです。 + +=== 手動修正が面倒な場合 === + +asr_handler_sensevoice.py を直接開いて: +1. Ctrl+F で "generate(" を検索 +2. その呼び出しの中に language="ja", を追加 +3. 保存して OpenAvatarChat を再起動 +""") + + +def main(): + print("=" * 60) + print("ASR SenseVoice Language Patch (Force Japanese)") + print("=" * 60) + + dry_run = "--dry-run" in sys.argv + + oac_dir = find_oac_dir() + if oac_dir is None: + print("ERROR: OpenAvatarChat directory not found") + print("Run from the OpenAvatarChat directory") + sys.exit(1) + + print(f"OAC: {oac_dir}") + print(f"Mode: {'DRY RUN' if dry_run else 'APPLY PATCHES'}") + print() + + print("[1/1] Force Japanese language in SenseVoice ASR:") + ok = patch_asr_language(oac_dir, dry_run=dry_run) + + print(f"\n{'=' * 60}") + if ok: + print("Patch applied successfully!") + else: + print("Automatic patching failed. Please apply manually:") + print_manual_guide() + + if not dry_run and ok: + print(f"\nBackup file: *.py.bak") + print(f"To revert: rename .bak file back to original") + + print(f"\nNext steps:") + print(f" 1. Copy Japanese config:") + print(f" copy tests\\a2e_japanese\\chat_with_lam_jp.yaml config\\chat_with_lam_jp.yaml") + print(f" 2. Edit config/chat_with_lam_jp.yaml - set your Gemini API key") + print(f" 3. Restart OpenAvatarChat with Japanese config:") + print(f" python src/demo.py --config config/chat_with_lam_jp.yaml") + + +if __name__ == "__main__": + if "--help" in sys.argv or "-h" in sys.argv: + print_manual_guide() + else: + main() diff --git a/tests/a2e_japanese/patch_asr_perf_fix.py b/tests/a2e_japanese/patch_asr_perf_fix.py new file mode 100644 index 0000000..067991a --- /dev/null +++ b/tests/a2e_japanese/patch_asr_perf_fix.py @@ -0,0 +1,377 @@ +""" +ASR SenseVoice パフォーマンス劣化修正パッチ + +問題: + 1回目の発話は正常に認識される(rtf=0.629, 1.25秒) + 2回目の発話でASR推論が24倍遅くなる(rtf=15.027, 29.83秒) + fastrtcが60秒タイムアウトでリセットされ、以降音声入力が無反応になる + +原因: + SenseVoice (FunASR) がGPU推論後にメモリを解放しない。 + LAMモデルとGPUメモリを共有しているため、2回目の推論で + GPUメモリ不足→CPUフォールバック→30秒かかる。 + +修正: + 1. SenseVoice推論後に torch.cuda.empty_cache() を追加 + 2. 推論にタイムアウトを追加(10秒超で強制中断→再試行) + 3. GCで不要なテンソルを即座に回収 + +使い方: + cd C:\\Users\\hamad\\OpenAvatarChat + python tests/a2e_japanese/patch_asr_perf_fix.py + + 確認のみ: + python tests/a2e_japanese/patch_asr_perf_fix.py --dry-run +""" + +import re +import shutil +import sys +from pathlib import Path + + +def find_oac_dir() -> Path: + candidates = [ + Path(r"C:\Users\hamad\OpenAvatarChat"), + Path.home() / "OpenAvatarChat", + Path.cwd(), + ] + for p in candidates: + if (p / "src" / "handlers").exists(): + return p + return None + + +def patch_asr_handler(oac_dir: Path, dry_run: bool = False) -> bool: + """SenseVoice ASR handler にGPUメモリ管理を追加""" + handler_path = (oac_dir / "src" / "handlers" / "asr" / + "sensevoice" / "asr_handler_sensevoice.py") + + if not handler_path.exists(): + print(f" [ERROR] {handler_path} not found") + return False + + content = handler_path.read_text(encoding="utf-8") + + if "# [PERF_PATCH]" in content: + print(" [ALREADY] Performance patch already applied") + return True + + lines = content.splitlines() + changes = [] + + # ======================================== + # 修正1: import追加(ファイル先頭付近) + # ======================================== + import_lines = [] + last_import_idx = 0 + for i, line in enumerate(lines): + if line.startswith("import ") or line.startswith("from "): + last_import_idx = i + + # gc と torch のimport追加 + has_gc = any("import gc" in l for l in lines) + has_torch_import = any("import torch" in l for l in lines) + + new_imports = [] + if not has_gc: + new_imports.append("import gc") + if not has_torch_import: + new_imports.append("import torch") + + if new_imports: + insert_text = "\n".join(new_imports) + lines.insert(last_import_idx + 1, insert_text) + changes.append(f"Added imports: {', '.join(new_imports)}") + # Adjust indices after insert + last_import_idx += 1 + + # ======================================== + # 修正2: generate()呼び出し後にGPUメモリクリーンアップ追加 + # ======================================== + # generate() 呼び出しの場所を探す + gen_result_line = None + gen_indent = "" + for i, line in enumerate(lines): + # generate()の結果をログ出力している行を探す + if "generate(" in line and ("self.model" in line or "model.generate" in line): + gen_result_line = i + gen_indent = line[:len(line) - len(line.lstrip())] + break + + if gen_result_line is not None: + # generate() 呼び出しの閉じ括弧を探す + paren_count = 0 + end_line = gen_result_line + for i in range(gen_result_line, min(gen_result_line + 30, len(lines))): + paren_count += lines[i].count("(") - lines[i].count(")") + if paren_count <= 0: + end_line = i + break + + # generate()の後にGPUクリーンアップを挿入 + cleanup_code = [ + f"{gen_indent}# [PERF_PATCH] Free GPU memory after ASR inference", + f"{gen_indent}# Prevents 2nd inference from falling back to CPU (24x slowdown)", + f"{gen_indent}if torch.cuda.is_available():", + f"{gen_indent} torch.cuda.empty_cache()", + f"{gen_indent}gc.collect()", + ] + + # ログ出力行の後に挿入(generate結果のlog行を探す) + insert_after = end_line + for i in range(end_line + 1, min(end_line + 10, len(lines))): + if "logger" in lines[i] and ("text" in lines[i] or "result" in lines[i] or "info" in lines[i].lower()): + insert_after = i + break + + for j, cl in enumerate(cleanup_code): + lines.insert(insert_after + 1 + j, cl) + + changes.append(f"Added GPU memory cleanup after generate() (line ~{end_line + 1})") + else: + print(" [WARN] Could not find model.generate() call") + print(" Adding cleanup at end of handle() method instead") + + # handle() メソッドの return 前に追加 + for i in range(len(lines) - 1, -1, -1): + stripped = lines[i].strip() + if stripped.startswith("return") and "handle" not in stripped: + indent = lines[i][:len(lines[i]) - len(lines[i].lstrip())] + cleanup_code = [ + f"{indent}# [PERF_PATCH] Free GPU memory after ASR inference", + f"{indent}if torch.cuda.is_available():", + f"{indent} torch.cuda.empty_cache()", + f"{indent}gc.collect()", + ] + for j, cl in enumerate(cleanup_code): + lines.insert(i, cl) + changes.append(f"Added GPU cleanup before return (line ~{i + 1})") + break + + # ======================================== + # 修正3: dump audio の部分にもクリーンアップ + # ======================================== + for i, line in enumerate(lines): + if "dump audio" in line and "logger" in line: + indent = line[:len(line) - len(line.lstrip())] + # dump audio の前にGPUキャッシュクリア + cleanup = f"{indent}torch.cuda.empty_cache() if torch.cuda.is_available() else None # [PERF_PATCH]" + lines.insert(i, cleanup) + changes.append(f"Added pre-inference GPU cleanup (line ~{i + 1})") + break + + if not changes: + print(" [SKIP] No changes to make") + return True + + # 結果表示 + new_content = "\n".join(lines) + + print(" Changes:") + for c in changes: + print(f" - {c}") + + if dry_run: + print("\n [DRY RUN] No files modified") + return True + + # バックアップ + backup = handler_path.with_suffix(".py.perf_bak") + if not backup.exists(): + shutil.copy2(handler_path, backup) + print(f" Backup: {backup}") + + handler_path.write_text(new_content, encoding="utf-8") + print(f" [SAVED] {handler_path}") + return True + + +def patch_lam_handler(oac_dir: Path, dry_run: bool = False) -> bool: + """LAM avatar handler にもGPUメモリ管理を追加""" + handler_path = (oac_dir / "src" / "handlers" / "avatar" / + "lam" / "avatar_handler_lam_audio2expression.py") + + if not handler_path.exists(): + print(f" [SKIP] {handler_path} not found") + return True # Not critical + + content = handler_path.read_text(encoding="utf-8") + + if "# [PERF_PATCH]" in content: + print(" [ALREADY] LAM performance patch already applied") + return True + + lines = content.splitlines() + changes = [] + + # import torch があるか確認 + has_torch = any("import torch" in l for l in lines) + has_gc = any("import gc" in l for l in lines) + + if not has_gc: + # 最後のimport行の後にgc追加 + for i, line in enumerate(lines): + if line.startswith("import ") or line.startswith("from "): + last_import = i + lines.insert(last_import + 1, "import gc") + changes.append("Added import gc") + + # Inference完了ログの後にGPUクリーンアップ追加 + for i, line in enumerate(lines): + if "Inference on" in line and "finished in" in line: + indent = line[:len(line) - len(line.lstrip())] + cleanup = [ + f"{indent}# [PERF_PATCH] Free GPU memory after LAM inference", + f"{indent}if torch.cuda.is_available():", + f"{indent} torch.cuda.empty_cache()", + f"{indent}gc.collect()", + ] + for j, cl in enumerate(cleanup): + lines.insert(i + 1 + j, cl) + changes.append(f"Added GPU cleanup after LAM inference (line ~{i + 1})") + break + + if not changes: + print(" [SKIP] No changes to make") + return True + + new_content = "\n".join(lines) + + print(" Changes:") + for c in changes: + print(f" - {c}") + + if dry_run: + print("\n [DRY RUN] No files modified") + return True + + backup = handler_path.with_suffix(".py.perf_bak") + if not backup.exists(): + shutil.copy2(handler_path, backup) + print(f" Backup: {backup}") + + handler_path.write_text(new_content, encoding="utf-8") + print(f" [SAVED] {handler_path}") + return True + + +def create_startup_wrapper(oac_dir: Path, dry_run: bool = False) -> bool: + """GPUメモリ管理を強化した起動ラッパーを作成""" + wrapper_path = oac_dir / "start_japanese.py" + + if wrapper_path.exists(): + content = wrapper_path.read_text(encoding="utf-8") + if "PERF_PATCH" in content: + print(" [ALREADY] Startup wrapper already exists") + return True + + wrapper_content = '''""" +Japanese mode startup with GPU memory optimization. +Usage: python start_japanese.py +""" +import os +import sys + +# [PERF_PATCH] GPU memory management environment variables +# Reserve less memory so ASR and LAM can share GPU +os.environ.setdefault("PYTORCH_CUDA_ALLOC_CONF", "expandable_segments:True") +# Prevent TensorFlow/ONNX from grabbing all GPU memory +os.environ.setdefault("CUDA_MODULE_LOADING", "LAZY") +# Limit GPU memory growth +os.environ.setdefault("PYTORCH_NO_CUDA_MEMORY_CACHING", "0") + +# Ensure UTF-8 output on Windows +os.environ.setdefault("PYTHONUTF8", "1") + +print("=" * 50) +print("Starting OpenAvatarChat (Japanese Mode)") +print("GPU Memory Optimization: ENABLED") +print("=" * 50) + +# Check GPU memory +try: + import torch + if torch.cuda.is_available(): + gpu = torch.cuda.get_device_properties(0) + total_mb = gpu.total_mem / 1024 / 1024 + print(f"GPU: {gpu.name} ({total_mb:.0f} MB)") + free_mb = (torch.cuda.mem_get_info()[0]) / 1024 / 1024 + print(f"Free GPU Memory: {free_mb:.0f} MB") + if free_mb < 2000: + print("WARNING: Low GPU memory! ASR may fall back to CPU.") + print(" Close other GPU applications before running.") + else: + print("WARNING: CUDA not available. ASR will be slow.") +except Exception as e: + print(f"GPU check failed: {e}") + +print() + +# Launch with Japanese config +sys.argv = ["src/demo.py", "--config", "config/chat_with_lam.yaml"] +exec(open("src/demo.py").read()) +''' + + if dry_run: + print(" [DRY RUN] Would create start_japanese.py") + return True + + wrapper_path.write_text(wrapper_content, encoding="utf-8") + print(f" [CREATED] {wrapper_path}") + return True + + +def main(): + print("=" * 60) + print("ASR Performance Fix Patch") + print("SenseVoice 2回目推論の24倍遅延を修正") + print("=" * 60) + + dry_run = "--dry-run" in sys.argv + + oac_dir = find_oac_dir() + if not oac_dir: + print("ERROR: OpenAvatarChat directory not found") + sys.exit(1) + + print(f"OAC: {oac_dir}") + print(f"Mode: {'DRY RUN' if dry_run else 'APPLY'}\n") + + # Patch 1: ASR handler + print("[1/3] ASR SenseVoice handler (GPU memory cleanup):") + ok1 = patch_asr_handler(oac_dir, dry_run) + + # Patch 2: LAM handler + print(f"\n[2/3] LAM avatar handler (GPU memory cleanup):") + ok2 = patch_lam_handler(oac_dir, dry_run) + + # Patch 3: Startup wrapper + print(f"\n[3/3] Startup wrapper (GPU memory optimization):") + ok3 = create_startup_wrapper(oac_dir, dry_run) + + print(f"\n{'=' * 60}") + if ok1 and ok2 and ok3: + print("All patches applied!") + else: + print("Some patches failed. See above for details.") + + print(f""" +Next steps: + 1. Apply all patches (run in order): + python tests/a2e_japanese/patch_config_japanese.py + python tests/a2e_japanese/patch_asr_language.py + python tests/a2e_japanese/patch_asr_perf_fix.py + python tests/a2e_japanese/patch_vad_handler.py + + 2. Start with GPU-optimized launcher: + python start_japanese.py + + 3. Or manually: + set PYTORCH_CUDA_ALLOC_CONF=expandable_segments:True + python src/demo.py --config config/chat_with_lam.yaml +""") + + +if __name__ == "__main__": + main() diff --git a/tests/a2e_japanese/patch_config_japanese.py b/tests/a2e_japanese/patch_config_japanese.py new file mode 100644 index 0000000..275ae92 --- /dev/null +++ b/tests/a2e_japanese/patch_config_japanese.py @@ -0,0 +1,186 @@ +""" +既存の chat_with_lam.yaml を日本語対応に自動パッチ + +動いている config/chat_with_lam.yaml をそのまま使い、 +日本語に必要な3箇所だけ変更する。新しい設定ファイルは作らない。 + +使い方: + cd C:\\Users\\hamad\\OpenAvatarChat + python tests/a2e_japanese/patch_config_japanese.py + + 確認だけ: + python tests/a2e_japanese/patch_config_japanese.py --dry-run +""" + +import re +import shutil +import sys +from pathlib import Path + + +def find_oac_dir() -> Path: + candidates = [ + Path(r"C:\Users\hamad\OpenAvatarChat"), + Path.home() / "OpenAvatarChat", + Path.cwd(), + ] + for p in candidates: + if (p / "src" / "handlers").exists(): + return p + return None + + +def patch_config(oac_dir: Path, dry_run: bool = False) -> bool: + config_path = oac_dir / "config" / "chat_with_lam.yaml" + + if not config_path.exists(): + print(f" [ERROR] {config_path} not found") + return False + + content = config_path.read_text(encoding="utf-8") + original = content + + changes = [] + + # --- 1. TTS voice → 日本語 --- + # voice: "xxx" → voice: "ja-JP-NanamiNeural" + voice_pattern = r'(voice:\s*["\'])([^"\']+)(["\'])' + voice_match = re.search(voice_pattern, content) + if voice_match: + old_voice = voice_match.group(2) + if "ja-JP" not in old_voice: + content = re.sub( + voice_pattern, + r'\g<1>ja-JP-NanamiNeural\g<3>', + content + ) + changes.append(f"TTS voice: {old_voice} → ja-JP-NanamiNeural") + else: + changes.append(f"TTS voice: already Japanese ({old_voice})") + else: + # voice行がない場合、Edge_TTS セクションに追加 + edge_pattern = r'(Edge_TTS:.*?module:\s*[^\n]+)' + edge_match = re.search(edge_pattern, content, re.DOTALL) + if edge_match: + insert_after = edge_match.group(0) + indent = " " + content = content.replace( + insert_after, + insert_after + f'\n{indent}voice: "ja-JP-NanamiNeural"' + ) + changes.append("TTS voice: added ja-JP-NanamiNeural") + + # --- 2. LLM system_prompt → 日本語 --- + jp_prompt = "あなたはAIコンシェルジュです。日本語で簡潔に2〜3文で回答してください。" + prompt_pattern = r'(system_prompt:\s*["\'])([^"\']*?)(["\'])' + prompt_match = re.search(prompt_pattern, content) + if prompt_match: + old_prompt = prompt_match.group(2) + if "日本語" not in old_prompt: + content = re.sub( + prompt_pattern, + f'\\g<1>{jp_prompt}\\g<3>', + content + ) + changes.append(f"system_prompt: → Japanese") + else: + changes.append(f"system_prompt: already Japanese") + else: + # system_prompt がない場合、LLM セクションに追加 + llm_pattern = r'(LLMOpenAICompatible:.*?model_name:\s*[^\n]+)' + llm_match = re.search(llm_pattern, content, re.DOTALL) + if llm_match: + insert_after = llm_match.group(0) + indent = " " + content = content.replace( + insert_after, + insert_after + f'\n{indent}system_prompt: "{jp_prompt}"' + ) + changes.append("system_prompt: added Japanese prompt") + + # --- 3. SenseVoice language → ja --- + # SenseVoice セクションに language: "ja" を追加 + if 'language:' in content and 'SenseVoice' in content: + # 既に language がある場合、値を "ja" に変更 + lang_pattern = r'(language:\s*["\'])([^"\']*?)(["\'])' + lang_match = re.search(lang_pattern, content) + if lang_match and lang_match.group(2) != "ja": + content = re.sub(lang_pattern, r'\g<1>ja\g<3>', content) + changes.append(f"ASR language: {lang_match.group(2)} → ja") + else: + changes.append("ASR language: already ja") + else: + # SenseVoice セクションの model_name 行の後に追加 + sv_pattern = r'(SenseVoice:.*?model_name:\s*[^\n]+)' + sv_match = re.search(sv_pattern, content, re.DOTALL) + if sv_match: + insert_after = sv_match.group(0) + # model_name 行のインデントを取得 + model_line = re.search(r'(\s+)model_name:', insert_after) + indent = model_line.group(1) if model_line else " " + content = content.replace( + insert_after, + insert_after + f'\n{indent}language: "ja"' + ) + changes.append("ASR language: added ja") + else: + changes.append("[WARN] SenseVoice section not found") + + # --- 結果表示 --- + if not changes: + print(" No changes needed") + return True + + print(" Changes:") + for c in changes: + print(f" - {c}") + + if content == original: + print(" [SKIP] Already configured for Japanese") + return True + + if dry_run: + print("\n [DRY RUN] No files modified") + return True + + # バックアップ + backup = config_path.with_suffix(".yaml.bak") + if not backup.exists(): + shutil.copy2(config_path, backup) + print(f" Backup: {backup}") + + config_path.write_text(content, encoding="utf-8") + print(f" [SAVED] {config_path}") + return True + + +def main(): + print("=" * 60) + print("Config Japanese Patch") + print("config/chat_with_lam.yaml を日本語対応に変更") + print("=" * 60) + + dry_run = "--dry-run" in sys.argv + + oac_dir = find_oac_dir() + if not oac_dir: + print("ERROR: OpenAvatarChat directory not found") + sys.exit(1) + + print(f"OAC: {oac_dir}") + print(f"Mode: {'DRY RUN' if dry_run else 'APPLY'}\n") + + ok = patch_config(oac_dir, dry_run) + + print(f"\n{'=' * 60}") + if ok: + print("Done!") + print(f"\nNext:") + print(f" python tests/a2e_japanese/patch_asr_language.py") + print(f" python src/demo.py --config config/chat_with_lam.yaml") + else: + print("Failed. Please edit config/chat_with_lam.yaml manually.") + + +if __name__ == "__main__": + main() diff --git a/tests/a2e_japanese/patch_llm_handler.py b/tests/a2e_japanese/patch_llm_handler.py new file mode 100644 index 0000000..b6bd7e4 --- /dev/null +++ b/tests/a2e_japanese/patch_llm_handler.py @@ -0,0 +1,290 @@ +""" +LLM Handler (OpenAI Compatible) 修正パッチ + +問題: + Gemini API の OpenAI互換エンドポイントが delta.content を + 文字列ではなく dict や list で返すことがある。 + これにより set_main_data() → np.array(data, dtype=np.float32) で + TypeError: float() argument must be a string or a real number, not 'dict' + が発生する。 + +エラー: + File "llm_handler_openai_compatible.py", line 167, in handle + output.set_main_data(output_text) + ... + TypeError: float() argument must be a string or a real number, not 'dict' + +修正: + output_text が dict/list の場合に文字列を正しく抽出する。 + +使い方: + cd C:\\Users\\hamad\\OpenAvatarChat + python tests/a2e_japanese/patch_llm_handler.py + + または --dry-run で変更内容だけ確認: + python tests/a2e_japanese/patch_llm_handler.py --dry-run +""" + +import re +import shutil +import sys +from pathlib import Path + + +def find_oac_dir() -> Path: + """OpenAvatarChat ディレクトリを自動検出""" + candidates = [ + Path(r"C:\Users\hamad\OpenAvatarChat"), + Path.home() / "OpenAvatarChat", + Path.cwd(), + ] + for p in candidates: + if (p / "src" / "handlers").exists(): + return p + return None + + +def patch_llm_handler(oac_dir: Path, dry_run: bool = False) -> bool: + """LLMハンドラーにGemini dict対応パッチを適用""" + handler_path = (oac_dir / "src" / "handlers" / "llm" / + "openai_compatible" / "llm_handler_openai_compatible.py") + + if not handler_path.exists(): + print(f" [ERROR] File not found: {handler_path}") + return False + + content = handler_path.read_text(encoding="utf-8") + lines = content.splitlines() + + # --- 修正1: output_text の dict/list 安全変換 --- + # パターン: output.set_main_data(output_text) の直前に型チェックを挿入 + # + # Gemini API の OpenAI互換エンドポイントは delta.content を + # 以下のいずれかの形式で返す可能性がある: + # (a) str: "こんにちは" ← 正常 + # (b) dict: {"type": "text", "text": "こんにちは"} + # (c) list: [{"type": "text", "text": "こんにちは"}] + # (d) None ← ストリームの最初/最後のチャンク + + # 既にパッチ済みか確認 + if "# [PATCH] Gemini dict content fix" in content: + print(" [ALREADY] LLM handler already patched") + return True + + # set_main_data(output_text) を含む行を探す + target_line_idx = None + for i, line in enumerate(lines): + if "set_main_data(output_text)" in line: + target_line_idx = i + break + + if target_line_idx is None: + # 別パターン: set_main_data(text) など + for i, line in enumerate(lines): + if re.search(r'set_main_data\(\s*\w*text\w*\s*\)', line): + target_line_idx = i + break + + if target_line_idx is None: + print(" [WARN] Could not find set_main_data(output_text) line") + print(" Manual patching required (see below)") + print_manual_guide() + return False + + # インデント検出 + target_line = lines[target_line_idx] + indent = len(target_line) - len(target_line.lstrip()) + indent_str = target_line[:indent] + + # output_text 変数名を検出 + match = re.search(r'set_main_data\((\w+)\)', target_line) + if not match: + print(" [WARN] Cannot parse variable name from set_main_data call") + print_manual_guide() + return False + var_name = match.group(1) + + # パッチ内容: set_main_data の前に安全変換を挿入 + patch_lines = [ + f"{indent_str}# [PATCH] Gemini dict content fix", + f"{indent_str}if isinstance({var_name}, dict):", + f"{indent_str} {var_name} = {var_name}.get('text', '') or {var_name}.get('content', '') or str({var_name})", + f"{indent_str}elif isinstance({var_name}, list):", + f"{indent_str} {var_name} = ''.join(", + f"{indent_str} part.get('text', '') if isinstance(part, dict) else str(part)", + f"{indent_str} for part in {var_name}", + f"{indent_str} )", + f"{indent_str}elif {var_name} is None:", + f"{indent_str} {var_name} = ''", + f"{indent_str}elif not isinstance({var_name}, str):", + f"{indent_str} {var_name} = str({var_name})", + ] + + print(f" Target: line {target_line_idx + 1}: {target_line.strip()}") + print(f" Variable: {var_name}") + print(f" Inserting {len(patch_lines)} lines of type-safety check before set_main_data") + + if dry_run: + print("\n --- Patch preview ---") + for pl in patch_lines: + print(f" + {pl}") + print(f" {target_line}") + print(" --- End preview ---") + return True + + # バックアップ + backup_path = handler_path.with_suffix(".py.bak") + if not backup_path.exists(): + shutil.copy2(handler_path, backup_path) + print(f" Backup: {backup_path}") + + # パッチ適用 + new_lines = lines[:target_line_idx] + patch_lines + lines[target_line_idx:] + new_content = "\n".join(new_lines) + if content.endswith("\n"): + new_content += "\n" + + handler_path.write_text(new_content, encoding="utf-8") + print(f" [APPLIED] Gemini dict content fix") + return True + + +def patch_llm_skip_empty_text(oac_dir: Path, dry_run: bool = False) -> bool: + """空文字列の set_main_data をスキップするパッチ""" + handler_path = (oac_dir / "src" / "handlers" / "llm" / + "openai_compatible" / "llm_handler_openai_compatible.py") + + if not handler_path.exists(): + return False + + content = handler_path.read_text(encoding="utf-8") + + # 既にパッチ済みか確認 + if "# [PATCH] Skip empty text" in content: + print(" [ALREADY] Skip-empty-text already patched") + return True + + lines = content.splitlines() + + # set_main_data 行を探す + for i, line in enumerate(lines): + if "set_main_data(" in line and ("text" in line.lower() or "output" in line.lower()): + indent = len(line) - len(line.lstrip()) + indent_str = line[:indent] + + match = re.search(r'set_main_data\((\w+)\)', line) + if not match: + continue + var_name = match.group(1) + + # set_main_data の前にガードを挿入 + guard_lines = [ + f"{indent_str}# [PATCH] Skip empty text", + f"{indent_str}if not {var_name}:", + f"{indent_str} continue", + ] + + # 既に Gemini dict fix パッチがある場合、その後に挿入 + # (dict fix パッチは set_main_data の直前にある) + insert_idx = i + # Gemini dict fix パッチの後ろを探す + for j in range(max(0, i - 15), i): + if "# [PATCH] Gemini dict content fix" in lines[j]: + # dict fix パッチの最後の行の直後に挿入 + for k in range(j + 1, i): + if not lines[k].strip().startswith(("if ", "elif ", var_name, "part.", "for ")): + if lines[k].strip() and not lines[k].strip().startswith(")"): + insert_idx = k + break + break + + if dry_run: + print(f"\n --- Skip-empty-text patch preview (before line {insert_idx + 1}) ---") + for gl in guard_lines: + print(f" + {gl}") + print(" --- End preview ---") + return True + + new_lines = lines[:insert_idx] + guard_lines + lines[insert_idx:] + new_content = "\n".join(new_lines) + if content.endswith("\n"): + new_content += "\n" + + handler_path.write_text(new_content, encoding="utf-8") + print(f" [APPLIED] Skip empty text guard") + return True + + print(" [SKIP] Could not find set_main_data for skip-empty patch") + return True + + +def print_manual_guide(): + """手動修正ガイドを表示""" + print(""" +=== 手動修正ガイド === + +ファイル: src/handlers/llm/openai_compatible/llm_handler_openai_compatible.py + +output.set_main_data(output_text) の直前に以下を追加: + + # [PATCH] Gemini dict content fix + if isinstance(output_text, dict): + output_text = output_text.get('text', '') or output_text.get('content', '') or str(output_text) + elif isinstance(output_text, list): + output_text = ''.join( + part.get('text', '') if isinstance(part, dict) else str(part) + for part in output_text + ) + elif output_text is None: + output_text = '' + elif not isinstance(output_text, str): + output_text = str(output_text) + # [PATCH] Skip empty text + if not output_text: + continue +""") + + +def main(): + print("=" * 60) + print("LLM Handler Patch Tool (Gemini dict content fix)") + print("=" * 60) + + dry_run = "--dry-run" in sys.argv + + oac_dir = find_oac_dir() + if oac_dir is None: + print("ERROR: OpenAvatarChat directory not found") + print("Run from the OpenAvatarChat directory") + sys.exit(1) + + print(f"OAC: {oac_dir}") + print(f"Mode: {'DRY RUN' if dry_run else 'APPLY PATCHES'}") + print() + + print("[1/2] Gemini dict content fix:") + ok1 = patch_llm_handler(oac_dir, dry_run=dry_run) + + print(f"\n[2/2] Skip empty text guard:") + ok2 = patch_llm_skip_empty_text(oac_dir, dry_run=dry_run) + + print(f"\n{'=' * 60}") + if ok1 and ok2: + print("All patches applied successfully!") + else: + print("Some patches could not be applied. See manual guide:") + print_manual_guide() + + if not dry_run: + print(f"\nBackup files: *.py.bak") + print(f"To revert: rename .bak files back to originals") + + print(f"\nNext: Restart OpenAvatarChat:") + print(f" python src/demo.py --config config/chat_with_lam_jp.yaml") + + +if __name__ == "__main__": + if "--help" in sys.argv or "-h" in sys.argv: + print_manual_guide() + else: + main() diff --git a/tests/a2e_japanese/patch_vad_handler.py b/tests/a2e_japanese/patch_vad_handler.py new file mode 100644 index 0000000..de8865d --- /dev/null +++ b/tests/a2e_japanese/patch_vad_handler.py @@ -0,0 +1,266 @@ +""" +VAD ハンドラー修正パッチ + +RuntimeError: Input data type is not supported. +の原因を特定・修正するためのパッチ。 + +使い方(2通り): + +方法A: 直接適用(推奨) + vad_handler_silero.py を直接編集する。 + このスクリプトの「修正内容」セクションを参照。 + +方法B: モンキーパッチ(デバッグ用) + OpenAvatarChatの起動前に以下を実行: + cd C:\\Users\\hamad\\OpenAvatarChat + python tests/a2e_japanese/patch_vad_handler.py + +修正内容: + 1. timestamp[0] の NoneType エラー修正 + 2. ONNX入力の防御的 numpy 変換 + 3. エラー発生時の詳細ログ追加 + 4. SenseVoice の dtype 不一致修正 +""" + +import os +import re +import shutil +import sys +from pathlib import Path + + +# ============================================================ +# 修正1: vad_handler_silero.py の handle() メソッド +# ============================================================ + +VAD_HANDLER_PATCHES = [ + { + "description": "Fix timestamp[0] NoneType crash", + "file": "src/handlers/vad/silerovad/vad_handler_silero.py", + "find": " context.slice_context.update_start_id(timestamp[0], force_update=False)", + "replace": """ if timestamp is not None: + context.slice_context.update_start_id(timestamp[0], force_update=False) + else: + context.slice_context.update_start_id(0, force_update=False)""", + }, + { + "description": "Add defensive numpy conversion in _inference", + "file": "src/handlers/vad/silerovad/vad_handler_silero.py", + "find": """ def _inference(self, context: HumanAudioVADContext, clip: np.ndarray, sr: int=16000): + clip = clip.squeeze() + if clip.ndim != 1: + logger.warning("Input audio should be 1-dim array") + return 0 + clip = np.expand_dims(clip, axis=0) + inputs = { + "input": clip, + "sr": np.array([sr], dtype=np.int64), + "state": context.model_state + } + prob, state = self.model.run(None, inputs) + context.model_state = state + return prob[0][0]""", + "replace": """ def _inference(self, context: HumanAudioVADContext, clip: np.ndarray, sr: int=16000): + # Ensure clip is a numpy array (defensive check) + if not isinstance(clip, np.ndarray): + logger.warning(f"VAD input clip is {type(clip).__name__}, converting to numpy") + clip = np.array(clip, dtype=np.float32) + clip = clip.squeeze() + if clip.ndim != 1: + logger.warning("Input audio should be 1-dim array") + return 0 + clip = np.expand_dims(clip, axis=0).astype(np.float32) + # Ensure model_state is a numpy array (defensive check) + if context.model_state is None: + context.model_state = np.zeros((2, 1, 128), dtype=np.float32) + elif not isinstance(context.model_state, np.ndarray): + logger.warning(f"VAD model_state is {type(context.model_state).__name__}, converting to numpy") + context.model_state = np.array(context.model_state, dtype=np.float32) + inputs = { + "input": clip, + "sr": np.array([sr], dtype=np.int64), + "state": context.model_state + } + try: + ort_outputs = self.model.run(None, inputs) + if len(ort_outputs) == 2: + prob, state = ort_outputs + elif len(ort_outputs) == 3: + # Silero VAD v5 may have 3 outputs: prob, hn, cn + prob = ort_outputs[0] + state = np.stack([ort_outputs[1], ort_outputs[2]]) + else: + prob = ort_outputs[0] + state = context.model_state # keep current state + # Ensure state remains a numpy array + if not isinstance(state, np.ndarray): + state = np.array(state, dtype=np.float32) + context.model_state = state + return prob.flatten()[0] + except RuntimeError as e: + logger.error(f"ONNX RuntimeError in VAD: {e}") + logger.error(f" input type={type(clip).__name__}, dtype={clip.dtype}, shape={clip.shape}") + logger.error(f" state type={type(context.model_state).__name__}") + if isinstance(context.model_state, np.ndarray): + logger.error(f" state dtype={context.model_state.dtype}, shape={context.model_state.shape}") + # Reset state and return 0 (no speech) to avoid crash loop + context.model_state = np.zeros((2, 1, 128), dtype=np.float32) + return 0""", + }, +] + +# ============================================================ +# 修正2: asr_handler_sensevoice.py の dtype 修正 +# ============================================================ + +ASR_HANDLER_PATCHES = [ + { + "description": "Fix np.zeros dtype mismatch in SenseVoice handler", + "file": "src/handlers/asr/sensevoice/asr_handler_sensevoice.py", + "find": " remainder_audio = np.concatenate(\n [remainder_audio,\n np.zeros(shape=(context.audio_slice_context.slice_size - remainder_audio.shape[0]))])", + "replace": " remainder_audio = np.concatenate(\n [remainder_audio,\n np.zeros(shape=(context.audio_slice_context.slice_size - remainder_audio.shape[0]),\n dtype=remainder_audio.dtype)])", + }, +] + + +def apply_patches(oac_dir: Path, patches: list, dry_run: bool = False) -> int: + """パッチを適用する""" + applied = 0 + + for patch in patches: + filepath = oac_dir / patch["file"] + if not filepath.exists(): + print(f" [SKIP] {patch['file']} not found") + continue + + content = filepath.read_text(encoding="utf-8") + + if patch["find"] not in content: + if patch["replace"] in content: + print(f" [ALREADY] {patch['description']}") + applied += 1 + continue + else: + print(f" [WARN] Cannot find target text for: {patch['description']}") + print(f" File may have been modified. Manual patching required.") + continue + + if dry_run: + print(f" [DRY-RUN] Would apply: {patch['description']}") + applied += 1 + continue + + # バックアップ作成 + backup_path = filepath.with_suffix(filepath.suffix + ".bak") + if not backup_path.exists(): + shutil.copy2(filepath, backup_path) + print(f" Backup: {backup_path}") + + # パッチ適用 + new_content = content.replace(patch["find"], patch["replace"], 1) + filepath.write_text(new_content, encoding="utf-8") + print(f" [APPLIED] {patch['description']}") + applied += 1 + + return applied + + +def main(): + print("=" * 60) + print("VAD Handler Patch Tool") + print("=" * 60) + + # OACディレクトリ解決 + if len(sys.argv) > 1 and sys.argv[1] == "--dry-run": + dry_run = True + else: + dry_run = False + + oac_dir = None + for candidate in [ + Path(r"C:\Users\hamad\OpenAvatarChat"), + Path.home() / "OpenAvatarChat", + Path.cwd(), + ]: + if (candidate / "src" / "handlers").exists(): + oac_dir = candidate + break + + if oac_dir is None: + print("ERROR: OpenAvatarChat directory not found") + print("Run from the OpenAvatarChat directory or specify path") + sys.exit(1) + + print(f"OAC: {oac_dir}") + if dry_run: + print("Mode: DRY RUN (no changes will be made)") + else: + print("Mode: APPLY PATCHES") + print() + + # VAD handler patches + print("[1/2] VAD Handler Patches:") + vad_applied = apply_patches(oac_dir, VAD_HANDLER_PATCHES, dry_run=dry_run) + + # ASR handler patches + print(f"\n[2/2] ASR Handler Patches:") + asr_applied = apply_patches(oac_dir, ASR_HANDLER_PATCHES, dry_run=dry_run) + + total = vad_applied + asr_applied + print(f"\n{'=' * 60}") + print(f"Applied {total} patch(es)") + + if not dry_run and total > 0: + print(f"\nBackup files created with .bak extension.") + print(f"To revert: rename .bak files back to originals.") + + print(f"\nNext: Restart OpenAvatarChat and test voice input:") + print(f" python src/demo.py --config config/chat_with_lam_jp.yaml") + + +# ============================================================ +# 手動修正ガイド(コピペ用) +# ============================================================ + +MANUAL_FIX_GUIDE = """ +=== 手動修正ガイド === + +もしパッチスクリプトが動かない場合、以下を手動で修正: + +【ファイル1】 src/handlers/vad/silerovad/vad_handler_silero.py + +修正箇所A: handle() メソッド内の timestamp[0] 修正 +--- 修正前 --- + context.slice_context.update_start_id(timestamp[0], force_update=False) +--- 修正後 --- + if timestamp is not None: + context.slice_context.update_start_id(timestamp[0], force_update=False) + else: + context.slice_context.update_start_id(0, force_update=False) + +修正箇所B: _inference() メソッドの防御的チェック追加 +--- _inference の先頭に追加 --- + if not isinstance(clip, np.ndarray): + clip = np.array(clip, dtype=np.float32) +--- model_state チェック追加(inputs = { の前に追加) --- + if context.model_state is None: + context.model_state = np.zeros((2, 1, 128), dtype=np.float32) + elif not isinstance(context.model_state, np.ndarray): + context.model_state = np.array(context.model_state, dtype=np.float32) + +【ファイル2】 src/handlers/asr/sensevoice/asr_handler_sensevoice.py + +修正箇所: np.zeros に dtype 追加 +--- 修正前 --- + np.zeros(shape=(context.audio_slice_context.slice_size - remainder_audio.shape[0]))]) +--- 修正後 --- + np.zeros(shape=(context.audio_slice_context.slice_size - remainder_audio.shape[0]), + dtype=remainder_audio.dtype)]) +""" + + +if __name__ == "__main__": + if "--help" in sys.argv or "-h" in sys.argv: + print(MANUAL_FIX_GUIDE) + else: + main() diff --git a/tests/a2e_japanese/run_all_tests.py b/tests/a2e_japanese/run_all_tests.py new file mode 100644 index 0000000..be008b1 --- /dev/null +++ b/tests/a2e_japanese/run_all_tests.py @@ -0,0 +1,148 @@ +""" +A2E + 日本語音声テスト: マスターテストランナー + +全テストを順番に実行: + Step 0: 環境チェック (setup_oac_env.py) + Step 1: テスト音声生成 (generate_test_audio.py) + Step 2: A2Eテスト (test_a2e_cpu.py) + Step 3: ブレンドシェイプ分析 (analyze_blendshapes.py) ※推論結果がある場合 + +使い方: + cd C:\Users\hamad\OpenAvatarChat + conda activate oac + python tests/a2e_japanese/run_all_tests.py + + または: + python tests/a2e_japanese/run_all_tests.py --oac-dir C:\Users\hamad\OpenAvatarChat +""" + +import argparse +import os +import subprocess +import sys +import time +from pathlib import Path + + +def run_step(step_name: str, script_path: str, extra_args: list = None): + """テストステップを実行""" + print(f"\n{'#' * 60}") + print(f"# {step_name}") + print(f"{'#' * 60}\n") + + if not os.path.exists(script_path): + print(f" ERROR: Script not found: {script_path}") + return False + + cmd = [sys.executable, script_path] + (extra_args or []) + t0 = time.time() + + try: + result = subprocess.run(cmd, timeout=300) + elapsed = time.time() - t0 + success = result.returncode == 0 + status = "PASSED" if success else "FAILED" + print(f"\n [{status}] {step_name} ({elapsed:.1f}s)") + return success + except subprocess.TimeoutExpired: + print(f"\n [TIMEOUT] {step_name} (>300s)") + return False + except Exception as e: + print(f"\n [ERROR] {step_name}: {e}") + return False + + +def main(): + parser = argparse.ArgumentParser(description="A2E Japanese Audio Test Runner") + parser.add_argument("--oac-dir", type=str, default=None, + help="Path to OpenAvatarChat directory") + parser.add_argument("--skip-env-check", action="store_true", + help="Skip environment check") + parser.add_argument("--skip-audio-gen", action="store_true", + help="Skip audio generation (use existing)") + args = parser.parse_args() + + script_dir = Path(__file__).parent + oac_args = ["--oac-dir", args.oac_dir] if args.oac_dir else [] + + print("=" * 60) + print("A2E + Japanese Audio Test Suite - Master Runner") + print(f"Time: {time.strftime('%Y-%m-%d %H:%M:%S')}") + print("=" * 60) + + results = {} + + # Step 0: 環境チェック + if not args.skip_env_check: + results["env_check"] = run_step( + "Step 0: Environment Check", + str(script_dir / "setup_oac_env.py"), + oac_args, + ) + else: + print("\n [SKIP] Environment check") + results["env_check"] = True + + # Step 1: テスト音声生成 + if not args.skip_audio_gen: + results["audio_gen"] = run_step( + "Step 1: Generate Test Audio", + str(script_dir / "generate_test_audio.py"), + ) + else: + print("\n [SKIP] Audio generation") + results["audio_gen"] = True + + # Step 2: A2Eテスト + results["a2e_test"] = run_step( + "Step 2: A2E Inference Test", + str(script_dir / "test_a2e_cpu.py"), + oac_args, + ) + + # Step 3: ブレンドシェイプ分析 + output_dir = script_dir / "blendshape_outputs" + if output_dir.exists() and list(output_dir.glob("*.npy")): + results["analysis"] = run_step( + "Step 3: Blendshape Analysis", + str(script_dir / "analyze_blendshapes.py"), + ["--input-dir", str(output_dir), "--export-csv", "--export-json"], + ) + else: + print(f"\n [SKIP] Step 3: No blendshape outputs in {output_dir}") + print(" Run full A2E inference and save outputs there first.") + results["analysis"] = None + + # サマリー + print("\n" + "=" * 60) + print("FINAL SUMMARY") + print("=" * 60) + + for name, passed in results.items(): + if passed is None: + status = "SKIP" + elif passed: + status = "PASS" + else: + status = "FAIL" + print(f" [{status}] {name}") + + failed = sum(1 for v in results.values() if v is False) + if failed: + print(f"\n {failed} step(s) failed.") + print("\n Troubleshooting:") + print(" 1. Run setup_oac_env.py to check environment") + print(" 2. Ensure all models are downloaded") + print(" 3. For GPU errors, patch infer.py: .cuda() -> .cpu()") + return 1 + else: + print("\n All steps completed!") + print("\n Next: Start OpenAvatarChat and test lip sync quality") + print(" cd C:\\Users\\hamad\\OpenAvatarChat") + print(" python src/demo.py --config config/chat_with_lam_jp.yaml") + print(" Open https://localhost:8282 and speak Japanese") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tests/a2e_japanese/save_a2e_output.py b/tests/a2e_japanese/save_a2e_output.py new file mode 100644 index 0000000..feacb49 --- /dev/null +++ b/tests/a2e_japanese/save_a2e_output.py @@ -0,0 +1,256 @@ +""" +A2E推論出力保存スクリプト + +OpenAvatarChat環境内でA2Eを直接呼び出し、 +日本語音声からブレンドシェイプ出力をnpyファイルに保存する。 + +このスクリプトはOpenAvatarChatのavatar_handler_lam_audio2expressionを +直接呼び出して、A2Eモデルの生出力をキャプチャする。 + +使い方: + cd C:\Users\hamad\OpenAvatarChat + conda activate oac + python tests/a2e_japanese/save_a2e_output.py --audio-dir tests/a2e_japanese/audio_samples + +出力: + tests/a2e_japanese/blendshape_outputs/ にnpyファイルが保存される +""" + +import argparse +import os +import sys +import time +import wave +from pathlib import Path + +import numpy as np + + +def load_wav_as_pcm(wav_path: str, target_sr: int = 24000) -> np.ndarray: + """WAVファイルをPCM float32配列として読み込み""" + with wave.open(wav_path, "r") as wf: + n_channels = wf.getnchannels() + sample_width = wf.getsampwidth() + frame_rate = wf.getframerate() + n_frames = wf.getnframes() + raw = wf.readframes(n_frames) + + if sample_width == 2: + audio = np.frombuffer(raw, dtype=np.int16).astype(np.float32) / 32768.0 + elif sample_width == 4: + audio = np.frombuffer(raw, dtype=np.int32).astype(np.float32) / 2147483648.0 + else: + raise ValueError(f"Unsupported sample width: {sample_width}") + + if n_channels > 1: + audio = audio.reshape(-1, n_channels).mean(axis=1) + + # リサンプリング + if frame_rate != target_sr: + duration = len(audio) / frame_rate + target_len = int(duration * target_sr) + indices = np.linspace(0, len(audio) - 1, target_len).astype(int) + audio = audio[indices] + + return audio + + +def try_direct_a2e_inference(oac_dir: Path, audio_path: str) -> np.ndarray: + """A2Eモデルを直接ロードして推論""" + # OpenAvatarChatのパスを追加 + paths = [ + str(oac_dir / "src"), + str(oac_dir / "src" / "handlers"), + str(oac_dir / "src" / "handlers" / "avatar" / "lam"), + str(oac_dir / "src" / "handlers" / "avatar" / "lam" / "LAM_Audio2Expression"), + ] + for p in paths: + if p not in sys.path: + sys.path.insert(0, p) + + import torch + + # Wav2Vec2で特徴量抽出 + from transformers import Wav2Vec2Model, Wav2Vec2Processor + + wav2vec_dir = oac_dir / "models" / "wav2vec2-base-960h" + if wav2vec_dir.exists() and (wav2vec_dir / "config.json").exists(): + model_name = str(wav2vec_dir) + else: + model_name = "facebook/wav2vec2-base-960h" + + print(f" Loading Wav2Vec2: {model_name}") + try: + processor = Wav2Vec2Processor.from_pretrained(model_name) + except Exception: + processor = Wav2Vec2Processor.from_pretrained("facebook/wav2vec2-base-960h") + + wav2vec_model = Wav2Vec2Model.from_pretrained(model_name) + wav2vec_model.eval() + + # 音声読み込み (Wav2Vec2は16kHz) + audio_16k = load_wav_as_pcm(audio_path, target_sr=16000) + print(f" Audio: {len(audio_16k)/16000:.2f}s at 16kHz") + + # 特徴量抽出 + inputs = processor(audio_16k, sampling_rate=16000, return_tensors="pt", padding=True) + with torch.no_grad(): + outputs = wav2vec_model(**inputs) + features = outputs.last_hidden_state # (1, T, 768) + print(f" Wav2Vec2 features: {features.shape}") + + # A2Eデコーダーのロード試行 + try: + from LAM_Audio2Expression.engines.infer import Audio2ExpressionInfer + from LAM_Audio2Expression.engines.defaults import default_setup + + # A2Eのconfigを構築 + # 注: 実際のconfig構造はLAM_Audio2Expressionの実装に依存 + print(" A2E module loaded. Attempting inference...") + + # A2E推論 (実装依存) + # result = a2e_infer(features) + # return result + + print(" NOTE: Direct A2E inference requires full config setup.") + print(" Falling back to Wav2Vec2 feature analysis.") + raise ImportError("Direct A2E not configured") + + except ImportError: + # A2Eデコーダーがロードできない場合、Wav2Vec2特徴量の分析を返す + print(" A2E decoder not available. Saving Wav2Vec2 features instead.") + print(" For full A2E output, run OpenAvatarChat and capture the output.") + return features.squeeze(0).numpy() # (T, 768) + + +def try_handler_inference(oac_dir: Path, audio_path: str) -> np.ndarray: + """OpenAvatarChatのhandler経由でA2E推論""" + paths = [ + str(oac_dir / "src"), + str(oac_dir / "src" / "handlers"), + ] + for p in paths: + if p not in sys.path: + sys.path.insert(0, p) + + try: + from avatar.lam.avatar_handler_lam_audio2expression import HandlerAvatarLAM + print(" HandlerAvatarLAM loaded.") + + # Handler config + class MockConfig: + model_name = "LAM_audio2exp" + feature_extractor_model_name = "wav2vec2-base-960h" + audio_sample_rate = 24000 + + class MockEngineConfig: + model_root = str(oac_dir / "models") + + handler = HandlerAvatarLAM() + handler.load(MockEngineConfig(), MockConfig()) + + # 音声をPCMとして読み込み + audio_24k = load_wav_as_pcm(audio_path, target_sr=24000) + audio_bytes = (audio_24k * 32768).astype(np.int16).tobytes() + + # handler.process() の出力をキャプチャ + # 注: 実際のAPIは HandlerAvatarLAM の実装に依存 + print(" NOTE: Handler API depends on OpenAvatarChat internals.") + print(" This may need adjustment based on the actual handler interface.") + + return None + + except ImportError as e: + print(f" Handler not available: {e}") + return None + except Exception as e: + print(f" Handler error: {e}") + return None + + +def main(): + parser = argparse.ArgumentParser(description="Save A2E Inference Output") + parser.add_argument("--oac-dir", type=str, default=None) + parser.add_argument("--audio-dir", type=str, default=None) + parser.add_argument("--audio-file", type=str, default=None, help="Single audio file") + args = parser.parse_args() + + script_dir = Path(__file__).parent + + # OACディレクトリ解決 + if args.oac_dir: + oac_dir = Path(args.oac_dir) + else: + candidates = [ + Path(r"C:\Users\hamad\OpenAvatarChat"), + Path.home() / "OpenAvatarChat", + Path.cwd(), + ] + oac_dir = next((p for p in candidates if (p / "src" / "demo.py").exists()), None) + if oac_dir is None: + print("ERROR: OpenAvatarChat not found. Use --oac-dir") + sys.exit(1) + + # 音声ファイル解決 + if args.audio_file: + audio_files = [Path(args.audio_file)] + elif args.audio_dir: + audio_files = sorted(Path(args.audio_dir).glob("*.wav")) + else: + audio_files = sorted((script_dir / "audio_samples").glob("*.wav")) + + if not audio_files: + print("ERROR: No WAV files found.") + print("Run generate_test_audio.py first.") + sys.exit(1) + + output_dir = script_dir / "blendshape_outputs" + os.makedirs(output_dir, exist_ok=True) + + print("=" * 60) + print("A2E Inference Output Capture") + print(f"OAC: {oac_dir}") + print(f"Audio files: {len(audio_files)}") + print(f"Output: {output_dir}") + print("=" * 60) + + for audio_path in audio_files: + name = audio_path.stem + output_path = output_dir / f"{name}.npy" + + if output_path.exists(): + print(f"\n[SKIP] {name}: output already exists") + continue + + print(f"\n[{name}] Processing: {audio_path}") + t0 = time.time() + + # 方法1: 直接A2E推論 + result = try_direct_a2e_inference(oac_dir, str(audio_path)) + + if result is None: + # 方法2: Handler経由 + result = try_handler_inference(oac_dir, str(audio_path)) + + if result is not None: + np.save(str(output_path), result) + elapsed = time.time() - t0 + print(f" Saved: {output_path} shape={result.shape} ({elapsed:.1f}s)") + else: + print(f" FAILED: Could not generate output for {name}") + + # サマリー + saved_files = list(output_dir.glob("*.npy")) + print(f"\n{'=' * 60}") + print(f"Saved {len(saved_files)} output files to {output_dir}") + for f in sorted(saved_files): + data = np.load(str(f)) + print(f" {f.name}: shape={data.shape}") + + if saved_files: + print(f"\nNext: Analyze with:") + print(f" python tests/a2e_japanese/analyze_blendshapes.py --input-dir {output_dir}") + + +if __name__ == "__main__": + main() diff --git a/tests/a2e_japanese/setup_oac_env.py b/tests/a2e_japanese/setup_oac_env.py new file mode 100644 index 0000000..4bb8f5e --- /dev/null +++ b/tests/a2e_japanese/setup_oac_env.py @@ -0,0 +1,406 @@ +""" +OpenAvatarChat 環境セットアップ & 既知問題自動修正スクリプト + +チャットログで判明した既知問題を自動的に検出・修正: + 1. chat_with_lam.yaml の構造 (handlers: → default: > chat_engine: > handler_configs:) + 2. infer.py の .cuda() → .cpu() (GPUなし環境) + 3. 不足パッケージのインストール + 4. モデルファイルの存在確認 + 5. SSL証明書の確認 + +使い方: + cd C:\Users\hamad\OpenAvatarChat + conda activate oac + python tests/a2e_japanese/setup_oac_env.py + + または: + python tests/a2e_japanese/setup_oac_env.py --oac-dir C:\Users\hamad\OpenAvatarChat +""" + +import argparse +import os +import re +import shutil +import subprocess +import sys +from pathlib import Path + + +class OACSetupChecker: + def __init__(self, oac_dir: Path): + self.oac_dir = oac_dir + self.issues = [] + self.fixes_applied = [] + + def check_all(self): + """全チェック実行""" + print("=" * 60) + print("OpenAvatarChat Environment Check") + print(f"Directory: {self.oac_dir}") + print("=" * 60) + + self._check_directory_structure() + self._check_python_packages() + self._check_models() + self._check_cuda_cpu() + self._check_config_yaml() + self._check_ssl_certs() + self._check_vad_handler_bugs() + self._check_llm_handler_bugs() + + print("\n" + "=" * 60) + print("RESULTS") + print("=" * 60) + if not self.issues: + print(" All checks passed! Environment is ready.") + else: + print(f" {len(self.issues)} issue(s) found:") + for i, issue in enumerate(self.issues, 1): + print(f" {i}. {issue}") + + if self.fixes_applied: + print(f"\n {len(self.fixes_applied)} fix(es) applied:") + for fix in self.fixes_applied: + print(f" - {fix}") + + return len(self.issues) == 0 + + def _check_directory_structure(self): + """基本ディレクトリ構造の確認""" + print("\n[1/6] Directory Structure") + required = [ + "src/demo.py", + "src/handlers/avatar/lam/avatar_handler_lam_audio2expression.py", + "src/handlers/avatar/lam/LAM_Audio2Expression/engines/infer.py", + "config/chat_with_lam.yaml", + ] + for rel_path in required: + full_path = self.oac_dir / rel_path + exists = full_path.exists() + status = "OK" if exists else "MISSING" + print(f" [{status}] {rel_path}") + if not exists: + self.issues.append(f"Missing: {rel_path}") + + def _check_python_packages(self): + """必要パッケージの確認""" + print("\n[2/6] Python Packages") + packages = { + "edge_tts": "edge-tts", + "addict": "addict", + "yapf": "yapf", + "regex": "regex", + "librosa": "librosa", + "transformers": "transformers", + "termcolor": "termcolor", + "torch": "torch", + "numpy": "numpy", + "omegaconf": "omegaconf", + } + missing = [] + for module_name, pip_name in packages.items(): + try: + __import__(module_name) + print(f" [OK] {module_name}") + except ImportError: + print(f" [MISSING] {module_name} (pip install {pip_name})") + missing.append(pip_name) + + if missing: + self.issues.append(f"Missing packages: {', '.join(missing)}") + print(f"\n Install all missing: pip install {' '.join(missing)}") + + def _check_models(self): + """モデルファイルの確認""" + print("\n[3/6] Model Files") + models_dir = self.oac_dir / "models" + + checks = { + "LAM_audio2exp checkpoint": [ + models_dir / "LAM_audio2exp" / "pretrained_models" / "lam_audio2exp_streaming.tar", + models_dir / "LAM_audio2exp" / "pretrained_models", + ], + "wav2vec2-base-960h": [ + models_dir / "wav2vec2-base-960h" / "pytorch_model.bin", + models_dir / "wav2vec2-base-960h" / "model.safetensors", + models_dir / "wav2vec2-base-960h" / "config.json", + ], + "SenseVoiceSmall": [ + models_dir / "iic" / "SenseVoiceSmall" / "model.pt", + ], + } + + for name, paths in checks.items(): + found = any(p.exists() for p in paths) + status = "OK" if found else "MISSING" + print(f" [{status}] {name}") + if not found: + self.issues.append(f"Missing model: {name}") + if "LAM_audio2exp" in name: + print(f" Download from HuggingFace: 3DAIGC/LAM_audio2exp") + elif "wav2vec2" in name: + print(f" Run: python -c \"from transformers import Wav2Vec2Model; " + f"m = Wav2Vec2Model.from_pretrained('facebook/wav2vec2-base-960h'); " + f"m.save_pretrained(r'{models_dir / 'wav2vec2-base-960h'}')\"") + + def _check_cuda_cpu(self): + """CUDA/CPU環境の確認とinfer.pyの修正""" + print("\n[4/6] CUDA/CPU Environment") + + try: + import torch + cuda_available = torch.cuda.is_available() + print(f" PyTorch: {torch.__version__}") + print(f" CUDA available: {cuda_available}") + except ImportError: + print(" [FAIL] PyTorch not installed") + self.issues.append("PyTorch not installed") + return + + if cuda_available: + print(f" CUDA version: {torch.version.cuda}") + print(" GPU mode: OK") + return + + # GPUなし → infer.pyの.cuda()を.cpu()に変更が必要 + print(" GPU not available. Checking infer.py for .cuda() calls...") + + infer_path = (self.oac_dir / "src" / "handlers" / "avatar" / "lam" / + "LAM_Audio2Expression" / "engines" / "infer.py") + + if not infer_path.exists(): + print(f" [SKIP] infer.py not found at {infer_path}") + return + + content = infer_path.read_text(encoding="utf-8") + cuda_calls = [ + (i + 1, line.strip()) + for i, line in enumerate(content.splitlines()) + if ".cuda()" in line and not line.strip().startswith("#") + ] + + if cuda_calls: + print(f" [WARN] Found {len(cuda_calls)} .cuda() calls in infer.py:") + for line_no, line in cuda_calls: + print(f" Line {line_no}: {line}") + self.issues.append(f"infer.py has {len(cuda_calls)} .cuda() calls (no GPU available)") + print("\n To fix, replace .cuda() with .cpu() in infer.py") + print(f" File: {infer_path}") + else: + print(" [OK] No .cuda() calls found (already patched or not needed)") + + def _check_config_yaml(self): + """chat_with_lam.yamlの構造確認""" + print("\n[5/6] Config YAML Structure") + + config_path = self.oac_dir / "config" / "chat_with_lam.yaml" + if not config_path.exists(): + print(f" [MISSING] {config_path}") + self.issues.append("chat_with_lam.yaml not found") + return + + try: + import yaml + with open(config_path, "r", encoding="utf-8") as f: + config = yaml.safe_load(f) + except Exception as e: + print(f" [FAIL] Cannot parse YAML: {e}") + self.issues.append(f"YAML parse error: {e}") + return + + # 構造チェック: default > chat_engine > handler_configs が正しい構造 + if "handlers" in config and "default" not in config: + print(" [FAIL] Wrong structure: 'handlers:' at root level") + print(" Should be: default > chat_engine > handler_configs") + self.issues.append("chat_with_lam.yaml has wrong structure (handlers: instead of default:)") + return + + handler_configs = (config.get("default", {}) + .get("chat_engine", {}) + .get("handler_configs", {})) + + if not handler_configs: + print(" [FAIL] No handler_configs found") + self.issues.append("No handler_configs in chat_with_lam.yaml") + return + + print(f" [OK] Structure: default > chat_engine > handler_configs") + print(f" Handlers: {', '.join(handler_configs.keys())}") + + # 各handlerのmoduleチェック + required_handlers = ["LamClient", "SileroVad", "SenseVoice", "LLMOpenAICompatible", "LAM_Driver"] + tts_handlers = ["Edge_TTS", "EdgeTTS"] + + for h in required_handlers: + if h in handler_configs: + print(f" [OK] {h}: {handler_configs[h].get('module', 'N/A')}") + else: + print(f" [MISSING] {h}") + self.issues.append(f"Missing handler: {h}") + + tts_found = any(h in handler_configs for h in tts_handlers) + if tts_found: + tts_name = next(h for h in tts_handlers if h in handler_configs) + voice = handler_configs[tts_name].get("voice", "N/A") + print(f" [OK] TTS ({tts_name}): voice={voice}") + else: + print(f" [MISSING] TTS handler (Edge_TTS or EdgeTTS)") + self.issues.append("Missing TTS handler") + + # LLM API設定 + llm_config = handler_configs.get("LLMOpenAICompatible", {}) + api_url = llm_config.get("api_url", "") + api_key = llm_config.get("api_key", "") + model = llm_config.get("model_name", "") + + if "gemini" in api_url.lower() or "gemini" in model.lower(): + print(f" [OK] LLM: Gemini API ({model})") + if not api_key or api_key == "YOUR_GEMINI_API_KEY": + print(f" [WARN] API key not set!") + self.issues.append("Gemini API key not configured") + elif "dashscope" in api_url.lower(): + print(f" [WARN] LLM: DashScope (may not work outside China)") + else: + print(f" [INFO] LLM: {api_url} ({model})") + + def _check_ssl_certs(self): + """SSL証明書の確認(WebRTCに必要)""" + print("\n[6/6] SSL Certificates (for WebRTC)") + + cert_file = self.oac_dir / "ssl_certs" / "localhost.crt" + key_file = self.oac_dir / "ssl_certs" / "localhost.key" + + if cert_file.exists() and key_file.exists(): + print(f" [OK] SSL certificates found") + else: + print(f" [WARN] SSL certificates not found") + print(f" WebRTC requires HTTPS. For localhost testing:") + print(f" mkdir ssl_certs") + print(f" openssl req -x509 -newkey rsa:2048 -keyout ssl_certs/localhost.key \\") + print(f" -out ssl_certs/localhost.crt -days 365 -nodes \\") + print(f" -subj '/CN=localhost'") + print(f" Or use mkcert: mkcert -install && mkcert localhost") + # SSLは必須ではない(localhost HTTPでもマイク動く場合あり) + # self.issues.append("SSL certificates missing") + + + def _check_vad_handler_bugs(self): + """VADハンドラーの既知バグ確認""" + print("\n[7/7] VAD Handler Known Bugs") + + vad_path = (self.oac_dir / "src" / "handlers" / "vad" / "silerovad" / + "vad_handler_silero.py") + + if not vad_path.exists(): + print(f" [SKIP] VAD handler not found") + return + + content = vad_path.read_text(encoding="utf-8") + + # Bug 1: timestamp[0] NoneType crash + if ("context.slice_context.update_start_id(timestamp[0]" in content + and "if timestamp is not None" not in content): + print(" [BUG] timestamp[0] NoneType crash detected!") + print(" When audio arrives without valid timestamp,") + print(" timestamp[0] crashes with TypeError.") + print(" FIX: Apply patch_vad_handler.py") + self.issues.append("VAD handler: timestamp[0] NoneType bug") + else: + print(" [OK] timestamp null check") + + # Bug 2: No defensive type check on ONNX inputs + if ("isinstance(clip, np.ndarray)" not in content + and "isinstance(context.model_state" not in content): + print(" [WARN] No defensive type checking on ONNX inputs") + print(" If upstream data is not numpy, ONNX will crash with:") + print(" RuntimeError: Input data type is not supported.") + print(" FIX: Apply patch_vad_handler.py") + self.issues.append("VAD handler: missing ONNX input type validation") + else: + print(" [OK] ONNX input type checking") + + # Check SenseVoice handler + asr_path = (self.oac_dir / "src" / "handlers" / "asr" / "sensevoice" / + "asr_handler_sensevoice.py") + + if asr_path.exists(): + asr_content = asr_path.read_text(encoding="utf-8") + if "np.zeros(shape=" in asr_content and "dtype=remainder_audio.dtype" not in asr_content: + print(" [WARN] SenseVoice np.zeros dtype mismatch") + print(" np.zeros without dtype creates float64, audio is float32") + self.issues.append("SenseVoice handler: np.zeros dtype mismatch") + else: + print(" [OK] SenseVoice dtype handling") + + # Check SileroVAD ONNX model + model_candidates = list(self.oac_dir.rglob("silero_vad.onnx")) + if model_candidates: + print(f" [OK] SileroVAD ONNX model found: {model_candidates[0]}") + try: + import onnxruntime + print(f" [OK] onnxruntime {onnxruntime.__version__}") + except ImportError: + print(" [FAIL] onnxruntime not installed") + self.issues.append("onnxruntime not installed") + else: + print(" [WARN] silero_vad.onnx not found") + self.issues.append("SileroVAD ONNX model not found") + + + def _check_llm_handler_bugs(self): + """LLMハンドラーの既知バグ確認 (Gemini dict content)""" + print("\n[8/8] LLM Handler Known Bugs") + + llm_path = (self.oac_dir / "src" / "handlers" / "llm" / + "openai_compatible" / "llm_handler_openai_compatible.py") + + if not llm_path.exists(): + print(f" [SKIP] LLM handler not found") + return + + content = llm_path.read_text(encoding="utf-8") + + # Bug: Gemini API returns delta.content as dict instead of str + # This causes: TypeError: float() argument must be a string or + # a real number, not 'dict' + if ("set_main_data(" in content + and "# [PATCH] Gemini dict content fix" not in content): + print(" [BUG] Gemini dict content not handled!") + print(" Gemini OpenAI-compatible API may return delta.content") + print(" as dict/list instead of str, causing TypeError.") + print(" FIX: python tests/a2e_japanese/patch_llm_handler.py") + self.issues.append("LLM handler: Gemini dict content bug") + else: + print(" [OK] Gemini dict content handling") + + +def main(): + parser = argparse.ArgumentParser(description="OpenAvatarChat Environment Setup Checker") + parser.add_argument("--oac-dir", type=str, default=None, + help="Path to OpenAvatarChat directory") + parser.add_argument("--fix", action="store_true", + help="Attempt to auto-fix issues") + args = parser.parse_args() + + if args.oac_dir: + oac_dir = Path(args.oac_dir) + else: + # 自動検出 + candidates = [ + Path(r"C:\Users\hamad\OpenAvatarChat"), + Path.home() / "OpenAvatarChat", + Path.cwd(), + ] + oac_dir = next((p for p in candidates if (p / "src" / "demo.py").exists()), None) + if oac_dir is None: + print("ERROR: OpenAvatarChat directory not found.") + print("Use --oac-dir to specify the path.") + sys.exit(1) + + checker = OACSetupChecker(oac_dir) + ok = checker.check_all() + sys.exit(0 if ok else 1) + + +if __name__ == "__main__": + main() diff --git a/tests/a2e_japanese/test_a2e_cpu.py b/tests/a2e_japanese/test_a2e_cpu.py new file mode 100644 index 0000000..4ae70d5 --- /dev/null +++ b/tests/a2e_japanese/test_a2e_cpu.py @@ -0,0 +1,559 @@ +""" +A2E (Audio2Expression) 日本語音声テスト - CPU版 + +LAM Audio2Expression モデルをCPU上でロードし、 +日本語音声から52次元ARKitブレンドシェイプを生成してテスト。 + +前提条件: + - OpenAvatarChat が C:\Users\hamad\OpenAvatarChat にインストール済み + - models/LAM_audio2exp/pretrained_models/lam_audio2exp_streaming.tar ダウンロード済み + - models/wav2vec2-base-960h ダウンロード済み + - infer.py の .cuda() → .cpu() 変更済み + +使い方: + cd C:\Users\hamad\OpenAvatarChat + conda activate oac + python -m tests.a2e_japanese.test_a2e_cpu + + または: + python tests/a2e_japanese/test_a2e_cpu.py --oac-dir C:\Users\hamad\OpenAvatarChat +""" + +import argparse +import json +import os +import sys +import time +import wave +from pathlib import Path + +import numpy as np + +# ARKit 52 ブレンドシェイプ名(Apple公式仕様) +ARKIT_BLENDSHAPE_NAMES = [ + "eyeBlinkLeft", "eyeLookDownLeft", "eyeLookInLeft", "eyeLookOutLeft", + "eyeLookUpLeft", "eyeSquintLeft", "eyeWideLeft", + "eyeBlinkRight", "eyeLookDownRight", "eyeLookInRight", "eyeLookOutRight", + "eyeLookUpRight", "eyeSquintRight", "eyeWideRight", + "jawForward", "jawLeft", "jawRight", "jawOpen", + "mouthClose", "mouthFunnel", "mouthPucker", "mouthLeft", "mouthRight", + "mouthSmileLeft", "mouthSmileRight", "mouthFrownLeft", "mouthFrownRight", + "mouthDimpleLeft", "mouthDimpleRight", "mouthStretchLeft", "mouthStretchRight", + "mouthRollLower", "mouthRollUpper", "mouthShrugLower", "mouthShrugUpper", + "mouthPressLeft", "mouthPressRight", "mouthLowerDownLeft", "mouthLowerDownRight", + "mouthUpperUpLeft", "mouthUpperUpRight", + "browDownLeft", "browDownRight", "browInnerUp", "browOuterUpLeft", "browOuterUpRight", + "cheekPuff", "cheekSquintLeft", "cheekSquintRight", + "noseSneerLeft", "noseSneerRight", + "tongueOut", +] + +# 日本語母音に対応するARKitブレンドシェイプの期待パターン +# A2Eが正しく動作していれば、これらのブレンドシェイプが活性化するはず +JAPANESE_VOWEL_EXPECTED = { + "あ(a)": {"jawOpen": "high", "mouthFunnel": "low"}, + "い(i)": {"jawOpen": "low", "mouthSmileLeft": "mid", "mouthSmileRight": "mid"}, + "う(u)": {"jawOpen": "low", "mouthPucker": "mid", "mouthFunnel": "mid"}, + "え(e)": {"jawOpen": "mid", "mouthSmileLeft": "low", "mouthSmileRight": "low"}, + "お(o)": {"jawOpen": "mid", "mouthFunnel": "mid"}, +} + +# リップシンクに関連するブレンドシェイプのインデックス +LIP_RELATED_INDICES = [ + i for i, name in enumerate(ARKIT_BLENDSHAPE_NAMES) + if name.startswith(("jaw", "mouth", "tongue", "cheekPuff")) +] + +LIP_RELATED_NAMES = [ARKIT_BLENDSHAPE_NAMES[i] for i in LIP_RELATED_INDICES] + + +def find_oac_dir() -> Path: + """OpenAvatarChatのディレクトリを探す""" + candidates = [ + Path(r"C:\Users\hamad\OpenAvatarChat"), + Path.home() / "OpenAvatarChat", + Path.cwd(), + ] + for p in candidates: + if (p / "src" / "handlers" / "avatar" / "lam").exists(): + return p + return None + + +def setup_python_path(oac_dir: Path): + """OpenAvatarChatのPythonパスを設定""" + paths_to_add = [ + str(oac_dir / "src"), + str(oac_dir / "src" / "handlers"), + str(oac_dir / "src" / "handlers" / "avatar" / "lam"), + str(oac_dir / "src" / "handlers" / "avatar" / "lam" / "LAM_Audio2Expression"), + ] + for p in paths_to_add: + if p not in sys.path: + sys.path.insert(0, p) + + +def load_wav(wav_path: str, target_sr: int = 16000) -> np.ndarray: + """WAVファイルを読み込んでnumpy arrayに変換""" + with wave.open(wav_path, "r") as wf: + n_channels = wf.getnchannels() + sample_width = wf.getsampwidth() + frame_rate = wf.getframerate() + n_frames = wf.getnframes() + raw = wf.readframes(n_frames) + + if sample_width == 2: + audio = np.frombuffer(raw, dtype=np.int16).astype(np.float32) / 32768.0 + elif sample_width == 4: + audio = np.frombuffer(raw, dtype=np.int32).astype(np.float32) / 2147483648.0 + else: + raise ValueError(f"Unsupported sample width: {sample_width}") + + if n_channels > 1: + audio = audio.reshape(-1, n_channels).mean(axis=1) + + # リサンプリング(簡易版) + if frame_rate != target_sr: + duration = len(audio) / frame_rate + target_len = int(duration * target_sr) + indices = np.linspace(0, len(audio) - 1, target_len).astype(int) + audio = audio[indices] + + return audio + + +def test_a2e_model_loading(oac_dir: Path) -> dict: + """テスト1: A2Eモデルのロードテスト""" + print("\n" + "=" * 60) + print("TEST 1: A2E Model Loading (CPU)") + print("=" * 60) + + result = {"name": "model_loading", "passed": False, "details": {}} + + model_dir = oac_dir / "models" / "LAM_audio2exp" + wav2vec_dir = oac_dir / "models" / "wav2vec2-base-960h" + + # ファイル存在確認 + checks = { + "model_dir_exists": model_dir.exists(), + "wav2vec_dir_exists": wav2vec_dir.exists(), + } + + # pretrained modelの確認 + pretrained_dir = model_dir / "pretrained_models" + if pretrained_dir.exists(): + tar_files = list(pretrained_dir.glob("*.tar")) + checks["pretrained_models_found"] = len(tar_files) > 0 + if tar_files: + checks["pretrained_model_path"] = str(tar_files[0]) + else: + checks["pretrained_models_found"] = False + + # wav2vec2のモデルファイル確認 + wav2vec_files = list(wav2vec_dir.glob("*.bin")) + list(wav2vec_dir.glob("*.safetensors")) + checks["wav2vec_model_found"] = len(wav2vec_files) > 0 + + result["details"] = checks + + all_ok = all([ + checks.get("model_dir_exists"), + checks.get("wav2vec_dir_exists"), + checks.get("pretrained_models_found"), + checks.get("wav2vec_model_found"), + ]) + + if all_ok: + print(" [PASS] All model files found") + result["passed"] = True + else: + for k, v in checks.items(): + status = "OK" if v else "MISSING" + print(f" [{status}] {k}: {v}") + print(" [FAIL] Some model files are missing") + + return result + + +def test_wav2vec_feature_extraction(oac_dir: Path, audio_dir: Path) -> dict: + """テスト2: Wav2Vec2による特徴量抽出テスト""" + print("\n" + "=" * 60) + print("TEST 2: Wav2Vec2 Feature Extraction") + print("=" * 60) + + result = {"name": "wav2vec_extraction", "passed": False, "details": {}} + + wav_files = sorted(audio_dir.glob("*.wav")) + if not wav_files: + print(" [SKIP] No WAV files found. Run generate_test_audio.py first.") + result["details"]["error"] = "No WAV files" + return result + + try: + import torch + from transformers import Wav2Vec2Model, Wav2Vec2Processor + + wav2vec_dir = oac_dir / "models" / "wav2vec2-base-960h" + if wav2vec_dir.exists() and (wav2vec_dir / "config.json").exists(): + model_name = str(wav2vec_dir) + else: + model_name = "facebook/wav2vec2-base-960h" + + print(f" Loading Wav2Vec2 from: {model_name}") + t0 = time.time() + + try: + processor = Wav2Vec2Processor.from_pretrained(model_name) + except Exception: + # Processor not saved locally, use online + processor = Wav2Vec2Processor.from_pretrained("facebook/wav2vec2-base-960h") + + model = Wav2Vec2Model.from_pretrained(model_name) + model.eval() + load_time = time.time() - t0 + print(f" Model loaded in {load_time:.2f}s") + + results_per_file = {} + for wav_path in wav_files: + audio = load_wav(str(wav_path), target_sr=16000) + inputs = processor(audio, sampling_rate=16000, return_tensors="pt", padding=True) + + with torch.no_grad(): + outputs = model(**inputs) + + hidden_states = outputs.last_hidden_state + feature_shape = tuple(hidden_states.shape) + results_per_file[wav_path.name] = { + "audio_duration_s": len(audio) / 16000, + "feature_shape": feature_shape, + "feature_time_steps": feature_shape[1], + "feature_dim": feature_shape[2], + } + print(f" [{wav_path.name}] audio={len(audio)/16000:.2f}s → features={feature_shape}") + + result["details"] = { + "load_time_s": load_time, + "files_processed": len(results_per_file), + "per_file": results_per_file, + } + result["passed"] = True + print(f"\n [PASS] Wav2Vec2 extracted features from {len(results_per_file)} files") + + except ImportError as e: + print(f" [FAIL] Missing dependency: {e}") + result["details"]["error"] = str(e) + except Exception as e: + print(f" [FAIL] Error: {e}") + result["details"]["error"] = str(e) + + return result + + +def test_a2e_inference(oac_dir: Path, audio_dir: Path) -> dict: + """テスト3: A2E推論テスト(日本語音声 → 52次元ブレンドシェイプ)""" + print("\n" + "=" * 60) + print("TEST 3: A2E Inference (Japanese Audio → ARKit Blendshapes)") + print("=" * 60) + + result = {"name": "a2e_inference", "passed": False, "details": {}} + + wav_files = sorted(audio_dir.glob("*.wav")) + if not wav_files: + print(" [SKIP] No WAV files found.") + return result + + try: + setup_python_path(oac_dir) + import torch + + # A2Eの推論エンジンをインポート試行 + try: + from LAM_Audio2Expression.engines.defaults import default_setup + from LAM_Audio2Expression.engines.infer import Audio2ExpressionInfer + a2e_available = True + except ImportError: + a2e_available = False + + if not a2e_available: + # 直接推論できない場合、avatar_handlerのロードを試行 + try: + from avatar.lam.avatar_handler_lam_audio2expression import HandlerAvatarLAM + a2e_via_handler = True + except ImportError: + a2e_via_handler = False + + if not a2e_via_handler: + print(" [SKIP] A2E module not importable from this environment.") + print(" This test must be run from OpenAvatarChat directory.") + print(" cd C:\\Users\\hamad\\OpenAvatarChat") + print(" python tests/a2e_japanese/test_a2e_cpu.py") + result["details"]["error"] = "A2E module not importable" + return result + + # A2Eモデルのロードと推論は環境依存のため、ここではチェックのみ + print(" A2E module is importable. Full inference test requires:") + print(" 1. Run from OpenAvatarChat directory") + print(" 2. GPU or CPU-patched infer.py") + print(" 3. All model weights downloaded") + + # Wav2Vec2での特徴量抽出は確認済みのため、 + # A2Eの出力形式を検証するモックテスト + print("\n Verifying expected A2E output format...") + mock_output = np.random.rand(100, 52).astype(np.float32) # 100 frames, 52 blendshapes + assert mock_output.shape[1] == 52, "Expected 52 ARKit blendshapes" + assert mock_output.shape[1] == len(ARKIT_BLENDSHAPE_NAMES), "Name count mismatch" + + print(f" Expected output: (num_frames, 52) float32") + print(f" ARKit blendshape names: {len(ARKIT_BLENDSHAPE_NAMES)} defined") + print(f" Lip-related indices: {len(LIP_RELATED_INDICES)} blendshapes") + + result["details"] = { + "a2e_importable": a2e_available or a2e_via_handler, + "expected_output_dim": 52, + "lip_related_count": len(LIP_RELATED_INDICES), + } + result["passed"] = True + print("\n [PASS] A2E module verified (full inference requires OAC environment)") + + except Exception as e: + print(f" [FAIL] Error: {e}") + import traceback + traceback.print_exc() + result["details"]["error"] = str(e) + + return result + + +def test_blendshape_analysis(audio_dir: Path) -> dict: + """テスト4: ブレンドシェイプ出力の分析(保存済みの場合)""" + print("\n" + "=" * 60) + print("TEST 4: Blendshape Output Analysis") + print("=" * 60) + + result = {"name": "blendshape_analysis", "passed": False, "details": {}} + + output_dir = audio_dir.parent / "blendshape_outputs" + npy_files = sorted(output_dir.glob("*.npy")) if output_dir.exists() else [] + + if not npy_files: + print(" [SKIP] No blendshape output files found.") + print(" Run full A2E inference first, then save outputs to:") + print(f" {output_dir}/") + print(" Format: numpy array of shape (num_frames, 52)") + result["details"]["error"] = "No output files" + return result + + analysis = {} + for npy_path in npy_files: + data = np.load(str(npy_path)) + name = npy_path.stem + + if data.ndim != 2 or data.shape[1] != 52: + print(f" [WARN] {name}: unexpected shape {data.shape}, expected (N, 52)") + continue + + # 基本統計 + stats = { + "num_frames": data.shape[0], + "mean": float(data.mean()), + "std": float(data.std()), + "min": float(data.min()), + "max": float(data.max()), + } + + # リップ関連ブレンドシェイプの活性度 + lip_data = data[:, LIP_RELATED_INDICES] + stats["lip_mean_activation"] = float(lip_data.mean()) + stats["lip_max_activation"] = float(lip_data.max()) + stats["lip_active_ratio"] = float((lip_data.abs() > 0.01).any(axis=0).mean()) + + # 最も活性化されたブレンドシェイプ Top5 + mean_activation = data.mean(axis=0) + top_indices = np.argsort(-np.abs(mean_activation))[:5] + stats["top5_blendshapes"] = [ + {"name": ARKIT_BLENDSHAPE_NAMES[i], "mean": float(mean_activation[i])} + for i in top_indices + ] + + analysis[name] = stats + print(f"\n [{name}]") + print(f" Frames: {stats['num_frames']}, Mean: {stats['mean']:.4f}, Std: {stats['std']:.4f}") + print(f" Lip activation: mean={stats['lip_mean_activation']:.4f}, max={stats['lip_max_activation']:.4f}") + print(f" Lip active ratio: {stats['lip_active_ratio']:.1%}") + print(f" Top 5 blendshapes:") + for bs in stats["top5_blendshapes"]: + print(f" {bs['name']}: {bs['mean']:.4f}") + + if analysis: + result["details"] = analysis + result["passed"] = True + print(f"\n [PASS] Analyzed {len(analysis)} blendshape output files") + else: + print(" [FAIL] No valid output files to analyze") + + return result + + +def test_zip_structure(oac_dir: Path) -> dict: + """テスト5: コンシェルジュZIPの構造検証""" + print("\n" + "=" * 60) + print("TEST 5: Concierge ZIP Structure") + print("=" * 60) + + result = {"name": "zip_structure", "passed": False, "details": {}} + + import zipfile + + # ZIPファイルを探す + zip_candidates = [] + for search_dir in [oac_dir / "lam_samples", oac_dir, Path.cwd()]: + if search_dir.exists(): + zip_candidates.extend(search_dir.glob("*.zip")) + + if not zip_candidates: + print(" [SKIP] No ZIP files found. Place concierge ZIP in:") + print(f" {oac_dir / 'lam_samples'}/") + result["details"]["error"] = "No ZIP files" + return result + + expected_files = {"skin.glb", "animation.glb", "offset.ply", "vertex_order.json"} + + for zip_path in zip_candidates: + print(f"\n Checking: {zip_path.name} ({zip_path.stat().st_size / 1024:.1f} KB)") + + try: + with zipfile.ZipFile(str(zip_path), "r") as zf: + names = set() + for info in zf.infolist(): + basename = os.path.basename(info.filename) + if basename: + names.add(basename) + print(f" {info.filename} ({info.file_size:,} bytes)") + + found = expected_files & names + missing = expected_files - names + extra = names - expected_files + + zip_result = { + "path": str(zip_path), + "size_kb": zip_path.stat().st_size / 1024, + "found": list(found), + "missing": list(missing), + "valid": missing == set(), + } + + if missing: + print(f" MISSING: {missing}") + if extra: + print(f" EXTRA: {extra}") + + # GLBマジックナンバー確認 + for glb_name in ["skin.glb", "animation.glb"]: + matching = [n for n in zf.namelist() if n.endswith(glb_name)] + if matching: + data = zf.read(matching[0])[:4] + is_glb = data == b"glTF" + zip_result[f"{glb_name}_valid_glb"] = is_glb + print(f" {glb_name} GLB magic: {'OK' if is_glb else 'INVALID'}") + + # vertex_order.json の検証 + vo_matching = [n for n in zf.namelist() if n.endswith("vertex_order.json")] + if vo_matching: + vo_data = json.loads(zf.read(vo_matching[0])) + is_list = isinstance(vo_data, list) + is_sequential = vo_data == list(range(len(vo_data))) if is_list else False + zip_result["vertex_order_count"] = len(vo_data) if is_list else 0 + zip_result["vertex_order_is_sequential"] = is_sequential + print(f" vertex_order: {len(vo_data)} entries, sequential={is_sequential}") + if is_sequential: + print(f" WARNING: Sequential vertex_order may indicate the bird-monster bug!") + + result["details"][zip_path.name] = zip_result + + except zipfile.BadZipFile: + print(f" ERROR: Not a valid ZIP file") + + any_valid = any( + d.get("valid", False) for d in result["details"].values() + if isinstance(d, dict) + ) + result["passed"] = any_valid + print(f"\n [{'PASS' if any_valid else 'FAIL'}] ZIP structure check") + + return result + + +def save_report(results: list, output_path: str): + """テスト結果をJSONレポートに保存""" + report = { + "timestamp": time.strftime("%Y-%m-%d %H:%M:%S"), + "summary": { + "total": len(results), + "passed": sum(1 for r in results if r.get("passed")), + "failed": sum(1 for r in results if not r.get("passed")), + }, + "tests": results, + } + + with open(output_path, "w", encoding="utf-8") as f: + json.dump(report, f, indent=2, ensure_ascii=False) + + print(f"\nReport saved to: {output_path}") + + +def main(): + parser = argparse.ArgumentParser(description="A2E Japanese Audio Test Suite") + parser.add_argument("--oac-dir", type=str, default=None, + help="Path to OpenAvatarChat directory") + parser.add_argument("--audio-dir", type=str, default=None, + help="Path to audio samples directory") + args = parser.parse_args() + + # ディレクトリ解決 + script_dir = Path(__file__).parent + audio_dir = Path(args.audio_dir) if args.audio_dir else script_dir / "audio_samples" + + if args.oac_dir: + oac_dir = Path(args.oac_dir) + else: + oac_dir = find_oac_dir() + if oac_dir is None: + print("ERROR: OpenAvatarChat directory not found.") + print("Use --oac-dir to specify the path.") + sys.exit(1) + + print("=" * 60) + print("A2E + Japanese Audio Test Suite") + print("=" * 60) + print(f"OpenAvatarChat: {oac_dir}") + print(f"Audio samples: {audio_dir}") + print(f"Time: {time.strftime('%Y-%m-%d %H:%M:%S')}") + + results = [] + + # テスト実行 + results.append(test_a2e_model_loading(oac_dir)) + results.append(test_wav2vec_feature_extraction(oac_dir, audio_dir)) + results.append(test_a2e_inference(oac_dir, audio_dir)) + results.append(test_blendshape_analysis(audio_dir)) + results.append(test_zip_structure(oac_dir)) + + # サマリー + print("\n" + "=" * 60) + print("TEST SUMMARY") + print("=" * 60) + passed = sum(1 for r in results if r.get("passed")) + total = len(results) + for r in results: + status = "PASS" if r.get("passed") else "FAIL/SKIP" + print(f" [{status}] {r['name']}") + print(f"\n Result: {passed}/{total} passed") + + # レポート保存 + report_path = str(script_dir / "test_report.json") + save_report(results, report_path) + + return 0 if passed == total else 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..30e36ad --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,183 @@ +""" +共通テストフィクスチャ + +A2Eサービスのテストで使用するフィクスチャを定義。 +モデルファイル不要のCI実行を前提とする。 +""" + +import base64 +import io +import struct +import wave + +import numpy as np +import pytest + + +# --- ARKit 52 ブレンドシェイプ定義 --- + +ARKIT_BLENDSHAPE_NAMES_INFER = [ + "browDownLeft", "browDownRight", "browInnerUp", "browOuterUpLeft", "browOuterUpRight", + "cheekPuff", "cheekSquintLeft", "cheekSquintRight", + "eyeBlinkLeft", "eyeBlinkRight", "eyeLookDownLeft", "eyeLookDownRight", + "eyeLookInLeft", "eyeLookInRight", "eyeLookOutLeft", "eyeLookOutRight", + "eyeLookUpLeft", "eyeLookUpRight", "eyeSquintLeft", "eyeSquintRight", + "eyeWideLeft", "eyeWideRight", + "jawForward", "jawLeft", "jawOpen", "jawRight", + "mouthClose", "mouthDimpleLeft", "mouthDimpleRight", "mouthFrownLeft", "mouthFrownRight", + "mouthFunnel", "mouthLeft", "mouthLowerDownLeft", "mouthLowerDownRight", + "mouthPressLeft", "mouthPressRight", "mouthPucker", "mouthRight", + "mouthRollLower", "mouthRollUpper", "mouthShrugLower", "mouthShrugUpper", + "mouthSmileLeft", "mouthSmileRight", "mouthStretchLeft", "mouthStretchRight", + "mouthUpperUpLeft", "mouthUpperUpRight", + "noseSneerLeft", "noseSneerRight", + "tongueOut", +] + +ARKIT_BLENDSHAPE_NAMES_FALLBACK = [ + "eyeBlinkLeft", "eyeLookDownLeft", "eyeLookInLeft", "eyeLookOutLeft", + "eyeLookUpLeft", "eyeSquintLeft", "eyeWideLeft", + "eyeBlinkRight", "eyeLookDownRight", "eyeLookInRight", "eyeLookOutRight", + "eyeLookUpRight", "eyeSquintRight", "eyeWideRight", + "jawForward", "jawLeft", "jawRight", "jawOpen", + "mouthClose", "mouthFunnel", "mouthPucker", "mouthLeft", "mouthRight", + "mouthSmileLeft", "mouthSmileRight", "mouthFrownLeft", "mouthFrownRight", + "mouthDimpleLeft", "mouthDimpleRight", "mouthStretchLeft", "mouthStretchRight", + "mouthRollLower", "mouthRollUpper", "mouthShrugLower", "mouthShrugUpper", + "mouthPressLeft", "mouthPressRight", "mouthLowerDownLeft", "mouthLowerDownRight", + "mouthUpperUpLeft", "mouthUpperUpRight", + "browDownLeft", "browDownRight", "browInnerUp", "browOuterUpLeft", "browOuterUpRight", + "cheekPuff", "cheekSquintLeft", "cheekSquintRight", + "noseSneerLeft", "noseSneerRight", + "tongueOut", +] + + +def generate_wav_bytes( + duration_s: float = 1.0, + sample_rate: int = 16000, + frequency: float = 440.0, + amplitude: float = 0.5, +) -> bytes: + """テスト用WAVバイト列を生成""" + n_samples = int(duration_s * sample_rate) + t = np.linspace(0, duration_s, n_samples, endpoint=False) + samples = (amplitude * np.sin(2 * np.pi * frequency * t) * 32767).astype(np.int16) + + buf = io.BytesIO() + with wave.open(buf, "wb") as wf: + wf.setnchannels(1) + wf.setsampwidth(2) + wf.setframerate(sample_rate) + wf.writeframes(samples.tobytes()) + return buf.getvalue() + + +def generate_silence_wav_bytes(duration_s: float = 1.0, sample_rate: int = 16000) -> bytes: + """無音WAVバイト列を生成""" + return generate_wav_bytes(duration_s=duration_s, sample_rate=sample_rate, + frequency=0.0, amplitude=0.0) + + +@pytest.fixture +def wav_440hz_1s(): + """1秒 440Hz 正弦波 WAV""" + return generate_wav_bytes(duration_s=1.0, frequency=440.0) + + +@pytest.fixture +def wav_440hz_1s_base64(): + """1秒 440Hz 正弦波 WAV (base64)""" + return base64.b64encode(generate_wav_bytes(duration_s=1.0, frequency=440.0)).decode() + + +@pytest.fixture +def wav_silence_1s(): + """1秒無音 WAV""" + return generate_silence_wav_bytes(duration_s=1.0) + + +@pytest.fixture +def wav_silence_1s_base64(): + """1秒無音 WAV (base64)""" + return base64.b64encode(generate_silence_wav_bytes(duration_s=1.0)).decode() + + +@pytest.fixture +def wav_speech_like_2s(): + """擬似音声 WAV (複数周波数)""" + sr = 16000 + duration = 2.0 + n = int(sr * duration) + t = np.linspace(0, duration, n, endpoint=False) + # 基本周波数 + 倍音でスピーチらしい波形を生成 + signal = ( + 0.4 * np.sin(2 * np.pi * 200 * t) + + 0.2 * np.sin(2 * np.pi * 400 * t) + + 0.1 * np.sin(2 * np.pi * 800 * t) + + 0.05 * np.sin(2 * np.pi * 1600 * t) + ) + # エンベロープで発話区間を再現 + envelope = np.ones(n) + envelope[:int(0.1 * sr)] = np.linspace(0, 1, int(0.1 * sr)) + envelope[int(1.5 * sr):int(1.7 * sr)] = 0.0 # 無音区間 + envelope[int(1.9 * sr):] = np.linspace(1, 0, n - int(1.9 * sr)) + signal *= envelope + + samples = (signal * 32767).astype(np.int16) + buf = io.BytesIO() + with wave.open(buf, "wb") as wf: + wf.setnchannels(1) + wf.setsampwidth(2) + wf.setframerate(sr) + wf.writeframes(samples.tobytes()) + return buf.getvalue() + + +@pytest.fixture +def wav_speech_like_2s_base64(wav_speech_like_2s): + """擬似音声 WAV (base64)""" + return base64.b64encode(wav_speech_like_2s).decode() + + +@pytest.fixture +def mock_a2e_response(): + """A2E APIの期待レスポンス形式""" + n_frames = 30 # 1秒 @ 30fps + frames = np.random.rand(n_frames, 52).astype(np.float32) * 0.5 + return { + "names": ARKIT_BLENDSHAPE_NAMES_INFER, + "frames": [frame.tolist() for frame in frames], + "frame_rate": 30, + } + + +@pytest.fixture +def sample_blendshape_frames(): + """テスト用ブレンドシェイプフレーム (母音パターン)""" + # 「あ」パターン: jawOpen高、mouthFunnel低 + frame_a = np.zeros(52, dtype=np.float32) + idx = {n: i for i, n in enumerate(ARKIT_BLENDSHAPE_NAMES_INFER)} + frame_a[idx["jawOpen"]] = 0.7 + frame_a[idx["mouthLowerDownLeft"]] = 0.3 + frame_a[idx["mouthLowerDownRight"]] = 0.3 + + # 「い」パターン: jawOpen低、mouthSmile高 + frame_i = np.zeros(52, dtype=np.float32) + frame_i[idx["jawOpen"]] = 0.1 + frame_i[idx["mouthSmileLeft"]] = 0.5 + frame_i[idx["mouthSmileRight"]] = 0.5 + + # 「う」パターン: jawOpen低、mouthPucker/Funnel高 + frame_u = np.zeros(52, dtype=np.float32) + frame_u[idx["jawOpen"]] = 0.15 + frame_u[idx["mouthPucker"]] = 0.6 + frame_u[idx["mouthFunnel"]] = 0.4 + + return { + "a": frame_a, + "i": frame_i, + "u": frame_u, + "names": ARKIT_BLENDSHAPE_NAMES_INFER, + "idx": idx, + } diff --git a/tests/test_a2e_api.py b/tests/test_a2e_api.py new file mode 100644 index 0000000..da834fc --- /dev/null +++ b/tests/test_a2e_api.py @@ -0,0 +1,217 @@ +""" +A2E Flask API コントラクトテスト + +Flask test client を使用して API のリクエスト・レスポンス形式を検証。 +実際のモデル推論はモックする。 +""" + +import base64 +import json +import sys +from pathlib import Path +from unittest.mock import MagicMock, patch + +import numpy as np +import pytest + +SERVICE_DIR = Path(__file__).parent.parent / "services" / "audio2exp-service" +sys.path.insert(0, str(SERVICE_DIR)) + +from conftest import ARKIT_BLENDSHAPE_NAMES_INFER + + +def make_mock_engine(): + """モックされた A2E エンジン""" + engine = MagicMock() + engine.is_ready.return_value = True + engine.get_mode.return_value = "infer" + engine.device_name = "cpu" + + # process() のモックレスポンス + n_frames = 30 + frames = np.random.rand(n_frames, 52).astype(np.float32) + engine.process.return_value = { + "names": list(ARKIT_BLENDSHAPE_NAMES_INFER), + "frames": [frame.tolist() for frame in frames], + "frame_rate": 30, + } + return engine + + +@pytest.fixture +def app(): + """Flask アプリケーション (エンジンをモック)""" + mock_engine = make_mock_engine() + + with patch.dict("sys.modules", {"a2e_engine": MagicMock()}): + # app.py をモック付きでインポートし直す + import importlib + # a2e_engine モジュールのモック + mock_a2e_module = MagicMock() + mock_a2e_module.Audio2ExpressionEngine.return_value = mock_engine + sys.modules["a2e_engine"] = mock_a2e_module + + # app モジュールのキャッシュをクリア + if "app" in sys.modules: + del sys.modules["app"] + + import app as flask_app + flask_app.engine = mock_engine + flask_app.app.config["TESTING"] = True + yield flask_app.app, mock_engine + + +@pytest.fixture +def client(app): + """Flask test client""" + flask_app, engine = app + return flask_app.test_client(), engine + + +class TestHealthEndpoint: + """GET /health エンドポイント""" + + @pytest.mark.api + def test_health_returns_200(self, client): + c, engine = client + rv = c.get("/health") + assert rv.status_code == 200 + + @pytest.mark.api + def test_health_response_format(self, client): + c, engine = client + rv = c.get("/health") + data = rv.get_json() + assert "status" in data + assert "engine_ready" in data + assert "mode" in data + assert "device" in data + assert "model_dir" in data + + @pytest.mark.api + def test_health_status_healthy(self, client): + c, engine = client + rv = c.get("/health") + data = rv.get_json() + assert data["status"] == "healthy" + assert data["engine_ready"] is True + + +class TestAudio2ExpressionEndpoint: + """POST /api/audio2expression エンドポイント""" + + @pytest.mark.api + def test_missing_audio_returns_400(self, client): + c, engine = client + rv = c.post("/api/audio2expression", + json={"session_id": "test"}) + assert rv.status_code == 400 + + @pytest.mark.api + def test_empty_audio_returns_400(self, client): + c, engine = client + rv = c.post("/api/audio2expression", + json={"audio_base64": "", "session_id": "test"}) + assert rv.status_code == 400 + + @pytest.mark.api + def test_valid_request_returns_200(self, client, wav_440hz_1s_base64): + c, engine = client + rv = c.post("/api/audio2expression", + json={ + "audio_base64": wav_440hz_1s_base64, + "session_id": "test-session", + "audio_format": "wav", + }) + assert rv.status_code == 200 + + @pytest.mark.api + def test_response_has_required_fields(self, client, wav_440hz_1s_base64): + c, engine = client + rv = c.post("/api/audio2expression", + json={ + "audio_base64": wav_440hz_1s_base64, + "session_id": "test", + "audio_format": "wav", + }) + data = rv.get_json() + assert "names" in data + assert "frames" in data + assert "frame_rate" in data + + @pytest.mark.api + def test_response_names_count(self, client, wav_440hz_1s_base64): + c, engine = client + rv = c.post("/api/audio2expression", + json={ + "audio_base64": wav_440hz_1s_base64, + "session_id": "test", + "audio_format": "wav", + }) + data = rv.get_json() + assert len(data["names"]) == 52 + + @pytest.mark.api + def test_response_frame_dimensions(self, client, wav_440hz_1s_base64): + c, engine = client + rv = c.post("/api/audio2expression", + json={ + "audio_base64": wav_440hz_1s_base64, + "session_id": "test", + "audio_format": "wav", + }) + data = rv.get_json() + assert len(data["frames"]) > 0 + assert len(data["frames"][0]) == 52 + + @pytest.mark.api + def test_response_frame_rate(self, client, wav_440hz_1s_base64): + c, engine = client + rv = c.post("/api/audio2expression", + json={ + "audio_base64": wav_440hz_1s_base64, + "session_id": "test", + "audio_format": "wav", + }) + data = rv.get_json() + assert data["frame_rate"] == 30 + + @pytest.mark.api + def test_default_audio_format_mp3(self, client, wav_440hz_1s_base64): + """audio_format 省略時はデフォルト mp3""" + c, engine = client + rv = c.post("/api/audio2expression", + json={ + "audio_base64": wav_440hz_1s_base64, + "session_id": "test", + }) + # engine.process が呼ばれたときの audio_format を確認 + call_args = engine.process.call_args + assert call_args[1].get("audio_format", "mp3") == "mp3" or \ + (len(call_args[0]) > 1 and call_args[0][1] == "mp3") or \ + call_args.kwargs.get("audio_format", "mp3") == "mp3" + + @pytest.mark.api + def test_engine_error_returns_500(self, client, wav_440hz_1s_base64): + c, engine = client + engine.process.side_effect = RuntimeError("Model error") + rv = c.post("/api/audio2expression", + json={ + "audio_base64": wav_440hz_1s_base64, + "session_id": "test", + "audio_format": "wav", + }) + assert rv.status_code == 500 + data = rv.get_json() + assert "error" in data + + @pytest.mark.api + def test_session_id_defaults_to_unknown(self, client, wav_440hz_1s_base64): + """session_id 省略時でもリクエストが通る""" + c, engine = client + rv = c.post("/api/audio2expression", + json={ + "audio_base64": wav_440hz_1s_base64, + "audio_format": "wav", + }) + assert rv.status_code == 200 diff --git a/tests/test_a2e_engine_unit.py b/tests/test_a2e_engine_unit.py new file mode 100644 index 0000000..80001e2 --- /dev/null +++ b/tests/test_a2e_engine_unit.py @@ -0,0 +1,332 @@ +""" +A2Eエンジン ユニットテスト + +モデルファイル不要で実行可能な、ロジックレベルのテスト。 +対象: services/audio2exp-service/a2e_engine.py +""" + +import base64 +import io +import sys +import wave +from pathlib import Path +from unittest.mock import MagicMock, patch + +import numpy as np +import pytest + +# a2e_engine.py をインポートできるよう sys.path を設定 +SERVICE_DIR = Path(__file__).parent.parent / "services" / "audio2exp-service" +sys.path.insert(0, str(SERVICE_DIR)) + + +# ---- ブレンドシェイプ名定義テスト ---- + +class TestBlendshapeNames: + """ARKitブレンドシェイプ名の定義が正しいことを検証""" + + def test_infer_names_count(self): + from a2e_engine import ARKIT_BLENDSHAPE_NAMES_INFER + assert len(ARKIT_BLENDSHAPE_NAMES_INFER) == 52 + + def test_fallback_names_count(self): + from a2e_engine import ARKIT_BLENDSHAPE_NAMES_FALLBACK + assert len(ARKIT_BLENDSHAPE_NAMES_FALLBACK) == 52 + + def test_infer_names_unique(self): + from a2e_engine import ARKIT_BLENDSHAPE_NAMES_INFER + assert len(set(ARKIT_BLENDSHAPE_NAMES_INFER)) == 52 + + def test_fallback_names_unique(self): + from a2e_engine import ARKIT_BLENDSHAPE_NAMES_FALLBACK + assert len(set(ARKIT_BLENDSHAPE_NAMES_FALLBACK)) == 52 + + def test_both_lists_same_set(self): + """INFER名とFALLBACK名は順序違いでも同じセットであるべき""" + from a2e_engine import ( + ARKIT_BLENDSHAPE_NAMES_FALLBACK, + ARKIT_BLENDSHAPE_NAMES_INFER, + ) + assert set(ARKIT_BLENDSHAPE_NAMES_INFER) == set(ARKIT_BLENDSHAPE_NAMES_FALLBACK) + + def test_jawopen_exists(self): + from a2e_engine import ARKIT_BLENDSHAPE_NAMES_INFER + assert "jawOpen" in ARKIT_BLENDSHAPE_NAMES_INFER + + def test_lip_related_names_present(self): + """リップシンクに必要なブレンドシェイプが含まれている""" + from a2e_engine import ARKIT_BLENDSHAPE_NAMES_INFER + required = [ + "jawOpen", "mouthClose", "mouthFunnel", "mouthPucker", + "mouthSmileLeft", "mouthSmileRight", + "mouthLowerDownLeft", "mouthLowerDownRight", + "mouthUpperUpLeft", "mouthUpperUpRight", + ] + for name in required: + assert name in ARKIT_BLENDSHAPE_NAMES_INFER, f"{name} missing" + + +# ---- 音声デコードテスト (モック不要) ---- + +class TestAudioDecoding: + """_decode_audio メソッドの単体テスト""" + + @pytest.fixture(autouse=True) + def _setup_engine_class(self): + """エンジンクラスのみインポート (初期化はモックする)""" + from a2e_engine import Audio2ExpressionEngine + self.EngineClass = Audio2ExpressionEngine + + def _make_engine_no_init(self): + """__init__ をスキップしてインスタンスを作成""" + engine = object.__new__(self.EngineClass) + engine.model_dir = Path("/tmp/fake_models") + engine._ready = False + engine._use_infer = False + engine.device = "cpu" + engine.device_name = "cpu" + return engine + + def test_decode_wav_format(self, wav_440hz_1s_base64): + engine = self._make_engine_no_init() + pcm = engine._decode_audio(wav_440hz_1s_base64, "wav") + assert isinstance(pcm, np.ndarray) + assert pcm.dtype == np.float32 + # 1秒 16kHz = 16000サンプル + assert abs(len(pcm) - 16000) < 100 + # float32 正規化 [-1, 1] + assert pcm.max() <= 1.0 + assert pcm.min() >= -1.0 + + def test_decode_pcm_format(self): + """PCM int16 → float32 変換""" + engine = self._make_engine_no_init() + # 100サンプルの PCM int16 データ + pcm_int16 = np.array([0, 16384, 32767, -32768, -16384], dtype=np.int16) + pcm_b64 = base64.b64encode(pcm_int16.tobytes()).decode() + result = engine._decode_audio(pcm_b64, "pcm") + assert result.dtype == np.float32 + assert len(result) == 5 + assert abs(result[0]) < 1e-6 # 0 + assert abs(result[2] - 1.0) < 0.001 # 32767/32768 ≈ 1.0 + assert abs(result[3] + 1.0) < 0.001 # -32768/32768 = -1.0 + + def test_decode_invalid_format_raises(self): + engine = self._make_engine_no_init() + with pytest.raises(ValueError, match="Unsupported audio format"): + engine._decode_audio(base64.b64encode(b"dummy").decode(), "aac") + + def test_decode_silence(self, wav_silence_1s_base64): + engine = self._make_engine_no_init() + pcm = engine._decode_audio(wav_silence_1s_base64, "wav") + assert np.abs(pcm).max() < 0.01 # ほぼ無音 + + +# ---- リサンプリングテスト ---- + +class TestResampling: + """_resample_to_fps メソッドの単体テスト""" + + @pytest.fixture(autouse=True) + def _setup(self): + from a2e_engine import Audio2ExpressionEngine + engine = object.__new__(Audio2ExpressionEngine) + engine.model_dir = Path("/tmp/fake") + engine.device = "cpu" + engine.device_name = "cpu" + self.engine = engine + + def test_resample_same_length(self): + """ソースとターゲットが同じ長さの場合""" + blendshapes = np.random.rand(30, 52).astype(np.float32) + frames = self.engine._resample_to_fps(blendshapes, duration=1.0, target_fps=30) + assert len(frames) == 30 + assert len(frames[0]) == 52 + + def test_resample_upsample(self): + """アップサンプリング (10fps → 30fps)""" + blendshapes = np.random.rand(10, 52).astype(np.float32) + frames = self.engine._resample_to_fps(blendshapes, duration=1.0, target_fps=30) + assert len(frames) == 30 + + def test_resample_downsample(self): + """ダウンサンプリング (60fps → 30fps)""" + blendshapes = np.random.rand(60, 52).astype(np.float32) + frames = self.engine._resample_to_fps(blendshapes, duration=1.0, target_fps=30) + assert len(frames) == 30 + + def test_resample_preserves_range(self): + """リサンプリング後の値域が元データの範囲内""" + blendshapes = np.random.rand(50, 52).astype(np.float32) + frames = self.engine._resample_to_fps(blendshapes, duration=2.0, target_fps=30) + arr = np.array(frames) + assert arr.min() >= blendshapes.min() - 1e-6 + assert arr.max() <= blendshapes.max() + 1e-6 + + def test_resample_output_format(self): + """出力がリストのリスト (JSON互換) であること""" + blendshapes = np.random.rand(10, 52).astype(np.float32) + frames = self.engine._resample_to_fps(blendshapes, duration=1.0, target_fps=30) + assert isinstance(frames, list) + assert isinstance(frames[0], list) + assert all(isinstance(v, float) for v in frames[0]) + + def test_resample_short_duration(self): + """非常に短い音声 (最低1フレーム保証)""" + blendshapes = np.random.rand(2, 52).astype(np.float32) + frames = self.engine._resample_to_fps(blendshapes, duration=0.01, target_fps=30) + assert len(frames) >= 1 + + +# ---- フォールバック推論ロジックテスト ---- + +class TestFallbackLogic: + """Wav2Vec2 フォールバックのブレンドシェイプ生成ロジックをテスト""" + + @pytest.fixture(autouse=True) + def _setup(self): + from a2e_engine import Audio2ExpressionEngine, ARKIT_BLENDSHAPE_NAMES_FALLBACK + engine = object.__new__(Audio2ExpressionEngine) + engine.model_dir = Path("/tmp/fake") + engine.device = "cpu" + engine.device_name = "cpu" + self.engine = engine + self.names = ARKIT_BLENDSHAPE_NAMES_FALLBACK + self.idx = {n: i for i, n in enumerate(self.names)} + + def _make_fake_features(self, n_frames: int, pattern: str = "speech"): + """テスト用のWav2Vec2出力テンソルを生成""" + import torch + if pattern == "speech": + features = torch.randn(1, n_frames, 768) * 0.5 + 0.3 + elif pattern == "silence": + features = torch.zeros(1, n_frames, 768) + elif pattern == "loud": + features = torch.randn(1, n_frames, 768) * 2.0 + else: + features = torch.randn(1, n_frames, 768) + return features + + @pytest.mark.unit + def test_fallback_output_shape(self): + """フォールバック出力が (N, 52) であること""" + try: + import torch + except ImportError: + pytest.skip("torch not installed") + features = self._make_fake_features(50, "speech") + result = self.engine._wav2vec_to_blendshapes_fallback(features, duration=1.0) + assert result.shape == (50, 52) + assert result.dtype == np.float32 + + @pytest.mark.unit + def test_fallback_values_clipped(self): + """出力値が [0, 1] 範囲内""" + try: + import torch + except ImportError: + pytest.skip("torch not installed") + features = self._make_fake_features(50, "loud") + result = self.engine._wav2vec_to_blendshapes_fallback(features, duration=1.0) + assert result.min() >= -0.01 # スムージングで若干の誤差あり + assert result.max() <= 1.01 + + @pytest.mark.unit + def test_fallback_silence_suppressed(self): + """無音入力時にブレンドシェイプが抑制される""" + try: + import torch + except ImportError: + pytest.skip("torch not installed") + features = self._make_fake_features(50, "silence") + result = self.engine._wav2vec_to_blendshapes_fallback(features, duration=1.0) + # 無音時は全ブレンドシェイプがほぼゼロ + assert result.max() < 0.1 + + @pytest.mark.unit + def test_fallback_jawopen_active_for_speech(self): + """音声入力時に jawOpen が活性化する""" + try: + import torch + except ImportError: + pytest.skip("torch not installed") + features = self._make_fake_features(50, "speech") + result = self.engine._wav2vec_to_blendshapes_fallback(features, duration=1.0) + jaw_open_idx = self.idx["jawOpen"] + assert result[:, jaw_open_idx].max() > 0.1 + + @pytest.mark.unit + def test_fallback_smoothing(self): + """スムージングが適用されている (連続するフレーム間の差が小さい)""" + try: + import torch + except ImportError: + pytest.skip("torch not installed") + features = self._make_fake_features(100, "speech") + result = self.engine._wav2vec_to_blendshapes_fallback(features, duration=2.0) + # フレーム間差分の標準偏差がスムージングなしより小さいことを確認 + diffs = np.diff(result, axis=0) + max_frame_diff = np.abs(diffs).max() + # スムージングにより極端なジャンプはない + assert max_frame_diff < 1.0 + + +# ---- 定数テスト ---- + +class TestConstants: + """定数定義の正確性""" + + def test_output_fps(self): + from a2e_engine import A2E_OUTPUT_FPS + assert A2E_OUTPUT_FPS == 30 + + def test_input_sample_rate(self): + from a2e_engine import INFER_INPUT_SAMPLE_RATE + assert INFER_INPUT_SAMPLE_RATE == 16000 + + +# ---- モジュール探索テスト ---- + +class TestModuleDiscovery: + """_find_lam_module, _find_checkpoint, _find_wav2vec_dir のテスト""" + + @pytest.fixture(autouse=True) + def _setup(self): + from a2e_engine import Audio2ExpressionEngine + engine = object.__new__(Audio2ExpressionEngine) + engine.model_dir = Path("/tmp/nonexistent_model_dir_test") + engine.device = "cpu" + engine.device_name = "cpu" + self.engine = engine + + def test_find_checkpoint_returns_none_when_missing(self): + result = self.engine._find_checkpoint() + assert result is None + + def test_find_wav2vec_dir_returns_none_when_missing(self): + result = self.engine._find_wav2vec_dir() + assert result is None + + def test_find_lam_module_consistent_with_filesystem(self): + """LAM_Audio2Expression の探索結果がファイルシステムと一致する""" + result = self.engine._find_lam_module() + # サービスディレクトリに実在する場合は見つかるのが正しい動作 + if result is not None: + assert "LAM_Audio2Expression" in result + assert Path(result).exists() + + def test_find_lam_module_finds_local(self, tmp_path): + """LAM_Audio2Expression がサービスディレクトリ直下にある場合""" + lam_dir = tmp_path / "LAM_Audio2Expression" + lam_dir.mkdir() + self.engine.model_dir = tmp_path / "models" + # _find_lam_module は __file__ ベースのパスを見るので、 + # 環境変数経由のパスをテスト + import os + os.environ["LAM_A2E_PATH"] = str(lam_dir) + try: + result = self.engine._find_lam_module() + assert result is not None + assert "LAM_Audio2Expression" in result + finally: + del os.environ["LAM_A2E_PATH"] diff --git a/tests/test_blendshape_validation.py b/tests/test_blendshape_validation.py new file mode 100644 index 0000000..c5b4e5d --- /dev/null +++ b/tests/test_blendshape_validation.py @@ -0,0 +1,230 @@ +""" +ブレンドシェイプ データ形式バリデーションテスト + +A2E出力の52次元ARKitブレンドシェイプデータが +フロントエンド (gourmet-sp) の期待形式と整合するかを検証。 +""" + +import json +import sys +from pathlib import Path + +import numpy as np +import pytest + +SERVICE_DIR = Path(__file__).parent.parent / "services" / "audio2exp-service" +sys.path.insert(0, str(SERVICE_DIR)) + +from conftest import ARKIT_BLENDSHAPE_NAMES_FALLBACK, ARKIT_BLENDSHAPE_NAMES_INFER + + +# ---- Apple ARKit 公式仕様との整合性 ---- + +# Apple ARKit 公式 52 ブレンドシェイプ (アルファベット順ではなく機能別グループ) +ARKIT_OFFICIAL_NAMES = { + # 目 + "eyeBlinkLeft", "eyeBlinkRight", + "eyeLookDownLeft", "eyeLookDownRight", + "eyeLookInLeft", "eyeLookInRight", + "eyeLookOutLeft", "eyeLookOutRight", + "eyeLookUpLeft", "eyeLookUpRight", + "eyeSquintLeft", "eyeSquintRight", + "eyeWideLeft", "eyeWideRight", + # 顎 + "jawForward", "jawLeft", "jawRight", "jawOpen", + # 口 + "mouthClose", "mouthFunnel", "mouthPucker", + "mouthLeft", "mouthRight", + "mouthSmileLeft", "mouthSmileRight", + "mouthFrownLeft", "mouthFrownRight", + "mouthDimpleLeft", "mouthDimpleRight", + "mouthStretchLeft", "mouthStretchRight", + "mouthRollLower", "mouthRollUpper", + "mouthShrugLower", "mouthShrugUpper", + "mouthPressLeft", "mouthPressRight", + "mouthLowerDownLeft", "mouthLowerDownRight", + "mouthUpperUpLeft", "mouthUpperUpRight", + # 眉 + "browDownLeft", "browDownRight", "browInnerUp", + "browOuterUpLeft", "browOuterUpRight", + # 頬 + "cheekPuff", "cheekSquintLeft", "cheekSquintRight", + # 鼻 + "noseSneerLeft", "noseSneerRight", + # 舌 + "tongueOut", +} + + +class TestARKitCompliance: + """Apple ARKit 52ブレンドシェイプ仕様との整合""" + + def test_official_count(self): + assert len(ARKIT_OFFICIAL_NAMES) == 52 + + def test_infer_matches_arkit(self): + assert set(ARKIT_BLENDSHAPE_NAMES_INFER) == ARKIT_OFFICIAL_NAMES + + def test_fallback_matches_arkit(self): + assert set(ARKIT_BLENDSHAPE_NAMES_FALLBACK) == ARKIT_OFFICIAL_NAMES + + def test_a2e_engine_infer_names_match_arkit(self): + from a2e_engine import ARKIT_BLENDSHAPE_NAMES_INFER as engine_names + assert set(engine_names) == ARKIT_OFFICIAL_NAMES + + def test_a2e_engine_fallback_names_match_arkit(self): + from a2e_engine import ARKIT_BLENDSHAPE_NAMES_FALLBACK as engine_names + assert set(engine_names) == ARKIT_OFFICIAL_NAMES + + +# ---- INFER パイプラインのインデックスマッピング ---- + +class TestINFERIndexMapping: + """INFER パイプラインのブレンドシェイプインデックスが正しいことを検証。 + a2e_engine.py:428 の jawOpen=index 24 が正しいか確認。""" + + def test_jawopen_index_in_infer_order(self): + from a2e_engine import ARKIT_BLENDSHAPE_NAMES_INFER + assert ARKIT_BLENDSHAPE_NAMES_INFER[24] == "jawOpen" + + def test_jawopen_index_in_fallback_order(self): + from a2e_engine import ARKIT_BLENDSHAPE_NAMES_FALLBACK + idx = ARKIT_BLENDSHAPE_NAMES_FALLBACK.index("jawOpen") + assert idx == 17 # fallback order + + +# ---- レスポンス形式テスト ---- + +class TestResponseFormat: + """API レスポンスのデータ形式が期待通りか検証""" + + def test_mock_response_structure(self, mock_a2e_response): + data = mock_a2e_response + assert "names" in data + assert "frames" in data + assert "frame_rate" in data + + def test_mock_response_names_type(self, mock_a2e_response): + data = mock_a2e_response + assert isinstance(data["names"], list) + assert all(isinstance(n, str) for n in data["names"]) + + def test_mock_response_frames_type(self, mock_a2e_response): + data = mock_a2e_response + assert isinstance(data["frames"], list) + assert all(isinstance(f, list) for f in data["frames"]) + assert all(isinstance(v, float) for v in data["frames"][0]) + + def test_mock_response_json_serializable(self, mock_a2e_response): + """レスポンスがJSON直列化可能""" + json_str = json.dumps(mock_a2e_response) + parsed = json.loads(json_str) + assert len(parsed["names"]) == 52 + assert len(parsed["frames"]) > 0 + + def test_frames_values_in_range(self, mock_a2e_response): + """フレーム値が 0~1 の範囲内""" + data = mock_a2e_response + for frame in data["frames"]: + for val in frame: + assert 0.0 <= val <= 1.0, f"Value {val} out of [0, 1] range" + + +# ---- フロントエンド統合テスト ---- + +class TestFrontendIntegration: + """フロントエンド (vrm-expression-manager.ts) が期待するデータ形式との整合""" + + def test_expression_manager_mapping(self, sample_blendshape_frames): + """ExpressionManager のマッピングロジック再現: + jawOpen × 0.6 + (mouthLowerDownL + mouthLowerDownR) / 2 × 0.2 + + (mouthUpperUpL + mouthUpperUpR) / 2 × 0.1 + + mouthFunnel × 0.05 + mouthPucker × 0.05 + → mouthOpenness (0.0 ~ 1.0) + """ + idx = sample_blendshape_frames["idx"] + frame_a = sample_blendshape_frames["a"] + + jaw_open = frame_a[idx["jawOpen"]] + lower_down = (frame_a[idx["mouthLowerDownLeft"]] + frame_a[idx["mouthLowerDownRight"]]) / 2 + upper_up = (frame_a[idx["mouthUpperUpLeft"]] + frame_a[idx["mouthUpperUpRight"]]) / 2 + funnel = frame_a[idx["mouthFunnel"]] + pucker = frame_a[idx["mouthPucker"]] + + mouth_openness = ( + jaw_open * 0.6 + + lower_down * 0.2 + + upper_up * 0.1 + + funnel * 0.05 + + pucker * 0.05 + ) + assert 0.0 <= mouth_openness <= 1.0 + # 「あ」は口が大きく開くので openness が高い + assert mouth_openness > 0.3 + + def test_vowel_a_pattern(self, sample_blendshape_frames): + """「あ」: jawOpen が高い""" + idx = sample_blendshape_frames["idx"] + frame = sample_blendshape_frames["a"] + assert frame[idx["jawOpen"]] > 0.5 + + def test_vowel_i_pattern(self, sample_blendshape_frames): + """「い」: mouthSmile が高い、jawOpen が低い""" + idx = sample_blendshape_frames["idx"] + frame = sample_blendshape_frames["i"] + assert frame[idx["jawOpen"]] < 0.3 + assert frame[idx["mouthSmileLeft"]] > 0.3 + assert frame[idx["mouthSmileRight"]] > 0.3 + + def test_vowel_u_pattern(self, sample_blendshape_frames): + """「う」: mouthPucker/Funnel が高い""" + idx = sample_blendshape_frames["idx"] + frame = sample_blendshape_frames["u"] + assert frame[idx["mouthPucker"]] > 0.3 + assert frame[idx["mouthFunnel"]] > 0.2 + + def test_lam_avatar_controller_format(self, mock_a2e_response): + """lamAvatarController.queueExpressionFrames() が期待する形式: + frames: [{name: weight}, ...] の配列 + """ + data = mock_a2e_response + # フロントエンドの変換ロジック再現 + converted_frames = [] + for frame_weights in data["frames"]: + frame_dict = {} + for name, weight in zip(data["names"], frame_weights): + frame_dict[name] = weight + converted_frames.append(frame_dict) + + assert len(converted_frames) == len(data["frames"]) + assert "jawOpen" in converted_frames[0] + assert isinstance(converted_frames[0]["jawOpen"], float) + + +# ---- INFER/Fallback 名前順序一貫性 ---- + +class TestNameOrderConsistency: + """INFER と Fallback で名前順序が異なることの影響テスト""" + + def test_name_order_differs(self): + """INFER と Fallback の名前順序は異なる (意図的な設計)""" + assert ARKIT_BLENDSHAPE_NAMES_INFER != ARKIT_BLENDSHAPE_NAMES_FALLBACK + + def test_name_lookup_by_dict(self): + """名前→インデックスの辞書ルックアップで順序差を吸収できる""" + infer_idx = {n: i for i, n in enumerate(ARKIT_BLENDSHAPE_NAMES_INFER)} + fallback_idx = {n: i for i, n in enumerate(ARKIT_BLENDSHAPE_NAMES_FALLBACK)} + + # jawOpen は両方に存在するが、異なるインデックス + assert infer_idx["jawOpen"] != fallback_idx["jawOpen"] + # 名前からアクセスすれば正しい値が取れる + assert "jawOpen" in infer_idx + assert "jawOpen" in fallback_idx + + def test_frontend_uses_names_not_indices(self, mock_a2e_response): + """フロントエンドは names 配列を使ってマッピングするため、 + 順序の違いは問題にならない""" + data = mock_a2e_response + # names と frames を zip して dict にする (フロントエンドのロジック) + frame_dict = dict(zip(data["names"], data["frames"][0])) + assert "jawOpen" in frame_dict