【HarmonyOS 7 语音 AI 开放能力深度实战】 01 实时语音识别实战,麦克风输入与文字结果显示
前言
录音页面增加文字显示以后,用户可以边说边核对内容,发现漏词时及时补充。然而,麦克风正在工作与页面已经得到可保存的文字,属于两个不同的状态。声音需要经过采集和识别,识别引擎也可能在后续声音到达以后修订前面的文字。如果应用把每一次回调都追加到正文里,同一句话就会反复出现;如果页面始终等待最后一次结果,用户又无法知道刚才的声音有没有进入识别过程。
Core Speech Kit 提供中文离线语音识别。应用负责申请录音权限、创建引擎、维护会话和展示文字,系统可以在实时录音模式下采集麦克风声音。这种分工让页面不用另外建立录音器,但权限、启动和结束仍然需要按顺序完成。按钮响应以后立即显示识别中,会把尚未成功创建引擎的阶段也算进去,发生错误时就很难判断问题停在哪里。
页面提供开始麦克风识别、结束输入和停止并释放三个操作,并在下方显示识别文字。2026 年 10 月 22 日,工程在 API 26 Release 上完成构建,并在 HUAWEI nova 14 标准版运行。测试时,Mac 扬声器播放了一段 27.843 秒的虚构中文材料,手机通过麦克风采集声音;识别引擎没有通过音频流接口读取该文件。要让页面接收这段声音,开始按钮首先需要取得录音权限。

一、让开始按钮先取得录音条件
打开工程并安装应用以后,从首页进入实时语音识别。页面上方说明输入来自麦克风,下方保留处理状态和文字区域。第一次点击开始麦克风识别时,应用请求 ohos.permission.MICROPHONE;系统已经保存授权的设备,也可能直接返回授权结果。应用需要检查返回值,确认授权成功以后才创建引擎,不能把调用权限请求的方法本身当作已经得到授权。
权限需要同时出现在模块声明和运行时请求中。模块的 requestPermissions 说明使用原因与前台场景,运行时请求则让用户决定当前应用是否可以录音。两处承担不同职责,缺少其中一处都会影响采集。文件识别页面没有采集麦克风,因此同一工程里的文件入口跳过这段请求;这种区分能够避免用户只是转写已有文件,页面却突然要求使用麦克风。
下面这段代码位于识别服务的启动方法中。空文件名代表实时麦克风入口,id 保存这次操作的会话身份,context 来自当前页面所属的应用上下文。
if (filename === '') {
const auth = await abilityAccessCtrl.createAtManager().requestPermissionsFromUser(context,
['ohos.permission.MICROPHONE']);
if (id !== this.session || !this.active) { return; }
this.sink('permission', JSON.stringify(auth.authResults));
if (auth.authResults.some((value: number) => value !== 0)) { throw new Error('麦克风权限未获得'); }
}
权限弹窗可能停留一段时间。用户在等待期间可以离开页面,原来的授权请求却仍然会返回,所以代码在授权之后重新检查会话身份和活动状态。已经取消的操作直接结束,不再继续创建识别引擎。
如果权限没有获得,服务会抛出麦克风权限未获得的错误,由页面显示消息。错误对象需要读取 message,直接序列化普通异常对象可能只得到空对象。

这个入口需要观察的是输入来源和状态区域。应用没有要求选择音频文件,开始以后由系统采集手机周围的声音。授权完成、引擎创建成功并收到启动回调后,页面开始等待识别文字。
手机麦克风采集到的是扬声器声音与周围环境的混合。复现时可以先让手机靠近可正常播放的声音来源,再点击开始,等启动成功以后播放固定材料。设备媒体音量、两台设备之间的距离和室内噪声都可能改变输入,因此保留原始材料能够帮助分清内容变化与识别变化。
二、把一次录音绑定到一条识别会话
语音引擎创建时,应用选择 zh-CN 和离线模式,online: 1 表示离线,不能按照字段名称猜成联网。创建参数里的 recognizerMode 选择 short,启动参数里的 recognitionMode 选择 0。前一个字段决定短语音处理模式,后一个字段决定声音由系统实时录音取得。把两项都叫识别模式容易在排错时混淆,因此代码和页面说明需要保留它们各自的职责。
引擎创建完成以后,应用先调用 setListener 注册监听,再调用 startListening。监听提前安装,能够接住会话开始、语音事件、文字结果和完成消息。如果先启动再注册,页面就可能缺少启动阶段的观察入口。当前实现还等待 onStart,没有在创建引擎返回以后立刻认为麦克风会话已经准备好。
每次启动使用新的 sessionId,格式为英文前缀、下划线与时间值。回调返回会话标识,应用先核对身份,再更新页面。用户停止后重新开始时,这项检查能把旧任务迟到的消息挡在新的结果之外。

图中的声音路径直接从系统录音进入识别引擎,应用接收的是状态与文字回调。当前实时路径没有调用 writeAudio;这个接口用于应用主动提供音频数据。
短语音会话把最长输入设为60000毫秒,前端等待设为10000毫秒,尾部静音设为3000毫秒。前端等待限制开始后没有说话的时间,尾部静音帮助判断声音已经停下,两者不能当作识别响应速度。当前采用较宽的等待时间,是为了给声学输入留出准备空间。
页面还需要区分初始化与正在处理。初始化可能包含授权和创建引擎,正在处理则意味着会话已经开始接收输入。语音事件会提供开始说话和停止说话的信息,文字回调继续给出内容。当前页面把这些实际消息显示在状态区域,排错时能够沿着最后出现的位置继续检查,避免所有异常都只剩一句识别失败。
启动方法返回与整次识别结束还需要分别处理。麦克风路径在会话准备好以后就从启动方法返回,后面的讲话和识别通过监听继续进行,所以页面不能只在等待这个方法结束以后就解除忙碌状态。当前页面在用户点击开始时锁住新的启动操作,随后由完成、错误或取消消息恢复。这样即使用户在讲话过程中再次点击开始,也会先得到当前操作尚未结束的提示,而不会同时创建另一条会话。
等待启动的循环最多检查一百五十次,每次间隔二十毫秒。这段约三秒的等待是页面自己的超时设置;超过时间后,服务会报告没有等到启动结果并释放引擎。
三、让临时文字替换,让最终子句保留
声音进入以后,同一句话可能触发多次 onResult。临时结果会随着后续声音变化,最终结果则表示当前子句已经确定。页面需要保留已经确定的部分,同时让正在识别的部分更新。把临时结果也永久追加,会出现这、这是、这是一次这样的重复内容;每次都覆盖全部正文,又会丢掉之前已经结束的子句。
当前服务使用 finalText 保存已经确定的文字。收到最终子句时,服务把它追加进去;收到临时结果时,服务只在显示值中接上这一段,不改动已保存的终稿。对应代码直接位于监听对象中,显示层只接收整理后的结果。
onResult: (sid: string, result: speechRecognizer.SpeechRecognitionResult) => {
if (sid !== this.session) { return; }
this.sink('rawResult', JSON.stringify(result));
if (result.isFinal) { this.finalText += result.result; }
this.sink('result', this.finalText + (result.isFinal ? '' : result.result));
},
isFinal 表示当前子句已经定稿,isLast 表示它是最后一句。两个字段经常会在短录音结束时一起出现,但应用不能因此认定它们始终相同。用户停顿较多或改用长语音以后,前面的最终子句仍可能后接新的文字。整个会话何时结束,页面继续等待 onComplete,不会因为任意一条最终子句就允许再次启动。
这次固定材料描述三位成员的工作安排。手机先返回多条临时文字,随后返回一条 isFinal: true、isLast: true 的结果,并触发完成回调。输入中的林舟被识别成林州,许岚成为许兰,陈禾也被识别成了全和;页面显示的是这些实际返回的文字。

截图展示了最终文字,回调记录还保留了此前的临时更新。参考材料中的下午三点和明天上午十点在结果中采用了数字写法,与姓名识别错误属于不同类型的变化。当前材料未用于统一字错率评测。
收到结果后,页面把文字写入可观察的状态变量,由界面随状态重新绘制。识别服务不直接操作文本组件,错误消息和识别文字因此可以进入不同区域,处理提示也不会混进正文。
四、让结束输入和取消各自完成清理
用户说完以后,结束输入调用 finish,让当前识别过程收尾并继续等待结果;停止并释放则取消当前任务并释放引擎。前一种操作希望保留本次转写,后一种操作允许用户放弃处理。页面需要把两个入口说清楚,否则用户只是想结束讲话,却可能主动取消了尚未返回的文字。
当前服务在完成回调中恢复空闲,并调用 shutdown 释放引擎。启动失败与识别错误也进入释放路径,避免页面显示失败以后仍持有无用资源。每次任务结束就释放引擎,是当前页面采用的处理方式。
取消路径先保存原引擎与会话标识,再把当前活动状态、就绪状态和身份清空。这样旧回调即使随后到达,也无法继续更新当前页面。真正调用系统取消和释放时,代码使用之前保存的局部对象,确保清理的是原来的引擎。
cancel(): void {
const engine = this.engine;
const id = this.session;
const wasActive = this.active;
this.active = false;
this.ready = false;
this.session = '';
this.engine = undefined;
try { if (wasActive && id !== '') { engine?.cancel(id); } }
finally { engine?.shutdown(); }
this.sink('cancel', '已取消并释放识别引擎');
}
finally 保证取消过程出现异常时仍然尝试释放资源。清理还需要覆盖初始化期间,因为权限或引擎创建都可能晚于用户的返回动作完成。创建成功以后再核对会话身份,能够把这种迟到的引擎及时关闭;只在页面消失时检查当时是否有引擎,还会漏掉尚未返回的创建请求。
识别没有按预期进行时,可以依次检查权限结果、引擎创建结果、启动回调、语音事件和文字结果。确定最后成功的一步以后,再区分问题发生在声音采集、回调接收还是页面更新。
总结
Core Speech Kit 的实时识别模式让系统采集麦克风声音,并通过会话回调返回识别文字。应用先取得录音权限、创建引擎,再根据启动、语音事件和完成消息更新页面;会话标识用来区分多次操作,防止旧回调覆盖新的结果。
文字显示分为两部分:已经定稿的子句持续保留,临时文字随识别过程更新。结束输入后,页面继续等待最终结果和完成回调;用户取消时,服务使当前会话失效并释放引擎。
核心关键代码
import { speechRecognizer } from '@kit.CoreSpeechKit';
import { common, abilityAccessCtrl } from '@kit.AbilityKit';
import { BusinessError } from '@kit.BasicServicesKit';
interface RecognizerOption { enablePartialResult: boolean; }
export type SpeechSink = (kind: string, value: string) => void;
export class SpeechSession {
private engine: speechRecognizer.SpeechRecognitionEngine | undefined;
private session: string = '';
private active: boolean = false;
private ready: boolean = false;
private finalText: string = '';
private sink: SpeechSink;
constructor(sink: SpeechSink) { this.sink = sink; }
async start(context: common.UIAbilityContext, filename: string, longMode: boolean,
engineHotwords: boolean = false, sessionHotwords: boolean = false): Promise<void> {
// 一次只运行一个会话,新的标识用于过滤旧操作的回调。
if (this.active) { throw new Error('识别会话仍在处理,请结束后重试'); }
this.active = true;
this.ready = false;
this.finalText = '';
this.session = 'speech_' + Date.now();
const id = this.session;
try {
// 空文件名表示使用麦克风,只有这一入口申请录音权限。
if (filename === '') {
const auth = await abilityAccessCtrl.createAtManager().requestPermissionsFromUser(context,
['ohos.permission.MICROPHONE']);
if (id !== this.session || !this.active) { return; }
this.sink('permission', JSON.stringify(auth.authResults));
if (auth.authResults.some((value: number) => value !== 0)) { throw new Error('麦克风权限未获得'); }
}
// 实时入口使用短语音模式,创建参数还保留长语音选项。
const mode: string = longMode ? 'long' : 'short';
const extras: Record<string, Object> = {};
extras['locate'] = 'CN';
extras['recognizerMode'] = mode;
if (engineHotwords) { extras['sysGeneralLexicon'] = ['会议随记']; }
// 引擎创建可能晚于取消操作,返回后先核对当前会话。
const created = await speechRecognizer.createEngine({ language: 'zh-CN', online: 1, extraParams: extras });
if (id !== this.session || !this.active) { created.shutdown(); return; }
this.engine = created;
this.sink('engine', JSON.stringify(extras));
this.engine.setListener({
onStart: (sid: string, message: string) => {
if (sid !== this.session) { return; }
this.ready = true;
this.sink('start', sid + ' ' + message);
},
onEvent: (sid: string, code: number, message: string) => {
if (sid === this.session) { this.sink('event', code + ' ' + message); }
},
// 最终子句累计保存,中间结果只用于刷新当前显示。
onResult: (sid: string, result: speechRecognizer.SpeechRecognitionResult) => {
if (sid !== this.session) { return; }
this.sink('rawResult', JSON.stringify(result));
if (result.isFinal) { this.finalText += result.result; }
this.sink('result', this.finalText + (result.isFinal ? '' : result.result));
},
// 收到会话完成后更新页面状态,并释放识别引擎。
onComplete: (sid: string, message: string) => {
if (sid !== this.session) { return; }
this.active = false;
this.sink('complete', message);
this.engine?.shutdown();
this.engine = undefined;
},
onError: (sid: string, code: number, message: string) => {
if (sid !== this.session) { return; }
this.active = false;
this.sink('error', code + ' ' + message);
this.engine?.shutdown();
this.engine = undefined;
}
});
const option: RecognizerOption = { enablePartialResult: true };
// 0 由系统采集麦克风;1 由应用主动写入音频数据。
const recognitionMode: number = filename === '' ? 0 : 1;
const maxDuration: number = longMode ? 180000 : 60000;
const startExtras: Record<string, Object> = {};
startExtras['recognitionMode'] = recognitionMode;
if (!longMode) {
startExtras['vadBegin'] = 10000;
startExtras['vadEnd'] = 3000;
}
startExtras['maxAudioDuration'] = maxDuration;
startExtras['recognizerOption'] = option;
if (sessionHotwords) { startExtras['sessionGeneralLexicon'] = ['会议随记']; }
this.engine.startListening({ sessionId: id,
audioInfo: { audioType: 'pcm', sampleRate: 16000, soundChannel: 1, sampleBit: 16 },
extraParams: startExtras });
this.sink('params', JSON.stringify(startExtras));
// 等待启动回调后再输入文件,最多等待约三秒。
for (let count = 0; !this.ready && this.active && count < 150; count++) { await this.delay(20); }
if (!this.ready) { throw new Error('等待 onStart 超时或识别启动失败'); }
if (filename !== '') { await this.feed(context, filename, id); }
} catch (error) {
if (id !== this.session) { return; }
this.active = false;
this.engine?.shutdown();
this.engine = undefined;
const failure = error as BusinessError;
throw new Error((failure.code ? failure.code + ' ' : '') + failure.message);
}
}
private async feed(context: common.UIAbilityContext, filename: string, id: string): Promise<void> {
const data = await context.resourceManager.getRawFileContent(filename);
this.sink('input', filename + ' | ' + data.length + ' 字节 | ' + (data.length / 32000).toFixed(2) + ' 秒');
// 1280字节是40ms;不足一帧的末尾补零,保留有效字节数。
let sent = 0;
for (let offset = 0; offset < data.length && this.active && id === this.session; offset += 1280) {
const packet = new Uint8Array(1280);
packet.set(data.subarray(offset, Math.min(offset + 1280, data.length)));
this.engine?.writeAudio(id, packet);
sent += Math.min(1280, data.length - offset);
if (offset % 32000 === 0) { this.sink('progress', (sent / 32000).toFixed(1) + ' / ' + (data.length / 32000).toFixed(1) + ' 秒'); }
await this.delay(40);
}
// 文件输入结束后调用 finish,最终文字仍由回调返回。
if (this.active && id === this.session) {
this.sink('inputEnd', '有效字节 ' + sent);
this.engine?.finish(id);
}
}
finish(): void { if (this.active && this.ready) { this.engine?.finish(this.session); } }
// 先使会话失效,再取消任务;finally 确保继续释放引擎。
cancel(): void {
const engine = this.engine;
const id = this.session;
const wasActive = this.active;
this.active = false;
this.ready = false;
this.session = '';
this.engine = undefined;
try { if (wasActive && id !== '') { engine?.cancel(id); } }
finally { engine?.shutdown(); }
this.sink('cancel', '已取消并释放识别引擎');
}
private delay(ms: number): Promise<void> { return new Promise<void>((resolve) => setTimeout(resolve, ms)); }
}
更多推荐

所有评论(0)