agentvibes 5.12.0 → 5.13.1
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/install-manifest.json +111 -91
- package/.claude/commands/agent-vibes/commands.json +0 -20
- package/.claude/commands/agent-vibes/unmute.md +6 -2
- package/.claude/config/audio-effects.cfg +6 -6
- package/.claude/github-star-reminder.txt +1 -1
- package/.claude/hooks/agentvibes-session-id.sh +69 -0
- package/.claude/hooks/bmad-party-speak.sh +20 -4
- package/.claude/hooks/bmad-speak.sh +60 -2
- package/.claude/hooks/bmad-tts-injector.sh +20 -1
- package/.claude/hooks/bmad-voice-manager.sh +25 -3
- package/.claude/hooks/clawdbot-receiver-SECURE.sh +21 -2
- package/.claude/hooks/clawdbot-receiver.sh +19 -1
- package/.claude/hooks/elevenlabs-voices.sh +62 -0
- package/.claude/hooks/kokoro-installer.sh +20 -10
- package/.claude/hooks/language-manager.sh +10 -3
- package/.claude/hooks/party-set-room.sh +71 -0
- package/.claude/hooks/party-stage-roster.py +328 -0
- package/.claude/hooks/personality-manager.sh +19 -2
- package/.claude/hooks/piper-voice-manager.sh +3 -2
- package/.claude/hooks/play-tts-agentvibes-receiver-for-voiceless-connections.sh +24 -5
- package/.claude/hooks/play-tts-elevenlabs.sh +38 -118
- package/.claude/hooks/play-tts-kokoro.sh +33 -6
- package/.claude/hooks/play-tts-soprano.sh +3 -2
- package/.claude/hooks/play-tts-ssh-remote.sh +37 -29
- package/.claude/hooks/play-tts-termux-ssh.sh +5 -4
- package/.claude/hooks/play-tts.sh +66 -61
- package/.claude/hooks/provider-catalog.json +352 -0
- package/.claude/hooks/provider-catalog.sh +161 -0
- package/.claude/hooks/provider-commands.sh +2 -1
- package/.claude/hooks/provider-manager.sh +47 -9
- package/.claude/hooks/python-resolver.sh +117 -0
- package/.claude/hooks/session-id.sh +56 -0
- package/.claude/hooks/session-start-tts.sh +39 -0
- package/.claude/hooks/speed-manager.sh +1 -1
- package/.claude/hooks/translate-manager.sh +3 -2
- package/.claude/hooks/translator.py +1 -1
- package/.claude/hooks/voice-manager.sh +242 -10
- package/.claude/hooks-windows/language-manager.ps1 +7 -1
- package/.claude/hooks-windows/personality-manager.ps1 +16 -1
- package/.claude/hooks-windows/play-tts-kokoro.ps1 +20 -4
- package/.claude/hooks-windows/play-tts.ps1 +32 -3
- package/.claude/hooks-windows/provider-catalog.ps1 +140 -0
- package/.claude/hooks-windows/provider-manager.ps1 +63 -8
- package/.claude/hooks-windows/tts-watcher.ps1 +33 -12
- package/.claude/hooks-windows/voice-manager-windows.ps1 +49 -0
- package/.mcp.json +0 -7
- package/README.md +12 -3
- package/RELEASE_NOTES.md +61 -0
- package/mcp-server/server.py +146 -49
- package/mcp-server/test_mcp_correctness.py +20 -2
- package/mcp-server/test_windows_script_parity.py +0 -2
- package/package.json +2 -2
- package/src/cli/list-voices.js +218 -114
- package/src/console/bling.js +71 -0
- package/src/console/music-preview.js +79 -0
- package/src/console/tabs/music-tab.js +16 -39
- package/src/console/tabs/settings-tab.js +195 -13
- package/src/console/tabs/setup-tab.js +9 -34
- package/src/console/tabs/voices-tab.js +83 -14
- package/src/console/widgets/track-picker.js +82 -0
- package/src/installer.js +136 -18
- package/src/services/provider-catalog.js +412 -0
- package/src/services/provider-voice-catalog.js +52 -73
- package/src/services/tts-engine-service.js +29 -0
- package/src/utils/provider-validator.js +62 -12
- package/.claude/commands/agent-vibes/language.md +0 -23
- package/.claude/commands/agent-vibes/learn.md +0 -67
- package/.claude/commands/agent-vibes/replay-target.md +0 -14
- package/.claude/commands/agent-vibes/target-voice.md +0 -26
- package/.claude/commands/agent-vibes/target.md +0 -30
- package/.claude/hooks/learn-manager.sh +0 -492
- package/.claude/hooks/replay-target-audio.sh +0 -95
- package/.claude/hooks-windows/learn-manager.ps1 +0 -241
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* AgentVibes — Shared "bling" readiness cue.
|
|
3
|
+
*
|
|
4
|
+
* A short, fire-and-forget chime played the instant a preview is committed, so
|
|
5
|
+
* silence during synthesis / an SSH round-trip never reads as a hang. Used by
|
|
6
|
+
* BOTH the voice picker (setup-tab.js) and the music-preview surfaces
|
|
7
|
+
* (music-tab.js, track-picker.js) so they can't drift.
|
|
8
|
+
*
|
|
9
|
+
* `buildBlingCommand` was originally defined in setup-tab.js; it was moved here
|
|
10
|
+
* and is re-exported from setup-tab.js for backward compatibility (existing
|
|
11
|
+
* imports and tests keep working).
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import fs from 'node:fs';
|
|
15
|
+
import path from 'node:path';
|
|
16
|
+
import { spawn } from 'node:child_process';
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Build the fire-and-forget "preview ready" cue command for a platform. Pure
|
|
20
|
+
* (no spawning here, so it is unit-testable). Plays the bundled CC0 wav when
|
|
21
|
+
* present, else falls back to a system sound (Windows) / freedesktop cue or
|
|
22
|
+
* terminal bell (POSIX). stdio is ignored by the caller, so the POSIX bell is
|
|
23
|
+
* redirected to /dev/tty rather than the discarded stdout.
|
|
24
|
+
* @param {string} platform - process.platform value
|
|
25
|
+
* @param {string} wavPath - absolute path to the bling wav
|
|
26
|
+
* @param {boolean} haveWav - whether wavPath exists on disk
|
|
27
|
+
* @returns {{command: string, args: string[]}}
|
|
28
|
+
*/
|
|
29
|
+
export function buildBlingCommand(platform, wavPath, haveWav) {
|
|
30
|
+
if (platform === 'win32') {
|
|
31
|
+
const ps = haveWav
|
|
32
|
+
? `Add-Type -AssemblyName System.Windows.Forms; (New-Object System.Media.SoundPlayer('${wavPath.replace(/'/g, "''")}')).PlaySync()`
|
|
33
|
+
: '[System.Media.SystemSounds]::Asterisk.Play(); Start-Sleep -Milliseconds 700';
|
|
34
|
+
return { command: 'powershell', args: ['-NoProfile', '-Command', ps] };
|
|
35
|
+
}
|
|
36
|
+
if (haveWav) {
|
|
37
|
+
// Pass wavPath as a positional arg ($1) so the path is never interpolated
|
|
38
|
+
// into the shell string (prevents injection / breakage on special chars).
|
|
39
|
+
const sh = 'paplay "$1" 2>/dev/null || aplay -q "$1" 2>/dev/null || printf "\\a" > /dev/tty 2>/dev/null';
|
|
40
|
+
return { command: 'bash', args: ['-c', sh, '--', wavPath] };
|
|
41
|
+
}
|
|
42
|
+
const sh = 'paplay /usr/share/sounds/freedesktop/stereo/message.oga 2>/dev/null || printf "\\a" > /dev/tty 2>/dev/null';
|
|
43
|
+
return { command: 'bash', args: ['-c', sh] };
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Resolve the bundled CC0 bling wav for a package root.
|
|
48
|
+
* Sound: "Ui sounds - Shimmering success" by Philip_Berger, CC0 (freesound
|
|
49
|
+
* #648212). See .claude/audio/ui/CREDITS.txt.
|
|
50
|
+
* @param {string} packageRoot - AgentVibes package/repo root
|
|
51
|
+
* @returns {string}
|
|
52
|
+
*/
|
|
53
|
+
export function resolveBlingWav(packageRoot) {
|
|
54
|
+
return path.join(packageRoot, '.claude', 'audio', 'ui', 'bling-success.wav');
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Play the readiness cue, fire-and-forget. Detached + unref'd + errors
|
|
59
|
+
* swallowed, so it never blocks the UI or fails a preview. A missing wav falls
|
|
60
|
+
* back to a platform system sound via buildBlingCommand.
|
|
61
|
+
* @param {string} packageRoot - AgentVibes package/repo root
|
|
62
|
+
*/
|
|
63
|
+
export function playBlingCue(packageRoot) {
|
|
64
|
+
try {
|
|
65
|
+
const wav = resolveBlingWav(packageRoot);
|
|
66
|
+
const { command, args } = buildBlingCommand(process.platform, wav, fs.existsSync(wav));
|
|
67
|
+
const cue = spawn(command, args, { stdio: 'ignore', detached: true }); // NOSONAR
|
|
68
|
+
cue.on('error', () => { /* best-effort cue; a spawn failure must not surface */ });
|
|
69
|
+
cue.unref();
|
|
70
|
+
} catch { /* the readiness cue is purely cosmetic — never break the preview */ }
|
|
71
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* AgentVibes — Shared music-preview transport helpers.
|
|
3
|
+
*
|
|
4
|
+
* Both music-preview surfaces (the Music tab and the track-picker widget) must
|
|
5
|
+
* make the same remote-vs-local decision: a transport provider routes audio to
|
|
6
|
+
* a remote receiver, so a local MP3 preview would be silent on a headless box
|
|
7
|
+
* and must be forwarded instead. This module holds the provider-resolve and the
|
|
8
|
+
* remote-forward spawn so the two surfaces can't drift.
|
|
9
|
+
*
|
|
10
|
+
* The caller owns UI feedback (status text, error messages) and attaches its own
|
|
11
|
+
* exit/error/stderr handlers to the returned child process.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import fs from 'node:fs';
|
|
15
|
+
import path from 'node:path';
|
|
16
|
+
import os from 'node:os';
|
|
17
|
+
import { spawn } from 'node:child_process';
|
|
18
|
+
|
|
19
|
+
// Transport providers route audio to a remote receiver; local MP3 playback is
|
|
20
|
+
// silent on a headless/remote box, so track previews must be forwarded instead.
|
|
21
|
+
export const REMOTE_PROVIDERS = ['ssh-remote', 'agentvibes-receiver'];
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Resolve the active provider and the project dir it was read from, using the
|
|
25
|
+
* same search order as the voice pickers (CLAUDE_PROJECT_DIR → cwd → package →
|
|
26
|
+
* home). Returns { remote, projectDir } so a remote preview can forward the
|
|
27
|
+
* track to the receiver and tell the sender which .claude dir to resolve.
|
|
28
|
+
* @param {string} packageRoot - AgentVibes package/repo root (package fallback)
|
|
29
|
+
* @returns {{ remote: boolean, projectDir: string }}
|
|
30
|
+
*/
|
|
31
|
+
export function resolveMusicProvider(packageRoot) {
|
|
32
|
+
const dirs = [process.env.CLAUDE_PROJECT_DIR, process.cwd(), packageRoot, os.homedir()].filter(Boolean);
|
|
33
|
+
for (const d of dirs) {
|
|
34
|
+
const p = path.join(d, '.claude', 'tts-provider.txt');
|
|
35
|
+
try {
|
|
36
|
+
if (fs.existsSync(p)) {
|
|
37
|
+
const provider = fs.readFileSync(p, 'utf8').trim();
|
|
38
|
+
return { remote: REMOTE_PROVIDERS.includes(provider), projectDir: d };
|
|
39
|
+
}
|
|
40
|
+
} catch { /* next */ }
|
|
41
|
+
}
|
|
42
|
+
return { remote: false, projectDir: '' };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Resolve the SSH sender script path, preferring the package-bundled copy and
|
|
47
|
+
* falling back to the project's .claude/hooks copy.
|
|
48
|
+
* @param {string} packageRoot
|
|
49
|
+
* @param {string} projectDir
|
|
50
|
+
* @returns {string}
|
|
51
|
+
*/
|
|
52
|
+
export function resolveRemoteSender(packageRoot, projectDir) {
|
|
53
|
+
const pkgSender = path.resolve(packageRoot, '.claude', 'hooks', 'play-tts-ssh-remote.sh');
|
|
54
|
+
return fs.existsSync(pkgSender)
|
|
55
|
+
? pkgSender
|
|
56
|
+
: path.join(projectDir, '.claude', 'hooks', 'play-tts-ssh-remote.sh');
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Spawn the SSH sender to forward a music track to the receiver, or stop the
|
|
61
|
+
* current one. Fire-and-forget (the sender exits after handing the payload to
|
|
62
|
+
* SSH; the receiver plays/stops asynchronously). Returns the child process so
|
|
63
|
+
* the caller can wire its own exit/error/stderr handlers for UI feedback.
|
|
64
|
+
*
|
|
65
|
+
* @param {object} opts
|
|
66
|
+
* @param {string} opts.packageRoot
|
|
67
|
+
* @param {string} opts.projectDir
|
|
68
|
+
* @param {object} opts.env - base spawn env (e.g. buildAudioEnv())
|
|
69
|
+
* @param {string} [opts.track] - track filename to play (ignored when stop)
|
|
70
|
+
* @param {boolean} [opts.stop] - send an explicit music-stop signal instead
|
|
71
|
+
* @returns {import('node:child_process').ChildProcess}
|
|
72
|
+
*/
|
|
73
|
+
export function spawnMusicRemote({ packageRoot, projectDir, env, track = null, stop = false }) {
|
|
74
|
+
const senderPath = resolveRemoteSender(packageRoot, projectDir);
|
|
75
|
+
const spawnEnv = { ...env, CLAUDE_PROJECT_DIR: projectDir };
|
|
76
|
+
if (stop) spawnEnv.AGENTVIBES_MUSIC_STOP = '1';
|
|
77
|
+
else spawnEnv.AGENTVIBES_MUSIC_ONLY = track;
|
|
78
|
+
return spawn('bash', [senderPath, '', ''], { stdio: ['ignore', 'ignore', 'pipe'], detached: true, env: spawnEnv }); // NOSONAR
|
|
79
|
+
}
|
|
@@ -11,43 +11,20 @@
|
|
|
11
11
|
|
|
12
12
|
import fs from 'node:fs';
|
|
13
13
|
import path from 'node:path';
|
|
14
|
-
import os from 'node:os';
|
|
15
|
-
import { spawn } from 'node:child_process';
|
|
16
14
|
import { fileURLToPath } from 'node:url';
|
|
17
15
|
import { buildAudioEnv, spawnMp3Player } from '../audio-env.js';
|
|
16
|
+
import { resolveMusicProvider, spawnMusicRemote } from '../music-preview.js';
|
|
17
|
+
import { playBlingCue } from '../bling.js';
|
|
18
18
|
import { t } from '../../i18n/strings.js';
|
|
19
19
|
|
|
20
20
|
const _MUSIC_TAB_DIR = path.dirname(fileURLToPath(import.meta.url));
|
|
21
21
|
|
|
22
|
-
//
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
);
|
|
26
|
-
|
|
27
|
-
// Transport providers route audio to a remote receiver; local MP3 playback is
|
|
28
|
-
// silent on a headless/remote box, so track previews must be forwarded instead.
|
|
29
|
-
const _REMOTE_PROVIDERS = ['ssh-remote', 'agentvibes-receiver'];
|
|
22
|
+
// AgentVibes package/repo root — used to resolve bundled assets (tracks dir,
|
|
23
|
+
// bling cue) and as the package fallback when resolving the active provider.
|
|
24
|
+
const _PKG_ROOT = path.resolve(_MUSIC_TAB_DIR, '..', '..', '..');
|
|
30
25
|
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
* same search order as the voice pickers (CLAUDE_PROJECT_DIR → cwd → package →
|
|
34
|
-
* home). Returns { remote, projectDir } so a remote preview can forward the
|
|
35
|
-
* track to the receiver and tell the sender which .claude dir to resolve.
|
|
36
|
-
*/
|
|
37
|
-
function _resolveMusicProvider() {
|
|
38
|
-
const projectRoot = path.resolve(_MUSIC_TAB_DIR, '..', '..', '..');
|
|
39
|
-
const dirs = [process.env.CLAUDE_PROJECT_DIR, process.cwd(), projectRoot, os.homedir()].filter(Boolean);
|
|
40
|
-
for (const d of dirs) {
|
|
41
|
-
const p = path.join(d, '.claude', 'tts-provider.txt');
|
|
42
|
-
try {
|
|
43
|
-
if (fs.existsSync(p)) {
|
|
44
|
-
const provider = fs.readFileSync(p, 'utf8').trim();
|
|
45
|
-
return { remote: _REMOTE_PROVIDERS.includes(provider), projectDir: d };
|
|
46
|
-
}
|
|
47
|
-
} catch { /* next */ }
|
|
48
|
-
}
|
|
49
|
-
return { remote: false, projectDir: '' };
|
|
50
|
-
}
|
|
26
|
+
// Package-relative tracks dir — used as fallback when cwd has no .claude/audio/tracks/
|
|
27
|
+
const _PKG_TRACKS_DIR = path.join(_PKG_ROOT, '.claude', 'audio', 'tracks');
|
|
51
28
|
|
|
52
29
|
const IS_TEST = process.env.AGENTVIBES_TEST_MODE === 'true';
|
|
53
30
|
|
|
@@ -591,16 +568,9 @@ export function createMusicTab(screen, services) {
|
|
|
591
568
|
* @param {{track?:string, stop?:boolean}} opts
|
|
592
569
|
*/
|
|
593
570
|
function _sendMusicRemote(mp, { track = null, stop = false } = {}) {
|
|
594
|
-
const _pkgSender = path.resolve(_MUSIC_TAB_DIR, '..', '..', '..', '.claude', 'hooks', 'play-tts-ssh-remote.sh');
|
|
595
|
-
const senderPath = fs.existsSync(_pkgSender)
|
|
596
|
-
? _pkgSender
|
|
597
|
-
: path.join(mp.projectDir, '.claude', 'hooks', 'play-tts-ssh-remote.sh');
|
|
598
|
-
const env = { ..._spawnEnv, CLAUDE_PROJECT_DIR: mp.projectDir };
|
|
599
|
-
if (stop) env.AGENTVIBES_MUSIC_STOP = '1';
|
|
600
|
-
else env.AGENTVIBES_MUSIC_ONLY = track;
|
|
601
571
|
let rproc;
|
|
602
572
|
try {
|
|
603
|
-
rproc =
|
|
573
|
+
rproc = spawnMusicRemote({ packageRoot: _PKG_ROOT, projectDir: mp.projectDir, env: _spawnEnv, track, stop });
|
|
604
574
|
} catch {
|
|
605
575
|
previewLine.setContent('{red-fg}Remote music preview failed{/red-fg}');
|
|
606
576
|
screen.render();
|
|
@@ -646,7 +616,7 @@ export function createMusicTab(screen, services) {
|
|
|
646
616
|
// is silent on a headless box). The receiver auto-stops any prior track when
|
|
647
617
|
// a new one arrives; pressing Space on the currently-playing track sends an
|
|
648
618
|
// explicit stop (toggle off).
|
|
649
|
-
const _mp =
|
|
619
|
+
const _mp = resolveMusicProvider(_PKG_ROOT);
|
|
650
620
|
if (_mp.remote) {
|
|
651
621
|
if (_remotePlayingTrackId === trackId) {
|
|
652
622
|
_sendMusicRemote(_mp, { stop: true });
|
|
@@ -655,6 +625,9 @@ export function createMusicTab(screen, services) {
|
|
|
655
625
|
screen.render();
|
|
656
626
|
return;
|
|
657
627
|
}
|
|
628
|
+
// Bling first (fire-and-forget, plays locally) — same readiness cue as the
|
|
629
|
+
// voice preview — then forward the track to the receiver.
|
|
630
|
+
playBlingCue(_PKG_ROOT);
|
|
658
631
|
const rlabel = _allTracks.find(t => t.id === trackId)?.label ?? formatTrackLabel(trackId);
|
|
659
632
|
if (_sendMusicRemote(_mp, { track: trackId })) {
|
|
660
633
|
_remotePlayingTrackId = trackId;
|
|
@@ -678,6 +651,10 @@ export function createMusicTab(screen, services) {
|
|
|
678
651
|
_killPlayingProcess();
|
|
679
652
|
_playingTrackId = null;
|
|
680
653
|
|
|
654
|
+
// Bling first (fire-and-forget) — same readiness cue as the voice preview —
|
|
655
|
+
// then start local playback.
|
|
656
|
+
playBlingCue(_PKG_ROOT);
|
|
657
|
+
|
|
681
658
|
const proc = spawnMp3Player(trackPath, _spawnEnv);
|
|
682
659
|
if (!proc) {
|
|
683
660
|
const installHint = process.platform === 'win32'
|
|
@@ -23,6 +23,7 @@ import { fileURLToPath } from 'node:url';
|
|
|
23
23
|
import {
|
|
24
24
|
scanInstalledVoices, getVoiceMeta, genderIconTag, PIPER_VOICES_DIR, SAMPLE_PHRASES, parseMultiSpeaker,
|
|
25
25
|
} from './voices-tab.js';
|
|
26
|
+
import { voicesForProvider } from '../../services/provider-voice-catalog.js';
|
|
26
27
|
import { LanguageService } from '../../services/language-service.js';
|
|
27
28
|
import { SUPPORTED_LANGUAGES, t } from '../../i18n/strings.js';
|
|
28
29
|
import { buildAudioEnv, detectWavPlayer, detectRemoteLlm } from '../audio-env.js';
|
|
@@ -33,7 +34,7 @@ import { PERSONALITY_EMOJIS } from '../constants/personalities.js';
|
|
|
33
34
|
import { formatTrackName as _sharedFormatTrackName, formatReverbState as _sharedFormatReverbState } from '../widgets/format-utils.js';
|
|
34
35
|
import { showNotice as _showNoticeWidget } from '../widgets/notice.js';
|
|
35
36
|
import {
|
|
36
|
-
getAvailableEngines, checkEngineInstalled,
|
|
37
|
+
getAvailableEngines, getAllEngines, checkEngineInstalled, receiverProviderId,
|
|
37
38
|
} from '../../services/tts-engine-service.js';
|
|
38
39
|
|
|
39
40
|
const IS_TEST = process.env.AGENTVIBES_TEST_MODE === 'true';
|
|
@@ -179,7 +180,7 @@ export function createSettingsTab(screen, services) {
|
|
|
179
180
|
const installed = engines.find(e => checkEngineInstalled(e.id));
|
|
180
181
|
return installed ? installed.name : '(none)';
|
|
181
182
|
}
|
|
182
|
-
const match =
|
|
183
|
+
const match = getAllEngines().find(e => e.id === engine);
|
|
183
184
|
return match ? match.name : engine;
|
|
184
185
|
},
|
|
185
186
|
desc: 'Global default — individual providers can override in Setup → Configure',
|
|
@@ -485,15 +486,128 @@ export function createSettingsTab(screen, services) {
|
|
|
485
486
|
|
|
486
487
|
// ── TTS Engine editor ────────────────────────────────────────────────────
|
|
487
488
|
|
|
489
|
+
// Build actionable "how to enable" text for an engine that shows [N/A] in the
|
|
490
|
+
// picker, so the user is never left stranded not knowing what to do.
|
|
491
|
+
function _engineGuidance(engine) {
|
|
492
|
+
if (!engine) return '';
|
|
493
|
+
if (engine.requiresApiKey) {
|
|
494
|
+
const keyHint = engine.id === 'elevenlabs'
|
|
495
|
+
? '\n or save it to ~/.agentvibes/elevenlabs-key.txt'
|
|
496
|
+
: '';
|
|
497
|
+
return `${engine.name} needs an API key.\n\n`
|
|
498
|
+
+ `Set ${engine.apiKeyEnvVar} in your shell profile${keyHint}.\n\n`
|
|
499
|
+
+ `Get a free key at elevenlabs.io, then reopen this menu.`;
|
|
500
|
+
}
|
|
501
|
+
if (engine.installCmd && !/^echo /.test(engine.installCmd)) {
|
|
502
|
+
return `${engine.name} isn't installed yet.\n\nInstall it with:\n ${engine.installCmd}\n\nThen reopen this menu.`;
|
|
503
|
+
}
|
|
504
|
+
return `${engine.name} isn't available on this system.`;
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
function _showEngineGuidance(engine) {
|
|
508
|
+
const text = _engineGuidance(engine);
|
|
509
|
+
if (!text) return;
|
|
510
|
+
navigationService?.openModal();
|
|
511
|
+
const lines = text.split('\n');
|
|
512
|
+
const width = Math.min(76, Math.max(44, ...lines.map(l => l.length + 4)));
|
|
513
|
+
const modal = blessed.box({
|
|
514
|
+
parent: screen,
|
|
515
|
+
top: 'center',
|
|
516
|
+
left: 'center',
|
|
517
|
+
width,
|
|
518
|
+
height: lines.length + 4,
|
|
519
|
+
border: { type: 'line' },
|
|
520
|
+
tags: true,
|
|
521
|
+
keys: true,
|
|
522
|
+
mouse: true,
|
|
523
|
+
label: ` {bold}{yellow-fg} How to enable ${engine.name} {/yellow-fg}{/bold} `,
|
|
524
|
+
content: text,
|
|
525
|
+
style: { fg: COLORS.labelFg, bg: COLORS.contentBg, border: { fg: 'yellow' } },
|
|
526
|
+
});
|
|
527
|
+
modal.setFront();
|
|
528
|
+
const close = () => {
|
|
529
|
+
navigationService?.closeModal();
|
|
530
|
+
destroyList(modal, screen);
|
|
531
|
+
box.focus();
|
|
532
|
+
screen.render();
|
|
533
|
+
};
|
|
534
|
+
modal.key(['enter', 'escape', 'q', 'space'], close);
|
|
535
|
+
modal.focus();
|
|
536
|
+
screen.render();
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
// Announce the chosen engine to the remote by writing receiver-provider.txt —
|
|
540
|
+
// the exact file play-tts-ssh-remote.sh reads to fill the payload's `provider`
|
|
541
|
+
// field. The receiver then honors it (or overrides with its own local config).
|
|
542
|
+
// Written to ~/.agentvibes/config/ (the sender's guaranteed fallback path).
|
|
543
|
+
function _writeReceiverProvider(engineId) {
|
|
544
|
+
const provider = receiverProviderId(engineId);
|
|
545
|
+
try {
|
|
546
|
+
const dir = path.join(os.homedir(), '.agentvibes', 'config');
|
|
547
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
548
|
+
fs.writeFileSync(path.join(dir, 'receiver-provider.txt'), provider + '\n', 'utf8');
|
|
549
|
+
} catch (e) {
|
|
550
|
+
process.stderr.write(`[AgentVibes] could not write receiver-provider.txt: ${e.message}\n`);
|
|
551
|
+
}
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
// Confirm selecting an engine that can't run on THIS machine but may run on the
|
|
555
|
+
// remote receiver (remote mode only). onConfirm() runs on Yes.
|
|
556
|
+
function _confirmReceiverEngine(engine, onConfirm) {
|
|
557
|
+
navigationService?.openModal();
|
|
558
|
+
const lines = [
|
|
559
|
+
`${engine.name} can't run on this machine's OS.`,
|
|
560
|
+
'',
|
|
561
|
+
'In remote mode the receiver synthesizes the audio, so this',
|
|
562
|
+
'only works if your remote receiver supports it.',
|
|
563
|
+
'',
|
|
564
|
+
`Are you sure your receiver supports ${engine.name}?`,
|
|
565
|
+
'',
|
|
566
|
+
' [Y] Yes, send it to the receiver [N] Cancel',
|
|
567
|
+
];
|
|
568
|
+
const modal = blessed.box({
|
|
569
|
+
parent: screen,
|
|
570
|
+
top: 'center',
|
|
571
|
+
left: 'center',
|
|
572
|
+
width: Math.min(70, Math.max(50, ...lines.map(l => l.length + 4))),
|
|
573
|
+
height: lines.length + 4,
|
|
574
|
+
border: { type: 'line' },
|
|
575
|
+
tags: true,
|
|
576
|
+
keys: true,
|
|
577
|
+
mouse: true,
|
|
578
|
+
label: ' {bold}{yellow-fg} Confirm remote engine {/yellow-fg}{/bold} ',
|
|
579
|
+
content: lines.join('\n'),
|
|
580
|
+
style: { fg: COLORS.labelFg, bg: COLORS.contentBg, border: { fg: 'yellow' } },
|
|
581
|
+
});
|
|
582
|
+
modal.setFront();
|
|
583
|
+
// Restore focus to whatever was focused before (the engine list) so cancel
|
|
584
|
+
// returns the user there to pick again; on confirm, onConfirm() takes over.
|
|
585
|
+
const prevFocus = screen.focused;
|
|
586
|
+
const close = (confirmed) => {
|
|
587
|
+
navigationService?.closeModal();
|
|
588
|
+
destroyList(modal, screen);
|
|
589
|
+
if (prevFocus && !prevFocus.detached) prevFocus.focus(); else box.focus();
|
|
590
|
+
screen.render();
|
|
591
|
+
if (confirmed) onConfirm();
|
|
592
|
+
};
|
|
593
|
+
modal.key(['y', 'Y', 'enter'], () => close(true));
|
|
594
|
+
modal.key(['n', 'N', 'escape', 'q'], () => close(false));
|
|
595
|
+
modal.focus();
|
|
596
|
+
screen.render();
|
|
597
|
+
}
|
|
598
|
+
|
|
488
599
|
function _editTtsEngine() {
|
|
489
600
|
navigationService?.openModal();
|
|
490
601
|
|
|
491
|
-
|
|
602
|
+
// Show ALL engines (not just the platform-available ones) so off-platform
|
|
603
|
+
// engines like Windows SAPI appear greyed "(Not supported)" instead of looking
|
|
604
|
+
// missing. `supported` marks whether the engine can run on this OS.
|
|
605
|
+
const engines = getAllEngines();
|
|
492
606
|
const modal = blessed.list({
|
|
493
607
|
parent: screen,
|
|
494
608
|
top: 'center',
|
|
495
609
|
left: 'center',
|
|
496
|
-
width:
|
|
610
|
+
width: 54,
|
|
497
611
|
height: engines.length + 4,
|
|
498
612
|
border: { type: 'line' },
|
|
499
613
|
tags: true,
|
|
@@ -511,17 +625,51 @@ export function createSettingsTab(screen, services) {
|
|
|
511
625
|
});
|
|
512
626
|
modal.setFront();
|
|
513
627
|
|
|
628
|
+
// In remote mode the receiver synthesizes, so off-platform engines (e.g.
|
|
629
|
+
// Windows SAPI on a Linux server) ARE valid choices — the selection is sent
|
|
630
|
+
// to the receiver. Locally they cannot run, so they stay greyed & blocked.
|
|
631
|
+
const _isRemote = (configService?.getConfig?.()?.audio_destination ?? 'local') === 'remote';
|
|
632
|
+
|
|
514
633
|
const items = engines.map(e => {
|
|
634
|
+
if (!e.supported) {
|
|
635
|
+
if (_isRemote) {
|
|
636
|
+
// Selectable, but tag it so it's clear it runs on the receiver, not here.
|
|
637
|
+
return `{cyan-fg} ${e.name} (Receiver){/cyan-fg}`;
|
|
638
|
+
}
|
|
639
|
+
// Greyed, non-selectable: not runnable on this OS and no remote to send to.
|
|
640
|
+
return `{#607d8b-fg} ${e.name} (Not supported){/#607d8b-fg}`;
|
|
641
|
+
}
|
|
515
642
|
const installed = checkEngineInstalled(e.id);
|
|
516
643
|
const status = installed ? '{green-fg}[OK]{/green-fg}' : '{yellow-fg}[N/A]{/yellow-fg}';
|
|
517
644
|
return ` ${e.name} ${status}`;
|
|
518
645
|
});
|
|
519
646
|
modal.setItems(items);
|
|
520
647
|
|
|
648
|
+
// Commit a selection: persist the local default AND announce it to the remote.
|
|
649
|
+
const _applyEngine = (sel, showGuidance) => {
|
|
650
|
+
configService.set('ttsEngine', sel.id);
|
|
651
|
+
_writeReceiverProvider(sel.id);
|
|
652
|
+
_closeModal();
|
|
653
|
+
if (showGuidance) _showEngineGuidance(sel);
|
|
654
|
+
};
|
|
655
|
+
|
|
521
656
|
modal.key(['enter'], () => {
|
|
522
657
|
const sel = engines[modal.selected];
|
|
523
|
-
if (sel)
|
|
524
|
-
|
|
658
|
+
if (!sel) { _closeModal(); return; }
|
|
659
|
+
if (!sel.supported) {
|
|
660
|
+
if (!_isRemote) {
|
|
661
|
+
// No remote receiver to run it on — explain and keep the menu open.
|
|
662
|
+
_showNoticeWidget(screen, `${sel.name} is not supported on this system`);
|
|
663
|
+
return;
|
|
664
|
+
}
|
|
665
|
+
// Remote mode: confirm the receiver can run it, then send the selection.
|
|
666
|
+
_confirmReceiverEngine(sel, () => _applyEngine(sel, false));
|
|
667
|
+
return;
|
|
668
|
+
}
|
|
669
|
+
// Supported locally: [N/A] means "installable here" — offer guidance, but not
|
|
670
|
+
// in remote mode (local install is irrelevant when the receiver synthesizes).
|
|
671
|
+
const notInstalled = !checkEngineInstalled(sel.id);
|
|
672
|
+
_applyEngine(sel, notInstalled && !_isRemote);
|
|
525
673
|
});
|
|
526
674
|
modal.key(['escape', 'q'], _closeModal);
|
|
527
675
|
|
|
@@ -644,7 +792,17 @@ export function createSettingsTab(screen, services) {
|
|
|
644
792
|
if (_vpClosed) return;
|
|
645
793
|
const savedIdx = vpList.selected ?? 0;
|
|
646
794
|
const savedScroll = vpList.childBase ?? 0;
|
|
647
|
-
|
|
795
|
+
// Provider-aware: list the ACTIVE engine's voices, derived from the Provider
|
|
796
|
+
// Catalog (via the voicesForProvider shim) — Kokoro/ElevenLabs from the catalog,
|
|
797
|
+
// Piper from the on-disk scan, soprano its single entry. Map {id,gender} → id
|
|
798
|
+
// so the rest of this picker (which treats _allVoices as string ids) is unchanged.
|
|
799
|
+
// The active ENGINE drives which voices to list. The "Default TTS Engine"
|
|
800
|
+
// selector writes config.ttsEngine (kokoro/elevenlabs/piper/…); prefer it,
|
|
801
|
+
// then the provider, then piper. (getActiveProvider = config.provider, which
|
|
802
|
+
// can be a transport like agentvibes-receiver and isn't the engine.)
|
|
803
|
+
const _activeProvider = configService?.getConfig?.()?.ttsEngine
|
|
804
|
+
|| providerService?.getActiveProvider?.() || 'piper';
|
|
805
|
+
_allVoices = voicesForProvider(_activeProvider, { scanInstalledVoices, getVoiceMeta }).map(v => v.id);
|
|
648
806
|
// Sort by display name so the first-letter quick jump is intuitive
|
|
649
807
|
_allVoices.sort((a, b) => getVoiceMeta(a).displayName.localeCompare(
|
|
650
808
|
getVoiceMeta(b).displayName, undefined, { sensitivity: 'base' }));
|
|
@@ -662,17 +820,41 @@ export function createSettingsTab(screen, services) {
|
|
|
662
820
|
const phrase = SAMPLE_PHRASES[Math.floor(Math.random() * SAMPLE_PHRASES.length)]; // NOSONAR
|
|
663
821
|
|
|
664
822
|
if (_isWin) {
|
|
665
|
-
//
|
|
666
|
-
const
|
|
667
|
-
|
|
823
|
+
// Which engine is being previewed? (Default TTS Engine selector.)
|
|
824
|
+
const _engine = configService?.getConfig?.()?.ttsEngine
|
|
825
|
+
|| providerService?.getActiveProvider?.() || 'piper';
|
|
826
|
+
const _prov = receiverProviderId(_engine); // windows-sapi / piper / kokoro / …
|
|
827
|
+
|
|
828
|
+
// Windows SAPI voice names ("Microsoft David Desktop") don't auto-route, and
|
|
829
|
+
// play-tts.ps1's per-LLM engine resolution overrides -ProviderOverride back to
|
|
830
|
+
// Piper — so preview SAPI by calling its provider script DIRECTLY. Deterministic
|
|
831
|
+
// and it plays the voice (effects are skipped, which is fine for a preview).
|
|
832
|
+
// Piper/Kokoro keep the existing play-tts.ps1 path (they auto-route correctly).
|
|
833
|
+
const _directNames = _prov === 'windows-sapi'
|
|
834
|
+
? ['play-tts-sapi.ps1', 'play-tts-windows-sapi.ps1']
|
|
835
|
+
: null;
|
|
836
|
+
let _script = null;
|
|
837
|
+
const _psArgs = ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-File'];
|
|
838
|
+
if (_directNames) {
|
|
839
|
+
for (const n of _directNames) {
|
|
840
|
+
const p = _hookScript('hooks-windows', n);
|
|
841
|
+
if (fs.existsSync(p)) { _script = p; break; }
|
|
842
|
+
}
|
|
843
|
+
if (_script) _psArgs.push(_script, '-Text', phrase, '-VoiceOverride', voiceId);
|
|
844
|
+
}
|
|
845
|
+
if (!_script) {
|
|
846
|
+
_script = _hookScript('hooks-windows', 'play-tts.ps1');
|
|
847
|
+
if (!fs.existsSync(_script)) return;
|
|
848
|
+
_psArgs.push(_script, phrase, voiceId);
|
|
849
|
+
}
|
|
850
|
+
|
|
668
851
|
_previewVoiceId = voiceId;
|
|
669
852
|
if (!_vpClosed) {
|
|
670
853
|
vpPreviewLine.setContent(`{cyan-fg}♪ Playing: ${voiceId}...{/cyan-fg}`);
|
|
671
854
|
_refreshVP();
|
|
672
855
|
}
|
|
673
|
-
_previewProc = spawn('powershell',
|
|
674
|
-
|
|
675
|
-
], { stdio: 'ignore', detached: false, windowsHide: true, env: _spawnEnv });
|
|
856
|
+
_previewProc = spawn('powershell', _psArgs, // NOSONAR
|
|
857
|
+
{ stdio: 'ignore', detached: false, windowsHide: true, env: _spawnEnv });
|
|
676
858
|
_previewProc.on('exit', () => {
|
|
677
859
|
if (_previewVoiceId === voiceId) {
|
|
678
860
|
_previewVoiceId = null;
|
|
@@ -46,7 +46,13 @@ import { destroyList } from '../widgets/destroy-list.js';
|
|
|
46
46
|
import { scanInstalledVoices, getVoiceMeta, genderIconTag, formatVoiceRow, voiceRowHeader, PIPER_VOICES_DIR, SAMPLE_PHRASES, parseMultiSpeaker, getFavorites, getThumbsDown, toggleFavorite, toggleThumbsUp, toggleThumbsDown } from './voices-tab.js';
|
|
47
47
|
import { attachBtnBlink } from './agents-tab.js';
|
|
48
48
|
import { buildAudioEnv, detectWavPlayer } from '../audio-env.js';
|
|
49
|
+
import { buildBlingCommand, playBlingCue } from '../bling.js';
|
|
49
50
|
import { spawn, spawnSync } from 'node:child_process';
|
|
51
|
+
|
|
52
|
+
// Re-exported for backward compatibility — buildBlingCommand moved to
|
|
53
|
+
// ../bling.js so the music-preview surfaces can share it. Existing imports and
|
|
54
|
+
// tests that reference it from this module keep working.
|
|
55
|
+
export { buildBlingCommand };
|
|
50
56
|
import os from 'node:os';
|
|
51
57
|
import crypto from 'node:crypto';
|
|
52
58
|
import net from 'node:net';
|
|
@@ -271,33 +277,8 @@ export function buildPyModuleCheckArgs(mods) {
|
|
|
271
277
|
return ['-c', `import importlib.util as u, sys; sys.exit(0 if ${expr} else 1)`];
|
|
272
278
|
}
|
|
273
279
|
|
|
274
|
-
|
|
275
|
-
|
|
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
|
-
}
|
|
280
|
+
// buildBlingCommand moved to ../bling.js (imported + re-exported at the top of
|
|
281
|
+
// this file). See that module for the readiness-cue command builder.
|
|
301
282
|
|
|
302
283
|
// ---------------------------------------------------------------------------
|
|
303
284
|
// Dependency detection helpers
|
|
@@ -3175,14 +3156,8 @@ export function createSetupTab(screen, services) {
|
|
|
3175
3156
|
// Sound: "Ui sounds - Shimmering success" by Philip_Berger, CC0 (freesound
|
|
3176
3157
|
// #648212). See .claude/audio/ui/CREDITS.txt. Falls back to a system sound
|
|
3177
3158
|
// if the bundled asset is ever missing.
|
|
3178
|
-
const _blingWav = path.join(packageDir, '.claude', 'audio', 'ui', 'bling-success.wav');
|
|
3179
3159
|
function _playReadyCue() {
|
|
3180
|
-
|
|
3181
|
-
const { command, args } = buildBlingCommand(process.platform, _blingWav, fs.existsSync(_blingWav));
|
|
3182
|
-
const cue = spawn(command, args, { stdio: 'ignore', detached: true }); // NOSONAR
|
|
3183
|
-
cue.on('error', () => { /* best-effort cue; a spawn failure must not surface */ });
|
|
3184
|
-
cue.unref();
|
|
3185
|
-
} catch { /* the readiness cue is purely cosmetic — never break the preview */ }
|
|
3160
|
+
playBlingCue(packageDir);
|
|
3186
3161
|
}
|
|
3187
3162
|
|
|
3188
3163
|
kPicker.key(['space'], () => {
|