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

speak_qt_async.py をダウンロード

speak_qt_async.py
speak_qt_async.py
   1"""Qt6とtkttsを利用した統合型TTS (Text-to-Speech) GUIアプリケーション。
   2
   3このモジュールは、PySide6フレームワークを用いて構築されたTTSアプリケーションを提供します。
   4非同期音声生成、テキスト置換ルール適用、スライドごとのテキスト管理、
   5そして複数のTTSエンジン(pyttsx3, WinRT, VoiceVox, Qwen3, Irodori-TTS, OpenAI, ElevenLabs, AquesTalkPlayer, Gemini)
   6のサポートを特徴としています。
   7
   8ユーザーは入力ファイルを指定し、置換ルールを適用したテキストの音声生成・再生を行うことができます。
   9生成された音声ファイルは一時的に保存され、アプリケーション終了時にクリーンアップされます。
  10各種設定はINIファイルに保存・ロードされ、アプリケーションの再起動時にも維持されます。
  11
  12:doc:`speak_qt_async_usage`
  13
  14"""
  15import sys
  16import os
  17import traceback
  18import shutil
  19import re
  20import uuid
  21import glob
  22import time
  23from typing import List, Optional, Dict, Tuple, Any
  24
  25try:
  26    import chardet
  27except ImportError:
  28    print("Error: Missing library: chardet. Please run 'pip install chardet'")
  29    sys.exit(1)
  30
  31try:
  32    import tktts 
  33    from pydub import AudioSegment 
  34except ImportError:
  35     print("Error: Missing libraries: tktts or pydub. Please run 'pip install tktts pydub'")
  36     sys.exit(1)
  37
  38
  39from PySide6.QtWidgets import (
  40    QApplication, QWidget, QVBoxLayout, QHBoxLayout,
  41    QTextEdit, QLineEdit, QPushButton, QFileDialog,
  42    QSlider, QDoubleSpinBox, QComboBox, QLabel, QMessageBox,
  43    QProgressBar, QGridLayout, QFrame, QTabWidget, QSizePolicy
  44)
  45from PySide6.QtCore import Qt, QUrl, QThread, Signal, Slot, QRect, QDir
  46from PySide6.QtMultimedia import QMediaPlayer, QAudioOutput, QMediaDevices 
  47
  48
  49# --- 定数とヘルパー関数(tkttsで利用される引数構造を維持) ---
  50DEFAULT_ENGINE = "pyttsx3"
  51DEFAULT_VOICEVOX_ENDPOINT = "http://127.0.0.1:50021"
  52DEFAULT_AQUESTALK_PATH = "AquesTalkPlayer.exe"
  53DEFAULT_TEMP_DIR = "tts_temp_wavs"
  54TEMP_WAV_PREFIX = "_tktts_tmp_"
  55TEMP_WAV_EXT = ".wav"
  56INI_FILE_NAME = os.path.splitext(__file__)[0] + ".ini"
  57
  58
  59def detect_encoding(file_path: str) -> Optional[str]:
  60    """ファイルの文字コードを判定して開く
  61
  62    指定されたファイルのバイナリデータを読み込み、chardetライブラリを使用して文字コードを検出します。
  63
  64    :param file_path: 検出対象のファイルパス。
  65    :type file_path: str
  66    :returns: 検出されたファイルのエンコーディング。検出できなかった場合はNone。
  67    :rtype: Optional[str]
  68    """
  69    with open(file_path, 'rb') as f:
  70        raw_data = f.read()
  71    result = chardet.detect(raw_data)
  72    return result['encoding']
  73
  74# グローバルな置換辞書とタイムスタンプキャッシュ
  75_GLOBAL_REPLACE_DICT_CACHE = {}
  76_GLOBAL_TIMESTAMP_CACHE = {}
  77
  78def load_replace_dict(ini_path: str, force_reload: bool = False) -> Dict[str, str]:
  79    """INIファイルから置換ルール辞書を読み込む。
  80
  81    ファイルのタイムスタンプをチェックし、変更がなければキャッシュされた辞書を使用する。
  82    TOML風の簡易パーサで `#` で始まる行をコメントとして扱い、`key=value` 形式の行をパースする。
  83    キーはクォートされていても対応する。
  84
  85    :param ini_path: 読み込むINIファイルのパス。
  86    :type ini_path: str
  87    :param force_reload: True の場合、キャッシュを無視して強制的にファイルを再読み込みする。
  88    :type force_reload: bool
  89    :returns: 読み込まれた置換ルール辞書。ファイルが存在しないかエラーの場合は空辞書を返す。
  90    :rtype: Dict[str, str]
  91    """
  92    if not ini_path or not os.path.isfile(ini_path):
  93        return {}
  94
  95    try:
  96        current_timestamp = os.path.getmtime(ini_path)
  97    except OSError:
  98        # ファイルが存在しない、またはアクセス権がない場合
  99        return {}
 100
 101    # キャッシュチェック
 102    if not force_reload and ini_path in _GLOBAL_REPLACE_DICT_CACHE and \
 103       _GLOBAL_TIMESTAMP_CACHE.get(ini_path) == current_timestamp:
 104        return _GLOBAL_REPLACE_DICT_CACHE[ini_path]
 105
 106    # ファイルの読み込みとパース
 107    replace_dict = {}
 108    try:
 109        encoding = detect_encoding(ini_path)
 110        if encoding is None: encoding = 'utf-8'
 111        with open(ini_path, 'r', encoding=encoding) as f:
 112            for line in f:
 113                line = line.rstrip('\n')
 114                if line.startswith('#') or '=' not in line:
 115                    continue
 116
 117                # キーと値を抽出する正規表現(キーはクォートあり/なしに対応)
 118                match = re.match(r"""^(['"].+?['"]|[^=]+?)=(.*)$""", line)
 119                if not match:
 120                    continue
 121
 122                raw_key, val = match.groups()
 123                # キーからクォートを除去
 124                key = raw_key[1:-1] if (raw_key.startswith("'") and raw_key.endswith("'")) or (raw_key.startswith('"') and raw_key.endswith('"')) else raw_key.strip()
 125                replace_dict[key] = val.strip()
 126
 127        # キャッシュを更新
 128        _GLOBAL_REPLACE_DICT_CACHE[ini_path] = replace_dict
 129        _GLOBAL_TIMESTAMP_CACHE[ini_path] = current_timestamp
 130        print(f"Status: Loaded/Reloaded INI file: {os.path.basename(ini_path)}")
 131        return replace_dict
 132
 133    except Exception as e:
 134        print(f"  [skip] Failed to read/parse {ini_path}: {e}")
 135        return {}
 136
 137def apply_replacements(text: str, replace_dict: Dict[str, str]) -> str:
 138    """指定されたテキストに対し、置換ルール辞書に基づいて正規表現による置換を適用する。
 139    
 140    各置換パターンは、大文字小文字を区別せず、複数行モードで処理されます。
 141
 142    :param text: 置換を適用する元のテキスト。
 143    :type text: str
 144    :param replace_dict: キーが正規表現パターン、値が置換文字列の辞書。
 145    :type replace_dict: Dict[str, str]
 146    :returns: 置換が適用されたテキスト。
 147    :rtype: str
 148    """
 149    
 150    replace_list = list(replace_dict.items())
 151
 152    for pattern, replacement in replace_list:
 153        try:
 154            # re.IGNORECASE (大文字小文字無視) と re.MULTILINE (複数行モード) を適用
 155            text = re.sub(pattern, replacement, text, flags=re.IGNORECASE | re.MULTILINE)
 156        except Exception as e:
 157            print(f"re.sub error for [{pattern}]: {e}")
 158    return text
 159
 160
 161class ArgsStub:
 162    """tkttsライブラリの内部ヘルパー関数に引数を渡すためのスタブクラス。
 163
 164    キーワード引数として渡された値をインスタンスの属性として設定します。
 165    これにより、`tktts` が期待する `args` オブジェクトの振る舞いを模倣します。
 166    """
 167    def __init__(self, **kwargs: Any):
 168        for k, v in kwargs.items():
 169            setattr(self, k, v)
 170
 171
 172class MyTTSWorker(QThread):
 173    """音声生成処理をバックグラウンドで非同期実行するQThreadワーカー。
 174
 175    `tktts` ライブラリの統一インターフェースを呼び出し、様々なTTSエンジン
 176    (pyttsx3, WinRT, VoiceVox, Qwen3, Irodori-TTSなど)で音声を生成します。
 177    生成の進行状況や完了、エラーをシグナルでUIスレッドに通知します。
 178
 179    :ivar finished: 音声生成が成功した場合に、生成されたファイルのパスを送信するシグナル。
 180    :vartype finished: Signal[str]
 181    :ivar error: 音声生成中にエラーが発生した場合に、エラーメッセージを送信するシグナル。
 182    :vartype error: Signal[str]
 183    :ivar progress: 音声生成の進行状況(0-100%)を送信するシグナル。
 184    :vartype progress: Signal[int]
 185    """
 186    finished = Signal(str)
 187    error = Signal(str)
 188    progress = Signal(int)
 189
 190    def __init__(self, text: str, outfile: str, tts_engine: str, speed_rate: float, pitch: float, instruction: str, voice_name: str, tmp_files: List[str],
 191                 aquestalk_path: str, temp_dir: str, voicevox_endpoint: str,
 192                 qwen3_language: str = "Japanese",
 193                 qwen3_model_id: str = "Qwen/Qwen3-TTS-12Hz-0.6B-CustomVoice",
 194                 qwen3_device: str = "auto",
 195                 qwen3_dtype: str = "auto",
 196                 qwen3_instruct: str = "",
 197                 irodori_caption: str = "落ち着いた自然な声で、明瞭に読み上げる。",
 198                 irodori_ref_wav: str = "",
 199                 irodori_model_id: str = "Aratako/Irodori-TTS-v4.1-Small",
 200                 irodori_device: str = "auto",
 201                 irodori_precision: str = "auto",
 202                 irodori_num_steps: int = 40,
 203                 irodori_duration_scale: float = 1.0,
 204                 irodori_seed: str = "0",
 205                 parent: Optional[QThread] = None):
 206        """MyTTSWorkerのコンストラクタ。音声生成に必要なパラメータを初期化する。
 207
 208        :param text: 読み上げ対象のテキスト。
 209        :type text: str
 210        :param outfile: 出力ファイルパス(指定しない場合は一時ファイルが生成される)。
 211        :type outfile: str
 212        :param tts_engine: 使用するTTSエンジン名。
 213        :type tts_engine: str
 214        :param speed_rate: 読み上げ速度。
 215        :type speed_rate: float
 216        :param pitch: 音声のピッチ。
 217        :type pitch: float
 218        :param instruction: OpenAI/Geminiエンジン向けの指示テキスト。
 219        :type instruction: str
 220        :param voice_name: 使用するボイスの名前またはID。
 221        :type voice_name: str
 222        :param tmp_files: 生成された一時ファイルを追跡するためのリスト。
 223        :type tmp_files: List[str]
 224        :param aquestalk_path: AquesTalkPlayer.exeのパス。
 225        :type aquestalk_path: str
 226        :param temp_dir: 一時ファイルを保存するディレクトリ。
 227        :type temp_dir: str
 228        :param voicevox_endpoint: VoiceVoxエンジンのエンドポイントURL。
 229        :type voicevox_endpoint: str
 230        :param qwen3_language: Qwen3-TTSの言語。
 231        :type qwen3_language: str
 232        :param qwen3_model_id: Qwen3-TTSのモデルID。
 233        :type qwen3_model_id: str
 234        :param qwen3_device: Qwen3-TTSの実行デバイス。
 235        :type qwen3_device: str
 236        :param qwen3_dtype: Qwen3-TTSのデータ型。
 237        :type qwen3_dtype: str
 238        :param qwen3_instruct: Qwen3-TTSの指示テキスト。
 239        :type qwen3_instruct: str
 240        :param irodori_caption: Irodori-TTSのキャプション。
 241        :type irodori_caption: str
 242        :param irodori_ref_wav: Irodori-TTSの参照WAVファイルパス。
 243        :type irodori_ref_wav: str
 244        :param irodori_model_id: Irodori-TTSのモデルID。
 245        :type irodori_model_id: str
 246        :param irodori_device: Irodori-TTSの実行デバイス。
 247        :type irodori_device: str
 248        :param irodori_precision: Irodori-TTSの精度。
 249        :type irodori_precision: str
 250        :param irodori_num_steps: Irodori-TTSのサンプリングステップ数。
 251        :type irodori_num_steps: int
 252        :param irodori_duration_scale: Irodori-TTSの音声再生時間スケール。
 253        :type irodori_duration_scale: float
 254        :param irodori_seed: Irodori-TTSの乱数シード。
 255        :type irodori_seed: str
 256        :param parent: 親QObject。
 257        :type parent: Optional[QThread]
 258        """
 259        super().__init__(parent)
 260        self.text = text
 261        self.user_outfile = outfile if outfile and outfile.strip() else None
 262
 263        self.tts_engine = tts_engine.lower()
 264        self.speed_rate = speed_rate
 265        self.pitch = pitch
 266        self.instruction = instruction
 267        self.voice_name = voice_name
 268        self.tmp_files = tmp_files
 269
 270        self.aquestalk_path = aquestalk_path
 271        self.temp_dir = temp_dir
 272        self.voicevox_endpoint = voicevox_endpoint
 273
 274        self.qwen3_language = qwen3_language
 275        self.qwen3_model_id = qwen3_model_id
 276        self.qwen3_device = qwen3_device
 277        self.qwen3_dtype = qwen3_dtype
 278        self.qwen3_instruct = qwen3_instruct
 279
 280        self.irodori_caption = irodori_caption
 281        self.irodori_ref_wav = irodori_ref_wav or None
 282        self.irodori_model_id = irodori_model_id
 283        self.irodori_device = irodori_device
 284        self.irodori_precision = irodori_precision
 285        self.irodori_num_steps = irodori_num_steps
 286        self.irodori_duration_scale = irodori_duration_scale
 287        self.irodori_seed = irodori_seed
 288
 289        # tkttsの引数スタブを更新
 290        self.tktts_args = ArgsStub(
 291            tts=self.tts_engine,
 292            monologue=1,
 293            voices=self.voice_name,
 294            speak_rate=150,
 295            fspeak_rate=self.speed_rate,
 296            fspeak_pitch=self.pitch,
 297            tinterval=0.5,
 298            temp_dir=self.temp_dir, 
 299            outfile="",
 300            instruction=self.instruction,
 301            aquestalk_path=self.aquestalk_path,
 302            endpoint=self.voicevox_endpoint,
 303            elevenlabs_api_key=os.getenv("ELEVENLABS_API_KEY"),
 304
 305            # Qwen3-TTS
 306            qwen3_language=self.qwen3_language,
 307            qwen3_model_id=self.qwen3_model_id,
 308            qwen3_device=self.qwen3_device,
 309            qwen3_dtype=self.qwen3_dtype,
 310            qwen3_instruct=(self.qwen3_instruct or None),
 311
 312            # Irodori-TTS
 313            irodori_caption=self.irodori_caption,
 314            irodori_ref_wav=self.irodori_ref_wav,
 315            irodori_ref_wavs=None,
 316            irodori_model_id=self.irodori_model_id,
 317            irodori_device=self.irodori_device,
 318            irodori_precision=self.irodori_precision,
 319            irodori_codec_device=None,
 320            irodori_codec_precision=None,
 321            irodori_num_steps=self.irodori_num_steps,
 322            irodori_cfg_scale_text=3.5,
 323            irodori_cfg_scale_caption=3.0,
 324            irodori_cfg_scale_speaker=5.0,
 325            irodori_duration_scale=self.irodori_duration_scale,
 326            irodori_seed=self.irodori_seed,
 327            irodori_lora_adapter=None,
 328        )
 329
 330    def run(self):
 331        """ワーカーのスレッド実行エントリポイント。指定されたパラメータで音声生成を実行する。
 332
 333        `tktts.speak_dialogue` を呼び出して音声を生成し、その結果を `finished` シグナルまたは `error` シグナルで通知します。
 334        進行状況は `progress` シグナルで更新されます。
 335
 336        :returns: None
 337        :rtype: None
 338        """
 339
 340        self.progress.emit(10)
 341
 342        try:
 343            config = tktts.TTS_ENGINES.get(self.tts_engine)
 344            if config is None:
 345                self.error.emit(
 346                    f"TTSエンジン [{self.tts_engine}] の設定が見つかりません。"
 347                )
 348                return
 349
 350            if tktts.get_tts(self.tts_engine) is None:
 351                self.error.emit(
 352                    f"TTSエンジン [{self.tts_engine}] のロードに失敗しました。"
 353                )
 354                return
 355
 356            lines = self.text.strip().split("\n")
 357            dialogue = [(None, line.strip()) for line in lines if line.strip()]
 358            if not dialogue:
 359                self.error.emit("読み上げるテキストがありません。")
 360                return
 361
 362            temp_dir = tktts.create_temp_dir(self.temp_dir)
 363
 364            if self.user_outfile:
 365                output_path = os.path.abspath(self.user_outfile)
 366            else:
 367                # GUIでの再生互換性を優先し、一時出力は常にWAVにする。
 368                temp_filebody = TEMP_WAV_PREFIX + uuid.uuid4().hex[:8]
 369                output_path = os.path.abspath(
 370                    os.path.join(temp_dir, temp_filebody + "_merged.wav")
 371                )
 372
 373            output_dir = os.path.dirname(output_path)
 374            if output_dir:
 375                os.makedirs(output_dir, exist_ok=True)
 376
 377            self.tktts_args.outfile = output_path
 378            self.tktts_args.endpoint = self.voicevox_endpoint
 379            self.tktts_args.elevenlabs_api_key = os.getenv("ELEVENLABS_API_KEY")
 380
 381            print(f"Status: {self.tts_engine.upper()}の音声生成開始...")
 382            # 音声名を文字列のまま渡すと、WinRTバックエンドでspeaker名として
 383            # 正規化され、空白を含む ``Microsoft Ayumi ...`` が切れることがある。
 384            # 値として保持する話者マップにして渡す。
 385            selected_voice_map = {
 386                None: self.voice_name,
 387                "": self.voice_name,
 388                0: self.voice_name,
 389            }
 390
 391            result = tktts.speak_dialogue(
 392                self.tktts_args,
 393                dialogue,
 394                voice_map=selected_voice_map,
 395                replacements={},
 396                endpoint=self.voicevox_endpoint,
 397                api_key=self.tktts_args.elevenlabs_api_key,
 398                output_format=None,  # 出力パスの拡張子から tktts.py が判定
 399            )
 400
 401            if not result:
 402                self.error.emit(
 403                    f"TTSエンジン [{self.tts_engine}] での音声生成に失敗しました。"
 404                )
 405                return
 406
 407            generated_path = result if isinstance(result, str) else output_path
 408            if not os.path.isfile(generated_path):
 409                self.error.emit(f"生成された音声ファイルが見つかりません: {generated_path}")
 410                return
 411
 412            self.progress.emit(100)
 413            self.finished.emit(generated_path)
 414
 415            if not self.user_outfile:
 416                self.tmp_files.append(generated_path)
 417
 418        except Exception as e:
 419            error_msg = f"音声生成またはファイル操作エラー: {type(e).__name__}: {e}"
 420            print(error_msg)
 421            traceback.print_exc()
 422            self.error.emit(error_msg)
 423
 424
 425class MyTTSApp(QWidget):
 426    """統合型TTS (Text-to-Speech) のPySide6 GUIアプリケーション。
 427
 428    ファイルの読み込み、テキスト置換ルールの適用、各種TTSエンジンによる音声生成と再生、
 429    一時ファイルの管理、設定の保存・読み込みなどの機能を提供します。
 430    """
 431    def __init__(self):
 432        """MyTTSAppのコンストラクタ。
 433
 434        UIの初期化、メディアプレイヤーの設定、シグナル接続、設定のロードを行います。
 435        """
 436        super().__init__()
 437        self.setWindowTitle("統合TTS GUI (コンパクト・リサイズ可能)")
 438        
 439        # メディア関連の初期化
 440        self.worker_thread: Optional[MyTTSWorker] = None
 441        self.audio_output = QAudioOutput(QMediaDevices.defaultAudioOutput())
 442        self.player: QMediaPlayer = QMediaPlayer()
 443        self.player.setAudioOutput(self.audio_output)
 444        self.current_audio_path: Optional[str] = None
 445        
 446        # 状態管理
 447        self.tmp_files = [] 
 448        self.slide_data: Dict[int, str] = {}
 449        self.last_dir: Dict[str, str] = {} 
 450        self.is_converted_text_dirty: bool = True 
 451
 452        self._load_settings()
 453
 454        # シグナルとスロットの接続
 455        self.player.durationChanged.connect(self.set_slider_range)
 456        self.player.positionChanged.connect(self.update_slider)
 457        self.player.playbackStateChanged.connect(self.update_playback_buttons)
 458        self.player.errorOccurred.connect(self.handle_media_player_error)
 459
 460        self.initUI() 
 461        self.setGeometry(self.settings.get('geometry', QRect(100, 100, 800, 650))) 
 462        self.setMinimumSize(400, 450)
 463
 464        # TTS設定UIの変更を監視し、ダーティフラグを立てる
 465        self.text_input_converted.textChanged.connect(self.set_dirty)
 466        self.speed_spin.valueChanged.connect(self.set_dirty)
 467        self.pitch_spin.valueChanged.connect(self.set_dirty)
 468        self.instruction_line.textChanged.connect(self.set_dirty)
 469        self.engine_combo.currentIndexChanged.connect(self.update_voice_list) # update_voice_list内でもset_dirtyを呼ぶ
 470        self.voice_combo.currentIndexChanged.connect(self.set_dirty) # ボイス変更時
 471
 472        # Qwen/Irodori settings
 473        for w in (
 474            self.qwen3_model_line, self.qwen3_language_line, self.qwen3_instruct_line,
 475            self.irodori_model_line, self.irodori_caption_line,
 476            self.irodori_ref_wav_line, self.irodori_seed_line,
 477        ):
 478            w.textChanged.connect(self.set_dirty)
 479        self.qwen3_device_combo.currentIndexChanged.connect(self.set_dirty)
 480        self.qwen3_dtype_combo.currentIndexChanged.connect(self.set_dirty)
 481        self.irodori_device_combo.currentIndexChanged.connect(self.set_dirty)
 482        self.irodori_precision_combo.currentIndexChanged.connect(self.set_dirty)
 483        self.irodori_steps_spin.valueChanged.connect(self.set_dirty)
 484        self.irodori_duration_spin.valueChanged.connect(self.set_dirty)
 485
 486        self.update_voice_list()
 487
 488        QApplication.instance().aboutToQuit.connect(self.cleanup_temp_files)
 489        QApplication.instance().aboutToQuit.connect(self._save_settings)
 490
 491
 492    # --- 状態管理 ---
 493    @Slot()
 494    def set_dirty(self):
 495        """TTS出力に影響する設定が変更された際にダーティフラグを立てるスロット。
 496
 497        このフラグは、再生成が必要かどうかを判断するために使用されます。
 498        再生ボタンの表示テキストも更新されます。
 499        """
 500        self.is_converted_text_dirty = True
 501        if self.player.playbackState() != QMediaPlayer.PlaybackState.PlayingState:
 502            self.update_playback_buttons(self.player.playbackState())
 503            
 504    def _load_settings(self):
 505        """アプリケーションの設定ファイル (INI) から設定を読み込む。
 506
 507        ウィンドウのジオメトリ、パス設定、TTSエンジンの詳細設定などを読み込み、
 508        `self.settings` 辞書に格納します。
 509        """
 510        default_settings = {
 511            'geometry': QRect(100, 100, 800, 650),
 512            'temp_dir': DEFAULT_TEMP_DIR,
 513            'aquestalk_path': DEFAULT_AQUESTALK_PATH,
 514            'voicevox_endpoint': DEFAULT_VOICEVOX_ENDPOINT,
 515            'input_file': "input.md",
 516            'replace_file': "replace.ini",
 517            'replace_file2': "user_replace.ini",
 518            'output_file': "",
 519            'qwen3_language': "Japanese",
 520            'qwen3_model_id': "Qwen/Qwen3-TTS-12Hz-0.6B-CustomVoice",
 521            'qwen3_device': "auto",
 522            'qwen3_dtype': "auto",
 523            'qwen3_instruct': "",
 524            'irodori_caption': "落ち着いた自然な声で、明瞭に読み上げる。",
 525            'irodori_ref_wav': "",
 526            'irodori_model_id': "Aratako/Irodori-TTS-v4.1-Small",
 527            'irodori_device': "auto",
 528            'irodori_precision': "auto",
 529            'irodori_num_steps': "40",
 530            'irodori_duration_scale': "1.0",
 531            'irodori_seed': "0",
 532        }
 533        self.settings: Dict[str, Any] = default_settings.copy()
 534        
 535        if not os.path.exists(INI_FILE_NAME):
 536            return
 537
 538        try:
 539            with open(INI_FILE_NAME, 'r', encoding='utf-8') as f:
 540                content = f.read()
 541                current_section = None
 542                for line in content.splitlines():
 543                    line = line.strip()
 544                    if not line or line.startswith('#'):
 545                        continue
 546                    if line.startswith('[') and line.endswith(']'):
 547                        current_section = line[1:-1].strip()
 548                    elif '=' in line:
 549                        key, value = line.split('=', 1)
 550                        key = key.strip()
 551                        value = value.strip().strip('"')
 552
 553                        if current_section == "window":
 554                            if key == "x": self.settings['x'] = int(value)
 555                            elif key == "y": self.settings['y'] = int(value)
 556                            elif key == "width": self.settings['width'] = int(value)
 557                            elif key == "height": self.settings['height'] = int(value)
 558                        elif current_section in ("tts_paths", "qwen3", "irodori"):
 559                            if key in default_settings:
 560                                self.settings[key] = value
 561                
 562                if 'x' in self.settings:
 563                    self.settings['geometry'] = QRect(
 564                        self.settings['x'], self.settings['y'],
 565                        self.settings.get('width', 800), self.settings.get('height', 650)
 566                    )
 567        except Exception as e:
 568            print(f"設定ファイル読み込みエラー: {e}")
 569            self.settings = default_settings.copy()
 570
 571
 572    def _save_settings(self):
 573        """アプリケーションの現在の設定をINIファイルに保存する。
 574
 575        ウィンドウのジオメトリ、各入力フィールドの値、TTSエンジンの詳細設定などを
 576        INIファイルに書き出します。
 577        """
 578        geom = self.geometry()
 579        
 580        current_settings = {
 581            'temp_dir': self.temp_dir_line.text(),
 582            'aquestalk_path': self.aquestalk_path_line.text(),
 583            'voicevox_endpoint': self.voicevox_endpoint_line.text(),
 584            'input_file': self.input_file_line.text(),
 585            'replace_file': self.replace_ini_line.text(),
 586            'replace_file2': self.replace_ini2_line.text(),
 587            'output_file': self.output_line.text(),
 588            'qwen3_language': self.qwen3_language_line.text(),
 589            'qwen3_model_id': self.qwen3_model_line.text(),
 590            'qwen3_device': self.qwen3_device_combo.currentText(),
 591            'qwen3_dtype': self.qwen3_dtype_combo.currentText(),
 592            'qwen3_instruct': self.qwen3_instruct_line.text(),
 593            'irodori_caption': self.irodori_caption_line.text(),
 594            'irodori_ref_wav': self.irodori_ref_wav_line.text(),
 595            'irodori_model_id': self.irodori_model_line.text(),
 596            'irodori_device': self.irodori_device_combo.currentText(),
 597            'irodori_precision': self.irodori_precision_combo.currentText(),
 598            'irodori_num_steps': str(self.irodori_steps_spin.value()),
 599            'irodori_duration_scale': str(self.irodori_duration_spin.value()),
 600            'irodori_seed': self.irodori_seed_line.text(),
 601        }
 602
 603        content = (
 604            f'[window]\n'
 605            f'x = {geom.x()}\n'
 606            f'y = {geom.y()}\n'
 607            f'width = {geom.width()}\n'
 608            f'height = {geom.height()}\n'
 609            f'\n'
 610            f'[tts_paths]\n'
 611            f'temp_dir = "{current_settings["temp_dir"]}"\n'
 612            f'aquestalk_path = "{current_settings["aquestalk_path"]}"\n'
 613            f'voicevox_endpoint = "{current_settings["voicevox_endpoint"]}"\n'
 614            f'input_file = "{current_settings["input_file"]}"\n'
 615            f'replace_file = "{current_settings["replace_file"]}"\n'
 616            f'replace_file2 = "{current_settings["replace_file2"]}"\n'
 617            f'output_file = "{current_settings["output_file"]}"\n'
 618            f'\n'
 619            f'[qwen3]\n'
 620            f'qwen3_language = "{current_settings["qwen3_language"]}"\n'
 621            f'qwen3_model_id = "{current_settings["qwen3_model_id"]}"\n'
 622            f'qwen3_device = "{current_settings["qwen3_device"]}"\n'
 623            f'qwen3_dtype = "{current_settings["qwen3_dtype"]}"\n'
 624            f'qwen3_instruct = "{current_settings["qwen3_instruct"]}"\n'
 625            f'\n'
 626            f'[irodori]\n'
 627            f'irodori_caption = "{current_settings["irodori_caption"]}"\n'
 628            f'irodori_ref_wav = "{current_settings["irodori_ref_wav"]}"\n'
 629            f'irodori_model_id = "{current_settings["irodori_model_id"]}"\n'
 630            f'irodori_device = "{current_settings["irodori_device"]}"\n'
 631            f'irodori_precision = "{current_settings["irodori_precision"]}"\n'
 632            f'irodori_num_steps = "{current_settings["irodori_num_steps"]}"\n'
 633            f'irodori_duration_scale = "{current_settings["irodori_duration_scale"]}"\n'
 634            f'irodori_seed = "{current_settings["irodori_seed"]}"\n'
 635        )
 636
 637        try:
 638            with open(INI_FILE_NAME, 'w', encoding='utf-8') as f:
 639                f.write(content)
 640        except Exception as e:
 641            print(f"設定ファイル保存エラー: {e}")
 642
 643
 644    def cleanup_temp_files(self):
 645        """アプリケーション終了時に生成された一時音声ファイルをクリーンアップする。
 646
 647        メディアプレイヤーを停止し、ワーカーを終了させ、追跡している一時ファイルと
 648        一時ディレクトリ内のプレフィックス付きファイルを削除します。
 649        """
 650        self.handle_stop()
 651        self.player.setSource(QUrl())
 652        if self.worker_thread and self.worker_thread.isRunning():
 653             self.worker_thread.quit()
 654             self.worker_thread.wait()
 655
 656        for f in self.tmp_files:
 657            try:
 658                if os.path.isfile(f):
 659                    os.remove(f)
 660            except Exception as e:
 661                print(f"一時ファイル削除エラー: {f}: {e}")
 662
 663        temp_dir = self.settings.get('temp_dir', DEFAULT_TEMP_DIR)
 664        try:
 665            if os.path.exists(temp_dir):
 666                search_pattern = os.path.join(temp_dir, f"{TEMP_WAV_PREFIX}*")
 667                for file_path in glob.glob(search_pattern):
 668                    try:
 669                        if os.path.isfile(file_path):
 670                            os.remove(file_path)
 671                    except:
 672                        pass
 673                if not os.listdir(temp_dir):
 674                    os.rmdir(temp_dir)
 675        except Exception as e:
 676             print(f"一時ディレクトリのクリーンアップエラー: {e}")
 677
 678
 679    # --- UI初期化 ---
 680    def initUI(self):
 681        """アプリケーションのユーザーインターフェースを初期化する。
 682
 683        メインレイアウト、タブウィジェット、ファイル選択、テキスト編集、TTS設定、
 684        メディアコントロール、ステータス表示などの各UI要素を配置し、接続します。
 685        """
 686        main_layout = QVBoxLayout()
 687        self.tabs = QTabWidget()
 688        
 689        # --- TTS設定ウィジェットの事前初期化 ---
 690        # これらのウィジェットはMain/Configタブ間で共有されるため、先に初期化する
 691        self.engine_combo = QComboBox(self)
 692        self.engine_combo.addItems([
 693            "pyttsx3",
 694            "winrt",
 695            "voicevox",
 696            "qwen3",
 697            "irodori",
 698            "openai",
 699            "elevenlabs",
 700            "aquestalkplayer",
 701            "gemini"
 702        ])
 703        self.engine_combo.setCurrentText(DEFAULT_ENGINE)
 704        self.voice_combo = QComboBox(self)
 705        self.speed_spin = QDoubleSpinBox(self)
 706        self.speed_spin.setRange(0.1, 5.0)
 707        self.speed_spin.setSingleStep(0.1)
 708        self.speed_spin.setValue(1.0)
 709        self.pitch_spin = QDoubleSpinBox(self)
 710        self.pitch_spin.setRange(-10.0, 10.0)
 711        self.pitch_spin.setSingleStep(0.1)
 712        self.pitch_spin.setValue(0.0)
 713        self.instruction_line = QLineEdit()
 714        self.instruction_line.setPlaceholderText("OpenAI/Gemini instruction (Qwen/Irodoriは下の専用設定を使用)")
 715        
 716        # --- Tab 1: Main Content (メイン操作) ---
 717        main_page = QWidget()
 718        main_page_layout = QVBoxLayout(main_page)
 719
 720        # 1. ファイル設定
 721        file_slide_layout = QGridLayout()
 722        # Input File
 723        file_slide_layout.addWidget(QLabel("Input File (infile):"), 0, 0)
 724        self.input_file_line = QLineEdit(self.settings.get('input_file'))
 725        file_slide_layout.addWidget(self.input_file_line, 0, 1)
 726        self.input_file_btn = QPushButton("Path")
 727        self.input_file_btn.clicked.connect(self.select_input_file)
 728        file_slide_layout.addWidget(self.input_file_btn, 0, 2)
 729        # Replace INI 1 (Default)
 730        file_slide_layout.addWidget(QLabel("Replace INI (Default):"), 1, 0)
 731        self.replace_ini_line = QLineEdit(self.settings.get('replace_file'))
 732        file_slide_layout.addWidget(self.replace_ini_line, 1, 1)
 733        self.replace_ini_btn = QPushButton("Path")
 734        self.replace_ini_btn.clicked.connect(lambda: self.select_ini_file(self.replace_ini_line, 'replace_file'))
 735        file_slide_layout.addWidget(self.replace_ini_btn, 1, 2)
 736        # Replace INI 2 (User)
 737        file_slide_layout.addWidget(QLabel("Replace INI (User):"), 2, 0)
 738        self.replace_ini2_line = QLineEdit(self.settings.get('replace_file2'))
 739        file_slide_layout.addWidget(self.replace_ini2_line, 2, 1)
 740        self.replace_ini2_btn = QPushButton("Path")
 741        self.replace_ini2_btn.clicked.connect(lambda: self.select_ini_file(self.replace_ini2_line, 'replace_file2'))
 742        file_slide_layout.addWidget(self.replace_ini2_btn, 2, 2)
 743        # Output File
 744        file_slide_layout.addWidget(QLabel("出力ファイル (wav/mp3):"), 3, 0)
 745        self.output_line = QLineEdit(self)
 746        self.output_line.setPlaceholderText("未指定の場合、一時ファイルを作成して再生します (推奨)")
 747        self.output_line.setText(self.settings.get('output_file', ''))
 748        file_slide_layout.addWidget(self.output_line, 3, 1)
 749        self.output_btn = QPushButton("Path")
 750        self.output_btn.clicked.connect(self.select_output_file)
 751        file_slide_layout.addWidget(self.output_btn, 3, 2)
 752        main_page_layout.addLayout(file_slide_layout)
 753        
 754        # Slide Page Pulldown (位置変更)
 755        slide_page_layout = QHBoxLayout()
 756        slide_page_layout.addWidget(QLabel("Slide Page:"))
 757        self.slide_page_combo = QComboBox(self)
 758        self.slide_page_combo.addItem("1. No file loaded")
 759        self.slide_page_combo.setCurrentIndex(0)
 760        self.slide_page_combo.currentIndexChanged.connect(self.on_slide_page_changed)
 761        slide_page_layout.addWidget(self.slide_page_combo)
 762        main_page_layout.addLayout(slide_page_layout)
 763
 764        # 4. テキスト入力エリア (2分割 & 拡張可能に)
 765        text_layout = QVBoxLayout()
 766        
 767        # Original Text
 768        text_layout.addWidget(QLabel("読み上げテキスト (Original):"))
 769        self.text_input_original = QTextEdit(self)
 770        self.text_input_original.setPlaceholderText("入力ファイルの内容(スライドページ)がここに表示されます。")
 771        self.text_input_original.setMinimumHeight(80) 
 772        self.text_input_original.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding) 
 773        text_layout.addWidget(self.text_input_original)
 774        
 775        # Converted Text
 776        text_layout.addWidget(QLabel("読み上げテキスト (Converted):"))
 777        self.text_input_converted = QTextEdit(self)
 778        self.text_input_converted.setPlaceholderText("置換ルール適用後のテキストがここに表示されます。Play/Generateボタンはこのテキストを読み上げます。")
 779        self.text_input_converted.setMinimumHeight(80) 
 780        self.text_input_converted.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding)
 781        text_layout.addWidget(self.text_input_converted)
 782        main_page_layout.addLayout(text_layout)
 783        
 784        # 5. コントロール (Convertedテキストの直下に配置)
 785        control_layout = QHBoxLayout()
 786        
 787        # Convert Button
 788        self.convert_btn = QPushButton("⚙️ Convert (Apply Rules)")
 789        self.convert_btn.clicked.connect(self.handle_convert)
 790        self.convert_btn.setStyleSheet("font-weight: bold; padding: 5px;")
 791        control_layout.addWidget(self.convert_btn)
 792
 793        # 再生/生成ボタン
 794        self.play_btn = QPushButton("▶ Play/Generate")
 795        self.generate_btn = QPushButton("⚡ Generate (Force)")
 796        self.pause_btn = QPushButton("⏸ Pause")
 797        self.stop_btn = QPushButton("■ Stop")
 798        
 799        self.play_btn.clicked.connect(lambda: self.handle_play(force_generate=False))
 800        self.generate_btn.clicked.connect(lambda: self.handle_play(force_generate=True)) # 強制生成
 801        self.pause_btn.clicked.connect(self.handle_pause)
 802        self.stop_btn.clicked.connect(self.handle_stop)
 803        
 804        control_layout.addWidget(self.play_btn)
 805        control_layout.addWidget(self.generate_btn)
 806        control_layout.addWidget(self.pause_btn)
 807        control_layout.addWidget(self.stop_btn)
 808        
 809        main_page_layout.addLayout(control_layout)
 810
 811
 812        # --- Tab 2: Config (パス設定) ---
 813        config_page = QWidget()
 814        config_page_layout = QVBoxLayout(config_page)
 815        
 816        # アプリケーションパス設定
 817        app_path_layout = QGridLayout()
 818        # AquesTalk Path
 819        app_path_layout.addWidget(QLabel("AquesTalk Path:"), 0, 0)
 820        self.aquestalk_path_line = QLineEdit(self.settings.get('aquestalk_path', DEFAULT_AQUESTALK_PATH))
 821        app_path_layout.addWidget(self.aquestalk_path_line, 0, 1)
 822        self.aquestalk_path_btn = QPushButton("Path")
 823        self.aquestalk_path_btn.clicked.connect(self.select_aquestalk_path)
 824        app_path_layout.addWidget(self.aquestalk_path_btn, 0, 2)
 825        # Voicevox Endpoint
 826        app_path_layout.addWidget(QLabel("Voicevox Endpoint:"), 1, 0)
 827        self.voicevox_endpoint_line = QLineEdit(self.settings.get('voicevox_endpoint', DEFAULT_VOICEVOX_ENDPOINT))
 828        app_path_layout.addWidget(self.voicevox_endpoint_line, 1, 1, 1, 2) 
 829        # Temp Dir
 830        app_path_layout.addWidget(QLabel("Temp Dir:"), 2, 0)
 831        self.temp_dir_line = QLineEdit(self.settings.get('temp_dir', DEFAULT_TEMP_DIR))
 832        app_path_layout.addWidget(self.temp_dir_line, 2, 1, 1, 2)
 833
 834        config_page_layout.addLayout(app_path_layout)
 835        
 836        # TTS設定(Configタブに配置)
 837        tts_settings_layout_config = QGridLayout()
 838        tts_settings_layout_config.addWidget(QLabel("Engine:"), 3, 0)
 839        tts_settings_layout_config.addWidget(self.engine_combo, 3, 1) 
 840        tts_settings_layout_config.addWidget(QLabel("Voice:"), 3, 2)
 841        tts_settings_layout_config.addWidget(self.voice_combo, 3, 3) 
 842        tts_settings_layout_config.addWidget(QLabel("Speed (fspeak_rate):"), 4, 0)
 843        tts_settings_layout_config.addWidget(self.speed_spin, 4, 1)
 844        tts_settings_layout_config.addWidget(QLabel("Pitch (ピッチ):"), 4, 2)
 845        tts_settings_layout_config.addWidget(self.pitch_spin, 4, 3)
 846        tts_settings_layout_config.addWidget(QLabel("Instruction (OpenAI/Gemini):"), 5, 0)
 847        tts_settings_layout_config.addWidget(self.instruction_line, 5, 1, 1, 3)
 848        
 849        config_page_layout.addLayout(tts_settings_layout_config)
 850
 851        # Qwen3-TTS settings
 852        qwen_layout = QGridLayout()
 853        qwen_layout.addWidget(QLabel("Qwen3 model:"), 0, 0)
 854        self.qwen3_model_line = QLineEdit(self.settings.get("qwen3_model_id", "Qwen/Qwen3-TTS-12Hz-0.6B-CustomVoice"))
 855        qwen_layout.addWidget(self.qwen3_model_line, 0, 1, 1, 3)
 856
 857        qwen_layout.addWidget(QLabel("Language:"), 1, 0)
 858        self.qwen3_language_line = QLineEdit(self.settings.get("qwen3_language", "Japanese"))
 859        qwen_layout.addWidget(self.qwen3_language_line, 1, 1)
 860
 861        qwen_layout.addWidget(QLabel("Device:"), 1, 2)
 862        self.qwen3_device_combo = QComboBox()
 863        self.qwen3_device_combo.addItems(["auto", "cuda:0", "cpu"])
 864        self.qwen3_device_combo.setCurrentText(self.settings.get("qwen3_device", "auto"))
 865        qwen_layout.addWidget(self.qwen3_device_combo, 1, 3)
 866
 867        qwen_layout.addWidget(QLabel("dtype:"), 2, 0)
 868        self.qwen3_dtype_combo = QComboBox()
 869        self.qwen3_dtype_combo.addItems(["auto", "bfloat16", "float16", "float32"])
 870        self.qwen3_dtype_combo.setCurrentText(self.settings.get("qwen3_dtype", "auto"))
 871        qwen_layout.addWidget(self.qwen3_dtype_combo, 2, 1)
 872
 873        qwen_layout.addWidget(QLabel("Qwen instruct:"), 2, 2)
 874        self.qwen3_instruct_line = QLineEdit(self.settings.get("qwen3_instruct", ""))
 875        qwen_layout.addWidget(self.qwen3_instruct_line, 2, 3)
 876
 877        self.qwen_frame = QFrame()
 878        self.qwen_frame.setFrameShape(QFrame.Shape.StyledPanel)
 879        self.qwen_frame.setLayout(qwen_layout)
 880        config_page_layout.addWidget(QLabel("Qwen3-TTS"))
 881        config_page_layout.addWidget(self.qwen_frame)
 882
 883        # Irodori-TTS settings
 884        irodori_layout = QGridLayout()
 885        irodori_layout.addWidget(QLabel("Irodori model:"), 0, 0)
 886        self.irodori_model_line = QLineEdit(self.settings.get("irodori_model_id", "Aratako/Irodori-TTS-v4.1-Small"))
 887        irodori_layout.addWidget(self.irodori_model_line, 0, 1, 1, 3)
 888
 889        irodori_layout.addWidget(QLabel("Caption:"), 1, 0)
 890        self.irodori_caption_line = QLineEdit(self.settings.get("irodori_caption", "落ち着いた自然な声で、明瞭に読み上げる。"))
 891        irodori_layout.addWidget(self.irodori_caption_line, 1, 1, 1, 3)
 892
 893        irodori_layout.addWidget(QLabel("Reference WAV:"), 2, 0)
 894        self.irodori_ref_wav_line = QLineEdit(self.settings.get("irodori_ref_wav", ""))
 895        irodori_layout.addWidget(self.irodori_ref_wav_line, 2, 1, 1, 2)
 896        self.irodori_ref_wav_btn = QPushButton("Path")
 897        self.irodori_ref_wav_btn.clicked.connect(self.select_irodori_ref_wav)
 898        irodori_layout.addWidget(self.irodori_ref_wav_btn, 2, 3)
 899
 900        irodori_layout.addWidget(QLabel("Device:"), 3, 0)
 901        self.irodori_device_combo = QComboBox()
 902        self.irodori_device_combo.addItems(["auto", "cuda", "cuda:0", "cpu"])
 903        self.irodori_device_combo.setCurrentText(self.settings.get("irodori_device", "auto"))
 904        irodori_layout.addWidget(self.irodori_device_combo, 3, 1)
 905
 906        irodori_layout.addWidget(QLabel("Precision:"), 3, 2)
 907        self.irodori_precision_combo = QComboBox()
 908        self.irodori_precision_combo.addItems(["auto", "bf16", "fp32"])
 909        self.irodori_precision_combo.setCurrentText(self.settings.get("irodori_precision", "auto"))
 910        irodori_layout.addWidget(self.irodori_precision_combo, 3, 3)
 911
 912        irodori_layout.addWidget(QLabel("Steps:"), 4, 0)
 913        self.irodori_steps_spin = QDoubleSpinBox()
 914        self.irodori_steps_spin.setDecimals(0)
 915        self.irodori_steps_spin.setRange(1, 200)
 916        self.irodori_steps_spin.setSingleStep(1)
 917        self.irodori_steps_spin.setValue(float(self.settings.get("irodori_num_steps", "40")))
 918        irodori_layout.addWidget(self.irodori_steps_spin, 4, 1)
 919
 920        irodori_layout.addWidget(QLabel("Duration scale:"), 4, 2)
 921        self.irodori_duration_spin = QDoubleSpinBox()
 922        self.irodori_duration_spin.setRange(0.1, 3.0)
 923        self.irodori_duration_spin.setSingleStep(0.05)
 924        self.irodori_duration_spin.setValue(float(self.settings.get("irodori_duration_scale", "1.0")))
 925        irodori_layout.addWidget(self.irodori_duration_spin, 4, 3)
 926
 927        irodori_layout.addWidget(QLabel("Seed:"), 5, 0)
 928        self.irodori_seed_line = QLineEdit(self.settings.get("irodori_seed", "0"))
 929        self.irodori_seed_line.setPlaceholderText("0 / random / none")
 930        irodori_layout.addWidget(self.irodori_seed_line, 5, 1)
 931
 932        self.irodori_frame = QFrame()
 933        self.irodori_frame.setFrameShape(QFrame.Shape.StyledPanel)
 934        self.irodori_frame.setLayout(irodori_layout)
 935        config_page_layout.addWidget(QLabel("Irodori-TTS"))
 936        config_page_layout.addWidget(self.irodori_frame)
 937
 938        config_page_layout.addStretch(1) # 残りのスペースを埋める
 939
 940        # Tab Widgetに追加
 941        self.tabs.addTab(main_page, "Main")
 942        self.tabs.addTab(config_page, "Config")
 943        main_layout.addWidget(self.tabs)
 944        
 945        # --- Tabの外の共通コントロール ---
 946
 947        # 6. プログレスバーと再生位置を1行に統合
 948        progress_slider_layout = QHBoxLayout()
 949        progress_slider_layout.addWidget(QLabel("Prog/Pos:"))
 950        
 951        # プログレスバー
 952        self.progress_bar = QProgressBar(self)
 953        self.progress_bar.setRange(0, 100)
 954        self.progress_bar.setValue(0)
 955        self.progress_bar.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Preferred)
 956        progress_slider_layout.addWidget(self.progress_bar)
 957
 958        # 再生位置スライダー
 959        self.position_slider = QSlider(Qt.Orientation.Horizontal)
 960        self.position_slider.setRange(0, 0)
 961        self.position_slider.setTracking(False)
 962        self.position_slider.sliderMoved.connect(self.seek_position)
 963        self.position_slider.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Preferred)
 964        progress_slider_layout.addWidget(self.position_slider)
 965
 966        main_layout.addLayout(progress_slider_layout)
 967
 968        # 7. ステータスラベル
 969        self.status_label = QLabel("Status: Ready")
 970        main_layout.addWidget(self.status_label)
 971
 972        self.setLayout(main_layout)
 973        self.update_playback_buttons(self.player.playbackState())
 974
 975        # 初期状態では選択中エンジン以外の専用設定を無効化
 976        self.qwen_frame.setEnabled(self.engine_combo.currentText().lower() in ("qwen3", "qwen"))
 977        self.irodori_frame.setEnabled(self.engine_combo.currentText().lower() in ("irodori", "irodori-tts"))
 978
 979        self.text_input_original.setText("TTSアプリへようこそ!\nInput Fileを選択すると、内容がここに表示され、Convertボタンで置換が適用されます。")
 980        self.text_input_converted.setText("Play/Generateボタンを押すと、このconvertedテキストが読み上げられます。")
 981
 982
 983    # --- ファイル選択/スロット群 ---
 984    
 985    def _get_initial_dir(self, path_line: QLineEdit, key: str) -> str:
 986        """ファイルダイアログの初期ディレクトリを決定するためのヘルパーメソッド。
 987
 988        QLineEditの内容または記憶されたディレクトリを元に初期ディレクトリを返します。
 989
 990        :param path_line: 関連するファイルパスを表示するQLineEditウィジェット。
 991        :type path_line: QLineEdit
 992        :param key: 最後に使用したディレクトリを記憶するためのキー。
 993        :type key: str
 994        :returns: ファイルダイアログの初期ディレクトリパス。
 995        :rtype: str
 996        """
 997        current_path = path_line.text()
 998        if os.path.isfile(current_path):
 999            dir_name = os.path.dirname(current_path)
1000            self.last_dir[key] = dir_name
1001            return dir_name
1002        elif os.path.isdir(current_path):
1003            self.last_dir[key] = current_path
1004            return current_path
1005        elif key in self.last_dir and os.path.isdir(self.last_dir[key]):
1006            return self.last_dir[key]
1007        return QDir.currentPath()
1008
1009    @Slot()
1010    def select_input_file(self):
1011        """入力テキストファイルを選択するダイアログを開き、選択されたファイルを読み込むスロット。
1012        """
1013        key = 'input_file'
1014        initial_dir = self._get_initial_dir(self.input_file_line, key)
1015        file_path, _ = QFileDialog.getOpenFileName(
1016            self,
1017            "入力テキストファイルの選択",
1018            initial_dir,
1019            "テキストファイル (*.txt *.md);;全てのファイル (*)"
1020        )
1021        if file_path:
1022            self.input_file_line.setText(file_path)
1023            self.last_dir[key] = os.path.dirname(file_path)
1024            self.load_input_file(file_path)
1025
1026    @Slot()
1027    def select_output_file(self):
1028        """出力音声ファイルを保存するダイアログを開き、選択されたパスを更新するスロット。
1029        """
1030        key = 'output_file'
1031        initial_dir = self._get_initial_dir(self.output_line, key)
1032        file_path, _ = QFileDialog.getSaveFileName(
1033            self, 
1034            "出力音声ファイルの選択", 
1035            initial_dir, 
1036            "Audio Files (*.wav *.mp3);;Wave Files (*.wav);;MP3 Files (*.mp3);;All Files (*)"
1037        )
1038        if file_path:
1039            root, ext = os.path.splitext(file_path)
1040            if not ext:
1041                engine = self.engine_combo.currentText().lower()
1042                default_ext = ".mp3" if engine in ("openai", "elevenlabs", "eleven") else ".wav"
1043                file_path += default_ext
1044            self.output_line.setText(file_path)
1045            self.last_dir[key] = os.path.dirname(file_path)
1046
1047    @Slot()
1048    def select_aquestalk_path(self):
1049        """AquesTalkPlayer.exeのパスを選択するダイアログを開き、パスを更新するスロット。
1050        """
1051        key = 'aquestalk_path'
1052        initial_dir = self._get_initial_dir(self.aquestalk_path_line, key)
1053        file_path, _ = QFileDialog.getOpenFileName(
1054            self,
1055            "AquesTalkPlayer.exeの選択",
1056            initial_dir,
1057            "実行ファイル (*.exe);;全てのファイル (*)" 
1058        )
1059        if file_path:
1060            self.aquestalk_path_line.setText(file_path)
1061            self.last_dir[key] = os.path.dirname(file_path)
1062            
1063    @Slot()
1064    def select_irodori_ref_wav(self):
1065        """Irodori-TTSの参照WAVファイルを選択するダイアログを開き、パスを更新するスロット。
1066        """
1067        key = "irodori_ref_wav"
1068        initial_dir = self._get_initial_dir(self.irodori_ref_wav_line, key)
1069        file_path, _ = QFileDialog.getOpenFileName(
1070            self,
1071            "Irodori-TTS Reference WAV",
1072            initial_dir,
1073            "Wave Files (*.wav);;Audio Files (*.wav *.mp3 *.flac);;All Files (*)"
1074        )
1075        if file_path:
1076            self.irodori_ref_wav_line.setText(file_path)
1077            self.last_dir[key] = os.path.dirname(file_path)
1078
1079    @Slot(QLineEdit, str)
1080    def select_ini_file(self, line_edit: QLineEdit, key: str):
1081        """INIファイル(置換ルール)を選択するダイアログを開き、指定されたQLineEditを更新するスロット。
1082
1083        :param line_edit: ファイルパスを表示・設定するQLineEditウィジェット。
1084        :type line_edit: QLineEdit
1085        :param key: 最後に使用したディレクトリを記憶するためのキー。
1086        :type key: str
1087        """
1088        initial_dir = self._get_initial_dir(line_edit, key)
1089        file_path, _ = QFileDialog.getOpenFileName(
1090            self,
1091            "INIファイル(置換ルール)の選択",
1092            initial_dir,
1093            "INIファイル (*.ini);;全てのファイル (*)"
1094        )
1095        if file_path:
1096            line_edit.setText(file_path)
1097            self.last_dir[key] = os.path.dirname(file_path)
1098
1099    def load_input_file(self, file_path: str):
1100        """指定された入力ファイルを読み込み、スライド(ページ)ごとにテキストをパースしてUIを更新する。
1101
1102        `# Slide` または `*N` のパターンでスライドを分割し、`slide_data` に格納します。
1103        スライドページ選択コンボボックスも更新します。
1104
1105        :param file_path: 読み込む入力ファイルのパス。
1106        :type file_path: str
1107        """
1108        self.slide_data = {}
1109        self.current_infile_path = file_path
1110        
1111        try:
1112            encoding = detect_encoding(file_path)
1113            if encoding is None: encoding = 'utf-8'
1114            with open(file_path, 'r', encoding=encoding) as f:
1115                full_text = f.read()
1116
1117            self.slide_data[0] = full_text.strip()
1118            current_slide_number = 1
1119            
1120            # スライド区切りパターン: `# Slide` で始まる行、または `*1, *2` などの行
1121            slide_separator_pattern = re.compile(r"^\s*(#\s*Slide.*|\*\d+)\s*$", re.IGNORECASE | re.MULTILINE)
1122            
1123            parts = slide_separator_pattern.split(full_text)
1124            
1125            if len(parts) > 1:
1126                
1127                # parts[0]は最初の区切りより前のテキスト
1128                if parts[0].strip():
1129                    self.slide_data[1] = parts[0].strip()
1130                    current_slide_number = 2
1131                
1132                # parts[1]以降は区切りと区切りの間のテキスト
1133                for part in parts[1:]:
1134                    part_content = part.strip()
1135                    # 区切りパターンにマッチしたテキスト(空か、区切り文字自体)はスキップ
1136                    if part_content and not slide_separator_pattern.match(part_content): 
1137                         self.slide_data[current_slide_number] = part_content
1138                         current_slide_number += 1
1139                
1140                # スライドが分割された場合は、改めてページ0(全文)を再構築
1141                self.slide_data[0] = full_text.strip()
1142
1143
1144            # 3. Slide pageプルダウンを更新
1145            self.slide_page_combo.clear()
1146            
1147            is_slide_parsed = len(self.slide_data) > 1 
1148            
1149            self.slide_page_combo.addItem("0. (All Document)")
1150            if is_slide_parsed:
1151                for i in sorted([k for k in self.slide_data.keys() if k > 0]):
1152                    self.slide_page_combo.addItem(f"{i}. Slide {i}")
1153                self.slide_page_combo.setEnabled(True)
1154            else:
1155                self.slide_page_combo.setEnabled(False)
1156                
1157            self.slide_page_combo.setCurrentIndex(0)
1158
1159        except Exception as e:
1160            QMessageBox.critical(self, "ファイル読み込みエラー", f"ファイルの読み込みに失敗しました: {e}")
1161            self.slide_page_combo.clear()
1162            self.slide_page_combo.addItem("1. No file loaded")
1163            self.slide_page_combo.setEnabled(False)
1164            self.text_input_original.setText("")
1165            self.text_input_converted.setText("")
1166
1167    @Slot(int)
1168    def on_slide_page_changed(self, index: int):
1169        """スライドページ選択コンボボックスの値が変更されたときに呼び出されるスロット。
1170
1171        選択されたページに対応するオリジナルテキストを表示し、変換済みテキストをリセットして
1172        ダーティフラグを立てます。
1173
1174        :param index: 選択されたスライドページのインデックス。
1175        :type index: int
1176        """
1177        page_key = index
1178        
1179        if page_key in self.slide_data:
1180            original_text = self.slide_data[page_key]
1181            
1182            # Convertedテキストの変更を一時的に無効化
1183            self.text_input_converted.textChanged.disconnect(self.set_dirty) 
1184            
1185            self.text_input_original.setText(original_text)
1186            self.text_input_converted.setText("")
1187            self.is_converted_text_dirty = True # ページが変わったので変換が必要
1188            
1189            # Convertedテキストの変更監視を再開
1190            self.text_input_converted.textChanged.connect(self.set_dirty)
1191            
1192            self.status_label.setText(f"Status: Page {page_key} loaded. Ready to Convert.")
1193        else:
1194             self.text_input_original.setText("")
1195             self.text_input_converted.setText("")
1196             self.status_label.setText("Status: Error - Page content missing.")
1197             
1198    @Slot()
1199    def handle_convert(self):
1200        """「Convert (Apply Rules)」ボタンがクリックされたときに、置換処理を実行するスロット。
1201
1202        2つのINIファイルから置換ルールを読み込み(ユーザーINIがデフォルトを上書き)、
1203        オリジナルテキストに適用します。スライド区切りマーカーを削除し、結果を
1204        Convertedテキストエリアに表示します。
1205        """
1206        original_text = self.text_input_original.toPlainText()
1207        if not original_text.strip():
1208            QMessageBox.warning(self, "Convertエラー", "Originalテキストが空です。ファイルを読み込むか、テキストを入力してください。")
1209            return
1210        
1211        default_ini_path = self.replace_ini_line.text()
1212        user_ini_path = self.replace_ini2_line.text()
1213        
1214        self.status_label.setText("Status: Loading/Checking replacement rules...")
1215        QApplication.processEvents()
1216        
1217        # タイムスタンプチェックと再読み込み (force_reload=True)
1218        dict2 = load_replace_dict(user_ini_path, force_reload=True)
1219        dict1 = load_replace_dict(default_ini_path, force_reload=True)
1220        
1221        dict1_filtered = {k: v for k, v in dict1.items() if k not in dict2}
1222        
1223        final_replace_dict = {}
1224        final_replace_dict.update(dict1_filtered) 
1225        final_replace_dict.update(dict2) 
1226        
1227        if not final_replace_dict:
1228             QMessageBox.information(self, "Convert情報", "有効な置換ルールがINIファイルから見つかりませんでした。")
1229             self.text_input_converted.setText(original_text)
1230             
1231             # Convertedテキストの変更を一時的に無効化
1232             self.text_input_converted.textChanged.disconnect(self.set_dirty)
1233             self.is_converted_text_dirty = False 
1234             self.text_input_converted.textChanged.connect(self.set_dirty)
1235             
1236             self.status_label.setText("Status: No rules applied. Converted = Original.")
1237             return
1238             
1239        self.status_label.setText("Status: Applying replacement rules...")
1240        QApplication.processEvents()
1241
1242        replaced_text = apply_replacements(original_text, final_replace_dict)
1243        
1244        # 修正: `# Slide...` および `*N` 形式のマーカー行を完全に削除
1245        # 1. `# Slide...` 行の削除(行頭/行末に空白があっても良い)
1246        replaced_text = re.sub(r"^\s*#\s*Slide.*$", "\n", replaced_text, flags=re.IGNORECASE | re.MULTILINE)
1247        
1248        # 2. `*N` (スライド番号) 行の削除(行頭/行末に空白があっても良い)
1249        replaced_text = re.sub(r"^\s*\*\d+\s*$", "\n", replaced_text, flags=re.IGNORECASE | re.MULTILINE)
1250        
1251        # 空行のみの行を削除(連続する空行を一つにまとめる)
1252        replaced_text = re.sub(r'\n\s*\n', '\n\n', replaced_text).strip()
1253        
1254        # Convertedテキストの変更を一時的に無効化してから更新
1255        self.text_input_converted.textChanged.disconnect(self.set_dirty)
1256        self.text_input_converted.setText(replaced_text)
1257        self.is_converted_text_dirty = False # 変換完了
1258        self.text_input_converted.textChanged.connect(self.set_dirty)
1259
1260        self.status_label.setText("Status: Conversion complete. Ready to Play.")
1261
1262
1263    def handle_play(self, force_generate: bool = False):
1264        """「Play/Generate」ボタンの動作を制御するスロット。
1265
1266        再生中またはポーズ中の場合はその状態を継続・再開します。
1267        ダーティフラグが立っている、または `force_generate=True` の場合は、
1268        新しい音声ファイルを生成し、完了後に再生します。
1269        そうでない場合は、既存の音声ファイルを再生します。
1270
1271        :param force_generate: True の場合、ダーティフラグの状態にかかわらず強制的に音声生成を行う。
1272        :type force_generate: bool
1273        """
1274
1275        # 1. 既に再生中の場合は無視
1276        if self.player.playbackState() == QMediaPlayer.PlaybackState.PlayingState:
1277            return
1278
1279        # 2. ポーズ状態からの再開
1280        if self.player.playbackState() == QMediaPlayer.PlaybackState.PausedState:
1281            self.player.play()
1282            return
1283            
1284        # 3. 強制生成が不要かつダーティでない場合 -> 再生
1285        is_ready_to_play = (
1286            not force_generate and 
1287            not self.is_converted_text_dirty and 
1288            self.current_audio_path and 
1289            os.path.exists(self.current_audio_path)
1290        )
1291        
1292        if is_ready_to_play:
1293             self.status_label.setText(f"Status: Playing existing audio: {os.path.basename(self.current_audio_path)}")
1294             try:
1295                audio_url = QUrl.fromLocalFile(self.current_audio_path)
1296                self.player.stop()
1297                self.player.setSource(audio_url)
1298                self.player.play()
1299             except Exception as e:
1300                 QMessageBox.critical(self, "再生エラー", f"既存の音声ファイルの再生に失敗しました: {e}")
1301                 self.current_audio_path = None 
1302             return
1303
1304        # 4. 生成が必要な場合 (ダーティ or ファイルがない or 強制生成)
1305        
1306        if self.worker_thread and self.worker_thread.isRunning():
1307            QMessageBox.warning(self, "処理中", "現在、音声生成が実行中です。完了をお待ちください。")
1308            return
1309
1310        text = self.text_input_converted.toPlainText() 
1311        outfile = self.output_line.text().strip()
1312        tts_engine = self.engine_combo.currentText()
1313        speed_rate = self.speed_spin.value()
1314        voice_name = self.voice_combo.currentText()
1315        pitch = self.pitch_spin.value()
1316        instruction = self.instruction_line.text()
1317        
1318        aquestalk_path = self.aquestalk_path_line.text().strip()
1319        temp_dir = self.temp_dir_line.text().strip()
1320        voicevox_endpoint = self.voicevox_endpoint_line.text().strip()
1321
1322        qwen3_language = self.qwen3_language_line.text().strip() or "Japanese"
1323        qwen3_model_id = self.qwen3_model_line.text().strip() or "Qwen/Qwen3-TTS-12Hz-0.6B-CustomVoice"
1324        qwen3_device = self.qwen3_device_combo.currentText()
1325        qwen3_dtype = self.qwen3_dtype_combo.currentText()
1326        qwen3_instruct = self.qwen3_instruct_line.text().strip()
1327
1328        irodori_caption = self.irodori_caption_line.text().strip()
1329        irodori_ref_wav = self.irodori_ref_wav_line.text().strip()
1330        irodori_model_id = self.irodori_model_line.text().strip() or "Aratako/Irodori-TTS-v4.1-Small"
1331        irodori_device = self.irodori_device_combo.currentText()
1332        irodori_precision = self.irodori_precision_combo.currentText()
1333        irodori_num_steps = int(self.irodori_steps_spin.value())
1334        irodori_duration_scale = float(self.irodori_duration_spin.value())
1335        irodori_seed = self.irodori_seed_line.text().strip() or "0"
1336
1337        if not text.strip():
1338            QMessageBox.warning(self, "入力エラー", "読み上げテキスト (Converted) が空です。")
1339            return
1340
1341        if not voice_name or "No voices found" in voice_name or "Error loading voices" in voice_name:
1342             QMessageBox.warning(self, "ボイス選択エラー", "有効なボイスが選択されていません。エンジンを確認してください。")
1343             return
1344
1345        self.status_label.setText("Status: 音声生成を開始しました... (非同期実行中)")
1346        self.update_playback_buttons(QMediaPlayer.PlaybackState.StoppedState, is_generating=True)
1347        self.progress_bar.setValue(0)
1348
1349        self.worker_thread = MyTTSWorker(
1350            text, outfile, tts_engine, speed_rate, pitch, instruction, voice_name, self.tmp_files,
1351            aquestalk_path, temp_dir, voicevox_endpoint,
1352            qwen3_language=qwen3_language,
1353            qwen3_model_id=qwen3_model_id,
1354            qwen3_device=qwen3_device,
1355            qwen3_dtype=qwen3_dtype,
1356            qwen3_instruct=qwen3_instruct,
1357            irodori_caption=irodori_caption,
1358            irodori_ref_wav=irodori_ref_wav,
1359            irodori_model_id=irodori_model_id,
1360            irodori_device=irodori_device,
1361            irodori_precision=irodori_precision,
1362            irodori_num_steps=irodori_num_steps,
1363            irodori_duration_scale=irodori_duration_scale,
1364            irodori_seed=irodori_seed,
1365        )
1366        self.worker_thread.finished.connect(self.on_synthesis_finished)
1367        self.worker_thread.error.connect(self.on_synthesis_error)
1368        self.worker_thread.progress.connect(self.progress_bar.setValue)
1369        self.worker_thread.start()
1370
1371
1372    @Slot(str)
1373    def on_synthesis_finished(self, file_path: str):
1374        """`MyTTSWorker` から音声生成が完了したことを通知されたときに呼び出されるスロット。
1375
1376        生成された音声ファイルを再生し、ステータスを更新します。
1377
1378        :param file_path: 生成された音声ファイルのパス。
1379        :type file_path: str
1380        """
1381        self.current_audio_path = file_path
1382        self.is_converted_text_dirty = False 
1383        self.status_label.setText(f"Status: 音声生成が完了しました: {os.path.basename(file_path)}")
1384        self.progress_bar.setValue(100)
1385
1386        self.update_playback_buttons(QMediaPlayer.PlaybackState.StoppedState, is_generating=False)
1387
1388        if not file_path or not os.path.exists(file_path):
1389             self.on_synthesis_error(f"音声ファイルが見つかりません: {file_path}")
1390             return
1391
1392        try:
1393            audio_url = QUrl.fromLocalFile(file_path)
1394            self.player.stop()
1395            self.player.setSource(audio_url)
1396            self.player.play()
1397        except Exception as e:
1398            self.on_synthesis_error(f"音声再生の開始に失敗しました: {e}")
1399
1400    @Slot(str)
1401    def on_synthesis_error(self, message: str):
1402        """`MyTTSWorker` から音声生成中にエラーが発生したことを通知されたときに呼び出されるスロット。
1403
1404        エラーメッセージを表示し、ステータスを更新します。
1405
1406        :param message: エラーの詳細メッセージ。
1407        :type message: str
1408        """
1409        self.status_label.setText("Status: エラー発生")
1410        self.progress_bar.setValue(0)
1411        QMessageBox.critical(self, "エラー", message)
1412        self.current_audio_path = None 
1413
1414        self.update_playback_buttons(QMediaPlayer.PlaybackState.StoppedState, is_generating=False)
1415        if self.worker_thread:
1416            self.worker_thread.quit()
1417            self.worker_thread.wait()
1418
1419    @Slot()
1420    def update_voice_list(self):
1421        """選択されたTTSエンジンに応じて、利用可能なボイスリストを更新し、関連するUI要素の有効/無効状態を切り替えるスロット。
1422
1423        `tktts.get_available_voices` を呼び出し、各エンジン固有のUI設定
1424        (エンドポイント、AquesTalkパスなど)を制御します。
1425        """
1426
1427        engine = self.engine_combo.currentText()
1428        engine_lower = engine.lower()
1429        self.voice_combo.clear()
1430
1431        is_winrt = engine_lower in ("winrt", "onecore")
1432        is_voicevox = engine_lower == "voicevox"
1433        is_qwen = engine_lower in ("qwen3", "qwen")
1434        is_irodori = engine_lower in ("irodori", "irodori-tts")
1435        is_openai = engine_lower == "openai"
1436        is_elevenlabs = engine_lower in ("elevenlabs", "eleven")
1437        is_aqt = engine_lower in ("aquestalkplayer", "atp")
1438        is_gemini = engine_lower == "gemini"
1439
1440        # Pitchは現在 VoiceVox / AquesTalkPlayer のみ。
1441        self.pitch_spin.setEnabled(is_voicevox or is_aqt)
1442        # Speedは pyttsx3 / WinRT / ElevenLabsを含め、全エンジンで表示する。
1443        self.speed_spin.setEnabled(True)
1444        self.instruction_line.setEnabled(is_openai or is_gemini)
1445
1446        if hasattr(self, "qwen_frame"):
1447            self.qwen_frame.setEnabled(is_qwen)
1448        if hasattr(self, "irodori_frame"):
1449            self.irodori_frame.setEnabled(is_irodori)
1450
1451        # Qwen/Irodori は speed/pitch の直接制御を行わない
1452        if is_qwen or is_irodori:
1453            self.speed_spin.setEnabled(False)
1454            self.pitch_spin.setEnabled(False)
1455
1456        if hasattr(self, "voicevox_endpoint_line"):
1457            self.voicevox_endpoint_line.setEnabled(is_voicevox)
1458            self.aquestalk_path_line.setEnabled(is_aqt)
1459
1460        endpoint = None
1461        if is_voicevox and hasattr(self, "voicevox_endpoint_line"):
1462            endpoint = self.voicevox_endpoint_line.text().strip()
1463
1464        api_key = os.getenv("ELEVENLABS_API_KEY") if is_elevenlabs else None
1465
1466        try:
1467            voices = tktts.get_available_voices(
1468                engine_lower,
1469                endpoint=endpoint,
1470                api_key=api_key,
1471            )
1472
1473            if voices:
1474                self.voice_combo.addItems([str(v) for v in voices])
1475
1476                default_voice = None
1477                if engine_lower == "pyttsx3":
1478                    default_voice = getattr(tktts, "default_pyttsx3_voice", None)
1479                elif is_winrt:
1480                    default_voice = getattr(tktts, "default_winrt_voice", None)
1481                elif is_openai:
1482                    default_voice = getattr(
1483                        tktts,
1484                        "default_openai_voice",
1485                        getattr(tktts, "default_optnai_voice", None),
1486                    )
1487                elif is_elevenlabs:
1488                    default_voice = getattr(tktts, "default_elevenlabs_voice", None)
1489                elif is_voicevox:
1490                    default_voice = getattr(tktts, "default_voicevox_voice", None)
1491                elif is_qwen:
1492                    default_voice = getattr(tktts, "default_qwen3_voice", "Ono_Anna")
1493                elif is_irodori:
1494                    default_voice = getattr(tktts, "default_irodori_voice", "default")
1495                elif is_aqt:
1496                    default_voice = getattr(tktts, "default_aqt_preset", None)
1497                elif is_gemini:
1498                    default_voice = getattr(tktts, "default_gemini_voice", "Kore")
1499
1500                if default_voice and default_voice in voices:
1501                    self.voice_combo.setCurrentText(default_voice)
1502                else:
1503                    self.voice_combo.setCurrentIndex(0)
1504
1505                self.status_label.setText(
1506                    f"Status: {engine} voices loaded ({len(voices)})."
1507                )
1508            else:
1509                if is_elevenlabs and not api_key:
1510                    self.voice_combo.addItem("ELEVENLABS_API_KEY is not set")
1511                else:
1512                    self.voice_combo.addItem(f"No voices found for {engine}")
1513
1514        except Exception as e:
1515            error_msg = f"Error loading voices for {engine}: {e}"
1516            self.voice_combo.addItem(error_msg)
1517            print(error_msg)
1518            traceback.print_exc()
1519
1520        # エンジン変更または音声一覧の再取得は、必ず再生成対象にする。
1521        self.set_dirty()
1522
1523    def update_playback_buttons(self, state: QMediaPlayer.PlaybackState, is_generating: Optional[bool] = None):
1524        """メディアプレイヤーの再生状態とワーカーの実行状態に基づいて、再生コントロールボタンの表示と有効/無効状態を更新する。
1525
1526        :param state: メディアプレイヤーの現在の再生状態。
1527        :type state: QMediaPlayer.PlaybackState
1528        :param is_generating: ワーカーが音声生成中かどうかを明示的に指定する場合に使うフラグ。指定しない場合は `self.worker_thread` の状態を見る。
1529        :type is_generating: Optional[bool]
1530        """
1531        if is_generating is None:
1532            is_worker_running = self.worker_thread and self.worker_thread.isRunning()
1533        else:
1534            is_worker_running = is_generating
1535
1536        self.convert_btn.setEnabled(not is_worker_running)
1537        self.generate_btn.setEnabled(not is_worker_running)
1538        
1539        if is_worker_running:
1540            self.play_btn.setText("▶ Generating...")
1541            self.play_btn.setEnabled(False)
1542            self.pause_btn.setEnabled(False)
1543            self.stop_btn.setEnabled(False)
1544            return
1545
1546        if state == QMediaPlayer.PlaybackState.PlayingState:
1547            self.play_btn.setText("▶ Playing...")
1548            self.play_btn.setEnabled(False)
1549            self.pause_btn.setEnabled(True)
1550            self.stop_btn.setEnabled(True)
1551        elif state == QMediaPlayer.PlaybackState.PausedState:
1552            self.play_btn.setText("▶ Resume")
1553            self.play_btn.setEnabled(True)
1554            self.pause_btn.setEnabled(False)
1555            self.stop_btn.setEnabled(True)
1556        elif state == QMediaPlayer.PlaybackState.StoppedState:
1557            if self.is_converted_text_dirty:
1558                 self.play_btn.setText("▶ Generate (Text changed)")
1559            elif self.current_audio_path:
1560                 self.play_btn.setText("▶ Play (Existing)")
1561            else:
1562                 self.play_btn.setText("▶ Play/Generate")
1563
1564            self.play_btn.setEnabled(True)
1565            self.pause_btn.setEnabled(False)
1566            self.stop_btn.setEnabled(False)
1567            if not is_worker_running and "Status: エラー" not in self.status_label.text():
1568                 if self.current_audio_path and not self.is_converted_text_dirty:
1569                      self.status_label.setText("Status: Generated (Ready to play)")
1570                 else:
1571                      self.status_label.setText("Status: Ready")
1572
1573    def handle_pause(self):
1574        """「Pause」ボタンがクリックされたときに、メディアプレイヤーを一時停止するスロット。
1575        """
1576        if self.player.playbackState() == QMediaPlayer.PlaybackState.PlayingState:
1577            self.player.pause()
1578
1579    def handle_stop(self):
1580        """「Stop」ボタンがクリックされたときに、メディアプレイヤーを停止し、再生位置をリセットするスロット。
1581        """
1582        self.player.stop()
1583        self.position_slider.setValue(0)
1584        self.status_label.setText("Status: 停止")
1585        self.update_playback_buttons(QMediaPlayer.PlaybackState.StoppedState)
1586
1587    def handle_media_player_error(self, error: QMediaPlayer.Error, error_string: str):
1588        """メディアプレイヤーでエラーが発生したときに呼び出されるスロット。
1589
1590        エラーメッセージをコンソールに出力し、ステータスラベルを更新します。
1591
1592        :param error: 発生したエラーコード。
1593        :type error: QMediaPlayer.Error
1594        :param error_string: エラーの詳細メッセージ。
1595        :type error_string: str
1596        """
1597        if error != QMediaPlayer.Error.NoError:
1598             print(f"--- QMediaPlayer Error ---")
1599             print(f"Code: {error.name}, Message: {error_string}")
1600             print(f"--------------------------")
1601             self.status_label.setText(f"Status: 再生エラー発生 ({error_string})")
1602             self.player.stop()
1603             self.update_playback_buttons(QMediaPlayer.PlaybackState.StoppedState)
1604
1605    def set_slider_range(self, duration: int):
1606        """メディアの総再生時間に基づいて再生位置スライダーの範囲を設定するスロット。
1607
1608        :param duration: メディアの総再生時間(ミリ秒)。
1609        :type duration: int
1610        """
1611        self.position_slider.setRange(0, duration)
1612
1613    def update_slider(self, position: int):
1614        """メディアの再生位置が変更されたときに、再生位置スライダーとステータスラベルを更新するスロット。
1615
1616        :param position: メディアの現在の再生位置(ミリ秒)。
1617        :type position: int
1618        """
1619        self.position_slider.setValue(position)
1620        total_duration = self.player.duration()
1621        if total_duration > 0:
1622            current_sec = position // 1000
1623            total_sec = total_duration // 1000
1624            self.status_label.setText(f"Status: Playing ({current_sec} sec / {total_sec} sec)")
1625
1626    def seek_position(self, position: int):
1627        """再生位置スライダーが操作されたときに、メディアの再生位置を変更するスロット。
1628
1629        :param position: 設定する再生位置(ミリ秒)。
1630        :type position: int
1631        """
1632        self.player.setPosition(position)
1633
1634
1635if __name__ == '__main__':
1636    app = QApplication(sys.argv)
1637    ex = MyTTSApp()
1638    ex.show()
1639    sys.exit(app.exec())