agentvibes 5.13.0 → 5.14.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 (82) hide show
  1. package/.claude/audio/ui/CREDITS.txt +16 -16
  2. package/.claude/commands/agent-vibes-bmad-voices.md +117 -117
  3. package/.claude/commands/agent-vibes-rdp.md +24 -24
  4. package/.claude/config/audio-effects.cfg.sample +52 -52
  5. package/.claude/docs/TERMUX_SETUP.md +408 -408
  6. package/.claude/github-star-reminder.txt +1 -1
  7. package/.claude/hooks/kokoro-server.py +219 -219
  8. package/.claude/hooks/kokoro-tts.py +141 -141
  9. package/.claude/hooks/party-stage-roster.py +328 -328
  10. package/.claude/hooks/play-tts-kokoro.sh +7 -2
  11. package/.claude/hooks/play-tts-ssh-remote.sh +38 -3
  12. package/.claude/hooks/requirements.txt +6 -6
  13. package/.claude/hooks/soprano-gradio-synth.py +139 -139
  14. package/.claude/hooks/translator.py +237 -237
  15. package/.claude/hooks-windows/kokoro-server.py +219 -219
  16. package/.claude/hooks-windows/kokoro-tts.py +107 -107
  17. package/.claude/hooks-windows/play-tts.ps1 +17 -0
  18. package/.claude/hooks-windows/soprano-gradio-synth.py +153 -153
  19. package/.claude/verbosity.txt +1 -1
  20. package/.clawdbot/README.md +105 -105
  21. package/CLAUDE.md +84 -176
  22. package/README.md +27 -2
  23. package/RELEASE_NOTES.md +86 -0
  24. package/WINDOWS-SETUP.md +208 -208
  25. package/bin/agent-vibes +39 -39
  26. package/bin/mcp-server.js +121 -121
  27. package/bin/test-bmad-pr +78 -78
  28. package/mcp-server/QUICK_START.md +203 -203
  29. package/mcp-server/README.md +345 -345
  30. package/mcp-server/examples/claude_desktop_config.json +11 -11
  31. package/mcp-server/examples/claude_desktop_config_piper.json +9 -9
  32. package/mcp-server/examples/custom_instructions.md +169 -169
  33. package/mcp-server/install-deps.js +12 -2
  34. package/mcp-server/server.py +2111 -2085
  35. package/mcp-server/test_mcp_correctness.py +504 -504
  36. package/mcp-server/test_windows_script_parity.py +339 -339
  37. package/package.json +7 -4
  38. package/setup-ssh-receiver.ps1 +259 -0
  39. package/setup-windows.ps1 +39 -29
  40. package/src/cli/list-personalities.js +110 -110
  41. package/src/commands/bmad-voices.js +394 -394
  42. package/src/console/brand-colors.js +13 -13
  43. package/src/console/constants/personalities.js +44 -44
  44. package/src/console/tabs/agents-tab.js +14 -4
  45. package/src/console/tabs/help-tab.js +314 -314
  46. package/src/console/tabs/readme-tab.js +272 -272
  47. package/src/console/tabs/settings-tab.js +31 -6
  48. package/src/console/tabs/setup-tab.js +154 -32
  49. package/src/console/tabs/voices-tab.js +48 -6
  50. package/src/console/widgets/destroy-list.js +25 -25
  51. package/src/console/widgets/notice.js +55 -55
  52. package/src/installer/language-screen.js +31 -31
  53. package/src/installer/music-file-input.js +304 -304
  54. package/src/installer.js +103 -43
  55. package/src/services/language-service.js +47 -47
  56. package/src/services/llm-provider-service.js +4 -1
  57. package/src/services/provider-voice-catalog.js +9 -5
  58. package/src/utils/audio-format-validator.js +277 -277
  59. package/src/utils/dependency-checker.js +469 -469
  60. package/src/utils/file-ownership-verifier.js +358 -358
  61. package/src/utils/music-file-validator.js +285 -285
  62. package/src/utils/preview-list-prompt.js +144 -144
  63. package/src/utils/secure-music-storage.js +412 -412
  64. package/templates/agentvibes-receiver.ps1 +311 -0
  65. package/templates/agentvibes-receiver.sh +297 -297
  66. package/voice-assignments.json +8244 -8244
  67. package/.agentvibes/config.json +0 -17
  68. package/.agentvibes/install-manifest.json +0 -342
  69. package/.claude/config/audio-effects.cfg +0 -7
  70. package/.claude/config/audio-effects.cfg.bak-kokoro +0 -7
  71. package/.claude/config/background-music-enabled.txt +0 -1
  72. package/.claude/config/background-music-position.txt +0 -27
  73. package/.claude/config/background-music-volume.txt +0 -1
  74. package/.claude/config/background-music.cfg +0 -1
  75. package/.claude/config/background-music.txt +0 -1
  76. package/.claude/config/language.txt +0 -1
  77. package/.claude/config/personality.txt +0 -1
  78. package/.claude/config/reverb-level.txt +0 -1
  79. package/.claude/config/tts-speech-rate.txt +0 -1
  80. package/.claude/config/tts-verbosity.txt +0 -1
  81. package/.claude/hooks-windows/audio-cache-utils.ps1.user.bak +0 -119
  82. package/.claude/hooks-windows/soprano-gradio-synth.py.user.bak +0 -153
@@ -20,7 +20,7 @@ import { promisify } from 'node:util';
20
20
  import fs from 'node:fs';
21
21
  import { promises as _fsP } from 'node:fs';
22
22
  import { SUPPORTED_LANGUAGES, t } from '../../i18n/strings.js';
23
- import { ELEVENLABS_VOICES, KOKORO_VOICE_IDS } from '../../services/provider-voice-catalog.js';
23
+ import { ELEVENLABS_VOICES, KOKORO_VOICE_IDS, voicesForProvider } from '../../services/provider-voice-catalog.js';
24
24
  import {
25
25
  PROVIDERS,
26
26
  checkClaudeInstalled, checkCopilotInstalled, checkCodexInstalled,
@@ -36,14 +36,14 @@ import {
36
36
  loadLlmConfigSync, saveLlmConfigSync, resolveCfgPath,
37
37
  } from '../../services/llm-provider-service.js';
38
38
  import {
39
- getAvailableEngines, getEngineStatuses, checkEngineInstalled,
39
+ getAvailableEngines, getAllEngines, checkEngineInstalled, receiverProviderId,
40
40
  } from '../../services/tts-engine-service.js';
41
41
  import { openReverbPicker, formatEffectLabel } from '../widgets/reverb-picker.js';
42
42
  import { openTrackPicker, openVolumeInput } from '../widgets/track-picker.js';
43
43
  import { renderHelpBar, selectorTitle } from '../widgets/help-bar.js';
44
44
  import { formatTrackName } from '../widgets/format-utils.js';
45
45
  import { destroyList } from '../widgets/destroy-list.js';
46
- import { scanInstalledVoices, getVoiceMeta, genderIconTag, formatVoiceRow, voiceRowHeader, PIPER_VOICES_DIR, SAMPLE_PHRASES, parseMultiSpeaker, getFavorites, getThumbsDown, toggleFavorite, toggleThumbsUp, toggleThumbsDown } from './voices-tab.js';
46
+ import { scanInstalledVoices, getVoiceMeta, previewPhrase, 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
49
  import { buildBlingCommand, playBlingCue } from '../bling.js';
@@ -175,6 +175,20 @@ const NATIVE_ENGINE_VOICES = {
175
175
  elevenlabs: { id: 'EXAVITQu4vr4xnSDxMaL', label: 'ElevenLabs' },
176
176
  };
177
177
 
178
+ // SAPI and macOS Say are NATIVE but MULTI-voice (many built-in system voices),
179
+ // unlike soprano (a genuine single-voice engine). They get the full voice picker
180
+ // and a real default voice, not the engine-id placeholder.
181
+ const MULTI_VOICE_NATIVE = new Set(['sapi', 'macos-say']);
182
+
183
+ /** Default voice to assign when an engine is first selected in a per-LLM draft. */
184
+ function defaultVoiceForEngine(engine) {
185
+ if (MULTI_VOICE_NATIVE.has(engine)) {
186
+ const list = voicesForProvider(engine, { scanInstalledVoices, getVoiceMeta });
187
+ return list[0]?.id || NATIVE_ENGINE_VOICES[engine]?.id || '';
188
+ }
189
+ return NATIVE_ENGINE_VOICES[engine]?.id || '';
190
+ }
191
+
178
192
  // Static built-in ElevenLabs premade voices — universal to every ElevenLabs
179
193
  // account. The stored config value is the raw ElevenLabs voice_id; the TTS hook
180
194
  // accepts raw IDs directly (its ^[a-zA-Z0-9]{10,40}$ path), so there is no
@@ -810,7 +824,6 @@ export function createSetupTab(screen, services) {
810
824
  const providerStatusTexts = [];
811
825
 
812
826
  // Transport provider row widgets (Configure-only, no Install/Remove)
813
- const transportRows = [];
814
827
 
815
828
  // Info box for provider details
816
829
  const infoBox = blessed.box({
@@ -1229,8 +1242,14 @@ export function createSetupTab(screen, services) {
1229
1242
  style: { bg: COLORS.contentBg },
1230
1243
  });
1231
1244
 
1245
+ // _bgRestoreFn is modal-scoped so _killPreview can restore bg music synchronously,
1246
+ // eliminating the race condition when Preview is clicked twice rapidly.
1232
1247
  let _previewModalProc = null;
1248
+ let _bgRestoreFn = null;
1233
1249
  function _killPreview() {
1250
+ // Restore bg music immediately (synchronously) before killing the process —
1251
+ // otherwise a second Preview reads bgWas=true before the first's exit fires.
1252
+ if (_bgRestoreFn) { _bgRestoreFn(); _bgRestoreFn = null; }
1234
1253
  if (_previewModalProc) { try { _previewModalProc.kill(); } catch {} _previewModalProc = null; }
1235
1254
  }
1236
1255
 
@@ -1266,6 +1285,24 @@ export function createSetupTab(screen, services) {
1266
1285
  const hooksSubdir = process.platform === 'win32' ? 'hooks-windows' : 'hooks';
1267
1286
  const isWin = process.platform === 'win32' && !process.env.WSL_DISTRO_NAME;
1268
1287
  const sampleText = 'This is how your Hermes audio settings sound right now.';
1288
+
1289
+ // Temporarily enable background music for preview if a track is configured.
1290
+ // Preview must play everything combined — voice, effects AND music. Write to
1291
+ // targetDir (project): audio-processor.sh checks CLAUDE_PROJECT_DIR first
1292
+ // (set to targetDir in the subprocess env below).
1293
+ if (!!draft.bgTrack) {
1294
+ const bgEnabledFile = path.join(targetDir, '.claude', 'config', 'background-music-enabled.txt');
1295
+ let bgWas = false;
1296
+ try { bgWas = fs.readFileSync(bgEnabledFile, 'utf8').trim() === 'true'; } catch {}
1297
+ if (!bgWas) {
1298
+ try {
1299
+ fs.mkdirSync(path.dirname(bgEnabledFile), { recursive: true });
1300
+ fs.writeFileSync(bgEnabledFile, 'true', 'utf8');
1301
+ } catch {}
1302
+ _bgRestoreFn = () => { try { fs.writeFileSync(bgEnabledFile, 'false', 'utf8'); } catch {} };
1303
+ }
1304
+ }
1305
+
1269
1306
  let cmd, args;
1270
1307
  if (draft.ttsEngine === 'elevenlabs') {
1271
1308
  // ElevenLabs has no Windows provider; route through the bash orchestrator
@@ -1288,12 +1325,19 @@ export function createSetupTab(screen, services) {
1288
1325
  stdio: 'ignore', windowsHide: true,
1289
1326
  env: {
1290
1327
  ...process.env, CLAUDE_PROJECT_DIR: targetDir,
1328
+ // audition = keep the draft's EXACT voice (F1). Force the receiver engine
1329
+ // ONLY when this row has an explicit engine — an empty engine means
1330
+ // "use the global/voice-derived engine", and forcing piper here would
1331
+ // shadow a kokoro/SAPI row voice on the receiver (Fable review).
1332
+ AGENTVIBES_VOICE_SOURCE: 'audition',
1333
+ ...(draft.ttsEngine ? { AGENTVIBES_RECEIVER_PROVIDER_OVERRIDE: receiverProviderId(draft.ttsEngine) } : {}),
1291
1334
  ...(draft.ttsEngine === 'elevenlabs' ? { AGENTVIBES_FORCE_PROVIDER: 'elevenlabs' } : {}),
1292
1335
  },
1293
1336
  });
1294
1337
  _previewModalProc = proc;
1295
1338
  proc.on('exit', (code) => {
1296
1339
  _previewModalProc = null;
1340
+ if (_bgRestoreFn) { _bgRestoreFn(); _bgRestoreFn = null; }
1297
1341
  if (!_closed) {
1298
1342
  if (code !== 0 && code !== null) {
1299
1343
  const engineLabel = NATIVE_ENGINE_VOICES[draft.ttsEngine]?.label || draft.ttsEngine || 'engine';
@@ -1305,7 +1349,11 @@ export function createSetupTab(screen, services) {
1305
1349
  }
1306
1350
  }
1307
1351
  });
1308
- proc.on('error', () => { _previewModalProc = null; if (!_closed) { previewLine.setContent('{red-fg}Preview failed{/red-fg}'); screen.render(); } });
1352
+ proc.on('error', () => {
1353
+ _previewModalProc = null;
1354
+ if (_bgRestoreFn) { _bgRestoreFn(); _bgRestoreFn = null; }
1355
+ if (!_closed) { previewLine.setContent('{red-fg}Preview failed{/red-fg}'); screen.render(); }
1356
+ });
1309
1357
  }
1310
1358
 
1311
1359
  const previewBtn = _modalBtn('Preview', 4, _playPreview);
@@ -1889,14 +1937,9 @@ export function createSetupTab(screen, services) {
1889
1937
  }
1890
1938
 
1891
1939
  function _autoSave(silent) {
1892
- saveTransportConfig(provider.id, draft).then(() => {
1893
- // Refresh status text below provider name
1894
- const row = transportRows.find(r => r.id === provider.id);
1895
- if (row && draft.host) {
1896
- row.statusText.setContent(`{#9e9e9e-fg}→ ${draft.host}:${draft.port}{/#9e9e9e-fg}`);
1897
- screen.render();
1898
- }
1899
- }).catch(() => {});
1940
+ // (Removed a dead status-text refresh keyed off `transportRows`, which was
1941
+ // always empty Sonar S2583. The config still saves below.)
1942
+ saveTransportConfig(provider.id, draft).catch(() => {});
1900
1943
  if (!silent) _showSavedToast(`${provider.name} Config`, `~/.agentvibes/transport-config.json`);
1901
1944
  }
1902
1945
 
@@ -2225,6 +2268,12 @@ export function createSetupTab(screen, services) {
2225
2268
  windowsHide: true,
2226
2269
  env: {
2227
2270
  ...process.env, CLAUDE_PROJECT_DIR: targetDir, AGENTVIBES_LLM_KEY: `llm:${llmKey}`,
2271
+ // audition = keep the draft's EXACT voice (F1). Force the receiver
2272
+ // engine ONLY when this row has an explicit engine (empty = use the
2273
+ // global/voice-derived engine; forcing piper would shadow a kokoro/
2274
+ // SAPI row voice on the receiver — Fable review).
2275
+ AGENTVIBES_VOICE_SOURCE: 'audition',
2276
+ ...(draft.ttsEngine ? { AGENTVIBES_RECEIVER_PROVIDER_OVERRIDE: receiverProviderId(draft.ttsEngine) } : {}),
2228
2277
  ...(draft.ttsEngine === 'elevenlabs' ? { AGENTVIBES_FORCE_PROVIDER: 'elevenlabs' } : {}),
2229
2278
  },
2230
2279
  });
@@ -2478,8 +2527,24 @@ export function createSetupTab(screen, services) {
2478
2527
  }
2479
2528
  navigationService?.openModal(null, _closePicker);
2480
2529
 
2481
- const engines = getEngineStatuses();
2530
+ // Show ALL engines (like the Settings → Default TTS Engine picker), not just
2531
+ // the platform-native ones — otherwise Windows SAPI / macOS Say vanish on a
2532
+ // Linux/other host even though a remote receiver could synthesize them. When
2533
+ // this LLM (or the global default) is in remote mode the receiver renders the
2534
+ // audio, so off-platform engines are valid, selectable choices tagged
2535
+ // "(Receiver)"; locally they stay greyed "(Not supported)".
2536
+ const _isRemote = draft.mode === 'remote'
2537
+ || (configService?.getConfig?.()?.audio_destination ?? 'local') === 'remote';
2538
+ const engines = getAllEngines().map(e => ({
2539
+ ...e,
2540
+ installed: e.supported ? checkEngineInstalled(e.id) : false,
2541
+ }));
2482
2542
  const items = engines.map(e => {
2543
+ if (!e.supported) {
2544
+ return _isRemote
2545
+ ? `{cyan-fg} ${e.name.padEnd(20)} (Receiver){/cyan-fg} ${e.desc}`
2546
+ : `{#607d8b-fg} ${e.name.padEnd(20)} (Not supported){/#607d8b-fg}`;
2547
+ }
2483
2548
  const status = e.installed ? '{green-fg}[OK]{/green-fg}' : '{yellow-fg}[Not Found]{/yellow-fg}';
2484
2549
  return ` ${e.name.padEnd(20)} ${status} ${e.desc}`;
2485
2550
  });
@@ -2533,18 +2598,22 @@ export function createSetupTab(screen, services) {
2533
2598
  const engine = idx > 0 ? engines[idx - 1] : null;
2534
2599
  if (idx > 0 && !engine) { _closePicker(); return; }
2535
2600
  const selectedEngine = engine ? engine.id : '';
2536
- // Guard: block selection of non-installed optional engines
2601
+ // Guard selection. In remote mode the receiver synthesizes, so any engine
2602
+ // (incl. off-platform SAPI/macOS) is allowed. Locally, block engines that
2603
+ // can't run here: off-platform ones, and optional engines not installed.
2537
2604
  if (selectedEngine) {
2538
2605
  const engineStatus = engines.find(e => e.id === selectedEngine);
2539
- if (engineStatus && !engineStatus.installed && !engineStatus.native) {
2540
- box.setLabel(` {red-fg} ${engineStatus.name} is not installed — go to Setup > TTS Engines to install {/red-fg} `);
2606
+ const _flash = (msg) => {
2607
+ box.setLabel(` {red-fg} ${msg} {/red-fg} `);
2541
2608
  screen.render();
2542
- setTimeout(() => {
2543
- if (!box.destroyed) {
2544
- box.setLabel(ENGINE_TITLE);
2545
- screen.render();
2546
- }
2547
- }, 3000);
2609
+ setTimeout(() => { if (!box.destroyed) { box.setLabel(ENGINE_TITLE); screen.render(); } }, 3000);
2610
+ };
2611
+ if (engineStatus && !_isRemote && !engineStatus.supported) {
2612
+ _flash(`${engineStatus.name} runs on the receiver — switch this LLM to a remote receiver to use it`);
2613
+ return;
2614
+ }
2615
+ if (engineStatus && !_isRemote && engineStatus.supported && !engineStatus.installed && !engineStatus.native) {
2616
+ _flash(`${engineStatus.name} is not installed — go to Setup > TTS Engines to install`);
2548
2617
  return;
2549
2618
  }
2550
2619
  }
@@ -2558,8 +2627,12 @@ export function createSetupTab(screen, services) {
2558
2627
  const looksLikeElId = /^[A-Za-z0-9]{10,40}$/.test(draft.voice || '');
2559
2628
  draft.voice = (elevenLabsVoiceName(draft.voice) || looksLikeElId)
2560
2629
  ? draft.voice : ELEVENLABS_DEFAULT_VOICE_ID;
2630
+ } else if (MULTI_VOICE_NATIVE.has(selectedEngine)
2631
+ && voicesForProvider(selectedEngine, { scanInstalledVoices, getVoiceMeta }).some(v => v.id === draft.voice)) {
2632
+ // Re-selecting the SAME multi-voice native engine must NOT discard an
2633
+ // already-valid chosen voice (e.g. Zira -> David) — keep it (Fable review).
2561
2634
  } else {
2562
- draft.voice = NATIVE_ENGINE_VOICES[selectedEngine]?.id || '';
2635
+ draft.voice = defaultVoiceForEngine(selectedEngine);
2563
2636
  }
2564
2637
  _closePicker();
2565
2638
  });
@@ -3196,7 +3269,7 @@ export function createSetupTab(screen, services) {
3196
3269
  ? '안녕하세요, 코코로 한국어 음성 미리보기입니다.'
3197
3270
  : _pfx2 === 'jf' || _pfx2 === 'jm'
3198
3271
  ? 'こんにちは、これはKokoroの日本語音声プレビューです。'
3199
- : `Hi, I am the ${voiceId.slice(3)} Kokoro voice.`;
3272
+ : previewPhrase(voiceId);
3200
3273
 
3201
3274
  // Bling now — a real preview is committed (past the install-prompt and
3202
3275
  // toggle-off early returns). Fires for both local and remote paths.
@@ -3726,7 +3799,7 @@ export function createSetupTab(screen, services) {
3726
3799
  const elScript = path.join(packageDir, '.claude', 'hooks', 'play-tts-elevenlabs.sh');
3727
3800
  let proc;
3728
3801
  try {
3729
- proc = _spawnAudio(resolveBash(), [elScript, `Hi, I am ${v.name}.`, v.id], { // NOSONAR — local hook on user's PATH
3802
+ proc = _spawnAudio(resolveBash(), [elScript, `Hi, I'm ${v.name} from ElevenLabs.`, v.id], { // NOSONAR — local hook on user's PATH
3730
3803
  stdio: 'ignore',
3731
3804
  env: { ...process.env, CLAUDE_PROJECT_DIR: targetDir },
3732
3805
  });
@@ -3827,9 +3900,13 @@ export function createSetupTab(screen, services) {
3827
3900
  destroyList(vpModal, screen, onDone);
3828
3901
  }
3829
3902
 
3830
- // AVI-S5.1/5.2: Single-item overlay for non-Piper engines.
3831
- // scanInstalledVoices() is NOT called; Space previews via the correct engine binary.
3832
- const nativeVoice = NATIVE_ENGINE_VOICES[draft.ttsEngine];
3903
+ // AVI-S5.1/5.2: Single-item overlay for TRUE single-voice engines (soprano).
3904
+ // SAPI / macOS Say are native but MULTI-voice they fall through to the full
3905
+ // voice picker below (populated from their built-in catalog) so a specific
3906
+ // voice (e.g. Zira) can be chosen and previewed, not just the engine name.
3907
+ const nativeVoice = MULTI_VOICE_NATIVE.has(draft.ttsEngine)
3908
+ ? null
3909
+ : NATIVE_ENGINE_VOICES[draft.ttsEngine];
3833
3910
  if (nativeVoice) {
3834
3911
  draft.voice = nativeVoice.id;
3835
3912
  let _nvClosed = false;
@@ -3890,7 +3967,10 @@ export function createSetupTab(screen, services) {
3890
3967
  screen.render();
3891
3968
  return;
3892
3969
  }
3893
- const phrase = `Hi, I am the ${nativeVoice.label} voice.`;
3970
+ const _engineLabel = nativeVoice.id === 'macos' ? 'macOS'
3971
+ : (nativeVoice.id && nativeVoice.id.includes('sapi')) ? 'Windows SAPI'
3972
+ : (nativeVoice.id || 'your system');
3973
+ const phrase = `Hi, I'm ${nativeVoice.label} from ${_engineLabel}.`;
3894
3974
  const engine = nativeVoice.id;
3895
3975
 
3896
3976
  function _spawnAndTrack(cmd, args, opts) {
@@ -4091,7 +4171,10 @@ export function createSetupTab(screen, services) {
4091
4171
  if (_vpClosed) return;
4092
4172
  const savedIdx = vpList.selected ?? 0;
4093
4173
  const savedScroll = vpList.childBase ?? 0;
4094
- _allVoices = scanInstalledVoices();
4174
+ // SAPI / macOS Say list their built-in catalog voices; Piper scans disk.
4175
+ _allVoices = MULTI_VOICE_NATIVE.has(draft.ttsEngine)
4176
+ ? voicesForProvider(draft.ttsEngine, { scanInstalledVoices, getVoiceMeta }).map(v => v.id)
4177
+ : scanInstalledVoices();
4095
4178
  // Sort by display name so the first-letter quick jump is intuitive
4096
4179
  _allVoices.sort((a, b) => getVoiceMeta(a).displayName.localeCompare(
4097
4180
  getVoiceMeta(b).displayName, undefined, { sensitivity: 'base' }));
@@ -4106,7 +4189,7 @@ export function createSetupTab(screen, services) {
4106
4189
  if (_previewVoiceId === voiceId) { _killVP(); vpPreviewLine.setContent(''); _refreshVP(); return; }
4107
4190
  _killVP();
4108
4191
 
4109
- const phrase = `Hi, my name is ${getVoiceMeta(voiceId).displayName}.`;
4192
+ const phrase = previewPhrase(voiceId);
4110
4193
 
4111
4194
  // Route through remote provider if active
4112
4195
  // Search order: targetDir → cwd → package root → home
@@ -4133,6 +4216,10 @@ export function createSetupTab(screen, services) {
4133
4216
  : targetDir;
4134
4217
  const _rEnv = {
4135
4218
  ..._spawnEnv, CLAUDE_PROJECT_DIR: targetDir,
4219
+ // audition = keep the EXACT previewed voice (F1); force the receiver
4220
+ // engine so SAPI/macOS voices render in their own engine, not the default.
4221
+ AGENTVIBES_VOICE_SOURCE: 'audition',
4222
+ AGENTVIBES_RECEIVER_PROVIDER_OVERRIDE: receiverProviderId(draft.ttsEngine || 'piper'),
4136
4223
  ...(llmKey ? { AGENTVIBES_LLM_KEY: `llm:${llmKey}` } : {}),
4137
4224
  };
4138
4225
  let rProc;
@@ -4163,6 +4250,41 @@ export function createSetupTab(screen, services) {
4163
4250
  return;
4164
4251
  }
4165
4252
 
4253
+ // Local preview of the native MULTI-voice engines (Windows SAPI, macOS Say):
4254
+ // the piper synth below cannot render these. Restores the local preview the
4255
+ // single-item overlay used to provide before SAPI/macOS became multi-voice
4256
+ // (Fable review — otherwise a local SAPI preview mis-ran piper and showed a
4257
+ // bogus "is Piper installed?" error).
4258
+ if (MULTI_VOICE_NATIVE.has(draft.ttsEngine)) {
4259
+ let nProc = null;
4260
+ if (_isWin && draft.ttsEngine === 'sapi') {
4261
+ let _sapiScript = '';
4262
+ for (const n of ['play-tts-sapi.ps1', 'play-tts-windows-sapi.ps1']) {
4263
+ for (const base of [packageDir, targetDir]) {
4264
+ const _p = path.join(base, '.claude', 'hooks-windows', n);
4265
+ if (fs.existsSync(_p)) { _sapiScript = _p; break; }
4266
+ }
4267
+ if (_sapiScript) break;
4268
+ }
4269
+ if (_sapiScript) {
4270
+ nProc = _spawnAudio('powershell', ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', _sapiScript, '-Text', phrase, '-VoiceOverride', voiceId], { stdio: 'ignore', windowsHide: true }); // NOSONAR
4271
+ }
4272
+ } else if (process.platform === 'darwin' && draft.ttsEngine === 'macos-say') {
4273
+ nProc = _spawnAudio('say', ['-v', voiceId, phrase], { stdio: 'ignore' }); // NOSONAR
4274
+ }
4275
+ if (!nProc) {
4276
+ // Engine can't run on THIS OS and this LLM isn't remote — nothing to play.
4277
+ if (!_vpClosed) { vpPreviewLine.setContent('{yellow-fg}♪ Runs on the receiver — set this LLM to remote to preview{/yellow-fg}'); _refreshVP(); }
4278
+ return;
4279
+ }
4280
+ _previewProc = nProc;
4281
+ _previewVoiceId = voiceId;
4282
+ if (!_vpClosed) { vpPreviewLine.setContent(`{cyan-fg}♪ Playing: ${voiceId}...{/cyan-fg}`); _refreshVP(); }
4283
+ nProc.on('exit', () => { if (_previewVoiceId === voiceId) { _previewVoiceId = null; _previewProc = null; if (!_vpClosed) { vpPreviewLine.setContent(''); _refreshVP(); } } });
4284
+ nProc.on('error', () => { _previewProc = null; _previewVoiceId = null; });
4285
+ return;
4286
+ }
4287
+
4166
4288
  const _ms = parseMultiSpeaker(voiceId);
4167
4289
  const voicePath = path.resolve(PIPER_VOICES_DIR, _ms.model + '.onnx');
4168
4290
  const safeBase = path.resolve(PIPER_VOICES_DIR);
@@ -17,6 +17,7 @@ import { buildAudioEnv, detectWavPlayer, getAllWavPlayers } from '../audio-env.j
17
17
  import { SURNAME_POOL, uniquifyVoiceName } from '../../utils/voice-names.js';
18
18
  import { voicesForProvider, ELEVENLABS_VOICES, KOKORO_VOICE_IDS, WINDOWS_SAPI_VOICES, MACOS_VOICES, kokoroGender } from '../../services/provider-voice-catalog.js';
19
19
  import { getProvider as _catalogGetProvider } from '../../services/provider-catalog.js';
20
+ import { receiverProviderId } from '../../services/tts-engine-service.js';
20
21
 
21
22
  /**
22
23
  * Resolve display name / gender / provider label for a NON-Piper catalog voice
@@ -531,7 +532,7 @@ export function scanInstalledVoices() {
531
532
  } catch { /* fall through to add as single voice */ }
532
533
  result.push(voiceId);
533
534
  }
534
- return result.sort();
535
+ return result.sort((a, b) => a.localeCompare(b));
535
536
  } catch {
536
537
  return [];
537
538
  }
@@ -724,6 +725,27 @@ export function getVoiceMeta(voiceId) {
724
725
  return result;
725
726
  }
726
727
 
728
+ /**
729
+ * Build the spoken preview phrase for a voice — announces BOTH the voice's name
730
+ * and the engine it renders on, consistently across every voice picker in the
731
+ * app (Piper / Kokoro / Windows SAPI / macOS / ElevenLabs). Centralised so a
732
+ * preview reads the same everywhere: "Hi, I'm Bella from Piper."
733
+ * @param {string} voiceId
734
+ * @returns {string}
735
+ */
736
+ export function previewPhrase(voiceId) {
737
+ const meta = getVoiceMeta(voiceId);
738
+ // Strip a parenthetical model note ("Piper (libritts)" -> "Piper") for a clean
739
+ // spoken line; fall back to a neutral label if the provider can't be resolved.
740
+ const engine = String(meta.provider || '').replace(/\s*\(.*\)\s*$/, '').trim() || 'AgentVibes';
741
+ // Kokoro ids (af_bella, am_michael) carry a 2-letter lang/gender prefix that the
742
+ // display name keeps ("Af Bella") — awkward aloud, so speak just the name part.
743
+ let name = meta.displayName;
744
+ const kok = voiceId.match(/^[a-z]{2}_([a-z0-9_]+)$/);
745
+ if (kok) name = kok[1].replace(/_/g, ' ').replace(/\b\w/g, c => c.toUpperCase());
746
+ return `Hi, I'm ${name} from ${engine}.`;
747
+ }
748
+
727
749
  // ---------------------------------------------------------------------------
728
750
 
729
751
  /**
@@ -1037,7 +1059,27 @@ export function createVoicesTab(screen, services) {
1037
1059
  if (!_isPiperLocal) {
1038
1060
  const _remote = _remoteProviders.includes(activeProvider);
1039
1061
  const isWindows = process.platform === 'win32' && !process.env.WSL_DISTRO_NAME;
1040
- const phrase = `Hi, my name is ${getVoiceMeta(voiceId).displayName}.`;
1062
+ const phrase = previewPhrase(voiceId);
1063
+ // A PREVIEW must render in the previewed voice's OWN engine — never the
1064
+ // receiver's persisted default (receiver-provider.txt). Compute the engine
1065
+ // these voices belong to (the Default TTS Engine, same rule the list uses)
1066
+ // and forward it as a ONE-OFF override the remote sender honours for this
1067
+ // invocation only. This changes no settings; it just makes preview preview.
1068
+ let _previewEngine = configService?.getConfig?.()?.ttsEngine
1069
+ || providerService?.getActiveProvider?.() || 'piper';
1070
+ // Guard: if that resolves to a transport (not a real engine), fall back to
1071
+ // piper so we never tell the receiver to "synthesize with ssh-remote".
1072
+ if (_remoteProviders.includes(_previewEngine)) _previewEngine = 'piper';
1073
+ // AGENTVIBES_VOICE_SOURCE=audition: the resolver must keep the EXACT voice
1074
+ // we're previewing (F1), not demote it to the per-LLM/provider voice —
1075
+ // required for engines whose ids aren't self-identifying (SAPI/macOS).
1076
+ // receiverProviderId maps the engine id to the receiver's provider id the
1077
+ // sender validates (sapi -> windows-sapi, macos-say -> macos).
1078
+ const _previewEnv = {
1079
+ ..._spawnEnv,
1080
+ AGENTVIBES_VOICE_SOURCE: 'audition',
1081
+ AGENTVIBES_RECEIVER_PROVIDER_OVERRIDE: receiverProviderId(_previewEngine),
1082
+ };
1041
1083
  // Hooks live in the AgentVibes package (projectRoot), not the user's project dir.
1042
1084
  // Fall back to CLAUDE_PROJECT_DIR / cwd only if the hook isn't at projectRoot
1043
1085
  // (e.g. when running from a published npm package with a different layout).
@@ -1052,13 +1094,13 @@ export function createVoicesTab(screen, services) {
1052
1094
  const playTts = path.join(hooksBase, '.claude', 'hooks-windows', 'play-tts.ps1');
1053
1095
  const _pdArgs = activeProjectDir ? ['-ProjectDir', activeProjectDir] : [];
1054
1096
  proc = spawn('powershell', ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', playTts, phrase, voiceId, ..._pdArgs], { // NOSONAR
1055
- stdio: 'ignore', detached: false, windowsHide: true, env: _spawnEnv,
1097
+ stdio: 'ignore', detached: false, windowsHide: true, env: _previewEnv,
1056
1098
  });
1057
1099
  } else {
1058
1100
  const playTts = path.join(hooksBase, '.claude', 'hooks', 'play-tts.sh');
1059
1101
  const _pdArgs = activeProjectDir ? ['--project-dir', activeProjectDir] : [];
1060
1102
  proc = spawn('bash', [playTts, phrase, voiceId, ..._pdArgs], { // NOSONAR
1061
- stdio: ['ignore', 'ignore', 'pipe'], detached: true, env: _spawnEnv,
1103
+ stdio: ['ignore', 'ignore', 'pipe'], detached: true, env: _previewEnv,
1062
1104
  cwd: process.cwd(),
1063
1105
  });
1064
1106
  }
@@ -1103,7 +1145,7 @@ export function createVoicesTab(screen, services) {
1103
1145
  }
1104
1146
 
1105
1147
  const tempWav = path.join(os.tmpdir(), `agentvibes-preview-${Date.now()}.wav`);
1106
- const phrase = `Hi, my name is ${getVoiceMeta(voiceId).displayName}.`;
1148
+ const phrase = previewPhrase(voiceId);
1107
1149
 
1108
1150
  // Synthesize: spawn piper; on Windows use the exe path directly
1109
1151
  const isWindows = process.platform === 'win32' && !process.env.WSL_DISTRO_NAME;
@@ -1351,7 +1393,7 @@ export function createVoicesTab(screen, services) {
1351
1393
  try { fs.unlinkSync(path.join(globalClaudeDir, 'tts-piper-speaker-id.txt')); } catch { /* ok */ }
1352
1394
  }
1353
1395
  // Also write global tts-voice.txt for shell scripts
1354
- try { fs.writeFileSync(path.join(globalClaudeDir, 'tts-voice.txt'), ms.isMultiSpeaker ? voiceId : voiceId, 'utf8'); } catch { /* ok */ }
1396
+ try { fs.writeFileSync(path.join(globalClaudeDir, 'tts-voice.txt'), voiceId, 'utf8'); } catch { /* ok */ }
1355
1397
  }
1356
1398
 
1357
1399
  function _openSelectVoiceModal(voiceId) {
@@ -1,25 +1,25 @@
1
- /**
2
- * AgentVibes TUI — Shared Widget: Modal Destroy Helper
3
- *
4
- * Force-invalidates blessed's olines buffer after destroying a modal widget.
5
- * Without this, blessed skips repainting cells where lines==olines and the
6
- * terminal retains stale modal content as ghost artifacts.
7
- */
8
-
9
- /**
10
- * Destroy a blessed list/box widget and force full screen repaint.
11
- *
12
- * @param {object} widget - blessed widget to destroy
13
- * @param {object} screen - blessed screen instance
14
- * @param {Function} [onClose] - optional callback after destruction
15
- */
16
- export function destroyList(widget, screen, onClose) {
17
- widget.destroy();
18
- try {
19
- for (let r = 0; r < screen.height; r++)
20
- for (let c = 0; c < screen.width; c++)
21
- if (screen.olines[r]?.[c]) screen.olines[r][c][0] = -1;
22
- } catch {}
23
- onClose?.();
24
- screen.render();
25
- }
1
+ /**
2
+ * AgentVibes TUI — Shared Widget: Modal Destroy Helper
3
+ *
4
+ * Force-invalidates blessed's olines buffer after destroying a modal widget.
5
+ * Without this, blessed skips repainting cells where lines==olines and the
6
+ * terminal retains stale modal content as ghost artifacts.
7
+ */
8
+
9
+ /**
10
+ * Destroy a blessed list/box widget and force full screen repaint.
11
+ *
12
+ * @param {object} widget - blessed widget to destroy
13
+ * @param {object} screen - blessed screen instance
14
+ * @param {Function} [onClose] - optional callback after destruction
15
+ */
16
+ export function destroyList(widget, screen, onClose) {
17
+ widget.destroy();
18
+ try {
19
+ for (let r = 0; r < screen.height; r++)
20
+ for (let c = 0; c < screen.width; c++)
21
+ if (screen.olines[r]?.[c]) screen.olines[r][c][0] = -1;
22
+ } catch {}
23
+ onClose?.();
24
+ screen.render();
25
+ }
@@ -1,55 +1,55 @@
1
- /**
2
- * AgentVibes TUI — Shared Widget: Notice Toast
3
- *
4
- * Displays a short auto-dismissing notice modal centred on screen.
5
- * Usable from any tab; no settings-specific state required.
6
- */
7
-
8
- import { destroyList } from './destroy-list.js';
9
-
10
- const IS_TEST = process.env.AGENTVIBES_TEST_MODE === 'true';
11
- let blessed;
12
- if (!IS_TEST) {
13
- const { default: b } = await import('blessed');
14
- blessed = b;
15
- }
16
-
17
- /**
18
- * Show a temporary notice that auto-dismisses after 2.5 seconds.
19
- *
20
- * @param {object} screen - blessed screen instance
21
- * @param {string} message - text to display
22
- * @param {object} [opts]
23
- * @param {string} [opts.bg='#0a0e1a'] - background colour
24
- * @param {string} [opts.fg='#e3f2fd'] - foreground colour
25
- * @param {string} [opts.borderFg='bright-cyan'] - border colour
26
- * @param {number} [opts.durationMs=2500] - auto-dismiss delay in ms
27
- */
28
- export function showNotice(screen, message, opts = {}) {
29
- const bg = opts.bg ?? '#0a0e1a';
30
- const fg = opts.fg ?? '#e3f2fd';
31
- const borderFg = opts.borderFg ?? 'bright-cyan';
32
- const durationMs = opts.durationMs ?? 2500;
33
-
34
- const width = Math.max(28, message.length + 6);
35
- const modal = blessed.box({
36
- parent: screen,
37
- top: 'center',
38
- left: 'center',
39
- width,
40
- height: 3,
41
- border: { type: 'line' },
42
- tags: true,
43
- content: `{center}${message}{/center}`,
44
- style: {
45
- fg,
46
- bg,
47
- border: { fg: borderFg },
48
- },
49
- });
50
- screen.render();
51
-
52
- setTimeout(() => {
53
- destroyList(modal, screen);
54
- }, durationMs);
55
- }
1
+ /**
2
+ * AgentVibes TUI — Shared Widget: Notice Toast
3
+ *
4
+ * Displays a short auto-dismissing notice modal centred on screen.
5
+ * Usable from any tab; no settings-specific state required.
6
+ */
7
+
8
+ import { destroyList } from './destroy-list.js';
9
+
10
+ const IS_TEST = process.env.AGENTVIBES_TEST_MODE === 'true';
11
+ let blessed;
12
+ if (!IS_TEST) {
13
+ const { default: b } = await import('blessed');
14
+ blessed = b;
15
+ }
16
+
17
+ /**
18
+ * Show a temporary notice that auto-dismisses after 2.5 seconds.
19
+ *
20
+ * @param {object} screen - blessed screen instance
21
+ * @param {string} message - text to display
22
+ * @param {object} [opts]
23
+ * @param {string} [opts.bg='#0a0e1a'] - background colour
24
+ * @param {string} [opts.fg='#e3f2fd'] - foreground colour
25
+ * @param {string} [opts.borderFg='bright-cyan'] - border colour
26
+ * @param {number} [opts.durationMs=2500] - auto-dismiss delay in ms
27
+ */
28
+ export function showNotice(screen, message, opts = {}) {
29
+ const bg = opts.bg ?? '#0a0e1a';
30
+ const fg = opts.fg ?? '#e3f2fd';
31
+ const borderFg = opts.borderFg ?? 'bright-cyan';
32
+ const durationMs = opts.durationMs ?? 2500;
33
+
34
+ const width = Math.max(28, message.length + 6);
35
+ const modal = blessed.box({
36
+ parent: screen,
37
+ top: 'center',
38
+ left: 'center',
39
+ width,
40
+ height: 3,
41
+ border: { type: 'line' },
42
+ tags: true,
43
+ content: `{center}${message}{/center}`,
44
+ style: {
45
+ fg,
46
+ bg,
47
+ border: { fg: borderFg },
48
+ },
49
+ });
50
+ screen.render();
51
+
52
+ setTimeout(() => {
53
+ destroyList(modal, screen);
54
+ }, durationMs);
55
+ }