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.
- package/.agentvibes/install-manifest.json +111 -91
- package/.claude/commands/agent-vibes/commands.json +0 -20
- package/.claude/commands/agent-vibes/unmute.md +6 -2
- package/.claude/config/audio-effects.cfg +6 -6
- package/.claude/github-star-reminder.txt +1 -1
- package/.claude/hooks/agentvibes-session-id.sh +69 -0
- package/.claude/hooks/bmad-party-speak.sh +20 -4
- package/.claude/hooks/bmad-speak.sh +60 -2
- package/.claude/hooks/bmad-tts-injector.sh +20 -1
- package/.claude/hooks/bmad-voice-manager.sh +25 -3
- package/.claude/hooks/clawdbot-receiver-SECURE.sh +21 -2
- package/.claude/hooks/clawdbot-receiver.sh +19 -1
- package/.claude/hooks/elevenlabs-voices.sh +62 -0
- package/.claude/hooks/kokoro-installer.sh +20 -10
- package/.claude/hooks/language-manager.sh +10 -3
- package/.claude/hooks/party-set-room.sh +71 -0
- package/.claude/hooks/party-stage-roster.py +328 -0
- package/.claude/hooks/personality-manager.sh +19 -2
- package/.claude/hooks/piper-voice-manager.sh +3 -2
- package/.claude/hooks/play-tts-agentvibes-receiver-for-voiceless-connections.sh +24 -5
- package/.claude/hooks/play-tts-elevenlabs.sh +38 -118
- package/.claude/hooks/play-tts-kokoro.sh +33 -6
- package/.claude/hooks/play-tts-soprano.sh +3 -2
- package/.claude/hooks/play-tts-ssh-remote.sh +37 -29
- package/.claude/hooks/play-tts-termux-ssh.sh +5 -4
- package/.claude/hooks/play-tts.sh +66 -61
- package/.claude/hooks/provider-catalog.json +352 -0
- package/.claude/hooks/provider-catalog.sh +161 -0
- package/.claude/hooks/provider-commands.sh +2 -1
- package/.claude/hooks/provider-manager.sh +47 -9
- package/.claude/hooks/python-resolver.sh +117 -0
- package/.claude/hooks/session-id.sh +56 -0
- package/.claude/hooks/session-start-tts.sh +39 -0
- package/.claude/hooks/speed-manager.sh +1 -1
- package/.claude/hooks/translate-manager.sh +3 -2
- package/.claude/hooks/translator.py +1 -1
- package/.claude/hooks/voice-manager.sh +242 -10
- package/.claude/hooks-windows/language-manager.ps1 +7 -1
- package/.claude/hooks-windows/personality-manager.ps1 +16 -1
- package/.claude/hooks-windows/play-tts-kokoro.ps1 +20 -4
- package/.claude/hooks-windows/play-tts.ps1 +32 -3
- package/.claude/hooks-windows/provider-catalog.ps1 +140 -0
- package/.claude/hooks-windows/provider-manager.ps1 +63 -8
- package/.claude/hooks-windows/tts-watcher.ps1 +33 -12
- package/.claude/hooks-windows/voice-manager-windows.ps1 +49 -0
- package/.mcp.json +0 -7
- package/README.md +12 -3
- package/RELEASE_NOTES.md +61 -0
- package/mcp-server/server.py +146 -49
- package/mcp-server/test_mcp_correctness.py +20 -2
- package/mcp-server/test_windows_script_parity.py +0 -2
- package/package.json +2 -2
- package/src/cli/list-voices.js +218 -114
- package/src/console/bling.js +71 -0
- package/src/console/music-preview.js +79 -0
- package/src/console/tabs/music-tab.js +16 -39
- package/src/console/tabs/settings-tab.js +195 -13
- package/src/console/tabs/setup-tab.js +9 -34
- package/src/console/tabs/voices-tab.js +83 -14
- package/src/console/widgets/track-picker.js +82 -0
- package/src/installer.js +136 -18
- package/src/services/provider-catalog.js +412 -0
- package/src/services/provider-voice-catalog.js +52 -73
- package/src/services/tts-engine-service.js +29 -0
- package/src/utils/provider-validator.js +62 -12
- package/.claude/commands/agent-vibes/language.md +0 -23
- package/.claude/commands/agent-vibes/learn.md +0 -67
- package/.claude/commands/agent-vibes/replay-target.md +0 -14
- package/.claude/commands/agent-vibes/target-voice.md +0 -26
- package/.claude/commands/agent-vibes/target.md +0 -30
- package/.claude/hooks/learn-manager.sh +0 -492
- package/.claude/hooks/replay-target-audio.sh +0 -95
- package/.claude/hooks-windows/learn-manager.ps1 +0 -241
package/mcp-server/server.py
CHANGED
|
@@ -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
|
|
864
|
+
Switch TTS provider between the supported synthesis engines.
|
|
732
865
|
|
|
733
866
|
Args:
|
|
734
|
-
provider: Provider name
|
|
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
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
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 /
|
|
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.
|
|
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.
|
|
99
|
+
"js-yaml": "^4.3.0",
|
|
100
100
|
"ora": "^6.0.0"
|
|
101
101
|
},
|
|
102
102
|
"engines": {
|
package/src/cli/list-voices.js
CHANGED
|
@@ -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 {
|
|
11
|
-
import
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
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 };
|