dexterm-cli 0.1.1 → 0.1.3
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/dist/packages/agents/src/bus.js +57 -6
- package/dist/packages/agents/src/executor.js +25 -0
- package/dist/packages/agents/src/follow.js +105 -0
- package/dist/packages/agents/src/gate.js +3 -0
- package/dist/packages/agents/src/identity.js +41 -2
- package/dist/packages/agents/src/index.js +5 -0
- package/dist/packages/agents/src/spectator-server.js +153 -0
- package/dist/packages/agents/src/spectator.js +212 -0
- package/dist/packages/agents/src/tiers.js +5 -0
- package/dist/packages/agents/src/tools.js +3 -0
- package/dist/packages/cli/assets/claude/analyst-system.md +6 -1
- package/dist/packages/cli/assets/claude/plugin/skills/larp-check/SKILL.md +11 -3
- package/dist/packages/cli/assets/claude/plugin/skills/rug-check/SKILL.md +13 -3
- package/dist/packages/cli/assets/claude/plugin/skills/token-dossier/SKILL.md +3 -3
- package/dist/packages/cli/src/agent-cmd.js +107 -10
- package/dist/packages/cli/src/agent-spectator.js +37 -0
- package/dist/packages/cli/src/alerts/adapters/desktop.js +40 -0
- package/dist/packages/cli/src/alerts/adapters/discord.js +36 -0
- package/dist/packages/cli/src/alerts/adapters/telegram.js +43 -0
- package/dist/packages/cli/src/alerts/adapters/toast.js +16 -0
- package/dist/packages/cli/src/alerts/creds.js +83 -0
- package/dist/packages/cli/src/alerts/format.js +66 -0
- package/dist/packages/cli/src/alerts/index.js +10 -0
- package/dist/packages/cli/src/alerts/notifier.js +53 -0
- package/dist/packages/cli/src/alerts/route.js +19 -0
- package/dist/packages/cli/src/alerts/types.js +1 -0
- package/dist/packages/cli/src/alerts-cmd.js +212 -0
- package/dist/packages/cli/src/app-help-gen.js +365 -0
- package/dist/packages/cli/src/backtest-cmd.js +62 -3
- package/dist/packages/cli/src/coin-target.js +2 -0
- package/dist/packages/cli/src/doctor.js +21 -6
- package/dist/packages/cli/src/export.js +235 -0
- package/dist/packages/cli/src/follow-cmd.js +107 -1
- package/dist/packages/cli/src/help-cmd.js +48 -0
- package/dist/packages/cli/src/live/index.js +66 -3
- package/dist/packages/cli/src/live/screens/token-live.js +24 -0
- package/dist/packages/cli/src/live/signals.js +109 -0
- package/dist/packages/cli/src/live/trade.js +24 -1
- package/dist/packages/cli/src/main.js +596 -49
- package/dist/packages/cli/src/mcp-cmd.js +321 -0
- package/dist/packages/cli/src/pnl-app.js +221 -0
- package/dist/packages/cli/src/pnl-card.js +615 -0
- package/dist/packages/cli/src/record-cmd.js +455 -0
- package/dist/packages/cli/src/rules.js +69 -2
- package/dist/packages/cli/src/screens/follows.js +91 -2
- package/dist/packages/cli/src/screens/overlay.js +3 -0
- package/dist/packages/cli/src/screens/positions.js +1 -1
- package/dist/packages/cli/src/screens/rules.js +1 -1
- package/dist/packages/cli/src/skill-eval-build.js +109 -0
- package/dist/packages/cli/src/spectate-cmd.js +104 -0
- package/dist/packages/cli/src/state.js +1 -1
- package/dist/packages/cli/src/update.js +294 -0
- package/dist/packages/cli/src/version.js +3 -0
- package/dist/packages/cli/src/voice-cmd.js +194 -0
- package/dist/packages/cli/src/voice-hud.js +1 -1
- package/dist/packages/cli/src/watch-cmd.js +190 -0
- package/dist/packages/custom/src/rules.js +3 -2
- package/dist/packages/custom/src/schema.js +24 -1
- package/dist/packages/data/src/analyst-actions.js +130 -3
- package/dist/packages/data/src/coin-target.js +82 -0
- package/dist/packages/data/src/fast-lane.js +16 -2
- package/dist/packages/data/src/index.js +4 -0
- package/dist/packages/data/src/intel/helius-history.js +12 -1
- package/dist/packages/data/src/intel/live-signals.js +185 -0
- package/dist/packages/data/src/larp/link-baseline.js +196 -0
- package/dist/packages/data/src/sources/dexscreener.js +14 -0
- package/dist/packages/data/src/sources/pumpportal.js +6 -1
- package/dist/packages/data/src/trade/copy/index.js +1 -1
- package/dist/packages/data/src/trade/copy/stats.js +36 -2
- package/dist/packages/data/src/trade/copy/tracker.js +3 -2
- package/dist/packages/mcp/assets/kb/app-help/app-help-command.md +35 -0
- package/dist/packages/mcp/assets/kb/app-help/ask-the-analyst.md +43 -0
- package/dist/packages/mcp/assets/kb/app-help/backtest.md +33 -0
- package/dist/packages/mcp/assets/kb/app-help/bubble-map.md +35 -0
- package/dist/packages/mcp/assets/kb/app-help/calm-mode.md +31 -0
- package/dist/packages/mcp/assets/kb/app-help/change-voice-speed.md +31 -0
- package/dist/packages/mcp/assets/kb/app-help/change-voice.md +30 -0
- package/dist/packages/mcp/assets/kb/app-help/chart-popup-selftest.md +32 -0
- package/dist/packages/mcp/assets/kb/app-help/coin-images.md +29 -0
- package/dist/packages/mcp/assets/kb/app-help/configure-rpc-key.md +28 -0
- package/dist/packages/mcp/assets/kb/app-help/connect-an-agent-registry.md +35 -0
- package/dist/packages/mcp/assets/kb/app-help/connect-an-agent.md +39 -0
- package/dist/packages/mcp/assets/kb/app-help/custom-keymap.md +32 -0
- package/dist/packages/mcp/assets/kb/app-help/edit-kb-or-skill.md +36 -0
- package/dist/packages/mcp/assets/kb/app-help/edit-rules.md +34 -0
- package/dist/packages/mcp/assets/kb/app-help/export-a-view.md +36 -0
- package/dist/packages/mcp/assets/kb/app-help/follow-a-wallet.md +33 -0
- package/dist/packages/mcp/assets/kb/app-help/getting-started.md +37 -0
- package/dist/packages/mcp/assets/kb/app-help/kol-lists.md +34 -0
- package/dist/packages/mcp/assets/kb/app-help/larp-check.md +36 -0
- package/dist/packages/mcp/assets/kb/app-help/link-a-wallet.md +41 -0
- package/dist/packages/mcp/assets/kb/app-help/login-with-x.md +33 -0
- package/dist/packages/mcp/assets/kb/app-help/narrative-board.md +32 -0
- package/dist/packages/mcp/assets/kb/app-help/open-a-coin.md +43 -0
- package/dist/packages/mcp/assets/kb/app-help/open-chart-popup.md +33 -0
- package/dist/packages/mcp/assets/kb/app-help/order-plans.md +33 -0
- package/dist/packages/mcp/assets/kb/app-help/paper-trade.md +36 -0
- package/dist/packages/mcp/assets/kb/app-help/price-or-marketcap.md +38 -0
- package/dist/packages/mcp/assets/kb/app-help/record-headless.md +34 -0
- package/dist/packages/mcp/assets/kb/app-help/recorder-history.md +39 -0
- package/dist/packages/mcp/assets/kb/app-help/rug-check.md +39 -0
- package/dist/packages/mcp/assets/kb/app-help/run-doctor.md +32 -0
- package/dist/packages/mcp/assets/kb/app-help/scan-mode.md +37 -0
- package/dist/packages/mcp/assets/kb/app-help/screen-analyst.md +181 -0
- package/dist/packages/mcp/assets/kb/app-help/screen-kol.md +190 -0
- package/dist/packages/mcp/assets/kb/app-help/screen-newpairs.md +181 -0
- package/dist/packages/mcp/assets/kb/app-help/screen-positions.md +180 -0
- package/dist/packages/mcp/assets/kb/app-help/screen-screener.md +199 -0
- package/dist/packages/mcp/assets/kb/app-help/screen-token.md +240 -0
- package/dist/packages/mcp/assets/kb/app-help/screen-trending.md +192 -0
- package/dist/packages/mcp/assets/kb/app-help/screen-watchlist.md +181 -0
- package/dist/packages/mcp/assets/kb/app-help/set-a-filter.md +36 -0
- package/dist/packages/mcp/assets/kb/app-help/setup-neural-voice.md +36 -0
- package/dist/packages/mcp/assets/kb/app-help/share-packs.md +34 -0
- package/dist/packages/mcp/assets/kb/app-help/sort-the-screener.md +34 -0
- package/dist/packages/mcp/assets/kb/app-help/spectate-an-agent.md +36 -0
- package/dist/packages/mcp/assets/kb/app-help/standing-mandates.md +33 -0
- package/dist/packages/mcp/assets/kb/app-help/switch-theme.md +42 -0
- package/dist/packages/mcp/assets/kb/app-help/turn-on-alerts.md +37 -0
- package/dist/packages/mcp/assets/kb/app-help/update-dexterm.md +30 -0
- package/dist/packages/mcp/assets/kb/app-help/use-presets.md +42 -0
- package/dist/packages/mcp/assets/kb/app-help/use-voice-mode.md +36 -0
- package/dist/packages/mcp/assets/kb/app-help/watch-a-coin.md +34 -0
- package/dist/packages/mcp/assets/kb/app-help/watch-headless.md +35 -0
- package/dist/packages/mcp/assets/kb/app-help/what-dexterm-shows-agents.md +31 -0
- package/dist/packages/mcp/assets/kb/app-help/workspaces.md +38 -0
- package/dist/packages/mcp/src/app-help.js +135 -0
- package/dist/packages/mcp/src/index.js +3 -0
- package/dist/packages/mcp/src/server.js +179 -5
- package/dist/packages/mcp/src/spectator-sink.js +72 -0
- package/dist/packages/packs/src/index.js +3 -0
- package/dist/packages/packs/src/skill-backtest.js +300 -0
- package/dist/packages/packs/src/skill-care.js +206 -0
- package/dist/packages/popup/src/pages/spectator.css +45 -0
- package/dist/packages/popup/src/pages/spectator.html +25 -0
- package/dist/packages/popup/src/pages/spectator.js +74 -0
- package/dist/packages/popup/src/selftest.js +19 -0
- package/dist/packages/popup/src/server.js +7 -1
- package/dist/packages/tui/src/index.js +3 -0
- package/dist/packages/tui/src/keymap.js +12 -0
- package/dist/packages/tui/src/raster-font.js +195 -0
- package/dist/packages/tui/src/raster.js +213 -0
- package/dist/packages/voice/src/controller.js +57 -4
- package/dist/packages/voice/src/doctor.js +96 -11
- package/dist/packages/voice/src/download.js +184 -0
- package/dist/packages/voice/src/engines.js +55 -8
- package/dist/packages/voice/src/index.js +9 -3
- package/dist/packages/voice/src/speakable.js +111 -0
- package/dist/packages/voice/src/stt/whisper.js +14 -4
- package/dist/packages/voice/src/tts/espeak.js +131 -0
- package/dist/packages/voice/src/tts/piper-setup.js +229 -0
- package/dist/packages/voice/src/tts/piper.js +449 -0
- package/dist/packages/voice/src/tts/say.js +72 -35
- package/dist/packages/voice/src/tts/voices.js +209 -0
- package/package.json +5 -9
|
@@ -0,0 +1,449 @@
|
|
|
1
|
+
// Piper neural TTS engine (task-1408, task-1416). Piper is a free, local, no-key neural text-to-speech
|
|
2
|
+
// engine (rhasspy/piper, MIT) that sounds markedly better than espeak-ng or the built-in macOS voices.
|
|
3
|
+
// It is the PREFERRED speech-out engine EVERYWHERE it is installed — on macOS (via the managed Python
|
|
4
|
+
// runtime, `dexterm voice setup`) as well as off macOS (via the prebuilt binary) — with `say`/espeak-ng
|
|
5
|
+
// kept as the fallback chain (see engines.ts). Verified in Docker on Linux (task-1397/task-1408) and via
|
|
6
|
+
// the managed venv on an Intel Mac (task-1416): the en_GB-alan-medium voice writes a valid, non-silent
|
|
7
|
+
// 22.05 kHz WAV that whisper.cpp transcribes almost word-for-word.
|
|
8
|
+
//
|
|
9
|
+
// Two runtimes, one engine:
|
|
10
|
+
// - macOS (managed): a private virtualenv at `~/.dexterm/piper/venv` with `pip install piper-tts`. The
|
|
11
|
+
// prebuilt macOS tarball from rhasspy is broken (it needs a libespeak-ng dylib it does not bundle),
|
|
12
|
+
// so we run `<venv>/bin/python -m piper` instead. Built by `dexterm voice setup`.
|
|
13
|
+
// - Linux/Windows: the prebuilt `piper` binary extracted into `~/.dexterm/piper/` (or on PATH).
|
|
14
|
+
//
|
|
15
|
+
// It carries the SAME contract as the macOS `say` and espeak engines:
|
|
16
|
+
// - text is SANITISED (sanitizeSpeech strips `[[…]]`, control chars, a leading `-`, caps length)
|
|
17
|
+
// - text is delivered on STDIN, never as an argv word
|
|
18
|
+
// - a new speak() cleanly INTERRUPTS the current one — one queue, never two voices at once
|
|
19
|
+
// - the voice is fixed by the downloaded MODEL, never by a macOS voice name (so config.voice.name,
|
|
20
|
+
// which on a Mac is e.g. "Eddy (English (UK))", is NEVER passed verbatim to Piper)
|
|
21
|
+
//
|
|
22
|
+
// Latency (task-1416): loading the 63 MB model into onnxruntime dominates first-audio (~1.4–4 s cold,
|
|
23
|
+
// load-dependent), so we keep a pre-warmed STANDBY process that has already loaded the model and primed
|
|
24
|
+
// onnxruntime. speak() ADOPTS the standby, feeds it the reply SENTENCE BY SENTENCE (each line is a
|
|
25
|
+
// separate utterance → a separate WAV), plays those WAVs in order as they land, and spawns a fresh
|
|
26
|
+
// standby in the background for the next reply. So the first word starts ~0.3–0.5 s after an answer lands
|
|
27
|
+
// instead of waiting on a cold model load. stop() kills the adopted synth process AND the player.
|
|
28
|
+
//
|
|
29
|
+
// Piper has no audio output of its own: it synthesises WAVs, which we play through the platform player
|
|
30
|
+
// (afplay on macOS; aplay / paplay / ffplay off macOS — detected and reported by the doctor). Containers
|
|
31
|
+
// have no audio device, so real playback is unverified there; synthesis-to-file IS verified (synthToFile
|
|
32
|
+
// + whisper), and macOS playback via afplay is verified on a real Mac (task-1416).
|
|
33
|
+
import { spawn } from 'node:child_process';
|
|
34
|
+
import { existsSync, mkdtempSync, readdirSync, rmSync, statSync } from 'node:fs';
|
|
35
|
+
import { homedir, tmpdir } from 'node:os';
|
|
36
|
+
import { join } from 'node:path';
|
|
37
|
+
import { sanitizeSpeech } from "./voices.js";
|
|
38
|
+
import { splitSentences } from "../summary.js";
|
|
39
|
+
/** The Piper voice dexterm defaults to: a British male, medium quality (local, no key). */
|
|
40
|
+
export const DEFAULT_PIPER_VOICE = 'en_GB-alan-medium';
|
|
41
|
+
/** `~/.dexterm`, honouring the DEXTERM_HOME override (tests point it at a temp dir) — matches @dexterm/custom. */
|
|
42
|
+
function dextermHomeDir() {
|
|
43
|
+
const h = process.env.DEXTERM_HOME;
|
|
44
|
+
return h && h.trim() ? h : join(homedir(), '.dexterm');
|
|
45
|
+
}
|
|
46
|
+
/** Where the downloaded Piper voice model lives (see `dexterm voice download piper-voice`). */
|
|
47
|
+
export const PIPER_MODEL = process.env.DEXTERM_PIPER_MODEL
|
|
48
|
+
|| join(dextermHomeDir(), 'models', `${DEFAULT_PIPER_VOICE}.onnx`);
|
|
49
|
+
/** The managed Python interpreter built by `dexterm voice setup` (macOS). */
|
|
50
|
+
export function managedVenvPython(home = dextermHomeDir()) {
|
|
51
|
+
const bin = process.platform === 'win32' ? join('Scripts', 'python.exe') : join('bin', 'python');
|
|
52
|
+
return join(home, 'piper', 'venv', bin);
|
|
53
|
+
}
|
|
54
|
+
/** Audio players we can hand a WAV to, best-first. afplay ships with macOS; the rest are the Linux/Windows set. */
|
|
55
|
+
export const PIPER_PLAYERS = process.platform === 'darwin'
|
|
56
|
+
? ['afplay', 'ffplay']
|
|
57
|
+
: ['aplay', 'paplay', 'ffplay'];
|
|
58
|
+
function which(bin) {
|
|
59
|
+
if (!bin)
|
|
60
|
+
return null;
|
|
61
|
+
if (bin.includes('/') || bin.includes('\\'))
|
|
62
|
+
return existsSync(bin) ? bin : null;
|
|
63
|
+
const sep = process.platform === 'win32' ? ';' : ':';
|
|
64
|
+
for (const p of (process.env.PATH ?? '').split(sep)) {
|
|
65
|
+
if (!p)
|
|
66
|
+
continue;
|
|
67
|
+
if (existsSync(join(p, bin)))
|
|
68
|
+
return join(p, bin);
|
|
69
|
+
if (process.platform === 'win32' && existsSync(join(p, `${bin}.exe`)))
|
|
70
|
+
return join(p, `${bin}.exe`);
|
|
71
|
+
}
|
|
72
|
+
return null;
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* Resolve the Piper binary (native-binary runtime only). Kept for the off-macOS doctor/status that only
|
|
76
|
+
* ever ship the prebuilt binary. Looks at an explicit override, the dexterm-local install, then PATH.
|
|
77
|
+
* NOTE: Debian's `apt install piper` is an UNRELATED gaming-mouse tool, so the doctor steers users to the
|
|
78
|
+
* rhasspy prebuilt release rather than apt; a wrong `piper` on PATH simply fails to synth and voice
|
|
79
|
+
* degrades to text (it never crashes).
|
|
80
|
+
*/
|
|
81
|
+
export function resolvePiperBin() {
|
|
82
|
+
return which(process.env.DEXTERM_PIPER_BIN || '')
|
|
83
|
+
|| which(join(dextermHomeDir(), 'piper', 'piper'))
|
|
84
|
+
|| which('piper');
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* Resolve the Piper RUNTIME for this machine, best-first:
|
|
88
|
+
* 1. DEXTERM_PIPER_BIN — an explicit binary (also the test seam).
|
|
89
|
+
* 2. DEXTERM_PIPER_PYTHON — an explicit `python` to run `-m piper` (test seam for the managed path).
|
|
90
|
+
* 3. the managed venv python (`~/.dexterm/piper/venv/bin/python`) — built by `dexterm voice setup`.
|
|
91
|
+
* 4. the dexterm-local prebuilt binary (`~/.dexterm/piper/piper`) or `piper` on PATH.
|
|
92
|
+
* Returns null when none is installed (voice degrades to the next engine, never crashes).
|
|
93
|
+
*/
|
|
94
|
+
export function resolvePiperRuntime() {
|
|
95
|
+
const binOverride = which(process.env.DEXTERM_PIPER_BIN || '');
|
|
96
|
+
if (binOverride)
|
|
97
|
+
return { cmd: binOverride, prefix: [], kind: 'binary' };
|
|
98
|
+
const pyOverride = process.env.DEXTERM_PIPER_PYTHON;
|
|
99
|
+
if (pyOverride && existsSync(pyOverride))
|
|
100
|
+
return { cmd: pyOverride, prefix: ['-m', 'piper'], kind: 'python' };
|
|
101
|
+
const venv = managedVenvPython();
|
|
102
|
+
if (existsSync(venv))
|
|
103
|
+
return { cmd: venv, prefix: ['-m', 'piper'], kind: 'python' };
|
|
104
|
+
const localBin = which(join(dextermHomeDir(), 'piper', 'piper'));
|
|
105
|
+
if (localBin)
|
|
106
|
+
return { cmd: localBin, prefix: [], kind: 'binary' };
|
|
107
|
+
const pathBin = which('piper');
|
|
108
|
+
if (pathBin)
|
|
109
|
+
return { cmd: pathBin, prefix: [], kind: 'binary' };
|
|
110
|
+
return null;
|
|
111
|
+
}
|
|
112
|
+
/** The player that will play Piper's WAV, or null when none is installed. */
|
|
113
|
+
export function pickPlayer() {
|
|
114
|
+
const override = process.env.DEXTERM_AUDIO_PLAYER;
|
|
115
|
+
if (override)
|
|
116
|
+
return which(override) ? override : null;
|
|
117
|
+
for (const p of PIPER_PLAYERS)
|
|
118
|
+
if (which(p))
|
|
119
|
+
return p;
|
|
120
|
+
return null;
|
|
121
|
+
}
|
|
122
|
+
/** Argv to play a finished WAV through the given player (quiet, exits on end, no window). */
|
|
123
|
+
function playerArgs(player, wav) {
|
|
124
|
+
const base = player.replace(/\.exe$/i, '').split(/[\\/]/).pop() ?? player;
|
|
125
|
+
switch (base) {
|
|
126
|
+
case 'afplay': return [wav];
|
|
127
|
+
case 'ffplay': return ['-nodisp', '-autoexit', '-loglevel', 'quiet', wav];
|
|
128
|
+
case 'paplay': return [wav];
|
|
129
|
+
case 'aplay':
|
|
130
|
+
default: return ['-q', wav];
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
/** Map a words-per-minute rate to Piper's --length_scale (lower rate → slower → larger scale). */
|
|
134
|
+
function lengthScaleFor(rate) {
|
|
135
|
+
if (typeof rate !== 'number' || !(rate > 0))
|
|
136
|
+
return undefined;
|
|
137
|
+
return Math.max(0.5, Math.min(2, 190 / rate));
|
|
138
|
+
}
|
|
139
|
+
/** WAV basenames in a dir, oldest-first (Piper names them by an increasing timestamp). */
|
|
140
|
+
function listWavs(dir) {
|
|
141
|
+
let names;
|
|
142
|
+
try {
|
|
143
|
+
names = readdirSync(dir).filter((f) => f.endsWith('.wav'));
|
|
144
|
+
}
|
|
145
|
+
catch {
|
|
146
|
+
return [];
|
|
147
|
+
}
|
|
148
|
+
return names.sort((a, b) => {
|
|
149
|
+
try {
|
|
150
|
+
return statSync(join(dir, a)).mtimeMs - statSync(join(dir, b)).mtimeMs || a.localeCompare(b);
|
|
151
|
+
}
|
|
152
|
+
catch {
|
|
153
|
+
return a.localeCompare(b);
|
|
154
|
+
}
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
/** Sanitise, then split into one line per sentence for sentence-by-sentence streaming (never empty lines). */
|
|
158
|
+
function splitForSpeech(text) {
|
|
159
|
+
const safe = sanitizeSpeech(text);
|
|
160
|
+
if (!safe)
|
|
161
|
+
return [];
|
|
162
|
+
const parts = splitSentences(safe).map((s) => s.trim()).filter(Boolean);
|
|
163
|
+
return parts.length ? parts : [safe];
|
|
164
|
+
}
|
|
165
|
+
export class PiperTts {
|
|
166
|
+
name = 'piper';
|
|
167
|
+
label = 'Piper (neural, local, no key)';
|
|
168
|
+
model;
|
|
169
|
+
/** The utterance currently playing. A new speak() stops it first, so there is only ever one. */
|
|
170
|
+
current = null;
|
|
171
|
+
/** A pre-warmed process waiting to be adopted by the next speak() (task-1416 latency). */
|
|
172
|
+
standby = null;
|
|
173
|
+
/** Every live warm process, so dispose() can never leak one. */
|
|
174
|
+
live = new Set();
|
|
175
|
+
constructor(model = PIPER_MODEL) { this.model = model; }
|
|
176
|
+
/** The voice id this engine speaks with (the model basename), for the status line. */
|
|
177
|
+
get voiceName() { return DEFAULT_PIPER_VOICE; }
|
|
178
|
+
/** True when Piper can speak ALOUD here: a runtime, the voice model and a player are all present. */
|
|
179
|
+
async available() {
|
|
180
|
+
return resolvePiperRuntime() !== null && existsSync(this.model) && pickPlayer() !== null;
|
|
181
|
+
}
|
|
182
|
+
/** True when Piper can SYNTHESISE here (runtime + model) even with no audio device (used for the proof). */
|
|
183
|
+
canSynth() {
|
|
184
|
+
return resolvePiperRuntime() !== null && existsSync(this.model);
|
|
185
|
+
}
|
|
186
|
+
/** Piper's voice is the model; there is no per-machine installed-voice list to cycle. */
|
|
187
|
+
async listVoices() { return [DEFAULT_PIPER_VOICE]; }
|
|
188
|
+
/**
|
|
189
|
+
* Synthesise `text` to a WAV at `outPath`. Resolves on success, rejects on a missing runtime/model or a
|
|
190
|
+
* non-zero exit. This is the engine's testable, audio-device-free core — the Docker proof and the unit
|
|
191
|
+
* tests drive it directly. The text is sanitised and delivered on stdin, exactly as speak() does.
|
|
192
|
+
*/
|
|
193
|
+
synthToFile(text, outPath, opts = {}) {
|
|
194
|
+
const runtime = resolvePiperRuntime();
|
|
195
|
+
const safe = sanitizeSpeech(text);
|
|
196
|
+
return new Promise((resolve, reject) => {
|
|
197
|
+
if (!runtime) {
|
|
198
|
+
reject(new Error('piper runtime not found'));
|
|
199
|
+
return;
|
|
200
|
+
}
|
|
201
|
+
const args = [...runtime.prefix, '--model', this.model, '--output_file', outPath];
|
|
202
|
+
if (typeof opts.lengthScale === 'number' && Number.isFinite(opts.lengthScale)) {
|
|
203
|
+
args.push('--length_scale', String(opts.lengthScale)); // >1 = slower speech
|
|
204
|
+
}
|
|
205
|
+
let child;
|
|
206
|
+
try {
|
|
207
|
+
child = spawn(runtime.cmd, args, { stdio: ['pipe', 'ignore', 'pipe'] });
|
|
208
|
+
}
|
|
209
|
+
catch (e) {
|
|
210
|
+
reject(e);
|
|
211
|
+
return;
|
|
212
|
+
}
|
|
213
|
+
let err = '';
|
|
214
|
+
child.stderr?.on('data', (d) => { err += d; });
|
|
215
|
+
child.stdin?.on('error', () => { });
|
|
216
|
+
// Piper reads one line of text from stdin; the sanitiser already collapsed newlines to one line.
|
|
217
|
+
try {
|
|
218
|
+
child.stdin?.end(safe + '\n');
|
|
219
|
+
}
|
|
220
|
+
catch { /* child gone */ }
|
|
221
|
+
child.on('error', reject);
|
|
222
|
+
child.on('close', (code) => code === 0
|
|
223
|
+
? resolve()
|
|
224
|
+
: reject(new Error(`piper exit ${code}: ${err.slice(-200)}`)));
|
|
225
|
+
});
|
|
226
|
+
}
|
|
227
|
+
/**
|
|
228
|
+
* Pre-load the model into a standby process so the first reply starts speaking quickly. Safe to call
|
|
229
|
+
* repeatedly and before anything is spoken (the controller calls it when voice is enabled). No-op when
|
|
230
|
+
* Piper is not installed here or a standby for this rate already exists.
|
|
231
|
+
*/
|
|
232
|
+
warmup(rate) {
|
|
233
|
+
const scale = lengthScaleFor(rate);
|
|
234
|
+
if (this.standby && !this.standby.disposed && this.standby.scale === scale)
|
|
235
|
+
return;
|
|
236
|
+
if (this.standby) {
|
|
237
|
+
this.standby.dispose();
|
|
238
|
+
this.standby = null;
|
|
239
|
+
}
|
|
240
|
+
this.standby = this.spawnWarm(scale);
|
|
241
|
+
}
|
|
242
|
+
/** Spawn a warm process in output-dir mode and prime the model with one discarded utterance. */
|
|
243
|
+
spawnWarm(scale) {
|
|
244
|
+
const runtime = resolvePiperRuntime();
|
|
245
|
+
if (!runtime || !existsSync(this.model))
|
|
246
|
+
return null;
|
|
247
|
+
const dir = mkdtempSync(join(tmpdir(), 'dexterm-piper-'));
|
|
248
|
+
const args = [...runtime.prefix, '--model', this.model, '--output_dir', dir];
|
|
249
|
+
if (scale !== undefined)
|
|
250
|
+
args.push('--length_scale', String(scale));
|
|
251
|
+
let child;
|
|
252
|
+
try {
|
|
253
|
+
child = spawn(runtime.cmd, args, { stdio: ['pipe', 'ignore', 'ignore'] });
|
|
254
|
+
}
|
|
255
|
+
catch {
|
|
256
|
+
try {
|
|
257
|
+
rmSync(dir, { recursive: true, force: true });
|
|
258
|
+
}
|
|
259
|
+
catch { /* ignore */ }
|
|
260
|
+
return null;
|
|
261
|
+
}
|
|
262
|
+
const seen = new Set();
|
|
263
|
+
const warm = {
|
|
264
|
+
child, dir, seen, scale, disposed: false,
|
|
265
|
+
ready: new Promise((resolve, reject) => {
|
|
266
|
+
const poll = setInterval(() => {
|
|
267
|
+
if (warm.disposed) {
|
|
268
|
+
clearInterval(poll);
|
|
269
|
+
return;
|
|
270
|
+
}
|
|
271
|
+
for (const f of listWavs(dir))
|
|
272
|
+
if (!seen.has(f)) {
|
|
273
|
+
seen.add(f);
|
|
274
|
+
clearInterval(poll);
|
|
275
|
+
resolve();
|
|
276
|
+
return;
|
|
277
|
+
}
|
|
278
|
+
}, 20);
|
|
279
|
+
child.on('error', () => { clearInterval(poll); reject(new Error('piper warm process error')); });
|
|
280
|
+
child.on('close', () => { clearInterval(poll); reject(new Error('piper warm process exited')); });
|
|
281
|
+
}),
|
|
282
|
+
dispose: () => {
|
|
283
|
+
if (warm.disposed)
|
|
284
|
+
return;
|
|
285
|
+
warm.disposed = true;
|
|
286
|
+
this.live.delete(warm);
|
|
287
|
+
try {
|
|
288
|
+
child.kill('SIGTERM');
|
|
289
|
+
}
|
|
290
|
+
catch { /* already gone */ }
|
|
291
|
+
try {
|
|
292
|
+
rmSync(dir, { recursive: true, force: true });
|
|
293
|
+
}
|
|
294
|
+
catch { /* ignore */ }
|
|
295
|
+
},
|
|
296
|
+
};
|
|
297
|
+
this.live.add(warm);
|
|
298
|
+
// Swallow the rejection so a dead warm process never becomes an unhandled rejection; adopters await it.
|
|
299
|
+
warm.ready.catch(() => { });
|
|
300
|
+
// A short priming utterance makes onnxruntime load the model now; its WAV lands in `seen` and is never
|
|
301
|
+
// played. stdin stays OPEN so speak() can adopt this process and feed the real sentences.
|
|
302
|
+
try {
|
|
303
|
+
child.stdin?.on('error', () => { });
|
|
304
|
+
child.stdin?.write('ready\n');
|
|
305
|
+
}
|
|
306
|
+
catch { /* child gone */ }
|
|
307
|
+
return warm;
|
|
308
|
+
}
|
|
309
|
+
/** Take the standby if it matches the rate; otherwise discard it and spawn a fresh warm process. */
|
|
310
|
+
takeWarm(scale) {
|
|
311
|
+
if (this.standby && !this.standby.disposed && this.standby.scale === scale) {
|
|
312
|
+
const w = this.standby;
|
|
313
|
+
this.standby = null;
|
|
314
|
+
return w;
|
|
315
|
+
}
|
|
316
|
+
if (this.standby) {
|
|
317
|
+
this.standby.dispose();
|
|
318
|
+
this.standby = null;
|
|
319
|
+
}
|
|
320
|
+
return this.spawnWarm(scale);
|
|
321
|
+
}
|
|
322
|
+
speak(text, opts = {}) {
|
|
323
|
+
this.stopCurrent(); // single queue: never two speakers at once
|
|
324
|
+
const player = pickPlayer();
|
|
325
|
+
const scale = lengthScaleFor(opts.rate);
|
|
326
|
+
const sentences = splitForSpeech(text);
|
|
327
|
+
let stopped = false;
|
|
328
|
+
let resolve;
|
|
329
|
+
const done = new Promise((r) => { resolve = r; });
|
|
330
|
+
let warm = null;
|
|
331
|
+
let playing = null;
|
|
332
|
+
const cleanup = () => { if (warm) {
|
|
333
|
+
warm.dispose();
|
|
334
|
+
warm = null;
|
|
335
|
+
} };
|
|
336
|
+
const settle = () => {
|
|
337
|
+
if (stopped)
|
|
338
|
+
return;
|
|
339
|
+
stopped = true;
|
|
340
|
+
if (this.current === handle)
|
|
341
|
+
this.current = null;
|
|
342
|
+
try {
|
|
343
|
+
playing?.kill('SIGTERM');
|
|
344
|
+
}
|
|
345
|
+
catch { /* ignore */ }
|
|
346
|
+
cleanup();
|
|
347
|
+
resolve();
|
|
348
|
+
};
|
|
349
|
+
const handle = { done, stop: settle };
|
|
350
|
+
this.current = handle;
|
|
351
|
+
// Nothing to say, or we cannot play aloud → degrade to text (resolve, never throw).
|
|
352
|
+
if (!player || sentences.length === 0 || !this.canSynth()) {
|
|
353
|
+
queueMicrotask(settle);
|
|
354
|
+
return handle;
|
|
355
|
+
}
|
|
356
|
+
// Adopt a warm process for the right rate (respawn if the rate changed), then refill the standby.
|
|
357
|
+
warm = this.takeWarm(scale);
|
|
358
|
+
if (!warm) {
|
|
359
|
+
queueMicrotask(settle);
|
|
360
|
+
return handle;
|
|
361
|
+
} // no runtime/model → text
|
|
362
|
+
this.standby = this.spawnWarm(scale); // keep one warm for the NEXT reply
|
|
363
|
+
const queue = [];
|
|
364
|
+
let synthClosed = false;
|
|
365
|
+
let firstFired = false;
|
|
366
|
+
const adopted = warm;
|
|
367
|
+
const startPlayback = () => {
|
|
368
|
+
const poll = setInterval(() => {
|
|
369
|
+
if (stopped) {
|
|
370
|
+
clearInterval(poll);
|
|
371
|
+
return;
|
|
372
|
+
}
|
|
373
|
+
for (const f of listWavs(adopted.dir))
|
|
374
|
+
if (!adopted.seen.has(f)) {
|
|
375
|
+
adopted.seen.add(f);
|
|
376
|
+
queue.push(join(adopted.dir, f));
|
|
377
|
+
}
|
|
378
|
+
}, 20);
|
|
379
|
+
adopted.child.on('close', () => { synthClosed = true; });
|
|
380
|
+
const playNext = () => {
|
|
381
|
+
if (stopped) {
|
|
382
|
+
clearInterval(poll);
|
|
383
|
+
return;
|
|
384
|
+
}
|
|
385
|
+
// A WAV is safe to play once a LATER one exists or the synth has exited (so it is fully written).
|
|
386
|
+
const ready = queue.length > 1 || (queue.length === 1 && synthClosed);
|
|
387
|
+
if (!ready) {
|
|
388
|
+
if (synthClosed && queue.length === 0) {
|
|
389
|
+
clearInterval(poll);
|
|
390
|
+
settle();
|
|
391
|
+
return;
|
|
392
|
+
}
|
|
393
|
+
setTimeout(playNext, 20);
|
|
394
|
+
return;
|
|
395
|
+
}
|
|
396
|
+
const wav = queue.shift();
|
|
397
|
+
try {
|
|
398
|
+
playing = spawn(player, playerArgs(player, wav), { stdio: 'ignore' });
|
|
399
|
+
playing.on('spawn', () => { if (!stopped && !firstFired) {
|
|
400
|
+
firstFired = true;
|
|
401
|
+
opts.onFirstAudio?.();
|
|
402
|
+
} });
|
|
403
|
+
playing.on('error', () => { if (!stopped)
|
|
404
|
+
setTimeout(playNext, 0); });
|
|
405
|
+
playing.on('close', () => { if (!stopped)
|
|
406
|
+
setTimeout(playNext, 0); });
|
|
407
|
+
}
|
|
408
|
+
catch {
|
|
409
|
+
if (!stopped)
|
|
410
|
+
setTimeout(playNext, 0);
|
|
411
|
+
}
|
|
412
|
+
};
|
|
413
|
+
playNext();
|
|
414
|
+
};
|
|
415
|
+
adopted.ready.then(() => {
|
|
416
|
+
if (stopped)
|
|
417
|
+
return;
|
|
418
|
+
// Feed the reply one sentence per line; end stdin so the process exits after the last WAV.
|
|
419
|
+
try {
|
|
420
|
+
for (const s of sentences)
|
|
421
|
+
adopted.child.stdin?.write(s + '\n');
|
|
422
|
+
adopted.child.stdin?.end();
|
|
423
|
+
}
|
|
424
|
+
catch {
|
|
425
|
+
settle();
|
|
426
|
+
return;
|
|
427
|
+
}
|
|
428
|
+
startPlayback();
|
|
429
|
+
}).catch(settle); // the warm process died before it was ready → degrade to text
|
|
430
|
+
return handle;
|
|
431
|
+
}
|
|
432
|
+
stopCurrent() {
|
|
433
|
+
const h = this.current;
|
|
434
|
+
if (h) {
|
|
435
|
+
this.current = null;
|
|
436
|
+
try {
|
|
437
|
+
h.stop();
|
|
438
|
+
}
|
|
439
|
+
catch { /* ignore */ }
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
/** Release the standby and any live warm processes (called when voice is disabled). */
|
|
443
|
+
dispose() {
|
|
444
|
+
this.stopCurrent();
|
|
445
|
+
for (const w of [...this.live])
|
|
446
|
+
w.dispose();
|
|
447
|
+
this.standby = null;
|
|
448
|
+
}
|
|
449
|
+
}
|
|
@@ -1,15 +1,30 @@
|
|
|
1
|
-
// macOS `say` TTS engine (task-1282). `say` ships with macOS, needs no key and no
|
|
2
|
-
// 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
|
|
4
|
-
//
|
|
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. Brisk by default (task-1411:
|
|
17
|
+
* the old delivery was reported "way too slow"); the user can nudge it live with < / > or set voice.rate. */
|
|
18
|
+
export const DEFAULT_RATE = 205;
|
|
8
19
|
export class SayTts {
|
|
9
20
|
name = 'say';
|
|
10
21
|
label = 'macOS say (built-in, no key)';
|
|
11
22
|
defaultVoice;
|
|
23
|
+
/** The utterance currently playing. A new speak() stops it first, so there is only ever one. */
|
|
24
|
+
current = null;
|
|
12
25
|
constructor(defaultVoice) { this.defaultVoice = defaultVoice; }
|
|
26
|
+
/** The resolved default voice this engine speaks with — used to seed the controller / status line. */
|
|
27
|
+
get voiceName() { return this.defaultVoice; }
|
|
13
28
|
async available() {
|
|
14
29
|
if (process.platform !== 'darwin')
|
|
15
30
|
return false;
|
|
@@ -23,22 +38,12 @@ export class SayTts {
|
|
|
23
38
|
}
|
|
24
39
|
});
|
|
25
40
|
}
|
|
26
|
-
/**
|
|
27
|
-
async
|
|
41
|
+
/** Installed voices with their locale. Robust against the single-space padding on long voice names. */
|
|
42
|
+
async listVoicesDetailed() {
|
|
28
43
|
return new Promise((resolve) => {
|
|
29
44
|
try {
|
|
30
45
|
execFile(SAY_BIN, ['-v', '?'], { timeout: 5000, maxBuffer: 1 << 20 }, (err, stdout) => {
|
|
31
|
-
|
|
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);
|
|
46
|
+
resolve(err || !stdout ? [] : parseSayVoices(stdout));
|
|
42
47
|
});
|
|
43
48
|
}
|
|
44
49
|
catch {
|
|
@@ -46,45 +51,64 @@ export class SayTts {
|
|
|
46
51
|
}
|
|
47
52
|
});
|
|
48
53
|
}
|
|
54
|
+
/** Selectable voice names (the TtsEngine contract). */
|
|
55
|
+
async listVoices() {
|
|
56
|
+
return (await this.listVoicesDetailed()).map((v) => v.name);
|
|
57
|
+
}
|
|
49
58
|
speak(text, opts = {}) {
|
|
59
|
+
// One queue: cut off whatever is playing so two `say` processes never overlap (clean interrupt).
|
|
60
|
+
this.stopCurrent();
|
|
50
61
|
const voice = opts.voice ?? this.defaultVoice;
|
|
51
|
-
const rate = opts.rate ??
|
|
62
|
+
const rate = opts.rate ?? DEFAULT_RATE;
|
|
63
|
+
// Sanitise the UNTRUSTED text first, then prepend our own TRUSTED pitch command. Order matters: the
|
|
64
|
+
// sanitiser strips any `[[…]]` the text tried to smuggle, so only our validated `[[pbas N]]` survives.
|
|
65
|
+
let safe = sanitizeSpeech(text);
|
|
66
|
+
if (typeof opts.pitch === 'number' && Number.isFinite(opts.pitch)) {
|
|
67
|
+
const pbas = Math.max(0, Math.min(100, Math.round(opts.pitch)));
|
|
68
|
+
safe = `[[pbas ${pbas}]] ${safe}`;
|
|
69
|
+
}
|
|
52
70
|
const args = [];
|
|
53
71
|
if (voice)
|
|
54
72
|
args.push('-v', voice);
|
|
55
|
-
args.push('-r', String(rate)
|
|
73
|
+
args.push('-r', String(rate));
|
|
74
|
+
args.push('-f', '-'); // read the utterance from stdin — the text is NEVER an argv word
|
|
56
75
|
let child = null;
|
|
57
76
|
let stopped = false;
|
|
58
77
|
let resolve;
|
|
59
78
|
const done = new Promise((r) => { resolve = r; });
|
|
79
|
+
const finish = () => { if (!stopped) {
|
|
80
|
+
stopped = true;
|
|
81
|
+
if (this.current === handle)
|
|
82
|
+
this.current = null;
|
|
83
|
+
resolve();
|
|
84
|
+
} };
|
|
60
85
|
try {
|
|
61
|
-
child = spawn(SAY_BIN, args, { stdio: 'ignore' });
|
|
62
|
-
//
|
|
63
|
-
|
|
86
|
+
child = spawn(SAY_BIN, args, { stdio: ['pipe', 'ignore', 'ignore'] });
|
|
87
|
+
// Nothing is spoken until stdin is closed; write the sanitised text and end it.
|
|
88
|
+
child.stdin?.on('error', () => { });
|
|
89
|
+
try {
|
|
90
|
+
child.stdin?.end(safe);
|
|
91
|
+
}
|
|
92
|
+
catch { /* child gone */ }
|
|
93
|
+
// `say` has no first-sample hook; the spawn event is the closest honest lower bound and is what the
|
|
94
|
+
// latency table reports as "first spoken word" for this engine.
|
|
64
95
|
child.on('spawn', () => { if (!stopped)
|
|
65
96
|
opts.onFirstAudio?.(); });
|
|
66
|
-
child.on('error',
|
|
67
|
-
|
|
68
|
-
resolve();
|
|
69
|
-
} });
|
|
70
|
-
child.on('close', () => { if (!stopped) {
|
|
71
|
-
stopped = true;
|
|
72
|
-
resolve();
|
|
73
|
-
} });
|
|
97
|
+
child.on('error', finish);
|
|
98
|
+
child.on('close', finish);
|
|
74
99
|
}
|
|
75
100
|
catch {
|
|
76
101
|
// No `say` on this machine: resolve immediately so the caller degrades to text.
|
|
77
|
-
queueMicrotask(
|
|
78
|
-
stopped = true;
|
|
79
|
-
resolve();
|
|
80
|
-
} });
|
|
102
|
+
queueMicrotask(finish);
|
|
81
103
|
}
|
|
82
|
-
|
|
104
|
+
const handle = {
|
|
83
105
|
done,
|
|
84
106
|
stop: () => {
|
|
85
107
|
if (stopped)
|
|
86
108
|
return;
|
|
87
109
|
stopped = true;
|
|
110
|
+
if (this.current === handle)
|
|
111
|
+
this.current = null;
|
|
88
112
|
try {
|
|
89
113
|
child?.kill('SIGTERM');
|
|
90
114
|
}
|
|
@@ -92,5 +116,18 @@ export class SayTts {
|
|
|
92
116
|
resolve();
|
|
93
117
|
},
|
|
94
118
|
};
|
|
119
|
+
this.current = handle;
|
|
120
|
+
return handle;
|
|
121
|
+
}
|
|
122
|
+
/** Stop the currently-playing utterance, if any. */
|
|
123
|
+
stopCurrent() {
|
|
124
|
+
const h = this.current;
|
|
125
|
+
if (h) {
|
|
126
|
+
this.current = null;
|
|
127
|
+
try {
|
|
128
|
+
h.stop();
|
|
129
|
+
}
|
|
130
|
+
catch { /* ignore */ }
|
|
131
|
+
}
|
|
95
132
|
}
|
|
96
133
|
}
|