agentvibes 5.12.0-alpha.0 → 5.13.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.agentvibes/config.json +15 -1
- package/.agentvibes/install-manifest.json +342 -0
- package/.claude/activation-instructions +54 -54
- package/.claude/commands/agent-vibes/commands.json +0 -20
- package/.claude/commands/agent-vibes/unmute.md +6 -2
- package/.claude/commands/agent-vibes-bmad-voices.md +117 -117
- package/.claude/commands/agent-vibes-rdp.md +24 -24
- package/.claude/config/audio-effects.cfg +6 -6
- package/.claude/config/audio-effects.cfg.bak-kokoro +7 -0
- package/.claude/config/background-music-enabled.txt +1 -1
- package/.claude/config/background-music-position.txt +27 -0
- package/.claude/config/background-music-volume.txt +1 -0
- package/.claude/config/background-music.cfg +1 -0
- package/.claude/config/background-music.txt +1 -0
- package/.claude/config/language.txt +1 -0
- package/.claude/config/personality.txt +1 -0
- package/.claude/config/tts-speech-rate.txt +1 -0
- package/.claude/config/tts-verbosity.txt +1 -0
- package/.claude/docs/TERMUX_SETUP.md +408 -408
- 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 +72 -27
- package/.claude/hooks/play-tts-termux-ssh.sh +5 -4
- package/.claude/hooks/play-tts.sh +86 -69
- 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/audio-cache-utils.ps1.user.bak +119 -0
- 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/soprano-gradio-synth.py.user.bak +153 -0
- package/.claude/hooks-windows/tts-watcher.ps1 +84 -8
- package/.claude/hooks-windows/voice-manager-windows.ps1 +49 -0
- package/.claude/piper-voices-dir.txt +1 -0
- package/.clawdbot/README.md +105 -105
- package/.mcp.json +15 -0
- package/LICENSE +190 -190
- package/README.md +20 -6
- package/RELEASE_NOTES.md +81 -0
- package/WINDOWS-SETUP.md +208 -208
- package/bin/agent-vibes +39 -39
- package/bin/mcp-server.js +121 -121
- package/bin/test-bmad-pr +78 -78
- package/mcp-server/QUICK_START.md +203 -203
- package/mcp-server/README.md +345 -345
- package/mcp-server/examples/claude_desktop_config.json +11 -11
- package/mcp-server/examples/claude_desktop_config_piper.json +9 -9
- package/mcp-server/examples/custom_instructions.md +169 -169
- package/mcp-server/pyproject.toml +52 -52
- package/mcp-server/requirements.txt +2 -2
- package/mcp-server/server.py +146 -49
- package/mcp-server/test_mcp_correctness.py +20 -2
- package/mcp-server/test_server.py +395 -395
- package/mcp-server/test_windows_script_parity.py +0 -2
- package/package.json +1 -1
- package/src/cli/list-personalities.js +110 -110
- package/src/cli/list-voices.js +115 -11
- package/src/commands/bmad-voices.js +394 -394
- package/src/console/app.js +1 -9
- package/src/console/bling.js +71 -0
- package/src/console/brand-colors.js +13 -13
- package/src/console/constants/personalities.js +44 -44
- package/src/console/footer-config.js +0 -4
- package/src/console/music-preview.js +79 -0
- package/src/console/navigation.js +0 -1
- package/src/console/tabs/agents-tab.js +14 -2
- package/src/console/tabs/help-tab.js +314 -314
- package/src/console/tabs/music-tab.js +81 -5
- package/src/console/tabs/placeholder-tab.js +0 -2
- package/src/console/tabs/readme-tab.js +272 -272
- package/src/console/tabs/settings-tab.js +207 -14
- package/src/console/tabs/setup-tab.js +39 -36
- package/src/console/tabs/voices-tab.js +102 -23
- package/src/console/widgets/destroy-list.js +25 -25
- package/src/console/widgets/notice.js +55 -55
- package/src/console/widgets/track-picker.js +82 -0
- package/src/installer/language-screen.js +31 -31
- package/src/installer/music-file-input.js +304 -304
- package/src/installer.js +124 -10
- package/src/services/language-service.js +47 -47
- package/src/services/navigation-service.js +1 -1
- 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/audio-format-validator.js +277 -277
- package/src/utils/dependency-checker.js +469 -469
- package/src/utils/file-ownership-verifier.js +358 -358
- package/src/utils/music-file-validator.js +285 -285
- package/src/utils/preview-list-prompt.js +144 -144
- package/src/utils/provider-validator.js +62 -12
- package/src/utils/secure-music-storage.js +412 -412
- package/voice-assignments.json +8244 -8244
- 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
|
@@ -0,0 +1,328 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
# /// script
|
|
3
|
+
# requires-python = ">=3.11"
|
|
4
|
+
# ///
|
|
5
|
+
"""Broadcast a BMAD party's cast to an AgentVibes receiver (best-effort doorbell).
|
|
6
|
+
|
|
7
|
+
A receiver normally learns an avatar only when it speaks (one /speak event per
|
|
8
|
+
line). This doorbell stages the WHOLE cast up front: it resolves the active
|
|
9
|
+
party roster (via BMAD's read-only ``resolve_party.py``), joins each member to
|
|
10
|
+
its saved voice/avatar, and POSTs the result to a receiver's ``/stage-roster``
|
|
11
|
+
endpoint keyed by the SESSION id. Any receiver that implements ``/stage-roster``
|
|
12
|
+
can then paint the labelled, voiced cast at once; a receiver that doesn't (or a
|
|
13
|
+
down/absent one) simply never hears the doorbell and the party runs unchanged.
|
|
14
|
+
|
|
15
|
+
Session key
|
|
16
|
+
-----------
|
|
17
|
+
The broadcast is keyed by the canonical routing SESSION id -- ``basename`` of
|
|
18
|
+
``CLAUDE_PROJECT_DIR`` (falling back to ``--project-root``), slugified the same
|
|
19
|
+
way ``.claude/hooks/session-id.sh :: av_session_id`` slugifies it -- plus the
|
|
20
|
+
``--session-suffix`` (default ``bmad-party-mode``). The suffix stages the party
|
|
21
|
+
on its OWN session, distinct from the plain-TTS session for the same project.
|
|
22
|
+
|
|
23
|
+
Open-cast vs fixed-cast (the forward-compat contract)
|
|
24
|
+
-----------------------------------------------------
|
|
25
|
+
* Fixed-cast (members present): POST
|
|
26
|
+
{"session","project","roster":[{name,agentId,voice,role?,avatarId?}]}
|
|
27
|
+
* Open-cast (resolve_party sets ``open_cast`` when a room lists no fixed
|
|
28
|
+
members, only a freeform ``scene`` theme): POST
|
|
29
|
+
{"session","project","roster":[...anchors if any...],
|
|
30
|
+
"openCast":true,"room":{"id","name","scene"}}
|
|
31
|
+
Open-cast rooms are broadcast rather than skipped, so a receiver that
|
|
32
|
+
understands the room theme has everything it needs to render it.
|
|
33
|
+
|
|
34
|
+
``project`` is sent as a back-compat alias of ``session`` (current receivers key
|
|
35
|
+
on ``project``).
|
|
36
|
+
|
|
37
|
+
Best-effort contract
|
|
38
|
+
--------------------
|
|
39
|
+
Stdlib only (urllib). Any failure -- resolver error, connection refused, 404,
|
|
40
|
+
timeout -- prints a benign note to stderr and returns 0. A down or absent
|
|
41
|
+
receiver must NEVER block or fail the party.
|
|
42
|
+
|
|
43
|
+
party-stage-roster.py --project-root P [--skill S] [--party ID]
|
|
44
|
+
[--session-suffix bmad-party-mode] [--url http://127.0.0.1:3747]
|
|
45
|
+
"""
|
|
46
|
+
|
|
47
|
+
import argparse
|
|
48
|
+
import json
|
|
49
|
+
import os
|
|
50
|
+
import re
|
|
51
|
+
import subprocess
|
|
52
|
+
import sys
|
|
53
|
+
import urllib.request
|
|
54
|
+
import urllib.error
|
|
55
|
+
from pathlib import Path
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _resolve_roster(project_root: Path, skill_root: Path, party):
|
|
59
|
+
"""Run BMAD's resolve_party.py (READ-ONLY) and return its parsed JSON, or None."""
|
|
60
|
+
script = skill_root / "scripts" / "resolve_party.py"
|
|
61
|
+
cmd = [sys.executable, str(script), "--project-root", str(project_root), "--skill", str(skill_root)]
|
|
62
|
+
if party:
|
|
63
|
+
cmd += ["--party", party]
|
|
64
|
+
try:
|
|
65
|
+
# encoding+errors are REQUIRED on Windows: text=True defaults to cp1252,
|
|
66
|
+
# which crashes on the non-Latin icon bytes the resolver can emit.
|
|
67
|
+
# PYTHONUTF8 forces the child to write UTF-8 rather than crash.
|
|
68
|
+
child_env = {**os.environ, "PYTHONUTF8": "1", "PYTHONIOENCODING": "utf-8"}
|
|
69
|
+
out = subprocess.run(cmd, capture_output=True, text=True,
|
|
70
|
+
encoding="utf-8", errors="replace",
|
|
71
|
+
env=child_env, timeout=60)
|
|
72
|
+
except (OSError, subprocess.SubprocessError) as e:
|
|
73
|
+
sys.stderr.write(f"stage-roster: resolve_party failed to run: {e}\n")
|
|
74
|
+
return None
|
|
75
|
+
if out.returncode != 0 or not out.stdout.strip():
|
|
76
|
+
sys.stderr.write("stage-roster: resolve_party returned nothing usable\n")
|
|
77
|
+
return None
|
|
78
|
+
try:
|
|
79
|
+
return json.loads(out.stdout)
|
|
80
|
+
except json.JSONDecodeError:
|
|
81
|
+
sys.stderr.write("stage-roster: resolve_party output was not JSON\n")
|
|
82
|
+
return None
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _home_dir() -> Path:
|
|
86
|
+
"""User home, resilient to the launch environment.
|
|
87
|
+
|
|
88
|
+
os.path.expanduser("~") returns "~" unchanged when USERPROFILE and
|
|
89
|
+
HOMEDRIVE/HOMEPATH are all unset -- which is exactly the env some launchers
|
|
90
|
+
hand these scripts. Check the vars actually present, Windows-then-POSIX.
|
|
91
|
+
"""
|
|
92
|
+
for var in ("USERPROFILE", "HOME"):
|
|
93
|
+
v = os.environ.get(var)
|
|
94
|
+
if v:
|
|
95
|
+
return Path(v)
|
|
96
|
+
hd, hp = os.environ.get("HOMEDRIVE"), os.environ.get("HOMEPATH")
|
|
97
|
+
if hd and hp:
|
|
98
|
+
return Path(hd + hp)
|
|
99
|
+
return Path(os.path.expanduser("~"))
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _find_skill_root(project_root: Path):
|
|
103
|
+
"""Locate the installed bmad-party-mode skill dir (project-local, else global).
|
|
104
|
+
|
|
105
|
+
Returns a Path whose scripts/resolve_party.py exists, or None.
|
|
106
|
+
"""
|
|
107
|
+
candidates = [
|
|
108
|
+
project_root / ".claude" / "skills" / "bmad-party-mode",
|
|
109
|
+
_home_dir() / ".claude" / "skills" / "bmad-party-mode",
|
|
110
|
+
]
|
|
111
|
+
for c in candidates:
|
|
112
|
+
if (c / "scripts" / "resolve_party.py").exists():
|
|
113
|
+
return c
|
|
114
|
+
return None
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _load_json_map(name: str) -> dict:
|
|
118
|
+
"""Read a ~/.agentvibes/<name> JSON map. {} if absent or unreadable."""
|
|
119
|
+
path = _home_dir() / ".agentvibes" / name
|
|
120
|
+
try:
|
|
121
|
+
with path.open(encoding="utf-8") as f:
|
|
122
|
+
data = json.load(f)
|
|
123
|
+
except (OSError, json.JSONDecodeError):
|
|
124
|
+
return {}
|
|
125
|
+
return data if isinstance(data, dict) else {}
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _voice_for(code: str, vmap: dict) -> str:
|
|
129
|
+
"""Saved voice string for an agent code, or '' if none.
|
|
130
|
+
|
|
131
|
+
Prefers the richer agents[code].voice; falls back to the flat
|
|
132
|
+
voiceMap[code] (older shape). Both key by the canonical agent code.
|
|
133
|
+
"""
|
|
134
|
+
agents = vmap.get("agents") or {}
|
|
135
|
+
entry = agents.get(code)
|
|
136
|
+
if isinstance(entry, dict) and entry.get("voice"):
|
|
137
|
+
return str(entry["voice"])
|
|
138
|
+
flat = vmap.get("voiceMap") or {}
|
|
139
|
+
if isinstance(flat.get(code), str):
|
|
140
|
+
return flat[code]
|
|
141
|
+
return ""
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def _avatar_for(code: str, vmap: dict, amap: dict) -> str:
|
|
145
|
+
"""Saved avatarId for an agent code, or '' if none.
|
|
146
|
+
|
|
147
|
+
The dedicated avatar map wins; the voice map's agents[id].avatarId is a
|
|
148
|
+
legacy fallback (honoured if present).
|
|
149
|
+
"""
|
|
150
|
+
ag = (amap.get("agents") or {}) if isinstance(amap, dict) else {}
|
|
151
|
+
entry = ag.get(code)
|
|
152
|
+
if isinstance(entry, dict) and entry.get("avatarId"):
|
|
153
|
+
return str(entry["avatarId"])
|
|
154
|
+
agents = vmap.get("agents") or {}
|
|
155
|
+
ventry = agents.get(code)
|
|
156
|
+
if isinstance(ventry, dict) and ventry.get("avatarId"):
|
|
157
|
+
return str(ventry["avatarId"])
|
|
158
|
+
return ""
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def _session_key(project_root: Path) -> str:
|
|
162
|
+
"""Canonical routing SESSION id -- the same one the TTS forward paths send.
|
|
163
|
+
|
|
164
|
+
Mirrors .claude/hooks/session-id.sh :: av_session_id: basename of
|
|
165
|
+
CLAUDE_PROJECT_DIR (the real user project) when set, else --project-root,
|
|
166
|
+
slugified so the id is a safe JSON string and a stable receiver map key.
|
|
167
|
+
"""
|
|
168
|
+
cpd = os.environ.get("CLAUDE_PROJECT_DIR", "").strip()
|
|
169
|
+
base = Path(cpd) if cpd else project_root
|
|
170
|
+
name = base.name or "unknown"
|
|
171
|
+
# Slugify to match av_session_id: collapse anything outside [A-Za-z0-9._-]
|
|
172
|
+
# to '-', then trim leading/trailing dashes.
|
|
173
|
+
name = re.sub(r"[^A-Za-z0-9._-]", "-", name).strip("-")
|
|
174
|
+
return name or "unknown"
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def _active_party(session_key: str, explicit):
|
|
178
|
+
"""Resolve which room to stage.
|
|
179
|
+
|
|
180
|
+
Explicit --party wins. Otherwise read the AgentVibes-side active-room state
|
|
181
|
+
file ~/.agentvibes/party-active-<sessionid>.json = {"group": "<id>"} (written
|
|
182
|
+
by party-set-room.sh). If neither is present, return None so the DEFAULT
|
|
183
|
+
party resolves (the common installed-agent room).
|
|
184
|
+
"""
|
|
185
|
+
if explicit:
|
|
186
|
+
return explicit
|
|
187
|
+
path = _home_dir() / ".agentvibes" / f"party-active-{session_key}.json"
|
|
188
|
+
try:
|
|
189
|
+
with path.open(encoding="utf-8") as f:
|
|
190
|
+
data = json.load(f)
|
|
191
|
+
except (OSError, json.JSONDecodeError):
|
|
192
|
+
return None
|
|
193
|
+
if isinstance(data, dict):
|
|
194
|
+
group = data.get("group")
|
|
195
|
+
if isinstance(group, str) and group.strip():
|
|
196
|
+
return group.strip()
|
|
197
|
+
return None
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def build_roster(resolved: dict, vmap: dict, amap: dict):
|
|
201
|
+
"""Join resolver members to voices/avatars. Returns (roster, skipped_names)."""
|
|
202
|
+
members = resolved.get("members") or []
|
|
203
|
+
roster, skipped = [], []
|
|
204
|
+
for m in members:
|
|
205
|
+
code = m.get("code") or ""
|
|
206
|
+
name = m.get("name") or code or "Agent"
|
|
207
|
+
voice = _voice_for(code, vmap)
|
|
208
|
+
if not voice:
|
|
209
|
+
# Without a voice string the member can't key-match a later /speak
|
|
210
|
+
# event, so staging it would orphan an avatar -- skip it.
|
|
211
|
+
skipped.append(name)
|
|
212
|
+
continue
|
|
213
|
+
member = {"name": name, "agentId": code, "voice": voice}
|
|
214
|
+
title = (m.get("title") or "").strip()
|
|
215
|
+
if title:
|
|
216
|
+
member["role"] = title
|
|
217
|
+
avatar = _avatar_for(code, vmap, amap)
|
|
218
|
+
if avatar:
|
|
219
|
+
member["avatarId"] = avatar
|
|
220
|
+
roster.append(member)
|
|
221
|
+
return roster, skipped
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def post_payload(url: str, payload: dict):
|
|
225
|
+
"""POST the payload to /stage-roster. Returns (ok, message)."""
|
|
226
|
+
body = json.dumps(payload).encode("utf-8")
|
|
227
|
+
base = url.rstrip("/")
|
|
228
|
+
req = urllib.request.Request(
|
|
229
|
+
base + "/stage-roster",
|
|
230
|
+
data=body,
|
|
231
|
+
headers={"Content-Type": "application/json", "Origin": base},
|
|
232
|
+
method="POST",
|
|
233
|
+
)
|
|
234
|
+
try:
|
|
235
|
+
with urllib.request.urlopen(req, timeout=10) as resp:
|
|
236
|
+
return True, resp.read().decode("utf-8", "replace")
|
|
237
|
+
except urllib.error.HTTPError as e:
|
|
238
|
+
return False, f"HTTP {e.code}: {e.read().decode('utf-8', 'replace')[:200]}"
|
|
239
|
+
except (urllib.error.URLError, OSError) as e:
|
|
240
|
+
# Receiver not running / unreachable -- benign; the party runs regardless.
|
|
241
|
+
return False, f"could not reach receiver ({e}); is a receiver up?"
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def _default_url() -> str:
|
|
245
|
+
port = (os.environ.get("AGENTVIBES_TH_PORT")
|
|
246
|
+
or os.environ.get("TALKING_HEAD_PORT")
|
|
247
|
+
or "3747").strip()
|
|
248
|
+
if not port.isdigit():
|
|
249
|
+
port = "3747"
|
|
250
|
+
return f"http://127.0.0.1:{port}"
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def main() -> int:
|
|
254
|
+
ap = argparse.ArgumentParser(description="Broadcast a BMAD party cast to an AgentVibes receiver.")
|
|
255
|
+
ap.add_argument("--project-root", required=True)
|
|
256
|
+
ap.add_argument("--skill", default=None,
|
|
257
|
+
help="Path to the bmad-party-mode skill dir (auto-located if omitted)")
|
|
258
|
+
ap.add_argument("--party", default=None, help="Explicit group id (wins over the active-room file)")
|
|
259
|
+
ap.add_argument("--session-suffix", "--coin-suffix", dest="session_suffix",
|
|
260
|
+
default="bmad-party-mode",
|
|
261
|
+
help="Append '-<suffix>' to the session key so the party stages on its "
|
|
262
|
+
"OWN session (default: bmad-party-mode).")
|
|
263
|
+
ap.add_argument("--url", default=None)
|
|
264
|
+
args = ap.parse_args()
|
|
265
|
+
|
|
266
|
+
project_root = Path(args.project_root).resolve()
|
|
267
|
+
|
|
268
|
+
skill_root = Path(args.skill).resolve() if args.skill else _find_skill_root(project_root)
|
|
269
|
+
if skill_root is None:
|
|
270
|
+
sys.stderr.write("stage-roster: bmad-party-mode skill not found -- nothing to stage.\n")
|
|
271
|
+
return 0
|
|
272
|
+
|
|
273
|
+
session = _session_key(project_root)
|
|
274
|
+
party = _active_party(session, args.party)
|
|
275
|
+
|
|
276
|
+
resolved = _resolve_roster(project_root, skill_root, party)
|
|
277
|
+
if resolved is None:
|
|
278
|
+
return 0 # resolver failure already warned; never break the party
|
|
279
|
+
|
|
280
|
+
suffix = (args.session_suffix or "").strip().strip("-")
|
|
281
|
+
session_key = f"{session}-{suffix}" if suffix else session
|
|
282
|
+
|
|
283
|
+
vmap = _load_json_map("bmad-voice-map.json")
|
|
284
|
+
amap = _load_json_map("bmad-avatar-map.json")
|
|
285
|
+
roster, skipped = build_roster(resolved, vmap, amap)
|
|
286
|
+
if skipped:
|
|
287
|
+
sys.stderr.write(f"stage-roster: skipped (no saved voice): {', '.join(skipped)}\n")
|
|
288
|
+
|
|
289
|
+
open_cast = bool(resolved.get("open_cast"))
|
|
290
|
+
|
|
291
|
+
if open_cast:
|
|
292
|
+
# Broadcast open-cast rooms rather than skipping them: the room theme is
|
|
293
|
+
# all a receiver needs to render the scene.
|
|
294
|
+
room = {"id": resolved.get("active", ""), "name": resolved.get("name", "")}
|
|
295
|
+
scene = resolved.get("scene")
|
|
296
|
+
if scene:
|
|
297
|
+
room["scene"] = scene
|
|
298
|
+
payload = {"session": session_key, "project": session_key,
|
|
299
|
+
"roster": roster, "openCast": True, "room": room}
|
|
300
|
+
else:
|
|
301
|
+
if not roster:
|
|
302
|
+
sys.stderr.write("stage-roster: no members had a saved voice -- nothing to stage.\n")
|
|
303
|
+
return 0
|
|
304
|
+
payload = {"session": session_key, "project": session_key, "roster": roster}
|
|
305
|
+
|
|
306
|
+
# Test hook: capture the exact payload to a file instead of hitting the
|
|
307
|
+
# network, so the hermetic bats suite can assert the wire shape.
|
|
308
|
+
capture = os.environ.get("AGENTVIBES_STAGE_CAPTURE", "").strip()
|
|
309
|
+
if capture:
|
|
310
|
+
try:
|
|
311
|
+
Path(capture).write_text(json.dumps(payload, ensure_ascii=False), encoding="utf-8")
|
|
312
|
+
except OSError as e:
|
|
313
|
+
sys.stderr.write(f"stage-roster: could not write capture file: {e}\n")
|
|
314
|
+
print(f"stage-roster: captured payload for '{session_key}' -> {capture}")
|
|
315
|
+
return 0
|
|
316
|
+
|
|
317
|
+
url = args.url or _default_url()
|
|
318
|
+
ok, msg = post_payload(url, payload)
|
|
319
|
+
kind = "open-cast" if open_cast else f"{len(roster)} member(s)"
|
|
320
|
+
if ok:
|
|
321
|
+
print(f"stage-roster: staged {kind} for '{session_key}' -> {msg.strip()}")
|
|
322
|
+
else:
|
|
323
|
+
print(f"stage-roster: {msg}")
|
|
324
|
+
return 0 # benign: a down receiver must never block the party
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
if __name__ == "__main__":
|
|
328
|
+
sys.exit(main())
|
|
@@ -62,6 +62,17 @@ fi
|
|
|
62
62
|
|
|
63
63
|
PERSONALITY_FILE="$CLAUDE_DIR/tts-personality.txt"
|
|
64
64
|
|
|
65
|
+
# Source the generated Provider Catalog (bash-3.2-safe SSOT accessors) when present.
|
|
66
|
+
# FAIL-SAFE: consumers probe `type catalog_* >/dev/null 2>&1` and fall back to the
|
|
67
|
+
# legacy hardcoded literal when the artifact is missing (installed-tree skew) —
|
|
68
|
+
# mirrors voice-manager.sh's identical pattern (AVI-S9.3/9.4).
|
|
69
|
+
_load_provider_catalog() {
|
|
70
|
+
if [[ -f "$SCRIPT_DIR/provider-catalog.sh" ]]; then
|
|
71
|
+
# shellcheck source=/dev/null
|
|
72
|
+
source "$SCRIPT_DIR/provider-catalog.sh" 2>/dev/null || true
|
|
73
|
+
fi
|
|
74
|
+
}
|
|
75
|
+
|
|
65
76
|
# Function to get personality data from markdown file
|
|
66
77
|
get_personality_data() {
|
|
67
78
|
local personality="$1"
|
|
@@ -200,8 +211,14 @@ case "$1" in
|
|
|
200
211
|
# Try to get Piper-specific voice first
|
|
201
212
|
ASSIGNED_VOICE=$(get_personality_data "$PERSONALITY" "piper_voice")
|
|
202
213
|
if [[ -z "$ASSIGNED_VOICE" ]]; then
|
|
203
|
-
# Fallback to default Piper voice
|
|
204
|
-
|
|
214
|
+
# Fallback to default Piper voice — sourced from the generated Provider
|
|
215
|
+
# Catalog SSOT (AVI-S9.4). FAIL-SAFE: legacy literal if artifact missing.
|
|
216
|
+
_load_provider_catalog
|
|
217
|
+
if type catalog_default_voice >/dev/null 2>&1; then
|
|
218
|
+
ASSIGNED_VOICE="$(catalog_default_voice piper)"
|
|
219
|
+
else
|
|
220
|
+
ASSIGNED_VOICE="en_US-lessac-medium"
|
|
221
|
+
fi
|
|
205
222
|
fi
|
|
206
223
|
else
|
|
207
224
|
# Use Piper voice (reads from piper_voice: field)
|
|
@@ -296,6 +296,7 @@ patch_libritts_speaker_names() {
|
|
|
296
296
|
# Find voice-assignments.json relative to this script (SCRIPT_DIR/../.. = project root)
|
|
297
297
|
local script_dir
|
|
298
298
|
script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
299
|
+
source "$script_dir/python-resolver.sh"
|
|
299
300
|
local project_root
|
|
300
301
|
project_root="$(cd "$script_dir/../.." 2>/dev/null && pwd)"
|
|
301
302
|
local catalog="$project_root/voice-assignments.json"
|
|
@@ -315,8 +316,8 @@ patch_libritts_speaker_names() {
|
|
|
315
316
|
fi
|
|
316
317
|
|
|
317
318
|
# Check if already patched (first key doesn't start with 'p' + digits)
|
|
318
|
-
if
|
|
319
|
-
|
|
319
|
+
if [[ -n "$PYTHON_BIN" ]]; then
|
|
320
|
+
"$PYTHON_BIN" -c "
|
|
320
321
|
import json, sys
|
|
321
322
|
|
|
322
323
|
json_path = sys.argv[1]
|
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
set -euo pipefail
|
|
15
15
|
|
|
16
16
|
TEXT="${1:-}"
|
|
17
|
-
VOICE="${2:-
|
|
17
|
+
VOICE="${2:-}"
|
|
18
18
|
AGENT_NAME="${3:-default}"
|
|
19
19
|
|
|
20
20
|
# Validate required input
|
|
@@ -26,8 +26,27 @@ fi
|
|
|
26
26
|
|
|
27
27
|
# Get script directory
|
|
28
28
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
29
|
+
source "$SCRIPT_DIR/python-resolver.sh"
|
|
29
30
|
PROJECT_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"
|
|
30
31
|
|
|
32
|
+
# Default voice: prefer the Provider Catalog's piper default (AVI-S9.6 AC3,
|
|
33
|
+
# design row 23), falling back to the legacy literal if the generated catalog
|
|
34
|
+
# artifact is missing (installed-tree skew). NOTE: any voice-format allowlist
|
|
35
|
+
# added to this receiver in the future must stay independent of the catalog —
|
|
36
|
+
# a security allowlist is deliberately narrower than the full inventory (see
|
|
37
|
+
# provider-catalog.js module header).
|
|
38
|
+
if [[ -z "$VOICE" ]]; then
|
|
39
|
+
if [[ -f "$SCRIPT_DIR/provider-catalog.sh" ]]; then
|
|
40
|
+
# shellcheck source=/dev/null
|
|
41
|
+
source "$SCRIPT_DIR/provider-catalog.sh" 2>/dev/null || true
|
|
42
|
+
fi
|
|
43
|
+
if type catalog_default_voice >/dev/null 2>&1; then
|
|
44
|
+
VOICE="$(catalog_default_voice piper)"
|
|
45
|
+
else
|
|
46
|
+
VOICE="en_US-lessac-medium" # legacy fallback
|
|
47
|
+
fi
|
|
48
|
+
fi
|
|
49
|
+
|
|
31
50
|
# ---------------------------------------------------------------------------
|
|
32
51
|
# Get SSH connection details from config
|
|
33
52
|
# Prefer ~/.agentvibes/transport-config.json, fall back to legacy host file
|
|
@@ -38,10 +57,10 @@ SSH_KEY=""
|
|
|
38
57
|
SSH_PORT=""
|
|
39
58
|
|
|
40
59
|
_TRANSPORT_CFG="$HOME/.agentvibes/transport-config.json"
|
|
41
|
-
if [[ -f "$_TRANSPORT_CFG" ]] &&
|
|
42
|
-
SSH_HOST=$(
|
|
43
|
-
SSH_KEY=$(
|
|
44
|
-
SSH_PORT=$(
|
|
60
|
+
if [[ -f "$_TRANSPORT_CFG" ]] && [[ -n "$PYTHON_BIN" ]]; then
|
|
61
|
+
SSH_HOST=$("$PYTHON_BIN" -c "import json; d=json.load(open('$_TRANSPORT_CFG')); p=d.get('agentvibes-receiver',{}); print(p.get('host',''))" 2>/dev/null || echo "")
|
|
62
|
+
SSH_KEY=$("$PYTHON_BIN" -c "import json; d=json.load(open('$_TRANSPORT_CFG')); p=d.get('agentvibes-receiver',{}); print(p.get('sshKey',''))" 2>/dev/null || echo "")
|
|
63
|
+
SSH_PORT=$("$PYTHON_BIN" -c "import json; d=json.load(open('$_TRANSPORT_CFG')); p=d.get('agentvibes-receiver',{}); print(p.get('port',''))" 2>/dev/null || echo "")
|
|
45
64
|
fi
|
|
46
65
|
|
|
47
66
|
if [[ -z "$SSH_HOST" ]]; then
|
|
@@ -16,7 +16,8 @@
|
|
|
16
16
|
# @related play-tts.sh, provider-manager.sh, language-manager.sh
|
|
17
17
|
#
|
|
18
18
|
# Voice can be a name (e.g. "Rachel") or a raw ElevenLabs voice ID.
|
|
19
|
-
# Set ELEVENLABS_API_KEY in your shell profile or
|
|
19
|
+
# Set ELEVENLABS_API_KEY in your shell profile, or store it in
|
|
20
|
+
# ~/.agentvibes/elevenlabs-key.txt (chmod 600).
|
|
20
21
|
# Default voice: Rachel (English, warm female)
|
|
21
22
|
#
|
|
22
23
|
|
|
@@ -31,6 +32,7 @@ SCRIPT_PATH="$(readlink -f "${BASH_SOURCE[0]}")"
|
|
|
31
32
|
SCRIPT_DIR="$(dirname "$SCRIPT_PATH")"
|
|
32
33
|
|
|
33
34
|
source "$SCRIPT_DIR/audio-cache-utils.sh"
|
|
35
|
+
source "$SCRIPT_DIR/python-resolver.sh"
|
|
34
36
|
source "$SCRIPT_DIR/language-manager.sh"
|
|
35
37
|
|
|
36
38
|
if [[ -z "$TEXT" ]]; then
|
|
@@ -44,58 +46,7 @@ if [[ ${#TEXT} -gt 2000 ]]; then
|
|
|
44
46
|
fi
|
|
45
47
|
|
|
46
48
|
# ---------------------------------------------------------------------------
|
|
47
|
-
#
|
|
48
|
-
# Opt-in via ~/.agentvibes/infisical.env, which sets (env vars take precedence):
|
|
49
|
-
# INFISICAL_SECRET_NAME name of the secret in Infisical (e.g. ELEVEN_LABS)
|
|
50
|
-
# INFISICAL_PROJECT_ID project/workspace id
|
|
51
|
-
# INFISICAL_ENV environment slug (default: prod)
|
|
52
|
-
# INFISICAL_DOMAIN API base URL (default: http://127.0.0.1:8200/api)
|
|
53
|
-
# INFISICAL_BOOTSTRAP universal-auth creds file (default: ~/.palace-bootstrap)
|
|
54
|
-
# Requires the infisical CLI + python3. Fails soft: prints nothing and returns
|
|
55
|
-
# non-zero when unconfigured or unreachable, so the normal error path still runs.
|
|
56
|
-
# The fetched value is never printed.
|
|
57
|
-
_fetch_key_from_infisical() {
|
|
58
|
-
local cfg="${HOME}/.agentvibes/infisical.env"
|
|
59
|
-
if [[ -f "$cfg" ]]; then
|
|
60
|
-
set -o allexport; source "$cfg"; set +o allexport
|
|
61
|
-
fi
|
|
62
|
-
|
|
63
|
-
local secret_name="${INFISICAL_SECRET_NAME:-}"
|
|
64
|
-
local project_id="${INFISICAL_PROJECT_ID:-}"
|
|
65
|
-
local env_name="${INFISICAL_ENV:-prod}"
|
|
66
|
-
local domain="${INFISICAL_DOMAIN:-http://127.0.0.1:8200/api}"
|
|
67
|
-
local bootstrap="${INFISICAL_BOOTSTRAP:-${HOME}/.palace-bootstrap}"
|
|
68
|
-
|
|
69
|
-
[[ -n "$secret_name" && -n "$project_id" ]] || return 1
|
|
70
|
-
command -v infisical >/dev/null 2>&1 || return 1
|
|
71
|
-
command -v python3 >/dev/null 2>&1 || return 1
|
|
72
|
-
|
|
73
|
-
# Obtain an access token: reuse an exported INFISICAL_TOKEN, else exchange the
|
|
74
|
-
# universal-auth client credentials from the bootstrap file (creds via env only).
|
|
75
|
-
local token="${INFISICAL_TOKEN:-}"
|
|
76
|
-
if [[ -z "$token" ]]; then
|
|
77
|
-
[[ -f "$bootstrap" ]] || return 1
|
|
78
|
-
set -o allexport; source "$bootstrap"; set +o allexport
|
|
79
|
-
[[ -n "${INFISICAL_UNIVERSAL_AUTH_CLIENT_ID:-}" && -n "${INFISICAL_UNIVERSAL_AUTH_CLIENT_SECRET:-}" ]] || return 1
|
|
80
|
-
token="$(_AV_DOMAIN="$domain" python3 -c '
|
|
81
|
-
import os, json, urllib.request
|
|
82
|
-
d = os.environ["_AV_DOMAIN"].rstrip("/")
|
|
83
|
-
req = urllib.request.Request(d + "/v1/auth/universal-auth/login",
|
|
84
|
-
data=json.dumps({"clientId": os.environ["INFISICAL_UNIVERSAL_AUTH_CLIENT_ID"],
|
|
85
|
-
"clientSecret": os.environ["INFISICAL_UNIVERSAL_AUTH_CLIENT_SECRET"]}).encode(),
|
|
86
|
-
headers={"Content-Type": "application/json"})
|
|
87
|
-
print(json.loads(urllib.request.urlopen(req, timeout=10).read())["accessToken"])
|
|
88
|
-
' 2>/dev/null || true)"
|
|
89
|
-
fi
|
|
90
|
-
[[ -n "$token" ]] || return 1
|
|
91
|
-
|
|
92
|
-
INFISICAL_TOKEN="$token" infisical secrets get "$secret_name" \
|
|
93
|
-
--projectId "$project_id" --env "$env_name" --domain "$domain" \
|
|
94
|
-
--plain 2>/dev/null | head -1
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
# ---------------------------------------------------------------------------
|
|
98
|
-
# API key — env var (only if it looks valid), then key file, then Infisical.
|
|
49
|
+
# API key — env var (only if it looks valid), then key file.
|
|
99
50
|
# A malformed/truncated ELEVENLABS_API_KEY (e.g. a half-pasted key) is ignored so
|
|
100
51
|
# it can't silently shadow a good key file — a common foot-gun that yields
|
|
101
52
|
# confusing "API key fail" errors despite a valid key on disk.
|
|
@@ -122,91 +73,55 @@ if [[ -z "$API_KEY" ]]; then
|
|
|
122
73
|
API_KEY="$(tr -d '[:space:]' < "$_key_file")"
|
|
123
74
|
fi
|
|
124
75
|
fi
|
|
125
|
-
if [[ -z "$API_KEY" ]]; then
|
|
126
|
-
API_KEY="$(_fetch_key_from_infisical || true)"
|
|
127
|
-
API_KEY="$(printf '%s' "$API_KEY" | tr -d '[:space:]')"
|
|
128
|
-
fi
|
|
129
76
|
|
|
130
77
|
if [[ -z "$API_KEY" ]]; then
|
|
131
78
|
echo "❌ ElevenLabs API key not set." >&2
|
|
132
|
-
echo " Set it one of
|
|
79
|
+
echo " Set it one of two ways:" >&2
|
|
133
80
|
echo " 1. export ELEVENLABS_API_KEY=your_key (add to ~/.bashrc or ~/.zshrc)" >&2
|
|
134
81
|
echo " 2. echo 'your_key' > ~/.agentvibes/elevenlabs-key.txt && chmod 600 ~/.agentvibes/elevenlabs-key.txt" >&2
|
|
135
|
-
echo " 3. Configure Infisical in ~/.agentvibes/infisical.env (INFISICAL_SECRET_NAME + INFISICAL_PROJECT_ID)" >&2
|
|
136
82
|
echo " Get a free key at: https://elevenlabs.io" >&2
|
|
137
83
|
exit 2
|
|
138
84
|
fi
|
|
139
85
|
|
|
140
86
|
# ---------------------------------------------------------------------------
|
|
141
|
-
#
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
VOICE_IDS["Freya"]="jsCqWAovK2LkecY7zXl4"
|
|
161
|
-
VOICE_IDS["Gigi"]="jBpfuIE2acCO8z3wKNLl"
|
|
162
|
-
VOICE_IDS["Giovanni"]="zcAOhNBS3c14rBihAFp1"
|
|
163
|
-
VOICE_IDS["Glinda"]="z9fAnlkpzviPz146aGWa"
|
|
164
|
-
VOICE_IDS["Grace"]="oWAxZDx7w5VEj9dCyTzz"
|
|
165
|
-
VOICE_IDS["Harry"]="SOYHLrjzK2X1ezoPC6cr"
|
|
166
|
-
VOICE_IDS["James"]="ZQe5CZNOzWyzPSCn5a3c"
|
|
167
|
-
VOICE_IDS["Jessie"]="t0jbNlBVZ17f02VDIeMI"
|
|
168
|
-
VOICE_IDS["Josh"]="TxGEqnHWrfWFTfGW9XjX"
|
|
169
|
-
VOICE_IDS["Liam"]="TX3LPaxmHKxFdv7VOQHJ"
|
|
170
|
-
VOICE_IDS["Lily"]="pFZP5JQG7iQjIQuC4Bku"
|
|
171
|
-
VOICE_IDS["Matilda"]="XrExE9yKIg1WjnnlVkGX"
|
|
172
|
-
VOICE_IDS["Michael"]="flq6f7yk4E4fJM5XTYuZ"
|
|
173
|
-
VOICE_IDS["Mimi"]="zrHiDhphv9ZnVXBqCLjz"
|
|
174
|
-
VOICE_IDS["Nicole"]="piTKgcLEGmPE4e6mEKli"
|
|
175
|
-
VOICE_IDS["Patrick"]="ODq5zmih8GrVes37Dizd"
|
|
176
|
-
VOICE_IDS["Paul"]="5Q0t7uMcjvnagumLfvZi"
|
|
177
|
-
VOICE_IDS["Sam"]="yoZ06aMxZJJ28mfd3POQ"
|
|
178
|
-
VOICE_IDS["Sarah"]="EXAVITQu4vr4xnSDxMaL"
|
|
179
|
-
VOICE_IDS["Serena"]="pMsXgVXv3BLzUgSXRplE"
|
|
180
|
-
VOICE_IDS["Thomas"]="GBv7mTt0atIp3Br8iCZE"
|
|
181
|
-
|
|
182
|
-
DEFAULT_VOICE_ID="${VOICE_IDS[Rachel]}"
|
|
87
|
+
# ElevenLabs voice catalog — single source of truth (shared with voice-manager.sh).
|
|
88
|
+
# Provides ELEVENLABS_DEFAULT_VOICE, elevenlabs_resolve_voice(), elevenlabs_voice_names().
|
|
89
|
+
if [[ -f "$SCRIPT_DIR/elevenlabs-voices.sh" ]]; then
|
|
90
|
+
# shellcheck source=/dev/null
|
|
91
|
+
source "$SCRIPT_DIR/elevenlabs-voices.sh"
|
|
92
|
+
else
|
|
93
|
+
# Minimal fallback so speech still works if the catalog file is missing.
|
|
94
|
+
# bash-3.2-safe: no associative array, case-statement resolver.
|
|
95
|
+
ELEVENLABS_DEFAULT_VOICE="Sarah"
|
|
96
|
+
elevenlabs_resolve_voice() {
|
|
97
|
+
local q="${1:-}"; [[ -z "$q" ]] && return 1
|
|
98
|
+
case "$(printf '%s' "$q" | tr '[:upper:]' '[:lower:]')" in
|
|
99
|
+
sarah) printf %s "EXAVITQu4vr4xnSDxMaL"; return 0 ;;
|
|
100
|
+
esac
|
|
101
|
+
[[ "$q" =~ ^[A-Za-z0-9]{20}$ ]] && { printf %s "$q"; return 0; }
|
|
102
|
+
return 1
|
|
103
|
+
}
|
|
104
|
+
fi
|
|
105
|
+
DEFAULT_VOICE_ID="$(elevenlabs_resolve_voice "$ELEVENLABS_DEFAULT_VOICE")"
|
|
183
106
|
|
|
184
107
|
# ---------------------------------------------------------------------------
|
|
185
108
|
# Resolve voice ID from override or config
|
|
186
109
|
VOICE_ID=""
|
|
187
110
|
|
|
188
111
|
if [[ -n "$VOICE_OVERRIDE" ]]; then
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
elif [[ "$VOICE_OVERRIDE" =~ ^[a-zA-Z0-9]{10,40}$ ]]; then
|
|
192
|
-
# Looks like a raw voice ID
|
|
193
|
-
VOICE_ID="$VOICE_OVERRIDE"
|
|
194
|
-
else
|
|
195
|
-
echo "⚠️ Unknown ElevenLabs voice '$VOICE_OVERRIDE', using Rachel" >&2
|
|
112
|
+
VOICE_ID="$(elevenlabs_resolve_voice "$VOICE_OVERRIDE")" || {
|
|
113
|
+
echo "[WARN] Unknown ElevenLabs voice '$VOICE_OVERRIDE', using $ELEVENLABS_DEFAULT_VOICE" >&2
|
|
196
114
|
VOICE_ID="$DEFAULT_VOICE_ID"
|
|
197
|
-
|
|
115
|
+
}
|
|
198
116
|
else
|
|
199
|
-
#
|
|
117
|
+
# Config path: read the voice saved by /agent-vibes:switch (a raw voice_id or
|
|
118
|
+
# a friendly name) and resolve it the same way — this is what hook-driven
|
|
119
|
+
# speech uses, so a switched voice actually plays (not a silent default).
|
|
200
120
|
VOICE_NAME=""
|
|
201
121
|
if [[ -f "$SCRIPT_DIR/voice-manager.sh" ]]; then
|
|
202
122
|
VOICE_NAME="$("$SCRIPT_DIR/voice-manager.sh" get 2>/dev/null || true)"
|
|
203
123
|
fi
|
|
204
|
-
|
|
205
|
-
if [[ -n "$VOICE_NAME" && -n "${VOICE_IDS[$VOICE_NAME]:-}" ]]; then
|
|
206
|
-
VOICE_ID="${VOICE_IDS[$VOICE_NAME]}"
|
|
207
|
-
else
|
|
208
|
-
VOICE_ID="$DEFAULT_VOICE_ID"
|
|
209
|
-
fi
|
|
124
|
+
VOICE_ID="$(elevenlabs_resolve_voice "$VOICE_NAME")" || VOICE_ID="$DEFAULT_VOICE_ID"
|
|
210
125
|
fi
|
|
211
126
|
|
|
212
127
|
# ---------------------------------------------------------------------------
|
|
@@ -246,7 +161,12 @@ trap 'rm -f "${TEMP_FILE:-}" "${BODY_FILE:-}" 2>/dev/null || true' EXIT
|
|
|
246
161
|
# ---------------------------------------------------------------------------
|
|
247
162
|
# Build the JSON request body with python so all escaping is handled correctly
|
|
248
163
|
# (text is passed via env, never interpolated into a shell-quoted payload).
|
|
249
|
-
if
|
|
164
|
+
if [[ -z "$PYTHON_BIN" ]]; then
|
|
165
|
+
echo "❌ ElevenLabs needs Python 3, but none was found." >&2
|
|
166
|
+
echo " Install Python 3, or set AGENTVIBES_PYTHON=/path/to/python." >&2
|
|
167
|
+
exit 3
|
|
168
|
+
fi
|
|
169
|
+
if ! TTS_TEXT="$TEXT" TTS_MODEL="$MODEL_ID" TTS_LANG="$LANGUAGE_CODE" "$PYTHON_BIN" -c '
|
|
250
170
|
import os, json
|
|
251
171
|
print(json.dumps({
|
|
252
172
|
"text": os.environ["TTS_TEXT"],
|
|
@@ -254,7 +174,7 @@ print(json.dumps({
|
|
|
254
174
|
"language_code": os.environ["TTS_LANG"],
|
|
255
175
|
"voice_settings": {"stability": 0.5, "similarity_boost": 0.75},
|
|
256
176
|
}))' > "$BODY_FILE" 2>/dev/null; then
|
|
257
|
-
echo "❌ Failed to build ElevenLabs request body (
|
|
177
|
+
echo "❌ Failed to build ElevenLabs request body ($PYTHON_BIN required)" >&2
|
|
258
178
|
exit 3
|
|
259
179
|
fi
|
|
260
180
|
|