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

+117
View File
@@ -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
}
+2
View File
@@ -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
+2
View File
@@ -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=
+14 -1
View File
@@ -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
}
View File
Whitespace-only changes.
+56
View File
@@ -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
}
+167
View File
@@ -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)
}
}
+136
View File
@@ -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")
}
}
+175
View File
@@ -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
}
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)
}
}