
最近接了个需求在H5页面里加一个语音输入功能用户点一下按钮说话系统把语音转成文字填到表单里。这个功能听起来很常规但真正落地时牵扯到的技术点其实不少——浏览器兼容性、麦克风权限、识别引擎选型、录音数据处理每一环都有坑。这篇文章就把我实现“H5语音转文字”的完整过程整理出来。内容包括两条技术路线的对比浏览器原生API方案和录音后端识别方案、具体代码实现、参数调优思路以及实际开发中遇到的一堆典型问题。无论你是刚接触H5开发的新手还是要在现有项目里快速集成语音输入功能的老手这篇文章应该都能帮你少走几步弯路。1. 技术选型两条路线怎么选1.1 浏览器原生识别和服务端识别的本质区别实现网页端语音转文字业界基本就两条路。第一条路是使用浏览器自带的 Web Speech API具体来说是SpeechRecognition接口。你的声音在本地被浏览器捕获然后直接发给浏览器厂商的语音识别服务Chrome 走的是 Google 的服务识别完成后把文字结果返回给你。整个过程不需要你自己搭建任何后端服务也不需要申请第三方API Key代码量非常少。第二条路是先把用户的语音录下来生成音频文件然后通过 HTTP 请求上传到你自己的服务器或者第三方的语音识别API比如云厂商的ASR服务、开源的Whisper接口等由服务器完成识别后再把文本返回给前端。这两条路线的核心区别我可以给你看个对比表对比维度Web Speech API 原生方案录音后端ASR方案前端工作量很小核心代码几十行中等涉及录音、音频处理、上传后端依赖不需要必须要有服务器接收音频并调用识别接口浏览器兼容性仅Chrome/Edge等Chromium内核支持支持所有可用getUserMedia的浏览器识别准确率对普通话、英文表现不错取决于ASR引擎通常可定制性更强流式识别原生支持边说话边出结果通常要等音频传完才能出结果离线能力不支持如果部署本地大模型可以完全离线成本免费但厂商策略可能变动按API调用量计费或自部署硬件成本1.2 我为什么最终保留了双方案我在实际项目里没有只选一条路而是做了一个简单的降级策略。主方案用的是 Web Speech API因为实现快、体验好——“边说话边出字”这个交互反馈对用户来说太重要了能明显降低他们的不确定感。但我也写了一个 fallback如果浏览器不支持SpeechRecognition比如 Safari、部分安卓内置浏览器、App内嵌的WebView就自动切到录音上传方案。这样做的主要原因是业务场景太复杂。我做的是一个企业服务类H5用户可能用微信内置浏览器打开也可能在钉钉里打开还可能是在某个管理App的WebView里打开这些环境的浏览器内核差异很大。如果只做原生语音识别方案相当一部分用户会直接看到“此浏览器不支持语音输入”那这个功能就白做了。另外补充一点如果你要接的是本地语音转文字大模型那就只能走录音上传方案。这类模型通常部署在内网服务器或本地电脑上通过HTTP接口接收音频文件再把识别文本返回给前端。H5这边要做的就是录音、降噪、转格式、上传把“前端该干的活”干好。2. 主方案用 Web Speech API 实现实时语音识别2.1 先做兼容性判断再决定走哪条分支不管页面怎么设计进入语音功能的第一步都不是弹录音按钮而是先检测当前环境到底支不支持原生API。我建议把这段兼容性判断放在页面初始化的时候执行const SpeechRecognition window.SpeechRecognition || window.webkitSpeechRecognition; if (SpeechRecognition) { // 走原生方案 initNativeRecognition(); } else { // 走录音上传方案 enableFallbackMode(); }注意webkitSpeechRecognition这个前缀。Chrome 实现这个API比较早早期版本都需要加前缀虽然现在新版本已经支持无前缀写法了但为了兼容老版本浏览器两个都要判断一下。这里有一个前提条件需要提前告诉读者Web Speech API 只允许在安全上下文HTTPS或者 localhost 环境下调用。如果你在 HTTP 环境里访问navigator.mediaDevices会是 undefinedSpeechRecognition也可能拿不到。我调试的时候经常用内网IP加端口访问结果半天没反应最后才发现是没有走 HTTPS。注意语音识别功能对安全上下文有硬性要求。不要等到上线才发现问题开发阶段就应该把测试环境配好 HTTPS 证书或者直接用 localhost 调。2.2 核心代码从按下按钮到文字上屏的完整链路这里我直接给你一套可以用到项目里的完整示例。场景是页面上有一个“按住说话”的按钮按下开始识别松开结束识别识别出的文字填入一个textarea里。class VoiceInput { constructor() { this.recognition null; this.finalText ; this.isRecording false; this.init(); } init() { const SpeechRecognition window.SpeechRecognition || window.webkitSpeechRecognition; if (!SpeechRecognition) { console.error(当前浏览器不支持 SpeechRecognition); return; } this.recognition new SpeechRecognition(); // 设置识别语言为简体中文 this.recognition.lang zh-CN; // 连续识别适合长段语音 this.recognition.continuous true; // 返回中间结果实现边说话边出字 this.recognition.interimResults true; // 返回几个候选结果一般1个就够 this.recognition.maxAlternatives 1; this.recognition.onresult (event) { let interimText ; for (let i event.resultIndex; i event.results.length; i) { const result event.results[i]; if (result.isFinal) { // 这句已经识别完毕拼到最终文本里 this.finalText result[0].transcript; } else { // 这句还在边听边识别先展示在界面上 interimText result[0].transcript; } } // 把“最终文字 中间文字”一起渲染 this.renderText(this.finalText interimText); }; this.recognition.onerror (event) { console.error(识别错误, event.error); }; this.recognition.onend () { // 当识别结束时如果仍处于录音状态则自动重启 if (this.isRecording) { this.recognition.start(); } }; } start() { if (!this.recognition) return; this.finalText ; this.isRecording true; try { this.recognition.start(); } catch (e) { // 避免重复start导致的异常 } } stop() { this.isRecording false; if (this.recognition) { this.recognition.stop(); } } }这段代码有几个关键的“为什么”我解释一下。第一为什么continuous要设为true当你按住说话的时间超过几秒原生识别引擎可能就自动中断并返回结果了。如果你设置的是单句识别默认为false用户说了一长段话中间一停顿就断掉体验很差。设置为true后识别会话会持续进行所有语音合成一段结果。第二为什么onresult里还要区分isFinal和中间结果这是 Web Speech API 的一个特性它是流式识别的识别引擎每处理一小段语音就会回调一次结果但结果分为稳定结果isFinal为true和临时结果isFinal为false。用户看到的“边说边出字”效果就是临时结果在持续刷新。如果你只处理最终结果用户会觉得很卡——说完话半天才蹦出一整句。第三onend里为什么还要重启start()因为continuous true也不是无限连续的可能因为网络波动、识别引擎空闲等原因自动结束。为了让“按住说话”期间的识别不中断要在onend回调里检查是否还在录音状态如果是就重新启动。2.3 交互细节按住说话比点击切换体验好语音输入最常见的交互有两种点击切换点一下开始再点一下结束和按住说话。我做过的体验测试告诉我对“语音转文字”场景按住说话明显更好。原因很简单用户不需要考虑当前到底是什么状态按住就是录音松手就是结束心里负担小。实现按住说话用指针事件这里有几个需要注意的点const button document.getElementById(voiceBtn); button.addEventListener(pointerdown, (e) { e.preventDefault(); voiceInput.start(); button.classList.add(recording); }); button.addEventListener(pointerup, (e) { voiceInput.stop(); button.classList.remove(recording); }); // 用户按住了按钮但滑出了按钮区域也要取消 button.addEventListener(pointerleave, (e) { if (e.buttons 1) { voiceInput.stop(); button.classList.remove(recording); } });用pointerdown而不是mousedown/touchstart是为了在移动端和PC端能用一套代码搞定。pointerleave里判断e.buttons 1表示鼠标左键还按着但已经滑出了按钮区域这个时候应该停止识别逻辑和微信的按住说话一样。注意在 iOS 的 Safari 里录音相关的接口通常要求必须由用户的触摸手势来触发。如果pointerdown里没有调e.preventDefault()可能触发不了getUserMedia的授权流程一定要加上。3. 兜底方案录音上传到后端识别的完整实现3.1 什么时候必须走后端方案Web Speech API 的兼容性是硬伤。这里列几个我实测过的典型场景Safari 浏览器至今不支持SpeechRecognition你在 iPhone 的 Safari 里打开用原生API的页面会直接报错。安卓微信内置浏览器某些版本的 X5 内核不支持或者支持了但调用会闪退。企业App的WebView取决于系统WebView的版本安卓低版本系统配合旧内核几乎不可用。线下会议场景如果部署了本地语音转文字大模型后端识别才符合客户的数据安全要求不能把音频传到公网。出现这些情况就要走“前端录音 后端ASR”的方案了。3.2 录音与波形可视化Web Audio API 的几个关键坑录音这步主要用navigator.mediaDevices.getUserMedia拿麦克风音频流再用MediaRecorder录制。这部分的核心代码基本是这个套路async function startRecording() { const stream await navigator.mediaDevices.getUserMedia({ audio: { echoCancellation: true, noiseSuppression: true, autoGainControl: true, } }); // 用于波形绘制 const audioContext new AudioContext(); const source audioContext.createMediaStreamSource(stream); const analyser audioContext.createAnalyser(); analyser.fftSize 256; source.connect(analyser); // 用于最终录制的文件 const mediaRecorder new MediaRecorder(stream); const chunks []; mediaRecorder.ondataavailable (e) { if (e.data.size 0) { chunks.push(e.data); } }; mediaRecorder.onstop () { const blob new Blob(chunks, { type: mediaRecorder.mimeType || audio/webm }); uploadAudio(blob); }; mediaRecorder.start(); // 保存实例方便后续停止 window.currentRecorder mediaRecorder; }有几个坑我必须单独拿出来说。第一个坑是getUserMedia的参数。如果你只写{ audio: true }浏览器会用默认的音频配置在嘈杂环境下识别效果会很差。强烈建议把echoCancellation、noiseSuppression、autoGainControl都设为true。这三个参数分别控制回声消除、降噪、自动增益对语音识别前端的音质影响很大。第二个坑是浏览器录制出来的音频格式。MediaRecorder在不同浏览器里默认格式不一样Chrome 一般输出audio/webmSafari 输出audio/mp4。如果你的后端ASR服务只支持固定的格式比如常见的wav或mp3前端直接上传 webm 可能会被拒。处理办法是前端先把录音转成目标格式或者后端兼容多种格式。第三个坑是音频采样率。很多语音识别模型要求音频采样率是 16kHz也有支持 8kHz 电话质量的但浏览器录音默认的采样率通常是 48kHz 或 44.1kHz。如果直接上传识别引擎可能会因为采样率不对而出错或准确率下降。处理方式是在前端用AudioContext重采样或者在后端做重采样处理。前端重采样的代码我会在下面详细说。3.3 AudioContext 重采样与 WAV 编码如果你后端的识别接口比较“挑剔”只接收 16kHz 单声道的 WAV 文件我推荐加一步前端音频处理把录音转成标准的 WAV 格式再上传。思路是这样的MediaRecorder录完的 blob 先转成 ArrayBuffer用AudioContext.decodeAudioData解码出原始音频数据再用一个离线AudioContext以 16000Hz 的采样率重新编码最后手动拼一个 WAV 文件头。示例代码async function convertToWav(blob, targetSampleRate 16000) { const arrayBuffer await blob.arrayBuffer(); const audioContext new AudioContext(); const audioBuffer await audioContext.decodeAudioData(arrayBuffer); const numChannels 1; // 强制单声道 const numSamples Math.floor(audioBuffer.duration * targetSampleRate); const offlineContext new OfflineAudioContext(numChannels, numSamples, targetSampleRate); const source offlineContext.createBufferSource(); source.buffer audioBuffer; source.connect(offlineContext.destination); source.start(); const renderedBuffer await offlineContext.startRendering(); return bufferToWav(renderedBuffer); } function bufferToWav(buffer) { const numChannels buffer.numberOfChannels; const sampleRate buffer.sampleRate; const numFrames buffer.length; const bytesPerSample 2; const blockAlign numChannels * bytesPerSample; const dataSize numFrames * blockAlign; const arrayBuffer new ArrayBuffer(44 dataSize); const view new DataView(arrayBuffer); // WAV 文件头 writeString(view, 0, RIFF); view.setUint32(4, 36 dataSize, true); writeString(view, 8, WAVE); writeString(view, 12, fmt ); view.setUint32(16, 16, true); view.setUint16(20, 1, true); // PCM格式 view.setUint16(22, numChannels, true); view.setUint32(24, sampleRate, true); view.setUint32(28, sampleRate * blockAlign, true); view.setUint16(32, blockAlign, true); view.setUint16(34, bytesPerSample * 8, true); writeString(view, 36, data); view.setUint32(40, dataSize, true); // 写入音频数据取每个通道的样本这里只处理单声道 const channels []; for (let i 0; i numChannels; i) { channels.push(buffer.getChannelData(i)); } let offset 44; for (let i 0; i numFrames; i) { for (let ch 0; ch numChannels; ch) { const sample Math.max(-1, Math.min(1, channels[ch][i])); view.setInt16(offset, sample 0 ? sample * 0x8000 : sample * 0x7FFF, true); offset 2; } } return new Blob([arrayBuffer], { type: audio/wav }); }这段代码解决了两件事把浏览器录出来的 audio/webm 或 audio/mp4 转成标准 WAV把采样率重采样成 16kHz。因为最终要上传的音频在时间上其实也是这几秒到几十秒前端做一次转换的性能开销完全可以接受。上传的时候直接走FormDataasync function uploadAudio(blob) { const formData new FormData(); formData.append(audio, blob, speech.wav); formData.append(lang, zh-CN); const response await fetch(/api/asr, { method: POST, body: formData, }); const result await response.json(); // 把识别出的文字填回输入框 document.getElementById(resultText).value result.text; }这里接口的返回结构完全取决于后端设计。我接触过的项目里有的返回纯文本字段有的返回带时间戳的句子数组有的还带标点恢复和数字格式化。前端只要按约定把文字回填到页面上即可。4. 参数调优让识别结果更准的几个经验4.1 语言设置与连续识别策略recognition.lang这个参数直接影响识别效果。我在国内项目里默认用zh-CN但注意两点如果用户有明显的方言口音比如四川话、粤语zh-CN的默认模型表现一般。这时候可以考虑切换到对应的方言语言代码比如zh-CN下有的引擎会尝试带方言的口音自适应但不保证。实际项目中如果目标用户群集中在某个方言区建议直接测一下方言识别率再决定是否需要调整语言代码。中英文混说场景下比如夹英文单词可以试试zh-CN会不会影响英文词的识别。实测下来Chrome 的zh-CN模型对常见英文缩写比如API、App、URL处理得还不错但长篇英文就不行了那种场景建议切成en-US或者干脆切换识别引擎。连续识别策略方面使用场景continuous 设置建议会议纪要、长段口述true配合静音自动停止表单输入、短句填写false一句说完自动结束对话式交互false每轮说完就返回等待用户操作语音听写true不间断记录后期统一处理如果想要“自动停止”的效果就是用户说完话停顿几秒后自动结束识别需要前端做静音检测。思路是定期去查AnalyserNode的音量数据如果连续N秒低于阈值就调recognition.stop()。这里没有统一的“有效静音”阈值我习惯用 0.01 作为音量阈值停顿 2.5 秒作为触发条件你可以根据实际环境微调。4.2 中间结果的刷新策略与光标处理在把识别结果写到textarea的时候有一个体验细节当interimResults开启后识别结果会非常频繁地回调如果每次都把整段文本重新塞进输入框用户会看到光标在闪烁跳动。而且如果用户本来已经把光标移到某个位置想手动修改识别结果刷新会把光标“踢”到末尾。我的处理建议有两点。第一在识别期间给输入框设置一个“只读缓冲”状态比如加一个 CSS class让背景色变一下提示用户当前是语音输入模式避免用户和识别结果“抢方向盘”。第二识别结束后把最终文本追加到输入框当前光标位置。追加之前先记录selectionStart完成后再手动恢复光标位置function insertTextAtCursor(textarea, text) { const start textarea.selectionStart; const end textarea.selectionEnd; const value textarea.value; textarea.value value.slice(0, start) text value.slice(end); textarea.selectionStart textarea.selectionEnd start text.length; textarea.focus(); }4.3 音量检测与可视化反馈录音过程中给用户一个实时的音量反馈这个功能看起来很花哨但对用户信心提升很大。他们能看到“这根柱子随着声音在跳”就知道录音确实在进行。用AnalyserNode.getByteFrequencyData或getByteTimeDomainData拿时域数据计算当前音量function getVolumeLevel(analyser, dataArray) { analyser.getByteTimeDomainData(dataArray); let sum 0; for (let i 0; i dataArray.length; i) { const val (dataArray[i] - 128) / 128; sum val * val; } return Math.sqrt(sum / dataArray.length); }拿到音量数值后可以驱动一个 canvas 画波形也可以驱动一个一维的长度变化。我的建议是用requestAnimationFrame循环去画不要用setIntervalsetInterval在移动端会因为线程调度产生明显的卡顿。5. H5页面适配与体验细节5.1 页面响应式布局与按钮位置H5页面要兼顾手机竖屏、PC浏览器、大屏投屏几种场景录音按钮的位置和大小设计很重要。我的做法是底部固定一个悬浮按钮刚好在拇指最容易点击的区域同时用媒体查询控制按钮的大小。这里有一个很重要的安全区问题iPhone 的底部有 Home Indicator安卓有虚拟导航栏如果按钮太靠下会被这些系统控件挡住。解决方案是计算环境安全区.voice-button { position: fixed; left: 50%; transform: translateX(-50%); bottom: calc(20px env(safe-area-inset-bottom)); width: 64px; height: 64px; border-radius: 50%; }env(safe-area-inset-bottom)是iOS Safari支持的环境变量配合viewport-fitcover才能生效。安卓端 Chrome 从较新的版本开始也支持了所以可以放心用。5.2 在WebView与微信H5里的兼容性问题之前我做过一个被嵌入到企业App里的H5语音输入模块踩了几个 WebView 特有的坑权限询问某些App的WebView没有允许网页调用麦克风需要在原生层配置权限。前端检测到navigator.mediaDevices为undefined时可以给用户一个友好提示而不是直接报错。缓存问题安卓 WebView 里 H5 页面更新后缓存仍然生效导致语音功能代码没有更新到最新版本。排查的时候经常发现我们以为线上是新代码实际上用户跑的还是几周前的逻辑。可以在页面 URL 加版本参数或者让原生开发在初始化WebView时禁用缓存。录音时长限制部分低端安卓机 WebView 里MediaRecorder录制超过一定时间会自动终止。处理方式是在onpause或onstop事件里检查录制时长如果太短就提示用户重试。5.3 音频文件上传与识别结果回填录音完成后音频文件上传过程中的用户引导也很重要。我发现给用户加一个“识别中”的过渡状态很有必要不然页面看起来像卡死了。上传时我会这样设计UI上传前显示录音时长、音频大小让用户决定是重新录音还是使用当前结果。上传中显示一个 Loading 动画文案是“正在识别...”同时禁用按钮防止重复提交。上传完成把识别文字回填到目标输入框如果返回了置信度较低的结果用不同颜色标记出来让用户手动修改。上传失败保留录音文件允许用户重试上传不要直接把录音文件清掉。6. 常见问题与排查技巧实录6.1 语音识别报错速查表下面这些错误是我在开发中最常遇到的直接整理成一张速查表错误现象可能原因解决办法not-allowed用户拒绝了麦克风权限检查权限请求时机避免页面加载就弹窗建议用按钮手势触发service-not-allowed浏览器禁止语音服务确认HTTPS环境清理浏览器设置network网络异常导致识别服务不可达提示用户检查网络做自动重试机制no-speech没有检测到语音提示用户靠近麦克风检查设备权限audio-capture找不到可用麦克风设备检查系统录音设备是否被占用abortedstop()被主动调用或冲突排查是否有重复调用stop识别结果全乱码采样率不匹配或前端传参错误检查音频转换后的格式确认后端模型要求的采样率识别结果没有标点识别引擎未开启标点恢复如果是Web Speech API通常自带标点如果是后端ASR确认是否开启自动标点6.2 麦克风权限被拒后如何引导恢复麦克风权限被拒是最常见的坑。在Chrome里如果用户第一次点了“阻止”下次网页再调用时会直接静默拒绝连授权弹窗都不出现。这种情况下前端只能引导用户手动去浏览器设置里恢复授权。我的做法是在识别错误回调里捕获not-allowed弹出一个提示层引导用户点击“去设置”同时用简单的图文说明告诉他在哪里修改。如果你能拿到用户设备的精确识别安卓/iOS还可以直接深链到系统设置页面。但注意iOS Safari 目前不允许网页直接打开系统设置只能靠纯文字引导。6.3 识别不准确怎么办识别不准确的排查顺序我一般按这个思路走先确认识别语言和场景是否匹配。你对着en-US说中文结果当然离谱。再确认输入音频质量。噪音大、距离远、多人说话再强的引擎也扛不住。看音频的采样率和格式是否满足引擎要求。格式不对经常导致识别结果异常。如果以上都没问题那大概率是后端模型本身的准确率问题需要换引擎或者做领域定制。还有一个实用小技巧Web Speech API 的maxAlternatives参数可以设大于1。比如设为3event.results[i]数组里会返回多个候选文本。你可以把第二候选词作为“你是不想打这个吗”的联想词展示出来或者在后端做纠错。不过说实话大多数场景候选词用处不大因为差距往往是微小的同音字差异属于语义层面单纯靠多候选取一个也选不对。6.4 一个典型的“本地大模型识别特别慢”的排查案例有个线下项目的H5语音转文字前端录音没问题上传也没问题但后端识别耗时经常十几秒。排查下来发现前端上传的音频采样率是48kHz而本地部署的模型配置要求8kHz输入导致模型内部先做了一次重采样这个操作在CPU环境下非常耗时。后来前端直接把录音转成16kHz WAV上传识别速度从十几秒降到了三四秒效果立竿见影。这个案例说明前端看似“多此一举”的音频预处理对整体链路的影响可能非常大。别嫌麻烦格式和采样率这种基础参数还是要和前端的每个后端确认清楚。7. 进阶方向从功能到体验的几点扩展思路7.1 做一个可复用的语音输入组件项目里如果多个页面都要用到语音转文字我会建议封装成一个独立组件而不是每个页面都复制一遍代码。组件对外暴露的接口尽量简单const voiceInput new VoiceInput({ target: #input-id, // 目标输入框 onStart: () {}, // 开始识别回调 onResult: (text) {}, // 拿到最终结果回调 onError: (err) {}, // 错误处理 });组件内部统一处理原生API降级逻辑、录音按钮UI、权限请求、错误提示、音频转换上传。这样业务方只需要引入组件并传一个输入框选择器剩下的细节全部由组件兜住。7.2 长音频分段识别与时间戳对齐如果你要把一段很长的录音比如会议录音转成带时间戳的文字稿不能把整段音频一次性丢给后端识别。多数识别引擎对单次请求的音频时长有上限比如60秒或5分钟即使没有上限一次性识别长音频的延迟也让人难以接受。合理的做法是前端或后端先把音频切成若干段比如每30秒一段逐段识别再把每段的结果按时间戳拼接起来。切分点的选择要谨慎最好在静音段附近切割避免把一句话从中间切断。这个逻辑通常放在后端做前端需要做的就是允许上传更大的音频文件并把上传进度展示给用户。7.3 结合本地大模型的完全离线方案在数据敏感的内网场景本地部署语音转文字大模型的需求越来越常见。H5端在这种方案里做的事情没有变录音、转格式、上传。但有一点不同本地模型的接口需要特别关注。有的模型还支持流式识别需要前端通过WebSocket持续推送音频分片而不是一次性传完文件再等结果。如果后端提供的是 WebSocket 流式接口前端就要把录音的音频流切成小块逐块发送mediaRecorder.ondataavailable (e) { if (socket.readyState WebSocket.OPEN) { socket.send(e.data); } };这种方式的好处是边录边传后端边听边识别整体延迟能明显降低。我在实际项目中测试过使用本地大模型做语音识别时流式传输方案比文件上传方案的“首字时间”能快一半以上但实现复杂度也高不少。如果你的业务对响应速度要求不高文件上传方案其实更稳妥。一些来自实操的体会做完这个H5语音转文字功能后我最大的感觉是语音识别本身的准确率很重要但用户最终体验好坏更多取决于交互细节和异常兜底做得怎么样。同样是识别出一句“我要订明天下午三点的会议室”用户会因为这句话是实时蹦出来的而觉得“好智能”也会因为录音按钮在某个浏览器里点了没反应而直接放弃这个功能。这两者的差距往往不是模型决定的而是前端工程细节决定的。所以我的建议是动手写代码之前先花半小时把兼容性降级路径想清楚把错误提示文案写好把“识别中”和“识别失败”的状态设计好。这些边角料工作比多调几个识别参数更能直接提升用户满意度。如果你也在做类似功能遇到什么坑或者有更好的处理思路欢迎一起交流。毕竟语音交互这个方向前端能玩的细节实在太多了。