Files
kami d52f60c54e maven: fix test mocks for CalendarEvents interface (verification)
- Add CalendarEvents method to recordingAPI in auth_test.go
- Add CalendarEvents method to fakeCore in handlers_test.go

Co-Authored-By: opencode <opencode@anthropic.com>
2026-07-06 04:20:16 +04:00

183 lines
8.4 KiB
Go

// 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"`
}