当我正在对QuizRope进行最后的完善工作时,这个我开发的教育类移动应用利用大语言模型提供实时辅导和作业帮助,我当时就知道下一步应该尝试引入语音功能。在屏幕上阅读文本固然很好,但让人工智能导师真正地“与你对话”,才能彻底改变学习体验。

自然而然地,我的第一反应是寻找云服务提供商。虽然像ElevenLabs这样的服务能提供非常高质量的语音服务,但我很快算了算成本:无论是API的使用费用、长时间辅导过程中所需的token消耗量,还是我预计会拥有的用户数量,这些因素加在一起,使得依赖付费API来实现应用中的语音功能对于一个独立开发者来说根本不可行。

如果你现在想问“QuizRope项目进展到什么程度了?”的话,说实话,我当时就放弃了这个项目,因为找不到一种既可行又经济可行的方案来实现文本转语音功能。

除了高昂的成本之外,还有延迟问题。等待服务器处理指令、生成音频并将其传输回移动设备,会完全破坏对话的流畅感。更糟糕的是,这意味着学生提出的每一个问题都会被发送到第三方服务器上。

正是这种挫败感促使我开始寻找一种可靠、无需联网且完全免费的解决方案。

在这篇文章中,我们将学习如何使用设备的硬件资源,在完全离线的情况下实现高保真的文本转语音功能。

如果你还没有搭建开发环境,或者需要回顾一下本地推理的相关基础知识,我强烈推荐你阅读我之前的文章:如何使用QVAC在React Native中实现本地大语言模型运行。在那篇文章中,我详细介绍了项目初始化、预构建以及与硬件相关的配置步骤。

本指南假设你已经准备好了一个配置了QVAC SDK的项目,并且可以在实体设备上运行它。

目录

先决条件

要想充分理解本文的内容,您需要具备现代网页开发和移动应用开发方面的扎实基础:

  • JavaScript/TypeScript与React:熟悉React的相关概念及钩子函数,尤其是useStateuseEffectuseRef

  • React Native与Expo:了解基本的布局结构(如ViewScrollViewTextInput)以及样式设置规则。

  • 异步JavaScript与二进制缓冲区:具备使用async/await、Promise进行异步操作的经验,同时熟悉对Int16ArrayBuffer等数组的基本操作。

  • 开发环境配置:了解如何运行本地编译命令,特别是npx expo prebuild这一命令,用于构建iOS和Android的原生模块。

  • 实体移动设备:由于本地机器学习模型需要利用设备的硬件加速功能及原生优化机制,因此QVAC SDK不支持模拟器环境。您必须拥有一台开启了开发者模式的iOS或Android测试设备。

什么是QVAC?

为了帮助您更有效地理解本文内容,我们首先需要明确QVAC的定义及其存在的意义。

QVAC是由Tether开发的、以本地计算为核心的人工智能开发工具包,专门用于构建跨平台的点对点应用程序和系统。

许多使用大型语言模型或文本转语音技术的移动应用都需要通过网络向云端的API服务(如OpenAI或ElevenLabs)发送请求。虽然这种方式使用起来很方便,但它存在依赖网络连接、需要支付API使用费用以及用户数据会被传输到第三方服务器等问题。

QVAC提供了另一种解决方案——它允许在客户端设备上直接运行人工智能模型。这种以本地计算为主的设计模式具有以下显著优势:

  • 本地优先执行:推理过程直接在客户端硬件上完成,因此无需依赖外部API或持续保持互联网连接。

  • 点对点支持:可以在局域网内分配推理任务,从而实现无需中心服务器即可协调工作流程的目标。

  • 跨平台兼容性

    :提供统一的JavaScript/TypeScript接口,确保在不同硬件和运行环境中都能正常使用。

  • 功能集成

    :同一个工具包中就包含了文本生成、语音转文字、图像生成以及语音合成等功能。

设备端推理的关键概念

要理解QVAC在移动设备上的运行机制,我们需要掌握以下几个关键概念:

  • 设备端推理:在本地执行模型计算。QVAC并不依赖单一的推理引擎,而是会根据具体任务选择不同的专用本地推理后端——例如,llama.cpp用于文本处理,whisper.cpp用于转录,而自定义的扩散算法后端则用于图像生成。在这些后端的内部实现中,量化后的模型权重会被直接映射到设备的RAM中,计算过程也会借助GPU的硬件加速功能来完成。

  • 量化技术(GGUF格式):这是一种数学优化方法,用于压缩模型的权重数据——例如,将原本的16位浮点数精度降为4位或8位整数。这种技术使得模型能够适应消费级移动设备的内存限制,同时仍能保证输出质量。

  • KV缓存:这是一种用于存储先前处理过的数据的结构,这样模型在生成每个单词或符号时,就无需重新计算整个上下文信息了。

QVAC支持的架构

在编写代码之前,了解系统内部的具体运作机制是非常重要的。为了确保设备不会因过度运行而损坏,QVAC SDK负责管理硬件资源的分配以及模型的生命周期,并且会与那些经过优化、由社区维护的GGML推理后端进行对接。

QVAC SDK并没有采用“一刀切”的设计方式,而是为语音合成提供了两种截然不同的神经网络架构。根据你的应用需求——无论是想要实现即时语音克隆功能,还是需要使用预训练的高保真语音效果——你可以在这两种架构中做出选择:ChatterboxSupertonic

特性 Chatterbox Supertonic
架构类型 基于Transformer的语言模型 基于扩散算法的潜在噪声去除技术
模型结构 分体式结构(T3 GGUF模型 + S3Gen辅助模块) 单文件格式(GGUF格式)
语音生成方式 零样本语音克隆(参考WAV文件) 预训练的语音风格库
采样率 24,000 Hz 44,100 Hz

1. Chatterbox引擎

Chatterbox是基于基于Transformer的语言模型架构设计的。它将音频生成过程类比于大型语言模型预测句子中下一个单词的方式,但不同的是,它生成的其实是离散的声学符号。

正是由于这种架构设计,Chatterbox在零样本语音克隆方面表现得非常出色。用户不仅可以使用预训练好的语音模板,还可以提供一段参考音频文件,让系统根据这些音频特征生成全新的克隆语音。

2. SuperTonic引擎

SuperTonic采用了完全不同的技术路径,它运用了基于扩散的潜在去噪技术——这种技术架构同样被Stable Diffusion等AI图像生成工具所采用,但在这里被应用于音频处理领域。

该系统从纯数字噪声开始,根据文本指令逐步将其优化为44.1 kHz的高保真语音波形。SuperTonic使用的是一个统一的GGUF文件格式,而非多个独立的模型组件。它并不依赖动态声音克隆技术,而是直接利用经过高度优化的预训练语音模板(例如voice: "F1"voice: "M1")来生成语音。因此,在不需要动态克隆功能的情况下,这种系统能够非常高效地生成清晰、高质量的语音。

在本教程中,我们将使用SuperTonic。它开箱即用就能产生出色的效果,而且无需处理繁琐的多文件加载流程。

推理流程

为了更好地理解我们在代码中是如何与这些引擎进行交互的,可以把本地文本转语音功能想象成在手机内存中运行着一个虚拟录音室:

  1. 加载模型:我们将压缩后的GGUF文件直接加载到设备的RAM或GPU VRAM中。

  2. 输入文本指令:我们将纯文本数据传递给已加载的引擎。

  3. 生成音频信号:引擎会读取文本内容,并通过数学计算生成相应的声音波形。需要注意的是,AI并不会直接生成完整的音频文件,而是输出原始的数字声波数据(即PCM样本)。

  4. 封装音频文件:由于原始的数字数据无法被标准媒体播放器直接播放,因此我们需要将其包装成标准的WAV格式文件。

  5. 释放系统资源:由于语音合成过程会占用大量内存,并且会保持持续运行的状态,因此系统会在使用完毕后清除相关模型数据,以释放内存资源。

环境配置与依赖项管理

在开始编写代码之前,如果你的项目使用了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.write UInt32LE(16, 16); // Subchunk1Size
  buffer.write(UInt16LE(1, 20); // AudioFormat (PCM)
  buffer.writeUInt16LE(channels, 22);
  buffer.writeUInt32LE(sampleRate, 24);
  buffer.write UInt32LE(byteRate, 28);
  buffer.writeUInt16LE(blockAlign, 32);
  buffer.write(UInt16LE(16, 34); // BitsPerSample
  buffer.write("data", 36);
  buffer.writeUInt32LE(dataLength, 40);

  return buffer;
}

/**
 * 将QVAC输出的原始Int16Array样本转换为二进制缓冲区
 */
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 {
  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({ 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_ENSUPERTONIC_Q8_0,
modelConfig: {
ttsEngine: "supertonic",
language: "en",
voice: "F1",
ttsSpeed: 1.05,
ttsNumInferenceSteps: 5,
},
});

setIsModelLoaded(true);
setIsDownloading(false);
}
} catch (err: unknown) {
console.warn("在组件挂载时,尝试自动加载已缓存的模型失败:", 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_ENSUPERTONIC_Q8_0,
modelConfig: {
ttsEngine: "supertonic",
language: "en",
voice: "F1",
ttsSpeed: 1.05,
ttsNumInferenceSteps: 5,
},
});

setIsModelLoaded(true);
setIsDownloading(false);
} catch (err: unknown) {
console.error("在下载或加载模型时出现错误:", 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 {
-status({ phase: "synthesizing" });

// 1. 卸载并重新加载模型,以重置其状态并清除键值缓存。
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. 将文本转换为原始PCM样本。
const result = textToSpeech({
modelId: globalModelId,
text: text.trim(),
inputType: "text",
stream: false,
});

const audioBuffer = await result.buffer;

// 3. 使用本地工具包将样本打包为WAV文件并保存。
const samplesInt16 = new Int16Array(audioBuffer);
const wavUri = await saveAudioToDevice(
samplesInt16,
SUPERTONIC_SAMPLE_RATE,
);

// 4. 显示音频播放器。
status({ phase: "done", audioUri:wavUri });
} catch (err: unknown) {
console.error("TTS过程中出现错误:", err);
const msg = err instanceof Error ? err.message : String(err);
status({ phase: "error", message: msg });
}
};

const buttonLabel =
status.phase === "synthesizing" ? "合成中…" : "将文本转换为语音";

if (!isModelLoaded) {
return (

);
}

return (




将文本转换为语音
请输入或粘贴文本内容,以便将其转换为语音。



{status.phase === "error" && (
出现错误:{status.message}
)}
{status(phase === "done" && }





);
}

代码库构成分析

让我们来了解一下这个本地文本转语音实现是如何管理原生模型生命周期以及处理原始音频数据的。

1. 管理原生模型的生命周期

加载用于语音合成的神经网络权重是一项计算成本较高的操作。当QVAC运行时初始化一个模型时,它必须从本地磁盘读取参数,并将活跃的权重复制到设备的内存中。

为了高效地处理这一过程,我们在组件范围之外声明了这个引用变量:

let globalModelId: string | null = null;

如果globalModelId被存储在组件的状态中,那么当用户离开文本转语音界面时,该状态会被清除,从而导致应用程序不必要的丢弃这个引用。将这个ID存储在全局范围内,就可以确保在不同的界面切换过程中它仍然可以被保留下来。

2. 清空KV缓存:卸载并重新加载模型

在使用GGML引擎进行离线语音生成时,状态管理是其中一个非常重要的环节:

// 1. 卸载并重新加载模型,以重置其状态并清空KV缓存。
if (globalModelId) {
  await unloadModel({ modelId: globalModelId });
}

globalModelId = await loadModel({ ... });

关于声学幻觉现象需要特别提醒:如果你持续使用同一个文本转语音模型实例来合成句子,而不对其进行重置,那么该模型的键值缓存就会逐渐被填满。这样一来,系统会将新输入的句子视为之前句子的延续,从而导致严重的机械音效、回声或重复的声音出现。

通过使用unloadModel显式地销毁模型,然后立即使用loadModel重新加载一个全新的模型实例,我们可以确保模型处于一个初始、空的状态。由于模型已经下载到设备内存中,并且已经被映射到了内存地址上,因此直接从本地存储空间重新加载模型的速度非常快,在现代移动设备上通常只需要几秒钟就能完成这一过程,这样就能保证用户获得流畅的使用体验,同时也能确保音频质量不受任何影响。

3. 解析WAV文件头结构

操作系统和内置的移动媒体解码器无法直接解析原始的PCM音频数据。原始的PCM缓冲区实际上只是一串表示音频波幅的数字坐标而已。

为了解决这个问题,我们会在PCM缓冲区的前面添加一个标准的44字节长的RIFF/WAVE文件头。

这个文件头就像一张“护照”,它包含了以下信息:

  • 音频格式(1:表示未压缩的线性PCM音频格式。

  • 声道数量(1:单声道音频。

  • 采样率(44100:适合进行SuperTonic播放的时钟频率。

  • 每样本位数(16:每个样本占用16位二进制数据,即2个字节。

此外,文件的写入过程会通过Base64编码来进行处理,这样就可以安全地穿越React Native的JavaScript到原生代码的转换层,而不会丢失任何二进制数据:

const base64Data = finalWavBuffer.toString("base64");
await FileSystem.writeAsStringAsync(fileUri, base64Data, {
  encoding: FileSystem EncodingType.Base64,
});

4. 视觉波形播放器

我们并没有使用那种会在后台立即开始运行的简单原生音频播放器,而是将本地WAV文件的路径传递给一个由@simform_solutions/react-native-audio-waveform支持的定制组件。

这个组件会分析我们新生成的WAV文件,并生成一种外观简洁、设计灵感来源于WhatsApp的交互式视觉波形图,让用户能够完全控制播放过程、动态调整播放速度(1x1.5x2x),以及进行快进/倒带操作。这一设计极大地提升了用户体验,使得最终呈现的效果显得更加高端和精致。

结论

将文本转语音的功能从云端转移到设备本地硬件上,为移动应用开发者提供了一种实用的技术方案。在本地运行模型推理可以消除对远程互联网连接的依赖,避免反复产生API使用费用,同时也能确保用户的文本输入数据不会离开该设备。

对于那些需要交互性、教育性或对话功能的应用来说,集成本地语音合成技术会带来巨大的好处。例如,在语音引导系统中,设备本地的TTS功能可以让应用程序在私密环境或离线状态下正常运行。随着边缘处理器获得了专门的硬件加速核心,而开源模型的内存占用量也因为量化技术的应用而有所减少,因此以本地处理为主的设计方案对于那些重视隐私保护、离线可用性以及成本可控性的开发者来说,无疑是一个非常有吸引力的选择。

资源与扩展阅读

如果你想更深入地了解本地文本转语音技术,查看源代码,或者探索如何为你的移动应用配置更高级的功能,可以参考以下资源:

Comments are closed.