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,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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
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