Skip to content
Free shipping on orders over $2,000.

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

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.

textDatagram layout
<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>
FieldRequiredRules
v1yesLiteral version tag. The parser rejects anything else
seqyesMonotonic integer per session. Digits only
device_sessionyes[A-Za-z0-9_-]{1,64}. A new random value on every boot
macyes17 characters, AA:BB:CC:DD:EE:FF. Bare 12-hex is accepted and normalised
org_id, property_id, team_id, user_idnoIntegers. May be empty between commas
flagsnoReserved. 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

PropertyValue
Encodings16le, mono, dense
Sample rate16 000 Hz (server-configurable; must match)
Payload sizeWhatever your DMA buffer yields — 320 to 1024 samples is typical
HeaderNone

Emitting the format

cDevice side — one datagram per DMA buffer
/* 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);
}
pythonBench harness — stream a WAV file as if it were a device
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 speech

Three ingest formats

FormatWhat it expectsWhen
udp-v1The metadata line above. The default. Use thisNew integrations
rawBare PCM, no metadata. Peers are bucketed by source address with a synthetic MACBench testing, scope captures
walkieTries udp-v1, falls back to a legacy marker framingLegacy fleets

What happens server-side, and why your button matters

  1. 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.
  2. 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.
  3. 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.
  4. 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.
  5. 5Persistence. Each sealed utterance becomes a conversation row with the audio clip attached in blob storage.
  6. 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.