Sherpa-onnx 离线 TTS 集成解决 openharmony 下语音播报完整方案

进行鸿蒙开发(基于openharmony)常常会遇到没有集成 CoreSpeechKit的情况,无法实现语音功能。

📋 目录

  1. [当前 TTS 实现分析](#当前 TTS 实现分析)
  2. 方案对比与选型
  3. [Sherpa-onnx 技术架构](#Sherpa-onnx 技术架构)
  4. 详细实现步骤
    • [4.1 Native C++ 集成](#4.1 Native C++ 集成)
    • [4.2 ArkTS 封装层](#4.2 ArkTS 封装层)
    • [4.3 统一 API 接口](#4.3 统一 API 接口)
  5. 模型文件准备
  6. 配置与构建
  7. 测试验证
  8. 故障排查
  9. 性能优化建议
  10. 集成清单

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}`;
}

问题总结:

  1. ❌ 网络依赖: 无网络环境无法使用(医院内网场景)
  2. ❌ 高延迟: 下载 (500ms-2s) + 写入 (100ms) + 播放 (启动 200ms)
  3. ❌ API 风险: 百度公开接口可能随时失效
  4. ❌ 并发限制: 频繁调用可能被限流
  5. ❌ 缓存污染: 每次播放都生成临时文件

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?

✅ 优势
  1. 官方 HarmonyOS 支持

    • GitHub 仓库已提供 HarmonyOS 预编译库
    • 社区活跃,问题响应快
    • 示例代码完善
  2. 音质卓越

    • 基于 VITS 模型,自然度接近真人
    • 支持情感、韵律控制
    • 中文发音准确
  3. 性能优异

    • ONNX Runtime 高度优化
    • 支持 CPU/GPU 推理
    • 延迟 <200ms(首次合成)
  4. 易于集成

    • C API 简洁清晰
    • N-API 绑定成熟
    • 文档齐全
  5. 模型丰富

    • 多种预训练中文模型
    • 支持自定义训练
    • 模型体积可控
⚠️ 劣势与缓解
劣势 影响 缓解方案
包体积增大 ~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

可能原因:

  1. 模型文件路径错误
  2. 模型文件损坏
  3. 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

可能原因:

  1. 文本为空或包含不支持字符
  2. tokens.txt 或 lexicon.txt 缺失
  3. 内存不足

解决方案:

typescript 复制代码
// 检查文本
console.log('Text length:', text.length);
console.log('Text content:', text);

// 简化文本测试
await speak('你好');  // 最简单的测试
问题 3: 播放无声音

可能原因:

  1. WAV 转换错误
  2. AVPlayer 未初始化
  3. 设备静音

解决方案:

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

解决方案:

  1. 按需下载模型(不打包到 APK)

    typescript 复制代码
    // 首次启动下载模型
    async downloadModels() {
      const modelUrl = 'https://your-server.com/tts/model.onnx';
      await downloadToCache(modelUrl, 'tts/model.onnx');
    }
  2. 使用压缩模型

    • 选择 Piper 轻量级模型(~20MB)
    • 使用模型量化(INT8)
  3. 动态库复用

    • 多个 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. 参考资源

B. 技术支持

C. 更新记录

版本 日期 变更
1.0.0 2025-12-23 初始版本
相关推荐
Flutter_OH1 天前
Flutter OHOS 三方库与插件适配相关问题
harmonyos
Flutter_OH1 天前
Flutter OHOS ohos应用编译相关问题
harmonyos
马剑威(威哥爱编程)1 天前
【共创稿事节】HarmonyOS 7 Agent 开发实战 01:云端大模型 API 接入底座
华为·harmonyos
万物智能信息科技1 天前
人体感应芯片PIR模块驱动开发—【万物智能之开源鸿蒙OpenHarmony系统实战开发系列教程】
驱动开发·嵌入式硬件·华为·开源·harmonyos
m0_738185821 天前
Flutter 鸿蒙化实战:sqflite 适配 OpenHarmony,本地 SQLite 数据库
flutter·华为·harmonyos·鸿蒙
马剑威(威哥爱编程)1 天前
【共创稿事节】HarmonyOS 7 Agent 开发实战 02:Agent 工程脚手架与对话 UI 骨架
华为·harmonyos
打工仔折腾 AI1 天前
鸿蒙平板远控电脑实测:ToDesk蓝牙键鼠、双模式鼠标与跨端剪贴板落地记录
人工智能·后端·python·性能优化·计算机外设·电脑·harmonyos
打工仔折腾 AI1 天前
鸿蒙端ToDesk远控电脑实测:蓝牙键鼠、双模式鼠标与跨端剪贴板实践
后端·python·性能优化·计算机外设·电脑·harmonyos·ai agent 实战
OH_TPC1 天前
【鸿蒙优选三方库】@ohos/axios:前端转鸿蒙,网络层几乎不用改
前端·华为·harmonyos·鸿蒙
m0_738185821 天前
Flutter 鸿蒙化实战:simple_circular_progress_bar 适配 OpenHarmony,环形进度条
flutter·华为·harmonyos·鸿蒙