前端请求音频返回pcm流进行播放
业务场景是chat回答,点击播放则会将回答内容进行请求,返回音频数据流进行播放
实现方案,因为后端返回的是流式接口,但是流式接口我去截取后用自己完成的流式播放器方法进行播放会存在杂音,但是短句接口返回速度尚可,所以截取需要转音频的短句进行多次调用接口,返回的数据进行处理后存储下来,播放完上一段音频数据后即刻播放下一条。
注意返回的接口数据是pcm的base64编码格式
以下是代码片段
if (type === "播放") {
if (audioElement.value) {
stopAudio();
}
currentIndex = 0;
audioUrls = [];
isPlay.forEach((item, index) => {
isPlay[index] = false;
});
isPlay.push(true);
// getSpeech(oldAnswer.replace(/[\n\t\s*]+/g, ""));
let text = oldAnswer.replace(/[\n\t\s*]+/g, "");
let arr = text.split("。").filter(Boolean);
audioUrls = []; // 用于存储所有音频的 URL
currentIndex = 0; // 当前应该播放的音频索引
fetchAndPlayAudios(arr, isPlay.length - 1);
}
let audioUrls = []; // 用于存储所有音频的 URL
let currentIndex = 0; // 当前应该播放的音频索引
let isPlay = [];
async function fetchAndPlayAudios(texts, isPlayIndex) {
for (let index = 0; index < texts.length; index++) {
console.log("index", index);
if (!isPlay[isPlayIndex]) {
break;
}
const res = await getSpeechAPI({
input_text: texts[index],
spk_id: "0",
});
// 解码 Base64 数据并存储 URL
const audioUrl = pcmToAudioUrl(res);
audioUrls.push(audioUrl);
// 如果这是第一个音频,则立即播放它
if (index === 0) {
playNextAudio();
}
if (index == texts.length - 1) {
isPlay[isPlayIndex] = false;
}
}
}
const audioElement = ref("");
function playNextAudio() {
if (currentIndex < audioUrls.length) {
audioElement.value = new Audio(audioUrls[currentIndex]);
// 监听音频播放结束事件
audioElement.value.addEventListener("ended", () => {
audioElement.value.pause(); // 实际上在"ended"事件中,播放已经结束,但这行可以保留作为清晰性
audioElement.value.currentTime = 0; // 重置播放位置
currentIndex++; // 移动到下一个音频
playNextAudio(); // 播放下一个音频(如果有的话)
});
audioElement.value.play();
}
}
const stopAudio = () => {
audioElement.value.pause(); // 实际上在"ended"事件中,播放已经结束,但这行可以保留作为清晰性
audioElement.value.currentTime = 0; // 重置播放位置
};
function pcmToAudioUrl(base64Data) {
// console.log(base64Data)
let pcmData = base64ToUint8Array(base64Data);
// 创建WAV格式的Blob对象 (这是重点!直接创建blob数据是无法播放的!)
const wavBlob = createWavBlob(pcmData);
// 将URL设置为音频源即可
return URL.createObjectURL(wavBlob);
// base64编码的pcm16音频数据 转换为unit8格式数据
function base64ToUint8Array(base64String) {
const padding = "=".repeat((4 - (base64String.length % 4)) % 4);
const base64 = (base64String + padding).replace(/-/g, "+").replace(/_/g, "/");
const rawData = window.atob(base64);
const outputArray = new Uint8Array(rawData.length);
for (let i = 0; i < rawData.length; ++i) {
outputArray[i] = rawData.charCodeAt(i);
}
return outputArray;
}
// 创建WAV格式的Blob对象 (调节采样率来微调语速和音调)
function createWavBlob(pcmData) {
const format = 1; // 格式代码(1表示PCM)
const numChannels = 1; // 声道数量(单声道为1,立体声为2)
const sampleRate = 26500; // 采样率(例如44100 Hz)
const bitsPerSample = 16; // 每样本的位数(例如16位)
const blockAlign = numChannels * (bitsPerSample / 8); // 对齐单位
const byteRate = sampleRate * blockAlign; // 每秒的字节数
const buffer = new ArrayBuffer(44 + pcmData.length); // WAV文件头部长度为44字节
const view = new DataView(buffer);
// 写入WAV文件头部信息
writeString(view, 0, "RIFF"); // ChunkID
view.setUint32(4, 36 + pcmData.length, true); // ChunkSize
writeString(view, 8, "WAVE"); // Format
writeString(view, 12, "fmt "); // Subchunk1ID
view.setUint32(16, 16, true); // Subchunk1Size
view.setUint16(20, format, true); // AudioFormat
view.setUint16(22, numChannels, true); // NumChannels
view.setUint32(24, sampleRate, true); // SampleRate
view.setUint32(28, byteRate, true); // ByteRate
view.setUint16(32, blockAlign, true); // BlockAlign
view.setUint16(34, bitsPerSample, true); // BitsPerSample
writeString(view, 36, "data"); // Subchunk2ID
view.setUint32(40, pcmData.length, true); // Subchunk2Size
// 将PCM数据写入buffer
const pcmDataView = new Uint8Array(buffer, 44);
pcmDataView.set(pcmData);
return new Blob([view], { type: "audio/wav" });
}
// 写入字符串到DataView中的指定位置
function writeString(view, offset, string) {
for (let i = 0; i < string.length; i++) {
view.setUint8(offset + i, string.charCodeAt(i));
}
}
}
业务场景: