☰
C++实现即时语音合成工具:基于SAPI的文本朗读方案
2026/10/15 15:21:10 网站建设 项目流程

这阵子给本地阅读器加朗读功能,索性用 C++ 写了个 TTS 即时语音合成工具。核心需求很简单:文本进来,马上出声,能排队、能调速、能随时丢下一段新的文本进去;不要每次调用都起一个外部进程,也不要等整段合成完才开口。断断续续踩了两周的坑,把流程和代码记录下来,给准备在 C++ 项目里接语音合成的朋友做个参考。

这里的“即时”不是说模型本身有多快,而是产品层面的实时性:调用接口到扬声器发声的过程要短,文本可以连续进入,朗读顺序不能乱。适合的场景包括阅读器朗读、字幕配音、按键提示音、自动化测试里的语音播报等。工具本身用 C++ 写,合成内核用系统自带的 SAPI,好处是离线、低延迟、不需要额外部署服务。

1. 项目定位与选型思路

1.1 核心需求拆解

动手之前我先把“即时语音合成”拆成了四个点:

  • 输入是普通文本,不是预先录好的音频。工具要能接受动态产生的段落,比如用户粘贴的整页文字、聊天消息、日志输出。
  • 从调用到出声要短。用户按下“朗读”按钮,如果等一秒钟才听到声音,体验就废了。
  • 要支持排队和清空。连续翻页时,新文本不能把正在读的句子打断,也不应该堆积成几百条队列互相干扰。
  • 尽量离线。阅读器的文本可能是隐私内容,我不希望为了读一段文字把内容传到外部服务上。

这四点结合起来,C++ 是合适的选择。它不是唯一选择,但对于“嵌入到现有桌面工具 + 精细控制音频流程”这种需求,C++ 的天然优势很明显:可以直接调用系统多媒体接口,可以精确控制线程,没有脚本语言的解释器开销。

1.2 为什么最终选了 C++ 加系统 SAPI

有人可能会说,用 Python 调一个库不就行了?确实能跑,但做出来的东西更像“脚本工具”而不是“嵌入式模块”。Python 的 GIL 在处理音频队列时容易添乱,打包体积也不好看,还要处理运行时版本问题。C# 也不错,但如果你团队的应用主体是 C++,为了一个朗读功能再引一套 .NET 运行时,值不值得需要掂量。

SAPI 是 Windows 系统自带的语音接口,对 C++ 来说就是一套 COM 接口,直接CoCreateInstance就能拿到语音对象。它内置中文语音包后,基本开箱即用。我在选型时还考虑了其他方案:

方案集成成本实时表现适用场景
SAPI低,COM 接口直接调用好,内部流式输出Windows 桌面工具
开源命令行合成器中,要封装子进程或 C API中,进程启动有开销跨平台小工具
离线神经网络模型高,要加载模型和推理框架取决于模型量化程度需要高质量音色的独立产品
在线语音合成低,请求接口即可受网络 RTT 影响不具备离线条件的场景

我最后选了 SAPI 作为第一版方案。项目先跑通流程,将来如果要换引擎,再在接口层做隔离。文章里所有代码都基于这一套设计。

1.3 合成引擎底层发生了什么

写 TTS 工具的人容易把“合成”想成一个神秘的盒子:文本进去,音频出来。实际上现代语音引擎内部普遍分两层:前端做文本规范化、分词、注音,后端负责声学建模和波形生成。前端决定文本怎么读,后端决定声音像不像真人。

SAPI 把这部分封装得非常干净。调用Speak的时候,引擎会先把文本拆成语段,再送进合成器,最后把音频流直接送到当前默认音频设备。对我们这些做应用层集成的人来说,不需要去碰音素级的细节,但至少要明白一个道理:合成引擎输出音频是流式的,不是等你给完所有文本才开始。这也正是“即时”能成立的基础。

2. 整体架构与核心模块设计

2.1 模块划分

我把整个工具划分为五个模块:

  1. 文本输入层:接收 UI 传来的字符串,可能是 UTF-8,也可能是宽字符。
  2. 文本预处理层:把长文本切成短句,去掉多余空白,必要时做 XML 转义。
  3. 请求队列:用线程安全的队列保存待朗读文本,保证顺序。
  4. 合成工作线程:唯一负责调用 SAPI 的线程,循环地从队列取文本。
  5. 控制接口:提供开始、排队、清空、退出、调速、调音量等方法。

把“处理文本”和“朗读文本”分开,是为了让 UI 线程永远不被Speak阻塞。UI 线程只往队列里塞数据,真正的阻塞操作都发生在工作线程里。

2.2 数据流与线程模型

数据流是这样的:

UI 输入 -> 文本切分 -> 队列 -> 工作线程 -> SAPI 引擎 -> 声卡

工作线程启动后先初始化 COM,再创建ISpVoice实例,然后进入一个死循环。它使用std::condition_variable等待队列非空。有新文本入队时,工作线程被唤醒,取出一条文本,调用同步的Speak朗读。朗读完再取下一条。

用单工作线程的模型是我刻意保留的简单方案。最开始我也试过多线程直接Speak,结果声音互相打断、顺序乱成一团。语音合成这种任务,本质上要求强顺序输出,改成单线程后,只需要一个互斥锁保护队列就够了,代码简单,也不会出现两个线程同时操作同一个 COM 对象的情况。

2.3 即时性从哪来

很多人以为即时就是把Speak的异步标志打开,调用后立刻返回。其实异步标志只是不阻塞当前线程,真正决定听感的是两个点:

第一个点是“引擎常驻进程内”。如果通过命令行启动外部合成器,每次朗读都要fork出一个进程,加上进程初始化、加载语音模型,几百毫秒就没了。SAPI 的 COM 实例一旦创建,后续每次Speak都复用同一个对象,开声速度自然快。

第二个点是“文本切成短块”。一次塞给引擎五千字,引擎要先处理完一整段,用户会明显感觉延迟。我把长文本按标点切句,每句送到队列,第一句很快开始合成朗读,后面的句子排队继续。这样听感就是“边说边来”,而不是“沉默半天之后突然全说完”。

我实测下来,空队列状态下朗读一个二十字左右的短句,从调用接口到出声基本在 100 到 200 毫秒这个量级。这个数值会因为机器和语音包不同浮动,但作为“即时反馈”已经够用了。

3. 关键代码与实现步骤

3.1 工程初始化与 CMake 配置

我用 CMake 管理工程,编译器用 MSVC。因为 SAPI 是系统 COM 接口,链接时需要ole32和oleaut32。

cmake_minimum_required(VERSION 3.20) project(tts_tool CXX) set(CMAKE_CXX_STANDARD 17) set(CMAKE_CXX_STANDARD_REQUIRED ON) add_executable(tts_tool main.cpp tts_engine.cpp ) target_link_libraries(tts_tool PRIVATE ole32 oleaut32 ) if(MSVC) target_compile_options(tts_tool PRIVATE /utf-8) endif()

这里要强调/utf-8。如果不加这个选项,MSVC 在编译包含中文字符串的源码时容易出现编码警告,运行时代码里的std::string内容也可能和源码不一致。加上之后,源码、字符串字面量、执行字符集都统一成 UTF-8,能从根上少一类乱码问题。

3.2 引擎封装与工作线程

下面这个类是核心。我把 SAPI 的创建、队列循环、参数设置都放进TtsEngine里,UI 层只管SpeakText和几个控制方法。

#include <sapi.h> #include <windows.h> #include <atomic> #include <condition_variable> #include <mutex> #include <queue> #include <string> #include <thread> class TtsEngine { public: TtsEngine() = default; ~TtsEngine() { Stop(); } void Start() { worker_ = std::thread([this] { RunLoop(); }); } void Stop() { { std::lock_guard<std::mutex> lock(mutex_); running_ = false; } cv_.notify_one(); if (worker_.joinable()) { worker_.join(); } } void SpeakText(const std::wstring& text) { if (text.empty()) { return; } { std::lock_guard<std::mutex> lock(mutex_); queue_.push(text); } cv_.notify_one(); } void ClearQueue() { std::lock_guard<std::mutex> lock(mutex_); while (!queue_.empty()) { queue_.pop(); } } void SetRate(int rate) { rate_.store(rate); } void SetVolume(int volume) { volume_.store(volume); } private: void RunLoop() { CoInitializeEx(nullptr, COINIT_MULTITHREADED); ISpVoice* voice = nullptr; HRESULT hr = CoCreateInstance( CLSID_SpVoice, nullptr, CLSCTX_INPROC_SERVER, IID_ISpVoice, reinterpret_cast<void**>(&voice)); if (SUCCEEDED(hr) && voice != nullptr) { voice->SetRate(rate_.load()); voice->SetVolume(static_cast<USHORT>(volume_.load())); } std::unique_lock<std::mutex> lock(mutex_); while (running_) { cv_.wait(lock, [this] { return !running_ || !queue_.empty(); }); if (!running_) { break; } if (queue_.empty()) { continue; } auto text = std::move(queue_.front()); queue_.pop(); lock.unlock(); if (voice != nullptr && !text.empty()) { voice->Speak(text.c_str(), 0, nullptr); } lock.lock(); } if (voice != nullptr) { voice->Release(); } CoUninitialize(); } std::thread worker_; std::mutex mutex_; std::condition_variable cv_; std::queue<std::wstring> queue_; std::atomic<int> rate_{0}; std::atomic<int> volume_{100}; bool running_ = true; };

有几个细节值得说。

CoInitializeEx放在工作线程内部执行,而不是放在主线程。这样ISpVoice实例在线程内部创建,所有调用也都发生在同一个线程里,规避了很多 COM 混线程调用的坑。

Stop是优雅退出,不是强杀线程。如果工作线程正在朗读一个很长的段落,Stop会等它读完再返回。这在实际使用中更安全。真要强制打断,需要额外的打断机制,后面说。

rate_和volume_用std::atomic,是为了让 UI 线程可以随时修改参数,工作线程在下一次朗读前读到最新值。不要直接在 UI 线程调voice->SetRate,那会和工作线程的Speak产生竞态。

3.3 长文本切分与中文处理

SAPI 的Speak接口接收宽字符串。如果 UI 给的是 UTF-8 的std::string,要先转成std::wstring。常见的转换方法是用系统 API:

#include <windows.h> std::wstring Utf8ToWide(const std::string& text) { if (text.empty()) { return L""; } int length = MultiByteToWideChar( CP_UTF8, 0, text.c_str(), -1, nullptr, 0); std::wstring result(length - 1, L'\0'); MultiByteToWideChar( CP_UTF8, 0, text.c_str(), -1, &result[0], length); return result; }

转换之后,再做切句。我按中文常见标点和换行符切分,同时限制单块长度:

#include <vector> std::vector<std::wstring> SplitForSpeech( const std::wstring& input, size_t maxLen = 200) { std::vector<std::wstring> result; std::wstring current; for (wchar_t ch : input) { current.push_back(ch); if (ch == L'。' || ch == L'!' || ch == L'?' || ch == L'\n' || current.size() >= maxLen) { result.push_back(current); current.clear(); } } if (!current.empty()) { result.push_back(current); } return result; }

接入时这样用:

auto chunks = SplitForSpeech(longText); for (auto& chunk : chunks) { tts.SpeakText(chunk); }

这里有个我踩过的坑:一开始只按句号切,遇到长段落里大量“,”时,一个句子可能会到三五百字,开声延迟明显变大。把maxLen限制在 200 字左右后,延迟稳定了很多。朗读节奏上,短句之间衔接反而更自然。

3.4 命令行示例与 UI 接入

为了方便验证,我写了一个简单的主程序,从 stdin 读入文本,回车触发朗读:

#include <iostream> #include <string> int main() { SetConsoleOutputCP(CP_UTF8); TtsEngine tts; tts.Start(); tts.SetRate(1); tts.SetVolume(90); std::cout << "输入文本,回车朗读;输入 :clear 清空;输入 :quit 退出\n"; std::string line; while (std::getline(std::cin, line)) { if (line == ":clear") { tts.ClearQueue(); continue; } if (line == ":quit") { break; } std::wstring text = Utf8ToWide(line); if (!text.empty()) { tts.SpeakText(text); } } tts.Stop(); return 0; }

在实际的阅读器项目里,你只需要把std::getline的部分替换成界面事件回调。注意一点:SpeakText内部只做入队操作,锁粒度很小,所以可以直接在 UI 线程调用,不会造成明显卡顿。

4. 实测效果与参数调优

4.1 开声延迟与排队表现

我在普通台式机上跑这个工具,用的是系统自带的中文语音包。空队列状态下,输入二十字左右的短句,从回车到听到声音,目测在 100 到 200 毫秒之间。连续输入多段文本时,第一段会立刻开声,后续段落依次排队。

这里有一个重要结论:切句切得越短,第一句开声越快。如果你把一篇五千字的文章一次性传给Speak,SAPI 会先对整篇做文本处理和韵律规划,用户会明显听到一段空白。切句送到队列后,引擎只规划当前这一小句,声音自然来得快。

语音包不同,耗时也不同。机械感比较强的语音包通常更快,高自然度的合成模型因为计算量大,延迟会稍高。做产品时不要采用“定制语音包一定更好”的思路,要先拿目标语音包实测首字延迟。

4.2 语速、音量和朗读间隔的调整

SAPI 的SetRate参数范围是-10到10。我平时用0作为默认,听感比较正常;快速浏览时用2,能明显提速但又不会黏成一片。-1适合朗读长段内容,听起来更稳。具体数值因人而异,建议做成滑块让用户自己调,不要写在配置里锁死。

音量参数SetVolume接收0到100的整数。如果你发现系统音量已经很大但合成的音量偏小,先把它设到 80 以上。

还有一个很实用的技巧:在文本里插入 SSML 标记可以控制停顿。SAPI 支持<break time="120ms"/>,比如句子之间想留一点呼吸感:

std::wstring text = L"这句话说完,<break time=\"150ms\"/>下一句再接上。"; voice->Speak(text.c_str(), SPF_IS_XML, nullptr);

使用 SSML 时需要传入SPF_IS_XML标志,否则引擎会把<break>当成普通文本读出来。插入标记之前还要对文本里的<、>、&做转义,否则会被误解析成标签。我在实际项目中只在固定文案里用 SSML,动态文本一律不拼接标记,因为转义漏掉一个符号就可能读到一串乱码。

4.3 资源占用与控制心态

这个工具跑起来后,工作线程平时在condition_variable上等待,几乎没有 CPU 占用。语音包加载完成后,内存占用大约几十 MB,对现代桌面应用来说可以忽略。

做这类工具时,我自己的心态是:先把功能跑通,再谈优化。第一版不要急着上异步事件流、暂停恢复、波形可视化那些炫技功能。先把“文本进、声音出、顺序不乱、能清空”这四件事做好,产品已经能用了。

5. 常见问题与排查技巧实录

5.1 没有中文语音,文本被按英文读

这可能是最常见的问题。系统自带 SAPI 默认语音包可能不是中文,这时候中文会被读成不标准的英文口音。解决方法是先确认系统里安装了中文语音包,并在代码里显式选择中文语音 token。

不要依赖默认 voice。我建议在初始化ISpVoice之后,枚举系统语音 token,找出语言标记为中文的那个,然后调用voice->SetVoice(token)。如果找不到中文 token,可以提示用户去系统“语音设置”里添加,而不是默默读出一堆怪音。

5.2 Stop 之后线程退不出来

如果你的长文本没有切句,一次性塞给Speak一个几千字的字符串,Stop会一直等着它朗读完,表现为“点了退出但程序没反应”。

这不一定是死锁,而是同步朗读还没结束。对策就是前面说的切句限制,每个块不要太长。如果产品里确实需要立刻退出,可以在Stop里调用Speak(NULL, SPF_PURGEBEFORESPEAK, nullptr)清空当前语音,但这个调用需要和正在执行的Speak放在同一个线程里才最安全。我的建议是,工作线程结构下优先做“很快退出”,而不是“立刻退出”。

5.3 没有任何声音输出

先检查三件事:ISpVoice对象是否创建成功,SetVolume是否设置成了 0,系统默认播放设备是否正常。SAPI 默认输出到系统默认音频设备,如果你的程序日志里输出重定向到了无音频环境,就听不到声音。

还有一种情况是CoInitializeEx没有被正确调用。在非 COM 初始化线程里创建ISpVoice,大概率返回错误。把初始化和使用放到同一个线程,问题就少很多。

5.4 实时打断功能怎么做

本文给出的同步队列方案,优点是简单可靠,缺点是无法打断正在朗读的那一句。ClearQueue只能清掉还没开始读的内容,当前句子会读完。

如果产品需要类似“用户按了新按钮,立刻闭嘴”的效果,我建议换一种设计:用SPF_ASYNC调用Speak,并监听 SAPI 的输入流开始结束事件。这样打断时可以调用Speak(NULL, SPF_PURGEBEFORESPEAK, nullptr),它会把当前语音清掉,再入队新文本。这个方案的代码复杂度会明显上升,因为要处理事件回调、线程同步、中断后的队列一致性。按我的经验,先想清楚产品到底需不需要打断:很多阅读器场景里,“念完当前这句再响应新指令”反而是更自然的行为,硬打断只会让用户觉得声音忽停忽起。

最后再说一个实际体会:做即时语音合成工具,最大的坑往往不在语音引擎本身,而在“不设防的文本”。换行符、全半角标点、XML 标签、超长字符串,都可能让听感突然崩掉。把文本预处理当成一等模块来设计,别图省事直接丢进Speak。这套结构跑顺手之后,再接入新的引擎,只是替换一个实现类的事。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询