tktts.py ダウンロード/コピー

tktts.py をダウンロード

tktts.py
tktts.py
   1"""tktts.py: 複数のTTS(Text-to-Speech)エンジンを統合し、テキストからの音声合成機能を提供します。
   2
   3さまざまなTTSバックエンド(pyttsx3, WinRT, VOICEVOXなど)を動的にロードし、
   4テキストを音声に変換して再生またはファイルに保存する機能を提供します。
   5話者ごとの音声設定、一時ファイルの管理、音声の結合なども行います。
   6
   7:doc:`tktts_usage`
   8"""
   9import os
  10import sys
  11import re
  12import time
  13import subprocess
  14import shutil
  15import tempfile
  16from pathlib import Path
  17import traceback
  18import inspect 
  19
  20# --- コアライブラリとオプションTTSバックエンド ---
  21# 各TTSエンジン固有の依存関係は必須にしない。
  22# 使えるバックエンドだけを安全に登録する。
  23import importlib
  24import importlib.util
  25from types import SimpleNamespace
  26
  27CORE_LIBS = ["pydub", "pyperclip", "chardet"]
  28missing_core = [name for name in CORE_LIBS if importlib.util.find_spec(name) is None]
  29if missing_core:
  30    print(f"Error: Missing core libraries: {', '.join(missing_core)}")
  31    print("  install: pip install pydub pyperclip chardet")
  32    print("  MP3などを扱う場合は FFmpeg もPATHに設定してください。")
  33    raise SystemExit(1)
  34
  35import pyperclip
  36import chardet
  37from pydub import AudioSegment
  38
  39
  40def check_ffmpeg_available() -> bool:
  41    """システムにFFmpegがインストールされているかを確認します。
  42
  43    FFmpegとFFprobeの両方の実行可能ファイルがシステムのPATHで利用可能かをチェックし、
  44    その結果をコンソールに出力します。
  45
  46    :returns: bool - FFmpegとFFprobeの両方が利用可能であればTrue、そうでなければFalse。
  47    """
  48    ffmpeg = shutil.which("ffmpeg")
  49    ffprobe = shutil.which("ffprobe")
  50
  51    if ffmpeg is None:
  52        print("❌ ffmpeg が見つかりません。")
  53        return False
  54
  55    if ffprobe is None:
  56        print("⚠️ ffprobe が見つかりません。読み込み時に問題が出る可能性があります。")
  57        return False
  58
  59    print(f"✅ ffmpeg: {ffmpeg}")
  60    print(f"✅ ffprobe: {ffprobe}")
  61    return True
  62
  63ffmpeg_path = shutil.which("ffmpeg")
  64
  65if ffmpeg_path is None:
  66    print("tktts.py: ffmpeg が見つかりません")
  67    raise SystemExit(1)
  68else:
  69#    print("tktts.py: ffmpeg が使えます:", ffmpeg_path)
  70    pass
  71
  72
  73def _safe_import(module_name: str, dependency: str | None = None):
  74    """オプションのTTSバックエンドモジュールを安全にインポートします。
  75
  76    依存ライブラリがインストールされていない場合や、インポート中にエラーが発生した場合は、
  77    そのモジュールをスキップし、Noneを返します。
  78    これにより、一部のTTSバックエンドが利用できなくても、モジュール全体がクラッシュすることなく
  79    動作を継続できます。
  80
  81    :param module_name: str: インポートするモジュールの名前。
  82    :param dependency: str or None: ``module_name`` が依存する外部ライブラリの名前。
  83                                    この依存ライブラリが未導入の場合はインポートを試みません。
  84    :returns: module or None - 正常にインポートされたモジュール、またはインポートに失敗した場合はNone。
  85    """
  86    if dependency and importlib.util.find_spec(dependency) is None:
  87        print(
  88            f"Warning in tktts.py: {module_name} is disabled "
  89            f"because optional dependency [{dependency}] is not installed."
  90        )
  91        return None
  92
  93    try:
  94        return importlib.import_module(module_name)
  95    except SystemExit as e:
  96        print(f"Warning in tktts.py: {module_name} terminated during import: {e}")
  97        return None
  98    except Exception as e:
  99        print(f"\nWarning in tktts.py: Import error for {module_name}")
 100        print("------------------------------------------------------------------")
 101        print(f"Error message: {e}")
 102        traceback.print_exc()
 103        print("------------------------------------------------------------------")
 104        return None
 105
 106
 107tktts_pyttsx3 = _safe_import("tktts_pyttsx3", "pyttsx3")
 108tktts_winrt = _safe_import("tktts_winrt", "winsdk")
 109tktts_voicevox = _safe_import("tktts_voicevox")
 110tktts_qwen3 = _safe_import("tktts_qwen3", "qwen_tts")
 111tktts_irodori = _safe_import("tktts_irodori", "irodori_tts")
 112tktts_aquestalkplayer = _safe_import("tktts_aquestalkplayer")
 113tktts_openai = _safe_import("tktts_openai", "openai")
 114tktts_elevenlabs = _safe_import("tktts_elevenlabs", "elevenlabs")
 115tktts_gemini = _safe_import("tktts_gemini", "google.genai")
 116
 117
 118default_pyttsx3_voice = "Zira"
 119default_winrt_voice = "Ayumi"
 120default_voicevox_voice = "四国めたん"
 121default_qwen3_voice = "Ono_Anna"
 122default_irodori_voice = "default"
 123default_aqt_preset = "れいむ"
 124default_openai_voice = "alloy"
 125# 旧コードとの互換性のため、綴り間違いの変数名も残す。
 126default_optnai_voice = default_openai_voice
 127default_elevenlabs_voice = "Rachel"
 128default_gemini_voice = "Kore"
 129
 130TTS_ENGINES = {
 131    "pyttsx3": {
 132        "engine": tktts_pyttsx3,
 133        "default_voice": default_pyttsx3_voice,
 134        "ext": "wav",
 135        "direct_playback": True,
 136    },
 137    "winrt": {
 138        "engine": tktts_winrt,
 139        "default_voice": default_winrt_voice,
 140        "ext": "wav",
 141        "direct_playback": True,
 142    },
 143    "onecore": {
 144        "engine": tktts_winrt,
 145        "default_voice": default_winrt_voice,
 146        "ext": "wav",
 147        "direct_playback": True,
 148    },
 149    "voicevox": {
 150        "engine": tktts_voicevox,
 151        "default_voice": default_voicevox_voice,
 152        "ext": "wav",
 153        "direct_playback": False,
 154    },
 155    "qwen3": {
 156        "engine": tktts_qwen3,
 157        "default_voice": default_qwen3_voice,
 158        "ext": "wav",
 159        "direct_playback": False,
 160    },
 161    "qwen": {
 162        "engine": tktts_qwen3,
 163        "default_voice": default_qwen3_voice,
 164        "ext": "wav",
 165        "direct_playback": False,
 166    },
 167    "irodori": {
 168        "engine": tktts_irodori,
 169        "default_voice": default_irodori_voice,
 170        "ext": "wav",
 171        "direct_playback": False,
 172    },
 173    "irodori-tts": {
 174        "engine": tktts_irodori,
 175        "default_voice": default_irodori_voice,
 176        "ext": "wav",
 177        "direct_playback": False,
 178    },
 179    "aquestalkplayer": {
 180        "engine": tktts_aquestalkplayer,
 181        "default_voice": default_aqt_preset,
 182        "ext": "wav",
 183        "direct_playback": False,
 184    },
 185    "atp": {
 186        "engine": tktts_aquestalkplayer,
 187        "default_voice": default_aqt_preset,
 188        "ext": "wav",
 189        "direct_playback": False,
 190    },
 191    "openai": {
 192        "engine": tktts_openai,
 193        "default_voice": default_openai_voice,
 194        "ext": "mp3",
 195        "direct_playback": False,
 196    },
 197    "elevenlabs": {
 198        "engine": tktts_elevenlabs,
 199        "default_voice": default_elevenlabs_voice,
 200        "ext": "mp3",
 201        "direct_playback": False,
 202    },
 203    "eleven": {
 204        "engine": tktts_elevenlabs,
 205        "default_voice": default_elevenlabs_voice,
 206        "ext": "mp3",
 207        "direct_playback": False,
 208    },
 209    "gemini": {
 210        "engine": tktts_gemini,
 211        "default_voice": default_gemini_voice,
 212        "ext": "wav",
 213        "direct_playback": False,
 214    },
 215}
 216
 217
 218def get_available_engines(available_only: bool = True) -> list[str]:
 219    """利用可能なTTSエンジンまたは登録されている全てのTTSエンジン名をリストとして返します。
 220
 221    GUIのプルダウンメニュー生成などにも利用できます。
 222
 223    :param available_only: bool: Trueの場合、実際にロードされて利用可能なエンジンのみを返します。
 224                                 Falseの場合、設定ファイルに登録されている全てのエンジン名を返します。
 225    :returns: list[str] - TTSエンジンの名前のリスト。
 226    """
 227    if not available_only:
 228        return list(TTS_ENGINES.keys())
 229    return [name for name, cfg in TTS_ENGINES.items() if cfg["engine"] is not None]
 230
 231
 232def _supported_kwargs(func, kwargs: dict) -> dict:
 233    """関数が受け取れるキーワード引数だけを残します。
 234
 235    関数のシグネチャを検査し、関数が定義している引数、または ``**kwargs`` を受け入れる場合にのみ、
 236    辞書内の引数を保持します。これにより、バックエンド関数が予期しない引数を受け取ってエラーになるのを防ぎます。
 237
 238    :param func: Callable: キーワード引数を適用する対象の関数。
 239    :param kwargs: dict: フィルタリング対象のキーワード引数の辞書。
 240    :returns: dict - 関数が受け取れるキーワード引数のみを含む辞書。
 241    """
 242    try:
 243        signature = inspect.signature(func)
 244    except (TypeError, ValueError):
 245        # シグネチャが取得できない場合は、Noneでない全ての引数を返す
 246        return {k: v for k, v in kwargs.items() if v is not None}
 247
 248    # 関数が可変キーワード引数 (**kwargs) を受け入れる場合は、Noneでない全ての引数を返す
 249    if any(p.kind == inspect.Parameter.VAR_KEYWORD for p in signature.parameters.values()):
 250        return {k: v for k, v in kwargs.items() if v is not None}
 251
 252    # 関数が定義している引数のみをフィルタリング
 253    return {
 254        k: v
 255        for k, v in kwargs.items()
 256        if v is not None and k in signature.parameters
 257    }
 258
 259
 260def _call_backend(func, **kwargs):
 261    """内部的にバックエンド関数の呼び出しをラップし、サポートされている引数のみを渡します。
 262
 263    :param func: Callable: 呼び出すバックエンド関数。
 264    :param kwargs: Any: 関数に渡すキーワード引数。
 265    :returns: Any - バックエンド関数の実行結果。
 266    """
 267    return func(**_supported_kwargs(func, kwargs))
 268
 269def apply_replacements(text: str, replacements: dict[str, str] | None) -> str:
 270    """置換辞書を使ってテキストを置換(大文字小文字無視)。
 271
 272    :param text: str: 置換処理を行う元のテキスト。
 273    :param replacements: dict[str, str] or None: キーを検索パターン、値を置換後の文字列とする辞書。
 274                                                Noneの場合、置換は行われません。
 275    :returns: str - 置換処理が適用されたテキスト。
 276    """
 277    for key, val in (replacements or {}).items():
 278        text = re.sub(key, val, text, flags=re.IGNORECASE)
 279    return text
 280
 281def load_text(input_path: str, monologue: bool = False, wait_for_clipboard: bool = True) -> list[tuple[str | None, str]] | None:
 282    """入力元('clip'またはファイルパス)からテキストを取得し、(speaker, text)リストを返します。
 283
 284    入力パスが"clip"の場合、クリップボードからテキストを取得します。
 285    ファイルパスの場合、ファイルを読み込み、chardetでエンコーディングを検出します。
 286    テキストは行ごとに解析され、カンマで区切られた話者とテキストのペア、
 287    またはモノローグ形式で返されます。
 288
 289    :param input_path: str: テキストの入力元。"clip"またはファイルパス。
 290    :param monologue: bool: Trueの場合、全てを単一の話者のテキストとして扱います。
 291                           Falseの場合、行を「話者,テキスト」形式で解析します。
 292    :param wait_for_clipboard: bool: ``input_path`` が "clip" の場合に、
 293                                     ユーザーがクリップボードにコピーするのを待つか。
 294    :returns: list[tuple[str or None, str]] or None - (話者名, テキスト)のタプルのリスト、
 295                                                    またはエラー発生時はNone。
 296    """
 297
 298    print()
 299    print(f"load_text from {input_path}")
 300    if input_path.lower() == "clip" and wait_for_clipboard:
 301        print()
 302        input("読み上げるテキストをクリップボードにコピーしてください:\n")
 303        print("📥 クリップボードからテキストを取得中...")
 304        text = pyperclip.paste()
 305        text_lines = text.splitlines()
 306    else:
 307        file_name = input_path
 308        if not os.path.isfile(file_name):
 309            print(f"Error: ファイル [{file_name}] が見つかりません。")
 310            return None
 311
 312        print(f"📖 ファイル [{file_name}] を読み込み中...")
 313        try:
 314            with open(file_name, "rb") as f:
 315                raw_data = f.read()
 316            
 317            result = chardet.detect(raw_data)
 318            encoding = result["encoding"]
 319            if encoding is None: encoding = 'utf-8'
 320            
 321            text = raw_data.decode(encoding, errors='ignore')
 322            print(f"  検出されたエンコード: {encoding}")
 323            text_lines = text.splitlines()
 324        except Exception as e:
 325            print(f"❌ ファイル読み込み/エンコード判別エラー: {e}")
 326            return None
 327
 328    # dialogueリストへの変換 (既存ロジックを流用)
 329    dialogue = []
 330    for line in text_lines:
 331        line = line.strip()
 332        if not line: continue
 333        if line.startswith("#"): continue
 334
 335        if monologue:
 336            dialogue.append((None, line.strip()))
 337        else:
 338            if "," in line:
 339                try:
 340                    speaker, text = line.split(",", 1)
 341                    dialogue.append((speaker.strip(), text.strip()))
 342                except ValueError:
 343                    continue
 344            # 対話モードでカンマがない行は無視
 345            # 修正前のコードではカンマがない行は無視されているため、ここでは continue 
 346            continue
 347                
 348    return dialogue
 349
 350def create_temp_dir(temp_dir: str) -> str:
 351    """指定されたパスに一時ディレクトリを作成します。
 352
 353    ディレクトリが存在しない場合は新しく作成します。
 354
 355    :param temp_dir: str: 作成する一時ディレクトリのパス。
 356    :returns: str - 作成された一時ディレクトリのパス。
 357    """
 358    if not os.path.exists(temp_dir):
 359        os.makedirs(temp_dir, exist_ok=True)
 360        print(f"一時ディレクトリを作成: {temp_dir}")
 361    return temp_dir
 362
 363def get_speaker_dict(tts_engine: str, dialogue: list[tuple[str | None, str]], voices: str | None,
 364        default_voicevox_voice: str = "四国めたん", default_pyttsx3_voice: str = "Zira",
 365        default_winrt_voice: str = "Ayumi", default_aqt_preset: str = "れいむ",
 366        default_optnai_voice: str = "alloy", default_elevenlabs_voice: str = "Rachel",
 367        default_gemini_voice: str = "Kore") -> dict[str | None, str] | None:
 368    """対話データと指定された音声設定に基づいて、話者と対応する音声名をマッピングする辞書を生成します。
 369
 370    `voices` 引数で指定された優先順位、またはデフォルトの音声設定に基づいて話者と音声の
 371    マッピングを決定します。
 372
 373    :param tts_engine: str: 使用するTTSエンジンの名前。
 374    :param dialogue: list[tuple[str or None, str]]: (話者名, テキスト)のタプルのリスト。
 375    :param voices: str or None: セミコロンで区切られた音声名の文字列。
 376    :param default_voicevox_voice: str: VOICEVOXのデフォルト音声名。
 377    :param default_pyttsx3_voice: str: pyttsx3のデフォルト音声名。
 378    :param default_winrt_voice: str: WinRTのデフォルト音声名。
 379    :param default_aqt_preset: str: AquesTalkPlayerのデフォルトプリセット名。
 380    :param default_optnai_voice: str: OpenAIのデフォルト音声名。
 381    :param default_elevenlabs_voice: str: ElevenLabsのデフォルト音声名。
 382    :param default_gemini_voice: str: Geminiのデフォルト音声名。
 383    :returns: dict[str or None, str] or None - 話者名をキー、音声名を値とする辞書、
 384                                                または無効なエンジン名の場合はNone。
 385    """
 386
 387    tts_engine = str(tts_engine).lower()
 388    if tts_engine in TTS_ENGINES:
 389        default_voice = TTS_ENGINES[tts_engine]["default_voice"]
 390    else:
 391        print(f"\nError in tktts.get_speaker_dict(): Invalid tts engine [{tts_engine}]\n")
 392        return None
 393
 394    print("\n話者リスト作成...")
 395    voices_specified = [v.strip() for v in str(voices or "").split(";") if v.strip()]
 396    if voices_specified:
 397        default_voice = voices_specified[0]
 398
 399    speaker_names = []
 400    target_voices = {}
 401    for speaker, _text in dialogue or []:
 402        if speaker is None or speaker == "" or speaker in speaker_names:
 403            continue
 404        speaker_names.append(speaker)
 405        index = len(speaker_names) - 1
 406        target_voices[speaker] = (
 407            voices_specified[index] if index < len(voices_specified) else default_voice
 408        )
 409
 410    target_voices[""] = default_voice
 411    target_voices[None] = default_voice
 412
 413    for i, (speaker, voice) in enumerate(target_voices.items()):
 414        print(f"  {i:02d}: {speaker} => {voice}")
 415
 416    return target_voices
 417
 418def get_tts(tts_engine: str | None):
 419    """指定されたTTSエンジンのモジュールを返します。
 420
 421    エンジン名が ``TTS_ENGINES`` に登録されていない場合や、エンジンのモジュールが
 422    ロードされていない場合はNoneを返します。
 423
 424    :param tts_engine: str or None: 取得するTTSエンジンの名前。
 425    :returns: module or None - TTSエンジンのモジュール、または利用できない場合はNone。
 426    """
 427    tts_engine = str(tts_engine or "").lower()
 428    config = TTS_ENGINES.get(tts_engine)
 429    if config is None:
 430        print(f"\nError in tktts.get_tts(): Invalid tts engine [{tts_engine}]\n")
 431        return None
 432
 433    tts_module = config["engine"]
 434    if tts_module is not None:
 435        return tts_module
 436
 437    print(f"\nError in tktts.get_tts(): TTS engine [{tts_engine}] is unavailable.")
 438    print("対応バックエンドファイルと、そのオプション依存ライブラリを確認してください。")
 439    return None
 440
 441
 442def get_available_voices_info(tts_engine: str | None, endpoint: str | None = None, api_key: str | None = None):
 443    """指定されたTTSエンジンで利用可能な音声の詳細情報を取得します。
 444
 445    バックエンドモジュールに ``get_available_voices_info`` 関数が存在する場合にそれを呼び出します。
 446
 447    :param tts_engine: str or None: 情報を取得するTTSエンジンの名前。
 448    :param endpoint: str or None: APIを使用するTTSエンジンのエンドポイントURL。
 449    :param api_key: str or None: APIを使用するTTSエンジンのAPIキー。
 450    :returns: Any or None - 利用可能な音声の詳細情報、または機能が存在しない場合はNone。
 451    """
 452    tts_engine = str(tts_engine or "").lower()
 453    tts = get_tts(tts_engine)
 454    if tts is None or not hasattr(tts, "get_available_voices_info"):
 455        return None
 456    return _call_backend(
 457        tts.get_available_voices_info,
 458        endpoint=endpoint,
 459        api_key=api_key,
 460    )
 461
 462
 463def get_available_voices(tts_engine: str | None, endpoint: str | None = None, api_key: str | None = None):
 464    """指定されたTTSエンジンで利用可能な音声のリストを取得します。
 465
 466    バックエンドモジュールに ``get_available_voices`` 関数が存在する場合にそれを呼び出します。
 467
 468    :param tts_engine: str or None: リストを取得するTTSエンジンの名前。
 469    :param endpoint: str or None: APIを使用するTTSエンジンのエンドポイントURL。
 470    :param api_key: str or None: APIを使用するTTSエンジンのAPIキー。
 471    :returns: list[str] or None - 利用可能な音声のリスト、または機能が存在しない場合はNone。
 472    """
 473    tts_engine = str(tts_engine or "").lower()
 474    tts = get_tts(tts_engine)
 475    if tts is None or not hasattr(tts, "get_available_voices"):
 476        return None
 477    return _call_backend(
 478        tts.get_available_voices,
 479        endpoint=endpoint,
 480        api_key=api_key,
 481    )
 482
 483
 484def list_available_voices(tts_engine: str | None, endpoint: str | None = None, api_key: str | None = None) -> bool:
 485    """指定されたTTSエンジンで利用可能な音声をコンソールに出力します。
 486
 487    バックエンドモジュールに ``list_available_voices`` 関数が存在する場合にそれを呼び出します。
 488
 489    :param tts_engine: str or None: リストを出力するTTSエンジンの名前。
 490    :param endpoint: str or None: APIを使用するTTSエンジンのエンドポイントURL。
 491    :param api_key: str or None: APIを使用するTTSエンジンのAPIキー。
 492    :returns: bool - 処理が成功した場合はTrue、失敗した場合はFalse。
 493    """
 494    tts_engine = str(tts_engine or "").lower()
 495    tts = get_tts(tts_engine)
 496    if tts is None:
 497        return False
 498    if not hasattr(tts, "list_available_voices"):
 499        print(f"Error: TTS module [{tts_engine}] has no list_available_voices function.")
 500        return False
 501
 502    try:
 503        ret = _call_backend(
 504            tts.list_available_voices,
 505            endpoint=endpoint,
 506            api_key=api_key,
 507        )
 508    except Exception as e:
 509        print(f"Error calling list_available_voices for {tts_engine}: {e}")
 510        traceback.print_exc()
 511        ret = False
 512
 513    print("===============================")
 514    return ret
 515
 516def normalize_speaker(speaker: str | None, tts_engine: str | None = None) -> str | None:
 517    """話者ラベルを正規化します。
 518
 519    半角スペースでは分割しない。WinRTなどの音声表示名には
 520    ``Microsoft Ayumi ...`` のように空白が含まれるためである。
 521    明示的なスタイル表記 ``話者(style)`` / ``話者(style)`` のみ除く。
 522
 523    :param speaker: str or None: 正規化する話者名。
 524    :param tts_engine: str or None: 現在は使用されていないが将来的な拡張のために残されているTTSエンジン名。
 525    :returns: str or None - 正規化された話者名、または入力がNoneの場合はNone。
 526    """
 527    if speaker is None:
 528        return None
 529
 530    normalized = str(speaker).strip()
 531    for opening in ("(", "("):
 532        if opening in normalized:
 533            normalized = normalized.split(opening, 1)[0].rstrip()
 534    return normalized
 535
 536def parse_kv_string(kv_string: str | None, keys: list[str] = [], allow_no_key_kv_string: bool = False) -> dict[str | int, str]:
 537    """key=val;key=val 形式を dict に変換します。
 538
 539    `keys` 引数で指定されたキーのリストが与えられた場合、キーが指定されていない値に対しては、
 540    そのリストのインデックスに基づいてキーを割り当てます。
 541
 542    :param kv_string: str or None: "key=val;key=val" 形式の文字列。
 543    :param keys: list[str]: キーがない値に割り当てるためのキーのリスト。
 544    :param allow_no_key_kv_string: bool: キーが指定されていないアイテム(例: "value1;value2")を許可するかどうか。
 545    :returns: dict[str or int, str] - 変換された辞書。キーは文字列またはインデックス。
 546    """
 547
 548    if not kv_string: return {}
 549    nkeys = len(keys)
 550
 551    d = {}
 552    idx = 0
 553    for item in kv_string.split(";"):
 554        if "=" in item:
 555            k, v = item.split("=", 1)
 556            d[k.strip()] = v.strip()
 557        elif allow_no_key_kv_string:
 558            if "*" in  item or item.strip() == "": continue
 559
 560            if idx < nkeys: d[keys[idx]] = item
 561            d[idx] = item
 562            idx += 1
 563    for speaker in keys:
 564        if speaker in d.keys(): continue
 565        d[speaker] = speaker
 566
 567    return d
 568
 569def _get_attr(args: object | None, name: str, default: Any | None = None) -> Any | None:
 570    """オブジェクトから属性を安全に取得します。
 571
 572    `args` がNoneの場合や、指定された属性が存在しない場合はデフォルト値を返します。
 573
 574    :param args: object or None: 属性を取得する対象のオブジェクト(通常は ``argparse.Namespace``)。
 575    :param name: str: 取得する属性の名前。
 576    :param default: Any or None: 属性が存在しない場合に返すデフォルト値。
 577    :returns: Any or None - 取得した属性の値、またはデフォルト値。
 578    """
 579    return getattr(args, name, default) if args is not None else default
 580
 581
 582def _effective_pyttsx3_rate(args: object) -> int:
 583    """pyttsx3の音声速度を計算します。
 584
 585    ``--speak_rate`` と ``--fspeak_rate`` オプションを考慮して
 586    最終的な音声速度(WPM: Words Per Minute)を決定します。
 587
 588    :param args: object: ``speak_rate`` と ``fspeak_rate`` 属性を持つオブジェクト。
 589    :returns: int - pyttsx3に設定する有効な音声速度。
 590    """
 591    base_rate = float(_get_attr(args, "speak_rate", 150) or 150)
 592    multiplier = _get_attr(args, "fspeak_rate", None)
 593    if multiplier is not None:
 594        try:
 595            base_rate *= float(multiplier)
 596        except (TypeError, ValueError):
 597            pass
 598    return max(1, int(round(base_rate)))
 599
 600
 601def _effective_winrt_rate(args: object) -> float | int:
 602    """WinRTの音声速度を計算します。
 603
 604    ``--fspeak_rate`` または ``--speak_rate`` オプションから音声速度を決定します。
 605    GUIの `fspeak_rate` は相対倍率なので、WinRTにはそのまま渡すことができます。
 606
 607    :param args: object: ``fspeak_rate`` と ``speak_rate`` 属性を持つオブジェクト。
 608    :returns: float or int - WinRTに設定する有効な音声速度。
 609    """
 610    # GUIの fspeak_rate は相対倍率なので、WinRTにはそのまま渡せる。
 611    relative_rate = _get_attr(args, "fspeak_rate", None)
 612    if relative_rate is not None:
 613        return relative_rate
 614    return _get_attr(args, "speak_rate", 150)
 615
 616
 617def _elevenlabs_voice_settings(args: object) -> dict | None:
 618    """ElevenLabsの音声設定 (``VoiceSettings``) を ``args`` オブジェクトから構築します。
 619
 620    ``elevenlabs_voice_settings`` または ``voice_settings`` 属性から設定辞書を取得するか、
 621    個別の ``elevenlabs_stability``、``elevenlabs_similarity_boost`` などの属性から設定を組み立てます。
 622
 623    :param args: object: ElevenLabs関連の設定属性を持つオブジェクト。
 624    :returns: dict or None - ElevenLabsの音声設定辞書、または設定がない場合はNone。
 625    """
 626    settings = _get_attr(args, "elevenlabs_voice_settings", None)
 627    if settings is None:
 628        settings = _get_attr(args, "voice_settings", None)
 629    if isinstance(settings, dict):
 630        return dict(settings)
 631
 632    values = {}
 633    for key in ("stability", "similarity_boost", "style", "use_speaker_boost"):
 634        value = _get_attr(args, f"elevenlabs_{key}", None)
 635        if value is not None:
 636            values[key] = value
 637    return values or None
 638
 639
 640def _infer_output_format(outfile: str | Path, requested_format: str | None, default_format: str) -> str:
 641    """出力ファイル名、要求された形式、デフォルト形式から最終的な出力形式を推測します。
 642
 643    ``requested_format`` があればそれを優先し、そうでなければ ``outfile`` の拡張子から推測します。
 644    どちらもなければ ``default_format`` を使用します。
 645
 646    :param outfile: str or Path: 出力ファイルのパス。
 647    :param requested_format: str or None: 要求された出力形式(例: "mp3", "wav")。
 648    :param default_format: str: デフォルトの出力形式。
 649    :returns: str - 推測された出力形式(拡張子のみ、例: "mp3")。
 650    """
 651    if requested_format:
 652        return str(requested_format).lower().lstrip(".")
 653    suffix = Path(str(outfile)).suffix.lower().lstrip(".")
 654    if suffix:
 655        return "wav" if suffix == "wave" else suffix
 656    return default_format
 657
 658
 659def speak_dialogue(args: object, dialogue: list[tuple[str | None, str]], voice_map: dict[str | None, str] | str | None = None, speakers: dict | None = None, replacements: dict[str, str] | None = None,
 660        default_voicevox_voice: str = "四国めたん", default_pyttsx3_voice: str = "Zira",
 661        default_winrt_voice: str = "Ayumi", default_aqt_preset: str = "れいむ",
 662        default_optnai_voice: str = "alloy", default_elevenlabs_voice: str = "Rachel",
 663        default_gemini_voice: str = "Kore",
 664        endpoint: str | None = None, api_key: str | None = None, output_format: str | None = None) -> str | bool:
 665    """選択されたエンジンで音声生成・結合・保存または直接再生を行います。
 666
 667    TTSエンジンを選択し、設定を読み込みます。
 668    話者ごとの音声マッピングを決定し、一時ディレクトリを作成して各セリフの音声ファイルを生成します。
 669    生成された音声ファイルを結合し、指定された出力形式で保存します。
 670    pyttsx3やWinRTのように直接再生に対応している場合は、保存せずに再生を試みます。
 671
 672    :param args: object: コマンドライン引数などの設定オブジェクト。
 673    :param dialogue: list[tuple[str or None, str]]: (話者名, テキスト)のタプルのリスト。
 674    :param voice_map: dict[str or None, str] or str or None: 話者と音声名をマッピングする辞書、
 675                                                           または単一の音声名文字列。
 676                                                           Noneの場合、引数 ``voices`` から生成されます。
 677    :param speakers: dict or None: 以前に解析された話者情報。
 678    :param replacements: dict[str, str] or None: テキスト置換ルール。
 679    :param default_voicevox_voice: str: VOICEVOXのデフォルト音声名。
 680    :param default_pyttsx3_voice: str: pyttsx3のデフォルト音声名。
 681    :param default_winrt_voice: str: WinRTのデフォルト音声名。
 682    :param default_aqt_preset: str: AquesTalkPlayerのデフォルトプリセット名。
 683    :param default_optnai_voice: str: OpenAIのデフォルト音声名。
 684    :param default_elevenlabs_voice: str: ElevenLabsのデフォルト音声名。
 685    :param default_gemini_voice: str: Geminiのデフォルト音声名。
 686    :param endpoint: str or None: APIを使用するTTSエンジンのエンドポイントURL。
 687    :param api_key: str or None: APIを使用するTTSエンジンのAPIキー。
 688    :param output_format: str or None: 出力音声ファイルの形式(例: "mp3", "wav")。
 689    :returns: str or bool - 保存されたファイルのパス(str)、
 690                            または成功の場合はTrue (直接再生時)、失敗の場合はFalse。
 691    """
 692
 693    print("tktts.speak_dialogue(): Generate audio files:")
 694    tts_engine = str(_get_attr(args, "tts", "")).lower()
 695    tts = get_tts(tts_engine)
 696    if tts is None:
 697        return False
 698
 699    config = TTS_ENGINES.get(tts_engine)
 700    if config is None:
 701        return False
 702
 703    outfile = _get_attr(args, "outfile", "") or ""
 704    is_save_mode = bool(outfile)
 705    monologue = bool(_get_attr(args, "monologue", False))
 706    speak_rate = _get_attr(args, "speak_rate", 150)
 707    tinterval = float(_get_attr(args, "tinterval", 0.5) or 0.0)
 708    voices = _get_attr(args, "voices", "") or ""
 709    speakers = speakers or {}
 710    replacements = replacements or {}
 711
 712    print("\nspeak_dialogue:")
 713    print(
 714        f"  tts_engine  : {tts_engine}  is monologue: {monologue}  "
 715        f"speak rate: {speak_rate}  tinterval: {tinterval}"
 716    )
 717    print(f"  voices      : {voices}")
 718    print(f"  outfile     : {outfile}" if is_save_mode else "  is_save_mode: False")
 719
 720    if voice_map is None or isinstance(voice_map, str):
 721        # GUIなどから単一の音声名が文字列で渡された場合も、話者マップへ
 722        # 変換してバックエンドへ渡す。WinRTバックエンド側で音声名を
 723        # speakerとして正規化すると、空白を含む表示名が切れる実装との
 724        # 互換問題を避けられる。
 725        selected_voices = voice_map if isinstance(voice_map, str) else voices
 726        target_voices = get_speaker_dict(
 727            tts_engine,
 728            dialogue,
 729            selected_voices,
 730            default_voicevox_voice=default_voicevox_voice,
 731            default_pyttsx3_voice=default_pyttsx3_voice,
 732            default_winrt_voice=default_winrt_voice,
 733            default_aqt_preset=default_aqt_preset,
 734            default_optnai_voice=default_optnai_voice,
 735            default_elevenlabs_voice=default_elevenlabs_voice,
 736            default_gemini_voice=default_gemini_voice,
 737        )
 738    else:
 739        target_voices = voice_map
 740
 741    if target_voices is None:
 742        return False
 743
 744    if not is_save_mode and not config.get("direct_playback", False):
 745        print("❌ このエンジンではファイル保存が必要です。--outfile を指定してください。")
 746        return False
 747
 748    temp_dir_name = _get_attr(args, "temp_dir", None)
 749    if not temp_dir_name:
 750        print("❌ 一時ファイル保存先が必要です。--temp_dir を指定してください。")
 751        return False
 752    temp_dir = create_temp_dir(temp_dir_name)
 753
 754    call_kwargs = {
 755        "dialogue": dialogue or [],
 756        "replacements": replacements,
 757        "target_voices": target_voices,
 758        "speakers": speakers,
 759        "temp_dir": temp_dir,
 760        "outfile": outfile,
 761        "ext": config["ext"],
 762        "cfg": args,
 763    }
 764
 765    if tts_engine == "pyttsx3":
 766        call_kwargs["speak_rate"] = _effective_pyttsx3_rate(args)
 767    elif tts_engine in ("winrt", "onecore"):
 768        call_kwargs["speak_rate"] = _effective_winrt_rate(args)
 769    elif tts_engine == "voicevox":
 770        call_kwargs["endpoint"] = endpoint or _get_attr(args, "endpoint", None)
 771    elif tts_engine in ("qwen3", "qwen"):
 772        call_kwargs.update({
 773            "language": _get_attr(args, "qwen3_language", None),
 774            "model_id": _get_attr(args, "qwen3_model_id", None),
 775            "device": _get_attr(args, "qwen3_device", None),
 776            "dtype": _get_attr(args, "qwen3_dtype", None),
 777        })
 778    elif tts_engine in ("irodori", "irodori-tts"):
 779        call_kwargs.update({
 780            "caption": _get_attr(args, "irodori_caption", None),
 781            "ref_wav": _get_attr(args, "irodori_ref_wav", None),
 782            "ref_wavs": _get_attr(args, "irodori_ref_wavs", None),
 783            "model_id": _get_attr(args, "irodori_model_id", None),
 784            "device": _get_attr(args, "irodori_device", None),
 785            "precision": _get_attr(args, "irodori_precision", None),
 786        })
 787    elif tts_engine in ("aquestalkplayer", "atp"):
 788        call_kwargs["aquestalk_path"] = _get_attr(args, "aquestalk_path", None)
 789    elif tts_engine == "openai":
 790        call_kwargs["instruction"] = _get_attr(args, "instruction", None)
 791    elif tts_engine == "gemini":
 792        call_kwargs["instruction"] = _get_attr(args, "instruction", None)
 793    elif tts_engine in ("elevenlabs", "eleven"):
 794        call_kwargs.update({
 795            "api_key": (
 796                api_key
 797                or _get_attr(args, "elevenlabs_api_key", None)
 798                or _get_attr(args, "api_key", None)
 799            ),
 800            "model_id": (
 801                _get_attr(args, "elevenlabs_model_id", None)
 802                or _get_attr(args, "model_id", None)
 803            ),
 804            "voice_settings": _elevenlabs_voice_settings(args),
 805            "output_format": _get_attr(args, "elevenlabs_output_format", None),
 806            "optimize_streaming_latency": _get_attr(
 807                args, "elevenlabs_optimize_streaming_latency", None
 808            ),
 809            "language_code": _get_attr(args, "elevenlabs_language_code", None),
 810        })
 811
 812    print(f"\n⚙️ {tts_engine.upper()}で音声ファイルを生成中...")
 813    try:
 814        result = _call_backend(tts.speak_dialogue, **call_kwargs)
 815        success, tmpfiles = result
 816    except Exception as e:
 817        print(f"❌ {tts_engine} の speak_dialogue 呼び出しエラー: {e}")
 818        traceback.print_exc()
 819        return False
 820
 821    if not success:
 822        return False
 823
 824    # pyttsx3 / WinRT の直接再生はバックエンド側で完了する。
 825    if not is_save_mode:
 826        return True
 827
 828    tmpfiles = list(tmpfiles or [])
 829    if not tmpfiles:
 830        if os.path.isfile(outfile) and os.path.getsize(outfile) > 0:
 831            return outfile
 832        print("❌ 結合対象の一時音声ファイルが生成されませんでした。")
 833        return False
 834
 835    final_format = _infer_output_format(outfile, output_format, config["ext"])
 836    output_parent = os.path.dirname(os.path.abspath(outfile))
 837    if output_parent:
 838        os.makedirs(output_parent, exist_ok=True)
 839
 840    print("  ファイルを結合中...")
 841    combined_audio = AudioSegment.silent(duration=0)
 842    try:
 843        for tmpfile in tmpfiles:
 844            segment = AudioSegment.from_file(tmpfile, format=config["ext"])
 845            combined_audio += segment
 846            if tinterval > 0:
 847                print(f"  insert {tinterval} sec interval")
 848                combined_audio += AudioSegment.silent(duration=int(tinterval * 1000))
 849
 850        combined_audio.export(outfile, format=final_format)
 851        print(f"✅ 出力音声を {outfile} に保存しました (形式: {final_format})。")
 852    except Exception as e:
 853        print(f"❌ 音声ファイルの結合または保存に失敗しました: {e}")
 854        traceback.print_exc()
 855        return False
 856    finally:
 857        time.sleep(0.1)
 858        print("🗑️ 一時ファイルを削除中...")
 859        for filename in tmpfiles:
 860            try:
 861                if os.path.isfile(filename):
 862                    os.remove(filename)
 863            except OSError as e:
 864                print(f"  [warn] 一時ファイルを削除できません: {filename}: {e}")
 865        try:
 866            if os.path.isdir(temp_dir) and not os.listdir(temp_dir):
 867                os.rmdir(temp_dir)
 868        except OSError:
 869            pass
 870
 871    return outfile
 872
 873
 874class tkTTS:
 875    """複数のTTS(Text-to-Speech)エンジンを統一的に扱うためのラッパークラスです。
 876
 877    選択されたTTSエンジンの設定を管理し、音声合成、利用可能な音声の取得、
 878    話者マッピングなどの機能を提供します。
 879    """
 880    def __init__(self, tts_name: str | None = None, config: SimpleNamespace | None = None):
 881        """``tkTTS`` クラスのコンストラクタです。
 882
 883        :param tts_name: str or None: 初期化時に設定するTTSエンジンの名前。
 884        :param config: SimpleNamespace or None: 設定情報を含むオブジェクト。
 885                                               (例: ``argparse.Namespace`` のインスタンス)。
 886        """
 887        self.tts_name = tts_name
 888        self.tts = None
 889        self.config = config if config is not None else SimpleNamespace()
 890        self.endpoint = getattr(self.config, "endpoint", None)
 891        self.aquestalk_path = getattr(self.config, "aquestalk_path", None)
 892        self.api_key = (
 893            getattr(self.config, "elevenlabs_api_key", None)
 894            or getattr(self.config, "api_key", None)
 895        )
 896
 897        if tts_name is not None:
 898            self.set_engine(tts_name)
 899
 900    def set_engine(self, tts_name: str):
 901        """使用するTTSエンジンを設定します。
 902
 903        :param tts_name: str: 設定するTTSエンジンの名前。
 904        """
 905        self.tts_name = str(tts_name).lower()
 906        self.tts = get_tts(self.tts_name)
 907
 908    def set_endpoint(self, endpoint: str):
 909        """APIベースのTTSエンジンのエンドポイントURLを設定します。
 910
 911        :param endpoint: str: 設定するエンドポイントURL。
 912        """
 913        self.endpoint = endpoint
 914        setattr(self.config, "endpoint", endpoint)
 915
 916    def set_aquestalk_path(self, aquestalk_path: str):
 917        """AquesTalkPlayerの実行パスを設定します。
 918
 919        :param aquestalk_path: str: AquesTalkPlayerの実行可能ファイルのパス。
 920        """
 921        self.aquestalk_path = aquestalk_path
 922        setattr(self.config, "aquestalk_path", aquestalk_path)
 923
 924    def set_api_key(self, api_key: str):
 925        """APIベースのTTSエンジンのAPIキーを設定します。
 926
 927        :param api_key: str: 設定するAPIキー。
 928        """
 929        self.api_key = api_key
 930        setattr(self.config, "elevenlabs_api_key", api_key)
 931
 932    def get_tts_name(self, tts_name: str | None = None) -> str:
 933        """現在設定されているTTSエンジン名、または引数で指定されたTTSエンジン名を返します。
 934
 935        :param tts_name: str or None: 取得したいTTSエンジンの名前。
 936                                     指定しない場合はインスタンスの設定を使用します。
 937        :returns: str - TTSエンジンの名前。
 938        """
 939        if tts_name is None: return self.tts_name
 940        return tts_name
 941
 942    def get_default_voice(self, tts_name: str | None = None) -> str | None:
 943        """指定されたTTSエンジンのデフォルト音声名を返します。
 944
 945        :param tts_name: str or None: デフォルト音声名を取得するTTSエンジンの名前。
 946                                     指定しない場合はインスタンスの設定を使用します。
 947        :returns: str or None - デフォルト音声名、または設定がない場合はNone。
 948        """
 949        tts_def = TTS_ENGINES.get(self.get_tts_name(tts_name), None)
 950        if tts_def:return tts_def["default_voice"]
 951        return None
 952
 953    def normalize_speaker(self, speaker: str | None, tts_name: str | None = None) -> str | None:
 954        """話者ラベルを正規化します。
 955
 956        このメソッドは、グローバル関数 ``normalize_speaker`` のラッパーです。
 957
 958        :param speaker: str or None: 正規化する話者名。
 959        :param tts_name: str or None: 現在は使用されていないが将来的な拡張のために残されているTTSエンジン名。
 960        :returns: str or None - 正規化された話者名、または入力がNoneの場合はNone。
 961        """
 962        if tts_name is None: tts_name = self.tts_name
 963        return normalize_speaker(speaker, tts_name)
 964
 965    def get_available_voices_info(self, tts_name: str | None = None, endpoint: str | None = None, api_key: str | None = None):
 966        """現在設定されている、または指定されたTTSエンジンで利用可能な音声の詳細情報を取得します。
 967
 968        このメソッドは、グローバル関数 ``get_available_voices_info`` のラッパーです。
 969        インスタンスに設定されているエンドポイントやAPIキーを自動的に渡します。
 970
 971        :param tts_name: str or None: 情報を取得するTTSエンジンの名前。
 972                                     指定しない場合はインスタンスの設定を使用します。
 973        :param endpoint: str or None: APIを使用するTTSエンジンのエンドポイントURL。
 974                                     指定しない場合はインスタンスの設定を使用します。
 975        :param api_key: str or None: APIを使用するTTSエンジンのAPIキー。
 976                                     指定しない場合はインスタンスの設定を使用します。
 977        :returns: Any or None - 利用可能な音声の詳細情報、または機能が存在しない場合はNone。
 978        """
 979        if tts_name is None:
 980            tts_name = self.tts_name
 981        if endpoint is None:
 982            endpoint = self.endpoint
 983        if api_key is None:
 984            api_key = self.api_key
 985        return get_available_voices_info(tts_name, endpoint=endpoint, api_key=api_key)
 986
 987    def get_available_voices(self, tts_name: str | None = None, endpoint: str | None = None, api_key: str | None = None):
 988        """現在設定されている、または指定されたTTSエンジンで利用可能な音声のリストを取得します。
 989
 990        このメソッドは、グローバル関数 ``get_available_voices`` のラッパーです。
 991        インスタンスに設定されているエンドポイントやAPIキーを自動的に渡します。
 992
 993        :param tts_name: str or None: リストを取得するTTSエンジンの名前。
 994                                     指定しない場合はインスタンスの設定を使用します。
 995        :param endpoint: str or None: APIを使用するTTSエンジンのエンドポイントURL。
 996                                     指定しない場合はインスタンスの設定を使用します。
 997        :param api_key: str or None: APIを使用するTTSエンジンのAPIキー。
 998                                     指定しない場合はインスタンスの設定を使用します。
 999        :returns: list[str] or None - 利用可能な音声のリスト、または機能が存在しない場合はNone。
1000        """
1001        if tts_name is None:
1002            tts_name = self.tts_name
1003        if endpoint is None:
1004            endpoint = self.endpoint
1005        if api_key is None:
1006            api_key = self.api_key
1007        return get_available_voices(tts_name, endpoint=endpoint, api_key=api_key)
1008
1009    def list_available_voices(self, tts_name: str | None = None, endpoint: str | None = None, api_key: str | None = None) -> bool:
1010        """現在設定されている、または指定されたTTSエンジンで利用可能な音声をコンソールに出力します。
1011
1012        このメソッドは、グローバル関数 ``list_available_voices`` のラッパーです。
1013        インスタンスに設定されているエンドポイントやAPIキーを自動的に渡します。
1014
1015        :param tts_name: str or None: リストを出力するTTSエンジンの名前。
1016                                     指定しない場合はインスタンスの設定を使用します。
1017        :param endpoint: str or None: APIを使用するTTSエンジンのエンドポイントURL。
1018                                     指定しない場合はインスタンスの設定を使用します。
1019        :param api_key: str or None: APIを使用するTTSエンジンのAPIキー。
1020                                     指定しない場合はインスタンスの設定を使用します。
1021        :returns: bool - 処理が成功した場合はTrue、失敗した場合はFalse。
1022        """
1023        if tts_name is None:
1024            tts_name = self.tts_name
1025        if endpoint is None:
1026            endpoint = self.endpoint
1027        if api_key is None:
1028            api_key = self.api_key
1029        return list_available_voices(tts_name, endpoint=endpoint, api_key=api_key)
1030
1031    def show_voice_map(self, infile: str, voices: str, VOICE_MAPS: dict, is_monologue: bool, tts_name: str | None = None, endpoint: str | None = None) -> bool:
1032        """入力ファイルから話者を抽出し、既存の音声マップと指定された音声設定に基づいて、
1033        最終的な話者と音声のマッピングを表示します。
1034
1035        このメソッドは主にデバッグや設定確認のために使用され、読み上げは行いません。
1036
1037        :param infile: str: 解析する入力ファイル(テキストまたはクリップボード)。
1038        :param voices: str: セミコロンで区切られた音声指定文字列(例: "speaker1=voiceA;speaker2=voiceB;voiceC")。
1039        :param VOICE_MAPS: dict: 全てのTTSエンジンに対する既存の音声マッピング辞書。
1040        :param is_monologue: bool: 入力ファイルが独話形式か。
1041        :param tts_name: str or None: 使用するTTSエンジンの名前。指定しない場合はインスタンスの設定を使用します。
1042        :param endpoint: str or None: APIを使用するTTSエンジンのエンドポイントURL。
1043        :returns: bool - 処理が成功した場合はTrue、失敗した場合はFalse。
1044        """
1045        print()
1046        print(f"[{infile}]を解析します:")
1047        dialogue = self.load_text(infile, is_monologue, wait_for_clipboard = False)
1048        if not dialogue:
1049            print("エラー: 有効なテキストデータが取得できませんでした。")
1050            if not is_monologue:
1051                print("  対話形式でない場合は --monologue=1 オプションをつけてください。")
1052            return False
1053
1054        speakers_in_file = self.get_speakers_from_dialogue(dialogue)
1055        print(f"  Speakers in [{infile}]")
1056        for idx, sp in enumerate(speakers_in_file):
1057            print(f"    {idx:02d}: {sp}")
1058
1059        current_voice_map = self.update_voice_map(voice_map = VOICE_MAPS, 
1060                            voices = voices, speakers = speakers_in_file)
1061
1062        print()
1063        print(f"Voice map:")
1064        print(f"  {'Speaker':<20} => Voice")
1065        for key, val in current_voice_map.items():
1066            if type(key) is str:
1067                print(f"  {key:<20} => {val}")
1068        for key, val in current_voice_map.items():
1069            if type(key) is not str and type(key) is not int:
1070                if key is None: key = 'None'
1071                print(f"  {key:<20} => {val}")
1072        for key, val in current_voice_map.items():
1073            if type(key) is int:
1074                print(f"  {key:<20} => {val}")
1075
1076        print()
1077        print(f"[{infile}] から検出された話者:")
1078        for s in sorted(speakers_in_file):
1079            if s is None or s == "":
1080                voice = current_voice_map.get(s, None)
1081                if voice is None: voice = current_voice_map.get(0, None)
1082                print(f"  (独話): {voice}")
1083            else:
1084                ns = self.normalize_speaker(s)
1085                voice = current_voice_map.get(ns, '未設定')
1086                print(f"  (speaker) {s}: {ns}: (voice) {voice}")
1087        return True
1088
1089
1090    def load_text(self, input_path: str, monologue: bool = False, wait_for_clipboard: bool = True) -> list[tuple[str | None, str]] | None:
1091        """指定された入力元(クリップボードまたはファイル)からテキストを読み込み、
1092        (speaker, text)のリストを生成します。
1093
1094        このメソッドは、グローバル関数 ``load_text`` のラッパーです。
1095
1096        :param input_path: str: テキストの入力元。"clip"またはファイルパス。
1097        :param monologue: bool: Trueの場合、全てを単一の話者のテキストとして扱います。
1098                               Falseの場合、行を「話者,テキスト」形式で解析します。
1099        :param wait_for_clipboard: bool: ``input_path`` が "clip" の場合に、
1100                                         ユーザーがクリップボードにコピーするのを待つか。
1101        :returns: list[tuple[str or None, str]] or None - (話者名, テキスト)のタプルのリスト、
1102                                                        またはエラー発生時はNone。
1103        """
1104        return load_text(input_path, monologue = monologue, wait_for_clipboard = wait_for_clipboard)
1105
1106    def get_speakers_from_dialogue(self, dialogue: list[tuple[str | None, str]]) -> list[str | None]:
1107        """対話データからユニークな話者名のリストを抽出します。
1108
1109        :param dialogue: list[tuple[str or None, str]]: (話者名, テキスト)のタプルのリスト。
1110        :returns: list[str or None] - 対話に含まれるユニークな話者名のリスト。
1111        """
1112        return list(set(s for s, _ in dialogue))
1113    
1114    def parse_kv_string(self, kv_string: str | None, keys: list[str] = [], allow_no_key_kv_string: bool = False) -> dict[str | int, str]:
1115        """key=val;key=val 形式の文字列を dict に変換します。
1116
1117        このメソッドは、グローバル関数 ``parse_kv_string`` をラップし、
1118        話者名の正規化を適用します。
1119
1120        :param kv_string: str or None: "key=val;key=val" 形式の文字列。
1121        :param keys: list[str]: キーがない値に割り当てるためのキーのリスト。
1122        :param allow_no_key_kv_string: bool: キーが指定されていないアイテム(例: "value1;value2")を許可するかどうか。
1123        :returns: dict[str or int, str] - 変換された辞書。キーは正規化された話者名またはインデックス。
1124        """
1125
1126        print()
1127        print("parse_kv_string:")
1128        print("  kv_string:", kv_string)
1129        print("  keys:", keys)
1130
1131        if not kv_string: return {}
1132        nkeys = len(keys)
1133
1134        speakers_kv = []
1135        d = {}
1136        idx = 0
1137# kv_stringのspeaker
1138        for item in kv_string.split(";"):
1139            if "=" in item:
1140                k, voice = item.split("=", 1)
1141                speaker = self.normalize_speaker(k.strip())
1142                d[speaker] = voice
1143                if voice not in d.keys(): d[voice] = voice
1144                if speaker not in speakers_kv: 
1145                    speakers_kv.append(speaker)
1146#                    print("478 add:", speaker)
1147            elif allow_no_key_kv_string:
1148# = が無い場合は整数idxの辞書をつくる
1149                if "*" in  item or item.strip() == "": continue
1150
1151# ユーザ指定辞書keysで与えられたspeaker(読み上げファイルのspeaker)をkv_stringにマップ
1152                if idx < nkeys:
1153                    voice = item
1154#                    speaker = self.normalize_speaker(voice)
1155#                    voice = self.normalize_speaker(keys[idx])
1156                    speaker = self.normalize_speaker(keys[idx])
1157                    d[speaker] = voice
1158                    if voice not in d.keys(): d[voice] = voice
1159                    if speaker not in speakers_kv: 
1160                        speakers_kv.append(speaker)
1161                d[idx] = item
1162                idx += 1
1163
1164# ユーザ指定辞書keysで与えられたspeaker(読み上げファイルのspeaker)をkv_stringにマップ
1165        for idx, speaker_keys in enumerate(keys):
1166            speaker_keys = self.normalize_speaker(speaker_keys)
1167            if speaker_keys in d.keys(): continue
1168
1169            d[speaker_keys] = speaker_keys
1170
1171        return d
1172
1173    def update_voice_map(self, voice_map: dict = {}, voices: str = "", speakers: list[str | None] = []) -> dict[str | None | int, str]:
1174        """既存の音声マップと新しい音声指定、話者リストに基づいて、
1175        現在のTTSエンジンの音声マッピングを更新します。
1176
1177        ``voices`` 引数が空の場合、``speakers`` リストから話者名を取得し、
1178        それをデフォルトの音声名としてマップします。
1179        ``voices`` が指定されている場合は、それを解析して既存のマップを上書きまたは追加します。
1180
1181        :param voice_map: dict: 全てのTTSエンジンに対する既存の音声マッピング辞書。
1182        :param voices: str: セミコロンで区切られた音声指定文字列
1183                           (例: "speaker1=voiceA;speaker2=voiceB;voiceC")。
1184        :param speakers: list[str or None]: 読み上げファイルから検出された話者のリスト。
1185        :returns: dict[str or None or int, str] - 更新された現在のTTSエンジンの音声マッピング辞書。
1186        """
1187        current_voice_map = voice_map.get(self.tts_name, {}).copy()
1188        print(f"  Voice maps for [{self.tts_name}]];", current_voice_map)
1189
1190        voices = voices.strip()
1191        if voices == "":
1192            print(f"voices is blank. Use given speakers")
1193            for idx, sp in enumerate(speakers):
1194                sp = self.normalize_speaker(sp)
1195                current_voice_map[sp] = sp
1196                current_voice_map[idx] = sp
1197        else:
1198            print(f"Read voice maps from voices:", voices)
1199# 読み上げファイルから抽出したspeakersは使わない
1200#            voices_override = self.parse_kv_string(voices, {}, allow_no_key_kv_string = True)
1201            voices_override = self.parse_kv_string(voices, speakers, allow_no_key_kv_string = True)
1202            current_voice_map.update(voices_override)
1203
1204        current_voice_map[None] = current_voice_map.get(0, None)
1205        current_voice_map[""] = current_voice_map.get(0, None)
1206
1207        return current_voice_map
1208
1209    def speak_dialogue(self, dialogue: list[tuple[str | None, str]] | None = None, voice_map: dict[str | None, str] | str | None = None, speakers: dict | None = None,
1210                replacements: dict[str, str] | None = None, endpoint: str | None = None, api_key: str | None = None,
1211                output_format: str | None = None, config: SimpleNamespace | None = None) -> str | bool:
1212        """現在設定されているTTSエンジンを使用して、対話データから音声を生成し、
1213        結合してファイルに保存または直接再生します。
1214
1215        このメソッドは、グローバル関数 ``speak_dialogue`` を呼び出すラッパーメソッドです。
1216        インスタンスに設定されているエンドポイントやAPIキー、設定オブジェクトを自動的に渡します。
1217
1218        :param dialogue: list[tuple[str or None, str]] or None: (話者名, テキスト)のタプルのリスト。
1219        :param voice_map: dict[str or None, str] or str or None: 話者と音声名をマッピングする辞書、
1220                                                               または単一の音声名文字列。
1221        :param speakers: dict or None: 以前に解析された話者情報。
1222        :param replacements: dict[str, str] or None: テキスト置換ルール。
1223        :param endpoint: str or None: APIを使用するTTSエンジンのエンドポイントURL。
1224                                     指定しない場合はインスタンスの設定を使用します。
1225        :param api_key: str or None: APIを使用するTTSエンジンのAPIキー。
1226                                     指定しない場合はインスタンスの設定を使用します。
1227        :param output_format: str or None: 出力音声ファイルの形式(例: "mp3", "wav")。
1228        :param config: SimpleNamespace or None: 設定情報を含むオブジェクト。
1229                                               指定しない場合はインスタンスの設定を使用します。
1230        :returns: str or bool - 保存されたファイルのパス(str)、
1231                                または成功の場合はTrue (直接再生時)、失敗の場合はFalse。
1232        """
1233        if endpoint is None:
1234            endpoint = self.endpoint
1235        if api_key is None:
1236            api_key = self.api_key
1237        if config is None:
1238            config = self.config
1239        return speak_dialogue(
1240            config,
1241            dialogue,
1242            voice_map,
1243            speakers=speakers,
1244            replacements=replacements,
1245            default_voicevox_voice=self.get_default_voice("voicevox"),
1246            default_pyttsx3_voice=self.get_default_voice("pyttsx3"),
1247            default_winrt_voice=self.get_default_voice("winrt"),
1248            default_aqt_preset=self.get_default_voice("atp"), # 辞書キーは "atp" or "aquestalkplayer"
1249            default_optnai_voice=self.get_default_voice("openai"),
1250            default_elevenlabs_voice=self.get_default_voice("elevenlabs"),
1251            default_gemini_voice=self.get_default_voice("gemini"),
1252            endpoint=endpoint,
1253            api_key=api_key,
1254            output_format=output_format,
1255        )