AI 录音设备 · 开放协议与云端 AI 接口 AI Recorder · Open Protocol & Cloud AI API

蓝牙协议全开放,浏览器里就能连上你手里的设备验证。转写、说话人分离、纪要与问答由我们的云端提供,一天内接完。支持整机 ODM、贴牌与私有化部署。 Fully open BLE protocol — connect your device right in this browser and verify. Transcription, speaker separation, summaries and Q&A come from our cloud API. Integrate in a day. ODM, white-label and on-premise deployment available.

Open BLE Protocol Cloud ASR + Diarization ODM / White-label On-premise option

在线测试台Live Tester

不装任何软件。Chrome 或 Edge 打开本页(必须 HTTPS),点右上角「连接设备」。所有通信在你的浏览器和设备之间直接进行,音频不经过我们的服务器。 No install. Open this page in Chrome or Edge over HTTPS and hit “Connect”. Everything runs between your browser and the device — no audio touches our servers.

Windows 用户注意:不要用「系统设置 → 添加蓝牙设备」去配对。这类 BLE GATT 外设通常不支持配对,Windows 会报「连接不稳定」,而且失败后留下的记录会持续抢占连接。正确做法就是用本页的「连接设备」按钮。如果之前配对过,先去系统设置里「删除设备」。 Windows users: do not pair via Settings → Add Bluetooth device. These BLE GATT peripherals usually don't support pairing; Windows reports “connection unstable” and the leftover entry keeps stealing the link. Use the Connect button on this page instead. If you already paired, remove the device in Settings first.
Battery
Storage
Firmware
Auth code
Rec state
Mic gain
通信日志Traffic log

硬件规格Hardware

CPUqs30存储Storage32 GB (~29.1 GiB usable, verified)
麦克风Microphones3 — 2× MEMS + 1× 骨传导/振动bone-conduction 录音格式FormatOPUS ~16 kbps (设备内)(on device) / WAV (设备转码输出)(transcoded on request)
屏幕Display0.42" 40×72电池Battery400 mAh
续航Battery life录音约 20-24h / 待机约 33h~20-24 h recording, ~33 h standby 尺寸/重量Size / weight86 × 54 × 3.2 mm · ~30 g
接口InterfaceBLE + 磁吸 USBmagnetic USB 录音模式Modes上推=通话录音 (call*) / 下推=普通录音 (note*)slide up = call (call*) / down = ambient (note*)
骨传导麦的实际用途(容易误解):它不是拾取佩戴者颅骨,而是贴在手机背面拾取听筒振动——通话不开免提时,空气麦收不到对方的声音。这意味着在通话模式下,空气麦≈本机说话人、振动麦≈电话对方,两个人在物理上就是分开的。若固件能输出双声道,说话人分离可做到接近 100% 准确,而不必依赖声纹算法。这是本机型相对同类产品最值得做的差异点。 What the bone-conduction mic is actually for (commonly misread): it does not pick up the wearer's skull — it sits against the back of the phone and picks up earpiece vibration, because air mics can't hear the far end when the call isn't on speaker. So in call mode the air mic ≈ the local speaker and the vibration mic ≈ the remote party: the two voices are physically separated. If firmware exposes dual-channel audio, speaker separation becomes near-100% accurate with no voice-print algorithm at all. This is the strongest differentiator on this device.

BLE 协议BLE Protocol

GATT 服务GATT services

UUID属性Property用途Use
0xAE20Primary Service业务服务Main service
0xAE21WRITE_WITHOUT_RESPONSEApp → Device
0xAE22NOTIFY应答 / 音频 / 列表 / 文件数据Replies, audio, list, file data
0xAE23NOTIFY按键与录音状态Key events, rec state

帧格式Frame format

Offset  Len   Field   Endian   Note
0       1B    MAGIC   -        0x5A
1       1B    SEQ     -        0..255 rolling
2       2B    CRC     LE       CRC-16/XMODEM over LEN(2B) + DATA
4       2B    LEN     LE       byte length of DATA
6       LEN   DATA    -        [TYPE:1B][CMD:1B][PARAMS...]

CRC-16/XMODEM   poly 0x1021  init 0x0000  no reflect  xorout 0x0000
Check vector    "123456789" -> 0x31C3
两条最容易踩的:① CRC 的计算范围必须包含帧头里 LEN 那两个原始字节,不含 MAGIC/SEQ/CRC 自身,漏了会全表对不上。② 字节序是混的——帧头 LEN/CRC 是小端,而文件列表里的 count/time/size 是大端 Two classic traps: (1) the CRC input must include the two raw LEN bytes from the header, and exclude MAGIC/SEQ/CRC itself — miss this and nothing validates. (2) Endianness is mixed: LEN/CRC in the header are little-endian, but count/time/size in the file list are big-endian.

命令表Command reference

TYPE-CMDDir名称Name参数 / 应答Params / reply
0-0同步时间year:2B LE + month/day/hour/min/sec 各1B
0-0Sync timeyear:2B LE + month/day/hour/min/sec 1B each
0-1 / 0-2→ / ←获取 / 应答 容量remain:4B LE + total:4B LE,单位 1KB(厂商原文标 8KB,实测为 1KB)
0-1 / 0-2→ / ←Get / reply storageremain:4B LE + total:4B LE, unit = 1KB (vendor doc says 8KB; measured 1KB)
0-3 / 0-4→ / ←获取 / 应答 电量1B: 0-100;110 = 充电中
0-3 / 0-4→ / ←Get / reply battery1B: 0-100; 110 = charging
0-10 / 0-11→ / ←获取 / 应答 固件版本6B ASCII,如 V1.0.0
0-10 / 0-11→ / ←Get / reply firmware6B ASCII, e.g. V1.0.0
0-12 / 0-13→ / ←获取 / 应答 授权码ASCII 字节串。明文可读,勿作密钥
0-12 / 0-13→ / ←Get / reply auth codeASCII string. Readable in the clear — never use as a secret
1-0→ / ←开始实时 / 本次文件名设备先回文件名再推流
1-0→ / ←Start realtime / filenamedevice sends the filename before streaming
1-1实时音频数据OPUS 码流。固定 40B 裸包,不是 Ogg
1-1Realtime audioOPUS bitstream. Fixed 40B raw packets, not Ogg
1-2 / 1-3 / 1-4→ / → / ←结束 / 暂停继续 / 设备状态1B: 0=继续 1=暂停 2=停止
1-2 / 1-3 / 1-4→ / → / ←Stop / pause-resume / state1B: 0=resume 1=pause 2=stop
2-0 / 2-1→ / ←获取 / 应答 文件列表count:4B BE + N×28B 条目;可多帧
2-0 / 2-1→ / ←Get / reply file listcount:4B BE + N×28B entries; may span frames
2-2请求下载offset:4B LE + filename:24B。必须整帧 36B 一次写入
2-2Request downloadoffset:4B LE + filename:24B. Must be one 36B GATT write
2-3 / 2-4 / 2-5开始 / 数据 / 结束结束码 0=完成 1=不存在 2=offset过大 3=其他
2-3 / 2-4 / 2-5Begin / data / endcode 0=ok 1=not found 2=offset too large 3=other
2-7 / 2-11→ / ←终止导入 / 应答
2-7 / 2-11→ / ←Abort import / ack
2-8 / 2-13→ / ←删除单个 / 应答实测负载 = offset:4B LE + filename:24B与 2-2 相同,文件名要补回扩展名)。文档写的「28B 条目原样回传」在 V1.0.0 上回 code=01 失败。应答 1B:0=成功。破坏性,设备上没有回收站
2-8 / 2-13→ / ←Delete one / ackMeasured payload = offset:4B LE + filename:24B (same as 2-2, extension restored). The documented "echo the 28B entry" returns code=01 on V1.0.0. Ack is 1B: 0=ok. Destructive — no recycle bin on the device
2-9删除全部建议不要实现。无二次确认、不可撤销,一个误触就是客户全部录音没了
2-9Delete allRecommend not implementing. No confirmation, not reversible — one stray tap wipes every recording the customer has
2-12分段下载start:4B LE + end:4B LE + filename
2-12Ranged downloadstart:4B LE + end:4B LE + filename
2-18列表发送完毕1B: 0=完成。旧固件可能不发
2-18List complete1B: 0=done. Older firmware may omit it
3-1..3-8→ / ←开始/保存/暂停/继续 录音结果 1B:1=成功 2=失败
3-1..3-8→ / ←Start/save/pause/resume recresult 1B: 1=ok 2=fail
3-19 / 3-20→ / ←获取 / 应答 录音状态1=录音中 2=未录音 3=暂停
3-19 / 3-20→ / ←Get / reply rec state1=recording 2=idle 3=paused
3-25 / 3-27获取 / 设置 增益1=低 2=中 3=高
3-25 / 3-27Get / set mic gain1=low 2=mid 3=high

文件列表条目(28B)File list entry (28B)

time  4B  BE   时长(秒);个别固件为 unix 时间戳duration in seconds; some firmware sends a unix timestamp
size  4B  BE   设备内压缩体积(Byte)compressed size on device (bytes)
name  20B      NUL 填充。注意只有 20BNUL-padded. Note: only 20 bytes
文件名会被截断:note20260710-162938.opus 是 24B,而 name 字段只有 20B,所以列表实际返回 note20260710-162938.(扩展名没了)。下载时必须自己重建扩展名,按 .opus → .mp3 → .wav → 原始截断名 依次尝试,靠 2-5/code=1 自动往下换。 Names are truncated: note20260710-162938.opus is 24B but the field is only 20B, so the list returns note20260710-162938. — extension gone. You must rebuild it, trying .opus → .mp3 → .wav → raw truncated name and falling through on 2-5/code=1.

实测踩坑Field Notes

以下每一条都来自真机实测,不是从文档抄的。照着做能省掉几天。 Every item below came out of testing on real hardware, not from a datasheet. They will save you days.

现象Symptom原因与做法Cause & fix
浏览器弹出的选择器里一个设备都没有别用 filters:[{services:[0xAE20]}]。filters 匹配的是广播包里声明的服务,而这批固件的广播包根本不带 0xAE20 —— 服务要连上之后才发现得到。用 acceptAllDevices:true + optionalServices:[0xAE20]
⚠️ 也别写「filters 找不到就回退到 acceptAllDevices」:requestDevice 会消耗掉那一次用户手势,第二次调用必抛 must be handled by user gesture,那段回退是死代码。
The browser's device picker is emptyDon't use filters:[{services:[0xAE20]}]. Filters match the services declared in the advertisement, and this firmware does not advertise 0xAE20 — the service is only discoverable after connecting. Use acceptAllDevices:true with optionalServices:[0xAE20].
⚠️ And don't "fall back to acceptAllDevices if filters find nothing": requestDevice consumes the user gesture, so the second call always throws must be handled by user gesture. That fallback is dead code.
刚才还能扫到,现在扫不到了BLE 外设一旦建立连接就停止广播,所以它「消失」通常意味着它还连在别处(上一个没关的标签页、手机、或没断干净的 GATT)。表现和「设备没开机」一模一样。
做法:用 navigator.bluetooth.getDevices() 拿已授权设备不弹选择器直接重连;并在断开时丢掉旧的 device 对象。实在不行让用户把录音笔关机再开机。
It was discoverable a minute ago, now it isn'tA BLE peripheral stops advertising once connected, so "disappearing" usually means it is still connected somewhere else — an old tab, a phone, or a GATT link that was never closed. It looks exactly like a powered-off device.
Use navigator.bluetooth.getDevices() to reconnect without the picker, and drop the stale device object on disconnect. Failing that, power-cycle the recorder.
删除返回 2-13 code=01厂商文档说 2-8 的负载是列表里那 28B 条目原样回传 —— CB08 固件 V1.0.0 不认。实测能用的是和下载 2-2 完全一样的寻址4B offset + 24B 文件名,且文件名要补回扩展名base + '.opus'),不能用列表里那个截断到 20B 的名字。
建议先发这个变体、失败再退回文档写法;反过来的话每次删除都要先失败一次。2-9(删全部)不要实现,设备上没有回收站。
Delete returns 2-13 code=01The vendor doc says the 2-8 payload is the 28-byte list entry echoed back — CB08 firmware V1.0.0 rejects that. What actually works is the same addressing as the 2-2 download: 4B offset + 24B filename, with the extension restored (base + '.opus'), not the 20-byte truncated name from the list.
Send this variant first and fall back to the documented one; the other order makes every delete fail once. Do not implement 2-9 (delete all) — there is no recycle bin on the device.
下载下来的 .opus 播不了 / ffmpeg 报 Invalid data这条不只适用于实时流 —— 从设备下载的 .opus 同样是裸码流,一串固定 40B 的 OPUS 包,没有容器。存盘前必须自己封 Ogg。
⚠️ Ogg 的 CRC-32 不反转、末尾不异或,跟 zlib 那个不是一回事。抄错了会生成一个「看着像 Ogg、每个播放器都拒收」的文件,而且没有任何报错提示你封错了。自检向量:oggCrc("123456789") == 0x89A1897F
The downloaded .opus won't play / ffmpeg says Invalid dataThis isn't only about the realtime stream — files pulled off the device are raw bitstreams too: fixed 40-byte OPUS packets with no container. Wrap them in Ogg before saving.
⚠️ Ogg's CRC-32 is non-reflected with no final XOR — not zlib's. Get it wrong and you produce a file that looks like Ogg and every player refuses, with nothing to tell you why. Test vector: oggCrc("123456789") == 0x89A1897F.
iPhone 上连不了苹果禁止 iOS 上任何浏览器使用 Web Bluetooth —— Safari 不行,装 Chrome 也不行(iOS 上的 Chrome 内核仍是 WebKit)。这是系统层面的,没有变通办法。iOS 只能走原生 App(CoreBluetooth)。
Can't connect on iPhoneApple blocks Web Bluetooth in every iOS browser — Safari and Chrome alike, since Chrome on iOS is WebKit underneath. It's an OS-level restriction with no workaround. On iOS you need a native app using CoreBluetooth.
容量显示成「剩余 29 MB」0-2 返回的单位是 1KB,不是厂商文档写的 8KB 块。按 KB 算 /1048576 得到的是 GB:这台机实际是 28.8 / 29.1 GB。当成 8KB 块会把容量算大 8 倍,当成字节会算小 1024 倍 —— 两种都不会报错,只会显示一个离谱的数字。
Storage shows "29 MB remaining"The unit in 0-2 is 1KB, not the 8KB blocks the vendor doc claims. Divide by 1048576 and you get GB: this unit really has 28.8 / 29.1 GB. Treating it as 8KB blocks inflates capacity 8×; treating it as bytes shrinks it 1024×. Neither errors — you just get an absurd number.
一列文件名,看不出哪个是刚录的设备不返回录制时间戳(28B 条目里那个 4B 是时长,个别固件才是 unix 时间)。唯一的时间来源是文件名本身note20260830-014512 / call20260830-014512。自己解析出来、按它倒序排,并用前缀区分「随手记 / 通话」两种模式。
A list of filenames with no way to tell which is newestThe device returns no recording timestamp — the 4-byte field in the 28B entry is duration (only some firmware puts a unix time there). The only source of time is the filename: note20260830-014512 / call20260830-014512. Parse it, sort newest-first, and use the prefix to distinguish note vs. call mode.
列表里有这个文件,下载却返回「文件不存在」下载请求 2-2 是 36B,必须一次 GATT write 写完。安卓单次可写 = MTU-3,默认 MTU 23 只能写 20B,被截断后设备解析错文件名。必须先 requestMtu(≥39) 成功再发;MTU 不够就报错,不要做“尽力而为”的降级。
File is in the list but download says “not found”The 2-2 request is 36B and must go out as a single GATT write. On Android one write = MTU-3; the default MTU 23 allows only 20B, so the filename gets mangled. Require requestMtu(≥39) to succeed first — fail loudly rather than silently truncating.
偶尔解析出乱码帧 / CRC 全错一个 notify 可能只含半帧,也可能含多帧,必须按 LEN 跨通知重组。且 AE22 与 AE23 必须各用一个独立缓冲,两路字节交织会互相破坏半帧。
Garbage frames / CRC always failsOne notification may carry half a frame or several frames; reassemble by LEN across notifications. And AE22 and AE23 need separate buffers — interleaving the two streams corrupts partial frames.
文件列表只收到一部分列表以 2-18 为结束标志,但旧固件不发。加一个「最后一帧后空闲约 1.2 秒即收尾」的兜底。
File list arrives incomplete2-18 marks the end, but older firmware never sends it. Add a fallback: finish ~1.2 s after the last frame.
下载中断后重试,文件坏了只有在一个字节都没收到时才允许换候选文件名。已经收到过数据还换名,等于把已下的部分作废。中断后应当用 offset 续传。
Retry after a stall yields a corrupt fileOnly switch candidate filenames when zero bytes were received. Switching after partial data discards what you already have. Resume with offset instead.
实时流存下来播不了实时音频是固定 40B 的 OPUS 裸包,不带容器。要能播必须自己封 Ogg(OpusHead + OpusTags + 带 CRC 的 data page + EOS)。
Saved realtime stream won't playRealtime audio is raw 40-byte OPUS packets with no container. You must wrap it in Ogg yourself (OpusHead + OpusTags + CRC'd data pages + EOS).
识别率偏低本机型录音电平偏低是已知现象,且默认增益已是「高」,没有更高档可调。转写前做峰值归一化能救回内容,但救不回质量——实测把 -22dB 的录音放大 8 倍后,同音字错误明显增多,且说话人分离会直接失效
Recognition accuracy is poorRecording level runs low on this model, and gain is already at “high” by default — there is no higher setting. Peak-normalising before ASR recovers the content but not the quality: amplifying a -22 dB clip 8× produced clearly more homophone errors and broke speaker diarization outright in our tests.
Windows 上连不上,手机能连不要用系统设置配对(见上文红框)。另外 Windows 侧 BLE 吞吐明显低于安卓,实测浏览器下载约 6.7 KB/s
Won't connect on Windows but works on phoneDon't pair via system settings (see the red box above). Windows BLE throughput is also notably lower than Android — we measured about 6.7 KB/s in-browser.

用你自己的 API KEY 跑一遍Run it with your own API key

上面从设备下载下来的录音,可以直接送进云端跑完整流程:转写 → 说话人分离 → 纪要与待办 → 跨录音问答。填入我们发给你的 API KEY 即可,额度和数据都是你自己的,跟其他客户完全隔离。 Take the file you just pulled off the device and run the full pipeline: transcription → speaker separation → summary & action items → cross-recording Q&A. Paste the API key we issued you. Your quota and your data are yours alone, isolated from every other customer.

KEY 只存在你自己浏览器的 localStorage 里,只会作为 Bearer 令牌发给 API。没有 KEY? The key is kept in your own browser's localStorage and only ever sent as a Bearer token. No key yet? 申请试用Request a trial

从上面「文件列表」下载完成后,会自动把那个文件填到这里,不用手动选。 After a download finishes above, that file is queued here automatically — no need to pick it manually.

云端 AI 接口Cloud AI API

设备只负责录音。转写、说话人分离、纪要、待办和跨录音问答全部由这套接口提供。你只需要把音频(或已经转好的文本)POST 过来。 The device only records. Transcription, speaker separation, summaries, action items and cross-recording Q&A all come from this API. You just POST the audio — or the text, if you transcribed it yourself.

POST /rec/v1/upload

上传音频,返回任务 id。WAV / MP3 / OPUS / M4A 都收,服务端自动解码。 Upload audio, get a job id. WAV / MP3 / OPUS / M4A accepted, decoded server-side.

POST /rec/v1/upload
Authorization: Bearer <API_KEY>
X-Filename: call20260209-211442.opus
X-Speaker-Count: 2      # 可选,知道人数就给optional, give it if known
X-Diarization: 1        # 0=关闭说话人分离0 = disable
<binary audio>

-> { "id":"a1b2…", "status":"queued", "durationSec":102 }

POST /rec/v1/ingest

只传文本,不传音频。给需要「音频不出内网」的客户用:在本地转写,只把文字送上云。 Text only, no audio. For customers who require audio to stay on-premise: transcribe locally, send only text.

POST /rec/v1/ingest
Authorization: Bearer <API_KEY>
{ "filename":"call…opus", "durationSec":102,
  "asrEngine":"sensevoice-small-local",
  "spkSource":"diarization",
  "segments":[
    {"start":0,"end":12,"spk":"0","text":"…"},
    {"start":12,"end":26,"spk":"1","text":"…"} ] }

GET /rec/v1/item/{id}

{ "status":"done", "recMode":"call",
  "hasSpeakers":true, "spkSource":"diarization",
  "speakers":[{"spk":"0","label":"…","sec":15,"turns":3}],
  "title":"…", "summary":"…", "todos":["…"],
  "segments":[…], "text":"[0:00] A:…" }

spkSource 会告诉你分离方式:channel=按声道物理分离(准),diarization=声纹算法(可能有误),none=未分离。请把这个差别透传给最终用户 spkSource tells you how separation was done: channel = physical, per-channel (accurate); diarization = voice-print clustering (can be wrong); none. Surface this distinction to end users.

POST /rec/v1/ask

跨全部录音检索问答,回答带出处和时间码。 Retrieval Q&A across all recordings; answers cite source and timecode.

{ "q":"客户对交期有什么要求what did the client say about lead time" }

-> { "answer":"…25 days…",
     "sources":[{"itemId":"…","title":"…",
                 "start":0,"end":20}] }

其他端点Other endpoints

POST /rec/v1/item/{id}/speakers给说话人改名(如「说话人A」→「张总」)。只重渲染,不重跑转写、不二次计费。Rename speakers. Re-renders only — no re-transcription, no extra charge.
POST /rec/v1/retranscribe/{id}?asr=换引擎重转Re-run with a different ASR engine
GET /rec/v1/list列表List recordings
POST /rec/t/{key}/search语音助手插件端点:{"user_text":"…"}{"text":"可直接朗读的一句话"},永远返回 200Voice-assistant plugin endpoint: {"user_text":"…"}{"text":"one speakable sentence"}, always HTTP 200
三种部署形态,按客户合规要求选:
① 全云端 — 音频上传,最省事。
② 边缘转写 + 云端理解 — 音频在客户内网转写,只有文本上云(走 /rec/v1/ingest)。适合律所、医疗、政企。
③ 全私有化 — 整套部署在客户机房。注意摘要与问答需要 LLM,需对接客户已有的模型服务。
Three deployment shapes, pick by the customer's compliance bar:
1. Full cloud — upload audio, simplest.
2. Edge ASR + cloud understanding — transcribe inside the customer's network, send text only (via /rec/v1/ingest). Fits law firms, healthcare, government.
3. Fully on-premise — everything in the customer's racks. Note that summaries and Q&A need an LLM, so you'll integrate with their existing model service.

转写链路是怎么跑的How the ASR pipeline works

从一段 opus 到带说话人的逐句文本,中间有五步。每一步都有个「为什么必须这样」, 下面按顺序讲。这套跑在阿里云百炼 paraformer-v2 上,换别的引擎见下一节。 Five steps take an opus file to speaker-labelled sentences. Each has a reason it must be done that way. This describes the Alibaba Bailian paraformer-v2 path; see the next section to swap in another engine.

opus/wav  →  解码 PCM  →  音量归一化  →  上传对象存储拿公网 URL  →  提交转写  →  轮询取 JSON
                            normalize      object storage URL        submit         poll

① 为什么要过一趟对象存储Why it goes through object storage

文件转写接口只收公网可访问的 URL,不收字节流。 所以本地那份音频必须先落到一个能被对方服务器拉到的地方。我们用阿里云 OSS, 但换成任何一个能出公网 URL 的存储都一样 —— S3、R2、甚至你自己的 nginx 静态目录都行。 The file-transcription endpoint takes a public URL, not raw bytes. So the audio has to land somewhere the vendor's servers can fetch. We use Alibaba OSS, but anything that yields a public URL works — S3, R2, even your own nginx static dir.

🔑 OSS V1 签名只签 /{bucket}/{key},host 不参与签名。 所以同一个签名换任意 endpoint 都有效 —— 我们据此让 PUT 走传输加速端点(国内各地就近接入,实测 0.27~0.6s), GET 走标准端点(服务端回源同速,不产生加速流量费)。 🔑 The OSS V1 signature covers only /{bucket}/{key} — the host is not signed. So one signature works against any endpoint: we PUT via the transfer-acceleration endpoint (nearest PoP, measured 0.27–0.6 s) and GET via the standard one (same speed to the vendor, no acceleration traffic billed).

PUT  https://<bucket>.oss-accelerate.aliyuncs.com/<key>      # 就近上传 / nearest PoP
GET  https://<bucket>.oss-<region>.aliyuncs.com/<key>        # 引擎回源 / vendor fetches
Authorization: OSS <AccessKeyId>:<base64(hmac-sha1(secret, StringToSign))>

StringToSign = VERB + "\n" + Content-MD5 + "\n" + Content-Type + "\n" + Date + "\n"
             + CanonicalizedResource        # = /<bucket>/<key>   ← host 不在里面

⚠️ 音频落在存储上就是一份可下载的个人数据。 生产环境请给这个 bucket 配生命周期规则自动过期,别让它无限堆积 (我们这台试用机是 24 小时清一次,见「隐私与保留期」)。 ⚠️ Once uploaded, that audio is downloadable personal data. Set a lifecycle expiry rule on the bucket in production. Our trial host wipes after 24 h.

② 上传前必须做的两件事Two things to do before uploading

处理Step为什么Why
音量归一化(按峰值算增益)Peak normalisation 录音笔离说话人远时峰值常只有 -20dBFS 以下,直接送去识别,准确率明显下降。 按峰值算一个增益整体放大即可,不要做压缩或 AGC(会把说话人之间的相对音量抹掉)。 Recordings often peak below −20 dBFS; accuracy drops noticeably. Apply one whole-file gain from the peak. Do not compress or AGC — that destroys the relative loudness between speakers.
锁定语种Pin the language 不锁语种时,噪声段或旁边有人说别的语言,模型会把背景说话人转写出来, 而且是另一种语言 —— 它不会报错,只会给你一句通顺但完全不相干的话。 Unpinned, a noisy stretch or a nearby speaker in another language gets transcribed instead — and it never errors, it just returns a fluent, unrelated sentence.

③ 说话人分离:两条互斥的路Speaker separation: two mutually exclusive paths

这是这类产品最值钱的一段,也最容易做错。先看声道数,再决定走哪条。 The most valuable part, and the easiest to get wrong. Branch on channel count first.

输入Input做法Approach
单声道Mono 交给引擎的 diarization_enabled,让它按声纹猜。 ⚠️ 这个开关只对单声道生效,多声道传了也不起作用。 Use the engine's diarization_enabled. ⚠️ It only applies to mono — passing it with multi-channel input does nothing.
双声道(通话模式:一路空气麦、一路振动麦) Stereo (call mode: air mic + bone-conduction mic) 按声道分离,不要用算法猜。硬件已经把两个人分在两条轨上了 —— 空气麦≈本机说话人,振动麦≈对方。分别识别再按每段的能量差归属,比声纹准得多。 Split by channel; don't guess. The hardware already separated them — air mic ≈ near speaker, bone mic ≈ far speaker. Transcribe each and assign by per-segment energy difference. Far more reliable than voiceprints.

🔴 双声道两路必须用【同一个】增益,绝不能各自归一化。 两路的相对能量正是「这句话是谁说的」的判据,分别归一化会把这个信息直接抹掉,串音就分不掉了。 取两路各自所需增益里较小的那个:响的那路不削顶,比例也原样保留。 🔴 Apply one shared gain to both channels, never normalise them separately. Their relative energy is the attribution signal; separate normalisation erases it and crosstalk becomes unseparable. Use the smaller of the two required gains — the louder channel stays unclipped and the ratio is preserved.

const gain = Math.min(peakGain(left), peakGain(right));   // ✅
// const L = normalize(left), R = normalize(right);       // ❌ 相对能量没了 / ratio destroyed

④ 提交与轮询Submit and poll

POST /api/v1/services/audio/asr/transcription
X-DashScope-Async: enable
{ "model": "paraformer-v2",
  "input":  { "file_urls": ["https://<bucket>.oss-<region>.aliyuncs.com/<key>"] },
  "parameters": { "language_hints": ["zh"], "diarization_enabled": true } }

→ { "output": { "task_id": "..." } }
GET /api/v1/tasks/<task_id>      # 轮询到 SUCCEEDED,再去拉结果 JSON

⚠️ 任务成功 ≠ 有内容。见过 SUCCESS_WITH_NO_VALID_FRAGMENT(已计费但一个字没有)。 判成功要看有没有拿到句子,不能只看状态码,否则会把空结果当正常结果写进库。 ⚠️ Success ≠ content. SUCCESS_WITH_NO_VALID_FRAGMENT is billed but empty. Check that sentences came back, not just the status, or you will store empty results as good ones.

接你自己的 ASRPlug in your own ASR

转写引擎是可插拔的。你只要提供一个函数,把音频变成统一格式的句子数组, 其余(归一化、说话人归属、摘要、检索、下载)全部不用动。 The engine is pluggable. Provide one function that turns audio into a normalised array of sentences; normalisation, attribution, summarisation, search and export all stay as they are.

/**
 * @param wavBuf  Buffer   16k/16bit PCM 的 WAV(已归一化)/ normalised WAV
 * @param tag     string   日志用的 id / id for logs
 * @param opts    { diarization: boolean, speakerCount: number }
 * @returns Promise<Segment[]>
 *
 * Segment = {
 *   start:   number,   // 秒 / seconds
 *   end:     number,
 *   text:    string,
 *   speaker: string|null   // "说话人A" 之类;引擎不支持就给 null
 * }
 */
async function asrMyEngine(wavBuf, tag, { diarization = true, speakerCount = 0 } = {}) {
  // 1) 你的引擎要 URL 就先传对象存储;要字节流就直接 POST
  // 2) 把返回结构映射成上面的 Segment[]
  // 3) 失败【抛异常】,不要 return [] —— 见下面第 2 条
  return segments;
}

然后在路由处加一档。现有逻辑是:需要说话人分离 → 走支持它的引擎; 短音频 → 走边际成本为零的那条;都不满足 → 走默认。 Then add a branch in the router. Current logic: needs diarization → engine that supports it; short audio → the zero-marginal-cost one; otherwise → default.

let provider = item.asrProvider || CFG.asrDefault || 'auto';
if (needSpeakers && MY_KEY) provider = 'myengine';
// ...
if (provider === 'myengine') segs = await asrMyEngine(wav, id, { diarization, speakerCount });

三条硬约束(都是踩出来的)Three hard rules (all learned the hard way)

#约束Rule
1要说话人分离,就不能路由到只吐一整段文本的引擎。 有些语音服务只返回一个 final_text,没有 speaker 字段。 把一段 27 秒的双人对话路由给它,返回 200、没有报错、结果里既没有说话人也几乎没有内容 —— 失败是静默的。路由条件里必须显式判断「这个引擎支持不支持分离」。 Never route a diarization job to an engine that returns one flat string. Some services return only final_text with no speaker field. Feed it a 27-second two-person call and you get HTTP 200, no error, no speakers and almost no content — the failure is silent. Make engine capability an explicit routing condition.
2失败要抛异常,不要 catch {} 里置空。 「失败就写 null」再叠加一个「缺了就回落到别的」的前端逻辑,故障会以完全无关的症状出现在用户面前 —— 我们真实遇到过:TTS 限流导致部分音轨缺失,前端自动回落到另一语言的轨, 用户看到的现象是「绘本一会儿中文一会儿英文」,排查方向从一开始就是错的。 任何「失败就置空」的分支,必须打日志 + 在出口处对账。 Throw on failure; never swallow into null inside catch {}. "Null on failure" plus a front-end "fall back to something else if missing" makes faults surface as completely unrelated symptoms. Real case: rate-limited TTS left some tracks missing, the UI silently fell back to another language, and the user reported "the book keeps switching languages". Every fail-to-null branch needs a log line and a reconciliation check at the end.
3加「快速失败」开关前,先确认它不是某条路径的唯一引擎。 我们曾为了不白等跨洋请求加了个 DISABLE_X=1, 结果非中文路径上 X 是唯一的 provider —— 中文一切正常,英文 100% 转写失败, 而用户感受到的只是「按了麦克风没反应」。 Before adding a fail-fast kill switch, check it isn't the only provider on some path. We once set DISABLE_X=1 to avoid waiting on a cross-ocean call; X turned out to be the only provider for non-Chinese input. Chinese worked perfectly, English failed 100 %, and all the user saw was "the mic button does nothing".

前端参考实现:从蓝牙到转写结果Front-end reference: from BLE to transcript

网页端完整流程是这五步。BLE 那半段的命令细节在上面「BLE 协议」一节, 这里讲的是把它接到云端并把结果显示出来的部分。 Five steps end to end. The BLE command details are in the protocol section above; this covers wiring it to the cloud and showing the result.

requestDevice  →  2-0 列目录  →  2-2 下载文件  →  POST /rec/v1/upload  →  轮询 /rec/v1/item/<id>
  连接              list           download          upload                 poll until done

① 上传Upload

// 蓝牙下下来的 blob 直接送走,元信息走 header(比 multipart 简单)
const r = await fetch('/rec/v1/upload', {
  method : 'POST',
  headers: { 'Authorization': 'Bearer ' + KEY,
             'X-Filename'   : encodeURIComponent(name),   // ← 见下面的中文乱码坑
             'Content-Type' : 'application/octet-stream' },
  body   : blob
});
const { id } = await r.json();     // → 拿到 id 就开始轮询

🔴 header 里的中文必然乱码。 Node 和 Python 的 http 层都按 latin-1 解 header,裸 UTF-8 会变成 名片.png → åç.png发送端 encodeURIComponent, 接收端先 URL 解码再尝试还原 UTF-8。 文件名不是装饰 —— call / note 前缀要靠它判断录音模式, 而且设备不返回录制时间戳,文件名是唯一的时间来源。 🔴 Non-ASCII in headers will mojibake. Both Node's and Python's HTTP layers decode headers as latin-1. Percent-encode on send, URL-decode then re-interpret as UTF-8 on receive. The filename matters: the call/note prefix determines recording mode, and the device returns no timestamp — the filename is the only source of time.

② 轮询Poll

// 转写是异步的(排队 → ASR → 摘要),几秒到几十秒,所以轮询而不是傻等
function watch(id, key) {                 // key = 设备上的文件名,见下面第 1 条
  clearInterval(timers[key]);
  results[key] = { id, status: 'queued' };
  render();
  const t0 = Date.now();
  timers[key] = setInterval(async () => {
    const it = await api('/item/' + id);
    results[key] = it;  render();
    if (it.status === 'done' || it.status === 'failed') clearInterval(timers[key]);
    else if (Date.now() - t0 > 180000) { clearInterval(timers[key]); note('还在转,稍后再看'); }
  }, 2500);
}

③ 把结果挂到它自己那一行Attach the result to its own row

别做成「一个全局结果块显示在列表下方」—— 同步第二条就会把第一条顶掉, 而客户在现场最常做的动作恰恰是一条条点开来看。按文件名存成一个字典,渲染时挂到对应行下面。 Don't render one global result block below the list — syncing a second recording replaces the first, and clicking through them one by one is exactly what people do. Key the results by filename and render each under its own row.

var results = {};      // 设备文件名 → 转写结果 / filename → result
var open    = {};      // 设备文件名 → 是否展开 / filename → expanded

rows.forEach(f => {
  html += renderRow(f);                    // 时间 / 时长 / 大小 / 同步按钮
  html += renderResult(results[f.raw], f.raw);   // ← 就挂在它下面
});

三个必踩的坑Three pitfalls you will hit

#Pitfall
1异步回调里不要用下标去索引可能变化的数组。 我们最初在 xhr.onload 里用 files[idx].raw 现取文件名。 上传是异步的(几秒到几十秒),期间文件列表可能已经刷新、下标错位 —— 结果就是第二条的转写结果显示在第一条下面,而后端数据完全正确,极难查。 要用的值在发起那一刻就取出来带走:下标是「位置」,位置会变;值不会。 Never index a mutable array by position inside an async callback. We first read files[idx].raw in xhr.onload. Uploads take seconds to a minute; the list can refresh and indices shift — so the second recording's transcript rendered under the first one, while the backend data was perfectly correct. Painful to debug. Capture the value when you start, not the index.
2多条结果并存时,固定 id 必然撞车。 <audio id="player">#btnDownload 这种在单条时能用, 一旦每行都有一份就全指向第一个。改成 data-* 属性 + 遍历绑定: [data-dl="<文件名>"] Fixed ids collide once several results coexist. <audio id="player"> and #btnDownload work for one result and silently target the first when every row has one. Use data-* attributes and bind by iteration.
3音频和下载不能直接写 <audio src><a href> —— 那样带不上 Authorization。匿名试用时碰巧能过(服务端会落到试用账号), 但填了自己 key 的人会拿到 404,而且完全看不出为什么。统一走带认证的 fetch + blob。 Don't use <audio src> or <a href> directly — they cannot carry the Authorization header. It happens to work anonymously (the server falls back to a trial account) but returns 404 for anyone using their own key, with no visible reason. Always fetch with auth, then use a blob URL.
// ✅ 带认证取音频 / fetch with auth
async function authBlob(path) {
  const r = await fetch(API + path, { headers: { Authorization: 'Bearer ' + KEY } });
  if (!r.ok) throw new Error('HTTP ' + r.status);
  return r.blob();
}
authBlob('/file/' + id).then(b => { audioEl.src = URL.createObjectURL(b); });

// 下载:同一条路,再触发一次 <a download>
function saveAs(blob, name) {
  const u = URL.createObjectURL(blob), a = document.createElement('a');
  a.href = u; a.download = name; document.body.appendChild(a); a.click();
  setTimeout(() => { URL.revokeObjectURL(u); a.remove(); }, 1500);
}

隐私与保留期Privacy and retention

试用场景下客户传的是他自己的真实通话录音。 不清理就等于无限期持有别人的个人数据,客户还不知道 —— 欧洲客户直接踩 GDPR。 这台机器的做法: In a trial, people upload their own real phone calls. Keeping them forever means holding someone else's personal data indefinitely without them knowing — a direct GDPR problem for EU customers. What this host does:

保留期Retention 24 小时,每小时扫一次,到点连音频带文本一起删 24 hours, swept hourly; audio and text both removed
范围Scope 只清试用账号;正式账号一个字节不动 Trial accounts only; permanent accounts untouched
告知Disclosure 转写结果旁边常驻一行提示,并把下载按钮放在最显眼的位置 A persistent notice next to every transcript, with download buttons up front

🔴 删文件要按 id 前缀扫目录,不要枚举后缀。 一条记录散在 audio / text / files / thumbs / preview 五个目录,后缀还不止一种 (.txt .chunks.json .segments.json .wav .opus…)。 枚举漏一个就留下孤儿文件 —— 列表里已经看不见了,磁盘却一直在涨,没人会发现。 🔴 Delete by scanning directories for the id prefix, not by enumerating extensions. One record spans five directories with several suffixes each. Miss one and you leave orphan files: gone from the list, still growing on disk, and nobody notices.

自己搭一套(离线部署)Build it yourself (on-prem)

上面那套云端 API 是我们托管的。如果你的客户要求「资料一个字节都不出内网」,同一套软件可以整包跑在一台小主机上:转写、OCR、说话人分离、语义检索、问答全在本地,不需要任何 API KEY,也不联网
协议是公开的,代码是给你的 —— 你愿意自己开发就自己开发;不想碰硬件,我们把主机装好调好整机发给你。
The cloud API above is hosted by us. If your customer requires that no data leaves their network, the same software runs entirely on one small box: transcription, OCR, speaker separation, semantic search and Q&A all local — no API key, no internet.
The protocol is open and the code is yours. Build on it yourself, or skip the hardware work and we ship you a configured box.

架构Architecture

Node 主服务core   API / 知识库 / 检索 / 问答API / library / retrieval / Q&A
              零依赖,几 MB,必装zero deps, a few MB, required
Python 边车sidecar 本地 ASR / 文档解析 / OCR / 向量local ASR / documents / OCR / embeddings
              约 400MB,纯云端用法可不装~400MB, optional if you use the cloud API
边车只暴露一个窄接口 POST /convert:丢文件进去,吐干净文本出来。录音、视频、Word/PPT/Excel/PDF/EPUB、扫描件、图片都吃。全程无 API KEY、不联网。 The sidecar exposes one narrow endpoint, POST /convert: drop a file in, get clean text out. Audio, video, Word/PPT/Excel/PDF/EPUB, scans and images. No API key, no network.

硬件要求(实测)Hardware (measured)

CPUCPUIntel N97 / N100 / N150,三者差距在 10% 以内,不用纠结 — within 10% of each other, don't overthink it
内存RAM8GB 够用is enough(实测峰值 797MB,16GB 是浪费) (peak measured 797MB; 16GB is wasted)
磁盘Disk模型 268MB + 客户资料268MB of models + customer data
转写速度ASR speed约 4× 实时(27 秒录音 6.5 秒出字)~4× realtime (27s of audio in 6.5s)
别买 J4125 / N5105 —— 没有 AVX2 或差一大截,本地 ASR 会慢到不可用。真正要看的是散热(持续满载会降频)和内存是不是双通道,不是型号数字。 Avoid J4125 / N5105 — no AVX2, or far behind it; local ASR becomes unusable. What actually matters is thermals (sustained load throttles) and dual-channel memory, not the model number.

开源许可(全部核过,可商用打包)Licensing (all verified, safe to ship commercially)

组件Component许可License
sherpa-onnx(ASR) (ASR) · RapidOCRApache-2.0
cndoc · anydoc · pdf-inspector · pdfplumberMIT
pypdfium2BSD-3
GeoNames 地名库place databaseCC BY 4.0 页脚必须署名(attribution required in the footer)
三个不要碰:Firecrawl 主项目是 AGPL-3.0;Dify 改过的 Apache 2.0 不许移除 logo、不许做多租户 SaaS;anydoc 的 ocr 参数必须锁死 'reject' —— 设成 'hosted' 会把客户文件发去 Firecrawl 云端,「数据不出内网」当场破功而且客户不会知道
可以用的白标方案是 AnythingLLM(MIT)。
Three to avoid: the Firecrawl main project is AGPL-3.0; Dify's modified Apache 2.0 forbids removing their logo and forbids multi-tenant SaaS; and anydoc's ocr parameter must be pinned to 'reject' — setting it to 'hosted' ships customer files to Firecrawl's cloud, silently breaking your "nothing leaves the network" promise without the customer ever knowing.
AnythingLLM (MIT) is a safe white-label option.

自己动手会踩到的四个坑Four things that will bite you

Trap做法What to do
转写出日语幻觉、同一句识别两遍顺序不能反:先按 VAD 自然停顿切句识别,再用说话人时间轴给每句打标。反过来(先按说话人切再识别)产生的碎片会让模型开始编。另外一定要锁定语种,否则跨语种幻觉躲不掉。
Japanese hallucinations, the same sentence recognised twiceOrder matters: segment by VAD pauses and recognise first, then label each sentence from the diarization timeline. Doing it the other way (cut by speaker, then recognise) produces fragments the model starts inventing text for. Also pin the language — cross-language hallucination is otherwise unavoidable.
OCR 出来的表格结构没了清洗时用 join_lines=False。那些「接回 PDF 断行」的工具对逐行 OCR 会把「数量:500台 / 单价:199元」合并成一行,表格就毁了。
OCR'd tables lose their structureClean with join_lines=False. Tools meant to rejoin PDF line-wraps will merge "Qty: 500 / Unit: 199" into one line and destroy the table.
中文文件名变成 åç.pngNode 和 Python 的 HTTP 库都按 latin-1 解 header,裸 UTF-8 必乱码。两边都要「先 URL 解码、再试还原 UTF-8」。文件名不是装饰 —— call / note 前缀决定了要不要走说话人分离。
Chinese filenames arrive as åç.pngBoth Node's and Python's HTTP stacks decode headers as latin-1, so raw UTF-8 turns to mojibake. Do "URL-decode, then attempt UTF-8 recovery" on both sides. The filename is not decoration — the call / note prefix decides whether to run speaker separation.
换个说法就搜不到纯关键词检索太脆:搜「交期」找不到「交货期」。用混合检索——精确匹配 0.6 + 向量 0.4,并把摘要/关键词/待办也写成一个可检索片当同义词层。一条都没命中时退回最近几条摘要交给模型判断,别直接返回空。
Rephrase it slightly and nothing comes backKeyword-only retrieval is brittle: "lead time" misses "delivery time". Use hybrid retrieval — exact match 0.6 + vectors 0.4 — and index the summary/keywords/action-items as an extra chunk that acts as a synonym layer. When nothing matches, fall back to the few most recent summaries and let the model judge, rather than returning empty.

合作与定制Work with us

整机 ODM / 贴牌ODM / white-label

外观、包装、固件参数、App 全部可定制。Enclosure, packaging, firmware parameters and app, all customisable.

API 授权API licensing

你有硬件,我们提供转写与理解能力,按用量计费。You have the hardware; we supply transcription and understanding, billed by usage.

私有化部署On-premise

数据不出内网,可交付镜像或预装硬件。Data never leaves the network. Delivered as an image or preloaded hardware.

发邮件咨询Email us kristy@aitoysuppliers.com