准备接 MAI-Voice-2-Flash 时,真正要先备好的不是参数表,而是三样能被验证的东西:一份放对位置、不进仓库的凭证;一条只合成一句话就能跑通的最小请求;一条音频能落盘、能回溯到输入文本的存储路径。日志算第四样,但通常和落盘一起做,缺了它出错时只能靠猜。建议的顺序是先把「文本进、音频文件出、日志留痕」这条最短链路跑通,再回头看音色、语速、流式这些参数。
判断:接入初期只做一件事——用一句话打通「请求→音频文件→日志」闭环。适用场景是本地开发机或测试环境,操作上把凭证放环境变量、请求字段留占位并对照官方文档填、音频按输入文本哈希加时间戳命名,验证方式是目录里出现可播放文件、日志里有请求标识与状态码。边界:具体字段名、鉴权方式、计费口径以官方文档为准,本文不覆盖并发压测与额度管理。
确认凭证与权限从哪来、怎么放
凭证通常由控制台或平台账号体系下发,拿到后第一件事是决定它存在哪,而不是先写代码。建议只放两处:本机环境变量、或团队用的密钥管理服务;代码里一律通过读取环境变量获取,本地开发可额外用 .env 文件,但 .env 必须进 .gitignore。下面这段是通用骨架,变量名按你的项目改。
import os
API_KEY = os.environ.get('MAI_VOICE_API_KEY')
BASE_URL = os.environ.get('MAI_VOICE_BASE_URL', 'https://<官方文档给出的接入地址>')
if not API_KEY:
raise SystemExit('缺少 MAI_VOICE_API_KEY,请在环境变量或本地 .env 中配置后再运行')
提交前的硬编码检查项,建议固化成命令或提交钩子:
- 在仓库里搜明文密钥特征:
grep -rn "Bearer \|api_key\s*=" ./src,命中即人工确认。 - 确认
.env、config.local.*已在 .gitignore 中,且git status看不到它们。 - 确认前端代码、示例脚本、README、截图里没有出现完整密钥。
- 确认日志打印语句不会把
Authorization头整段输出。
权限最小化清单:只开通语音合成所需的能力,不申请账号管理、成员变更、账单类权限;为接入单独建一个凭证,便于单独吊销;在平台侧设置用量上限或告警;凭证轮换时只换环境变量,不动代码。密钥的具体格式与有效期以官方文档为准,这里不做假设。
写一个只合成一句话的最小请求
这一步的目标是把链路打通,不是调参。文本固定一句短的,音色和输出格式先用文档里给的最小可用值。下面骨架里的 text、音色字段、输出格式字段都是占位,字段名与取值务必以官方文档为准,不要照抄。
import os, time, requests
URL = os.environ['MAI_VOICE_BASE_URL'].rstrip('/') + '/<官方文档给出的合成路径>'
HEADERS = {
'Authorization': 'Bearer ' + os.environ['MAI_VOICE_API_KEY'],
'Content-Type': 'application/json',
}
payload = {
'text': '你好,这是一条最短链路测试。', # 占位:字段名以官方文档为准
'voice': '<音色标识>', # 占位
'output_format': '<mp3 或 wav 等>', # 占位
}
t0 = time.time()
resp = requests.post(URL, headers=HEADERS, json=payload, timeout=(5, 30))
elapsed = round(time.time() - t0, 3)
print(resp.status_code, elapsed, len(resp.content))
超时建议分开写:连接超时给短一些(几秒),读超时按你要合成的文本长度放长一些,不要用 requests 默认的无限等待。验证方式很直接:状态码落在 2xx、resp.content 不为空、长度与这句短文本大致相称,且能保存成文件后正常播放。如果返回的是 JSON 包着音频地址或 base64,先按文档确认是哪一种,不要直接当二进制写文件。
把返回音频按可追溯规则落盘
落盘规则要在接入阶段就定,否则文件一多就分不清哪条对应哪段文本。建议目录按日期分层,文件名带输入文本的短哈希,旁边放一份同名的元数据文件,记录文本、请求参数、耗时和状态码。
data/audio/20250101/3f9a1c2b-142530.mp3
data/audio/20250101/3f9a1c2b-142530.json # 元数据:text / voice / format / elapsed / status
哈希取输入文本 sha256 的前 8 位即可,用来一眼比对「这段音频是不是这句话合成的」。写入建议先写临时文件再原子替换,避免下载中断留下半截文件:
import hashlib, json, os, time
text = payload['text']
digest = hashlib.sha256(text.encode('utf-8')).hexdigest()[:8]
day = time.strftime('%Y%m%d')
stamp = time.strftime('%H%M%S')
out_dir = os.path.join('data', 'audio', day)
os.makedirs(out_dir, exist_ok=True)
base = os.path.join(out_dir, digest + '-' + stamp + '.mp3')
tmp = base + '.part'
try:
with open(tmp, 'wb') as f:
f.write(resp.content)
os.replace(tmp, base)
except OSError as e:
print('落盘失败:', e, '状态码:', resp.status_code)
with open(base + '.failed.json', 'w', encoding='utf-8') as f:
json.dump({'error': str(e), 'status': resp.status_code}, f, ensure_ascii=False)
覆盖策略:默认不覆盖。命中同名文件时,先比对元数据里的文本哈希与请求参数,一致就跳过并记一条日志,不一致则加序号另存。静默覆盖会让「哪次请求产生了哪个文件」失去追溯性,调试期尤其吃亏。
打开日志看这一次请求发生了什么
接入阶段的日志不需要复杂,但要能在事后回答「这次请求发了什么、花了多久、结果如何」。建议至少记录这几个字段:请求标识(自己生成的 uuid 或响应里带的 id)、文本长度、耗时、状态码、错误信息。文本本身只记长度和开头若干字符,不整段落库。
ts=2025-01-01T14:25:30+08:00 req_id=a1b2c3d4 text_len=16 elapsed_ms=842 status=200 error=-
脱敏规则要写死:Authorization 头、完整密钥、完整音频二进制一律不进日志;长文本按前 20 字加省略号截断;错误信息里如果带上了请求体,落盘前先过一遍截断。日志格式建议统一成一行一条,方便 grep 和后续导入检索。验证方式是跑一次最小请求后,用 grep req_id 能串起请求、响应和落盘三条记录。
用三个边界输入各跑一次
正常句子跑通只说明主路径可用,边界输入才暴露问题。建议在接入阶段就各跑一次,并把实际返回记下来。
- 空文本或纯空白:预期是被拒绝并返回明确的参数错误;如果返回 2xx 但音频为空,说明校验需要在自己这侧补。
- 超长文本:预期是成功但耗时明显上升,或返回长度超限错误。建议先按文档确认单次上限,再决定是截断还是分段合成。
- 特殊符号:emoji、中英文混排、换行、引号、HTML 标签、全角标点。预期是正常合成或明确报错,重点看有没有静默吞字。
| 输入类型 | 预期表现 | 实际返回(自行填写) | 异常时处理 |
|---|---|---|---|
| 空文本 | 参数错误,不入队 | 状态码 / 错误码 | 本地先拦截,返回提示给调用方 |
| 超长文本 | 成功但慢,或超限报错 | 状态码 / 耗时 / 音频长度 | 按句切分后逐段合成,再合并 |
| 特殊符号 | 正常合成,或明确报错 | 状态码 / 文件是否可播放 | 保留原始文本,标记为待人工复核 |
降级处理的原则是「宁可少合成,不要错合成」:空文本直接返回明确提示;超长文本切成不超过上限的段落,逐段请求并记录每段结果,失败段单独重试;特殊符号失败时保留输入原文和请求标识,便于复现。三类输入的结果建议写进同一张表,接入评审时直接看这张表,比口头描述靠谱。