Skip to content

Instantly share code, notes, and snippets.

@2called-chaos
Created December 8, 2023 14:09
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save 2called-chaos/89181731c696965c20ce2aba8b8ddd7c to your computer and use it in GitHub Desktop.
Save 2called-chaos/89181731c696965c20ce2aba8b8ddd7c to your computer and use it in GitHub Desktop.
Switch between two audio devices on macOS depending on process presence (or other factors).
#!/usr/bin/env ruby
# > vban-autoswitch
# Switches between two audio devices depending on process presence and other conditions.
#
# @dependency SwitchAudioSource (brew install switchaudio-osx)
#
# Configuration is at the end of this file!
#
# Note: This must be running to work obviously.
# You can use Automator to create an application (run shell script, select ruby as shell and paste the script)
# or create a launchd script to run it at launch.
# ~$ vban-autoswitch
# current audio device: MacBook Pro Speakers
# [2023-12-08 14:52:55] app found (pid=27707)
# [2023-12-08 14:52:55] output audio device set to "BlackHole 2ch"
# [2023-12-08 14:52:55] system audio device set to "BlackHole 2ch"
# [2023-12-08 14:54:55] app quit (was pid=27707)
# [2023-12-08 14:54:55] output audio device set to "MacBook Pro Speakers"
# [2023-12-08 14:54:55] system audio device set to "MacBook Pro Speakers"
# [2023-12-08 14:55:12] app found (pid=27869)
# [2023-12-08 14:55:12] output audio device set to "BlackHole 2ch"
# [2023-12-08 14:55:12] system audio device set to "BlackHole 2ch"
# [2023-12-08 14:55:50] condition failed: ac_power (still running pid=27869)
# [2023-12-08 14:55:50] output audio device set to "MacBook Pro Speakers"
# [2023-12-08 14:55:51] system audio device set to "MacBook Pro Speakers"
# -----------------------
require "shellwords"
class VbanSwitcher
attr_accessor :debug, :check_start, :check_stop, :switch_system, :process_name, :device_a, :device_b, :notify, :sound_a, :sound_b
def initialize
@must_fulfill = {}
yield(self) if block_given?
end
def condition name, interval = false, &block
@must_fulfill[name] = [block, nil, interval, 0]
end
def find_process
pid = `pgrep -x #{process_name.shellescape}`.chomp
pid.to_i unless pid.empty?
end
def process_alive? pid
Process.getpgid(pid)
true
rescue Errno::ESRCH
false
end
def all_conditions_met?
@must_fulfill.all? do |name, ref|
b, v, i, l = ref
if !i || v.nil? || (Time.now - l).to_i > i
ref[1] = ref[0].call(self)
puts "[#{Time.now}] check: #{name} #{ref}" if debug
ref[3] = Time.now if i
end
ref[1]
end
end
def current_device
`SwitchAudioSource -t output -c`.chomp
end
def display_notification key, resolved, sound
cmd = %{osascript -e 'display notification "► #{resolved.gsub(/'"/, '')}" with title "VBAN-autoswitch" subtitle "switched to #{key}"}
cmd += %{ sound name "#{sound}"} if sound
cmd += %{'}
sleep 1 if sound # or sound may get eaten
system(cmd)
end
def change_device! to
key = :"DEVICE_#{to}"
resolved = send(key.to_s.downcase)
if resolved == current_device
puts "[#{Time.now}] switching to device `#{key}'(#{resolved}) skipped: already on that device" if debug
else
puts "[#{Time.now}] " + `SwitchAudioSource -t output -s #{resolved.shellescape}`.chomp
puts "[#{Time.now}] " + `SwitchAudioSource -t system -s #{resolved.shellescape}`.chomp if switch_system
display_notification(key, resolved, send("sound_#{to}".downcase)) if notify
end
end
def wait_for_pid
if !all_conditions_met?
unless @has_shown_failed_condition_while_waiting
puts "[#{Time.now}] condition failed: #{@must_fulfill.map{|n, r| n if r[1] == false }.compact.join(", ")}"
@has_shown_failed_condition_while_waiting = true
end
change_device!(:A) if @first_loop
sleep check_start
elsif @pid = find_process
@has_shown_failed_condition_while_waiting = false
puts "[#{Time.now}] app found (pid=#{@pid})"
change_device!(:B)
else
puts "[#{Time.now}] app not running" if debug
change_device!(:A) if @first_loop
sleep check_start
end
end
def wait_for_quit
alive = process_alive?(@pid)
if alive && all_conditions_met?
puts "[#{Time.now}] app still running (pid=#{@pid})" if debug
sleep check_stop
else
if alive
puts "[#{Time.now}] condition failed: #{@must_fulfill.map{|n, r| n if r[1] == false }.compact.join(", ")} (still running pid=#{@pid})"
else
puts "[#{Time.now}] app quit (was pid=#{@pid})"
end
@pid = nil
change_device!(:A)
end
end
def run!
puts "current audio device: #{current_device}"
@first_loop = true
loop do
@pid ? wait_for_quit : wait_for_pid
@first_loop = false
end
end
end
VbanSwitcher.new do |app|
# Device to use when process is ABSENT
app.device_a = "MacBook Pro Speakers"
# Device to use when process EXISTS
app.device_b = "BlackHole 2ch"
# Process (via pgrep -x) to look for
app.process_name = "VBAN Talkie Cherry"
# Also switch system sounds (notifications, etc.)
app.switch_system = true
# Message Center notifications
app.notify = true
# Sound for switching to either device, "" for default sound, false to disable
app.sound_a = "Bottle"
app.sound_b = "Blow"
# time between checks (in seconds)
app.check_start = 3.0 # uses pgrep shell-out (slower ~ 0.01 - 0.05)
app.check_stop = 1.0 # uses Process.getpgid (very fast < 0.0000)
# print more information
app.debug = false
# Define additional conditions that must be met in order to switch (or stay) on DEVICE_B.
# Note that these get checked often unless you specify a second argument (seconds to cache the value)
# Nil's won't get cached so return truthy or literally false.
#
#app.condition(:mandatory_unique_name, cache_in_seconds=false) { truthy_value_or_false_but_not_nil }
# examples deactivated by default, remove this condition (or set it to true) or move/copy the examples
if false
# Example: Only on AC-power
app.condition(:ac_power, 30) do
power_type = `pmset -g ps|sed -nE "s|.*'(.*) Power.*|\\1|p"`.chomp.downcase.to_sym
power_type == :ac # or :battery
end
# Example: Only when external display(s) are connected
app.condition(:external_displays, 30) do
displays = `system_profiler SPDisplaysDataType | grep -c Resolution`.chomp.to_i
displays > 1
end
# Example: Only when specific wifi is connected
app.condition(:wifi_ssid, 30) do
# get wifi card (typically en0 or en1, you may hardcode)
card = `networksetup -listallhardwareports | awk '/Wi-Fi|Airport/{getline; print $2}'`.chomp
#card = "en0"
ssid = `networksetup -getairportnetwork #{card.shellescape}`.chomp.split(": ", 2)[1]
ssid == "MyWifiNetwork"
end
end
app.run!
end
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment