【HarmonyOS 7 语音 AI 开放能力深度实战】 03 长语音识别实战,会议录音处理与状态管理
前言
会议声音超过一分钟以后,应用需要持续接收多段文字,用户也很难一直盯着页面确认任务有没有结束。说话人停顿几秒,可能只是切换话题;一段文字已经定稿,也可能还有后面的声音需要处理。如果页面沿用短录音的一次输入、一次结果的想法,就容易提前恢复按钮,或把已经返回的文字误当作整场录音的全部内容。
长语音识别需要同时处理声音持续输入和识别结果分段返回。应用可以逐步显示已经确定的文字,但输入位置、当前子句和整个会话仍然要分开管理。文件读完以后,识别引擎还需要完成收尾;用户中途离开时,原来的输入循环又必须停止。会话状态因此直接影响页面能否准确告诉用户接下来应该等待、结束还是重新开始。
页面读取一段 147.139 秒的虚构应用开发会议材料。材料由中文合成声线生成,只有一名合成讲述者。2026 年 10 月 22 日,工程在 API 26 Release 和 nova 14 上完成了这段转写,收到五段最终子句和正常完成消息。要让这段超过一分钟的声音进入同一条会话,首先需要在创建引擎时选择长语音模式。

一、让引擎模式与录音长度相符
打开长语音识别页面,输入区域显示虚构会议文件及其音频参数。当前材料是无容器头的原始 PCM,16000赫兹、单声道、十六位,大小为4708436字节。按照每秒32000字节计算,声音时长为147.138625秒,已经超过短语音模式的六十秒范围。使用这样的材料,能够实际观察持续输入、子句更新和结束处理,而不会只在一段十几秒声音上修改模式名称。
模式在创建引擎时选择,当前把 extraParams.recognizerMode 设为 long。启动会话时再把 recognitionMode 设为 1,表示应用写入音频流。前者决定采用长语音处理,后者决定声音从文件输入,两项配置分别解决不同问题。只有修改最大时长而保留短语音引擎,无法完整表达当前处理条件。
引擎创建的参数来自同一启动方法,页面根据是否选择长语音传入 longMode。长语音页面向启动方法传入长模式条件,不开启热词配置。
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));
online: 1 选择中文离线识别。创建过程异步返回,所以拿到引擎以后仍然需要检查会话是否有效;用户已经取消时,新返回的实例会被直接释放。这样可以处理用户离开页面后才完成创建的引擎。
最大音频时长显式设置为 180000 毫秒,可以覆盖这份约两分半钟的材料。页面将时长保留两位小数,显示为 147.14 秒;工程计算仍使用源文件的实际字节数量。
长语音的 maxAudioDuration 参数范围允许更长的录音,但 writeAudio 接口说明同时写明最大音频长度为 60000 毫秒,两处说明存在差异。2026 年 10 月 22 日,HUAWEI nova 14 在 API 26 Release 环境下使用当前配置,完成了 147.139 秒文件的输入并收到会话完成回调。这个结果只覆盖该设备、版本和材料。产品使用时,单次会话的音频输入可以先按 60 秒控制;如需超过这一时长,应先向华为确认接口适用范围,并在目标设备上验证。
长模式的语音端点配置也与短模式不同。短模式可以配置开始前等待和尾部静音,长模式的尾部端点用于划分子句,固定为五百毫秒,不接受相同方式的调整。当前代码只在短模式写入 vadBegin 与 vadEnd,长模式保留支持的时长和输入设置。语音停顿会影响子句产生,但应用不会因为任意一段停顿就主动结束整个文件。

输入画面需要核对的是文件、时长和正在处理的状态。当前读取的是固定虚构材料,页面没有向麦克风采集声音,也不会弹出本次文件操作不需要的录音权限。点击开始文件转写以后,状态先经过初始化,随后才进入持续输入。若没有得到启动回调,后面的进度与文字都没有开始的依据。
二、持续输入时分别观察音频位置和识别进度
长语音继续采用每片1280字节、每次等待40毫秒的输入方式,音频格式保持不变。模式改变以后,应用仍然需要遵守单次音频片的要求,不能把整个长文件作为一次写入。当前材料会产生3678个完整片与596字节的末片,末片补零到1280字节,源文件有效字节数仍然保持4708436。这样的处理让最后一小段声音能够继续进入引擎,也让发送过程能够与原文件核对。
输入循环以当前会话身份和活动标记作为继续条件。每发送一片,代码增加有效字节计数,按一定间隔更新页面进度,再等待下一次写入。进度中的时间是已经送入声音对应的时长,不是已经得到准确文字的时长。识别结果可能落后于输入,也可能在当前子句结束时一次增加较多文字,所以两个区域不应被合并成同一个百分比。

连续音频进入会话后,临时文字和最终子句可能在文件输入结束前陆续返回。整体完成则要等待会话的完成消息。页面可以一边显示文字增长,一边保持忙碌状态,让用户知道输入结束后识别仍在收尾。
当前记录中,从开始输入到源文件有效字节发送完毕,约经过155.43秒,比音频本身稍长。这个过程包含定时等待、系统调度、接口调用与页面记录等工作,不能把多出的时间全部解释成语音算法耗时。
长任务也需要限制重复操作。页面在开始以后保持忙碌,服务内部还检查是否已有活动会话,避免新的开始请求覆盖当前身份。前者给用户操作反馈,后者保护服务调用,两层分别针对页面和方法入口。只改变按钮外观,仍可能让其他调用方启动第二条任务;只在服务里拒绝,页面又可能不断出现难以解释的错误。
当前实现从资源中一次读取整份约四点七兆字节材料,再在内存中切片。输入期间,整份文件仍然保留在内存中。
当前输入全程使用同一个会话标识。手机处理的是一条持续超过一分钟的长语音会话,页面里的多段文字来自同一会话内返回的子句。这些子句何时定稿,需要继续看结果回调中的字段。
三、让五段终稿按出现顺序保留下来
长语音返回的最终结果通常对应一个子句,并不代表整个文件已经处理完。当前材料实际产生五条 isFinal: true 的结果,其中前四条的 isLast 为假,只有第五条标记最后一句。页面因此需要保留已经确定的内容,再把当前临时子句接在后面显示。每次收到最终子句就停止,会让材料后面的大段声音没有进入结果。
服务使用 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));
},
子句边界还需要与文章段落区别开来。引擎根据声音处理产生的五段终稿,没有完全对应人工参考文字的自然段,其中一处把可以执行的操作分到了相邻两条结果里。当前服务按顺序直接拼接,并保留引擎返回的标点,因此可以看到词语被句号隔开的现象。应用没有额外重写这些内容,也没有自动生成适合阅读的会议分段。

结果画面展示的是多段终稿组合后的文字。参考内容中的林舟、许岚、陈禾出现了同音字变化,识别会话也被转成了识别绘画等不合原意的文字。
结果包含材料尾部关于虚构内容和个人信息的说明,五条终稿和完成回调也记录了这次会话的结束过程。
会议材料在这里承担连续声音输入。虽然文字提到三位虚构成员,声音由同一合成声线朗读,当前接口也没有返回人员身份。
语音事件与文字回调也应当保留各自含义。开始说话和停止说话的事件描述声音端点,最终子句描述文字是否定稿,两者都不能单独表示整份文件已经结束。当前页面把事件消息显示在状态区域,再让文字区域继续更新。处理停顿较多的会议内容时,这种区分能够避免用户刚看到停止说话,就误以为后面的声音不会再处理;应用是否结束仍然依据当前会话的完成消息。
四、让输入结束、正常完成与取消有清楚顺序
源文件发送完毕以后,应用记录有效字节数,再调用 finish。最后一片已经进入接口,不代表终稿也已经全部返回,因此服务继续保留引擎和监听。当前记录先出现输入结束,随后收到第五条最终结果,再收到 onComplete。页面以完成回调恢复空闲,这样最后的识别文字仍有机会显示。
正常完成的处理把活动状态清空,通知页面,再释放引擎。下面的代码位于当前监听对象中,先核对回调属于哪一条会话,避免旧任务结束时干扰新任务。
onComplete: (sid: string, message: string) => {
if (sid !== this.session) { return; }
this.active = false;
this.sink('complete', message);
this.engine?.shutdown();
this.engine = undefined;
},
取消则先让当前会话身份失效,再尝试取消并释放原引擎。输入循环在下一次继续之前发现身份或活动状态不匹配,就不再发送后面的声音。用户重新开始时会建立新会话,并从固定文件开头重新读取。
资源释放还要覆盖错误路径。服务收到 onError 会显示原错误码与消息,并清理引擎;初始化抛出异常时,也要核对这次操作是否仍然拥有当前实例。频繁创建与销毁、资源不存在或服务超时可能出现相近错误,所以页面需要保留原始消息与当时状态,不能凭一个失败结果直接归因于设备不支持长语音。
这段 147.139 秒的文件已正常结束。取消后再次开始时,页面获得了新的启动回调,并从文件开头重新输入声音。当前操作处理的是前台固定文件,后台持续录音不在这条输入流程中。
总结
当前长语音页面继续采用文件分片输入,把更长的录音交给同一个识别会话处理。应用在创建引擎时选择 long,设置适合当前文件的最大时长,再持续发送音频;页面上的输入进度表示已经送入的声音,不等同于文字已经全部定稿。
识别结果以子句逐段返回。服务保留已经确定的子句,用临时结果更新当前文字,并在输入结束后等待 onComplete 再结束会话。147.139 秒的固定文件返回了五条最终子句,展示了持续输入、分段显示和会话收尾之间的关系。
核心关键代码
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('麦克风权限未获得'); }
}
// longMode 选择长语音引擎;时长参数在启动会话时设置。
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);
}
// 所有分片输入完毕后结束输入,后续子句仍从回调到达。
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)