agentvibes 5.7.7 → 5.10.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. package/.agentvibes/config.json +0 -2
  2. package/.claude/config/audio-effects.cfg +4 -4
  3. package/.claude/config/background-music-enabled.txt +1 -0
  4. package/.claude/github-star-reminder.txt +1 -1
  5. package/.claude/hooks/play-tts-piper.sh +20 -13
  6. package/.claude/hooks/play-tts-ssh-remote.sh +2 -2
  7. package/.claude/hooks/voice-manager.sh +6 -0
  8. package/.claude/hooks-windows/audio-cache-utils.ps1 +119 -119
  9. package/.claude/hooks-windows/bmad-speak.ps1 +9 -38
  10. package/.claude/hooks-windows/play-tts-soprano.ps1 +13 -2
  11. package/.claude/hooks-windows/play-tts-windows-piper.ps1 +22 -16
  12. package/.mcp.json +13 -9
  13. package/README.md +33 -2
  14. package/RELEASE_NOTES.md +80 -0
  15. package/mcp-server/server.py +17 -7
  16. package/package.json +2 -2
  17. package/src/commands/install-mcp.js +270 -16
  18. package/src/console/app.js +3 -3
  19. package/src/console/audio-env.js +4 -1
  20. package/src/console/tabs/agents-tab.js +89 -66
  21. package/src/console/tabs/music-tab.js +4 -3
  22. package/src/console/tabs/receiver-tab.js +13 -13
  23. package/src/console/tabs/settings-tab.js +2 -2
  24. package/src/console/tabs/setup-tab.js +291 -47
  25. package/src/console/tabs/voices-tab.js +17 -5
  26. package/src/console/widgets/personality-picker.js +2 -2
  27. package/src/console/widgets/reverb-picker.js +1 -1
  28. package/src/installer.js +32 -27
  29. package/src/services/provider-service.js +1 -1
  30. package/src/services/tts-engine-service.js +2 -2
  31. package/src/utils/audio-duration-validator.js +2 -2
  32. package/src/utils/list-formatter.js +9 -3
  33. package/src/utils/platform-resolver.js +369 -0
  34. package/src/utils/provider-validator.js +9 -9
  35. package/.agentvibes/install-manifest.json +0 -442
  36. package/.claude/config/background-music-position.txt +0 -27
  37. package/.claude/config/background-music-volume.txt +0 -1
  38. package/.claude/config/background-music.cfg +0 -1
  39. package/.claude/config/background-music.txt +0 -1
  40. package/.claude/config/reverb-level.txt +0 -1
  41. package/.claude/config/tts-speech-rate.txt +0 -1
  42. package/.claude/config/tts-verbosity.txt +0 -1
  43. package/.claude/hooks/bmad-party-manager.sh +0 -225
  44. package/.claude/hooks/stop.sh +0 -38
  45. package/.claude/piper-voices-dir.txt +0 -1
  46. /package/.claude/audio/tracks/{CelestialVelvet.mp3 → celestial_velvet.mp3} +0 -0
@@ -47,6 +47,7 @@ import { buildAudioEnv, detectWavPlayer } from '../audio-env.js';
47
47
  import { spawn } from 'node:child_process';
48
48
  import os from 'node:os';
49
49
  import crypto from 'node:crypto';
50
+ import net from 'node:net';
50
51
 
51
52
  const _execFileAsync = promisify(execFile);
52
53
 
@@ -87,6 +88,60 @@ const COLORS = {
87
88
 
88
89
  const FOOTER_TEXT = '[Enter] Continue [Esc] Back [Tab] Next Tab [Q] Quit';
89
90
 
91
+ // Maps non-Piper engine IDs to their canonical voice ID and display label.
92
+ // Used by the voice picker, _buildFields display, and auto-save logic.
93
+ const NATIVE_ENGINE_VOICES = {
94
+ soprano: { id: 'soprano', label: 'Soprano' },
95
+ sapi: { id: 'sapi', label: 'Windows SAPI' },
96
+ 'macos-say': { id: 'macos-say', label: 'macOS Say' },
97
+ };
98
+
99
+ // ---------------------------------------------------------------------------
100
+ // Soprano WebUI auto-start helpers
101
+
102
+ function _checkSopranoPort(port) {
103
+ return new Promise((resolve) => {
104
+ const socket = net.createConnection({ host: '127.0.0.1', port });
105
+ socket.setTimeout(2000);
106
+ socket.once('connect', () => { socket.destroy(); resolve(true); });
107
+ socket.once('error', () => resolve(false));
108
+ socket.once('timeout', () => { socket.destroy(); resolve(false); });
109
+ // Absorb any late errors emitted after destroy() to prevent uncaught 'error' crash
110
+ socket.on('error', () => {});
111
+ });
112
+ }
113
+
114
+ // Timestamp of last soprano-webui spawn; prevents duplicate processes on rapid re-entry
115
+ let _sopranoSpawnedAt = 0;
116
+
117
+ async function _ensureSopranoWebUI(onStatus, signal) {
118
+ const port = parseInt(process.env.SOPRANO_PORT || '7860', 10);
119
+ if (signal?.aborted) return false;
120
+ if (await _checkSopranoPort(port)) return true;
121
+ onStatus('Starting Soprano WebUI...');
122
+ // Only spawn a new soprano-webui process if we haven't done so in the last 10 s
123
+ if (Date.now() - _sopranoSpawnedAt > 10_000) {
124
+ _sopranoSpawnedAt = Date.now();
125
+ try {
126
+ const p = spawn('soprano-webui', [], { // NOSONAR
127
+ stdio: 'ignore', detached: true, windowsHide: true,
128
+ shell: process.platform === 'win32',
129
+ });
130
+ p.unref();
131
+ } catch (e) {
132
+ process.stderr.write(`[AgentVibes] soprano-webui spawn failed: ${e.message}\n`);
133
+ }
134
+ }
135
+ for (let i = 0; i < 45; i++) {
136
+ if (signal?.aborted) return false;
137
+ await new Promise(r => setTimeout(r, 2000));
138
+ if (signal?.aborted) return false;
139
+ if (await _checkSopranoPort(port)) return true;
140
+ onStatus(`Starting Soprano WebUI... ${(i + 1) * 2}s`);
141
+ }
142
+ return false;
143
+ }
144
+
90
145
  // ---------------------------------------------------------------------------
91
146
  // Exported pure helpers (kept from install-tab for backward compat)
92
147
 
@@ -807,7 +862,7 @@ export function createSetupTab(screen, services) {
807
862
  function _buildFields() {
808
863
  const base = [
809
864
  { key: 'ttsEngine', label: 'TTS Engine', getValue: () => draft.ttsEngine || `(global: ${globalEngine})` },
810
- { key: 'voice', label: 'Voice', getValue: () => draft.voice || `(global: ${globalVoice})` },
865
+ { key: 'voice', label: 'Voice', getValue: () => NATIVE_ENGINE_VOICES[draft.voice]?.label ?? (draft.voice || `(global: ${globalVoice})`) },
811
866
  { key: 'pretext', label: 'Pretext', getValue: () => draft.pretext || '(none)' },
812
867
  { key: 'reverb', label: 'Reverb', getValue: () => {
813
868
  const p = REVERB_PRESETS.find(r => r.value === draft.reverbPreset);
@@ -893,7 +948,7 @@ export function createSetupTab(screen, services) {
893
948
 
894
949
  // Auto-save: persist both audio config and Hermes SSH config
895
950
  function _autoSave(silent) {
896
- const engine = draft.ttsEngine || (draft.voice ? 'piper' : '');
951
+ const engine = draft.ttsEngine || (draft.voice && !NATIVE_ENGINE_VOICES[draft.voice] ? 'piper' : '');
897
952
  saveLlmConfigSync('hermes', {
898
953
  voice: draft.voice,
899
954
  pretext: draft.pretext,
@@ -938,7 +993,19 @@ export function createSetupTab(screen, services) {
938
993
  env: { ...process.env, CLAUDE_PROJECT_DIR: targetDir },
939
994
  });
940
995
  _previewModalProc = proc;
941
- proc.on('exit', () => { _previewModalProc = null; if (!_closed) { previewLine.setContent(''); screen.render(); } });
996
+ proc.on('exit', (code) => {
997
+ _previewModalProc = null;
998
+ if (!_closed) {
999
+ if (code !== 0 && code !== null) {
1000
+ const engineLabel = NATIVE_ENGINE_VOICES[draft.ttsEngine]?.label || draft.ttsEngine || 'engine';
1001
+ previewLine.setContent(`{red-fg}Preview failed — is ${engineLabel} running/installed?{/red-fg}`);
1002
+ screen.render();
1003
+ setTimeout(() => { if (!_closed) { previewLine.setContent(''); screen.render(); } }, 4000);
1004
+ } else {
1005
+ previewLine.setContent(''); screen.render();
1006
+ }
1007
+ }
1008
+ });
942
1009
  proc.on('error', () => { _previewModalProc = null; if (!_closed) { previewLine.setContent('{red-fg}Preview failed{/red-fg}'); screen.render(); } });
943
1010
  }
944
1011
 
@@ -1092,7 +1159,7 @@ export function createSetupTab(screen, services) {
1092
1159
  const raw = fs.readFileSync(sshConfigPath, 'utf8');
1093
1160
  const seen = new Set();
1094
1161
  for (const line of raw.split('\n')) {
1095
- const m = line.match(/^\s*IdentityFile\s+(.+)\s*$/i);
1162
+ const m = line.match(/^\s*IdentityFile\s+(.+)\s*$/i); // NOSONAR
1096
1163
  if (m) {
1097
1164
  const expanded = m[1].trim().replace(/^~/, os.homedir());
1098
1165
  if (!seen.has(expanded)) { seen.add(expanded); keys.push(expanded); }
@@ -1230,7 +1297,7 @@ export function createSetupTab(screen, services) {
1230
1297
  try {
1231
1298
  const lines = fs.readFileSync(path.join(os.homedir(), '.ssh', 'config'), 'utf8').split('\n');
1232
1299
  for (const line of lines) {
1233
- const m = line.match(/^\s*[Hh]ost\s+(.+)$/);
1300
+ const m = line.match(/^\s*[Hh]ost\s+(.+)$/); // NOSONAR
1234
1301
  if (m) {
1235
1302
  for (const name of m[1].trim().split(/\s+/)) {
1236
1303
  if (!name.includes('*') && !name.includes('?') && !seen.has(name)) {
@@ -1541,10 +1608,10 @@ export function createSetupTab(screen, services) {
1541
1608
  const sampleText = 'This is how your audio settings sound right now.';
1542
1609
  const script = path.join(targetDir, '.claude', hooksSubdir, isWin ? 'play-tts.ps1' : 'play-tts.sh');
1543
1610
  const proc = isWin
1544
- ? spawn('powershell', ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', script, sampleText], {
1611
+ ? spawn('powershell', ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', script, sampleText], { // NOSONAR
1545
1612
  stdio: 'ignore', windowsHide: true, env: { ...process.env, CLAUDE_PROJECT_DIR: targetDir },
1546
1613
  })
1547
- : spawn('bash', [script, sampleText], {
1614
+ : spawn('bash', [script, sampleText], { // NOSONAR
1548
1615
  stdio: 'ignore', env: { ...process.env, CLAUDE_PROJECT_DIR: targetDir },
1549
1616
  });
1550
1617
  _previewProc = proc;
@@ -1675,7 +1742,7 @@ export function createSetupTab(screen, services) {
1675
1742
  function _buildFields() {
1676
1743
  const base = [
1677
1744
  { key: 'ttsEngine', label: 'TTS Engine', getValue: () => draft.ttsEngine || `(global: ${globalEngine})` },
1678
- { key: 'voice', label: 'Voice', getValue: () => draft.voice || `(global: ${globalVoice})` },
1745
+ { key: 'voice', label: 'Voice', getValue: () => NATIVE_ENGINE_VOICES[draft.voice]?.label ?? (draft.voice || `(global: ${globalVoice})`) },
1679
1746
  { key: 'pretext', label: 'Pretext', getValue: () => draft.pretext || '(none)' },
1680
1747
  { key: 'reverb', label: 'Reverb', getValue: () => {
1681
1748
  const p = REVERB_PRESETS.find(r => r.value === draft.reverbPreset);
@@ -1781,11 +1848,13 @@ export function createSetupTab(screen, services) {
1781
1848
  // eliminating the race condition when Preview is clicked twice rapidly.
1782
1849
  let _previewModalProc = null;
1783
1850
  let _bgRestoreFn = null;
1851
+ let _previewEnsureAbort = null;
1784
1852
  function _killPreview() {
1785
1853
  // Restore bg music immediately (synchronously) before killing the process.
1786
1854
  // This prevents the async exit-handler race where a second Preview invocation
1787
1855
  // reads bgWas=true (music already enabled) before the first's exit fires.
1788
1856
  if (_bgRestoreFn) { _bgRestoreFn(); _bgRestoreFn = null; }
1857
+ if (_previewEnsureAbort) { _previewEnsureAbort.abort(); _previewEnsureAbort = null; }
1789
1858
  if (_previewModalProc) {
1790
1859
  try { _previewModalProc.kill(); } catch {}
1791
1860
  _previewModalProc = null;
@@ -1828,41 +1897,73 @@ export function createSetupTab(screen, services) {
1828
1897
  }
1829
1898
  }
1830
1899
 
1831
- let cmd, args;
1832
- if (isWin) {
1833
- const script = path.join(_hooksBase, '.claude', hooksSubdir, 'play-tts.ps1');
1834
- cmd = 'powershell';
1835
- args = ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', script, sampleText, '', '-llm', llmKey];
1836
- } else {
1837
- const script = path.join(_hooksBase, '.claude', hooksSubdir, 'play-tts.sh');
1838
- cmd = 'bash';
1839
- args = [script, sampleText, '', '--llm', llmKey];
1840
- }
1841
-
1842
- const proc = spawn(cmd, args, {
1843
- stdio: 'ignore',
1844
- windowsHide: true,
1845
- env: { ...process.env, CLAUDE_PROJECT_DIR: targetDir, AGENTVIBES_LLM_KEY: `llm:${llmKey}` },
1846
- });
1847
- _previewModalProc = proc;
1900
+ function _doSpawnPreview() {
1901
+ let cmd, args;
1902
+ if (isWin) {
1903
+ const script = path.join(_hooksBase, '.claude', hooksSubdir, 'play-tts.ps1');
1904
+ cmd = 'powershell';
1905
+ args = ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', script, sampleText, '', '-llm', llmKey];
1906
+ } else {
1907
+ const script = path.join(_hooksBase, '.claude', hooksSubdir, 'play-tts.sh');
1908
+ cmd = 'bash';
1909
+ args = [script, sampleText, '', '--llm', llmKey];
1910
+ }
1911
+ const proc = spawn(cmd, args, {
1912
+ stdio: 'ignore',
1913
+ windowsHide: true,
1914
+ env: { ...process.env, CLAUDE_PROJECT_DIR: targetDir, AGENTVIBES_LLM_KEY: `llm:${llmKey}` },
1915
+ });
1916
+ _previewModalProc = proc;
1917
+
1918
+ proc.on('exit', (code) => {
1919
+ _previewModalProc = null;
1920
+ if (_bgRestoreFn) { _bgRestoreFn(); _bgRestoreFn = null; }
1921
+ if (!_closed) {
1922
+ if (code !== 0 && code !== null) {
1923
+ const engineLabel = NATIVE_ENGINE_VOICES[draft.ttsEngine]?.label || draft.ttsEngine || 'engine';
1924
+ previewLine.setContent(`{red-fg}Preview failed — is ${engineLabel} running/installed?{/red-fg}`);
1925
+ screen.render();
1926
+ setTimeout(() => { if (!_closed) { previewLine.setContent(''); screen.render(); } }, 4000);
1927
+ } else {
1928
+ previewLine.setContent(''); screen.render();
1929
+ }
1930
+ }
1931
+ });
1932
+ proc.on('error', () => {
1933
+ _previewModalProc = null;
1934
+ if (_bgRestoreFn) { _bgRestoreFn(); _bgRestoreFn = null; }
1935
+ if (!_closed) { previewLine.setContent('{red-fg}Preview failed{/red-fg}'); screen.render(); }
1936
+ });
1937
+ } // end _doSpawnPreview
1848
1938
 
1849
- proc.on('exit', () => {
1850
- _previewModalProc = null;
1851
- if (_bgRestoreFn) { _bgRestoreFn(); _bgRestoreFn = null; }
1852
- if (!_closed) { previewLine.setContent(''); screen.render(); }
1853
- });
1854
- proc.on('error', () => {
1855
- _previewModalProc = null;
1856
- if (_bgRestoreFn) { _bgRestoreFn(); _bgRestoreFn = null; }
1857
- if (!_closed) { previewLine.setContent('{red-fg}Preview failed{/red-fg}'); screen.render(); }
1858
- });
1859
- }
1939
+ // Soprano on Windows: ensure WebUI server is running before preview
1940
+ if (draft.ttsEngine === 'soprano' && isWin) {
1941
+ previewLine.setContent('{cyan-fg}Checking Soprano...{/cyan-fg}');
1942
+ screen.render();
1943
+ _previewEnsureAbort = new AbortController();
1944
+ _ensureSopranoWebUI((msg) => {
1945
+ if (!_closed) { previewLine.setContent(`{cyan-fg}${msg}{/cyan-fg}`); screen.render(); }
1946
+ }, _previewEnsureAbort.signal).then((ready) => {
1947
+ _previewEnsureAbort = null;
1948
+ if (_closed) return;
1949
+ if (!ready) {
1950
+ previewLine.setContent('{red-fg}Soprano WebUI failed to start{/red-fg}');
1951
+ screen.render();
1952
+ setTimeout(() => { if (!_closed) { previewLine.setContent(''); screen.render(); } }, 4000);
1953
+ return;
1954
+ }
1955
+ _doSpawnPreview();
1956
+ }).catch(() => { _previewEnsureAbort = null; });
1957
+ return;
1958
+ }
1959
+ _doSpawnPreview();
1960
+ } // end _playPreview
1860
1961
 
1861
1962
  // Auto-save: persist draft to config immediately on any change
1862
1963
  function _autoSave(silent) {
1863
- // Infer engine from voice voice picker only shows Piper voices,
1864
- // so if a voice is set but no engine chosen, default to piper
1865
- const engine = draft.ttsEngine || (draft.voice ? 'piper' : '');
1964
+ // Preserve draft.ttsEngine as authoritative; only infer 'piper' when engine
1965
+ // is unset AND voice is not a native-engine canonical ID.
1966
+ const engine = draft.ttsEngine || (draft.voice && !NATIVE_ENGINE_VOICES[draft.voice] ? 'piper' : '');
1866
1967
  saveLlmConfigSync(llmKey, {
1867
1968
  voice: draft.voice,
1868
1969
  pretext: draft.pretext,
@@ -2087,11 +2188,11 @@ export function createSetupTab(screen, services) {
2087
2188
 
2088
2189
  picker.key(['enter'], () => {
2089
2190
  const idx = picker.selected;
2090
- if (idx === 0) {
2091
- draft.ttsEngine = '';
2092
- } else {
2093
- draft.ttsEngine = engines[idx - 1].id;
2094
- }
2191
+ const selectedEngine = idx === 0 ? '' : engines[idx - 1].id;
2192
+ draft.ttsEngine = selectedEngine;
2193
+ // Auto-set voice to native engine canonical ID so the Voice field updates
2194
+ // immediately. For piper or empty engine, clear to '' (shows global default).
2195
+ draft.voice = NATIVE_ENGINE_VOICES[selectedEngine]?.id || '';
2095
2196
  _closePicker();
2096
2197
  });
2097
2198
 
@@ -2151,6 +2252,149 @@ export function createSetupTab(screen, services) {
2151
2252
  destroyList(vpModal, screen, onDone);
2152
2253
  }
2153
2254
 
2255
+ // AVI-S5.1/5.2: Single-item overlay for non-Piper engines.
2256
+ // scanInstalledVoices() is NOT called; Space previews via the correct engine binary.
2257
+ const nativeVoice = NATIVE_ENGINE_VOICES[draft.ttsEngine];
2258
+ if (nativeVoice) {
2259
+ draft.voice = nativeVoice.id;
2260
+ let _nvClosed = false;
2261
+ let _nvPreviewProc = null;
2262
+ let _nvEnsureAbort = null;
2263
+
2264
+ function _killNvPreview() {
2265
+ if (_nvPreviewProc) { try { _nvPreviewProc.kill(); } catch {} _nvPreviewProc = null; }
2266
+ }
2267
+
2268
+ function _closeNV() {
2269
+ if (_nvClosed) return;
2270
+ _nvClosed = true;
2271
+ _killNvPreview();
2272
+ if (_nvEnsureAbort) { _nvEnsureAbort.abort(); _nvEnsureAbort = null; }
2273
+ navigationService?.closeModal();
2274
+ destroyList(nvPicker, screen, onDone);
2275
+ }
2276
+
2277
+ const nvPicker = blessed.list({
2278
+ parent: screen,
2279
+ top: 'center',
2280
+ left: 'center',
2281
+ width: 52,
2282
+ height: 7,
2283
+ border: { type: 'line' },
2284
+ tags: true,
2285
+ label: ' {bold}{cyan-fg} Select Voice {/cyan-fg}{/bold} ',
2286
+ keys: true,
2287
+ vi: false,
2288
+ mouse: true,
2289
+ style: {
2290
+ fg: COLORS.labelFg,
2291
+ bg: COLORS.contentBg,
2292
+ border: { fg: 'cyan' },
2293
+ selected: { bg: 'green', fg: 'white', bold: true },
2294
+ item: { fg: COLORS.labelFg },
2295
+ },
2296
+ });
2297
+ nvPicker.setFront();
2298
+ nvPicker.setItems([` ${nativeVoice.label} {gray-fg}[Space] preview [Enter] select{/gray-fg}`]);
2299
+ nvPicker.select(0);
2300
+
2301
+ function _previewNativeVoice() {
2302
+ if (_nvPreviewProc) {
2303
+ _killNvPreview();
2304
+ nvPicker.setLabel(' {bold}{cyan-fg} Select Voice {/cyan-fg}{/bold} ');
2305
+ screen.render();
2306
+ return;
2307
+ }
2308
+ const phrase = `Hi, I am the ${nativeVoice.label} voice.`;
2309
+ const engine = nativeVoice.id;
2310
+
2311
+ function _spawnAndTrack(cmd, args, opts) {
2312
+ let proc;
2313
+ try { proc = spawn(cmd, args, opts); } catch (e) {
2314
+ process.stderr.write(`[AgentVibes] preview spawn failed: ${e.message}\n`);
2315
+ if (!_nvClosed) { nvPicker.setLabel(' {red-fg}Engine not installed{/red-fg} '); screen.render(); }
2316
+ return;
2317
+ }
2318
+ _nvPreviewProc = proc;
2319
+ nvPicker.setLabel(` {cyan-fg}♪ ${nativeVoice.label}... (Space=stop){/cyan-fg} `);
2320
+ screen.render();
2321
+ proc.on('exit', (code) => {
2322
+ _nvPreviewProc = null;
2323
+ if (!_nvClosed) {
2324
+ if (code !== 0 && code !== null) {
2325
+ nvPicker.setLabel(` {red-fg}Preview failed (exit ${code}){/red-fg} `);
2326
+ setTimeout(() => { if (!_nvClosed) { nvPicker.setLabel(' {bold}{cyan-fg} Select Voice {/cyan-fg}{/bold} '); screen.render(); } }, 3000);
2327
+ } else {
2328
+ nvPicker.setLabel(' {bold}{cyan-fg} Select Voice {/cyan-fg}{/bold} ');
2329
+ }
2330
+ screen.render();
2331
+ }
2332
+ });
2333
+ proc.on('error', () => {
2334
+ _nvPreviewProc = null;
2335
+ if (!_nvClosed) { nvPicker.setLabel(' {red-fg}Engine not installed{/red-fg} '); screen.render(); }
2336
+ });
2337
+ }
2338
+
2339
+ if (engine === 'soprano' && process.platform === 'win32' && !process.env.WSL_DISTRO_NAME) {
2340
+ // Ensure soprano WebUI is running before preview; start it if not.
2341
+ nvPicker.setLabel(' {cyan-fg}Checking Soprano...{/cyan-fg} ');
2342
+ screen.render();
2343
+ _nvEnsureAbort = new AbortController();
2344
+ _ensureSopranoWebUI((msg) => {
2345
+ if (!_nvClosed) { nvPicker.setLabel(` {cyan-fg}${msg}{/cyan-fg} `); screen.render(); }
2346
+ }, _nvEnsureAbort.signal).then((ready) => {
2347
+ _nvEnsureAbort = null;
2348
+ if (_nvClosed) return;
2349
+ if (!ready) {
2350
+ nvPicker.setLabel(' {red-fg}Soprano WebUI failed to start{/red-fg} ');
2351
+ screen.render();
2352
+ return;
2353
+ }
2354
+ const scriptPath = path.join(os.homedir(), '.claude', 'hooks-windows', 'play-tts-soprano.ps1');
2355
+ _spawnAndTrack('powershell', ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', scriptPath, phrase], { stdio: 'ignore', windowsHide: true });
2356
+ }).catch(() => { _nvEnsureAbort = null; });
2357
+ return;
2358
+ }
2359
+
2360
+ let proc = null;
2361
+ try {
2362
+ if (engine === 'soprano') {
2363
+ proc = spawn('soprano', [phrase], { stdio: 'ignore' }); // NOSONAR
2364
+ } else if (engine === 'sapi') {
2365
+ const safePhrase = phrase.replace(/'/g, "''");
2366
+ const sapiScript = `Add-Type -AssemblyName System.Speech; (New-Object System.Speech.Synthesis.SpeechSynthesizer).Speak('${safePhrase}')`;
2367
+ proc = spawn('powershell', ['-NoProfile', '-Command', sapiScript], { stdio: 'ignore', windowsHide: true }); // NOSONAR
2368
+ } else if (engine === 'macos-say') {
2369
+ proc = spawn('say', [phrase], { stdio: 'ignore' }); // NOSONAR
2370
+ }
2371
+ } catch {}
2372
+ if (!proc) {
2373
+ nvPicker.setLabel(' {red-fg}Engine not installed{/red-fg} ');
2374
+ screen.render();
2375
+ return;
2376
+ }
2377
+ _nvPreviewProc = proc;
2378
+ nvPicker.setLabel(` {cyan-fg}♪ ${nativeVoice.label}... (Space=stop){/cyan-fg} `);
2379
+ screen.render();
2380
+ proc.on('exit', () => {
2381
+ _nvPreviewProc = null;
2382
+ if (!_nvClosed) { nvPicker.setLabel(' {bold}{cyan-fg} Select Voice {/cyan-fg}{/bold} '); screen.render(); }
2383
+ });
2384
+ proc.on('error', () => {
2385
+ _nvPreviewProc = null;
2386
+ if (!_nvClosed) { nvPicker.setLabel(' {red-fg}Engine not installed{/red-fg} '); screen.render(); }
2387
+ });
2388
+ }
2389
+
2390
+ nvPicker.key(['enter'], () => { draft.voice = nativeVoice.id; _closeNV(); });
2391
+ nvPicker.key(['space'], _previewNativeVoice);
2392
+ nvPicker.key(['escape', 'q', 'Q'], _closeNV);
2393
+ nvPicker.focus();
2394
+ screen.render();
2395
+ return;
2396
+ }
2397
+
2154
2398
  const vpModal = blessed.box({
2155
2399
  parent: screen,
2156
2400
  top: '6%',
@@ -2182,7 +2426,7 @@ export function createSetupTab(screen, services) {
2182
2426
  style: {
2183
2427
  fg: COLORS.labelFg, bg: COLORS.contentBg,
2184
2428
  border: { fg: 'blue' },
2185
- selected: { bg: 'green', fg: 'black', bold: true },
2429
+ selected: { bg: 'green', fg: 'white', bold: true },
2186
2430
  item: { fg: COLORS.labelFg },
2187
2431
  },
2188
2432
  });
@@ -2275,13 +2519,13 @@ export function createSetupTab(screen, services) {
2275
2519
  if (_isWin) {
2276
2520
  const _playTts = path.join(_hooksBase, '.claude', 'hooks-windows', 'play-tts.ps1');
2277
2521
  const _llmArgs = llmKey ? ['-llm', llmKey] : [];
2278
- rProc = spawn('powershell', ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', _playTts, phrase, voiceId, ..._llmArgs], {
2522
+ rProc = spawn('powershell', ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', _playTts, phrase, voiceId, ..._llmArgs], { // NOSONAR
2279
2523
  stdio: 'ignore', detached: false, windowsHide: true, env: _rEnv,
2280
2524
  });
2281
2525
  } else {
2282
2526
  const _playTts = path.join(_hooksBase, '.claude', 'hooks', 'play-tts.sh');
2283
2527
  const _llmArgs = llmKey ? ['--llm', llmKey] : [];
2284
- rProc = spawn('bash', [_playTts, phrase, voiceId, ..._llmArgs], {
2528
+ rProc = spawn('bash', [_playTts, phrase, voiceId, ..._llmArgs], { // NOSONAR
2285
2529
  stdio: 'ignore', detached: true, env: _rEnv, cwd: targetDir,
2286
2530
  });
2287
2531
  }
@@ -429,7 +429,7 @@ export function scanInstalledVoices() {
429
429
  const jsonPath = path.join(PIPER_VOICES_DIR, voiceId + '.onnx.json');
430
430
  try {
431
431
  const data = JSON.parse(fs.readFileSync(jsonPath, 'utf8'));
432
- if (data.num_speakers > 1 && data.speaker_id_map) {
432
+ if (data.num_speakers > 1 && data.speaker_id_map && Object.keys(data.speaker_id_map).length > 0) {
433
433
  // Expand multi-speaker model into individual entries
434
434
  for (const speakerName of Object.keys(data.speaker_id_map)) {
435
435
  result.push(`${voiceId}${MS_SEP}${speakerName}`);
@@ -598,6 +598,18 @@ export function getVoiceMeta(voiceId) {
598
598
  return result;
599
599
  }
600
600
 
601
+ // Prefer catalog name for known curated single-speaker voices (avoids dataset-based duplicates)
602
+ const catEntry = _catalogMap.get(voiceId);
603
+ if (catEntry) {
604
+ const result = {
605
+ displayName: catEntry.displayName,
606
+ gender: catEntry.gender || inferGender(voiceId, null),
607
+ provider: 'Piper',
608
+ };
609
+ _metaCache.set(voiceId, result);
610
+ return result;
611
+ }
612
+
601
613
  let dataset = null;
602
614
  try {
603
615
  const jsonPath = path.join(PIPER_VOICES_DIR, voiceId + '.onnx.json');
@@ -924,12 +936,12 @@ export function createVoicesTab(screen, services) {
924
936
  let proc;
925
937
  if (isWindows) {
926
938
  const playTts = path.join(hooksBase, '.claude', 'hooks-windows', 'play-tts.ps1');
927
- proc = spawn('powershell', ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', playTts, phrase, voiceId], {
939
+ proc = spawn('powershell', ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', playTts, phrase, voiceId], { // NOSONAR
928
940
  stdio: 'ignore', detached: false, windowsHide: true, env: _spawnEnv,
929
941
  });
930
942
  } else {
931
943
  const playTts = path.join(hooksBase, '.claude', 'hooks', 'play-tts.sh');
932
- proc = spawn('bash', [playTts, phrase, voiceId], {
944
+ proc = spawn('bash', [playTts, phrase, voiceId], { // NOSONAR
933
945
  stdio: ['ignore', 'ignore', 'pipe'], detached: true, env: _spawnEnv,
934
946
  cwd: process.cwd(),
935
947
  });
@@ -1500,13 +1512,13 @@ export function createVoicesTab(screen, services) {
1500
1512
  Invoke-WebRequest -Uri '${configUrl}' -OutFile '${configFile.replace(/'/g, "''")}' -ErrorAction Stop
1501
1513
  Write-Output 'PHASE:done'
1502
1514
  `;
1503
- dlProc = spawn('powershell', ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-Command', psScript], {
1515
+ dlProc = spawn('powershell', ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-Command', psScript], { // NOSONAR
1504
1516
  stdio: ['ignore', 'pipe', 'pipe'],
1505
1517
  env: _spawnEnv,
1506
1518
  });
1507
1519
  } else {
1508
1520
  const managerScript = path.resolve(packageRoot, '.claude', 'hooks', 'piper-voice-manager.sh');
1509
- dlProc = spawn('bash', ['-c', 'source "$1" && download_voice "$2"', '_', managerScript, modelToDownload], {
1521
+ dlProc = spawn('bash', ['-c', 'source "$1" && download_voice "$2"', '_', managerScript, modelToDownload], { // NOSONAR
1510
1522
  stdio: ['ignore', 'pipe', 'pipe'],
1511
1523
  env: _spawnEnv,
1512
1524
  });
@@ -132,7 +132,7 @@ export function openPersonalityPicker(screen, currentPersonality, onSelect, onCl
132
132
  const _cwdScript = path.join(process.cwd(), '.claude', 'hooks-windows', 'play-tts.ps1');
133
133
  const _homeScript = path.join(os.homedir(), '.claude', 'hooks-windows', 'play-tts.ps1');
134
134
  const ttsScript = fs.existsSync(_cwdScript) ? _cwdScript : _homeScript;
135
- _pickerTtsProc = spawn('powershell', ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', ttsScript, phrase], {
135
+ _pickerTtsProc = spawn('powershell', ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', ttsScript, phrase], { // NOSONAR
136
136
  stdio: 'ignore',
137
137
  env: _env,
138
138
  });
@@ -141,7 +141,7 @@ export function openPersonalityPicker(screen, currentPersonality, onSelect, onCl
141
141
  const remoteLlm = detectRemoteLlm();
142
142
  const ttsArgs = [ttsScript, phrase];
143
143
  if (remoteLlm) ttsArgs.push('--llm', remoteLlm);
144
- _pickerTtsProc = spawn('bash', ttsArgs, {
144
+ _pickerTtsProc = spawn('bash', ttsArgs, { // NOSONAR
145
145
  stdio: 'ignore',
146
146
  detached: true,
147
147
  env: _env,
@@ -78,7 +78,7 @@ export function openReverbPicker(screen, currentPreset, onSelect, onClose, opts
78
78
  const _isWin = process.platform === 'win32' && !process.env.WSL_DISTRO_NAME;
79
79
  if (!_isWin) {
80
80
  const effectsScript = path.join(process.cwd(), '.claude', 'hooks', 'effects-manager.sh');
81
- spawnSync('bash', [effectsScript, 'set-reverb', selected.value, 'default'], {
81
+ spawnSync('bash', [effectsScript, 'set-reverb', selected.value, 'default'], { // NOSONAR
82
82
  stdio: 'ignore',
83
83
  timeout: 5000,
84
84
  env: { ...process.env },