家里有个爱听故事的小朋友,和一个讲不动了的老父亲。于是我决定给她做一个故事机——她想听的任何故事,都能有人念给她听。
这篇文章记录从选型到落地的完整过程:为什么选长文本 TTS、为什么是阿里云、Token 怎么管、任务怎么排队、音频怎么持久化,以及前端怎么播。
一、选型:为什么是"长文本"TTS
1.1 我的核心诉求
需求其实很简单——文本转语音。但落到工程上,有四条硬约束:
- 生成的语音要准确、清晰;
- 生成速度不能太慢;
- 接入成本不能太高;
- 使用成本要可控。
1.2 三类方案的横向对比
摆在面前的路大致有三条:直接用云厂商服务、自己部署开源模型、或者买 AIGC 平台的接口。
| 对比维度 | 云厂商服务 (阿里云 / 讯飞 / 火山等长文本 TTS) | 开源部署 (CosyVoice / FishSpeech 等) | AIGC 平台 (MiniMax 等) |
|---|---|---|---|
| 使用成本 | 按字符按量计费,无需硬件投入;中小稿件成本可控,超大批量稿件总成本会上涨 | 一次性 GPU 服务器硬件成本 + 长期算力电费;无字符计费,适合高频超大文本生产 | 按字符计费,定价偏高;超大文本任务单价有优势,任务量少时性价比一般 |
| 转换效果 | 中文韵律成熟,长文本上下文连贯,SSML 支持完善;音色库丰富稳定,商业配音质量达标 | 模型上限高,可自定义音色;但需要调参,长文本全局韵律稳定性弱于云厂商,部分生僻字、中英混合容易出错 | 情感表现力强,角色对话自然;长文本连贯性优秀,部分特殊标点、专业术语处理能力一般 |
| 接入成本 | 低。REST API 开箱即用,自带任务队列、回调、音频存储、异常处理等基础能力,业务侧只需少量代码 | 很高。需要 GPU 环境与模型部署,任务调度、持久化、重试、音频存储、监控告警整套工程都得自己写 | 中等。API 接口简单,但任务管控与音频生命周期管理能力弱,业务侧要自己处理任务超时、文件有效期问题 |
| 转换速度 | 后台弹性算力,常规万字任务分钟级;大批量任务会排队,速度受云厂商资源调度影响 | 取决于 GPU 卡型;单卡推理速度稳定,多任务并发受硬件上限限制,无弹性扩容 | 弹性算力,超大文本任务优势明显;高峰期任务排队延迟不可控 |
我的场景只是给小朋友做一台故事机,理论上不会有特别大的转换需求,所以云厂商的服务就够用了。按量付费的模式非常契合这种低频、量小的场景,以后如果有更合适的产品,替换成本也不高。
1.3 为什么一定要"长文本"版本
选定云厂商之后,下一个问题是:阿里云的语音合成有好几种形态,我该用哪个?关键在于实时 TTS(短文本流式) 和长文本 TTS(异步版) 这两条完全不同的路子。
| 对比维度 | 普通实时 TTS(短文本流式) | 长文本 TTS(异步版) |
|---|---|---|
| 单次文本上限 | 最多 300 字符 | 单次任务最高 10 万字符 |
| 接口协议 | WebSocket wss 流式双向通信 | RESTful HTTP 异步任务(提交任务 → 轮询 / 回调 → 下载音频) |
| 延迟模型 | 低延迟,边发文本边返回音频分片,适合实时播放 | 非低延迟,提交任务后后台排队合成;几万字需要分钟级等待,不适合实时交互 |
| 韵律与上下文 | 单句独立合成,分段拼接时句间停顿、语气容易断裂 | 全文上下文感知,一次性通读全文做韵律规划,段落与句间衔接更自然,适合小说、长篇稿件配音 |
| 时间戳能力 | 字级时间戳,适合逐字字幕、高亮跟读 | 句级时间戳,只返回句子起止时间,无法精确到单个汉字 |
| 结果存储 | 实时流式返回二进制音频,服务端不保存文件 | 合成完成的音频文件在云端保存 7 天,返回音频 URL 供下载;部分模型(如 CosyVoice 长文本)也支持流式返回分片 |
| SSML 支持 | 实时流式接口不支持 SSML | 支持 SSML 标签(<emotion> 情绪、<phoneme> 注音、停顿控制),可精细化控制朗读效果 |
| 并发模型 | WebSocket 长连接,连接即占用并发额度 | 任务型并发,按任务排队,不长期占用流式连接资源 |
| 典型场景 | 智能客服、语音助手、App 弹窗播报、实时朗读 | 有声书、课程配音、短视频长篇旁白、新闻稿件批量生成 |
一篇儿童故事通常在 300~1000 字不等。实时 TTS 单次 300 字符的上限太低了——虽然它能免费试用三个月、更省钱,但不符合真实场景,所以直接排除。
1.4 那为什么是阿里云
坦白讲,我并没有横向做过各家云厂商的对比。语音合成到今天已经是一门相对成熟的技术,各家产品之间的差距不会特别明显。
选择阿里云的背后其实没有什么深层理由——仅仅是因为我恰好在用百炼。同一套账号体系、同一份 AccessKey,接入路径最短。这是最朴素的工程选择:能在已有基础设施上解决的事,就不额外引入新的依赖。
二、接入准备
参考官方 Getting Started 文档完成前置动作:
- 准备阿里云账号与 AccessKey;
- 开通智能语音交互(ISI)服务;
- 在语音合成下将长文本语音合成升级为商用版(试用版有额度限制)。
拿到 AccessKeyId、AccessKeySecret、AppKey 和项目区域(Region)之后,就可以开始写代码了。
三、整体流程
整条链路的骨架如下:
graph LR
A[故事保存成功] --> B[写入音频任务表<br/>status = pending]
B --> C[Worker 轮询取任务]
C --> D[获取 NLS Token<br/>带本地缓存]
D --> E[提交长文本 TTS 任务<br/>拿到 task_id]
E --> F[轮询任务结果<br/>10s / 次]
F --> G[下载音频二进制]
G --> H[上传 OSS 持久化]
H --> I[回写 object_key / 分句 / 时长<br/>status = ready]
I --> J[故事详情页返回 audio 对象]
J --> K[前端 audio 控件播放<br/>分句高亮 + 自动翻页]
用一句话概括:
文本 → 获取 TTS Token → 调用 TTS 服务生成音频 → 轮询获取结果 → 得到音频地址 → 音频上传 OSS,分句(sentences)写入数据库 → OSS 地址写入数据库。
四、获取 Token
Token 是应用调用 TTS 服务的凭证。项目使用 Python 3 开发,这里直接用阿里云公共 SDK 来获取。
Token 具备有效期,且有效期会随每次请求下发。因此我按有效期做了一层缓存:Token 还在有效期内就直接走缓存,避免每次转换都发起一次凭证请求。
import json
import threading
import time
from typing import Callable
from aliyunsdkcore.client import AcsClient
from aliyunsdkcore.request import CommonRequest
# 提前刷新窗口,避免边界时刻拿到即将过期的 Token
TOKEN_REFRESH_SKEW_SECONDS = 60
class AliyunTTSTokenProvider:
"""获取并缓存 NLS Token,在过期前自动刷新。"""
def __init__(
self,
settings: TTSSettings,
*,
now: Callable[[], float] = time.time,
) -> None:
self.settings = settings
self._now = now
self._cached_token: TTSToken | None = None
self._lock = threading.Lock()
def get_token(self) -> str:
# 快路径:命中缓存直接返回,不加锁
cached = self._cached_token
if self._is_valid(cached):
return cached.value
# 慢路径:双重检查 + 加锁,避免并发重复请求
with self._lock:
cached = self._cached_token
if self._is_valid(cached):
return cached.value
token = self._request_token()
self._cached_token = token
return token.value
def _is_valid(self, token: TTSToken | None) -> bool:
return bool(
token
and token.value
and self._now() < token.expires_at - TOKEN_REFRESH_SKEW_SECONDS
)
def _request_token(self) -> TTSToken:
self.settings.validate()
client = AcsClient(
self.settings.access_key_id,
self.settings.access_key_secret,
self.settings.region,
)
request = CommonRequest()
request.set_method("POST")
request.set_protocol_type("https")
request.set_domain(f"nls-meta.{self.settings.region}.aliyuncs.com")
request.set_version("2019-02-28")
request.set_action_name("CreateToken")
try:
raw_result = client.do_action_with_exception(request)
payload = json.loads(raw_result)
token_data = payload["Token"]
token = str(token_data["Id"]).strip()
expires_at = int(token_data["ExpireTime"])
except Exception as error:
raise AliyunTTSError("获取阿里云 TTS Token 失败") from error
if not token or expires_at <= self._now():
raise AliyunTTSError("阿里云 TTS Token 响应无效")
return TTSToken(value=token, expires_at=expires_at)
两个小细节值得说一下:
- 双检锁(double-checked locking) :
get_token在加锁前后各判断一次缓存。高频调用时绝大多数请求走无锁快路径,只有缓存失效的那一次会真正串行化,既保证线程安全又不牺牲性能。
- 提前 60 秒刷新:不等到 Token 真正过期才换,避免"取出来还在有效期、发出去已经过期"的边界问题。
五、任务创建与结果轮询
5.1 为什么要做成异步任务
阿里云 TTS 的速度其实不算慢,但实测 1000 字左右要等 1~2 分钟。这个时长对用户来说是没法接受的——总不能让请求线程在那儿挂着。
所以我把调用拆成了两步:提交任务 → 轮询结果。
故事保存成功后,立刻为它的每个章节注册一条音频生成任务:
def _register_audio_tasks(self, story_set: StorySetEntity) -> None:
if self.audio_repository is None:
return
for episode in story_set.episodes:
text = episode.content.strip()
if not text:
continue
self.audio_repository.register_generation(
story_id=episode.id,
voice_code=self.audio_voice_code,
# 用内容哈希做幂等:正文没变就不重复合成
content_hash=hashlib.sha256(text.encode("utf-8")).hexdigest(),
audio_format=self.audio_format,
# 其余参数略
)
至于任务队列本身,出于"场景简单 + 成本敏感"的考虑,我没有引入 MQ 或 Redis 这类经典中间件,而是采用了**「数据库表充当任务队列 + 独立 Worker 轮询消费」**的方案。这套做法在中小规模场景下足够可靠,实现成本也低得多,这里就不展开了。
Worker 取到任务后,发起真正的转换请求。
5.2 创建 TTS 任务
def _create_task(
self,
client: httpx.Client,
*,
token: str,
text: str,
) -> tuple[str, str | None]:
response = client.post(
self.settings.endpoint,
headers={"Content-Type": "application/json"},
json={
"header": {
"appkey": self.settings.app_key,
"token": token,
},
"context": {"device_id": self.settings.device_id},
"payload": {
"enable_notify": False,
"notify_url": "",
"tts_request": {
"voice": self.settings.voice,
"sample_rate": self.settings.sample_rate,
"format": self.settings.audio_format,
"enable_subtitle": self.settings.enable_subtitle,
"text": text,
},
},
},
)
payload = self._parse_response(response, operation="创建语音合成任务")
data = payload.get("data")
task_id = data.get("task_id") if isinstance(data, dict) else None
if not isinstance(task_id, str) or not task_id.strip():
raise AliyunTTSError("阿里云 TTS 创建成功,但响应中没有 task_id")
request_id = payload.get("request_id")
return task_id, str(request_id) if request_id else None
这里有两点设计取舍:
enable_notify设为False:长文本 TTS 支持通过notify_url回调通知结果,但我选择了轮询。原因是回调要求服务端暴露公网地址、还要处理回调的幂等与重放,对个人项目来说链路更重;轮询虽然笨一点,但逻辑内聚、无外部依赖、调试成本低。
enable_subtitle设为True:为了拿到句级时间戳(下面讲高亮时会用到)。
5.3 轮询结果
以 10 秒的频率拉取任务状态,从结果中同时提取音频 URL 与分句文本(sentences):
def _wait_for_completion(
self,
client: httpx.Client,
*,
token: str,
task_id: str,
) -> tuple[str, tuple[TTSSentence, ...]]:
deadline = self._monotonic() + self.settings.task_timeout_seconds
while True:
response = client.get(
self.settings.endpoint,
params={
"appkey": self.settings.app_key,
"task_id": task_id,
"token": token,
},
)
payload = self._parse_response(
response,
operation="查询语音合成任务",
task_id=task_id,
)
data = payload.get("data")
if not isinstance(data, dict):
raise AliyunTTSError("阿里云 TTS 查询响应中缺少 data")
# 出现 audio_address 即视为合成完成
audio_url = data.get("audio_address")
if isinstance(audio_url, str) and audio_url.strip():
return (
self._normalize_audio_url(audio_url),
self._parse_sentences(data.get("sentences")),
)
task_status = str(data.get("task_status") or "").strip().upper()
if task_status in {"FAIL", "FAILED", "FAILURE", "ERROR"}:
message = self._safe_provider_message(
data.get("error_message") or payload.get("message")
)
suffix = f",message={message}" if message else ""
raise AliyunTTSError(
"阿里云 TTS 任务执行失败:"
f"task_id={task_id},task_status={task_status}{suffix}"
)
now = self._monotonic()
if now >= deadline:
raise AliyunTTSError(f"阿里云 TTS 任务执行超时,task_id={task_id}")
# 最后一次等待不要超过 deadline,避免超时后还白等一轮
self._sleep(
min(self.settings.poll_interval_seconds, max(0.0, deadline - now))
)
这段逻辑里有三个我认为必须写死的点:
- 必须有时钟边界。
deadline保证任务不会无限轮询下去,超时后抛异常交给任务队列重试,而不是让 Worker 卡死。
- 完成判定不能只看
task_status。以audio_address是否出现作为成功信号更稳——状态字段是枚举,而地址是实体。
- 最后一次 sleep 要收敛到 deadline。否则超时已经逼近,还会固定睡满 10 秒才报错。
六、音频转存 OSS 与结果落库
6.1 为什么必须转存 OSS
TTS 返回的音频 URL 只在云端保留 7 天。对故事机来说这是致命的——用户今天存的故事,一周后就播不了了。
所以音频生成的最后一步,是把音频下载下来、转存到自己的 OSS,用永久地址替换掉临时地址:
def generate_and_upload(
self,
*,
owner_user_id: int,
text: str,
) -> StoredAudioResult:
if owner_user_id <= 0:
raise ValueError("owner_user_id must be positive")
self.oss_settings.validate()
tts_result = self.tts_client.request(text)
audio = self.tts_client.download_audio(
tts_result.audio_url,
max_download_bytes=self.oss_settings.max_audio_bytes,
)
object_key = self._build_object_key(
owner_user_id=owner_user_id,
file_extension=audio.file_extension,
)
duration_ms = self._get_duration_ms(audio)
alignment = self._build_alignment(text.strip(), tts_result.sentences)
try:
self.storage.put(
object_key,
audio.content,
content_type=audio.content_type,
)
audio_url = self.storage.sign_get_url(
object_key,
expires_in=self.oss_settings.signed_url_expire_seconds,
)
except ObjectStorageError as error:
# 上传失败时清理可能已写入的半成品对象,避免产生孤儿文件
self._delete_failed_upload(object_key)
raise AliyunTTSError("TTS 音频上传 OSS 失败") from error
return StoredAudioResult(
task_id=tts_result.task_id,
request_id=tts_result.request_id,
object_key=object_key,
audio_url=audio_url,
audio_url_expires_in=self.oss_settings.signed_url_expire_seconds,
content_type=audio.content_type,
file_size=len(audio.content),
duration_ms=duration_ms,
alignment=alignment,
)
注意 download_audio 传了 max_download_bytes:下载第三方返回的文件时,一定要有大小上限,否则一个异常响应就可能把内存打满。
6.2 回写数据库并出队
合成完成后,一次性把音频地址、时长、分句等结果写回,状态从 processing 改为 ready——这条数据也就自然从任务队列里"出队"了:
def mark_ready(
self,
*,
asset_id: int,
object_key: str,
content_type: str,
file_size: int,
duration_ms: int,
alignment: tuple[StoryAudioCueEntity, ...],
provider_task_id: str,
provider_request_id: str | None,
) -> StoryAudioAssetEntity:
now = datetime.now(UTC)
result = self.session.execute(
update(StoryAudioAsset)
.where(
StoryAudioAsset.id == asset_id,
# 条件里带上 status,天然实现"只允许一次成功写入"
StoryAudioAsset.status == "processing",
)
.values(
object_key=object_key,
storage_url=None,
content_type=content_type,
file_size=file_size,
duration_ms=duration_ms,
alignment_data=[
{
"index": cue.index,
"text": cue.text,
"start_offset": cue.start_offset,
"end_offset": cue.end_offset,
"begin_ms": cue.begin_ms,
"end_ms": cue.end_ms,
}
for cue in alignment
] or None,
provider_task_id=provider_task_id,
provider_request_id=provider_request_id,
status="ready",
next_retry_at=None,
generated_at=now,
error_message=None,
updated_at=now,
)
)
if result.rowcount != 1:
self.session.rollback()
raise StoryAudioTaskStateError("音频任务已失效,不能写入生成结果")
self.session.commit()
model = self.session.get(StoryAudioAsset, asset_id)
if model is None:
raise LookupError("story audio asset not found")
return self._to_entity(model)
用 WHERE status = 'processing' 做乐观并发控制,是这套轻量任务队列最关键的护栏:Worker 重试、重复投递、多实例竞争的情况下,只有第一个写成功的人能改状态,其他人 rowcount 为 0 直接失败,不会出现音频被覆盖或重复生成。
七、前端播放
对于已成功生成音频的故事,故事详情页接口会返回一个 audio 对象,包含音频 OSS 地址、格式、时长、分句(sentences)等信息。
前端直接用了 HTML 原生的 audio 控件:
<div class="story-audio__body">
<div class="story-audio__heading">
<strong>听故事</strong>
<small>闭上眼睛,让故事陪你去旅行</small>
</div>
<audio
ref="audioRef"
preload="metadata"
@play="handlePlay"
@loadedmetadata="applyInitialPosition"
@pause="handlePause"
@timeupdate="emitProgress($event.currentTarget)"
@seeked="handleSeeked"
@ended="handleEnded"
:src="props.src"
:autoplay="props.autoplay"
:controls="props.controls"
>
当前浏览器暂不支持音频播放。
</audio>
</div>
原生控件的好处是零成本、移动端体验一致,同时 timeupdate 事件已经把播放进度暴露出来了——足够支撑后续做分句高亮和跟随进度自动翻页。
而这两个效果的实现,依赖的正是前面特意开启的 enable_subtitle 拿到的句级时间戳。具体实现逻辑,我在后续文档里再写。
最终效果如下:
八、成本
结尾顺带提一下钱的事。长文本语音合成按合成字数计费,按量付费标准价 3 元/万字,所以:
一个 600 字的故事 = 0.06 万字 × 3 元/万字 = ¥0.18
这个量级永远落在最低档(要一天合成 19 万字以上才有梯度折扣),没有折扣空间;资源包我也不打算买——1 年有效期的 100 万字包,按一个故事 600 字算得生成 1667 个才能用完,家庭场景显然跑不到。
另外,按量付费是按天结算、一般次日出账的,所以当天在控制台看不到这笔消费是正常的。
作为对照,同一个故事的文本由 deepseek-v4-flash 生成、消耗 11.83k tokens,成本约 ¥0.02。TTS 的开销大约是 LLM 生成的 10 倍——真要压缩成本,重点在语音合成这一侧。
九、小结
整条链路跑通之后,回头看有几个判断是可以复用的:
- 先定场景,再定技术形态。故事机的故事是 300~1000 字,实时 TTS 的 300 字符上限就足以把它排除——选型不是比谁先进,而是比谁合适。
- 长耗时的调用一定要拆成"提交 + 轮询" 。1~2 分钟的合成时长放在同步请求里是灾难,异步化之后用户无感。
- 第三方返回的资源地址不等于你的存储。7 天过期的临时 URL 必须尽早转存,这是所有接入 AIGC 服务都会踩的坑。
- 轻量任务队列用数据库就够。表 + Worker 的组合,配合状态字段做乐观锁,能覆盖大部分中小规模场景,比引 MQ 划算得多。
- 一个人也能做完一条完整链路。从选型、后端任务系统到前端播放控件,这个项目的每一层都值得单独写一篇。
如果你也在做类似的事情,或者对分句高亮的实现感兴趣,欢迎在评论区聊聊。