@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.
- package/Docs/ARCHITECTURE.md +44 -29
- package/README.md +25 -5
- package/main.go +252 -75
- package/package.json +1 -1
- package/scripts/ispeak +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
|
-
│ -
|
|
37
|
-
│ - 调用 TTS
|
|
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
|
-
|
|
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,28 +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.
|
|
109
|
+
1. 删除所有 `pending` 任务
|
|
110
|
+
2. 不打断当前 `running` 事务
|
|
111
|
+
3. 创建新任务(`pending`)
|
|
112
|
+
4. 唤醒 transaction worker
|
|
112
113
|
|
|
113
114
|
策略说明:
|
|
114
|
-
-
|
|
115
|
-
-
|
|
115
|
+
- 未开始的旧任务直接删除
|
|
116
|
+
- 已领取但过期的旧任务在事务执行前跳过
|
|
117
|
+
- 正在合成/播放的任务自然结束
|
|
116
118
|
|
|
117
|
-
###
|
|
119
|
+
### Transaction worker 规则
|
|
118
120
|
|
|
119
|
-
1. FIFO 领取 `
|
|
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()`
|
|
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
|
-
-
|
|
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
|
-
- 单
|
|
149
|
-
- `
|
|
163
|
+
- 单 transaction worker,保证播报顺序稳定
|
|
164
|
+
- `wake` 为缓冲 1 的唤醒信号,防止重复唤醒堆积
|
|
150
165
|
- FIFO 保证未开始任务公平顺序
|
|
151
166
|
|
|
152
167
|
## 失败与成本策略
|
|
153
168
|
|
|
154
|
-
-
|
|
155
|
-
-
|
|
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
|
-

|
|
4
4
|
[](https://opensource.org/licenses/MIT)
|
|
5
5
|
[](https://golang.org/dl/)
|
|
6
6
|

|
|
@@ -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,9 +86,29 @@ ispeak "iSpeak 准备好了"
|
|
|
86
86
|
|
|
87
87
|
**任务状态流转:**
|
|
88
88
|
```
|
|
89
|
-
|
|
89
|
+
pending → running → 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
|
|
58
|
-
cmd
|
|
59
|
-
|
|
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
|
-
|
|
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
|
-
|
|
110
|
-
|
|
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.
|
|
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
|
-
|
|
123
|
-
|
|
124
|
-
|
|
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
|
|
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
|
-
// 生命周期:
|
|
203
|
+
// 生命周期:pending -> running -> delete
|
|
165
204
|
type TaskStatus int
|
|
166
205
|
|
|
167
206
|
const (
|
|
168
|
-
|
|
169
|
-
|
|
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
|
-
// 任务引擎:任务仓库 +
|
|
220
|
+
// 任务引擎:任务仓库 + 单事务 worker
|
|
182
221
|
type TaskEngine struct {
|
|
183
222
|
mu sync.Mutex
|
|
184
223
|
|
|
185
|
-
nextID
|
|
186
|
-
|
|
187
|
-
|
|
224
|
+
nextID uint64
|
|
225
|
+
latestID uint64
|
|
226
|
+
tasks map[uint64]*Task
|
|
227
|
+
pending []uint64
|
|
188
228
|
|
|
189
|
-
|
|
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
|
-
|
|
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.
|
|
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.
|
|
251
|
+
// 新任务进来先删所有未开始的任务。
|
|
252
|
+
for _, id := range e.pending {
|
|
214
253
|
delete(e.tasks, id)
|
|
215
|
-
log.Printf("
|
|
254
|
+
log.Printf("删除待执行任务: id=%d", id)
|
|
216
255
|
}
|
|
217
|
-
e.
|
|
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:
|
|
262
|
+
Status: TaskStatusPending,
|
|
224
263
|
Voice: voice,
|
|
225
264
|
Cfg: cfg,
|
|
226
265
|
}
|
|
227
266
|
e.tasks[task.ID] = task
|
|
228
|
-
e.
|
|
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.
|
|
271
|
+
notify(e.wake)
|
|
272
|
+
e.mu.Unlock()
|
|
273
|
+
|
|
232
274
|
return task.ID
|
|
233
275
|
}
|
|
234
276
|
|
|
235
|
-
func (e *TaskEngine)
|
|
277
|
+
func (e *TaskEngine) transactionWorker() {
|
|
236
278
|
for {
|
|
237
|
-
id := e.
|
|
279
|
+
id := e.claimPending()
|
|
238
280
|
if id == 0 {
|
|
239
|
-
<-e.
|
|
281
|
+
<-e.wake
|
|
240
282
|
continue
|
|
241
283
|
}
|
|
242
284
|
|
|
243
|
-
e.
|
|
285
|
+
e.processTransaction(id)
|
|
244
286
|
}
|
|
245
287
|
}
|
|
246
288
|
|
|
247
|
-
func (e *TaskEngine)
|
|
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
|
-
|
|
261
|
-
|
|
262
|
-
|
|
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
|
|
272
|
-
log.Printf("播报失败并删除任务: id=%d err=%v", id,
|
|
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)
|
|
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(
|
|
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)
|
|
346
|
+
func (e *TaskEngine) claimPending() uint64 {
|
|
310
347
|
e.mu.Lock()
|
|
311
348
|
defer e.mu.Unlock()
|
|
312
349
|
|
|
313
|
-
for len(e.
|
|
314
|
-
id := e.
|
|
315
|
-
e.
|
|
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 !=
|
|
357
|
+
if task.Status != TaskStatusPending {
|
|
321
358
|
continue
|
|
322
359
|
}
|
|
323
|
-
task.Status =
|
|
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
|
-
|
|
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
|
|
705
|
+
if line == "" {
|
|
706
|
+
inMarkdownTable = false
|
|
707
|
+
continue
|
|
708
|
+
}
|
|
709
|
+
if codeFenceStartRe.MatchString(line) {
|
|
710
|
+
inCodeBlock = !inCodeBlock
|
|
657
711
|
continue
|
|
658
712
|
}
|
|
659
|
-
if
|
|
713
|
+
if inCodeBlock {
|
|
660
714
|
continue
|
|
661
715
|
}
|
|
662
|
-
|
|
663
|
-
|
|
716
|
+
if artifactStartRe.MatchString(line) {
|
|
717
|
+
inArtifact = !strings.Contains(strings.ToLower(line), "</artifact>")
|
|
664
718
|
continue
|
|
665
719
|
}
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
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