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.
Files changed (73) hide show
  1. package/.agentvibes/install-manifest.json +111 -91
  2. package/.claude/commands/agent-vibes/commands.json +0 -20
  3. package/.claude/commands/agent-vibes/unmute.md +6 -2
  4. package/.claude/config/audio-effects.cfg +6 -6
  5. package/.claude/github-star-reminder.txt +1 -1
  6. package/.claude/hooks/agentvibes-session-id.sh +69 -0
  7. package/.claude/hooks/bmad-party-speak.sh +20 -4
  8. package/.claude/hooks/bmad-speak.sh +60 -2
  9. package/.claude/hooks/bmad-tts-injector.sh +20 -1
  10. package/.claude/hooks/bmad-voice-manager.sh +25 -3
  11. package/.claude/hooks/clawdbot-receiver-SECURE.sh +21 -2
  12. package/.claude/hooks/clawdbot-receiver.sh +19 -1
  13. package/.claude/hooks/elevenlabs-voices.sh +62 -0
  14. package/.claude/hooks/kokoro-installer.sh +20 -10
  15. package/.claude/hooks/language-manager.sh +10 -3
  16. package/.claude/hooks/party-set-room.sh +71 -0
  17. package/.claude/hooks/party-stage-roster.py +328 -0
  18. package/.claude/hooks/personality-manager.sh +19 -2
  19. package/.claude/hooks/piper-voice-manager.sh +3 -2
  20. package/.claude/hooks/play-tts-agentvibes-receiver-for-voiceless-connections.sh +24 -5
  21. package/.claude/hooks/play-tts-elevenlabs.sh +38 -118
  22. package/.claude/hooks/play-tts-kokoro.sh +33 -6
  23. package/.claude/hooks/play-tts-soprano.sh +3 -2
  24. package/.claude/hooks/play-tts-ssh-remote.sh +37 -29
  25. package/.claude/hooks/play-tts-termux-ssh.sh +5 -4
  26. package/.claude/hooks/play-tts.sh +66 -61
  27. package/.claude/hooks/provider-catalog.json +352 -0
  28. package/.claude/hooks/provider-catalog.sh +161 -0
  29. package/.claude/hooks/provider-commands.sh +2 -1
  30. package/.claude/hooks/provider-manager.sh +47 -9
  31. package/.claude/hooks/python-resolver.sh +117 -0
  32. package/.claude/hooks/session-id.sh +56 -0
  33. package/.claude/hooks/session-start-tts.sh +39 -0
  34. package/.claude/hooks/speed-manager.sh +1 -1
  35. package/.claude/hooks/translate-manager.sh +3 -2
  36. package/.claude/hooks/translator.py +1 -1
  37. package/.claude/hooks/voice-manager.sh +242 -10
  38. package/.claude/hooks-windows/language-manager.ps1 +7 -1
  39. package/.claude/hooks-windows/personality-manager.ps1 +16 -1
  40. package/.claude/hooks-windows/play-tts-kokoro.ps1 +20 -4
  41. package/.claude/hooks-windows/play-tts.ps1 +32 -3
  42. package/.claude/hooks-windows/provider-catalog.ps1 +140 -0
  43. package/.claude/hooks-windows/provider-manager.ps1 +63 -8
  44. package/.claude/hooks-windows/tts-watcher.ps1 +33 -12
  45. package/.claude/hooks-windows/voice-manager-windows.ps1 +49 -0
  46. package/.mcp.json +0 -7
  47. package/README.md +12 -3
  48. package/RELEASE_NOTES.md +61 -0
  49. package/mcp-server/server.py +146 -49
  50. package/mcp-server/test_mcp_correctness.py +20 -2
  51. package/mcp-server/test_windows_script_parity.py +0 -2
  52. package/package.json +2 -2
  53. package/src/cli/list-voices.js +218 -114
  54. package/src/console/bling.js +71 -0
  55. package/src/console/music-preview.js +79 -0
  56. package/src/console/tabs/music-tab.js +16 -39
  57. package/src/console/tabs/settings-tab.js +195 -13
  58. package/src/console/tabs/setup-tab.js +9 -34
  59. package/src/console/tabs/voices-tab.js +83 -14
  60. package/src/console/widgets/track-picker.js +82 -0
  61. package/src/installer.js +136 -18
  62. package/src/services/provider-catalog.js +412 -0
  63. package/src/services/provider-voice-catalog.js +52 -73
  64. package/src/services/tts-engine-service.js +29 -0
  65. package/src/utils/provider-validator.js +62 -12
  66. package/.claude/commands/agent-vibes/language.md +0 -23
  67. package/.claude/commands/agent-vibes/learn.md +0 -67
  68. package/.claude/commands/agent-vibes/replay-target.md +0 -14
  69. package/.claude/commands/agent-vibes/target-voice.md +0 -26
  70. package/.claude/commands/agent-vibes/target.md +0 -30
  71. package/.claude/hooks/learn-manager.sh +0 -492
  72. package/.claude/hooks/replay-target-audio.sh +0 -95
  73. 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
- // Package-relative tracks dir — used as fallback when cwd has no .claude/audio/tracks/
23
- const _PKG_TRACKS_DIR = path.resolve(
24
- _MUSIC_TAB_DIR, '..', '..', '..', '.claude', 'audio', 'tracks'
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
- * Resolve the active provider and the project dir it was read from, using the
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 = spawn('bash', [senderPath, '', ''], { stdio: ['ignore', 'ignore', 'pipe'], detached: true, env }); // NOSONAR
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 = _resolveMusicProvider();
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 = getAvailableEngines().find(e => e.id === engine);
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
- const engines = getAvailableEngines();
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: 50,
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) configService.set('ttsEngine', sel.id);
524
- _closeModal();
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
- _allVoices = scanInstalledVoices();
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
- // Windows: route through play-tts.ps1 (same pattern as non-Windows bash route)
666
- const playTtsScript = _hookScript('hooks-windows', 'play-tts.ps1');
667
- if (!fs.existsSync(playTtsScript)) return;
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', [ // NOSONAR
674
- '-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', playTtsScript, phrase, voiceId,
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
- * 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
- }
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
- try {
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'], () => {