|
require 'socket' |
|
|
|
class Carabiner |
|
DEFAULT_QUANTUM = 4 |
|
|
|
def initialize |
|
# assumes Carabiner has already been started |
|
@socket = TCPSocket.open("localhost", 17000) |
|
end |
|
|
|
def status |
|
command("status") |
|
end |
|
|
|
# convenience function |
|
def time_now_in_micros |
|
# this may vary across platforms and Ruby versions |
|
# For parity with Link we need Process::CLOCK_UPTIME_RAW for |
|
# > mac os 10.12 and > Ruby 2.4 |
|
# else |
|
# Process::CLOCK_MONOTONIC |
|
Process.clock_gettime(Process::CLOCK_UPTIME_RAW, :microsecond) |
|
end |
|
|
|
def set_bpm(bpm) |
|
# todo clamp to within 20.0 to 999.0 |
|
command("bpm", bpm) |
|
end |
|
|
|
def beat_at_time(micro_time, quantum = DEFAULT_QUANTUM) |
|
command("beat-at-time", micro_time, quantum) |
|
end |
|
|
|
def phase_at_time(micro_time, quantum = DEFAULT_QUANTUM) |
|
command("phase-at-time", micro_time, quantum) |
|
end |
|
|
|
def time_at_beat(beat, quantum = DEFAULT_QUANTUM) |
|
command("time-at-beat", beat, quantum) |
|
end |
|
|
|
def force_beat_at_time!(beat, micro_time, quantum = DEFAULT_QUANTUM) |
|
# WARNING: This command should only be used when synchronizing a Link |
|
# session to an external timing source which cannot participate in the |
|
# consensus-based Link timeline, and should be done only when the session |
|
# has drifted beyond some reasonable threshold, so that external jitter does |
|
# not lead to excessive adjustments. |
|
|
|
# If you are building an application that can perform quantized starts, and |
|
# thereby participate in a Link session more graciously, without requiring |
|
# the other participants to shift the timeline, you should use the following |
|
# command instead: |
|
command("force-beat-at-time", beat, micro_time, quantum) |
|
end |
|
|
|
def request_beat_at_time(beat, micro_time, quantum = DEFAULT_QUANTUM) |
|
# Ask Link to try to gracefully adjust its timeline so that the specified |
|
# beat will occur at the specified time. If there are no other peers in the |
|
# Link session, this will behave the same as force-beat-at-time, above. |
|
# However, if there are any peers, it will avoid the kinds of audible |
|
# discontinuities described above, by adjusting the local timeline so that |
|
# the specified beat will instead fall at the next point in time after the |
|
# requested time which has the same phase as the specified beat. |
|
|
|
# Carabiner responds with a status message which reports the new :start |
|
# timestamp of the timeline. |
|
command("request-beat-at-time", beat, micro_time, quantum) |
|
end |
|
|
|
def enable_start_stop_sync |
|
command("enable-start-stop-sync") |
|
end |
|
|
|
def disable_start_stop_sync |
|
command("disable-start-stop-sync") |
|
end |
|
|
|
def start_playing_at(micro_time) |
|
command("start-playing", micro_time) |
|
end |
|
|
|
def stop_playing_at(micro_time) |
|
command("stop-playing", micro_time) |
|
end |
|
|
|
private |
|
|
|
def command(*args) |
|
# example response returned by carabiner |
|
# "status { :peers 1 :bpm 120.000000 :start 17526103328 :beat 7521.742530 }" |
|
|
|
puts "DEBUG *#{args.join(" ")}*" |
|
@socket.sendmsg args.join(" ") |
|
res = @socket.recvmsg[0] |
|
|
|
if res[/unsupported|bad/] |
|
$stderr.puts "Received bad response for '#{args.join(' ')}': #{res}" |
|
return |
|
end |
|
|
|
edn_data = res. |
|
# strip "status { ... }" |
|
match(/\w+ {([^\}]+)}/)[1].strip. |
|
# split on ":<key> <value>" |
|
split(/:([^\s]+)\s/). |
|
# reject blank "" at start |
|
slice(1..-1) |
|
|
|
# [key, val, key, val] has a to_hash method |
|
# vals are currently strings - use eval to get the proper Ruby types |
|
edn_data.each_slice(2).collect {|k,v| [k.to_sym, eval(v)] }.to_h |
|
end |
|
end |
|
|
|
# inline tests ftw |
|
|
|
ca = Carabiner.new |
|
ca.set_bpm(80) |
|
5.times do |
|
puts ca.status |
|
end |
|
ca.set_bpm(120) |
|
5.times do |
|
puts ca.status |
|
end |
|
puts "Time now in microseconds: #{ca.time_now_in_micros}" |
|
puts "Time of beat 500us from now: #{ca.beat_at_time(ca.time_now_in_micros + 500)}" |
|
puts "Time of phase 500us from now: #{ca.phase_at_time(ca.time_now_in_micros + 500)}" |
|
puts "Time at beat 100: #{ca.time_at_beat(100)}" |
|
|
|
puts "Force resetting beat 4 to be 500us from now" |
|
puts "" |
|
ca.force_beat_at_time!(4, ca.time_now_in_micros + 500) |
|
puts "Time of beat now: #{ca.beat_at_time(ca.time_now_in_micros + 500)}" |
|
|
|
puts "" |
|
|
|
puts "Gracefully resetting beat 1 to be 500us from now" |
|
puts "" |
|
ca.request_beat_at_time(4, ca.time_now_in_micros + 500) |
|
puts "Time of beat now: #{ca.beat_at_time(ca.time_now_in_micros + 500)}" |
|
|
|
puts "Enable start/stop sync #{ca.enable_start_stop_sync}" |
|
puts |
|
|
|
sleep 1 |
|
|
|
puts "Start playing #{ca.start_playing_at(ca.time_now_in_micros)}" |
|
puts |
|
|
|
sleep 4 |
|
|
|
puts "Stop playing #{ca.stop_playing_at(ca.time_now_in_micros)}" |
|
puts |
|
|
|
puts "Disable start/stop sync #{ca.disable_start_stop_sync}" |
|
puts |