微软azure语音生成(升级交互)

预览:

支持口音:

  • 澳大利亚英语:Natasha / William
  • 英国英语:Sonia / Ryan
  • 美国英语:Jenny / Guy
  • 新西兰英语:Molly / Mitchell
  • 加拿大英语:Clara / Liam
  • 印度英语:Neerja / Prabhat

使用:

  1. 双击 Start_Multi_Accent_TTS.cmd。
  2. 填写或确认 Azure Speech Key 与 Region。
  3. 粘贴英文。
  4. 勾选口音、女声/男声和练习模式。
  5. 点击“生成 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()

发表评论

您的邮箱地址不会被公开。 必填项已用 * 标注

滚动至顶部