pi-say 1.1.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/LICENSE +21 -0
- package/README.md +105 -0
- package/extensions/say.ts +642 -0
- package/package.json +33 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 matifema
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
# pi-say
|
|
2
|
+
|
|
3
|
+
Spoken output for [pi](https://pi.dev/): a `say` tool that turns short sentences into speech.
|
|
4
|
+
|
|
5
|
+
Give pi a voice. The model calls `say` for acks before slow work and for the final result:
|
|
6
|
+
|
|
7
|
+
> "Checking the logs now." … "Found three failed requests."
|
|
8
|
+
|
|
9
|
+
Google Cloud TTS is used when credentials are available, with local fallback
|
|
10
|
+
(Kokoro/Piper server, macOS `say`, or `espeak-ng`) so speech keeps working offline.
|
|
11
|
+
|
|
12
|
+
## Install
|
|
13
|
+
|
|
14
|
+
```bash
|
|
15
|
+
pi install npm:pi-say
|
|
16
|
+
# or from git
|
|
17
|
+
pi install git:github.com/matifema/pi-say
|
|
18
|
+
# or try without installing
|
|
19
|
+
pi -e npm:pi-say
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
Restart pi (or start a new session) after installing.
|
|
23
|
+
|
|
24
|
+
## Google Cloud TTS
|
|
25
|
+
|
|
26
|
+
Provide credentials in one of these ways:
|
|
27
|
+
|
|
28
|
+
- **API key:** set `PI_SAY_GOOGLE_API_KEY` (or `VOICECTL_GOOGLE_API_KEY`, or `GOOGLE_API_KEY`)
|
|
29
|
+
- **Service account JSON:** set `GOOGLE_APPLICATION_CREDENTIALS`, or drop the file at
|
|
30
|
+
`~/.config/pi-say/google-sa.json` (voicectl's `~/.config/voicectl/google-sa.json` is also picked up)
|
|
31
|
+
|
|
32
|
+
The Cloud Text-to-Speech API must be enabled on the project. Without
|
|
33
|
+
credentials, pi-say silently falls back to the local engines below.
|
|
34
|
+
|
|
35
|
+
## Voice
|
|
36
|
+
|
|
37
|
+
Change the voice from pi in two ways:
|
|
38
|
+
|
|
39
|
+
- Ask pi in plain language — the model calls `say_voice` for you
|
|
40
|
+
- Type `/voice <name>` in the pi prompt (`/voice` shows the current settings, `/voice list` lists Google voices)
|
|
41
|
+
|
|
42
|
+
Examples:
|
|
43
|
+
|
|
44
|
+
```
|
|
45
|
+
/voice en-US-Chirp3-HD-Aoede
|
|
46
|
+
/voice en-US-Journey-O
|
|
47
|
+
/voice local
|
|
48
|
+
/voice speed 1.15
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
Settings persist in `~/.config/pi-say/config.json`:
|
|
52
|
+
|
|
53
|
+
```json
|
|
54
|
+
{
|
|
55
|
+
"engine": "auto",
|
|
56
|
+
"voice": "en-US-Chirp3-HD-Charon",
|
|
57
|
+
"speed": 1.0
|
|
58
|
+
}
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
`engine` is one of `auto` (Google, then local, then espeak), `google`,
|
|
62
|
+
`local`, or `espeak`.
|
|
63
|
+
|
|
64
|
+
## Local fallback
|
|
65
|
+
|
|
66
|
+
`auto` mode tries these in order:
|
|
67
|
+
|
|
68
|
+
1. **Google Cloud TTS** — when credentials are configured
|
|
69
|
+
2. **Local HTTP TTS** — any server exposing `POST /tts` with `{ text, voice, speed }`
|
|
70
|
+
returning audio (Kokoro, Piper, voicectl, …); default `http://127.0.0.1:8181`
|
|
71
|
+
3. **macOS `say`** — built in
|
|
72
|
+
4. **`espeak-ng` / `espeak`** — install with your package manager
|
|
73
|
+
|
|
74
|
+
Playback uses a voicectl-style HUD unix socket when present, otherwise
|
|
75
|
+
`pw-play`, `paplay`, `aplay`, or `ffplay` (`afplay` on macOS).
|
|
76
|
+
|
|
77
|
+
## Environment variables
|
|
78
|
+
|
|
79
|
+
Everything is optional; values in `~/.config/pi-say/config.json` (written by
|
|
80
|
+
`/voice` and `say_voice`) take precedence. `VOICECTL_*` aliases remain
|
|
81
|
+
supported for compatibility with [voicectl](https://github.com/matifema/voicectl).
|
|
82
|
+
|
|
83
|
+
| Variable | Default | Description |
|
|
84
|
+
| --- | --- | --- |
|
|
85
|
+
| `PI_SAY_GOOGLE_API_KEY` | — | Google Cloud TTS API key. |
|
|
86
|
+
| `PI_SAY_GOOGLE_SA` / `GOOGLE_APPLICATION_CREDENTIALS` | auto-discovered | Service account JSON path. |
|
|
87
|
+
| `PI_SAY_ENGINE` | `auto` | `auto`, `google`, `local`, or `espeak`. |
|
|
88
|
+
| `PI_SAY_VOICE` | `en-US-Chirp3-HD-Charon` | Voice name. |
|
|
89
|
+
| `PI_SAY_SPEED` | `1.0` | Speaking rate. |
|
|
90
|
+
| `PI_SAY_TTS_URL` | `http://127.0.0.1:8181` | Local TTS server. Set to `off` to skip it. |
|
|
91
|
+
| `PI_SAY_HUD_SOCKET` | `$XDG_RUNTIME_DIR/voicectl.sock` | HUD socket that accepts `{"cmd":"speak","file":…,"interrupt":…}`. |
|
|
92
|
+
| `PI_SAY_PLAYER` | auto | Force a player command, e.g. `aplay`. |
|
|
93
|
+
| `PI_SAY_TIMING_LOG` | `$XDG_RUNTIME_DIR/pi-say-timing.jsonl` | JSONL latency log; failures are silent. |
|
|
94
|
+
|
|
95
|
+
## How it works
|
|
96
|
+
|
|
97
|
+
1. Text is clipped to twelve words.
|
|
98
|
+
2. Synthesis: Google Cloud TTS → local HTTP TTS → macOS `say` → `espeak-ng`.
|
|
99
|
+
3. Playback: HUD socket → `pw-play` / `paplay` / `aplay` / `ffplay` (`afplay` on macOS).
|
|
100
|
+
|
|
101
|
+
Generated WAVs are cached under `~/.cache/pi-say/`.
|
|
102
|
+
|
|
103
|
+
## License
|
|
104
|
+
|
|
105
|
+
MIT
|
|
@@ -0,0 +1,642 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* say — spoken output for pi.
|
|
3
|
+
*
|
|
4
|
+
* Synthesis: Google Cloud TTS (API key or service account JSON)
|
|
5
|
+
* -> local HTTP TTS server (Kokoro/Piper/voicectl, default :8181)
|
|
6
|
+
* -> macOS built-in `say`
|
|
7
|
+
* -> espeak-ng / espeak
|
|
8
|
+
* Playback: HUD unix socket (if present)
|
|
9
|
+
* -> pw-play / paplay / aplay / ffplay (afplay on macOS)
|
|
10
|
+
*
|
|
11
|
+
* Voice, speed and engine live in ~/.config/pi-say/config.json and can be
|
|
12
|
+
* changed from pi with the `say_voice` tool or the `/voice` command.
|
|
13
|
+
*/
|
|
14
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
15
|
+
import { Type } from "typebox";
|
|
16
|
+
import { spawn } from "node:child_process";
|
|
17
|
+
import { createSign, randomBytes } from "node:crypto";
|
|
18
|
+
import { appendFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
19
|
+
import { mkdir, writeFile } from "node:fs/promises";
|
|
20
|
+
import { createConnection } from "node:net";
|
|
21
|
+
import { homedir, platform, tmpdir } from "node:os";
|
|
22
|
+
import { dirname, join } from "node:path";
|
|
23
|
+
|
|
24
|
+
const firstEnv = (...names: string[]): string | undefined => {
|
|
25
|
+
for (const name of names) {
|
|
26
|
+
const value = process.env[name]?.trim();
|
|
27
|
+
if (value)
|
|
28
|
+
return value;
|
|
29
|
+
}
|
|
30
|
+
return undefined;
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
const IS_MAC = platform() === "darwin";
|
|
34
|
+
const RUNTIME_DIR = process.env.XDG_RUNTIME_DIR ?? tmpdir();
|
|
35
|
+
const CONFIG_PATH = join(
|
|
36
|
+
process.env.XDG_CONFIG_HOME ?? join(homedir(), ".config"),
|
|
37
|
+
"pi-say",
|
|
38
|
+
"config.json",
|
|
39
|
+
);
|
|
40
|
+
const TIMING = firstEnv("PI_SAY_TIMING_LOG") ?? join(RUNTIME_DIR, "pi-say-timing.jsonl");
|
|
41
|
+
const DEFAULT_VOICE = "en-US-Chirp3-HD-Charon";
|
|
42
|
+
const ENGINES = ["auto", "google", "local", "espeak"];
|
|
43
|
+
|
|
44
|
+
const COMMON_VOICES = [
|
|
45
|
+
"en-US-Chirp3-HD-Charon",
|
|
46
|
+
"en-US-Chirp3-HD-Aoede",
|
|
47
|
+
"en-US-Chirp3-HD-Kore",
|
|
48
|
+
"en-US-Chirp3-HD-Leda",
|
|
49
|
+
"en-US-Chirp3-HD-Orus",
|
|
50
|
+
"en-US-Chirp3-HD-Puck",
|
|
51
|
+
"en-US-Chirp3-HD-Fenrir",
|
|
52
|
+
"en-US-Chirp3-HD-Zephyr",
|
|
53
|
+
"en-US-Journey-D",
|
|
54
|
+
"en-US-Journey-F",
|
|
55
|
+
"en-US-Journey-O",
|
|
56
|
+
"en-US-Studio-O",
|
|
57
|
+
"en-US-Studio-Q",
|
|
58
|
+
];
|
|
59
|
+
|
|
60
|
+
type SayConfig = {
|
|
61
|
+
engine?: string;
|
|
62
|
+
voice?: string;
|
|
63
|
+
speed?: number;
|
|
64
|
+
localTtsUrl?: string;
|
|
65
|
+
hudSocket?: string;
|
|
66
|
+
googleApiKey?: string;
|
|
67
|
+
googleSaPath?: string;
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
function loadConfig(): SayConfig {
|
|
71
|
+
try {
|
|
72
|
+
return JSON.parse(readFileSync(CONFIG_PATH, "utf8")) as SayConfig;
|
|
73
|
+
} catch {
|
|
74
|
+
return {};
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function saveConfig(patch: SayConfig): SayConfig {
|
|
79
|
+
const next = { ...loadConfig(), ...patch };
|
|
80
|
+
for (const [key, value] of Object.entries(next)) {
|
|
81
|
+
if (value === undefined || value === "")
|
|
82
|
+
delete next[key as keyof SayConfig];
|
|
83
|
+
}
|
|
84
|
+
try {
|
|
85
|
+
mkdirSync(dirname(CONFIG_PATH), { recursive: true });
|
|
86
|
+
writeFileSync(CONFIG_PATH, `${JSON.stringify(next, null, 2)}\n`);
|
|
87
|
+
} catch {
|
|
88
|
+
/* read-only home; env vars still work */
|
|
89
|
+
}
|
|
90
|
+
return next;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function currentEngine(): string {
|
|
94
|
+
const engine = (loadConfig().engine ?? firstEnv("PI_SAY_ENGINE") ?? "auto").toLowerCase();
|
|
95
|
+
return ENGINES.includes(engine) ? engine : "auto";
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function currentVoice(override?: string): string {
|
|
99
|
+
return (
|
|
100
|
+
override?.trim() ||
|
|
101
|
+
loadConfig().voice ||
|
|
102
|
+
firstEnv("PI_SAY_VOICE", "VOICECTL_TTS_VOICE", "VOICECTL_VOICE") ||
|
|
103
|
+
DEFAULT_VOICE
|
|
104
|
+
);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function currentSpeed(): number {
|
|
108
|
+
const raw = loadConfig().speed ?? Number(firstEnv("PI_SAY_SPEED", "VOICECTL_SPEED") ?? "1.0");
|
|
109
|
+
return Number.isFinite(raw) && raw > 0 ? raw : 1;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function localTtsUrl(): string {
|
|
113
|
+
return (
|
|
114
|
+
loadConfig().localTtsUrl ??
|
|
115
|
+
firstEnv("PI_SAY_TTS_URL", "VOICECTL_TTS_URL") ??
|
|
116
|
+
"http://127.0.0.1:8181"
|
|
117
|
+
);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function hudSocket(): string {
|
|
121
|
+
return (
|
|
122
|
+
loadConfig().hudSocket ??
|
|
123
|
+
firstEnv("PI_SAY_HUD_SOCKET", "VOICECTL_SOCKET") ??
|
|
124
|
+
(process.env.XDG_RUNTIME_DIR
|
|
125
|
+
? join(process.env.XDG_RUNTIME_DIR, "voicectl.sock")
|
|
126
|
+
: join(homedir(), ".voicectl.sock"))
|
|
127
|
+
);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function googleApiKey(): string | undefined {
|
|
131
|
+
return (
|
|
132
|
+
loadConfig().googleApiKey ??
|
|
133
|
+
firstEnv("PI_SAY_GOOGLE_API_KEY", "VOICECTL_GOOGLE_API_KEY", "GOOGLE_API_KEY")
|
|
134
|
+
);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function googleSaPath(): string | undefined {
|
|
138
|
+
const configured = loadConfig().googleSaPath ?? firstEnv("PI_SAY_GOOGLE_SA", "GOOGLE_APPLICATION_CREDENTIALS");
|
|
139
|
+
if (configured)
|
|
140
|
+
return existsSync(configured) ? configured : undefined;
|
|
141
|
+
for (const candidate of [
|
|
142
|
+
join(homedir(), ".config/pi-say/google-sa.json"),
|
|
143
|
+
join(homedir(), ".config/voicectl/google-sa.json"),
|
|
144
|
+
]) {
|
|
145
|
+
if (existsSync(candidate))
|
|
146
|
+
return candidate;
|
|
147
|
+
}
|
|
148
|
+
return undefined;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function hasGoogleCreds(): boolean {
|
|
152
|
+
return Boolean(googleApiKey() ?? googleSaPath());
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function tlog(phase: string, extra: Record<string, unknown> = {}): void {
|
|
156
|
+
const rec = { ts: Date.now() / 1000, src: "say", phase, ...extra };
|
|
157
|
+
try {
|
|
158
|
+
appendFileSync(TIMING, `${JSON.stringify(rec)}\n`);
|
|
159
|
+
} catch {
|
|
160
|
+
/* diagnostics only */
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
async function sendHud(payload: unknown): Promise<void> {
|
|
165
|
+
await new Promise<void>((resolve, reject) => {
|
|
166
|
+
const sock = createConnection(hudSocket());
|
|
167
|
+
let buf = "";
|
|
168
|
+
let settled = false;
|
|
169
|
+
const done = (err?: Error) => {
|
|
170
|
+
if (settled)
|
|
171
|
+
return;
|
|
172
|
+
settled = true;
|
|
173
|
+
sock.destroy();
|
|
174
|
+
if (err)
|
|
175
|
+
reject(err);
|
|
176
|
+
else
|
|
177
|
+
resolve();
|
|
178
|
+
};
|
|
179
|
+
sock.setTimeout(3000);
|
|
180
|
+
sock.on("connect", () => {
|
|
181
|
+
sock.write(`${JSON.stringify(payload)}\n`);
|
|
182
|
+
});
|
|
183
|
+
sock.on("data", (chunk) => {
|
|
184
|
+
buf += String(chunk);
|
|
185
|
+
if (!buf.includes("\n"))
|
|
186
|
+
return;
|
|
187
|
+
try {
|
|
188
|
+
const reply = JSON.parse(buf.split("\n", 1)[0] || "{}");
|
|
189
|
+
if (reply.ok === false)
|
|
190
|
+
done(new Error(String(reply.error ?? "hud speak failed")));
|
|
191
|
+
else
|
|
192
|
+
done();
|
|
193
|
+
} catch (err) {
|
|
194
|
+
done(err instanceof Error ? err : new Error(String(err)));
|
|
195
|
+
}
|
|
196
|
+
});
|
|
197
|
+
sock.on("timeout", () => done(new Error("voicectl socket timeout")));
|
|
198
|
+
sock.on("error", (err) => done(err));
|
|
199
|
+
sock.on("close", () => {
|
|
200
|
+
if (!settled)
|
|
201
|
+
done(new Error("voicectl socket closed before reply"));
|
|
202
|
+
});
|
|
203
|
+
});
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function clipSpoken(text: string): string {
|
|
207
|
+
const words = text.trim().split(/\s+/).filter(Boolean);
|
|
208
|
+
if (words.length <= 12)
|
|
209
|
+
return words.join(" ");
|
|
210
|
+
return words.slice(0, 12).join(" ");
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
function isDaemonMissing(err: unknown): boolean {
|
|
214
|
+
const msg = String(err);
|
|
215
|
+
return msg.includes("voicectl socket") || msg.includes("ECONNREFUSED") || msg.includes("ENOENT");
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function run(cmd: string, args: string[]): Promise<{ code: number; stdout: string; stderr: string }> {
|
|
219
|
+
return new Promise((resolve, reject) => {
|
|
220
|
+
const child = spawn(cmd, args, { stdio: ["ignore", "pipe", "pipe"] });
|
|
221
|
+
let stdout = "";
|
|
222
|
+
let stderr = "";
|
|
223
|
+
child.stdout.on("data", (d) => {
|
|
224
|
+
stdout += String(d);
|
|
225
|
+
});
|
|
226
|
+
child.stderr.on("data", (d) => {
|
|
227
|
+
stderr += String(d);
|
|
228
|
+
});
|
|
229
|
+
child.on("error", reject);
|
|
230
|
+
child.on("close", (code) => resolve({ code: code ?? 1, stdout, stderr }));
|
|
231
|
+
});
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
function languageOfVoice(name: string): string {
|
|
235
|
+
const match = /^([a-z]{2,3}-[A-Z]{2})/.exec(name);
|
|
236
|
+
return match ? match[1] : "en-US";
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
function isWav(buf: Buffer): boolean {
|
|
240
|
+
return (
|
|
241
|
+
buf.length >= 12 &&
|
|
242
|
+
buf.subarray(0, 4).toString("ascii") === "RIFF" &&
|
|
243
|
+
buf.subarray(8, 12).toString("ascii") === "WAVE"
|
|
244
|
+
);
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
function pcmToWav(pcm: Buffer, sampleRate: number): Buffer {
|
|
248
|
+
const header = Buffer.alloc(44);
|
|
249
|
+
header.write("RIFF", 0);
|
|
250
|
+
header.writeUInt32LE(36 + pcm.length, 4);
|
|
251
|
+
header.write("WAVE", 8);
|
|
252
|
+
header.write("fmt ", 12);
|
|
253
|
+
header.writeUInt32LE(16, 16);
|
|
254
|
+
header.writeUInt16LE(1, 20);
|
|
255
|
+
header.writeUInt16LE(1, 22);
|
|
256
|
+
header.writeUInt32LE(sampleRate, 24);
|
|
257
|
+
header.writeUInt32LE(sampleRate * 2, 28);
|
|
258
|
+
header.writeUInt16LE(2, 32);
|
|
259
|
+
header.writeUInt16LE(16, 34);
|
|
260
|
+
header.write("data", 36);
|
|
261
|
+
header.writeUInt32LE(pcm.length, 40);
|
|
262
|
+
return Buffer.concat([header, pcm]);
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
let googleToken: { token: string; exp: number } | null = null;
|
|
266
|
+
|
|
267
|
+
async function googleAccessToken(saPath: string): Promise<string> {
|
|
268
|
+
if (googleToken && Date.now() < googleToken.exp)
|
|
269
|
+
return googleToken.token;
|
|
270
|
+
const sa = JSON.parse(readFileSync(saPath, "utf8")) as { client_email?: string; private_key?: string };
|
|
271
|
+
if (!sa.client_email || !sa.private_key)
|
|
272
|
+
throw new Error("service account JSON missing client_email/private_key");
|
|
273
|
+
const now = Math.floor(Date.now() / 1000);
|
|
274
|
+
const header = Buffer.from(JSON.stringify({ alg: "RS256", typ: "JWT" })).toString("base64url");
|
|
275
|
+
const claim = Buffer.from(
|
|
276
|
+
JSON.stringify({
|
|
277
|
+
iss: sa.client_email,
|
|
278
|
+
scope: "https://www.googleapis.com/auth/cloud-platform",
|
|
279
|
+
aud: "https://oauth2.googleapis.com/token",
|
|
280
|
+
iat: now,
|
|
281
|
+
exp: now + 3600,
|
|
282
|
+
}),
|
|
283
|
+
).toString("base64url");
|
|
284
|
+
const signer = createSign("RSA-SHA256");
|
|
285
|
+
signer.update(`${header}.${claim}`);
|
|
286
|
+
const jwt = `${header}.${claim}.${signer.sign(sa.private_key, "base64url")}`;
|
|
287
|
+
const res = await fetch("https://oauth2.googleapis.com/token", {
|
|
288
|
+
method: "POST",
|
|
289
|
+
headers: { "content-type": "application/x-www-form-urlencoded" },
|
|
290
|
+
body: `grant_type=${encodeURIComponent("urn:ietf:params:oauth:grant-type:jwt-bearer")}&assertion=${jwt}`,
|
|
291
|
+
});
|
|
292
|
+
const data = (await res.json()) as { access_token?: string; error_description?: string; error?: string };
|
|
293
|
+
if (!data.access_token)
|
|
294
|
+
throw new Error(data.error_description ?? data.error ?? `token request failed (${res.status})`);
|
|
295
|
+
googleToken = { token: data.access_token, exp: Date.now() + 50 * 60 * 1000 };
|
|
296
|
+
return data.access_token;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
async function googleAuth(): Promise<{ headers: Record<string, string>; query: string }> {
|
|
300
|
+
const key = googleApiKey();
|
|
301
|
+
if (key)
|
|
302
|
+
return { headers: { "content-type": "application/json" }, query: `?key=${encodeURIComponent(key)}` };
|
|
303
|
+
const saPath = googleSaPath();
|
|
304
|
+
if (!saPath)
|
|
305
|
+
throw new Error("no Google credentials");
|
|
306
|
+
return {
|
|
307
|
+
headers: { "content-type": "application/json", authorization: `Bearer ${await googleAccessToken(saPath)}` },
|
|
308
|
+
query: "",
|
|
309
|
+
};
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
async function synthGoogle(
|
|
313
|
+
text: string,
|
|
314
|
+
voice: string,
|
|
315
|
+
speed: number,
|
|
316
|
+
outPath: string,
|
|
317
|
+
signal?: AbortSignal,
|
|
318
|
+
): Promise<void> {
|
|
319
|
+
const { headers, query } = await googleAuth();
|
|
320
|
+
const body = {
|
|
321
|
+
input: { text },
|
|
322
|
+
voice: { languageCode: languageOfVoice(voice), name: voice },
|
|
323
|
+
audioConfig: { audioEncoding: "LINEAR16", sampleRateHertz: 24000, speakingRate: speed },
|
|
324
|
+
};
|
|
325
|
+
const res = await fetch(`https://texttospeech.googleapis.com/v1/text:synthesize${query}`, {
|
|
326
|
+
method: "POST",
|
|
327
|
+
headers,
|
|
328
|
+
body: JSON.stringify(body),
|
|
329
|
+
signal,
|
|
330
|
+
});
|
|
331
|
+
const data = (await res.json().catch(() => ({}))) as {
|
|
332
|
+
audioContent?: string;
|
|
333
|
+
error?: { message?: string };
|
|
334
|
+
};
|
|
335
|
+
if (!res.ok || !data.audioContent)
|
|
336
|
+
throw new Error(`Google TTS ${res.status}: ${data.error?.message ?? res.statusText}`);
|
|
337
|
+
const buf = Buffer.from(data.audioContent, "base64");
|
|
338
|
+
await writeFile(outPath, isWav(buf) ? buf : pcmToWav(buf, 24000));
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
async function synthHttp(
|
|
342
|
+
url: string,
|
|
343
|
+
text: string,
|
|
344
|
+
voice: string,
|
|
345
|
+
speed: number,
|
|
346
|
+
outPath: string,
|
|
347
|
+
signal?: AbortSignal,
|
|
348
|
+
): Promise<void> {
|
|
349
|
+
const res = await fetch(`${url}/tts`, {
|
|
350
|
+
method: "POST",
|
|
351
|
+
headers: { "content-type": "application/json" },
|
|
352
|
+
body: JSON.stringify({ text, voice, speed }),
|
|
353
|
+
signal,
|
|
354
|
+
});
|
|
355
|
+
if (!res.ok) {
|
|
356
|
+
const err = await res.text().catch(() => res.statusText);
|
|
357
|
+
throw new Error(`TTS ${res.status}: ${err}`);
|
|
358
|
+
}
|
|
359
|
+
await writeFile(outPath, Buffer.from(await res.arrayBuffer()));
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
async function synthEspeak(text: string, outPath: string): Promise<string> {
|
|
363
|
+
for (const bin of ["espeak-ng", "espeak"]) {
|
|
364
|
+
try {
|
|
365
|
+
const r = await run(bin, ["-w", outPath, "--", text]);
|
|
366
|
+
if (r.code === 0)
|
|
367
|
+
return bin;
|
|
368
|
+
} catch {
|
|
369
|
+
/* try next binary */
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
throw new Error("espeak-ng/espeak not installed");
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
async function synthMacSay(text: string, outPath: string): Promise<void> {
|
|
376
|
+
const r = await run("say", ["-o", outPath, "--data-format=LEI16@22050", text]);
|
|
377
|
+
if (r.code !== 0)
|
|
378
|
+
throw new Error(`say failed: ${r.stderr.trim() || `exit ${r.code}`}`);
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
async function playFile(file: string): Promise<string> {
|
|
382
|
+
const override = firstEnv("PI_SAY_PLAYER");
|
|
383
|
+
const candidates: Array<[string, string[]]> = override
|
|
384
|
+
? [[override, [file]]]
|
|
385
|
+
: IS_MAC
|
|
386
|
+
? [["afplay", [file]]]
|
|
387
|
+
: [
|
|
388
|
+
["pw-play", [file]],
|
|
389
|
+
["paplay", [file]],
|
|
390
|
+
["aplay", ["-q", file]],
|
|
391
|
+
["ffplay", ["-nodisp", "-autoexit", "-loglevel", "quiet", file]],
|
|
392
|
+
];
|
|
393
|
+
const errors: string[] = [];
|
|
394
|
+
for (const [bin, args] of candidates) {
|
|
395
|
+
try {
|
|
396
|
+
const r = await run(bin, args);
|
|
397
|
+
if (r.code === 0)
|
|
398
|
+
return bin;
|
|
399
|
+
errors.push(`${bin}: ${r.stderr.trim() || `exit ${r.code}`}`);
|
|
400
|
+
} catch (err) {
|
|
401
|
+
errors.push(`${bin}: ${err instanceof Error ? err.message : String(err)}`);
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
throw new Error(`no audio player available (${errors.join("; ")})`);
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
async function googleVoices(language: string): Promise<string[]> {
|
|
408
|
+
const { headers, query } = await googleAuth();
|
|
409
|
+
const sep = query ? "&" : "?";
|
|
410
|
+
const res = await fetch(
|
|
411
|
+
`https://texttospeech.googleapis.com/v1/voices${query}${sep}languageCode=${encodeURIComponent(language)}`,
|
|
412
|
+
{ headers },
|
|
413
|
+
);
|
|
414
|
+
const data = (await res.json().catch(() => ({}))) as {
|
|
415
|
+
voices?: Array<{ name?: string }>;
|
|
416
|
+
error?: { message?: string };
|
|
417
|
+
};
|
|
418
|
+
if (!res.ok)
|
|
419
|
+
throw new Error(`Google voices ${res.status}: ${data.error?.message ?? res.statusText}`);
|
|
420
|
+
return (data.voices ?? []).map((v) => v.name ?? "").filter(Boolean).sort();
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
function describeSettings(): string {
|
|
424
|
+
const voice = currentVoice();
|
|
425
|
+
const parts = [`voice ${voice}`, `engine ${currentEngine()}`, `speed ${currentSpeed()}`];
|
|
426
|
+
if (hasGoogleCreds())
|
|
427
|
+
parts.push("Google credentials ready");
|
|
428
|
+
else
|
|
429
|
+
parts.push("no Google credentials, local fallback only");
|
|
430
|
+
return parts.join(", ");
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
export default function (pi: ExtensionAPI) {
|
|
434
|
+
pi.registerTool({
|
|
435
|
+
name: "say",
|
|
436
|
+
label: "Say",
|
|
437
|
+
description:
|
|
438
|
+
"Speak one short sentence aloud (under twelve words). Use it as an ack before slow work and once at the end with the result. Never speak lists, code, paths, diffs, or secrets. Use say_voice to change voice, speed, or engine.",
|
|
439
|
+
promptSnippet: "One short spoken sentence; ack before slow work; silent after visible desktop changes",
|
|
440
|
+
promptGuidelines: [
|
|
441
|
+
"Before updates, installs, search, or long bash: say a 3-6 word ack first, then tools in the same turn.",
|
|
442
|
+
"After: one sentence. No package lists or tool output.",
|
|
443
|
+
"Do not say after a successful tile, focus, or keybind.",
|
|
444
|
+
"If the user asks to change the spoken voice, use say_voice instead of editing files.",
|
|
445
|
+
],
|
|
446
|
+
parameters: Type.Object({
|
|
447
|
+
text: Type.String({ description: "One short sentence, under twelve words" }),
|
|
448
|
+
interrupt: Type.Optional(Type.Boolean({ description: "Stop current speech first" })),
|
|
449
|
+
voice: Type.Optional(Type.String({ description: "One-off voice override for this sentence" })),
|
|
450
|
+
}),
|
|
451
|
+
async execute(_id, params, signal) {
|
|
452
|
+
const t0 = Date.now();
|
|
453
|
+
const text = clipSpoken(params.text);
|
|
454
|
+
if (!text)
|
|
455
|
+
return { content: [{ type: "text", text: "empty say ignored" }], details: {} };
|
|
456
|
+
|
|
457
|
+
const engine = currentEngine();
|
|
458
|
+
const voice = currentVoice(params.voice);
|
|
459
|
+
const speed = currentSpeed();
|
|
460
|
+
tlog("say_start", { chars: text.length, engine });
|
|
461
|
+
|
|
462
|
+
const dir = join(homedir(), ".cache/pi-say");
|
|
463
|
+
await mkdir(dir, { recursive: true });
|
|
464
|
+
const outPath = join(dir, `${Date.now()}-${randomBytes(4).toString("hex")}.wav`);
|
|
465
|
+
|
|
466
|
+
let used: string | undefined;
|
|
467
|
+
const failures: string[] = [];
|
|
468
|
+
|
|
469
|
+
if (engine !== "local" && engine !== "espeak" && hasGoogleCreds()) {
|
|
470
|
+
try {
|
|
471
|
+
await synthGoogle(text, voice, speed, outPath, signal);
|
|
472
|
+
used = "google";
|
|
473
|
+
} catch (err) {
|
|
474
|
+
failures.push(`google: ${err instanceof Error ? err.message : String(err)}`);
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
const url = localTtsUrl();
|
|
478
|
+
if (!used && engine !== "espeak" && !/^(off|none|-)$/i.test(url)) {
|
|
479
|
+
try {
|
|
480
|
+
await synthHttp(url, text, voice, speed, outPath, signal);
|
|
481
|
+
used = "local";
|
|
482
|
+
} catch (err) {
|
|
483
|
+
failures.push(`local: ${err instanceof Error ? err.message : String(err)}`);
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
if (!used && engine !== "espeak" && IS_MAC) {
|
|
487
|
+
try {
|
|
488
|
+
await synthMacSay(text, outPath);
|
|
489
|
+
used = "say";
|
|
490
|
+
} catch (err) {
|
|
491
|
+
failures.push(`say: ${err instanceof Error ? err.message : String(err)}`);
|
|
492
|
+
}
|
|
493
|
+
}
|
|
494
|
+
if (!used) {
|
|
495
|
+
try {
|
|
496
|
+
used = await synthEspeak(text, outPath);
|
|
497
|
+
} catch (err) {
|
|
498
|
+
failures.push(`espeak: ${err instanceof Error ? err.message : String(err)}`);
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
if (!used)
|
|
502
|
+
throw new Error(`could not synthesize speech (${failures.join("; ")})`);
|
|
503
|
+
|
|
504
|
+
tlog("say_tts_done", { ms: Date.now() - t0, engine: used, voice });
|
|
505
|
+
|
|
506
|
+
try {
|
|
507
|
+
await sendHud({ cmd: "speak", file: outPath, interrupt: Boolean(params.interrupt) });
|
|
508
|
+
tlog("say_hud_acked", { ms: Date.now() - t0 });
|
|
509
|
+
return {
|
|
510
|
+
content: [{ type: "text", text: `spoke: ${text}` }],
|
|
511
|
+
details: { engine: used, voice, file: outPath },
|
|
512
|
+
};
|
|
513
|
+
} catch (err) {
|
|
514
|
+
if (!isDaemonMissing(err))
|
|
515
|
+
throw err;
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
const player = await playFile(outPath);
|
|
519
|
+
tlog("say_player_played", { ms: Date.now() - t0, player });
|
|
520
|
+
return {
|
|
521
|
+
content: [{ type: "text", text: `spoke (${player}): ${text}` }],
|
|
522
|
+
details: { engine: used, voice, player, file: outPath },
|
|
523
|
+
};
|
|
524
|
+
},
|
|
525
|
+
});
|
|
526
|
+
|
|
527
|
+
pi.registerTool({
|
|
528
|
+
name: "say_voice",
|
|
529
|
+
label: "Say Voice",
|
|
530
|
+
description:
|
|
531
|
+
"Change or inspect the voice, speed, and engine used by the say tool. Use when the user asks to change how they are spoken to. Persists in ~/.config/pi-say/config.json.",
|
|
532
|
+
promptSnippet: "Change the spoken voice/speed/engine for say",
|
|
533
|
+
parameters: Type.Object({
|
|
534
|
+
voice: Type.Optional(Type.String({ description: "Google voice name, e.g. en-US-Chirp3-HD-Aoede" })),
|
|
535
|
+
speed: Type.Optional(Type.Number({ description: "Speaking rate, e.g. 1.0" })),
|
|
536
|
+
engine: Type.Optional(Type.String({ description: "auto, google, local, or espeak" })),
|
|
537
|
+
list: Type.Optional(Type.Boolean({ description: "List available Google voices" })),
|
|
538
|
+
language: Type.Optional(Type.String({ description: "Language code for list, default from current voice" })),
|
|
539
|
+
}),
|
|
540
|
+
async execute(_id, params) {
|
|
541
|
+
if (params.list) {
|
|
542
|
+
const language = params.language?.trim() || languageOfVoice(currentVoice());
|
|
543
|
+
try {
|
|
544
|
+
const names = await googleVoices(language);
|
|
545
|
+
const shown = names.slice(0, 60).join(", ");
|
|
546
|
+
return {
|
|
547
|
+
content: [
|
|
548
|
+
{
|
|
549
|
+
type: "text",
|
|
550
|
+
text: `${names.length} ${language} voices: ${shown}${names.length > 60 ? ", …" : ""}`,
|
|
551
|
+
},
|
|
552
|
+
],
|
|
553
|
+
details: { voices: names },
|
|
554
|
+
};
|
|
555
|
+
} catch (err) {
|
|
556
|
+
return {
|
|
557
|
+
content: [
|
|
558
|
+
{
|
|
559
|
+
type: "text",
|
|
560
|
+
text: `could not list Google voices: ${err instanceof Error ? err.message : String(err)}`,
|
|
561
|
+
},
|
|
562
|
+
],
|
|
563
|
+
details: {},
|
|
564
|
+
};
|
|
565
|
+
}
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
const patch: SayConfig = {};
|
|
569
|
+
if (params.voice?.trim())
|
|
570
|
+
patch.voice = params.voice.trim();
|
|
571
|
+
if (typeof params.speed === "number" && Number.isFinite(params.speed) && params.speed > 0)
|
|
572
|
+
patch.speed = params.speed;
|
|
573
|
+
if (params.engine) {
|
|
574
|
+
const engine = params.engine.trim().toLowerCase();
|
|
575
|
+
if (!ENGINES.includes(engine))
|
|
576
|
+
return {
|
|
577
|
+
content: [{ type: "text", text: `unknown engine ${engine}; use one of ${ENGINES.join(", ")}` }],
|
|
578
|
+
details: {},
|
|
579
|
+
};
|
|
580
|
+
patch.engine = engine;
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
if (Object.keys(patch).length > 0)
|
|
584
|
+
saveConfig(patch);
|
|
585
|
+
return {
|
|
586
|
+
content: [{ type: "text", text: describeSettings() }],
|
|
587
|
+
details: { config: { ...loadConfig() } },
|
|
588
|
+
};
|
|
589
|
+
},
|
|
590
|
+
});
|
|
591
|
+
|
|
592
|
+
pi.registerCommand("voice", {
|
|
593
|
+
description: "Show or change the say voice (/voice <name>, /voice list, /voice google|local|espeak)",
|
|
594
|
+
getArgumentCompletions: (prefix: string) => {
|
|
595
|
+
const items = [...ENGINES, "list", "speed", ...COMMON_VOICES, currentVoice()];
|
|
596
|
+
const seen = new Set<string>();
|
|
597
|
+
const matches = items
|
|
598
|
+
.filter((item) => {
|
|
599
|
+
if (seen.has(item))
|
|
600
|
+
return false;
|
|
601
|
+
seen.add(item);
|
|
602
|
+
return item.startsWith(prefix);
|
|
603
|
+
})
|
|
604
|
+
.map((item) => ({ value: item, label: item }));
|
|
605
|
+
return matches.length > 0 ? matches : null;
|
|
606
|
+
},
|
|
607
|
+
handler: async (args, ctx) => {
|
|
608
|
+
const arg = args.trim();
|
|
609
|
+
if (!arg) {
|
|
610
|
+
ctx.ui.notify(describeSettings(), "info");
|
|
611
|
+
return;
|
|
612
|
+
}
|
|
613
|
+
if (arg === "list") {
|
|
614
|
+
try {
|
|
615
|
+
const names = await googleVoices(languageOfVoice(currentVoice()));
|
|
616
|
+
ctx.ui.notify(`${names.length} voices: ${names.slice(0, 40).join(", ")}`, "info");
|
|
617
|
+
} catch (err) {
|
|
618
|
+
ctx.ui.notify(`could not list voices: ${err instanceof Error ? err.message : String(err)}`, "error");
|
|
619
|
+
}
|
|
620
|
+
return;
|
|
621
|
+
}
|
|
622
|
+
if (ENGINES.includes(arg.toLowerCase())) {
|
|
623
|
+
saveConfig({ engine: arg.toLowerCase() });
|
|
624
|
+
ctx.ui.notify(describeSettings(), "info");
|
|
625
|
+
return;
|
|
626
|
+
}
|
|
627
|
+
const speedMatch = /^speed\s+([0-9]*\.?[0-9]+)$/i.exec(arg);
|
|
628
|
+
if (speedMatch) {
|
|
629
|
+
const speed = Number(speedMatch[1]);
|
|
630
|
+
if (Number.isFinite(speed) && speed > 0) {
|
|
631
|
+
saveConfig({ speed });
|
|
632
|
+
ctx.ui.notify(describeSettings(), "info");
|
|
633
|
+
} else {
|
|
634
|
+
ctx.ui.notify("speed must be a positive number", "error");
|
|
635
|
+
}
|
|
636
|
+
return;
|
|
637
|
+
}
|
|
638
|
+
saveConfig({ voice: arg });
|
|
639
|
+
ctx.ui.notify(describeSettings(), "info");
|
|
640
|
+
},
|
|
641
|
+
});
|
|
642
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "pi-say",
|
|
3
|
+
"version": "1.1.0",
|
|
4
|
+
"description": "Spoken output for pi: a say tool that speaks short sentences through local TTS (Kokoro/Piper HTTP server, macOS say, or espeak-ng).",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"keywords": [
|
|
8
|
+
"pi-package",
|
|
9
|
+
"pi",
|
|
10
|
+
"tts",
|
|
11
|
+
"text-to-speech",
|
|
12
|
+
"speech",
|
|
13
|
+
"voice",
|
|
14
|
+
"accessibility"
|
|
15
|
+
],
|
|
16
|
+
"files": [
|
|
17
|
+
"extensions",
|
|
18
|
+
"README.md",
|
|
19
|
+
"LICENSE"
|
|
20
|
+
],
|
|
21
|
+
"pi": {
|
|
22
|
+
"extensions": [
|
|
23
|
+
"./extensions"
|
|
24
|
+
]
|
|
25
|
+
},
|
|
26
|
+
"peerDependencies": {
|
|
27
|
+
"@earendil-works/pi-coding-agent": "*",
|
|
28
|
+
"typebox": "*"
|
|
29
|
+
},
|
|
30
|
+
"engines": {
|
|
31
|
+
"node": ">=20"
|
|
32
|
+
}
|
|
33
|
+
}
|