前言
在一个安静而又普通的午后,我坐在电脑前,思索着如何将一个看似遥不可及的愿望化为现实。那个愿望,是一个来自虚拟世界的幻想,一个关于“重生”的故事。
每个人都曾幻想过如果能重新来过会怎么样,纠正生命中的种种错误,抓住逝去的时光。但对于我,这个愿望似乎不再是仅仅停留在幻想中的奢望。作为一名文本转音频API工程师,我一直探索着将文字变成声音的可能性,将想象力融入现实。而这一切的开始,源自于一个神秘而神奇的机会。
我要讲述的是一个充满创意和技术的故事,一个在虚拟和现实之间穿梭的旅程。这是一个关于重生、创造力和坚持不懈的故事,一个我在这个世界中的探索之旅。
故事的主人公是我自己,一个普通的工程师,但这个故事也代表了许多人内心深处的渴望。随着故事的展开,我们将共同经历激动人心的时刻、挑战和成功,一起探索技术的奇妙,以及如何将一个虚拟世界的梦想转化为现实。
请跟随我,一同踏上这段充满未知的旅程,去探索那个无法触及的重生之梦,以及如何将文字转化为声音的神奇过程。这是我在这个世界的故事,也是你我共同的冒险。
故此《从零玩转系列之微信支付UNIAPP》文章当中的功能需要支付成功后提示用户支付成功, 并且提示语说动态变更的那么我就想到了 文本转音频
,这里呢我就介绍使用讯飞的来玩玩!
配置
首先进入 讯飞官方网站 注册、配置信息
创建我的应用、一个只能创建一个
语音合成
可以看到 服务量、接口认证信息、在线语音合成API
每天可以使用 500次的服务量 晚上12点重置 良心~
⚠️二维码可别泄漏咯会扣除真实的服务次数
文档
点击在线语音合成API 旁边的文档按钮
接口要求
集成在线语音合成流式API时,需按照以下要求。
内容 |
说明 |
请求协议 |
wss(为提高安全性,强烈推荐wss) |
请求地址 |
wss: //tts-api.xfyun.cn/v2/tts |
请求行 |
GET /v2/tts HTTP/1.1 |
接口鉴权 |
签名机制,详情请参照下方接口鉴权 |
字符编码 |
UTF8、GB2312、GBK、BIG5、UNICODE、GB18030 |
响应格式 |
统一采用JSON格式 |
开发语言 |
任意,只要可以向讯飞云服务发起Websocket请求的均可 |
操作系统 |
任意 |
音频属性 |
采样率16k或8k |
音频格式 |
pcm、mp3、speex(8k)、speex-wb(16k) |
文本长度 |
单次调用长度需小于8000字节(约2000汉字) |
发音人 |
中英粤多语种、川豫多方言、小语种、男女声多风格,可以在 这里 在线体验发音人效果 |
接口调用流程
- 通过接口密钥基于hmac-sha256计算签名,向服务器端发送Websocket协议握手请求。详见下方 接口鉴权 。
- 握手成功后,客户端通过Websocket连接同时上传和接收数据。数据上传完毕,客户端需要上传一次数据结束标识。详见下方 接口数据传输与接收 。
- 接收到服务器端的结果全部返回标识后断开Websocket连接。
注: Websocket使用注意事项如下
- 服务端支持的websocket-version 为13,请确保客户端使用的框架支持该版本。
- 服务端返回的所有的帧类型均为TextMessage,对应于原生websocket的协议帧中opcode=1,请确保客户端解析到的帧类型一定为该类型,如果不是,请尝试升级客户端框架版本,或者更换技术框架。
- 如果出现分帧问题,即一个json数据包分多帧返回给了客户端,导致客户端解析json失败。出现这种问题大部分情况是客户端的框架对websocket协议解析存在问题,如果出现请先尝试升级框架版本,或者更换技术框架。
- 客户端会话结束后如果需要关闭连接,尽量保证传给服务端的错误码为websocket错误码1000(如果客户端框架没有提供关闭时传错误码的接口。则无需关注本条)
下载Demo
看看咋玩的
调用示例
注: demo只是一个简单的调用示例,不适合直接放在复杂多变的生产环境使用
我们只是看看流程待会不使用这个方式
打开项目后可以看到使用了 Java-WebSocket
、okhttp
等依赖这两个是必须的
将认证信息配置全部填好、均到控制台-语音合成页面获取
public static final String appid = " "; public static final String apiSecret = " "; public static final String apiKey = " ";
修改语音合成文件格式 mp3 默认说 pcm 需要专门的工具播放、我们不需要这玩意.
可以看到 aue
字段 需要传递 lame
参数表示mp3格式
修改aue
修改生成文件格式 mp3
测试
// 合成文本 public static final String TEXT = "欢迎来到讯飞开放平台";
如果需要更改文本则更改此处
点击运行✅可以看到资源文件夹生成了一个mp3音频
重生buff叠满
自己创建一个SpringBoot项目
新增依赖
<!-- Square为Java和Kotlin精心设计的HTTP客户端。--> <dependency> <groupId>com.squareup.okhttp3</groupId> <artifactId>okhttp</artifactId> <version>4.8.1</version> </dependency> <dependency> <groupId>com.google.code.gson</groupId> <artifactId>gson</artifactId> </dependency> <!-- 100%用Java编写的准系统WebSocket客户端和服务器实现 --> <dependency> <groupId>org.java-websocket</groupId> <artifactId>Java-WebSocket</artifactId> <version>1.5.3</version> </dependency>
新增认证配置修改 application.yml
xunfei: hostUrl: https://tts-api.xfyun.cn/v2/tts appid: xxxxxxxxx apisecret: xxxxxxxxx apikey: xxxxxxxxx
编写工具类,东西和刚刚写的demo一样
package com.yby6.utils; import com.google.gson.Gson; import com.google.gson.JsonObject; import lombok.Getter; import okhttp3.*; import okio.ByteString; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Component; import javax.crypto.Mac; import javax.crypto.spec.SecretKeySpec; import java.net.URL; import java.nio.charset.Charset; import java.nio.charset.StandardCharsets; import java.text.SimpleDateFormat; import java.util.*; /** * 讯飞WebApi语音合成 * * @author Yang Buyi * Create By 2023/09/02 */ @Component public class XunFeiUtil { protected static final Logger log = LoggerFactory.getLogger(XunFeiUtil.class); //讯飞四个注入参数,保存在配置文件,便于复用和避免代码上传gitee后泄漏 private static String hostUrl; @Value("${xunfei.hostUrl}") public void setHostUrl(String hostUrl) { XunFeiUtil.hostUrl = hostUrl; } private static String appid; @Value("${xunfei.appid}") public void setAppid(String appid) { XunFeiUtil.appid = appid; } private static String apiSecret; @Value("${xunfei.apisecret}") public void setApiSecret(String apiSecret) { XunFeiUtil.apiSecret = apiSecret; } private static String apiKey; @Value("${xunfei.apikey}") public void setApiKey(String apiKey) { XunFeiUtil.apiKey = apiKey; } public static final Gson json = new Gson(); private static String base64 = ""; private static volatile boolean lock = true; /** * 将文本转换为MP3格语音base64文件 * * @param text 要转换的文本(如JSON串) * @return 转换后的base64文件 */ public static String convertText(String text) throws Exception { lock = true; base64 = ""; // 构建鉴权url String authUrl = getAuthUrl(hostUrl, apiKey, apiSecret); OkHttpClient client = new OkHttpClient.Builder().build(); //将url中的 schema http://和https://分别替换为ws:// 和 wss:// String url = authUrl.replace("http://", "ws://").replace("https://", "wss://"); Request request = new Request.Builder().url(url).build(); List<byte[]> list = new LinkedList<>(); WebSocket webSocket = client.newWebSocket(request, new WebSocketListener() { @Override public void onOpen(WebSocket webSocket, Response response) { super.onOpen(webSocket, response); log.info("链接开始合成音频:{}",response.body()); //发送数据 JsonObject frame = new JsonObject(); JsonObject business = new JsonObject(); JsonObject common = new JsonObject(); JsonObject data = new JsonObject(); // 填充common common.addProperty("app_id", appid); //填充business,AUE属性lame是MP3格式,raw是PCM格式 business.addProperty("aue", "lame"); business.addProperty("sfl", 1); business.addProperty("tte", "UTF8");//小语种必须使用UNICODE编码 business.addProperty("vcn", "xiaoyan");//到控制台-我的应用-语音合成-添加试用或购买发音人,添加后即显示该发音人参数值,若试用未添加的发音人会报错11200 business.addProperty("pitch", 50); business.addProperty("speed", 50); //填充data data.addProperty("status", 2);//固定位2 data.addProperty("text", Base64.getEncoder().encodeToString(text.getBytes(StandardCharsets.UTF_8))); //使用小语种须使用下面的代码,此处的unicode指的是 utf16小端的编码方式,即"UTF-16LE"” //data.addProperty("text", Base64.getEncoder().encodeToString(text.getBytes("UTF-16LE"))); //填充frame frame.add("common", common); frame.add("business", business); frame.add("data", data); webSocket.send(frame.toString()); } @Override public void onMessage(WebSocket webSocket, String text) { super.onMessage(webSocket, text); //处理返回数据 log.info("开始处理文本合成音频"); ResponseData resp = null; try { resp = json.fromJson(text, ResponseData.class); } catch (Exception e) { log.error("异常:", e); } if (resp != null) { if (resp.getCode() != 0) { log.error("error=>" + resp.getMessage() + " sid=" + resp.getSid()); return; } if (resp.getData() != null) { String result = resp.getData().audio; byte[] audio = Base64.getDecoder().decode(result); list.add(audio); // 说明数据全部返回完毕,可以关闭连接,释放资源 if (resp.getData().status == 2) { String is = base64Concat(list); base64 = is; lock = false; webSocket.close(1000, ""); } } } } @Override public void onMessage(WebSocket webSocket, ByteString bytes) { super.onMessage(webSocket, bytes); } @Override public void onClosing(WebSocket webSocket, int code, String reason) { super.onClosing(webSocket, code, reason); } @Override public void onClosed(WebSocket webSocket, int code, String reason) { super.onClosed(webSocket, code, reason); } @Override public void onFailure(WebSocket webSocket, Throwable t, Response response) { super.onFailure(webSocket, t, response); } }); while (lock) { } return base64; } /** * * base64拼接 */ static String base64Concat(List<byte[]> list) { int length = 0; for (byte[] b : list) { length += b.length; } int len = 0; byte[] retByte = new byte[length]; for (byte[] b : list) { retByte = concat(len, retByte, b); len += b.length; } return cn.hutool.core.codec.Base64.encode(retByte); } static byte[] concat(int len, byte[] a, byte[] b) { for (int i = 0; i < b.length; i++) { a[len] = b[i]; len++; } return a; } /** * * 获取权限地址 * * * * @param hostUrl * * @param apiKey * * @param apiSecret * * @return */ private static String getAuthUrl(String hostUrl, String apiKey, String apiSecret) throws Exception { URL url = new URL(hostUrl); SimpleDateFormat format = new SimpleDateFormat("EEE, dd MMM yyyy HH:mm:ss z", Locale.US); format.setTimeZone(TimeZone.getTimeZone("GMT")); String date = format.format(new Date()); StringBuilder builder = new StringBuilder("host: ").append(url.getHost()).append("\n"). append("date: ").append(date).append("\n"). append("GET ").append(url.getPath()).append(" HTTP/1.1"); Charset charset = StandardCharsets.UTF_8; Mac mac = Mac.getInstance("hmacsha256"); SecretKeySpec spec = new SecretKeySpec(apiSecret.getBytes(charset), "hmacsha256"); mac.init(spec); byte[] hexDigits = mac.doFinal(builder.toString().getBytes(charset)); String sha = Base64.getEncoder().encodeToString(hexDigits); String authorization = String.format("hmac username=\"%s\", algorithm=\"%s\", headers=\"%s\", signature=\"%s\"", apiKey, "hmac-sha256", "host date request-line", sha); HttpUrl httpUrl = HttpUrl.parse("https://" + url.getHost() + url.getPath()).newBuilder(). addQueryParameter("authorization", Base64.getEncoder().encodeToString(authorization.getBytes(charset))). addQueryParameter("date", date). addQueryParameter("host", url.getHost()). build(); return httpUrl.toString(); } @Getter public static class ResponseData { private int code; private String message; private String sid; private Data data; } private static class Data { //标志音频是否返回结束 status=1,表示后续还有音频返回,status=2表示所有的音频已经返回 private int status; //返回的音频,base64 编码 private String audio; // 合成进度 private String ced; } }
创建 TextToAudioController
package com.yby6.controller; import cn.hutool.core.lang.UUID; import com.yby6.reponse.R; import com.yby6.utils.MinIoUtil; import com.yby6.utils.XunFeiUtil; import io.minio.ObjectWriteResponse; import jakarta.servlet.http.HttpServletResponse; import lombok.RequiredArgsConstructor; import org.apache.commons.lang3.StringUtils; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; import java.io.*; import java.util.Base64; /** * 语音合成 * * @author Yang Buyi * Create By 2023/09/02 */ @RequestMapping("/xunfei") @RestController @RequiredArgsConstructor public class TextToAudioController { private static final Logger log = LoggerFactory.getLogger(TextToAudioController.class); /** * 生成语音返回流 */ @PostMapping(value = "textToAudio") public void textToAudio(@RequestParam String text, HttpServletResponse response) throws IOException { if (StringUtils.isNotBlank(text)) { //过滤图片,h5标签 final byte[] audioByte = getAudioByte(text); response.setContentType("application/octet-stream;charset=UTF-8"); OutputStream os = new BufferedOutputStream(response.getOutputStream()); try { //音频流 os.write(audioByte); } catch (IOException e) { log.error("音频数据异常", e); } finally { os.flush(); os.close(); } } } /** * 获取讯飞音频流 * @return {@link byte[]} */ private static byte[] getAudioByte(String text) { text = text.replaceAll("\\&[a-zA-Z]{1,10};", "").replaceAll("<[^>]*>", "").replaceAll("[(/>)<]", "").trim(); //调用微服务接口获取音频base64 String result = ""; try { result = XunFeiUtil.convertText(text); } catch (Exception e) { log.error("【文字转语音接口调用异常】", e); } // 音频数据 return Base64.getDecoder().decode(result); } }
以上代码演示了如何在Spring Boot应用程序中使用XunFeiUtil工具类来将文本转换为语音,并且返回了音频流到前端
重生的画面
我这里就使用从零玩转系列之微信支付的工程前端来发送请求测试
新增语音合成API
import request from '@/utils/request'; export function textToAudio(params) { return request({ url: '/xunfei/textToAudio', method: 'post', data: params, responseType: "blob"//后台返回的为语音的流数据 }); }
⚠️ 响应拦截器处理
页面编写
<template> <div class="app-container"> <h1>文本转语音Demo</h1> <div style="width: 600px;"> <el-input type="textarea" :autosize="{minRows:3,maxRows:5}" placeholder="请输入内容" v-model="textArea"> </el-input> <el-badge class="item" style="margin-right: 12px" v-loading="audioLoading"> <el-button v-if="!audioPlay" style="margin: 10px 10px;" @click="getAudio(textArea)">转换</el-button> <el-button v-if="audioPlay" style="margin: 10px 10px;" @click="audioPause">暂停播放</el-button> <el-button @click="reload">重新播放</el-button> </el-badge> </div> </div> </template> <script> import {textToAudio} from '@/api/audio' export default { name: "Audio", props: {}, components: {}, data() { return { text: '', //文件组件 textArea: '', //语音组件 audioObj: {}, //转换时loading设置 audioLoading: false, audioPlay: false, } }, mounted() { this.audioObj = new Audio();//在VUE中使用audio标签 }, methods: { reload() { if (this.audioObj.src) { // 将当前时间设置为0(重新开始) this.audioObj.currentTime = 0; // 播放音频 this.audioObj.play(); } }, //调用后台讯飞语音转换 getAudio(text) { if (this.text === text && this.audioObj.src) { //已有声音直接播放 this.audioObj.play() } else { //判断输入框内容是否改变,如果是则重新发请求 this.text = text; if (text) { this.audioLoading = true let formData = new FormData() formData.append('text', text) textToAudio(formData).then(response => { let url = URL.createObjectURL(response)//通过这个API让语音数据转为成一个url地址 console.log(url); this.audioObj.src = url//设置audio的src为上面生成的url let playPromiser = this.audioObj.play()//进行播放 //在谷歌内核中,audio.play()会返回一个promise的值,在IE内核中就不会返回任何的值 //所以如果你要分浏览器,可以判断playPromiser的值来进行操作哦 this.audioObj.onended = () => { } this.audioLoading = false }).catch(err => { console.log(err); }) this.audioPlay = true } } }, // 播放暂停 audioPause() { this.audioObj.pause() this.audioPlay = false } } } </script> <style scoped> .audio { width: 90%; position: absolute; top: 20px; left: 20px; font-size: 26px; } </style>
页面代码讲解
当调用getAudio
方法时,会执行以下步骤:
- 首先,方法会检查当前文本(
text
)是否等于之前已经转换为音频并正在播放的文本。如果是,说明已经有对应的音频文件在播放,因此直接调用this.audioObj.play()
来播放该音频文件。 - 如果当前文本不等于之前已经转换为音频并正在播放的文本,说明需要重新发送请求将新的文本转换为语音。方法会将输入的文本赋值给
this.text
,并通过if (text)
条件判断语句进入下一步操作。 - 在下一步操作中,方法会创建一个
FormData
对象,并将文本作为参数通过formData.append('text', text)
添加到该对象中。 - 然后,方法会调用
textToAudio(formData)
函数将文本转换为语音,并返回一个Promise对象。该Promise对象在成功转换语音后会被解析为响应数据,因此可以通过.then()
方法访问响应数据。 - 在
.then()
方法中,首先会创建一个新的URL对象,通过将响应数据作为参数调用URL.createObjectURL(response)
。这个URL对象表示转换后的语音数据的URL地址。 - 然后,方法会将这个URL地址赋值给
this.audioObj.src
,从而将音频文件的源设置为转换后的语音数据的URL地址。 - 接着,方法会调用
this.audioObj.play()
尝试播放音频文件。在大多数现代浏览器中,播放音频会返回一个Promise对象,因此可以将播放音频的返回值赋值给playPromiser
变量。 - 如果音频播放成功,那么
playPromiser
的值会是Promise { <fulfilled> true }
,可以在控制台输出该值。如果音频播放失败,那么playPromiser
的值会是Promise { <rejected> Error }
,同样可以在控制台输出该值。 - 最后,方法会将
this.audioLoading
设置为false
,表示音频转换和播放已经完成,并且可以通过this.audioObj.onended
设置音频播放结束时的处理程序。
如果在转换语音或播放音频时出现错误,那么可以通过.catch()
方法捕获错误信息并打印出来。
总结
通过本文,你学会了如何使用Java工具类来实现讯飞WebApi语音合成。这个工具类可以帮助你将文本转换为MP3格式的语音文件,为你的应用程序增加语音合成功能。记得在配置文件中保存讯飞相关的参数,以确保顺利使用这个功能。希望本文对你有所帮助,祝你顺利实现讯飞语音合成功能!
本期结束咱们下次再见👋~ ,关注我不迷路,如果本篇文章对你有所帮助,或者你有什么疑问,欢迎在评论区留言,我一般看到都会回复的。大家点赞支持一下哟~ 💗