@xdfnet/ispeak 1.6.3 → 1.6.4
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/Docs/ARCHITECTURE.md +27 -27
- package/README.md +5 -5
- package/main.go +35 -82
- package/package.json +1 -1
package/Docs/ARCHITECTURE.md
CHANGED
|
@@ -4,8 +4,8 @@
|
|
|
4
4
|
|
|
5
5
|
iSpeak 是一个运行在 macOS 上的本地 TTS 播报守护进程,通过 Unix Socket 接收文本,调用火山引擎 TTS 流式 API,边合成边播放。
|
|
6
6
|
|
|
7
|
-
当前版本采用“任务仓库 + 单
|
|
8
|
-
-
|
|
7
|
+
当前版本采用“任务仓库 + 单 transaction worker”流式链路:
|
|
8
|
+
- transaction worker:领取待执行任务,SSE 每到一段音频就写入播放器 stdin
|
|
9
9
|
- 播放器优先使用 `ffplay -i pipe:0`,没有 `ffplay` 时回退到完整音频 `afplay`
|
|
10
10
|
|
|
11
11
|
## 系统架构
|
|
@@ -28,15 +28,15 @@ iSpeak 是一个运行在 macOS 上的本地 TTS 播报守护进程,通过 Uni
|
|
|
28
28
|
│ ┌───────────────────────────────────────────────────────┐ │
|
|
29
29
|
│ │ Task Repository (in-memory) │ │
|
|
30
30
|
│ │ - tasks: map[uint64]*Task │ │
|
|
31
|
-
│ │ -
|
|
31
|
+
│ │ - pending: []uint64 (FIFO) │ │
|
|
32
32
|
│ └───────────────────────────────────────────────────────┘ │
|
|
33
33
|
│ │ │
|
|
34
34
|
│ ▼ │
|
|
35
|
-
│
|
|
36
|
-
│ -
|
|
35
|
+
│ Transaction Worker (single) │
|
|
36
|
+
│ - pending -> running │
|
|
37
37
|
│ - 调用 TTS 流式接口(失败直接删除,不重试) │
|
|
38
38
|
│ - SSE audio chunk -> StreamPlayer.Write │
|
|
39
|
-
│ -
|
|
39
|
+
│ - 播放完成后删除任务;失败直接删除任务 │
|
|
40
40
|
│ │
|
|
41
41
|
└─────────────────────────────────────────────────────────────┘
|
|
42
42
|
```
|
|
@@ -48,7 +48,7 @@ iSpeak 是一个运行在 macOS 上的本地 TTS 播报守护进程,通过 Uni
|
|
|
48
48
|
```go
|
|
49
49
|
type Task struct {
|
|
50
50
|
ID uint64 // 任务 ID(递增)
|
|
51
|
-
Text string //
|
|
51
|
+
Text string // 过滤后的待执行文本
|
|
52
52
|
Status TaskStatus // 当前状态
|
|
53
53
|
Voice VoiceInfo // 任务音色快照
|
|
54
54
|
Cfg Config // 任务配置快照(提交时)
|
|
@@ -59,8 +59,8 @@ type Task struct {
|
|
|
59
59
|
|
|
60
60
|
```go
|
|
61
61
|
const (
|
|
62
|
-
|
|
63
|
-
|
|
62
|
+
TaskStatusPending TaskStatus = iota // 待执行
|
|
63
|
+
TaskStatusRunning // 合成播放事务执行中
|
|
64
64
|
)
|
|
65
65
|
```
|
|
66
66
|
|
|
@@ -76,9 +76,9 @@ type TaskEngine struct {
|
|
|
76
76
|
|
|
77
77
|
nextID uint64
|
|
78
78
|
tasks map[uint64]*Task
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
79
|
+
latestID uint64
|
|
80
|
+
pending []uint64
|
|
81
|
+
wake chan struct{}
|
|
82
82
|
|
|
83
83
|
synthesizeStreamFn func(ctx context.Context, cfg Config, text string, voice *VoiceInfo, onAudio func([]byte) error) error
|
|
84
84
|
newStreamPlayerFn func() (StreamPlayer, error)
|
|
@@ -100,30 +100,30 @@ type StreamPlayer interface {
|
|
|
100
100
|
### 状态流转
|
|
101
101
|
|
|
102
102
|
```
|
|
103
|
-
|
|
103
|
+
pending -> running -> delete
|
|
104
104
|
```
|
|
105
105
|
|
|
106
106
|
### 任务提交(核心规则)
|
|
107
107
|
|
|
108
108
|
`Submit(cleanedText, voice, cfg)` 原子执行:
|
|
109
|
-
1. 删除所有 `
|
|
110
|
-
2.
|
|
111
|
-
3. 创建新任务(`
|
|
112
|
-
4. 唤醒
|
|
109
|
+
1. 删除所有 `pending` 任务
|
|
110
|
+
2. 不打断当前 `running` 事务
|
|
111
|
+
3. 创建新任务(`pending`)
|
|
112
|
+
4. 唤醒 transaction worker
|
|
113
113
|
|
|
114
114
|
策略说明:
|
|
115
|
-
-
|
|
116
|
-
-
|
|
117
|
-
-
|
|
115
|
+
- 未开始的旧任务直接删除
|
|
116
|
+
- 已领取但过期的旧任务在事务执行前跳过
|
|
117
|
+
- 正在合成/播放的任务自然结束
|
|
118
118
|
|
|
119
|
-
###
|
|
119
|
+
### Transaction worker 规则
|
|
120
120
|
|
|
121
|
-
1. FIFO 领取 `
|
|
121
|
+
1. FIFO 领取 `pending` 任务并置 `running`
|
|
122
122
|
2. 启动 `StreamPlayer`
|
|
123
123
|
3. 调用 TTS 流式接口,SSE 每解析出一个音频 chunk 就写入播放器
|
|
124
124
|
4. TTS 结束后关闭播放器 stdin 并等待播放结束
|
|
125
125
|
5. 成功:删除任务
|
|
126
|
-
6.
|
|
126
|
+
6. 失败:删除任务,不重试
|
|
127
127
|
|
|
128
128
|
## 消息流程
|
|
129
129
|
|
|
@@ -150,7 +150,7 @@ pending_synth -> speaking -> delete
|
|
|
150
150
|
|
|
151
151
|
### 2. 流式合成播放阶段
|
|
152
152
|
|
|
153
|
-
-
|
|
153
|
+
- transaction worker 领取任务
|
|
154
154
|
- HTTP POST 火山引擎 TTS 接口
|
|
155
155
|
- 解析 SSE 流并 base64 解码音频 chunk
|
|
156
156
|
- 优先将 chunk 写入 `ffplay` stdin 实时播放
|
|
@@ -160,13 +160,13 @@ pending_synth -> speaking -> delete
|
|
|
160
160
|
## 并发与一致性
|
|
161
161
|
|
|
162
162
|
- 单引擎锁 `mu` 保护任务仓库与 FIFO 队列
|
|
163
|
-
- 单
|
|
164
|
-
- `
|
|
163
|
+
- 单 transaction worker,保证播报顺序稳定
|
|
164
|
+
- `wake` 为缓冲 1 的唤醒信号,防止重复唤醒堆积
|
|
165
165
|
- FIFO 保证未开始任务公平顺序
|
|
166
166
|
|
|
167
167
|
## 失败与成本策略
|
|
168
168
|
|
|
169
|
-
-
|
|
169
|
+
- 新任务到达时只清理 `pending`,不打断当前任务
|
|
170
170
|
- 流式合成/播放失败:直接删除任务,不重试,避免重复播报
|
|
171
171
|
- 只保留最新消息优先播报,降低 TTS 成本
|
|
172
172
|
|
package/README.md
CHANGED
|
@@ -26,8 +26,8 @@ ispeak-codex "构建完成,耗时 12 秒"
|
|
|
26
26
|
|
|
27
27
|
| 问题 | 方案 |
|
|
28
28
|
|------|------|
|
|
29
|
-
| AI 生成多条回复,TTS 账单飞涨 |
|
|
30
|
-
| 回复快慢不一,音频播报乱序 | 单
|
|
29
|
+
| AI 生成多条回复,TTS 账单飞涨 | 新消息只保留最新待执行任务,避免无效合成 |
|
|
30
|
+
| 回复快慢不一,音频播报乱序 | 单 transaction worker,FIFO 顺序稳定 |
|
|
31
31
|
| 修改配置要重启服务 | 热更新:编辑 `config.json` 立即生效 |
|
|
32
32
|
| 默认音色太无聊 | 来源专属音色,Claude 和 Codex 声音不同 |
|
|
33
33
|
|
|
@@ -72,7 +72,7 @@ ispeak "iSpeak 准备好了"
|
|
|
72
72
|
│ │ │
|
|
73
73
|
│ ▼ │
|
|
74
74
|
│ 任务引擎 │
|
|
75
|
-
│ (
|
|
75
|
+
│ (pending → running → delete) │
|
|
76
76
|
│ │ │
|
|
77
77
|
│ ▼ │
|
|
78
78
|
│ 单 Worker 流式链路 │
|
|
@@ -86,10 +86,10 @@ ispeak "iSpeak 准备好了"
|
|
|
86
86
|
|
|
87
87
|
**任务状态流转:**
|
|
88
88
|
```
|
|
89
|
-
|
|
89
|
+
pending → running → delete
|
|
90
90
|
```
|
|
91
91
|
|
|
92
|
-
|
|
92
|
+
新消息到达时只清理未开始任务,不打断当前合成/播放;当前事务结束后再播最新消息。
|
|
93
93
|
|
|
94
94
|
## 语音清洗规则
|
|
95
95
|
|
package/main.go
CHANGED
|
@@ -200,12 +200,12 @@ func (p *bufferedStreamPlayer) Abort() error {
|
|
|
200
200
|
}
|
|
201
201
|
|
|
202
202
|
// 任务状态
|
|
203
|
-
// 生命周期:
|
|
203
|
+
// 生命周期:pending -> running -> delete
|
|
204
204
|
type TaskStatus int
|
|
205
205
|
|
|
206
206
|
const (
|
|
207
|
-
|
|
208
|
-
|
|
207
|
+
TaskStatusPending TaskStatus = iota
|
|
208
|
+
TaskStatusRunning
|
|
209
209
|
)
|
|
210
210
|
|
|
211
211
|
// 单个 TTS 任务
|
|
@@ -217,18 +217,16 @@ type Task struct {
|
|
|
217
217
|
Cfg Config
|
|
218
218
|
}
|
|
219
219
|
|
|
220
|
-
// 任务引擎:任务仓库 +
|
|
220
|
+
// 任务引擎:任务仓库 + 单事务 worker
|
|
221
221
|
type TaskEngine struct {
|
|
222
222
|
mu sync.Mutex
|
|
223
223
|
|
|
224
|
-
nextID
|
|
225
|
-
latestID
|
|
226
|
-
tasks
|
|
227
|
-
|
|
228
|
-
activeID uint64
|
|
229
|
-
activeCancel context.CancelFunc
|
|
224
|
+
nextID uint64
|
|
225
|
+
latestID uint64
|
|
226
|
+
tasks map[uint64]*Task
|
|
227
|
+
pending []uint64
|
|
230
228
|
|
|
231
|
-
|
|
229
|
+
wake chan struct{}
|
|
232
230
|
|
|
233
231
|
synthesizeStreamFn func(ctx context.Context, cfg Config, text string, voice *VoiceInfo, onAudio func([]byte) error) error
|
|
234
232
|
newStreamPlayerFn func() (StreamPlayer, error)
|
|
@@ -237,67 +235,58 @@ type TaskEngine struct {
|
|
|
237
235
|
func NewTaskEngine() *TaskEngine {
|
|
238
236
|
return &TaskEngine{
|
|
239
237
|
tasks: make(map[uint64]*Task),
|
|
240
|
-
|
|
238
|
+
wake: make(chan struct{}, 1),
|
|
241
239
|
synthesizeStreamFn: synthesizeStream,
|
|
242
240
|
newStreamPlayerFn: newDefaultStreamPlayer,
|
|
243
241
|
}
|
|
244
242
|
}
|
|
245
243
|
|
|
246
244
|
func (e *TaskEngine) Start() {
|
|
247
|
-
go e.
|
|
245
|
+
go e.transactionWorker()
|
|
248
246
|
}
|
|
249
247
|
|
|
250
248
|
func (e *TaskEngine) Submit(text string, voice VoiceInfo, cfg Config) uint64 {
|
|
251
249
|
e.mu.Lock()
|
|
252
250
|
|
|
253
|
-
//
|
|
254
|
-
for _, id := range e.
|
|
251
|
+
// 新任务进来先删所有未开始的任务。
|
|
252
|
+
for _, id := range e.pending {
|
|
255
253
|
delete(e.tasks, id)
|
|
256
|
-
log.Printf("
|
|
257
|
-
}
|
|
258
|
-
e.pendingSynth = e.pendingSynth[:0]
|
|
259
|
-
|
|
260
|
-
cancelActive := e.activeCancel
|
|
261
|
-
activeID := e.activeID
|
|
262
|
-
if activeID != 0 {
|
|
263
|
-
log.Printf("打断当前播报任务: id=%d", activeID)
|
|
254
|
+
log.Printf("删除待执行任务: id=%d", id)
|
|
264
255
|
}
|
|
256
|
+
e.pending = e.pending[:0]
|
|
265
257
|
|
|
266
258
|
e.nextID++
|
|
267
259
|
task := &Task{
|
|
268
260
|
ID: e.nextID,
|
|
269
261
|
Text: text,
|
|
270
|
-
Status:
|
|
262
|
+
Status: TaskStatusPending,
|
|
271
263
|
Voice: voice,
|
|
272
264
|
Cfg: cfg,
|
|
273
265
|
}
|
|
274
266
|
e.tasks[task.ID] = task
|
|
275
267
|
e.latestID = task.ID
|
|
276
|
-
e.
|
|
268
|
+
e.pending = append(e.pending, task.ID)
|
|
277
269
|
log.Printf("任务创建: id=%d text=%s", task.ID, text)
|
|
278
270
|
|
|
279
|
-
notify(e.
|
|
271
|
+
notify(e.wake)
|
|
280
272
|
e.mu.Unlock()
|
|
281
273
|
|
|
282
|
-
if cancelActive != nil {
|
|
283
|
-
cancelActive()
|
|
284
|
-
}
|
|
285
274
|
return task.ID
|
|
286
275
|
}
|
|
287
276
|
|
|
288
|
-
func (e *TaskEngine)
|
|
277
|
+
func (e *TaskEngine) transactionWorker() {
|
|
289
278
|
for {
|
|
290
|
-
id := e.
|
|
279
|
+
id := e.claimPending()
|
|
291
280
|
if id == 0 {
|
|
292
|
-
<-e.
|
|
281
|
+
<-e.wake
|
|
293
282
|
continue
|
|
294
283
|
}
|
|
295
284
|
|
|
296
|
-
e.
|
|
285
|
+
e.processTransaction(id)
|
|
297
286
|
}
|
|
298
287
|
}
|
|
299
288
|
|
|
300
|
-
func (e *TaskEngine)
|
|
289
|
+
func (e *TaskEngine) processTransaction(id uint64) {
|
|
301
290
|
defer func() {
|
|
302
291
|
if r := recover(); r != nil {
|
|
303
292
|
log.Printf("播报任务崩溃并删除: id=%d err=%v", id, r)
|
|
@@ -305,27 +294,18 @@ func (e *TaskEngine) processSpeakTask(id uint64) {
|
|
|
305
294
|
}
|
|
306
295
|
}()
|
|
307
296
|
|
|
308
|
-
ctx, cancel := context.WithCancel(context.Background())
|
|
309
|
-
e.setActiveTask(id, cancel)
|
|
310
|
-
defer e.clearActiveTask(id)
|
|
311
|
-
|
|
312
297
|
task, ok := e.getTask(id)
|
|
313
298
|
if !ok {
|
|
314
299
|
return
|
|
315
300
|
}
|
|
316
301
|
if !e.isLatestTask(id) {
|
|
317
|
-
cancel()
|
|
318
302
|
log.Printf("跳过过期播报任务: id=%d", id)
|
|
319
303
|
e.deleteTask(id)
|
|
320
304
|
return
|
|
321
305
|
}
|
|
322
306
|
|
|
323
|
-
if err := e.
|
|
324
|
-
|
|
325
|
-
log.Printf("播报已打断并删除任务: id=%d", id)
|
|
326
|
-
} else {
|
|
327
|
-
log.Printf("播报失败并删除任务: id=%d err=%v", id, err)
|
|
328
|
-
}
|
|
307
|
+
if err := e.runTransaction(task); err != nil {
|
|
308
|
+
log.Printf("播报失败并删除任务: id=%d err=%v", id, err)
|
|
329
309
|
e.deleteTask(id)
|
|
330
310
|
return
|
|
331
311
|
}
|
|
@@ -334,7 +314,7 @@ func (e *TaskEngine) processSpeakTask(id uint64) {
|
|
|
334
314
|
e.deleteTask(id)
|
|
335
315
|
}
|
|
336
316
|
|
|
337
|
-
func (e *TaskEngine)
|
|
317
|
+
func (e *TaskEngine) runTransaction(task *Task) error {
|
|
338
318
|
startedAt := time.Now()
|
|
339
319
|
player, err := e.newStreamPlayerFn()
|
|
340
320
|
if err != nil {
|
|
@@ -350,45 +330,34 @@ func (e *TaskEngine) speakOnce(ctx context.Context, task *Task) error {
|
|
|
350
330
|
return player.Write(audio)
|
|
351
331
|
}
|
|
352
332
|
|
|
353
|
-
if err := e.synthesizeStreamFn(
|
|
333
|
+
if err := e.synthesizeStreamFn(context.Background(), task.Cfg, task.Text, &task.Voice, onAudio); err != nil {
|
|
354
334
|
_ = player.Abort()
|
|
355
335
|
return err
|
|
356
336
|
}
|
|
357
337
|
log.Printf("TTS 流结束: id=%d elapsed=%s", task.ID, time.Since(startedAt).Round(time.Millisecond))
|
|
358
338
|
|
|
359
|
-
|
|
360
|
-
go func() {
|
|
361
|
-
done <- player.CloseAndWait()
|
|
362
|
-
}()
|
|
363
|
-
select {
|
|
364
|
-
case err := <-done:
|
|
365
|
-
if err != nil {
|
|
366
|
-
_ = player.Abort()
|
|
367
|
-
return err
|
|
368
|
-
}
|
|
369
|
-
case <-ctx.Done():
|
|
339
|
+
if err := player.CloseAndWait(); err != nil {
|
|
370
340
|
_ = player.Abort()
|
|
371
|
-
|
|
372
|
-
return ctx.Err()
|
|
341
|
+
return err
|
|
373
342
|
}
|
|
374
343
|
return nil
|
|
375
344
|
}
|
|
376
345
|
|
|
377
|
-
func (e *TaskEngine)
|
|
346
|
+
func (e *TaskEngine) claimPending() uint64 {
|
|
378
347
|
e.mu.Lock()
|
|
379
348
|
defer e.mu.Unlock()
|
|
380
349
|
|
|
381
|
-
for len(e.
|
|
382
|
-
id := e.
|
|
383
|
-
e.
|
|
350
|
+
for len(e.pending) > 0 {
|
|
351
|
+
id := e.pending[0]
|
|
352
|
+
e.pending = e.pending[1:]
|
|
384
353
|
task, ok := e.tasks[id]
|
|
385
354
|
if !ok {
|
|
386
355
|
continue
|
|
387
356
|
}
|
|
388
|
-
if task.Status !=
|
|
357
|
+
if task.Status != TaskStatusPending {
|
|
389
358
|
continue
|
|
390
359
|
}
|
|
391
|
-
task.Status =
|
|
360
|
+
task.Status = TaskStatusRunning
|
|
392
361
|
return id
|
|
393
362
|
}
|
|
394
363
|
return 0
|
|
@@ -412,22 +381,6 @@ func (e *TaskEngine) deleteTask(id uint64) {
|
|
|
412
381
|
delete(e.tasks, id)
|
|
413
382
|
}
|
|
414
383
|
|
|
415
|
-
func (e *TaskEngine) setActiveTask(id uint64, cancel context.CancelFunc) {
|
|
416
|
-
e.mu.Lock()
|
|
417
|
-
defer e.mu.Unlock()
|
|
418
|
-
e.activeID = id
|
|
419
|
-
e.activeCancel = cancel
|
|
420
|
-
}
|
|
421
|
-
|
|
422
|
-
func (e *TaskEngine) clearActiveTask(id uint64) {
|
|
423
|
-
e.mu.Lock()
|
|
424
|
-
defer e.mu.Unlock()
|
|
425
|
-
if e.activeID == id {
|
|
426
|
-
e.activeID = 0
|
|
427
|
-
e.activeCancel = nil
|
|
428
|
-
}
|
|
429
|
-
}
|
|
430
|
-
|
|
431
384
|
func (e *TaskEngine) isLatestTask(id uint64) bool {
|
|
432
385
|
e.mu.Lock()
|
|
433
386
|
defer e.mu.Unlock()
|
package/package.json
CHANGED