// voice/client.go — the client-side dialer. // // Used by cmd/mavenclient to round-trip a PushToTalk. One Client = one TCP // conn = one frame at a time (singleplex — the reference client doesn't // multiplex; production may, but the wire shape already carries IDs so // multiplexing is a client-lib affair, not a wire change). When the user // wants to receive proactive voice pushes, RunPushReceiver spawns a reader // goroutine that calls the PushHandler for each server-initiated Push frame. // // The wire is symmetric: a Request from the client is answered by a // Response with a matching ID, OR a server-initiated Push frame (no ID) // may arrive interleaved. One reader goroutine per connection owns the // socket. It hands each Response to whichever SendRequest is waiting on // that ID and each Push to the handler, so a client may send and listen // at the same time on one conn. mavwaked needs exactly that: it speaks // utterances and it must hear nudges, and the server routes a nudge to // the session that spoke most recently, so a second listening conn would // never be picked (V-671). package voice import ( "context" "encoding/json" "fmt" "io" "net" "sync" "sync/atomic" "time" "github.com/kami/maven/internal/audio" ) // PushHandler — receives server-initiated Push frames. cmd/mavenclient's // -listen mode wires one that writes the audio to disk / aplay. The Push // arrives on the reader goroutine; the handler runs there too, so a slow // handler blocks subsequent frames (intentional — proactive voice playback // should not queue up behind a stuck player; the next nudge replaces the // stale one in the user's attention, not dogpiles on it). type PushHandler interface { OnPush(p Push) } // Client — one connection to the voice.Server. type Client struct { addr string nextID atomic.Uint64 mu sync.Mutex c net.Conn // pending holds one channel per in-flight request, keyed by frame id. // The reader goroutine delivers the Response here and deletes the entry. pending map[uint64]chan *Response // dead is closed by the reader goroutine when this conn ends, so a // waiting SendRequest fails at once instead of at its own deadline. dead chan struct{} // wmu serialises writes. Frames must not interleave on the wire. wmu sync.Mutex // pushH is set by RunPushReceiver and survives a reconnect, because the // client that wants pushes wants them on whatever conn it ends up with. pushMu sync.Mutex pushH PushHandler } // Dial returns a Client that will connect to addr on first use. func Dial(addr string) *Client { return &Client{addr: addr} } // Connect opens the conn now rather than on the first request. mavwaked calls // it at startup: the server registers a session on accept, and a client that // has never connected cannot be sent a nudge. func (c *Client) Connect(ctx context.Context) error { c.mu.Lock() defer c.mu.Unlock() return c.ensureConnLocked(ctx) } // Close releases the conn. Idempotent. func (c *Client) Close() error { c.mu.Lock() defer c.mu.Unlock() if c.c == nil { return nil } err := c.c.Close() c.c = nil return err } // PushToTalk is the convenience for fire-and-forget: send audio bytes + // language, block for the reply. Used by cmd/mavenclient's one-shot mode. func (c *Client) PushToTalk(ctx context.Context, a audio.Audio, lang string) (PushToTalkResp, error) { var out PushToTalkResp err := c.SendRequest(ctx, MethodPushToTalk, PushToTalkReq{Audio: a, Lang: lang, Surface: SurfacePCClient}, &out) return out, err } // requestTimeout bounds a round-trip with no deadline on its context. It is // generous because the far end runs speech-to-text, a router and a voice. const requestTimeout = 120 * time.Second // SendRequest sends one Request frame and waits for the matching Response. // Push frames arriving meanwhile go to the handler on the reader goroutine, // so listening and sending on one Client is supported rather than merely // tolerated. func (c *Client) SendRequest(ctx context.Context, m Method, params any, out any) error { body, err := marshalParams(params) if err != nil { return err } id := c.nextID.Add(1) req := Request{ID: id, Method: m, Params: body} c.mu.Lock() if err := c.ensureConnLocked(ctx); err != nil { c.mu.Unlock() return err } conn, dead := c.c, c.dead ch := make(chan *Response, 1) c.pending[id] = ch c.mu.Unlock() c.wmu.Lock() err = writeFrame(conn, &req) c.wmu.Unlock() if err != nil { c.forget(id) c.teardownConn(conn) return err } timer := time.NewTimer(requestTimeout) defer timer.Stop() var resp *Response select { case resp = <-ch: case <-dead: c.forget(id) return fmt.Errorf("voice: connection closed before reply") case <-ctx.Done(): c.forget(id) return ctx.Err() case <-timer.C: c.forget(id) c.teardownConn(conn) return fmt.Errorf("voice: no reply within %s", requestTimeout) } if resp.Error != nil { return hydrate(resp.Error) } if out != nil { if err := json.Unmarshal(resp.Result, out); err != nil { return fmt.Errorf("voice: unmarshal result: %w", err) } } return nil } // forget drops an abandoned request so a late Response is discarded rather // than delivered to nobody. func (c *Client) forget(id uint64) { c.mu.Lock() delete(c.pending, id) c.mu.Unlock() } // RunPushReceiver wires h and blocks until the conn ends or ctx is // cancelled. Frames are read by the per-conn reader goroutine, so a client // may call SendRequest on the same Client while this is running. Returns nil // when the conn ended, so a caller that wants to stay reachable reconnects // and calls it again. func (c *Client) RunPushReceiver(ctx context.Context, h PushHandler) error { c.mu.Lock() if err := c.ensureConnLocked(ctx); err != nil { c.mu.Unlock() return err } dead := c.dead c.mu.Unlock() c.pushMu.Lock() c.pushH = h c.pushMu.Unlock() defer func() { c.pushMu.Lock() c.pushH = nil c.pushMu.Unlock() }() select { case <-ctx.Done(): return ctx.Err() case <-dead: return nil } } // readLoop owns conn for its whole life. It ends on any read error, which is // how a closed conn, a killed server and a cancelled dial all arrive here. func (c *Client) readLoop(conn net.Conn, dead chan struct{}) { defer close(dead) for { resp, push, err := readOneFrame(conn) if err != nil { c.teardownConn(conn) return } if push != nil { c.deliverPush(*push) continue } c.mu.Lock() ch := c.pending[resp.ID] delete(c.pending, resp.ID) c.mu.Unlock() if ch != nil { ch <- resp } } } func (c *Client) deliverPush(p Push) { c.pushMu.Lock() h := c.pushH c.pushMu.Unlock() if h != nil { h.OnPush(p) } } func (c *Client) ensureConnLocked(ctx context.Context) error { if c.c != nil { return nil } d := net.Dialer{Timeout: 10 * time.Second} conn, err := d.DialContext(ctx, "tcp", c.addr) if err != nil { return fmt.Errorf("voice: dial %s: %w", c.addr, err) } c.c = conn c.pending = make(map[uint64]chan *Response) c.dead = make(chan struct{}) go c.readLoop(conn, c.dead) return nil } // teardownConn closes conn and forgets it, but only if it is still the live // one. A reconnect may already have replaced it, and closing the new conn // because the old one died takes the client down on every hiccup. func (c *Client) teardownConn(conn net.Conn) { c.mu.Lock() defer c.mu.Unlock() if c.c != nil && c.c == conn { _ = c.c.Close() c.c = nil } } // readOneFrame — reads one frame and tries Response-then-Push shape. A // frame with a non-zero `id` is a Response; a frame with `kind` and no `id` // is a Push. Exactly one return value is non-nil. func readOneFrame(r io.Reader) (*Response, *Push, error) { var raw struct { ID uint64 `json:"id"` Result json.RawMessage `json:"r,omitempty"` Error *RpcError `json:"e,omitempty"` Kind PushKind `json:"kind,omitempty"` Params json.RawMessage `json:"p,omitempty"` } if err := readFrame(r, &raw); err != nil { return nil, nil, err } if raw.Kind != "" && raw.ID == 0 { return nil, &Push{Kind: raw.Kind, Params: raw.Params}, nil } return &Response{ID: raw.ID, Result: raw.Result, Error: raw.Error}, nil, nil } func marshalParams(v any) (json.RawMessage, error) { if v == nil { return nil, nil } b, err := json.Marshal(v) if err != nil { return nil, fmt.Errorf("voice: marshal params: %w", err) } return b, nil }