.local-bin/scripts/volumizer.sh
2026-02-09 13:52:33 -05:00

369 lines
8.9 KiB
Bash
Executable file
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/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 (0100)
# --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