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