吾爱破解 - 52pojie.cn

 找回密码
 注册[Register]

QQ登录

只需一步,快速开始

查看: 1178|回复: 31
上一主题 下一主题
收起左侧

[Python 原创] 单词音频批量下载与合成工具附源码

[复制链接]
跳转到指定楼层
楼主
pythonfun 发表于 2026-7-7 20:46 回帖奖励
本帖最后由 pythonfun 于 2026-7-7 20:50 编辑

一、软件名称

单词音频批量下载与合成工具

二、软件简介

本软件主要解决英语学习者在制作听力练习材料、单词听写音频时手动逐个下载和拼接音频的繁琐问题。通过调用在线语音合成API,支持批量下载英音和美音单词音频,并按自定义顺序合成连续的MP3文件,同时内置音量标准化、听写纸导出、词库管理等功能,为教师、学生和自学者提供一站式的单词音频制作解决方案,让听力素材制作变得高效、专业。

三、软件功能

本软件是一款基于Python开发的单词音频批量处理工具,主要功能包括:

  1. 双音标下载:支持英音(uk)和美音(us)两种发音的批量下载,可单独或同时选择
  2. 灵活合成设置:支持自定义合成顺序(仅英音、仅美音、先英后美、先美后英)、单词间隔时长(0.5~5秒)和朗读遍数(1~5遍)
  3. 音量标准化:自动均衡各音频文件的音量,使合成后的整体响度一致,避免音量突变
  4. 智能单词管理:支持手动添加、批量导入(TXT/Excel)、从剪贴板导入(自动按空格、逗号、分号、换行、制表位分词)
  5. 词库持久化:支持以.vocab格式保存和加载单词列表,方便复用和分享
  6. 列表排序与调整:支持随机打乱、字母排序,以及上移/下移调整单词顺序
  7. 导出听写纸:自动生成含序号、填空横线和参考答案的听写练习纸(TXT格式),方便打印使用
  8. 合成音频管理:合成完成后可一键打开文件位置、复制到指定目录或删除音频
  9. 进度反馈:下载和合成过程中实时显示进度条和状态信息

四、使用方法和操作步骤

  1. 运行程序后,打开主界面,左侧为单词列表区域,右侧为控制面板
  2. 导入单词:可通过“导入TXT/XLSX”按钮选择文件,或在手动输入框中添加单词后点击“添加”,或点击“从剪贴板导入”粘贴文本(支持多种分隔符)
  3. 管理词库:使用“保存词库”和“加载词库”按钮保存当前列表为.vocab文件或加载已有词库
  4. 调整列表顺序:使用“随机顺序”、“字母顺序”或“↑上移/↓下移”按钮调整单词播放次序
  5. 下载音频:勾选需要的发音类型(英音/美音),点击“开始下载”,程序将自动为列表中的每个单词下载对应的MP3音频文件(存放于sounds/uksounds/us目录)
  6. 合成音频:在合成设置区选择合成顺序、间隔秒数和朗读遍数,可选“音频标准化”,点击“合成音频”按钮,等待合成完成
  7. 导出听写纸:合成成功后(或直接使用现有列表),点击“导出听写纸”按钮,选择保存路径,生成含填空和答案的TXT文件
  8. 操作合成结果:合成完成后,可点击“打开音频”播放、“复制音频”另存为其他位置,或“删除音频”清理文件
  9. 状态栏和进度条会实时提示当前操作进度和结果

五、运行环境

  • 系统要求:支持Windows、macOS、Linux等操作系统
  • Python环境:需要Python 3.6及以上,并安装以下依赖库:
    • tkinter(通常内置)
    • requests
    • beautifulsoup4
    • pydub(用于音频合成与标准化)
    • openpyxl(用于导入Excel文件)
  • 网络要求:下载音频功能需要互联网连接,以访问在线语音合成API
  • 音频文件目录:程序自动在运行目录下创建sounds/uksounds/us文件夹,用于存放下载的音频

六、技术特点说明

  • 数据获取技术:基于requests库调用在线语音合成API(海词/欧路词典接口),通过解析网页获取动态txt参数,实现高质量发音音频的批量拉取
  • 音频处理技术:利用pydub库进行音频合并、间隔插入和音量标准化(effects.normalize),确保输出音频的流畅性和一致性
  • 词库管理技术:采用JSON格式序列化单词列表,支持读写.vocab文件,便于数据持久化和跨设备迁移
  • 界面交互技术:基于tkinter构建图形界面,利用多线程(threading)实现后台下载和合成,避免界面卡顿
  • 剪贴板解析技术:通过正则表达式智能分割多种分隔符(空格、逗号、分号、换行、制表位),提升导入效率

七、特色亮点

  • 批量高效:一次性处理数百个单词,大幅节省手动搜索和下载时间
  • 灵活定制:合成顺序、间隔、遍数均可调节,满足不同教学场景(听写、跟读、考试练习)
  • 智能均衡:音量标准化功能消除不同单词音频间的响度差异,提升听觉体验
  • 无缝衔接:从导入、下载、合成到导出听写纸,全流程一体化,无需切换工具
  • 易用友好:直观的界面设计,清晰的进度反馈,并支持撤销、排序等辅助操作
  • 可复用性:词库保存功能支持建立个人词库库,便于多课程、多班级重复使用

八、注意事项

  • 下载音频依赖外部API服务,需保持网络畅通;若API变动或失效,可能影响下载功能
  • 首次使用需安装pydubopenpyxl,可通过pip install pydub openpyxl安装
  • 音频文件保存于本地sounds目录,请勿手动修改文件命名,以免合成时找不到对应文件
  • 合成音频时建议根据单词长度和数量适当调整间隔时长,过短可能导致语音重叠
  • 听写纸导出功能仅生成TXT文本,如需打印可自行调整格式或导入Word编辑
  • 本工具仅供个人学习使用,请勿用于商业用途或大量爬取API资源

九、界面展示


打包软件下载:https://wwaws.lanzouq.com/ig2tN3uys1sf[/font]

十、源码展示


[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="&#11015; 下载音频", 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="&#11015;开始下载", 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"&#128221; 单词列表(共 {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()

免费评分

参与人数 6吾爱币 +12 热心值 +6 收起 理由
苏紫方璇 + 7 + 1 欢迎分析讨论交流,吾爱破解论坛有你更精彩!
zhaostar + 1 + 1 谢谢@Thanks!
shuangluo + 1 + 1 我很赞同!
heavenman + 1 + 1 感谢发布原创作品,吾爱破解论坛因你更精彩!
CEXO3574 + 1 + 1 谢谢@Thanks!
yinduyang444 + 1 + 1 谢谢@Thanks!

查看全部评分

本帖被以下淘专辑推荐:

发帖前要善用论坛搜索功能,那里可能会有你要找的答案或者已经有人发布过相同内容了,请勿重复发帖。

推荐
sheward999 发表于 2026-7-9 10:24
本帖最后由 sheward999 于 2026-7-9 10:25 编辑

单纯的单词听写意义不是很大。我是这样做的:
1. 从课本找出单词及对应的例句,将单词音频与例句音频合并在一起;
2. 如果是单词书教材,比如:BR200/BR400/BR800/BR1200/1000 Basic English Words/2000 Core English Words,也是利用其官方音频,将单词音频与例句音频合并在一起;
3. 制作成Anki/SuperMemo课程,学习对应的单词后,再在Anki/SuperMemo App上进行复习。Anki/SuperMemo会根据每次复习考核的评分(记得、差不多、忘记等),来动态调整复习间隔。
推荐
忧郁之子 发表于 2026-7-9 15:25
在作者原界面基础上增加一个美化过的版本
[Python] 纯文本查看 复制代码
# -*- coding: utf-8 -*-
"""
单词音频批量下载与合成工具(PySide6 美化版)
使用 PySide6 重构,采用现代化 UI 设计
"""

import sys
import os
import re
import json
import random
import threading
import time
from datetime import datetime
import shutil
import subprocess
from pathlib import Path

import requests
from bs4 import BeautifulSoup

# PySide6 导入
from PySide6.QtWidgets import *
from PySide6.QtCore import *
from PySide6.QtGui import *

# 第三方库
try:
    import openpyxl
except ImportError:
    openpyxl = None

try:
    from pydub import AudioSegment
    from pydub import effects
except ImportError:
    AudioSegment = None
    effects = None


class DownloadWorker(QThread):
    """下载工作线程"""
    progress = Signal(int, int)  # 当前进度, 总数
    finished = Signal(int, list)  # 成功数, 失败列表
    status = Signal(str, str)  # 状态文本, 颜色
    
    def __init__(self, word_list, uk_dir, us_dir, download_uk, download_us):
        super().__init__()
        self.word_list = word_list
        self.uk_dir = uk_dir
        self.us_dir = us_dir
        self.download_uk = download_uk
        self.download_us = download_us
        self._stop_requested = False
        
    def stop(self):
        self._stop_requested = True
        
    def run(self):
        existing_uk = {Path(f).stem for f in os.listdir(self.uk_dir) if f.endswith('.mp3')}
        existing_us = {Path(f).stem for f in os.listdir(self.us_dir) if f.endswith('.mp3')}
        
        success = 0
        failed = []
        total = len(self.word_list)
        
        for idx, word in enumerate(self.word_list):
            if self._stop_requested:
                break
                
            self.progress.emit(idx + 1, total)
            
            if self.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 self.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.finished.emit(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


class SynthesisWorker(QThread):
    """合成工作线程"""
    progress = Signal(int, int)
    finished = Signal(bool, str)
    
    def __init__(self, word_list, uk_dir, us_dir, order_list, repeat_times, interval_ms, normalize):
        super().__init__()
        self.word_list = word_list
        self.uk_dir = uk_dir
        self.us_dir = us_dir
        self.order_list = order_list
        self.repeat_times = repeat_times
        self.interval_ms = interval_ms
        self.normalize = normalize
        self._stop_requested = False
        
    def stop(self):
        self._stop_requested = True
        
    def run(self):
        try:
            silence = AudioSegment.silent(duration=self.interval_ms) if self.interval_ms > 0 else None
            
            total_steps = len(self.word_list) * self.repeat_times * len(self.order_list)
            step = 0
            self.progress.emit(step, total_steps)
            
            combined = None
            
            for word in self.word_list:
                for rep in range(self.repeat_times):
                    if self._stop_requested:
                        break
                    
                    for accent in self.order_list:
                        dir_path = self.uk_dir if accent == 'uk' else self.us_dir
                        file_path = os.path.join(dir_path, f"{word}.mp3")
                        
                        if not os.path.exists(file_path):
                            raise FileNotFoundError(f"缺少音频: {file_path}")
                        
                        seg = AudioSegment.from_mp3(file_path)
                        if self.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.progress.emit(step, total_steps)
                    
                    if rep < self.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(self.order_list)
            repeat_suffix = f"_x{self.repeat_times}" if self.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.finished.emit(True, output_path)
            
        except Exception as e:
            self.finished.emit(False, str(e))


class AudioDownloaderApp(QMainWindow):
    def __init__(self):
        super().__init__()
        
        # 数据
        self.word_list = []
        self.downloaded_uk = set()
        self.downloaded_us = set()
        self.last_output_path = None
        
        # 目录
        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.download_thread = None
        self.synthesis_thread = None
        
        # 初始化UI
        self.setup_ui()
        self.apply_styles()
        self.check_dependencies()
        
        # 窗口设置
        self.setWindowTitle("单词音频批量下载与合成工具 - 增强版")
        self.setMinimumSize(1200, 800)
        self.resize(1300, 850)
        
        # 居中显示
        self.center_window()
        
    def center_window(self):
        """窗口居中"""
        screen = QApplication.primaryScreen().geometry()
        size = self.geometry()
        self.move(
            (screen.width() - size.width()) // 2,
            (screen.height() - size.height()) // 2
        )
    
    def setup_ui(self):
        """构建UI"""
        central_widget = QWidget()
        self.setCentralWidget(central_widget)
        
        # 主布局
        main_layout = QHBoxLayout(central_widget)
        main_layout.setSpacing(20)
        main_layout.setContentsMargins(20, 20, 20, 20)
        
        # 左侧面板
        left_panel = self.create_left_panel()
        main_layout.addWidget(left_panel, 1)
        
        # 右侧面板
        right_panel = self.create_right_panel()
        main_layout.addWidget(right_panel, 2)
        
        # 状态栏
        self.status_bar = QStatusBar()
        self.setStatusBar(self.status_bar)
        self.status_bar.showMessage("欢迎使用 | 请导入单词或手动添加")
        
        # 创建菜单栏
        self.create_menu_bar()
    
    def create_menu_bar(self):
        """创建菜单栏"""
        menubar = self.menuBar()
        
        # 文件菜单
        file_menu = menubar.addMenu("文件")
        
        save_action = QAction(QIcon.fromTheme("document-save"), "保存词库", self)
        save_action.triggered.connect(self.save_vocabulary)
        file_menu.addAction(save_action)
        
        load_action = QAction(QIcon.fromTheme("document-open"), "加载词库", self)
        load_action.triggered.connect(self.load_vocabulary)
        file_menu.addAction(load_action)
        
        file_menu.addSeparator()
        
        exit_action = QAction(QIcon.fromTheme("application-exit"), "退出", self)
        exit_action.triggered.connect(self.close)
        file_menu.addAction(exit_action)
        
        # 工具菜单
        tools_menu = menubar.addMenu("工具")
        
        export_action = QAction(QIcon.fromTheme("document-export"), "导出听写纸", self)
        export_action.triggered.connect(self.export_script)
        tools_menu.addAction(export_action)
        
        # 帮助菜单
        help_menu = menubar.addMenu("帮助")
        
        about_action = QAction(QIcon.fromTheme("help-about"), "关于", self)
        about_action.triggered.connect(self.show_about)
        help_menu.addAction(about_action)
    
    def create_left_panel(self):
        """创建左侧面板"""
        panel = QFrame()
        panel.setObjectName("leftPanel")
        layout = QVBoxLayout(panel)
        layout.setSpacing(15)
        
        # 标题
        title_label = QLabel("&#128218; 单词列表")
        title_label.setObjectName("panelTitle")
        layout.addWidget(title_label)
        
        # 统计标签
        self.list_title_label = QLabel("共 0 个单词")
        self.list_title_label.setObjectName("statsLabel")
        layout.addWidget(self.list_title_label)
        
        # 列表控件
        self.word_listbox = QListWidget()
        self.word_listbox.setObjectName("wordList")
        self.word_listbox.setSelectionMode(QAbstractItemView.ExtendedSelection)
        self.word_listbox.setAlternatingRowColors(True)
        layout.addWidget(self.word_listbox)
        
        # 列表操作按钮
        list_btn_layout = QHBoxLayout()
        
        btn_delete = QPushButton("&#128465;&#65039; 删除选中")
        btn_delete.setObjectName("secondaryButton")
        btn_delete.clicked.connect(self.delete_selected)
        list_btn_layout.addWidget(btn_delete)
        
        btn_clear = QPushButton("&#129529; 清空列表")
        btn_clear.setObjectName("secondaryButton")
        btn_clear.clicked.connect(self.clear_list)
        list_btn_layout.addWidget(btn_clear)
        
        layout.addLayout(list_btn_layout)
        
        # 移动按钮
        move_btn_layout = QHBoxLayout()
        
        btn_up = QPushButton("&#11014;&#65039; 上移")
        btn_up.setObjectName("iconButton")
        btn_up.clicked.connect(self.move_up)
        move_btn_layout.addWidget(btn_up)
        
        btn_down = QPushButton("&#11015;&#65039; 下移")
        btn_down.setObjectName("iconButton")
        btn_down.clicked.connect(self.move_down)
        move_btn_layout.addWidget(btn_down)
        
        btn_shuffle = QPushButton("&#128256; 随机")
        btn_shuffle.setObjectName("iconButton")
        btn_shuffle.clicked.connect(self.shuffle_list)
        move_btn_layout.addWidget(btn_shuffle)
        
        btn_sort = QPushButton("&#128292; 排序")
        btn_sort.setObjectName("iconButton")
        btn_sort.clicked.connect(self.sort_list)
        move_btn_layout.addWidget(btn_sort)
        
        layout.addLayout(move_btn_layout)
        
        return panel
    
    def create_right_panel(self):
        """创建右侧面板"""
        panel = QFrame()
        panel.setObjectName("rightPanel")
        layout = QVBoxLayout(panel)
        layout.setSpacing(20)
        
        # 导入区域
        import_group = self.create_import_section()
        layout.addWidget(import_group)
        
        # 下载区域
        download_group = self.create_download_section()
        layout.addWidget(download_group)
        
        # 合成区域
        synth_group = self.create_synthesis_section()
        layout.addWidget(synth_group)
        
        # 进度条
        self.progress_bar = QProgressBar()
        self.progress_bar.setTextVisible(True)
        self.progress_bar.setFormat("%v / %m 项")
        self.progress_bar.hide()
        layout.addWidget(self.progress_bar)
        
        layout.addStretch()
        
        return panel
    
    def create_import_section(self):
        """创建导入区域"""
        group = QGroupBox("&#128229; 导入与词库管理")
        group.setObjectName("sectionGroup")
        layout = QVBoxLayout(group)
        layout.setSpacing(12)
        
        # 第一行:文件导入 + 手动添加 + 粘贴导入
        row1 = QHBoxLayout()
        
        self.btn_import = QPushButton("&#128196; 导入 TXT/XLSX")
        self.btn_import.setObjectName("primaryButton")
        self.btn_import.clicked.connect(self.import_file)
        row1.addWidget(self.btn_import)
        
        row1.addSpacing(20)
        
        row1.addWidget(QLabel("&#9999;&#65039; 手动添加:"))
        self.manual_entry = QLineEdit()
        self.manual_entry.setPlaceholderText("输入单词后回车添加")
        self.manual_entry.returnPressed.connect(self.add_manual_word)
        row1.addWidget(self.manual_entry)
        
        btn_add = QPushButton("&#10133; 添加")
        btn_add.setObjectName("secondaryButton")
        btn_add.clicked.connect(self.add_manual_word)
        row1.addWidget(btn_add)
        
        btn_paste = QPushButton("&#128203; 从剪贴板导入")
        btn_paste.setObjectName("secondaryButton")
        btn_paste.clicked.connect(self.import_from_clipboard)
        row1.addWidget(btn_paste)
        
        row1.addStretch()
        layout.addLayout(row1)
        
        # 第二行:词库保存与加载
        row2 = QHBoxLayout()
        
        btn_save_vocab = QPushButton("&#128190; 保存词库")
        btn_save_vocab.setObjectName("secondaryButton")
        btn_save_vocab.clicked.connect(self.save_vocabulary)
        row2.addWidget(btn_save_vocab)
        
        btn_load_vocab = QPushButton("&#128194; 加载词库")
        btn_load_vocab.setObjectName("secondaryButton")
        btn_load_vocab.clicked.connect(self.load_vocabulary)
        row2.addWidget(btn_load_vocab)
        
        row2.addWidget(QLabel("(*.vocab 文件)"))
        row2.addStretch()
        layout.addLayout(row2)
        
        return group
    
    def create_download_section(self):
        """创建下载区域"""
        group = QGroupBox("&#128266; 下载音频")
        group.setObjectName("sectionGroup")
        layout = QVBoxLayout(group)
        layout.setSpacing(12)
        
        # 发音选择
        accent_layout = QHBoxLayout()
        accent_layout.addWidget(QLabel("选择发音:"))
        
        self.download_uk_cb = QCheckBox("&#127468;&#127463; 英音 (UK)")
        self.download_uk_cb.setChecked(True)
        accent_layout.addWidget(self.download_uk_cb)
        
        self.download_us_cb = QCheckBox("&#127482;&#127480; 美音 (US)")
        self.download_us_cb.setChecked(True)
        accent_layout.addWidget(self.download_us_cb)
        
        accent_layout.addStretch()
        layout.addLayout(accent_layout)
        
        # 下载按钮和状态
        download_layout = QHBoxLayout()
        
        self.btn_download = QPushButton("&#11015;&#65039; 开始下载")
        self.btn_download.setObjectName("accentButton")
        self.btn_download.clicked.connect(self.start_download)
        download_layout.addWidget(self.btn_download)
        
        self.download_status_label = QLabel("&#9989; 就绪")
        self.download_status_label.setObjectName("statusLabel")
        download_layout.addWidget(self.download_status_label)
        
        download_layout.addStretch()
        layout.addLayout(download_layout)
        
        return group
    
    def create_synthesis_section(self):
        """创建合成区域"""
        group = QGroupBox("&#127925; 合成与高级设置")
        group.setObjectName("sectionGroup")
        layout = QVBoxLayout(group)
        layout.setSpacing(12)
        
        # 第一行:顺序、间隔、遍数
        row1 = QHBoxLayout()
        
        row1.addWidget(QLabel("合成顺序:"))
        self.order_combo = QComboBox()
        self.order_combo.addItems([
            "仅英音 (UK)",
            "仅美音 (US)",
            "先英后美 (UK→US)",
            "先美后英 (US→UK)"
        ])
        self.order_combo.setCurrentIndex(2)
        row1.addWidget(self.order_combo)
        
        row1.addSpacing(20)
        
        row1.addWidget(QLabel("间隔(秒):"))
        self.interval_spin = QDoubleSpinBox()
        self.interval_spin.setRange(0.5, 5.0)
        self.interval_spin.setValue(1.0)
        self.interval_spin.setSingleStep(0.1)
        row1.addWidget(self.interval_spin)
        
        row1.addSpacing(20)
        
        row1.addWidget(QLabel("朗读遍数:"))
        self.repeat_spin = QSpinBox()
        self.repeat_spin.setRange(1, 5)
        self.repeat_spin.setValue(1)
        row1.addWidget(self.repeat_spin)
        
        row1.addStretch()
        layout.addLayout(row1)
        
        # 第二行:选项
        row2 = QHBoxLayout()
        
        self.normalize_cb = QCheckBox("&#128266; 音频标准化")
        self.normalize_cb.setChecked(True)
        row2.addWidget(self.normalize_cb)
        
        row2.addSpacing(20)
        
        self.btn_export_script = QPushButton("&#128221; 导出听写纸")
        self.btn_export_script.setObjectName("secondaryButton")
        self.btn_export_script.clicked.connect(self.export_script)
        self.btn_export_script.setEnabled(False)
        row2.addWidget(self.btn_export_script)
        
        row2.addSpacing(10)
        row2.addWidget(QLabel("(需先合成)"))
        
        row2.addStretch()
        layout.addLayout(row2)
        
        # 第三行:合成按钮和操作
        row3 = QHBoxLayout()
        
        self.btn_synth = QPushButton("&#127926; 合成音频")
        self.btn_synth.setObjectName("accentButton")
        self.btn_synth.clicked.connect(self.start_synthesis)
        row3.addWidget(self.btn_synth)
        
        self.synth_status_label = QLabel("&#9989; 就绪")
        self.synth_status_label.setObjectName("statusLabel")
        row3.addWidget(self.synth_status_label)
        
        row3.addSpacing(30)
        
        self.btn_open_audio = QPushButton("&#128194; 打开音频")
        self.btn_open_audio.setObjectName("secondaryButton")
        self.btn_open_audio.clicked.connect(self.open_audio)
        self.btn_open_audio.setEnabled(False)
        row3.addWidget(self.btn_open_audio)
        
        self.btn_copy_audio = QPushButton("&#128203; 复制音频")
        self.btn_copy_audio.setObjectName("secondaryButton")
        self.btn_copy_audio.clicked.connect(self.copy_audio)
        self.btn_copy_audio.setEnabled(False)
        row3.addWidget(self.btn_copy_audio)
        
        self.btn_del_audio = QPushButton("&#128465;&#65039; 删除音频")
        self.btn_del_audio.setObjectName("dangerButton")
        self.btn_del_audio.clicked.connect(self.delete_audio)
        self.btn_del_audio.setEnabled(False)
        row3.addWidget(self.btn_del_audio)
        
        row3.addStretch()
        layout.addLayout(row3)
        
        # 提示
        hint_label = QLabel("&#128161; 提示: 每个单词按指定顺序播放,间隔为单词间的停顿,朗读遍数控制重复次数")
        hint_label.setObjectName("hintLabel")
        hint_label.setWordWrap(True)
        layout.addWidget(hint_label)
        
        return group
    
    def apply_styles(self):
        """应用样式表"""
        self.setStyleSheet("""
            QMainWindow {
                background-color: #f5f7fa;
            }
            
            QFrame#leftPanel, QFrame#rightPanel {
                background-color: white;
                border-radius: 12px;
                border: 1px solid #e1e8ed;
            }
            
            QGroupBox#sectionGroup {
                font-size: 14px;
                font-weight: bold;
                color: #1da1f2;
                border: 2px solid #e1e8ed;
                border-radius: 8px;
                margin-top: 12px;
                padding-top: 10px;
            }
            
            QGroupBox#sectionGroup::title {
                subcontrol-origin: margin;
                left: 15px;
                padding: 0 5px;
            }
            
            QLabel#panelTitle {
                font-size: 18px;
                font-weight: bold;
                color: #14171a;
            }
            
            QLabel#statsLabel {
                font-size: 13px;
                color: #657786;
                padding: 5px 0;
            }
            
            QLabel#statusLabel {
                font-size: 13px;
                color: #17bf63;
                padding: 5px 10px;
                background-color: #e8f5e9;
                border-radius: 4px;
            }
            
            QLabel#hintLabel {
                font-size: 12px;
                color: #657786;
                padding: 8px;
                background-color: #f8f9fa;
                border-radius: 4px;
                border-left: 3px solid #1da1f2;
            }
            
            QListWidget#wordList {
                border: 1px solid #e1e8ed;
                border-radius: 6px;
                padding: 5px;
                font-size: 14px;
                alternate-background-color: #f8f9fa;
            }
            
            QListWidget#wordList::item {
                padding: 8px;
                border-bottom: 1px solid #e1e8ed;
            }
            
            QListWidget#wordList::item:selected {
                background-color: #1da1f2;
                color: white;
            }
            
            QLineEdit {
                padding: 8px 12px;
                border: 1px solid #e1e8ed;
                border-radius: 6px;
                font-size: 14px;
                background-color: white;
            }
            
            QLineEdit:focus {
                border: 2px solid #1da1f2;
            }
            
            QPushButton#primaryButton {
                background-color: #1da1f2;
                color: white;
                border: none;
                padding: 10px 20px;
                border-radius: 6px;
                font-weight: bold;
                font-size: 13px;
            }
            
            QPushButton#primaryButton:hover {
                background-color: #1991db;
            }
            
            QPushButton#primaryButton:pressed {
                background-color: #1a81c2;
            }
            
            QPushButton#secondaryButton {
                background-color: #f8f9fa;
                color: #14171a;
                border: 1px solid #e1e8ed;
                padding: 8px 16px;
                border-radius: 6px;
                font-size: 13px;
            }
            
            QPushButton#secondaryButton:hover {
                background-color: #e9ecef;
                border-color: #adb5bd;
            }
            
            QPushButton#accentButton {
                background-color: #17bf63;
                color: white;
                border: none;
                padding: 10px 24px;
                border-radius: 6px;
                font-weight: bold;
                font-size: 14px;
            }
            
            QPushButton#accentButton:hover {
                background-color: #15a853;
            }
            
            QPushButton#dangerButton {
                background-color: #e0245e;
                color: white;
                border: none;
                padding: 8px 16px;
                border-radius: 6px;
                font-size: 13px;
            }
            
            QPushButton#dangerButton:hover {
                background-color: #c91845;
            }
            
            QPushButton#iconButton {
                background-color: transparent;
                border: 1px solid #e1e8ed;
                padding: 6px 12px;
                border-radius: 6px;
                font-size: 12px;
            }
            
            QPushButton#iconButton:hover {
                background-color: #f8f9fa;
            }
            
            QPushButton:disabled {
                background-color: #e9ecef;
                color: #adb5bd;
                border-color: #dee2e6;
            }
            
            QComboBox, QSpinBox, QDoubleSpinBox {
                padding: 6px 12px;
                border: 1px solid #e1e8ed;
                border-radius: 6px;
                font-size: 13px;
                background-color: white;
                min-width: 80px;
            }
            
            QComboBox:focus, QSpinBox:focus, QDoubleSpinBox:focus {
                border: 2px solid #1da1f2;
            }
            
            QComboBox::drop-down {
                border: none;
                width: 20px;
            }
            
            QCheckBox {
                font-size: 13px;
                spacing: 8px;
            }
            
            QCheckBox::indicator {
                width: 18px;
                height: 18px;
                border: 2px solid #e1e8ed;
                border-radius: 3px;
            }
            
            QCheckBox::indicator:checked {
                background-color: #1da1f2;
                border-color: #1da1f2;
                image: url(check.png);
            }
            
            QProgressBar {
                border: 1px solid #e1e8ed;
                border-radius: 6px;
                text-align: center;
                font-size: 12px;
                color: #14171a;
                background-color: #f8f9fa;
                height: 24px;
            }
            
            QProgressBar::chunk {
                background-color: qlineargradient(x1:0, y1:0, x2:1, y2:0,
                    stop:0 #1da1f2, stop:1 #17bf63);
                border-radius: 5px;
            }
            
            QStatusBar {
                background-color: #f8f9fa;
                color: #657786;
                border-top: 1px solid #e1e8ed;
                font-size: 13px;
            }
            
            QMenuBar {
                background-color: white;
                border-bottom: 1px solid #e1e8ed;
            }
            
            QMenuBar::item {
                padding: 8px 12px;
                background-color: transparent;
            }
            
            QMenuBar::item:selected {
                background-color: #f8f9fa;
            }
            
            QMenu {
                background-color: white;
                border: 1px solid #e1e8ed;
                border-radius: 6px;
                padding: 5px;
            }
            
            QMenu::item {
                padding: 8px 20px;
                border-radius: 4px;
            }
            
            QMenu::item:selected {
                background-color: #f8f9fa;
            }
        """)
    
    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.setEnabled(False)
            self.btn_import.setText("导入(需openpyxl)")
        
        if AudioSegment is None:
            self.btn_synth.setEnabled(False)
            self.btn_synth.setText("合成(需pydub)")
            QMessageBox.warning(
                self, "依赖缺失",
                "请安装 pydub 和 openpyxl\n命令行执行:pip install pydub openpyxl"
            )
    
    # ============ 列表操作方法 ============
    def update_listbox(self):
        """更新列表显示"""
        self.word_listbox.clear()
        for word in self.word_list:
            item = QListWidgetItem(f"&#128214; {word}")
            self.word_listbox.addItem(item)
        self.list_title_label.setText(f"共 {len(self.word_list)} 个单词")
    
    def move_up(self):
        """上移选中项"""
        current_row = self.word_listbox.currentRow()
        if current_row <= 0:
            return
        
        item = self.word_list.pop(current_row)
        self.word_list.insert(current_row - 1, item)
        self.update_listbox()
        self.word_listbox.setCurrentRow(current_row - 1)
    
    def move_down(self):
        """下移选中项"""
        current_row = self.word_listbox.currentRow()
        if current_row >= len(self.word_list) - 1 or current_row < 0:
            return
        
        item = self.word_list.pop(current_row)
        self.word_list.insert(current_row + 1, item)
        self.update_listbox()
        self.word_listbox.setCurrentRow(current_row + 1)
    
    def add_manual_word(self):
        """手动添加单词"""
        word = self.manual_entry.text().strip().lower()
        if not word:
            QMessageBox.information(self, "提示", "请输入单词")
            return
        
        if not re.match(r'^[a-zA-Z\-]+$', word):
            QMessageBox.warning(self, "提示", "只支持字母和连字符")
            return
        
        if word in self.word_list:
            QMessageBox.information(self, "提示", f"单词 '{word}' 已在列表中")
            return
        
        self.word_list.append(word)
        self.update_listbox()
        self.manual_entry.clear()
        self.status_bar.showMessage(f"已添加单词: {word}")
    
    def delete_selected(self):
        """删除选中单词"""
        selected_items = self.word_listbox.selectedItems()
        if not selected_items:
            QMessageBox.information(self, "提示", "请先选中要删除的单词")
            return
        
        for item in selected_items:
            word = item.text()[2:]  # 去掉emoji
            if word in self.word_list:
                self.word_list.remove(word)
        
        self.update_listbox()
        self.status_bar.showMessage(f"已删除 {len(selected_items)} 个单词")
    
    def clear_list(self):
        """清空列表"""
        if not self.word_list:
            return
        
        reply = QMessageBox.question(
            self, "确认", "确定要清空所有单词吗?",
            QMessageBox.Yes | QMessageBox.No, QMessageBox.No
        )
        
        if reply == QMessageBox.Yes:
            self.word_list.clear()
            self.update_listbox()
            self.status_bar.showMessage("已清空列表")
    
    def shuffle_list(self):
        """随机打乱列表"""
        if len(self.word_list) < 2:
            QMessageBox.information(self, "提示", "列表单词数少于2个,无需随机")
            return
        
        random.shuffle(self.word_list)
        self.update_listbox()
        self.status_bar.showMessage("已随机打乱列表顺序")
    
    def sort_list(self):
        """按字母排序"""
        if len(self.word_list) < 2:
            QMessageBox.information(self, "提示", "列表单词数少于2个,无需排序")
            return
        
        self.word_list.sort(key=str.lower)
        self.update_listbox()
        self.status_bar.showMessage("已按字母顺序排列列表")
    
    # ============ 导入方法 ============
    def import_file(self):
        """导入文件"""
        file_path, _ = QFileDialog.getOpenFileName(
            self, "选择单词列表文件", "",
            "文本文件 (*.txt);;Excel文件 (*.xlsx);;所有文件 (*.*)"
        )
        
        if not file_path:
            return
        
        # 询问导入方式
        reply = QMessageBox.question(
            self, "导入方式",
            "是否追加到现有列表?\n(选“否”则覆盖现有列表)",
            QMessageBox.Yes | QMessageBox.No | QMessageBox.Cancel,
            QMessageBox.Yes
        )
        
        if reply == QMessageBox.Cancel:
            return
        
        append = (reply == QMessageBox.Yes)
        
        try:
            words = []
            
            if file_path.endswith('.txt'):
                with open(file_path, 'r', encoding='utf-8') as f:
                    for line in f:
                        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:
                    QMessageBox.critical(self, "错误", "未安装 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:
                QMessageBox.warning(self, "提示", "不支持的文件格式,请选择 .txt 或 .xlsx")
                return
            
            if not words:
                QMessageBox.information(self, "提示", "未提取到有效单词(只接受字母和连字符)")
                return
            
            # 去重
            unique_words = []
            seen = set()
            for w in words:
                if w not in seen:
                    seen.add(w)
                    unique_words.append(w)
            
            if append:
                existing_set = set(self.word_list)
                for w in unique_words:
                    if w not in existing_set:
                        self.word_list.append(w)
                        existing_set.add(w)
            else:
                self.word_list = unique_words
            
            self.update_listbox()
            self.manual_entry.clear()
            self.status_bar.showMessage(f"已{'追加' if append else '导入'} {len(self.word_list)} 个单词")
            
        except Exception as e:
            QMessageBox.critical(self, "导入错误", f"读取文件失败: {str(e)}")
            self.status_bar.showMessage("导入失败")
    
    def import_from_clipboard(self):
        """从剪贴板导入"""
        clipboard = QApplication.clipboard()
        raw_text = clipboard.text()
        
        if not raw_text.strip():
            QMessageBox.information(self, "提示", "剪贴板内容为空")
            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:
            QMessageBox.information(self, "提示", "未提取到有效单词(只接受字母和连字符)")
            return
        
        # 去重
        unique_words = []
        seen = set()
        for w in words:
            if w not in seen:
                seen.add(w)
                unique_words.append(w)
        
        # 询问导入方式
        reply = QMessageBox.question(
            self, "导入方式",
            f"从剪贴板提取到 {len(unique_words)} 个单词。\n是否追加到现有列表?\n(选“否”则覆盖现有列表)",
            QMessageBox.Yes | QMessageBox.No | QMessageBox.Cancel,
            QMessageBox.Yes
        )
        
        if reply == QMessageBox.Cancel:
            return
        
        append = (reply == QMessageBox.Yes)
        
        if append:
            existing_set = set(self.word_list)
            for w in unique_words:
                if w not in existing_set:
                    self.word_list.append(w)
                    existing_set.add(w)
        else:
            self.word_list = unique_words
        
        self.update_listbox()
        self.status_bar.showMessage(f"已从剪贴板{'追加' if append else '导入'} {len(unique_words)} 个单词")
        QMessageBox.information(self, "导入完成", f"成功{'追加' if append else '导入'} {len(unique_words)} 个单词")
    
    # ============ 词库管理方法 ============
    def save_vocabulary(self):
        """保存词库"""
        if not self.word_list:
            QMessageBox.warning(self, "提示", "单词列表为空,无需保存")
            return
        
        file_path, _ = QFileDialog.getSaveFileName(
            self, "保存词库", "my_vocab.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)
                
                QMessageBox.information(self, "保存成功", f"词库已保存至:{file_path}")
                self.status_bar.showMessage(f"词库已保存: {os.path.basename(file_path)}")
            except Exception as e:
                QMessageBox.critical(self, "保存失败", f"保存词库出错: {str(e)}")
    
    def load_vocabulary(self):
        """加载词库"""
        file_path, _ = QFileDialog.getOpenFileName(
            self, "加载词库文件", "",
            "词库文件 (*.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:
                QMessageBox.warning(self, "提示", "词库中没有单词")
                return
            
            self.word_list = words
            self.update_listbox()
            self.status_bar.showMessage(f"已加载词库: {os.path.basename(file_path)} ({len(words)} 个单词)")
            QMessageBox.information(self, "加载成功", f"成功加载 {len(words)} 个单词")
        except Exception as e:
            QMessageBox.critical(self, "加载失败", f"加载词库出错: {str(e)}")
    
    # ============ 下载方法 ============
    def start_download(self):
        """开始下载"""
        if self.download_thread and self.download_thread.isRunning():
            return
        
        if not self.word_list:
            QMessageBox.warning(self, "提示", "单词列表为空")
            return
        
        if not self.download_uk_cb.isChecked() and not self.download_us_cb.isChecked():
            QMessageBox.warning(self, "提示", "请至少选择一种发音")
            return
        
        self.download_status_label.setText("&#9203; 下载中...")
        self.download_status_label.setStyleSheet("""
            QLabel#statusLabel {
                color: #ffad1f;
                background-color: #fff8e1;
            }
        """)
        
        self.progress_bar.setMaximum(len(self.word_list))
        self.progress_bar.setValue(0)
        self.progress_bar.show()
        
        self.download_thread = DownloadWorker(
            self.word_list,
            self.uk_dir,
            self.us_dir,
            self.download_uk_cb.isChecked(),
            self.download_us_cb.isChecked()
        )
        
        self.download_thread.progress.connect(self.update_download_progress)
        self.download_thread.finished.connect(self.download_finished)
        self.download_thread.start()
    
    def update_download_progress(self, current, total):
        """更新下载进度"""
        self.progress_bar.setValue(current)
        self.progress_bar.setFormat(f"{current} / {total} 项")
    
    def download_finished(self, success_count, failed_list):
        """下载完成"""
        self.progress_bar.hide()
        
        msg = f"下载完成!成功 {success_count} 个"
        
        if failed_list:
            msg += f",失败 {len(failed_list)} 个"
            self.download_status_label.setText("&#9888;&#65039; 部分失败")
            self.download_status_label.setStyleSheet("""
                QLabel#statusLabel {
                    color: #ffad1f;
                    background-color: #fff8e1;
                }
            """)
            
            QMessageBox.warning(
                self, "下载完成",
                f"{msg}\n失败单词: {', '.join(failed_list[:10])}"
                + (f"\n... 共 {len(failed_list)} 个" if len(failed_list) > 10 else "")
            )
        else:
            self.download_status_label.setText("&#9989; 下载完成")
            self.download_status_label.setStyleSheet("""
                QLabel#statusLabel {
                    color: #17bf63;
                    background-color: #e8f5e9;
                }
            """)
            QMessageBox.information(self, "下载完成", msg)
        
        self.status_bar.showMessage(msg)
    
    # ============ 合成方法 ============
    def start_synthesis(self):
        """开始合成"""
        if self.synthesis_thread and self.synthesis_thread.isRunning():
            return
        
        if not self.word_list:
            QMessageBox.warning(self, "提示", "单词列表为空")
            return
        
        if AudioSegment is None:
            QMessageBox.critical(self, "错误", "未安装 pydub,请执行: pip install pydub")
            return
        
        # 解析合成顺序
        order_text = self.order_combo.currentText()
        if "仅英音" in order_text:
            order_list = ['uk']
        elif "仅美音" in order_text:
            order_list = ['us']
        elif "先英后美" in order_text:
            order_list = ['uk', 'us']
        elif "先美后英" in order_text:
            order_list = ['us', 'uk']
        else:
            order_list = ['uk', 'us']
        
        # 检查缺失文件
        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:
            QMessageBox.warning(
                self, "缺少音频",
                f"以下音频文件缺失,请先下载:\n" + "\n".join(missing[:20])
                + (f"\n... 共 {len(missing)} 个" if len(missing) > 20 else "")
            )
            return
        
        self.synth_status_label.setText("&#9203; 合成中...")
        self.synth_status_label.setStyleSheet("""
            QLabel#statusLabel {
                color: #ffad1f;
                background-color: #fff8e1;
            }
        """)
        
        self.btn_open_audio.setEnabled(False)
        self.btn_copy_audio.setEnabled(False)
        self.btn_del_audio.setEnabled(False)
        self.btn_export_script.setEnabled(False)
        
        self.progress_bar.setMaximum(len(self.word_list) * self.repeat_spin.value() * len(order_list))
        self.progress_bar.setValue(0)
        self.progress_bar.show()
        
        self.synthesis_thread = SynthesisWorker(
            self.word_list,
            self.uk_dir,
            self.us_dir,
            order_list,
            self.repeat_spin.value(),
            int(self.interval_spin.value() * 1000),
            self.normalize_cb.isChecked()
        )
        
        self.synthesis_thread.progress.connect(self.update_synthesis_progress)
        self.synthesis_thread.finished.connect(self.synthesis_finished)
        self.synthesis_thread.start()
    
    def update_synthesis_progress(self, current, total):
        """更新合成进度"""
        self.progress_bar.setValue(current)
        self.progress_bar.setFormat(f"{current} / {total} 步")
    
    def synthesis_finished(self, success, info):
        """合成完成"""
        self.progress_bar.hide()
        
        if success:
            self.synth_status_label.setText("&#9989; 合成完成")
            self.synth_status_label.setStyleSheet("""
                QLabel#statusLabel {
                    color: #17bf63;
                    background-color: #e8f5e9;
                }
            """)
            
            self.last_output_path = info
            self.btn_open_audio.setEnabled(True)
            self.btn_copy_audio.setEnabled(True)
            self.btn_del_audio.setEnabled(True)
            self.btn_export_script.setEnabled(True)
            
            QMessageBox.information(self, "合成完成", f"音频已保存至: {info}")
            self.status_bar.showMessage(f"合成成功: {info}")
        else:
            self.synth_status_label.setText("&#10060; 合成失败")
            self.synth_status_label.setStyleSheet("""
                QLabel#statusLabel {
                    color: #e0245e;
                    background-color: #ffebee;
                }
            """)
            
            self.btn_open_audio.setEnabled(False)
            self.btn_copy_audio.setEnabled(False)
            self.btn_del_audio.setEnabled(False)
            self.btn_export_script.setEnabled(False)
            
            QMessageBox.critical(self, "合成失败", f"错误: {info}")
            self.status_bar.showMessage("合成失败")
    
    # ============ 导出听写纸 ============
    def export_script(self):
        """导出听写纸"""
        if not self.word_list:
            QMessageBox.warning(self, "提示", "单词列表为空,无法导出")
            return
        
        file_path, _ = QFileDialog.getSaveFileName(
            self, "导出听写纸", "听写纸.txt",
            "文本文件 (*.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")
            
            QMessageBox.information(self, "导出成功", f"听写纸已保存至:{file_path}")
            self.status_bar.showMessage(f"听写纸已导出: {os.path.basename(file_path)}")
        except Exception as e:
            QMessageBox.critical(self, "导出失败", f"导出听写纸出错: {str(e)}")
    
    # ============ 音频文件操作 ============
    def open_audio(self):
        """打开音频文件"""
        if not self.last_output_path or not os.path.exists(self.last_output_path):
            QMessageBox.critical(self, "错误", "音频文件不存在")
            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:
            QMessageBox.critical(self, "打开失败", f"无法打开音频文件: {str(e)}")
    
    def copy_audio(self):
        """复制音频文件"""
        if not self.last_output_path or not os.path.exists(self.last_output_path):
            QMessageBox.critical(self, "错误", "没有可复制的音频文件")
            return
        
        save_path, _ = QFileDialog.getSaveFileName(
            self, "复制音频", os.path.basename(self.last_output_path),
            "MP3 文件 (*.mp3);;所有文件 (*.*)"
        )
        
        if save_path:
            try:
                shutil.copy2(self.last_output_path, save_path)
                QMessageBox.information(self, "复制成功", f"音频已复制到:\n{save_path}")
                self.status_bar.showMessage(f"已复制音频至: {save_path}")
            except Exception as e:
                QMessageBox.critical(self, "复制失败", f"复制出错: {str(e)}")
    
    def delete_audio(self):
        """删除音频文件"""
        if not self.last_output_path or not os.path.exists(self.last_output_path):
            QMessageBox.critical(self, "错误", "音频文件不存在")
            return
        
        reply = QMessageBox.question(
            self, "确认删除",
            f"确定要删除音频文件吗?\n{self.last_output_path}",
            QMessageBox.Yes | QMessageBox.No, QMessageBox.No
        )
        
        if reply == QMessageBox.Yes:
            try:
                os.remove(self.last_output_path)
                self.last_output_path = None
                self.btn_open_audio.setEnabled(False)
                self.btn_copy_audio.setEnabled(False)
                self.btn_del_audio.setEnabled(False)
                self.btn_export_script.setEnabled(False)
                self.status_bar.showMessage("音频已删除")
                QMessageBox.information(self, "删除成功", "音频文件已删除")
            except PermissionError:
                QMessageBox.critical(self, "删除失败", "音频文件正在使用中,请关闭播放器后再删除")
            except Exception as e:
                QMessageBox.critical(self, "删除失败", f"删除出错: {str(e)}")
    
    def show_about(self):
        """显示关于对话框"""
        about_text = """
        <h3>单词音频批量下载与合成工具</h3>
        <p><b>版本:</b> 2.0 (PySide6 美化版)</p>
        <p><b>功能:</b></p>
        <ul>
            <li>批量下载英音/美音单词音频</li>
            <li>音频合成与音量标准化</li>
            <li>词库保存与加载 (.vocab)</li>
            <li>从剪贴板快速导入</li>
            <li>导出听写纸</li>
        </ul>
        <p><b>原作者:</b> pythonfun</p>
        <p><b>美化作者:</b>忧郁之子</p>
        <p><b>依赖:</b> PySide6, requests, beautifulsoup4, pydub, openpyxl</p>
        """
        
        QMessageBox.about(self, "关于", about_text)
    
    def closeEvent(self, event):
        """窗口关闭事件"""
        if (self.download_thread and self.download_thread.isRunning()) or \
           (self.synthesis_thread and self.synthesis_thread.isRunning()):
            
            reply = QMessageBox.question(
                self, "确认退出",
                "正在后台处理,确定退出吗?",
                QMessageBox.Yes | QMessageBox.No, QMessageBox.No
            )
            
            if reply == QMessageBox.Yes:
                if self.download_thread:
                    self.download_thread.stop()
                    self.download_thread.wait()
                if self.synthesis_thread:
                    self.synthesis_thread.stop()
                    self.synthesis_thread.wait()
                event.accept()
            else:
                event.ignore()
        else:
            event.accept()


def main():
    app = QApplication(sys.argv)
    
    # 设置应用程序图标(如果有的话)
    # app.setWindowIcon(QIcon("icon.png"))
    
    # 设置应用程序名称
    app.setApplicationName("单词音频工具")
    app.setOrganizationName("pythonfun")
    
    # 创建并显示主窗口
    window = AudioDownloaderApp()
    window.show()
    
    sys.exit(app.exec())


if __name__ == "__main__":
    main()

推荐
jacktong85 发表于 2026-7-7 21:59
这个批量下载方便,加强听音识别能力。比猫爪一个个下载好用多了

点评

感谢支持。  发表于 2026-7-7 22:07
3#
zhu791023529 发表于 2026-7-7 22:19
本帖最后由 zhu791023529 于 2026-7-7 22:21 编辑

合成失败:错误:[WinError2]系统找不到指定的文件。
4#
sqqqqq 发表于 2026-7-8 00:01
下来试试,有时候得给孩子听写,但是有的不会发音
5#
hnxylhl 发表于 2026-7-8 08:12
已试过了,简直好的不要不要的,谢谢分享。

图片1.png (28.88 KB, 下载次数: 0)

图片1.png
6#
t9k9k9 发表于 2026-7-8 08:32
合成失败:错误:[WinError2]系统找不到指定的文件。
7#
CEXO3574 发表于 2026-7-8 08:49
好像很不错,给小孩子备一个。
8#
iawyxkdn8 发表于 2026-7-8 09:05
真的很好用,
9#
xiaoxino 发表于 2026-7-8 09:47
下来看看什么样的
10#
jialife 发表于 2026-7-8 10:32
感谢分享,很好用,分流给有需要的大佬

百度:https://pan.baidu.com/s/1nbFgO-4RwwnKueSV5JkMGQ?pwd=h8jh 提取码: h8jh

夸克:https://pan.quark.cn/s/2ad5508feff4?pwd=f3cY 提取码:f3cY

迅雷:https://pan.xunlei.com/s/VOwzYdxEDTORSPUvU3u2UdTaA1?pwd=dqgg 提取码:dqgg
您需要登录后才可以回帖 登录 | 注册[Register]

本版积分规则

返回列表

RSS订阅|小黑屋|处罚记录|联系我们|吾爱破解 - 52pojie.cn ( 京ICP备16042023号 | 京公网安备 11010502030087号 )

GMT+8, 2026-7-15 09:02

Powered by Discuz!

Copyright © 2001-2020, Tencent Cloud.

快速回复 返回顶部 返回列表