使用 Omi Device Go SDK(omidevice)接入 Omi 可穿戴设备:BLE 音频流与流式语音识别实战指南
2026/9/17 1:35:33 网站建设 项目流程

使用 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构建约束下的文件里,scanlistenreadCodec三个未导出函数一律返回ErrBLEDisabled;而 ble_test.go 中的TestScanListenDisabledWithoutBLETag测试则验证了默认构建下ScanListenListenPayloadReadCodec四个导出 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 == ErrBLEDisabled

ErrBLEDisabled的定义位于 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-d104768a1214notify 特征,承载音频帧
音频编码(AudioCodec)19b10002-e8f2-537e-4f6c-d104768a1214read 特征,首字节为 CodecID
电池服务(BatterySvc)0000180f-0000-1000-8000-00805f9b34fb标准电池服务
电池电量(BatteryLevel)00002a19-0000-1000-8000-00805f9b34fb标准电池电量特征

这些常量与协议契约是固件耦合的——PROTOCOL.md 明确指出,包头大小和编码映射如果变更,必须同步修改每一个设备 SDK。

3.2 CodecID:区分 DevKit 与 Omi CV1 固件的关键

AudioCodec特征的首字节表示音频编码格式(CodecID类型),源码定义了四种取值:

常量含义
CodecPCM160PCM 16-bit
CodecPCM81PCM 8-bit
CodecOpus20Opus,160 采样/帧 @ 100 fps(DevKit 固件)
CodecOpusFS32021Opus 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 = 1

3.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},而短包返回nilTestCodecIDs则逐一断言四个 CodecID 的字节值与固件耦合关系。

解码后的 PCM 输出规格是16-bit 小端序、单声道、16 kHz(见 PROTOCOL.md)。

四、BLE 构建(-tags ble):接入真实设备

4.1 构建约束与后端选择

BLE 能力通过构建标签ble启用,底层使用tinygo.org/x/bluetooth的注释)说明了选型理由:相比go-ble/blepaypal/gatttinygo.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()后等待扫描协程退出,避免资源泄漏。

ListenPayloadListen的封装——同一套连接监听逻辑,只是对每个 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)的关键链路:

  1. 校验回调非空、deviceID非空白;
  2. Enable()蓝牙适配器;
  3. 通过connect辅助函数(先短扫描匹配地址或名称,再Connect)连接设备;
  4. DiscoverServices查找ServiceUUID,再DiscoverCharacteristics查找AudioDataUUID
  5. EnableNotifications注册回调——注意回调里先append([]byte(nil), buf...)拷贝一份,因为底层栈可能复用通知缓冲区;
  6. 阻塞直到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)

三个引擎统一导出:DeepgramWhisperParakeet。这与 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.AppendPCMatomic.Boolready标志做门控:

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:8080ws://parakeet.example:8080/v3/stream?sample_rate=16000(http→ws)
https://parakeet.example/gateway?region=euwss://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 明确列出了三类限制,使用时必须注意:

  1. 权限与构建环境:BLE 功能需要蓝牙权限/适配器,因此 CI 与无头主机应停留在默认构建(不带-tags ble)。若在无适配器的环境误用-tags bleadapter.Enable()会返回错误并包装为omidevice: enable adapter: %w

  2. macOS 设备 ID 语义:macOS 上返回的Device.ID是 CoreBluetooth 标识符字符串,不一定是经典的 MAC 地址(如aa:bb:cc:dd:ee:ff形式)。这正是connect同时支持按名称匹配、并在扫描失败后兜底addr.Set()的原因。

  3. ReadCodec 依赖 GATT ReadReadCodec依赖对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),仅供参考

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询