add_notes_voice_pptx2.py ダウンロード/コピー
add_notes_voice_pptx2.py をダウンロード
add_notes_voice_pptx2.py
add_notes_voice_pptx2.py
1"""
2PowerPointノートから音声ファイルを生成し、スライドに自動再生リンクとして埋め込むスクリプト。
3
4Windows環境でPowerPoint COMオブジェクトを利用し、複数のTTSエンジン(pyttsx3, VOICEVOX, Qwen3-TTS,
5Irodori-TTS, AquesTalkPlayer, OpenAI, Gemini)に対応しています。
6独話モードと対話モードをサポートし、ノート内の指定されたテキストを音声化してプレゼンテーションに統合します。
7
8:doc:`add_notes_voice_pptx2_usage`
9"""
10import os
11import sys
12import re
13import shutil
14import argparse
15import time
16import traceback
17
18try:
19 from tktts import tkTTS
20 import win32com.client
21except ImportError as e:
22 print(f"Error: tktts/win32com.client のインポートエラー: {e}")
23 print("tktts.py が同一ディレクトリに存在し、win32com.client がインストールされていることを確認してください。")
24 sys.exit(1)
25
26
27DEFAULT_ENGINE = "pyttsx3"
28DEFAULT_VOICEVOX_ENDPOINT = "http://127.0.0.1:50021"
29DEFAULT_TEMP_DIR = "tts_temp_wavs_pptx" # 一時ファイルを格納するディレクトリ
30DEFAULT_SPEAK_RATE = 150 # pyttsx3の読み上げ速度 (WPM)
31
32VOICE_MAPS = {
33 "pyttsx3": {"Zira": "Zira", "David": "David", "四国めたん": "Zira", "ずんだもん": "David", "れいむ": "Zira", "まりさ": "David"},
34 "qwen3": {"四国めたん": "Ono_Anna", "ずんだもん": "Ono_Anna", "れいむ": "Ono_Anna", "まりさ": "Ono_Anna"},
35 "qwen": {"四国めたん": "Ono_Anna", "ずんだもん": "Ono_Anna", "れいむ": "Ono_Anna", "まりさ": "Ono_Anna"},
36 "irodori": {"四国めたん": "default", "ずんだもん": "default", "れいむ": "default", "まりさ": "default"},
37 "irodori-tts": {"四国めたん": "default", "ずんだもん": "default", "れいむ": "default", "まりさ": "default"},
38 "aquestalkplayer": {"四国めたん": "れいむ", "ずんだもん": "まりさ", "れいむ": "れいむ", "まりさ": "まりさ"},
39 "openai": {"四国めたん": "nova", "ずんだもん": "shimmer", "れいむ": "alloy", "まりさ": "fable"},
40 "gemini": {"四国めたん": "Kore", "ずんだもん": "Puck", "れいむ": "Aoede", "まりさ": "Charon"},
41}
42
43
44def terminate(pause = True):
45 """プログラムを終了します。
46
47 :param pause: Trueの場合、終了前にユーザー入力を待機するかどうか。
48 :type pause: bool
49 """
50 if pause: input("\nPress ENTER to terminate>>\n")
51 exit()
52
53def initialize():
54 """コマンドライン引数を解析し、設定オブジェクトを返します。
55
56 `argparse` を使用して、TTSエンジン、入力/出力パス、音声ディレクトリ、読み上げ速度、
57 各種TTS固有オプション(VOICEVOX、AquesTalkPlayer、OpenAI、Qwen3-TTS、Irodori-TTSなど)
58 を設定します。
59
60 :returns: 解析されたコマンドライン引数を保持する名前空間オブジェクト。
61 :rtype: argparse.Namespace
62 """
63 # speak.py のオプションを可能な限り移植
64 parser = argparse.ArgumentParser(
65 description="PPTXノートから音声ファイルを生成し、自動再生リンクを設定するプログラム (Windows/PowerPoint, tktts使用)",
66 formatter_class=argparse.RawTextHelpFormatter
67 )
68
69 parser.add_argument("--mode", choices=['list', 'conv'], help="実行モード:\n list: 利用可能な音声名を表示\n conv: PPTXノートから音声を生成しPPTXにリンク", default='conv')
70 parser.add_argument(
71 "--monologue", type=int, choices=(0, 1), default=1,
72 help="1: 独話形式、0: '話者名,本文' の対話形式 (default: 1)",
73 )
74
75 parser.add_argument("--tts", choices=["pyttsx3", "voicevox", "qwen3", "qwen", "irodori", "irodori-tts", "aquestalkplayer", "atp", "openai", "gemini"], default=DEFAULT_ENGINE, help="TTSエンジンを選択")
76
77 parser.add_argument("--input_path", "-i", type=str, help="[convモード] ノート設定済みPPTXファイルパス")
78 parser.add_argument("--audio_dir", "-a", type=str, default="audio_output", help="[convモード] 生成された音声ファイルを保存するディレクトリ")
79 parser.add_argument("--output_path", "-o", type=str, help="[convモード] 音声リンクが追加された出力PPTXファイルパス")
80
81 parser.add_argument("--voices", "-v", type=str, default="", help="[tktts] voice_map の上書き (話者名=ボイス;話者名=ボイス)")
82 parser.add_argument("--temp_dir", type=str, default=DEFAULT_TEMP_DIR, help="一時ファイルを作成するディレクトリ名")
83
84 # pyttsx3/AQT/OpenAI 共通 (speak.py準拠)
85 parser.add_argument("--speak_rate", type=int, default=DEFAULT_SPEAK_RATE, help="[pyttsx3] 読み上げ速度 (WPM) / [AQT] 速度比")
86 parser.add_argument("--tinterval", type=float, default=0.5, help="[AQT/OpenAI/VOICEVOX] 音声ファイル間に挿入する無音区間の長さ(秒)")
87
88 # VOICEVOX 固有オプション (speak.py準拠)
89 parser.add_argument("--endpoint", type=str, default=DEFAULT_VOICEVOX_ENDPOINT, help="[VOICEVOX] Engineのendpoint")
90 parser.add_argument("--fspeak_rate", type=float, default=1.0, help="[VOICEVOX] 読み上げ速度比 (標準: 1.0)")
91 parser.add_argument("--fspeak_pitch", type=float, default=0.0, help="[VOICEVOX] 声の高さ比 (標準: 0.0)")
92
93 # AquesTalkPlayer 固有オプション (speak.py準拠)
94 parser.add_argument("--aquestalk_path", type=str, default="AquesTalkPlayer.exe", help="[AQT] AquesTalkPlayer.exe の実行パス")
95
96 # OpenAI 固有オプション (speak.py準拠)
97 parser.add_argument("--instruction", type=str, default="", help="[OpenAI/Gemini] TTS APIへの追加指示")
98 # Qwen3-TTS
99 parser.add_argument("--qwen3_language", type=str, default="Japanese", help="[Qwen3-TTS] language")
100 parser.add_argument("--qwen3_model_id", type=str, default="Qwen/Qwen3-TTS-12Hz-0.6B-CustomVoice", help="[Qwen3-TTS] model ID")
101 parser.add_argument("--qwen3_device", type=str, default="auto", help="[Qwen3-TTS] auto/cuda:0/cpu")
102 parser.add_argument("--qwen3_dtype", type=str, default="auto",
103 choices=["auto", "bfloat16", "bf16", "float16", "fp16", "float32", "fp32"],
104 help="[Qwen3-TTS] dtype")
105 parser.add_argument("--qwen3_instruct", type=str, default="", help="[Qwen3-TTS] CustomVoice instruction")
106
107 # Irodori-TTS
108 parser.add_argument("--irodori_caption", type=str,
109 default="落ち着いた自然な声で、明瞭に読み上げる。",
110 help="[Irodori-TTS] voice/style caption")
111 parser.add_argument("--irodori_ref_wav", type=str, default="", help="[Irodori-TTS] reference WAV")
112 parser.add_argument("--irodori_ref_wavs", type=str, default="", help="[Irodori-TTS] reference WAVs (;区切り)")
113 parser.add_argument("--irodori_model_id", type=str, default="Aratako/Irodori-TTS-v4.1-Small",
114 help="[Irodori-TTS] model ID")
115 parser.add_argument("--irodori_device", type=str, default="auto", help="[Irodori-TTS] auto/cuda/cuda:0/cpu")
116 parser.add_argument("--irodori_precision", type=str, default="auto",
117 choices=["auto", "bf16", "bfloat16", "fp32", "float32"],
118 help="[Irodori-TTS] precision")
119 parser.add_argument("--irodori_codec_device", type=str, default=None, help="[Irodori-TTS] codec device")
120 parser.add_argument("--irodori_codec_precision", type=str, default=None, help="[Irodori-TTS] codec precision")
121 parser.add_argument("--irodori_num_steps", type=int, default=40, help="[Irodori-TTS] sampling steps")
122 parser.add_argument("--irodori_cfg_scale_text", type=float, default=3.5)
123 parser.add_argument("--irodori_cfg_scale_caption", type=float, default=3.0)
124 parser.add_argument("--irodori_cfg_scale_speaker", type=float, default=5.0)
125 parser.add_argument("--irodori_duration_scale", type=float, default=1.0)
126 parser.add_argument("--irodori_seed", type=str, default="0", help="[Irodori-TTS] seed; random/noneも可")
127 parser.add_argument("--irodori_lora_adapter", type=str, default="", help="[Irodori-TTS] LoRA adapter")
128
129 parser.add_argument("--pause", type=int, default=1, help="プログラム終了時にENTER入力を要求するか [0|1]")
130
131
132 args = parser.parse_args()
133
134 # 独話時の既定voice。tktts.update_voice_map()でvoice=Noneになるのを避ける。
135 if not args.voices:
136 if args.tts.lower() in ("qwen3", "qwen"):
137 args.voices = "Ono_Anna"
138 elif args.tts.lower() in ("irodori", "irodori-tts"):
139 args.voices = "default"
140 elif args.tts.lower() == "gemini":
141 args.voices = "Kore"
142
143 # バックエンドでは空文字よりNoneが適切な項目
144 if args.qwen3_instruct == "":
145 args.qwen3_instruct = None
146 if args.irodori_ref_wav == "":
147 args.irodori_ref_wav = None
148 if args.irodori_ref_wavs == "":
149 args.irodori_ref_wavs = None
150 if args.irodori_lora_adapter == "":
151 args.irodori_lora_adapter = None
152
153 return args
154
155
156def extract_narration_text(notes_text):
157 """PowerPointのノートから、ナレーションとして使用するテキストを抽出します。
158
159 ノートテキスト内で ``((テキスト))`` の形式で囲まれた部分を抽出します。
160 この形式が見つからない場合は、ノート全体をナレーションテキストと見なします。
161 これは、互換性維持のためのフォールバック動作です。
162
163 :param notes_text: スライドのノートテキスト。
164 :type notes_text: str
165 :returns: 抽出されたナレーションテキストと、デリミタ ``(( ))`` が見つかったかどうかの真偽値のタプル。
166 :rtype: tuple[str, bool]
167 """
168 notes_text = notes_text.strip()
169 match = re.search(r"\(\(\s*(.*?)\s*\)\)", notes_text, re.DOTALL)
170 if match:
171 return match.group(1).strip(), True
172 return notes_text, False
173
174
175def parse_dialogue_lines(narration_text):
176 """ナレーションテキストを解析し、話者ごとの対話リストに変換します。
177
178 ``「話者名,本文」`` の形式の行を解析します。カンマがない継続行は、直前の話者の発言に連結されます。
179 話者名がない行は、既定の音声で読み上げられるものとして処理されます。
180
181 :param narration_text: ``(( ))`` 内から抽出された、対話形式で記述されたテキスト。
182 :type narration_text: str
183 :returns: ``(話者名, テキスト)`` のタプルのリスト。話者名がない場合は ``None``。
184 :rtype: list[tuple[str | None, str]]
185 """
186 dialogue = []
187 for line_no, line in enumerate(narration_text.splitlines(), 1):
188 line = line.strip()
189 if not line or line.startswith("#"):
190 continue
191 if "," in line:
192 speaker, text = (part.strip() for part in line.split(",", 1))
193 if speaker:
194 dialogue.append((speaker, text))
195 continue
196 if dialogue:
197 speaker, text = dialogue[-1]
198 dialogue[-1] = (speaker, text + "\n" + line)
199 else:
200 print(f"Warning: 対話の{line_no}行目に先行話者がありません。既定音声で読み上げます。")
201 dialogue.append((None, line))
202 return [(speaker, text) for speaker, text in dialogue if text]
203
204def collect_speakers(dialogue_map):
205 """全ての対話データから、登場する話者名を初出順に重複なく収集します。
206
207 話者名の比較には `casefold()` を使用し、大文字小文字の違いを無視します。
208 返されるリストは、各話者の初出時の表記を保持します。
209
210 :param dialogue_map: スライドインデックスをキーとし、値に対話のリストを持つ辞書。
211 :type dialogue_map: dict[int, list[tuple[str | None, str]]]
212 :returns: 初出順に並べられた、重複のない話者名のリスト。
213 :rtype: list[str]
214 """
215 seen = {}
216 order = []
217 for idx in sorted(dialogue_map.keys()):
218 for speaker, _text in dialogue_map[idx]:
219 if not speaker:
220 continue
221 key = speaker.casefold()
222 if key not in seen:
223 seen[key] = speaker
224 order.append(key)
225 return [seen[key] for key in order]
226
227
228def build_dialogue_voice_map(tktts_obj, tts_engine, voices_override, speakers):
229 """対話モードで使用する話者と音声名のマッピング (`voice_map`) を構築します。
230
231 `VOICE_MAPS`、`--voices` 引数による上書き、およびTTSエンジンのデフォルト音声設定に
232 基づいて、各話者に最適な音声名を割り当てます。
233
234 :param tktts_obj: `tkTTS` オブジェクトのインスタンス。
235 :type tktts_obj: tkTTS
236 :param tts_engine: 使用するTTSエンジンの名前(小文字)。
237 :type tts_engine: str
238 :param voices_override: コマンドライン引数 `--voices` で指定された、話者と音声のマッピング文字列。
239 :type voices_override: str
240 :param speakers: 検出された話者名のリスト。
241 :type speakers: list[str]
242 :returns: 各話者名に対応する音声名を格納した辞書。
243 :rtype: dict[str | int | None, str]
244 """
245 engine_voice_map = VOICE_MAPS.get(tts_engine, {})
246 casefold_lookup = {k.casefold(): v for k, v in engine_voice_map.items()}
247 default_voice = tktts_obj.get_default_voice(tts_engine)
248
249 explicit_map = {}
250 positional_values = []
251 if voices_override:
252 for item in voices_override.split(";"):
253 item = item.strip()
254 if not item:
255 continue
256 if "=" in item:
257 k, v = item.split("=", 1)
258 explicit_map[k.strip().casefold()] = v.strip()
259 else:
260 positional_values.append(item)
261
262 current_voice_map = {}
263 for idx, speaker in enumerate(speakers):
264 key = speaker.casefold()
265 if key in explicit_map:
266 voice = explicit_map[key]
267 elif idx < len(positional_values):
268 voice = positional_values[idx]
269 elif key in casefold_lookup:
270 voice = casefold_lookup[key]
271 else:
272 voice = default_voice
273 current_voice_map[speaker] = voice
274 current_voice_map[idx] = voice
275
276 fallback_voice = current_voice_map.get(0, default_voice)
277 current_voice_map[None] = fallback_voice
278 current_voice_map[""] = fallback_voice
279
280 return current_voice_map
281
282
283def get_notes_text(slide):
284 """指定されたPowerPointスライドからノートのテキストを取得します。
285
286 スライドの `NotesPage` 内の図形を検索し、テキストフレームを持つプレースホルダー型2の
287 図形からテキストを抽出します。
288
289 :param slide: PowerPointのCOMスライドオブジェクト。
290 :type slide: win32com.client.CDispatch
291 :returns: 抽出されたノートテキスト。ノートがない場合は空文字列。
292 :rtype: str
293 """
294 for shp in slide.NotesPage.Shapes:
295 if shp.Type == 14 and shp.PlaceholderFormat.Type == 2:
296 if shp.HasTextFrame and shp.TextFrame.HasText:
297 return shp.TextFrame.TextRange.Text.strip()
298 return ""
299
300
301def close_powerpoint(pres, ppt):
302 """PowerPointのプレゼンテーションとアプリケーションを安全に閉じます。
303
304 開いているプレゼンテーション (`pres`) とPowerPointアプリケーション (`ppt`) を閉じます。
305 エラーが発生した場合は警告を出力しますが、プログラムの続行を妨げません。
306
307 :param pres: PowerPointのプレゼンテーションオブジェクト。
308 :type pres: win32com.client.CDispatch or None
309 :param ppt: PowerPointのアプリケーションオブジェクト。
310 :type ppt: win32com.client.CDispatch or None
311 """
312 if pres is not None:
313 try:
314 pres.Close()
315 except Exception as e:
316 print(f"Warning: PowerPointのCloseに失敗: {e}")
317 if ppt is not None:
318 try:
319 ppt.Quit()
320 except Exception as e:
321 print(f"Warning: PowerPointのQuitに失敗: {e}")
322
323def link_audio_autoplay(source_pptx_path, audio_tasks, output_pptx):
324 """生成された音声ファイルをPowerPointスライドに自動再生リンクとして埋め込みます。
325
326 元のPPTXファイルをコピーし、新しいPPTXファイルに音声ファイルをリンクします。
327 各音声はスライドの右下隅にアイコンとして配置され、スライド表示時に自動再生されるように設定されます。
328 本プログラムが追加した既存の音声オブジェクトは置き換えられます。
329
330 :param source_pptx_path: リンク元となるPPTXファイルのパス。
331 :type source_pptx_path: str
332 :param audio_tasks: ``(スライドインデックス, 音声ファイルパス)`` のタプルのリスト。
333 :type audio_tasks: list[tuple[int, str]]
334 :param output_pptx: 音声リンクが追加される出力PPTXファイルのパス。
335 :type output_pptx: str
336 :returns: リンク処理の結果を示す辞書。
337 `"linked"` (成功したスライドインデックスのリスト) と
338 `"saved"` (保存が成功したか) を含む。
339 :rtype: dict[str, Any]
340 """
341 print("\n--- PPTXに音声ファイルをリンク中 ---")
342 ppt = pres = None
343 linked = []
344 saved = False
345 try:
346 same_file = os.path.samefile(source_pptx_path, output_pptx) if os.path.exists(output_pptx) else False
347 if not same_file:
348 shutil.copyfile(source_pptx_path, output_pptx)
349 ppt = win32com.client.Dispatch("PowerPoint.Application")
350 pres = ppt.Presentations.Open(os.path.abspath(output_pptx))
351 for idx, wav_path in audio_tasks:
352 shape = None
353 try:
354 wav_path = os.path.abspath(wav_path)
355 if not os.path.isfile(wav_path) or os.path.getsize(wav_path) == 0:
356 print(f"Warning: スライド {idx}: 音声が存在しないか空です: {wav_path}")
357 continue
358 slide = pres.Slides(idx)
359 # 本プログラムの識別名を持つ音声だけを置き換える。
360 name = f"tktts_narration_slide_{idx}"
361 old_shapes = [shp for shp in slide.Shapes if shp.Name == name]
362 shape = slide.Shapes.AddMediaObject2(
363 wav_path, LinkToFile=True, SaveWithDocument=False,
364 Left=pres.PageSetup.SlideWidth - 50,
365 Top=pres.PageSetup.SlideHeight - 50, Width=40, Height=40)
366 # 再生設定をMainSequenceに統一。先頭・直前と同時で表示時に再生。
367 slide.TimeLine.MainSequence.AddEffect(shape, 83, 0, 2, 1)
368 for old in old_shapes:
369 old.Delete()
370 shape.Name = name
371 linked.append(idx)
372 print(f" リンク追加: スライド {idx}: {wav_path}")
373 except Exception as e:
374 print(f"Warning: スライド {idx} の音声リンクに失敗: {e}")
375 if shape is not None:
376 try:
377 shape.Delete()
378 except Exception as cleanup_error:
379 print(f"Warning: 追加途中の音声の削除に失敗: {cleanup_error}")
380 pres.Save()
381 saved = True
382 except Exception as e:
383 print(f"Warning: PPTXのコピー・オープン・保存に失敗: {e}")
384 finally:
385 close_powerpoint(pres, ppt)
386 print(f"リンク結果: 成功 {len(linked)}/{len(audio_tasks)}件、保存 {'成功' if saved else '失敗'}")
387 return {"linked": linked, "saved": saved}
388
389def extract_dialogue_from_pptx(pptx_path, monologue):
390 """PowerPointファイルからノートテキストを抽出し、対話形式または独話形式に解析します。
391
392 各スライドのノートテキストを取得し、``((...))`` デリミタで囲まれた部分を抽出します。
393 `monologue` が `False` の場合、抽出されたテキストを話者ごとの対話に分割します。
394 発音可能な文字を含まない行はスキップされます。
395
396 :param pptx_path: 入力PPTXファイルのパス。
397 :type pptx_path: str
398 :param monologue: 独話モード (`True`) か対話モード (`False`) か。
399 :type monologue: bool
400 :returns: スライドインデックスをキーとし、値に ``(話者名, テキスト)`` のリストを持つ辞書。
401 :rtype: dict[int, list[tuple[str | None, str]]]
402 """
403 ppt = pres = None
404 dialogue_map = {}
405 canonical_speakers = {}
406 try:
407 ppt = win32com.client.Dispatch("PowerPoint.Application")
408 pres = ppt.Presentations.Open(os.path.abspath(pptx_path))
409 for idx in range(1, pres.Slides.Count + 1):
410 try:
411 notes = get_notes_text(pres.Slides(idx))
412 if not notes:
413 continue
414
415 narration, found_delimiter = extract_narration_text(notes)
416 if not narration:
417 continue
418
419 # --- 追加: 行単位で発音できる文字がない行を除外する ---
420 valid_lines = []
421 for line in narration.splitlines():
422 # 行内に英数字・日本語などの文字が含まれているか判定
423 if any(c.isalnum() for c in line):
424 valid_lines.append(line)
425
426 # 有効な行だけを結合して再構築
427 narration = "\n".join(valid_lines).strip()
428
429 # すべての行が除外されて空になった場合はスライド全体をスキップ
430 if not narration:
431 print(f" 情報: スライド {idx} は発音できるテキストがないためスキップします。")
432 continue
433 # --------------------------------------------------------
434 dialogue = None
435
436 if not monologue and found_delimiter:
437 print(f" 対話解析: スライド {idx}")
438 dialogue = parse_dialogue_lines(narration)
439 if not dialogue:
440 dialogue = [(None, narration)]
441 normalized = []
442 for speaker, text in dialogue:
443 if speaker is not None:
444 speaker = canonical_speakers.setdefault(speaker.casefold(), speaker)
445 normalized.append((speaker, text))
446 dialogue_map[idx] = normalized
447 except Exception as e:
448 print(f"Warning: スライド {idx} のノート抽出に失敗: {e}")
449 except Exception as e:
450 print(f"Warning: PowerPointからのノート抽出に失敗: {e}")
451 finally:
452 close_powerpoint(pres, ppt)
453 return dialogue_map
454
455def generate_audio_files_tktts(dialogue_map, voice_map, args, tktts):
456 """`tkTTS` オブジェクトを使用して、解析された対話データから音声ファイルを生成します。
457
458 `dialogue_map` 内の各スライドと、それに対応する `voice_map` を使用して、
459 指定されたTTSエンジンで音声ファイルを生成します。
460 生成されたファイルは `args.audio_dir` に保存されます。
461 生成中の一時ディレクトリは処理後に削除されます。
462
463 :param dialogue_map: スライドインデックスをキーとし、値に対話のリストを持つ辞書。
464 :type dialogue_map: dict[int, list[tuple[str | None, str]]]
465 :param voice_map: 話者名に対応する音声名を格納した辞書。
466 :type voice_map: dict[str | int | None, str]
467 :param args: コマンドライン引数を保持する名前空間オブジェクト。
468 :type args: argparse.Namespace
469 :param tktts: `tkTTS` オブジェクトのインスタンス。
470 :type tktts: tkTTS
471 :returns: ``(スライドインデックス, 生成された音声ファイルパス)`` のタプルのリスト。
472 :rtype: list[tuple[int, str]]
473 """
474
475 tts_engine = args.tts.lower()
476 output_dir = args.audio_dir
477 os.makedirs(output_dir, exist_ok=True)
478
479 replacements = {}
480
481 print(f"\n--- 🗣️ スライドごとに {tts_engine.upper()} で音声ファイルを生成中 ---")
482
483 ext = "wav"
484# ext = "mp3"
485 sorted_slide_indices = sorted(dialogue_map.keys())
486 audio_tasks = []
487 total_t0 = time.perf_counter()
488 for slide_idx in sorted_slide_indices:
489 print()
490 print(f"スライド #{slide_idx} の音声ファイルを生成しています...")
491 slide_t0 = time.perf_counter()
492
493 # スライドごとの読み上げテキスト。独話なら [(None, text)]、
494 # 対話なら [(speaker1, text1), (speaker2, text2), ...]。
495 single_dialogue = dialogue_map[slide_idx]
496 slide_outfile = os.path.join(output_dir, f"slide{slide_idx}.{ext}")
497
498 try:
499 args.outfile = slide_outfile
500 generated_file = tktts.speak_dialogue(
501 config = args, dialogue = single_dialogue,
502 voice_map = voice_map, replacements = replacements,
503 output_format = ext,
504 )
505
506 if generated_file:
507 if os.path.isfile(generated_file) and os.path.getsize(generated_file) > 0:
508 audio_tasks.append((slide_idx, os.path.abspath(generated_file)))
509 print(f" ✅ 生成完了: {os.path.basename(generated_file)} (スライド {slide_idx})")
510 else:
511 # エラー処理
512 print(f" Warning: ファイルが見つかりません: スライド {slide_idx} での生成失敗")
513
514 else:
515 print(f" Warning: 生成失敗: スライド {slide_idx}")
516
517 except Exception as e:
518 print(f" Warning: TTSモジュール呼び出しエラー: スライド {slide_idx} - {e}")
519 traceback.print_exc()
520 finally:
521 slide_elapsed = time.perf_counter() - slide_t0
522 print(f" ⏱️ 変換時間: {slide_elapsed:.1f} 秒")
523
524 total_elapsed = time.perf_counter() - total_t0
525 print(f"\n⏱️ TTS総変換時間: {total_elapsed:.1f} 秒")
526
527 # 4. 一時ファイルのクリーンアップ (tktts 内部で削除されない場合を考慮)
528 if os.path.exists(args.temp_dir):
529 try:
530 shutil.rmtree(args.temp_dir)
531 print(f"\n🗑️ 一時ディレクトリ {args.temp_dir} を削除しました。")
532 except Exception as e:
533 print(f"Warning: 一時ディレクトリの削除に失敗: {e}")
534
535
536 print(f"音声生成結果: 成功 {len(audio_tasks)}/{len(dialogue_map)}件")
537 if len(audio_tasks) != len(dialogue_map):
538 print("Warning: 一部のスライドの音声生成に失敗しました。成功分の処理を続けます。")
539 return audio_tasks
540
541
542def main():
543 """プログラムの主要な実行ロジック。
544
545 コマンドライン引数の初期化、TTSエンジンのリスト表示、
546 または音声生成とPowerPointへのリンク処理を行います。
547 入力/出力ファイルの検証、ディレクトリの準備、PowerPointからのノート抽出、
548 音声ファイルの生成、そしてPowerPointスライドへの音声リンクの埋め込みという
549 一連のプロセスを管理します。
550 """
551 args = initialize()
552
553 # TTS固有設定はargsでtkTTSへ渡す。
554 if args.mode == 'list':
555 tktts = tkTTS(tts_name=args.tts, config=args)
556 if args.tts.lower() == "voicevox":
557 tktts.list_available_voices(args.tts, endpoint=args.endpoint)
558 else:
559 tktts.list_available_voices(args.tts)
560 terminate(args.pause)
561
562 source_pptx = args.input_path
563 output_pptx = args.output_path
564
565 if not all([source_pptx, output_pptx]):
566 print("\nエラー: convモードでは --input_path と --output_path が必要です。")
567 print(f"現在の設定: input={source_pptx}, output={output_pptx}")
568 terminate(args.pause)
569 if not os.path.isfile(source_pptx):
570 print(f"\nエラー: 入力PPTXファイルが見つかりません: {source_pptx}")
571 terminate(args.pause)
572
573 source_pptx = os.path.abspath(source_pptx)
574 output_pptx = os.path.abspath(output_pptx)
575 try:
576 os.makedirs(os.path.dirname(output_pptx), exist_ok=True)
577 os.makedirs(args.audio_dir, exist_ok=True)
578 if os.path.isdir(output_pptx):
579 raise ValueError("出力PPTXにフォルダーが指定されています")
580 except Exception as e:
581 print(f"Warning: 出力先を準備できません: {e}")
582 return
583 tktts = tkTTS(tts_name=args.tts, config=args)
584
585 print("--- 💻 PowerPointナレーション作成プログラム (CONVモード) 開始 ---")
586 print(f" TTS engine : {args.tts}")
587 print(f" is monologue: {args.monologue}")
588 print(f" 入力PPTX : {source_pptx}")
589 print(f" 出力PPTX : {output_pptx}")
590 print(f" 音声フォルダ: {args.audio_dir}")
591
592 if args.tts.lower() in ("qwen3", "qwen"):
593 print(f" Qwen model : {args.qwen3_model_id}")
594 print(f" Qwen device : {args.qwen3_device}")
595 print(f" Qwen dtype : {args.qwen3_dtype}")
596 print(f" Qwen language: {args.qwen3_language}")
597 print(f" Qwen voice : {args.voices}")
598
599 if args.tts.lower() in ("irodori", "irodori-tts"):
600 print(f" Irodori model : {args.irodori_model_id}")
601 print(f" Irodori device : {args.irodori_device}")
602 print(f" Irodori precision: {args.irodori_precision}")
603 print(f" Irodori steps : {args.irodori_num_steps}")
604 print(f" Irodori caption : {args.irodori_caption}")
605 print(f" Irodori ref WAV : {args.irodori_ref_wav}")
606
607 print("-" * 50)
608
609 # 入力PPTXのノートを取得する(対話モードなら話者ごとに分割済み)。
610 print()
611 print("--- 📝 PPTXからノートを取得中 ---")
612 dialogue_map = extract_dialogue_from_pptx(source_pptx, monologue=bool(args.monologue))
613 if not dialogue_map:
614 print(" ⚠️ ノートが記載されたスライドが見つかりませんでした。")
615 terminate(args.pause)
616 print(f" ✅ ノートを検出: {len(dialogue_map)} スライド")
617
618 # 対話モードでは、ノートから検出した話者(3名以上も可)を元にvoice_mapを構築する。
619 if args.monologue:
620 speakers = [None]
621 current_voice_map = tktts.update_voice_map(
622 voice_map=VOICE_MAPS,
623 voices=args.voices,
624 speakers=speakers,
625 )
626 else:
627 speakers = collect_speakers(dialogue_map)
628 if not speakers:
629 print(" ⚠️ 対話モード(--monologue 0)ですが、話者を検出できませんでした。")
630 print(" (( )) の中を「話者名,本文」の形式(1行1発言)で記述してください。")
631 current_voice_map = build_dialogue_voice_map(
632 tktts, args.tts.lower(), args.voices, speakers,
633 )
634
635 print(" Voice mapping:")
636 for speaker in speakers:
637 label = "独話" if speaker is None else speaker
638 print(f" {label}: {current_voice_map.get(speaker)}")
639
640 # ノートのテキストから音声ファイルを生成する。
641 print()
642 print("音声ファイルを生成します:")
643 audio_tasks = generate_audio_files_tktts(dialogue_map, voice_map = current_voice_map,
644 args = args, tktts = tktts)
645
646 # 音声ファイルをリンクして出力PPTXを保存する。
647 if audio_tasks:
648 print()
649 print(f"音声ファイルを[{source_pptx}]にリンクします:")
650 result = link_audio_autoplay(source_pptx, audio_tasks, output_pptx)
651 if not result["saved"] or len(result["linked"]) != len(audio_tasks):
652 print("Warning: PPTXへの音声リンクが完全には完了していません。上記の警告を確認してください。")
653 else:
654 print("\nWarning: 音声ファイルが生成されなかったため、リンク処理をスキップしました。")
655
656 print("--- 🎉 プログラム終了 ---")
657
658 terminate(args.pause)
659
660if __name__ == "__main__":
661 main()