前言
在一个安静而又普通的午后,我坐在电脑前,思索着如何将一个看似遥不可及的愿望化为现实。那个愿望,是一个来自虚拟世界的幻想,一个关于“重生”的故事。
每个人都曾幻想过如果能重新来过会怎么样,纠正生命中的种种错误,抓住逝去的时光。但对于我,这个愿望似乎不再是仅仅停留在幻想中的奢望。作为一名文本转音频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格式的语音文件,为你的应用程序增加语音合成功能。记得在配置文件中保存讯飞相关的参数,以确保顺利使用这个功能。希望本文对你有所帮助,祝你顺利实现讯飞语音合成功能!
本期结束咱们下次再见?~ ,关注我不迷路,如果本篇文章对你有所帮助,或者你有什么疑问,欢迎在评论区留言,我一般看到都会回复的。大家点赞支持一下哟~ ?
评论区