Refactor STT architecture
This commit is contained in:
@@ -1,12 +1,11 @@
|
||||
// Package service orchestrates the odidere voice assistant server.
|
||||
// It coordinates the HTTP server, STT/LLM clients, and handles
|
||||
// It coordinates the HTTP server, LLM client, and handles
|
||||
// graceful shutdown.
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"embed"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"html/template"
|
||||
@@ -24,7 +23,6 @@ import (
|
||||
"code.chimeric.al/chimerical/odidere/internal/job"
|
||||
"code.chimeric.al/chimerical/odidere/internal/llm"
|
||||
"code.chimeric.al/chimerical/odidere/internal/service/templates"
|
||||
"code.chimeric.al/chimerical/odidere/internal/stt"
|
||||
"code.chimeric.al/chimerical/odidere/internal/tool"
|
||||
|
||||
"github.com/google/uuid"
|
||||
@@ -41,19 +39,6 @@ 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
|
||||
|
||||
@@ -67,7 +52,6 @@ type Service struct {
|
||||
log *slog.Logger
|
||||
mux *http.ServeMux
|
||||
server *http.Server
|
||||
stt *stt.Client
|
||||
tmpl *template.Template
|
||||
tools *tool.Registry
|
||||
}
|
||||
@@ -88,13 +72,6 @@ func New(cfg *config.Config, log *slog.Logger) (*Service, error) {
|
||||
}
|
||||
svc.tools = registry
|
||||
|
||||
// Create STT client.
|
||||
sttClient, err := stt.NewClient(cfg.STT, log)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create STT client: %v", err)
|
||||
}
|
||||
svc.stt = sttClient
|
||||
|
||||
// Create LLM client.
|
||||
llmClient, err := llm.NewClient(cfg.LLM, registry, log)
|
||||
if err != nil {
|
||||
@@ -135,8 +112,8 @@ func New(cfg *config.Config, log *slog.Logger) (*Service, error) {
|
||||
"/static/", http.FileServer(http.FS(staticFS)),
|
||||
),
|
||||
)
|
||||
svc.mux.HandleFunc("POST /v1/chat/voice", svc.voice)
|
||||
svc.mux.HandleFunc("POST /v1/chat/voice/stream", svc.voiceStream)
|
||||
svc.mux.HandleFunc("POST /v1/chat/voice", svc.chat)
|
||||
svc.mux.HandleFunc("POST /v1/chat/voice/stream", svc.chatStream)
|
||||
svc.mux.HandleFunc("GET /v1/models", svc.models)
|
||||
|
||||
svc.server = &http.Server{
|
||||
@@ -226,10 +203,6 @@ func (svc *Service) Run(ctx context.Context) error {
|
||||
"server",
|
||||
slog.String("address", svc.cfg.Address),
|
||||
),
|
||||
slog.Group(
|
||||
"stt",
|
||||
slog.String("url", svc.cfg.STT.URL),
|
||||
),
|
||||
slog.Group(
|
||||
"tools",
|
||||
slog.Int("count", len(svc.tools.List())),
|
||||
@@ -243,6 +216,10 @@ func (svc *Service) Run(ctx context.Context) error {
|
||||
slog.String("url", svc.cfg.TTS.URL),
|
||||
slog.String("default_voice", svc.cfg.TTS.Voice),
|
||||
),
|
||||
slog.Group(
|
||||
"stt_url",
|
||||
slog.String("url", svc.cfg.STT.URL),
|
||||
),
|
||||
slog.Group(
|
||||
"jobs",
|
||||
slog.Int("count", svc.JobCount()),
|
||||
@@ -355,19 +332,6 @@ 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()
|
||||
@@ -383,9 +347,11 @@ func (svc *Service) home(w http.ResponseWriter, r *http.Request) {
|
||||
w, "index.gohtml", struct {
|
||||
TTSURL string
|
||||
TTSDefaultVoice string
|
||||
STTURL string
|
||||
}{
|
||||
TTSURL: svc.cfg.TTS.URL,
|
||||
TTSDefaultVoice: svc.cfg.TTS.Voice,
|
||||
STTURL: svc.cfg.STT.URL,
|
||||
},
|
||||
); err != nil {
|
||||
log.ErrorContext(
|
||||
@@ -404,10 +370,8 @@ func (svc *Service) status(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}
|
||||
|
||||
// Request is the incoming request format for chat and voice endpoints.
|
||||
// Request is the incoming request format for the chat endpoints.
|
||||
type Request struct {
|
||||
// Audio is base64-encoded audio data (webm) for transcription.
|
||||
Audio string `json:"audio,omitempty"`
|
||||
// Messages is the conversation history.
|
||||
Messages []openai.ChatCompletionMessage `json:"messages"`
|
||||
// Model is the LLM model ID. If empty, the default model is used.
|
||||
@@ -420,21 +384,17 @@ type Request struct {
|
||||
|
||||
// Response is the response format for chat and voice endpoints.
|
||||
type Response struct {
|
||||
// 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,
|
||||
// including tool calls and tool results.
|
||||
Messages []openai.ChatCompletionMessage `json:"messages,omitempty"`
|
||||
// Model is the LLM model used for the response.
|
||||
Model string `json:"used_model,omitempty"`
|
||||
// Transcription is the transcribed user speech from the input audio.
|
||||
Transcription string `json:"transcription,omitempty"`
|
||||
// Voice is the voice used for TTS synthesis.
|
||||
Voice string `json:"used_voice,omitempty"`
|
||||
}
|
||||
|
||||
// voice processes voice requests with audio input/output.
|
||||
func (svc *Service) voice(w http.ResponseWriter, r *http.Request) {
|
||||
// chat processes text chat requests.
|
||||
func (svc *Service) chat(w http.ResponseWriter, r *http.Request) {
|
||||
var (
|
||||
ctx = r.Context()
|
||||
log = ctx.Value(logKey).(*slog.Logger)
|
||||
@@ -462,101 +422,12 @@ func (svc *Service) voice(w http.ResponseWriter, r *http.Request) {
|
||||
slog.Any("data", req.Messages),
|
||||
)
|
||||
|
||||
var (
|
||||
messages = req.Messages
|
||||
transcription string
|
||||
detectedLang string
|
||||
)
|
||||
|
||||
// If audio provided, transcribe and append to last message.
|
||||
if req.Audio != "" {
|
||||
last := &messages[len(messages)-1]
|
||||
if last.Role != openai.ChatMessageRoleUser {
|
||||
http.Error(
|
||||
w,
|
||||
"last message must be role=user when audio is provided",
|
||||
http.StatusBadRequest,
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
data, err := base64.StdEncoding.DecodeString(req.Audio)
|
||||
if err != nil {
|
||||
log.ErrorContext(
|
||||
ctx,
|
||||
"failed to decode audio",
|
||||
slog.Any("error", err),
|
||||
)
|
||||
http.Error(w, "invalid audio", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
output, err := svc.stt.Transcribe(ctx, data)
|
||||
if err != nil {
|
||||
log.ErrorContext(
|
||||
ctx,
|
||||
"STT failed",
|
||||
slog.Any("error", err),
|
||||
)
|
||||
http.Error(
|
||||
w,
|
||||
"STT error",
|
||||
http.StatusInternalServerError,
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
transcription = strings.TrimSpace(output.Text)
|
||||
detectedLang = output.DetectedLanguage
|
||||
if detectedLang == "" {
|
||||
detectedLang = output.Language
|
||||
}
|
||||
log.InfoContext(
|
||||
ctx,
|
||||
"transcribed audio",
|
||||
slog.String("text", transcription),
|
||||
slog.String("language", detectedLang),
|
||||
)
|
||||
|
||||
// Append transcription to last message's content.
|
||||
switch {
|
||||
// Already using MultiContent, append text part.
|
||||
case len(last.MultiContent) > 0:
|
||||
last.MultiContent = append(last.MultiContent,
|
||||
openai.ChatMessagePart{
|
||||
Type: openai.ChatMessagePartTypeText,
|
||||
Text: transcription,
|
||||
},
|
||||
)
|
||||
last.Content = ""
|
||||
|
||||
// Has string content, convert to MultiContent.
|
||||
case last.Content != "":
|
||||
last.MultiContent = []openai.ChatMessagePart{
|
||||
{
|
||||
Type: openai.ChatMessagePartTypeText,
|
||||
Text: last.Content,
|
||||
},
|
||||
{
|
||||
Type: openai.ChatMessagePartTypeText,
|
||||
Text: transcription,
|
||||
},
|
||||
}
|
||||
last.Content = ""
|
||||
// Empty message, just set content.
|
||||
// Clear MultiContent, as required by the API spec.
|
||||
default:
|
||||
last.Content = transcription
|
||||
last.MultiContent = nil
|
||||
}
|
||||
}
|
||||
|
||||
// Get LLM response.
|
||||
var model = req.Model
|
||||
if model == "" {
|
||||
model = svc.llm.DefaultModel()
|
||||
}
|
||||
msgs, err := svc.llm.Query(ctx, messages, model, req.SystemMessage, 0)
|
||||
msgs, err := svc.llm.Query(ctx, req.Messages, model, req.SystemMessage, 0)
|
||||
if err != nil {
|
||||
log.ErrorContext(
|
||||
ctx,
|
||||
@@ -582,32 +453,11 @@ func (svc *Service) voice(w http.ResponseWriter, r *http.Request) {
|
||||
slog.String("model", model),
|
||||
)
|
||||
|
||||
// Determine voice to use.
|
||||
var voice = req.Voice
|
||||
if req.Voice == "" && detectedLang != "" {
|
||||
if autoVoice := svc.selectVoice(
|
||||
detectedLang,
|
||||
); autoVoice != "" {
|
||||
voice = autoVoice
|
||||
log.InfoContext(ctx, "auto-selected voice",
|
||||
slog.String("language", detectedLang),
|
||||
slog.String("voice", voice),
|
||||
)
|
||||
}
|
||||
} else if req.Voice == "" {
|
||||
log.WarnContext(
|
||||
ctx,
|
||||
"auto-voice enabled but no language detected",
|
||||
)
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
if err := json.NewEncoder(w).Encode(Response{
|
||||
DetectedLanguage: detectedLang,
|
||||
Messages: msgs,
|
||||
Model: model,
|
||||
Transcription: transcription,
|
||||
Voice: voice,
|
||||
Messages: msgs,
|
||||
Model: model,
|
||||
Voice: req.Voice,
|
||||
}); err != nil {
|
||||
log.ErrorContext(
|
||||
ctx,
|
||||
@@ -617,24 +467,20 @@ func (svc *Service) voice(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
}
|
||||
|
||||
// StreamMessage is the SSE event payload for the streaming voice endpoint.
|
||||
// StreamMessage is the SSE event payload for the streaming chat endpoint.
|
||||
type StreamMessage struct {
|
||||
// DetectedLanguage is the language detected in the input speech.
|
||||
DetectedLanguage string `json:"detected_language,omitempty"`
|
||||
// Error is an error message, if any.
|
||||
Error string `json:"error,omitempty"`
|
||||
// Message is the chat completion message.
|
||||
Message openai.ChatCompletionMessage `json:"message"`
|
||||
// Model is the LLM model used for the response.
|
||||
Model string `json:"model,omitempty"`
|
||||
// Transcription is the transcribed user speech from the input audio.
|
||||
Transcription string `json:"transcription,omitempty"`
|
||||
// Voice is the voice used for TTS synthesis.
|
||||
Voice string `json:"voice,omitempty"`
|
||||
}
|
||||
|
||||
// voiceStream processes voice requests with streaming SSE output.
|
||||
func (svc *Service) voiceStream(w http.ResponseWriter, r *http.Request) {
|
||||
// chatStream processes chat requests with streaming SSE output.
|
||||
func (svc *Service) chatStream(w http.ResponseWriter, r *http.Request) {
|
||||
var (
|
||||
ctx = r.Context()
|
||||
log = ctx.Value(logKey).(*slog.Logger)
|
||||
@@ -670,90 +516,6 @@ func (svc *Service) voiceStream(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
var (
|
||||
messages = req.Messages
|
||||
transcription string
|
||||
detectedLang string
|
||||
)
|
||||
|
||||
// If audio provided, transcribe and append to last message.
|
||||
if req.Audio != "" {
|
||||
last := &messages[len(messages)-1]
|
||||
if last.Role != openai.ChatMessageRoleUser {
|
||||
http.Error(
|
||||
w,
|
||||
"last message must be role=user when audio is provided",
|
||||
http.StatusBadRequest,
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
data, err := base64.StdEncoding.DecodeString(req.Audio)
|
||||
if err != nil {
|
||||
log.ErrorContext(
|
||||
ctx,
|
||||
"failed to decode audio",
|
||||
slog.Any("error", err),
|
||||
)
|
||||
http.Error(w, "invalid audio", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
output, err := svc.stt.Transcribe(ctx, data)
|
||||
if err != nil {
|
||||
log.ErrorContext(
|
||||
ctx,
|
||||
"STT failed",
|
||||
slog.Any("error", err),
|
||||
)
|
||||
http.Error(
|
||||
w,
|
||||
"STT error",
|
||||
http.StatusInternalServerError,
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
transcription = strings.TrimSpace(output.Text)
|
||||
detectedLang = output.DetectedLanguage
|
||||
if detectedLang == "" {
|
||||
detectedLang = output.Language
|
||||
}
|
||||
log.InfoContext(
|
||||
ctx,
|
||||
"transcribed audio",
|
||||
slog.String("text", transcription),
|
||||
slog.String("language", detectedLang),
|
||||
)
|
||||
|
||||
// Append transcription to last message's content.
|
||||
switch {
|
||||
case len(last.MultiContent) > 0:
|
||||
last.MultiContent = append(last.MultiContent,
|
||||
openai.ChatMessagePart{
|
||||
Type: openai.ChatMessagePartTypeText,
|
||||
Text: transcription,
|
||||
},
|
||||
)
|
||||
last.Content = ""
|
||||
case last.Content != "":
|
||||
last.MultiContent = []openai.ChatMessagePart{
|
||||
{
|
||||
Type: openai.ChatMessagePartTypeText,
|
||||
Text: last.Content,
|
||||
},
|
||||
{
|
||||
Type: openai.ChatMessagePartTypeText,
|
||||
Text: transcription,
|
||||
},
|
||||
}
|
||||
last.Content = ""
|
||||
default:
|
||||
last.Content = transcription
|
||||
last.MultiContent = nil
|
||||
}
|
||||
}
|
||||
|
||||
// Set SSE headers.
|
||||
w.Header().Set("Cache-Control", "no-cache")
|
||||
w.Header().Set("Connection", "keep-alive")
|
||||
@@ -772,45 +534,19 @@ func (svc *Service) voiceStream(w http.ResponseWriter, r *http.Request) {
|
||||
flusher.Flush()
|
||||
}
|
||||
|
||||
// If audio was transcribed, send user message with transcription.
|
||||
if transcription != "" {
|
||||
send(StreamMessage{
|
||||
Message: openai.ChatCompletionMessage{
|
||||
Role: openai.ChatMessageRoleUser,
|
||||
Content: transcription,
|
||||
},
|
||||
Transcription: transcription,
|
||||
DetectedLanguage: detectedLang,
|
||||
})
|
||||
}
|
||||
|
||||
// Get model.
|
||||
var model = req.Model
|
||||
if model == "" {
|
||||
model = svc.llm.DefaultModel()
|
||||
}
|
||||
|
||||
// Determine voice to use.
|
||||
var voice = req.Voice
|
||||
if req.Voice == "" && detectedLang != "" {
|
||||
if autoVoice := svc.selectVoice(
|
||||
detectedLang,
|
||||
); autoVoice != "" {
|
||||
voice = autoVoice
|
||||
log.InfoContext(ctx, "auto-selected voice",
|
||||
slog.String("language", detectedLang),
|
||||
slog.String("voice", voice),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Start streaming LLM query.
|
||||
var (
|
||||
events = make(chan llm.StreamEvent)
|
||||
llmErr error
|
||||
)
|
||||
go func() {
|
||||
llmErr = svc.llm.QueryStream(ctx, messages, model, req.SystemMessage, 0, events)
|
||||
llmErr = svc.llm.QueryStream(ctx, req.Messages, model, req.SystemMessage, 0, events)
|
||||
}()
|
||||
|
||||
// Consume events and send as SSE.
|
||||
@@ -844,7 +580,7 @@ func (svc *Service) voiceStream(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
last.Model = model
|
||||
last.Voice = voice
|
||||
last.Voice = req.Voice
|
||||
send(last)
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user