agentvibes 5.12.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 (72) hide show
  1. package/.claude/commands/agent-vibes/commands.json +0 -20
  2. package/.claude/commands/agent-vibes/unmute.md +6 -2
  3. package/.claude/config/audio-effects.cfg +6 -6
  4. package/.claude/github-star-reminder.txt +1 -1
  5. package/.claude/hooks/agentvibes-session-id.sh +69 -0
  6. package/.claude/hooks/bmad-party-speak.sh +20 -4
  7. package/.claude/hooks/bmad-speak.sh +60 -2
  8. package/.claude/hooks/bmad-tts-injector.sh +20 -1
  9. package/.claude/hooks/bmad-voice-manager.sh +25 -3
  10. package/.claude/hooks/clawdbot-receiver-SECURE.sh +21 -2
  11. package/.claude/hooks/clawdbot-receiver.sh +19 -1
  12. package/.claude/hooks/elevenlabs-voices.sh +62 -0
  13. package/.claude/hooks/kokoro-installer.sh +20 -10
  14. package/.claude/hooks/language-manager.sh +10 -3
  15. package/.claude/hooks/party-set-room.sh +71 -0
  16. package/.claude/hooks/party-stage-roster.py +328 -0
  17. package/.claude/hooks/personality-manager.sh +19 -2
  18. package/.claude/hooks/piper-voice-manager.sh +3 -2
  19. package/.claude/hooks/play-tts-agentvibes-receiver-for-voiceless-connections.sh +24 -5
  20. package/.claude/hooks/play-tts-elevenlabs.sh +38 -118
  21. package/.claude/hooks/play-tts-kokoro.sh +33 -6
  22. package/.claude/hooks/play-tts-soprano.sh +3 -2
  23. package/.claude/hooks/play-tts-ssh-remote.sh +37 -29
  24. package/.claude/hooks/play-tts-termux-ssh.sh +5 -4
  25. package/.claude/hooks/play-tts.sh +66 -61
  26. package/.claude/hooks/provider-catalog.json +352 -0
  27. package/.claude/hooks/provider-catalog.sh +161 -0
  28. package/.claude/hooks/provider-commands.sh +2 -1
  29. package/.claude/hooks/provider-manager.sh +47 -9
  30. package/.claude/hooks/python-resolver.sh +117 -0
  31. package/.claude/hooks/session-id.sh +56 -0
  32. package/.claude/hooks/session-start-tts.sh +39 -0
  33. package/.claude/hooks/speed-manager.sh +1 -1
  34. package/.claude/hooks/translate-manager.sh +3 -2
  35. package/.claude/hooks/translator.py +1 -1
  36. package/.claude/hooks/voice-manager.sh +242 -10
  37. package/.claude/hooks-windows/language-manager.ps1 +7 -1
  38. package/.claude/hooks-windows/personality-manager.ps1 +16 -1
  39. package/.claude/hooks-windows/play-tts-kokoro.ps1 +20 -4
  40. package/.claude/hooks-windows/play-tts.ps1 +32 -3
  41. package/.claude/hooks-windows/provider-catalog.ps1 +140 -0
  42. package/.claude/hooks-windows/provider-manager.ps1 +63 -8
  43. package/.claude/hooks-windows/tts-watcher.ps1 +33 -12
  44. package/.claude/hooks-windows/voice-manager-windows.ps1 +49 -0
  45. package/.mcp.json +0 -7
  46. package/README.md +12 -3
  47. package/RELEASE_NOTES.md +43 -0
  48. package/mcp-server/server.py +146 -49
  49. package/mcp-server/test_mcp_correctness.py +20 -2
  50. package/mcp-server/test_windows_script_parity.py +0 -2
  51. package/package.json +1 -1
  52. package/src/cli/list-voices.js +218 -114
  53. package/src/console/bling.js +71 -0
  54. package/src/console/music-preview.js +79 -0
  55. package/src/console/tabs/music-tab.js +16 -39
  56. package/src/console/tabs/settings-tab.js +195 -13
  57. package/src/console/tabs/setup-tab.js +9 -34
  58. package/src/console/tabs/voices-tab.js +83 -14
  59. package/src/console/widgets/track-picker.js +82 -0
  60. package/src/installer.js +124 -10
  61. package/src/services/provider-catalog.js +412 -0
  62. package/src/services/provider-voice-catalog.js +52 -73
  63. package/src/services/tts-engine-service.js +29 -0
  64. package/src/utils/provider-validator.js +62 -12
  65. package/.claude/commands/agent-vibes/language.md +0 -23
  66. package/.claude/commands/agent-vibes/learn.md +0 -67
  67. package/.claude/commands/agent-vibes/replay-target.md +0 -14
  68. package/.claude/commands/agent-vibes/target-voice.md +0 -26
  69. package/.claude/commands/agent-vibes/target.md +0 -30
  70. package/.claude/hooks/learn-manager.sh +0 -492
  71. package/.claude/hooks/replay-target-audio.sh +0 -95
  72. package/.claude/hooks-windows/learn-manager.ps1 +0 -241
@@ -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'], () => {
@@ -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) {
@@ -987,7 +1027,15 @@ export function createVoicesTab(screen, services) {
987
1027
  }
988
1028
  } catch {}
989
1029
 
990
- 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);
991
1039
  const isWindows = process.platform === 'win32' && !process.env.WSL_DISTRO_NAME;
992
1040
  const phrase = `Hi, my name is ${getVoiceMeta(voiceId).displayName}.`;
993
1041
  // Hooks live in the AgentVibes package (projectRoot), not the user's project dir.
@@ -1017,7 +1065,7 @@ export function createVoicesTab(screen, services) {
1017
1065
  _playingProcess = proc;
1018
1066
  _playingVoiceId = voiceId;
1019
1067
  refreshDisplay();
1020
- previewLine.setContent('{bright-magenta-fg}♪ Synthesizing on remote...{/bright-magenta-fg}');
1068
+ previewLine.setContent(`{bright-magenta-fg}♪ Synthesizing${_remote ? ' on remote' : ''}...{/bright-magenta-fg}`);
1021
1069
  screen.render();
1022
1070
  let _stderrBuf = '';
1023
1071
  if (proc.stderr) {
@@ -1688,10 +1736,16 @@ export function createVoicesTab(screen, services) {
1688
1736
  // genderIconTag has invisible color tags — pad with literal spaces (1 visible char + 3 spaces = 4)
1689
1737
  const gIcon = genderIconTag(gender);
1690
1738
  if (!installed) {
1691
- // Greyed-out row for uninstalled catalog voices close grey wrap around the icon so its colors show
1692
- 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}`;
1693
1744
  }
1694
- 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')}` : ''}`;
1695
1749
  });
1696
1750
  }
1697
1751
 
@@ -1742,15 +1796,30 @@ export function createVoicesTab(screen, services) {
1742
1796
  _metaCache.clear();
1743
1797
  }
1744
1798
 
1745
- // Installed voices (from local disk)
1746
- const installed = scanInstalledVoices();
1747
- _installedSet = new Set(installed);
1748
-
1749
- // Merge: installed voices first, then uninstalled catalog voices
1750
- const catalogOnly = _catalogEntries
1751
- .filter(c => !_installedSet.has(c.voiceId))
1752
- .map(c => c.voiceId);
1753
- _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
+ }
1754
1823
 
1755
1824
  const active = providerService.getActiveVoiceId();
1756
1825
  const favorites = getFavorites(configService);
@@ -8,11 +8,19 @@
8
8
 
9
9
  import fs from 'node:fs';
10
10
  import path from 'node:path';
11
+ import { fileURLToPath } from 'node:url';
11
12
  import { destroyList } from './destroy-list.js';
12
13
  import { BRAND_PINK } from '../brand-colors.js';
13
14
  import { renderHelpBar, selectorTitle } from './help-bar.js';
14
15
  import { formatTrackName } from './format-utils.js';
15
16
  import { buildAudioEnv, spawnMp3Player } from '../audio-env.js';
17
+ import { resolveMusicProvider, spawnMusicRemote } from '../music-preview.js';
18
+ import { playBlingCue } from '../bling.js';
19
+
20
+ // AgentVibes package/repo root — resolves bundled assets (bling cue) and is the
21
+ // package fallback when resolving the active provider for remote forwarding.
22
+ const _WIDGET_DIR = path.dirname(fileURLToPath(import.meta.url));
23
+ const _PKG_ROOT = path.resolve(_WIDGET_DIR, '..', '..', '..');
16
24
 
17
25
  const IS_TEST = process.env.AGENTVIBES_TEST_MODE === 'true';
18
26
  let blessed;
@@ -237,6 +245,10 @@ export function openTrackPicker(screen, currentTrack, currentVolume, onSelect, o
237
245
  const _spawnEnv = buildAudioEnv();
238
246
  let _previewProc = null;
239
247
  let _previewTrackId = null;
248
+ // Remote playback is fire-and-forget (the local sender exits in ms while the
249
+ // receiver keeps playing), so the toggle tracks the intended remote track
250
+ // here rather than relying on a live local process.
251
+ let _remotePreviewTrackId = null;
240
252
 
241
253
  function _killPreview() {
242
254
  if (_previewProc) {
@@ -244,9 +256,75 @@ export function openTrackPicker(screen, currentTrack, currentVolume, onSelect, o
244
256
  _previewProc = null;
245
257
  }
246
258
  _previewTrackId = null;
259
+ // Stop any remote preview so closing/selecting doesn't leave music playing
260
+ // on the receiver (the local equivalent of killing the player process).
261
+ if (_remotePreviewTrackId) {
262
+ const mp = resolveMusicProvider(_PKG_ROOT);
263
+ if (mp.remote) _sendRemote(mp, { stop: true });
264
+ _remotePreviewTrackId = null;
265
+ }
266
+ }
267
+
268
+ /**
269
+ * Forward a music track to the receiver (or stop it), mirroring the Music
270
+ * tab's remote-preview behavior. Fire-and-forget; UI errors surface in the
271
+ * title status area. Returns true if the send was launched.
272
+ * @param {{remote:boolean, projectDir:string}} mp
273
+ * @param {{track?:string, stop?:boolean}} opts
274
+ */
275
+ function _sendRemote(mp, { track = null, stop = false } = {}) {
276
+ let proc;
277
+ try {
278
+ proc = spawnMusicRemote({ packageRoot: _PKG_ROOT, projectDir: mp.projectDir, env: _spawnEnv, track, stop });
279
+ } catch {
280
+ if (!stop) {
281
+ _setStatus(' {red-fg}Remote preview failed{/red-fg} ');
282
+ setTimeout(() => { _setStatus(); }, 4000);
283
+ }
284
+ return false;
285
+ }
286
+ let _rerr = '';
287
+ if (proc.stderr) proc.stderr.on('data', d => { _rerr += d.toString(); });
288
+ proc.on('exit', (code) => {
289
+ if (stop || code === 0) return;
290
+ if (_remotePreviewTrackId === track) _remotePreviewTrackId = null;
291
+ const msg = _rerr.trim().split('\n').pop() || 'Remote preview failed';
292
+ _setStatus(` {red-fg}♪ ${msg}{/red-fg} `);
293
+ setTimeout(() => { _setStatus(); }, 4000);
294
+ });
295
+ proc.on('error', () => {
296
+ if (stop) return;
297
+ if (_remotePreviewTrackId === track) _remotePreviewTrackId = null;
298
+ _setStatus(' {red-fg}Remote preview failed{/red-fg} ');
299
+ setTimeout(() => { _setStatus(); }, 4000);
300
+ });
301
+ return true;
247
302
  }
248
303
 
249
304
  function _previewTrack(trackFile) {
305
+ // Transport provider: forward to the receiver instead of local playback
306
+ // (which is silent on a headless box) — parity with the Music tab.
307
+ const _mp = resolveMusicProvider(_PKG_ROOT);
308
+ if (_mp.remote) {
309
+ // Toggle off if same track → explicit music-stop signal
310
+ if (_remotePreviewTrackId === trackFile) {
311
+ _sendRemote(_mp, { stop: true });
312
+ _remotePreviewTrackId = null;
313
+ _setStatus();
314
+ return;
315
+ }
316
+ // Bling first (fire-and-forget, plays locally like the voice preview),
317
+ // then forward the track to the receiver.
318
+ playBlingCue(_PKG_ROOT);
319
+ if (_sendRemote(_mp, { track: trackFile })) {
320
+ _remotePreviewTrackId = trackFile;
321
+ const rlabel = tracks.find(t => t.file === trackFile)?.label ?? trackFile;
322
+ _setStatus(` {bright-cyan-fg}♪ ${rlabel} (Space to stop){/bright-cyan-fg} `);
323
+ }
324
+ return;
325
+ }
326
+
327
+ // ── Local playback ──────────────────────────────────────────────────────
250
328
  // Toggle off if same track
251
329
  if (_previewTrackId === trackFile) {
252
330
  _killPreview();
@@ -266,6 +344,10 @@ export function openTrackPicker(screen, currentTrack, currentVolume, onSelect, o
266
344
  return;
267
345
  }
268
346
 
347
+ // Bling first (fire-and-forget) — same readiness cue as the voice preview —
348
+ // then start local playback.
349
+ playBlingCue(_PKG_ROOT);
350
+
269
351
  const proc = spawnMp3Player(trackPath, _spawnEnv);
270
352
  if (!proc) {
271
353
  _setStatus(' {red-fg}No MP3 player found{/red-fg} ');