H5语音合成效果图:
方案一:Web Speech API:
<template>
<div class="contert">
<h2 style="margin: 40px 0 20px 0">方案一:Web Speech API</h2>
<button @click="speak">开始合成</button>
<button class="stop" @click="pause">停止播放</button>
<div class="result">
<p>识别文字:</p>
<div class="interim">{{ text }}</div>
</div>
</div>
</template>
<script setup>
import { ref, reactive, onMounted } from "vue";
const text = ref(
"岱宗夫如何,齐鲁青未了。造化钟神秀,阴阳割昏晓。汤胸生层云,决眦入归鸟,会当凌绝顶,一览众山小。"
);
const voices = ref([]);
const selectedVoice = ref(null);
const isSpeaking = ref(false);
// 语音参数
const rate = ref(1);
const pitch = ref(1);
const volume = ref(1);
let synthesis = null;
let utterance = null;
// 初始化语音合成
onMounted(() => {
if ("speechSynthesis" in window) {
synthesis = window.speechSynthesis;
// 异步获取语音列表
const loadVoices = () => {
voices.value = synthesis.getVoices();
// 默认选择第一个中文语音
selectedVoice.value = voices.value.find(
(voice) => voice.lang.startsWith("zh") || voice.lang.startsWith("cmn")
);
};
synthesis.onvoiceschanged = loadVoices;
loadVoices();
} else {
alert("您的浏览器不支持语音合成功能");
}
});
// 创建语音实例
const createUtterance = () => {
utterance = new SpeechSynthesisUtterance(text.value);
utterance.voice = selectedVoice.value;
utterance.rate = rate.value;
utterance.pitch = pitch.value;
utterance.volume = volume.value;
// 事件监听
utterance.onstart = () => (isSpeaking.value = true);
utterance.onend = utterance.onerror = () => (isSpeaking.value = false);
};
// 控制方法
const speak = () => {
if (!synthesis) return;
synthesis.cancel(); // 停止当前播放
createUtterance();
synthesis.speak(utterance);
};
const pause = () => synthesis.pause();
</script>
<style scoped>
.contert {
padding: 0 20px;
}
button {
padding: 8px 15px;
background: linear-gradient(135deg, #6253e1, #04befe);
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
transition: background 0.3s;
}
button:hover {
background: #66b1ff;
}
button.stop {
background: #f56c6c;
margin-left: 20px;
}
.result {
margin-top: 20px;
padding: 15px;
border: 1px solid #eee;
border-radius: 4px;
}
.interim {
color: #666;
margin-top: 10px;
min-height: 20px;
}
</style>
方案二:科大讯飞
<template>
<div class="contert">
<h2 style="margin: 80px 0 20px 0">方案二:科大讯飞</h2>
<button @click="play">开始合成</button>
<button class="stop" @click="pause">停止播放</button>
<div class="result">
<p>识别文字:</p>
<div class="interim">{{ text }}</div>
</div>
</div>
</template>
<script setup>
import { ref } from "vue";
import TtsRecorder from "../utils/tts_xunfei/audio";
const text = ref(
"孤鸿海上来,池潢不敢顾。侧见双翠鸟,巢在三珠树。矫矫珍木巅,得无金丸惧。美服患人指,高明逼神恶。今我游冥冥,弋者何所慕。"
);
const ttsRecorder = new TtsRecorder();
const play = () => {
ttsRecorder.setParams({
text: text.value,
speed: 50,
voice: 50,
});
ttsRecorder.start();
};
const pause = () => {
ttsRecorder.stop();
};
</script>
<style scoped>
.contert {
padding: 0 20px;
}
button {
padding: 8px 15px;
background: linear-gradient(135deg, #6253e1, #04befe);
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
transition: background 0.3s;
}
button:hover {
background: #66b1ff;
}
button.stop {
background: #f56c6c;
margin-left: 20px;
}
.result {
margin-top: 20px;
padding: 15px;
border: 1px solid #eee;
border-radius: 4px;
}
.interim {
color: #666;
margin-top: 10px;
min-height: 20px;
}
</style>
/utils/tts_xunfei/audio.js:
// 在线语音合成 WebAPI 接口调用示例 接口文档(必看):
//https://www.xfyun.cn/doc/tts/online_tts/API.html
// 1. websocket连接:判断浏览器是否兼容,获取websocket url并连接,这里为了方便本地生成websocket url
// 2. 连接websocket,向websocket发送数据,实时接收websocket返回数据
// 3. 处理websocket返回数据为浏览器可以播放的音频数据
// 4. 播放音频数据
// ps: 该示例用到了es6中的一些语法,建议在chrome下运行
import CryptoJS from "crypto-js";
const transWorker = new Worker(
new URL("./transcode.worker.js", import.meta.url)
);
import { Base64 } from "./base64js.js";
//APPID,APISecret,APIKey在控制台-我的应用-语音合成(流式版)页面获取
const APPID = "f70c6429";
const API_SECRET = "YjBiOTU2YTU4YzEzZWQ3MWZlNGNkM2I3";
const API_KEY = "beba0ca58e09b5a244858b5d0dab8b8b";
function getWebsocketUrl() {
return new Promise((resolve, reject) => {
var apiKey = API_KEY;
var apiSecret = API_SECRET;
var url = "wss://tts-api.xfyun.cn/v2/tts";
var host = location.host;
var date = new Date().toGMTString();
var algorithm = "hmac-sha256";
var headers = "host date request-line";
var signatureOrigin = `host: ${host}\ndate: ${date}\nGET /v2/tts HTTP/1.1`;
var signatureSha = CryptoJS.HmacSHA256(signatureOrigin, apiSecret);
var signature = CryptoJS.enc.Base64.stringify(signatureSha);
var authorizationOrigin = `api_key="${apiKey}", algorithm="${algorithm}", headers="${headers}", signature="${signature}"`;
var authorization = btoa(authorizationOrigin);
url = `${url}?authorization=${authorization}&date=${date}&host=${host}`;
resolve(url);
});
}
const TTSRecorder = class {
constructor({
speed = 30,
voice = 50,
pitch = 50,
voiceName = "xiaoyan",
appId = APPID,
text = "",
tte = "UTF8",
defaultText = "请输入您要合成的文本",
} = {}) {
this.speed = speed;
this.voice = voice;
this.pitch = pitch;
this.voiceName = voiceName;
this.text = text;
this.tte = tte;
this.defaultText = defaultText;
this.appId = appId;
this.audioData = [];
this.rawAudioData = [];
this.audioDataOffset = 0;
this.status = "init";
transWorker.onmessage = (e) => {
this.audioData.push(...e.data.data);
this.rawAudioData.push(...e.data.rawAudioData);
};
}
// 修改录音听写状态
setStatus(status) {
this.onWillStatusChange && this.onWillStatusChange(this.status, status);
this.status = status;
}
// 设置合成相关参数
setParams({ speed, voice, pitch, text, voiceName, tte }) {
speed !== undefined && (this.speed = speed);
voice !== undefined && (this.voice = voice);
pitch !== undefined && (this.pitch = pitch);
text && (this.text = text);
tte && (this.tte = tte);
voiceName && (this.voiceName = voiceName);
this.resetAudio();
}
// 连接websocket
connectWebSocket() {
this.setStatus("ttsing");
return getWebsocketUrl().then((url) => {
let ttsWS;
if ("WebSocket" in window) {
ttsWS = new WebSocket(url);
} else if ("MozWebSocket" in window) {
ttsWS = new MozWebSocket(url);
} else {
alert("浏览器不支持WebSocket");
return;
}
this.ttsWS = ttsWS;
ttsWS.onopen = (e) => {
this.webSocketSend();
this.playTimeout = setTimeout(() => {
this.audioPlay();
}, 1000);
};
ttsWS.onmessage = (e) => {
this.result(e.data);
};
ttsWS.onerror = (e) => {
clearTimeout(this.playTimeout);
this.setStatus("errorTTS");
alert("WebSocket报错,请f12查看详情");
console.error(`详情查看:${encodeURI(url.replace("wss:", "https:"))}`);
};
ttsWS.onclose = (e) => {
// console.log(e)
};
});
}
// 处理音频数据
transToAudioData(audioData) { }
// websocket发送数据
webSocketSend() {
var params = {
common: {
app_id: this.appId, // APPID
},
business: {
aue: "raw",
// sfl= 1,
auf: "audio/L16;rate=16000",
vcn: this.voiceName,
speed: this.speed,
volume: this.voice,
pitch: this.pitch,
bgs: 0,
tte: this.tte,
},
data: {
status: 2,
text: this.encodeText(
this.text || this.defaultText,
this.tte === "unicode" ? "base64&utf16le" : ""
),
},
};
this.ttsWS.send(JSON.stringify(params));
}
encodeText(text, encoding) {
switch (encoding) {
case "utf16le": {
let buf = new ArrayBuffer(text.length * 4);
let bufView = new Uint16Array(buf);
for (let i = 0, strlen = text.length; i < strlen; i++) {
bufView[i] = text.charCodeAt(i);
}
return buf;
}
case "buffer2Base64": {
let binary = "";
let bytes = new Uint8Array(text);
let len = bytes.byteLength;
for (let i = 0; i < len; i++) {
binary += String.fromCharCode(bytes[i]);
}
return window.btoa(binary);
}
case "base64&utf16le": {
return this.encodeText(
this.encodeText(text, "utf16le"),
"buffer2Base64"
);
}
default: {
return Base64.encode(text);
}
}
}
// websocket接收数据的处理
result(resultData) {
let jsonData = JSON.parse(resultData);
// 合成失败
if (jsonData.code !== 0) {
alert(`合成失败: ${jsonData.code}:${jsonData.message}`);
console.error(`${jsonData.code}:${jsonData.message}`);
this.resetAudio();
return;
}
transWorker.postMessage(jsonData.data.audio);
if (jsonData.code === 0 && jsonData.data.status === 2) {
this.ttsWS.close();
}
}
// 重置音频数据
resetAudio() {
this.audioStop();
this.setStatus("init");
this.audioDataOffset = 0;
this.audioData = [];
this.rawAudioData = [];
this.ttsWS && this.ttsWS.close();
clearTimeout(this.playTimeout);
}
// 音频初始化
audioInit() {
let AudioContext = window.AudioContext || window.webkitAudioContext;
if (AudioContext) {
this.audioContext = new AudioContext();
this.audioContext.resume();
this.audioDataOffset = 0;
}
}
// 音频播放
audioPlay() {
this.setStatus("play");
let audioData = this.audioData.slice(this.audioDataOffset);
this.audioDataOffset += audioData.length;
let audioBuffer = this.audioContext.createBuffer(
1,
audioData.length,
22050
);
let nowBuffering = audioBuffer.getChannelData(0);
if (audioBuffer.copyToChannel) {
audioBuffer.copyToChannel(new Float32Array(audioData), 0, 0);
} else {
for (let i = 0; i < audioData.length; i++) {
nowBuffering[i] = audioData[i];
}
}
let bufferSource = (this.bufferSource =
this.audioContext.createBufferSource());
bufferSource.buffer = audioBuffer;
bufferSource.connect(this.audioContext.destination);
bufferSource.start();
bufferSource.onended = (event) => {
if (this.status !== "play") {
return;
}
if (this.audioDataOffset < this.audioData.length) {
this.audioPlay();
} else {
this.audioStop();
}
};
}
// 音频播放结束
audioStop() {
this.setStatus("endPlay");
clearTimeout(this.playTimeout);
this.audioDataOffset = 0;
if (this.bufferSource) {
try {
this.bufferSource.stop();
} catch (e) {
// console.log(e)
}
}
}
start() {
if (this.audioData.length) {
this.audioPlay();
} else {
if (!this.audioContext) {
this.audioInit();
}
if (!this.audioContext) {
alert("该浏览器不支持webAudioApi相关接口");
return;
}
this.connectWebSocket();
}
}
stop() {
this.audioStop();
}
};
export default TTSRecorder;