简介:面向前端开发者的音频功能实战资源,聚焦H5调用麦克风获取实时音频流、录音并上传后台的完整链路,适合需要实现语音识别、在线通话、实时音频处理等交互应用的前端初中级开发者。资源共9个文件,核心包括3个JavaScript脚本与2个HTML页面,分别负责音频流获取、Web Audio API音频节点处理、MediaRecorder分块录制及页面交互;另有ashx与cs后台文件,用于演示服务端接收上传;同时附带mp3音频样本和效果预览图,便于对照验证。压缩包仅189KB,内容紧凑且目录清晰。已有7407人学习使用。通过示例可直观理解PC端音频采集与上传的完整工程实现,包括getUserMedia权限获取、AudioContext创建、实时音频流连接、录音数据分块合并、Blob生成以及Ajax/Fetch上传等细节,并涵盖浏览器兼容性检查与错误处理,可直接改造嵌入业务项目,有效缩短前端音频功能开发周期。 前段时间接了个在线语音面试的前端模块,需求一句话就能说完:页面上点一个按钮,调起浏览器麦克风,拿到实时音频流,边录边在页面上展示音量,松手停止后把录音上传到后台。也就是大家常说的“前端调用麦克风获取实时音频流和录音并上传至后台”。听起来就是getUserMedia + MediaRecorder + FormData三条 API 串起来,但真落地的时候,我从权限到格式再到上传,前后改了四版才稳定。这篇文章把完整链路和踩坑记录写给你,适合正在做语音采集、录音上传、通话质检、在线面试这类需求的前端同学,也适合 Hybrid App 里处理 H5 录音的开发者。
1. 先从 getUserMedia 说起:权限、安全上下文与轨道释放
1.1 最小可运行代码与参数选择
很多文章只写一行navigator.mediaDevices.getUserMedia({ audio: true }),实际项目里我会把音频参数对象化,因为浏览器允许你指定回声消除、降噪和自动增益,这对语音面试场景非常关键。
const stream = await navigator.mediaDevices.getUserMedia({ audio: { echoCancellation: true, noiseSuppression: true, autoGainControl: true }, video: false });回声消除主要解决外放时麦克风又收回去的声音;降噪能滤掉键盘声和空调底噪;自动增益可以让人在离麦克风远近不同时音量相对稳定。这三个开关在 PC 端支持得不错,移动端部分浏览器会忽略,但至少给了我们一个“尽量朝这个方向优化”的机会。
另外要注意,getUserMedia返回的是 Promise,权限弹窗期间用户可能犹豫很久,所以接口层要加超时控制,不能让它一直挂着。我习惯在外面包一层Promise.race,5 秒内没拿到流就提示用户检查权限弹窗。
1.2 HTTPS、localhost 与浏览器权限恢复
getUserMedia只在安全上下文中可用,也就是 HTTPS 或localhost。内网 IP 部署的测试环境经常在这里翻车,Chrome 会直接报SecurityError,Firefox 也会静默拒绝。生产环境必须上证书,测试环境建议直接用localhost跑,省掉一堆权限问题。
常见的权限错误可以归纳成一张表,排查时对照着看:
| 错误名 | 含义 | 常见原因 |
|---|---|---|
| NotFoundError | 找不到麦克风设备 | 设备被拔出、系统驱动异常 |
| NotAllowedError | 用户拒绝或浏览器阻止 | 权限被关、WebView 未授权 |
| NotReadableError | 设备不可读 | 麦克风被其他应用占用 |
| SecurityError | 非安全上下文 | HTTP 页面、iframe 缺 allow 属性 |
| OverconstrainedError | 约束不满足 | 指定了不支持的采样率或设备 ID |
热词里提到的“火狐已经阻止 linux 的麦克风权限怎么恢复”,我在 Ubuntu 上遇到过。Firefox 的地址栏左侧有个麦克风权限图标,点进去把当前站点改成“允许”就行;如果入口不好找,也可以直接进about:preferences#privacy,在“权限”区域找到“麦克风”,把被阻止的站点移除。Linux 下还有一种情况是系统层没识别到设备,先用arecord -l确认声卡在,再检查 PulseAudio 或 PipeWire 的输入源,否则浏览器层面怎么改都白搭。
Chrome 也有一个容易误导人的提示叫“已屏蔽相应权限以保护您的隐私”,这通常是因为站点曾经被拒绝过一次,或者用户点了地址栏右侧的麦克风图标选择了“一律屏蔽”。恢复路径是进入chrome://settings/content/microphone,把对应站点加回允许列表,再重新触发getUserMedia。
1.3 轨道管理与“重复请求权限”问题
流对象拿回来之后,不要只盯着stream用,真正占用麦克风硬件的是MediaStreamTrack。录音结束以后如果不主动停止,麦克风指示灯会一直亮着,浏览器顶部也会显示“正在使用麦克风”,很多用户会因此疑惑甚至投诉。
// 停止所有轨道 stream.getTracks().forEach(track => track.stop());另一个常见问题是重复请求权限。页面里如果每次点击录音都调用一次getUserMedia,用户会反复看到权限弹窗,体验很差。更好的做法是进入页面后只申请一次流,把stream存在模块级变量里,录音和音量分析都复用它;只有在用户主动切换麦克风设备时才重新获取。设备切换可以通过navigator.mediaDevices.enumerateDevices()拿到设备列表,再监听devicechange事件,当用户拔插麦克风时更新下拉选项。
2. 实时音频流的可视化与生命周期:不录音也要看着它
2.1 为什么需要单独拉一条 Web Audio 分析链路
MediaRecorder 能录音,但它不给你实时音量数据。如果需求只是“录音 + 上传”,确实可以不用 AudioContext;但一旦涉及音量条、静音判断、说话人检测,就必须在 MediaRecorder 之外再建一条分析链路。
我的做法是拿到stream之后,同时喂给两个消费者:一个是 MediaRecorder,一个是 Web Audio 的AudioContext。两路互不干扰,MediaRecorder 负责产出文件,Web Audio 负责实时分析。
const audioContext = new AudioContext(); const source = audioContext.createMediaStreamSource(stream); const analyser = audioContext.createAnalyser(); analyser.fftSize = 512; source.connect(analyser);这里有一个容易翻车的点:默认情况下source连接analyser之后,声音并不会外放。如果你希望用户能实时听到自己的声音,需要再把analyser连接到audioContext.destination,但这在未戴耳机时极易产生啸叫。语音面试这种场景,用户通常不需要听自己说话,所以我的原则是:默认不接destination,只有产品明确要求“实时监听”时才打开,并且强制提示用户戴耳机。
2.2 音量计算:从时域数据到 RMS
有了analyser节点,音量计算有很多种写法。网上常见的做法是拿频域数据getByteFrequencyData,然后求平均,数值随音乐频率分布波动很大。我更推荐用getByteTimeDomainData做时域采样,算 RMS 值,也就是均方根,这样更能反映人耳感知的“响度”。
function getVolume(analyser) { const buffer = new Uint8Array(analyser.fftSize); analyser.getByteTimeDomainData(buffer); let sum = 0; for (let i = 0; i < buffer.length; i++) { const v = (buffer[i] - 128) / 128; sum += v * v; } return Math.sqrt(sum / buffer.length); }返回值大致在 0 到 1 之间,0 表示完全没有信号,0.5 以上基本可以判断为有人在正常说话。拿到这个值之后,用requestAnimationFrame驱动页面上的音量条,频率跟显示器刷新率对齐,视觉上最平滑。注意不要用setInterval每 100 毫秒拿一次,音量条会一顿一顿的,观感很差。
这里也顺带解决了“静音检测”的需求:连续 N 秒音量低于阈值,就认为用户没在说话,可以在 UI 上给出“检测到长时间静音”的提示,或者自动暂停录音。
2.3 移动端切后台、AudioContext 挂起与恢复
移动端 Safari 和 Chrome 在页面切到后台时,会主动把 AudioContext 挂起,状态变成suspended,回来之后也不会自动恢复。表现就是用户锁屏再解锁,音量条不动了,甚至录音文件后半段是空的。
我的处理方式是监听visibilitychange,页面重新可见时检查audioContext.state,如果为suspended,就调用await audioContext.resume()。
document.addEventListener('visibilitychange', async () => { if (!document.hidden && audioContext.state === 'suspended') { await audioContext.resume(); } });如果是长时间录音,比如面试或者会议记录,我还会建议前端做屏幕常亮提醒。浏览器没有直接控制屏幕常亮的 API,但可以通过重复播放一段极短的静音音频或使用屏幕 Wake Lock API 来尽量保持系统不熄屏。Wake Lock 在移动端支持还不算完美,至少 Chrome 系可以试一下,比什么都不做强很多。
3. MediaRecorder 录音的格式与时间片:这一步直接决定文件能不能用
3.1 MIME 选择不能写死
MediaRecorder 在不同浏览器里支持的封装格式不一样,写死audio/webm在 Safari 上会直接抛异常。稳妥的做法是先用MediaRecorder.isTypeSupported逐个探测。
const candidates = [ 'audio/webm;codecs=opus', 'audio/webm', 'audio/mp4', 'audio/ogg;codecs=opus' ]; const mimeType = candidates.find(type => MediaRecorder.isTypeSupported(type)) || ''; const recorder = new MediaRecorder(stream, { mimeType, audioBitsPerSecond: 128000 });从实际兼容性来看,Chrome、Firefox 和 Edge 基本都是webm + opus,Safari 14 以上支持audio/mp4。也就是说,如果你们后端只用 Java 或 Go 解析 webm,那 Safari 用户上传的文件可能直接失败。前后端一定要提前约定:优先支持 webm,后端兼容 mp4。
另外一个经验是不要再信任文件扩展名。通过new File([blob], 'record.webm')构造的文件名很容易造假,后端应该读Content-Type或者文件头部的 magic bytes 来判断真实格式,否则很容易出现“扩展名是 webm,内容是 mp4”的脏数据。
3.2 时间片到底传不传
recorder.start()有两个常见用法:不传参数,或者传入时间片毫秒数。
不传参数时,ondataavailable只会在stop()时触发一次,chunks数组只有一块,最终Blob拼接简单,但录音期间所有数据都堆在内存里。录 10 分钟可能还好,录 1 小时,内存占用就很可观。
传时间片,比如recorder.start(1000),表示每秒触发一次ondataavailable,你能实时拿到音频切片。这样有两个好处:一是可以做“边录边传”,录完 1 秒传 1 秒;二是即使页面崩溃,已经收到的切片还在,不至于全部丢失。
const chunks = []; recorder.ondataavailable = (event) => { if (event.data && event.data.size > 0) { chunks.push(event.data); } }; recorder.onstop = () => { const blob = new Blob(chunks, { type: chunks[0]?.type || mimeType }); // 上传或本地预览 };不过边录边传会显著增加后端复杂度,需要按uploadId + 分片序号暂存,最后再合并。我的建议是:录音时长小于 10 分钟,直接整包上传,简单可靠;超过 10 分钟,或者明确要求低内存,才用时间片 + 分片上传。
3.3 暂停、继续与静音自动停止
MediaRecorder 原生支持pause()和resume(),暂停时不会生成新的切片,恢复后继续写同一个文件。语音面试场景里,如果候选人中途要停下来思考,可以用暂停功能,而不是录完再剪辑。
但要提醒一点,pause()在部分 Android WebView 里实现有 bug,恢复之后可能出现音频时间轴错位。我一般会做一个兼容判断:如果recorder.state === 'paused'之后能正常 resume,才开放暂停按钮;否则退化为“停止并重新录制”。
静音自动停止是我在这个项目里加的一个小功能:用第 2 节的音量检测,连续 10 秒静音就自动调用recorder.stop()。实现起来不复杂,但要注意阈值别设太低。我用过 0.05,结果有人戴着耳机呼吸声稍微大点就被误判成有声;后来调到 0.03,并且要求连续 15 秒低于阈值,误报率才降下来。
4. 上传链路设计:从 Blob 到 FormData,再到分片和重试
4.1 Blob 如何包装成 File,FormData 的坑
录音结束拿到Blob之后,可以直接塞进 FormData,但为了后端好处理,我习惯先包装成File,这样可以指定文件名。文件命名里带上时间戳和用户 ID,排查问题时能省很多力气。
const file = new File([blob], `record-${userId}-${Date.now()}.webm`, { type: blob.type }); const formData = new FormData(); formData.append('file', file); formData.append('userId', userId); formData.append('roomId', roomId); formData.append('duration', durationMs.toString());这里有一个非常经典的坑:有人觉得 multipart 请求需要手动设置Content-Type,于是在fetch里写了Content-Type: multipart/form-data,结果后端一直报“boundary not found”。实际上 multipart 的 boundary 是浏览器在生成 FormData 时自动添加的,你一旦手动设置 Content-Type,boundary 就丢了。正确做法是:用 FormData 时不要手动设置请求头,让浏览器自动生成完整 Content-Type。
上传方式我推荐用XMLHttpRequest而不是fetch,原因只有一个:fetch 没有原生上传进度事件,而录音上传这种体验,进度条几乎是刚需。
const xhr = new XMLHttpRequest(); xhr.open('POST', '/api/upload'); xhr.upload.onprogress = (event) => { if (event.lengthComputable) { const percent = Math.round((event.loaded / event.total) * 100); // 更新进度条 } }; xhr.onload = () => { // 处理响应 }; xhr.send(formData);4.2 超过 10MB 的录音怎么上传:分片与并发控制
录音文件一旦超过几十 MB,整包上传会遇到两个问题:单请求超时、失败后重传成本太高。这时候需要分片上传。前端按固定大小切片,比如每片 2MB,每片携带uploadId、分片序号和总分片数。
const CHUNK_SIZE = 2 * 1024 * 1024; const total = Math.ceil(blob.size / CHUNK_SIZE); const uploadId = `${userId}-${Date.now()}`; async function uploadChunk(chunk, index, uploadId, total) { const formData = new FormData(); formData.append('uploadId', uploadId); formData.append('index', index); formData.append('total', total); formData.append('chunk', chunk, `part-${index}`); // 用 XHR 上传,记录每片进度 }这里有一个工程上的小技巧:不要把所有分片一次性丢出去。浏览器对同一域名的并发连接数是有限的,几十个分片同时上传,后面的请求会长时间排队,进度看着像死了。我通常控制并发数为 3,手写一个简单队列:
async function uploadWithConcurrency(tasks, limit = 3) { const running = []; for (const task of tasks) { const promise = Promise.resolve().then(task); running.push(promise); if (running.length >= limit) { await Promise.race(running); running.splice( running.findIndex(p => p === promise), 1 ); } } await Promise.all(running); }分片上传还有一个好处是失败重试的成本低。哪一片失败只重传哪一片,不需要重新上传整个文件。我还会要求后端提供一个“查询已上传分片”的接口,前端重试时先查一下哪些分片已经到位,能省一部分流量。
4.3 失败重试和 Worker 计算哈希
上传失败时,前端不能只弹一个“网络错误”就完事。网络抖动是常态,尤其移动端。我会封装一个带重试策略的上传函数,失败后等待 1 秒、2 秒、4 秒,最多重试 3 次,也就是指数退避。同时把失败的分片信息存到内存里,用户点“重新上传”时,只重传未成功的分片,而不是整个文件。
关于热词里提到的“json.stringify 前端性能优化”,在录音上传场景里也有对应案例。有人习惯把所有参数拼成一个对象,然后JSON.stringify后直接放进请求体,但 FormData 本身就是键值结构,完全不需要把整个对象序列化成字符串再传输。尤其是分片上传时,每次 append 几个字段就好,不要为了构造一个“完整的请求 JSON”而反复 stringify 大对象,那会在主线程产生大量字符串拷贝,录音页面会明显卡顿。
如果要做分片完整性校验,可以给整个 Blob 计算一个哈希。计算大文件哈希时会占用主线程,页面掉帧很严重。我的做法是把文件切片处理后交给 Worker 计算。Worker 里fetch也能用,所以你也可以把上传动作整个放到 Worker 里,但上传进度回传主线程的频率不要太高,否则 postMessage 本身也会成为性能瓶颈。对于大多数语音面试场景,我的建议是:哈希校验可选,不必为几 MB 录音增加太多复杂度;但如果是外呼录音、合规留存这类重要数据,哈希校验就很有必要。
5. 生产环境典型报错与排查链路:权限屏蔽、异步上传失败、回调丢失
5.1 麦克风权限被屏蔽了几层:浏览器层、系统层、WebView 层
我遇到最多的线上问题,不是代码逻辑,而是“麦克风权限被屏蔽了”。而且这类问题的排查链路往往比想象中长,因为权限可能被浏览器拦一道,被操作系统拦一道,被 WebView 又拦一道。
浏览器层的问题最常见,Chrome 和 Firefox 都能通过地址栏权限设置解决,前面第 1 节已经说过了。系统层的问题更多出现在桌面端:Windows 的隐私设置里如果“允许桌面应用访问麦克风”是关闭的,浏览器拿到权限也会发现设备不可用。macOS 则在“系统设置 - 隐私与安全性 - 麦克风”里单独控制每个应用的权限。
WebView 层是最容易被忽略的。很多 Hybrid App 里,H5 页面明明调了getUserMedia,结果一直报NotAllowedError,前端工程师改了半天,最后发现是 iOS 缺少NSMicrophoneUsageDescription声明,或者 Android 包没有申请RECORD_AUDIO运行时权限。这一类问题纯前端解决不了,必须让原生同事把权限声明和申请逻辑补上。
5.2 一次 “async upload fail error” 的真机排查
热词里有一条是“message:error: 上传失败:网络请求错误, (async upload fail error: 系统错误”,这个报错我印象深刻,因为它的文案极具误导性。
那次是用户用真机调试一个 H5 录音功能,录音和进度条都正常,但点击上传后,控制台出现这个“async upload fail error”。第一反应是后端接口跨域,但直接在浏览器里用同一台电脑访问接口又是好的。后来我按顺序排查:
- 看浏览器 Network 面板,请求到底发出去没有。
- 如果请求没发,或者状态为 0,大概率是网络权限或 WebView 拦截。
- 如果请求状态是 413,说明文件太大,后端网关限制了 body 大小。
- 如果请求状态是 504,说明反向代理超时,需要调整 nginx
proxy_read_timeout。 - 最后看后端返回体,而不是只看网络请求的 status。
那次的问题出在真机调试的 WebView 没走系统代理,接口地址用的是局域网 IP,但手机上这个 IP 不在白名单,请求在网关层就被丢掉了,前端收到的就是泛化的“网络请求错误”。所以这类报错不要盯着“async upload fail”这几个字猜,先定位到具体请求和响应,再决定下一步。
后端也需要提前做好配合:录音文件接口的client_max_body_size或网关的 body 限制要调大;如果走分片上传,合并接口要做好幂等,同一个uploadId重复调用不能生成两份文件。
5.3 上传完成后的状态回传:SignalR 订阅与重连
录音上传成功只是第一步,很多产品会接着做“录音转文字”、“质检分析”这类耗时任务。上传接口如果同步等转写结果,用户会在页面转圈很久。更合理的模式是:上传成功后立即返回一个任务 ID,后端处理完再通过 WebSocket 或 SignalR 推给前端。
SignalR 前端订阅很简单,但有一个隐藏坑:断线重连之后,服务端事件不会自动“补发”给你。如果你只在页面加载时订阅了一次,一旦连接断开再重连,期间产生的转写结果就丢了。我的做法是在重连成功事件里重新订阅,并在前端维护一个“任务状态查询接口”,收到推送更新状态,收不到推送就轮询兜底。
const connection = new signalR.HubConnectionBuilder() .withUrl('/hubs/recording', { accessTokenFactory: () => getToken() }) .withAutomaticReconnect() .build(); connection.on('TranscriptionUpdated', ({ taskId, progress, text }) => { // 更新页面上的转写内容 }); await connection.start();这里的事件名是大小写敏感的,前后端约定命名时最好统一风格,否则会出现“连接正常但就是收不到消息”的诡异问题。真遇到,先在服务端日志确认消息确实发出去了,再检查前端事件名,别一上来就怀疑 SignalR 配置。
这套链路做到这里,基本不用再担心用户录了二十分钟结果上传没反应的问题。我给自己的检查顺序是:先看错误名而不是错误文案,再看浏览器权限设置,然后看网络请求是否真正发出,最后对后端响应体做结构判断。把这个顺序固定下来,类似场景都能少熬夜。
本文还有配套的精品资源,点击获取