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:
viettranx committed 2026-04-20 07:28:15 +07:00
1 parent 5b009caa95
commit f13524ec08
6 files changed
+55 -40

No files matched your search

+36 -30
View File
@@ -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.
+9 -4
View File
@@ -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])
+2
View File
@@ -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 {
+3 -3
View File
@@ -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 }) {