Powered by AppSignal & Oban Pro
Would you like to see your link here? Contact us

OSCx tour

livebook/oscx_tour.livemd

OSCx tour

Mix.install([{:oscx, "~> 0.1.1"}])

Setup

Create aliases for Message, Encoder and Decoder.

alias OSCx.{Message, Encoder, Decoder}
[OSCx.Message, OSCx.Encoder, OSCx.Decoder]

Create an OSC message

An OSC message is defined using the %OSCx.Message{} struct.

You can use the OSCx.Message.new/1 function, e.g.:

Message.new(address: "/status", arguments: ["my string argument"])

or populate the struct directly, see the cell below:

msg = %Message{address: "/status", arguments: []}
%OSCx.Message{address: "/status", arguments: []}

Encode the message

The OSCx.encode/1 function can be used to encode the struct to an OSC binary message.

encoded_osc_msg = OSCx.encode(msg)
<<47, 115, 116, 97, 116, 117, 115, 0, 44, 0, 0, 0>>

The message above is now ready to be sent via UDP.

Send an encoded message using UDP

Download Protokol

For this part, you may want to download an OSC data inspection tool, like Protokol.

Protokol is a free responsive console app for monitoring and logging control protocols, such as OSC. It can log MIDI and Gamepad inputs.

It’s available on Mac, Windows and Linux.

We’ll use Protokol to act as the OSC server (recipient) of our message.

Enable OSC in Protokol

Once Protokol is installed, load it and click the ‘OSC’ tab. Make sure Enabled is checked on.

Open a UDP port

Erlang and therefore Elixir comes with the :gen_udp library for opening UDP ports and sending and receiving messages.

Lets use it to send an OSC message to Protokol. By default, Protokol listens on port 8000 but you can change this if necessary.

Assuming Protokol is running on the same machine as this Livebook, we’ll use the localhost IP address of 127.0.0.1 (or you can change this to 'localhost' if you prefer).

ip_address = ~c"127.0.0.1"
port_num = 8000

# Open a port
{:ok, port} = :gen_udp.open(0, [:binary, {:active, true}])
{:ok, #Port<0.8>}

Now the port is open, lets send our message using :gen_udp.send/4 as below:

:gen_udp.send(port, ip_address, port_num, encoded_osc_msg)
:ok

You should now see our message with "/status" on Protokol’s OSC tab:

To make it more interesting, lets send a more complex message by adding arguments to the %OSCx.Messages{arguments: []} key like this:

encoded_osc_msg =
  %Message{
    address: "/some/address",
    arguments: [1, 2.0, [:A, :B, :C], "Hello world", true, false, nil, :impulse]
  }
  |> OSCx.encode()
<<47, 115, 111, 109, 101, 47, 97, 100, 100, 114, 101, 115, 115, 0, 0, 0, 44, 105, 102, 91, 83, 83,
  83, 93, 115, 84, 70, 78, 73, 0, 0, 0, 0, 0, 0, 1, 64, 0, 0, 0, 65, 0, 0, 0, 66, 0, 0, 0, 67, 0,
  ...>>

This message has a mix of OSC types and values:

OSC type Elixir type Elixir example
Integer Integer 1
Float Float 2.0
Array List [1, 2, 3]
String String "Hello world"
Symbol Atom :A
True true true
False false false
Null nil nil or :null
Impulse Atom (of value :impulse) :impulse

Let’s send it as before:

:gen_udp.send(port, ip_address, port_num, encoded_osc_msg)
:ok

You should now see our richer OSC message on the second line in Protokol: Unfortunately Protokol doesn’t show the array of symbols. This is something you may come across in OSC. Some types are optional parts of the OSC standard and are not implemented on all software.

Examples of some optional or non-standard types are:

  • Symbols (e.g. Atoms in Elixir)
  • 64-bit integers or floats
  • Single ASCII character
  • RGBA colour
  • MIDI message
  • Array (e.g. List in Elixir)

You can send an OSC array of other data instead:

bin_osc_msg =
  %Message{
    address: "/some/address",
    arguments: [1, 2, 3, ["X", "Y", "Z"]]
  }
  |> OSCx.encode()

:gen_udp.send(port, ip_address, port_num, bin_osc_msg)
:ok

You can see on the third line that the list values in the last was also received:

MIDI messages via OSC can also be sent by wrapping the binary MIDI message in a %{midi: value} map as follows:

bin_osc_msg =
  %Message{
    address: "/some/address",
    arguments: [%{midi: <<0x90, 60, 127>>}]
  }
  |> OSCx.encode()

:gen_udp.send(port, ip_address, port_num, bin_osc_msg)
:ok

You should see the MIDI message type and data appear in the Protokol screen:

The OSC Spec requires a 4-byte MIDI message, defined as: port id, status byte, data1, and data2.

However, MIDI messages are predominatly consist of 3-bytes (a status byte, followed by 2 data bytes). If a 3 byte MIDI message is given, OSCx will prepend a port ID of <<0>> to make up the 1 byte difference.

If the OSC server (receiver) supports it (Protokol doesn’t), RGBA colours can be sent as follows:

bin_osc_msg =
  %Message{
    address: "/some/address",
    arguments: [%{rgba: [153, 234, 69, 1]}]
  }
  |> OSCx.encode()

:gen_udp.send(port, ip_address, port_num, bin_osc_msg)
:ok

Receiving OSC messages

Using OSC to send and receive messages from audio software is a common use-case.

This example assumes SuperCollider is installed and running on the same machine as this Livebook.

Install SuperCollider

In this example we’ll use SuperCollider. SuperCollider is an ‘audio synthesis and algorithmic composition platform’. It’s used by musicials, artists and researchers working with sound.

We can interact with SuperCollider using OSC to produce audio.

SuperCollider is available on a range of platforms: https://supercollider.github.io/downloads

Boot SuperCollider

Launch SuperCollider, and from it’s menu, select Server > Boot Server for it to boot.

Create a GenServer

SuperCollider listents to UDP port 57110 by default. We’ll use the GenServer’s state to hold the UDP socket, and implement a handle_info callback where we’ll receive UDP messages from SuperCollider.

defmodule SC do
  use GenServer

  @impl true
  def init(_state) do
    # Open a port and add the UDP socket to the state
    {:ok, socket} = :gen_udp.open(0, [:binary, {:active, true}])

    {:ok, socket}
  end

  @impl true
  def handle_cast({:send, osc_bin_msg}, state) do
    # This could be changed to named address, like 'localhost'
    ip_address = ~c"localhost"
    sc_port_num = 57110
    :gen_udp.send(state, ip_address, sc_port_num, osc_bin_msg)

    {:noreply, state}
  end

  @impl true
  def handle_info(msg, state) do
    case msg do
      {:udp, _process_port, _ip_addr, _port_num, res} ->
        IO.inspect(res, label: "Binary message received")
        IO.inspect(Message.decode(res), label: "\nDecoded message")
        state

      _ ->
        state
    end

    {:noreply, state}
  end

  def start_link() do
    GenServer.start_link(SC, nil)
  end

  def send(pid, osc_bin_msg) do
    GenServer.cast(pid, {:send, osc_bin_msg})
  end
end
{:module, SC, <<70, 79, 82, 49, 0, 0, 20, ...>>, {:send, 2}}

Let’s start our GenServer so it’s ready to send and receive messages:

{:ok, sc_pid} = SC.start_link()
{:ok, #PID<0.272.0>}

Let’s create a simple message and watch for a response.

One of the simplest messages to SuperCollider is sending a message with the address "/status" and no arguments:

encoded_osc_msg =
  %Message{address: "/status"}
  |> OSCx.encode()

SC.send(sc_pid, encoded_osc_msg)
:ok
Binary message received: <<47, 115, 116, 97, 116, 117, 115, 46, 114, 101, 112, 108, 121, 0, 0, 0, 44,
  105, 105, 105, 105, 105, 102, 102, 100, 100, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0,
  0, 0, 0, 0, 0, 0, 2, 0, 0, 0, 111, 60, 201, ...>>

Decoded message: %OSCx.Message{
  address: "/status.reply",
  arguments: [1, 0, 0, 2, 111, 0.024587105959653854, 0.06639117002487183,
   44100.0, 44099.94006284866]
}

If successfull, you’ll see from IO.inspect the binary OSC message received e.g. <<47, 115, 116...>> and it decoded as a struct:

%OSCx.Message{
  address: "/status.reply",
  arguments: [1, 0, 0, 2, 111, 0.029667392373085022, 0.14714068174362183]
}
Address

Notice how SuperCollider sends the same address back as we sent but with “.reply” appended to it: "/status.reply". When sending commands to SuperCollider, replies will often follow this convention.

Arguments

The arguments have meaning in SuperCollider, but you can see a mixture of integers and floats were returned.

Let’s try another simple message, this time we’ll send a message with the address "/version" and no arguments:

encoded_osc_msg =
  %Message{address: "/version"}
  |> OSCx.encode()

SC.send(sc_pid, encoded_osc_msg)
:ok
Binary message received: <<47, 118, 101, 114, 115, 105, 111, 110, 46, 114, 101, 112, 108, 121, 0, 0, 44,
  115, 105, 105, 115, 115, 115, 0, 115, 99, 115, 121, 110, 116, 104, 0, 0, 0, 0,
  3, 0, 0, 0, 13, 46, 48, 0, 0, 86, 101, 114, 115, 105, 111, ...>>

Decoded message: %OSCx.Message{
  address: "/version.reply",
  arguments: ["scsynth", 3, 13, ".0", "Version-3.13.0", "3188503"]
}

In this case, you can see version information sent back in the decoded message arguments:

%OSCx.Message{
  address: "/version.reply",
  arguments: ["scsynth", 3, 13, ".0", "Version-3.13.0", "3188503"]
}

You message may be slightly different to this, depending on what you’ve installed locally.

Play a sound

You can also try asking SuperCollider to play some audio:

# Send a message to play the default sound with a new synth
synth_definition_name = "default"
synth_node_id = 400
add_action = 0
add_target_id = 0

encoded_osc_msg =
  %Message{
    address: "/s_new",
    arguments: [synth_definition_name, synth_node_id, add_action, add_target_id]
  }
  |> OSCx.encode()

SC.send(sc_pid, encoded_osc_msg)
:ok

SuperCollider’s default tone should be playing.

Stop the sound

To stop the sound, evaluate the next cell:

encoded_osc_msg =
  %Message{
    address: "/n_free",
    arguments: [synth_node_id]
  }
  |> OSCx.encode()

SC.send(sc_pid, encoded_osc_msg)
:ok

Wrap up

This ends the simple tour of the OSCx library.

In summary:

  • Create OSCx messages by using the %OSCx.Message{} struct
  • Encode them with OSCx.encode/1
  • You can send and receive OSC messages via UDP, using Erlang’s :gen_utp library.
  • When receiving messages, as with our SuperCollider example above, use the OSCx.decode/1 function and it will parse the binary OSC message and populate the %OSCx.Message{} struct for you.