Refactor TTS architecture
This commit is contained in:
@@ -11,11 +11,41 @@ import (
|
|||||||
"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/stt"
|
||||||
"code.chimeric.al/chimerical/odidere/internal/tool"
|
"code.chimeric.al/chimerical/odidere/internal/tool"
|
||||||
"code.chimeric.al/chimerical/odidere/internal/tts"
|
|
||||||
|
|
||||||
"gopkg.in/yaml.v3"
|
"gopkg.in/yaml.v3"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// TTS holds the configuration for TTS.
|
||||||
|
type TTS struct {
|
||||||
|
// URL is the base URL of kokoro-fastapi.
|
||||||
|
// For example, "http://localhost:8880".
|
||||||
|
URL string `yaml:"url"`
|
||||||
|
// Voice is the default voice ID to use (e.g., "af_heart").
|
||||||
|
Voice string `yaml:"voice"`
|
||||||
|
// Timeout is the maximum duration for a synthesis request.
|
||||||
|
// Defaults to 60s if empty.
|
||||||
|
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 {
|
||||||
|
if cfg.URL == "" {
|
||||||
|
return fmt.Errorf("missing URL")
|
||||||
|
}
|
||||||
|
if cfg.Voice == "" {
|
||||||
|
return fmt.Errorf("missing voice")
|
||||||
|
}
|
||||||
|
if cfg.Timeout != "" {
|
||||||
|
if _, err := time.ParseDuration(cfg.Timeout); err != nil {
|
||||||
|
return fmt.Errorf("invalid timeout: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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.
|
||||||
@@ -36,7 +66,7 @@ type Config struct {
|
|||||||
// Tools defines external commands available to the LLM.
|
// Tools defines external commands available to the LLM.
|
||||||
Tools []tool.Tool `yaml:"tools"`
|
Tools []tool.Tool `yaml:"tools"`
|
||||||
// TTS configures the text-to-speech client.
|
// TTS configures the text-to-speech client.
|
||||||
TTS tts.Config `yaml:"tts"`
|
TTS TTS `yaml:"tts"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// ApplyDefaults sets default values for optional configuration fields.
|
// ApplyDefaults sets default values for optional configuration fields.
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
// Package service orchestrates the odidere voice assistant server.
|
// 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.
|
// graceful shutdown.
|
||||||
package service
|
package service
|
||||||
|
|
||||||
@@ -26,7 +26,6 @@ import (
|
|||||||
"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/stt"
|
||||||
"code.chimeric.al/chimerical/odidere/internal/tool"
|
"code.chimeric.al/chimerical/odidere/internal/tool"
|
||||||
"code.chimeric.al/chimerical/odidere/internal/tts"
|
|
||||||
|
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
"github.com/robfig/cron/v3"
|
"github.com/robfig/cron/v3"
|
||||||
@@ -42,6 +41,19 @@ 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
|
||||||
|
|
||||||
@@ -58,7 +70,6 @@ type Service struct {
|
|||||||
stt *stt.Client
|
stt *stt.Client
|
||||||
tmpl *template.Template
|
tmpl *template.Template
|
||||||
tools *tool.Registry
|
tools *tool.Registry
|
||||||
tts *tts.Client
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// New creates a Service from the provided configuration.
|
// 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
|
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.
|
// Convert job configs to jobs.
|
||||||
jobs := make([]*job.Job, 0, len(cfg.Jobs))
|
jobs := make([]*job.Job, 0, len(cfg.Jobs))
|
||||||
for _, jc := range 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", svc.voice)
|
||||||
svc.mux.HandleFunc("POST /v1/chat/voice/stream", svc.voiceStream)
|
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.mux.HandleFunc("GET /v1/models", svc.models)
|
||||||
|
|
||||||
svc.server = &http.Server{
|
svc.server = &http.Server{
|
||||||
@@ -236,7 +239,7 @@ func (svc *Service) Run(ctx context.Context) error {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
slog.Group(
|
slog.Group(
|
||||||
"tts",
|
"tts_url",
|
||||||
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),
|
||||||
),
|
),
|
||||||
@@ -352,6 +355,19 @@ 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()
|
||||||
@@ -364,7 +380,13 @@ func (svc *Service) home(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||||
if err := svc.tmpl.ExecuteTemplate(
|
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 {
|
); err != nil {
|
||||||
log.ErrorContext(
|
log.ErrorContext(
|
||||||
ctx, "template error", slog.Any("error", err),
|
ctx, "template error", slog.Any("error", err),
|
||||||
@@ -398,8 +420,6 @@ 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 {
|
||||||
// Audio is the base64-encoded WAV audio response.
|
|
||||||
Audio string `json:"audio,omitempty"`
|
|
||||||
// DetectedLanguage is the language detected in the input speech.
|
// DetectedLanguage is the language detected in the input speech.
|
||||||
DetectedLanguage string `json:"detected_language,omitempty"`
|
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,
|
||||||
@@ -565,7 +585,7 @@ func (svc *Service) voice(w http.ResponseWriter, r *http.Request) {
|
|||||||
// Determine voice to use.
|
// Determine voice to use.
|
||||||
var voice = req.Voice
|
var voice = req.Voice
|
||||||
if req.Voice == "" && detectedLang != "" {
|
if req.Voice == "" && detectedLang != "" {
|
||||||
if autoVoice := svc.tts.SelectVoice(
|
if autoVoice := svc.selectVoice(
|
||||||
detectedLang,
|
detectedLang,
|
||||||
); autoVoice != "" {
|
); autoVoice != "" {
|
||||||
voice = 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")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
if err := json.NewEncoder(w).Encode(Response{
|
if err := json.NewEncoder(w).Encode(Response{
|
||||||
Audio: base64.StdEncoding.EncodeToString(audio),
|
|
||||||
DetectedLanguage: detectedLang,
|
DetectedLanguage: detectedLang,
|
||||||
Messages: msgs,
|
Messages: msgs,
|
||||||
Model: model,
|
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.
|
// StreamMessage is the SSE event payload for the streaming voice endpoint.
|
||||||
type StreamMessage struct {
|
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 is the language detected in the input speech.
|
||||||
DetectedLanguage string `json:"detected_language,omitempty"`
|
DetectedLanguage string `json:"detected_language,omitempty"`
|
||||||
// Error is an error message, if any.
|
// 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.
|
// Determine voice to use.
|
||||||
var voice = req.Voice
|
var voice = req.Voice
|
||||||
if req.Voice == "" && detectedLang != "" {
|
if req.Voice == "" && detectedLang != "" {
|
||||||
if autoVoice := svc.tts.SelectVoice(
|
if autoVoice := svc.selectVoice(
|
||||||
detectedLang,
|
detectedLang,
|
||||||
); autoVoice != "" {
|
); autoVoice != "" {
|
||||||
voice = autoVoice
|
voice = autoVoice
|
||||||
@@ -834,20 +843,6 @@ func (svc *Service) voiceStream(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
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.Model = model
|
||||||
last.Voice = voice
|
last.Voice = voice
|
||||||
send(last)
|
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),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -144,6 +144,13 @@ body {
|
|||||||
padding: var(--s-2) var(--s-1);
|
padding: var(--s-2) var(--s-1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.collapsible__summary::before {
|
||||||
|
content: "▶";
|
||||||
|
font-size: 0.625em;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
transition: transform 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
.collapsible[open] .collapsible__summary::before {
|
.collapsible[open] .collapsible__summary::before {
|
||||||
transform: rotate(90deg);
|
transform: rotate(90deg);
|
||||||
}
|
}
|
||||||
@@ -204,13 +211,6 @@ body {
|
|||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.collapsible__summary::before {
|
|
||||||
content: "▶";
|
|
||||||
font-size: 0.625em;
|
|
||||||
color: var(--color-text-muted);
|
|
||||||
transition: transform 0.15s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.collapsible__summary .icon {
|
.collapsible__summary .icon {
|
||||||
width: var(--icon-size);
|
width: var(--icon-size);
|
||||||
height: var(--icon-size);
|
height: var(--icon-size);
|
||||||
@@ -530,10 +530,24 @@ body {
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.message__icon {
|
||||||
|
width: var(--action-icon-size);
|
||||||
|
height: var(--action-icon-size);
|
||||||
|
flex-shrink: 0;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
.message--assistant .message__icon {
|
.message--assistant .message__icon {
|
||||||
color: var(--color-primary);
|
color: var(--color-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.message__debug {
|
||||||
|
display: none;
|
||||||
|
padding: var(--s-2) var(--s-1);
|
||||||
|
border-top: 1px dotted var(--color-border-light);
|
||||||
|
background: var(--color-surface);
|
||||||
|
}
|
||||||
|
|
||||||
.message--debug-open .message__debug {
|
.message--debug-open .message__debug {
|
||||||
display: block;
|
display: block;
|
||||||
}
|
}
|
||||||
@@ -579,6 +593,10 @@ body {
|
|||||||
color: var(--color-primary);
|
color: var(--color-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.message__action-btn--play.playing {
|
||||||
|
color: var(--color-red);
|
||||||
|
}
|
||||||
|
|
||||||
@media (hover: hover) {
|
@media (hover: hover) {
|
||||||
.message__action-btn--delete:hover {
|
.message__action-btn--delete:hover {
|
||||||
color: var(--color-red);
|
color: var(--color-red);
|
||||||
@@ -637,13 +655,6 @@ body {
|
|||||||
border-radius: var(--radius);
|
border-radius: var(--radius);
|
||||||
}
|
}
|
||||||
|
|
||||||
.message__debug {
|
|
||||||
display: none;
|
|
||||||
padding: var(--s-2) var(--s-1);
|
|
||||||
border-top: 1px dotted var(--color-border-light);
|
|
||||||
background: var(--color-surface);
|
|
||||||
}
|
|
||||||
|
|
||||||
.message__debug-list {
|
.message__debug-list {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: auto 1fr;
|
grid-template-columns: auto 1fr;
|
||||||
@@ -663,13 +674,6 @@ body {
|
|||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.message__icon {
|
|
||||||
width: var(--action-icon-size);
|
|
||||||
height: var(--action-icon-size);
|
|
||||||
flex-shrink: 0;
|
|
||||||
color: var(--color-text-muted);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ==================== */
|
/* ==================== */
|
||||||
/* Settings Modal */
|
/* Settings Modal */
|
||||||
/* ==================== */
|
/* ==================== */
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ const MODELS_ENDPOINT = '/v1/models';
|
|||||||
const MODEL_KEY = 'odidere_model';
|
const MODEL_KEY = 'odidere_model';
|
||||||
const STORAGE_KEY = 'odidere_history';
|
const STORAGE_KEY = 'odidere_history';
|
||||||
const SYSTEM_MESSAGE_KEY = 'odidere_system_message';
|
const SYSTEM_MESSAGE_KEY = 'odidere_system_message';
|
||||||
const VOICES_ENDPOINT = '/v1/voices';
|
|
||||||
const VOICE_KEY = 'odidere_voice';
|
const VOICE_KEY = 'odidere_voice';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -38,6 +37,14 @@ class Odidere {
|
|||||||
this.leafId = null;
|
this.leafId = null;
|
||||||
this.rootId = null;
|
this.rootId = null;
|
||||||
|
|
||||||
|
// TTS state
|
||||||
|
this.ttsUrl = document.querySelector('meta[name="tts-url"]')?.content || '';
|
||||||
|
this.ttsDefaultVoice =
|
||||||
|
document.querySelector('meta[name="tts-default-voice"]')?.content || '';
|
||||||
|
this.playQueue = [];
|
||||||
|
this.isPlaying = false;
|
||||||
|
this.currentMessageId = null;
|
||||||
|
|
||||||
// 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;
|
||||||
@@ -92,6 +99,7 @@ class Odidere {
|
|||||||
track.stop();
|
track.stop();
|
||||||
}
|
}
|
||||||
this.#stopCurrentAudio();
|
this.#stopCurrentAudio();
|
||||||
|
this.#stopTTS();
|
||||||
this.currentController?.abort();
|
this.currentController?.abort();
|
||||||
this.#releaseWakeLock();
|
this.#releaseWakeLock();
|
||||||
}
|
}
|
||||||
@@ -102,6 +110,7 @@ class Odidere {
|
|||||||
*/
|
*/
|
||||||
reset() {
|
reset() {
|
||||||
this.#stopCurrentAudio();
|
this.#stopCurrentAudio();
|
||||||
|
this.#stopTTS();
|
||||||
|
|
||||||
if (this.currentController) {
|
if (this.currentController) {
|
||||||
this.currentController.abort();
|
this.currentController.abort();
|
||||||
@@ -234,7 +243,7 @@ class Odidere {
|
|||||||
|
|
||||||
// ====================
|
// ====================
|
||||||
// CONVERSATION TREE
|
// CONVERSATION TREE
|
||||||
// =====================
|
// ====================
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* #loadHistory loads chat history from localStorage and renders it.
|
* #loadHistory loads chat history from localStorage and renders it.
|
||||||
@@ -466,7 +475,7 @@ class Odidere {
|
|||||||
let current = startId;
|
let current = startId;
|
||||||
while (current) {
|
while (current) {
|
||||||
const msg = this.messagesMap.get(current);
|
const msg = this.messagesMap.get(current);
|
||||||
if (!msg || !msg.children || msg.children.length === 0) break;
|
if (!msg?.children || msg.children.length === 0) break;
|
||||||
// Follow the first child. With branching, this picks the first branch.
|
// Follow the first child. With branching, this picks the first branch.
|
||||||
current = msg.children[0];
|
current = msg.children[0];
|
||||||
}
|
}
|
||||||
@@ -811,6 +820,7 @@ class Odidere {
|
|||||||
*/
|
*/
|
||||||
#stopConversation() {
|
#stopConversation() {
|
||||||
this.#stopCurrentAudio();
|
this.#stopCurrentAudio();
|
||||||
|
this.#stopTTS();
|
||||||
|
|
||||||
if (this.currentController) {
|
if (this.currentController) {
|
||||||
this.currentController.abort();
|
this.currentController.abort();
|
||||||
@@ -833,6 +843,7 @@ class Odidere {
|
|||||||
if (this.isProcessing) return;
|
if (this.isProcessing) return;
|
||||||
|
|
||||||
this.#stopCurrentAudio();
|
this.#stopCurrentAudio();
|
||||||
|
this.#stopTTS();
|
||||||
|
|
||||||
// Initialize MediaRecorder on first use
|
// Initialize MediaRecorder on first use
|
||||||
if (!this.mediaRecorder) {
|
if (!this.mediaRecorder) {
|
||||||
@@ -911,20 +922,92 @@ class Odidere {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ====================
|
// ====================
|
||||||
// AUDIO PLAYBACK
|
// TTS PLAYBACK
|
||||||
// ====================
|
// ====================
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* #playAudio decodes and plays base64-encoded WAV audio.
|
* #enqueueTTS adds a message to the TTS playback queue and starts
|
||||||
* @param {string} base64Audio
|
* playback if not already playing.
|
||||||
|
* @param {string} text
|
||||||
|
* @param {string} voice
|
||||||
|
* @param {string} messageId
|
||||||
*/
|
*/
|
||||||
async #playAudio(base64Audio) {
|
#enqueueTTS(text, voice, messageId) {
|
||||||
|
if (!text || !this.ttsUrl) return;
|
||||||
|
this.playQueue.push({ text, voice, messageId });
|
||||||
|
if (!this.isPlaying) {
|
||||||
|
this.#processQueue();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* #processQueue pops the next item from the TTS queue and synthesizes it.
|
||||||
|
*/
|
||||||
|
async #processQueue() {
|
||||||
|
if (this.playQueue.length === 0) {
|
||||||
|
this.isPlaying = false;
|
||||||
|
this.currentMessageId = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.isPlaying = true;
|
||||||
|
const item = this.playQueue.shift();
|
||||||
|
this.currentMessageId = item.messageId;
|
||||||
|
|
||||||
|
// Update the play button on the message to show stop state.
|
||||||
|
this.#setPlayButtonState(item.messageId, true);
|
||||||
|
|
||||||
|
await this.#synthesizeAndPlay(item.text, item.voice, item.messageId);
|
||||||
|
|
||||||
|
// Reset this message's button back to play state before moving on.
|
||||||
|
this.#setPlayButtonState(item.messageId, false);
|
||||||
|
|
||||||
|
// Move to next item in queue.
|
||||||
|
this.#processQueue();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* #stopTTS stops current audio playback, clears the queue, and resets UI.
|
||||||
|
*/
|
||||||
|
#stopTTS() {
|
||||||
|
this.#stopCurrentAudio();
|
||||||
|
this.playQueue = [];
|
||||||
|
this.isPlaying = false;
|
||||||
|
|
||||||
|
// Reset all play buttons.
|
||||||
|
if (this.currentMessageId) {
|
||||||
|
this.#setPlayButtonState(this.currentMessageId, false);
|
||||||
|
}
|
||||||
|
this.currentMessageId = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* #synthesizeAndPlay calls kokoro-fastapi to synthesize text and plays
|
||||||
|
* the resulting audio. On error, logs and returns without blocking the queue.
|
||||||
|
* @param {string} text
|
||||||
|
* @param {string} voice
|
||||||
|
* @param {string} _messageId
|
||||||
|
*/
|
||||||
|
async #synthesizeAndPlay(text, voice, _messageId) {
|
||||||
try {
|
try {
|
||||||
this.#stopCurrentAudio();
|
this.#stopCurrentAudio();
|
||||||
|
|
||||||
const audioData = Uint8Array.from(atob(base64Audio), (c) =>
|
const res = await fetch(`${this.ttsUrl}/v1/audio/speech`, {
|
||||||
c.charCodeAt(0),
|
method: 'POST',
|
||||||
);
|
headers: { 'Content-Type': 'application/json' },
|
||||||
const audioBlob = new Blob([audioData], { type: 'audio/wav' });
|
body: JSON.stringify({
|
||||||
|
model: 'tts-1',
|
||||||
|
input: text,
|
||||||
|
voice: voice || this.$voice.value || this.ttsDefaultVoice,
|
||||||
|
response_format: 'wav',
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error(`TTS error ${res.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const audioBlob = await res.blob();
|
||||||
const audioUrl = URL.createObjectURL(audioBlob);
|
const audioUrl = URL.createObjectURL(audioBlob);
|
||||||
|
|
||||||
this.currentAudioUrl = audioUrl;
|
this.currentAudioUrl = audioUrl;
|
||||||
@@ -951,11 +1034,59 @@ class Odidere {
|
|||||||
this.currentAudio.play().catch(reject);
|
this.currentAudio.play().catch(reject);
|
||||||
});
|
});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('failed to play audio:', e);
|
console.error('TTS synthesis failed:', e);
|
||||||
this.#cleanupAudio();
|
this.#cleanupAudio();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* #setPlayButtonState updates the play/stop button icon and state for a
|
||||||
|
* given message.
|
||||||
|
* @param {string} messageId
|
||||||
|
* @param {boolean} playing
|
||||||
|
*/
|
||||||
|
#setPlayButtonState(messageId, playing) {
|
||||||
|
const $msg = this.$chat.querySelector(`[data-id="${messageId}"]`);
|
||||||
|
if (!$msg) return;
|
||||||
|
const $btn = $msg.querySelector('[data-action="play"]');
|
||||||
|
if (!$btn) return;
|
||||||
|
|
||||||
|
if (playing) {
|
||||||
|
$btn.classList.add('playing');
|
||||||
|
$btn.innerHTML = `<svg class="icon"><use href="${ICONS_URL}#stop"></use></svg>`;
|
||||||
|
$btn.setAttribute('aria-label', 'Stop');
|
||||||
|
} else {
|
||||||
|
$btn.classList.remove('playing');
|
||||||
|
$btn.innerHTML = `<svg class="icon"><use href="${ICONS_URL}#volume"></use></svg>`;
|
||||||
|
$btn.setAttribute('aria-label', 'Play');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* #handlePlayClick handles the play/stop button on an assistant message.
|
||||||
|
* If not playing: stop any current audio, enqueue this message, start playing.
|
||||||
|
* If currently playing this message: stop playback, clear remaining queue, reset.
|
||||||
|
* @param {MouseEvent} event
|
||||||
|
* @param {string} messageId
|
||||||
|
* @param {string} text
|
||||||
|
* @param {string} voice
|
||||||
|
*/
|
||||||
|
#handlePlayClick(event, messageId, text, voice) {
|
||||||
|
event.stopPropagation();
|
||||||
|
|
||||||
|
if (this.currentMessageId === messageId) {
|
||||||
|
// Currently playing this message: stop everything.
|
||||||
|
this.#stopTTS();
|
||||||
|
} else {
|
||||||
|
// Not playing this message: stop current playback, enqueue this one.
|
||||||
|
this.#stopTTS();
|
||||||
|
this.#enqueueTTS(text, voice, messageId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ====================
|
||||||
|
// AUDIO PLAYBACK
|
||||||
|
// ====================
|
||||||
/**
|
/**
|
||||||
* #stopCurrentAudio stops and cleans up any playing audio.
|
* #stopCurrentAudio stops and cleans up any playing audio.
|
||||||
*/
|
*/
|
||||||
@@ -1002,13 +1133,17 @@ class Odidere {
|
|||||||
*/
|
*/
|
||||||
async #fetchVoices() {
|
async #fetchVoices() {
|
||||||
try {
|
try {
|
||||||
const res = await fetch(VOICES_ENDPOINT);
|
const res = await fetch(`${this.ttsUrl}/v1/audio/voices`);
|
||||||
if (!res.ok) throw new Error(`${res.status}`);
|
if (!res.ok) throw new Error(`${res.status}`);
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
|
|
||||||
// Filter out legacy v0 voices.
|
// Filter out legacy v0 voices.
|
||||||
const filtered = data.voices.filter((v) => !v.includes('_v0'));
|
const filtered = data.voices.filter((v) => {
|
||||||
this.#populateVoiceSelect(filtered);
|
const id = typeof v === 'string' ? v : v.id;
|
||||||
|
return !id.includes('_v0');
|
||||||
|
});
|
||||||
|
const voiceIds = filtered.map((v) => (typeof v === 'string' ? v : v.id));
|
||||||
|
this.#populateVoiceSelect(voiceIds);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('failed to fetch voices:', e);
|
console.error('failed to fetch voices:', e);
|
||||||
this.$voice.innerHTML = '';
|
this.$voice.innerHTML = '';
|
||||||
@@ -1034,6 +1169,7 @@ class Odidere {
|
|||||||
if (this.isProcessing) return;
|
if (this.isProcessing) return;
|
||||||
|
|
||||||
this.#stopCurrentAudio();
|
this.#stopCurrentAudio();
|
||||||
|
this.#stopTTS();
|
||||||
this.$textInput.value = '';
|
this.$textInput.value = '';
|
||||||
this.$textInput.style.height = 'auto';
|
this.$textInput.style.height = 'auto';
|
||||||
|
|
||||||
@@ -1050,7 +1186,7 @@ class Odidere {
|
|||||||
*
|
*
|
||||||
* Messages are rendered incrementally as SSE events arrive:
|
* Messages are rendered incrementally as SSE events arrive:
|
||||||
* - user (transcription), assistant (tool calls), tool (results),
|
* - user (transcription), assistant (tool calls), tool (results),
|
||||||
* and a final assistant message with synthesized audio.
|
* and a final assistant message with content.
|
||||||
*
|
*
|
||||||
* @param {Object} options
|
* @param {Object} options
|
||||||
* @param {Blob} [options.audio] - Recorded audio blob
|
* @param {Blob} [options.audio] - Recorded audio blob
|
||||||
@@ -1234,10 +1370,13 @@ class Odidere {
|
|||||||
const appended = this.#appendHistory([message], meta);
|
const appended = this.#appendHistory([message], meta);
|
||||||
this.#renderMessages(appended, meta);
|
this.#renderMessages(appended, meta);
|
||||||
|
|
||||||
// For the final assistant message with audio, play it.
|
// Enqueue TTS for the final assistant message with content.
|
||||||
if (event.audio) {
|
if (message.content) {
|
||||||
this.#setLoadingState(false);
|
this.#enqueueTTS(
|
||||||
await this.#playAudio(event.audio);
|
message.content,
|
||||||
|
event.voice || this.$voice.value || '',
|
||||||
|
message.id,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1766,6 +1905,17 @@ class Odidere {
|
|||||||
$inspect.setAttribute('aria-expanded', String(isOpen));
|
$inspect.setAttribute('aria-expanded', String(isOpen));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Play button: only for messages with non-empty content.
|
||||||
|
const $play = $msg.querySelector('[data-action="play"]');
|
||||||
|
if (message.content) {
|
||||||
|
const voice = meta?.voice || this.$voice.value || '';
|
||||||
|
$play.addEventListener('click', (e) =>
|
||||||
|
this.#handlePlayClick(e, message.id, message.content, voice),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
$play.remove();
|
||||||
|
}
|
||||||
|
|
||||||
// Assemble text from all available content.
|
// Assemble text from all available content.
|
||||||
const copyParts = [];
|
const copyParts = [];
|
||||||
if (message.reasoning_content) copyParts.push(message.reasoning_content);
|
if (message.reasoning_content) copyParts.push(message.reasoning_content);
|
||||||
|
|||||||
@@ -4,6 +4,8 @@
|
|||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
<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-default-voice" content="{{ .TTSDefaultVoice }}">
|
||||||
<title>Odidere</title>
|
<title>Odidere</title>
|
||||||
<link rel="stylesheet" href="/static/main.css">
|
<link rel="stylesheet" href="/static/main.css">
|
||||||
<script defer src="/static/main.js"></script>
|
<script defer src="/static/main.js"></script>
|
||||||
|
|||||||
@@ -47,6 +47,14 @@
|
|||||||
<div class="message__actions">
|
<div class="message__actions">
|
||||||
<svg class="message__icon" aria-hidden="true"><use href="/static/icons.svg#assistant"></use></svg>
|
<svg class="message__icon" aria-hidden="true"><use href="/static/icons.svg#assistant"></use></svg>
|
||||||
<div class="message__actions-buttons">
|
<div class="message__actions-buttons">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="message__action-btn message__action-btn--play"
|
||||||
|
data-action="play"
|
||||||
|
aria-label="Play"
|
||||||
|
>
|
||||||
|
<svg class="icon"><use href="/static/icons.svg#volume"></use></svg>
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="message__action-btn message__action-btn--delete"
|
class="message__action-btn message__action-btn--delete"
|
||||||
|
|||||||
@@ -1,208 +0,0 @@
|
|||||||
// Package tts provides a client for kokoro-fastapi text-to-speech.
|
|
||||||
package tts
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bytes"
|
|
||||||
"context"
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
"io"
|
|
||||||
"log/slog"
|
|
||||||
"net/http"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Config holds the configuration for a TTS client.
|
|
||||||
type Config struct {
|
|
||||||
// URL is the base URL of kokoro-fastapi.
|
|
||||||
// For example, "http://localhost:8880".
|
|
||||||
URL string `yaml:"url"`
|
|
||||||
// Voice is the default voice ID to use (e.g., "af_heart").
|
|
||||||
Voice string `yaml:"voice"`
|
|
||||||
// Timeout is the maximum duration for a synthesis request.
|
|
||||||
// Defaults to 60s if empty.
|
|
||||||
Timeout string `yaml:"timeout"`
|
|
||||||
// VoiceMap maps whisper language names to voice IDs.
|
|
||||||
// Used by SelectVoice to auto-select voices based on detected
|
|
||||||
// language.
|
|
||||||
// If empty, defaultVoices is used.
|
|
||||||
VoiceMap map[string]string `yaml:"voice_map"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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.Voice == "" {
|
|
||||||
return fmt.Errorf("missing voice")
|
|
||||||
}
|
|
||||||
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 kokoro-fastapi requests.
|
|
||||||
type Client struct {
|
|
||||||
client *http.Client
|
|
||||||
log *slog.Logger
|
|
||||||
timeout time.Duration
|
|
||||||
url string
|
|
||||||
voice string
|
|
||||||
voiceMap map[string]string
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewClient creates a new TTS 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)
|
|
||||||
}
|
|
||||||
|
|
||||||
timeout := 60 * time.Second
|
|
||||||
if cfg.Timeout != "" {
|
|
||||||
d, err := time.ParseDuration(cfg.Timeout)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("parse timeout: %v", err)
|
|
||||||
}
|
|
||||||
timeout = d
|
|
||||||
}
|
|
||||||
|
|
||||||
voiceMap := cfg.VoiceMap
|
|
||||||
if len(voiceMap) == 0 {
|
|
||||||
voiceMap = defaultVoices
|
|
||||||
}
|
|
||||||
|
|
||||||
return &Client{
|
|
||||||
client: &http.Client{Timeout: timeout},
|
|
||||||
log: log,
|
|
||||||
timeout: timeout,
|
|
||||||
url: cfg.URL,
|
|
||||||
voice: cfg.Voice,
|
|
||||||
voiceMap: voiceMap,
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// defaultVoices maps whisper language names to default Kokoro voices.
|
|
||||||
// Used when Config.VoiceMap is empty.
|
|
||||||
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",
|
|
||||||
}
|
|
||||||
|
|
||||||
// SelectVoice returns the voice ID for the given whisper language name.
|
|
||||||
// Returns an empty string if no mapping exists for the language.
|
|
||||||
func (c *Client) SelectVoice(lang string) string {
|
|
||||||
if voice, ok := c.voiceMap[lang]; ok {
|
|
||||||
return voice
|
|
||||||
}
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
// Synthesize converts text to speech and returns WAV audio.
|
|
||||||
// If voice is empty, the default configured voice is used.
|
|
||||||
func (c *Client) Synthesize(ctx context.Context, text, voice string) (
|
|
||||||
[]byte, error,
|
|
||||||
) {
|
|
||||||
ctx, cancel := context.WithTimeout(ctx, c.timeout)
|
|
||||||
defer cancel()
|
|
||||||
if voice == "" {
|
|
||||||
voice = c.voice
|
|
||||||
}
|
|
||||||
|
|
||||||
body, err := json.Marshal(struct {
|
|
||||||
Input string `json:"input"`
|
|
||||||
Voice string `json:"voice"`
|
|
||||||
}{
|
|
||||||
Input: text,
|
|
||||||
Voice: voice,
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("marshal request: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
req, err := http.NewRequestWithContext(
|
|
||||||
ctx,
|
|
||||||
http.MethodPost,
|
|
||||||
c.url+"/v1/audio/speech",
|
|
||||||
bytes.NewReader(body),
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("create request: %w", err)
|
|
||||||
}
|
|
||||||
req.Header.Set("Content-Type", "application/json")
|
|
||||||
|
|
||||||
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(
|
|
||||||
"tts error %d: %s", res.StatusCode, body,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
audio, err := io.ReadAll(res.Body)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("read response: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return audio, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// ListVoices returns the available voices from kokoro-fastapi.
|
|
||||||
func (c *Client) ListVoices(ctx context.Context) ([]string, error) {
|
|
||||||
ctx, cancel := context.WithTimeout(ctx, c.timeout)
|
|
||||||
defer cancel()
|
|
||||||
|
|
||||||
req, err := http.NewRequestWithContext(
|
|
||||||
ctx,
|
|
||||||
http.MethodGet,
|
|
||||||
c.url+"/v1/audio/voices",
|
|
||||||
nil,
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("create request: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
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, _ := io.ReadAll(res.Body)
|
|
||||||
return nil, fmt.Errorf(
|
|
||||||
"voices error %d: %s", res.StatusCode, body,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
var voices = struct {
|
|
||||||
Voices []string `json:"voices"`
|
|
||||||
}{}
|
|
||||||
if err := json.NewDecoder(res.Body).Decode(&voices); err != nil {
|
|
||||||
return nil, fmt.Errorf("decode response: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return voices.Voices, nil
|
|
||||||
}
|
|
||||||
@@ -1,236 +0,0 @@
|
|||||||
package tts
|
|
||||||
|
|
||||||
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{
|
|
||||||
Voice: "af_heart",
|
|
||||||
},
|
|
||||||
wantErr: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "missing voice",
|
|
||||||
cfg: Config{
|
|
||||||
URL: "http://localhost:8880",
|
|
||||||
},
|
|
||||||
wantErr: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "invalid timeout",
|
|
||||||
cfg: Config{
|
|
||||||
URL: "http://localhost:8880",
|
|
||||||
Voice: "af_heart",
|
|
||||||
Timeout: "not-a-duration",
|
|
||||||
},
|
|
||||||
wantErr: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "valid minimal config",
|
|
||||||
cfg: Config{
|
|
||||||
URL: "http://localhost:8880",
|
|
||||||
Voice: "af_heart",
|
|
||||||
},
|
|
||||||
wantErr: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "valid config with timeout",
|
|
||||||
cfg: Config{
|
|
||||||
URL: "http://localhost:8880",
|
|
||||||
Voice: "af_heart",
|
|
||||||
Timeout: "120s",
|
|
||||||
},
|
|
||||||
wantErr: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "valid config with voice map",
|
|
||||||
cfg: Config{
|
|
||||||
URL: "http://localhost:8880",
|
|
||||||
Voice: "af_heart",
|
|
||||||
VoiceMap: map[string]string{
|
|
||||||
"english": "af_heart",
|
|
||||||
"chinese": "zf_xiaobei",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
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:8880",
|
|
||||||
Voice: "af_heart",
|
|
||||||
},
|
|
||||||
wantErr: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "valid config with timeout",
|
|
||||||
cfg: Config{
|
|
||||||
URL: "http://localhost:8880",
|
|
||||||
Voice: "af_heart",
|
|
||||||
Timeout: "90s",
|
|
||||||
},
|
|
||||||
wantErr: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "valid config with custom voice map",
|
|
||||||
cfg: Config{
|
|
||||||
URL: "http://localhost:8880",
|
|
||||||
Voice: "af_heart",
|
|
||||||
VoiceMap: map[string]string{
|
|
||||||
"english": "custom_en",
|
|
||||||
"french": "custom_fr",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
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")
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestSelectVoice(t *testing.T) {
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
voiceMap map[string]string
|
|
||||||
lang string
|
|
||||||
want string
|
|
||||||
}{
|
|
||||||
{
|
|
||||||
name: "default map english",
|
|
||||||
voiceMap: nil,
|
|
||||||
lang: "english",
|
|
||||||
want: "af_heart",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "default map chinese",
|
|
||||||
voiceMap: nil,
|
|
||||||
lang: "chinese",
|
|
||||||
want: "zf_xiaobei",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "default map japanese",
|
|
||||||
voiceMap: nil,
|
|
||||||
lang: "japanese",
|
|
||||||
want: "jf_alpha",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "default map unknown language",
|
|
||||||
voiceMap: nil,
|
|
||||||
lang: "klingon",
|
|
||||||
want: "",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "custom map",
|
|
||||||
voiceMap: map[string]string{
|
|
||||||
"english": "custom_english",
|
|
||||||
"german": "custom_german",
|
|
||||||
},
|
|
||||||
lang: "english",
|
|
||||||
want: "custom_english",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "custom map missing language",
|
|
||||||
voiceMap: map[string]string{
|
|
||||||
"english": "custom_english",
|
|
||||||
},
|
|
||||||
lang: "french",
|
|
||||||
want: "",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tt := range tests {
|
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
|
||||||
cfg := Config{
|
|
||||||
URL: "http://localhost:8880",
|
|
||||||
Voice: "af_heart",
|
|
||||||
VoiceMap: tt.voiceMap,
|
|
||||||
}
|
|
||||||
client, err := NewClient(cfg, nil)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("NewClient() error = %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
got := client.SelectVoice(tt.lang)
|
|
||||||
if got != tt.want {
|
|
||||||
t.Errorf(
|
|
||||||
"SelectVoice(%q) = %q, want %q",
|
|
||||||
tt.lang, got, tt.want,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestDefaultVoiceMap(t *testing.T) {
|
|
||||||
// Verify that DefaultVoiceMap contains expected entries.
|
|
||||||
expected := map[string]string{
|
|
||||||
"english": "af_heart",
|
|
||||||
"chinese": "zf_xiaobei",
|
|
||||||
"japanese": "jf_alpha",
|
|
||||||
"spanish": "ef_dora",
|
|
||||||
"french": "ff_siwis",
|
|
||||||
"korean": "kf_sarah",
|
|
||||||
}
|
|
||||||
|
|
||||||
for lang, voice := range expected {
|
|
||||||
if got := defaultVoices[lang]; got != voice {
|
|
||||||
t.Errorf(
|
|
||||||
"DefaultVoiceMap[%q] = %q, want %q",
|
|
||||||
lang, got, voice,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user