@xdfnet/ispeak 1.6.2 → 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.
@@ -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
37
- │ - 调用 TTS 流式接口(失败重试1次)
35
+ Transaction Worker (single)
36
+ │ - pending -> running
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,28 +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. 创建新任务(`pending_synth`)
111
- 3. 唤醒 speak worker
109
+ 1. 删除所有 `pending` 任务
110
+ 2. 不打断当前 `running` 事务
111
+ 3. 创建新任务(`pending`)
112
+ 4. 唤醒 transaction worker
112
113
 
113
114
  策略说明:
114
- - 只清理“未开始合成”的任务
115
- - 不打断 `speaking`
115
+ - 未开始的旧任务直接删除
116
+ - 已领取但过期的旧任务在事务执行前跳过
117
+ - 正在合成/播放的任务自然结束
116
118
 
117
- ### Speak worker 规则
119
+ ### Transaction worker 规则
118
120
 
119
- 1. FIFO 领取 `pending_synth` 任务并置 `speaking`
121
+ 1. FIFO 领取 `pending` 任务并置 `running`
120
122
  2. 启动 `StreamPlayer`
121
123
  3. 调用 TTS 流式接口,SSE 每解析出一个音频 chunk 就写入播放器
122
124
  4. TTS 结束后关闭播放器 stdin 并等待播放结束
123
125
  5. 成功:删除任务
124
- 6. 连续失败:删除任务
126
+ 6. 失败:删除任务,不重试
125
127
 
126
128
  ## 消息流程
127
129
 
@@ -130,12 +132,25 @@ pending_synth -> speaking -> delete
130
132
  `handleConnection()`:
131
133
  - 读取 socket 文本
132
134
  - 解析 `{source:xxx}` 音色前缀
133
- - `cleanText()` 过滤 Markdown/表格符号
135
+ - `cleanText()` 生成语音友好的文本
134
136
  - 将“过滤后文本”提交给 `TaskEngine.Submit`
135
137
 
138
+ `cleanText()` 只影响 TTS 播报,不改变屏幕显示内容。当前清洗规则:
139
+
140
+ - Markdown 格式符号:标题、加粗、反引号、引用符
141
+ - Markdown 表格整块:表头、分隔线、表格内容
142
+ - 代码块、artifact、HTML 页面源码
143
+ - Markdown 链接 URL,仅保留链接标题
144
+ - 绝对路径简化为“路径”
145
+ - 长 commit hash、UUID、长 ID
146
+ - 明显文件列表、模型分片列表、下载清单
147
+ - 下载进度、速度、进度条、ANSI 控制符等终端噪声
148
+
149
+ 清洗目标是保留适合听的内容:结论、成功/失败状态、下一步动作、关键错误原因。
150
+
136
151
  ### 2. 流式合成播放阶段
137
152
 
138
- - speak worker 领取任务
153
+ - transaction worker 领取任务
139
154
  - HTTP POST 火山引擎 TTS 接口
140
155
  - 解析 SSE 流并 base64 解码音频 chunk
141
156
  - 优先将 chunk 写入 `ffplay` stdin 实时播放
@@ -145,15 +160,15 @@ pending_synth -> speaking -> delete
145
160
  ## 并发与一致性
146
161
 
147
162
  - 单引擎锁 `mu` 保护任务仓库与 FIFO 队列
148
- - 单 speak worker,保证播报顺序稳定
149
- - `synthWake` 为缓冲 1 的唤醒信号,防止重复唤醒堆积
163
+ - 单 transaction worker,保证播报顺序稳定
164
+ - `wake` 为缓冲 1 的唤醒信号,防止重复唤醒堆积
150
165
  - FIFO 保证未开始任务公平顺序
151
166
 
152
167
  ## 失败与成本策略
153
168
 
154
- - 新任务到达时仅清理 `pending_synth`,避免无效合成
155
- - 流式合成/播放失败:整条播报重试 1 次后删除
156
- - 执行中任务不打断,行为稳定、可预期
169
+ - 新任务到达时只清理 `pending`,不打断当前任务
170
+ - 流式合成/播放失败:直接删除任务,不重试,避免重复播报
171
+ - 只保留最新消息优先播报,降低 TTS 成本
157
172
 
158
173
  ## 文件布局
159
174
 
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # iSpeak
2
2
 
3
- ![Version](https://img.shields.io/badge/version-1.6.2-blue)
3
+ ![Version](https://img.shields.io/badge/version-1.6.3-blue)
4
4
  [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](https://opensource.org/licenses/MIT)
5
5
  [![Go Version](https://img.shields.io/badge/Go-1.26-blue)](https://golang.org/dl/)
6
6
  ![Platform](https://img.shields.io/badge/platform-macOS-green)
@@ -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,9 +86,29 @@ ispeak "iSpeak 准备好了"
86
86
 
87
87
  **任务状态流转:**
88
88
  ```
89
- pending_synthspeaking → delete
89
+ pendingrunning → delete
90
90
  ```
91
91
 
92
+ 新消息到达时只清理未开始任务,不打断当前合成/播放;当前事务结束后再播最新消息。
93
+
94
+ ## 语音清洗规则
95
+
96
+ 清洗只影响 TTS 播报内容,不改变 Claude/Codex 屏幕显示内容。
97
+
98
+ 播报前会过滤或简化这些内容:
99
+
100
+ - Markdown 格式符号:标题 `#`、加粗 `**`、反引号、引用 `>`
101
+ - Markdown 表格整块:表头、分隔线、表格内容都不播
102
+ - 代码块:``` 包裹的内容不播
103
+ - artifact / HTML 内容:不播生成的页面源码
104
+ - Markdown 链接:只保留链接标题,不播 URL
105
+ - 绝对路径:简化为“路径”
106
+ - 长 commit hash、UUID、长 ID:不播
107
+ - 明显文件列表:如模型分片、代码文件列表、下载文件清单
108
+ - 下载进度和终端噪声:百分比、速度、进度条、ANSI 控制符
109
+
110
+ 保留优先级:结论、成功/失败状态、需要用户操作的下一步、关键错误原因。
111
+
92
112
  ## 全部命令
93
113
 
94
114
  ```bash
package/main.go CHANGED
@@ -17,6 +17,7 @@ import (
17
17
  "os/exec"
18
18
  "os/signal"
19
19
  "path/filepath"
20
+ "regexp"
20
21
  "strings"
21
22
  "sync"
22
23
  "syscall"
@@ -25,11 +26,6 @@ import (
25
26
  "gopkg.in/natefinch/lumberjack.v2"
26
27
  )
27
28
 
28
- const (
29
- ttsMaxAttempts = 2
30
- ttsRetryBackoff = 400 * time.Millisecond
31
- )
32
-
33
29
  var configDir = os.ExpandEnv("$HOME/.config/iSpeak")
34
30
 
35
31
  var (
@@ -47,6 +43,25 @@ var tempDir string
47
43
 
48
44
  var errAlreadyRunning = errors.New("iSpeak already running")
49
45
 
46
+ var (
47
+ markdownLinkRe = regexp.MustCompile(`\[[^\]]+\]\(([^)]*)\)`)
48
+ absolutePathRe = regexp.MustCompile(`/(?:Users|private|tmp|var|opt|usr|bin|sbin|etc|Library|Applications)/\S+`)
49
+ commitHashRe = regexp.MustCompile(`\b[0-9a-f]{7,40}\b`)
50
+ uuidRe = regexp.MustCompile(`\b[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}\b`)
51
+ urlRe = regexp.MustCompile(`https?://\S+`)
52
+ ansiEscapeRe = regexp.MustCompile(`\x1b\[[0-9;]*[A-Za-z]`)
53
+ multiSpaceRe = regexp.MustCompile(`\s+`)
54
+ markdownListRe = regexp.MustCompile(`^\s*(?:[-*+]\s+|\d+[.)]\s+)`)
55
+ htmlTagRe = regexp.MustCompile(`<[^>]+>`)
56
+ codeFenceStartRe = regexp.MustCompile("^```")
57
+ artifactStartRe = regexp.MustCompile(`(?i)^<artifact\b`)
58
+ htmlDocumentLineRe = regexp.MustCompile(`(?i)^<!doctype html|^<html\b|^<head\b|^<body\b|^<style\b|^</`)
59
+ progressNoiseRe = regexp.MustCompile(`(?i)(^\s*\d{1,3}%\s*$|\d{1,3}%.*\d+(?:\.\d+)?\s*(?:kb|mb|gb)/s|\bETA\b|^\s*[-=]{3,}\s*$)`)
60
+ speedNoiseRe = regexp.MustCompile(`(?i)\d+(?:\.\d+)?\s*(?:kb|mb|gb)/s`)
61
+ etaNoiseRe = regexp.MustCompile(`(?i)\bETA\b|预计剩余|剩余时间`)
62
+ fileListNoiseRe = regexp.MustCompile(`(?i)\.(?:go|js|ts|tsx|jsx|json|md|yaml|yml|toml|sum|mod|lock|html|css|sh|plist|safetensors|mp3|wav|png|jpg|jpeg|pdf|docx)\b`)
63
+ )
64
+
50
65
  type StreamPlayer interface {
51
66
  Write(audio []byte) error
52
67
  CloseAndWait() error
@@ -54,9 +69,13 @@ type StreamPlayer interface {
54
69
  }
55
70
 
56
71
  type ffplayStreamPlayer struct {
57
- path string
58
- cmd *exec.Cmd
59
- stdin io.WriteCloser
72
+ path string
73
+ cmd *exec.Cmd
74
+
75
+ mu sync.Mutex
76
+ stdin io.WriteCloser
77
+ waitOnce sync.Once
78
+ waitErr error
60
79
  }
61
80
 
62
81
  func newDefaultStreamPlayer() (StreamPlayer, error) {
@@ -99,35 +118,55 @@ func (p *ffplayStreamPlayer) Write(audio []byte) error {
99
118
  if len(audio) == 0 {
100
119
  return nil
101
120
  }
102
- if _, err := p.stdin.Write(audio); err != nil {
121
+ p.mu.Lock()
122
+ stdin := p.stdin
123
+ p.mu.Unlock()
124
+ if stdin == nil {
125
+ return fmt.Errorf("播放器输入已关闭")
126
+ }
127
+ if _, err := stdin.Write(audio); err != nil {
103
128
  return fmt.Errorf("写入播放器失败: %w", err)
104
129
  }
105
130
  return nil
106
131
  }
107
132
 
108
133
  func (p *ffplayStreamPlayer) CloseAndWait() error {
109
- if p.stdin != nil {
110
- if err := p.stdin.Close(); err != nil {
134
+ p.mu.Lock()
135
+ stdin := p.stdin
136
+ p.stdin = nil
137
+ p.mu.Unlock()
138
+ if stdin != nil {
139
+ if err := stdin.Close(); err != nil {
111
140
  return fmt.Errorf("关闭播放器输入失败: %w", err)
112
141
  }
113
- p.stdin = nil
114
142
  }
115
- if err := p.cmd.Wait(); err != nil {
143
+ if err := p.wait(); err != nil {
116
144
  return fmt.Errorf("ffplay failed: %w", err)
117
145
  }
118
146
  return nil
119
147
  }
120
148
 
121
149
  func (p *ffplayStreamPlayer) Abort() error {
122
- if p.stdin != nil {
123
- _ = p.stdin.Close()
124
- p.stdin = nil
150
+ p.mu.Lock()
151
+ stdin := p.stdin
152
+ p.stdin = nil
153
+ p.mu.Unlock()
154
+ if stdin != nil {
155
+ _ = stdin.Close()
125
156
  }
126
157
  if p.cmd != nil && p.cmd.Process != nil {
127
158
  _ = p.cmd.Process.Kill()
128
- _ = p.cmd.Wait()
129
159
  }
130
- return nil
160
+ return p.wait()
161
+ }
162
+
163
+ func (p *ffplayStreamPlayer) wait() error {
164
+ p.waitOnce.Do(func() {
165
+ if p.cmd != nil {
166
+ p.waitErr = p.cmd.Wait()
167
+ }
168
+ })
169
+ return p.waitErr
131
170
  }
132
171
 
133
172
  type bufferedStreamPlayer struct {
@@ -161,12 +200,12 @@ func (p *bufferedStreamPlayer) Abort() error {
161
200
  }
162
201
 
163
202
  // 任务状态
164
- // 生命周期:pending_synth -> speaking -> delete
203
+ // 生命周期:pending -> running -> delete
165
204
  type TaskStatus int
166
205
 
167
206
  const (
168
- TaskStatusPendingSynth TaskStatus = iota
169
- TaskStatusSpeaking
207
+ TaskStatusPending TaskStatus = iota
208
+ TaskStatusRunning
170
209
  )
171
210
 
172
211
  // 单个 TTS 任务
@@ -178,15 +217,16 @@ type Task struct {
178
217
  Cfg Config
179
218
  }
180
219
 
181
- // 任务引擎:任务仓库 + 单流式合成播放 worker
220
+ // 任务引擎:任务仓库 + 单事务 worker
182
221
  type TaskEngine struct {
183
222
  mu sync.Mutex
184
223
 
185
- nextID uint64
186
- tasks map[uint64]*Task
187
- pendingSynth []uint64
224
+ nextID uint64
225
+ latestID uint64
226
+ tasks map[uint64]*Task
227
+ pending []uint64
188
228
 
189
- synthWake chan struct{}
229
+ wake chan struct{}
190
230
 
191
231
  synthesizeStreamFn func(ctx context.Context, cfg Config, text string, voice *VoiceInfo, onAudio func([]byte) error) error
192
232
  newStreamPlayerFn func() (StreamPlayer, error)
@@ -195,56 +235,58 @@ type TaskEngine struct {
195
235
  func NewTaskEngine() *TaskEngine {
196
236
  return &TaskEngine{
197
237
  tasks: make(map[uint64]*Task),
198
- synthWake: make(chan struct{}, 1),
238
+ wake: make(chan struct{}, 1),
199
239
  synthesizeStreamFn: synthesizeStream,
200
240
  newStreamPlayerFn: newDefaultStreamPlayer,
201
241
  }
202
242
  }
203
243
 
204
244
  func (e *TaskEngine) Start() {
205
- go e.speakWorker()
245
+ go e.transactionWorker()
206
246
  }
207
247
 
208
248
  func (e *TaskEngine) Submit(text string, voice VoiceInfo, cfg Config) uint64 {
209
249
  e.mu.Lock()
210
- defer e.mu.Unlock()
211
250
 
212
- // 新任务进来先删所有未开始合成任务
213
- for _, id := range e.pendingSynth {
251
+ // 新任务进来先删所有未开始的任务。
252
+ for _, id := range e.pending {
214
253
  delete(e.tasks, id)
215
- log.Printf("删除待合成任务: id=%d", id)
254
+ log.Printf("删除待执行任务: id=%d", id)
216
255
  }
217
- e.pendingSynth = e.pendingSynth[:0]
256
+ e.pending = e.pending[:0]
218
257
 
219
258
  e.nextID++
220
259
  task := &Task{
221
260
  ID: e.nextID,
222
261
  Text: text,
223
- Status: TaskStatusPendingSynth,
262
+ Status: TaskStatusPending,
224
263
  Voice: voice,
225
264
  Cfg: cfg,
226
265
  }
227
266
  e.tasks[task.ID] = task
228
- e.pendingSynth = append(e.pendingSynth, task.ID)
267
+ e.latestID = task.ID
268
+ e.pending = append(e.pending, task.ID)
229
269
  log.Printf("任务创建: id=%d text=%s", task.ID, text)
230
270
 
231
- notify(e.synthWake)
271
+ notify(e.wake)
272
+ e.mu.Unlock()
273
+
232
274
  return task.ID
233
275
  }
234
276
 
235
- func (e *TaskEngine) speakWorker() {
277
+ func (e *TaskEngine) transactionWorker() {
236
278
  for {
237
- id := e.claimPendingSynth()
279
+ id := e.claimPending()
238
280
  if id == 0 {
239
- <-e.synthWake
281
+ <-e.wake
240
282
  continue
241
283
  }
242
284
 
243
- e.processSpeakTask(id)
285
+ e.processTransaction(id)
244
286
  }
245
287
  }
246
288
 
247
- func (e *TaskEngine) processSpeakTask(id uint64) {
289
+ func (e *TaskEngine) processTransaction(id uint64) {
248
290
  defer func() {
249
291
  if r := recover(); r != nil {
250
292
  log.Printf("播报任务崩溃并删除: id=%d err=%v", id, r)
@@ -256,20 +298,14 @@ func (e *TaskEngine) processSpeakTask(id uint64) {
256
298
  if !ok {
257
299
  return
258
300
  }
259
-
260
- var lastErr error
261
- for i := 1; i <= ttsMaxAttempts; i++ {
262
- lastErr = e.speakOnce(context.Background(), task)
263
- if lastErr == nil {
264
- break
265
- }
266
- if i < ttsMaxAttempts {
267
- time.Sleep(ttsRetryBackoff)
268
- }
301
+ if !e.isLatestTask(id) {
302
+ log.Printf("跳过过期播报任务: id=%d", id)
303
+ e.deleteTask(id)
304
+ return
269
305
  }
270
306
 
271
- if lastErr != nil {
272
- log.Printf("播报失败并删除任务: id=%d err=%v", id, lastErr)
307
+ if err := e.runTransaction(task); err != nil {
308
+ log.Printf("播报失败并删除任务: id=%d err=%v", id, err)
273
309
  e.deleteTask(id)
274
310
  return
275
311
  }
@@ -278,7 +314,7 @@ func (e *TaskEngine) processSpeakTask(id uint64) {
278
314
  e.deleteTask(id)
279
315
  }
280
316
 
281
- func (e *TaskEngine) speakOnce(ctx context.Context, task *Task) error {
317
+ func (e *TaskEngine) runTransaction(task *Task) error {
282
318
  startedAt := time.Now()
283
319
  player, err := e.newStreamPlayerFn()
284
320
  if err != nil {
@@ -294,11 +330,12 @@ func (e *TaskEngine) speakOnce(ctx context.Context, task *Task) error {
294
330
  return player.Write(audio)
295
331
  }
296
332
 
297
- 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 {
298
334
  _ = player.Abort()
299
335
  return err
300
336
  }
301
337
  log.Printf("TTS 流结束: id=%d elapsed=%s", task.ID, time.Since(startedAt).Round(time.Millisecond))
338
+
302
339
  if err := player.CloseAndWait(); err != nil {
303
340
  _ = player.Abort()
304
341
  return err
@@ -306,21 +343,21 @@ func (e *TaskEngine) speakOnce(ctx context.Context, task *Task) error {
306
343
  return nil
307
344
  }
308
345
 
309
- func (e *TaskEngine) claimPendingSynth() uint64 {
346
+ func (e *TaskEngine) claimPending() uint64 {
310
347
  e.mu.Lock()
311
348
  defer e.mu.Unlock()
312
349
 
313
- for len(e.pendingSynth) > 0 {
314
- id := e.pendingSynth[0]
315
- e.pendingSynth = e.pendingSynth[1:]
350
+ for len(e.pending) > 0 {
351
+ id := e.pending[0]
352
+ e.pending = e.pending[1:]
316
353
  task, ok := e.tasks[id]
317
354
  if !ok {
318
355
  continue
319
356
  }
320
- if task.Status != TaskStatusPendingSynth {
357
+ if task.Status != TaskStatusPending {
321
358
  continue
322
359
  }
323
- task.Status = TaskStatusSpeaking
360
+ task.Status = TaskStatusRunning
324
361
  return id
325
362
  }
326
363
  return 0
@@ -344,6 +381,12 @@ func (e *TaskEngine) deleteTask(id uint64) {
344
381
  delete(e.tasks, id)
345
382
  }
346
383
 
384
+ func (e *TaskEngine) isLatestTask(id uint64) bool {
385
+ e.mu.Lock()
386
+ defer e.mu.Unlock()
387
+ return e.latestID == id
388
+ }
389
+
347
390
  func notify(ch chan struct{}) {
348
391
  select {
349
392
  case ch <- struct{}{}:
@@ -648,29 +691,56 @@ func extractAudioBase64(event map[string]any) string {
648
691
  return ""
649
692
  }
650
693
 
651
- // 过滤格式符号,保留自然朗读文本
694
+ // 过滤格式符号,保留自然朗读文本。
695
+ // 顺序很重要:先跳过跨行块结构,再跳过整行噪声,最后清理行内符号。
652
696
  func cleanText(text string) string {
653
697
  var lines []string
654
- for _, line := range strings.Split(text, "\n") {
698
+ rawLines := strings.Split(text, "\n")
699
+ inCodeBlock := false
700
+ inArtifact := false
701
+ inMarkdownTable := false
702
+ for i := 0; i < len(rawLines); i++ {
703
+ line := rawLines[i]
655
704
  line = strings.TrimSpace(line)
656
- if strings.HasPrefix(line, "|---") || strings.HasPrefix(line, "|:---") {
705
+ if line == "" {
706
+ inMarkdownTable = false
707
+ continue
708
+ }
709
+ if codeFenceStartRe.MatchString(line) {
710
+ inCodeBlock = !inCodeBlock
657
711
  continue
658
712
  }
659
- if strings.HasPrefix(line, "---") && strings.Count(line, "-") > 3 {
713
+ if inCodeBlock {
660
714
  continue
661
715
  }
662
- // 过滤纯表格分隔行(|---|---|、:---|:---| 等)
663
- if strings.Trim(line, "|-: ") == "" {
716
+ if artifactStartRe.MatchString(line) {
717
+ inArtifact = !strings.Contains(strings.ToLower(line), "</artifact>")
664
718
  continue
665
719
  }
666
- cleaned := strings.NewReplacer(
667
- "**", "",
668
- "*", "",
669
- "`", "",
670
- "#", "",
671
- ">", "",
672
- ).Replace(line)
673
- cleaned = strings.TrimSpace(cleaned)
720
+ if inArtifact {
721
+ if strings.Contains(strings.ToLower(line), "</artifact>") {
722
+ inArtifact = false
723
+ }
724
+ continue
725
+ }
726
+ if isMarkdownTableSeparator(line) {
727
+ if len(lines) > 0 && isMarkdownTableRow(strings.TrimSpace(rawLines[i-1])) {
728
+ lines = lines[:len(lines)-1]
729
+ }
730
+ inMarkdownTable = true
731
+ continue
732
+ }
733
+ if inMarkdownTable {
734
+ if isMarkdownTableRow(line) {
735
+ continue
736
+ }
737
+ inMarkdownTable = false
738
+ }
739
+ if shouldSkipSpeechLine(line) {
740
+ continue
741
+ }
742
+
743
+ cleaned := cleanSpeechLine(line)
674
744
  if cleaned != "" {
675
745
  lines = append(lines, cleaned)
676
746
  }
@@ -678,6 +748,110 @@ func cleanText(text string) string {
678
748
  return strings.Join(lines, ",")
679
749
  }
680
750
 
751
+ func shouldSkipSpeechLine(line string) bool {
752
+ if isMarkdownTableSeparator(line) {
753
+ return true
754
+ }
755
+ if strings.HasPrefix(line, "---") && strings.Count(line, "-") > 3 {
756
+ return true
757
+ }
758
+ if htmlDocumentLineRe.MatchString(line) {
759
+ return true
760
+ }
761
+ if isProgressNoiseLine(line) {
762
+ return true
763
+ }
764
+ if isMostlyTableRow(line) {
765
+ return true
766
+ }
767
+ if isMostlyFileListLine(line) {
768
+ return true
769
+ }
770
+ return false
771
+ }
772
+
773
+ func isMarkdownTableSeparator(line string) bool {
774
+ line = strings.TrimSpace(line)
775
+ return strings.Contains(line, "|") && strings.Trim(line, "|-: ") == ""
776
+ }
777
+
778
+ func isMarkdownTableRow(line string) bool {
779
+ line = strings.TrimSpace(line)
780
+ return strings.Count(line, "|") >= 2
781
+ }
782
+
783
+ func cleanSpeechLine(line string) string {
784
+ // Markdown 链接必须在 URL 删除前处理,否则会丢掉链接标题。
785
+ line = ansiEscapeRe.ReplaceAllString(line, "")
786
+ line = markdownListRe.ReplaceAllString(line, "")
787
+ line = markdownLinkRe.ReplaceAllStringFunc(line, func(match string) string {
788
+ if end := strings.Index(match, "]"); end > 1 {
789
+ return match[1:end]
790
+ }
791
+ return ""
792
+ })
793
+ line = urlRe.ReplaceAllString(line, "")
794
+ line = absolutePathRe.ReplaceAllString(line, " 路径 ")
795
+ // UUID 必须在短 hash 前处理,避免先删短片段后破坏 UUID 识别。
796
+ line = uuidRe.ReplaceAllString(line, "")
797
+ line = commitHashRe.ReplaceAllString(line, "")
798
+ line = htmlTagRe.ReplaceAllString(line, "")
799
+ line = strings.NewReplacer(
800
+ "**", "",
801
+ "*", "",
802
+ "`", "",
803
+ "#", "",
804
+ ">", "",
805
+ "✅", "",
806
+ "❌", "",
807
+ "✓", "",
808
+ "✗", "",
809
+ "→", "到",
810
+ ).Replace(line)
811
+ line = strings.Trim(line, " \t-:|")
812
+ line = multiSpaceRe.ReplaceAllString(line, " ")
813
+ return strings.TrimSpace(line)
814
+ }
815
+
816
+ func isMostlyTableRow(line string) bool {
817
+ if !strings.Contains(line, "|") {
818
+ return false
819
+ }
820
+ return strings.Count(line, "|") >= 2 && len([]rune(line)) > 40
821
+ }
822
+
823
+ func isProgressNoiseLine(line string) bool {
824
+ if !progressNoiseRe.MatchString(line) {
825
+ return false
826
+ }
827
+ if speedNoiseRe.MatchString(line) || etaNoiseRe.MatchString(line) {
828
+ return true
829
+ }
830
+ return !containsCJK(line)
831
+ }
832
+
833
+ func isMostlyFileListLine(line string) bool {
834
+ if !fileListNoiseRe.MatchString(line) {
835
+ return false
836
+ }
837
+ if containsCJK(line) {
838
+ return false
839
+ }
840
+ if strings.Contains(line, ".safetensors") {
841
+ return true
842
+ }
843
+ return strings.Count(line, ".") >= 2 || strings.Contains(line, "/") || strings.Contains(line, " - ")
844
+ }
845
+
846
+ func containsCJK(s string) bool {
847
+ for _, r := range s {
848
+ if r >= '\u4e00' && r <= '\u9fff' {
849
+ return true
850
+ }
851
+ }
852
+ return false
853
+ }
854
+
681
855
  func main() {
682
856
  log.SetFlags(log.Ltime | log.Lshortfile)
683
857
 
@@ -847,6 +1021,9 @@ func handleConnection(conn net.Conn, engine *TaskEngine) {
847
1021
  scanner := bufio.NewScanner(conn)
848
1022
  scanner.Buffer(make([]byte, 1*1024*1024), 1*1024*1024)
849
1023
  for scanner.Scan() {
1024
+ if sb.Len() > 0 {
1025
+ sb.WriteByte('\n')
1026
+ }
850
1027
  sb.WriteString(scanner.Text())
851
1028
  }
852
1029
  if err := scanner.Err(); err != nil {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xdfnet/ispeak",
3
- "version": "1.6.2",
3
+ "version": "1.6.4",
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",
package/scripts/ispeak CHANGED
@@ -2,7 +2,7 @@
2
2
  # ispeak — iSpeak 控制命令
3
3
  set -euo pipefail
4
4
 
5
- VERSION="1.6.2"
5
+ VERSION="1.6.3"
6
6
  SOCK="$HOME/.config/iSpeak/ispeak.sock"
7
7
  PLIST="$HOME/Library/LaunchAgents/com.iSpeak.plist"
8
8
  CMD_NAME="$(basename "$0")"