@xdfnet/ispeak 1.6.8 → 1.6.10
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.
- package/AGENTS.md +103 -0
- package/CLAUDE.md +9 -0
- package/Makefile +120 -0
- package/README.md +1 -1
- package/avaudioengine_player_darwin.go +315 -0
- package/configs/hook-speak.sh +45 -6
- package/docs/hook-text-extraction.md +60 -27
- package/main_test.go +751 -0
- package/package.json +7 -1
- package/scripts/ispeak +1 -1
- package/stream_player_unsupported.go +9 -0
package/main_test.go
ADDED
|
@@ -0,0 +1,751 @@
|
|
|
1
|
+
package main
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"context"
|
|
5
|
+
"encoding/base64"
|
|
6
|
+
"errors"
|
|
7
|
+
"net"
|
|
8
|
+
"os"
|
|
9
|
+
"path/filepath"
|
|
10
|
+
"strings"
|
|
11
|
+
"sync"
|
|
12
|
+
"testing"
|
|
13
|
+
"time"
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
func TestSubmitClearsPendingOnly(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) != 1 {
|
|
32
|
+
t.Fatalf("expected 1 pending, got %d", len(e.pending))
|
|
33
|
+
}
|
|
34
|
+
id := e.pending[0]
|
|
35
|
+
task, ok := e.tasks[id]
|
|
36
|
+
if !ok {
|
|
37
|
+
t.Fatalf("expected pending task exists")
|
|
38
|
+
}
|
|
39
|
+
if task.Text != "b" {
|
|
40
|
+
t.Fatalf("expected latest task text b, got %s", task.Text)
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
func TestTransactionDeletesOnSynthesisFailureWithoutRetry(t *testing.T) {
|
|
45
|
+
e := NewTaskEngine()
|
|
46
|
+
var mu sync.Mutex
|
|
47
|
+
calls := 0
|
|
48
|
+
e.synthesizeStreamFn = func(ctx context.Context, cfg Config, text string, voice *VoiceInfo, onAudio func([]byte) error) error {
|
|
49
|
+
mu.Lock()
|
|
50
|
+
defer mu.Unlock()
|
|
51
|
+
calls++
|
|
52
|
+
return errors.New("fail")
|
|
53
|
+
}
|
|
54
|
+
e.newStreamPlayerFn = newFakeStreamPlayerFactory()
|
|
55
|
+
e.Start()
|
|
56
|
+
|
|
57
|
+
cfg := Config{}
|
|
58
|
+
voice := VoiceInfo{VoiceType: "v", ResourceID: "r"}
|
|
59
|
+
id := e.Submit("x", voice, cfg)
|
|
60
|
+
|
|
61
|
+
waitFor(t, 2*time.Second, func() bool {
|
|
62
|
+
e.mu.Lock()
|
|
63
|
+
defer e.mu.Unlock()
|
|
64
|
+
_, ok := e.tasks[id]
|
|
65
|
+
return !ok
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
mu.Lock()
|
|
69
|
+
defer mu.Unlock()
|
|
70
|
+
if calls != 1 {
|
|
71
|
+
t.Fatalf("expected synth attempts=1, got %d", calls)
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
func TestTransactionDeletesOnPlayerWriteFailureWithoutRetry(t *testing.T) {
|
|
76
|
+
e := NewTaskEngine()
|
|
77
|
+
e.synthesizeStreamFn = func(ctx context.Context, cfg Config, text string, voice *VoiceInfo, onAudio func([]byte) error) error {
|
|
78
|
+
return onAudio([]byte("audio"))
|
|
79
|
+
}
|
|
80
|
+
var mu sync.Mutex
|
|
81
|
+
calls := 0
|
|
82
|
+
e.newStreamPlayerFn = func() (StreamPlayer, error) {
|
|
83
|
+
mu.Lock()
|
|
84
|
+
defer mu.Unlock()
|
|
85
|
+
calls++
|
|
86
|
+
return &fakeStreamPlayer{writeErr: calls == 1}, nil
|
|
87
|
+
}
|
|
88
|
+
e.Start()
|
|
89
|
+
|
|
90
|
+
cfg := Config{}
|
|
91
|
+
voice := VoiceInfo{VoiceType: "v", ResourceID: "r"}
|
|
92
|
+
id := e.Submit("x", voice, cfg)
|
|
93
|
+
|
|
94
|
+
waitFor(t, 2*time.Second, func() bool {
|
|
95
|
+
e.mu.Lock()
|
|
96
|
+
defer e.mu.Unlock()
|
|
97
|
+
_, ok := e.tasks[id]
|
|
98
|
+
return !ok
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
mu.Lock()
|
|
102
|
+
defer mu.Unlock()
|
|
103
|
+
if calls != 1 {
|
|
104
|
+
t.Fatalf("expected player attempts=1, got %d", calls)
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
func TestSubmitDoesNotInterruptRunningTask(t *testing.T) {
|
|
109
|
+
e := NewTaskEngine()
|
|
110
|
+
start := make(chan struct{}, 1)
|
|
111
|
+
release := make(chan struct{})
|
|
112
|
+
var mu sync.Mutex
|
|
113
|
+
processed := make([]string, 0, 2)
|
|
114
|
+
|
|
115
|
+
e.synthesizeStreamFn = func(ctx context.Context, cfg Config, text string, voice *VoiceInfo, onAudio func([]byte) error) error {
|
|
116
|
+
if text == "a" {
|
|
117
|
+
start <- struct{}{}
|
|
118
|
+
}
|
|
119
|
+
if text == "a" {
|
|
120
|
+
<-release
|
|
121
|
+
}
|
|
122
|
+
mu.Lock()
|
|
123
|
+
processed = append(processed, text)
|
|
124
|
+
mu.Unlock()
|
|
125
|
+
return onAudio([]byte(text))
|
|
126
|
+
}
|
|
127
|
+
e.newStreamPlayerFn = newFakeStreamPlayerFactory()
|
|
128
|
+
e.Start()
|
|
129
|
+
|
|
130
|
+
cfg := Config{}
|
|
131
|
+
voice := VoiceInfo{VoiceType: "v", ResourceID: "r"}
|
|
132
|
+
e.Submit("a", voice, cfg)
|
|
133
|
+
<-start // a 已进入 running
|
|
134
|
+
secondID := e.Submit("b", voice, cfg)
|
|
135
|
+
|
|
136
|
+
e.mu.Lock()
|
|
137
|
+
if len(e.pending) != 1 || e.pending[0] != secondID {
|
|
138
|
+
t.Fatalf("expected second task pending while first keeps running, got %#v", e.pending)
|
|
139
|
+
}
|
|
140
|
+
e.mu.Unlock()
|
|
141
|
+
|
|
142
|
+
close(release)
|
|
143
|
+
|
|
144
|
+
waitFor(t, 3*time.Second, func() bool {
|
|
145
|
+
e.mu.Lock()
|
|
146
|
+
defer e.mu.Unlock()
|
|
147
|
+
return len(e.tasks) == 0
|
|
148
|
+
})
|
|
149
|
+
|
|
150
|
+
mu.Lock()
|
|
151
|
+
defer mu.Unlock()
|
|
152
|
+
if strings.Join(processed, "") != "ab" {
|
|
153
|
+
t.Fatalf("expected processed [a b], got %#v", processed)
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
func TestClaimedStaleTaskIsSkippedBeforeTransaction(t *testing.T) {
|
|
158
|
+
e := NewTaskEngine()
|
|
159
|
+
calls := 0
|
|
160
|
+
e.synthesizeStreamFn = func(ctx context.Context, cfg Config, text string, voice *VoiceInfo, onAudio func([]byte) error) error {
|
|
161
|
+
calls++
|
|
162
|
+
return onAudio([]byte(text))
|
|
163
|
+
}
|
|
164
|
+
e.newStreamPlayerFn = newFakeStreamPlayerFactory()
|
|
165
|
+
|
|
166
|
+
cfg := Config{}
|
|
167
|
+
voice := VoiceInfo{VoiceType: "v", ResourceID: "r"}
|
|
168
|
+
firstID := e.Submit("a", voice, cfg)
|
|
169
|
+
claimedID := e.claimPending()
|
|
170
|
+
if claimedID != firstID {
|
|
171
|
+
t.Fatalf("expected claimed first task %d, got %d", firstID, claimedID)
|
|
172
|
+
}
|
|
173
|
+
e.Submit("b", voice, cfg)
|
|
174
|
+
|
|
175
|
+
e.processTransaction(firstID)
|
|
176
|
+
|
|
177
|
+
if calls != 0 {
|
|
178
|
+
t.Fatalf("expected stale task skipped before transaction, got calls=%d", calls)
|
|
179
|
+
}
|
|
180
|
+
e.mu.Lock()
|
|
181
|
+
_, firstExists := e.tasks[firstID]
|
|
182
|
+
e.mu.Unlock()
|
|
183
|
+
if firstExists {
|
|
184
|
+
t.Fatalf("expected stale task deleted")
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
func TestSynthesisPanicDeletesTaskAndWorkerContinues(t *testing.T) {
|
|
189
|
+
e := NewTaskEngine()
|
|
190
|
+
e.synthesizeStreamFn = func(ctx context.Context, cfg Config, text string, voice *VoiceInfo, onAudio func([]byte) error) error {
|
|
191
|
+
if text == "panic" {
|
|
192
|
+
panic("boom")
|
|
193
|
+
}
|
|
194
|
+
return onAudio([]byte(text))
|
|
195
|
+
}
|
|
196
|
+
e.newStreamPlayerFn = newFakeStreamPlayerFactory()
|
|
197
|
+
e.Start()
|
|
198
|
+
|
|
199
|
+
cfg := Config{}
|
|
200
|
+
voice := VoiceInfo{VoiceType: "v", ResourceID: "r"}
|
|
201
|
+
panicID := e.Submit("panic", voice, cfg)
|
|
202
|
+
waitForTaskDeleted(t, e, panicID)
|
|
203
|
+
|
|
204
|
+
okID := e.Submit("ok", voice, cfg)
|
|
205
|
+
waitForTaskDeleted(t, e, okID)
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
func TestPlaybackPanicDeletesTaskAndWorkerContinues(t *testing.T) {
|
|
209
|
+
e := NewTaskEngine()
|
|
210
|
+
e.synthesizeStreamFn = func(ctx context.Context, cfg Config, text string, voice *VoiceInfo, onAudio func([]byte) error) error {
|
|
211
|
+
return onAudio([]byte(text))
|
|
212
|
+
}
|
|
213
|
+
var mu sync.Mutex
|
|
214
|
+
calls := 0
|
|
215
|
+
e.newStreamPlayerFn = func() (StreamPlayer, error) {
|
|
216
|
+
mu.Lock()
|
|
217
|
+
defer mu.Unlock()
|
|
218
|
+
calls++
|
|
219
|
+
return &fakeStreamPlayer{panicOnWrite: calls == 1}, nil
|
|
220
|
+
}
|
|
221
|
+
e.Start()
|
|
222
|
+
|
|
223
|
+
cfg := Config{}
|
|
224
|
+
voice := VoiceInfo{VoiceType: "v", ResourceID: "r"}
|
|
225
|
+
panicID := e.Submit("panic", voice, cfg)
|
|
226
|
+
waitForTaskDeleted(t, e, panicID)
|
|
227
|
+
|
|
228
|
+
okID := e.Submit("ok", voice, cfg)
|
|
229
|
+
waitForTaskDeleted(t, e, okID)
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
func TestParseSSEStreamWritesChunksInOrder(t *testing.T) {
|
|
233
|
+
stream := strings.NewReader(
|
|
234
|
+
"data: {\"audio\":\"YQ==\"}\n\n" +
|
|
235
|
+
"data: {\"data\":{\"audio\":\"Yg==\"}}\n\n" +
|
|
236
|
+
"data: [DONE]\n\n",
|
|
237
|
+
)
|
|
238
|
+
|
|
239
|
+
var got []string
|
|
240
|
+
err := parseSSEStream(stream, func(audio []byte) error {
|
|
241
|
+
got = append(got, string(audio))
|
|
242
|
+
return nil
|
|
243
|
+
})
|
|
244
|
+
if err != nil {
|
|
245
|
+
t.Fatalf("parse stream: %v", err)
|
|
246
|
+
}
|
|
247
|
+
if strings.Join(got, "") != "ab" {
|
|
248
|
+
t.Fatalf("expected chunks ab, got %#v", got)
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
func TestParseSSEStreamHandlesLargeAudioLine(t *testing.T) {
|
|
253
|
+
payload := strings.Repeat("a", 300*1024)
|
|
254
|
+
encoded := base64.StdEncoding.EncodeToString([]byte(payload))
|
|
255
|
+
stream := strings.NewReader("data: {\"audio\":\"" + encoded + "\"}\n\n")
|
|
256
|
+
|
|
257
|
+
var got []byte
|
|
258
|
+
err := parseSSEStream(stream, func(audio []byte) error {
|
|
259
|
+
got = append(got, audio...)
|
|
260
|
+
return nil
|
|
261
|
+
})
|
|
262
|
+
if err != nil {
|
|
263
|
+
t.Fatalf("parse stream: %v", err)
|
|
264
|
+
}
|
|
265
|
+
if string(got) != payload {
|
|
266
|
+
t.Fatalf("expected large payload preserved, got len=%d", len(got))
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
func TestParseSSEStreamReturnsTTSFailureMessage(t *testing.T) {
|
|
271
|
+
stream := strings.NewReader("event: 153\ndata: {\"code\":55001307,\"message\":\"voice clone failed\",\"data\":null}\n\n")
|
|
272
|
+
|
|
273
|
+
err := parseSSEStream(stream, func(audio []byte) error {
|
|
274
|
+
t.Fatalf("unexpected audio callback")
|
|
275
|
+
return nil
|
|
276
|
+
})
|
|
277
|
+
if err == nil {
|
|
278
|
+
t.Fatalf("expected tts failure")
|
|
279
|
+
}
|
|
280
|
+
if !strings.Contains(err.Error(), "55001307") || !strings.Contains(err.Error(), "voice clone failed") {
|
|
281
|
+
t.Fatalf("expected code and message in error, got %v", err)
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
func TestCleanTextRemovesSpeechNoise(t *testing.T) {
|
|
286
|
+
input := strings.Join([]string{
|
|
287
|
+
"## 结果",
|
|
288
|
+
"- **验证通过**:[main.go](/Users/admin/iCode/iSpeak/main.go:123)",
|
|
289
|
+
"- commit: a97e57d Improve latest-only task handling",
|
|
290
|
+
"- 路径:/Users/admin/iCode/iSpeak/main.go",
|
|
291
|
+
"| 文件 | 状态 |",
|
|
292
|
+
"|------|------|",
|
|
293
|
+
"| model-00001.safetensors | ✅ 完整 |",
|
|
294
|
+
"```go",
|
|
295
|
+
"fmt.Println(\"不要播代码\")",
|
|
296
|
+
"```",
|
|
297
|
+
"https://example.com/path",
|
|
298
|
+
"飞哥,需要你重启服务。",
|
|
299
|
+
}, "\n")
|
|
300
|
+
|
|
301
|
+
got := cleanText(input)
|
|
302
|
+
for _, bad := range []string{
|
|
303
|
+
"**",
|
|
304
|
+
"`",
|
|
305
|
+
"/Users/admin",
|
|
306
|
+
"https://",
|
|
307
|
+
"fmt.Println",
|
|
308
|
+
"safetensors",
|
|
309
|
+
"文件",
|
|
310
|
+
"状态",
|
|
311
|
+
"完整",
|
|
312
|
+
"|------|",
|
|
313
|
+
"a97e57d",
|
|
314
|
+
} {
|
|
315
|
+
if strings.Contains(got, bad) {
|
|
316
|
+
t.Fatalf("expected cleaned text not to contain %q, got %q", bad, got)
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
for _, want := range []string{
|
|
320
|
+
"结果",
|
|
321
|
+
"验证通过",
|
|
322
|
+
"main.go",
|
|
323
|
+
"路径",
|
|
324
|
+
"飞哥,需要你重启服务。",
|
|
325
|
+
} {
|
|
326
|
+
if !strings.Contains(got, want) {
|
|
327
|
+
t.Fatalf("expected cleaned text to contain %q, got %q", want, got)
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
func TestCleanTextOrderingPreservesLinkTitleBeforeRemovingURL(t *testing.T) {
|
|
333
|
+
got := cleanText("参考:[架构文档](https://example.com/docs)。")
|
|
334
|
+
if !strings.Contains(got, "架构文档") {
|
|
335
|
+
t.Fatalf("expected link title preserved, got %q", got)
|
|
336
|
+
}
|
|
337
|
+
if strings.Contains(got, "https://") || strings.Contains(got, "example.com") {
|
|
338
|
+
t.Fatalf("expected URL removed, got %q", got)
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
func TestCleanTextOrderingSkipsCodeBeforePathAndTableRules(t *testing.T) {
|
|
343
|
+
input := strings.Join([]string{
|
|
344
|
+
"结论保留。",
|
|
345
|
+
"```text",
|
|
346
|
+
"| 不该 | 播 |",
|
|
347
|
+
"/Users/admin/iCode/iSpeak/main.go",
|
|
348
|
+
"```",
|
|
349
|
+
"后续保留。",
|
|
350
|
+
}, "\n")
|
|
351
|
+
|
|
352
|
+
got := cleanText(input)
|
|
353
|
+
for _, bad := range []string{"不该", "/Users/admin", "main.go"} {
|
|
354
|
+
if strings.Contains(got, bad) {
|
|
355
|
+
t.Fatalf("expected code block removed before inline cleaning, got %q", got)
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
if !strings.Contains(got, "结论保留。") || !strings.Contains(got, "后续保留。") {
|
|
359
|
+
t.Fatalf("expected surrounding text preserved, got %q", got)
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
func TestCleanTextOrderingRemovesTableHeaderWhenSeparatorAppears(t *testing.T) {
|
|
364
|
+
input := strings.Join([]string{
|
|
365
|
+
"前言。",
|
|
366
|
+
"| 文件 | 状态 |",
|
|
367
|
+
"|---|---|",
|
|
368
|
+
"| main.go | 通过 |",
|
|
369
|
+
"结论。",
|
|
370
|
+
}, "\n")
|
|
371
|
+
|
|
372
|
+
got := cleanText(input)
|
|
373
|
+
for _, bad := range []string{"文件", "状态", "main.go"} {
|
|
374
|
+
if strings.Contains(got, bad) {
|
|
375
|
+
t.Fatalf("expected table header/content removed, got %q", got)
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
if !strings.Contains(got, "前言。") || !strings.Contains(got, "结论。") {
|
|
379
|
+
t.Fatalf("expected surrounding text preserved, got %q", got)
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
func TestCleanTextOrderingRemovesUUIDBeforeCommitHash(t *testing.T) {
|
|
384
|
+
got := cleanText("请求 ID:123e4567-e89b-12d3-a456-426614174000,状态成功。")
|
|
385
|
+
if strings.Contains(got, "123e4567") || strings.Contains(got, "426614174000") {
|
|
386
|
+
t.Fatalf("expected UUID removed as a whole, got %q", got)
|
|
387
|
+
}
|
|
388
|
+
if !strings.Contains(got, "状态成功。") {
|
|
389
|
+
t.Fatalf("expected conclusion preserved, got %q", got)
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
func TestCleanTextSkipsWholeMarkdownTable(t *testing.T) {
|
|
394
|
+
input := strings.Join([]string{
|
|
395
|
+
"表格如下:",
|
|
396
|
+
"| 文件 | 状态 |",
|
|
397
|
+
"|------|------|",
|
|
398
|
+
"| main.go | 通过 |",
|
|
399
|
+
"| main_test.go | 通过 |",
|
|
400
|
+
"结论:验证通过。",
|
|
401
|
+
}, "\n")
|
|
402
|
+
|
|
403
|
+
got := cleanText(input)
|
|
404
|
+
for _, bad := range []string{"文件", "状态", "main.go", "main_test.go"} {
|
|
405
|
+
if strings.Contains(got, bad) {
|
|
406
|
+
t.Fatalf("expected table content removed, got %q", got)
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
if !strings.Contains(got, "表格如下:") || !strings.Contains(got, "结论:验证通过。") {
|
|
410
|
+
t.Fatalf("expected surrounding text preserved, got %q", got)
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
func TestCleanTextSkipsArtifactAndHTML(t *testing.T) {
|
|
415
|
+
input := strings.Join([]string{
|
|
416
|
+
"这是前置结论。",
|
|
417
|
+
`<artifact identifier="demo" type="text/html">`,
|
|
418
|
+
"<!doctype html>",
|
|
419
|
+
"<html><body>不要播 HTML</body></html>",
|
|
420
|
+
"</artifact>",
|
|
421
|
+
"这是后置结论。",
|
|
422
|
+
}, "\n")
|
|
423
|
+
|
|
424
|
+
got := cleanText(input)
|
|
425
|
+
if strings.Contains(got, "HTML") || strings.Contains(got, "artifact") {
|
|
426
|
+
t.Fatalf("expected artifact/html removed, got %q", got)
|
|
427
|
+
}
|
|
428
|
+
if !strings.Contains(got, "这是前置结论。") || !strings.Contains(got, "这是后置结论。") {
|
|
429
|
+
t.Fatalf("expected surrounding conclusions preserved, got %q", got)
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
func TestCleanTextKeepsChinesePercentConclusion(t *testing.T) {
|
|
434
|
+
input := strings.Join([]string{
|
|
435
|
+
"下载 42% 12MB/s eta 1m",
|
|
436
|
+
"测试通过率 95%,可以发布。",
|
|
437
|
+
}, "\n")
|
|
438
|
+
|
|
439
|
+
got := cleanText(input)
|
|
440
|
+
if strings.Contains(got, "12MB/s") || strings.Contains(got, "eta") {
|
|
441
|
+
t.Fatalf("expected progress noise removed, got %q", got)
|
|
442
|
+
}
|
|
443
|
+
if !strings.Contains(got, "测试通过率 95%,可以发布。") {
|
|
444
|
+
t.Fatalf("expected Chinese percent conclusion preserved, got %q", got)
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
func TestCleanTextKeepsPlainPercentLine(t *testing.T) {
|
|
449
|
+
got := cleanText("覆盖率 95%")
|
|
450
|
+
if !strings.Contains(got, "覆盖率 95%") {
|
|
451
|
+
t.Fatalf("expected plain percent line preserved, got %q", got)
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
func TestCleanTextKeepsOrdinaryFileReferenceLine(t *testing.T) {
|
|
456
|
+
got := cleanText("已更新 main.go 和 README.md。")
|
|
457
|
+
if !strings.Contains(got, "main.go") || !strings.Contains(got, "README.md") {
|
|
458
|
+
t.Fatalf("expected ordinary file references preserved, got %q", got)
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
func TestCleanTextSingleLineArtifactDoesNotSwallowFollowingText(t *testing.T) {
|
|
463
|
+
input := strings.Join([]string{
|
|
464
|
+
`<artifact identifier="demo">不要播</artifact>`,
|
|
465
|
+
"后面的结论要保留。",
|
|
466
|
+
}, "\n")
|
|
467
|
+
|
|
468
|
+
got := cleanText(input)
|
|
469
|
+
if strings.Contains(got, "不要播") || strings.Contains(got, "artifact") {
|
|
470
|
+
t.Fatalf("expected single-line artifact removed, got %q", got)
|
|
471
|
+
}
|
|
472
|
+
if !strings.Contains(got, "后面的结论要保留。") {
|
|
473
|
+
t.Fatalf("expected following text preserved, got %q", got)
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
func TestHandleConnectionPreservesMultilineBeforeCleaning(t *testing.T) {
|
|
478
|
+
oldConfigDir := configDir
|
|
479
|
+
oldCacheValid := configCacheValid
|
|
480
|
+
oldCachePath := configCachePath
|
|
481
|
+
oldCacheModTime := configCacheModTime
|
|
482
|
+
oldCache := configCache
|
|
483
|
+
t.Cleanup(func() {
|
|
484
|
+
configDir = oldConfigDir
|
|
485
|
+
configCacheValid = oldCacheValid
|
|
486
|
+
configCachePath = oldCachePath
|
|
487
|
+
configCacheModTime = oldCacheModTime
|
|
488
|
+
configCache = oldCache
|
|
489
|
+
})
|
|
490
|
+
|
|
491
|
+
dir := t.TempDir()
|
|
492
|
+
configDir = dir
|
|
493
|
+
configCacheValid = false
|
|
494
|
+
cfg := `{
|
|
495
|
+
"apiKey": "key",
|
|
496
|
+
"endpoint": "https://example.com/tts",
|
|
497
|
+
"defaultVoice": {"voice_type": "voice", "resourceId": "resource"}
|
|
498
|
+
}`
|
|
499
|
+
if err := os.WriteFile(filepath.Join(dir, "config.json"), []byte(cfg), 0644); err != nil {
|
|
500
|
+
t.Fatalf("write config: %v", err)
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
server, client := net.Pipe()
|
|
504
|
+
e := NewTaskEngine()
|
|
505
|
+
done := make(chan struct{})
|
|
506
|
+
go func() {
|
|
507
|
+
handleConnection(server, e)
|
|
508
|
+
close(done)
|
|
509
|
+
}()
|
|
510
|
+
|
|
511
|
+
msg := strings.Join([]string{
|
|
512
|
+
"不是,飞哥。",
|
|
513
|
+
"",
|
|
514
|
+
"| 部分 | 是否常驻 |",
|
|
515
|
+
"|---|---|",
|
|
516
|
+
"| ispeakd | 是 |",
|
|
517
|
+
"",
|
|
518
|
+
"也就是说:daemon 常驻,播放器不是常驻。",
|
|
519
|
+
}, "\n")
|
|
520
|
+
if _, err := client.Write([]byte(msg)); err != nil {
|
|
521
|
+
t.Fatalf("write client: %v", err)
|
|
522
|
+
}
|
|
523
|
+
if err := client.Close(); err != nil {
|
|
524
|
+
t.Fatalf("close client: %v", err)
|
|
525
|
+
}
|
|
526
|
+
select {
|
|
527
|
+
case <-done:
|
|
528
|
+
case <-time.After(time.Second):
|
|
529
|
+
t.Fatalf("handleConnection did not return")
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
e.mu.Lock()
|
|
533
|
+
defer e.mu.Unlock()
|
|
534
|
+
if len(e.pending) != 1 {
|
|
535
|
+
t.Fatalf("expected one pending task, got %d", len(e.pending))
|
|
536
|
+
}
|
|
537
|
+
task := e.tasks[e.pending[0]]
|
|
538
|
+
if !strings.Contains(task.Text, "不是,飞哥。") ||
|
|
539
|
+
!strings.Contains(task.Text, "也就是说:daemon 常驻,播放器不是常驻。") {
|
|
540
|
+
t.Fatalf("expected surrounding text preserved, got %q", task.Text)
|
|
541
|
+
}
|
|
542
|
+
if strings.Contains(task.Text, "是否常驻") || strings.Contains(task.Text, "ispeakd | 是") {
|
|
543
|
+
t.Fatalf("expected table removed, got %q", task.Text)
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
func TestInvalidSSEAudioDeletesTaskAndWorkerContinues(t *testing.T) {
|
|
548
|
+
e := NewTaskEngine()
|
|
549
|
+
e.synthesizeStreamFn = func(ctx context.Context, cfg Config, text string, voice *VoiceInfo, onAudio func([]byte) error) error {
|
|
550
|
+
if text == "bad" {
|
|
551
|
+
return parseSSEStream(strings.NewReader("data: {\"audio\":\"***\"}\n\n"), onAudio)
|
|
552
|
+
}
|
|
553
|
+
return onAudio([]byte(text))
|
|
554
|
+
}
|
|
555
|
+
e.newStreamPlayerFn = newFakeStreamPlayerFactory()
|
|
556
|
+
e.Start()
|
|
557
|
+
|
|
558
|
+
cfg := Config{}
|
|
559
|
+
voice := VoiceInfo{VoiceType: "v", ResourceID: "r"}
|
|
560
|
+
badID := e.Submit("bad", voice, cfg)
|
|
561
|
+
waitForTaskDeleted(t, e, badID)
|
|
562
|
+
|
|
563
|
+
okID := e.Submit("ok", voice, cfg)
|
|
564
|
+
waitForTaskDeleted(t, e, okID)
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
func TestValidateConfigRequiresDefaultVoiceResourceID(t *testing.T) {
|
|
568
|
+
cfg := Config{
|
|
569
|
+
APIKey: "key",
|
|
570
|
+
Endpoint: "https://example.com/tts",
|
|
571
|
+
DefaultVoice: &VoiceInfo{
|
|
572
|
+
VoiceType: "voice",
|
|
573
|
+
},
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
err := validateConfig(cfg)
|
|
577
|
+
if err == nil || !strings.Contains(err.Error(), "defaultVoice.resourceId") {
|
|
578
|
+
t.Fatalf("expected defaultVoice.resourceId error, got %v", err)
|
|
579
|
+
}
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
func TestValidateConfigRequiresSourceVoiceResourceID(t *testing.T) {
|
|
583
|
+
cfg := Config{
|
|
584
|
+
APIKey: "key",
|
|
585
|
+
Endpoint: "https://example.com/tts",
|
|
586
|
+
DefaultVoice: &VoiceInfo{
|
|
587
|
+
VoiceType: "voice",
|
|
588
|
+
ResourceID: "resource",
|
|
589
|
+
},
|
|
590
|
+
SourceVoices: map[string]*VoiceInfo{
|
|
591
|
+
"codex": {
|
|
592
|
+
VoiceType: "codex-voice",
|
|
593
|
+
},
|
|
594
|
+
},
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
err := validateConfig(cfg)
|
|
598
|
+
if err == nil || !strings.Contains(err.Error(), "sourceVoices.codex.resourceId") {
|
|
599
|
+
t.Fatalf("expected sourceVoices.codex.resourceId error, got %v", err)
|
|
600
|
+
}
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
type fakeStreamPlayer struct {
|
|
604
|
+
writeErr bool
|
|
605
|
+
closeErr bool
|
|
606
|
+
panicOnWrite bool
|
|
607
|
+
chunks [][]byte
|
|
608
|
+
aborted bool
|
|
609
|
+
closed bool
|
|
610
|
+
closeBlock chan struct{}
|
|
611
|
+
closeStarted chan struct{}
|
|
612
|
+
closeOnce sync.Once
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
func newFakeStreamPlayerFactory() func() (StreamPlayer, error) {
|
|
616
|
+
return func() (StreamPlayer, error) {
|
|
617
|
+
return &fakeStreamPlayer{}, nil
|
|
618
|
+
}
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
func (p *fakeStreamPlayer) Write(audio []byte) error {
|
|
622
|
+
if p.panicOnWrite {
|
|
623
|
+
panic("boom")
|
|
624
|
+
}
|
|
625
|
+
if p.writeErr {
|
|
626
|
+
return errors.New("write failed")
|
|
627
|
+
}
|
|
628
|
+
p.chunks = append(p.chunks, append([]byte(nil), audio...))
|
|
629
|
+
return nil
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
func (p *fakeStreamPlayer) CloseAndWait() error {
|
|
633
|
+
p.closed = true
|
|
634
|
+
if p.closeStarted != nil {
|
|
635
|
+
p.closeStarted <- struct{}{}
|
|
636
|
+
}
|
|
637
|
+
if p.closeBlock != nil {
|
|
638
|
+
<-p.closeBlock
|
|
639
|
+
}
|
|
640
|
+
if p.closeErr {
|
|
641
|
+
return errors.New("close failed")
|
|
642
|
+
}
|
|
643
|
+
return nil
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
func (p *fakeStreamPlayer) Abort() error {
|
|
647
|
+
p.aborted = true
|
|
648
|
+
if p.closeBlock != nil {
|
|
649
|
+
p.closeOnce.Do(func() {
|
|
650
|
+
close(p.closeBlock)
|
|
651
|
+
})
|
|
652
|
+
}
|
|
653
|
+
return nil
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
func TestListenUnixSocketRemovesStalePath(t *testing.T) {
|
|
657
|
+
socketPath := shortSocketPath(t)
|
|
658
|
+
if err := os.WriteFile(socketPath, []byte("stale"), 0644); err != nil {
|
|
659
|
+
t.Fatalf("write stale socket path: %v", err)
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
listener, err := listenUnixSocket(socketPath)
|
|
663
|
+
if err != nil {
|
|
664
|
+
t.Fatalf("listen with stale socket path: %v", err)
|
|
665
|
+
}
|
|
666
|
+
defer listener.Close()
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
func TestListenUnixSocketDetectsRunningInstance(t *testing.T) {
|
|
670
|
+
socketPath := shortSocketPath(t)
|
|
671
|
+
listener, err := listenUnixSocket(socketPath)
|
|
672
|
+
if err != nil {
|
|
673
|
+
t.Fatalf("first listen: %v", err)
|
|
674
|
+
}
|
|
675
|
+
defer listener.Close()
|
|
676
|
+
|
|
677
|
+
done := make(chan struct{})
|
|
678
|
+
go func() {
|
|
679
|
+
conn, err := listener.Accept()
|
|
680
|
+
if err == nil {
|
|
681
|
+
_ = conn.Close()
|
|
682
|
+
}
|
|
683
|
+
close(done)
|
|
684
|
+
}()
|
|
685
|
+
|
|
686
|
+
second, err := listenUnixSocket(socketPath)
|
|
687
|
+
if second != nil {
|
|
688
|
+
_ = second.Close()
|
|
689
|
+
}
|
|
690
|
+
if !errors.Is(err, errAlreadyRunning) {
|
|
691
|
+
t.Fatalf("expected errAlreadyRunning, got %v", err)
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
select {
|
|
695
|
+
case <-done:
|
|
696
|
+
case <-time.After(time.Second):
|
|
697
|
+
t.Fatalf("test listener did not accept probe connection")
|
|
698
|
+
}
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
func TestListenUnixSocketRemovesClosedListenerSocket(t *testing.T) {
|
|
702
|
+
socketPath := shortSocketPath(t)
|
|
703
|
+
stale, err := net.Listen("unix", socketPath)
|
|
704
|
+
if err != nil {
|
|
705
|
+
t.Fatalf("create stale listener: %v", err)
|
|
706
|
+
}
|
|
707
|
+
if err := stale.Close(); err != nil {
|
|
708
|
+
t.Fatalf("close stale listener: %v", err)
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
listener, err := listenUnixSocket(socketPath)
|
|
712
|
+
if err != nil {
|
|
713
|
+
t.Fatalf("listen after stale listener close: %v", err)
|
|
714
|
+
}
|
|
715
|
+
defer listener.Close()
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
func shortSocketPath(t *testing.T) string {
|
|
719
|
+
t.Helper()
|
|
720
|
+
|
|
721
|
+
dir, err := os.MkdirTemp("/tmp", "ispeak-*")
|
|
722
|
+
if err != nil {
|
|
723
|
+
t.Fatalf("create temp dir: %v", err)
|
|
724
|
+
}
|
|
725
|
+
t.Cleanup(func() {
|
|
726
|
+
_ = os.RemoveAll(dir)
|
|
727
|
+
})
|
|
728
|
+
return filepath.Join(dir, "sock")
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
func waitForTaskDeleted(t *testing.T, e *TaskEngine, id uint64) {
|
|
732
|
+
t.Helper()
|
|
733
|
+
waitFor(t, 2*time.Second, func() bool {
|
|
734
|
+
e.mu.Lock()
|
|
735
|
+
defer e.mu.Unlock()
|
|
736
|
+
_, ok := e.tasks[id]
|
|
737
|
+
return !ok
|
|
738
|
+
})
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
func waitFor(t *testing.T, timeout time.Duration, fn func() bool) {
|
|
742
|
+
t.Helper()
|
|
743
|
+
deadline := time.Now().Add(timeout)
|
|
744
|
+
for time.Now().Before(deadline) {
|
|
745
|
+
if fn() {
|
|
746
|
+
return
|
|
747
|
+
}
|
|
748
|
+
time.Sleep(10 * time.Millisecond)
|
|
749
|
+
}
|
|
750
|
+
t.Fatalf("condition not met within %s", timeout)
|
|
751
|
+
}
|