前言

应用直接朗读一段文字时,合成引擎可以接管播放。用户点击开始以后,页面等待开始与完成回调,就能更新按钮和提示。如果应用还要保存这段声音、稍后重新播放,或者检查生成音频的长度,只有播放状态就不够了。应用需要拿到真正的音频字节,并且知道这些字节应该按照什么格式解释。

音频数据回调会陆续返回多块内容。一块数据到达,并不代表整段文字已经合成结束;几块数据的到达顺序,也可能受到异步通信影响。如果页面一收到字节就直接追加文件,文件虽然能够写入,声音的前后顺序却可能出错。因此,保存功能需要同时处理请求归属、分片顺序和合成结束时机,随后才能把文件交给播放器。

页面使用 Core Speech Kit 将固定中文内容合成为原始音频,保存后再交给应用自己的播放器读取。音频能否交给应用处理,取决于创建请求时选择的输出模式;模式确定以后,才能继续接收和整理数据。

语音合成数据流与音频播放封面

一、让合成请求返回可以保存的音频

打开完整工程以后,从首页进入合成数据流与音频播放。输入区显示固定文字及中文离线音色,开始操作前可以先核对文字内容。页面提供合成并保存 PCM 与播放合成文件两个按钮,使生成文件和播放文件成为两次明确操作。只有第一步真正取得文件,第二个按钮才有可以读取的输入。

引擎播放与应用取得数据由合成参数决定。当前调用把 playType 设置为 0,要求引擎合成并返回音频流;设置为 1 时,引擎直接合成播放,不返回这份数据流。如果业务已经采用直接播报模式,等待音频数据回调一直没有结果,应该先检查这个参数。重复增加监听器无法改变当前请求选择的输出方式。

数据流请求需要先创建引擎,再注册监听。当前工程使用中文离线模式和音色编号 13,并查询设备返回的音色信息,然后调用合成。音色列表包含安装状态,列表中的音色不一定都已安装。

每次合成都有独立的请求标识。开始新请求时,服务先停止旧引擎,清空上一次的分片和保存路径,再建立新的标识。初始化包含异步等待,因此创建完成后还要比较标识;用户在等待期间停止操作时,刚刚创建的旧引擎会被释放。这样可以避免页面已经开始新任务,较晚返回的旧数据却被写入新的文件。

请求标识也用于区分回调归属。开始、数据、完成和错误回调先与当前标识比较,再影响页面状态。用户停止后,服务清空标识,旧请求迟到的完成消息就不会使新请求提前结束。

音频回调把字节和序号一起交给应用。只有属于当前请求的数据才会进入缓存。

      onData: (id: string, data: ArrayBuffer, response: textToSpeech.SynthesisResponse) => {
        if (id !== this.request) { return; }
        this.chunks.set(response.sequence, data.slice(0));
        this.sink('ttsData', response.sequence + ' | ' + data.byteLength + ' 字节');
      }

缓存以序号作为键,字节则复制一份后保存。复制让服务持有自己的缓冲区,后面的合并过程不会依赖回调参数是否仍被其他处理使用。页面只展示分片序号与大小,不把每次回调都当作一次播放完成。原始字节会继续留在服务中,等待当前合成过程结束。

合成数据从请求到文件播放的处理流程

引擎先返回音频,应用再整理并保存,播放器随后读取文件。合成结束与播放器结束因此对应不同阶段;排查时可以分别检查请求、字节整理和音频输出。

二、合成结束以后,再按序号重组文件

异步回调之间存在时间差,应用不能用最后一次收到数据的时刻猜测合成已经结束。当前服务监听完成回调,并检查完成类型。类型为 0 时表示合成结束,服务才尝试保存已经取得的分片。这里没有等待播放完成,因为当前请求没有要求引擎直接播放。

保存前,服务取得所有分片序号,按数值从小到大排序。排序使用数值比较,避免文本排序把十排在二前面。随后,服务要求排序后的第一项从零开始,后面的序号逐个连续。已收到的序号之间出现缺口时,页面显示分片序号不连续,并停止生成文件,避免把这个已知缺口带入保存结果。

分配最终缓冲区之前,服务先检查分片序号。

    const order = Array.from(this.chunks.keys()).sort((a: number, b: number) => a - b);
    let bytes = 0;
    for (let index = 0; index < order.length; index++) {
      if (order[index] !== index) { this.sink('error', '合成分片序号不连续'); return; }
      bytes += this.chunks.get(order[index])!.byteLength;
    }

这里的分片来自语音合成服务,长度由返回结果决定。音频识别输入要求的固定帧长与发送间隔,不适用于这些合成回调;应用不需要为了凑成同样大小而删除或补齐每一块。当前处理保留每片实际长度,只在合并时计算它们的先后位置,生成文件才能继续与原始返回字节核对。

当前回调没有给出预期总片数,因此已收到的序号连续,仍不能单独排除末尾分片缺失。

序号检查通过以后,服务按照总字节数创建一块缓冲区,再逐片复制内容。每次复制后,写入位置增加当前分片的长度,下一片接在前一片后面。分片大小可以不同,因此当前实现没有把序号乘以固定大小来计算位置。实际返回的分片中就有不同长度,固定偏移会造成空隙或覆盖。

文件保存在应用私有目录,名称中包含当前请求标识。再次合成时会生成新名称,页面记录的保存路径也随之更新,因此每份文件都能对应到一次请求。

文件句柄在写入完成后关闭。播放器之后会重新打开文件读取,两个操作各自持有自己的句柄。这样保存结束并不要求文件一直处于打开状态,播放结束也不会影响下一次创建新文件。应用将来增加导出功能时,可以继续读取已经保存的内容,而不用重新向合成引擎请求同一段文字。

本次固定文字实际返回了一百二十六个分片,合并后为一百四十二万三千五百四十六字节。分片数量和文件大小来自本次合成返回的数据。

三、使用返回格式解释原始 PCM

文件写入完成以后,还需要知道声音格式。原始 PCM 直接保存采样值,没有 WAV 文件头替应用描述采样率和声道。仅给文件换一个后缀,不会自动增加这些信息。播放器如果用错误的采样率读取同一份字节,声音的速度、时长和音调就可能发生变化。

当前服务在开始回调中保存格式。本次手机返回的是 PCM、一万六千赫兹、十六位、单声道,压缩率为零。文件播放使用返回的采样率与音频通道,采样格式采用当前接口确认的十六位小端有符号整数。这里的音频通道来自返回字段,不能把合成参数里的播放场景编号直接拿来替代。

有了真实格式,应用才可以把字节数换算成时长。当前声音每秒包含一万六千个采样点,每个采样点占两个字节,单声道每秒因此占三万两千字节。用文件大小除以这个数,得到约四十四点四九秒。

合成耗时不能直接当作音频时长。数据模式可以在短于整段声音播放的时间内返回许多分片,保存完成以后,播放器才开始按采样速度输出。页面因此分别显示生成文件和文件播放的状态。

在 nova 14 页面点击合成并保存 PCM 后,处理状态会经历初始化和分片接收,随后显示文件保存路径、大小与分片数量。画面中可以核对当前文字、返回格式和保存结果。文件尚未保存时点击播放,页面会提示先完成合成。

nova 14 中的真实合成文件与返回格式

生成文件经过离线信号检查,包含非零采样,时长约四十四点四九秒,也没有出现满幅削波采样。

原始文件也可以在开发电脑上转换成 WAV,方便普通播放器识别。转换时必须使用刚刚确认的采样率、位深和声道,保留原始 PCM 供比对。当前配套材料同时保留了原始字节与便于播放的封装版本,转换只增加容器描述,没有重新生成另一段语音。应用中的播放流程仍然直接读取原始文件。

四、文件写入播放器以后,等它完成排空

保存成功后点击播放合成文件,服务会先检查是否已经持有文件路径和格式,再创建 AudioRenderer。当前文件属于语音内容,播放器按照媒体用途配置,随后启动输出。创建播放器也需要等待,因此用户在这段时间取消时,刚刚建立的旧播放器会被释放,不会继续进入文件读取。

播放器的一次写入不一定接受应用提交的全部字节。当前循环按照实际返回的写入长度推进文件位置,剩余部分留到下一次继续提交。如果返回的长度没有前进,服务会抛出错误,避免循环一直重复同一段内容。下面的代码保留了这项处理,文件内容不会因为一次部分写入就被直接跳过。

      while (offset < read && generation === this.playbackGeneration) {
        const written = await renderer.write(data.slice(offset, Math.min(read, offset + 8192)));
        if (written <= 0) { throw new Error('AudioRenderer没有接收音频字节'); }
        offset += written;
      }

全部字节提交以后,播放器内部还可能保留尚未输出的音频。服务因此等待排空,再执行停止,随后关闭文件句柄并释放播放器。页面显示的写入并排空字节数,可以与保存文件的大小比较。本次两处均为一百四十二万三千五百四十六字节,说明保存的内容已经进入这条文件播放流程。

nova 14 中播放器完成写入与排空的状态

截图显示播放器完成了写入和排空,生成的非静音文件与回调记录也保留了合成和保存结果。本次没有独立采集手机扬声器的声音。

停止按钮还会使当前播放编号失效。文件写入循环每轮检查编号,发现用户已经停止就退出,结束路径继续释放播放器。旧播放器尚未释放时再次点击会被拒绝,释放后可以重新操作;新的播放请求不会自动排队。

当前实现会把整份生成文件读入内存,再分块写给播放器。约四十四秒的固定材料已经完成运行,更长的文件会占用更多内存。

总结

文本转语音除了由引擎直接播报,也可以通过 playType: 0 将合成音频返回给应用。数据回调携带请求标识和分片序号,服务在合成结束后按序号排序、检查已收到分片的连续性,再将它们合并为 PCM 文件。

保存音频以后,应用根据返回的采样率和声道创建 AudioRenderer,按实际写入长度推进,等待播放器排空后再释放资源。生成文件与播放文件因此成为两个可以分别控制的步骤,声音也能在合成结束后再次读取。

核心关键代码

import { textToSpeech } from '@kit.CoreSpeechKit';
import { common } from '@kit.AbilityKit';
import { fileIo } from '@kit.CoreFileKit';
import { audio } from '@kit.AudioKit';
import { SpeechSink } from './SpeechSession';

export class SpeechOutput {
  private engine: textToSpeech.TextToSpeechEngine | undefined;
  private chunks: Map<number, ArrayBuffer> = new Map();
  private sink: SpeechSink;
  private format: textToSpeech.StartResponse | undefined;
  private renderer: audio.AudioRenderer | undefined;
  private request: string = '';
  private savedPath: string = '';
  private playbackGeneration: number = 0;
  constructor(sink: SpeechSink) { this.sink = sink; }

  async speak(context: common.UIAbilityContext, text: string, dataOnly: boolean): Promise<void> {
    // 清理旧请求和播放状态,再为本次合成创建独立标识。
    this.stop();
    this.chunks.clear();
    this.savedPath = '';
    this.request = 'tts' + Date.now();
    const request = this.request;
    // 创建是异步操作;如果请求已失效,就释放刚创建的引擎。
    const created = await textToSpeech.createEngine({ language: 'zh-CN', person: 13, online: 1 });
    if (request !== this.request) { created.shutdown(); return; }
    this.engine = created;
    const voices = await created.listVoices({ requestId: 'voices' + Date.now(), online: 1 });
    if (request !== this.request) { return; }
    this.sink('voices', JSON.stringify(voices));
    this.engine.setListener({
      // 保存开始回调中的音频格式,供保存后的文件播放使用。
      onStart: (id: string, response: textToSpeech.StartResponse) => {
        if (id !== this.request) { return; }
        this.format = response;
        this.sink('ttsStart', JSON.stringify(response));
      },
      // type 0 表示合成结束;缓存中有分片时才合并并保存。
      onComplete: (id: string, response: textToSpeech.CompleteResponse) => {
        if (id !== this.request) { return; }
        this.sink('ttsComplete', JSON.stringify(response));
        if (response.type === 0 && this.chunks.size > 0) { this.save(context); }
      },
      onStop: (id: string, response: textToSpeech.StopResponse) => { if (id !== this.request) { return; } this.sink('ttsStop', JSON.stringify(response)); },
      onError: (id: string, code: number, message: string) => { if (id !== this.request) { return; } this.sink('error', code + ' ' + message); },
      // 按分片序号缓存音频,并复制数据以便后续合并。
      onData: (id: string, data: ArrayBuffer, response: textToSpeech.SynthesisResponse) => {
        if (id !== this.request) { return; }
        this.chunks.set(response.sequence, data.slice(0));
        this.sink('ttsData', response.sequence + ' | ' + data.byteLength + ' 字节');
      }
    });
    // dataOnly 为 true 时返回数据流;为 false 时由引擎直接播放。
    this.engine.speak(text, { requestId: this.request, extraParams: {
      queueMode: 0, speed: 1, volume: 1, pitch: 1,
      languageContext: 'zh-CN', audioType: 'pcm', soundChannel: 1, playType: dataOnly ? 0 : 1
    } });
  }

  // 按数字顺序整理分片,并检查已收到的序号是否从零连续。
  private save(context: common.UIAbilityContext): void {
    const order = Array.from(this.chunks.keys()).sort((a: number, b: number) => a - b);
    let bytes = 0;
    for (let index = 0; index < order.length; index++) {
      if (order[index] !== index) { this.sink('error', '合成分片序号不连续'); return; }
      bytes += this.chunks.get(order[index])!.byteLength;
    }
    // 使用每片的实际长度确定合并位置,避免不同长度造成覆盖。
    const merged = new Uint8Array(bytes);
    let offset = 0;
    order.forEach((seq: number) => {
      const part = this.chunks.get(seq)!;
      merged.set(new Uint8Array(part), offset);
      offset += part.byteLength;
    });
    // 将重组后的 PCM 保存到应用私有目录,文件名对应当前请求。
    this.savedPath = context.filesDir + '/' + this.request + '.pcm';
    const file = fileIo.openSync(this.savedPath, fileIo.OpenMode.CREATE | fileIo.OpenMode.WRITE_ONLY | fileIo.OpenMode.TRUNC);
    try { fileIo.writeSync(file.fd, merged.buffer); } finally { fileIo.closeSync(file); }
    this.sink('ttsSaved', this.savedPath + ' | ' + bytes + ' 字节 | 分片 ' + order.length);
  }

  // 根据合成返回的格式创建播放器,并用代次识别已取消的播放。
  async playSaved(): Promise<void> {
    if (!this.format || !this.savedPath) { throw new Error('请先完成合成并保存音频'); }
    if (this.renderer) { throw new Error('合成文件正在播放'); }
    const generation = ++this.playbackGeneration;
    const renderer = await audio.createAudioRenderer({ streamInfo: {
      samplingRate: this.format.sampleRate, channels: this.format.audioChannel,
      sampleFormat: audio.AudioSampleFormat.SAMPLE_FORMAT_S16LE, encodingType: audio.AudioEncodingType.ENCODING_TYPE_RAW
    }, rendererInfo: { content: audio.ContentType.CONTENT_TYPE_SPEECH, usage: audio.StreamUsage.STREAM_USAGE_MEDIA, rendererFlags: 0 } });
    if (generation !== this.playbackGeneration) { await renderer.release(); return; }
    this.renderer = renderer;
    const file = fileIo.openSync(this.savedPath, fileIo.OpenMode.READ_ONLY);
    try {
      const size = fileIo.statSync(file.fd).size;
      const data = new ArrayBuffer(size);
      const read = fileIo.readSync(file.fd, data);
      await renderer.start();
      let offset = 0;
      // write 可能只接收部分字节,按实际写入量推进位置。
      while (offset < read && generation === this.playbackGeneration) {
        const written = await renderer.write(data.slice(offset, Math.min(read, offset + 8192)));
        if (written <= 0) { throw new Error('AudioRenderer没有接收音频字节'); }
        offset += written;
      }
      if (generation !== this.playbackGeneration) { return; }
      // 等待播放器输出缓冲区排空后,再停止并释放资源。
      await renderer.drain();
      if (generation !== this.playbackGeneration) { return; }
      await renderer.stop();
      this.sink('rendererComplete', '音频写入并排空 ' + offset + ' 字节');
    } finally {
      fileIo.closeSync(file);
      await renderer.release();
      if (this.renderer === renderer) { this.renderer = undefined; }
    }
  }
  // 更新播放代次并清空请求标识,停止文件播放和合成引擎。
  stop(): void {
    this.playbackGeneration++;
    this.renderer?.stop().catch((error: Error) => this.sink('rendererStop', error.message));
    this.request = ''; this.engine?.stop(); this.engine?.shutdown(); this.engine = undefined; }
}
Logo

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

更多推荐