369 lines
8.9 KiB
Bash
Executable file
369 lines
8.9 KiB
Bash
Executable file
#!/usr/bin/env bash
|
||
#
|
||
# ==========================================
|
||
# Volume Limiter Script (macOS & Linux)
|
||
# ==========================================
|
||
# Limits system volume to protect hearing.
|
||
# Works with: PipeWire, PulseAudio, and ALSA
|
||
#
|
||
# Options:
|
||
# --headphones-only Limit only when headphones are connected
|
||
# --max-volume <int> Maximum allowed volume (0–100)
|
||
# --delay <seconds> Interval between volume checks
|
||
#
|
||
|
||
set -euo pipefail
|
||
|
||
# ----------------------------
|
||
# Configuration
|
||
# ----------------------------
|
||
MAX_VOLUME=60 # 60% = ~80-85dB, safe for extended listening
|
||
ONLY_ON_HEADPHONES=false
|
||
DELAY=5 # Check every 5 seconds
|
||
DEBUG=false
|
||
LOCKFILE="/tmp/volumizer.lock"
|
||
STATE_FILE="/tmp/volumizer.state"
|
||
|
||
# ----------------------------
|
||
# Parse Arguments
|
||
# ----------------------------
|
||
while [[ $# -gt 0 ]]; do
|
||
case "$1" in
|
||
--headphones-only)
|
||
ONLY_ON_HEADPHONES=true
|
||
shift
|
||
;;
|
||
--max-volume)
|
||
MAX_VOLUME="$2"
|
||
shift 2
|
||
;;
|
||
--delay)
|
||
DELAY="$2"
|
||
shift 2
|
||
;;
|
||
--debug)
|
||
DEBUG=true
|
||
shift
|
||
;;
|
||
*)
|
||
echo "Usage: $0 [--headphones-only] [--max-volume N] [--delay N] [--debug]"
|
||
exit 1
|
||
;;
|
||
esac
|
||
done
|
||
|
||
# ----------------------------
|
||
# Debug Logging
|
||
# ----------------------------
|
||
if [ "$DEBUG" = true ]; then
|
||
debug() {
|
||
local func="${FUNCNAME[1]:-main}"
|
||
local line="${BASH_LINENO[0]}"
|
||
echo "[$(date '+%Y-%m-%d %H:%M:%S')] [$func:$line] $*" >&2
|
||
}
|
||
else
|
||
debug() { :; }
|
||
fi
|
||
|
||
# ----------------------------
|
||
# Cleanup Function
|
||
# ----------------------------
|
||
cleanup() {
|
||
# Remove traps to prevent double-execution
|
||
trap - EXIT INT TERM
|
||
|
||
debug "Cleaning up and exiting"
|
||
|
||
# Kill all child processes in our process group
|
||
local pgrp
|
||
pgrp=$(ps -o pgid= -p $$ | tr -d ' ')
|
||
if [ -n "$pgrp" ]; then
|
||
pkill -TERM -g "$pgrp" 2>/dev/null || true
|
||
sleep 0.5
|
||
pkill -KILL -g "$pgrp" 2>/dev/null || true
|
||
fi
|
||
|
||
rm -f "$LOCKFILE"
|
||
exit
|
||
}
|
||
|
||
# ----------------------------
|
||
# Single Instance Check
|
||
# ----------------------------
|
||
debug "Checking for existing instance..."
|
||
if [ -f "$LOCKFILE" ]; then
|
||
LOCKPID=$(cat "$LOCKFILE" 2>/dev/null || echo "")
|
||
if [ -n "$LOCKPID" ] && kill -0 "$LOCKPID" 2>/dev/null; then
|
||
debug "Already running (PID: $LOCKPID), exiting"
|
||
exit 0
|
||
fi
|
||
debug "Stale lockfile found, removing"
|
||
fi
|
||
|
||
echo $$ >"$LOCKFILE"
|
||
debug "Created lockfile with PID: $$"
|
||
trap cleanup EXIT INT TERM
|
||
|
||
# ----------------------------
|
||
# Platform Detection
|
||
# ----------------------------
|
||
case "$(uname)" in
|
||
Darwin)
|
||
PLATFORM="macos"
|
||
;;
|
||
Linux)
|
||
PLATFORM="linux"
|
||
# Detect Linux audio system
|
||
if command -v wpctl >/dev/null 2>&1 && wpctl status >/dev/null 2>&1; then
|
||
AUDIO_SYSTEM="pipewire"
|
||
debug "Detected: PipeWire"
|
||
elif command -v pactl >/dev/null 2>&1 && pactl info >/dev/null 2>&1; then
|
||
AUDIO_SYSTEM="pulseaudio"
|
||
debug "Detected: PulseAudio"
|
||
elif command -v amixer >/dev/null 2>&1; then
|
||
AUDIO_SYSTEM="alsa"
|
||
debug "Detected: ALSA only"
|
||
else
|
||
echo "Error: No supported audio system found (PipeWire/PulseAudio/ALSA)"
|
||
exit 1
|
||
fi
|
||
;;
|
||
*)
|
||
echo "Unsupported platform: $(uname)"
|
||
exit 1
|
||
;;
|
||
esac
|
||
|
||
debug "Platform: $PLATFORM"
|
||
|
||
# ----------------------------
|
||
# Volume Functions
|
||
# ----------------------------
|
||
get_volume() {
|
||
if [ "$PLATFORM" = "macos" ]; then
|
||
osascript -e "output volume of (get volume settings)" 2>/dev/null
|
||
else
|
||
case "$AUDIO_SYSTEM" in
|
||
pipewire)
|
||
# wpctl returns volume as decimal (0.00 to 1.50+)
|
||
local vol
|
||
vol=$(wpctl get-volume @DEFAULT_AUDIO_SINK@ 2>/dev/null | awk '{print $2}')
|
||
# Convert to percentage and round
|
||
echo "$vol" | awk '{printf "%.0f", $1 * 100}'
|
||
;;
|
||
pulseaudio)
|
||
# pactl get-sink-volume returns percentage
|
||
pactl get-sink-volume @DEFAULT_SINK@ 2>/dev/null | grep -oP '\d+%' | head -1 | tr -d '%'
|
||
;;
|
||
alsa)
|
||
amixer get Master 2>/dev/null | grep -oP '\d+%' | head -1 | tr -d '%'
|
||
;;
|
||
esac
|
||
fi
|
||
}
|
||
|
||
set_volume() {
|
||
local target=$1
|
||
|
||
if [ "$PLATFORM" = "macos" ]; then
|
||
osascript -e "set volume output volume $target" 2>/dev/null
|
||
else
|
||
case "$AUDIO_SYSTEM" in
|
||
pipewire)
|
||
# wpctl uses decimal values (0.6 = 60%)
|
||
local decimal
|
||
decimal=$(echo "$target" | awk '{printf "%.2f", $1 / 100}')
|
||
wpctl set-volume @DEFAULT_AUDIO_SINK@ "$decimal" 2>/dev/null
|
||
;;
|
||
pulseaudio)
|
||
pactl set-sink-volume @DEFAULT_SINK@ "${target}%" 2>/dev/null
|
||
;;
|
||
alsa)
|
||
amixer -q set Master "${target}%" 2>/dev/null
|
||
;;
|
||
esac
|
||
fi
|
||
}
|
||
|
||
# ----------------------------
|
||
# Audio Output Detection
|
||
# ----------------------------
|
||
is_using_headphones() {
|
||
if [ "$PLATFORM" = "macos" ]; then
|
||
local output
|
||
local transport
|
||
local detection_method
|
||
|
||
if command -v SwitchAudioSource >/dev/null 2>&1; then
|
||
output=$(SwitchAudioSource -c 2>/dev/null || echo "")
|
||
detection_method="SwitchAudioSource"
|
||
debug "Audio detection method: $detection_method"
|
||
debug "Raw audio output: '$output'"
|
||
|
||
if echo "$output" | grep -qi "Speaker\|Built-in Output"; then
|
||
debug "Match found: Built-in speakers"
|
||
return 1
|
||
else
|
||
debug "No speaker match: Assuming headphones/external"
|
||
return 0
|
||
fi
|
||
else
|
||
transport=$(system_profiler SPAudioDataType 2>/dev/null | awk '
|
||
/Default Output Device: Yes/ {found=1}
|
||
found && /Transport:/ {print $2; exit}
|
||
')
|
||
|
||
detection_method="system_profiler (Transport)"
|
||
debug "Audio detection method: $detection_method"
|
||
debug "Transport type: '$transport'"
|
||
|
||
if [ "$transport" = "Built-in" ]; then
|
||
debug "Built-in transport: Using speakers"
|
||
return 1
|
||
else
|
||
debug "External transport ($transport): Using headphones"
|
||
return 0
|
||
fi
|
||
fi
|
||
else
|
||
# Linux headphone detection
|
||
case "$AUDIO_SYSTEM" in
|
||
pipewire)
|
||
# Check active port on default sink
|
||
local port
|
||
port=$(wpctl inspect @DEFAULT_AUDIO_SINK@ 2>/dev/null | grep -i "node.nick" | cut -d'"' -f2)
|
||
debug "PipeWire active device: '$port'"
|
||
|
||
# Check if it's headphones/external
|
||
if echo "$port" | grep -iq "headphone\|bluetooth\|usb"; then
|
||
debug "Detected: Headphones/External"
|
||
return 0
|
||
else
|
||
debug "Detected: Speakers/Internal"
|
||
return 1
|
||
fi
|
||
;;
|
||
pulseaudio)
|
||
# Check active port on default sink
|
||
local active_port
|
||
active_port=$(pactl list sinks 2>/dev/null | grep -A 50 "State: RUNNING" | grep "Active Port:" | head -1 | awk '{print $3}')
|
||
debug "PulseAudio active port: '$active_port'"
|
||
|
||
# Check port name patterns
|
||
if echo "$active_port" | grep -iq "headphone\|bluetooth\|usb"; then
|
||
debug "Detected: Headphones/External"
|
||
return 0
|
||
else
|
||
debug "Detected: Speakers/Internal"
|
||
return 1
|
||
fi
|
||
;;
|
||
alsa)
|
||
# ALSA doesn't reliably distinguish - assume headphones if volume control exists
|
||
debug "ALSA: Cannot reliably detect headphones, assuming speakers"
|
||
return 1
|
||
;;
|
||
esac
|
||
fi
|
||
}
|
||
|
||
# ----------------------------
|
||
# Notification
|
||
# ----------------------------
|
||
notify() {
|
||
local message="$1"
|
||
|
||
debug "Sending notification: $message"
|
||
|
||
if [ "$PLATFORM" = "macos" ]; then
|
||
if command -v terminal-notifier >/dev/null 2>&1; then
|
||
terminal-notifier -group "volumizer-status" -title "Volume Limiter" -message "$message" >/dev/null 2>&1
|
||
else
|
||
osascript -e "display notification \"$message\" with title \"Volume Limiter\"" 2>/dev/null
|
||
fi
|
||
else
|
||
if command -v notify-send >/dev/null 2>&1; then
|
||
notify-send -u normal -r 9999 "Volume Limiter" "$message" 2>/dev/null
|
||
fi
|
||
fi
|
||
}
|
||
|
||
# ----------------------------
|
||
# State Management
|
||
# ----------------------------
|
||
get_last_state() {
|
||
cat "$STATE_FILE" 2>/dev/null || echo ""
|
||
}
|
||
|
||
save_state() {
|
||
echo "$1" >"$STATE_FILE"
|
||
}
|
||
|
||
# ----------------------------
|
||
# Main Logic
|
||
# ----------------------------
|
||
debug "Starting main loop"
|
||
|
||
# Show startup notification
|
||
if [ "$ONLY_ON_HEADPHONES" = false ]; then
|
||
notify "🔊 Limiting volume to $MAX_VOLUME%"
|
||
fi
|
||
|
||
# Main loop
|
||
if [ "$DEBUG" = true ]; then
|
||
ITERATION=0
|
||
fi
|
||
|
||
while true; do
|
||
if [ "$DEBUG" = true ]; then
|
||
ITERATION=$((ITERATION + 1))
|
||
debug "--- Iteration $ITERATION ---"
|
||
fi
|
||
|
||
CURRENT_VOLUME=$(get_volume)
|
||
debug "Current volume: $CURRENT_VOLUME"
|
||
|
||
if [ "$ONLY_ON_HEADPHONES" = true ]; then
|
||
# Check headphone status
|
||
if is_using_headphones; then
|
||
CURRENT_STATE="headphones"
|
||
else
|
||
CURRENT_STATE="speakers"
|
||
fi
|
||
|
||
debug "Current state: $CURRENT_STATE"
|
||
|
||
LAST_STATE=$(get_last_state)
|
||
debug "Last state: $LAST_STATE"
|
||
|
||
# Notify on state change
|
||
if [ "$CURRENT_STATE" != "$LAST_STATE" ]; then
|
||
debug "State changed from '$LAST_STATE' to '$CURRENT_STATE'"
|
||
if [ "$CURRENT_STATE" = "headphones" ]; then
|
||
notify "🎧 Limiting headphones to $MAX_VOLUME%"
|
||
else
|
||
notify "Volume limiter paused (speakers)"
|
||
fi
|
||
save_state "$CURRENT_STATE"
|
||
fi
|
||
|
||
# Only limit volume when using headphones
|
||
if [ "$CURRENT_STATE" = "headphones" ] && [ "$CURRENT_VOLUME" -gt "$MAX_VOLUME" ]; then
|
||
debug "Volume $CURRENT_VOLUME exceeds limit $MAX_VOLUME, reducing"
|
||
set_volume "$MAX_VOLUME"
|
||
else
|
||
debug "No volume adjustment needed"
|
||
fi
|
||
else
|
||
# Always limit volume
|
||
if [ "$CURRENT_VOLUME" -gt "$MAX_VOLUME" ]; then
|
||
debug "Volume $CURRENT_VOLUME exceeds limit $MAX_VOLUME, reducing"
|
||
set_volume "$MAX_VOLUME"
|
||
else
|
||
debug "Volume within limit"
|
||
fi
|
||
fi
|
||
|
||
debug "Sleeping for $DELAY seconds"
|
||
sleep "$DELAY"
|
||
done
|