agentvibes 5.7.5 → 5.7.7

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.
@@ -19,10 +19,17 @@ set -euo pipefail
19
19
 
20
20
  SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
21
21
  LOCK_FILE="/tmp/agentvibes-party-queue.lock"
22
+ DEBUG_LOG="/tmp/agentvibes-party-debug.log"
23
+
24
+ _dbg() { printf '[%s] %s\n' "$(date -Iseconds)" "$*" >> "$DEBUG_LOG" 2>/dev/null || true; }
22
25
 
23
26
  # --- Read stdin ---
24
27
  raw="$(cat)"
25
- [[ -z "$raw" ]] && exit 0
28
+ if [[ -z "$raw" ]]; then
29
+ _dbg "exit: empty stdin"
30
+ exit 0
31
+ fi
32
+ _dbg "fired (stdin ${#raw} bytes)"
26
33
 
27
34
  # --- Parse all needed fields in one python3 call (fixes M5: 3x subprocess, echo safety) ---
28
35
  # Outputs: TOOL_NAME|DISPLAY_NAME|RESPONSE_TEXT (newlines in response encoded as \n literals)
@@ -73,13 +80,26 @@ response_text="${rest#*|}"
73
80
  response_text="${response_text//\\n/ }"
74
81
 
75
82
  # --- Only handle Agent tool ---
76
- [[ "$tool_name" != "Agent" ]] && exit 0
83
+ if [[ "$tool_name" != "Agent" ]]; then
84
+ _dbg "skip: tool_name='$tool_name' (not Agent)"
85
+ exit 0
86
+ fi
77
87
 
78
88
  # --- Fingerprint: only fire for BMAD party mode agents (safe string match, no pipe) ---
79
- [[ "$raw" == *"BMAD agent in a collaborative roundtable"* ]] || exit 0
89
+ if [[ "$raw" != *"BMAD agent in a collaborative roundtable"* ]]; then
90
+ _dbg "skip: fingerprint MISS (Agent call but prompt lacks 'BMAD agent in a collaborative roundtable')"
91
+ exit 0
92
+ fi
93
+ _dbg "fingerprint HIT: display='$display_name' text_len=${#response_text}"
80
94
 
81
- [[ -z "$display_name" ]] && exit 0
82
- [[ -z "$response_text" ]] && exit 0
95
+ if [[ -z "$display_name" ]]; then
96
+ _dbg "skip: empty display_name"
97
+ exit 0
98
+ fi
99
+ if [[ -z "$response_text" ]]; then
100
+ _dbg "skip: empty response_text"
101
+ exit 0
102
+ fi
83
103
 
84
104
  # --- Resolve project root ---
85
105
  project_root="${CLAUDE_PROJECT_DIR:-}"
@@ -154,7 +174,8 @@ esac
154
174
  exec 9>"$LOCK_FILE"
155
175
  if command -v flock &>/dev/null; then
156
176
  flock -w 60 9
157
- "$bmad_speak" "$agent_id" "$response_text" || true
177
+ _dbg "invoking: $bmad_speak '$agent_id' (text_len=${#response_text})"
178
+ "$bmad_speak" "$agent_id" "$response_text" || _dbg "bmad-speak returned non-zero"
158
179
  flock -u 9
159
180
  else
160
181
  # macOS fallback: atomic mkdir polling lock
@@ -151,12 +151,23 @@ SOX_EFFECTS=""
151
151
  BG_FILE=""
152
152
  BG_VOLUME="0.10"
153
153
 
154
- EFFECTS_CFG="$PROJECT_ROOT/.claude/config/audio-effects.cfg"
154
+ # Use CLAUDE_PROJECT_DIR (injected via --project-dir by play-tts.sh) so the
155
+ # agent-name / default row is read from the user's project, not the package.
156
+ if [[ -n "${CLAUDE_PROJECT_DIR:-}" && -d "$CLAUDE_PROJECT_DIR/.claude" ]]; then
157
+ EFFECTS_CFG="$CLAUDE_PROJECT_DIR/.claude/config/audio-effects.cfg"
158
+ else
159
+ EFFECTS_CFG="$PROJECT_ROOT/.claude/config/audio-effects.cfg"
160
+ fi
155
161
  if [[ -f "$EFFECTS_CFG" ]]; then
156
- CONFIG_LINE=$(grep "^${AGENT_NAME}|" "$EFFECTS_CFG" 2>/dev/null || \
157
- grep "^default|" "$EFFECTS_CFG" 2>/dev/null || true)
162
+ # awk exact field-1 match — no regex injection risk from AGENT_NAME
163
+ CONFIG_LINE=$(awk -F'|' -v k="${AGENT_NAME}" '$1==k{print;exit}' "$EFFECTS_CFG" 2>/dev/null || true)
164
+ [[ -z "$CONFIG_LINE" ]] && \
165
+ CONFIG_LINE=$(awk -F'|' '$1=="default"{print;exit}' "$EFFECTS_CFG" 2>/dev/null || true)
158
166
  if [[ -n "$CONFIG_LINE" ]]; then
159
167
  IFS='|' read -r _ SOX_EFFECTS BG_FILE BG_VOLUME <<< "$CONFIG_LINE"
168
+ SOX_EFFECTS="${SOX_EFFECTS## }"; SOX_EFFECTS="${SOX_EFFECTS%% }"
169
+ BG_FILE="${BG_FILE## }"; BG_FILE="${BG_FILE%% }"
170
+ BG_VOLUME="${BG_VOLUME## }"; BG_VOLUME="${BG_VOLUME%% }"
160
171
  fi
161
172
  fi
162
173
 
@@ -172,14 +183,18 @@ LLM_REVERB=""
172
183
  LLM_BG_FILE=""
173
184
  LLM_BG_VOLUME=""
174
185
 
175
- # Build config search path: CLAUDE_PROJECT_DIR first (the actual user project,
176
- # even when hooks run from the package dir), then PROJECT_ROOT (may be the
177
- # same or the package dir), then global home fallback.
186
+ # Build config search path for LLM-specific row lookup.
187
+ # Priority: CLAUDE_PROJECT_DIR (real user project) global HOME fallback.
188
+ # PROJECT_ROOT is intentionally excluded when CLAUDE_PROJECT_DIR is set and
189
+ # different — prevents the AgentVibes package's own audio-effects.cfg from
190
+ # bleeding into a user project that doesn't define its own llm: row.
178
191
  _llm_cfg_paths=()
179
192
  if [[ -n "${CLAUDE_PROJECT_DIR:-}" && "$CLAUDE_PROJECT_DIR" != "$PROJECT_ROOT" ]]; then
180
193
  _llm_cfg_paths+=("$CLAUDE_PROJECT_DIR/.claude/config/audio-effects.cfg")
194
+ else
195
+ _llm_cfg_paths+=("$PROJECT_ROOT/.claude/config/audio-effects.cfg")
181
196
  fi
182
- _llm_cfg_paths+=("$PROJECT_ROOT/.claude/config/audio-effects.cfg" "$HOME/.claude/config/audio-effects.cfg")
197
+ _llm_cfg_paths+=("$HOME/.claude/config/audio-effects.cfg")
183
198
 
184
199
  _llm_key="llm:${LLM_NAME}"
185
200
  _llm_row_found=0
@@ -224,7 +239,11 @@ if [[ -n "$AGENT_PROFILE_FILE" ]] && [[ -f "$AGENT_PROFILE_FILE" ]]; then
224
239
  fi
225
240
  fi
226
241
 
227
- # Read pretext if configured
242
+ # PRETEXT is NOT extracted from the llm row here.
243
+ # play-tts.sh already prepends the llm row's pretext to TEXT before calling this script.
244
+ # Extracting it again and sending it as a separate JSON field would cause the receiver
245
+ # to prepend it a second time — the user hears the intro text twice.
246
+ # PRETEXT here is only for the (rare) pretext.txt override file, not the llm row.
228
247
  PRETEXT=""
229
248
  PRETEXT_FILE="$PROJECT_ROOT/.agentvibes/config/pretext.txt"
230
249
  if [[ -f "$PRETEXT_FILE" ]]; then
@@ -279,9 +298,9 @@ build_json_payload() {
279
298
  --arg llm "$LLM_NAME" \
280
299
  '{text: $text, voice: $voice, effects: $effects, music: $music, volume: $volume, project: $project, pretext: $pretext, speed: $speed, provider: $provider, llm: $llm}'
281
300
  else
282
- # Manual JSON — escape double quotes and backslashes in text
301
+ # Manual JSON — escape backslashes, quotes, control chars
283
302
  local escaped_text
284
- escaped_text=$(printf '%s' "$TEXT" | sed 's/\\/\\\\/g; s/"/\\"/g; s/\t/\\t/g')
303
+ escaped_text=$(printf '%s' "$TEXT" | sed 's/\\/\\\\/g; s/"/\\"/g; s/\t/\\t/g' | tr '\n' ' ' | sed 's/\r//g')
285
304
  local escaped_pretext
286
305
  escaped_pretext=$(printf '%s' "$PRETEXT" | sed 's/\\/\\\\/g; s/"/\\"/g')
287
306
  printf '{"text":"%s","voice":"%s","effects":"%s","music":"%s","volume":"%s","project":"%s","pretext":"%s","speed":"%s","provider":"%s","llm":"%s"}' \
@@ -303,6 +322,13 @@ fi
303
322
  # Send to receiver via SSH (fire and forget — backgrounded)
304
323
  # ---------------------------------------------------------------------------
305
324
 
325
+ # In test mode, dump the decoded payload to stdout so tests can inspect it
326
+ # without needing a real SSH connection or a mock binary.
327
+ if [[ "${AGENTVIBES_TEST_MODE:-false}" == "true" ]]; then
328
+ echo "$JSON_PAYLOAD"
329
+ exit 0
330
+ fi
331
+
306
332
  echo "Sending to $SSH_HOST..." >&2
307
333
 
308
334
  # Build SSH args — use explicit key/port from config if available, else rely on ~/.ssh/config
@@ -310,15 +336,18 @@ SSH_ARGS=()
310
336
  [[ -n "$SSH_KEY" && -f "$SSH_KEY" ]] && SSH_ARGS+=(-i "$SSH_KEY")
311
337
  [[ -n "$SSH_PORT" ]] && SSH_ARGS+=(-p "$SSH_PORT")
312
338
 
313
- # ForceCommand receiver: SSH_ORIGINAL_COMMAND passes the payload directly
314
- ssh "${SSH_ARGS[@]}" "$SSH_HOST" "$ENCODED_PAYLOAD" &
315
- SSH_PID=$!
316
-
317
- # Log SSH failures asynchronously so the hook doesn't block
318
- ( wait "$SSH_PID"; _exit=$?; if [[ $_exit -ne 0 ]]; then
339
+ # ForceCommand receiver: SSH_ORIGINAL_COMMAND passes the payload directly.
340
+ # Run ssh inside the backgrounded subshell so its exit code is reachable via $?
341
+ # (a `wait` from outside the spawning shell would error: "pid X is not a child").
342
+ (
343
+ ssh "${SSH_ARGS[@]}" "$SSH_HOST" "$ENCODED_PAYLOAD"
344
+ _exit=$?
345
+ if [[ $_exit -ne 0 ]]; then
319
346
  echo "$(date -Iseconds) [ERROR] SSH to $SSH_HOST failed (exit $_exit)" \
320
347
  >> "$HOME/.agentvibes/ssh-remote.log" 2>/dev/null || true
321
- fi ) &
348
+ fi
349
+ ) &
350
+ SSH_PID=$!
322
351
 
323
352
  echo "Sent to $SSH_HOST (PID: $SSH_PID)" >&2
324
353
  exit 0
package/README.md CHANGED
@@ -11,7 +11,7 @@
11
11
  [![Publish](https://github.com/paulpreibisch/AgentVibes/actions/workflows/publish.yml/badge.svg)](https://github.com/paulpreibisch/AgentVibes/actions/workflows/publish.yml)
12
12
  [![License](https://img.shields.io/badge/License-Apache_2.0-blue.svg)](https://opensource.org/licenses/Apache-2.0)
13
13
 
14
- **Author**: Paul Preibisch ([@997Fire](https://x.com/997Fire)) | **Version**: v5.7.5
14
+ **Author**: Paul Preibisch ([@997Fire](https://x.com/997Fire)) | **Version**: v5.7.7
15
15
 
16
16
  ---
17
17
 
@@ -40,19 +40,29 @@ Whether you're coding in Claude Code, chatting in Claude Desktop, using Warp Ter
40
40
 
41
41
  ---
42
42
 
43
- ## 🌟 NEW IN v5.7.5TUI Button Contrast + BMAD Routing Fixes
43
+ ## 🌟 NEW IN v5.7.7Party Mode Voice Restore + Polish
44
44
 
45
- **TUI buttons:** All focused/selected buttons now show white text on dark green grey text on light-blue is gone across all terminals and all tabs.
45
+ **Party mode agents now speak again:** BMAD `/party-mode` now reliably invokes the correct AgentVibes skill, and each agent's response is spoken aloud in their unique voice with per-agent music, pretext, and reverb — loaded automatically from `~/.agentvibes/bmad-voice-map.json`.
46
46
 
47
- **BMAD tab:** The voice preview indicator now appears correctly in the voice list, with a 2-second minimum display timer for SSH-remote fire-and-forget mode.
47
+ **New bundled track:** 🌌 CelestialVelvet added to the built-in music catalog.
48
48
 
49
- **Installer pretext:** Non-interactive installs now derive the pretext from the project folder name (e.g., `"MyProject here"`) instead of always defaulting to `"Claude Code here"`.
49
+ **TUI contrast fix:** Selected rows in Voices and Agents tabs no longer render unreadable gray text.
50
50
 
51
- **BMAD music routing:** Per-agent background music and reverb overrides now correctly reach the SSH receiver.
51
+ **SSH remote:** Fixed "wait: pid is not a child of this shell" error in `play-tts-ssh-remote.sh`.
52
52
 
53
- **TERM fix:** The TUI no longer throws a `plab_norm` error when `TERM` is a `screen-*` or `tmux-*` variant.
53
+ ## v5.7.6 SSH Remote Payload Integrity + Receiver Rewrite
54
54
 
55
- ## v5.7.0BMAD v6.6 Support + Windows Auto-Restart Watcher
55
+ **SSH remote music/voice fix:** The correct project music track and voice now reach the remote receiver previously the global config was used instead of the active project's settings.
56
+
57
+ **Bash receiver rewrite:** The Linux/Termux `agentvibes-receiver.sh` has been fully rewritten to decode the current base64 JSON payload format. The old positional-arg format from pre-v5.5 is gone.
58
+
59
+ **No more double intro:** The personality pretext (e.g., "Bcs latin dance here") is no longer spoken twice over SSH remote. `play-tts.sh` prepends it to the text; the receiver no longer gets a separate pretext field to prepend again.
60
+
61
+ **SSH host visible in TUI:** The Settings and Voices tabs now display your configured SSH remote host alias.
62
+
63
+ **Security fixes** and 24 new BATS tests covering the full sender → receiver round-trip.
64
+
65
+ ## v5.7.5 — TUI Button Contrast + BMAD Routing Fixes
56
66
 
57
67
  **BMAD v6.6.0:** AgentVibes now detects the new `.claude/skills/*/agents/` agent structure, correctly handles globally-installed BMAD at `~/_bmad`, and gracefully skips v6.6+ plain-Markdown agents during TTS injection instead of erroring. The BMAD tab now shows detection correctly for global installs.
58
68
 
@@ -353,7 +363,7 @@ All 50+ Piper voices AgentVibes provides are sourced from Hugging Face's open-so
353
363
 
354
364
  ## 📰 Latest Release
355
365
 
356
- **[v3.6.0 - "Voice Explorer" Release](https://github.com/paulpreibisch/AgentVibes/releases/tag/v3.6.0)** 🎉
366
+ **[v3.6.0 - "Voice Explorer" Release](https://github.com/paulpreibisch/AgentVibes/releases/tag/v5.7.5)** 🎉
357
367
 
358
368
  ### 🎤 Voices Tab — Browse & Sample 914 Voices
359
369
 
package/RELEASE_NOTES.md CHANGED
@@ -1,5 +1,66 @@
1
1
  # AgentVibes Release Notes
2
2
 
3
+ ## 🎭 v5.7.7 — Party Mode Voice Restore + Polish
4
+
5
+ **Released:** 2026-05-17
6
+
7
+ ### 🐛 BMAD Party Mode Agents Silent (No Per-Agent TTS)
8
+
9
+ Party mode agents were displaying responses in text but not speaking with their unique voices. Two root causes:
10
+
11
+ **Skill disambiguation:** `/party-mode` was matching the upstream BMAD `_bmad/core/workflows/party-mode` command (which tries to load a path that doesn't exist in this project) instead of the AgentVibes skill. A project-local `/party-mode` command override now routes to the correct skill.
12
+
13
+ **Mandatory TTS step:** The orchestrator's `bmad-speak.js` call step was underspecified, so it was sometimes skipped. Step 4 in the BMAD party mode skill is now clearly marked MANDATORY, with explicit documentation of what `bmad-speak.js` applies per agent: voice, pretext, reverb, personality, and background music — all loaded automatically from `~/.agentvibes/bmad-voice-map.json`.
14
+
15
+ ### 🔍 Party Mode Debug Logging
16
+
17
+ `bmad-party-speak.sh` (PostToolUse hook) now writes structured diagnostic entries to `/tmp/agentvibes-party-debug.log` — `fired`, `fingerprint HIT/MISS`, `invoking`, and errors — so voice issues are diagnosable without guessing.
18
+
19
+ ### 🎵 New Bundled Track: CelestialVelvet
20
+
21
+ A new ambient music track **CelestialVelvet** (🌌) has been added to the built-in catalog. Available immediately in the TUI music picker and BMAD voice map — no download required.
22
+
23
+ ### 🐛 TUI: Gray Text on Selected Rows Fixed
24
+
25
+ White text now renders correctly on selected rows in the Voices and Agents tabs. Previously `bright-black` foreground combined with green background produced unreadable gray text in many terminals.
26
+
27
+ ### 🐛 SSH Remote: "wait: pid is not a child of this shell" Error
28
+
29
+ `play-tts-ssh-remote.sh` would emit `wait: pid X is not a child of this shell` on certain shells. Fixed by spawning `ssh` directly inside the background subshell so `$?` captures the exit code without a cross-shell `wait` call.
30
+
31
+ ---
32
+
33
+ ## 🔧 v5.7.6 — SSH Remote Payload Integrity + Receiver Rewrite
34
+
35
+ **Released:** 2026-05-16
36
+
37
+ ### 🐛 SSH Remote Playing Wrong Music and Voice
38
+
39
+ When using the SSH remote TTS feature, the wrong project's music track and voice were being applied. Root cause: `CLAUDE_PROJECT_DIR` was not forwarded to the sender, causing it to fall back to the global config instead of the active project's `audio-effects.cfg`.
40
+
41
+ ### 🐛 Bash Receiver Incompatible with JSON Payload Format
42
+
43
+ The Linux/Termux bash receiver (`agentvibes-receiver.sh`) was using a positional-argument format from pre-v5.5 and could not decode the current base64 JSON payload at all. The receiver has been fully rewritten to match the PowerShell receiver's logic: decodes base64, parses JSON, applies voice/music/effects/volume, and validates all fields.
44
+
45
+ ### 🐛 Personality Intro Heard Twice on Remote
46
+
47
+ The personality pretext (e.g., "Bcs latin dance here") was being spoken twice when using SSH remote TTS. Root cause: `play-tts.sh` already prepends the pretext to the speech text before calling the sender; the sender was also packing it into the JSON `pretext` field, causing the receiver to prepend it again. The `pretext` JSON field is now intentionally left empty — the personality is delivered via the `text` field only.
48
+
49
+ ### 🆕 SSH Host Alias Visible in Settings Tab
50
+
51
+ The configured SSH remote host alias is now displayed in the Settings and Voices tabs so users can confirm which remote machine TTS is targeting without opening config files.
52
+
53
+ ### 🔒 Security Fixes
54
+
55
+ Input validation improvements in the SSH remote sender and receiver.
56
+
57
+ ### 🧪 24 New BATS Tests
58
+
59
+ - 15 SSH remote payload tests: verify voice, music track, volume, reverb/effects, pretext handling, LLM identifier, project config precedence, and JSON validity
60
+ - 9 end-to-end round-trip tests: sender builds payload → receiver decodes and applies all fields simultaneously, catching regressions in either end
61
+
62
+ ---
63
+
3
64
  ## 🖥️ v5.7.5 — TUI Button Contrast + BMAD Routing Fixes
4
65
 
5
66
  **Released:** 2026-05-13
@@ -121,7 +121,7 @@ Now we'll tell Claude Desktop to use AgentVibes.
121
121
  "mcpServers": {
122
122
  "agentvibes": {
123
123
  "command": "npx",
124
- "args": ["-y", "agentvibes@beta", "agentvibes-mcp-server"]
124
+ "args": ["-y", "-p", "agentvibes@beta", "agentvibes-mcp-server"]
125
125
  }
126
126
  }
127
127
  }
@@ -270,7 +270,7 @@ Your config file has syntax errors:
270
270
  "mcpServers": {
271
271
  "agentvibes": {
272
272
  "command": "npx",
273
- "args": ["-y", "agentvibes@beta", "agentvibes-mcp-server"]
273
+ "args": ["-y", "-p", "agentvibes@beta", "agentvibes-mcp-server"]
274
274
  }
275
275
  }
276
276
  }
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.7.5",
4
+ "version": "5.7.7",
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": [
@@ -163,3 +163,22 @@ export function detectWavPlayer(env) {
163
163
  }
164
164
  return _detect(WAV_PLAYERS, env);
165
165
  }
166
+
167
+ /**
168
+ * Returns all installed WAV players in preference order.
169
+ * Used for fallback playback — try each until one succeeds.
170
+ * On Windows, always appends the PowerShell fallback.
171
+ *
172
+ * @param {Object} [env] - Environment (defaults to buildAudioEnv())
173
+ * @returns {Player[]}
174
+ */
175
+ export function getAllWavPlayers(env) {
176
+ env = env || buildAudioEnv();
177
+ const whichCmd = process.platform === 'win32' ? 'where' : 'which';
178
+ const installed = WAV_PLAYERS.filter(p => {
179
+ const r = spawnSync(whichCmd, [p.bin], { stdio: 'pipe', env });
180
+ return r.status === 0;
181
+ });
182
+ if (process.platform === 'win32') installed.push(WIN_WAV_PLAYER);
183
+ return installed;
184
+ }
@@ -153,7 +153,7 @@ const COL_MUSIC = 11;
153
153
  const COL_VOL = 5; // e.g. "70%" or "100%"
154
154
 
155
155
  // Inline hint appended to the selected row when list is focused
156
- const _ROW_HINT_BMAD = ` {bright-black-fg}[Space] Preview [Enter] Configure{/bright-black-fg}`;
156
+ const _ROW_HINT_BMAD = ` {white-fg}[Space] Preview [Enter] Configure{/white-fg}`;
157
157
 
158
158
  // ---------------------------------------------------------------------------
159
159
 
@@ -64,6 +64,7 @@ const TRACK_DISPLAY = Object.freeze({
64
64
  'agent_vibes_arabic_v2_loop.mp3': '🎵 Arabic Oud',
65
65
  'agent_vibes_bachata_v1_loop.mp3': '🎺 Bachata',
66
66
  'agent_vibes_bossa_nova_v2_loop.mp3': '🌸 Bossa Nova',
67
+ 'CelestialVelvet.mp3': '🌌 Celestial Velvet',
67
68
  'agent_vibes_celtic_harp_v1_loop.mp3': '🎶 Celtic Harp',
68
69
  'agent_vibes_chillwave_v2_loop.mp3': '🌊 Chillwave',
69
70
  'agent_vibes_cumbia_v1_loop.mp3': '🎸 Cumbia',
@@ -85,6 +86,7 @@ const BUILT_IN_TRACK_CATALOG = Object.freeze([
85
86
  { id: 'agent_vibes_arabic_v2_loop.mp3', label: '🎵 Arabic Oud' },
86
87
  { id: 'agent_vibes_bachata_v1_loop.mp3', label: '🎺 Bachata' },
87
88
  { id: 'agent_vibes_bossa_nova_v2_loop.mp3', label: '🌸 Bossa Nova' },
89
+ { id: 'CelestialVelvet.mp3', label: '🌌 Celestial Velvet' },
88
90
  { id: 'agent_vibes_celtic_harp_v1_loop.mp3', label: '🎶 Celtic Harp' },
89
91
  { id: 'agent_vibes_chillwave_v2_loop.mp3', label: '🌊 Chillwave' },
90
92
  { id: 'agent_vibes_cumbia_v1_loop.mp3', label: '🎸 Cumbia' },
@@ -201,6 +201,15 @@ export function createSettingsTab(screen, services) {
201
201
  },
202
202
  desc: 'Play audio locally or stream to a remote host via SSH',
203
203
  },
204
+ {
205
+ key: 'sshAlias',
206
+ label: 'SSH Host Alias',
207
+ getValue: () => {
208
+ const alias = configService?.getConfig?.()?.audio_ssh_alias ?? '';
209
+ return alias || '(not set)';
210
+ },
211
+ desc: 'SSH host alias from ~/.ssh/config used when Audio Destination is Remote',
212
+ },
204
213
  {
205
214
  key: 'configStorage',
206
215
  label: 'Config Storage',
@@ -395,6 +404,9 @@ export function createSettingsTab(screen, services) {
395
404
  case 'audioDst':
396
405
  _editAudioDst();
397
406
  break;
407
+ case 'sshAlias':
408
+ _editSshAlias();
409
+ break;
398
410
  case 'rerunWizard':
399
411
  _rerunWizard();
400
412
  break;
@@ -864,6 +876,68 @@ export function createSettingsTab(screen, services) {
864
876
  screen.render();
865
877
  }
866
878
 
879
+ // ── SSH alias editor ─────────────────────────────────────────────────────
880
+
881
+ function _editSshAlias() {
882
+ navigationService?.openModal();
883
+
884
+ const aliases = _detectSshAliases().filter(a => !a.includes('github.com'));
885
+ const MANUAL_OPTION = ' Type manually...';
886
+ const items = [...aliases.map(a => ` ${a}`), MANUAL_OPTION];
887
+ const currentAlias = configService?.getConfig?.()?.audio_ssh_alias ?? '';
888
+
889
+ const listHeight = Math.min(items.length + 4, 18);
890
+ const modal = blessed.list({
891
+ parent: screen,
892
+ top: 'center',
893
+ left: 'center',
894
+ width: 44,
895
+ height: listHeight,
896
+ border: { type: 'line' },
897
+ tags: true,
898
+ label: ' {bold}{cyan-fg} Select SSH Host Alias {/cyan-fg}{/bold} ',
899
+ keys: true,
900
+ vi: true,
901
+ mouse: true,
902
+ style: {
903
+ fg: COLORS.labelFg,
904
+ bg: COLORS.contentBg,
905
+ border: { fg: 'cyan' },
906
+ selected: { bg: 'blue', fg: 'yellow' },
907
+ item: { fg: COLORS.labelFg },
908
+ },
909
+ });
910
+ modal.setFront();
911
+ modal.setItems(items);
912
+
913
+ const currentIdx = aliases.indexOf(currentAlias);
914
+ if (currentIdx >= 0) modal.select(currentIdx);
915
+
916
+ modal.key(['enter'], () => {
917
+ const selItem = items[modal.selected];
918
+ if (selItem === MANUAL_OPTION) {
919
+ _closeModal();
920
+ _promptSshAlias();
921
+ return;
922
+ }
923
+ const alias = selItem?.trim();
924
+ if (alias) configService.set('audio_ssh_alias', alias);
925
+ _closeModal();
926
+ });
927
+ modal.key(['escape', 'q'], _closeModal);
928
+
929
+ function _closeModal() {
930
+ navigationService?.closeModal();
931
+ destroyList(modal, screen);
932
+ _refreshValues();
933
+ box.focus();
934
+ screen.render();
935
+ }
936
+
937
+ modal.focus();
938
+ screen.render();
939
+ }
940
+
867
941
  // ── Re-run wizard ────────────────────────────────────────────────────────
868
942
 
869
943
  function _rerunWizard() {
@@ -13,7 +13,7 @@ import path from 'node:path';
13
13
  import os from 'node:os';
14
14
  import { spawn } from 'node:child_process';
15
15
  import { fileURLToPath } from 'node:url';
16
- import { buildAudioEnv, detectWavPlayer } from '../audio-env.js';
16
+ import { buildAudioEnv, detectWavPlayer, getAllWavPlayers } from '../audio-env.js';
17
17
  import { SURNAME_POOL, uniquifyVoiceName } from '../../utils/voice-names.js';
18
18
 
19
19
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
@@ -770,7 +770,7 @@ export function createVoicesTab(screen, services) {
770
770
  // Inline selection hint appended to the currently highlighted voice row.
771
771
  // _hintBase stores the item's clean content (no hint, no █) — no sentinel needed.
772
772
  // Use getter functions so hints re-translate when language changes.
773
- const _rowHintInstalled = () => ` {bright-black-fg}${_tl('voicesRowHintInstalled')}{/bright-black-fg}`;
773
+ const _rowHintInstalled = () => ` {white-fg}${_tl('voicesRowHintInstalled')}{/white-fg}`;
774
774
  const _rowHintUninstalled = () => ` {bright-yellow-fg}${_tl('voicesRowHintUninstalled')}{/bright-yellow-fg}`;
775
775
  let _hintIdx = -1;
776
776
  let _hintBase = ''; // content of items[_hintIdx] before hint was appended
@@ -1022,9 +1022,9 @@ export function createVoicesTab(screen, services) {
1022
1022
  return;
1023
1023
  }
1024
1024
 
1025
- // Play the synthesized wav in its own process group so we can kill it
1026
- const _wavP = detectWavPlayer(_spawnEnv);
1027
- if (!_wavP) {
1025
+ // Play the synthesized wav try each installed player until one succeeds
1026
+ const _wavPlayers = getAllWavPlayers(_spawnEnv);
1027
+ if (_wavPlayers.length === 0) {
1028
1028
  _playingVoiceId = null;
1029
1029
  _playingProcess = null;
1030
1030
  previewLine.setContent(`{red-fg}No audio player found. Install ffmpeg.{/red-fg}`);
@@ -1033,44 +1033,62 @@ export function createVoicesTab(screen, services) {
1033
1033
  try { fs.unlinkSync(tempWav); } catch {}
1034
1034
  return;
1035
1035
  }
1036
- const playProc = spawn(_wavP.bin, _wavP.args(tempWav), {
1037
- stdio: 'ignore',
1038
- detached: !isWindows,
1039
- windowsHide: true,
1040
- env: _spawnEnv,
1041
- });
1036
+
1042
1037
  // Race note: _playingVoiceId could change between piper exit and here
1043
1038
  // if the user stops playback. Re-check before assigning to avoid orphan.
1044
1039
  if (_playingVoiceId !== voiceId) { try { fs.unlinkSync(tempWav); } catch {} return; }
1045
- _playingProcess = playProc;
1046
1040
 
1047
1041
  previewLine.setContent(`{${COLORS.activeFg}-fg}♪ Playing: ${voiceId} (Enter/Space to stop){/${COLORS.activeFg}-fg}`);
1048
1042
  screen.render();
1049
1043
 
1050
- playProc.on('exit', (code) => {
1051
- if (_playingVoiceId === voiceId) {
1044
+ function _tryNextPlayer(remainingPlayers) {
1045
+ if (!remainingPlayers.length) {
1052
1046
  _playingVoiceId = null;
1053
1047
  _playingProcess = null;
1048
+ previewLine.setContent(`{red-fg}♪ Audio playback failed (no audio device?) — check your provider in Setup{/red-fg}`);
1049
+ screen.render();
1050
+ setTimeout(() => { if (!_closed) { previewLine.setContent(_listFocused ? HINT_TEXT : ''); screen.render(); } }, 5000);
1051
+ try { fs.unlinkSync(tempWav); } catch {}
1052
+ return;
1053
+ }
1054
+ const [wavP, ...rest] = remainingPlayers;
1055
+ const playProc = spawn(wavP.bin, wavP.args(tempWav), {
1056
+ stdio: 'ignore',
1057
+ detached: !isWindows,
1058
+ windowsHide: true,
1059
+ env: _spawnEnv,
1060
+ });
1061
+ if (_playingVoiceId !== voiceId) {
1062
+ try { playProc.kill(); } catch {}
1063
+ try { fs.unlinkSync(tempWav); } catch {}
1064
+ return;
1065
+ }
1066
+ _playingProcess = playProc;
1067
+
1068
+ playProc.on('exit', (code) => {
1069
+ if (_playingVoiceId !== voiceId) {
1070
+ try { fs.unlinkSync(tempWav); } catch {}
1071
+ return;
1072
+ }
1054
1073
  if (code !== 0) {
1055
- previewLine.setContent(`{red-fg}♪ Audio playback failed (no audio device?) check your provider in Setup{/red-fg}`);
1056
- screen.render();
1057
- setTimeout(() => { if (!_closed) { previewLine.setContent(_listFocused ? HINT_TEXT : ''); screen.render(); } }, 5000);
1074
+ // This player failed — try the next one
1075
+ _tryNextPlayer(rest);
1058
1076
  } else {
1077
+ _playingVoiceId = null;
1078
+ _playingProcess = null;
1059
1079
  previewLine.setContent(_listFocused ? HINT_TEXT : '');
1060
- refreshDisplay(); // clears (playing) label
1080
+ refreshDisplay();
1081
+ try { fs.unlinkSync(tempWav); } catch {}
1061
1082
  }
1062
- }
1063
- try { fs.unlinkSync(tempWav); } catch {}
1064
- });
1083
+ });
1065
1084
 
1066
- playProc.on('error', () => {
1067
- _playingVoiceId = null;
1068
- _playingProcess = null;
1069
- previewLine.setContent(`{red-fg}♪ Audio player not found — install ffmpeg or check your provider in Setup{/red-fg}`);
1070
- screen.render();
1071
- setTimeout(() => { if (!_closed) { previewLine.setContent(_listFocused ? HINT_TEXT : ''); screen.render(); } }, 5000);
1072
- try { fs.unlinkSync(tempWav); } catch {}
1073
- });
1085
+ playProc.on('error', () => {
1086
+ if (_playingVoiceId !== voiceId) { try { fs.unlinkSync(tempWav); } catch {} return; }
1087
+ _tryNextPlayer(rest);
1088
+ });
1089
+ }
1090
+
1091
+ _tryNextPlayer(_wavPlayers);
1074
1092
  });
1075
1093
 
1076
1094
  piper.on('error', () => {