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 初始版本
相关推荐
音浪豆豆_Rachel2 小时前
Flutter跨平台通信的实战演练:复杂数据结构与单元测试在鸿蒙生态中的完美实现
数据结构·flutter·单元测试·harmonyos
坚果派·白晓明2 小时前
【鸿蒙开发者跨平台开发可选工具】Windows 11 安装 Android Studio 完整指南
windows·android studio·harmonyos·开发者可选工具·开源项目可选ide·鸿蒙跨平台开发
音浪豆豆_Rachel3 小时前
Flutter跨平台通信的类型安全艺术:枚举与复杂对象在鸿蒙生态中的映射与序列化
flutter·harmonyos
昼-枕3 小时前
【鸿蒙Flutter入门】10分钟快速上手开发天气应用
flutter·华为·harmonyos
前端世界3 小时前
鸿蒙应用能耗优化实战:如何避免引用不当引发的后台运行
华为·harmonyos
kirk_wang3 小时前
Flutter `shared_preferences` 三方库在 OpenHarmony 平台的适配实践
flutter·移动开发·跨平台·arkts·鸿蒙
鸿蒙开发工程师—阿辉3 小时前
HarmonyOS 5 上下文的使用:理清“上下文”的关系
华为·harmonyos
音浪豆豆_Rachel4 小时前
Flutter鸿蒙文件选择器内核解析:从Dart调用到ArkTS系统级对话
flutter·harmonyos