How to Run Private Text-to-Speech on Your Own Hardware Using QVAC
TL;DR · AI 摘要
使用 QVAC 在本地设备上实现高质量离线文本转语音(TTS),无需依赖云服务,适合独立开发者。
核心要点
- QVAC 可在本地设备上实现高质量离线 TTS,避免云服务的高成本和延迟。
- React Native 与 QVAC SDK 集成可实现本地语音合成,适合移动应用开发。
- 文章提供完整实现步骤,包括环境配置、依赖设置和音频工具打包。
结构提纲
按章节快速跳转。
思维导图
用一张图看清主题之间的关系。
查看大纲文本(无障碍 / 无 JS 友好)
- 本地离线 TTS 实现
- QVAC 框架
- 本地硬件加速
- 移动设备推理
- 实现步骤
- 环境配置
- 依赖设置
- 音频工具打包
金句 / Highlights
值得收藏与分享的关键句。
依赖云服务的 TTS 成本过高且延迟严重,导致作者放弃 QuizRope 项目。
QVAC 支持本地硬件加速和优化,适用于移动设备上的机器学习模型部署。
文章提供完整的代码实现,包括音频工具打包和代码库分解。
如何使用 QVAC 在自己的硬件上运行私有的文本到语音
2026年6月14日
/
#机器学习
Djibril-M🍀
当我正在为 QuizRope 这款教育类移动应用做最后的润色时,这款应用使用了大型语言模型(LLMs)来进行实时辅导和作业帮助,我知道下一步自然就是语音。在屏幕上阅读文本固然很好,但让一个AI导师直接与你对话,能够彻底改变整个学习体验。
自然而然,我的第一反应是查看云服务提供商。虽然像 ElevenLabs 这样的服务提供了出色的语音质量,但当我快速计算成本时,数字却变得令人难以接受。从API的价格、长时间辅导会话中消耗的令牌数量,到我预计的用户数量,这些数字很快变得非常糟糕。对于一个独立开发者来说,依赖付费API来处理应用中每一个句子的语音合成显然不可持续。
如果你正要问:“QuizRope 项目进展到哪里了?”,老实说,当时我因为找不到一个合理且价格可接受的TTS功能解决方案,直接放弃了这个项目。
除了高昂的成本,还有延迟的问题。等待服务器处理提示、生成音频,并将其流式传输回移动设备,这完全破坏了对话的幻觉。最糟糕的是,这意味着学生提出的每一个问题都会被发送到第三方服务器。
这种挫败感促使我寻找一个可靠、离线、完全零成本的解决方案。
在本文中,我们将构建一个React Native应用程序,使用设备自身的硬件完全离线地执行高保真文本到语音(TTS)。
如果你尚未设置环境,或者需要复习本地推理的基础知识,我强烈建议你阅读我之前的文章《如何使用 QVAC 在 React Native 中离线运行本地 LLM》,其中我涵盖了项目初始化、预构建和原生硬件依赖。
本指南假设你已经有一个配置了 QVAC SDK 并准备好在物理设备上运行的项目。
目录
- 先决条件
- 什么是 QVAC?
- QVAC 支持的架构
- 推理流程
- 环境和依赖配置
- 音频工具包封装
- 完整实现
- 代码库分解
- 结论
- 资源和进一步阅读
先决条件
为了充分利用本文内容,你应该具备现代网页和移动开发的坚实基础:
- JavaScript/TypeScript & React:熟悉React概念和钩子,特别是useState、useEffect和useRef。
- React Native & Expo:基本了解布局结构(如View、ScrollView、TextInput)和样式规范。
- 异步JavaScript & 二进制缓冲区:有使用async/await、Promises以及Int16Array或Buffer等数组的基本操作经验。
- 开发构建环境:熟悉运行本地开发编译命令,特别是使用npx expo prebuild来构建原生iOS和Android模块。
- 物理移动设备:由于本地机器学习模型利用设备特定的硬件加速和原生优化,QVAC SDK不支持模拟器环境。你必须拥有一个启用了开发者模式的物理iOS或Android测试设备。
什么是 QVAC?
为了帮助你更有效地理解,让我们先明确QVAC是什么以及它存在的原因。
由 Tether 开发的 QVAC 是一个以本地优先(local-first)为设计理念的 AI SDK,旨在构建跨平台、点对点(P2P)的应用和系统。
许多使用大型语言模型(LLMs)或文本转语音(TTS)引擎的移动应用依赖于向云端 API(如 OpenAI 或 ElevenLabs)发送网络请求。虽然这种模式很便捷,但会引入对网络连接的依赖、重复的 API 使用费用以及将用户数据传输到第三方服务器的问题。
QVAC 提供了一种替代方案,即直接在客户端设备上运行 AI 模型。这种以本地优先的架构带来了以下实际优势:
- 本地优先执行:直接在客户端硬件上运行推理,无需外部 API 或活跃的互联网连接。
- 点对点(P2P)支持:允许在本地网络中分配推理任务,无需依赖中央服务器即可协调工作负载。
- 跨平台兼容性:提供一个统一的 JavaScript/TypeScript 接口,可在不同的硬件和运行时环境中保持一致的行为。
- 统一的功能:在一个包中提供文本生成、语音转文字、图像生成和语音合成等功能。
本地推理的关键概念
要理解 QVAC 如何在移动设备上运行,我们需要牢记以下几个关键概念:
- 本地推理(On-Device Inference):在本地运行模型计算。与依赖单一引擎不同,QVAC 根据任务的不同支持多种本地推理后端(如用于文本的 llama.cpp、用于语音转文字的 whisper.cpp 或用于图像生成的自定义扩散后端)。在底层,这些引擎会直接将量化后的模型权重映射到设备的 RAM 中,并使用原生 GPU 硬件加速进行计算。
- 量化(GGUF 格式):一种数学优化技术,用于压缩模型的权重(例如,从标准的 16 位浮点精度压缩到 4 位或 8 位整数)。这使得模型能够在消费级移动硬件的内存限制下运行,同时保持输出质量。
- KV(键值)缓存:一个存储先前标记计算状态的内存区域,使模型在生成每个单词或标记时无需重新评估整个上下文窗口。
QVAC 支持的架构
在编写代码之前,理解 QVAC SDK 在幕后实际发生的事情至关重要。为了在不使设备过热的情况下处理本地执行,QVAC SDK 管理硬件绑定和模型生命周期,同时集成到优化过的、由社区维护的 GGML 推理后端中。
QVAC SDK 并不采用“一刀切”的方法,而是支持两种截然不同的神经网络架构用于语音合成。根据您的应用需求——无论是希望实现即时语音克隆还是超高保真预训练语音——您可以在 Chatterbox 和 Supertonic 之间进行选择。
| 特性 | Chatterbox | Supertonic | |------|------------|------------| | 架构 | 基于 Transformer 的语言模型 | 基于扩散的潜在去噪 | | 模型结构 | 分离(T3 GGUF + S3Gen 附加组件) | 单个文件(GGUF) | | 语音方法 | 零样本语音克隆(参考 WAV) | 预训练语音风格 | | 采样率 | 24,000 Hz | 44,100 Hz |
1. Chatterbox 引擎
Chatterbox 基于 Transformer 的语言模型架构构建。它将音频生成的方式类似于 LLM 预测句子中下一个单词,但不同的是,它预测的是离散的声学标记。
由于这种架构,Chatterbox 在零样本语音克隆方面表现出色。与其完全依赖预设的语音,你可以将一个可选的 referenceAudioSrc(一段某人说话的短 WAV 文件)与你的文本一起传入。Transformer 会分析参考音频的声学特性,并根据这些特征生成克隆的语音。
2. Supertonic 引擎
Supertonic 采用完全不同的方法,利用基于扩散的潜在去噪技术——这与 Stable Diffusion 等 AI 图像生成器所使用的根本架构相同,但应用于音频。
它从纯数字噪声开始,逐步将其优化为基于文本提示的 44.1 kHz 高保真语音波形。Supertonic 使用一个统一的 GGUF 文件,而不是拆分模型。它不依赖于动态语音克隆,而是使用高度优化的预训练语音风格(例如,语音:“F1” 或 语音:“M1”),这些风格直接嵌入到模型中。这使得在不需要动态克隆功能时,生成清晰、专业级别的语音变得非常高效。
在本教程中,我们将使用 Supertonic。它开箱即用就能产生出色的结果,并避免了加载多个配套文件的复杂性。
推理流程
为了可视化我们如何在代码库中与这些引擎进行交互,可以将本地 TTS(文本到语音)想象成在手机内存中运行的虚拟录音棚:
- 雇佣演员(加载模型):我们将压缩的 GGUF 文件直接映射到设备的 RAM 或 GPU VRAM 中。
- 交付剧本(文本输入):我们将纯文本传递给已加载的引擎。
- 表演(推理):引擎读取文本并数学地预测声波。关键的是,AI 不会生成完成的音频文件。相反,它输出原始的数字声波,称为 PCM 样本。
- 包装音频:由于原始数字列表无法被标准媒体播放器播放,我们必须手动将 PCM 数据封装在标准的 WAV 头中。
- 关闭录音棚(卸载):由于语音合成内存密集且保持持久状态,模型将从 RAM 中清除以释放资源并清除其上下文。
环境和依赖配置
在深入代码库之前,如果你的项目使用了 pnpm 包管理器,需要记住一个关键的依赖设置。
由于 QVAC 插件依赖于传递的原生对等依赖项,像 pnpm 这样的严格包管理器会将这些依赖项锁定在隐藏的 .pnpm 子文件夹中。
为了确保 QVAC 原生打包器(bare-pack)在构建时能正确解析你的工作线程插件,请在项目根目录中创建一个 .npmrc 文件:
shamefully-hoist=true重要提示:创建此文件后,你必须运行一次干净的依赖安装(pnpm install)。这确保了你的根 node_modules 中的布局是扁平的,从而在本地 npx expo prebuild 编译步骤中能正确解析所有 QVAC 特定的辅助包。
音频实用工具封装
由于 QVAC 输出原始的 PCM 数组,我们需要在内存中构建一个有效的 WAV 文件,并将其写入设备的存储,以便原生音频播放器可以播放它。
为了实现这一点,让我们在 src/lib/utils.ts 中创建一个实用模块,用于构建所需的 WAV 头,将原始音频样本转换为二进制缓冲区,并将其写入本地存储。
import { Buffer } from "buffer";
import * as FileSystem from "expo-file-system/legacy";/**
- 为16位PCM音频创建WAV头
*/ export function createWavHeader( dataLength: number, sampleRate: number, ): Buffer { const buffer = Buffer.alloc(44); const channels = 1; // 单声道 const byteRate = sampleRate * channels * 2; // 16位音频 const blockAlign = channels * 2;
buffer.write("RIFF", 0); buffer.writeUInt32LE(36 + dataLength, 4); buffer.write("WAVE", 8); buffer.write("fmt ", 12); buffer.writeUInt32LE(16, 16); // 子块大小 buffer.writeUInt16LE(1, 20); // 音频格式(PCM) buffer.writeUInt16LE(channels, 22); buffer.writeUInt32LE(sampleRate, 24); buffer.writeUInt32LE(byteRate, 28); buffer.writeUInt16LE(blockAlign, 32); buffer.writeUInt16LE(16, 34); // 每样本位数 buffer.write("data", 36); buffer.writeUInt32LE(dataLength, 40);
return buffer; }
/**
- 将来自QVAC的原始Int16Array样本转换为二进制Buffer
*/ export function int16ArrayToBuffer(int16Array: Int16Array): Buffer { const buffer = Buffer.alloc(int16Array.length * 2); for (let i = 0; i < int16Array.length; i++) { buffer.writeInt16LE(int16Array[i] ?? 0, i * 2); } return buffer; }
/**
- 主函数,用于打包并保存文件到本地移动存储
*/ export async function saveAudioToDevice( audioBuffer: Int16Array, sampleRate: number, ): Promise<string> { try { const audioData = int16ArrayToBuffer(audioBuffer); const wavHeader = createWavHeader(audioData.length, sampleRate); const finalWavBuffer = Buffer.concat([wavHeader, audioData]); const base64Data = finalWavBuffer.toString("base64");
const filename = tts-speech-${Date.now()}.wav; const fileUri = \${FileSystem.documentDirectory}\${filename};
await FileSystem.writeAsStringAsync(fileUri, base64Data, { encoding: FileSystem.EncodingType.Base64, });
console.log(✅ 文件已本地保存至: ${fileUri}); return fileUri; } catch (error) { console.error("❌ 保存音频文件到本地失败:", error); throw error; } }
## 完整实现
现在我们将所有内容整合在一起。我们将实现一个接口,该接口接收用户输入,管理Supertonic引擎的下载和加载状态,将生成的原始波形打包为可播放的本地文件,并渲染一个交互式的波形可视化播放器。
请将您的入口应用文件 src/app/index.tsx 替换为以下实现:
import { useState, useEffect } from "react"; import { TextInput, KeyboardAvoidingView, Platform, ScrollView, } from "react-native"; import { loadModel, unloadModel, textToSpeech, downloadAsset, TTS_EN_SUPERTONIC_Q8_0, getModelInfo, type ModelProgressUpdate, } from "@qvac/sdk"; import { saveAudioToDevice } from "@/lib/utils"; import { TtsModelLoader } from "@/components/tts-model-loader"; import { AudioPlayer } from "@/components/audio-player"; import { Card, CardContent, CardDescription, CardHeader, CardTitle, } from "@/components/ui/card"; import { Button } from "@/components/ui/button"; import { Text } from "@/components/ui/text";
const SUPERTONIC_SAMPLE_RATE = 44100;
// 全局模型ID引用 let globalModelId: string | null = null;
type TtsStatus = | { phase: "idle" } | { phase: "synthesizing" } | { phase: "done"; audioUri: string } | { phase: "error"; message: string };
export default function TextToVoiceScreen() { const [text, setText] = useState(""); const [status, setStatus] = useState<TtsStatus>({ phase: "idle" });
const [isModelLoaded, setIsModelLoaded] = useState(!!globalModelId); const [isDownloading, setIsDownloading] = useState(false); const [downloadProgress, setDownloadProgress] = useState(0);
const isBusy = status.phase === "synthesizing";
useEffect(() => { async function checkAndAutoLoad() { if (globalModelId) return; try { const info = await getModelInfo({ name: TTS_EN_SUPERTONIC_Q8_0.name }); if (info.isCached) { setIsDownloading(true); setDownloadProgress(1);
globalModelId = await loadModel({ modelSrc: TTS_EN_SUPERTONIC_Q8_0, modelConfig: { ttsEngine: "supertonic", language: "en", voice: "F1", ttsSpeed: 1.05, ttsNumInferenceSteps: 5, }, });
setIsModelLoaded(true); setIsDownloading(false); } } catch (err: unknown) { console.warn("Failed to auto-load cached model on mount:", err); setIsDownloading(false); } } checkAndAutoLoad(); }, []);
const handleDownloadModel = async () => { if (isDownloading || isModelLoaded) return;
try { setIsDownloading(true); setDownloadProgress(0);
await downloadAsset({ assetSrc: TTS_EN_SUPERTONIC_Q8_0, onProgress: (p: ModelProgressUpdate) => { setDownloadProgress(p.percentage / 100); }, });
setDownloadProgress(1);
globalModelId = await loadModel({ modelSrc: TTS_EN_SUPERTONIC_Q8_0, modelConfig: { ttsEngine: "supertonic", language: "en", voice: "F1", ttsSpeed: 1.05, ttsNumInferenceSteps: 5, }, });
setIsModelLoaded(true); setIsDownloading(false); } catch (err: unknown) { console.error("Failed to download or load model:", err); setIsDownloading(false); setStatus({ phase: "error", message: err instanceof Error ? err.message : String(err), }); setIsModelLoaded(false); } };
const handleSubmit = async () => { if (!text.trim() || isBusy || !globalModelId) return;
try { setStatus({ phase: "synthesizing" });
// 1. Unload and reload the model to reset its state and clear the KV cache. if (globalModelId) { await unloadModel({ modelId: globalModelId }); } globalModelId = await loadModel({ modelSrc: TTS_EN_SUPERTONIC_Q8_0, modelConfig: { ttsEngine: "supertonic", language: "en", voice: "F1", ttsSpeed: 1.05, ttsNumInferenceSteps: 5, }, });
// 2. Synthesize text to raw PCM samples const result = textToSpeech({ modelId: globalModelId, text: text.trim(), inputType: "text", stream: false, });
const audioBuffer = await result.buffer;
// 3. Package and save WAV file using our local util const samplesInt16 = new Int16Array(audioBuffer); const wavUri = await saveAudioToDevice( samplesInt16, SUPERTONIC_SAMPLE_RATE, );
// 4. Show player setStatus({ phase: "done", audioUri: wavUri }); } catch (err: unknown) { console.error("TTS error:", err); const msg = err instanceof Error ? err.message : String(err); setStatus({ phase: "error", message: msg }); } };
const buttonLabel = status.phase === "synthesizing" ? "Synthesizing…" : "Synthesize Speech";
if (!isModelLoaded) { return ( <TtsModelLoader onDownload={handleDownloadModel} isDownloading={isDownloading} progress={downloadProgress} /> ); }
return ( <KeyboardAvoidingView behavior={Platform.OS === "ios" ? "padding" : "height"} className="flex-1 bg-black" > <ScrollView contentContainerClassName="flex-grow p-6 justify-center"> <Card className="border border-border bg-card max-w-md w-full mx-auto"> <CardHeader> <CardTitle variant="h3" className="text-white text-center"> Text to Voice </CardTitle> <CardDescription className="text-center mt-1"> Type or paste your content to synthesize speech </CardDescription> </CardHeader>
<CardContent className="gap-6"> <TextInput className="bg-muted text-white border border-border rounded-lg p-4 h-48 text-base leading-6" multiline numberOfLines={8} placeholder="Type your message here..." placeholderTextColor="#666" value={text} onChangeText={setText} style={{ textAlignVertical: "top" }} editable={!isBusy} />
{status.phase === "error" && ( <Text className="text-destructive text-sm text-center"> {status.message} </Text> )}
{status.phase === "done" && <AudioPlayer uri={status.audioUri} />}
<Button onPress={handleSubmit} className="w-full h-12 rounded-xl" disabled={!text.trim() || isBusy} > <Text className="font-semibold text-lg">{buttonLabel}</Text> </Button> </CardContent> </Card> </ScrollView> </KeyboardAvoidingView> ); }
### 代码库解析
让我们深入了解这个本地文本到语音实现是如何管理原生模型生命周期以及处理原始音频数组的。
#### 1. 管理原生生命周期
加载用于语音合成的神经网络权重计算成本很高。当 QVAC 运行时初始化模型时,它必须从本地磁盘读取参数,并将活动权重复制到设备的 RAM 中。
为了高效处理这一过程,我们在组件作用域之外声明了引用变量:
let globalModelId: string | null = null;
如果将 globalModelId 跟踪在组件状态中,当用户导航离开文本到语音屏幕时,状态会被清理,导致应用程序不必要地释放引用。通过全局存储 ID,我们可以确保在布局转换过程中保持引用。
#### 2. 刷新 KV 缓存:卸载和重新加载
使用 GGML 引擎进行离线生成时,状态管理是最重要的方面之一:
// 1. 卸载并重新加载模型以重置其状态并清除 KV 缓存。 if (globalModelId) { await unloadModel({ modelId: globalModelId }); }
globalModelId = await loadModel({ ... });
关于声学幻觉的警告:如果你在不重置单个 TTS 模型实例的情况下持续合成句子,模型的键值(KV)缓存会逐渐被填满。此时,模型会将新句子视为前一句的延续,从而导致严重的机械式失真、回声和重复的声音。
通过显式地使用 unloadModel 销毁模型,并立即通过 loadModel 启动一个新的实例,我们强制模型进入一个干净、空的上下文窗口。由于模型已经下载并映射到内存中,从本地闪存存储中直接重新加载模型的速度非常快,通常在现代移动设备上只需不到一秒的时间,从而确保无缝的用户体验,同时保证音频无瑕疵。
#### 3. 解析 WAV 文件头结构
操作系统和内置的移动媒体解码器无法直接解析原始的、未封装的 PCM(脉冲编码调制)声波。原始 PCM 缓冲区只是一个表示音频波幅值的数值坐标流。
我们通过在 PCM 缓冲区前面添加一个标准的 44 字节的 RIFF/WAVE 文件头来解决这个问题。
该文件头就像一个通行证,定义了以下内容:
- AudioFormat(1):表示未压缩的线性 PCM。
- NumChannels(1):单声道音频。
- SampleRate(44100):Supertonic 播放所需的时钟频率。
- BitsPerSample(16):16 位字长(每个样本 2 字节)。
此外,文件的写入是通过 Base64 编码来实现的,这样可以安全地跨过 React Native 的 JavaScript 到 Native 桥接,而不会丢失二进制数据:
const base64Data = finalWavBuffer.toString("base64"); await FileSystem.writeAsStringAsync(fileUri, base64Data, { encoding: FileSystem.EncodingType.Base64, });
#### 4. 视觉波形播放器
我们没有使用一个简单的后台音频播放器,而是将本地 WAV 文件路径传递给一个由 @simform_solutions/react-native-audio-waveform 支持的自定义 <AudioPlayer> 组件。
这个模块会分析我们新生成的 WAV 文件,并绘制出一个光滑、类似 WhatsApp 的交互式视觉波形,使用户能够完全控制播放、动态速度调整(1x、1.5x、2x)和跳转。这大大提升了用户体验,使最终结果显得高端且精致。
## 结论
将文本到语音(TTS)从云端迁移到设备本地硬件,为移动应用开发者提供了一种切实可行的方法。在本地运行模型推理可以消除对远程互联网连接的依赖,避免重复的 API 使用成本,并确保用户的文本输入永远不会离开物理设备。
将本地语音合成集成到交互式、教育类或对话式应用中,可以带来显著的好处。例如,在语音引导系统中,设备本地的 TTS 允许应用在私密或离线环境中运行。随着边缘处理器获得专用的硬件加速核心,以及开源模型通过量化研究减少内存占用,以本地优先架构为特点的方案,为重视隐私、离线韧性以及可预测成本结构的开发者提供了极具吸引力的替代方案。
## 资源与进一步阅读
要深入了解本地文本到语音的推理过程,查看源代码,或探索移动应用的高级配置,请参考以下资源:
- QVAC Expo 集成文档:了解如何在 Expo 中配置自定义本地模型。
- react-native-audio-waveform : 了解更多关于交互式 React Native 音频可视化的内容。
- Hugging Face 上的 GGUF 模型库 : 浏览兼容的量化开源模型。
- Latent Denoising 深度解析 : 深入探讨基于扩散模型的声学生成技术。
- https://github.com/DjibrilM/QVAC-TTS-Expo-Implementation : 完整的实现代码。
👋 我是 Jibril Mugisho,一名专注于使用 AI 和现代后端技术构建智能、可扩展应用的全栈开发者。我的工作重点在于 AI 代理开发、大语言模型集成,以及创建能够提升自动化和决策能力的自主系统。我喜欢使用 LangChain 构建 AI 驱动的工作流程,并热衷于尝试本地推理设置,以探索高效且注重隐私的 AI 解决方案。
如果你读到了这里,请感谢作者,以表达你对他们的关心。说声谢谢
免费学习编程。freeCodeCamp 的开源课程已帮助超过 40,000 人成为开发者。立即开始
ADVERTISEMENT