Refactor STT architecture

This commit is contained in:
dwrz
2026-06-15 10:51:17 +00:00
parent 70e1a995f3
commit c71300b1bf
12 changed files with 321 additions and 634 deletions

View File

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