From 2c00d50a6e6a810727a1338e3395014534fdc4dc Mon Sep 17 00:00:00 2001 From: viettranx Date: Mon, 20 Apr 2026 07:32:35 +0700 Subject: [PATCH] feat(tts): parse MiniMax voice gender + language from naming convention MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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_` 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. --- internal/audio/minimax/voices.go | 48 +++++++++++++++++++ .../audio/minimax/voices_internal_test.go | 37 ++++++++++++++ internal/audio/minimax/voices_test.go | 1 + 3 files changed, 86 insertions(+) create mode 100644 internal/audio/minimax/voices_internal_test.go diff --git a/internal/audio/minimax/voices.go b/internal/audio/minimax/voices.go index 49a6817c..398fd59a 100644 --- a/internal/audio/minimax/voices.go +++ b/internal/audio/minimax/voices.go @@ -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 +} diff --git a/internal/audio/minimax/voices_internal_test.go b/internal/audio/minimax/voices_internal_test.go new file mode 100644 index 00000000..559a2436 --- /dev/null +++ b/internal/audio/minimax/voices_internal_test.go @@ -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) + } + } + }) + } +} diff --git a/internal/audio/minimax/voices_test.go b/internal/audio/minimax/voices_test.go index a3964c22..ec6ee8cc 100644 --- a/internal/audio/minimax/voices_test.go +++ b/internal/audio/minimax/voices_test.go @@ -178,3 +178,4 @@ func TestListVoices_5xxFallback_ReturnsStaleCacheWhenPresent(t *testing.T) { t.Errorf("second call (cached): expected 2 voices, got %d", len(voices2)) } } +