本文记录 kmp-ohos-tts 接入 OpenHarmony 语音识别能力的完整过程,覆盖 KMP/CMP 工程盘点、ohosArm64 目标、Kotlin/Native 动态库、C ABI/N-API 桥接、AudioKit 麦克风采集、Core Speech Kit 识别、ArkUI 真机页面、签名 HAP 和真机验收。

这次适配保留 Kotlin 侧的识别结果模型、会话归约、文本归一化和自检逻辑,再由 ArkTS 调用 OpenHarmony 的 SpeechRecognitionEngineAudioCapturer。页面展示的是麦克风实时回调,不是写死在页面里的演示句子;停止识别后,文本还会经过 Kotlin/Native 共享模型统一成 TtsResult

项目地址: AtomGit/oh-tpc/kmp-ohos-tts

开发工具: 华为云码道

一、背景

1.1 为什么做开源鸿蒙平台 KMP/CMP 适配

语音转文字看起来只是“打开麦克风并显示一句话”,但在 OpenHarmony 上要走通一条完整的跨语言链路:应用必须先获得麦克风权限,AudioKit 要稳定输出指定格式的 PCM,Core Speech Kit 要收到连续音频,识别结果要从 ArkTS 回调进入共享 Kotlin 模型,最后才能在 ArkUI 中呈现结果。

如果只重新写一个 ArkTS 页面,页面即使能显示按钮,也无法证明 KMP 共享模型已经在 OpenHarmony ARM64 设备上运行。适配需要同时解决下面的问题:

障碍具体问题
目标缺失KMP 模块默认面向 JVM,必须加入 ohosArm64() 才能编译 OpenHarmony KLIB。
工具链不一致Kotlin/Native、OpenHarmony LLVM、Native SDK、Gradle 和 Hvigor 需要使用兼容版本。
语言边界不同ArkTS 不能直接持有 Kotlin data class,必须经过 C ABI、C++ N-API 和 JSON。
音频格式严格识别引擎要求 PCM、16 kHz、单声道、16 bit,格式不匹配时可能无法开始或没有结果。
生命周期复杂readData 回调、识别引擎、录音器的创建、启动、停止和释放必须保持正确顺序。
权限与系统状态权限被拒绝、其他应用占用麦克风或设备不支持 Core Speech Kit,都会表现为启动失败。
结果契约中间结果和最终结果的字段、置信度、语言和会话模式需要在共享层统一。
交付链路复杂Native 动态库、CMake、N-API、HAP、签名、设备安装和真机日志要分别验证。

因此,本项目把边界放在三个位置:Kotlin/Native 负责共享模型,C ABI/N-API 负责稳定传输,ArkTS 负责设备能力、页面状态和实时回调。平台差异留在 example/ohosApptts 模块不依赖 OpenHarmony API。

1.2 库提供的能力

tts 公共模块提供以下能力:

  • TtsLocale:中文、英文和日文的语言提示枚举;
  • TtsSessionMode:单句 SHORT 和连续听写 LONG 两种会话模式;
  • TtsSegment:包含文本、置信度和稳定性的识别分段;
  • TtsResult:包含会话 id、语言、模式、分段和耗时的不可变结果;
  • TtsEngine.adopt:把平台返回的一段文本归一化为共享结果;
  • TtsEngine.keepFinal:只保留最终分段;
  • TtsEngine.reduce:对确定性示例会话执行动作归约;
  • TtsEngine.runChecks:在 JVM、Kotlin/Native 和真机页面复用同一组自检;
  • TtsResult.toJson:生成可安全交给 ArkTS 解析的 JSON。

内置的确定性目录包含三个场景,用来测试共享模型,而不是代替真机语音识别:

场景语言模式内容
demo-commandZH_CNSHORT灯光和提醒等中文指令
demo-noteZH_CNLONG中文会议记录
demo-meetingEN_USLONG英文会议示例

真机识别时,平台侧产生的中间文本通过回调直接更新页面;点击“停止识别”后,页面调用 TtsAdopt,把最终文本交给 TtsEngine.adopt,再回显统一的 TtsResult

1.3 实现适配

维度要求
代码复用结果模型、置信度约束、文本归一化、状态归约、JSON 和自检由 Kotlin 共享。
平台目标ttsexample/sharedexample/nativeApp 加入 ohosArm64
桥接稳定使用五个 C ABI 符号和 JSON,避免把 Kotlin 对象地址交给 ArkTS。
音频接入通过 AudioCapturer 采集 16 kHz、单声道、16 bit PCM,再调用 writeAudio
识别接入通过 @kit.CoreSpeechKit 创建引擎、注册监听器并接收 onResult
权限安全在创建录音器前检查并请求 ohos.permission.MICROPHONE
可测试JVM 测试、Native 链接、N-API 加载、HAP 构建和真机启动分别验收。
仓库规范项目说明、适配文章、效果图和源码链接统一使用 AtomGit。

example/ohosApp 是真机验收宿主,公共 API 仍然保持平台无关。其他 KMP/CMP 应用可以复用 TtsResultTtsEngine.adopt,再自行决定如何连接平台识别器。

二、实现路线图

第 1 阶段:项目初始化     ── 盘点 tts、example/shared 和 OpenHarmony 宿主边界
第 2 阶段:目标与依赖打通 ── 加入 ohosArm64、Native 动态库和独立 Stage 工程
第 3 阶段:结果与序列化   ── 建立 TtsResult、分段模型、adopt 和 JSON 契约
第 4 阶段:原生桥接       ── Kotlin/Native C ABI、C++ N-API、内存释放和类型检查
第 5 阶段:系统能力封装   ── 权限、AudioCapturer、Core Speech Kit 和实时回调
第 6 阶段:示例与验证     ── ArkUI 页面、HAP 签名、设备安装和真机效果图

每个阶段都使用真实产物作为下一阶段输入:先用 JVM 验证共享模型,再把相同的 commonMain 链接为 ARM64 动态库,随后由 CMake 和 N-API 加载到 Stage 工程,最后安装签名 HAP 在真机上采集和识别语音。

三、逐步实现过程

第 1 阶段:项目初始化

1.1 盘点公共 API 和工程边界

项目采用 KMP/CMP 常见的分层方式:

tts/                         KMP 识别结果模型、引擎、JSON 和自检
example/shared/              共享示例门面和 JVM 测试
example/nativeApp/           ohosArm64 Kotlin/Native 动态库
example/ohosApp/             DevEco Stage 工程、ArkUI 页面和 N-API
docs/images/                 真机效果图
README.OpenHarmony*.md       OpenHarmony 构建和排障说明

example 是独立 Gradle 工程,不把 DevEco 工程强行作为根工程的 Kotlin 子模块。这样可以分别执行 Gradle、Kotlin/Native 和 Hvigor,也可以把 ohosApp 单独交给 DevEco Studio 配置签名。

1.2 固定工具链和版本矩阵

本项目的 Gradle 配置采用以下约定:

项目配置用途
Kotlin Multiplatform2.2.21-1.0.0JVM、Kotlin/Native 和 KLIB
KMP JVM toolchainJDK 17ttsexample/shared 的 JVM 编译
Native 示例 toolchainJDK 21example/nativeApp 的 Native 任务
OpenHarmony 目标ohosArm64ARM64 真机动态库
HAP ABIarm64-v8aStage 工程加载 Native 库
系统能力Core Speech Kit / AudioKit识别引擎与麦克风采集

执行 Gradle 脚本前先确认 JDK:

java -version
./gradlew :tts:tasks --all

Native 编译器和 DevEco Studio 的 SDK 位置应以本机安装为准。API 版本满足要求只说明工程可以编译和安装,是否能返回识别文本仍然需要真机验证。

1.3 创建 OpenHarmony 示例目录

示例页面围绕“权限、开始识别、实时文本、停止识别、最终结果和引擎自检”组织:

标题区          端侧语音转文字 / Core Speech Kit 真实拾音
操作区          开始识别 / 停止识别
状态区          点击提示、正在聆听、未识别或识别完成
实时区          识别中的中间文本
结果区          最终全文、平均置信度和分段详情
自检区          Kotlin/Native 引擎自检项
错误区          权限、音频、引擎和 adopt 错误

页面加载时只运行共享自检,不会提前打开麦克风。只有点击“开始识别”后,ArkTS 才检查权限、创建识别引擎和录音器。

第 2 阶段:目标与依赖打通

2.1 加入 ohosArm64 目标

公共模块同时保留 JVM 测试和 OpenHarmony Native 目标:

plugins {
  kotlin("multiplatform")
}

kotlin {
  explicitApi()
  jvm()
  jvmToolchain(17)
  ohosArm64()

  sourceSets {
    commonTest.dependencies {
      implementation(kotlin("test"))
    }
  }
}

JVM 目标让模型规则可以快速测试;ohosArm64 则把同一份 commonMain 代码编译成 OpenHarmony KLIB,供示例动态库继续链接。

2.2 Native focused build 的作用

example/nativeApp 构建一个受到 linker map 约束的 shared library:

kotlin {
  ohosArm64 {
    binaries.sharedLib {
      baseName = "ohos_tts"
      linkerOpts(
        "--entry=0",
        "--version-script=${project.file("src/ohosArm64Main/linker/shared-library.map")}",
      )
      linkerOpts("-lace_napi.z", "-luv", "-lhilog_ndk.z")
    }
  }
  sourceSets {
    commonMain.dependencies { implementation(project(":shared")) }
  }
}

链接器只导出下面五个 C ABI 符号:

TtsResultGet
TtsResultReduce
TtsAdopt
TtsRunChecks
TtsFree

prepareOhos 任务会在 Native 链接完成后,把 libohos_tts.so 和生成的头文件复制到 example/ohosApp/entry,CMake 再将它们链接到 N-API 模块。

2.3 配置 CMake 和 Stage 工程

CMakeLists.txt 将 Kotlin/Native 动态库作为 imported library,并让 entry N-API 模块链接它:

add_library(ohos_tts SHARED IMPORTED)
set_target_properties(ohos_tts PROPERTIES
  IMPORTED_LOCATION "${CMAKE_CURRENT_SOURCE_DIR}/../../../libs/arm64-v8a/libohos_tts.so")

add_library(entry SHARED napi_init.cpp)
target_include_directories(entry PRIVATE "${CMAKE_CURRENT_SOURCE_DIR}/include")
target_link_libraries(entry PRIVATE ohos_tts libace_napi.z.so)

build-profile.json5 固定 arm64-v8a,这样 HAP 中的 ABI 与 ohosArm64 产物一致。动态库、签名 HAP 和本机证书属于构建产物,开发机可以根据源码重新生成。

第 3 阶段:结果与序列化

3.1 为什么需要统一 JSON 契约

ArkTS、C++ 和 Kotlin/Native 不能直接共享 Kotlin 对象,因此跨语言边界只传输 UTF-8 字符串:

AudioCapturer PCM
        ↓
Core Speech Kit onResult
        ↓ live text
ArkUI partialText
        ↓ stop
N-API TtsAdopt()
        ↓ C ABI
Kotlin TtsEngine.adopt()
        ↓ JSON TtsResult
ArkUI session / segments

中间识别文本不需要先构造成 Kotlin 对象;结束时只把完整文本、语言、模式、置信度、耗时和修订号交给 TtsAdopt。这样设备 API 留在平台层,KMP 共享模型可以在 JVM 和 Native 中独立测试。

3.2 TtsSegmentTtsResult

共享模型在构造时校验非法数据:

public data class TtsSegment(
  val text: String,
  val confidence: Double,
  val stability: TtsSegmentStability,
) {
  init {
    require(text.isNotEmpty()) { "Segment text must not be empty" }
    require(confidence in 0.0..1.0) { "Confidence must be between 0.0 and 1.0" }
  }
}

public data class TtsResult(
  val id: String,
  val locale: TtsLocale,
  val mode: TtsSessionMode,
  val segments: List<TtsSegment>,
  val durationMs: Long,
) {
  init {
    require(id.isNotBlank()) { "Result id must not be blank" }
    require(durationMs >= 0) { "Duration must be non-negative" }
    require(segments.all { it.isFinal }) { "Result segments must be final" }
  }

  public val fullText: String get() = segments.joinToString(" ") { it.text }
}

TtsResult 只接受最终分段;实时中间结果由 ArkUI 以 partialText 展示,停止后再进入共享最终模型。这样页面的“正在识别”和“识别完成”不会混用两种数据状态。

3.3 adopt 归一化平台文本

TtsEngine.adopt 去除首尾空白,把换行拆成多个最终分段,并把置信度限制在 0.0..1.0

public fun adopt(
  utterance: String,
  locale: TtsLocale,
  mode: TtsSessionMode,
  confidence: Double = 1.0,
  durationMs: Long = 0,
  revision: Int = 0,
): TtsResult {
  val trimmed = utterance.trim()
  require(trimmed.isNotEmpty()) { "Utterance must not be blank" }
  val segments = trimmed.lines()
    .map { it.trim() }
    .filter { it.isNotEmpty() }
    .map { TtsSegment(it, confidence.coerceIn(0.0, 1.0), TtsSegmentStability.FINAL) }
  return TtsResult("live-$revision", locale, mode, segments, durationMs)
}
3.4 JSON 编码

TtsResult.toJson 输出稳定字段,jsonQuote 负责处理引号、反斜杠、换行和控制字符:

public fun TtsResult.toJson(): String = buildString {
  append("{\"id\":${id.jsonQuote()},\"locale\":${locale.name.jsonQuote()}")
  append(",\"mode\":${mode.name.jsonQuote()}")
  append(",\"fullText\":${fullText.jsonQuote()}")
  append(",\"averageConfidence\":$averageConfidence")
  append(",\"durationMs\":$durationMs,\"segments\":[")
  append(segments.joinToString { segment ->
    "{\"text\":${segment.text.jsonQuote()},\"confidence\":${segment.confidence}," +
      "\"stability\":${segment.stability.name.jsonQuote()}}"
  })
  append("]}")
}

第 4 阶段:原生桥接

4.1 Kotlin/Native C ABI 入口

NativeBridge.kt 不把 Kotlin 字符串指针直接暴露为长期对象,而是为每次响应分配一段以 0 结尾的 Native buffer:

private fun textBuffer(text: String): CPointer<ByteVar> {
  val bytes = text.encodeToByteArray()
  val buffer = nativeHeap.allocArray<ByteVar>(bytes.size + 1)
  bytes.forEachIndexed { index, byte -> buffer[index] = byte }
  buffer[bytes.size] = 0
  return buffer
}

private inline fun response(block: () -> String): CPointer<ByteVar> = try {
  textBuffer(block())
} catch (error: Throwable) {
  textBuffer("{\"error\":${(error.message ?: "Native error").jsonQuote()}}")
}

@CName("TtsAdopt")
public fun adoptNative(
  utterance: String,
  locale: String,
  mode: String,
  confidence: Double,
  durationMs: Long,
  revision: Int,
): CPointer<ByteVar> = response {
  val localeValue = TtsLocale.entries.firstOrNull { it.name == locale } ?: TtsLocale.ZH_CN
  val modeValue = TtsSessionMode.entries.firstOrNull { it.name == mode } ?: TtsSessionMode.SHORT
  TtsExamples.adopt(utterance, localeValue, modeValue, confidence, durationMs, revision).toJson()
}

@CName("TtsFree")
public fun freeNative(pointer: CPointer<ByteVar>?) {
  if (pointer != null) nativeHeap.free(pointer)
}

异常在 Native 边界被转换成 {"error":"..."},由 N-API 再抛给 ArkTS。这样非法输入不会直接导致页面崩溃,调用方也能看到具体错误。

4.2 C++ N-API 导出

napi_init.cpp 注册 libentry.so 的方法,把 ArkTS 参数转换为 C ABI 参数,并在读取返回值后调用 TtsFree

static napi_value AdoptResult(napi_env env, napi_callback_info info) {
    // 读取 utterance、locale、mode、confidence、durationMs、revision。
    // 调用 TtsAdopt,复制 UTF-8 JSON,最后释放 Native buffer。
    return MakeJsonResult(env, TtsAdopt(...));
}

static napi_value Init(napi_env env, napi_value exports) {
    napi_property_descriptor methods[] = {
        {"getTtsResult", nullptr, GetTtsResult, nullptr, nullptr, nullptr, napi_default, nullptr},
        {"reduceTtsResult", nullptr, ReduceTtsResult, nullptr, nullptr, nullptr, napi_default, nullptr},
        {"getChecks", nullptr, RunChecks, nullptr, nullptr, nullptr, napi_default, nullptr},
        {"adoptResult", nullptr, AdoptResult, nullptr, nullptr, nullptr, napi_default, nullptr},
    };
    napi_define_properties(env, exports, sizeof(methods) / sizeof(methods[0]), methods);
    return exports;
}

实际的类型检查和参数读取都集中在 C++ 中,业务规则仍然由 TtsEngine 执行。ArkTS 侧只需要导入类型声明:

export const getTtsResult: (index: number, revision: number) => string;
export const reduceTtsResult: (index: number, action: string, revision: number) => string;
export const getChecks: () => string;
export const adoptResult: (
  utterance: string,
  locale: string,
  mode: string,
  confidence: number,
  durationMs: number,
  revision: number,
) => string;

第 5 阶段:系统能力封装

5.1 声明和请求麦克风权限

module.json5 声明 ohos.permission.MICROPHONE,页面开始识别前通过 @ohos.abilityAccessCtrl 检查 token;未授权时调用 requestPermissionsFromUser。用户拒绝后,页面显示权限错误,不把它包装成模糊的音频初始化失败。

const permission = 'ohos.permission.MICROPHONE';
const tokenId = this.context.applicationInfo.accessTokenId;
const current = atManager.checkAccessTokenSync(tokenId, permission);
if (current !== abilityAccessCtrl.GrantStatus.PERMISSION_GRANTED) {
  const result = await atManager.requestPermissionsFromUser(this.context, [permission]);
  if (result.authResults.length === 0 ||
      result.authResults[0] !== abilityAccessCtrl.GrantStatus.PERMISSION_GRANTED) {
    throw new Error('麦克风权限未授权,请在系统设置中允许后重试');
  }
}
5.2 创建 Core Speech Kit 引擎

识别客户端使用 @kit.CoreSpeechKit 创建中文识别引擎,注册 RecognitionListener,再开始监听:

const engine = await speechRecognizer.createEngine({
  language: 'zh-CN',
  online: 1,
} as speechRecognizer.CreateEngineParams);

engine.setListener({
  onStart: (_sessionId, _eventMessage) => {},
  onComplete: (_sessionId, _eventMessage) => {
    if (this.session) this.session.ended = true;
  },
  onError: (_sessionId, code, message) => {
    this.errorListener?.(code, message);
  },
  onResult: (_sessionId, result) => {
    if (result.result && result.result.length > 0) {
      this.emit([{ text: result.result, confidence: 1, isFinal: result.isFinal }]);
    }
  },
} as speechRecognizer.RecognitionListener);

engine.startListening({
  sessionId: 'kmp-ohos-tts-live',
  audioInfo: {
    audioType: 'pcm', sampleRate: 16000,
    soundChannel: 1, sampleBit: 16,
  },
} as speechRecognizer.StartParams);

当前示例固定中文 zh-CN,应用要支持多语言时,可以把语言参数提升到页面状态并在开始识别时传入。

5.3 创建 AudioCapturer 并转发 PCM

AudioKit 的录音器使用与识别引擎一致的音频格式:

const streamInfo: audio.AudioStreamInfo = {
  samplingRate: audio.AudioSamplingRate.SAMPLE_RATE_16000,
  channels: audio.AudioChannel.CHANNEL_1,
  sampleFormat: audio.AudioSampleFormat.SAMPLE_FORMAT_S16LE,
  encodingType: audio.AudioEncodingType.ENCODING_TYPE_RAW,
};
const capturerInfo: audio.AudioCapturerInfo = {
  source: audio.SourceType.SOURCE_TYPE_MIC,
  capturerFlags: 0,
};
const capturer = await audio.createAudioCapturer({ streamInfo, capturerInfo });

关键顺序是先注册 readData 回调,再调用 capturer.start()

capturer.on('readData', (buffer: ArrayBuffer) => {
  if (!this.session || this.session.ended) return;
  engine.writeAudio('kmp-ohos-tts-live', new Uint8Array(buffer));
});
await capturer.start();

如果在 start() 之后才注册回调,录音器可能已经进入工作状态但没有可用的数据消费者,常见表现就是点击后报启动错误或一直没有识别结果。

5.4 停止和释放

停止时先结束识别会话,再关闭引擎,随后停止并释放录音器;无论哪一步失败,都尝试完成剩余的清理:

session.engine.finish('kmp-ohos-tts-live');
session.engine.shutdown();
await session.capturer.stop();
await session.capturer.release();

SpeechRecognizerClientsession 是否为空表示运行状态,用 ended 防止识别引擎已经完成后继续发送 PCM。

第 6 阶段:ArkUI 页面和共享模型落地

6.1 开始识别

点击按钮后页面先清空旧结果,显示“正在聆听…”,再串行执行权限检查和识别器启动:

private async startListening(): Promise<void> {
  this.errorText = '';
  this.partialText = '';
  this.session = null;
  this.listening = true;
  this.statusText = '正在聆听…';
  try {
    await this.ensureMicrophonePermission();
    await this.recognizer.start(
      (segments) => this.onLiveSegments(segments),
      (code, message) => this.onRecognizerError(code, message),
    );
  } catch (error) {
    this.listening = false;
    this.statusText = '点击「开始识别」并说话';
    this.errorText = `启动识别失败:${errorText(error)}`;
  }
}

这样“启动识别失败”会保留真实异常文本,例如权限未授权、AudioCapturer 创建失败或 Core Speech Kit 不可用,便于真机排查。

6.2 实时结果和最终结果

识别回调只更新 partialText

private onLiveSegments(segments: LiveSegment[]): void {
  if (segments.length === 0) return;
  const liveText = segments.map((item) => item.text).join(' ');
  if (liveText.length > 0) this.partialText = liveText;
}

点击停止后,页面获取运行时长并调用 Native adoptResult

const text = this.partialText;
const durationMs = await this.recognizer.stop();
if (text.length === 0) {
  this.statusText = '未识别到语音,请靠近麦克风重试';
  return;
}
const raw = adoptResult(text, 'ZH_CN', 'SHORT', 1.0, durationMs, this.revision + 1);
this.session = JSON.parse(raw) as TtsSessionView;
this.revision += 1;

最终结果由共享 Kotlin 模型生成,页面展示全文、平均置信度、分段文本和稳定性。

四、完整代码对照

4.1 整体架构

设备麦克风
    │ AudioKit AudioCapturer
    ▼
Core Speech Kit SpeechRecognitionEngine
    │ onResult / onError
    ▼
TtsClient.ets -> Index.ets
    │ N-API
    ▼
libentry.so
    │ C ABI
    ▼
libohos_tts.so
    │ Kotlin/Native
    ▼
TtsEngine.adopt -> TtsResult -> JSON
    │
    ▼
ArkUI 最终结果页面

4.2 文件清单

文件职责
tts/src/commonMain/kotlin/com/ohos/tts/TtsResult.kt语言、模式、分段和结果模型
tts/src/commonMain/kotlin/com/ohos/tts/TtsEngine.kt示例目录、归约、adopt 和共享自检
tts/src/commonMain/kotlin/com/ohos/tts/TtsJson.ktJSON 编码和字符串转义
example/shared/src/commonMain/.../TtsExamples.ktJVM 与 Native 共用的示例门面
example/nativeApp/.../NativeBridge.ktC ABI、JSON 返回和内存释放
example/ohosApp/.../tts/TtsClient.etsCore Speech Kit、AudioKit 和识别生命周期
example/ohosApp/.../tts/NativeTts.etsArkTS 到 N-API 的薄封装
example/ohosApp/.../pages/Index.ets权限、状态、实时文本和结果页面
example/ohosApp/entry/src/main/cpp/napi_init.cppN-API 导出和参数检查
example/nativeApp/.../linker/shared-library.mapNative 导出符号白名单
docs/images/tts-openharmony-demo.jpg真机运行效果图

4.3 关键 API 对照

层次API作用
KotlinTtsEngine.adopt将平台文本转换为共享最终结果
KotlinTtsEngine.reduce执行确定性会话动作归约
KotlinTtsEngine.runChecks返回页面自检项
NativeTtsAdopt通过 C ABI 返回一段结果 JSON
NativeTtsFree释放 Native 返回 buffer
N-APIadoptResult向 ArkTS 暴露文本归一化方法
ArkTSSpeechRecognizerClient.start创建识别器和录音器并启动数据流
ArkTSSpeechRecognizerClient.stop完成识别并释放设备资源

4.4 ArkTS 与 Kotlin 的边界

ArkTS 负责设备能力和界面生命周期:

const durationMs = await this.recognizer.stop();
const raw = adoptResult(text, 'ZH_CN', 'SHORT', 1.0, durationMs, revision);

Kotlin 负责业务含义和不可变数据:

val result = TtsEngine.adopt(
  utterance = text,
  locale = TtsLocale.ZH_CN,
  mode = TtsSessionMode.SHORT,
  durationMs = durationMs,
  revision = revision,
)

两者之间只传输字符串、数字和 JSON,不传输 Kotlin 对象、ArkTS class 实例或未释放的 Native 指针。

五、关键决策说明

决策 1:把 ohosArm64 加入共享构建约定

只有真正链接 ohosArm64 动态库,才能证明共享 Kotlin 代码可以进入 OpenHarmony ARM64 运行时。只在 JVM 上跑测试不能替代 Native 链接验收。

决策 2:平台识别和共享归一化分层

权限、麦克风、音频缓冲和识别回调属于设备能力,留在 ArkTS;文本拆分、置信度约束、结果 id 和 JSON 属于共享模型,留在 Kotlin。这样 Android 或桌面宿主可以替换自己的识别器,而不改变 TtsResult 契约。

决策 3:JSON 作为跨语言数据契约

JSON 可读、易调试,并且不会暴露 Kotlin/Native 对象布局。后续增加字段时,可以在不改变 ArkTS 业务对象地址的情况下扩展协议。

决策 4:桥接层只开放五个 C ABI 入口

获取示例、归约、adopt、自检和释放已经覆盖示例所需能力。减少 ABI 符号数量,可以降低 Native 生命周期和版本兼容风险。

决策 5:readData 回调必须先于 start

语音识别依赖连续 PCM。把回调注册放在 capturer.start() 之前,可以确保录音器一开始工作就有数据消费者,避免“已经显示正在聆听但引擎没有收到音频”的问题。

决策 6:把自动测试和真机验证分开

JVM 测试验证结果模型,Native 链接验证 ABI,Hvigor 验证 HAP,真机验证权限、麦克风和 Core Speech Kit 回调。每一层都有明确的失败边界,编译通过不会被误认为识别链路已经可用。

六、测试与验证

6.1 测试环境

建议使用:

  • macOS 或支持 OpenHarmony 工具链的开发机;
  • JDK 17 运行 ttsexample/shared 的 JVM 任务;
  • JDK 21 运行 Native 示例任务;
  • DevEco Studio 及 OpenHarmony ARM64 Native SDK;
  • 已配置签名的 Stage 工程;
  • USB 连接、开启开发者模式和 USB 调试的 OpenHarmony/HarmonyOS 手机或平板。

6.2 静态检查与单元测试

根工程测试:

./gradlew :tts:build

示例工程测试和 Native 链接:

cd example
./gradlew :shared:jvmTest :nativeApp:linkDebugSharedOhosArm64

测试覆盖:

  • 三个示例会话和语言模式;
  • 文本为空、置信度越界、非最终分段等非法输入;
  • adopt 的换行拆分和修订号;
  • JSON 引号、反斜杠和换行转义;
  • keepFinalreduce 行为;
  • 共享自检项数量和结果。

6.3 Native 和 HAP 验证

先生成 OpenHarmony 动态库和头文件,再构建 Stage 应用:

cd example
./gradlew :nativeApp:prepareOhos

cd ohosApp
export NODE_HOME=/Applications/DevEco-Studio.app/Contents/tools/node
export DEVECO_SDK_HOME=/Applications/DevEco-Studio.app/Contents/sdk

/Applications/DevEco-Studio.app/Contents/tools/hvigor/bin/hvigorw \
  assembleApp --no-daemon --no-parallel

签名 HAP 输出位置:

example/ohosApp/entry/build/default/outputs/default/entry-default-signed.hap

6.4 安装和启动

DEVICE='your-device-id'
HAP='example/ohosApp/entry/build/default/outputs/default/entry-default-signed.hap'

hdc -t "$DEVICE" install -r "$HAP"
hdc -t "$DEVICE" shell aa start \
  -a EntryAbility \
  -b com.ohos.tts.sample

Bundle 是 com.ohos.tts.sample,入口 Ability 是 EntryAbility。确认设备状态:

hdc list targets
hdc -t "$DEVICE" shell aa dump -l

6.5 功能验证用例

用例 1:默认页面和共享自检

启动应用后显示“端侧语音转文字”和“引擎自检 通过”。自检由 Kotlin/Native 返回,覆盖三个场景、分段存在、置信度范围、会话 id 唯一、最终分段保留和新状态生成。

用例 2:首次启动权限

点击“开始识别”,系统弹出麦克风授权窗口。允许后应用创建 SpeechRecognitionEngineAudioCapturer;拒绝后页面回到开始状态并显示权限错误。

用例 3:实时识别

保持页面显示“正在聆听…”,对着设备麦克风说普通话。onResult 回调到达后,实时文本区域应更新,不需要等待点击停止。

用例 4:停止并归一化

点击“停止识别”,应用停止音频、结束识别会话、释放录音器,再通过 TtsAdopt 生成最终 JSON。页面显示全文、平均置信度、分段数和耗时。

用例 5:没有识别文本

开始后保持静音,再点击停止。页面显示“未识别到语音,请靠近麦克风重试”,不会调用 adopt 生成空结果。

用例 6:识别异常

Core Speech Kit 回调错误时,页面显示错误码和错误信息;Native 或 JSON 解析异常则显示对应的 adopt 失败 或自检失败文本。

6.6 验证结论

通过 JVM、Kotlin/Native 链接、CMake/N-API 加载和签名 HAP 构建后,还需要在目标真机上确认权限弹窗、隐私指示、PCM 回调、onResult 回调和最终结果回显。只有这些步骤全部通过,才算完成从麦克风到 KMP 共享结果的适配。

七、运行效果

7.1 真机截图

在这里插入图片描述

截图中的页面包含:

  • 深色 ArkUI 页面和“端侧语音转文字”标题;
  • 红色“停止识别”按钮,表示当前会话正在运行;
  • “正在聆听…”状态和实时识别文本;
  • “引擎自检 通过”提示;
  • 六项由 Kotlin/Native 返回的共享模型检查结果。

效果图使用仓库内的相对路径引用,文章复制到 AtomGit 后仍然可以直接加载,不依赖外部图床。

7.2 命令速查

# 共享模块构建
./gradlew :tts:build

# 示例测试和 Native 动态库
(cd example && ./gradlew :shared:jvmTest :nativeApp:prepareOhos)

# 构建签名 HAP
cd example/ohosApp
export NODE_HOME=/Applications/DevEco-Studio.app/Contents/tools/node
export DEVECO_SDK_HOME=/Applications/DevEco-Studio.app/Contents/sdk
/Applications/DevEco-Studio.app/Contents/tools/hvigor/bin/hvigorw \
  assembleApp --no-daemon --no-parallel

# 安装并启动
DEVICE='<设备序列号>'
HAP='entry/build/default/outputs/default/entry-default-signed.hap'
hdc -t "$DEVICE" install -r "$HAP"
hdc -t "$DEVICE" shell aa start -a EntryAbility -b com.ohos.tts.sample

八、遗留问题与改进方向

8.1 踩坑复盘

  1. 只改 ArkTS 页面不算 KMP 适配:必须把共享模型编译成 ohosArm64 动态库,并从 N-API 实际调用。
  2. 权限检查要早于录音器创建:权限拒绝应在页面显示明确原因,不能等到 AudioCapturer.start() 才暴露模糊错误。
  3. 音频格式必须完全一致:Core Speech Kit 的 audioInfo 与 AudioKit 的 streamInfo 都使用 16 kHz、单声道、16 bit PCM。
  4. 回调注册要早于启动readDatacapturer.start() 之后注册,可能导致没有 PCM 送进引擎。
  5. Native buffer 必须释放:每次 C ABI 返回的字符串都由调用侧读取后通过 TtsFree 释放。
  6. 中间结果不能直接当最终结果:只有停止后才调用 TtsEngine.adopt 生成满足 TtsResult 约束的最终分段。

8.2 已知问题

  • 当前示例固定识别语言为 zh-CN,页面没有提供语言切换控件;
  • Core Speech Kit 创建参数中的 online 使用当前示例配置,实际服务能力受设备系统和网络状态影响;
  • onResult 回调中的实时文本由页面直接覆盖,复杂场景还需要按分段 id 合并中间结果;
  • 识别引擎结束时页面只标记会话结束,业务可以进一步增加自动停止和状态提示;
  • 真机签名配置与设备绑定,文章中的命令不能代替开发者本机的证书和 Profile;
  • 不同系统版本的 Core Speech Kit 错误码和提示文本可能不同。

8.3 未来优化方向

  • 将语言、在线/离线模式和会话模式提升为可配置的共享请求;
  • 将实时识别封装为 Flow<TtsSegment> 或事件流,统一中间结果合并策略;
  • 增加权限状态、引擎状态、录音状态和完成状态的显式 reducer;
  • 为 Android、桌面和 iOS 提供平台识别器适配,复用相同的 TtsResult
  • 在持续集成中加入 ohosArm64 链接、N-API 编译和未签名 HAP 构建;
  • 增加真机自动化脚本,采集权限、启动、回调和释放阶段的 HILOG。

九、总结

9.1 核心难点回顾

本次适配真正需要处理的不是一个 startListening 调用,而是一条完整的跨端链路:

麦克风权限
    → AudioKit PCM 采集
    → Core Speech Kit 识别
    → ArkTS 实时回调
    → C++ N-API
    → Kotlin/Native C ABI
    → TtsEngine.adopt
    → TtsResult JSON
    → ArkUI 最终结果

9.2 封装层次

  • KMP 层:定义语言、模式、分段、结果、归一化、归约和 JSON;
  • Native 层:生成 ARM64 动态库,并通过 C ABI 输出有限入口;
  • N-API 层:完成参数检查、字符串复制、错误转换和内存释放;
  • ArkTS 层:管理权限、识别引擎、录音器、实时文本、停止和异常状态;
  • DevEco 层:完成 CMake、HAP、签名、安装和真机运行。

9.3 三条经验

  1. 先让共享模型在 JVM 和 Native 通过,再接入 ArkUI 设备能力;
  2. 用 JSON 和少量 C ABI 代替跨语言对象传递,明确所有权和释放责任;
  3. 把权限、音频格式、回调顺序、HAP 构建和真机识别分别记录,避免把“页面能打开”误认为“语音链路可用”。

9.4 适配成果

当前 kmp-ohos-tts 已完成:

  • TtsLocaleTtsSessionModeTtsSegmentTtsResult 共享模型;
  • TtsEngine.adoptkeepFinalreducerunChecks 共享逻辑;
  • JVM 与 OpenHarmony ARM64 共用的 Kotlin/Native 动态库;
  • Kotlin/Native + C ABI + C++ N-API 桥接;
  • OpenHarmony 麦克风权限申请和 AudioKit PCM 采集;
  • Core Speech Kit 识别引擎和实时结果回调;
  • 停止后统一归一化为 KMP TtsResult
  • 签名 HAP 构建、设备安装和真机效果图;
  • AtomGit 项目 README、适配文章和仓库内图片资源。

参考文档

Logo

作为“人工智能6S店”的官方数字引擎,为AI开发者与企业提供一个覆盖软硬件全栈、一站式门户。

更多推荐