WebGPU 跑 Whisper:分块浪费6.8倍算力
WebGPU 跑 Whisper分块浪费6.8倍算力WebGPU 2025 年底成 BaselineTransformers.js v4 让浏览器内 Whisper 提速 3-10 倍。但模型进浏览器只是第一步前端转写用 whisper-tiny 本地识别4 秒分块让 encoder 每帧算 26 秒静音转录 60 秒多花 6.8 倍算力。一、先看这个东西能干什么项目在Dresume/speech-to-text一共只有 4 个文件index.html、app.js、styles.css、download_model.py加上内置在models/里的 whisper-tiny 权重。没有构建步骤、没有 npm install、没有后端起个静态服务器就能跑。它最大的特点是双模式可切换模式引擎音频去向联网准确率首次成本云端识别Web Speech API上传到浏览器厂商服务器必须高0离线本地Transformers.js whisper-tiny ONNX只在本机浏览器内不需要模型已内置中等加载 127 MB 权重两种模式共用同一套 UI 和状态机切换只改一个mode变量。这个设计很关键——本地模型一旦加载失败用户可以一键退回云端不至于整个功能挂掉。前端能力清单8 种语言切换中/英/日/韩/法/西/德实时追加转写结果区分「已确定」与「临时」文本一键复制 / 下载.txt/ 清空实时字数统计完整的错误映射权限被拒、无麦克风、服务被阻止、网络异常分别提示1.1 云端模式也不能糊弄错误码映射表Web Speech API 抛出来的error是一串英文枚举直接丢给用户等于没说。项目里做了一张映射表把每一种失败翻译成可执行的建议error 枚举含义项目给出的提示not-allowed麦克风权限被拒请在地址栏允许麦克风访问后重试service-not-allowed识别服务被阻止语音识别服务被阻止可能未授权或网络受限audio-capture找不到采集设备未找到麦克风设备请检查麦克风连接network网络异常网络异常云端识别需要联网aborted被中断识别被中断no-speech瞬时静音直接忽略继续聆听no-speech这一条容易被漏掉用户中途停顿一两秒就会触发如果当成错误处理直接停掉识别体验会非常糟。正确做法是 return 掉继续监听。还有一个真实坑Chrome 在持续静音几秒后会自己触发onend。所以onend里必须判断「用户是否还想听」想听就立刻重启recognition.onendfunction(){commitInterim();// 把临时结果固化避免丢字render();if(wantListening){try{recognition.start();}catch(e){/* 已在启动中 */}}else{els.startBtn.disabledfalse;els.stopBtn.disabledtrue;}};wantListening这个布尔量是整个状态机的锚点——云端和本地两套引擎都靠它决定「停」是临时中断还是彻底结束。二、技术选型为什么不只用 Web Speech API先说结论Web Speech API 好用但不能只用。方案体积隐私离线可控性浏览器兼容Web Speech API0音频出本机不可用无黑盒Chrome/Edge 为主whisper.cpp WASM约 40 MB本机可用高无 GPU 加速Transformers.js (wasm)127 MB本机可用高全平台Transformers.js (webgpu)127 MB本机可用高需 WebGPUWeb Speech API 有三个硬伤标准未规定后端由浏览器厂商实现国内 Chrome 表现不稳定、必须联网、以及音频要离开本机。做会议记录、问诊口述这类场景第三条直接一票否决。之所以选 Transformers.js 而不是 whisper.cpp WASM是因为前者 API 跟 Python transformers 一一对应且 ONNX Runtime Web 同时提供 wasm 与 WebGPU 两条后端device一个字段就能切——这在 WebGPU 刚普及的 2026 年很重要。三、核心原理一段音频在浏览器里经历了什么整条链路可以拆成五步每一步都有坑麦克风 getUserMedia → MediaRecorder 每 4 秒吐一个 Blobwebm/opus 压缩 → AudioContext.decodeAudioData 解码成 PCM 浮点数组 → 重采样到 16 kHz 单声道 → WhisperFeatureExtractor 算 80 维 mel 频谱 → encoder(4 层) → decoder(4 层) 自回归出 token → tokenizer 解码成文本3.1 采样率必须对齐 16 kHzpreprocessor_config.json里写死了sampling_rate: 16000、hop_length: 160、n_fft: 400、feature_size: 80。而AudioContext的采样率取决于声卡通常是 44100 或 48000。项目里这么处理// 解码 MediaRecorder 吐出的压缩 Blob拿回真实 PCMconstaudioBufawaitctx.decodeAudioData(buf);// 只取左声道Whisper 只吃单声道constsamplesaudioBuf.getChannelData(0);// 关键把「真实采样率」一起交给 pipeline让它内部决定要不要重采样constoutawaittranscriber({array:samples,sampling_rate:audioBuf.sampleRate},{language:whisperLang(),task:transcribe});这里的sampling_rate: audioBuf.sampleRate绝对不能写死 16000。实测三种采样率下的 mel 帧数差异AudioContext 采样率4 秒采样点正确重采样后 mel 帧若不重采样直接算 mel 帧偏差4410017640040011022.76 倍4800019200040012003.00 倍1600064000400400无如果撒谎说「我这音频是 16 kHz」特征抽取会按 hop160 在 48 kHz 数据上滑窗整段频谱被拉伸 3 倍音色变成慢放的怪物识别结果直接崩坏。3.2 mel 频谱与那个著名的 30 秒窗口Whisper 的 encoder 输入固定是 30 秒。这是 OpenAI 训练时定死的preprocessor_config.json里n_samples: 48000030 × 16000、nb_max_frames: 3000。帧数换算480000 / 160(hop) 3000 帧 melencoder 前面有两层卷积第二层 stride2所以 3000 帧 →1500 个 encoder 位置。// 等价实现从音频秒数推到 encoder 位置数functionframesOf(sec,sr16000){returnMath.floor((sec*sr)/160);}functionencPos(sec,sr16000){returnMath.floor(framesOf(sec,sr)/2);}实测不同时长对应的位置数音频时长采样点mel 帧encoder 位置占 30 秒窗口1 s16000100503.3%2 s320002001006.7%4 s6400040020013.3%10 s160000100050033.3%15 s240000150075050.0%30 s48000030001500100.0%记住 4 秒 200 这个数字下面第四个坑全靠它。3.3 语言参数BCP-47 标签要翻译成 Whisper 的全名Web Speech API 用zh-CN这种 BCP-47 标签Whisper 却要chinese这种英文全名两边对不上。项目里做了一层转换// 前端语言标签 - Whisper 语言全名functionwhisperLang(){constmap{zh-CN:chinese,zh-TW:chinese,en-US:english,ja-JP:japanese,ko-KR:korean,fr-FR:french,es-ES:spanish,de-DE:german};returnmap[els.langSelect.value]||chinese;// 兜底中文}界面选项BCP-47传给 Whisper说明中文普通话zh-CNchinese中文台灣zh-TWchineseWhisper 不区分简繁合并映射English (US)en-USenglish日本語ja-JPjapanese한국어ko-KRkoreanFrançaisfr-FRfrenchEspañoles-ESspanishDeutschde-DEgerman这里有个取舍要说明zh-TW也被映射成chinese因为 whisper-tiny 的多语言 tokenizer 不区分简体与繁体输出硬要区分反而会触发不支持的分支。兜底值给chinese而不是english是因为目标用户主要在中文场景。四、踩坑记录算力是怎么被吃掉的坑 14 秒分块被填充成 30 秒91% 算力在算静音项目里MediaRecorder每 4 秒产出一个片段追求「近似实时」的体验mediaRecorder.start(4000);// 每 4 秒产出一段近似实时但 pipeline 的chunk_length_s: 30意味着每段不足 30 秒的音频都会被右侧补零到 30 秒encoder 老老实实算满 1500 个位置。按 whisper-tiny 的配置d_model384、4 层 encoder、6 头、FFN 1536算 MAC 数// 每层每 tokenQKVO 投影 4d² FFN 2·d·ffnconstlinPerTokPerLayer4*384*3842*384*1536;// 1,769,472functionencMacs(L){constlinlinPerTokPerLayer*L*4;// 4 层线性constattn2*L*L*384*4;// QK^T 与 A·Vreturn{lin,attn,total:linattn};}实测结果场景encoder 位置线性 GMAC注意力 GMAC合计 GMAC填充到 30 s 窗口150010.626.9117.534 s 音频真实长度2001.420.121.54填充浪费 11.39 倍也就是 91.2% 的算力在算补出来的静音。而单块 17.81 GMAC 里 encoder 占了 98.4%——优化方向非常明确别动 decoder先救 encoder。坑 2stride_length_s: 5在 4 秒输入下完全不生效很多人以为配了stride_length_s就有滑窗重叠、上下文连贯。实际上 transformers.js 的切分逻辑是只有音频超过chunk_length_s时才按chunk - stride的步长滑窗。functionchunkPlan(sec,chunk30,stride5){constnSamplessec*16000;// 不足一个窗口 只有 1 个窗口stride 无从谈起if(nSampleschunk*16000)return{窗口数:1,重叠:0};conststep(chunk-stride)*16000;return{窗口数:Math.ceil((nSamples-chunk*16000)/step)1,重叠:stride};}实测音频时长窗口数stride 是否生效4 s1否10 s1否30 s1否45 s2是步长 25 s60 s3是步长 25 s120 s5是步长 25 s4 秒分块下stride_length_s: 5就是一个纯装饰参数一个字节的重叠都没产生。坑 3分块越小越亏60 秒音频差 6.8 倍把上面的账算到「转录 60 秒语音」这个真实任务上分块大小块数encoder GMACdecoder GMAC合计 GMAC相对 4 s 块4 s15262.934.22267.161.00x5 s12210.354.23214.570.80x10 s6105.174.24109.410.41x15 s470.124.2574.360.28x30 s235.064.2839.340.15x4 秒分块比 30 秒分块多算 6.79 倍。注意 decoder 那一列几乎不动4.22 → 4.28全部差距来自 encoder 的重复填充。代价是延迟30 秒分块意味着用户说完要等 30 秒才出第一句。工程上折中选 10 秒——算力降到 0.41x延迟还能接受。坑 4每块独立编码跨块上下文完全断裂这比算力更致命。每个 4 秒块都是一次全新推理独立算 mel、独立过 encoder、decoder 从头开始自回归。Whisper 看不到上一块说了什么。后果是块边界上的词被硬切开而且 Whisper 在只有 4 秒孤立音频、尾部被大量静音包围时非常容易「幻听」出训练语料里的常见句。项目里这段队列处理是串行且无重叠的asyncfunctionpumpQueue(){if(processing)return;// 单飞同一时刻只处理一块背压靠队列堆积processingtrue;try{while(chunkQueue.length){constblobchunkQueue.shift();constbufawaitblob.arrayBuffer();constaudioBufawaitctx.decodeAudioData(buf);// 每块一次完整推理前一块的输出不会作为下一块的 promptconstoutawaittranscriber({array:audioBuf.getChannelData(0),sampling_rate:audioBuf.sampleRate},{language:whisperLang(),task:transcribe});finals.push(out.text);render();}}finally{processingfalse;}}缓解办法有三条按性价比排序改成 10-15 秒分块边界数量直接砍掉 60%-70%保留上一块末尾文本作为下一块的prompt_idsWhisper 支持条件前缀加 Silero VAD只在检测到语音时切片静音段根本不送进模型坑 5device: wasm在 2026 年已经是次优解项目里写死了 WASM 后端transcriberawaitpipeline(automatic-speech-recognition,Xenova/whisper-tiny,{chunk_length_s:30,stride_length_s:5,dtype:q8,// int8 量化压体积提速度device:wasm,// 2026 年应优先考虑 webgpuprogress_callback:(p){/* 显示加载进度百分比 */}});WASM 走 CPUWASM 推理通常比 WebGPU 慢一个数量级。既然 encoder 占了 98.4% 的算力把它挪到 GPU 的收益远大于纠结 decoder。稳妥写法是特性探测后降级// 先探测 WebGPU拿不到适配器就退回 WASMasyncfunctionpickDevice(){if(!navigator.gpu)returnwasm;try{return(awaitnavigator.gpu.requestAdapter())?webgpu:wasm;}catch{returnwasm;}}constdeviceawaitpickDevice();换后端到底值不值把「达到实时算完 x 秒音频不超过 x 秒需要的有效算力」算出来就很直观分块单块 GMAC单块 GFLOP实时所需算力4 s17.8135.628.91 GFLOP/s10 s18.2336.473.65 GFLOP/s15 s18.5937.182.48 GFLOP/s30 s19.6739.341.31 GFLOP/sWASM 后端跑在 CPU 上能稳定给出的浮点算力有限4 秒分块要求 8.91 GFLOP/s 基本压不到实时而 WebGPU 把这块搬到 GPU 后有 3-10 倍提速空间。先改分块把门槛从 8.91 降到 3.65再换后端拿到 3-10 倍两步叠加才是完整解法——只换后端不改分块等于让 GPU 去算 91% 的静音。五、量化与体积这笔账dtype: q8也不是白来的。实测models/目录下 131.4 MB其中 ONNX 权重 127.2 MB占 96.8%文件体积 (MB)说明encoder_model.onnx (fp32)31.38未量化体积 3.25 倍encoder_model_quantized.onnx (q8)9.66实际加载decoder_model_quantized.onnx29.05decoder_model_merged_quantized.onnx29.30decoder_with_past_model_quantized.onnx27.88KV cache 复用版q8 让 encoder 从 31.38 MB 压到 9.66 MB省 21.72 MB。如果你想进一步压业界常见做法是混合精度encoder 保持 fp32特征质量敏感decoder 用 q4体积占大头且容错高。按config.json推算 whisper-tiny 参数量encoder 4 层 707.8 万 decoder 4 层 943.7 万 token 嵌入 1991.6 万 卷积 53.5 万 位置嵌入 57.6 万 ≈3754 万与官方标称的 39 M 基本吻合。六、还有两个容易忽略的工程细节模型必须走本地路径。项目用download_model.py从hf-mirror.com镜像把权重拉到models/运行时只从 localhost 加载env.allowLocalModelstrue;env.localModelPath./models/;// 优先本地env.remoteHost镜像域名;// 项目填的是 hf-mirror本地缺失时兜底env.allowRemoteModelstrue;不加这段浏览器会直连 huggingface.co国内基本加载不出来。有个坑是env赋值必须在pipeline()调用之前完成写在后面不生效。必须用 https 或 localhost。file://协议下getUserMedia直接不可用项目在启动时会做环境检查并禁用开始按钮避免出现「点了没反应」的玄学问题。七、小结浏览器端 ASR 在 2026 年已经是能落地的方案但「能跑」和「跑得好」之间隔着分块策略这个大坑。三个最值钱的结论encoder 占了单块 98.4% 的算力优化只盯它就够了分块越碎越亏4 秒分块比 30 秒分块多算 6.79 倍10 秒是延迟与算力的平衡点chunk_length_s以下一律不触发滑窗stride_length_s在短音频上是摆设下一步打算接入 Silero VAD 做语音端点检测让静音段根本不进模型再把device换成 WebGPU 后端实测一波提速。完整工程已整理好含一键下载模型的脚本 本地部署文档需要的同学评论区扣「源码」我看到会一一回复也欢迎关注我后续会把浏览器端 AI 系列继续更下去。