package voice import ( "context" "fmt" "sync" ) // Service runs STT/TTS requests on a background worker pool so caller // goroutines (the agent loop) are never blocked by provider latency. Each // request is enqueued on a jobs channel and answered on a per-job result // channel; a canceled caller context or a closed service unblocks the caller // immediately and no worker goroutine is leaked. It is safe for concurrent use. type Service struct { chain *Chain jobs chan job bgCtx context.Context cancel context.CancelFunc nWorkers int wg sync.WaitGroup mu sync.RWMutex closed bool } // ServiceOption customizes a Service. type ServiceOption func(*Service) // WithWorkers sets the worker-pool size. Values <= 0 are ignored and the // default single worker is kept. func WithWorkers(n int) ServiceOption { return func(s *Service) { if n > 0 { s.nWorkers = n } } } // jobKind selects the operation a worker performs. type jobKind int const ( jobSTT jobKind = iota jobTTS ) // job is one request enqueued by the caller. type job struct { kind jobKind audio Audio text string res chan jobResult } // jobResult carries the outcome written back to the caller. type jobResult struct { text string audio Audio provider string err error } // NewService builds a Service backed by chain and starts the worker pool. func NewService(chain *Chain, opts ...ServiceOption) *Service { bg, cancel := context.WithCancel(context.Background()) s := &Service{ chain: chain, jobs: make(chan job), bgCtx: bg, cancel: cancel, nWorkers: 1, } for _, o := range opts { o(s) } for i := 0; i < s.nWorkers; i++ { s.wg.Add(1) go s.worker() } return s } // worker consumes jobs until the service closes. func (s *Service) worker() { defer s.wg.Done() for { select { case <-s.bgCtx.Done(): return case j := <-s.jobs: s.run(j) } } } // run executes a job and delivers the result. Providers run under the service's // background context so Close cancels in-flight work and no worker goroutine is // leaked; the result is written to a buffered channel so a caller that already // returned never blocks the worker. func (s *Service) run(j job) { res := jobResult{} switch j.kind { case jobSTT: res.text, res.provider, res.err = s.chain.STTWithProvider(s.bgCtx, j.audio) case jobTTS: res.audio, res.provider, res.err = s.chain.TTSWithProvider(s.bgCtx, j.text) } j.res <- res } // STT enqueues a transcription request and waits for the result. ctx cancels // the wait and the underlying provider call. func (s *Service) STT(ctx context.Context, audio Audio) (string, error) { text, _, err := s.STTWithProvider(ctx, audio) return text, err } // STTWithProvider is STT but also reports the provider rung that produced the // transcription. func (s *Service) STTWithProvider(ctx context.Context, audio Audio) (string, string, error) { res, err := s.submit(ctx, job{kind: jobSTT, audio: audio, res: make(chan jobResult, 1)}) if err != nil { return "", "", err } return res.text, res.provider, res.err } // TTS enqueues a synthesis request and waits for the result. ctx cancels the // wait and the underlying provider call. func (s *Service) TTS(ctx context.Context, text string) (Audio, error) { audio, _, err := s.TTSWithProvider(ctx, text) return audio, err } // TTSWithProvider is TTS but also reports the provider rung that produced the // audio. func (s *Service) TTSWithProvider(ctx context.Context, text string) (Audio, string, error) { res, err := s.submit(ctx, job{kind: jobTTS, text: text, res: make(chan jobResult, 1)}) if err != nil { return Audio{}, "", err } return res.audio, res.provider, res.err } // CloneVoice creates a custom voice, delegating to the underlying chain. It is a // one-shot admin operation (not a worker-pool job), so it runs directly. func (s *Service) CloneVoice(ctx context.Context, referenceURL, prefix string) (string, error) { return s.chain.CloneVoice(ctx, referenceURL, prefix) } // submit enqueues j and blocks until the result or a cancellation. func (s *Service) submit(ctx context.Context, j job) (jobResult, error) { select { case s.jobs <- j: case <-ctx.Done(): return jobResult{}, fmt.Errorf("voice: %w", ctx.Err()) case <-s.bgCtx.Done(): return jobResult{}, ErrClosed } select { case r := <-j.res: return r, nil case <-ctx.Done(): return jobResult{}, fmt.Errorf("voice: %w", ctx.Err()) case <-s.bgCtx.Done(): return jobResult{}, ErrClosed } } // Close cancels the worker pool and waits for in-flight jobs to finish. It is // safe to call once; later calls are no-ops. In-flight provider calls are // bounded by each provider's own context timeout, so Close returns promptly. func (s *Service) Close() { s.mu.Lock() defer s.mu.Unlock() if s.closed { return } s.closed = true s.cancel() s.wg.Wait() }