d52f60c54e
- 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>
183 lines
8.4 KiB
Go
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"`
|
|
}
|