Files
harness-tools/docs/dictate/protocol.md
T

7.0 KiB

Pi Dictate local-service protocol

Transport and version

Protocol version 1 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
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

{"version":1,"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

{"version":1,"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

{"version":1,"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

{"version":1,"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

{"version":1,"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

{"version":1,"type":"pong","requestId":"hello-1","service":"pi-dictate","ready":false}

state

{"version":1,"type":"state","requestId":"start-1","generationId":"generation-1","state":"loading"}

state is one of loading, listening, stopping, transcribing, or idle.

result

{"version":1,"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

{"version":1,"type":"cancelled","requestId":"cancel-1","generationId":"generation-1"}

This is a terminal delivery guarantee: the generation cannot later produce a result.

error

{"version":1,"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 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:

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.