使用 Omi Device Go SDK(omidevice)接入 Omi 可穿戴设备:BLE 音频流与流式语音识别实战指南
【免费下载链接】FriendAI that sees your screen, listens to your conversations and tells you what to do项目地址: https://gitcode.com/GitHub_Trending/fr/Friend
导读
omidevice是 Omi 开源项目面向 Go 语言提供的设备侧 SDK,它的职责非常聚焦:为接入 Omi 可穿戴眼镜的 BLE 音频流提供协议辅助函数(UUID 常量、3 字节包头发剥离),并内置一套可选的 BLE 扫描/连接/监听实现与多种流式 STT 引擎封装。本文以 sdks/device/go/README.md 为主干,结合仓库中omidevice包的源码与测试,系统讲解如何无硬件完成协议层开发与测试、如何通过-tags ble启用真实 BLE 能力、如何把 16 kHz 单声道 PCM 音频送入 Deepgram / Parakeet 等流式语音识别服务,并给出各 API 的边界行为与平台限制。读完本文,你将能够在自己的 Go 项目中直接集成 Omi 设备的音频采集与实时转写链路。
一、SDK 定位:协议辅助 + 可选 BLE,而非完整 BLE 客户端
在动手之前,先理解omidevice在整个 Omi 设备 SDK 家族中的位置。根据 sdks/device/README.md,Omi 的完整设备 SDK 只有三套:Python(bleak 扫描/监听 + Opus 解码 + Deepgram)、Swift(CoreBluetooth + 编解码 + Whisper)、React Native(react-native-ble-plx)。其余语言(TypeScript、Go、Rust、C++、Dart)以"便携式协议包"的形式存在,共享同一份 BLE 协议契约,但默认不提供完整的生产级 BLE 客户端。
原因很务实:BLE 栈是操作系统强相关的(CoreBluetooth、BlueZ、WinRT、Web Bluetooth、noble 等),在每种语言里维护一个生产级扫描器是巨大的持续维护面。因此 Go 版 SDK 选择了"UUID 常量 + 包头剥离 + 可选 BLE(-tags ble)"的轻量路线,让使用者可以把平台 BLE 库接到AudioDataUUID上,再统一用这些辅助函数做协议处理。
Go 包的模块声明位于 sdks/device/go/go.mod,模块路径为github.com/BasedHardware/omi/sdks/device/go,Go 版本要求go 1.23.8,运行时依赖只有两个:github.com/gorilla/websocket v1.5.3(流式 STT 的 WebSocket 客户端)和tinygo.org/x/bluetooth v0.15.0(仅 BLE 构建使用)。
二、默认构建(无硬件):协议层开发与测试的第一步
2.1 三条命令,零依赖跑通
omidevice的默认构建不依赖任何 BLE 硬件、API 凭证或网络服务,直接用标准测试命令即可验证:
go test ./... go test -race ./...其中go test -race ./...会运行启用竞态检测的完整测试套件。README 特别强调:这套测试通过内存中的 WebSocket 连接来验证流式转写器的就绪语义(streaming-transcriber readiness),不需要真机、不需要 API Key、也不需要真实网络服务。
这意味着开发者在没有 Omi 设备的情况下,也能对协议层和 STT 封装层做完整的功能与竞态验证。下面展开说明"就绪语义"究竟测的是什么。
2.2 默认构建导出的能力边界
默认构建下,包对外导出:
- 全部 UUID 常量(服务、音频数据、音频编码、电池);
StripPacketHeader(剥离 3 字节 Omi 音频包头);- 三个 STT 引擎辅助函数(Deepgram / Whisper / Parakeet 的流式转写器构造器);
- BLE 相关 API:
Scan/Listen/ListenPayload/ReadCodec。
但 BLE 相关 API 在默认构建下不会碰蓝牙适配器,而是直接返回ErrBLEDisabled。这一点在 ble_stub.go 中实现得极其干净:!ble构建约束下的文件里,scan、listen、readCodec三个未导出函数一律返回ErrBLEDisabled;而 ble_test.go 中的TestScanListenDisabledWithoutBLETag测试则验证了默认构建下Scan、Listen、ListenPayload、ReadCodec四个导出 API 都返回该错误——这保证 CI 和无头(headless)主机可以始终停留在默认构建上,绝不会误碰硬件。
// 默认构建下的预期行为 _, err := omidevice.Scan(ctx, 100*time.Millisecond) // err == ErrBLEDisabled err = omidevice.ListenPayload(ctx, "aa:bb:cc:dd:ee:ff", func(payload []byte) {}) // err == ErrBLEDisabledErrBLEDisabled的定义位于 ble.go:
var ErrBLEDisabled = errors.New("omidevice: BLE disabled; rebuild with -tags ble")三、协议层核心:UUID 常量、编解码 ID 与 3 字节包头
3.1 GATT 服务与特征 UUID
omidevice在 protocol.go 中定义了与 PROTOCOL.md 完全一致的 GATT 契约:
| 角色 | UUID | 说明 |
|---|---|---|
| Omi 服务(Service) | 19b10000-e8f2-537e-4f6c-d104768a1214 | 音频相关服务主 UUID |
| 音频数据流(AudioData) | 19b10001-e8f2-537e-4f6c-d104768a1214 | notify 特征,承载音频帧 |
| 音频编码(AudioCodec) | 19b10002-e8f2-537e-4f6c-d104768a1214 | read 特征,首字节为 CodecID |
| 电池服务(BatterySvc) | 0000180f-0000-1000-8000-00805f9b34fb | 标准电池服务 |
| 电池电量(BatteryLevel) | 00002a19-0000-1000-8000-00805f9b34fb | 标准电池电量特征 |
这些常量与协议契约是固件耦合的——PROTOCOL.md 明确指出,包头大小和编码映射如果变更,必须同步修改每一个设备 SDK。
3.2 CodecID:区分 DevKit 与 Omi CV1 固件的关键
AudioCodec特征的首字节表示音频编码格式(CodecID类型),源码定义了四种取值:
| 常量 | 值 | 含义 |
|---|---|---|
CodecPCM16 | 0 | PCM 16-bit |
CodecPCM8 | 1 | PCM 8-bit |
CodecOpus | 20 | Opus,160 采样/帧 @ 100 fps(DevKit 固件) |
CodecOpusFS320 | 21 | Opus FS320,320 采样/帧 @ 50 fps(Omi CV1 固件) |
其中后两种 Opus 编码解码后的 PCM 契约完全相同,唯一区别是帧时长:DevKit 是 10 ms(160 采样),Omi CV1 是 20 ms(320 采样)。正如 PROTOCOL.md 所警告的:如果解码器固定假设 10 ms 帧,处理 CV1 流时会出现时间错位(mis-time)。编解码/帧对齐的真源(source of truth)是 App 侧的BleAudioCodec(app/lib/backend/schema/bt_device/bt_device.dart)。
值得特别注意的一个常被误解的点:各瘦 SDK 导出的OPUS_FRAME_SAMPLES = 960常量,是opus_decode的解码缓冲区上限,而不是线上的帧大小——真实线上帧是上表中的 160 或 320 采样。Go 包中对应的常量定义在 protocol.go:
PacketHeaderBytes = 3 // 每个音频 notify 包前的包头字节数 PCMSampleRateHz = 16000 OpusFrameSamples = 960 // 解码缓冲区上限,非线上帧大小 PCMChannels = 13.3 StripPacketHeader:音频包解帧入口
BLE 音频数据特征上的 notify 负载格式为:
[3 字节包头][codec payload...]设备 SDK 在 Opus/PCM 解码前必须先剥离前 3 字节(与 sdks/python/omi/decoder.py 的行为一致)。omidevice提供线程安全的纯函数实现:
// StripPacketHeader removes the 3-byte Omi audio header. func StripPacketHeader(packet []byte) []byte { if len(packet) <= PacketHeaderBytes { return nil } out := make([]byte, len(packet)-PacketHeaderBytes) copy(out, packet[PacketHeaderBytes:]) return out }注意两点边界行为(均有测试覆盖):
- 若输入长度 ≤ 3 字节,返回
nil(而不是空切片); - 返回值是新分配的切片,通过
copy拷贝,避免与底层缓冲共享内存。
对应测试在 protocol_test.go:输入{0xaa, 0xbb, 0xcc, 0x01, 0x02, 0x03}会得到{0x01, 0x02, 0x03},而短包返回nil。TestCodecIDs则逐一断言四个 CodecID 的字节值与固件耦合关系。
解码后的 PCM 输出规格是16-bit 小端序、单声道、16 kHz(见 PROTOCOL.md)。
四、BLE 构建(-tags ble):接入真实设备
4.1 构建约束与后端选择
BLE 能力通过构建标签ble启用,底层使用tinygo.org/x/bluetooth的注释)说明了选型理由:相比go-ble/ble和paypal/gatt,tinygo.org/x/bluetooth在 darwin/arm64 桌面上无需额外 CGO 工具链即可干净编译。
go test -tags ble ./... go run -tags ble ./examples/... # 若存在 examples 目录由于 BLE 代码集中在ble_tinygo.go(//go:build ble),而默认桩在ble_stub.go(//go:build !ble),两种构建可以长期共存:CI 与无头主机用默认构建,真机联调用-tags ble构建。
4.2 扫描、连接与监听音频流
README 给出了最核心的使用示例,扫描 5 秒后直接监听第一台设备的音频负载:
devices, err := omidevice.Scan(ctx, 5*time.Second) err = omidevice.ListenPayload(ctx, devices[0].ID, func(payload []byte) { // 此处收到的是已剥离 3 字节包头后的 Opus/PCM 帧 })Device结构体(ble.go)暴露三个字段:
type Device struct { ID string // 适配器地址 / CoreBluetooth 标识符字符串 Name string RSSI int16 }Scan的底层实现(ble_tinygo.go)有几个值得注意的细节:
- 优先使用
ctx.Deadline();若上下文没有截止时间,则以timeout(默认 5 秒)派生超时; - 若两者都有,取更早的截止时间;
- 扫描回调内部用
sync.Mutex保护seenmap,以设备地址去重; - 通过
errCh(容量 1 的缓冲通道)捕获扫描错误,并在ctx.Done()分支主动StopScan()后等待扫描协程退出,避免资源泄漏。
ListenPayload是Listen的封装——同一套连接监听逻辑,只是对每个 notify 自动应用StripPacketHeader:
func Listen(ctx context.Context, deviceID string, onPacket func([]byte)) error { return listen(ctx, deviceID, onPacket, false) } func ListenPayload(ctx context.Context, deviceID string, onPayload func([]byte)) error { return listen(ctx, deviceID, onPayload, true) }监听实现(ble_tinygo.go)的关键链路:
- 校验回调非空、
deviceID非空白; Enable()蓝牙适配器;- 通过
connect辅助函数(先短扫描匹配地址或名称,再Connect)连接设备; DiscoverServices查找ServiceUUID,再DiscoverCharacteristics查找AudioDataUUID;EnableNotifications注册回调——注意回调里先append([]byte(nil), buf...)拷贝一份,因为底层栈可能复用通知缓冲区;- 阻塞直到
ctx.Done(),然后关闭通知并返回ctx.Err()。
connect的匹配逻辑(ble_tinygo.go)会同时尝试按地址(小写比较)与按本地名称(EqualFold大小写不敏感)匹配;若扫描超时仍未找到,最后兜底尝试addr.Set(deviceID)直接解析地址——这正对应 README 中"macOS 设备 ID 是 CoreBluetooth 标识符、不总是经典 MAC 字符串"的说明。
4.3 读取音频编码:ReadCodec
ReadCodec连接设备后读取AudioCodec特征,返回首字节对应的CodecID(ble_tinygo.go)。实现细节:读取到 16 字节缓冲区,若n < 1视为"空编码特征"错误,否则返回CodecID(buf[0])。调用方可用它判断当前固件是 DevKit(20)还是 Omi CV1(21),进而决定帧时长假设。
4.4 与 Python SDK 的镜像关系
README 明确说明:Scan/ListenPayload的语义分别镜像 Python SDK 的print_devices/listen_to_omi(见 sdks/python/omi/bluetooth.py)。这意味着熟悉 Python 设备 SDK 的开发者可以无痛迁移到 Go;如果你需要在 Python 生态中做同样的扫描与监听,可对照该文件查看bleak版本的行为差异。
五、流式 STT 引擎封装:Deepgram / Parakeet / Whisper
5.1 统一的 StreamingTranscriber 接口
STT 层位于 sdks/device/go/omidevice/stt/stt.go,核心是一个极简接口:
type StreamingTranscriber interface { AppendPCM(pcm []byte) error Stop() error } type Handler func(text string)三个引擎统一导出:Deepgram、Whisper、Parakeet。这与 sdks/device/README.md 中"所有语言暴露相同的引擎名"的约定一致。
5.2 Deepgram:连接即就绪
func NewDeepgram(apiKey string, sampleRate int, onTranscript Handler) (StreamingTranscriber, error)实现细节:
apiKey为空直接报错;sampleRate == 0时默认 16000;- 固定拼接 WebSocket 地址:
wss://api.deepgram.com/v1/listen?punctuate=true&model=nova&language=en-US&encoding=linear16&sample_rate=%d&channels=1; - 通过
Authorization: Token <apiKey>请求头鉴权; - 连接建立后立即就绪(
ready.Store(true)),PCM 可以马上发送; - 后台读循环解析 JSON 消息,取
channel.alternatives[0].transcript非空文本回调给onTranscript。
5.3 Parakeet:等待服务端 ready 信号
func NewParakeet(apiURL string, sampleRate int, onTranscript Handler) (StreamingTranscriber, error)与 Deepgram 的最大差异是就绪语义(README 原话):Parakeet 在服务端发送ready之前会忽略 PCM,而 Deepgram 在连接建立后立即接受 PCM。实现上,wsTranscriber.AppendPCM用atomic.Bool的ready标志做门控:
func (t *wsTranscriber) AppendPCM(pcm []byte) error { if t.conn == nil { return fmt.Errorf("not connected") } if !t.ready.Load() { return nil // 未就绪时静默丢弃 } return t.conn.WriteMessage(websocket.BinaryMessage, pcm) }Parakeet 的读循环只有收到{"type":"ready"}消息后才ready.Store(true);此前的 PCM 会被安全忽略而非报错。这一行为在 parakeet_test.go 的TestParakeetReadyWhileAppendingPCM中被专门验证:测试用net.Pipe构造内存 WebSocket,模拟"音频在 ready 消息到达前就绪"的时序,确认AppendPCM不报错、ready 之后 PCM 能原样送达服务端。对应的TestDeepgramStartsReady验证 Deepgram 无需 ready 即可发送。
5.4 ParakeetWSURL:URL 归一化辅助
NewParakeet依赖ParakeetWSURL(apiURL, sampleRate)把任意形式的 API 基地址归一化成 WebSocket 流式端点。其规则(stt.go 与 stt_test.go 的 8 个用例)包括:
| 输入形式 | 归一化结果 |
|---|---|
https://parakeet.example/ | wss://parakeet.example/v3/stream?sample_rate=16000 |
http://parakeet.example:8080 | ws://parakeet.example:8080/v3/stream?sample_rate=16000(http→ws) |
https://parakeet.example/gateway?region=eu | wss://parakeet.example/gateway/v3/stream?region=eu&sample_rate=16000(保留已有查询参数) |
...?sample_rate=8000&token=abc#frag | 替换 sample_rate 为目标值、剥离 fragment |
无协议头parakeet.example/gateway | 自动补https:// |
规律:http/ws归一化为ws,其余(含无协议、https)归一化为wss;路径尾部追加/v3/stream;若已存在sample_rate参数则替换,否则追加;fragment 一律清除。注意原路径中已百分号转义的部分(如gateway%2Fv1)会被保留在RawPath中同步处理。
5.5 优雅结束:finalize 帧与 drain 窗口
Stop()的语义对转写结果完整性至关重要。源码注释揭示了两个引擎的差异(stt.go):
- Deepgram 通过 JSON 控制消息
{"type":"CloseStream"}冲刷并关闭连接; - Parakeet 通过纯文本帧
finalize结束; - 把 Parakeet 的帧发给 Deepgram 会被忽略,导致 socket 关闭后服务端最终结果永远不会吐出。
因此每个转写器保存自己的finalize帧。Stop()发送 finalize 后不会立即关闭 socket,而是等待最多defaultDrainTimeout(2 秒)让读循环收到服务端的收尾结果(或等读循环返回),再执行conn.Close()——避免"刚发完 finalize 就断连丢掉尾包结果"的问题。
5.6 Whisper:注入式 runner 的批量模式
NewWhisper(runner func(pcm []byte) (string, error), onTranscript Handler)是功能门控的:必须注入一个 runner(默认构建不内置本地模型,传入nil会返回错误,见TestWhisperRequiresRunner)。其行为是批量而非流式:内部按 5 秒(16000 * 2 * 5字节 = 16 kHz × 2 字节 × 5 秒)聚合 PCM,达到批次才调用 runner 转写;若 runner 出错,保留已缓冲音频让下一次调用重试同一批样本而不静默丢弃;Stop()时冲刷剩余缓冲并做最后一次转写。
六、已知限制与平台注意事项
README 明确列出了三类限制,使用时必须注意:
权限与构建环境:BLE 功能需要蓝牙权限/适配器,因此 CI 与无头主机应停留在默认构建(不带
-tags ble)。若在无适配器的环境误用-tags ble,adapter.Enable()会返回错误并包装为omidevice: enable adapter: %w。macOS 设备 ID 语义:macOS 上返回的
Device.ID是 CoreBluetooth 标识符字符串,不一定是经典的 MAC 地址(如aa:bb:cc:dd:ee:ff形式)。这正是connect同时支持按名称匹配、并在扫描失败后兜底addr.Set()的原因。ReadCodec 依赖 GATT Read:
ReadCodec依赖对AudioCodec特征的 GATT 读操作;在某些固件上该特征可能仅支持 notify(read-only 之外),导致读失败。这是固件相关行为,升级固件或更换设备型号时需要重新验证。
七、从协议到转写:一条完整的最小链路
把以上内容串起来,一个最小可用的 Omi 设备→Go→流式转写的链路大致如下:
package main import ( "context" "time" "example.com/yourapp/omidevice" "example.com/yourapp/omidevice/stt" ) func main() { ctx, cancel := context.WithCancel(context.Background()) defer cancel() // 1. 扫描设备(默认 5s 超时) devices, err := omidevice.Scan(ctx, 5*time.Second) if err != nil { panic(err) // 默认构建下为 ErrBLEDisabled } if len(devices) == 0 { panic("no device found") } // 2. 读取编码,确认固件类型(DevKit=20 / CV1=21) codec, err := omidevice.ReadCodec(ctx, devices[0].ID) if err != nil { panic(err) } _ = codec // 3. 建立流式转写器(示例用 Deepgram;Parakeet 需等待服务端 ready) tr, err := stt.NewDeepgram("YOUR_API_KEY", 16000, func(text string) { println(text) }) if err != nil { panic(err) } defer tr.Stop() // 4. 监听音频并实时转写(回调里已剥离 3 字节包头) err = omidevice.ListenPayload(ctx, devices[0].ID, func(payload []byte) { // Opus 帧需先解码为 PCM 再送入 tr.AppendPCM; // 若固件输出裸 PCM,则可直接送入 }) if err != nil { panic(err) } }说明:
ListenPayload回调收到的是剥离包头后的原始 codec 负载。若固件编码为 Opus(CodecID 20/21),需先用平台的 Opus 解码器(Python 用opuslib,Go 可用 libopus 绑定)按帧长(160 或 320 采样)解码为 16 kHz 单声道 PCM 再送入AppendPCM;解码缓冲区上限参考OpusFrameSamples = 960。
八、测试策略与代码阅读索引
本项目对omidevice的测试设计非常值得借鉴——它保证了无硬件、无凭证、无网络条件下的全链路验证:
- sdks/device/go/omidevice/ble_test.go:验证默认构建下四个 BLE API 全部返回
ErrBLEDisabled,绝不让 CI 触碰适配器; - sdks/device/go/omidevice/protocol_test.go:验证
StripPacketHeader的边界行为与四个 CodecID 的固件耦合取值; - sdks/device/go/omidevice/stt/stt_test.go:8 组
ParakeetWSURL归一化用例 + Whisper runner 空值校验; - sdks/device/go/omidevice/stt/parakeet_test.go:用
net.Pipe+ 自定义pipeListener构造内存 WebSocket,精确验证 Parakeet 等待 ready、Deepgram 立即可发送的时序语义。
想进一步理解协议整体契约,可阅读 sdks/device/PROTOCOL.md(GATT 表、CodecID 表、音频帧格式、PCM 输出规格)与 sdks/device/README.md(各语言 SDK 定位与 BLE 后端选型);STT 引擎的整体设计见 sdks/device/STT.md,多语言行为一致性对照见 sdks/device/PARITY.md。
九、总结
omidevice用最克制的 API 面解决了 Omi 设备接入 Go 生态的两个核心问题:一是通过构建标签把"可无硬件测试的协议层"与"真实 BLE 后端"干净隔离,让 CI 与开发环境各取所需;二是用统一的StreamingTranscriber接口抹平 Deepgram、Parakeet、Whisper 三套引擎在就绪语义、结束帧与调用方式上的差异。无论是想快速在 Go 服务里消费 Omi 音频流做实时转写,还是想为其他语言移植 Omi 设备协议,这份 SDK 都是一个可靠、可测试、边界清晰的参照实现。
【免费下载链接】FriendAI that sees your screen, listens to your conversations and tells you what to do项目地址: https://gitcode.com/GitHub_Trending/fr/Friend
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考