agentvibes 4.6.2 → 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.
@@ -1,368 +1,368 @@
1
- #!/usr/bin/env bash
2
- #
3
- # File: .claude/hooks/play-tts-macos.sh
4
- #
5
- # AgentVibes - Finally, your AI Agents can Talk Back! Text-to-Speech WITH personality for AI Assistants!
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
- # You may obtain a copy of the License at
15
- #
16
- # http://www.apache.org/licenses/LICENSE-2.0
17
- #
18
- # Unless required by applicable law or agreed to in writing, software
19
- # distributed under the License is distributed on an "AS IS" BASIS,
20
- # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
21
- # See the License for the specific language governing permissions and
22
- # limitations under the License.
23
- #
24
- # DISCLAIMER: This software is provided "AS IS", WITHOUT WARRANTY OF ANY KIND,
25
- # express or implied. Use at your own risk. See the Apache License for details.
26
- #
27
- # ---
28
- #
29
- # @fileoverview macOS Say Provider Implementation - Native macOS TTS using the 'say' command
30
- # @context Provides zero-dependency, offline TTS for macOS users using built-in Apple voices
31
- # @architecture Implements provider interface contract for macOS 'say' command integration
32
- # @dependencies macOS only (Darwin), say command (built-in), afplay (built-in)
33
- # @entrypoints Called by play-tts.sh router when provider=macos
34
- # @patterns Provider contract: text/voice → audio file path, voice validation, platform guard
35
- # @related play-tts.sh, macos-voice-manager.sh, provider-manager.sh
36
- #
37
-
38
- # Platform guard - fail fast on non-macOS systems
39
- if [[ "$(uname -s)" != "Darwin" ]]; then
40
- echo "❌ Error: macOS provider only works on macOS"
41
- echo " Current platform: $(uname -s)"
42
- echo ""
43
- echo " Switch to a different provider:"
44
- echo " /agent-vibes:provider switch piper"
45
- exit 1
46
- fi
47
-
48
- TEXT="$1"
49
- VOICE_OVERRIDE="$2" # Optional: voice name (e.g., "Samantha", "Daniel")
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
-
62
- SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
63
-
64
- # Source audio cache utilities
65
- source "$SCRIPT_DIR/audio-cache-utils.sh"
66
-
67
- # Default voice for macOS
68
- DEFAULT_VOICE="Samantha"
69
-
70
- # Common macOS voices with descriptions
71
- # These are typically available on all macOS systems
72
- # Using simple list format for bash 3.x compatibility (macOS default)
73
- show_common_voices() {
74
- echo " Alex - American English male (enhanced)"
75
- echo " Daniel - British English male (enhanced)"
76
- echo " Fiona - Scottish English female (enhanced)"
77
- echo " Karen - Australian English female (enhanced)"
78
- echo " Moira - Irish English female (enhanced)"
79
- echo " Samantha - American English female (enhanced)"
80
- echo " Tessa - South African English female (enhanced)"
81
- echo " Veena - Indian English female (enhanced)"
82
- echo " Victoria - American English female"
83
- }
84
-
85
- # @function get_voice_file_path
86
- # @intent Determine path to voice configuration file
87
- # @returns Echoes path to tts-voice.txt
88
- get_voice_file_path() {
89
- local voice_file=""
90
-
91
- if [[ -n "$CLAUDE_PROJECT_DIR" ]] && [[ -f "$CLAUDE_PROJECT_DIR/.claude/tts-voice.txt" ]]; then
92
- voice_file="$CLAUDE_PROJECT_DIR/.claude/tts-voice.txt"
93
- elif [[ -f "$SCRIPT_DIR/../tts-voice.txt" ]]; then
94
- voice_file="$SCRIPT_DIR/../tts-voice.txt"
95
- elif [[ -f "$HOME/.claude/tts-voice.txt" ]]; then
96
- voice_file="$HOME/.claude/tts-voice.txt"
97
- fi
98
-
99
- echo "$voice_file"
100
- }
101
-
102
- # @function determine_voice
103
- # @intent Resolve which voice to use
104
- # @returns Sets $VOICE_NAME global variable
105
- VOICE_NAME=""
106
-
107
- if [[ -n "$VOICE_OVERRIDE" ]]; then
108
- VOICE_NAME="$VOICE_OVERRIDE"
109
- echo "🎤 Using voice: $VOICE_OVERRIDE (session-specific)"
110
- else
111
- VOICE_FILE=$(get_voice_file_path)
112
-
113
- if [[ -n "$VOICE_FILE" ]] && [[ -f "$VOICE_FILE" ]]; then
114
- FILE_VOICE=$(cat "$VOICE_FILE" 2>/dev/null | tr -d '\n\r')
115
- if [[ -n "$FILE_VOICE" ]]; then
116
- VOICE_NAME="$FILE_VOICE"
117
- fi
118
- fi
119
-
120
- # Fallback to default if no voice set
121
- if [[ -z "$VOICE_NAME" ]]; then
122
- VOICE_NAME="$DEFAULT_VOICE"
123
- fi
124
- fi
125
-
126
- # @function validate_inputs
127
- # @intent Check required parameters
128
- if [[ -z "$TEXT" ]]; then
129
- echo "Usage: $0 \"text to speak\" [voice_name]"
130
- echo ""
131
- echo "Common voices (run 'say -v ?' for full list):"
132
- show_common_voices
133
- exit 1
134
- fi
135
-
136
- # @function validate_voice
137
- # @intent Check if the specified voice exists on this system
138
- # @param $1 Voice name to validate
139
- # @returns 0 if valid, 1 if not found
140
- validate_voice() {
141
- local voice="$1"
142
- say -v ? 2>/dev/null | grep -qi "^${voice} "
143
- }
144
-
145
- # Validate voice exists (case-insensitive search)
146
- if ! validate_voice "$VOICE_NAME"; then
147
- echo "⚠️ Voice '$VOICE_NAME' not found on this system"
148
- echo " Falling back to default: $DEFAULT_VOICE"
149
- VOICE_NAME="$DEFAULT_VOICE"
150
-
151
- # If default also doesn't exist, try to find any English voice
152
- if ! validate_voice "$VOICE_NAME"; then
153
- VOICE_NAME=$(say -v ? 2>/dev/null | grep -i "en_" | head -1 | awk '{print $1}')
154
- if [[ -z "$VOICE_NAME" ]]; then
155
- echo "❌ No English voices found on this system"
156
- exit 2
157
- fi
158
- echo " Using first available English voice: $VOICE_NAME"
159
- fi
160
- fi
161
-
162
- # @function determine_audio_directory
163
- # @intent Find appropriate directory for audio file storage
164
- if [[ -n "$CLAUDE_PROJECT_DIR" ]]; then
165
- AUDIO_DIR="$CLAUDE_PROJECT_DIR/.claude/audio"
166
- else
167
- CURRENT_DIR="$PWD"
168
- while [[ "$CURRENT_DIR" != "/" ]]; do
169
- if [[ -d "$CURRENT_DIR/.claude" ]]; then
170
- AUDIO_DIR="$CURRENT_DIR/.claude/audio"
171
- break
172
- fi
173
- CURRENT_DIR=$(dirname "$CURRENT_DIR")
174
- done
175
- if [[ -z "$AUDIO_DIR" ]]; then
176
- AUDIO_DIR="$HOME/.claude/audio"
177
- fi
178
- fi
179
-
180
- mkdir -p "$AUDIO_DIR"
181
-
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")
185
-
186
- # @function get_speech_rate
187
- # @intent Determine speech rate for synthesis
188
- # @returns Speech rate value (words per minute, default ~175-200)
189
- get_speech_rate() {
190
- local rate_config=""
191
-
192
- # Check for rate config file
193
- if [[ -f "$SCRIPT_DIR/../config/tts-speech-rate.txt" ]]; then
194
- rate_config="$SCRIPT_DIR/../config/tts-speech-rate.txt"
195
- elif [[ -f "$HOME/.claude/config/tts-speech-rate.txt" ]]; then
196
- rate_config="$HOME/.claude/config/tts-speech-rate.txt"
197
- fi
198
-
199
- if [[ -n "$rate_config" ]]; then
200
- local user_speed=$(cat "$rate_config" 2>/dev/null | grep -v '^#' | grep -v '^$' | tail -1)
201
- # Convert multiplier to words per minute (base ~200 WPM)
202
- # User: 0.5=slower, 1.0=normal, 2.0=faster
203
- echo "scale=0; 200 * $user_speed / 1" | bc -l 2>/dev/null || echo "200"
204
- return
205
- fi
206
-
207
- # Default: 200 WPM (normal rate)
208
- echo "200"
209
- }
210
-
211
- SPEECH_RATE=$(get_speech_rate)
212
-
213
- # @function synthesize_with_say
214
- # @intent Generate speech using macOS 'say' command
215
- # @returns Creates audio file at $TEMP_FILE
216
- echo "$TEXT" | say -v "$VOICE_NAME" -r "$SPEECH_RATE" -o "$TEMP_FILE" 2>/dev/null
217
-
218
- if [[ ! -f "$TEMP_FILE" ]] || [[ ! -s "$TEMP_FILE" ]]; then
219
- echo "❌ Failed to synthesize speech with macOS say command"
220
- echo "Voice: $VOICE_NAME"
221
- exit 3
222
- fi
223
-
224
- # @function convert_and_pad_audio
225
- # @intent Convert AIFF to WAV and add silence padding for consistency
226
- # @why Maintains consistent audio format across providers
227
- if command -v ffmpeg &> /dev/null; then
228
- # Add 200ms of silence at the beginning and convert to WAV
229
- ffmpeg -f lavfi -i anullsrc=r=44100:cl=stereo:d=0.2 -i "$TEMP_FILE" \
230
- -filter_complex "[0:a][1:a]concat=n=2:v=0:a=1[out]" \
231
- -map "[out]" -y "$FINAL_FILE" 2>/dev/null
232
-
233
- if [[ -f "$FINAL_FILE" ]]; then
234
- rm -f "$TEMP_FILE"
235
- TEMP_FILE="$FINAL_FILE"
236
- fi
237
- else
238
- # No ffmpeg - use AIFF directly (rename for consistency)
239
- FINAL_FILE=$(mktemp "$AUDIO_DIR/tts-padded-XXXXXX.aiff")
240
- mv "$TEMP_FILE" "$FINAL_FILE"
241
- TEMP_FILE="$FINAL_FILE"
242
- fi
243
-
244
- # @function play_audio
245
- # @intent Play generated audio - via PulseAudio tunnel for SSH, afplay for local
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
260
-
261
- # Wait for previous audio to finish (max 30 seconds)
262
- for i in {1..60}; do
263
- if [ ! -f "$LOCK_FILE" ]; then
264
- break
265
- fi
266
- sleep 0.5
267
- done
268
-
269
- # Create lock and play audio
270
- touch "$LOCK_FILE"
271
-
272
- # Get audio duration for proper lock timing
273
- if command -v ffprobe &> /dev/null; then
274
- DURATION=$(ffprobe -v error -show_entries format=duration -of default=noprint_wrappers=1:nokey=1 "$TEMP_FILE" 2>/dev/null)
275
- DURATION=${DURATION%.*} # Round to integer
276
- else
277
- # Estimate duration based on text length (~150 WPM)
278
- WORD_COUNT=$(echo "$TEXT" | wc -w)
279
- DURATION=$(( (WORD_COUNT * 60 / 150) + 1 ))
280
- fi
281
- DURATION=${DURATION:-2} # Default to 2 seconds if detection fails
282
-
283
- # Play audio in background (skip if in test mode or no-playback mode)
284
- # AGENTVIBES_NO_PLAYBACK: Set to "true" to generate audio without playing (for post-processing)
285
- if [[ "${AGENTVIBES_TEST_MODE:-false}" != "true" ]] && [[ "${AGENTVIBES_NO_PLAYBACK:-false}" != "true" ]]; then
286
- # Check if we're in an SSH session with PulseAudio tunnel available
287
- if [[ -n "$SSH_CONNECTION" ]] && [[ -n "$PULSE_SERVER" ]]; then
288
- # Use paplay to send audio through PulseAudio tunnel to remote machine
289
- if command -v /opt/homebrew/bin/paplay &> /dev/null; then
290
- /opt/homebrew/bin/paplay "$TEMP_FILE" >/dev/null 2>&1 &
291
- PLAYER_PID=$!
292
- echo "🔊 Playing via PulseAudio tunnel"
293
- else
294
- echo "⚠️ paplay not found - install pulseaudio for SSH audio"
295
- afplay "$TEMP_FILE" >/dev/null 2>&1 &
296
- PLAYER_PID=$!
297
- fi
298
- else
299
- # Local session - use native macOS player
300
- afplay "$TEMP_FILE" >/dev/null 2>&1 &
301
- PLAYER_PID=$!
302
- fi
303
- fi
304
-
305
- # Wait for audio to finish, then release lock
306
- (sleep $DURATION; rm -f "$LOCK_FILE") &
307
- disown
308
-
309
- # Get audio cache stats
310
- AUDIO_DIR_PATH=$(get_audio_dir)
311
- FILE_COUNT=$(count_tts_files "$AUDIO_DIR_PATH")
312
- SIZE_BYTES=$(calculate_tts_size_bytes "$AUDIO_DIR_PATH")
313
- SIZE_HUMAN=$(bytes_to_human "$SIZE_BYTES")
314
-
315
- # Color codes
316
- BLUE='\033[0;34m'
317
- YELLOW='\033[1;33m'
318
- PURPLE='\033[0;35m'
319
- LIGHT_PURPLE='\033[1;35m'
320
- RED='\033[0;31m'
321
- GREEN='\033[0;32m'
322
- ORANGE='\033[0;33m'
323
- WHITE='\033[1;37m'
324
- MAGENTA='\033[0;35m'
325
- CYAN='\033[0;36m'
326
- GOLD='\033[38;5;226m'
327
- NC='\033[0m'
328
-
329
- # Dynamic color coding based on cache size
330
- # Green: < 500MB (small)
331
- # Yellow: 500MB - 3GB (lots)
332
- # Red: > 3GB (extreme)
333
- CACHE_COLOR=$GREEN
334
- if [[ $SIZE_BYTES -gt 3221225472 ]]; then # > 3GB
335
- CACHE_COLOR=$RED
336
- elif [[ $SIZE_BYTES -gt 524288000 ]]; then # > 500MB
337
- CACHE_COLOR=$YELLOW
338
- fi
339
-
340
- # Display with file count and auto-clean indicator
341
- # Get auto-clean threshold for display
342
- AUTO_CLEAN_THRESHOLD=$(get_auto_clean_threshold)
343
- echo -e "${WHITE}💾 Saved to:${NC} ${CYAN}$TEMP_FILE${NC} ${WHITE}📦${NC} ${YELLOW}$FILE_COUNT${NC} ${CACHE_COLOR}$SIZE_HUMAN${NC} ${WHITE}🧹${NC}${GOLD}[${AUTO_CLEAN_THRESHOLD}mb]${NC}"
344
-
345
- # Auto-cleanup check - delete oldest files if over size threshold
346
- THRESHOLD_MB=$(get_auto_clean_threshold)
347
- if [[ $SIZE_BYTES -gt $((THRESHOLD_MB * 1048576)) ]]; then
348
- DELETED=$(auto_clean_old_files "$AUDIO_DIR_PATH" "$THRESHOLD_MB")
349
- if [[ $DELETED -gt 0 ]]; then
350
- echo -e "${ORANGE}🧹 Auto-cleaned $DELETED files${NC}"
351
- fi
352
- fi
353
-
354
- echo -e "${CYAN}🎤 Voice used:${NC} ${WHITE}$VOICE_NAME (macOS Say)${NC}"
355
-
356
- # Show personality if configured
357
- PERSONALITY=$(cat "$PROJECT_ROOT/.claude/tts-personality.txt" 2>/dev/null || cat "$HOME/.claude/tts-personality.txt" 2>/dev/null || echo "")
358
- if [[ -n "$PERSONALITY" ]] && [[ "$PERSONALITY" != "none" ]] && [[ "$PERSONALITY" != "normal" ]]; then
359
- echo -e "${GOLD}💫 Personality:${NC} ${WHITE}$PERSONALITY${NC}"
360
- fi
361
-
362
- # Check audio folder size and warn if getting large
363
- if [[ -d "$AUDIO_DIR_PATH" ]]; then
364
- AUDIO_SIZE=$(du -sm "$AUDIO_DIR_PATH" 2>/dev/null | cut -f1)
365
- if [[ -n "$AUDIO_SIZE" ]] && [[ "$AUDIO_SIZE" -gt 100 ]]; then
366
- echo -e "\033[0;31m⚠️ Audio cache is ${AUDIO_SIZE}MB - Run: /agent-vibes:cleanup\033[0m"
367
- fi
368
- fi
1
+ #!/usr/bin/env bash
2
+ #
3
+ # File: .claude/hooks/play-tts-macos.sh
4
+ #
5
+ # AgentVibes - Finally, your AI Agents can Talk Back! Text-to-Speech WITH personality for AI Assistants!
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
+ # You may obtain a copy of the License at
15
+ #
16
+ # http://www.apache.org/licenses/LICENSE-2.0
17
+ #
18
+ # Unless required by applicable law or agreed to in writing, software
19
+ # distributed under the License is distributed on an "AS IS" BASIS,
20
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
21
+ # See the License for the specific language governing permissions and
22
+ # limitations under the License.
23
+ #
24
+ # DISCLAIMER: This software is provided "AS IS", WITHOUT WARRANTY OF ANY KIND,
25
+ # express or implied. Use at your own risk. See the Apache License for details.
26
+ #
27
+ # ---
28
+ #
29
+ # @fileoverview macOS Say Provider Implementation - Native macOS TTS using the 'say' command
30
+ # @context Provides zero-dependency, offline TTS for macOS users using built-in Apple voices
31
+ # @architecture Implements provider interface contract for macOS 'say' command integration
32
+ # @dependencies macOS only (Darwin), say command (built-in), afplay (built-in)
33
+ # @entrypoints Called by play-tts.sh router when provider=macos
34
+ # @patterns Provider contract: text/voice → audio file path, voice validation, platform guard
35
+ # @related play-tts.sh, macos-voice-manager.sh, provider-manager.sh
36
+ #
37
+
38
+ # Platform guard - fail fast on non-macOS systems
39
+ if [[ "$(uname -s)" != "Darwin" ]]; then
40
+ echo "❌ Error: macOS provider only works on macOS"
41
+ echo " Current platform: $(uname -s)"
42
+ echo ""
43
+ echo " Switch to a different provider:"
44
+ echo " /agent-vibes:provider switch piper"
45
+ exit 1
46
+ fi
47
+
48
+ TEXT="$1"
49
+ VOICE_OVERRIDE="$2" # Optional: voice name (e.g., "Samantha", "Daniel")
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
+
62
+ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
63
+
64
+ # Source audio cache utilities
65
+ source "$SCRIPT_DIR/audio-cache-utils.sh"
66
+
67
+ # Default voice for macOS
68
+ DEFAULT_VOICE="Samantha"
69
+
70
+ # Common macOS voices with descriptions
71
+ # These are typically available on all macOS systems
72
+ # Using simple list format for bash 3.x compatibility (macOS default)
73
+ show_common_voices() {
74
+ echo " Alex - American English male (enhanced)"
75
+ echo " Daniel - British English male (enhanced)"
76
+ echo " Fiona - Scottish English female (enhanced)"
77
+ echo " Karen - Australian English female (enhanced)"
78
+ echo " Moira - Irish English female (enhanced)"
79
+ echo " Samantha - American English female (enhanced)"
80
+ echo " Tessa - South African English female (enhanced)"
81
+ echo " Veena - Indian English female (enhanced)"
82
+ echo " Victoria - American English female"
83
+ }
84
+
85
+ # @function get_voice_file_path
86
+ # @intent Determine path to voice configuration file
87
+ # @returns Echoes path to tts-voice.txt
88
+ get_voice_file_path() {
89
+ local voice_file=""
90
+
91
+ if [[ -n "$CLAUDE_PROJECT_DIR" ]] && [[ -f "$CLAUDE_PROJECT_DIR/.claude/tts-voice.txt" ]]; then
92
+ voice_file="$CLAUDE_PROJECT_DIR/.claude/tts-voice.txt"
93
+ elif [[ -f "$SCRIPT_DIR/../tts-voice.txt" ]]; then
94
+ voice_file="$SCRIPT_DIR/../tts-voice.txt"
95
+ elif [[ -f "$HOME/.claude/tts-voice.txt" ]]; then
96
+ voice_file="$HOME/.claude/tts-voice.txt"
97
+ fi
98
+
99
+ echo "$voice_file"
100
+ }
101
+
102
+ # @function determine_voice
103
+ # @intent Resolve which voice to use
104
+ # @returns Sets $VOICE_NAME global variable
105
+ VOICE_NAME=""
106
+
107
+ if [[ -n "$VOICE_OVERRIDE" ]]; then
108
+ VOICE_NAME="$VOICE_OVERRIDE"
109
+ echo "🎤 Using voice: $VOICE_OVERRIDE (session-specific)"
110
+ else
111
+ VOICE_FILE=$(get_voice_file_path)
112
+
113
+ if [[ -n "$VOICE_FILE" ]] && [[ -f "$VOICE_FILE" ]]; then
114
+ FILE_VOICE=$(cat "$VOICE_FILE" 2>/dev/null | tr -d '\n\r')
115
+ if [[ -n "$FILE_VOICE" ]]; then
116
+ VOICE_NAME="$FILE_VOICE"
117
+ fi
118
+ fi
119
+
120
+ # Fallback to default if no voice set
121
+ if [[ -z "$VOICE_NAME" ]]; then
122
+ VOICE_NAME="$DEFAULT_VOICE"
123
+ fi
124
+ fi
125
+
126
+ # @function validate_inputs
127
+ # @intent Check required parameters
128
+ if [[ -z "$TEXT" ]]; then
129
+ echo "Usage: $0 \"text to speak\" [voice_name]"
130
+ echo ""
131
+ echo "Common voices (run 'say -v ?' for full list):"
132
+ show_common_voices
133
+ exit 1
134
+ fi
135
+
136
+ # @function validate_voice
137
+ # @intent Check if the specified voice exists on this system
138
+ # @param $1 Voice name to validate
139
+ # @returns 0 if valid, 1 if not found
140
+ validate_voice() {
141
+ local voice="$1"
142
+ say -v ? 2>/dev/null | grep -qi "^${voice} "
143
+ }
144
+
145
+ # Validate voice exists (case-insensitive search)
146
+ if ! validate_voice "$VOICE_NAME"; then
147
+ echo "⚠️ Voice '$VOICE_NAME' not found on this system"
148
+ echo " Falling back to default: $DEFAULT_VOICE"
149
+ VOICE_NAME="$DEFAULT_VOICE"
150
+
151
+ # If default also doesn't exist, try to find any English voice
152
+ if ! validate_voice "$VOICE_NAME"; then
153
+ VOICE_NAME=$(say -v ? 2>/dev/null | grep -i "en_" | head -1 | awk '{print $1}')
154
+ if [[ -z "$VOICE_NAME" ]]; then
155
+ echo "❌ No English voices found on this system"
156
+ exit 2
157
+ fi
158
+ echo " Using first available English voice: $VOICE_NAME"
159
+ fi
160
+ fi
161
+
162
+ # @function determine_audio_directory
163
+ # @intent Find appropriate directory for audio file storage
164
+ if [[ -n "$CLAUDE_PROJECT_DIR" ]]; then
165
+ AUDIO_DIR="$CLAUDE_PROJECT_DIR/.claude/audio"
166
+ else
167
+ CURRENT_DIR="$PWD"
168
+ while [[ "$CURRENT_DIR" != "/" ]]; do
169
+ if [[ -d "$CURRENT_DIR/.claude" ]]; then
170
+ AUDIO_DIR="$CURRENT_DIR/.claude/audio"
171
+ break
172
+ fi
173
+ CURRENT_DIR=$(dirname "$CURRENT_DIR")
174
+ done
175
+ if [[ -z "$AUDIO_DIR" ]]; then
176
+ AUDIO_DIR="$HOME/.claude/audio"
177
+ fi
178
+ fi
179
+
180
+ mkdir -p "$AUDIO_DIR"
181
+
182
+ # SECURITY: Use mktemp for unpredictable filenames (#130)
183
+ _tmp=$(mktemp "$AUDIO_DIR/tts-XXXXXX"); TEMP_FILE="${_tmp}.aiff"; mv "$_tmp" "$TEMP_FILE"
184
+ _tmp=$(mktemp "$AUDIO_DIR/tts-padded-XXXXXX"); FINAL_FILE="${_tmp}.wav"; mv "$_tmp" "$FINAL_FILE"
185
+
186
+ # @function get_speech_rate
187
+ # @intent Determine speech rate for synthesis
188
+ # @returns Speech rate value (words per minute, default ~175-200)
189
+ get_speech_rate() {
190
+ local rate_config=""
191
+
192
+ # Check for rate config file
193
+ if [[ -f "$SCRIPT_DIR/../config/tts-speech-rate.txt" ]]; then
194
+ rate_config="$SCRIPT_DIR/../config/tts-speech-rate.txt"
195
+ elif [[ -f "$HOME/.claude/config/tts-speech-rate.txt" ]]; then
196
+ rate_config="$HOME/.claude/config/tts-speech-rate.txt"
197
+ fi
198
+
199
+ if [[ -n "$rate_config" ]]; then
200
+ local user_speed=$(cat "$rate_config" 2>/dev/null | grep -v '^#' | grep -v '^$' | tail -1)
201
+ # Convert multiplier to words per minute (base ~200 WPM)
202
+ # User: 0.5=slower, 1.0=normal, 2.0=faster
203
+ echo "scale=0; 200 * $user_speed / 1" | bc -l 2>/dev/null || echo "200"
204
+ return
205
+ fi
206
+
207
+ # Default: 200 WPM (normal rate)
208
+ echo "200"
209
+ }
210
+
211
+ SPEECH_RATE=$(get_speech_rate)
212
+
213
+ # @function synthesize_with_say
214
+ # @intent Generate speech using macOS 'say' command
215
+ # @returns Creates audio file at $TEMP_FILE
216
+ echo "$TEXT" | say -v "$VOICE_NAME" -r "$SPEECH_RATE" -o "$TEMP_FILE" 2>/dev/null
217
+
218
+ if [[ ! -f "$TEMP_FILE" ]] || [[ ! -s "$TEMP_FILE" ]]; then
219
+ echo "❌ Failed to synthesize speech with macOS say command"
220
+ echo "Voice: $VOICE_NAME"
221
+ exit 3
222
+ fi
223
+
224
+ # @function convert_and_pad_audio
225
+ # @intent Convert AIFF to WAV and add silence padding for consistency
226
+ # @why Maintains consistent audio format across providers
227
+ if command -v ffmpeg &> /dev/null; then
228
+ # Add 200ms of silence at the beginning and convert to WAV
229
+ ffmpeg -f lavfi -i anullsrc=r=44100:cl=stereo:d=0.2 -i "$TEMP_FILE" \
230
+ -filter_complex "[0:a][1:a]concat=n=2:v=0:a=1[out]" \
231
+ -map "[out]" -y "$FINAL_FILE" 2>/dev/null
232
+
233
+ if [[ -f "$FINAL_FILE" ]]; then
234
+ rm -f "$TEMP_FILE"
235
+ TEMP_FILE="$FINAL_FILE"
236
+ fi
237
+ else
238
+ # No ffmpeg - use AIFF directly (rename for consistency)
239
+ _tmp=$(mktemp "$AUDIO_DIR/tts-padded-XXXXXX"); FINAL_FILE="${_tmp}.aiff"; mv "$_tmp" "$FINAL_FILE"
240
+ mv "$TEMP_FILE" "$FINAL_FILE"
241
+ TEMP_FILE="$FINAL_FILE"
242
+ fi
243
+
244
+ # @function play_audio
245
+ # @intent Play generated audio - via PulseAudio tunnel for SSH, afplay for local
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
260
+
261
+ # Wait for previous audio to finish (max 30 seconds)
262
+ for i in {1..60}; do
263
+ if [ ! -f "$LOCK_FILE" ]; then
264
+ break
265
+ fi
266
+ sleep 0.5
267
+ done
268
+
269
+ # Create lock and play audio
270
+ touch "$LOCK_FILE"
271
+
272
+ # Get audio duration for proper lock timing
273
+ if command -v ffprobe &> /dev/null; then
274
+ DURATION=$(ffprobe -v error -show_entries format=duration -of default=noprint_wrappers=1:nokey=1 "$TEMP_FILE" 2>/dev/null)
275
+ DURATION=${DURATION%.*} # Round to integer
276
+ else
277
+ # Estimate duration based on text length (~150 WPM)
278
+ WORD_COUNT=$(echo "$TEXT" | wc -w)
279
+ DURATION=$(( (WORD_COUNT * 60 / 150) + 1 ))
280
+ fi
281
+ DURATION=${DURATION:-2} # Default to 2 seconds if detection fails
282
+
283
+ # Play audio in background (skip if in test mode or no-playback mode)
284
+ # AGENTVIBES_NO_PLAYBACK: Set to "true" to generate audio without playing (for post-processing)
285
+ if [[ "${AGENTVIBES_TEST_MODE:-false}" != "true" ]] && [[ "${AGENTVIBES_NO_PLAYBACK:-false}" != "true" ]]; then
286
+ # Check if we're in an SSH session with PulseAudio tunnel available
287
+ if [[ -n "$SSH_CONNECTION" ]] && [[ -n "$PULSE_SERVER" ]]; then
288
+ # Use paplay to send audio through PulseAudio tunnel to remote machine
289
+ if command -v /opt/homebrew/bin/paplay &> /dev/null; then
290
+ /opt/homebrew/bin/paplay "$TEMP_FILE" >/dev/null 2>&1 &
291
+ PLAYER_PID=$!
292
+ echo "🔊 Playing via PulseAudio tunnel"
293
+ else
294
+ echo "⚠️ paplay not found - install pulseaudio for SSH audio"
295
+ afplay "$TEMP_FILE" >/dev/null 2>&1 &
296
+ PLAYER_PID=$!
297
+ fi
298
+ else
299
+ # Local session - use native macOS player
300
+ afplay "$TEMP_FILE" >/dev/null 2>&1 &
301
+ PLAYER_PID=$!
302
+ fi
303
+ fi
304
+
305
+ # Wait for audio to finish, then release lock
306
+ (sleep $DURATION; rm -f "$LOCK_FILE") &
307
+ disown
308
+
309
+ # Get audio cache stats
310
+ AUDIO_DIR_PATH=$(get_audio_dir)
311
+ FILE_COUNT=$(count_tts_files "$AUDIO_DIR_PATH")
312
+ SIZE_BYTES=$(calculate_tts_size_bytes "$AUDIO_DIR_PATH")
313
+ SIZE_HUMAN=$(bytes_to_human "$SIZE_BYTES")
314
+
315
+ # Color codes
316
+ BLUE='\033[0;34m'
317
+ YELLOW='\033[1;33m'
318
+ PURPLE='\033[0;35m'
319
+ LIGHT_PURPLE='\033[1;35m'
320
+ RED='\033[0;31m'
321
+ GREEN='\033[0;32m'
322
+ ORANGE='\033[0;33m'
323
+ WHITE='\033[1;37m'
324
+ MAGENTA='\033[0;35m'
325
+ CYAN='\033[0;36m'
326
+ GOLD='\033[38;5;226m'
327
+ NC='\033[0m'
328
+
329
+ # Dynamic color coding based on cache size
330
+ # Green: < 500MB (small)
331
+ # Yellow: 500MB - 3GB (lots)
332
+ # Red: > 3GB (extreme)
333
+ CACHE_COLOR=$GREEN
334
+ if [[ $SIZE_BYTES -gt 3221225472 ]]; then # > 3GB
335
+ CACHE_COLOR=$RED
336
+ elif [[ $SIZE_BYTES -gt 524288000 ]]; then # > 500MB
337
+ CACHE_COLOR=$YELLOW
338
+ fi
339
+
340
+ # Display with file count and auto-clean indicator
341
+ # Get auto-clean threshold for display
342
+ AUTO_CLEAN_THRESHOLD=$(get_auto_clean_threshold)
343
+ echo -e "${WHITE}💾 Saved to:${NC} ${CYAN}$TEMP_FILE${NC} ${WHITE}📦${NC} ${YELLOW}$FILE_COUNT${NC} ${CACHE_COLOR}$SIZE_HUMAN${NC} ${WHITE}🧹${NC}${GOLD}[${AUTO_CLEAN_THRESHOLD}mb]${NC}"
344
+
345
+ # Auto-cleanup check - delete oldest files if over size threshold
346
+ THRESHOLD_MB=$(get_auto_clean_threshold)
347
+ if [[ $SIZE_BYTES -gt $((THRESHOLD_MB * 1048576)) ]]; then
348
+ DELETED=$(auto_clean_old_files "$AUDIO_DIR_PATH" "$THRESHOLD_MB")
349
+ if [[ $DELETED -gt 0 ]]; then
350
+ echo -e "${ORANGE}🧹 Auto-cleaned $DELETED files${NC}"
351
+ fi
352
+ fi
353
+
354
+ echo -e "${CYAN}🎤 Voice used:${NC} ${WHITE}$VOICE_NAME (macOS Say)${NC}"
355
+
356
+ # Show personality if configured
357
+ PERSONALITY=$(cat "$PROJECT_ROOT/.claude/tts-personality.txt" 2>/dev/null || cat "$HOME/.claude/tts-personality.txt" 2>/dev/null || echo "")
358
+ if [[ -n "$PERSONALITY" ]] && [[ "$PERSONALITY" != "none" ]] && [[ "$PERSONALITY" != "normal" ]]; then
359
+ echo -e "${GOLD}💫 Personality:${NC} ${WHITE}$PERSONALITY${NC}"
360
+ fi
361
+
362
+ # Check audio folder size and warn if getting large
363
+ if [[ -d "$AUDIO_DIR_PATH" ]]; then
364
+ AUDIO_SIZE=$(du -sm "$AUDIO_DIR_PATH" 2>/dev/null | cut -f1)
365
+ if [[ -n "$AUDIO_SIZE" ]] && [[ "$AUDIO_SIZE" -gt 100 ]]; then
366
+ echo -e "\033[0;31m⚠️ Audio cache is ${AUDIO_SIZE}MB - Run: /agent-vibes:cleanup\033[0m"
367
+ fi
368
+ fi