initial commit

This commit is contained in:
kami
2026-07-03 00:32:48 +02:00
commit 612583d59a
92 changed files with 14521 additions and 0 deletions
+186
View File
@@ -0,0 +1,186 @@
// worker/client.go — the core-side dialer.
//
// Core is the CLIENT of its stt/tts worker modules: it dials them, ships
// audio bytes (Transcribe) and reply text (Synthesize), reads back the
// result. One Client ⇒ one conn ⇒ one mutex ⇒ no frame interleaving by
// construction (a module needing parallelism opens N clients, but stt/tts
// jobs are serial at single-user scale — the model is the bottleneck, not
// the wire). ctx cancel ⇒ conn close (a half-sent frame desyncs the stream;
// teardown is the clean recovery, a fresh Dial is the caller's job on next
// call). Same instinct as internal/ipc/client.go.
//
// The daemon holds two Clients — one for the stt module, one for tts — each
// behind a stt.Transcriber / tts.Synthesizer interface (see internal/stt,
// internal/tts). The interface IS the swap seam: a Stub impl satisfies the
// same interface in-process; the Remote wraps this Client. Core never sees
// a difference.
package worker
import (
"context"
"encoding/json"
"fmt"
"net"
"sync"
"time"
)
// Client — one connection to one worker module. NOT goroutine-safe for
// concurrent calls on the same conn: the mutex serializes writes per
// request so a frame doesn't interleave with another, but parallel calls
// block on each other. Open N clients for N parallel jobs.
type Client struct {
path string
mu sync.Mutex
c net.Conn
dial func() (net.Conn, error)
}
// Dial opens a Client to the worker socket at path. The first call lazily
// dials; subsequent calls reuse the conn (a fresh dial happens on next call
// after a teardown). Lazy dial keeps a worker that's restarting from
// blocking core's startup; core attempts the dial on first use.
func Dial(path string) *Client {
return &Client{
path: path,
dial: func() (net.Conn, error) {
return net.Dial("unix", path)
},
}
}
// Close releases the conn. Idempotent; subsequent calls re-dial on demand.
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
}
// Transcribe calls the transcribe verb on the connected worker module. Raises
// ErrUnknownMethod when the worker serves synthesize only; the daemon wiring
// has pointed this Client at the wrong socket, surfaced as a clean error.
func (c *Client) Transcribe(ctx context.Context, req TranscribeReq) (TranscribeResp, error) {
var resp TranscribeResp
err := c.call(ctx, MethodTranscribe, req, &resp)
return resp, err
}
// Synthesize calls the synthesize verb.
func (c *Client) Synthesize(ctx context.Context, req SynthesizeReq) (SynthesizeResp, error) {
var resp SynthesizeResp
err := c.call(ctx, MethodSynthesize, req, &resp)
return resp, err
}
func (c *Client) call(ctx context.Context, m Method, params any, out any) error {
body, err := marshalParams(params)
if err != nil {
return err
}
req := Request{Method: m, Params: body}
c.mu.Lock()
defer c.mu.Unlock()
if err := c.ensureConnLocked(ctx); err != nil {
return err
}
// ctx cancel ⇒ close conn so a stalled peer doesn't hang the caller; a
// half-sent frame would desync the stream. Teardown is the clean
// recovery; the next call re-dials.
if dl, ok := ctx.Deadline(); ok {
_ = c.c.SetDeadline(dl)
} else {
_ = c.c.SetDeadline(time.Now().Add(defaultCallTimeout))
}
defer c.c.SetDeadline(time.Time{})
if err := writeFrame(c.c, req); err != nil {
c.teardownLocked()
return err
}
var resp Response
if err := readFrame(c.c, &resp); err != nil {
c.teardownLocked()
return err
}
if resp.Error != nil {
return hydrate(resp.Error)
}
if err := json.Unmarshal(resp.Result, out); err != nil {
return fmt.Errorf("worker: unmarshal result: %w", err)
}
return nil
}
func (c *Client) ensureConnLocked(ctx context.Context) error {
if c.c != nil {
return nil
}
connCh := make(chan net.Conn, 1)
errCh := make(chan error, 1)
go func() {
nc, err := c.dial()
if err != nil {
errCh <- err
return
}
connCh <- nc
}()
select {
case nc := <-connCh:
c.c = nc
return nil
case err := <-errCh:
return fmt.Errorf("worker: dial %s: %w", c.path, err)
case <-ctx.Done():
// the dial goroutine will finish or not; the conn if any is leaked
// to GC. acceptable — dial failures are rare, and a leaked closed
// socket is harmless.
return ctx.Err()
}
}
func (c *Client) teardownLocked() {
if c.c != nil {
_ = c.c.Close()
c.c = nil
}
}
// defaultCallTimeout — a worker that hangs > 60s is dead or stuck on a
// model forward pass that's gone off the rails. Surface as a timeout
// instead of hanging the daemon's tick / voice path. Production may override
// via ctx (a long meeting-record transcription, post-MVP).
const defaultCallTimeout = 60 * time.Second
func hydrate(e *RpcError) error {
switch e.Code {
case codeUnknownMethod:
return fmt.Errorf("%w: %s", ErrUnknownMethod, e.Message)
case codeBadParams:
return fmt.Errorf("%w: %s", ErrBadParams, e.Message)
default:
if e.Message != "" {
return fmt.Errorf("worker: %s: %s", e.Code, e.Message)
}
return fmt.Errorf("worker: %s", e.Code)
}
}
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("worker: marshal params: %w", err)
}
return b, nil
}
+67
View File
@@ -0,0 +1,67 @@
// worker/frame.go — length-prefixed JSON framing.
//
// Identical shape to internal/ipc/frame.go with one difference: the cap is
// 64 MiB here vs 4 there. Audio bytes (base64-encoded in JSON) push frame
// sizes up to ~80% over their decoded size; 64 MiB on the wire comfortably
// allows minutes-long single utterances, while still bounding a confused
// peer's length prefix from allocating an unbounded buffer.
//
// Binary bytes are base64-encoded inside the JSON envelope. Local socket
// + single-user scale ⇒ the 33% expansion cost is invisible vs. a model
// forward pass; the benefit is one debuggable wire shape (socat shows a
// readable JSON frame, no separate byte-pump to inspect audio).
package worker
import (
"encoding/binary"
"encoding/json"
"errors"
"fmt"
"io"
)
// ErrFrameTooLarge — a frame exceeded maxFrame; the conn is now
// desynchronized (we read the length but not the body), so the caller must
// close it.
var ErrFrameTooLarge = errors.New("worker: frame too large")
func writeFrame(w io.Writer, v any) error {
body, err := json.Marshal(v)
if err != nil {
return fmt.Errorf("worker: marshal frame: %w", err)
}
if len(body) > maxFrame {
return fmt.Errorf("%w: %d bytes", ErrFrameTooLarge, len(body))
}
var hdr [4]byte
binary.BigEndian.PutUint32(hdr[:], uint32(len(body)))
if _, err := w.Write(hdr[:]); err != nil {
return fmt.Errorf("worker: write frame header: %w", err)
}
if _, err := w.Write(body); err != nil {
return fmt.Errorf("worker: write frame body: %w", err)
}
return nil
}
func readFrame(r io.Reader, v any) error {
var hdr [4]byte
if _, err := io.ReadFull(r, hdr[:]); err != nil {
if errors.Is(err, io.EOF) {
return io.EOF
}
return fmt.Errorf("worker: read frame header: %w", err)
}
n := binary.BigEndian.Uint32(hdr[:])
if n > maxFrame {
return fmt.Errorf("%w: %d bytes", ErrFrameTooLarge, n)
}
buf := make([]byte, n)
if _, err := io.ReadFull(r, buf); err != nil {
return fmt.Errorf("worker: read frame body: %w", err)
}
if err := json.Unmarshal(buf, v); err != nil {
return fmt.Errorf("worker: unmarshal frame: %w", err)
}
return nil
}
+25
View File
@@ -0,0 +1,25 @@
// worker/handler.go — the worker-side seam. A module process implements
// either Transcriber or Synthesizer (rarely both — stt and tts are
// independent units with independent model weights and restart lifecycles).
// Server dispatches a Method to the matching handler.
package worker
import "context"
// Transcriber — the stt module's contract. Implementations:
// - the daemon's in-process Stub (built-in stt stub handler, for tests +
// for the "no models on disk yet" floor; the daemon decides which to wire
// from config),
// - a real faster-whisper / vosk process's main (cmd/mavsttd/main.go today
// ships the Stub handler; production swaps in onnxruntime-backed code in
// that same main, no worker-package change).
type Transcriber interface {
Transcribe(ctx context.Context, req TranscribeReq) (TranscribeResp, error)
}
// Synthesizer — the tts module's contract. Mirrors Transcriber (separate
// interface so the two modules can be in separate packages / binaries, and
// so a server asked for the wrong verb refuses cleanly).
type Synthesizer interface {
Synthesize(ctx context.Context, req SynthesizeReq) (SynthesizeResp, error)
}
+54
View File
@@ -0,0 +1,54 @@
// worker/jobs.go — the two verb payloads (Transcribe + Synthesize).
//
// Audio bytes are base64-encoded by encoding/json automatically via the
// []byte type — the wire shape is a string field carrying base64. Local
// unix socket ⇒ expansion cost is invisible; the JSON envelope stays
// debuggable per the package doc.
//
// Input shape (Transcribe): core has captured audio (push-to-talk) or
// synthesised it (TTS round-trip test path — non-production). The worker
// module consumes audio bytes with the declared Format. Lang is a hint
// ("ru"/"en"/"mixed"); faster-whisper is multilingual and treats the hint
// as a soft bias, vosk ru ignores it (one-model, one-language). The reference
// stt stub ignores all params and returns a canned phrase so the loop is
// exercisable without a model on disk.
//
// Output shape (Transcribe): Text is the recognized string. Confidence is
// the model's own estimate when available; 0 ⇒ unknown. The router downstream
// does its own confidence scoring (the cosine-sim classifier), so worker-side
// confidence is for logging/gating, not for routing.
//
// Input shape (Synthesize): text to render. Lang is the requested voice
// language ("ru"/"en"). Voice ID is a named voice when supported, "" ⇒ the
// worker's configured default. Speed is a 1.0 = normal multiplier; out of
// range is clamped by the worker, not the caller.
//
// Output shape (Synthesize): Audio is the rendered PCM bytes in Format.
package worker
import "github.com/kami/maven/internal/audio"
// TranscribeReq — the transcribe verb args.
type TranscribeReq struct {
Audio audio.Audio `json:"audio"` // capture bytes (raw PCM, format declared)
Lang string `json:"lang"` // "ru" | "en" | "mixed" | "" ⇒ module default
}
// TranscribeResp — the transcribe verb result.
type TranscribeResp struct {
Text string `json:"text"`
Confidence float64 `json:"confidence,omitempty"` // 0 ⇒ unknown
}
// SynthesizeReq — the synthesize verb args.
type SynthesizeReq struct {
Text string `json:"text"`
Lang string `json:"lang"` // "ru" | "en" | ""
Voice string `json:"voice"` // named voice or "" ⇒ worker default
Speed float64 `json:"speed"` // 1.0 = normal; clamped server-side
}
// SynthesizeResp — the synthesize verb result.
type SynthesizeResp struct {
Audio audio.Audio `json:"audio"` // rendered PCM
}
+226
View File
@@ -0,0 +1,226 @@
// worker/server.go — the worker-side listener + accept loop.
//
// Each call is dispatched to a single handler (Transcriber OR Synthesizer,
// depending on what the module process);
// the other verb returns ErrUnknownMethod — a stt process won't serve
// synthesize. robust to a misconfigured client (the daemon wiring chooses
// which module to dial; mixing the two is a config error caught cleanly by
// the wire, not a runtime goroutine panic). One Server per module process.
//
// Socket perms mirror ipc.Server: dir 0700, socket 0600 ⇒ same unix user.
// The module has no key, so the floor is "same user"; the wg/mTLS layers
// are out of scope here (this socket never crosses the network radius —
// it's local-only, point-to-point between two processes on the box).
package worker
import (
"context"
"encoding/json"
"fmt"
"net"
"os"
"sync"
"sync/atomic"
"golang.org/x/sys/unix"
)
// Server — a worker module process's listener. Wires either a Transcriber,
// a Synthesizer, or both (the both case is unusual; the daemon typically
// runs two separate module processes). The unset verb returns
// ErrUnknownMethod.
type Server struct {
t Transcriber
s Synthesizer
path string
ln net.Listener
wg sync.WaitGroup
done chan struct{}
// connCount — assigned per accepted conn, used in logs to distinguish
// concurrent connections. Monotonic; not load-bearing for correctness.
connCount atomic.Uint64
}
// NewServer builds a Server with a Transcriber. The caller wires a
// Synthesizer via SetSynthesizer if this process serves tts. Use
// NewSynthesizerServer for the tts-only case (mirrors this constructor).
func NewServer(path string, t Transcriber) *Server {
return &Server{t: t, path: path, done: make(chan struct{})}
}
// NewSynthesizerServer builds a Server with a Synthesizer (the tts module).
func NewSynthesizerServer(path string, s Synthesizer) *Server {
return &Server{s: s, path: path, done: make(chan struct{})}
}
// SetSynthesizer wires the synthesize verb on a Transcriber-built Server.
// Used only when one process serves both (non-default; the daemon prefers
// two separate processes per the restart-free / fail-independent invariant).
func (srv *Server) SetSynthesizer(s Synthesizer) { srv.s = s }
// Listen binds the unix socket with 0700 dir + 0600 socket perms (same floor
// as internal/ipc). A stale socket at path is removed first so the worker
// process restarts cleanly after a crash, no manual cleanup needed.
func (srv *Server) Listen() error {
_ = os.Remove(srv.path)
if err := os.MkdirAll(parentDir(srv.path), 0o700); err != nil {
return fmt.Errorf("worker: mkdir socket dir: %w", err)
}
oldMask := unix.Umask(0o077)
ln, err := net.Listen("unix", srv.path)
unix.Umask(oldMask)
if err != nil {
return fmt.Errorf("worker: listen %s: %w", srv.path, err)
}
if err := os.Chmod(srv.path, 0o600); err != nil {
_ = ln.Close()
_ = os.Remove(srv.path)
return fmt.Errorf("worker: chmod socket: %w", err)
}
srv.ln = ln
return nil
}
// Path returns the bound socket path (after Listen; "" before).
func (srv *Server) Path() string { return srv.path }
// Serve accepts connections until the listener closes. Each connection is
// served in its own goroutine; a panicking handler tears down only that conn
// (the rest of the module keeps serving, restart-free per spec).
func (srv *Server) Serve() error {
if srv.ln == nil {
return fmt.Errorf("worker: serve before listen")
}
for {
c, err := srv.ln.Accept()
if err != nil {
select {
case <-srv.done:
return nil
default:
return fmt.Errorf("worker: accept: %w", err)
}
}
srv.wg.Add(1)
go func(c net.Conn) {
defer srv.wg.Done()
defer c.Close()
srv.serveConn(c)
}(c)
}
}
func (srv *Server) serveConn(c net.Conn) {
id := srv.connCount.Add(1)
for {
var req Request
if err := readFrame(c, &req); err != nil {
return // EOF / malformed ⇒ end this conn
}
result, err := srv.safeDispatch(c.RemoteAddr(), id, req)
resp := Response{}
if err != nil {
resp.Error = rpcErr(err)
} else {
resp.Result = result
}
if err := writeFrame(c, resp); err != nil {
return
}
}
}
func (srv *Server) safeDispatch(addr net.Addr, id uint64, req Request) (result json.RawMessage, err error) {
defer func() {
if r := recover(); r != nil {
err = fmt.Errorf("worker: panic dispatching %s (conn %d): %v", req.Method, id, r)
}
}()
return srv.dispatch(req)
}
func (srv *Server) dispatch(req Request) (json.RawMessage, error) {
switch req.Method {
case MethodTranscribe:
if srv.t == nil {
return nil, fmt.Errorf("%w: %s", ErrUnknownMethod, req.Method)
}
var p TranscribeReq
if err := unmarshalParams(req.Params, &p); err != nil {
return nil, err
}
resp, err := srv.t.Transcribe(context.Background(), p)
if err != nil {
return nil, err
}
return marshalResult(resp), nil
case MethodSynthesize:
if srv.s == nil {
return nil, fmt.Errorf("%w: %s", ErrUnknownMethod, req.Method)
}
var p SynthesizeReq
if err := unmarshalParams(req.Params, &p); err != nil {
return nil, err
}
resp, err := srv.s.Synthesize(context.Background(), p)
if err != nil {
return nil, err
}
return marshalResult(resp), nil
default:
return nil, fmt.Errorf("%w: %s", ErrUnknownMethod, req.Method)
}
}
// Close stops accepting and waits for in-flight connections to drain. The
// socket file is removed so a restart can rebind cleanly. Idempotent.
func (srv *Server) Close() error {
select {
case <-srv.done:
return nil
default:
close(srv.done)
}
if srv.ln == nil {
return nil
}
err := srv.ln.Close()
srv.wg.Wait()
_ = os.Remove(srv.path)
return err
}
func unmarshalParams(raw json.RawMessage, v any) error {
if len(raw) == 0 {
raw = []byte("null")
}
if err := json.Unmarshal(raw, v); err != nil {
return fmt.Errorf("%w: %v", ErrBadParams, err)
}
return nil
}
func marshalResult(v any) json.RawMessage {
if v == nil {
return json.RawMessage("null")
}
b, _ := json.Marshal(v)
return b
}
func parentDir(p string) string {
for i := len(p) - 1; i >= 0; i-- {
if p[i] == '/' {
if i == 0 {
return "/"
}
return p[:i]
}
}
return "."
}
+123
View File
@@ -0,0 +1,123 @@
// Package worker is the audio-job boundary between core and its stt/tts
// modules.
//
// It is a SIBLING to internal/ipc, not a reuse of it:
//
// - internal/ipc is the core↔module *state* boundary. Modules call INTO
// core to write facts / read presence. core is the server; the module
// is the client. The verbs (WriteFact, Since, ...) are authority-bound;
// the auth layer scopes them per Caller.
// - internal/worker is the core↔module *job* boundary. Core calls OUT to
// the stt/tts module: "transcribe these bytes", "synthesize this text."
// Core is the client here; the worker module is the server. The verbs
// are not authority-bound — they are pure compute over the payload core
// already owns (audio bytes / reply text). Nothing about authority moves
// across this boundary because the worker has no key, no facts, nothing
// stateful to read or write.
//
// The reversal matters: a module that ships audio work to core would have
// to hold a Caller identity, an enrolled daemon surface, etc.; core that
// ships work to a module ships only the work. Same unix-socket floor (0600
// dir + socket perms carry "same user") but the flow is reversed, and the
// surface stays out of the auth cascade — the worker never reaches into
// core's state, so it has nothing to be capped against.
//
// Transport: unix domain socket, local-only, same-host as core. Same wire
// shape as ipc (4-byte big-endian length + JSON body) so `socat`/`nc` can
// debug it identically. Frame cap is 64 MiB (vs ipc's 4) so audio blobs fit
// — a minute of 16k mono int16 is ~1.9 MiB, comfortably under; an hour's
// sleep-clip transcription is implausible to send as one frame but the cap
// permits long-enough live clips without framing the worker into chunks.
package worker
import (
"encoding/json"
"errors"
"fmt"
)
// maxFrame — 64 MiB. Audio up to ~3 minutes @ 16k mono int16 fits one frame
// with headroom; longer audio is chunked by the caller (meeting-record mode
// is post-MVP) or rejected with ErrFrameTooLarge. Sized for stt inputs and
// tts outputs at single-utterance scale.
const maxFrame = 64 << 20
// Method — one RPC verb. Adding one is a worker-API change, not an authority
// change (worker has no authority surface — see package doc). The two verbs
// mirror the stt/tts interfaces the daemon wires; new verbs go with a new
// module kind (e.g. translation), not a new feature of an existing one.
type Method string
const (
MethodTranscribe Method = "transcribe"
MethodSynthesize Method = "synthesize"
)
// Request — one frame from core to a worker module.
type Request struct {
Method Method `json:"m"`
Params json.RawMessage `json:"p,omitempty"`
}
// Response — one frame back from the worker. Exactly one of Result/Error is set.
type Response struct {
Result json.RawMessage `json:"r,omitempty"`
Error *RpcError `json:"e,omitempty"`
}
// RpcError — a typed wire error. Mirrors internal/ipc's shape for tooling
// parity (same args to socat, same `errors.Is` rehydration pattern); worker
// sentinels are independent since this boundary is independent.
type RpcError struct {
Code string `json:"c"`
Message string `json:"m,omitempty"`
}
func (e *RpcError) Error() string {
if e.Message != "" {
return fmt.Sprintf("worker: %s: %s", e.Code, e.Message)
}
return fmt.Sprintf("worker: %s", e.Code)
}
// Sentinel codes. Stable over the wire — do not rename. Mirror the package
// sentinels 1:1.
const (
codeUnknownMethod = "unknown_method"
codeBadParams = "bad_params"
codeInternal = "internal"
)
// Sentinel errors. The client rehydrates a wire RpcError into one of these so
// callers use errors.Is the same way they would in-process.
var (
ErrUnknownMethod = errors.New("worker: unknown method")
ErrBadParams = errors.New("worker: bad params")
)
// codeOf maps a server-side sentinel to its wire code. Anything not matched
// is codeInternal — internal Go error text never ships to the caller; the
// server logs the real text and the client sees a generic code.
func codeOf(err error) string {
switch {
case err == nil:
return ""
case errors.Is(err, ErrUnknownMethod):
return codeUnknownMethod
case errors.Is(err, ErrBadParams):
return codeBadParams
default:
return codeInternal
}
}
// rpcErr builds the wire error for a server-side error. message is omitted
// for sentinel codes (the Code carries the meaning). internal errors carry
// the message text — it's not authority-bearing text, just a diagnostic.
func rpcErr(err error) *RpcError {
c := codeOf(err)
if c == codeInternal || c == codeBadParams {
return &RpcError{Code: c, Message: err.Error()}
}
return &RpcError{Code: c}
}
+289
View File
@@ -0,0 +1,289 @@
package worker
import (
"bytes"
"context"
"errors"
"io"
"net"
"os"
"path/filepath"
"sync"
"testing"
"time"
"github.com/kami/maven/internal/audio"
)
// stubTranscriber returns a fixed string; satisfies worker.Transcriber.
type stubTranscriber struct {
mu sync.Mutex
gotLast audio.Audio
}
func (s *stubTranscriber) Transcribe(_ context.Context, req TranscribeReq) (TranscribeResp, error) {
s.mu.Lock()
defer s.mu.Unlock()
s.gotLast = req.Audio
return TranscribeResp{Text: "hello from stt", Confidence: 0.9}, nil
}
type errTranscriber struct{}
func (errTranscriber) Transcribe(context.Context, TranscribeReq) (TranscribeResp, error) {
return TranscribeResp{}, errors.New("synth failed")
}
type stubSynthesizer struct{}
func (stubSynthesizer) Synthesize(_ context.Context, req SynthesizeReq) (SynthesizeResp, error) {
pcm := make([]byte, 3200) // 100ms of silence @ 16k mono int16
_ = req
return SynthesizeResp{Audio: audio.Audio{Format: audio.PCM16kMono, Bytes: pcm}}, nil
}
// newServer builds a Server on a temp socket, starts Serve in a goroutine,
// returns the Server + path + a cleanup. Tests use this to get a real
// round-trip over a unix socket.
func newServer(t *testing.T, srv *Server) (*Server, string, func()) {
t.Helper()
dir := t.TempDir()
sock := filepath.Join(dir, "worker.sock")
srv.path = sock
if err := srv.Listen(); err != nil {
t.Fatalf("listen: %v", err)
}
go func() {
if err := srv.Serve(); err != nil {
t.Logf("serve ended: %v", err)
}
}()
cleanup := func() {
_ = srv.Close()
}
return srv, sock, cleanup
}
func TestServerRejectsUnknownMethod(t *testing.T) {
t.Parallel()
srv := NewSynthesizerServer("", stubSynthesizer{})
_, sock, cleanup := newServer(t, srv)
defer cleanup()
c := Dial(sock)
defer c.Close()
_, err := c.Transcribe(context.Background(), TranscribeReq{Audio: audio.Audio{Format: audio.PCM16kMono, Bytes: []byte{1, 2}}})
if err == nil {
t.Fatalf("Transcribe on a tts server should error")
}
if !errors.Is(err, ErrUnknownMethod) {
t.Fatalf("err should be ErrUnknownMethod, got: %v", err)
}
}
func TestServerTranscribeRoundTrip(t *testing.T) {
t.Parallel()
tr := &stubTranscriber{}
srv := NewServer("", tr)
_, sock, cleanup := newServer(t, srv)
defer cleanup()
c := Dial(sock)
defer c.Close()
in := audio.Audio{Format: audio.PCM16kMono, Bytes: []byte("hello audio payload")}
resp, err := c.Transcribe(context.Background(), TranscribeReq{Audio: in, Lang: "ru"})
if err != nil {
t.Fatalf("Transcribe: %v", err)
}
if resp.Text != "hello from stt" {
t.Fatalf("Text: %q", resp.Text)
}
if resp.Confidence != 0.9 {
t.Fatalf("Confidence: %v", resp.Confidence)
}
if !bytes.Equal(tr.gotLast.Bytes, in.Bytes) {
t.Fatalf("audio bytes did not round-trip: in=%v got=%v", in.Bytes, tr.gotLast.Bytes)
}
}
func TestServerSynthesizeRoundTrip(t *testing.T) {
t.Parallel()
srv := NewSynthesizerServer("", stubSynthesizer{})
_, sock, cleanup := newServer(t, srv)
defer cleanup()
c := Dial(sock)
defer c.Close()
resp, err := c.Synthesize(context.Background(), SynthesizeReq{Text: "привет", Lang: "ru"})
if err != nil {
t.Fatalf("Synthesize: %v", err)
}
if !resp.Audio.Format.IsValid() {
t.Fatalf("audio format invalid: %+v", resp.Audio.Format)
}
if len(resp.Audio.Bytes) != 3200 {
t.Fatalf("audio bytes len: %d, want 3200", len(resp.Audio.Bytes))
}
}
func TestServerUnsetVerbReturnsUnknownMethod(t *testing.T) {
t.Parallel()
srv := NewServer("", &stubTranscriber{}) // transcriber-only
_, sock, cleanup := newServer(t, srv)
defer cleanup()
c := Dial(sock)
defer c.Close()
_, err := c.Synthesize(context.Background(), SynthesizeReq{Text: "x"})
if !errors.Is(err, ErrUnknownMethod) {
t.Fatalf("Synthesize on a stt-only server: want ErrUnknownMethod, got %v", err)
}
}
func TestServerDispatchesBadParams(t *testing.T) {
t.Parallel()
srv := NewServer("", &stubTranscriber{})
_, sock, cleanup := newServer(t, srv)
defer cleanup()
// raw socket: ship a Request frame with malformed params JSON. We can't
// use writeFrame because Request marshals RawMessage and rejects invalid
// JSON itself — so build the bytes by hand: a valid Request envelope
// whose `p` field is a syntactically broken JSON string.
conn, err := net.Dial("unix", sock)
if err != nil {
t.Fatalf("dial: %v", err)
}
defer conn.Close()
// {"m":"transcribe","p":{not json}} — but the `p` value is invalid JSON.
// We instead send `{"m":"transcribe","p":"<not an object>"}` — a valid
// JSON frame whose params unmarshal fails into TranscribeReq (string
// into a struct). That path hits unmarshalParams' error ⇒ codeBadParams.
body := []byte(`{"m":"transcribe","p":"not an object"}`)
var hdr [4]byte
hdr[0] = byte(len(body) >> 24)
hdr[1] = byte(len(body) >> 16)
hdr[2] = byte(len(body) >> 8)
hdr[3] = byte(len(body))
if _, err := conn.Write(hdr[:]); err != nil {
t.Fatalf("write hdr: %v", err)
}
if _, err := conn.Write(body); err != nil {
t.Fatalf("write body: %v", err)
}
var resp Response
if err := readFrame(conn, &resp); err != nil {
t.Fatalf("readFrame: %v", err)
}
if resp.Error == nil || resp.Error.Code != codeBadParams {
t.Fatalf("want codeBadParams, got %+v", resp.Error)
}
}
func TestServerInternalErrorPropagates(t *testing.T) {
t.Parallel()
srv := NewServer("", errTranscriber{})
_, sock, cleanup := newServer(t, srv)
defer cleanup()
c := Dial(sock)
defer c.Close()
_, err := c.Transcribe(context.Background(), TranscribeReq{Audio: audio.Audio{Format: audio.PCM16kMono}})
if err == nil {
t.Fatalf("want error from errTranscriber")
}
if errors.Is(err, ErrUnknownMethod) || errors.Is(err, ErrBadParams) {
t.Fatalf("internal error should not match known sentinels: %v", err)
}
if !contains(err.Error(), "synth failed") {
t.Fatalf("err should carry internal message text, got: %v", err)
}
}
func TestClientContextCancelStopsCall(t *testing.T) {
t.Parallel()
// a server that never replies — transcriber blocked on a chan.
hang := &hangTranscriber{ok: make(chan struct{})}
srv := NewServer("", hang)
_, sock, cleanup := newServer(t, srv)
defer cleanup()
defer close(hang.ok)
c := Dial(sock)
defer c.Close()
ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
defer cancel()
_, err := c.Transcribe(ctx, TranscribeReq{Audio: audio.Audio{Format: audio.PCM16kMono, Bytes: []byte("x")}})
if err == nil {
t.Fatalf("want ctx timeout error")
}
if !contains(err.Error(), "dial") && !errors.Is(err, context.DeadlineExceeded) {
// dial may succeed within 100ms; if so, the deadline set on the conn
// surfaces as an i/o timeout from writeFrame/readFrame. Either path
// is acceptable; we just assert the call returned in finite time.
}
}
type hangTranscriber struct {
ok chan struct{}
}
func (h *hangTranscriber) Transcribe(ctx context.Context, _ TranscribeReq) (TranscribeResp, error) {
select {
case <-h.ok:
return TranscribeResp{}, nil
case <-ctx.Done():
return TranscribeResp{}, ctx.Err()
}
}
func TestServerCloseIsIdempotent(t *testing.T) {
t.Parallel()
srv := NewServer("", &stubTranscriber{})
_, _, cleanup := newServer(t, srv)
cleanup() // first close
if err := srv.Close(); err != nil {
t.Fatalf("second Close should be no-op, got: %v", err)
}
}
func TestFrameTooLargeRejected(t *testing.T) {
t.Parallel()
// build a frame whose length prefix exceeds maxFrame; ensure readFrame
// returns ErrFrameTooLarge.
r, w := io.Pipe()
go func() {
var hdr [4]byte
hdr[0] = 0xff
hdr[1] = 0xff
hdr[2] = 0xff
hdr[3] = 0xff
_, _ = w.Write(hdr[:])
_ = w.Close()
}()
var v any
err := readFrame(r, &v)
if !errors.Is(err, ErrFrameTooLarge) {
t.Fatalf("want ErrFrameTooLarge, got %v", err)
}
_ = r.Close()
}
func TestPathAfterListen(t *testing.T) {
t.Parallel()
srv := NewServer("", &stubTranscriber{})
dir := t.TempDir()
sock := filepath.Join(dir, "x.sock")
srv.path = sock
if err := srv.Listen(); err != nil {
t.Fatalf("listen: %v", err)
}
defer srv.Close()
if srv.Path() != sock {
t.Fatalf("Path: got %q, want %q", srv.Path(), sock)
}
if _, err := os.Stat(sock); err != nil {
t.Fatalf("socket file missing after listen: %v", err)
}
}
func contains(haystack, needle string) bool {
return len(haystack) >= len(needle) && (bytes.Contains([]byte(haystack), []byte(needle)))
}