RTP Midi Transport - JoueBien/rtp-midi-transport GitHub Wiki

Overview

The library supports operating in both a server (master) and client (slave) modes. This library doesn't use the standard RTP slave and master nomenclatures. Apple's version of the standard has dedicated operation modes which are better described as server and client. The original RTP MIDI specification more or less is a bunch of network ports screaming into the void, where it's original naming makes some sense.

The server is responsible for:

  • keeping track of the surfaces state.
  • updating multiple clients of state changes.
  • updating internal state based on messages sent from clients.

The client is responsible for:

  • listening to the server's state changes.
  • telling the server if any state needs to be changed.

A client can only have one connection. A server can have multiple clients.

Ports used

Communication for RTP MIDI is done over two UDP ports. The first one (defaults to 5000) is used for sending control messages and the second one (defaults to 5001) for MIDI messages and clock sync messages. The RTP MIDI specification expects the first and second port numbers to be sequential, with the second port being one larger than the first.

The RTP MIDI specification expects the server and client to talk back and fourth over the same two ports. To make testing easier the transport doesn't enforce this and allows you to set both the ports messages are sent on and the ports messages are received on. This port flexibility is not usually available on real hardware however.

Transport handshake

The following is a description of what happens when connection is initiated between a client and server.

When a client wants to connect to a server it must first send an IN message on the control port. The server will respond with an OK message on the control port if it accepts the connection. If the server rejects the IN request it will send a NO message on the control port.

After receiving the first OK message on the control port, the client will send an IN message on the message port. The server will respond with an OK message on the message port if it accepts the connection. If the server rejects the IN request it will send a NO message on the message port.

After receiving the second IN message the client will start a clock sync by sending several CK (clock) messages at fixed intervals. In this libraries case 3 messages, the first one at 0ms the second one at 200ms and the third at 400ms. The server will also start its own clock sync by sending several CK (clock) messages at fixed intervals. This clock sync allows the client and server to figure out what the delay is like over the network.

Note: When running the transport as a server you are responsible for accepting connections and starting the clock sync as it's not implemented for you.

Cases where a handshake can be rejected

If the server receives an IN message where the client has the same SSRC id (the id for the hardware) as the server, the server will respond with a NO message. The client should then try to reconnect again after choosing a new SSRC id. The transport in client mode will choose a random SSRC id every time it is initialised.

When a server replies to a clients IN command with a OK or NO it must send the clients token back with the message. This token ensure the servers replies are in response to the clients original request. The transport in client mode will check that the token matches the one it originally sent and the connect will fail it it does not match.

Clock Pulse

For a connection to stay active between a client and a server the client must continue to send CK (clock) messages to the server (a clock pulse). This CK message must be sent at least once every 10 seconds. A server may choose to send CK events back to the client as well.

Responding to CK messages is handled on both the server and client modes of the transport. When the transport is in client mode it will preform the clock pulse on it's own. However you will need to implement the clock pulse on the server yourself as it is not implemented for you.

Hanging up

When you want to stop sending messages you need to send a BY message on the control port followed by a BY message on the message port. Once a BY message is received the transport should abort/deconstruct and stop sending messages.

The transport running in the client mode will automatically stop talking and abort the transport. If you need to run further code to clean up then you can listen for the BY event using onMessage or onOnceMessage.

client.onMessage({
  command: "BY",
  callback: (event) => {
    // Run teardown.
  },
});

client.onOnceMessage({
  command: "BY",
  callback: (event) => {
    // Run teardown.
  },
});

Transport as a client

The connect function will ask on both UDP ports if the server can accept a new connection. Once the new connection is started the transport will start the clock sync and set up the clock pulse to keep the connection alive.

If the connect function fails then the connection function will return a Failure.

import { MidiTransport } from "@joue-bien/rtp-midi-transport";

const client = new MidiTransport({
  controlClient: {
    remotePort: 5000,
    responsePort: 5033,
  },
  messageClient: {
    remotePort: 5001,
    responsePort: 5034,
  },
  // The custom name of the client device.
  hardwareName: "Client",
  // A custom or shared abort controller.
  cleanUpController: new AbortController(),
});

// Connect to server and start clock pulse.
await client.connect();

Sending control messages

You can send the following control messages:

  • CK - clock Message.
  • IN - ask if server can start reiving a new connection.
  • OK - accept connection message.
  • NO - reject connection message.
  • BY - tell the server the client no longer wants to receive messages.
// Sending on the control port.
await client.send({
  BY: {
    header: "BY",
    on: "control",
  },
});

// Sending on the message port.
await client.send({
  CK: {
    header: "CK",
    timestamps: [timestamp.nowRTP64Bit()],
  },
});

Sending MIDI messages

You can send MIDI messages using send function. The supported MIDI commands are:

Channel Command Value Long Command Value
NoteOff 8   SystemExclusiveStart 240
NoteOn 9   TimeCodeQuarterFrame 241
PolyphonicAftertouch 10   SongPositionPointer 242
ControlChange 11   SongSelect 243
ProgramChange 12   TuneRequest 246
ChannelAftertouch 13   TimingClock 248
PitchBend 14   Start 250
      Continue 251
      Stop 252
      ActiveSensing 254
      SystemReset 255
await client.send({
  midi: {
    header: "midi",
    data: [
      {
        [9]: {
          label: "NoteOn",
          command: 9,
          channel: 1,
          data: [123, 123],
        },
      },
    ],
    timestamp: timestamp.nowRTP32Bit(),
  },
});

You can also send MIDI messages using the human readable annotated versions of the commands.

// Using annotated MIDI commands.
await client.send({
  midi: {
    header: "midi",
    data: [
      {
        [NoteOn]: {
          label: "NoteOn",
          command: NoteOn,
          channel: 1,
          data: [123, 123],
        } satisfies MidiData[typeof NoteOn],
      },
    ],
    timestamp: timestamp.nowRTP32Bit(),
  },
});

Transport as a Server

You can create a server that will listen on two UDP ports. This will only start listening you will need to set up the accept/reject and clock logic after a successful listen started.

import { MidiTransport } from "@joue-bien/rtp-midi-transport";

const server = new MidiTransport({
  controlClient: {
    responsePort: 5033,
  },
  messageClient: {
    responsePort: 5034,
  },
  // The custom name of the server device.
  hardwareName: "Server",
  // A custom or shared abort controller.
  cleanUpController: new AbortController(),
});

await server.listen();

Responding control messages

You can respond to a client the following control messages:

  • CK - clock Message.
  • IN - ask if server can start reiving a new connection.
  • OK - accept connection message.
  • NO - reject connection message.
  • BY - tell the client the server no longer wants to send messages.
// Sending on the control port.
await server.respond({
  msg: {
    BY: { header: "BY", on: "control", token: 1234 },
  },
  to: {
    remoteAddress: "192.168.0.1",
    remotePort: 1234,
  },
});

// Sending on the message port.
await server.respond({
  msg: {
    BY: { header: "BY", on: "message", token: 1234 },
  },
  to: {
    remoteAddress: "192.168.0.1",
    remotePort: 1234,
  },
});

Responding MIDI messages

You can send MIDI messages using respond function. The supported MIDI commands are:

Channel Command Value Long Command Value
NoteOff 8   SystemExclusiveStart 240
NoteOn 9   TimeCodeQuarterFrame 241
PolyphonicAftertouch 10   SongPositionPointer 242
ControlChange 11   SongSelect 243
ProgramChange 12   TuneRequest 246
ChannelAftertouch 13   TimingClock 248
PitchBend 14   Start 250
      Continue 251
      Stop 252
      ActiveSensing 254
      SystemReset 255
await server.respond({
  msg: {
    midi: {
      header: "midi",
      data: [
        {
          9: {
            label: "NoteOn",
            command: 9,
            channel: 1,
            data: [123, 123],
          },
        },
      ],
      timestamp: timestamp.nowRTP32Bit(),
    },
  },
  to: {
    remoteAddress: "192.168.0.1",
    remotePort: 1234,
  },
});

You can also send MIDI messages using the human readable annotated versions of the commands.

// Using annotated MIDI commands.
await server.respond({
  msg: {
    midi: {
      header: "midi",
      data: [
        {
          [NoteOn]: {
            label: "NoteOn",
            command: NoteOn,
            channel: 1,
            data: [123, 123],
          } satisfies MidiData[typeof NoteOn],
        },
      ],
      timestamp: timestamp.nowRTP32Bit(),
    },
  },
  to: {
    remoteAddress: "192.168.0.1",
    remotePort: 1234,
  },
});

Listening for messages

Both the server and client can listen for control and MIDI messages. The listeners start listening immediately after being added.

On any message

You can listen for any control or MIDI messages using onAnyMessage:

const cleanUp = transport.onAnyMessage((event) => {
  const {
    // The decoded data - control or MIDI data.
    decoded,
    // The original buffer.
    msg,
    // control or message port.
    on,
    // Where the message came from.
    rinfo,
  } = event;
});

Call the returned cleanUp function to remove the listener.

On a single message

You can listen for the next control or MIDI messages using onOnceAnyMessage:

const cleanUp = transport.onOnceAnyMessage((event) => {
  const {
    // THe decoded data - control or MIDI data.
    decoded,
    // The original buffer.
    msg,
    // control or message port.
    on,
    // Where the message came from.
    rinfo,
  } = event;
});

Call the returned cleanUp function to remove the listener.

Wait for message

You can wait for the next command or MIDI message with waitForMessage:

const nextMessage = await transport.waitForMessage({
  command: "CK",
});

const nextMessage = await transport.waitForMessage({
  command: "midi",
});

On MIDI message

You can listen for a particular MIDI event using onMidiMessage:

const cleanUp = transport.onMidiMessage({
  command: 9,
  callback: (event) => {
    const { msg, decoded, on, rinfo } = event;
  },
});

Call the returned cleanUp function to remove the listener.

If you only want to trigger the callback when certain values are present you can add the matches parameter:

const cleanUp = transport.onMidiMessage({
  matches: {
    data: [123, 121],
  },
  command: 9,
  callback: (event) => {
    const { msg, decoded, on, rinfo } = event;
  },
});

On MIDI message once

You can listen for a particular MIDI event a single time using onMidiMessageOnce:

const cleanUp = transport.onMidiMessageOnce({
  command: 9,
  callback: (event) => {
    const { msg, decoded, on, rinfo } = event;
  },
});

Call the returned cleanUp function to remove the listener.

If you only want to trigger the callback when certain values are present you can add the matches parameter:

const cleanUp = transport.onMidiMessage({
  matches: {
    data: [123, 121],
  },
  command: 9,
  callback: (event) => {
    const { msg, decoded, on, rinfo } = event;
  },
});

Wait for MIDI message

You can async await the next MIDI message with waitForMidiMessage:

const nextMessage = await transport.waitForMidiMessage({
  command: 9,
});

If you only want to trigger the promise when certain values are present you can add the matches parameter:

const nextMessage = await transport.waitForMidiMessage({
  matches: {
    data: [123, 121],
  },
  command: 9,
});

Handling Errors

On any error

Register a function to run when an error is received.

const cleanUp = transport.onError((error) => {
  console.log(error);
});

// Un-register the function by calling the returned clean up.
cleanUp();

On single error

Register a function to run when the next error is received.

const cleanUp = transport.onOnceError((error) => {
  console.log(error);
});

// Un-register the function by calling the returned clean up.
cleanUp();

Disconnecting

To stop listeners and to release the response/remote ports you need to call abort on the transports abort controller.

transport.cleanUpController.abort();

// Alternatively the classes `deconstructor` will do the same thing.
transport.deconstructor();

Once the MIDI Transport has been aborted the transport is done and ready to be trash collected. If you want to re-establish a connection or change/update a connection you must create a new instance.

If you try to add a new message listener or send a message with an aborted transport an error will be returned instead.