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