import pathlib
import tkinter as tk
from tkinter import ttk
from tkinter import filedialog, messagebox
import pyttsx3
from PIL import Image, ImageTk
import threading
import os
import platform
import webbrowser
from datetime import datetime
class Application(tk.Tk):
def __init__(self):
super().__init__()
self.title("文本转语音转换器")
self.geometry("1200x700")
self.minsize(700, 400)
self.resizable(True, True)
self.engine = pyttsx3.init()
self.file_path = tk.StringVar()
self.output_dir = tk.StringVar()
self.rate_var = tk.DoubleVar(value=50.00)
self.volume_var = tk.DoubleVar(value=50.00)
self.display_rate_var = tk.StringVar()
self.display_volume_var = tk.StringVar()
self.update_display_vars()
self.rate_var.trace_add('write', self.update_display_vars)
self.volume_var.trace_add('write', self.update_display_vars)
self.target_voice = "Microsoft Huihui"
self.voice_id = None
self.style = ttk.Style()
self.style.theme_use('clam')
self.style.configure('TButton', font=('微软雅黑', 10), padding=5)
self.style.configure('TEntry', font=('微软雅黑', 10))
self.style.configure('TLabel', font=('微软雅黑', 10))
self.style.configure('TFrame', background='#f0f0f0')
self.style.configure('TLabelframe', font=('微软雅黑', 10, 'bold'))
self.style.configure('Accent.TButton', foreground='white', background='#4CAF50', font=('微软雅黑', 11, 'bold'))
self.style.map('Accent.TButton', background=[('active', '#45a049'), ('!active', '#4CAF50')])
self.init_ui()
self.load_voices()
def init_ui(self):
main_frame = ttk.Frame(self, padding=20)
main_frame.pack(fill=tk.BOTH, expand=True)
title_frame = tk.Frame(main_frame, bd=0, highlightthickness=0, bg=self.cget('bg'))
title_frame.pack(fill=tk.X, pady=(0, 20))
title_label = tk.Label(
title_frame, text="文本转语音转换器", font=('微软雅黑', 16, 'bold'), bg=self.cget('bg'))
title_label.pack(side=tk.LEFT)
help_btn = ttk.Button(title_frame, text="帮助", command=self.show_help, width=8)
help_btn.pack(side=tk.RIGHT, padx=(10, 0))
pw = ttk.PanedWindow(main_frame, orient=tk.HORIZONTAL)
pw.pack(fill=tk.BOTH, expand=True)
left_panel = ttk.Labelframe(pw, text="输入和设置", padding=15)
pw.add(left_panel, weight=2)
right_panel = ttk.Labelframe(pw, text="文本预览和转换", padding=15)
pw.add(right_panel, weight=3)
self.create_left_panel(left_panel)
self.create_right_panel(right_panel)
self.status_var = tk.StringVar()
self.status_var.set("准备就绪")
status_frame = ttk.Frame(main_frame)
status_frame.pack(fill=tk.X, pady=(10, 0))
status_label = ttk.Label(status_frame, textvariable=self.status_var, foreground='#666')
status_label.pack(side=tk.LEFT)
version_label = ttk.Label(status_frame, text="v1.0", foreground='#999')
version_label.pack(side=tk.RIGHT)
def update_display_vars(self, *args):
self.display_rate_var.set(f"{self.rate_var.get():.2f}")
self.display_volume_var.set(f"{self.volume_var.get():.2f}")
def create_left_panel(self, parent):
file_frame = ttk.LabelFrame(parent, text="文本文件", padding=10)
file_frame.pack(fill=tk.X, pady=(0, 10))
entry_frame = ttk.Frame(file_frame)
entry_frame.pack(fill=tk.X, expand=True)
self.txt_path = ttk.Entry(entry_frame, textvariable=self.file_path, width=30)
self.txt_path.pack(side=tk.LEFT, fill=tk.X, expand=True, padx=(0, 10))
btn_sel = ttk.Button(entry_frame, text="选择文件", command=self.select_file, width=10)
btn_sel.pack(side=tk.RIGHT)
output_frame = ttk.LabelFrame(parent, text="输出设置", padding=10)
output_frame.pack(fill=tk.X, pady=(0, 10))
dir_frame = ttk.Frame(output_frame)
dir_frame.pack(fill=tk.X, expand=True)
self.txt_output_dir = ttk.Entry(dir_frame, textvariable=self.output_dir, width=30)
self.txt_output_dir.pack(side=tk.LEFT, fill=tk.X, expand=True, padx=(0, 10))
btn_dir = ttk.Button(dir_frame, text="选择目录", command=self.select_output_dir, width=10)
btn_dir.pack(side=tk.RIGHT)
prefix_frame = ttk.Frame(output_frame)
prefix_frame.pack(fill=tk.X, pady=(5, 0))
ttk.Label(prefix_frame, text="文件名前缀:").pack(side=tk.LEFT)
self.prefix_entry = ttk.Entry(prefix_frame, width=15)
self.prefix_entry.pack(side=tk.LEFT, padx=(5, 0))
self.prefix_entry.insert(0, "wen_qing_zhou")
voice_frame = ttk.LabelFrame(parent, text="语音设置", padding=10)
voice_frame.pack(fill=tk.X, pady=(0, 10))
ttk.Label(voice_frame, text=f"当前语音:{self.target_voice}").pack(side=tk.LEFT)
rate_frame = ttk.Frame(voice_frame)
rate_frame.pack(fill=tk.X, pady=(5, 0))
ttk.Label(rate_frame, text="语速:").pack(side=tk.LEFT)
self.rate_scale = ttk.Scale(rate_frame, from_=1, to=100, variable=self.rate_var, orient=tk.HORIZONTAL)
self.rate_scale.pack(side=tk.LEFT, fill=tk.X, expand=True, padx=(5, 0))
self.rate_label = ttk.Label(rate_frame, textvariable=self.display_rate_var, width=6)
self.rate_label.pack(side=tk.LEFT)
volume_frame = ttk.Frame(voice_frame)
volume_frame.pack(fill=tk.X, pady=(5, 0))
ttk.Label(volume_frame, text="音量:").pack(side=tk.LEFT)
self.volume_scale = ttk.Scale(volume_frame, from_=1, to=100, variable=self.volume_var, orient=tk.HORIZONTAL)
self.volume_scale.pack(side=tk.LEFT, fill=tk.X, expand=True, padx=(5, 0))
self.volume_label = ttk.Label(volume_frame, textvariable=self.display_volume_var, width=6)
self.volume_label.pack(side=tk.LEFT)
def create_right_panel(self, parent):
preview_frame = ttk.LabelFrame(parent, text="文本预览", padding=10)
preview_frame.pack(fill=tk.BOTH, expand=True, pady=(0, 10))
self.text_preview = tk.Text(preview_frame, height=8, font=('微软雅黑', 10), wrap=tk.WORD)
self.text_preview.pack(fill=tk.BOTH, expand=True)
self.text_preview.bind('<<Modified>>', self.on_text_modified)
scrollbar = ttk.Scrollbar(preview_frame, orient=tk.VERTICAL, command=self.text_preview.yview)
scrollbar.pack(side=tk.RIGHT, fill=tk.Y)
self.text_preview.configure(yscrollcommand=scrollbar.set)
convert_frame = ttk.Frame(parent)
convert_frame.pack(fill=tk.X, pady=(10, 0))
self.btn_convert = ttk.Button(
convert_frame, text="转换为 MP3", command=self.convert_to_mp3, style='Accent.TButton')
self.btn_convert.pack(fill=tk.X, pady=(10, 0))
btn_batch = ttk.Button(
convert_frame, text="批量转换", command=self.batch_convert, style='Accent.TButton')
btn_batch.pack(fill=tk.X, pady=(5, 0))
btn_open_dir = ttk.Button(
convert_frame, text="打开输出目录", command=self.open_output_dir)
btn_open_dir.pack(fill=tk.X, pady=(5, 0))
self.progress = ttk.Progressbar(convert_frame, mode='indeterminate', length=200)
def load_voices(self):
try:
voices = self.engine.getProperty('voices')
for voice in voices:
if self.target_voice in voice.name:
self.voice_id = voice.id
break
if not self.voice_id:
if voices:
self.voice_id = voices[0].id
self.status_var.set(f"未找到 {self.target_voice} 语音,将使用 {voices[0].name}")
else:
raise Exception("未找到任何可用语音")
except Exception as e:
self.status_var.set(f"加载语音列表失败:{str(e)}")
def select_file(self):
current_file = self.file_path.get()
if current_file and os.path.exists(current_file):
initial_dir = os.path.dirname(current_file)
else:
initial_dir = str(pathlib.Path.home())
txt_file = filedialog.askopenfilename(
initialdir=initial_dir, title="选择文本文件", filetypes=(('文本文件', '*.txt'), ('所有文件', '*.*')))
if txt_file:
self.file_path.set(txt_file)
file_dir = os.path.dirname(txt_file)
self.output_dir.set(file_dir)
self.status_var.set(f"已选择文件:{pathlib.Path(txt_file).name}")
try:
with open(txt_file, 'r', encoding='utf8') as f:
content = f.read()
self.text_preview.delete(1.0, tk.END)
self.text_preview.insert(tk.END, content)
self.text_preview.edit_modified(False)
except Exception as e:
messagebox.showwarning("警告", f"无法读取文件内容:{str(e)}")
def select_output_dir(self):
current_file = self.file_path.get()
if current_file and os.path.exists(current_file):
initial_dir = os.path.dirname(current_file)
elif self.output_dir.get():
initial_dir = self.output_dir.get()
else:
initial_dir = str(pathlib.Path.home())
directory = filedialog.askdirectory(
initialdir=initial_dir, title="选择输出目录")
if directory:
self.output_dir.set(directory)
self.status_var.set(f"输出目录设置为:{directory}")
def on_text_modified(self, event):
if self.text_preview.edit_modified():
current_file = self.file_path.get()
if current_file and os.path.exists(current_file):
try:
content = self.text_preview.get(1.0, tk.END)
with open(current_file, 'w', encoding='utf8') as f:
f.write(content)
self.status_var.set(f"已自动保存修改到文件:{os.path.basename(current_file)}")
except Exception as e:
self.status_var.set(f"自动保存失败:{str(e)}")
self.text_preview.edit_modified(False)
def configure_engine(self):
try:
if self.voice_id:
self.engine.setProperty('voice', self.voice_id)
rate = self.rate_var.get()
volume = self.volume_var.get()
if rate <= 50:
progress = rate / 50.0
mapped_rate = 20 + (150 - 20) * (progress ** 1.5)
else:
progress = (rate - 50) / 50.0
mapped_rate = 150 + (300 - 150) * progress
mapped_volume = volume / 100.0
mapped_rate = max(20, min(300, mapped_rate))
mapped_volume = max(0.0, min(1.0, mapped_volume))
self.engine.setProperty('rate', int(mapped_rate))
self.engine.setProperty('volume', mapped_volume)
except Exception as e:
messagebox.showwarning("警告", f"配置语音引擎失败:{str(e)}")
def convert_to_mp3(self):
if not self.file_path.get():
messagebox.showwarning("警告", "请先选择文本文件!")
return
def convert_thread():
try:
self.btn_convert.state(['disabled'])
self.progress.pack(fill=tk.X, pady=(10, 0))
self.progress.start()
self.status_var.set("正在转换...")
self.update()
text = self.text_preview.get(1.0, tk.END)
if not text.strip():
raise ValueError("文本内容为空!")
self.configure_engine()
prefix = self.prefix_entry.get() or "TTS_Output"
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
output_name = f"{prefix}_{timestamp}.mp3"
output_dir = self.output_dir.get() or os.path.dirname(self.file_path.get()) or str(pathlib.Path.home())
output_path = pathlib.Path(output_dir) / output_name
self.engine.save_to_file(text, str(output_path))
self.engine.runAndWait()
self.progress.stop()
self.progress.pack_forget()
self.status_var.set(f"转换成功!文件已保存为:{output_path.name}")
messagebox.showinfo("成功", f"文件转换成功!\n已保存为:{output_path.name}")
except Exception as e:
self.progress.stop()
self.progress.pack_forget()
self.status_var.set("转换失败")
messagebox.showerror("错误", f"转换过程中出错:\n{str(e)}")
finally:
self.btn_convert.state(['!disabled'])
threading.Thread(target=convert_thread, daemon=True).start()
def batch_convert(self):
files = filedialog.askopenfilenames(
initialdir=os.path.dirname(self.file_path.get()) if self.file_path.get() else str(pathlib.Path.home()),
title="选择多个文本文件", filetypes=(('文本文件', '*.txt'), ('所有文件', '*.*')))
if not files:
return
def batch_thread():
try:
self.btn_convert.state(['disabled'])
self.progress.pack(fill=tk.X, pady=(10, 0))
self.progress.start()
self.status_var.set("正在批量转换...")
self.update()
success_count = 0
prefix = self.prefix_entry.get() or "TTS_Output"
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
self.configure_engine()
for i, file_path in enumerate(files, 1):
if not file_path:
continue
try:
with open(file_path, 'r', encoding='utf8') as f:
text = f.read()
if not text.strip():
continue
file_prefix = f"{prefix}_batch_{timestamp}_{i}"
output_name = f"{file_prefix}.mp3"
output_dir = self.output_dir.get() or os.path.dirname(file_path) or str(pathlib.Path.home())
output_path = pathlib.Path(output_dir) / output_name
self.engine.save_to_file(text, str(output_path))
self.engine.runAndWait()
success_count += 1
self.status_var.set(f"正在批量转换... 已完成 {i}/{len(files)}")
self.update()
except Exception as e:
self.status_var.set(f"转换 {os.path.basename(file_path)} 失败:{str(e)}")
continue
self.progress.stop()
self.progress.pack_forget()
self.status_var.set(f"批量转换完成!成功转换 {success_count}/{len(files)} 个文件")
messagebox.showinfo("完成", f"批量转换完成!\n成功转换 {success_count}/{len(files)} 个文件")
except Exception as e:
self.progress.stop()
self.progress.pack_forget()
self.status_var.set("批量转换失败")
messagebox.showerror("错误", f"批量转换过程中出错:\n{str(e)}")
finally:
self.btn_convert.state(['!disabled'])
threading.Thread(target=batch_thread, daemon=True).start()
def open_output_dir(self):
dir_path = self.output_dir.get()
if not dir_path and self.file_path.get():
dir_path = os.path.dirname(self.file_path.get())
if not dir_path or not os.path.isdir(dir_path):
messagebox.showwarning("警告", "无效的输出目录!")
return
try:
if platform.system() == "Windows":
os.startfile(dir_path)
elif platform.system() == "Darwin":
webbrowser.open(f"file://{dir_path}")
else:
webbrowser.open(dir_path)
except Exception as e:
messagebox.showerror("错误", f"无法打开目录:\n{str(e)}")
def show_help(self):
help_text = """文本转语音转换器使用说明
1. 基本使用:
- 点击'选择文件'按钮选择要转换的文本文件
- 在右侧预览区域可以查看和编辑文件内容(修改后会自动保存)
- 点击'转换为 MP3'进行转换(使用编辑后的内容)
2. 高级设置:
- 输出目录:默认使用当前文件所在目录,可手动修改
- 文件名前缀:设置输出文件的前缀名称
- 语音设置:固定使用 Microsoft Huihui 语音,可调整语速和音量
3. 批量转换:
- 点击'批量转换'按钮可以选择多个文本文件进行批量转换
- 转换后的文件会添加序号以避免重名
4. 其他功能:
- 打开输出目录:快速访问保存的 MP3 文件"""
messagebox.showinfo("帮助", help_text)
if __name__ == "__main__":
app = Application()
app.mainloop()