预览:

支持口音:
- 澳大利亚英语:Natasha / William
- 英国英语:Sonia / Ryan
- 美国英语:Jenny / Guy
- 新西兰英语:Molly / Mitchell
- 加拿大英语:Clara / Liam
- 印度英语:Neerja / Prabhat
使用:
- 双击 Start_Multi_Accent_TTS.cmd。
- 填写或确认 Azure Speech Key 与 Region。
- 粘贴英文。
- 勾选口音、女声/男声和练习模式。
- 点击“生成 MP3”。
建议:
- 跟读主范音:优先澳音。
- 泛听:英音、美音、新西兰音轮换。
- 印度英语可用于适应国际工程职场,但不建议作为发音模仿主范音。
- 一次勾选太多选项会生成很多文件。例如:
3种口音 × 2种性别 × 3种模式 = 18个 MP3。
配置:
勾选“记住设置”时,Key 会明文保存在程序目录的
azure_tts_config.ini 中,请勿上传或转发。
Python代码:
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
from __future__ import annotations
import configparser
import html
import os
import re
import threading
import tkinter as tk
from pathlib import Path
from tkinter import filedialog, messagebox, ttk
try:
import azure.cognitiveservices.speech as speechsdk
except ImportError:
root = tk.Tk()
root.withdraw()
messagebox.showerror(
"缺少组件",
"尚未安装 Azure Speech SDK。\n\n"
"请运行:python -m pip install azure-cognitiveservices-speech"
)
raise SystemExit(1)
APP_DIR = Path(__file__).resolve().parent
CONFIG_FILE = APP_DIR / "azure_tts_config.ini"
ACCENTS = {
"澳大利亚英语 en-AU": {
"code": "au",
"locale": "en-AU",
"female": ("Natasha", "en-AU-NatashaNeural"),
"male": ("William", "en-AU-WilliamNeural"),
},
"英国英语 en-GB": {
"code": "gb",
"locale": "en-GB",
"female": ("Sonia", "en-GB-SoniaNeural"),
"male": ("Ryan", "en-GB-RyanNeural"),
},
"美国英语 en-US": {
"code": "us",
"locale": "en-US",
"female": ("Jenny", "en-US-JennyNeural"),
"male": ("Guy", "en-US-GuyNeural"),
},
"新西兰英语 en-NZ": {
"code": "nz",
"locale": "en-NZ",
"female": ("Molly", "en-NZ-MollyNeural"),
"male": ("Mitchell", "en-NZ-MitchellNeural"),
},
"加拿大英语 en-CA": {
"code": "ca",
"locale": "en-CA",
"female": ("Clara", "en-CA-ClaraNeural"),
"male": ("Liam", "en-CA-LiamNeural"),
},
"印度英语 en-IN": {
"code": "in",
"locale": "en-IN",
"female": ("Neerja", "en-IN-NeerjaNeural"),
"male": ("Prabhat", "en-IN-PrabhatNeural"),
},
}
MODES = {
"精听慢速": {
"suffix": "listen",
"rate": "-15%",
"sentence_pause": 750,
"paragraph_pause": 1200,
},
"跟读 Shadowing": {
"suffix": "shadow",
"rate": "-8%",
"sentence_pause": 420,
"paragraph_pause": 800,
},
"正常语速": {
"suffix": "natural",
"rate": "0%",
"sentence_pause": 260,
"paragraph_pause": 550,
},
}
def normalize_text(text: str) -> str:
text = text.replace("\r\n", "\n").replace("\r", "\n")
paragraphs = []
for paragraph in re.split(r"\n\s*\n+", text):
lines = [
re.sub(r"\s+", " ", line).strip()
for line in paragraph.splitlines()
if line.strip()
]
if lines:
paragraphs.append(" ".join(lines))
return "\n\n".join(paragraphs)
def split_sentences(paragraph: str) -> list[str]:
parts = re.split(r'(?<=[.!?])\s+(?=[A-Z0-9"\'])', paragraph.strip())
return [part.strip() for part in parts if part.strip()]
def build_ssml(text: str, voice: str, locale: str, mode: dict, custom_rate: str | None) -> str:
text = normalize_text(text)
body = []
paragraphs = text.split("\n\n")
for p_index, paragraph in enumerate(paragraphs):
sentences = split_sentences(paragraph)
for s_index, sentence in enumerate(sentences):
body.append(f"<s>{html.escape(sentence)}</s>")
if s_index < len(sentences) - 1:
body.append(f'<break time="{mode["sentence_pause"]}ms"/>')
if p_index < len(paragraphs) - 1:
body.append(f'<break time="{mode["paragraph_pause"]}ms"/>')
rate = custom_rate or mode["rate"]
return f'''<speak version="1.0"
xmlns="http://www.w3.org/2001/10/synthesis"
xml:lang="{locale}">
<voice name="{voice}">
<prosody rate="{rate}" pitch="0%" volume="+3%">
{" ".join(body)}
</prosody>
</voice>
</speak>'''
class AzureTTSApp(tk.Tk):
def __init__(self):
super().__init__()
self.title("Azure 多口音英语 TTS MP3 生成器 v3")
self.geometry("1080x760")
self.minsize(900, 680)
self.key_var = tk.StringVar()
self.region_var = tk.StringVar()
self.remember_var = tk.BooleanVar(value=True)
self.output_var = tk.StringVar(value=str(APP_DIR / "output"))
self.filename_var = tk.StringVar(value="english_listening")
self.rate_var = tk.StringVar(value="使用模式默认值")
self.status_var = tk.StringVar(value="准备就绪")
self.accent_vars = {
name: tk.BooleanVar(value=(name == "澳大利亚英语 en-AU"))
for name in ACCENTS
}
self.gender_vars = {
"female": tk.BooleanVar(value=True),
"male": tk.BooleanVar(value=True),
}
self.mode_vars = {name: tk.BooleanVar(value=True) for name in MODES}
self._build_ui()
self._load_config()
def _build_ui(self):
self.columnconfigure(0, weight=1)
self.rowconfigure(1, weight=1)
account = ttk.LabelFrame(self, text="Azure 设置", padding=8)
account.grid(row=0, column=0, sticky="ew", padx=12, pady=(10, 5))
account.columnconfigure(1, weight=1)
ttk.Label(account, text="Speech Key:").grid(row=0, column=0, sticky="w")
self.key_entry = ttk.Entry(account, textvariable=self.key_var, show="*")
self.key_entry.grid(row=0, column=1, sticky="ew", padx=6)
ttk.Button(account, text="显示/隐藏", command=self._toggle_key).grid(row=0, column=2)
ttk.Label(account, text="Region:").grid(row=1, column=0, sticky="w", pady=(6, 0))
ttk.Entry(account, textvariable=self.region_var, width=25).grid(
row=1, column=1, sticky="w", padx=6, pady=(6, 0)
)
ttk.Checkbutton(
account,
text="记住设置(Key 会保存在本机配置文件中)",
variable=self.remember_var,
).grid(row=2, column=1, sticky="w", padx=6, pady=(6, 0))
text_frame = ttk.LabelFrame(self, text="练习英文(直接粘贴)", padding=8)
text_frame.grid(row=1, column=0, sticky="nsew", padx=12, pady=5)
text_frame.columnconfigure(0, weight=1)
text_frame.rowconfigure(1, weight=1)
toolbar = ttk.Frame(text_frame)
toolbar.grid(row=0, column=0, sticky="ew", pady=(0, 5))
ttk.Button(toolbar, text="打开 TXT", command=self._open_txt).pack(side="left")
ttk.Button(
toolbar,
text="清空",
command=lambda: self.text.delete("1.0", "end"),
).pack(side="left", padx=6)
self.text = tk.Text(
text_frame,
wrap="word",
font=("Segoe UI", 11),
undo=True,
height=10,
)
self.text.grid(row=1, column=0, sticky="nsew")
text_scroll = ttk.Scrollbar(
text_frame,
orient="vertical",
command=self.text.yview,
)
text_scroll.grid(row=1, column=1, sticky="ns")
self.text.configure(yscrollcommand=text_scroll.set)
options = ttk.Frame(self)
options.grid(row=2, column=0, sticky="ew", padx=12, pady=5)
for col in range(4):
options.columnconfigure(col, weight=1)
accents_frame = ttk.LabelFrame(options, text="英语口音", padding=8)
accents_frame.grid(row=0, column=0, sticky="nsew", padx=(0, 4))
for name, var in self.accent_vars.items():
ttk.Checkbutton(
accents_frame,
text=name,
variable=var,
).pack(anchor="w")
genders_frame = ttk.LabelFrame(options, text="声音性别", padding=8)
genders_frame.grid(row=0, column=1, sticky="nsew", padx=4)
ttk.Checkbutton(
genders_frame,
text="女声",
variable=self.gender_vars["female"],
).pack(anchor="w")
ttk.Checkbutton(
genders_frame,
text="男声",
variable=self.gender_vars["male"],
).pack(anchor="w")
modes_frame = ttk.LabelFrame(options, text="练习模式", padding=8)
modes_frame.grid(row=0, column=2, sticky="nsew", padx=4)
for name, var in self.mode_vars.items():
ttk.Checkbutton(
modes_frame,
text=name,
variable=var,
).pack(anchor="w")
rate_frame = ttk.LabelFrame(options, text="自定义语速", padding=8)
rate_frame.grid(row=0, column=3, sticky="nsew", padx=(4, 0))
ttk.Combobox(
rate_frame,
textvariable=self.rate_var,
state="readonly",
values=[
"使用模式默认值",
"-20%", "-15%", "-12%", "-10%", "-8%",
"-5%", "0%", "+5%", "+10%",
],
width=18,
).pack(anchor="w")
output = ttk.LabelFrame(self, text="输出设置", padding=8)
output.grid(row=3, column=0, sticky="ew", padx=12, pady=5)
output.columnconfigure(1, weight=1)
ttk.Label(output, text="文件名前缀:").grid(row=0, column=0, sticky="w")
ttk.Entry(output, textvariable=self.filename_var, width=28).grid(
row=0, column=1, sticky="w", padx=6
)
ttk.Label(output, text="输出文件夹:").grid(row=1, column=0, sticky="w", pady=(6, 0))
ttk.Entry(output, textvariable=self.output_var).grid(
row=1, column=1, sticky="ew", padx=6, pady=(6, 0)
)
ttk.Button(output, text="选择", command=self._choose_output).grid(
row=1, column=2, pady=(6, 0)
)
ttk.Button(output, text="打开文件夹", command=self._open_output_folder).grid(
row=1, column=3, padx=(6, 0), pady=(6, 0)
)
bottom = ttk.Frame(self)
bottom.grid(row=4, column=0, sticky="ew", padx=12, pady=(5, 12))
bottom.columnconfigure(1, weight=1)
self.generate_btn = ttk.Button(
bottom,
text="生成 MP3",
command=self._start_generation,
)
self.generate_btn.grid(row=0, column=0)
self.progress = ttk.Progressbar(bottom, mode="determinate")
self.progress.grid(row=0, column=1, sticky="ew", padx=10)
ttk.Label(bottom, textvariable=self.status_var).grid(row=0, column=2)
self.text.insert(
"1.0",
"Each member must perform its function safely within the complete load path.\n"
"Structural engineers should check drawings carefully before construction begins."
)
def _toggle_key(self):
self.key_entry.configure(show="" if self.key_entry.cget("show") == "*" else "*")
def _load_config(self):
key = os.getenv("AZURE_SPEECH_KEY", "")
region = os.getenv("AZURE_SPEECH_REGION", "")
if CONFIG_FILE.exists():
cfg = configparser.ConfigParser()
cfg.read(CONFIG_FILE, encoding="utf-8")
key = cfg.get("azure", "key", fallback=key)
region = cfg.get("azure", "region", fallback=region)
self.output_var.set(cfg.get("app", "output", fallback=self.output_var.get()))
self.key_var.set(key)
self.region_var.set(region)
def _save_config(self):
if not self.remember_var.get():
return
cfg = configparser.ConfigParser()
cfg["azure"] = {
"key": self.key_var.get().strip(),
"region": self.region_var.get().strip(),
}
cfg["app"] = {"output": self.output_var.get().strip()}
with CONFIG_FILE.open("w", encoding="utf-8") as file:
cfg.write(file)
def _open_txt(self):
path = filedialog.askopenfilename(
title="选择英文文本",
filetypes=[("Text files", "*.txt"), ("All files", "*.*")],
)
if not path:
return
try:
content = Path(path).read_text(encoding="utf-8-sig")
except UnicodeDecodeError:
content = Path(path).read_text(encoding="gb18030")
self.text.delete("1.0", "end")
self.text.insert("1.0", content)
self.filename_var.set(Path(path).stem)
def _choose_output(self):
path = filedialog.askdirectory(initialdir=self.output_var.get())
if path:
self.output_var.set(path)
def _open_output_folder(self):
folder = Path(self.output_var.get())
folder.mkdir(parents=True, exist_ok=True)
os.startfile(folder)
def _start_generation(self):
key = self.key_var.get().strip()
region = self.region_var.get().strip()
text = self.text.get("1.0", "end").strip()
selected_accents = [
(label, ACCENTS[label])
for label, var in self.accent_vars.items()
if var.get()
]
selected_genders = [
gender
for gender, var in self.gender_vars.items()
if var.get()
]
selected_modes = [
(label, MODES[label])
for label, var in self.mode_vars.items()
if var.get()
]
if not key or not region:
messagebox.showwarning("缺少设置", "请填写 Azure Speech Key 和 Region。")
return
if not text:
messagebox.showwarning("没有文本", "请粘贴或输入要朗读的英文。")
return
if not selected_accents:
messagebox.showwarning("没有选择口音", "请至少选择一种英语口音。")
return
if not selected_genders:
messagebox.showwarning("没有选择声音", "请至少选择女声或男声。")
return
if not selected_modes:
messagebox.showwarning("没有选择模式", "请至少选择一个练习模式。")
return
self._save_config()
total = len(selected_accents) * len(selected_genders) * len(selected_modes)
self.generate_btn.configure(state="disabled")
self.progress["maximum"] = total
self.progress["value"] = 0
self.status_var.set("正在生成……")
threading.Thread(
target=self._generate,
args=(key, region, text, selected_accents, selected_genders, selected_modes),
daemon=True,
).start()
def _generate(
self,
key,
region,
text,
selected_accents,
selected_genders,
selected_modes,
):
output_dir = Path(self.output_var.get().strip())
output_dir.mkdir(parents=True, exist_ok=True)
prefix = re.sub(
r'[<>:"/\\|?*]',
"_",
self.filename_var.get().strip(),
) or "tts"
custom_rate = (
None if self.rate_var.get() == "使用模式默认值"
else self.rate_var.get()
)
completed = 0
errors = []
for _, accent in selected_accents:
for gender in selected_genders:
voice_display, voice_name = accent[gender]
for _, mode in selected_modes:
output_file = (
output_dir
/ f"{prefix}_{accent['code']}_{gender}_{voice_display}_{mode['suffix']}.mp3"
)
ssml = build_ssml(
text,
voice_name,
accent["locale"],
mode,
custom_rate,
)
try:
speech_config = speechsdk.SpeechConfig(
subscription=key,
region=region,
)
speech_config.set_speech_synthesis_output_format(
speechsdk.SpeechSynthesisOutputFormat.Audio24Khz96KBitRateMonoMp3
)
audio_config = speechsdk.audio.AudioOutputConfig(
filename=str(output_file)
)
synthesizer = speechsdk.SpeechSynthesizer(
speech_config=speech_config,
audio_config=audio_config,
)
result = synthesizer.speak_ssml_async(ssml).get()
if result.reason != speechsdk.ResultReason.SynthesizingAudioCompleted:
if result.reason == speechsdk.ResultReason.Canceled:
details = speechsdk.SpeechSynthesisCancellationDetails(result)
raise RuntimeError(
details.error_details or str(details.reason)
)
raise RuntimeError(str(result.reason))
except Exception as exc:
errors.append(f"{output_file.name}: {exc}")
completed += 1
self.after(0, self._update_progress, completed)
self.after(0, self._finish_generation, output_dir, errors)
def _update_progress(self, completed):
self.progress["value"] = completed
total = int(self.progress["maximum"])
self.status_var.set(f"已完成 {completed}/{total}")
def _finish_generation(self, output_dir, errors):
self.generate_btn.configure(state="normal")
if errors:
self.status_var.set("部分任务失败")
messagebox.showerror(
"生成未完全成功",
"以下任务失败:\n\n" + "\n".join(errors[:8]),
)
return
self.status_var.set("全部生成完成")
if messagebox.askyesno(
"生成完成",
f"MP3 已保存到:\n{output_dir}\n\n是否立即打开文件夹?",
):
os.startfile(output_dir)
if __name__ == "__main__":
AzureTTSApp().mainloop()
