@xdfnet/ispeak 1.6.15 → 1.6.16

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/AGENTS.md CHANGED
@@ -28,11 +28,9 @@ make help # 显示帮助
28
28
  ispeak (CLI, bash)
29
29
  └─ nc -U ~/.config/iSpeak/ispeak.sock
30
30
  └─ ispeakd (Go daemon)
31
- ├─ Task Engine (任务仓库)
32
- └─ pending FIFO
33
- └─ transactionWorker (single)
34
- └─ pending -> running -> delete
35
- └─ SSE PCM chunk -> AVAudioEngine
31
+ └─ Player (channel, buffer=1)
32
+ └─ loop goroutine: 单 AVAudioEngine 实例复用
33
+ └─ SSE PCM chunk → AVAudioEngine
36
34
  ```
37
35
 
38
36
  - **Socket**: `~/.config/iSpeak/ispeak.sock`
@@ -42,7 +40,7 @@ ispeak (CLI, bash)
42
40
 
43
41
  ## 核心文件
44
42
 
45
- - `main.go` — 守护进程、任务引擎、TTS 流式请求、SSE 解析、流式播放
43
+ - `main.go` — 守护进程、Player (channel 驱动)、TTS 流式请求、SSE 解析
46
44
  - `avaudioengine_player_darwin.go` — macOS 原生 `AVAudioEngine` PCM 播放器
47
45
  - `clean_text.go` — TTS 播报文本清洗
48
46
  - `main_test.go` — 任务引擎关键行为测试
@@ -62,18 +60,13 @@ CLI 与 daemon 通过 socket 传输原始文本,支持音色前缀:
62
60
  ## 任务策略(节省 TTS 费用)
63
61
 
64
62
  新消息到达时:
65
- 1. 删除所有 `pending` 任务(未开始)
66
- 2. 不打断当前 `running` 事务
67
- 3. 创建新任务并进入 `pending`
68
-
69
- **任务状态流转:**
70
- ```
71
- pending → running → delete
72
- ```
63
+ 1. 丢弃 channel 中排队的旧消息
64
+ 2. 不打断当前正在合成/播放的任务
65
+ 3. 新消息入队
73
66
 
74
67
  ## 失败策略
75
68
 
76
- - 流式合成/播放失败:直接删除任务,不重试,避免重复播报
69
+ - 流式合成/播放失败:日志记录,继续处理下一条,不重试
77
70
 
78
71
  ## 配置
79
72
 
@@ -93,11 +86,11 @@ pending → running → delete
93
86
 
94
87
  ## 稳定性设计
95
88
 
96
- - 单 transaction worker,合成与播放同链路,降低首播延迟
97
- - 关键 goroutine 有 `panic recover`
89
+ - 单 Player goroutine,合成与播放同链路,降低首播延迟
90
+ - AVAudioEngine 实例复用,避免重复初始化开销
91
+ - Channel buffer=1 + drain,新消息自动丢弃旧排队消息
98
92
  - 配置热更新(mtime 缓存 + 自动重载)
99
93
  - TTS HTTP Client 复用,减少连接开销
100
94
  - 主链路使用 macOS 原生 `AVAudioEngine` 播放 PCM
101
- - 播放失败直接删除任务,不重试
95
+ - 合成/播放失败直接跳过,不重试
102
96
  - 日志轮转,防止文件过大
103
- - 进程级 temp 目录,退出时自动清理
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.9
3
+ VERSION := 1.6.16
4
4
  TAG := v$(VERSION)
5
5
  NPM_PKG := @xdfnet/ispeak
6
6
  BIN := build/ispeakd
@@ -252,12 +252,6 @@ func (p *avAudioEngineStreamPlayer) CloseAndWait() error {
252
252
  return p.closeLocked()
253
253
  }
254
254
 
255
- func (p *avAudioEngineStreamPlayer) Abort() error {
256
- p.mu.Lock()
257
- defer p.mu.Unlock()
258
- return p.closeLocked()
259
- }
260
-
261
255
  func (p *avAudioEngineStreamPlayer) writeChunk(data []byte) error {
262
256
  if len(data) == 0 {
263
257
  return nil
@@ -1,15 +1,14 @@
1
1
  #!/bin/bash
2
2
  # Claude Code / Codex 共用播报 Hook:
3
- # 只取最后一条 assistant 回复,加 `{source:<name>}` 前缀后发给 ispeakd。
3
+ # last_assistant_message,加 {source:<name>} 前缀后发给 ispeakd。
4
+ # Claude: payload.last_assistant_message (snake_case)
5
+ # Codex: payload["last-assistant-message"] (kebab-case)
4
6
  [[ "$ISPEAK_SKIP" == "1" ]] && exit 0
5
7
 
6
8
  SOURCE="${1:-claude}"
7
9
  SOCK="$HOME/.config/iSpeak/ispeak.sock"
8
10
  LOG="$HOME/.config/iSpeak/hook.log"
9
- STATE_FILE="$HOME/.config/iSpeak/hook.last"
10
11
 
11
- # Codex `notify` 会把 JSON 作为最后一个参数传入;
12
- # Claude/Claude 风格 Stop Hook 会把 JSON 写到 stdin。
13
12
  input="${2:-}"
14
13
  if [[ -z "$input" ]]; then
15
14
  input=$(cat)
@@ -18,183 +17,26 @@ input_file=$(mktemp)
18
17
  trap 'rm -f "$input_file"' EXIT
19
18
  printf "%s" "$input" > "$input_file"
20
19
 
21
- result=$(SOURCE="$SOURCE" HOOK_INPUT_FILE="$input_file" HOOK_STATE_FILE="$STATE_FILE" node <<'NODE' 2>/dev/null
20
+ result=$(HOOK_INPUT_FILE="$input_file" node <<'NODE' 2>>"$LOG"
22
21
  const fs = require("fs");
23
- const crypto = require("crypto");
24
22
 
25
23
  (() => {
26
24
  const input = readFile(process.env.HOOK_INPUT_FILE || "");
27
25
  const payload = parseJSON(input) || {};
28
- const source = process.env.SOURCE || "";
29
- const stateFile = process.env.HOOK_STATE_FILE || "";
30
- const result = source.startsWith("codex")
31
- ? lastCodexAssistant(payload)
32
- : lastClaudeAssistant(payload);
33
26
 
34
- if (!result.text) {
35
- return;
36
- }
27
+ const text = payload.last_assistant_message
28
+ || payload["last-assistant-message"]
29
+ || "";
37
30
 
38
- if (stateFile && result.turnId) {
39
- if (isDuplicateTurn(stateFile, source, result.turnId)) {
40
- return;
41
- }
42
- saveTurn(stateFile, source, result.turnId, result.text);
43
- } else if (stateFile) {
44
- saveTurn(stateFile, source, "", result.text);
45
- }
46
-
47
- process.stdout.write(result.text);
31
+ if (text) process.stdout.write(text);
48
32
  })();
49
33
 
50
- function lastClaudeAssistant(payload) {
51
- const direct = firstString(payload.last_assistant_message, payload.message);
52
- if (direct) return { text: direct, turnId: extractTurnId(payload) };
53
-
54
- const transcript = firstString(payload.transcript_path, payload.transcriptPath);
55
- return transcript ? lastClaudeTranscript(transcript, payload) : { text: "", turnId: extractTurnId(payload) };
56
- }
57
-
58
- function lastCodexAssistant(payload) {
59
- const direct = firstString(
60
- payload["last-assistant-message"],
61
- payload.last_assistant_message,
62
- payload.lastAssistantMessage,
63
- payload.message,
64
- payload.lastMessage
65
- );
66
- if (direct) return { text: direct, turnId: extractTurnId(payload) };
67
-
68
- const transcript = firstString(
69
- payload.transcript_path,
70
- payload.transcriptPath,
71
- payload["transcript-path"]
72
- );
73
- return transcript ? lastAssistantFromTranscript(transcript, "codex") : { text: "", turnId: extractTurnId(payload) };
74
- }
75
-
76
34
  function readFile(file) {
77
- try {
78
- return fs.readFileSync(file, "utf8");
79
- } catch {
80
- return "";
81
- }
35
+ try { return fs.readFileSync(file, "utf8"); } catch { return ""; }
82
36
  }
83
-
84
37
  function parseJSON(text) {
85
- try {
86
- return JSON.parse(text);
87
- } catch {
88
- return null;
89
- }
90
- }
91
-
92
- function firstString(...values) {
93
- for (const value of values) {
94
- if (typeof value === "string" && value !== "") return value;
95
- }
96
- return "";
97
- }
98
-
99
- function collectText(content) {
100
- if (typeof content === "string") return content;
101
- if (Array.isArray(content)) {
102
- return content
103
- .map(item => collectText(item))
104
- .filter(Boolean)
105
- .join(" ");
106
- }
107
- if (!content || typeof content !== "object") return "";
108
- if (typeof content.text === "string") return content.text;
109
- if (content.content) return collectText(content.content);
110
- return "";
38
+ try { return JSON.parse(text); } catch { return null; }
111
39
  }
112
-
113
- function lastClaudeTranscript(file, payload) {
114
- const deadline = Date.now() + 5000;
115
- let result = { text: "", turnId: extractTurnId(payload) };
116
-
117
- while (Date.now() <= deadline) {
118
- result = lastAssistantFromTranscript(file, "claude");
119
- if (result.text) return result;
120
- sleepMs(120);
121
- }
122
-
123
- return result;
124
- }
125
-
126
- function lastAssistantFromTranscript(file, source) {
127
- let data = "";
128
- try {
129
- data = fs.readFileSync(file, "utf8");
130
- } catch {
131
- return "";
132
- }
133
-
134
- let last = "";
135
- let turnId = "";
136
- for (const line of data.split(/\r?\n/)) {
137
- if (!line.trim()) continue;
138
- const event = parseJSON(line);
139
- if (!event) continue;
140
-
141
- if (source === "claude") {
142
- if (event.role === "assistant") {
143
- last = collectText(event.content) || last;
144
- }
145
- if (event.message && event.message.role === "assistant") {
146
- last = collectText(event.message.content) || last;
147
- }
148
- }
149
-
150
- if (source === "codex" &&
151
- event.type === "response_item" &&
152
- event.payload &&
153
- event.payload.type === "message" &&
154
- event.payload.role === "assistant"
155
- ) {
156
- last = collectText(event.payload.content) || last;
157
- turnId = turnId || extractTurnId(event) || extractTurnId(event.payload);
158
- }
159
- }
160
- return { text: last, turnId };
161
- }
162
-
163
- function extractTurnId(payload) {
164
- return firstString(
165
- payload.turn_id,
166
- payload.turnId,
167
- payload["turn-id"]
168
- );
169
- }
170
-
171
- function isDuplicateTurn(stateFile, source, turnId) {
172
- const current = `${source}:${turnId}`;
173
- try {
174
- return fs.readFileSync(stateFile, "utf8").trim() === current;
175
- } catch {
176
- return false;
177
- }
178
- }
179
-
180
- function saveTurn(stateFile, source, turnId, text) {
181
- const current = `${source}:${turnId || textHash(text)}`;
182
- try {
183
- fs.mkdirSync(require("path").dirname(stateFile), { recursive: true });
184
- fs.writeFileSync(stateFile, current, "utf8");
185
- } catch {
186
- // 去重失败不影响播报。
187
- }
188
- }
189
-
190
- function textHash(text) {
191
- return crypto.createHash("sha1").update(text, "utf8").digest("hex");
192
- }
193
-
194
- function sleepMs(ms) {
195
- Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
196
- }
197
-
198
40
  NODE
199
41
  )
200
42
 
@@ -203,31 +45,8 @@ if [[ "$ISPEAK_HOOK_PRINT_TEXT" == "1" ]]; then
203
45
  exit 0
204
46
  fi
205
47
 
206
- echo "=== $(date) ===" >> "$LOG"
207
- echo "SOURCE: $SOURCE" >> "$LOG"
208
- echo "TEXT_LEN: ${#result}" >> "$LOG"
209
- echo "PREVIEW: ${result:0:150}" >> "$LOG"
210
-
211
- # Claude Code Stop Hook 调试
212
- if [[ "$SOURCE" == "claude" && -n "$input" ]]; then
213
- # 用 grep 提取 transcript_path
214
- tp=$(echo "$input" | grep -o '"transcript_path":"[^"]*"' | head -1 | sed 's/"transcript_path":"//;s/"$//')
215
- if [[ -n "$tp" ]]; then
216
- echo "CLAUDE_TRANSCRIPT_PATH: $tp" >> "$LOG"
217
- if [[ -f "$tp" ]]; then
218
- echo "CLAUDE_TRANSCRIPT_EXISTS: yes" >> "$LOG"
219
- else
220
- echo "CLAUDE_TRANSCRIPT_EXISTS: no" >> "$LOG"
221
- fi
222
- else
223
- echo "CLAUDE_TRANSCRIPT_PATH: none" >> "$LOG"
224
- echo "CLAUDE_RAW: ${input:0:300}" >> "$LOG"
225
- fi
226
- fi
227
-
228
48
  if [[ -n "$result" && -S "$SOCK" ]]; then
229
49
  printf "{source:%s}%s" "$SOURCE" "$result" | nc -U -w5 "$SOCK" 2>> "$LOG"
230
- echo "SPOKE: OK" >> "$LOG"
231
50
  else
232
- echo "SPOKE: SKIP" >> "$LOG"
51
+ echo "$(date): SKIP source=$SOURCE text_len=${#result}" >> "$LOG"
233
52
  fi
@@ -1,274 +1,88 @@
1
1
  # Hook 文本提取链路
2
2
 
3
- 本文记录 Claude Code / Codex CLI 在 Hook 中拿到“最后一条 assistant 回复”的实际方式。`hook-speak.sh` 的目标只做两件事:取最后一条 assistant 回复,发给 iSpeak socket。
3
+ `hook-speak.sh` 只做一件事:从 Hook JSON 里取 assistant 回复文本,发给 iSpeak socket。
4
4
 
5
5
  ## 结论
6
6
 
7
- 推荐优先级:
7
+ 一行提取,覆盖所有来源:
8
8
 
9
- 1. **Codex `notify`**:从脚本第二个参数 `$2` 读取 JSON,取 `last-assistant-message`(kebab-case)。
10
- 2. **Codex Stop Hook**:从 stdin 读取 JSON,取 `last_assistant_message`(snake_case)。
11
- 3. **Claude Code Stop Hook**:从 stdin 读取 JSON,只读 `transcript_path`(官方无 direct 字段)。
12
-
13
- 不扫描 `~/.codex/sessions`。没有 direct 字段也没有 `transcript_path` 时,本次不播报。
14
-
15
- ## Codex CLI:notify
16
-
17
- 当前本机版本:
18
-
19
- ```text
20
- codex-cli 0.130.0
9
+ ```js
10
+ const text = payload.last_assistant_message // Claude Stop / Codex Stop (snake_case)
11
+ || payload["last-assistant-message"] // Codex notify (kebab-case)
12
+ || payload.message // fallback
13
+ || "";
21
14
  ```
22
15
 
23
- Codex CLI 的 `notify = [...]` 是 legacy notify 机制。官方源码里会把通知 JSON 追加成命令的最后一个 argv 参数,不写 stdin。
16
+ 不再需要 transcript 轮询、去重、状态文件。
24
17
 
25
- 配置示例:
18
+ ## 输入来源
26
19
 
27
- ```toml
28
- notify = ["/Users/你的用户名/.config/iSpeak/hook-speak.sh", "codex"]
29
- ```
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` |
30
25
 
31
- 脚本实际收到:
26
+ 脚本统一处理:
32
27
 
33
28
  ```bash
34
- $1 = "codex"
35
- $2 = '{"type":"agent-turn-complete",...,"last-assistant-message":"..."}'
36
- stdin = empty
37
- ```
38
-
39
- 核心字段:
40
-
41
- ```json
42
- {
43
- "type": "agent-turn-complete",
44
- "thread-id": "...",
45
- "turn-id": "...",
46
- "cwd": "...",
47
- "input-messages": ["..."],
48
- "last-assistant-message": "最后一条 assistant 回复"
49
- }
50
- ```
51
-
52
- 所以 Codex `notify` 的正确读取方式是:
53
-
54
- ```bash
55
- input="${2:-}"
56
- ```
57
-
58
- 然后解析:
59
-
60
- ```js
61
- payload["last-assistant-message"]
29
+ input="${2:-}" # Codex notify 走 $2
30
+ if [[ -z "$input" ]]; then
31
+ input=$(cat) # Claude / Codex Stop Hook 走 stdin
32
+ fi
62
33
  ```
63
34
 
64
- 源码依据:`codex-rs/hooks/src/legacy_notify.rs`(https://github.com/openai/codex,2026-05-11)。该文件把 `last_assistant_message` 序列化为 kebab-case 的 `last-assistant-message`,并在执行命令前 `command.arg(notify_payload)`。
65
-
66
- ## Codex CLI:Stop Hook
35
+ ## Codex notify
67
36
 
68
- Codex 也支持 Claude 风格 Hook。Stop Hook 的输入 JSON 写入 stdin。
37
+ Codex `notify = [...]` JSON 追加为命令最后一个 argv 参数。
69
38
 
70
- 配置示例:
39
+ 配置:
71
40
 
72
- ```json
73
- {
74
- "hooks": {
75
- "Stop": [
76
- {
77
- "hooks": [
78
- {
79
- "type": "command",
80
- "command": "bash $HOME/.config/iSpeak/hook-speak.sh codex",
81
- "timeout": 30
82
- }
83
- ]
84
- }
85
- ]
86
- }
87
- }
41
+ ```toml
42
+ notify = ["/Users/xxx/.config/iSpeak/hook-speak.sh", "codex"]
88
43
  ```
89
44
 
90
- 脚本实际收到:
45
+ 脚本收到:
91
46
 
92
47
  ```bash
93
48
  $1 = "codex"
94
- $2 = empty
95
- stdin = '{"hook_event_name":"Stop",...,"last_assistant_message":"..."}'
49
+ $2 = '{"type":"agent-turn-complete",...,"last-assistant-message":"..."}'
96
50
  ```
97
51
 
98
- 核心字段(源码 `StopCommandInput` struct):
52
+ 源码:`codex-rs/hooks/src/legacy_notify.rs` `last_assistant_message` 序列化为 kebab-case `last-assistant-message`,通过 `command.arg(notify_payload)` 传入。
99
53
 
100
- ```rust
101
- struct StopCommandInput {
102
- session_id: String,
103
- turn_id: String,
104
- transcript_path: NullableString,
105
- cwd: String,
106
- hook_event_name: String,
107
- model: String,
108
- permission_mode: String,
109
- stop_hook_active: bool,
110
- last_assistant_message: NullableString, // ← Codex 有此字段
111
- }
112
- ```
54
+ ## Codex Stop Hook
113
55
 
114
- 对应 JSON:
56
+ stdin JSON:
115
57
 
116
58
  ```json
117
59
  {
118
- "session_id": "...",
119
60
  "turn_id": "...",
120
61
  "transcript_path": "...",
121
- "cwd": "...",
122
- "hook_event_name": "Stop",
123
- "model": "...",
124
- "permission_mode": "bypassPermissions",
125
- "stop_hook_active": false,
126
62
  "last_assistant_message": "最后一条 assistant 回复"
127
63
  }
128
64
  ```
129
65
 
130
- 源码依据:
131
-
132
- - `codex-rs/hooks/src/events/stop.rs`(https://github.com/openai/codex,2026-05-11):构造 `StopCommandInput`,包含 `last_assistant_message` 和 `transcript_path`。
133
- - `codex-rs/hooks/src/engine/command_runner.rs`(同上):Hook 命令通过 stdin 接收 `input_json`。
66
+ 源码:`codex-rs/hooks/src/events/stop.rs` — `StopCommandInput` struct 包含 `last_assistant_message`。
134
67
 
135
- ## Codex Transcript
68
+ ## Claude Code Stop Hook
136
69
 
137
- Codex 的 transcript/session 文件是 JSONL。实际 assistant 回复形态:
70
+ stdin JSON(实测,2026-05):
138
71
 
139
72
  ```json
140
73
  {
141
- "type": "response_item",
142
- "payload": {
143
- "type": "message",
144
- "role": "assistant",
145
- "content": [
146
- {
147
- "type": "output_text",
148
- "text": "最后一条 assistant 回复"
149
- }
150
- ]
151
- }
152
- }
153
- ```
154
-
155
- 提取规则:
156
-
157
- ```js
158
- event.type === "response_item" &&
159
- event.payload?.type === "message" &&
160
- event.payload?.role === "assistant"
161
- ```
162
-
163
- 然后拼接:
164
-
165
- ```js
166
- event.payload.content[].text
167
- ```
168
-
169
- ## Claude Code:Stop Hook
170
-
171
- > **来源**:[Claude Code Hooks Reference](https://code.claude.com/docs/en/hooks.md),更新时间:2026-05-11
172
-
173
- Claude Code 官方 Stop Hook **没有 `last_assistant_message` 字段**。
174
-
175
- 根据官方文档,Stop Hook 的 Common Input Fields 为:
176
-
177
- ```json
178
- {
179
- "session_id": "abc123",
180
- "transcript_path": "/home/user/.claude/projects/.../transcript.jsonl",
181
- "cwd": "/home/user/my-project",
182
- "permission_mode": "default",
74
+ "session_id": "...",
75
+ "transcript_path": "/Users/admin/.claude/projects/.../xxx.jsonl",
76
+ "cwd": "...",
183
77
  "hook_event_name": "Stop",
184
- "effort": {
185
- "level": "medium"
186
- }
187
- }
188
- ```
189
-
190
- 子 agent 上下文中额外字段:
191
-
192
- ```json
193
- {
194
- "agent_id": "subagent_xyz",
195
- "agent_type": "Explore"
78
+ "last_assistant_message": "最后一条 assistant 回复"
196
79
  }
197
80
  ```
198
81
 
199
- **结论**:Claude Code Stop Hook 官方设计只提供 `transcript_path`,没有直接内嵌 `last_assistant_message`。旧版本脚本的 `last_assistant_message` / `message` fallback 实际上**从未被官方文档支持**。
200
-
201
- Claude transcript 常见 assistant 形态:
202
-
203
- ```json
204
- {"role":"assistant","content":[{"type":"text","text":"..."}]}
205
- ```
206
-
207
- 或:
208
-
209
- ```json
210
- {"message":{"role":"assistant","content":[{"type":"text","text":"..."}]}}
211
- ```
212
-
213
- ## 当前脚本策略
214
-
215
- `configs/hook-speak.sh` 当前入口:
216
-
217
- ```bash
218
- input="${2:-}"
219
- if [[ -z "$input" ]]; then
220
- input=$(cat)
221
- fi
222
- ```
223
-
224
- 含义:
225
-
226
- - Codex `notify`:读 `$2`
227
- - Claude / Codex Stop Hook:读 stdin
228
- - 如果 Codex 的 `notify` 和 `Stop` 同时启用,脚本会按 `turn_id` 去重,避免同一回合播两次
229
-
230
- Codex 文本字段优先级(源码确认):
231
-
232
- ```js
233
- payload["last-assistant-message"] // notify: kebab-case
234
- payload.last_assistant_message // Stop Hook: snake_case
235
- payload.lastAssistantMessage
236
- payload.message
237
- payload.lastMessage
238
- payload.transcript_path
239
- payload.transcriptPath
240
- payload["transcript-path"]
241
- ```
242
-
243
- Claude Code 文本字段优先级(官方文档):
244
-
245
- ```js
246
- payload.transcript_path // 官方支持的唯一方式
247
- ```
248
-
249
- > **注**:Claude Code Stop Hook 官方 payload 中**没有 `last_assistant_message` 字段**,这是与 Codex 的本质区别。
250
-
251
- ## 为什么不能只读 stdin
252
-
253
- 因为 Codex `notify` 不走 stdin。只读 stdin 会导致:
254
-
255
- ```text
256
- TEXT_LEN: 0
257
- SPOKE: SKIP
258
- ```
259
-
260
- 正确做法是先读 `$2`,再读 stdin;不扫历史 session。
261
-
262
- ## Claude Code TEXT_LEN: 0 的根因
263
-
264
- 当 Claude Code Stop Hook 触发但 `TEXT_LEN: 0` 时:
265
-
266
- 1. **官方字段不存在**:Claude Code Stop Hook 官方 payload 中**没有 `last_assistant_message` 字段**,只有 `transcript_path`
267
- 2. **transcript 文件可能晚一点才写完**:Hook 触发时文件虽已存在,但最后一条 assistant 文本还没落盘
268
- 3. **结果**:如果只读一次,`hook-speak.sh` 可能拿到空串,本次不播报
269
-
270
- 当前脚本对 Claude transcript 做了很短的轮询,等最后一条 assistant 文本真正出现再播,避免这个时序窗。
82
+ Claude Code 官方文档只列出 `transcript_path`,但实际 payload **包含 `last_assistant_message`**(实测确认)。优先用 direct 字段,无需读 transcript。
271
83
 
272
- 这是 **Claude Code 与 Codex 的设计差异**,非 bug。Codex CLI(无论 notify 还是 Stop Hook)都提供 `last_assistant_message`,而 Claude Code 官方只提供 `transcript_path`。
84
+ ## 历史演进
273
85
 
274
- 解决方案:从 `transcript_path` 读取并解析为最终一条 assistant 回复,并在 Claude 路径上补一个短轮询。
86
+ - v1(250 行):transcript 轮询 + turn_id 去重 + state file + text hash。复杂度高,`session_id` 做去重 key 导致同一 session 只播第一条。
87
+ - v2(53 行):省略去重和 transcript 轮询,但 Claude/Codex 分开写提取逻辑。
88
+ - v3(当前,51 行):统一提取,一行覆盖所有来源。
package/main.go CHANGED
@@ -40,7 +40,6 @@ var errAlreadyRunning = errors.New("iSpeak already running")
40
40
  type StreamPlayer interface {
41
41
  Write(audio []byte) error
42
42
  CloseAndWait() error
43
- Abort() error
44
43
  }
45
44
 
46
45
  // 最简单的播放器:channel 队列,串行播报
@@ -55,25 +54,31 @@ type job struct {
55
54
  }
56
55
 
57
56
  func NewPlayer() *Player {
58
- p := &Player{ch: make(chan job, 256)}
57
+ p := &Player{ch: make(chan job, 1)}
59
58
  go p.loop()
60
59
  return p
61
60
  }
62
61
 
63
62
  func (p *Player) Submit(text string, voice VoiceInfo, cfg Config) {
64
63
  log.Printf("TTS: %s", text)
64
+ // 丢弃队列中的旧消息,只保留最新
65
+ select {
66
+ case <-p.ch:
67
+ default:
68
+ }
65
69
  p.ch <- job{text, voice, cfg}
66
70
  }
67
71
 
68
72
  func (p *Player) loop() {
73
+ player, err := newDefaultStreamPlayer()
74
+ if err != nil {
75
+ log.Printf("启动播放器失败: %v", err)
76
+ return
77
+ }
78
+ defer player.CloseAndWait()
79
+
69
80
  for j := range p.ch {
70
- player, err := newDefaultStreamPlayer()
71
- if err != nil {
72
- log.Printf("启动播放器失败: %v", err)
73
- continue
74
- }
75
81
  p.play(j, player)
76
- _ = player.CloseAndWait()
77
82
  }
78
83
  }
79
84
 
@@ -231,7 +236,7 @@ func synthesizeStream(ctx context.Context, cfg Config, text string, voice *Voice
231
236
  return fmt.Errorf("http request: %w", err)
232
237
  }
233
238
  if resp.StatusCode != 200 {
234
- io.Copy(io.Discard, resp.Body) // 消费 body 以释放连接
239
+ io.Copy(io.Discard, resp.Body)
235
240
  resp.Body.Close()
236
241
  return fmt.Errorf("http status %d", resp.StatusCode)
237
242
  }
@@ -240,28 +245,6 @@ func synthesizeStream(ctx context.Context, cfg Config, text string, voice *Voice
240
245
  return parseSSEStream(resp.Body, onAudio)
241
246
  }
242
247
 
243
- // 解析 SSE 流,提取 base64 音频数据
244
- func parseSSE(r io.Reader) ([]byte, error) {
245
- var chunks [][]byte
246
- if err := parseSSEStream(r, func(audio []byte) error {
247
- chunk := append([]byte(nil), audio...)
248
- chunks = append(chunks, chunk)
249
- return nil
250
- }); err != nil {
251
- return nil, err
252
- }
253
-
254
- total := 0
255
- for _, c := range chunks {
256
- total += len(c)
257
- }
258
- result := make([]byte, 0, total)
259
- for _, c := range chunks {
260
- result = append(result, c...)
261
- }
262
- return result, nil
263
- }
264
-
265
248
  func parseSSEStream(r io.Reader, onAudio func([]byte) error) error {
266
249
  audioChunks := 0
267
250
  reader := bufio.NewReaderSize(r, 64*1024)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xdfnet/ispeak",
3
- "version": "1.6.15",
3
+ "version": "1.6.16",
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.9"
5
+ VERSION="1.6.16"
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"