开源鸿蒙平台 KMP_CMP 三方库「端侧语音转文字」适配全流程
本文记录
kmp-ohos-tts接入 OpenHarmony 语音识别能力的完整过程,覆盖 KMP/CMP 工程盘点、ohosArm64目标、Kotlin/Native 动态库、C ABI/N-API 桥接、AudioKit 麦克风采集、Core Speech Kit 识别、ArkUI 真机页面、签名 HAP 和真机验收。这次适配保留 Kotlin 侧的识别结果模型、会话归约、文本归一化和自检逻辑,再由 ArkTS 调用 OpenHarmony 的
SpeechRecognitionEngine与AudioCapturer。页面展示的是麦克风实时回调,不是写死在页面里的演示句子;停止识别后,文本还会经过 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/ohosApp,tts 模块不依赖 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-command | ZH_CN | SHORT | 灯光和提醒等中文指令 |
demo-note | ZH_CN | LONG | 中文会议记录 |
demo-meeting | EN_US | LONG | 英文会议示例 |
真机识别时,平台侧产生的中间文本通过回调直接更新页面;点击“停止识别”后,页面调用 TtsAdopt,把最终文本交给 TtsEngine.adopt,再回显统一的 TtsResult。
1.3 实现适配
| 维度 | 要求 |
|---|---|
| 代码复用 | 结果模型、置信度约束、文本归一化、状态归约、JSON 和自检由 Kotlin 共享。 |
| 平台目标 | tts、example/shared 和 example/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 应用可以复用TtsResult和TtsEngine.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 Multiplatform | 2.2.21-1.0.0 | JVM、Kotlin/Native 和 KLIB |
| KMP JVM toolchain | JDK 17 | tts 与 example/shared 的 JVM 编译 |
| Native 示例 toolchain | JDK 21 | example/nativeApp 的 Native 任务 |
| OpenHarmony 目标 | ohosArm64 | ARM64 真机动态库 |
| HAP ABI | arm64-v8a | Stage 工程加载 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 TtsSegment 和 TtsResult
共享模型在构造时校验非法数据:
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();
SpeechRecognizerClient 用 session 是否为空表示运行状态,用 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.kt | JSON 编码和字符串转义 |
example/shared/src/commonMain/.../TtsExamples.kt | JVM 与 Native 共用的示例门面 |
example/nativeApp/.../NativeBridge.kt | C ABI、JSON 返回和内存释放 |
example/ohosApp/.../tts/TtsClient.ets | Core Speech Kit、AudioKit 和识别生命周期 |
example/ohosApp/.../tts/NativeTts.ets | ArkTS 到 N-API 的薄封装 |
example/ohosApp/.../pages/Index.ets | 权限、状态、实时文本和结果页面 |
example/ohosApp/entry/src/main/cpp/napi_init.cpp | N-API 导出和参数检查 |
example/nativeApp/.../linker/shared-library.map | Native 导出符号白名单 |
docs/images/tts-openharmony-demo.jpg | 真机运行效果图 |
4.3 关键 API 对照
| 层次 | API | 作用 |
|---|---|---|
| Kotlin | TtsEngine.adopt | 将平台文本转换为共享最终结果 |
| Kotlin | TtsEngine.reduce | 执行确定性会话动作归约 |
| Kotlin | TtsEngine.runChecks | 返回页面自检项 |
| Native | TtsAdopt | 通过 C ABI 返回一段结果 JSON |
| Native | TtsFree | 释放 Native 返回 buffer |
| N-API | adoptResult | 向 ArkTS 暴露文本归一化方法 |
| ArkTS | SpeechRecognizerClient.start | 创建识别器和录音器并启动数据流 |
| ArkTS | SpeechRecognizerClient.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 运行
tts和example/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 引号、反斜杠和换行转义;
keepFinal和reduce行为;- 共享自检项数量和结果。
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:首次启动权限
点击“开始识别”,系统弹出麦克风授权窗口。允许后应用创建 SpeechRecognitionEngine 和 AudioCapturer;拒绝后页面回到开始状态并显示权限错误。
用例 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 踩坑复盘
- 只改 ArkTS 页面不算 KMP 适配:必须把共享模型编译成
ohosArm64动态库,并从 N-API 实际调用。 - 权限检查要早于录音器创建:权限拒绝应在页面显示明确原因,不能等到
AudioCapturer.start()才暴露模糊错误。 - 音频格式必须完全一致:Core Speech Kit 的
audioInfo与 AudioKit 的streamInfo都使用 16 kHz、单声道、16 bit PCM。 - 回调注册要早于启动:
readData在capturer.start()之后注册,可能导致没有 PCM 送进引擎。 - Native buffer 必须释放:每次 C ABI 返回的字符串都由调用侧读取后通过
TtsFree释放。 - 中间结果不能直接当最终结果:只有停止后才调用
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 三条经验
- 先让共享模型在 JVM 和 Native 通过,再接入 ArkUI 设备能力;
- 用 JSON 和少量 C ABI 代替跨语言对象传递,明确所有权和释放责任;
- 把权限、音频格式、回调顺序、HAP 构建和真机识别分别记录,避免把“页面能打开”误认为“语音链路可用”。
9.4 适配成果
当前 kmp-ohos-tts 已完成:
TtsLocale、TtsSessionMode、TtsSegment和TtsResult共享模型;TtsEngine.adopt、keepFinal、reduce和runChecks共享逻辑;- JVM 与 OpenHarmony ARM64 共用的 Kotlin/Native 动态库;
- Kotlin/Native + C ABI + C++ N-API 桥接;
- OpenHarmony 麦克风权限申请和 AudioKit PCM 采集;
- Core Speech Kit 识别引擎和实时结果回调;
- 停止后统一归一化为 KMP
TtsResult; - 签名 HAP 构建、设备安装和真机效果图;
- AtomGit 项目 README、适配文章和仓库内图片资源。
参考文档
更多推荐



所有评论(0)