// Package voice is maven's client↔core network surface. // // It is the SECOND protocol surface (the first being internal/ipc, the // module boundary). Three structural differences from ipc: // // - TRAVERSAL: clients cross the network (spec: wg + mTLS / passkey). ipc // is local-only unix socket; this surface is a TCP listener, expected to // live inside the wg tunnel. The auth cascade (L0 wg / L1 mTLS / L2 // passkey / L3 step-up) applies HERE; ipc's auth floor is "same unix // user" on the box. Today's floor is plaintext: same-wg-tunnel caller // trusted (the listener binds wg-egress only). mTLS / passkey layer in // when the auth machinery lands; the wire frames stay unchanged. // // - DIRECTION: bidirectional. ipc is strict request/response (modules // pull). clients here ALSO receive server-initiated pushes (async TTS // audio back from reactive requests, proactive nudge audio from the // loop). One persistent conn per client; the server reads requests AND // writes pushes through it. The wire distinguishes a Response (matches // a client Request.ID) from a Push (server-initiated, no matching ID). // // - PAYLOAD: audio bytes. Frames hold raw PCM (base64 in JSON for the // same debuggability instinct as ipc/worker). Cap is 64 MiB — same as // worker — to allow minutes-long transcription / synthesis payloads // without chunking complexity (chunked audio is post-MVP, only needed // by meeting-record mode). // // Session tracking: each connected client = one session (id, surface, // last-active). Proactive voice delivery (voicesink) routes to the // most-recently-active session by last-active ts. The spec's "proactive // voice routing: most-recently-active client plays it; if no client // reachable, reroute to ntfy/telegram" is enforced HERE not in delivery — // delivery's voice sink asks Sessions.PickRecent() and either pushes audio // or returns "no live session" to the dispatcher, which the dispatcher // translates into rerouting per ChannelsFor's away path. (Today the // routing-table already drops care-away; the rerouting for ops-when- // no-client is a deferred plug point in delivery/voicesink, marked below.) // // The surface caps: a Push frame carries Audio; the client plays it. The // reference client (cmd/mavenclient) writes the audio to stdout/-out for // `aplay` / inspection. A real PWA / native client plays it directly. package voice import ( "encoding/json" "time" "github.com/kami/maven/internal/audio" "github.com/kami/maven/internal/auth" ) // Surface — the auth surface a client is on. Pulled from internal/auth so // the voice surface maps 1:1 to the auth cascade's surface-caps table // (SurfacePCClient / SurfaceAuthedPage / SurfaceVoice). The wire does NOT // carry the surface — the auth handshake derives it (mTLS metadata, // passkey enrollment); today's floor sets it to SurfacePCClient (the // reference client's surface), capped at L3 per the table. type Surface = auth.Surface // Re-export the surface constants so the rest of the voice package and // downstream clients (cmd/mavenclient, daemon wiring) don't need to import // internal/auth directly. The auth package IS the source of truth; these // aliases forward to it. const ( SurfaceVoice = auth.SurfaceVoice SurfaceTelegram = auth.SurfaceTelegram SurfacePCClient = auth.SurfacePCClient SurfaceAuthedPage = auth.SurfaceAuthedPage SurfaceCoreProcess = auth.SurfaceCoreProcess SurfaceUnknown = auth.SurfaceUnknown ) // maxFrame — 64 MiB. Same instinct as worker: a single utterance at 16k mono // int16 ⇒ ~1.9 MiB/min; 64 MiB covers ~33 minutes of audio in one frame, // which comfortably includes a long pushed reply (a few seconds) and a // long upload (push-to-talk clips). const maxFrame = 64 << 20 // Method — one client→server verb. Adding one is a voice API change (the // client has to learn it). Today: PushToTalk (reactive round-trip) + Pong // (liveness/last-active update). Proactive is server-initiated (a Push // frame), not a Method. type Method string const ( MethodPushToTalk Method = "push_to_talk" // client audio → reply audio (sync) MethodPong Method = "pong" // reply to a Ping push, refreshes last-active ) // Request — one frame from client to server. ID is chosen by the client, // monotonically increasing per conn; the server echoes it back in the // matching Response so the client can multiplex (today it doesn't, but the // field is reserved for an async client library later). type Request struct { ID uint64 `json:"id"` Method Method `json:"m"` Params json.RawMessage `json:"p,omitempty"` } // Response — one frame from server to client, matching a Request.ID. For // today's synchronous PushToTalk, the server writes the Response on the // same conn immediately after handling the Request (the client blocks // reading). Asynchronous replies (server-initiated) come as Push frames, // not Responses. type Response struct { ID uint64 `json:"id"` Result json.RawMessage `json:"r,omitempty"` Error *RpcError `json:"e,omitempty"` } // Push — one server-initiated frame. No matching Request.ID (the field is // absent). Kind names the push type; today the only Push is the proactive // voice nudge (KindAudioNudge), where the server has TTS'd a nudge to audio // and wants the client to play it. A Push arriving on a conn that's // awaiting a Request response is interleaved — the client distinguishes by // the json shape (Response has `id`, Push has `kind`). type Push struct { Kind PushKind `json:"kind"` Params json.RawMessage `json:"p,omitempty"` } // PushKind — one server→client push verb. type PushKind string const ( // PushKindAudioNudge — proactive delivery: a loop rule fired and the // phraser rendered its Body; the TTS module synthesised audio; the // voicesink picked this session (most-recently-active) and is pushing // the bytes here for the client to play. Params: AudioNudgePush. PushKindAudioNudge PushKind = "audio_nudge" // PushKindPing — liveness probe. Server may send this to refresh // last-active; client may respond with MethodPong (today optional — // last-active is updated by ANY frame from the client, including the // next PushToTalk). Reserved for heartbeat wiring. PushKindPing PushKind = "ping" ) // RpcError — typed wire error, same shape as ipc's. Sentinel codes mirror // the package sentinels 1:1 (see errors.go). Errors don't carry internal // text across the wire except for bad-params / internal codes (diagnostic // only; not authority-bearing — auth refusals carry codeForbidden with no // message). type RpcError struct { Code string `json:"c"` Message string `json:"m,omitempty"` } // PushToTalkReq — the push-to-talk payload. Audio is the captured PCM // (format declared in the audio.Audio.Format). Lang is the requested // recognition language for this utterance, overriding the daemon's default // for mid-sentence code-switches ("ru" / "en" / "mixed"). Surface is the // client's auth surface; today the floor sets it to SurfacePCClient for // every conn, but the wire carries it so future mTLS / passkey handshakes // can populate it without a protocol version bump. type PushToTalkReq struct { Audio audio.Audio `json:"audio"` Lang string `json:"lang,omitempty"` Surface Surface `json:"surface,omitempty"` } // PushToTalkResp — the reply. ReplyAudio is TTS-synthesised; ReplyText is // the same reply in text form (for clients that can't play audio, for // logging, for tests asserting the round-trip shape). Routed channels list // the away channels the dispatcher also delivered to (e.g. a low-severity // nudge fired alongside the reply and was forwarded to ntfy); today the // reactive handler doesn't dispatch nudges, so this is empty. type PushToTalkResp struct { ReplyAudio audio.Audio `json:"reply_audio"` ReplyText string `json:"reply_text"` Transcript string `json:"transcript,omitempty"` RoutedChannels []string `json:"routed_channels,omitempty"` } // AudioNudgePush — the proactive nudge push payload. RuleName + Severity // for the client to display; Audio is the synthesised Body; Text is the // same in text form. type AudioNudgePush struct { RuleName string `json:"rule_name"` Severity int `json:"severity"` Audio audio.Audio `json:"audio"` Text string `json:"text"` Ts time.Time `json:"ts"` }