"""Qt6とtkttsを利用した統合型TTS (Text-to-Speech) GUIアプリケーション。

このモジュールは、PySide6フレームワークを用いて構築されたTTSアプリケーションを提供します。
非同期音声生成、テキスト置換ルール適用、スライドごとのテキスト管理、
そして複数のTTSエンジン（pyttsx3, WinRT, VoiceVox, Qwen3, Irodori-TTS, OpenAI, ElevenLabs, AquesTalkPlayer, Gemini）
のサポートを特徴としています。

ユーザーは入力ファイルを指定し、置換ルールを適用したテキストの音声生成・再生を行うことができます。
生成された音声ファイルは一時的に保存され、アプリケーション終了時にクリーンアップされます。
各種設定はINIファイルに保存・ロードされ、アプリケーションの再起動時にも維持されます。

:doc:`speak_qt_async_usage`

"""
import sys
import os
import traceback
import shutil
import re
import uuid
import glob
import time
from typing import List, Optional, Dict, Tuple, Any

try:
    import chardet
except ImportError:
    print("Error: Missing library: chardet. Please run 'pip install chardet'")
    sys.exit(1)

try:
    import tktts 
    from pydub import AudioSegment 
except ImportError:
     print("Error: Missing libraries: tktts or pydub. Please run 'pip install tktts pydub'")
     sys.exit(1)


from PySide6.QtWidgets import (
    QApplication, QWidget, QVBoxLayout, QHBoxLayout,
    QTextEdit, QLineEdit, QPushButton, QFileDialog,
    QSlider, QDoubleSpinBox, QComboBox, QLabel, QMessageBox,
    QProgressBar, QGridLayout, QFrame, QTabWidget, QSizePolicy
)
from PySide6.QtCore import Qt, QUrl, QThread, Signal, Slot, QRect, QDir
from PySide6.QtMultimedia import QMediaPlayer, QAudioOutput, QMediaDevices 


# --- 定数とヘルパー関数（tkttsで利用される引数構造を維持） ---
DEFAULT_ENGINE = "pyttsx3"
DEFAULT_VOICEVOX_ENDPOINT = "http://127.0.0.1:50021"
DEFAULT_AQUESTALK_PATH = "AquesTalkPlayer.exe"
DEFAULT_TEMP_DIR = "tts_temp_wavs"
TEMP_WAV_PREFIX = "_tktts_tmp_"
TEMP_WAV_EXT = ".wav"
INI_FILE_NAME = os.path.splitext(__file__)[0] + ".ini"


def detect_encoding(file_path: str) -> Optional[str]:
    """ファイルの文字コードを判定して開く

    指定されたファイルのバイナリデータを読み込み、chardetライブラリを使用して文字コードを検出します。

    :param file_path: 検出対象のファイルパス。
    :type file_path: str
    :returns: 検出されたファイルのエンコーディング。検出できなかった場合はNone。
    :rtype: Optional[str]
    """
    with open(file_path, 'rb') as f:
        raw_data = f.read()
    result = chardet.detect(raw_data)
    return result['encoding']

# グローバルな置換辞書とタイムスタンプキャッシュ
_GLOBAL_REPLACE_DICT_CACHE = {}
_GLOBAL_TIMESTAMP_CACHE = {}

def load_replace_dict(ini_path: str, force_reload: bool = False) -> Dict[str, str]:
    """INIファイルから置換ルール辞書を読み込む。

    ファイルのタイムスタンプをチェックし、変更がなければキャッシュされた辞書を使用する。
    TOML風の簡易パーサで `#` で始まる行をコメントとして扱い、`key=value` 形式の行をパースする。
    キーはクォートされていても対応する。

    :param ini_path: 読み込むINIファイルのパス。
    :type ini_path: str
    :param force_reload: True の場合、キャッシュを無視して強制的にファイルを再読み込みする。
    :type force_reload: bool
    :returns: 読み込まれた置換ルール辞書。ファイルが存在しないかエラーの場合は空辞書を返す。
    :rtype: Dict[str, str]
    """
    if not ini_path or not os.path.isfile(ini_path):
        return {}

    try:
        current_timestamp = os.path.getmtime(ini_path)
    except OSError:
        # ファイルが存在しない、またはアクセス権がない場合
        return {}

    # キャッシュチェック
    if not force_reload and ini_path in _GLOBAL_REPLACE_DICT_CACHE and \
       _GLOBAL_TIMESTAMP_CACHE.get(ini_path) == current_timestamp:
        return _GLOBAL_REPLACE_DICT_CACHE[ini_path]

    # ファイルの読み込みとパース
    replace_dict = {}
    try:
        encoding = detect_encoding(ini_path)
        if encoding is None: encoding = 'utf-8'
        with open(ini_path, 'r', encoding=encoding) as f:
            for line in f:
                line = line.rstrip('\n')
                if line.startswith('#') or '=' not in line:
                    continue

                # キーと値を抽出する正規表現（キーはクォートあり/なしに対応）
                match = re.match(r"""^(['"].+?['"]|[^=]+?)=(.*)$""", line)
                if not match:
                    continue

                raw_key, val = match.groups()
                # キーからクォートを除去
                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()
                replace_dict[key] = val.strip()

        # キャッシュを更新
        _GLOBAL_REPLACE_DICT_CACHE[ini_path] = replace_dict
        _GLOBAL_TIMESTAMP_CACHE[ini_path] = current_timestamp
        print(f"Status: Loaded/Reloaded INI file: {os.path.basename(ini_path)}")
        return replace_dict

    except Exception as e:
        print(f"  [skip] Failed to read/parse {ini_path}: {e}")
        return {}

def apply_replacements(text: str, replace_dict: Dict[str, str]) -> str:
    """指定されたテキストに対し、置換ルール辞書に基づいて正規表現による置換を適用する。
    
    各置換パターンは、大文字小文字を区別せず、複数行モードで処理されます。

    :param text: 置換を適用する元のテキスト。
    :type text: str
    :param replace_dict: キーが正規表現パターン、値が置換文字列の辞書。
    :type replace_dict: Dict[str, str]
    :returns: 置換が適用されたテキスト。
    :rtype: str
    """
    
    replace_list = list(replace_dict.items())

    for pattern, replacement in replace_list:
        try:
            # re.IGNORECASE (大文字小文字無視) と re.MULTILINE (複数行モード) を適用
            text = re.sub(pattern, replacement, text, flags=re.IGNORECASE | re.MULTILINE)
        except Exception as e:
            print(f"re.sub error for [{pattern}]: {e}")
    return text


class ArgsStub:
    """tkttsライブラリの内部ヘルパー関数に引数を渡すためのスタブクラス。

    キーワード引数として渡された値をインスタンスの属性として設定します。
    これにより、`tktts` が期待する `args` オブジェクトの振る舞いを模倣します。
    """
    def __init__(self, **kwargs: Any):
        for k, v in kwargs.items():
            setattr(self, k, v)


class MyTTSWorker(QThread):
    """音声生成処理をバックグラウンドで非同期実行するQThreadワーカー。

    `tktts` ライブラリの統一インターフェースを呼び出し、様々なTTSエンジン
    （pyttsx3, WinRT, VoiceVox, Qwen3, Irodori-TTSなど）で音声を生成します。
    生成の進行状況や完了、エラーをシグナルでUIスレッドに通知します。

    :ivar finished: 音声生成が成功した場合に、生成されたファイルのパスを送信するシグナル。
    :vartype finished: Signal[str]
    :ivar error: 音声生成中にエラーが発生した場合に、エラーメッセージを送信するシグナル。
    :vartype error: Signal[str]
    :ivar progress: 音声生成の進行状況（0-100%）を送信するシグナル。
    :vartype progress: Signal[int]
    """
    finished = Signal(str)
    error = Signal(str)
    progress = Signal(int)

    def __init__(self, text: str, outfile: str, tts_engine: str, speed_rate: float, pitch: float, instruction: str, voice_name: str, tmp_files: List[str],
                 aquestalk_path: str, temp_dir: str, voicevox_endpoint: str,
                 qwen3_language: str = "Japanese",
                 qwen3_model_id: str = "Qwen/Qwen3-TTS-12Hz-0.6B-CustomVoice",
                 qwen3_device: str = "auto",
                 qwen3_dtype: str = "auto",
                 qwen3_instruct: str = "",
                 irodori_caption: str = "落ち着いた自然な声で、明瞭に読み上げる。",
                 irodori_ref_wav: str = "",
                 irodori_model_id: str = "Aratako/Irodori-TTS-v4.1-Small",
                 irodori_device: str = "auto",
                 irodori_precision: str = "auto",
                 irodori_num_steps: int = 40,
                 irodori_duration_scale: float = 1.0,
                 irodori_seed: str = "0",
                 parent: Optional[QThread] = None):
        """MyTTSWorkerのコンストラクタ。音声生成に必要なパラメータを初期化する。

        :param text: 読み上げ対象のテキスト。
        :type text: str
        :param outfile: 出力ファイルパス（指定しない場合は一時ファイルが生成される）。
        :type outfile: str
        :param tts_engine: 使用するTTSエンジン名。
        :type tts_engine: str
        :param speed_rate: 読み上げ速度。
        :type speed_rate: float
        :param pitch: 音声のピッチ。
        :type pitch: float
        :param instruction: OpenAI/Geminiエンジン向けの指示テキスト。
        :type instruction: str
        :param voice_name: 使用するボイスの名前またはID。
        :type voice_name: str
        :param tmp_files: 生成された一時ファイルを追跡するためのリスト。
        :type tmp_files: List[str]
        :param aquestalk_path: AquesTalkPlayer.exeのパス。
        :type aquestalk_path: str
        :param temp_dir: 一時ファイルを保存するディレクトリ。
        :type temp_dir: str
        :param voicevox_endpoint: VoiceVoxエンジンのエンドポイントURL。
        :type voicevox_endpoint: str
        :param qwen3_language: Qwen3-TTSの言語。
        :type qwen3_language: str
        :param qwen3_model_id: Qwen3-TTSのモデルID。
        :type qwen3_model_id: str
        :param qwen3_device: Qwen3-TTSの実行デバイス。
        :type qwen3_device: str
        :param qwen3_dtype: Qwen3-TTSのデータ型。
        :type qwen3_dtype: str
        :param qwen3_instruct: Qwen3-TTSの指示テキスト。
        :type qwen3_instruct: str
        :param irodori_caption: Irodori-TTSのキャプション。
        :type irodori_caption: str
        :param irodori_ref_wav: Irodori-TTSの参照WAVファイルパス。
        :type irodori_ref_wav: str
        :param irodori_model_id: Irodori-TTSのモデルID。
        :type irodori_model_id: str
        :param irodori_device: Irodori-TTSの実行デバイス。
        :type irodori_device: str
        :param irodori_precision: Irodori-TTSの精度。
        :type irodori_precision: str
        :param irodori_num_steps: Irodori-TTSのサンプリングステップ数。
        :type irodori_num_steps: int
        :param irodori_duration_scale: Irodori-TTSの音声再生時間スケール。
        :type irodori_duration_scale: float
        :param irodori_seed: Irodori-TTSの乱数シード。
        :type irodori_seed: str
        :param parent: 親QObject。
        :type parent: Optional[QThread]
        """
        super().__init__(parent)
        self.text = text
        self.user_outfile = outfile if outfile and outfile.strip() else None

        self.tts_engine = tts_engine.lower()
        self.speed_rate = speed_rate
        self.pitch = pitch
        self.instruction = instruction
        self.voice_name = voice_name
        self.tmp_files = tmp_files

        self.aquestalk_path = aquestalk_path
        self.temp_dir = temp_dir
        self.voicevox_endpoint = voicevox_endpoint

        self.qwen3_language = qwen3_language
        self.qwen3_model_id = qwen3_model_id
        self.qwen3_device = qwen3_device
        self.qwen3_dtype = qwen3_dtype
        self.qwen3_instruct = qwen3_instruct

        self.irodori_caption = irodori_caption
        self.irodori_ref_wav = irodori_ref_wav or None
        self.irodori_model_id = irodori_model_id
        self.irodori_device = irodori_device
        self.irodori_precision = irodori_precision
        self.irodori_num_steps = irodori_num_steps
        self.irodori_duration_scale = irodori_duration_scale
        self.irodori_seed = irodori_seed

        # tkttsの引数スタブを更新
        self.tktts_args = ArgsStub(
            tts=self.tts_engine,
            monologue=1,
            voices=self.voice_name,
            speak_rate=150,
            fspeak_rate=self.speed_rate,
            fspeak_pitch=self.pitch,
            tinterval=0.5,
            temp_dir=self.temp_dir, 
            outfile="",
            instruction=self.instruction,
            aquestalk_path=self.aquestalk_path,
            endpoint=self.voicevox_endpoint,
            elevenlabs_api_key=os.getenv("ELEVENLABS_API_KEY"),

            # Qwen3-TTS
            qwen3_language=self.qwen3_language,
            qwen3_model_id=self.qwen3_model_id,
            qwen3_device=self.qwen3_device,
            qwen3_dtype=self.qwen3_dtype,
            qwen3_instruct=(self.qwen3_instruct or None),

            # Irodori-TTS
            irodori_caption=self.irodori_caption,
            irodori_ref_wav=self.irodori_ref_wav,
            irodori_ref_wavs=None,
            irodori_model_id=self.irodori_model_id,
            irodori_device=self.irodori_device,
            irodori_precision=self.irodori_precision,
            irodori_codec_device=None,
            irodori_codec_precision=None,
            irodori_num_steps=self.irodori_num_steps,
            irodori_cfg_scale_text=3.5,
            irodori_cfg_scale_caption=3.0,
            irodori_cfg_scale_speaker=5.0,
            irodori_duration_scale=self.irodori_duration_scale,
            irodori_seed=self.irodori_seed,
            irodori_lora_adapter=None,
        )

    def run(self):
        """ワーカーのスレッド実行エントリポイント。指定されたパラメータで音声生成を実行する。

        `tktts.speak_dialogue` を呼び出して音声を生成し、その結果を `finished` シグナルまたは `error` シグナルで通知します。
        進行状況は `progress` シグナルで更新されます。

        :returns: None
        :rtype: None
        """

        self.progress.emit(10)

        try:
            config = tktts.TTS_ENGINES.get(self.tts_engine)
            if config is None:
                self.error.emit(
                    f"TTSエンジン [{self.tts_engine}] の設定が見つかりません。"
                )
                return

            if tktts.get_tts(self.tts_engine) is None:
                self.error.emit(
                    f"TTSエンジン [{self.tts_engine}] のロードに失敗しました。"
                )
                return

            lines = self.text.strip().split("\n")
            dialogue = [(None, line.strip()) for line in lines if line.strip()]
            if not dialogue:
                self.error.emit("読み上げるテキストがありません。")
                return

            temp_dir = tktts.create_temp_dir(self.temp_dir)

            if self.user_outfile:
                output_path = os.path.abspath(self.user_outfile)
            else:
                # GUIでの再生互換性を優先し、一時出力は常にWAVにする。
                temp_filebody = TEMP_WAV_PREFIX + uuid.uuid4().hex[:8]
                output_path = os.path.abspath(
                    os.path.join(temp_dir, temp_filebody + "_merged.wav")
                )

            output_dir = os.path.dirname(output_path)
            if output_dir:
                os.makedirs(output_dir, exist_ok=True)

            self.tktts_args.outfile = output_path
            self.tktts_args.endpoint = self.voicevox_endpoint
            self.tktts_args.elevenlabs_api_key = os.getenv("ELEVENLABS_API_KEY")

            print(f"Status: {self.tts_engine.upper()}の音声生成開始...")
            # 音声名を文字列のまま渡すと、WinRTバックエンドでspeaker名として
            # 正規化され、空白を含む ``Microsoft Ayumi ...`` が切れることがある。
            # 値として保持する話者マップにして渡す。
            selected_voice_map = {
                None: self.voice_name,
                "": self.voice_name,
                0: self.voice_name,
            }

            result = tktts.speak_dialogue(
                self.tktts_args,
                dialogue,
                voice_map=selected_voice_map,
                replacements={},
                endpoint=self.voicevox_endpoint,
                api_key=self.tktts_args.elevenlabs_api_key,
                output_format=None,  # 出力パスの拡張子から tktts.py が判定
            )

            if not result:
                self.error.emit(
                    f"TTSエンジン [{self.tts_engine}] での音声生成に失敗しました。"
                )
                return

            generated_path = result if isinstance(result, str) else output_path
            if not os.path.isfile(generated_path):
                self.error.emit(f"生成された音声ファイルが見つかりません: {generated_path}")
                return

            self.progress.emit(100)
            self.finished.emit(generated_path)

            if not self.user_outfile:
                self.tmp_files.append(generated_path)

        except Exception as e:
            error_msg = f"音声生成またはファイル操作エラー: {type(e).__name__}: {e}"
            print(error_msg)
            traceback.print_exc()
            self.error.emit(error_msg)


class MyTTSApp(QWidget):
    """統合型TTS (Text-to-Speech) のPySide6 GUIアプリケーション。

    ファイルの読み込み、テキスト置換ルールの適用、各種TTSエンジンによる音声生成と再生、
    一時ファイルの管理、設定の保存・読み込みなどの機能を提供します。
    """
    def __init__(self):
        """MyTTSAppのコンストラクタ。

        UIの初期化、メディアプレイヤーの設定、シグナル接続、設定のロードを行います。
        """
        super().__init__()
        self.setWindowTitle("統合TTS GUI (コンパクト・リサイズ可能)")
        
        # メディア関連の初期化
        self.worker_thread: Optional[MyTTSWorker] = None
        self.audio_output = QAudioOutput(QMediaDevices.defaultAudioOutput())
        self.player: QMediaPlayer = QMediaPlayer()
        self.player.setAudioOutput(self.audio_output)
        self.current_audio_path: Optional[str] = None
        
        # 状態管理
        self.tmp_files = [] 
        self.slide_data: Dict[int, str] = {}
        self.last_dir: Dict[str, str] = {} 
        self.is_converted_text_dirty: bool = True 

        self._load_settings()

        # シグナルとスロットの接続
        self.player.durationChanged.connect(self.set_slider_range)
        self.player.positionChanged.connect(self.update_slider)
        self.player.playbackStateChanged.connect(self.update_playback_buttons)
        self.player.errorOccurred.connect(self.handle_media_player_error)

        self.initUI() 
        self.setGeometry(self.settings.get('geometry', QRect(100, 100, 800, 650))) 
        self.setMinimumSize(400, 450)

        # TTS設定UIの変更を監視し、ダーティフラグを立てる
        self.text_input_converted.textChanged.connect(self.set_dirty)
        self.speed_spin.valueChanged.connect(self.set_dirty)
        self.pitch_spin.valueChanged.connect(self.set_dirty)
        self.instruction_line.textChanged.connect(self.set_dirty)
        self.engine_combo.currentIndexChanged.connect(self.update_voice_list) # update_voice_list内でもset_dirtyを呼ぶ
        self.voice_combo.currentIndexChanged.connect(self.set_dirty) # ボイス変更時

        # Qwen/Irodori settings
        for w in (
            self.qwen3_model_line, self.qwen3_language_line, self.qwen3_instruct_line,
            self.irodori_model_line, self.irodori_caption_line,
            self.irodori_ref_wav_line, self.irodori_seed_line,
        ):
            w.textChanged.connect(self.set_dirty)
        self.qwen3_device_combo.currentIndexChanged.connect(self.set_dirty)
        self.qwen3_dtype_combo.currentIndexChanged.connect(self.set_dirty)
        self.irodori_device_combo.currentIndexChanged.connect(self.set_dirty)
        self.irodori_precision_combo.currentIndexChanged.connect(self.set_dirty)
        self.irodori_steps_spin.valueChanged.connect(self.set_dirty)
        self.irodori_duration_spin.valueChanged.connect(self.set_dirty)

        self.update_voice_list()

        QApplication.instance().aboutToQuit.connect(self.cleanup_temp_files)
        QApplication.instance().aboutToQuit.connect(self._save_settings)


    # --- 状態管理 ---
    @Slot()
    def set_dirty(self):
        """TTS出力に影響する設定が変更された際にダーティフラグを立てるスロット。

        このフラグは、再生成が必要かどうかを判断するために使用されます。
        再生ボタンの表示テキストも更新されます。
        """
        self.is_converted_text_dirty = True
        if self.player.playbackState() != QMediaPlayer.PlaybackState.PlayingState:
            self.update_playback_buttons(self.player.playbackState())
            
    def _load_settings(self):
        """アプリケーションの設定ファイル (INI) から設定を読み込む。

        ウィンドウのジオメトリ、パス設定、TTSエンジンの詳細設定などを読み込み、
        `self.settings` 辞書に格納します。
        """
        default_settings = {
            'geometry': QRect(100, 100, 800, 650),
            'temp_dir': DEFAULT_TEMP_DIR,
            'aquestalk_path': DEFAULT_AQUESTALK_PATH,
            'voicevox_endpoint': DEFAULT_VOICEVOX_ENDPOINT,
            'input_file': "input.md",
            'replace_file': "replace.ini",
            'replace_file2': "user_replace.ini",
            'output_file': "",
            'qwen3_language': "Japanese",
            'qwen3_model_id': "Qwen/Qwen3-TTS-12Hz-0.6B-CustomVoice",
            'qwen3_device': "auto",
            'qwen3_dtype': "auto",
            'qwen3_instruct': "",
            'irodori_caption': "落ち着いた自然な声で、明瞭に読み上げる。",
            'irodori_ref_wav': "",
            'irodori_model_id': "Aratako/Irodori-TTS-v4.1-Small",
            'irodori_device': "auto",
            'irodori_precision': "auto",
            'irodori_num_steps': "40",
            'irodori_duration_scale': "1.0",
            'irodori_seed': "0",
        }
        self.settings: Dict[str, Any] = default_settings.copy()
        
        if not os.path.exists(INI_FILE_NAME):
            return

        try:
            with open(INI_FILE_NAME, 'r', encoding='utf-8') as f:
                content = f.read()
                current_section = None
                for line in content.splitlines():
                    line = line.strip()
                    if not line or line.startswith('#'):
                        continue
                    if line.startswith('[') and line.endswith(']'):
                        current_section = line[1:-1].strip()
                    elif '=' in line:
                        key, value = line.split('=', 1)
                        key = key.strip()
                        value = value.strip().strip('"')

                        if current_section == "window":
                            if key == "x": self.settings['x'] = int(value)
                            elif key == "y": self.settings['y'] = int(value)
                            elif key == "width": self.settings['width'] = int(value)
                            elif key == "height": self.settings['height'] = int(value)
                        elif current_section in ("tts_paths", "qwen3", "irodori"):
                            if key in default_settings:
                                self.settings[key] = value
                
                if 'x' in self.settings:
                    self.settings['geometry'] = QRect(
                        self.settings['x'], self.settings['y'],
                        self.settings.get('width', 800), self.settings.get('height', 650)
                    )
        except Exception as e:
            print(f"設定ファイル読み込みエラー: {e}")
            self.settings = default_settings.copy()


    def _save_settings(self):
        """アプリケーションの現在の設定をINIファイルに保存する。

        ウィンドウのジオメトリ、各入力フィールドの値、TTSエンジンの詳細設定などを
        INIファイルに書き出します。
        """
        geom = self.geometry()
        
        current_settings = {
            'temp_dir': self.temp_dir_line.text(),
            'aquestalk_path': self.aquestalk_path_line.text(),
            'voicevox_endpoint': self.voicevox_endpoint_line.text(),
            'input_file': self.input_file_line.text(),
            'replace_file': self.replace_ini_line.text(),
            'replace_file2': self.replace_ini2_line.text(),
            'output_file': self.output_line.text(),
            'qwen3_language': self.qwen3_language_line.text(),
            'qwen3_model_id': self.qwen3_model_line.text(),
            'qwen3_device': self.qwen3_device_combo.currentText(),
            'qwen3_dtype': self.qwen3_dtype_combo.currentText(),
            'qwen3_instruct': self.qwen3_instruct_line.text(),
            'irodori_caption': self.irodori_caption_line.text(),
            'irodori_ref_wav': self.irodori_ref_wav_line.text(),
            'irodori_model_id': self.irodori_model_line.text(),
            'irodori_device': self.irodori_device_combo.currentText(),
            'irodori_precision': self.irodori_precision_combo.currentText(),
            'irodori_num_steps': str(self.irodori_steps_spin.value()),
            'irodori_duration_scale': str(self.irodori_duration_spin.value()),
            'irodori_seed': self.irodori_seed_line.text(),
        }

        content = (
            f'[window]\n'
            f'x = {geom.x()}\n'
            f'y = {geom.y()}\n'
            f'width = {geom.width()}\n'
            f'height = {geom.height()}\n'
            f'\n'
            f'[tts_paths]\n'
            f'temp_dir = "{current_settings["temp_dir"]}"\n'
            f'aquestalk_path = "{current_settings["aquestalk_path"]}"\n'
            f'voicevox_endpoint = "{current_settings["voicevox_endpoint"]}"\n'
            f'input_file = "{current_settings["input_file"]}"\n'
            f'replace_file = "{current_settings["replace_file"]}"\n'
            f'replace_file2 = "{current_settings["replace_file2"]}"\n'
            f'output_file = "{current_settings["output_file"]}"\n'
            f'\n'
            f'[qwen3]\n'
            f'qwen3_language = "{current_settings["qwen3_language"]}"\n'
            f'qwen3_model_id = "{current_settings["qwen3_model_id"]}"\n'
            f'qwen3_device = "{current_settings["qwen3_device"]}"\n'
            f'qwen3_dtype = "{current_settings["qwen3_dtype"]}"\n'
            f'qwen3_instruct = "{current_settings["qwen3_instruct"]}"\n'
            f'\n'
            f'[irodori]\n'
            f'irodori_caption = "{current_settings["irodori_caption"]}"\n'
            f'irodori_ref_wav = "{current_settings["irodori_ref_wav"]}"\n'
            f'irodori_model_id = "{current_settings["irodori_model_id"]}"\n'
            f'irodori_device = "{current_settings["irodori_device"]}"\n'
            f'irodori_precision = "{current_settings["irodori_precision"]}"\n'
            f'irodori_num_steps = "{current_settings["irodori_num_steps"]}"\n'
            f'irodori_duration_scale = "{current_settings["irodori_duration_scale"]}"\n'
            f'irodori_seed = "{current_settings["irodori_seed"]}"\n'
        )

        try:
            with open(INI_FILE_NAME, 'w', encoding='utf-8') as f:
                f.write(content)
        except Exception as e:
            print(f"設定ファイル保存エラー: {e}")


    def cleanup_temp_files(self):
        """アプリケーション終了時に生成された一時音声ファイルをクリーンアップする。

        メディアプレイヤーを停止し、ワーカーを終了させ、追跡している一時ファイルと
        一時ディレクトリ内のプレフィックス付きファイルを削除します。
        """
        self.handle_stop()
        self.player.setSource(QUrl())
        if self.worker_thread and self.worker_thread.isRunning():
             self.worker_thread.quit()
             self.worker_thread.wait()

        for f in self.tmp_files:
            try:
                if os.path.isfile(f):
                    os.remove(f)
            except Exception as e:
                print(f"一時ファイル削除エラー: {f}: {e}")

        temp_dir = self.settings.get('temp_dir', DEFAULT_TEMP_DIR)
        try:
            if os.path.exists(temp_dir):
                search_pattern = os.path.join(temp_dir, f"{TEMP_WAV_PREFIX}*")
                for file_path in glob.glob(search_pattern):
                    try:
                        if os.path.isfile(file_path):
                            os.remove(file_path)
                    except:
                        pass
                if not os.listdir(temp_dir):
                    os.rmdir(temp_dir)
        except Exception as e:
             print(f"一時ディレクトリのクリーンアップエラー: {e}")


    # --- UI初期化 ---
    def initUI(self):
        """アプリケーションのユーザーインターフェースを初期化する。

        メインレイアウト、タブウィジェット、ファイル選択、テキスト編集、TTS設定、
        メディアコントロール、ステータス表示などの各UI要素を配置し、接続します。
        """
        main_layout = QVBoxLayout()
        self.tabs = QTabWidget()
        
        # --- TTS設定ウィジェットの事前初期化 ---
        # これらのウィジェットはMain/Configタブ間で共有されるため、先に初期化する
        self.engine_combo = QComboBox(self)
        self.engine_combo.addItems([
            "pyttsx3",
            "winrt",
            "voicevox",
            "qwen3",
            "irodori",
            "openai",
            "elevenlabs",
            "aquestalkplayer",
            "gemini"
        ])
        self.engine_combo.setCurrentText(DEFAULT_ENGINE)
        self.voice_combo = QComboBox(self)
        self.speed_spin = QDoubleSpinBox(self)
        self.speed_spin.setRange(0.1, 5.0)
        self.speed_spin.setSingleStep(0.1)
        self.speed_spin.setValue(1.0)
        self.pitch_spin = QDoubleSpinBox(self)
        self.pitch_spin.setRange(-10.0, 10.0)
        self.pitch_spin.setSingleStep(0.1)
        self.pitch_spin.setValue(0.0)
        self.instruction_line = QLineEdit()
        self.instruction_line.setPlaceholderText("OpenAI/Gemini instruction (Qwen/Irodoriは下の専用設定を使用)")
        
        # --- Tab 1: Main Content (メイン操作) ---
        main_page = QWidget()
        main_page_layout = QVBoxLayout(main_page)

        # 1. ファイル設定
        file_slide_layout = QGridLayout()
        # Input File
        file_slide_layout.addWidget(QLabel("Input File (infile):"), 0, 0)
        self.input_file_line = QLineEdit(self.settings.get('input_file'))
        file_slide_layout.addWidget(self.input_file_line, 0, 1)
        self.input_file_btn = QPushButton("Path")
        self.input_file_btn.clicked.connect(self.select_input_file)
        file_slide_layout.addWidget(self.input_file_btn, 0, 2)
        # Replace INI 1 (Default)
        file_slide_layout.addWidget(QLabel("Replace INI (Default):"), 1, 0)
        self.replace_ini_line = QLineEdit(self.settings.get('replace_file'))
        file_slide_layout.addWidget(self.replace_ini_line, 1, 1)
        self.replace_ini_btn = QPushButton("Path")
        self.replace_ini_btn.clicked.connect(lambda: self.select_ini_file(self.replace_ini_line, 'replace_file'))
        file_slide_layout.addWidget(self.replace_ini_btn, 1, 2)
        # Replace INI 2 (User)
        file_slide_layout.addWidget(QLabel("Replace INI (User):"), 2, 0)
        self.replace_ini2_line = QLineEdit(self.settings.get('replace_file2'))
        file_slide_layout.addWidget(self.replace_ini2_line, 2, 1)
        self.replace_ini2_btn = QPushButton("Path")
        self.replace_ini2_btn.clicked.connect(lambda: self.select_ini_file(self.replace_ini2_line, 'replace_file2'))
        file_slide_layout.addWidget(self.replace_ini2_btn, 2, 2)
        # Output File
        file_slide_layout.addWidget(QLabel("出力ファイル (wav/mp3):"), 3, 0)
        self.output_line = QLineEdit(self)
        self.output_line.setPlaceholderText("未指定の場合、一時ファイルを作成して再生します (推奨)")
        self.output_line.setText(self.settings.get('output_file', ''))
        file_slide_layout.addWidget(self.output_line, 3, 1)
        self.output_btn = QPushButton("Path")
        self.output_btn.clicked.connect(self.select_output_file)
        file_slide_layout.addWidget(self.output_btn, 3, 2)
        main_page_layout.addLayout(file_slide_layout)
        
        # Slide Page Pulldown (位置変更)
        slide_page_layout = QHBoxLayout()
        slide_page_layout.addWidget(QLabel("Slide Page:"))
        self.slide_page_combo = QComboBox(self)
        self.slide_page_combo.addItem("1. No file loaded")
        self.slide_page_combo.setCurrentIndex(0)
        self.slide_page_combo.currentIndexChanged.connect(self.on_slide_page_changed)
        slide_page_layout.addWidget(self.slide_page_combo)
        main_page_layout.addLayout(slide_page_layout)

        # 4. テキスト入力エリア (2分割 & 拡張可能に)
        text_layout = QVBoxLayout()
        
        # Original Text
        text_layout.addWidget(QLabel("読み上げテキスト (Original):"))
        self.text_input_original = QTextEdit(self)
        self.text_input_original.setPlaceholderText("入力ファイルの内容（スライドページ）がここに表示されます。")
        self.text_input_original.setMinimumHeight(80) 
        self.text_input_original.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding) 
        text_layout.addWidget(self.text_input_original)
        
        # Converted Text
        text_layout.addWidget(QLabel("読み上げテキスト (Converted):"))
        self.text_input_converted = QTextEdit(self)
        self.text_input_converted.setPlaceholderText("置換ルール適用後のテキストがここに表示されます。Play/Generateボタンはこのテキストを読み上げます。")
        self.text_input_converted.setMinimumHeight(80) 
        self.text_input_converted.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding)
        text_layout.addWidget(self.text_input_converted)
        main_page_layout.addLayout(text_layout)
        
        # 5. コントロール (Convertedテキストの直下に配置)
        control_layout = QHBoxLayout()
        
        # Convert Button
        self.convert_btn = QPushButton("⚙️ Convert (Apply Rules)")
        self.convert_btn.clicked.connect(self.handle_convert)
        self.convert_btn.setStyleSheet("font-weight: bold; padding: 5px;")
        control_layout.addWidget(self.convert_btn)

        # 再生/生成ボタン
        self.play_btn = QPushButton("▶ Play/Generate")
        self.generate_btn = QPushButton("⚡ Generate (Force)")
        self.pause_btn = QPushButton("⏸ Pause")
        self.stop_btn = QPushButton("■ Stop")
        
        self.play_btn.clicked.connect(lambda: self.handle_play(force_generate=False))
        self.generate_btn.clicked.connect(lambda: self.handle_play(force_generate=True)) # 強制生成
        self.pause_btn.clicked.connect(self.handle_pause)
        self.stop_btn.clicked.connect(self.handle_stop)
        
        control_layout.addWidget(self.play_btn)
        control_layout.addWidget(self.generate_btn)
        control_layout.addWidget(self.pause_btn)
        control_layout.addWidget(self.stop_btn)
        
        main_page_layout.addLayout(control_layout)


        # --- Tab 2: Config (パス設定) ---
        config_page = QWidget()
        config_page_layout = QVBoxLayout(config_page)
        
        # アプリケーションパス設定
        app_path_layout = QGridLayout()
        # AquesTalk Path
        app_path_layout.addWidget(QLabel("AquesTalk Path:"), 0, 0)
        self.aquestalk_path_line = QLineEdit(self.settings.get('aquestalk_path', DEFAULT_AQUESTALK_PATH))
        app_path_layout.addWidget(self.aquestalk_path_line, 0, 1)
        self.aquestalk_path_btn = QPushButton("Path")
        self.aquestalk_path_btn.clicked.connect(self.select_aquestalk_path)
        app_path_layout.addWidget(self.aquestalk_path_btn, 0, 2)
        # Voicevox Endpoint
        app_path_layout.addWidget(QLabel("Voicevox Endpoint:"), 1, 0)
        self.voicevox_endpoint_line = QLineEdit(self.settings.get('voicevox_endpoint', DEFAULT_VOICEVOX_ENDPOINT))
        app_path_layout.addWidget(self.voicevox_endpoint_line, 1, 1, 1, 2) 
        # Temp Dir
        app_path_layout.addWidget(QLabel("Temp Dir:"), 2, 0)
        self.temp_dir_line = QLineEdit(self.settings.get('temp_dir', DEFAULT_TEMP_DIR))
        app_path_layout.addWidget(self.temp_dir_line, 2, 1, 1, 2)

        config_page_layout.addLayout(app_path_layout)
        
        # TTS設定（Configタブに配置）
        tts_settings_layout_config = QGridLayout()
        tts_settings_layout_config.addWidget(QLabel("Engine:"), 3, 0)
        tts_settings_layout_config.addWidget(self.engine_combo, 3, 1) 
        tts_settings_layout_config.addWidget(QLabel("Voice:"), 3, 2)
        tts_settings_layout_config.addWidget(self.voice_combo, 3, 3) 
        tts_settings_layout_config.addWidget(QLabel("Speed (fspeak_rate):"), 4, 0)
        tts_settings_layout_config.addWidget(self.speed_spin, 4, 1)
        tts_settings_layout_config.addWidget(QLabel("Pitch (ピッチ):"), 4, 2)
        tts_settings_layout_config.addWidget(self.pitch_spin, 4, 3)
        tts_settings_layout_config.addWidget(QLabel("Instruction (OpenAI/Gemini):"), 5, 0)
        tts_settings_layout_config.addWidget(self.instruction_line, 5, 1, 1, 3)
        
        config_page_layout.addLayout(tts_settings_layout_config)

        # Qwen3-TTS settings
        qwen_layout = QGridLayout()
        qwen_layout.addWidget(QLabel("Qwen3 model:"), 0, 0)
        self.qwen3_model_line = QLineEdit(self.settings.get("qwen3_model_id", "Qwen/Qwen3-TTS-12Hz-0.6B-CustomVoice"))
        qwen_layout.addWidget(self.qwen3_model_line, 0, 1, 1, 3)

        qwen_layout.addWidget(QLabel("Language:"), 1, 0)
        self.qwen3_language_line = QLineEdit(self.settings.get("qwen3_language", "Japanese"))
        qwen_layout.addWidget(self.qwen3_language_line, 1, 1)

        qwen_layout.addWidget(QLabel("Device:"), 1, 2)
        self.qwen3_device_combo = QComboBox()
        self.qwen3_device_combo.addItems(["auto", "cuda:0", "cpu"])
        self.qwen3_device_combo.setCurrentText(self.settings.get("qwen3_device", "auto"))
        qwen_layout.addWidget(self.qwen3_device_combo, 1, 3)

        qwen_layout.addWidget(QLabel("dtype:"), 2, 0)
        self.qwen3_dtype_combo = QComboBox()
        self.qwen3_dtype_combo.addItems(["auto", "bfloat16", "float16", "float32"])
        self.qwen3_dtype_combo.setCurrentText(self.settings.get("qwen3_dtype", "auto"))
        qwen_layout.addWidget(self.qwen3_dtype_combo, 2, 1)

        qwen_layout.addWidget(QLabel("Qwen instruct:"), 2, 2)
        self.qwen3_instruct_line = QLineEdit(self.settings.get("qwen3_instruct", ""))
        qwen_layout.addWidget(self.qwen3_instruct_line, 2, 3)

        self.qwen_frame = QFrame()
        self.qwen_frame.setFrameShape(QFrame.Shape.StyledPanel)
        self.qwen_frame.setLayout(qwen_layout)
        config_page_layout.addWidget(QLabel("Qwen3-TTS"))
        config_page_layout.addWidget(self.qwen_frame)

        # Irodori-TTS settings
        irodori_layout = QGridLayout()
        irodori_layout.addWidget(QLabel("Irodori model:"), 0, 0)
        self.irodori_model_line = QLineEdit(self.settings.get("irodori_model_id", "Aratako/Irodori-TTS-v4.1-Small"))
        irodori_layout.addWidget(self.irodori_model_line, 0, 1, 1, 3)

        irodori_layout.addWidget(QLabel("Caption:"), 1, 0)
        self.irodori_caption_line = QLineEdit(self.settings.get("irodori_caption", "落ち着いた自然な声で、明瞭に読み上げる。"))
        irodori_layout.addWidget(self.irodori_caption_line, 1, 1, 1, 3)

        irodori_layout.addWidget(QLabel("Reference WAV:"), 2, 0)
        self.irodori_ref_wav_line = QLineEdit(self.settings.get("irodori_ref_wav", ""))
        irodori_layout.addWidget(self.irodori_ref_wav_line, 2, 1, 1, 2)
        self.irodori_ref_wav_btn = QPushButton("Path")
        self.irodori_ref_wav_btn.clicked.connect(self.select_irodori_ref_wav)
        irodori_layout.addWidget(self.irodori_ref_wav_btn, 2, 3)

        irodori_layout.addWidget(QLabel("Device:"), 3, 0)
        self.irodori_device_combo = QComboBox()
        self.irodori_device_combo.addItems(["auto", "cuda", "cuda:0", "cpu"])
        self.irodori_device_combo.setCurrentText(self.settings.get("irodori_device", "auto"))
        irodori_layout.addWidget(self.irodori_device_combo, 3, 1)

        irodori_layout.addWidget(QLabel("Precision:"), 3, 2)
        self.irodori_precision_combo = QComboBox()
        self.irodori_precision_combo.addItems(["auto", "bf16", "fp32"])
        self.irodori_precision_combo.setCurrentText(self.settings.get("irodori_precision", "auto"))
        irodori_layout.addWidget(self.irodori_precision_combo, 3, 3)

        irodori_layout.addWidget(QLabel("Steps:"), 4, 0)
        self.irodori_steps_spin = QDoubleSpinBox()
        self.irodori_steps_spin.setDecimals(0)
        self.irodori_steps_spin.setRange(1, 200)
        self.irodori_steps_spin.setSingleStep(1)
        self.irodori_steps_spin.setValue(float(self.settings.get("irodori_num_steps", "40")))
        irodori_layout.addWidget(self.irodori_steps_spin, 4, 1)

        irodori_layout.addWidget(QLabel("Duration scale:"), 4, 2)
        self.irodori_duration_spin = QDoubleSpinBox()
        self.irodori_duration_spin.setRange(0.1, 3.0)
        self.irodori_duration_spin.setSingleStep(0.05)
        self.irodori_duration_spin.setValue(float(self.settings.get("irodori_duration_scale", "1.0")))
        irodori_layout.addWidget(self.irodori_duration_spin, 4, 3)

        irodori_layout.addWidget(QLabel("Seed:"), 5, 0)
        self.irodori_seed_line = QLineEdit(self.settings.get("irodori_seed", "0"))
        self.irodori_seed_line.setPlaceholderText("0 / random / none")
        irodori_layout.addWidget(self.irodori_seed_line, 5, 1)

        self.irodori_frame = QFrame()
        self.irodori_frame.setFrameShape(QFrame.Shape.StyledPanel)
        self.irodori_frame.setLayout(irodori_layout)
        config_page_layout.addWidget(QLabel("Irodori-TTS"))
        config_page_layout.addWidget(self.irodori_frame)

        config_page_layout.addStretch(1) # 残りのスペースを埋める

        # Tab Widgetに追加
        self.tabs.addTab(main_page, "Main")
        self.tabs.addTab(config_page, "Config")
        main_layout.addWidget(self.tabs)
        
        # --- Tabの外の共通コントロール ---

        # 6. プログレスバーと再生位置を1行に統合
        progress_slider_layout = QHBoxLayout()
        progress_slider_layout.addWidget(QLabel("Prog/Pos:"))
        
        # プログレスバー
        self.progress_bar = QProgressBar(self)
        self.progress_bar.setRange(0, 100)
        self.progress_bar.setValue(0)
        self.progress_bar.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Preferred)
        progress_slider_layout.addWidget(self.progress_bar)

        # 再生位置スライダー
        self.position_slider = QSlider(Qt.Orientation.Horizontal)
        self.position_slider.setRange(0, 0)
        self.position_slider.setTracking(False)
        self.position_slider.sliderMoved.connect(self.seek_position)
        self.position_slider.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Preferred)
        progress_slider_layout.addWidget(self.position_slider)

        main_layout.addLayout(progress_slider_layout)

        # 7. ステータスラベル
        self.status_label = QLabel("Status: Ready")
        main_layout.addWidget(self.status_label)

        self.setLayout(main_layout)
        self.update_playback_buttons(self.player.playbackState())

        # 初期状態では選択中エンジン以外の専用設定を無効化
        self.qwen_frame.setEnabled(self.engine_combo.currentText().lower() in ("qwen3", "qwen"))
        self.irodori_frame.setEnabled(self.engine_combo.currentText().lower() in ("irodori", "irodori-tts"))

        self.text_input_original.setText("TTSアプリへようこそ！\nInput Fileを選択すると、内容がここに表示され、Convertボタンで置換が適用されます。")
        self.text_input_converted.setText("Play/Generateボタンを押すと、このconvertedテキストが読み上げられます。")


    # --- ファイル選択/スロット群 ---
    
    def _get_initial_dir(self, path_line: QLineEdit, key: str) -> str:
        """ファイルダイアログの初期ディレクトリを決定するためのヘルパーメソッド。

        QLineEditの内容または記憶されたディレクトリを元に初期ディレクトリを返します。

        :param path_line: 関連するファイルパスを表示するQLineEditウィジェット。
        :type path_line: QLineEdit
        :param key: 最後に使用したディレクトリを記憶するためのキー。
        :type key: str
        :returns: ファイルダイアログの初期ディレクトリパス。
        :rtype: str
        """
        current_path = path_line.text()
        if os.path.isfile(current_path):
            dir_name = os.path.dirname(current_path)
            self.last_dir[key] = dir_name
            return dir_name
        elif os.path.isdir(current_path):
            self.last_dir[key] = current_path
            return current_path
        elif key in self.last_dir and os.path.isdir(self.last_dir[key]):
            return self.last_dir[key]
        return QDir.currentPath()

    @Slot()
    def select_input_file(self):
        """入力テキストファイルを選択するダイアログを開き、選択されたファイルを読み込むスロット。
        """
        key = 'input_file'
        initial_dir = self._get_initial_dir(self.input_file_line, key)
        file_path, _ = QFileDialog.getOpenFileName(
            self,
            "入力テキストファイルの選択",
            initial_dir,
            "テキストファイル (*.txt *.md);;全てのファイル (*)"
        )
        if file_path:
            self.input_file_line.setText(file_path)
            self.last_dir[key] = os.path.dirname(file_path)
            self.load_input_file(file_path)

    @Slot()
    def select_output_file(self):
        """出力音声ファイルを保存するダイアログを開き、選択されたパスを更新するスロット。
        """
        key = 'output_file'
        initial_dir = self._get_initial_dir(self.output_line, key)
        file_path, _ = QFileDialog.getSaveFileName(
            self, 
            "出力音声ファイルの選択", 
            initial_dir, 
            "Audio Files (*.wav *.mp3);;Wave Files (*.wav);;MP3 Files (*.mp3);;All Files (*)"
        )
        if file_path:
            root, ext = os.path.splitext(file_path)
            if not ext:
                engine = self.engine_combo.currentText().lower()
                default_ext = ".mp3" if engine in ("openai", "elevenlabs", "eleven") else ".wav"
                file_path += default_ext
            self.output_line.setText(file_path)
            self.last_dir[key] = os.path.dirname(file_path)

    @Slot()
    def select_aquestalk_path(self):
        """AquesTalkPlayer.exeのパスを選択するダイアログを開き、パスを更新するスロット。
        """
        key = 'aquestalk_path'
        initial_dir = self._get_initial_dir(self.aquestalk_path_line, key)
        file_path, _ = QFileDialog.getOpenFileName(
            self,
            "AquesTalkPlayer.exeの選択",
            initial_dir,
            "実行ファイル (*.exe);;全てのファイル (*)" 
        )
        if file_path:
            self.aquestalk_path_line.setText(file_path)
            self.last_dir[key] = os.path.dirname(file_path)
            
    @Slot()
    def select_irodori_ref_wav(self):
        """Irodori-TTSの参照WAVファイルを選択するダイアログを開き、パスを更新するスロット。
        """
        key = "irodori_ref_wav"
        initial_dir = self._get_initial_dir(self.irodori_ref_wav_line, key)
        file_path, _ = QFileDialog.getOpenFileName(
            self,
            "Irodori-TTS Reference WAV",
            initial_dir,
            "Wave Files (*.wav);;Audio Files (*.wav *.mp3 *.flac);;All Files (*)"
        )
        if file_path:
            self.irodori_ref_wav_line.setText(file_path)
            self.last_dir[key] = os.path.dirname(file_path)

    @Slot(QLineEdit, str)
    def select_ini_file(self, line_edit: QLineEdit, key: str):
        """INIファイル（置換ルール）を選択するダイアログを開き、指定されたQLineEditを更新するスロット。

        :param line_edit: ファイルパスを表示・設定するQLineEditウィジェット。
        :type line_edit: QLineEdit
        :param key: 最後に使用したディレクトリを記憶するためのキー。
        :type key: str
        """
        initial_dir = self._get_initial_dir(line_edit, key)
        file_path, _ = QFileDialog.getOpenFileName(
            self,
            "INIファイル（置換ルール）の選択",
            initial_dir,
            "INIファイル (*.ini);;全てのファイル (*)"
        )
        if file_path:
            line_edit.setText(file_path)
            self.last_dir[key] = os.path.dirname(file_path)

    def load_input_file(self, file_path: str):
        """指定された入力ファイルを読み込み、スライド（ページ）ごとにテキストをパースしてUIを更新する。

        `# Slide` または `*N` のパターンでスライドを分割し、`slide_data` に格納します。
        スライドページ選択コンボボックスも更新します。

        :param file_path: 読み込む入力ファイルのパス。
        :type file_path: str
        """
        self.slide_data = {}
        self.current_infile_path = file_path
        
        try:
            encoding = detect_encoding(file_path)
            if encoding is None: encoding = 'utf-8'
            with open(file_path, 'r', encoding=encoding) as f:
                full_text = f.read()

            self.slide_data[0] = full_text.strip()
            current_slide_number = 1
            
            # スライド区切りパターン: `# Slide` で始まる行、または `*1, *2` などの行
            slide_separator_pattern = re.compile(r"^\s*(#\s*Slide.*|\*\d+)\s*$", re.IGNORECASE | re.MULTILINE)
            
            parts = slide_separator_pattern.split(full_text)
            
            if len(parts) > 1:
                
                # parts[0]は最初の区切りより前のテキスト
                if parts[0].strip():
                    self.slide_data[1] = parts[0].strip()
                    current_slide_number = 2
                
                # parts[1]以降は区切りと区切りの間のテキスト
                for part in parts[1:]:
                    part_content = part.strip()
                    # 区切りパターンにマッチしたテキスト（空か、区切り文字自体）はスキップ
                    if part_content and not slide_separator_pattern.match(part_content): 
                         self.slide_data[current_slide_number] = part_content
                         current_slide_number += 1
                
                # スライドが分割された場合は、改めてページ0(全文)を再構築
                self.slide_data[0] = full_text.strip()


            # 3. Slide pageプルダウンを更新
            self.slide_page_combo.clear()
            
            is_slide_parsed = len(self.slide_data) > 1 
            
            self.slide_page_combo.addItem("0. (All Document)")
            if is_slide_parsed:
                for i in sorted([k for k in self.slide_data.keys() if k > 0]):
                    self.slide_page_combo.addItem(f"{i}. Slide {i}")
                self.slide_page_combo.setEnabled(True)
            else:
                self.slide_page_combo.setEnabled(False)
                
            self.slide_page_combo.setCurrentIndex(0)

        except Exception as e:
            QMessageBox.critical(self, "ファイル読み込みエラー", f"ファイルの読み込みに失敗しました: {e}")
            self.slide_page_combo.clear()
            self.slide_page_combo.addItem("1. No file loaded")
            self.slide_page_combo.setEnabled(False)
            self.text_input_original.setText("")
            self.text_input_converted.setText("")

    @Slot(int)
    def on_slide_page_changed(self, index: int):
        """スライドページ選択コンボボックスの値が変更されたときに呼び出されるスロット。

        選択されたページに対応するオリジナルテキストを表示し、変換済みテキストをリセットして
        ダーティフラグを立てます。

        :param index: 選択されたスライドページのインデックス。
        :type index: int
        """
        page_key = index
        
        if page_key in self.slide_data:
            original_text = self.slide_data[page_key]
            
            # Convertedテキストの変更を一時的に無効化
            self.text_input_converted.textChanged.disconnect(self.set_dirty) 
            
            self.text_input_original.setText(original_text)
            self.text_input_converted.setText("")
            self.is_converted_text_dirty = True # ページが変わったので変換が必要
            
            # Convertedテキストの変更監視を再開
            self.text_input_converted.textChanged.connect(self.set_dirty)
            
            self.status_label.setText(f"Status: Page {page_key} loaded. Ready to Convert.")
        else:
             self.text_input_original.setText("")
             self.text_input_converted.setText("")
             self.status_label.setText("Status: Error - Page content missing.")
             
    @Slot()
    def handle_convert(self):
        """「Convert (Apply Rules)」ボタンがクリックされたときに、置換処理を実行するスロット。

        2つのINIファイルから置換ルールを読み込み（ユーザーINIがデフォルトを上書き）、
        オリジナルテキストに適用します。スライド区切りマーカーを削除し、結果を
        Convertedテキストエリアに表示します。
        """
        original_text = self.text_input_original.toPlainText()
        if not original_text.strip():
            QMessageBox.warning(self, "Convertエラー", "Originalテキストが空です。ファイルを読み込むか、テキストを入力してください。")
            return
        
        default_ini_path = self.replace_ini_line.text()
        user_ini_path = self.replace_ini2_line.text()
        
        self.status_label.setText("Status: Loading/Checking replacement rules...")
        QApplication.processEvents()
        
        # タイムスタンプチェックと再読み込み (force_reload=True)
        dict2 = load_replace_dict(user_ini_path, force_reload=True)
        dict1 = load_replace_dict(default_ini_path, force_reload=True)
        
        dict1_filtered = {k: v for k, v in dict1.items() if k not in dict2}
        
        final_replace_dict = {}
        final_replace_dict.update(dict1_filtered) 
        final_replace_dict.update(dict2) 
        
        if not final_replace_dict:
             QMessageBox.information(self, "Convert情報", "有効な置換ルールがINIファイルから見つかりませんでした。")
             self.text_input_converted.setText(original_text)
             
             # Convertedテキストの変更を一時的に無効化
             self.text_input_converted.textChanged.disconnect(self.set_dirty)
             self.is_converted_text_dirty = False 
             self.text_input_converted.textChanged.connect(self.set_dirty)
             
             self.status_label.setText("Status: No rules applied. Converted = Original.")
             return
             
        self.status_label.setText("Status: Applying replacement rules...")
        QApplication.processEvents()

        replaced_text = apply_replacements(original_text, final_replace_dict)
        
        # 修正: `# Slide...` および `*N` 形式のマーカー行を完全に削除
        # 1. `# Slide...` 行の削除（行頭/行末に空白があっても良い）
        replaced_text = re.sub(r"^\s*#\s*Slide.*$", "\n", replaced_text, flags=re.IGNORECASE | re.MULTILINE)
        
        # 2. `*N` (スライド番号) 行の削除（行頭/行末に空白があっても良い）
        replaced_text = re.sub(r"^\s*\*\d+\s*$", "\n", replaced_text, flags=re.IGNORECASE | re.MULTILINE)
        
        # 空行のみの行を削除（連続する空行を一つにまとめる）
        replaced_text = re.sub(r'\n\s*\n', '\n\n', replaced_text).strip()
        
        # Convertedテキストの変更を一時的に無効化してから更新
        self.text_input_converted.textChanged.disconnect(self.set_dirty)
        self.text_input_converted.setText(replaced_text)
        self.is_converted_text_dirty = False # 変換完了
        self.text_input_converted.textChanged.connect(self.set_dirty)

        self.status_label.setText("Status: Conversion complete. Ready to Play.")


    def handle_play(self, force_generate: bool = False):
        """「Play/Generate」ボタンの動作を制御するスロット。

        再生中またはポーズ中の場合はその状態を継続・再開します。
        ダーティフラグが立っている、または `force_generate=True` の場合は、
        新しい音声ファイルを生成し、完了後に再生します。
        そうでない場合は、既存の音声ファイルを再生します。

        :param force_generate: True の場合、ダーティフラグの状態にかかわらず強制的に音声生成を行う。
        :type force_generate: bool
        """

        # 1. 既に再生中の場合は無視
        if self.player.playbackState() == QMediaPlayer.PlaybackState.PlayingState:
            return

        # 2. ポーズ状態からの再開
        if self.player.playbackState() == QMediaPlayer.PlaybackState.PausedState:
            self.player.play()
            return
            
        # 3. 強制生成が不要かつダーティでない場合 -> 再生
        is_ready_to_play = (
            not force_generate and 
            not self.is_converted_text_dirty and 
            self.current_audio_path and 
            os.path.exists(self.current_audio_path)
        )
        
        if is_ready_to_play:
             self.status_label.setText(f"Status: Playing existing audio: {os.path.basename(self.current_audio_path)}")
             try:
                audio_url = QUrl.fromLocalFile(self.current_audio_path)
                self.player.stop()
                self.player.setSource(audio_url)
                self.player.play()
             except Exception as e:
                 QMessageBox.critical(self, "再生エラー", f"既存の音声ファイルの再生に失敗しました: {e}")
                 self.current_audio_path = None 
             return

        # 4. 生成が必要な場合 (ダーティ or ファイルがない or 強制生成)
        
        if self.worker_thread and self.worker_thread.isRunning():
            QMessageBox.warning(self, "処理中", "現在、音声生成が実行中です。完了をお待ちください。")
            return

        text = self.text_input_converted.toPlainText() 
        outfile = self.output_line.text().strip()
        tts_engine = self.engine_combo.currentText()
        speed_rate = self.speed_spin.value()
        voice_name = self.voice_combo.currentText()
        pitch = self.pitch_spin.value()
        instruction = self.instruction_line.text()
        
        aquestalk_path = self.aquestalk_path_line.text().strip()
        temp_dir = self.temp_dir_line.text().strip()
        voicevox_endpoint = self.voicevox_endpoint_line.text().strip()

        qwen3_language = self.qwen3_language_line.text().strip() or "Japanese"
        qwen3_model_id = self.qwen3_model_line.text().strip() or "Qwen/Qwen3-TTS-12Hz-0.6B-CustomVoice"
        qwen3_device = self.qwen3_device_combo.currentText()
        qwen3_dtype = self.qwen3_dtype_combo.currentText()
        qwen3_instruct = self.qwen3_instruct_line.text().strip()

        irodori_caption = self.irodori_caption_line.text().strip()
        irodori_ref_wav = self.irodori_ref_wav_line.text().strip()
        irodori_model_id = self.irodori_model_line.text().strip() or "Aratako/Irodori-TTS-v4.1-Small"
        irodori_device = self.irodori_device_combo.currentText()
        irodori_precision = self.irodori_precision_combo.currentText()
        irodori_num_steps = int(self.irodori_steps_spin.value())
        irodori_duration_scale = float(self.irodori_duration_spin.value())
        irodori_seed = self.irodori_seed_line.text().strip() or "0"

        if not text.strip():
            QMessageBox.warning(self, "入力エラー", "読み上げテキスト (Converted) が空です。")
            return

        if not voice_name or "No voices found" in voice_name or "Error loading voices" in voice_name:
             QMessageBox.warning(self, "ボイス選択エラー", "有効なボイスが選択されていません。エンジンを確認してください。")
             return

        self.status_label.setText("Status: 音声生成を開始しました... (非同期実行中)")
        self.update_playback_buttons(QMediaPlayer.PlaybackState.StoppedState, is_generating=True)
        self.progress_bar.setValue(0)

        self.worker_thread = MyTTSWorker(
            text, outfile, tts_engine, speed_rate, pitch, instruction, voice_name, self.tmp_files,
            aquestalk_path, temp_dir, voicevox_endpoint,
            qwen3_language=qwen3_language,
            qwen3_model_id=qwen3_model_id,
            qwen3_device=qwen3_device,
            qwen3_dtype=qwen3_dtype,
            qwen3_instruct=qwen3_instruct,
            irodori_caption=irodori_caption,
            irodori_ref_wav=irodori_ref_wav,
            irodori_model_id=irodori_model_id,
            irodori_device=irodori_device,
            irodori_precision=irodori_precision,
            irodori_num_steps=irodori_num_steps,
            irodori_duration_scale=irodori_duration_scale,
            irodori_seed=irodori_seed,
        )
        self.worker_thread.finished.connect(self.on_synthesis_finished)
        self.worker_thread.error.connect(self.on_synthesis_error)
        self.worker_thread.progress.connect(self.progress_bar.setValue)
        self.worker_thread.start()


    @Slot(str)
    def on_synthesis_finished(self, file_path: str):
        """`MyTTSWorker` から音声生成が完了したことを通知されたときに呼び出されるスロット。

        生成された音声ファイルを再生し、ステータスを更新します。

        :param file_path: 生成された音声ファイルのパス。
        :type file_path: str
        """
        self.current_audio_path = file_path
        self.is_converted_text_dirty = False 
        self.status_label.setText(f"Status: 音声生成が完了しました: {os.path.basename(file_path)}")
        self.progress_bar.setValue(100)

        self.update_playback_buttons(QMediaPlayer.PlaybackState.StoppedState, is_generating=False)

        if not file_path or not os.path.exists(file_path):
             self.on_synthesis_error(f"音声ファイルが見つかりません: {file_path}")
             return

        try:
            audio_url = QUrl.fromLocalFile(file_path)
            self.player.stop()
            self.player.setSource(audio_url)
            self.player.play()
        except Exception as e:
            self.on_synthesis_error(f"音声再生の開始に失敗しました: {e}")

    @Slot(str)
    def on_synthesis_error(self, message: str):
        """`MyTTSWorker` から音声生成中にエラーが発生したことを通知されたときに呼び出されるスロット。

        エラーメッセージを表示し、ステータスを更新します。

        :param message: エラーの詳細メッセージ。
        :type message: str
        """
        self.status_label.setText("Status: エラー発生")
        self.progress_bar.setValue(0)
        QMessageBox.critical(self, "エラー", message)
        self.current_audio_path = None 

        self.update_playback_buttons(QMediaPlayer.PlaybackState.StoppedState, is_generating=False)
        if self.worker_thread:
            self.worker_thread.quit()
            self.worker_thread.wait()

    @Slot()
    def update_voice_list(self):
        """選択されたTTSエンジンに応じて、利用可能なボイスリストを更新し、関連するUI要素の有効/無効状態を切り替えるスロット。

        `tktts.get_available_voices` を呼び出し、各エンジン固有のUI設定
        （エンドポイント、AquesTalkパスなど）を制御します。
        """

        engine = self.engine_combo.currentText()
        engine_lower = engine.lower()
        self.voice_combo.clear()

        is_winrt = engine_lower in ("winrt", "onecore")
        is_voicevox = engine_lower == "voicevox"
        is_qwen = engine_lower in ("qwen3", "qwen")
        is_irodori = engine_lower in ("irodori", "irodori-tts")
        is_openai = engine_lower == "openai"
        is_elevenlabs = engine_lower in ("elevenlabs", "eleven")
        is_aqt = engine_lower in ("aquestalkplayer", "atp")
        is_gemini = engine_lower == "gemini"

        # Pitchは現在 VoiceVox / AquesTalkPlayer のみ。
        self.pitch_spin.setEnabled(is_voicevox or is_aqt)
        # Speedは pyttsx3 / WinRT / ElevenLabsを含め、全エンジンで表示する。
        self.speed_spin.setEnabled(True)
        self.instruction_line.setEnabled(is_openai or is_gemini)

        if hasattr(self, "qwen_frame"):
            self.qwen_frame.setEnabled(is_qwen)
        if hasattr(self, "irodori_frame"):
            self.irodori_frame.setEnabled(is_irodori)

        # Qwen/Irodori は speed/pitch の直接制御を行わない
        if is_qwen or is_irodori:
            self.speed_spin.setEnabled(False)
            self.pitch_spin.setEnabled(False)

        if hasattr(self, "voicevox_endpoint_line"):
            self.voicevox_endpoint_line.setEnabled(is_voicevox)
            self.aquestalk_path_line.setEnabled(is_aqt)

        endpoint = None
        if is_voicevox and hasattr(self, "voicevox_endpoint_line"):
            endpoint = self.voicevox_endpoint_line.text().strip()

        api_key = os.getenv("ELEVENLABS_API_KEY") if is_elevenlabs else None

        try:
            voices = tktts.get_available_voices(
                engine_lower,
                endpoint=endpoint,
                api_key=api_key,
            )

            if voices:
                self.voice_combo.addItems([str(v) for v in voices])

                default_voice = None
                if engine_lower == "pyttsx3":
                    default_voice = getattr(tktts, "default_pyttsx3_voice", None)
                elif is_winrt:
                    default_voice = getattr(tktts, "default_winrt_voice", None)
                elif is_openai:
                    default_voice = getattr(
                        tktts,
                        "default_openai_voice",
                        getattr(tktts, "default_optnai_voice", None),
                    )
                elif is_elevenlabs:
                    default_voice = getattr(tktts, "default_elevenlabs_voice", None)
                elif is_voicevox:
                    default_voice = getattr(tktts, "default_voicevox_voice", None)
                elif is_qwen:
                    default_voice = getattr(tktts, "default_qwen3_voice", "Ono_Anna")
                elif is_irodori:
                    default_voice = getattr(tktts, "default_irodori_voice", "default")
                elif is_aqt:
                    default_voice = getattr(tktts, "default_aqt_preset", None)
                elif is_gemini:
                    default_voice = getattr(tktts, "default_gemini_voice", "Kore")

                if default_voice and default_voice in voices:
                    self.voice_combo.setCurrentText(default_voice)
                else:
                    self.voice_combo.setCurrentIndex(0)

                self.status_label.setText(
                    f"Status: {engine} voices loaded ({len(voices)})."
                )
            else:
                if is_elevenlabs and not api_key:
                    self.voice_combo.addItem("ELEVENLABS_API_KEY is not set")
                else:
                    self.voice_combo.addItem(f"No voices found for {engine}")

        except Exception as e:
            error_msg = f"Error loading voices for {engine}: {e}"
            self.voice_combo.addItem(error_msg)
            print(error_msg)
            traceback.print_exc()

        # エンジン変更または音声一覧の再取得は、必ず再生成対象にする。
        self.set_dirty()

    def update_playback_buttons(self, state: QMediaPlayer.PlaybackState, is_generating: Optional[bool] = None):
        """メディアプレイヤーの再生状態とワーカーの実行状態に基づいて、再生コントロールボタンの表示と有効/無効状態を更新する。

        :param state: メディアプレイヤーの現在の再生状態。
        :type state: QMediaPlayer.PlaybackState
        :param is_generating: ワーカーが音声生成中かどうかを明示的に指定する場合に使うフラグ。指定しない場合は `self.worker_thread` の状態を見る。
        :type is_generating: Optional[bool]
        """
        if is_generating is None:
            is_worker_running = self.worker_thread and self.worker_thread.isRunning()
        else:
            is_worker_running = is_generating

        self.convert_btn.setEnabled(not is_worker_running)
        self.generate_btn.setEnabled(not is_worker_running)
        
        if is_worker_running:
            self.play_btn.setText("▶ Generating...")
            self.play_btn.setEnabled(False)
            self.pause_btn.setEnabled(False)
            self.stop_btn.setEnabled(False)
            return

        if state == QMediaPlayer.PlaybackState.PlayingState:
            self.play_btn.setText("▶ Playing...")
            self.play_btn.setEnabled(False)
            self.pause_btn.setEnabled(True)
            self.stop_btn.setEnabled(True)
        elif state == QMediaPlayer.PlaybackState.PausedState:
            self.play_btn.setText("▶ Resume")
            self.play_btn.setEnabled(True)
            self.pause_btn.setEnabled(False)
            self.stop_btn.setEnabled(True)
        elif state == QMediaPlayer.PlaybackState.StoppedState:
            if self.is_converted_text_dirty:
                 self.play_btn.setText("▶ Generate (Text changed)")
            elif self.current_audio_path:
                 self.play_btn.setText("▶ Play (Existing)")
            else:
                 self.play_btn.setText("▶ Play/Generate")

            self.play_btn.setEnabled(True)
            self.pause_btn.setEnabled(False)
            self.stop_btn.setEnabled(False)
            if not is_worker_running and "Status: エラー" not in self.status_label.text():
                 if self.current_audio_path and not self.is_converted_text_dirty:
                      self.status_label.setText("Status: Generated (Ready to play)")
                 else:
                      self.status_label.setText("Status: Ready")

    def handle_pause(self):
        """「Pause」ボタンがクリックされたときに、メディアプレイヤーを一時停止するスロット。
        """
        if self.player.playbackState() == QMediaPlayer.PlaybackState.PlayingState:
            self.player.pause()

    def handle_stop(self):
        """「Stop」ボタンがクリックされたときに、メディアプレイヤーを停止し、再生位置をリセットするスロット。
        """
        self.player.stop()
        self.position_slider.setValue(0)
        self.status_label.setText("Status: 停止")
        self.update_playback_buttons(QMediaPlayer.PlaybackState.StoppedState)

    def handle_media_player_error(self, error: QMediaPlayer.Error, error_string: str):
        """メディアプレイヤーでエラーが発生したときに呼び出されるスロット。

        エラーメッセージをコンソールに出力し、ステータスラベルを更新します。

        :param error: 発生したエラーコード。
        :type error: QMediaPlayer.Error
        :param error_string: エラーの詳細メッセージ。
        :type error_string: str
        """
        if error != QMediaPlayer.Error.NoError:
             print(f"--- QMediaPlayer Error ---")
             print(f"Code: {error.name}, Message: {error_string}")
             print(f"--------------------------")
             self.status_label.setText(f"Status: 再生エラー発生 ({error_string})")
             self.player.stop()
             self.update_playback_buttons(QMediaPlayer.PlaybackState.StoppedState)

    def set_slider_range(self, duration: int):
        """メディアの総再生時間に基づいて再生位置スライダーの範囲を設定するスロット。

        :param duration: メディアの総再生時間（ミリ秒）。
        :type duration: int
        """
        self.position_slider.setRange(0, duration)

    def update_slider(self, position: int):
        """メディアの再生位置が変更されたときに、再生位置スライダーとステータスラベルを更新するスロット。

        :param position: メディアの現在の再生位置（ミリ秒）。
        :type position: int
        """
        self.position_slider.setValue(position)
        total_duration = self.player.duration()
        if total_duration > 0:
            current_sec = position // 1000
            total_sec = total_duration // 1000
            self.status_label.setText(f"Status: Playing ({current_sec} sec / {total_sec} sec)")

    def seek_position(self, position: int):
        """再生位置スライダーが操作されたときに、メディアの再生位置を変更するスロット。

        :param position: 設定する再生位置（ミリ秒）。
        :type position: int
        """
        self.player.setPosition(position)


if __name__ == '__main__':
    app = QApplication(sys.argv)
    ex = MyTTSApp()
    ex.show()
    sys.exit(app.exec())