#!/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 Maximum allowed volume (0–100) # --delay 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