本地语音平台最初能够完成一件事:输入文字,调用 TTS 生成 MP3,再让 ESP32 播放。但我很快遇到一个产品层面的浪费——同一句话每点一次“生成并发送”,后台就重新调用一次 TTS,并保存一份新的 MP3。
检查重复文件后,它们的 SHA-256 完全相同。这说明我不是得到了两段不同语音,而是为同一份内容付出了两次生成时间和调用成本。
问题不只是“没有缓存”,而是两个对象被混成了一个
原来的 speech_jobs 表同时承担两种职责:既保存生成的音频,又记录一次设备播放。asset_id 还有唯一约束,因此一份音频也不能自然对应多次播放任务。
| 对象 | 它回答的问题 | 生命周期 |
|---|---|---|
| 语音资产 | “这段文字和配置生成了哪份 MP3?” | 可以长期保存、试听和重复使用 |
| 播放任务 | “哪台设备在什么时候播放了哪份资产?” | 每次发送都新建,并记录 queued、playing、completed 等状态 |
一句话可以只生成一份音频资产,但被发送 10 次时,仍应该有 10 个播放任务。反过来,同一句文字如果换了音色或输出格式,也不应该误命中旧资产。
最终数据模型
调整后,平台把数据拆成三个层次:
-
audio_assets:保存文字、Provider、音色、文件路径、大小、缓存键和 SHA-256。
-
speech_jobs:每次发送建立一条独立任务,引用已有音频资产。
-
playback_events:记录设备下载、开始播放和完成等事件。
这种拆分让“生成一次”和“播放多次”成为两件可以独立统计和重试的事情。页面也因此可以展示历史语音、直接在浏览器试听,并把同一份资产再次发送给 ESP32。
缓存键应该包含什么
平台会把规范化后的完整文字、TTS Provider、音色和会改变音频文件的输出参数组合起来,生成稳定缓存键。Python 标准库的 hashlib 文档说明了 SHA-256 摘要的标准接口;在本项目里,摘要被用来生成稳定标识和比较文件内容,不承担密码存储职责。
| 是否进入缓存键 | 字段 | 原因 |
|---|---|---|
| 进入 | 完整文字 | 文字变化会改变语音内容 |
| 进入 | Provider 与音色 | 同一句话可能生成不同声音 |
| 进入 | 输出格式等音频参数 | 参数可能改变最终文件 |
| 不进入 | API Key | 它是访问凭证,不是音频内容 |
| 不进入 | 设备播放音量 | 它只改变播放,不应生成新 MP3 |
这里的“相同文字”并不等于只比较一段输入字符串。真正应该复用的是“相同内容加相同生成配置”的输出。
命中缓存后仍然要创建任务
用户第二次提交相同文字时,后台跳过 TTS,直接引用已有 asset_id;但仍创建新的 speech_job。历史列表点击“再次发送”也一样:复用资产,不复用任务。
这样做保留了两类信息:
-
成本和速度:相同内容不再重复生成。
-
设备日志:每次实际发送和播放仍然可以独立追踪。
旧数据没有被简单删除
迁移前先备份真实 SQLite 数据库。迁移后保留了原有 9 个任务、27 个事件和 9 个资产,并移除了 speech_jobs.asset_id 的唯一约束。页面按 MP3 的 SHA-256 折叠展示历史重复文件,但旧任务和事件仍完整保留。
验证包括 4 个缓存回归测试、16 项完整 Python 测试、C++ 设备协议测试,以及本地页面 HTTP 200 和历史列表展示。用户也亲自测试了历史列表、浏览器试听、再次发送与相同文字重复提交,确认相同文字确实复用了已有语音。
这项设计带来的实际价值
对我来说,最重要的价值有两个:减少 TTS 调用费用,以及提升重复内容的响应速度。它同时为以后只允许其他用户使用已经生成好的语音包提供了基础,因为资产可以被管理和复用,播放任务则继续保留每次设备行为。