agentvibes 5.12.0-alpha.0 → 5.13.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.
Files changed (129) hide show
  1. package/.agentvibes/config.json +15 -1
  2. package/.agentvibes/install-manifest.json +342 -0
  3. package/.claude/activation-instructions +54 -54
  4. package/.claude/commands/agent-vibes/commands.json +0 -20
  5. package/.claude/commands/agent-vibes/unmute.md +6 -2
  6. package/.claude/commands/agent-vibes-bmad-voices.md +117 -117
  7. package/.claude/commands/agent-vibes-rdp.md +24 -24
  8. package/.claude/config/audio-effects.cfg +6 -6
  9. package/.claude/config/audio-effects.cfg.bak-kokoro +7 -0
  10. package/.claude/config/background-music-enabled.txt +1 -1
  11. package/.claude/config/background-music-position.txt +27 -0
  12. package/.claude/config/background-music-volume.txt +1 -0
  13. package/.claude/config/background-music.cfg +1 -0
  14. package/.claude/config/background-music.txt +1 -0
  15. package/.claude/config/language.txt +1 -0
  16. package/.claude/config/personality.txt +1 -0
  17. package/.claude/config/tts-speech-rate.txt +1 -0
  18. package/.claude/config/tts-verbosity.txt +1 -0
  19. package/.claude/docs/TERMUX_SETUP.md +408 -408
  20. package/.claude/github-star-reminder.txt +1 -1
  21. package/.claude/hooks/agentvibes-session-id.sh +69 -0
  22. package/.claude/hooks/bmad-party-speak.sh +20 -4
  23. package/.claude/hooks/bmad-speak.sh +60 -2
  24. package/.claude/hooks/bmad-tts-injector.sh +20 -1
  25. package/.claude/hooks/bmad-voice-manager.sh +25 -3
  26. package/.claude/hooks/clawdbot-receiver-SECURE.sh +21 -2
  27. package/.claude/hooks/clawdbot-receiver.sh +19 -1
  28. package/.claude/hooks/elevenlabs-voices.sh +62 -0
  29. package/.claude/hooks/kokoro-installer.sh +20 -10
  30. package/.claude/hooks/language-manager.sh +10 -3
  31. package/.claude/hooks/party-set-room.sh +71 -0
  32. package/.claude/hooks/party-stage-roster.py +328 -0
  33. package/.claude/hooks/personality-manager.sh +19 -2
  34. package/.claude/hooks/piper-voice-manager.sh +3 -2
  35. package/.claude/hooks/play-tts-agentvibes-receiver-for-voiceless-connections.sh +24 -5
  36. package/.claude/hooks/play-tts-elevenlabs.sh +38 -118
  37. package/.claude/hooks/play-tts-kokoro.sh +33 -6
  38. package/.claude/hooks/play-tts-soprano.sh +3 -2
  39. package/.claude/hooks/play-tts-ssh-remote.sh +72 -27
  40. package/.claude/hooks/play-tts-termux-ssh.sh +5 -4
  41. package/.claude/hooks/play-tts.sh +86 -69
  42. package/.claude/hooks/provider-catalog.json +352 -0
  43. package/.claude/hooks/provider-catalog.sh +161 -0
  44. package/.claude/hooks/provider-commands.sh +2 -1
  45. package/.claude/hooks/provider-manager.sh +47 -9
  46. package/.claude/hooks/python-resolver.sh +117 -0
  47. package/.claude/hooks/session-id.sh +56 -0
  48. package/.claude/hooks/session-start-tts.sh +39 -0
  49. package/.claude/hooks/speed-manager.sh +1 -1
  50. package/.claude/hooks/translate-manager.sh +3 -2
  51. package/.claude/hooks/translator.py +1 -1
  52. package/.claude/hooks/voice-manager.sh +242 -10
  53. package/.claude/hooks-windows/audio-cache-utils.ps1.user.bak +119 -0
  54. package/.claude/hooks-windows/language-manager.ps1 +7 -1
  55. package/.claude/hooks-windows/personality-manager.ps1 +16 -1
  56. package/.claude/hooks-windows/play-tts-kokoro.ps1 +20 -4
  57. package/.claude/hooks-windows/play-tts.ps1 +32 -3
  58. package/.claude/hooks-windows/provider-catalog.ps1 +140 -0
  59. package/.claude/hooks-windows/provider-manager.ps1 +63 -8
  60. package/.claude/hooks-windows/soprano-gradio-synth.py.user.bak +153 -0
  61. package/.claude/hooks-windows/tts-watcher.ps1 +84 -8
  62. package/.claude/hooks-windows/voice-manager-windows.ps1 +49 -0
  63. package/.claude/piper-voices-dir.txt +1 -0
  64. package/.clawdbot/README.md +105 -105
  65. package/.mcp.json +15 -0
  66. package/LICENSE +190 -190
  67. package/README.md +20 -6
  68. package/RELEASE_NOTES.md +81 -0
  69. package/WINDOWS-SETUP.md +208 -208
  70. package/bin/agent-vibes +39 -39
  71. package/bin/mcp-server.js +121 -121
  72. package/bin/test-bmad-pr +78 -78
  73. package/mcp-server/QUICK_START.md +203 -203
  74. package/mcp-server/README.md +345 -345
  75. package/mcp-server/examples/claude_desktop_config.json +11 -11
  76. package/mcp-server/examples/claude_desktop_config_piper.json +9 -9
  77. package/mcp-server/examples/custom_instructions.md +169 -169
  78. package/mcp-server/pyproject.toml +52 -52
  79. package/mcp-server/requirements.txt +2 -2
  80. package/mcp-server/server.py +146 -49
  81. package/mcp-server/test_mcp_correctness.py +20 -2
  82. package/mcp-server/test_server.py +395 -395
  83. package/mcp-server/test_windows_script_parity.py +0 -2
  84. package/package.json +1 -1
  85. package/src/cli/list-personalities.js +110 -110
  86. package/src/cli/list-voices.js +115 -11
  87. package/src/commands/bmad-voices.js +394 -394
  88. package/src/console/app.js +1 -9
  89. package/src/console/bling.js +71 -0
  90. package/src/console/brand-colors.js +13 -13
  91. package/src/console/constants/personalities.js +44 -44
  92. package/src/console/footer-config.js +0 -4
  93. package/src/console/music-preview.js +79 -0
  94. package/src/console/navigation.js +0 -1
  95. package/src/console/tabs/agents-tab.js +14 -2
  96. package/src/console/tabs/help-tab.js +314 -314
  97. package/src/console/tabs/music-tab.js +81 -5
  98. package/src/console/tabs/placeholder-tab.js +0 -2
  99. package/src/console/tabs/readme-tab.js +272 -272
  100. package/src/console/tabs/settings-tab.js +207 -14
  101. package/src/console/tabs/setup-tab.js +39 -36
  102. package/src/console/tabs/voices-tab.js +102 -23
  103. package/src/console/widgets/destroy-list.js +25 -25
  104. package/src/console/widgets/notice.js +55 -55
  105. package/src/console/widgets/track-picker.js +82 -0
  106. package/src/installer/language-screen.js +31 -31
  107. package/src/installer/music-file-input.js +304 -304
  108. package/src/installer.js +124 -10
  109. package/src/services/language-service.js +47 -47
  110. package/src/services/navigation-service.js +1 -1
  111. package/src/services/provider-catalog.js +412 -0
  112. package/src/services/provider-voice-catalog.js +52 -73
  113. package/src/services/tts-engine-service.js +29 -0
  114. package/src/utils/audio-format-validator.js +277 -277
  115. package/src/utils/dependency-checker.js +469 -469
  116. package/src/utils/file-ownership-verifier.js +358 -358
  117. package/src/utils/music-file-validator.js +285 -285
  118. package/src/utils/preview-list-prompt.js +144 -144
  119. package/src/utils/provider-validator.js +62 -12
  120. package/src/utils/secure-music-storage.js +412 -412
  121. package/voice-assignments.json +8244 -8244
  122. package/.claude/commands/agent-vibes/language.md +0 -23
  123. package/.claude/commands/agent-vibes/learn.md +0 -67
  124. package/.claude/commands/agent-vibes/replay-target.md +0 -14
  125. package/.claude/commands/agent-vibes/target-voice.md +0 -26
  126. package/.claude/commands/agent-vibes/target.md +0 -30
  127. package/.claude/hooks/learn-manager.sh +0 -492
  128. package/.claude/hooks/replay-target-audio.sh +0 -95
  129. package/.claude/hooks-windows/learn-manager.ps1 +0 -241
@@ -19,9 +19,11 @@ import path from 'node:path';
19
19
  import os from 'node:os';
20
20
  import crypto from 'node:crypto';
21
21
  import { spawn } from 'node:child_process';
22
+ import { fileURLToPath } from 'node:url';
22
23
  import {
23
24
  scanInstalledVoices, getVoiceMeta, genderIconTag, PIPER_VOICES_DIR, SAMPLE_PHRASES, parseMultiSpeaker,
24
25
  } from './voices-tab.js';
26
+ import { voicesForProvider } from '../../services/provider-voice-catalog.js';
25
27
  import { LanguageService } from '../../services/language-service.js';
26
28
  import { SUPPORTED_LANGUAGES, t } from '../../i18n/strings.js';
27
29
  import { buildAudioEnv, detectWavPlayer, detectRemoteLlm } from '../audio-env.js';
@@ -32,7 +34,7 @@ import { PERSONALITY_EMOJIS } from '../constants/personalities.js';
32
34
  import { formatTrackName as _sharedFormatTrackName, formatReverbState as _sharedFormatReverbState } from '../widgets/format-utils.js';
33
35
  import { showNotice as _showNoticeWidget } from '../widgets/notice.js';
34
36
  import {
35
- getAvailableEngines, checkEngineInstalled,
37
+ getAvailableEngines, getAllEngines, checkEngineInstalled, receiverProviderId,
36
38
  } from '../../services/tts-engine-service.js';
37
39
 
38
40
  const IS_TEST = process.env.AGENTVIBES_TEST_MODE === 'true';
@@ -130,6 +132,16 @@ export function createSettingsTab(screen, services) {
130
132
  // Capture cwd once at construction (matches agents-tab.js pattern)
131
133
  const _projectRoot = process.cwd();
132
134
 
135
+ // Preview hooks: prefer the CURRENT package copy over the project-local
136
+ // .claude/hooks (a tarball reinstall refreshes node_modules only, not the
137
+ // project's .claude), so previews never run a stale sender. CLAUDE_PROJECT_DIR
138
+ // still points at _projectRoot so the hook reads the project's config/provider.
139
+ const _pkgClaudeDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..', '..', '.claude');
140
+ function _hookScript(subdir, name) {
141
+ const pkg = path.join(_pkgClaudeDir, subdir, name);
142
+ return fs.existsSync(pkg) ? pkg : path.join(_projectRoot, '.claude', subdir, name);
143
+ }
144
+
133
145
  // ── Container ────────────────────────────────────────────────────────────
134
146
 
135
147
  const box = blessed.box({
@@ -168,7 +180,7 @@ export function createSettingsTab(screen, services) {
168
180
  const installed = engines.find(e => checkEngineInstalled(e.id));
169
181
  return installed ? installed.name : '(none)';
170
182
  }
171
- const match = getAvailableEngines().find(e => e.id === engine);
183
+ const match = getAllEngines().find(e => e.id === engine);
172
184
  return match ? match.name : engine;
173
185
  },
174
186
  desc: 'Global default — individual providers can override in Setup → Configure',
@@ -474,15 +486,128 @@ export function createSettingsTab(screen, services) {
474
486
 
475
487
  // ── TTS Engine editor ────────────────────────────────────────────────────
476
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
+
477
599
  function _editTtsEngine() {
478
600
  navigationService?.openModal();
479
601
 
480
- 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();
481
606
  const modal = blessed.list({
482
607
  parent: screen,
483
608
  top: 'center',
484
609
  left: 'center',
485
- width: 50,
610
+ width: 54,
486
611
  height: engines.length + 4,
487
612
  border: { type: 'line' },
488
613
  tags: true,
@@ -500,17 +625,51 @@ export function createSettingsTab(screen, services) {
500
625
  });
501
626
  modal.setFront();
502
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
+
503
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
+ }
504
642
  const installed = checkEngineInstalled(e.id);
505
643
  const status = installed ? '{green-fg}[OK]{/green-fg}' : '{yellow-fg}[N/A]{/yellow-fg}';
506
644
  return ` ${e.name} ${status}`;
507
645
  });
508
646
  modal.setItems(items);
509
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
+
510
656
  modal.key(['enter'], () => {
511
657
  const sel = engines[modal.selected];
512
- if (sel) configService.set('ttsEngine', sel.id);
513
- _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);
514
673
  });
515
674
  modal.key(['escape', 'q'], _closeModal);
516
675
 
@@ -633,7 +792,17 @@ export function createSettingsTab(screen, services) {
633
792
  if (_vpClosed) return;
634
793
  const savedIdx = vpList.selected ?? 0;
635
794
  const savedScroll = vpList.childBase ?? 0;
636
- _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);
637
806
  // Sort by display name so the first-letter quick jump is intuitive
638
807
  _allVoices.sort((a, b) => getVoiceMeta(a).displayName.localeCompare(
639
808
  getVoiceMeta(b).displayName, undefined, { sensitivity: 'base' }));
@@ -651,17 +820,41 @@ export function createSettingsTab(screen, services) {
651
820
  const phrase = SAMPLE_PHRASES[Math.floor(Math.random() * SAMPLE_PHRASES.length)]; // NOSONAR
652
821
 
653
822
  if (_isWin) {
654
- // Windows: route through play-tts.ps1 (same pattern as non-Windows bash route)
655
- const playTtsScript = path.join(_projectRoot, '.claude', 'hooks-windows', 'play-tts.ps1');
656
- 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
+
657
851
  _previewVoiceId = voiceId;
658
852
  if (!_vpClosed) {
659
853
  vpPreviewLine.setContent(`{cyan-fg}♪ Playing: ${voiceId}...{/cyan-fg}`);
660
854
  _refreshVP();
661
855
  }
662
- _previewProc = spawn('powershell', [ // NOSONAR
663
- '-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', playTtsScript, phrase, voiceId,
664
- ], { stdio: 'ignore', detached: false, windowsHide: true, env: _spawnEnv });
856
+ _previewProc = spawn('powershell', _psArgs, // NOSONAR
857
+ { stdio: 'ignore', detached: false, windowsHide: true, env: _spawnEnv });
665
858
  _previewProc.on('exit', () => {
666
859
  if (_previewVoiceId === voiceId) {
667
860
  _previewVoiceId = null;
@@ -674,7 +867,7 @@ export function createSettingsTab(screen, services) {
674
867
  }
675
868
 
676
869
  // Non-Windows: use bash play-tts.sh
677
- const playTtsScript = path.join(_projectRoot, '.claude', 'hooks', 'play-tts.sh');
870
+ const playTtsScript = _hookScript('hooks', 'play-tts.sh');
678
871
  if (!fs.existsSync(playTtsScript)) return;
679
872
 
680
873
  const remoteLlm = detectRemoteLlm();
@@ -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
@@ -2852,11 +2833,21 @@ export function createSetupTab(screen, services) {
2852
2833
  let _kSpinInterval = null;
2853
2834
  let _kSpinFrame = 0;
2854
2835
  let _kSpinningIdx = -1;
2836
+ let _kSpinStartTs = 0;
2837
+ let _kFloorTimer = null; // pending _stopKSpinnerWithFloor timer (tracked so it can be cancelled)
2838
+ // Remote SSH preview is fire-and-forget: play-tts-ssh-remote.sh backgrounds
2839
+ // the ssh call and exits within milliseconds, so the row spinner would be
2840
+ // torn down before it ever paints a frame while the receiver plays the audio
2841
+ // a beat later — the user hears sound but never sees the spinner. Hold the
2842
+ // spinner on-screen for this minimum window so the remote path still gives a
2843
+ // visible "preview sent" cue (fire-and-forget has no playback signal to await).
2844
+ const _K_MIN_SPIN_MS = 1100;
2855
2845
 
2856
2846
  function _startKSpinner(listIdx) {
2857
2847
  _stopKSpinner();
2858
2848
  _kSpinningIdx = listIdx;
2859
2849
  _kSpinFrame = 0;
2850
+ _kSpinStartTs = Date.now();
2860
2851
  _kSpinInterval = setInterval(() => {
2861
2852
  if (_kClosed) { _stopKSpinner(); return; }
2862
2853
  const spin = `{cyan-fg}${_K_SPIN[_kSpinFrame++ % _K_SPIN.length]}{/cyan-fg}`;
@@ -2866,6 +2857,9 @@ export function createSetupTab(screen, services) {
2866
2857
  }
2867
2858
 
2868
2859
  function _stopKSpinner() {
2860
+ // Cancel any pending floor timer so a stale one can't fire against a newer
2861
+ // preview's spinner (rapid Space presses) or after the picker closes.
2862
+ if (_kFloorTimer) { clearTimeout(_kFloorTimer); _kFloorTimer = null; }
2869
2863
  if (_kSpinInterval) { clearInterval(_kSpinInterval); _kSpinInterval = null; }
2870
2864
  if (_kSpinningIdx >= 0 && !_kClosed) {
2871
2865
  kPicker.setItem(_kSpinningIdx, _kokoroItem(voices[_kSpinningIdx]));
@@ -2874,6 +2868,21 @@ export function createSetupTab(screen, services) {
2874
2868
  _kSpinningIdx = -1;
2875
2869
  }
2876
2870
 
2871
+ // Stop the spinner but keep it on-screen for at least _K_MIN_SPIN_MS so a
2872
+ // fire-and-forget remote send stays visible; runs `after` once the floor is
2873
+ // met. No-ops safely if the picker closes in the meantime (_stopKSpinner and
2874
+ // the _kClosed guard both short-circuit). Used only by the remote path — the
2875
+ // local synth path already spins for the whole (multi-second) synthesis.
2876
+ function _stopKSpinnerWithFloor(after) {
2877
+ if (_kFloorTimer) { clearTimeout(_kFloorTimer); _kFloorTimer = null; }
2878
+ const wait = Math.max(0, _K_MIN_SPIN_MS - (Date.now() - _kSpinStartTs));
2879
+ _kFloorTimer = setTimeout(() => {
2880
+ _kFloorTimer = null;
2881
+ _stopKSpinner();
2882
+ if (!_kClosed && after) after();
2883
+ }, wait);
2884
+ }
2885
+
2877
2886
  const LEGEND_H = 3;
2878
2887
  const pickerH = Math.min(voices.length + LEGEND_H + 3, 24);
2879
2888
 
@@ -3147,14 +3156,8 @@ export function createSetupTab(screen, services) {
3147
3156
  // Sound: "Ui sounds - Shimmering success" by Philip_Berger, CC0 (freesound
3148
3157
  // #648212). See .claude/audio/ui/CREDITS.txt. Falls back to a system sound
3149
3158
  // if the bundled asset is ever missing.
3150
- const _blingWav = path.join(packageDir, '.claude', 'audio', 'ui', 'bling-success.wav');
3151
3159
  function _playReadyCue() {
3152
- try {
3153
- const { command, args } = buildBlingCommand(process.platform, _blingWav, fs.existsSync(_blingWav));
3154
- const cue = spawn(command, args, { stdio: 'ignore', detached: true }); // NOSONAR
3155
- cue.on('error', () => { /* best-effort cue; a spawn failure must not surface */ });
3156
- cue.unref();
3157
- } catch { /* the readiness cue is purely cosmetic — never break the preview */ }
3160
+ playBlingCue(packageDir);
3158
3161
  }
3159
3162
 
3160
3163
  kPicker.key(['space'], () => {
@@ -3226,8 +3229,7 @@ export function createSetupTab(screen, services) {
3226
3229
  _kPreviewProc = remoteProc;
3227
3230
  remoteProc.on('exit', (code) => {
3228
3231
  _kPreviewProc = null;
3229
- _stopKSpinner();
3230
- if (_kClosed) return;
3232
+ _stopKSpinnerWithFloor(() => {
3231
3233
  if (code !== 0 && _isCjkVoice(voiceId)) {
3232
3234
  // CJK voice failed on receiver — needs the language-specific misaki extra there.
3233
3235
  // Still try to download the .pt file locally so the picker shows ✓.
@@ -3272,6 +3274,7 @@ export function createSetupTab(screen, services) {
3272
3274
  screen.render();
3273
3275
  setTimeout(() => { if (!_kClosed) { kBox.setLabel(IDLE_LABEL); screen.render(); } }, 3000);
3274
3276
  }
3277
+ });
3275
3278
  });
3276
3279
  remoteProc.on('error', () => {
3277
3280
  _kPreviewProc = null;
@@ -15,6 +15,40 @@ import { spawn } from 'node:child_process';
15
15
  import { fileURLToPath } from 'node:url';
16
16
  import { buildAudioEnv, detectWavPlayer, getAllWavPlayers } from '../audio-env.js';
17
17
  import { SURNAME_POOL, uniquifyVoiceName } from '../../utils/voice-names.js';
18
+ import { voicesForProvider, ELEVENLABS_VOICES, KOKORO_VOICE_IDS, WINDOWS_SAPI_VOICES, MACOS_VOICES, kokoroGender } from '../../services/provider-voice-catalog.js';
19
+ import { getProvider as _catalogGetProvider } from '../../services/provider-catalog.js';
20
+
21
+ /**
22
+ * Resolve display name / gender / provider label for a NON-Piper catalog voice
23
+ * (Kokoro or ElevenLabs) from the Provider Catalog SSOT. Returns null for Piper
24
+ * ids so getVoiceMeta falls through to its Piper resolution. This keeps the
25
+ * picker's labels/gender correct once non-Piper voices are listed — otherwise
26
+ * a raw ElevenLabs id (EXAVITQu4vr4xnSDxMaL) would render as its own name,
27
+ * gender '—', provider 'Piper'.
28
+ */
29
+ function catalogVoiceMeta(voiceId) {
30
+ const el = ELEVENLABS_VOICES.find(v => v.id === voiceId);
31
+ if (el) return { displayName: el.name || voiceId, gender: el.gender || '—', provider: 'ElevenLabs' };
32
+ if (KOKORO_VOICE_IDS.includes(voiceId)) {
33
+ // Kokoro ids look like af_heart / am_michael; title-case for display.
34
+ const pretty = voiceId.replace(/_/g, ' ').replace(/\b\w/g, c => c.toUpperCase());
35
+ return { displayName: pretty, gender: kokoroGender(voiceId) || '—', provider: 'Kokoro' };
36
+ }
37
+ // OS-native voices (Windows SAPI "Microsoft David Desktop", macOS "Samantha").
38
+ const sapi = WINDOWS_SAPI_VOICES.find(v => v.id === voiceId);
39
+ if (sapi) return { displayName: sapi.name, gender: sapi.gender || '—', provider: 'Windows SAPI' };
40
+ const mac = MACOS_VOICES.find(v => v.id === voiceId);
41
+ if (mac) return { displayName: mac.name, gender: mac.gender || '—', provider: 'macOS Say' };
42
+ // Single-voice / native providers whose voice id IS the provider id or alias
43
+ // (soprano, windows-sapi/sapi, macos-say/say). Without this they fall through to
44
+ // the Piper resolution below and get mislabeled "Piper" (the soprano bug).
45
+ const prov = _catalogGetProvider(voiceId);
46
+ if (prov && prov.engineId !== 'piper') {
47
+ const label = prov.displayName.replace(/\s+TTS$/, '');
48
+ return { displayName: label, gender: '—', provider: label };
49
+ }
50
+ return null;
51
+ }
18
52
 
19
53
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
20
54
 
@@ -603,6 +637,12 @@ export function getVoiceMeta(voiceId) {
603
637
  // Lazy-load the catalog so callers from any tab get uniquified names
604
638
  loadCatalog();
605
639
 
640
+ // Non-Piper catalog voices (Kokoro / ElevenLabs) resolve from the Provider
641
+ // Catalog SSOT — real name, real gender, correct provider label — before the
642
+ // Piper-only paths below (which would mislabel them).
643
+ const nonPiper = catalogVoiceMeta(voiceId);
644
+ if (nonPiper) { _metaCache.set(voiceId, nonPiper); return nonPiper; }
645
+
606
646
  const ms = parseMultiSpeaker(voiceId);
607
647
  if (ms.isMultiSpeaker) {
608
648
  if (!ms.speakerName) {
@@ -967,19 +1007,35 @@ export function createVoicesTab(screen, services) {
967
1007
  // NOTE: projectRoot is AgentVibes repo root (3 levels up from src/console/tabs/)
968
1008
  const projectRoot = path.resolve(__dirname, '..', '..', '..');
969
1009
  let activeProvider = '';
1010
+ // Capture the dir the provider was read from so we can forward it to
1011
+ // play-tts.sh as --project-dir. Without it the script re-resolves the
1012
+ // provider from its OWN cwd/package .claude (which has no tts-provider.txt),
1013
+ // falls back to piper-local, and synthesizes to a silent local device on a
1014
+ // headless/remote box — i.e. the preview appears to do nothing. Passing the
1015
+ // exact dir we matched keeps the JS check and the shell routing in agreement.
1016
+ let activeProjectDir = '';
970
1017
  try {
971
- const providerPaths = [
972
- process.env.CLAUDE_PROJECT_DIR && path.join(process.env.CLAUDE_PROJECT_DIR, '.claude', 'tts-provider.txt'),
973
- path.join(process.cwd(), '.claude', 'tts-provider.txt'),
974
- path.join(projectRoot, '.claude', 'tts-provider.txt'),
975
- path.join(os.homedir(), '.claude', 'tts-provider.txt'),
1018
+ const providerDirs = [
1019
+ process.env.CLAUDE_PROJECT_DIR,
1020
+ process.cwd(),
1021
+ projectRoot,
1022
+ os.homedir(),
976
1023
  ].filter(Boolean);
977
- for (const p of providerPaths) {
978
- if (fs.existsSync(p)) { activeProvider = fs.readFileSync(p, 'utf8').trim(); break; }
1024
+ for (const d of providerDirs) {
1025
+ const p = path.join(d, '.claude', 'tts-provider.txt');
1026
+ if (fs.existsSync(p)) { activeProvider = fs.readFileSync(p, 'utf8').trim(); activeProjectDir = d; break; }
979
1027
  }
980
1028
  } catch {}
981
1029
 
982
- if (_remoteProviders.includes(activeProvider)) {
1030
+ // Route through the engine-aware play-tts for ANY non-Piper provider — remote
1031
+ // transports (ssh-remote / agentvibes-receiver) AND local non-Piper engines
1032
+ // (kokoro / elevenlabs / soprano / macos). Only Piper (and unset = default)
1033
+ // uses the fast local piper path below; play-tts.sh/.ps1 dispatches to the
1034
+ // correct engine so Kokoro/ElevenLabs previews actually synthesize instead of
1035
+ // erroring against a nonexistent <id>.onnx piper model.
1036
+ const _isPiperLocal = (activeProvider === '' || activeProvider === 'piper' || activeProvider === 'windows-piper');
1037
+ if (!_isPiperLocal) {
1038
+ const _remote = _remoteProviders.includes(activeProvider);
983
1039
  const isWindows = process.platform === 'win32' && !process.env.WSL_DISTRO_NAME;
984
1040
  const phrase = `Hi, my name is ${getVoiceMeta(voiceId).displayName}.`;
985
1041
  // Hooks live in the AgentVibes package (projectRoot), not the user's project dir.
@@ -994,12 +1050,14 @@ export function createVoicesTab(screen, services) {
994
1050
  let proc;
995
1051
  if (isWindows) {
996
1052
  const playTts = path.join(hooksBase, '.claude', 'hooks-windows', 'play-tts.ps1');
997
- proc = spawn('powershell', ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', playTts, phrase, voiceId], { // NOSONAR
1053
+ const _pdArgs = activeProjectDir ? ['-ProjectDir', activeProjectDir] : [];
1054
+ proc = spawn('powershell', ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', playTts, phrase, voiceId, ..._pdArgs], { // NOSONAR
998
1055
  stdio: 'ignore', detached: false, windowsHide: true, env: _spawnEnv,
999
1056
  });
1000
1057
  } else {
1001
1058
  const playTts = path.join(hooksBase, '.claude', 'hooks', 'play-tts.sh');
1002
- proc = spawn('bash', [playTts, phrase, voiceId], { // NOSONAR
1059
+ const _pdArgs = activeProjectDir ? ['--project-dir', activeProjectDir] : [];
1060
+ proc = spawn('bash', [playTts, phrase, voiceId, ..._pdArgs], { // NOSONAR
1003
1061
  stdio: ['ignore', 'ignore', 'pipe'], detached: true, env: _spawnEnv,
1004
1062
  cwd: process.cwd(),
1005
1063
  });
@@ -1007,7 +1065,7 @@ export function createVoicesTab(screen, services) {
1007
1065
  _playingProcess = proc;
1008
1066
  _playingVoiceId = voiceId;
1009
1067
  refreshDisplay();
1010
- previewLine.setContent('{bright-magenta-fg}♪ Synthesizing on remote...{/bright-magenta-fg}');
1068
+ previewLine.setContent(`{bright-magenta-fg}♪ Synthesizing${_remote ? ' on remote' : ''}...{/bright-magenta-fg}`);
1011
1069
  screen.render();
1012
1070
  let _stderrBuf = '';
1013
1071
  if (proc.stderr) {
@@ -1678,10 +1736,16 @@ export function createVoicesTab(screen, services) {
1678
1736
  // genderIconTag has invisible color tags — pad with literal spaces (1 visible char + 3 spaces = 4)
1679
1737
  const gIcon = genderIconTag(gender);
1680
1738
  if (!installed) {
1681
- // Greyed-out row for uninstalled catalog voices close grey wrap around the icon so its colors show
1682
- return `{bright-black-fg} ${star} ${name}{/bright-black-fg}${gIcon} {bright-black-fg}${provider}{/bright-black-fg}`;
1739
+ // Greyed-out row for uninstalled catalog voices. Use a mid blue-grey (not
1740
+ // bright-black) so the row stays legible when selected — bright-black on the
1741
+ // green selection bg is unreadable. Close the grey wrap around the icon so
1742
+ // its own colors show.
1743
+ return `{#90a4ae-fg} ${star} ${name}{/#90a4ae-fg}${gIcon} {#90a4ae-fg}${provider}{/#90a4ae-fg}`;
1683
1744
  }
1684
- return `{${COLORS.labelFg}-fg} ${star}${dot} ${name}{/${COLORS.labelFg}-fg}${gIcon} {${COLORS.labelFg}-fg}${provider}${isPrev ? ` ${_tl('voicePlaying')}` : ''}{/${COLORS.labelFg}-fg}`;
1745
+ // No fg wrap on the name/provider: unselected rows inherit the list's item.fg
1746
+ // (labelFg); the selected row inherits selected.fg (#ffffff bold) so the
1747
+ // highlight is white-on-green and readable, instead of pale text on green.
1748
+ return ` ${star}${dot} ${name}${gIcon} ${provider}${isPrev ? ` ${_tl('voicePlaying')}` : ''}`;
1685
1749
  });
1686
1750
  }
1687
1751
 
@@ -1732,15 +1796,30 @@ export function createVoicesTab(screen, services) {
1732
1796
  _metaCache.clear();
1733
1797
  }
1734
1798
 
1735
- // Installed voices (from local disk)
1736
- const installed = scanInstalledVoices();
1737
- _installedSet = new Set(installed);
1738
-
1739
- // Merge: installed voices first, then uninstalled catalog voices
1740
- const catalogOnly = _catalogEntries
1741
- .filter(c => !_installedSet.has(c.voiceId))
1742
- .map(c => c.voiceId);
1743
- _allVoices = [...installed, ...catalogOnly];
1799
+ // Provider-aware voice list. Piper (disk-discovered) keeps the installer
1800
+ // behavior — installed voices plus uninstalled catalog voices to download.
1801
+ // For Kokoro/ElevenLabs/soprano the voices come from the Provider Catalog
1802
+ // (via voicesForProvider) and are all available (nothing to "install"), so
1803
+ // mark them installed to suppress the download UI.
1804
+ // Prefer the explicitly-chosen Default TTS Engine (config.ttsEngine) over the
1805
+ // provider (which may be a transport like agentvibes-receiver, not an engine).
1806
+ const _vtProvider = configService?.getConfig?.()?.ttsEngine
1807
+ || providerService?.getActiveProvider?.() || 'piper';
1808
+ const _vtIsDisk = (_vtProvider === 'piper' || _vtProvider === 'windows-piper');
1809
+ if (_vtIsDisk) {
1810
+ // Installed voices (from local disk)
1811
+ const installed = scanInstalledVoices();
1812
+ _installedSet = new Set(installed);
1813
+
1814
+ // Merge: installed voices first, then uninstalled catalog voices
1815
+ const catalogOnly = _catalogEntries
1816
+ .filter(c => !_installedSet.has(c.voiceId))
1817
+ .map(c => c.voiceId);
1818
+ _allVoices = [...installed, ...catalogOnly];
1819
+ } else {
1820
+ _allVoices = voicesForProvider(_vtProvider, { scanInstalledVoices, getVoiceMeta }).map(v => v.id);
1821
+ _installedSet = new Set(_allVoices);
1822
+ }
1744
1823
 
1745
1824
  const active = providerService.getActiveVoiceId();
1746
1825
  const favorites = getFavorites(configService);