feat: add gateway API, voice service, WebSocket hub, and startup entry
- services/gateway/api: net/http router with JWT auth, stub login, 6 tests - services/gateway/ws: WebSocket hub with echo support - services/voice: STT/TTS provider chain with fallback, 6 tests - cmd/cyrene/main.go: single binary startup, 108 tests total - Smoke test: health check + login verified on :8080
This commit is contained in:
1 parent
81ebdb910c
commit
99806fefad
12 files changed
+874
-1
No files matched your search
Whitespace-only changes.
@@ -0,0 +1,115 @@
|
||||
// Package voice provides speech-to-text and text-to-speech capabilities.
|
||||
// It defines provider interfaces allowing pluggable backends (DashScope, Whisper, Edge-TTS, espeak).
|
||||
package voice
|
||||
|
||||
import "context"
|
||||
|
||||
// STTProvider converts speech audio to text.
|
||||
type STTProvider interface {
|
||||
// Name returns the provider identifier.
|
||||
Name() string // "dashscope", "whisper-local"
|
||||
|
||||
// Recognize converts audio data to text.
|
||||
Recognize(ctx context.Context, audioData []byte) (string, error)
|
||||
}
|
||||
|
||||
// TTSProvider converts text to speech audio.
|
||||
type TTSProvider interface {
|
||||
// Name returns the provider identifier.
|
||||
Name() string // "edge-tts", "espeak", "silent"
|
||||
|
||||
// Synthesize converts text to audio bytes.
|
||||
Synthesize(ctx context.Context, text string, voice string) ([]byte, error)
|
||||
}
|
||||
|
||||
// Service manages STT and TTS providers with fallback chains.
|
||||
type Service struct {
|
||||
sttProvider STTProvider
|
||||
ttsProviders []TTSProvider // Ordered by priority (first = preferred)
|
||||
}
|
||||
|
||||
// Config holds voice service configuration.
|
||||
type Config struct {
|
||||
STTProvider string // "dashscope" | "whisper-local"
|
||||
TTSProviders []string // ["edge-tts", "espeak", "silent"] ordered by priority
|
||||
DashScopeKey string
|
||||
}
|
||||
|
||||
// New creates a new voice service.
|
||||
func New(cfg Config) *Service {
|
||||
svc := &Service{}
|
||||
|
||||
// STT: for now, use a simple placeholder
|
||||
if cfg.STTProvider == "dashscope" && cfg.DashScopeKey != "" {
|
||||
svc.sttProvider = &dashscopeSTT{apiKey: cfg.DashScopeKey}
|
||||
} else {
|
||||
svc.sttProvider = &silentSTT{}
|
||||
}
|
||||
|
||||
// TTS: build fallback chain
|
||||
for _, name := range cfg.TTSProviders {
|
||||
switch name {
|
||||
case "edge-tts":
|
||||
svc.ttsProviders = append(svc.ttsProviders, &edgeTTS{})
|
||||
case "espeak":
|
||||
svc.ttsProviders = append(svc.ttsProviders, &espeakTTS{})
|
||||
case "silent":
|
||||
svc.ttsProviders = append(svc.ttsProviders, &silentTTS{})
|
||||
}
|
||||
}
|
||||
if len(svc.ttsProviders) == 0 {
|
||||
svc.ttsProviders = append(svc.ttsProviders, &silentTTS{})
|
||||
}
|
||||
|
||||
return svc
|
||||
}
|
||||
|
||||
// Recognize performs speech recognition with the configured STT provider.
|
||||
func (s *Service) Recognize(ctx context.Context, audioData []byte) (string, error) {
|
||||
return s.sttProvider.Recognize(ctx, audioData)
|
||||
}
|
||||
|
||||
// Synthesize performs TTS with automatic fallback through the provider chain.
|
||||
func (s *Service) Synthesize(ctx context.Context, text string, voice string) ([]byte, error) {
|
||||
var lastErr error
|
||||
for _, p := range s.ttsProviders {
|
||||
audio, err := p.Synthesize(ctx, text, voice)
|
||||
if err == nil {
|
||||
return audio, nil
|
||||
}
|
||||
lastErr = err
|
||||
}
|
||||
return nil, lastErr
|
||||
}
|
||||
|
||||
// --- Provider stubs ---
|
||||
|
||||
type dashscopeSTT struct{ apiKey string }
|
||||
func (p *dashscopeSTT) Name() string { return "dashscope" }
|
||||
func (p *dashscopeSTT) Recognize(_ context.Context, _ []byte) (string, error) {
|
||||
return "", nil // Placeholder: real implementation requires DashScope SDK
|
||||
}
|
||||
|
||||
type silentSTT struct{}
|
||||
func (p *silentSTT) Name() string { return "silent" }
|
||||
func (p *silentSTT) Recognize(_ context.Context, _ []byte) (string, error) {
|
||||
return "", nil
|
||||
}
|
||||
|
||||
type edgeTTS struct{}
|
||||
func (p *edgeTTS) Name() string { return "edge-tts" }
|
||||
func (p *edgeTTS) Synthesize(_ context.Context, text string, _ string) ([]byte, error) {
|
||||
return []byte(text), nil // Placeholder
|
||||
}
|
||||
|
||||
type espeakTTS struct{}
|
||||
func (p *espeakTTS) Name() string { return "espeak" }
|
||||
func (p *espeakTTS) Synthesize(_ context.Context, text string, _ string) ([]byte, error) {
|
||||
return []byte(text), nil
|
||||
}
|
||||
|
||||
type silentTTS struct{}
|
||||
func (p *silentTTS) Name() string { return "silent" }
|
||||
func (p *silentTTS) Synthesize(_ context.Context, _ string, _ string) ([]byte, error) {
|
||||
return make([]byte, 0), nil
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
package voice
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestNewServiceDefaultConfig(t *testing.T) {
|
||||
svc := New(Config{
|
||||
STTProvider: "silent",
|
||||
TTSProviders: []string{"silent"},
|
||||
})
|
||||
|
||||
if svc.sttProvider == nil {
|
||||
t.Fatal("expected STT provider")
|
||||
}
|
||||
if svc.sttProvider.Name() != "silent" {
|
||||
t.Errorf("expected silent STT, got %s", svc.sttProvider.Name())
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewServiceEmptyConfig(t *testing.T) {
|
||||
svc := New(Config{})
|
||||
|
||||
if svc.sttProvider == nil {
|
||||
t.Fatal("expected fallback STT provider")
|
||||
}
|
||||
if len(svc.ttsProviders) == 0 {
|
||||
t.Fatal("expected at least one fallback TTS provider")
|
||||
}
|
||||
}
|
||||
|
||||
func TestServiceRecognize(t *testing.T) {
|
||||
svc := New(Config{})
|
||||
|
||||
result, err := svc.Recognize(context.Background(), []byte("test audio"))
|
||||
if err != nil {
|
||||
t.Errorf("Recognize should not error: %v", err)
|
||||
}
|
||||
if result != "" {
|
||||
t.Logf("Recognize result: %s", result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestServiceSynthesize(t *testing.T) {
|
||||
svc := New(Config{TTSProviders: []string{"silent"}})
|
||||
|
||||
audio, err := svc.Synthesize(context.Background(), "你好世界", "zh-CN-XiaoxiaoNeural")
|
||||
if err != nil {
|
||||
t.Fatalf("Synthesize should not error: %v", err)
|
||||
}
|
||||
if audio == nil {
|
||||
t.Error("expected non-nil audio")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTTSProviderChain(t *testing.T) {
|
||||
svc := New(Config{
|
||||
TTSProviders: []string{"edge-tts", "espeak", "silent"},
|
||||
})
|
||||
|
||||
if len(svc.ttsProviders) != 3 {
|
||||
t.Errorf("expected 3 TTS providers, got %d", len(svc.ttsProviders))
|
||||
}
|
||||
|
||||
names := []string{"edge-tts", "espeak", "silent"}
|
||||
for i, p := range svc.ttsProviders {
|
||||
if p.Name() != names[i] {
|
||||
t.Errorf("provider[%d]: expected %s, got %s", i, names[i], p.Name())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestProviderNames(t *testing.T) {
|
||||
if n := (&dashscopeSTT{}).Name(); n != "dashscope" {
|
||||
t.Errorf("got %s", n)
|
||||
}
|
||||
if n := (&silentSTT{}).Name(); n != "silent" {
|
||||
t.Errorf("got %s", n)
|
||||
}
|
||||
if n := (&edgeTTS{}).Name(); n != "edge-tts" {
|
||||
t.Errorf("got %s", n)
|
||||
}
|
||||
if n := (&espeakTTS{}).Name(); n != "espeak" {
|
||||
t.Errorf("got %s", n)
|
||||
}
|
||||
if n := (&silentTTS{}).Name(); n != "silent" {
|
||||
t.Errorf("got %s", n)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user