@xdfnet/ispeak 1.6.1 → 1.6.2

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  # iSpeak
2
2
 
3
- ![Version](https://img.shields.io/badge/version-1.6.1-blue)
3
+ ![Version](https://img.shields.io/badge/version-1.6.2-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)
@@ -51,11 +51,12 @@ brew install ffmpeg
51
51
  git clone https://github.com/xdfnet/iSpeak.git && cd iSpeak && make install
52
52
  ```
53
53
 
54
- 安装时手动输入 API Key,然后验证:
54
+ 安装后编辑 API Key,然后验证:
55
55
 
56
56
  ```bash
57
+ open ~/.config/iSpeak/config.json
57
58
  ispeak status
58
- ispeak test "iSpeak 准备好了"
59
+ ispeak "iSpeak 准备好了"
59
60
  ```
60
61
 
61
62
  ## 工作原理
@@ -11,52 +11,107 @@ LOG="$HOME/.config/iSpeak/hook.log"
11
11
 
12
12
  input=$(cat)
13
13
 
14
- # 从 stdin JSON 提取 transcript 路径和最后一条消息
15
- # 简单 JSON 解析(不依赖 python3)
16
- transcript=$(echo "$input" | sed -n 's/.*"transcript_path"\s*:\s*"\([^"]*\)".*/\1/p')
17
- last_msg=$(echo "$input" | sed -n 's/.*"last_assistant_message"\s*:\s*"\([^"]*\)".*/\1/p')
14
+ json_value() {
15
+ local key="$1"
16
+ if command -v node >/dev/null 2>&1; then
17
+ printf "%s" "$input" | node -e '
18
+ const key = process.argv[1];
19
+ let input = "";
20
+ process.stdin.setEncoding("utf8");
21
+ process.stdin.on("data", chunk => input += chunk);
22
+ process.stdin.on("end", () => {
23
+ try {
24
+ const value = JSON.parse(input)[key];
25
+ if (typeof value === "string") process.stdout.write(value);
26
+ } catch (_) {}
27
+ });
28
+ ' "$key"
29
+ return
30
+ fi
18
31
 
19
- all_text="$last_msg"
32
+ printf "%s" "$input" | sed -n "s/.*\"$key\"[[:space:]]*:[[:space:]]*\"\([^\"]*\)\".*/\1/p"
33
+ }
20
34
 
21
- # 如果有 transcript 文件,提取最近 30 秒内的所有 assistant 消息
22
- if [[ -n "$transcript" && -f "$transcript" ]]; then
23
- # 计算 30 秒前的时间戳(毫秒)
24
- cutoff=$(($(date +%s) * 1000 - 30000))
35
+ extract_recent_assistant_text() {
36
+ local transcript="$1"
37
+ local cutoff="$2"
25
38
 
26
- # awk 解析 JSON lines,提取 role=assistant 且 timestamp > cutoff 的 text
27
- extra=$(awk -v cutoff="$cutoff" '
39
+ if command -v node >/dev/null 2>&1; then
40
+ node -e '
41
+ const fs = require("fs");
42
+ const file = process.argv[1];
43
+ const cutoff = Number(process.argv[2]);
44
+ const out = [];
45
+
46
+ function collectText(content) {
47
+ if (typeof content === "string") {
48
+ out.push(content);
49
+ return;
50
+ }
51
+ if (!Array.isArray(content)) return;
52
+ for (const item of content) {
53
+ if (item && typeof item.text === "string") out.push(item.text);
54
+ }
55
+ }
56
+
57
+ for (const line of fs.readFileSync(file, "utf8").split(/\r?\n/)) {
58
+ if (!line.trim()) continue;
59
+ try {
60
+ const event = JSON.parse(line);
61
+ if (typeof event.timestamp === "number" && event.timestamp < cutoff) continue;
62
+ if (event.role === "assistant") collectText(event.content);
63
+ if (event.message && event.message.role === "assistant") collectText(event.message.content);
64
+ } catch (_) {}
65
+ }
66
+ process.stdout.write([...new Set(out.filter(Boolean))].join(" "));
67
+ ' "$transcript" "$cutoff" 2>/dev/null
68
+ return
69
+ fi
70
+
71
+ awk -v cutoff="$cutoff" '
28
72
  {
29
- # 提取 timestamp
30
- if (match($0, /"timestamp"\s*:\s*[0-9]+/)) {
73
+ if (match($0, /"timestamp"[[:space:]]*:[[:space:]]*[0-9]+/)) {
31
74
  ts = substr($0, RSTART, RLENGTH)
32
75
  gsub(/[^0-9]/, "", ts)
33
76
  ts = int(ts)
34
77
  if (ts < cutoff) next
35
78
  }
36
79
 
37
- # 提取 role
38
- if (match($0, /"role"\s*:\s*"assistant"/)) {
39
- # 提取 content(可能是字符串或数组)
40
- if (match($0, /"content"\s*:\s*\[/)) {
41
- # 数组形式,提取所有 text 字段
80
+ if (match($0, /"role"[[:space:]]*:[[:space:]]*"assistant"/)) {
81
+ if (match($0, /"content"[[:space:]]*:[[:space:]]*\[/)) {
42
82
  gsub(/[^{]*\[/, "", $0)
43
83
  gsub(/\].*/, "", $0)
44
- while (match($0, /"text"\s*:\s*"[^"]*"/)) {
84
+ while (match($0, /"text"[[:space:]]*:[[:space:]]*"[^"]*"/)) {
45
85
  t = substr($0, RSTART, RLENGTH)
46
- gsub(/"text"\s*:\s*"/, "", t)
86
+ gsub(/"text"[[:space:]]*:[[:space:]]*"/, "", t)
47
87
  gsub(/"$/, "", t)
48
88
  if (t != "") print t
49
89
  $0 = substr($0, RSTART + RLENGTH)
50
90
  }
51
- } else if (match($0, /"content"\s*:\s*"\([^"]*\)"/)) {
91
+ } else if (match($0, /"content"[[:space:]]*:[[:space:]]*"[^"]*"/)) {
52
92
  t = substr($0, RSTART, RLENGTH)
53
- gsub(/"content"\s*:\s*"/, "", t)
93
+ gsub(/"content"[[:space:]]*:[[:space:]]*"/, "", t)
54
94
  gsub(/"$/, "", t)
55
95
  if (t != "") print t
56
96
  }
57
97
  }
58
98
  }
59
- ' "$transcript" 2>/dev/null | sort -u | tr '\n' ' ')
99
+ ' "$transcript" 2>/dev/null | sort -u | tr '\n' ' '
100
+ }
101
+
102
+ # 从 stdin JSON 提取 transcript 路径和最后一条消息
103
+ transcript=$(json_value "transcript_path")
104
+ last_msg=$(json_value "last_assistant_message")
105
+
106
+ all_text="$last_msg"
107
+
108
+ # 如果有 transcript 文件,提取最近 30 秒内的所有 assistant 消息
109
+ if [[ -n "$transcript" && -f "$transcript" ]]; then
110
+ # 计算 30 秒前的时间戳(毫秒)
111
+ cutoff=$(($(date +%s) * 1000 - 30000))
112
+
113
+ # 优先用 JSON parser,Node 不存在时回退到简易 awk。
114
+ extra=$(extract_recent_assistant_text "$transcript" "$cutoff")
60
115
 
61
116
  if [[ -n "$extra" ]]; then
62
117
  all_text="$extra"
package/main.go CHANGED
@@ -387,6 +387,17 @@ func loadConfig() Config {
387
387
  }
388
388
  var cfg Config
389
389
  if json.Unmarshal(data, &cfg) == nil && cfg.APIKey != "" {
390
+ if err := validateConfig(cfg); err != nil {
391
+ log.Printf("配置文件无效: %s err=%v", p, err)
392
+ configCacheMu.Lock()
393
+ if configCacheValid {
394
+ cached := configCache
395
+ configCacheMu.Unlock()
396
+ return cached
397
+ }
398
+ configCacheMu.Unlock()
399
+ return cfg
400
+ }
390
401
  log.Printf("配置文件: %s", p)
391
402
  if cfg.DefaultVoice != nil {
392
403
  log.Printf("默认音色: %s (%s)", cfg.DefaultVoice.VoiceType, cfg.DefaultVoice.ResourceID)
@@ -779,8 +790,26 @@ func validateConfig(cfg Config) error {
779
790
  if cfg.Endpoint == "" {
780
791
  return fmt.Errorf("endpoint 未设置")
781
792
  }
782
- if cfg.DefaultVoice == nil || cfg.DefaultVoice.VoiceType == "" {
783
- return fmt.Errorf("defaultVoice 未设置")
793
+ if err := validateVoiceInfo("defaultVoice", cfg.DefaultVoice); err != nil {
794
+ return err
795
+ }
796
+ for source, voice := range cfg.SourceVoices {
797
+ if err := validateVoiceInfo(fmt.Sprintf("sourceVoices.%s", source), voice); err != nil {
798
+ return err
799
+ }
800
+ }
801
+ return nil
802
+ }
803
+
804
+ func validateVoiceInfo(name string, voice *VoiceInfo) error {
805
+ if voice == nil {
806
+ return fmt.Errorf("%s 未设置", name)
807
+ }
808
+ if voice.VoiceType == "" {
809
+ return fmt.Errorf("%s.voice_type 未设置", name)
810
+ }
811
+ if voice.ResourceID == "" {
812
+ return fmt.Errorf("%s.resourceId 未设置", name)
784
813
  }
785
814
  return nil
786
815
  }
@@ -809,6 +838,10 @@ func handleConnection(conn net.Conn, engine *TaskEngine) {
809
838
  defer conn.Close()
810
839
 
811
840
  cfg := loadConfig()
841
+ if err := validateConfig(cfg); err != nil {
842
+ log.Printf("配置错误,跳过本次播报: %v", err)
843
+ return
844
+ }
812
845
 
813
846
  var sb strings.Builder
814
847
  scanner := bufio.NewScanner(conn)
@@ -816,6 +849,10 @@ func handleConnection(conn net.Conn, engine *TaskEngine) {
816
849
  for scanner.Scan() {
817
850
  sb.WriteString(scanner.Text())
818
851
  }
852
+ if err := scanner.Err(); err != nil {
853
+ log.Printf("读取 socket 消息失败: %v", err)
854
+ return
855
+ }
819
856
 
820
857
  text := strings.TrimSpace(sb.String())
821
858
  if text == "" {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xdfnet/ispeak",
3
- "version": "1.6.1",
3
+ "version": "1.6.2",
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.1"
5
+ VERSION="1.6.2"
6
6
  SOCK="$HOME/.config/iSpeak/ispeak.sock"
7
7
  PLIST="$HOME/Library/LaunchAgents/com.iSpeak.plist"
8
8
  CMD_NAME="$(basename "$0")"