From 04e62ba03061a9ac871f59749e75139ebceaa679 Mon Sep 17 00:00:00 2001 From: Hare Date: Wed, 4 Feb 2026 21:11:17 +0900 Subject: [PATCH] feat: reference text strip --- __init__.py | 11 ++++ flake.nix | 12 +--- operators.py | 146 ++++++++++++++++++++++++++++++++++++++--- operators_reference.py | 60 +++++++++++++++++ operators_speaker.py | 36 ++++++++++ panels.py | 14 +++- properties.py | 88 ++++++++++++++++++++----- subtitles.py | 70 ++++++++++---------- voicevox.py | 70 +++++++++++++------- 9 files changed, 410 insertions(+), 97 deletions(-) create mode 100644 operators_reference.py create mode 100644 operators_speaker.py diff --git a/__init__.py b/__init__.py index 2c6f56c..3656039 100644 --- a/__init__.py +++ b/__init__.py @@ -17,6 +17,8 @@ bl_info = { import bpy from . import operators +from . import operators_reference +from . import operators_speaker from . import panels from . import properties @@ -26,6 +28,9 @@ classes = ( operators.VOICEVOX_OT_generate_speech, operators.VOICEVOX_OT_add_subtitle, operators.VOICEVOX_OT_test_connection, + operators_reference.VOICEVOX_OT_set_reference_text, + operators_reference.VOICEVOX_OT_clear_reference_text, + operators_speaker.VOICEVOX_OT_refresh_speakers, panels.VOICEVOX_PT_main_panel, ) @@ -38,6 +43,12 @@ def register(): # シーンにプロパティを追加 bpy.types.Scene.voicevox = bpy.props.PointerProperty(type=properties.VoiceVoxProperties) + # 初回起動時に話者リストを取得 + try: + properties.update_speaker_cache() + except: + pass # 初回はVoiceVoxが起動していない可能性があるのでスキップ + print("VoiceVox Plugin registered") diff --git a/flake.nix b/flake.nix index 1daeddb..e4cde3a 100644 --- a/flake.nix +++ b/flake.nix @@ -8,7 +8,6 @@ outputs = { - self, nixpkgs, flake-utils, }: @@ -20,21 +19,14 @@ { devShells.default = pkgs.mkShell { buildInputs = with pkgs; [ - # Python開発環境 python311 python311Packages.pip python311Packages.requests python311Packages.pillow - - # Blender (開発・テスト用) blender - - # 開発ツール git - - # VoiceVoxエンジン(オプション) - # voicevox-engine は Nix パッケージとして提供されていない可能性があるため - # 別途インストールが必要 + voicevox-core + voicevox-engine ]; shellHook = '' diff --git a/operators.py b/operators.py index 4adcb69..69bfc24 100644 --- a/operators.py +++ b/operators.py @@ -9,6 +9,93 @@ from .voicevox import VoiceVoxAPI from .subtitles import SubtitleManager +def get_text_strip_settings(context): + """ + リファレンスまたは選択されているテキストストリップから設定を抽出 + + Returns: + dict: 抽出された設定、見つからない場合はNone + """ + if not context.scene.sequence_editor: + return None + + seq_editor = context.scene.sequence_editor + props = context.scene.voicevox + text_strip = None + + # 1. まずリファレンステキストストリップをチェック + if props.reference_text_strip: + for strip in seq_editor.strips_all: + if strip.name == props.reference_text_strip and strip.type == 'TEXT': + text_strip = strip + print(f"[VoiceVox] リファレンステキスト '{strip.name}' を使用") + break + + # 2. リファレンスが見つからない場合は選択されているストリップを使用 + if not text_strip: + if seq_editor.active_strip and seq_editor.active_strip.type == 'TEXT': + text_strip = seq_editor.active_strip + print(f"[VoiceVox] アクティブなテキスト '{text_strip.name}' を使用") + else: + # 選択されているストリップからテキストを探す + for strip in seq_editor.strips_all: + if strip.select and strip.type == 'TEXT': + text_strip = strip + print(f"[VoiceVox] 選択されたテキスト '{text_strip.name}' を使用") + break + + if not text_strip: + return None + + # すべてのプロパティを動的に抽出 + settings = {} + + # ストリップ固有のプロパティ(コピーしないもの) + skip_properties = { + 'name', 'type', 'frame_start', 'frame_final_start', + 'frame_final_end', 'frame_final_duration', 'frame_duration', + 'frame_offset_start', 'frame_offset_end', 'frame_still_start', + 'frame_still_end', 'select', 'select_left_handle', + 'select_right_handle', 'mute', 'lock', 'text', # textは別途設定 + 'channel', # channelはUIの設定を優先 + } + + # bl_rna.propertiesから全プロパティを取得 + if hasattr(text_strip, 'bl_rna') and hasattr(text_strip.bl_rna, 'properties'): + for prop in text_strip.bl_rna.properties: + prop_name = prop.identifier + + # スキップするプロパティ + if prop_name in skip_properties: + continue + + # 読み取り専用プロパティはスキップ + if prop.is_readonly: + continue + + # プロパティの値を取得 + try: + value = getattr(text_strip, prop_name) + + # locationは特別処理 + if prop_name == 'location': + try: + settings['position_x'] = value[0] + settings['position_y'] = value[1] + except (TypeError, IndexError): + pass + else: + settings[prop_name] = value + + print(f"[VoiceVox] コピー: {prop_name} = {value}") + except Exception as e: + print(f"[VoiceVox] {prop_name} の取得をスキップ: {e}") + + print(f"[VoiceVox] テキストストリップ '{text_strip.name}' から {len(settings)} 個のプロパティを抽出") + + return settings + + class VOICEVOX_OT_test_connection(Operator): """VoiceVoxエンジンへの接続をテスト""" bl_idname = "voicevox.test_connection" @@ -52,9 +139,12 @@ class VOICEVOX_OT_generate_speech(Operator): output_dir = bpy.path.abspath(props.output_directory) print(f"[VoiceVox] 出力先: {output_dir}") + # speakerプロパティからIDを取得 + speaker_id = int(props.speaker) if props.speaker else 0 + audio_file = api.generate_speech( text=props.text, - speaker_id=props.speaker_id, + speaker_id=speaker_id, speed_scale=props.speed_scale, pitch_scale=props.pitch_scale, intonation_scale=props.intonation_scale, @@ -93,6 +183,7 @@ class VOICEVOX_OT_generate_speech(Operator): seq_editor = context.scene.sequence_editor frame_current = context.scene.frame_current + props = context.scene.voicevox # 音声を追加 audio_strip = seq_editor.strips.new_sound( @@ -106,17 +197,36 @@ class VOICEVOX_OT_generate_speech(Operator): audio_duration_frames = audio_strip.frame_final_duration print(f"[VoiceVox] 音声の長さ: {audio_duration_frames} フレーム") + # 選択されているテキストストリップから設定を抽出(あれば) + text_settings = get_text_strip_settings(context) + # 字幕を追加(音声の長さに合わせる) - props = context.scene.voicevox subtitle_mgr = SubtitleManager() + + # テキストストリップから設定を取得できた場合はそれを優先 + if text_settings: + font_size = text_settings.get('font_size', 48) # デフォルト48 + position_y = text_settings.get('position_y', 0.1) # デフォルト0.1 + channel = text_settings.get('channel', props.subtitle_channel) + # その他の設定も渡す + extra_settings = {k: v for k, v in text_settings.items() + if k not in ['font_size', 'position_y', 'channel']} + else: + # リファレンスがない場合のデフォルト値 + font_size = 48 + position_y = 0.1 + channel = props.subtitle_channel + extra_settings = {} + subtitle_mgr.add_subtitle( context=context, text=text, frame_start=frame_current, duration_frames=audio_duration_frames, # 音声の長さに合わせる - font_size=props.subtitle_font_size, - position_y=props.subtitle_position_y, - channel=props.subtitle_channel, + font_size=font_size, + position_y=position_y, + channel=channel, + **extra_settings, ) @@ -143,15 +253,35 @@ class VOICEVOX_OT_add_subtitle(Operator): default_duration_sec = 3.0 duration_frames = int(default_duration_sec * context.scene.render.fps) + # 選択されているテキストストリップから設定を抽出(あれば) + text_settings = get_text_strip_settings(context) + subtitle_mgr = SubtitleManager() + + # テキストストリップから設定を取得できた場合はそれを優先 + if text_settings: + font_size = text_settings.get('font_size', 48) # デフォルト48 + position_y = text_settings.get('position_y', 0.1) # デフォルト0.1 + channel = text_settings.get('channel', props.subtitle_channel) + # その他の設定も渡す + extra_settings = {k: v for k, v in text_settings.items() + if k not in ['font_size', 'position_y', 'channel']} + else: + # リファレンスがない場合のデフォルト値 + font_size = 48 + position_y = 0.1 + channel = props.subtitle_channel + extra_settings = {} + subtitle_mgr.add_subtitle( context=context, text=props.text, frame_start=frame_current, duration_frames=duration_frames, - font_size=props.subtitle_font_size, - position_y=props.subtitle_position_y, - channel=props.subtitle_channel, + font_size=font_size, + position_y=position_y, + channel=channel, + **extra_settings, ) self.report({'INFO'}, "字幕を追加しました") diff --git a/operators_reference.py b/operators_reference.py new file mode 100644 index 0000000..b3e62a0 --- /dev/null +++ b/operators_reference.py @@ -0,0 +1,60 @@ +""" +リファレンステキストストリップ管理用オペレーター +""" + +import bpy +from bpy.types import Operator + + +class VOICEVOX_OT_set_reference_text(Operator): + """選択したテキストストリップをリファレンスとして設定""" + bl_idname = "voicevox.set_reference_text" + bl_label = "Set as Reference" + bl_description = "選択したテキストストリップをリファレンスとして設定します" + bl_options = {'REGISTER', 'UNDO'} + + def execute(self, context): + if not context.scene.sequence_editor: + self.report({'ERROR'}, "シーケンスエディタがありません") + return {'CANCELLED'} + + seq_editor = context.scene.sequence_editor + props = context.scene.voicevox + + # 選択されているテキストストリップを探す + text_strip = None + + if seq_editor.active_strip and seq_editor.active_strip.type == 'TEXT': + text_strip = seq_editor.active_strip + else: + for strip in seq_editor.strips_all: + if strip.select and strip.type == 'TEXT': + text_strip = strip + break + + if not text_strip: + self.report({'ERROR'}, "テキストストリップが選択されていません") + return {'CANCELLED'} + + # リファレンスとして設定 + props.reference_text_strip = text_strip.name + self.report({'INFO'}, f"リファレンステキスト: '{text_strip.name}'") + print(f"[VoiceVox] リファレンステキストを設定: {text_strip.name}") + + return {'FINISHED'} + + +class VOICEVOX_OT_clear_reference_text(Operator): + """リファレンステキストストリップをクリア""" + bl_idname = "voicevox.clear_reference_text" + bl_label = "Clear Reference" + bl_description = "リファレンステキストストリップをクリアします" + bl_options = {'REGISTER', 'UNDO'} + + def execute(self, context): + props = context.scene.voicevox + props.reference_text_strip = "" + self.report({'INFO'}, "リファレンステキストをクリアしました") + print(f"[VoiceVox] リファレンステキストをクリア") + + return {'FINISHED'} diff --git a/operators_speaker.py b/operators_speaker.py new file mode 100644 index 0000000..93cdb51 --- /dev/null +++ b/operators_speaker.py @@ -0,0 +1,36 @@ +""" +話者リスト更新用オペレーター +""" + +import bpy +from bpy.types import Operator +from .properties import update_speaker_cache + + +class VOICEVOX_OT_refresh_speakers(Operator): + """VoiceVoxから話者リストを再取得""" + bl_idname = "voicevox.refresh_speakers" + bl_label = "Refresh Speakers" + bl_description = "VoiceVoxエンジンから話者リストを再取得します" + bl_options = {'REGISTER'} + + def execute(self, context): + props = context.scene.voicevox + + # VoiceVox APIから話者リストを取得してキャッシュを更新 + count = update_speaker_cache(props.voicevox_host, props.voicevox_port) + + if count > 0: + self.report({'INFO'}, f"{count}人の話者を取得しました") + else: + self.report({'WARNING'}, "話者の取得に失敗しました") + + # EnumPropertyを強制的に再評価 + current = props.speaker + try: + props.speaker = '0' + props.speaker = current + except: + pass + + return {'FINISHED'} diff --git a/panels.py b/panels.py index 7c35a53..7b8e204 100644 --- a/panels.py +++ b/panels.py @@ -39,7 +39,9 @@ class VOICEVOX_PT_main_panel(Panel): # 音声設定 box = layout.box() box.label(text="Voice Settings", icon='SPEAKER') - box.prop(props, "speaker_id") + row = box.row() + row.prop(props, "speaker", text="") + row.operator("voicevox.refresh_speakers", text="", icon='FILE_REFRESH') box.prop(props, "speed_scale", slider=True) box.prop(props, "pitch_scale", slider=True) box.prop(props, "intonation_scale", slider=True) @@ -50,8 +52,14 @@ class VOICEVOX_PT_main_panel(Panel): # 字幕設定 box = layout.box() box.label(text="Subtitle Settings", icon='FONT_DATA') - box.prop(props, "subtitle_font_size") - box.prop(props, "subtitle_position_y", slider=True) + + # リファレンステキスト + if props.reference_text_strip: + row = box.row() + row.label(text=f"Ref: {props.reference_text_strip}", icon='LINKED') + row.operator("voicevox.clear_reference_text", text="", icon='X') + else: + box.operator("voicevox.set_reference_text", icon='EYEDROPPER') layout.separator() diff --git a/properties.py b/properties.py index b0b6637..10264ab 100644 --- a/properties.py +++ b/properties.py @@ -12,6 +12,63 @@ from bpy.props import ( EnumProperty, ) +# グローバル変数で話者リストをキャッシュ +_speaker_cache = [] + + +def get_speaker_items(self, context): + """キャッシュされた話者一覧を返す""" + global _speaker_cache + + if not _speaker_cache: + # キャッシュが空の場合はデフォルトを返す + return [('0', 'リロードボタンを押してください', 'VoiceVoxから話者リストを取得')] + + return _speaker_cache + + +def update_speaker_cache(host: str = "127.0.0.1", port: int = 50021): + """VoiceVox APIから話者リストを取得してキャッシュを更新""" + global _speaker_cache + from .voicevox import VoiceVoxAPI + + items = [] + + try: + api = VoiceVoxAPI(host, port) + speakers = api.get_speakers() + + if speakers: + for speaker in speakers: + name = speaker.get('name', 'Unknown') + styles = speaker.get('styles', []) + + for style in styles: + style_name = style.get('name', '') + style_id = style.get('id', 0) + + # 表示名: "キャラクター名 (スタイル名)" + display_name = f"{name} ({style_name})" if style_name else name + + items.append(( + str(style_id), + display_name, + f"Speaker ID: {style_id}", + )) + + _speaker_cache = items + return len(items) + else: + _speaker_cache = [('0', 'VoiceVoxに接続できません', '')] + return 0 + + except Exception as e: + print(f"[VoiceVox] 話者一覧の取得に失敗: {e}") + import traceback + traceback.print_exc() + _speaker_cache = [('0', '接続エラー', str(e))] + return 0 + class VoiceVoxProperties(bpy.types.PropertyGroup): """VoiceVoxプラグインのプロパティ""" @@ -38,6 +95,13 @@ class VoiceVoxProperties(bpy.types.PropertyGroup): default="", ) + speaker: EnumProperty( + name="Speaker", + description="話者を選択", + items=get_speaker_items, + ) + + # 後方互換性のため残す(内部的にはspeakerを使用) speaker_id: IntProperty( name="Speaker ID", description="話者ID (VoiceVoxのキャラクター)", @@ -77,23 +141,6 @@ class VoiceVoxProperties(bpy.types.PropertyGroup): max=2.0, ) - # 字幕の設定 - subtitle_font_size: IntProperty( - name="Font Size", - description="字幕のフォントサイズ", - default=48, - min=12, - max=200, - ) - - subtitle_position_y: FloatProperty( - name="Position Y", - description="字幕の垂直位置 (0.0 = 下, 1.0 = 上)", - default=0.1, - min=0.0, - max=1.0, - ) - # チャンネル設定 audio_channel: IntProperty( name="Audio Channel", @@ -111,6 +158,13 @@ class VoiceVoxProperties(bpy.types.PropertyGroup): max=128, ) + # リファレンステキストストリップ + reference_text_strip: StringProperty( + name="Reference Text Strip", + description="設定を流用するリファレンステキストストリップの名前", + default="", + ) + # 出力設定 output_directory: StringProperty( name="Output Directory", diff --git a/subtitles.py b/subtitles.py index 1357d90..0aad3df 100644 --- a/subtitles.py +++ b/subtitles.py @@ -18,6 +18,7 @@ class SubtitleManager: font_size: int = 48, position_y: float = 0.1, channel: int = 2, + **extra_settings, ): """ シーケンサーに字幕を追加 @@ -50,44 +51,45 @@ class SubtitleManager: # テキスト内容を設定 text_strip.text = text - # Blender 5.0で利用可能なプロパティのみ設定 - try: - text_strip.font_size = font_size - except AttributeError: - print(f"[Subtitle] font_size プロパティは利用できません") + # デフォルト設定 + default_settings = { + 'use_shadow': True, + 'shadow_color': (0.0, 0.0, 0.0, 0.8), + 'color': (1.0, 1.0, 1.0, 1.0), + 'blend_type': 'ALPHA_OVER', + 'align_x': 'CENTER', + 'align_y': 'BOTTOM', + } - try: - text_strip.use_shadow = True - text_strip.shadow_color = (0.0, 0.0, 0.0, 0.8) - except AttributeError: - print(f"[Subtitle] shadow プロパティは利用できません") + # デフォルト設定から開始し、extra_settingsで上書き + all_settings = default_settings.copy() + if extra_settings: + all_settings.update(extra_settings) + + # font_sizeは常にパラメータの値を使用 + all_settings['font_size'] = font_size + + # 位置の処理 + position_x = all_settings.pop('position_x', 0.5) + position_y_val = all_settings.pop('position_y', position_y) + + # 全てのプロパティを設定 + for prop, value in all_settings.items(): + try: + setattr(text_strip, prop, value) + print(f"[Subtitle] 設定: {prop} = {value}") + except AttributeError: + print(f"[Subtitle] {prop} プロパティは利用できません(スキップ)") + except Exception as e: + print(f"[Subtitle] {prop} の設定に失敗: {e}") # 位置を設定 try: - text_strip.location[0] = 0.5 # 水平中央 - text_strip.location[1] = position_y # 垂直位置 - except (AttributeError, TypeError): - print(f"[Subtitle] location プロパティは利用できません") - - # テキストの配置(Blender 5.0では変更された可能性あり) - try: - text_strip.align_x = 'CENTER' - text_strip.align_y = 'BOTTOM' - except AttributeError: - # Blender 5.0では align_x/align_y が削除または変更された - print(f"[Subtitle] align_x/align_y プロパティは利用できません") - - # テキストの色(白) - try: - text_strip.color = (1.0, 1.0, 1.0, 1.0) - except AttributeError: - print(f"[Subtitle] color プロパティは利用できません") - - # ブレンドモード - try: - text_strip.blend_type = 'ALPHA_OVER' - except AttributeError: - print(f"[Subtitle] blend_type プロパティは利用できません") + text_strip.location[0] = position_x + text_strip.location[1] = position_y_val + print(f"[Subtitle] location = ({position_x}, {position_y_val})") + except (AttributeError, TypeError) as e: + print(f"[Subtitle] location プロパティは利用できません: {e}") print(f"Subtitle added: '{text}' at frame {frame_start}") diff --git a/voicevox.py b/voicevox.py index 79e617b..0542b4c 100644 --- a/voicevox.py +++ b/voicevox.py @@ -3,12 +3,12 @@ VoiceVox API連携 VoiceVoxエンジンとの通信を担当 """ -import os -import json import hashlib -from typing import Optional -import urllib.request +import json +import os import urllib.error +import urllib.request +from typing import Optional class VoiceVoxAPI: @@ -34,9 +34,16 @@ class VoiceVoxAPI: url = f"{self.base_url}/speakers" with urllib.request.urlopen(url, timeout=10) as response: data = response.read() - return json.loads(data) + # UTF-8として明示的にデコード + text = data.decode("utf-8") + speakers = json.loads(text) + print(f"[VoiceVox API] 話者を {len(speakers)} 人取得") + return speakers except Exception as e: print(f"Failed to get speakers: {e}") + import traceback + + traceback.print_exc() return None def generate_speech( @@ -62,45 +69,51 @@ class VoiceVoxAPI: # 1. 音声合成用のクエリを作成 print(f"[VoiceVox API] Step 2: 音声クエリを生成中...") query_url = f"{self.base_url}/audio_query" - query_params = urllib.parse.urlencode({ - 'text': text, - 'speaker': speaker_id, - }) + query_params = urllib.parse.urlencode( + { + "text": text, + "speaker": speaker_id, + } + ) full_query_url = f"{query_url}?{query_params}" print(f"[VoiceVox API] リクエストURL: {full_query_url}") try: # POSTリクエストにするため空のdataを渡す - req = urllib.request.Request(full_query_url, data=b'', method='POST') + req = urllib.request.Request(full_query_url, data=b"", method="POST") with urllib.request.urlopen(req, timeout=10) as response: query_data = json.loads(response.read()) print(f"[VoiceVox API] クエリ生成成功") except urllib.error.HTTPError as e: print(f"[VoiceVox API Error] HTTPエラー: {e.code} {e.reason}") - print(f"[VoiceVox API Error] レスポンス: {e.read().decode('utf-8', errors='replace')}") + print( + f"[VoiceVox API Error] レスポンス: {e.read().decode('utf-8', errors='replace')}" + ) raise except urllib.error.URLError as e: print(f"[VoiceVox API Error] 接続エラー: {e.reason}") - print(f"[VoiceVox API Error] VoiceVoxエンジンが起動しているか確認してください") - raise + print( + f"[VoiceVox API Error] VoiceVoxエンジンが起動しているか確認してください" + ) + raisee # パラメータを適用 - query_data['speedScale'] = speed_scale - query_data['pitchScale'] = pitch_scale - query_data['intonationScale'] = intonation_scale - query_data['volumeScale'] = volume_scale + query_data["speedScale"] = speed_scale + query_data["pitchScale"] = pitch_scale + query_data["intonationScale"] = intonation_scale + query_data["volumeScale"] = volume_scale # 2. 音声合成を実行 print(f"[VoiceVox API] Step 3: 音声合成を実行中...") synthesis_url = f"{self.base_url}/synthesis" - synthesis_params = urllib.parse.urlencode({'speaker': speaker_id}) + synthesis_params = urllib.parse.urlencode({"speaker": speaker_id}) req = urllib.request.Request( f"{synthesis_url}?{synthesis_params}", - data=json.dumps(query_data).encode('utf-8'), - headers={'Content-Type': 'application/json'}, - method='POST', + data=json.dumps(query_data).encode("utf-8"), + headers={"Content-Type": "application/json"}, + method="POST", ) try: @@ -109,27 +122,32 @@ class VoiceVoxAPI: print(f"[VoiceVox API] 音声合成成功 ({len(audio_data)} bytes)") except urllib.error.HTTPError as e: print(f"[VoiceVox API Error] 合成HTTPエラー: {e.code} {e.reason}") - print(f"[VoiceVox API Error] レスポンス: {e.read().decode('utf-8', errors='replace')}") + print( + f"[VoiceVox API Error] レスポンス: {e.read().decode('utf-8', errors='replace')}" + ) raise # 3. 音声ファイルを保存 print(f"[VoiceVox API] Step 4: 音声ファイルを保存中...") - text_hash = hashlib.md5(text.encode('utf-8')).hexdigest()[:8] + text_hash = hashlib.md5(text.encode("utf-8")).hexdigest()[:8] filename = f"voicevox_{text_hash}_{speaker_id}.wav" filepath = os.path.join(output_dir, filename) - with open(filepath, 'wb') as f: + with open(filepath, "wb") as f: f.write(audio_data) print(f"[VoiceVox API] 音声保存完了: {filepath}") return filepath except urllib.error.URLError as e: - error_msg = f"接続エラー: VoiceVoxエンジンに接続できません ({self.base_url})" + error_msg = ( + f"接続エラー: VoiceVoxエンジンに接続できません ({self.base_url})" + ) print(f"[VoiceVox API Error] {error_msg}") print(f"[VoiceVox API Error] 詳細: {e}") self.last_error = error_msg import traceback + traceback.print_exc() return None except urllib.error.HTTPError as e: @@ -137,6 +155,7 @@ class VoiceVoxAPI: print(f"[VoiceVox API Error] {error_msg}") self.last_error = error_msg import traceback + traceback.print_exc() return None except Exception as e: @@ -144,5 +163,6 @@ class VoiceVoxAPI: print(f"[VoiceVox API Error] 音声生成失敗: {error_msg}") self.last_error = error_msg import traceback + traceback.print_exc() return None