ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

H5录音与IndexedDB本地存储实战:从采集到AI转写全链路

H5录音与IndexedDB本地存储实战:从采集到AI转写全链路 1. 从一个真实需求说起为什么要在H5里做录音和本地存储去年年底我接手了一个内部工具项目需求很朴素做一个语音备忘录用户打开网页就能录音录完自动保存刷新页面数据还在最好还能把录音转成文字摘要。听起来简单但真正动手才发现坑不少。浏览器录音涉及权限申请、编码格式兼容、音频数据分片本地存储涉及容量限制、索引设计、数据清理策略。更麻烦的是这个工具要跑在手机浏览器里iOS和Android的差异能把人逼疯。我一开始想用localStorage存音频结果录了30秒就爆了因为localStorage通常只有5MB左右而且只能存字符串。后来换成IndexedDB才算找到正路。IndexedDB是浏览器内置的NoSQL数据库可以存二进制数据容量按域名分配一般能到几百MB甚至更多具体取决于浏览器和磁盘空间。对于语音备忘录这种场景IndexedDB几乎是唯一选择。这篇文章我会把整个实现过程拆开讲包括录音的采集逻辑、IndexedDB的建库建表、音频数据的存储结构、AI语音转文字的对接思路以及我在iOS和Android上踩过的具体坑。如果你也在做H5录音或者本地大文件存储这篇内容应该能帮你省下不少调试时间。2. H5录音的采集链路从getUserMedia到音频分片2.1 录音权限的申请时机与兼容性处理H5录音的第一步是拿到麦克风权限靠的是navigator.mediaDevices.getUserMedia。这个API看起来简单但调用时机很关键。我试过在页面加载时直接申请结果iOS Safari会弹出一个权限框用户如果点了拒绝后面再想申请就难了。更好的做法是让用户主动触发比如点一个“开始录音”按钮在点击事件里调用getUserMedia。这样权限申请有明确的用户意图通过率会高很多。代码大概长这样async function startRecording() { try { const stream await navigator.mediaDevices.getUserMedia({ audio: { echoCancellation: true, noiseSuppression: true, sampleRate: 16000, channelCount: 1 } }); // 后续处理 } catch (err) { console.error(麦克风权限获取失败, err); } }这里有几个参数值得说明。echoCancellation和noiseSuppression建议开启语音备忘录场景下能明显提升可读性。sampleRate设成16000Hz就够了因为人声主要频率范围在300Hz到3400Hz之间16k采样完全覆盖而且文件体积小。channelCount设成1单声道足够立体声只会让文件翻倍。注意iOS Safari对sampleRate的支持不太稳定有时候你设了16000实际拿到的可能是44100。所以后面编码的时候不能硬编码采样率要从track的settings里读实际值。2.2 MediaRecorder的编码格式选择与分片策略拿到stream之后用MediaRecorder来录制。这里最大的坑是编码格式。Chrome支持audio/webm;codecsopusSafari支持audio/mp4Firefox又是另一套。我一开始想统一用webm结果iOS上直接报错。后来改成动态检测function getSupportedMimeType() { const types [ audio/webm;codecsopus, audio/webm, audio/mp4, audio/ogg;codecsopus ]; for (const type of types) { if (MediaRecorder.isTypeSupported(type)) { return type; } } return ; }实测下来Chrome走webm/opusSafari走mp4都能正常录。opus的压缩率很好1分钟语音大概只有100KB左右mp4稍微大一点但也能接受。分片策略方面MediaRecorder有个start(timeslice)方法可以每隔一段时间触发一次ondataavailable。我设的是1000毫秒也就是每秒拿一个Blob片段。这样做的好处是可以在录音过程中就把数据写进IndexedDB不用等全部录完再存。万一页面崩溃或者用户误关已经录的部分不会丢。const recorder new MediaRecorder(stream, { mimeType }); const chunks []; recorder.ondataavailable (e) { if (e.data.size 0) { chunks.push(e.data); // 可以在这里做增量存储 } }; recorder.start(1000);2.3 录音结束后的数据处理与格式转换录音结束后把chunks合并成一个Blob然后根据格式决定后续处理。如果是webm/opus可以直接存如果是mp4也直接存。但如果要对接AI语音转文字很多服务要求wav或者pcm格式这时候就需要转码。转码有两种思路。一种是用AudioContext的decodeAudioData把音频解码成PCM然后再手动封装成wav。另一种是直接用OfflineAudioContext做重采样。我试过第一种代码量不大但要注意decodeAudioData是异步的而且iOS上对某些格式支持不好。async function blobToWav(blob) { const arrayBuffer await blob.arrayBuffer(); const audioContext new AudioContext({ sampleRate: 16000 }); const audioBuffer await audioContext.decodeAudioData(arrayBuffer); // 手动封装wav头 const wavBuffer encodeWav(audioBuffer); return new Blob([wavBuffer], { type: audio/wav }); }encodeWav函数需要自己写主要是拼接RIFF头、fmt块和data块。网上有现成的实现但要注意采样率、位深、声道数这些参数要和audioBuffer一致。我一般用16位深、单声道、16000Hz这样1分钟wav大概1.8MB比opus大不少但兼容性最好。3. IndexedDB的建库建表为音频数据设计合适的存储结构3.1 为什么不用localStorage和Cache API先说说为什么排除其他方案。localStorage前面提过容量小且只能存字符串音频转base64会膨胀33%完全不现实。Cache API主要是给Service Worker用的适合缓存网络请求不适合存用户生成的二进制数据而且查询能力弱。IndexedDB虽然API丑但它是唯一能同时满足大容量、二进制存储、索引查询的方案。IndexedDB的容量限制因浏览器而异。Chrome按域名分配一般能用磁盘剩余空间的60%左右Firefox也是类似策略Safari稍微保守一些但几百MB没问题。对于语音备忘录假设每条1分钟、100KB存1000条也才100MB完全够用。3.2 数据库版本管理与对象仓库设计IndexedDB的版本管理是个容易忽略的点。每次修改表结构都要升级版本号在onupgradeneeded里做迁移。我一般把版本号写成一个常量方便维护。const DB_NAME VoiceMemoDB; const DB_VERSION 1; function openDB() { return new Promise((resolve, reject) { const request indexedDB.open(DB_NAME, DB_VERSION); request.onupgradeneeded (event) { const db event.target.result; if (!db.objectStoreNames.contains(memos)) { const store db.createObjectStore(memos, { keyPath: id, autoIncrement: true }); store.createIndex(createdAt, createdAt, { unique: false }); store.createIndex(status, status, { unique: false }); } }; request.onsuccess () resolve(request.result); request.onerror () reject(request.error); }); }这里我建了两个索引createdAt用于按时间排序status用于筛选待处理、已转写、已删除等状态。索引不要建太多因为每次写入都要更新索引会影响性能。一般根据查询需求建两三个就够了。3.3 音频Blob的存储方式与读取优化存Blob的时候有个细节IndexedDB可以直接存Blob对象不需要转成ArrayBuffer。但有些老版本浏览器对Blob存储支持不好所以我会做一个兼容判断。如果直接存Blob有问题就转成ArrayBuffer存读的时候再转回来。async function saveMemo(memo) { const db await openDB(); return new Promise((resolve, reject) { const tx db.transaction(memos, readwrite); const store tx.objectStore(memos); const request store.add({ ...memo, createdAt: Date.now(), status: pending }); request.onsuccess () resolve(request.result); request.onerror () reject(request.error); }); }读取的时候如果列表页只需要显示标题和时间就不要把整个Blob都读出来。IndexedDB的getAll会把所有字段都加载到内存如果存了几百条音频内存会爆。正确做法是用游标只读需要的字段或者把元数据和音频分开存两个表。我后来改成了两个对象仓库memoMeta存标题、时间、时长、状态memoAudio存音频Blob用同一个id关联。列表页只查memoMeta播放时才去memoAudio取数据。这样列表滚动非常流畅内存占用也低。4. AI语音转文字的对接从音频到文本的完整链路4.1 语音转文字的服务选型与调用方式语音备忘录如果只能录不能转文字价值会打折扣。我对比过几种方案浏览器原生的SpeechRecognitionAPI优点是免费、无需网络请求缺点是iOS支持差、准确率一般云端语音转文字服务准确率高但需要上传音频本地模型隐私好但需要下载模型文件对H5来说太重。最后我选了云端方案因为语音备忘录的场景下用户对准确率要求高而且音频本来就要存上传不是额外负担。调用方式很简单把wav或opus的Blob通过FormData发到服务端服务端调用语音识别接口返回文本。async function transcribeAudio(blob) { const formData new FormData(); formData.append(audio, blob, memo.wav); formData.append(format, wav); formData.append(sampleRate, 16000); const response await fetch(/api/transcribe, { method: POST, body: formData }); const result await response.json(); return result.text; }这里要注意上传之前最好把音频压缩一下。opus格式的100KB音频转成wav会变成1.8MB上传时间差很多。如果服务端支持opus就直接传opus如果不支持可以在客户端用WebAssembly版的opus解码器先转成pcm再封装wav但这样代码复杂度会上升。4.2 转写结果的存储与状态同步转写是异步的用户录完音可能马上就关页面了。所以我在memoMeta里加了一个status字段取值包括pending、transcribing、done、failed。录音保存时状态是pending上传转写时改成transcribing拿到结果后改成done并写入文本。如果用户关页面时还在转写下次打开需要恢复。我的做法是在应用启动时查所有transcribing状态的记录重新发起转写请求。因为音频还在IndexedDB里重新上传就行。这里要注意幂等性服务端最好支持用同一个id去重避免重复转写。async function resumeTranscribing() { const db await openDB(); const tx db.transaction(memoMeta, readonly); const index tx.objectStore(memoMeta).index(status); const request index.getAll(transcribing); request.onsuccess async () { for (const memo of request.result) { await retryTranscribe(memo.id); } }; }4.3 AI摘要生成让语音备忘录更实用转成文字之后还可以进一步用AI生成摘要。比如一段5分钟的会议录音转成文字可能有两三千字用户没时间逐字看。这时候可以调用文本摘要接口生成一两句话的概括。我一般会把转写文本按长度分块每块不超过模型的最大输入长度然后分别摘要再合并。如果文本很短就直接一次调用。摘要结果也存回memoMeta字段叫summary。这样列表页可以直接显示摘要用户一眼就能知道这条录音讲了什么。提示摘要生成可以做成可选的因为会增加API调用成本。我一般让用户在设置里开关默认关闭需要时手动触发。5. 移动端适配的坑iOS和Android的差异处理5.1 iOS Safari的录音权限与后台限制iOS Safari在录音方面有几个硬限制。第一页面切到后台时录音会被暂停MediaRecorder的ondataavailable不再触发。这意味着如果用户录到一半切出去回消息后面的内容就丢了。我的处理方案是监听visibilitychange事件页面隐藏时自动停止录音并保存已录部分页面恢复时提示用户是否继续。document.addEventListener(visibilitychange, () { if (document.hidden recorder recorder.state recording) { recorder.stop(); // 保存已录内容 } });第二iOS上getUserMedia必须在用户手势的同步调用栈里触发不能放在setTimeout或者await之后。我试过先弹一个自定义确认框用户点确认后再申请权限结果iOS直接拒绝。后来改成按钮点击后立即调用才正常。5.2 Android Chrome的采样率与编码差异Android Chrome相对宽松但不同厂商的ROM差异很大。我在小米和华为上测过同样的代码小米拿到的采样率是48000华为是44100。所以前面说的动态读取采样率很重要。另外Android上MediaRecorder的audio/webm;codecsopus支持很好但有些低端机不支持opus会回退到audio/webm这时候文件会大一些。我一般会在isTypeSupported里按优先级检测拿到什么用什么。5.3 页面刷新与数据恢复的处理H5页面刷新会丢失内存里的状态但IndexedDB里的数据还在。所以应用启动时要先读数据库把已有的备忘录列表渲染出来。如果刷新时正在录音那部分数据可能只存了前几秒需要标记为“不完整”并提示用户。我一般会在录音开始时就在数据库里插入一条记录状态是recording然后每秒更新一次已录时长。如果页面刷新下次启动时发现状态是recording就把它改成interrupted并在列表里显示一个警告图标。用户可以选择播放已录部分或者删除重录。6. 性能优化与数据清理让备忘录长期可用6.1 音频数据的压缩与格式选择前面提到opus压缩率好但兼容性不如mp4。我的策略是如果浏览器支持opus就录opus存的时候也存opus如果不支持就录mp4。播放的时候用URL.createObjectURL生成临时链接用完就revokeObjectURL释放内存。function playMemo(blob) { const url URL.createObjectURL(blob); const audio new Audio(url); audio.play(); audio.onended () URL.revokeObjectURL(url); }如果存储空间紧张可以在保存前做一次重采样把44100Hz降到16000Hz文件能小一半多。但重采样需要解码再编码比较耗CPU我一般只在用户手动选择“压缩存储”时才做。6.2 IndexedDB的容量监控与清理策略IndexedDB没有直接的容量查询API但可以用navigator.storage.estimate()拿到已用空间和配额。const estimate await navigator.storage.estimate(); console.log(已用 ${estimate.usage} / 配额 ${estimate.quota});我一般设一个阈值比如用到配额的80%时提示用户清理。清理策略有两种一是按时间删除30天前的记录二是按数量保留最近100条。我倾向于让用户自己选因为有些备忘录可能很重要。删除的时候要注意IndexedDB的删除是异步的而且不会立即释放磁盘空间。需要等事务完成浏览器才会在后台回收。如果用户删了大量数据可以提示“空间将在稍后释放”。6.3 列表渲染的虚拟滚动与懒加载如果备忘录有几百条列表渲染要用虚拟滚动只渲染可视区域内的DOM。我一开始用简单的v-for结果列表滚动卡顿严重。后来改成只渲染前20条滚动到底部再加载更多流畅度提升明显。图片和音频的懒加载也很重要。列表页不要加载音频Blob只显示元数据。用户点击播放时再去数据库取。这样首屏加载时间能从几秒降到几百毫秒。7. 我踩过的几个典型坑与解决方案7.1 录音时长计算不准的问题MediaRecorder没有直接提供录音时长我一开始用Date.now()差值来算结果发现暂停再继续后时长会偏大。后来改成用AudioContext解码后读audioBuffer.duration这个值是精确的。const audioBuffer await audioContext.decodeAudioData(arrayBuffer); const duration audioBuffer.duration; // 单位秒但解码需要时间对于长录音可能卡顿。折中方案是录音时用performance.now()累计暂停时停止累计恢复时继续。这样精度够用也不耗CPU。7.2 iOS上Blob存储后读取失败iOS Safari有个bug存进去的Blob读出来之后type可能变成空字符串导致URL.createObjectURL生成的链接无法播放。我的解决办法是存的时候把type单独存一个字段读出来之后重新构造Blob。const blob new Blob([storedData], { type: storedType });这个坑花了我整整一个下午才定位到因为Chrome上完全正常只有iOS有问题。7.3 转写接口的超时与重试语音转文字接口有时候会超时尤其是音频比较长的时候。我一开始没做超时处理用户等了30秒没反应就以为卡死了。后来加了AbortController设15秒超时超时后自动重试一次再失败就标记为failed让用户手动重试。const controller new AbortController(); const timeoutId setTimeout(() controller.abort(), 15000); try { const response await fetch(/api/transcribe, { signal: controller.signal, // ... }); } catch (err) { if (err.name AbortError) { // 重试逻辑 } } finally { clearTimeout(timeoutId); }7.4 多标签页同时录音的冲突如果用户开了两个标签页都去申请麦克风第二个会失败。我试过用BroadcastChannel做跨标签页通信但兼容性一般。后来改成用localStorage存一个锁标记录音前先检查有没有其他标签页在录。虽然不够优雅但够用。function acquireLock() { const lock localStorage.getItem(recording_lock); if (lock Date.now() - parseInt(lock) 60000) { return false; } localStorage.setItem(recording_lock, Date.now().toString()); return true; }这个锁需要定期续期否则用户正常录音超过60秒会被误判。我一般每10秒更新一次时间戳。8. 一些实用的扩展思路8.1 离线优先的架构设计语音备忘录很适合做成离线优先的应用。录音、存储、播放全部在本地完成只有转写和摘要需要网络。这样即使在地铁里没信号也能正常录音等有网了再自动同步转写。实现上可以用Service Worker缓存页面资源用IndexedDB存数据用Background Sync API做延迟上传。8.2 音频波形的可视化如果想让备忘录更直观可以在录音时实时绘制波形。用AnalyserNode拿到频域数据然后画到canvas上。这个功能不难但要注意性能不要每帧都重绘整个canvas只更新变化的部分。const analyser audioContext.createAnalyser(); analyser.fftSize 256; const dataArray new Uint8Array(analyser.frequencyBinCount); function drawWaveform() { analyser.getByteFrequencyData(dataArray); // 绘制到canvas requestAnimationFrame(drawWaveform); }8.3 导出与分享功能用户可能想把备忘录导出成文件或者分享给别人。导出可以用Blob生成下载链接分享可以用navigator.share。注意iOS上navigator.share需要用户手势触发不能自动调用。async function shareMemo(blob, title) { const file new File([blob], ${title}.wav, { type: audio/wav }); if (navigator.canShare navigator.canShare({ files: [file] })) { await navigator.share({ files: [file], title }); } }这个功能在移动端很实用用户录完可以直接分享到聊天工具里。9. 最后聊几句实际使用中的体会这套方案我已经在几个内部工具里用了大半年整体稳定性不错。IndexedDB的可靠性比我想象中好只要不主动清理浏览器数据存进去的东西基本不会丢。iOS的坑确实多但摸清楚规律之后也能绕过去。如果让我重新设计我会把元数据和音频从一开始就分开存这样列表页的性能会更好。另外转写状态的管理要更细致最好加一个重试次数上限避免无限重试浪费资源。还有一点录音按钮的交互要做得足够明显因为很多用户第一次用不知道要授权麦克风点一下没反应就以为坏了。这个方向后续还可以扩展很多比如加标签分类、全文搜索、多端同步。但核心的录音加IndexedDB存储这条链路上面这些内容应该够用了。
返回列表