在win11上自制一款免费的多角色文本转语音工具

 一直在网上寻找免费的多角色文本转语音工具,但是都不满意。要么是收费,要么是有免费额度,要么是不支持多角色,总之各种使用不方便。

最近Ai大模型很流行,就想着能不能让Ai教我来制作一款文本转语音工具。于是我就请教Gemini,帮我做了一款免费的文本转语音工具,支持多角色,支持方言以及多语言角色,

使用非常灵活。当然,配置不是一帆风顺的,毕竟当前阶段的Ai大模型还不能一次搞定代码,直接跑通。我也是经过了很测试,不断纠正才最终完成的。下面直接展示制作的过程:

1.安装环境:

我的电脑是win11 64位操作系统,但是我感觉这个对电脑的配置没什么要求,你们可以试下。

2.安装过程:

a.第一步:首先需要Python 环境

  • 访问 Python 官网 (python.org)
  • 下载适用于 Windows 的最新版本。
  • 关键步骤: 在安装界面,务必勾选 “Add Python to PATH”(将 Python 添加到环境变量),然后点击“Install Now”
b.第二步:安装 edge-tts 工具
  • 在键盘上按下 Win + R 键,输入 cmd 并回车,打开命令提示符
  • 输入以下命令并回车(如果下载慢,可以使用国内镜像源):                                                pip install edge-tts                                                                                                                       如果想快一点,用清华源: 
          pip install edge-tts -i https://pypi.tuna.tsinghua.edu.cn/simple
  • 等待进度条走完,显示 Successfully installed 即表示安装成功。
c.第三步:在命令行测试edge-tts 
现在你的电脑已经安装的edge-tts工具,你可以在命令行试着调用了,让我们来试下:
在命令行输入下面这串代码,然后回车运行:
edge-tts --list-voices | findstr "zh-CN"
你会看到 zh-CN-XiaoxiaoNeural(晓晓)、zh-CN-YunxiNeural(云希)等常用名称。
(这一步很关键,如果没有配置成功,可能是环境变量没有加上,请在下方留言,我看到会回复您)
d.第四步:配置图形化界面
请在桌面新建一个文本文档,命名为 my_tts.py(确保后缀是 .py 而不是 .txt),然后将以下代码粘贴进去:
import tkinter as tk
from tkinter import ttk, messagebox, filedialog
import asyncio
from edge_tts import Communicate
import threading
import io
import pygame
import re
import time

# 初始化播放器
try:
    pygame.mixer.init()
except:
    pass

# --- 扩展音色库 ---
# --- 修正后的扩展音色库 ---
VOICE_DICT = {
    "晓晓 (女-旁白)": "zh-CN-XiaoxiaoNeural",
    "云希 (男-通用)": "zh-CN-YunxiNeural",
    "晓伊 (女-少女/萝莉)": "zh-CN-XiaoyiNeural",
    "云健 (男-成熟)": "zh-CN-YunjianNeural",
    "云夏 (男-少年/正太)": "zh-CN-YunxiaNeural",
    "辽宁晓北 (女-东北话)": "zh-CN-liaoning-XiaobeiNeural",
    "陕西晓妮 (女-西北话)": "zh-CN-shaanxi-XiaoniNeural",
    "云扬 (男-播音)": "zh-CN-YunyangNeural",
    "晓臻 (台-女)": "zh-TW-HsiaoChenNeural",
    "云哲 (台-男)": "zh-TW-YunJheNeural",
    "晓佳 (女-原生粤语)": "zh-HK-HiuGaaiNeural",
    "云龙 (男-原生粤语)": "zh-HK-WanLungNeural",
    "晓曼 (女-港式普通话)": "zh-HK-HiuMaanNeural",
    "Andrew (美语男-支持中文)": "en-US-AndrewNeural",
    "Emma (美语女-支持中文)": "en-US-EmmaNeural",
    "Brian (美语男-多语言版)": "en-US-BrianNeural",
    "Florian (德语男-多语言)": "de-DE-FlorianMultilingualNeural",
    "Seraphina (德语女-多语言)": "de-DE-SeraphinaMultilingualNeural",
    "Vivienne (法语女-多语言)": "fr-FR-VivienneMultilingualNeural",
    "Remy (法语男-多语言)": "fr-FR-RemyMultilingualNeural"
}
SORTED_NAMES = list(VOICE_DICT.keys())

# --- 全局动态追踪列表 ---
role_rows = [] # 存放每一行的组件对象:[ {name_entry, voice_combo, rate_scale, pitch_scale, test_btn}, ... ]
is_playing = False

# --- 合成引擎 ---
async def generate_audio_stream(text, role_configs):
    lines = text.split('\n')
    combined_data = bytearray()
    current_role = ""
    default_role = next(iter(role_configs.keys()), "旁白")

    # 预处理:加入“多音字”自动修正
    HOT_FIX = {"落下": "辣下", "还钱": "环钱"}

    for line in lines:
        line = line.strip()
        if not line: continue
        processed_line = line.replace(":", ":")
        found_new_role = False
        for role_name in role_configs.keys():
            if processed_line.startswith(f"{role_name}:"):
                current_role = role_name
                speech_content = processed_line.split(":", 1)[1].strip()
                found_new_role = True
                break
        if not found_new_role:
            if not current_role: current_role = default_role
            speech_content = line

        # 多音字修正
        for word, fix in HOT_FIX.items():
            speech_content = speech_content.replace(word, fix)

        cfg = role_configs.get(current_role, {})
        vid = VOICE_DICT.get(cfg.get('voice'), "zh-CN-XiaoxiaoNeural")
        rate_val, pitch_val = f"{int(cfg.get('rate', 0)):+d}%", f"{int(cfg.get('pitch', 0)):+d}Hz"

        try:
            comm = Communicate(speech_content, vid, rate=rate_val, pitch=pitch_val)
            async for chunk in comm.stream():
                if chunk["type"] == "audio": combined_data.extend(chunk["data"])
        except: continue
    return combined_data

def smart_clean_text():
    """修复 PDF 复制导致的断行问题"""
    raw_text = txt.get("1.0", "end-1c")
    if not raw_text.strip():
        return
    
    # 算法:保护段落,合并因 PDF 换行产生的断句
    # 1. 将真正的双换行(段落)暂时替换为特殊标记
    text = re.sub(r'\n\s*\n', '[[PARAGRAPH]]', raw_text)
    # 2. 将被汉字包围的单换行删除(合并行)
    text = re.sub(r'([\u4e00-\u9fa5])\n([\u4e00-\u9fa5])', r'\1\2', text)
    # 3. 恢复段落
    text = text.replace('[[PARAGRAPH]]', '\n\n')
    
    # 清空并重新插入
    txt.delete("1.0", "end")
    txt.insert("1.0", text)
    messagebox.showinfo("成功", "文本清洗完成:已尝试修复 PDF 断行。")

# --- 动态添加角色行 ---
def add_role_row():
    row_frame = tk.Frame(f_config)
    row_frame.pack(fill="x", pady=2)
    
    idx = len(role_rows) + 1
    tk.Label(row_frame, text=f"角色{idx}:", width=6).pack(side="left")
    
    n = tk.Entry(row_frame, width=10)
    n.insert(0, "旁白" if idx == 1 else f"角色{idx}")
    n.pack(side="left", padx=5)
    
    v = ttk.Combobox(row_frame, values=SORTED_NAMES, state="readonly", width=22)
    v.current(0); v.pack(side="left", padx=5)
    
    tk.Label(row_frame, text="语速:").pack(side="left")
    rs = tk.Scale(row_frame, from_=-50, to=50, orient="horizontal", length=100)
    rs.set(0); rs.pack(side="left")
    
    tk.Label(row_frame, text="音调:").pack(side="left")
    ps = tk.Scale(row_frame, from_=-20, to=20, orient="horizontal", length=100)
    ps.set(0); ps.pack(side="left")
    
    bt = tk.Button(row_frame, text="🔊 试听", bg="#E3F2FD")
    # 绑定试听逻辑
    bt.config(command=lambda: test_role_logic(n, v, rs, ps, bt))
    bt.pack(side="left", padx=10)
    
    # 存入列表供合成时读取
    role_rows.append({'name': n, 'voice': v, 'rate': rs, 'pitch': ps, 'btn': bt})
    
    # 更新滚动区域
    container.update_idletasks()
    canvas.config(scrollregion=canvas.bbox("all"))

def test_role_logic(n, v, rs, ps, btn):
    def run():
        try:
            btn.config(text="⌛", state="disabled")
            name = n.get().strip() or "角色"
            vid = VOICE_DICT.get(v.get(), "zh-CN-XiaoxiaoNeural")
            rate, pitch = f"{int(rs.get()):+d}%", f"{int(ps.get()):+d}Hz"
            loop = asyncio.new_event_loop(); asyncio.set_event_loop(loop)
            comm = Communicate(f"我是{name},声音已就绪。", vid, rate=rate, pitch=pitch)
            data = bytearray()
            async def pull():
                async for chunk in comm.stream():
                    if chunk["type"] == "audio": data.extend(chunk["data"])
            loop.run_until_complete(pull())
            if data:
                pygame.mixer.music.load(io.BytesIO(data)); pygame.mixer.music.play()
                while pygame.mixer.music.get_busy(): time.sleep(0.1)
        finally: btn.config(text="🔊 试听", state="normal")
    threading.Thread(target=run, daemon=True).start()

# --- 获取所有配置的辅助函数 ---
def get_all_configs():
    configs = {}
    for row in role_rows:
        name = row['name'].get().strip()
        if name:
            configs[name] = {
                'voice': row['voice'].get(),
                'rate': row['rate'].get(),
                'pitch': row['pitch'].get()
            }
    return configs

# --- 合成与预览逻辑 ---
async def handle_save_task(save_path, btn_ref):
    text_data = txt.get("1.0", "end-1c").strip()
    if not text_data: return
    configs = get_all_configs()
    pop = tk.Toplevel(root); pop.title("合成中"); pop.geometry("300x100")
    pop.transient(root); pop.grab_set()
    tk.Label(pop, text="\n🚀 正在连接云端合成音频...").pack()
    try:
        data = await generate_audio_stream(text_data, configs)
        if data:
            with open(save_path, "wb") as f: f.write(data)
            pop.destroy()
            messagebox.showinfo("成功", "保存完成!")
    except Exception as e:
        pop.destroy(); messagebox.showerror("错误", str(e))
    finally: btn_ref.config(state="normal", text="🚀 合成 MP3")

def handle_preview():
    global is_playing
    if pygame.mixer.music.get_busy():
        pygame.mixer.music.stop(); is_playing = False; return
    def run():
        global is_playing
        btn_pre.config(text="⌛ 缓冲中...", state="disabled")
        configs = get_all_configs()
        loop = asyncio.new_event_loop(); asyncio.set_event_loop(loop)
        data = loop.run_until_complete(generate_audio_stream(txt.get("1.0", "end"), configs))
        if data:
            pygame.mixer.music.load(io.BytesIO(data)); pygame.mixer.music.play()
            is_playing = True; btn_pre.config(text="⏹ 停止预览", state="normal", bg="#f44336")
            while pygame.mixer.music.get_busy() and is_playing: time.sleep(0.5)
        btn_pre.config(text="🎧 全文预览", state="normal", bg="#FF9800"); is_playing = False
    threading.Thread(target=run, daemon=True).start()

# --- UI 构造 ---
root = tk.Tk(); root.title("AI 配音专家 - 动态无限角色版"); root.geometry("1150x950")
canvas = tk.Canvas(root); canvas.pack(side="left", fill="both", expand=True)
scbar = ttk.Scrollbar(root, command=canvas.yview); scbar.pack(side="right", fill="y")
canvas.configure(yscrollcommand=scbar.set)
container = tk.Frame(canvas); canvas.create_window((0,0), window=container, anchor="nw")
container.bind("<Configure>", lambda e: canvas.configure(scrollregion=canvas.bbox("all")))

# 1. 配置区
f_config = tk.LabelFrame(container, text="角色调音管理", padx=15, pady=10)
f_config.pack(padx=20, pady=10, fill="x")

# 添加角色按钮
btn_add = tk.Button(container, text="➕ 添加新角色", command=add_role_row, bg="#4CAF50", fg="white", font=("微软雅黑", 9, "bold"))
btn_add.pack(pady=5)

# 默认添加第一个角色
add_role_row()

# 2. 文本区
# 找到这一行进行替换
tk.Button(
    container, 
    text="🧹 智能清洗文本", 
    command=smart_clean_text, # <--- 确保指向刚才定义的函数
    bg="#2196F3", 
    fg="white"
).pack(pady=5)
txt = tk.Text(container, height=20, width=125, font=("微软雅黑", 10)); txt.pack(padx=20, pady=5)

# 3. 底部按钮
btn_group = tk.Frame(container); btn_group.pack(pady=20)
btn_pre = tk.Button(btn_group, text="🎧 全文预览", bg="#FF9800", fg="white", width=25, height=2, command=handle_preview)
btn_pre.pack(side="left", padx=10)

btn_save = tk.Button(btn_group, text="🚀 合成 MP3", bg="#E91E63", fg="white", width=25, height=2, 
                     command=lambda: threading.Thread(target=lambda: asyncio.run(handle_save_task(filedialog.asksaveasfilename(defaultextension=".mp3"), btn_save)), daemon=True).start())
btn_save.pack(side="left", padx=10)

root.mainloop()

然后双击运行刚才创建的文件就可以了,你生成的界面如果没错的话,应该跟我的一样,如下:

e.第五步:如何使用这个软件
双击运行刚才建的文件以后,会生成一个命令行窗口,这个窗口是不能关掉的。如何多角色朗读呢?比如你的角色1名字是旁白,那么你只需要在输入框里面这样写就可以了:
旁白:这是朗读内容
角色名称+英文冒号+你要朗读的内容。
如果有多个角色,用法相同。点击添加角色可以添加多个角色,每个角色都可以选择不同的声音,可以调节语速和音调,后面的试听按钮则可以试听你选择的声音。
智能清洗文本,可以帮你清洗从其他地方复制过来的文本,保留原始的段落,避免换行造成的朗读停顿。
全文预览,可以预览整段内容(这个功能不建议使用,因为本质上跟合成mp3没区别,只是合成以后放在缓存里面了,而且预览消耗的时间和合成mp3消耗的时间相同)
合成mp3,点击这个按钮则会把内容转换成语音,然后提示你选择要保存的文件夹,然后合成mp3,内容越多,耗时越长,请耐心等待。

Comments

Popular Posts

Frequently Mis answered Questions on the PMP Exam(5 Qs, Answers Below)-PMPSIM510

摩托车考试,最容易轻视的科目三