package ttsgateway import ( "context" "crypto/tls" "crypto/x509" "fmt" "log/slog" "os" "path/filepath" "time" "gitea.nik4nao.com/nik/home-services/discord-bot/internal/logger" ttsv1 "gitea.nik4nao.com/nik/home-services/gen/tts/v1" "go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc" "google.golang.org/grpc" "google.golang.org/grpc/credentials" "google.golang.org/grpc/credentials/insecure" ) // Client implements the app's TTS driven port over gRPC. type Client struct { conn *grpc.ClientConn client ttsv1.TTSServiceClient log *slog.Logger } // New constructs a gRPC client for the internal tts-gateway service. func New(ctx context.Context, addr, tlsDir string, log *slog.Logger) (*Client, error) { transportCreds := insecure.NewCredentials() if tlsDir != "" { creds, err := loadTransportCredentials(tlsDir) if err != nil { return nil, fmt.Errorf("load mTLS credentials: %w", err) } transportCreds = creds } conn, err := grpc.NewClient( addr, grpc.WithTransportCredentials(transportCreds), grpc.WithStatsHandler(otelgrpc.NewClientHandler()), ) if err != nil { return nil, fmt.Errorf("dial tts-gateway: %w", err) } return &Client{ conn: conn, client: ttsv1.NewTTSServiceClient(conn), log: log, }, nil } // Close closes the underlying gRPC connection. func (c *Client) Close() error { if err := c.conn.Close(); err != nil { return fmt.Errorf("close tts-gateway client: %w", err) } return nil } // Synthesize forwards a speech synthesis request to tts-gateway. noise_scale/ // noise_scale_w/length_scale are left unset so tts-gateway applies its own defaults. func (c *Client) Synthesize(ctx context.Context, speakerName, text string) ([]byte, string, error) { start := time.Now() log := logger.FromContext(ctx).With("grpc.method", "TTSService/Synthesize") resp, err := c.client.Synthesize(ctx, &ttsv1.SynthesizeRequest{ SpeakerName: speakerName, Text: text, }) if err != nil { log.Error("grpc call failed", "duration_ms", time.Since(start).Milliseconds(), "error", err.Error(), ) return nil, "", fmt.Errorf("synthesize speech: %w", err) } log.Debug("grpc call completed", "duration_ms", time.Since(start).Milliseconds()) return resp.GetAudio(), resp.GetMimeType(), nil } // ListSpeakers returns the speaker roster from tts-gateway, optionally filtered by search. func (c *Client) ListSpeakers(ctx context.Context, search string) ([]string, error) { start := time.Now() log := logger.FromContext(ctx).With("grpc.method", "TTSService/ListSpeakers") resp, err := c.client.ListSpeakers(ctx, &ttsv1.ListSpeakersRequest{Search: search}) if err != nil { log.Error("grpc call failed", "duration_ms", time.Since(start).Milliseconds(), "error", err.Error(), ) return nil, fmt.Errorf("list tts-gateway speakers: %w", err) } log.Debug("grpc call completed", "duration_ms", time.Since(start).Milliseconds()) return append([]string(nil), resp.GetSpeakerNames()...), nil } func loadTransportCredentials(tlsDir string) (credentials.TransportCredentials, error) { cert, err := tls.LoadX509KeyPair( filepath.Join(tlsDir, "tls.crt"), filepath.Join(tlsDir, "tls.key"), ) if err != nil { return nil, fmt.Errorf("load client key pair: %w", err) } caPEM, err := os.ReadFile(filepath.Join(tlsDir, "ca.crt")) if err != nil { return nil, fmt.Errorf("read server CA: %w", err) } rootCAs := x509.NewCertPool() if !rootCAs.AppendCertsFromPEM(caPEM) { return nil, fmt.Errorf("append server CA: invalid PEM") } return credentials.NewTLS(&tls.Config{ Certificates: []tls.Certificate{cert}, RootCAs: rootCAs, ServerName: "tts-gateway.home-services.svc.cluster.local", MinVersion: tls.VersionTLS13, }), nil }