从文本到有声故事:接入阿里云长文本 TTS 的完整实践

39 阅读14分钟

家里有个爱听故事的小朋友,和一个讲不动了的老父亲。于是我决定给她做一个故事机——她想听的任何故事,都能有人念给她听。

这篇文章记录从选型到落地的完整过程:为什么选长文本 TTS、为什么是阿里云、Token 怎么管、任务怎么排队、音频怎么持久化,以及前端怎么播。


一、选型:为什么是"长文本"TTS

1.1 我的核心诉求

需求其实很简单——文本转语音。但落到工程上,有四条硬约束:

  1. 生成的语音要准确、清晰;
  1. 生成速度不能太慢;
  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 文档完成前置动作:

  1. 准备阿里云账号与 AccessKey;
  1. 开通智能语音交互(ISI)服务;
  1. 在语音合成下将长文本语音合成升级为商用版(试用版有额度限制)。

拿到 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))
        )

这段逻辑里有三个我认为必须写死的点:

  1. 必须有时钟边界。deadline 保证任务不会无限轮询下去,超时后抛异常交给任务队列重试,而不是让 Worker 卡死。
  1. 完成判定不能只看 task_status。以 audio_address 是否出现作为成功信号更稳——状态字段是枚举,而地址是实体。
  1. 最后一次 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 拿到的句级时间戳。具体实现逻辑,我在后续文档里再写。

最终效果如下:

有声故事播放.gif


八、成本

结尾顺带提一下钱的事。长文本语音合成按合成字数计费,按量付费标准价 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 划算得多。
  • 一个人也能做完一条完整链路。从选型、后端任务系统到前端播放控件,这个项目的每一层都值得单独写一篇。

如果你也在做类似的事情,或者对分句高亮的实现感兴趣,欢迎在评论区聊聊。