// Package inferencesidecar implements driven.TTSEngine by calling a small // Python/libtorch sidecar (tts-gateway/sidecar) over HTTP - the fallback // documented in TTS_GATEWAY_PLAN.md's Risk 1 and adopted after the Phase 0 // spike found the checkpoint's data-dependent output length isn't cleanly // exportable via torch.export. Same shape as ai-gateway's Ollama client: // the tensor math lives outside Go, hidden behind this port like any other // outbound dependency. package inferencesidecar import ( "bytes" "context" "encoding/binary" "encoding/json" "fmt" "io" "math" "net/http" "strconv" "time" "gitea.nik4nao.com/nik/home-services/tts-gateway/internal/core/domain" ) type Client struct { baseURL string httpClient *http.Client } // NewClient constructs the sidecar HTTP client. addr is a host:port, e.g. // "localhost:50054" (config.Config.InferenceSidecarAddr). func NewClient(addr string) *Client { return &Client{ baseURL: "http://" + addr, httpClient: &http.Client{Timeout: 60 * time.Second}, } } type synthesizeRequest struct { SpeakerID int `json:"speaker_id"` SymbolIDs []int64 `json:"symbol_ids"` NoiseScale float32 `json:"noise_scale"` NoiseScaleW float32 `json:"noise_scale_w"` LengthScale float32 `json:"length_scale"` } // Synthesize posts symbol IDs + params to the sidecar's /synthesize // endpoint and decodes its raw little-endian float32 PCM response. func (c *Client) Synthesize(ctx context.Context, speakerID int, symbolIDs []int64, params domain.SynthesisParams) ([]float32, int, error) { reqBody, err := json.Marshal(synthesizeRequest{ SpeakerID: speakerID, SymbolIDs: symbolIDs, NoiseScale: params.NoiseScale, NoiseScaleW: params.NoiseScaleW, LengthScale: params.LengthScale, }) if err != nil { return nil, 0, fmt.Errorf("inferencesidecar: marshal request: %w", err) } req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL+"/synthesize", bytes.NewReader(reqBody)) if err != nil { return nil, 0, fmt.Errorf("inferencesidecar: build request: %w", err) } req.Header.Set("Content-Type", "application/json") resp, err := c.httpClient.Do(req) if err != nil { return nil, 0, fmt.Errorf("inferencesidecar: request failed: %w", err) } defer resp.Body.Close() data, err := io.ReadAll(resp.Body) if err != nil { return nil, 0, fmt.Errorf("inferencesidecar: read response: %w", err) } if resp.StatusCode != http.StatusOK { return nil, 0, fmt.Errorf("inferencesidecar: %s: %s", resp.Status, string(data)) } sampleRate, err := strconv.Atoi(resp.Header.Get("X-Sample-Rate")) if err != nil { return nil, 0, fmt.Errorf("inferencesidecar: missing/invalid X-Sample-Rate header: %w", err) } if len(data)%4 != 0 { return nil, 0, fmt.Errorf("inferencesidecar: response length %d not a multiple of 4", len(data)) } pcm := make([]float32, len(data)/4) for i := range pcm { bits := binary.LittleEndian.Uint32(data[i*4 : i*4+4]) pcm[i] = math.Float32frombits(bits) } return pcm, sampleRate, nil }