前言

已有会议声音保存在文件里时,应用可以反复使用同一份输入核对转写结果,也能避免每次说话的语速、距离和环境噪声改变测试条件。不过,手机能够播放一个文件,并不能保证识别引擎可以直接接收里面的所有字节。播放接口通常会读取容器、解码音频,再交给播放器;语音流输入则要求应用提供规定格式的原始采样数据。把整个文件直接送进去,会把这些处理职责混在一起。

文件转写还需要处理时间。几秒钟的声音虽然可以很快读进内存,识别接口仍然规定每次输入的数据量和间隔。应用既要保持字节顺序,也要让输入速度接近声音的时间推进。全部数据一次提交、末尾不足一片就丢弃,或者文件读完以后立刻销毁引擎,都可能让后面的识别结果缺少正常的结束机会。

页面从应用资源中读取一段固定普通话材料,依次完成格式确认、分片输入和文字显示。材料由本机中文合成声线生成,内容全部虚构,转换后的原始文件长 43.234 秒。2026 年 10 月 22 日,API 26 Release 工程在 nova 14 上完成了文件输入并返回文字。

PCM音频转写与分片处理封面

一、先确认文件里的字节代表什么

打开 PCM 音频转写页面,上方能够看到固定普通话文件的名称与音频参数。当前输入是无文件头的 file.pcm,采样率为16000赫兹,单声道,每个采样值占16位。十六位相当于两个字节,因此一秒声音包含三万两千字节。这项计算把音频时长和文件大小联系起来,也为后面的分片大小提供依据。

这份文件实际包含1383496字节,除以每秒32000字节,得到43.23425秒。页面只显示两位小数,所以输入信息中的43.23秒与素材的完整时长一致。需要核对文件是否被截断时,应当比较真实字节数量和完整时长,不能因为页面的小数位不同就判断输入发生了变化。相同内容如果重新合成或重新转换,字节也可能改变,所以固定材料还保留了原始音频和参考文字。

准备材料时,先用 macOS 内置中文声线生成声音,再经过解码、重采样和单声道转换,输出十六位小端原始采样。原始合成文件与转换后的可播放文件一并保留,识别入口读取最后导出的原始数据。

WAV 常见于保存未压缩声音,但它仍然带有容器头,里面也可能采用不同编码。MP3 则需要先解码,不能从扩展名直接推断字节已经满足识别条件。当前页面没有实现通用音频转换器,它只读取已经确认格式的固定资源。因此替换材料以前,需要在应用外完成相同的格式核对;把界面上的参数文字改成16000,并不会自动改变文件本身。

nova 14文件输入信息与开始入口

开始文件转写前,可以结合输入区域核对文件名、字节数和时长。文件名用于定位资源,字节数对应实际输入,时长则帮助判断转写需要等待多久。三项与预期一致后,再启动识别会话。

原始文件没有容器头,读取程序无法从文件中自动取得采样率和声道,应用需要让实际数据与声明参数一致。以常见的左右声道交错排列为例,双声道数据如果直接按单声道输入,左右声道的采样值会被当作单声道的连续采样值,并不会自动混合成单声道。

同样采用 16000 赫兹和 16 位采样时,双声道每秒占 64000 字节,是当前单声道的两倍。如果仍按每秒 32000 字节计算,显示的时长就会变成实际时长的两倍;每片 1280 字节也只对应 20 毫秒的双声道声音,继续按 40 毫秒间隔发送就与原始声音的时间不一致。当前识别配置要求单声道,遇到双声道文件时需要先转换,再按照转换后的格式计算时长和分片。

二、把一段声音拆成接口接受的时间片

语音流接口每次接收640或1280字节,对应的发送间隔分别为20或40毫秒。当前页面选择1280字节和40毫秒,让一次调用正好承载四十分之一秒的声音。在16000赫兹、单声道、十六位条件下,分片大小与发送间隔刚好匹配。

应用通过 resourceManager.getRawFileContent 读取资源,得到 Uint8Array。这份固定材料随工程打包,读取时不需要访问外部文件目录或申请麦克风权限。读取方法返回整份资源,后面的分片属于内存里的数据切分。

PCM格式检查、分片输入与文字返回流程

文件读入内存后,应用仍要按声音时间分片发送:每次按顺序取出一片,发送后等待 40 毫秒,再处理下一片。下面的循环中,id 对应已经创建的识别会话。

    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);
    }

每次先创建固定长度的数组,再把当前范围的有效数据复制进去。新数组初始为零,所以最后不足1280字节的部分自然补成静音,不会读取到文件之外的内容。当前文件可以分成1080个完整片和1096字节的末片,末片还需要184字节补零,最终发送1081片。增加的声音时长约为5.75毫秒,应用仍然按照源文件的有效字节统计进度。

sent 累加实际来自文件的字节数量,不能直接累加数组长度,否则最后的补零也会被算成原始声音。这个区分帮助结果核对:输入结束时有效字节应当等于1383496,发送总字节稍多则来自明确的末片处理。页面显示进度时采用有效字节除以每秒字节数,用户看到的是材料已经输入到什么位置。

循环还同时检查活动状态和会话身份。取消以后,活动状态变为结束;重新开始以后,当前身份也会改变,旧循环因此无法继续向新引擎写入。数据片与会话需要成对使用,只检查引擎对象是否存在还不够,因为那个对象可能已经属于另一项操作。

末片还需要保持采样点完整。十六位采样每次占两个字节,当前1096字节的有效尾部能够完整容纳采样点,补零以后也仍然对齐。换用来源不明的文件时,如果总字节数连一个采样帧都无法对齐,就应该先检查转换或保存过程。静音补齐只能补足接口要求的片长度,不能用来掩盖损坏数据或错误编码;否则字节数量虽然合规,声音内容仍然可能不正确。

当前材料约为一点三八兆字节,应用先把整份资源读入内存,再创建小片发送。分片发送期间,原始数组仍然保留在内存中。

三、等会话启动以后再送入文件

引擎创建采用中文离线短语音模式,启动时把 recognitionMode 设为 1,明确由应用提供音频流。应用先注册结果监听,再启动会话,收到 onStart 以后才读取并发送资源。这个顺序让文件输入拥有明确的准备条件。只得到引擎对象,还无法说明当前会话已经成功开始,提前发送时也就难以确定错误出现在哪一步。

音频信息与真实资源保持相同,包括 pcm、16000赫兹、单声道和十六位。短语音最长时长显式设置为60000毫秒,当前43.234秒的材料位于这一范围内。不能保留默认的二十秒上限,却把一段四十多秒声音当作完整输入;显式设置以后,文件时长与会话允许的时间才有对应关系。

启动前后的关键顺序保存在同一处代码。startExtras 已经包含输入方式、时长与当前短语音参数,监听对象也已完成注册。

      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); }

等待循环检查的是监听回调设置的就绪状态,最多等待约三秒。超过时间还没有就绪,应用报告启动问题并清理引擎,文件输入也随即终止。这个超时值来自当前页面的实现选择,不代表平台承诺的初始化耗时。实际排错需要保留创建错误和回调消息,再确定是否涉及资源、服务或重复启动。

输入结束以后,应用调用 finish,随后继续等待最终结果和完成消息。文件已经没有更多数据,只能说明应用这一侧发送完毕,识别引擎仍可能正在收尾。当前记录中,读取开始到有效字节发送完毕约经过45.62秒,稍长于材料的43.234秒;这段时间包含调用处理和定时等待,不能直接当作算法识别耗时。循环使用普通定时等待,并没有实现严格的音频时钟调度。

四、用固定参考文字核对转写结果

文件输入进行时,页面逐渐显示临时文字。文字更新只替换当前尚未定稿的部分,已经结束的子句继续保留,所以用户可以观察内容推进,又不会看到每一次中间结果被重复拼接。输入进度和识别文字分开显示,前者说明已经送入多少声音,后者说明引擎当前返回了什么,两项数值和内容没有必要同时变化。

这次 nova 14 返回了整段材料的最终文字,随后收到正常完成消息。参考文字来自生成声音时使用的人工编写文本,与原始音频和转换后的文件对应。页面保留了实际返回的差异:林舟变成临周,遗漏变成仪录,只验证变成之验证,部分标点也与参考文字不同。

nova 14固定PCM文件实际转写结果

材料尾部说明不生成会议结论、不替成员安排工作,实际返回文字也包含相应内容,说明这份固定文件处理到了尾部。将开头、尾部和中间内容与参考文字对照,可以分别观察输入范围与具体的识别变化。

排查时可以先判断有无启动回调,再检查输入字节和进度是否变化。没有启动就报错,应回到引擎与会话条件;已经启动却始终没有文字,需要检查文件是否非空、编码是否匹配以及是否真的调用写入。只有前半段文字时,还需要核对时长上限、末片和结束调用。这样的顺序来自数据实际经过的位置,可以减少一开始就修改所有参数造成的新变量。

当前页面一次处理一份固定材料。取消后重新开始会建立新会话,从文件开头重新读取;页面没有实现从中断位置继续输入。

总结

文件转写使用应用主动输入音频的识别模式。当前页面读取 16000 赫兹、单声道、16 位的原始 PCM,以每片 1280 字节、间隔 40 毫秒的方式写入识别会话;末片不足时补零,同时单独统计源文件的有效字节。

文件读完只表示输入结束,服务调用 finish 后仍要等待最终文字和会话完成回调。与系统直接采集麦克风的入口相比,文件入口把格式、分片和发送节奏交给了应用。

核心关键代码

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;
    // 每次发送一片并等待 40 毫秒;sent 不包含末片补零。
    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)); }
}
Logo

讨论HarmonyOS开发技术,专注于API与组件、DevEco Studio、测试、元服务和应用上架分发等。

更多推荐