agentvibes 4.0.0 → 4.2.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.
Files changed (42) hide show
  1. package/.claude/config/audio-effects.cfg +3 -2
  2. package/.claude/config/background-music-position.txt +1 -1
  3. package/.claude/hooks/audio-processor.sh +87 -43
  4. package/.claude/hooks/bmad-speak.sh +184 -27
  5. package/.claude/hooks/play-tts-enhanced.sh +40 -5
  6. package/.claude/hooks/play-tts-macos.sh +29 -6
  7. package/.claude/hooks/play-tts-piper.sh +174 -67
  8. package/.claude/hooks/play-tts-soprano.sh +42 -6
  9. package/.claude/hooks/play-tts-ssh-remote.sh +117 -38
  10. package/.claude/hooks/play-tts.sh +12 -9
  11. package/.claude/hooks/session-start-tts.sh +10 -0
  12. package/.claude/hooks/stop-tts.sh +84 -0
  13. package/.claude/hooks/tts-queue-worker.sh +51 -20
  14. package/.claude/hooks/tts-queue.sh +37 -8
  15. package/.claude/hooks/voice-manager.sh +5 -1
  16. package/CLAUDE.md +0 -11
  17. package/README.md +176 -78
  18. package/RELEASE_NOTES.md +1197 -60
  19. package/bin/agentvibes-voice-browser.js +35 -21
  20. package/mcp-server/server.py +36 -0
  21. package/package.json +1 -3
  22. package/src/console/app.js +23 -5
  23. package/src/console/constants/personalities.js +44 -0
  24. package/src/console/footer-config.js +8 -0
  25. package/src/console/navigation.js +3 -1
  26. package/src/console/tabs/agents-tab.js +1219 -72
  27. package/src/console/tabs/install-tab.js +2 -1
  28. package/src/console/tabs/placeholder-tab.js +9 -1
  29. package/src/console/tabs/receiver-tab.js +1212 -0
  30. package/src/console/tabs/settings-tab.js +33 -323
  31. package/src/console/widgets/destroy-list.js +25 -0
  32. package/src/console/widgets/format-utils.js +89 -0
  33. package/src/console/widgets/notice.js +55 -0
  34. package/src/console/widgets/personality-picker.js +185 -0
  35. package/src/console/widgets/reverb-picker.js +94 -0
  36. package/src/console/widgets/track-picker.js +285 -0
  37. package/src/installer.js +54 -2
  38. package/src/services/agent-voice-store.js +282 -22
  39. package/src/services/config-service.js +24 -0
  40. package/src/services/navigation-service.js +1 -1
  41. package/src/utils/music-file-validator.js +41 -31
  42. package/templates/agentvibes-receiver.sh +431 -111
@@ -25,7 +25,7 @@ Winston|reverb 40 50 90 gain -2|agentvibes_soft_flamenco_loop.mp3|0.25
25
25
  Amelia|compand 0.3,1 6:-70,-60,-20|agentvibes_soft_flamenco_loop.mp3|0.25
26
26
  |||
27
27
  # Business Analyst Mary - warm, analytical|||
28
- Mary|equalizer 200 1q +3 equalizer 2500 1q -2|agentvibes_soft_flamenco_loop.mp3|0.25
28
+ Mary|reverb 70 50 100 equalizer 200 1q +3 equalizer 2500 1q -2|agentvibes_soft_flamenco_loop.mp3|0.25
29
29
  |||
30
30
  # Scrum Master Bob - refined, organized|||
31
31
  Bob|reverb 30 40 70 compand 0.2,0.5 6:-70,-60,-20|agentvibes_soft_flamenco_loop.mp3|0.25
@@ -49,4 +49,5 @@ BMad Master|reverb 50 60 100 pitch -100|agentvibes_soft_flamenco_loop.mp3|0.30
49
49
  _party_mode|compand 0.3,1 6:-70,-60,-20|agent_vibes_dark_chill_step_loop.mp3|0.40
50
50
  |||
51
51
  # Default (no agent specified) - clean with Bachata background|||
52
- default|reverb 40 50 70|agent_vibes_celtic_harp_v1_loop.mp3|0.30
52
+ default|reverb 40 50 70|agentvibes_soft_flamenco_loop.mp3|0.30
53
+ analyst|reverb 70 50 100|agentvibes_soft_flamenco_loop.mp3|0.30
@@ -1 +1 @@
1
- agent_vibes_celtic_harp_v1_loop.mp3:12.341678
1
+ agent_vibes_celtic_harp_v1_loop.mp3:.00000000000000000012190647
@@ -34,6 +34,7 @@ PROJECT_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"
34
34
  INPUT_FILE="${1:-}"
35
35
  AGENT_NAME="${2:-default}"
36
36
  OUTPUT_FILE="${3:-}"
37
+ AGENT_PROFILE_FILE="${4:-}" # Optional: path to per-agent profile JSON (from bmad-speak.sh)
37
38
 
38
39
  # Config and directories (resolve to absolute paths)
39
40
  CONFIG_FILE="$(cd "$SCRIPT_DIR/.." && pwd)/config/audio-effects.cfg"
@@ -140,9 +141,11 @@ apply_sox_effects() {
140
141
 
141
142
  # Position tracking file for continuous playback
142
143
  POSITION_FILE="$SCRIPT_DIR/../config/background-music-position.txt"
144
+ # Lock file for position file — prevents race conditions in party mode with concurrent agents
145
+ POSITION_LOCK="/tmp/agentvibes-bgpos-$(id -u).lock"
143
146
 
144
147
  # @function get_background_position
145
- # @intent Get saved position for a background track
148
+ # @intent Get saved position for a background track (caller must hold POSITION_LOCK)
146
149
  # @param $1 Background file path
147
150
  # @returns Position in seconds (or 0 if not found)
148
151
  get_background_position() {
@@ -158,7 +161,7 @@ get_background_position() {
158
161
  }
159
162
 
160
163
  # @function save_background_position
161
- # @intent Save position for a background track
164
+ # @intent Save position for a background track (caller must hold POSITION_LOCK)
162
165
  # @param $1 Background file path
163
166
  # @param $2 New position in seconds
164
167
  save_background_position() {
@@ -173,7 +176,8 @@ save_background_position() {
173
176
  local tmp_pos
174
177
  tmp_pos=$(mktemp "${POSITION_FILE}.XXXXXX")
175
178
  if [[ -f "$POSITION_FILE" ]]; then
176
- grep -v "^${bg_name}:" "$POSITION_FILE" > "$tmp_pos" 2>/dev/null || true
179
+ # SECURITY: Use grep -F for fixed string matching (#134)
180
+ grep -vF "${bg_name}:" "$POSITION_FILE" > "$tmp_pos" 2>/dev/null || true
177
181
  fi
178
182
  echo "${bg_name}:${position}" >> "$tmp_pos"
179
183
  mv "$tmp_pos" "$POSITION_FILE"
@@ -216,48 +220,58 @@ mix_background() {
216
220
  bg_duration=$(ffprobe -v error -show_entries format=duration -of default=noprint_wrappers=1:nokey=1 "$background" 2>/dev/null)
217
221
  bg_duration=${bg_duration:-0}
218
222
 
219
- # Get saved position for this track (continuous playback)
223
+ # Read the start position and pre-compute the new position atomically under flock.
224
+ # This prevents party-mode race conditions where concurrent agents both read the
225
+ # same position, compute independently, and overwrite each other's updates.
220
226
  local start_pos
221
- start_pos=$(get_background_position "$background")
222
-
223
- # Validate start_pos: if too small (floating point error) or invalid, reset to 0
224
- if command -v bc &> /dev/null; then
225
- if ! [[ "$start_pos" =~ ^[0-9]+\.?[0-9]*$ ]] || (( $(echo "$start_pos < 0.001" | bc -l) )); then
226
- start_pos="0"
227
- fi
228
- else
229
- # Without bc, just check if it's a valid number
230
- if ! [[ "$start_pos" =~ ^[0-9]+\.?[0-9]*$ ]]; then
231
- start_pos="0"
227
+ local new_pos
228
+ local total_duration
229
+ {
230
+ flock -x 200
231
+
232
+ # Get saved position for this track (continuous playback)
233
+ start_pos=$(get_background_position "$background")
234
+
235
+ # Validate start_pos: if too small (floating point error) or invalid, reset to 0
236
+ if command -v bc &> /dev/null; then
237
+ if ! [[ "$start_pos" =~ ^[0-9]+\.?[0-9]*$ ]] || (( $(echo "$start_pos < 0.001" | bc -l) )); then
238
+ start_pos="0"
239
+ fi
240
+ else
241
+ # Without bc, just check if it's a valid number
242
+ if ! [[ "$start_pos" =~ ^[0-9]+\.?[0-9]*$ ]]; then
243
+ start_pos="0"
244
+ fi
232
245
  fi
233
- fi
234
246
 
235
- # If position exceeds track length, wrap around
236
- if command -v bc &> /dev/null && [[ -n "$bg_duration" ]]; then
237
- if (( $(echo "$start_pos >= $bg_duration" | bc -l) )); then
238
- start_pos=$(echo "$start_pos % $bg_duration" | bc -l)
247
+ # If position exceeds track length, wrap around
248
+ if command -v bc &> /dev/null && [[ -n "$bg_duration" ]]; then
249
+ if (( $(echo "$start_pos >= $bg_duration" | bc -l) )); then
250
+ start_pos=$(echo "$start_pos % $bg_duration" | bc -l)
251
+ fi
239
252
  fi
240
- fi
241
253
 
242
- # Extend total duration by 2 seconds for background music fade out
243
- local total_duration
244
- if command -v bc &> /dev/null; then
245
- total_duration=$(echo "$duration + 2" | bc -l)
246
- else
247
- total_duration=$(awk "BEGIN {print $duration + 2}")
248
- fi
254
+ # Extend total duration by 2 seconds for background music fade out
255
+ if command -v bc &> /dev/null; then
256
+ total_duration=$(echo "$duration + 2" | bc -l)
257
+ else
258
+ total_duration=$(awk "BEGIN {print $duration + 2}")
259
+ fi
249
260
 
250
- # Calculate new position after this clip (including fade out time)
251
- local new_pos
252
- if command -v bc &> /dev/null; then
253
- new_pos=$(echo "$start_pos + $total_duration" | bc -l)
254
- # Wrap around if needed
255
- if [[ -n "$bg_duration" ]] && (( $(echo "$new_pos >= $bg_duration" | bc -l) )); then
256
- new_pos=$(echo "$new_pos % $bg_duration" | bc -l)
261
+ # Calculate new position after this clip (including fade out time)
262
+ if command -v bc &> /dev/null; then
263
+ new_pos=$(echo "$start_pos + $total_duration" | bc -l)
264
+ # Wrap around if needed
265
+ if [[ -n "$bg_duration" ]] && (( $(echo "$new_pos >= $bg_duration" | bc -l) )); then
266
+ new_pos=$(echo "$new_pos % $bg_duration" | bc -l)
267
+ fi
268
+ else
269
+ new_pos="0"
257
270
  fi
258
- else
259
- new_pos="0"
260
- fi
271
+
272
+ # Claim the new position immediately so concurrent agents advance past it
273
+ save_background_position "$background" "$new_pos"
274
+ } 200>"$POSITION_LOCK"
261
275
 
262
276
  # Mix: Seek to position in background, apply volume and fades
263
277
  # Background fades in at start (0.3s), continues under speech, then fades out over 2s after speech ends
@@ -302,9 +316,6 @@ mix_background() {
302
316
  cp "$voice" "$output"
303
317
  return
304
318
  }
305
-
306
- # Save new position for next time
307
- save_background_position "$background" "$new_pos"
308
319
  }
309
320
 
310
321
  # Main processing
@@ -318,6 +329,25 @@ main() {
318
329
  # Parse config (format: NAME|EFFECTS|BACKGROUND|VOLUME)
319
330
  IFS='|' read -r _ sox_effects background_file bg_volume <<< "$config"
320
331
 
332
+ # Per-agent background music override from bmad-speak.sh profile JSON (takes priority over cfg).
333
+ # The profile file is a PID-scoped temp file written by bmad-speak.sh; no env var leakage.
334
+ if [[ -n "$AGENT_PROFILE_FILE" ]] && [[ -f "$AGENT_PROFILE_FILE" ]]; then
335
+ # SECURITY: Pass profile path via env var to avoid shell injection in node -e string
336
+ local _prof_track _prof_vol _prof_enabled
337
+ _prof_track=$(_AV_PROF="$AGENT_PROFILE_FILE" node -e "try{const p=JSON.parse(require('fs').readFileSync(process.env._AV_PROF,'utf8'));process.stdout.write(p.backgroundMusic?.track??'')}catch{process.stdout.write('')}" 2>/dev/null || true)
338
+ _prof_vol=$(_AV_PROF="$AGENT_PROFILE_FILE" node -e "try{const p=JSON.parse(require('fs').readFileSync(process.env._AV_PROF,'utf8'));process.stdout.write(String(p.backgroundMusic?.volume??''))}catch{process.stdout.write('')}" 2>/dev/null || true)
339
+ _prof_enabled=$(_AV_PROF="$AGENT_PROFILE_FILE" node -e "try{const p=JSON.parse(require('fs').readFileSync(process.env._AV_PROF,'utf8'));process.stdout.write(String(p.backgroundMusic?.enabled??''))}catch{process.stdout.write('')}" 2>/dev/null || true)
340
+ if [[ "$_prof_enabled" == "true" ]] && [[ -n "$_prof_track" ]]; then
341
+ background_file="$_prof_track"
342
+ # Convert percentage volume (0-100) to decimal (0.0-1.0) for ffmpeg
343
+ if [[ "$_prof_vol" =~ ^[0-9]+$ ]]; then
344
+ bg_volume=$(awk "BEGIN{printf \"%.2f\", ${_prof_vol}/100}")
345
+ else
346
+ bg_volume="0.70"
347
+ fi
348
+ fi
349
+ fi
350
+
321
351
  # SECURITY: Use secure temp directory per CLAUDE.md guidelines
322
352
  # Prefer XDG_RUNTIME_DIR (user-owned, restricted permissions)
323
353
  # Fall back to user-specific directory in /tmp
@@ -338,7 +368,10 @@ main() {
338
368
  chmod 700 "$TEMP_DIR"
339
369
 
340
370
  # SECURITY: Verify ownership of temp directory
341
- if [[ "$(stat -c '%u' "$TEMP_DIR" 2>/dev/null || stat -f '%u' "$TEMP_DIR" 2>/dev/null)" != "$(id -u)" ]]; then
371
+ # SECURITY: Handle stat failure explicitly (#134)
372
+ local _dir_uid
373
+ _dir_uid=$(stat -c '%u' "$TEMP_DIR" 2>/dev/null || stat -f '%u' "$TEMP_DIR" 2>/dev/null)
374
+ if [[ -z "$_dir_uid" ]] || [[ "$_dir_uid" != "$(id -u)" ]]; then
342
375
  echo "Error: Temp directory not owned by current user: $TEMP_DIR" >&2
343
376
  exit 1
344
377
  fi
@@ -369,8 +402,19 @@ main() {
369
402
  background_path="$BACKGROUNDS_DIR/$background_file"
370
403
  fi
371
404
 
405
+ # Per-agent profile enables music independently of the global flag.
406
+ local _bg_allowed=false
407
+ if is_background_music_enabled; then
408
+ _bg_allowed=true
409
+ elif [[ -n "$AGENT_PROFILE_FILE" ]] && [[ -f "$AGENT_PROFILE_FILE" ]]; then
410
+ # A valid agent profile with enabled=true overrides the global off switch.
411
+ local _check_enabled
412
+ _check_enabled=$(_AV_PROF="$AGENT_PROFILE_FILE" node -e "try{const p=JSON.parse(require('fs').readFileSync(process.env._AV_PROF,'utf8'));process.stdout.write(String(p.backgroundMusic?.enabled??''))}catch{process.stdout.write('')}" 2>/dev/null || true)
413
+ [[ "$_check_enabled" == "true" ]] && _bg_allowed=true
414
+ fi
415
+
372
416
  local used_background=""
373
- if is_background_music_enabled && [[ -n "$background_path" ]] && [[ -f "$background_path" ]] && [[ "${bg_volume:-0}" != "0" ]] && [[ "${bg_volume:-0}" != "0.0" ]]; then
417
+ if [[ "$_bg_allowed" == "true" ]] && [[ -n "$background_path" ]] && [[ -f "$background_path" ]] && [[ "${bg_volume:-0}" != "0" ]] && [[ "${bg_volume:-0}" != "0.0" ]]; then
374
418
  echo " → Mixing background: $background_file at ${bg_volume} volume" >&2
375
419
  mix_background "$temp_effects" "$background_path" "$bg_volume" "$temp_final"
376
420
  used_background="$background_path" # Return full path instead of just filename
@@ -28,6 +28,10 @@ DIALOGUE="$2"
28
28
  DIALOGUE="${DIALOGUE//\\!/!}"
29
29
  DIALOGUE="${DIALOGUE//\\\$/\$}"
30
30
 
31
+ # Strip markdown formatting — prevent Piper from speaking "asterisk asterisk" literally.
32
+ # play-tts-piper.sh also strips via perl, but do it here early as defense-in-depth.
33
+ DIALOGUE=$(printf '%s' "$DIALOGUE" | sed 's/\*\*//g; s/\*//g; s/`//g; s/^[[:space:]]*#\+[[:space:]]*//')
34
+
31
35
  # Check if party mode is enabled
32
36
  if [[ -f "$PROJECT_ROOT/.agentvibes/bmad/bmad-party-mode-disabled.flag" ]]; then
33
37
  exit 0
@@ -38,40 +42,110 @@ if [[ ! -f "$PROJECT_ROOT/_bmad/_config/agent-manifest.csv" ]]; then
38
42
  exit 0
39
43
  fi
40
44
 
41
- # Map display name to agent ID, OR pass through if already an agent ID
45
+ # ---------------------------------------------------------------------------
46
+ # Per-agent profile reader — reads from ~/.agentvibes/bmad-voice-map.json
47
+ # Uses node for reliable JSON parsing (jq may not be installed)
48
+ # Returns empty string if field not found or file missing
49
+
50
+ VOICE_MAP_FILE="$HOME/.agentvibes/bmad-voice-map.json"
51
+
52
+ # Read a field from the per-agent profile in bmad-voice-map.json
53
+ # Usage: read_agent_profile <agent_id> <field>
54
+ # Fields: voice, pretext, reverbPreset, personality, backgroundMusic.track, backgroundMusic.volume
55
+ read_agent_profile() {
56
+ local agent_id="$1"
57
+ local field="$2"
58
+
59
+ if [[ ! -f "$VOICE_MAP_FILE" ]]; then
60
+ echo ""
61
+ return
62
+ fi
63
+
64
+ # Validate agent_id format (prevent injection)
65
+ if [[ ! "$agent_id" =~ ^[a-zA-Z0-9_-]+$ ]]; then
66
+ echo ""
67
+ return
68
+ fi
69
+
70
+ # Use node for JSON parsing (always available in AgentVibes projects)
71
+ # SECURITY: Pass values via env vars to prevent shell injection
72
+ _VOICE_MAP="$VOICE_MAP_FILE" _AGENT_ID="$agent_id" _FIELD="$field" node -e "
73
+ try {
74
+ const d = JSON.parse(require('fs').readFileSync(process.env._VOICE_MAP,'utf8'));
75
+ const a = d.agents?.[process.env._AGENT_ID] ?? {};
76
+ const f = process.env._FIELD;
77
+ if (f.includes('.')) {
78
+ const [k1, k2] = f.split('.');
79
+ process.stdout.write(String(a[k1]?.[k2] ?? ''));
80
+ } else {
81
+ process.stdout.write(String(a[f] ?? ''));
82
+ }
83
+ } catch { process.stdout.write(''); }
84
+ " 2>/dev/null || echo ""
85
+ }
86
+
87
+ # Read all profile fields in a single Node.js invocation to avoid ~900ms of overhead.
88
+ # Returns: voice|pretext|reverbPreset|personality|backgroundMusic.track|backgroundMusic.volume
89
+ # Outputs `|||||` if the file is missing or the agent is not found.
90
+ # SECURITY: Pass values via env vars to prevent shell injection
91
+ read_agent_profile_all() {
92
+ local agent_id="$1"
93
+
94
+ # Validate agent_id format (prevent injection)
95
+ if [[ ! "$agent_id" =~ ^[a-zA-Z0-9_-]+$ ]]; then
96
+ echo "|||||"
97
+ return
98
+ fi
99
+
100
+ if [[ ! -f "$VOICE_MAP_FILE" ]]; then
101
+ echo "||||||"
102
+ return
103
+ fi
104
+
105
+ _VOICE_MAP="$VOICE_MAP_FILE" _AGENT_ID="$agent_id" node -e "
106
+ try {
107
+ const d = JSON.parse(require('fs').readFileSync(process.env._VOICE_MAP,'utf8'));
108
+ const a = d.agents?.[process.env._AGENT_ID] ?? {};
109
+ const fields = [
110
+ String(a.voice ?? ''),
111
+ String(a.pretext ?? ''),
112
+ String(a.reverbPreset ?? ''),
113
+ String(a.personality ?? ''),
114
+ String(a.backgroundMusic?.track ?? ''),
115
+ String(a.backgroundMusic?.volume ?? ''),
116
+ String(a.backgroundMusic?.enabled ?? ''),
117
+ ];
118
+ process.stdout.write(fields.join('|'));
119
+ } catch { process.stdout.write('||||||'); }
120
+ " 2>/dev/null || echo "||||||"
121
+ }
122
+
123
+ # ---------------------------------------------------------------------------
124
+ # Map display name to agent ID
125
+
42
126
  map_to_agent_id() {
43
127
  local name_or_id="$1"
44
128
 
45
129
  # If it looks like a file path (.bmad/*/agents/*.md), extract the agent ID
46
- # Example: .bmad/bmm/agents/pm.md -> pm
47
130
  if [[ "$name_or_id" =~ _?\.?bmad/.*/agents/([^/]+)\.md$ ]]; then
48
131
  echo "${BASH_REMATCH[1]}"
49
132
  return
50
133
  fi
51
134
 
52
135
  # First check if it's already an agent ID (column 1 of manifest)
53
- # CSV format: name,displayName,title,icon,role,...
54
136
  local direct_match=$(grep -i "^\"*${name_or_id}\"*," "$PROJECT_ROOT/_bmad/_config/agent-manifest.csv" | head -1)
55
137
  if [[ -n "$direct_match" ]]; then
56
- # Already an agent ID, pass through
57
138
  echo "$name_or_id"
58
139
  return
59
140
  fi
60
141
 
61
142
  # Otherwise map display name to agent ID (for party mode)
62
- # Extract 'name' (column 1) where displayName (column 2) contains the name
63
- # displayName format in CSV: "John", "Mary", "Winston", etc. (first word before any parentheses)
64
143
  local agent_id=$(awk -F',' -v name="$name_or_id" '
65
144
  BEGIN { IGNORECASE=1 }
66
145
  NR > 1 {
67
- # Extract displayName (column 2)
68
146
  display = $2
69
- gsub(/^"|"$/, "", display) # Remove surrounding quotes
70
-
71
- # Check if display name starts with the search name (case-insensitive)
72
- # This handles both "John" and "John (Product Manager)"
147
+ gsub(/^"|"$/, "", display)
73
148
  if (tolower(display) ~ "^" tolower(name) "($| |\\()") {
74
- # Extract agent ID (column 1)
75
149
  agent = $1
76
150
  gsub(/^"|"$/, "", agent)
77
151
  print agent
@@ -83,30 +157,113 @@ map_to_agent_id() {
83
157
  echo "$agent_id"
84
158
  }
85
159
 
86
- # Get agent ID
160
+ # ---------------------------------------------------------------------------
161
+ # Resolve agent profile
162
+
87
163
  AGENT_ID=$(map_to_agent_id "$AGENT_NAME_OR_ID")
88
164
 
89
- # Get agent's voice and intro text
90
- AGENT_VOICE=""
91
- AGENT_INTRO=""
92
- if [[ -n "$AGENT_ID" ]] && [[ -f "$SCRIPT_DIR/bmad-voice-manager.sh" ]]; then
93
- AGENT_VOICE=$(cd "$PROJECT_ROOT" && "$SCRIPT_DIR/bmad-voice-manager.sh" get-voice "$AGENT_ID" 2>/dev/null)
94
- AGENT_INTRO=$(cd "$PROJECT_ROOT" && "$SCRIPT_DIR/bmad-voice-manager.sh" get-intro "$AGENT_ID" 2>/dev/null)
165
+ # Read per-agent profile from bmad-voice-map.json (takes priority)
166
+ PROFILE_VOICE=""
167
+ PROFILE_PRETEXT=""
168
+ PROFILE_REVERB=""
169
+ PROFILE_PERSONALITY=""
170
+ PROFILE_MUSIC_TRACK=""
171
+ PROFILE_MUSIC_VOLUME=""
172
+ PROFILE_MUSIC_ENABLED=""
173
+
174
+ if [[ -n "$AGENT_ID" ]] && [[ -f "$VOICE_MAP_FILE" ]]; then
175
+ # Single Node.js call for all fields — avoids ~900ms of per-call startup overhead
176
+ _ALL_FIELDS=$(read_agent_profile_all "$AGENT_ID")
177
+ IFS='|' read -r PROFILE_VOICE PROFILE_PRETEXT PROFILE_REVERB PROFILE_PERSONALITY PROFILE_MUSIC_TRACK PROFILE_MUSIC_VOLUME PROFILE_MUSIC_ENABLED <<< "$_ALL_FIELDS"
178
+ fi
179
+
180
+ # Fallback to bmad-voice-manager.sh if no profile voice found
181
+ AGENT_VOICE="$PROFILE_VOICE"
182
+ AGENT_INTRO="$PROFILE_PRETEXT"
183
+
184
+ if [[ -z "$AGENT_VOICE" ]] && [[ -n "$AGENT_ID" ]] && [[ -f "$SCRIPT_DIR/bmad-voice-manager.sh" ]]; then
185
+ AGENT_VOICE=$(cd "$PROJECT_ROOT" && "$SCRIPT_DIR/bmad-voice-manager.sh" get-voice "$AGENT_ID" 2>/dev/null || true)
186
+ fi
187
+
188
+ if [[ -z "$AGENT_INTRO" ]] && [[ -n "$AGENT_ID" ]] && [[ -f "$SCRIPT_DIR/bmad-voice-manager.sh" ]]; then
189
+ AGENT_INTRO=$(cd "$PROJECT_ROOT" && "$SCRIPT_DIR/bmad-voice-manager.sh" get-intro "$AGENT_ID" 2>/dev/null || true)
190
+ fi
191
+
192
+ # ---------------------------------------------------------------------------
193
+ # Write PID-scoped temp profile file for per-agent overrides
194
+ # play-tts-enhanced.sh and queue worker read this for reverb/personality/music
195
+
196
+ TEMP_PROFILE=""
197
+ if [[ -n "$PROFILE_REVERB" ]] || [[ -n "$PROFILE_PERSONALITY" ]] || [[ -n "$PROFILE_MUSIC_TRACK" ]]; then
198
+ PROFILE_DIR="${XDG_RUNTIME_DIR:-/tmp}/agentvibes-$(id -u)"
199
+ mkdir -p "$PROFILE_DIR"
200
+ chmod 700 "$PROFILE_DIR"
201
+ TEMP_PROFILE="$PROFILE_DIR/agent-profile-$$.json"
202
+
203
+ # Write profile as JSON for reliable parsing downstream
204
+ # SECURITY: Pass values via env vars to prevent shell injection
205
+ _P_REVERB="$PROFILE_REVERB" _P_PERSONALITY="$PROFILE_PERSONALITY" \
206
+ _P_MUSIC_TRACK="$PROFILE_MUSIC_TRACK" _P_MUSIC_VOL="${PROFILE_MUSIC_VOLUME:-70}" \
207
+ _P_MUSIC_ENABLED="$PROFILE_MUSIC_ENABLED" \
208
+ _P_OUTFILE="$TEMP_PROFILE" node -e "
209
+ const p = {};
210
+ if (process.env._P_REVERB) p.reverbPreset = process.env._P_REVERB;
211
+ if (process.env._P_PERSONALITY) p.personality = process.env._P_PERSONALITY;
212
+ if (process.env._P_MUSIC_TRACK) p.backgroundMusic = {
213
+ track: process.env._P_MUSIC_TRACK,
214
+ volume: parseInt(process.env._P_MUSIC_VOL) || 70,
215
+ enabled: process.env._P_MUSIC_ENABLED === 'true'
216
+ };
217
+ require('fs').writeFileSync(process.env._P_OUTFILE, JSON.stringify(p), { mode: 0o600 });
218
+ " 2>/dev/null || true
219
+
220
+ # NOTE: Do NOT clean up temp profile here — the queue worker processes it
221
+ # asynchronously and cleans it up after use (see tts-queue-worker.sh).
222
+ # Removing it here would race with the background queue consumer.
95
223
  fi
96
224
 
97
- # Prepend intro text if configured (e.g., "John, Product Manager here. [dialogue]")
225
+ # ---------------------------------------------------------------------------
226
+ # Build full text with intro/pretext
227
+
98
228
  FULL_TEXT="$DIALOGUE"
99
229
  if [[ -n "$AGENT_INTRO" ]]; then
100
230
  FULL_TEXT="${AGENT_INTRO}. ${DIALOGUE}"
101
231
  fi
102
232
 
103
- # Speak with agent's voice using queue system (non-blocking for Claude)
104
- # Queue system ensures sequential playback while allowing Claude to continue
105
- # Pass agent display name for unique background music per agent (audio-effects.cfg)
106
- # Output from play-tts.sh will be displayed by the queue worker (GitHub Issue #39)
233
+
234
+ # Serialize speech prevents overlap when Claude fires parallel calls
235
+ # Uses mkdir as a portable atomic lock (works on Linux, macOS, WSL)
236
+ SPEECH_LOCK="${XDG_RUNTIME_DIR:-/tmp}/agentvibes-speech.lock"
237
+
238
+ # Acquire lock (wait up to 120s, retry every 0.5s)
239
+ # Clean up stale file locks from older flock-based version
240
+ [[ -f "$SPEECH_LOCK" ]] && rm -f "$SPEECH_LOCK"
241
+ _WAIT=0
242
+ while ! mkdir "$SPEECH_LOCK" 2>/dev/null; do
243
+ if [[ -e "$SPEECH_LOCK" ]]; then
244
+ _LOCK_AGE=$(( $(date +%s) - $(stat -c '%Y' "$SPEECH_LOCK" 2>/dev/null || stat -f '%m' "$SPEECH_LOCK" 2>/dev/null || echo 0) ))
245
+ [[ $_LOCK_AGE -gt 60 ]] && { rm -rf "$SPEECH_LOCK" 2>/dev/null || true; continue; }
246
+ fi
247
+ sleep 0.5
248
+ _WAIT=$((_WAIT + 1))
249
+ [[ $_WAIT -gt 240 ]] && break
250
+ done
251
+ trap 'rmdir "$SPEECH_LOCK" 2>/dev/null' EXIT
252
+
253
+ # Speak with agent's voice, passing the temp profile path as arg 3 so
254
+ # play-tts-piper.sh → audio-processor.sh can read per-agent music settings
255
+ # without any env vars (safe for concurrent multi-project use).
107
256
  if [[ -n "$AGENT_VOICE" ]]; then
108
- bash "$SCRIPT_DIR/tts-queue.sh" add "$FULL_TEXT" "$AGENT_VOICE" "$AGENT_NAME_OR_ID" &
257
+ bash "$SCRIPT_DIR/play-tts.sh" "$FULL_TEXT" "$AGENT_VOICE" "$TEMP_PROFILE"
109
258
  else
110
- # Fallback to default voice, still pass agent name for background music
111
- bash "$SCRIPT_DIR/tts-queue.sh" add "$FULL_TEXT" "" "$AGENT_NAME_OR_ID" &
259
+ bash "$SCRIPT_DIR/play-tts.sh" "$FULL_TEXT" "" "$TEMP_PROFILE"
260
+ fi
261
+
262
+ # Release lock
263
+ rmdir "$SPEECH_LOCK" 2>/dev/null || true
264
+ trap - EXIT
265
+
266
+ # Clean up temp profile after use
267
+ if [[ -n "$TEMP_PROFILE" ]] && [[ -f "$TEMP_PROFILE" ]]; then
268
+ rm -f "$TEMP_PROFILE"
112
269
  fi
@@ -31,16 +31,51 @@ if [[ "${AGENTVIBES_PARTY_MODE:-false}" == "true" ]]; then
31
31
  CONFIG_KEY="_party_mode"
32
32
  fi
33
33
 
34
+ # ---------------------------------------------------------------------------
35
+ # Per-agent profile overrides (from bmad-voice-map.json via bmad-speak.sh)
36
+ # If AGENTVIBES_AGENT_PROFILE is set and the file exists, apply reverb/personality/music
37
+ # overrides by temporarily setting effects-manager config for this agent
38
+ AGENT_PROFILE="${AGENTVIBES_AGENT_PROFILE:-}"
39
+
40
+ if [[ -n "$AGENT_PROFILE" ]] && [[ -f "$AGENT_PROFILE" ]]; then
41
+ # Read profile fields using node (reliable JSON parsing)
42
+ # SECURITY: Pass values via env vars to prevent shell injection
43
+ _PROFILE_REVERB=$(_APFILE="$AGENT_PROFILE" node -e "try{const p=JSON.parse(require('fs').readFileSync(process.env._APFILE,'utf8'));process.stdout.write(p.reverbPreset||'')}catch{}" 2>/dev/null || true)
44
+ _PROFILE_MUSIC_TRACK=$(_APFILE="$AGENT_PROFILE" node -e "try{const p=JSON.parse(require('fs').readFileSync(process.env._APFILE,'utf8'));process.stdout.write(p.backgroundMusic?.track||'')}catch{}" 2>/dev/null || true)
45
+ _PROFILE_MUSIC_VOL=$(_APFILE="$AGENT_PROFILE" node -e "try{const p=JSON.parse(require('fs').readFileSync(process.env._APFILE,'utf8'));process.stdout.write(String(p.backgroundMusic?.volume||''))}catch{}" 2>/dev/null || true)
46
+
47
+ # Apply per-agent reverb via effects-manager (scoped to this agent's config key)
48
+ if [[ -n "$_PROFILE_REVERB" ]] && [[ -f "$SCRIPT_DIR/effects-manager.sh" ]]; then
49
+ bash "$SCRIPT_DIR/effects-manager.sh" set-reverb "$_PROFILE_REVERB" "$CONFIG_KEY" 2>/dev/null || true
50
+ fi
51
+
52
+ # Override background music track/volume for this invocation via env vars
53
+ if [[ -n "$_PROFILE_MUSIC_TRACK" ]]; then
54
+ export AGENTVIBES_BG_TRACK="$_PROFILE_MUSIC_TRACK"
55
+ fi
56
+ if [[ -n "$_PROFILE_MUSIC_VOL" ]]; then
57
+ export AGENTVIBES_BG_VOLUME="$_PROFILE_MUSIC_VOL"
58
+ fi
59
+ fi
60
+
34
61
  # Step 1: Generate TTS WITHOUT playback
35
62
  export AGENTVIBES_NO_PLAYBACK=true
36
- OUTPUT=$("$SCRIPT_DIR/play-tts.sh" "$TEXT" "$VOICE_OVERRIDE" 2>&1)
63
+ export AGENTVIBES_WAV_OUTPATH="${XDG_RUNTIME_DIR:-/tmp}/agentvibes-last-wav-$$.txt"
37
64
 
38
- # Extract the generated file path from output
39
- GENERATED_FILE=$(echo "$OUTPUT" | grep "Saved to:" | sed 's/.*Saved to: //')
65
+ # Cleanup temp outpath file on exit
66
+ trap 'rm -f "$AGENTVIBES_WAV_OUTPATH"' EXIT
67
+ "$SCRIPT_DIR/play-tts.sh" "$TEXT" "$VOICE_OVERRIDE"
68
+
69
+ # Read the generated file path (written by play-tts-piper.sh via AGENTVIBES_WAV_OUTPATH)
70
+ GENERATED_FILE=""
71
+ if [[ -f "$AGENTVIBES_WAV_OUTPATH" ]]; then
72
+ GENERATED_FILE=$(cat "$AGENTVIBES_WAV_OUTPATH")
73
+ rm -f "$AGENTVIBES_WAV_OUTPATH"
74
+ fi
75
+ unset AGENTVIBES_WAV_OUTPATH
40
76
 
41
77
  if [[ -z "$GENERATED_FILE" ]] || [[ ! -f "$GENERATED_FILE" ]]; then
42
- echo "Error: Failed to generate TTS audio" >&2
43
- echo "$OUTPUT" >&2
78
+ echo "Error: Could not find generated audio file" >&2
44
79
  exit 1
45
80
  fi
46
81
 
@@ -48,6 +48,17 @@ fi
48
48
  TEXT="$1"
49
49
  VOICE_OVERRIDE="$2" # Optional: voice name (e.g., "Samantha", "Daniel")
50
50
 
51
+ # Strip emojis, asterisks, and markdown formatting
52
+ TEXT=$(printf '%s' "$TEXT" | perl -CSD -pe '
53
+ s/[\x{1F300}-\x{1F9FF}]//g;
54
+ s/[\x{2600}-\x{27BF}]//g;
55
+ s/[\x{FE00}-\x{FE0F}]//g;
56
+ s/[\x{200D}]//g;
57
+ s/[\x{2500}-\x{257F}]//g;
58
+ s/[\x{2580}-\x{259F}]//g;
59
+ s/\*+//g; s/#+\s*//g; s/`//g; s/~+//g; s/^\s*[-]\s*//g;
60
+ ')
61
+
51
62
  SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
52
63
 
53
64
  # Source audio cache utilities
@@ -168,10 +179,9 @@ fi
168
179
 
169
180
  mkdir -p "$AUDIO_DIR"
170
181
 
171
- # Generate unique filename
172
- TIMESTAMP=$(date +%s)
173
- TEMP_FILE="$AUDIO_DIR/tts-${TIMESTAMP}.aiff"
174
- FINAL_FILE="$AUDIO_DIR/tts-padded-${TIMESTAMP}.wav"
182
+ # SECURITY: Use mktemp for unpredictable filenames (#130)
183
+ TEMP_FILE=$(mktemp "$AUDIO_DIR/tts-XXXXXX.aiff")
184
+ FINAL_FILE=$(mktemp "$AUDIO_DIR/tts-padded-XXXXXX.wav")
175
185
 
176
186
  # @function get_speech_rate
177
187
  # @intent Determine speech rate for synthesis
@@ -226,14 +236,27 @@ if command -v ffmpeg &> /dev/null; then
226
236
  fi
227
237
  else
228
238
  # No ffmpeg - use AIFF directly (rename for consistency)
229
- FINAL_FILE="$AUDIO_DIR/tts-padded-${TIMESTAMP}.aiff"
239
+ FINAL_FILE=$(mktemp "$AUDIO_DIR/tts-padded-XXXXXX.aiff")
230
240
  mv "$TEMP_FILE" "$FINAL_FILE"
231
241
  TEMP_FILE="$FINAL_FILE"
232
242
  fi
233
243
 
234
244
  # @function play_audio
235
245
  # @intent Play generated audio - via PulseAudio tunnel for SSH, afplay for local
236
- LOCK_FILE="/tmp/agentvibes-audio.lock"
246
+ # SECURITY: Use user-isolated lock directory (#129)
247
+ _LOCK_DIR="${XDG_RUNTIME_DIR:-/tmp/agentvibes-$(id -u)}"
248
+ mkdir -p "$_LOCK_DIR"
249
+ chmod 700 "$_LOCK_DIR"
250
+ LOCK_FILE="$_LOCK_DIR/agentvibes-audio.lock"
251
+
252
+ # Auto-remove stale lock files (older than 30 seconds)
253
+ if [ -f "$LOCK_FILE" ]; then
254
+ _lock_mtime=$(stat -f %m "$LOCK_FILE" 2>/dev/null || echo 0)
255
+ _lock_age=$(( $(date +%s) - _lock_mtime ))
256
+ if [[ $_lock_age -gt 30 ]]; then
257
+ rm -f "$LOCK_FILE"
258
+ fi
259
+ fi
237
260
 
238
261
  # Wait for previous audio to finish (max 30 seconds)
239
262
  for i in {1..60}; do