Refactor STT architecture
This commit is contained in:
@@ -1,5 +1,4 @@
|
|||||||
// Command odidere serves the odidere voice assistant API.
|
// Command odidere serves the odidere voice assistant API.
|
||||||
// It provides STT, LLM, and TTS orchestration via HTTP endpoints.
|
|
||||||
// See package service for the main orchestration logic and package
|
// See package service for the main orchestration logic and package
|
||||||
// config for configuration options.
|
// config for configuration options.
|
||||||
package main
|
package main
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ server:
|
|||||||
|
|
||||||
stt:
|
stt:
|
||||||
url: "http://localhost:8178"
|
url: "http://localhost:8178"
|
||||||
timeout: "30s"
|
|
||||||
|
|
||||||
llm:
|
llm:
|
||||||
url: "http://localhost:8081/v1"
|
url: "http://localhost:8081/v1"
|
||||||
@@ -17,16 +16,6 @@ llm:
|
|||||||
tts:
|
tts:
|
||||||
url: "http://localhost:8880"
|
url: "http://localhost:8880"
|
||||||
voice: "af_heart"
|
voice: "af_heart"
|
||||||
voice_map:
|
|
||||||
english: "af_heart" # American English
|
|
||||||
chinese: "zf_xiaobei" # Mandarin Chinese
|
|
||||||
japanese: "jf_alpha" # Japanese
|
|
||||||
spanish: "ef_dora" # Spanish
|
|
||||||
french: "ff_siwis" # French
|
|
||||||
hindi: "hf_alpha" # Hindi
|
|
||||||
italian: "if_sara" # Italian
|
|
||||||
portuguese: "pf_dora" # Brazilian Portuguese
|
|
||||||
korean: "kf_sarah" # Korean
|
|
||||||
timeout: "60s"
|
timeout: "60s"
|
||||||
|
|
||||||
tools:
|
tools:
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ import (
|
|||||||
|
|
||||||
"code.chimeric.al/chimerical/odidere/internal/job"
|
"code.chimeric.al/chimerical/odidere/internal/job"
|
||||||
"code.chimeric.al/chimerical/odidere/internal/llm"
|
"code.chimeric.al/chimerical/odidere/internal/llm"
|
||||||
"code.chimeric.al/chimerical/odidere/internal/stt"
|
|
||||||
"code.chimeric.al/chimerical/odidere/internal/tool"
|
"code.chimeric.al/chimerical/odidere/internal/tool"
|
||||||
|
|
||||||
"gopkg.in/yaml.v3"
|
"gopkg.in/yaml.v3"
|
||||||
@@ -25,10 +24,6 @@ type TTS struct {
|
|||||||
// Timeout is the maximum duration for a synthesis request.
|
// Timeout is the maximum duration for a synthesis request.
|
||||||
// Defaults to 60s if empty.
|
// Defaults to 60s if empty.
|
||||||
Timeout string `yaml:"timeout"`
|
Timeout string `yaml:"timeout"`
|
||||||
// VoiceMap maps whisper language names to voice IDs.
|
|
||||||
// Used by SelectVoice to auto-select voices based on detected
|
|
||||||
// language.
|
|
||||||
VoiceMap map[string]string `yaml:"voice_map"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (cfg TTS) Validate() error {
|
func (cfg TTS) Validate() error {
|
||||||
@@ -46,6 +41,22 @@ func (cfg TTS) Validate() error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// STT holds the configuration for the frontend-facing speech-to-text server.
|
||||||
|
// The URL is passed to the browser via a meta tag; the browser calls
|
||||||
|
// whisper-server directly.
|
||||||
|
type STT struct {
|
||||||
|
// URL is the base URL of the whisper-server.
|
||||||
|
// For example, "http://localhost:8178".
|
||||||
|
URL string `yaml:"url"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (cfg STT) Validate() error {
|
||||||
|
if cfg.URL == "" {
|
||||||
|
return fmt.Errorf("missing URL")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// Config holds all application configuration sections.
|
// Config holds all application configuration sections.
|
||||||
type Config struct {
|
type Config struct {
|
||||||
// Address is the host:port to listen on.
|
// Address is the host:port to listen on.
|
||||||
@@ -59,8 +70,8 @@ type Config struct {
|
|||||||
|
|
||||||
// LLM configures the language model client.
|
// LLM configures the language model client.
|
||||||
LLM llm.Config `yaml:"llm"`
|
LLM llm.Config `yaml:"llm"`
|
||||||
// STT configures the speech-to-text client.
|
// STT configures the speech-to-text server URL passed to the frontend.
|
||||||
STT stt.Config `yaml:"stt"`
|
STT STT `yaml:"stt"`
|
||||||
// Jobs defines scheduled autonomous tasks.
|
// Jobs defines scheduled autonomous tasks.
|
||||||
Jobs []job.Config `yaml:"jobs"`
|
Jobs []job.Config `yaml:"jobs"`
|
||||||
// Tools defines external commands available to the LLM.
|
// Tools defines external commands available to the LLM.
|
||||||
|
|||||||
@@ -215,7 +215,7 @@ llm:
|
|||||||
url: http://localhost:8080
|
url: http://localhost:8080
|
||||||
|
|
||||||
stt:
|
stt:
|
||||||
timeout: "30s"
|
url: ""
|
||||||
|
|
||||||
tts:
|
tts:
|
||||||
url: http://localhost:8880
|
url: http://localhost:8880
|
||||||
|
|||||||
@@ -1,12 +1,11 @@
|
|||||||
// Package service orchestrates the odidere voice assistant server.
|
// 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.
|
// graceful shutdown.
|
||||||
package service
|
package service
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"embed"
|
"embed"
|
||||||
"encoding/base64"
|
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"html/template"
|
"html/template"
|
||||||
@@ -24,7 +23,6 @@ import (
|
|||||||
"code.chimeric.al/chimerical/odidere/internal/job"
|
"code.chimeric.al/chimerical/odidere/internal/job"
|
||||||
"code.chimeric.al/chimerical/odidere/internal/llm"
|
"code.chimeric.al/chimerical/odidere/internal/llm"
|
||||||
"code.chimeric.al/chimerical/odidere/internal/service/templates"
|
"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/tool"
|
||||||
|
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
@@ -41,19 +39,6 @@ const (
|
|||||||
ipKey key = "ip"
|
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/*
|
//go:embed all:static/*
|
||||||
var static embed.FS
|
var static embed.FS
|
||||||
|
|
||||||
@@ -67,7 +52,6 @@ type Service struct {
|
|||||||
log *slog.Logger
|
log *slog.Logger
|
||||||
mux *http.ServeMux
|
mux *http.ServeMux
|
||||||
server *http.Server
|
server *http.Server
|
||||||
stt *stt.Client
|
|
||||||
tmpl *template.Template
|
tmpl *template.Template
|
||||||
tools *tool.Registry
|
tools *tool.Registry
|
||||||
}
|
}
|
||||||
@@ -88,13 +72,6 @@ func New(cfg *config.Config, log *slog.Logger) (*Service, error) {
|
|||||||
}
|
}
|
||||||
svc.tools = registry
|
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.
|
// Create LLM client.
|
||||||
llmClient, err := llm.NewClient(cfg.LLM, registry, log)
|
llmClient, err := llm.NewClient(cfg.LLM, registry, log)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -135,8 +112,8 @@ func New(cfg *config.Config, log *slog.Logger) (*Service, error) {
|
|||||||
"/static/", http.FileServer(http.FS(staticFS)),
|
"/static/", http.FileServer(http.FS(staticFS)),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
svc.mux.HandleFunc("POST /v1/chat/voice", svc.voice)
|
svc.mux.HandleFunc("POST /v1/chat/voice", svc.chat)
|
||||||
svc.mux.HandleFunc("POST /v1/chat/voice/stream", svc.voiceStream)
|
svc.mux.HandleFunc("POST /v1/chat/voice/stream", svc.chatStream)
|
||||||
svc.mux.HandleFunc("GET /v1/models", svc.models)
|
svc.mux.HandleFunc("GET /v1/models", svc.models)
|
||||||
|
|
||||||
svc.server = &http.Server{
|
svc.server = &http.Server{
|
||||||
@@ -226,10 +203,6 @@ func (svc *Service) Run(ctx context.Context) error {
|
|||||||
"server",
|
"server",
|
||||||
slog.String("address", svc.cfg.Address),
|
slog.String("address", svc.cfg.Address),
|
||||||
),
|
),
|
||||||
slog.Group(
|
|
||||||
"stt",
|
|
||||||
slog.String("url", svc.cfg.STT.URL),
|
|
||||||
),
|
|
||||||
slog.Group(
|
slog.Group(
|
||||||
"tools",
|
"tools",
|
||||||
slog.Int("count", len(svc.tools.List())),
|
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("url", svc.cfg.TTS.URL),
|
||||||
slog.String("default_voice", svc.cfg.TTS.Voice),
|
slog.String("default_voice", svc.cfg.TTS.Voice),
|
||||||
),
|
),
|
||||||
|
slog.Group(
|
||||||
|
"stt_url",
|
||||||
|
slog.String("url", svc.cfg.STT.URL),
|
||||||
|
),
|
||||||
slog.Group(
|
slog.Group(
|
||||||
"jobs",
|
"jobs",
|
||||||
slog.Int("count", svc.JobCount()),
|
slog.Int("count", svc.JobCount()),
|
||||||
@@ -355,19 +332,6 @@ func (svc *Service) JobCount() int {
|
|||||||
return len(svc.jobs)
|
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) {
|
func (svc *Service) home(w http.ResponseWriter, r *http.Request) {
|
||||||
var (
|
var (
|
||||||
ctx = r.Context()
|
ctx = r.Context()
|
||||||
@@ -383,9 +347,11 @@ func (svc *Service) home(w http.ResponseWriter, r *http.Request) {
|
|||||||
w, "index.gohtml", struct {
|
w, "index.gohtml", struct {
|
||||||
TTSURL string
|
TTSURL string
|
||||||
TTSDefaultVoice string
|
TTSDefaultVoice string
|
||||||
|
STTURL string
|
||||||
}{
|
}{
|
||||||
TTSURL: svc.cfg.TTS.URL,
|
TTSURL: svc.cfg.TTS.URL,
|
||||||
TTSDefaultVoice: svc.cfg.TTS.Voice,
|
TTSDefaultVoice: svc.cfg.TTS.Voice,
|
||||||
|
STTURL: svc.cfg.STT.URL,
|
||||||
},
|
},
|
||||||
); err != nil {
|
); err != nil {
|
||||||
log.ErrorContext(
|
log.ErrorContext(
|
||||||
@@ -404,10 +370,8 @@ func (svc *Service) status(w http.ResponseWriter, r *http.Request) {
|
|||||||
w.WriteHeader(http.StatusOK)
|
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 {
|
type Request struct {
|
||||||
// Audio is base64-encoded audio data (webm) for transcription.
|
|
||||||
Audio string `json:"audio,omitempty"`
|
|
||||||
// Messages is the conversation history.
|
// Messages is the conversation history.
|
||||||
Messages []openai.ChatCompletionMessage `json:"messages"`
|
Messages []openai.ChatCompletionMessage `json:"messages"`
|
||||||
// Model is the LLM model ID. If empty, the default model is used.
|
// 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.
|
// Response is the response format for chat and voice endpoints.
|
||||||
type Response struct {
|
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,
|
// Messages is the full list of messages generated during the query,
|
||||||
// including tool calls and tool results.
|
// including tool calls and tool results.
|
||||||
Messages []openai.ChatCompletionMessage `json:"messages,omitempty"`
|
Messages []openai.ChatCompletionMessage `json:"messages,omitempty"`
|
||||||
// Model is the LLM model used for the response.
|
// Model is the LLM model used for the response.
|
||||||
Model string `json:"used_model,omitempty"`
|
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 is the voice used for TTS synthesis.
|
||||||
Voice string `json:"used_voice,omitempty"`
|
Voice string `json:"used_voice,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// voice processes voice requests with audio input/output.
|
// chat processes text chat requests.
|
||||||
func (svc *Service) voice(w http.ResponseWriter, r *http.Request) {
|
func (svc *Service) chat(w http.ResponseWriter, r *http.Request) {
|
||||||
var (
|
var (
|
||||||
ctx = r.Context()
|
ctx = r.Context()
|
||||||
log = ctx.Value(logKey).(*slog.Logger)
|
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),
|
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.
|
// Get LLM response.
|
||||||
var model = req.Model
|
var model = req.Model
|
||||||
if model == "" {
|
if model == "" {
|
||||||
model = svc.llm.DefaultModel()
|
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 {
|
if err != nil {
|
||||||
log.ErrorContext(
|
log.ErrorContext(
|
||||||
ctx,
|
ctx,
|
||||||
@@ -582,32 +453,11 @@ func (svc *Service) voice(w http.ResponseWriter, r *http.Request) {
|
|||||||
slog.String("model", model),
|
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")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
if err := json.NewEncoder(w).Encode(Response{
|
if err := json.NewEncoder(w).Encode(Response{
|
||||||
DetectedLanguage: detectedLang,
|
Messages: msgs,
|
||||||
Messages: msgs,
|
Model: model,
|
||||||
Model: model,
|
Voice: req.Voice,
|
||||||
Transcription: transcription,
|
|
||||||
Voice: voice,
|
|
||||||
}); err != nil {
|
}); err != nil {
|
||||||
log.ErrorContext(
|
log.ErrorContext(
|
||||||
ctx,
|
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 {
|
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 is an error message, if any.
|
||||||
Error string `json:"error,omitempty"`
|
Error string `json:"error,omitempty"`
|
||||||
// Message is the chat completion message.
|
// Message is the chat completion message.
|
||||||
Message openai.ChatCompletionMessage `json:"message"`
|
Message openai.ChatCompletionMessage `json:"message"`
|
||||||
// Model is the LLM model used for the response.
|
// Model is the LLM model used for the response.
|
||||||
Model string `json:"model,omitempty"`
|
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 is the voice used for TTS synthesis.
|
||||||
Voice string `json:"voice,omitempty"`
|
Voice string `json:"voice,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// voiceStream processes voice requests with streaming SSE output.
|
// chatStream processes chat requests with streaming SSE output.
|
||||||
func (svc *Service) voiceStream(w http.ResponseWriter, r *http.Request) {
|
func (svc *Service) chatStream(w http.ResponseWriter, r *http.Request) {
|
||||||
var (
|
var (
|
||||||
ctx = r.Context()
|
ctx = r.Context()
|
||||||
log = ctx.Value(logKey).(*slog.Logger)
|
log = ctx.Value(logKey).(*slog.Logger)
|
||||||
@@ -670,90 +516,6 @@ func (svc *Service) voiceStream(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
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.
|
// Set SSE headers.
|
||||||
w.Header().Set("Cache-Control", "no-cache")
|
w.Header().Set("Cache-Control", "no-cache")
|
||||||
w.Header().Set("Connection", "keep-alive")
|
w.Header().Set("Connection", "keep-alive")
|
||||||
@@ -772,45 +534,19 @@ func (svc *Service) voiceStream(w http.ResponseWriter, r *http.Request) {
|
|||||||
flusher.Flush()
|
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.
|
// Get model.
|
||||||
var model = req.Model
|
var model = req.Model
|
||||||
if model == "" {
|
if model == "" {
|
||||||
model = svc.llm.DefaultModel()
|
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.
|
// Start streaming LLM query.
|
||||||
var (
|
var (
|
||||||
events = make(chan llm.StreamEvent)
|
events = make(chan llm.StreamEvent)
|
||||||
llmErr error
|
llmErr error
|
||||||
)
|
)
|
||||||
go func() {
|
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.
|
// Consume events and send as SSE.
|
||||||
@@ -844,7 +580,7 @@ func (svc *Service) voiceStream(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
last.Model = model
|
last.Model = model
|
||||||
last.Voice = voice
|
last.Voice = req.Voice
|
||||||
send(last)
|
send(last)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -57,6 +57,10 @@
|
|||||||
<line x1="12" x2="12" y1="19" y2="22"/>
|
<line x1="12" x2="12" y1="19" y2="22"/>
|
||||||
</symbol>
|
</symbol>
|
||||||
|
|
||||||
|
<symbol id="dictate" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<path d="M13 21h8m.174-14.188a1 1 0 0 0-3.986-3.987L3.842 16.174a2 2 0 0 0-.5.83l-1.321 4.352a.5.5 0 0 0 .623.622l4.353-1.32a2 2 0 0 0 .83-.497z"/>
|
||||||
|
</symbol>
|
||||||
|
|
||||||
<symbol id="send" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
<symbol id="send" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
<path d="M14.536 21.686a.5.5 0 0 0 .937-.024l6.5-19a.496.496 0 0 0-.635-.635l-19 6.5a.5.5 0 0 0-.024.937l7.93 3.18a2 2 0 0 1 1.112 1.11z"/>
|
<path d="M14.536 21.686a.5.5 0 0 0 .937-.024l6.5-19a.496.496 0 0 0-.635-.635l-19 6.5a.5.5 0 0 0-.024.937l7.93 3.18a2 2 0 0 1 1.112 1.11z"/>
|
||||||
<path d="m21.854 2.147-10.94 10.939"/>
|
<path d="m21.854 2.147-10.94 10.939"/>
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 6.7 KiB After Width: | Height: | Size: 7.0 KiB |
@@ -279,6 +279,19 @@ body {
|
|||||||
animation: pulse 1s ease-in-out infinite;
|
animation: pulse 1s ease-in-out infinite;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Transcribe button: recording state (same as PTT but for transcribe) */
|
||||||
|
.compose__action-btn--recording {
|
||||||
|
color: white;
|
||||||
|
background: var(--color-recording);
|
||||||
|
animation: pulse 1s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Transcribe button: transcribing state (spinning) */
|
||||||
|
.compose__action-btn--transcribing {
|
||||||
|
color: var(--color-primary);
|
||||||
|
animation: spin 1s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
.compose__action-btn--send .compose__icon--stop {
|
.compose__action-btn--send .compose__icon--stop {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,21 @@ const STORAGE_KEY = 'odidere_history';
|
|||||||
const SYSTEM_MESSAGE_KEY = 'odidere_system_message';
|
const SYSTEM_MESSAGE_KEY = 'odidere_system_message';
|
||||||
const VOICE_KEY = 'odidere_voice';
|
const VOICE_KEY = 'odidere_voice';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* VOICE_MAP maps whisper language names to default Kokoro voices.
|
||||||
|
* Used for auto-selecting a voice when the selector is set to "Auto".
|
||||||
|
*/
|
||||||
|
const VOICE_MAP = {
|
||||||
|
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',
|
||||||
|
};
|
||||||
/**
|
/**
|
||||||
* Odidere is the main application class for the voice assistant UI.
|
* Odidere is the main application class for the voice assistant UI.
|
||||||
* It manages audio recording, chat history, and communication with the API.
|
* It manages audio recording, chat history, and communication with the API.
|
||||||
@@ -25,6 +40,7 @@ class Odidere {
|
|||||||
this.currentController = null;
|
this.currentController = null;
|
||||||
this.isProcessing = false;
|
this.isProcessing = false;
|
||||||
this.isRecording = false;
|
this.isRecording = false;
|
||||||
|
this.isTranscribing = false;
|
||||||
this.isMuted = false;
|
this.isMuted = false;
|
||||||
this.isResetPending = false;
|
this.isResetPending = false;
|
||||||
this.isScrollPending = false;
|
this.isScrollPending = false;
|
||||||
@@ -45,6 +61,9 @@ class Odidere {
|
|||||||
this.isPlaying = false;
|
this.isPlaying = false;
|
||||||
this.currentMessageId = null;
|
this.currentMessageId = null;
|
||||||
|
|
||||||
|
// STT state
|
||||||
|
this.sttUrl = document.querySelector('meta[name="stt-url"]')?.content || '';
|
||||||
|
|
||||||
// Auto-scroll: enabled by default, disabled when user scrolls up,
|
// Auto-scroll: enabled by default, disabled when user scrolls up,
|
||||||
// re-enabled when they scroll back to bottom or send a new message.
|
// re-enabled when they scroll back to bottom or send a new message.
|
||||||
this.isAutoScrollEnabled = true;
|
this.isAutoScrollEnabled = true;
|
||||||
@@ -64,6 +83,7 @@ class Odidere {
|
|||||||
this.$textInput = document.getElementById('text-input');
|
this.$textInput = document.getElementById('text-input');
|
||||||
this.$voice = document.getElementById('voice');
|
this.$voice = document.getElementById('voice');
|
||||||
this.$mute = document.getElementById('mute');
|
this.$mute = document.getElementById('mute');
|
||||||
|
this.$transcribe = document.getElementById('transcribe');
|
||||||
|
|
||||||
// Settings modal
|
// Settings modal
|
||||||
this.$settings = document.getElementById('settings');
|
this.$settings = document.getElementById('settings');
|
||||||
@@ -192,6 +212,35 @@ class Odidere {
|
|||||||
this.#handleAttachments(e.target.files),
|
this.#handleAttachments(e.target.files),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Transcribe button: hold to record, release to transcribe and populate textarea.
|
||||||
|
this.$transcribe.addEventListener(
|
||||||
|
'touchstart',
|
||||||
|
this.#handleTranscribeTouchStart,
|
||||||
|
{
|
||||||
|
passive: false,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
this.$transcribe.addEventListener(
|
||||||
|
'touchend',
|
||||||
|
this.#handleTranscribeTouchEnd,
|
||||||
|
);
|
||||||
|
this.$transcribe.addEventListener(
|
||||||
|
'touchcancel',
|
||||||
|
this.#handleTranscribeTouchEnd,
|
||||||
|
);
|
||||||
|
// Prevent context menu on long press.
|
||||||
|
this.$transcribe.addEventListener('contextmenu', (e) => e.preventDefault());
|
||||||
|
|
||||||
|
this.$transcribe.addEventListener(
|
||||||
|
'mousedown',
|
||||||
|
this.#handleTranscribeMouseDown,
|
||||||
|
);
|
||||||
|
this.$transcribe.addEventListener('mouseup', this.#handleTranscribeMouseUp);
|
||||||
|
this.$transcribe.addEventListener(
|
||||||
|
'mouseleave',
|
||||||
|
this.#handleTranscribeMouseUp,
|
||||||
|
);
|
||||||
|
|
||||||
// Save selections on change.
|
// Save selections on change.
|
||||||
this.$model.addEventListener('change', () => {
|
this.$model.addEventListener('change', () => {
|
||||||
localStorage.setItem(MODEL_KEY, this.$model.value);
|
localStorage.setItem(MODEL_KEY, this.$model.value);
|
||||||
@@ -830,6 +879,122 @@ class Odidere {
|
|||||||
this.#setLoadingState(false);
|
this.#setLoadingState(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ====================
|
||||||
|
// STT: FRONTEND TRANSCRIPTION
|
||||||
|
// ====================
|
||||||
|
/**
|
||||||
|
* #transcribeAudio sends an audio blob to whisper-server directly from
|
||||||
|
* the browser and returns the transcription result.
|
||||||
|
* @param {Blob} audioBlob
|
||||||
|
* @returns {Promise<{text: string, detectedLanguage: string}>}
|
||||||
|
*/
|
||||||
|
async #transcribeAudio(audioBlob) {
|
||||||
|
if (!this.sttUrl) {
|
||||||
|
throw new Error('STT URL not configured');
|
||||||
|
}
|
||||||
|
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('file', audioBlob, 'audio.webm');
|
||||||
|
formData.append('response_format', 'verbose_json');
|
||||||
|
|
||||||
|
const res = await fetch(`${this.sttUrl}/inference`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: formData,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
const errText = await res.text().catch(() => '');
|
||||||
|
throw new Error(`STT error ${res.status}: ${errText}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await res.json();
|
||||||
|
const text = (data.text || '').trim();
|
||||||
|
const detectedLanguage = data.detected_language || data.language || '';
|
||||||
|
return { text, detectedLanguage };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* #handleTranscribeTouchStart handles touch start on the transcribe button.
|
||||||
|
* Begins recording for transcription (hold-to-record, like PTT).
|
||||||
|
* @param {TouchEvent} event
|
||||||
|
*/
|
||||||
|
#handleTranscribeTouchStart = (event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
this.#startTranscribeRecording();
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* #handleTranscribeTouchEnd handles touch end on the transcribe button.
|
||||||
|
* Stops recording; the shared stop handler will populate the textarea.
|
||||||
|
* @param {TouchEvent} event
|
||||||
|
*/
|
||||||
|
#handleTranscribeTouchEnd = (event) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
this.#stopTranscribeRecording();
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* #handleTranscribeMouseDown handles mouse down on the transcribe button.
|
||||||
|
* Ignores events that originate from touch to avoid double-firing.
|
||||||
|
* @param {MouseEvent} event
|
||||||
|
*/
|
||||||
|
#handleTranscribeMouseDown = (event) => {
|
||||||
|
if (event.sourceCapabilities?.firesTouchEvents) return;
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
this.#startTranscribeRecording();
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* #handleTranscribeMouseUp handles mouse up on the transcribe button.
|
||||||
|
* @param {MouseEvent} event
|
||||||
|
*/
|
||||||
|
#handleTranscribeMouseUp = (event) => {
|
||||||
|
if (event.sourceCapabilities?.firesTouchEvents) return;
|
||||||
|
event.stopPropagation();
|
||||||
|
this.#stopTranscribeRecording();
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* #startTranscribeRecording begins recording for transcription mode.
|
||||||
|
* Unlike PTT, this populates the textarea on stop rather than sending.
|
||||||
|
*/
|
||||||
|
async #startTranscribeRecording() {
|
||||||
|
if (this.isTranscribing) return;
|
||||||
|
if (this.isRecording) return;
|
||||||
|
if (this.isProcessing) return;
|
||||||
|
|
||||||
|
this.#stopCurrentAudio();
|
||||||
|
this.#stopTTS();
|
||||||
|
|
||||||
|
// Initialize MediaRecorder on first use (shared with PTT).
|
||||||
|
if (!this.mediaRecorder) {
|
||||||
|
const success = await this.#initMediaRecorder();
|
||||||
|
if (!success) return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.audioChunks = [];
|
||||||
|
this.mediaRecorder.start();
|
||||||
|
this.isTranscribing = true;
|
||||||
|
this.$transcribe.classList.add('compose__action-btn--recording');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* #stopTranscribeRecording stops the media recorder for transcription mode.
|
||||||
|
* The shared 'stop' event handler (set up in #initMediaRecorder) will
|
||||||
|
* fire and clear UI state since this.isTranscribing is still true.
|
||||||
|
*/
|
||||||
|
#stopTranscribeRecording() {
|
||||||
|
if (!this.isTranscribing) return;
|
||||||
|
if (!this.mediaRecorder) return;
|
||||||
|
|
||||||
|
// Keep isTranscribing=true so the stop handler knows to populate textarea.
|
||||||
|
// The stop handler is responsible for clearing the recording class,
|
||||||
|
// so we do not remove it here.
|
||||||
|
this.mediaRecorder.stop();
|
||||||
|
}
|
||||||
|
|
||||||
// ====================
|
// ====================
|
||||||
// AUDIO RECORDING
|
// AUDIO RECORDING
|
||||||
// ====================
|
// ====================
|
||||||
@@ -840,6 +1005,7 @@ class Odidere {
|
|||||||
*/
|
*/
|
||||||
async #startRecording() {
|
async #startRecording() {
|
||||||
if (this.isRecording) return;
|
if (this.isRecording) return;
|
||||||
|
if (this.isTranscribing) return;
|
||||||
if (this.isProcessing) return;
|
if (this.isProcessing) return;
|
||||||
|
|
||||||
this.#stopCurrentAudio();
|
this.#stopCurrentAudio();
|
||||||
@@ -854,7 +1020,6 @@ class Odidere {
|
|||||||
this.audioChunks = [];
|
this.audioChunks = [];
|
||||||
this.mediaRecorder.start();
|
this.mediaRecorder.start();
|
||||||
this.#setRecordingState(true);
|
this.#setRecordingState(true);
|
||||||
this.#acquireWakeLock();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -864,13 +1029,17 @@ class Odidere {
|
|||||||
if (!this.isRecording) return;
|
if (!this.isRecording) return;
|
||||||
if (!this.mediaRecorder) return;
|
if (!this.mediaRecorder) return;
|
||||||
|
|
||||||
|
// Do not clear recording state here. The async 'stop' event handler
|
||||||
|
// is responsible for clearing it (#setRecordingState(false)) after
|
||||||
|
// the audio blob has been assembled.
|
||||||
this.mediaRecorder.stop();
|
this.mediaRecorder.stop();
|
||||||
this.#setRecordingState(false);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* #initMediaRecorder initializes the MediaRecorder with microphone access.
|
* #initMediaRecorder initializes the MediaRecorder with microphone access.
|
||||||
* Prefers opus codec for better compression if available.
|
* Prefers opus codec for better compression if available.
|
||||||
|
* The stop handler branches on this.isTranscribing: when true it populates
|
||||||
|
* the textarea; when false (PTT flow) it sends the request.
|
||||||
* @returns {Promise<boolean>} true if successful
|
* @returns {Promise<boolean>} true if successful
|
||||||
*/
|
*/
|
||||||
async #initMediaRecorder() {
|
async #initMediaRecorder() {
|
||||||
@@ -891,22 +1060,87 @@ class Odidere {
|
|||||||
this.mediaRecorder.addEventListener('stop', async () => {
|
this.mediaRecorder.addEventListener('stop', async () => {
|
||||||
if (this.audioChunks.length === 0) return;
|
if (this.audioChunks.length === 0) return;
|
||||||
|
|
||||||
|
// Capture the mode synchronously before any async work.
|
||||||
|
// The 'stop' event fires asynchronously, so this.isTranscribing
|
||||||
|
// may have changed by the time we await below.
|
||||||
|
const wasTranscribing = this.isTranscribing;
|
||||||
|
|
||||||
const audioBlob = new Blob(this.audioChunks, {
|
const audioBlob = new Blob(this.audioChunks, {
|
||||||
type: this.mediaRecorder.mimeType,
|
type: this.mediaRecorder.mimeType,
|
||||||
});
|
});
|
||||||
this.audioChunks = [];
|
this.audioChunks = [];
|
||||||
|
|
||||||
// Capture and clear text input to send with audio.
|
// Clear the flag now that the stop event has fired.
|
||||||
const text = this.$textInput.value.trim();
|
if (wasTranscribing) {
|
||||||
this.$textInput.value = '';
|
this.isTranscribing = false;
|
||||||
this.$textInput.style.height = 'auto';
|
this.$transcribe.classList.remove('compose__action-btn--recording');
|
||||||
|
} else {
|
||||||
|
this.#setRecordingState(false);
|
||||||
|
}
|
||||||
|
|
||||||
await this.#sendRequest({ audio: audioBlob, text });
|
// Transcribe the audio.
|
||||||
|
try {
|
||||||
|
const { text: transcription, detectedLanguage } =
|
||||||
|
await this.#transcribeAudio(audioBlob);
|
||||||
|
|
||||||
|
// Resolve the effective voice for this message.
|
||||||
|
// If the selector is "Auto", pick a default for the detected language.
|
||||||
|
// This does NOT change the dropdown - the voice is per-message only.
|
||||||
|
let voice = this.$voice.value;
|
||||||
|
if (!voice && detectedLanguage) {
|
||||||
|
voice = VOICE_MAP[detectedLanguage] || '';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Branch: transcribe-only (populate textarea) vs PTT (send).
|
||||||
|
if (wasTranscribing) {
|
||||||
|
// Transcribe mode: populate textarea, do not send.
|
||||||
|
this.$transcribe.classList.add('compose__action-btn--transcribing');
|
||||||
|
try {
|
||||||
|
if (transcription) {
|
||||||
|
const existing = this.$textInput.value;
|
||||||
|
if (existing.trim()) {
|
||||||
|
this.$textInput.value = `${existing}\n${transcription}`;
|
||||||
|
} else {
|
||||||
|
this.$textInput.value = transcription;
|
||||||
|
}
|
||||||
|
this.#handleTextareaInput();
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
this.$transcribe.classList.remove(
|
||||||
|
'compose__action-btn--transcribing',
|
||||||
|
);
|
||||||
|
this.$textInput.focus();
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// PTT mode: capture text, clear input, send request.
|
||||||
|
const text = this.$textInput.value.trim();
|
||||||
|
this.$textInput.value = '';
|
||||||
|
this.$textInput.style.height = 'auto';
|
||||||
|
|
||||||
|
const combined = text ? `${text}\n${transcription}` : transcription;
|
||||||
|
await this.#sendRequest({
|
||||||
|
text: combined,
|
||||||
|
detectedLanguage,
|
||||||
|
voice,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('transcription failed:', e);
|
||||||
|
this.#renderError(`Transcription failed: ${e.message}`);
|
||||||
|
if (!wasTranscribing) {
|
||||||
|
this.#setLoadingState(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
this.mediaRecorder.addEventListener('error', (event) => {
|
this.mediaRecorder.addEventListener('error', (event) => {
|
||||||
console.error('MediaRecorder error:', event.error);
|
console.error('MediaRecorder error:', event.error);
|
||||||
this.#setRecordingState(false);
|
if (this.isTranscribing) {
|
||||||
|
this.isTranscribing = false;
|
||||||
|
this.$transcribe.classList.remove('compose__action-btn--recording');
|
||||||
|
} else {
|
||||||
|
this.#setRecordingState(false);
|
||||||
|
}
|
||||||
this.audioChunks = [];
|
this.audioChunks = [];
|
||||||
this.#renderError(
|
this.#renderError(
|
||||||
`Recording error: ${event.error?.message || 'Unknown error'}`,
|
`Recording error: ${event.error?.message || 'Unknown error'}`,
|
||||||
@@ -1181,25 +1415,24 @@ class Odidere {
|
|||||||
*
|
*
|
||||||
* For text-only input, renders the user message immediately for
|
* For text-only input, renders the user message immediately for
|
||||||
* responsiveness.
|
* responsiveness.
|
||||||
* For audio input, waits for the server's transcription event before
|
|
||||||
* rendering the user message.
|
|
||||||
*
|
*
|
||||||
* Messages are rendered incrementally as SSE events arrive:
|
* Messages are rendered incrementally as SSE events arrive:
|
||||||
* - user (transcription), assistant (tool calls), tool (results),
|
* - user, assistant (tool calls), tool (results),
|
||||||
* and a final assistant message with content.
|
* and a final assistant message with content.
|
||||||
*
|
*
|
||||||
* @param {Object} options
|
* @param {Object} options
|
||||||
* @param {Blob} [options.audio] - Recorded audio blob
|
|
||||||
* @param {string} [options.text] - Text input
|
* @param {string} [options.text] - Text input
|
||||||
|
* @param {string} [options.detectedLanguage] - Detected language from STT
|
||||||
|
* @param {string} [options.voice] - Resolved voice for this message (from auto-selection)
|
||||||
*/
|
*/
|
||||||
async #sendRequest({ audio = null, text = '' }) {
|
async #sendRequest({ text = '', detectedLanguage = '', voice }) {
|
||||||
this.#setLoadingState(true);
|
this.#setLoadingState(true);
|
||||||
// Re-enable auto-scroll when the user sends a new message.
|
// Re-enable auto-scroll when the user sends a new message.
|
||||||
this.isAutoScrollEnabled = true;
|
this.isAutoScrollEnabled = true;
|
||||||
this.currentController = new AbortController();
|
this.currentController = new AbortController();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const hasNewInput = text || this.attachments.length > 0 || audio;
|
const hasNewInput = text || this.attachments.length > 0;
|
||||||
|
|
||||||
// Build messages array from active path + current user message.
|
// Build messages array from active path + current user message.
|
||||||
const activePath = this.getActivePath();
|
const activePath = this.getActivePath();
|
||||||
@@ -1221,25 +1454,24 @@ class Odidere {
|
|||||||
|
|
||||||
const payload = {
|
const payload = {
|
||||||
messages,
|
messages,
|
||||||
voice: this.$voice.value,
|
voice: voice ?? this.$voice.value,
|
||||||
model: this.$model.value,
|
model: this.$model.value,
|
||||||
};
|
};
|
||||||
const systemMessage = localStorage.getItem(SYSTEM_MESSAGE_KEY);
|
const systemMessage = localStorage.getItem(SYSTEM_MESSAGE_KEY);
|
||||||
if (systemMessage) {
|
if (systemMessage) {
|
||||||
payload.system_message = systemMessage;
|
payload.system_message = systemMessage;
|
||||||
}
|
}
|
||||||
if (audio) {
|
|
||||||
payload.audio = await this.#toBase64(audio);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Clear attachments after building payload (before async operations).
|
// Clear attachments after building payload (before async operations).
|
||||||
this.#clearAttachments();
|
this.#clearAttachments();
|
||||||
|
|
||||||
// For text-only requests with new input, add to history and render
|
// For text-only requests with new input, add to history and render
|
||||||
// immediately. Skip when continuing conversation (no new user message).
|
// immediately. Skip when continuing conversation (no new user message).
|
||||||
if (!audio && hasNewInput) {
|
if (hasNewInput) {
|
||||||
const appended = this.#appendHistory([userMessage]);
|
const meta = {};
|
||||||
this.#renderMessages(appended);
|
if (detectedLanguage) meta.language = detectedLanguage;
|
||||||
|
const appended = this.#appendHistory([userMessage], meta);
|
||||||
|
this.#renderMessages(appended, meta);
|
||||||
}
|
}
|
||||||
|
|
||||||
const res = await fetch(STREAM_ENDPOINT, {
|
const res = await fetch(STREAM_ENDPOINT, {
|
||||||
@@ -1296,37 +1528,6 @@ class Odidere {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const message = event.message;
|
const message = event.message;
|
||||||
// Handle user message from server (audio transcription).
|
|
||||||
if (message.role === 'user' && event.transcription) {
|
|
||||||
const displayParts = [];
|
|
||||||
if (text) displayParts.push(text);
|
|
||||||
if (event.transcription) displayParts.push(event.transcription);
|
|
||||||
const displayContent = displayParts.join('\n\n');
|
|
||||||
|
|
||||||
let historyContent;
|
|
||||||
if (typeof userMessage.content === 'string') {
|
|
||||||
historyContent = displayContent;
|
|
||||||
} else {
|
|
||||||
historyContent = [...userMessage.content];
|
|
||||||
if (event.transcription) {
|
|
||||||
historyContent.push({
|
|
||||||
type: 'text',
|
|
||||||
text: event.transcription,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const audioUserMessage = {
|
|
||||||
id: userMessage.id,
|
|
||||||
role: 'user',
|
|
||||||
content: historyContent,
|
|
||||||
meta: { language: event.detected_language },
|
|
||||||
};
|
|
||||||
|
|
||||||
this.#appendHistory([audioUserMessage]);
|
|
||||||
this.#renderMessages([audioUserMessage]);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Stash assistant messages with tool calls; render once all
|
// Stash assistant messages with tool calls; render once all
|
||||||
// tool results have arrived so the output is visible in the UI.
|
// tool results have arrived so the output is visible in the UI.
|
||||||
@@ -1488,6 +1689,7 @@ class Odidere {
|
|||||||
this.$chatLoading.hidden = !loading;
|
this.$chatLoading.hidden = !loading;
|
||||||
// Keep the send button enabled during loading so it can act as a stop button.
|
// Keep the send button enabled during loading so it can act as a stop button.
|
||||||
this.$ptt.disabled = loading;
|
this.$ptt.disabled = loading;
|
||||||
|
this.$transcribe.disabled = loading;
|
||||||
// Clear any pending stop state when loading ends.
|
// Clear any pending stop state when loading ends.
|
||||||
if (!loading && this.isStopPending) {
|
if (!loading && this.isStopPending) {
|
||||||
this.isStopPending = false;
|
this.isStopPending = false;
|
||||||
|
|||||||
@@ -19,6 +19,15 @@
|
|||||||
</button>
|
</button>
|
||||||
<input type="file" id="file-input" multiple hidden />
|
<input type="file" id="file-input" multiple hidden />
|
||||||
<div class="compose__actions-right">
|
<div class="compose__actions-right">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="compose__action-btn"
|
||||||
|
id="transcribe"
|
||||||
|
aria-label="Transcribe to text"
|
||||||
|
title="Record and transcribe (does not send)"
|
||||||
|
>
|
||||||
|
<svg class="icon"><use href="/static/icons.svg#dictate"></use></svg>
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="compose__action-btn compose__action-btn--record"
|
class="compose__action-btn compose__action-btn--record"
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
<meta name="viewport"
|
<meta name="viewport"
|
||||||
content="width=device-width, initial-scale=1.0, shrink-to-fit=no, interactive-widget=resizes-content">
|
content="width=device-width, initial-scale=1.0, shrink-to-fit=no, interactive-widget=resizes-content">
|
||||||
<meta name="tts-url" content="{{ .TTSURL }}">
|
<meta name="tts-url" content="{{ .TTSURL }}">
|
||||||
|
<meta name="stt-url" content="{{ .STTURL }}">
|
||||||
<meta name="tts-default-voice" content="{{ .TTSDefaultVoice }}">
|
<meta name="tts-default-voice" content="{{ .TTSDefaultVoice }}">
|
||||||
<title>Odidere</title>
|
<title>Odidere</title>
|
||||||
<link rel="stylesheet" href="/static/main.css">
|
<link rel="stylesheet" href="/static/main.css">
|
||||||
|
|||||||
@@ -1,171 +0,0 @@
|
|||||||
// Package stt provides a client for whisper.cpp speech-to-text.
|
|
||||||
package stt
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bytes"
|
|
||||||
"context"
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
"io"
|
|
||||||
"mime/multipart"
|
|
||||||
"net/http"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"log/slog"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Config holds the configuration for a STT client.
|
|
||||||
type Config struct {
|
|
||||||
// URL is the base URL of the whisper-server.
|
|
||||||
// For example, "http://localhost:8178".
|
|
||||||
URL string `yaml:"url"`
|
|
||||||
// Timeout is the maximum duration for a transcription request.
|
|
||||||
// Defaults to 30s.
|
|
||||||
Timeout string `yaml:"timeout"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validate checks that required configuration values are present and valid.
|
|
||||||
func (cfg Config) Validate() error {
|
|
||||||
if cfg.URL == "" {
|
|
||||||
return fmt.Errorf("missing URL")
|
|
||||||
}
|
|
||||||
if cfg.Timeout != "" {
|
|
||||||
if _, err := time.ParseDuration(cfg.Timeout); err != nil {
|
|
||||||
return fmt.Errorf("invalid timeout: %w", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Client wraps an HTTP client for whisper-server requests.
|
|
||||||
type Client struct {
|
|
||||||
client *http.Client
|
|
||||||
log *slog.Logger
|
|
||||||
url string
|
|
||||||
timeout time.Duration
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewClient creates a new STT client with the provided configuration.
|
|
||||||
func NewClient(cfg Config, log *slog.Logger) (*Client, error) {
|
|
||||||
if err := cfg.Validate(); err != nil {
|
|
||||||
return nil, fmt.Errorf("invalid config: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
var timeout = 30 * time.Second
|
|
||||||
if cfg.Timeout != "" {
|
|
||||||
d, err := time.ParseDuration(cfg.Timeout)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("parse timeout: %v", err)
|
|
||||||
}
|
|
||||||
timeout = d
|
|
||||||
}
|
|
||||||
|
|
||||||
return &Client{
|
|
||||||
client: &http.Client{Timeout: timeout},
|
|
||||||
log: log,
|
|
||||||
url: cfg.URL,
|
|
||||||
timeout: timeout,
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Output represents the JSON response from whisper.cpp (verbose_json format).
|
|
||||||
type Output struct {
|
|
||||||
Task string `json:"task,omitempty"`
|
|
||||||
Language string `json:"language,omitempty"`
|
|
||||||
Duration float64 `json:"duration,omitempty"`
|
|
||||||
Text string `json:"text"`
|
|
||||||
DetectedLanguage string `json:"detected_language,omitempty"`
|
|
||||||
DetectedLanguageProbability float64 `json:"detected_language_probability,omitempty"`
|
|
||||||
Segments []Segment `json:"segments,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// Segment represents a transcription segment with timing and confidence
|
|
||||||
// information.
|
|
||||||
type Segment struct {
|
|
||||||
ID int `json:"id"`
|
|
||||||
Start float64 `json:"start"`
|
|
||||||
End float64 `json:"end"`
|
|
||||||
Text string `json:"text"`
|
|
||||||
Temperature float64 `json:"temperature,omitempty"`
|
|
||||||
AvgLogProb float64 `json:"avg_logprob,omitempty"`
|
|
||||||
NoSpeechProb float64 `json:"no_speech_prob,omitempty"`
|
|
||||||
Tokens []int `json:"tokens,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// Transcribe sends audio to whisper.cpp and returns the transcription output.
|
|
||||||
func (c *Client) Transcribe(ctx context.Context, audio []byte) (*Output, error) {
|
|
||||||
ctx, cancel := context.WithTimeout(ctx, c.timeout)
|
|
||||||
defer cancel()
|
|
||||||
|
|
||||||
// Build multipart form.
|
|
||||||
var buf bytes.Buffer
|
|
||||||
w := multipart.NewWriter(&buf)
|
|
||||||
|
|
||||||
fw, err := w.CreateFormFile("file", "audio.webm")
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("create form file: %w", err)
|
|
||||||
}
|
|
||||||
if _, err := fw.Write(audio); err != nil {
|
|
||||||
return nil, fmt.Errorf("write audio: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Request verbose JSON response format to get full output including
|
|
||||||
// detected_language.
|
|
||||||
if err := w.WriteField("response_format", "verbose_json"); err != nil {
|
|
||||||
return nil, fmt.Errorf("write response_format: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := w.Close(); err != nil {
|
|
||||||
return nil, fmt.Errorf("close multipart: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
req, err := http.NewRequestWithContext(
|
|
||||||
ctx,
|
|
||||||
http.MethodPost,
|
|
||||||
c.url+"/inference",
|
|
||||||
&buf,
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("create request: %w", err)
|
|
||||||
}
|
|
||||||
req.Header.Set("Content-Type", w.FormDataContentType())
|
|
||||||
|
|
||||||
res, err := c.client.Do(req)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("send request: %w", err)
|
|
||||||
}
|
|
||||||
defer res.Body.Close()
|
|
||||||
|
|
||||||
if res.StatusCode != http.StatusOK {
|
|
||||||
body, err := io.ReadAll(res.Body)
|
|
||||||
if err != nil {
|
|
||||||
c.log.ErrorContext(
|
|
||||||
ctx,
|
|
||||||
"failed to read response body",
|
|
||||||
slog.Any("error", err),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil, fmt.Errorf(
|
|
||||||
"whisper error %d: %s", res.StatusCode, body,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
body, err := io.ReadAll(res.Body)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("read response: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
var output Output
|
|
||||||
if err := json.Unmarshal(body, &output); err != nil {
|
|
||||||
return nil, fmt.Errorf("parse response: %w", err)
|
|
||||||
}
|
|
||||||
c.log.DebugContext(ctx, "stt response",
|
|
||||||
slog.String("text", output.Text),
|
|
||||||
slog.String("language", output.Language),
|
|
||||||
slog.String("detected_language", output.DetectedLanguage),
|
|
||||||
slog.Float64("duration", output.Duration),
|
|
||||||
)
|
|
||||||
|
|
||||||
return &output, nil
|
|
||||||
}
|
|
||||||
@@ -1,106 +0,0 @@
|
|||||||
package stt
|
|
||||||
|
|
||||||
import (
|
|
||||||
"testing"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestConfigValidate(t *testing.T) {
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
cfg Config
|
|
||||||
wantErr bool
|
|
||||||
}{
|
|
||||||
{
|
|
||||||
name: "empty config",
|
|
||||||
cfg: Config{},
|
|
||||||
wantErr: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "missing URL",
|
|
||||||
cfg: Config{
|
|
||||||
Timeout: "30s",
|
|
||||||
},
|
|
||||||
wantErr: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "invalid timeout",
|
|
||||||
cfg: Config{
|
|
||||||
URL: "http://localhost:8178",
|
|
||||||
Timeout: "not-a-duration",
|
|
||||||
},
|
|
||||||
wantErr: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "valid minimal config",
|
|
||||||
cfg: Config{
|
|
||||||
URL: "http://localhost:8178",
|
|
||||||
},
|
|
||||||
wantErr: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "valid config with timeout",
|
|
||||||
cfg: Config{
|
|
||||||
URL: "http://localhost:8178",
|
|
||||||
Timeout: "60s",
|
|
||||||
},
|
|
||||||
wantErr: false,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tt := range tests {
|
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
|
||||||
err := tt.cfg.Validate()
|
|
||||||
if (err != nil) != tt.wantErr {
|
|
||||||
t.Errorf(
|
|
||||||
"Validate() error = %v, wantErr %v",
|
|
||||||
err, tt.wantErr,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestNewClient(t *testing.T) {
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
cfg Config
|
|
||||||
wantErr bool
|
|
||||||
}{
|
|
||||||
{
|
|
||||||
name: "invalid config",
|
|
||||||
cfg: Config{},
|
|
||||||
wantErr: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "valid config without timeout",
|
|
||||||
cfg: Config{
|
|
||||||
URL: "http://localhost:8178",
|
|
||||||
},
|
|
||||||
wantErr: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "valid config with timeout",
|
|
||||||
cfg: Config{
|
|
||||||
URL: "http://localhost:8178",
|
|
||||||
Timeout: "45s",
|
|
||||||
},
|
|
||||||
wantErr: false,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tt := range tests {
|
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
|
||||||
client, err := NewClient(tt.cfg, nil)
|
|
||||||
if (err != nil) != tt.wantErr {
|
|
||||||
t.Errorf(
|
|
||||||
"NewClient() error = %v, wantErr %v",
|
|
||||||
err, tt.wantErr,
|
|
||||||
)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if !tt.wantErr && client == nil {
|
|
||||||
t.Error("NewClient() returned nil client")
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user