dexterm-cli 0.1.1 → 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.
Files changed (60) hide show
  1. package/dist/packages/agents/src/bus.js +20 -5
  2. package/dist/packages/agents/src/executor.js +25 -0
  3. package/dist/packages/agents/src/gate.js +3 -0
  4. package/dist/packages/agents/src/identity.js +41 -2
  5. package/dist/packages/agents/src/tiers.js +5 -0
  6. package/dist/packages/agents/src/tools.js +3 -0
  7. package/dist/packages/cli/assets/claude/plugin/skills/larp-check/SKILL.md +11 -3
  8. package/dist/packages/cli/assets/claude/plugin/skills/rug-check/SKILL.md +13 -3
  9. package/dist/packages/cli/assets/claude/plugin/skills/token-dossier/SKILL.md +3 -3
  10. package/dist/packages/cli/src/agent-cmd.js +36 -3
  11. package/dist/packages/cli/src/alerts/adapters/desktop.js +40 -0
  12. package/dist/packages/cli/src/alerts/adapters/discord.js +36 -0
  13. package/dist/packages/cli/src/alerts/adapters/telegram.js +43 -0
  14. package/dist/packages/cli/src/alerts/adapters/toast.js +16 -0
  15. package/dist/packages/cli/src/alerts/creds.js +83 -0
  16. package/dist/packages/cli/src/alerts/format.js +41 -0
  17. package/dist/packages/cli/src/alerts/index.js +10 -0
  18. package/dist/packages/cli/src/alerts/notifier.js +53 -0
  19. package/dist/packages/cli/src/alerts/route.js +19 -0
  20. package/dist/packages/cli/src/alerts/types.js +1 -0
  21. package/dist/packages/cli/src/alerts-cmd.js +212 -0
  22. package/dist/packages/cli/src/backtest-cmd.js +62 -3
  23. package/dist/packages/cli/src/coin-target.js +2 -0
  24. package/dist/packages/cli/src/doctor.js +20 -6
  25. package/dist/packages/cli/src/export.js +235 -0
  26. package/dist/packages/cli/src/live/index.js +2 -0
  27. package/dist/packages/cli/src/main.js +243 -41
  28. package/dist/packages/cli/src/mcp-cmd.js +326 -0
  29. package/dist/packages/cli/src/rules.js +21 -2
  30. package/dist/packages/cli/src/skill-eval-build.js +109 -0
  31. package/dist/packages/cli/src/update.js +294 -0
  32. package/dist/packages/cli/src/version.js +3 -0
  33. package/dist/packages/cli/src/voice-cmd.js +119 -0
  34. package/dist/packages/cli/src/watch-cmd.js +190 -0
  35. package/dist/packages/custom/src/rules.js +1 -1
  36. package/dist/packages/custom/src/schema.js +24 -1
  37. package/dist/packages/data/src/coin-target.js +82 -0
  38. package/dist/packages/data/src/index.js +3 -0
  39. package/dist/packages/data/src/intel/helius-history.js +12 -1
  40. package/dist/packages/data/src/larp/link-baseline.js +196 -0
  41. package/dist/packages/data/src/sources/dexscreener.js +14 -0
  42. package/dist/packages/data/src/sources/pumpportal.js +6 -1
  43. package/dist/packages/mcp/src/server.js +89 -2
  44. package/dist/packages/packs/src/index.js +1 -0
  45. package/dist/packages/packs/src/skill-backtest.js +300 -0
  46. package/dist/packages/tui/src/index.js +3 -0
  47. package/dist/packages/tui/src/keymap.js +2 -0
  48. package/dist/packages/tui/src/raster-font.js +195 -0
  49. package/dist/packages/tui/src/raster.js +213 -0
  50. package/dist/packages/voice/src/controller.js +15 -3
  51. package/dist/packages/voice/src/doctor.js +75 -7
  52. package/dist/packages/voice/src/download.js +184 -0
  53. package/dist/packages/voice/src/engines.js +48 -8
  54. package/dist/packages/voice/src/index.js +7 -3
  55. package/dist/packages/voice/src/stt/whisper.js +14 -4
  56. package/dist/packages/voice/src/tts/espeak.js +131 -0
  57. package/dist/packages/voice/src/tts/piper.js +206 -0
  58. package/dist/packages/voice/src/tts/say.js +71 -35
  59. package/dist/packages/voice/src/tts/voices.js +206 -0
  60. package/package.json +5 -9
@@ -15,15 +15,25 @@ export const WHISPER_BIN = process.env.DEXTERM_WHISPER_BIN || 'whisper-cli';
15
15
  export const WHISPER_MODEL = process.env.DEXTERM_WHISPER_MODEL || join(homedir(), '.dexterm', 'models', 'ggml-base.en.bin');
16
16
  /** Where the user is told to put the model (doctor fix line). */
17
17
  export const WHISPER_MODEL_DIR = join(homedir(), '.dexterm', 'models');
18
- /** Pick a mic-capture tool: sox (`rec`) first, then ffmpeg's avfoundation input. */
19
- function pickRecorder() {
18
+ /**
19
+ * Pick a mic-capture tool, best-first. sox (`rec`) is cross-platform; `arecord` (alsa-utils) is the
20
+ * native Linux capture tool (task-1408) and finalises its WAV header on SIGINT; ffmpeg is last and its
21
+ * input format is platform-specific (avfoundation on macOS, alsa on Linux). All write a 16 kHz mono
22
+ * 16-bit WAV, which is what whisper.cpp wants. Returns null when nothing is installed.
23
+ */
24
+ export function pickRecorder() {
20
25
  if (which('rec'))
21
26
  return { cmd: 'rec', args: (wav) => ['-q', '-c', '1', '-r', '16000', '-b', '16', wav], stopSignal: 'SIGINT' };
22
27
  if (which('sox'))
23
28
  return { cmd: 'sox', args: (wav) => ['-q', '-d', '-c', '1', '-r', '16000', '-b', '16', wav], stopSignal: 'SIGINT' };
29
+ if (process.platform !== 'darwin' && which('arecord')) {
30
+ return { cmd: 'arecord', args: (wav) => ['-q', '-f', 'S16_LE', '-c', '1', '-r', '16000', wav], stopSignal: 'SIGINT' };
31
+ }
24
32
  if (which('ffmpeg')) {
25
- const dev = process.env.DEXTERM_FFMPEG_MIC || ':default';
26
- return { cmd: 'ffmpeg', args: (wav) => ['-hide_banner', '-loglevel', 'error', '-f', 'avfoundation', '-i', dev, '-ac', '1', '-ar', '16000', '-y', wav], stopSignal: 'SIGINT' };
33
+ const linux = process.platform === 'linux';
34
+ const fmt = linux ? 'alsa' : 'avfoundation';
35
+ const dev = process.env.DEXTERM_FFMPEG_MIC || (linux ? 'default' : ':default');
36
+ return { cmd: 'ffmpeg', args: (wav) => ['-hide_banner', '-loglevel', 'error', '-f', fmt, '-i', dev, '-ac', '1', '-ar', '16000', '-y', wav], stopSignal: 'SIGINT' };
27
37
  }
28
38
  return null;
29
39
  }
@@ -0,0 +1,131 @@
1
+ // Portable (Linux/Windows) TTS via espeak-ng (task-1393 Part 2). espeak-ng is free, local, ships in or
2
+ // installs trivially on every Linux distro, and runs on Windows too. It is the "works everywhere" baseline
3
+ // off macOS; a Piper neural voice is a higher-quality upgrade that needs a downloaded model (see doctor).
4
+ //
5
+ // It carries the SAME contract as the macOS engine: text is SANITISED and delivered on STDIN (espeak-ng
6
+ // treats `[[…]]` as phoneme input, so stripping it is a safety requirement, not a nicety), a new speak()
7
+ // cleanly interrupts the current one (single queue), and the default voice is an explicit deep British
8
+ // male (`en-gb+m3`) — never a surprise.
9
+ //
10
+ // VERIFICATION NOTE: the logic is unit-tested against a fake binary; it was NOT run against real
11
+ // espeak-ng audio because the Linux (WSL) box was unreachable during this task. See the handoff.
12
+ import { spawn } from 'node:child_process';
13
+ import { existsSync } from 'node:fs';
14
+ import { join } from 'node:path';
15
+ import { sanitizeSpeech } from "./voices.js";
16
+ /** espeak-ng first (actively maintained), then the older espeak. Overridable for tests. */
17
+ export const ESPEAK_BIN = process.env.DEXTERM_ESPEAK_BIN || '';
18
+ /** The default portable voice: a British male. `+m3` is a deeper male variant. */
19
+ export const DEFAULT_ESPEAK_VOICE = 'en-gb+m3';
20
+ /** espeak words-per-minute default (close to the macOS 190 feel). */
21
+ export const DEFAULT_ESPEAK_RATE = 175;
22
+ function which(bin) {
23
+ if (!bin)
24
+ return null;
25
+ if (bin.includes('/') || bin.includes('\\'))
26
+ return existsSync(bin) ? bin : null;
27
+ for (const p of (process.env.PATH ?? '').split(process.platform === 'win32' ? ';' : ':')) {
28
+ if (!p)
29
+ continue;
30
+ if (existsSync(join(p, bin)))
31
+ return join(p, bin);
32
+ if (process.platform === 'win32' && existsSync(join(p, `${bin}.exe`)))
33
+ return join(p, `${bin}.exe`);
34
+ }
35
+ return null;
36
+ }
37
+ /** Resolve the espeak binary path: an explicit override, else espeak-ng, else espeak. */
38
+ export function resolveEspeakBin() {
39
+ return which(ESPEAK_BIN) || which('espeak-ng') || which('espeak');
40
+ }
41
+ /**
42
+ * espeak voice names are language codes, optionally with a variant: `en`, `en-gb`, `en-us+m3`, `de+f2`.
43
+ * A macOS voice name like "Eddy (English (UK))" or "Daniel" is NOT one and must never be passed to
44
+ * espeak verbatim (task-1408 residual) — it would be an invalid `-v` argument. This matches only the
45
+ * espeak form, so a Mac voice name is rejected and the engine default is used instead.
46
+ */
47
+ const ESPEAK_VOICE_RE = /^[a-z]{2,3}(-[a-z0-9]+)*(\+[a-z0-9]+)?$/i;
48
+ /** A valid espeak voice token, or null when `configured` is empty or is a macOS voice name. */
49
+ export function espeakVoiceFor(configured) {
50
+ const v = configured?.trim();
51
+ if (!v)
52
+ return null;
53
+ return ESPEAK_VOICE_RE.test(v) ? v : null;
54
+ }
55
+ export class EspeakTts {
56
+ name = 'espeak';
57
+ label = 'espeak-ng (local, no key)';
58
+ defaultVoice;
59
+ current = null;
60
+ constructor(defaultVoice = DEFAULT_ESPEAK_VOICE) { this.defaultVoice = defaultVoice; }
61
+ get voiceName() { return this.defaultVoice; }
62
+ async available() { return resolveEspeakBin() !== null; }
63
+ /** espeak-ng has no per-machine "installed voices" list the user cycles; the default covers it. */
64
+ async listVoices() { return [this.defaultVoice]; }
65
+ speak(text, opts = {}) {
66
+ this.stopCurrent(); // single queue: never two speakers at once
67
+ const bin = resolveEspeakBin();
68
+ const voice = opts.voice ?? this.defaultVoice;
69
+ const rate = opts.rate ?? DEFAULT_ESPEAK_RATE;
70
+ const safe = sanitizeSpeech(text);
71
+ const args = ['-v', voice, '-s', String(rate)];
72
+ if (typeof opts.pitch === 'number' && Number.isFinite(opts.pitch)) {
73
+ args.push('-p', String(Math.max(0, Math.min(99, Math.round(opts.pitch))))); // 0-99, lower = deeper
74
+ }
75
+ args.push('--stdin'); // read the utterance from stdin — never an argv word
76
+ let child = null;
77
+ let stopped = false;
78
+ let resolve;
79
+ const done = new Promise((r) => { resolve = r; });
80
+ const finish = () => { if (!stopped) {
81
+ stopped = true;
82
+ if (this.current === handle)
83
+ this.current = null;
84
+ resolve();
85
+ } };
86
+ try {
87
+ if (!bin)
88
+ throw new Error('espeak not found');
89
+ child = spawn(bin, args, { stdio: ['pipe', 'ignore', 'ignore'] });
90
+ child.stdin?.on('error', () => { });
91
+ try {
92
+ child.stdin?.end(safe);
93
+ }
94
+ catch { /* child gone */ }
95
+ child.on('spawn', () => { if (!stopped)
96
+ opts.onFirstAudio?.(); });
97
+ child.on('error', finish);
98
+ child.on('close', finish);
99
+ }
100
+ catch {
101
+ queueMicrotask(finish); // no espeak → degrade to text
102
+ }
103
+ const handle = {
104
+ done,
105
+ stop: () => {
106
+ if (stopped)
107
+ return;
108
+ stopped = true;
109
+ if (this.current === handle)
110
+ this.current = null;
111
+ try {
112
+ child?.kill('SIGTERM');
113
+ }
114
+ catch { /* already gone */ }
115
+ resolve();
116
+ },
117
+ };
118
+ this.current = handle;
119
+ return handle;
120
+ }
121
+ stopCurrent() {
122
+ const h = this.current;
123
+ if (h) {
124
+ this.current = null;
125
+ try {
126
+ h.stop();
127
+ }
128
+ catch { /* ignore */ }
129
+ }
130
+ }
131
+ }
@@ -0,0 +1,206 @@
1
+ // Piper neural TTS engine (task-1408). Piper is a free, local, no-key neural text-to-speech engine
2
+ // (rhasspy/piper, MIT) that sounds markedly better than espeak-ng. Off macOS it is the PREFERRED
3
+ // speech-out engine; espeak-ng stays the last-resort baseline (see engines.ts). Verified in Docker
4
+ // (task-1397/task-1408): the prebuilt Linux binary + the en_GB-alan-medium voice write a valid,
5
+ // non-silent 22.05 kHz WAV that whisper.cpp transcribes almost word-for-word.
6
+ //
7
+ // It carries the SAME contract as the macOS `say` and espeak engines:
8
+ // - text is SANITISED (sanitizeSpeech strips `[[…]]`, control chars, a leading `-`, caps length)
9
+ // - text is delivered on STDIN, never as an argv word
10
+ // - a new speak() cleanly INTERRUPTS the current one — one queue, never two voices at once
11
+ // - the voice is fixed by the downloaded MODEL, never by a macOS voice name (so config.voice.name,
12
+ // which on a Mac is e.g. "Eddy (English (UK))", is NEVER passed verbatim to Piper)
13
+ //
14
+ // Piper has no audio output of its own: it synthesises to a WAV, which we then play through the
15
+ // platform player (aplay / paplay / ffplay — detected and reported by the doctor). Containers have no
16
+ // audio device, so real playback is unverified; synthesis-to-file IS verified (synthToFile + whisper).
17
+ import { spawn } from 'node:child_process';
18
+ import { existsSync, mkdtempSync, rmSync } from 'node:fs';
19
+ import { homedir, tmpdir } from 'node:os';
20
+ import { join } from 'node:path';
21
+ import { sanitizeSpeech } from "./voices.js";
22
+ /** The Piper voice dexterm defaults to off macOS: a British male, medium quality (local, no key). */
23
+ export const DEFAULT_PIPER_VOICE = 'en_GB-alan-medium';
24
+ /** Where the downloaded Piper voice model lives (see `dexterm voice download piper-voice`). */
25
+ export const PIPER_MODEL = process.env.DEXTERM_PIPER_MODEL
26
+ || join(homedir(), '.dexterm', 'models', `${DEFAULT_PIPER_VOICE}.onnx`);
27
+ /** Audio players we can hand a WAV to, best-first. Reported by the doctor so the user knows which plays. */
28
+ export const PIPER_PLAYERS = ['aplay', 'paplay', 'ffplay'];
29
+ function which(bin) {
30
+ if (!bin)
31
+ return null;
32
+ if (bin.includes('/') || bin.includes('\\'))
33
+ return existsSync(bin) ? bin : null;
34
+ const sep = process.platform === 'win32' ? ';' : ':';
35
+ for (const p of (process.env.PATH ?? '').split(sep)) {
36
+ if (!p)
37
+ continue;
38
+ if (existsSync(join(p, bin)))
39
+ return join(p, bin);
40
+ if (process.platform === 'win32' && existsSync(join(p, `${bin}.exe`)))
41
+ return join(p, `${bin}.exe`);
42
+ }
43
+ return null;
44
+ }
45
+ /**
46
+ * Resolve the Piper binary. We look at an explicit override, then the dexterm-local install
47
+ * (`~/.dexterm/piper/piper`, where the prebuilt release is extracted), then PATH. NOTE: Debian's
48
+ * `apt install piper` is an UNRELATED gaming-mouse tool, so the doctor steers users to the rhasspy
49
+ * prebuilt release rather than apt; a wrong `piper` on PATH simply fails to synth and voice degrades
50
+ * to text (it never crashes).
51
+ */
52
+ export function resolvePiperBin() {
53
+ return which(process.env.DEXTERM_PIPER_BIN || '')
54
+ || which(join(homedir(), '.dexterm', 'piper', 'piper'))
55
+ || which('piper');
56
+ }
57
+ /** The player that will play Piper's WAV, or null when none is installed. */
58
+ export function pickPlayer() {
59
+ const override = process.env.DEXTERM_AUDIO_PLAYER;
60
+ if (override)
61
+ return which(override) ? override : null;
62
+ for (const p of PIPER_PLAYERS)
63
+ if (which(p))
64
+ return p;
65
+ return null;
66
+ }
67
+ /** Argv to play a finished WAV through the given player (quiet, exits on end, no window). */
68
+ function playerArgs(player, wav) {
69
+ switch (player) {
70
+ case 'ffplay': return ['-nodisp', '-autoexit', '-loglevel', 'quiet', wav];
71
+ case 'paplay': return [wav];
72
+ case 'aplay':
73
+ default: return ['-q', wav];
74
+ }
75
+ }
76
+ export class PiperTts {
77
+ name = 'piper';
78
+ label = 'Piper (neural, local, no key)';
79
+ model;
80
+ /** The utterance currently playing. A new speak() stops it first, so there is only ever one. */
81
+ current = null;
82
+ constructor(model = PIPER_MODEL) { this.model = model; }
83
+ /** The voice id this engine speaks with (the model basename), for the status line. */
84
+ get voiceName() { return DEFAULT_PIPER_VOICE; }
85
+ /** True when Piper can speak ALOUD here: the binary, the voice model and a player are all present. */
86
+ async available() {
87
+ return resolvePiperBin() !== null && existsSync(this.model) && pickPlayer() !== null;
88
+ }
89
+ /** True when Piper can SYNTHESISE here (binary + model) even with no audio device (used for the proof). */
90
+ canSynth() {
91
+ return resolvePiperBin() !== null && existsSync(this.model);
92
+ }
93
+ /** Piper's voice is the model; there is no per-machine installed-voice list to cycle. */
94
+ async listVoices() { return [DEFAULT_PIPER_VOICE]; }
95
+ /**
96
+ * Synthesise `text` to a WAV at `outPath`. Resolves on success, rejects on a missing binary/model or a
97
+ * non-zero exit. This is the engine's testable, audio-device-free core — the Docker proof and the unit
98
+ * tests drive it directly. The text is sanitised and delivered on stdin, exactly as speak() does.
99
+ */
100
+ synthToFile(text, outPath, opts = {}) {
101
+ const bin = resolvePiperBin();
102
+ const safe = sanitizeSpeech(text);
103
+ return new Promise((resolve, reject) => {
104
+ if (!bin) {
105
+ reject(new Error('piper binary not found'));
106
+ return;
107
+ }
108
+ const args = ['--model', this.model, '--output_file', outPath];
109
+ if (typeof opts.lengthScale === 'number' && Number.isFinite(opts.lengthScale)) {
110
+ args.push('--length_scale', String(opts.lengthScale)); // >1 = slower speech
111
+ }
112
+ let child;
113
+ try {
114
+ child = spawn(bin, args, { stdio: ['pipe', 'ignore', 'pipe'] });
115
+ }
116
+ catch (e) {
117
+ reject(e);
118
+ return;
119
+ }
120
+ let err = '';
121
+ child.stderr?.on('data', (d) => { err += d; });
122
+ child.stdin?.on('error', () => { });
123
+ // Piper reads one line of text from stdin; the sanitiser already collapsed newlines to one line.
124
+ try {
125
+ child.stdin?.end(safe + '\n');
126
+ }
127
+ catch { /* child gone */ }
128
+ child.on('error', reject);
129
+ child.on('close', (code) => code === 0
130
+ ? resolve()
131
+ : reject(new Error(`piper exit ${code}: ${err.slice(-200)}`)));
132
+ });
133
+ }
134
+ speak(text, opts = {}) {
135
+ this.stopCurrent(); // single queue: never two speakers at once
136
+ const player = pickPlayer();
137
+ // rate: map the words-per-minute feel to Piper's length_scale (lower rate → slower → larger scale).
138
+ const lengthScale = typeof opts.rate === 'number' && opts.rate > 0 ? Math.max(0.5, Math.min(2, 190 / opts.rate)) : undefined;
139
+ let child = null;
140
+ let stopped = false;
141
+ let resolve;
142
+ const done = new Promise((r) => { resolve = r; });
143
+ const dir = mkdtempSync(join(tmpdir(), 'dexterm-piper-'));
144
+ const wav = join(dir, 'speech.wav');
145
+ const cleanup = () => { try {
146
+ rmSync(dir, { recursive: true, force: true });
147
+ }
148
+ catch { /* ignore */ } };
149
+ const finish = () => {
150
+ if (stopped)
151
+ return;
152
+ stopped = true;
153
+ if (this.current === handle)
154
+ this.current = null;
155
+ cleanup();
156
+ resolve();
157
+ };
158
+ const handle = {
159
+ done,
160
+ stop: () => {
161
+ if (stopped)
162
+ return;
163
+ stopped = true;
164
+ if (this.current === handle)
165
+ this.current = null;
166
+ try {
167
+ child?.kill('SIGTERM');
168
+ }
169
+ catch { /* already gone */ }
170
+ cleanup();
171
+ resolve();
172
+ },
173
+ };
174
+ this.current = handle;
175
+ if (!player) {
176
+ queueMicrotask(finish);
177
+ return handle;
178
+ } // no player → can't speak aloud; degrade to text
179
+ // Synthesise, then play. Piper's RTF is ~0.16, so synth is fast; first audio is the player spawn.
180
+ this.synthToFile(text, wav, lengthScale !== undefined ? { lengthScale } : {}).then(() => {
181
+ if (stopped)
182
+ return;
183
+ try {
184
+ child = spawn(player, playerArgs(player, wav), { stdio: 'ignore' });
185
+ child.on('spawn', () => { if (!stopped)
186
+ opts.onFirstAudio?.(); });
187
+ child.on('error', finish);
188
+ child.on('close', finish);
189
+ }
190
+ catch {
191
+ finish();
192
+ }
193
+ }).catch(finish); // synth failed (bad binary/model) → degrade to text
194
+ return handle;
195
+ }
196
+ stopCurrent() {
197
+ const h = this.current;
198
+ if (h) {
199
+ this.current = null;
200
+ try {
201
+ h.stop();
202
+ }
203
+ catch { /* ignore */ }
204
+ }
205
+ }
206
+ }
@@ -1,15 +1,29 @@
1
- // macOS `say` TTS engine (task-1282). `say` ships with macOS, needs no key and no network, so it is
2
- // the free/portable default (SPEC: macOS say first). It streams to the audio device as it synthesises,
3
- // so time-to-first-audio is close to process spawn. speak() is interruptible via stop() (SIGTERM to
4
- // the child), which is how a key or the user starting to talk cuts a reply short.
1
+ // macOS `say` TTS engine (task-1282, hardened task-1393). `say` ships with macOS, needs no key and no
2
+ // network, so it is the free/portable default (SPEC: macOS say first). It streams to the audio device,
3
+ // so time-to-first-audio is close to process spawn. speak() is interruptible via stop() (SIGTERM to the
4
+ // child), which is how a key or the user starting to talk cuts a reply short.
5
+ //
6
+ // task-1393 hardening:
7
+ // - text is SANITISED (no `[[…]]` embedded commands, no leading-`-` flag, addresses shortened, capped)
8
+ // - text is delivered on STDIN (`say -f -`), never as the last argv word
9
+ // - a new speak() cleanly INTERRUPTS the current one, so two `say` processes never overlap (one queue)
10
+ // - the voice is always EXPLICIT (resolved to a deep British male upstream); it never falls back to the
11
+ // machine's system voice
5
12
  import { spawn } from 'node:child_process';
6
13
  import { execFile } from 'node:child_process';
14
+ import { parseSayVoices, sanitizeSpeech } from "./voices.js";
7
15
  export const SAY_BIN = process.env.DEXTERM_SAY_BIN || 'say';
16
+ /** Consistent speaking rate (words-per-minute-ish) for every reply path. */
17
+ export const DEFAULT_RATE = 190;
8
18
  export class SayTts {
9
19
  name = 'say';
10
20
  label = 'macOS say (built-in, no key)';
11
21
  defaultVoice;
22
+ /** The utterance currently playing. A new speak() stops it first, so there is only ever one. */
23
+ current = null;
12
24
  constructor(defaultVoice) { this.defaultVoice = defaultVoice; }
25
+ /** The resolved default voice this engine speaks with — used to seed the controller / status line. */
26
+ get voiceName() { return this.defaultVoice; }
13
27
  async available() {
14
28
  if (process.platform !== 'darwin')
15
29
  return false;
@@ -23,22 +37,12 @@ export class SayTts {
23
37
  }
24
38
  });
25
39
  }
26
- /** Parse `say -v '?'`: each line is "Name en_US # sample text". */
27
- async listVoices() {
40
+ /** Installed voices with their locale. Robust against the single-space padding on long voice names. */
41
+ async listVoicesDetailed() {
28
42
  return new Promise((resolve) => {
29
43
  try {
30
44
  execFile(SAY_BIN, ['-v', '?'], { timeout: 5000, maxBuffer: 1 << 20 }, (err, stdout) => {
31
- if (err || !stdout) {
32
- resolve([]);
33
- return;
34
- }
35
- const names = [];
36
- for (const line of stdout.split('\n')) {
37
- const m = line.match(/^(.+?)\s{2,}[a-z]{2}(?:[_-][A-Za-z]+)?\s/);
38
- if (m)
39
- names.push(m[1].trim());
40
- }
41
- resolve(names);
45
+ resolve(err || !stdout ? [] : parseSayVoices(stdout));
42
46
  });
43
47
  }
44
48
  catch {
@@ -46,45 +50,64 @@ export class SayTts {
46
50
  }
47
51
  });
48
52
  }
53
+ /** Selectable voice names (the TtsEngine contract). */
54
+ async listVoices() {
55
+ return (await this.listVoicesDetailed()).map((v) => v.name);
56
+ }
49
57
  speak(text, opts = {}) {
58
+ // One queue: cut off whatever is playing so two `say` processes never overlap (clean interrupt).
59
+ this.stopCurrent();
50
60
  const voice = opts.voice ?? this.defaultVoice;
51
- const rate = opts.rate ?? 190;
61
+ const rate = opts.rate ?? DEFAULT_RATE;
62
+ // Sanitise the UNTRUSTED text first, then prepend our own TRUSTED pitch command. Order matters: the
63
+ // sanitiser strips any `[[…]]` the text tried to smuggle, so only our validated `[[pbas N]]` survives.
64
+ let safe = sanitizeSpeech(text);
65
+ if (typeof opts.pitch === 'number' && Number.isFinite(opts.pitch)) {
66
+ const pbas = Math.max(0, Math.min(100, Math.round(opts.pitch)));
67
+ safe = `[[pbas ${pbas}]] ${safe}`;
68
+ }
52
69
  const args = [];
53
70
  if (voice)
54
71
  args.push('-v', voice);
55
- args.push('-r', String(rate), text);
72
+ args.push('-r', String(rate));
73
+ args.push('-f', '-'); // read the utterance from stdin — the text is NEVER an argv word
56
74
  let child = null;
57
75
  let stopped = false;
58
76
  let resolve;
59
77
  const done = new Promise((r) => { resolve = r; });
78
+ const finish = () => { if (!stopped) {
79
+ stopped = true;
80
+ if (this.current === handle)
81
+ this.current = null;
82
+ resolve();
83
+ } };
60
84
  try {
61
- child = spawn(SAY_BIN, args, { stdio: 'ignore' });
62
- // `say` has no first-sample hook; the spawn event is the closest honest lower bound and is what
63
- // the latency table reports as "first spoken word" for this engine.
85
+ child = spawn(SAY_BIN, args, { stdio: ['pipe', 'ignore', 'ignore'] });
86
+ // Nothing is spoken until stdin is closed; write the sanitised text and end it.
87
+ child.stdin?.on('error', () => { });
88
+ try {
89
+ child.stdin?.end(safe);
90
+ }
91
+ catch { /* child gone */ }
92
+ // `say` has no first-sample hook; the spawn event is the closest honest lower bound and is what the
93
+ // latency table reports as "first spoken word" for this engine.
64
94
  child.on('spawn', () => { if (!stopped)
65
95
  opts.onFirstAudio?.(); });
66
- child.on('error', () => { if (!stopped) {
67
- stopped = true;
68
- resolve();
69
- } });
70
- child.on('close', () => { if (!stopped) {
71
- stopped = true;
72
- resolve();
73
- } });
96
+ child.on('error', finish);
97
+ child.on('close', finish);
74
98
  }
75
99
  catch {
76
100
  // No `say` on this machine: resolve immediately so the caller degrades to text.
77
- queueMicrotask(() => { if (!stopped) {
78
- stopped = true;
79
- resolve();
80
- } });
101
+ queueMicrotask(finish);
81
102
  }
82
- return {
103
+ const handle = {
83
104
  done,
84
105
  stop: () => {
85
106
  if (stopped)
86
107
  return;
87
108
  stopped = true;
109
+ if (this.current === handle)
110
+ this.current = null;
88
111
  try {
89
112
  child?.kill('SIGTERM');
90
113
  }
@@ -92,5 +115,18 @@ export class SayTts {
92
115
  resolve();
93
116
  },
94
117
  };
118
+ this.current = handle;
119
+ return handle;
120
+ }
121
+ /** Stop the currently-playing utterance, if any. */
122
+ stopCurrent() {
123
+ const h = this.current;
124
+ if (h) {
125
+ this.current = null;
126
+ try {
127
+ h.stop();
128
+ }
129
+ catch { /* ignore */ }
130
+ }
95
131
  }
96
132
  }