前言

会议笔记已经整理成文字,用户离开屏幕以后,可能仍然希望听一遍已经确认的事项。应用可以提供一个播报按钮,把当前内容交给语音合成服务。但是,按钮点击成功与整段内容播放结束之间,还隔着引擎初始化、音色选择、合成和音频输出。页面如果在第一个完成回调出现时就恢复空闲,用户可能再次启动播报,让新的内容与尚未结束的声音互相影响。

文本转语音服务负责把文字转换为声音,应用仍然需要选择音色、提交请求并管理页面状态。对于直接播报,合成引擎可以负责播放,页面不必另建播放器;但页面仍需要根据播放事件判断什么时候可以开始下一次操作。

在 nova 14 的 HarmonyOS 7、API 26 Release 环境中,页面收到了音频格式、合成完成和播放完成回调。

文本转语音实战封面

一、先确认文字与当前可用音色

打开文本转语音页面,输入区域说明采用固定中文文字和离线中文音色。当前文字包含194个中文字符,内容用于会议事项播报,没有真实客户信息。应用从资源目录读取文本,并按照原本的文字与标点提交,方便重复运行时核对相同输入。如果一边更换文字一边调整语速,就难以判断时长变化来自哪一项条件。

读取文件时需要按字符编码解码。资源返回的是字节,直接把每个字节转换成字符会破坏中文,页面可能出现乱码,合成结果也会偏离原文。当前文件保存为UTF-8,应用使用系统文本解码器取得字符串。确认能够正确显示中文以后,再把字符串传入播报服务,可以先排除文件读取问题,避免把输入乱码误判为音色或者引擎异常。

单次播报文本不能超过 10000 个字符。当前固定文字在这个范围内,页面将其作为一个请求提交,完成状态也对应这一段文字。较长的内容需要在提交前拆分,并单独管理每段的播放顺序和停止位置。

文字已经确定以后,再查询设备上的音色。当前服务创建中文离线引擎,使用编号13的聆小珊女声。查询结果同时列出了中文男声和英文女声,但后两者的状态为可下载,编号13的状态为已安装。因此,页面采用已经安装的音色,不通过列表中出现过某个名称就认定该声音能够立即使用,也没有触发模型下载。

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

创建参数明确指定离线模式和值为 13 的音色。不同设备上的模型安装情况、语言和服务资源可能不同,因此创建失败时,应根据实际错误检查设备上的音色与语言条件。代码先创建引擎,再查询音色列表并记录结果;它没有根据列表自动下载或切换声音。

固定文字中的标点也属于输入的一部分。当前页面保留原文中的逗号和句号,没有额外添加发音标记或替换数字,提交给合成器的内容与读取到的文字一致。

二、让播放参数对应当前用户动作

用户点击开始文字播报以后,应用会先停止旧请求,再建立新的请求标识。这个标识用于分辨后面收到的回调。初始化属于异步操作,用户可能在创建引擎期间返回其他页面,因此服务在创建完成后再次比较请求标识。如果请求已经失效,新对象会被释放,避免离开的页面稍后突然开始播报。

当前选择直接由引擎完成播放。调用参数中的 playType 设置为 1,表示合成并播放;这种模式不返回音频数据流。应用监听播报状态即可,无须再创建播放器。排查时如果没有收到音频分片,可以先检查请求选择的播放模式。

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

页面传入的最后一个开关为关闭数据模式,因此这次调用选择播放分支。代码同时显式设置语速、音量和音调。语速与音调的取值范围为0.5到2,音量为0到2;当前均为1。

音量参数与设备当前媒体音量也需要分别理解。应用给出的倍数描述合成播放配置,系统输出仍然受到手机音量、连接设备和音频场景的影响。当前页面没有控制系统媒体音量,也没有判断声音究竟从哪个外接设备输出。因此,用户报告没有听到声音时,先确认完成回调和系统输出条件,再决定是否需要调整合成参数,避免无目的地把音量倍数调到最大。

文字播报的引擎、参数与状态关系

音色查询发生在提交文字以前,回调监听也先于播报调用设置。当前参数选择了排队方式,但页面只允许一个操作处于忙碌状态,每次新的服务调用也会停止旧请求,因此页面没有形成多段文字播放队列。

播放参数里的音频场景与返回格式中的声道需要分开。当前调用使用的播放通道参数服务于系统音频输出场景,启动回调中的单声道则描述本次音频数据。两个字段虽然都为一,含义并不相同。

三、合成结束以后,页面为什么还要等待

点击开始文字播报后,状态区先显示初始化,随后记录音色和启动信息。当前收到的启动回调给出原始音频格式、16000赫兹采样率、16位采样、单声道和零压缩率,描述了本次引擎输出的音频格式。

播报页面的固定输入与处理状态

初始化完成后,页面仍然保持忙碌,直到与播放对应的结束事件出现。这个等待条件来自合成与播放两种工作完成的时间不同:文字可以先转换成音频,播放器随后继续消耗已经生成的数据。如果应用只看到完成回调的名称,而没有检查其中的类型,就可能在音频尚未播放完时允许新的操作。

当前固定文字的回调先返回类型0和 synthesis complete,表示合成结束;随后返回类型1和 speak complete,表示播放结束。两条记录之间存在明显时间差,能直接说明当前调用里确实需要区分这两个事件。应用把完整返回对象记录下来,再由页面根据播放完成类型解除忙碌,没有把第一次回调当成所有工作已经结束。

      onStart: (id: string, response: textToSpeech.StartResponse) => {
        if (id !== this.request) { return; }
        this.format = response;
        this.sink('ttsStart', JSON.stringify(response));
      },
      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); }
      },

同一个服务类还支持只合成数据的调用,所以代码中保留了数据保存分支。当前播放模式没有收集音频分片;页面收到类型1的回调后,才结束本次播报操作。

当前请求返回播放完成的实际界面

截图和回调记录显示,这段文字经过合成并收到了播放完成消息。手机扬声器的声音尚未独立采集,因此这里不评价实际音色和响度。

状态区显示服务错误并解除忙碌。创建阶段的语言或音色错误,与播放开始后的音频错误发生在不同阶段,可以根据错误消息定位。

四、返回页面时,让旧请求失效

播报属于持续一段时间的操作,页面离开时需要有明确处理。当前应用在返回功能首页和页面消失时调用停止逻辑,让旧请求不再继续影响界面。服务停止合成与播放,再释放引擎,应用保留的引用随后清空。这个选择适合前台试听和短文本播报,也让不同功能之间的切换有清楚的结束位置。

停止逻辑先清空当前请求标识,后续返回的旧回调就无法通过标识检查。这个处理解决了异步时间差:用户已经点击停止,服务进程中的事件仍可能稍后送到应用。旧事件如果继续把页面改成播放完成,用户就难以判断结束来自主动停止还是自然完成。当前服务对开始、完成、停止和错误都检查请求身份,让结果属于仍然有效的操作。

错误回调出现以后,页面解除忙碌,服务可以通过停止入口处理旧对象。新的播报也会先执行停止逻辑,再生成新的请求标识并创建引擎。

页面切换入口时会清空旧结果,再由新请求的回调更新状态。当前操作在前台执行,没有开启后台播放或后台持续任务。

总结

语音识别把声音转换为文字,文本转语音则提供相反方向的处理:应用提交文字并选择音色,合成引擎生成声音。页面采用 playType: 1,由引擎直接播放一段固定文本。

合成完成与播放完成是两个不同的回调阶段。页面等待播放完成后恢复空闲,停止时使请求标识失效并释放引擎,避免旧消息影响新操作。需要保存生成的声音时,应用可以选择返回音频数据的模式,再自行处理文件和播放。

核心关键代码

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; }
        // 页面以 type 1 判断直接播报结束,避免合成完成后过早解锁。
        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;
    });
    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、测试、元服务和应用上架分发等。

更多推荐