1f1e002789
SendRequest and RunPushReceiver each read the conn, so a client that wanted both raced for every frame. A second listening conn is not the fix: it never sends a request, so its lastActive never moves and PushToMostRecent never picks it. mavwaked needs both on one conn. The reader now owns the socket for the life of the conn. It hands each Response to whichever SendRequest waits on that id, and each Push to the handler. SendRequest waits on its own channel, on the conn dying, on its context, or on a timeout, and forgets its slot on every path that leaves without an answer. RunPushReceiver just wires the handler and blocks. Connect opens the conn without sending anything, for a client that must hold a session before it has spoken. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013ptwopxyo3Z2kwFckHkLvN
294 lines
8.2 KiB
Go
294 lines
8.2 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. 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
|
|
}
|