@xdfnet/ispeak 1.6.16 → 1.7.0
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/Makefile +1 -1
- package/README.md +9 -18
- package/configs/hook-speak.sh +3 -2
- package/docs/architecture.md +91 -117
- package/docs/hook-text-extraction.md +71 -33
- package/main.go +41 -15
- package/package.json +1 -1
- package/scripts/ispeak +1 -1
package/Makefile
CHANGED
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
|

|
|
@@ -20,8 +20,8 @@ ispeak "Pull request 已合并,3 个测试通过"
|
|
|
20
20
|
|
|
21
21
|
| 问题 | 方案 |
|
|
22
22
|
|------|------|
|
|
23
|
-
| AI 生成多条回复,TTS 账单飞涨 |
|
|
24
|
-
| 回复快慢不一,音频播报乱序 | 单
|
|
23
|
+
| AI 生成多条回复,TTS 账单飞涨 | 新消息丢弃旧排队消息,避免无效合成 |
|
|
24
|
+
| 回复快慢不一,音频播报乱序 | 单 channel goroutine,串行顺序稳定 |
|
|
25
25
|
| 修改配置要重启服务 | 热更新:编辑 `config.json` 立即生效 |
|
|
26
26
|
| 默认音色太无聊 | hook 按来源前缀选择音色 |
|
|
27
27
|
|
|
@@ -33,7 +33,7 @@ ispeak "Pull request 已合并,3 个测试通过"
|
|
|
33
33
|
npm i -g @xdfnet/ispeak
|
|
34
34
|
```
|
|
35
35
|
|
|
36
|
-
当前 npm 安装会在本机编译 `ispeakd`,需要已安装 Go。主播放链路使用 macOS 原生 `AVAudioEngine`,不依赖 `ffmpeg
|
|
36
|
+
当前 npm 安装会在本机编译 `ispeakd`,需要已安装 Go。主播放链路使用 macOS 原生 `AVAudioEngine`,不依赖 `ffmpeg`。合成失败记录日志,播放器异常自动重建。
|
|
37
37
|
|
|
38
38
|
**源码安装:**
|
|
39
39
|
|
|
@@ -61,26 +61,17 @@ ispeak "iSpeak 准备好了"
|
|
|
61
61
|
│ 通过 Unix Socket 接收文本 │
|
|
62
62
|
│ │ │
|
|
63
63
|
│ ▼ │
|
|
64
|
-
│
|
|
65
|
-
│
|
|
64
|
+
│ Player (channel) │
|
|
65
|
+
│ buffer=1 + drain(新消息丢弃旧排队消息) │
|
|
66
66
|
│ │ │
|
|
67
67
|
│ ▼ │
|
|
68
|
-
│
|
|
69
|
-
│ (SSE PCM chunk → AVAudioEngine) │
|
|
68
|
+
│ TTS SSE → AVAudioEngine(单实例复用) │
|
|
70
69
|
│ │ │
|
|
71
70
|
│ ▼ │
|
|
72
|
-
│
|
|
73
|
-
│ (失败时记录日志并删除任务) │
|
|
71
|
+
│ 失败记录日志,播放器异常自动重建 │
|
|
74
72
|
└─────────────────────────────────────────────────────┘
|
|
75
73
|
```
|
|
76
74
|
|
|
77
|
-
**任务状态流转:**
|
|
78
|
-
```
|
|
79
|
-
pending → running → delete
|
|
80
|
-
```
|
|
81
|
-
|
|
82
|
-
新消息到达时只清理未开始任务,不打断当前合成/播放;当前事务结束后再播最新消息。
|
|
83
|
-
|
|
84
75
|
## 语音清洗规则
|
|
85
76
|
|
|
86
77
|
清洗只影响 TTS 播报内容,不改变 Claude/Codex 屏幕显示内容。
|
|
@@ -138,7 +129,7 @@ ispeak version # 版本
|
|
|
138
129
|
|
|
139
130
|
Claude Code 和 Codex 的详细 hook 配置见 [docs/hook-text-extraction.md](/Users/admin/iCode/iSpeak/docs/hook-text-extraction.md)。
|
|
140
131
|
|
|
141
|
-
`hook-speak.sh`
|
|
132
|
+
`hook-speak.sh` 会自动跳过 Codex 遗留 notify 的 `agent-turn-complete` 事件,避免同一回合重复播报。
|
|
142
133
|
|
|
143
134
|
## 开发命令
|
|
144
135
|
|
package/configs/hook-speak.sh
CHANGED
|
@@ -24,6 +24,9 @@ const fs = require("fs");
|
|
|
24
24
|
const input = readFile(process.env.HOOK_INPUT_FILE || "");
|
|
25
25
|
const payload = parseJSON(input) || {};
|
|
26
26
|
|
|
27
|
+
// Codex Stop hook 会在 agent-turn-complete 事件中重复触发,跳过
|
|
28
|
+
if (payload.type === "agent-turn-complete") return;
|
|
29
|
+
|
|
27
30
|
const text = payload.last_assistant_message
|
|
28
31
|
|| payload["last-assistant-message"]
|
|
29
32
|
|| "";
|
|
@@ -47,6 +50,4 @@ fi
|
|
|
47
50
|
|
|
48
51
|
if [[ -n "$result" && -S "$SOCK" ]]; then
|
|
49
52
|
printf "{source:%s}%s" "$SOURCE" "$result" | nc -U -w5 "$SOCK" 2>> "$LOG"
|
|
50
|
-
else
|
|
51
|
-
echo "$(date): SKIP source=$SOURCE text_len=${#result}" >> "$LOG"
|
|
52
53
|
fi
|
package/docs/architecture.md
CHANGED
|
@@ -2,179 +2,145 @@
|
|
|
2
2
|
|
|
3
3
|
## 概述
|
|
4
4
|
|
|
5
|
-
iSpeak 是一个运行在 macOS 上的本地 TTS 播报守护进程,通过 Unix Socket 接收文本,调用火山引擎 TTS 流式 API
|
|
5
|
+
iSpeak 是一个运行在 macOS 上的本地 TTS 播报守护进程,通过 Unix Socket 接收文本,调用火山引擎 TTS 流式 API,边合成边通过原生 AVAudioEngine 播放 PCM 音频。
|
|
6
6
|
|
|
7
|
-
|
|
8
|
-
- transaction worker:领取待执行任务,SSE 每到一段音频就写入播放器 stdin
|
|
9
|
-
- 播放器优先使用 `ffplay -i pipe:0`,没有 `ffplay` 时回退到完整音频 `afplay`
|
|
7
|
+
核心链路:**Socket → Player (channel) → TTS SSE → AVAudioEngine**
|
|
10
8
|
|
|
11
9
|
## 系统架构
|
|
12
10
|
|
|
13
11
|
```
|
|
14
12
|
┌─────────────────────────────────────────────────────────────┐
|
|
15
13
|
│ 客户端 │
|
|
16
|
-
│
|
|
14
|
+
│ nc -U ─────────> ~/.config/iSpeak/ispeak.sock │
|
|
15
|
+
│ ispeak "文本" (Unix Socket) │
|
|
17
16
|
└─────────────────────────────────────────────────────────────┘
|
|
18
17
|
│
|
|
19
18
|
▼
|
|
20
19
|
┌─────────────────────────────────────────────────────────────┐
|
|
21
|
-
│ ispeakd (Go Daemon)
|
|
20
|
+
│ ispeakd (Go Daemon) │
|
|
22
21
|
│ │
|
|
23
|
-
│ Socket Acceptor
|
|
24
|
-
│ -
|
|
25
|
-
│ - 每个连接读取文本并提交任务 │
|
|
22
|
+
│ Socket Acceptor (handleConnection) │
|
|
23
|
+
│ - 读文本 → 解析 {source:xxx} → 选音色 → cleanText → 提交 │
|
|
26
24
|
│ │
|
|
27
|
-
│
|
|
25
|
+
│ Player (channel 驱动) │
|
|
28
26
|
│ ┌───────────────────────────────────────────────────────┐ │
|
|
29
|
-
│ │
|
|
30
|
-
│ │
|
|
31
|
-
│ │
|
|
27
|
+
│ │ chan job (buffer=1) │ │
|
|
28
|
+
│ │ Submit: drain 旧消息 → 入队最新 │ │
|
|
29
|
+
│ │ loop: for j := range ch → play(j, player) │ │
|
|
32
30
|
│ └───────────────────────────────────────────────────────┘ │
|
|
33
31
|
│ │ │
|
|
34
32
|
│ ▼ │
|
|
35
|
-
│
|
|
36
|
-
│
|
|
37
|
-
│
|
|
38
|
-
│
|
|
39
|
-
│ - 播放完成后删除任务;失败直接删除任务 │
|
|
40
|
-
│ │
|
|
33
|
+
│ AVAudioEngine (cgo, 单实例复用) │
|
|
34
|
+
│ - PCM 48kHz 单声道 int16 → float32 │
|
|
35
|
+
│ - 流式 scheduleBuffer + pending 计数 + cond 同步 │
|
|
36
|
+
│ - 关闭时补齐残留字节 │
|
|
41
37
|
└─────────────────────────────────────────────────────────────┘
|
|
42
38
|
```
|
|
43
39
|
|
|
44
40
|
## 核心数据结构
|
|
45
41
|
|
|
46
|
-
###
|
|
42
|
+
### job
|
|
47
43
|
|
|
48
44
|
```go
|
|
49
|
-
type
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
Cfg Config // 任务配置快照(提交时)
|
|
45
|
+
type job struct {
|
|
46
|
+
text string // cleanText 清洗后的文本
|
|
47
|
+
voice VoiceInfo // 音色快照
|
|
48
|
+
source string // 来源: "claude" / "codex" / "default"
|
|
49
|
+
cfg Config // 配置快照
|
|
55
50
|
}
|
|
56
51
|
```
|
|
57
52
|
|
|
58
|
-
###
|
|
59
|
-
|
|
60
|
-
```go
|
|
61
|
-
const (
|
|
62
|
-
TaskStatusPending TaskStatus = iota // 待执行
|
|
63
|
-
TaskStatusRunning // 合成播放事务执行中
|
|
64
|
-
)
|
|
65
|
-
```
|
|
66
|
-
|
|
67
|
-
说明:
|
|
68
|
-
- 终态不持久化。任务成功/失败后都会从仓库删除。
|
|
69
|
-
- 不保留 `failed/canceled/completed` 常驻状态,历史通过日志追踪。
|
|
70
|
-
|
|
71
|
-
### TaskEngine
|
|
53
|
+
### Player
|
|
72
54
|
|
|
73
55
|
```go
|
|
74
|
-
type
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
nextID uint64
|
|
78
|
-
tasks map[uint64]*Task
|
|
79
|
-
latestID uint64
|
|
80
|
-
pending []uint64
|
|
81
|
-
wake chan struct{}
|
|
82
|
-
|
|
83
|
-
synthesizeStreamFn func(ctx context.Context, cfg Config, text string, voice *VoiceInfo, onAudio func([]byte) error) error
|
|
84
|
-
newStreamPlayerFn func() (StreamPlayer, error)
|
|
56
|
+
type Player struct {
|
|
57
|
+
ch chan job // buffer=1,串行播报
|
|
85
58
|
}
|
|
86
59
|
```
|
|
87
60
|
|
|
88
|
-
|
|
61
|
+
单 goroutine 消费 channel,一个 AVAudioEngine 实例复用。新消息到达时 drain 旧消息,不打断正在播放的。
|
|
62
|
+
|
|
63
|
+
### StreamPlayer
|
|
89
64
|
|
|
90
65
|
```go
|
|
91
66
|
type StreamPlayer interface {
|
|
92
67
|
Write(audio []byte) error
|
|
93
68
|
CloseAndWait() error
|
|
94
|
-
Abort() error
|
|
95
69
|
}
|
|
96
70
|
```
|
|
97
71
|
|
|
98
|
-
##
|
|
99
|
-
|
|
100
|
-
### 状态流转
|
|
101
|
-
|
|
102
|
-
```
|
|
103
|
-
pending -> running -> delete
|
|
104
|
-
```
|
|
72
|
+
## 消息流程
|
|
105
73
|
|
|
106
|
-
###
|
|
74
|
+
### 1. Socket 接收
|
|
107
75
|
|
|
108
|
-
`
|
|
109
|
-
1.
|
|
110
|
-
2.
|
|
111
|
-
3.
|
|
112
|
-
4.
|
|
76
|
+
`handleConnection()`:
|
|
77
|
+
1. `bufio.Scanner` 读取完整文本(最大 1MB)
|
|
78
|
+
2. `extractVoicePrefix` 解析 `{source:claude}` 前缀,匹配 SourceVoices
|
|
79
|
+
3. 未匹配到 → fallback 到 DefaultVoice
|
|
80
|
+
4. `cleanText()` 过滤文本噪音(markdown/code/URL/path/UUID 等)
|
|
81
|
+
5. `player.Submit(文本, 音色, 来源, 配置)`
|
|
113
82
|
|
|
114
|
-
|
|
115
|
-
- 未开始的旧任务直接删除
|
|
116
|
-
- 已领取但过期的旧任务在事务执行前跳过
|
|
117
|
-
- 正在合成/播放的任务自然结束
|
|
83
|
+
### 2. 调度与去重
|
|
118
84
|
|
|
119
|
-
|
|
85
|
+
`Submit()`:
|
|
86
|
+
- 非阻塞 drain channel 中旧消息:`select { case <-ch: default: }`
|
|
87
|
+
- 新消息入队
|
|
120
88
|
|
|
121
|
-
|
|
122
|
-
2. 启动 `StreamPlayer`
|
|
123
|
-
3. 调用 TTS 流式接口,SSE 每解析出一个音频 chunk 就写入播放器
|
|
124
|
-
4. TTS 结束后关闭播放器 stdin 并等待播放结束
|
|
125
|
-
5. 成功:删除任务
|
|
126
|
-
6. 失败:删除任务,不重试
|
|
89
|
+
策略:**新消息丢弃旧排队消息,不打断正在播放的**
|
|
127
90
|
|
|
128
|
-
|
|
91
|
+
### 3. 流式合成与播放
|
|
129
92
|
|
|
130
|
-
|
|
93
|
+
`play()`:
|
|
94
|
+
1. HTTP POST 火山引擎 `/api/v3/tts/unidirectional/sse`
|
|
95
|
+
2. SSE 流式解析 → base64 解码 → PCM int16 数据
|
|
96
|
+
3. 每块 PCM 立即写入 AVAudioEngine 播放
|
|
97
|
+
4. **合成失败**:只记日志,播放器正常继续
|
|
98
|
+
5. **播放器写入失败**:返回 error,loop 层重建 AVAudioEngine
|
|
131
99
|
|
|
132
|
-
|
|
133
|
-
- 读取 socket 文本
|
|
134
|
-
- 解析 `{source:xxx}` 音色前缀
|
|
135
|
-
- `cleanText()` 生成语音友好的文本
|
|
136
|
-
- 将“过滤后文本”提交给 `TaskEngine.Submit`
|
|
100
|
+
## SSE 解析
|
|
137
101
|
|
|
138
|
-
`
|
|
102
|
+
`parseSSEStream()`:
|
|
103
|
+
- 逐行读取,累积 `data:` 行
|
|
104
|
+
- 空行触发 flush → `processEvent()` 解析 JSON
|
|
105
|
+
- 兼容非标准直出(无 `data:` 前缀的裸 JSON)
|
|
106
|
+
- `extractAudioBase64` 递归提取:顶层 `data/audio/audio_data` → 嵌套 `data/result/payload`
|
|
107
|
+
- 错误码检查:`code` 不为 0 且不为 20000000 时返回 error
|
|
108
|
+
- 整条流无音频块 → 返回 `"no audio data"`
|
|
139
109
|
|
|
140
|
-
|
|
141
|
-
- Markdown 表格整块:表头、分隔线、表格内容
|
|
142
|
-
- 代码块、artifact、HTML 页面源码
|
|
143
|
-
- Markdown 链接 URL,仅保留链接标题
|
|
144
|
-
- 绝对路径简化为“路径”
|
|
145
|
-
- 长 commit hash、UUID、长 ID
|
|
146
|
-
- 明显文件列表、模型分片列表、下载清单
|
|
147
|
-
- 下载进度、速度、进度条、ANSI 控制符等终端噪声
|
|
110
|
+
## 配置热加载
|
|
148
111
|
|
|
149
|
-
|
|
112
|
+
`loadConfig()`:
|
|
113
|
+
- mtime 缓存:路径相同 + 修改时间未变 → 直接返回缓存
|
|
114
|
+
- 校验失败 → 用上一次有效配置兜底
|
|
115
|
+
- 文件不存在 → fallback 环境变量 `IAGENT_TTS_API_KEY` / `IAGENT_TTS_ENDPOINT`
|
|
150
116
|
|
|
151
|
-
|
|
117
|
+
## 稳定性设计
|
|
152
118
|
|
|
153
|
-
-
|
|
154
|
-
-
|
|
155
|
-
-
|
|
156
|
-
-
|
|
157
|
-
-
|
|
158
|
-
-
|
|
119
|
+
- **panic recover**: loop goroutine 崩溃后 `go p.loop()` 自动重启
|
|
120
|
+
- **播放器重建**: 写入失败时关闭旧实例并创建新的 AVAudioEngine
|
|
121
|
+
- **新消息优先**: channel buffer=1 + drain,旧排队消息自动丢弃
|
|
122
|
+
- **配置热加载**: 每次连接重新读取,mtime 缓存避免频繁 I/O
|
|
123
|
+
- **HTTP 复用**: 全局 `ttsHTTPClient`,30s 超时,连接池复用
|
|
124
|
+
- **日志轮转**: lumberjack,10MB/份,保留 3 份,压缩归档
|
|
125
|
+
- **优雅退出**: SIGINT/SIGTERM 触发 listener.Close()
|
|
159
126
|
|
|
160
|
-
##
|
|
127
|
+
## 清洗规则
|
|
161
128
|
|
|
162
|
-
|
|
163
|
-
- 单 transaction worker,保证播报顺序稳定
|
|
164
|
-
- `wake` 为缓冲 1 的唤醒信号,防止重复唤醒堆积
|
|
165
|
-
- FIFO 保证未开始任务公平顺序
|
|
129
|
+
`cleanText()` 过滤顺序(先跨行块再行内符号):
|
|
166
130
|
|
|
167
|
-
|
|
131
|
+
1. 跳过代码块 (` ```...``` `)
|
|
132
|
+
2. 跳过 artifact (`<artifact>...</artifact>`)
|
|
133
|
+
3. 跳过 Markdown 表格(分隔线 + 表头 + 内容行)
|
|
134
|
+
4. 跳过 HTML 源码行、进度噪声行
|
|
135
|
+
5. 行内清洗:ANSI 转义 → 链接 URL → 绝对路径 → UUID → commit hash → markdown 符号 → HTML 标签
|
|
168
136
|
|
|
169
|
-
|
|
170
|
-
- 流式合成/播放失败:直接删除任务,不重试,避免重复播报
|
|
171
|
-
- 只保留最新消息优先播报,降低 TTS 成本
|
|
137
|
+
保留适合听的内容:结论、状态、下一步动作、关键错误原因。
|
|
172
138
|
|
|
173
139
|
## 文件布局
|
|
174
140
|
|
|
175
141
|
```
|
|
176
142
|
~/.config/iSpeak/
|
|
177
|
-
├── config.json # API Key
|
|
143
|
+
├── config.json # API Key、音色映射
|
|
178
144
|
├── ispeak.sock # Unix Socket
|
|
179
145
|
├── ispeak.log # 日志(lumberjack 轮转)
|
|
180
146
|
└── hook-speak.sh # Claude/Codex Hook
|
|
@@ -183,10 +149,18 @@ pending -> running -> delete
|
|
|
183
149
|
└── com.ispeak.plist # launchd 服务配置
|
|
184
150
|
```
|
|
185
151
|
|
|
186
|
-
##
|
|
152
|
+
## 来源 & 音色映射
|
|
153
|
+
|
|
154
|
+
Hook 传入 `{source:claude}` 前缀,ispeakd 解析后匹配 `config.json` 中的 `sourceVoices`:
|
|
155
|
+
|
|
156
|
+
```json
|
|
157
|
+
{
|
|
158
|
+
"defaultVoice": { "voice_type": "zh_female_mizai_uranus_bigtts", "resourceId": "seed-tts-2.0" },
|
|
159
|
+
"sourceVoices": {
|
|
160
|
+
"claude": { "voice_type": "zh_female_tianmeitaozi_uranus_bigtts", "resourceId": "seed-tts-2.0" },
|
|
161
|
+
"codex": { "voice_type": "zh_female_shuangkuaisisi_uranus_bigtts", "resourceId": "seed-tts-2.0" }
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
```
|
|
187
165
|
|
|
188
|
-
|
|
189
|
-
- 配置热更新(每次连接重新加载配置)
|
|
190
|
-
- 播放器子进程命令协议,保证“播完再删任务”
|
|
191
|
-
- 日志轮转(10MB/份,保留 3 份)
|
|
192
|
-
- 进程级 temp 目录,退出时自动清理
|
|
166
|
+
日志区分来源:`TTS [claude]: 文本` / `TTS [codex]: 文本` / `TTS [default]: 文本`
|
|
@@ -1,69 +1,107 @@
|
|
|
1
1
|
# Hook 文本提取链路
|
|
2
2
|
|
|
3
|
-
`hook-speak.sh` 只做一件事:从 Hook JSON 里取 assistant 回复文本,发给 iSpeak socket
|
|
3
|
+
`hook-speak.sh` 只做一件事:从 Hook JSON 里取 assistant 回复文本,发给 iSpeak socket。当前 51 行。
|
|
4
4
|
|
|
5
|
-
##
|
|
6
|
-
|
|
7
|
-
一行提取,覆盖所有来源:
|
|
5
|
+
## 提取逻辑
|
|
8
6
|
|
|
9
7
|
```js
|
|
8
|
+
// Codex 遗留 notify(agent-turn-complete)与现代 Stop Hook 重复触发,跳过
|
|
9
|
+
if (payload.type === "agent-turn-complete") return;
|
|
10
|
+
|
|
10
11
|
const text = payload.last_assistant_message // Claude Stop / Codex Stop (snake_case)
|
|
11
12
|
|| payload["last-assistant-message"] // Codex notify (kebab-case)
|
|
12
|
-
|| payload.message // fallback
|
|
13
13
|
|| "";
|
|
14
14
|
```
|
|
15
15
|
|
|
16
|
-
不再需要 transcript
|
|
16
|
+
不再需要 transcript 轮询、去重、状态文件、`payload.message` fallback。
|
|
17
17
|
|
|
18
18
|
## 输入来源
|
|
19
19
|
|
|
20
|
-
| 来源 | 传参方式 | 字段名 |
|
|
21
|
-
|
|
22
|
-
| Claude Code Stop Hook | stdin | `last_assistant_message` |
|
|
23
|
-
| Codex Stop Hook | stdin | `last_assistant_message` |
|
|
24
|
-
| Codex notify | `$2` (argv) | `last-assistant-message` |
|
|
20
|
+
| 来源 | 传参方式 | 字段名 | 处理 |
|
|
21
|
+
|------|---------|--------|------|
|
|
22
|
+
| Claude Code Stop Hook | stdin | `last_assistant_message` | 提取并播报 |
|
|
23
|
+
| Codex Stop Hook | stdin | `last_assistant_message` | 提取并播报 |
|
|
24
|
+
| Codex 遗留 notify | `$2` (argv) | `last-assistant-message` | 跳过(`agent-turn-complete`) |
|
|
25
25
|
|
|
26
|
-
|
|
26
|
+
脚本统一处理 stdin 和 argv:
|
|
27
27
|
|
|
28
28
|
```bash
|
|
29
|
-
input="${2:-}" #
|
|
29
|
+
input="${2:-}" # 遗留 notify 走 $2
|
|
30
30
|
if [[ -z "$input" ]]; then
|
|
31
|
-
input=$(cat) #
|
|
31
|
+
input=$(cat) # Stop Hook 走 stdin
|
|
32
32
|
fi
|
|
33
33
|
```
|
|
34
34
|
|
|
35
|
-
## Codex
|
|
35
|
+
## Codex Stop Hook(现代)
|
|
36
|
+
|
|
37
|
+
stdin JSON:
|
|
38
|
+
|
|
39
|
+
```json
|
|
40
|
+
{
|
|
41
|
+
"turn_id": "...",
|
|
42
|
+
"transcript_path": "...",
|
|
43
|
+
"last_assistant_message": "最后一条 assistant 回复"
|
|
44
|
+
}
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
源码:`codex-rs/hooks/src/events/stop.rs` — `StopCommandInput` struct 包含 `last_assistant_message`。
|
|
48
|
+
|
|
49
|
+
## Codex 遗留 notify(跳过)
|
|
50
|
+
|
|
51
|
+
Codex 有两套通知机制同时触发:
|
|
36
52
|
|
|
37
|
-
|
|
53
|
+
| 机制 | 事件 | 触发时机 |
|
|
54
|
+
|------|------|---------|
|
|
55
|
+
| 现代 Stop Hook | `stop` | agent 回合结束 |
|
|
56
|
+
| 遗留 notify | `agent-turn-complete` | agent 回合结束 |
|
|
38
57
|
|
|
39
|
-
|
|
58
|
+
两套系统都包含 `last_assistant_message`,导致重复播报。现代 Stop Hook 已覆盖需求,遗留 notify 通过 `payload.type === "agent-turn-complete"` 跳过。
|
|
40
59
|
|
|
41
|
-
|
|
42
|
-
|
|
60
|
+
源码:`codex-rs/hooks/src/legacy_notify.rs` — 向后兼容,JSON 通过 `command.arg()` 传入,字段序列化为 kebab-case。
|
|
61
|
+
|
|
62
|
+
## 触发时间点
|
|
63
|
+
|
|
64
|
+
Hook 在 AI **回复完成**时触发,每个回合一次。Claude Code 和 Codex 均使用 `Stop` 事件:
|
|
65
|
+
|
|
66
|
+
```
|
|
67
|
+
用户发送消息 → AI 生成回复 → 回复结束 → Hook 触发 → 提取文本 → 发送 socket → TTS 播报
|
|
43
68
|
```
|
|
44
69
|
|
|
45
|
-
|
|
70
|
+
从 Hook 触发到 TTS 首字延迟通常 < 500ms(取决于文本长度和网络)。
|
|
71
|
+
|
|
72
|
+
## 来源 & 音色
|
|
73
|
+
|
|
74
|
+
Hook 调用时传入来源名称(`$1`),对应 `config.json` 中的音色映射:
|
|
46
75
|
|
|
47
76
|
```bash
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
```
|
|
77
|
+
# ~/.claude/settings.json — Claude Code
|
|
78
|
+
"command": "bash ~/.config/iSpeak/hook-speak.sh claude"
|
|
51
79
|
|
|
52
|
-
|
|
80
|
+
# ~/.codex/hooks.json — Codex
|
|
81
|
+
"command": "bash /Users/admin/.config/iSpeak/hook-speak.sh codex"
|
|
82
|
+
```
|
|
53
83
|
|
|
54
|
-
|
|
84
|
+
文本加上 `{source:claude}` 或 `{source:codex}` 前缀发往 socket,`ispeakd` 解析后选择对应音色。无前缀则用 `defaultVoice`。
|
|
55
85
|
|
|
56
|
-
|
|
86
|
+
音色映射示例(`~/.config/iSpeak/config.json`):
|
|
57
87
|
|
|
58
88
|
```json
|
|
59
89
|
{
|
|
60
|
-
"
|
|
61
|
-
"
|
|
62
|
-
|
|
90
|
+
"defaultVoice": { "voice_type": "zh_female_mizai_uranus_bigtts" },
|
|
91
|
+
"sourceVoices": {
|
|
92
|
+
"claude": { "voice_type": "zh_female_tianmeitaozi_uranus_bigtts" },
|
|
93
|
+
"codex": { "voice_type": "zh_female_shuangkuaisisi_uranus_bigtts" }
|
|
94
|
+
}
|
|
63
95
|
}
|
|
64
96
|
```
|
|
65
97
|
|
|
66
|
-
|
|
98
|
+
日志中也会区分来源:
|
|
99
|
+
|
|
100
|
+
```
|
|
101
|
+
TTS [claude]: 飞哥好。 → tianmeitaozi 音色
|
|
102
|
+
TTS [codex]: 飞哥,你好。 → shuangkuaisisi 音色
|
|
103
|
+
TTS [default]: 直接文本 → mizai 音色
|
|
104
|
+
```
|
|
67
105
|
|
|
68
106
|
## Claude Code Stop Hook
|
|
69
107
|
|
|
@@ -79,10 +117,10 @@ stdin JSON(实测,2026-05):
|
|
|
79
117
|
}
|
|
80
118
|
```
|
|
81
119
|
|
|
82
|
-
Claude Code 官方文档只列出 `transcript_path`,但实际 payload **包含 `last_assistant_message
|
|
120
|
+
Claude Code 官方文档只列出 `transcript_path`,但实际 payload **包含 `last_assistant_message`**(实测确认)。直接用 direct 字段,无需读 transcript。
|
|
83
121
|
|
|
84
122
|
## 历史演进
|
|
85
123
|
|
|
86
124
|
- v1(250 行):transcript 轮询 + turn_id 去重 + state file + text hash。复杂度高,`session_id` 做去重 key 导致同一 session 只播第一条。
|
|
87
|
-
- v2(53 行):省略去重和 transcript 轮询,但
|
|
88
|
-
- v3
|
|
125
|
+
- v2(53 行):省略去重和 transcript 轮询,但 `payload.message` 回退太宽泛,且 Codex 重复触发未处理。
|
|
126
|
+
- v3(51 行):统一提取,移除 `payload.message`,过滤 `agent-turn-complete` 解决 Codex 双重通知导致的重复播报。
|
package/main.go
CHANGED
|
@@ -48,9 +48,10 @@ type Player struct {
|
|
|
48
48
|
}
|
|
49
49
|
|
|
50
50
|
type job struct {
|
|
51
|
-
text
|
|
52
|
-
voice
|
|
53
|
-
|
|
51
|
+
text string
|
|
52
|
+
voice VoiceInfo
|
|
53
|
+
source string
|
|
54
|
+
cfg Config
|
|
54
55
|
}
|
|
55
56
|
|
|
56
57
|
func NewPlayer() *Player {
|
|
@@ -59,17 +60,24 @@ func NewPlayer() *Player {
|
|
|
59
60
|
return p
|
|
60
61
|
}
|
|
61
62
|
|
|
62
|
-
func (p *Player) Submit(text string, voice VoiceInfo, cfg Config) {
|
|
63
|
-
log.Printf("TTS: %s", text)
|
|
63
|
+
func (p *Player) Submit(text string, voice VoiceInfo, source string, cfg Config) {
|
|
64
|
+
log.Printf("TTS [%s]: %s", source, text)
|
|
64
65
|
// 丢弃队列中的旧消息,只保留最新
|
|
65
66
|
select {
|
|
66
67
|
case <-p.ch:
|
|
67
68
|
default:
|
|
68
69
|
}
|
|
69
|
-
p.ch <- job{text, voice, cfg}
|
|
70
|
+
p.ch <- job{text, voice, source, cfg}
|
|
70
71
|
}
|
|
71
72
|
|
|
72
73
|
func (p *Player) loop() {
|
|
74
|
+
defer func() {
|
|
75
|
+
if r := recover(); r != nil {
|
|
76
|
+
log.Printf("Player loop 崩溃: %v,重启中", r)
|
|
77
|
+
go p.loop()
|
|
78
|
+
}
|
|
79
|
+
}()
|
|
80
|
+
|
|
73
81
|
player, err := newDefaultStreamPlayer()
|
|
74
82
|
if err != nil {
|
|
75
83
|
log.Printf("启动播放器失败: %v", err)
|
|
@@ -78,21 +86,37 @@ func (p *Player) loop() {
|
|
|
78
86
|
defer player.CloseAndWait()
|
|
79
87
|
|
|
80
88
|
for j := range p.ch {
|
|
81
|
-
p.play(j, player)
|
|
89
|
+
if err := p.play(j, player); err != nil {
|
|
90
|
+
log.Printf("播放器异常,重建: %v", err)
|
|
91
|
+
player.CloseAndWait()
|
|
92
|
+
player, err = newDefaultStreamPlayer()
|
|
93
|
+
if err != nil {
|
|
94
|
+
log.Printf("重建播放器失败: %v", err)
|
|
95
|
+
return
|
|
96
|
+
}
|
|
97
|
+
}
|
|
82
98
|
}
|
|
83
99
|
}
|
|
84
100
|
|
|
85
|
-
func (p *Player) play(j job, player StreamPlayer) {
|
|
101
|
+
func (p *Player) play(j job, player StreamPlayer) error {
|
|
86
102
|
startedAt := time.Now()
|
|
103
|
+
var writeErr error
|
|
87
104
|
onAudio := func(audio []byte) error {
|
|
88
|
-
|
|
105
|
+
if err := player.Write(audio); err != nil {
|
|
106
|
+
writeErr = err
|
|
107
|
+
return err
|
|
108
|
+
}
|
|
109
|
+
return nil
|
|
89
110
|
}
|
|
90
111
|
|
|
91
112
|
if err := synthesizeStream(context.Background(), j.cfg, j.text, &j.voice, onAudio); err != nil {
|
|
113
|
+
if writeErr != nil {
|
|
114
|
+
return writeErr
|
|
115
|
+
}
|
|
92
116
|
log.Printf("TTS 合成失败: %v", err)
|
|
93
|
-
return
|
|
94
117
|
}
|
|
95
118
|
log.Printf("TTS: 完成 elapsed=%s", time.Since(startedAt).Round(time.Millisecond))
|
|
119
|
+
return nil
|
|
96
120
|
}
|
|
97
121
|
|
|
98
122
|
// 音色信息
|
|
@@ -535,7 +559,7 @@ func handleConnection(conn net.Conn, player *Player) {
|
|
|
535
559
|
return
|
|
536
560
|
}
|
|
537
561
|
|
|
538
|
-
voice, content := extractVoicePrefix(text, cfg)
|
|
562
|
+
source, voice, content := extractVoicePrefix(text, cfg)
|
|
539
563
|
if voice == nil {
|
|
540
564
|
voice = cfg.DefaultVoice
|
|
541
565
|
}
|
|
@@ -549,22 +573,24 @@ func handleConnection(conn net.Conn, player *Player) {
|
|
|
549
573
|
return
|
|
550
574
|
}
|
|
551
575
|
|
|
552
|
-
player.Submit(cleaned, *voice, cfg)
|
|
576
|
+
player.Submit(cleaned, *voice, source, cfg)
|
|
553
577
|
}
|
|
554
578
|
|
|
555
|
-
// 解析消息中的音色前缀,返回
|
|
556
|
-
func extractVoicePrefix(text string, cfg Config) (voice *VoiceInfo, content string) {
|
|
579
|
+
// 解析消息中的音色前缀,返回 (来源, 音色, 内容)
|
|
580
|
+
func extractVoicePrefix(text string, cfg Config) (source string, voice *VoiceInfo, content string) {
|
|
557
581
|
// 格式: {source:claude}文本
|
|
558
582
|
const prefix = "{source:"
|
|
559
583
|
if strings.HasPrefix(text, prefix) {
|
|
560
584
|
if end := strings.Index(text, "}"); end > len(prefix) {
|
|
561
|
-
|
|
585
|
+
source = text[len(prefix):end]
|
|
586
|
+
if v, ok := cfg.SourceVoices[source]; ok {
|
|
562
587
|
voice = v
|
|
563
588
|
}
|
|
564
589
|
content = text[end+1:]
|
|
565
590
|
return
|
|
566
591
|
}
|
|
567
592
|
}
|
|
593
|
+
source = "default"
|
|
568
594
|
content = text
|
|
569
595
|
return
|
|
570
596
|
}
|
package/package.json
CHANGED
package/scripts/ispeak
CHANGED