@p8n.ai/pi-listens 0.1.0 → 0.1.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/CHANGELOG.md CHANGED
@@ -6,6 +6,22 @@ This project follows [Semantic Versioning](https://semver.org/).
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [0.1.2] - 2026-05-09
10
+
11
+ ### Changed
12
+
13
+ - Stream TTS audio directly to the local player so speech starts sooner.
14
+ - Make `voice_output` non-blocking by default; pass `wait_for_playback: true` to wait.
15
+ - Replace the `R` voice-panel shortcut with Space for easier listen/stop control.
16
+
17
+ ## [0.1.1] - 2026-05-09
18
+
19
+ ### Fixed
20
+
21
+ - Return Sarvam STT results faster after flushing microphone audio.
22
+ - Stop current speech playback before starting a new listen, without cancelling the new recording.
23
+ - Keep spoken auto-summaries concise and avoid headings, hashtags, bullet lists, and boilerplate recaps.
24
+
9
25
  ## [0.1.0] - 2026-05-09
10
26
 
11
27
  ### Added
@@ -23,5 +39,7 @@ This project follows [Semantic Versioning](https://semver.org/).
23
39
  - Stop active audio capture/playback subprocesses when voice mode is closed or the Pi session shuts down.
24
40
  - Clean up generated audio files when spoken playback is interrupted.
25
41
 
26
- [Unreleased]: https://github.com/p8n-ai/pi-listens/compare/v0.1.0...HEAD
42
+ [Unreleased]: https://github.com/p8n-ai/pi-listens/compare/v0.1.2...HEAD
27
43
  [0.1.0]: https://github.com/p8n-ai/pi-listens/releases/tag/v0.1.0
44
+ [0.1.1]: https://github.com/p8n-ai/pi-listens/releases/tag/v0.1.1
45
+ [0.1.2]: https://github.com/p8n-ai/pi-listens/releases/tag/v0.1.2
package/README.md CHANGED
@@ -80,9 +80,9 @@ The package registers these tools for Pi's agent:
80
80
  The extension also injects voice guidance into the system prompt:
81
81
 
82
82
  - use `voice_ask` whenever user input is needed in voice-first sessions
83
- - use `voice_output` for short spoken status or response snippets
83
+ - use `voice_output` only for short spoken status or response snippets
84
+ - keep spoken replies to 1-2 short sentences with no headings, hashtags, bullet lists, boilerplate recaps, or full task summaries
84
85
  - do not speak code blocks, logs, diffs, stack traces, or long explanations
85
- - keep spoken questions concise and answerable in a short response
86
86
 
87
87
  ## Commands
88
88
 
@@ -95,10 +95,10 @@ The extension also injects voice guidance into the system prompt:
95
95
  | `/voice-status` | Show setup and voice-mode status. |
96
96
 
97
97
  Voice panel controls in interactive mode:
98
- - R: listen now; press again while listening to stop listening
98
+ - Space: listen now; press again while listening to stop listening; if Pi is speaking, Space stops playback before listening
99
99
  - A: auto-listen on/off (listen again after each assistant reply)
100
100
  - S: read aloud on/off (speak assistant replies)
101
- - Q: close the panel (and stop listening first if needed)
101
+ - Q: close the panel and stop any active listening or speaking
102
102
  - Click the orb: visual ripple feedback (terminals with mouse reporting)
103
103
 
104
104
  ## Headless/RPC behavior
@@ -144,7 +144,7 @@ Example config file:
144
144
  "ttsOutputCodec": "wav",
145
145
  "textFallback": true,
146
146
  "autoSpeakAssistant": false,
147
- "maxAutoSpeakChars": 900
147
+ "maxAutoSpeakChars": 320
148
148
  }
149
149
  ```
150
150
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@p8n.ai/pi-listens",
3
- "version": "0.1.0",
3
+ "version": "0.1.2",
4
4
  "description": "Pi package for speech-first interaction using Sarvam AI speech-to-text and text-to-speech.",
5
5
  "author": "Ravindra Barthwal",
6
6
  "license": "MIT",
@@ -33,7 +33,7 @@
33
33
  ],
34
34
  "scripts": {
35
35
  "typecheck": "tsc --noEmit",
36
- "test": "npm run typecheck"
36
+ "test": "npm run typecheck && node --import tsx --test test/**/*.test.ts"
37
37
  },
38
38
  "pi": {
39
39
  "extensions": [
@@ -19,10 +19,11 @@ This Pi package provides voice tools backed by Sarvam AI.
19
19
 
20
20
  1. When you need user input, clarification, or confirmation, use `voice_ask` instead of asking only in text.
21
21
  2. Before using `voice_input`, make sure the user already knows you are listening. If not, use `voice_ask`.
22
- 3. Use `voice_output` for concise spoken status updates or spoken summaries that matter to the user.
23
- 4. Do not speak code blocks, diffs, stack traces, logs, long tables, or lengthy explanations. Summarize briefly and leave details in text.
24
- 5. Treat transcripts returned by `voice_input` or `voice_ask` as user input, while allowing for speech-recognition mistakes. If the transcript is ambiguous, ask a short follow-up with `voice_ask`.
25
- 6. If speech is not recognized, rely on the tool's text fallback when available, or ask again with a shorter prompt.
22
+ 3. Use `voice_output` only for concise spoken status updates or spoken summaries that matter to the user.
23
+ 4. Spoken output must be brief: 1-2 short sentences, no markdown headings, no hashtags, no bullet lists, no boilerplate recap, and no full task summaries. Leave details in text.
24
+ 5. Do not speak code blocks, diffs, stack traces, logs, long tables, or lengthy explanations. Summarize briefly and leave details in text.
25
+ 6. Treat transcripts returned by `voice_input` or `voice_ask` as user input, while allowing for speech-recognition mistakes. If the transcript is ambiguous, ask a short follow-up with `voice_ask`.
26
+ 7. If speech is not recognized, rely on the tool's text fallback when available, or ask again with a shorter prompt.
26
27
 
27
28
  ## Good voice question style
28
29
 
package/src/audio.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  import { mkdir, rm } from "node:fs/promises";
2
2
  import { randomUUID } from "node:crypto";
3
3
  import { join } from "node:path";
4
- import { spawn } from "node:child_process";
4
+ import { spawn, type StdioOptions } from "node:child_process";
5
5
  import { accessSync, constants } from "node:fs";
6
6
  import { once } from "node:events";
7
7
  import type { PiListensConfig } from "./config.js";
@@ -10,14 +10,17 @@ export interface AudioRuntime {
10
10
  record(seconds?: number, signal?: AbortSignal): Promise<string>;
11
11
  streamPcm(signal?: AbortSignal): AsyncIterable<Buffer>;
12
12
  play(path: string, signal?: AbortSignal): Promise<void>;
13
+ playStream(stream: ReadableStream<Uint8Array>, signal?: AbortSignal): Promise<void>;
13
14
  cleanup(path: string): Promise<void>;
15
+ stopPlayback(): void;
14
16
  stopAll(): void;
15
- describe(): { recorder: string; player: string };
17
+ describe(): { recorder: string; player: string; streamingPlayer: string };
16
18
  }
17
19
 
18
20
  export function createAudioRuntime(config: PiListensConfig): AudioRuntime {
19
21
  const recorder = config.recordCommand ? "custom" : detectRecorder();
20
22
  const player = config.playCommand ? "custom" : detectPlayer();
23
+ const streamingPlayer = detectStreamingPlayer();
21
24
 
22
25
  return {
23
26
  async record(seconds = config.recordSeconds, signal?: AbortSignal): Promise<string> {
@@ -41,7 +44,7 @@ export function createAudioRuntime(config: PiListensConfig): AudioRuntime {
41
44
  : useUtteranceMode
42
45
  ? utteranceRecorderCommand(recorder, path, config.recordSampleRate, config.silenceStartSeconds, config.silenceStopSeconds, config.silenceThreshold)
43
46
  : recorderCommand(recorder, path, seconds, config.recordSampleRate);
44
- await run(command.command, command.args, signal, useUtteranceMode ? { timeoutMs: seconds * 1000, resolveOnTimeout: true } : undefined);
47
+ await run(command.command, command.args, signal, { ...(useUtteranceMode ? { timeoutMs: seconds * 1000, resolveOnTimeout: true } : {}), kind: "record" });
45
48
  return path;
46
49
  },
47
50
 
@@ -54,7 +57,7 @@ export function createAudioRuntime(config: PiListensConfig): AudioRuntime {
54
57
  const command = config.streamCommand
55
58
  ? customCommand(config.streamCommand, { sampleRate: config.recordSampleRate })
56
59
  : pcmStreamCommand(recorder, config.recordSampleRate);
57
- return streamCommandOutput(command.command, command.args, signal);
60
+ return streamCommandOutput(command.command, command.args, signal, "record");
58
61
  },
59
62
 
60
63
  async play(path: string, signal?: AbortSignal): Promise<void> {
@@ -64,7 +67,17 @@ export function createAudioRuntime(config: PiListensConfig): AudioRuntime {
64
67
  );
65
68
  }
66
69
  const command = config.playCommand ? customCommand(config.playCommand, { path }) : playerCommand(player, path);
67
- await run(command.command, command.args, signal);
70
+ await run(command.command, command.args, signal, { kind: "play" });
71
+ },
72
+
73
+ async playStream(stream: ReadableStream<Uint8Array>, signal?: AbortSignal): Promise<void> {
74
+ if (!streamingPlayer) {
75
+ throw new Error(
76
+ "No streaming audio player found. Install ffplay or sox (`play`) for low-latency TTS playback, or use file playback fallback.",
77
+ );
78
+ }
79
+ const command = streamingPlayerCommand(streamingPlayer, config.ttsOutputCodec, config.ttsSampleRate);
80
+ await pipeStreamToCommand(stream, command.command, command.args, signal);
68
81
  },
69
82
 
70
83
  async cleanup(path: string): Promise<void> {
@@ -72,12 +85,16 @@ export function createAudioRuntime(config: PiListensConfig): AudioRuntime {
72
85
  await rm(path, { force: true }).catch(() => undefined);
73
86
  },
74
87
 
88
+ stopPlayback(): void {
89
+ stopActiveAudioProcesses({ kind: "play" });
90
+ },
91
+
75
92
  stopAll(): void {
76
93
  stopActiveAudioProcesses();
77
94
  },
78
95
 
79
96
  describe() {
80
- return { recorder: recorder ?? "missing", player: player ?? "missing" };
97
+ return { recorder: recorder ?? "missing", player: player ?? "missing", streamingPlayer: streamingPlayer ?? "missing" };
81
98
  },
82
99
  };
83
100
  }
@@ -200,6 +217,13 @@ function detectPlayer(): string | null {
200
217
  return null;
201
218
  }
202
219
 
220
+ function detectStreamingPlayer(): string | null {
221
+ if (isCommandAvailable("ffplay")) return "ffplay";
222
+ if (isCommandAvailable("play")) return "play";
223
+ if (isCommandAvailable("aplay")) return "aplay";
224
+ return null;
225
+ }
226
+
203
227
  function isCommandAvailable(command: string): boolean {
204
228
  const paths = (process.env.PATH ?? "").split(":").filter(Boolean);
205
229
  for (const dir of paths) {
@@ -213,14 +237,14 @@ function isCommandAvailable(command: string): boolean {
213
237
  return false;
214
238
  }
215
239
 
216
- function run(command: string, args: string[], signal?: AbortSignal, options: { timeoutMs?: number; resolveOnTimeout?: boolean } = {}): Promise<void> {
240
+ function run(command: string, args: string[], signal?: AbortSignal, options: { timeoutMs?: number; resolveOnTimeout?: boolean; kind?: AudioProcessKind } = {}): Promise<void> {
217
241
  return new Promise((resolve, reject) => {
218
242
  if (signal?.aborted) {
219
243
  reject(new Error("Cancelled"));
220
244
  return;
221
245
  }
222
246
 
223
- const child = spawnManaged(command, args);
247
+ const child = spawnManaged(command, args, options.kind ?? "other");
224
248
  let stderr = "";
225
249
  let stdout = "";
226
250
  let timedOut = false;
@@ -265,9 +289,9 @@ function run(command: string, args: string[], signal?: AbortSignal, options: { t
265
289
  });
266
290
  }
267
291
 
268
- async function* streamCommandOutput(command: string, args: string[], signal?: AbortSignal): AsyncIterable<Buffer> {
292
+ async function* streamCommandOutput(command: string, args: string[], signal?: AbortSignal, kind: AudioProcessKind = "other"): AsyncIterable<Buffer> {
269
293
  if (signal?.aborted) throw new Error("Cancelled");
270
- const child = spawnManaged(command, args);
294
+ const child = spawnManaged(command, args, kind);
271
295
  let stderr = "";
272
296
  let exitCode: number | null = null;
273
297
  let exitSignal: NodeJS.Signals | null = null;
@@ -298,23 +322,99 @@ async function* streamCommandOutput(command: string, args: string[], signal?: Ab
298
322
  }
299
323
  }
300
324
 
325
+ async function pipeStreamToCommand(stream: ReadableStream<Uint8Array>, command: string, args: string[], signal?: AbortSignal): Promise<void> {
326
+ if (signal?.aborted) throw new Error("Cancelled");
327
+ const child = spawnManaged(command, args, "play", ["pipe", "pipe", "pipe"]);
328
+ let stderr = "";
329
+ let stdout = "";
330
+ let exitCode: number | null = null;
331
+ let exitSignal: NodeJS.Signals | null = null;
332
+ let spawnError: Error | undefined;
333
+
334
+ const stop = () => terminateChild(child);
335
+ signal?.addEventListener("abort", stop, { once: true });
336
+ child.stdout?.on("data", (chunk) => { stdout += chunk.toString(); });
337
+ child.stderr?.on("data", (chunk) => { stderr += chunk.toString(); });
338
+ child.on("error", (err) => { spawnError = err; });
339
+ child.on("close", (code, termSignal) => { exitCode = code; exitSignal = termSignal; });
340
+
341
+ try {
342
+ if (!child.stdin) throw new Error(`${command} did not provide stdin for streaming audio playback`);
343
+ const stdin = child.stdin;
344
+ const reader = stream.getReader();
345
+ try {
346
+ while (true) {
347
+ if (signal?.aborted) throw new Error("Cancelled");
348
+ if (spawnError) throw spawnError;
349
+ const { done, value } = await reader.read();
350
+ if (done) break;
351
+ if (!value?.byteLength) continue;
352
+ if (!stdin.write(Buffer.from(value))) await once(stdin, "drain");
353
+ }
354
+ } finally {
355
+ reader.releaseLock();
356
+ }
357
+ stdin.end();
358
+ if (exitCode === null && !spawnError) await once(child, "close");
359
+ if (signal?.aborted) throw new Error("Cancelled");
360
+ if (spawnError) throw spawnError;
361
+ if (exitCode !== 0) {
362
+ const output = [stderr.trim(), stdout.trim()].filter(Boolean).join("\n");
363
+ throw new Error(`${command} failed${exitSignal ? ` (${exitSignal})` : ""}${exitCode === null ? "" : ` with exit code ${exitCode}`}${output ? `: ${output}` : ""}`);
364
+ }
365
+ } finally {
366
+ signal?.removeEventListener("abort", stop);
367
+ if (!child.killed && exitCode === null) stop();
368
+ }
369
+ }
370
+
371
+ function streamingPlayerCommand(player: string, codec: PiListensConfig["ttsOutputCodec"], sampleRate: number): CommandSpec {
372
+ if (player === "ffplay") {
373
+ const args = ["-nodisp", "-autoexit", "-loglevel", "error"];
374
+ if (codec === "linear16") args.push("-f", "s16le", "-ar", String(sampleRate), "-ac", "1");
375
+ if (codec === "mulaw") args.push("-f", "mulaw", "-ar", String(sampleRate), "-ac", "1");
376
+ if (codec === "alaw") args.push("-f", "alaw", "-ar", String(sampleRate), "-ac", "1");
377
+ args.push("-i", "pipe:0");
378
+ return { command: "ffplay", args };
379
+ }
380
+ if (player === "play") {
381
+ if (codec === "linear16") return { command: "play", args: ["-q", "-r", String(sampleRate), "-c", "1", "-b", "16", "-e", "signed-integer", "-t", "raw", "-"] };
382
+ if (codec === "mulaw" || codec === "alaw") return { command: "play", args: ["-q", "-r", String(sampleRate), "-c", "1", "-t", codec, "-"] };
383
+ return { command: "play", args: ["-q", "-t", soxTypeForCodec(codec), "-"] };
384
+ }
385
+ if (player === "aplay" && codec === "wav") return { command: "aplay", args: ["-q", "-"] };
386
+ throw new Error(`Unsupported streaming player ${player} for codec ${codec}`);
387
+ }
388
+
389
+ function soxTypeForCodec(codec: PiListensConfig["ttsOutputCodec"]): string {
390
+ if (codec === "aac") return "adts";
391
+ if (codec === "linear16") return "raw";
392
+ return codec;
393
+ }
394
+
395
+ type AudioProcessKind = "record" | "play" | "other";
396
+
301
397
  type ManagedChild = ReturnType<typeof spawn>;
302
398
 
303
399
  const activeChildren = new Set<ManagedChild>();
400
+ const childKinds = new WeakMap<ManagedChild, AudioProcessKind>();
304
401
  const terminatingChildren = new WeakSet<ManagedChild>();
305
402
  let processExitCleanupInstalled = false;
306
403
 
307
- export function stopActiveAudioProcesses(force = false): void {
308
- for (const child of [...activeChildren]) terminateChild(child, force);
404
+ export function stopActiveAudioProcesses(options: { kind?: AudioProcessKind; force?: boolean } = {}): void {
405
+ for (const child of [...activeChildren]) {
406
+ if (!options.kind || childKinds.get(child) === options.kind) terminateChild(child, options.force);
407
+ }
309
408
  }
310
409
 
311
- function spawnManaged(command: string, args: string[]): ManagedChild {
410
+ function spawnManaged(command: string, args: string[], kind: AudioProcessKind, stdio: StdioOptions = ["ignore", "pipe", "pipe"]): ManagedChild {
312
411
  installProcessExitCleanup();
313
412
  const child = spawn(command, args, {
314
- stdio: ["ignore", "pipe", "pipe"],
413
+ stdio,
315
414
  detached: process.platform !== "win32",
316
415
  });
317
416
  activeChildren.add(child);
417
+ childKinds.set(child, kind);
318
418
  const untrack = () => activeChildren.delete(child);
319
419
  child.once("close", untrack);
320
420
  child.once("error", untrack);
@@ -324,7 +424,7 @@ function spawnManaged(command: string, args: string[]): ManagedChild {
324
424
  function installProcessExitCleanup(): void {
325
425
  if (processExitCleanupInstalled) return;
326
426
  processExitCleanupInstalled = true;
327
- process.once("exit", () => stopActiveAudioProcesses(true));
427
+ process.once("exit", () => stopActiveAudioProcesses({ force: true }));
328
428
  }
329
429
 
330
430
  function terminateChild(child: ManagedChild, force = false): void {
package/src/commands.ts CHANGED
@@ -74,6 +74,7 @@ export function registerVoiceCommands(pi: ExtensionAPI, services: VoiceToolServi
74
74
  `Sarvam API key: ${config.apiKey ? "set" : "missing"}`,
75
75
  `Recorder: ${audio.recorder}`,
76
76
  `Player: ${audio.player}`,
77
+ `Streaming player: ${audio.streamingPlayer}`,
77
78
  `STT: ${config.sttModel} (${config.translateInputToEnglish ? "translate→English" : config.sttMode}, ${config.sttLanguageCode})`,
78
79
  `TTS: ${config.ttsModel} (${config.ttsLanguageCode}, speaker ${config.ttsSpeaker})`,
79
80
  ].join("\n"),
@@ -121,6 +122,7 @@ async function listenAndSend(
121
122
  state.listenAbortController?.abort();
122
123
  return;
123
124
  }
125
+ stopSpeaking(services, state);
124
126
  state.recordSeconds = seconds ?? services.getConfig().recordSeconds;
125
127
  state.silenceStopSeconds = services.getConfig().silenceStopSeconds;
126
128
  state.isListening = true;
@@ -176,10 +178,8 @@ async function listenAndSend(
176
178
  }
177
179
 
178
180
  async function speakText(services: VoiceToolServices, text: string, signal?: AbortSignal, state?: VoiceModeState, ctx?: ExtensionContext) {
179
- const config = services.getConfig();
180
181
  const speakAbortController = state ? new AbortController() : undefined;
181
182
  const speakSignal = combineSignals(signal, speakAbortController?.signal);
182
- let path: string | undefined;
183
183
 
184
184
  if (state) {
185
185
  state.speakAbortController?.abort();
@@ -189,14 +189,9 @@ async function speakText(services: VoiceToolServices, text: string, signal?: Abo
189
189
  }
190
190
 
191
191
  try {
192
- await mkdir(config.audioDir, { recursive: true });
193
- path = join(config.audioDir, `pi-listens-command-${Date.now()}.${audioExtensionForCodec(config.ttsOutputCodec)}`);
194
- const result = await services.getSpeech().synthesizeToFile(text, path, speakSignal.signal);
195
- path = result.path;
196
- await services.getAudio().play(result.path, speakSignal.signal);
192
+ await playSpeechBest(services, text, speakSignal.signal);
197
193
  } finally {
198
194
  speakSignal.cleanup();
199
- if (path) await services.getAudio().cleanup(path);
200
195
  if (state && state.speakAbortController === speakAbortController) state.speakAbortController = undefined;
201
196
  if (state && state.status === "speaking") {
202
197
  state.status = "idle";
@@ -205,6 +200,25 @@ async function speakText(services: VoiceToolServices, text: string, signal?: Abo
205
200
  }
206
201
  }
207
202
 
203
+ async function playSpeechBest(services: VoiceToolServices, text: string, signal?: AbortSignal) {
204
+ const audio = services.getAudio();
205
+ if (audio.describe().streamingPlayer !== "missing") {
206
+ const result = await services.getSpeech().synthesizeStream(text, signal);
207
+ await audio.playStream(result.stream, signal);
208
+ return;
209
+ }
210
+
211
+ const config = services.getConfig();
212
+ await mkdir(config.audioDir, { recursive: true });
213
+ const path = join(config.audioDir, `pi-listens-command-${Date.now()}.${audioExtensionForCodec(config.ttsOutputCodec)}`);
214
+ try {
215
+ const result = await services.getSpeech().synthesizeToFile(text, path, signal);
216
+ await audio.play(result.path, signal);
217
+ } finally {
218
+ await audio.cleanup(path);
219
+ }
220
+ }
221
+
208
222
  function parseSeconds(args: string): number | undefined {
209
223
  const match = args.match(/(?:^|\s)(\d{1,4})(?:\s|$)/);
210
224
  if (!match) return undefined;
@@ -253,6 +267,13 @@ function isCancelled(err: unknown): boolean {
253
267
  return err instanceof Error && /cancelled|aborted/i.test(err.message);
254
268
  }
255
269
 
270
+ function stopSpeaking(services: VoiceToolServices, state: VoiceModeState) {
271
+ const speakAbortController = state.speakAbortController;
272
+ state.speakAbortController = undefined;
273
+ speakAbortController?.abort();
274
+ services.getAudio().stopPlayback();
275
+ }
276
+
256
277
  export function stopVoiceMode(services: VoiceToolServices, state: VoiceModeState, ctx?: ExtensionContext | ExtensionCommandContext) {
257
278
  state.enabled = false;
258
279
  state.autoListen = false;
@@ -264,10 +285,7 @@ export function stopVoiceMode(services: VoiceToolServices, state: VoiceModeState
264
285
  state.listenAbortController = undefined;
265
286
  listenAbortController?.abort();
266
287
 
267
- const speakAbortController = state.speakAbortController;
268
- state.speakAbortController = undefined;
269
- speakAbortController?.abort();
270
-
288
+ stopSpeaking(services, state);
271
289
  services.getAudio().stopAll();
272
290
 
273
291
  if (ctx) uninstallVoiceUi(ctx, state);
package/src/config.ts CHANGED
@@ -60,7 +60,7 @@ const DEFAULT_CONFIG: PiListensConfig = {
60
60
  deleteAudio: true,
61
61
  textFallback: true,
62
62
  autoSpeakAssistant: false,
63
- maxAutoSpeakChars: 900,
63
+ maxAutoSpeakChars: 320,
64
64
  };
65
65
 
66
66
  type RawConfig = Partial<PiListensConfig>;
package/src/index.ts CHANGED
@@ -65,7 +65,7 @@ export default function piListensExtension(pi: ExtensionAPI) {
65
65
 
66
66
  pi.on("before_agent_start", async (event) => {
67
67
  return {
68
- systemPrompt: `${event.systemPrompt}\n\nPi Listens voice guidance:\n- The user may primarily interact by speech through Sarvam AI. Text input is still possible.\n- When voice mode is active, treat it as a hands-free conversation: listen only while the voice UI/input tool is active, then pause listening while you work.\n- Use voice_output for concise spoken progress, completion, or status updates that matter to the user.\n- When you need clarification, confirmation, or any user input, prefer voice_ask with a concise spoken question instead of asking only in text.\n- Use voice_input only after the user already knows you are listening.\n- Do not speak code blocks, logs, diffs, stack traces, or long explanations; summarize them briefly and leave detail in text.`,
68
+ systemPrompt: `${event.systemPrompt}\n\nPi Listens voice guidance:\n- The user may primarily interact by speech through Sarvam AI. Text input is still possible.\n- When voice mode is active, treat it as a hands-free conversation: listen only while the voice UI/input tool is active, then pause listening while you work.\n- Use voice_output only for concise spoken progress, completion, or status updates that matter to the user.\n- Spoken replies must be brief: 1-2 short sentences, no headings, no hashtags, no bullet lists, no boilerplate recap, and no full task summaries. Leave details in text.\n- When you need clarification, confirmation, or any user input, prefer voice_ask with a concise spoken question instead of asking only in text.\n- Use voice_input only after the user already knows you are listening.\n- Do not speak code blocks, logs, diffs, stack traces, or long explanations; summarize briefly and leave detail in text.`,
69
69
  };
70
70
  });
71
71
 
package/src/sarvam.ts CHANGED
@@ -1,5 +1,4 @@
1
1
  import { readFile, writeFile } from "node:fs/promises";
2
- import { setTimeout as delay } from "node:timers/promises";
3
2
  import { SarvamAIClient } from "sarvamai";
4
3
  import type { AudioRuntime } from "./audio.js";
5
4
  import type { PiListensConfig, SttMode } from "./config.js";
@@ -16,6 +15,10 @@ export interface SynthesisResult {
16
15
  bytes: number;
17
16
  }
18
17
 
18
+ export interface SynthesisStreamResult {
19
+ stream: ReadableStream<Uint8Array>;
20
+ }
21
+
19
22
  type StreamingData = {
20
23
  transcript?: string;
21
24
  request_id?: string;
@@ -128,6 +131,28 @@ export class SarvamSpeechClient {
128
131
  return { path, bytes: buffer.byteLength };
129
132
  }
130
133
 
134
+ async synthesizeStream(text: string, signal?: AbortSignal): Promise<SynthesisStreamResult> {
135
+ const config = this.getConfig();
136
+ const client = this.getClient(config);
137
+ const response = await client.textToSpeech.convertStream(
138
+ {
139
+ text,
140
+ target_language_code: config.ttsLanguageCode as never,
141
+ speaker: config.ttsSpeaker as never,
142
+ model: config.ttsModel as never,
143
+ pace: config.ttsPace,
144
+ temperature: config.ttsTemperature,
145
+ speech_sample_rate: config.ttsSampleRate as never,
146
+ enable_preprocessing: true,
147
+ output_audio_codec: config.ttsOutputCodec as never,
148
+ },
149
+ { abortSignal: signal },
150
+ );
151
+ const stream = response.stream();
152
+ if (!stream) throw new Error("Sarvam TTS response did not include a readable audio stream");
153
+ return { stream };
154
+ }
155
+
131
156
  private async withStreamingSocket(
132
157
  signal: AbortSignal | undefined,
133
158
  mode: SttMode | undefined,
@@ -145,35 +170,46 @@ export class SarvamSpeechClient {
145
170
  let languageProbability: number | undefined;
146
171
  let streamError: Error | undefined;
147
172
  let lastMessageAt = Date.now();
148
-
173
+ const messageWaiters = new Set<() => void>();
149
174
  const socket = connectStreamingSocket(config, mode ?? (config.translateInputToEnglish ? "translate" : config.sttMode), inputAudioCodec);
150
175
 
151
176
  const closeOnAbort = () => socket.close();
152
177
  signal?.addEventListener("abort", closeOnAbort, { once: true });
178
+ const notifyMessageWaiters = () => {
179
+ const waiters = [...messageWaiters];
180
+ messageWaiters.clear();
181
+ for (const waiter of waiters) waiter();
182
+ };
153
183
  socket.onMessage((message: StreamingResponse) => {
154
184
  lastMessageAt = Date.now();
155
- if (message.type === "error") {
156
- streamError = new Error(message.data?.error ?? message.data?.code ?? "Sarvam streaming STT failed");
157
- return;
185
+ try {
186
+ if (message.type === "error") {
187
+ streamError = new Error(message.data?.error ?? message.data?.code ?? "Sarvam streaming STT failed");
188
+ return;
189
+ }
190
+ if (message.type !== "data") return;
191
+ const data = message.data;
192
+ if (!data) return;
193
+ transcript = mergeTranscript(transcript, data.transcript ?? "");
194
+ requestId = data.request_id ?? requestId;
195
+ languageCode = data.language_code ?? languageCode;
196
+ languageProbability = data.language_probability ?? languageProbability;
197
+ } finally {
198
+ notifyMessageWaiters();
158
199
  }
159
- if (message.type !== "data") return;
160
- const data = message.data;
161
- if (!data) return;
162
- transcript = mergeTranscript(transcript, data.transcript ?? "");
163
- requestId = data.request_id ?? requestId;
164
- languageCode = data.language_code ?? languageCode;
165
- languageProbability = data.language_probability ?? languageProbability;
166
200
  });
167
- socket.onError((error: Error) => { streamError = error; });
201
+ socket.onError((error: Error) => { streamError = error; notifyMessageWaiters(); });
168
202
 
169
203
  try {
170
204
  await socket.waitForOpen();
171
205
  await streamAudio(socket, async () => {
172
206
  const startedWaitingAt = Date.now();
173
- while (Date.now() - startedWaitingAt < 3000) {
207
+ const maxWaitMs = transcript.trim() ? 900 : 1600;
208
+ const settleMs = 250;
209
+ while (Date.now() - startedWaitingAt < maxWaitMs) {
174
210
  if (streamError) throw streamError;
175
- if (Date.now() - lastMessageAt > 850 && transcript.trim()) break;
176
- await delay(100, undefined, { signal }).catch((err) => { throw err; });
211
+ if (transcript.trim() && Date.now() - lastMessageAt >= settleMs) break;
212
+ await waitForMessageOrTimeout(messageWaiters, 50, signal);
177
213
  }
178
214
  });
179
215
  if (streamError) throw streamError;
@@ -288,6 +324,27 @@ function connectStreamingSocket(config: PiListensConfig, mode: SttMode, inputAud
288
324
  };
289
325
  }
290
326
 
327
+ function waitForMessageOrTimeout(waiters: Set<() => void>, timeoutMs: number, signal?: AbortSignal): Promise<void> {
328
+ return new Promise((resolve, reject) => {
329
+ if (signal?.aborted) {
330
+ reject(new Error("Cancelled"));
331
+ return;
332
+ }
333
+
334
+ const done = () => { cleanup(); resolve(); };
335
+ const onAbort = () => { cleanup(); reject(new Error("Cancelled")); };
336
+ const timeout = setTimeout(done, timeoutMs);
337
+ const cleanup = () => {
338
+ clearTimeout(timeout);
339
+ waiters.delete(done);
340
+ signal?.removeEventListener("abort", onAbort);
341
+ };
342
+
343
+ waiters.add(done);
344
+ signal?.addEventListener("abort", onAbort, { once: true });
345
+ });
346
+ }
347
+
291
348
  type CombinedSignal = { signal?: AbortSignal; cleanup: () => void };
292
349
 
293
350
  function combineSignals(...signals: Array<AbortSignal | undefined>): CombinedSignal {
package/src/text.ts CHANGED
@@ -16,17 +16,32 @@ export function firstTextContent(message: unknown): string {
16
16
 
17
17
  export function prepareSpokenText(text: string, maxChars: number): string {
18
18
  let prepared = text
19
- .replace(/```[\s\S]*?```/g, " I am skipping a code block. ")
19
+ .replace(/```[\s\S]*?```/g, " I skipped a code block. ")
20
+ .replace(/^\s{0,3}#{1,6}\s+/gm, "")
21
+ .replace(/^\s*[-*+]\s+/gm, "")
22
+ .replace(/^\s*\d+[.)]\s+/gm, "")
20
23
  .replace(/`([^`]+)`/g, "$1")
21
24
  .replace(/https?:\/\/\S+/g, "link")
25
+ .replace(/[#*_>~|]+/g, " ")
22
26
  .replace(/\s+/g, " ")
23
27
  .trim();
28
+
29
+ prepared = conciseSpokenSummary(prepared);
24
30
  if (prepared.length > maxChars) {
25
- prepared = `${prepared.slice(0, Math.max(0, maxChars - 80)).trim()}… I have more details on screen.`;
31
+ prepared = `${prepared.slice(0, Math.max(0, maxChars - 32)).trim()}… More on screen.`;
26
32
  }
27
33
  return prepared;
28
34
  }
29
35
 
36
+ function conciseSpokenSummary(text: string): string {
37
+ const sentences = text.match(/[^.!?]+[.!?]+|[^.!?]+$/g)?.map((part) => part.trim()).filter(Boolean) ?? [];
38
+ if (sentences.length === 0) return text;
39
+
40
+ const useful = sentences.filter((sentence) => !/^(sure|here('|’)s|summary|in summary|done|completed|i('|’)ve|i have)\b/i.test(sentence));
41
+ const picked = (useful.length ? useful : sentences).slice(0, 2).join(" ").trim();
42
+ return picked || text;
43
+ }
44
+
30
45
  export function conciseTranscript(transcript: string): string {
31
46
  const trimmed = transcript.trim();
32
47
  return trimmed.length === 0 ? "(no speech recognized)" : trimmed;
package/src/tools.ts CHANGED
@@ -17,7 +17,7 @@ export interface VoiceToolServices {
17
17
 
18
18
  const VoiceOutputParams = Type.Object({
19
19
  text: Type.String({ description: "Short text to speak to the user. Keep it concise; do not speak code blocks or long logs." }),
20
- wait_for_playback: Type.Optional(Type.Boolean({ description: "Wait until audio playback completes before returning. Default true." })),
20
+ wait_for_playback: Type.Optional(Type.Boolean({ description: "Wait until audio playback completes before returning. Default false." })),
21
21
  });
22
22
 
23
23
  const VoiceInputParams = Type.Object({
@@ -49,26 +49,25 @@ export function registerVoiceTools(pi: ExtensionAPI, services: VoiceToolServices
49
49
  description: "Speak a short message to the user using Sarvam AI text-to-speech and local audio playback.",
50
50
  promptSnippet: "Speak short user-facing messages with Sarvam AI TTS",
51
51
  promptGuidelines: [
52
- "Use voice_output when a spoken user-facing message matters, especially before waiting for voice input.",
53
- "Keep voice_output text brief and conversational; do not speak code blocks, command output, stack traces, or long explanations.",
52
+ "Use voice_output only when a spoken user-facing message matters, especially before waiting for voice input.",
53
+ "Keep voice_output to 1-2 short conversational sentences. Do not speak headings, hashtags, bullet lists, boilerplate recaps, code, command output, stack traces, or long explanations.",
54
54
  ],
55
55
  parameters: VoiceOutputParams,
56
56
  async execute(_toolCallId, params: VoiceOutputInput, signal, onUpdate) {
57
- onUpdate?.({ content: [{ type: "text", text: "Synthesizing speech with Sarvam AI…" }], details: {} });
58
- const result = await speak(params.text, services, signal);
59
- const playback = services.getAudio().play(result.path, signal).finally(() => services.getAudio().cleanup(result.path));
60
- if (params.wait_for_playback === false) {
57
+ onUpdate?.({ content: [{ type: "text", text: "Starting streamed speech with Sarvam AI…" }], details: {} });
58
+ const playback = playSpeechBest(params.text, services, signal);
59
+ if (params.wait_for_playback !== true) {
61
60
  void playback.catch(() => undefined);
62
61
  return {
63
62
  content: [{ type: "text", text: `Started speaking to user: ${params.text}` }],
64
- details: { ...result, played: "started", text: params.text },
63
+ details: { played: "started", text: params.text },
65
64
  };
66
65
  }
67
66
  onUpdate?.({ content: [{ type: "text", text: "Playing audio…" }], details: {} });
68
- await playback;
67
+ const details = await playback;
69
68
  return {
70
69
  content: [{ type: "text", text: `Spoke to user: ${params.text}` }],
71
- details: { ...result, played: true, text: params.text },
70
+ details: { ...details, played: true, text: params.text },
72
71
  };
73
72
  },
74
73
  renderCall(args: VoiceOutputInput, theme) {
@@ -117,12 +116,7 @@ export function registerVoiceTools(pi: ExtensionAPI, services: VoiceToolServices
117
116
  parameters: VoiceAskParams,
118
117
  async execute(_toolCallId, params: VoiceAskInput, signal, onUpdate, ctx) {
119
118
  onUpdate?.({ content: [{ type: "text", text: "Speaking question…" }], details: {} });
120
- const spoken = await speak(params.question, services, signal);
121
- try {
122
- await services.getAudio().play(spoken.path, signal);
123
- } finally {
124
- await services.getAudio().cleanup(spoken.path);
125
- }
119
+ await playSpeechBest(params.question, services, signal);
126
120
  const answer = await listenAndMaybeFallback(
127
121
  params,
128
122
  services,
@@ -173,6 +167,7 @@ export function registerVoiceTools(pi: ExtensionAPI, services: VoiceToolServices
173
167
  `Sarvam API key: ${config.apiKey ? "set" : "missing"}`,
174
168
  `Recorder: ${audio.recorder}`,
175
169
  `Player: ${audio.player}`,
170
+ `Streaming player: ${audio.streamingPlayer}`,
176
171
  `STT: ${config.sttModel} (${config.translateInputToEnglish ? "translate→English" : config.sttMode}, ${config.sttLanguageCode})`,
177
172
  `TTS: ${config.ttsModel} (${config.ttsLanguageCode}, speaker ${config.ttsSpeaker})`,
178
173
  ].join("\n"),
@@ -184,7 +179,24 @@ export function registerVoiceTools(pi: ExtensionAPI, services: VoiceToolServices
184
179
  });
185
180
  }
186
181
 
187
- async function speak(text: string, services: VoiceToolServices, signal?: AbortSignal) {
182
+ async function playSpeechBest(text: string, services: VoiceToolServices, signal?: AbortSignal): Promise<Record<string, unknown>> {
183
+ const audio = services.getAudio();
184
+ if (audio.describe().streamingPlayer !== "missing") {
185
+ const result = await services.getSpeech().synthesizeStream(text, signal);
186
+ await audio.playStream(result.stream, signal);
187
+ return { playback: "stream" };
188
+ }
189
+
190
+ const result = await speakToFile(text, services, signal);
191
+ try {
192
+ await audio.play(result.path, signal);
193
+ return { ...result, playback: "file" };
194
+ } finally {
195
+ await audio.cleanup(result.path);
196
+ }
197
+ }
198
+
199
+ async function speakToFile(text: string, services: VoiceToolServices, signal?: AbortSignal) {
188
200
  const config = services.getConfig();
189
201
  await mkdir(config.audioDir, { recursive: true });
190
202
  const path = join(config.audioDir, `pi-listens-output-${Date.now()}-${randomUUID()}.${audioExtensionForCodec(config.ttsOutputCodec)}`);
package/src/voice-ui.ts CHANGED
@@ -88,7 +88,7 @@ class VoiceLoopEditor extends CustomEditor {
88
88
  if (mouse.pressed && mouse.button === 0) this.triggerMouseOrbClick(mouse);
89
89
  return;
90
90
  }
91
- if (data.toLowerCase() === "r") {
91
+ if (data === " ") {
92
92
  this.triggerOrbClick(1);
93
93
  this.callbacks.startListening();
94
94
  return;
@@ -309,7 +309,7 @@ function frameIntervalForStatus(status: VoiceModeState["status"]): number {
309
309
  function controlRail(state: VoiceModeState, palette: OrbPalette, width: number): string[] {
310
310
  const listenLabel = state.isListening ? "stop" : "listen";
311
311
  const pills = [
312
- controlPill("R", listenLabel, state.isListening ? "active" : "primary", palette),
312
+ controlPill("Space", listenLabel, state.isListening ? "active" : "primary", palette),
313
313
  controlPill("A", state.autoListen ? "auto-listen on" : "auto-listen off", state.autoListen ? "active" : "muted", palette),
314
314
  controlPill("S", state.autoSpeakAssistant ? "read aloud on" : "read aloud off", state.autoSpeakAssistant ? "active" : "muted", palette),
315
315
  controlPill("Q", "close", "danger", palette),