program : C:\Users\tkami\Downloads\compare_update_diff.py version : 2026.09.06-4 root_dir1: D:\git\tkProg\tkprog_COE root_dir2: \\192.168.27.13\conf\public_html\download\tkprog_COE_30zxclkm34rdfczskl23xzpldfdsalk3\tkprog_COE filemask : *.py files : root_dir1=698, root_dir2=675 diff : compact, context=2 --- ML/bayes_decay_model_selection.py: created (new file updated on 2026/9/3) ML/nn_activation_animation.py: created (new file updated on 2026/8/29) ML/nn_relu_animation_subplot.py: created (new file updated on 2026/8/29) ML/sevennet_md.py: created (new file updated on 2026/8/29) ML/simple_nn.py: created (new file updated on 2026/8/29) Quantum/QE/cif2pw.py: created (new file updated on 2026/8/22) Quantum/QE/find_qe_pseudo.py: created (new file updated on 2026/8/22) VASP/VASP_test/template/make_summary.py: created (new file updated on 2026/8/23) VASP/VASP_test/template/prev/make_summary20260812.py: created (new file updated on 2026/8/12) VASP/VASP_test/template/prev/test_VASP.py: created (new file updated on 2025/8/7) VASP/VASP_test/test_VASP.py: deleted (old file updated on 2025/8/21) VASP/vasp_assign_xas.py: created (new file updated on 2026/9/1) VASP/vasp_plot_dos.py: updated (updated on 2026/8/28) import os +import re import sys -import shutil -import glob -import csv -import re + import numpy as np -from numpy import sqrt, exp, log, sin, cos, tan, arcsin, arccos, arctan, pi -from scipy.interpolate import interp1d -from pprint import pprint from matplotlib import pyplot as plt - -from tklib.tkfile import tkFile -import tklib.tkre as tkre -from tklib.tkutils import save_csv -from tklib.tkutils import IsDir, IsFile, SplitFilePath -from tklib.tkutils import terminate, pint, pfloat, getarg, getintarg, getfloatarg -from tklib.tksci.tksci import Reduce01, Round -from tklib.tksci.tkmatrix import make_matrix1, make_matrix2, make_matrix3 -from tklib.tksci.tkconvolution import convolution, convolve_func -from tklib.tkcrystal.tkcif import tkCIF, tkCIFData -from tklib.tkcrystal.tkcrystal import tkCrystal -from tklib.tkcrystal.tkatomtype import tkAtomType +from tklib.tkutils import terminate, getarg, getintarg, getfloatarg +from tklib.tksci.tkconvolution import convolution from tklib.tkcrystal.tkvasp import tkVASP -from tklib.tksci import tkequation -import tklib.tkcsv ... # Energy to search edge energies EF0 = 0.1 -# Critera DOS value to search band edges -DOSth = 1.0e-5 - -# Max number of electrons occupies one state -Nemax = 1.0 - # width of Gaussian functio for convolution width = 0.0 - -# Plot configuration -band_marker_size = 4 -band_marker_edge_width = 0.5 Emin = -10.0 # eV ... figsize = (8, 6) fontsize = 16 -labelfontsize = 12 legend_fontsize = 12 ... print("") print("Usage:") - print(" (a) python {} mode CAR_dir Emin Emax Gaussian_width, save_figure plot_figure plot_figure_Ne".format(sys.argv[0])) - print(" ex: python {} {} {} {} {} {} {} {} {}" - .format(sys.argv[0], mode, CAR_dir, Emin, Emax, width, save_figure, plot_figure, plot_figure_Ne)) + print(" (a) python {} mode CAR_dir Emin Emax occ_th Gaussian_width save_figure plot_figure plot_figure_Ne".format(sys.argv[0])) + print(" ex: python {} {} {} {} {} {} {} {} {} {}" + .format(sys.argv[0], mode, CAR_dir, Emin, Emax, occ_th, width, + save_figure, plot_figure, plot_figure_Ne)) def updatevars(): ... -#def save_csv(path, headerlist, datalist, is_print = 0): - -def plot_dos(mode, CAR_path, Emin, Emax): +class DOSCARFormatError(ValueError): + """DOSCARの内容が宣言された形式と一致しない場合の例外。""" + + +def _vasp_float(text, *, line_number, field_name): + """VASPの浮動小数点表記をfloatへ変換する。""" + value = text.strip().replace("D", "E").replace("d", "e") + + # 桁あふれ時に指数部の E が省略された表記(例: 1.23-123)にも対応する。 + if "e" not in value.lower(): + value = re.sub(r"(?<=\d)([+-]\d{2,3})$", r"e\1", value) + + try: + return float(value) + except ValueError as exc: + raise DOSCARFormatError( + f"DOSCAR line {line_number}: {field_name}=[{text}]を数値に変換できません。" + ) from exc + + +def _vasp_int(text, *, line_number, field_name): + """整数フィールドを読み、1.0のような非整数表記は拒否する。""" + value = _vasp_float(text, line_number=line_number, field_name=field_name) + integer = int(value) + if value != integer: + raise DOSCARFormatError( + f"DOSCAR line {line_number}: {field_name}={value} は整数ではありません。" + ) + return integer + + +def _read_required_line(fp, line_number, description): + line = fp.readline() + if line == "": + raise DOSCARFormatError( + f"DOSCAR line {line_number}: {description}を読む前にファイルが終了しました。" + ) + return line + + +def _parse_total_dos_header(line, line_number): + """全DOSヘッダーから Emax, Emin, NEDOS, Efermi を読む。""" + fields = line.split() + if len(fields) >= 4: + try: + return ( + _vasp_float(fields[0], line_number=line_number, field_name="Emax"), + _vasp_float(fields[1], line_number=line_number, field_name="Emin"), + _vasp_int(fields[2], line_number=line_number, field_name="NEDOS"), + _vasp_float(fields[3], line_number=line_number, field_name="Efermi"), + ) + except DOSCARFormatError: + # NEDOSが直前のEminに連結された古い固定幅出力を下で再解析する。 + pass + + # VASPの固定幅出力で、EminとNEDOSの間に空白がない場合への対応。 + if len(line) >= 53: + return ( + _vasp_float(line[0:16], line_number=line_number, field_name="Emax"), + _vasp_float(line[16:32], line_number=line_number, field_name="Emin"), + _vasp_int(line[32:37], line_number=line_number, field_name="NEDOS"), + _vasp_float(line[37:53], line_number=line_number, field_name="Efermi"), + ) + + raise DOSCARFormatError( + f"DOSCAR line {line_number}: 全DOSヘッダーは4列以上必要ですが、" + f"{len(fields)}列しかありません。" + ) + + +def read_doscar_total( + doscar_path, *, fermi_energy=None, volume_ang3=None, + normalize_energy=True, unit=""): + """DOSCARの全DOSブロックだけを読み込む。 + + このプロットでは原子・軌道分解DOSを使わないため、PDOSブロックは + 読み込まない。これにより、PDOSの有無やLORBITによる列数の違いから + 全DOSプロットを独立させる。 + + 全DOSは次の標準形式に対応する。 + ISPIN=1: E, DOS, integrated DOS(3列) + ISPIN=2: E, DOS(up), DOS(down), integrated(up), integrated(down)(5列) + """ + if not os.path.isfile(doscar_path): + raise FileNotFoundError(f"DOSCARが見つかりません: {doscar_path}") + + with open(doscar_path, "r", encoding="utf-8", errors="replace") as fp: + line1 = _read_required_line(fp, 1, "イオン数ヘッダー") + fields = line1.split() + if len(fields) < 2: + raise DOSCARFormatError( + f"DOSCAR line 1: イオン数ヘッダーは2列以上必要です。" + ) + nions_with_empty = _vasp_int( + fields[0], line_number=1, field_name="NIONS including empty spheres" + ) + nions = _vasp_int(fields[1], line_number=1, field_name="NIONS") + + line2 = _read_required_line(fp, 2, "セル情報") + cell_fields = line2.split() + if not cell_fields: + raise DOSCARFormatError("DOSCAR line 2: セル体積がありません。") + doscar_volume = _vasp_float( + cell_fields[0], line_number=2, field_name="cell volume" + ) + + _read_required_line(fp, 3, "温度情報") + _read_required_line(fp, 4, "CAR識別行") + sample_name = _read_required_line(fp, 5, "SYSTEM名").strip() + line6 = _read_required_line(fp, 6, "全DOSヘッダー") + Emax_dos, Emin_dos, nE, doscar_fermi = _parse_total_dos_header(line6, 6) + + if nE <= 0: + raise DOSCARFormatError(f"DOSCAR line 6: NEDOS={nE} は不正です。") + + rows = [] + expected_columns = None + for iE in range(nE): + line_number = 7 + iE + line = _read_required_line(fp, line_number, f"全DOSデータ {iE + 1}/{nE}") + fields = line.split() + + if expected_columns is None: + expected_columns = len(fields) + if expected_columns not in (3, 5): + raise DOSCARFormatError( + f"DOSCAR line {line_number}: 全DOSは3列または5列である必要が" + f"ありますが、{expected_columns}列です。" + ) + elif len(fields) != expected_columns: + raise DOSCARFormatError( + f"DOSCAR line {line_number}: 全DOSの列数が途中で" + f"{expected_columns}列から{len(fields)}列に変わりました。" + ) + + rows.append([ + _vasp_float(value, line_number=line_number, field_name=f"column {j + 1}") + for j, value in enumerate(fields) + ]) + + total = np.asarray(rows, dtype=float) + reference_fermi = doscar_fermi if fermi_energy is None else float(fermi_energy) + energies = total[:, 0].copy() + if normalize_energy: + energies -= reference_fermi + + if expected_columns == 3: + total_dos_up = total[:, 1].copy() + total_dos_dn = None + integrated_up = total[:, 2].copy() + integrated_dn = None + is_spin_polarized = False + else: + total_dos_up = total[:, 1].copy() + total_dos_dn = total[:, 2].copy() + integrated_up = total[:, 3].copy() + integrated_dn = total[:, 4].copy() + is_spin_polarized = True + + used_volume = doscar_volume if volume_ang3 is None else float(volume_ang3) + if unit == "/cm3": + if used_volume <= 0.0: + raise DOSCARFormatError( + f"DOSを/cm3へ換算できません: セル体積={used_volume} A^3" + ) + scale = 1.0 / (used_volume * 1.0e-24) + total_dos_up *= scale + integrated_up *= scale + if total_dos_dn is not None: + total_dos_dn *= scale + integrated_dn *= scale + elif unit != "": + raise ValueError(f"未対応のDOS単位です: {unit}") + + return { + "SampleName": sample_name, + "nions_with_empty": nions_with_empty, + "nions": nions, + "Vcell_DOSCAR": doscar_volume, + "Vcell": used_volume, + "EF": reference_fermi, + "Efermi": doscar_fermi, + "Emax": Emax_dos, + "Emin": Emin_dos, + "nE": nE, + "ncolumns": expected_columns, + "IsSpinPolarized": is_spin_polarized, + "E": energies, + "TotalDOSup": total_dos_up, + "TotalDOSdn": total_dos_dn, + "Neup": integrated_up, + "Nedn": integrated_dn, + } + + +def read_band_edges(vasp, eigenval_path, outcar_path, EF, ISPIN): + """バンド端を読み、EIGENVALが不完全な場合はDOS描画を継続する。""" + if not os.path.isfile(eigenval_path): + print(f"Warning: EIGENVALがないためバンド端は表示しません: {eigenval_path}") + return None + + try: + eigenvalinf = vasp.read_eigenval(eigenval_path, EF=EF) + if not eigenvalinf or "EList" not in eigenvalinf: + raise ValueError("EIGENVALの読み込み結果にEListがありません。") + + occupation_edges = vasp.find_band_edges_from_eigenval( + EF0=EF0, eigenvalinf=eigenvalinf, ISPIN=ISPIN, occ_th=occ_th + ) + required = {"EV", "EC", "Eg", "EHOMO", "ELUMO"} + missing = required.difference(occupation_edges) + if missing: + raise ValueError(f"バンド端情報に{sorted(missing)}がありません。") + except (OSError, IndexError, KeyError, TypeError, ValueError) as exc: + print(f"Warning: EIGENVALの解析に失敗しました: {exc}") + print(" DOSは描画しますが、バンド端マーカーは省略します。") + return None + + energy_edges = None + try: + energy_edges = vasp.gbandedges(".", outcar_path, eigenval_path, EF) + required = {"EVBM", "ECBM", "Eg"} + missing = required.difference(energy_edges) + if missing: + raise ValueError(f"EF基準のバンド端情報に{sorted(missing)}がありません。") + except (OSError, IndexError, KeyError, TypeError, ValueError, UnboundLocalError) as exc: + print(f"Warning: EF基準のバンド端探索に失敗しました: {exc}") + energy_edges = None + + return { + "eigenval": eigenvalinf, + "occupation": occupation_edges, + "energy": energy_edges, + } + + +def draw_energy_marker(ax, energy, label, color): + """値が有効な場合だけ縦線を描く。""" + if energy is None or not np.isfinite(energy): + return + ymin, ymax = ax.get_ylim() + ax.plot( + [energy, energy], [ymin, ymax], label=label, + linestyle="dashed", linewidth=0.5, color=color + ) + +def plot_dos(CAR_path, Emin, Emax): vasp = tkVASP() ... print("") print(f"Plot E range: {Emin} - {Emax} eV") - print(f"Occupancy threshold to seprate HOMO and LUMO: {occ_th}") + print(f"Occupancy threshold to separate HOMO and LUMO: {occ_th}") print(f"Gaussian width for convolution: {width} eV") print(f"save_figure : {save_figure}") ... print("") print("*** Read crystal structure from [{}]".format(POSCAR_path)) - cry1 = vasp.read_poscar(POSCAR_path) + try: + cry1 = vasp.read_poscar(POSCAR_path) + except (OSError, IndexError, KeyError, TypeError, ValueError) as exc: + terminate(f"Error: POSCARを読めません: {exc}", usage=usage) if cry1 is None: terminate("Error: Can not read [{}]".format(POSCAR_path), usage = usage) - a1, b1, c1, alpha1, beta1, gamm1 = cry1.LatticeParameters() Vcell1 = cry1.Volume() cry1.PrintInf("cell") print("") - incarinf = vasp.read_incar_inf(INCAR_path) - - print("") - outcarinf = vasp.read_outcar_inf(OUTCAR_path) + vasp.read_incar_inf(INCAR_path) + + print("") + try: + outcarinf = vasp.read_outcar_inf(OUTCAR_path) + except (OSError, IndexError, KeyError, TypeError, ValueError) as exc: + terminate(f"Error: OUTCARを読めません: {exc}", usage=usage) + if not outcarinf or "EF" not in outcarinf or "ISPIN" not in outcarinf: + terminate("Error: OUTCARからEFまたはISPINを読めません: {}".format(OUTCAR_path), usage=usage) EF = outcarinf["EF"] ISPIN = outcarinf["ISPIN"] ... print(" EF = {} eV corrected to 0".format(EF)) - doscarinf = vasp.read_doscar(DOSCAR_path, unit = '/cm3') + try: + doscarinf = read_doscar_total( + DOSCAR_path, fermi_energy=EF, volume_ang3=Vcell1, unit="/cm3" + ) + except (OSError, DOSCARFormatError, ValueError) as exc: + terminate(f"Error: DOSCARを読めません: {exc}", usage=usage) + nE = doscarinf["nE"] E = doscarinf["E"] - if nE == 0: - Edmin = 0.0 - Edmax = 0.0 - else: - Edmin = min(E) - Edmax = max(E) + Edmin = min(E) + Edmax = max(E) tDOSup = doscarinf["TotalDOSup"] Neup = doscarinf["Neup"] tDOSdn = doscarinf["TotalDOSdn"] Nedn = doscarinf["Nedn"] - - if nE > 0: - print("") - print("DOS E range: {:10.6g} - {:10.6g} eV, {} points".format(Edmin, Edmax, nE)) - - eigenvalinf = vasp.read_eigenval(EIGENVAL_path, EF = EF) - nk = eigenvalinf["nk"] - nLevels = eigenvalinf["nLevels"] - bandedgeinf = vasp.find_band_edges_from_eigenval(EF0 = EF0, eigenvalinf = eigenvalinf, ISPIN = ISPIN, occ_th = occ_th) - bandedgeinf2 = vasp.gbandedges(CAR_path, OUTCAR_path, EIGENVAL_path, EF) - - print("k points in EIGENVAL:") - print("nk=", nk) - print("nLevels=", nLevels) - for i in range(nk): - el = eigenvalinf['EList'][i] - kx, ky, kz, wk, dk, ktot, Eups, occups, Edns, occdns = el - print(" ({:8.4f} {:8.4f} {:8.4f}) w={:8.4g} {:8.4f} {:12.4f}".format(kx, ky, kz, wk, dk, ktot)) - - EV1 = bandedgeinf["EV"] - EC1 = bandedgeinf["EC"] - Eg1 = bandedgeinf["Eg"] - EV = bandedgeinf2["EVBM"] - EF - EC = bandedgeinf2["ECBM"] - EF - Eg = bandedgeinf2["Eg"] - EHOMO = bandedgeinf["EHOMO"] - ELUMO = bandedgeinf["ELUMO"] - print(f"Band edge from EIGENVAL:") - print(f"EF0={EF0:10.6f} eV") - print(f" find_band_edges: EV={EV:10.6f} EC={EC:10.6f} Eg={Eg:10.6f} eV") - print(f" HOMO:{EHOMO:10.6f} eV") - print(f" LUMO:{ELUMO:10.6f} eV") - print(f" gbandedges() : EV={EV:10.6f} EC={EC:10.6f} Eg={Eg:10.6f} eV") + dos_is_spin_polarized = doscarinf["IsSpinPolarized"] + + print("") + print("DOS E range: {:10.6g} - {:10.6g} eV, {} points, {} columns" + .format(Edmin, Edmax, nE, doscarinf["ncolumns"])) + print(f" Efermi in DOSCAR: {doscarinf['Efermi']:10.6f} eV") + print(f" Efermi used : {doscarinf['EF']:10.6f} eV (from OUTCAR)") + + volume_difference = abs(doscarinf["Vcell_DOSCAR"] - Vcell1) + if volume_difference > max(1.0e-6, 1.0e-5 * abs(Vcell1)): + print("Warning: DOSCARとPOSCARのセル体積が一致しません:") + print(f" DOSCAR={doscarinf['Vcell_DOSCAR']:.8g} A^3, " + f"POSCAR={Vcell1:.8g} A^3") + + if (ISPIN == 2) != dos_is_spin_polarized: + print("Warning: OUTCARのISPINとDOSCARの全DOS列数が一致しません:") + print(f" ISPIN={ISPIN}, DOSCAR={doscarinf['ncolumns']} columns") + + EV = EC = EHOMO = ELUMO = None + band_info = read_band_edges(vasp, EIGENVAL_path, OUTCAR_path, EF, ISPIN) + if band_info is not None: + eigenvalinf = band_info["eigenval"] + bandedgeinf = band_info["occupation"] + bandedgeinf2 = band_info["energy"] + + nk = eigenvalinf.get("nk", len(eigenvalinf.get("EList", []))) + nLevels = eigenvalinf.get("nLevels", 0) + print("k points in EIGENVAL:") + print("nk=", nk) + print("nLevels=", nLevels) + if debug: + for el in eigenvalinf.get("EList", []): + if len(el) != 10: + print(f" Warning: unexpected EIGENVAL entry: {el}") + continue + kx, ky, kz, wk, dk, ktot, Eups, occups, Edns, occdns = el + print(f" ({kx:8.4f} {ky:8.4f} {kz:8.4f}) w={wk:8.4g} " + f"dk={dk} ktot={ktot}") + + EV1 = bandedgeinf["EV"] + EC1 = bandedgeinf["EC"] + Eg1 = bandedgeinf["Eg"] + EHOMO = bandedgeinf["EHOMO"] + ELUMO = bandedgeinf["ELUMO"] + + # gbandedges()が使えない場合は占有率に基づく値を描画に使う。 + EV = EV1 + EC = EC1 + print("Band edge from EIGENVAL:") + print(f"EF0={EF0:10.6f} eV") + print(f" find_band_edges: EV={EV1:10.6f} EC={EC1:10.6f} Eg={Eg1:10.6f} eV") + print(f" HOMO:{EHOMO:10.6f} eV") + print(f" LUMO:{ELUMO:10.6f} eV") + + if bandedgeinf2 is not None: + EV = bandedgeinf2["EVBM"] - EF + EC = bandedgeinf2["ECBM"] - EF + Eg = bandedgeinf2["Eg"] + print(f" gbandedges() : EV={EV:10.6f} EC={EC:10.6f} Eg={Eg:10.6f} eV") ... tDOSup_s = convolution(E, tDOSup, width, func_type = 'gauss') - ax1.plot(E, tDOSup_s, label = 'up', linestyle = '-', linewidth = 0.5, color = 'black') - if ISPIN == 2: + up_label = "up" if dos_is_spin_polarized else "total" + ax1.plot(E, tDOSup_s, label=up_label, linestyle='-', linewidth=0.5, color='black') + if dos_is_spin_polarized: tDOSdn_s = convolution(E, tDOSdn, width, func_type = 'gauss') ax1.plot(E, tDOSdn_s, label = 'dn', linestyle = '-', linewidth = 0.5, color = 'red') - ylim = ax1.get_ylim() - ax1.plot([EV, EV], ylim, label = '$E_V$', linestyle = 'dashed', linewidth = 0.5, color = 'red') - ax1.plot([EC, EC], ylim, label = '$E_C$', linestyle = 'dashed', linewidth = 0.5, color = 'red') - ax1.plot([EF0, EF0], ylim, label = '$E_{F0}$', linestyle = 'dashed', linewidth = 0.5, color = 'blue') - ax1.plot([EHOMO, EHOMO], ylim, label = '$E_{HOMO}$', linestyle = 'dashed', linewidth = 0.5, color = 'green') - ax1.plot([ELUMO, ELUMO], ylim, label = '$E_{LUMO}$', linestyle = 'dashed', linewidth = 0.5, color = 'green') + draw_energy_marker(ax1, EV, '$E_V$', 'red') + draw_energy_marker(ax1, EC, '$E_C$', 'red') + draw_energy_marker(ax1, EF0, '$E_{F0}$', 'blue') + draw_energy_marker(ax1, EHOMO, '$E_{HOMO}$', 'green') + draw_energy_marker(ax1, ELUMO, '$E_{LUMO}$', 'green') ax1.set_xlabel("$E$ (eV)", fontsize = fontsize) ax1.set_ylabel("DOS (states/cm$^3$/eV)", fontsize = fontsize) ... if plot_figure_Ne: - ax2.plot(E, Neup, label = 'up', linestyle = '-', linewidth = 0.5, color = 'black') - if ISPIN == 2: + ax2.plot(E, Neup, label=up_label, linestyle='-', linewidth=0.5, color='black') + if dos_is_spin_polarized: ax2.plot(E, Nedn, label = 'dn', linestyle = '-', linewidth = 0.5, color = 'red') - ylim = ax2.get_ylim() - ax2.plot([EV, EV], ylim, label = '$E_V$', linestyle = 'dashed', linewidth = 0.5, color = 'red') - ax2.plot([EC, EC], ylim, label = '$E_C$', linestyle = 'dashed', linewidth = 0.5, color = 'red') - ax2.plot([EF0, EF0], ylim, label = '$E_{F0}$', linestyle = 'dashed', linewidth = 0.5, color = 'blue') - ax2.plot([EHOMO, EHOMO], ylim, label = '$E_{HOMO}$', linestyle = 'dashed', linewidth = 0.5, color = 'green') - ax2.plot([ELUMO, ELUMO], ylim, label = '$E_{LUMO}$', linestyle = 'dashed', linewidth = 0.5, color = 'green') + draw_energy_marker(ax2, EV, '$E_V$', 'red') + draw_energy_marker(ax2, EC, '$E_C$', 'red') + draw_energy_marker(ax2, EF0, '$E_{F0}$', 'blue') + draw_energy_marker(ax2, EHOMO, '$E_{HOMO}$', 'green') + draw_energy_marker(ax2, ELUMO, '$E_{LUMO}$', 'green') ax2.set_xlabel("$E$ (eV)", fontsize = fontsize) ax2.set_ylabel("$N_e$ (states/cm$^3$)", fontsize = fontsize) ... def main(): global mode - global cifpath, poscarpath updatevars() ... if mode == 'DOS': - plot_dos(mode, CAR_dir, Emin, Emax) + plot_dos(CAR_dir, Emin, Emax) else: terminate("Error: Invalide mode [{}]".format(mode), usage = usage) VASP/vasp_plot_dos20231024.py: created (new file updated on 2023/10/24) VASP/vasp_plot_dos_vasp2dos.py: updated (updated on 2026/8/28) import tklib.tkcsv -import filter.vasp2dos as v2d +import tkfilter.vasp.vasp2dos as v2d VASP/vasp_plot_epsilon.py: updated (updated on 2026/8/28) import csv import re +import argparse import numpy as np from numpy import exp, log, sin, cos, tan, arcsin, arccos, arctan, pi from scipy.interpolate import interp1d +from scipy.ndimage import gaussian_filter1d from pprint import pprint from matplotlib import pyplot as plt ... debug = 0 -# mode: 'density', 'current' +# mode: 'density', 'current', 'xas', 'dos' mode = 'density' ... # Plot configuration -Emin = 0.0 # eV -Emax = 8.0 # eV +# None selects the full energy range present in OUTCAR. +Emin = None # eV +Emax = None # eV +sigma = 0.0 # Gaussian standard deviation for XAS post-processing (eV) #colors = ['#000000', '#ff0000', '#00aa00', '#0000ff', '#aaaa00', '#ff00ff', '#00ffff', '#aa0000', '#00aa00', '#0000aa'] #colors = ['k', 'r', 'g', 'b', 'y', 'm', 'c'] ... global mode global CAR_dir - global Emin, Emax + global Emin, Emax, sigma print("") print("Usage:") - print(" (a) python {} mode CAR_dir Emin, Emax".format(sys.argv[0])) - print(" ex: python {} {} {} {}".format(sys.argv[0], mode, CAR_dir, Emin, Emax)) + print(" python {} mode [CAR_dir] [Emin] [Emax] [--sigma SIGMA]".format(sys.argv[0])) + print(" ex: python {} xas . 2140 2190 --sigma 0.3".format(sys.argv[0])) + print(" mode: density, current, xas, dos") + print(" xas reads the imaginary dielectric function written by CH_LSPEC=.TRUE.") + print(" dos reads the total DOS in DOSCAR and uses E - E_F as its energy axis.") + print(" --sigma: Gaussian standard deviation in eV for XAS/DOS post-processing") + print(" Omit Emin/Emax to use the complete energy range in the input data.") def updatevars(): global mode global CAR_dir - global Emin, Emax - - mode = getarg (1, mode) - CAR_dir = getarg (2, CAR_dir) - Emin = getfloatarg(3, Emin) - Emax = getfloatarg(4, Emax) - if mode == 'density' or mode == 'current': - pass - else: - terminate("Error: Invalide mode [{}]".format(mode), usage = usage) + global Emin, Emax, sigma + + parser = argparse.ArgumentParser(add_help=False) + parser.add_argument('mode', nargs='?', default=mode) + parser.add_argument('CAR_dir', nargs='?', default=CAR_dir) + parser.add_argument('Emin', nargs='?', type=float, default=None) + parser.add_argument('Emax', nargs='?', type=float, default=None) + parser.add_argument('--sigma', type=float, default=0.0) + parser.add_argument('-h', '--help', action='store_true') + args, unknown = parser.parse_known_args() + + if args.help or unknown: + usage() + if unknown: + terminate("Error: unknown argument(s): {}".format(' '.join(unknown))) + terminate("") + + mode, CAR_dir = args.mode, args.CAR_dir + Emin, Emax, sigma = args.Emin, args.Emax, args.sigma + if mode not in ('density', 'current', 'xas', 'dos'): + terminate("Error: Invalide mode [{}]".format(mode), usage=usage) + if sigma < 0.0: + terminate("Error: --sigma must be non-negative.", usage=usage) + if Emin is not None and Emax is not None and Emin >= Emax: + terminate("Error: Emin must be smaller than Emax.", usage=usage) ... return Elist, e1list1, e2list1, e1list2, e2list2 + +def read_xas(OUTCAR_path): + """Read the core-hole XAS spectrum from OUTCAR. + + With CH_LSPEC=.TRUE., VASP writes the XAS spectrum as the imaginary + dielectric tensor. Its energy axis is already relative to the selected + core level, so no conversion from the ordinary LOPTICS energy scale is + required here. + """ + outcar = tkFile(OUTCAR_path, 'r') + Elist, xaslist = read_spectrum( + outcar, + r"IMAGINARY DIELECTRIC FUNCTION.+density", + ) + outcar.Close() + + if len(Elist) == 0: + terminate( + "Error: XAS spectrum was not found in OUTCAR. " + "Check CH_LSPEC=.TRUE. and that the calculation finished.", + usage=usage, + ) + return Elist, xaslist + +def select_plot_range(Emin, Emax, E_min, E_max): + """Use the data extent for omitted bounds and clamp explicit bounds.""" + if Emin is None: + Emin = E_min + else: + Emin = max(Emin, E_min) + if Emax is None: + Emax = E_max + else: + Emax = min(Emax, E_max) + if Emin >= Emax: + terminate("Error: requested energy range does not overlap the data.", usage=usage) + return Emin, Emax + + def plot_epsilon(mode, CAR_path, Emin, Emax): vasp = tkVASP() ... nE = len(Elist) print("Data E range: {} - {} eV, {} points".format(E_min, E_max, nE)) - Emin = max([Emin, E_min]) - Emax = min([Emax, E_max]) + Emin, Emax = select_plot_range(Emin, Emax, E_min, E_max) print("Plot E range: {} - {} eV".format(Emin, Emax)) ... +def gaussian_broaden(Earray, intensity, sigma_eV): + """Gaussian broadening on a VASP energy grid. + + DOSCAR is nominally uniformly sampled, but printed energies can have small + rounding differences. For a non-uniform printed grid, interpolate to a + uniform grid, convolve there, and interpolate back to the original grid. + """ + if sigma_eV == 0.0: + return intensity.copy() + if len(Earray) < 2: + terminate("Error: at least two energy points are required for --sigma.", usage=usage) + dE = np.diff(Earray) + step = np.median(dE) + if step <= 0.0 or np.any(dE <= 0.0): + terminate("Error: energy grid must be strictly increasing for --sigma.", usage=usage) + + if np.allclose(dE, step, rtol=1.e-4, atol=1.e-8): + return gaussian_filter1d(intensity, sigma_eV / step, axis=-1, mode='constant') + + uniform_energy = np.linspace(Earray[0], Earray[-1], len(Earray)) + uniform_intensity = np.interp(uniform_energy, Earray, intensity) + uniform_step = uniform_energy[1] - uniform_energy[0] + broadened_uniform = gaussian_filter1d( + uniform_intensity, + sigma_eV / uniform_step, + mode='constant', + ) + return np.interp(Earray, uniform_energy, broadened_uniform) + + +def plot_xas(CAR_path, Emin, Emax, sigma): + """Plot the imaginary dielectric tensor from a VASP SCH/XAS run.""" + vasp = tkVASP() + base_path = vasp.getdir(CAR_path) + OUTCAR_path = vasp.get_OUTCAR(base_path) + + print("") + print("mode: xas") + print("CAR dir : ", CAR_path) + print("OUTCAR : ", OUTCAR_path) + + Elist, xaslist = read_xas(OUTCAR_path) + Earray = np.asarray(Elist) + xasraw = np.asarray(xaslist) + xasarray = gaussian_broaden(Earray, xasraw, sigma) + isotropic = (xasarray[0] + xasarray[1] + xasarray[2]) / 3.0 + diagonal_sum = xasarray[0] + xasarray[1] + xasarray[2] + + E_min = min(Elist) + E_max = max(Elist) + print("Data E range: {} - {} eV, {} points".format(E_min, E_max, len(Elist))) + Emin, Emax = select_plot_range(Emin, Emax, E_min, E_max) + print("Plot E range: {} - {} eV".format(Emin, Emax)) + if sigma > 0.0: + print("Apply Gaussian broadening: sigma = {} eV".format(sigma)) + + outxlsx = 'xas.xlsx' + print("Save XAS data to [{}]".format(outxlsx)) + tkVariousData().to_excel( + outxlsx, + ["E(eV)", "xas_xx_raw", "xas_yy_raw", "xas_zz_raw", "xas_xy_raw", "xas_yz_raw", "xas_zx_raw", + "xas_xx", "xas_yy", "xas_zz", "xas_xy", "xas_yz", "xas_zx", + "xas_isotropic", "xas_diagonal_sum"], + [Elist, xasraw[0], xasraw[1], xasraw[2], xasraw[3], xasraw[4], xasraw[5], + xasarray[0], xasarray[1], xasarray[2], xasarray[3], xasarray[4], xasarray[5], + isotropic.tolist(), diagonal_sum.tolist()], + ) + + fig, ax = plt.subplots(figsize=(10, 7)) + colors = ['tab:red', 'tab:green', 'tab:blue'] + for i, label in enumerate(('xx', 'yy', 'zz')): + ax.plot(Earray, xasarray[i], label=label, color=colors[i], linewidth=0.8, alpha=0.8) + spectrum_label = 'isotropic: (xx+yy+zz)/3' + if sigma > 0.0: + spectrum_label += r', Gaussian $\sigma={}$ eV'.format(sigma) + ax.plot(Earray, isotropic, label=spectrum_label, color='black', linewidth=1.6) + ax.set_xlim(Emin, Emax) + ax.set_xlabel('$E$ relative to core level (eV)', fontsize=fontsize) + ax.set_ylabel('XAS intensity (arb. units)', fontsize=fontsize) + ax.legend(fontsize=legend_fontsize) + ax.tick_params(labelsize=fontsize) + fig.tight_layout() + fig.savefig('xas.png', dpi=200) + print("Save XAS plot to [xas.png]") + + plt.pause(0.1) + print("") + print("Press ENTER to exit>>", end='') + input() + terminate("", usage=usage) + + +def read_doscar(DOSCAR_path): + """Read the total-DOS block from a VASP DOSCAR file. + + Returns energies relative to the Fermi energy. Projected DOS blocks, if + present after the total-DOS block, are intentionally not read here. + """ + try: + with open(DOSCAR_path, 'r') as fp: + for _ in range(5): + fp.readline() + header = fp.readline().split() + if len(header) < 4: + raise ValueError('missing total-DOS header') + nedos = int(float(header[2])) + efermi = float(header[3]) + + data = [] + for _ in range(nedos): + line = fp.readline() + if not line: + break + values = [float(value) for value in line.split()] + if len(values) < 3: + break + data.append(values) + except (OSError, ValueError) as exc: + terminate("Error reading DOSCAR [{}]: {}".format(DOSCAR_path, exc), usage=usage) + + if len(data) != nedos: + terminate("Error: DOSCAR total-DOS block is incomplete.", usage=usage) + + array = np.asarray(data) + energy = array[:, 0] - efermi + # Non-spin-polarized: E, DOS, integrated DOS + # Spin-polarized: E, DOS(up), DOS(down), integrated(up), integrated(down) + if array.shape[1] >= 5: + return energy, array[:, 1], array[:, 2], True, efermi + return energy, array[:, 1], None, False, efermi + + +def plot_dos(CAR_path, Emin, Emax, sigma): + """Plot VASP total DOS from DOSCAR.""" + vasp = tkVASP() + base_path = vasp.getdir(CAR_path) + DOSCAR_path = os.path.join(base_path, 'DOSCAR') + if not os.path.isfile(DOSCAR_path): + terminate("Error: DOSCAR was not found at [{}].".format(DOSCAR_path), usage=usage) + + energy, dos_up_raw, dos_down_raw, is_spin, efermi = read_doscar(DOSCAR_path) + dos_up = gaussian_broaden(energy, dos_up_raw, sigma) + dos_down = None if dos_down_raw is None else gaussian_broaden(energy, dos_down_raw, sigma) + E_min, E_max = min(energy), max(energy) + Emin, Emax = select_plot_range(Emin, Emax, E_min, E_max) + + print("") + print("mode: dos") + print("DOSCAR : ", DOSCAR_path) + print("Fermi energy: {} eV".format(efermi)) + print("Data E-EF range: {} - {} eV, {} points".format(E_min, E_max, len(energy))) + print("Plot E-EF range: {} - {} eV".format(Emin, Emax)) + if sigma > 0.0: + print("Apply Gaussian broadening: sigma = {} eV".format(sigma)) + + outxlsx = 'dos.xlsx' + if is_spin: + tkVariousData().to_excel( + outxlsx, + ['E-EF(eV)', 'DOS_up_raw', 'DOS_down_raw', 'DOS_up', 'DOS_down'], + [energy.tolist(), dos_up_raw.tolist(), dos_down_raw.tolist(), + dos_up.tolist(), dos_down.tolist()], + ) + else: + tkVariousData().to_excel( + outxlsx, + ['E-EF(eV)', 'DOS_raw', 'DOS'], + [energy.tolist(), dos_up_raw.tolist(), dos_up.tolist()], + ) + print("Save DOS data to [{}]".format(outxlsx)) + + fig, ax = plt.subplots(figsize=(10, 7)) + if is_spin: + ax.plot(energy, dos_up, label='DOS up', color='tab:red', linewidth=1.0) + ax.plot(energy, -dos_down, label='DOS down', color='tab:blue', linewidth=1.0) + else: + label = 'total DOS' + if sigma > 0.0: + label += r', Gaussian $\sigma={}$ eV'.format(sigma) + ax.plot(energy, dos_up, label=label, color='black', linewidth=1.0) + ax.axvline(0.0, color='gray', linestyle='--', linewidth=0.8, label=r'$E_F$') + ax.set_xlim(Emin, Emax) + ax.set_xlabel(r'$E - E_F$ (eV)', fontsize=fontsize) + ax.set_ylabel('DOS (states/eV)', fontsize=fontsize) + ax.legend(fontsize=legend_fontsize) + ax.tick_params(labelsize=fontsize) + fig.tight_layout() + fig.savefig('dos.png', dpi=200) + print("Save DOS plot to [dos.png]") + + plt.pause(0.1) + print("") + print("Press ENTER to exit>>", end='') + input() + terminate("", usage=usage) + + def main(): global mode ... if mode == 'density' or mode == 'current': plot_epsilon(mode, CAR_dir, Emin, Emax) + elif mode == 'xas': + plot_xas(CAR_dir, Emin, Emax, sigma) + elif mode == 'dos': + plot_dos(CAR_dir, Emin, Emax, sigma) else: terminate("Error: Invalide mode [{}]".format(mode), usage = usage) ai/add_notes_voice_pptx.py: updated (updated on 2026/8/27) -import os -import sys -import re -import shutil -import argparse -import time -from pathlib import Path -import traceback - -try: - import tktts - from tktts import tkTTS - import pythoncom - import win32com.client -except ImportError as e: - print(f"Error: tktts/pythoncom/win32com.client のインポートエラー: {e}") - print("tktts.py が同一ディレクトリに存在し、win32com.client がインストールされていることを確認してください。") - sys.exit(1) - - -DEFAULT_ENGINE = "pyttsx3" -DEFAULT_VOICEVOX_ENDPOINT = "http://127.0.0.1:50021" -DEFAULT_AQUESTALK_PATH = "AquesTalkPlayer.exe" -DEFAULT_TEMP_DIR = "tts_temp_wavs_pptx" # 一時ファイルを格納するディレクトリ -DEFAULT_SPEAK_RATE = 150 # pyttsx3の読み上げ速度 (WPM) - -VOICE_MAPS = { - "pyttsx3": {"Zira": "Zira", "David": "David", "四国めたん": "Zira", "ずんだもん": "David", "れいむ": "Zira", "まりさ": "David"}, - "aquestalkplayer": {"四国めたん": "れいむ", "ずんだもん": "まりさ", "れいむ": "れいむ", "まりさ": "まりさ"}, - "openai": {"四国めたん": "nova", "ずんだもん": "shimmer", "れいむ": "alloy", "まりさ": "fable"}, -} - - -def terminate(pause = True): - """プログラムを終了する""" - if pause: input("\nPress ENTER to terminate>>\n") - exit() - -def initialize(): - # speak.py のオプションを可能な限り移植 - parser = argparse.ArgumentParser( - description="PPTXノートから音声ファイルを生成し、自動再生リンクを設定するプログラム (Windows/PowerPoint, tktts使用)", - formatter_class=argparse.RawTextHelpFormatter - ) - - parser.add_argument("--mode", choices=['list', 'map', 'conv'], help="実行モード:\n list: 利用可能な音声名を表示\n conv: 音声ファイルを生成しPPTXにリンク", default='conv') - parser.add_argument("--monologue", "-m", type=int, default=0, help="独話形式 (カンマのない行も読み込む)") - - parser.add_argument("--tts", choices=["pyttsx3", "voicevox", "aquestalkplayer", "atp", "openai"], default=DEFAULT_ENGINE, help="TTSエンジンを選択") - - parser.add_argument("--input_path", "-i", type=str, help="[convモード] ノートを追加する元のPPTXファイルパス") - parser.add_argument("--narration_txt", "-n", type=str, help="[convモード] ノート内容を含む発言テキストファイルパス (オプション: 既存のノートがない場合)") - 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("--replace", "-r", type=str, default="", help="[tktts] 文字列置換ルール (key=val;key=val)") - 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] TTS APIへの追加指示") - - parser.add_argument("--pause", type=int, default=1, help="プログラム終了時にENTER入力を要求するか [0|1]") - - - args = parser.parse_args() - return args - -def parse_narration_file(narration_path, monologue = True): - """発言テキストファイルからスライド番号とノート内容をパースする。""" - slide_texts = {} - current_slide = None - current_lines = [] - try: - with open(narration_path, "r", encoding="utf-8") as f: - for line in f: - line = line.rstrip("\n") - if line.strip() == "" or line.strip() == '---': continue - - m = re.match(r"#\s*Slide\s*(\d+)", line, re.IGNORECASE) - if m: - if current_slide is not None: - slide_texts[current_slide] = "\n".join(current_lines).strip() - current_slide = int(m.group(1)) - current_lines = [] - else: - if line.startswith('#'): continue - if line.startswith('(') and line.endswith(')'): continue -# if monologue: - if True: - current_lines.append(line) - else: - _aa = line.split(',', 1) - if len(_aa) == 2: - current_lines.append(_aa[1]) - else: - current_lines.append(line) - if current_slide is not None: - slide_texts[current_slide] = "\n".join(current_lines).strip() - except Exception as e: - print(f"❌ テキストファイルの読み込み/パースエラー: {e}") - return None - - print(f"✅ テキストファイルを解析しました: {len(slide_texts)} スライド分のノートを検出。") - return slide_texts - -def add_notes_to_pptx_com(pptx_path, slide_texts, temp_pptx_path): - # (既存の win32com.client を使用したノート書き込みロジック) - ppt = win32com.client.Dispatch("PowerPoint.Application") - try: - shutil.copyfile(pptx_path, temp_pptx_path) - except Exception as e: - print(f"エラー: コピー中にエラー: {e}") - ppt.Quit() - return False - - try: - full_path = os.path.abspath(temp_pptx_path) - pres = ppt.Presentations.Open(full_path) - except Exception as e: - print(f"エラー: PowerPointで一時ファイルを開けませんでした: {e}") - ppt.Quit() - return False - - print(" ノートを一時ファイルに書き込み中...") - success_count = 0 - total_slides = pres.Slides.Count - for idx in range(1, total_slides + 1): - if idx in slide_texts: - notes_text = slide_texts[idx] - slide = pres.Slides(idx) - notes_page = slide.NotesPage - for shp in notes_page.Shapes: - if shp.Type == 14 and shp.PlaceholderFormat.Type == 2: # 14: msoTextBox, 2: ppNotesPlaceholder - shp.TextFrame.TextRange.Text = notes_text - success_count += 1 - break - - # ループ内で解放 - shp = None - notes_page = None - slide = None - - #リソースを全て解放 - slide = None - shp = None - notes_page = None - - try: - pres.Save() - pythoncom.PumpWaitingMessages() - pres.Close() - ppt.Quit() - print(f"✅ ノートを一時PPTXファイルに書き込みました: {temp_pptx_path} ({success_count}件)") - return True - except Exception as e: - print(f"エラー: 一時ファイルへの保存中にエラー: {e}") - print(f"  無視して続行します") - ppt.Quit() - return True -# return False - -def link_audio_autoplay(source_pptx_path, audio_tasks, output_pptx): - # (既存の win32com.client を使用した音声リンク設定ロジック) - print("\n--- 🔗 PPTXに音声ファイルをリンクし、自動再生を設定中 ---") - try: - shutil.copyfile(source_pptx_path, output_pptx) - except Exception as e: - print(f"エラー: 出力ファイル {output_pptx} へのコピー中にエラー: {e}") - return - - ppt = win32com.client.Dispatch("PowerPoint.Application") - try: - pres = ppt.Presentations.Open(os.path.abspath(output_pptx)) - except Exception as e: - print(f"エラー: PowerPointで出力ファイルを開けませんでした: {e}") - ppt.Quit() - return - - for idx, wav_path in audio_tasks: - slide = pres.Slides(idx) - slide_width = pres.PageSetup.SlideWidth - slide_height = pres.PageSetup.SlideHeight - icon_size = 40 - left = slide_width - icon_size - 10 - top = slide_height - icon_size - 10 - - wav_path = os.path.abspath(wav_path) - print(f" 🔗 リンク追加: スライド {idx} ({wav_path[-40:]})") - if not os.path.exists(wav_path): +import os +import sys +import re +import shutil +import argparse +import time +from pathlib import Path +import traceback + +try: + import tktts + from tktts import tkTTS + import pythoncom + import win32com.client +except ImportError as e: + print(f"Error: tktts/pythoncom/win32com.client のインポートエラー: {e}") + print("tktts.py が同一ディレクトリに存在し、win32com.client がインストールされていることを確認してください。") + sys.exit(1) + + +DEFAULT_ENGINE = "pyttsx3" +DEFAULT_VOICEVOX_ENDPOINT = "http://127.0.0.1:50021" +DEFAULT_AQUESTALK_PATH = "AquesTalkPlayer.exe" +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"}, +} + + +def terminate(pause = True): + """プログラムを終了する""" + if pause: input("\nPress ENTER to terminate>>\n") + exit() + +def initialize(): + # speak.py のオプションを可能な限り移植 + parser = argparse.ArgumentParser( + description="PPTXノートから音声ファイルを生成し、自動再生リンクを設定するプログラム (Windows/PowerPoint, tktts使用)", + formatter_class=argparse.RawTextHelpFormatter + ) + + parser.add_argument("--mode", choices=['list', 'map', 'conv'], help="実行モード:\n list: 利用可能な音声名を表示\n conv: 音声ファイルを生成しPPTXにリンク", default='conv') + parser.add_argument("--monologue", "-m", type=int, default=0, help="独話形式 (カンマのない行も読み込む)") + + parser.add_argument("--tts", choices=["pyttsx3", "voicevox", "qwen3", "qwen", "irodori", "irodori-tts", "aquestalkplayer", "atp", "openai"], default=DEFAULT_ENGINE, help="TTSエンジンを選択") + + parser.add_argument("--input_path", "-i", type=str, help="[convモード] ノートを追加する元のPPTXファイルパス") + parser.add_argument("--narration_txt", "-n", type=str, help="[convモード] ノート内容を含む発言テキストファイルパス (オプション: 既存のノートがない場合)") + 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("--replace", "-r", type=str, default="", help="[tktts] 文字列置換ルール (key=val;key=val)") + 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] 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" + + # バックエンドでは空文字より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 parse_narration_file(narration_path, monologue = True): + """発言テキストファイルからスライド番号とノート内容をパースする。""" + slide_texts = {} + current_slide = None + current_lines = [] + try: + with open(narration_path, "r", encoding="utf-8") as f: + for line in f: + line = line.rstrip("\n") + if line.strip() == "" or line.strip() == '---': continue + + m = re.match(r"#\s*Slide\s*(\d+)", line, re.IGNORECASE) + if m: + if current_slide is not None: + slide_texts[current_slide] = "\n".join(current_lines).strip() + current_slide = int(m.group(1)) + current_lines = [] + else: + if line.startswith('#'): continue + if line.startswith('(') and line.endswith(')'): continue +# if monologue: + if True: + current_lines.append(line) + else: + _aa = line.split(',', 1) + if len(_aa) == 2: + current_lines.append(_aa[1]) + else: + current_lines.append(line) + if current_slide is not None: + slide_texts[current_slide] = "\n".join(current_lines).strip() + except Exception as e: + print(f"❌ テキストファイルの読み込み/パースエラー: {e}") + return None + + print(f"✅ テキストファイルを解析しました: {len(slide_texts)} スライド分のノートを検出。") + return slide_texts + +def add_notes_to_pptx_com(pptx_path, slide_texts, temp_pptx_path): + # (既存の win32com.client を使用したノート書き込みロジック) + ppt = win32com.client.Dispatch("PowerPoint.Application") + try: + shutil.copyfile(pptx_path, temp_pptx_path) + except Exception as e: + print(f"エラー: コピー中にエラー: {e}") + ppt.Quit() + return False + + try: + full_path = os.path.abspath(temp_pptx_path) + pres = ppt.Presentations.Open(full_path) + except Exception as e: + print(f"エラー: PowerPointで一時ファイルを開けませんでした: {e}") + ppt.Quit() + return False + + print(" ノートを一時ファイルに書き込み中...") + success_count = 0 + total_slides = pres.Slides.Count + for idx in range(1, total_slides + 1): + if idx in slide_texts: + notes_text = slide_texts[idx] + slide = pres.Slides(idx) + notes_page = slide.NotesPage + for shp in notes_page.Shapes: + if shp.Type == 14 and shp.PlaceholderFormat.Type == 2: # 14: msoTextBox, 2: ppNotesPlaceholder + shp.TextFrame.TextRange.Text = notes_text + success_count += 1 + break + + # ループ内で解放 + shp = None + notes_page = None + slide = None + + #リソースを全て解放 + slide = None + shp = None + notes_page = None + + try: + pres.Save() + pythoncom.PumpWaitingMessages() + pres.Close() + ppt.Quit() + print(f"✅ ノートを一時PPTXファイルに書き込みました: {temp_pptx_path} ({success_count}件)") + return True + except Exception as e: + print(f"エラー: 一時ファイルへの保存中にエラー: {e}") + print(f"  無視して続行します") + ppt.Quit() + return True +# return False + +def link_audio_autoplay(source_pptx_path, audio_tasks, output_pptx): + # (既存の win32com.client を使用した音声リンク設定ロジック) + print("\n--- 🔗 PPTXに音声ファイルをリンクし、自動再生を設定中 ---") + try: + shutil.copyfile(source_pptx_path, output_pptx) + except Exception as e: + print(f"エラー: 出力ファイル {output_pptx} へのコピー中にエラー: {e}") + return + + ppt = win32com.client.Dispatch("PowerPoint.Application") + try: + pres = ppt.Presentations.Open(os.path.abspath(output_pptx)) + except Exception as e: + print(f"エラー: PowerPointで出力ファイルを開けませんでした: {e}") + ppt.Quit() + return + + for idx, wav_path in audio_tasks: + slide = pres.Slides(idx) + slide_width = pres.PageSetup.SlideWidth + slide_height = pres.PageSetup.SlideHeight + icon_size = 40 + left = slide_width - icon_size - 10 + top = slide_height - icon_size - 10 + + wav_path = os.path.abspath(wav_path) + print(f" 🔗 リンク追加: スライド {idx} ({wav_path[-40:]})") + if not os.path.exists(wav_path): break - - shape = slide.Shapes.AddMediaObject2( - wav_path, - LinkToFile=True, - SaveWithDocument=False, - Left=left, Top=top, - Width=icon_size, Height=icon_size - ) - - # 自動再生設定 - play_settings = shape.AnimationSettings.PlaySettings - play_settings.PlayOnEntry = True - - # msoAnimTriggerWithPrevious = 2 でスライド遷移と同時に実行 - effect = slide.TimeLine.MainSequence.AddEffect( - shape, - 9, # msoAnimEffectMediaPlay - 0, # 第3引数 Level (ここでは0) - 2 # 第4引数 Trigger (msoAnimTriggerWithPrevious) - ) -# effect.Timing.Duration = 600.0 # 600.0秒 (10分) - - pres.Save() - try: - pres.Close() - except: - print("Warning: PowerPoiintオブジェクトのClose()に失敗しました") - pass - try: - ppt.Quit() - except: - print("Warning: PowerPoiintオブジェクトのQuit()に失敗しました") - pass - print(f"\n✅ PowerPoint更新完了: {output_pptx}") - - -def generate_audio_files_tktts(pptx_path, voice_map, args): - """ tktts のインターフェースを使用して、PPTXノートから音声ファイルを生成する。 """ - - tktts = tkTTS(tts_name = args.tts, config = args) - tts_engine = args.tts.lower() - output_dir = args.audio_dir - os.makedirs(output_dir, exist_ok=True) - - # 1. PPTXからノートを取得 - print("--- 📝 PPTXからノートを取得中 ---") - ppt = win32com.client.Dispatch("PowerPoint.Application") - full_path = os.path.abspath(pptx_path) - try: - pres = ppt.Presentations.Open(full_path) - except Exception as e: - print(f"エラー: PowerPointでファイルを開けませんでした: {e}") - ppt.Quit() - return [], "" - - dialogue_map = {} # {slide_idx: (None, notes_text)} - for idx in range(1, pres.Slides.Count + 1): -# print() -# print(f"Slide {idx} の音声を生成します:") - slide = pres.Slides(idx) - notes = "" - try: - for shp in slide.NotesPage.Shapes: - if shp.Type == 14 and shp.PlaceholderFormat.Type == 2: - notes = shp.TextFrame.TextRange.Text.strip() - break - except Exception: - pass - - if notes: - # tktts.speak_dialogue が期待する形式 (speaker=None, text) - dialogue_map[idx] = (None, notes) - - pres.Close() - ppt.Quit() - - if not dialogue_map: - print(" ⚠️ ノートが記載されたスライドが見つかりませんでした。") - return [], "" - - print(f" ✅ ノートを検出: {len(dialogue_map)} スライド") - - # 2. tktts.speak_dialogue のためのデータ準備 -# dialogue_list = list(dialogue_map.values()) # [(None, text1), (None, text2), ...] - replacements = {} - - # 3. エンジンモジュールを直接使用してスライドごとに音声を生成 - print(f"\n--- 🗣️ スライドごとに {tts_engine.upper()} で音声ファイルを生成中 ---") - - ext = "wav" -# ext = "mp3" - sorted_slide_indices = sorted(dialogue_map.keys()) - audio_tasks = [] - for slide_idx in sorted_slide_indices: - print() - print(f"スライド #{slide_idx} の音声ファイルを生成しています...") - - # スライドごとの読み上げテキストを (None, text) のタプルで渡す - speaker, notes = dialogue_map[slide_idx] - # 単一スライドの対話リスト - single_dialogue = [(speaker, notes)] - 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.exists(slide_outfile): - audio_tasks.append((slide_idx, os.path.abspath(generated_file))) - print(f" ✅ 生成完了: {os.path.basename(slide_outfile)} (スライド {slide_idx})") - else: - # エラー処理 - print(f" ❌ ファイルが見つかりません: スライド {slide_idx} での生成失敗") - - else: - print(f" ❌ 生成失敗: スライド {slide_idx}") - - except Exception as e: - print(f" ❌ TTSモジュール呼び出しエラー: スライド {slide_idx} - {e}") - traceback.print_exc() - - # 4. 一時ファイルのクリーンアップ (tktts 内部で削除されない場合を考慮) - if os.path.exists(args.temp_dir): - shutil.rmtree(args.temp_dir) - print(f"\n🗑️ 一時ディレクトリ {args.temp_dir} を削除しました。") - - - print(f"✅ 音声ファイル生成完了 ({len(audio_tasks)}件)") - return audio_tasks - - -def main(): - args = initialize() - -# endpoint, aquestalk_pathはargsで渡す - tktts = tkTTS(tts_name = args.tts, config = args) - - if args.mode == 'list': - 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 - narration_file = args.narration_txt - output_pptx = args.output_path - - if not all([source_pptx, narration_file]): - print("\nエラー: mapモードでは --input_path, --narration_txtの引数が必要です。") - print(f"現在の設定: input={source_pptx}, text={narration_file}") - terminate(args.pause) - - if args.mode == 'map': - tktts.show_voice_map(narration_file, args.voices, VOICE_MAPS = {}, is_monologue = args.monologue) - terminate(args.pause) - - if not all([source_pptx, narration_file, output_pptx]): - print("\nエラー: map/convモードでは --input_path, --narration_txt, --output_path の全ての引数が必要です。") - print(f"現在の設定: input={source_pptx}, text={narration_file}, output={output_pptx}") - terminate(args.pause) - - print("--- 💻 PowerPointナレーション作成プログラム (CONVモード) 開始 ---") - print(f" TTS engine : {args.tts}") - print(f" is monologue: {args.monologue}") - print(f" 入力PPTX : {source_pptx}") - print(f" 入力テキスト: {narration_file}") - print(f" 出力PPTX : {output_pptx}") - print(f" 音声フォルダ: {args.audio_dir}") - print("-" * 50) - - use_narration = narration_file and os.path.exists(narration_file) - temp_pptx_file = source_pptx - - # 1 & 2. テキストファイルがあればノートを追加 - current_voice_map = None - if use_narration: - print() - print(f"[{narration_file}]を解析します:") - dialogue = tktts.load_text(narration_file, args.monologue, wait_for_clipboard = False) - if not dialogue: - print("エラー: 有効なテキストデータが取得できませんでした。") - if not args.monologue: - print(" 対話形式でない場合は --monologue=1 オプションをつけてください。") - terminate(args.pause) - - speakers_in_file = tktts.get_speakers_from_dialogue(dialogue) - print(f" Speakers in [{narration_file}]") - for idx, sp in enumerate(speakers_in_file): - print(f" {idx:02d}: {sp}") - - current_voice_map = tktts.update_voice_map(voice_map = VOICE_MAPS, - voices = args.voices, speakers = speakers_in_file) - - print() - print(f"Voice map updated:") - for key, val in current_voice_map.items(): - if type(key) is str: - print(f" (speaker) {key}: (voice) {val}") - for key, val in current_voice_map.items(): - if type(key) is not str and type(key) is not int: - print(f" (speaker) {key}: (voice) {val}") - for key, val in current_voice_map.items(): - if type(key) is int: - print(f" (speaker) {key}: (voice) {val}") - - print("=== 検出された話者とvoice ===") - for s in sorted(speakers_in_file): - if s is None or s == "": - voice = current_voice_map.get(s, None) - if voice is None: voice = current_voice_map.get(0, None) - print(f" (独話): {voice}") - else: - s = tktts.normalize_speaker(s, args.tts) - print(f"- {s}: {current_voice_map.get(s, '未設定')}") - - # ノート用にナレーションファイルを読みこみ - slide_texts = parse_narration_file(narration_file, args.monologue) - if not slide_texts: terminate(args.pause) - - temp_pptx_file = "temp_notes_added.pptx" - ret = add_notes_to_pptx_com(source_pptx, slide_texts, temp_pptx_file) - if not ret: terminate(args.pause) - - # 3. ノートが書き込まれた一時ファイルから音声ファイルを生成 (tktts版) - # ノートが書き込まれたファイルからノートテキストを抽出して音声生成を行う - print() - print("音声ファイルを生成します:") - audio_tasks = generate_audio_files_tktts(temp_pptx_file, voice_map = current_voice_map, - args = args) - - # 4. 音声ファイルをリンクして出力PPTXを保存 - if audio_tasks: - print() - print(f"音声ファイルを[{temp_pptx_file}]にリンクします:") - link_audio_autoplay(temp_pptx_file, audio_tasks, output_pptx) - else: - print("\n完了: 音声ファイルが生成されなかったため、リンク処理をスキップしました。") - - # クリーンアップ - if use_narration and os.path.exists(temp_pptx_file) and temp_pptx_file != source_pptx: - print() - print(f"一時ファイル [{temp_pptx_file}] を削除します:") - os.remove(temp_pptx_file) - print(f" クリーンアップ完了") - - print("--- 🎉 プログラム終了 ---") - - terminate(args.pause) - -if __name__ == "__main__": - main() + + shape = slide.Shapes.AddMediaObject2( + wav_path, + LinkToFile=True, + SaveWithDocument=False, + Left=left, Top=top, + Width=icon_size, Height=icon_size + ) + + # 自動再生設定 + play_settings = shape.AnimationSettings.PlaySettings + play_settings.PlayOnEntry = True + + # msoAnimTriggerWithPrevious = 2 でスライド遷移と同時に実行 + effect = slide.TimeLine.MainSequence.AddEffect( + shape, + 9, # msoAnimEffectMediaPlay + 0, # 第3引数 Level (ここでは0) + 2 # 第4引数 Trigger (msoAnimTriggerWithPrevious) + ) +# effect.Timing.Duration = 600.0 # 600.0秒 (10分) + + pres.Save() + try: + pres.Close() + except: + print("Warning: PowerPoiintオブジェクトのClose()に失敗しました") + pass + try: + ppt.Quit() + except: + print("Warning: PowerPoiintオブジェクトのQuit()に失敗しました") + pass + print(f"\n✅ PowerPoint更新完了: {output_pptx}") + + +def generate_audio_files_tktts(pptx_path, voice_map, args): + """ tktts のインターフェースを使用して、PPTXノートから音声ファイルを生成する。 """ + + tktts = tkTTS(tts_name = args.tts, config = args) + tts_engine = args.tts.lower() + output_dir = args.audio_dir + os.makedirs(output_dir, exist_ok=True) + + # 1. PPTXからノートを取得 + print("--- 📝 PPTXからノートを取得中 ---") + ppt = win32com.client.Dispatch("PowerPoint.Application") + full_path = os.path.abspath(pptx_path) + try: + pres = ppt.Presentations.Open(full_path) + except Exception as e: + print(f"エラー: PowerPointでファイルを開けませんでした: {e}") + ppt.Quit() + return [], "" + + dialogue_map = {} # {slide_idx: (None, notes_text)} + for idx in range(1, pres.Slides.Count + 1): +# print() +# print(f"Slide {idx} の音声を生成します:") + slide = pres.Slides(idx) + notes = "" + try: + for shp in slide.NotesPage.Shapes: + if shp.Type == 14 and shp.PlaceholderFormat.Type == 2: + notes = shp.TextFrame.TextRange.Text.strip() + break + except Exception: + pass + + if notes: + # tktts.speak_dialogue が期待する形式 (speaker=None, text) + dialogue_map[idx] = (None, notes) + + pres.Close() + ppt.Quit() + + if not dialogue_map: + print(" ⚠️ ノートが記載されたスライドが見つかりませんでした。") + return [], "" + + print(f" ✅ ノートを検出: {len(dialogue_map)} スライド") + + # 2. tktts.speak_dialogue のためのデータ準備 +# dialogue_list = list(dialogue_map.values()) # [(None, text1), (None, text2), ...] + replacements = {} + + # 3. エンジンモジュールを直接使用してスライドごとに音声を生成 + 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) のタプルで渡す + speaker, notes = dialogue_map[slide_idx] + # 単一スライドの対話リスト + single_dialogue = [(speaker, notes)] + 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.exists(slide_outfile): + audio_tasks.append((slide_idx, os.path.abspath(generated_file))) + print(f" ✅ 生成完了: {os.path.basename(slide_outfile)} (スライド {slide_idx})") + else: + # エラー処理 + print(f" ❌ ファイルが見つかりません: スライド {slide_idx} での生成失敗") + + else: + print(f" ❌ 生成失敗: スライド {slide_idx}") + + except Exception as e: + print(f" ❌ 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): + shutil.rmtree(args.temp_dir) + print(f"\n🗑️ 一時ディレクトリ {args.temp_dir} を削除しました。") + + + print(f"✅ 音声ファイル生成完了 ({len(audio_tasks)}件)") + return audio_tasks + + +def main(): + args = initialize() + +# endpoint, aquestalk_pathはargsで渡す + tktts = tkTTS(tts_name = args.tts, config = args) + + if args.mode == 'list': + 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 + narration_file = args.narration_txt + output_pptx = args.output_path + + if not all([source_pptx, narration_file]): + print("\nエラー: mapモードでは --input_path, --narration_txtの引数が必要です。") + print(f"現在の設定: input={source_pptx}, text={narration_file}") + terminate(args.pause) + + if args.mode == 'map': + tktts.show_voice_map(narration_file, args.voices, VOICE_MAPS = {}, is_monologue = args.monologue) + terminate(args.pause) + + if not all([source_pptx, narration_file, output_pptx]): + print("\nエラー: map/convモードでは --input_path, --narration_txt, --output_path の全ての引数が必要です。") + print(f"現在の設定: input={source_pptx}, text={narration_file}, output={output_pptx}") + terminate(args.pause) + + print("--- 💻 PowerPointナレーション作成プログラム (CONVモード) 開始 ---") + print(f" TTS engine : {args.tts}") + print(f" is monologue: {args.monologue}") + print(f" 入力PPTX : {source_pptx}") + print(f" 入力テキスト: {narration_file}") + 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) + + use_narration = narration_file and os.path.exists(narration_file) + temp_pptx_file = source_pptx + + # 1 & 2. テキストファイルがあればノートを追加 + current_voice_map = None + if use_narration: + print() + print(f"[{narration_file}]を解析します:") + dialogue = tktts.load_text(narration_file, args.monologue, wait_for_clipboard = False) + if not dialogue: + print("エラー: 有効なテキストデータが取得できませんでした。") + if not args.monologue: + print(" 対話形式でない場合は --monologue=1 オプションをつけてください。") + terminate(args.pause) + + speakers_in_file = tktts.get_speakers_from_dialogue(dialogue) + print(f" Speakers in [{narration_file}]") + for idx, sp in enumerate(speakers_in_file): + print(f" {idx:02d}: {sp}") + + current_voice_map = tktts.update_voice_map(voice_map = VOICE_MAPS, + voices = args.voices, speakers = speakers_in_file) + + print() + print(f"Voice map updated:") + for key, val in current_voice_map.items(): + if type(key) is str: + print(f" (speaker) {key}: (voice) {val}") + for key, val in current_voice_map.items(): + if type(key) is not str and type(key) is not int: + print(f" (speaker) {key}: (voice) {val}") + for key, val in current_voice_map.items(): + if type(key) is int: + print(f" (speaker) {key}: (voice) {val}") + + print("=== 検出された話者とvoice ===") + for s in sorted(speakers_in_file): + if s is None or s == "": + voice = current_voice_map.get(s, None) + if voice is None: voice = current_voice_map.get(0, None) + print(f" (独話): {voice}") + else: + s = tktts.normalize_speaker(s, args.tts) + print(f"- {s}: {current_voice_map.get(s, '未設定')}") + + # ノート用にナレーションファイルを読みこみ + slide_texts = parse_narration_file(narration_file, args.monologue) + if not slide_texts: terminate(args.pause) + + temp_pptx_file = "temp_notes_added.pptx" + ret = add_notes_to_pptx_com(source_pptx, slide_texts, temp_pptx_file) + if not ret: terminate(args.pause) + + # 3. ノートが書き込まれた一時ファイルから音声ファイルを生成 (tktts版) + # ノートが書き込まれたファイルからノートテキストを抽出して音声生成を行う + print() + print("音声ファイルを生成します:") + audio_tasks = generate_audio_files_tktts(temp_pptx_file, voice_map = current_voice_map, + args = args) + + # 4. 音声ファイルをリンクして出力PPTXを保存 + if audio_tasks: + print() + print(f"音声ファイルを[{temp_pptx_file}]にリンクします:") + link_audio_autoplay(temp_pptx_file, audio_tasks, output_pptx) + else: + print("\n完了: 音声ファイルが生成されなかったため、リンク処理をスキップしました。") + + # クリーンアップ + if use_narration and os.path.exists(temp_pptx_file) and temp_pptx_file != source_pptx: + print() + print(f"一時ファイル [{temp_pptx_file}] を削除します:") + os.remove(temp_pptx_file) + print(f" クリーンアップ完了") + + print("--- 🎉 プログラム終了 ---") + + terminate(args.pause) + +if __name__ == "__main__": + main() ai/add_notes_voice_pptx20260617.py: created (new file updated on 2026/6/17) ai/ai_ocr2md20260409.py: deleted (old file updated on 2026/4/9) ai/explain_program5.py: updated (updated on 2026/8/25) from tkai_lib_litellm import read_ai_config, query_ai, extract_text except ImportError: - print("Error: tkai_litellm.py が見つかりません。パスを確認してください。", file=sys.stderr) + print("Error: tkai_lib_litellm.py が見つかりません。パスを確認してください。", file=sys.stderr) sys.exit(1) ai/list_gemini_models.py: deleted (old file updated on 2026/4/10) ai/list_openai_models.py: deleted (old file updated on 2026/4/10) ai/pptx2md_with_image.py: deleted (old file updated on 2026/6/10) ai/speak.py: updated (updated on 2026/8/23) -# このプログラムの実行には以下のライブラリと外部依存が必要です。 -# pip install chardet pyttsx3 openai pydub pyperclip -# -# 外部依存: -# - ffmpeg.exe: 環境PATHに設定するか、pydubに検出させる必要があります。 -# - AquesTalkPlayer.exe: Windowsでのみ使用可能。実行パスを指定する必要があります。 -# - OpenAI API Key: 環境変数 OPENAI_API_KEY に設定する必要があります。 - -import os -import sys -import argparse -import traceback - -missing = [] -for lib in ["chardet", "pyperclip", "tktts"]: - try: - __import__(lib) - except ImportError: -# missing.append(lib) - traceback.print_exc() - pass - -if missing: - print(f"Error: Missing libraries:\n{', '.join(missing)}") - print(" install: pip chardet") - input("\nPress ENTER to terminate>>\n") - sys.exit(1) - -import chardet -import pyperclip - -try: - import tktts - from tktts import tkTTS -except Exception as e: - print(f"\nWarning in tktts.py: Import error for tktts_pyttsx3") - print("------------------------------------------------------------------") - print(f"Error message: {e}") - print("Traceback:") - traceback.print_exc() - print("------------------------------------------------------------------") - - -DEFAULT_INPUT = "clip" -DEFAULT_ENGINE = "pyttsx3" -DEFAULT_VOICEVOX_ENDPOINT = "http://127.0.0.1:50021" -DEFAULT_AQUESTALK_PATH = "AquesTalkPlayer.exe" -DEFAULT_TEMP_DIR = "tts_temp_wavs" - -VOICE_MAPS = { - "pyttsx3": {"四国めたん": "Zira", "ずんだもん": "David", "れいむ": "Zira", "まりさ": "David"}, - "aquestalkplayer": {"四国めたん": "れいむ", "ずんだもん": "まりさ", "れいむ": "れいむ", "まりさ": "まりさ", "青山龍星": "青山龍星"}, - "openai": {"四国めたん": "nova", "ずんだもん": "shimmer", "れいむ": "alloy", "まりさ": "fable"}, -} - -pause = 0 - - -def terminate(): - if pause: - input("\nPress ENTER to terminate>>\n") - exit() - -def initialize(): - parser = argparse.ArgumentParser(description="統合TTS (pyttsx3, AquesTalkPlayer, OpenAI) CLIツール") - parser.add_argument("--tts", "-t", choices=["pyttsx3", "winrt", "voicevox", "aquestalkplayer", "atp", "openai"], default=DEFAULT_ENGINE, help="TTSエンジンを選択") - parser.add_argument("--endpoint", type=str, default=DEFAULT_VOICEVOX_ENDPOINT, help="VOICEVOX Engineのendpoint") -# parser.add_argument("--language", default = 'japanese', help="pyttsx3 で使用する言語") - parser.add_argument("--monologue", "-m", type=int, default=0, help="独話形式 (カンマのない行も読み込む)") - parser.add_argument("--voices", "-v", type=str, default="", help="voice_map の上書き (key=val;key=val)") - parser.add_argument("--replace", "-r", type=str, default="", help="文字列置換ルール (key=val;key=val)") - - parser.add_argument("--infile", "-i", type=str, default=DEFAULT_INPUT, help="入力元 ('clip' またはファイルパス)") - parser.add_argument("--outfile", "-o", type=str, default="", help="出力音声ファイル (未指定の場合、リアルタイム再生)") - - parser.add_argument("--temp_dir", type=str, default=DEFAULT_TEMP_DIR, help="一時ファイルを作成するディレクトリ名 (AquesTalkPlayer/OpenAI使用時)") - parser.add_argument("--list", action="store_true", help="利用可能な voices を表示して終了") - parser.add_argument("--map", action="store_true", help="voice map を表示して終了") - parser.add_argument("--pause", "-p", type=int, default=0, help="終了時に入力待ちする") - parser.add_argument("--wait_for_clipboard", type=int, default=1, help="Clipbordから適すつを取得する際に入力待ちする") - - parser.add_argument("--speak_rate", type=int, default=150, help="pyttsx3 の読み上げ速度 (Word Per Minute)") - 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)") - - parser.add_argument("--aquestalk_path", type=str, default=DEFAULT_AQUESTALK_PATH, help="AquesTalkPlayer.exe の実行パス (AquesTalkPlayer使用時)") - parser.add_argument("--tinterval", type=float, default=0.5, help="AquesTalkPlayer/OpenAIの音声ファイル間に挿入する無音区間の長さ(秒、デフォルト 0.5)") - - parser.add_argument("--instruction", type=str, default="", help="OpenAI TTS APIへの追加指示 (OpenAI使用時)") - - args = parser.parse_args() - return args - -def main(): - global pause - - print() - print(f"\n===== 統合TTS CLIツール speak.py =====") - - args = initialize() - pause = args.pause - if args.infile == "": args.infile = "clip" - - print(f"TTS engine : {args.tts}") - print(f"is monologue: {args.monologue}") - print(f"Input : {args.infile}") - print(f"Output : {args.outfile}") -# print(f"Language: {args.language}") - print(f"pyttsx3 speak_rate: {args.speak_rate}") - print(f"VOICEVOX speak_rate: {args.fspeak_rate}") - print(f"VOICEVOX speak_pitch: {args.fspeak_pitch}") - print(f"VOICEVOX Engine endpoint: {args.endpoint}") - print(f"wait_for_clipboard: {args.wait_for_clipboard}") - -# endpoint, aquestalk_pathはargsで渡す - tktts = tkTTS(tts_name = args.tts, config = args) - - if args.list: - print() - tktts.list_available_voices() - terminate() - - if args.map: - print() - tktts.show_voice_map(args.infile, args.voices, VOICE_MAPS, args.monologue) - terminate() - - print() - print(f"[{args.infile}]を解析します:") - dialogue = tktts.load_text(args.infile, args.monologue, wait_for_clipboard = args.wait_for_clipboard) - if not dialogue: - print("エラー: 有効なテキストデータが取得できませんでした。") - if not args.monologue: - print(" 対話形式でない場合は --monologue=1 オプションをつけてください。") - terminate() - - speakers_in_file = tktts.get_speakers_from_dialogue(dialogue) - print(f" Speakers in [{args.infile}]") - for idx, sp in enumerate(speakers_in_file): - print(f" {idx:02d}: {sp}") - - current_voice_map = tktts.update_voice_map(voice_map = VOICE_MAPS, - voices = args.voices, speakers = speakers_in_file) - - print() - print("=== 置換辞書 ===") - replacements = tktts.parse_kv_string(args.replace) - if replacements: - for key, val in replacements.items(): - print(f" {key}: {val}") - else: - print(" (なし)") - - print() - print(f"Voice map updated:") - for key, val in current_voice_map.items(): - if type(key) is str: - print(f" (speaker) {key}: (voice) {val}") - for key, val in current_voice_map.items(): - if type(key) is not str and type(key) is not int: - print(f" (speaker) {key}: (voice) {val}") - for key, val in current_voice_map.items(): - if type(key) is int: - print(f" (speaker) {key}: (voice) {val}") - - print("=== 検出された話者とvoice ===") - print(f"Voice map updated;", current_voice_map) - for s in sorted(speakers_in_file): - if s is None or s == "": - voice = current_voice_map.get(s, None) - if voice is None: voice = current_voice_map.get(0, None) - print(f" (独話): {voice}") - else: - s = tktts.normalize_speaker(s, args.tts) - print(f" (speaker) {s}: (voice) {current_voice_map.get(s, '未設定')}") - - print() - print("--- 読み上げ処理開始 ---") - ret = tktts.speak_dialogue( - config = args, dialogue = dialogue, - voice_map = current_voice_map, replacements = replacements) - if ret is None: - terminate() - - print("--- 処理完了 ---") - - -if __name__ == "__main__": - main() - terminate() +# このプログラムの実行には以下のライブラリと外部依存が必要です。 +# pip install chardet pyttsx3 openai pydub pyperclip +# +# 外部依存: +# - ffmpeg.exe: 環境PATHに設定するか、pydubに検出させる必要があります。 +# - AquesTalkPlayer.exe: Windowsでのみ使用可能。実行パスを指定する必要があります。 +# - OpenAI API Key: 環境変数 OPENAI_API_KEY に設定する必要があります。 + +import os +import sys +import argparse +import traceback + +missing = [] +for lib in ["chardet", "pyperclip", "tktts"]: + try: + __import__(lib) + except ImportError: +# missing.append(lib) + traceback.print_exc() + pass + +if missing: + print(f"Error: Missing libraries:\n{', '.join(missing)}") + print(" install: pip chardet") + input("\nPress ENTER to terminate>>\n") + sys.exit(1) + +import chardet +import pyperclip + +try: + import tktts + from tktts import tkTTS +except Exception as e: + print(f"\nWarning in tktts.py: Import error for tktts_pyttsx3") + print("------------------------------------------------------------------") + print(f"Error message: {e}") + print("Traceback:") + traceback.print_exc() + print("------------------------------------------------------------------") + + +DEFAULT_INPUT = "clip" +DEFAULT_ENGINE = "pyttsx3" +DEFAULT_VOICEVOX_ENDPOINT = "http://127.0.0.1:50021" +DEFAULT_AQUESTALK_PATH = "AquesTalkPlayer.exe" +DEFAULT_TEMP_DIR = "tts_temp_wavs" + +VOICE_MAPS = { + "pyttsx3": {"四国めたん": "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"}, +} + +pause = 0 + + +def terminate(): + if pause: + input("\nPress ENTER to terminate>>\n") + exit() + +def initialize(): + parser = argparse.ArgumentParser(description="統合TTS (pyttsx3, WinRT, VOICEVOX, Qwen3-TTS, Irodori-TTS, AquesTalkPlayer, OpenAI) CLIツール") + parser.add_argument("--tts", "-t", choices=["pyttsx3", "winrt", "voicevox", "qwen3", "qwen", "irodori", "irodori-tts", "aquestalkplayer", "atp", "openai"], default=DEFAULT_ENGINE, help="TTSエンジンを選択") + parser.add_argument("--endpoint", type=str, default=DEFAULT_VOICEVOX_ENDPOINT, help="VOICEVOX Engineのendpoint") +# parser.add_argument("--language", default = 'japanese', help="pyttsx3 で使用する言語") + parser.add_argument("--monologue", "-m", type=int, default=0, help="独話形式 (カンマのない行も読み込む)") + parser.add_argument("--voices", "-v", type=str, default="", help="voice_map の上書き (key=val;key=val)") + parser.add_argument("--replace", "-r", type=str, default="", help="文字列置換ルール (key=val;key=val)") + + parser.add_argument("--infile", "-i", type=str, default=DEFAULT_INPUT, help="入力元 ('clip' またはファイルパス)") + parser.add_argument("--outfile", "-o", type=str, default="", help="出力音声ファイル (未指定の場合、リアルタイム再生)") + + parser.add_argument("--temp_dir", type=str, default=DEFAULT_TEMP_DIR, help="一時ファイルを作成するディレクトリ名 (AquesTalkPlayer/OpenAI使用時)") + parser.add_argument("--list", action="store_true", help="利用可能な voices を表示して終了") + parser.add_argument("--map", action="store_true", help="voice map を表示して終了") + parser.add_argument("--pause", "-p", type=int, default=0, help="終了時に入力待ちする") + parser.add_argument("--wait_for_clipboard", type=int, default=1, help="Clipbordから適すつを取得する際に入力待ちする") + + parser.add_argument("--speak_rate", type=int, default=150, help="pyttsx3 の読み上げ速度 (Word Per Minute)") + 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)") + + parser.add_argument("--aquestalk_path", type=str, default=DEFAULT_AQUESTALK_PATH, help="AquesTalkPlayer.exe の実行パス (AquesTalkPlayer使用時)") + parser.add_argument("--tinterval", type=float, default=0.5, help="AquesTalkPlayer/OpenAIの音声ファイル間に挿入する無音区間の長さ(秒、デフォルト 0.5)") + + # Qwen3-TTS + parser.add_argument("--qwen3_language", type=str, default="Japanese", help="Qwen3-TTS language (default: Japanese)") + 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 device: 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への追加指示") + + # 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 device: 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 codec device (未指定ならmodel device)") + parser.add_argument("--irodori_codec_precision", type=str, default=None, help="Irodori codec precision (未指定ならmodel 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, help="Irodori-TTS CFG scale for text") + parser.add_argument("--irodori_cfg_scale_caption", type=float, default=3.0, help="Irodori-TTS CFG scale for caption") + parser.add_argument("--irodori_cfg_scale_speaker", type=float, default=5.0, help="Irodori-TTS CFG scale for speaker/reference") + parser.add_argument("--irodori_duration_scale", type=float, default=1.0, help="Irodori-TTS duration scale") + parser.add_argument("--irodori_seed", type=str, default="0", help="Irodori-TTS seed; none/randomでランダム") + parser.add_argument("--irodori_lora_adapter", type=str, default="", help="Irodori-TTS LoRA adapter path") + + parser.add_argument("--instruction", type=str, default="", help="OpenAI TTS APIへの追加指示 (OpenAI使用時)") + + args = parser.parse_args() + + # 独話でvoice指定が空の場合の既定値 + if not args.voices: + if args.tts in ("qwen3", "qwen"): + args.voices = "Ono_Anna" + elif args.tts in ("irodori", "irodori-tts"): + args.voices = "default" + + # 空文字はバックエンド側では未指定として扱う + 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 + if args.qwen3_instruct == "": + args.qwen3_instruct = None + + return args + +def main(): + global pause + + print() + print(f"\n===== 統合TTS CLIツール speak.py =====") + + args = initialize() + pause = args.pause + if args.infile == "": args.infile = "clip" + + print(f"TTS engine : {args.tts}") + print(f"is monologue: {args.monologue}") + print(f"Input : {args.infile}") + print(f"Output : {args.outfile}") +# print(f"Language: {args.language}") + print(f"pyttsx3 speak_rate: {args.speak_rate}") + print(f"VOICEVOX speak_rate: {args.fspeak_rate}") + print(f"VOICEVOX speak_pitch: {args.fspeak_pitch}") + print(f"VOICEVOX Engine endpoint: {args.endpoint}") + print(f"wait_for_clipboard: {args.wait_for_clipboard}") + + if args.tts in ("qwen3", "qwen"): + print(f"Qwen3 model : {args.qwen3_model_id}") + print(f"Qwen3 device: {args.qwen3_device}") + print(f"Qwen3 dtype : {args.qwen3_dtype}") + print(f"Qwen3 lang : {args.qwen3_language}") + print(f"Qwen3 voice : {args.voices}") + print(f"Qwen3 instruct: {args.qwen3_instruct}") + + if args.tts 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 caption : {args.irodori_caption}") + print(f"Irodori ref_wav : {args.irodori_ref_wav}") + print(f"Irodori ref_wavs : {args.irodori_ref_wavs}") + print(f"Irodori steps : {args.irodori_num_steps}") + print(f"Irodori duration : {args.irodori_duration_scale}") + print(f"Irodori seed : {args.irodori_seed}") + +# endpoint, aquestalk_pathはargsで渡す + tktts = tkTTS(tts_name = args.tts, config = args) + + if args.list: + print() + tktts.list_available_voices() + terminate() + + if args.map: + print() + tktts.show_voice_map(args.infile, args.voices, VOICE_MAPS, args.monologue) + terminate() + + print() + print(f"[{args.infile}]を解析します:") + dialogue = tktts.load_text(args.infile, args.monologue, wait_for_clipboard = args.wait_for_clipboard) + if not dialogue: + print("エラー: 有効なテキストデータが取得できませんでした。") + if not args.monologue: + print(" 対話形式でない場合は --monologue=1 オプションをつけてください。") + terminate() + + speakers_in_file = tktts.get_speakers_from_dialogue(dialogue) + print(f" Speakers in [{args.infile}]") + for idx, sp in enumerate(speakers_in_file): + print(f" {idx:02d}: {sp}") + + current_voice_map = tktts.update_voice_map(voice_map = VOICE_MAPS, + voices = args.voices, speakers = speakers_in_file) + + print() + print("=== 置換辞書 ===") + replacements = tktts.parse_kv_string(args.replace) + if replacements: + for key, val in replacements.items(): + print(f" {key}: {val}") + else: + print(" (なし)") + + print() + print(f"Voice map updated:") + for key, val in current_voice_map.items(): + if type(key) is str: + print(f" (speaker) {key}: (voice) {val}") + for key, val in current_voice_map.items(): + if type(key) is not str and type(key) is not int: + print(f" (speaker) {key}: (voice) {val}") + for key, val in current_voice_map.items(): + if type(key) is int: + print(f" (speaker) {key}: (voice) {val}") + + print("=== 検出された話者とvoice ===") + print(f"Voice map updated;", current_voice_map) + for s in sorted(speakers_in_file): + if s is None or s == "": + voice = current_voice_map.get(s, None) + if voice is None: voice = current_voice_map.get(0, None) + print(f" (独話): {voice}") + else: + s = tktts.normalize_speaker(s, args.tts) + print(f" (speaker) {s}: (voice) {current_voice_map.get(s, '未設定')}") + + print() + print("--- 読み上げ処理開始 ---") + ret = tktts.speak_dialogue( + config = args, dialogue = dialogue, + voice_map = current_voice_map, replacements = replacements) + if ret is None: + terminate() + + print("--- 処理完了 ---") + + +if __name__ == "__main__": + main() + terminate() ai/speak20260624.py: created (new file updated on 2026/6/24) ai/speak_qt_async.py: updated (updated on 2026/8/23) -import sys -import os -import traceback -import shutil -import re -import uuid -import glob -import time -from typing import List, Optional, Dict, Tuple, Any - -try: - import chardet -except ImportError: - print("Error: Missing library: chardet. Please run 'pip install chardet'") - sys.exit(1) - -try: - import tktts - from pydub import AudioSegment -except ImportError: - print("Error: Missing libraries: tktts or pydub. Please run 'pip install tktts pydub'") - sys.exit(1) - - -from PySide6.QtWidgets import ( - QApplication, QWidget, QVBoxLayout, QHBoxLayout, - QTextEdit, QLineEdit, QPushButton, QFileDialog, - QSlider, QDoubleSpinBox, QComboBox, QLabel, QMessageBox, - QProgressBar, QGridLayout, QFrame, QTabWidget, QSizePolicy -) -from PySide6.QtCore import Qt, QUrl, QThread, Signal, Slot, QRect, QDir -from PySide6.QtMultimedia import QMediaPlayer, QAudioOutput, QMediaDevices - - -# --- 定数とヘルパー関数(tkttsで利用される引数構造を維持) --- -DEFAULT_ENGINE = "pyttsx3" -DEFAULT_VOICEVOX_ENDPOINT = "http://127.0.0.1:50021" -DEFAULT_AQUESTALK_PATH = "AquesTalkPlayer.exe" -DEFAULT_TEMP_DIR = "tts_temp_wavs" -TEMP_WAV_PREFIX = "_tktts_tmp_" -TEMP_WAV_EXT = ".wav" -INI_FILE_NAME = os.path.splitext(__file__)[0] + ".ini" - - -def detect_encoding(file_path): - """ファイルの文字コードを判定して開く""" - with open(file_path, 'rb') as f: - raw_data = f.read() - result = chardet.detect(raw_data) - return result['encoding'] - -# グローバルな置換辞書とタイムスタンプキャッシュ -_GLOBAL_REPLACE_DICT_CACHE = {} -_GLOBAL_TIMESTAMP_CACHE = {} - -def load_replace_dict(ini_path, force_reload=False): - """ - replace.iniを読み込んで辞書を作成(TOML風の簡易実装)。 - タイムスタンプをチェックし、更新がなければキャッシュを使用。 - """ - if not ini_path or not os.path.isfile(ini_path): - return {} - - try: - current_timestamp = os.path.getmtime(ini_path) - except OSError: - # ファイルが存在しない、またはアクセス権がない場合 - return {} - - # キャッシュチェック - if not force_reload and ini_path in _GLOBAL_REPLACE_DICT_CACHE and \ - _GLOBAL_TIMESTAMP_CACHE.get(ini_path) == current_timestamp: - return _GLOBAL_REPLACE_DICT_CACHE[ini_path] - - # ファイルの読み込みとパース - replace_dict = {} - try: - encoding = detect_encoding(ini_path) - if encoding is None: encoding = 'utf-8' - with open(ini_path, 'r', encoding=encoding) as f: - for line in f: - line = line.rstrip('\n') - if line.startswith('#') or '=' not in line: - continue - - # キーと値を抽出する正規表現(キーはクォートあり/なしに対応) - match = re.match(r"""^(['"].+?['"]|[^=]+?)=(.*)$""", line) - if not match: - continue - - raw_key, val = match.groups() - # キーからクォートを除去 - key = raw_key[1:-1] if (raw_key.startswith("'") and raw_key.endswith("'")) or (raw_key.startswith('"') and raw_key.endswith('"')) else raw_key.strip() - replace_dict[key] = val.strip() - - # キャッシュを更新 - _GLOBAL_REPLACE_DICT_CACHE[ini_path] = replace_dict - _GLOBAL_TIMESTAMP_CACHE[ini_path] = current_timestamp - print(f"Status: Loaded/Reloaded INI file: {os.path.basename(ini_path)}") - return replace_dict - - except Exception as e: - print(f" [skip] Failed to read/parse {ini_path}: {e}") - return {} - -def apply_replacements(text, replace_dict: Dict[str, str]): - """テキストに対して正規表現による置換を適用""" - - replace_list = list(replace_dict.items()) - - for pattern, replacement in replace_list: - try: - # re.IGNORECASE (大文字小文字無視) と re.MULTILINE (複数行モード) を適用 - text = re.sub(pattern, replacement, text, flags=re.IGNORECASE | re.MULTILINE) - except Exception as e: - print(f"re.sub error for [{pattern}]: {e}") - return text - - -class ArgsStub: - """tkttsのヘルパー関数に引数を渡すためのスタブクラス""" - def __init__(self, **kwargs): - for k, v in kwargs.items(): - setattr(self, k, v) - - -class MyTTSWorker(QThread): - """音声生成を非同期で実行するワーカークラス (tkttsの内部ロジックを直接実行)""" - finished = Signal(str) - error = Signal(str) - progress = Signal(int) - - def __init__(self, text, outfile, tts_engine, speed_rate, pitch, instruction, voice_name, tmp_files, - aquestalk_path, temp_dir, voicevox_endpoint, parent=None): - super().__init__(parent) - self.text = text - self.user_outfile = outfile if outfile and outfile.strip() else None - - self.tts_engine = tts_engine.lower() - self.speed_rate = speed_rate - self.pitch = pitch - self.instruction = instruction - self.voice_name = voice_name - self.tmp_files = tmp_files - - self.aquestalk_path = aquestalk_path - self.temp_dir = temp_dir - self.voicevox_endpoint = voicevox_endpoint - - # tkttsの引数スタブを更新 - self.tktts_args = ArgsStub( - tts=self.tts_engine, - monologue=1, - voices=self.voice_name, - speak_rate=150, - fspeak_rate=self.speed_rate, - fspeak_pitch=self.pitch, - tinterval=0.5, - temp_dir=self.temp_dir, - outfile="", - instruction=self.instruction, - aquestalk_path=self.aquestalk_path, - endpoint=self.voicevox_endpoint, - elevenlabs_api_key=os.getenv("ELEVENLABS_API_KEY"), - ) - - def run(self): - """tktts.py の統一インターフェースで音声を非同期生成する。""" - - self.progress.emit(10) - - try: - config = tktts.TTS_ENGINES.get(self.tts_engine) - if config is None: - self.error.emit( - f"TTSエンジン [{self.tts_engine}] の設定が見つかりません。" - ) - return - - if tktts.get_tts(self.tts_engine) is None: - self.error.emit( - f"TTSエンジン [{self.tts_engine}] のロードに失敗しました。" - ) - return - - lines = self.text.strip().split("\n") - dialogue = [(None, line.strip()) for line in lines if line.strip()] - if not dialogue: - self.error.emit("読み上げるテキストがありません。") - return - - temp_dir = tktts.create_temp_dir(self.temp_dir) - - if self.user_outfile: - output_path = os.path.abspath(self.user_outfile) - else: - # GUIでの再生互換性を優先し、一時出力は常にWAVにする。 - temp_filebody = TEMP_WAV_PREFIX + uuid.uuid4().hex[:8] - output_path = os.path.abspath( - os.path.join(temp_dir, temp_filebody + "_merged.wav") - ) - - output_dir = os.path.dirname(output_path) - if output_dir: - os.makedirs(output_dir, exist_ok=True) - - self.tktts_args.outfile = output_path - self.tktts_args.endpoint = self.voicevox_endpoint - self.tktts_args.elevenlabs_api_key = os.getenv("ELEVENLABS_API_KEY") - - print(f"Status: {self.tts_engine.upper()}の音声生成開始...") - # 音声名を文字列のまま渡すと、WinRTバックエンドでspeaker名として - # 正規化され、空白を含む ``Microsoft Ayumi ...`` が切れることがある。 - # 値として保持する話者マップにして渡す。 - selected_voice_map = { - None: self.voice_name, - "": self.voice_name, - 0: self.voice_name, - } - - result = tktts.speak_dialogue( - self.tktts_args, - dialogue, - voice_map=selected_voice_map, - replacements={}, - endpoint=self.voicevox_endpoint, - api_key=self.tktts_args.elevenlabs_api_key, - output_format=None, # 出力パスの拡張子から tktts.py が判定 - ) - - if not result: - self.error.emit( - f"TTSエンジン [{self.tts_engine}] での音声生成に失敗しました。" - ) - return - - generated_path = result if isinstance(result, str) else output_path - if not os.path.isfile(generated_path): - self.error.emit(f"生成された音声ファイルが見つかりません: {generated_path}") - return - - self.progress.emit(100) - self.finished.emit(generated_path) - - if not self.user_outfile: - self.tmp_files.append(generated_path) - - except Exception as e: - error_msg = f"音声生成またはファイル操作エラー: {type(e).__name__}: {e}" - print(error_msg) - traceback.print_exc() - self.error.emit(error_msg) - - -class MyTTSApp(QWidget): - def __init__(self): - super().__init__() - self.setWindowTitle("統合TTS GUI (コンパクト・リサイズ可能)") - - # メディア関連の初期化 - self.worker_thread: Optional[MyTTSWorker] = None - self.audio_output = QAudioOutput(QMediaDevices.defaultAudioOutput()) - self.player: QMediaPlayer = QMediaPlayer() - self.player.setAudioOutput(self.audio_output) - self.current_audio_path: Optional[str] = None - - # 状態管理 - self.tmp_files = [] - self.slide_data: Dict[int, str] = {} - self.last_dir: Dict[str, str] = {} - self.is_converted_text_dirty: bool = True - - self._load_settings() - - # シグナルとスロットの接続 - self.player.durationChanged.connect(self.set_slider_range) - self.player.positionChanged.connect(self.update_slider) - self.player.playbackStateChanged.connect(self.update_playback_buttons) - self.player.errorOccurred.connect(self.handle_media_player_error) - - self.initUI() - self.setGeometry(self.settings.get('geometry', QRect(100, 100, 800, 650))) - self.setMinimumSize(400, 450) - - # TTS設定UIの変更を監視し、ダーティフラグを立てる - self.text_input_converted.textChanged.connect(self.set_dirty) - self.speed_spin.valueChanged.connect(self.set_dirty) - self.pitch_spin.valueChanged.connect(self.set_dirty) - self.instruction_line.textChanged.connect(self.set_dirty) - self.engine_combo.currentIndexChanged.connect(self.update_voice_list) # update_voice_list内でもset_dirtyを呼ぶ - self.voice_combo.currentIndexChanged.connect(self.set_dirty) # ボイス変更時 - - self.update_voice_list() - - QApplication.instance().aboutToQuit.connect(self.cleanup_temp_files) - QApplication.instance().aboutToQuit.connect(self._save_settings) - - - # --- 状態管理 --- - @Slot() - def set_dirty(self): - """TTS出力に影響する設定が変更されたとき、フラグを立てる""" - self.is_converted_text_dirty = True - if self.player.playbackState() != QMediaPlayer.PlaybackState.PlayingState: - self.update_playback_buttons(self.player.playbackState()) - - def _load_settings(self): - # 設定のロードロジックは省略せずに残す - default_settings = { - 'geometry': QRect(100, 100, 800, 650), - 'temp_dir': DEFAULT_TEMP_DIR, - 'aquestalk_path': DEFAULT_AQUESTALK_PATH, - 'voicevox_endpoint': DEFAULT_VOICEVOX_ENDPOINT, - 'input_file': "input.md", - 'replace_file': "replace.ini", - 'replace_file2': "user_replace.ini", - 'output_file': "", - } - self.settings: Dict[str, Any] = default_settings.copy() - - if not os.path.exists(INI_FILE_NAME): - return - - try: - with open(INI_FILE_NAME, 'r', encoding='utf-8') as f: - content = f.read() - current_section = None - for line in content.splitlines(): - line = line.strip() - if not line or line.startswith('#'): - continue - if line.startswith('[') and line.endswith(']'): - current_section = line[1:-1].strip() - elif '=' in line: - key, value = line.split('=', 1) - key = key.strip() - value = value.strip().strip('"') - - if current_section == "window": - if key == "x": self.settings['x'] = int(value) - elif key == "y": self.settings['y'] = int(value) - elif key == "width": self.settings['width'] = int(value) - elif key == "height": self.settings['height'] = int(value) - elif current_section == "tts_paths": - if key in default_settings: self.settings[key] = value - - if 'x' in self.settings: - self.settings['geometry'] = QRect( - self.settings['x'], self.settings['y'], - self.settings.get('width', 800), self.settings.get('height', 650) - ) - except Exception as e: - print(f"設定ファイル読み込みエラー: {e}") - self.settings = default_settings.copy() - - - def _save_settings(self): - # 設定保存ロジックは省略せずに残す - geom = self.geometry() - - current_settings = { - 'temp_dir': self.temp_dir_line.text(), - 'aquestalk_path': self.aquestalk_path_line.text(), - 'voicevox_endpoint': self.voicevox_endpoint_line.text(), - 'input_file': self.input_file_line.text(), - 'replace_file': self.replace_ini_line.text(), - 'replace_file2': self.replace_ini2_line.text(), - 'output_file': self.output_line.text(), - } - - content = ( - f'[window]\n' - f'x = {geom.x()}\n' - f'y = {geom.y()}\n' - f'width = {geom.width()}\n' - f'height = {geom.height()}\n' - f'\n' - f'[tts_paths]\n' - f'temp_dir = "{current_settings["temp_dir"]}"\n' - f'aquestalk_path = "{current_settings["aquestalk_path"]}"\n' - f'voicevox_endpoint = "{current_settings["voicevox_endpoint"]}"\n' - f'input_file = "{current_settings["input_file"]}"\n' - f'replace_file = "{current_settings["replace_file"]}"\n' - f'replace_file2 = "{current_settings["replace_file2"]}"\n' - f'output_file = "{current_settings["output_file"]}"\n' - ) - - try: - with open(INI_FILE_NAME, 'w', encoding='utf-8') as f: - f.write(content) - except Exception as e: - print(f"設定ファイル保存エラー: {e}") - - - def cleanup_temp_files(self): - # クリーンアップロジックは省略せずに残す - self.handle_stop() - self.player.setSource(QUrl()) - if self.worker_thread and self.worker_thread.isRunning(): - self.worker_thread.quit() - self.worker_thread.wait() - - for f in self.tmp_files: - try: - if os.path.isfile(f): - os.remove(f) - except Exception as e: - print(f"一時ファイル削除エラー: {f}: {e}") - - temp_dir = self.settings.get('temp_dir', DEFAULT_TEMP_DIR) - try: - if os.path.exists(temp_dir): - search_pattern = os.path.join(temp_dir, f"{TEMP_WAV_PREFIX}*") - for file_path in glob.glob(search_pattern): - try: - if os.path.isfile(file_path): - os.remove(file_path) - except: - pass - if not os.listdir(temp_dir): - os.rmdir(temp_dir) - except Exception as e: - print(f"一時ディレクトリのクリーンアップエラー: {e}") - - - # --- UI初期化 --- - def initUI(self): - main_layout = QVBoxLayout() - self.tabs = QTabWidget() - - # --- TTS設定ウィジェットの事前初期化 --- - # これらのウィジェットはMain/Configタブ間で共有されるため、先に初期化する - self.engine_combo = QComboBox(self) - self.engine_combo.addItems([ - "pyttsx3", - "winrt", - "voicevox", - "openai", - "elevenlabs", - "aquestalkplayer", - ]) - self.engine_combo.setCurrentText(DEFAULT_ENGINE) - self.voice_combo = QComboBox(self) - self.speed_spin = QDoubleSpinBox(self) - self.speed_spin.setRange(0.1, 5.0) - self.speed_spin.setSingleStep(0.1) - self.speed_spin.setValue(1.0) - self.pitch_spin = QDoubleSpinBox(self) - self.pitch_spin.setRange(-10.0, 10.0) - self.pitch_spin.setSingleStep(0.1) - self.pitch_spin.setValue(0.0) - self.instruction_line = QLineEdit() - self.instruction_line.setPlaceholderText("Optional text for OpenAI instruction...") - - # --- Tab 1: Main Content (メイン操作) --- - main_page = QWidget() - main_page_layout = QVBoxLayout(main_page) - - # 1. ファイル設定 - file_slide_layout = QGridLayout() - # Input File - file_slide_layout.addWidget(QLabel("Input File (infile):"), 0, 0) - self.input_file_line = QLineEdit(self.settings.get('input_file')) - file_slide_layout.addWidget(self.input_file_line, 0, 1) - self.input_file_btn = QPushButton("Path") - self.input_file_btn.clicked.connect(self.select_input_file) - file_slide_layout.addWidget(self.input_file_btn, 0, 2) - # Replace INI 1 (Default) - file_slide_layout.addWidget(QLabel("Replace INI (Default):"), 1, 0) - self.replace_ini_line = QLineEdit(self.settings.get('replace_file')) - file_slide_layout.addWidget(self.replace_ini_line, 1, 1) - self.replace_ini_btn = QPushButton("Path") - self.replace_ini_btn.clicked.connect(lambda: self.select_ini_file(self.replace_ini_line, 'replace_file')) - file_slide_layout.addWidget(self.replace_ini_btn, 1, 2) - # Replace INI 2 (User) - file_slide_layout.addWidget(QLabel("Replace INI (User):"), 2, 0) - self.replace_ini2_line = QLineEdit(self.settings.get('replace_file2')) - file_slide_layout.addWidget(self.replace_ini2_line, 2, 1) - self.replace_ini2_btn = QPushButton("Path") - self.replace_ini2_btn.clicked.connect(lambda: self.select_ini_file(self.replace_ini2_line, 'replace_file2')) - file_slide_layout.addWidget(self.replace_ini2_btn, 2, 2) - # Output File - file_slide_layout.addWidget(QLabel("出力ファイル (wav/mp3):"), 3, 0) - self.output_line = QLineEdit(self) - self.output_line.setPlaceholderText("未指定の場合、一時ファイルを作成して再生します (推奨)") - self.output_line.setText(self.settings.get('output_file', '')) - file_slide_layout.addWidget(self.output_line, 3, 1) - self.output_btn = QPushButton("Path") - self.output_btn.clicked.connect(self.select_output_file) - file_slide_layout.addWidget(self.output_btn, 3, 2) - main_page_layout.addLayout(file_slide_layout) - - # Slide Page Pulldown (位置変更) - slide_page_layout = QHBoxLayout() - slide_page_layout.addWidget(QLabel("Slide Page:")) - self.slide_page_combo = QComboBox(self) - self.slide_page_combo.addItem("1. No file loaded") - self.slide_page_combo.setCurrentIndex(0) - self.slide_page_combo.currentIndexChanged.connect(self.on_slide_page_changed) - slide_page_layout.addWidget(self.slide_page_combo) - main_page_layout.addLayout(slide_page_layout) - - # 4. テキスト入力エリア (2分割 & 拡張可能に) - text_layout = QVBoxLayout() - - # Original Text - text_layout.addWidget(QLabel("読み上げテキスト (Original):")) - self.text_input_original = QTextEdit(self) - self.text_input_original.setPlaceholderText("入力ファイルの内容(スライドページ)がここに表示されます。") - self.text_input_original.setMinimumHeight(80) - self.text_input_original.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding) - text_layout.addWidget(self.text_input_original) - - # Converted Text - text_layout.addWidget(QLabel("読み上げテキスト (Converted):")) - self.text_input_converted = QTextEdit(self) - self.text_input_converted.setPlaceholderText("置換ルール適用後のテキストがここに表示されます。Play/Generateボタンはこのテキストを読み上げます。") - self.text_input_converted.setMinimumHeight(80) - self.text_input_converted.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding) - text_layout.addWidget(self.text_input_converted) - main_page_layout.addLayout(text_layout) - - # 5. コントロール (Convertedテキストの直下に配置) - control_layout = QHBoxLayout() - - # Convert Button - self.convert_btn = QPushButton("⚙️ Convert (Apply Rules)") - self.convert_btn.clicked.connect(self.handle_convert) - self.convert_btn.setStyleSheet("font-weight: bold; padding: 5px;") - control_layout.addWidget(self.convert_btn) - - # 再生/生成ボタン - self.play_btn = QPushButton("▶ Play/Generate") - self.generate_btn = QPushButton("⚡ Generate (Force)") - self.pause_btn = QPushButton("⏸ Pause") - self.stop_btn = QPushButton("■ Stop") - - self.play_btn.clicked.connect(lambda: self.handle_play(force_generate=False)) - self.generate_btn.clicked.connect(lambda: self.handle_play(force_generate=True)) # 強制生成 - self.pause_btn.clicked.connect(self.handle_pause) - self.stop_btn.clicked.connect(self.handle_stop) - - control_layout.addWidget(self.play_btn) - control_layout.addWidget(self.generate_btn) - control_layout.addWidget(self.pause_btn) - control_layout.addWidget(self.stop_btn) - - main_page_layout.addLayout(control_layout) - - - # --- Tab 2: Config (パス設定) --- - config_page = QWidget() - config_page_layout = QVBoxLayout(config_page) - - # アプリケーションパス設定 - app_path_layout = QGridLayout() - # AquesTalk Path - app_path_layout.addWidget(QLabel("AquesTalk Path:"), 0, 0) - self.aquestalk_path_line = QLineEdit(self.settings.get('aquestalk_path', DEFAULT_AQUESTALK_PATH)) - app_path_layout.addWidget(self.aquestalk_path_line, 0, 1) - self.aquestalk_path_btn = QPushButton("Path") - self.aquestalk_path_btn.clicked.connect(self.select_aquestalk_path) - app_path_layout.addWidget(self.aquestalk_path_btn, 0, 2) - # Voicevox Endpoint - app_path_layout.addWidget(QLabel("Voicevox Endpoint:"), 1, 0) - self.voicevox_endpoint_line = QLineEdit(self.settings.get('voicevox_endpoint', DEFAULT_VOICEVOX_ENDPOINT)) - app_path_layout.addWidget(self.voicevox_endpoint_line, 1, 1, 1, 2) - # Temp Dir - app_path_layout.addWidget(QLabel("Temp Dir:"), 2, 0) - self.temp_dir_line = QLineEdit(self.settings.get('temp_dir', DEFAULT_TEMP_DIR)) - app_path_layout.addWidget(self.temp_dir_line, 2, 1, 1, 2) - - config_page_layout.addLayout(app_path_layout) - - # TTS設定(Configタブに配置) - tts_settings_layout_config = QGridLayout() - tts_settings_layout_config.addWidget(QLabel("Engine:"), 3, 0) - tts_settings_layout_config.addWidget(self.engine_combo, 3, 1) - tts_settings_layout_config.addWidget(QLabel("Voice:"), 3, 2) - tts_settings_layout_config.addWidget(self.voice_combo, 3, 3) - tts_settings_layout_config.addWidget(QLabel("Speed (fspeak_rate):"), 4, 0) - tts_settings_layout_config.addWidget(self.speed_spin, 4, 1) - tts_settings_layout_config.addWidget(QLabel("Pitch (ピッチ):"), 4, 2) - tts_settings_layout_config.addWidget(self.pitch_spin, 4, 3) - tts_settings_layout_config.addWidget(QLabel("Instruction (OpenAI):"), 5, 0) - tts_settings_layout_config.addWidget(self.instruction_line, 5, 1, 1, 3) - - config_page_layout.addLayout(tts_settings_layout_config) - config_page_layout.addStretch(1) # 残りのスペースを埋める - - # Tab Widgetに追加 - self.tabs.addTab(main_page, "Main") - self.tabs.addTab(config_page, "Config") - main_layout.addWidget(self.tabs) - - # --- Tabの外の共通コントロール --- - - # 6. プログレスバーと再生位置を1行に統合 - progress_slider_layout = QHBoxLayout() - progress_slider_layout.addWidget(QLabel("Prog/Pos:")) - - # プログレスバー - self.progress_bar = QProgressBar(self) - self.progress_bar.setRange(0, 100) - self.progress_bar.setValue(0) - self.progress_bar.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Preferred) - progress_slider_layout.addWidget(self.progress_bar) - - # 再生位置スライダー - self.position_slider = QSlider(Qt.Orientation.Horizontal) - self.position_slider.setRange(0, 0) - self.position_slider.setTracking(False) - self.position_slider.sliderMoved.connect(self.seek_position) - self.position_slider.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Preferred) - progress_slider_layout.addWidget(self.position_slider) - - main_layout.addLayout(progress_slider_layout) - - # 7. ステータスラベル - self.status_label = QLabel("Status: Ready") - main_layout.addWidget(self.status_label) - - self.setLayout(main_layout) - self.update_playback_buttons(self.player.playbackState()) - - self.text_input_original.setText("TTSアプリへようこそ!\nInput Fileを選択すると、内容がここに表示され、Convertボタンで置換が適用されます。") - self.text_input_converted.setText("Play/Generateボタンを押すと、このconvertedテキストが読み上げられます。") - - - # --- ファイル選択/スロット群 --- - - def _get_initial_dir(self, path_line: QLineEdit, key: str) -> str: - """QLineEditの内容または記憶されたディレクトリを元に初期ディレクトリを返す""" - current_path = path_line.text() - if os.path.isfile(current_path): - dir_name = os.path.dirname(current_path) - self.last_dir[key] = dir_name - return dir_name - elif os.path.isdir(current_path): - self.last_dir[key] = current_path - return current_path - elif key in self.last_dir and os.path.isdir(self.last_dir[key]): - return self.last_dir[key] - return QDir.currentPath() - - @Slot() - def select_input_file(self): - """Input Fileを選択し、ファイルを読み込み、スライドをパースする""" - key = 'input_file' - initial_dir = self._get_initial_dir(self.input_file_line, key) - file_path, _ = QFileDialog.getOpenFileName( - self, - "入力テキストファイルの選択", - initial_dir, - "テキストファイル (*.txt *.md);;全てのファイル (*)" - ) - if file_path: - self.input_file_line.setText(file_path) - self.last_dir[key] = os.path.dirname(file_path) - self.load_input_file(file_path) - - @Slot() - def select_output_file(self): - """出力音声ファイルを選択し、パスを更新する""" - key = 'output_file' - initial_dir = self._get_initial_dir(self.output_line, key) - file_path, _ = QFileDialog.getSaveFileName( - self, - "出力音声ファイルの選択", - initial_dir, - "Audio Files (*.wav *.mp3);;Wave Files (*.wav);;MP3 Files (*.mp3);;All Files (*)" - ) - if file_path: - root, ext = os.path.splitext(file_path) - if not ext: - engine = self.engine_combo.currentText().lower() - default_ext = ".mp3" if engine in ("openai", "elevenlabs", "eleven") else ".wav" - file_path += default_ext - self.output_line.setText(file_path) - self.last_dir[key] = os.path.dirname(file_path) - - @Slot() - def select_aquestalk_path(self): - """AquesTalkPlayer.exeのファイルパスを選択するダイアログを開く""" - key = 'aquestalk_path' - initial_dir = self._get_initial_dir(self.aquestalk_path_line, key) - file_path, _ = QFileDialog.getOpenFileName( - self, - "AquesTalkPlayer.exeの選択", - initial_dir, - "実行ファイル (*.exe);;全てのファイル (*)" - ) - if file_path: - self.aquestalk_path_line.setText(file_path) - self.last_dir[key] = os.path.dirname(file_path) - - @Slot(QLineEdit, str) - def select_ini_file(self, line_edit: QLineEdit, key: str): - """INIファイルを選択するダイアログを開く""" - initial_dir = self._get_initial_dir(line_edit, key) - file_path, _ = QFileDialog.getOpenFileName( - self, - "INIファイル(置換ルール)の選択", - initial_dir, - "INIファイル (*.ini);;全てのファイル (*)" - ) - if file_path: - line_edit.setText(file_path) - self.last_dir[key] = os.path.dirname(file_path) - - def load_input_file(self, file_path: str): - self.slide_data = {} - self.current_infile_path = file_path - - try: - encoding = detect_encoding(file_path) - if encoding is None: encoding = 'utf-8' - with open(file_path, 'r', encoding=encoding) as f: - full_text = f.read() - - self.slide_data[0] = full_text.strip() - current_slide_number = 1 - - # スライド区切りパターン: `# Slide` で始まる行、または `*1, *2` などの行 - slide_separator_pattern = re.compile(r"^\s*(#\s*Slide.*|\*\d+)\s*$", re.IGNORECASE | re.MULTILINE) - - parts = slide_separator_pattern.split(full_text) - - if len(parts) > 1: - - # parts[0]は最初の区切りより前のテキスト - if parts[0].strip(): - self.slide_data[1] = parts[0].strip() - current_slide_number = 2 - - # parts[1]以降は区切りと区切りの間のテキスト - for part in parts[1:]: - part_content = part.strip() - # 区切りパターンにマッチしたテキスト(空か、区切り文字自体)はスキップ - if part_content and not slide_separator_pattern.match(part_content): - self.slide_data[current_slide_number] = part_content - current_slide_number += 1 - - # スライドが分割された場合は、改めてページ0(全文)を再構築 - self.slide_data[0] = full_text.strip() - - - # 3. Slide pageプルダウンを更新 - self.slide_page_combo.clear() - - is_slide_parsed = len(self.slide_data) > 1 - - self.slide_page_combo.addItem("0. (All Document)") - if is_slide_parsed: - for i in sorted([k for k in self.slide_data.keys() if k > 0]): - self.slide_page_combo.addItem(f"{i}. Slide {i}") - self.slide_page_combo.setEnabled(True) - else: - self.slide_page_combo.setEnabled(False) - - self.slide_page_combo.setCurrentIndex(0) - - except Exception as e: - QMessageBox.critical(self, "ファイル読み込みエラー", f"ファイルの読み込みに失敗しました: {e}") - self.slide_page_combo.clear() - self.slide_page_combo.addItem("1. No file loaded") - self.slide_page_combo.setEnabled(False) - self.text_input_original.setText("") - self.text_input_converted.setText("") - - @Slot(int) - def on_slide_page_changed(self, index): - page_key = index - - if page_key in self.slide_data: - original_text = self.slide_data[page_key] - - # Convertedテキストの変更を一時的に無効化 - self.text_input_converted.textChanged.disconnect(self.set_dirty) - - self.text_input_original.setText(original_text) - self.text_input_converted.setText("") - self.is_converted_text_dirty = True # ページが変わったので変換が必要 - - # Convertedテキストの変更監視を再開 - self.text_input_converted.textChanged.connect(self.set_dirty) - - self.status_label.setText(f"Status: Page {page_key} loaded. Ready to Convert.") - else: - self.text_input_original.setText("") - self.text_input_converted.setText("") - self.status_label.setText("Status: Error - Page content missing.") - - @Slot() - def handle_convert(self): - """Convertボタンが押されたとき、置換処理を実行する""" - original_text = self.text_input_original.toPlainText() - if not original_text.strip(): - QMessageBox.warning(self, "Convertエラー", "Originalテキストが空です。ファイルを読み込むか、テキストを入力してください。") - return - - default_ini_path = self.replace_ini_line.text() - user_ini_path = self.replace_ini2_line.text() - - self.status_label.setText("Status: Loading/Checking replacement rules...") - QApplication.processEvents() - - # タイムスタンプチェックと再読み込み (force_reload=True) - dict2 = load_replace_dict(user_ini_path, force_reload=True) - dict1 = load_replace_dict(default_ini_path, force_reload=True) - - dict1_filtered = {k: v for k, v in dict1.items() if k not in dict2} - - final_replace_dict = {} - final_replace_dict.update(dict1_filtered) - final_replace_dict.update(dict2) - - if not final_replace_dict: - QMessageBox.information(self, "Convert情報", "有効な置換ルールがINIファイルから見つかりませんでした。") - self.text_input_converted.setText(original_text) - - # Convertedテキストの変更を一時的に無効化 - self.text_input_converted.textChanged.disconnect(self.set_dirty) - self.is_converted_text_dirty = False - self.text_input_converted.textChanged.connect(self.set_dirty) - - self.status_label.setText("Status: No rules applied. Converted = Original.") - return - - self.status_label.setText("Status: Applying replacement rules...") - QApplication.processEvents() - - replaced_text = apply_replacements(original_text, final_replace_dict) - - # 修正: `# Slide...` および `*N` 形式のマーカー行を完全に削除 - # 1. `# Slide...` 行の削除(行頭/行末に空白があっても良い) - replaced_text = re.sub(r"^\s*#\s*Slide.*$", "\n", replaced_text, flags=re.IGNORECASE | re.MULTILINE) - - # 2. `*N` (スライド番号) 行の削除(行頭/行末に空白があっても良い) - replaced_text = re.sub(r"^\s*\*\d+\s*$", "\n", replaced_text, flags=re.IGNORECASE | re.MULTILINE) - - # 空行のみの行を削除(連続する空行を一つにまとめる) - replaced_text = re.sub(r'\n\s*\n', '\n\n', replaced_text).strip() - - # Convertedテキストの変更を一時的に無効化してから更新 - self.text_input_converted.textChanged.disconnect(self.set_dirty) - self.text_input_converted.setText(replaced_text) - self.is_converted_text_dirty = False # 変換完了 - self.text_input_converted.textChanged.connect(self.set_dirty) - - self.status_label.setText("Status: Conversion complete. Ready to Play.") - - - def handle_play(self, force_generate: bool = False): - """ - Play/Generateボタンの動作。 - force_generate=True の場合は、ダーティフラグにかかわらず強制的に生成。 - """ - - # 1. 既に再生中の場合は無視 - if self.player.playbackState() == QMediaPlayer.PlaybackState.PlayingState: - return - - # 2. ポーズ状態からの再開 - if self.player.playbackState() == QMediaPlayer.PlaybackState.PausedState: - self.player.play() - return - - # 3. 強制生成が不要かつダーティでない場合 -> 再生 - is_ready_to_play = ( - not force_generate and - not self.is_converted_text_dirty and - self.current_audio_path and - os.path.exists(self.current_audio_path) - ) - - if is_ready_to_play: - self.status_label.setText(f"Status: Playing existing audio: {os.path.basename(self.current_audio_path)}") - try: - audio_url = QUrl.fromLocalFile(self.current_audio_path) - self.player.stop() - self.player.setSource(audio_url) - self.player.play() - except Exception as e: - QMessageBox.critical(self, "再生エラー", f"既存の音声ファイルの再生に失敗しました: {e}") - self.current_audio_path = None - return - - # 4. 生成が必要な場合 (ダーティ or ファイルがない or 強制生成) - - if self.worker_thread and self.worker_thread.isRunning(): - QMessageBox.warning(self, "処理中", "現在、音声生成が実行中です。完了をお待ちください。") - return - - text = self.text_input_converted.toPlainText() - outfile = self.output_line.text().strip() - tts_engine = self.engine_combo.currentText() - speed_rate = self.speed_spin.value() - voice_name = self.voice_combo.currentText() - pitch = self.pitch_spin.value() - instruction = self.instruction_line.text() - - aquestalk_path = self.aquestalk_path_line.text().strip() - temp_dir = self.temp_dir_line.text().strip() - voicevox_endpoint = self.voicevox_endpoint_line.text().strip() - - if not text.strip(): - QMessageBox.warning(self, "入力エラー", "読み上げテキスト (Converted) が空です。") - return - - if not voice_name or "No voices found" in voice_name or "Error loading voices" in voice_name: - QMessageBox.warning(self, "ボイス選択エラー", "有効なボイスが選択されていません。エンジンを確認してください。") - return - - self.status_label.setText("Status: 音声生成を開始しました... (非同期実行中)") - self.update_playback_buttons(QMediaPlayer.PlaybackState.StoppedState, is_generating=True) - self.progress_bar.setValue(0) - - self.worker_thread = MyTTSWorker( - text, outfile, tts_engine, speed_rate, pitch, instruction, voice_name, self.tmp_files, - aquestalk_path, temp_dir, voicevox_endpoint, - ) - self.worker_thread.finished.connect(self.on_synthesis_finished) - self.worker_thread.error.connect(self.on_synthesis_error) - self.worker_thread.progress.connect(self.progress_bar.setValue) - self.worker_thread.start() - - - @Slot(str) - def on_synthesis_finished(self, file_path): - self.current_audio_path = file_path - self.is_converted_text_dirty = False - self.status_label.setText(f"Status: 音声生成が完了しました: {os.path.basename(file_path)}") - self.progress_bar.setValue(100) - - self.update_playback_buttons(QMediaPlayer.PlaybackState.StoppedState, is_generating=False) - - if not file_path or not os.path.exists(file_path): - self.on_synthesis_error(f"音声ファイルが見つかりません: {file_path}") - return - - try: - audio_url = QUrl.fromLocalFile(file_path) - self.player.stop() - self.player.setSource(audio_url) - self.player.play() - except Exception as e: - self.on_synthesis_error(f"音声再生の開始に失敗しました: {e}") - - @Slot(str) - def on_synthesis_error(self, message): - self.status_label.setText("Status: エラー発生") - self.progress_bar.setValue(0) - QMessageBox.critical(self, "エラー", message) - self.current_audio_path = None - - self.update_playback_buttons(QMediaPlayer.PlaybackState.StoppedState, is_generating=False) - if self.worker_thread: - self.worker_thread.quit() - self.worker_thread.wait() - - @Slot() - def update_voice_list(self): - """エンジンに応じて音声一覧と有効な設定項目を更新する。""" - - engine = self.engine_combo.currentText() - engine_lower = engine.lower() - self.voice_combo.clear() - - is_winrt = engine_lower in ("winrt", "onecore") - is_voicevox = engine_lower == "voicevox" - is_openai = engine_lower == "openai" - is_elevenlabs = engine_lower in ("elevenlabs", "eleven") - is_aqt = engine_lower in ("aquestalkplayer", "atp") - - # Pitchは現在 VoiceVox / AquesTalkPlayer のみ。 - self.pitch_spin.setEnabled(is_voicevox or is_aqt) - # Speedは pyttsx3 / WinRT / ElevenLabsを含め、全エンジンで表示する。 - self.speed_spin.setEnabled(True) - self.instruction_line.setEnabled(is_openai) - - if hasattr(self, "voicevox_endpoint_line"): - self.voicevox_endpoint_line.setEnabled(is_voicevox) - self.aquestalk_path_line.setEnabled(is_aqt) - - endpoint = None - if is_voicevox and hasattr(self, "voicevox_endpoint_line"): - endpoint = self.voicevox_endpoint_line.text().strip() - - api_key = os.getenv("ELEVENLABS_API_KEY") if is_elevenlabs else None - - try: - voices = tktts.get_available_voices( - engine_lower, - endpoint=endpoint, - api_key=api_key, - ) - - if voices: - self.voice_combo.addItems([str(v) for v in voices]) - - default_voice = None - if engine_lower == "pyttsx3": - default_voice = getattr(tktts, "default_pyttsx3_voice", None) - elif is_winrt: - default_voice = getattr(tktts, "default_winrt_voice", None) - elif is_openai: - default_voice = getattr( - tktts, - "default_openai_voice", - getattr(tktts, "default_optnai_voice", None), - ) - elif is_elevenlabs: - default_voice = getattr(tktts, "default_elevenlabs_voice", None) - elif is_voicevox: - default_voice = getattr(tktts, "default_voicevox_voice", None) - elif is_aqt: - default_voice = getattr(tktts, "default_aqt_preset", None) - - if default_voice and default_voice in voices: - self.voice_combo.setCurrentText(default_voice) - else: - self.voice_combo.setCurrentIndex(0) - - self.status_label.setText( - f"Status: {engine} voices loaded ({len(voices)})." - ) - else: - if is_elevenlabs and not api_key: - self.voice_combo.addItem("ELEVENLABS_API_KEY is not set") - else: - self.voice_combo.addItem(f"No voices found for {engine}") - - except Exception as e: - error_msg = f"Error loading voices for {engine}: {e}" - self.voice_combo.addItem(error_msg) - print(error_msg) - traceback.print_exc() - - # エンジン変更または音声一覧の再取得は、必ず再生成対象にする。 - self.set_dirty() - - def update_playback_buttons(self, state: QMediaPlayer.PlaybackState, is_generating: Optional[bool] = None): - if is_generating is None: - is_worker_running = self.worker_thread and self.worker_thread.isRunning() - else: - is_worker_running = is_generating - - self.convert_btn.setEnabled(not is_worker_running) - self.generate_btn.setEnabled(not is_worker_running) - - if is_worker_running: - self.play_btn.setText("▶ Generating...") - self.play_btn.setEnabled(False) - self.pause_btn.setEnabled(False) - self.stop_btn.setEnabled(False) - return - - if state == QMediaPlayer.PlaybackState.PlayingState: - self.play_btn.setText("▶ Playing...") - self.play_btn.setEnabled(False) - self.pause_btn.setEnabled(True) - self.stop_btn.setEnabled(True) - elif state == QMediaPlayer.PlaybackState.PausedState: - self.play_btn.setText("▶ Resume") - self.play_btn.setEnabled(True) - self.pause_btn.setEnabled(False) - self.stop_btn.setEnabled(True) - elif state == QMediaPlayer.PlaybackState.StoppedState: - if self.is_converted_text_dirty: - self.play_btn.setText("▶ Generate (Text changed)") - elif self.current_audio_path: - self.play_btn.setText("▶ Play (Existing)") - else: - self.play_btn.setText("▶ Play/Generate") - - self.play_btn.setEnabled(True) - self.pause_btn.setEnabled(False) - self.stop_btn.setEnabled(False) - if not is_worker_running and "Status: エラー" not in self.status_label.text(): - if self.current_audio_path and not self.is_converted_text_dirty: - self.status_label.setText("Status: Generated (Ready to play)") - else: - self.status_label.setText("Status: Ready") - - # --- メディア制御系は変更なし --- - def handle_pause(self): - if self.player.playbackState() == QMediaPlayer.PlaybackState.PlayingState: - self.player.pause() - - def handle_stop(self): - self.player.stop() - self.position_slider.setValue(0) - self.status_label.setText("Status: 停止") - self.update_playback_buttons(QMediaPlayer.PlaybackState.StoppedState) - - def handle_media_player_error(self, error: QMediaPlayer.Error, error_string: str): - if error != QMediaPlayer.Error.NoError: - print(f"--- QMediaPlayer Error ---") - print(f"Code: {error.name}, Message: {error_string}") - print(f"--------------------------") - self.status_label.setText(f"Status: 再生エラー発生 ({error_string})") - self.player.stop() - self.update_playback_buttons(QMediaPlayer.PlaybackState.StoppedState) - - def set_slider_range(self, duration): - self.position_slider.setRange(0, duration) - - def update_slider(self, position): - self.position_slider.setValue(position) - total_duration = self.player.duration() - if total_duration > 0: - current_sec = position // 1000 - total_sec = total_duration // 1000 - self.status_label.setText(f"Status: Playing ({current_sec} sec / {total_sec} sec)") - - def seek_position(self, position): - self.player.setPosition(position) - - -if __name__ == '__main__': - app = QApplication(sys.argv) - ex = MyTTSApp() - ex.show() +import sys +import os +import traceback +import shutil +import re +import uuid +import glob +import time +from typing import List, Optional, Dict, Tuple, Any + +try: + import chardet +except ImportError: + print("Error: Missing library: chardet. Please run 'pip install chardet'") + sys.exit(1) + +try: + import tktts + from pydub import AudioSegment +except ImportError: + print("Error: Missing libraries: tktts or pydub. Please run 'pip install tktts pydub'") + sys.exit(1) + + +from PySide6.QtWidgets import ( + QApplication, QWidget, QVBoxLayout, QHBoxLayout, + QTextEdit, QLineEdit, QPushButton, QFileDialog, + QSlider, QDoubleSpinBox, QComboBox, QLabel, QMessageBox, + QProgressBar, QGridLayout, QFrame, QTabWidget, QSizePolicy +) +from PySide6.QtCore import Qt, QUrl, QThread, Signal, Slot, QRect, QDir +from PySide6.QtMultimedia import QMediaPlayer, QAudioOutput, QMediaDevices + + +# --- 定数とヘルパー関数(tkttsで利用される引数構造を維持) --- +DEFAULT_ENGINE = "pyttsx3" +DEFAULT_VOICEVOX_ENDPOINT = "http://127.0.0.1:50021" +DEFAULT_AQUESTALK_PATH = "AquesTalkPlayer.exe" +DEFAULT_TEMP_DIR = "tts_temp_wavs" +TEMP_WAV_PREFIX = "_tktts_tmp_" +TEMP_WAV_EXT = ".wav" +INI_FILE_NAME = os.path.splitext(__file__)[0] + ".ini" + + +def detect_encoding(file_path): + """ファイルの文字コードを判定して開く""" + with open(file_path, 'rb') as f: + raw_data = f.read() + result = chardet.detect(raw_data) + return result['encoding'] + +# グローバルな置換辞書とタイムスタンプキャッシュ +_GLOBAL_REPLACE_DICT_CACHE = {} +_GLOBAL_TIMESTAMP_CACHE = {} + +def load_replace_dict(ini_path, force_reload=False): + """ + replace.iniを読み込んで辞書を作成(TOML風の簡易実装)。 + タイムスタンプをチェックし、更新がなければキャッシュを使用。 + """ + if not ini_path or not os.path.isfile(ini_path): + return {} + + try: + current_timestamp = os.path.getmtime(ini_path) + except OSError: + # ファイルが存在しない、またはアクセス権がない場合 + return {} + + # キャッシュチェック + if not force_reload and ini_path in _GLOBAL_REPLACE_DICT_CACHE and \ + _GLOBAL_TIMESTAMP_CACHE.get(ini_path) == current_timestamp: + return _GLOBAL_REPLACE_DICT_CACHE[ini_path] + + # ファイルの読み込みとパース + replace_dict = {} + try: + encoding = detect_encoding(ini_path) + if encoding is None: encoding = 'utf-8' + with open(ini_path, 'r', encoding=encoding) as f: + for line in f: + line = line.rstrip('\n') + if line.startswith('#') or '=' not in line: + continue + + # キーと値を抽出する正規表現(キーはクォートあり/なしに対応) + match = re.match(r"""^(['"].+?['"]|[^=]+?)=(.*)$""", line) + if not match: + continue + + raw_key, val = match.groups() + # キーからクォートを除去 + key = raw_key[1:-1] if (raw_key.startswith("'") and raw_key.endswith("'")) or (raw_key.startswith('"') and raw_key.endswith('"')) else raw_key.strip() + replace_dict[key] = val.strip() + + # キャッシュを更新 + _GLOBAL_REPLACE_DICT_CACHE[ini_path] = replace_dict + _GLOBAL_TIMESTAMP_CACHE[ini_path] = current_timestamp + print(f"Status: Loaded/Reloaded INI file: {os.path.basename(ini_path)}") + return replace_dict + + except Exception as e: + print(f" [skip] Failed to read/parse {ini_path}: {e}") + return {} + +def apply_replacements(text, replace_dict: Dict[str, str]): + """テキストに対して正規表現による置換を適用""" + + replace_list = list(replace_dict.items()) + + for pattern, replacement in replace_list: + try: + # re.IGNORECASE (大文字小文字無視) と re.MULTILINE (複数行モード) を適用 + text = re.sub(pattern, replacement, text, flags=re.IGNORECASE | re.MULTILINE) + except Exception as e: + print(f"re.sub error for [{pattern}]: {e}") + return text + + +class ArgsStub: + """tkttsのヘルパー関数に引数を渡すためのスタブクラス""" + def __init__(self, **kwargs): + for k, v in kwargs.items(): + setattr(self, k, v) + + +class MyTTSWorker(QThread): + """音声生成を非同期で実行するワーカークラス (tkttsの内部ロジックを直接実行)""" + finished = Signal(str) + error = Signal(str) + progress = Signal(int) + + def __init__(self, text, outfile, tts_engine, speed_rate, pitch, instruction, voice_name, tmp_files, + aquestalk_path, temp_dir, voicevox_endpoint, + qwen3_language="Japanese", + qwen3_model_id="Qwen/Qwen3-TTS-12Hz-0.6B-CustomVoice", + qwen3_device="auto", + qwen3_dtype="auto", + qwen3_instruct="", + irodori_caption="落ち着いた自然な声で、明瞭に読み上げる。", + irodori_ref_wav="", + irodori_model_id="Aratako/Irodori-TTS-v4.1-Small", + irodori_device="auto", + irodori_precision="auto", + irodori_num_steps=40, + irodori_duration_scale=1.0, + irodori_seed="0", + parent=None): + super().__init__(parent) + self.text = text + self.user_outfile = outfile if outfile and outfile.strip() else None + + self.tts_engine = tts_engine.lower() + self.speed_rate = speed_rate + self.pitch = pitch + self.instruction = instruction + self.voice_name = voice_name + self.tmp_files = tmp_files + + self.aquestalk_path = aquestalk_path + self.temp_dir = temp_dir + self.voicevox_endpoint = voicevox_endpoint + + self.qwen3_language = qwen3_language + self.qwen3_model_id = qwen3_model_id + self.qwen3_device = qwen3_device + self.qwen3_dtype = qwen3_dtype + self.qwen3_instruct = qwen3_instruct + + self.irodori_caption = irodori_caption + self.irodori_ref_wav = irodori_ref_wav or None + self.irodori_model_id = irodori_model_id + self.irodori_device = irodori_device + self.irodori_precision = irodori_precision + self.irodori_num_steps = irodori_num_steps + self.irodori_duration_scale = irodori_duration_scale + self.irodori_seed = irodori_seed + + # tkttsの引数スタブを更新 + self.tktts_args = ArgsStub( + tts=self.tts_engine, + monologue=1, + voices=self.voice_name, + speak_rate=150, + fspeak_rate=self.speed_rate, + fspeak_pitch=self.pitch, + tinterval=0.5, + temp_dir=self.temp_dir, + outfile="", + instruction=self.instruction, + aquestalk_path=self.aquestalk_path, + endpoint=self.voicevox_endpoint, + elevenlabs_api_key=os.getenv("ELEVENLABS_API_KEY"), + + # Qwen3-TTS + qwen3_language=self.qwen3_language, + qwen3_model_id=self.qwen3_model_id, + qwen3_device=self.qwen3_device, + qwen3_dtype=self.qwen3_dtype, + qwen3_instruct=(self.qwen3_instruct or None), + + # Irodori-TTS + irodori_caption=self.irodori_caption, + irodori_ref_wav=self.irodori_ref_wav, + irodori_ref_wavs=None, + irodori_model_id=self.irodori_model_id, + irodori_device=self.irodori_device, + irodori_precision=self.irodori_precision, + irodori_codec_device=None, + irodori_codec_precision=None, + irodori_num_steps=self.irodori_num_steps, + irodori_cfg_scale_text=3.5, + irodori_cfg_scale_caption=3.0, + irodori_cfg_scale_speaker=5.0, + irodori_duration_scale=self.irodori_duration_scale, + irodori_seed=self.irodori_seed, + irodori_lora_adapter=None, + ) + + def run(self): + """tktts.py の統一インターフェースで音声を非同期生成する。""" + + self.progress.emit(10) + + try: + config = tktts.TTS_ENGINES.get(self.tts_engine) + if config is None: + self.error.emit( + f"TTSエンジン [{self.tts_engine}] の設定が見つかりません。" + ) + return + + if tktts.get_tts(self.tts_engine) is None: + self.error.emit( + f"TTSエンジン [{self.tts_engine}] のロードに失敗しました。" + ) + return + + lines = self.text.strip().split("\n") + dialogue = [(None, line.strip()) for line in lines if line.strip()] + if not dialogue: + self.error.emit("読み上げるテキストがありません。") + return + + temp_dir = tktts.create_temp_dir(self.temp_dir) + + if self.user_outfile: + output_path = os.path.abspath(self.user_outfile) + else: + # GUIでの再生互換性を優先し、一時出力は常にWAVにする。 + temp_filebody = TEMP_WAV_PREFIX + uuid.uuid4().hex[:8] + output_path = os.path.abspath( + os.path.join(temp_dir, temp_filebody + "_merged.wav") + ) + + output_dir = os.path.dirname(output_path) + if output_dir: + os.makedirs(output_dir, exist_ok=True) + + self.tktts_args.outfile = output_path + self.tktts_args.endpoint = self.voicevox_endpoint + self.tktts_args.elevenlabs_api_key = os.getenv("ELEVENLABS_API_KEY") + + print(f"Status: {self.tts_engine.upper()}の音声生成開始...") + # 音声名を文字列のまま渡すと、WinRTバックエンドでspeaker名として + # 正規化され、空白を含む ``Microsoft Ayumi ...`` が切れることがある。 + # 値として保持する話者マップにして渡す。 + selected_voice_map = { + None: self.voice_name, + "": self.voice_name, + 0: self.voice_name, + } + + result = tktts.speak_dialogue( + self.tktts_args, + dialogue, + voice_map=selected_voice_map, + replacements={}, + endpoint=self.voicevox_endpoint, + api_key=self.tktts_args.elevenlabs_api_key, + output_format=None, # 出力パスの拡張子から tktts.py が判定 + ) + + if not result: + self.error.emit( + f"TTSエンジン [{self.tts_engine}] での音声生成に失敗しました。" + ) + return + + generated_path = result if isinstance(result, str) else output_path + if not os.path.isfile(generated_path): + self.error.emit(f"生成された音声ファイルが見つかりません: {generated_path}") + return + + self.progress.emit(100) + self.finished.emit(generated_path) + + if not self.user_outfile: + self.tmp_files.append(generated_path) + + except Exception as e: + error_msg = f"音声生成またはファイル操作エラー: {type(e).__name__}: {e}" + print(error_msg) + traceback.print_exc() + self.error.emit(error_msg) + + +class MyTTSApp(QWidget): + def __init__(self): + super().__init__() + self.setWindowTitle("統合TTS GUI (コンパクト・リサイズ可能)") + + # メディア関連の初期化 + self.worker_thread: Optional[MyTTSWorker] = None + self.audio_output = QAudioOutput(QMediaDevices.defaultAudioOutput()) + self.player: QMediaPlayer = QMediaPlayer() + self.player.setAudioOutput(self.audio_output) + self.current_audio_path: Optional[str] = None + + # 状態管理 + self.tmp_files = [] + self.slide_data: Dict[int, str] = {} + self.last_dir: Dict[str, str] = {} + self.is_converted_text_dirty: bool = True + + self._load_settings() + + # シグナルとスロットの接続 + self.player.durationChanged.connect(self.set_slider_range) + self.player.positionChanged.connect(self.update_slider) + self.player.playbackStateChanged.connect(self.update_playback_buttons) + self.player.errorOccurred.connect(self.handle_media_player_error) + + self.initUI() + self.setGeometry(self.settings.get('geometry', QRect(100, 100, 800, 650))) + self.setMinimumSize(400, 450) + + # TTS設定UIの変更を監視し、ダーティフラグを立てる + self.text_input_converted.textChanged.connect(self.set_dirty) + self.speed_spin.valueChanged.connect(self.set_dirty) + self.pitch_spin.valueChanged.connect(self.set_dirty) + self.instruction_line.textChanged.connect(self.set_dirty) + self.engine_combo.currentIndexChanged.connect(self.update_voice_list) # update_voice_list内でもset_dirtyを呼ぶ + self.voice_combo.currentIndexChanged.connect(self.set_dirty) # ボイス変更時 + + # Qwen/Irodori settings + for w in ( + self.qwen3_model_line, self.qwen3_language_line, self.qwen3_instruct_line, + self.irodori_model_line, self.irodori_caption_line, + self.irodori_ref_wav_line, self.irodori_seed_line, + ): + w.textChanged.connect(self.set_dirty) + self.qwen3_device_combo.currentIndexChanged.connect(self.set_dirty) + self.qwen3_dtype_combo.currentIndexChanged.connect(self.set_dirty) + self.irodori_device_combo.currentIndexChanged.connect(self.set_dirty) + self.irodori_precision_combo.currentIndexChanged.connect(self.set_dirty) + self.irodori_steps_spin.valueChanged.connect(self.set_dirty) + self.irodori_duration_spin.valueChanged.connect(self.set_dirty) + + self.update_voice_list() + + QApplication.instance().aboutToQuit.connect(self.cleanup_temp_files) + QApplication.instance().aboutToQuit.connect(self._save_settings) + + + # --- 状態管理 --- + @Slot() + def set_dirty(self): + """TTS出力に影響する設定が変更されたとき、フラグを立てる""" + self.is_converted_text_dirty = True + if self.player.playbackState() != QMediaPlayer.PlaybackState.PlayingState: + self.update_playback_buttons(self.player.playbackState()) + + def _load_settings(self): + # 設定のロードロジックは省略せずに残す + default_settings = { + 'geometry': QRect(100, 100, 800, 650), + 'temp_dir': DEFAULT_TEMP_DIR, + 'aquestalk_path': DEFAULT_AQUESTALK_PATH, + 'voicevox_endpoint': DEFAULT_VOICEVOX_ENDPOINT, + 'input_file': "input.md", + 'replace_file': "replace.ini", + 'replace_file2': "user_replace.ini", + 'output_file': "", + 'qwen3_language': "Japanese", + 'qwen3_model_id': "Qwen/Qwen3-TTS-12Hz-0.6B-CustomVoice", + 'qwen3_device': "auto", + 'qwen3_dtype': "auto", + 'qwen3_instruct': "", + 'irodori_caption': "落ち着いた自然な声で、明瞭に読み上げる。", + 'irodori_ref_wav': "", + 'irodori_model_id': "Aratako/Irodori-TTS-v4.1-Small", + 'irodori_device': "auto", + 'irodori_precision': "auto", + 'irodori_num_steps': "40", + 'irodori_duration_scale': "1.0", + 'irodori_seed': "0", + } + self.settings: Dict[str, Any] = default_settings.copy() + + if not os.path.exists(INI_FILE_NAME): + return + + try: + with open(INI_FILE_NAME, 'r', encoding='utf-8') as f: + content = f.read() + current_section = None + for line in content.splitlines(): + line = line.strip() + if not line or line.startswith('#'): + continue + if line.startswith('[') and line.endswith(']'): + current_section = line[1:-1].strip() + elif '=' in line: + key, value = line.split('=', 1) + key = key.strip() + value = value.strip().strip('"') + + if current_section == "window": + if key == "x": self.settings['x'] = int(value) + elif key == "y": self.settings['y'] = int(value) + elif key == "width": self.settings['width'] = int(value) + elif key == "height": self.settings['height'] = int(value) + elif current_section in ("tts_paths", "qwen3", "irodori"): + if key in default_settings: + self.settings[key] = value + + if 'x' in self.settings: + self.settings['geometry'] = QRect( + self.settings['x'], self.settings['y'], + self.settings.get('width', 800), self.settings.get('height', 650) + ) + except Exception as e: + print(f"設定ファイル読み込みエラー: {e}") + self.settings = default_settings.copy() + + + def _save_settings(self): + # 設定保存ロジックは省略せずに残す + geom = self.geometry() + + current_settings = { + 'temp_dir': self.temp_dir_line.text(), + 'aquestalk_path': self.aquestalk_path_line.text(), + 'voicevox_endpoint': self.voicevox_endpoint_line.text(), + 'input_file': self.input_file_line.text(), + 'replace_file': self.replace_ini_line.text(), + 'replace_file2': self.replace_ini2_line.text(), + 'output_file': self.output_line.text(), + 'qwen3_language': self.qwen3_language_line.text(), + 'qwen3_model_id': self.qwen3_model_line.text(), + 'qwen3_device': self.qwen3_device_combo.currentText(), + 'qwen3_dtype': self.qwen3_dtype_combo.currentText(), + 'qwen3_instruct': self.qwen3_instruct_line.text(), + 'irodori_caption': self.irodori_caption_line.text(), + 'irodori_ref_wav': self.irodori_ref_wav_line.text(), + 'irodori_model_id': self.irodori_model_line.text(), + 'irodori_device': self.irodori_device_combo.currentText(), + 'irodori_precision': self.irodori_precision_combo.currentText(), + 'irodori_num_steps': str(self.irodori_steps_spin.value()), + 'irodori_duration_scale': str(self.irodori_duration_spin.value()), + 'irodori_seed': self.irodori_seed_line.text(), + } + + content = ( + f'[window]\n' + f'x = {geom.x()}\n' + f'y = {geom.y()}\n' + f'width = {geom.width()}\n' + f'height = {geom.height()}\n' + f'\n' + f'[tts_paths]\n' + f'temp_dir = "{current_settings["temp_dir"]}"\n' + f'aquestalk_path = "{current_settings["aquestalk_path"]}"\n' + f'voicevox_endpoint = "{current_settings["voicevox_endpoint"]}"\n' + f'input_file = "{current_settings["input_file"]}"\n' + f'replace_file = "{current_settings["replace_file"]}"\n' + f'replace_file2 = "{current_settings["replace_file2"]}"\n' + f'output_file = "{current_settings["output_file"]}"\n' + f'\n' + f'[qwen3]\n' + f'qwen3_language = "{current_settings["qwen3_language"]}"\n' + f'qwen3_model_id = "{current_settings["qwen3_model_id"]}"\n' + f'qwen3_device = "{current_settings["qwen3_device"]}"\n' + f'qwen3_dtype = "{current_settings["qwen3_dtype"]}"\n' + f'qwen3_instruct = "{current_settings["qwen3_instruct"]}"\n' + f'\n' + f'[irodori]\n' + f'irodori_caption = "{current_settings["irodori_caption"]}"\n' + f'irodori_ref_wav = "{current_settings["irodori_ref_wav"]}"\n' + f'irodori_model_id = "{current_settings["irodori_model_id"]}"\n' + f'irodori_device = "{current_settings["irodori_device"]}"\n' + f'irodori_precision = "{current_settings["irodori_precision"]}"\n' + f'irodori_num_steps = "{current_settings["irodori_num_steps"]}"\n' + f'irodori_duration_scale = "{current_settings["irodori_duration_scale"]}"\n' + f'irodori_seed = "{current_settings["irodori_seed"]}"\n' + ) + + try: + with open(INI_FILE_NAME, 'w', encoding='utf-8') as f: + f.write(content) + except Exception as e: + print(f"設定ファイル保存エラー: {e}") + + + def cleanup_temp_files(self): + # クリーンアップロジックは省略せずに残す + self.handle_stop() + self.player.setSource(QUrl()) + if self.worker_thread and self.worker_thread.isRunning(): + self.worker_thread.quit() + self.worker_thread.wait() + + for f in self.tmp_files: + try: + if os.path.isfile(f): + os.remove(f) + except Exception as e: + print(f"一時ファイル削除エラー: {f}: {e}") + + temp_dir = self.settings.get('temp_dir', DEFAULT_TEMP_DIR) + try: + if os.path.exists(temp_dir): + search_pattern = os.path.join(temp_dir, f"{TEMP_WAV_PREFIX}*") + for file_path in glob.glob(search_pattern): + try: + if os.path.isfile(file_path): + os.remove(file_path) + except: + pass + if not os.listdir(temp_dir): + os.rmdir(temp_dir) + except Exception as e: + print(f"一時ディレクトリのクリーンアップエラー: {e}") + + + # --- UI初期化 --- + def initUI(self): + main_layout = QVBoxLayout() + self.tabs = QTabWidget() + + # --- TTS設定ウィジェットの事前初期化 --- + # これらのウィジェットはMain/Configタブ間で共有されるため、先に初期化する + self.engine_combo = QComboBox(self) + self.engine_combo.addItems([ + "pyttsx3", + "winrt", + "voicevox", + "qwen3", + "irodori", + "openai", + "elevenlabs", + "aquestalkplayer", + ]) + self.engine_combo.setCurrentText(DEFAULT_ENGINE) + self.voice_combo = QComboBox(self) + self.speed_spin = QDoubleSpinBox(self) + self.speed_spin.setRange(0.1, 5.0) + self.speed_spin.setSingleStep(0.1) + self.speed_spin.setValue(1.0) + self.pitch_spin = QDoubleSpinBox(self) + self.pitch_spin.setRange(-10.0, 10.0) + self.pitch_spin.setSingleStep(0.1) + self.pitch_spin.setValue(0.0) + self.instruction_line = QLineEdit() + self.instruction_line.setPlaceholderText("OpenAI instruction (Qwen/Irodoriは下の専用設定を使用)") + + # --- Tab 1: Main Content (メイン操作) --- + main_page = QWidget() + main_page_layout = QVBoxLayout(main_page) + + # 1. ファイル設定 + file_slide_layout = QGridLayout() + # Input File + file_slide_layout.addWidget(QLabel("Input File (infile):"), 0, 0) + self.input_file_line = QLineEdit(self.settings.get('input_file')) + file_slide_layout.addWidget(self.input_file_line, 0, 1) + self.input_file_btn = QPushButton("Path") + self.input_file_btn.clicked.connect(self.select_input_file) + file_slide_layout.addWidget(self.input_file_btn, 0, 2) + # Replace INI 1 (Default) + file_slide_layout.addWidget(QLabel("Replace INI (Default):"), 1, 0) + self.replace_ini_line = QLineEdit(self.settings.get('replace_file')) + file_slide_layout.addWidget(self.replace_ini_line, 1, 1) + self.replace_ini_btn = QPushButton("Path") + self.replace_ini_btn.clicked.connect(lambda: self.select_ini_file(self.replace_ini_line, 'replace_file')) + file_slide_layout.addWidget(self.replace_ini_btn, 1, 2) + # Replace INI 2 (User) + file_slide_layout.addWidget(QLabel("Replace INI (User):"), 2, 0) + self.replace_ini2_line = QLineEdit(self.settings.get('replace_file2')) + file_slide_layout.addWidget(self.replace_ini2_line, 2, 1) + self.replace_ini2_btn = QPushButton("Path") + self.replace_ini2_btn.clicked.connect(lambda: self.select_ini_file(self.replace_ini2_line, 'replace_file2')) + file_slide_layout.addWidget(self.replace_ini2_btn, 2, 2) + # Output File + file_slide_layout.addWidget(QLabel("出力ファイル (wav/mp3):"), 3, 0) + self.output_line = QLineEdit(self) + self.output_line.setPlaceholderText("未指定の場合、一時ファイルを作成して再生します (推奨)") + self.output_line.setText(self.settings.get('output_file', '')) + file_slide_layout.addWidget(self.output_line, 3, 1) + self.output_btn = QPushButton("Path") + self.output_btn.clicked.connect(self.select_output_file) + file_slide_layout.addWidget(self.output_btn, 3, 2) + main_page_layout.addLayout(file_slide_layout) + + # Slide Page Pulldown (位置変更) + slide_page_layout = QHBoxLayout() + slide_page_layout.addWidget(QLabel("Slide Page:")) + self.slide_page_combo = QComboBox(self) + self.slide_page_combo.addItem("1. No file loaded") + self.slide_page_combo.setCurrentIndex(0) + self.slide_page_combo.currentIndexChanged.connect(self.on_slide_page_changed) + slide_page_layout.addWidget(self.slide_page_combo) + main_page_layout.addLayout(slide_page_layout) + + # 4. テキスト入力エリア (2分割 & 拡張可能に) + text_layout = QVBoxLayout() + + # Original Text + text_layout.addWidget(QLabel("読み上げテキスト (Original):")) + self.text_input_original = QTextEdit(self) + self.text_input_original.setPlaceholderText("入力ファイルの内容(スライドページ)がここに表示されます。") + self.text_input_original.setMinimumHeight(80) + self.text_input_original.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding) + text_layout.addWidget(self.text_input_original) + + # Converted Text + text_layout.addWidget(QLabel("読み上げテキスト (Converted):")) + self.text_input_converted = QTextEdit(self) + self.text_input_converted.setPlaceholderText("置換ルール適用後のテキストがここに表示されます。Play/Generateボタンはこのテキストを読み上げます。") + self.text_input_converted.setMinimumHeight(80) + self.text_input_converted.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding) + text_layout.addWidget(self.text_input_converted) + main_page_layout.addLayout(text_layout) + + # 5. コントロール (Convertedテキストの直下に配置) + control_layout = QHBoxLayout() + + # Convert Button + self.convert_btn = QPushButton("⚙️ Convert (Apply Rules)") + self.convert_btn.clicked.connect(self.handle_convert) + self.convert_btn.setStyleSheet("font-weight: bold; padding: 5px;") + control_layout.addWidget(self.convert_btn) + + # 再生/生成ボタン + self.play_btn = QPushButton("▶ Play/Generate") + self.generate_btn = QPushButton("⚡ Generate (Force)") + self.pause_btn = QPushButton("⏸ Pause") + self.stop_btn = QPushButton("■ Stop") + + self.play_btn.clicked.connect(lambda: self.handle_play(force_generate=False)) + self.generate_btn.clicked.connect(lambda: self.handle_play(force_generate=True)) # 強制生成 + self.pause_btn.clicked.connect(self.handle_pause) + self.stop_btn.clicked.connect(self.handle_stop) + + control_layout.addWidget(self.play_btn) + control_layout.addWidget(self.generate_btn) + control_layout.addWidget(self.pause_btn) + control_layout.addWidget(self.stop_btn) + + main_page_layout.addLayout(control_layout) + + + # --- Tab 2: Config (パス設定) --- + config_page = QWidget() + config_page_layout = QVBoxLayout(config_page) + + # アプリケーションパス設定 + app_path_layout = QGridLayout() + # AquesTalk Path + app_path_layout.addWidget(QLabel("AquesTalk Path:"), 0, 0) + self.aquestalk_path_line = QLineEdit(self.settings.get('aquestalk_path', DEFAULT_AQUESTALK_PATH)) + app_path_layout.addWidget(self.aquestalk_path_line, 0, 1) + self.aquestalk_path_btn = QPushButton("Path") + self.aquestalk_path_btn.clicked.connect(self.select_aquestalk_path) + app_path_layout.addWidget(self.aquestalk_path_btn, 0, 2) + # Voicevox Endpoint + app_path_layout.addWidget(QLabel("Voicevox Endpoint:"), 1, 0) + self.voicevox_endpoint_line = QLineEdit(self.settings.get('voicevox_endpoint', DEFAULT_VOICEVOX_ENDPOINT)) + app_path_layout.addWidget(self.voicevox_endpoint_line, 1, 1, 1, 2) + # Temp Dir + app_path_layout.addWidget(QLabel("Temp Dir:"), 2, 0) + self.temp_dir_line = QLineEdit(self.settings.get('temp_dir', DEFAULT_TEMP_DIR)) + app_path_layout.addWidget(self.temp_dir_line, 2, 1, 1, 2) + + config_page_layout.addLayout(app_path_layout) + + # TTS設定(Configタブに配置) + tts_settings_layout_config = QGridLayout() + tts_settings_layout_config.addWidget(QLabel("Engine:"), 3, 0) + tts_settings_layout_config.addWidget(self.engine_combo, 3, 1) + tts_settings_layout_config.addWidget(QLabel("Voice:"), 3, 2) + tts_settings_layout_config.addWidget(self.voice_combo, 3, 3) + tts_settings_layout_config.addWidget(QLabel("Speed (fspeak_rate):"), 4, 0) + tts_settings_layout_config.addWidget(self.speed_spin, 4, 1) + tts_settings_layout_config.addWidget(QLabel("Pitch (ピッチ):"), 4, 2) + tts_settings_layout_config.addWidget(self.pitch_spin, 4, 3) + tts_settings_layout_config.addWidget(QLabel("Instruction (OpenAI):"), 5, 0) + tts_settings_layout_config.addWidget(self.instruction_line, 5, 1, 1, 3) + + config_page_layout.addLayout(tts_settings_layout_config) + + # Qwen3-TTS settings + qwen_layout = QGridLayout() + qwen_layout.addWidget(QLabel("Qwen3 model:"), 0, 0) + self.qwen3_model_line = QLineEdit(self.settings.get("qwen3_model_id", "Qwen/Qwen3-TTS-12Hz-0.6B-CustomVoice")) + qwen_layout.addWidget(self.qwen3_model_line, 0, 1, 1, 3) + + qwen_layout.addWidget(QLabel("Language:"), 1, 0) + self.qwen3_language_line = QLineEdit(self.settings.get("qwen3_language", "Japanese")) + qwen_layout.addWidget(self.qwen3_language_line, 1, 1) + + qwen_layout.addWidget(QLabel("Device:"), 1, 2) + self.qwen3_device_combo = QComboBox() + self.qwen3_device_combo.addItems(["auto", "cuda:0", "cpu"]) + self.qwen3_device_combo.setCurrentText(self.settings.get("qwen3_device", "auto")) + qwen_layout.addWidget(self.qwen3_device_combo, 1, 3) + + qwen_layout.addWidget(QLabel("dtype:"), 2, 0) + self.qwen3_dtype_combo = QComboBox() + self.qwen3_dtype_combo.addItems(["auto", "bfloat16", "float16", "float32"]) + self.qwen3_dtype_combo.setCurrentText(self.settings.get("qwen3_dtype", "auto")) + qwen_layout.addWidget(self.qwen3_dtype_combo, 2, 1) + + qwen_layout.addWidget(QLabel("Qwen instruct:"), 2, 2) + self.qwen3_instruct_line = QLineEdit(self.settings.get("qwen3_instruct", "")) + qwen_layout.addWidget(self.qwen3_instruct_line, 2, 3) + + self.qwen_frame = QFrame() + self.qwen_frame.setFrameShape(QFrame.Shape.StyledPanel) + self.qwen_frame.setLayout(qwen_layout) + config_page_layout.addWidget(QLabel("Qwen3-TTS")) + config_page_layout.addWidget(self.qwen_frame) + + # Irodori-TTS settings + irodori_layout = QGridLayout() + irodori_layout.addWidget(QLabel("Irodori model:"), 0, 0) + self.irodori_model_line = QLineEdit(self.settings.get("irodori_model_id", "Aratako/Irodori-TTS-v4.1-Small")) + irodori_layout.addWidget(self.irodori_model_line, 0, 1, 1, 3) + + irodori_layout.addWidget(QLabel("Caption:"), 1, 0) + self.irodori_caption_line = QLineEdit(self.settings.get("irodori_caption", "落ち着いた自然な声で、明瞭に読み上げる。")) + irodori_layout.addWidget(self.irodori_caption_line, 1, 1, 1, 3) + + irodori_layout.addWidget(QLabel("Reference WAV:"), 2, 0) + self.irodori_ref_wav_line = QLineEdit(self.settings.get("irodori_ref_wav", "")) + irodori_layout.addWidget(self.irodori_ref_wav_line, 2, 1, 1, 2) + self.irodori_ref_wav_btn = QPushButton("Path") + self.irodori_ref_wav_btn.clicked.connect(self.select_irodori_ref_wav) + irodori_layout.addWidget(self.irodori_ref_wav_btn, 2, 3) + + irodori_layout.addWidget(QLabel("Device:"), 3, 0) + self.irodori_device_combo = QComboBox() + self.irodori_device_combo.addItems(["auto", "cuda", "cuda:0", "cpu"]) + self.irodori_device_combo.setCurrentText(self.settings.get("irodori_device", "auto")) + irodori_layout.addWidget(self.irodori_device_combo, 3, 1) + + irodori_layout.addWidget(QLabel("Precision:"), 3, 2) + self.irodori_precision_combo = QComboBox() + self.irodori_precision_combo.addItems(["auto", "bf16", "fp32"]) + self.irodori_precision_combo.setCurrentText(self.settings.get("irodori_precision", "auto")) + irodori_layout.addWidget(self.irodori_precision_combo, 3, 3) + + irodori_layout.addWidget(QLabel("Steps:"), 4, 0) + self.irodori_steps_spin = QDoubleSpinBox() + self.irodori_steps_spin.setDecimals(0) + self.irodori_steps_spin.setRange(1, 200) + self.irodori_steps_spin.setSingleStep(1) + self.irodori_steps_spin.setValue(float(self.settings.get("irodori_num_steps", "40"))) + irodori_layout.addWidget(self.irodori_steps_spin, 4, 1) + + irodori_layout.addWidget(QLabel("Duration scale:"), 4, 2) + self.irodori_duration_spin = QDoubleSpinBox() + self.irodori_duration_spin.setRange(0.1, 3.0) + self.irodori_duration_spin.setSingleStep(0.05) + self.irodori_duration_spin.setValue(float(self.settings.get("irodori_duration_scale", "1.0"))) + irodori_layout.addWidget(self.irodori_duration_spin, 4, 3) + + irodori_layout.addWidget(QLabel("Seed:"), 5, 0) + self.irodori_seed_line = QLineEdit(self.settings.get("irodori_seed", "0")) + self.irodori_seed_line.setPlaceholderText("0 / random / none") + irodori_layout.addWidget(self.irodori_seed_line, 5, 1) + + self.irodori_frame = QFrame() + self.irodori_frame.setFrameShape(QFrame.Shape.StyledPanel) + self.irodori_frame.setLayout(irodori_layout) + config_page_layout.addWidget(QLabel("Irodori-TTS")) + config_page_layout.addWidget(self.irodori_frame) + + config_page_layout.addStretch(1) # 残りのスペースを埋める + + # Tab Widgetに追加 + self.tabs.addTab(main_page, "Main") + self.tabs.addTab(config_page, "Config") + main_layout.addWidget(self.tabs) + + # --- Tabの外の共通コントロール --- + + # 6. プログレスバーと再生位置を1行に統合 + progress_slider_layout = QHBoxLayout() + progress_slider_layout.addWidget(QLabel("Prog/Pos:")) + + # プログレスバー + self.progress_bar = QProgressBar(self) + self.progress_bar.setRange(0, 100) + self.progress_bar.setValue(0) + self.progress_bar.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Preferred) + progress_slider_layout.addWidget(self.progress_bar) + + # 再生位置スライダー + self.position_slider = QSlider(Qt.Orientation.Horizontal) + self.position_slider.setRange(0, 0) + self.position_slider.setTracking(False) + self.position_slider.sliderMoved.connect(self.seek_position) + self.position_slider.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Preferred) + progress_slider_layout.addWidget(self.position_slider) + + main_layout.addLayout(progress_slider_layout) + + # 7. ステータスラベル + self.status_label = QLabel("Status: Ready") + main_layout.addWidget(self.status_label) + + self.setLayout(main_layout) + self.update_playback_buttons(self.player.playbackState()) + + # 初期状態では選択中エンジン以外の専用設定を無効化 + self.qwen_frame.setEnabled(self.engine_combo.currentText().lower() in ("qwen3", "qwen")) + self.irodori_frame.setEnabled(self.engine_combo.currentText().lower() in ("irodori", "irodori-tts")) + + self.text_input_original.setText("TTSアプリへようこそ!\nInput Fileを選択すると、内容がここに表示され、Convertボタンで置換が適用されます。") + self.text_input_converted.setText("Play/Generateボタンを押すと、このconvertedテキストが読み上げられます。") + + + # --- ファイル選択/スロット群 --- + + def _get_initial_dir(self, path_line: QLineEdit, key: str) -> str: + """QLineEditの内容または記憶されたディレクトリを元に初期ディレクトリを返す""" + current_path = path_line.text() + if os.path.isfile(current_path): + dir_name = os.path.dirname(current_path) + self.last_dir[key] = dir_name + return dir_name + elif os.path.isdir(current_path): + self.last_dir[key] = current_path + return current_path + elif key in self.last_dir and os.path.isdir(self.last_dir[key]): + return self.last_dir[key] + return QDir.currentPath() + + @Slot() + def select_input_file(self): + """Input Fileを選択し、ファイルを読み込み、スライドをパースする""" + key = 'input_file' + initial_dir = self._get_initial_dir(self.input_file_line, key) + file_path, _ = QFileDialog.getOpenFileName( + self, + "入力テキストファイルの選択", + initial_dir, + "テキストファイル (*.txt *.md);;全てのファイル (*)" + ) + if file_path: + self.input_file_line.setText(file_path) + self.last_dir[key] = os.path.dirname(file_path) + self.load_input_file(file_path) + + @Slot() + def select_output_file(self): + """出力音声ファイルを選択し、パスを更新する""" + key = 'output_file' + initial_dir = self._get_initial_dir(self.output_line, key) + file_path, _ = QFileDialog.getSaveFileName( + self, + "出力音声ファイルの選択", + initial_dir, + "Audio Files (*.wav *.mp3);;Wave Files (*.wav);;MP3 Files (*.mp3);;All Files (*)" + ) + if file_path: + root, ext = os.path.splitext(file_path) + if not ext: + engine = self.engine_combo.currentText().lower() + default_ext = ".mp3" if engine in ("openai", "elevenlabs", "eleven") else ".wav" + file_path += default_ext + self.output_line.setText(file_path) + self.last_dir[key] = os.path.dirname(file_path) + + @Slot() + def select_aquestalk_path(self): + """AquesTalkPlayer.exeのファイルパスを選択するダイアログを開く""" + key = 'aquestalk_path' + initial_dir = self._get_initial_dir(self.aquestalk_path_line, key) + file_path, _ = QFileDialog.getOpenFileName( + self, + "AquesTalkPlayer.exeの選択", + initial_dir, + "実行ファイル (*.exe);;全てのファイル (*)" + ) + if file_path: + self.aquestalk_path_line.setText(file_path) + self.last_dir[key] = os.path.dirname(file_path) + + @Slot() + def select_irodori_ref_wav(self): + key = "irodori_ref_wav" + initial_dir = self._get_initial_dir(self.irodori_ref_wav_line, key) + file_path, _ = QFileDialog.getOpenFileName( + self, + "Irodori-TTS Reference WAV", + initial_dir, + "Wave Files (*.wav);;Audio Files (*.wav *.mp3 *.flac);;All Files (*)" + ) + if file_path: + self.irodori_ref_wav_line.setText(file_path) + self.last_dir[key] = os.path.dirname(file_path) + + @Slot(QLineEdit, str) + def select_ini_file(self, line_edit: QLineEdit, key: str): + """INIファイルを選択するダイアログを開く""" + initial_dir = self._get_initial_dir(line_edit, key) + file_path, _ = QFileDialog.getOpenFileName( + self, + "INIファイル(置換ルール)の選択", + initial_dir, + "INIファイル (*.ini);;全てのファイル (*)" + ) + if file_path: + line_edit.setText(file_path) + self.last_dir[key] = os.path.dirname(file_path) + + def load_input_file(self, file_path: str): + self.slide_data = {} + self.current_infile_path = file_path + + try: + encoding = detect_encoding(file_path) + if encoding is None: encoding = 'utf-8' + with open(file_path, 'r', encoding=encoding) as f: + full_text = f.read() + + self.slide_data[0] = full_text.strip() + current_slide_number = 1 + + # スライド区切りパターン: `# Slide` で始まる行、または `*1, *2` などの行 + slide_separator_pattern = re.compile(r"^\s*(#\s*Slide.*|\*\d+)\s*$", re.IGNORECASE | re.MULTILINE) + + parts = slide_separator_pattern.split(full_text) + + if len(parts) > 1: + + # parts[0]は最初の区切りより前のテキスト + if parts[0].strip(): + self.slide_data[1] = parts[0].strip() + current_slide_number = 2 + + # parts[1]以降は区切りと区切りの間のテキスト + for part in parts[1:]: + part_content = part.strip() + # 区切りパターンにマッチしたテキスト(空か、区切り文字自体)はスキップ + if part_content and not slide_separator_pattern.match(part_content): + self.slide_data[current_slide_number] = part_content + current_slide_number += 1 + + # スライドが分割された場合は、改めてページ0(全文)を再構築 + self.slide_data[0] = full_text.strip() + + + # 3. Slide pageプルダウンを更新 + self.slide_page_combo.clear() + + is_slide_parsed = len(self.slide_data) > 1 + + self.slide_page_combo.addItem("0. (All Document)") + if is_slide_parsed: + for i in sorted([k for k in self.slide_data.keys() if k > 0]): + self.slide_page_combo.addItem(f"{i}. Slide {i}") + self.slide_page_combo.setEnabled(True) + else: + self.slide_page_combo.setEnabled(False) + + self.slide_page_combo.setCurrentIndex(0) + + except Exception as e: + QMessageBox.critical(self, "ファイル読み込みエラー", f"ファイルの読み込みに失敗しました: {e}") + self.slide_page_combo.clear() + self.slide_page_combo.addItem("1. No file loaded") + self.slide_page_combo.setEnabled(False) + self.text_input_original.setText("") + self.text_input_converted.setText("") + + @Slot(int) + def on_slide_page_changed(self, index): + page_key = index + + if page_key in self.slide_data: + original_text = self.slide_data[page_key] + + # Convertedテキストの変更を一時的に無効化 + self.text_input_converted.textChanged.disconnect(self.set_dirty) + + self.text_input_original.setText(original_text) + self.text_input_converted.setText("") + self.is_converted_text_dirty = True # ページが変わったので変換が必要 + + # Convertedテキストの変更監視を再開 + self.text_input_converted.textChanged.connect(self.set_dirty) + + self.status_label.setText(f"Status: Page {page_key} loaded. Ready to Convert.") + else: + self.text_input_original.setText("") + self.text_input_converted.setText("") + self.status_label.setText("Status: Error - Page content missing.") + + @Slot() + def handle_convert(self): + """Convertボタンが押されたとき、置換処理を実行する""" + original_text = self.text_input_original.toPlainText() + if not original_text.strip(): + QMessageBox.warning(self, "Convertエラー", "Originalテキストが空です。ファイルを読み込むか、テキストを入力してください。") + return + + default_ini_path = self.replace_ini_line.text() + user_ini_path = self.replace_ini2_line.text() + + self.status_label.setText("Status: Loading/Checking replacement rules...") + QApplication.processEvents() + + # タイムスタンプチェックと再読み込み (force_reload=True) + dict2 = load_replace_dict(user_ini_path, force_reload=True) + dict1 = load_replace_dict(default_ini_path, force_reload=True) + + dict1_filtered = {k: v for k, v in dict1.items() if k not in dict2} + + final_replace_dict = {} + final_replace_dict.update(dict1_filtered) + final_replace_dict.update(dict2) + + if not final_replace_dict: + QMessageBox.information(self, "Convert情報", "有効な置換ルールがINIファイルから見つかりませんでした。") + self.text_input_converted.setText(original_text) + + # Convertedテキストの変更を一時的に無効化 + self.text_input_converted.textChanged.disconnect(self.set_dirty) + self.is_converted_text_dirty = False + self.text_input_converted.textChanged.connect(self.set_dirty) + + self.status_label.setText("Status: No rules applied. Converted = Original.") + return + + self.status_label.setText("Status: Applying replacement rules...") + QApplication.processEvents() + + replaced_text = apply_replacements(original_text, final_replace_dict) + + # 修正: `# Slide...` および `*N` 形式のマーカー行を完全に削除 + # 1. `# Slide...` 行の削除(行頭/行末に空白があっても良い) + replaced_text = re.sub(r"^\s*#\s*Slide.*$", "\n", replaced_text, flags=re.IGNORECASE | re.MULTILINE) + + # 2. `*N` (スライド番号) 行の削除(行頭/行末に空白があっても良い) + replaced_text = re.sub(r"^\s*\*\d+\s*$", "\n", replaced_text, flags=re.IGNORECASE | re.MULTILINE) + + # 空行のみの行を削除(連続する空行を一つにまとめる) + replaced_text = re.sub(r'\n\s*\n', '\n\n', replaced_text).strip() + + # Convertedテキストの変更を一時的に無効化してから更新 + self.text_input_converted.textChanged.disconnect(self.set_dirty) + self.text_input_converted.setText(replaced_text) + self.is_converted_text_dirty = False # 変換完了 + self.text_input_converted.textChanged.connect(self.set_dirty) + + self.status_label.setText("Status: Conversion complete. Ready to Play.") + + + def handle_play(self, force_generate: bool = False): + """ + Play/Generateボタンの動作。 + force_generate=True の場合は、ダーティフラグにかかわらず強制的に生成。 + """ + + # 1. 既に再生中の場合は無視 + if self.player.playbackState() == QMediaPlayer.PlaybackState.PlayingState: + return + + # 2. ポーズ状態からの再開 + if self.player.playbackState() == QMediaPlayer.PlaybackState.PausedState: + self.player.play() + return + + # 3. 強制生成が不要かつダーティでない場合 -> 再生 + is_ready_to_play = ( + not force_generate and + not self.is_converted_text_dirty and + self.current_audio_path and + os.path.exists(self.current_audio_path) + ) + + if is_ready_to_play: + self.status_label.setText(f"Status: Playing existing audio: {os.path.basename(self.current_audio_path)}") + try: + audio_url = QUrl.fromLocalFile(self.current_audio_path) + self.player.stop() + self.player.setSource(audio_url) + self.player.play() + except Exception as e: + QMessageBox.critical(self, "再生エラー", f"既存の音声ファイルの再生に失敗しました: {e}") + self.current_audio_path = None + return + + # 4. 生成が必要な場合 (ダーティ or ファイルがない or 強制生成) + + if self.worker_thread and self.worker_thread.isRunning(): + QMessageBox.warning(self, "処理中", "現在、音声生成が実行中です。完了をお待ちください。") + return + + text = self.text_input_converted.toPlainText() + outfile = self.output_line.text().strip() + tts_engine = self.engine_combo.currentText() + speed_rate = self.speed_spin.value() + voice_name = self.voice_combo.currentText() + pitch = self.pitch_spin.value() + instruction = self.instruction_line.text() + + aquestalk_path = self.aquestalk_path_line.text().strip() + temp_dir = self.temp_dir_line.text().strip() + voicevox_endpoint = self.voicevox_endpoint_line.text().strip() + + qwen3_language = self.qwen3_language_line.text().strip() or "Japanese" + qwen3_model_id = self.qwen3_model_line.text().strip() or "Qwen/Qwen3-TTS-12Hz-0.6B-CustomVoice" + qwen3_device = self.qwen3_device_combo.currentText() + qwen3_dtype = self.qwen3_dtype_combo.currentText() + qwen3_instruct = self.qwen3_instruct_line.text().strip() + + irodori_caption = self.irodori_caption_line.text().strip() + irodori_ref_wav = self.irodori_ref_wav_line.text().strip() + irodori_model_id = self.irodori_model_line.text().strip() or "Aratako/Irodori-TTS-v4.1-Small" + irodori_device = self.irodori_device_combo.currentText() + irodori_precision = self.irodori_precision_combo.currentText() + irodori_num_steps = int(self.irodori_steps_spin.value()) + irodori_duration_scale = float(self.irodori_duration_spin.value()) + irodori_seed = self.irodori_seed_line.text().strip() or "0" + + if not text.strip(): + QMessageBox.warning(self, "入力エラー", "読み上げテキスト (Converted) が空です。") + return + + if not voice_name or "No voices found" in voice_name or "Error loading voices" in voice_name: + QMessageBox.warning(self, "ボイス選択エラー", "有効なボイスが選択されていません。エンジンを確認してください。") + return + + self.status_label.setText("Status: 音声生成を開始しました... (非同期実行中)") + self.update_playback_buttons(QMediaPlayer.PlaybackState.StoppedState, is_generating=True) + self.progress_bar.setValue(0) + + self.worker_thread = MyTTSWorker( + text, outfile, tts_engine, speed_rate, pitch, instruction, voice_name, self.tmp_files, + aquestalk_path, temp_dir, voicevox_endpoint, + qwen3_language=qwen3_language, + qwen3_model_id=qwen3_model_id, + qwen3_device=qwen3_device, + qwen3_dtype=qwen3_dtype, + qwen3_instruct=qwen3_instruct, + irodori_caption=irodori_caption, + irodori_ref_wav=irodori_ref_wav, + irodori_model_id=irodori_model_id, + irodori_device=irodori_device, + irodori_precision=irodori_precision, + irodori_num_steps=irodori_num_steps, + irodori_duration_scale=irodori_duration_scale, + irodori_seed=irodori_seed, + ) + self.worker_thread.finished.connect(self.on_synthesis_finished) + self.worker_thread.error.connect(self.on_synthesis_error) + self.worker_thread.progress.connect(self.progress_bar.setValue) + self.worker_thread.start() + + + @Slot(str) + def on_synthesis_finished(self, file_path): + self.current_audio_path = file_path + self.is_converted_text_dirty = False + self.status_label.setText(f"Status: 音声生成が完了しました: {os.path.basename(file_path)}") + self.progress_bar.setValue(100) + + self.update_playback_buttons(QMediaPlayer.PlaybackState.StoppedState, is_generating=False) + + if not file_path or not os.path.exists(file_path): + self.on_synthesis_error(f"音声ファイルが見つかりません: {file_path}") + return + + try: + audio_url = QUrl.fromLocalFile(file_path) + self.player.stop() + self.player.setSource(audio_url) + self.player.play() + except Exception as e: + self.on_synthesis_error(f"音声再生の開始に失敗しました: {e}") + + @Slot(str) + def on_synthesis_error(self, message): + self.status_label.setText("Status: エラー発生") + self.progress_bar.setValue(0) + QMessageBox.critical(self, "エラー", message) + self.current_audio_path = None + + self.update_playback_buttons(QMediaPlayer.PlaybackState.StoppedState, is_generating=False) + if self.worker_thread: + self.worker_thread.quit() + self.worker_thread.wait() + + @Slot() + def update_voice_list(self): + """エンジンに応じて音声一覧と有効な設定項目を更新する。""" + + engine = self.engine_combo.currentText() + engine_lower = engine.lower() + self.voice_combo.clear() + + is_winrt = engine_lower in ("winrt", "onecore") + is_voicevox = engine_lower == "voicevox" + is_qwen = engine_lower in ("qwen3", "qwen") + is_irodori = engine_lower in ("irodori", "irodori-tts") + is_openai = engine_lower == "openai" + is_elevenlabs = engine_lower in ("elevenlabs", "eleven") + is_aqt = engine_lower in ("aquestalkplayer", "atp") + + # Pitchは現在 VoiceVox / AquesTalkPlayer のみ。 + self.pitch_spin.setEnabled(is_voicevox or is_aqt) + # Speedは pyttsx3 / WinRT / ElevenLabsを含め、全エンジンで表示する。 + self.speed_spin.setEnabled(True) + self.instruction_line.setEnabled(is_openai) + + if hasattr(self, "qwen_frame"): + self.qwen_frame.setEnabled(is_qwen) + if hasattr(self, "irodori_frame"): + self.irodori_frame.setEnabled(is_irodori) + + # Qwen/Irodori は speed/pitch の直接制御を行わない + if is_qwen or is_irodori: + self.speed_spin.setEnabled(False) + self.pitch_spin.setEnabled(False) + + if hasattr(self, "voicevox_endpoint_line"): + self.voicevox_endpoint_line.setEnabled(is_voicevox) + self.aquestalk_path_line.setEnabled(is_aqt) + + endpoint = None + if is_voicevox and hasattr(self, "voicevox_endpoint_line"): + endpoint = self.voicevox_endpoint_line.text().strip() + + api_key = os.getenv("ELEVENLABS_API_KEY") if is_elevenlabs else None + + try: + voices = tktts.get_available_voices( + engine_lower, + endpoint=endpoint, + api_key=api_key, + ) + + if voices: + self.voice_combo.addItems([str(v) for v in voices]) + + default_voice = None + if engine_lower == "pyttsx3": + default_voice = getattr(tktts, "default_pyttsx3_voice", None) + elif is_winrt: + default_voice = getattr(tktts, "default_winrt_voice", None) + elif is_openai: + default_voice = getattr( + tktts, + "default_openai_voice", + getattr(tktts, "default_optnai_voice", None), + ) + elif is_elevenlabs: + default_voice = getattr(tktts, "default_elevenlabs_voice", None) + elif is_voicevox: + default_voice = getattr(tktts, "default_voicevox_voice", None) + elif is_qwen: + default_voice = getattr(tktts, "default_qwen3_voice", "Ono_Anna") + elif is_irodori: + default_voice = getattr(tktts, "default_irodori_voice", "default") + elif is_aqt: + default_voice = getattr(tktts, "default_aqt_preset", None) + + if default_voice and default_voice in voices: + self.voice_combo.setCurrentText(default_voice) + else: + self.voice_combo.setCurrentIndex(0) + + self.status_label.setText( + f"Status: {engine} voices loaded ({len(voices)})." + ) + else: + if is_elevenlabs and not api_key: + self.voice_combo.addItem("ELEVENLABS_API_KEY is not set") + else: + self.voice_combo.addItem(f"No voices found for {engine}") + + except Exception as e: + error_msg = f"Error loading voices for {engine}: {e}" + self.voice_combo.addItem(error_msg) + print(error_msg) + traceback.print_exc() + + # エンジン変更または音声一覧の再取得は、必ず再生成対象にする。 + self.set_dirty() + + def update_playback_buttons(self, state: QMediaPlayer.PlaybackState, is_generating: Optional[bool] = None): + if is_generating is None: + is_worker_running = self.worker_thread and self.worker_thread.isRunning() + else: + is_worker_running = is_generating + + self.convert_btn.setEnabled(not is_worker_running) + self.generate_btn.setEnabled(not is_worker_running) + + if is_worker_running: + self.play_btn.setText("▶ Generating...") + self.play_btn.setEnabled(False) + self.pause_btn.setEnabled(False) + self.stop_btn.setEnabled(False) + return + + if state == QMediaPlayer.PlaybackState.PlayingState: + self.play_btn.setText("▶ Playing...") + self.play_btn.setEnabled(False) + self.pause_btn.setEnabled(True) + self.stop_btn.setEnabled(True) + elif state == QMediaPlayer.PlaybackState.PausedState: + self.play_btn.setText("▶ Resume") + self.play_btn.setEnabled(True) + self.pause_btn.setEnabled(False) + self.stop_btn.setEnabled(True) + elif state == QMediaPlayer.PlaybackState.StoppedState: + if self.is_converted_text_dirty: + self.play_btn.setText("▶ Generate (Text changed)") + elif self.current_audio_path: + self.play_btn.setText("▶ Play (Existing)") + else: + self.play_btn.setText("▶ Play/Generate") + + self.play_btn.setEnabled(True) + self.pause_btn.setEnabled(False) + self.stop_btn.setEnabled(False) + if not is_worker_running and "Status: エラー" not in self.status_label.text(): + if self.current_audio_path and not self.is_converted_text_dirty: + self.status_label.setText("Status: Generated (Ready to play)") + else: + self.status_label.setText("Status: Ready") + + # --- メディア制御系は変更なし --- + def handle_pause(self): + if self.player.playbackState() == QMediaPlayer.PlaybackState.PlayingState: + self.player.pause() + + def handle_stop(self): + self.player.stop() + self.position_slider.setValue(0) + self.status_label.setText("Status: 停止") + self.update_playback_buttons(QMediaPlayer.PlaybackState.StoppedState) + + def handle_media_player_error(self, error: QMediaPlayer.Error, error_string: str): + if error != QMediaPlayer.Error.NoError: + print(f"--- QMediaPlayer Error ---") + print(f"Code: {error.name}, Message: {error_string}") + print(f"--------------------------") + self.status_label.setText(f"Status: 再生エラー発生 ({error_string})") + self.player.stop() + self.update_playback_buttons(QMediaPlayer.PlaybackState.StoppedState) + + def set_slider_range(self, duration): + self.position_slider.setRange(0, duration) + + def update_slider(self, position): + self.position_slider.setValue(position) + total_duration = self.player.duration() + if total_duration > 0: + current_sec = position // 1000 + total_sec = total_duration // 1000 + self.status_label.setText(f"Status: Playing ({current_sec} sec / {total_sec} sec)") + + def seek_position(self, position): + self.player.setPosition(position) + + +if __name__ == '__main__': + app = QApplication(sys.argv) + ex = MyTTSApp() + ex.show() sys.exit(app.exec()) ai/speak_qt_async20260617.py: created (new file updated on 2026/6/17) ai/tktts.py: updated (updated on 2026/8/20) -tktts_pyttsx3 = _safe_import("tktts_pyttsx3", "pyttsx3") -tktts_winrt = _safe_import("tktts_winrt", "winsdk") -tktts_voicevox = _safe_import("tktts_voicevox") -tktts_aquestalkplayer = _safe_import("tktts_aquestalkplayer") -tktts_openai = _safe_import("tktts_openai", "openai") -tktts_elevenlabs = _safe_import("tktts_elevenlabs", "elevenlabs") +tktts_pyttsx3 = _safe_import("tktts_pyttsx3", "pyttsx3") +tktts_winrt = _safe_import("tktts_winrt", "winsdk") +tktts_voicevox = _safe_import("tktts_voicevox") +tktts_qwen3 = _safe_import("tktts_qwen3", "qwen_tts") +tktts_irodori = _safe_import("tktts_irodori", "irodori_tts") +tktts_aquestalkplayer = _safe_import("tktts_aquestalkplayer") +tktts_openai = _safe_import("tktts_openai", "openai") +tktts_elevenlabs = _safe_import("tktts_elevenlabs", "elevenlabs") default_pyttsx3_voice = "Zira" -default_winrt_voice = "Ayumi" -default_voicevox_voice = "四国めたん" -default_aqt_preset = "れいむ" -default_openai_voice = "alloy" +default_winrt_voice = "Ayumi" +default_voicevox_voice = "四国めたん" +default_qwen3_voice = "Ono_Anna" +default_irodori_voice = "default" +default_aqt_preset = "れいむ" +default_openai_voice = "alloy" # 旧コードとの互換性のため、綴り間違いの変数名も残す。 default_optnai_voice = default_openai_voice ... "direct_playback": True, }, - "voicevox": { - "engine": tktts_voicevox, - "default_voice": default_voicevox_voice, - "ext": "wav", - "direct_playback": False, - }, + "voicevox": { + "engine": tktts_voicevox, + "default_voice": default_voicevox_voice, + "ext": "wav", + "direct_playback": False, + }, + "qwen3": { + "engine": tktts_qwen3, + "default_voice": default_qwen3_voice, + "ext": "wav", + "direct_playback": False, + }, + "qwen": { + "engine": tktts_qwen3, + "default_voice": default_qwen3_voice, + "ext": "wav", + "direct_playback": False, + }, + "irodori": { + "engine": tktts_irodori, + "default_voice": default_irodori_voice, + "ext": "wav", + "direct_playback": False, + }, + "irodori-tts": { + "engine": tktts_irodori, + "default_voice": default_irodori_voice, + "ext": "wav", + "direct_playback": False, + }, "aquestalkplayer": { "engine": tktts_aquestalkplayer, ... elif tts_engine in ("winrt", "onecore"): call_kwargs["speak_rate"] = _effective_winrt_rate(args) - elif tts_engine == "voicevox": - call_kwargs["endpoint"] = endpoint or _get_attr(args, "endpoint", None) - elif tts_engine in ("aquestalkplayer", "atp"): + elif tts_engine == "voicevox": + call_kwargs["endpoint"] = endpoint or _get_attr(args, "endpoint", None) + elif tts_engine in ("qwen3", "qwen"): + call_kwargs.update({ + "language": _get_attr(args, "qwen3_language", None), + "model_id": _get_attr(args, "qwen3_model_id", None), + "device": _get_attr(args, "qwen3_device", None), + "dtype": _get_attr(args, "qwen3_dtype", None), + }) + elif tts_engine in ("irodori", "irodori-tts"): + call_kwargs.update({ + "caption": _get_attr(args, "irodori_caption", None), + "ref_wav": _get_attr(args, "irodori_ref_wav", None), + "ref_wavs": _get_attr(args, "irodori_ref_wavs", None), + "model_id": _get_attr(args, "irodori_model_id", None), + "device": _get_attr(args, "irodori_device", None), + "precision": _get_attr(args, "irodori_precision", None), + }) + elif tts_engine in ("aquestalkplayer", "atp"): call_kwargs["aquestalk_path"] = _get_attr(args, "aquestalk_path", None) elif tts_engine == "openai": ai/tktts20260601.py: deleted (old file updated on 2026/6/1) ai/tktts20260708.py: created (new file updated on 2026/7/8) ai/tktts_elevenlabs20251218.py: deleted (old file updated on 2025/12/18) ai/tktts_irodori.py: created (new file updated on 2026/8/20) ai/tktts_qwen3.py: created (new file updated on 2026/8/19) ai/transcribe_simple20251030.py: deleted (old file updated on 2025/10/30) ai/transcribe_simple20260612.py: deleted (old file updated on 2026/6/12) converter/convert20260409.py: deleted (old file updated on 2026/4/9) converter/pandoc20260211.py: deleted (old file updated on 2026/2/11) converter/pdf2pptx.py: updated (updated on 2026/9/1) ) parser.add_argument("--quiet", action="store_true", help="Suppress progress messages") + parser.add_argument("--pause", action="store_true", help="Pause before terminate") return parser.parse_args() ... verbose=not args.quiet, ) + + if args.pause: + input("\nPress ENTER to terminate>>\n") + return 0 converter/pptx_notes_word.py: created (new file updated on 2026/9/5) converter/video2pptx.py: created (new file updated on 2026/9/1) editor/iniedit20251003.py: deleted (old file updated on 2025/10/3) electrical/hetero_pn_band.py: created (new file updated on 2026/8/25) excel/apply_display.py: created (new file updated on 2026/8/19) excel/diff_xlsx.py: created (new file updated on 2026/6/27) excel/merge_excel.py: created (new file updated on 2025/7/12) gpu/examples/fft_compare.py: created (new file updated on 2026/8/12) gpu/fft_benchmark.py: created (new file updated on 2026/8/13) gpu/fft_compare.py: created (new file updated on 2026/8/13) gpu/fft_cuda_cpu.py: created (new file updated on 2026/8/13) gpu/test_cuda.py: created (new file updated on 2026/8/13) gpu/tests/test_numpy_backend.py: created (new file updated on 2026/8/12) gpu/tkcompute/__init__.py: created (new file updated on 2026/8/12) gpu/tkcompute/backends/__init__.py: created (new file updated on 2026/8/12) gpu/tkcompute/backends/cupy_backend.py: created (new file updated on 2026/8/12) gpu/tkcompute/backends/dpnp_backend.py: created (new file updated on 2026/8/12) gpu/tkcompute/backends/numpy_backend.py: created (new file updated on 2026/8/12) gpu/tkcompute/base.py: created (new file updated on 2026/8/12) gpu/tkcompute/device_info.py: created (new file updated on 2026/8/12) gpu/tkcompute/registry.py: created (new file updated on 2026/8/12) misc/compare_update.py: created (new file updated on 2026/9/6) regression/adaptive_gaussian_ridge.py: deleted (old file updated on 2026/5/13) regression/arrhenius_plot.py: deleted (old file updated on 2023/11/2) regression/arrhenius_plot_argparse.py: deleted (old file updated on 2026/5/3) regression/arrhenius_plot_argparse20250616.py: deleted (old file updated on 2025/6/16) regression/arrhenius_plot_argparse_gemini.py: deleted (old file updated on 2026/5/3) regression/fit_4exp_to_3tau_windowed.py: created (new file updated on 2026/9/4) regression/gp_simulation_animation_tkbo.py: deleted (old file updated on 2026/5/18) regression/mlsq_error20250615.py: deleted (old file updated on 2025/6/15) spectrum/deconvolution20260511.py: deleted (old file updated on 2026/5/11) spectrum/deconvolution20260609.py: deleted (old file updated on 2026/5/11) sphinx/check_sphinx_titles.py: created (new file updated on 2026/7/29) sphinx/make_sphinx_files.py: updated (updated on 2026/9/6) import argparse import shutil -from glob import glob import subprocess import traceback ... SCRIPT_BASENAME = os.path.splitext(os.path.basename(SCRIPT_FULLPATH))[0] -# 起動スクリプトのディレクトリに基づいたパス設定 ADD_DOCSTRING_PATH = os.path.join(SCRIPT_DIR, "add_docstring.py") EXPLAIN_PROGRAM_PATH = os.path.join(SCRIPT_DIR, "explain_program5.py") -DEFAULT_INI_PATH = os.path.join(SCRIPT_DIR, f"{SCRIPT_BASENAME}.ini") ... parts = p.parts - # 最初に出現する "source" を探す try: idx = parts.index("source") except ValueError: -# raise RuntimeError("'source' ディレクトリがパスに含まれていません") return "" - # source 以下のパス(ファイル名付き) - rel_path = Path(*parts[idx+1:]) - - # ファイル名を削除してディレクトリだけにする + rel_path = Path(*parts[idx + 1:]) return str(rel_path.parent) def run_step(message, cmd_list): - """作業ステップを表示し、外部コマンドを実行します。""" print(f"\n>>> {message}") print(f" コマンド: {' '.join(cmd_list)}") try: - result = subprocess.run(cmd_list, text=True, errors='ignore') -# result = subprocess.run(cmd_list, capture_output=True, text=True, encoding='utf-8', errors='ignore') + result = subprocess.run(cmd_list, text=True, errors="ignore") if result.returncode != 0: - print(f"!!! エラーが発生しました:\n{result.stderr}") + print("!!! エラーが発生しました") return False return True ... print(f"!!! 実行エラー: {e}") return False + def make_init_py(path): ... print(f" Done: {path}") + def make_index_rst(index_rst, base_name, package_path): print(f">>> Step: {index_rst} を作成中...") ... :maxdepth: 1 + {base_name}_download {base_name}_usage + {base_name}_quality {base_name}_examples {base_name}_api ... f.write(index_content) print(f" Done: {index_rst}") + + +def make_download_rst(download_rst, base_name, script_rel_path): + print(f">>> Step: {download_rst} を作成中...") + + script_name = os.path.basename(script_rel_path) + + download_content = f"""{script_name} ダウンロード/コピー +======================================================== + +:download:`{script_name} をダウンロード <{script_rel_path}>` + +.. dropdown:: {script_name} + :color: primary + :icon: file-code + + .. literalinclude:: {script_rel_path} + :language: python + :linenos: + :caption: {script_name} +""" + with open(download_rst, "w", encoding="utf-8") as f: + f.write(download_content) + + print(f" Done: {download_rst}") + def make_api_rst(api_rst, base_name, package_path): ... print(f" Done: {api_rst}") + def make_examples_md(examples_md, infile, base_name): print(f">>> Step: {examples_md} テンプレートを作成中...") -# 画像ファイルの自動検出 image_files = sorted( f for f in os.listdir(".") if f.startswith(base_name) and f.lower().endswith((".png", ".jpg", ".jpeg")) - ) - -# データファイルの自動検出(CSV / Excel / TXT) + ) + data_files = sorted( f for f in os.listdir(".") if f.startswith(base_name) and f.lower().endswith((".csv", ".xlsx", ".xls", ".txt")) - ) - + ) + print("Image files:", image_files) print("Data files:", data_files) - - # ヘルプ出力を取得 print(" help logを取得します") - result = subprocess.run(["python", infile, "--help"], - text=True, capture_output = True) + result = subprocess.run( + [sys.executable, infile, "--help"], + text=True, + capture_output=True, + errors="ignore", + ) + print(" return code:", result.returncode) + if result.returncode == 0: help_log = result.stdout + "\n" + result.stderr -# print(" help log:", help_log) else: help_log = "(ヘルプの自動取得に失敗しました。ここに実行ログを貼り付けてください)" ... image_section = "## 生成された画像一覧\n(画像ファイルが見つかりませんでした)\n\n" - examples_content = f"""# {base_name} 実行例 ... {image_section} - """ with open(examples_md, "w", encoding="utf-8") as f: f.write(examples_content) + print(f" Done: {examples_md}") + + +def check_updated(file1, file2): + if not os.path.exists(file1): + return False + if not os.path.exists(file2): + return True + + t1 = os.path.getmtime(file1) + dt1 = datetime.fromtimestamp(t1) + t2 = os.path.getmtime(file2) + dt2 = datetime.fromtimestamp(t2) + + print(f"File stamps: {file1} : {dt1}") + print(f" {file2} : {dt2}") + + if t1 >= t2: + return True + return False def main(args): infile = args.infile + if not os.path.exists(infile): print(f"エラー: 入力ファイル '{infile}' が見つかりません。") return - # 基本情報の整理 base_name = os.path.splitext(os.path.basename(infile))[0] date_str = datetime.now().strftime("%Y%m%d") - - # ファイル名定義 + init_py = "__init__.py" index_template = "index.template" docstring_out = f"{base_name}_docstring.py" backup_file = f"{base_name}_{date_str}.py" + usage_md = f"{base_name}_usage.md" + quality_md = f"{base_name}_quality.md" examples_md = f"{base_name}_examples.md" index_rst = f"{base_name}_index.rst" + download_rst = f"{base_name}_download.rst" api_rst = f"{base_name}_api.rst" - # カレントディレクトリ名からパッケージパスを判定 (010...等の数値ディレクトリを想定) - current_dir_name = os.path.basename(os.getcwd()) - # フォルダ名が 010xx_page のような形式ならモジュールパスに含める -# module_path = f"{current_dir_name}.{base_name}" if "page" in current_dir_name else base_name if args.subdir is not None and args.subdir != "": module_path = os.path.join(args.subdir, base_name) ... package_path = module_path - print("="*60) + print("=" * 60) print(f" プロジェクト: {base_name} のSphinxファイル自動生成を開始します") print(f" モジュールパス: {module_path}") print(f" パッケージパス: {package_path}") - print("="*60) + print("=" * 60) make_init_py(init_py) + if os.path.exists(index_template): print(f"** Warning: [{index_template}] exists. Skip to create.") else: make_index_template(index_template, module_path) - if os.path.exists(index_rst): + + if args.update_index: + print(f"** Message: [Force update {index_rst} due to {args.update_index=}].") + make_index_rst(index_rst, base_name, package_path) + elif os.path.exists(index_rst): print(f"** Warning: [{index_rst}] exists. Skip to create.") else: make_index_rst(index_rst, base_name, package_path) + + if os.path.exists(download_rst): + print(f"** Warning: [{download_rst}] exists. Skip to create.") + else: + make_download_rst(download_rst, base_name, infile) + if os.path.exists(api_rst): print(f"** Warning: [{api_rst}] exists. Skip to create.") else: make_api_rst(api_rst, base_name, package_path) + if os.path.exists(examples_md): print(f"** Warning: [{examples_md}] exists. Skip to create.") ... make_examples_md(examples_md, infile, base_name) - args_list = ["--api", args.api, "--update", str(args.update), "--overwrite", str(args.overwrite), - "--pause", str(args.pause)] - - # explain_program.py の実行 - if not run_step("Step: EXPLAIN_PROGRAM_PATH を実行してプログラム解説を生成中...", - ["python", EXPLAIN_PROGRAM_PATH, infile, *args_list]): - return - - # add_docstring.py の実行 - if not run_step("Step: ADD_DOCSTRING_PATH を実行してDocstringを追加中...", - ["python", ADD_DOCSTRING_PATH, infile, *args_list]): - return - -#====================================================================== - # バックアップの作成 - print(f">>> Step: オリジナルファイルのバックアップを作成中...") - if os.path.exists(infile): - shutil.copy2(infile, backup_file) - print(f" Done: {infile} -> {backup_file}") - else: - print("!!! バックアップ対象のファイルが見つかりません。") - return - -#====================================================================== - # ファイルの置き換え - print(f">>> Step: 生成されたDocstring版ファイルを {infile} にリネーム中...") - if os.path.exists(docstring_out): - os.replace(docstring_out, infile) - print(f" Done: {docstring_out} -> {infile}") - with open(docstring_out, "w") as fp: - fp.write("") - print(f" ダミーの空ファイル {docstring_out} を作りました") - else: - print("!!! Docstring版ファイルが生成されていなかったため、リネームをスキップします。") - return - - - print("\n" + "="*60) + args_list = [ + "--api", args.api, + "--update", str(args.update), + "--overwrite", str(args.overwrite), + "--pause", str(args.pause), + ] + + if not args.overwrite and not check_updated(infile, usage_md): + print(f"Step EXPLAIN_PROGRAM_PATH: {infile}が{usage_md}より古いのでスキップします") + else: + if not run_step( + "Step: EXPLAIN_PROGRAM_PATH を実行してusageを生成中...", + [ + sys.executable, + EXPLAIN_PROGRAM_PATH, + infile, + usage_md, + "--inifile", + args.prompt_explain, + *args_list, + ], + ): + return + + if not check_updated(infile, docstring_out): + print(f"Step ADD_DOCSTRING_PATH: {infile}が{docstring_out}より古いのでスキップします") + else: + if not run_step( + "Step: ADD_DOCSTRING_PATH を実行してDocstringを追加中...", + [ + sys.executable, + ADD_DOCSTRING_PATH, + infile, + "--inifile", + args.prompt_add_docstring, + *args_list, + ], + ): + return + + print(">>> Step: オリジナルファイルのバックアップを作成中...") + if os.path.exists(infile): + shutil.copy2(infile, backup_file) + print(f" Done: {infile} -> {backup_file}") + else: + print("!!! バックアップ対象のファイルが見つかりません。") + return + + if os.path.exists(docstring_out) and os.path.exists(infile): + t_doc = os.path.getmtime(docstring_out) + size_byte = os.path.getsize(docstring_out) + t_in = os.path.getmtime(infile) + + dt_doc = datetime.fromtimestamp(t_doc) + dt_in = datetime.fromtimestamp(t_in) + + print(f"File stamps: {docstring_out} : {dt_doc} ({size_byte} bytes)") + print(f" {infile} : {dt_in}") + + if size_byte == 0: + print(f" {docstring_out} はダミーファイルのため、ファイルコピーは行いません") + elif t_doc <= t_in: + print(" infile の方が新しいのでファイルコピーは行いません") + else: + print(f">>> Step: 生成されたDocstring版ファイルを {infile} にリネーム中...") + if os.path.exists(docstring_out): + os.replace(docstring_out, infile) + print(f" Done: {docstring_out} -> {infile}") + + with open(docstring_out, "w", encoding="utf-8") as fp: + fp.write("") + + print(f" ダミーの空ファイル {docstring_out} を作りました") + else: + print("!!! Docstring版ファイルが生成されていなかったため、リネームをスキップします。") + return + + if args.prompt_quality: + if not args.overwrite and not check_updated(infile, quality_md): + print(f"Step EXPLAIN_PROGRAM_PATH: {infile}が{quality_md}より古いのでスキップします") + else: + if not run_step( + "Step: EXPLAIN_PROGRAM_PATH を実行してコード品質評価を生成中...", + [ + sys.executable, + EXPLAIN_PROGRAM_PATH, + infile, + quality_md, + "--inifile", + args.prompt_quality, + *args_list, + ], + ): + return + else: + print("** Warning: --prompt_quality が指定されていないため、quality生成をスキップします。") + + print("\n" + "=" * 60) print(" 全ての自動生成プロセスが正常に終了しました。") - print("="*60) + print("=" * 60) def initialize(): - """コマンドライン引数のパーサーを構築します。""" parser = argparse.ArgumentParser( description="Sphinxドキュメント生成の一連のルーチン(Docstring追加、バックアップ、解説生成、RST作成)を自動化します。" ) + parser.add_argument("infile", help="対象となるPythonスクリプトファイル名 (例: mu_fit.py)") parser.add_argument("--subdir", default=None, help="sourceディレクトリからの相対パス") - parser.add_argument("--api", choices=["openai", "openai5", "google", "gemini"], default='google') + parser.add_argument( + "--prompt_explain", + default="explain_program5.ini", + help="usage生成用プロンプトファイルのパス", + ) + parser.add_argument( + "--prompt_quality", + default="prompt_quality.ini", + help="quality生成用プロンプトファイルのパス", + ) + parser.add_argument( + "--prompt_add_docstring", + default="", + help="add_docstring.pyに渡すプロンプトファイルのパス", + ) + + parser.add_argument("--api", choices=["openai", "openai5", "google", "gemini"], default="google") + parser.add_argument("--update_index", type=int, default=0) parser.add_argument("-u", "--update", type=int, default=1) parser.add_argument("-w", "--overwrite", type=int, default=0) parser.add_argument("-p", "--pause", type=int, default=0) + return parser def read_args(parser): - """引数を解析して返します。""" args = parser.parse_args() + if args.subdir is None: args.subdir = relative_from_source(args.infile) + return args + + +if __name__ == "__main__": + print() + print(f"=== {sys.argv[0]} ===") + print(f"{EXPLAIN_PROGRAM_PATH=}") + print(f"{ADD_DOCSTRING_PATH=}") + + parser = initialize() +# args = read_args(parser) + args = parser.parse_args() print() print("Args:") ... print(f" {args.overwrite=}") print(f" {args.pause=}") - - return args - - -if __name__ == "__main__": - print() - print(f"=== {sys.argv[0]} ===") - print(f"{EXPLAIN_PROGRAM_PATH=}") - print(f"{ADD_DOCSTRING_PATH=}") - - parser = initialize() - args = read_args(parser) + print(f" {args.prompt_explain=}") + print(f" {args.prompt_quality=}") + print(f" {args.prompt_add_docstring=}") + print(f" {args.update=}") + print(f" {args.overwrite=}") + print(f" {args.update_index=}") + print(f" {args.pause=}") try: main(args) except Exception: - print("\n" + "!"*60) + print("\n" + "!" * 60) print(" 予期せぬ致命的なエラーが発生しました。") traceback.print_exc() - print("!"*60) - sys.exit(1) - + print("!" * 60) + sys.exit(1) sphinx/make_sphinx_files20260329.py: created (new file updated on 2026/3/29) thermal/thermal_kappa_fit.py: created (new file updated on 2026/9/5) web/extract_script_tags.py: created (new file updated on 2026/9/4) --- root_dir1 was lastly updated on 2026/9/6 10:52:01 root_dir2 was lastly updated on 2026/8/6 18:29:22