FFFF fix(edge)!: replace EdgeTTSVoices with Voices(), which returns a copy · felinics/twilight@06eb4ef · GitHub
[go: up one dir, main page]

Skip to content

Commit 06eb4ef

Browse files
committed
fix(edge)!: replace EdgeTTSVoices with Voices(), which returns a copy
What: replace the exported package variable `EdgeTTSVoices map[string][]string` with `Voices() map[string][]string`, which returns a deep copy of the catalog on every call. The backing map is now unexported and is still populated once in init from the embedded voices.json. Docs are updated accordingly. Why: an exported map lets any importer mutate package state, including the slices shared with the voiceToLang reverse index, and a caller writing to it while another goroutine reads it triggers a fatal "concurrent map read and map write" at runtime. Returning a copy keeps the package data read-only and safe for concurrent use. BREAKING CHANGE: `speech.EdgeTTSVoices` is removed; use `speech.Voices()`.
1 parent 22d89a4 commit 06eb4ef

4 files changed

Lines changed: 90 additions & 9 deletions

File tree

‎docs/api-reference.md‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -978,13 +978,13 @@ The Edge provider reads these keys from `SpeechParams.Config`:
978978
| `speed` | `float64` | `0` | Speech rate (1.0 = normal) |
979979
| `pitch` | `float64` | `0` | Pitch in Hz |
980980

981-
#### Package-Level Variables
981+
#### Helper Functions
982982

983983
```go
984-
var EdgeTTSVoices map[string][]string // language tag → voice IDs
984+
func Voices() map[string][]string
985985
```
986986

987-
#### Helper Functions
987+
Returns the voice catalog (language tag → voice IDs). Each call returns a new deep copy; modifying it does not affect the package.
988988

989989
```go
990990
func LookupVoiceLang(voiceID string) (string, bool)

‎docs/speech.md‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -150,13 +150,13 @@ The Edge provider reads these keys from `WithSpeechConfig`:
150150

151151
### Voices
152152

153-
Edge TTS supports 400+ voices across 100+ languages. Use `speech.EdgeTTSVoices` to browse the full catalog:
153+
Edge TTS supports 400+ voices across 100+ languages. Use `speech.Voices()` to browse the full catalog. It returns a copy, so modifying the result does not affect the package:
154154

155155
```go
156156
import "github.com/felinics/twilight/provider/edge/speech"
157157

158158
// Map of language tag → voice IDs
159-
for lang, voices := range speech.EdgeTTSVoices {
159+
for lang, voices := range speech.Voices() {
160160
fmt.Printf("%s: %v\n", lang, voices)
161161
}
162162

‎provider/edge/speech/type.go‎

Lines changed: 18 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ package speech
33
import (
44
_ "embed"
55
"encoding/json"
6+
"slices"
67
)
78

89
const (
@@ -22,23 +23,36 @@ const (
2223
//go:embed voices.json
2324
var voicesJSON []byte
2425

25-
// EdgeTTSVoices maps language tags to available voice IDs, loaded from voices.json.
26-
var EdgeTTSVoices map[string][]string
26+
// voicesByLang maps language tags to voice IDs. It is populated once in init
27+
// and never mutated afterwards, so concurrent reads are safe. It must not be
28+
// handed out directly; use Voices, which returns a copy.
29+
var voicesByLang map[string][]string
2730

2831
var voiceToLang map[string]string
2932

3033
func init() {
31-
if err := json.Unmarshal(voicesJSON, &EdgeTTSVoices); err != nil {
34+
if err := json.Unmarshal(voicesJSON, &voicesByLang); err != nil {
3235
panic("edge: failed to parse voices.json: " + err.Error())
3336
}
3437
voiceToLang = make(map[string]string, 256)
35-
for lang, voices := range EdgeTTSVoices {
38+
for lang, voices := range voicesByLang {
3639
for _, v := range voices {
3740
voiceToLang[v] = lang
3841
}
3942
}
4043
}
4144

45+
// Voices returns the Edge TTS voice catalog as a map from language tag to
46+
// voice IDs. The result is a fresh deep copy on every call; callers may modify
47+
// it freely without affecting the package or other callers.
48+
func Voices() map[string][]string {
49+
out := make(map[string][]string, len(voicesByLang))
50+
for lang, ids := range voicesByLang {
51+
out[lang] = slices.Clone(ids)
52+
}
53+
return out
54+
}
55+
4256
// LookupVoiceLang returns the language for a known Edge TTS voice ID.
4357
// Returns ("", false) if the voice is not recognized.
4458
func LookupVoiceLang(voiceID string) (string, bool) {

‎provider/edge/speech/type_test.go‎

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
package speech
2+
3+
import (
4+
"slices"
5+
"sync"
6+
"testing"
7+
)
8+
9+
func TestVoices_ReturnsIndependentCopy(t *testing.T) {
10+
t.Parallel()
11+
12+
first := Voices()
13+
zh, ok := first["zh-CN"]
14+
if !ok || !slices.Contains(zh, "zh-CN-XiaoxiaoNeural") {
15+
t.Fatalf("zh-CN voices missing XiaoxiaoNeural: %v", zh)
16+
}
17+
18+
zh[0] = "mutated"
19+
first["zh-CN"] = nil
20+
delete(first, "en-US")
21+
first["xx-XX"] = []string{"xx-XX-FakeNeural"}
22+
23+
second := Voices()
24+
if !slices.Contains(second["zh-CN"], "zh-CN-XiaoxiaoNeural") || slices.Contains(second["zh-CN"], "mutated") {
25+
t.Fatalf("package data changed through returned slice: %v", second["zh-CN"])
26+
}
27+
if _, ok := second["en-US"]; !ok {
28+
t.Fatal("deleting from returned map removed en-US from package data")
29+
}
30+
if _, ok := second["xx-XX"]; ok {
31+
t.Fatal("inserting into returned map leaked into package data")
32+
}
33+
if lang, ok := LookupVoiceLang("zh-CN-XiaoxiaoNeural"); !ok || lang != "zh-CN" {
34+
t.Fatalf("LookupVoiceLang = %q, %v", lang, ok)
35+
}
36+
}
37+
38+
// TestVoices_ConcurrentReadAndMutate is meaningful under -race: every goroutine
39+
// reads the catalog and mutates its own copy. If Voices returned shared state,
40+
// the race detector (or the runtime's concurrent map write check) would fire.
41+
func TestVoices_ConcurrentReadAndMutate(t *testing.T) {
42+
t.Parallel()
43+
44+
var wg sync.WaitGroup
45+
for i := range 16 {
46+
wg.Add(1)
47+
go func() {
48+
defer wg.Done()
49+
for range 50 {
50+
v := Voices()
51+
for lang, ids := range v {
52+
if len(ids) > 0 {
53+
ids[0] = "mutated"
54+
}
55+
v[lang] = append(ids, "extra")
56+
}
57+
v["goroutine"] = []string{string(rune('a' + i))}
58+
_, _ = LookupVoiceLang("en-US-EmmaMultilingualNeural")
59+
}
60+
}()
61+
}
62+
wg.Wait()
63+
64+
if slices.Contains(Voices()["en-US"], "mutated") {
65+
t.Fatal("concurrent mutation of returned copies reached package data")
66+
}
67+
}

0 commit comments

Comments
 (0)
0