Files
Maven/internal/voice/client.go
T
2026-07-03 00:32:48 +02:00

240 lines
6.5 KiB
Go

// 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. SendRequest loops reading frames, drops Push
// frames to the harness if a receiver is running (or silently if not),
// and returns the first Response with the matching ID.
package voice
import (
"context"
"encoding/json"
"errors"
"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
mu sync.Mutex
c net.Conn
nextID atomic.Uint64
// pushCh fan-out: a reader goroutine (started by RunPushReceiver)
// writes Push frames here; SendRequest also drains it when no reader
// is running (drops the frame in that case).
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} }
// 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
}
// SendRequest sends one Request frame and waits for the matching Response.
// Push frames received while waiting are dropped on the floor UNLESS a
// PushHandler has been wired via RunPushReceiver, in which case the handler
// is invoked inline (still synchronous with the SendRequest caller's
// read). For sanity, the reference client runs either one-shot (no
// receiver) or interactive (RunPushReceiver, no concurrent SendRequest).
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 := c.c
c.mu.Unlock()
if dl, ok := ctx.Deadline(); ok {
_ = conn.SetDeadline(dl)
} else {
_ = conn.SetDeadline(time.Now().Add(120 * time.Second))
}
defer conn.SetDeadline(time.Time{})
if err := writeFrame(conn, &req); err != nil {
c.teardown()
return err
}
for {
resp, push, err := readOneFrame(conn)
if err != nil {
c.teardown()
return err
}
if push != nil {
c.deliverPush(*push)
continue
}
if resp.ID != id {
continue // not ours; ignore (singleplex ⇒ shouldn't happen)
}
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
}
}
// RunPushReceiver spawns a reader goroutine that delivers Push frames to h
// until the conn closes or Close is called. Today's reference client uses
// this in -listen mode (proactive voice playback). SendRequest and
// RunPushReceiver SHOULD NOT be used concurrently on the same Client — the
// wire is singleplex at the reference client's scale; production picks one
// mode per conn. Returns when the goroutine ends (ctx cancel or conn close).
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
}
conn := c.c
c.mu.Unlock()
c.pushMu.Lock()
c.pushH = h
c.pushMu.Unlock()
defer func() {
c.pushMu.Lock()
c.pushH = nil
c.pushMu.Unlock()
}()
for {
select {
case <-ctx.Done():
return ctx.Err()
default:
}
_, push, err := readOneFrame(conn)
if err != nil {
if errors.Is(err, io.EOF) || errors.Is(err, net.ErrClosed) {
return nil
}
return err
}
if push != nil {
c.deliverPush(*push)
}
}
}
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
return nil
}
func (c *Client) teardown() {
c.mu.Lock()
defer c.mu.Unlock()
if c.c != nil {
_ = 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
}