diff --git a/internal/audio/gemini/voices.go b/internal/audio/gemini/voices.go index bec9fffd..0efb5391 100644 --- a/internal/audio/gemini/voices.go +++ b/internal/audio/gemini/voices.go @@ -4,37 +4,43 @@ import "github.com/nextlevelbuilder/goclaw/internal/audio" // geminiVoices is the static catalog of 30 Gemini prebuilt voices. // Source: https://ai.google.dev/gemini-api/docs/speech-generation (April 2026) +// +// Google does NOT publish gender for these voices — by design, to keep voice +// selection inclusive. Only style descriptors are official. We surface the +// style as a label so users can pick by character (Bright/Firm/Smooth/etc.). +func styleLabel(style string) map[string]string { return map[string]string{"style": style} } + var geminiVoices = []audio.VoiceOption{ - {VoiceID: "Zephyr", Name: "Zephyr"}, - {VoiceID: "Puck", Name: "Puck"}, - {VoiceID: "Charon", Name: "Charon"}, - {VoiceID: "Kore", Name: "Kore"}, - {VoiceID: "Fenrir", Name: "Fenrir"}, - {VoiceID: "Leda", Name: "Leda"}, - {VoiceID: "Orus", Name: "Orus"}, - {VoiceID: "Aoede", Name: "Aoede"}, - {VoiceID: "Callirrhoe", Name: "Callirrhoe"}, - {VoiceID: "Autonoe", Name: "Autonoe"}, - {VoiceID: "Enceladus", Name: "Enceladus"}, - {VoiceID: "Iapetus", Name: "Iapetus"}, - {VoiceID: "Umbriel", Name: "Umbriel"}, - {VoiceID: "Algieba", Name: "Algieba"}, - {VoiceID: "Despina", Name: "Despina"}, - {VoiceID: "Erinome", Name: "Erinome"}, - {VoiceID: "Algenib", Name: "Algenib"}, - {VoiceID: "Rasalgethi", Name: "Rasalgethi"}, - {VoiceID: "Laomedeia", Name: "Laomedeia"}, - {VoiceID: "Achernar", Name: "Achernar"}, - {VoiceID: "Alnilam", Name: "Alnilam"}, - {VoiceID: "Schedar", Name: "Schedar"}, - {VoiceID: "Gacrux", Name: "Gacrux"}, - {VoiceID: "Pulcherrima", Name: "Pulcherrima"}, - {VoiceID: "Achird", Name: "Achird"}, - {VoiceID: "Zubenelgenubi", Name: "Zubenelgenubi"}, - {VoiceID: "Vindemiatrix", Name: "Vindemiatrix"}, - {VoiceID: "Sadachbia", Name: "Sadachbia"}, - {VoiceID: "Sadaltager", Name: "Sadaltager"}, - {VoiceID: "Sulafat", Name: "Sulafat"}, + {VoiceID: "Zephyr", Name: "Zephyr", Labels: styleLabel("Bright")}, + {VoiceID: "Puck", Name: "Puck", Labels: styleLabel("Upbeat")}, + {VoiceID: "Charon", Name: "Charon", Labels: styleLabel("Informative")}, + {VoiceID: "Kore", Name: "Kore", Labels: styleLabel("Firm")}, + {VoiceID: "Fenrir", Name: "Fenrir", Labels: styleLabel("Excitable")}, + {VoiceID: "Leda", Name: "Leda", Labels: styleLabel("Youthful")}, + {VoiceID: "Orus", Name: "Orus", Labels: styleLabel("Firm")}, + {VoiceID: "Aoede", Name: "Aoede", Labels: styleLabel("Breezy")}, + {VoiceID: "Callirrhoe", Name: "Callirrhoe", Labels: styleLabel("Easy-going")}, + {VoiceID: "Autonoe", Name: "Autonoe", Labels: styleLabel("Bright")}, + {VoiceID: "Enceladus", Name: "Enceladus", Labels: styleLabel("Breathy")}, + {VoiceID: "Iapetus", Name: "Iapetus", Labels: styleLabel("Clear")}, + {VoiceID: "Umbriel", Name: "Umbriel", Labels: styleLabel("Easy-going")}, + {VoiceID: "Algieba", Name: "Algieba", Labels: styleLabel("Smooth")}, + {VoiceID: "Despina", Name: "Despina", Labels: styleLabel("Smooth")}, + {VoiceID: "Erinome", Name: "Erinome", Labels: styleLabel("Clear")}, + {VoiceID: "Algenib", Name: "Algenib", Labels: styleLabel("Gravelly")}, + {VoiceID: "Rasalgethi", Name: "Rasalgethi", Labels: styleLabel("Informative")}, + {VoiceID: "Laomedeia", Name: "Laomedeia", Labels: styleLabel("Upbeat")}, + {VoiceID: "Achernar", Name: "Achernar", Labels: styleLabel("Soft")}, + {VoiceID: "Alnilam", Name: "Alnilam", Labels: styleLabel("Firm")}, + {VoiceID: "Schedar", Name: "Schedar", Labels: styleLabel("Even")}, + {VoiceID: "Gacrux", Name: "Gacrux", Labels: styleLabel("Mature")}, + {VoiceID: "Pulcherrima", Name: "Pulcherrima", Labels: styleLabel("Forward")}, + {VoiceID: "Achird", Name: "Achird", Labels: styleLabel("Friendly")}, + {VoiceID: "Zubenelgenubi", Name: "Zubenelgenubi", Labels: styleLabel("Casual")}, + {VoiceID: "Vindemiatrix", Name: "Vindemiatrix", Labels: styleLabel("Gentle")}, + {VoiceID: "Sadachbia", Name: "Sadachbia", Labels: styleLabel("Lively")}, + {VoiceID: "Sadaltager", Name: "Sadaltager", Labels: styleLabel("Knowledgeable")}, + {VoiceID: "Sulafat", Name: "Sulafat", Labels: styleLabel("Warm")}, } // defaultVoice is the voice used when none is specified. diff --git a/internal/audio/types_capabilities.go b/internal/audio/types_capabilities.go index a1da780f..af682cb9 100644 --- a/internal/audio/types_capabilities.go +++ b/internal/audio/types_capabilities.go @@ -38,11 +38,16 @@ type Dependency struct { } // VoiceOption is a static voice entry exposed in ProviderCapabilities. +// +// Labels carries provider-specific descriptors (e.g. {"style": "Bright"} for +// Gemini, {"gender": "female", "accent": "american"} for ElevenLabs). Frontend +// renders the first 1–2 entries as badges next to the voice name. type VoiceOption struct { - VoiceID string `json:"voice_id"` - Name string `json:"name"` - Language string `json:"language,omitempty"` - Gender string `json:"gender,omitempty"` + VoiceID string `json:"voice_id"` + Name string `json:"name"` + Language string `json:"language,omitempty"` + Gender string `json:"gender,omitempty"` + Labels map[string]string `json:"labels,omitempty"` } // ParamSchema describes a single configurable parameter for a TTS provider. diff --git a/ui/desktop/frontend/src/api/tts-capabilities.ts b/ui/desktop/frontend/src/api/tts-capabilities.ts index 9998ec04..17ad3f22 100644 --- a/ui/desktop/frontend/src/api/tts-capabilities.ts +++ b/ui/desktop/frontend/src/api/tts-capabilities.ts @@ -50,6 +50,8 @@ export interface VoiceOption { name: string; language?: string; gender?: string; + /** Provider-specific descriptors (e.g. {style: "Bright"} for Gemini). */ + labels?: Record; } export interface ProviderCapabilities { diff --git a/ui/desktop/frontend/src/components/agents/voice-picker.tsx b/ui/desktop/frontend/src/components/agents/voice-picker.tsx index 94882260..de441d14 100644 --- a/ui/desktop/frontend/src/components/agents/voice-picker.tsx +++ b/ui/desktop/frontend/src/components/agents/voice-picker.tsx @@ -37,13 +37,13 @@ export interface PortalVoice { preview_url?: string } -/** Maps a capability VoiceOption to PortalVoice (no labels/preview for Gemini). */ +/** Maps a capability VoiceOption to PortalVoice. Labels passed through (e.g. Gemini style). */ function mapCapVoiceToPortal(v: VoiceOption): PortalVoice { - return { voice_id: v.voice_id, name: v.name } + return { voice_id: v.voice_id, name: v.name, labels: v.labels } } function VoiceOption({ voice, selected }: { voice: PortalVoice; selected: boolean }) { - const labelEntries = ['gender', 'accent', 'age', 'use_case'] + const labelEntries = ['gender', 'accent', 'age', 'use_case', 'style'] .filter((k) => voice.labels?.[k]) .map((k) => voice.labels![k]) diff --git a/ui/web/src/api/tts-capabilities.ts b/ui/web/src/api/tts-capabilities.ts index 38df1e90..97be0a43 100644 --- a/ui/web/src/api/tts-capabilities.ts +++ b/ui/web/src/api/tts-capabilities.ts @@ -51,6 +51,8 @@ export interface VoiceOption { name: string; language?: string; gender?: string; + /** Provider-specific descriptors (e.g. {style: "Bright"} for Gemini). */ + labels?: Record; } export interface ProviderCapabilities { diff --git a/ui/web/src/components/voice-picker.tsx b/ui/web/src/components/voice-picker.tsx index 373d1187..3ca5c919 100644 --- a/ui/web/src/components/voice-picker.tsx +++ b/ui/web/src/components/voice-picker.tsx @@ -46,7 +46,7 @@ interface Props { placeholder?: string; } -const LABEL_KEYS = ["gender", "accent", "age", "use_case"] as const; +const LABEL_KEYS = ["gender", "accent", "age", "use_case", "style"] as const; /** * Unified voice shape for PortalVoicePicker. @@ -59,9 +59,9 @@ export interface PortalVoice { preview_url?: string; } -/** Maps a capability VoiceOption to PortalVoice (labels/preview absent for Gemini). */ +/** Maps a capability VoiceOption to PortalVoice. Labels passed through (e.g. Gemini style). */ function mapCapVoiceToPortal(v: VoiceOption): PortalVoice { - return { voice_id: v.voice_id, name: v.name }; + return { voice_id: v.voice_id, name: v.name, labels: v.labels }; } function VoiceRow({ voice, selected, onSelect }: { voice: PortalVoice; selected: boolean; onSelect: () => void }) {