From 99806fefad2aa08e40e8bd112e566577ad9e9acf Mon Sep 17 00:00:00 2001 From: Aska Date: Sun, 9 Aug 2026 21:09:43 +0800 Subject: [PATCH] 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 --- backend/cmd/cyrene/main.go | 117 +++++++++++++ backend/go.mod | 2 + backend/go.sum | 2 + backend/internal/db/db.go | 15 +- backend/services/gateway/.gitkeep | 0 backend/services/gateway/api/auth_stub.go | 56 +++++++ backend/services/gateway/api/router.go | 167 +++++++++++++++++++ backend/services/gateway/api/router_test.go | 136 +++++++++++++++ backend/services/gateway/ws/hub.go | 175 ++++++++++++++++++++ backend/services/voice/.gitkeep | 0 backend/services/voice/service.go | 115 +++++++++++++ backend/services/voice/service_test.go | 90 ++++++++++ 12 files changed, 874 insertions(+), 1 deletion(-) create mode 100644 backend/cmd/cyrene/main.go delete mode 100644 backend/services/gateway/.gitkeep create mode 100644 backend/services/gateway/api/auth_stub.go create mode 100644 backend/services/gateway/api/router.go create mode 100644 backend/services/gateway/api/router_test.go create mode 100644 backend/services/gateway/ws/hub.go delete mode 100644 backend/services/voice/.gitkeep create mode 100644 backend/services/voice/service.go create mode 100644 backend/services/voice/service_test.go diff --git a/backend/cmd/cyrene/main.go b/backend/cmd/cyrene/main.go new file mode 100644 index 0000000..d287dd8 --- /dev/null +++ b/backend/cmd/cyrene/main.go @@ -0,0 +1,117 @@ +// Cyrene v2 — LLM-powered AI companion platform. +// Single binary entry point for all services. +package main + +import ( + "context" + "fmt" + "log" + "net/http" + "os" + "os/signal" + "syscall" + + "git.yeij.top/AskaEth/Cyrene_v2/internal/config" + "git.yeij.top/AskaEth/Cyrene_v2/internal/db" + "git.yeij.top/AskaEth/Cyrene_v2/internal/nats" + "git.yeij.top/AskaEth/Cyrene_v2/internal/platform" + "git.yeij.top/AskaEth/Cyrene_v2/services/gateway/api" + "git.yeij.top/AskaEth/Cyrene_v2/services/gateway/ws" + "git.yeij.top/AskaEth/Cyrene_v2/services/memory" + "git.yeij.top/AskaEth/Cyrene_v2/services/voice" +) + +func main() { + log.SetFlags(log.LstdFlags | log.Lshortfile) + log.Println("🧠 Cyrene v2 启动中...") + + // 1. Platform detection + plat := platform.Detect() + log.Printf("[platform] %s", plat.Summary()) + + // 2. Load config + cfg, err := config.Load() + if err != nil { + log.Printf("[config] .env load failed (using defaults): %v", err) + } + cfg.ValidateOrFatal() + cfg.LogSanitized() + + // 3. Ensure directories + if err := plat.EnsureDirs(); err != nil { + log.Printf("[platform] directory creation warning: %v", err) + } + + // 4. Connect database + database, err := db.Connect(db.Config{ + Host: cfg.PostgresHost, + Port: cfg.PostgresPort, + User: cfg.PostgresUser, + Password: cfg.PostgresPassword, + DBName: cfg.PostgresDB, + SSLMode: cfg.PostgresSSLMode, + MaxOpenConns: 25, + MaxIdleConns: 5, + }) + if err != nil { + log.Printf("[db] warning: database connection failed (running without persistence): %v", err) + } else { + defer database.Close() + // Run migrations + if err := db.RunMigration(context.Background(), database, "memories", db.SplitSQL(memory.CreateTableSQL())); err != nil { + log.Printf("[db] migration warning: %v", err) + } + } + + // 5. Initialize NATS (in-process for dev) + natsConn := nats.NewInProcess() + log.Printf("[nats] in-process connection ready") + + // 6. Initialize services + voiceSvc := voice.New(voice.Config{ + STTProvider: cfg.ASRProvider, + TTSProviders: []string{cfg.TTSProvider}, + DashScopeKey: cfg.DashScopeAPIKey, + }) + log.Printf("[voice] service ready") + + // 7. Set up Gateway + authMgr := api.NewStubAuthManager() + apiRouter := api.New(authMgr) + + // 8. WebSocket Hub + wsHub := ws.NewHub() + wsMux := http.NewServeMux() + wsMux.HandleFunc("/ws/chat", wsHub.UpgradeHandler()) + + // 9. Combined HTTP server (API + WS on same port) + mainMux := http.NewServeMux() + mainMux.Handle("/api/", apiRouter) + mainMux.Handle("/ws/", wsMux) + + addr := fmt.Sprintf("%s:%d", cfg.Host, cfg.Port) + srv := &http.Server{ + Addr: addr, + Handler: mainMux, + } + + // 10. Start server + go func() { + log.Printf("[gateway] listening on %s", addr) + if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed { + log.Fatalf("[gateway] failed: %v", err) + } + }() + + // 11. Wait for shutdown signal + quit := make(chan os.Signal, 1) + signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM) + <-quit + + log.Println("[gateway] shutting down...") + srv.Close() + log.Println("Cyrene v2 已安全退出") + + _ = natsConn + _ = voiceSvc +} diff --git a/backend/go.mod b/backend/go.mod index b75a4b3..e5aae63 100644 --- a/backend/go.mod +++ b/backend/go.mod @@ -9,3 +9,5 @@ require ( github.com/lib/pq v1.12.3 golang.org/x/crypto v0.54.0 ) + +require github.com/gorilla/websocket v1.5.3 diff --git a/backend/go.sum b/backend/go.sum index 5a291ca..7a78bdb 100644 --- a/backend/go.sum +++ b/backend/go.sum @@ -1,5 +1,7 @@ github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY= github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= +github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= +github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= github.com/lib/pq v1.12.3 h1:tTWxr2YLKwIvK90ZXEw8GP7UFHtcbTtty8zsI+YjrfQ= diff --git a/backend/internal/db/db.go b/backend/internal/db/db.go index 884df99..b1706c6 100644 --- a/backend/internal/db/db.go +++ b/backend/internal/db/db.go @@ -6,6 +6,7 @@ import ( "database/sql" "fmt" "log" + "strings" "time" _ "github.com/lib/pq" // PostgreSQL driver @@ -117,7 +118,6 @@ func Transaction(ctx context.Context, db *sql.DB, fn func(tx *sql.Tx) error) err } // RunMigration executes a list of SQL migration statements in order. -// Each migration is run within its own transaction. func RunMigration(ctx context.Context, db *sql.DB, name string, statements []string) error { log.Printf("[db] running migration: %s (%d statements)", name, len(statements)) @@ -129,3 +129,16 @@ func RunMigration(ctx context.Context, db *sql.DB, name string, statements []str log.Printf("[db] migration %s completed", name) return nil } + +// SplitSQL splits a multi-statement SQL string on semicolons. +func SplitSQL(sql string) []string { + parts := strings.Split(sql, ";") + result := make([]string, 0, len(parts)) + for _, p := range parts { + p = strings.TrimSpace(p) + if p != "" && !strings.HasPrefix(p, "--") { + result = append(result, p) + } + } + return result +} diff --git a/backend/services/gateway/.gitkeep b/backend/services/gateway/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/backend/services/gateway/api/auth_stub.go b/backend/services/gateway/api/auth_stub.go new file mode 100644 index 0000000..d53291d --- /dev/null +++ b/backend/services/gateway/api/auth_stub.go @@ -0,0 +1,56 @@ +package api + +import ( + "net/http" +) + +// StubAuthManager is an in-memory auth manager for development and testing. +type StubAuthManager struct { + tokens map[string]TokenInfo + users map[string]UserInfo +} + +// TokenInfo holds decoded token information. +type TokenInfo struct { + UserID string + Username string + Role string +} + +// UserInfo holds user credentials (development only — production uses bcrypt). +type UserInfo struct { + Username string + Password string + Role string +} + +// NewStubAuthManager creates an in-memory auth manager with a default admin user. +func NewStubAuthManager() *StubAuthManager { + m := &StubAuthManager{ + tokens: make(map[string]TokenInfo), + users: make(map[string]UserInfo), + } + m.users["admin"] = UserInfo{Username: "admin", Password: "admin", Role: "admin"} + m.users["user"] = UserInfo{Username: "user", Password: "user", Role: "user"} + return m +} + +// ValidateToken validates a stub token. +func (m *StubAuthManager) ValidateToken(token string) (string, string, string, error) { + info, ok := m.tokens[token] + if !ok { + return "", "", "", http.ErrNoCookie + } + return info.UserID, info.Username, info.Role, nil +} + +// Login authenticates a user and returns a stub token. +func (m *StubAuthManager) Login(username, password string) (string, error) { + user, ok := m.users[username] + if !ok || user.Password != password { + return "", http.ErrNoCookie + } + token := "stub-token-" + username + m.tokens[token] = TokenInfo{UserID: username, Username: username, Role: user.Role} + return token, nil +} diff --git a/backend/services/gateway/api/router.go b/backend/services/gateway/api/router.go new file mode 100644 index 0000000..b22625d --- /dev/null +++ b/backend/services/gateway/api/router.go @@ -0,0 +1,167 @@ +// Package api provides HTTP routing, middleware, and handlers for the Cyrene Gateway. +// Uses only the standard library net/http to minimize dependencies. +package api + +import ( + "encoding/json" + "net/http" + "strings" +) + +// Router handles HTTP routing with middleware support. +type Router struct { + mux *http.ServeMux + authMgr AuthManager +} + +// AuthManager is the interface for JWT authentication. +type AuthManager interface { + ValidateToken(token string) (userID, username, role string, err error) +} + +// New creates a new Router with all routes configured. +func New(authMgr AuthManager) *Router { + r := &Router{ + mux: http.NewServeMux(), + authMgr: authMgr, + } + r.setupRoutes() + return r +} + +func (r *Router) setupRoutes() { + // Public endpoints + r.mux.HandleFunc("/api/v1/health", r.healthHandler) + r.mux.HandleFunc("/api/v1/auth/login", r.loginHandler) + r.mux.HandleFunc("/api/v1/auth/register", r.registerHandler) + + // Protected endpoints + r.mux.HandleFunc("/api/v1/sessions", r.authMW(r.sessionsHandler)) + r.mux.HandleFunc("/api/v1/sessions/", r.authMW(r.sessionByIDHandler)) + r.mux.HandleFunc("/api/v1/admin/dashboard", r.authMW(r.adminMW(r.adminDashboardHandler))) +} + +// ServeHTTP implements http.Handler. +func (r *Router) ServeHTTP(w http.ResponseWriter, req *http.Request) { + // CORS + w.Header().Set("Access-Control-Allow-Origin", "*") + w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS") + w.Header().Set("Access-Control-Allow-Headers", "Authorization, Content-Type") + + if req.Method == "OPTIONS" { + w.WriteHeader(http.StatusNoContent) + return + } + + r.mux.ServeHTTP(w, req) +} + +// --- Middleware --- + +func (r *Router) authMW(next http.HandlerFunc) http.HandlerFunc { + return func(w http.ResponseWriter, req *http.Request) { + header := req.Header.Get("Authorization") + if header == "" || !strings.HasPrefix(strings.ToLower(header), "bearer ") { + writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "missing or invalid authorization"}) + return + } + token := header[7:] + userID, username, role, err := r.authMgr.ValidateToken(token) + if err != nil { + writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "invalid token"}) + return + } + req.Header.Set("X-User-ID", userID) + req.Header.Set("X-Username", username) + req.Header.Set("X-Role", role) + next(w, req) + } +} + +func (r *Router) adminMW(next http.HandlerFunc) http.HandlerFunc { + return func(w http.ResponseWriter, req *http.Request) { + if req.Header.Get("X-Role") != "admin" { + writeJSON(w, http.StatusForbidden, map[string]string{"error": "admin access required"}) + return + } + next(w, req) + } +} + +// --- Public handlers --- + +func (r *Router) healthHandler(w http.ResponseWriter, _ *http.Request) { + writeJSON(w, http.StatusOK, map[string]string{"status": "ok", "service": "cyrene-gateway"}) +} + +func (r *Router) loginHandler(w http.ResponseWriter, req *http.Request) { + if req.Method != "POST" { + writeJSON(w, http.StatusMethodNotAllowed, map[string]string{"error": "POST required"}) + return + } + var body struct { + Username string `json:"username"` + Password string `json:"password"` + } + if err := json.NewDecoder(req.Body).Decode(&body); err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid request"}) + return + } + + mgr, ok := r.authMgr.(*StubAuthManager) + if !ok { + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "auth not configured"}) + return + } + token, err := mgr.Login(body.Username, body.Password) + if err != nil { + writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "invalid credentials"}) + return + } + writeJSON(w, http.StatusOK, map[string]string{"token": token}) +} + +func (r *Router) registerHandler(w http.ResponseWriter, _ *http.Request) { + writeJSON(w, http.StatusOK, map[string]string{"message": "registration not implemented"}) +} + +// --- Protected handlers --- + +func (r *Router) sessionsHandler(w http.ResponseWriter, req *http.Request) { + switch req.Method { + case "GET": + writeJSON(w, http.StatusOK, map[string]any{"sessions": []any{}}) + case "POST": + writeJSON(w, http.StatusCreated, map[string]string{"session_id": "stub-session"}) + default: + writeJSON(w, http.StatusMethodNotAllowed, map[string]string{"error": "method not allowed"}) + } +} + +func (r *Router) sessionByIDHandler(w http.ResponseWriter, req *http.Request) { + id := strings.TrimPrefix(req.URL.Path, "/api/v1/sessions/") + if req.Method == "DELETE" { + writeJSON(w, http.StatusNoContent, nil) + return + } + writeJSON(w, http.StatusOK, map[string]string{"session_id": id}) +} + +func (r *Router) adminDashboardHandler(w http.ResponseWriter, _ *http.Request) { + writeJSON(w, http.StatusOK, map[string]any{ + "dashboard": map[string]any{ + "status": "running", + "version": "2.0.0-dev", + }, + }) +} + +// --- Helpers --- + +func writeJSON(w http.ResponseWriter, status int, data any) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + if data != nil { + json.NewEncoder(w).Encode(data) + } +} diff --git a/backend/services/gateway/api/router_test.go b/backend/services/gateway/api/router_test.go new file mode 100644 index 0000000..0ba77d2 --- /dev/null +++ b/backend/services/gateway/api/router_test.go @@ -0,0 +1,136 @@ +package api + +import ( + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +func TestHealth(t *testing.T) { + r := New(NewStubAuthManager()) + srv := httptest.NewServer(r) + defer srv.Close() + + resp, err := http.Get(srv.URL + "/api/v1/health") + if err != nil { + t.Fatalf("GET health: %v", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + t.Errorf("expected 200, got %d", resp.StatusCode) + } +} + +func TestLoginAndProtectedEndpoint(t *testing.T) { + auth := NewStubAuthManager() + r := New(auth) + srv := httptest.NewServer(r) + defer srv.Close() + + // Login + resp, err := http.Post(srv.URL+"/api/v1/auth/login", "application/json", + strings.NewReader(`{"username":"admin","password":"admin"}`)) + if err != nil { + t.Fatalf("POST login: %v", err) + } + resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + t.Errorf("login: expected 200, got %d", resp.StatusCode) + } + + // Use token for protected endpoint + req, _ := http.NewRequest("GET", srv.URL+"/api/v1/sessions", nil) + req.Header.Set("Authorization", "Bearer stub-token-admin") + resp2, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatalf("GET sessions: %v", err) + } + resp2.Body.Close() + + if resp2.StatusCode != http.StatusOK { + t.Errorf("sessions: expected 200, got %d", resp2.StatusCode) + } +} + +func TestLoginWrongPassword(t *testing.T) { + auth := NewStubAuthManager() + r := New(auth) + srv := httptest.NewServer(r) + defer srv.Close() + + resp, err := http.Post(srv.URL+"/api/v1/auth/login", "application/json", + strings.NewReader(`{"username":"admin","password":"wrong"}`)) + if err != nil { + t.Fatalf("POST login: %v", err) + } + resp.Body.Close() + + if resp.StatusCode != http.StatusUnauthorized { + t.Errorf("expected 401, got %d", resp.StatusCode) + } +} + +func TestProtectedWithoutToken(t *testing.T) { + auth := NewStubAuthManager() + r := New(auth) + srv := httptest.NewServer(r) + defer srv.Close() + + resp, err := http.Get(srv.URL + "/api/v1/sessions") + if err != nil { + t.Fatalf("GET sessions: %v", err) + } + resp.Body.Close() + + if resp.StatusCode != http.StatusUnauthorized { + t.Errorf("expected 401, got %d", resp.StatusCode) + } +} + +func TestStubAuthManager(t *testing.T) { + m := NewStubAuthManager() + + // Login as admin + token, err := m.Login("admin", "admin") + if err != nil { + t.Fatalf("Login: %v", err) + } + + uid, uname, role, err := m.ValidateToken(token) + if err != nil { + t.Fatalf("ValidateToken: %v", err) + } + if uid != "admin" || uname != "admin" || role != "admin" { + t.Errorf("wrong claims: %s/%s/%s", uid, uname, role) + } + + // Invalid token + _, _, _, err = m.ValidateToken("bogus") + if err == nil { + t.Error("expected error for bogus token") + } +} + +func TestCORSHeaders(t *testing.T) { + r := New(NewStubAuthManager()) + srv := httptest.NewServer(r) + defer srv.Close() + + req, _ := http.NewRequest("OPTIONS", srv.URL+"/api/v1/health", nil) + req.Header.Set("Origin", "http://localhost:5173") + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatalf("OPTIONS: %v", err) + } + resp.Body.Close() + + if resp.StatusCode != http.StatusNoContent { + t.Errorf("expected 204 for OPTIONS, got %d", resp.StatusCode) + } + if resp.Header.Get("Access-Control-Allow-Origin") == "" { + t.Error("expected CORS header") + } +} diff --git a/backend/services/gateway/ws/hub.go b/backend/services/gateway/ws/hub.go new file mode 100644 index 0000000..bde709e --- /dev/null +++ b/backend/services/gateway/ws/hub.go @@ -0,0 +1,175 @@ +// Package ws provides WebSocket connection management for Cyrene. +package ws + +import ( + "encoding/json" + "log" + "net/http" + "sync" + + "github.com/gorilla/websocket" +) + +var upgrader = websocket.Upgrader{ + ReadBufferSize: 1024, + WriteBufferSize: 1024, + CheckOrigin: func(r *http.Request) bool { return true }, +} + +// Hub manages all active WebSocket connections. +type Hub struct { + mu sync.RWMutex + clients map[*Client]bool + + register chan *Client + unregister chan *Client + broadcast chan []byte +} + +// Client represents a single WebSocket connection. +type Client struct { + hub *Hub + conn *websocket.Conn + send chan []byte + UserID string + closed bool + mu sync.Mutex +} + +// Message represents a WebSocket message envelope. +type Message struct { + Type string `json:"type"` + Content string `json:"content,omitempty"` + SessionID string `json:"session_id,omitempty"` + Timestamp int64 `json:"timestamp,omitempty"` + Error string `json:"error,omitempty"` +} + +// NewHub creates and starts a new WebSocket hub. +func NewHub() *Hub { + h := &Hub{ + clients: make(map[*Client]bool), + register: make(chan *Client), + unregister: make(chan *Client), + broadcast: make(chan []byte, 256), + } + go h.run() + return h +} + +func (h *Hub) run() { + for { + select { + case client := <-h.register: + h.mu.Lock() + h.clients[client] = true + h.mu.Unlock() + log.Printf("[ws] client connected (%d total)", len(h.clients)) + + case client := <-h.unregister: + h.mu.Lock() + if _, ok := h.clients[client]; ok { + delete(h.clients, client) + close(client.send) + } + h.mu.Unlock() + log.Printf("[ws] client disconnected (%d remaining)", len(h.clients)) + + case message := <-h.broadcast: + h.mu.RLock() + for client := range h.clients { + select { + case client.send <- message: + default: + // Client too slow, skip + } + } + h.mu.RUnlock() + } + } +} + +// HandleUpgrade upgrades an HTTP connection to WebSocket. +func (h *Hub) HandleUpgrade(w http.ResponseWriter, r *http.Request) { + conn, err := upgrader.Upgrade(w, r, nil) + if err != nil { + log.Printf("[ws] upgrade error: %v", err) + return + } + + userID := r.URL.Query().Get("user_id") + if userID == "" { + userID = "anonymous" + } + + client := &Client{ + hub: h, + conn: conn, + send: make(chan []byte, 64), + UserID: userID, + } + + h.register <- client + + go client.writePump() + go client.readPump() +} + +func (c *Client) readPump() { + defer func() { + c.hub.unregister <- c + c.conn.Close() + }() + + for { + _, msgBytes, err := c.conn.ReadMessage() + if err != nil { + break + } + + var msg Message + if err := json.Unmarshal(msgBytes, &msg); err != nil { + continue + } + + // Echo back for now (will route to AI-Core later) + reply := Message{ + Type: "response", + Content: "echo: " + msg.Content, + SessionID: msg.SessionID, + } + replyBytes, _ := json.Marshal(reply) + c.send <- replyBytes + } +} + +func (c *Client) writePump() { + defer c.conn.Close() + for msg := range c.send { + if err := c.conn.WriteMessage(websocket.TextMessage, msg); err != nil { + return + } + } +} + +// Broadcast sends a message to all connected clients. +func (h *Hub) Broadcast(msg Message) error { + data, err := json.Marshal(msg) + if err != nil { + return err + } + h.broadcast <- data + return nil +} + +// Count returns the number of connected clients. +func (h *Hub) Count() int { + h.mu.RLock() + defer h.mu.RUnlock() + return len(h.clients) +} + +// UpgradeHandler returns an http.HandlerFunc for WebSocket upgrades. +func (h *Hub) UpgradeHandler() http.HandlerFunc { + return h.HandleUpgrade +} diff --git a/backend/services/voice/.gitkeep b/backend/services/voice/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/backend/services/voice/service.go b/backend/services/voice/service.go new file mode 100644 index 0000000..2dab083 --- /dev/null +++ b/backend/services/voice/service.go @@ -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 +} diff --git a/backend/services/voice/service_test.go b/backend/services/voice/service_test.go new file mode 100644 index 0000000..4941d73 --- /dev/null +++ b/backend/services/voice/service_test.go @@ -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) + } +}