PROTOCOL.md: generated from internal/voice/wire.go — documents the voice wire format (TCP, length-prefixed JSON, methods, pushes, errors) for multi-client implementors. Must stay in sync with wire.go. dispatcher: when voice sink returns ErrVoiceNoSession, skip voice and continue to remaining channels instead of aborting. sev4-present already has ntfy in the routing table (continues naturally). sev1-3 present have only voice — the loop ends with no dispatches, which matches the spec (care/ops-soft drop on no-voice). voicesink: maps voice.ErrNoSession to delivery.ErrVoiceNoSession so the dispatcher can detect it without importing the voice package.
7.3 KiB
Maven Voice Protocol
Auto-generated from
internal/voice/wire.go,internal/voice/errors.go,internal/voice/frame.go,internal/voice/client.go. If this file and those files disagree, the code wins.
Transport
TCP, length-prefixed JSON. Each frame is:
[4 bytes big-endian uint32 length][JSON payload]
The length is the number of bytes of the JSON payload that follows
(excludes the 4-byte length prefix itself). Maximum frame size is 64 MiB
(maxFrame = 64 << 20), enough for ~33 minutes of 16k mono int16 PCM audio.
The server binds a TCP address inside the WireGuard tunnel (production) or
127.0.0.1:9100 (local smoke test). The reference server port is configured
via voice.bind in mavend.json.
Frame types
Three frame shapes share the same length-prefixed envelope. A reader distinguishes them by shape rather than a type tag:
| Frame | Has id? |
Has kind? |
Direction |
|---|---|---|---|
| Request | yes (id + m) |
no | client → server |
| Response | yes (id) |
no | server → client |
| Push | no | yes (kind) |
server → client (async) |
Request (client → server)
{
"id": 1,
"m": "push_to_talk",
"p": { ... }
}
| Field | Type | Description |
|---|---|---|
id |
uint64 | Chosen by client, monotonically increasing per connection. Server echoes it back in the matching Response |
m |
string | Method name (see Methods below) |
p |
object | Method-specific params (omitempty) |
Response (server → client)
{
"id": 1,
"r": { ... },
"e": null
}
| Field | Type | Description |
|---|---|---|
id |
uint64 | Matches the Request this replies to |
r |
object | Result payload (omitempty). Set exactly when e is null |
e |
object | Error payload (omitempty). See Error codes |
A Response always matches a prior Request. It is written on the same connection immediately after handling. The client can block reading — there is exactly one Response per Request for today's synchronous methods.
Push (server → client, async)
{
"kind": "audio_nudge",
"p": { ... }
}
| Field | Type | Description |
|---|---|---|
kind |
string | Push kind (see Push kinds below) |
p |
object | Push-specific params (omitempty) |
A Push is server-initiated. It can arrive on a connection that is also
awaiting a Response. The client distinguishes them by checking whether id
is present (Response) or kind is present (Push).
Methods
push_to_talk
The reactive round-trip: client sends captured audio, server replies with synthesised audio + reply text.
Request params (PushToTalkReq):
{
"audio": {
"fmt": "pcm_16k_mono",
"data": "<base64-encoded PCM bytes>"
},
"lang": "ru",
"surface": "pc_client"
}
| Field | Type | Description |
|---|---|---|
audio |
Audio | Captured PCM. fmt is one of pcm_16k_mono, pcm_44k_stereo, etc. (see internal/audio). data is base64-encoded raw PCM bytes |
lang |
string | Recognition language hint: "ru", "en", "mixed", or unset for daemon default |
surface |
string | Client's auth surface. Today the floor always sets "pc_client"; future mTLS/passkey handshakes populate this |
Response result (PushToTalkResp):
{
"reply_audio": {
"fmt": "pcm_16k_mono",
"data": "<base64>"
},
"reply_text": "тихий режим включён. буду реже напоминать.",
"transcript": "тихий режим",
"routed_channels": null
}
| Field | Type | Description |
|---|---|---|
reply_audio |
Audio | TTS-synthesised reply audio (always present on success) |
reply_text |
string | Same reply in plain text |
transcript |
string | STT transcription of the input audio (omitempty) |
routed_channels |
[string] | Away channels the dispatcher also delivered to (empty for today's reactive-only path) |
pong
Liveness response. The client sends this in reply to a ping Push to
refresh its last-active timestamp on the server.
Request params: none (empty "p": null or omitted).
Response result: null.
Push kinds
audio_nudge
Server has a proactive nudge to deliver. The client should play the audio.
Params (AudioNudgePush):
{
"rule_name": "water",
"severity": 2,
"audio": {
"fmt": "pcm_16k_mono",
"data": "<base64>"
},
"text": "кажется, ты давно не пил воду.",
"ts": "2026-07-03T12:00:00Z"
}
| Field | Type | Description |
|---|---|---|
rule_name |
string | The proactive rule that fired |
severity |
int | 1–4 where 4 = most urgent |
audio |
Audio | TTS-synthesised nudge body |
text |
string | Same body as plain text |
ts |
datetime | When the nudge was sent (RFC3339) |
ping
Liveness probe from the server. The client should respond with a pong
Request to keep its session alive.
Params: none ("p": null or omitted).
Audio format
The Audio type is always:
{
"fmt": "pcm_16k_mono",
"data": "<base64>"
}
- Sample rate: 16000 Hz
- Channels: 1 (mono)
- Sample format: signed 16-bit little-endian int (int16)
- Encoding: raw PCM, no header
- Wire encoding: base64 inside the JSON frame
The reference client wraps/unwraps WAV headers at the file edge
(audio.WAVFromPCM, audio.PCMFromWAV). The wire never carries WAV.
Error codes
Errors are returned as an object in the Response e field:
{
"id": 1,
"r": null,
"e": {
"c": "unknown_method",
"m": "optional diagnostic"
}
}
| Code | Meaning | Has message? |
|---|---|---|
unknown_method |
The method name is not recognised | yes (the method name) |
bad_params |
Params failed to parse / validate | yes (parse error text) |
forbidden |
The surface is not authorised for this method | no |
internal |
Server-side error (transient, retryable) | yes (diagnostic only) |
The message field is never authority-bearing. Auth refusals carry
forbidden with no message.
Auth surface
The server enforces capability scopes per surface. Today's floor assigns
SurfacePCClient to every connection (full L3 access). Future passkey
handshakes will set the surface from mTLS metadata / WebAuthn
enrollment.
Known surface values:
voice— voice/chat channel (structurally capped below destructive acts)pc_client— reference desktop clientauthed_page— mavweb /tools pagetelegram— Telegram inbound
Session lifecycle
- Client connects via TCP to the voice address.
- Server registers a Session (assigns an opaque ID, records
lastActive). - Client sends Requests and receives Responses + Pushes on the same conn.
- Proactive delivery routes to the most-recently-active session by
lastActivetimestamp. If no session is live, the dispatcher falls through to away channels. - On disconnect (EOF / read error / shutdown), the session is removed.
Reference client
cmd/mavenclient implements this protocol. Use it to smoke-test:
# one-shot: send audio.wav → get reply.wav
mavenclient -addr 127.0.0.1:9100 -in audio.wav -out reply.wav
# listen mode: stay connected, write incoming Pushes to disk
mavenclient -addr 127.0.0.1:9100 -listen -out-prefix /tmp/nudge-