@xdfnet/ispeak 1.6.6 → 1.6.8
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/README.md +9 -63
- package/configs/{com.iSpeak.plist → com.ispeak.plist} +1 -1
- package/configs/hook-speak.sh +70 -15
- package/{Docs/ARCHITECTURE.md → docs/architecture.md} +1 -1
- package/{Docs/HOOK_TEXT_EXTRACTION.md → docs/hook-text-extraction.md} +1 -0
- package/main.go +5 -174
- package/npm/postinstall.js +9 -4
- package/package.json +3 -5
- package/scripts/ispeak +5 -10
- /package/{Docs/HTTP Chunked:SSE/345/215/225/345/220/221/346/265/201/345/274/217-V3.md" → docs/http-chunked-sse-unidirectional-v3.md} +0 -0
- /package/{Docs//345/243/260/351/237/263/345/244/215/345/210/273API-V3.md" → docs/tts-voice-clone-api-v3.md} +0 -0
- /package/{Docs//351/237/263/350/211/262/345/210/227/350/241/250.md" → docs/voice-list.md} +0 -0
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
|

|
|
@@ -14,12 +14,6 @@ iSpeak 让 AI 编程助手开口说话。你写代码,它播结果——眼睛
|
|
|
14
14
|
```
|
|
15
15
|
# 默认音色:温柔女声
|
|
16
16
|
ispeak "Pull request 已合并,3 个测试通过"
|
|
17
|
-
|
|
18
|
-
# Claude 模式:专属音色
|
|
19
|
-
ispeak-claude "Code review 完成,发现 2 处可优化"
|
|
20
|
-
|
|
21
|
-
# Codex 模式:另一种音色
|
|
22
|
-
ispeak-codex "构建完成,耗时 12 秒"
|
|
23
17
|
```
|
|
24
18
|
|
|
25
19
|
## 为什么选 iSpeak
|
|
@@ -29,7 +23,7 @@ ispeak-codex "构建完成,耗时 12 秒"
|
|
|
29
23
|
| AI 生成多条回复,TTS 账单飞涨 | 新消息只保留最新待执行任务,避免无效合成 |
|
|
30
24
|
| 回复快慢不一,音频播报乱序 | 单 transaction worker,FIFO 顺序稳定 |
|
|
31
25
|
| 修改配置要重启服务 | 热更新:编辑 `config.json` 立即生效 |
|
|
32
|
-
| 默认音色太无聊 |
|
|
26
|
+
| 默认音色太无聊 | hook 按来源前缀选择音色 |
|
|
33
27
|
|
|
34
28
|
## 快速上手
|
|
35
29
|
|
|
@@ -39,11 +33,7 @@ ispeak-codex "构建完成,耗时 12 秒"
|
|
|
39
33
|
npm i -g @xdfnet/ispeak
|
|
40
34
|
```
|
|
41
35
|
|
|
42
|
-
当前 npm 安装会在本机编译 `ispeakd`,需要已安装 Go
|
|
43
|
-
|
|
44
|
-
```bash
|
|
45
|
-
brew install ffmpeg
|
|
46
|
-
```
|
|
36
|
+
当前 npm 安装会在本机编译 `ispeakd`,需要已安装 Go。主播放链路使用 macOS 原生 `AVAudioEngine`,不依赖 `ffmpeg`。失败时直接记录日志并删除任务。
|
|
47
37
|
|
|
48
38
|
**源码安装:**
|
|
49
39
|
|
|
@@ -76,11 +66,11 @@ ispeak "iSpeak 准备好了"
|
|
|
76
66
|
│ │ │
|
|
77
67
|
│ ▼ │
|
|
78
68
|
│ 单 Worker 流式链路 │
|
|
79
|
-
│ (SSE
|
|
69
|
+
│ (SSE PCM chunk → AVAudioEngine) │
|
|
80
70
|
│ │ │
|
|
81
71
|
│ ▼ │
|
|
82
|
-
│
|
|
83
|
-
│
|
|
72
|
+
│ 错误处理 │
|
|
73
|
+
│ (失败时记录日志并删除任务) │
|
|
84
74
|
└─────────────────────────────────────────────────────┘
|
|
85
75
|
```
|
|
86
76
|
|
|
@@ -117,12 +107,6 @@ ispeak restart # 重启服务
|
|
|
117
107
|
ispeak version # 版本
|
|
118
108
|
```
|
|
119
109
|
|
|
120
|
-
语音专属快捷命令(指向 ispeak 的软链接):
|
|
121
|
-
```bash
|
|
122
|
-
ispeak-claude "消息" # Claude 专属音色
|
|
123
|
-
ispeak-codex "消息" # Codex 专属音色
|
|
124
|
-
```
|
|
125
|
-
|
|
126
110
|
## 配置说明
|
|
127
111
|
|
|
128
112
|
`~/.config/iSpeak/config.json`:
|
|
@@ -152,47 +136,9 @@ ispeak-codex "消息" # Codex 专属音色
|
|
|
152
136
|
|
|
153
137
|
## 集成说明
|
|
154
138
|
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
在 `~/.claude/settings.json` 中添加 Stop Hook:
|
|
139
|
+
Claude Code 和 Codex 的详细 hook 配置见 [docs/hook-text-extraction.md](/Users/admin/iCode/iSpeak/docs/hook-text-extraction.md)。
|
|
158
140
|
|
|
159
|
-
|
|
160
|
-
{
|
|
161
|
-
"hooks": {
|
|
162
|
-
"Stop": [{
|
|
163
|
-
"hooks": [{
|
|
164
|
-
"type": "command",
|
|
165
|
-
"command": "bash $HOME/.config/iSpeak/hook-speak.sh claude",
|
|
166
|
-
"timeout": 30
|
|
167
|
-
}]
|
|
168
|
-
}]
|
|
169
|
-
}
|
|
170
|
-
}
|
|
171
|
-
```
|
|
172
|
-
|
|
173
|
-
### Codex
|
|
174
|
-
|
|
175
|
-
推荐在 `~/.codex/config.toml` 中添加回合结束通知:
|
|
176
|
-
|
|
177
|
-
```toml
|
|
178
|
-
notify = ["bash", "/Users/你的用户名/.config/iSpeak/hook-speak.sh", "codex"]
|
|
179
|
-
```
|
|
180
|
-
|
|
181
|
-
如果你启用了 Codex hooks,也可以在 `~/.codex/hooks.json` 中添加 Stop Hook:
|
|
182
|
-
|
|
183
|
-
```json
|
|
184
|
-
{
|
|
185
|
-
"hooks": {
|
|
186
|
-
"Stop": [{
|
|
187
|
-
"hooks": [{
|
|
188
|
-
"type": "command",
|
|
189
|
-
"command": "bash $HOME/.config/iSpeak/hook-speak.sh codex",
|
|
190
|
-
"timeout": 30
|
|
191
|
-
}]
|
|
192
|
-
}]
|
|
193
|
-
}
|
|
194
|
-
}
|
|
195
|
-
```
|
|
141
|
+
`hook-speak.sh` 会按 `turn_id` 做一次去重,所以同一回合不会播两次。
|
|
196
142
|
|
|
197
143
|
## 开发命令
|
|
198
144
|
|
|
@@ -209,7 +155,7 @@ make help # 显示帮助
|
|
|
209
155
|
|
|
210
156
|
| 文件 | 用途 |
|
|
211
157
|
|------|------|
|
|
212
|
-
| `~/Library/LaunchAgents/com.
|
|
158
|
+
| `~/Library/LaunchAgents/com.ispeak.plist` | macOS 自动启动服务 |
|
|
213
159
|
| `~/.config/iSpeak/ispeak.sock` | Unix Socket |
|
|
214
160
|
| `~/.config/iSpeak/ispeak.log` | 日志(轮转) |
|
|
215
161
|
| `~/.config/iSpeak/config.json` | 你的 API Key 和音色配置 |
|
package/configs/hook-speak.sh
CHANGED
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
SOURCE="${1:-claude}"
|
|
7
7
|
SOCK="$HOME/.config/iSpeak/ispeak.sock"
|
|
8
8
|
LOG="$HOME/.config/iSpeak/hook.log"
|
|
9
|
+
STATE_FILE="$HOME/.config/iSpeak/hook.last"
|
|
9
10
|
|
|
10
11
|
# Codex `notify` 会把 JSON 作为最后一个参数传入;
|
|
11
12
|
# Claude/Claude 风格 Stop Hook 会把 JSON 写到 stdin。
|
|
@@ -17,26 +18,41 @@ input_file=$(mktemp)
|
|
|
17
18
|
trap 'rm -f "$input_file"' EXIT
|
|
18
19
|
printf "%s" "$input" > "$input_file"
|
|
19
20
|
|
|
20
|
-
|
|
21
|
+
result=$(SOURCE="$SOURCE" HOOK_INPUT_FILE="$input_file" HOOK_STATE_FILE="$STATE_FILE" node <<'NODE' 2>/dev/null
|
|
21
22
|
const fs = require("fs");
|
|
23
|
+
const crypto = require("crypto");
|
|
22
24
|
|
|
23
|
-
{
|
|
25
|
+
(() => {
|
|
24
26
|
const input = readFile(process.env.HOOK_INPUT_FILE || "");
|
|
25
27
|
const payload = parseJSON(input) || {};
|
|
26
28
|
const source = process.env.SOURCE || "";
|
|
27
|
-
const
|
|
29
|
+
const stateFile = process.env.HOOK_STATE_FILE || "";
|
|
30
|
+
const result = source.startsWith("codex")
|
|
28
31
|
? lastCodexAssistant(payload)
|
|
29
32
|
: lastClaudeAssistant(payload);
|
|
30
33
|
|
|
31
|
-
if (text)
|
|
32
|
-
|
|
34
|
+
if (!result.text) {
|
|
35
|
+
return;
|
|
36
|
+
}
|
|
37
|
+
|
|
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);
|
|
48
|
+
})();
|
|
33
49
|
|
|
34
50
|
function lastClaudeAssistant(payload) {
|
|
35
51
|
const direct = firstString(payload.last_assistant_message, payload.message);
|
|
36
|
-
if (direct) return direct;
|
|
52
|
+
if (direct) return { text: direct, turnId: extractTurnId(payload) };
|
|
37
53
|
|
|
38
54
|
const transcript = firstString(payload.transcript_path, payload.transcriptPath);
|
|
39
|
-
return transcript ? lastAssistantFromTranscript(transcript, "claude") : "";
|
|
55
|
+
return transcript ? lastAssistantFromTranscript(transcript, "claude") : { text: "", turnId: extractTurnId(payload) };
|
|
40
56
|
}
|
|
41
57
|
|
|
42
58
|
function lastCodexAssistant(payload) {
|
|
@@ -47,14 +63,14 @@ function lastCodexAssistant(payload) {
|
|
|
47
63
|
payload.message,
|
|
48
64
|
payload.lastMessage
|
|
49
65
|
);
|
|
50
|
-
if (direct) return direct;
|
|
66
|
+
if (direct) return { text: direct, turnId: extractTurnId(payload) };
|
|
51
67
|
|
|
52
68
|
const transcript = firstString(
|
|
53
69
|
payload.transcript_path,
|
|
54
70
|
payload.transcriptPath,
|
|
55
71
|
payload["transcript-path"]
|
|
56
72
|
);
|
|
57
|
-
return transcript ? lastAssistantFromTranscript(transcript, "codex") : "";
|
|
73
|
+
return transcript ? lastAssistantFromTranscript(transcript, "codex") : { text: "", turnId: extractTurnId(payload) };
|
|
58
74
|
}
|
|
59
75
|
|
|
60
76
|
function readFile(file) {
|
|
@@ -98,6 +114,7 @@ function lastAssistantFromTranscript(file, source) {
|
|
|
98
114
|
}
|
|
99
115
|
|
|
100
116
|
let last = "";
|
|
117
|
+
let turnId = "";
|
|
101
118
|
for (const line of data.split(/\r?\n/)) {
|
|
102
119
|
if (!line.trim()) continue;
|
|
103
120
|
const event = parseJSON(line);
|
|
@@ -119,26 +136,64 @@ function lastAssistantFromTranscript(file, source) {
|
|
|
119
136
|
event.payload.role === "assistant"
|
|
120
137
|
) {
|
|
121
138
|
last = collectText(event.payload.content) || last;
|
|
139
|
+
turnId = turnId || extractTurnId(event) || extractTurnId(event.payload);
|
|
122
140
|
}
|
|
123
141
|
}
|
|
124
|
-
return last;
|
|
142
|
+
return { text: last, turnId };
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function extractTurnId(payload) {
|
|
146
|
+
return firstString(
|
|
147
|
+
payload.turn_id,
|
|
148
|
+
payload.turnId,
|
|
149
|
+
payload["turn-id"],
|
|
150
|
+
payload.session_id,
|
|
151
|
+
payload.sessionId,
|
|
152
|
+
payload["session-id"],
|
|
153
|
+
payload.thread_id,
|
|
154
|
+
payload.threadId,
|
|
155
|
+
payload["thread-id"]
|
|
156
|
+
);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function isDuplicateTurn(stateFile, source, turnId) {
|
|
160
|
+
const current = `${source}:${turnId}`;
|
|
161
|
+
try {
|
|
162
|
+
return fs.readFileSync(stateFile, "utf8").trim() === current;
|
|
163
|
+
} catch {
|
|
164
|
+
return false;
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function saveTurn(stateFile, source, turnId, text) {
|
|
169
|
+
const current = `${source}:${turnId || textHash(text)}`;
|
|
170
|
+
try {
|
|
171
|
+
fs.mkdirSync(require("path").dirname(stateFile), { recursive: true });
|
|
172
|
+
fs.writeFileSync(stateFile, current, "utf8");
|
|
173
|
+
} catch {
|
|
174
|
+
// 去重失败不影响播报。
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function textHash(text) {
|
|
179
|
+
return crypto.createHash("sha1").update(text, "utf8").digest("hex");
|
|
125
180
|
}
|
|
126
181
|
|
|
127
182
|
NODE
|
|
128
183
|
)
|
|
129
184
|
|
|
130
185
|
if [[ "$ISPEAK_HOOK_PRINT_TEXT" == "1" ]]; then
|
|
131
|
-
printf "%s" "$
|
|
186
|
+
printf "%s" "$result"
|
|
132
187
|
exit 0
|
|
133
188
|
fi
|
|
134
189
|
|
|
135
190
|
echo "=== $(date) ===" >> "$LOG"
|
|
136
191
|
echo "SOURCE: $SOURCE" >> "$LOG"
|
|
137
|
-
echo "TEXT_LEN: ${#
|
|
138
|
-
echo "PREVIEW: ${
|
|
192
|
+
echo "TEXT_LEN: ${#result}" >> "$LOG"
|
|
193
|
+
echo "PREVIEW: ${result:0:150}" >> "$LOG"
|
|
139
194
|
|
|
140
|
-
if [[ -n "$
|
|
141
|
-
printf "{source:%s}%s" "$SOURCE" "$
|
|
195
|
+
if [[ -n "$result" && -S "$SOCK" ]]; then
|
|
196
|
+
printf "{source:%s}%s" "$SOURCE" "$result" | nc -U -w5 "$SOCK" 2>> "$LOG"
|
|
142
197
|
echo "SPOKE: OK" >> "$LOG"
|
|
143
198
|
else
|
|
144
199
|
echo "SPOKE: SKIP" >> "$LOG"
|
package/main.go
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// ttsd — 独立 TTS 播报守护进程
|
|
2
|
-
// 监听 Unix Socket,收到文本 → 字节跳动 TTS SSE →
|
|
2
|
+
// 监听 Unix Socket,收到文本 → 字节跳动 TTS SSE/PCM → 原生流式播放
|
|
3
3
|
package main
|
|
4
4
|
|
|
5
5
|
import (
|
|
@@ -14,7 +14,6 @@ import (
|
|
|
14
14
|
"net"
|
|
15
15
|
"net/http"
|
|
16
16
|
"os"
|
|
17
|
-
"os/exec"
|
|
18
17
|
"os/signal"
|
|
19
18
|
"path/filepath"
|
|
20
19
|
"strings"
|
|
@@ -48,137 +47,6 @@ type StreamPlayer interface {
|
|
|
48
47
|
Abort() error
|
|
49
48
|
}
|
|
50
49
|
|
|
51
|
-
type ffplayStreamPlayer struct {
|
|
52
|
-
path string
|
|
53
|
-
cmd *exec.Cmd
|
|
54
|
-
|
|
55
|
-
mu sync.Mutex
|
|
56
|
-
stdin io.WriteCloser
|
|
57
|
-
waitOnce sync.Once
|
|
58
|
-
waitErr error
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
func newDefaultStreamPlayer() (StreamPlayer, error) {
|
|
62
|
-
if path, ok := findExecutable("ffplay", "/opt/homebrew/bin/ffplay", "/usr/local/bin/ffplay"); ok {
|
|
63
|
-
log.Printf("播放器模式: ffplay 流式 stdin (%s)", path)
|
|
64
|
-
return newFFplayStreamPlayer(path)
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
log.Printf("播放器模式: afplay 完整音频 fallback")
|
|
68
|
-
return &bufferedStreamPlayer{}, nil
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
func findExecutable(name string, candidates ...string) (string, bool) {
|
|
72
|
-
if path, err := exec.LookPath(name); err == nil {
|
|
73
|
-
return path, true
|
|
74
|
-
}
|
|
75
|
-
for _, path := range candidates {
|
|
76
|
-
if st, err := os.Stat(path); err == nil && !st.IsDir() && st.Mode()&0111 != 0 {
|
|
77
|
-
return path, true
|
|
78
|
-
}
|
|
79
|
-
}
|
|
80
|
-
return "", false
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
func newFFplayStreamPlayer(path string) (*ffplayStreamPlayer, error) {
|
|
84
|
-
cmd := exec.Command(path, "-nodisp", "-autoexit", "-loglevel", "error", "-i", "pipe:0")
|
|
85
|
-
stdin, err := cmd.StdinPipe()
|
|
86
|
-
if err != nil {
|
|
87
|
-
return nil, err
|
|
88
|
-
}
|
|
89
|
-
cmd.Stderr = os.Stderr
|
|
90
|
-
if err := cmd.Start(); err != nil {
|
|
91
|
-
_ = stdin.Close()
|
|
92
|
-
return nil, err
|
|
93
|
-
}
|
|
94
|
-
return &ffplayStreamPlayer{path: path, cmd: cmd, stdin: stdin}, nil
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
func (p *ffplayStreamPlayer) Write(audio []byte) error {
|
|
98
|
-
if len(audio) == 0 {
|
|
99
|
-
return nil
|
|
100
|
-
}
|
|
101
|
-
p.mu.Lock()
|
|
102
|
-
stdin := p.stdin
|
|
103
|
-
p.mu.Unlock()
|
|
104
|
-
if stdin == nil {
|
|
105
|
-
return fmt.Errorf("播放器输入已关闭")
|
|
106
|
-
}
|
|
107
|
-
if _, err := stdin.Write(audio); err != nil {
|
|
108
|
-
return fmt.Errorf("写入播放器失败: %w", err)
|
|
109
|
-
}
|
|
110
|
-
return nil
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
func (p *ffplayStreamPlayer) CloseAndWait() error {
|
|
114
|
-
p.mu.Lock()
|
|
115
|
-
stdin := p.stdin
|
|
116
|
-
p.stdin = nil
|
|
117
|
-
p.mu.Unlock()
|
|
118
|
-
if stdin != nil {
|
|
119
|
-
if err := stdin.Close(); err != nil {
|
|
120
|
-
return fmt.Errorf("关闭播放器输入失败: %w", err)
|
|
121
|
-
}
|
|
122
|
-
}
|
|
123
|
-
if err := p.wait(); err != nil {
|
|
124
|
-
return fmt.Errorf("ffplay failed: %w", err)
|
|
125
|
-
}
|
|
126
|
-
return nil
|
|
127
|
-
}
|
|
128
|
-
|
|
129
|
-
func (p *ffplayStreamPlayer) Abort() error {
|
|
130
|
-
p.mu.Lock()
|
|
131
|
-
stdin := p.stdin
|
|
132
|
-
p.stdin = nil
|
|
133
|
-
p.mu.Unlock()
|
|
134
|
-
if stdin != nil {
|
|
135
|
-
_ = stdin.Close()
|
|
136
|
-
}
|
|
137
|
-
if p.cmd != nil && p.cmd.Process != nil {
|
|
138
|
-
_ = p.cmd.Process.Kill()
|
|
139
|
-
}
|
|
140
|
-
return p.wait()
|
|
141
|
-
}
|
|
142
|
-
|
|
143
|
-
func (p *ffplayStreamPlayer) wait() error {
|
|
144
|
-
p.waitOnce.Do(func() {
|
|
145
|
-
if p.cmd != nil {
|
|
146
|
-
p.waitErr = p.cmd.Wait()
|
|
147
|
-
}
|
|
148
|
-
})
|
|
149
|
-
return p.waitErr
|
|
150
|
-
}
|
|
151
|
-
|
|
152
|
-
type bufferedStreamPlayer struct {
|
|
153
|
-
chunks [][]byte
|
|
154
|
-
}
|
|
155
|
-
|
|
156
|
-
func (p *bufferedStreamPlayer) Write(audio []byte) error {
|
|
157
|
-
if len(audio) == 0 {
|
|
158
|
-
return nil
|
|
159
|
-
}
|
|
160
|
-
chunk := append([]byte(nil), audio...)
|
|
161
|
-
p.chunks = append(p.chunks, chunk)
|
|
162
|
-
return nil
|
|
163
|
-
}
|
|
164
|
-
|
|
165
|
-
func (p *bufferedStreamPlayer) CloseAndWait() error {
|
|
166
|
-
total := 0
|
|
167
|
-
for _, chunk := range p.chunks {
|
|
168
|
-
total += len(chunk)
|
|
169
|
-
}
|
|
170
|
-
audio := make([]byte, 0, total)
|
|
171
|
-
for _, chunk := range p.chunks {
|
|
172
|
-
audio = append(audio, chunk...)
|
|
173
|
-
}
|
|
174
|
-
return playAudio(audio)
|
|
175
|
-
}
|
|
176
|
-
|
|
177
|
-
func (p *bufferedStreamPlayer) Abort() error {
|
|
178
|
-
p.chunks = nil
|
|
179
|
-
return nil
|
|
180
|
-
}
|
|
181
|
-
|
|
182
50
|
// 任务状态
|
|
183
51
|
// 生命周期:pending -> running -> delete
|
|
184
52
|
type TaskStatus int
|
|
@@ -312,13 +180,13 @@ func (e *TaskEngine) runTransaction(task *Task) error {
|
|
|
312
180
|
|
|
313
181
|
if err := e.synthesizeStreamFn(context.Background(), task.Cfg, task.Text, &task.Voice, onAudio); err != nil {
|
|
314
182
|
_ = player.Abort()
|
|
315
|
-
return err
|
|
183
|
+
return fmt.Errorf("TTS 合成失败: id=%d: %w", task.ID, err)
|
|
316
184
|
}
|
|
317
185
|
log.Printf("TTS 流结束: id=%d elapsed=%s", task.ID, time.Since(startedAt).Round(time.Millisecond))
|
|
318
186
|
|
|
319
187
|
if err := player.CloseAndWait(); err != nil {
|
|
320
188
|
_ = player.Abort()
|
|
321
|
-
return err
|
|
189
|
+
return fmt.Errorf("播放器失败: id=%d: %w", task.ID, err)
|
|
322
190
|
}
|
|
323
191
|
return nil
|
|
324
192
|
}
|
|
@@ -476,28 +344,6 @@ type ttsAudioParams struct {
|
|
|
476
344
|
SampleRate int `json:"sample_rate"`
|
|
477
345
|
}
|
|
478
346
|
|
|
479
|
-
// 调用字节跳动 TTS API,返回完整 MP3 音频数据。保留给测试和 fallback 使用。
|
|
480
|
-
func synthesize(ctx context.Context, cfg Config, text string, voice *VoiceInfo) ([]byte, error) {
|
|
481
|
-
var chunks [][]byte
|
|
482
|
-
if err := synthesizeStream(ctx, cfg, text, voice, func(audio []byte) error {
|
|
483
|
-
chunk := append([]byte(nil), audio...)
|
|
484
|
-
chunks = append(chunks, chunk)
|
|
485
|
-
return nil
|
|
486
|
-
}); err != nil {
|
|
487
|
-
return nil, err
|
|
488
|
-
}
|
|
489
|
-
|
|
490
|
-
total := 0
|
|
491
|
-
for _, c := range chunks {
|
|
492
|
-
total += len(c)
|
|
493
|
-
}
|
|
494
|
-
result := make([]byte, 0, total)
|
|
495
|
-
for _, c := range chunks {
|
|
496
|
-
result = append(result, c...)
|
|
497
|
-
}
|
|
498
|
-
return result, nil
|
|
499
|
-
}
|
|
500
|
-
|
|
501
347
|
// 调用字节跳动 TTS API,边解析 SSE 边回调 MP3 音频块
|
|
502
348
|
func synthesizeStream(ctx context.Context, cfg Config, text string, voice *VoiceInfo, onAudio func([]byte) error) error {
|
|
503
349
|
speaker := voice.VoiceType
|
|
@@ -512,8 +358,8 @@ func synthesizeStream(ctx context.Context, cfg Config, text string, voice *Voice
|
|
|
512
358
|
Text: text,
|
|
513
359
|
Speaker: speaker,
|
|
514
360
|
AudioParams: ttsAudioParams{
|
|
515
|
-
Format: "
|
|
516
|
-
SampleRate:
|
|
361
|
+
Format: "pcm",
|
|
362
|
+
SampleRate: 48000,
|
|
517
363
|
},
|
|
518
364
|
},
|
|
519
365
|
}
|
|
@@ -848,21 +694,6 @@ func validateVoiceInfo(name string, voice *VoiceInfo) error {
|
|
|
848
694
|
return nil
|
|
849
695
|
}
|
|
850
696
|
|
|
851
|
-
func playAudio(data []byte) error {
|
|
852
|
-
tmpFile := filepath.Join(tempDir, fmt.Sprintf("ttsd-%d.mp3", time.Now().UnixNano()))
|
|
853
|
-
if err := os.WriteFile(tmpFile, data, 0644); err != nil {
|
|
854
|
-
return fmt.Errorf("写入临时文件失败: %w", err)
|
|
855
|
-
}
|
|
856
|
-
defer os.Remove(tmpFile)
|
|
857
|
-
|
|
858
|
-
cmd := exec.Command("/usr/bin/afplay", tmpFile)
|
|
859
|
-
log.Printf("播放开始: %s", filepath.Base(tmpFile))
|
|
860
|
-
if err := cmd.Run(); err != nil {
|
|
861
|
-
return fmt.Errorf("播放失败: %w", err)
|
|
862
|
-
}
|
|
863
|
-
return nil
|
|
864
|
-
}
|
|
865
|
-
|
|
866
697
|
func handleConnection(conn net.Conn, engine *TaskEngine) {
|
|
867
698
|
defer func() {
|
|
868
699
|
if r := recover(); r != nil {
|
package/npm/postinstall.js
CHANGED
|
@@ -10,7 +10,8 @@ const root = path.resolve(__dirname, "..");
|
|
|
10
10
|
const home = os.homedir();
|
|
11
11
|
const binDir = path.join(home, ".local", "bin");
|
|
12
12
|
const configDir = path.join(home, ".config", "iSpeak");
|
|
13
|
-
const plistPath = path.join(home, "Library", "LaunchAgents", "com.
|
|
13
|
+
const plistPath = path.join(home, "Library", "LaunchAgents", "com.ispeak.plist");
|
|
14
|
+
const legacyPlistPath = path.join(home, "Library", "LaunchAgents", "com.iSpeak.plist");
|
|
14
15
|
const socketPath = path.join(configDir, "ispeak.sock");
|
|
15
16
|
const binaryPath = path.join(binDir, "ispeakd");
|
|
16
17
|
const cliPath = path.join(binDir, "ispeak");
|
|
@@ -136,12 +137,16 @@ function main() {
|
|
|
136
137
|
run("go", ["build", "-ldflags=-s -w", "-o", buildPath, "."]);
|
|
137
138
|
|
|
138
139
|
console.log("停止旧服务...");
|
|
140
|
+
run("launchctl", ["unload", legacyPlistPath], { allowFailure: true, stdio: "ignore" });
|
|
139
141
|
run("launchctl", ["unload", plistPath], { allowFailure: true, stdio: "ignore" });
|
|
142
|
+
try {
|
|
143
|
+
fs.rmSync(legacyPlistPath, { force: true });
|
|
144
|
+
} catch (_) {
|
|
145
|
+
// Ignore migration cleanup failures.
|
|
146
|
+
}
|
|
140
147
|
|
|
141
148
|
copyExecutable(buildPath, binaryPath);
|
|
142
149
|
copyExecutable(path.join(root, "scripts", "ispeak"), cliPath);
|
|
143
|
-
symlinkForce(cliPath, path.join(binDir, "ispeak-claude"));
|
|
144
|
-
symlinkForce(cliPath, path.join(binDir, "ispeak-codex"));
|
|
145
150
|
|
|
146
151
|
const configPath = path.join(configDir, "config.json");
|
|
147
152
|
copyIfMissing(path.join(root, "configs", "config.example.json"), configPath);
|
|
@@ -149,7 +154,7 @@ function main() {
|
|
|
149
154
|
installHook(path.join(root, "configs", "hook-speak.sh"), path.join(configDir, "hook-speak.sh"));
|
|
150
155
|
|
|
151
156
|
const plist = fs
|
|
152
|
-
.readFileSync(path.join(root, "configs", "com.
|
|
157
|
+
.readFileSync(path.join(root, "configs", "com.ispeak.plist"), "utf8")
|
|
153
158
|
.replaceAll("BINARY_PATH_PLACEHOLDER", binaryPath);
|
|
154
159
|
fs.writeFileSync(plistPath, plist);
|
|
155
160
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@xdfnet/ispeak",
|
|
3
|
-
"version": "1.6.
|
|
3
|
+
"version": "1.6.8",
|
|
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",
|
|
@@ -19,9 +19,7 @@
|
|
|
19
19
|
"x64"
|
|
20
20
|
],
|
|
21
21
|
"bin": {
|
|
22
|
-
"ispeak": "scripts/ispeak"
|
|
23
|
-
"ispeak-claude": "scripts/ispeak",
|
|
24
|
-
"ispeak-codex": "scripts/ispeak"
|
|
22
|
+
"ispeak": "scripts/ispeak"
|
|
25
23
|
},
|
|
26
24
|
"scripts": {
|
|
27
25
|
"build": "go build -ldflags=\"-s -w\" -o build/ispeakd .",
|
|
@@ -38,7 +36,7 @@
|
|
|
38
36
|
"scripts/ispeak",
|
|
39
37
|
"configs/",
|
|
40
38
|
"npm/",
|
|
41
|
-
"
|
|
39
|
+
"docs/",
|
|
42
40
|
"README.md",
|
|
43
41
|
"LICENSE"
|
|
44
42
|
],
|
package/scripts/ispeak
CHANGED
|
@@ -2,16 +2,10 @@
|
|
|
2
2
|
# ispeak — iSpeak 控制命令
|
|
3
3
|
set -euo pipefail
|
|
4
4
|
|
|
5
|
-
VERSION="1.6.
|
|
5
|
+
VERSION="1.6.8"
|
|
6
6
|
SOCK="$HOME/.config/iSpeak/ispeak.sock"
|
|
7
|
-
PLIST="$HOME/Library/LaunchAgents/com.
|
|
8
|
-
|
|
9
|
-
SOURCE_PREFIX=""
|
|
10
|
-
|
|
11
|
-
case "$CMD_NAME" in
|
|
12
|
-
ispeak-claude) SOURCE_PREFIX="{source:claude}" ;;
|
|
13
|
-
ispeak-codex) SOURCE_PREFIX="{source:codex}" ;;
|
|
14
|
-
esac
|
|
7
|
+
PLIST="$HOME/Library/LaunchAgents/com.ispeak.plist"
|
|
8
|
+
LEGACY_PLIST="$HOME/Library/LaunchAgents/com.iSpeak.plist"
|
|
15
9
|
|
|
16
10
|
cmd_status() {
|
|
17
11
|
echo "== iSpeak =="
|
|
@@ -28,6 +22,7 @@ cmd_status() {
|
|
|
28
22
|
}
|
|
29
23
|
|
|
30
24
|
cmd_restart() {
|
|
25
|
+
launchctl unload "$LEGACY_PLIST" 2>/dev/null || true
|
|
31
26
|
launchctl unload "$PLIST" 2>/dev/null || true
|
|
32
27
|
launchctl load "$PLIST"
|
|
33
28
|
sleep 0.5
|
|
@@ -53,6 +48,6 @@ case "${1:-}" in
|
|
|
53
48
|
echo " ispeak version 版本"
|
|
54
49
|
;;
|
|
55
50
|
*)
|
|
56
|
-
printf "%s
|
|
51
|
+
printf "%s" "$*" | nc -U -w5 "$SOCK" 2>/dev/null || echo "ispeak: socket 不可用" >&2
|
|
57
52
|
;;
|
|
58
53
|
esac
|
|
File without changes
|
|
File without changes
|
|
File without changes
|