Created
December 8, 2023 14:09
-
-
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).
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
#!/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