agentvibes 5.12.0 → 5.13.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (73) hide show
  1. package/.agentvibes/install-manifest.json +111 -91
  2. package/.claude/commands/agent-vibes/commands.json +0 -20
  3. package/.claude/commands/agent-vibes/unmute.md +6 -2
  4. package/.claude/config/audio-effects.cfg +6 -6
  5. package/.claude/github-star-reminder.txt +1 -1
  6. package/.claude/hooks/agentvibes-session-id.sh +69 -0
  7. package/.claude/hooks/bmad-party-speak.sh +20 -4
  8. package/.claude/hooks/bmad-speak.sh +60 -2
  9. package/.claude/hooks/bmad-tts-injector.sh +20 -1
  10. package/.claude/hooks/bmad-voice-manager.sh +25 -3
  11. package/.claude/hooks/clawdbot-receiver-SECURE.sh +21 -2
  12. package/.claude/hooks/clawdbot-receiver.sh +19 -1
  13. package/.claude/hooks/elevenlabs-voices.sh +62 -0
  14. package/.claude/hooks/kokoro-installer.sh +20 -10
  15. package/.claude/hooks/language-manager.sh +10 -3
  16. package/.claude/hooks/party-set-room.sh +71 -0
  17. package/.claude/hooks/party-stage-roster.py +328 -0
  18. package/.claude/hooks/personality-manager.sh +19 -2
  19. package/.claude/hooks/piper-voice-manager.sh +3 -2
  20. package/.claude/hooks/play-tts-agentvibes-receiver-for-voiceless-connections.sh +24 -5
  21. package/.claude/hooks/play-tts-elevenlabs.sh +38 -118
  22. package/.claude/hooks/play-tts-kokoro.sh +33 -6
  23. package/.claude/hooks/play-tts-soprano.sh +3 -2
  24. package/.claude/hooks/play-tts-ssh-remote.sh +37 -29
  25. package/.claude/hooks/play-tts-termux-ssh.sh +5 -4
  26. package/.claude/hooks/play-tts.sh +66 -61
  27. package/.claude/hooks/provider-catalog.json +352 -0
  28. package/.claude/hooks/provider-catalog.sh +161 -0
  29. package/.claude/hooks/provider-commands.sh +2 -1
  30. package/.claude/hooks/provider-manager.sh +47 -9
  31. package/.claude/hooks/python-resolver.sh +117 -0
  32. package/.claude/hooks/session-id.sh +56 -0
  33. package/.claude/hooks/session-start-tts.sh +39 -0
  34. package/.claude/hooks/speed-manager.sh +1 -1
  35. package/.claude/hooks/translate-manager.sh +3 -2
  36. package/.claude/hooks/translator.py +1 -1
  37. package/.claude/hooks/voice-manager.sh +242 -10
  38. package/.claude/hooks-windows/language-manager.ps1 +7 -1
  39. package/.claude/hooks-windows/personality-manager.ps1 +16 -1
  40. package/.claude/hooks-windows/play-tts-kokoro.ps1 +20 -4
  41. package/.claude/hooks-windows/play-tts.ps1 +32 -3
  42. package/.claude/hooks-windows/provider-catalog.ps1 +140 -0
  43. package/.claude/hooks-windows/provider-manager.ps1 +63 -8
  44. package/.claude/hooks-windows/tts-watcher.ps1 +33 -12
  45. package/.claude/hooks-windows/voice-manager-windows.ps1 +49 -0
  46. package/.mcp.json +0 -7
  47. package/README.md +12 -3
  48. package/RELEASE_NOTES.md +61 -0
  49. package/mcp-server/server.py +146 -49
  50. package/mcp-server/test_mcp_correctness.py +20 -2
  51. package/mcp-server/test_windows_script_parity.py +0 -2
  52. package/package.json +2 -2
  53. package/src/cli/list-voices.js +218 -114
  54. package/src/console/bling.js +71 -0
  55. package/src/console/music-preview.js +79 -0
  56. package/src/console/tabs/music-tab.js +16 -39
  57. package/src/console/tabs/settings-tab.js +195 -13
  58. package/src/console/tabs/setup-tab.js +9 -34
  59. package/src/console/tabs/voices-tab.js +83 -14
  60. package/src/console/widgets/track-picker.js +82 -0
  61. package/src/installer.js +136 -18
  62. package/src/services/provider-catalog.js +412 -0
  63. package/src/services/provider-voice-catalog.js +52 -73
  64. package/src/services/tts-engine-service.js +29 -0
  65. package/src/utils/provider-validator.js +62 -12
  66. package/.claude/commands/agent-vibes/language.md +0 -23
  67. package/.claude/commands/agent-vibes/learn.md +0 -67
  68. package/.claude/commands/agent-vibes/replay-target.md +0 -14
  69. package/.claude/commands/agent-vibes/target-voice.md +0 -26
  70. package/.claude/commands/agent-vibes/target.md +0 -30
  71. package/.claude/hooks/learn-manager.sh +0 -492
  72. package/.claude/hooks/replay-target-audio.sh +0 -95
  73. package/.claude/hooks-windows/learn-manager.ps1 +0 -241
@@ -46,6 +46,7 @@ import os
46
46
  import platform
47
47
  import re as _re
48
48
  import subprocess
49
+ import sys
49
50
  from dataclasses import dataclass
50
51
  from pathlib import Path
51
52
  from typing import Optional
@@ -61,6 +62,53 @@ import mcp.server.stdio
61
62
  DEFAULT_SCRIPT_TIMEOUT = 30.0
62
63
 
63
64
 
65
+ # ── Provider Catalog (SSOT Layer 2) — platform allowlists + display names ────
66
+ #
67
+ # server.py DERIVES its per-platform provider allowlists and provider display
68
+ # names from the shipped provider-catalog.json (generated from
69
+ # src/services/provider-catalog.js). The literals below are an EMBEDDED FALLBACK
70
+ # used ONLY when that JSON is missing or unreadable (installed-tree skew) —
71
+ # degraded, never dead: the MCP server MUST start without the file.
72
+ #
73
+ # Bidirectional parity (embedded fallback ≡ catalog, per platform, BOTH
74
+ # directions) is asserted by test/unit/provider-catalog-conformance.test.js so
75
+ # the fallback can never silently drift from the catalog (design §8 — a
76
+ # one-directional check is the exact gap that let elevenlabs-on-Windows through
77
+ # the old positive-only test).
78
+ #
79
+ # The non-Windows fallback mirrors the catalog's DARWIN set (the superset that
80
+ # also covers Linux: darwin adds macOS `say`, which a Linux box simply won't
81
+ # have installed — availability is enforced by the dispatchers downstream, not
82
+ # here). Windows uses the catalog WINDOWS set. elevenlabs is deliberately ABSENT
83
+ # from Windows: there is NO play-tts-elevenlabs.ps1 runtime (AVI-S9.1 /
84
+ # provider-catalog.js: elevenlabs.runtime.windows === null).
85
+ _FALLBACK_PROVIDERS_WINDOWS = ["windows-piper", "windows-sapi", "soprano", "kokoro"]
86
+ _FALLBACK_PROVIDERS_NON_WINDOWS = ["piper", "macos", "soprano", "kokoro", "elevenlabs"]
87
+
88
+ # termux-ssh is a TRANSPORT (relays TTS over SSH to a phone), NOT a synthesis
89
+ # provider: it has no catalog record and no play-tts-termux-ssh runtime. It is
90
+ # accepted on non-Windows as a documented non-catalog transport token, and is
91
+ # EXCLUDED BY NAME from the catalog parity assertion (AC6) so it can neither be
92
+ # silently dropped nor silently drift INTO the catalog.
93
+ _TRANSPORT_TOKENS = ["termux-ssh"]
94
+
95
+ # Embedded fallback display names — byte-equal to catalog.json `displayNames`
96
+ # (group-8 parity). termux-ssh is NOT here (it has no catalog record); its
97
+ # display name is added separately as a transport token.
98
+ _FALLBACK_DISPLAY_NAMES = {
99
+ "soprano": "Soprano TTS",
100
+ "piper": "Piper TTS",
101
+ "kokoro": "Kokoro TTS",
102
+ "elevenlabs": "ElevenLabs",
103
+ "macos": "macOS Say",
104
+ "windows-sapi": "Windows SAPI",
105
+ "windows-piper": "Piper TTS",
106
+ }
107
+ _TRANSPORT_DISPLAY_NAMES = {
108
+ "termux-ssh": "Termux SSH",
109
+ }
110
+
111
+
64
112
  @dataclass
65
113
  class ScriptResult:
66
114
  """Structured result of running a hook script.
@@ -127,6 +175,91 @@ class AgentVibesServer:
127
175
  # the same file at the same time is not covered; see story 8.2 notes).
128
176
  self._override_lock = asyncio.Lock()
129
177
 
178
+ # provider-catalog.json is read lazily once and cached (design: no
179
+ # blocking I/O per call; load at first use, not per set_provider).
180
+ self._provider_catalog = None
181
+ self._provider_catalog_loaded = False
182
+
183
+ def _load_provider_catalog(self) -> Optional[dict]:
184
+ """Load the shipped provider-catalog.json ONCE (cached).
185
+
186
+ Generated from src/services/provider-catalog.js into
187
+ .claude/hooks/provider-catalog.json and shipped beside the hooks, it is
188
+ the SSOT for per-platform provider allowlists and display names. Returns
189
+ the parsed dict, or None on ANY failure (missing / unreadable /
190
+ malformed) so the MCP server always starts — callers then fall back to
191
+ the embedded literals that conformance asserts are equivalent.
192
+ """
193
+ if self._provider_catalog_loaded:
194
+ return self._provider_catalog
195
+ self._provider_catalog_loaded = True
196
+ catalog = None
197
+ try:
198
+ # catalog.json lives under hooks/ on EVERY platform (program data,
199
+ # not a per-platform hook script — the generator only writes it there).
200
+ path = self.claude_dir / "hooks" / "provider-catalog.json"
201
+ if path.exists() and not path.is_symlink():
202
+ parsed = json.loads(path.read_text(encoding="utf-8"))
203
+ if isinstance(parsed, dict):
204
+ catalog = parsed
205
+ except (OSError, ValueError):
206
+ catalog = None
207
+ if catalog is None:
208
+ # Degraded, never dead. Warn on stderr ONLY (stdout is the MCP
209
+ # JSON-RPC stream); one terse line, since a missing file is the
210
+ # common installed-tree-skew case.
211
+ print(
212
+ "agentvibes: provider-catalog.json unavailable; using embedded provider fallback",
213
+ file=sys.stderr,
214
+ )
215
+ self._provider_catalog = catalog
216
+ return catalog
217
+
218
+ def _valid_providers(self) -> list:
219
+ """Per-platform provider allowlist, DERIVED from provider-catalog.json.
220
+
221
+ Windows → catalog `platforms.windows`; non-Windows → catalog
222
+ `platforms.darwin` (the superset covering both Linux and macOS). Falls
223
+ back to the embedded literals when the catalog is unavailable. Transport
224
+ tokens (termux-ssh) are appended on non-Windows only.
225
+ """
226
+ catalog = self._load_provider_catalog()
227
+ base = None
228
+ if catalog:
229
+ platforms = catalog.get("platforms")
230
+ if isinstance(platforms, dict):
231
+ key = "windows" if self.is_windows else "darwin"
232
+ derived = platforms.get(key)
233
+ if isinstance(derived, list) and derived:
234
+ base = list(derived)
235
+ if base is None:
236
+ base = list(
237
+ _FALLBACK_PROVIDERS_WINDOWS if self.is_windows else _FALLBACK_PROVIDERS_NON_WINDOWS
238
+ )
239
+ if not self.is_windows:
240
+ for token in _TRANSPORT_TOKENS:
241
+ if token not in base:
242
+ base.append(token)
243
+ return base
244
+
245
+ def _provider_display_names(self) -> dict:
246
+ """Provider display names, DERIVED from provider-catalog.json.
247
+
248
+ Falls back to the embedded dict when the catalog is unavailable. Non-
249
+ catalog transport tokens (termux-ssh) are always merged in.
250
+ """
251
+ catalog = self._load_provider_catalog()
252
+ names = None
253
+ if catalog:
254
+ derived = catalog.get("displayNames")
255
+ if isinstance(derived, dict) and derived:
256
+ names = dict(derived)
257
+ if names is None:
258
+ names = dict(_FALLBACK_DISPLAY_NAMES)
259
+ for token, label in _TRANSPORT_DISPLAY_NAMES.items():
260
+ names.setdefault(token, label)
261
+ return names
262
+
130
263
  def _find_claude_dir(self) -> Path:
131
264
  """Find the .claude directory relative to this script"""
132
265
  # Get the AgentVibes root directory (parent of mcp-server)
@@ -728,33 +861,31 @@ class AgentVibesServer:
728
861
 
729
862
  async def set_provider(self, provider: str) -> str:
730
863
  """
731
- Switch TTS provider between Piper, macOS, and Termux SSH.
864
+ Switch TTS provider between the supported synthesis engines.
732
865
 
733
866
  Args:
734
- provider: Provider name ("piper", "macos", or "termux-ssh")
867
+ provider: Provider name. Non-Windows: "piper", "macos", "termux-ssh",
868
+ "soprano", "kokoro", "elevenlabs". Windows: "windows-piper",
869
+ "windows-sapi", "soprano", "kokoro".
735
870
 
736
871
  Returns:
737
872
  Success or error message
738
873
  """
739
874
  provider = provider.lower()
740
- if self.is_windows:
741
- valid_providers = ["windows-piper", "windows-sapi", "soprano"]
742
- else:
743
- valid_providers = ["piper", "macos", "termux-ssh", "soprano"]
875
+ # Platform allowlist + display names DERIVE from provider-catalog.json
876
+ # (SSOT), with embedded fallbacks (module constants above). kokoro is
877
+ # cross-platform; elevenlabs is Unix-only (NO play-tts-elevenlabs.ps1, so
878
+ # it is absent from the Windows set — switching to it on Windows would be
879
+ # silently unplayable). See AVI-S9.1 / AVI-S9.5 and provider-catalog.js
880
+ # (elevenlabs.runtime.windows === null).
881
+ valid_providers = self._valid_providers()
744
882
  if provider not in valid_providers:
745
883
  return f"❌ Invalid provider: {provider}. Choose from: {', '.join(valid_providers)}"
746
884
 
747
885
  result = await self._run_script("provider-manager.sh", ["switch", provider])
748
886
  if result.ok:
749
- # Automatically speak confirmation in the new provider's voice
750
- provider_names = {
751
- "macos": "macOS",
752
- "termux-ssh": "Termux SSH",
753
- "piper": "Piper",
754
- "windows-piper": "Windows Piper",
755
- "windows-sapi": "Windows SAPI",
756
- "soprano": "Soprano",
757
- }
887
+ # Automatically speak confirmation in the new provider's voice.
888
+ provider_names = self._provider_display_names()
758
889
  provider_name = provider_names.get(provider, provider.title())
759
890
  confirmation_text = f"Successfully switched to {provider_name} provider"
760
891
 
@@ -775,24 +906,6 @@ class AgentVibesServer:
775
906
 
776
907
  return f"❌ Failed to switch provider: {result.error_detail}"
777
908
 
778
- async def set_learn_mode(self, enabled: bool) -> str:
779
- """
780
- Enable or disable language learning mode.
781
-
782
- When enabled, TTS speaks in both your main language and target language.
783
-
784
- Args:
785
- enabled: True to enable, False to disable
786
-
787
- Returns:
788
- Success or error message
789
- """
790
- action = "enable" if enabled else "disable"
791
- result = await self._run_script("learn-manager.sh", [action])
792
- if result.ok:
793
- return result.stdout if "✓" in result.stdout else f"✓ {result.stdout}"
794
- return f"❌ Failed to set learn mode: {result.error_detail}"
795
-
796
909
  async def set_speed(self, speed: str, target: bool = False) -> str:
797
910
  """
798
911
  Set speech speed for main or target voice.
@@ -1591,20 +1704,6 @@ Examples:
1591
1704
  "required": ["provider"],
1592
1705
  },
1593
1706
  ),
1594
- Tool(
1595
- name="set_learn_mode",
1596
- description="Enable or disable language learning mode. When ON, TTS speaks in both your main language and target language for bilingual learning.",
1597
- inputSchema={
1598
- "type": "object",
1599
- "properties": {
1600
- "enabled": {
1601
- "type": "boolean",
1602
- "description": "True to enable learning mode, False to disable"
1603
- }
1604
- },
1605
- "required": ["enabled"],
1606
- },
1607
- ),
1608
1707
  Tool(
1609
1708
  name="set_speed",
1610
1709
  description="Set speech speed for main or target voice. Works with both Piper and macOS providers. Use this to make voices faster or slower.",
@@ -1900,8 +1999,6 @@ async def call_tool(name: str, arguments: dict) -> list[TextContent] | CallToolR
1900
1999
  result = await agent_vibes.replay_audio(n)
1901
2000
  elif name == "set_provider":
1902
2001
  result = await agent_vibes.set_provider(arguments["provider"])
1903
- elif name == "set_learn_mode":
1904
- result = await agent_vibes.set_learn_mode(arguments["enabled"])
1905
2002
  elif name == "set_speed":
1906
2003
  target = arguments.get("target", False)
1907
2004
  result = await agent_vibes.set_speed(arguments["speed"], target)
@@ -122,13 +122,12 @@ def test_set_personality_fails_on_nonzero_exit_despite_matching_marker_text():
122
122
  "method_name,call_args,plain_stdout",
123
123
  [
124
124
  ("set_language", ("spanish",), "Language set to: spanish"),
125
- ("set_learn_mode", (True,), "Learn mode enabled"),
126
125
  ("set_verbosity", ("high",), "Verbosity set to: high (project-local)"),
127
126
  ("replay_audio", (1,), "Replaying audio #1: tts-foo.wav"),
128
127
  ],
129
128
  )
130
129
  def test_windows_style_plain_text_success_is_not_treated_as_failure(method_name, call_args, plain_stdout):
131
- """set_language / set_learn_mode / set_verbosity / replay_audio must all
130
+ """set_language / set_verbosity / replay_audio must all
132
131
  report success from returncode==0 alone, even when stdout carries none of
133
132
  the emoji markers the old code sniffed for (✓ / ✅ / 🔊)."""
134
133
  server = AgentVibesServer()
@@ -482,5 +481,24 @@ def test_tool_descriptions_state_20_percent_default_not_30():
482
481
  assert "0.30" not in volume_desc
483
482
 
484
483
 
484
+ def test_valid_providers_derives_from_catalog_and_excludes_elevenlabs_on_windows():
485
+ """AVI-S9.5: _valid_providers() derives the per-platform allowlist from
486
+ provider-catalog.json (or the embedded fallback ≡ catalog). ElevenLabs has
487
+ no Windows runtime (no play-tts-elevenlabs.ps1), so it must be ABSENT from
488
+ the Windows allowlist and PRESENT on non-Windows — the exact bug class the
489
+ Provider Catalog closes."""
490
+ server = AgentVibesServer()
491
+
492
+ server.is_windows = True
493
+ win = server._valid_providers()
494
+ assert "elevenlabs" not in win, f"elevenlabs must not be a Windows provider: {win}"
495
+ assert "kokoro" in win and "soprano" in win
496
+
497
+ server.is_windows = False
498
+ non_win = server._valid_providers()
499
+ assert "elevenlabs" in non_win, f"elevenlabs must be a non-Windows provider: {non_win}"
500
+ assert "kokoro" in non_win and "soprano" in non_win
501
+
502
+
485
503
  if __name__ == "__main__":
486
504
  sys.exit(pytest.main([__file__, "-v"]))
@@ -42,7 +42,6 @@ HOOKS_LINUX = PROJECT_ROOT / ".claude" / "hooks"
42
42
  # The auto-conversion (server.py:902-903) converts .sh -> .ps1
43
43
  MCP_HARDCODED_SCRIPTS = [
44
44
  "provider-manager",
45
- "learn-manager",
46
45
  "speed-manager",
47
46
  "download-extra-voices",
48
47
  "verbosity-manager",
@@ -159,7 +158,6 @@ def test_run_script_error_for_missing():
159
158
  MCP_TOOL_SCRIPT_MAP = {
160
159
  "set_speed": "speed-manager",
161
160
  "get_speed": "speed-manager",
162
- "set_learn_mode": "learn-manager",
163
161
  "set_verbosity": "verbosity-manager",
164
162
  "get_verbosity": "verbosity-manager",
165
163
  "mute": "verbosity-manager",
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "$schema": "https://json.schemastore.org/package.json",
3
3
  "name": "agentvibes",
4
- "version": "5.12.0",
4
+ "version": "5.13.1",
5
5
  "description": "Now your AI Agents can finally talk back! Professional TTS voice for Claude Code, Claude Desktop (via MCP), and Clawdbot with multi-provider support.",
6
6
  "homepage": "https://agentvibes.org",
7
7
  "keywords": [
@@ -96,7 +96,7 @@
96
96
  "commander": "^10.0.0",
97
97
  "figlet": "^1.6.0",
98
98
  "inquirer": "^12.0.0",
99
- "js-yaml": "^4.1.1",
99
+ "js-yaml": "^4.3.0",
100
100
  "ora": "^6.0.0"
101
101
  },
102
102
  "engines": {
@@ -1,114 +1,218 @@
1
- #!/usr/bin/env node
2
- /**
3
- * Voice List Display - Beautiful multi-column voice listing
4
- * Called by voice-manager.sh to display voices with boxen formatting
5
- */
6
-
7
- import { formatVoicesList } from '../utils/list-formatter.js';
8
- import fs from 'fs';
9
- import path from 'path';
10
- import { execFileSync } from 'child_process';
11
- import os from 'os';
12
-
13
- /**
14
- * Get Piper voices from voice directory
15
- */
16
- function getPiperVoices(voiceDir, currentVoice) {
17
- const voices = [];
18
-
19
- if (!fs.existsSync(voiceDir)) {
20
- return voices;
21
- }
22
-
23
- const files = fs.readdirSync(voiceDir);
24
- for (const file of files) {
25
- if (file.endsWith('.onnx')) {
26
- const voiceName = path.basename(file, '.onnx');
27
- voices.push({
28
- name: voiceName,
29
- lang: extractLanguage(voiceName),
30
- current: voiceName === currentVoice
31
- });
32
- }
33
- }
34
-
35
- return voices.sort((a, b) => a.name.localeCompare(b.name));
36
- }
37
-
38
- /**
39
- * Get macOS voices using say -v ?
40
- */
41
- function getMacOSVoices(currentVoice) {
42
- const voices = [];
43
-
44
- if (os.platform() !== 'darwin') {
45
- return voices;
46
- }
47
-
48
- try {
49
- const output = execFileSync('say', ['-v', '?'], { encoding: 'utf8' }); // NOSONAR - Safe: checking macOS say voices from system PATH
50
- const lines = output.split('\n');
51
-
52
- for (const line of lines) {
53
- if (!line.trim()) continue;
54
-
55
- const parts = line.trim().split(/\s+/);
56
- if (parts.length >= 2) {
57
- const voiceName = parts[0];
58
- const lang = parts[1];
59
-
60
- voices.push({
61
- name: voiceName,
62
- lang,
63
- current: voiceName === currentVoice
64
- });
65
- }
66
- }
67
- } catch (error) {
68
- // say command failed
69
- }
70
-
71
- return voices;
72
- }
73
-
74
- /**
75
- * Extract language code from voice name
76
- */
77
- function extractLanguage(voiceName) {
78
- const match = voiceName.match(/^([a-z]{2}_[A-Z]{2})/);
79
- return match ? match[1] : '';
80
- }
81
-
82
- /**
83
- * Main function
84
- */
85
- function main() {
86
- const args = process.argv.slice(2);
87
-
88
- // Parse arguments
89
- const provider = args[0] || 'piper';
90
- const currentVoice = args[1] || '';
91
- const voiceDir = args[2] || '';
92
-
93
- let voices = [];
94
- let providerName = 'Piper TTS';
95
-
96
- if (provider === 'piper') {
97
- voices = getPiperVoices(voiceDir, currentVoice);
98
- providerName = 'Piper TTS';
99
- } else if (provider === 'macos') {
100
- voices = getMacOSVoices(currentVoice);
101
- providerName = 'macOS TTS';
102
- }
103
-
104
- // Display with boxen
105
- const output = formatVoicesList(voices, {
106
- provider: providerName,
107
- columns: 2,
108
- showUsage: true
109
- });
110
-
111
- console.log(output);
112
- }
113
-
114
- main();
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Voice List Display - Beautiful multi-column voice listing
4
+ * Called by voice-manager.sh to display voices with boxen formatting
5
+ */
6
+
7
+ import { formatVoicesList } from '../utils/list-formatter.js';
8
+ import fs from 'fs';
9
+ import path from 'path';
10
+ import { fileURLToPath } from 'url';
11
+ import { execFileSync } from 'child_process';
12
+ import os from 'os';
13
+ import {
14
+ KOKORO_VOICE_IDS,
15
+ kokoroGender,
16
+ ELEVENLABS_VOICES,
17
+ } from '../services/provider-voice-catalog.js';
18
+ import {
19
+ listVoices as catalogListVoices,
20
+ getProvider,
21
+ } from '../services/provider-catalog.js';
22
+
23
+ /**
24
+ * Get Piper voices from voice directory
25
+ */
26
+ function getPiperVoices(voiceDir, currentVoice) {
27
+ const voices = [];
28
+
29
+ if (!fs.existsSync(voiceDir)) {
30
+ return voices;
31
+ }
32
+
33
+ const files = fs.readdirSync(voiceDir);
34
+ for (const file of files) {
35
+ if (file.endsWith('.onnx')) {
36
+ const voiceName = path.basename(file, '.onnx');
37
+ voices.push({
38
+ name: voiceName,
39
+ lang: extractLanguage(voiceName),
40
+ current: voiceName === currentVoice
41
+ });
42
+ }
43
+ }
44
+
45
+ return voices.sort((a, b) => a.name.localeCompare(b.name));
46
+ }
47
+
48
+ /**
49
+ * Get macOS voices using say -v ?
50
+ */
51
+ function getMacOSVoices(currentVoice) {
52
+ const voices = [];
53
+
54
+ if (os.platform() !== 'darwin') {
55
+ return voices;
56
+ }
57
+
58
+ try {
59
+ const output = execFileSync('say', ['-v', '?'], { encoding: 'utf8' }); // NOSONAR - Safe: checking macOS say voices from system PATH
60
+ const lines = output.split('\n');
61
+
62
+ for (const line of lines) {
63
+ if (!line.trim()) continue;
64
+
65
+ const parts = line.trim().split(/\s+/);
66
+ if (parts.length >= 2) {
67
+ const voiceName = parts[0];
68
+ const lang = parts[1];
69
+
70
+ voices.push({
71
+ name: voiceName,
72
+ lang,
73
+ current: voiceName === currentVoice
74
+ });
75
+ }
76
+ }
77
+ } catch (error) {
78
+ // say command failed
79
+ }
80
+
81
+ return voices;
82
+ }
83
+
84
+ /**
85
+ * Kokoro voice ids follow `<lang><sex>_name`. The first char is the language.
86
+ * Map it to a human-readable language label for the listing.
87
+ */
88
+ const KOKORO_LANG_LABELS = {
89
+ a: 'en-US', b: 'en-GB', j: 'ja', z: 'zh', e: 'es',
90
+ f: 'fr', h: 'hi', i: 'it', p: 'pt-BR', k: 'ko',
91
+ };
92
+
93
+ /**
94
+ * Get Kokoro voices from the canonical catalog (KOKORO_VOICE_IDS).
95
+ * Kokoro's voice set is a fixed catalog, not discovered on disk.
96
+ */
97
+ function getKokoroVoices(currentVoice) {
98
+ return KOKORO_VOICE_IDS.map((id) => ({
99
+ name: id,
100
+ lang: KOKORO_LANG_LABELS[id[0]] || '',
101
+ gender: kokoroGender(id),
102
+ current: id === currentVoice,
103
+ }));
104
+ }
105
+
106
+ /**
107
+ * Get ElevenLabs voices from the canonical catalog (ELEVENLABS_VOICES).
108
+ * The listing shows the friendly name; matching against currentVoice accepts
109
+ * either the friendly name or the raw voice_id.
110
+ */
111
+ function getElevenLabsVoices(currentVoice) {
112
+ return ELEVENLABS_VOICES.map((v) => ({
113
+ name: v.name,
114
+ lang: v.lang || '',
115
+ gender: v.gender || '',
116
+ current: v.name === currentVoice || v.id === currentVoice,
117
+ }));
118
+ }
119
+
120
+ /**
121
+ * Get Soprano voices from the canonical catalog. Soprano is voiceModel `single`
122
+ * (design §3.1): exactly one canonical voice, soprano-default — no picker.
123
+ */
124
+ function getSopranoVoices(currentVoice) {
125
+ return catalogListVoices('soprano').map((v) => ({
126
+ name: v.id,
127
+ lang: '',
128
+ gender: v.gender || '',
129
+ current: v.id === currentVoice || currentVoice === '' || currentVoice === 'soprano',
130
+ }));
131
+ }
132
+
133
+ /**
134
+ * Extract language code from voice name
135
+ */
136
+ function extractLanguage(voiceName) {
137
+ const match = voiceName.match(/^([a-z]{2}_[A-Z]{2})/);
138
+ return match ? match[1] : '';
139
+ }
140
+
141
+ /**
142
+ * Discovered providers list-voices enumerates HERE, each via its own platform
143
+ * discovery path (piper: `*.onnx` disk glob; macos: `say -v ?`). Other discovered
144
+ * providers (windows-piper / windows-sapi) are enumerated by the Windows lister,
145
+ * not this Unix/darwin CLI, so they fall to the honest "no voice list" label —
146
+ * preserving pre-AVI-S9.5 output. Adding a record with a static/name-to-id/single
147
+ * voiceModel adds a list arm for free (no edit here needed).
148
+ */
149
+ const LISTABLE_DISCOVERED = new Set(['piper', 'macos']);
150
+
151
+ /**
152
+ * Resolve a provider token to its voice list + display label by iterating the
153
+ * Provider Catalog (src/services/provider-catalog.js) and branching ONLY on the
154
+ * record's `voiceModel` — replacing the former four hardcoded per-provider
155
+ * equality branches (AVI-S9.5 / design row 19). Unknown tokens keep the honest
156
+ * "no voice list available" label from AVI-S8.1.
157
+ *
158
+ * @param {string} provider
159
+ * @param {string} currentVoice
160
+ * @param {string} voiceDir
161
+ * @returns {{ voices: object[], providerName: string }}
162
+ */
163
+ function selectVoices(provider, currentVoice, voiceDir) {
164
+ const record = getProvider(provider);
165
+ if (!record) {
166
+ return { voices: [], providerName: `${provider} (no voice list available)` };
167
+ }
168
+
169
+ // Label preserves the pre-existing strings exactly (macOS uses "TTS", not the
170
+ // catalog display name "macOS Say"); all others equal record.displayName.
171
+ const providerName = record.id === 'macos' ? 'macOS TTS' : record.displayName;
172
+
173
+ switch (record.voiceModel) {
174
+ case 'static': // kokoro
175
+ return { voices: getKokoroVoices(currentVoice), providerName };
176
+ case 'name-to-id': // elevenlabs
177
+ return { voices: getElevenLabsVoices(currentVoice), providerName };
178
+ case 'single': // soprano
179
+ return { voices: getSopranoVoices(currentVoice), providerName };
180
+ case 'discovered':
181
+ default:
182
+ if (record.id === 'piper') return { voices: getPiperVoices(voiceDir, currentVoice), providerName };
183
+ if (record.id === 'macos') return { voices: getMacOSVoices(currentVoice), providerName };
184
+ // A discovered provider without a discovery path on this platform: label
185
+ // it honestly instead of rendering an empty list under a wrong provider.
186
+ return { voices: [], providerName: `${provider} (no voice list available)` };
187
+ }
188
+ }
189
+
190
+ /**
191
+ * Main function
192
+ */
193
+ function main() {
194
+ const args = process.argv.slice(2);
195
+
196
+ // Parse arguments
197
+ const provider = args[0] || 'piper';
198
+ const currentVoice = args[1] || '';
199
+ const voiceDir = args[2] || '';
200
+
201
+ const { voices, providerName } = selectVoices(provider, currentVoice, voiceDir);
202
+
203
+ // Display with boxen
204
+ const output = formatVoicesList(voices, {
205
+ provider: providerName,
206
+ columns: 2,
207
+ showUsage: true
208
+ });
209
+
210
+ console.log(output);
211
+ }
212
+
213
+ // Only run when invoked as a CLI (keeps selectVoices importable by tests).
214
+ const _invokedDirectly = process.argv[1] &&
215
+ path.resolve(process.argv[1]) === fileURLToPath(import.meta.url);
216
+ if (_invokedDirectly) main();
217
+
218
+ export { selectVoices, LISTABLE_DISCOVERED };