在项目中需要用到将景点文字合成语音,通过语音方式向用户介绍景点信息,需要用到文字转语音的在线合成解决方案。通过对各种文字转语音合成方案与效果比较,觉得讯飞的效果最好,语音拟人效果、文章断词都非常不错,并且有一年10万次的免费使用量,因此对比后决定使用讯的在线语音合成解决方案。由于这信主题网上教程非常少,只找到了一个没提供完整源代码的参考案例,结合官网资料,搞定的完整解决方案和效果图如下:



java 讯飞离线语音转换 例子 讯飞离线语音合成_讯飞tts语音引擎9.0

在线语音合成集成后效果



一、注册讯飞开发者,获取访问Key

到讯飞开发者平台(https://console.xfyun.cn/app/myapp),用实名注册好讯飞开发者用户,添加好自己拟开发的应用系统,申请好应用访问讯飞平台的访问参数。由于自己的服务器性能不行,是使用在线语音合成,采用的是WebAPI方式。此外,读飞还提供了一相demo程序,新手需要下载后反复研读。




java 讯飞离线语音转换 例子 讯飞离线语音合成_讯飞tts语音引擎9.0_02

讯飞接口4个参数



二、创建在线语音转换的Util

讯飞demo提供的是一个独立运行的方法,将且用PCM格式,将转换后文件保存在服务器上,需要对此程序进行改造,我主要进行了以下几个方面的改造:

1、将访问参数放到properties文件,以免放在程序代码段被泄漏,由于这部分代码定义的是静态变量,参数注入方式有变化

2、对前端传递过来的TEXT文本,转换后使用MP3格式输出给前端,完整代码如下

//静态参数注入,必须增加@Component注解@Componentpublic 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文件     * @throws IOException 异常     */    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.toString().replace("http://", "ws://").replace("https://", "wss://");        Request request = new Request.Builder().url(url).build();        List list = Lists.newArrayList();        WebSocket webSocket = client.newWebSocket(request, new WebSocketListener() {            @Override            public void onOpen(WebSocket webSocket, Response response) {                super.onOpen(webSocket, response);               try {                    System.out.println(response.body().string());                } catch (IOException e) {                    e.printStackTrace();                }                //发送数据                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                try {                    data.addProperty("text", Base64.getEncoder().encodeToString(text.getBytes("utf8")));                    //使用小语种须使用下面的代码,此处的unicode指的是 utf16小端的编码方式,即"UTF-16LE"”                    //data.addProperty("text", Base64.getEncoder().encodeToString(text.getBytes("UTF-16LE")));                } catch (UnsupportedEncodingException e) {                    e.printStackTrace();                }                //填充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);                //处理返回数据                System.out.println("receive=>");                ResponseData resp = null;                try {                    resp = json.fromJson(text, ResponseData.class);                } catch (Exception e) {                    e.printStackTrace();                }                if (resp != null) {                    if (resp.getCode() != 0) {                        System.out.println("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);                System.out.println("socket closing");            }            @Override            public void onClosed(WebSocket webSocket, int code, String reason) {                super.onClosed(webSocket, code, reason);                System.out.println("socket closed");            }            @Override            public void onFailure(WebSocket webSocket, Throwable t, Response response) {                super.onFailure(webSocket, t, response);                System.out.println("connection failed" + response.message());            }        });        while (lock) {        }        return base64;    }    /**     *  * base64拼接     *       */    static String base64Concat(List list) {        int length = 0;        for (byte[] b : list) {            length += b.length;        }        byte[] retByte = new byte[length];        for (byte[] b : list) {            retByte = ByteUtils.concat(retByte, b);        }        return cn.hutool.core.codec.Base64.encode(retByte);    }    /**     *  * 获取权限地址     *  *     *  * @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("").                append("date: ").append(date).append("").                append("GET ").append(url.getPath()).append(" HTTP/1.1");        Charset charset = Charset.forName("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();    }    public static class ResponseData {        private int code;        private String message;        private String sid;        private Data data;        public int getCode() {            return code;        }        public String getMessage() {            return this.message;        }        public String getSid() {            return sid;        }        public Data getData() {            return data;        }    }    private static class Data {        //标志音频是否返回结束  status=1,表示后续还有音频返回,status=2表示所有的音频已经返回        private int status;        //返回的音频,base64 编码        private String audio;        // 合成进度        private String ced;    }}

三、构建前后端接口控制文件

构建建前后端访问的接口,由于前后端通过VUE参数方式传递过来,使用Post方式,我另有文章介绍为什么要用Post,这是VUE的约定,用Get有问题。

@RestController@RequestMapping("/data/xunfei")public class TextToAudioController {    private static final Logger log = LoggerFactory.getLogger(TextToAudioController.class);    @ApiOperation(value = "文字转语音", notes = "文字转语音")    @PostMapping(value = "text_to_audio")    public void textToAudio(String text, HttpServletRequest request , HttpServletResponse response) throws IOException {        if (StringUtils.isNotBlank(text)) {            //过滤图片,h5标签            text = text.replaceAll("&[a-zA-Z]{1,10};", "").replaceAll("]*>", "").replaceAll("[(/>)]*>", "").replaceAll("[(/>)

四、前端访问请求修改

在request.js拦截器中增加语音处理部分,让前端能正确接收语音语件

// 响应拦截器service.interceptors.response.use(res => {  //语音处理    const headers = res.headers    if (headers['content-type'] === 'application/octet-stream;charset=UTF-8') {      return res.data    }}

五、构建前端访问接口

// 讯飞语音获取/** * 文字转语音接口 */export function textToAudio(data) {  return request({    url: '/data/xunfei/text_to_audio',    method: 'post',    data: data,    responseType: "blob"//后台返回的为语音的流数据  })}

六、前端程序

为了 便于介绍,单独做了一个独立,完整的程序,这个组件功能就是选取textarea中的内容,进行后台转换为MP3语音,并且增加了避免重复转换判断,增加暂停与继续播放按钮,效果如后面图。




java 讯飞离线语音转换 例子 讯飞离线语音合成_java 讯飞离线语音转换 例子_03

效果图



语音转文字的完整方案还是比较少,经过几天努力搞定了此问题,特完整地记录一下,后续将增加转换MP3保存在OSS功能,避免重复调服务,毕竟后续服务要费用,OSS还是比较便宜。