bc0e3a5a89
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.
259 lines
7.3 KiB
Markdown
259 lines
7.3 KiB
Markdown
# 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)
|
||
|
||
```json
|
||
{
|
||
"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)
|
||
|
||
```json
|
||
{
|
||
"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)
|
||
|
||
```json
|
||
{
|
||
"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`):
|
||
|
||
```json
|
||
{
|
||
"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`):
|
||
|
||
```json
|
||
{
|
||
"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`):
|
||
|
||
```json
|
||
{
|
||
"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:
|
||
|
||
```json
|
||
{
|
||
"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:
|
||
|
||
```json
|
||
{
|
||
"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 client
|
||
- `authed_page` — mavweb /tools page
|
||
- `telegram` — Telegram inbound
|
||
|
||
## Session lifecycle
|
||
|
||
1. Client connects via TCP to the voice address.
|
||
2. Server registers a Session (assigns an opaque ID, records `lastActive`).
|
||
3. Client sends Requests and receives Responses + Pushes on the same conn.
|
||
4. Proactive delivery routes to the most-recently-active session by
|
||
`lastActive` timestamp. If no session is live, the dispatcher falls
|
||
through to away channels.
|
||
5. On disconnect (EOF / read error / shutdown), the session is removed.
|
||
|
||
## Reference client
|
||
|
||
`cmd/mavenclient` implements this protocol. Use it to smoke-test:
|
||
|
||
```shell
|
||
# 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-
|
||
```
|