Low-latency protocol for media. SHM and UDP implementation. https://slay.tools/
  • Rust 99.6%
  • Shell 0.4%
Find a file
Slaygon bb47eb7d80 feat: non-blocking data socket, shed datagrams under send pressure
A realtime sender must never stall on a full send queue. The video data
socket was blocking, so on macOS -- which has no UDP send-side socket buffer,
send_to hands the datagram straight to the shallow loopback/interface queue --
a large raw NV12 frame's ~2200-datagram burst blocked send_to waiting for the
receiver to drain, throttling the stream to ~4 fps. The tell was complete
frames with no partials: a sender-side stall, not receiver-side loss. Linux's
~208 KB send buffer absorbs the same burst and only sheds a few chunks that
FEC recovers.

Make the data socket non-blocking so a full queue drops the datagram
(WouldBlock/ENOBUFS, already ignored at the send site) instead of stalling;
udp_send_all's per-packet pacing and FEC carry the stream. The socket is
send-only (inbound PKT_MSG uses ctrl_sock), so reads are unaffected.

Bump to 0.5.0.
2026-07-04 17:46:11 +02:00
src feat: non-blocking data socket, shed datagrams under send pressure 2026-07-04 17:46:11 +02:00
.gitignore Initial commit 2026-05-14 15:33:33 +02:00
build.sh feat(protocol): add sender_ts_ns to wire and SHM frames (protocol v3) 2026-05-31 11:51:23 +02:00
Cargo.lock feat: non-blocking data socket, shed datagrams under send pressure 2026-07-04 17:46:11 +02:00
Cargo.toml feat: non-blocking data socket, shed datagrams under send pressure 2026-07-04 17:46:11 +02:00
LICENSE-APACHE chore: add MIT OR Apache-2.0 dual license 2026-06-21 15:58:15 +02:00
LICENSE-MIT chore: add MIT OR Apache-2.0 dual license 2026-06-21 15:58:15 +02:00
PROTOCOL.md Surface inbound operator messages; bump to 0.3.0 2026-06-22 16:39:53 +02:00
README.md Actually point README to correct version 2026-06-29 23:46:24 +02:00

slay-slm

Rust implementation of the SLM (SLAY Media) protocol sender -- a low-latency protocol for media.

It is an attempt to get as close to real-time interaction between an external "VJ" application, like a soundboard or video clip player, and OBS as possible. Initial attempts were made using NDI, but that added several hundred milliseconds unpredictibly (at worst) of latency between hitting "play" and actually getting the content through to OBS, which is why this project exists.

Handles multicast discovery, the HELLO/CAPS handshake, UDP frame delivery, and shared memory transport (POSIX on Unix, Named File Mappings on Windows). Wire format is defined in PROTOCOL.md.

More information at the SLAY Tools home page.


What it does

  • Broadcasts multicast ANNOUNCE packets (~1 Hz) so receivers can find the sender
  • Accepts HELLO from receivers and replies with CAPS (stream parameters)
  • Sends NV12 video frames and f32le audio frames to all registered receivers over UDP
  • Writes frames into a shared memory ring buffer for same-machine receivers (no UDP overhead)
  • Tracks receiver liveness and expires stale entries automatically
  • Surfaces short operator messages (MSG) that receivers send back over the control channel

What it does not do

Frame production is the caller's responsibility. This crate accepts raw NV12 bytes and f32le PCM samples; it does not decode video or audio.


Usage

[dependencies]
slay-slm = "0.4"

Or, if you want to live on the bleeding edge:

[dependencies]
slay-slm = { git = "https://git.c64.org/slayradio-public/slay-slm.git", branch = "main" }
use slay_slm::{SlmConfig, SlmSender};

let sender = SlmSender::new(SlmConfig {
    name:       "My Source".into(),
    width:      1920,
    height:     1080,
    fps_num:    30,
    fps_den:    1,
    compress:   true,   // LZ4 on the UDP video path
    audio_only: false,
})?;

// In your frame loop (synchronous; use block_in_place from async):
sender.send_video(timestamp_ns, &nv12_bytes);
sender.send_audio(timestamp_ns, &f32le_samples);

// Between clips:
sender.clear_disconnect();   // before starting
sender.send_disconnect();    // after ending, so the receiver flushes its frame queue
sender.update_heartbeat();   // call from idle loops to stay alive

// On shutdown:
sender.send_bye();

A receiver (for example the OBS plugin's "Send message to operator" button) can send short operator messages back over the control channel. Drain them from your status-polling loop:

for msg in sender.take_messages() {
    println!("operator: {msg}");
}

take_messages() returns the messages received since the last call, oldest first, and empties the queue.

SlmSender is Clone and backed by an Arc, so you can share a handle between your feed loop and a status-polling thread without wrapping it yourself.

All send methods are synchronous. When calling from an async tokio task, wrap them in tokio::task::block_in_place.


Platform notes

Feature Linux / macOS Windows
UDP transport yes yes
SHM transport yes (POSIX shm_open) yes (Win32 Named File Mapping)

The shared memory layout is byte-for-byte identical on both platforms; only the OS primitives differ. On Unix the region is a POSIX named SHM object (/slm-<token>); on Windows it is a page-file-backed Named File Mapping (Local\slm-<token>), where <token> is a 16-hex-char FNV-1a-64 hash of the sender name -- a fixed length, so the name stays within macOS's 31-char shm_open limit. Both use named semaphores for wake-up signalling.

The Unix path is gated behind #[cfg(unix)] (requires librt on Linux, linked automatically via libc). The Windows path is gated behind #[cfg(windows)] (requires windows-sys). On any other platform the transport is a no-op stub and all frames go over UDP only.


Protocol

See PROTOCOL.md for the full wire format specification, packet layouts, session lifecycle, and SHM region layout. The C header files in the slm-wire-for-obs repo (slm_protocol.h, slm_shm.h) are the normative cross-language reference.