Files
Cyrene_v2/internal/runtime/multimodal/voice/service.go
T
AskaEth 99f222761e
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
feat(voice): 语音silent降级信号(C2) — provider仅在结果为空时出现
- voice/Chain+Service: STTWithProvider/TTSWithProvider(真实档位cloud/local/edge-tts/espeak/silent), 旧方法改薄包装
- gateway: transcribe空文本带{text,provider} / voice_output空音频带provider / speech空音频tts_empty+X-Voice-Provider头
- middleware: CORS Access-Control-Expose-Headers: X-Voice-Provider
- 可选接口 voiceProviderReporter(type断言透传), 自定义实现回退silent
- 新增测试10个(provider_signal/voice_signal), build/vet/test全绿+交叉编译
- Docs/api-client v0.23 + api-reference CORS 同步
2026-09-08 21:23:12 +08:00

182 lines
4.8 KiB
Go

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()
}