agentvibes 5.9.0 → 5.11.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.agentvibes/config.json +17 -12
- package/.agentvibes/install-manifest.json +98 -86
- package/.claude/audio/ui/CREDITS.txt +16 -0
- package/.claude/audio/ui/bling-success.wav +0 -0
- package/.claude/commands/agent-vibes/receiver.md +64 -0
- package/.claude/config/audio-effects.cfg +3 -3
- package/.claude/config/personality.txt +1 -0
- package/.claude/config/reverb-level.txt +1 -1
- package/.claude/github-star-reminder.txt +1 -1
- package/.claude/hooks/audio-processor.sh +57 -18
- package/.claude/hooks/kokoro-installer.sh +117 -0
- package/.claude/hooks/kokoro-server.py +219 -0
- package/.claude/hooks/kokoro-tts.py +141 -0
- package/.claude/hooks/piper-download-voices.sh +39 -16
- package/.claude/hooks/piper-installer.sh +11 -6
- package/.claude/hooks/piper-voice-manager.sh +6 -6
- package/.claude/hooks/play-tts-agentvibes-receiver-for-voiceless-connections.sh +3 -1
- package/.claude/hooks/play-tts-elevenlabs.sh +360 -0
- package/.claude/hooks/play-tts-kokoro.sh +127 -0
- package/.claude/hooks/play-tts-piper.sh +20 -13
- package/.claude/hooks/play-tts-ssh-remote.sh +9 -2
- package/.claude/hooks/play-tts-termux-ssh.sh +3 -1
- package/.claude/hooks/play-tts.sh +78 -2
- package/.claude/hooks/provider-commands.sh +71 -22
- package/.claude/hooks/provider-manager.sh +15 -7
- package/.claude/hooks/voice-manager.sh +6 -0
- package/.claude/hooks-windows/kokoro-server.py +219 -0
- package/.claude/hooks-windows/kokoro-tts.py +107 -0
- package/.claude/hooks-windows/play-tts-kokoro.ps1 +191 -0
- package/.claude/hooks-windows/play-tts-windows-piper.ps1 +22 -16
- package/.claude/hooks-windows/play-tts.ps1 +29 -1
- package/README.md +12 -86
- package/RELEASE_NOTES.md +60 -0
- package/mcp-server/server.py +17 -7
- package/package.json +4 -3
- package/src/commands/install-mcp.js +730 -476
- package/src/console/app.js +28 -12
- package/src/console/audio-env.js +85 -1
- package/src/console/navigation.js +4 -0
- package/src/console/tabs/agents-tab.js +18 -12
- package/src/console/tabs/music-tab.js +7 -25
- package/src/console/tabs/receiver-tab.js +13 -13
- package/src/console/tabs/settings-tab.js +2 -2
- package/src/console/tabs/setup-tab.js +1708 -124
- package/src/console/tabs/voices-tab.js +76 -92
- package/src/console/widgets/format-utils.js +14 -2
- package/src/console/widgets/help-bar.js +55 -0
- package/src/console/widgets/personality-picker.js +2 -2
- package/src/console/widgets/reverb-picker.js +429 -41
- package/src/console/widgets/track-picker.js +60 -51
- package/src/i18n/de.js +204 -203
- package/src/i18n/en.js +1 -0
- package/src/i18n/es.js +204 -203
- package/src/i18n/fr.js +204 -203
- package/src/i18n/hi.js +204 -203
- package/src/i18n/ja.js +204 -203
- package/src/i18n/ko.js +204 -203
- package/src/i18n/pt.js +204 -203
- package/src/i18n/strings.js +54 -54
- package/src/i18n/zh-CN.js +204 -203
- package/src/installer.js +127 -34
- package/src/services/provider-service.js +178 -143
- package/src/services/provider-voice-catalog.js +126 -0
- package/src/services/tts-engine-service.js +53 -4
- package/src/utils/audio-duration-validator.js +341 -298
- package/src/utils/list-formatter.js +200 -194
- package/src/utils/platform-resolver.js +369 -0
- package/src/utils/preview-list-prompt.js +8 -0
- package/src/utils/provider-validator.js +79 -9
- package/templates/agentvibes-receiver.sh +6 -2
|
@@ -20,6 +20,7 @@ import { promisify } from 'node:util';
|
|
|
20
20
|
import fs from 'node:fs';
|
|
21
21
|
import { promises as _fsP } from 'node:fs';
|
|
22
22
|
import { SUPPORTED_LANGUAGES, t } from '../../i18n/strings.js';
|
|
23
|
+
import { ELEVENLABS_VOICES, KOKORO_VOICE_IDS } from '../../services/provider-voice-catalog.js';
|
|
23
24
|
import {
|
|
24
25
|
PROVIDERS,
|
|
25
26
|
checkClaudeInstalled, checkCopilotInstalled, checkCodexInstalled,
|
|
@@ -37,14 +38,15 @@ import {
|
|
|
37
38
|
import {
|
|
38
39
|
getAvailableEngines, getEngineStatuses, checkEngineInstalled,
|
|
39
40
|
} from '../../services/tts-engine-service.js';
|
|
40
|
-
import { openReverbPicker,
|
|
41
|
+
import { openReverbPicker, formatEffectLabel } from '../widgets/reverb-picker.js';
|
|
41
42
|
import { openTrackPicker, openVolumeInput } from '../widgets/track-picker.js';
|
|
43
|
+
import { renderHelpBar, selectorTitle } from '../widgets/help-bar.js';
|
|
42
44
|
import { formatTrackName } from '../widgets/format-utils.js';
|
|
43
45
|
import { destroyList } from '../widgets/destroy-list.js';
|
|
44
|
-
import { scanInstalledVoices, getVoiceMeta, genderIconTag, PIPER_VOICES_DIR, SAMPLE_PHRASES, parseMultiSpeaker, getFavorites, getThumbsDown, toggleFavorite, toggleThumbsUp, toggleThumbsDown } from './voices-tab.js';
|
|
46
|
+
import { scanInstalledVoices, getVoiceMeta, genderIconTag, formatVoiceRow, voiceRowHeader, PIPER_VOICES_DIR, SAMPLE_PHRASES, parseMultiSpeaker, getFavorites, getThumbsDown, toggleFavorite, toggleThumbsUp, toggleThumbsDown } from './voices-tab.js';
|
|
45
47
|
import { attachBtnBlink } from './agents-tab.js';
|
|
46
48
|
import { buildAudioEnv, detectWavPlayer } from '../audio-env.js';
|
|
47
|
-
import { spawn } from 'node:child_process';
|
|
49
|
+
import { spawn, spawnSync } from 'node:child_process';
|
|
48
50
|
import os from 'node:os';
|
|
49
51
|
import crypto from 'node:crypto';
|
|
50
52
|
import net from 'node:net';
|
|
@@ -53,6 +55,74 @@ const _execFileAsync = promisify(execFile);
|
|
|
53
55
|
|
|
54
56
|
const IS_TEST = process.env.AGENTVIBES_TEST_MODE === 'true';
|
|
55
57
|
|
|
58
|
+
// True while the automated test suite is running. The voice/cue previews below
|
|
59
|
+
// synthesize audio by spawning players DIRECTLY (SAPI, piper, say, the
|
|
60
|
+
// ElevenLabs hook, kokoro playback) — bypassing play-tts.sh's mute guard. The
|
|
61
|
+
// coverage suites fire every key handler (including Space = preview) on real
|
|
62
|
+
// widgets, so without this guard they would speak aloud on the developer's
|
|
63
|
+
// machine. We honor an explicit env flag (set by scripts/run-tests.sh) and the
|
|
64
|
+
// shared "tests running" marker file that play-tts.sh already respects.
|
|
65
|
+
// NOTE: distinct from AGENTVIBES_TEST_MODE, which stubs the whole tab — these
|
|
66
|
+
// previews must keep running their bodies (for coverage); only the final audio
|
|
67
|
+
// process is replaced with a silent no-op.
|
|
68
|
+
function _suppressAudio() {
|
|
69
|
+
if (process.env.AGENTVIBES_SUPPRESS_AUDIO === 'true') return true;
|
|
70
|
+
try { return fs.existsSync(path.join(os.homedir(), '.agentvibes-tests-running')); } catch { return false; }
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// A child_process-like stub that emits a clean exit on the next tick, so the
|
|
74
|
+
// surrounding preview lifecycle code (label updates, exit/error handlers) still
|
|
75
|
+
// runs and stays covered — without launching a real audio process. Not routed
|
|
76
|
+
// through the imported spawn(), so it never pollutes a test's spawn tracking.
|
|
77
|
+
function _silentProc() {
|
|
78
|
+
const proc = {
|
|
79
|
+
_cbs: {},
|
|
80
|
+
killed: false,
|
|
81
|
+
on(ev, cb) { (this._cbs[ev] ||= []).push(cb); return this; },
|
|
82
|
+
once(ev, cb) { return this.on(ev, cb); },
|
|
83
|
+
emit(ev, ...a) { (this._cbs[ev] || []).forEach((f) => f(...a)); return true; },
|
|
84
|
+
kill() { this.killed = true; },
|
|
85
|
+
unref() {},
|
|
86
|
+
stdin: { write() {}, end() {} },
|
|
87
|
+
stdout: { on() {}, },
|
|
88
|
+
stderr: { on() {}, },
|
|
89
|
+
};
|
|
90
|
+
setImmediate(() => proc.emit('exit', 0));
|
|
91
|
+
return proc;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// Spawn an audio player, or a silent no-op while tests are running.
|
|
95
|
+
function _spawnAudio(cmd, args, opts) {
|
|
96
|
+
if (_suppressAudio()) return _silentProc();
|
|
97
|
+
return spawn(cmd, args, opts);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// Exported under stable aliases purely so the audio-suppression behavior can be
|
|
101
|
+
// unit-tested without standing up the blessed TUI.
|
|
102
|
+
export { _suppressAudio as __suppressAudio, _silentProc as __silentProc, _spawnAudio as __spawnAudio };
|
|
103
|
+
|
|
104
|
+
// Resolve a real Unix bash for spawning .sh hooks. On Windows a bare
|
|
105
|
+
// spawn('bash', …) resolves to C:\Windows\System32\bash.exe — that's WSL, which
|
|
106
|
+
// has a different HOME (no AgentVibes key/config), doesn't inherit the Windows
|
|
107
|
+
// env, and can't execute a Windows-path script. Prefer git-bash / msys2 bash.
|
|
108
|
+
let _cachedBash = null;
|
|
109
|
+
function resolveBash() {
|
|
110
|
+
if (_cachedBash) return _cachedBash;
|
|
111
|
+
if (process.platform !== 'win32') { _cachedBash = 'bash'; return _cachedBash; }
|
|
112
|
+
const candidates = [
|
|
113
|
+
process.env.AGENTVIBES_BASH,
|
|
114
|
+
'C:\\Program Files\\Git\\bin\\bash.exe',
|
|
115
|
+
'C:\\Program Files\\Git\\usr\\bin\\bash.exe',
|
|
116
|
+
'C:\\Program Files (x86)\\Git\\bin\\bash.exe',
|
|
117
|
+
'C:\\msys64\\usr\\bin\\bash.exe',
|
|
118
|
+
].filter(Boolean);
|
|
119
|
+
for (const c of candidates) {
|
|
120
|
+
try { if (fs.existsSync(c)) { _cachedBash = c; return _cachedBash; } } catch { /* keep trying */ }
|
|
121
|
+
}
|
|
122
|
+
_cachedBash = 'bash'; // last resort
|
|
123
|
+
return _cachedBash;
|
|
124
|
+
}
|
|
125
|
+
|
|
56
126
|
let blessed;
|
|
57
127
|
if (!IS_TEST) {
|
|
58
128
|
const { default: b } = await import('blessed');
|
|
@@ -91,11 +161,33 @@ const FOOTER_TEXT = '[Enter] Continue [Esc] Back [Tab] Next Tab [Q] Quit';
|
|
|
91
161
|
// Maps non-Piper engine IDs to their canonical voice ID and display label.
|
|
92
162
|
// Used by the voice picker, _buildFields display, and auto-save logic.
|
|
93
163
|
const NATIVE_ENGINE_VOICES = {
|
|
94
|
-
soprano: { id: 'soprano',
|
|
95
|
-
sapi: { id: 'sapi',
|
|
96
|
-
'macos-say': { id: 'macos-say',
|
|
164
|
+
soprano: { id: 'soprano', label: 'Soprano' },
|
|
165
|
+
sapi: { id: 'sapi', label: 'Windows SAPI' },
|
|
166
|
+
'macos-say': { id: 'macos-say', label: 'macOS Say' },
|
|
167
|
+
// elevenlabs is handled by its own multi-voice picker (see ELEVENLABS_VOICES);
|
|
168
|
+
// the id here is the default voice assigned when the engine is first selected.
|
|
169
|
+
elevenlabs: { id: 'EXAVITQu4vr4xnSDxMaL', label: 'ElevenLabs' },
|
|
97
170
|
};
|
|
98
171
|
|
|
172
|
+
// Static built-in ElevenLabs premade voices — universal to every ElevenLabs
|
|
173
|
+
// account. The stored config value is the raw ElevenLabs voice_id; the TTS hook
|
|
174
|
+
// accepts raw IDs directly (its ^[a-zA-Z0-9]{10,40}$ path), so there is no
|
|
175
|
+
// name→id map to keep in sync. Custom/cloned account voices are NOT listed here
|
|
176
|
+
// (that needs a live API fetch); add them on elevenlabs.io to use their IDs.
|
|
177
|
+
const ELEVENLABS_DEFAULT_VOICE_ID = 'EXAVITQu4vr4xnSDxMaL'; // Sarah
|
|
178
|
+
// ELEVENLABS_VOICES is imported from services/provider-voice-catalog.js (single source of truth).
|
|
179
|
+
|
|
180
|
+
/** Returns the friendly name for an ElevenLabs voice_id, or null if unknown. */
|
|
181
|
+
const elevenLabsVoiceName = (id) => ELEVENLABS_VOICES.find(v => v.id === id)?.name || null;
|
|
182
|
+
|
|
183
|
+
/** Formats a stored voice value for display in the Voice field. */
|
|
184
|
+
function formatVoiceLabel(voice, globalVoice) {
|
|
185
|
+
if (NATIVE_ENGINE_VOICES[voice]) return NATIVE_ENGINE_VOICES[voice].label;
|
|
186
|
+
const el = elevenLabsVoiceName(voice);
|
|
187
|
+
if (el) return `ElevenLabs (${el})`;
|
|
188
|
+
return voice || `(global: ${globalVoice})`;
|
|
189
|
+
}
|
|
190
|
+
|
|
99
191
|
// ---------------------------------------------------------------------------
|
|
100
192
|
// Soprano WebUI auto-start helpers
|
|
101
193
|
|
|
@@ -123,7 +215,7 @@ async function _ensureSopranoWebUI(onStatus, signal) {
|
|
|
123
215
|
if (Date.now() - _sopranoSpawnedAt > 10_000) {
|
|
124
216
|
_sopranoSpawnedAt = Date.now();
|
|
125
217
|
try {
|
|
126
|
-
const p = spawn('soprano-webui', [], {
|
|
218
|
+
const p = spawn('soprano-webui', [], { // NOSONAR
|
|
127
219
|
stdio: 'ignore', detached: true, windowsHide: true,
|
|
128
220
|
shell: process.platform === 'win32',
|
|
129
221
|
});
|
|
@@ -166,6 +258,47 @@ export function formatGreeting(introText, projectName) {
|
|
|
166
258
|
return `${name} is ready! Welcome to AgentVibes. Love AgentVibes? We'd really appreciate it if you could give us a star on GitHub.`;
|
|
167
259
|
}
|
|
168
260
|
|
|
261
|
+
/**
|
|
262
|
+
* Build `python` args that check whether modules are importable WITHOUT
|
|
263
|
+
* importing them. `import kokoro` drags in torch (40s+ cold-start on Windows)
|
|
264
|
+
* and blows short spawn timeouts; find_spec only resolves the module spec and
|
|
265
|
+
* is ~instant. Pure/testable — caller runs it via spawnSync.
|
|
266
|
+
* @param {string[]} mods - top-level module names (e.g. ['kokoro','soundfile'])
|
|
267
|
+
* @returns {string[]} args array for `python <args>`; exit 0 = all present
|
|
268
|
+
*/
|
|
269
|
+
export function buildPyModuleCheckArgs(mods) {
|
|
270
|
+
const expr = mods.map(m => `u.find_spec('${m}')`).join(' and ');
|
|
271
|
+
return ['-c', `import importlib.util as u, sys; sys.exit(0 if ${expr} else 1)`];
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/**
|
|
275
|
+
* Build the fire-and-forget "preview ready" cue command for a platform. Pure
|
|
276
|
+
* (no spawning here, so it is unit-testable). Plays the bundled CC0 wav when
|
|
277
|
+
* present, else falls back to a system sound (Windows) / freedesktop cue or
|
|
278
|
+
* terminal bell (POSIX). stdio is ignored by the caller, so the POSIX bell is
|
|
279
|
+
* redirected to /dev/tty rather than the discarded stdout.
|
|
280
|
+
* @param {string} platform - process.platform value
|
|
281
|
+
* @param {string} wavPath - absolute path to the bling wav
|
|
282
|
+
* @param {boolean} haveWav - whether wavPath exists on disk
|
|
283
|
+
* @returns {{command: string, args: string[]}}
|
|
284
|
+
*/
|
|
285
|
+
export function buildBlingCommand(platform, wavPath, haveWav) {
|
|
286
|
+
if (platform === 'win32') {
|
|
287
|
+
const ps = haveWav
|
|
288
|
+
? `Add-Type -AssemblyName System.Windows.Forms; (New-Object System.Media.SoundPlayer('${wavPath.replace(/'/g, "''")}')).PlaySync()`
|
|
289
|
+
: '[System.Media.SystemSounds]::Asterisk.Play(); Start-Sleep -Milliseconds 700';
|
|
290
|
+
return { command: 'powershell', args: ['-NoProfile', '-Command', ps] };
|
|
291
|
+
}
|
|
292
|
+
if (haveWav) {
|
|
293
|
+
// Pass wavPath as a positional arg ($1) so the path is never interpolated
|
|
294
|
+
// into the shell string (prevents injection / breakage on special chars).
|
|
295
|
+
const sh = 'paplay "$1" 2>/dev/null || aplay -q "$1" 2>/dev/null || printf "\\a" > /dev/tty 2>/dev/null';
|
|
296
|
+
return { command: 'bash', args: ['-c', sh, '--', wavPath] };
|
|
297
|
+
}
|
|
298
|
+
const sh = 'paplay /usr/share/sounds/freedesktop/stereo/message.oga 2>/dev/null || printf "\\a" > /dev/tty 2>/dev/null';
|
|
299
|
+
return { command: 'bash', args: ['-c', sh] };
|
|
300
|
+
}
|
|
301
|
+
|
|
169
302
|
// ---------------------------------------------------------------------------
|
|
170
303
|
// Dependency detection helpers
|
|
171
304
|
|
|
@@ -204,7 +337,26 @@ async function _checkDependenciesAsync() {
|
|
|
204
337
|
}
|
|
205
338
|
}
|
|
206
339
|
|
|
207
|
-
|
|
340
|
+
// Kokoro: check python import (non-fatal if python3 unavailable)
|
|
341
|
+
// Use find_spec instead of import to avoid slow torch load (which causes ETIMEDOUT on sync checks)
|
|
342
|
+
const kokoro = await new Promise(resolve => {
|
|
343
|
+
try {
|
|
344
|
+
const proc = spawn('python3', ['-c', "import importlib.util; exit(0 if importlib.util.find_spec('kokoro') else 1)"], { stdio: 'ignore' }); // NOSONAR
|
|
345
|
+
const timer = setTimeout(() => { proc.kill(); resolve(false); }, 5000);
|
|
346
|
+
proc.on('close', code => { clearTimeout(timer); resolve(code === 0); });
|
|
347
|
+
proc.on('error', () => { clearTimeout(timer); resolve(false); });
|
|
348
|
+
} catch { resolve(false); }
|
|
349
|
+
});
|
|
350
|
+
|
|
351
|
+
// ElevenLabs: check env var or key file (sync — just a file read)
|
|
352
|
+
const elevenlabs = Boolean(process.env.ELEVENLABS_API_KEY) || (() => {
|
|
353
|
+
try {
|
|
354
|
+
const kf = path.join(os.homedir(), '.agentvibes', 'elevenlabs-key.txt');
|
|
355
|
+
return fs.existsSync(kf) && fs.readFileSync(kf, 'utf8').trim().length > 0;
|
|
356
|
+
} catch { return false; }
|
|
357
|
+
})();
|
|
358
|
+
|
|
359
|
+
return { node, npm, piper, soprano: sopranoTts || sopranoWebui, kokoro, elevenlabs, ffmpeg };
|
|
208
360
|
}
|
|
209
361
|
|
|
210
362
|
// ---------------------------------------------------------------------------
|
|
@@ -427,8 +579,19 @@ export function createSetupTab(screen, services) {
|
|
|
427
579
|
|
|
428
580
|
function _cycleTtsFocus(dir) {
|
|
429
581
|
const items = _ttsFocusableItems.filter(b => !b.hidden);
|
|
430
|
-
if (!items.length)
|
|
431
|
-
|
|
582
|
+
if (!items.length) {
|
|
583
|
+
_s2ContinueBtn.focus();
|
|
584
|
+
screen.render();
|
|
585
|
+
return;
|
|
586
|
+
}
|
|
587
|
+
const nextIdx = _ttsFocusIndex + dir;
|
|
588
|
+
if (dir > 0 && nextIdx >= items.length) {
|
|
589
|
+
// Tab past last install button → land on Continue
|
|
590
|
+
_s2ContinueBtn.focus();
|
|
591
|
+
screen.render();
|
|
592
|
+
return;
|
|
593
|
+
}
|
|
594
|
+
_ttsFocusIndex = (nextIdx + items.length) % items.length;
|
|
432
595
|
items[_ttsFocusIndex].focus();
|
|
433
596
|
screen.render();
|
|
434
597
|
}
|
|
@@ -460,21 +623,158 @@ export function createSetupTab(screen, services) {
|
|
|
460
623
|
}
|
|
461
624
|
}
|
|
462
625
|
|
|
626
|
+
function _openApiKeyInput(engine, row) {
|
|
627
|
+
const keyFile = path.join(os.homedir(), '.agentvibes', `${engine.id}-key.txt`);
|
|
628
|
+
const dlg = blessed.box({
|
|
629
|
+
parent: screen, top: 'center', left: 'center',
|
|
630
|
+
width: 70, height: 11,
|
|
631
|
+
border: { type: 'line' }, tags: true,
|
|
632
|
+
label: ` {bold}{cyan-fg} ${engine.name} — Enter API Key {/cyan-fg}{/bold} `,
|
|
633
|
+
style: { fg: COLORS.labelFg, bg: COLORS.contentBg, border: { fg: 'cyan' } },
|
|
634
|
+
});
|
|
635
|
+
dlg.setFront();
|
|
636
|
+
|
|
637
|
+
blessed.text({
|
|
638
|
+
parent: dlg, top: 1, left: 2, tags: true,
|
|
639
|
+
content: `{white-fg}Paste your ${engine.name} API key (saved to ${keyFile}):{/white-fg}`,
|
|
640
|
+
style: { bg: COLORS.contentBg },
|
|
641
|
+
});
|
|
642
|
+
|
|
643
|
+
const inputBox = blessed.textbox({
|
|
644
|
+
parent: dlg, top: 3, left: 2, right: 2, height: 3,
|
|
645
|
+
border: { type: 'line' },
|
|
646
|
+
style: { fg: 'white', bg: 'black', border: { fg: 'blue' }, focus: { border: { fg: 'cyan' } } },
|
|
647
|
+
});
|
|
648
|
+
|
|
649
|
+
blessed.text({
|
|
650
|
+
parent: dlg, top: 7, left: 2, tags: true,
|
|
651
|
+
content: '{#9e9e9e-fg}[Enter] Save [Esc] Cancel{/#9e9e9e-fg}',
|
|
652
|
+
style: { bg: COLORS.contentBg },
|
|
653
|
+
});
|
|
654
|
+
|
|
655
|
+
let _closed = false;
|
|
656
|
+
const _prevGrabKeys = screen.grabKeys;
|
|
657
|
+
|
|
658
|
+
function _close(save) {
|
|
659
|
+
if (_closed) return;
|
|
660
|
+
_closed = true;
|
|
661
|
+
inputBox.removeAllListeners('keypress');
|
|
662
|
+
screen.grabKeys = _prevGrabKeys;
|
|
663
|
+
screen.program.hideCursor();
|
|
664
|
+
// Balance the openModal() below so the global Escape/nav handlers see the
|
|
665
|
+
// correct modal depth once this dialog is gone.
|
|
666
|
+
navigationService?.closeModal();
|
|
667
|
+
dlg.destroy();
|
|
668
|
+
if (save) {
|
|
669
|
+
const key = (inputBox.value || '').trim();
|
|
670
|
+
if (key.length > 0) {
|
|
671
|
+
try {
|
|
672
|
+
fs.mkdirSync(path.join(os.homedir(), '.agentvibes'), { recursive: true });
|
|
673
|
+
fs.writeFileSync(keyFile, key + '\n', { mode: 0o600 });
|
|
674
|
+
if (row) {
|
|
675
|
+
row.statusLabel.setContent('{green-fg}[Installed]{/green-fg}');
|
|
676
|
+
row.installBtn.hide();
|
|
677
|
+
}
|
|
678
|
+
} catch (e) {
|
|
679
|
+
if (row) row.statusLabel.setContent(`{red-fg}[Save failed: ${e.message}]{/red-fg}`);
|
|
680
|
+
}
|
|
681
|
+
}
|
|
682
|
+
}
|
|
683
|
+
// Return focus to the TTS engine list so arrow/Tab/Escape work again.
|
|
684
|
+
const _visible = _ttsFocusableItems.filter(b => !b.hidden);
|
|
685
|
+
const _target = (row && row.installBtn && !row.installBtn.hidden) ? row.installBtn : _visible[0];
|
|
686
|
+
_target?.focus();
|
|
687
|
+
screen.render();
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
// Register as a modal so the global Escape handlers (setup-tab line ~4610 and
|
|
691
|
+
// navigation.js) recognise this dialog. Without this, isModalOpen() stays
|
|
692
|
+
// false and Escape navigates the wizard back a screen while leaving this box
|
|
693
|
+
// orphaned with grabKeys stuck on — trapping the user. forceCloseAll() will
|
|
694
|
+
// invoke this close callback to dismiss the dialog cleanly.
|
|
695
|
+
navigationService?.openModal(null, () => _close(false));
|
|
696
|
+
|
|
697
|
+
let _cursor = 0;
|
|
698
|
+
inputBox.value = '';
|
|
699
|
+
|
|
700
|
+
function _renderInput() {
|
|
701
|
+
const val = inputBox.value;
|
|
702
|
+
const lpos = inputBox._getCoords();
|
|
703
|
+
if (!lpos) { screen.render(); return; }
|
|
704
|
+
const contentWidth = Math.max(1, (lpos.xl - lpos.xi) - inputBox.iwidth);
|
|
705
|
+
const start = _cursor > contentWidth - 1 ? _cursor - contentWidth + 1 : 0;
|
|
706
|
+
// Mask the API key on screen — render bullets while inputBox.value keeps
|
|
707
|
+
// the real key. This is the API-key dialog (engine.requiresApiKey).
|
|
708
|
+
inputBox.setContent('•'.repeat(val.length).slice(start));
|
|
709
|
+
screen.render();
|
|
710
|
+
screen.program.cup(lpos.yi + inputBox.itop, lpos.xi + inputBox.ileft + (_cursor - start));
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
screen.grabKeys = true;
|
|
714
|
+
inputBox.on('keypress', (ch, key) => {
|
|
715
|
+
if (key.name === 'enter') { _close(true); return; }
|
|
716
|
+
if (key.name === 'escape') { _close(false); return; }
|
|
717
|
+
if (key.name === 'backspace') {
|
|
718
|
+
if (_cursor > 0) { inputBox.value = inputBox.value.slice(0, _cursor - 1) + inputBox.value.slice(_cursor); _cursor--; }
|
|
719
|
+
} else if (key.name === 'delete') {
|
|
720
|
+
if (_cursor < inputBox.value.length) { inputBox.value = inputBox.value.slice(0, _cursor) + inputBox.value.slice(_cursor + 1); }
|
|
721
|
+
} else if (key.name === 'left') {
|
|
722
|
+
if (_cursor > 0) _cursor--;
|
|
723
|
+
} else if (key.name === 'right') {
|
|
724
|
+
if (_cursor < inputBox.value.length) _cursor++;
|
|
725
|
+
} else if (key.name === 'home') {
|
|
726
|
+
_cursor = 0;
|
|
727
|
+
} else if (key.name === 'end') {
|
|
728
|
+
_cursor = inputBox.value.length;
|
|
729
|
+
} else if (ch && !key.ctrl && !key.meta) {
|
|
730
|
+
// Strip CR/LF from pasted text and advance the cursor by the full
|
|
731
|
+
// inserted length (a multi-char paste otherwise desyncs the cursor).
|
|
732
|
+
const ins = ch.replace(/[\r\n]/g, '');
|
|
733
|
+
inputBox.value = inputBox.value.slice(0, _cursor) + ins + inputBox.value.slice(_cursor);
|
|
734
|
+
_cursor += ins.length;
|
|
735
|
+
}
|
|
736
|
+
_renderInput();
|
|
737
|
+
});
|
|
738
|
+
|
|
739
|
+
dlg.once('destroy', () => {
|
|
740
|
+
if (!_closed) { _closed = true; inputBox.removeAllListeners('keypress'); screen.grabKeys = _prevGrabKeys; screen.program.hideCursor(); navigationService?.closeModal(); }
|
|
741
|
+
});
|
|
742
|
+
|
|
743
|
+
inputBox.focus();
|
|
744
|
+
screen.program.showCursor();
|
|
745
|
+
_renderInput();
|
|
746
|
+
}
|
|
747
|
+
|
|
463
748
|
let _ttsInstalling = false;
|
|
464
749
|
async function _handleTtsInstall(engine) {
|
|
465
750
|
if (!engine.installCmd || _ttsInstalling) return;
|
|
751
|
+
|
|
752
|
+
// ElevenLabs requires an API key — show a key-entry dialog instead of running a shell command
|
|
753
|
+
if (engine.requiresApiKey) {
|
|
754
|
+
const row = _ttsEngineRows.find(r => r.engine.id === engine.id);
|
|
755
|
+
_openApiKeyInput(engine, row);
|
|
756
|
+
return;
|
|
757
|
+
}
|
|
758
|
+
|
|
466
759
|
_ttsInstalling = true;
|
|
467
760
|
|
|
468
|
-
// Show installing status
|
|
469
761
|
const row = _ttsEngineRows.find(r => r.engine.id === engine.id);
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
762
|
+
const spinFrames = ['⠋','⠙','⠹','⠸','⠼','⠴','⠦','⠧','⠇','⠏'];
|
|
763
|
+
let spinIdx = 0;
|
|
764
|
+
const spinTimer = setInterval(() => {
|
|
765
|
+
if (row) {
|
|
766
|
+
row.statusLabel.setContent(`{yellow-fg}${spinFrames[spinIdx % spinFrames.length]} Installing...{/yellow-fg}`);
|
|
767
|
+
screen.render();
|
|
768
|
+
}
|
|
769
|
+
spinIdx++;
|
|
770
|
+
}, 100);
|
|
474
771
|
|
|
475
772
|
try {
|
|
476
|
-
const opts = { stdio: 'pipe', timeout:
|
|
477
|
-
if (
|
|
773
|
+
const opts = { stdio: 'pipe', timeout: 1800000 };
|
|
774
|
+
if (engine.installSpec) {
|
|
775
|
+
// Structured form — space-safe, no shell (FIX: installCmd.split(' ') corrupted paths with spaces)
|
|
776
|
+
await _execFileAsync(engine.installSpec.cmd, engine.installSpec.args, opts);
|
|
777
|
+
} else if (process.platform === 'win32') {
|
|
478
778
|
opts.shell = true;
|
|
479
779
|
await _execFileAsync(engine.installCmd, [], opts);
|
|
480
780
|
} else {
|
|
@@ -482,7 +782,7 @@ export function createSetupTab(screen, services) {
|
|
|
482
782
|
await _execFileAsync(parts[0], parts.slice(1), opts);
|
|
483
783
|
}
|
|
484
784
|
|
|
485
|
-
|
|
785
|
+
clearInterval(spinTimer);
|
|
486
786
|
const installed = checkEngineInstalled(engine.id);
|
|
487
787
|
if (row) {
|
|
488
788
|
row.statusLabel.setContent(installed
|
|
@@ -491,6 +791,7 @@ export function createSetupTab(screen, services) {
|
|
|
491
791
|
if (installed) row.installBtn.hide();
|
|
492
792
|
}
|
|
493
793
|
} catch (err) {
|
|
794
|
+
clearInterval(spinTimer);
|
|
494
795
|
if (row) {
|
|
495
796
|
row.statusLabel.setContent(`{red-fg}[Failed]{/red-fg}`);
|
|
496
797
|
}
|
|
@@ -504,6 +805,15 @@ export function createSetupTab(screen, services) {
|
|
|
504
805
|
if (_screen < 3) { _screen++; _showCurrentScreen(); }
|
|
505
806
|
});
|
|
506
807
|
_s2ContinueBtn.hidden = true;
|
|
808
|
+
_s2ContinueBtn.key(['right', 'enter'], () => { if (_screen < 3) { _screen++; _showCurrentScreen(); } });
|
|
809
|
+
_s2ContinueBtn.key(['S-tab', 'up'], () => {
|
|
810
|
+
const items = _ttsFocusableItems.filter(b => !b.hidden);
|
|
811
|
+
if (items.length) {
|
|
812
|
+
_ttsFocusIndex = items.length - 1;
|
|
813
|
+
items[_ttsFocusIndex].focus();
|
|
814
|
+
screen.render();
|
|
815
|
+
}
|
|
816
|
+
});
|
|
507
817
|
|
|
508
818
|
// =========================================================================
|
|
509
819
|
// SCREEN 3: LLM Providers (new — from llm-providers-tab)
|
|
@@ -862,12 +1172,9 @@ export function createSetupTab(screen, services) {
|
|
|
862
1172
|
function _buildFields() {
|
|
863
1173
|
const base = [
|
|
864
1174
|
{ key: 'ttsEngine', label: 'TTS Engine', getValue: () => draft.ttsEngine || `(global: ${globalEngine})` },
|
|
865
|
-
{ key: 'voice', label: 'Voice', getValue: () =>
|
|
1175
|
+
{ key: 'voice', label: 'Voice', getValue: () => formatVoiceLabel(draft.voice, globalVoice) },
|
|
866
1176
|
{ key: 'pretext', label: 'Pretext', getValue: () => draft.pretext || '(none)' },
|
|
867
|
-
{ key: '
|
|
868
|
-
const p = REVERB_PRESETS.find(r => r.value === draft.reverbPreset);
|
|
869
|
-
return p ? p.label : draft.reverbPreset || 'Off';
|
|
870
|
-
}},
|
|
1177
|
+
{ key: 'audioEffects', label: 'Audio Effects', getValue: () => formatEffectLabel(draft.reverbPreset) },
|
|
871
1178
|
{ key: 'bgTrack', label: 'Music Track', getValue: () => formatTrackName(draft.bgTrack) || '(default)' },
|
|
872
1179
|
{ key: 'bgVolume', label: 'Music Vol', getValue: () => `${Math.round(parseFloat(draft.bgVolume) * 100)}%` },
|
|
873
1180
|
{ key: 'destination', label: 'Destination', getValue: () => draft.mode === 'remote' ? '🌐 Remote (SSH)' : '🏠 Local' },
|
|
@@ -881,8 +1188,8 @@ export function createSetupTab(screen, services) {
|
|
|
881
1188
|
return `✏ ${h}${p}${k}`;
|
|
882
1189
|
}});
|
|
883
1190
|
if (draft.connType === 'manual') {
|
|
884
|
-
base.push({ key: 'sshKey', label: 'SSH Key', getValue: () => draft.sshKey || '{
|
|
885
|
-
base.push({ key: 'port', label: 'Port', getValue: () => draft.port || '{
|
|
1191
|
+
base.push({ key: 'sshKey', label: 'SSH Key', getValue: () => draft.sshKey || '{#9e9e9e-fg}(optional){/#9e9e9e-fg}' });
|
|
1192
|
+
base.push({ key: 'port', label: 'Port', getValue: () => draft.port || '{#9e9e9e-fg}(default: 22){/#9e9e9e-fg}' });
|
|
886
1193
|
}
|
|
887
1194
|
}
|
|
888
1195
|
return base;
|
|
@@ -979,7 +1286,15 @@ export function createSetupTab(screen, services) {
|
|
|
979
1286
|
const isWin = process.platform === 'win32' && !process.env.WSL_DISTRO_NAME;
|
|
980
1287
|
const sampleText = 'This is how your Hermes audio settings sound right now.';
|
|
981
1288
|
let cmd, args;
|
|
982
|
-
if (
|
|
1289
|
+
if (draft.ttsEngine === 'elevenlabs') {
|
|
1290
|
+
// ElevenLabs has no Windows provider; route through the bash orchestrator
|
|
1291
|
+
// (play-tts.sh) so pretext, effects, and background music all apply. It then
|
|
1292
|
+
// dispatches to the ElevenLabs provider for synthesis.
|
|
1293
|
+
const script = path.join(packageDir, '.claude', 'hooks', 'play-tts.sh');
|
|
1294
|
+
cmd = resolveBash();
|
|
1295
|
+
args = [script, sampleText, draft.voice || ELEVENLABS_DEFAULT_VOICE_ID,
|
|
1296
|
+
'--llm', 'hermes', '--project-dir', targetDir];
|
|
1297
|
+
} else if (isWin) {
|
|
983
1298
|
const script = path.join(targetDir, '.claude', hooksSubdir, 'play-tts.ps1');
|
|
984
1299
|
cmd = 'powershell';
|
|
985
1300
|
args = ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', script, sampleText, '', '-llm', 'hermes'];
|
|
@@ -988,9 +1303,12 @@ export function createSetupTab(screen, services) {
|
|
|
988
1303
|
cmd = 'bash';
|
|
989
1304
|
args = [script, sampleText, '', '--llm', 'hermes'];
|
|
990
1305
|
}
|
|
991
|
-
const proc =
|
|
1306
|
+
const proc = _spawnAudio(cmd, args, {
|
|
992
1307
|
stdio: 'ignore', windowsHide: true,
|
|
993
|
-
env: {
|
|
1308
|
+
env: {
|
|
1309
|
+
...process.env, CLAUDE_PROJECT_DIR: targetDir,
|
|
1310
|
+
...(draft.ttsEngine === 'elevenlabs' ? { AGENTVIBES_FORCE_PROVIDER: 'elevenlabs' } : {}),
|
|
1311
|
+
},
|
|
994
1312
|
});
|
|
995
1313
|
_previewModalProc = proc;
|
|
996
1314
|
proc.on('exit', (code) => {
|
|
@@ -1063,8 +1381,16 @@ export function createSetupTab(screen, services) {
|
|
|
1063
1381
|
case 'ttsEngine': _openTtsEnginePicker(draft, _refreshField); break;
|
|
1064
1382
|
case 'voice': _openVoicePickerForLlm(draft, _refreshField); break;
|
|
1065
1383
|
case 'pretext': _openPretextEditor(modal, draft, _refreshField); break;
|
|
1066
|
-
case '
|
|
1067
|
-
openReverbPicker(screen, draft.reverbPreset, (val) => { draft.reverbPreset = val; _refreshField(); }, _cancelField, {
|
|
1384
|
+
case 'audioEffects':
|
|
1385
|
+
openReverbPicker(screen, draft.reverbPreset, (val) => { draft.reverbPreset = val; _refreshField(); }, _cancelField, {
|
|
1386
|
+
applyToEffectsManager: false,
|
|
1387
|
+
previewHooksDir: path.join(packageDir, '.claude', 'hooks'),
|
|
1388
|
+
previewLlmKey: 'hermes',
|
|
1389
|
+
previewTargetDir: targetDir,
|
|
1390
|
+
previewVoice: draft.voice,
|
|
1391
|
+
previewBashBin: resolveBash(),
|
|
1392
|
+
previewForceProvider: draft.ttsEngine === 'elevenlabs' ? 'elevenlabs' : undefined,
|
|
1393
|
+
});
|
|
1068
1394
|
break;
|
|
1069
1395
|
case 'bgTrack':
|
|
1070
1396
|
openTrackPicker(screen, draft.bgTrack, Math.round(parseFloat(draft.bgVolume) * 100), (track) => { draft.bgTrack = track; _refreshField(); }, _cancelField, { skipVolume: true });
|
|
@@ -1159,7 +1485,7 @@ export function createSetupTab(screen, services) {
|
|
|
1159
1485
|
const raw = fs.readFileSync(sshConfigPath, 'utf8');
|
|
1160
1486
|
const seen = new Set();
|
|
1161
1487
|
for (const line of raw.split('\n')) {
|
|
1162
|
-
const m = line.match(/^\s*IdentityFile\s+(.+)\s*$/i);
|
|
1488
|
+
const m = line.match(/^\s*IdentityFile\s+(.+)\s*$/i); // NOSONAR
|
|
1163
1489
|
if (m) {
|
|
1164
1490
|
const expanded = m[1].trim().replace(/^~/, os.homedir());
|
|
1165
1491
|
if (!seen.has(expanded)) { seen.add(expanded); keys.push(expanded); }
|
|
@@ -1297,7 +1623,7 @@ export function createSetupTab(screen, services) {
|
|
|
1297
1623
|
try {
|
|
1298
1624
|
const lines = fs.readFileSync(path.join(os.homedir(), '.ssh', 'config'), 'utf8').split('\n');
|
|
1299
1625
|
for (const line of lines) {
|
|
1300
|
-
const m = line.match(/^\s*[Hh]ost\s+(.+)$/);
|
|
1626
|
+
const m = line.match(/^\s*[Hh]ost\s+(.+)$/); // NOSONAR
|
|
1301
1627
|
if (m) {
|
|
1302
1628
|
for (const name of m[1].trim().split(/\s+/)) {
|
|
1303
1629
|
if (!name.includes('*') && !name.includes('?') && !seen.has(name)) {
|
|
@@ -1316,7 +1642,7 @@ export function createSetupTab(screen, services) {
|
|
|
1316
1642
|
const known = _getKnownHosts().filter(h => !aliases.includes(h)); // avoid duplicates
|
|
1317
1643
|
|
|
1318
1644
|
// Build display items — aliases first (they carry key/port in ~/.ssh/config)
|
|
1319
|
-
const aliasItems = aliases.map(a => ` 📋 ${a} {
|
|
1645
|
+
const aliasItems = aliases.map(a => ` 📋 ${a} {#9e9e9e-fg}(SSH alias){/#9e9e9e-fg}`);
|
|
1320
1646
|
const knownItems = known.map(h => ` ${h}`);
|
|
1321
1647
|
const allItems = [...aliasItems, ...knownItems, ' ✏ Enter manually…'];
|
|
1322
1648
|
|
|
@@ -1389,7 +1715,7 @@ export function createSetupTab(screen, services) {
|
|
|
1389
1715
|
border: { type: 'line' }, tags: true,
|
|
1390
1716
|
label: ' {bold}{cyan-fg} SSH Connection {/cyan-fg}{/bold} ',
|
|
1391
1717
|
items: [
|
|
1392
|
-
` 📋 SSH Alias — pick from ~/.ssh/config${hasAliases ? '' : ' {
|
|
1718
|
+
` 📋 SSH Alias — pick from ~/.ssh/config${hasAliases ? '' : ' {#9e9e9e-fg}(none found){/#9e9e9e-fg}'}`,
|
|
1393
1719
|
' ✏ Manual — enter host, key, and port',
|
|
1394
1720
|
],
|
|
1395
1721
|
style: {
|
|
@@ -1517,8 +1843,8 @@ export function createSetupTab(screen, services) {
|
|
|
1517
1843
|
return `✏ ${h}${p}${k}`;
|
|
1518
1844
|
}}];
|
|
1519
1845
|
if (draft.connType === 'manual') {
|
|
1520
|
-
base.push({ key: 'sshKey', label: 'SSH Key', getValue: () => draft.sshKey || '{
|
|
1521
|
-
base.push({ key: 'port', label: 'Port', getValue: () => draft.port || provider.defaultPort || '{
|
|
1846
|
+
base.push({ key: 'sshKey', label: 'SSH Key', getValue: () => draft.sshKey || '{#9e9e9e-fg}(optional){/#9e9e9e-fg}' });
|
|
1847
|
+
base.push({ key: 'port', label: 'Port', getValue: () => draft.port || provider.defaultPort || '{#9e9e9e-fg}(default){/#9e9e9e-fg}' });
|
|
1522
1848
|
}
|
|
1523
1849
|
return base;
|
|
1524
1850
|
}
|
|
@@ -1586,7 +1912,7 @@ export function createSetupTab(screen, services) {
|
|
|
1586
1912
|
// Refresh status text below provider name
|
|
1587
1913
|
const row = transportRows.find(r => r.id === provider.id);
|
|
1588
1914
|
if (row && draft.host) {
|
|
1589
|
-
row.statusText.setContent(`{
|
|
1915
|
+
row.statusText.setContent(`{#9e9e9e-fg}→ ${draft.host}:${draft.port}{/#9e9e9e-fg}`);
|
|
1590
1916
|
screen.render();
|
|
1591
1917
|
}
|
|
1592
1918
|
}).catch(() => {});
|
|
@@ -1608,10 +1934,10 @@ export function createSetupTab(screen, services) {
|
|
|
1608
1934
|
const sampleText = 'This is how your audio settings sound right now.';
|
|
1609
1935
|
const script = path.join(targetDir, '.claude', hooksSubdir, isWin ? 'play-tts.ps1' : 'play-tts.sh');
|
|
1610
1936
|
const proc = isWin
|
|
1611
|
-
?
|
|
1937
|
+
? _spawnAudio('powershell', ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', script, sampleText], { // NOSONAR
|
|
1612
1938
|
stdio: 'ignore', windowsHide: true, env: { ...process.env, CLAUDE_PROJECT_DIR: targetDir },
|
|
1613
1939
|
})
|
|
1614
|
-
:
|
|
1940
|
+
: _spawnAudio(resolveBash(), [script, sampleText], { // NOSONAR
|
|
1615
1941
|
stdio: 'ignore', env: { ...process.env, CLAUDE_PROJECT_DIR: targetDir },
|
|
1616
1942
|
});
|
|
1617
1943
|
_previewProc = proc;
|
|
@@ -1742,12 +2068,9 @@ export function createSetupTab(screen, services) {
|
|
|
1742
2068
|
function _buildFields() {
|
|
1743
2069
|
const base = [
|
|
1744
2070
|
{ key: 'ttsEngine', label: 'TTS Engine', getValue: () => draft.ttsEngine || `(global: ${globalEngine})` },
|
|
1745
|
-
{ key: 'voice', label: 'Voice', getValue: () =>
|
|
2071
|
+
{ key: 'voice', label: 'Voice', getValue: () => formatVoiceLabel(draft.voice, globalVoice) },
|
|
1746
2072
|
{ key: 'pretext', label: 'Pretext', getValue: () => draft.pretext || '(none)' },
|
|
1747
|
-
{ key: '
|
|
1748
|
-
const p = REVERB_PRESETS.find(r => r.value === draft.reverbPreset);
|
|
1749
|
-
return p ? p.label : draft.reverbPreset || 'Off';
|
|
1750
|
-
}},
|
|
2073
|
+
{ key: 'audioEffects', label: 'Audio Effects', getValue: () => formatEffectLabel(draft.reverbPreset) },
|
|
1751
2074
|
{ key: 'bgTrack', label: 'Music Track', getValue: () => formatTrackName(draft.bgTrack) || '(default)' },
|
|
1752
2075
|
{ key: 'bgVolume', label: 'Music Vol', getValue: () => `${Math.round(parseFloat(draft.bgVolume) * 100)}%` },
|
|
1753
2076
|
{ key: 'destination', label: 'Destination', getValue: () => draft.mode === 'remote' ? '🌐 Remote (SSH)' : '🏠 Local' },
|
|
@@ -1761,8 +2084,8 @@ export function createSetupTab(screen, services) {
|
|
|
1761
2084
|
return `✏ ${h}${p}${k}`;
|
|
1762
2085
|
}});
|
|
1763
2086
|
if (draft.connType === 'manual') {
|
|
1764
|
-
base.push({ key: 'sshKey', label: 'SSH Key', getValue: () => draft.sshKey || '{
|
|
1765
|
-
base.push({ key: 'port', label: 'Port', getValue: () => draft.port || '{
|
|
2087
|
+
base.push({ key: 'sshKey', label: 'SSH Key', getValue: () => draft.sshKey || '{#9e9e9e-fg}(optional){/#9e9e9e-fg}' });
|
|
2088
|
+
base.push({ key: 'port', label: 'Port', getValue: () => draft.port || '{#9e9e9e-fg}(default: 22){/#9e9e9e-fg}' });
|
|
1766
2089
|
}
|
|
1767
2090
|
}
|
|
1768
2091
|
return base;
|
|
@@ -1899,7 +2222,15 @@ export function createSetupTab(screen, services) {
|
|
|
1899
2222
|
|
|
1900
2223
|
function _doSpawnPreview() {
|
|
1901
2224
|
let cmd, args;
|
|
1902
|
-
if (
|
|
2225
|
+
if (draft.ttsEngine === 'elevenlabs') {
|
|
2226
|
+
// ElevenLabs has no Windows provider; route through the bash orchestrator
|
|
2227
|
+
// (play-tts.sh) so pretext, effects, and background music all apply. It then
|
|
2228
|
+
// dispatches to the ElevenLabs provider for synthesis.
|
|
2229
|
+
const script = path.join(_hooksBase, '.claude', 'hooks', 'play-tts.sh');
|
|
2230
|
+
cmd = resolveBash();
|
|
2231
|
+
args = [script, sampleText, draft.voice || ELEVENLABS_DEFAULT_VOICE_ID,
|
|
2232
|
+
'--llm', llmKey, '--project-dir', targetDir];
|
|
2233
|
+
} else if (isWin) {
|
|
1903
2234
|
const script = path.join(_hooksBase, '.claude', hooksSubdir, 'play-tts.ps1');
|
|
1904
2235
|
cmd = 'powershell';
|
|
1905
2236
|
args = ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', script, sampleText, '', '-llm', llmKey];
|
|
@@ -1908,10 +2239,13 @@ export function createSetupTab(screen, services) {
|
|
|
1908
2239
|
cmd = 'bash';
|
|
1909
2240
|
args = [script, sampleText, '', '--llm', llmKey];
|
|
1910
2241
|
}
|
|
1911
|
-
const proc =
|
|
2242
|
+
const proc = _spawnAudio(cmd, args, {
|
|
1912
2243
|
stdio: 'ignore',
|
|
1913
2244
|
windowsHide: true,
|
|
1914
|
-
env: {
|
|
2245
|
+
env: {
|
|
2246
|
+
...process.env, CLAUDE_PROJECT_DIR: targetDir, AGENTVIBES_LLM_KEY: `llm:${llmKey}`,
|
|
2247
|
+
...(draft.ttsEngine === 'elevenlabs' ? { AGENTVIBES_FORCE_PROVIDER: 'elevenlabs' } : {}),
|
|
2248
|
+
},
|
|
1915
2249
|
});
|
|
1916
2250
|
_previewModalProc = proc;
|
|
1917
2251
|
|
|
@@ -2058,11 +2392,19 @@ export function createSetupTab(screen, services) {
|
|
|
2058
2392
|
_openPretextEditor(modal, draft, _refreshField);
|
|
2059
2393
|
break;
|
|
2060
2394
|
|
|
2061
|
-
case '
|
|
2395
|
+
case 'audioEffects':
|
|
2062
2396
|
openReverbPicker(screen, draft.reverbPreset, (val) => {
|
|
2063
2397
|
draft.reverbPreset = val;
|
|
2064
2398
|
_refreshField();
|
|
2065
|
-
}, _cancelField, {
|
|
2399
|
+
}, _cancelField, {
|
|
2400
|
+
applyToEffectsManager: false,
|
|
2401
|
+
previewHooksDir: path.join(packageDir, '.claude', 'hooks'),
|
|
2402
|
+
previewLlmKey: llmKey,
|
|
2403
|
+
previewTargetDir: targetDir,
|
|
2404
|
+
previewVoice: draft.voice,
|
|
2405
|
+
previewBashBin: resolveBash(),
|
|
2406
|
+
previewForceProvider: draft.ttsEngine === 'elevenlabs' ? 'elevenlabs' : undefined,
|
|
2407
|
+
});
|
|
2066
2408
|
break;
|
|
2067
2409
|
|
|
2068
2410
|
case 'bgTrack':
|
|
@@ -2150,7 +2492,7 @@ export function createSetupTab(screen, services) {
|
|
|
2150
2492
|
function _openTtsEnginePicker(draft, onDone) {
|
|
2151
2493
|
function _closePicker() {
|
|
2152
2494
|
navigationService?.closeModal();
|
|
2153
|
-
destroyList(
|
|
2495
|
+
destroyList(box, screen);
|
|
2154
2496
|
onDone();
|
|
2155
2497
|
}
|
|
2156
2498
|
navigationService?.openModal(null, _closePicker);
|
|
@@ -2163,36 +2505,81 @@ export function createSetupTab(screen, services) {
|
|
|
2163
2505
|
// Add "(global default)" option at top
|
|
2164
2506
|
items.unshift(' (global default)');
|
|
2165
2507
|
|
|
2166
|
-
|
|
2508
|
+
// Standardized chrome: titled box + pinned top help bar; selecting an engine
|
|
2509
|
+
// is not audible, so no preview hint.
|
|
2510
|
+
const ENGINE_TITLE = selectorTitle('Text To Speech (TTS) Engine');
|
|
2511
|
+
const box = blessed.box({
|
|
2167
2512
|
parent: screen,
|
|
2168
2513
|
top: 'center',
|
|
2169
2514
|
left: 'center',
|
|
2170
2515
|
width: 70,
|
|
2171
|
-
height: Math.min(items.length +
|
|
2516
|
+
height: Math.min(items.length + 5, 17),
|
|
2172
2517
|
border: { type: 'line' },
|
|
2173
2518
|
tags: true,
|
|
2174
|
-
label:
|
|
2519
|
+
label: ENGINE_TITLE,
|
|
2520
|
+
style: { fg: COLORS.labelFg, bg: COLORS.contentBg, border: { fg: 'cyan' } },
|
|
2521
|
+
});
|
|
2522
|
+
box.setFront();
|
|
2523
|
+
blessed.text({
|
|
2524
|
+
parent: box, top: 0, left: 1, right: 1, height: 1, tags: true,
|
|
2525
|
+
content: renderHelpBar([{ key: 'Enter', label: 'select' }, { key: 'Esc', label: 'cancel' }]),
|
|
2526
|
+
style: { bg: COLORS.contentBg },
|
|
2527
|
+
});
|
|
2528
|
+
|
|
2529
|
+
const picker = blessed.list({
|
|
2530
|
+
parent: box,
|
|
2531
|
+
top: 1,
|
|
2532
|
+
left: 0,
|
|
2533
|
+
right: 0,
|
|
2534
|
+
bottom: 0,
|
|
2535
|
+
tags: true,
|
|
2536
|
+
items,
|
|
2175
2537
|
keys: true,
|
|
2176
2538
|
vi: false,
|
|
2177
2539
|
mouse: true,
|
|
2178
2540
|
style: {
|
|
2179
2541
|
fg: COLORS.labelFg,
|
|
2180
2542
|
bg: COLORS.contentBg,
|
|
2181
|
-
border: { fg: 'cyan' },
|
|
2182
2543
|
selected: { bg: 'blue', fg: 'yellow' },
|
|
2183
2544
|
item: { fg: COLORS.labelFg },
|
|
2184
2545
|
},
|
|
2185
2546
|
});
|
|
2186
|
-
picker.setFront();
|
|
2187
|
-
picker.setItems(items);
|
|
2188
2547
|
|
|
2189
2548
|
picker.key(['enter'], () => {
|
|
2190
2549
|
const idx = picker.selected;
|
|
2191
|
-
|
|
2550
|
+
// idx 0 = "(global default)"; idx 1..N map to engines[idx-1]. Guard the
|
|
2551
|
+
// bounds so a stray/out-of-range selection can never read .id of undefined.
|
|
2552
|
+
const engine = idx > 0 ? engines[idx - 1] : null;
|
|
2553
|
+
if (idx > 0 && !engine) { _closePicker(); return; }
|
|
2554
|
+
const selectedEngine = engine ? engine.id : '';
|
|
2555
|
+
// Guard: block selection of non-installed optional engines
|
|
2556
|
+
if (selectedEngine) {
|
|
2557
|
+
const engineStatus = engines.find(e => e.id === selectedEngine);
|
|
2558
|
+
if (engineStatus && !engineStatus.installed && !engineStatus.native) {
|
|
2559
|
+
box.setLabel(` {red-fg} ${engineStatus.name} is not installed — go to Setup > TTS Engines to install {/red-fg} `);
|
|
2560
|
+
screen.render();
|
|
2561
|
+
setTimeout(() => {
|
|
2562
|
+
if (!box.destroyed) {
|
|
2563
|
+
box.setLabel(ENGINE_TITLE);
|
|
2564
|
+
screen.render();
|
|
2565
|
+
}
|
|
2566
|
+
}, 3000);
|
|
2567
|
+
return;
|
|
2568
|
+
}
|
|
2569
|
+
}
|
|
2192
2570
|
draft.ttsEngine = selectedEngine;
|
|
2193
2571
|
// Auto-set voice to native engine canonical ID so the Voice field updates
|
|
2194
2572
|
// immediately. For piper or empty engine, clear to '' (shows global default).
|
|
2195
|
-
|
|
2573
|
+
if (selectedEngine === 'elevenlabs') {
|
|
2574
|
+
// Keep an already-chosen ElevenLabs voice — a known built-in OR any raw
|
|
2575
|
+
// 20-char ElevenLabs voice_id (a custom/library voice the user configured
|
|
2576
|
+
// that isn't in our static list). Otherwise assign the default.
|
|
2577
|
+
const looksLikeElId = /^[A-Za-z0-9]{10,40}$/.test(draft.voice || '');
|
|
2578
|
+
draft.voice = (elevenLabsVoiceName(draft.voice) || looksLikeElId)
|
|
2579
|
+
? draft.voice : ELEVENLABS_DEFAULT_VOICE_ID;
|
|
2580
|
+
} else {
|
|
2581
|
+
draft.voice = NATIVE_ENGINE_VOICES[selectedEngine]?.id || '';
|
|
2582
|
+
}
|
|
2196
2583
|
_closePicker();
|
|
2197
2584
|
});
|
|
2198
2585
|
|
|
@@ -2202,8 +2589,1015 @@ export function createSetupTab(screen, services) {
|
|
|
2202
2589
|
screen.render();
|
|
2203
2590
|
}
|
|
2204
2591
|
|
|
2592
|
+
// ── API Key Warning Popup ─────────────────────────────────────────────────
|
|
2593
|
+
// Non-blocking warning when a cloud TTS provider needs an API key.
|
|
2594
|
+
// Shows on top of whatever is currently open; calls onDismiss() when closed.
|
|
2595
|
+
function _showApiKeyWarning(serviceName, envVarName, keyFilePath, onDismiss) {
|
|
2596
|
+
const warningBox = blessed.box({
|
|
2597
|
+
parent: screen,
|
|
2598
|
+
top: 'center',
|
|
2599
|
+
left: 'center',
|
|
2600
|
+
width: 66,
|
|
2601
|
+
height: 11,
|
|
2602
|
+
border: { type: 'line' },
|
|
2603
|
+
tags: true,
|
|
2604
|
+
label: ` {bold}{yellow-fg} ${serviceName} — API Key Not Detected {/yellow-fg}{/bold} `,
|
|
2605
|
+
style: { fg: COLORS.labelFg, bg: COLORS.contentBg, border: { fg: 'yellow' } },
|
|
2606
|
+
});
|
|
2607
|
+
warningBox.setFront();
|
|
2608
|
+
|
|
2609
|
+
blessed.text({
|
|
2610
|
+
parent: warningBox, top: 1, left: 2, right: 2, tags: true,
|
|
2611
|
+
content: `{yellow-fg}No API key found for ${serviceName}.{/yellow-fg}`,
|
|
2612
|
+
style: { bg: COLORS.contentBg },
|
|
2613
|
+
});
|
|
2614
|
+
blessed.text({
|
|
2615
|
+
parent: warningBox, top: 3, left: 2, right: 2, tags: true,
|
|
2616
|
+
content: `Set it in your shell:\n {cyan-fg}export ${envVarName}=your_key_here{/cyan-fg}`,
|
|
2617
|
+
style: { bg: COLORS.contentBg },
|
|
2618
|
+
});
|
|
2619
|
+
blessed.text({
|
|
2620
|
+
parent: warningBox, top: 6, left: 2, right: 2, tags: true,
|
|
2621
|
+
content: `Or write the key to:\n {cyan-fg}${keyFilePath}{/cyan-fg}`,
|
|
2622
|
+
style: { bg: COLORS.contentBg },
|
|
2623
|
+
});
|
|
2624
|
+
blessed.text({
|
|
2625
|
+
parent: warningBox, bottom: 1, left: 2, right: 2, tags: true,
|
|
2626
|
+
content: '{#9e9e9e-fg}[Enter] or [Esc] to dismiss{/#9e9e9e-fg}',
|
|
2627
|
+
style: { bg: COLORS.contentBg },
|
|
2628
|
+
});
|
|
2629
|
+
|
|
2630
|
+
let _warnClosed = false;
|
|
2631
|
+
function _closeWarning() {
|
|
2632
|
+
if (_warnClosed) return;
|
|
2633
|
+
_warnClosed = true;
|
|
2634
|
+
// Balance the openModal() below so a tab-switch forceCloseAll() that
|
|
2635
|
+
// invokes this callback tears the warning down cleanly instead of
|
|
2636
|
+
// orphaning it over the destroyed picker.
|
|
2637
|
+
navigationService?.closeModal();
|
|
2638
|
+
destroyList(warningBox, screen);
|
|
2639
|
+
onDismiss();
|
|
2640
|
+
}
|
|
2641
|
+
|
|
2642
|
+
warningBox.key(['enter', 'escape', 'space', 'q', 'Q'], _closeWarning);
|
|
2643
|
+
warningBox.on('click', _closeWarning);
|
|
2644
|
+
// Register as a modal so forceCloseAll() dismisses this dialog rather than
|
|
2645
|
+
// leaving it orphaned when the underlying picker is destroyed.
|
|
2646
|
+
navigationService?.openModal(null, _closeWarning);
|
|
2647
|
+
warningBox.focus();
|
|
2648
|
+
screen.render();
|
|
2649
|
+
}
|
|
2650
|
+
|
|
2205
2651
|
// ── Voice picker for LLM config (matches agents-tab pattern) ──────────────
|
|
2206
2652
|
|
|
2653
|
+
// ── Kokoro voice scanner ─────────────────────────────────────────────────
|
|
2654
|
+
// Full static list for Kokoro v0.9.x. Voices not yet cached are downloaded
|
|
2655
|
+
// automatically by the library on first use via huggingface-hub.
|
|
2656
|
+
const _KOKORO_ALL_VOICES = KOKORO_VOICE_IDS;
|
|
2657
|
+
|
|
2658
|
+
function _scanKokoroVoices() {
|
|
2659
|
+
// Determine which voices are already cached locally
|
|
2660
|
+
const cached = new Set();
|
|
2661
|
+
try {
|
|
2662
|
+
const snapshotsDir = path.join(
|
|
2663
|
+
os.homedir(), '.cache', 'huggingface', 'hub',
|
|
2664
|
+
'models--hexgrad--Kokoro-82M', 'snapshots'
|
|
2665
|
+
);
|
|
2666
|
+
for (const snap of fs.readdirSync(snapshotsDir)) {
|
|
2667
|
+
const voicesDir = path.join(snapshotsDir, snap, 'voices');
|
|
2668
|
+
if (!fs.existsSync(voicesDir)) continue;
|
|
2669
|
+
for (const f of fs.readdirSync(voicesDir)) {
|
|
2670
|
+
if (f.endsWith('.pt')) cached.add(f.replace('.pt', ''));
|
|
2671
|
+
}
|
|
2672
|
+
}
|
|
2673
|
+
} catch {}
|
|
2674
|
+
return { voices: _KOKORO_ALL_VOICES, cached };
|
|
2675
|
+
}
|
|
2676
|
+
|
|
2677
|
+
// Kokoro voice-id prefix → { language, gender } so they render as SEPARATE,
|
|
2678
|
+
// identically-styled columns (gender colored) like the other providers.
|
|
2679
|
+
const KOKORO_LANG = {
|
|
2680
|
+
af: { lang: 'en-US', gender: 'Female' }, am: { lang: 'en-US', gender: 'Male' },
|
|
2681
|
+
bf: { lang: 'en-GB', gender: 'Female' }, bm: { lang: 'en-GB', gender: 'Male' },
|
|
2682
|
+
jf: { lang: 'ja', gender: 'Female' }, jm: { lang: 'ja', gender: 'Male' },
|
|
2683
|
+
zf: { lang: 'zh', gender: 'Female' }, zm: { lang: 'zh', gender: 'Male' },
|
|
2684
|
+
ef: { lang: 'es', gender: 'Female' }, em: { lang: 'es', gender: 'Male' },
|
|
2685
|
+
ff: { lang: 'fr', gender: 'Female' }, fm: { lang: 'fr', gender: 'Male' },
|
|
2686
|
+
hf: { lang: 'hi', gender: 'Female' }, hm: { lang: 'hi', gender: 'Male' },
|
|
2687
|
+
pf: { lang: 'pt', gender: 'Female' }, pm: { lang: 'pt', gender: 'Male' },
|
|
2688
|
+
kf: { lang: 'ko', gender: 'Female' }, km: { lang: 'ko', gender: 'Male' },
|
|
2689
|
+
if: { lang: 'it', gender: 'Female' }, im: { lang: 'it', gender: 'Male' },
|
|
2690
|
+
};
|
|
2691
|
+
function _kokoroVoiceMeta(id) {
|
|
2692
|
+
const m = KOKORO_LANG[id.slice(0, 2)] || { lang: id.slice(0, 2), gender: '' };
|
|
2693
|
+
const name = id.slice(3);
|
|
2694
|
+
return { displayName: name.charAt(0).toUpperCase() + name.slice(1), lang: m.lang, gender: m.gender };
|
|
2695
|
+
}
|
|
2696
|
+
|
|
2697
|
+
function _openKokoroVoicePicker(draft, onDone, llmKey = '') {
|
|
2698
|
+
const { voices, cached } = _scanKokoroVoices();
|
|
2699
|
+
|
|
2700
|
+
// Detect the correct Python command for this platform (Windows: python / py, Unix: python3)
|
|
2701
|
+
let _pythonCmd = 'python3';
|
|
2702
|
+
for (const cmd of process.platform === 'win32' ? ['python', 'python3', 'py'] : ['python3', 'python']) {
|
|
2703
|
+
try {
|
|
2704
|
+
if (spawnSync(cmd, ['--version'], { stdio: 'ignore', timeout: 2000 }).status === 0) { // NOSONAR
|
|
2705
|
+
_pythonCmd = cmd;
|
|
2706
|
+
break;
|
|
2707
|
+
}
|
|
2708
|
+
} catch {}
|
|
2709
|
+
}
|
|
2710
|
+
|
|
2711
|
+
// Check Python modules are importable WITHOUT importing them. `import kokoro`
|
|
2712
|
+
// drags in torch, which on Windows cold-start takes 40s+ and blows any short
|
|
2713
|
+
// spawnSync timeout — yielding a false "not installed" and a pointless pip run
|
|
2714
|
+
// that sits at 0% ("Requirement already satisfied"). find_spec is ~instant and
|
|
2715
|
+
// only resolves the module spec, so detection stays reliable. (see line ~209)
|
|
2716
|
+
const _pyHasModules = (mods) =>
|
|
2717
|
+
spawnSync(_pythonCmd, buildPyModuleCheckArgs(mods), // NOSONAR
|
|
2718
|
+
{ stdio: 'ignore', timeout: 5000 }).status === 0;
|
|
2719
|
+
|
|
2720
|
+
// Load favorites from ~/.agentvibes/kokoro-favorites.json
|
|
2721
|
+
let _kokoroFavorites = new Set();
|
|
2722
|
+
try {
|
|
2723
|
+
const favPath = path.join(os.homedir(), '.agentvibes', 'kokoro-favorites.json');
|
|
2724
|
+
_kokoroFavorites = new Set(JSON.parse(fs.readFileSync(favPath, 'utf8')));
|
|
2725
|
+
} catch {}
|
|
2726
|
+
|
|
2727
|
+
let _kClosed = false;
|
|
2728
|
+
let _kPreviewProc = null;
|
|
2729
|
+
let _kTmpWav = null;
|
|
2730
|
+
let _kAnimInterval = null;
|
|
2731
|
+
// Track all spawned download procs (Download-All + CJK/synth fallbacks) so
|
|
2732
|
+
// _killKPreview() can terminate them on close and their exit handlers never
|
|
2733
|
+
// touch a destroyed picker (use-after-destroy crash guard).
|
|
2734
|
+
const _kDlProcs = [];
|
|
2735
|
+
|
|
2736
|
+
// Read SSH config so previews route to the receiver
|
|
2737
|
+
let _sshHost = '', _sshKey = '', _sshPort = '';
|
|
2738
|
+
try {
|
|
2739
|
+
const tcPath = path.join(os.homedir(), '.agentvibes', 'transport-config.json');
|
|
2740
|
+
const tc = JSON.parse(fs.readFileSync(tcPath, 'utf8'));
|
|
2741
|
+
|
|
2742
|
+
// Priority 1: per-LLM remote config (e.g. tc['claude-code'].mode === 'remote')
|
|
2743
|
+
if (llmKey && tc[llmKey]?.mode === 'remote') {
|
|
2744
|
+
_sshHost = tc[llmKey].host || '';
|
|
2745
|
+
_sshKey = tc[llmKey].sshKey || '';
|
|
2746
|
+
_sshPort = String(tc[llmKey].port || '');
|
|
2747
|
+
}
|
|
2748
|
+
|
|
2749
|
+
// Priority 2: global provider is explicitly ssh-remote/agentvibes-receiver
|
|
2750
|
+
if (!_sshHost) {
|
|
2751
|
+
const provFilePath = path.join(os.homedir(), '.claude', 'tts-provider.txt');
|
|
2752
|
+
const globalProv = fs.readFileSync(provFilePath, 'utf8').trim();
|
|
2753
|
+
if (globalProv === 'ssh-remote' || globalProv === 'agentvibes-receiver') {
|
|
2754
|
+
_sshHost = tc[globalProv]?.host || '';
|
|
2755
|
+
_sshKey = tc[globalProv]?.sshKey || '';
|
|
2756
|
+
_sshPort = String(tc[globalProv]?.port || '');
|
|
2757
|
+
}
|
|
2758
|
+
}
|
|
2759
|
+
|
|
2760
|
+
// Priority 3: scan all transport-config entries for any mode=remote entry
|
|
2761
|
+
// (mirrors play-tts-ssh-remote.sh Priority 2b — handles the case where
|
|
2762
|
+
// tts-provider.txt says 'piper' but a per-LLM remote route is configured)
|
|
2763
|
+
if (!_sshHost) {
|
|
2764
|
+
for (const val of Object.values(tc)) {
|
|
2765
|
+
if (val?.mode === 'remote' && val?.host) {
|
|
2766
|
+
_sshHost = val.host;
|
|
2767
|
+
_sshKey = val.sshKey || '';
|
|
2768
|
+
_sshPort = String(val.port || '');
|
|
2769
|
+
break;
|
|
2770
|
+
}
|
|
2771
|
+
}
|
|
2772
|
+
}
|
|
2773
|
+
} catch {}
|
|
2774
|
+
|
|
2775
|
+
// Pre-validate SSH config once so the space handler can branch without re-checking
|
|
2776
|
+
const _validSshHost = Boolean(_sshHost && /^[a-zA-Z0-9][a-zA-Z0-9._@:-]*$/.test(_sshHost));
|
|
2777
|
+
const _validSshKey = Boolean(_sshKey && /^\//.test(_sshKey) && fs.existsSync(_sshKey));
|
|
2778
|
+
// Empty port => omit AGENTVIBES_SSH_PORT so the sender never forces
|
|
2779
|
+
// "-p 22" and an ~/.ssh/config Host alias keeps its real port (defer to ~/.ssh/config).
|
|
2780
|
+
const _validSshPort = Boolean(_sshPort && /^\d+$/.test(_sshPort));
|
|
2781
|
+
|
|
2782
|
+
const IDLE_LABEL = selectorTitle('Voice');
|
|
2783
|
+
|
|
2784
|
+
function _killKPreview() {
|
|
2785
|
+
_stopKSpinner();
|
|
2786
|
+
if (_kAnimInterval) { clearInterval(_kAnimInterval); _kAnimInterval = null; }
|
|
2787
|
+
if (_kPreviewProc) { try { _kPreviewProc.kill(); } catch {} _kPreviewProc = null; }
|
|
2788
|
+
// Kill any in-flight download procs (Download-All + fallbacks) so their
|
|
2789
|
+
// exit handlers don't fire against a destroyed picker.
|
|
2790
|
+
if (_dlAllProc) { try { _dlAllProc.kill(); } catch {} _dlAllProc = null; }
|
|
2791
|
+
while (_kDlProcs.length) {
|
|
2792
|
+
const p = _kDlProcs.pop();
|
|
2793
|
+
if (p) { try { p.kill(); } catch {} }
|
|
2794
|
+
}
|
|
2795
|
+
if (_kTmpWav) { try { fs.unlinkSync(_kTmpWav); } catch {} _kTmpWav = null; }
|
|
2796
|
+
}
|
|
2797
|
+
function _closeKP() {
|
|
2798
|
+
if (_kClosed) return;
|
|
2799
|
+
_kClosed = true;
|
|
2800
|
+
_killKPreview();
|
|
2801
|
+
// _dlAllProc and _dlAllActive are closed via _kClosed check in the dl loop
|
|
2802
|
+
navigationService?.closeModal();
|
|
2803
|
+
destroyList(kBox, screen, onDone);
|
|
2804
|
+
}
|
|
2805
|
+
navigationService?.openModal(null, _closeKP);
|
|
2806
|
+
|
|
2807
|
+
// ✓ = cached locally, ☁ = downloadable, ! = CJK (misaki[lang] missing), ★ = favorited
|
|
2808
|
+
function _isCjkVoice(id) {
|
|
2809
|
+
return ['jf','jm','zf','zm','kf','km'].includes(id.slice(0, 2));
|
|
2810
|
+
}
|
|
2811
|
+
// Returns the misaki extra and the import check for each CJK language group.
|
|
2812
|
+
// ja: misaki[ja] → pyopenjtalk; ko: misaki[ko] → jamo; zh: misaki[zh] → pypinyin
|
|
2813
|
+
function _cjkDeps(id) {
|
|
2814
|
+
const pfx = id.slice(0, 2);
|
|
2815
|
+
if (pfx === 'jf' || pfx === 'jm') return { pkg: 'misaki[ja]', check: 'pyopenjtalk', label: 'Japanese language support' };
|
|
2816
|
+
if (pfx === 'kf' || pfx === 'km') return { pkg: 'misaki[ko]', check: 'jamo', label: 'Korean language support' };
|
|
2817
|
+
if (pfx === 'zf' || pfx === 'zm') return { pkg: 'misaki[zh]', check: 'pypinyin', label: 'Chinese language support' };
|
|
2818
|
+
return { pkg: 'misaki[ja]', check: 'pyopenjtalk', label: 'Japanese language support' };
|
|
2819
|
+
}
|
|
2820
|
+
|
|
2821
|
+
// Check which CJK language groups already have their misaki extras installed.
|
|
2822
|
+
// Runs three quick spawnSync checks at picker-open time so icons are accurate.
|
|
2823
|
+
// Keys are the 2-char voice prefixes (jf/jm, kf/km, zf/zm) so _kokoroItem lookup is direct.
|
|
2824
|
+
const _cjkInstalled = new Set();
|
|
2825
|
+
for (const [pfxes, check] of [
|
|
2826
|
+
[['jf', 'jm'], 'pyopenjtalk'],
|
|
2827
|
+
[['kf', 'km'], 'jamo'],
|
|
2828
|
+
[['zf', 'zm'], 'pypinyin'],
|
|
2829
|
+
]) {
|
|
2830
|
+
if (_pyHasModules([check])) {
|
|
2831
|
+
pfxes.forEach(p => _cjkInstalled.add(p));
|
|
2832
|
+
}
|
|
2833
|
+
}
|
|
2834
|
+
|
|
2835
|
+
// Check if kokoro + soundfile are installed (both required for synthesis)
|
|
2836
|
+
let _kokoroInstalled = _pyHasModules(['kokoro', 'soundfile']);
|
|
2837
|
+
|
|
2838
|
+
function _kokoroItem(id) {
|
|
2839
|
+
const fav = _kokoroFavorites.has(id) ? '{#FFD700-fg}★{/#FFD700-fg}' : ' ';
|
|
2840
|
+
let mark;
|
|
2841
|
+
if (_isCjkVoice(id) && !_cjkInstalled.has(id.slice(0, 2))) mark = '{yellow-fg}!{/yellow-fg}';
|
|
2842
|
+
else if (!_kokoroInstalled) mark = '{yellow-fg}!{/yellow-fg}';
|
|
2843
|
+
else if (cached.has(id)) mark = '{green-fg}✓{/green-fg}';
|
|
2844
|
+
else mark = '{cyan-fg}☁{/cyan-fg}';
|
|
2845
|
+
const m = _kokoroVoiceMeta(id);
|
|
2846
|
+
return formatVoiceRow({ status: `${fav} ${mark}`, name: m.displayName, gender: m.gender, lang: m.lang, detail: id });
|
|
2847
|
+
}
|
|
2848
|
+
const items = voices.map(_kokoroItem);
|
|
2849
|
+
|
|
2850
|
+
// Spinner state for Space-preview animation on the item row
|
|
2851
|
+
const _K_SPIN = ['⠋','⠙','⠹','⠸','⠼','⠴','⠦','⠧','⠇','⠏'];
|
|
2852
|
+
let _kSpinInterval = null;
|
|
2853
|
+
let _kSpinFrame = 0;
|
|
2854
|
+
let _kSpinningIdx = -1;
|
|
2855
|
+
|
|
2856
|
+
function _startKSpinner(listIdx) {
|
|
2857
|
+
_stopKSpinner();
|
|
2858
|
+
_kSpinningIdx = listIdx;
|
|
2859
|
+
_kSpinFrame = 0;
|
|
2860
|
+
_kSpinInterval = setInterval(() => {
|
|
2861
|
+
if (_kClosed) { _stopKSpinner(); return; }
|
|
2862
|
+
const spin = `{cyan-fg}${_K_SPIN[_kSpinFrame++ % _K_SPIN.length]}{/cyan-fg}`;
|
|
2863
|
+
kPicker.setItem(listIdx, `${_kokoroItem(voices[listIdx])} ${spin}`);
|
|
2864
|
+
screen.render();
|
|
2865
|
+
}, 80);
|
|
2866
|
+
}
|
|
2867
|
+
|
|
2868
|
+
function _stopKSpinner() {
|
|
2869
|
+
if (_kSpinInterval) { clearInterval(_kSpinInterval); _kSpinInterval = null; }
|
|
2870
|
+
if (_kSpinningIdx >= 0 && !_kClosed) {
|
|
2871
|
+
kPicker.setItem(_kSpinningIdx, _kokoroItem(voices[_kSpinningIdx]));
|
|
2872
|
+
screen.render();
|
|
2873
|
+
}
|
|
2874
|
+
_kSpinningIdx = -1;
|
|
2875
|
+
}
|
|
2876
|
+
|
|
2877
|
+
const LEGEND_H = 3;
|
|
2878
|
+
const pickerH = Math.min(voices.length + LEGEND_H + 3, 24);
|
|
2879
|
+
|
|
2880
|
+
// Outer box (border + label) — legend is a fixed child, not part of the scroll list
|
|
2881
|
+
const kBox = blessed.box({
|
|
2882
|
+
parent: screen,
|
|
2883
|
+
top: 'center', left: 'center',
|
|
2884
|
+
width: 78, height: pickerH,
|
|
2885
|
+
border: { type: 'line' }, tags: true,
|
|
2886
|
+
label: IDLE_LABEL,
|
|
2887
|
+
style: { fg: COLORS.labelFg, bg: COLORS.contentBg, border: { fg: 'cyan' } },
|
|
2888
|
+
});
|
|
2889
|
+
kBox.setFront();
|
|
2890
|
+
|
|
2891
|
+
// Fixed legend rows — pinned at top so they never scroll away
|
|
2892
|
+
blessed.text({
|
|
2893
|
+
parent: kBox, top: 0, left: 0, right: 0, height: LEGEND_H, tags: true,
|
|
2894
|
+
// Controls split across two rows so they don't wrap in narrow tmux panes;
|
|
2895
|
+
// third row is the symbol legend.
|
|
2896
|
+
content: renderHelpBar([
|
|
2897
|
+
{ key: 'Space', label: 'preview' },
|
|
2898
|
+
{ key: 'Enter', label: 'select' },
|
|
2899
|
+
{ key: 'Esc', label: 'cancel' },
|
|
2900
|
+
]) + '\n' + renderHelpBar([
|
|
2901
|
+
{ key: '*', label: 'favorite' },
|
|
2902
|
+
{ key: 'D', label: 'download all' },
|
|
2903
|
+
]) + '\n{green-fg}✓{/green-fg}{#9e9e9e-fg}=cached {/#9e9e9e-fg}{#FFD700-fg}★{/#FFD700-fg}{#9e9e9e-fg}=fav {/#9e9e9e-fg}{cyan-fg}☁{/cyan-fg}{#9e9e9e-fg}=download {/#9e9e9e-fg}{yellow-fg}!{/yellow-fg}{#9e9e9e-fg}=needs pip install (kokoro or misaki[lang])',
|
|
2904
|
+
style: { bg: COLORS.contentBg },
|
|
2905
|
+
});
|
|
2906
|
+
|
|
2907
|
+
// Scrollable voice list — starts below the legend row
|
|
2908
|
+
const kPicker = blessed.list({
|
|
2909
|
+
parent: kBox,
|
|
2910
|
+
top: LEGEND_H, left: 0, right: 0, bottom: 0,
|
|
2911
|
+
keys: true, vi: true, mouse: true, tags: true,
|
|
2912
|
+
items,
|
|
2913
|
+
scrollable: true,
|
|
2914
|
+
scrollbar: { ch: '|', style: { fg: 'cyan' } },
|
|
2915
|
+
style: {
|
|
2916
|
+
fg: COLORS.labelFg, bg: COLORS.contentBg,
|
|
2917
|
+
selected: { bg: 'green', fg: 'white', bold: true },
|
|
2918
|
+
item: { fg: COLORS.labelFg },
|
|
2919
|
+
},
|
|
2920
|
+
});
|
|
2921
|
+
|
|
2922
|
+
const curIdx = voices.indexOf(draft.voice);
|
|
2923
|
+
if (curIdx >= 0) kPicker.select(curIdx);
|
|
2924
|
+
|
|
2925
|
+
kPicker.key(['enter'], () => {
|
|
2926
|
+
if (voices.length) draft.voice = voices[kPicker.selected];
|
|
2927
|
+
_closeKP();
|
|
2928
|
+
});
|
|
2929
|
+
|
|
2930
|
+
// Generic pip-install dialog + progress modal. onSuccess() is called after a clean exit
|
|
2931
|
+
// to let callers update icons/state; onCancel() is called when the user dismisses.
|
|
2932
|
+
function _promptInstallPkg(pkg, label, voiceId, onSuccess, onCancel) {
|
|
2933
|
+
const dlg = blessed.box({
|
|
2934
|
+
parent: screen,
|
|
2935
|
+
top: 'center', left: 'center',
|
|
2936
|
+
width: 64, height: 11,
|
|
2937
|
+
border: { type: 'line' }, tags: true,
|
|
2938
|
+
label: ` {yellow-fg}⚠ Missing: ${label}{/yellow-fg} `,
|
|
2939
|
+
content: [
|
|
2940
|
+
'',
|
|
2941
|
+
` {white-fg}${voiceId}{/white-fg}{#9e9e9e-fg} needs {/#9e9e9e-fg}{cyan-fg}${pkg}{/cyan-fg}{#9e9e9e-fg} to speak.{/#9e9e9e-fg}`,
|
|
2942
|
+
` {#9e9e9e-fg}Install it now with:{/#9e9e9e-fg}`,
|
|
2943
|
+
` {cyan-fg}pip install ${pkg}{/cyan-fg}`,
|
|
2944
|
+
'',
|
|
2945
|
+
` {#9e9e9e-fg}If using SSH receiver, run pip install there too.{/#9e9e9e-fg}`,
|
|
2946
|
+
'',
|
|
2947
|
+
` {green-fg}[I]{/green-fg}{#9e9e9e-fg}=Install now {/#9e9e9e-fg}{red-fg}[Esc]{/red-fg}{#9e9e9e-fg}=Cancel`,
|
|
2948
|
+
].join('\n'),
|
|
2949
|
+
style: { border: { fg: 'yellow' }, bg: '#1a1a2e' },
|
|
2950
|
+
});
|
|
2951
|
+
dlg.focus();
|
|
2952
|
+
navigationService?.openModal(null, () => { closeDlg(); });
|
|
2953
|
+
screen.render();
|
|
2954
|
+
|
|
2955
|
+
const closeDlg = () => { navigationService?.closeModal(); dlg.destroy(); kPicker.focus(); screen.render(); };
|
|
2956
|
+
|
|
2957
|
+
dlg.key(['escape', 'n', 'N'], () => { closeDlg(); onCancel?.(); });
|
|
2958
|
+
dlg.key(['i', 'I', 'y', 'Y', 'enter'], () => {
|
|
2959
|
+
navigationService?.closeModal();
|
|
2960
|
+
dlg.destroy();
|
|
2961
|
+
|
|
2962
|
+
// ── Single persistent install modal: shows progress during install,
|
|
2963
|
+
// updates to success/failure when done. Enter=OK only after done. ──
|
|
2964
|
+
const _PIP_BAR_W = 22;
|
|
2965
|
+
let _pipPct = 0;
|
|
2966
|
+
let _collecting = 0;
|
|
2967
|
+
let _pipBuf = '';
|
|
2968
|
+
let _pipErrBuf = '';
|
|
2969
|
+
let _mClosed = false;
|
|
2970
|
+
let _installProc = null;
|
|
2971
|
+
|
|
2972
|
+
function _bar(p) {
|
|
2973
|
+
const f = Math.round(p * _PIP_BAR_W / 100);
|
|
2974
|
+
return '{yellow-fg}' + '█'.repeat(f) + '{/yellow-fg}'
|
|
2975
|
+
+ '{#555555-fg}' + '░'.repeat(_PIP_BAR_W - f) + '{/#555555-fg}';
|
|
2976
|
+
}
|
|
2977
|
+
|
|
2978
|
+
const iMod = blessed.box({
|
|
2979
|
+
parent: screen,
|
|
2980
|
+
top: 'center', left: 'center',
|
|
2981
|
+
width: 66, height: 9,
|
|
2982
|
+
border: { type: 'line' }, tags: true,
|
|
2983
|
+
label: ` {yellow-fg}⬇ Installing ${pkg}{/yellow-fg} `,
|
|
2984
|
+
style: { border: { fg: 'yellow' }, bg: '#1a1a2e' },
|
|
2985
|
+
});
|
|
2986
|
+
|
|
2987
|
+
function _setContent(barPct, phase, extra, isDone) {
|
|
2988
|
+
const barStr = ` {yellow-fg}⬇{/yellow-fg} [${_bar(barPct)}] {white-fg}${barPct}%{/white-fg} {#9e9e9e-fg}${phase}{/#9e9e9e-fg}`;
|
|
2989
|
+
const footer = isDone
|
|
2990
|
+
? ` {green-fg}[Enter]{/green-fg}{#9e9e9e-fg} = OK{/#9e9e9e-fg}`
|
|
2991
|
+
: ` {red-fg}[Esc]{/red-fg}{#9e9e9e-fg} = Cancel{/#9e9e9e-fg}`;
|
|
2992
|
+
iMod.setContent([ '', barStr, '', extra, '', footer ].join('\n'));
|
|
2993
|
+
screen.render();
|
|
2994
|
+
}
|
|
2995
|
+
|
|
2996
|
+
_setContent(0, 'starting...', ` {cyan-fg}pip install ${pkg}{/cyan-fg}`, false);
|
|
2997
|
+
iMod.focus();
|
|
2998
|
+
|
|
2999
|
+
const cancelInstall = () => {
|
|
3000
|
+
if (_mClosed) return;
|
|
3001
|
+
_mClosed = true;
|
|
3002
|
+
if (_installProc && !_installProc.killed) { try { _installProc.kill(); } catch {} }
|
|
3003
|
+
navigationService?.closeModal();
|
|
3004
|
+
iMod.destroy();
|
|
3005
|
+
kBox.setLabel(IDLE_LABEL);
|
|
3006
|
+
setTimeout(() => { kPicker.focus(); screen.render(); }, 0);
|
|
3007
|
+
};
|
|
3008
|
+
navigationService?.openModal(null, cancelInstall);
|
|
3009
|
+
iMod.key(['escape'], cancelInstall);
|
|
3010
|
+
screen.render();
|
|
3011
|
+
|
|
3012
|
+
_installProc = spawn(_pythonCmd, ['-m', 'pip', 'install', '--progress-bar', 'on', ...pkg.split(/\s+/)], { // NOSONAR
|
|
3013
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
3014
|
+
});
|
|
3015
|
+
|
|
3016
|
+
_installProc.stdout.on('data', (chunk) => {
|
|
3017
|
+
if (_mClosed) return;
|
|
3018
|
+
_pipBuf += chunk.toString('utf8');
|
|
3019
|
+
const parts = _pipBuf.split(/[\r\n]/);
|
|
3020
|
+
_pipBuf = parts.pop() ?? '';
|
|
3021
|
+
for (const rawLine of parts) {
|
|
3022
|
+
// Strip ANSI escape codes — pip on Windows emits them even on non-TTY
|
|
3023
|
+
const line = rawLine.replace(/\x1b\[[0-9;?]*[A-Za-z]/g, ''); // NOSONAR
|
|
3024
|
+
const dlMatch = line.match(/(\d+(?:\.\d+)?)\s*\/\s*(\d+(?:\.\d+)?)\s*(kB|MB|GB)/i);
|
|
3025
|
+
if (dlMatch) {
|
|
3026
|
+
const done = parseFloat(dlMatch[1]), total = parseFloat(dlMatch[2]);
|
|
3027
|
+
if (total > 0) _pipPct = Math.max(_pipPct, Math.round(25 + (done / total) * 55));
|
|
3028
|
+
_setContent(_pipPct, 'downloading...', ` {cyan-fg}pip install ${pkg}{/cyan-fg}`, false);
|
|
3029
|
+
continue;
|
|
3030
|
+
}
|
|
3031
|
+
const t = line.trim();
|
|
3032
|
+
if (/^Collecting /i.test(t)) {
|
|
3033
|
+
_collecting++;
|
|
3034
|
+
_pipPct = Math.max(_pipPct, Math.min(20, _collecting * 5));
|
|
3035
|
+
_setContent(_pipPct, 'collecting...', ` {cyan-fg}${t}{/cyan-fg}`, false);
|
|
3036
|
+
} else if (/^Requirement already satisfied/i.test(t)) {
|
|
3037
|
+
// Everything already installed — pip emits no Collecting/Downloading,
|
|
3038
|
+
// so without this the bar would freeze at 0% until exit. Show motion.
|
|
3039
|
+
_pipPct = Math.max(_pipPct, 90);
|
|
3040
|
+
_setContent(_pipPct, 'verifying...', ` {#9e9e9e-fg}already satisfied — verifying{/#9e9e9e-fg}`, false);
|
|
3041
|
+
} else if (/Downloading.*\.whl/i.test(t)) {
|
|
3042
|
+
_pipPct = Math.max(_pipPct, 25);
|
|
3043
|
+
_setContent(_pipPct, 'downloading...', ` {cyan-fg}pip install ${pkg}{/cyan-fg}`, false);
|
|
3044
|
+
} else if (/Building|Running setup|running build/i.test(t)) {
|
|
3045
|
+
_pipPct = Math.max(_pipPct, 75);
|
|
3046
|
+
_setContent(_pipPct, 'building...', ` {cyan-fg}compiling ${pkg} from source{/cyan-fg}`, false);
|
|
3047
|
+
} else if (/Installing collected/i.test(t)) {
|
|
3048
|
+
_pipPct = Math.max(_pipPct, 88);
|
|
3049
|
+
_setContent(_pipPct, 'installing...', ` {cyan-fg}pip install ${pkg}{/cyan-fg}`, false);
|
|
3050
|
+
} else if (/Successfully installed/i.test(t)) {
|
|
3051
|
+
_pipPct = 100;
|
|
3052
|
+
_setContent(100, 'done', ` {cyan-fg}pip install ${pkg}{/cyan-fg}`, false);
|
|
3053
|
+
}
|
|
3054
|
+
}
|
|
3055
|
+
});
|
|
3056
|
+
|
|
3057
|
+
_installProc.stderr.on('data', (chunk) => { _pipErrBuf += chunk.toString('utf8'); });
|
|
3058
|
+
|
|
3059
|
+
_installProc.on('exit', (code) => {
|
|
3060
|
+
if (_mClosed) return;
|
|
3061
|
+
const closeOk = () => {
|
|
3062
|
+
if (_mClosed) return;
|
|
3063
|
+
_mClosed = true;
|
|
3064
|
+
navigationService?.closeModal();
|
|
3065
|
+
iMod.destroy();
|
|
3066
|
+
kBox.setLabel(IDLE_LABEL);
|
|
3067
|
+
setTimeout(() => { kPicker.focus(); screen.render(); }, 0);
|
|
3068
|
+
};
|
|
3069
|
+
if (code === 0) {
|
|
3070
|
+
onSuccess?.();
|
|
3071
|
+
iMod.setLabel(` {green-fg}✓ Installed{/green-fg} `);
|
|
3072
|
+
iMod.style.border.fg = 'green';
|
|
3073
|
+
_setContent(100, 'done', ` {green-fg}${pkg}{/green-fg}{#9e9e9e-fg} installed — press Space on {/#9e9e9e-fg}{white-fg}${voiceId}{/white-fg}{#9e9e9e-fg} to preview{/#9e9e9e-fg}`, true);
|
|
3074
|
+
} else {
|
|
3075
|
+
const allOut = _pipBuf + _pipErrBuf;
|
|
3076
|
+
const needsDev = /Python\.h|python3-dev/i.test(allOut);
|
|
3077
|
+
const needsCmake = /cmake not found|cmake.*required/i.test(allOut);
|
|
3078
|
+
let hint;
|
|
3079
|
+
if (needsDev) hint = ` {yellow-fg}Fix:{/yellow-fg} {cyan-fg}sudo apt install python3-dev build-essential{/cyan-fg}`;
|
|
3080
|
+
else if (needsCmake) hint = ` {yellow-fg}Fix:{/yellow-fg} {cyan-fg}sudo apt install cmake{/cyan-fg}`;
|
|
3081
|
+
else hint = ` {#9e9e9e-fg}Run {/#9e9e9e-fg}{cyan-fg}pip install ${pkg}{/cyan-fg}{#9e9e9e-fg} in a terminal to see the error.{/#9e9e9e-fg}`;
|
|
3082
|
+
iMod.setLabel(` {red-fg}✗ Install Failed{/red-fg} `);
|
|
3083
|
+
iMod.style.border.fg = 'red';
|
|
3084
|
+
_setContent(_pipPct, `failed (exit ${code})`, hint, true);
|
|
3085
|
+
}
|
|
3086
|
+
iMod.key(['enter', 'o', 'O'], closeOk);
|
|
3087
|
+
iMod.focus();
|
|
3088
|
+
screen.render();
|
|
3089
|
+
});
|
|
3090
|
+
|
|
3091
|
+
_installProc.on('error', () => {
|
|
3092
|
+
if (_mClosed) return;
|
|
3093
|
+
iMod.setLabel(` {red-fg}✗ pip Not Found{/red-fg} `);
|
|
3094
|
+
iMod.style.border.fg = 'red';
|
|
3095
|
+
_setContent(0, 'error', ` {red-fg}Python not in PATH — install Python 3 first{/red-fg}`, true);
|
|
3096
|
+
const closeOk = () => {
|
|
3097
|
+
if (_mClosed) return;
|
|
3098
|
+
_mClosed = true;
|
|
3099
|
+
navigationService?.closeModal();
|
|
3100
|
+
iMod.destroy();
|
|
3101
|
+
kBox.setLabel(IDLE_LABEL);
|
|
3102
|
+
setTimeout(() => { kPicker.focus(); screen.render(); }, 0);
|
|
3103
|
+
};
|
|
3104
|
+
iMod.key(['enter', 'o', 'O'], closeOk);
|
|
3105
|
+
iMod.focus();
|
|
3106
|
+
screen.render();
|
|
3107
|
+
});
|
|
3108
|
+
});
|
|
3109
|
+
}
|
|
3110
|
+
|
|
3111
|
+
function _promptInstallMisakiLang(voiceId, onCancel) {
|
|
3112
|
+
const { pkg, label } = _cjkDeps(voiceId);
|
|
3113
|
+
_promptInstallPkg(pkg, label, voiceId, () => {
|
|
3114
|
+
const _LANG_PAIRS = { jf: 'jm', jm: 'jf', kf: 'km', km: 'kf', zf: 'zm', zm: 'zf' };
|
|
3115
|
+
const langPfx = voiceId.slice(0, 2);
|
|
3116
|
+
_cjkInstalled.add(langPfx);
|
|
3117
|
+
_cjkInstalled.add(_LANG_PAIRS[langPfx] ?? langPfx);
|
|
3118
|
+
voices.forEach((v, i) => {
|
|
3119
|
+
if (v.slice(0, 2) === langPfx || v.slice(0, 2) === (_LANG_PAIRS[langPfx] ?? langPfx)) {
|
|
3120
|
+
kPicker.setItem(i, _kokoroItem(v));
|
|
3121
|
+
}
|
|
3122
|
+
});
|
|
3123
|
+
}, onCancel);
|
|
3124
|
+
}
|
|
3125
|
+
|
|
3126
|
+
// Instant "bling" the moment a preview starts. Kokoro synthesis (and the SSH
|
|
3127
|
+
// round-trip to a remote receiver) can take ~20s, and silence reads as a hang
|
|
3128
|
+
// — this confirms the preview is on its way. Fire-and-forget: detached,
|
|
3129
|
+
// unref'd, errors swallowed, so it never blocks the UI or fails the preview.
|
|
3130
|
+
// Mirrors the chime heard on incoming remote messages.
|
|
3131
|
+
// Sound: "Ui sounds - Shimmering success" by Philip_Berger, CC0 (freesound
|
|
3132
|
+
// #648212). See .claude/audio/ui/CREDITS.txt. Falls back to a system sound
|
|
3133
|
+
// if the bundled asset is ever missing.
|
|
3134
|
+
const _blingWav = path.join(packageDir, '.claude', 'audio', 'ui', 'bling-success.wav');
|
|
3135
|
+
function _playReadyCue() {
|
|
3136
|
+
try {
|
|
3137
|
+
const { command, args } = buildBlingCommand(process.platform, _blingWav, fs.existsSync(_blingWav));
|
|
3138
|
+
const cue = spawn(command, args, { stdio: 'ignore', detached: true }); // NOSONAR
|
|
3139
|
+
cue.on('error', () => { /* best-effort cue; a spawn failure must not surface */ });
|
|
3140
|
+
cue.unref();
|
|
3141
|
+
} catch { /* the readiness cue is purely cosmetic — never break the preview */ }
|
|
3142
|
+
}
|
|
3143
|
+
|
|
3144
|
+
kPicker.key(['space'], () => {
|
|
3145
|
+
if (!voices.length) return;
|
|
3146
|
+
const voiceId = voices[kPicker.selected];
|
|
3147
|
+
if (_kPreviewProc) {
|
|
3148
|
+
_killKPreview();
|
|
3149
|
+
kBox.setLabel(IDLE_LABEL);
|
|
3150
|
+
screen.render();
|
|
3151
|
+
return;
|
|
3152
|
+
}
|
|
3153
|
+
|
|
3154
|
+
// kokoro package itself must be installed for any voice
|
|
3155
|
+
if (!_kokoroInstalled) {
|
|
3156
|
+
_promptInstallPkg('kokoro soundfile numpy', 'Kokoro TTS engine', voiceId, () => {
|
|
3157
|
+
_kokoroInstalled = true;
|
|
3158
|
+
voices.forEach((v, i) => kPicker.setItem(i, _kokoroItem(v)));
|
|
3159
|
+
});
|
|
3160
|
+
return;
|
|
3161
|
+
}
|
|
3162
|
+
|
|
3163
|
+
// CJK voices need language-specific misaki extras — prompt to install if missing
|
|
3164
|
+
if (_isCjkVoice(voiceId)) {
|
|
3165
|
+
const { check: importCheck } = _cjkDeps(voiceId);
|
|
3166
|
+
if (!_pyHasModules([importCheck])) {
|
|
3167
|
+
_promptInstallMisakiLang(voiceId);
|
|
3168
|
+
return;
|
|
3169
|
+
}
|
|
3170
|
+
}
|
|
3171
|
+
|
|
3172
|
+
// CJK voices require native-language text — English produces silence
|
|
3173
|
+
const _pfx2 = voiceId.slice(0, 2);
|
|
3174
|
+
const phrase = _pfx2 === 'zf' || _pfx2 === 'zm'
|
|
3175
|
+
? '你好,这是 Kokoro 中文语音预览。'
|
|
3176
|
+
: _pfx2 === 'kf' || _pfx2 === 'km'
|
|
3177
|
+
? '안녕하세요, 코코로 한국어 음성 미리보기입니다.'
|
|
3178
|
+
: _pfx2 === 'jf' || _pfx2 === 'jm'
|
|
3179
|
+
? 'こんにちは、これはKokoroの日本語音声プレビューです。'
|
|
3180
|
+
: `Hi, I am the ${voiceId.slice(3)} Kokoro voice.`;
|
|
3181
|
+
|
|
3182
|
+
// Bling now — a real preview is committed (past the install-prompt and
|
|
3183
|
+
// toggle-off early returns). Fires for both local and remote paths.
|
|
3184
|
+
_playReadyCue();
|
|
3185
|
+
|
|
3186
|
+
// ── Remote preview: route through SSH pipeline so receiver plays it ──
|
|
3187
|
+
if (_validSshHost) {
|
|
3188
|
+
_startKSpinner(kPicker.selected);
|
|
3189
|
+
const hookDir = path.join(packageDir, '.claude', 'hooks');
|
|
3190
|
+
const remoteEnv = { ...process.env, CLAUDE_PROJECT_DIR: targetDir, AGENTVIBES_SSH_HOST: _sshHost };
|
|
3191
|
+
if (_validSshKey) remoteEnv.AGENTVIBES_SSH_KEY = _sshKey;
|
|
3192
|
+
if (_validSshPort) remoteEnv.AGENTVIBES_SSH_PORT = _sshPort;
|
|
3193
|
+
let remoteProc;
|
|
3194
|
+
let _remoteStderr = '';
|
|
3195
|
+
try {
|
|
3196
|
+
remoteProc = spawn(resolveBash(), [path.join(hookDir, 'play-tts-ssh-remote.sh'), phrase, voiceId], { // NOSONAR
|
|
3197
|
+
stdio: ['ignore', 'ignore', 'pipe'],
|
|
3198
|
+
env: remoteEnv,
|
|
3199
|
+
});
|
|
3200
|
+
} catch {
|
|
3201
|
+
_stopKSpinner();
|
|
3202
|
+
if (!_kClosed) {
|
|
3203
|
+
kBox.setLabel(` {red-fg}Remote preview failed{/red-fg} `);
|
|
3204
|
+
screen.render();
|
|
3205
|
+
setTimeout(() => { if (!_kClosed) { kBox.setLabel(IDLE_LABEL); screen.render(); } }, 3000);
|
|
3206
|
+
}
|
|
3207
|
+
return;
|
|
3208
|
+
}
|
|
3209
|
+
remoteProc.stderr.on('data', (d) => { _remoteStderr += d.toString(); });
|
|
3210
|
+
_kPreviewProc = remoteProc;
|
|
3211
|
+
remoteProc.on('exit', (code) => {
|
|
3212
|
+
_kPreviewProc = null;
|
|
3213
|
+
_stopKSpinner();
|
|
3214
|
+
if (_kClosed) return;
|
|
3215
|
+
if (code !== 0 && _isCjkVoice(voiceId)) {
|
|
3216
|
+
// CJK voice failed on receiver — needs the language-specific misaki extra there.
|
|
3217
|
+
// Still try to download the .pt file locally so the picker shows ✓.
|
|
3218
|
+
const { pkg: cjkPkg } = _cjkDeps(voiceId);
|
|
3219
|
+
const pyScript = path.join(packageDir, '.claude', 'hooks', 'kokoro-tts.py');
|
|
3220
|
+
const dlProc = spawn(_pythonCmd, [pyScript, '--download-only', voiceId], { // NOSONAR
|
|
3221
|
+
stdio: ['ignore', 'pipe', 'ignore'], env: { ...process.env },
|
|
3222
|
+
});
|
|
3223
|
+
_kDlProcs.push(dlProc);
|
|
3224
|
+
let dlOut = '';
|
|
3225
|
+
dlProc.stdout.on('data', d => { dlOut += d.toString(); });
|
|
3226
|
+
dlProc.on('exit', (dlCode) => {
|
|
3227
|
+
const _i = _kDlProcs.indexOf(dlProc); if (_i >= 0) _kDlProcs.splice(_i, 1);
|
|
3228
|
+
if (_kClosed) return;
|
|
3229
|
+
if (dlCode === 0 && dlOut.trim()) {
|
|
3230
|
+
cached.add(voiceId);
|
|
3231
|
+
const li = voices.indexOf(voiceId);
|
|
3232
|
+
if (li >= 0) { kPicker.setItem(li, _kokoroItem(voiceId)); }
|
|
3233
|
+
}
|
|
3234
|
+
if (!_kClosed) {
|
|
3235
|
+
kBox.setLabel(` {yellow-fg}⚠ ${voiceId}: receiver needs pip install ${cjkPkg}{/yellow-fg} `);
|
|
3236
|
+
screen.render();
|
|
3237
|
+
setTimeout(() => { if (!_kClosed) { kBox.setLabel(IDLE_LABEL); screen.render(); } }, 5000);
|
|
3238
|
+
}
|
|
3239
|
+
});
|
|
3240
|
+
dlProc.on('error', () => {
|
|
3241
|
+
const _i = _kDlProcs.indexOf(dlProc); if (_i >= 0) _kDlProcs.splice(_i, 1);
|
|
3242
|
+
if (!_kClosed) {
|
|
3243
|
+
kBox.setLabel(` {yellow-fg}⚠ ${voiceId}: receiver needs pip install ${cjkPkg}{/yellow-fg} `);
|
|
3244
|
+
screen.render();
|
|
3245
|
+
setTimeout(() => { if (!_kClosed) { kBox.setLabel(IDLE_LABEL); screen.render(); } }, 5000);
|
|
3246
|
+
}
|
|
3247
|
+
});
|
|
3248
|
+
} else {
|
|
3249
|
+
const errLine = _remoteStderr.split('\n').find(l => l.includes('[ERROR]') || l.includes('kex_') || l.includes('Connection'));
|
|
3250
|
+
const label = errLine
|
|
3251
|
+
? ` {red-fg}SSH: ${errLine.slice(0, 50)}{/red-fg} `
|
|
3252
|
+
: code !== 0
|
|
3253
|
+
? ` {red-fg}SSH exit ${code}{/red-fg} `
|
|
3254
|
+
: ` {green-fg}✓ sent→${_sshHost}{/green-fg} `;
|
|
3255
|
+
kBox.setLabel(label);
|
|
3256
|
+
screen.render();
|
|
3257
|
+
setTimeout(() => { if (!_kClosed) { kBox.setLabel(IDLE_LABEL); screen.render(); } }, 3000);
|
|
3258
|
+
}
|
|
3259
|
+
});
|
|
3260
|
+
remoteProc.on('error', () => {
|
|
3261
|
+
_kPreviewProc = null;
|
|
3262
|
+
_stopKSpinner();
|
|
3263
|
+
if (!_kClosed) {
|
|
3264
|
+
kBox.setLabel(` {red-fg}Remote preview failed{/red-fg} `);
|
|
3265
|
+
screen.render();
|
|
3266
|
+
setTimeout(() => { if (!_kClosed) { kBox.setLabel(IDLE_LABEL); screen.render(); } }, 3000);
|
|
3267
|
+
}
|
|
3268
|
+
});
|
|
3269
|
+
return;
|
|
3270
|
+
}
|
|
3271
|
+
|
|
3272
|
+
// ── Local preview: synthesize WAV then play ──────────────────────────
|
|
3273
|
+
const isDownloaded = cached.has(voiceId);
|
|
3274
|
+
|
|
3275
|
+
// ── Download progress bar helpers ──────────────────────────────────────
|
|
3276
|
+
const DL_BAR_W = 22;
|
|
3277
|
+
const DL_SPIN = ['⠋','⠙','⠹','⠸','⠼','⠴','⠦','⠧','⠇','⠏'];
|
|
3278
|
+
let _dlSpinIdx = 0;
|
|
3279
|
+
let _dlRealPct = -1; // -1 = no real % received yet
|
|
3280
|
+
|
|
3281
|
+
function _dlBarLabel(pct) {
|
|
3282
|
+
const filled = Math.round(pct * DL_BAR_W / 100);
|
|
3283
|
+
const bar = '{yellow-fg}' + '█'.repeat(filled) + '{/yellow-fg}'
|
|
3284
|
+
+ '{#555555-fg}' + '░'.repeat(DL_BAR_W - filled) + '{/#555555-fg}';
|
|
3285
|
+
return ` {yellow-fg}☁{/yellow-fg} [${bar}{#9e9e9e-fg}]{/#9e9e9e-fg} {white-fg}${pct}%{/white-fg} {#9e9e9e-fg}${voiceId}{/#9e9e9e-fg} `;
|
|
3286
|
+
}
|
|
3287
|
+
|
|
3288
|
+
function _startDlAnim() {
|
|
3289
|
+
kBox.setLabel(_dlBarLabel(0));
|
|
3290
|
+
screen.render();
|
|
3291
|
+
_kAnimInterval = setInterval(() => {
|
|
3292
|
+
if (_kClosed) { clearInterval(_kAnimInterval); _kAnimInterval = null; return; }
|
|
3293
|
+
if (_dlRealPct >= 0) {
|
|
3294
|
+
// Real progress received — update bar, keep interval running for smooth render
|
|
3295
|
+
kBox.setLabel(_dlBarLabel(_dlRealPct));
|
|
3296
|
+
} else {
|
|
3297
|
+
// No real data yet — spin
|
|
3298
|
+
_dlSpinIdx = (_dlSpinIdx + 1) % DL_SPIN.length;
|
|
3299
|
+
kBox.setLabel(` {yellow-fg}${DL_SPIN[_dlSpinIdx]} Downloading ${voiceId}...{/yellow-fg} `);
|
|
3300
|
+
}
|
|
3301
|
+
screen.render();
|
|
3302
|
+
}, 120);
|
|
3303
|
+
}
|
|
3304
|
+
|
|
3305
|
+
function _stopDlAnim() {
|
|
3306
|
+
if (_kAnimInterval) { clearInterval(_kAnimInterval); _kAnimInterval = null; }
|
|
3307
|
+
}
|
|
3308
|
+
// ──────────────────────────────────────────────────────────────────────
|
|
3309
|
+
|
|
3310
|
+
if (isDownloaded) {
|
|
3311
|
+
_startKSpinner(kPicker.selected);
|
|
3312
|
+
} else {
|
|
3313
|
+
_startDlAnim();
|
|
3314
|
+
}
|
|
3315
|
+
screen.render();
|
|
3316
|
+
|
|
3317
|
+
// Phase 1: synthesize WAV locally with kokoro-tts.py
|
|
3318
|
+
// stderr is captured so we can parse HuggingFace tqdm download progress
|
|
3319
|
+
const pyScript = path.join(packageDir, '.claude', 'hooks', 'kokoro-tts.py');
|
|
3320
|
+
const tmpWav = _secureTempWav('kokoro-preview');
|
|
3321
|
+
_kTmpWav = tmpWav;
|
|
3322
|
+
|
|
3323
|
+
let synthProc;
|
|
3324
|
+
try {
|
|
3325
|
+
synthProc = spawn(_pythonCmd, [pyScript, phrase, voiceId, tmpWav, '1.0'], { // NOSONAR
|
|
3326
|
+
stdio: ['ignore', 'ignore', 'pipe'],
|
|
3327
|
+
env: { ...process.env, CLAUDE_PROJECT_DIR: targetDir },
|
|
3328
|
+
});
|
|
3329
|
+
} catch {
|
|
3330
|
+
_stopDlAnim();
|
|
3331
|
+
_kTmpWav = null;
|
|
3332
|
+
try { fs.unlinkSync(tmpWav); } catch {}
|
|
3333
|
+
if (!_kClosed) {
|
|
3334
|
+
kBox.setLabel(` {red-fg}Preview failed — is Kokoro installed?{/red-fg} `);
|
|
3335
|
+
screen.render();
|
|
3336
|
+
setTimeout(() => { if (!_kClosed) { kBox.setLabel(IDLE_LABEL); screen.render(); } }, 3000);
|
|
3337
|
+
}
|
|
3338
|
+
return;
|
|
3339
|
+
}
|
|
3340
|
+
_kPreviewProc = synthProc;
|
|
3341
|
+
|
|
3342
|
+
// Always drain stderr to prevent pipe-buffer deadlock from PyTorch warnings.
|
|
3343
|
+
// For uncached voices, also parse tqdm download progress lines.
|
|
3344
|
+
let _stderrBuf = '';
|
|
3345
|
+
synthProc.stderr.on('data', (chunk) => {
|
|
3346
|
+
if (_kClosed) return;
|
|
3347
|
+
if (isDownloaded) return; // just drain — no UI update needed
|
|
3348
|
+
_stderrBuf += chunk.toString();
|
|
3349
|
+
// tqdm writes \r-terminated progress on same line; split on \r or \n
|
|
3350
|
+
const parts = _stderrBuf.split(/[\r\n]/);
|
|
3351
|
+
_stderrBuf = parts.pop() ?? '';
|
|
3352
|
+
for (const line of parts) {
|
|
3353
|
+
const m = line.match(/\b(\d{1,3})%\s*\|/);
|
|
3354
|
+
if (m) {
|
|
3355
|
+
_dlRealPct = Math.min(100, parseInt(m[1], 10));
|
|
3356
|
+
}
|
|
3357
|
+
}
|
|
3358
|
+
});
|
|
3359
|
+
|
|
3360
|
+
synthProc.on('exit', (synthCode) => {
|
|
3361
|
+
_stopDlAnim();
|
|
3362
|
+
_stopKSpinner();
|
|
3363
|
+
_kPreviewProc = null;
|
|
3364
|
+
_kTmpWav = null;
|
|
3365
|
+
if (_kClosed) { try { fs.unlinkSync(tmpWav); } catch {} return; }
|
|
3366
|
+
if (synthCode !== 0) {
|
|
3367
|
+
try { fs.unlinkSync(tmpWav); } catch {}
|
|
3368
|
+
kBox.setLabel(` {red-fg}Synthesis failed — run: pip install kokoro{/red-fg} `);
|
|
3369
|
+
screen.render();
|
|
3370
|
+
setTimeout(() => { if (!_kClosed) { kBox.setLabel(IDLE_LABEL); screen.render(); } }, 5000);
|
|
3371
|
+
// For voices where synthesis fails due to missing language deps (exit 3),
|
|
3372
|
+
// try a direct .pt file download so the voice can still be cached (shows ✓)
|
|
3373
|
+
if (synthCode === 3) {
|
|
3374
|
+
const pyScript = path.join(packageDir, '.claude', 'hooks', 'kokoro-tts.py');
|
|
3375
|
+
const dlProc = spawn(_pythonCmd, [pyScript, '--download-only', voiceId], { // NOSONAR
|
|
3376
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
3377
|
+
env: { ...process.env },
|
|
3378
|
+
});
|
|
3379
|
+
_kDlProcs.push(dlProc);
|
|
3380
|
+
let dlOut = '';
|
|
3381
|
+
dlProc.stdout.on('data', d => { dlOut += d.toString(); });
|
|
3382
|
+
dlProc.on('exit', (dlCode) => {
|
|
3383
|
+
const _i = _kDlProcs.indexOf(dlProc); if (_i >= 0) _kDlProcs.splice(_i, 1);
|
|
3384
|
+
if (_kClosed) return;
|
|
3385
|
+
if (dlCode === 0 && dlOut.trim()) {
|
|
3386
|
+
cached.add(voiceId);
|
|
3387
|
+
kPicker.setItems(voices.map(_kokoroItem));
|
|
3388
|
+
if (!_kClosed) { kBox.setLabel(` {green-fg}✓ ${voiceId} cached (synthesis requires extra deps){/green-fg} `); screen.render(); }
|
|
3389
|
+
setTimeout(() => { if (!_kClosed) { kBox.setLabel(IDLE_LABEL); screen.render(); } }, 3000);
|
|
3390
|
+
}
|
|
3391
|
+
});
|
|
3392
|
+
dlProc.on('error', () => { const _i = _kDlProcs.indexOf(dlProc); if (_i >= 0) _kDlProcs.splice(_i, 1); });
|
|
3393
|
+
}
|
|
3394
|
+
return;
|
|
3395
|
+
}
|
|
3396
|
+
// Update icon to ✓ for newly-downloaded voice
|
|
3397
|
+
if (!isDownloaded) {
|
|
3398
|
+
cached.add(voiceId);
|
|
3399
|
+
kPicker.setItem(kPicker.selected, _kokoroItem(voiceId));
|
|
3400
|
+
}
|
|
3401
|
+
|
|
3402
|
+
// Phase 2: play WAV locally (remote destinations handled before synthesis via SSH pipeline)
|
|
3403
|
+
let playProc;
|
|
3404
|
+
try {
|
|
3405
|
+
if (process.platform === 'win32') {
|
|
3406
|
+
// SoundPlayer needs the Windows Forms assembly loaded and native backslash paths
|
|
3407
|
+
const psPath = tmpWav.replace(/'/g, "''");
|
|
3408
|
+
playProc = spawn('powershell', ['-NoProfile', '-Command', // NOSONAR
|
|
3409
|
+
`Add-Type -AssemblyName System.Windows.Forms; ` +
|
|
3410
|
+
`(New-Object System.Media.SoundPlayer('${psPath}')).PlaySync()`,
|
|
3411
|
+
], { stdio: 'pipe', env: { ...process.env } });
|
|
3412
|
+
} else {
|
|
3413
|
+
playProc = spawn(resolveBash(), ['-c', `aplay -q "$1" 2>/dev/null || paplay "$1" 2>/dev/null || true`, '--', tmpWav], { // NOSONAR
|
|
3414
|
+
stdio: 'ignore',
|
|
3415
|
+
env: { ...process.env },
|
|
3416
|
+
});
|
|
3417
|
+
}
|
|
3418
|
+
} catch {
|
|
3419
|
+
try { fs.unlinkSync(tmpWav); } catch {}
|
|
3420
|
+
if (!_kClosed) {
|
|
3421
|
+
kBox.setLabel(` {red-fg}Playback failed{/red-fg} `);
|
|
3422
|
+
screen.render();
|
|
3423
|
+
setTimeout(() => { if (!_kClosed) { kBox.setLabel(IDLE_LABEL); screen.render(); } }, 3000);
|
|
3424
|
+
}
|
|
3425
|
+
return;
|
|
3426
|
+
}
|
|
3427
|
+
_kPreviewProc = playProc;
|
|
3428
|
+
_kTmpWav = tmpWav;
|
|
3429
|
+
_startKSpinner(kPicker.selected);
|
|
3430
|
+
screen.render();
|
|
3431
|
+
playProc.on('exit', (code) => {
|
|
3432
|
+
_kPreviewProc = null;
|
|
3433
|
+
_stopKSpinner();
|
|
3434
|
+
if (_kTmpWav) { try { fs.unlinkSync(_kTmpWav); } catch {} _kTmpWav = null; }
|
|
3435
|
+
if (code !== 0 && !_kClosed) {
|
|
3436
|
+
kBox.setLabel(` {red-fg}Playback failed (exit ${code}){/red-fg} `);
|
|
3437
|
+
screen.render();
|
|
3438
|
+
setTimeout(() => { if (!_kClosed) { kBox.setLabel(IDLE_LABEL); screen.render(); } }, 4000);
|
|
3439
|
+
}
|
|
3440
|
+
});
|
|
3441
|
+
playProc.on('error', () => {
|
|
3442
|
+
_kPreviewProc = null;
|
|
3443
|
+
_stopKSpinner();
|
|
3444
|
+
if (_kTmpWav) { try { fs.unlinkSync(_kTmpWav); } catch {} _kTmpWav = null; }
|
|
3445
|
+
if (!_kClosed) { kBox.setLabel(` {red-fg}Playback failed{/red-fg} `); screen.render(); }
|
|
3446
|
+
});
|
|
3447
|
+
});
|
|
3448
|
+
|
|
3449
|
+
synthProc.on('error', () => {
|
|
3450
|
+
_stopDlAnim();
|
|
3451
|
+
_stopKSpinner();
|
|
3452
|
+
_kPreviewProc = null;
|
|
3453
|
+
_kTmpWav = null;
|
|
3454
|
+
try { fs.unlinkSync(tmpWav); } catch {}
|
|
3455
|
+
if (!_kClosed) {
|
|
3456
|
+
kBox.setLabel(` {red-fg}Python not found — run: pip install kokoro{/red-fg} `);
|
|
3457
|
+
screen.render();
|
|
3458
|
+
setTimeout(() => { if (!_kClosed) { kBox.setLabel(IDLE_LABEL); screen.render(); } }, 5000);
|
|
3459
|
+
}
|
|
3460
|
+
});
|
|
3461
|
+
});
|
|
3462
|
+
|
|
3463
|
+
// Toggle favorite (use '*' for consistency with every other picker)
|
|
3464
|
+
kPicker.key(['*', '+'], () => {
|
|
3465
|
+
const voiceId = voices[kPicker.selected];
|
|
3466
|
+
if (!voiceId) return;
|
|
3467
|
+
const favPath = path.join(os.homedir(), '.agentvibes', 'kokoro-favorites.json');
|
|
3468
|
+
if (_kokoroFavorites.has(voiceId)) {
|
|
3469
|
+
_kokoroFavorites.delete(voiceId);
|
|
3470
|
+
} else {
|
|
3471
|
+
_kokoroFavorites.add(voiceId);
|
|
3472
|
+
}
|
|
3473
|
+
try {
|
|
3474
|
+
fs.mkdirSync(path.join(os.homedir(), '.agentvibes'), { recursive: true });
|
|
3475
|
+
fs.writeFileSync(favPath, JSON.stringify([..._kokoroFavorites], null, 2));
|
|
3476
|
+
} catch {}
|
|
3477
|
+
// Rebuild picker items
|
|
3478
|
+
const newItems = voices.map(id => {
|
|
3479
|
+
return _kokoroItem(id);
|
|
3480
|
+
});
|
|
3481
|
+
kPicker.setItems(newItems);
|
|
3482
|
+
screen.render();
|
|
3483
|
+
});
|
|
3484
|
+
|
|
3485
|
+
// Download All — sequentially downloads every uncached voice
|
|
3486
|
+
let _dlAllActive = false;
|
|
3487
|
+
let _dlAllProc = null;
|
|
3488
|
+
kPicker.key(['d', 'D'], () => {
|
|
3489
|
+
if (_kPreviewProc || _dlAllActive) return;
|
|
3490
|
+
const toDownload = voices.filter(v => !cached.has(v));
|
|
3491
|
+
if (!toDownload.length) {
|
|
3492
|
+
kBox.setLabel(` {green-fg}✓ All ${voices.length} voices already cached{/green-fg} `);
|
|
3493
|
+
setTimeout(() => { if (!_kClosed) { kBox.setLabel(IDLE_LABEL); screen.render(); } }, 2500);
|
|
3494
|
+
screen.render();
|
|
3495
|
+
return;
|
|
3496
|
+
}
|
|
3497
|
+
_dlAllActive = true;
|
|
3498
|
+
let dlIdx = 0;
|
|
3499
|
+
|
|
3500
|
+
function _dlNext() {
|
|
3501
|
+
if (_kClosed || dlIdx >= toDownload.length) {
|
|
3502
|
+
_dlAllActive = false;
|
|
3503
|
+
_dlAllProc = null;
|
|
3504
|
+
if (!_kClosed) {
|
|
3505
|
+
const total = voices.filter(v => cached.has(v)).length;
|
|
3506
|
+
kBox.setLabel(` {green-fg}✓ Download complete — ${total}/${voices.length} cached{/green-fg} `);
|
|
3507
|
+
setTimeout(() => { if (!_kClosed) { kBox.setLabel(IDLE_LABEL); screen.render(); } }, 3000);
|
|
3508
|
+
screen.render();
|
|
3509
|
+
}
|
|
3510
|
+
return;
|
|
3511
|
+
}
|
|
3512
|
+
const voiceId = toDownload[dlIdx];
|
|
3513
|
+
const n = dlIdx + 1;
|
|
3514
|
+
kBox.setLabel(` {yellow-fg}☁ ${n}/${toDownload.length}: ${voiceId}...{/yellow-fg} `);
|
|
3515
|
+
screen.render();
|
|
3516
|
+
|
|
3517
|
+
const pyScript = path.join(packageDir, '.claude', 'hooks', 'kokoro-tts.py');
|
|
3518
|
+
let dlProc;
|
|
3519
|
+
|
|
3520
|
+
if (_isCjkVoice(voiceId)) {
|
|
3521
|
+
// CJK voices: skip synthesis (needs misaki[lang]), just download the .pt file
|
|
3522
|
+
try {
|
|
3523
|
+
dlProc = spawn(_pythonCmd, [pyScript, '--download-only', voiceId], { // NOSONAR
|
|
3524
|
+
stdio: ['ignore', 'pipe', 'ignore'], env: { ...process.env },
|
|
3525
|
+
});
|
|
3526
|
+
} catch { dlIdx++; _dlNext(); return; }
|
|
3527
|
+
_dlAllProc = dlProc;
|
|
3528
|
+
let dlOut = '';
|
|
3529
|
+
dlProc.stdout.on('data', d => { dlOut += d.toString(); });
|
|
3530
|
+
dlProc.on('exit', (code) => {
|
|
3531
|
+
_dlAllProc = null;
|
|
3532
|
+
if (_kClosed) return;
|
|
3533
|
+
if (code === 0 && dlOut.trim()) {
|
|
3534
|
+
cached.add(voiceId);
|
|
3535
|
+
const listIdx = voices.indexOf(voiceId);
|
|
3536
|
+
if (listIdx >= 0) kPicker.setItem(listIdx, _kokoroItem(voiceId));
|
|
3537
|
+
}
|
|
3538
|
+
dlIdx++; _dlNext();
|
|
3539
|
+
});
|
|
3540
|
+
dlProc.on('error', () => { _dlAllProc = null; dlIdx++; _dlNext(); });
|
|
3541
|
+
} else {
|
|
3542
|
+
const tmpWav = _secureTempWav('kokoro-dl');
|
|
3543
|
+
try {
|
|
3544
|
+
dlProc = spawn(_pythonCmd, [pyScript, 'hello', voiceId, tmpWav, '1.0'], { // NOSONAR
|
|
3545
|
+
stdio: ['ignore', 'ignore', 'pipe'],
|
|
3546
|
+
env: { ...process.env, CLAUDE_PROJECT_DIR: targetDir },
|
|
3547
|
+
});
|
|
3548
|
+
} catch {
|
|
3549
|
+
dlIdx++;
|
|
3550
|
+
_dlNext();
|
|
3551
|
+
return;
|
|
3552
|
+
}
|
|
3553
|
+
_dlAllProc = dlProc;
|
|
3554
|
+
|
|
3555
|
+
let _buf = '', _dlPct = -1;
|
|
3556
|
+
const DL_W = 18;
|
|
3557
|
+
dlProc.stderr.on('data', (chunk) => {
|
|
3558
|
+
if (_kClosed) return;
|
|
3559
|
+
_buf += chunk.toString();
|
|
3560
|
+
const parts = _buf.split(/[\r\n]/);
|
|
3561
|
+
_buf = parts.pop() ?? '';
|
|
3562
|
+
for (const line of parts) {
|
|
3563
|
+
const m = line.match(/\b(\d{1,3})%\s*\|/);
|
|
3564
|
+
if (m) {
|
|
3565
|
+
const pct = Math.min(100, parseInt(m[1], 10));
|
|
3566
|
+
if (pct !== _dlPct) {
|
|
3567
|
+
_dlPct = pct;
|
|
3568
|
+
const filled = Math.round(pct * DL_W / 100);
|
|
3569
|
+
const bar = '{yellow-fg}' + '█'.repeat(filled) + '{/yellow-fg}'
|
|
3570
|
+
+ '{#555555-fg}' + '░'.repeat(DL_W - filled) + '{/#555555-fg}';
|
|
3571
|
+
kBox.setLabel(` {yellow-fg}☁ ${n}/${toDownload.length} [${bar}] ${pct}% ${voiceId}{/yellow-fg} `);
|
|
3572
|
+
screen.render();
|
|
3573
|
+
}
|
|
3574
|
+
}
|
|
3575
|
+
}
|
|
3576
|
+
});
|
|
3577
|
+
|
|
3578
|
+
dlProc.on('exit', (code) => {
|
|
3579
|
+
_dlAllProc = null;
|
|
3580
|
+
try { fs.unlinkSync(tmpWav); } catch {}
|
|
3581
|
+
if (_kClosed) return;
|
|
3582
|
+
if (code === 0) {
|
|
3583
|
+
cached.add(voiceId);
|
|
3584
|
+
const listIdx = voices.indexOf(voiceId);
|
|
3585
|
+
if (listIdx >= 0) kPicker.setItem(listIdx, _kokoroItem(voiceId));
|
|
3586
|
+
}
|
|
3587
|
+
dlIdx++;
|
|
3588
|
+
_dlNext();
|
|
3589
|
+
});
|
|
3590
|
+
dlProc.on('error', () => { _dlAllProc = null; try { fs.unlinkSync(tmpWav); } catch {} dlIdx++; _dlNext(); });
|
|
3591
|
+
}
|
|
3592
|
+
}
|
|
3593
|
+
_dlNext();
|
|
3594
|
+
});
|
|
3595
|
+
|
|
3596
|
+
kPicker.key(['escape', 'q', 'Q'], _closeKP);
|
|
3597
|
+
kPicker.focus();
|
|
3598
|
+
screen.render();
|
|
3599
|
+
}
|
|
3600
|
+
|
|
2207
3601
|
function _secureTempWav(prefix) {
|
|
2208
3602
|
const baseDir = process.env.XDG_RUNTIME_DIR || os.tmpdir();
|
|
2209
3603
|
const dir = path.join(baseDir, `agentvibes-${process.getuid?.() ?? 'u'}`);
|
|
@@ -2212,7 +3606,152 @@ export function createSetupTab(screen, services) {
|
|
|
2212
3606
|
return path.join(dir, `${prefix}-${crypto.randomUUID()}.wav`);
|
|
2213
3607
|
}
|
|
2214
3608
|
|
|
3609
|
+
// ElevenLabs voice picker — lists the static built-in premade voices.
|
|
3610
|
+
// Space previews the highlighted voice (real API call via the hook), Enter
|
|
3611
|
+
// selects it (stores the raw voice_id), Escape cancels.
|
|
3612
|
+
function _openElevenLabsVoicePicker(draft, onDone) {
|
|
3613
|
+
let _elClosed = false;
|
|
3614
|
+
let _elPreviewProc = null;
|
|
3615
|
+
|
|
3616
|
+
function _killElPreview() {
|
|
3617
|
+
if (_elPreviewProc) { try { _elPreviewProc.kill(); } catch {} _elPreviewProc = null; }
|
|
3618
|
+
}
|
|
3619
|
+
function _closeEl() {
|
|
3620
|
+
if (_elClosed) return;
|
|
3621
|
+
_elClosed = true;
|
|
3622
|
+
_killElPreview();
|
|
3623
|
+
navigationService?.closeModal();
|
|
3624
|
+
destroyList(elBox, screen, onDone);
|
|
3625
|
+
}
|
|
3626
|
+
|
|
3627
|
+
const _items = ELEVENLABS_VOICES.map(v => formatVoiceRow({ name: v.name, gender: v.gender, lang: v.lang, detail: v.desc }));
|
|
3628
|
+
const _height = Math.min(ELEVENLABS_VOICES.length + 5, Math.max(13, (screen.height || 24) - 4));
|
|
3629
|
+
|
|
3630
|
+
// Standardized chrome: titled box + pinned top help bar; transient
|
|
3631
|
+
// preview/error status shows in the title area.
|
|
3632
|
+
const _defaultHint = selectorTitle('Voice');
|
|
3633
|
+
const elBox = blessed.box({
|
|
3634
|
+
parent: screen,
|
|
3635
|
+
top: 'center',
|
|
3636
|
+
left: 'center',
|
|
3637
|
+
width: 56,
|
|
3638
|
+
height: _height,
|
|
3639
|
+
border: { type: 'line' },
|
|
3640
|
+
tags: true,
|
|
3641
|
+
label: _defaultHint,
|
|
3642
|
+
style: { fg: COLORS.labelFg, bg: COLORS.contentBg, border: { fg: 'cyan' } },
|
|
3643
|
+
});
|
|
3644
|
+
elBox.setFront();
|
|
3645
|
+
blessed.text({
|
|
3646
|
+
parent: elBox, top: 0, left: 1, right: 1, height: 1, tags: true,
|
|
3647
|
+
content: renderHelpBar([
|
|
3648
|
+
{ key: 'Space', label: 'preview' },
|
|
3649
|
+
{ key: 'Enter', label: 'select' },
|
|
3650
|
+
{ key: 'Esc', label: 'cancel' },
|
|
3651
|
+
]),
|
|
3652
|
+
style: { bg: COLORS.contentBg },
|
|
3653
|
+
});
|
|
3654
|
+
|
|
3655
|
+
const elPicker = blessed.list({
|
|
3656
|
+
parent: elBox,
|
|
3657
|
+
top: 1,
|
|
3658
|
+
left: 0,
|
|
3659
|
+
right: 0,
|
|
3660
|
+
bottom: 0,
|
|
3661
|
+
tags: true,
|
|
3662
|
+
items: _items,
|
|
3663
|
+
keys: true,
|
|
3664
|
+
vi: false,
|
|
3665
|
+
mouse: true,
|
|
3666
|
+
scrollbar: { ch: '|', style: { fg: 'cyan' } },
|
|
3667
|
+
style: {
|
|
3668
|
+
fg: COLORS.labelFg,
|
|
3669
|
+
bg: COLORS.contentBg,
|
|
3670
|
+
selected: { bg: 'green', fg: 'white', bold: true },
|
|
3671
|
+
item: { fg: COLORS.labelFg },
|
|
3672
|
+
},
|
|
3673
|
+
});
|
|
3674
|
+
|
|
3675
|
+
// Start on the currently-selected voice if it is one of ours.
|
|
3676
|
+
const _curIdx = ELEVENLABS_VOICES.findIndex(v => v.id === draft.voice);
|
|
3677
|
+
elPicker.select(_curIdx >= 0 ? _curIdx : 0);
|
|
3678
|
+
|
|
3679
|
+
function _setHint(text) { elBox.setLabel(text || _defaultHint); screen.render(); }
|
|
3680
|
+
_setHint(_defaultHint);
|
|
3681
|
+
|
|
3682
|
+
function _previewEl() {
|
|
3683
|
+
if (_elPreviewProc) { // toggle off
|
|
3684
|
+
_killElPreview();
|
|
3685
|
+
_setHint(_defaultHint);
|
|
3686
|
+
return;
|
|
3687
|
+
}
|
|
3688
|
+
const v = ELEVENLABS_VOICES[elPicker.selected];
|
|
3689
|
+
if (!v) return;
|
|
3690
|
+
const elScript = path.join(packageDir, '.claude', 'hooks', 'play-tts-elevenlabs.sh');
|
|
3691
|
+
let proc;
|
|
3692
|
+
try {
|
|
3693
|
+
proc = _spawnAudio(resolveBash(), [elScript, `Hi, I am ${v.name}.`, v.id], { // NOSONAR — local hook on user's PATH
|
|
3694
|
+
stdio: 'ignore',
|
|
3695
|
+
env: { ...process.env, CLAUDE_PROJECT_DIR: targetDir },
|
|
3696
|
+
});
|
|
3697
|
+
} catch (e) {
|
|
3698
|
+
_setHint(' {red-fg}Preview failed to start{/red-fg} ');
|
|
3699
|
+
return;
|
|
3700
|
+
}
|
|
3701
|
+
_elPreviewProc = proc;
|
|
3702
|
+
_setHint(` {cyan-fg}♪ ${v.name}... (Space=stop){/cyan-fg} `);
|
|
3703
|
+
proc.on('exit', (code) => {
|
|
3704
|
+
_elPreviewProc = null;
|
|
3705
|
+
if (_elClosed) return;
|
|
3706
|
+
if (code && code !== 0) {
|
|
3707
|
+
_setHint(' {red-fg}Preview failed — check API key / plan{/red-fg} ');
|
|
3708
|
+
setTimeout(() => { if (!_elClosed) _setHint(_defaultHint); }, 3000);
|
|
3709
|
+
} else {
|
|
3710
|
+
_setHint(_defaultHint);
|
|
3711
|
+
}
|
|
3712
|
+
});
|
|
3713
|
+
proc.on('error', () => { _elPreviewProc = null; if (!_elClosed) _setHint(' {red-fg}Preview failed{/red-fg} '); });
|
|
3714
|
+
}
|
|
3715
|
+
|
|
3716
|
+
elPicker.key(['enter'], () => {
|
|
3717
|
+
const v = ELEVENLABS_VOICES[elPicker.selected];
|
|
3718
|
+
if (v) draft.voice = v.id;
|
|
3719
|
+
_closeEl();
|
|
3720
|
+
});
|
|
3721
|
+
elPicker.key(['space'], _previewEl);
|
|
3722
|
+
elPicker.key(['escape', 'q', 'Q'], _closeEl);
|
|
3723
|
+
|
|
3724
|
+
navigationService?.openModal(null, _closeEl);
|
|
3725
|
+
elPicker.focus();
|
|
3726
|
+
screen.render();
|
|
3727
|
+
|
|
3728
|
+
// Warn (non-blocking) if no API key is configured yet.
|
|
3729
|
+
const _elKeySet = Boolean(process.env.ELEVENLABS_API_KEY) || (() => {
|
|
3730
|
+
try {
|
|
3731
|
+
const kf = path.join(os.homedir(), '.agentvibes', 'elevenlabs-key.txt');
|
|
3732
|
+
return fs.existsSync(kf) && fs.readFileSync(kf, 'utf8').trim().length > 0;
|
|
3733
|
+
} catch { return false; }
|
|
3734
|
+
})();
|
|
3735
|
+
if (!_elKeySet) {
|
|
3736
|
+
_showApiKeyWarning('ElevenLabs', 'ELEVENLABS_API_KEY',
|
|
3737
|
+
path.join(os.homedir(), '.agentvibes', 'elevenlabs-key.txt'),
|
|
3738
|
+
() => { if (!_elClosed) { elPicker.focus(); screen.render(); } });
|
|
3739
|
+
}
|
|
3740
|
+
}
|
|
3741
|
+
|
|
2215
3742
|
function _openVoicePickerForLlm(draft, onDone, llmKey = '') {
|
|
3743
|
+
// Kokoro has its own multi-voice picker (voices scanned from HF cache)
|
|
3744
|
+
if (draft.ttsEngine === 'kokoro') {
|
|
3745
|
+
_openKokoroVoicePicker(draft, onDone, llmKey);
|
|
3746
|
+
return;
|
|
3747
|
+
}
|
|
3748
|
+
|
|
3749
|
+
// ElevenLabs has its own multi-voice picker (static built-in premade list)
|
|
3750
|
+
if (draft.ttsEngine === 'elevenlabs') {
|
|
3751
|
+
_openElevenLabsVoicePicker(draft, onDone);
|
|
3752
|
+
return;
|
|
3753
|
+
}
|
|
3754
|
+
|
|
2216
3755
|
navigationService?.openModal(null, _closeVP);
|
|
2217
3756
|
|
|
2218
3757
|
let _allVoices = [];
|
|
@@ -2271,37 +3810,47 @@ export function createSetupTab(screen, services) {
|
|
|
2271
3810
|
_killNvPreview();
|
|
2272
3811
|
if (_nvEnsureAbort) { _nvEnsureAbort.abort(); _nvEnsureAbort = null; }
|
|
2273
3812
|
navigationService?.closeModal();
|
|
2274
|
-
destroyList(
|
|
3813
|
+
destroyList(nvBox, screen, onDone);
|
|
2275
3814
|
}
|
|
2276
3815
|
|
|
2277
|
-
const
|
|
3816
|
+
const NV_TITLE = selectorTitle('Voice');
|
|
3817
|
+
const NV_HELP = renderHelpBar([{ key: 'Space', label: 'preview' }, { key: 'Enter', label: 'select' }, { key: 'Esc', label: 'cancel' }]);
|
|
3818
|
+
// Standardized chrome: titled box + pinned top help bar; the single voice
|
|
3819
|
+
// row sits below. Status messages show in the title area.
|
|
3820
|
+
const nvBox = blessed.box({
|
|
2278
3821
|
parent: screen,
|
|
2279
|
-
top: 'center',
|
|
2280
|
-
|
|
2281
|
-
|
|
2282
|
-
|
|
2283
|
-
border: {
|
|
3822
|
+
top: 'center', left: 'center',
|
|
3823
|
+
width: 52, height: 7,
|
|
3824
|
+
border: { type: 'line' }, tags: true,
|
|
3825
|
+
label: NV_TITLE,
|
|
3826
|
+
style: { fg: COLORS.labelFg, bg: COLORS.contentBg, border: { fg: 'cyan' } },
|
|
3827
|
+
});
|
|
3828
|
+
nvBox.setFront();
|
|
3829
|
+
blessed.text({
|
|
3830
|
+
parent: nvBox, top: 0, left: 1, right: 1, height: 1, tags: true,
|
|
3831
|
+
content: NV_HELP, style: { bg: COLORS.contentBg },
|
|
3832
|
+
});
|
|
3833
|
+
const nvPicker = blessed.list({
|
|
3834
|
+
parent: nvBox,
|
|
3835
|
+
top: 1, left: 0, right: 0, bottom: 0,
|
|
2284
3836
|
tags: true,
|
|
2285
|
-
label: ' {bold}{cyan-fg} Select Voice {/cyan-fg}{/bold} ',
|
|
2286
3837
|
keys: true,
|
|
2287
3838
|
vi: false,
|
|
2288
3839
|
mouse: true,
|
|
2289
3840
|
style: {
|
|
2290
3841
|
fg: COLORS.labelFg,
|
|
2291
3842
|
bg: COLORS.contentBg,
|
|
2292
|
-
border: { fg: 'cyan' },
|
|
2293
3843
|
selected: { bg: 'green', fg: 'white', bold: true },
|
|
2294
3844
|
item: { fg: COLORS.labelFg },
|
|
2295
3845
|
},
|
|
2296
3846
|
});
|
|
2297
|
-
nvPicker.
|
|
2298
|
-
nvPicker.setItems([` ${nativeVoice.label} {gray-fg}[Space] preview [Enter] select{/gray-fg}`]);
|
|
3847
|
+
nvPicker.setItems([` ${nativeVoice.label}`]);
|
|
2299
3848
|
nvPicker.select(0);
|
|
2300
3849
|
|
|
2301
3850
|
function _previewNativeVoice() {
|
|
2302
3851
|
if (_nvPreviewProc) {
|
|
2303
3852
|
_killNvPreview();
|
|
2304
|
-
|
|
3853
|
+
nvBox.setLabel(NV_TITLE);
|
|
2305
3854
|
screen.render();
|
|
2306
3855
|
return;
|
|
2307
3856
|
}
|
|
@@ -2310,44 +3859,44 @@ export function createSetupTab(screen, services) {
|
|
|
2310
3859
|
|
|
2311
3860
|
function _spawnAndTrack(cmd, args, opts) {
|
|
2312
3861
|
let proc;
|
|
2313
|
-
try { proc =
|
|
3862
|
+
try { proc = _spawnAudio(cmd, args, opts); } catch (e) {
|
|
2314
3863
|
process.stderr.write(`[AgentVibes] preview spawn failed: ${e.message}\n`);
|
|
2315
|
-
if (!_nvClosed) {
|
|
3864
|
+
if (!_nvClosed) { nvBox.setLabel(' {red-fg}Engine not installed{/red-fg} '); screen.render(); }
|
|
2316
3865
|
return;
|
|
2317
3866
|
}
|
|
2318
3867
|
_nvPreviewProc = proc;
|
|
2319
|
-
|
|
3868
|
+
nvBox.setLabel(` {cyan-fg}♪ ${nativeVoice.label}... (Space=stop){/cyan-fg} `);
|
|
2320
3869
|
screen.render();
|
|
2321
3870
|
proc.on('exit', (code) => {
|
|
2322
3871
|
_nvPreviewProc = null;
|
|
2323
3872
|
if (!_nvClosed) {
|
|
2324
3873
|
if (code !== 0 && code !== null) {
|
|
2325
|
-
|
|
2326
|
-
setTimeout(() => { if (!_nvClosed) {
|
|
3874
|
+
nvBox.setLabel(` {red-fg}Preview failed (exit ${code}){/red-fg} `);
|
|
3875
|
+
setTimeout(() => { if (!_nvClosed) { nvBox.setLabel(NV_TITLE); screen.render(); } }, 3000);
|
|
2327
3876
|
} else {
|
|
2328
|
-
|
|
3877
|
+
nvBox.setLabel(NV_TITLE);
|
|
2329
3878
|
}
|
|
2330
3879
|
screen.render();
|
|
2331
3880
|
}
|
|
2332
3881
|
});
|
|
2333
3882
|
proc.on('error', () => {
|
|
2334
3883
|
_nvPreviewProc = null;
|
|
2335
|
-
if (!_nvClosed) {
|
|
3884
|
+
if (!_nvClosed) { nvBox.setLabel(' {red-fg}Engine not installed{/red-fg} '); screen.render(); }
|
|
2336
3885
|
});
|
|
2337
3886
|
}
|
|
2338
3887
|
|
|
2339
3888
|
if (engine === 'soprano' && process.platform === 'win32' && !process.env.WSL_DISTRO_NAME) {
|
|
2340
3889
|
// Ensure soprano WebUI is running before preview; start it if not.
|
|
2341
|
-
|
|
3890
|
+
nvBox.setLabel(' {cyan-fg}Checking Soprano...{/cyan-fg} ');
|
|
2342
3891
|
screen.render();
|
|
2343
3892
|
_nvEnsureAbort = new AbortController();
|
|
2344
3893
|
_ensureSopranoWebUI((msg) => {
|
|
2345
|
-
if (!_nvClosed) {
|
|
3894
|
+
if (!_nvClosed) { nvBox.setLabel(` {cyan-fg}${msg}{/cyan-fg} `); screen.render(); }
|
|
2346
3895
|
}, _nvEnsureAbort.signal).then((ready) => {
|
|
2347
3896
|
_nvEnsureAbort = null;
|
|
2348
3897
|
if (_nvClosed) return;
|
|
2349
3898
|
if (!ready) {
|
|
2350
|
-
|
|
3899
|
+
nvBox.setLabel(' {red-fg}Soprano WebUI failed to start{/red-fg} ');
|
|
2351
3900
|
screen.render();
|
|
2352
3901
|
return;
|
|
2353
3902
|
}
|
|
@@ -2360,30 +3909,36 @@ export function createSetupTab(screen, services) {
|
|
|
2360
3909
|
let proc = null;
|
|
2361
3910
|
try {
|
|
2362
3911
|
if (engine === 'soprano') {
|
|
2363
|
-
proc =
|
|
3912
|
+
proc = _spawnAudio('soprano', [phrase], { stdio: 'ignore' }); // NOSONAR
|
|
2364
3913
|
} else if (engine === 'sapi') {
|
|
2365
3914
|
const safePhrase = phrase.replace(/'/g, "''");
|
|
2366
3915
|
const sapiScript = `Add-Type -AssemblyName System.Speech; (New-Object System.Speech.Synthesis.SpeechSynthesizer).Speak('${safePhrase}')`;
|
|
2367
|
-
proc =
|
|
3916
|
+
proc = _spawnAudio('powershell', ['-NoProfile', '-Command', sapiScript], { stdio: 'ignore', windowsHide: true }); // NOSONAR
|
|
2368
3917
|
} else if (engine === 'macos-say') {
|
|
2369
|
-
proc =
|
|
3918
|
+
proc = _spawnAudio('say', [phrase], { stdio: 'ignore' }); // NOSONAR
|
|
3919
|
+
} else if (engine.startsWith('elevenlabs')) {
|
|
3920
|
+
const elScript = path.join(packageDir, '.claude', 'hooks', 'play-tts-elevenlabs.sh');
|
|
3921
|
+
proc = _spawnAudio(resolveBash(), [elScript, phrase, 'Rachel'], { // NOSONAR
|
|
3922
|
+
stdio: 'ignore',
|
|
3923
|
+
env: { ...process.env, CLAUDE_PROJECT_DIR: targetDir },
|
|
3924
|
+
});
|
|
2370
3925
|
}
|
|
2371
3926
|
} catch {}
|
|
2372
3927
|
if (!proc) {
|
|
2373
|
-
|
|
3928
|
+
nvBox.setLabel(' {red-fg}Engine not installed{/red-fg} ');
|
|
2374
3929
|
screen.render();
|
|
2375
3930
|
return;
|
|
2376
3931
|
}
|
|
2377
3932
|
_nvPreviewProc = proc;
|
|
2378
|
-
|
|
3933
|
+
nvBox.setLabel(` {cyan-fg}♪ ${nativeVoice.label}... (Space=stop){/cyan-fg} `);
|
|
2379
3934
|
screen.render();
|
|
2380
3935
|
proc.on('exit', () => {
|
|
2381
3936
|
_nvPreviewProc = null;
|
|
2382
|
-
if (!_nvClosed) {
|
|
3937
|
+
if (!_nvClosed) { nvBox.setLabel(NV_TITLE); screen.render(); }
|
|
2383
3938
|
});
|
|
2384
3939
|
proc.on('error', () => {
|
|
2385
3940
|
_nvPreviewProc = null;
|
|
2386
|
-
if (!_nvClosed) {
|
|
3941
|
+
if (!_nvClosed) { nvBox.setLabel(' {red-fg}Engine not installed{/red-fg} '); screen.render(); }
|
|
2387
3942
|
});
|
|
2388
3943
|
}
|
|
2389
3944
|
|
|
@@ -2392,6 +3947,25 @@ export function createSetupTab(screen, services) {
|
|
|
2392
3947
|
nvPicker.key(['escape', 'q', 'Q'], _closeNV);
|
|
2393
3948
|
nvPicker.focus();
|
|
2394
3949
|
screen.render();
|
|
3950
|
+
|
|
3951
|
+
// ElevenLabs: if no API key is set, show a warning on top of the picker.
|
|
3952
|
+
// The picker stays open; user dismisses the warning and can still select/preview.
|
|
3953
|
+
if (draft.ttsEngine === 'elevenlabs') {
|
|
3954
|
+
const _elKeySet = Boolean(process.env.ELEVENLABS_API_KEY) || (() => {
|
|
3955
|
+
try {
|
|
3956
|
+
const kf = path.join(os.homedir(), '.agentvibes', 'elevenlabs-key.txt');
|
|
3957
|
+
return fs.existsSync(kf) && fs.readFileSync(kf, 'utf8').trim().length > 0;
|
|
3958
|
+
} catch { return false; }
|
|
3959
|
+
})();
|
|
3960
|
+
if (!_elKeySet) {
|
|
3961
|
+
_showApiKeyWarning(
|
|
3962
|
+
'ElevenLabs',
|
|
3963
|
+
'ELEVENLABS_API_KEY',
|
|
3964
|
+
path.join(os.homedir(), '.agentvibes', 'elevenlabs-key.txt'),
|
|
3965
|
+
() => { if (!_nvClosed) { nvPicker.focus(); screen.render(); } }
|
|
3966
|
+
);
|
|
3967
|
+
}
|
|
3968
|
+
}
|
|
2395
3969
|
return;
|
|
2396
3970
|
}
|
|
2397
3971
|
|
|
@@ -2403,22 +3977,33 @@ export function createSetupTab(screen, services) {
|
|
|
2403
3977
|
height: '88%',
|
|
2404
3978
|
border: { type: 'line' },
|
|
2405
3979
|
tags: true,
|
|
2406
|
-
label: '
|
|
3980
|
+
label: selectorTitle('Voice'),
|
|
2407
3981
|
style: { fg: COLORS.labelFg, bg: COLORS.contentBg, border: { fg: 'cyan' } },
|
|
2408
3982
|
});
|
|
2409
3983
|
vpModal.setFront();
|
|
2410
3984
|
|
|
2411
|
-
//
|
|
2412
|
-
|
|
2413
|
-
|
|
3985
|
+
// Standardized help bar, pinned at the top (under the title).
|
|
3986
|
+
blessed.text({
|
|
3987
|
+
parent: vpModal, top: 0, left: 2, right: 2, height: 1, tags: true,
|
|
3988
|
+
content: renderHelpBar([
|
|
3989
|
+
{ key: 'Space', label: 'preview' },
|
|
3990
|
+
{ key: 'Enter', label: 'select' },
|
|
3991
|
+
{ key: '+', label: 'like' },
|
|
3992
|
+
{ key: '-', label: 'dislike' },
|
|
3993
|
+
{ key: 'Esc', label: 'cancel' },
|
|
3994
|
+
]),
|
|
3995
|
+
style: { bg: COLORS.contentBg },
|
|
3996
|
+
});
|
|
3997
|
+
|
|
3998
|
+
// Column header — matches the shared formatVoiceRow column layout.
|
|
2414
3999
|
blessed.text({
|
|
2415
|
-
parent: vpModal, top:
|
|
2416
|
-
content:
|
|
4000
|
+
parent: vpModal, top: 2, left: 3, tags: true,
|
|
4001
|
+
content: voiceRowHeader(),
|
|
2417
4002
|
style: { bg: COLORS.contentBg },
|
|
2418
4003
|
});
|
|
2419
4004
|
|
|
2420
4005
|
const vpList = blessed.list({
|
|
2421
|
-
parent: vpModal, top:
|
|
4006
|
+
parent: vpModal, top: 3, left: 2, right: 2, bottom: 5,
|
|
2422
4007
|
keys: true, vi: true, mouse: true,
|
|
2423
4008
|
border: { type: 'line' },
|
|
2424
4009
|
scrollbar: { ch: '|', style: { fg: 'cyan' } },
|
|
@@ -2436,15 +4021,15 @@ export function createSetupTab(screen, services) {
|
|
|
2436
4021
|
content: ' ', style: { fg: 'cyan', bg: COLORS.contentBg },
|
|
2437
4022
|
});
|
|
2438
4023
|
|
|
2439
|
-
//
|
|
2440
|
-
|
|
2441
|
-
parent: vpModal, bottom: 3, left: 2, right: 2, height: 1, tags: true,
|
|
2442
|
-
content: '{white-fg}[↑↓] Nav [PgUp/PgDn] Page [a-z] Jump{/white-fg}',
|
|
2443
|
-
style: { bg: COLORS.contentBg },
|
|
2444
|
-
});
|
|
4024
|
+
// Movement hints at the bottom (primary actions live in the top help bar, so
|
|
4025
|
+
// they are not duplicated here). Standardized [key] = label formatting.
|
|
2445
4026
|
blessed.text({
|
|
2446
4027
|
parent: vpModal, bottom: 2, left: 2, right: 2, height: 1, tags: true,
|
|
2447
|
-
content:
|
|
4028
|
+
content: renderHelpBar([
|
|
4029
|
+
{ key: '↑↓', label: 'move' },
|
|
4030
|
+
{ key: 'PgUp/PgDn', label: 'page' },
|
|
4031
|
+
{ key: 'a-z', label: 'jump' },
|
|
4032
|
+
]),
|
|
2448
4033
|
style: { bg: COLORS.contentBg },
|
|
2449
4034
|
});
|
|
2450
4035
|
|
|
@@ -2459,11 +4044,10 @@ export function createSetupTab(screen, services) {
|
|
|
2459
4044
|
const dot = isPrev ? '♪' : (isActive ? '●' : ' ');
|
|
2460
4045
|
const star = isUp ? '{green-fg}👍{/green-fg}' : (isDown ? '{red-fg}👎{/red-fg}' : ' ');
|
|
2461
4046
|
const meta = getVoiceMeta(v);
|
|
2462
|
-
|
|
2463
|
-
|
|
2464
|
-
|
|
2465
|
-
|
|
2466
|
-
return ` ${dot}${star} ${name}${genderIconTag(meta.gender)} ${meta.provider}`;
|
|
4047
|
+
// Derive the language tag from the Piper voice id prefix (e.g. en_US-… → en-US).
|
|
4048
|
+
const _lm = v.match(/^([a-z]{2})_([A-Z]{2})/);
|
|
4049
|
+
const lang = _lm ? `${_lm[1]}-${_lm[2]}` : '';
|
|
4050
|
+
return formatVoiceRow({ status: `${dot}${star}`, name: meta.displayName, gender: meta.gender, lang, detail: meta.provider });
|
|
2467
4051
|
});
|
|
2468
4052
|
}
|
|
2469
4053
|
|
|
@@ -2519,13 +4103,13 @@ export function createSetupTab(screen, services) {
|
|
|
2519
4103
|
if (_isWin) {
|
|
2520
4104
|
const _playTts = path.join(_hooksBase, '.claude', 'hooks-windows', 'play-tts.ps1');
|
|
2521
4105
|
const _llmArgs = llmKey ? ['-llm', llmKey] : [];
|
|
2522
|
-
rProc = spawn('powershell', ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', _playTts, phrase, voiceId, ..._llmArgs], {
|
|
4106
|
+
rProc = spawn('powershell', ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', _playTts, phrase, voiceId, ..._llmArgs], { // NOSONAR
|
|
2523
4107
|
stdio: 'ignore', detached: false, windowsHide: true, env: _rEnv,
|
|
2524
4108
|
});
|
|
2525
4109
|
} else {
|
|
2526
4110
|
const _playTts = path.join(_hooksBase, '.claude', 'hooks', 'play-tts.sh');
|
|
2527
4111
|
const _llmArgs = llmKey ? ['--llm', llmKey] : [];
|
|
2528
|
-
rProc = spawn(
|
|
4112
|
+
rProc = spawn(resolveBash(), [_playTts, phrase, voiceId, ..._llmArgs], { // NOSONAR
|
|
2529
4113
|
stdio: 'ignore', detached: true, env: _rEnv, cwd: targetDir,
|
|
2530
4114
|
});
|
|
2531
4115
|
}
|
|
@@ -2562,7 +4146,7 @@ export function createSetupTab(screen, services) {
|
|
|
2562
4146
|
|
|
2563
4147
|
const args = ['--model', voicePath, '--output_file', tempWav];
|
|
2564
4148
|
if (_ms.speakerId != null) args.push('--speaker', String(_ms.speakerId));
|
|
2565
|
-
const piper =
|
|
4149
|
+
const piper = _spawnAudio(_piperBin, args, {
|
|
2566
4150
|
stdio: ['pipe', 'ignore', 'ignore'],
|
|
2567
4151
|
detached: !_isWin,
|
|
2568
4152
|
windowsHide: true,
|
|
@@ -3175,17 +4759,19 @@ export function createSetupTab(screen, services) {
|
|
|
3175
4759
|
const ok = () => `{green-fg}OK ${t(_getLang(), 'installed')}{/green-fg}`;
|
|
3176
4760
|
const bad = () => `{red-fg}X ${t(_getLang(), 'notFound')}{/red-fg}`;
|
|
3177
4761
|
|
|
3178
|
-
const ttsOk = _deps.piper || _deps.soprano;
|
|
4762
|
+
const ttsOk = _deps.piper || _deps.soprano || _deps.kokoro || _deps.elevenlabs;
|
|
3179
4763
|
contentBox.setContent(_c([
|
|
3180
4764
|
_HDR('', t(_getLang(), 'dependencyCheck')),
|
|
3181
4765
|
'',
|
|
3182
|
-
` {white-fg}${'Dependency'.padEnd(
|
|
4766
|
+
` {white-fg}${'Dependency'.padEnd(16)}${'Status'}{/white-fg}`,
|
|
3183
4767
|
` {white-fg}${'---'.repeat(26)}{/white-fg}`,
|
|
3184
|
-
` {white-fg}${'Node.js'.padEnd(
|
|
3185
|
-
` {white-fg}${'npm'.padEnd(
|
|
3186
|
-
` {white-fg}${'Piper TTS'.padEnd(
|
|
3187
|
-
` {white-fg}${'
|
|
3188
|
-
` {white-fg}${'
|
|
4768
|
+
` {white-fg}${'Node.js'.padEnd(16)}{/white-fg}${_deps.node ? ok() : bad()}`,
|
|
4769
|
+
` {white-fg}${'npm'.padEnd(16)}{/white-fg}${_deps.npm ? ok() : bad()}`,
|
|
4770
|
+
` {white-fg}${'Piper TTS'.padEnd(16)}{/white-fg}${_deps.piper ? ok() : bad()}`,
|
|
4771
|
+
` {white-fg}${'Kokoro TTS'.padEnd(16)}{/white-fg}${_deps.kokoro ? ok() : `{#546e7a-fg}- optional{/#546e7a-fg}`}`,
|
|
4772
|
+
` {white-fg}${'ElevenLabs'.padEnd(16)}{/white-fg}${_deps.elevenlabs ? ok() : `{#546e7a-fg}- needs API key{/#546e7a-fg}`}`,
|
|
4773
|
+
` {white-fg}${'Soprano TTS'.padEnd(16)}{/white-fg}${_deps.soprano ? ok() : `{#546e7a-fg}- optional{/#546e7a-fg}`}`,
|
|
4774
|
+
` {white-fg}${'ffmpeg'.padEnd(16)}{/white-fg}${_deps.ffmpeg ? ok() : `{red-fg}! ${t(_getLang(), 'ffmpegMissing')}{/red-fg}`}`,
|
|
3189
4775
|
'',
|
|
3190
4776
|
ttsOk
|
|
3191
4777
|
? ` {green-fg}OK ${t(_getLang(), 'ttsDetected')}{/green-fg}`
|
|
@@ -3193,11 +4779,9 @@ export function createSetupTab(screen, services) {
|
|
|
3193
4779
|
'',
|
|
3194
4780
|
'',
|
|
3195
4781
|
]));
|
|
3196
|
-
|
|
3197
|
-
|
|
3198
|
-
|
|
3199
|
-
_s1ContinueBtn.focus();
|
|
3200
|
-
}
|
|
4782
|
+
_s1ContinueBtn.setContent(ttsOk ? _tl('continueArrowBtn') : ' Install TTS ->');
|
|
4783
|
+
_s1ContinueBtn.show();
|
|
4784
|
+
_s1ContinueBtn.focus();
|
|
3201
4785
|
screen.render();
|
|
3202
4786
|
}
|
|
3203
4787
|
|
|
@@ -3270,7 +4854,7 @@ export function createSetupTab(screen, services) {
|
|
|
3270
4854
|
|
|
3271
4855
|
// Show provider rows instead of contentBox
|
|
3272
4856
|
contentBox.hide();
|
|
3273
|
-
hintLine.setContent(' Screen 3: LLM Providers | [Enter] Action | [Tab] Next button | [Esc]
|
|
4857
|
+
hintLine.setContent(' Screen 3: LLM Providers | [Enter] Action | [Tab] Next button | [Esc] TTS Engines');
|
|
3274
4858
|
showAllProviderRows();
|
|
3275
4859
|
refreshInstalledState().then(() => {
|
|
3276
4860
|
if (providerFocusableItems.length) {
|