@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 +19 -1
- package/README.md +5 -5
- package/package.json +2 -2
- package/skills/pi-listens/SKILL.md +5 -4
- package/src/audio.ts +115 -15
- package/src/commands.ts +30 -12
- package/src/config.ts +1 -1
- package/src/index.ts +1 -1
- package/src/sarvam.ts +73 -16
- package/src/text.ts +17 -2
- package/src/tools.ts +29 -17
- package/src/voice-ui.ts +2 -2
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.
|
|
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
|
-
-
|
|
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
|
|
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":
|
|
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.
|
|
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.
|
|
24
|
-
5.
|
|
25
|
-
6.
|
|
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 } :
|
|
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 =
|
|
308
|
-
for (const child of [...activeChildren])
|
|
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
|
|
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
|
|
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
|
-
|
|
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
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
|
|
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
|
-
|
|
156
|
-
|
|
157
|
-
|
|
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
|
-
|
|
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
|
|
176
|
-
await
|
|
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
|
|
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 -
|
|
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
|
|
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
|
|
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: "
|
|
58
|
-
const
|
|
59
|
-
|
|
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: {
|
|
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: { ...
|
|
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
|
-
|
|
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
|
|
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
|
|
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("
|
|
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),
|