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 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
+ }