进行鸿蒙开发(基于openharmony)常常会遇到没有集成 CoreSpeechKit的情况,无法实现语音功能。
📋 目录
- [当前 TTS 实现分析](#当前 TTS 实现分析)
- 方案对比与选型
- [Sherpa-onnx 技术架构](#Sherpa-onnx 技术架构)
- 详细实现步骤
- [4.1 Native C++ 集成](#4.1 Native C++ 集成)
- [4.2 ArkTS 封装层](#4.2 ArkTS 封装层)
- [4.3 统一 API 接口](#4.3 统一 API 接口)
- 模型文件准备
- 配置与构建
- 测试验证
- 故障排查
- 性能优化建议
- 集成清单
1. 当前 TTS 实现分析
1.1 主项目 (pda_arkts) - 三种方案现状
| 方案 | 技术栈 | 优点 | 缺点 | 状态 |
|---|---|---|---|---|
| CoreSpeechKit | @kit.CoreSpeechKit |
• 系统原生 • 低功耗 • 完整 API | • API 不存在 • SDK 不支持 | ❌ 无法使用 |
| 降级方案 | 振动 + Toast | • 无外部依赖 • 稳定可靠 • 轻量级 | • 无真实语音 • 用户体验差 | ✅ 当前使用 |
| 网络 TTS (demo) | HTTP + AVPlayer | • 实现简单 • 无需本地模型 | • 依赖网络 • 高延迟 • API 不稳定 | ⚠️ 仅演示 |
1.2 demo - 网络 TTS 实现细节
核心代码分析 (demo/entry/src/main/ets/pages/Index.ets:12-69):
typescript
async function downloadAndPlayTTS(context: Context, url: string) {
// 步骤 1: 下载音频(使用百度公开测试 API)
let httpRequest = http.createHttp();
let response = await httpRequest.request(url, {
method: http.RequestMethod.GET,
header: {
'User-Agent': 'Mozilla/5.0...' // 🟢 反爬虫伪装
},
expectDataType: http.HttpDataType.ARRAY_BUFFER
});
// 步骤 2: 验证文件大小(防止下载到错误页面)
let buffer = response.result as ArrayBuffer;
if (buffer.byteLength < 1024) { // 🟢 小于 1KB 说明是错误文本
console.error(`[TTS] 下载失败!服务器返回的内容是文本`);
return;
}
// 步骤 3: 写入缓存目录
let cacheDir = context.cacheDir;
let filePath = `${cacheDir}/tts_temp.mp3`;
let file = fs.openSync(filePath, fs.OpenMode.READ_WRITE | fs.OpenMode.CREATE);
fs.writeSync(file.fd, buffer);
fs.closeSync(file);
// 步骤 4: AVPlayer 播放
if (avPlayer === null) {
avPlayer = await media.createAVPlayer();
avPlayer.on('error', (err) => console.error(`[TTS] PlayerError`));
avPlayer.on('stateChange', async (state) => {
if (state === 'initialized') avPlayer?.prepare();
if (state === 'prepared') avPlayer?.play();
});
}
let fileSrc = fs.openSync(filePath, fs.OpenMode.READ_ONLY);
avPlayer.url = `fd://${fileSrc.fd}`;
}
问题总结:
- ❌ 网络依赖: 无网络环境无法使用(医院内网场景)
- ❌ 高延迟: 下载 (500ms-2s) + 写入 (100ms) + 播放 (启动 200ms)
- ❌ API 风险: 百度公开接口可能随时失效
- ❌ 并发限制: 频繁调用可能被限流
- ❌ 缓存污染: 每次播放都生成临时文件
1.3 主项目降级方案分析
当前实现 (entry/src/main/ets/utils/TTSUtil.ets:1-276):
typescript
// 核心逻辑:CoreSpeechKit 初始化失败 → 使用振动 + Toast
async initialize(): Promise<void> {
try {
textToSpeech.createEngine(initParamsInfo, (err, engine) => {
if (err) {
console.error(`[TTS] ❌ 初始化失败: ${err.message}`);
// 降级到振动 + Toast
this.tryOnlineMode(resolve, reject);
}
// ...
});
} catch (error) {
// API 不存在时会直接抛异常
console.error(`[TTS] ❌ CoreSpeechKit 不可用`);
}
}
// 降级播报
async speak(text: string): Promise<void> {
// 实际上是:振动 100ms + Toast 显示 2-3 秒
vibrator.startVibration({ type: 'time', duration: 100 });
promptAction.showToast({ message: text });
}
局限性:
- ✅ 优点: 稳定、无依赖、兜底可用
- ❌ 缺点: 无语音播报、用户体验差、信息传达效率低
2. 方案对比与选型
2.1 候选方案对比
| 方案 | 技术栈 | 离线支持 | 音质 | 体积 | 延迟 | 维护性 | 综合评分 |
|---|---|---|---|---|---|---|---|
| Sherpa-onnx | ONNX Runtime | ✅ | ⭐⭐⭐⭐⭐ | ~50MB | <200ms | 🔥 活跃 | ⭐⭐⭐⭐⭐ |
| Sherpa-ncnn | NCNN | ✅ | ⭐⭐⭐⭐ | ~20MB | <300ms | 🔥 活跃 | ⭐⭐⭐⭐ |
| PaddleSpeech | PaddlePaddle | ✅ | ⭐⭐⭐⭐⭐ | ~100MB | <500ms | ⚠️ 需移植 | ⭐⭐⭐ |
| MNN TTS | MNN | ✅ | ⭐⭐⭐ | ~30MB | <250ms | ⚠️ 需移植 | ⭐⭐⭐ |
| WebRTC VAD | WebRTC | ❌ 仅识别 | N/A | ~5MB | <100ms | ⚠️ 不适用 | ❌ |
2.2 为什么选择 Sherpa-onnx?
✅ 优势
-
官方 HarmonyOS 支持
- GitHub 仓库已提供 HarmonyOS 预编译库
- 社区活跃,问题响应快
- 示例代码完善
-
音质卓越
- 基于 VITS 模型,自然度接近真人
- 支持情感、韵律控制
- 中文发音准确
-
性能优异
- ONNX Runtime 高度优化
- 支持 CPU/GPU 推理
- 延迟 <200ms(首次合成)
-
易于集成
- C API 简洁清晰
- N-API 绑定成熟
- 文档齐全
-
模型丰富
- 多种预训练中文模型
- 支持自定义训练
- 模型体积可控
⚠️ 劣势与缓解
| 劣势 | 影响 | 缓解方案 |
|---|---|---|
| 包体积增大 ~50MB | 应用安装包变大 | • 按需下载模型 • 使用压缩模型 |
| 首次加载慢 (1-2s) | 应用启动延迟 | • 异步初始化 • 懒加载模式 |
| 内存占用高 (~100MB) | 低端设备压力 | • 使用后释放 • 共享引擎实例 |
3. Sherpa-onnx 技术架构
3.1 整体架构图
┌─────────────────────────────────────────────────────────┐
│ 应用层 (ArkTS) │
│ ┌────────────────────────────────────────────────┐ │
│ │ 业务代码 (执行单扫码、配药流程等) │ │
│ │ - 扫码成功提示 │ │
│ │ - 操作引导播报 │ │
│ │ - 异常警告提醒 │ │
│ └────────────────────────────────────────────────┘ │
│ ↓ 调用 │
│ ┌────────────────────────────────────────────────┐ │
│ │ TTSUtil.ets (统一封装层) │ │
│ │ - speak(text, queueMode) │ │
│ │ - stop() │ │
│ │ - 自动降级逻辑 │ │
│ └────────────────────────────────────────────────┘ │
│ ↓ 委托 │
│ ┌────────────────────────────────────────────────┐ │
│ │ SherpaTTSWrapper.ets (Sherpa 封装) │ │
│ │ - initialize(context) │ │
│ │ - speak(text, speed) │ │
│ │ - AVPlayer 音频播放 │ │
│ └────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────┘
↓ FFI (N-API)
┌─────────────────────────────────────────────────────────┐
│ Native 层 (C++) │
│ ┌────────────────────────────────────────────────┐ │
│ │ sherpa_tts_napi.cpp (N-API 绑定) │ │
│ │ - InitTTS() → napi_value │ │
│ │ - GenerateAudio() → Float32Array │ │
│ │ - ShutdownTTS() → void │ │
│ └────────────────────────────────────────────────┘ │
│ ↓ 调用 │
│ ┌────────────────────────────────────────────────┐ │
│ │ sherpa_tts_wrapper.cpp (C++ 封装) │ │
│ │ - SherpaTTSWrapper::Initialize() │ │
│ │ - SherpaTTSWrapper::GenerateAudio() │ │
│ │ - 单例模式管理引擎 │ │
│ └────────────────────────────────────────────────┘ │
│ ↓ 使用 │
│ ┌────────────────────────────────────────────────┐ │
│ │ Sherpa-onnx C API (libsherpa-onnx.so) │ │
│ │ - SherpaOnnxCreateOfflineTts() │ │
│ │ - SherpaOnnxOfflineTtsGenerate() │ │
│ │ - ONNX Runtime 推理引擎 │ │
│ └────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────┘
↓ 读取
┌─────────────────────────────────────────────────────────┐
│ 模型文件层 │
│ entry/src/main/resources/rawfile/tts/ │
│ ├── model.onnx (~30MB, VITS 模型) │
│ ├── tokens.txt (~50KB, 词表) │
│ └── lexicon.txt (~500KB, 词典) │
└─────────────────────────────────────────────────────────┘
↓ 输出
┌─────────────────────────────────────────────────────────┐
│ 音频播放层 │
│ - AVPlayer 播放生成的 WAV/PCM 音频 │
│ - 支持暂停、停止、释放 │
└─────────────────────────────────────────────────────────┘
3.2 数据流转图
用户调用 Native 处理 输出
──────── ────────── ──────
speak("扫码成功")
↓
TTSUtil.speak()
↓
SherpaTTSWrapper
.speak()
│
├──→ [N-API]
│ sherpatts.generateAudio("扫码成功", 22050, 1.0)
│ ↓
│ sherpa_tts_napi.cpp::GenerateAudio()
│ ↓
│ SherpaTTSWrapper::GenerateAudio()
│ ↓
│ SherpaOnnxOfflineTtsGenerate(tts, "扫码成功", 0, 1.0)
│ ↓
│ [ONNX Runtime 推理]
│ │
│ ├─→ 读取 model.onnx
│ ├─→ 文本 → 音素序列 (tokens.txt)
│ ├─→ 音素 → 发音 (lexicon.txt)
│ ├─→ VITS 声学模型生成梅尔频谱
│ └─→ 声码器转换为波形
│ ↓
│ 返回 Float32Array (原始 PCM 数据)
│ │
├────────┘
↓
转换为 WAV 格式
↓
写入 /cache/sherpa_tts_temp.wav
↓
AVPlayer.url = "fd://xxx"
↓
播放音频 ──→ 🔊 "扫码成功"
3.3 核心类设计
C++ 层
cpp
class SherpaTTSWrapper {
private:
SherpaOnnxOfflineTts* tts_; // Sherpa-onnx 引擎实例
bool initialized_;
public:
static SherpaTTSWrapper& GetInstance(); // 单例
// 初始化(加载模型)
bool Initialize(const std::string& modelPath,
const std::string& tokensPath,
const std::string& lexiconPath);
// 生成音频(返回原始 PCM 样本)
std::vector<float> GenerateAudio(const std::string& text,
int sampleRate = 22050,
float speed = 1.0);
// 关闭引擎
void Shutdown();
};
ArkTS 层
typescript
class SherpaTTSWrapper {
private avPlayer: media.AVPlayer | null;
private isInitialized: boolean;
// 初始化(传入 Context 获取模型路径)
async initialize(context: Context): Promise<void>;
// 语音播报(调用 Native 生成 → 播放)
async speak(text: string, speed: number = 1.0): Promise<void>;
// 停止播放
async stop(): Promise<void>;
// 关闭引擎
async shutdown(): Promise<void>;
// 工具方法
private convertToWAV(samples: Float32Array, sampleRate: number): ArrayBuffer;
private playAudio(filePath: string): Promise<void>;
}
4. 详细实现步骤
4.1 Native C++ 集成
4.1.1 目录结构
entry/src/main/cpp/
├── CMakeLists.txt # CMake 构建配置
├── sherpa_tts_napi.cpp # N-API 绑定层(ArkTS ↔ C++)
├── sherpa_tts_wrapper.h # C++ 封装头文件
├── sherpa_tts_wrapper.cpp # C++ 封装实现
└── third_party/
├── sherpa-onnx/ # Sherpa-onnx 库
│ ├── include/
│ │ └── sherpa-onnx/
│ │ └── c-api/
│ │ └── c-api.h # C API 头文件
│ └── lib/
│ └── arm64-v8a/
│ └── libsherpa-onnx.so # 预编译库
└── onnxruntime/ # ONNX Runtime (Sherpa依赖)
└── lib/
└── arm64-v8a/
└── libonnxruntime.so
4.1.2 CMakeLists.txt
cmake
cmake_minimum_required(VERSION 3.10)
project(sherpa_tts_native)
set(CMAKE_CXX_STANDARD 17)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
# ============ 设置路径 ============
set(SHERPA_ONNX_DIR ${CMAKE_CURRENT_SOURCE_DIR}/third_party/sherpa-onnx)
set(ONNXRUNTIME_DIR ${CMAKE_CURRENT_SOURCE_DIR}/third_party/onnxruntime)
# ============ 包含头文件 ============
include_directories(
${SHERPA_ONNX_DIR}/include
${CMAKE_CURRENT_SOURCE_DIR}
)
# ============ 添加预编译库 ============
# Sherpa-onnx 主库
add_library(sherpa-onnx SHARED IMPORTED)
set_target_properties(sherpa-onnx PROPERTIES
IMPORTED_LOCATION ${SHERPA_ONNX_DIR}/lib/${OHOS_ARCH}/libsherpa-onnx.so
)
# ONNX Runtime(Sherpa 依赖)
add_library(onnxruntime SHARED IMPORTED)
set_target_properties(onnxruntime PROPERTIES
IMPORTED_LOCATION ${ONNXRUNTIME_DIR}/lib/${OHOS_ARCH}/libonnxruntime.so
)
# ============ 创建 Native 模块 ============
add_library(sherpa_tts_native SHARED
sherpa_tts_napi.cpp
sherpa_tts_wrapper.cpp
)
# ============ 链接库 ============
target_link_libraries(sherpa_tts_native
PUBLIC
sherpa-onnx # Sherpa-onnx 主库
onnxruntime # ONNX Runtime
ace_napi.z.so # HarmonyOS N-API
hilog_ndk.z.so # HarmonyOS 日志
)
# ============ 编译选项 ============
target_compile_options(sherpa_tts_native PRIVATE
-Wall
-Wextra
-O3 # 优化等级
-fvisibility=hidden # 隐藏符号
)
4.1.3 sherpa_tts_wrapper.h
cpp
#ifndef SHERPA_TTS_WRAPPER_H
#define SHERPA_TTS_WRAPPER_H
#include <string>
#include <vector>
#include <memory>
#include <mutex>
#include "sherpa-onnx/c-api/c-api.h"
/**
* Sherpa-onnx TTS 引擎封装类(单例模式)
*/
class SherpaTTSWrapper {
public:
/**
* 获取单例实例
*/
static SherpaTTSWrapper& GetInstance();
/**
* 初始化 TTS 引擎
* @param modelPath ONNX 模型文件路径
* @param tokensPath 词表文件路径
* @param lexiconPath 词典文件路径
* @return 是否初始化成功
*/
bool Initialize(const std::string& modelPath,
const std::string& tokensPath,
const std::string& lexiconPath);
/**
* 生成音频
* @param text 要合成的文本
* @param sampleRate 采样率(默认 22050Hz)
* @param speed 语速(0.5-2.0,默认 1.0)
* @return PCM 音频样本(Float32)
*/
std::vector<float> GenerateAudio(const std::string& text,
int sampleRate = 22050,
float speed = 1.0);
/**
* 关闭引擎(释放资源)
*/
void Shutdown();
/**
* 检查是否已初始化
*/
bool IsInitialized() const { return initialized_; }
private:
// 禁止外部构造
SherpaTTSWrapper() = default;
~SherpaTTSWrapper();
// 禁止拷贝和赋值
SherpaTTSWrapper(const SherpaTTSWrapper&) = delete;
SherpaTTSWrapper& operator=(const SherpaTTSWrapper&) = delete;
bool initialized_ = false;
SherpaOnnxOfflineTts* tts_ = nullptr;
std::mutex mutex_; // 线程安全
};
#endif // SHERPA_TTS_WRAPPER_H
4.1.4 sherpa_tts_wrapper.cpp
cpp
#include "sherpa_tts_wrapper.h"
#include <hilog/log.h>
#include <cstring>
#define LOG_TAG "SherpaTTS"
#define LOG_DOMAIN 0x0000
SherpaTTSWrapper::~SherpaTTSWrapper() {
Shutdown();
}
SherpaTTSWrapper& SherpaTTSWrapper::GetInstance() {
static SherpaTTSWrapper instance;
return instance;
}
bool SherpaTTSWrapper::Initialize(const std::string& modelPath,
const std::string& tokensPath,
const std::string& lexiconPath) {
std::lock_guard<std::mutex> lock(mutex_);
if (initialized_) {
OH_LOG_INFO(LOG_APP, "[SherpaTTS] Already initialized");
return true;
}
OH_LOG_INFO(LOG_APP, "[SherpaTTS] Initializing with:");
OH_LOG_INFO(LOG_APP, " Model: %{public}s", modelPath.c_str());
OH_LOG_INFO(LOG_APP, " Tokens: %{public}s", tokensPath.c_str());
OH_LOG_INFO(LOG_APP, " Lexicon: %{public}s", lexiconPath.c_str());
// 配置 TTS 模型
SherpaOnnxOfflineTtsConfig config;
memset(&config, 0, sizeof(config));
// VITS 模型配置
config.model.vits.model = modelPath.c_str();
config.model.vits.tokens = tokensPath.c_str();
config.model.vits.lexicon = lexiconPath.c_str();
config.model.vits.noise_scale = 0.667f; // 噪声比例
config.model.vits.noise_scale_w = 0.8f; // 噪声宽度
config.model.vits.length_scale = 1.0f; // 时长比例
// 通用模型配置
config.model.num_threads = 2; // 推理线程数
config.model.debug = 0; // 调试模式
config.model.provider = "cpu"; // 推理后端(cpu/gpu)
// 其他配置
config.max_num_sentences = 1; // 最大句子数
config.rule_fsts = ""; // FST 规则(可选)
// 创建 TTS 实例
tts_ = SherpaOnnxCreateOfflineTts(&config);
if (!tts_) {
OH_LOG_ERROR(LOG_APP, "[SherpaTTS] ❌ Failed to create TTS engine");
return false;
}
// 获取采样率
int sampleRate = SherpaOnnxOfflineTtsSampleRate(tts_);
OH_LOG_INFO(LOG_APP, "[SherpaTTS] Sample rate: %{public}d Hz", sampleRate);
initialized_ = true;
OH_LOG_INFO(LOG_APP, "[SherpaTTS] ✅ Initialized successfully");
return true;
}
std::vector<float> SherpaTTSWrapper::GenerateAudio(const std::string& text,
int sampleRate,
float speed) {
std::lock_guard<std::mutex> lock(mutex_);
if (!initialized_ || !tts_) {
OH_LOG_ERROR(LOG_APP, "[SherpaTTS] TTS not initialized");
return {};
}
if (text.empty()) {
OH_LOG_WARN(LOG_APP, "[SherpaTTS] Empty text");
return {};
}
OH_LOG_INFO(LOG_APP, "[SherpaTTS] Generating audio for: \"%{public}s\"", text.c_str());
OH_LOG_INFO(LOG_APP, "[SherpaTTS] Speed: %.2f", speed);
// 生成音频
const SherpaOnnxGeneratedAudio* audio = SherpaOnnxOfflineTtsGenerate(
tts_,
text.c_str(),
0, // speaker_id(多说话人模型使用)
speed // 语速
);
if (!audio || audio->n == 0) {
OH_LOG_ERROR(LOG_APP, "[SherpaTTS] ❌ Failed to generate audio");
return {};
}
OH_LOG_INFO(LOG_APP, "[SherpaTTS] Generated %{public}d samples (%.2f seconds)",
audio->n, (float)audio->n / audio->sample_rate);
// 复制音频数据
std::vector<float> samples(audio->samples, audio->samples + audio->n);
// 释放音频资源
SherpaOnnxDestroyOfflineTtsGeneratedAudio(audio);
return samples;
}
void SherpaTTSWrapper::Shutdown() {
std::lock_guard<std::mutex> lock(mutex_);
if (tts_) {
OH_LOG_INFO(LOG_APP, "[SherpaTTS] Shutting down...");
SherpaOnnxDestroyOfflineTts(tts_);
tts_ = nullptr;
}
initialized_ = false;
OH_LOG_INFO(LOG_APP, "[SherpaTTS] ✅ Shutdown complete");
}
4.1.5 sherpa_tts_napi.cpp
cpp
#include <napi/native_api.h>
#include <hilog/log.h>
#include "sherpa_tts_wrapper.h"
#define LOG_TAG "SherpaTTS_NAPI"
#define LOG_DOMAIN 0x0000
/**
* N-API 函数:初始化 TTS
* 参数:modelPath (string), tokensPath (string), lexiconPath (string)
* 返回:boolean
*/
static napi_value InitTTS(napi_env env, napi_callback_info info) {
size_t argc = 3;
napi_value args[3];
napi_get_cb_info(env, info, &argc, args, nullptr, nullptr);
if (argc < 3) {
OH_LOG_ERROR(LOG_APP, "[SherpaTTS_NAPI] Invalid argument count");
napi_value result;
napi_get_boolean(env, false, &result);
return result;
}
// 获取字符串参数
size_t modelPathLen, tokensPathLen, lexiconPathLen;
napi_get_value_string_utf8(env, args[0], nullptr, 0, &modelPathLen);
napi_get_value_string_utf8(env, args[1], nullptr, 0, &tokensPathLen);
napi_get_value_string_utf8(env, args[2], nullptr, 0, &lexiconPathLen);
char* modelPath = new char[modelPathLen + 1];
char* tokensPath = new char[tokensPathLen + 1];
char* lexiconPath = new char[lexiconPathLen + 1];
napi_get_value_string_utf8(env, args[0], modelPath, modelPathLen + 1, nullptr);
napi_get_value_string_utf8(env, args[1], tokensPath, tokensPathLen + 1, nullptr);
napi_get_value_string_utf8(env, args[2], lexiconPath, lexiconPathLen + 1, nullptr);
OH_LOG_INFO(LOG_APP, "[SherpaTTS_NAPI] InitTTS called");
// 调用 C++ 封装
bool success = SherpaTTSWrapper::GetInstance().Initialize(
modelPath, tokensPath, lexiconPath
);
delete[] modelPath;
delete[] tokensPath;
delete[] lexiconPath;
napi_value result;
napi_get_boolean(env, success, &result);
return result;
}
/**
* N-API 函数:生成音频
* 参数:text (string), sampleRate (number), speed (number)
* 返回:Float32Array | null
*/
static napi_value GenerateAudio(napi_env env, napi_callback_info info) {
size_t argc = 3;
napi_value args[3];
napi_get_cb_info(env, info, &argc, args, nullptr, nullptr);
if (argc < 3) {
OH_LOG_ERROR(LOG_APP, "[SherpaTTS_NAPI] Invalid argument count");
napi_value null_value;
napi_get_null(env, &null_value);
return null_value;
}
// 获取文本参数
size_t textLen;
napi_get_value_string_utf8(env, args[0], nullptr, 0, &textLen);
char* text = new char[textLen + 1];
napi_get_value_string_utf8(env, args[0], text, textLen + 1, nullptr);
// 获取数字参数
int32_t sampleRate;
double speed;
napi_get_value_int32(env, args[1], &sampleRate);
napi_get_value_double(env, args[2], &speed);
OH_LOG_INFO(LOG_APP, "[SherpaTTS_NAPI] GenerateAudio: \"%{public}s\"", text);
// 调用 C++ 生成音频
auto samples = SherpaTTSWrapper::GetInstance().GenerateAudio(
text, sampleRate, static_cast<float>(speed)
);
delete[] text;
if (samples.empty()) {
OH_LOG_ERROR(LOG_APP, "[SherpaTTS_NAPI] Generated audio is empty");
napi_value null_value;
napi_get_null(env, &null_value);
return null_value;
}
// 创建 ArrayBuffer
napi_value arrayBuffer;
void* data;
size_t byteLength = samples.size() * sizeof(float);
napi_create_arraybuffer(env, byteLength, &data, &arrayBuffer);
memcpy(data, samples.data(), byteLength);
// 创建 Float32Array
napi_value result;
napi_create_typedarray(env, napi_float32_array, samples.size(),
arrayBuffer, 0, &result);
OH_LOG_INFO(LOG_APP, "[SherpaTTS_NAPI] Returned %{public}zu samples", samples.size());
return result;
}
/**
* N-API 函数:关闭 TTS
*/
static napi_value ShutdownTTS(napi_env env, napi_callback_info info) {
OH_LOG_INFO(LOG_APP, "[SherpaTTS_NAPI] ShutdownTTS called");
SherpaTTSWrapper::GetInstance().Shutdown();
napi_value undefined;
napi_get_undefined(env, &undefined);
return undefined;
}
/**
* 模块注册
*/
EXTERN_C_START
static napi_value Init(napi_env env, napi_value exports) {
napi_property_descriptor desc[] = {
{ "initTTS", nullptr, InitTTS, nullptr, nullptr, nullptr, napi_default, nullptr },
{ "generateAudio", nullptr, GenerateAudio, nullptr, nullptr, nullptr, napi_default, nullptr },
{ "shutdownTTS", nullptr, ShutdownTTS, nullptr, nullptr, nullptr, napi_default, nullptr }
};
napi_define_properties(env, exports, sizeof(desc) / sizeof(desc[0]), desc);
OH_LOG_INFO(LOG_APP, "[SherpaTTS_NAPI] Module registered");
return exports;
}
EXTERN_C_END
static napi_module nativeModule = {
.nm_version = 1,
.nm_flags = 0,
.nm_filename = nullptr,
.nm_register_func = Init,
.nm_modname = "sherpatts", // 模块名称
.nm_priv = nullptr,
.reserved = { 0 },
};
extern "C" __attribute__((constructor)) void RegisterModule(void) {
napi_module_register(&nativeModule);
OH_LOG_INFO(LOG_APP, "[SherpaTTS_NAPI] Module constructor called");
}
4.2 ArkTS 封装层
4.2.1 SherpaTTSWrapper.ets
文件路径 : entry/src/main/ets/utils/SherpaTTSWrapper.ets
typescript
import sherpatts from 'libsherpa_tts_native.so'; // Native 模块
import { media } from '@kit.MediaKit';
import fs from '@ohos.file.fs';
/**
* Sherpa-onnx TTS 封装(离线语音合成)
*
* 使用示例:
* ```typescript
* const tts = SherpaTTSWrapper.getInstance();
* await tts.initialize(getContext(this));
* await tts.speak('扫码成功');
* ```
*/
export class SherpaTTSWrapper {
private static instance: SherpaTTSWrapper | null = null;
private isInitialized: boolean = false;
private avPlayer: media.AVPlayer | null = null;
private isSpeaking: boolean = false;
private constructor() {
console.info('[SherpaTTS-ArkTS] Instance created');
}
/**
* 获取单例实例
*/
static getInstance(): SherpaTTSWrapper {
if (!SherpaTTSWrapper.instance) {
SherpaTTSWrapper.instance = new SherpaTTSWrapper();
}
return SherpaTTSWrapper.instance;
}
/**
* 初始化 Sherpa TTS 引擎
* @param context 应用上下文(用于获取模型路径)
*/
async initialize(context: Context): Promise<void> {
if (this.isInitialized) {
console.info('[SherpaTTS-ArkTS] Already initialized');
return;
}
try {
console.info('[SherpaTTS-ArkTS] Starting initialization...');
// 模型文件路径(放在 rawfile 目录中)
const resourceDir = context.resourceDir;
const modelPath = `${resourceDir}/rawfile/tts/model.onnx`;
const tokensPath = `${resourceDir}/rawfile/tts/tokens.txt`;
const lexiconPath = `${resourceDir}/rawfile/tts/lexicon.txt`;
console.info('[SherpaTTS-ArkTS] Model paths:');
console.info(` Model: ${modelPath}`);
console.info(` Tokens: ${tokensPath}`);
console.info(` Lexicon: ${lexiconPath}`);
// 调用 Native 初始化
const success: boolean = sherpatts.initTTS(modelPath, tokensPath, lexiconPath);
if (!success) {
throw new Error('Failed to initialize Sherpa TTS engine (Native call failed)');
}
// 初始化音频播放器
console.info('[SherpaTTS-ArkTS] Creating AVPlayer...');
this.avPlayer = await media.createAVPlayer();
this.setupPlayerListeners();
this.isInitialized = true;
console.info('[SherpaTTS-ArkTS] ✅ Initialized successfully');
} catch (error) {
console.error('[SherpaTTS-ArkTS] ❌ Initialization failed:', JSON.stringify(error));
throw error;
}
}
/**
* 语音播报
* @param text 要播报的文本
* @param speed 语速(0.5-2.0,默认 1.0)
*/
async speak(text: string, speed: number = 1.0): Promise<void> {
// 自动初始化
if (!this.isInitialized) {
console.warn('[SherpaTTS-ArkTS] Not initialized, attempting auto-init...');
throw new Error('SherpaTTS not initialized. Call initialize() first.');
}
if (this.isSpeaking) {
console.warn('[SherpaTTS-ArkTS] Already speaking, stopping previous...');
await this.stop();
}
try {
console.info(`[SherpaTTS-ArkTS] 📢 Speaking: "${text}" (speed: ${speed})`);
this.isSpeaking = true;
// 调用 Native 生成音频(返回 Float32Array)
const audioSamples: Float32Array = sherpatts.generateAudio(text, 22050, speed);
if (!audioSamples || audioSamples.length === 0) {
throw new Error('Failed to generate audio (Native returned empty)');
}
console.info(`[SherpaTTS-ArkTS] Generated ${audioSamples.length} samples`);
// 转换为 WAV 格式
const wavBuffer = this.convertToWAV(audioSamples, 22050);
// 写入临时文件
const cacheDir = getContext(this).cacheDir;
const audioPath = `${cacheDir}/sherpa_tts_temp.wav`;
const file = fs.openSync(audioPath,
fs.OpenMode.READ_WRITE | fs.OpenMode.CREATE | fs.OpenMode.TRUNC);
fs.writeSync(file.fd, wavBuffer);
fs.closeSync(file);
console.info(`[SherpaTTS-ArkTS] Audio saved to: ${audioPath}`);
// 播放音频
await this.playAudio(audioPath);
console.info('[SherpaTTS-ArkTS] ✅ Playback complete');
} catch (error) {
console.error('[SherpaTTS-ArkTS] ❌ Speak failed:', JSON.stringify(error));
throw error;
} finally {
this.isSpeaking = false;
}
}
/**
* 停止播放
*/
async stop(): Promise<void> {
if (!this.avPlayer) {
console.warn('[SherpaTTS-ArkTS] AVPlayer not initialized');
return;
}
try {
console.info('[SherpaTTS-ArkTS] Stopping playback...');
await this.avPlayer.stop();
await this.avPlayer.reset();
this.isSpeaking = false;
console.info('[SherpaTTS-ArkTS] ✅ Stopped');
} catch (error) {
console.error('[SherpaTTS-ArkTS] ❌ Stop failed:', JSON.stringify(error));
}
}
/**
* 检查是否正在播放
*/
isBusy(): boolean {
return this.isSpeaking;
}
/**
* 关闭引擎(释放资源)
*/
async shutdown(): Promise<void> {
try {
console.info('[SherpaTTS-ArkTS] Shutting down...');
// 停止播放
if (this.isSpeaking) {
await this.stop();
}
// 释放播放器
if (this.avPlayer) {
await this.avPlayer.release();
this.avPlayer = null;
}
// 调用 Native 关闭
if (this.isInitialized) {
sherpatts.shutdownTTS();
}
this.isInitialized = false;
console.info('[SherpaTTS-ArkTS] ✅ Shutdown complete');
} catch (error) {
console.error('[SherpaTTS-ArkTS] ❌ Shutdown failed:', JSON.stringify(error));
}
}
/**
* 转换 Float32 PCM 为 WAV 格式
* @param samples Float32Array 音频样本
* @param sampleRate 采样率
* @returns WAV 格式 ArrayBuffer
*/
private convertToWAV(samples: Float32Array, sampleRate: number): ArrayBuffer {
const numChannels = 1; // 单声道
const bitsPerSample = 16; // 16-bit PCM
const byteRate = sampleRate * numChannels * bitsPerSample / 8;
const blockAlign = numChannels * bitsPerSample / 8;
const dataSize = samples.length * 2; // 16-bit = 2 bytes per sample
const buffer = new ArrayBuffer(44 + dataSize);
const view = new DataView(buffer);
// WAV 文件头(44 字节)
this.writeString(view, 0, 'RIFF'); // ChunkID
view.setUint32(4, 36 + dataSize, true); // ChunkSize
this.writeString(view, 8, 'WAVE'); // Format
this.writeString(view, 12, 'fmt '); // Subchunk1ID
view.setUint32(16, 16, true); // Subchunk1Size (16 for PCM)
view.setUint16(20, 1, true); // AudioFormat (1 = PCM)
view.setUint16(22, numChannels, true); // NumChannels
view.setUint32(24, sampleRate, true); // SampleRate
view.setUint32(28, byteRate, true); // ByteRate
view.setUint16(32, blockAlign, true); // BlockAlign
view.setUint16(34, bitsPerSample, true); // BitsPerSample
this.writeString(view, 36, 'data'); // Subchunk2ID
view.setUint32(40, dataSize, true); // Subchunk2Size
// 写入音频数据(Float32 → Int16)
let offset = 44;
for (let i = 0; i < samples.length; i++) {
// 限幅到 [-1.0, 1.0]
const sample = Math.max(-1, Math.min(1, samples[i]));
// 转换为 16-bit signed integer
const int16 = sample < 0 ? sample * 0x8000 : sample * 0x7FFF;
view.setInt16(offset, int16, true);
offset += 2;
}
return buffer;
}
/**
* 写入字符串到 DataView
*/
private writeString(view: DataView, offset: number, string: string): void {
for (let i = 0; i < string.length; i++) {
view.setUint8(offset + i, string.charCodeAt(i));
}
}
/**
* 播放音频文件
* @param filePath 音频文件路径
*/
private async playAudio(filePath: string): Promise<void> {
return new Promise<void>((resolve, reject) => {
if (!this.avPlayer) {
reject(new Error('AVPlayer not initialized'));
return;
}
try {
// 重置播放器
this.avPlayer.reset();
// 打开文件
const file = fs.openSync(filePath, fs.OpenMode.READ_ONLY);
this.avPlayer.url = `fd://${file.fd}`;
// 监听状态变化
this.avPlayer.on('stateChange', async (state: string) => {
console.info(`[SherpaTTS-ArkTS] Player state: ${state}`);
if (state === 'initialized') {
this.avPlayer?.prepare();
} else if (state === 'prepared') {
this.avPlayer?.play();
} else if (state === 'completed') {
console.info('[SherpaTTS-ArkTS] Playback completed');
fs.closeSync(file);
resolve();
}
});
// 监听错误
this.avPlayer.on('error', (err) => {
console.error('[SherpaTTS-ArkTS] Player error:', JSON.stringify(err));
fs.closeSync(file);
reject(err);
});
} catch (error) {
console.error('[SherpaTTS-ArkTS] ❌ playAudio failed:', JSON.stringify(error));
reject(error);
}
});
}
/**
* 设置播放器监听器
*/
private setupPlayerListeners(): void {
if (!this.avPlayer) {
return;
}
this.avPlayer.on('stateChange', (state: string) => {
console.info(`[SherpaTTS-ArkTS] 🎵 Player state: ${state}`);
});
this.avPlayer.on('error', (err) => {
console.error('[SherpaTTS-ArkTS] ❌ Player error:', JSON.stringify(err));
this.isSpeaking = false;
});
}
}
// ============ 快捷方法 ============
/**
* 快捷方法:语音播报
* @param text 要播报的文本
* @param speed 语速(0.5-2.0)
*/
export async function speakSherpa(text: string, speed: number = 1.0): Promise<void> {
await SherpaTTSWrapper.getInstance().speak(text, speed);
}
/**
* 快捷方法:初始化 Sherpa TTS
* @param context 应用上下文
*/
export async function initSherpaTTS(context: Context): Promise<void> {
await SherpaTTSWrapper.getInstance().initialize(context);
}
/**
* 快捷方法:停止播放
*/
export async function stopSherpaTTS(): Promise<void> {
await SherpaTTSWrapper.getInstance().stop();
}
/**
* 快捷方法:检查是否正在播放
*/
export function isSherpaBusy(): boolean {
return SherpaTTSWrapper.getInstance().isBusy();
}
4.3 统一 API 接口
4.3.1 修改 TTSUtil.ets
文件路径 : entry/src/main/ets/utils/TTSUtil.ets
typescript
import { SherpaTTSWrapper } from './SherpaTTSWrapper';
import { vibrator } from '@kit.SensorServiceKit';
import { promptAction } from '@kit.ArkUI';
/**
* TTS 统一接口(支持自动降级)
*
* 优先级:Sherpa-onnx > 振动+Toast
*/
export class TTSUtil {
private static instance: TTSUtil | null = null;
private backend: 'sherpa' | 'fallback' = 'sherpa';
private isInitialized: boolean = false;
private constructor() {
console.info('[TTS] TTSUtil instance created');
}
static getInstance(): TTSUtil {
if (!TTSUtil.instance) {
TTSUtil.instance = new TTSUtil();
}
return TTSUtil.instance;
}
/**
* 初始化 TTS(自动选择后端)
*/
async initialize(context: Context): Promise<void> {
if (this.isInitialized) {
console.info('[TTS] Already initialized');
return;
}
try {
// 优先尝试 Sherpa-onnx
console.info('[TTS] Trying Sherpa-onnx backend...');
await SherpaTTSWrapper.getInstance().initialize(context);
this.backend = 'sherpa';
console.info('[TTS] ✅ Using Sherpa-onnx backend (offline TTS)');
} catch (error) {
console.warn('[TTS] ⚠️ Sherpa-onnx failed, using fallback (vibration + Toast)');
console.error('[TTS] Error:', JSON.stringify(error));
this.backend = 'fallback';
}
this.isInitialized = true;
}
/**
* 语音播报(统一接口)
* @param text 要播报的文本
* @param queueMode 队列模式(仅 Sherpa 支持)
*/
async speak(text: string, queueMode: 'flush' | 'add' = 'flush'): Promise<void> {
if (!text || text.trim().length === 0) {
console.warn('[TTS] Empty text, skipping');
return;
}
try {
if (this.backend === 'sherpa') {
// 使用 Sherpa-onnx 真实语音
await SherpaTTSWrapper.getInstance().speak(text, 1.0);
} else {
// 降级方案:振动 + Toast
await this.fallbackSpeak(text);
}
} catch (error) {
console.error('[TTS] ❌ Speak failed, trying fallback');
await this.fallbackSpeak(text);
}
}
/**
* 降级方案:振动 + Toast
*/
private async fallbackSpeak(text: string): Promise<void> {
console.info(`[TTS-Fallback] 📢 ${text}`);
try {
// 振动反馈(100ms)
vibrator.startVibration({
type: 'time',
duration: 100
});
// Toast 显示(时长根据文本长度)
const duration = Math.min(2000 + text.length * 50, 3500);
promptAction.showToast({
message: text,
duration: duration
});
} catch (error) {
console.error('[TTS-Fallback] ❌ Fallback failed:', JSON.stringify(error));
}
}
/**
* 停止播报
*/
async stop(): Promise<void> {
if (this.backend === 'sherpa') {
await SherpaTTSWrapper.getInstance().stop();
}
// 降级方案无需停止
}
/**
* 检查是否正在播放
*/
isBusy(): boolean {
if (this.backend === 'sherpa') {
return SherpaTTSWrapper.getInstance().isBusy();
}
return false;
}
/**
* 获取当前后端
*/
getBackend(): 'sherpa' | 'fallback' {
return this.backend;
}
/**
* 关闭引擎
*/
async shutdown(): Promise<void> {
if (this.backend === 'sherpa') {
await SherpaTTSWrapper.getInstance().shutdown();
}
this.isInitialized = false;
console.info('[TTS] ✅ Shutdown complete');
}
}
// ============ 快捷方法(保持原有 API 兼容)============
/**
* 语音播报
*/
export async function speak(text: string, queueMode: 'flush' | 'add' = 'flush'): Promise<void> {
await TTSUtil.getInstance().speak(text, queueMode);
}
/**
* 初始化 TTS
*/
export async function initTTS(context: Context): Promise<void> {
await TTSUtil.getInstance().initialize(context);
}
/**
* 停止播报
*/
export async function stopTTS(): Promise<void> {
await TTSUtil.getInstance().stop();
}
/**
* 检查是否正在播放
*/
export function isTTSBusy(): boolean {
return TTSUtil.getInstance().isBusy();
}
/**
* 获取当前 TTS 后端
*/
export function getTTSBackend(): 'sherpa' | 'fallback' {
return TTSUtil.getInstance().getBackend();
}
5. 模型文件准备
5.1 推荐的中文 TTS 模型
| 模型 | 大小 | 音质 | 速度 | 推荐场景 |
|---|---|---|---|---|
| vits-zh-hf-fanchen-C | ~30MB | ⭐⭐⭐⭐⭐ | 快 | 推荐 医疗场景,发音清晰 |
| vits-zh-aishell3 | ~25MB | ⭐⭐⭐⭐ | 快 | 多说话人支持 |
| vits-piper-zh_CN-huayan | ~20MB | ⭐⭐⭐ | 很快 | 轻量级,适合低端设备 |
5.2 模型下载
方式 1: 从 GitHub Releases 下载
bash
# 下载模型(替换为实际版本)
wget https://github.com/k2-fsa/sherpa-onnx/releases/download/tts-models/vits-zh-hf-fanchen-C.tar.bz2
# 解压
tar -xf vits-zh-hf-fanchen-C.tar.bz2
# 文件列表
# vits-zh-hf-fanchen-C/
# ├── model.onnx (~30MB)
# ├── tokens.txt (~50KB)
# └── lexicon.txt (~500KB)
方式 2: 使用 Git LFS
bash
git clone https://huggingface.co/k2-fsa/sherpa-onnx-zh-tts
5.3 文件放置
bash
# 目标目录
entry/src/main/resources/rawfile/tts/
# 复制文件
cp vits-zh-hf-fanchen-C/model.onnx entry/src/main/resources/rawfile/tts/
cp vits-zh-hf-fanchen-C/tokens.txt entry/src/main/resources/rawfile/tts/
cp vits-zh-hf-fanchen-C/lexicon.txt entry/src/main/resources/rawfile/tts/
最终结构:
entry/src/main/resources/rawfile/
└── tts/
├── model.onnx # VITS 模型(~30MB)
├── tokens.txt # 词表(~50KB)
└── lexicon.txt # 词典(~500KB)
5.4 验证文件完整性
bash
# 检查文件大小
ls -lh entry/src/main/resources/rawfile/tts/
# 预期输出
# -rw-r--r-- 1 user staff 30M model.onnx
# -rw-r--r-- 1 user staff 50K tokens.txt
# -rw-r--r-- 1 user staff 500K lexicon.txt
6. 配置与构建
6.1 build-profile.json5
文件路径 : entry/build-profile.json5
json5
{
"apiType": "stageMode",
"buildOption": {
"strictMode": {
"caseSensitiveCheck": true,
"useNormalizedOHMUrl": true
},
// 🟢 关键配置:启用 Native 编译
"externalNativeOptions": {
"path": "./src/main/cpp/CMakeLists.txt",
"arguments": "",
"cppFlags": "-O3 -DNDEBUG", // 优化编译
// ⚠️ 重要:只编译 ARM64 架构(减小包体积)
"abiFilters": [
"arm64-v8a"
]
}
},
"targets": [
{
"name": "default",
"runtimeOS": "HarmonyOS"
}
]
}
6.2 oh-package.json5
文件路径 : entry/oh-package.json5
json5
{
"modelVersion": "5.0.0",
"description": "PDA护理应用",
"dependencies": {
// Sherpa-onnx 通过 CMake 链接,无需额外依赖
},
"devDependencies": {
"@ohos/hypium": "1.0.19"
}
}
6.3 module.json5 权限配置
文件路径 : entry/src/main/module.json5
json5
{
"module": {
"name": "entry",
"type": "entry",
"requestPermissions": [
{
"name": "ohos.permission.INTERNET",
"reason": "$string:permission_reason_internet"
},
// 🟢 TTS 可能需要的权限(根据实际情况添加)
{
"name": "ohos.permission.READ_MEDIA",
"reason": "$string:permission_reason_media"
}
]
}
}
6.4 EntryAbility 生命周期集成
文件路径 : entry/src/main/ets/entryability/EntryAbility.ets
typescript
import { UIAbility, Want, AbilityConstant } from '@kit.AbilityKit';
import { window } from '@kit.ArkUI';
import { hilog } from '@kit.PerformanceAnalysisKit';
import { TTSUtil } from '../utils/TTSUtil';
export default class EntryAbility extends UIAbility {
onCreate(want: Want, launchParam: AbilityConstant.LaunchParam): void {
hilog.info(0x0000, 'EntryAbility', 'onCreate');
// 设置全局 context
globalThis.abilityContext = this.context;
}
onWindowStageCreate(windowStage: window.WindowStage): void {
hilog.info(0x0000, 'EntryAbility', 'onWindowStageCreate');
// 🟢 异步初始化 TTS(不阻塞启动)
TTSUtil.getInstance().initialize(this.context)
.then(() => {
const backend = TTSUtil.getInstance().getBackend();
hilog.info(0x0000, 'EntryAbility', `[TTS] Initialized with backend: ${backend}`);
})
.catch((error: Error) => {
hilog.error(0x0000, 'EntryAbility', `[TTS] Initialization failed: ${error.message}`);
});
// 加载主页面
windowStage.loadContent('pages/Index', (err) => {
if (err.code) {
hilog.error(0x0000, 'EntryAbility', 'Failed to load content');
return;
}
hilog.info(0x0000, 'EntryAbility', 'Succeeded in loading content');
});
}
onDestroy(): void {
hilog.info(0x0000, 'EntryAbility', 'onDestroy');
// 🟢 关闭 TTS 引擎
TTSUtil.getInstance().shutdown()
.then(() => {
hilog.info(0x0000, 'EntryAbility', '[TTS] Shutdown complete');
})
.catch((error: Error) => {
hilog.error(0x0000, 'EntryAbility', `[TTS] Shutdown failed: ${error.message}`);
});
}
}
7. 测试验证
7.1 创建测试页面
文件路径 : entry/src/main/ets/pages/SherpaTTSTestPage.ets
typescript
import { speak, getTTSBackend, stopTTS, isTTSBusy } from '../utils/TTSUtil';
@Entry
@Component
struct SherpaTTSTestPage {
@State currentBackend: string = '未知';
@State isBusy: boolean = false;
@State testText: string = '扫码成功';
aboutToAppear(): void {
this.updateStatus();
}
updateStatus(): void {
this.currentBackend = getTTSBackend();
this.isBusy = isTTSBusy();
}
async testSpeak(text: string): Promise<void> {
try {
console.info(`[TTS测试] 播报: ${text}`);
await speak(text);
this.updateStatus();
console.info('[TTS测试] ✅ 播报成功');
} catch (error) {
console.error('[TTS测试] ❌ 播报失败:', JSON.stringify(error));
}
}
build() {
Column({ space: 20 }) {
// 标题
Text('Sherpa TTS 测试')
.fontSize(28)
.fontWeight(FontWeight.Bold)
// 状态信息
Column({ space: 10 }) {
Text(`当前后端: ${this.currentBackend}`)
.fontSize(18)
.fontColor(this.currentBackend === 'sherpa' ? Color.Green : Color.Orange)
Text(`播放状态: ${this.isBusy ? '播放中' : '空闲'}`)
.fontSize(18)
}
.alignItems(HorizontalAlign.Start)
.width('90%')
.padding(15)
.backgroundColor('#F0F0F0')
.borderRadius(10)
// 快速测试按钮
Column({ space: 15 }) {
Text('快速测试')
.fontSize(20)
.fontWeight(FontWeight.Bold)
Button('播报: 扫码成功')
.width('100%')
.onClick(() => this.testSpeak('扫码成功'))
Button('播报: 请扫描患者码')
.width('100%')
.onClick(() => this.testSpeak('请扫描患者码'))
Button('播报: 操作完成')
.width('100%')
.onClick(() => this.testSpeak('操作完成'))
Button('播报: 体温36.5度,血压正常')
.width('100%')
.onClick(() => this.testSpeak('体温36.5度,血压正常'))
}
.width('90%')
// 自定义文本
Column({ space: 10 }) {
Text('自定义文本')
.fontSize(20)
.fontWeight(FontWeight.Bold)
TextInput({ placeholder: '输入要播报的文本' })
.width('100%')
.onChange((value: string) => {
this.testText = value;
})
Button('播报自定义文本')
.width('100%')
.onClick(() => this.testSpeak(this.testText))
}
.width('90%')
// 控制按钮
Row({ space: 15 }) {
Button('停止播放')
.backgroundColor(Color.Red)
.onClick(async () => {
await stopTTS();
this.updateStatus();
})
Button('刷新状态')
.onClick(() => this.updateStatus())
}
}
.width('100%')
.height('100%')
.padding(20)
}
}
7.2 测试清单
| 测试项 | 测试步骤 | 预期结果 |
|---|---|---|
| 初始化 | 启动应用,查看日志 | [TTS] Initialized with backend: sherpa |
| 短文本 | 点击"扫码成功" | 听到清晰语音播报 |
| 中文本 | 播报"请扫描患者码" | 语音流畅,无卡顿 |
| 长文本 | 播报"体温36.5度,血压正常" | 完整播放,发音准确 |
| 停止功能 | 播放中点击停止 | 立即停止播放 |
| 并发测试 | 连续快速点击多次 | 不崩溃,正常播放 |
| 内存测试 | 连续播报100次 | 内存稳定,无泄漏 |
| 降级测试 | 删除模型文件后重启 | 自动降级到振动+Toast |
7.3 日志验证
成功初始化日志:
[SherpaTTS] Initializing with:
[SherpaTTS] Model: /data/.../rawfile/tts/model.onnx
[SherpaTTS] Tokens: /data/.../rawfile/tts/tokens.txt
[SherpaTTS] Lexicon: /data/.../rawfile/tts/lexicon.txt
[SherpaTTS] Sample rate: 22050 Hz
[SherpaTTS] ✅ Initialized successfully
[SherpaTTS-ArkTS] ✅ Initialized successfully
[TTS] ✅ Using Sherpa-onnx backend (offline TTS)
播报日志:
[SherpaTTS-ArkTS] 📢 Speaking: "扫码成功" (speed: 1.0)
[SherpaTTS] Generating audio for: "扫码成功"
[SherpaTTS] Generated 44100 samples (2.00 seconds)
[SherpaTTS-ArkTS] Generated 44100 samples
[SherpaTTS-ArkTS] Audio saved to: /data/.../cache/sherpa_tts_temp.wav
[SherpaTTS-ArkTS] 🎵 Player state: initialized
[SherpaTTS-ArkTS] 🎵 Player state: prepared
[SherpaTTS-ArkTS] 🎵 Player state: playing
[SherpaTTS-ArkTS] 🎵 Player state: completed
[SherpaTTS-ArkTS] ✅ Playback complete
8. 故障排查
8.1 常见问题
问题 1: 初始化失败
日志:
[SherpaTTS] ❌ Failed to create TTS engine
可能原因:
- 模型文件路径错误
- 模型文件损坏
- so 库未正确链接
解决方案:
typescript
// 检查文件是否存在
const modelPath = `${context.resourceDir}/rawfile/tts/model.onnx`;
const exists = fs.accessSync(modelPath);
console.log('Model exists:', exists);
// 检查文件大小
const stat = fs.statSync(modelPath);
console.log('Model size:', stat.size);
问题 2: 生成音频为空
日志:
[SherpaTTS] ❌ Failed to generate audio
可能原因:
- 文本为空或包含不支持字符
- tokens.txt 或 lexicon.txt 缺失
- 内存不足
解决方案:
typescript
// 检查文本
console.log('Text length:', text.length);
console.log('Text content:', text);
// 简化文本测试
await speak('你好'); // 最简单的测试
问题 3: 播放无声音
可能原因:
- WAV 转换错误
- AVPlayer 未初始化
- 设备静音
解决方案:
typescript
// 检查音频数据
console.log('Audio samples length:', audioSamples.length);
console.log('First 10 samples:', audioSamples.slice(0, 10));
// 检查 WAV 文件大小
const stat = fs.statSync(audioPath);
console.log('WAV file size:', stat.size);
问题 4: 包体积过大
原因: Sherpa-onnx + ONNX Runtime + 模型 ≈ 100MB
解决方案:
-
按需下载模型(不打包到 APK)
typescript// 首次启动下载模型 async downloadModels() { const modelUrl = 'https://your-server.com/tts/model.onnx'; await downloadToCache(modelUrl, 'tts/model.onnx'); } -
使用压缩模型
- 选择 Piper 轻量级模型(~20MB)
- 使用模型量化(INT8)
-
动态库复用
- 多个 HAP 共享同一个 so 库
9. 性能优化建议
9.1 启动性能
问题: 模型加载耗时 1-2 秒
优化方案:
typescript
// 方案 1: 懒加载(首次使用时初始化)
async speak(text: string) {
if (!this.isInitialized) {
console.info('[TTS] Lazy initialization...');
await this.initialize(getContext(this));
}
// ...
}
// 方案 2: 异步初始化(不阻塞启动)
onCreate() {
// 不等待初始化完成
TTSUtil.getInstance().initialize(this.context);
}
9.2 内存优化
问题: 引擎占用 ~100MB 内存
优化方案:
typescript
// 用完即释放(非常驻场景)
async speak(text: string) {
await this.initialize(context);
await this.speak(text);
await this.shutdown(); // 立即释放
}
// 定时释放(常驻场景)
let lastUseTime = Date.now();
async speak(text: string) {
// ...
lastUseTime = Date.now();
}
// 5分钟无使用自动释放
setInterval(() => {
if (Date.now() - lastUseTime > 5 * 60 * 1000) {
TTSUtil.getInstance().shutdown();
}
}, 60000);
9.3 播放性能
优化: 预生成常用语音
typescript
class TTSCache {
private cache: Map<string, ArrayBuffer> = new Map();
async pregenerate() {
const commonPhrases = [
'扫码成功',
'请扫描患者码',
'操作完成',
'核对成功'
];
for (const phrase of commonPhrases) {
const samples = sherpatts.generateAudio(phrase, 22050, 1.0);
const wav = this.convertToWAV(samples, 22050);
this.cache.set(phrase, wav);
console.info(`[TTSCache] Cached: ${phrase}`);
}
}
async speak(text: string) {
if (this.cache.has(text)) {
// 直接播放缓存的 WAV
await this.playFromCache(text);
} else {
// 实时生成
await this.generateAndPlay(text);
}
}
}
10. 集成清单
10.1 文件清单
| 文件 | 路径 | 大小 | 状态 |
|---|---|---|---|
| Native C++ | |||
| CMakeLists.txt | entry/src/main/cpp/ | 1KB | ⬜ 待创建 |
| sherpa_tts_wrapper.h | entry/src/main/cpp/ | 2KB | ⬜ 待创建 |
| sherpa_tts_wrapper.cpp | entry/src/main/cpp/ | 5KB | ⬜ 待创建 |
| sherpa_tts_napi.cpp | entry/src/main/cpp/ | 8KB | ⬜ 待创建 |
| ArkTS | |||
| SherpaTTSWrapper.ets | entry/src/main/ets/utils/ | 12KB | ⬜ 待创建 |
| TTSUtil.ets | entry/src/main/ets/utils/ | 8KB | ⬜ 待修改 |
| SherpaTTSTestPage.ets | entry/src/main/ets/pages/ | 4KB | ⬜ 待创建 |
| 预编译库 | |||
| libsherpa-onnx.so | cpp/third_party/sherpa-onnx/lib/ | 15MB | ⬜ 待下载 |
| libonnxruntime.so | cpp/third_party/onnxruntime/lib/ | 8MB | ⬜ 待下载 |
| 模型文件 | |||
| model.onnx | resources/rawfile/tts/ | 30MB | ⬜ 待下载 |
| tokens.txt | resources/rawfile/tts/ | 50KB | ⬜ 待下载 |
| lexicon.txt | resources/rawfile/tts/ | 500KB | ⬜ 待下载 |
| 配置文件 | |||
| build-profile.json5 | entry/ | - | ⬜ 待修改 |
| EntryAbility.ets | entry/src/main/ets/entryability/ | - | ⬜ 待修改 |
10.2 任务清单
| 步骤 | 任务 | 预计时间 | 状态 |
|---|---|---|---|
| 1. 环境准备 | |||
| 1.1 | 下载 Sherpa-onnx 预编译库(HarmonyOS ARM64) | 30min | ⬜ |
| 1.2 | 下载 ONNX Runtime 库 | 15min | ⬜ |
| 1.3 | 下载 TTS 模型文件 | 30min | ⬜ |
| 2. Native 层实现 | |||
| 2.1 | 创建 cpp 目录结构 | 10min | ⬜ |
| 2.2 | 编写 CMakeLists.txt | 30min | ⬜ |
| 2.3 | 实现 sherpa_tts_wrapper.h/cpp | 1h | ⬜ |
| 2.4 | 实现 sherpa_tts_napi.cpp | 1.5h | ⬜ |
| 2.5 | 编译验证 | 30min | ⬜ |
| 3. ArkTS 层实现 | |||
| 3.1 | 实现 SherpaTTSWrapper.ets | 1.5h | ⬜ |
| 3.2 | 修改 TTSUtil.ets(统一接口) | 1h | ⬜ |
| 3.3 | 创建测试页面 | 30min | ⬜ |
| 4. 集成与配置 | |||
| 4.1 | 修改 build-profile.json5 | 15min | ⬜ |
| 4.2 | 修改 EntryAbility.ets | 15min | ⬜ |
| 4.3 | 放置模型文件到 rawfile | 15min | ⬜ |
| 5. 测试验证 | |||
| 5.1 | 单元测试(Native 层) | 1h | ⬜ |
| 5.2 | 集成测试(ArkTS 层) | 1h | ⬜ |
| 5.3 | 性能测试 | 30min | ⬜ |
| 5.4 | 内存泄漏测试 | 30min | ⬜ |
| 6. 优化与调试 | |||
| 6.1 | 性能优化 | 1h | ⬜ |
| 6.2 | 错误处理完善 | 30min | ⬜ |
| 6.3 | 日志优化 | 15min | ⬜ |
| 总计 | ~14小时 |
10.3 验收标准
-
功能完整性
- 初始化成功率 100%
- 短文本(<10字)播报成功
- 长文本(>50字)播报成功
- 停止功能正常
- 降级方案正常
-
性能指标
- 初始化时间 <2 秒
- 首次合成延迟 <500ms
- 后续合成延迟 <200ms
- 内存占用 <150MB
-
稳定性
- 连续播报 100 次无崩溃
- 内存无明显泄漏
- 并发调用无异常
-
兼容性
- 原有 TTS API 100% 兼容
- 降级方案无缝切换
附录
A. 参考资源
- Sherpa-onnx 官方仓库: https://github.com/k2-fsa/sherpa-onnx
- HarmonyOS 示例: https://github.com/k2-fsa/sherpa-onnx/tree/master/harmony-os
- 模型下载: https://github.com/k2-fsa/sherpa-onnx/releases/tag/tts-models
- ONNX Runtime HarmonyOS: https://onnxruntime.ai/docs/build/eps.html
B. 技术支持
- GitHub Issues: https://github.com/k2-fsa/sherpa-onnx/issues
C. 更新记录
| 版本 | 日期 | 变更 |
|---|---|---|
| 1.0.0 | 2025-12-23 | 初始版本 |