SymbolStream Protocol v2

March 28, 2026 · View on GitHub

Framing specification for streaming raw voice codec parameters (IMBE, AMBE+2, AMBE, etc.) from trunk-recorder to external consumers via TCP or UDP.

Status: Draft specification. The current plugin (mqtt_dvcf) implements v2 binary framing. See §13 for the legacy v1 format.


Contents

  1. Overview
  2. Transport
  3. Binary Format
  4. JSON Format
  5. Message Types
  6. Codec Type Registry
  7. Codec Parameter Tables
  8. C Reference Structures and Examples
  9. File Format
  10. Receiver Guide
  11. Bandwidth
  12. Compatibility
  13. Version 1 Legacy Reference

1. Overview

The symbolstream plugin taps trunk-recorder's voice_codec_data() callback and forwards pre-vocoder codec parameters in real time. Where simplestream sends decoded PCM audio, symbolstream sends the raw codec codewords — IMBE u[0..7] for P25 Phase 1, AMBE+2 frames for P25 Phase 2 and DMR, etc.

This document specifies protocol version 2. It improves on v1 by:

  • Adding an 8-byte versioned header to every binary frame (enables resync, version detection)
  • Carrying all metadata (timestamps, call IDs, codec type, FEC errors) in-band
  • Separating binary and JSON modes cleanly — no more hybrid JSON+binary frames
  • Supporting all current codec types and providing a registry for future codecs
  • Including call lifecycle events (call_start, call_end) in both modes

2. Transport

TCP (recommended): The plugin connects to the receiver. One long-lived connection per configured stream. Messages arrive in order; calls may be interleaved on one connection.

UDP: One datagram per message. Simpler, fire-and-forget. No reliability guarantee.

Default port: 9090 (configurable per stream).

The framing format is identical for TCP and UDP. On UDP the header magic bytes serve as a per-datagram sanity check.


3. Binary Format

3.1 Frame Header (8 bytes, every message)

 Offset  Size  Type    Field
 0       1     uint8   magic[0] = 0x53  ('S')
 1       1     uint8   magic[1] = 0x59  ('Y')
 2       1     uint8   version  = 0x02
 3       1     uint8   msg_type
 4       4     uint32  payload_len  (little-endian; bytes following the header)

The 2-byte magic SY (0x5359) is a resync anchor. On a corrupted stream, scan for the next 0x53 0x59 0x02 triplet.

Receivers must skip messages with unknown msg_type by reading and discarding payload_len bytes — do not abort the connection.

3.2 CODEC_FRAME (msg_type = 0x01)

One decoded voice frame (20 ms at 50 fps for IMBE and AMBE variants).

 Offset  Size  Type      Field
 0       4     uint32    talkgroup_id
 4       4     uint32    src_id         (source radio ID; 0 if unknown)
 8       4     uint32    call_id        (links to CALL_START.call_id; 0 if not tracked)
 12      8     uint64    timestamp_us   (µs since Unix epoch; 0 if unknown)
 20      1     uint8     codec_type     (see §6)
 21      1     uint8     param_count    (number of uint32 codec params that follow)
 22      1     uint8     errs           (FEC error count for this frame)
 23      1     uint8     flags          (bit 0: silence/null frame; bits 1–7: reserved, set 0)
 24      N×4   uint32[]  codec_params   (param_count values, each little-endian)

Total size for IMBE (param_count=8): 8 + 24 + 32 = 64 bytes Total size for AMBE+2 (param_count=4): 8 + 24 + 16 = 48 bytes

All codec_param values are FEC-decoded uint32 (little-endian, lower bits significant), exactly as provided by trunk-recorder's voice_codec_data() callback.

3.3 CALL_START (msg_type = 0x02)

Sent when a new call begins on a monitored talkgroup.

 Offset  Size  Type      Field
 0       4     uint32    talkgroup_id
 4       8     uint64    frequency_hz   (RF frequency in Hz)
 12      8     uint64    timestamp_us   (µs since Unix epoch)
 20      4     uint32    call_id        (session-unique; monotonically increasing, wraps at 2³²)
 24      1     uint8     system_name_len  (0 = no system name)
 25      N     uint8[]   system_name    (UTF-8, not null-terminated; N = system_name_len)

call_id is assigned by the plugin. Receivers should create a call context keyed on call_id when this message arrives, and release it on the matching CALL_END.

3.4 CALL_END (msg_type = 0x03)

Sent when a call terminates.

 Offset  Size  Type      Field
 0       4     uint32    talkgroup_id
 4       4     uint32    call_id
 8       4     uint32    src_id         (final/dominant source radio ID)
 12      8     uint64    frequency_hz
 20      4     uint32    duration_ms
 24      4     uint32    error_count    (total FEC errors across the call)
 28      1     uint8     encrypted      (0 = no, 1 = yes)
 29      1     uint8     system_name_len
 30      N     uint8[]   system_name    (UTF-8)

3.5 HEARTBEAT (msg_type = 0x04)

payload_len = 0, no payload. Sent periodically (default: 30 s) by the plugin to detect dead connections. Receivers should reset a watchdog timer on receipt.

3.6 CALL_METADATA (msg_type = 0x05)

Optional per-call metadata as a JSON payload. Enables .dvcf files to be self-contained for dataset building without external talkgroup databases.

 Offset  Size  Type    Field
 0       N     char[]  UTF-8 JSON object (no null terminator; N = payload_len from header)

The JSON object contains call-level metadata from trunk-recorder's Call_Data_t. All fields are optional — omit absent fields rather than setting them to null. Receivers must ignore unknown keys.

Fields:

KeyTypeDescription
tg_tagstringHuman-readable talkgroup name
tg_alpha_tagstringShort alphabetic talkgroup label
tg_groupstringDomain category (e.g., Fire, Police, EMS)
tg_descriptionstringFull talkgroup description
signalfloatSignal level (dBFS)
noisefloatNoise floor (dBFS)
freq_errorintFrequency error (Hz)
spike_countintSpike count
emergencyboolEmergency call flag
priorityintCall priority level
phase2_tdmaboolP25 Phase 2 TDMA mode
tdma_slotintTDMA slot (0 or 1)
patched_tgsint[]Cross-patched talkgroup IDs
src_listobject[]Transmission source list (see below)

src_list entry fields:

KeyTypeDescription
srcintSource radio ID
timeintTransmission start time (epoch)
posfloatPosition within call (seconds)
emergencyintEmergency flag (0 or 1)
signal_systemstringSignal system identifier
tagstringUnit/radio tag (e.g., "Engine 5")

Example:

{
  "tg_tag": "Fire Dispatch",
  "tg_group": "Fire",
  "signal": -42.5,
  "noise": -110.2,
  "emergency": false,
  "src_list": [
    {"src": 1234567, "time": 1711234567, "pos": 0.0, "emergency": 0, "signal_system": "", "tag": "Engine 5"}
  ]
}

In a .dvcf file, CALL_METADATA is typically emitted after the last CODEC_FRAME and before CALL_END. However, receivers must dispatch on msg_type, not position — the message may appear at any point in the stream.


4. JSON Format

JSON mode uses length-prefixed framing: each message is a 4-byte LE uint32 length followed by a UTF-8 JSON object.

 Offset  Size  Type    Field
 0       4     uint32  json_len  (little-endian; bytes of the JSON object)
 4       N     char[]  JSON object (UTF-8, no null terminator)

Unlike v1, codec parameters are carried inline as a JSON integer array ("params"). There is no binary data after the JSON object. This makes JSON mode fully self-contained and parseable by any standard JSON library.

4.1 codec_frame

{
  "v": 2,
  "type": "codec_frame",
  "tg": 9170,
  "src": 1234567,
  "call_id": 42,
  "ts": 1711234567890123,
  "codec": 0,
  "errs": 0,
  "flags": 0,
  "params": [40960, 4096, 12288, 8192, 16384, 2048, 1024, 512]
}
FieldTypeDescription
vintProtocol version (2)
typestring"codec_frame"
tgintTalkgroup ID
srcintSource radio ID (0 if unknown)
call_idintCall identifier (0 if not tracking)
tsintTimestamp in µs since Unix epoch (0 if unknown)
codecintCodec type (see §6)
errsintFEC error count for this frame
flagsintBitmask: bit 0 = silence frame
paramsint[]Codec parameters (FEC-decoded uint32 values)

4.2 call_start

{
  "v": 2,
  "type": "call_start",
  "tg": 9170,
  "call_id": 42,
  "freq": 855737500,
  "ts": 1711234567890123,
  "sys": "butco"
}
FieldTypeDescription
tgintTalkgroup ID
call_idintSession-unique call identifier
freqintRF frequency in Hz
tsintStart timestamp (µs since epoch)
sysstringSystem short name (may be absent)

4.3 call_end

{
  "v": 2,
  "type": "call_end",
  "tg": 9170,
  "call_id": 42,
  "src": 1234567,
  "freq": 855737500,
  "dur_ms": 4500,
  "errs": 3,
  "enc": false,
  "sys": "butco"
}
FieldTypeDescription
tgintTalkgroup ID
call_idintMatches the call_start call_id
srcintFinal/dominant source radio ID
freqintRF frequency in Hz
dur_msintCall duration in milliseconds
errsintTotal FEC errors across the call
encboolTrue if the call was encrypted
sysstringSystem short name (may be absent)

4.4 heartbeat

{"v": 2, "type": "heartbeat"}

5. Message Types

Binary valueJSON typeDescription
0x01codec_frameOne voice codec frame
0x02call_startNew call beginning
0x03call_endCall terminated
0x04heartbeatKeep-alive
0x05call_metadataCall-level metadata (JSON)
0x06audio_dataOpaque audio blob (see AVCF spec)
0x07–0xFFReserved; skip via payload_len

6. Codec Type Registry

All codec_param values are the raw, FEC-decoded uint32 words from trunk-recorder's voice_codec_data() callback, in the order provided. Bit semantics are defined by each codec's standard; symbolstream does not transform them.

ValueCodecProtocolparam_countNotes
0IMBEP25 Phase 18u[0..7], widths: 12×4 + 11×3 + 7 bits (see §7.1)
1AMBE+2P25 Phase 24
2AMBEDMR4
3AMBED-STARvariableAMBE2400
4AMBEYSF Full8Same layout as IMBE
5AMBEYSF HalfvariableAMBE2250
6AMBE+2NXDN4
7–127Reserved for future Tier II
128Codec2variableFuture
129MELPevariableFuture
130+Reserved

When receiving a codec type not in this table, use param_count to consume the correct number of uint32 params and continue. Do not abort.



7. Codec Parameter Tables

7.1 IMBE — P25 Phase 1

IMBE (Improved Multi-Band Excitation) encodes a 20 ms voice frame as 88 information bits, divided into 8 FEC-protected groups that produce 8 codewords u[0..7] after decoding. These are the values in codec_params[] when codec_type = 0.

Each u[i] is carried as a uint32_t (4 bytes, little-endian). Only the lower N bits are significant; upper bits are zero-padded by trunk-recorder.

Codeword bit widths and content:

IndexBitsContent
u[0]12Fundamental frequency index (bits 11–7, 5 bits → ~69–220 Hz pitch period) + per-band voicing bitmap (bits 6–0, 7 bits — one bit per spectral band 1–7; 1=voiced)
u[1]12Spectral amplitude parameter b[1]
u[2]12Spectral amplitude parameter b[2]
u[3]12Spectral amplitude parameter b[3]
u[4]11Spectral amplitude parameter b[4]
u[5]11Spectral amplitude parameter b[5]
u[6]11Spectral amplitude parameter b[6]
u[7]7Spectral amplitude parameter b[7]

Totals: 88 information bits per frame = 4 × 12 + 3 × 11 + 7.

Interpretation of u[0]:

u[0] bits:  11  10   9   8   7  |  6   5   4   3   2   1   0
            ┌───────────────────┼───────────────────────────┐
            │  pitch index (5b) │  voicing bitmap (7b)      │
            └───────────────────┴───────────────────────────┘
  • Pitch index 0–31 maps to fundamental frequency F₀ via the IMBE pitch table (approximately 69.2 Hz at index 0 to 220.2 Hz at index 31).
  • Each voicing bit corresponds to one spectral band; voiced bands contribute harmonic energy, unvoiced bands contribute noise.

Using IMBE params with a software vocoder:

import ctypes
lib = ctypes.CDLL('libimbe.so')
decoder = lib.imbe_create()

# params = list of 8 uint32 values from CODEC_FRAME
frame_vec  = (ctypes.c_int16 * 8)(*[int(p) for p in params])
pcm_output = (ctypes.c_int16 * 160)()
lib.imbe_decode(decoder, frame_vec, pcm_output)
# pcm_output: 160 samples × int16 at 8 kHz (20 ms of audio)

FEC error guidance for IMBE:

errsSignalAudio quality
0CleanReference quality
1–2MarginalSlightly degraded, intelligible
3–4PoorNoisy; may become unintelligible
≥ 5SevereSubstitute silence or discard

7.2 AMBE+2 — P25 Phase 2 and DMR

AMBE+2 (Advanced Multi-Band Excitation Plus Two) is used in P25 Phase 2 TDMA and DMR. codec_type = 1 for P25 Phase 2; codec_type = 2 for DMR. Both use the same 4 × uint32_t layout in codec_params[].

Frame layout:

IndexBits usedContent
params[0]24AMBE+2 channel data bytes 0–2
params[1]24AMBE+2 channel data bytes 3–5
params[2]24AMBE+2 channel data bytes 6–8
params[3]8AMBE+2 channel data byte 9 (last); upper 24 bits zero

The AMBE+2 full-rate vocoder produces 72 bits (9 bytes) of channel data per 20 ms frame. Trunk-recorder distributes these 9 bytes across 4 × uint32_t words, 3 bytes per word (lower 24 bits), with the last word carrying the remaining byte in the lowest 8 bits.

Reconstructing the 9-byte AMBE+2 channel frame:

def ambe2_params_to_bytes(params: list[int]) -> bytes:
    """Reconstruct 9-byte AMBE+2 channel frame from 4 codec params."""
    out = bytearray(9)
    for i in range(3):
        out[i*3 + 0] =  params[i]        & 0xFF
        out[i*3 + 1] = (params[i] >>  8) & 0xFF
        out[i*3 + 2] = (params[i] >> 16) & 0xFF
    out[8] = params[3] & 0xFF
    return bytes(out)

Forwarding to a DVSI hardware vocoder (DV3000 / DVstick30):

import struct

def build_ambeserver_packet(params: list[int]) -> bytes:
    """Wrap AMBE+2 channel frame in AMBEserver CH_RX packet format."""
    channel_bytes = ambe2_params_to_bytes(params)
    # AMBEserver packet: start_byte(1) + type(1) + length(2) + data
    START = 0x61
    TYPE_CH_RX = 0x01
    pkt_len = len(channel_bytes) + 4
    return struct.pack('>BBH', START, TYPE_CH_RX, pkt_len) + channel_bytes

Note on codec_type 1 vs 2: P25 Phase 2 (type 1) and DMR (type 2) both use AMBE+2 with the same bit rate and frame structure. The codec type field distinguishes the system origin so consumers can apply system-appropriate FEC threshold policies or routing rules. The channel bytes themselves are processed identically.

FEC error guidance for AMBE+2:

errsMeaning
0Clean frame
1–3Recoverable errors corrected by AMBE+2 FEC
≥ 4Significant degradation; consider substituting silence

8. C Reference Structures and Examples

8.1 Header Definitions

#include <stdint.h>
#include <string.h>
#include <time.h>

/* Protocol constants */
#define SSSP_MAGIC_0          0x53   /* 'S' */
#define SSSP_MAGIC_1          0x59   /* 'Y' */
#define SSSP_VERSION          0x02
#define SSSP_HEADER_SIZE      8

#define SSSP_MSG_CODEC_FRAME  0x01
#define SSSP_MSG_CALL_START   0x02
#define SSSP_MSG_CALL_END     0x03
#define SSSP_MSG_HEARTBEAT    0x04

#define SSSP_CODEC_IMBE       0x00
#define SSSP_CODEC_AMBE2_P25  0x01
#define SSSP_CODEC_AMBE_DMR   0x02
#define SSSP_CODEC_AMBE_DSTAR 0x03
#define SSSP_CODEC_AMBE_YSF_F 0x04
#define SSSP_CODEC_AMBE_YSF_H 0x05
#define SSSP_CODEC_AMBE2_NXDN 0x06

/* 8-byte frame header — packed, always sent before payload */
typedef struct __attribute__((packed)) {
    uint8_t  magic[2];     /* { SSSP_MAGIC_0, SSSP_MAGIC_1 } */
    uint8_t  version;      /* SSSP_VERSION                   */
    uint8_t  msg_type;     /* SSSP_MSG_*                     */
    uint32_t payload_len;  /* little-endian; bytes after header */
} sssp_header_t;

/* CODEC_FRAME payload fixed header (24 bytes).
 * Followed immediately by param_count × uint32_t codec parameters. */
typedef struct __attribute__((packed)) {
    uint32_t talkgroup;    /* talkgroup ID                   */
    uint32_t src_id;       /* source radio ID (0 = unknown)  */
    uint32_t call_id;      /* session call ID (0 = not tracking) */
    uint64_t timestamp_us; /* µs since Unix epoch (0 = unknown) */
    uint8_t  codec_type;   /* SSSP_CODEC_*                   */
    uint8_t  param_count;  /* N: number of uint32 params     */
    uint8_t  errs;         /* FEC error count this frame     */
    uint8_t  flags;        /* bit 0 = silence/null frame     */
} sssp_codec_hdr_t;

/* Convenience: complete IMBE frame (header + params in one struct) */
typedef struct __attribute__((packed)) {
    sssp_header_t    frame_hdr;
    sssp_codec_hdr_t codec_hdr;
    uint32_t         params[8];   /* u[0..7] */
} sssp_imbe_frame_t;

/* Convenience: complete AMBE+2 frame */
typedef struct __attribute__((packed)) {
    sssp_header_t    frame_hdr;
    sssp_codec_hdr_t codec_hdr;
    uint32_t         params[4];
} sssp_ambe2_frame_t;

8.2 Utility: Build a Frame Header

static void sssp_fill_header(sssp_header_t *h, uint8_t msg_type,
                             uint32_t payload_len) {
    h->magic[0]   = SSSP_MAGIC_0;
    h->magic[1]   = SSSP_MAGIC_1;
    h->version    = SSSP_VERSION;
    h->msg_type   = msg_type;
    h->payload_len = payload_len;
}

static int sssp_check_header(const sssp_header_t *h) {
    return (h->magic[0] == SSSP_MAGIC_0 &&
            h->magic[1] == SSSP_MAGIC_1 &&
            h->version  == SSSP_VERSION) ? 0 : -1;
}

static uint64_t sssp_now_us(void) {
    struct timespec ts;
    clock_gettime(CLOCK_REALTIME, &ts);
    return (uint64_t)ts.tv_sec * 1000000ULL + ts.tv_nsec / 1000;
}

8.3 Sender: Transmit an IMBE Frame

#include <sys/socket.h>

int sssp_send_imbe(int sockfd, uint32_t tgid, uint32_t src, uint32_t call_id,
                   const uint32_t *u, int errs) {
    sssp_imbe_frame_t pkt;
    uint32_t plen = sizeof(sssp_codec_hdr_t) + 8 * sizeof(uint32_t);

    sssp_fill_header(&pkt.frame_hdr, SSSP_MSG_CODEC_FRAME, plen);

    pkt.codec_hdr.talkgroup   = tgid;
    pkt.codec_hdr.src_id      = src;
    pkt.codec_hdr.call_id     = call_id;
    pkt.codec_hdr.timestamp_us = sssp_now_us();
    pkt.codec_hdr.codec_type  = SSSP_CODEC_IMBE;
    pkt.codec_hdr.param_count = 8;
    pkt.codec_hdr.errs        = (uint8_t)errs;
    pkt.codec_hdr.flags       = 0;
    memcpy(pkt.params, u, 8 * sizeof(uint32_t));

    return send(sockfd, &pkt, sizeof(pkt), 0);
}

8.4 Receiver: Binary Frame Loop

#include <sys/socket.h>
#include <stdio.h>

/* Read exactly n bytes into buf; returns 0 on success, -1 on disconnect. */
static int recv_exact(int fd, void *buf, size_t n) {
    size_t got = 0;
    while (got < n) {
        ssize_t r = recv(fd, (char *)buf + got, n - got, 0);
        if (r <= 0) return -1;
        got += r;
    }
    return 0;
}

/* Scan forward until the 2-byte magic sequence is found. */
static int sssp_resync(int fd) {
    uint8_t b;
    while (recv_exact(fd, &b, 1) == 0) {
        if (b != SSSP_MAGIC_0) continue;
        if (recv_exact(fd, &b, 1) != 0) return -1;
        if (b == SSSP_MAGIC_1) return 0;  /* found */
    }
    return -1;
}

void sssp_receive_loop(int fd) {
    sssp_header_t hdr;
    uint8_t payload[65536];

    while (1) {
        if (recv_exact(fd, &hdr, SSSP_HEADER_SIZE) != 0) break;

        if (sssp_check_header(&hdr) != 0) {
            fprintf(stderr, "bad magic — resyncing\n");
            if (sssp_resync(fd) != 0) break;
            continue;
        }

        if (hdr.payload_len > sizeof(payload)) {
            fprintf(stderr, "payload too large (%u) — dropping\n", hdr.payload_len);
            break;
        }

        if (recv_exact(fd, payload, hdr.payload_len) != 0) break;

        switch (hdr.msg_type) {
        case SSSP_MSG_CODEC_FRAME: {
            if (hdr.payload_len < sizeof(sssp_codec_hdr_t)) break;
            sssp_codec_hdr_t *c = (sssp_codec_hdr_t *)payload;
            uint32_t *params = (uint32_t *)(payload + sizeof(*c));
            printf("CODEC tg=%u src=%u codec=%u errs=%u params[0]=%u\n",
                   c->talkgroup, c->src_id, c->codec_type,
                   c->errs, c->param_count > 0 ? params[0] : 0);
            break;
        }
        case SSSP_MSG_CALL_START:
            printf("CALL START (payload %u bytes)\n", hdr.payload_len);
            break;
        case SSSP_MSG_CALL_END:
            printf("CALL END (payload %u bytes)\n", hdr.payload_len);
            break;
        case SSSP_MSG_HEARTBEAT:
            /* no-op */
            break;
        default:
            /* unknown type — payload already consumed, safe to continue */
            fprintf(stderr, "unknown msg_type=0x%02x, skipping %u bytes\n",
                    hdr.msg_type, hdr.payload_len);
            break;
        }
    }
}

9. File Format

9.1 File Extension

The canonical file extension for SymbolStream v2 binary captures is .dvcf (Digital Voice Codec Frames).

A .dvcf file is a concatenation of SymbolStream v2 binary messages (§3) representing a single call. The typical structure is:

  1. One CALL_START message
  2. N × CODEC_FRAME messages (one per voice frame, 20ms / 50fps for IMBE)
  3. One CALL_METADATA message (optional; call-level metadata as JSON, see §3.6)
  4. One CALL_END message

Receivers must dispatch on msg_type, not on message position within the file. CALL_METADATA may appear at any point between CALL_START and CALL_END.

9.2 MIME Type

application/x-dvcf (unregistered; use for HTTP uploads and content-type headers).

9.3 File Naming Convention

.dvcf files are written as sidecar files alongside audio recordings. Given an audio file:

9173-1774473273.232_852687500.0-call_39.wav

The corresponding codec capture is:

9173-1774473273.232_852687500.0-call_39.dvcf

Same base name, .dvcf extension. This allows downstream consumers (e.g., the MQTT plugin, tr-engine) to discover the codec data by replacing the audio file extension.

9.4 Relationship to Transport

The file format uses the same binary framing as the TCP/UDP transport (§3). A .dvcf file can be replayed over a TCP connection, or a TCP stream can be saved directly to a .dvcf file. No additional framing or headers are needed beyond the per-message headers defined in §3.


10. Receiver Guide

10.1 Binary Mode State Machine

1. Create TCP server socket, bind to port, listen.
2. Accept connection from trunk-recorder plugin.
3. Loop:
   a. Read 8 bytes → header.
   b. Check magic[0]==0x53, magic[1]==0x59, version==0x02.
      - Wrong magic: attempt resync by scanning for 0x53 0x59 0x02.
      - Wrong version: warn, but continue (format may still be parseable).
   c. Read payload_len bytes → payload.
   d. Dispatch on msg_type:
      0x01 CODEC_FRAME → decode and process (see §3.2)
      0x02 CALL_START  → create call context keyed by call_id (see §3.3)
      0x03 CALL_END    → finalize and release call context (see §3.4)
      0x04 HEARTBEAT   → reset watchdog timer
      unknown          → discard payload, continue
   e. On EOF or socket error: log and reconnect (or exit).

10.2 JSON Mode State Machine

1. Create TCP server socket, bind, listen.
2. Accept connection.
3. Loop:
   a. Read 4 bytes, decode uint32 LE → json_len.
   b. Read json_len bytes, decode as UTF-8, parse JSON.
   c. Dispatch on msg["type"]:
      "codec_frame" → use msg["codec"], msg["params"], msg["tg"], etc.
      "call_start"  → create call context
      "call_end"    → finalize call context
      "heartbeat"   → reset watchdog
      unknown       → log and ignore

10.3 Error Handling

SituationRecommended action
Unknown msg_typeSkip payload_len bytes, continue
Unknown codec valueUse param_count to skip, continue
FEC errs > 0Audio quality reduced; errs ≥ 4 (IMBE) → unintelligible
Silence frame (flag)Null/inband frame; skip vocoder call
Truncated payloadConnection lost; close and reconnect
UDP packet lossNormal; gaps in call_id are not errors

10.4 Minimal Receiver (Python sketch)

See symbolstream_recv.py in this repository for a ~150-line reference implementation handling both binary and JSON modes, including call lifecycle tracking.


11. Bandwidth

At 50 fps (20 ms frames):

ModeFrame sizeRate10 calls
Binary IMBE64 bytes3.2 KB/s32 KB/s
Binary AMBE48 bytes2.4 KB/s24 KB/s
JSON IMBE~180 bytes9 KB/s90 KB/s
JSON AMBE~160 bytes8 KB/s80 KB/s

Binary mode costs 24 bytes of overhead per frame vs v1 (timestamp, call_id, header). That's an extra 1.2 KB/s for IMBE — worthwhile for the metadata gained.

JSON mode is intended for development, debugging, and low-volume monitoring only.


12. Compatibility

12.1 v2 vs v1

v2 is a breaking wire-format change from v1.

  • Binary: v1 has no frame header (raw tgid + src_id + codec bytes). v2 adds the 8-byte header. Receivers can detect v1 by inspecting the first bytes: v1 binary frames start with a talkgroup ID (any value), not with 0x53 0x59.
  • JSON: v1 sends 4-byte-length + JSON-metadata + binary-codec-tail. v2 sends 4-byte-length + JSON-only with params inline. Receivers can detect v2 JSON by checking for "v":2 in the parsed object.

12.2 Forward Compatibility

  • Receivers must skip unknown msg_type values using payload_len.
  • Receivers must skip unknown codec_type values using param_count.
  • Receivers must ignore unknown JSON fields.
  • The version byte in the binary header allows a future v3 to change the header layout; receivers should warn but attempt to continue on unexpected version values.

13. Version 1 (Legacy) Reference

The current symbolstream C++ plugin sends one of two formats, controlled by sendJSON in config.

13.1 sendJSON = false (binary only)

Fixed 40-byte packet per IMBE frame. No codec_type, no version, no call events.

 Offset  Size  Type      Field
 0       4     uint32    talkgroup_id (little-endian)
 4       4     uint32    src_id (little-endian)
 8       32    uint32[8] IMBE codewords u[0..7]

13.2 sendJSON = true (hybrid JSON + binary)

Per-frame message:

 Offset  Size  Type    Field
 0       4     uint32  JSON length (little-endian)
 4       N     char[]  JSON metadata (see below)
 4+N     32    uint32[8]  IMBE codewords (binary, appended after JSON)

JSON metadata:

{
  "event": "codec_frame",
  "talkgroup": 9170,
  "src": 1234567,
  "codec_type": 0,
  "errs": 0,
  "short_name": "butco"
}

Call events (JSON only, no binary tail):

{"event": "call_start", "talkgroup": 9170, "freq": 855737500, "short_name": "butco"}
{"event": "call_end",   "talkgroup": 9170, "src": 1234567, "freq": 855737500,
 "duration": 4.5, "short_name": "butco", "error_count": 3, "encrypted": false}

v1 limitations addressed by v2:

  • No frame header — cannot resync after corruption, cannot version-detect
  • No timestamp — receivers cannot reconstruct absolute call timing
  • No call_id — cannot correlate frames to calls when multiple talkgroups are multiplexed
  • Hybrid JSON+binary — awkward to parse, not composable with standard JSON tools
  • Codec type missing from binary mode — receiver must infer from context
  • Only IMBE is sent (codec_type != 0 is filtered out in the plugin)