Skip to content

Commit 6c544d5

Browse files
authored
Merge pull request #1 from anharvey/pr-29
feat(linux): enhance audio backend support and improve test reliability
2 parents 2feb2d6 + 691bc60 commit 6c544d5

3 files changed

Lines changed: 285 additions & 20 deletions

File tree

peon.sh

Lines changed: 105 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -16,12 +16,110 @@ detect_platform() {
1616
*) echo "unknown" ;;
1717
esac
1818
}
19-
PLATFORM=$(detect_platform)
19+
PLATFORM=${PLATFORM:-$(detect_platform)}
2020

2121
PEON_DIR="${CLAUDE_PEON_DIR:-$HOME/.claude/hooks/peon-ping}"
2222
CONFIG="$PEON_DIR/config.json"
2323
STATE="$PEON_DIR/.state.json"
2424

25+
# --- Linux audio backend detection ---
26+
detect_linux_player() {
27+
# Helper to check if a player is available (respects test-mode disable markers)
28+
player_available() {
29+
local cmd="$1"
30+
command -v "$cmd" &>/dev/null || return 1
31+
# In test mode, check for disable marker
32+
[ "${PEON_TEST:-0}" = "1" ] && [ -f "${CLAUDE_PEON_DIR}/.disabled_${cmd}" ] && return 1
33+
return 0
34+
}
35+
36+
if player_available pw-play; then
37+
echo "pw-play"
38+
elif player_available paplay; then
39+
echo "paplay"
40+
elif player_available ffplay; then
41+
echo "ffplay"
42+
elif player_available mpv; then
43+
echo "mpv"
44+
elif player_available play; then
45+
echo "play"
46+
elif player_available aplay; then
47+
echo "aplay"
48+
else
49+
# Warn only once per process to avoid spam
50+
if [ -z "${WARNED_NO_LINUX_AUDIO_BACKEND:-}" ]; then
51+
echo "WARNING: No audio backend found. Please install one of: pw-play, paplay, ffplay, mpv, play (SoX), or aplay" >&2
52+
WARNED_NO_LINUX_AUDIO_BACKEND=1
53+
fi
54+
return 1
55+
fi
56+
}
57+
58+
# --- Linux audio playback with backend-specific volume handling ---
59+
play_linux_sound() {
60+
local file="$1" vol="$2" player="$3"
61+
62+
# Skip playback if no backend available
63+
[ -z "$player" ] && return 0
64+
65+
# Background mode: use nohup & for async playback (default)
66+
# Synchronous mode: no nohup/& for tests (when PEON_TEST=1)
67+
local use_bg=true
68+
[ "${PEON_TEST:-0}" = "1" ] && use_bg=false
69+
70+
case "$player" in
71+
pw-play)
72+
# pw-play (PipeWire) expects volume as float 0.0-1.0 (unlike paplay 0-65536, ffplay/mpv 0-100)
73+
if [ "$use_bg" = true ]; then
74+
nohup pw-play --volume "$vol" "$file" >/dev/null 2>&1 &
75+
else
76+
pw-play --volume "$vol" "$file" >/dev/null 2>&1
77+
fi
78+
;;
79+
paplay)
80+
local pa_vol
81+
pa_vol=$(python3 -c "print(max(0, min(65536, int($vol * 65536))))")
82+
if [ "$use_bg" = true ]; then
83+
nohup paplay --volume="$pa_vol" "$file" >/dev/null 2>&1 &
84+
else
85+
paplay --volume="$pa_vol" "$file" >/dev/null 2>&1
86+
fi
87+
;;
88+
ffplay)
89+
local ff_vol
90+
ff_vol=$(python3 -c "print(max(0, min(100, int($vol * 100))))")
91+
if [ "$use_bg" = true ]; then
92+
nohup ffplay -nodisp -autoexit -volume "$ff_vol" "$file" >/dev/null 2>&1 &
93+
else
94+
ffplay -nodisp -autoexit -volume "$ff_vol" "$file" >/dev/null 2>&1
95+
fi
96+
;;
97+
mpv)
98+
local mpv_vol
99+
mpv_vol=$(python3 -c "print(max(0, min(100, int($vol * 100))))")
100+
if [ "$use_bg" = true ]; then
101+
nohup mpv --no-video --volume="$mpv_vol" "$file" >/dev/null 2>&1 &
102+
else
103+
mpv --no-video --volume="$mpv_vol" "$file" >/dev/null 2>&1
104+
fi
105+
;;
106+
play)
107+
if [ "$use_bg" = true ]; then
108+
nohup play -v "$vol" "$file" >/dev/null 2>&1 &
109+
else
110+
play -v "$vol" "$file" >/dev/null 2>&1
111+
fi
112+
;;
113+
aplay)
114+
if [ "$use_bg" = true ]; then
115+
nohup aplay -q "$file" >/dev/null 2>&1 &
116+
else
117+
aplay -q "$file" >/dev/null 2>&1
118+
fi
119+
;;
120+
esac
121+
}
122+
25123
# --- Platform-aware audio playback ---
26124
play_sound() {
27125
local file="$1" vol="$2"
@@ -46,24 +144,10 @@ play_sound() {
46144
" &>/dev/null &
47145
;;
48146
linux)
49-
# Try common Linux audio players in order of preference
50-
if command -v pw-play &>/dev/null; then
51-
local pw_vol
52-
nohup pw-play --volume="$vol" "$file" >/dev/null 2>&1 &
53-
elif command -v paplay &>/dev/null; then
54-
local pa_vol
55-
pa_vol=$(python3 -c "print(int($vol * 65536))")
56-
nohup paplay --volume="$pa_vol" "$file" >/dev/null 2>&1 &
57-
elif command -v ffplay &>/dev/null; then
58-
local ff_vol
59-
ff_vol=$(python3 -c "print(int($vol * 100))")
60-
nohup ffplay -nodisp -autoexit -volume "$ff_vol" "$file" >/dev/null 2>&1 &
61-
elif command -v mpv &>/dev/null; then
62-
local mpv_vol
63-
mpv_vol=$(python3 -c "print(int($vol * 100))")
64-
nohup mpv --no-video --volume="$mpv_vol" "$file" >/dev/null 2>&1 &
65-
elif command -v aplay &>/dev/null; then
66-
nohup aplay -q "$file" >/dev/null 2>&1 &
147+
local player
148+
player=$(detect_linux_player) || player=""
149+
if [ -n "$player" ]; then
150+
play_linux_sound "$file" "$vol" "$player"
67151
fi
68152
;;
69153
esac
@@ -156,7 +240,8 @@ terminal_is_focused() {
156240
return 1
157241
;;
158242
linux)
159-
if command -v xdotool &>/dev/null; then
243+
# Only use xdotool on X11; fallback to always notify on Wayland or if xdotool is missing
244+
if [ "${XDG_SESSION_TYPE:-}" = "x11" ] && command -v xdotool &>/dev/null; then
160245
local win_name
161246
win_name=$(xdotool getactivewindow getwindowname 2>/dev/null || echo "")
162247
if [[ "$win_name" =~ (terminal|konsole|alacritty|kitty|wezterm|foot|tilix|gnome-terminal|xterm|xfce4-terminal|sakura|terminator|st|urxvt|ghostty) ]]; then

tests/peon.bats

Lines changed: 158 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -489,3 +489,161 @@ JSON
489489
sound=$(afplay_sound)
490490
[[ "$sound" == *"/packs/peon/sounds/"* ]]
491491
}
492+
493+
# ============================================================
494+
# Linux audio backend detection (order of preference)
495+
# ============================================================
496+
497+
@test "Linux detects pw-play first" {
498+
export PLATFORM=linux
499+
# Disable all other players to ensure pw-play is selected
500+
for player in paplay ffplay mpv play aplay; do
501+
touch "$TEST_DIR/.disabled_${player}"
502+
done
503+
run_peon '{"hook_event_name":"SessionStart","cwd":"/tmp/myproject","session_id":"s1","permission_mode":"default"}'
504+
[ "$PEON_EXIT" -eq 0 ]
505+
linux_audio_was_called
506+
cmdline=$(linux_audio_cmdline)
507+
[[ "$cmdline" == *"--volume"* ]]
508+
}
509+
510+
@test "Linux detects paplay when pw-play not available" {
511+
export PLATFORM=linux
512+
touch "$TEST_DIR/.disabled_pw-play"
513+
run_peon '{"hook_event_name":"SessionStart","cwd":"/tmp/myproject","session_id":"s1","permission_mode":"default"}'
514+
[ "$PEON_EXIT" -eq 0 ]
515+
linux_audio_was_called
516+
cmdline=$(linux_audio_cmdline)
517+
[[ "$cmdline" == *"--volume"* ]]
518+
}
519+
520+
@test "Linux detects ffplay when pw-play and paplay not available" {
521+
export PLATFORM=linux
522+
touch "$TEST_DIR/.disabled_pw-play" "$TEST_DIR/.disabled_paplay"
523+
run_peon '{"hook_event_name":"SessionStart","cwd":"/tmp/myproject","session_id":"s1","permission_mode":"default"}'
524+
[ "$PEON_EXIT" -eq 0 ]
525+
linux_audio_was_called
526+
cmdline=$(linux_audio_cmdline)
527+
[[ "$cmdline" == *"-volume"* ]]
528+
}
529+
530+
@test "Linux detects mpv when pw-play, paplay, and ffplay not available" {
531+
export PLATFORM=linux
532+
touch "$TEST_DIR/.disabled_pw-play" "$TEST_DIR/.disabled_paplay" "$TEST_DIR/.disabled_ffplay"
533+
run_peon '{"hook_event_name":"SessionStart","cwd":"/tmp/myproject","session_id":"s1","permission_mode":"default"}'
534+
[ "$PEON_EXIT" -eq 0 ]
535+
linux_audio_was_called
536+
cmdline=$(linux_audio_cmdline)
537+
[[ "$cmdline" == *"--volume"* ]]
538+
}
539+
540+
@test "Linux detects play (SoX) when pw-play through mpv not available" {
541+
export PLATFORM=linux
542+
touch "$TEST_DIR/.disabled_pw-play" "$TEST_DIR/.disabled_paplay" "$TEST_DIR/.disabled_ffplay" "$TEST_DIR/.disabled_mpv"
543+
run_peon '{"hook_event_name":"SessionStart","cwd":"/tmp/myproject","session_id":"s1","permission_mode":"default"}'
544+
[ "$PEON_EXIT" -eq 0 ]
545+
linux_audio_was_called
546+
cmdline=$(linux_audio_cmdline)
547+
[[ "$cmdline" == *"-v"* ]]
548+
}
549+
550+
@test "Linux falls back to aplay when no other backend available" {
551+
export PLATFORM=linux
552+
touch "$TEST_DIR/.disabled_pw-play" "$TEST_DIR/.disabled_paplay" "$TEST_DIR/.disabled_ffplay" "$TEST_DIR/.disabled_mpv" "$TEST_DIR/.disabled_play"
553+
run_peon '{"hook_event_name":"SessionStart","cwd":"/tmp/myproject","session_id":"s1","permission_mode":"default"}'
554+
[ "$PEON_EXIT" -eq 0 ]
555+
linux_audio_was_called
556+
cmdline=$(linux_audio_cmdline)
557+
[[ "$cmdline" == *"-q"* ]]
558+
}
559+
560+
@test "Linux continues gracefully when no audio backend available" {
561+
export PLATFORM=linux
562+
for player in pw-play paplay ffplay mpv play aplay; do
563+
touch "$TEST_DIR/.disabled_${player}"
564+
done
565+
run_peon '{"hook_event_name":"SessionStart","cwd":"/tmp/myproject","session_id":"s1","permission_mode":"default"}'
566+
[ "$PEON_EXIT" -eq 0 ]
567+
! linux_audio_was_called
568+
[[ "$PEON_STDERR" == *"WARNING: No audio backend found"* ]]
569+
}
570+
571+
# ============================================================
572+
# Linux volume handling per backend
573+
# ============================================================
574+
575+
@test "Linux pw-play uses --volume with decimal" {
576+
export PLATFORM=linux
577+
for player in paplay ffplay mpv play aplay; do
578+
touch "$TEST_DIR/.disabled_${player}"
579+
done
580+
cat > "$TEST_DIR/config.json" <<'JSON'
581+
{ "active_pack": "peon", "volume": 0.3, "enabled": true, "categories": {} }
582+
JSON
583+
run_peon '{"hook_event_name":"SessionStart","cwd":"/tmp/myproject","session_id":"s1","permission_mode":"default"}'
584+
linux_audio_was_called
585+
cmdline=$(linux_audio_cmdline)
586+
[[ "$cmdline" == *"--volume 0.3"* ]]
587+
}
588+
589+
@test "Linux paplay scales volume to PulseAudio range" {
590+
export PLATFORM=linux
591+
touch "$TEST_DIR/.disabled_pw-play"
592+
cat > "$TEST_DIR/config.json" <<'JSON'
593+
{ "active_pack": "peon", "volume": 0.5, "enabled": true, "categories": {} }
594+
JSON
595+
run_peon '{"hook_event_name":"SessionStart","cwd":"/tmp/myproject","session_id":"s1","permission_mode":"default"}'
596+
linux_audio_was_called
597+
cmdline=$(linux_audio_cmdline)
598+
# 0.5 * 65536 = 32768
599+
[[ "$cmdline" == *"--volume=32768"* ]]
600+
}
601+
602+
@test "Linux ffplay scales volume to 0-100" {
603+
export PLATFORM=linux
604+
touch "$TEST_DIR/.disabled_pw-play" "$TEST_DIR/.disabled_paplay"
605+
cat > "$TEST_DIR/config.json" <<'JSON'
606+
{ "active_pack": "peon", "volume": 0.5, "enabled": true, "categories": {} }
607+
JSON
608+
run_peon '{"hook_event_name":"SessionStart","cwd":"/tmp/myproject","session_id":"s1","permission_mode":"default"}'
609+
linux_audio_was_called
610+
cmdline=$(linux_audio_cmdline)
611+
# 0.5 * 100 = 50
612+
[[ "$cmdline" == *"-volume 50"* ]]
613+
}
614+
615+
@test "Linux mpv scales volume to 0-100" {
616+
export PLATFORM=linux
617+
touch "$TEST_DIR/.disabled_pw-play" "$TEST_DIR/.disabled_paplay" "$TEST_DIR/.disabled_ffplay"
618+
cat > "$TEST_DIR/config.json" <<'JSON'
619+
{ "active_pack": "peon", "volume": 0.5, "enabled": true, "categories": {} }
620+
JSON
621+
run_peon '{"hook_event_name":"SessionStart","cwd":"/tmp/myproject","session_id":"s1","permission_mode":"default"}'
622+
linux_audio_was_called
623+
cmdline=$(linux_audio_cmdline)
624+
# 0.5 * 100 = 50
625+
[[ "$cmdline" == *"--volume=50"* ]]
626+
}
627+
628+
@test "Linux play (SoX) uses -v with decimal" {
629+
export PLATFORM=linux
630+
touch "$TEST_DIR/.disabled_pw-play" "$TEST_DIR/.disabled_paplay" "$TEST_DIR/.disabled_ffplay" "$TEST_DIR/.disabled_mpv"
631+
cat > "$TEST_DIR/config.json" <<'JSON'
632+
{ "active_pack": "peon", "volume": 0.3, "enabled": true, "categories": {} }
633+
JSON
634+
run_peon '{"hook_event_name":"SessionStart","cwd":"/tmp/myproject","session_id":"s1","permission_mode":"default"}'
635+
linux_audio_was_called
636+
cmdline=$(linux_audio_cmdline)
637+
[[ "$cmdline" == *"-v 0.3"* ]]
638+
}
639+
640+
@test "Linux aplay does not support volume control" {
641+
export PLATFORM=linux
642+
touch "$TEST_DIR/.disabled_pw-play" "$TEST_DIR/.disabled_paplay" "$TEST_DIR/.disabled_ffplay" "$TEST_DIR/.disabled_mpv" "$TEST_DIR/.disabled_play"
643+
run_peon '{"hook_event_name":"SessionStart","cwd":"/tmp/myproject","session_id":"s1","permission_mode":"default"}'
644+
linux_audio_was_called
645+
cmdline=$(linux_audio_cmdline)
646+
# aplay is used and no volume flags are passed
647+
[[ "$cmdline" != *"volume"* ]]
648+
[[ "$cmdline" != *"-v "* ]]
649+
}

tests/setup.bash

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -118,6 +118,15 @@ echo "$@" >> "${CLAUDE_PEON_DIR}/afplay.log"
118118
SCRIPT
119119
chmod +x "$MOCK_BIN/afplay"
120120

121+
# Mock Linux audio backends — log calls instead of playing sound
122+
for player in pw-play paplay ffplay mpv play aplay; do
123+
cat > "$MOCK_BIN/$player" <<'SCRIPT'
124+
#!/bin/bash
125+
echo "$@" >> "${CLAUDE_PEON_DIR}/linux_audio.log"
126+
SCRIPT
127+
chmod +x "$MOCK_BIN/$player"
128+
done
129+
121130
# Mock osascript — log calls instead of running AppleScript
122131
cat > "$MOCK_BIN/osascript" <<'SCRIPT'
123132
#!/bin/bash
@@ -154,6 +163,7 @@ teardown_test_env() {
154163
# Helper: run peon.sh with a JSON event
155164
run_peon() {
156165
local json="$1"
166+
export PEON_TEST=1
157167
echo "$json" | bash "$PEON_SH" 2>"$TEST_DIR/stderr.log"
158168
PEON_EXIT=$?
159169
PEON_STDERR=$(cat "$TEST_DIR/stderr.log" 2>/dev/null)
@@ -180,3 +190,15 @@ afplay_call_count() {
180190
echo "0"
181191
fi
182192
}
193+
194+
# Helper: check if a Linux audio player was called
195+
linux_audio_was_called() {
196+
[ -f "$TEST_DIR/linux_audio.log" ] && [ -s "$TEST_DIR/linux_audio.log" ]
197+
}
198+
199+
# Helper: get the command line used for Linux audio
200+
linux_audio_cmdline() {
201+
if [ -f "$TEST_DIR/linux_audio.log" ]; then
202+
tail -1 "$TEST_DIR/linux_audio.log"
203+
fi
204+
}

0 commit comments

Comments
 (0)