Transcripts reach you three ways: human-readable stdout, machine-readable JSONL, and optional WAV clips.
Stdout (human-readable):
[14:23:45] (es) Hola, cómo estás? [rms=0.041 end_of_turn]
[14:23:52] (en) What time is it? [rms=0.038 end_of_turn]
[14:24:10] (es) Gracias por tu ayuda. [rms=0.052 pause]
JSONL (one line per utterance; only written when jsonl = true):
{"ts": "2025-09-20T14:23:45.123456", "lang": "es", "lang_prob": 0.95, "duration_s": 2.5, "rms": 0.04123, "end_reason": "end_of_turn", "text": "Hola, cómo estás?", "transcribe_s": 0.42}
{"ts": "2025-09-20T14:23:52.654321", "lang": "en", "lang_prob": 0.98, "duration_s": 1.8, "rms": 0.03811, "end_reason": "pause", "text": "What time is it?", "transcribe_s": 0.31}
duration_s is the length of the utterance audio; transcribe_s is the wall-clock
time the worker spent transcribing it (inference latency), rounded to milliseconds.
Their ratio is the real-time factor. The field is omitted if the timing was not
measured (e.g. emit() called directly without transcribe_s).
end_reason is one of end_of_turn (full silence_ms elapsed), pause (chunked at
chunk_pause_ms because it passed soft_max_s), hard_cap (max_utterance_s
reached), or flush (shutdown mid-utterance).
Speaker fields — every JSONL record carries five additive, back-compatible
speaker fields (always present; null when unknown):
{"ts": "2026-09-24T10:01:03.123456", "lang": "en", "lang_prob": 0.98, "duration_s": 3.2, "rms": 0.031, "end_reason": "pause", "text": "turn on the lights", "seg_id": 142, "speaker_id": "spk_0001", "speaker_status": "matched", "speaker": "Efren", "speaker_score": 0.71}
seg_id — stable correlation key for the segment (int, null when unknown).speaker_id — stable cluster key for cross-reference (e.g. "spk_0001", null when unknown).speaker_status — one of:
matched — best score >= T_id (threshold_id).uncertain — T_new <= score < T_id (threshold_new <= score < threshold_id).unknown — below T_new and no profile minted this utterance.none — no embedding computed (model off, audio too short, or error).speaker — display label: matched+named -> the name ("Efren"),
matched+unnamed -> "spk_0001", uncertain -> "<top1>?" (e.g. "spk_0001?"),
unknown/none -> "?".speaker_score — the winning cosine similarity, rounded to 4 dp (null when unknown).Existing tooling that ignores unknown JSON keys keeps working unchanged.
Audio files — when save_audio = true, each transcribed utterance is also
written as a 16 kHz mono WAV under audio_dir/YYYY-MM-DD/, and the record gains
an "audio" field with its path:
{..., "end_reason": "pause", "text": "Hola", "audio": "audio/2025-09-20/142345-123-pause.wav"}
Saving is best-effort: if a write fails (e.g. disk full), the transcript is still emitted and a warning is logged.
⚠️ Disk space — saved audio is never rotated or cleaned up automatically. At 16 kHz mono 16-bit it costs 32 KB/s, i.e. ~1.9 MB/min, ~115 MB/hour, ~2.8 GB/day of continuous speech. Silence is not written, so real usage is lower — but an always-on daemon in a talkative room still accumulates quickly. Point
audio_dirat a volume with headroom and prune it yourself (e.g. a cron job removing files older than N days), and leavesave_audio = falseunless you actually need the clips for auditing.
Triage I/O dump (assistant-side) — with [triage].dump_io = true the assistant writes one
self-contained JSON per triage under triage/YYYY-MM-DD/HHMMSS-mmm.json (the Laya request/response
plus the LLM request/response). It is separate from the daemon transcript output above, off by
default, and best-effort. See TRIAGE.md.