[Python] 纯文本查看 复制代码
# -*- coding: utf-8 -*-
"""
单词音频批量下载与合成工具(增强版)
新增功能:
- 音量标准化(自动均衡音量)
- 导出听写纸(含序号、空格、答案)
- 词库保存与加载(.vocab 格式)
- 从剪贴板导入(按空格、逗号、分号、换行、制表位分词)
布局优化:右侧面板分区更清晰,控件对齐与间距优化
"""
import tkinter as tk
from tkinter import ttk, filedialog, messagebox, scrolledtext
import requests
from bs4 import BeautifulSoup
import re
import os
import threading
import time
from datetime import datetime
import tempfile
import shutil
import subprocess
import sys
import json
import random
# 第三方库
try:
import openpyxl
except ImportError:
openpyxl = None
try:
from pydub import AudioSegment
from pydub import effects
except ImportError:
AudioSegment = None
effects = None
class AudioDownloaderApp:
def __init__(self, root):
self.root = root
self.root.title("单词音频批量下载与合成工具 - 增强版 (pythonfun)")
self.root.geometry("1100x700") # 略微调大窗口
self.root.minsize(880, 680)
# 全局字体
default_font = ('宋体', 12)
style = ttk.Style()
style.theme_use('xpnative')
style.configure('TButton', font=('宋体', 11), padding=(6, 3))
style.configure('TLabel', font=default_font)
style.configure('TCheckbutton', font=default_font)
style.configure('TLabelFrame', font=('宋体', 12, 'bold'))
self.root.option_add('*Font', default_font)
# 数据
self.word_list = []
self.downloaded_uk = set()
self.downloaded_us = set()
# 目录
self.base_dir = "sounds"
self.uk_dir = os.path.join(self.base_dir, "uk")
self.us_dir = os.path.join(self.base_dir, "us")
self._ensure_dirs()
# 状态变量
self.is_downloading = False
self.is_synthesizing = False
self.last_output_path = None
# 构建UI
self._create_widgets()
# 底部状态栏
self.status_var = tk.StringVar(value="欢迎使用 | 请导入单词或手动添加")
status_bar = ttk.Label(self.root, textvariable=self.status_var, relief=tk.SUNKEN, anchor=tk.W)
status_bar.pack(side=tk.BOTTOM, fill=tk.X, padx=12, pady=(0, 10))
# 启动时检查依赖并禁用相应按钮
self._check_dependencies()
# ------------------------------------------
# 目录管理
# ------------------------------------------
def _ensure_dirs(self):
os.makedirs(self.uk_dir, exist_ok=True)
os.makedirs(self.us_dir, exist_ok=True)
# ------------------------------------------
# 依赖检查
# ------------------------------------------
def _check_dependencies(self):
if openpyxl is None:
self.btn_import.config(state=tk.DISABLED, text="导入(需openpyxl)")
if AudioSegment is None:
self.btn_synth.config(state=tk.DISABLED, text="合成(需pydub)")
messagebox.showwarning("依赖缺失", "请安装 pydub 和 openpyxl\n命令行执行:pip install pydub openpyxl")
# ------------------------------------------
# 构建界面(左侧列表 + 右侧控制面板)
# ------------------------------------------
def _create_widgets(self):
# 主容器(PanedWindow可调比例)
main_pane = ttk.PanedWindow(self.root, orient=tk.HORIZONTAL)
main_pane.pack(fill=tk.BOTH, expand=True, padx=12, pady=12)
# ---------- 左侧:单词列表 ----------
left_frame = ttk.Frame(main_pane, padding="5")
main_pane.add(left_frame, weight=1)
self.list_title_var = tk.StringVar(value="单词列表(共 0 个)")
ttk.Label(left_frame, textvariable=self.list_title_var, font=('宋体', 13, 'bold')).pack(anchor=tk.W, pady=(0, 8))
list_frame = ttk.Frame(left_frame)
list_frame.pack(fill=tk.BOTH, expand=True)
scrollbar = ttk.Scrollbar(list_frame)
scrollbar.pack(side=tk.RIGHT, fill=tk.Y)
self.word_listbox = tk.Listbox(list_frame, yscrollcommand=scrollbar.set,
font=('宋体', 13), selectmode=tk.EXTENDED,
activestyle='dotbox', height=10)
self.word_listbox.pack(side=tk.LEFT, fill=tk.BOTH, expand=True)
scrollbar.config(command=self.word_listbox.yview)
# 列表底部操作按钮
list_btn_frame = ttk.Frame(left_frame)
list_btn_frame.pack(fill=tk.X, pady=(8, 0))
btn_del_selected = ttk.Button(list_btn_frame, text="删除选中", command=self._delete_selected)
btn_del_selected.pack(side=tk.LEFT, padx=(0, 6))
btn_clear_list = ttk.Button(list_btn_frame, text="清空列表", command=self._clear_list)
btn_clear_list.pack(side=tk.LEFT)
btn_up = ttk.Button(list_btn_frame, text="↑上移", command=self._move_up)
btn_up.pack(side=tk.LEFT, padx=(6, 0))
btn_down = ttk.Button(list_btn_frame, text="↓下移", command=self._move_down)
btn_down.pack(side=tk.LEFT, padx=(6, 0))
# ---------- 右侧:控制面板(分区) ----------
right_frame = ttk.Frame(main_pane, padding="5")
main_pane.add(right_frame, weight=2)
# ----- 区域1:导入与词库管理 -----
import_frame = ttk.LabelFrame(right_frame, text="导入与词库管理", padding="10")
import_frame.pack(fill=tk.X, pady=(0, 12))
# 第一行:文件导入 + 手动添加 + 粘贴导入
row1 = ttk.Frame(import_frame)
row1.pack(fill=tk.X, pady=4)
self.btn_import = ttk.Button(row1, text="导入TXT/XLSX", command=self.import_file)
self.btn_import.pack(side=tk.LEFT, padx=(0, 12))
ttk.Label(row1, text="手动添加:").pack(side=tk.LEFT, padx=(12, 5))
self.manual_entry = ttk.Entry(row1, width=14, font=('宋体', 12))
self.manual_entry.pack(side=tk.LEFT, padx=(0, 5))
self.manual_entry.bind('<Return>', lambda e: self._add_manual_word())
btn_add = ttk.Button(row1, text="添加", command=self._add_manual_word)
btn_add.pack(side=tk.LEFT, padx=(0, 12))
btn_paste = ttk.Button(row1, text="从剪贴板导入", command=self._import_from_clipboard)
btn_paste.pack(side=tk.LEFT)
# 第二行:词库保存与加载
row2 = ttk.Frame(import_frame)
row2.pack(fill=tk.X, pady=4)
btn_save_vocab = ttk.Button(row2, text="保存词库", command=self.save_vocabulary)
btn_save_vocab.pack(side=tk.LEFT, padx=(0, 12))
btn_load_vocab = ttk.Button(row2, text="加载词库", command=self.load_vocabulary)
btn_load_vocab.pack(side=tk.LEFT)
ttk.Label(row2, text="(*.vocab 文件)", font=('宋体', 10), foreground='gray').pack(side=tk.LEFT, padx=10)
# 分隔线
ttk.Separator(right_frame, orient=tk.HORIZONTAL).pack(fill=tk.X, pady=6)
# ----- 区域2:下载设置 -----
download_frame = ttk.LabelFrame(right_frame, text="⬇ 下载音频", padding="10")
download_frame.pack(fill=tk.X, pady=(0, 12))
self.download_uk_var = tk.BooleanVar(value=True)
self.download_us_var = tk.BooleanVar(value=True)
chk_uk = ttk.Checkbutton(download_frame, text="英音(uk)", variable=self.download_uk_var)
chk_uk.pack(side=tk.LEFT, padx=(0, 25))
chk_us = ttk.Checkbutton(download_frame, text="美音(us)", variable=self.download_us_var)
chk_us.pack(side=tk.LEFT)
btn_download = ttk.Button(download_frame, text="⬇开始下载", command=self.start_download)
btn_download.pack(side=tk.LEFT, padx=(25, 12))
self.download_status = ttk.Label(download_frame, text="就绪", foreground='green')
self.download_status.pack(side=tk.LEFT)
self.progress = ttk.Progressbar(download_frame, orient=tk.HORIZONTAL, length=180, mode='determinate')
self.progress.pack(side=tk.RIGHT, padx=(5, 0))
# 分隔线
ttk.Separator(right_frame, orient=tk.HORIZONTAL).pack(fill=tk.X, pady=6)
# ----- 区域3:合成与高级设置 -----
synth_frame = ttk.LabelFrame(right_frame, text="合成与高级设置", padding="10")
synth_frame.pack(fill=tk.X, pady=(0, 12))
# 第一行:顺序、间隔、遍数
row3 = ttk.Frame(synth_frame)
row3.pack(fill=tk.X, pady=4)
ttk.Label(row3, text="合成顺序:").pack(side=tk.LEFT, padx=(0, 5))
self.order_var = tk.StringVar(value="先英后美(uk_us)")
order_combo = ttk.Combobox(row3, textvariable=self.order_var,
values=["仅英音(uk)", "仅美音(us)", "先英后美(uk_us)", "先美后英(us_uk)"],
state="readonly", width=16)
order_combo.pack(side=tk.LEFT, padx=(0, 20))
ttk.Label(row3, text="间隔(秒):").pack(side=tk.LEFT, padx=(0, 5))
self.interval_var = tk.DoubleVar(value=1.0)
spin_interval = ttk.Spinbox(row3, from_=0.5, to=5.0, increment=0.1,
textvariable=self.interval_var, width=6)
spin_interval.pack(side=tk.LEFT, padx=(0, 15))
ttk.Label(row3, text="朗读遍数:").pack(side=tk.LEFT, padx=(0, 5))
self.repeat_var = tk.IntVar(value=1)
spin_repeat = ttk.Spinbox(row3, from_=1, to=5, increment=1,
textvariable=self.repeat_var, width=6)
spin_repeat.pack(side=tk.LEFT)
# 第二行:音量标准化 + 导出听写纸 + 其他操作
row4 = ttk.Frame(synth_frame)
row4.pack(fill=tk.X, pady=4)
self.normalize_var = tk.BooleanVar(value=True)
chk_normalize = ttk.Checkbutton(row4, text="音频标准化", variable=self.normalize_var)
chk_normalize.pack(side=tk.LEFT, padx=(0, 10))
self.btn_export_script = ttk.Button(row4, text="导出听写纸", command=self.export_script, state=tk.DISABLED)
self.btn_export_script.pack(side=tk.LEFT, padx=(0, 10))
ttk.Label(row4, text="(需先合成)", font=('宋体', 10), foreground='gray').pack(side=tk.LEFT)
# 新增:列表排序按钮行(放在导出听写纸后面)
btn_shuffle = ttk.Button(row4, text="随机顺序", command=self._shuffle_list)
btn_shuffle.pack(side=tk.LEFT, padx=(0, 10))
btn_sort = ttk.Button(row4, text="字母顺序", command=self._sort_list)
btn_sort.pack(side=tk.LEFT)
ttk.Label(row4, text="(列表调整)", font=('宋体', 10), foreground='gray').pack(side=tk.LEFT, padx=10)
# 第三行:合成按钮和状态 + 打开/复制/删除
row5 = ttk.Frame(synth_frame)
row5.pack(fill=tk.X, pady=6)
self.btn_synth = ttk.Button(row5, text="合成音频", command=self.start_synthesis)
self.btn_synth.pack(side=tk.LEFT, padx=(0, 20))
self.synth_status = ttk.Label(row5, text="就绪", foreground='green')
self.synth_status.pack(side=tk.LEFT)
self.btn_open_audio = ttk.Button(row5, text="打开音频", command=self.open_audio, state=tk.DISABLED)
self.btn_open_audio.pack(side=tk.LEFT, padx=(20, 0))
self.btn_copy_audio = ttk.Button(row5, text="复制音频", command=self.copy_audio, state=tk.DISABLED)
self.btn_copy_audio.pack(side=tk.LEFT, padx=(6, 0))
self.btn_del_audio = ttk.Button(row5, text="删除音频", command=self.delete_audio, state=tk.DISABLED)
self.btn_del_audio.pack(side=tk.LEFT, padx=(6, 0))
# 底部提示
ttk.Label(synth_frame, text="每个单词按指定顺序播放,间隔为单词间的停顿,朗读遍数控制重复次数",
font=('宋体', 10), foreground='gray').pack(anchor=tk.W, pady=(4, 0))
# ------------------------------------------
# 列表操作
# ------------------------------------------
def _update_listbox(self):
self.word_listbox.delete(0, tk.END)
for w in self.word_list:
self.word_listbox.insert(tk.END, w)
self.list_title_var.set(f"📝 单词列表(共 {len(self.word_list)} 个)")
def _move_up(self):
selected = self.word_listbox.curselection()
if not selected or selected[0] == 0:
return
idx = selected[0]
self.word_list[idx], self.word_list[idx-1] = self.word_list[idx-1], self.word_list[idx]
self._update_listbox()
self.word_listbox.selection_set(idx-1)
def _move_down(self):
selected = self.word_listbox.curselection()
if not selected or selected[0] == len(self.word_list)-1:
return
idx = selected[0]
self.word_list[idx], self.word_list[idx+1] = self.word_list[idx+1], self.word_list[idx]
self._update_listbox()
self.word_listbox.selection_set(idx+1)
def _add_manual_word(self):
word = self.manual_entry.get().strip()
if not word:
messagebox.showinfo("提示", "请输入单词")
return
if not re.match(r'^[a-zA-Z\-]+$', word):
messagebox.showwarning("提示", "只支持字母和连字符")
return
word = word.lower()
if word in self.word_list:
messagebox.showinfo("提示", f"单词 '{word}' 已在列表中")
return
self.word_list.append(word)
self._update_listbox()
self.manual_entry.delete(0, tk.END)
self.status_var.set(f"已添加单词: {word}")
def _delete_selected(self):
selected = self.word_listbox.curselection()
if not selected:
messagebox.showinfo("提示", "请先选中要删除的单词")
return
for idx in reversed(selected):
del self.word_list[idx]
self._update_listbox()
self.status_var.set(f"已删除 {len(selected)} 个单词")
def _clear_list(self):
if not self.word_list:
return
if messagebox.askyesno("确认", "确定要清空所有单词吗?"):
self.word_list.clear()
self._update_listbox()
self.status_var.set("已清空列表")
def _shuffle_list(self):
"""随机打乱单词列表顺序"""
if len(self.word_list) < 2:
messagebox.showinfo("提示", "列表单词数少于2个,无需随机")
return
random.shuffle(self.word_list)
self._update_listbox()
self.status_var.set("已随机打乱列表顺序")
def _sort_list(self):
"""按字母升序排列单词列表"""
if len(self.word_list) < 2:
messagebox.showinfo("提示", "列表单词数少于2个,无需排序")
return
self.word_list.sort(key=str.lower) # 不区分大小写排序
self._update_listbox()
self.status_var.set("已按字母顺序排列列表")
# ------------------------------------------
# 导入文件(原有)
# ------------------------------------------
def import_file(self):
file_path = filedialog.askopenfilename(
title="选择单词列表文件",
filetypes=[("文本文件", "*.txt"), ("Excel文件", "*.xlsx"), ("所有文件", "*.*")]
)
if not file_path:
return
append = messagebox.askyesno("导入方式", "是否追加到现有列表?\n(选“否”则覆盖现有列表)")
words = []
try:
if file_path.endswith('.txt'):
with open(file_path, 'r', encoding='utf-8') as f:
lines = f.readlines()
for line in lines:
w = line.strip()
if w and re.match(r'^[a-zA-Z\-]+$', w):
words.append(w.lower())
elif file_path.endswith('.xlsx'):
if openpyxl is None:
messagebox.showerror("错误", "未安装 openpyxl,请执行: pip install openpyxl")
return
wb = openpyxl.load_workbook(file_path, read_only=True)
sheet = wb.active
for row in sheet.iter_rows(values_only=True):
if row and row[0]:
cell_val = str(row[0]).strip()
if cell_val and re.match(r'^[a-zA-Z\-]+$', cell_val):
words.append(cell_val.lower())
wb.close()
else:
messagebox.showwarning("提示", "不支持的文件格式,请选择 .txt 或 .xlsx")
return
if not words:
messagebox.showinfo("提示", "未提取到有效单词(只接受字母和连字符)")
return
unique = []
seen = set()
for w in words:
if w not in seen:
seen.add(w)
unique.append(w)
if append:
existing_set = set(self.word_list)
for w in unique:
if w not in existing_set:
self.word_list.append(w)
existing_set.add(w)
else:
self.word_list = unique
self._update_listbox()
self.manual_entry.delete(0, tk.END)
self.status_var.set(f"已{'追加' if append else '导入'} {len(self.word_list)} 个单词")
except Exception as e:
messagebox.showerror("导入错误", f"读取文件失败: {str(e)}")
self.status_var.set("导入失败")
# ------------------------------------------
# 从剪贴板导入(新增)
# ------------------------------------------
def _import_from_clipboard(self):
try:
raw_text = self.root.clipboard_get()
except tk.TclError:
messagebox.showwarning("提示", "剪贴板为空或无法读取")
return
if not raw_text.strip():
messagebox.showinfo("提示", "剪贴板内容为空")
return
# 按空格、逗号、分号、换行、制表位分割(支持中英文标点)
tokens = re.split(r'[\s,;,;\t\n\r]+', raw_text.strip())
words = []
for token in tokens:
token = token.strip()
if token and re.match(r'^[a-zA-Z\-]+$', token):
words.append(token.lower())
if not words:
messagebox.showinfo("提示", "未提取到有效单词(只接受字母和连字符)")
return
# 去重保留顺序
unique = []
seen = set()
for w in words:
if w not in seen:
seen.add(w)
unique.append(w)
# 询问追加还是覆盖
append = messagebox.askyesno("导入方式", f"从剪贴板提取到 {len(unique)} 个单词。\n是否追加到现有列表?\n(选“否”则覆盖现有列表)")
if append:
existing_set = set(self.word_list)
for w in unique:
if w not in existing_set:
self.word_list.append(w)
existing_set.add(w)
else:
self.word_list = unique
self._update_listbox()
self.status_var.set(f"已从剪贴板{'追加' if append else '导入'} {len(unique)} 个单词")
messagebox.showinfo("导入完成", f"成功{'追加' if append else '导入'} {len(unique)} 个单词")
# ------------------------------------------
# 词库保存与加载(不变)
# ------------------------------------------
def save_vocabulary(self):
if not self.word_list:
messagebox.showwarning("提示", "单词列表为空,无需保存")
return
file_path = filedialog.asksaveasfilename(
defaultextension=".vocab",
filetypes=[("词库文件", "*.vocab"), ("所有文件", "*.*")],
initialfile="my_vocab.vocab"
)
if file_path:
try:
data = {"words": self.word_list, "timestamp": datetime.now().isoformat()}
with open(file_path, 'w', encoding='utf-8') as f:
json.dump(data, f, ensure_ascii=False, indent=2)
messagebox.showinfo("保存成功", f"词库已保存至:{file_path}")
self.status_var.set(f"词库已保存: {os.path.basename(file_path)}")
except Exception as e:
messagebox.showerror("保存失败", f"保存词库出错: {str(e)}")
def load_vocabulary(self):
file_path = filedialog.askopenfilename(
title="加载词库文件",
filetypes=[("词库文件", "*.vocab"), ("所有文件", "*.*")]
)
if not file_path:
return
try:
with open(file_path, 'r', encoding='utf-8') as f:
data = json.load(f)
words = data.get("words", [])
if not words:
messagebox.showwarning("提示", "词库中没有单词")
return
self.word_list = words
self._update_listbox()
self.status_var.set(f"已加载词库: {os.path.basename(file_path)} ({len(words)} 个单词)")
messagebox.showinfo("加载成功", f"成功加载 {len(words)} 个单词")
except Exception as e:
messagebox.showerror("加载失败", f"加载词库出错: {str(e)}")
# ------------------------------------------
# 下载功能(保持不变)
# ------------------------------------------
def start_download(self):
if self.is_downloading:
return
if not self.word_list:
messagebox.showwarning("提示", "单词列表为空")
return
if not self.download_uk_var.get() and not self.download_us_var.get():
messagebox.showwarning("提示", "请至少选择一种发音")
return
self.is_downloading = True
self.download_status.config(text="下载中...", foreground='blue')
self.progress['value'] = 0
self.progress['maximum'] = len(self.word_list)
thread = threading.Thread(target=self._download_worker)
thread.daemon = True
thread.start()
def _download_worker(self):
download_uk = self.download_uk_var.get()
download_us = self.download_us_var.get()
existing_uk = {os.path.splitext(f)[0] for f in os.listdir(self.uk_dir) if f.endswith('.mp3')}
existing_us = {os.path.splitext(f)[0] for f in os.listdir(self.us_dir) if f.endswith('.mp3')}
success = 0
failed = []
for idx, word in enumerate(self.word_list):
if not self.is_downloading:
break
self.root.after(0, lambda i=idx: self.progress.config(value=i+1))
if download_uk and word not in existing_uk:
ok = self._download_single_audio(word, 'uk')
if ok:
success += 1
existing_uk.add(word)
else:
failed.append(f"{word} (uk)")
if download_us and word not in existing_us:
ok = self._download_single_audio(word, 'us')
if ok:
success += 1
existing_us.add(word)
else:
failed.append(f"{word} (us)")
time.sleep(0.2)
self.root.after(0, self._download_finished, success, failed)
def _download_single_audio(self, word, accent):
try:
txt_param = self._get_txt_param(word, accent)
if not txt_param:
return False
if accent == 'uk':
url = f"https://api.frdic.com/api/v2/speech/speakweb?langid=en&voicename=en_uk_male&txt={txt_param}"
save_dir = self.uk_dir
else:
url = f"https://api.frdic.com/api/v2/speech/speakweb?langid=en&voicename=en_us_female&txt={txt_param}"
save_dir = self.us_dir
headers = {"User-Agent": "Mozilla/5.0", "Referer": "https://dict.eudic.net/"}
resp = requests.get(url, headers=headers, timeout=15)
if resp.status_code != 200:
return False
filename = os.path.join(save_dir, f"{word}.mp3")
with open(filename, 'wb') as f:
f.write(resp.content)
return True
except Exception:
return False
def _get_txt_param(self, word, accent):
try:
url = f"https://dict.eudic.net/dicts/en/{word}"
headers = {"User-Agent": "Mozilla/5.0"}
resp = requests.get(url, headers=headers, timeout=10)
if resp.status_code != 200:
return None
soup = BeautifulSoup(resp.text, 'html.parser')
target = 'en_uk_male' if accent == 'uk' else 'en_us_female'
for btn in soup.find_all('a', class_='voice-js'):
rel = btn.get('data-rel', '')
if target in rel:
match = re.search(r'txt=([^&]+)', rel)
if match:
return match.group(1)
return None
except Exception:
return None
def _download_finished(self, success, failed):
self.is_downloading = False
self.progress['value'] = 0
msg = f"下载完成!成功 {success} 个"
if failed:
msg += f",失败 {len(failed)} 个"
self.download_status.config(text="部分失败", foreground='orange')
messagebox.showwarning("下载完成", f"{msg}\n失败单词: {', '.join(failed[:10])}")
else:
self.download_status.config(text="下载完成", foreground='green')
messagebox.showinfo("下载完成", msg)
self.status_var.set(msg)
# ------------------------------------------
# 合成功能(新增音量标准化)
# ------------------------------------------
def start_synthesis(self):
if self.is_synthesizing:
return
if not self.word_list:
messagebox.showwarning("提示", "单词列表为空")
return
if AudioSegment is None:
messagebox.showerror("错误", "未安装 pydub,请执行: pip install pydub")
return
order_str = self.order_var.get()
if "仅英音" in order_str:
order_list = ['uk']
elif "仅美音" in order_str:
order_list = ['us']
elif "先英后美" in order_str:
order_list = ['uk', 'us']
elif "先美后英" in order_str:
order_list = ['us', 'uk']
else:
order_list = ['uk', 'us']
repeat_times = self.repeat_var.get()
if repeat_times < 1:
repeat_times = 1
elif repeat_times > 5:
repeat_times = 5
missing = []
for accent in order_list:
dir_path = self.uk_dir if accent == 'uk' else self.us_dir
for word in self.word_list:
if not os.path.exists(os.path.join(dir_path, f"{word}.mp3")):
missing.append(f"{word} ({accent})")
if missing:
messagebox.showwarning("缺少音频",
f"以下音频文件缺失,请先下载:\n" + "\n".join(missing[:20]) +
(f"\n... 共 {len(missing)} 个" if len(missing) > 20 else ""))
return
self.is_synthesizing = True
self.synth_status.config(text="合成中...", foreground='blue')
self.btn_open_audio.config(state=tk.DISABLED)
self.btn_copy_audio.config(state=tk.DISABLED)
self.btn_del_audio.config(state=tk.DISABLED)
self.btn_export_script.config(state=tk.DISABLED)
thread = threading.Thread(target=self._synthesis_worker, args=(order_list, repeat_times))
thread.daemon = True
thread.start()
def _synthesis_worker(self, order_list, repeat_times):
try:
interval_ms = int(self.interval_var.get() * 1000)
normalize = self.normalize_var.get()
combined = None
silence = AudioSegment.silent(duration=interval_ms) if interval_ms > 0 else None
total_steps = len(self.word_list) * repeat_times * len(order_list)
step = 0
self.root.after(0, lambda: self.progress.config(maximum=total_steps, value=0))
for word in self.word_list:
for _ in range(repeat_times):
for accent in order_list:
dir_path = self.uk_dir if accent == 'uk' else self.us_dir
file_path = os.path.join(dir_path, f"{word}.mp3")
seg = AudioSegment.from_mp3(file_path)
if normalize and effects is not None:
seg = effects.normalize(seg, headroom=1.0)
if combined is None:
combined = seg
else:
if silence:
combined += silence
combined += seg
step += 1
self.root.after(0, lambda s=step: self.progress.config(value=s))
if _ < repeat_times - 1 and silence:
combined += silence
if combined is None:
raise Exception("没有可合成的音频")
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
suffix = "_".join(order_list)
repeat_suffix = f"_x{repeat_times}" if repeat_times > 1 else ""
filename = f"{timestamp}_{suffix}{repeat_suffix}.mp3"
output_path = os.path.join(os.getcwd(), filename)
combined.export(output_path, format="mp3")
self.root.after(0, self._synthesis_finished, True, output_path)
except Exception as e:
self.root.after(0, self._synthesis_finished, False, str(e))
def _synthesis_finished(self, success, info):
self.is_synthesizing = False
self.progress['value'] = 0
if success:
self.synth_status.config(text="合成完成", foreground='green')
self.last_output_path = info
self.btn_open_audio.config(state=tk.NORMAL)
self.btn_copy_audio.config(state=tk.NORMAL)
self.btn_del_audio.config(state=tk.NORMAL)
self.btn_export_script.config(state=tk.NORMAL)
messagebox.showinfo("合成完成", f"音频已保存至: {info}")
self.status_var.set(f"合成成功: {info}")
else:
self.synth_status.config(text="合成失败", foreground='red')
self.btn_open_audio.config(state=tk.DISABLED)
self.btn_copy_audio.config(state=tk.DISABLED)
self.btn_del_audio.config(state=tk.DISABLED)
self.btn_export_script.config(state=tk.DISABLED)
messagebox.showerror("合成失败", f"错误: {info}")
self.status_var.set("合成失败")
# ------------------------------------------
# 导出听写纸
# ------------------------------------------
def export_script(self):
if not self.word_list:
messagebox.showwarning("提示", "单词列表为空,无法导出")
return
file_path = filedialog.asksaveasfilename(
defaultextension=".txt",
filetypes=[("文本文件", "*.txt"), ("所有文件", "*.*")],
initialfile="听写纸.txt"
)
if not file_path:
return
try:
with open(file_path, 'w', encoding='utf-8') as f:
f.write("听写练习\n")
f.write("=" * 30 + "\n\n")
f.write("请听音频,写出对应的单词:\n\n")
for i, word in enumerate(self.word_list, 1):
f.write(f"{i}. ___________\n")
f.write("\n" + "=" * 30 + "\n")
f.write("参考答案:\n")
for i, word in enumerate(self.word_list, 1):
f.write(f"{i}. {word}\n")
messagebox.showinfo("导出成功", f"听写纸已保存至:{file_path}")
self.status_var.set(f"听写纸已导出: {os.path.basename(file_path)}")
except Exception as e:
messagebox.showerror("导出失败", f"导出听写纸出错: {str(e)}")
# ------------------------------------------
# 打开、复制、删除音频
# ------------------------------------------
def open_audio(self):
if not self.last_output_path or not os.path.exists(self.last_output_path):
messagebox.showerror("错误", "音频文件不存在")
return
try:
if sys.platform.startswith('win'):
os.startfile(self.last_output_path)
elif sys.platform.startswith('darwin'):
subprocess.Popen(['open', self.last_output_path])
else:
subprocess.Popen(['xdg-open', self.last_output_path])
except Exception as e:
messagebox.showerror("打开失败", f"无法打开音频文件: {str(e)}")
def copy_audio(self):
if not self.last_output_path or not os.path.exists(self.last_output_path):
messagebox.showerror("错误", "没有可复制的音频文件")
return
save_path = filedialog.asksaveasfilename(
defaultextension=".mp3",
filetypes=[("MP3 文件", "*.mp3")],
initialfile=os.path.basename(self.last_output_path)
)
if save_path:
try:
shutil.copy2(self.last_output_path, save_path)
messagebox.showinfo("复制成功", f"音频已复制到:\n{save_path}")
self.status_var.set(f"已复制音频至: {save_path}")
except Exception as e:
messagebox.showerror("复制失败", f"复制出错: {str(e)}")
def delete_audio(self):
if not self.last_output_path or not os.path.exists(self.last_output_path):
messagebox.showerror("错误", "音频文件不存在")
return
if not messagebox.askyesno("确认删除", f"确定要删除音频文件吗?\n{self.last_output_path}"):
return
try:
with open(self.last_output_path, 'r+b') as f:
pass
os.remove(self.last_output_path)
self.last_output_path = None
self.btn_open_audio.config(state=tk.DISABLED)
self.btn_copy_audio.config(state=tk.DISABLED)
self.btn_del_audio.config(state=tk.DISABLED)
self.btn_export_script.config(state=tk.DISABLED)
self.status_var.set("音频已删除")
messagebox.showinfo("删除成功", "音频文件已删除")
except PermissionError:
messagebox.showerror("删除失败", "音频文件正在使用中,请关闭播放器后再删除")
except Exception as e:
messagebox.showerror("删除失败", f"删除出错: {str(e)}")
# ------------------------------------------
# 窗口关闭清理
# ------------------------------------------
def on_closing(self):
if self.is_downloading or self.is_synthesizing:
if not messagebox.askokcancel("确认", "正在后台处理,确定退出吗?"):
return
self.root.destroy()
if __name__ == "__main__":
root = tk.Tk()
app = AudioDownloaderApp(root)
root.protocol("WM_DELETE_WINDOW", app.on_closing)
root.mainloop()