@xdfnet/ispeak 1.6.3 → 1.6.5

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.
@@ -4,8 +4,8 @@
4
4
 
5
5
  iSpeak 是一个运行在 macOS 上的本地 TTS 播报守护进程,通过 Unix Socket 接收文本,调用火山引擎 TTS 流式 API,边合成边播放。
6
6
 
7
- 当前版本采用“任务仓库 + 单 speak worker”流式链路:
8
- - speak worker:领取待合成任务,SSE 每到一段音频就写入播放器 stdin
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
- │ │ - pendingSynth: []uint64 (FIFO) │ │
31
+ │ │ - pending: []uint64 (FIFO) │ │
32
32
  │ └───────────────────────────────────────────────────────┘ │
33
33
  │ │ │
34
34
  │ ▼ │
35
- Speak Worker (single)
36
- │ - pending_synth -> speaking
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
- TaskStatusPendingSynth TaskStatus = iota // 待合成
63
- TaskStatusSpeaking // 流式合成播放中
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
- pendingSynth []uint64
80
-
81
- synthWake chan struct{}
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
- pending_synth -> speaking -> delete
103
+ pending -> running -> delete
104
104
  ```
105
105
 
106
106
  ### 任务提交(核心规则)
107
107
 
108
108
  `Submit(cleanedText, voice, cfg)` 原子执行:
109
- 1. 删除所有 `pending_synth` 任务
110
- 2. 打断当前 `speaking` 任务(取消合成/停止播放)
111
- 3. 创建新任务(`pending_synth`)
112
- 4. 唤醒 speak worker
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
- ### Speak worker 规则
119
+ ### Transaction worker 规则
120
120
 
121
- 1. FIFO 领取 `pending_synth` 任务并置 `speaking`
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
- - speak worker 领取任务
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
- - 单 speak worker,保证播报顺序稳定
164
- - `synthWake` 为缓冲 1 的唤醒信号,防止重复唤醒堆积
163
+ - 单 transaction worker,保证播报顺序稳定
164
+ - `wake` 为缓冲 1 的唤醒信号,防止重复唤醒堆积
165
165
  - FIFO 保证未开始任务公平顺序
166
166
 
167
167
  ## 失败与成本策略
168
168
 
169
- - 新任务到达时清理 `pending_synth` 并打断当前任务,避免无效合成/播放
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
- | 回复快慢不一,音频播报乱序 | 单 speak worker,FIFO 顺序稳定 |
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
- │ (pending_synthspeaking → delete) │
75
+ │ (pendingrunning → delete) │
76
76
  │ │ │
77
77
  │ ▼ │
78
78
  │ 单 Worker 流式链路 │
@@ -86,10 +86,10 @@ ispeak "iSpeak 准备好了"
86
86
 
87
87
  **任务状态流转:**
88
88
  ```
89
- pending_synthspeaking → delete
89
+ pendingrunning → delete
90
90
  ```
91
91
 
92
- 新消息到达时会清理未开始任务,并打断当前合成/播放,只保留最新消息优先播报。
92
+ 新消息到达时只清理未开始任务,不打断当前合成/播放;当前事务结束后再播最新消息。
93
93
 
94
94
  ## 语音清洗规则
95
95
 
@@ -1,5 +1,5 @@
1
1
  #!/bin/bash
2
- # Stop Hook: transcript 文件中提取本次会话所有 Claude 回复文本
2
+ # Stop Hook: 只播报本次停止时的最后一条 assistant 回复
3
3
  # iAgent 调用 Claude 时设 ISPEAK_SKIP=1,此时跳过(iAgent 自己播)
4
4
  [[ "$ISPEAK_SKIP" == "1" ]] && exit 0
5
5
 
@@ -32,87 +32,79 @@ json_value() {
32
32
  printf "%s" "$input" | sed -n "s/.*\"$key\"[[:space:]]*:[[:space:]]*\"\([^\"]*\)\".*/\1/p"
33
33
  }
34
34
 
35
- extract_recent_assistant_text() {
35
+ extract_last_assistant_text() {
36
36
  local transcript="$1"
37
- local cutoff="$2"
38
37
 
39
38
  if command -v node >/dev/null 2>&1; then
40
39
  node -e '
41
40
  const fs = require("fs");
42
41
  const file = process.argv[1];
43
- const cutoff = Number(process.argv[2]);
44
- const out = [];
42
+ let last = "";
45
43
 
46
44
  function collectText(content) {
45
+ const out = [];
47
46
  if (typeof content === "string") {
48
- out.push(content);
49
- return;
47
+ return content;
50
48
  }
51
- if (!Array.isArray(content)) return;
49
+ if (!Array.isArray(content)) return "";
52
50
  for (const item of content) {
53
51
  if (item && typeof item.text === "string") out.push(item.text);
54
52
  }
53
+ return out.join(" ");
55
54
  }
56
55
 
57
56
  for (const line of fs.readFileSync(file, "utf8").split(/\r?\n/)) {
58
57
  if (!line.trim()) continue;
59
58
  try {
60
59
  const event = JSON.parse(line);
61
- if (typeof event.timestamp === "number" && event.timestamp < cutoff) continue;
62
- if (event.role === "assistant") collectText(event.content);
63
- if (event.message && event.message.role === "assistant") collectText(event.message.content);
60
+ if (event.role === "assistant") last = collectText(event.content) || last;
61
+ if (event.message && event.message.role === "assistant") last = collectText(event.message.content) || last;
64
62
  } catch (_) {}
65
63
  }
66
- process.stdout.write([...new Set(out.filter(Boolean))].join(" "));
67
- ' "$transcript" "$cutoff" 2>/dev/null
64
+ process.stdout.write(last);
65
+ ' "$transcript" 2>/dev/null
68
66
  return
69
67
  fi
70
68
 
71
- awk -v cutoff="$cutoff" '
69
+ awk '
72
70
  {
73
- if (match($0, /"timestamp"[[:space:]]*:[[:space:]]*[0-9]+/)) {
74
- ts = substr($0, RSTART, RLENGTH)
75
- gsub(/[^0-9]/, "", ts)
76
- ts = int(ts)
77
- if (ts < cutoff) next
78
- }
79
-
80
71
  if (match($0, /"role"[[:space:]]*:[[:space:]]*"assistant"/)) {
81
72
  if (match($0, /"content"[[:space:]]*:[[:space:]]*\[/)) {
82
73
  gsub(/[^{]*\[/, "", $0)
83
74
  gsub(/\].*/, "", $0)
75
+ msg = ""
84
76
  while (match($0, /"text"[[:space:]]*:[[:space:]]*"[^"]*"/)) {
85
77
  t = substr($0, RSTART, RLENGTH)
86
78
  gsub(/"text"[[:space:]]*:[[:space:]]*"/, "", t)
87
79
  gsub(/"$/, "", t)
88
- if (t != "") print t
80
+ if (t != "") msg = msg " " t
89
81
  $0 = substr($0, RSTART + RLENGTH)
90
82
  }
83
+ if (msg != "") last = msg
91
84
  } else if (match($0, /"content"[[:space:]]*:[[:space:]]*"[^"]*"/)) {
92
85
  t = substr($0, RSTART, RLENGTH)
93
86
  gsub(/"content"[[:space:]]*:[[:space:]]*"/, "", t)
94
87
  gsub(/"$/, "", t)
95
- if (t != "") print t
88
+ if (t != "") last = t
96
89
  }
97
90
  }
98
91
  }
99
- ' "$transcript" 2>/dev/null | sort -u | tr '\n' ' '
92
+ END {
93
+ gsub(/^[[:space:]]+|[[:space:]]+$/, "", last)
94
+ if (last != "") print last
95
+ }
96
+ ' "$transcript" 2>/dev/null
100
97
  }
101
98
 
102
- # 从 stdin JSON 提取 transcript 路径和最后一条消息
99
+ # 从 stdin JSON 提取最后一条消息;没有时再从 transcript 取最后一条 assistant
103
100
  transcript=$(json_value "transcript_path")
104
101
  last_msg=$(json_value "last_assistant_message")
105
102
 
106
103
  all_text="$last_msg"
107
104
 
108
- # 如果有 transcript 文件,提取最近 30 秒内的所有 assistant 消息
109
- if [[ -n "$transcript" && -f "$transcript" ]]; then
110
- # 计算 30 秒前的时间戳(毫秒)
111
- cutoff=$(($(date +%s) * 1000 - 30000))
112
-
113
- # 优先用 JSON parser,Node 不存在时回退到简易 awk。
114
- extra=$(extract_recent_assistant_text "$transcript" "$cutoff")
115
-
105
+ # Claude Code 部分版本不提供 last_assistant_message,此时只取 transcript 最后一条 assistant
106
+ if [[ -z "$all_text" && -n "$transcript" && -f "$transcript" ]]; then
107
+ extra=$(extract_last_assistant_text "$transcript")
116
108
  if [[ -n "$extra" ]]; then
117
109
  all_text="$extra"
118
110
  fi
package/main.go CHANGED
@@ -200,12 +200,12 @@ func (p *bufferedStreamPlayer) Abort() error {
200
200
  }
201
201
 
202
202
  // 任务状态
203
- // 生命周期:pending_synth -> speaking -> delete
203
+ // 生命周期:pending -> running -> delete
204
204
  type TaskStatus int
205
205
 
206
206
  const (
207
- TaskStatusPendingSynth TaskStatus = iota
208
- TaskStatusSpeaking
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
- // 任务引擎:任务仓库 + 单流式合成播放 worker
220
+ // 任务引擎:任务仓库 + 单事务 worker
221
221
  type TaskEngine struct {
222
222
  mu sync.Mutex
223
223
 
224
- nextID uint64
225
- latestID uint64
226
- tasks map[uint64]*Task
227
- pendingSynth []uint64
228
- activeID uint64
229
- activeCancel context.CancelFunc
224
+ nextID uint64
225
+ latestID uint64
226
+ tasks map[uint64]*Task
227
+ pending []uint64
230
228
 
231
- synthWake chan struct{}
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
- synthWake: make(chan struct{}, 1),
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.speakWorker()
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.pendingSynth {
251
+ // 新任务进来先删所有未开始的任务。
252
+ for _, id := range e.pending {
255
253
  delete(e.tasks, id)
256
- log.Printf("删除待合成任务: id=%d", id)
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: TaskStatusPendingSynth,
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.pendingSynth = append(e.pendingSynth, task.ID)
268
+ e.pending = append(e.pending, task.ID)
277
269
  log.Printf("任务创建: id=%d text=%s", task.ID, text)
278
270
 
279
- notify(e.synthWake)
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) speakWorker() {
277
+ func (e *TaskEngine) transactionWorker() {
289
278
  for {
290
- id := e.claimPendingSynth()
279
+ id := e.claimPending()
291
280
  if id == 0 {
292
- <-e.synthWake
281
+ <-e.wake
293
282
  continue
294
283
  }
295
284
 
296
- e.processSpeakTask(id)
285
+ e.processTransaction(id)
297
286
  }
298
287
  }
299
288
 
300
- func (e *TaskEngine) processSpeakTask(id uint64) {
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.speakOnce(ctx, task); err != nil {
324
- if errors.Is(err, context.Canceled) {
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) speakOnce(ctx context.Context, task *Task) error {
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(ctx, task.Cfg, task.Text, &task.Voice, onAudio); err != nil {
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
- done := make(chan error, 1)
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
- <-done
372
- return ctx.Err()
341
+ return err
373
342
  }
374
343
  return nil
375
344
  }
376
345
 
377
- func (e *TaskEngine) claimPendingSynth() uint64 {
346
+ func (e *TaskEngine) claimPending() uint64 {
378
347
  e.mu.Lock()
379
348
  defer e.mu.Unlock()
380
349
 
381
- for len(e.pendingSynth) > 0 {
382
- id := e.pendingSynth[0]
383
- e.pendingSynth = e.pendingSynth[1:]
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 != TaskStatusPendingSynth {
357
+ if task.Status != TaskStatusPending {
389
358
  continue
390
359
  }
391
- task.Status = TaskStatusSpeaking
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xdfnet/ispeak",
3
- "version": "1.6.3",
3
+ "version": "1.6.5",
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",