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 同步
This commit is contained in:
1 parent
00f784b55f
commit
99f222761e
9 files changed
+345
-25
No files matched your search
@@ -25,8 +25,16 @@ func NewChain(steps ...Provider) *Chain {
|
||||
|
||||
// STT walks the chain for a transcription.
|
||||
func (c *Chain) STT(ctx context.Context, audio Audio) (string, error) {
|
||||
text, _, err := c.STTWithProvider(ctx, audio)
|
||||
return text, err
|
||||
}
|
||||
|
||||
// STTWithProvider is STT but also reports the name of the provider rung that
|
||||
// produced the transcription (cloud / local / edge-tts / silent), so a caller can
|
||||
// surface which tier actually served the request.
|
||||
func (c *Chain) STTWithProvider(ctx context.Context, audio Audio) (string, string, error) {
|
||||
if len(c.steps) == 0 {
|
||||
return "", ErrProviderFailed
|
||||
return "", "", ErrProviderFailed
|
||||
}
|
||||
var lastErr error
|
||||
for _, p := range c.steps {
|
||||
@@ -36,11 +44,11 @@ func (c *Chain) STT(ctx context.Context, audio Audio) (string, error) {
|
||||
}
|
||||
text, err := stt.STT(ctx, audio)
|
||||
if err == nil {
|
||||
return text, nil
|
||||
return text, p.Name(), nil
|
||||
}
|
||||
lastErr = err
|
||||
}
|
||||
return "", fmt.Errorf("voice: %w: %v", ErrProviderFailed, lastErr)
|
||||
return "", "", fmt.Errorf("voice: %w: %v", ErrProviderFailed, lastErr)
|
||||
}
|
||||
|
||||
// CloneVoice creates a custom voice via the cloud provider's voice-enrollment
|
||||
@@ -55,8 +63,15 @@ func (c *Chain) CloneVoice(ctx context.Context, referenceURL, prefix string) (st
|
||||
|
||||
// TTS walks the chain for synthesized audio.
|
||||
func (c *Chain) TTS(ctx context.Context, text string) (Audio, error) {
|
||||
audio, _, err := c.TTSWithProvider(ctx, text)
|
||||
return audio, err
|
||||
}
|
||||
|
||||
// TTSWithProvider is TTS but also reports the name of the provider rung that
|
||||
// produced the audio.
|
||||
func (c *Chain) TTSWithProvider(ctx context.Context, text string) (Audio, string, error) {
|
||||
if len(c.steps) == 0 {
|
||||
return Audio{}, ErrProviderFailed
|
||||
return Audio{}, "", ErrProviderFailed
|
||||
}
|
||||
var lastErr error
|
||||
for _, p := range c.steps {
|
||||
@@ -66,11 +81,11 @@ func (c *Chain) TTS(ctx context.Context, text string) (Audio, error) {
|
||||
}
|
||||
audio, err := tts.TTS(ctx, text)
|
||||
if err == nil {
|
||||
return audio, nil
|
||||
return audio, p.Name(), nil
|
||||
}
|
||||
lastErr = err
|
||||
}
|
||||
return Audio{}, fmt.Errorf("voice: %w: %v", ErrProviderFailed, lastErr)
|
||||
return Audio{}, "", fmt.Errorf("voice: %w: %v", ErrProviderFailed, lastErr)
|
||||
}
|
||||
|
||||
// Option customizes a chain built by New.
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
package voice
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestChain_STTWithProvider(t *testing.T) {
|
||||
t.Parallel()
|
||||
steps := []Provider{
|
||||
&stubProvider{name: "cloud", stt: &stubSTT{err: ErrNotConfigured}},
|
||||
&stubProvider{name: "local", stt: &stubSTT{text: "hi"}},
|
||||
}
|
||||
text, rung, err := NewChain(steps...).STTWithProvider(context.Background(), Audio{Format: "wav", Data: []byte("x")})
|
||||
if err != nil {
|
||||
t.Fatalf("STTWithProvider error: %v", err)
|
||||
}
|
||||
if text != "hi" || rung != "local" {
|
||||
t.Errorf("text/rung = %q/%q, want hi/local", text, rung)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChain_TTSWithProvider_Silent(t *testing.T) {
|
||||
t.Parallel()
|
||||
steps := []Provider{
|
||||
&stubProvider{name: "cloud", tts: &stubTTS{err: ErrNotConfigured}},
|
||||
SilentProvider(),
|
||||
}
|
||||
audio, rung, err := NewChain(steps...).TTSWithProvider(context.Background(), "hi")
|
||||
if err != nil {
|
||||
t.Fatalf("TTSWithProvider error: %v", err)
|
||||
}
|
||||
if len(audio.Data) != 0 || rung != "silent" {
|
||||
t.Errorf("audio/rung = %+v/%q, want empty/silent", audio, rung)
|
||||
}
|
||||
}
|
||||
|
||||
func TestService_TTSWithProvider(t *testing.T) {
|
||||
t.Parallel()
|
||||
svc := NewService(NewChain(NewProvider("edge-tts", nil, &stubTTS{audio: Audio{Format: "mp3", Data: []byte("a")}})))
|
||||
defer svc.Close()
|
||||
|
||||
audio, rung, err := svc.TTSWithProvider(context.Background(), "hi")
|
||||
if err != nil {
|
||||
t.Fatalf("TTSWithProvider error: %v", err)
|
||||
}
|
||||
if string(audio.Data) != "a" || rung != "edge-tts" {
|
||||
t.Errorf("audio/rung = %+v/%q, want a/edge-tts", audio, rung)
|
||||
}
|
||||
}
|
||||
@@ -53,9 +53,10 @@ type job struct {
|
||||
|
||||
// jobResult carries the outcome written back to the caller.
|
||||
type jobResult struct {
|
||||
text string
|
||||
audio Audio
|
||||
err error
|
||||
text string
|
||||
audio Audio
|
||||
provider string
|
||||
err error
|
||||
}
|
||||
|
||||
// NewService builds a Service backed by chain and starts the worker pool.
|
||||
@@ -99,9 +100,9 @@ func (s *Service) run(j job) {
|
||||
res := jobResult{}
|
||||
switch j.kind {
|
||||
case jobSTT:
|
||||
res.text, res.err = s.chain.STT(s.bgCtx, j.audio)
|
||||
res.text, res.provider, res.err = s.chain.STTWithProvider(s.bgCtx, j.audio)
|
||||
case jobTTS:
|
||||
res.audio, res.err = s.chain.TTS(s.bgCtx, j.text)
|
||||
res.audio, res.provider, res.err = s.chain.TTSWithProvider(s.bgCtx, j.text)
|
||||
}
|
||||
j.res <- res
|
||||
}
|
||||
@@ -109,21 +110,35 @@ func (s *Service) run(j job) {
|
||||
// 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 "", "", err
|
||||
}
|
||||
return res.text, res.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 Audio{}, "", err
|
||||
}
|
||||
return res.audio, res.err
|
||||
return res.audio, res.provider, res.err
|
||||
}
|
||||
|
||||
// CloneVoice creates a custom voice, delegating to the underlying chain. It is a
|
||||
|
||||
Reference in new issue
Block a user