重生之我在这个世界的文本转音频API工程师的故事

2023-09-02 02:32:48 浏览数 (1)

前言

在一个安静而又普通的午后,我坐在电脑前,思索着如何将一个看似遥不可及的愿望化为现实。那个愿望,是一个来自虚拟世界的幻想,一个关于“重生”的故事。

每个人都曾幻想过如果能重新来过会怎么样,纠正生命中的种种错误,抓住逝去的时光。但对于我,这个愿望似乎不再是仅仅停留在幻想中的奢望。作为一名文本转音频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使用注意事项如下

  1. 服务端支持的websocket-version 为13,请确保客户端使用的框架支持该版本。
  2. 服务端返回的所有的帧类型均为TextMessage,对应于原生websocket的协议帧中opcode=1,请确保客户端解析到的帧类型一定为该类型,如果不是,请尝试升级客户端框架版本,或者更换技术框架。
  3. 如果出现分帧问题,即一个json数据包分多帧返回给了客户端,导致客户端解析json失败。出现这种问题大部分情况是客户端的框架对websocket协议解析存在问题,如果出现请先尝试升级框架版本,或者更换技术框架。
  4. 客户端会话结束后如果需要关闭连接,尽量保证传给服务端的错误码为websocket错误码1000(如果客户端框架没有提供关闭时传错误码的接口。则无需关注本条)

下载Demo

看看咋玩的

调用示例

注: demo只是一个简单的调用示例,不适合直接放在复杂多变的生产环境使用

语音合成流式API demo java语言(点我)

我们只是看看流程待会不使用这个方式

打开项目后可以看到使用了 Java-WebSocketokhttp 等依赖这两个是必须的

将认证信息配置全部填好、均到控制台-语音合成页面获取

代码语言:java复制
    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项目

新增依赖

代码语言:javascript复制
        <!--   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

代码语言:javascript复制
xunfei:
  hostUrl: https://tts-api.xfyun.cn/v2/tts
  appid: xxxxxxxxx
  apisecret: xxxxxxxxx
  apikey: xxxxxxxxx

编写工具类,东西和刚刚写的demo一样

代码语言:java复制
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

代码语言:java复制
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

代码语言:javascript复制
import request from '@/utils/request';

export function textToAudio(params) {
  return request({
    url: '/xunfei/textToAudio',
    method: 'post',
    data: params,
    responseType: "blob"//后台返回的为语音的流数据
  });
}

⚠️ 响应拦截器处理

页面编写

代码语言:html复制
<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方法时,会执行以下步骤:

  1. 首先,方法会检查当前文本(text)是否等于之前已经转换为音频并正在播放的文本。如果是,说明已经有对应的音频文件在播放,因此直接调用this.audioObj.play()来播放该音频文件。
  2. 如果当前文本不等于之前已经转换为音频并正在播放的文本,说明需要重新发送请求将新的文本转换为语音。方法会将输入的文本赋值给this.text,并通过if (text)条件判断语句进入下一步操作。
  3. 在下一步操作中,方法会创建一个FormData对象,并将文本作为参数通过formData.append('text', text)添加到该对象中。
  4. 然后,方法会调用textToAudio(formData)函数将文本转换为语音,并返回一个Promise对象。该Promise对象在成功转换语音后会被解析为响应数据,因此可以通过.then()方法访问响应数据。
  5. .then()方法中,首先会创建一个新的URL对象,通过将响应数据作为参数调用URL.createObjectURL(response)。这个URL对象表示转换后的语音数据的URL地址。
  6. 然后,方法会将这个URL地址赋值给this.audioObj.src,从而将音频文件的源设置为转换后的语音数据的URL地址。
  7. 接着,方法会调用this.audioObj.play()尝试播放音频文件。在大多数现代浏览器中,播放音频会返回一个Promise对象,因此可以将播放音频的返回值赋值给playPromiser变量。
  8. 如果音频播放成功,那么playPromiser的值会是Promise { <fulfilled> true },可以在控制台输出该值。如果音频播放失败,那么playPromiser的值会是Promise { <rejected> Error },同样可以在控制台输出该值。
  9. 最后,方法会将this.audioLoading设置为false,表示音频转换和播放已经完成,并且可以通过this.audioObj.onended设置音频播放结束时的处理程序。

如果在转换语音或播放音频时出现错误,那么可以通过.catch()方法捕获错误信息并打印出来。

总结

通过本文,你学会了如何使用Java工具类来实现讯飞WebApi语音合成。这个工具类可以帮助你将文本转换为MP3格式的语音文件,为你的应用程序增加语音合成功能。记得在配置文件中保存讯飞相关的参数,以确保顺利使用这个功能。希望本文对你有所帮助,祝你顺利实现讯飞语音合成功能!

本期结束咱们下次再见

0 人点赞