Mode A — push-to-talk streaming
The udp-v1 wire format byte for byte: the metadata line, the PCM payload, what happens server-side, and the gotchas that make a stream fail as silence rather than as an error.
11 min read
On this page
The cheapest mode to run and the one with the largest deployed fleet. The device streams raw audio while the button is held. There is no downlink audio on this path.
The udp-v1 wire format
One datagram, no framing header, no length prefix: an ASCII metadata line, a single 0x0A newline, then the PCM payload.
<ASCII metadata line> 0x0A <PCM payload>
metadata: v1,seq,device_session,mac,org_id,property_id,team_id,user_id,flags
concretely:
v1,417,b7f3a1c2,AA:BB:CC:DD:EE:FF,2,4,7,19,
<640 bytes of s16le PCM>| Field | Required | Rules |
|---|---|---|
| v1 | yes | Literal version tag. The parser rejects anything else |
| seq | yes | Monotonic integer per session. Digits only |
| device_session | yes | [A-Za-z0-9_-]{1,64}. A new random value on every boot |
| mac | yes | 17 characters, AA:BB:CC:DD:EE:FF. Bare 12-hex is accepted and normalised |
| org_id, property_id, team_id, user_id | no | Integers. May be empty between commas |
| flags | no | Reserved. Parsed and carried, not acted on. Keep it empty or use it for your own tagging |
- At least nine comma-separated fields. Trailing empties are fine; too few and the packet is dropped.
- CRLF is tolerated — the parser strips the carriage return so it never lands in your PCM.
- The MAC may contain colons. No other field may contain a comma.
- An empty PCM payload is dropped silently.
- Invalid packets are logged and skipped, never fatal.
The session key on the server is mac|device_session, which is why the session value must change on boot: it is what distinguishes a reboot from a reconnect, and it is what gives each boot its own recognition session.
The PCM payload
| Property | Value |
|---|---|
| Encoding | s16le, mono, dense |
| Sample rate | 16 000 Hz (server-configurable; must match) |
| Payload size | Whatever your DMA buffer yields — 320 to 1024 samples is typical |
| Header | None |
Emitting the format
/* Called once per captured buffer while the button is held.
session_id is regenerated on every boot; seq is monotonic within it. */
static char session_id[9]; /* e.g. "b7f3a1c2" */
static uint32_t seq = 0;
void qc_send_frame(const int16_t *pcm, size_t samples)
{
static uint8_t dg[1400];
int n = snprintf((char *)dg, sizeof dg,
"v1,%u,%s,%s,%d,%d,%d,%d,\n",
seq++, session_id, cfg.mac,
cfg.org_id, cfg.property_id, cfg.team_id, cfg.user_id);
size_t bytes = samples * sizeof(int16_t);
if (n < 0 || (size_t)n + bytes > sizeof dg) return; /* never fragment */
memcpy(dg + n, pcm, bytes);
sendto(sock, dg, n + bytes, 0,
(struct sockaddr *)&ingest_addr, sizeof ingest_addr);
}import socket, time, uuid, wave
HOST, PORT = "api.yourcompany.com", 12345
MAC = "AA:BB:CC:DD:EE:FF"
SESSION = uuid.uuid4().hex[:8] # new value every run
FRAME = 320 # 20 ms at 16 kHz
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
wav = wave.open("utterance-16k-mono.wav", "rb")
assert wav.getframerate() == 16000 and wav.getnchannels() == 1
assert wav.getsampwidth() == 2 # s16le
seq = 0
while True:
pcm = wav.readframes(FRAME)
if not pcm:
break
header = f"v1,{seq},{SESSION},{MAC},2,4,7,19,\n".encode()
sock.sendto(header + pcm, (HOST, PORT))
seq += 1
time.sleep(FRAME / 16000) # pace it like real speechThree ingest formats
| Format | What it expects | When |
|---|---|---|
| udp-v1 | The metadata line above. The default. Use this | New integrations |
| raw | Bare PCM, no metadata. Peers are bucketed by source address with a synthetic MAC | Bench testing, scope captures |
| walkie | Tries udp-v1, falls back to a legacy marker framing | Legacy fleets |
What happens server-side, and why your button matters
- 1Voice gate. Every chunk's RMS is measured. The gate opens after three consecutive chunks above threshold (default RMS 700, with an adaptive mode that tracks the room's noise floor) and closes after 3000 ms below it. While closed, nothing reaches speech recognition and nothing is billed.
- 2Pre-roll. A rolling two-second buffer is kept while the gate is closed, and about one second of it is flushed when the gate opens. This is why the first syllable is never lost — you do not need to pre-buffer on the device.
- 3Live transcription. One recognition session per mac|device_session, in the device's configured language: en, ur, ar, or multi for code-switching. Non-English is translated to English for dashboards with the original preserved.
- 4Sealing. An utterance-boundary engine decides where one utterance ends. Drafts are persisted every few seconds mid-speech, so a crash never loses words already spoken.
- 5Persistence. Each sealed utterance becomes a conversation row with the audio clip attached in blob storage.
- 6Extraction. The utterance goes to the AI engine for intent, location and severity, which is where tasks come from.
Gotchas
- There is no downlink. NAT mappings expire during silence, which is exactly when a server would want to talk. If your use case needs the device to speak, use mode B or mode C.
- device_session must change on boot, or a reboot silently joins the previous session's transcript.
- seq should be monotonic within a session. It is not used for reordering today, but it is your only diagnostic for loss.
- Send property_id on every packet. A packet without it transcribes into nothing useful.
Something wrong or missing on this page? Tell us.

