Python调用Audio Speech REST API:文本分段、超时与原子落盘

26 阅读1分钟

客服公告一长,很多人只扫标题。要给客服知识库增加语音播报,最小可用方案不是一次把整篇文章扔进接口:请求可能因长度、网络或费用失败,重新跑整篇又慢又难追踪。今天用 Python 把文本切成短段,逐段调用文本转语音接口,先写临时文件再保存 MP3。你会掌握 TTS(Text-to-Speech,文本转语音)的输入约束、错误恢复和音频资产管理。

本例借近期官方 Python SDK 对音频模型选择的更新讨论“可用模型要按接口查”。实际调用使用官方语音接口明确列出的 gpt-4o-mini-tts,声音选 alloy,输出格式选 mp3。代码直接用 Python 标准库调用 REST API,方便看清 HTTP 请求和二进制响应。官方文档给出 input 最长 4096 字符;示例取更保守的每段 800 字符,为标点和后续扩展留余量。这是工程选择,并不表示模型一次只能读 800 字。

flowchart LR
  A[公告文本] --> B[按句切成短段]
  B --> C[逐段请求语音API]
  C --> D{HTTP成功且音频非空}
  D -->|是| E[写临时MP3并原子改名]
  D -->|否| F[记录段号和原因]
  E --> G[播放或后续合成]
  F --> H[只重试失败段]

环境准备

Python 3.10 以上即可,第三方依赖为零。准备一个纯文本文件 notice.txt,内容例如“门店将于周六调整营业时间。请提前确认预约信息。”。设置 OPENAI_API_KEY 环境变量,不要写进脚本或提交到版本库。把以下代码存为 tts_notice.py,在同一目录运行 python3 tts_notice.py notice.txt。成功后会得到 speech_parts/001.mp3 等文件;多个文件按编号播放即可。示例没有做无缝拼接,不能把字节直接相连当作一首完整 MP3。

import json
import os
from pathlib import Path
import re
import sys
import tempfile
import urllib.error
import urllib.request

ENDPOINT = "https://api.openai.com/v1/audio/speech"
MAX_CHARS = 800


def split_text(text: str) -> list[str]:
    sentences = re.split(r"(?<=[。!?!?])", text.strip())
    parts: list[str] = []
    current = ""
    for sentence in sentences:
        sentence = sentence.strip()
        while len(sentence) > MAX_CHARS:
            if current:
                parts.append(current)
                current = ""
            parts.append(sentence[:MAX_CHARS])
            sentence = sentence[MAX_CHARS:]
        if sentence and len(current) + len(sentence) > MAX_CHARS:
            parts.append(current)
            current = ""
        current += sentence
    if current:
        parts.append(current)
    return parts


def synthesize(text: str, key: str) -> bytes:
    body = json.dumps({"model": "gpt-4o-mini-tts",
                       "voice": "alloy", "input": text,
                       "response_format": "mp3"}, ensure_ascii=False)
    request = urllib.request.Request(
        ENDPOINT, data=body.encode("utf-8"), method="POST",
        headers={"Authorization": f"Bearer {key}",
                 "Content-Type": "application/json"})
    try:
        with urllib.request.urlopen(request, timeout=60) as response:
            audio = response.read()
    except urllib.error.HTTPError as exc:
        detail = exc.read(300).decode("utf-8", errors="replace")
        raise RuntimeError(f"HTTP {exc.code}: {detail}") from exc
    except (urllib.error.URLError, TimeoutError) as exc:
        raise RuntimeError(f"网络或读取超时:{exc}") from exc
    if not audio:
        raise RuntimeError("接口返回空音频")
    return audio


def main() -> None:
    key = os.environ.get("OPENAI_API_KEY")
    if not key:
        raise RuntimeError("请先设置 OPENAI_API_KEY")
    if len(sys.argv) != 2:
        raise ValueError("用法:python3 tts_notice.py notice.txt")
    text = Path(sys.argv[1]).read_text(encoding="utf-8")
    parts = split_text(text)
    if not parts:
        raise ValueError("输入文件为空")
    output_dir = Path("speech_parts")
    output_dir.mkdir(exist_ok=True)
    for index, part in enumerate(parts, 1):
        target = output_dir / f"{index:03d}.mp3"
        if target.exists() and target.stat().st_size > 0:
            print(f"跳过已有文件:{target}")
            continue
        audio = synthesize(part, key)
        with tempfile.NamedTemporaryFile(dir=output_dir, suffix=".tmp",
                                         delete=False) as temp:
            temp.write(audio)
            temp_name = Path(temp.name)
        temp_name.replace(target)
        print(f"完成第 {index}/{len(parts)} 段:{target}")


if __name__ == "__main__":
    try:
        main()
    except (RuntimeError, ValueError, OSError) as exc:
        print(f"失败:{exc}", file=sys.stderr)
        sys.exit(1)

代码怎样保证可恢复

split_text 优先按句末标点分段,遇到超长句再硬切;中文播报更自然的做法是按语义段落切,但这里先保证每段不会越过预设长度。synthesize 提交 model、voice、input、response_format 四个官方字段,拿到的是音频字节,不是 JSON 文本。60 秒为一次读取等待上限;更严格的全任务截止时间需在调度器层实现。

写入临时文件再改名,能减少程序突然中止时留下半个目标 MP3 的概率。重新运行会跳过非空文件,适合处理“第 7 段失败,只补第 7 段”的情况。不过,如果你修改了原文或声音,旧文件就不该沿用;生产版应把文本哈希、模型、声音和生成时间写进清单,只有哈希一致才跳过。程序没有自动重试,因为 TTS 请求可能已在服务端完成但客户端没有收到响应,盲目重试会重复计费。

预期终端显示“完成第 1/1 段:speech_parts/001.mp3”,播放器能打开该文件;实际语音音色由模型决定。示例未在本次任务中实际调用线上API,因此不声称语音质量或生成时间已经验证。

常见错误、边界与实践

第一,401/403 通常是密钥或项目权限问题。第二,429 是限流或额度问题,先按官方响应信息退避,别并发轰炸。第三,返回文件能播放但读错数字、日期或英文缩写:让真人抽听关键段,并在输入中把“10/12”改写成清晰的口语日期。第四,段与段之间音色或停顿不连贯:分段长度、标点和后期合成方式需要一起调。

它适合公告、文章辅助朗读和低风险教学音频;不适合在无人复核下播报医疗用药、紧急疏散或合同条款。工程化时应保留原文版本与片段对应关系、限制单次总字数,并在界面告知用户声音由 AI 合成。

交给播放器前,最好建立清单:每段的编号、原文起止位置、文本哈希、文件名和审核状态。修改一句时只重生受影响片段,审核员也能准确定位。文件能播放并不代表内容正确;数字、货币、日期、产品名和否定词应强制抽听。大量文章可以有限并发,但要同时控制请求数、总字符数和成本。片段若上传对象存储,还要核对校验和后才切换清单指向。

音频格式同样要处理。多个 MP3 片段不能简单拼接字节就当完整节目,应该用音频工具重新编码并加入适当停顿。上线前用不同设备试听,并保留文字版本供无障碍访问。5 分钟实践:写两句含日期和产品英文名的公告,运行后抽听,再把日期改写成中文口语比较差异。公告播报中,你会优先保证自然语气还是每句可追溯的准确性?

还有个隐藏的分段风险:程序按标点拆句,遇到“版本 2.1.3”“价格 19.9 元”或网址时可能拆得不自然。当前正则只识别中文句号、问号和感叹号,刻意不以小数点作为边界,但英文长段落会因缺少这些标点而被硬切。硬切可能截断单词或语义。生产版应先做语言检测和文本规范化,再按句子、词语与字数预算分段,并为每段保留前后文摘要。对播报质量敏感的内容,人工编辑分段比盲目追求全自动更稳。

音频落盘后的完整性不能只看文件非空。网络异常或接口返回代理错误页时,非空字节也可能不是可播放 MP3。示例通过接口成功状态和非空字节做了基础检查;上线版还要核验媒体类型、用解码器实际打开、计算时长,并比对预期的文本长度范围。过短的音频可能是漏读,过长的音频可能包含重复内容。用转写模型把生成音频再识别回文本,可以辅助发现重大遗漏,但转写自身也有误差,关键句仍需人工抽听。

若要支持多种声音,不能只凭“哪一个更好听”选择。为每个声音记录中文数字读法、英文缩写、专有名词和长句停顿的样本,邀请目标用户盲听比较。不要用未经授权的人声做声音克隆,也不要让合成播报冒充真人客服。页面上给出文字原稿、音频播放与生成标识,让听众有机会自行核对;这对听力困难或身处嘈杂环境的人同样有用。

本例的恢复逻辑还需要一个明确的操作习惯:每次改稿先清空或换一个输出目录。否则旧的非空文件会被跳过,最终音频与新文本不一致。若不想人工清理,就把输入文本的哈希纳入输出目录名或清单,程序只在哈希相同的情况下复用。这样不仅能避免漏更新,也方便编辑对比两版公告。正式发布前还应检查段数是否与清单一致、所有片段都能解码、顺序编号无缺口,然后再把整组文件设为可见。

语音内容还会遇到“文本看起来相同,听起来不同”的问题。比如“行”在“银行”和“可以行”里读音不同;缩写和品牌名也可能被逐字念出。评估样本要覆盖真实行业词,而不是只用通顺的普通句子。将常错词整理成改写规则,并且记录规则版本,下一次生成才能复现同样的读法。对价格和期限等关键声明,最好在文字版里也突出显示,让用户无需完全依赖听觉获取信息。

关注「蜗牛聊AI」,一起看懂技术变化背后的真正机会。


本文首发于 java4u.cn,转载请注明出处。