Refactor TTS architecture
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
// Package service orchestrates the odidere voice assistant server.
|
||||
// It coordinates the HTTP server, STT/LLM/TTS clients, and handles
|
||||
// It coordinates the HTTP server, STT/LLM clients, and handles
|
||||
// graceful shutdown.
|
||||
package service
|
||||
|
||||
@@ -26,7 +26,6 @@ import (
|
||||
"code.chimeric.al/chimerical/odidere/internal/service/templates"
|
||||
"code.chimeric.al/chimerical/odidere/internal/stt"
|
||||
"code.chimeric.al/chimerical/odidere/internal/tool"
|
||||
"code.chimeric.al/chimerical/odidere/internal/tts"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/robfig/cron/v3"
|
||||
@@ -42,6 +41,19 @@ const (
|
||||
ipKey key = "ip"
|
||||
)
|
||||
|
||||
// defaultVoices maps whisper language names to default Kokoro voices.
|
||||
var defaultVoices = map[string]string{
|
||||
"chinese": "zf_xiaobei",
|
||||
"english": "af_heart",
|
||||
"french": "ff_siwis",
|
||||
"hindi": "hf_alpha",
|
||||
"italian": "if_sara",
|
||||
"japanese": "jf_alpha",
|
||||
"korean": "kf_sarah",
|
||||
"portuguese": "pf_dora",
|
||||
"spanish": "ef_dora",
|
||||
}
|
||||
|
||||
//go:embed all:static/*
|
||||
var static embed.FS
|
||||
|
||||
@@ -58,7 +70,6 @@ type Service struct {
|
||||
stt *stt.Client
|
||||
tmpl *template.Template
|
||||
tools *tool.Registry
|
||||
tts *tts.Client
|
||||
}
|
||||
|
||||
// New creates a Service from the provided configuration.
|
||||
@@ -91,13 +102,6 @@ func New(cfg *config.Config, log *slog.Logger) (*Service, error) {
|
||||
}
|
||||
svc.llm = llmClient
|
||||
|
||||
// Create TTS client.
|
||||
ttsClient, err := tts.NewClient(cfg.TTS, log)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create TTS client: %v", err)
|
||||
}
|
||||
svc.tts = ttsClient
|
||||
|
||||
// Convert job configs to jobs.
|
||||
jobs := make([]*job.Job, 0, len(cfg.Jobs))
|
||||
for _, jc := range cfg.Jobs {
|
||||
@@ -133,7 +137,6 @@ func New(cfg *config.Config, log *slog.Logger) (*Service, error) {
|
||||
)
|
||||
svc.mux.HandleFunc("POST /v1/chat/voice", svc.voice)
|
||||
svc.mux.HandleFunc("POST /v1/chat/voice/stream", svc.voiceStream)
|
||||
svc.mux.HandleFunc("GET /v1/voices", svc.voices)
|
||||
svc.mux.HandleFunc("GET /v1/models", svc.models)
|
||||
|
||||
svc.server = &http.Server{
|
||||
@@ -236,7 +239,7 @@ func (svc *Service) Run(ctx context.Context) error {
|
||||
),
|
||||
),
|
||||
slog.Group(
|
||||
"tts",
|
||||
"tts_url",
|
||||
slog.String("url", svc.cfg.TTS.URL),
|
||||
slog.String("default_voice", svc.cfg.TTS.Voice),
|
||||
),
|
||||
@@ -352,6 +355,19 @@ func (svc *Service) JobCount() int {
|
||||
return len(svc.jobs)
|
||||
}
|
||||
|
||||
// selectVoice returns the voice ID for the given language.
|
||||
// It uses the configured voice map, falling back to defaults.
|
||||
func (svc *Service) selectVoice(lang string) string {
|
||||
voiceMap := svc.cfg.TTS.VoiceMap
|
||||
if len(voiceMap) == 0 {
|
||||
voiceMap = defaultVoices
|
||||
}
|
||||
if voice, ok := voiceMap[lang]; ok {
|
||||
return voice
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (svc *Service) home(w http.ResponseWriter, r *http.Request) {
|
||||
var (
|
||||
ctx = r.Context()
|
||||
@@ -364,7 +380,13 @@ func (svc *Service) home(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
if err := svc.tmpl.ExecuteTemplate(
|
||||
w, "index.gohtml", nil,
|
||||
w, "index.gohtml", struct {
|
||||
TTSURL string
|
||||
TTSDefaultVoice string
|
||||
}{
|
||||
TTSURL: svc.cfg.TTS.URL,
|
||||
TTSDefaultVoice: svc.cfg.TTS.Voice,
|
||||
},
|
||||
); err != nil {
|
||||
log.ErrorContext(
|
||||
ctx, "template error", slog.Any("error", err),
|
||||
@@ -398,8 +420,6 @@ type Request struct {
|
||||
|
||||
// Response is the response format for chat and voice endpoints.
|
||||
type Response struct {
|
||||
// Audio is the base64-encoded WAV audio response.
|
||||
Audio string `json:"audio,omitempty"`
|
||||
// DetectedLanguage is the language detected in the input speech.
|
||||
DetectedLanguage string `json:"detected_language,omitempty"`
|
||||
// Messages is the full list of messages generated during the query,
|
||||
@@ -565,7 +585,7 @@ func (svc *Service) voice(w http.ResponseWriter, r *http.Request) {
|
||||
// Determine voice to use.
|
||||
var voice = req.Voice
|
||||
if req.Voice == "" && detectedLang != "" {
|
||||
if autoVoice := svc.tts.SelectVoice(
|
||||
if autoVoice := svc.selectVoice(
|
||||
detectedLang,
|
||||
); autoVoice != "" {
|
||||
voice = autoVoice
|
||||
@@ -581,17 +601,8 @@ func (svc *Service) voice(w http.ResponseWriter, r *http.Request) {
|
||||
)
|
||||
}
|
||||
|
||||
// Generate audio response with selected voice.
|
||||
audio, err := svc.tts.Synthesize(ctx, final.Content, voice)
|
||||
if err != nil {
|
||||
log.ErrorContext(ctx, "TTS failed", slog.Any("error", err))
|
||||
http.Error(w, "TTS error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
if err := json.NewEncoder(w).Encode(Response{
|
||||
Audio: base64.StdEncoding.EncodeToString(audio),
|
||||
DetectedLanguage: detectedLang,
|
||||
Messages: msgs,
|
||||
Model: model,
|
||||
@@ -608,8 +619,6 @@ func (svc *Service) voice(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
// StreamMessage is the SSE event payload for the streaming voice endpoint.
|
||||
type StreamMessage struct {
|
||||
// Audio is the base64-encoded WAV audio response.
|
||||
Audio string `json:"audio,omitempty"`
|
||||
// DetectedLanguage is the language detected in the input speech.
|
||||
DetectedLanguage string `json:"detected_language,omitempty"`
|
||||
// Error is an error message, if any.
|
||||
@@ -784,7 +793,7 @@ func (svc *Service) voiceStream(w http.ResponseWriter, r *http.Request) {
|
||||
// Determine voice to use.
|
||||
var voice = req.Voice
|
||||
if req.Voice == "" && detectedLang != "" {
|
||||
if autoVoice := svc.tts.SelectVoice(
|
||||
if autoVoice := svc.selectVoice(
|
||||
detectedLang,
|
||||
); autoVoice != "" {
|
||||
voice = autoVoice
|
||||
@@ -834,20 +843,6 @@ func (svc *Service) voiceStream(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
// Synthesize TTS for the final assistant message.
|
||||
if last.Message.Content != "" {
|
||||
audio, err := svc.tts.Synthesize(
|
||||
ctx, last.Message.Content, voice,
|
||||
)
|
||||
if err != nil {
|
||||
log.ErrorContext(
|
||||
ctx, "TTS failed", slog.Any("error", err),
|
||||
)
|
||||
last.Error = fmt.Sprintf("TTS error: %v", err)
|
||||
} else {
|
||||
last.Audio = base64.StdEncoding.EncodeToString(audio)
|
||||
}
|
||||
}
|
||||
last.Model = model
|
||||
last.Voice = voice
|
||||
send(last)
|
||||
@@ -890,37 +885,3 @@ func (svc *Service) models(w http.ResponseWriter, r *http.Request) {
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// voices returns available TTS voices.
|
||||
func (svc *Service) voices(w http.ResponseWriter, r *http.Request) {
|
||||
var (
|
||||
ctx = r.Context()
|
||||
log = ctx.Value(logKey).(*slog.Logger)
|
||||
)
|
||||
|
||||
voices, err := svc.tts.ListVoices(ctx)
|
||||
if err != nil {
|
||||
log.ErrorContext(
|
||||
ctx,
|
||||
"failed to list voices",
|
||||
slog.Any("error", err),
|
||||
)
|
||||
http.Error(
|
||||
w,
|
||||
"failed to list voices",
|
||||
http.StatusInternalServerError,
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
if err := json.NewEncoder(w).Encode(map[string][]string{
|
||||
"voices": voices,
|
||||
}); err != nil {
|
||||
log.ErrorContext(
|
||||
ctx,
|
||||
"failed to encode voices response",
|
||||
slog.Any("error", err),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user