@xdfnet/ispeak 1.6.11 → 1.6.13

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/main.go +30 -174
  2. package/main_test.go +0 -378
  3. package/package.json +1 -1
package/main.go CHANGED
@@ -47,193 +47,51 @@ type StreamPlayer interface {
47
47
  Abort() error
48
48
  }
49
49
 
50
- // 任务状态
51
- // 生命周期:pending -> running -> delete
52
- type TaskStatus int
53
-
54
- const (
55
- TaskStatusPending TaskStatus = iota
56
- TaskStatusRunning
57
- )
58
-
59
- // 单个 TTS 任务
60
- type Task struct {
61
- ID uint64
62
- Text string
63
- Status TaskStatus
64
- Voice VoiceInfo
65
- Cfg Config
66
- }
67
-
68
- // 任务引擎:任务仓库 + 单事务 worker
69
- type TaskEngine struct {
70
- mu sync.Mutex
71
-
72
- nextID uint64
73
- latestID uint64
74
- tasks map[uint64]*Task
75
- pending []uint64
76
-
77
- wake chan struct{}
78
-
79
- synthesizeStreamFn func(ctx context.Context, cfg Config, text string, voice *VoiceInfo, onAudio func([]byte) error) error
80
- newStreamPlayerFn func() (StreamPlayer, error)
50
+ // 最简单的播放器:channel 队列,串行播报
51
+ type Player struct {
52
+ ch chan job
81
53
  }
82
54
 
83
- func NewTaskEngine() *TaskEngine {
84
- return &TaskEngine{
85
- tasks: make(map[uint64]*Task),
86
- wake: make(chan struct{}, 1),
87
- synthesizeStreamFn: synthesizeStream,
88
- newStreamPlayerFn: newDefaultStreamPlayer,
89
- }
55
+ type job struct {
56
+ text string
57
+ voice VoiceInfo
58
+ cfg Config
90
59
  }
91
60
 
92
- func (e *TaskEngine) Start() {
93
- go e.transactionWorker()
61
+ func NewPlayer() *Player {
62
+ p := &Player{ch: make(chan job, 256)}
63
+ go p.loop()
64
+ return p
94
65
  }
95
66
 
96
- func (e *TaskEngine) Submit(text string, voice VoiceInfo, cfg Config) uint64 {
97
- e.mu.Lock()
98
-
99
-
100
- e.nextID++
101
- task := &Task{
102
- ID: e.nextID,
103
- Text: text,
104
- Status: TaskStatusPending,
105
- Voice: voice,
106
- Cfg: cfg,
107
- }
108
- e.tasks[task.ID] = task
109
- e.latestID = task.ID
110
- e.pending = append(e.pending, task.ID)
111
- log.Printf("任务创建: id=%d text=%s", task.ID, text)
112
-
113
- notify(e.wake)
114
- e.mu.Unlock()
115
-
116
- return task.ID
67
+ func (p *Player) Submit(text string, voice VoiceInfo, cfg Config) {
68
+ log.Printf("TTS: %s", text)
69
+ p.ch <- job{text, voice, cfg}
117
70
  }
118
71
 
119
- func (e *TaskEngine) transactionWorker() {
120
- for {
121
- id := e.claimPending()
122
- if id == 0 {
123
- <-e.wake
72
+ func (p *Player) loop() {
73
+ for j := range p.ch {
74
+ player, err := newDefaultStreamPlayer()
75
+ if err != nil {
76
+ log.Printf("启动播放器失败: %v", err)
124
77
  continue
125
78
  }
126
-
127
- e.processTransaction(id)
128
- }
129
- }
130
-
131
- func (e *TaskEngine) processTransaction(id uint64) {
132
- defer func() {
133
- if r := recover(); r != nil {
134
- log.Printf("播报任务崩溃并删除: id=%d err=%v", id, r)
135
- e.deleteTask(id)
136
- }
137
- }()
138
-
139
- task, ok := e.getTask(id)
140
- if !ok {
141
- return
142
- }
143
- if !e.isLatestTask(id) {
144
- log.Printf("跳过过期播报任务: id=%d", id)
145
- e.deleteTask(id)
146
- return
147
- }
148
-
149
- if err := e.runTransaction(task); err != nil {
150
- log.Printf("播报失败并删除任务: id=%d err=%v", id, err)
151
- e.deleteTask(id)
152
- return
79
+ p.play(j, player)
80
+ _ = player.CloseAndWait()
153
81
  }
154
-
155
- log.Printf("播报完成并删除任务: id=%d", id)
156
- e.deleteTask(id)
157
82
  }
158
83
 
159
- func (e *TaskEngine) runTransaction(task *Task) error {
84
+ func (p *Player) play(j job, player StreamPlayer) {
160
85
  startedAt := time.Now()
161
- player, err := e.newStreamPlayerFn()
162
- if err != nil {
163
- return fmt.Errorf("启动播放器失败: %w", err)
164
- }
165
-
166
- firstChunkLogged := false
167
86
  onAudio := func(audio []byte) error {
168
- if len(audio) > 0 && !firstChunkLogged {
169
- firstChunkLogged = true
170
- log.Printf("首个音频 chunk: id=%d elapsed=%s bytes=%d", task.ID, time.Since(startedAt).Round(time.Millisecond), len(audio))
171
- }
172
87
  return player.Write(audio)
173
88
  }
174
89
 
175
- if err := e.synthesizeStreamFn(context.Background(), task.Cfg, task.Text, &task.Voice, onAudio); err != nil {
176
- _ = player.Abort()
177
- return fmt.Errorf("TTS 合成失败: id=%d: %w", task.ID, err)
178
- }
179
- log.Printf("TTS 流结束: id=%d elapsed=%s", task.ID, time.Since(startedAt).Round(time.Millisecond))
180
-
181
- if err := player.CloseAndWait(); err != nil {
182
- _ = player.Abort()
183
- return fmt.Errorf("播放器失败: id=%d: %w", task.ID, err)
184
- }
185
- return nil
186
- }
187
-
188
- func (e *TaskEngine) claimPending() uint64 {
189
- e.mu.Lock()
190
- defer e.mu.Unlock()
191
-
192
- for len(e.pending) > 0 {
193
- id := e.pending[0]
194
- e.pending = e.pending[1:]
195
- task, ok := e.tasks[id]
196
- if !ok {
197
- continue
198
- }
199
- if task.Status != TaskStatusPending {
200
- continue
201
- }
202
- task.Status = TaskStatusRunning
203
- return id
204
- }
205
- return 0
206
- }
207
-
208
- func (e *TaskEngine) getTask(id uint64) (*Task, bool) {
209
- e.mu.Lock()
210
- defer e.mu.Unlock()
211
-
212
- task, ok := e.tasks[id]
213
- if !ok {
214
- return nil, false
215
- }
216
- clone := *task
217
- return &clone, true
218
- }
219
-
220
- func (e *TaskEngine) deleteTask(id uint64) {
221
- e.mu.Lock()
222
- defer e.mu.Unlock()
223
- delete(e.tasks, id)
224
- }
225
-
226
- func (e *TaskEngine) isLatestTask(id uint64) bool {
227
- e.mu.Lock()
228
- defer e.mu.Unlock()
229
- return e.latestID == id
230
- }
231
-
232
- func notify(ch chan struct{}) {
233
- select {
234
- case ch <- struct{}{}:
235
- default:
90
+ if err := synthesizeStream(context.Background(), j.cfg, j.text, &j.voice, onAudio); err != nil {
91
+ log.Printf("TTS 合成失败: %v", err)
92
+ return
236
93
  }
94
+ log.Printf("TTS: 完成 elapsed=%s", time.Since(startedAt).Round(time.Millisecond))
237
95
  }
238
96
 
239
97
  // 音色信息
@@ -595,8 +453,7 @@ func main() {
595
453
  listener.Close()
596
454
  }()
597
455
 
598
- engine := NewTaskEngine()
599
- engine.Start()
456
+ player := NewPlayer()
600
457
 
601
458
  log.Printf("iSpeak 已启动,监听 %s", socketPath)
602
459
  for {
@@ -607,7 +464,7 @@ func main() {
607
464
  }
608
465
  continue
609
466
  }
610
- go handleConnection(conn, engine)
467
+ go handleConnection(conn, player)
611
468
  }
612
469
  }
613
470
 
@@ -688,7 +545,7 @@ func validateVoiceInfo(name string, voice *VoiceInfo) error {
688
545
  return nil
689
546
  }
690
547
 
691
- func handleConnection(conn net.Conn, engine *TaskEngine) {
548
+ func handleConnection(conn net.Conn, player *Player) {
692
549
  defer func() {
693
550
  if r := recover(); r != nil {
694
551
  log.Printf("连接处理崩溃: %v", r)
@@ -735,8 +592,7 @@ func handleConnection(conn net.Conn, engine *TaskEngine) {
735
592
  return
736
593
  }
737
594
 
738
- log.Printf("TTS: %s", cleaned)
739
- engine.Submit(cleaned, *voice, cfg)
595
+ player.Submit(cleaned, *voice, cfg)
740
596
  }
741
597
 
742
598
  // 解析消息中的音色前缀,返回 VoiceInfo
package/main_test.go CHANGED
@@ -1,229 +1,16 @@
1
1
  package main
2
2
 
3
3
  import (
4
- "context"
5
4
  "encoding/base64"
6
5
  "errors"
7
6
  "net"
8
7
  "os"
9
8
  "path/filepath"
10
9
  "strings"
11
- "sync"
12
10
  "testing"
13
11
  "time"
14
12
  )
15
13
 
16
- func TestSubmitKeepsAllPendingTasks(t *testing.T) {
17
- e := NewTaskEngine()
18
- e.synthesizeStreamFn = func(ctx context.Context, cfg Config, text string, voice *VoiceInfo, onAudio func([]byte) error) error {
19
- return onAudio([]byte("ok"))
20
- }
21
- e.newStreamPlayerFn = newFakeStreamPlayerFactory()
22
-
23
- cfg := Config{}
24
- voice := VoiceInfo{VoiceType: "v", ResourceID: "r"}
25
-
26
- e.Submit("a", voice, cfg)
27
- e.Submit("b", voice, cfg)
28
-
29
- e.mu.Lock()
30
- defer e.mu.Unlock()
31
- if len(e.pending) != 2 {
32
- t.Fatalf("expected 2 pending, got %d", len(e.pending))
33
- }
34
- if e.latestID != 2 {
35
- t.Fatalf("expected latestID 2, got %d", e.latestID)
36
- }
37
- }
38
-
39
- func TestTransactionDeletesOnSynthesisFailureWithoutRetry(t *testing.T) {
40
- e := NewTaskEngine()
41
- var mu sync.Mutex
42
- calls := 0
43
- e.synthesizeStreamFn = func(ctx context.Context, cfg Config, text string, voice *VoiceInfo, onAudio func([]byte) error) error {
44
- mu.Lock()
45
- defer mu.Unlock()
46
- calls++
47
- return errors.New("fail")
48
- }
49
- e.newStreamPlayerFn = newFakeStreamPlayerFactory()
50
- e.Start()
51
-
52
- cfg := Config{}
53
- voice := VoiceInfo{VoiceType: "v", ResourceID: "r"}
54
- id := e.Submit("x", voice, cfg)
55
-
56
- waitFor(t, 2*time.Second, func() bool {
57
- e.mu.Lock()
58
- defer e.mu.Unlock()
59
- _, ok := e.tasks[id]
60
- return !ok
61
- })
62
-
63
- mu.Lock()
64
- defer mu.Unlock()
65
- if calls != 1 {
66
- t.Fatalf("expected synth attempts=1, got %d", calls)
67
- }
68
- }
69
-
70
- func TestTransactionDeletesOnPlayerWriteFailureWithoutRetry(t *testing.T) {
71
- e := NewTaskEngine()
72
- e.synthesizeStreamFn = func(ctx context.Context, cfg Config, text string, voice *VoiceInfo, onAudio func([]byte) error) error {
73
- return onAudio([]byte("audio"))
74
- }
75
- var mu sync.Mutex
76
- calls := 0
77
- e.newStreamPlayerFn = func() (StreamPlayer, error) {
78
- mu.Lock()
79
- defer mu.Unlock()
80
- calls++
81
- return &fakeStreamPlayer{writeErr: calls == 1}, nil
82
- }
83
- e.Start()
84
-
85
- cfg := Config{}
86
- voice := VoiceInfo{VoiceType: "v", ResourceID: "r"}
87
- id := e.Submit("x", voice, cfg)
88
-
89
- waitFor(t, 2*time.Second, func() bool {
90
- e.mu.Lock()
91
- defer e.mu.Unlock()
92
- _, ok := e.tasks[id]
93
- return !ok
94
- })
95
-
96
- mu.Lock()
97
- defer mu.Unlock()
98
- if calls != 1 {
99
- t.Fatalf("expected player attempts=1, got %d", calls)
100
- }
101
- }
102
-
103
- func TestSubmitDoesNotInterruptRunningTask(t *testing.T) {
104
- e := NewTaskEngine()
105
- start := make(chan struct{}, 1)
106
- release := make(chan struct{})
107
- var mu sync.Mutex
108
- processed := make([]string, 0, 2)
109
-
110
- e.synthesizeStreamFn = func(ctx context.Context, cfg Config, text string, voice *VoiceInfo, onAudio func([]byte) error) error {
111
- if text == "a" {
112
- start <- struct{}{}
113
- }
114
- if text == "a" {
115
- <-release
116
- }
117
- mu.Lock()
118
- processed = append(processed, text)
119
- mu.Unlock()
120
- return onAudio([]byte(text))
121
- }
122
- e.newStreamPlayerFn = newFakeStreamPlayerFactory()
123
- e.Start()
124
-
125
- cfg := Config{}
126
- voice := VoiceInfo{VoiceType: "v", ResourceID: "r"}
127
- e.Submit("a", voice, cfg)
128
- <-start // a 已进入 running
129
- secondID := e.Submit("b", voice, cfg)
130
-
131
- e.mu.Lock()
132
- if len(e.pending) != 1 || e.pending[0] != secondID {
133
- t.Fatalf("expected second task pending while first keeps running, got %#v", e.pending)
134
- }
135
- e.mu.Unlock()
136
-
137
- close(release)
138
-
139
- waitFor(t, 3*time.Second, func() bool {
140
- e.mu.Lock()
141
- defer e.mu.Unlock()
142
- return len(e.tasks) == 0
143
- })
144
-
145
- mu.Lock()
146
- defer mu.Unlock()
147
- if strings.Join(processed, "") != "ab" {
148
- t.Fatalf("expected processed [a b], got %#v", processed)
149
- }
150
- }
151
-
152
- func TestClaimedStaleTaskIsSkippedBeforeTransaction(t *testing.T) {
153
- e := NewTaskEngine()
154
- calls := 0
155
- e.synthesizeStreamFn = func(ctx context.Context, cfg Config, text string, voice *VoiceInfo, onAudio func([]byte) error) error {
156
- calls++
157
- return onAudio([]byte(text))
158
- }
159
- e.newStreamPlayerFn = newFakeStreamPlayerFactory()
160
-
161
- cfg := Config{}
162
- voice := VoiceInfo{VoiceType: "v", ResourceID: "r"}
163
- firstID := e.Submit("a", voice, cfg)
164
- claimedID := e.claimPending()
165
- if claimedID != firstID {
166
- t.Fatalf("expected claimed first task %d, got %d", firstID, claimedID)
167
- }
168
- e.Submit("b", voice, cfg)
169
-
170
- e.processTransaction(firstID)
171
-
172
- if calls != 0 {
173
- t.Fatalf("expected stale task skipped before transaction, got calls=%d", calls)
174
- }
175
- e.mu.Lock()
176
- _, firstExists := e.tasks[firstID]
177
- e.mu.Unlock()
178
- if firstExists {
179
- t.Fatalf("expected stale task deleted")
180
- }
181
- }
182
-
183
- func TestSynthesisPanicDeletesTaskAndWorkerContinues(t *testing.T) {
184
- e := NewTaskEngine()
185
- e.synthesizeStreamFn = func(ctx context.Context, cfg Config, text string, voice *VoiceInfo, onAudio func([]byte) error) error {
186
- if text == "panic" {
187
- panic("boom")
188
- }
189
- return onAudio([]byte(text))
190
- }
191
- e.newStreamPlayerFn = newFakeStreamPlayerFactory()
192
- e.Start()
193
-
194
- cfg := Config{}
195
- voice := VoiceInfo{VoiceType: "v", ResourceID: "r"}
196
- panicID := e.Submit("panic", voice, cfg)
197
- waitForTaskDeleted(t, e, panicID)
198
-
199
- okID := e.Submit("ok", voice, cfg)
200
- waitForTaskDeleted(t, e, okID)
201
- }
202
-
203
- func TestPlaybackPanicDeletesTaskAndWorkerContinues(t *testing.T) {
204
- e := NewTaskEngine()
205
- e.synthesizeStreamFn = func(ctx context.Context, cfg Config, text string, voice *VoiceInfo, onAudio func([]byte) error) error {
206
- return onAudio([]byte(text))
207
- }
208
- var mu sync.Mutex
209
- calls := 0
210
- e.newStreamPlayerFn = func() (StreamPlayer, error) {
211
- mu.Lock()
212
- defer mu.Unlock()
213
- calls++
214
- return &fakeStreamPlayer{panicOnWrite: calls == 1}, nil
215
- }
216
- e.Start()
217
-
218
- cfg := Config{}
219
- voice := VoiceInfo{VoiceType: "v", ResourceID: "r"}
220
- panicID := e.Submit("panic", voice, cfg)
221
- waitForTaskDeleted(t, e, panicID)
222
-
223
- okID := e.Submit("ok", voice, cfg)
224
- waitForTaskDeleted(t, e, okID)
225
- }
226
-
227
14
  func TestParseSSEStreamWritesChunksInOrder(t *testing.T) {
228
15
  stream := strings.NewReader(
229
16
  "data: {\"audio\":\"YQ==\"}\n\n" +
@@ -469,96 +256,6 @@ func TestCleanTextSingleLineArtifactDoesNotSwallowFollowingText(t *testing.T) {
469
256
  }
470
257
  }
471
258
 
472
- func TestHandleConnectionPreservesMultilineBeforeCleaning(t *testing.T) {
473
- oldConfigDir := configDir
474
- oldCacheValid := configCacheValid
475
- oldCachePath := configCachePath
476
- oldCacheModTime := configCacheModTime
477
- oldCache := configCache
478
- t.Cleanup(func() {
479
- configDir = oldConfigDir
480
- configCacheValid = oldCacheValid
481
- configCachePath = oldCachePath
482
- configCacheModTime = oldCacheModTime
483
- configCache = oldCache
484
- })
485
-
486
- dir := t.TempDir()
487
- configDir = dir
488
- configCacheValid = false
489
- cfg := `{
490
- "apiKey": "key",
491
- "endpoint": "https://example.com/tts",
492
- "defaultVoice": {"voice_type": "voice", "resourceId": "resource"}
493
- }`
494
- if err := os.WriteFile(filepath.Join(dir, "config.json"), []byte(cfg), 0644); err != nil {
495
- t.Fatalf("write config: %v", err)
496
- }
497
-
498
- server, client := net.Pipe()
499
- e := NewTaskEngine()
500
- done := make(chan struct{})
501
- go func() {
502
- handleConnection(server, e)
503
- close(done)
504
- }()
505
-
506
- msg := strings.Join([]string{
507
- "不是,飞哥。",
508
- "",
509
- "| 部分 | 是否常驻 |",
510
- "|---|---|",
511
- "| ispeakd | 是 |",
512
- "",
513
- "也就是说:daemon 常驻,播放器不是常驻。",
514
- }, "\n")
515
- if _, err := client.Write([]byte(msg)); err != nil {
516
- t.Fatalf("write client: %v", err)
517
- }
518
- if err := client.Close(); err != nil {
519
- t.Fatalf("close client: %v", err)
520
- }
521
- select {
522
- case <-done:
523
- case <-time.After(time.Second):
524
- t.Fatalf("handleConnection did not return")
525
- }
526
-
527
- e.mu.Lock()
528
- defer e.mu.Unlock()
529
- if len(e.pending) != 1 {
530
- t.Fatalf("expected one pending task, got %d", len(e.pending))
531
- }
532
- task := e.tasks[e.pending[0]]
533
- if !strings.Contains(task.Text, "不是,飞哥。") ||
534
- !strings.Contains(task.Text, "也就是说:daemon 常驻,播放器不是常驻。") {
535
- t.Fatalf("expected surrounding text preserved, got %q", task.Text)
536
- }
537
- if strings.Contains(task.Text, "是否常驻") || strings.Contains(task.Text, "ispeakd | 是") {
538
- t.Fatalf("expected table removed, got %q", task.Text)
539
- }
540
- }
541
-
542
- func TestInvalidSSEAudioDeletesTaskAndWorkerContinues(t *testing.T) {
543
- e := NewTaskEngine()
544
- e.synthesizeStreamFn = func(ctx context.Context, cfg Config, text string, voice *VoiceInfo, onAudio func([]byte) error) error {
545
- if text == "bad" {
546
- return parseSSEStream(strings.NewReader("data: {\"audio\":\"***\"}\n\n"), onAudio)
547
- }
548
- return onAudio([]byte(text))
549
- }
550
- e.newStreamPlayerFn = newFakeStreamPlayerFactory()
551
- e.Start()
552
-
553
- cfg := Config{}
554
- voice := VoiceInfo{VoiceType: "v", ResourceID: "r"}
555
- badID := e.Submit("bad", voice, cfg)
556
- waitForTaskDeleted(t, e, badID)
557
-
558
- okID := e.Submit("ok", voice, cfg)
559
- waitForTaskDeleted(t, e, okID)
560
- }
561
-
562
259
  func TestValidateConfigRequiresDefaultVoiceResourceID(t *testing.T) {
563
260
  cfg := Config{
564
261
  APIKey: "key",
@@ -595,59 +292,6 @@ func TestValidateConfigRequiresSourceVoiceResourceID(t *testing.T) {
595
292
  }
596
293
  }
597
294
 
598
- type fakeStreamPlayer struct {
599
- writeErr bool
600
- closeErr bool
601
- panicOnWrite bool
602
- chunks [][]byte
603
- aborted bool
604
- closed bool
605
- closeBlock chan struct{}
606
- closeStarted chan struct{}
607
- closeOnce sync.Once
608
- }
609
-
610
- func newFakeStreamPlayerFactory() func() (StreamPlayer, error) {
611
- return func() (StreamPlayer, error) {
612
- return &fakeStreamPlayer{}, nil
613
- }
614
- }
615
-
616
- func (p *fakeStreamPlayer) Write(audio []byte) error {
617
- if p.panicOnWrite {
618
- panic("boom")
619
- }
620
- if p.writeErr {
621
- return errors.New("write failed")
622
- }
623
- p.chunks = append(p.chunks, append([]byte(nil), audio...))
624
- return nil
625
- }
626
-
627
- func (p *fakeStreamPlayer) CloseAndWait() error {
628
- p.closed = true
629
- if p.closeStarted != nil {
630
- p.closeStarted <- struct{}{}
631
- }
632
- if p.closeBlock != nil {
633
- <-p.closeBlock
634
- }
635
- if p.closeErr {
636
- return errors.New("close failed")
637
- }
638
- return nil
639
- }
640
-
641
- func (p *fakeStreamPlayer) Abort() error {
642
- p.aborted = true
643
- if p.closeBlock != nil {
644
- p.closeOnce.Do(func() {
645
- close(p.closeBlock)
646
- })
647
- }
648
- return nil
649
- }
650
-
651
295
  func TestListenUnixSocketRemovesStalePath(t *testing.T) {
652
296
  socketPath := shortSocketPath(t)
653
297
  if err := os.WriteFile(socketPath, []byte("stale"), 0644); err != nil {
@@ -722,25 +366,3 @@ func shortSocketPath(t *testing.T) string {
722
366
  })
723
367
  return filepath.Join(dir, "sock")
724
368
  }
725
-
726
- func waitForTaskDeleted(t *testing.T, e *TaskEngine, id uint64) {
727
- t.Helper()
728
- waitFor(t, 2*time.Second, func() bool {
729
- e.mu.Lock()
730
- defer e.mu.Unlock()
731
- _, ok := e.tasks[id]
732
- return !ok
733
- })
734
- }
735
-
736
- func waitFor(t *testing.T, timeout time.Duration, fn func() bool) {
737
- t.Helper()
738
- deadline := time.Now().Add(timeout)
739
- for time.Now().Before(deadline) {
740
- if fn() {
741
- return
742
- }
743
- time.Sleep(10 * time.Millisecond)
744
- }
745
- t.Fatalf("condition not met within %s", timeout)
746
- }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xdfnet/ispeak",
3
- "version": "1.6.11",
3
+ "version": "1.6.13",
4
4
  "description": "Local macOS TTS daemon for AI coding assistants, powered by Volcengine streaming TTS.",
5
5
  "license": "MIT",
6
6
  "homepage": "https://github.com/xdfnet/iSpeak#readme",