【HarmonyOS 7 语音 AI 开放能力深度实战】 07 语音合成数据流与音频播放实战
前言
应用直接朗读一段文字时,合成引擎可以接管播放。用户点击开始以后,页面等待开始与完成回调,就能更新按钮和提示。如果应用还要保存这段声音、稍后重新播放,或者检查生成音频的长度,只有播放状态就不够了。应用需要拿到真正的音频字节,并且知道这些字节应该按照什么格式解释。
音频数据回调会陆续返回多块内容。一块数据到达,并不代表整段文字已经合成结束;几块数据的到达顺序,也可能受到异步通信影响。如果页面一收到字节就直接追加文件,文件虽然能够写入,声音的前后顺序却可能出错。因此,保存功能需要同时处理请求归属、分片顺序和合成结束时机,随后才能把文件交给播放器。
页面使用 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 后,处理状态会经历初始化和分片接收,随后显示文件保存路径、大小与分片数量。画面中可以核对当前文字、返回格式和保存结果。文件尚未保存时点击播放,页面会提示先完成合成。

生成文件经过离线信号检查,包含非零采样,时长约四十四点四九秒,也没有出现满幅削波采样。
原始文件也可以在开发电脑上转换成 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;
}
全部字节提交以后,播放器内部还可能保留尚未输出的音频。服务因此等待排空,再执行停止,随后关闭文件句柄并释放播放器。页面显示的写入并排空字节数,可以与保存文件的大小比较。本次两处均为一百四十二万三千五百四十六字节,说明保存的内容已经进入这条文件播放流程。

截图显示播放器完成了写入和排空,生成的非静音文件与回调记录也保留了合成和保存结果。本次没有独立采集手机扬声器的声音。
停止按钮还会使当前播放编号失效。文件写入循环每轮检查编号,发现用户已经停止就退出,结束路径继续释放播放器。旧播放器尚未释放时再次点击会被拒绝,释放后可以重新操作;新的播放请求不会自动排队。
当前实现会把整份生成文件读入内存,再分块写给播放器。约四十四秒的固定材料已经完成运行,更长的文件会占用更多内存。
总结
文本转语音除了由引擎直接播报,也可以通过 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; }
}
更多推荐


所有评论(0)