mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-10-15 18:15:47 +00:00
feat(tts): show Gemini voice character (style) as a label badge
Google does not publish gender for the 30 prebuilt Gemini voices —
inclusive design — but does publish a one-word style descriptor
(Bright, Firm, Smooth, Mature, Lively, etc.). Surface it so users can
pick by character.
- `audio.VoiceOption` gains `Labels map[string]string` (provider-
specific descriptors). ElevenLabs already returns labels via API;
Gemini now hardcodes `{"style": "..."}` per voice from the official
catalog.
- Web + desktop `mapCapVoiceToPortal` passes labels through. Both
pickers' `LABEL_KEYS` now include `"style"`, so the existing badge
rendering picks it up — first 1–2 entries shown next to the name.
- TS `VoiceOption` interface (web + desktop) gains optional `labels`.
No gender field is invented for Gemini voices.
This commit is contained in:
1 parent
5b009caa95
commit
f13524ec08
6 files changed
+55
-40
No files matched your search
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -50,6 +50,8 @@ export interface VoiceOption {
|
||||
name: string;
|
||||
language?: string;
|
||||
gender?: string;
|
||||
/** Provider-specific descriptors (e.g. {style: "Bright"} for Gemini). */
|
||||
labels?: Record<string, string>;
|
||||
}
|
||||
|
||||
export interface ProviderCapabilities {
|
||||
|
||||
@@ -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])
|
||||
|
||||
|
||||
@@ -51,6 +51,8 @@ export interface VoiceOption {
|
||||
name: string;
|
||||
language?: string;
|
||||
gender?: string;
|
||||
/** Provider-specific descriptors (e.g. {style: "Bright"} for Gemini). */
|
||||
labels?: Record<string, string>;
|
||||
}
|
||||
|
||||
export interface ProviderCapabilities {
|
||||
|
||||
@@ -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 }) {
|
||||
|
||||
Reference in new issue
Block a user