黑白梦黑白梦

  • 文章
  • 专栏
  • 文章
  • 专栏
全部文章

Sherpa-ONNX 在 iOS 实现语音合成

发布于 2026-08-23约 16 分钟

Sherpa-ONNX 是基于 ONNX Runtime 的端侧离线语音处理引擎,支持在 iOS 平台运行离线语音合成(TTS)。其核心计算在本地设备完成,无需依赖云端接口,支持 VITS、Piper、Kokoro 与 Matcha 等多种声学模型。

业务背景与 TTS 方案横向对比

在英语朗读与听力类应用开发中,语音合成的自然度与连读表现直接影响听感体验。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 生态与跨平台端侧推理机制

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 架构解析与模型实测

核心架构特性

Sherpa-ONNX 是新一代 Kaldi(k2)开源组织开发的嵌入式端侧语音计算套件,涵盖语音识别(ASR)、语音合成(TTS)、说话人识别(Speaker ID)与语音活动检测(VAD)等能力。

  • C++ 核心与轻量依赖:底层基于 C++ 开发,剥离了对 Python 解释器及 PyTorch 运行时库的依赖,仅需链接 ONNX Runtime 动态库即可在原生端运行。
  • 声学模型统一抽象:针对 TTS 任务,统一封装了 VITS、Piper、Matcha-TTS、Kokoro 等声学模型的计算图与音素解析逻辑,屏蔽了底层算子实现的差异。
  • 多语言与跨平台接口:提供 C、C++、Swift、Kotlin、Python、Go、Rust 及 WebAssembly 的语言绑定,支持在各端复用。

离线模型实机基准测试

在 Apple Silicon (arm64) 架构下,针对 Sherpa-ONNX 支持的两类典型模型进行了基准测试:

延迟与实时率 (RTF)

语料场景 (英语) 音频时长 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
  • VITS-Piper Medium:单句推理耗时 110~220ms,RTF 在 0.03 左右,具备较低的实时响应延迟,发音平稳清晰。
  • Kokoro-INT8:单句推理耗时在 2.0s ~ 5.0s 之间,音质与语调自然度较高,但首句合成耗时较长,适合配合预合成流水线使用。

模型体积与资源构成

  • VITS-Piper Medium:模型文件约 63.1 MB,辅以 4.8 MB 音素数据,动态库增量约 15 MB,总净增体积约 82 MB。
  • Kokoro-INT8:量化模型权重约 134.2 MB,音色库与数据约 9.7 MB,总净增体积约 150 MB。

客户端架构设计:Provider 协议与双缓冲机制

为了解耦合成引擎与播放调度,整体设计采用 Provider 协议抽象,并结合 AVAudioEngine 实现双缓冲预合成调度:

  1. TTSProvider 协议抽象:统一输出 AVAudioPCMBuffer,使 SherpaKokoroProvider、SherpaVITSProvider 与 AVSpeechProvider 可以按需切换。
  2. 双缓冲预合成流水线:在播放当前句音频(以及句间留白停顿)的同时,后台异步预合成下一句音频,平摊掩盖 Kokoro 模型 2~3 秒的推理延迟。
  3. 静音 Buffer 填充句间留白:将跟读与停顿时间转化为静音 AVAudioPCMBuffer 排入播放队列,保持音频流持续输出,防止后台播放时被系统挂起。

编译构建与 Xcode 环境配置

在 iOS 项目中使用 Sherpa-ONNX,需引入编译生成的 XCFramework(包括 sherpa-onnx.xcframework 与 onnxruntime.xcframework)。

  • iOS 编译指南:https://k2-fsa.github.io/sherpa/onnx/ios/index.html
  • 官方仓库示例:https://github.com/k2-fsa/sherpa-onnx/tree/master/ios-swiftui

通过官方脚本构建 iOS 依赖库:

git clone https://github.com/k2-fsa/sherpa-onnx
cd sherpa-onnx
./build-ios.sh

构建完成后,在 build-ios 目录下会生成对应的 XCFramework 文件。

工程配置步骤:

  1. 将 sherpa-onnx.xcframework 与 onnxruntime.xcframework 拖入 Xcode 项目。
  2. 在 Target 的 General -> Frameworks, Libraries, and Embedded Content 中,将上述 Framework 设置为 Embed & Sign。
  3. 引入 C/C++ 桥接头文件(Bridging Header)或直接使用项目提供的 Swift 封装代码。

模型与词典资源组织

Sherpa-ONNX 的 TTS 模块依赖模型文件与分词资源。以中文 VITS 模型或多语言模型为例,所需资源通常包含:

  • model.onnx:ONNX 格式的声学模型文件。
  • tokens.txt:字符/音素字典。
  • lexicon.txt:发音词典(拼音/音素映射)。
  • rule.fst / date.fst / number.fst(可选):文本正则化规则文件,用于数字、日期等文本的预处理转换。
  • espeak-ng-data(可选):针对部分多语言 Piper/Kokoro 模型所需的音素转换数据目录。

资源配置方式:

  • 内置打包:将上述文件放入 Xcode 项目,并添加到 Build Phases > Copy Bundle Resources 中。
  • 动态下发:在应用启动后通过网络下载至沙盒目录(Documents 或 Application Support),以减小 App 安装包初始体积。

核心实现:TTSProvider 封装与异步合成

定义统一的 TTSProvider 协议,并在具体实现中完成引擎配置与异步推理,输出标准的 AVAudioPCMBuffer。

Provider 协议定义

import AVFoundation

protocol TTSProvider: AnyObject {
    func synthesize(text: String, speed: Float) async throws -> AVAudioPCMBuffer
}

基于 Sherpa-ONNX 的 Provider 实现

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 的流水线管理

使用 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 正则化规则文件可规避发音错误。
目录
业务背景与 TTS 方案横向对比ONNX 生态与跨平台端侧推理机制Sherpa-ONNX 架构解析与模型实测核心架构特性离线模型实机基准测试延迟与实时率 (RTF)模型体积与资源构成客户端架构设计:Provider 协议与双缓冲机制编译构建与 Xcode 环境配置模型与词典资源组织核心实现:TTSProvider 封装与异步合成Provider 协议定义基于 Sherpa-ONNX 的 Provider 实现播放调度:基于 AVAudioEngine 的流水线管理性能调优与容错降级实践

本文收录于专栏

AI 应用开发笔记

沉淀大模型智能体等 AI 应用的开发与落地笔记

0 篇文章更新于 2026-08-23
上一篇Mermaid 渲染方案设计与实现下一篇React Router v7 SSR 使用笔记

©2015-2026 黑白梦 粤ICP备15018165号

联系: heibaimeng@foxmail.com