最近在一款互动叙事项目的开发里,我遇到了一个很现实的需求:玩家在输入框里随便打一句话,NPC 角色就得当场把它读出来。按传统思路,这种功能要么提前把玩家可能说的话全部录成音频,要么准备一个巨大的素材库,文案只要改动几个字,录音、导入、打包的流程就得重新走一遍。在节奏极快的版本迭代里,这个方案基本行不通。后来我换成了运行时文字转语音(TTS)方案,在 Unity 生态里找到了 RtVioce 这款插件(Asset Store 里的官方名写作 RT-Voice),用下来确实把动态语音合成这件事理顺了。这篇博文就围绕 RtVioce 插件的功能、用法和下载安装做一次系统整理,给做 NPC 配音、无障碍朗读、有声内容工具或互动叙事的 Unity 开发者一个参考。
1. 这个插件解决了什么问题:RtVioce 的定位与应用场景
1.1 为什么需要运行时动态合成,而不是提前录音
静态录音方案的最大问题不是“录不起”,而是“改不动”。一个对话系统里,分支选项、玩家输入、后端下发的动态文案都是不可预知的,你不可能为每一个可能的字符串都准备一段音频。即使你的文案全部是固定的,策划改一句话、调整一段剧情节奏,都要重新协调录音、重新导出、重新提交包体,整个链路拖得非常长。
RtVioce 这类运行时 TTS 插件解决的是“文本到音频”的最后一步:把一段 string 直接变成语音播放出来。它的核心价值在于,合成动作发生在运行时,文本内容可以来自玩家输入、本地化配置、服务器下发,甚至可以由脚本临时拼接。这意味着你维护的从“音频资产”变成了“文本资源”,改动成本低了一个量级。
1.2 RtVioce 的能力边界
先说能干什么:它封装了不同操作系统底层的 TTS 引擎,让你在 Unity 里通过统一的 API 调用文本转语音,支持音量、语速、音调等基础参数,支持暂停、停止、事件回调,还提供编辑器扩展窗口方便试听和调试。平台覆盖上,主流的 Windows、macOS、Android、iOS、WebGL 都能跑。
再说它不擅长什么:它不做语音识别,也不负责自然语言理解,更不是录音棚级别的商业配音软件。同一句话在 Windows 和 Android 上发声的音色可能会不同,这是底层系统引擎决定的,插件只能做参数层面的统一,没法让两边发出完全一样的声音。理解这条边界很重要,否则你会在平台一致性上浪费大量时间。
1.3 什么场景推荐用,什么场景建议绕开
我用一张表整理了自己的判断:
| 场景 | 是否推荐 | 原因 |
|---|---|---|
| 玩家输入文本实时播报 | 强烈推荐 | 动态内容无法预录,只能运行时合成 |
| NPC 随机对话 / 互动叙事 | 推荐 | 文案灵活,改文本即可生效 |
| 无障碍朗读 / 读屏辅助 | 推荐 | 体量小、依赖系统语音,接入成本低 |
| 儿童识字、语言学习工具 | 推荐 | 可按需切换单词、跟读,反馈即时 |
| 过场 CG 台词的正式配音 | 不推荐 | 需要稳定的音色和情感表现,最好预烘焙 |
| 离线工业设备(无系统 TTS) | 谨慎 | 依赖平台自带引擎,平台不支持就白搭 |
| 要求多平台音色完全一致的项目 | 不推荐 | 底层引擎不同,音色不可能完全一致 |
说白了,RtVioce 适用于文本变化频繁、对音色没有“死磕”要求的场景。如果你只是想要一段精致的过场配音,老老实实找配音演员录,然后按常规音频流程做,别让运行时 TTS 去背这个锅。
2. 下载与导入:拿到插件后,第一步别急着拖场景
2.1 获取渠道与版本选择
RtVioce 的获取渠道比较常规,最方便的是直接在 Unity Asset Store 里搜索 “RT-Voice”。注意官方包名是带连字符的 RT-Voice,搜 RtVioce 这个拼写不一定能直接命中,我在商店里找的时候就被拼写坑过一次。商店页面会显示插件支持的 Unity 版本范围,导入前务必确认它和你当前项目的版本匹配,尤其是那些停留在 Unity 2022 以下的老项目,版本兼容性问题最容易在导入后爆出来。
如果你需要离线安装,可以在 Package Manager 的 My Assets 里先下载,再通过 “Import Package” 导入。开发商的官方文档页面一般还会提供额外的示例场景和更新日志,建议顺手存一下,后面查 API 和排查问题都用得上。这个插件属于付费资产,具体价格以商店页为准,购买前可以先看看页面上有没有可用的 Demo 版本或试用版本,先跑通再决定是否付费。
2.2 导入后的第一件正经事:检查平台模块与运行时依赖
很多人在 Assets 菜单里点完导入,就急着写代码跑 Speak,结果各种怪问题全冒出来。我的习惯是导入后先检查三件事:
第一,看目录结构。正常情况下导入后会出现 Runtime、Editor、Plugins 这类子目录。如果某个平台模块缺失,多半是导入时取消勾选了对应文件夹,回到 Project 窗口里重新导入即可。
第二,Windows 平台要确认 .NET 的 System.Speech 相关依赖没有被裁剪。RtVioce 在 Windows 上走的是系统 SAPI,如果你开了 IL2CPP 的代码裁剪,有可能把系统语音调用的注册信息裁掉,导致编辑器里正常、发布后无声。
第三,Android 和 iOS 平台的音频配置要提前看。iOS 如果需要在锁屏时继续播报,要在 Player Settings 里打开 Background Modes 的 Audio 选项;Android 则要留意应用是否有网络权限,因为部分语音包需要联网下载。
2.3 用官方 Demo 场景快速冒烟测试
导入完成后,我建议不要直接在自己项目的场景里硬调,而是先跑官方提供的 GettingStarted 或 Demo 场景,花五分钟确认环境没有问题。正常情况是这个场景运行后,按提示的按键就能听到语音合成结果。
RtVioce 还带一个编辑器扩展窗口,一般藏在 Window 菜单下的 Odc 或 RT-Voice 分类里。这个窗口可以直接输入文本、选择语音引擎、调整语速音调,然后在编辑器里试听,非常适合日常调试。注意这个窗口走的是编辑器所在系统的 TTS,它只能验证“插件在这个设备上能不能出声”,不能完全代替真机验证,真机上的行为还是有多平台差异的。
冒烟测试最好按这个清单走一遍:英文文本能出声音;切换中文语音后中文文本能出声音;连续调用两次 Speak 能按队列播放;Stop 能立刻中断;音量、语速、音调参数生效。这些都没问题,就可以放心进入业务集成了。
3. 一句话把文字变成语音:核心 API 与两种接入方式
3.1 最小代码方案:类单例 + Speak
RtVioce 的使用模式不复杂,最直接的接入方式是通过场景中的 RTVoice 组件来驱动。很多项目里它会以单例的形式存在,你可以通过类似 Instance 的方式直接拿到引用,然后调用 Speak。下面是一段最小可用代码:
using UnityEngine; public class SimpleSpeaker : MonoBehaviour { void Start() { // 前提:场景中存在 RTVoice 组件,或者它由插件自动创建 var voice = FindObjectOfType<RTVoice>(); if (voice != null) { voice.Speak("你好,这里是一段实时合成的语音。"); } } }如果你导入的版本暴露了静态单例,代码会更简洁,类似 RTVoice.Instance.Speak("...") 这种写法。不同版本的 API 命名会有一点差异,我拿到一个新版本后的习惯是直接在当前包的源码里搜 “public void Speak”,把方法的完整签名翻出来,对照自己需要哪些参数,这一步能省很多踩坑时间。
3.2 可视化组件方式:给非程序同事用的接入路径
如果你的项目里有策划需要自己搭语音播报节点,可以走可视化组件方式。RtVioce 提供了 RTVoiceComponent 这类可挂载组件,挂在场景物体上之后,可以在 Inspector 里直接配置音量、语速、音调,还可以把目标 AudioSource 拖进去指定播放通道。运行时通过公开方法触发播报,不需要业务脚本直接操作底层 API。
这套方式的好处是配置集中、可预览、可复用,适合把某个 NPC、某个 UI 面板的播报行为做成预制体。我个人的建议是,代码方式和组件方式不要混着用,项目里要做个约定:功能测试用组件方式,逻辑复杂的语音服务用代码方式封装,避免同一个功能两条路径都在改。
3.3 常用参数、状态控制与事件回调
无论走哪条接入路径,核心参数都是那几样:音量一般取 0 到 1;语速通常 0.5 表示慢速,1 是正常,2 是快速;音调也是类似区间,0.5 到 2 之间比较常用。这些参数在不同平台上对听感的影响程度不一样,比如 Android 系统对音调的定义和 Windows 就不完全一致,所以不能指望同一组参数在两个平台听感完全一样。
播报状态一般通过事件回调来判断,比轮询 IsSpeaking 更可靠。常见的事件大概有这些:开始播报、一句播完、播报被停止、播报出错。用事件的好处是你能准确知道“当前句子已经说完”,再触发下一句,不会出现句子重叠或丢句。
3.4 事件驱动的一个小例子
public class SpeechController : MonoBehaviour { private void OnEnable() { var voice = FindObjectOfType<RTVoice>(); voice.OnSpeakStart += HandleSpeakStart; voice.OnSpeakComplete += HandleSpeakComplete; } private void OnDisable() { var voice = FindObjectOfType<RTVoice>(); voice.OnSpeakStart -= HandleSpeakStart; voice.OnSpeakComplete -= HandleSpeakComplete; } private void HandleSpeakStart(string text) { // 把当前句子显示到字幕 UI 上 } private void HandleSpeakComplete(string text) { // 触发下一句,或者通知对话系统推进 } }事件机制不是花架子,它是做对话系统、字幕同步、多句连播的地基。很多新手直接在一个方法里连调好几个 Speak,结果声音乱成一片,原因就是没有等事件回调,下一句提前出发了。这个点我在第 5 章还会展开讲。
4. 多平台适配:同一个 Speak,在不同系统上走的不是同一条路
4.1 Windows:SAPI 与中文语音的选择
RtVioce 在 Windows 上封装的是系统的 SAPI(Speech Application Programming Interface)。Windows 自带英文语音,但中文语音不一定会默认安装,所以你在编辑器里试英文没问题,切到中文就听不到声音,通常是系统里没有可用的中文语音包。解决办法是去系统设置里安装中文语音模块,装完之后重启 Unity,再在 RtVioce 的语音列表里刷新就能看到中文选项。
如果你的软件要面向中文用户分发,建议在代码里做一次“可选语音枚举”并默认选择中文语音。在支持的平台上,插件会暴露可用的语音列表,每个语音有自己的名称和语言标识,你可以在启动时遍历并选择匹配的语音。
4.2 Android:系统 TTS 引擎是绕不开的依赖
Android 跟 Windows 完全是两条路线。RtVioce 在 Android 上调用的是系统 TextToSpeech 服务,所以你的应用等于是在使用外部系统服务。这里最典型的问题有两个:一是设备上没有可用的 TTS 引擎,二是引擎装上了但对应的语言语音包没下载。
我的经验是,在应用启动阶段就主动初始化 Android 的 TTS,并检查当前语音包是否可用。如果发现不可用,不要等到用户点播报按钮时才报错,应该在界面里提前提示,最好能引导用户跳转到系统 TTS 设置页面去下载离线语音包。国内不少定制 ROM 把 Google TTS 阉割掉了,你可能要适配厂商自己的引擎,这些引擎的语速、音调实现并不标准,稳妥的策略是给 Android 单独调一套参数。
4.3 iOS:音频会话与后台播报
iOS 上底层走的是 AVSpeechSynthesizer,整体稳定性比 Android 好,但有一个很容易被忽略的坑:音频会话。如果应用没有把音频会话配置成播放类型,用户开了静音拨片之后,TTS 可能会无声。另外如果你有后台语音播报的需求,必须在 Player Settings 里开启 Background Audio 模式,否则 App 一进后台声音就断了。
我现在做 iOS 适配时,会在启动阶段主动把音频会话给到一个合适的配置,比如同时支持播放和录音的场景用 PlayAndRecord,纯播报场景用 Playback。这部分配置插件不一定能完全替你处理,建议在自己的启动脚本里显式设置一次。
4.4 WebGL:浏览器声音服务与联网依赖
WebGL 平台用的一般是浏览器的 Web Speech API,所以它很特殊:同一个游戏,在 Chrome 里中文语音正常,换个浏览器可能就找不到中文语音;而且首次使用时浏览器需要加载语音列表,加载完成前调用 Speak 可能会失败。我在 WebGL 上做播报时,会先等待浏览器的 voiceschanged 事件,拿到可用语音列表之后再刷新播报按钮的状态,这样能避免用户页面一打开就点播报导致无声。
4.5 平台差异在上层封装一次
多平台的项目,强烈建议把“播报文本”这个动作收口到一个统一服务里,内部再用平台编译宏去处理差异:
public static class SpeakService { public static void Speak(string text) { #if UNITY_ANDROID // Android 专用初始化流程 SpeakOnAndroid(text); #elif UNITY_IOS // iOS 音频会话处理 SpeakOnIOS(text); #elif UNITY_WEBGL StartCoroutine(WaitForBrowserVoices(text)); #else SpeakOnWindows(text); #endif } }这样做的好处是,业务层永远只关心“我传了一段文本进去”,平台差异、会话配置、语音包检查全部收口在服务内部。后面接对话系统、接多语言,也不用在几十个业务脚本里到处打平台补丁。
5. 跑通 Demo 之后:四个容易翻车的地方与我的解决办法
5.1 连续播报乱序,声音叠在一起
这是最常见的坑。你写完“播放下一句”的逻辑,发现有时候上一句还没说完,下一句就响起来,甚至两句叠在一起。原因是 TTS 的合成是异步的,底层各平台的队列行为并不可控,你连续调用几个 Speak,它们之间的时序根本无法保证。
我的解决办法是绝不直接依赖底层队列,而是自己维护一个播报队列。每次想播报时,把文本塞进队列,由队列管理器一句一句消费,上一句的播报完成事件到来之后,再弹出下一句。这样不管外部调用多频繁,最终播放层始终是串行的。
5.2 播报声音混不进主音频体系,音量不受控
RtVioce 默认可能从它自己创建的音频源播放,如果你的项目有 AudioMixer,你会发现语音音量调不动,或者不受音效总控的节制。另外在 3D 场景里,如果没有设置正确的空间混合比,声音听起来像在耳边贴脸说话,一点距离感都没有。
正确的做法是给它指定一个专用 AudioSource,把这个 AudioSource 的 AudioMixerGroup 指向你项目中专门用于语音的 Bus,并设置 SpatialBlend 为 0,让语音走 2D 平面不受 3D 衰减影响。如果你确实要做 3D 空间语音,就把 SpatialBlend 设为 1,但那时音量、距离衰减就是另一套逻辑了。
5.3 Android 首次调用没声音,第二次又好了
这个现象特别迷惑人。Android 的 TextToSpeech 引擎初始化是异步的,第一次调用时引擎可能还没完全就绪,语音包也没有绑定,这次调用就会被丢弃;等你隔几秒再调,引擎已经准备好了,声音就出来了。我一度以为是插件 bug,后来查日志才发现是初始化时序问题。
解决方法是应用启动时预初始化 TTS,并注册初始化完成的回调。初始化完成之前,不要让播报按钮可点击;初始化失败时,提示用户安装或开启系统 TTS。把这个逻辑放进统一的 SpeakService 里,所有平台都用同一套初始化状态机,后面基本不会再遇到“第一次没声音”的问题。
5.4 长文本卡顿、GC 飙高甚至控制台报错
把一大段剧情文本直接丢给 Speak 是另一个高频操作,结果往往是在语音出现前有一段明显的停顿,同时内存占用上涨明显。原因很简单:超长文本被整体传到系统层合成,系统合成耗时和内存峰值都会很高,Unity 侧随后还要处理一个很大的音频缓冲区,GC 自然飙升。我遇到过最长的一次,一段一千多字的剧情文本差点把移动端的编辑器跑卡死。
建议在进入播报模块之前,先把文本按句子或标点切分成多个片段,每个片段单独进队列播报。这样不仅延迟更低,而且每一段音频都比较小,播放完成可以立刻释放,内存曲线平缓很多。如果某个片段需要重复使用,还可以对它做 Clip 缓存,我在下一章会讲到。
6. 再往深走一步:把 RtVioce 接进对话系统、多角色和音频缓存
6.1 对话系统队列化播报与字幕同步
对话系统的经典结构是:一连串句子,每一句有文本、发言人、可能的音效。接入 RtVioce 后,你可以把每一句当成一个播报任务,任务完成事件就是对话状态机的推进信号。播放前显示字幕,播放中锁定交互,播完隐藏字幕并推进到下一句。
我实现的时候一般会定义一个简单的数据结构:
[System.Serializable] public class DialogueLine { public string speaker; public string text; public float preDelay; }对话系统按顺序取出 DialogueLine,先延时,再显示字幕,然后调用 SpeakService.Speak(text),等播报完成事件后进入下一条。字幕同步的关键就在于不要自己估算“大概播多少秒”,而是直接用事件回调,事件什么时候触发,字幕就什么时候切换,这样不会出现字幕和语音各说各话的情况。
6.2 用音调和语速模拟不同角色
如果你不想给每个角色单独找系统语音,可以用音调和语速做最简单的角色区分。比如老人物的音调调低一点、语速放慢一点,小孩角色音调调高一点、语速略快,配合不同的转场音效,听感上会有一定辨识度。这个方法成本最低,适合原型验证和预算有限的独立项目。
如果项目对角色区分度要求更高,可以优先在 Windows 这类支持多语音引擎的平台上切换不同系统语音,让角色 A 用微软中文语音,角色 B 用自然语音或英文语音。但这种做法的缺点也很明显:平台一换,可选语音列表就变了,需要做好降级策略。比如某个平台没有你指定的音色,就自动回退到默认音色加音调偏移,保证用户至少能听到声音。
6.3 合成音频缓存:把热点文本提前转成 Clip
如果你经常播报重复的文本,比如每次进入主界面都要播同一句欢迎语,那么建议在第一次合成后就把音频缓存下来。RtVioce 这类 TTS 本质上会生成一段音频数据,你可以把这段数据转成 AudioClip,放进内存缓存;对于更长期的复用,甚至可以保存成 wav 文件写到本地,下次启动时直接加载播放,完全绕过实时合成。
我做缓存时通常分两级:内存缓存用于本次运行内反复播的短句,磁盘缓存用于跨启动复用的固定文案。需要注意两点:一是缓存文件的管理和清理机制要提前设计,否则磁盘会越堆越大;二是在分发产品时要考虑你所用语音引擎和语音包的使用条款,别在商业项目里踩了授权红线。缓存思路用得好,播报体验会很接近“本地语音库”的效果,延迟几乎感受不到。
最后分享一点实际使用中的体会。RtVioce 让我最大的感受是:它把“让程序说话”这件事从纯手工时代变成了一条可维护的自动化链路。在 Windows 上几乎开箱即用,Android 只要处理好 TTS 引擎初始化和语音包依赖就非常稳,iOS 把音频会话配置好也不容易翻车。如果决定在项目里长期用它,一定要从第一天就把它封装成独立的语音服务模块,把所有队列、事件、平台差异都关在里面。不要图省事在业务脚本里到处直接调用 Speak,等对话系统、多语言、缓存这些需求一个个加进来之后,统一封装带来的便利会远超你一开始省下的那点工作量。