Refactor TTS architecture

This commit is contained in:
dwrz
2026-06-13 18:55:16 +00:00
parent 4b629a2bc7
commit c17c48f269
8 changed files with 274 additions and 563 deletions

View File

@@ -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.

View File

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

View File

@@ -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 */
/* ==================== */ /* ==================== */

View File

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

View File

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

View File

@@ -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"

View File

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

View File

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