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:
Aska committed 2026-08-09 21:09:43 +08:00
1 parent 81ebdb910c
commit 99806fefad
12 files changed
+874 -1

No files matched your search

View File
Whitespace-only changes.
+115
View File
@@ -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
}
+90
View File
@@ -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)
}
}