Spouting VBAN into Ruby

The VBAN protocol is what drives the best parts of VoiceMeeter. It has a pretty friendly spec document, and a brief implementation on GitHub by the original author. Outside of this, it's pretty much a best-guess with experimentation on speaking the language. There's a very helpful Python implementation too that helped with debugging a few quirks.

VBAN has a sub-protocol for Service data, which is what we'll be chatting with to get data in and out of VoiceMeeter. The spec document says it's partially implemented, but seems to work with VoiceMeeter Potato (at 3.0.2.2 anyway).

Starting with Ruby, a  RT packet looks something like this:

require 'bindata'

class VbanPacket < BinData::Record
  endian :little

  SUB_PROTOCOLS = [
    :audio, :serial, :txt, :service, :undefined1, :undefined2, :undefined3, :user
  ]

  string :vban, length: 4
  uint8 :sample_rate_protocol
  
  def sub_protocol_raw
    sample_rate_protocol >> 5 # Top three bits only
  end

  def sub_protocol
    SUB_PROTOCOLS[sub_protocol_raw]
  end
end

class RtPacket < VbanPacket

  VOICEMEETER_TYPES = [
    '', 'basic', 'banana', 'potato'
  ]

  uint8 :function
  uint8 :service
  uint8 :additional_info
  string :stream_name, length: 16
  uint32 :frame_id

  # packet body
  uint8 :voicemeeter_type_raw
  uint8 :reserved
  uint16 :buffer_size
  array :voicemeeter_version_raw, type: :int8, initial_length: 4
  uint32 :options
  uint32 :sample_rate
  array :input_levels_raw, type: :int16, initial_length: 34
  array :output_levels_raw, type: :int16, initial_length: 64
  uint32 :transport
  array :strip_state, type: :uint32, initial_length: 8
  array :bus_state, type: :uint32, initial_length: 8
  array :strip_gain_layer_1, type: :uint16, initial_length: 8
  array :strip_gain_layer_2, type: :uint16, initial_length: 8
  array :strip_gain_layer_3, type: :uint16, initial_length: 8
  array :strip_gain_layer_4, type: :uint16, initial_length: 8
  array :strip_gain_layer_5, type: :uint16, initial_length: 8
  array :strip_gain_layer_6, type: :uint16, initial_length: 8
  array :strip_gain_layer_7, type: :uint16, initial_length: 8
  array :strip_gain_layer_8, type: :uint16, initial_length: 8
  array :bus_gain, type: :uint16, initial_length: 8
  array :strip_labels, initial_length: 8 do
    string length: 60, trim_padding: true
  end
  array :bus_labels, initial_length: 8 do
    string length: 60, trim_padding: true
  end

  def voicemeeter_type
    VOICEMEETER_TYPES[voicemeeter_type_raw]
  end

  def voicemeeter_version
    voicemeeter_version_raw.to_a.reverse.join('.')
  end
end

The input_levels an output_levels are packed according to this layout, with physical inputs having 2 channels, and virtual inputs and bus outputs having 8 channels each.

To get VoiceMeeter to spout all of its lovely internals at us, we have to 'register' as a receiver. This register packet just tells VoiceMeeter what we're interested in receiving and for how long. You have to periodically resend this register packet to ensure that you keep receiving all that wonderful state goodness. A register packet looks something like this:

class RegisterRtPacket < VbanPacket

  RTPACKET_REGISTER = 32

  uint8 :function
  uint8 :service
  uint8 :additional_info
  string :stream_name, length: 16
  uint32 :frame_id
  array :packet_ids, type: :uint8, initial_length: 128

end

If you don't specify any packet IDs, then you should receive all of them. Actually sending the packet gets a bit meatier, but still easily doable.

require 'socket'
require 'thread'

@vban = UDPSocket.new
@vban.bind('192.168.x.x', 6980)
@outgoing_vban = Queue.new

# vban sender
Thread.new do
  loop do
    message = @outgoing_vban.pop(true) rescue next
    @vban.send(message.to_binary_s, 0, '192.168.x.x', 6980)
  end
end

register_rt_packet = RegisterRtPacket.new.tap do |p|
  p.vban = "VBAN"
  p.sub_protocol = :service
  p.function = 0
  p.service = RegisterRtPacket::RTPACKET_REGISTER
  p.additional_info = 50
  p.stream_name = "X-Touch meters"
  p.frame_id = 1
end

@outgoing_vban << register_rt_packet

From there, you should start to get VoiceMeeter spouting a lot of data down that same port, but we'll get to that next time!