Sherpa-ONNX 是基于 ONNX Runtime 的端侧离线语音处理引擎,支持在 iOS 平台运行离线语音合成(TTS)。其核心计算在本地设备完成,无需依赖云端接口,支持 VITS、Piper、Kokoro 与 Matcha 等多种声学模型。
在英语朗读与听力类应用开发中,语音合成的自然度与连读表现直接影响听感体验。iOS 系统内置的 AVSpeechSynthesizer 默认语音机械感较明显,即使是需要用户手动下载的增强型神经网络语音,效果仍然不理想。
在选型阶段,针对主流的 TTS 技术方案进行了横向调研:
| 方案 | 运行模式 | 语音自然度 | 延迟与性能 | 稳定性与维护成本 | App 包体积增量 |
|---|---|---|---|---|---|
| AVSpeechSynthesizer (系统默认) | 本地离线 | 较低(机械感明显) | 极低(系统原生) | 稳定(系统级 API) | 0 MB |
| Edge-TTS (非官方接口) | 云端流式 | 较高(接近播音效果) | 200~500ms(首包) | 存在未授权接口变更与封禁风险 | < 0.5 MB |
| 云厂商官方 API (Azure / Google) | 云端 API | 较高 | 依网络环境而定 | 稳定,但需网络且产生持续字符计费 | < 0.5 MB |
| Coqui TTS / XTTS | 本地离线 | 较高 | 端侧推理耗时过长 | 依赖 Python/PyTorch 生态,移植成本高 | > 500 MB |
| Sherpa-ONNX (VITS / Kokoro) | 本地离线 | 良好至较高 | VITS: ~120ms; Kokoro: ~2.5s | 稳定无网络依赖,官方支持 iOS | 80~150 MB |
综合合规性、成本与离线可用性,采用“以 Sherpa-ONNX 作为本地主选引擎,AVSpeechSynthesizer 作为系统级降级兜底”的架构方案。
ONNX(Open Neural Network Exchange)是一种针对机器学习算法设计的开放模型格式标准,通过定义统一的操作符集合与通用文件格式,使模型能够在不同的深度学习训练框架(如 PyTorch、TensorFlow)与不同推理引擎之间转换与分发。
在之前的实践中,我们曾基于 Transformers.js 在 Electron / Node.js 环境中构建本地翻译流水线,其底层正是依赖 onnxruntime-node 在宿主环境执行计算图推理。
ONNX Runtime 提供了跨硬件、跨操作系统的执行环境(Execution Providers),既能在服务端和桌面端调用 CPU/GPU,也能在 iOS 与 macOS 上通过 CPU 多线程或 CoreML(调用 Apple Neural Engine)运行计算图。这使得以 ONNX 为基础的端侧模型方案具备跨平台的通用可行性:同一套模型权重文件与算子图,可以运行在 WebAssembly、Node.js、Android 以及 iOS 等不同形态的原生运行时中。
Sherpa-ONNX 是新一代 Kaldi(k2)开源组织开发的嵌入式端侧语音计算套件,涵盖语音识别(ASR)、语音合成(TTS)、说话人识别(Speaker ID)与语音活动检测(VAD)等能力。
在 Apple Silicon (arm64) 架构下,针对 Sherpa-ONNX 支持的两类典型模型进行了基准测试:
| 语料场景 (英语) | 音频时长 | Kokoro-INT8 推理耗时 | Kokoro RTF | VITS-Piper 推理耗时 | VITS RTF |
|---|---|---|---|---|---|
| 短引言 (8 词) | ~3.6s | 2,200 ~ 2,400 ms | 0.56 ~ 0.61 | 120 ms | 0.034 |
| 日常口语 (11 词) | ~3.1s | 1,800 ~ 2,100 ms | 0.59 ~ 0.68 | 112 ms | 0.036 |
| 叙事文学 (13 词) | ~4.5s | 2,850 ~ 2,970 ms | 0.60 ~ 0.64 | 146 ms | 0.034 |
| 学术长句 (22 词) | ~9.0s | 4,960 ~ 5,060 ms | 0.53 ~ 0.54 | 217 ms | 0.025 |
为了解耦合成引擎与播放调度,整体设计采用 Provider 协议抽象,并结合 AVAudioEngine 实现双缓冲预合成调度:
AVAudioPCMBuffer,使 SherpaKokoroProvider、SherpaVITSProvider 与 AVSpeechProvider 可以按需切换。AVAudioPCMBuffer 排入播放队列,保持音频流持续输出,防止后台播放时被系统挂起。在 iOS 项目中使用 Sherpa-ONNX,需引入编译生成的 XCFramework(包括 sherpa-onnx.xcframework 与 onnxruntime.xcframework)。
通过官方脚本构建 iOS 依赖库:
git clone https://github.com/k2-fsa/sherpa-onnx
cd sherpa-onnx
./build-ios.sh
构建完成后,在 build-ios 目录下会生成对应的 XCFramework 文件。
工程配置步骤:
sherpa-onnx.xcframework 与 onnxruntime.xcframework 拖入 Xcode 项目。Sherpa-ONNX 的 TTS 模块依赖模型文件与分词资源。以中文 VITS 模型或多语言模型为例,所需资源通常包含:
资源配置方式:
Documents 或 Application Support),以减小 App 安装包初始体积。定义统一的 TTSProvider 协议,并在具体实现中完成引擎配置与异步推理,输出标准的 AVAudioPCMBuffer。
import AVFoundation
protocol TTSProvider: AnyObject {
func synthesize(text: String, speed: Float) async throws -> AVAudioPCMBuffer
}
import Foundation
import AVFoundation
final class SherpaVITSProvider: TTSProvider {
private var tts: SherpaOnnxOfflineTts?
init() {
self.initializeEngine()
}
private func initializeEngine() {
guard let modelPath = Bundle.main.path(forResource: "model", ofType: "onnx"),
let tokensPath = Bundle.main.path(forResource: "tokens", ofType: "txt"),
let lexiconPath = Bundle.main.path(forResource: "lexicon", ofType: "txt") else {
print("模型或词表资源文件不存在")
return
}
var vitsConfig = SherpaOnnxOfflineTtsVitsModelConfig(
model: modelPath,
lexicon: lexiconPath,
tokens: tokensPath,
dataDir: "",
noiseScale: 0.667,
noiseScaleW: 0.8,
lengthScale: 1.0
)
var modelConfig = SherpaOnnxOfflineTtsModelConfig(
vits: vitsConfig,
numThreads: 2,
debug: false,
provider: "cpu"
)
var config = SherpaOnnxOfflineTtsConfig(
model: modelConfig,
ruleFsts: "",
maxNumSentences: 2
)
self.tts = SherpaOnnxOfflineTts(config: &config)
}
func synthesize(text: String, speed: Float = 1.0) async throws -> AVAudioPCMBuffer {
return try await withCheckedThrowingContinuation { continuation in
DispatchQueue.global(qos: .userInitiated).async { [weak self] in
guard let tts = self?.tts else {
continuation.resume(throwing: NSError(domain: "TTSProvider", code: -1, userInfo: [NSLocalizedDescriptionKey: "引擎未初始化"]))
return
}
let audio = tts.generate(text: text, sid: 0, speed: speed)
guard !audio.samples.isEmpty else {
continuation.resume(throwing: NSError(domain: "TTSProvider", code: -2, userInfo: [NSLocalizedDescriptionKey: "合成音频数据为空"]))
return
}
let audioFormat = AVAudioFormat(
commonFormat: .pcmFormatFloat32,
sampleRate: Double(audio.sampleRate),
channels: 1,
interleaved: false
)!
guard let buffer = AVAudioPCMBuffer(
pcmFormat: audioFormat,
frameCapacity: AVAudioFrameCount(audio.samples.count)
) else {
continuation.resume(throwing: NSError(domain: "TTSProvider", code: -3, userInfo: [NSLocalizedDescriptionKey: "创建 PCM Buffer 失败"]))
return
}
buffer.frameLength = AVAudioFrameCount(audio.samples.count)
if let channelData = buffer.floatChannelData {
channelData[0].initialize(from: audio.samples, count: audio.samples.count)
}
continuation.resume(returning: buffer)
}
}
}
}
使用 AVAudioEngine 配合 AVAudioPlayerNode 实现连续句子的排队播放、静音 Buffer 插入与后台播放维持:
import AVFoundation
final class AudioPipelinePlayer {
private let audioEngine = AVAudioEngine()
private let playerNode = AVAudioPlayerNode()
init() {
audioEngine.attach(playerNode)
audioEngine.connect(playerNode, to: audioEngine.mainMixerNode, format: nil)
}
func prepareEngine() throws {
if !audioEngine.isRunning {
try audioEngine.start()
}
}
/// 调度播放 PCM Buffer,并在当前句开始播放时触发下一句预合成
func scheduleSentence(
buffer: AVAudioPCMBuffer,
onStart: @escaping () -> Void,
onComplete: @escaping () -> Void
) {
playerNode.scheduleBuffer(buffer, at: nil, options: []) {
DispatchQueue.main.async {
onComplete()
}
}
if !playerNode.isPlaying {
playerNode.play()
}
onStart()
}
/// 插入静音 Buffer 以填充句间停顿,并保持持续音频流
func scheduleSilence(duration: Double, sampleRate: Double = 22050) {
let frameCount = AVAudioFrameCount(duration * sampleRate)
guard frameCount > 0,
let format = AVAudioFormat(
commonFormat: .pcmFormatFloat32,
sampleRate: sampleRate,
channels: 1,
interleaved: false
),
let silenceBuffer = AVAudioPCMBuffer(pcmFormat: format, frameCapacity: frameCount)
else { return }
silenceBuffer.frameLength = frameCount
if let channelData = silenceBuffer.floatChannelData {
channelData[0].initialize(repeating: 0, count: Int(frameCount))
}
playerNode.scheduleBuffer(silenceBuffer, at: nil, options: [], completionHandler: nil)
}
func stop() {
playerNode.stop()
audioEngine.stop()
}
}
numThreads 建议设置为 2 到 4。过高的线程数会增加多核调度开销并带来设备发热,设置为 2 时性能与功耗较为平衡。AVSpeechSynthesizer 合成当前句;连续多次失败可触发会话级全局降级。AVAudioSession.sharedInstance().setCategory(.playback, mode: .default),确保静音模式下正常发声。ruleFsts 正则化规则文件可规避发音错误。