mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-10-18 16:13:53 +00:00
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:
1 parent
f13524ec08
commit
2c00d50a6e
3 files changed
+86
No files matched your search
@@ -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)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -178,3 +178,4 @@ func TestListVoices_5xxFallback_ReturnsStaleCacheWhenPresent(t *testing.T) {
|
||||
t.Errorf("second call (cached): expected 2 voices, got %d", len(voices2))
|
||||
}
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user