【HarmonyOS 7 语音 AI 开放能力深度实战】 04 语音热词实战,专业词识别与会话配置
前言
会议录音中出现应用名称时,转写页面可能得到一个读音相近的普通词。句子仍然通顺,用户却需要重新播放录音,才能确认讨论的是哪个产品。技术交流还会夹杂英文名称,识别结果中的大小写、空格和单词都有可能变化。如果应用直接把这些文字保存为会议笔记,后面的搜索也会受到影响,因为用户输入的产品名可能与转写文字对不上。
给识别引擎传入热词,可以把业务中经常出现的名称加入识别配置。不过,配置进入接口以后,应用仍然需要比较返回文字。接口接受一个词组,只能说明当前参数能够用于识别;某个位置变得更接近原文,也只能说明这份声音在这个位置发生了变化。因此,专业词处理需要保留相同输入,把配置是否接受、文字是否变化以及仍然存在的错误分别检查。
页面提供无热词、引擎热词和会话热词三个入口,三次处理都读取同一份包含技术名称的录音,便于比较热词配置带来的变化。当前可运行配置只加入中文业务词会议随记;包含四个中英文名称的词组曾调用失败,原因尚未独立定位。固定音频以后,才有条件比较不同配置返回的文字。

一、固定声音以后,比较才有共同起点
从首页进入专业词与热词对照页面。输入区域显示固定专业词音频,下面保留三种识别按钮。这里采用文件输入,是因为每次重新对着麦克风说话,发音、音量、停顿和环境声音都会变化。即使识别文字不同,也很难确定变化来自热词还是来自新一次录音。重复读取同一份音频,可以先消除这部分输入差异。
文件中的声音由系统语音工具根据原创虚构文字生成,包含系统名称、开发语言、开发工具和会议应用名称。三组识别都使用这份普通话材料,合成声音的发音特点与自然交谈存在差异。
这份原始音频时长为 31.001 秒,大小为 992044 字节,采样率为 16000 赫兹,单声道,每个采样点占 16 位。三组处理均按每片 1280 字节、间隔 40 毫秒送入引擎,最后不足一片的位置补零。输入方式保持一致,比较时才能把注意力放在热词配置和返回文字上。
人工参考文字与音频一起保存,比较时先从中找出专业名称出现的位置,再核对识别结果。业务词在录音里出现两次,两个位置都需要检查。只挑选变化最好的一处,会漏掉同一名称在另一段话里仍然识别错误的事实。当前页面一次显示一组结果,操作时需要保留上一次返回文字,再启动下一组;页面没有同时展示三列结果,也没有自动计算准确率。

在nova 14上进入这一页以后,先点击无热词识别,等待处理状态显示会话完成,再继续选择另一组。按钮处于处理状态时,页面会阻止新的识别操作,避免两次输入互相穿插。应用记录每次会话标识、创建参数、启动参数和返回结果,比较时能够分清某段文字属于哪个入口。
三组识别都在HarmonyOS 7、API 26 Release的nova 14上执行,代码使用同一套语音识别服务。识别模型、音频格式和结果处理方式保持一致,只有热词配置发生变化。
文件识别不需要申请录音授权。当前输入已经保存在资源中,服务读取字节后交给引擎,不使用麦克风。
二、两个热词入口对应不同的生效阶段
固定输入以后,需要决定名称在什么阶段传入。应用中长期使用的产品名,可以在创建识别引擎时加入配置;某次任务临时出现的项目名,可以随本次识别会话传入。这两种位置有不同的生效范围,代码也分别读取两个开关。当前三个按钮只启用其中一种或全部关闭,方便把参数与结果一一对应。
引擎热词通过创建参数中的 sysGeneralLexicon 传入。当前实现显式设置中文、离线和短语音模式,再根据按钮决定是否增加会议随记。下面的片段来自识别服务的创建过程,临时参数对象每次重新建立,无热词入口不会沿用上一次加入的词条。
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 });
创建完成以后,服务设置结果监听并启动识别,热词参与后续的声音识别过程。每组识别结束后,当前实现会释放引擎,再按下一组的参数重新创建。
会话热词通过启动参数中的 sessionGeneralLexicon 传入。这个词组随当前会话使用,会话结束后释放;两种词组同时设置时,会话热词的优先级更高。当前页面分别测试两种位置,没有把二者同时开启,所以得到的结果不能用于判断冲突词组的优先级表现。需要验证覆盖关系时,应另外设计两个词组不同、其余条件相同的输入。
if (sessionHotwords) { startExtras['sessionGeneralLexicon'] = ['会议随记']; }
this.engine.startListening({ sessionId: id,
audioInfo: { audioType: 'pcm', sampleRate: 16000, soundChannel: 1, sampleBit: 16 },
extraParams: startExtras });
这两个词表都最多支持200个词,每个词长度为2到20个字符。会议随记处于这个长度范围,当前工程只传入这一条中文热词。

图中的三组入口共用音频读取和结果处理,变化只发生在创建与启动两个位置。引擎拿到配置以后,文件仍然要等待启动回调再开始输入。热词不能修复错误采样格式、缺失音频数据或会话尚未启动的问题,因此接入时先确认无热词输入能够完成,再增加词表,会更容易确定错误出在哪个阶段。
引擎创建时确定的词表跟随引擎配置,会话临时词组跟随当前任务。每次比较都重新建立参数对象,使页面开关与实际传入的词表对应。
三、把配置失败与识别错误分开排查
最初传入的词组包含系统名称、开发语言、开发工具和带英文后缀的应用名称。四个词组合使用时,引擎热词入口在创建阶段返回错误码 1002200001,消息为 msg;会话热词入口则在启动阶段返回 401 和 The parameter check failed。两次都没有形成能够用于比较的热词识别结果,所以这些调用不能归入热词已完成的组别。
创建失败和启动失败发生在两个不同阶段。遇到前一种情况,应用还没有得到可用的识别引擎,应检查创建参数和当时的服务条件;遇到后一种情况,已经创建的对象也需要结束并释放,不能继续向它写入音频。页面保留发生错误时的阶段,能够避免把所有问题都显示成识别失败,随后只在音频文件上反复调整。
错误码没有提供足够信息来确定哪个词条引发问题。创建错误包含多种可能原因,参数错误也没有指出具体字段。四词组同时包含英文、空格和中英文混合名称,而改为会议随记时,词数与内容一起变化。现有结果无法单独排除这些变量,因此不能据此宣布英文热词不可用,也不能把单词之间的空格写成平台禁止条件。
缩小为一个中文词以后,两种配置都完成了同一音频的识别,说明当前设备接受了这两种传入方式。原四词组的失败原因仍未定位。
识别结束后,无热词结果中的首处业务词为 会议随机bo,两种中文热词配置中的首处变为 会议随记bo。中文部分更接近参考名称,后缀仍然没有识别成目标英文。录音后面再次出现该名称时,结果仍有 会议随即。这两个位置必须一起看,才能避免用一处变化概括整段声音。

页面保留了引擎返回的文字,没有按照参考文本改写,因此可以定位热词影响了哪一处。英文名称和部分中文仍有错误;当前只有一份材料,未进行准确率统计。
四、保留原始文字,再决定业务怎样使用结果
热词在引擎识别声音时参与处理,页面收到的仍是引擎返回的原始文字。应用如果另外加入词语替换,需要区分这一步与热词配置的作用。例如普通词与产品名同音时,直接全局替换可能改变原句含义。当前页面没有自动替换,比较结果保留了引擎返回的文字。
中间结果也需要留在当前子句里更新。服务收到最终子句以后才累积正文,否则每次临时输出都会重复追加,页面可能出现越来越长的重复文字。当前处理保留原始结果字段,再把完成的子句和正在更新的内容组合显示。核对专业词时,以最终子句为准,临时结果只帮助观察文字怎样逐步形成。
this.sink('rawResult', JSON.stringify(result));
if (result.isFinal) { this.finalText += result.result; }
this.sink('result', this.finalText + (result.isFinal ? '' : result.result));
同一个名称在录音的一处发生变化,在另一处仍然识别错误。保留声音中的前后句、停顿和最终文字,可以继续比较这些位置的识别结果。
识别完成或出错后,服务结束活动状态并释放引擎,用户也可以主动停止。下一组比较因此从新的会话开始,旧请求不能继续更新页面。
总结
热词为语音识别提供业务词汇。sysGeneralLexicon 在创建引擎时传入,适合跟随引擎使用的词表;sessionGeneralLexicon 随会话传入,适合当前任务中的词组。两种配置都作用于识别过程,和识别结束后由应用替换文字处于不同阶段。
使用同一份音频比较时,会议随记这一中文热词在引擎级和会话级配置下均被接受,首处识别文字也发生了变化;另一处名称和英文后缀仍有错误。四词混合组在引擎创建或会话启动时出现错误,原因尚未定位。
核心关键代码
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);
}
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)