鸿蒙 Flutter 语音交互进阶:TTS/STT 全离线部署与多语言适配

欢迎大家加入[开源鸿蒙跨平台开发者社区](https://openharmonycrossplatform.csdn.net),

一起共建开源鸿蒙跨平台生态。

一、引言

在移动应用开发中,语音交互(TTS 文本转语音、STT 语音转文本)已成为提升用户体验的核心功能之一。尤其在鸿蒙(HarmonyOS)与 Flutter 混合开发场景下,全离线部署的语音交互能突破网络依赖限制,同时保障用户隐私安全,广泛适用于智能设备控制、离线导航、无障碍辅助等场景。

本文将聚焦 鸿蒙 + Flutter 混合开发架构,从环境搭建、离线引擎选型、TTS/STT 核心实现、多语言适配到性能优化,全方位拆解语音交互的全离线部署方案。文中包含大量可直接复用的代码示例、官方文档链接及实战避坑指南,助力开发者快速落地高质量的离线语音功能。

1.1 核心目标

  • 基于鸿蒙原生能力 + Flutter 跨平台特性,实现 TTS/STT 全离线运行(无网络环境下正常工作);
  • 支持中英日韩等多语言的语音合成与识别,适配不同地区用户需求;
  • 优化离线资源占用、响应速度及识别准确率,平衡功能与性能;
  • 提供 Flutter 与鸿蒙原生的无缝通信方案,降低混合开发复杂度。

1.2 技术栈选型

技术方向 选型方案 核心优势 官方文档链接
跨平台框架 Flutter 3.13+ 高性能 UI 渲染、跨平台一致性、丰富的生态 Flutter 官方文档
系统底座 HarmonyOS 4.0+ 原生离线语音能力、分布式架构支持、权限管控完善 鸿蒙开发者文档
离线 TTS 引擎 鸿蒙原生 TTS 引擎 + 离线语音包 系统级集成、低延迟、支持多语言 鸿蒙 TTS 开发指南
离线 STT 引擎 PocketSphinx + 鸿蒙 NDK 编译 轻量开源、支持离线识别、可自定义词典 PocketSphinx 官方仓库
跨端通信 Flutter MethodChannel 高效双向通信、支持复杂数据传递 Flutter MethodChannel 文档

二、环境准备

2.1 开发环境搭建

2.1.1 鸿蒙开发环境
  1. 安装 DevEco Studio 4.0+(需配置鸿蒙 SDK 4.0+,包含 AI 模块、NDK 工具链);
  2. 配置鸿蒙模拟器(API 版本 9+)或真实设备(需开启开发者模式);
  3. 启用鸿蒙原生 AI 能力:在 config.json 中添加 AI 权限与模块依赖。
2.1.2 Flutter 开发环境
  1. 安装 Flutter 3.13+(需兼容鸿蒙系统): bash

    运行

    复制代码
    # 安装指定版本 Flutter
    flutter version 3.13.0
    # 配置鸿蒙编译环境
    flutter config --enable-harmonyos
  2. 创建 Flutter 混合工程: bash

    运行

    复制代码
    flutter create --platforms=harmonyos my_voice_app
    cd my_voice_app
  3. 集成鸿蒙原生依赖:在 harmonyos/build.gradle 中添加 TTS/STT 相关依赖:

    groovy

    复制代码
    dependencies {
        // 鸿蒙原生 TTS 依赖
        implementation 'com.huawei.hms:ai-tts:4.0.0.300'
        // 鸿蒙 NDK 支持(用于编译 PocketSphinx)
        implementation 'com.huawei.hms:ndk-utils:1.0.0.300'
    }

2.2 离线资源准备

2.2.1 鸿蒙 TTS 离线语音包
  • 下载地址:鸿蒙离线语音包官网
  • 支持语言:中文(普通话)、英文、日语、韩语等(需下载对应语言的 .hap 格式资源包);
  • 部署方式:将语音包放入工程 harmonyos/src/main/raw 目录,或通过应用内下载后解压至沙箱路径。
2.2.2 PocketSphinx 离线模型
  • 下载地址:PocketSphinx 预训练模型
  • 核心文件:
    • 语言模型:en-us.lm.bin(英文)、zh-CN.lm.bin(中文);
    • 声学模型:hub4wsj_sc_8k.tar.gz(英文)、zh_cn.cd_cont_5000.tar.gz(中文);
    • 字典文件:cmudict-en-us.dict(英文)、mandarin_notone.dic(中文);
  • 部署方式:将模型文件放入 harmonyos/src/main/assets 目录,离线运行时加载。

三、核心实现:Flutter 与鸿蒙原生通信

鸿蒙 + Flutter 混合开发的核心是 跨端通信,通过 Flutter MethodChannel 实现 Dart 代码与鸿蒙 Java 代码的双向调用。以下是通用通信架构设计:

3.1 通信架构设计

MethodCall

转发调用

调用 TTS/STT 引擎

返回结果

MethodResult

回调结果

Flutter 端(Dart)

MethodChannel

鸿蒙原生端(Java)

离线引擎(鸿蒙 TTS/PocketSphinx)

3.2 通用通信封装(Flutter 端)

创建 lib/voice_channel.dart,封装 TTS/STT 相关的 MethodChannel 调用:

dart

复制代码
import 'package:flutter/services.dart';

class VoiceChannel {
  // 定义 MethodChannel 名称(需与鸿蒙端一致)
  static const MethodChannel _channel = MethodChannel('com.example.voice/tts_stt');

  // 初始化离线引擎(加载语音包/模型)
  static Future<bool> initOfflineEngine({required String language}) async {
    try {
      final bool result = await _channel.invokeMethod(
        'initOfflineEngine',
        {'language': language}, // 传递语言参数(如 "zh-CN", "en-US")
      );
      return result;
    } on PlatformException catch (e) {
      print('初始化离线引擎失败:${e.message}');
      return false;
    }
  }

  // TTS:文本转语音(离线)
  static Future<bool> startTts({
    required String text,
    required double volume, // 音量(0.0-1.0)
    required double speed,  // 语速(0.5-2.0)
  }) async {
    try {
      final bool result = await _channel.invokeMethod(
        'startTts',
        {
          'text': text,
          'volume': volume,
          'speed': speed,
        },
      );
      return result;
    } on PlatformException catch (e) {
      print('TTS 合成失败:${e.message}');
      return false;
    }
  }

  // STT:语音转文本(离线)
  static Future<String?> startStt() async {
    try {
      final String? result = await _channel.invokeMethod('startStt');
      return result;
    } on PlatformException catch (e) {
      print('STT 识别失败:${e.message}');
      return null;
    }
  }

  // 停止语音播放/识别
  static Future<bool> stopVoice() async {
    try {
      final bool result = await _channel.invokeMethod('stopVoice');
      return result;
    } on PlatformException catch (e) {
      print('停止语音操作失败:${e.message}');
      return false;
    }
  }
}

3.3 通用通信封装(鸿蒙端)

创建 harmonyos/src/main/java/com/example/voice/TtsSttChannel.java,实现 MethodChannel 接收与处理逻辑:

java

运行

复制代码
import ohos.aafwk.ability.Ability;
import ohos.agp.window.service.WindowManager;
import ohos.hiviewdfx.HiLog;
import ohos.hiviewdfx.HiLogLabel;
import io.flutter.embedding.engine.FlutterEngine;
import io.flutter.plugin.common.MethodCall;
import io.flutter.plugin.common.MethodChannel;
import io.flutter.plugin.common.MethodChannel.MethodCallHandler;
import io.flutter.plugin.common.MethodChannel.Result;

public class TtsSttChannel implements MethodCallHandler {
    private static final HiLogLabel LABEL = new HiLogLabel(HiLog.DEBUG, 0x00100, "TtsSttChannel");
    private final Ability ability;
    private OfflineTtsManager ttsManager;
    private OfflineSttManager sttManager;

    // 构造函数(传入当前 Ability)
    public TtsSttChannel(Ability ability) {
        this.ability = ability;
    }

    // 注册 MethodChannel
    public void registerWith(FlutterEngine flutterEngine) {
        new MethodChannel(
                flutterEngine.getDartExecutor().getBinaryMessenger(),
                "com.example.voice/tts_stt" // 与 Flutter 端一致
        ).setMethodCallHandler(this);
    }

    @Override
    public void onMethodCall(MethodCall call, Result result) {
        switch (call.method) {
            case "initOfflineEngine":
                // 初始化离线引擎
                String language = call.argument("language");
                boolean initSuccess = initEngine(language);
                result.success(initSuccess);
                break;
            case "startTts":
                // 处理 TTS 调用
                String text = call.argument("text");
                double volume = call.argument("volume");
                double speed = call.argument("speed");
                boolean ttsSuccess = startTts(text, volume, speed);
                result.success(ttsSuccess);
                break;
            case "startStt":
                // 处理 STT 调用
                String sttResult = startStt();
                result.success(sttResult);
                break;
            case "stopVoice":
                // 停止语音操作
                boolean stopSuccess = stopVoice();
                result.success(stopSuccess);
                break;
            default:
                result.notImplemented();
                break;
        }
    }

    // 初始化 TTS/STT 离线引擎(后续实现)
    private boolean initEngine(String language) {
        // TODO: 加载离线语音包与模型
        ttsManager = new OfflineTtsManager(ability, language);
        sttManager = new OfflineSttManager(ability, language);
        return ttsManager.init() && sttManager.init();
    }

    // 启动 TTS 合成(后续实现)
    private boolean startTts(String text, double volume, double speed) {
        return ttsManager.speak(text, volume, speed);
    }

    // 启动 STT 识别(后续实现)
    private String startStt() {
        return sttManager.recognize();
    }

    // 停止语音操作(后续实现)
    private boolean stopVoice() {
        ttsManager.stop();
        sttManager.stop();
        return true;
    }
}

3.4 注册通信通道

在鸿蒙端 MainAbility.java 中注册 MethodChannel:

java

运行

复制代码
import io.flutter.embedding.harmonyos.FlutterAbility;
import io.flutter.embedding.engine.FlutterEngine;

public class MainAbility extends FlutterAbility {
    @Override
    public void configureFlutterEngine(FlutterEngine flutterEngine) {
        super.configureFlutterEngine(flutterEngine);
        // 注册 TTS/STT 通信通道
        new TtsSttChannel(this).registerWith(flutterEngine);
    }
}

四、离线 TTS 实现(鸿蒙原生 + Flutter 调用)

4.1 鸿蒙原生离线 TTS 封装

创建 harmonyos/src/main/java/com/example/voice/OfflineTtsManager.java,基于鸿蒙原生 TTS 引擎实现离线合成:

java

运行

复制代码
import ohos.aafwk.ability.Ability;
import ohos.agp.utils.Color;
import ohos.hiviewdfx.HiLog;
import ohos.hiviewdfx.HiLogLabel;
import ohos.media.audio.AudioManager;
import ohos.media.tts.TtsEngine;
import ohos.media.tts.TtsHelper;
import ohos.media.tts.TtsPlayer;
import ohos.media.tts.TtsRequest;
import ohos.media.tts.TtsResponse;
import ohos.media.tts.TtsSpeaker;

import java.util.List;

public class OfflineTtsManager {
    private static final HiLogLabel LABEL = new HiLogLabel(HiLog.DEBUG, 0x00101, "OfflineTtsManager");
    private final Ability ability;
    private final String language;
    private TtsHelper ttsHelper;
    private TtsPlayer ttsPlayer;
    private boolean isInitialized = false;

    // 构造函数(传入 Ability 与目标语言)
    public OfflineTtsManager(Ability ability, String language) {
        this.ability = ability;
        this.language = language;
    }

    // 初始化离线 TTS 引擎
    public boolean init() {
        try {
            // 1. 创建 TtsHelper 实例
            ttsHelper = TtsHelper.getInstance(ability);
            ttsHelper.init();

            // 2. 配置离线模式(关键:禁用在线合成)
            TtsEngine.Config config = new TtsEngine.Config();
            config.setOnlineMode(false); // 强制离线模式
            config.setLanguage(language); // 设置语言(如 "zh-CN", "en-US")
            ttsHelper.setConfig(config);

            // 3. 加载离线语音包(从 raw 目录或沙箱路径)
            String offlineResourcePath = ability.getContext().getRawFileDir() + "/tts_offline_" + language + ".hap";
            ttsHelper.loadOfflineResource(offlineResourcePath);

            // 4. 创建 TtsPlayer 并设置回调
            ttsPlayer = TtsPlayer.create(ability);
            ttsPlayer.setTtsPlayerListener(new TtsPlayer.TtsPlayerListener() {
                @Override
                public void onPlayStart() {
                    HiLog.debug(LABEL, "TTS 播放开始");
                }

                @Override
                public void onPlayPause() {
                    HiLog.debug(LABEL, "TTS 播放暂停");
                }

                @Override
                public void onPlayStop() {
                    HiLog.debug(LABEL, "TTS 播放停止");
                }

                @Override
                public void onPlayComplete() {
                    HiLog.debug(LABEL, "TTS 播放完成");
                }

                @Override
                public void onError(int errorCode) {
                    HiLog.error(LABEL, "TTS 错误:errorCode = %{public}d", errorCode);
                }
            });

            isInitialized = true;
            return true;
        } catch (Exception e) {
            HiLog.error(LABEL, "TTS 初始化失败:%{public}s", e.getMessage());
            return false;
        }
    }

    // 语音合成与播放
    public boolean speak(String text, double volume, double speed) {
        if (!isInitialized || text.isEmpty()) {
            return false;
        }

        try {
            // 1. 配置音量(鸿蒙 TTS 音量范围:0-100)
            int ttsVolume = (int) (volume * 100);
            ttsPlayer.setVolume(ttsVolume);

            // 2. 配置语速(鸿蒙 TTS 语速范围:0.5-2.0,与 Flutter 端一致)
            ttsPlayer.setSpeed(speed);

            // 3. 创建 TTS 请求
            TtsRequest request = new TtsRequest();
            request.setText(text);
            request.setLanguage(language);
            request.setOnlineMode(false); // 确保离线合成

            // 4. 执行合成并播放
            TtsResponse response = ttsHelper.generateTts(request);
            if (response.getResultCode() == TtsResponse.SUCCESS) {
                ttsPlayer.play(response.getAudioData());
                return true;
            } else {
                HiLog.error(LABEL, "TTS 合成失败:resultCode = %{public}d", response.getResultCode());
                return false;
            }
        } catch (Exception e) {
            HiLog.error(LABEL, "TTS 播放失败:%{public}s", e.getMessage());
            return false;
        }
    }

    // 停止 TTS 播放
    public void stop() {
        if (ttsPlayer != null && ttsPlayer.isPlaying()) {
            ttsPlayer.stop();
        }
    }

    // 释放资源
    public void release() {
        if (ttsPlayer != null) {
            ttsPlayer.release();
        }
        if (ttsHelper != null) {
            ttsHelper.release();
        }
        isInitialized = false;
    }
}

4.2 Flutter 端 TTS 调用示例

lib/main.dart 中实现 UI 界面与 TTS 功能调用:

dart

复制代码
import 'package:flutter/material.dart';
import 'package:my_voice_app/voice_channel.dart';

void main() {
  runApp(const MyApp());
}

class MyApp extends StatelessWidget {
  const MyApp({super.key});

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: '离线语音交互 Demo',
      theme: ThemeData(primarySwatch: Colors.blue),
      home: const VoiceHomePage(),
    );
  }
}

class VoiceHomePage extends StatefulWidget {
  const VoiceHomePage({super.key});

  @override
  State<VoiceHomePage> createState() => _VoiceHomePageState();
}

class _VoiceHomePageState extends State<VoiceHomePage> {
  final TextEditingController _ttsTextController = TextEditingController(
    text: "Hello, 欢迎使用鸿蒙 Flutter 离线语音交互功能!",
  );
  double _volume = 0.8;
  double _speed = 1.0;
  String _language = "zh-CN";
  bool _isEngineInitialized = false;

  // 初始化离线引擎
  Future<void> _initEngine() async {
    final bool success = await VoiceChannel.initOfflineEngine(language: _language);
    setState(() {
      _isEngineInitialized = success;
    });
    ScaffoldMessenger.of(context).showSnackBar(
      SnackBar(content: Text(success ? "离线引擎初始化成功" : "离线引擎初始化失败")),
    );
  }

  // 触发 TTS 合成
  Future<void> _startTts() async {
    if (!_isEngineInitialized) {
      ScaffoldMessenger.of(context).showSnackBar(
        const SnackBar(content: Text("请先初始化离线引擎")),
      );
      return;
    }
    final bool success = await VoiceChannel.startTts(
      text: _ttsTextController.text,
      volume: _volume,
      speed: _speed,
    );
    if (!success) {
      ScaffoldMessenger.of(context).showSnackBar(
        const SnackBar(content: Text("TTS 合成失败,请检查配置")),
      );
    }
  }

  // 切换语言
  void _changeLanguage(String newLanguage) {
    setState(() {
      _language = newLanguage;
      _isEngineInitialized = false; // 切换语言后需重新初始化
    });
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text("离线 TTS/STT 演示")),
      body: Padding(
        padding: const EdgeInsets.all(16.0),
        child: Column(
          crossAxisAlignment: CrossAxisAlignment.start,
          children: [
            // 语言选择器
            DropdownButton<String>(
              value: _language,
              items: const [
                DropdownMenuItem(value: "zh-CN", child: Text("中文(普通话)")),
                DropdownMenuItem(value: "en-US", child: Text("英文(美国)")),
                DropdownMenuItem(value: "ja-JP", child: Text("日语")),
                DropdownMenuItem(value: "ko-KR", child: Text("韩语")),
              ],
              onChanged: (value) => _changeLanguage(value!),
              hint: const Text("选择语言"),
            ),
            const SizedBox(height: 16),

            // 初始化引擎按钮
            ElevatedButton(
              onPressed: _initEngine,
              child: const Text("初始化离线引擎"),
            ),
            const SizedBox(height: 16),

            // TTS 文本输入
            TextField(
              controller: _ttsTextController,
              decoration: const InputDecoration(
                labelText: "输入要合成的文本",
                border: OutlineInputBorder(),
                minLines: 3,
                maxLines: 5,
              ),
            ),
            const SizedBox(height: 16),

            // 音量调节
            Row(
              children: [
                const Text("音量:"),
                Expanded(
                  child: Slider(
                    value: _volume,
                    min: 0.0,
                    max: 1.0,
                    divisions: 10,
                    label: "${(_volume * 100).toInt()}%",
                    onChanged: (value) => setState(() => _volume = value),
                  ),
                ),
              ],
            ),

            // 语速调节
            Row(
              children: [
                const Text("语速:"),
                Expanded(
                  child: Slider(
                    value: _speed,
                    min: 0.5,
                    max: 2.0,
                    divisions: 15,
                    label: "$_speedx",
                    onChanged: (value) => setState(() => _speed = value),
                  ),
                ),
              ],
            ),
            const SizedBox(height: 16),

            // 开始合成按钮
            Center(
              child: ElevatedButton(
                onPressed: _startTts,
                style: ElevatedButton.styleFrom(
                  padding: const EdgeInsets.symmetric(horizontal: 32, vertical: 16),
                  textStyle: const TextStyle(fontSize: 18),
                ),
                child: const Text("开始语音合成"),
              ),
            ),
          ],
        ),
      ),
    );
  }

  @override
  void dispose() {
    _ttsTextController.dispose();
    super.dispose();
  }
}

4.3 离线 TTS 关键配置说明

  1. 离线资源加载优先级

    • 工程内置:raw 目录下的语音包优先加载,适用于固定语言需求;
    • 应用内下载:通过鸿蒙 DownloadManager 下载语音包后,解压至 getFilesDir() 路径,适用于动态切换多语言场景。
  2. 语言代码规范 :遵循 ISO 639-1 语言代码 + ISO 3166-1 alpha-2 国家代码,如中文(zh-CN)、英文(en-US)。

  3. 权限配置 :在 harmonyos/src/main/config.json 中添加以下权限:

    json

    复制代码
    "module": {
      "abilities": [...],
      "reqPermissions": [
        {
          "name": "ohos.permission.RECORD_AUDIO", // 录音权限(STT 需用)
          "reason": "用于离线语音识别",
          "usedScene": {
            "ability": ["com.example.voice.MainAbility"],
            "when": "inuse"
          }
        },
        {
          "name": "ohos.permission.READ_MEDIA", // 读取媒体文件权限(加载离线资源)
          "reason": "用于加载离线语音包",
          "usedScene": {
            "ability": ["com.example.voice.MainAbility"],
            "when": "inuse"
          }
        }
      ]
    }

五、离线 STT 实现(PocketSphinx + 鸿蒙 NDK + Flutter 调用)

5.1 PocketSphinx 鸿蒙 NDK 编译

PocketSphinx 是开源的离线语音识别引擎,需通过鸿蒙 NDK 编译为 .so 库后集成到工程中:

5.1.1 编译环境准备
  1. 安装鸿蒙 NDK 工具链(DevEco Studio 中通过 Tools > HarmonyOS > SDK Manager 下载);
  2. 下载 PocketSphinx 源码:git clone https://github.com/cmusphinx/pocketsphinx.git
  3. 创建鸿蒙 NDK 编译脚本 Android.mkApplication.mk(鸿蒙 NDK 兼容 Android NDK 语法)。
5.1.2 编译脚本配置(Android.mk

makefile

复制代码
LOCAL_PATH := $(call my-dir)

# 配置 PocketSphinx 源码路径
POCKETSPHINX_PATH := $(LOCAL_PATH)/pocketsphinx/src

# 编译 PocketSphinx 核心库
include $(CLEAR_VARS)
LOCAL_MODULE := pocketsphinx
LOCAL_SRC_FILES := \
    $(POCKETSPHINX_PATH)/libpocketsphinx/align.c \
    $(POCKETSPHINX_PATH)/libpocketsphinx/decoder.c \
    $(POCKETSPHINX_PATH)/libpocketsphinx/feat.c \
    $(POCKETSPHINX_PATH)/libpocketsphinx/lattice.c \
    $(POCKETSPHINX_PATH)/libpocketsphinx/mfcc.c \
    $(POCKETSPHINX_PATH)/libpocketsphinx/ngram_search.c \
    $(POCKETSPHINX_PATH)/libpocketsphinx/phone_loop_search.c \
    $(POCKETSPHINX_PATH)/libpocketsphinx/ps_lattice.c \
    $(POCKETSPHINX_PATH)/libpocketsphinx/ps_mllr.c \
    $(POCKETSPHINX_PATH)/libpocketsphinx/ps_search.c \
    $(POCKETSPHINX_PATH)/libpocketsphinx/ps_semseg.c \
    $(POCKETSPHINX_PATH)/libpocketsphinx/ps_stats.c \
    $(POCKETSPHINX_PATH)/libpocketsphinx/ps_alignment.c \
    $(POCKETSPHINX_PATH)/libpocketsphinx/ps_decoder.c \
    $(POCKETSPHINX_PATH)/libpocketsphinx/ps_features.c \
    $(POCKETSPHINX_PATH)/libpocketsphinx/ps_language_model.c \
    $(POCKETSPHINX_PATH)/libpocketsphinx/ps_mfcc.c \
    $(POCKETSPHINX_PATH)/libpocketsphinx/ps_ngram.c \
    $(POCKETSPHINX_PATH)/libpocketsphinx/ps_phone_loop.c \
    $(POCKETSPHINX_PATH)/libpocketsphinx/ps_speech_detector.c \
    $(POCKETSPHINX_PATH)/libpocketsphinx/ps_util.c \
    $(POCKETSPHINX_PATH)/libpocketsphinx/string2int.c \
    $(POCKETSPHINX_PATH)/libpocketsphinx/vad.c

# 配置编译选项
LOCAL_CFLAGS := \
    -DHAVE_CONFIG_H \
    -I$(POCKETSPHINX_PATH)/include \
    -I$(POCKETSPHINX_PATH)/libpocketsphinx \
    -O3 -fPIC

# 链接依赖库
LOCAL_LDLIBS := -llog -lm

include $(BUILD_SHARED_LIBRARY)
5.1.3 编译脚本配置(Application.mk

makefile

复制代码
# 支持的 CPU 架构(根据目标设备选择)
APP_ABI := arm64-v8a armeabi-v7a
# 鸿蒙 NDK 版本(需与下载的版本一致)
APP_PLATFORM := harmonyos-9
# C 语言标准
APP_CFLAGS := -std=c99
5.1.4 执行编译

bash

运行

复制代码
# 进入 jni 目录
cd harmonyos/src/main/jni
# 执行 NDK 编译(替换为鸿蒙 NDK 路径)
/home/user/DevEcoStudio/ndk/26.1.10909125/ndk-build

编译成功后,会在 libs 目录生成 libpocketsphinx.so 文件,将其复制到 harmonyos/src/main/jniLibs 目录。

5.2 鸿蒙原生离线 STT 封装

创建 harmonyos/src/main/java/com/example/voice/OfflineSttManager.java,通过 JNI 调用 PocketSphinx 实现离线识别:

java

运行

复制代码
import ohos.aafwk.ability.Ability;
import ohos.hiviewdfx.HiLog;
import ohos.hiviewdfx.HiLogLabel;
import ohos.media.audio.AudioDeviceDescriptor;
import ohos.media.audio.AudioManager;
import ohos.media.audio.AudioRecord;
import ohos.media.audio.AudioStreamInfo;
import ohos.media.audio.AudioStreamType;

import java.io.File;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;

public class OfflineSttManager {
    private static final HiLogLabel LABEL = new HiLogLabel(HiLog.DEBUG, 0x00102, "OfflineSttManager");
    private final Ability ability;
    private final String language;
    private AudioRecord audioRecord;
    private boolean isRecording = false;
    private boolean isInitialized = false;
    private String modelPath; // 离线模型路径

    // 加载 PocketSphinx 动态库
    static {
        System.loadLibrary("pocketsphinx");
    }

    // JNI 方法:初始化 PocketSphinx 引擎
    private native boolean initPocketSphinx(String modelPath, String dictPath, String lmPath);

    // JNI 方法:开始语音识别(传入音频数据)
    private native String startRecognition(byte[] audioData, int length);

    // JNI 方法:停止识别并释放资源
    private native void stopRecognition();

    // 构造函数
    public OfflineSttManager(Ability ability, String language) {
        this.ability = ability;
        this.language = language;
        this.modelPath = ability.getContext().getAssetsDir() + "/stt_models/";
    }

    // 初始化离线 STT 引擎
    public boolean init() {
        try {
            // 1. 复制 assets 中的模型文件到沙箱路径(首次启动时执行)
            copyAssetsToSandbox();

            // 2. 配置模型路径(根据语言选择对应的模型)
            String dictPath = modelPath + (language.equals("zh-CN") ? "mandarin_notone.dic" : "cmudict-en-us.dict");
            String lmPath = modelPath + (language.equals("zh-CN") ? "zh-CN.lm.bin" : "en-us.lm.bin");
            String acousticModelPath = modelPath + (language.equals("zh-CN") ? "zh_cn.cd_cont_5000" : "hub4wsj_sc_8k");

            // 3. 初始化 PocketSphinx(JNI 调用)
            boolean initSuccess = initPocketSphinx(acousticModelPath, dictPath, lmPath);
            if (!initSuccess) {
                HiLog.error(LABEL, "PocketSphinx 初始化失败");
                return false;
            }

            // 4. 配置 AudioRecord(录音参数:16kHz 采样率、16bit 位宽、单声道)
            AudioStreamInfo streamInfo = new AudioStreamInfo.Builder()
                    .setAudioStreamType(AudioStreamType.MEDIA)
                    .setSampleRate(16000) // PocketSphinx 推荐采样率
                    .setEncoding(AudioStreamInfo.Encoding.PCM_16BIT)
                    .setChannelMask(AudioStreamInfo.ChannelMask.CHANNEL_IN_MONO)
                    .build();

            audioRecord = new AudioRecord(streamInfo);
            isInitialized = true;
            return true;
        } catch (Exception e) {
            HiLog.error(LABEL, "STT 初始化失败:%{public}s", e.getMessage());
            return false;
        }
    }

    // 复制 assets 中的模型文件到沙箱(避免 assets 目录不可写问题)
    private void copyAssetsToSandbox() throws IOException {
        File modelDir = new File(modelPath);
        if (!modelDir.exists()) {
            modelDir.mkdirs();
        }

        // 列出 assets 中的模型文件(需提前将模型放入 assets/stt_models 目录)
        String[] modelFiles = ability.getContext().getAssets().list("stt_models");
        if (modelFiles == null) return;

        for (String fileName : modelFiles) {
            File targetFile = new File(modelPath + fileName);
            if (!targetFile.exists()) {
                InputStream is = ability.getContext().getAssets().open("stt_models/" + fileName);
                OutputStream os = new java.io.FileOutputStream(targetFile);
                byte[] buffer = new byte[1024];
                int len;
                while ((len = is.read(buffer)) != -1) {
                    os.write(buffer, 0, len);
                }
                is.close();
                os.close();
            }
        }
    }

    // 开始语音识别(同步阻塞调用,建议在子线程中执行)
    public String recognize() {
        if (!isInitialized) {
            HiLog.error(LABEL, "STT 引擎未初始化");
            return null;
        }

        try {
            isRecording = true;
            audioRecord.startRecording();
            HiLog.debug(LABEL, "开始录音识别");

            // 缓冲区大小(16kHz 采样率、16bit 位宽、单声道:1秒 = 32000 字节)
            int bufferSize = 32000;
            byte[] buffer = new byte[bufferSize];
            StringBuilder result = new StringBuilder();

            // 录制 5 秒音频(可根据需求调整时长)
            long startTime = System.currentTimeMillis();
            while (isRecording && System.currentTimeMillis() - startTime < 5000) {
                int readLen = audioRecord.read(buffer, 0, bufferSize);
                if (readLen > 0) {
                    // 调用 JNI 识别音频数据
                    String partialResult = startRecognition(buffer, readLen);
                    if (partialResult != null && !partialResult.isEmpty()) {
                        result.append(partialResult);
                    }
                }
            }

            stop();
            return result.toString().trim();
        } catch (Exception e) {
            HiLog.error(LABEL, "识别失败:%{public}s", e.getMessage());
            stop();
            return null;
        }
    }

    // 停止识别
    public void stop() {
        isRecording = false;
        if (audioRecord != null && audioRecord.getRecordingState() == AudioRecord.RecordingState.RECORDING) {
            audioRecord.stop();
        }
        stopRecognition(); // JNI 释放资源
        HiLog.debug(LABEL, "停止录音识别");
    }

    // 释放资源
    public void release() {
        stop();
        if (audioRecord != null) {
            audioRecord.release();
        }
        isInitialized = false;
    }
}

5.3 JNI 实现(PocketSphinx 调用)

创建 harmonyos/src/main/jni/pocketsphinx_jni.c,实现 JNI 方法与 PocketSphinx 的交互:

c

运行

复制代码
#include <jni.h>
#include <string.h>
#include <android/log.h>
#include <pocketsphinx.h>

#define LOG_TAG "PocketSphinxJNI"
#define LOGD(...) __android_log_print(ANDROID_LOG_DEBUG, LOG_TAG, __VA_ARGS__)
#define LOGE(...) __android_log_print(ANDROID_LOG_ERROR, LOG_TAG, __VA_ARGS__)

// 全局变量:PocketSphinx 解码器
static ps_decoder_t *ps = NULL;
static cmd_ln_t *config = NULL;

// 初始化 PocketSphinx 引擎
JNIEXPORT jboolean JNICALL
Java_com_example_voice_OfflineSttManager_initPocketSphinx(
        JNIEnv *env, jobject thiz,
        jstring acoustic_model_path,
        jstring dict_path,
        jstring lm_path) {
    // 转换 Java 字符串为 C 字符串
    const char *am_path = (*env)->GetStringUTFChars(env, acoustic_model_path, NULL);
    const char *dict_path_c = (*env)->GetStringUTFChars(env, dict_path, NULL);
    const char *lm_path_c = (*env)->GetStringUTFChars(env, lm_path, NULL);

    // 配置 PocketSphinx 参数
    config = cmd_ln_init(NULL, ps_args(), TRUE,
                        "-hmm", am_path,    // 声学模型路径
                        "-dict", dict_path_c, // 字典路径
                        "-lm", lm_path_c,    // 语言模型路径
                        "-samplerate", "16000", // 采样率(与 AudioRecord 一致)
                        "-nfft", "512",      // FFT 大小
                        NULL);

    if (config == NULL) {
        LOGE("PocketSphinx 配置初始化失败");
        return JNI_FALSE;
    }

    // 创建解码器
    ps = ps_init(config);
    if (ps == NULL) {
        LOGE("PocketSphinx 解码器初始化失败");
        cmd_ln_free_r(config);
        return JNI_FALSE;
    }

    // 释放字符串资源
    (*env)->ReleaseStringUTFChars(env, acoustic_model_path, am_path);
    (*env)->ReleaseStringUTFChars(env, dict_path, dict_path_c);
    (*env)->ReleaseStringUTFChars(env, lm_path, lm_path_c);

    LOGD("PocketSphinx 初始化成功");
    return JNI_TRUE;
}

// 开始语音识别
JNIEXPORT jstring JNICALL
Java_com_example_voice_OfflineSttManager_startRecognition(
        JNIEnv *env, jobject thiz,
        jbyteArray audio_data,
        jint length) {
    if (ps == NULL) {
        LOGE("解码器未初始化");
        return NULL;
    }

    // 获取音频数据
    jbyte *audio_bytes = (*env)->GetByteArrayElements(env, audio_data, NULL);
    if (audio_bytes == NULL) {
        LOGE("获取音频数据失败");
        return NULL;
    }

    // 开始识别(PocketSphinx 要求音频为 16bit PCM 格式)
    if (ps_start_utt(ps) < 0) {
        LOGE("开始识别失败");
        (*env)->ReleaseByteArrayElements(env, audio_data, audio_bytes, JNI_ABORT);
        return NULL;
    }

    // 处理音频数据
    ps_process_raw(ps, (const int16 *) audio_bytes, length / 2, FALSE, FALSE);

    // 结束识别并获取结果
    ps_end_utt(ps);
    char *hyp = ps_get_hyp(ps, NULL);

    // 释放资源
    (*env)->ReleaseByteArrayElements(env, audio_data, audio_bytes, JNI_ABORT);

    if (hyp == NULL) {
        LOGD("未识别到语音");
        return NULL;
    }

    // 转换 C 字符串为 Java 字符串
    jstring result = (*env)->NewStringUTF(env, hyp);
    ckd_free(hyp); // 释放 PocketSphinx 结果内存
    return result;
}

// 停止识别并释放资源
JNIEXPORT void JNICALL
Java_com_example_voice_OfflineSttManager_stopRecognition(
        JNIEnv *env, jobject thiz) {
    if (ps != NULL) {
        ps_free(ps);
        ps = NULL;
    }
    if (config != NULL) {
        cmd_ln_free_r(config);
        config = NULL;
    }
    LOGD("PocketSphinx 资源释放成功");
}

5.4 Flutter 端 STT 调用示例

VoiceHomePage 中添加 STT 识别相关 UI 与逻辑:

dart

复制代码
// 在 _VoiceHomePageState 中添加以下成员变量
String? _sttResult;
bool _isRecognizing = false;

// 开始 STT 识别
Future<void> _startStt() async {
  if (!_isEngineInitialized) {
    ScaffoldMessenger.of(context).showSnackBar(
      const SnackBar(content: Text("请先初始化离线引擎")),
    );
    return;
  }
  if (_isRecognizing) return;

  setState(() {
    _isRecognizing = true;
    _sttResult = null;
  });

  // 调用 STT 识别(建议在子线程中执行,避免阻塞 UI)
  final String? result = await compute(_callStt, null);

  setState(() {
    _isRecognizing = false;
    _sttResult = result ?? "未识别到语音";
  });
}

// 子线程执行 STT 调用(避免 UI 阻塞)
static String? _callStt(void _) {
  return VoiceChannel.startStt();
}

// 在 build 方法中添加 STT 相关 UI
Column(
  children: [
    // ... 原有 TTS 相关 UI ...

    const Divider(height: 32),
    const Text("离线语音识别", style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold)),
    const SizedBox(height: 16),

    // 开始识别按钮
    ElevatedButton(
      onPressed: _isRecognizing ? null : _startStt,
      style: ElevatedButton.styleFrom(
        backgroundColor: _isRecognizing ? Colors.grey : Colors.green,
        padding: const EdgeInsets.symmetric(horizontal: 32, vertical: 16),
        textStyle: const TextStyle(fontSize: 18),
      ),
      child: Text(_isRecognizing ? "识别中..." : "开始语音识别"),
    ),
    const SizedBox(height: 16),

    // 识别结果展示
    if (_sttResult != null)
      Card(
        child: Padding(
          padding: const EdgeInsets.all(16.0),
          child: Text(
            "识别结果:$_sttResult",
            style: const TextStyle(fontSize: 16),
          ),
        ),
      ),
  ],
)

六、多语言适配进阶

6.1 多语言资源管理

  1. 离线资源按需加载

    • 工程内置常用语言(如中文、英文)的语音包 / 模型,减少初始安装包体积;

    • 其他语言(如日语、韩语)通过应用内下载模块动态获取,下载后解压至沙箱路径;

    • 示例:Flutter 端语言下载逻辑(lib/services/download_service.dart):

      dart

      复制代码
      import 'dart:io';
      import 'package:dio/dio.dart';
      import 'package:path_provider/path_provider.dart';
      
      class DownloadService {
        static final Dio _dio = Dio();
      
        // 下载离线语音包(TTS)
        static Future<String?> downloadTtsResource(String language) async {
          try {
            // 语音包下载地址(需自行搭建服务器)
            final String url = "https://example.com/tts_offline_$language.hap";
            final Directory appDir = await getApplicationDocumentsDirectory();
            final String savePath = "${appDir.path}/tts_offline_$language.hap";
      
            // 检查是否已下载
            final File file = File(savePath);
            if (await file.exists()) {
              return savePath;
            }
      
            // 下载文件
            await _dio.download(
              url,
              savePath,
              onReceiveProgress: (received, total) {
                final progress = (received / total * 100).toStringAsFixed(1);
                print("TTS 资源下载进度:$progress%");
              },
            );
      
            return savePath;
          } catch (e) {
            print("下载 TTS 资源失败:$e");
            return null;
          }
        }
      
        // 下载离线模型(STT)
        static Future<bool> downloadSttModel(String language) async {
          // 类似 TTS 下载逻辑,下载对应的语言模型、字典、声学模型文件
          // ...
        }
      }
  2. 语言切换逻辑优化

    • 切换语言时,先检查本地是否存在对应离线资源,不存在则触发下载;

    • 下载完成后,重新初始化 TTS/STT 引擎,确保资源加载生效;

    • 示例:优化 _changeLanguage 方法:

      dart

      复制代码
      void _changeLanguage(String newLanguage) async {
        setState(() {
          _language = newLanguage;
          _isEngineInitialized = false;
          _isLoading = true; // 显示加载状态
        });
      
        // 检查并下载 TTS 离线资源
        final String? ttsResourcePath = await DownloadService.downloadTtsResource(newLanguage);
        if (ttsResourcePath == null) {
          ScaffoldMessenger.of(context).showSnackBar(
            SnackBar(content: Text("下载 $newLanguage 语音包失败")),
          );
          setState(() => _isLoading = false);
          return;
        }
      
        // 检查并下载 STT 离线模型
        final bool sttModelReady = await DownloadService.downloadSttModel(newLanguage);
        if (!sttModelReady) {
          ScaffoldMessenger.of(context).showSnackBar(
            SnackBar(content: Text("下载 $newLanguage 识别模型失败")),
          );
          setState(() => _isLoading = false);
          return;
        }
      
        // 初始化引擎
        await _initEngine();
        setState(() => _isLoading = false);
      }

6.2 语言适配细节

  1. 文本编码处理:多语言文本需使用 UTF-8 编码,避免中文、日语等字符乱码;
  2. 语音包版本兼容 :确保下载的离线语音包版本与鸿蒙 SDK 版本匹配(参考 鸿蒙语音包版本说明);
  3. 识别词典扩展 :针对特定场景(如行业术语、专有名词),可自定义 PocketSphinx 词典文件(.dic),提升识别准确率。

七、性能优化与避坑指南

7.1 性能优化策略

  1. 资源占用优化

    • 离线语音包 / 模型压缩:使用 gzip 压缩资源文件,下载后解压,减少存储空间占用;

    • 按需释放资源:应用退到后台时,释放 TTS/STT 引擎资源,前台唤醒时重新初始化;

    • 示例:鸿蒙端资源释放逻辑(MainAbility.java):

      java

      运行

      复制代码
      @Override
      protected void onBackground() {
        super.onBackground();
        // 释放 TTS/STT 资源
        if (ttsManager != null) {
          ttsManager.release();
        }
        if (sttManager != null) {
          sttManager.release();
        }
      }
      
      @Override
      protected void onForeground(Intent intent) {
        super.onForeground(intent);
        // 重新初始化引擎
        if (ttsManager != null && sttManager != null) {
          ttsManager.init();
          sttManager.init();
        }
      }
  2. 响应速度优化

    • TTS 预加载:启动应用时预加载常用语言的 TTS 引擎,减少首次合成延迟;
    • STT 音频缓冲区优化:调整 AudioRecord 缓冲区大小(建议 16kHz 采样率下使用 32000 字节缓冲区),平衡延迟与识别准确率;
    • 异步处理:所有引擎初始化、语音合成 / 识别操作均在子线程执行,避免阻塞 UI 线程。

7.2 常见问题与解决方案

问题现象 可能原因 解决方案
TTS 合成无声音 1. 离线语音包未加载;2. 音量设置为 0;3. 权限未授予 1. 检查语音包路径是否正确;2. 确保音量 > 0;3. 动态申请 READ_MEDIA 权限
STT 识别准确率低 1. 模型文件不匹配;2. 采样率与引擎配置不一致;3. 环境噪音大 1. 确认模型与语言对应;2. 统一采样率为 16kHz;3. 开启 VAD(语音活动检测)
应用启动慢 离线资源过大,初始化耗时过长 1. 拆分资源,按需加载;2. 异步初始化引擎,显示启动页
崩溃报错:UnsatisfiedLinkError PocketSphinx .so 库未正确加载 1. 检查 jniLibs 目录结构;2. 确保 CPU 架构匹配;3. 重新编译 .so
多语言切换后引擎初始化失败 新语言资源未下载完成或路径错误 1. 切换前检查资源完整性;2. 打印资源路径日志,排查路径错误

八、总结与展望

本文详细介绍了鸿蒙 + Flutter 混合开发架构下,TTS/STT 全离线部署与多语言适配的实现方案,核心亮点包括:

  1. 基于 MethodChannel 实现 Flutter 与鸿蒙原生的高效通信,兼顾跨平台一致性与原生性能;
  2. 整合鸿蒙原生 TTS 引擎与 PocketSphinx 开源 STT 引擎,实现全场景离线语音交互;
  3. 提供多语言资源按需加载、动态切换方案,适配全球化应用需求;
  4. 包含完整的代码示例、编译脚本与避坑指南,降低开发者落地成本。

未来优化方向

  1. 识别准确率提升:集成更先进的离线 STT 引擎(如 Whisper 轻量版),通过模型量化减少资源占用;
  2. 语音唤醒功能:添加离线语音唤醒(如 "小艺小艺"),实现零交互启动语音交互;
  3. 个性化定制:支持用户自定义语音合成音色、识别词典,适配垂直行业场景;
  4. 分布式语音交互:利用鸿蒙分布式能力,实现多设备间的离线语音协同(如手机录音、平板识别)。

参考资料

  1. 鸿蒙 TTS 开发指南
  2. Flutter MethodChannel 官方文档
  3. PocketSphinx 官方文档
  4. 鸿蒙 NDK 开发指南
  5. ISO 639-1 语言代码标准

本文代码已上传至 GitHub 仓库:harmonyos-flutter-voice-offline,欢迎 Star 关注!如有问题或优化建议,欢迎在评论区留言交流~

相关推荐
克喵的水银蛇2 小时前
Flutter 通用进度条组件:ProgressWidget 一键实现多类型进度展示
flutter
庄雨山3 小时前
Flutter 与开源鸿蒙 底部弹窗多项选择实现方案全解析
flutter·开源·openharmonyos
笨小孩7873 小时前
Flutter深度解析:从核心原理到实战开发全攻略
flutter
晚霞的不甘3 小时前
[鸿蒙2025领航者闯关]鸿蒙实战高阶:Stage模型架构与元服务开发深度解析
华为·架构·harmonyos
笨小孩7873 小时前
Flutter深度解析:从原理到实战的跨平台开发指南
flutter
汉堡黄•᷄ࡇ•᷅3 小时前
鸿蒙开发:案例集合List:多列表相互拖拽(删除/插入,偏移动效)(微暇)
华为·harmonyos·鸿蒙·鸿蒙系统
飛6793 小时前
Flutter 状态管理深度实战:从零封装轻量级响应式状态管理器,告别 Provider/Bloc 的臃肿与复杂
前端·javascript·flutter
tangweiguo030519873 小时前
Flutter iOS 风格弹框组件封装
flutter
weixin_4424722213 小时前
12A高效同步降压转换器在便携设备、网络系统与分布式电源中与汽车电子工业控制的WD5030K应用与设计指南
分布式·汽车·工业控制·电路设计·同步降压·12a·qfn5x5