agentvibes 4.6.3 → 4.6.5
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/.claude/hooks/audio-processor.sh +433 -433
- package/.claude/hooks/play-tts-macos.sh +368 -368
- package/.claude/hooks/play-tts-piper.sh +679 -679
- package/.claude/hooks/play-tts-soprano.sh +356 -356
- package/CLAUDE.md +12 -0
- package/README.md +2148 -2121
- package/RELEASE_NOTES.md +42 -0
- package/bin/agentvibes.js +15 -0
- package/package.json +1 -1
- package/src/console/app.js +41 -19
- package/src/console/tabs/agents-tab.js +1 -1
- package/src/console/tabs/help-tab.js +1 -1
- package/src/console/tabs/install-tab.js +1 -1
- package/src/console/tabs/music-tab.js +1 -1
- package/src/console/tabs/readme-tab.js +1 -1
- package/src/console/tabs/receiver-tab.js +1 -1
- package/src/console/tabs/settings-tab.js +1 -1
- package/src/console/tabs/voices-tab.js +1 -1
- package/src/installer.js +70 -27
|
@@ -1,433 +1,433 @@
|
|
|
1
|
-
#!/usr/bin/env bash
|
|
2
|
-
#
|
|
3
|
-
# File: .claude/hooks/audio-processor.sh
|
|
4
|
-
#
|
|
5
|
-
# AgentVibes - Audio Effects and Background Mixing Processor
|
|
6
|
-
# Website: https://agentvibes.org
|
|
7
|
-
# Repository: https://github.com/paulpreibisch/AgentVibes
|
|
8
|
-
#
|
|
9
|
-
# Co-created by Paul Preibisch with Claude AI
|
|
10
|
-
# Copyright (c) 2025 Paul Preibisch
|
|
11
|
-
#
|
|
12
|
-
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
13
|
-
# you may not use this file except in compliance with the License.
|
|
14
|
-
#
|
|
15
|
-
# ---
|
|
16
|
-
#
|
|
17
|
-
# @fileoverview Audio post-processor for TTS with effects and background mixing
|
|
18
|
-
# @context Applies sox effects and mixes background audio for enhanced TTS experience
|
|
19
|
-
# @architecture Post-processing hook called after TTS generation, before playback
|
|
20
|
-
# @dependencies sox, ffmpeg
|
|
21
|
-
# @entrypoints Called by play-tts-piper.sh after audio generation
|
|
22
|
-
# @patterns Pipeline pattern: input.wav → effects → mix → output.wav
|
|
23
|
-
#
|
|
24
|
-
|
|
25
|
-
set -euo pipefail
|
|
26
|
-
|
|
27
|
-
# Fix locale warnings
|
|
28
|
-
export LC_ALL=C
|
|
29
|
-
|
|
30
|
-
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
31
|
-
PROJECT_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"
|
|
32
|
-
|
|
33
|
-
# Input parameters
|
|
34
|
-
INPUT_FILE="${1:-}"
|
|
35
|
-
AGENT_NAME="${2:-default}"
|
|
36
|
-
OUTPUT_FILE="${3:-}"
|
|
37
|
-
AGENT_PROFILE_FILE="${4:-}" # Optional: path to per-agent profile JSON (from bmad-speak.sh)
|
|
38
|
-
|
|
39
|
-
# Config and directories (resolve to absolute paths)
|
|
40
|
-
CONFIG_FILE="$(cd "$SCRIPT_DIR/.." && pwd)/config/audio-effects.cfg"
|
|
41
|
-
BACKGROUNDS_DIR="$(cd "$SCRIPT_DIR/../audio" && pwd)/tracks"
|
|
42
|
-
ENABLED_FILE="$(cd "$SCRIPT_DIR/.." && pwd)/config/background-music-enabled.txt"
|
|
43
|
-
GLOBAL_ENABLED_FILE="$HOME/.claude/config/background-music-enabled.txt"
|
|
44
|
-
|
|
45
|
-
# Check if background music is enabled (project-local, then global fallback)
|
|
46
|
-
is_background_music_enabled() {
|
|
47
|
-
local enabled=""
|
|
48
|
-
if [[ -f "$ENABLED_FILE" ]]; then
|
|
49
|
-
enabled=$(cat "$ENABLED_FILE" 2>/dev/null | tr -d '[:space:]')
|
|
50
|
-
elif [[ -f "$GLOBAL_ENABLED_FILE" ]]; then
|
|
51
|
-
enabled=$(cat "$GLOBAL_ENABLED_FILE" 2>/dev/null | tr -d '[:space:]')
|
|
52
|
-
else
|
|
53
|
-
return 1 # Disabled by default
|
|
54
|
-
fi
|
|
55
|
-
|
|
56
|
-
# Return 0 (true) if enabled, 1 (false) otherwise
|
|
57
|
-
[[ "$enabled" == "true" ]]
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
# Validate inputs
|
|
61
|
-
if [[ -z "$INPUT_FILE" ]] || [[ ! -f "$INPUT_FILE" ]]; then
|
|
62
|
-
echo "Error: Input file required and must exist" >&2
|
|
63
|
-
echo "Usage: $0 <input.wav> [agent_name] [output.wav]" >&2
|
|
64
|
-
exit 1
|
|
65
|
-
fi
|
|
66
|
-
|
|
67
|
-
# Default output to input location with -processed suffix
|
|
68
|
-
if [[ -z "$OUTPUT_FILE" ]]; then
|
|
69
|
-
OUTPUT_FILE="${INPUT_FILE%.wav}-processed.wav"
|
|
70
|
-
fi
|
|
71
|
-
|
|
72
|
-
# Check for required tools
|
|
73
|
-
if ! command -v sox &> /dev/null; then
|
|
74
|
-
echo "Warning: sox not installed, skipping effects" >&2
|
|
75
|
-
cp "$INPUT_FILE" "$OUTPUT_FILE"
|
|
76
|
-
echo "$OUTPUT_FILE"
|
|
77
|
-
exit 0
|
|
78
|
-
fi
|
|
79
|
-
|
|
80
|
-
# @function get_agent_config
|
|
81
|
-
# @intent Parse audio-effects.cfg for agent-specific settings
|
|
82
|
-
# @param $1 Agent name
|
|
83
|
-
# @returns Pipe-separated config line or default
|
|
84
|
-
get_agent_config() {
|
|
85
|
-
local agent="$1"
|
|
86
|
-
|
|
87
|
-
if [[ ! -f "$CONFIG_FILE" ]]; then
|
|
88
|
-
echo "default|gain -8||0.0"
|
|
89
|
-
return
|
|
90
|
-
fi
|
|
91
|
-
|
|
92
|
-
# Try exact match first (use awk for safe literal matching)
|
|
93
|
-
local config
|
|
94
|
-
config=$(awk -F'|' -v agent="$agent" 'tolower($1) == tolower(agent)' "$CONFIG_FILE" 2>/dev/null | head -1)
|
|
95
|
-
|
|
96
|
-
# Fall back to default
|
|
97
|
-
if [[ -z "$config" ]]; then
|
|
98
|
-
config=$(grep "^default|" "$CONFIG_FILE" 2>/dev/null | head -1)
|
|
99
|
-
fi
|
|
100
|
-
|
|
101
|
-
# Return config or empty default
|
|
102
|
-
if [[ -n "$config" ]]; then
|
|
103
|
-
echo "$config"
|
|
104
|
-
else
|
|
105
|
-
echo "default|gain -8||0.0"
|
|
106
|
-
fi
|
|
107
|
-
}
|
|
108
|
-
|
|
109
|
-
# @function apply_sox_effects
|
|
110
|
-
# @intent Apply sox effect chain to audio file
|
|
111
|
-
# @param $1 Input file
|
|
112
|
-
# @param $2 Output file
|
|
113
|
-
# @param $3 Sox effects string
|
|
114
|
-
apply_sox_effects() {
|
|
115
|
-
local input="$1"
|
|
116
|
-
local output="$2"
|
|
117
|
-
local effects="$3"
|
|
118
|
-
|
|
119
|
-
if [[ -z "$effects" ]]; then
|
|
120
|
-
cp "$input" "$output"
|
|
121
|
-
return 0
|
|
122
|
-
fi
|
|
123
|
-
|
|
124
|
-
# Validate effects contain only allowed sox effect names and numeric params
|
|
125
|
-
local allowed_effects="gain|reverb|echo|chorus|flanger|phaser|tremolo|overdrive|bass|treble|equalizer|highpass|lowpass|bandpass|vol|speed|tempo|pitch|rate|pad|silence|trim|fade|norm|loudness|compand|contrast|delay|repeat|stat|remix"
|
|
126
|
-
for word in $effects; do
|
|
127
|
-
if ! [[ "$word" =~ ^-?[0-9]*\.?[0-9]+$ ]] && ! echo "$word" | grep -qiE "^($allowed_effects)$"; then
|
|
128
|
-
echo "Warning: Invalid sox effect '$word', skipping effects" >&2
|
|
129
|
-
cp "$input" "$output"
|
|
130
|
-
return 0
|
|
131
|
-
fi
|
|
132
|
-
done
|
|
133
|
-
|
|
134
|
-
# Apply effects - note: effects string is intentionally unquoted to allow word splitting
|
|
135
|
-
# shellcheck disable=SC2086
|
|
136
|
-
sox "$input" "$output" $effects 2>/dev/null || {
|
|
137
|
-
echo "Warning: Sox effects failed, using original" >&2
|
|
138
|
-
cp "$input" "$output"
|
|
139
|
-
}
|
|
140
|
-
}
|
|
141
|
-
|
|
142
|
-
# Position tracking file for continuous playback
|
|
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"
|
|
146
|
-
|
|
147
|
-
# @function get_background_position
|
|
148
|
-
# @intent Get saved position for a background track (caller must hold POSITION_LOCK)
|
|
149
|
-
# @param $1 Background file path
|
|
150
|
-
# @returns Position in seconds (or 0 if not found)
|
|
151
|
-
get_background_position() {
|
|
152
|
-
local bg_file="$1"
|
|
153
|
-
local bg_name
|
|
154
|
-
bg_name=$(basename "$bg_file")
|
|
155
|
-
|
|
156
|
-
if [[ -f "$POSITION_FILE" ]]; then
|
|
157
|
-
awk -F: -v name="$bg_name" '$1 == name {print $2}' "$POSITION_FILE" 2>/dev/null | tr -d '[:space:]' | tail -1
|
|
158
|
-
else
|
|
159
|
-
echo "0"
|
|
160
|
-
fi
|
|
161
|
-
}
|
|
162
|
-
|
|
163
|
-
# @function save_background_position
|
|
164
|
-
# @intent Save position for a background track (caller must hold POSITION_LOCK)
|
|
165
|
-
# @param $1 Background file path
|
|
166
|
-
# @param $2 New position in seconds
|
|
167
|
-
save_background_position() {
|
|
168
|
-
local bg_file="$1"
|
|
169
|
-
local position="$2"
|
|
170
|
-
local bg_name
|
|
171
|
-
bg_name=$(basename "$bg_file")
|
|
172
|
-
|
|
173
|
-
mkdir -p "$(dirname "$POSITION_FILE")"
|
|
174
|
-
|
|
175
|
-
# Remove old entry and add new one (atomic update via temp file + mv)
|
|
176
|
-
local tmp_pos
|
|
177
|
-
tmp_pos=$(mktemp "${POSITION_FILE}.XXXXXX")
|
|
178
|
-
if [[ -f "$POSITION_FILE" ]]; then
|
|
179
|
-
# SECURITY: Use grep -F for fixed string matching (#134)
|
|
180
|
-
grep -vF "${bg_name}:" "$POSITION_FILE" > "$tmp_pos" 2>/dev/null || true
|
|
181
|
-
fi
|
|
182
|
-
echo "${bg_name}:${position}" >> "$tmp_pos"
|
|
183
|
-
mv "$tmp_pos" "$POSITION_FILE"
|
|
184
|
-
}
|
|
185
|
-
|
|
186
|
-
# @function mix_background
|
|
187
|
-
# @intent Mix background audio with voice at specified volume, continuing from last position
|
|
188
|
-
# @param $1 Voice file (foreground)
|
|
189
|
-
# @param $2 Background file
|
|
190
|
-
# @param $3 Background volume (0.0-1.0)
|
|
191
|
-
# @param $4 Output file
|
|
192
|
-
mix_background() {
|
|
193
|
-
local voice="$1"
|
|
194
|
-
local background="$2"
|
|
195
|
-
local volume="$3"
|
|
196
|
-
local output="$4"
|
|
197
|
-
|
|
198
|
-
if [[ -z "$background" ]] || [[ ! -f "$background" ]]; then
|
|
199
|
-
cp "$voice" "$output"
|
|
200
|
-
return 0
|
|
201
|
-
fi
|
|
202
|
-
|
|
203
|
-
if ! command -v ffmpeg &> /dev/null; then
|
|
204
|
-
echo "Warning: ffmpeg not installed, skipping background mix" >&2
|
|
205
|
-
cp "$voice" "$output"
|
|
206
|
-
return 0
|
|
207
|
-
fi
|
|
208
|
-
|
|
209
|
-
# Get voice duration
|
|
210
|
-
local duration
|
|
211
|
-
duration=$(ffprobe -v error -show_entries format=duration -of default=noprint_wrappers=1:nokey=1 "$voice" 2>/dev/null)
|
|
212
|
-
|
|
213
|
-
if [[ -z "$duration" ]]; then
|
|
214
|
-
cp "$voice" "$output"
|
|
215
|
-
return 0
|
|
216
|
-
fi
|
|
217
|
-
|
|
218
|
-
# Get background track duration
|
|
219
|
-
local bg_duration
|
|
220
|
-
bg_duration=$(ffprobe -v error -show_entries format=duration -of default=noprint_wrappers=1:nokey=1 "$background" 2>/dev/null)
|
|
221
|
-
bg_duration=${bg_duration:-0}
|
|
222
|
-
|
|
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.
|
|
226
|
-
local start_pos
|
|
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
|
|
245
|
-
fi
|
|
246
|
-
|
|
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
|
|
252
|
-
fi
|
|
253
|
-
|
|
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
|
|
260
|
-
|
|
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"
|
|
270
|
-
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"
|
|
275
|
-
|
|
276
|
-
# Mix: Seek to position in background, apply volume and fades
|
|
277
|
-
# Background fades in at start (0.3s), continues under speech, then fades out over 2s after speech ends
|
|
278
|
-
# -ss before -i seeks efficiently without decoding
|
|
279
|
-
local bg_fade_out_start
|
|
280
|
-
if command -v bc &> /dev/null; then
|
|
281
|
-
bg_fade_out_start=$(echo "$duration" | bc -l)
|
|
282
|
-
else
|
|
283
|
-
bg_fade_out_start="$duration"
|
|
284
|
-
fi
|
|
285
|
-
|
|
286
|
-
# Auto-detect remote sessions (SSH/RDP) and enable compression
|
|
287
|
-
if [[ -z "${AGENTVIBES_RDP_MODE:-}" ]]; then
|
|
288
|
-
if [[ -n "${SSH_CLIENT:-}" ]] || [[ -n "${SSH_TTY:-}" ]] || [[ "${DISPLAY:-}" =~ ^localhost:.* ]]; then
|
|
289
|
-
export AGENTVIBES_RDP_MODE=true
|
|
290
|
-
fi
|
|
291
|
-
fi
|
|
292
|
-
|
|
293
|
-
# RDP-optimized audio settings: mono 22kHz for lower bandwidth
|
|
294
|
-
# Automatically enabled for remote desktop/SSH environments
|
|
295
|
-
local audio_settings=""
|
|
296
|
-
if [[ "${AGENTVIBES_RDP_MODE:-false}" == "true" ]]; then
|
|
297
|
-
audio_settings="-ac 1 -ar 22050 -b:a 64k"
|
|
298
|
-
fi
|
|
299
|
-
|
|
300
|
-
# Add 2 seconds of background music intro before voice starts
|
|
301
|
-
# Background: fades in (0.3s), plays solo (2s), then voice joins, fades out at end (2s)
|
|
302
|
-
# Voice: delayed by 2000ms (2s), no fade-in (full volume from first word)
|
|
303
|
-
local voice_delay_ms="2000" # adelay takes milliseconds
|
|
304
|
-
local voice_delay_sec="2.0"
|
|
305
|
-
local bg_fade_out_adjusted
|
|
306
|
-
if command -v bc &> /dev/null; then
|
|
307
|
-
bg_fade_out_adjusted=$(echo "$duration + $voice_delay_sec" | bc -l)
|
|
308
|
-
else
|
|
309
|
-
bg_fade_out_adjusted=$(echo "$duration + 2" | bc)
|
|
310
|
-
fi
|
|
311
|
-
|
|
312
|
-
ffmpeg -y -i "$voice" -ss "$start_pos" -stream_loop -1 -i "$background" \
|
|
313
|
-
-filter_complex "[1:a]volume=${volume},afade=t=in:st=0:d=0.3,afade=t=out:st=${bg_fade_out_adjusted}:d=2[bg];[0:a]adelay=${voice_delay_ms}|${voice_delay_ms},volume=1.5[v];[v][bg]amix=inputs=2:duration=longest:normalize=0[out]" \
|
|
314
|
-
-map "[out]" $audio_settings -t "$total_duration" "$output" 2>/dev/null || {
|
|
315
|
-
echo "Warning: Background mixing failed, using voice only" >&2
|
|
316
|
-
cp "$voice" "$output"
|
|
317
|
-
return
|
|
318
|
-
}
|
|
319
|
-
}
|
|
320
|
-
|
|
321
|
-
# Main processing
|
|
322
|
-
main() {
|
|
323
|
-
echo "🎛️ Processing audio for agent: $AGENT_NAME" >&2
|
|
324
|
-
|
|
325
|
-
# Get agent config
|
|
326
|
-
local config
|
|
327
|
-
config=$(get_agent_config "$AGENT_NAME")
|
|
328
|
-
|
|
329
|
-
# Parse config (format: NAME|EFFECTS|BACKGROUND|VOLUME)
|
|
330
|
-
IFS='|' read -r _ sox_effects background_file bg_volume <<< "$config"
|
|
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.20"
|
|
347
|
-
fi
|
|
348
|
-
fi
|
|
349
|
-
fi
|
|
350
|
-
|
|
351
|
-
# SECURITY: Use secure temp directory per CLAUDE.md guidelines
|
|
352
|
-
# Prefer XDG_RUNTIME_DIR (user-owned, restricted permissions)
|
|
353
|
-
# Fall back to user-specific directory in /tmp
|
|
354
|
-
local TEMP_DIR
|
|
355
|
-
if [[ -d "/data/data/com.termux" ]]; then
|
|
356
|
-
# On Termux - use Termux temp
|
|
357
|
-
TEMP_DIR="${TMPDIR:-${PREFIX:-/data/data/com.termux/files/usr}/tmp}/agentvibes-audio-$$"
|
|
358
|
-
elif [[ -n "${XDG_RUNTIME_DIR:-}" ]] && [[ -d "$XDG_RUNTIME_DIR" ]]; then
|
|
359
|
-
# Preferred: XDG_RUNTIME_DIR (user-owned, 700 permissions)
|
|
360
|
-
TEMP_DIR="$XDG_RUNTIME_DIR/agentvibes-audio"
|
|
361
|
-
else
|
|
362
|
-
# Fallback: user-specific directory in /tmp
|
|
363
|
-
TEMP_DIR="/tmp/agentvibes-audio-${USER:-$(id -un)}"
|
|
364
|
-
fi
|
|
365
|
-
|
|
366
|
-
# Create temp directory with restrictive permissions
|
|
367
|
-
mkdir -p "$TEMP_DIR"
|
|
368
|
-
chmod 700 "$TEMP_DIR"
|
|
369
|
-
|
|
370
|
-
# SECURITY: Verify ownership of temp directory
|
|
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
|
|
375
|
-
echo "Error: Temp directory not owned by current user: $TEMP_DIR" >&2
|
|
376
|
-
exit 1
|
|
377
|
-
fi
|
|
378
|
-
|
|
379
|
-
# SECURITY: Use mktemp for unpredictable filenames
|
|
380
|
-
local temp_effects
|
|
381
|
-
local temp_final
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
# Clean up on exit - use a cleanup function to avoid trap injection
|
|
386
|
-
_cleanup_effects="$temp_effects"
|
|
387
|
-
_cleanup_final="$temp_final"
|
|
388
|
-
cleanup() { rm -f "$_cleanup_effects" "$_cleanup_final"; }
|
|
389
|
-
trap cleanup EXIT
|
|
390
|
-
|
|
391
|
-
# Step 1: Apply sox effects
|
|
392
|
-
if [[ -n "$sox_effects" ]]; then
|
|
393
|
-
echo " → Applying effects: $sox_effects" >&2
|
|
394
|
-
apply_sox_effects "$INPUT_FILE" "$temp_effects" "$sox_effects"
|
|
395
|
-
else
|
|
396
|
-
cp "$INPUT_FILE" "$temp_effects"
|
|
397
|
-
fi
|
|
398
|
-
|
|
399
|
-
# Step 2: Mix background if configured AND enabled
|
|
400
|
-
local background_path=""
|
|
401
|
-
if [[ -n "$background_file" ]]; then
|
|
402
|
-
background_path="$BACKGROUNDS_DIR/$background_file"
|
|
403
|
-
fi
|
|
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
|
-
|
|
416
|
-
local used_background=""
|
|
417
|
-
if [[ "$_bg_allowed" == "true" ]] && [[ -n "$background_path" ]] && [[ -f "$background_path" ]] && [[ "${bg_volume:-0}" != "0" ]] && [[ "${bg_volume:-0}" != "0.0" ]]; then
|
|
418
|
-
echo " → Mixing background: $background_file at ${bg_volume} volume" >&2
|
|
419
|
-
mix_background "$temp_effects" "$background_path" "$bg_volume" "$temp_final"
|
|
420
|
-
used_background="$background_path" # Return full path instead of just filename
|
|
421
|
-
else
|
|
422
|
-
cp "$temp_effects" "$temp_final"
|
|
423
|
-
fi
|
|
424
|
-
|
|
425
|
-
# Move to final output
|
|
426
|
-
mv "$temp_final" "$OUTPUT_FILE"
|
|
427
|
-
|
|
428
|
-
# Return the output file path (stdout for caller to capture)
|
|
429
|
-
# Format: OUTPUT_FILE|BACKGROUND_FILE_PATH (background is empty if not used)
|
|
430
|
-
echo "$OUTPUT_FILE|$used_background"
|
|
431
|
-
}
|
|
432
|
-
|
|
433
|
-
main
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
#
|
|
3
|
+
# File: .claude/hooks/audio-processor.sh
|
|
4
|
+
#
|
|
5
|
+
# AgentVibes - Audio Effects and Background Mixing Processor
|
|
6
|
+
# Website: https://agentvibes.org
|
|
7
|
+
# Repository: https://github.com/paulpreibisch/AgentVibes
|
|
8
|
+
#
|
|
9
|
+
# Co-created by Paul Preibisch with Claude AI
|
|
10
|
+
# Copyright (c) 2025 Paul Preibisch
|
|
11
|
+
#
|
|
12
|
+
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
13
|
+
# you may not use this file except in compliance with the License.
|
|
14
|
+
#
|
|
15
|
+
# ---
|
|
16
|
+
#
|
|
17
|
+
# @fileoverview Audio post-processor for TTS with effects and background mixing
|
|
18
|
+
# @context Applies sox effects and mixes background audio for enhanced TTS experience
|
|
19
|
+
# @architecture Post-processing hook called after TTS generation, before playback
|
|
20
|
+
# @dependencies sox, ffmpeg
|
|
21
|
+
# @entrypoints Called by play-tts-piper.sh after audio generation
|
|
22
|
+
# @patterns Pipeline pattern: input.wav → effects → mix → output.wav
|
|
23
|
+
#
|
|
24
|
+
|
|
25
|
+
set -euo pipefail
|
|
26
|
+
|
|
27
|
+
# Fix locale warnings
|
|
28
|
+
export LC_ALL=C
|
|
29
|
+
|
|
30
|
+
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
31
|
+
PROJECT_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"
|
|
32
|
+
|
|
33
|
+
# Input parameters
|
|
34
|
+
INPUT_FILE="${1:-}"
|
|
35
|
+
AGENT_NAME="${2:-default}"
|
|
36
|
+
OUTPUT_FILE="${3:-}"
|
|
37
|
+
AGENT_PROFILE_FILE="${4:-}" # Optional: path to per-agent profile JSON (from bmad-speak.sh)
|
|
38
|
+
|
|
39
|
+
# Config and directories (resolve to absolute paths)
|
|
40
|
+
CONFIG_FILE="$(cd "$SCRIPT_DIR/.." && pwd)/config/audio-effects.cfg"
|
|
41
|
+
BACKGROUNDS_DIR="$(cd "$SCRIPT_DIR/../audio" && pwd)/tracks"
|
|
42
|
+
ENABLED_FILE="$(cd "$SCRIPT_DIR/.." && pwd)/config/background-music-enabled.txt"
|
|
43
|
+
GLOBAL_ENABLED_FILE="$HOME/.claude/config/background-music-enabled.txt"
|
|
44
|
+
|
|
45
|
+
# Check if background music is enabled (project-local, then global fallback)
|
|
46
|
+
is_background_music_enabled() {
|
|
47
|
+
local enabled=""
|
|
48
|
+
if [[ -f "$ENABLED_FILE" ]]; then
|
|
49
|
+
enabled=$(cat "$ENABLED_FILE" 2>/dev/null | tr -d '[:space:]')
|
|
50
|
+
elif [[ -f "$GLOBAL_ENABLED_FILE" ]]; then
|
|
51
|
+
enabled=$(cat "$GLOBAL_ENABLED_FILE" 2>/dev/null | tr -d '[:space:]')
|
|
52
|
+
else
|
|
53
|
+
return 1 # Disabled by default
|
|
54
|
+
fi
|
|
55
|
+
|
|
56
|
+
# Return 0 (true) if enabled, 1 (false) otherwise
|
|
57
|
+
[[ "$enabled" == "true" ]]
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
# Validate inputs
|
|
61
|
+
if [[ -z "$INPUT_FILE" ]] || [[ ! -f "$INPUT_FILE" ]]; then
|
|
62
|
+
echo "Error: Input file required and must exist" >&2
|
|
63
|
+
echo "Usage: $0 <input.wav> [agent_name] [output.wav]" >&2
|
|
64
|
+
exit 1
|
|
65
|
+
fi
|
|
66
|
+
|
|
67
|
+
# Default output to input location with -processed suffix
|
|
68
|
+
if [[ -z "$OUTPUT_FILE" ]]; then
|
|
69
|
+
OUTPUT_FILE="${INPUT_FILE%.wav}-processed.wav"
|
|
70
|
+
fi
|
|
71
|
+
|
|
72
|
+
# Check for required tools
|
|
73
|
+
if ! command -v sox &> /dev/null; then
|
|
74
|
+
echo "Warning: sox not installed, skipping effects" >&2
|
|
75
|
+
cp "$INPUT_FILE" "$OUTPUT_FILE"
|
|
76
|
+
echo "$OUTPUT_FILE"
|
|
77
|
+
exit 0
|
|
78
|
+
fi
|
|
79
|
+
|
|
80
|
+
# @function get_agent_config
|
|
81
|
+
# @intent Parse audio-effects.cfg for agent-specific settings
|
|
82
|
+
# @param $1 Agent name
|
|
83
|
+
# @returns Pipe-separated config line or default
|
|
84
|
+
get_agent_config() {
|
|
85
|
+
local agent="$1"
|
|
86
|
+
|
|
87
|
+
if [[ ! -f "$CONFIG_FILE" ]]; then
|
|
88
|
+
echo "default|gain -8||0.0"
|
|
89
|
+
return
|
|
90
|
+
fi
|
|
91
|
+
|
|
92
|
+
# Try exact match first (use awk for safe literal matching)
|
|
93
|
+
local config
|
|
94
|
+
config=$(awk -F'|' -v agent="$agent" 'tolower($1) == tolower(agent)' "$CONFIG_FILE" 2>/dev/null | head -1)
|
|
95
|
+
|
|
96
|
+
# Fall back to default
|
|
97
|
+
if [[ -z "$config" ]]; then
|
|
98
|
+
config=$(grep "^default|" "$CONFIG_FILE" 2>/dev/null | head -1)
|
|
99
|
+
fi
|
|
100
|
+
|
|
101
|
+
# Return config or empty default
|
|
102
|
+
if [[ -n "$config" ]]; then
|
|
103
|
+
echo "$config"
|
|
104
|
+
else
|
|
105
|
+
echo "default|gain -8||0.0"
|
|
106
|
+
fi
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
# @function apply_sox_effects
|
|
110
|
+
# @intent Apply sox effect chain to audio file
|
|
111
|
+
# @param $1 Input file
|
|
112
|
+
# @param $2 Output file
|
|
113
|
+
# @param $3 Sox effects string
|
|
114
|
+
apply_sox_effects() {
|
|
115
|
+
local input="$1"
|
|
116
|
+
local output="$2"
|
|
117
|
+
local effects="$3"
|
|
118
|
+
|
|
119
|
+
if [[ -z "$effects" ]]; then
|
|
120
|
+
cp "$input" "$output"
|
|
121
|
+
return 0
|
|
122
|
+
fi
|
|
123
|
+
|
|
124
|
+
# Validate effects contain only allowed sox effect names and numeric params
|
|
125
|
+
local allowed_effects="gain|reverb|echo|chorus|flanger|phaser|tremolo|overdrive|bass|treble|equalizer|highpass|lowpass|bandpass|vol|speed|tempo|pitch|rate|pad|silence|trim|fade|norm|loudness|compand|contrast|delay|repeat|stat|remix"
|
|
126
|
+
for word in $effects; do
|
|
127
|
+
if ! [[ "$word" =~ ^-?[0-9]*\.?[0-9]+$ ]] && ! echo "$word" | grep -qiE "^($allowed_effects)$"; then
|
|
128
|
+
echo "Warning: Invalid sox effect '$word', skipping effects" >&2
|
|
129
|
+
cp "$input" "$output"
|
|
130
|
+
return 0
|
|
131
|
+
fi
|
|
132
|
+
done
|
|
133
|
+
|
|
134
|
+
# Apply effects - note: effects string is intentionally unquoted to allow word splitting
|
|
135
|
+
# shellcheck disable=SC2086
|
|
136
|
+
sox "$input" "$output" $effects 2>/dev/null || {
|
|
137
|
+
echo "Warning: Sox effects failed, using original" >&2
|
|
138
|
+
cp "$input" "$output"
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
# Position tracking file for continuous playback
|
|
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"
|
|
146
|
+
|
|
147
|
+
# @function get_background_position
|
|
148
|
+
# @intent Get saved position for a background track (caller must hold POSITION_LOCK)
|
|
149
|
+
# @param $1 Background file path
|
|
150
|
+
# @returns Position in seconds (or 0 if not found)
|
|
151
|
+
get_background_position() {
|
|
152
|
+
local bg_file="$1"
|
|
153
|
+
local bg_name
|
|
154
|
+
bg_name=$(basename "$bg_file")
|
|
155
|
+
|
|
156
|
+
if [[ -f "$POSITION_FILE" ]]; then
|
|
157
|
+
awk -F: -v name="$bg_name" '$1 == name {print $2}' "$POSITION_FILE" 2>/dev/null | tr -d '[:space:]' | tail -1
|
|
158
|
+
else
|
|
159
|
+
echo "0"
|
|
160
|
+
fi
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
# @function save_background_position
|
|
164
|
+
# @intent Save position for a background track (caller must hold POSITION_LOCK)
|
|
165
|
+
# @param $1 Background file path
|
|
166
|
+
# @param $2 New position in seconds
|
|
167
|
+
save_background_position() {
|
|
168
|
+
local bg_file="$1"
|
|
169
|
+
local position="$2"
|
|
170
|
+
local bg_name
|
|
171
|
+
bg_name=$(basename "$bg_file")
|
|
172
|
+
|
|
173
|
+
mkdir -p "$(dirname "$POSITION_FILE")"
|
|
174
|
+
|
|
175
|
+
# Remove old entry and add new one (atomic update via temp file + mv)
|
|
176
|
+
local tmp_pos
|
|
177
|
+
tmp_pos=$(mktemp "${POSITION_FILE}.XXXXXX")
|
|
178
|
+
if [[ -f "$POSITION_FILE" ]]; then
|
|
179
|
+
# SECURITY: Use grep -F for fixed string matching (#134)
|
|
180
|
+
grep -vF "${bg_name}:" "$POSITION_FILE" > "$tmp_pos" 2>/dev/null || true
|
|
181
|
+
fi
|
|
182
|
+
echo "${bg_name}:${position}" >> "$tmp_pos"
|
|
183
|
+
mv "$tmp_pos" "$POSITION_FILE"
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
# @function mix_background
|
|
187
|
+
# @intent Mix background audio with voice at specified volume, continuing from last position
|
|
188
|
+
# @param $1 Voice file (foreground)
|
|
189
|
+
# @param $2 Background file
|
|
190
|
+
# @param $3 Background volume (0.0-1.0)
|
|
191
|
+
# @param $4 Output file
|
|
192
|
+
mix_background() {
|
|
193
|
+
local voice="$1"
|
|
194
|
+
local background="$2"
|
|
195
|
+
local volume="$3"
|
|
196
|
+
local output="$4"
|
|
197
|
+
|
|
198
|
+
if [[ -z "$background" ]] || [[ ! -f "$background" ]]; then
|
|
199
|
+
cp "$voice" "$output"
|
|
200
|
+
return 0
|
|
201
|
+
fi
|
|
202
|
+
|
|
203
|
+
if ! command -v ffmpeg &> /dev/null; then
|
|
204
|
+
echo "Warning: ffmpeg not installed, skipping background mix" >&2
|
|
205
|
+
cp "$voice" "$output"
|
|
206
|
+
return 0
|
|
207
|
+
fi
|
|
208
|
+
|
|
209
|
+
# Get voice duration
|
|
210
|
+
local duration
|
|
211
|
+
duration=$(ffprobe -v error -show_entries format=duration -of default=noprint_wrappers=1:nokey=1 "$voice" 2>/dev/null)
|
|
212
|
+
|
|
213
|
+
if [[ -z "$duration" ]]; then
|
|
214
|
+
cp "$voice" "$output"
|
|
215
|
+
return 0
|
|
216
|
+
fi
|
|
217
|
+
|
|
218
|
+
# Get background track duration
|
|
219
|
+
local bg_duration
|
|
220
|
+
bg_duration=$(ffprobe -v error -show_entries format=duration -of default=noprint_wrappers=1:nokey=1 "$background" 2>/dev/null)
|
|
221
|
+
bg_duration=${bg_duration:-0}
|
|
222
|
+
|
|
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.
|
|
226
|
+
local start_pos
|
|
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
|
|
245
|
+
fi
|
|
246
|
+
|
|
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
|
|
252
|
+
fi
|
|
253
|
+
|
|
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
|
|
260
|
+
|
|
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"
|
|
270
|
+
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"
|
|
275
|
+
|
|
276
|
+
# Mix: Seek to position in background, apply volume and fades
|
|
277
|
+
# Background fades in at start (0.3s), continues under speech, then fades out over 2s after speech ends
|
|
278
|
+
# -ss before -i seeks efficiently without decoding
|
|
279
|
+
local bg_fade_out_start
|
|
280
|
+
if command -v bc &> /dev/null; then
|
|
281
|
+
bg_fade_out_start=$(echo "$duration" | bc -l)
|
|
282
|
+
else
|
|
283
|
+
bg_fade_out_start="$duration"
|
|
284
|
+
fi
|
|
285
|
+
|
|
286
|
+
# Auto-detect remote sessions (SSH/RDP) and enable compression
|
|
287
|
+
if [[ -z "${AGENTVIBES_RDP_MODE:-}" ]]; then
|
|
288
|
+
if [[ -n "${SSH_CLIENT:-}" ]] || [[ -n "${SSH_TTY:-}" ]] || [[ "${DISPLAY:-}" =~ ^localhost:.* ]]; then
|
|
289
|
+
export AGENTVIBES_RDP_MODE=true
|
|
290
|
+
fi
|
|
291
|
+
fi
|
|
292
|
+
|
|
293
|
+
# RDP-optimized audio settings: mono 22kHz for lower bandwidth
|
|
294
|
+
# Automatically enabled for remote desktop/SSH environments
|
|
295
|
+
local audio_settings=""
|
|
296
|
+
if [[ "${AGENTVIBES_RDP_MODE:-false}" == "true" ]]; then
|
|
297
|
+
audio_settings="-ac 1 -ar 22050 -b:a 64k"
|
|
298
|
+
fi
|
|
299
|
+
|
|
300
|
+
# Add 2 seconds of background music intro before voice starts
|
|
301
|
+
# Background: fades in (0.3s), plays solo (2s), then voice joins, fades out at end (2s)
|
|
302
|
+
# Voice: delayed by 2000ms (2s), no fade-in (full volume from first word)
|
|
303
|
+
local voice_delay_ms="2000" # adelay takes milliseconds
|
|
304
|
+
local voice_delay_sec="2.0"
|
|
305
|
+
local bg_fade_out_adjusted
|
|
306
|
+
if command -v bc &> /dev/null; then
|
|
307
|
+
bg_fade_out_adjusted=$(echo "$duration + $voice_delay_sec" | bc -l)
|
|
308
|
+
else
|
|
309
|
+
bg_fade_out_adjusted=$(echo "$duration + 2" | bc)
|
|
310
|
+
fi
|
|
311
|
+
|
|
312
|
+
ffmpeg -y -i "$voice" -ss "$start_pos" -stream_loop -1 -i "$background" \
|
|
313
|
+
-filter_complex "[1:a]volume=${volume},afade=t=in:st=0:d=0.3,afade=t=out:st=${bg_fade_out_adjusted}:d=2[bg];[0:a]adelay=${voice_delay_ms}|${voice_delay_ms},volume=1.5[v];[v][bg]amix=inputs=2:duration=longest:normalize=0[out]" \
|
|
314
|
+
-map "[out]" $audio_settings -t "$total_duration" "$output" 2>/dev/null || {
|
|
315
|
+
echo "Warning: Background mixing failed, using voice only" >&2
|
|
316
|
+
cp "$voice" "$output"
|
|
317
|
+
return
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
# Main processing
|
|
322
|
+
main() {
|
|
323
|
+
echo "🎛️ Processing audio for agent: $AGENT_NAME" >&2
|
|
324
|
+
|
|
325
|
+
# Get agent config
|
|
326
|
+
local config
|
|
327
|
+
config=$(get_agent_config "$AGENT_NAME")
|
|
328
|
+
|
|
329
|
+
# Parse config (format: NAME|EFFECTS|BACKGROUND|VOLUME)
|
|
330
|
+
IFS='|' read -r _ sox_effects background_file bg_volume <<< "$config"
|
|
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.20"
|
|
347
|
+
fi
|
|
348
|
+
fi
|
|
349
|
+
fi
|
|
350
|
+
|
|
351
|
+
# SECURITY: Use secure temp directory per CLAUDE.md guidelines
|
|
352
|
+
# Prefer XDG_RUNTIME_DIR (user-owned, restricted permissions)
|
|
353
|
+
# Fall back to user-specific directory in /tmp
|
|
354
|
+
local TEMP_DIR
|
|
355
|
+
if [[ -d "/data/data/com.termux" ]]; then
|
|
356
|
+
# On Termux - use Termux temp
|
|
357
|
+
TEMP_DIR="${TMPDIR:-${PREFIX:-/data/data/com.termux/files/usr}/tmp}/agentvibes-audio-$$"
|
|
358
|
+
elif [[ -n "${XDG_RUNTIME_DIR:-}" ]] && [[ -d "$XDG_RUNTIME_DIR" ]]; then
|
|
359
|
+
# Preferred: XDG_RUNTIME_DIR (user-owned, 700 permissions)
|
|
360
|
+
TEMP_DIR="$XDG_RUNTIME_DIR/agentvibes-audio"
|
|
361
|
+
else
|
|
362
|
+
# Fallback: user-specific directory in /tmp
|
|
363
|
+
TEMP_DIR="/tmp/agentvibes-audio-${USER:-$(id -un)}"
|
|
364
|
+
fi
|
|
365
|
+
|
|
366
|
+
# Create temp directory with restrictive permissions
|
|
367
|
+
mkdir -p "$TEMP_DIR"
|
|
368
|
+
chmod 700 "$TEMP_DIR"
|
|
369
|
+
|
|
370
|
+
# SECURITY: Verify ownership of temp directory
|
|
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
|
|
375
|
+
echo "Error: Temp directory not owned by current user: $TEMP_DIR" >&2
|
|
376
|
+
exit 1
|
|
377
|
+
fi
|
|
378
|
+
|
|
379
|
+
# SECURITY: Use mktemp for unpredictable filenames
|
|
380
|
+
local temp_effects
|
|
381
|
+
local temp_final
|
|
382
|
+
_tmp=$(mktemp "$TEMP_DIR/effects-XXXXXX"); temp_effects="${_tmp}.wav"; mv "$_tmp" "$temp_effects"
|
|
383
|
+
_tmp=$(mktemp "$TEMP_DIR/final-XXXXXX"); temp_final="${_tmp}.wav"; mv "$_tmp" "$temp_final"
|
|
384
|
+
|
|
385
|
+
# Clean up on exit - use a cleanup function to avoid trap injection
|
|
386
|
+
_cleanup_effects="$temp_effects"
|
|
387
|
+
_cleanup_final="$temp_final"
|
|
388
|
+
cleanup() { rm -f "$_cleanup_effects" "$_cleanup_final"; }
|
|
389
|
+
trap cleanup EXIT
|
|
390
|
+
|
|
391
|
+
# Step 1: Apply sox effects
|
|
392
|
+
if [[ -n "$sox_effects" ]]; then
|
|
393
|
+
echo " → Applying effects: $sox_effects" >&2
|
|
394
|
+
apply_sox_effects "$INPUT_FILE" "$temp_effects" "$sox_effects"
|
|
395
|
+
else
|
|
396
|
+
cp "$INPUT_FILE" "$temp_effects"
|
|
397
|
+
fi
|
|
398
|
+
|
|
399
|
+
# Step 2: Mix background if configured AND enabled
|
|
400
|
+
local background_path=""
|
|
401
|
+
if [[ -n "$background_file" ]]; then
|
|
402
|
+
background_path="$BACKGROUNDS_DIR/$background_file"
|
|
403
|
+
fi
|
|
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
|
+
|
|
416
|
+
local used_background=""
|
|
417
|
+
if [[ "$_bg_allowed" == "true" ]] && [[ -n "$background_path" ]] && [[ -f "$background_path" ]] && [[ "${bg_volume:-0}" != "0" ]] && [[ "${bg_volume:-0}" != "0.0" ]]; then
|
|
418
|
+
echo " → Mixing background: $background_file at ${bg_volume} volume" >&2
|
|
419
|
+
mix_background "$temp_effects" "$background_path" "$bg_volume" "$temp_final"
|
|
420
|
+
used_background="$background_path" # Return full path instead of just filename
|
|
421
|
+
else
|
|
422
|
+
cp "$temp_effects" "$temp_final"
|
|
423
|
+
fi
|
|
424
|
+
|
|
425
|
+
# Move to final output
|
|
426
|
+
mv "$temp_final" "$OUTPUT_FILE"
|
|
427
|
+
|
|
428
|
+
# Return the output file path (stdout for caller to capture)
|
|
429
|
+
# Format: OUTPUT_FILE|BACKGROUND_FILE_PATH (background is empty if not used)
|
|
430
|
+
echo "$OUTPUT_FILE|$used_background"
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
main
|