资讯中心

鸿蒙API 20接入百度智能云TTS:免费语音合成实战指南

📅 2026/10/1 21:53:30
鸿蒙API 20接入百度智能云TTS:免费语音合成实战指南
这个系列的第二篇终于来了。上一篇我们聊了鸿蒙上怎么白嫖百度的在线翻译能力那篇发出来之后评论区反响比我预期好不少陆陆续续有朋友私信问翻译能白嫖语音合成能不能也照样搞一套我的答案是能而且代码我已经在HarmonyOS API 20上完整跑通了就是今天这篇的内容。先说清楚这篇文章解决什么问题。很多做鸿蒙应用的朋友都会遇到一个场景阅读类App想把文字转成语音朗读出来工具类App想加个语音提醒或者你想给老人做一个会说话的提醒助手。系统自带的TextToSpeech在鸿蒙上不是没有但中文音色质量参差不齐而且对API 20新工程的适配有些历史包袱。自己训练一个神经网络TTS模型又不现实光采集和标注语音数据就能把人劝退。最接地气的做法就是接在线TTS服务。而百度智能云的语音合成REST API个人开发者只要在免费额度范围内使用完全不用掏钱也不需要开通任何VIP套餐官方给的免费调用量对个人项目和中小型工具完全够用。这篇文章就是把整个接入过程、代码、坑从零到一全部摊开给你看。如果你是刚接触鸿蒙开发的小白这篇文章的代码是可以直接抄的如果你已经是熟手那里面关于API 20的适配细节、缓存策略、并发控制这些经验应该也能让你少走两天弯路。1. 整体设计思路拆解为什么是“鸿蒙百度智能云TTS”这套组合1.1 鸿蒙侧语音合成的现实困境先说鸿蒙这边的现状。HarmonyOS NEXT刚起步的头两年系统自带的TTS能力确实在慢慢变好但离“好用”还有一段距离。主要体现在几个地方第一系统TTS的语音包是跟着系统走的用户换了一台设备音色可能完全不一样应用体验没法保持一致第二系统TTS的音色模型风格偏机械念长文本的时候断句不够自然听久了容易疲劳第三部分老的API在API 20新架构下已经不推荐使用你需要处理一堆兼容问题。第三个问题尤其要命。很多从OpenHarmony早期版本迁移上来的开发者会发现之前在旧版本上能跑的TTS相关接口到了API 20上要么废弃要么换了调用方式强行用旧写法会有告警甚至直接编译不过。与其去啃那些文档都还没写全的系统接口不如直接在应用层解决做一次网络请求把文本发给云端合成拿回音频流播放这样不管系统底层怎么变只要HTTP接口能力还在我们的功能就稳定能用。1.2 百度智能云的免费额度到底够不够用百度智能云语音合成服务对个人开发者提供的免费额度是按“每月调用次数”来算的。我这边用小号实测了一段周期个人账户默认赠送的免费调用量足够支撑一个日活几百人的小工具每天生成几百条语音。换算成使用场景假设你的应用每条朗读音频平均40个字免费额度大概能支持你每天合成几千句正常个人项目根本用不完。而且重点在于整个开通和调用过程都不需要绑定支付方式也不存在“免费试用几天之后自动扣费”这种坑。只要你在百度智能云的控制台里创建应用、开通语音合成服务拿到ApiKey和SecretKey就可以直接通过REST接口调用。这就是很多人说的“无需VIP即可下载”——不是破解了什么付费能力而是官方本来就给了足够的免费资源普通人不知道而已。1.3 方案选型用REST API而不是SDK鸿蒙生态早期做过Android兼容那时候很多开发者图省事直接把Android SDK往鸿蒙项目里塞。但到了HarmonyOS NEXT阶段这条路已经彻底堵死了。百度智能云的官方SDK目前主要覆盖Android、iOS、Java、Python等平台还没有针对鸿蒙系统的官方SDK。所以我没有等官方适配而是直接走REST API。这样做有几个好处不受SDK版本约束鸿蒙API 12能用、API 20能用以后API 30大概率也能用只要接口不砍。不需要在项目里引入一堆aar或者so文件工程体积更干净。所有逻辑都能用ArkTS原生实现代码可控性高出了问题自己就能定位。代价是什么呢一切都要自己封装。包括鉴权请求、AccessToken管理、音频数据的接收与解码、本地缓存。好在百度智能云的REST接口文档写得很清楚配合抓包工具调几轮就能通。这也是这篇文章存在的意义——把这套封装过程完整记录下来你就不用再从头踩坑了。2. 准备工作百度智能云账号申请与语音合成服务配置2.1 控制台操作流程五分钟搞定所有配置这一段是给第一次接触百度智能云的朋友看的整个过程大概五分钟不需要任何付费操作。第一步打开百度智能云官网用百度账号登录控制台。没有账号的话先注册一个个人用户实名认证之后就可以使用大部分云服务。第二步进入“管理控制台”在产品列表里找到“人工智能”分类下的“语音技术”点进去之后选择“语音合成”。第三步创建应用。这里需要填一个应用名称随便取一个跟你项目相关的名字就行比如HarmonyTTS。创建完成之后系统会给你生成两个关键字符串API Key和Secret Key。这两个东西一定要保存好后面所有鉴权操作都靠它们。第四步确认服务状态是“已开通”。语音合成服务默认是开通状态不需要额外申请直接就能调用。如果页面显示“未开通”点一下“立即开通”按钮按提示走完流程即可。这里有一个容易踩的坑百度智能云的多个产品共用一套账号体系但API Key和Secret Key是按“应用”维度的不是按“产品”维度的。也就是说你创建一个应用之后这个应用名下可以同时开通语音识别、语音合成、自然语言处理等多项服务它们共用同一套Key。如果你之前为了测试其他百度云服务已经建过应用那直接在那个应用里开通语音合成就行不需要重新建。2.2 获取AccessToken语音合成的前置鉴权步骤百度智能云的语音合成REST接口采用OAuth 2.0的client_credentials模式做鉴权。通俗讲就是每次调用合成接口之前你需要先拿着API Key和Secret Key去换一个有时效性的临时通行证这个通行证就是AccessToken。AccessToken的有效期一般是30天所以没必要每次合成语音都重新获取一次正确的做法是把这个Token在本地缓存起来过期了再重新获取。如果每次请求都去换Token不仅浪费时间还有可能触发百度侧的频率限制。获取AccessToken的HTTP请求是这样的POST https://aip.baidubce.com/oauth/2.0/token Content-Type: application/json { grant_type: client_credentials, client_id: 你的API Key, client_secret: 你的Secret Key }实际请求的时候也可以把参数放在URL的query string里面效果是一样的POST https://aip.baidubce.com/oauth/2.0/token?grant_typeclient_credentialsclient_id你的APIKeyclient_secret你的SecretKey成功之后返回的JSON长这样{ refresh_token: 25.ddd..., expires_in: 2592000, session_key: 9mzd..., access_token: 24.f4a1..., scope: audio_tts_post brain_all_scope WiseBotBasic, session_secret: ad025... }我们只需要access_token这个字段expires_in是有效期秒数2592000秒正好是30天。我在项目里写了一个工具类来管理AccessToken核心逻辑是先读本地缓存如果没有或者快过期了才重新发起网络请求获取。这样一方面省流量另一方面也避免每次启动App都卡在Token获取上。3. 鸿蒙API 20项目搭建与基础能力封装3.1 DevEco Studio创建新工程版本选型要留意我这次用的开发环境是DevEco Studio 5.x版本对应的SDK是HarmonyOS 5.0系列API版本是API 20。如果你电脑上已经装了DevEco Studio新建工程的时候注意看“Compile SDK”选项选成API 20就行。创建工程的时候选择Empty Ability模板这个模板最干净没有多余代码。工程名称可以叫TtsSample包名我用的是com.example.ttssample不是固定的你自己取一个能过编译的就行。工程建好之后有两个地方需要提前改。第一module.json5里要申请网络权限。HarmonyOS的权限模型比较严格访问网络需要在配置文件里显式声明{ module: { requestPermissions: [ { name: ohos.permission.INTERNET } ] } }第二网络安全配置。如果调用的是https接口默认是允许的但如果你的调试环境或者某些代理工具拦截了证书会出现请求失败。开发阶段可以在工程配置里临时允许明文流量不过发布的时候建议删掉仅保留https请求。3.2 封装一个通用的HTTP请求工具鸿蒙的HTTP请求走的是ohos.net.http模块这个模块用起来跟Node.js的http模块有点像都是创建一个HttpRequest对象然后通过回调或者Promise处理结果。API 20里同样支持kit.BasicServicesKit的导入方式。我习惯把所有网络请求封装成一个工具类HttpRequestUtil.ets这样TTS的代码里就不必每次都写一堆createHttp()的样板代码。关键是请求TTS合成接口的时候返回的数据类型不是JSON而是音频的二进制数组这在http.HttpDataType里需要显式指定为ARRAY_BUFFER。下面是获取AccessToken的封装代码import http from ohos.net.http; import { BusinessError } from kit.BasicServicesKit; export class HttpRequestUtil { /** * 获取百度智能云AccessToken * param apiKey 应用的API Key * param secretKey 应用的Secret Key * returns Promisestring access_token */ static getAccessToken(apiKey: string, secretKey: string): Promisestring { return new Promise((resolve, reject) { const httpRequest http.createHttp(); const url https://aip.baidubce.com/oauth/2.0/token ?grant_typeclient_credentials client_id apiKey client_secret secretKey; httpRequest.request( url, { method: http.RequestMethod.POST, header: { Content-Type: application/json }, expectDataType: http.HttpDataType.STRING, connectTimeout: 10000, readTimeout: 10000 }, (err: BusinessError, data: http.HttpResponse) { if (!err data.responseCode 200) { // 这里的data.result是字符串需要解析JSON const result data.result as string; const json JSON.parse(result) as Recordstring, Object; const token json.access_token as string; resolve(token); } else { reject(new Error(getAccessToken failed: ${err?.message ?? data.responseCode})); } httpRequest.destroy(); } ); }); } }要注意的点回调处理完之后一定要调用httpRequest.destroy()释放资源。如果频繁创建HttpRequest对象不销毁在高频调用场景下会报“too many requests”或者连接数超限的错误。这个坑我在并发测试的时候踩过状态栏日志刷了一屏的报错信息排查半天才发现是资源没释放。3.3 登录模块与全局Token管理标题里提到的“登录模块”严格说起来不是用户登录而是对百度智能云的身份认证模块。我自己在工程里建了一个BaiduAuthManager.ets职责很单一管理AccessToken的获取、缓存和过期刷新。import { HttpRequestUtil } from ./HttpRequestUtil; export class BaiduAuthManager { private static instance: BaiduAuthManager; private token: string ; private expireTime: number 0; private readonly apiKey: string 你的API Key; private readonly secretKey: string 你的Secret Key; private readonly expireDuration: number 25 * 24 * 60 * 60 * 1000; static getInstance(): BaiduAuthManager { if (!BaiduAuthManager.instance) { BaiduAuthManager.instance new BaiduAuthManager(); } return BaiduAuthManager.instance; } async getToken(): Promisestring { const now Date.now(); if (this.token ! now this.expireTime) { return this.token; } const newToken await HttpRequestUtil.getAccessToken(this.apiKey, this.secretKey); this.token newToken; // 为了让Token在前台稳定可用这里把它缓存25天而不是30天提前几天刷新 this.expireTime now this.expireDuration; return newToken; } }这个模块里有两处经验可以分享。一是Token不要卡着30天才刷新我在代码里故意把缓存时间压到了25天。因为如果用户在Token过期的临界点使用App正好赶上刷新请求失败那这次语音合成就会直接失败。提前5天刷新相当于给自己留了容错余量。二是单例模式的运用。BaiduAuthManager在应用生命周期内只存在一个实例Token缓存也是全局唯一的避免了多个页面各持一份Token、各自去请求的重复浪费。4. 核心TTS调用链路从文本到语音播放的完整实现4.1 语音合成REST接口与参数细节准备工作全部完成后终于进入最重要的环节调用语音合成接口。百度的在线语音合成REST接口地址是固定的https://tsn.baidu.com/text2audio这个接口支持GET和POST两种请求方式参数基本一致。我实际测试下来的结论是短文本用GET足够长文本建议用POST因为GET的URL长度有限制文本内容一旦超过几百个字URL就会超出服务器限制。关键参数我整理成了表格方便查对参数名类型是否必填说明texstring是待合成的文本长度限制单次12000字节中文字符注意UTF-8编码tokstring是前面获取到的AccessTokencuidstring是用户唯一标识我直接用设备ID或者固定字符串ctpint是客户端类型固定填1表示web端lanstring是语言目前固定zh只支持中文spdint否语速取值0-15默认5数字越大语速越快pitint否音调取值0-15默认5数字越大音调越高volint否音量取值0-15默认5音量最大15perint否音色0为女生1为男生3为情感男声4为情感女声默认0aueint否音频格式3为mp34为pcm5为wav6为m4a默认3b64int否是否base64编码返回1为返回base64编码后的音频其他值为直接返回音频流我最终选择的是aue6m4a格式配合b641。原因有三点m4a压缩率高同样一段语音比mp3体积小20%左右鸿蒙的AVPlayer原生支持m4a播放不需要额外解码库b641返回的是一段base64字符串在ArkTS里处理起来比直接操作二进制流方便很多无论是存本地还是传给播放器都顺手。4.2 完整可运行的TTS请求代码直接上代码。这是我自己工程里实际在用的TtsService.ets抽掉业务逻辑之后的核心方法import http from ohos.net.http; import { BusinessError } from kit.BasicServicesKit; import { BaiduAuthManager } from ./BaiduAuthManager; export interface TtsOptions { spd?: number; // 语速 0-15 pit?: number; // 音调 0-15 vol?: number; // 音量 0-15 per?: number; // 音色 0女 1男 3情感男声 4情感女声 aue?: number; // 音频格式 3 mp3 4 pcm 5 wav 6 m4a } export class TtsService { static async synthesize(text: string, options?: TtsOptions): Promisestring { const token await BaiduAuthManager.getInstance().getToken(); const opts: TtsOptions Object.assign({ spd: 5, pit: 5, vol: 5, per: 4, aue: 6 }, options); const params new URLSearchParams(); params.append(tex, text); params.append(tok, token); params.append(cuid, harmony_os_tts_demo); params.append(ctp, 1); params.append(lan, zh); params.append(spd, opts.spd!.toString()); params.append(pit, opts.pit!.toString()); params.append(vol, opts.vol!.toString()); params.append(per, opts.per!.toString()); params.append(aue, opts.aue!.toString()); params.append(b64, 1); return new Promise((resolve, reject) { const httpRequest http.createHttp(); httpRequest.request( https://tsn.baidu.com/text2audio, { method: http.RequestMethod.POST, header: { Content-Type: application/x-www-form-urlencoded }, extraData: params.toString(), expectDataType: http.HttpDataType.STRING, connectTimeout: 15000, readTimeout: 30000 }, (err: BusinessError, data: http.HttpResponse) { if (!err data.responseCode 200) { const result data.result as string; // 如果返回的是JSON格式的错误信息说明调用失败 if (result.startsWith({)) { reject(new Error(TTS error: ${result})); } else { // 正常情况返回base64编码的音频 resolve(result); } } else { reject(new Error(TTS request failed: ${err?.message ?? data.responseCode})); } httpRequest.destroy(); } ); }); } }有几个细节说明一下。URLSearchParams是ArkTS的标准API用来组装表单格式的请求体比手动拼字符串要安全得多尤其当文本里包含特殊字符的时候手动拼接很容易出问题。expectDataType设置了STRING因为加了b641之后返回的就是字符串不需要再处理ArrayBuffer。返回结果里有一个很重要的判断如果百度服务器返回的是JSON字符串以{开头说明请求出错了返回体里带的err_msg字段会告诉我们具体原因。这时候如果仍然按照base64去解码播放器会直接报错。4.3 语音播放与本地缓存机制拿到base64字符串之后还不能直接播放给用户听。需要先解码成二进制数据写到一个本地文件里然后再通过AVPlayer播放。这一整套流程我封装成了AudioPlayerManager.ets。首先是把base64转成文件。这个操作涉及到两个工具函数一个是base64解码一个是文件写入。鸿蒙的ohos.file.fs模块提供了文件读写能力代码大概是这样的import fs from ohos.file.fs; import util from ohos.util; import { common } from kit.AbilityKit; export class AudioFileHelper { static async saveBase64ToCache(base64Str: string, fileName: string): Promisestring { const context getContext(this) as common.UIAbilityContext; const cacheDir context.cacheDir; const filePath ${cacheDir}/${fileName}; // base64转Uint8Array const buffer util.Base64Helper.decodeSync(base64Str); const uint8Arr new Uint8Array(buffer); // 写入缓存目录 const file fs.openSync(filePath, fs.OpenMode.READ_WRITE | fs.OpenMode.CREATE | fs.OpenMode.TRUNC); try { fs.writeSync(file.fd, uint8Arr.buffer); } finally { fs.closeSync(file); } return filePath; } }然后是AVPlayer播放。API 20的播放器和旧版本API有区别现在推荐用ohos.multimedia.media的createAVPlayer()。播放本地文件的核心代码import media from ohos.multimedia.media; export class AudioPlayManager { private avPlayer: media.AVPlayer | null null; async playFile(filePath: string): Promisevoid { if (this.avPlayer null) { this.avPlayer await media.createAVPlayer(); } const player this.avPlayer; player.url file://${filePath}; // 等stateChange回调进入initialized状态后再调用prepare await new Promisevoid((resolve) { const handler () { if (player.state initialized) { player.off(stateChange, handler); resolve(); } }; player.on(stateChange, handler); player.prepare(); }); player.play(); } }注意一个细节AVPlayer的url属性在设置之后并不是马上就生效的要等状态机从idle切换到initialized才能调用prepare()。刚接触AVPlayer的人经常在这儿卡住——明明url设置成功了但调用prepare()就报错实际上是状态机还没就位。缓存机制这块我的策略是“文本哈希做文件名”。同一段文本如果之前已经合成过语音本地缓存文件存在就直接播放缓存不再请求网络。这个优化对重复朗读的场景收益非常大比如用户反复点同一篇文章的朗读按钮第二次几乎就是秒开。4.4 关于“无需VIP即可下载”的说明再说回标题里那句话。很多朋友看到“无需VIP即可下载”第一反应是怀疑是不是需要破解什么付费墙。其实不是。百度的在线TTS本来就是免费开放给开发者的免费额度内正常调用就行不需要开通任何付费套餐也不需要是会员。所谓“VIP”更多是指部分语音合成平台把高级音色和长文本合成能力放在付费会员体系后面普通用户想用必须充值。而百度智能云的语音合成个人开发者用默认的几个音色在免费额度范围内完全够用。另外很多人以为要下载某个离线语音包才能用这也是一个误区。在线TTS的核心优势就是不需要在本地维护庞大的语音模型文本发过去音频传回来省事且模型迭代跟得上。我上一篇讲在线翻译的时候说过一句话这里再重复一遍把计算放在云端把体验留在本地这才是个人开发者最聪明的做法。5. 常见问题与排查技巧实录5.1 状态码与错误信息速查表我在调试过程中把可能遇到的状态码和错误信息整理成了表格排错的时候可以直接对照状态码/错误码含义排查方向500不支持合成检查lan参数是否填了zh目前只支持中英文混合501参数错误逐一检查tex、tok、ctp、lan是否有漏填或格式错误502请求方式不支持确认是GET或POST部分参数组合只支持POST3300输入参数不正确重点排查tex是否为空或超长UTF-8编码是否正确3301音频合成失败通常是文本包含特殊字符或者音色参数非法重置per、spd等参数试试3302音频数据失败检查网络状态可能音频流在传输过程中被中断了3303token验证失败大概率是AccessToken过期或者复制错了重新获取再试3304文本包含非法字符检查文本里是否有控制字符或者表情符号我自己遇到最多的是3303原因基本都是Timer设置太长Token已经过期了但本地缓存没有刷新逻辑。后来我把缓存时间压到25天并且增加了失败自动重试逻辑这个问题就很少出现了。5.2 我踩过的几个坑第一个坑是base64解码时的内存问题。有一段音频文字比较长合成出来的base64字符串接近1MB直接用util.Base64Helper.decodeSync去解码在部分低内存设备上会出现OOM。后来我的解决方法是限制单次合成文本长度把超过500字的文本切成多段逐段合成再拼接播放既避免了内存问题也绕开了百度单次请求的文本长度限制。第二个坑是播放器状态机误判。AVPlayer的stateChange回调在一个音源初始化完成后会进入completed状态但很多人在这里会误判为播放结束。实际上completed表示的是当前数据源准备工作完成真正的播完状态是stopped或者重新触发completed事件。如果你在这个时机就释放播放器会出现点击播放之后立刻报错“播放器已释放”的问题。第三个坑是并发请求。我的工具类里没有加锁如果用户快速点击多次播放按钮会同时发起多个HTTPS请求而百度侧对同一个AccessToken的并发请求是有频率限制的。轻则部分请求返回错误码重则触发临时封禁。解决方式很粗暴也很有效在TtsService里维护一个“当前是否正在请求”的布尔变量如果上一个请求还没返回直接忽略新的请求。第四个坑是URL编码。ArkTS里的encodeURIComponent和Web标准不太一样它在处理某些Unicode字符时行为有差异。我一开始直接用encodeURIComponent(text)去拼GET请求的URL结果含中文标点的文本总是合成失败。后来改成POST URLSearchParams这个问题彻底消失。5.3 性能与体验优化建议最后一个部分聊聊怎么把体验做得更顺手。第一点是预加载。如果你的应用有“下一段自动朗读”的需求可以在当前段落播放到一半的时候就把下一段的语音文件缓存好。因为TTS合成一个60字的段落最快也需要两三百毫秒不算快但不预加载也够用真正会卡顿的是用户快速连点的时候。所以一个最简单的优化就是播放器进入playing状态后马上触发下一段的合成请求。第二点是音频格式的选择。前面提到我用了m4a这个格式在压缩率和兼容性上平衡得不错。如果你对延迟特别敏感可以考虑aue4的pcm格式省去解码环节但文件体积会大很多。个人建议大多数场景用m4a就够了。第三点是错误时要给用户一个温柔的提示。调不通的时候不要直接弹一个“网络错误”的对话框那会让用户以为是你App的问题。我自己会在界面上显示“语音合成服务暂时不可用请稍后重试”然后静默重试一次如果第二次还失败再让用户检查网络。这个细节虽然小但对用户留存的影响其实很直接。写在最后的实战体会这次把百度智能云TTS接到鸿蒙API 20的工程里前前后后大概花了两天时间。第一天主要花在了解百度鉴权流程和鸿蒙新API上第二天就在调格式和播放器了。真正把代码理顺之后回头再看这个方案的工程量并不大核心也就是“Token管理”加“HTTP请求”加“音频播放”三件事。我个人在实际使用中的体会是这类在线云服务的能力拼装最怕的不是接口复杂而是文档和实际行为对不上。百度的文档整体算清晰的但有些细节也要实测之后才敢确定比如b641参数在某些旧文档里根本没写我也是抓包的时候无意间发现的。所以如果你照着这篇文章做过程中遇到任何跟预期不一致的地方不妨先用抓包工具看一眼实际请求和返回很多时候答案就在报文里。这个项目后续还能扩展的方向也不少。比如接入流式语音合成实现边说边播或者把音色换成情感男声/女声做更丰富的场景再或者把本地缓存升级成SQLite索引方便管理。如果你在鸿蒙上把这个方案跑通了欢迎回来评论区聊聊你的使用场景。这就是做开源工程最让人满足的地方——你踩过的每一个坑都会成为下一个人的路灯。

看完文章,想为自己的企业也做一次专业网站诊断?

尧图顾问免费为您评估现有网站,并给出建站/改版建议与报价方案。

免费获取方案