feat(tts): parse MiniMax voice gender + language from naming convention

MiniMax /v1/get_voice does not return explicit gender/language fields,
but the system-voice naming convention exposes them:

- voice_id prefix `male-*` / `female-*` (legacy Chinese voices)
- voice_name suffix `*_Man` / `*_Boy` / `*_Lord` / `*_Speaker` → male
- voice_name suffix `*_Lady` / `*_Girl` / `*_Belle` / `*_Lass` → female
- voice_name prefix `English_*` / `Chinese_*` / `Japanese_*` /
  `Korean_*` / `French_*` / etc. → language

`parseMinimaxLabels` is best-effort — returns nil for unparseable IDs
(e.g. `moss_audio_<uuid>` cloned voices), so the picker shows no badge
rather than a wrong one. The picker already renders `voice.labels` as
badges; nothing to wire on the FE side.

8-case unit test covers the matrix.
This commit is contained in:
viettranx committed 2026-04-20 07:32:35 +07:00
1 parent f13524ec08
commit 2c00d50a6e
3 files changed
+86

No files matched your search

+48
View File
@@ -7,6 +7,7 @@ import (
"fmt"
"io"
"net/http"
"strings"
"sync"
"time"
@@ -136,6 +137,7 @@ func (vl *VoiceLister) fetchVoices(ctx context.Context) ([]audio.Voice, error) {
ID: v.VoiceID,
Name: v.VoiceName,
Category: "system",
Labels: parseMinimaxLabels(v.VoiceID, v.VoiceName),
})
}
for _, v := range result.VoiceCloning {
@@ -154,3 +156,49 @@ func (vl *VoiceLister) fetchVoices(ctx context.Context) ([]audio.Voice, error) {
}
return voices, nil
}
// parseMinimaxLabels extracts gender + language hints from MiniMax system voice
// IDs and names. MiniMax API does not return these fields explicitly, but the
// naming convention exposes them:
// - voice_id prefix `male-*` / `female-*` (legacy Chinese voices)
// - voice_name suffix `*_Man` / `*_Boy` / `*_Lord` → male
// - voice_name suffix `*_Lady` / `*_Girl` / `*_Belle` / `*_Lass` → female
// - voice_name prefix `English_*` / `Chinese_*` / `Japanese_*` / `Korean_*` →
// language. Returns nil when nothing parseable, so the picker shows no
// badge instead of an empty one.
func parseMinimaxLabels(voiceID, voiceName string) map[string]string {
out := map[string]string{}
idLower := strings.ToLower(voiceID)
nameLower := strings.ToLower(voiceName)
switch {
case strings.HasPrefix(idLower, "male-") ||
hasAnySuffix(nameLower, "_man", "_boy", "_lord", "_speaker", "_uncle"):
out["gender"] = "male"
case strings.HasPrefix(idLower, "female-") ||
hasAnySuffix(nameLower, "_lady", "_girl", "_belle", "_lass", "_woman", "_aunt"):
out["gender"] = "female"
}
for _, lang := range []string{"english", "chinese", "japanese", "korean", "french", "spanish", "german", "italian", "portuguese"} {
if strings.HasPrefix(nameLower, lang+"_") {
// Capitalize first rune; lang labels are ASCII so no need for x/text/cases.
out["language"] = strings.ToUpper(lang[:1]) + lang[1:]
break
}
}
if len(out) == 0 {
return nil
}
return out
}
func hasAnySuffix(s string, suffixes ...string) bool {
for _, suf := range suffixes {
if strings.HasSuffix(s, suf) {
return true
}
}
return false
}
@@ -0,0 +1,37 @@
package minimax
import "testing"
// TestParseMinimaxLabels covers the heuristic that derives gender + language
// hints from MiniMax voice IDs/names. MiniMax API does not return these fields
// explicitly, so we depend on the documented naming convention.
func TestParseMinimaxLabels(t *testing.T) {
tests := []struct {
name string
voiceID string
voiceName string
want map[string]string
}{
{"male prefix", "male-qn-qingse", "male-qn-qingse", map[string]string{"gender": "male"}},
{"female prefix", "female-tianmei", "female-tianmei", map[string]string{"gender": "female"}},
{"english man suffix", "abc", "English_Persuasive_Man", map[string]string{"gender": "male", "language": "English"}},
{"english lady suffix", "xyz", "English_Graceful_Lady", map[string]string{"gender": "female", "language": "English"}},
{"english girl suffix", "xyz", "English_radiant_girl", map[string]string{"gender": "female", "language": "English"}},
{"japanese belle suffix", "abc", "Japanese_Whisper_Belle", map[string]string{"gender": "female", "language": "Japanese"}},
{"unknown name no prefix", "moss_audio_xyz", "moss_audio_xyz", nil},
{"empty inputs", "", "", nil},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
got := parseMinimaxLabels(tc.voiceID, tc.voiceName)
if len(got) != len(tc.want) {
t.Fatalf("len mismatch: got %v, want %v", got, tc.want)
}
for k, v := range tc.want {
if got[k] != v {
t.Errorf("key %q: got %q, want %q", k, got[k], v)
}
}
})
}
}
+1
View File
@@ -178,3 +178,4 @@ func TestListVoices_5xxFallback_ReturnsStaleCacheWhenPresent(t *testing.T) {
t.Errorf("second call (cached): expected 2 voices, got %d", len(voices2))
}
}