FMO(NFM Over Internet) 语音数据开放协议
为什么要开放协议?
因为业余无线电的精神,在于"看得懂、玩得转"。我们开放这份语音数据包格式,核心期望是让大家能开发出兼容网络——不只读懂协议,而是能够基于它造出自己的设备、客户端、授权模式和网络,让网络由参与者共同演化。另外,随着阶段的发展,FMO也进入了第二阶段:协议开放期。
为什么会有厂家标识(Vendor)?
我们维护厂家标识(vendor),更多是为了让大家都遵守同一种语音编码规则。 一份共同的线格式,是不同设备、不同服务器之间能够互通的前提。它是一份"共同语言",不是一道准入壁垒。
如果你是设备开发者
-
我们希望你有自己的主张:
FMO 开发者不会替你解决网络兼容问题。我们已经公布了所有设计思想和文档,当前阶段,您完全可以参考这些,搭建您自己的网络。当然这里会有一些门槛,这些需要您自己设计,而不是CCCV。
-
请务必尊重每个服务器:
你应自己发行一个信任根与互联网络,能否被接纳,取决于每一个服务器管理员们,而不是我这种设备的开发者。 因为我们从没声明过,也从没要求过大家建立服务器,大家只是拿到了设备,看了我们的文档,觉得理念相符,就建立了服务器。另外,管理员们从来都不是 FMO 项目开发者的的私有资产,他们不是客户,也不是员工,他们有权、有能力、也有自己的想法。大家只是因为共同的理念在一起,而不是因为我们要求他们在一起。
如果你的设备做得优秀,思想先进,相信每一位网络管理员都会欢迎它,届时互联经由管理员们自然就实现了。
当然,你可以开发一个运行于自己的服务器端专有设备,这比发行一个根要轻松一些,我们认为这是良性的,干净的设计。
但如果只是想做一个"虚拟客户端",借 FMO 的私钥接入网络,我们不推荐这种做法;若你仍选择它,请对自己的所有行为负责 参考文章:关于提取设备私钥的温和提醒。
我们真心希望你的设备,能够以良好的信任,优秀的设计,赢得所有管理员的欢迎,当他们把您的网络合并进他们的的服务器时,相信你的喜悦会远胜于“只是做了一个虚拟客户端”。
开放格式,不意味着开放一切
动手之前,请了解几点期望与边界:
-
身份仍受 PKI 系统约束:
即使你有能力开发兼容语音协议设备,但不意味着你可以畅通无阻的行走在FMO的各个服务器之间,目前FMO网络里真实的身份要求仍然是强制的。你应当自己去赢得管理员们的信任,发行自己的信任根,或让管理员们接受你的服务器理念,或和他们沟通,或让他们自然接受你的设计。总之做好自己的审核,让他们主动接纳你吧。
-
帧结构由 FMO 项目方主导:
本协议定义的帧结构不接受外部 PR 变更申请,演进建议请通过 FMO 项目方渠道提出,保证全网互操作性。
-
兜底机制始终在场:
爱好者们的服务器可能会部署 FAS 审计自动拦截身份冒用,这是FMO的规则,您可以设计自己的身份方案,但请不要试图绕过 FAS 审计,这是对服务器们的尊重,也是对自己的负责,另外服务器管理员拥有自主管理的黑名单;如果出现了严重的违规行为,我们也会对严重越界行为的用户启动数字证书吊销(CRL)。
-
参与者的责任:
如果你是服务器管理员,想桥接外部源,请对你的服务器输入源负责——接入你网络的音频与身份,是你需要把关的第一道门,如果你的服务器出现了问题,第一责任人依然是你自己。
我们最终期望的,是一个平行的网络:
不是一棵中心的树,而是一片由热心的管理员们各自搭建、又彼此融合的网络。管理者有自己的主张,设备有自己的设计,大家在同一个语音编码规则下自由互通。
我们欢迎每一个热爱钻研的人。你探索、你学习、你为自己的行为负责——这正是 FMO 想传递的业余无线电精神。
本文定义语音数据格式,供实现互通的设备/服务器参考。所有多字节字段均为小端序。文档以一条语音消息从外到内逐层展开:消息包 → 传输帧 → 编码语音帧 → 编码载荷。
1. 协议总览
消息包(≤1400B)
├─ 消息头(固定 64B)
└─ 传输帧 × N(N 个编码语音帧聚合)
├─ 传输帧头(8B)
└─ 编码语音帧
├─ 编码语音帧头(8B)
└─ 编码载荷
├─ OPUS 帧 (变长,约 40-60B / 40ms)
└─ RADPCM 帧 (固定 328B / 80ms)
发送端:模拟音频 → 采样 → 编码(OPUS/RADPCM)→ 编码语音帧 → 传输帧 → 消息包 → 网络发布
接收端:网络接收 → 解析消息包 → 拆分传输帧 → 解析编码语音帧 → 解码 → 模拟音频输出
公共音频参数:采样率 8000 Hz,16-bit 有符号,单声道。
2. 消息包
2.1 语义
一条消息包是语音数据的基本传输单元,携带同一发送者的一段连续语音。包头为固定 64B,其后为 1..N 个传输帧(见 §3)。
2.2 消息头(64B)
业务头 45B + 扩展区 19B:
| 偏移 | 大小 | 字段 | 说明 |
|---|---|---|---|
| 0 | 2 | version |
包版本,当前为 1 |
| 2 | 4 | vendor |
数据包厂家标识(0=FMO),见 §2.3 |
| 6 | 4 | UID |
发送者用户 ID |
| 10 | 12 | callsign |
呼号,不足补 0 |
| 22 | 4 | streamBeginUTC |
语音流起始时间(UTC ms) |
| 26 | 4 | timestamp |
本包生成时间(UTC ms) |
| 30 | 4 | len |
整个消息包长度(含头部) |
| 34 | 2 | frameNum |
本包内传输帧个数 |
| 36 | 4 | checkSum |
CRC32,仅覆盖帧区 |
| 40 | 1 | smeter |
S 表强度 |
| 41 | 4 | srvUID |
当前连接服务器 UID |
| 45 | 19 | reserved |
扩展区,总头长固定为 64B |
2.3 厂家标识(vendor)
vendor 字段标识数据包的生产厂家,用于设备识别与溯源。发放区间:
| 区间 | 类别 | 获取方式 |
|---|---|---|
0x0000 ~ 0x0FFF |
保留区(FMO 项目方) | 不开放,他人不得使用 |
0x1000 ~ 0x1FFF |
实验区 | 自由取用,不登记、不审核 |
0x2000 ~ 0x2FFF |
软件区 | 自由取用,不登记、不审核 |
0x3000 ~ 0xFFFFFFFF |
正式区 | 经 fmo-raw-vendor-id 仓库 PR 申请审核后登记 |
- 软件实现(PC/Web/SDK 等无硬件载体)请使用软件区;实验/原型项目请使用实验区,无需申请;自由取用不构成 FMO 的 ID 分配,不具权威,ID 冲突由 FMO 项目方裁决。
- 严禁使用保留区(
0x0000 ~ 0x0FFF)或冒用已登记正式 ID(0x3000起及注册表中已有条目)。 - 正式区注册按申请顺序分配,要求提供硬件实拍照片(含手写日期+呼号纸条)等诚意证明。
- 所有 vendor ID 由 FMO 项目方分配,厂商自行分配的 ID 无效。
- vendor 仅用于设备识别/溯源,不参与鉴权(鉴权依赖证书体系)。
声明:厂家编码分配仅为开放登记,不构成我方对申请厂家及其产品的任何认可、背书或保证。我方不担保注册厂家的设备体验、协议实现正确性、安全性或与网络的互通质量,相关责任由厂家自行承担;对严重违反协议或损害通联网络的厂家,我方保留拒绝注册或撤销编码的权利。
3. 传输帧
3.1 承载语义
一个传输帧承载一个编码语音帧,即一次编码周期的输出,对应固定时长的语音:OPUS 帧为 40ms、RADPCM 帧为 80ms。传输帧是消息包与编码语音帧之间的传输层单元,第一个传输帧紧贴消息头之后。
3.2 帧结构(8B 头)
| 偏移 | 大小 | 字段 | 说明 |
|---|---|---|---|
| 0 | 2 | index |
帧序号,从 1 开始递增 |
| 2 | 2 | len |
含本帧头在内的长度 |
| 4 | 4 | reserved |
保留 |
| 8 | - | data |
编码语音帧载荷 |
3.3 聚合语义
- 发送端把连续产生的编码语音帧依次追加为传输帧;当追加后超过
MTU = 1400B,或聚合时长达到 250ms 时,结束当前消息包并发布。 - 因此一个消息包通常含 ≤6 个 OPUS 帧(40ms/帧)或 ≤3 个 RADPCM 帧(80ms/帧)。
- 接收端按
frameNum与index将消息包拆回逐个传输帧,依序解码播放;聚合超时机制保证语音不因攒包而额外延迟。
4. 编码语音帧
4.1 语义
编码语音帧是一次编码的输出,独立携带 compressMode 声明编码类型,并自带长度,接收端可据此与解码器解耦。
4.2 帧结构(8B 头)
| 偏移 | 大小 | 字段 | 说明 |
|---|---|---|---|
| 0 | 1 | compressMode |
编码类型,见下表 |
| 1 | 2 | len |
含头在内的整个编码语音帧长度 |
| 3 | 5 | reserved |
保留(编码参数扩展位) |
| 8 | - | raw |
编码载荷起始 |
4.3 编码类型(compressMode)
| 值 | 编码 |
|---|---|
| 0 | PCM(预留,不用于网络传输) |
| 1 | OPUS |
| 2 | RADPCM |
5. 编码载荷(raw)
5.1 OPUS 帧
OPUS 编码器的直接输出,无额外包头,长度变长(编码参数见 §6.1)。
5.2 RADPCM 帧(固定 328B)
8B 帧头 + 320B 数据区:
| 偏移 | 大小 | 字段 | 说明 |
|---|---|---|---|
| 0 | 2 | frame_index |
帧序号(0 起,uint16_t 范围内循环) |
| 2 | 2 | recover_pcm |
编码开始时预测器值 |
| 4 | 1 | step_index |
编码开始时步长索引(0-88) |
| 5 | 1 | reserved |
保留 |
| 6 | 2 | adpcmBytes |
数据区有效字节数(=320) |
| 8 | 320 | 数据区 | 每字节 2 个 4-bit 样本:高 nibble 为第 1 个,低 nibble 为第 2 个;共 640 样本(80ms) |
兼容性注记:早期实现的
adpcmBytes为 8-bit 字段(线值因宽度截断实际为 64,且帧头含未初始化填充字节)。新实现按 16-bit 读写,并兼容旧格式包(以其低字节判定)。建议新实现直接使用 16-bit 语义。
recover_pcm 与 step_index 组成帧恢复点:丢包后解码器据此重置 IMA 编解码状态(见 §6.2)。
6. 编码规范
6.1 OPUS
| 参数 | 值 |
|---|---|
| 采样率 / 通道 | 8000 Hz / 1 |
| 应用模式 | VoIP(OPUS_APPLICATION_VOIP) |
| 比特率 | 自动(OPUS_AUTO) |
| 复杂度 / 信号 | 4 / 语音(OPUS_SIGNAL_VOICE) |
| VBR / VBR 约束 | 启用 |
| 帧长 | 40ms(320 样本 / 640B PCM) |
| 最大带宽 | 超宽带(受 8kHz 采样率限制,实际 ≤4kHz) |
自动比特率推导:
bitrate = 60 × Fs / frame_size + Fs × channels
= 60 × 8000 / 320 + 8000 × 1
= 1500 + 8000 = 9500 bps(约 1.19 KB/s)→ 每帧约 47.5B,VBR 下典型 40-60B
6.2 RADPCM
- 每帧 640 样本(80ms,1280B PCM)→ 320B ADPCM,压缩比 4:1(不计帧头)。
- 直流抑制(编码器侧):
y[n] = x[n] - x[n-1] + R·y[n-1],R = 0.999(Q15 32735),截止约 1.3Hz。 - 丢包恢复(解码器维护
last_frame):
1. last_frame == 0xFFFF(首帧)→ 用帧恢复点重置状态
2. frame_index == 0 → 视为新语音序列开始,重置状态
3. frame_index != last_frame+1 → 检测到丢包,用帧恢复点重置状态
4. 其余 → 连续帧,正常解码
-
IMA 核心算法:
- 89 级步长表
IMA_STEP_TABLE:
7, 8, 9, 10, 11, 12, 13, 14, 16, 17, 19, 21, 23, 25, 28, 31, 34, 37, 41, 45, 50, 55, 60, 66, 73, 80, 88, 97, 107, 118, 130, 143, 157, 173, 190, 209, 230, 253, 279, 307, 337, 371, 408, 449, 494, 544, 598, 658, 724, 796, 876, 963, 1060, 1166, 1282, 1411, 1552, 1707, 1878, 2066, 2272, 2499, 2749, 3024, 3327, 3660, 4026, 4428, 4871, 5358, 5894, 6484, 7132, 7845, 8630, 9493, 10442, 11487, 12635, 13899, 15289, 16818, 18500, 20350, 22385, 24623, 27086, 29794, 32767-
16 项索引表
IMA_INDEX_TABLE = {-1,-1,-1,-1, 2, 4, 6, 8, -1,-1,-1,-1, 2, 4, 6, 8}。 -
编码:
diff = pcm - predictor,按step = STEP_TABLE[step_index]逐级比较生成 4-bit 码(bit3 符号,bit0-2 增量),delta自step>>3基线起累加,predictor、step_index限幅(-32768..32767、0..88)后更新。 -
解码:由 4-bit 码重建
delta(step>>3基线 + 各 bit 增量),更新predictor后输出。
- 89 级步长表
说明:发送/接收端可另做音频滤波等增强处理,不影响上述线格式与互通。
7. 尺寸速查
| 项 | 字节数 |
|---|---|
| 编码语音帧头 | 8 |
| 传输帧头 | 8 |
| 消息包头 | 64 |
| OPUS 帧载荷 | 约 40-60(变长) |
| RADPCM 帧载荷 | 328(固定) |
RADPCM 帧独占一条消息时总尺寸:64 + 8 + 8 + 328 = 408B / 80ms;OPUS 约 130B / 40ms。实际多帧聚合共享消息头,单帧摊薄后更小。
8. PTT冲突控制
8.1 设计原则
对讲机通信本质上是半双工的:同一信道在任意时刻只应有一个发送者,否则语音会互相覆盖(混叠)。FMO 把这种约束从射频链路延伸到了网络链路,通过三层机制保证多设备、多服务器场景下通联的公平与有序:
| 层次 | 机制 | 作用范围 |
|---|---|---|
| 信道感知 | 占用检测 | 设备 ↔ 本地对讲机 |
| 方向互斥 | 单一数据方向通道 | 设备内部收发 |
| 网络仲裁 | 路由抢占裁决 | 设备 ↔ 设备 / 跨服务器 |
目标是:谁先开口谁优先,但任何一方都不能无限期独占信道;被更高优先级打断的一方应当明确感知到"台被抢断"。
8.2 信道占用检测
- 信道被占用时,设备不键控发射,即使本地有下行语音需要转发到 RF;
- 同时向用户提示"通道繁忙,请停止发射"并播放忙音,以固定间隔重复提示,避免刷屏。
本地发射请求 → 信道占用? ──是──→ 抑制 PTT,提示"通道繁忙"
│
└─否──→ 键控 PTT,开始发射
该机制属于物理层的冲突避免,防止设备在他人占台时强行插入射频信道。
8.3 半双工通道互斥
设备内部维持单一数据方向,同一时刻只允许「本机→网络」或「网络→本机」之一进行:
- 当前方向仍有数据在途时,反向请求被拒绝;
- 当前方向超过 1s 无数据,才允许切换到反方向。
数据方向 ⇄ RADIO_TO_NETWORK / NETWORK_TO_RADIO 二选一
切换条件:反向有数据 && 当前方向空闲超过 1s
该机制在设备侧杜绝了自身收发混叠,等效于把对讲机的半双工特性平移到网络链路。
8.4 网络路由仲裁
核心思想:任意时刻全网只有一个"当前讲话者",由各设备对路由的竞争结果决定。路由携带两个属性:占用者的身份(UID,见 §2.2)与占用窗口。窗口为 1500ms,窗口内路由不可被轻易切换,除非后者拥有更高优先级。
路由仲裁规则(依据消息头 §2.2 的 streamBeginUTC 与 UID 字段):
| 情形 | 裁决 |
|---|---|
同 UID |
续占,刷新占用窗口 |
| 当前路由已超时(1500ms) | 新包占用 |
新包 streamBeginUTC 更早(差值 ≤ 2s) |
抢占 |
新包 streamBeginUTC 相同且 UID 更小 |
抢占 |
| 其余情况 | 拒绝 |
1. UID 相同 → 续占,刷新窗口
2. 路由超时 → 新包占用
3. streamBeginUTC 更早(≤2s) → 抢占
4. streamBeginUTC 相同 && UID 更小 → 抢占
5. 否则 → 拒绝
两点说明:
- 2s 上限:只有"起始时间更早"(而非更晚)的流才允许抢占,且差值不得超过 2s。这防止因网络传输延迟,使一条"出生更晚"的流反而以更大时差抢断当前讲话者,造成播放乱序。
- 被抢占方行为:当设备发现自己正占用的路由被他人抢占,应立即停止上行,并向用户提示"通道繁忙"——讲话被打断的用户能够明确感知"我的台被抢了"。
仲裁完全基于消息头既有字段,不引入任何协议扩展;所有互通设备/服务器必须按同一套规则裁决,才能保证全网对"当前讲话者"的认知一致。
8.5 上行时长限制
为防止单一方长时间独占信道,单次连续上行设有时长上限:
- 默认 60s;
- 可配置为 0(不限)、30、60、90、120s;
- 达到上限后强制停止上行,并提示用户。
该限制保证语音信道在多人通联中能够流转,属于"防独占"的兜底手段。
8.6 字段关联
PTT 冲突控制复用的消息头字段(见 §2.2):
| 字段 | 用途 |
|---|---|
UID |
路由占用者身份、仲裁判定与平局裁决 |
streamBeginUTC |
语音流起始时间,仲裁的时序依据 |
实现互通时无需为冲突控制扩展任何新字段,仅需在设备/服务器侧按 §8.2 ~ §8.5 的规则实现,即可获得全网一致的行为。