文章目录
前言
分享一些在开发语音识别功能遇到的问题和解决方法,包含音频数据格式转换,大文件上传,录音权限获取,音频可视化
一、音频数据格式
1. 常见的数据格式
从音质,文件大小,应用场景三方面对比如下,文件大小无损(pcm,wav,flac)大于有损(mp3,ogg,opus),实时语音需要低延迟opus,而高进度语音识别需要高质格式wav或pcm
数据格式 | 音质 | 文件大小 | 应用场景 |
---|---|---|---|
MP3 (MPEG-1 Audio Layer III) | 有损压缩,支持多种比特率(如128/192/320kbps) | 较小 | 适合网络传输和移动设备,音质在较高比特率下接近无损,但不适合高精度语音识别 |
OGG (Ogg Vorbis) | 有损压缩,支持多种比特率,类似mps3,但通常提供更好的压缩效率和音质 | 较小 | 适合网络传输和移动设备,不适合高精度语音识别 |
Opus | 高效的有损压缩,音质在低比特率下表现良好,支持多种比特率和采样率 | 较小 | 适合实时语音通信和语音识别系统,尤其是需要低延迟的场景 |
WMA (Windows Media Audio) | 有损压缩,支持多种比特率,类似mps3,但通常提供更好的压缩效率和音质,专为Windows平台优化 | 较小 | 适合Windows平台的音频传输和存储,不适合高精度语音识别 |
AMR (Adaptive Multi-Rate) | 有损未压缩,专为移动通信设计,能够根据网络条件动态调整比特率 | 较小 | 广泛用于移动设备的语音通话和语音识别 |
WAV (Waveform Audio File Format) | 无损压缩,支持多种采样率(8/16/44.1khz)和位深(8/6位) | 较大 | 广泛用于语音识别、音频编辑和存储 |
FLAC (Free Lossless Audio Codec) | 无损压缩,能够将音频压缩到大约原始大小的一半,同时保持音质不变 | 比wav小 | 适合需要高质量音频但又希望减小文件大小的场景 |
PCM (Pulse Code Modulation) | 无损未压缩,直接将模拟音频信号转换为数字信号,提供最高的音质,支持多种采样率和位深 | 较大 | 常用于语音识别系统中的原始音频数据处理 |
二、获取音频方式
1. 用FileReader对象读取音频文件
FileReader 只能访问用户明确选择的文件内容,可以从input标签选择文件,或者从拖放对象里获取,可以将文件读取以二进制和文本输出,reader.readAsArrayBuffer(data)//data是 Blob 或 File 对象
,reader.readAsDataURL(data)
,reader.readAsText(data)
,以下是通过input方式:
<input ref="fileInput" type="file" @change="getFile" />
const getFile = async (event: any) => {
const file = event.target.files[0];
const reader = new FileReader();
reader.onload = (e: any) => {
const arrayBuffer = e.target.result;
};
reader.readAsArrayBuffer(file);
};
2. 通过url请求获取音频数据
以下是通过fetch获取本地音频资源:
import testurl from "@/assets/test.mp3"
async function fetchAndConvertToBlob(url: string) {
const response = await fetch(url)
const blob = await response.blob()
return blob
}
3. 录音获取音频流
(1)设置触发事件
Pointer Events 提供了一种统一的方式来处理不同类型的输入设备,包括鼠标、触摸屏和手写笔。可能会存在一些旧版本兼容问题,(推荐polyfill和shim处理兼容)
触摸事件防抖处理
const debounce = (fn: any, delay: any) => {
let timer: any
return (...args: any[]) => {
if (timer) {
clearTimeout(timer) // 清除前一个定时器
}
timer = setTimeout(() => {
fn(...args) // 调用函数,传递参数
}, delay)
}
}
const debounceStartRecord = debounce(startRecord,500)
const debounceEndRecord = debounce(endRecord,500)
// startRecord和endRecord实现见下,音频格式转换处理
(2)判断应用设备麦克风授权,支持录音API
const handlePermissionDenied = async (fn: () => void) => {
const checkPermissionSupport = 'permissions' in navigator
const checkMediaDevicesSupport = async () => {
if ('mediaDevices' in navigator && 'getUserMedia' in navigator.mediaDevices) {
try {
// 使用getUserMedia方法获取麦克风权限
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
// 录音权限已授权,去释放资源
stream.getTracks().forEach((track) => track.stop());
return true
} catch (error) {
console.error("navigator getUserMedia failed:", error);
return false
}
} else {
return false
}
}
const permissionDeniedMessage = "您已拒绝麦克风权限。请在设置中授权麦克风权限,以便使用录音功能。";
const permissionPromptMessage = "浏览器将提示您授权麦克风权限。请在设置中授权麦克风权限,以便使用录音功能。";
const permissionQueryFailedMessage = "无法查询麦克风权限状态。请在设置中授权麦克风权限,以便使用录音功能。";
const deviceNotSupportedMessage = "您的设备不支持录音功能。建议您使用支持该功能的浏览器或更新系统版本";
if (checkPermissionSupport) {
console.log("navigator.permissions is supported");
try {
const permissionStatus = await navigator.permissions.query({ name: "microphone" as PermissionName });
console.log('permissionStatus', permissionStatus);
if (permissionStatus.state === "granted") {
// 录音权限已授权
fn();
} else if (permissionStatus.state === "prompt") {
// 浏览器将提示用户进行授权,部分浏览器没有提示,需直接请求媒体设备
const mediaDevicesPermission = await checkMediaDevicesSupport();
if (mediaDevicesPermission) {
// 录音权限已授权
fn();
} else {
alert(permissionPromptMessage);
}
} else if (permissionStatus.state === "denied") {
// 录音权限被拒绝
alert(permissionDeniedMessage);
}
} catch (error) {
console.error("Permission query failed:", error);
alert(permissionQueryFailedMessage);
}
} else if (await checkMediaDevicesSupport()) {
console.log("navigator.permissions is not supported, but navigator.mediaDevices.getUserMedia is supported");
fn();
} else {
alert(deviceNotSupportedMessage);
}
};
(3)使用audio-recorder-polyfill库处理音频文件转换
满足兼容不同系统和设备的WebRTC录音api支持,并提供释放麦克风方法,可以转换wav文件,自定义采样位数 (sampleBits)16,采样率(sampleRate)16000Hz,声道(numChannels)1
开始录音
const startRecord = (event?: any) => {
event?.stopPropagation()
handlePermissionDenied(() => {
console.log("开始录音", isRecording.value)
isPermissioning = false // 避免重复点击
if (!isRecording.value) {
try {
navigator.mediaDevices.getUserMedia({ audio: true }).then((stream) => {
pcmRecorder.value = new AudioRecorder(stream, {
sampleBits: 16, // 采样位数,支持 8 或 16,默认是16
sampleRate: 16000, // 采样率,支持 11025、16000、22050、24000、44100、48000,根据浏览器默认值,我的chrome是48000
numChannels: 1 // 声道,支持 1 或 2, 默认是1
})
pcmRecorder.value.start()
changeRecordState("start")
})
} catch (error) {
console.error("开始录音失败", error)
}
}
})
}
结束录音
const endRecord = (e?: any) => {
e?.preventDefault()
console.log("结束录音", isRecording.value, isPermissioning)
if (isPermissioning) {
isRecording.value = false
}
if (isFouceEnd || !isRecording.value) {
return
}
try {
if (pcmRecorder.value) {
// console.log(pcmRecorder.value)
pcmRecorder.value.stop()
pcmRecorder.value.addEventListener("dataavailable", async (e: any) => {
pcmData.value = e.data
// console.log("pcmData.value", pcmData.value)
let base64String: any = await blobToBase64(pcmData.value)
// console.log("pcmstr", base64String)
base64String = base64String.split(",")[1]
audioToText({ voice: base64String }).then((res: any) => {
console.log("res", res)
if (res.text) {
curText.value = res.text
sendMsg({ type: "model" })
} else {
// 判断当前识别成功的文字,如果第一次对话res.text为空,仍然展示关键字跳转
if (msgList.value.length == 1) {
isShowLink.value = true
}
}
})
})
if (pcmRecorder.value && pcmRecorder.value.stream) {
pcmRecorder.value.stream.getTracks().forEach((track: any) => {
track.stop()
track.applyConstraints({ echoCancellation: false }).catch((error: any) => {
console.error("更新轨道状态失败2:", error)
})
})
pcmRecorder.value.stream = null // 释放 MediaStream 对象
}
changeRecordState("end")
}
} catch (error) {
changeRecordState("end")
console.error("停止录音失败:", error)
}
}
@vitejs/plugin-legacy 插件的主要作用是为旧版浏览器提供兼容性支持,通过 Babel 转译代码,使其可以在旧版浏览器中运行。它主要处理 JavaScript 语法的兼容性问题,而不是特定的 Web API(如 navigator.mediaDevices.getUserMedia 或 navigator.permissions)。这里推荐webrtc-adapter 和 permissions-api ,webrtc-adapter 是一个 Polyfill,用于解决 WebRTC API 的兼容性问题,特别是 navigator.mediaDevices.getUserMedia 方法的兼容性问题;permissions-api 是一个 Polyfill,用于解决 navigator.permissions API 的兼容性问题。navigator.permissions 是一个较新的 API,用于查询和监听权限状态。
Recorder.js 提供了更底层的控制,适合需要自定义音频处理的场景。
AudioRecorder 是一个更简单的封装,适合快速实现录音功能。
https://blog.csdn.net/gitblog_00046/article/details/137811883
两者都能满足你的需求(16 位采样位数、16000Hz 采样率、单声道),并在录音结束后释放麦克风资源。
三、音频格式转换
数据格式 | 概念 | 场景 |
---|---|---|
Data URL | 是一种将文件内容直接嵌入到页面中的方式,通常以 data: 协议开头。它将文件内容编码为 Base64 字符串 | 适合嵌入到 HTML 或 CSS 中,便于显示和传输,适合小文件,会增加页面大小,可能受浏览器存储限制 |
Blob URL | 是一种指向 Blob 对象的 URL,通常以 blob: 协议开头 | 临时的url,适用于大文件(如音频,视频),便于在 或 标签中直接播放,不增加页面大小 |
Blob(Binary Large Object) | 表示了一个不可变、原始数据的类文件对象。Blob 可以包含任意类型的数据,如文本、二进制数据、JSON 等 | 常用于处理文件上传、下载等操作。Blob 的大小可以从 0 到浏览器所允许的最大值不等 |
ArrayBuffer | 表示通用的、固定长度的原始二进制数据缓冲区。ArrayBuffer 的内容不能直接操作,只能通过 DataView 对象或 TypedArrray 对象来访问 | 适合底层处理,如音频分析或使用 Web Audio AP |
备注: Blob.arrayBuffer() 方法是一种较新的基于 Promise 的 API,用于将文件读取为数组缓冲区。
const encodedData = window.btoa(“Hello, world”); // 编码字符串
const decodedData = window.atob(encodedData); // 解码字符串
1. buffer和blob互相转换
const fileToBlob = (file: File) => {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = function (e: any) {
const blob = new Blob([e.target.result], { type: file.type });// buffer转blob
const blobUrl = URL.createObjectURL(blob);//创建blob url
resolve(blobUrl)
};
reader.onerror = (error) => {
reject(error);
};
reader.readAsArrayBuffer(file); // 读取为 ArrayBuffer
})
}
const bufferToAudioUrl = (buffer: any) => {
/*
MIME 类型:
1、 mp3:audio/mpeg 或 audio/mp3(所有主流浏览器均支持)
2、 wav:/wav、audio/wave 或 audio/x-wav(所有主流浏览器均支持)
3、 mp4:audio/mp4 或 audio/x-m4a(所有主流浏览器均支持)
4、 ogg:audio/ogg(Chrome、Firefox、Edge 支持,但 Safari 不支持)
5、 webm:audio/webm(Chrome、Firefox、Edge 支持,但 Safari 支持有限)
*/
const blob = new Blob([buffer], { type: "audio/mpeg" });
return URL.createObjectURL(blob); //创建blob url
}
export const blobToBuffer = (blob: Blob) => {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => {
resolve(reader.result); // reader.result 是 ArrayBuffer
};
reader.onerror = (error) => {
reject(error);
};
reader.readAsArrayBuffer(blob);
});
}
2. blob转base64
const blobToBase64 = (blob: Blob) => {
return new Promise((resolve, reject) => {
const reader = new FileReader()
reader.onload = () => {
resolve(reader.result)
}
reader.onerror = (error) => {
reject(error)
}
reader.readAsDataURL(blob) // 将 Blob 转换为 Base64 字符串
})
}
3. base64转Uint8Array
export function base64ToUint8Array(base64: string) {
// 解码Base64字符串
const byteCharacters = atob(base64.trim());
// 将解码后的二进制字符串转换为Uint8Array
const byteNumbers = new Uint8Array(byteCharacters.length);
for (let i = 0; i < byteCharacters.length; i++) {
byteNumbers[i] = byteCharacters.charCodeAt(i);
}
return byteNumbers;//byteNumbers.buffer是arraybuffer类型
};
四、音频可视化
主要是通过arraybuffer进行音频可视化将音频文件读取为 ArrayBuffer 是一种更底层的方法,适用于需要对音频数据进行进一步处理的场景,例如使用 Web Audio API 进行音频分析或处理。以下是实现思路和部分代码:
- 使用AudioContext创建音频上下文,获取音频节点source
创建音频节点source可以3种方式:audiodom获取audCtx.createMediaElementSource(data)
,MediaStream媒体流audCtx.createMediaStreamSource(data)
,音频转成AudioBufferaudCtx.createBufferSource()
- 创建音频分析对象
audCtx.createAnalyser()
,并将音频源(source)连接到音频分析器source.connect(analyser)
- 启动音频源播放
source.start(0)
- 绘制平滑动画使用
canvas
和requestAnimationFrame
- 从分析对象获取当前音频的频率数据,并将其填充到指定的 Uint8Array 数组
analyser.getByteFrequencyData(dataArray)
- 使用dataArray分析数据进行音频条纹绘制
// 音频分析器
const initAudioAnalyser = async (
data: any,
type: "audiodom" | "mediastream" | "arraybuffer"
) => {
// console.log(data, type);
if (data) {
// 创建音频节点source可以3种方式:audiodom获取,MediaStream媒体流,音频转成AudioBuffer
if (type == "audiodom") {
isRecording.value = true;
animationLoop();
source = audCtx.createMediaElementSource(data);
} else if (type == "mediastream") {
source = audCtx.createMediaStreamSource(data);
} else if (type == "arraybuffer") {
isRecording.value = true;
animationLoop();
source = audCtx.createBufferSource();
const uint8Array = base64ToUint8Array(data);
dataArray = uint8Array;
const arrayBuffer = uint8Array.buffer;
/*
返回audiodom实现思路
const audioUrl = bufferToAudioUrl(arrayBuffer);
const audioPlayer: any = document.getElementById("audioPlayer");
audioPlayer.src = audioUrl;
audioPlayer.play();
source = audCtx.createMediaElementSource(audioPlayer);
*/
if (arrayBuffer instanceof ArrayBuffer) {
try {
const audioBuffer = await audCtx.decodeAudioData(arrayBuffer);
console.log("audioBuffer 解码成功:", audioBuffer);
source.buffer = audioBuffer;
} catch (error: any) {
isRecording.value = false;
console.error("解码音频数据时出错:", error);
// 报解码音频数据时出错: DOMException: Failed to execute 'decodeAudioData' on 'BaseAudioContext': Unable to decode audio data
// 这是因为 ArrayBuffer 中的数据不是有效的音频文件格式
}
}
}
analyser = audCtx.createAnalyser();
analyser.fftSize = 256; // 设置 FFT 大小,影响频率分析的精度,常见的值有 128, 256, 512, 1024, 2048
console.log(
"Audio stream connected to AnalyserNode:",
source.numberOfOutputs > 0
); //为true表明已经连接到一个源
//创建数组,用于接收分析器节点的分析数据
dataArray = new Uint8Array(analyser.frequencyBinCount);//analyser.frequencyBinCount表示当前频率的数组长度
source.connect(analyser);
// analyser.connect(audCtx.destination); //输出设备 播放声音(如果需要将音频输出到扬声器,可以再连接到 audCtx.destination:)
if (audCtx.state === "suspended") {
// 如果你的代码在没有用户交互的情况下执行,AudioContext 可能不会从 suspended 状态变为 running
source.start(0);
source.onended = function () {
console.log("音频播放结束");
isRecording.value = false;
};
}
console.log(
"initAudioAnalyser",
audCtx.state,
audCtx.sampleRate,
dataArray
);
}
};
// 绘制音频波动的函数
const draw = () => {
if (!analyser) {
return;
}
analyser.getByteFrequencyData(dataArray); //让分析器节点分析出数据到数组中
const volumeThreshold = 40; // 设置音量阈值,只有当音量高于这个值时才更新可视化
// 计算平均振幅
let sum = 0;
for (let i = 0; i < dataArray.length; i++) {
sum += dataArray[i];
}
const averageAmplitude = sum / dataArray.length;// 计算平均振幅
console.log(audCtx.state, dataArray, averageAmplitude, volumeThreshold);
// 根据平均振幅决定是否更新可视化
if (averageAmplitude > volumeThreshold) {
// 更新可视化效果
// console.log("音量足够,更新可视化")
/*
dataArray一直为0,可能导致的原因
(1)音频未正确播放,audCtx.state不是running
(2)source是否正确连接analyser,source.connect(analyser)
(3)getByteFrequencyData调用时机不正确,是否音频播放后调用,需要持续调用,volumeThreshold设置音量
(4)采样率不匹配(audCtx.sampleRate和audiobuffer.sampleRate)
(5)调试时的时间延迟问题:AnalyserNode必须在音频播放一定时间后才能提供有效的数据
如果音频源被静音或音量为0
*/
// console.log("音乐节点", dataArray)
const bufferLength = dataArray.length / 2.5; //一般两半波幅
const barWidth = width / bufferLength;
// 清空画布
ctx.clearRect(0, 0, width, height);
ctx.fillStyle = "#000000";
for (let i = 0; i < bufferLength; i++) {
const data = dataArray[i]; //<256
const barHeight = (data / 255) * height; // 乘以height放大波幅
// console.log(barHeight)
// const x = i * barWidth
const x1 = i * barWidth + width / 2;
const x2 = width / 2 - (i + 1) * barWidth;
// const y = height - barHeight //底部对齐
const y = (height - barHeight) / 2; //中心对其
// ctx?.fillRect(x, y, barWidth - 3, barHeight)
ctx?.fillRect(x1, y, barWidth - 4, barHeight);
ctx?.fillRect(x2, y, barWidth - 4, barHeight);
}
} else {
// 音量较低,不更新可视化
// console.log("音量较低,跳过可视化更新")
ctx.clearRect(0, 0, width, height);
}
};
const animationLoop = () => {
if (!isRecording.value) {
return;
}
draw();
requestAnimationFrame(animationLoop);
};
五、大文件上传
语音识别为了保证识别准确性,选用高质量音频数据wav格式。由于WAV格式文件较大,直接上传可能导致网络传输效率低下或失败。因此,采用分片上传的方式将大文件分割成多个小片段依次上传。常见的分片上传方式有两种:
1. 固定大小分片
将文件按照固定的字节大小(如1MB)进行切分。这种方式实现简单,但可能会导致最后一个分片过小。这里提供两种传输方式 http 和 websocket,分片上传包含前端分片(遍历分割),分片上传(接口上传),分片合并(服务端),这里主要展示前两部分。
特性 | 数据类型 | 传输效率 | 适用场景 | 内存占用 | 实现复杂度 |
---|---|---|---|---|---|
HTTP application/octet-stream | 二进制(ArrayBuffer、Blob) | 高 | 文件上传、下载 | 低 | 中等 |
WebSocket 二进制帧 | 二进制(ArrayBuffer) | 高 | 实时文件传输 | 低 | 高 |
Base64 编码 | 文本字符串 | 低(体积增加 33%) | 小文件嵌入 | 高 | 低 |
(1)http
export const sendFile1 = (blob: any, sliceSize: any) => {
let resstext = ''
if (!blob) {
return
} else {
const sliceNum = Math.ceil(blob.size / sliceSize)
const fun: any = async () => {
for (let i = 0; i < sliceNum; i++) {
const start = i * sliceSize
const end = Math.min(start + sliceSize, blob.size)
const chunk = blob.slice(start, end) // 获取切片
const isLastChunk = i === sliceNum - 1
await new Promise((r) => {
//audioToText参数分别:文件切片,是否最后一片,第几片
audioToText(chunk, isLastChunk ? "1" : "0", i + 1).then((res: any) => {
for (const item of res.body) {
console.log(item)
if (item?.ansStr) {
resstext = resstext + JSON.parse(item.ansStr).ws_s
}
if (item.endFlag) {
console.log('结束发送',resstext);
break
}
}
r(res)
})
})
}
}
fun()
}
}
(2)websocket
export const sendFile2 = (blob: Blob, sliceSize: number) => {
let resstext = '';
if (!blob) return;
const sliceNum = Math.ceil(blob.size / sliceSize);
const socket = new WebSocket('wss://example.com/socket'); // 替换为实际的 WebSocket 地址
socket.onopen = () => {
console.log('WebSocket connection established');
const uploadChunks = async () => {
for (let i = 0; i < sliceNum; i++) {
const start = i * sliceSize;
const end = Math.min(start + sliceSize, blob.size);
const chunk = blob.slice(start, end); // 获取切片
const isLastChunk = i === sliceNum - 1;
const message = {
chunk: chunk,
isLastChunk: isLastChunk,
chunkIndex: i + 1
};
socket.send(JSON.stringify(message));//实时发送
}
};
uploadChunks();
};
socket.onmessage = (event) => {
const response = JSON.parse(event.data);
console.log('Received message from server:', response);
if (response.body) {
for (const item of response.body) {
if (item?.ansStr) {
resstext += JSON.parse(item.ansStr).ws_s;
}
if (item.endFlag) {
console.log('获取识别后的文字', resstext);
socket.close();
break;
}
}
}
};
socket.onerror = (error) => {
console.error('WebSocket error:', error);
};
socket.onclose = () => {
console.log('WebSocket connection closed');
};
};
2. 按时间戳分片
根据录音的时间戳来划分片段,例如每秒生成一个分片。这种方式更适用于流式传输场景,能够更好地适应不同长度的音频文件。
//...此处省去录音开始和结束方法
let chunks = []; // 存储录音数据
mediaRecorder.ondataavailable = (event) => {
// 监听录音结束拿到录音数据
if (event.data.size > 0) {
chunks.push(event.data);
// 按时间戳分片(例如每秒生成一个分片)
const currentTime = Date.now();
if (currentTime - startTime >= 1000) {
const blob = new Blob(chunks, { type: 'audio/webm' });
uploadChunk(blob); // 上传分片
chunks = []; // 清空当前分片数据
startTime = currentTime; // 重置开始时间
}
}
};
// 上传分片
function uploadChunk(blob) {
const formData = new FormData();
formData.append('file', blob, `chunk_${Date.now()}.webm`);
fetch('/upload', {
method: 'POST',
body: formData,
})
.then(response => response.json())
.then(data => {
console.log('分片上传成功:', data);
})
.catch(error => {
console.error('分片上传失败:', error);
});
}
总结
以上内容主要是围绕录音api拓展的功能和知识点,有FileReader文件读取,canvas绘制,文件分片上传都可以单独了解,希望可以帮助做为一些实现方案的参考,有错误遗漏希望指出,后续还会补充完善~