1. 从“会说话”到“说得好听”进阶TTS的意义上次咱们聊了鸿蒙文本转语音TTS的基础用法几分钟就能让应用“开口说话”。但说实话那种最基础的播报声音听起来可能有点“机器味儿”干巴巴的就像新闻联播的自动播报每个字都清楚但缺乏情感和节奏。如果你正在开发一个智能阅读助手、一个有声书应用或者一个需要更自然语音交互的智能客服这种基础效果可能就差点意思了。用户要的不是一个复读机而是一个有“灵魂”的讲述者。比如在阅读小说时不同角色的对话应该有语气差异播报新闻时重要的数据需要强调在智能客服中适时的停顿能让用户更好地理解信息。这些都需要我们对语音播报进行更精细的“雕刻”。鸿蒙的TTS引擎其实藏着不少“高级工具”用好它们你就能把生硬的电子音变成富有表现力的“好声音”。今天我就带你深入这些进阶功能核心就两件事一是怎么拿到并处理原始的音频数据流二是如何通过特殊文本标记像导演一样指挥语音的节奏、发音和语调。掌握了这些你的应用语音体验就能上一个大的台阶。2. 深入音频流不止于“播放”更要能“创作”基础用法里我们调用speak声音就直接从设备扬声器出来了。但很多时候我们需要的不仅仅是“播放”而是“获取”音频数据本身。想象一下你想让用户把一段文本转换成音频文件保存下来或者想对音频进行实时处理比如混音、添加背景乐再或者需要将音频流通过网络传输给其他设备。这时候onData回调就是你的金钥匙。2.1 理解 onData 回调音频数据的传送带当你调用speak方法后TTS引擎并不是一次性生成整个音频而是像流水线一样一段一段地合成并推送出来。onData回调就是这个流水线的出口每当有一小段音频数据合成完毕它就会被包装好送到这里。我们来看一下这个回调的详细参数onData(requestId: string, audio: ArrayBuffer, response: textToSpeech.SynthesisResponse) { // 你的处理逻辑 }requestId: 这次合成任务的唯一ID。如果你同时发起了多个播报请求可以用它来区分数据属于哪个任务。audio: 这是核心一个ArrayBuffer类型的原始音频数据。你可以把它想象成一串最原始的、未经包装的音频字节流。后续无论是保存为文件还是进行网络传输都要基于这个数据。response: 一个包含元数据的对象非常重要。它通常包括sequence: 当前数据块的序列号从0开始递增。你可以用它来判断音频流是否接收完整。audioDuration: 当前这个数据块对应的音频时长单位毫秒。这对于计算总时长或进度非常有用。可能还有其他信息如采样率、位深等具体需查阅最新API文档。我刚开始用的时候以为onData只会触发一次拿到整个音频。结果踩了坑发现它是多次触发的。所以处理它的标准姿势是在组件或全局准备一个数组用来拼接每次收到的audio数据块。2.2 实战将音频流保存为本地文件光说不练假把式。咱们来实现一个最实用的功能把文本转成的语音保存为手机上的.wav或.pcm文件。这里以保存为最原始的PCM格式为例因为它不需要复杂的文件头处理起来最简单。首先我们需要一个地方来累积音频数据。在ArkUI的组件里我们可以用一个ArrayBuffer[]来存储。import { textToSpeech } from kit.CoreSpeechKit; import { fileIo } from kit.CoreFileKit; // 引入文件IO模块 Entry Component struct TTSRecorder { private ttsEngine: textToSpeech.TextToSpeechEngine | undefined; private requestId: string record_001; // 用于存储音频数据块 private audioChunks: ArrayBuffer[] []; // 记录总数据大小方便后续创建缓冲区 private totalAudioLength: number 0; async aboutToAppear() { this.ttsEngine await textToSpeech.createEngine({ language: zh-CN, person: 0, online: 1 }); this.ttsEngine.setListener({ onStart: (reqId, resp) { console.info(开始合成: ${reqId}); // 开始新的录制前清空旧数据 this.audioChunks []; this.totalAudioLength 0; }, onData: (reqId, audioChunk, resp) { console.info(收到音频块 ${resp.sequence}, 时长: ${resp.audioDuration}ms); // 保存数据块 this.audioChunks.push(audioChunk); this.totalAudioLength audioChunk.byteLength; }, onComplete: async (reqId, resp) { console.info(合成完成: ${reqId}); // 所有数据块接收完毕开始合并和保存 await this.saveAudioToFile(); }, onError: (reqId, code, msg) { console.error(合成出错: ${msg}); } }); } }接下来是最关键的一步合并数据块并写入文件。PCM文件是裸数据直接写入即可。private async saveAudioToFile() { if (this.audioChunks.length 0) { console.error(没有音频数据可保存); return; } try { // 1. 合并所有ArrayBuffer const mergedBuffer new Uint8Array(this.totalAudioLength); let offset 0; for (const chunk of this.audioChunks) { mergedBuffer.set(new Uint8Array(chunk), offset); offset chunk.byteLength; } // 2. 获取应用沙箱路径并创建文件 const context getContext(this) as common.UIAbilityContext; const filesDir context.filesDir; const filePath ${filesDir}/my_audio_record.pcm; // 3. 写入文件 const file await fileIo.open(filePath, fileIo.OpenMode.READ_WRITE | fileIo.OpenMode.CREATE); await fileIo.write(file.fd, mergedBuffer.buffer); await fileIo.close(file.fd); console.info(音频文件已保存至: ${filePath}); // 这里可以给用户一个提示比如弹个Toast } catch (error) { console.error(保存音频文件失败:, error); } }注意直接保存的PCM文件没有采样率、声道数等头信息一般的播放器可能无法直接播放。如果你需要生成通用的.wav文件需要在数据前面拼接一个符合WAV标准的文件头这需要你根据TTS引擎的输出格式通常是单声道、16位、16kHz采样率来计算并写入相应的头信息。这是一个稍微复杂但非常值得掌握的技能能让你的音频文件在任何地方都能播放。2.3 进阶想象音频流的更多玩法拿到音频流之后世界就宽广了。除了保存文件你还可以实时音频可视化将ArrayBuffer数据转换为音频振幅驱动一个动态的波形图或频谱图让用户“看到”声音。网络流式传输将接收到的每一个audioChunk立即通过WebSocket或RTC协议发送给另一个客户端实现近乎实时的“语音转发”或“语音直播”。后期处理将音频数据送入另一个音频处理库需要寻找或实现HarmonyOS Native的音频处理库进行变速、变调、添加混响等效果再播放出来。这些玩法的核心都是一样的在onData回调中捕获数据然后把它送到你想要的下一站。这打破了TTS只能用于即时播放的限制让你的应用创意有了更大的发挥空间。3. 像导演一样定制播报特殊文本标记详解如果说处理音频流是“幕后制作”那么通过特殊文本标记控制播报效果就是“现场执导”。鸿蒙TTS支持在待播报的文本中嵌入一些特殊格式的标记这些标记不会被读出来但会指挥语音引擎如何播报它前后的内容。这功能对于提升播报的自然度和专业性至关重要。3.1 控制单词与数字的播报方式你有没有遇到过TTS把英文单词“AI”读成了“爱”或者把电话号码“13800138000”一口气当成一个巨大的数字读出来用这两个标记就能完美解决。单词播报控制[hN][h0](默认)引擎智能判断。对于常见的单词会连读对于非常见的可能按字母读。[h1]强制按字母逐个拼读。比如“AI[h1]”会被读成“A - I”确保技术术语的准确性。[h2]强制以单词方式连读。比如确保某个缩写始终被读成一个单词。实战场景在播报产品型号、代码变量名或英文人名时非常有用。例如播报“请查看文档API[h1]章节”就能清楚地读出“A-P-I”而不是“啊皮”。数字播报控制[nN][n0](默认)引擎智能判断。有时会读成数值有时会读成号码。[n1]强制作为号码即单个数字播报。“13800138000[n1]”会被读成“一三八零零一三八零零零”这是播报手机号、身份证号的正确方式。[n2]强制作为数值播报。“123[n2]”会被读成“一百二十三”适合播报金额、数量。实战场景智能客服中播报订单号“20240328001[n1]”会清晰地读成“二零二四零三二八零零一”。而播报金额“支付128[n2]元”则会读成“支付一百二十八元”非常自然。3.2 插入精准的静音停顿[pN]停顿是语言的呼吸是表达情感和层次的关键。一个没有停顿的播报就像一口气念完的绕口令让人听得喘不过气。格式[pN]其中N是无符号整数单位是毫秒(ms)。作用在标记的位置插入指定时长的静音。如何使用 直接把它插入到文本中需要停顿的地方。例如播报新闻标题“下面播报一则重要消息[p500]关于人工智能的最新进展。” 这里的[p500]会在“消息”之后制造一个半秒钟500毫秒的停顿让听众有时间消化前半句并引起对后半句的注意。更高级的用法 你可以根据标点符号自动添加停顿模拟真人朗读的节奏。比如在遇到逗号时插入[p200]句号时插入[p500]段落之间插入[p800]。function formatTextWithPauses(text: string): string { // 这是一个简单的示例实际处理可能需要更复杂的正则表达式 let formatted text.replace(//g, [p200]); formatted formatted.replace(/。/g, [p500]); formatted formatted.replace(/\n\n/g, [p800]); return formatted; } // 使用 let newsContent “第一段内容。\n\n第二段内容其中包含细节。” this.ttsEngine.speak(formatTextWithPauses(newsContent), {requestId: ‘news’});这个小技巧能极大提升长文本播报的聆听舒适度尤其在有声书应用中效果显著。3.3 指定汉字的发音[MN]中文里多音字是TTS的一大挑战。“行长”是读“háng zhǎng”还是“xíng zhǎng”“重担”是读“zhòng dàn”还是“chóng dān”通过[MN]标记你可以充当校对员确保每个字都发对音。格式[MN]M该汉字的拼音不带声调。N声调取值1-5。1: 阴平 (第一声ā)2: 阳平 (第二声á)3: 上声 (第三声ǎ)4: 去声 (第四声à)5: 轻声 (a)作用强制指定紧随其后的一个汉字的读音。实战案例解决多音字“银行行长[hang2]宣布了新政策。” 这里确保“行”读成“háng”。纠正误读某些生僻字或特定词汇TTS可能读错你可以手动纠正。比如TTS可能把“哪吒”读成“né zhā”你可以写成“哪[na2]吒[zha1]”来确保正确。特殊需求在语言学习类APP中你可以用这个功能来展示一个字的不同读音。例如“这个字有两种读法重[zhong4]量和重[chong2]新。”重要提示这个标记只影响紧挨着它的一个汉字。如果你想指定一个词两个汉字的读音需要对每个字分别标记。例如“重[zhong4]担[dan4]”。4. 综合实战打造一个智能新闻播报助手现在我们把所有进阶功能组合起来设想一个稍微复杂的场景一个智能新闻播报助手。它不仅能朗读新闻还能自动为新闻正文添加合理的停顿。智能处理新闻中的英文缩写和数字如“GDP增长5.2%”。将每一条新闻的语音保存为音频文件供用户离线收听。在播报时在UI上显示实时的音频波形。由于篇幅所限我们无法写出全部代码但可以勾勒出核心架构和关键代码片段。第一步文本预处理在发送给TTS引擎前先对新闻文本进行“预处理加工”。class NewsProcessor { static process(text: string): string { let result text; // 1. 处理常见的英文缩写 (假设我们有一个缩写词典) const abbrMap { ‘GDP’: ‘[h1]’, ‘AI’: ‘[h1]’, ‘CEO’: ‘[h1]’ }; for (const [abbr, tag] of Object.entries(abbrMap)) { const regex new RegExp(\\b${abbr}\\b, ‘g’); result result.replace(regex, ${abbr}${tag}); } // 2. 处理长数字串如手机号、年份假设13位以内数字可能是手机号 result result.replace(/\b\d{11,13}\b/g, ‘$[n1]’); // 3. 根据标点添加停顿 result result.replace(/。/g, ‘[p400]’); result result.replace(//g, ‘[p150]’); result result.replace(//g, ‘[p200]’); return result; } } // 使用 let rawNews “我国AI产业快速发展2023年GDP增长5.2%。某公司CEO表示前景乐观。” let processedText NewsProcessor.process(rawNews); // 处理后: “我国AI[h1]产业快速发展[p150]2023年GDP[h1]增长5.2%[p400]某公司CEO[h1]表示前景乐观。”第二步播报并同时保存与可视化在组件的onData回调中我们同时做三件事累积数据用于保存、转换数据用于可视化、播放数据引擎默认会播放我们无需干预。onData: (reqId, audioChunk, resp) { // 1. 保存累积数据块 this.audioChunks.push(audioChunk); this.totalAudioLength audioChunk.byteLength; // 2. 可视化将ArrayBuffer转换为振幅数据 // 假设audio是16位深度的PCM数据 const int16Array new Int16Array(audioChunk); let sum 0; for (let i 0; i int16Array.length; i) { sum Math.abs(int16Array[i]); } const averageAmplitude sum / int16Array.length; // 更新UI状态变量驱动波形图组件 this.currentAmplitude averageAmplitude; // 3. 播放引擎会自动处理我们不用管 },第三步播报完成后的文件保存在onComplete回调中调用我们之前写的saveAudioToFile方法将本条新闻的音频保存下来文件名可以包含新闻ID和时间戳。通过这样的组合你的应用就从简单的“文本朗读器”进化成了一个功能丰富的“音频内容生产与播报中心”。用户既能享受流畅、专业的听感又能获得可保存、可管理的音频资产。这些进阶功能的学习曲线并不陡峭但它们带来的体验提升是立竿见影的。多动手试试把这些标记和回调用到你的项目里听听看效果的不同很快你就能成为鸿蒙TTS的调音高手。