160 lines
7.5 KiB
Markdown
160 lines
7.5 KiB
Markdown
# Pi Dictate local-service protocol
|
|
|
|
## Transport and version
|
|
|
|
Protocol version `2` uses UTF-8, newline-delimited JSON over one persistent Unix stream socket. Each line contains one JSON object. The systemd socket unit owns the socket path and permissions. The service accepts the inherited listening file descriptor and does not bind or unlink the path.
|
|
|
|
Clients must send `hello` before any other request. A connection negotiates one version only. Coding terms are host-owned service configuration and never cross this protocol.
|
|
|
|
## Limits
|
|
|
|
| Data | Limit |
|
|
| --- | ---: |
|
|
| JSON frame, excluding newline | 65,536 bytes |
|
|
| `requestId` or `generationId` | 128 UTF-8 bytes |
|
|
| Final transcript | 32,768 UTF-8 bytes |
|
|
| Error detail | 2,048 UTF-8 bytes |
|
|
| Simultaneous client connections | 16 |
|
|
| Pending output per client | 65,536 bytes |
|
|
| Recorder stderr tail | 65,536 bytes |
|
|
| Audio-level event rate | 10 per second |
|
|
| Mono 16 kHz signed-16 PCM | 9,600,000 bytes, five minutes |
|
|
|
|
IDs are non-empty, opaque strings generated by the client. The service compares them only for request correlation, ownership, and stale-generation rejection. It does not interpret IDs as paths. Request objects reject unknown or missing fields.
|
|
|
|
Malformed JSON, a frame over the limit, or an unsupported version produces one bounded `error` frame and then closes the connection. The malformed-frame error uses an empty `requestId` when no client ID can be recovered. Schema, state, busy, and ownership errors leave the connection open.
|
|
|
|
## Client requests
|
|
|
|
All examples are one line when transmitted.
|
|
|
|
### `hello`
|
|
|
|
```json
|
|
{"version":2,"type":"hello","requestId":"hello-1"}
|
|
```
|
|
|
|
Exact fields: `version`, `type`, `requestId`.
|
|
|
|
A successful response is `pong` with the same `requestId`. `ready` reports whether the model is warm.
|
|
|
|
### `ping`
|
|
|
|
```json
|
|
{"version":2,"type":"ping","requestId":"ping-1"}
|
|
```
|
|
|
|
Exact fields: `version`, `type`, `requestId`. `hello` is required first. The response is `pong` and may include `ready` and `busy`.
|
|
|
|
### `start`
|
|
|
|
```json
|
|
{"version":2,"type":"start","requestId":"start-1","generationId":"generation-1"}
|
|
```
|
|
|
|
Exact fields: `version`, `type`, `requestId`, `generationId`. The service accepts only one global generation. It first emits `state: loading` for a cold model or `state: listening` for a warm model. Recording starts only after model loading succeeds.
|
|
|
|
### `stop`
|
|
|
|
```json
|
|
{"version":2,"type":"stop","requestId":"stop-1","generationId":"generation-1"}
|
|
```
|
|
|
|
Exact fields: `version`, `type`, `requestId`, `generationId`. Only the owning connection may stop the active listening generation.
|
|
|
|
### `cancel`
|
|
|
|
```json
|
|
{"version":2,"type":"cancel","requestId":"cancel-1","generationId":"generation-1"}
|
|
```
|
|
|
|
Exact fields: `version`, `type`, `requestId`, `generationId`. A valid cancellation immediately emits `cancelled`. Cancellation tombstones the generation, so it can never emit a later `result` or begin recording after a cancelled load.
|
|
|
|
## Service events
|
|
|
|
All service events contain `version`, `type`, and a bounded client-generated `requestId`. Generation events also contain `generationId`.
|
|
|
|
### `pong`
|
|
|
|
```json
|
|
{"version":2,"type":"pong","requestId":"hello-1","service":"pi-dictate","ready":false}
|
|
```
|
|
|
|
### `state`
|
|
|
|
```json
|
|
{"version":2,"type":"state","requestId":"start-1","generationId":"generation-1","state":"loading"}
|
|
```
|
|
|
|
`state` is one of `loading`, `listening`, `stopping`, `transcribing`, or `idle`.
|
|
|
|
### `level`
|
|
|
|
```json
|
|
{"version":2,"type":"level","requestId":"start-1","generationId":"generation-1","level":0.625}
|
|
```
|
|
|
|
While listening, the service emits at most ten `level` events per second. `level` is a finite number from `0` through `1`, derived from the recorder's current 100 ms RMS window on a logarithmic -60 dBFS to 0 dBFS scale. The event carries no PCM samples and stops before recorder shutdown begins.
|
|
|
|
### `result`
|
|
|
|
```json
|
|
{"version":2,"type":"result","requestId":"stop-1","generationId":"generation-1","transcript":"example text"}
|
|
```
|
|
|
|
There is at most one result for a generation. An empty transcript is a successful result with `"transcript":""`.
|
|
|
|
### `cancelled`
|
|
|
|
```json
|
|
{"version":2,"type":"cancelled","requestId":"cancel-1","generationId":"generation-1"}
|
|
```
|
|
|
|
This is a terminal delivery guarantee: the generation cannot later produce a result.
|
|
|
|
### `error`
|
|
|
|
```json
|
|
{"version":2,"type":"error","requestId":"stop-1","generationId":"generation-1","code":"recorder_timeout","detail":"pw-record did not stop after SIGTERM and was killed"}
|
|
```
|
|
|
|
Stable codes include:
|
|
|
|
- framing and schema: `malformed_json`, `frame_too_large`, `unsupported_version`, `invalid_request`;
|
|
- session: `hello_required`, `busy`, `not_owner`, `generation_mismatch`, `invalid_state`;
|
|
- recorder and audio: `recorder_spawn`, `recorder_exit`, `recorder_timeout`, `duration_limit`, `invalid_audio`;
|
|
- model and output: `model_failure`, `transcript_too_large`.
|
|
|
|
## Lifecycle and ownership
|
|
|
|
| Current state | Request or event | Outcome |
|
|
| --- | --- | --- |
|
|
| no generation | owner `start` | `loading`, then `listening`; warm models may emit `listening` directly |
|
|
| any active generation | another `start` | `busy`; connection stays open |
|
|
| `loading` | owner `cancel` | immediate `cancelled`; loading finishes in the worker but recording is suppressed |
|
|
| `listening` | recorder audio | rate-limited `level` events for the owning client |
|
|
| `listening` | owner `stop` | `stopping`; signal recorder, drain stdout to EOF, await child close, convert PCM, then `transcribing` |
|
|
| `listening` | owner `cancel` or disconnect | stop and drain recorder, discard audio, never infer |
|
|
| `stopping` | owner `cancel` | immediate `cancelled`; completed audio is discarded |
|
|
| `transcribing` | owner `cancel` or disconnect | tombstone immediately; inference finishes but its result is discarded |
|
|
| `transcribing` | successful model result | one `result`, then `idle` |
|
|
| any work state | terminal failure | one `error`, then `idle` |
|
|
| active generation | non-owner `stop` or `cancel` | `not_owner`; connection stays open |
|
|
| active generation | stale generation ID | `generation_mismatch`; connection stays open |
|
|
|
|
Cancelled model loading and inference are non-interruptible. The global service remains busy until the worker finishes even though `cancelled` was already emitted. Disconnecting a generation owner cancels capture or tombstones loading/inference under the same rules.
|
|
|
|
The recorder command is exactly:
|
|
|
|
```text
|
|
pw-record --raw --format s16 --rate 16000 --channels 1 -
|
|
```
|
|
|
|
The service launches `pw-record` in its own process group and marks the generation stopping before SIGTERM. It continuously drains bounded stdout and stderr, waits a bounded time for pipe EOF and child close, and only then converts or transcribes. A shutdown timeout escalates to a process-group kill and fails the generation. PipeWire's exit code `1` is accepted only after this service intentionally signalled a still-running recorder and stderr remained empty; an already-exited process or stderr-bearing exit remains a failure. Raw audio and recorder logs are held only in bounded memory and are not written to disk.
|
|
|
|
## Idle exit
|
|
|
|
A successfully parsed protocol request resets the 15-minute monotonic idle activity timer, including requests that later produce state or ownership errors. Accepting a socket, receiving malformed data, and disconnecting do not reset activity.
|
|
|
|
Idle exit is suspended while model loading, recording, recorder shutdown, PCM conversion, or inference is active. Once work becomes idle, the full timer starts again. Exiting closes accepted clients and the inherited listener but does not unlink the systemd-owned socket path.
|