@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  .PHONY: build test pack release push install deploy uninstall clean help
2
2
 
3
- VERSION := 1.6.16
3
+ VERSION := 1.7.0
4
4
  TAG := v$(VERSION)
5
5
  NPM_PKG := @xdfnet/ispeak
6
6
  BIN := build/ispeakd
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # iSpeak
2
2
 
3
- ![Version](https://img.shields.io/badge/version-1.6.9-blue)
3
+ ![Version](https://img.shields.io/badge/version-1.7.0-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)
@@ -20,8 +20,8 @@ ispeak "Pull request 已合并,3 个测试通过"
20
20
 
21
21
  | 问题 | 方案 |
22
22
  |------|------|
23
- | AI 生成多条回复,TTS 账单飞涨 | 新消息只保留最新待执行任务,避免无效合成 |
24
- | 回复快慢不一,音频播报乱序 | 单 transaction worker,FIFO 顺序稳定 |
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
- (pending running → delete)
64
+ Player (channel)
65
+ buffer=1 + drain(新消息丢弃旧排队消息)
66
66
  │ │ │
67
67
  │ ▼ │
68
- Worker 流式链路
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` 会按 `turn_id` 做一次去重,所以同一回合不会播两次。
132
+ `hook-speak.sh` 会自动跳过 Codex 遗留 notify 的 `agent-turn-complete` 事件,避免同一回合重复播报。
142
133
 
143
134
  ## 开发命令
144
135
 
@@ -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
@@ -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
- 当前版本采用“任务仓库 + transaction worker”流式链路:
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
- ispeak (bash CLI) ──nc -U──> ~/.config/iSpeak/ispeak.sock
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
- │ - net.Listener.Accept()
25
- │ - 每个连接读取文本并提交任务 │
22
+ │ Socket Acceptor (handleConnection)
23
+ │ - 读文本 → 解析 {source:xxx} → 选音色 → cleanText → 提交
26
24
  │ │
27
- Task Engine
25
+ Player (channel 驱动)
28
26
  │ ┌───────────────────────────────────────────────────────┐ │
29
- │ │ Task Repository (in-memory) │ │
30
- │ │ - tasks: map[uint64]*Task │ │
31
- │ │ - pending: []uint64 (FIFO) │ │
27
+ │ │ chan job (buffer=1) │ │
28
+ │ │ Submit: drain 旧消息 → 入队最新 │ │
29
+ │ │ loop: for j := range ch → play(j, player) │ │
32
30
  │ └───────────────────────────────────────────────────────┘ │
33
31
  │ │ │
34
32
  │ ▼ │
35
- Transaction Worker (single)
36
- - pending -> running
37
- - 调用 TTS 流式接口(失败直接删除,不重试)
38
- - SSE audio chunk -> StreamPlayer.Write
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
- ### Task
42
+ ### job
47
43
 
48
44
  ```go
49
- type Task struct {
50
- ID uint64 // 任务 ID(递增)
51
- Text string // 过滤后的待执行文本
52
- Status TaskStatus // 当前状态
53
- Voice VoiceInfo // 任务音色快照
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
- ### TaskStatus
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 TaskEngine struct {
75
- mu sync.Mutex
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
- `Submit(cleanedText, voice, cfg)` 原子执行:
109
- 1. 删除所有 `pending` 任务
110
- 2. 不打断当前 `running` 事务
111
- 3. 创建新任务(`pending`)
112
- 4. 唤醒 transaction worker
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
- ### Transaction worker 规则
85
+ `Submit()`:
86
+ - 非阻塞 drain channel 中旧消息:`select { case <-ch: default: }`
87
+ - 新消息入队
120
88
 
121
- 1. FIFO 领取 `pending` 任务并置 `running`
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
- ### 1. 接收并清洗消息
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
- `handleConnection()`:
133
- - 读取 socket 文本
134
- - 解析 `{source:xxx}` 音色前缀
135
- - `cleanText()` 生成语音友好的文本
136
- - 将“过滤后文本”提交给 `TaskEngine.Submit`
100
+ ## SSE 解析
137
101
 
138
- `cleanText()` 只影响 TTS 播报,不改变屏幕显示内容。当前清洗规则:
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
- - Markdown 格式符号:标题、加粗、反引号、引用符
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
- ### 2. 流式合成播放阶段
117
+ ## 稳定性设计
152
118
 
153
- - transaction worker 领取任务
154
- - HTTP POST 火山引擎 TTS 接口
155
- - 解析 SSE 流并 base64 解码音频 chunk
156
- - 优先将 chunk 写入 `ffplay` stdin 实时播放
157
- - 没有 `ffplay` 时缓存完整音频,结束后写临时 MP3 并用 `afplay` 播放
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
- - 单引擎锁 `mu` 保护任务仓库与 FIFO 队列
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
- - 新任务到达时只清理 `pending`,不打断当前任务
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
- - 关键 worker 使用 `panic recover`
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:-}" # Codex notify 走 $2
29
+ input="${2:-}" # 遗留 notify 走 $2
30
30
  if [[ -z "$input" ]]; then
31
- input=$(cat) # Claude / Codex Stop Hook 走 stdin
31
+ input=$(cat) # Stop Hook 走 stdin
32
32
  fi
33
33
  ```
34
34
 
35
- ## Codex notify
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
- Codex `notify = [...]` JSON 追加为命令最后一个 argv 参数。
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
- ```toml
42
- notify = ["/Users/xxx/.config/iSpeak/hook-speak.sh", "codex"]
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
- $1 = "codex"
49
- $2 = '{"type":"agent-turn-complete",...,"last-assistant-message":"..."}'
50
- ```
77
+ # ~/.claude/settings.json — Claude Code
78
+ "command": "bash ~/.config/iSpeak/hook-speak.sh claude"
51
79
 
52
- 源码:`codex-rs/hooks/src/legacy_notify.rs``last_assistant_message` 序列化为 kebab-case `last-assistant-message`,通过 `command.arg(notify_payload)` 传入。
80
+ # ~/.codex/hooks.jsonCodex
81
+ "command": "bash /Users/admin/.config/iSpeak/hook-speak.sh codex"
82
+ ```
53
83
 
54
- ## Codex Stop Hook
84
+ 文本加上 `{source:claude}` `{source:codex}` 前缀发往 socket,`ispeakd` 解析后选择对应音色。无前缀则用 `defaultVoice`。
55
85
 
56
- stdin JSON:
86
+ 音色映射示例(`~/.config/iSpeak/config.json`):
57
87
 
58
88
  ```json
59
89
  {
60
- "turn_id": "...",
61
- "transcript_path": "...",
62
- "last_assistant_message": "最后一条 assistant 回复"
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
- 源码:`codex-rs/hooks/src/events/stop.rs` — `StopCommandInput` struct 包含 `last_assistant_message`。
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`**(实测确认)。优先用 direct 字段,无需读 transcript。
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 轮询,但 Claude/Codex 分开写提取逻辑。
88
- - v3(当前,51 行):统一提取,一行覆盖所有来源。
125
+ - v2(53 行):省略去重和 transcript 轮询,但 `payload.message` 回退太宽泛,且 Codex 重复触发未处理。
126
+ - v351 行):统一提取,移除 `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 string
52
- voice VoiceInfo
53
- cfg Config
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
- return player.Write(audio)
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
- // 解析消息中的音色前缀,返回 VoiceInfo
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
- if v, ok := cfg.SourceVoices[text[len(prefix):end]]; ok {
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xdfnet/ispeak",
3
- "version": "1.6.16",
3
+ "version": "1.7.0",
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.16"
5
+ VERSION="1.7.0"
6
6
  SOCK="$HOME/.config/iSpeak/ispeak.sock"
7
7
  PLIST="$HOME/Library/LaunchAgents/com.ispeak.plist"
8
8
  LEGACY_PLIST="$HOME/Library/LaunchAgents/com.iSpeak.plist"