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>
241 lines
6.5 KiB
Go
241 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
|
|
}
|