"""
PowerPointノートから音声ファイルを生成し、スライドに自動再生リンクとして埋め込むスクリプト。

Windows環境でPowerPoint COMオブジェクトを利用し、複数のTTSエンジン（pyttsx3, VOICEVOX, Qwen3-TTS,
Irodori-TTS, AquesTalkPlayer, OpenAI, Gemini）に対応しています。
独話モードと対話モードをサポートし、ノート内の指定されたテキストを音声化してプレゼンテーションに統合します。

:doc:`add_notes_voice_pptx2_usage`
"""
import os
import sys
import re
import shutil
import argparse
import time
import traceback

try:
    from tktts import tkTTS
    import win32com.client
except ImportError as e:
    print(f"Error: tktts/win32com.client のインポートエラー: {e}")
    print("tktts.py が同一ディレクトリに存在し、win32com.client がインストールされていることを確認してください。")
    sys.exit(1)


DEFAULT_ENGINE = "pyttsx3"
DEFAULT_VOICEVOX_ENDPOINT = "http://127.0.0.1:50021"
DEFAULT_TEMP_DIR = "tts_temp_wavs_pptx" # 一時ファイルを格納するディレクトリ
DEFAULT_SPEAK_RATE = 150 # pyttsx3の読み上げ速度 (WPM)

VOICE_MAPS = {
    "pyttsx3": {"Zira": "Zira", "David": "David", "四国めたん": "Zira", "ずんだもん": "David", "れいむ": "Zira", "まりさ": "David"},
    "qwen3": {"四国めたん": "Ono_Anna", "ずんだもん": "Ono_Anna", "れいむ": "Ono_Anna", "まりさ": "Ono_Anna"},
    "qwen": {"四国めたん": "Ono_Anna", "ずんだもん": "Ono_Anna", "れいむ": "Ono_Anna", "まりさ": "Ono_Anna"},
    "irodori": {"四国めたん": "default", "ずんだもん": "default", "れいむ": "default", "まりさ": "default"},
    "irodori-tts": {"四国めたん": "default", "ずんだもん": "default", "れいむ": "default", "まりさ": "default"},
    "aquestalkplayer": {"四国めたん": "れいむ", "ずんだもん": "まりさ", "れいむ": "れいむ", "まりさ": "まりさ"},
    "openai": {"四国めたん": "nova", "ずんだもん": "shimmer", "れいむ": "alloy", "まりさ": "fable"},
    "gemini": {"四国めたん": "Kore", "ずんだもん": "Puck", "れいむ": "Aoede", "まりさ": "Charon"},
}


def terminate(pause = True):
    """プログラムを終了します。

    :param pause: Trueの場合、終了前にユーザー入力を待機するかどうか。
    :type pause: bool
    """
    if pause: input("\nPress ENTER to terminate>>\n")
    exit()

def initialize():
    """コマンドライン引数を解析し、設定オブジェクトを返します。

    `argparse` を使用して、TTSエンジン、入力/出力パス、音声ディレクトリ、読み上げ速度、
    各種TTS固有オプション（VOICEVOX、AquesTalkPlayer、OpenAI、Qwen3-TTS、Irodori-TTSなど）
    を設定します。

    :returns: 解析されたコマンドライン引数を保持する名前空間オブジェクト。
    :rtype: argparse.Namespace
    """
    # speak.py のオプションを可能な限り移植
    parser = argparse.ArgumentParser(
        description="PPTXノートから音声ファイルを生成し、自動再生リンクを設定するプログラム (Windows/PowerPoint, tktts使用)",
        formatter_class=argparse.RawTextHelpFormatter
    )

    parser.add_argument("--mode", choices=['list', 'conv'], help="実行モード:\n list: 利用可能な音声名を表示\n conv: PPTXノートから音声を生成しPPTXにリンク", default='conv')
    parser.add_argument(
        "--monologue", type=int, choices=(0, 1), default=1,
        help="1: 独話形式、0: '話者名,本文' の対話形式 (default: 1)",
    )

    parser.add_argument("--tts", choices=["pyttsx3", "voicevox", "qwen3", "qwen", "irodori", "irodori-tts", "aquestalkplayer", "atp", "openai", "gemini"], default=DEFAULT_ENGINE, help="TTSエンジンを選択")
    
    parser.add_argument("--input_path",    "-i", type=str, help="[convモード] ノート設定済みPPTXファイルパス")
    parser.add_argument("--audio_dir",     "-a", type=str, default="audio_output", help="[convモード] 生成された音声ファイルを保存するディレクトリ")
    parser.add_argument("--output_path",   "-o", type=str, help="[convモード] 音声リンクが追加された出力PPTXファイルパス")
    
    parser.add_argument("--voices", "-v", type=str, default="", help="[tktts] voice_map の上書き (話者名=ボイス;話者名=ボイス)")
    parser.add_argument("--temp_dir", type=str, default=DEFAULT_TEMP_DIR, help="一時ファイルを作成するディレクトリ名")
    
    # pyttsx3/AQT/OpenAI 共通 (speak.py準拠)
    parser.add_argument("--speak_rate", type=int, default=DEFAULT_SPEAK_RATE, help="[pyttsx3] 読み上げ速度 (WPM) / [AQT] 速度比")
    parser.add_argument("--tinterval", type=float, default=0.5, help="[AQT/OpenAI/VOICEVOX] 音声ファイル間に挿入する無音区間の長さ（秒）")
    
    # VOICEVOX 固有オプション (speak.py準拠)
    parser.add_argument("--endpoint", type=str, default=DEFAULT_VOICEVOX_ENDPOINT, help="[VOICEVOX] Engineのendpoint")
    parser.add_argument("--fspeak_rate", type=float, default=1.0, help="[VOICEVOX] 読み上げ速度比 (標準: 1.0)")
    parser.add_argument("--fspeak_pitch", type=float, default=0.0, help="[VOICEVOX] 声の高さ比 (標準: 0.0)")

    # AquesTalkPlayer 固有オプション (speak.py準拠)
    parser.add_argument("--aquestalk_path", type=str, default="AquesTalkPlayer.exe", help="[AQT] AquesTalkPlayer.exe の実行パス")
    
    # OpenAI 固有オプション (speak.py準拠)
    parser.add_argument("--instruction", type=str, default="", help="[OpenAI/Gemini] TTS APIへの追加指示")
    # Qwen3-TTS
    parser.add_argument("--qwen3_language", type=str, default="Japanese", help="[Qwen3-TTS] language")
    parser.add_argument("--qwen3_model_id", type=str, default="Qwen/Qwen3-TTS-12Hz-0.6B-CustomVoice", help="[Qwen3-TTS] model ID")
    parser.add_argument("--qwen3_device", type=str, default="auto", help="[Qwen3-TTS] auto/cuda:0/cpu")
    parser.add_argument("--qwen3_dtype", type=str, default="auto",
                        choices=["auto", "bfloat16", "bf16", "float16", "fp16", "float32", "fp32"],
                        help="[Qwen3-TTS] dtype")
    parser.add_argument("--qwen3_instruct", type=str, default="", help="[Qwen3-TTS] CustomVoice instruction")

    # Irodori-TTS
    parser.add_argument("--irodori_caption", type=str,
                        default="落ち着いた自然な声で、明瞭に読み上げる。",
                        help="[Irodori-TTS] voice/style caption")
    parser.add_argument("--irodori_ref_wav", type=str, default="", help="[Irodori-TTS] reference WAV")
    parser.add_argument("--irodori_ref_wavs", type=str, default="", help="[Irodori-TTS] reference WAVs (;区切り)")
    parser.add_argument("--irodori_model_id", type=str, default="Aratako/Irodori-TTS-v4.1-Small",
                        help="[Irodori-TTS] model ID")
    parser.add_argument("--irodori_device", type=str, default="auto", help="[Irodori-TTS] auto/cuda/cuda:0/cpu")
    parser.add_argument("--irodori_precision", type=str, default="auto",
                        choices=["auto", "bf16", "bfloat16", "fp32", "float32"],
                        help="[Irodori-TTS] precision")
    parser.add_argument("--irodori_codec_device", type=str, default=None, help="[Irodori-TTS] codec device")
    parser.add_argument("--irodori_codec_precision", type=str, default=None, help="[Irodori-TTS] codec precision")
    parser.add_argument("--irodori_num_steps", type=int, default=40, help="[Irodori-TTS] sampling steps")
    parser.add_argument("--irodori_cfg_scale_text", type=float, default=3.5)
    parser.add_argument("--irodori_cfg_scale_caption", type=float, default=3.0)
    parser.add_argument("--irodori_cfg_scale_speaker", type=float, default=5.0)
    parser.add_argument("--irodori_duration_scale", type=float, default=1.0)
    parser.add_argument("--irodori_seed", type=str, default="0", help="[Irodori-TTS] seed; random/noneも可")
    parser.add_argument("--irodori_lora_adapter", type=str, default="", help="[Irodori-TTS] LoRA adapter")

    parser.add_argument("--pause", type=int, default=1, help="プログラム終了時にENTER入力を要求するか [0|1]")


    args = parser.parse_args()

    # 独話時の既定voice。tktts.update_voice_map()でvoice=Noneになるのを避ける。
    if not args.voices:
        if args.tts.lower() in ("qwen3", "qwen"):
            args.voices = "Ono_Anna"
        elif args.tts.lower() in ("irodori", "irodori-tts"):
            args.voices = "default"
        elif args.tts.lower() == "gemini":
            args.voices = "Kore"

    # バックエンドでは空文字よりNoneが適切な項目
    if args.qwen3_instruct == "":
        args.qwen3_instruct = None
    if args.irodori_ref_wav == "":
        args.irodori_ref_wav = None
    if args.irodori_ref_wavs == "":
        args.irodori_ref_wavs = None
    if args.irodori_lora_adapter == "":
        args.irodori_lora_adapter = None

    return args


def extract_narration_text(notes_text):
    """PowerPointのノートから、ナレーションとして使用するテキストを抽出します。

    ノートテキスト内で ``((テキスト))`` の形式で囲まれた部分を抽出します。
    この形式が見つからない場合は、ノート全体をナレーションテキストと見なします。
    これは、互換性維持のためのフォールバック動作です。

    :param notes_text: スライドのノートテキスト。
    :type notes_text: str
    :returns: 抽出されたナレーションテキストと、デリミタ ``(( ))`` が見つかったかどうかの真偽値のタプル。
    :rtype: tuple[str, bool]
    """
    notes_text = notes_text.strip()
    match = re.search(r"\(\(\s*(.*?)\s*\)\)", notes_text, re.DOTALL)
    if match:
        return match.group(1).strip(), True
    return notes_text, False


def parse_dialogue_lines(narration_text):
    """ナレーションテキストを解析し、話者ごとの対話リストに変換します。

    ``「話者名,本文」`` の形式の行を解析します。カンマがない継続行は、直前の話者の発言に連結されます。
    話者名がない行は、既定の音声で読み上げられるものとして処理されます。

    :param narration_text: ``(( ))`` 内から抽出された、対話形式で記述されたテキスト。
    :type narration_text: str
    :returns: ``(話者名, テキスト)`` のタプルのリスト。話者名がない場合は ``None``。
    :rtype: list[tuple[str | None, str]]
    """
    dialogue = []
    for line_no, line in enumerate(narration_text.splitlines(), 1):
        line = line.strip()
        if not line or line.startswith("#"):
            continue
        if "," in line:
            speaker, text = (part.strip() for part in line.split(",", 1))
            if speaker:
                dialogue.append((speaker, text))
                continue
        if dialogue:
            speaker, text = dialogue[-1]
            dialogue[-1] = (speaker, text + "\n" + line)
        else:
            print(f"Warning: 対話の{line_no}行目に先行話者がありません。既定音声で読み上げます。")
            dialogue.append((None, line))
    return [(speaker, text) for speaker, text in dialogue if text]

def collect_speakers(dialogue_map):
    """全ての対話データから、登場する話者名を初出順に重複なく収集します。

    話者名の比較には `casefold()` を使用し、大文字小文字の違いを無視します。
    返されるリストは、各話者の初出時の表記を保持します。

    :param dialogue_map: スライドインデックスをキーとし、値に対話のリストを持つ辞書。
    :type dialogue_map: dict[int, list[tuple[str | None, str]]]
    :returns: 初出順に並べられた、重複のない話者名のリスト。
    :rtype: list[str]
    """
    seen = {}
    order = []
    for idx in sorted(dialogue_map.keys()):
        for speaker, _text in dialogue_map[idx]:
            if not speaker:
                continue
            key = speaker.casefold()
            if key not in seen:
                seen[key] = speaker
                order.append(key)
    return [seen[key] for key in order]


def build_dialogue_voice_map(tktts_obj, tts_engine, voices_override, speakers):
    """対話モードで使用する話者と音声名のマッピング (`voice_map`) を構築します。

    `VOICE_MAPS`、`--voices` 引数による上書き、およびTTSエンジンのデフォルト音声設定に
    基づいて、各話者に最適な音声名を割り当てます。

    :param tktts_obj: `tkTTS` オブジェクトのインスタンス。
    :type tktts_obj: tkTTS
    :param tts_engine: 使用するTTSエンジンの名前（小文字）。
    :type tts_engine: str
    :param voices_override: コマンドライン引数 `--voices` で指定された、話者と音声のマッピング文字列。
    :type voices_override: str
    :param speakers: 検出された話者名のリスト。
    :type speakers: list[str]
    :returns: 各話者名に対応する音声名を格納した辞書。
    :rtype: dict[str | int | None, str]
    """
    engine_voice_map = VOICE_MAPS.get(tts_engine, {})
    casefold_lookup = {k.casefold(): v for k, v in engine_voice_map.items()}
    default_voice = tktts_obj.get_default_voice(tts_engine)

    explicit_map = {}
    positional_values = []
    if voices_override:
        for item in voices_override.split(";"):
            item = item.strip()
            if not item:
                continue
            if "=" in item:
                k, v = item.split("=", 1)
                explicit_map[k.strip().casefold()] = v.strip()
            else:
                positional_values.append(item)

    current_voice_map = {}
    for idx, speaker in enumerate(speakers):
        key = speaker.casefold()
        if key in explicit_map:
            voice = explicit_map[key]
        elif idx < len(positional_values):
            voice = positional_values[idx]
        elif key in casefold_lookup:
            voice = casefold_lookup[key]
        else:
            voice = default_voice
        current_voice_map[speaker] = voice
        current_voice_map[idx] = voice

    fallback_voice = current_voice_map.get(0, default_voice)
    current_voice_map[None] = fallback_voice
    current_voice_map[""] = fallback_voice

    return current_voice_map


def get_notes_text(slide):
    """指定されたPowerPointスライドからノートのテキストを取得します。

    スライドの `NotesPage` 内の図形を検索し、テキストフレームを持つプレースホルダー型2の
    図形からテキストを抽出します。

    :param slide: PowerPointのCOMスライドオブジェクト。
    :type slide: win32com.client.CDispatch
    :returns: 抽出されたノートテキスト。ノートがない場合は空文字列。
    :rtype: str
    """
    for shp in slide.NotesPage.Shapes:
        if shp.Type == 14 and shp.PlaceholderFormat.Type == 2:
            if shp.HasTextFrame and shp.TextFrame.HasText:
                return shp.TextFrame.TextRange.Text.strip()
    return ""


def close_powerpoint(pres, ppt):
    """PowerPointのプレゼンテーションとアプリケーションを安全に閉じます。

    開いているプレゼンテーション (`pres`) とPowerPointアプリケーション (`ppt`) を閉じます。
    エラーが発生した場合は警告を出力しますが、プログラムの続行を妨げません。

    :param pres: PowerPointのプレゼンテーションオブジェクト。
    :type pres: win32com.client.CDispatch or None
    :param ppt: PowerPointのアプリケーションオブジェクト。
    :type ppt: win32com.client.CDispatch or None
    """
    if pres is not None:
        try:
            pres.Close()
        except Exception as e:
            print(f"Warning: PowerPointのCloseに失敗: {e}")
    if ppt is not None:
        try:
            ppt.Quit()
        except Exception as e:
            print(f"Warning: PowerPointのQuitに失敗: {e}")

def link_audio_autoplay(source_pptx_path, audio_tasks, output_pptx):
    """生成された音声ファイルをPowerPointスライドに自動再生リンクとして埋め込みます。

    元のPPTXファイルをコピーし、新しいPPTXファイルに音声ファイルをリンクします。
    各音声はスライドの右下隅にアイコンとして配置され、スライド表示時に自動再生されるように設定されます。
    本プログラムが追加した既存の音声オブジェクトは置き換えられます。

    :param source_pptx_path: リンク元となるPPTXファイルのパス。
    :type source_pptx_path: str
    :param audio_tasks: ``(スライドインデックス, 音声ファイルパス)`` のタプルのリスト。
    :type audio_tasks: list[tuple[int, str]]
    :param output_pptx: 音声リンクが追加される出力PPTXファイルのパス。
    :type output_pptx: str
    :returns: リンク処理の結果を示す辞書。
              `"linked"` (成功したスライドインデックスのリスト) と
              `"saved"` (保存が成功したか) を含む。
    :rtype: dict[str, Any]
    """
    print("\n--- PPTXに音声ファイルをリンク中 ---")
    ppt = pres = None
    linked = []
    saved = False
    try:
        same_file = os.path.samefile(source_pptx_path, output_pptx) if os.path.exists(output_pptx) else False
        if not same_file:
            shutil.copyfile(source_pptx_path, output_pptx)
        ppt = win32com.client.Dispatch("PowerPoint.Application")
        pres = ppt.Presentations.Open(os.path.abspath(output_pptx))
        for idx, wav_path in audio_tasks:
            shape = None
            try:
                wav_path = os.path.abspath(wav_path)
                if not os.path.isfile(wav_path) or os.path.getsize(wav_path) == 0:
                    print(f"Warning: スライド {idx}: 音声が存在しないか空です: {wav_path}")
                    continue
                slide = pres.Slides(idx)
                # 本プログラムの識別名を持つ音声だけを置き換える。
                name = f"tktts_narration_slide_{idx}"
                old_shapes = [shp for shp in slide.Shapes if shp.Name == name]
                shape = slide.Shapes.AddMediaObject2(
                    wav_path, LinkToFile=True, SaveWithDocument=False,
                    Left=pres.PageSetup.SlideWidth - 50,
                    Top=pres.PageSetup.SlideHeight - 50, Width=40, Height=40)
                # 再生設定をMainSequenceに統一。先頭・直前と同時で表示時に再生。
                slide.TimeLine.MainSequence.AddEffect(shape, 83, 0, 2, 1)
                for old in old_shapes:
                    old.Delete()
                shape.Name = name
                linked.append(idx)
                print(f"  リンク追加: スライド {idx}: {wav_path}")
            except Exception as e:
                print(f"Warning: スライド {idx} の音声リンクに失敗: {e}")
                if shape is not None:
                    try:
                        shape.Delete()
                    except Exception as cleanup_error:
                        print(f"Warning: 追加途中の音声の削除に失敗: {cleanup_error}")
        pres.Save()
        saved = True
    except Exception as e:
        print(f"Warning: PPTXのコピー・オープン・保存に失敗: {e}")
    finally:
        close_powerpoint(pres, ppt)
    print(f"リンク結果: 成功 {len(linked)}/{len(audio_tasks)}件、保存 {'成功' if saved else '失敗'}")
    return {"linked": linked, "saved": saved}

def extract_dialogue_from_pptx(pptx_path, monologue):
    """PowerPointファイルからノートテキストを抽出し、対話形式または独話形式に解析します。

    各スライドのノートテキストを取得し、``((...))`` デリミタで囲まれた部分を抽出します。
    `monologue` が `False` の場合、抽出されたテキストを話者ごとの対話に分割します。
    発音可能な文字を含まない行はスキップされます。

    :param pptx_path: 入力PPTXファイルのパス。
    :type pptx_path: str
    :param monologue: 独話モード (`True`) か対話モード (`False`) か。
    :type monologue: bool
    :returns: スライドインデックスをキーとし、値に ``(話者名, テキスト)`` のリストを持つ辞書。
    :rtype: dict[int, list[tuple[str | None, str]]]
    """
    ppt = pres = None
    dialogue_map = {}
    canonical_speakers = {}
    try:
        ppt = win32com.client.Dispatch("PowerPoint.Application")
        pres = ppt.Presentations.Open(os.path.abspath(pptx_path))
        for idx in range(1, pres.Slides.Count + 1):
            try:
                notes = get_notes_text(pres.Slides(idx))
                if not notes:
                    continue

                narration, found_delimiter = extract_narration_text(notes)
                if not narration:
                    continue
                
                # --- 追加: 行単位で発音できる文字がない行を除外する ---
                valid_lines = []
                for line in narration.splitlines():
                    # 行内に英数字・日本語などの文字が含まれているか判定
                    if any(c.isalnum() for c in line):
                        valid_lines.append(line)
                
                # 有効な行だけを結合して再構築
                narration = "\n".join(valid_lines).strip()
                
                # すべての行が除外されて空になった場合はスライド全体をスキップ
                if not narration:
                    print(f"  情報: スライド {idx} は発音できるテキストがないためスキップします。")
                    continue
                # --------------------------------------------------------
                dialogue = None

                if not monologue and found_delimiter:
                    print(f"  対話解析: スライド {idx}")
                    dialogue = parse_dialogue_lines(narration)
                if not dialogue:
                    dialogue = [(None, narration)]
                normalized = []
                for speaker, text in dialogue:
                    if speaker is not None:
                        speaker = canonical_speakers.setdefault(speaker.casefold(), speaker)
                    normalized.append((speaker, text))
                dialogue_map[idx] = normalized
            except Exception as e:
                print(f"Warning: スライド {idx} のノート抽出に失敗: {e}")
    except Exception as e:
        print(f"Warning: PowerPointからのノート抽出に失敗: {e}")
    finally:
        close_powerpoint(pres, ppt)
    return dialogue_map

def generate_audio_files_tktts(dialogue_map, voice_map, args, tktts):
    """`tkTTS` オブジェクトを使用して、解析された対話データから音声ファイルを生成します。

    `dialogue_map` 内の各スライドと、それに対応する `voice_map` を使用して、
    指定されたTTSエンジンで音声ファイルを生成します。
    生成されたファイルは `args.audio_dir` に保存されます。
    生成中の一時ディレクトリは処理後に削除されます。

    :param dialogue_map: スライドインデックスをキーとし、値に対話のリストを持つ辞書。
    :type dialogue_map: dict[int, list[tuple[str | None, str]]]
    :param voice_map: 話者名に対応する音声名を格納した辞書。
    :type voice_map: dict[str | int | None, str]
    :param args: コマンドライン引数を保持する名前空間オブジェクト。
    :type args: argparse.Namespace
    :param tktts: `tkTTS` オブジェクトのインスタンス。
    :type tktts: tkTTS
    :returns: ``(スライドインデックス, 生成された音声ファイルパス)`` のタプルのリスト。
    :rtype: list[tuple[int, str]]
    """

    tts_engine = args.tts.lower()
    output_dir = args.audio_dir
    os.makedirs(output_dir, exist_ok=True)

    replacements = {}

    print(f"\n--- 🗣️ スライドごとに {tts_engine.upper()} で音声ファイルを生成中 ---")

    ext = "wav"
#    ext = "mp3"
    sorted_slide_indices = sorted(dialogue_map.keys())
    audio_tasks = []
    total_t0 = time.perf_counter()
    for slide_idx in sorted_slide_indices:
        print()
        print(f"スライド #{slide_idx} の音声ファイルを生成しています...")
        slide_t0 = time.perf_counter()

        # スライドごとの読み上げテキスト。独話なら [(None, text)]、
        # 対話なら [(speaker1, text1), (speaker2, text2), ...]。
        single_dialogue = dialogue_map[slide_idx]
        slide_outfile = os.path.join(output_dir, f"slide{slide_idx}.{ext}")
        
        try:
            args.outfile = slide_outfile
            generated_file = tktts.speak_dialogue(
                config = args, dialogue = single_dialogue, 
                voice_map = voice_map, replacements = replacements,
                output_format = ext,
                )

            if generated_file:
                if os.path.isfile(generated_file) and os.path.getsize(generated_file) > 0:
                     audio_tasks.append((slide_idx, os.path.abspath(generated_file)))
                     print(f"  ✅ 生成完了: {os.path.basename(generated_file)} (スライド {slide_idx})")
                else:
                    # エラー処理
                    print(f"  Warning: ファイルが見つかりません: スライド {slide_idx} での生成失敗")
                    
            else:
                print(f"  Warning: 生成失敗: スライド {slide_idx}")
                
        except Exception as e:
            print(f"  Warning: TTSモジュール呼び出しエラー: スライド {slide_idx} - {e}")
            traceback.print_exc()
        finally:
            slide_elapsed = time.perf_counter() - slide_t0
            print(f"  ⏱️ 変換時間: {slide_elapsed:.1f} 秒")

    total_elapsed = time.perf_counter() - total_t0
    print(f"\n⏱️ TTS総変換時間: {total_elapsed:.1f} 秒")

    # 4. 一時ファイルのクリーンアップ (tktts 内部で削除されない場合を考慮)
    if os.path.exists(args.temp_dir):
        try:
            shutil.rmtree(args.temp_dir)
            print(f"\n🗑️ 一時ディレクトリ {args.temp_dir} を削除しました。")
        except Exception as e:
            print(f"Warning: 一時ディレクトリの削除に失敗: {e}")


    print(f"音声生成結果: 成功 {len(audio_tasks)}/{len(dialogue_map)}件")
    if len(audio_tasks) != len(dialogue_map):
        print("Warning: 一部のスライドの音声生成に失敗しました。成功分の処理を続けます。")
    return audio_tasks


def main():
    """プログラムの主要な実行ロジック。

    コマンドライン引数の初期化、TTSエンジンのリスト表示、
    または音声生成とPowerPointへのリンク処理を行います。
    入力/出力ファイルの検証、ディレクトリの準備、PowerPointからのノート抽出、
    音声ファイルの生成、そしてPowerPointスライドへの音声リンクの埋め込みという
    一連のプロセスを管理します。
    """
    args = initialize()

    # TTS固有設定はargsでtkTTSへ渡す。
    if args.mode == 'list':
        tktts = tkTTS(tts_name=args.tts, config=args)
        if args.tts.lower() == "voicevox":
            tktts.list_available_voices(args.tts, endpoint=args.endpoint)
        else:
            tktts.list_available_voices(args.tts)
        terminate(args.pause)

    source_pptx = args.input_path
    output_pptx = args.output_path

    if not all([source_pptx, output_pptx]):
        print("\nエラー: convモードでは --input_path と --output_path が必要です。")
        print(f"現在の設定: input={source_pptx}, output={output_pptx}")
        terminate(args.pause)
    if not os.path.isfile(source_pptx):
        print(f"\nエラー: 入力PPTXファイルが見つかりません: {source_pptx}")
        terminate(args.pause)

    source_pptx = os.path.abspath(source_pptx)
    output_pptx = os.path.abspath(output_pptx)
    try:
        os.makedirs(os.path.dirname(output_pptx), exist_ok=True)
        os.makedirs(args.audio_dir, exist_ok=True)
        if os.path.isdir(output_pptx):
            raise ValueError("出力PPTXにフォルダーが指定されています")
    except Exception as e:
        print(f"Warning: 出力先を準備できません: {e}")
        return
    tktts = tkTTS(tts_name=args.tts, config=args)

    print("--- 💻 PowerPointナレーション作成プログラム (CONVモード) 開始 ---")
    print(f"  TTS engine  : {args.tts}")
    print(f"  is monologue: {args.monologue}")
    print(f"  入力PPTX    : {source_pptx}")
    print(f"  出力PPTX    : {output_pptx}")
    print(f"  音声フォルダ: {args.audio_dir}")

    if args.tts.lower() in ("qwen3", "qwen"):
        print(f"  Qwen model   : {args.qwen3_model_id}")
        print(f"  Qwen device  : {args.qwen3_device}")
        print(f"  Qwen dtype   : {args.qwen3_dtype}")
        print(f"  Qwen language: {args.qwen3_language}")
        print(f"  Qwen voice   : {args.voices}")

    if args.tts.lower() in ("irodori", "irodori-tts"):
        print(f"  Irodori model    : {args.irodori_model_id}")
        print(f"  Irodori device   : {args.irodori_device}")
        print(f"  Irodori precision: {args.irodori_precision}")
        print(f"  Irodori steps    : {args.irodori_num_steps}")
        print(f"  Irodori caption  : {args.irodori_caption}")
        print(f"  Irodori ref WAV  : {args.irodori_ref_wav}")

    print("-" * 50)

    # 入力PPTXのノートを取得する(対話モードなら話者ごとに分割済み)。
    print()
    print("--- 📝 PPTXからノートを取得中 ---")
    dialogue_map = extract_dialogue_from_pptx(source_pptx, monologue=bool(args.monologue))
    if not dialogue_map:
        print("  ⚠️ ノートが記載されたスライドが見つかりませんでした。")
        terminate(args.pause)
    print(f"  ✅ ノートを検出: {len(dialogue_map)} スライド")

    # 対話モードでは、ノートから検出した話者(3名以上も可)を元にvoice_mapを構築する。
    if args.monologue:
        speakers = [None]
        current_voice_map = tktts.update_voice_map(
            voice_map=VOICE_MAPS,
            voices=args.voices,
            speakers=speakers,
        )
    else:
        speakers = collect_speakers(dialogue_map)
        if not speakers:
            print("  ⚠️ 対話モード(--monologue 0)ですが、話者を検出できませんでした。")
            print("     (( )) の中を「話者名,本文」の形式(1行1発言)で記述してください。")
        current_voice_map = build_dialogue_voice_map(
            tktts, args.tts.lower(), args.voices, speakers,
        )

    print("  Voice mapping:")
    for speaker in speakers:
        label = "独話" if speaker is None else speaker
        print(f"    {label}: {current_voice_map.get(speaker)}")

    # ノートのテキストから音声ファイルを生成する。
    print()
    print("音声ファイルを生成します:")
    audio_tasks = generate_audio_files_tktts(dialogue_map, voice_map = current_voice_map,
                    args = args, tktts = tktts)

    # 音声ファイルをリンクして出力PPTXを保存する。
    if audio_tasks:
        print()
        print(f"音声ファイルを[{source_pptx}]にリンクします:")
        result = link_audio_autoplay(source_pptx, audio_tasks, output_pptx)
        if not result["saved"] or len(result["linked"]) != len(audio_tasks):
            print("Warning: PPTXへの音声リンクが完全には完了していません。上記の警告を確認してください。")
    else:
        print("\nWarning: 音声ファイルが生成されなかったため、リンク処理をスキップしました。")

    print("--- 🎉 プログラム終了 ---")

    terminate(args.pause)

if __name__ == "__main__":
    main()