原理与复刻指南

把一个「豆包输入法 PC 版的语音识别通道」拆开看清楚,再手把手教你把同样的调用写进自己的代码里。
所有结论都是本轮实测得来(含反编译证据、抓到的原始协议、以及跑通的三种语言示例)。

0. 一分钟看懂

豆包输入法打字时用的「语音输入」,本质是客户端把麦克风音频发到字节的流式识别服务。它不需要登录、也没有对外公开 API,但它的凭据是按设备匿名下发的,所以我们可以自己复刻整条链路:

你的程序 ──①注册匿名设备──▶ log.snssdk.com 得到 device_id ──②用 device_id 换 key─▶ is.snssdk.com/settings 得到 asr_config.app_key ──③连 WebSocket────────▶ frontier-audio-ime-ws.doubao.com │ StartTask → StartSession │ TaskRequest(16k PCM / 20ms 一帧) │ FinishSession ◀──── ④识别结果(累积文本 + 逐字时间)──────────────────┘
能不能直接用?能,实测在 Linux 服务器上跑通了中文、英文、中英混说。 但它是未公开的内部接口:凭据按设备下发、随时可能变更或加风控,请只用于技术研究与自用,别拿去做对外产品(想产品化用火山引擎官方接口或本地开源模型)。

1. 豆包输入法是怎么被借用的

1.1 下载地址与安装包

官网 PC 页是单页应用,真实下载地址由一个接口给出(这是唯一官方渠道,别用第三方下载站):

# 官网页面
https://shurufa.doubao.com/pc

# 下载信息接口(platform 可选 windows / macos / android / ios / harmony)
curl 'https://shurufa.doubao.com/api/v1/app/download_url?platform=windows'
# {"code":0,"data":{"release_date":"2026.10.10",
#   "url":"https://lf-wave.doubaocdn.com/obj/doubao-ime/app/windows/DoubaoIME_Installer_0.9.2.7_release.exe",
#   "version_code":90207,"version_name":"V0.9.2"},"msg":"success"}
平台版本包
WindowsV0.9.2 (0.9.2.7, 2026-10-10) DoubaoIME_Installer_0.9.2.7_release.exe,123,961,912 字节
macOSV1.0.2DoubaoImeInstaller_v1000207_release_…zip,193,367,502 字节(内含 174MB 的 DoubaoIme.zip)
Androidv1.5.1doubaoime_v1.5.1_100501006_official_arm64_release.apk,170,975,042 字节

Windows 包校验(本次实测):

SHA256  d777027cc822a67dac04ba502616f6e6216a54c06681e21f8f45ef0a89cec5db
file    PE32 executable (GUI) Intel 80386, for MS Windows  # 32 位安装器

怎么把它解开

安装器是 Inno Setup 6.7.0:innoextract 1.9(官方最新版)和 master 分支(支持到 6.4.0.1)都读不了它。 可行的办法是用 Wine 32 位跑静默安装,直接把文件树取出来:

# 1) 装 32 位 wine 和虚拟显示(安装器需要窗口,无 X 会报 Invalid window handle)
dpkg --add-architecture i386 && apt-get update
apt-get install -y --no-install-recommends wine32:i386 xvfb

# 2) 静默安装到 wine 前缀(安装过程会因 TSF 注册失败回滚目录,但文件已经落盘)
export WINEPREFIX=/root/doubao-ime/wineprefix WINEARCH=win32
xvfb-run -a /usr/lib/wine/wine DoubaoIME_Installer_0.9.2.7_release.exe \
    /VERYSILENT /SUPPRESSMSGBOXES /NORESTART /LOG='C:\inno.log'

# 3) 文件就在(注意必须用 /usr/lib/wine/wine,/usr/local/bin/wine 是 wine64 跑不了 32 位)
ls "$WINEPREFIX/drive_c/Program Files/DoubaoIME/versions/v0.9.2.7/"

1.2 包结构:哪部分负责语音识别

装出来的目录(Program Files\DoubaoIME\versions\v0.9.2.7\,约 267 MB):

文件大小职责
ImeService.exe25 MB 服务进程:麦克风采集、驱动语音会话(SamiAsrClientWin)、AI 联想、上下文构造、日志上报。 导入 audioeffect-mt.dll / onnxruntime.dll / ui.dll / oime-config.dll
audioeffect-mt.dll2.9 MB 语音识别的核心在这里——字节 SAMI 语音 SDK:Opus 编解码、WebSocket/Cronet 传输、 protobuf 协议、frontier 连接池、token 处理、在线/整段两种识别模式的调度
tsf-oime.dll / tsf-oime-core.dll2.2 MB TSF 文本服务(输入法本体):组合串、语音上屏、选中文字后用语音改写(voice_modify_*)
ui.dll5 MB 输入法界面(候选窗、语音波形窗口)
onnxruntime.dll10 MB 本地 ONNX 推理:手写识别 CRNN、候选打分 MLP(不参与语音识别)
sscronet.dll9.9 MB Cronet/TTNet 网络栈(HTTP/2、QUIC、DNS 调度),SAMI 的传输层
ime_net_sdk.dll7 MB Rust 写的网络 SDK(QUIC、网关通信),内含客户端证书 CN=ImeNetSDK
rpc.dll / doubaoime-rpc-new.dll3.3 MB进程间 RPC(TSF DLL ↔ ImeService)
applogrs.dll / parfait*.dll—埋点统计 / 崩溃上报
DoubaoIme.Settings.*—.NET WPF 设置界面
files/data/dict/*约 170 MB 词库与模型,文件名是内容哈希(如 c48352298dbd = 主词库 46 MB); 其中 asr/ 是识别用的文本后处理词表(n-gram / 纠错 / 豁免词),不是声学模型
一句话结论:语音识别 = ImeService.exe(采集+调度) + audioeffect-mt.dll(SAMI 协议栈), 真正的声学模型在字节服务端。安装包里没有任何声学模型文件(这点很关键,见 1.6); 但这些模型能从公开渠道拿到,见 1.7。

从编译期路径能看出的源码结构

二进制里残留了开发机的源码路径,等于给了目录树:

模块路径线索(来自 audioeffect-mt.dll)
SAMI 语音 SDKC:\19626\sami_core\sami_interface\src\capi\speech_v2\asr\online\* (asr_on_worker / asr_on_callback / protocol / send_queue)
SAMI 传输层common_util\net\{frontier,connect_pool_v2,nqe,transport,multi_net}\*、 common_util\protobuf\WebSocket{Request,Response}.pb.cc
输入法服务端C:\Workfolder\oime\obric-ime5\src\server\* (sami_asr_client_win.cpp、asr_context_builder.cpp、voice_key_hook.cpp、 asr_modify_pair_uploader.cpp)
输入法引擎 / 界面obric-ime5\src\{shell,ime_ui,common,ttnet}\*(引擎代号 obric)

1.3 识别协议

一条 WebSocket 长连接,二进制 protobuf 帧,四个步骤:

连接 wss://frontier-audio-ime-ws.doubao.com/ocean/api/v1/ws?aid=685343&device_id=<did> 头部 User-Agent: com.bytedance.android.doubaoime/... ← 伪装成官方客户端 proto-version: v2 ← 关键!没有它就回 JSON 而不是 protobuf x-custom-keepalive: true ① StartTask { token=app_key, service=ASR } → TaskStarted ② StartSession { payload=会话参数 JSON } → SessionStarted ③ TaskRequest ×N { audio=20ms PCM, frame_state=1/3/9 } → 结果 JSON(累积文本) ④ FinishSession → SessionFinished

请求字段(实测得到的字段号)

字段号类型含义值
2stringtoken第 ② 步拿到的 asr_config.app_key
3stringservice_nameASR
5stringmethod_nameStartTask / StartSession / TaskRequest / FinishSession
6stringpayloadJSON 字符串(会话参数 / 每帧的时间戳)
7bytesaudio_data16k/单声道/PCM16,20 ms = 640 字节
8stringrequest_id自己生成的 UUID,用于对账
9varintframe_state首帧 1、中间 3、末帧 9

StartSession 的 payload

{
  "audio_info": { "channel": 1, "format": "raw", "sample_rate": 16000 },
  "enable_punctuation": true,
  "enable_speech_rejection": false,
  "extra": {
    "did": "<device_id>",
    "input_mode": "tool",
    "strong_ddc": true,            // 强纠错
    "use_twopass_retry": true,     // 二遍重试
    "enable_asr_twopass": true,    // 流式 + 整段两遍解码
    "enable_asr_threepass": true,  // 三遍
    "disable_user_words": false,   // 允许个人词库生效
    "cell_compress_rate": 8,
    "context": "<base64>"          // 可选:chat + ime_info + hotwords(热词偏置)
  }
}
音频格式:format 除了 raw(16k PCM16,最省事)也支持 speech_opus(官方客户端用的 Opus)。 用 raw 就不必自己实现 Opus 编码,实测同样能识别。

响应字段

字段号含义
2task_id
4事件名:TaskStarted / SessionStarted / SessionFinished / TaskFailed / SessionFailed
5状态码(见下表)
6状态文本
7结果 JSON(识别文本、时间、是否定稿)

结果 JSON(字段 7 的内容)

{
  "results": [{
    "text": "你好,这是语音识别测试",      // 本次发言的累积文本,最后一条即完整结果
    "start_time": 1.33, "end_time": 3.4,  // 秒,相对本次会话起点
    "confidence": 0.998,
    "is_interim": false,                  // false = 已定稿
    "is_vad_finished": true,              // VAD 判定这一句说完了
    "index": 0,
    "alternatives": [{ "text": "…", "words": [
        { "word": "你", "start_time": 0, "end_time": 0.34 }, …   // 逐字时间
    ]}]
  }]
}
两个实测坑: ① 短语气词会被反复改字(唉→哎→唉),三条消息 start_time 完全相同、只有 end_time 在涨——判断"新的一段"必须看起点,不能只看文本; ② alternatives[].words[].end_time 偏大(带前瞻窗口),做字幕要用"下一句的首字时间 / 整段 end_time"校正。

1.4 状态码与错误处理

状态码含义怎么处理
20000000OK—
50700000service discovery failure(后端实例发现失败) 上游随机抖动,实测同 payload 约 1/5 概率:重试整段即可(示例代码里带 3~4 次重试)
40200011并发配额满(共享 app_key 限流)排队/降并发,重试无用
40200001鉴权失败:IllegalToken / session auth, userID or appID is emptyapp_key 失效 → 重新走第 ② 步换 key
40100003StartTaskExpected顺序错了:必须先 StartTask
40000022IllegalPayloadpayload 必须是字符串(JSON 文本),不是嵌套对象
40000000app key not found in header or query鉴权信息没带上

1.5 语言能力(实测)

输入结果结论
普通话「你好,这是微信语音接口测试,今天阳光很好。」支持
英语(JFK 演讲)「And so my fellow Americans, ask not what your country can do for you, ask what you can do for your country.」支持(标点大小写都对)
中文一句 + 英语一句(同一会话)两句各自按语言正确输出中英混输
日语「こんにちは」「国内计划。」不支持
日语「私は日本語を勉強しています」「马达西」+「我倒希望你哄我。」不支持(声学层听到了,但语言模型只有中文)

原因是这个模型是 zh + en 双语(即官方宣传的"中英混输"),客户端创建识别句柄时写死的是 speech_opus + zh; 协议里没有 language 字段,所以没有"切语种"的开关。要用日语只能换官方多语种接口或在本地跑开源日语模型。

1.6 豆包的离线识别是怎么做的

这是本轮最有价值的发现。结论先给:离线识别 = 字节自研端侧推理引擎 Flute + 联网下载的模型包, 而且 Windows 0.9.2.7 里还没有这套东西(Android / macOS 有)。

层证据Windows 0.9.2.7macOS 1.0.2Android 1.5.1
端侧推理引擎 Flute flute/engine/engine.cc、flute_c_api.cc、flute/framework/*、 flute/base/filesystem_mmap/filesystem_packed_mmap.cc(打包 mmap 模型容器) 无主程序内libaudioeffect.so
ASR 模型结构 flute/module/asr/ntp_cif/{encoder_actor,cif_calculator,beam_searcher,decoder_context/prompt_processor}、 feature/{mel_bank,panther,subband}_feature_extractor、module/vad/v4、module/signal/aec 无有有
SAMI 的本地 worker speech_v2/asr/offline/{asr_off_singleton,asr_off_worker}.cpp、capi/flute/asr/flute_asr.cpp 有调度、无引擎有有
模型下载管理 应用侧 OimeOfflineModel 模块:ASROfflineModel{Manager,FileStore,PathResolver,MetaInfoService,Manifest}、 通知 start/pause/resume/retry/requestDelete/updateOfflineModelState、 日志 ASR offline model path:、缺模型弹窗 ASROfflineModelUnavailablePromptWindowController 无有(APK 内未打包模型)

模型长什么样

macOS 主程序里写死了模型的张量文件名(93 个 .bin/.dat/json),可以直接读出结构 —— 是 PANTHER Conformer 编码器 + LLM 风格解码器,CIF(连续积分激发)机制,int8 量化:

s/panther_conformer_*                     编码器
s/panther_decoder_L*                      解码器
s/panther_l0_{qkv_pre_rope,mha_raw,ffn1_gemm1,ffn2_swiglu,rmsnorm1,rmsnorm2,conv_out}.bin
up_proj.bin / down_proj.bin / v_proj.bin / swiglu.bin / o_proj ...   权重张量
*_shape.dat / input_shape.dat / config.json / token_config.json / llm_params.json

算力后端是 CPU int8(源码里有 PackBS8 format: 4x4 tiled layout matching CPU prepacked weights) 加 GPU kernel(macOS 用 Metal,源码里带 device const char* 的着色器)。体量对得上"离线模型包约 150 MB"的说法。

在线 / 离线怎么协同(SAMI 里的融合策略)

为什么不能直接拿客户端里的那份用: ① 模型是私有逐张量格式(SAMI 容器,载荷加密,熵 ≈ 8.0),引擎里写死了张量名; ② Flute 静态编进客户端(macOS 主程序 / Android 的 libaudioeffect.so),Windows 包里根本没有; ③ 客户端要的模型名是加密的(name_sec),扒不出它到底用哪个。
但这不等于拿不到可用的模型——字节的算法分发接口是公开的,里面有同架构的明文 ONNX,见 1.7。

1.7 离线模型能从公开渠道拿到(本项目已内置离线引擎)

本以为“只有私有引擎能吃这些模型”,结果发现字节把一批模型放在公开算法分发接口上,无需任何鉴权:

GET https://effect.snssdk.com/model/api/arithmetics?sdk_version=25.0.0
→ {"status_code":0,"data":{"arithmetics":{"cakeCook":[
     {"name":"lv_asr_support","version":"1.2",
      "file_url":{"url_list":["https://n98-lf-effect-aweme-302-ncdn.byteeffecttos.com/obj/ies.fe.effect/<md5>?module=model"],
                   "zip_url_list":["..."]}}]}}}

# 参数必须是小写 sdk_version(写 SdkVersion 会报 missing required parameter),只能 GET(POST 404)
# sdk_version 就是“面板版本”,值越大条目越多:1.0.2→3、15.0.0→875、25.0.0→1128(上限)

这条链路是怎么找到的(可复现)

  1. macOS 客户端主程序里有 ASROfflineModelMetaInfoService.swift、ASROfflineModelTTNetRequest (TTNet 是字节的网络库),顺着字符串邻域找到两个 TTNet 服务名 com.wave.offline-model.metainfo-service / com.wave.offline-model.metainfo-head;
  2. 同一片邻域里出现域名 https://effect.snssdk.com 与路径 /model/api/arithmetics (还有解析字段 fileDownloadURLs、installRelativePath、componentVersionKey);
  3. 裸调该路径,服务端直接报 params check error: expr_path=SdkVersion, cause=missing required parameter—— 按提示试出参数名必须是小写 sdk_version(写成 SdkVersion 反而一直报缺参数),POST 一律 404(只收 GET);
  4. 把 sdk_version 调大就能看出它是“面板版本”:1.0.2→3 个、15.0.0→875 个、25.0.0→1128 个(上限)。

经验:这类接口的报错比文档好用——服务端报什么字段名,就原样试什么字段名。

同一批物料里有两种封装,能力完全不同

例子封装能不能直接用
lv_asr_support(307 MB) zip → 明文 ONNX(encoder / decoder / classifier / punc + 词表 + ITN FST) 能 onnxruntime 直接跑,本项目用的就是它
lv_grow_voice_local_asr(297 MB)
lv_speech_asr_mobile(343 MB)
7z / 裸文件 → 单个 *.model 不能 SAMI 私有容器:31 字节头 + 熵 ≈ 8.0 的加密正文,要闭源 Flute 引擎
mobile_asr_transformer_ctc_chn(3.9 MB) 裸文件,魔数直接就是 SAMI 不能 同上

同批还有 lv_asr_cut_mobile、online_mobile_asr_transformer_pipeline(_douyin)、 KWS 唤醒词(kws_chn_hanzi 等)、unet_denoise_44k_speech 降噪等, --list 一共能列出 24 个语音相关模型。挑法很简单:看 zip 里有没有 .onnx—— 拉取脚本会自动判断,遇到私有容器会明确告诉你“这个用不了”。

我们要的这套(lv_asr_support,解开就是完整的 ASR 流水线):

文件大小作用
asr-model-encoder.onnx101 MBspeech[1,T,560] → enc[1,T,512]
asr-model-decoder.onnx60 MBenc → logits[1,L,8404] + us_cif_peak(CIF 架构)
asr-model-classifier.onnx10.5 MB质量分(Dispatcher 用,strategy.json 里 quality_score: 0.83)
asr-punc.onnx + asr-punc-token.txt180 MB / 2.3 MB标点(3 类,词表是竖线分隔的 27 万条)
asr-model-token.txt75 KB8404 个 token(<blank> 0 空格分隔)
asr-itn-fst.fst540 KB逆文本归一化 FST

用 onnxruntime 打开就能读出全部输入输出——这是判断“能不能自己跑”的依据:

模型输入输出
encoderspeech[1,T,560] f32、speech_lengths[1] i32 enc[1,T,512]、enc_len
decoderenc、enc_len logits[1,L,8404]、token_num、us_alphas、us_cif_peak
classifierenc、enc_lenquality_scores[1,1]
puncinputs[1,T] i32(字符 id)、text_lengthslogits[1,L,3]

us_cif_peak 是 CIF 的峰值序列,同时是“何时吐出一个字”的判据——和 Flute 里 ntp_cif/cif_calculator 是同一套思路。而 strategy.json 漏出了产品侧策略:

{ "dispatcher": { "ratio": 0.33, "max_counts": 5, "quality_score": 0.83 },
  "vad_strategy": { "max_slice_duration": 30, "min_slice_duration": 10 },
  "sentence_config": { "max_time_interval_ms": 300, "split_size": 16 } }

意思是:原始产品把长音频切成 10~30s 片,同一片可能用不同切点跑最多 5 次,再用 classifier 的质量分 (阈值 0.83)挑最好的一份——这也解释了包里为什么会带一个“质量分模型”。本项目简化成 停顿断句 + 单次解码 + 质量分只做展示(存在每句话的 quality 里,导出 JSON 能看到)。

输入 560 = 80 维 mel × 7(LFR m=7,n=6),和 FunASR Paraformer 同构;us_cif_peak 说明是 CIF / ntp_cif(和豆包输入法 Flute 里的模块同源),只是这里是明文 ONNX,onnxruntime 直接能跑。

实测(本机 CPU,纯 numpy 前端 + onnxruntime): 「你好,这是微信语音接口测试,今天阳光很好。」 → 你好这是微信语音接口测试今天阳光很好(全对); 「现在进行第二次接口验证,苹果香蕉橘子。」 → 现在进行第二次接口验证苹果香蕉橘子(全对); 中英混说中文全对、英文大体对;日语是乱码(中文模型)。6 秒音频 encoder 0.49s + decoder 0.32s, 约 0.13× 实时。
# 一条命令把模型拉齐(约 310MB,走公开 CDN)
./tools/fetch_offline_asr_models.sh            # 默认取 lv_asr_support
./tools/fetch_offline_asr_models.sh --list     # 先看看有哪些语音模型

前端特征怎么对上(最容易翻车的一步)

PCM16 / 16k / 单声道
  → 80 维 log-mel:25ms 窗、10ms 步长、HTK mel(1127·ln(1+f/700))、取 log
  → LFR(7,6):连续 7 帧拼成 1 帧、步长 6 → 560 维(80 × 7 = 560)
  → 逐句归一化:(x − mean) / (std + 1e−10)          ← 逐句 CMVN
  → encoder → decoder → argmax → 8404 词表拼字(跳过 id 0/1/2,去掉 @@ 后缀)
说清楚:归一化那一步是试出来的——包里没带 CMVN 统计量,直接喂原始 log-mel 会出现 重复字(「…接口测试你好今天阳光很好」),换成逐句均值方差归一化后中文才全对。 也就是说这套前端的正确性只在这几个测试音频上验证过,换模型版本或换语种需要重新校。
边界与提醒: ① 这批模型是字节 Effect 算法分发里的公开物料,不是豆包输入法客户端自己那份 (客户端要的模型名是加密的 name_sec,扒不出它选的是哪个),只是架构同源、能力够用; ② 它属于厂商的模型资产,本项目仅用于技术研究与自用演示——别商用、别二次分发模型文件; ③ 后处理(ITN、热词、标点的细节)都有自己的模型/资源,本项目只接了标点。

2. 凭据从哪来(为什么不用登录)

豆包输入法没有任何账号体系参与语音识别,身份是「应用身份 + 设备身份」:

① POST https://log.snssdk.com/service/2/device_register/ body: {magic_tag:"ss_app_log", header:{aid:685343, app_name:"oime", cdid/openudid/clientudid, …}} resp: {device_id, install_id, device_token} ← 匿名注册一台设备(头条系通用接口) ② POST https://is.snssdk.com/service/settings/v3/?device_id=<did>&cdid=<cdid> header: x-ss-stub = md5("body=null").upper() resp: data.settings.asr_config.app_key = "RTIHIRzbwS" ← ASR 用的 token,按设备下发 应用级 aid(编译进客户端)+ 设备级 app_key → 服务端据此计量/风控,不需要用户账号
要素值 / 说明
aid685343(取自 ImeService.exe)
User-Agentcom.bytedance.android.doubaoime/100102018 (Linux; U; Android 16; en_US; Pixel 7 Pro; Build/BP2A.250605.031.A2)
app_key按设备/时间下发(同一设备多次调用会拿到稳定的值,换设备就变),建议缓存 12 小时
为什么不需要登录身份建立在"应用 + 设备"上;这也是头条系 App 的通用做法(安装即匿名注册)
你的程序里不要暴露 app_key 给前端(它是设备级凭据),网页端一律走服务端中转。

3. 自己写客户端

下面三份代码都是完整可运行的单文件客户端:自己注册设备、拿 app_key、跑完一次识别并打印文本。 复制走改一改就能用(asr_min.py / asr_min.js / asr_min.go 也可直接下载)。

Python

pip install websockets
python asr_min.py test.wav        # 输出:你好,这是微信语音接口测试,今天阳光很好。
#!/usr/bin/env python3
"""豆包输入法语音识别 · 最小可用客户端(Python,单文件)

原理:客户端内置 aid + 匿名设备注册 → 服务端下发 ASR app_key → 用它连 WebSocket,
      按 20ms 一帧送 16k/单声道/PCM16,边送边收识别结果。

用法: python3 asr_min.py <16k-mono-16bit.wav>
依赖: pip install websockets
"""
import asyncio
import hashlib
import json
import secrets
import sys
import time
import urllib.parse
import urllib.request
import uuid
import wave

import websockets

AID = 685343                     # 豆包输入法的 app id(取自客户端 ImeService.exe)
APP_NAME = "oime"
VERSION_CODE = 100102018
VERSION_NAME = "1.1.2"
UA = ("com.bytedance.android.doubaoime/100102018 (Linux; U; Android 16; en_US; "
      "Pixel 7 Pro; Build/BP2A.250605.031.A2)")
REGISTER_URL = "https://log.snssdk.com/service/2/device_register/"   # 头条系匿名设备注册
SETTINGS_URL = "https://is.snssdk.com/service/settings/v3/"          # 下发 asr_config.app_key
WS_URL = "wss://frontier-audio-ime-ws.doubao.com/ocean/api/v1/ws"

SAMPLE_RATE = 16000
FRAME_BYTES = 640                # 20ms × 16kHz × 2字节 = 640


# ----------------------------------------------------------------- 1. 拿凭据

def _post(url, params, body, headers):
    req = urllib.request.Request(url + "?" + urllib.parse.urlencode(params), data=body, method="POST")
    for key, value in headers.items():
        req.add_header(key, value)
    with urllib.request.urlopen(req, timeout=20) as resp:
        return json.loads(resp.read().decode())


def get_credentials():
    """返回 (device_id, app_key)。全过程匿名,不需要登录。"""
    cdid, openudid, clientudid = str(uuid.uuid4()), secrets.token_hex(8), str(uuid.uuid4())
    header = {
        "device_id": 0, "install_id": 0, "aid": AID, "app_name": APP_NAME,
        "version_code": VERSION_CODE, "version_name": VERSION_NAME,
        "manifest_version_code": VERSION_CODE, "update_version_code": VERSION_CODE,
        "channel": "official", "device_platform": "android", "os": "android", "os_api": "34",
        "os_version": "16", "device_type": "Pixel 7 Pro", "device_brand": "google",
        "device_model": "Pixel 7 Pro", "resolution": "1080*2400", "dpi": "420",
        "language": "zh", "timezone": 8, "access": "wifi", "region": "CN",
        "tz_name": "Asia/Shanghai", "tz_offset": 28800, "openudid": openudid,
        "clientudid": clientudid, "cdid": cdid,
    }
    body = json.dumps({"magic_tag": "ss_app_log", "header": header,
                       "_gen_time": int(time.time() * 1000)}).encode()
    params = {
        "device_platform": "android", "os": "android", "ssmix": "a",
        "_rticket": str(int(time.time() * 1000)), "cdid": cdid, "channel": "official",
        "aid": str(AID), "app_name": APP_NAME, "version_code": str(VERSION_CODE),
        "version_name": VERSION_NAME, "manifest_version_code": str(VERSION_CODE),
        "update_version_code": str(VERSION_CODE), "resolution": "1080*2400", "dpi": "420",
        "device_type": "Pixel 7 Pro", "device_brand": "google", "language": "zh",
        "os_api": "34", "os_version": "16", "ac": "wifi",
    }
    out = _post(REGISTER_URL, params, body,
                {"Content-Type": "application/json", "User-Agent": UA})
    device_id = str(out.get("device_id_str") or out.get("device_id") or "")
    if not device_id or device_id == "0":
        raise RuntimeError(f"设备注册失败: {out}")

    stub_body = b"body=null"
    params = {
        "device_platform": "android", "os": "android", "ssmix": "a", "channel": "official",
        "aid": str(AID), "app_name": APP_NAME, "version_code": str(VERSION_CODE),
        "version_name": VERSION_NAME, "device_id": device_id, "cdid": cdid,
        "_rticket": str(int(time.time() * 1000)),
    }
    out = _post(SETTINGS_URL, params, stub_body, {
        "User-Agent": UA,
        "x-ss-stub": hashlib.md5(stub_body).hexdigest().upper(),
        "Content-Type": "application/x-www-form-urlencoded",
    })
    app_key = (out.get("data", {}).get("settings", {}).get("asr_config", {}) or {}).get("app_key")
    if not app_key:
        raise RuntimeError(f"settings 没给 app_key: {out}")
    return device_id, app_key


# ----------------------------------------------------------------- 2. protobuf 帧

def _varint(value):
    out = bytearray()
    while True:
        byte = value & 0x7F
        value >>= 7
        out.append(byte | 0x80 if value else byte)
        if not value:
            return bytes(out)


def encode(token="", method="", payload="", audio=b"", request_id="", state=0):
    """上游请求帧的字段号(实测得到):
       2=token(app_key) 3=service_name 5=method 6=payload 7=audio 8=request_id 9=frame_state
    """
    body = bytearray()
    for num, val in ((2, token), (3, "ASR"), (5, method), (6, payload)):
        if val:
            raw = val.encode()
            body += _varint(num << 3 | 2) + _varint(len(raw)) + raw
    if audio:
        body += _varint(7 << 3 | 2) + _varint(len(audio)) + audio
    if request_id:
        raw = request_id.encode()
        body += _varint(8 << 3 | 2) + _varint(len(raw)) + raw
    if state:
        body += _varint(9 << 3) + _varint(state)
    return bytes(body)


def decode(data):
    """响应帧 → {字段号: [值]}。2=task_id 4=消息类型 5=状态码 6=状态文本 7=结果 JSON"""
    out, off = {}, 0

    def read_varint(buf, pos):
        val = shift = 0
        while True:
            byte = buf[pos]
            pos += 1
            val |= (byte & 0x7F) << shift
            shift += 7
            if not byte & 0x80:
                return val, pos

    while off < len(data):
        tag, off = read_varint(data, off)
        field, wire = tag >> 3, tag & 7
        if wire == 0:
            val, off = read_varint(data, off)
            out.setdefault(field, []).append(val)
        elif wire == 2:
            length, off = read_varint(data, off)
            out.setdefault(field, []).append(data[off:off + length])
            off += length
        elif wire == 1:
            off += 8
        elif wire == 5:
            off += 4
        else:
            break
    return out


def text_of(msg, field):
    val = msg.get(field)
    if not val:
        return None
    return val[0].decode("utf-8", "replace") if isinstance(val[0], bytes) else val[0]


# ----------------------------------------------------------------- 3. 跑一次识别

def is_retryable(err):
    """上游会偶发 50700000 service discovery failure(实测约 1/5 概率),重试即可。"""
    msg = str(err)
    return "service discovery" in msg or any(f"[{code}" in msg for code in range(50000000, 60000000))


async def transcribe(path, retries=4):
    """带重试的一次识别:上游抖动 / 连接断开都重试整段。"""
    last_error = None
    for attempt in range(retries):
        try:
            return await _transcribe_once(path)
        except Exception as exc:                     # noqa: BLE001
            last_error = exc
            if not is_retryable(exc) or attempt == retries - 1:
                raise
            print(f"  [重试 {attempt + 1}/{retries - 1}] {exc}", file=sys.stderr)
            await asyncio.sleep(0.6)
    raise last_error


async def _transcribe_once(path):
    with wave.open(path, "rb") as wav:
        assert (wav.getnchannels(), wav.getsampwidth(), wav.getframerate()) == (1, 2, 16000), \
            "需要 16kHz / 单声道 / 16bit 的 WAV(可用 ffmpeg -ar 16000 -ac 1 转换)"
        pcm = wav.readframes(wav.getnframes())

    device_id, app_key = get_credentials()
    url = f"{WS_URL}?aid={AID}&device_id={device_id}"
    headers = {"User-Agent": UA, "proto-version": "v2", "x-custom-keepalive": "true"}
    request_id = str(uuid.uuid4())
    text, done = "", False

    async with websockets.connect(url, additional_headers=headers,
                                  open_timeout=15, max_size=None) as ws:
        # ① StartTask:建任务
        await ws.send(encode(app_key, "StartTask", request_id=request_id))
        msg = decode(await ws.recv())
        if text_of(msg, 4) != "TaskStarted":
            raise RuntimeError(f"StartTask 失败: {text_of(msg, 4)} {msg.get(5)} {text_of(msg, 6)}")

        # ② StartSession:声明音频格式 + 识别参数
        session_payload = json.dumps({
            "audio_info": {"channel": 1, "format": "raw", "sample_rate": SAMPLE_RATE},
            "enable_punctuation": True,
            "enable_speech_rejection": False,
            "extra": {
                "did": device_id, "input_mode": "tool",
                "strong_ddc": True, "use_twopass_retry": True,
                "enable_asr_twopass": True, "enable_asr_threepass": True,
                "disable_user_words": False, "cell_compress_rate": 8,
            },
        }, ensure_ascii=False)
        await ws.send(encode(app_key, "StartSession", payload=session_payload, request_id=request_id))
        msg = decode(await ws.recv())
        if text_of(msg, 4) != "SessionStarted":
            raise RuntimeError(f"StartSession 失败: {text_of(msg, 4)} {msg.get(5)} {text_of(msg, 6)}")

        # ③ TaskRequest:20ms 一帧;首帧 1、中间 3、末帧 9
        frames = [pcm[i:i + FRAME_BYTES] for i in range(0, len(pcm), FRAME_BYTES)]
        for index, frame in enumerate(frames):
            state = 1 if index == 0 else (9 if index == len(frames) - 1 else 3)
            await ws.send(encode("", "TaskRequest", payload="{}", audio=frame,
                                 request_id=request_id, state=state))
            await asyncio.sleep(0.02)          # 按实时速度喂;也可以更快

        # ④ FinishSession:收尾
        await ws.send(encode(app_key, "FinishSession", request_id=request_id))

        while not done:
            msg = decode(await asyncio.wait_for(ws.recv(), timeout=20))
            kind, result = text_of(msg, 4), text_of(msg, 7)
            if result:
                data = json.loads(result)
                for item in data.get("results") or []:
                    if item.get("text"):
                        text = item["text"]        # 累积文本,最后一条即完整结果
            if kind in ("SessionFinished", "TaskFinished"):
                done = True
            elif kind in ("SessionFailed", "TaskFailed"):
                raise RuntimeError(f"识别失败: {msg.get(5)} {text_of(msg, 6)}")
    return text


if __name__ == "__main__":
    if len(sys.argv) < 2:
        sys.exit("用法: python3 asr_min.py <16k-mono-16bit.wav>")
    print(asyncio.run(transcribe(sys.argv[1])))

Node.js

npm i ws
node asr_min.js test.wav
为什么不用 Node 自带的全局 WebSocket:上游靠 proto-version: v2 这个握手头来选择二进制 protobuf 协议, 没有它就回 JSON;而全局 WebSocket 和浏览器一样无法设置自定义请求头,所以这里用 ws 包。
#!/usr/bin/env node
/**
 * 豆包输入法语音识别 · 最小可用客户端(Node.js)
 *
 * 用法: npm i ws && node asr_min.js <16k-mono-16bit.wav>
 *
 * 为什么不用 Node 自带的全局 WebSocket:上游靠 `proto-version: v2` 这个握手头选择
 * 二进制 protobuf 协议,没有它就回 JSON;而全局 WebSocket 无法设置自定义请求头,
 * 所以这里用 ws 包(浏览器同理——这也是网页端必须走服务端中转的原因)。
 */

const fs = require("node:fs");
const crypto = require("node:crypto");
const WebSocket = require("ws");

const AID = 685343;                 // 豆包输入法的 app id(取自客户端 ImeService.exe)
const APP_NAME = "oime";
const VERSION_CODE = 100102018;
const VERSION_NAME = "1.1.2";
const UA = "com.bytedance.android.doubaoime/100102018 (Linux; U; Android 16; en_US; " +
           "Pixel 7 Pro; Build/BP2A.250605.031.A2)";
const REGISTER_URL = "https://log.snssdk.com/service/2/device_register/";
const SETTINGS_URL = "https://is.snssdk.com/service/settings/v3/";
const WS_URL = "wss://frontier-audio-ime-ws.doubao.com/ocean/api/v1/ws";

const SAMPLE_RATE = 16000;
const FRAME_BYTES = 640;            // 20ms × 16kHz × 2 字节

// ------------------------------------------------------------- 1. 拿凭据

async function postJson(url, params, body, headers) {
  const target = `${url}?${new URLSearchParams(params)}`;
  const resp = await fetch(target, { method: "POST", body, headers });
  if (!resp.ok) throw new Error(`${url} HTTP ${resp.status}: ${await resp.text()}`);
  return resp.json();
}

async function getCredentials() {
  const cdid = crypto.randomUUID();
  const openudid = crypto.randomUUID().replace(/-/g, "").slice(0, 16);
  const clientudid = crypto.randomUUID();
  const header = {
    device_id: 0, install_id: 0, aid: AID, app_name: APP_NAME,
    version_code: VERSION_CODE, version_name: VERSION_NAME,
    manifest_version_code: VERSION_CODE, update_version_code: VERSION_CODE,
    channel: "official", device_platform: "android", os: "android", os_api: "34",
    os_version: "16", device_type: "Pixel 7 Pro", device_brand: "google",
    device_model: "Pixel 7 Pro", resolution: "1080*2400", dpi: "420",
    language: "zh", timezone: 8, access: "wifi", region: "CN",
    tz_name: "Asia/Shanghai", tz_offset: 28800, openudid, clientudid, cdid,
  };
  const reg = await postJson(REGISTER_URL, {
    device_platform: "android", os: "android", ssmix: "a", _rticket: Date.now(),
    cdid, channel: "official", aid: AID, app_name: APP_NAME,
    version_code: VERSION_CODE, version_name: VERSION_NAME,
    manifest_version_code: VERSION_CODE, update_version_code: VERSION_CODE,
    resolution: "1080*2400", dpi: "420", device_type: "Pixel 7 Pro",
    device_brand: "google", language: "zh", os_api: "34", os_version: "16", ac: "wifi",
  }, JSON.stringify({ magic_tag: "ss_app_log", header, _gen_time: Date.now() }),
     { "Content-Type": "application/json", "User-Agent": UA });

  const deviceId = String(reg.device_id_str || reg.device_id || "");
  if (!deviceId || deviceId === "0") throw new Error(`设备注册失败: ${JSON.stringify(reg)}`);

  const stub = "body=null";
  const settings = await postJson(SETTINGS_URL, {
    device_platform: "android", os: "android", ssmix: "a", channel: "official",
    aid: AID, app_name: APP_NAME, version_code: VERSION_CODE, version_name: VERSION_NAME,
    device_id: deviceId, cdid, _rticket: Date.now(),
  }, stub, {
    "User-Agent": UA,
    "Content-Type": "application/x-www-form-urlencoded",
    "x-ss-stub": crypto.createHash("md5").update(stub).digest("hex").toUpperCase(),
  });
  const appKey = settings?.data?.settings?.asr_config?.app_key;
  if (!appKey) throw new Error(`settings 没给 app_key: ${JSON.stringify(settings)}`);
  return { deviceId, appKey };
}

// ------------------------------------------------------------- 2. protobuf 帧

function varint(value) {
  const out = [];
  while (true) {
    let byte = value & 0x7f;
    value = Math.floor(value / 128);
    out.push(value ? byte | 0x80 : byte);
    if (!value) return Buffer.from(out);
  }
}

function encode({ token = "", method = "", payload = "", audio = null, requestId = "", state = 0 }) {
  const parts = [];
  for (const [num, val] of [[2, token], [3, "ASR"], [5, method], [6, payload]]) {
    if (!val) continue;
    const raw = Buffer.from(val, "utf8");
    parts.push(varint((num << 3) | 2), varint(raw.length), raw);
  }
  if (audio && audio.length) {
    parts.push(varint((7 << 3) | 2), varint(audio.length), audio);
  }
  if (requestId) {
    const raw = Buffer.from(requestId, "utf8");
    parts.push(varint((8 << 3) | 2), varint(raw.length), raw);
  }
  if (state) parts.push(varint(9 << 3), varint(state));
  return Buffer.concat(parts);
}

function decode(buf) {
  const out = {};
  let off = 0;
  const readVarint = (pos) => {
    let val = 0, shift = 0;
    while (true) {
      const byte = buf[pos++];
      val |= (byte & 0x7f) << shift;
      shift += 7;
      if (!(byte & 0x80)) return [val, pos];
    }
  };
  while (off < buf.length) {
    let tag; [tag, off] = readVarint(off);
    const field = tag >> 3, wire = tag & 7;
    if (wire === 0) {
      let val; [val, off] = readVarint(off);
      (out[field] ||= []).push(val);
    } else if (wire === 2) {
      let len; [len, off] = readVarint(off);
      (out[field] ||= []).push(buf.subarray(off, off + len));
      off += len;
    } else if (wire === 1) off += 8;
    else if (wire === 5) off += 4;
    else break;
  }
  return out;
}

const textOf = (msg, field) => {
  const val = msg[field]?.[0];
  return val == null ? null : (Buffer.isBuffer(val) ? val.toString("utf8") : val);
};

// ------------------------------------------------------------- 3. 跑一次识别

function readWav(path) {
  const buf = fs.readFileSync(path);
  if (buf.toString("ascii", 0, 4) !== "RIFF") throw new Error("不是 WAV 文件");
  let off = 12;
  while (off + 8 <= buf.length) {
    const id = buf.toString("ascii", off, off + 4);
    const size = buf.readUInt32LE(off + 4);
    if (id === "fmt ") {
      const channels = buf.readUInt16LE(off + 10);
      const rate = buf.readUInt32LE(off + 12);
      const bits = buf.readUInt16LE(off + 22);
      if (channels !== 1 || rate !== SAMPLE_RATE || bits !== 16) {
        throw new Error(`需要 16kHz/单声道/16bit 的 WAV,实际 ${rate}Hz/${channels}ch/${bits}bit`);
      }
    }
    if (id === "data") {
      return buf.subarray(off + 8, off + 8 + size);
    }
    off += 8 + size + (size % 2);
  }
  throw new Error("WAV 里没有 data 段");
}

async function transcribeOnce(path) {
  const pcm = readWav(path);
  const { deviceId, appKey } = await getCredentials();
  const requestId = crypto.randomUUID();
  const ws = new WebSocket(`${WS_URL}?aid=${AID}&device_id=${deviceId}`, {
    headers: { "User-Agent": UA, "proto-version": "v2", "x-custom-keepalive": "true" },
  });

  let text = "";
  let finished = false;
  let failure = null;
  const queue = [];
  const waiters = [];
  let closed = false;

  ws.on("message", (data) => {
    const msg = decode(Buffer.from(data));
    if (waiters.length) waiters.shift()(msg); else queue.push(msg);
  });
  ws.on("error", (err) => { failure = failure || err; closed = true; while (waiters.length) waiters.shift()(null); });
  ws.on("close", () => { closed = true; while (waiters.length) waiters.shift()(null); });

  const nextMessage = () => new Promise((resolve, reject) => {
    if (queue.length) return resolve(queue.shift());
    if (closed) return reject(new Error("连接已关闭"));
    const timer = setTimeout(() => reject(new Error("等待上游响应超时")), 25000);
    waiters.push((msg) => { clearTimeout(timer); msg ? resolve(msg) : reject(new Error("连接已关闭")); });
  });

  await new Promise((resolve, reject) => {
    ws.once("open", resolve);
    ws.once("error", () => reject(new Error("WebSocket 连接失败")));
  });

  // ① StartTask
  ws.send(encode({ token: appKey, method: "StartTask", requestId }));
  let msg = await nextMessage();
  if (textOf(msg, 4) !== "TaskStarted") {
    throw new Error(`StartTask 失败: ${textOf(msg, 4)} ${msg[5]} ${textOf(msg, 6)}`);
  }

  // ② StartSession
  const sessionPayload = JSON.stringify({
    audio_info: { channel: 1, format: "raw", sample_rate: SAMPLE_RATE },
    enable_punctuation: true,
    enable_speech_rejection: false,
    extra: {
      did: deviceId, input_mode: "tool",
      strong_ddc: true, use_twopass_retry: true,
      enable_asr_twopass: true, enable_asr_threepass: true,
      disable_user_words: false, cell_compress_rate: 8,
    },
  });
  ws.send(encode({ token: appKey, method: "StartSession", payload: sessionPayload, requestId }));
  msg = await nextMessage();
  if (textOf(msg, 4) !== "SessionStarted") {
    throw new Error(`StartSession 失败: ${textOf(msg, 4)} ${msg[5]} ${textOf(msg, 6)}`);
  }

  // ③ 边收结果边发音频
  const collector = (async () => {
    while (!finished) {
      let incoming;
      try {
        incoming = await nextMessage();
      } catch (err) { failure = failure || err; return; }
      if (!incoming) { failure = failure || new Error("连接提前关闭"); return; }
      const kind = textOf(incoming, 4);
      const result = textOf(incoming, 7);
      if (result) {
        const data = JSON.parse(result);
        for (const item of data.results || []) {
          if (item.text) text = item.text;        // 累积文本,最后一条即完整结果
        }
      }
      if (kind === "SessionFinished" || kind === "TaskFinished") finished = true;
      if (kind === "SessionFailed" || kind === "TaskFailed") {
        failure = new Error(`识别失败: ${incoming[5]} ${textOf(incoming, 6)}`);
        finished = true;
      }
    }
  })();

  for (let i = 0; i < pcm.length; i += FRAME_BYTES) {
    const frame = pcm.subarray(i, i + FRAME_BYTES);
    const state = i === 0 ? 1 : (i + FRAME_BYTES >= pcm.length ? 9 : 3);
    ws.send(encode({ method: "TaskRequest", payload: "{}", audio: frame, requestId, state }));
    await new Promise((r) => setTimeout(r, 20));         // 按实时速度喂
  }

  // ④ FinishSession
  ws.send(encode({ token: appKey, method: "FinishSession", requestId }));
  await Promise.race([collector, new Promise((r) => setTimeout(r, 25000))]);
  ws.close();
  if (failure) throw failure;
  return text;
}

const isRetryable = (err) => /service discovery|\[5\d{7}/.test(String(err));

async function transcribe(path, retries = 4) {
  for (let attempt = 0; ; attempt++) {
    try {
      return await transcribeOnce(path);
    } catch (err) {
      if (!isRetryable(err) || attempt >= retries - 1) throw err;
      console.error(`  [重试 ${attempt + 1}/${retries - 1}] ${err.message}`);
      await new Promise((r) => setTimeout(r, 600));
    }
  }
}

const file = process.argv[2];
if (!file) {
  console.error("用法: node asr_min.js <16k-mono-16bit.wav>");
  process.exit(1);
}
transcribe(file).then((text) => console.log(text))
  .catch((err) => { console.error("失败:", err.message); process.exit(2); });

Go

cd examples && go mod tidy && go run asr_min.go test.wav
// 豆包输入法语音识别 · 最小可用客户端(Go)
//
// 用法:
//   cd examples && go mod tidy && go run asr_min.go <16k-mono-16bit.wav>
//
// 依赖: github.com/gorilla/websocket
package main

import (
	"bytes"
	"crypto/md5"
	"encoding/binary"
	"encoding/hex"
	"encoding/json"
	"errors"
	"fmt"
	"io"
	"net/http"
	"net/url"
	"os"
	"strings"
	"time"

	"github.com/google/uuid"
	"github.com/gorilla/websocket"
)

const (
	aid          = 685343 // 豆包输入法的 app id(取自客户端 ImeService.exe)
	appName      = "oime"
	versionCode  = 100102018
	versionName  = "1.1.2"
	userAgent    = "com.bytedance.android.doubaoime/100102018 (Linux; U; Android 16; en_US; Pixel 7 Pro; Build/BP2A.250605.031.A2)"
	registerURL  = "https://log.snssdk.com/service/2/device_register/"
	settingsURL  = "https://is.snssdk.com/service/settings/v3/"
	wsURL        = "wss://frontier-audio-ime-ws.doubao.com/ocean/api/v1/ws"
	sampleRate   = 16000
	frameBytes   = 640 // 20ms × 16kHz × 2 字节
	maxRetry     = 4
)

var httpClient = &http.Client{Timeout: 25 * time.Second}

// ---------------------------------------------------------------- 1. 拿凭据

func postJSON(endpoint string, params url.Values, body []byte, headers map[string]string, out any) error {
	req, err := http.NewRequest(http.MethodPost, endpoint+"?"+params.Encode(), bytes.NewReader(body))
	if err != nil {
		return err
	}
	for k, v := range headers {
		req.Header.Set(k, v)
	}
	resp, err := httpClient.Do(req)
	if err != nil {
		return err
	}
	defer resp.Body.Close()
	raw, _ := io.ReadAll(resp.Body)
	if resp.StatusCode != http.StatusOK {
		return fmt.Errorf("%s HTTP %s: %s", endpoint, resp.Status, string(raw))
	}
	return json.Unmarshal(raw, out)
}

// getCredentials 匿名注册一台设备并换取 ASR 的 app_key(不需要账号登录)
func getCredentials() (deviceID, appKey string, err error) {
	cdid := uuid.NewString()
	openudid := strings.ReplaceAll(uuid.NewString(), "-", "")[:16]
	clientudid := uuid.NewString()

	header := map[string]any{
		"device_id": 0, "install_id": 0, "aid": aid, "app_name": appName,
		"version_code": versionCode, "version_name": versionName,
		"manifest_version_code": versionCode, "update_version_code": versionCode,
		"channel": "official", "device_platform": "android", "os": "android", "os_api": "34",
		"os_version": "16", "device_type": "Pixel 7 Pro", "device_brand": "google",
		"device_model": "Pixel 7 Pro", "resolution": "1080*2400", "dpi": "420",
		"language": "zh", "timezone": 8, "access": "wifi", "region": "CN",
		"tz_name": "Asia/Shanghai", "tz_offset": 28800, "openudid": openudid,
		"clientudid": clientudid, "cdid": cdid,
	}
	body, _ := json.Marshal(map[string]any{
		"magic_tag": "ss_app_log", "header": header, "_gen_time": time.Now().UnixMilli(),
	})
	params := url.Values{
		"device_platform": {"android"}, "os": {"android"}, "ssmix": {"a"},
		"_rticket": {fmt.Sprint(time.Now().UnixMilli())}, "cdid": {cdid}, "channel": {"official"},
		"aid": {fmt.Sprint(aid)}, "app_name": {appName}, "version_code": {fmt.Sprint(versionCode)},
		"version_name": {versionName}, "manifest_version_code": {fmt.Sprint(versionCode)},
		"update_version_code": {fmt.Sprint(versionCode)}, "resolution": {"1080*2400"}, "dpi": {"420"},
		"device_type": {"Pixel 7 Pro"}, "device_brand": {"google"}, "language": {"zh"},
		"os_api": {"34"}, "os_version": {"16"}, "ac": {"wifi"},
	}
	var reg struct {
		DeviceIDStr string `json:"device_id_str"`
		DeviceID    any    `json:"device_id"`
	}
	if err = postJSON(registerURL, params, body,
		map[string]string{"Content-Type": "application/json", "User-Agent": userAgent}, &reg); err != nil {
		return "", "", err
	}
	deviceID = reg.DeviceIDStr
	if deviceID == "" || deviceID == "0" {
		deviceID = fmt.Sprint(reg.DeviceID)
	}
	if deviceID == "" || deviceID == "0" {
		return "", "", errors.New("设备注册失败:没有 device_id")
	}

	stub := []byte("body=null")
	sum := md5.Sum(stub)
	params = url.Values{
		"device_platform": {"android"}, "os": {"android"}, "ssmix": {"a"}, "channel": {"official"},
		"aid": {fmt.Sprint(aid)}, "app_name": {appName}, "version_code": {fmt.Sprint(versionCode)},
		"version_name": {versionName}, "device_id": {deviceID}, "cdid": {cdid},
		"_rticket": {fmt.Sprint(time.Now().UnixMilli())},
	}
	var settings struct {
		Data struct {
			Settings struct {
				ASRConfig struct {
					AppKey string `json:"app_key"`
				} `json:"asr_config"`
			} `json:"settings"`
		} `json:"data"`
	}
	if err = postJSON(settingsURL, params, stub, map[string]string{
		"User-Agent":       userAgent,
		"x-ss-stub":        strings.ToUpper(hex.EncodeToString(sum[:])),
		"Content-Type":     "application/x-www-form-urlencoded",
	}, &settings); err != nil {
		return "", "", err
	}
	appKey = settings.Data.Settings.ASRConfig.AppKey
	if appKey == "" {
		return "", "", errors.New("settings 没给 asr_config.app_key")
	}
	return deviceID, appKey, nil
}

// ---------------------------------------------------------------- 2. protobuf 帧

func putVarint(buf *bytes.Buffer, value uint64) {
	for {
		b := byte(value & 0x7f)
		value >>= 7
		if value != 0 {
			buf.WriteByte(b | 0x80)
		} else {
			buf.WriteByte(b)
			return
		}
	}
}

func putBytes(buf *bytes.Buffer, field int, raw []byte) {
	if len(raw) == 0 {
		return
	}
	putVarint(buf, uint64(field<<3|2))
	putVarint(buf, uint64(len(raw)))
	buf.Write(raw)
}

// encode 上游请求帧:2=token 3=service 5=method 6=payload 7=audio 8=request_id 9=frame_state
func encode(token, method, payload string, audio []byte, requestID string, state int) []byte {
	var buf bytes.Buffer
	putBytes(&buf, 2, []byte(token))
	putBytes(&buf, 3, []byte("ASR"))
	putBytes(&buf, 5, []byte(method))
	putBytes(&buf, 6, []byte(payload))
	putBytes(&buf, 7, audio)
	putBytes(&buf, 8, []byte(requestID))
	if state != 0 {
		putVarint(&buf, uint64(9<<3))
		putVarint(&buf, uint64(state))
	}
	return buf.Bytes()
}

func readVarint(data []byte, pos int) (uint64, int) {
	var value uint64
	var shift uint
	for {
		b := data[pos]
		pos++
		value |= uint64(b&0x7f) << shift
		shift += 7
		if b&0x80 == 0 {
			return value, pos
		}
	}
}

// decode 响应帧 → 字段号到值的映射(2=task_id 4=事件 5=状态码 6=状态文本 7=结果 JSON)
func decode(data []byte) map[int][][]byte {
	out := map[int][][]byte{}
	for off := 0; off < len(data); {
		tag, next := readVarint(data, off)
		off = next
		field, wire := int(tag>>3), int(tag&7)
		switch wire {
		case 0:
			val, next := readVarint(data, off)
			off = next
			raw := make([]byte, 8)
			binary.LittleEndian.PutUint64(raw, val)
			out[field] = append(out[field], raw)
		case 2:
			length, next := readVarint(data, off)
			off = next
			out[field] = append(out[field], data[off:off+int(length)])
			off += int(length)
		case 1:
			off += 8
		case 5:
			off += 4
		default:
			return out
		}
	}
	return out
}

func textOf(msg map[int][][]byte, field int) string {
	values := msg[field]
	if len(values) == 0 {
		return ""
	}
	return string(values[0])
}

func codeOf(msg map[int][][]byte) uint64 {
	values := msg[5]
	if len(values) == 0 {
		return 0
	}
	return binary.LittleEndian.Uint64(values[0])
}

// ---------------------------------------------------------------- 3. 跑一次识别

func readWav(path string) ([]byte, error) {
	raw, err := os.ReadFile(path)
	if err != nil {
		return nil, err
	}
	if len(raw) < 44 || string(raw[0:4]) != "RIFF" {
		return nil, errors.New("不是 WAV 文件")
	}
	for off := 12; off+8 <= len(raw); {
		id := string(raw[off : off+4])
		size := int(binary.LittleEndian.Uint32(raw[off+4 : off+8]))
		if id == "fmt " {
			channels := binary.LittleEndian.Uint16(raw[off+10 : off+12])
			rate := binary.LittleEndian.Uint32(raw[off+12 : off+16])
			bits := binary.LittleEndian.Uint16(raw[off+22 : off+24])
			if channels != 1 || rate != sampleRate || bits != 16 {
				return nil, fmt.Errorf("需要 16kHz/单声道/16bit 的 WAV,实际 %dHz/%dch/%dbit", rate, channels, bits)
			}
		}
		if id == "data" {
			end := off + 8 + size
			if end > len(raw) {
				end = len(raw)
			}
			return raw[off+8 : end], nil
		}
		off += 8 + size + size%2
	}
	return nil, errors.New("WAV 里没有 data 段")
}

type result struct {
	Results []struct {
		Text string `json:"text"`
	} `json:"results"`
}

func transcribeOnce(path string) (string, error) {
	pcm, err := readWav(path)
	if err != nil {
		return "", err
	}
	deviceID, appKey, err := getCredentials()
	if err != nil {
		return "", err
	}
	requestID := uuid.NewString()

	header := http.Header{}
	header.Set("User-Agent", userAgent)
	header.Set("proto-version", "v2")
	header.Set("x-custom-keepalive", "true")
	target := fmt.Sprintf("%s?aid=%d&device_id=%s", wsURL, aid, deviceID)
	conn, _, err := websocket.DefaultDialer.Dial(target, header)
	if err != nil {
		return "", err
	}
	defer conn.Close()

	// ① StartTask
	if err = conn.WriteMessage(websocket.BinaryMessage, encode(appKey, "StartTask", "", nil, requestID, 0)); err != nil {
		return "", err
	}
	_, data, err := conn.ReadMessage()
	if err != nil {
		return "", err
	}
	if msg := decode(data); textOf(msg, 4) != "TaskStarted" {
		return "", fmt.Errorf("StartTask 失败: %s %d %s", textOf(msg, 4), codeOf(msg), textOf(msg, 6))
	}

	// ② StartSession
	sessionPayload, _ := json.Marshal(map[string]any{
		"audio_info":              map[string]any{"channel": 1, "format": "raw", "sample_rate": sampleRate},
		"enable_punctuation":      true,
		"enable_speech_rejection": false,
		"extra": map[string]any{
			"did": deviceID, "input_mode": "tool",
			"strong_ddc": true, "use_twopass_retry": true,
			"enable_asr_twopass": true, "enable_asr_threepass": true,
			"disable_user_words": false, "cell_compress_rate": 8,
		},
	})
	if err = conn.WriteMessage(websocket.BinaryMessage,
		encode(appKey, "StartSession", string(sessionPayload), nil, requestID, 0)); err != nil {
		return "", err
	}
	if _, data, err = conn.ReadMessage(); err != nil {
		return "", err
	}
	if msg := decode(data); textOf(msg, 4) != "SessionStarted" {
		return "", fmt.Errorf("StartSession 失败: %s %d %s", textOf(msg, 4), codeOf(msg), textOf(msg, 6))
	}

	// ③ 后台收结果,主线程按 20ms 一帧送音频
	text := make(chan string, 1)
	done := make(chan error, 1)
	go func() {
		var last string
		for {
			_, data, err := conn.ReadMessage()
			if err != nil {
				done <- err
				return
			}
			msg := decode(data)
			if raw := textOf(msg, 7); raw != "" {
				var parsed result
				if json.Unmarshal([]byte(raw), &parsed) == nil {
					for _, item := range parsed.Results {
						if item.Text != "" {
							last = item.Text // 累积文本,最后一条即完整结果
						}
					}
				}
			}
			switch textOf(msg, 4) {
			case "SessionFinished", "TaskFinished":
				text <- last
				return
			case "SessionFailed", "TaskFailed":
				done <- fmt.Errorf("识别失败: %d %s", codeOf(msg), textOf(msg, 6))
				return
			}
		}
	}()

	frames := (len(pcm) + frameBytes - 1) / frameBytes
	for i := 0; i < frames; i++ {
		start := i * frameBytes
		end := start + frameBytes
		if end > len(pcm) {
			end = len(pcm)
		}
		state := 3
		if i == 0 {
			state = 1
		} else if i == frames-1 {
			state = 9
		}
		if err = conn.WriteMessage(websocket.BinaryMessage,
			encode("", "TaskRequest", "{}", pcm[start:end], requestID, state)); err != nil {
			return "", err
		}
		time.Sleep(20 * time.Millisecond) // 按实时速度喂
	}

	// ④ FinishSession
	if err = conn.WriteMessage(websocket.BinaryMessage,
		encode(appKey, "FinishSession", "", nil, requestID, 0)); err != nil {
		return "", err
	}
	select {
	case out := <-text:
		return out, nil
	case err := <-done:
		return "", err
	case <-time.After(25 * time.Second):
		return "", errors.New("等待结果超时")
	}
}

func retryable(err error) bool {
	msg := err.Error()
	return strings.Contains(msg, "service discovery") || strings.Contains(msg, "失败: 5")
}

func transcribe(path string) (string, error) {
	var lastErr error
	for attempt := 0; attempt < maxRetry; attempt++ {
		out, err := transcribeOnce(path)
		if err == nil {
			return out, nil
		}
		lastErr = err
		if !retryable(err) {
			return "", err
		}
		fmt.Fprintf(os.Stderr, "  [重试 %d/%d] %v\n", attempt+1, maxRetry-1, err)
		time.Sleep(600 * time.Millisecond)
	}
	return "", lastErr
}

func main() {
	if len(os.Args) < 2 {
		fmt.Fprintln(os.Stderr, "用法: go run asr_min.go <16k-mono-16bit.wav>")
		os.Exit(1)
	}
	out, err := transcribe(os.Args[1])
	if err != nil {
		fmt.Fprintln(os.Stderr, "失败:", err)
		os.Exit(2)
	}
	fmt.Println(out)
}
三份代码的共同骨架(照这个顺序写就不会错): ① 注册设备 → ② 换 app_key → ③ 连 WS(带 proto-version: v2)→ ④ StartTask → ⑤ StartSession → ⑥ 循环发 20ms 帧(首帧 1 / 中间 3 / 末帧 9)→ ⑦ FinishSession → ⑧ 读到最后一条累积文本。

4. 集成到你的项目

4.1 网页端为什么要中转

上游需要 3 个无法从浏览器发出的东西:自定义 WS 请求头(proto-version 等)、 设备级 app_key(不能下发到前端)、以及 HTTP 注册请求。所以浏览器方案必须是:

浏览器 ──(16k PCM / WebSocket)──▶ 你的服务端 ──(protobuf / WebSocket)──▶ 字节识别服务 ↑ 录音、去噪、重采样、实时上屏 ↑ 管凭据、帧序号、重试、降级

本 Demo 就是这么做的:前端只管录音和显示,服务端负责凭据、分帧、重试、降级、纠正词典。

4.2 本 Demo 的接口(可直接对接)

本项目的线上演示:doubao.hsfp.cn (代码就是本仓库)。下面示例里的 <你的域名> 换成你自己的部署地址即可。

接口说明
GET /录音页面(前端可直接抄 /static/index.html + /static/worklet.js)
WS /ws?engine=auto|cloud|local转写会话;auto = 云端优先,失败自动降级本地引擎
GET /health状态、设备凭据是否就绪、并发上限、纠正/热词数量
POST /api/corrections{"wrong":"识别错的","right":"用户改成的"}:记住并影响后续识别
POST|DELETE /api/hotwords手加热词 / 删除

WS 事件协议

// 客户端 → 服务端
二进制帧          16k 单声道 PCM16(随便怎么切,服务端会攒成 20ms 帧)
{"type":"stop"}  说完了,收尾并返回最终结果
{"type":"cancel"} 丢弃本次会话

// 服务端 → 客户端
{"type":"status","engine":"cloud|local","message":"…"}   状态/降级提示
{"type":"update","engine":"cloud","lines":[…],"text":"…"} 实时结果(每次给全量,前端整块重渲染)
{"type":"final","engine":"cloud","lines":[…],"text":"…","error":null}
{"type":"error","message":"…"}

// lines 里每一行
{"text":"你好。","start":0.0,"end":1.3,"ts":1792130303000,"final":true,
 "orig":"你号。","fixes":[["你号。","你好。"]]}   // orig/fixes 只在命中本地纠正时出现

最小前端调用示例(浏览器)

// 采集侧:AudioWorklet 把任意采样率转成 16k PCM16(完整实现在 static/worklet.js)
const ws = new WebSocket(`wss://<你的域名>/ws?engine=auto`);
ws.binaryType = "arraybuffer";

const ctx = new AudioContext({ sampleRate: 16000 });
await ctx.audioWorklet.addModule("/static/worklet.js");
const node = new AudioWorkletNode(ctx, "pcm16", { processorOptions: { targetRate: 16000 } });
ctx.createMediaStreamSource(await navigator.mediaDevices.getUserMedia({ audio: true })).connect(node);
node.connect(ctx.destination);

node.port.onmessage = (ev) => {
  if (ev.data.pcm && ws.readyState === 1) ws.send(ev.data.pcm);   // 二进制 PCM 直接发
};
ws.onmessage = (ev) => {
  const msg = JSON.parse(ev.data);
  if (msg.type === "update" || msg.type === "final") console.log(msg.text);
};
// 说完
setTimeout(() => ws.send(JSON.stringify({ type: "stop" })), 3000);

Node 后端批量转写(把服务端当上游用)

// 适合「把一批录音文件转成文字」:连本 Demo 的 /ws,喂 PCM,收最终文本
import fs from "node:fs";
import WebSocket from "ws";

async function transcribeFile(path) {
  const pcm = readWav16kMono(path);                       // 见 examples/asr_min.js 的 readWav
  const ws = new WebSocket("wss://<你的域名>/ws?engine=auto");
  return new Promise((resolve, reject) => {
    let done = null;
    ws.on("open", async () => {
      for (let i = 0; i < pcm.length; i += 640) {
        ws.send(pcm.subarray(i, i + 640));
        await new Promise((r) => setTimeout(r, 20));      // 按实时速度喂(也可更快)
      }
      ws.send(JSON.stringify({ type: "stop" }));
    });
    ws.on("message", (raw) => {
      const msg = JSON.parse(raw);
      if (msg.type === "final") { done = msg; resolve(msg.text); ws.close(); }
      if (msg.type === "error") reject(new Error(msg.message));
    });
    ws.on("close", () => { if (!done) reject(new Error("连接提前关闭")); });
  });
}

4.3 生产环境注意事项

点建议
凭据缓存device_id + app_key 落盘复用(600 权限),别每次请求都重新注册
重试50700000 是随机抖动(约 1/5),整段重试 3~4 次;40200011 不要重试(配额满)
并发所有客户端共享同一个 app_key 的配额,服务端做信号量(本 Demo 默认 3 路)
帧节奏按实时速度喂即可(20 ms/帧 = 32 KB/s);一次性灌太快容易触发异常
末帧实时流不知道哪帧是最后一帧 → 压一帧,收尾时把压着的这帧标 9 再发 FinishSession
安全不要在前端暴露 app_key;服务端加鉴权(本 Demo 用 API key / 访问控制即可)
合规这是未公开的内部接口,接口可能变更、风控可能封禁;正式产品请用官方接口(见下节)

5. 说话人分离(可选)

上游 ASR 的结果里没有 speaker 字段,所以「谁说的」得我们自己算。 默认走 online 模式:每句一定稿就提一次声纹、和已有说话人比相似度,边录边出标签; 也可以设 DOUBAO_DIAR_MODE=batch 改成"录完整段再聚类"(更准、但要等)。

录音(16k PCM) ├─ 分割:pyannote segmentation-3.0(ONNX,6 MB)→ 逐帧说话人活动 ├─ 声纹:3D-Speaker ERes2Net(ONNX,38 MB)→ 每段的说话人向量 ├─ 聚类:余弦相似度 + 阈值(默认 0.7)→ 谁和谁是同一个人 └─ 对齐:拿每句的 [start,end] 和分离片段求最大重叠 → spk 标签

这套配方与 screenpipe 一致(它的 Rust 实现同样是 pyannote 分割 + 3D-Speaker 类声纹 + 自定义聚类); 本项目直接用 sherpa-onnx 的现成实现,省掉自己接 ONNX。

./venv/bin/pip install -r requirements-diarization.txt   # sherpa-onnx + numpy
./tools/fetch_diarization_models.sh                       # 下模型到 ./models(走 hf-mirror)
实测结果
速度online:每句约 50ms(实时出标签);batch:19.3s 音频约 8s(≈2.4× 实时)
中文女声 + 英文男声正确分成 2 人(阈值 0.7)
单人录音只有 1 人,不会硬拆

声纹库:从"1 号 2 号"升级到"这是谁"

分离只给出匿名编号,而且编号只在单次录音内有意义。想跨录音认人,就把说话人的声纹存下来做 1:N 识别: 有两种方式:① 打开 /voice 声纹管理页,念 3 段不同内容(每段 2~4 秒), 页面会显示每段的质量和"与前几段的一致性",保存即入库(推荐,最干净); ② 在录音页点「说话人 N」标签、填真名并确认"记住这个声音"。 入库的是那个人每段录音的声纹向量(CAM++,192 维),存在浏览器 localStorage; 每次会话开始时随 config 发给服务端,新句子先跟声纹库比余弦(默认阈值 0.5), 认得就用真名(页面显示 ✓ 姓名),认不出再走会话内聚类。一个人可存多例, 检索取"最像的那一例"。

实测:在声纹管理页录 2 段注册「小飞」→ 回录音页边说边显示 ✓ 小飞; 同一段里没注册的英文男声保持「说话人 2」——不会把陌生人硬塞成已注册的名字。
声纹模型怎么挑(实测 2 人 × 3 段): ERes2Net-zh(38MB,中文专用)同人最低相似度 +0.29、异人最高 −0.02,可分性只有 0.31; 换成 CAM++ zh_en(28MB,中英双语)后同人 ≥ +0.72、异人 ≤ +0.05,可分性 0.67, 阈值 0.5 很安全。多例注册还有额外增益:1 秒短句 1 例 9/10 正确 → 4 例 4/4(A-B 分差 0.54 → 0.63)。
局限:不做重叠语音(两人同时说话归给其中一人);在线追踪是逐句独立判断, 说话人编号只在这一次录音内有意义(跨录音不会自动认人,要认人得自己建声纹库); 太短的句子(<0.6s)算不出可靠声纹,会继承上一位说话人。

6. 离线 / 本地方案

本项目自带离线引擎(offline_engine.py):用的是字节公开分发的 ASR 模型 (见 1.7),纯 ONNX + onnxruntime,不联网、不依赖豆包客户端。 网页上可以三档切换,方便对比效果:

档位走哪条路特点
自动(默认)engine=auto:云端优先,握手失败/会话中断/重连失败 → 自动切离线引擎(再不行才用 FunASR)日常用
在线engine=cloud:只用豆包云端最准、带标点、有热词上下文
离线engine=offline:只用本机 ONNX 模型断网可用、有真实句级时间戳与质量分;英文弱一些

每次会话的分隔线上会标出用了哪档(如 2026-10-17 03:33 · 离线 · 录音 43s / 识别到 42s),方便回溯对比。

6.1 离线引擎内部是怎么做的

它是个独立进程(默认 127.0.0.1:4272),接口形状和 FunASR 引擎完全一致, 所以 server.py 里两套本地引擎复用同一个客户端基类 ChunkEngineSession—— 换引擎只是换 URL 和“结果怎么翻译成前端要的行”。

浏览器 ──20ms PCM 帧──▶ server.py ──100ms 一批──▶ offline_engine.py
                          │                        ├─ 能量 VAD 切句
                          │                        ├─ encoder → decoder → argmax(整段)
                          ◀─ 定稿句(时间戳/质量分)─┤  punc 模型补标点
                          ◀─ 未定稿片段(每约1.2s)─┘
                          └─ 转成前端的 update / final 协议

POST /stream               → {"session_id": …}
POST /stream/{sid}/chunk   → {"sentences":[{start,end,text,quality}], "partial":"…", "audio_seconds": …}
POST /stream/{sid}/finish  → 全部句子
GET  /health               → 是否就绪 / token 数 / 是否带标点 / VAD 参数

切句与“未定稿”(体验的关键,也是踩过坑的地方)

踩过的坑:把人话切碎了 → 输出全是乱码。 最初是“停顿 0.6s 就断句”,上线的真人使用反馈是 炸很炸还炸(质量分 0.001)、我的是(0.001)、这是还是一个(0.000)—— 完整句的质量分是 0.44~0.72,碎片几乎为 0。复现结论: 这个模型在 1~2 秒的碎片上会输出乱码(把测试音频硬切成 1.2s 碎片,立刻复现同样的质量分与乱码)。 于是改成“宁可晚一点合,也不要把话切碎”,并用**模型自己的质量分**当断句闸门。
参数值为什么这么定
停顿(考虑断句)1.0s短于它的停顿一定是句内停顿,绝不切
合并宽限再等 1.5s停顿够久但质量分不够 → 继续合并;到期才无条件定稿
质量闸门0.35考虑断句时先解码看一眼:质量分≥ 0.35 才真断(碎片乱码实测 0.001~0.29,完整句 0.44~0.72)
最短解码片段1.2s比这更短的片段根本不去试断句(模型必然乱码)
段内长静音压到 0.18s合并后段内还有几秒静音时,模型会重复吐字(如「接口接口测试」),压短后就干净
单段上限15s解码整段跑,太长则慢、实时性差
解码后拆行按 。!? 拆合并出来的长段按标点拆成一行一句,时间按字数比例分
未定稿≥2.0s 才出,约 1.2s 一次太短的未定稿就是乱码,会在页面上闪垃圾字;缓冲 >8s 就不再出
底噪估计自适应3 帧滑动平均 + 静音帧 EMA;阈值 = max(0.0035, 1.8 × 底噪)
并发推理串行CPU 上并发跑只会互相拖慢,加锁排队
顺带修的一处:关掉浏览器的麦克风前处理。 getUserMedia 默认开 noiseSuppression / autoGainControl / echoCancellation, 这些对通话友好、对 ASR 有害(降噪会吞辅音、引入音乐噪声),小模型会因此重复吐字。 本项目已改成关闭(个别浏览器不支持时退回默认)。

实测效果(同一段音频跑两档)

音频在线 CER离线 CER差别在哪
fixture-6s(中文)0.0000.000都全对;离线反而更快(1.0s vs 1.4s)
fixture-2s(中文)0.0000.000离线按停顿切成两句、标点不如云端完整
中英混说0.1410.152中文都对;英文云端明显更完整,离线会串字
平均0.047 vs 0.051越小越好;中日英以外的语种离线直接乱码
./venv/bin/python tests/compare_engines.py     # 复现这张表(真连两个引擎跑)

怎么确认它真的没联网 / 怎么排障

curl -s http://127.0.0.1:4272/health        # 引擎自己的健康检查
tail -f /var/log/doubao-offline-asr.log     # 每句都有「定稿 1.23-3.45s(12 字,质量 0.683)」
/root/scripts/doubao-asr-web-ctl.sh offline        # 状态
/root/scripts/doubao-asr-web-ctl.sh offline-test   # 不进服务,直接用仓库音频自测
现象原因 / 处理
页面上「离线」变灰、title 写“不可用”引擎没起或模型没下:systemctl status doubao-offline-asr
/health 的 reason 是“缺模型文件”./tools/fetch_offline_asr_models.sh
识别出来重复/串字前端归一化或 LFR 参数不对;先用 --selftest 跑仓库音频确认
整段没反应底噪被环境噪声抬太高;看日志有没有“定稿”,必要时调最短语音/阈值
想临时换 FunASR?engine=local;或把 DOUBAO_OFFLINE_ENGINE 留空让 auto 降级到 FunASR

还没做的(说清楚)

离线引擎怎么跑

# 1) 下模型(约 310MB,公开 CDN;详见 1.7)
./tools/fetch_offline_asr_models.sh

# 2) 装依赖 + 起服务(默认监听 127.0.0.1:4272)
./venv/bin/pip install -r requirements-offline.txt
./venv/bin/python offline_engine.py                 # 前台
./venv/bin/python offline_engine.py --selftest tests/fixture-6s.wav   # 不进服务,直接自测

# 3) 让网页用上它(.env)
DOUBAO_OFFLINE_ENGINE=http://127.0.0.1:4272

# 4) 生产用 systemd(仓库外,部署环境里)
systemctl enable --now doubao-offline-asr

离线引擎的切句策略:能量 VAD(自适应底噪)+ 停顿 0.6s 断句 + 单段上限 12s; 一段话说到一半会每约 1.2s 给一次「未定稿」结果,前端表现为最后一行还在变,停顿后定稿并加标点。

另外两个开源兜底(可选)

方案特点适合
FunASR(ModelScope / 阿里) 中文效果好、带标点与 VAD、有流式模型;CPU 上约 1.5~2 倍实时;本机已在跑 中文为主的自托管
sherpa-onnx 纯 C++/ONNX 运行时,模型小、启动快,支持中/英/日/韩等多语种与流式;跨平台 要真正端侧、多语种
whisper.cpp / faster-whisper 多语种(含日语)最强,代价是模型大、CPU 慢、实时性差 离线文件转写、多语种

本 Demo 的降级链(已经接好了)

云端失败时依次尝试:离线引擎 → FunASR 本地引擎(配了才用)。配置:

# .env
DOUBAO_OFFLINE_ENGINE=http://127.0.0.1:4272      # 离线引擎(本机 ONNX,默认就是它)
DOUBAO_LOCAL_ENGINE=http://127.0.0.1:4271        # 可选:FunASR 引擎(最后兜底)
DOUBAO_LOCAL_ENGINE_TOKEN=…                      # FunASR 引擎的 x-engine-token
DOUBAO_CLOUD_RETRY=2                             # 云端抖动时的重连次数
DOUBAO_CONTEXT=1                                 # 是否把热词上下文带给上游

# 选择引擎
wss://<你的域名>/ws?engine=auto      # 默认:云端优先,失败自动降级
wss://<你的域名>/ws?engine=cloud     # 只用云端(=?engine=online 等价)
wss://<你的域名>/ws?engine=offline   # 只用离线引擎(本机 ONNX)
wss://<你的域名>/ws?engine=local     # 只用 FunASR 引擎

自己搭一个本地 ASR(FunASR 最小示例)

# 引擎侧(本机 127.0.0.1:4271 在跑的就是这套):FunASR 的流式 paraformer + VAD + 标点
from funasr import AutoModel

model = AutoModel(model="iic/speech_paraformer-large_asr_nat-zh-cn-16k-common-vocab8404-online",
                  vad_model="iic/speech_fsmn_vad_zh-cn-16k-common-pytorch",
                  punc_model="iic/punc_ct-transformer_cn-en-common-vocab471067-large")

cache, text = {}, ""
for chunk in pcm_chunks:                    # 每块 16k/单声道/PCM16(建议 600ms)
    import numpy as np
    audio = np.frombuffer(chunk, dtype=np.int16).astype(np.float32) / 32768.0
    res = model.generate(input=audio, cache=cache, is_final=False, chunk_size=[0, 10, 5])
    text += "".join(r.get("text", "") for r in (res or []))
print(text)

要日语 / 多语种

# sherpa-onnx:下载中日英模型后直接跑(离线、无网络依赖)
pip install sherpa-onnx
# 例:模型目录里通常带 run.sh,或用官方 python api:
python -m sherpa_onnx.offline_recognizer --tokens tokens.txt --paraformer model.onnx audio.wav

7. 常见问题

Q:离线档认出来的字不对/在重复,怎么办?

先看句子后面有没有黄色小问号(低置信标记)——那说明这句的质量分低于 0.35。 离线模型对输入比云端敏感,常见原因:说话太快或停顿很多(会被合并/切分)、环境噪声大、麦克风增益过低。 自检顺序:① 用 ./offline_engine.py --selftest tests/fixture-6s.wav 确认引擎本身正常; ② 看 /var/log/doubao-offline-asr.log 里每句的质量分;③ 换成「在线」档对比,判断是音频问题还是模型问题。

Q:为什么我自己连不上,一直 50700000?

上游随机抖动(实测约 1/5),重试整段即可。如果重试也一直失败,可能是接口变了或被风控,换新设备试试、或改用本地引擎。

Q:直接用 whisper-1 模型名行不行?

不行,这不是 OpenAI 接口。字段 5 是固定的方法名,音频字段是裸 PCM/Opus,不解析 multipart。

Q:能不能不注册设备、硬编码一个 app_key?

不建议:app_key 按设备下发且会轮换,硬编码很快就失效;而且那属于拿别人的凭据。

Q:识别结果能拿到逐字时间吗?

能,在 results[].alternatives[].words[] 里(注意 end_time 偏大,需要按 1.3 的坑校正)。

Q:能做成 OpenAI 兼容的 /v1/audio/transcriptions 吗?

能,服务端包一层即可:接收 multipart → 转 16k PCM → 走本节协议 → 返回 {"text": ...}。 本 Demo 目前只提供了 WebSocket 接口,需要 HTTP 版可以照着 4.2 的协议自己加。