- Rust 99.6%
- Shell 0.4%
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. |
||
|---|---|---|
| src | ||
| .gitignore | ||
| build.sh | ||
| Cargo.lock | ||
| Cargo.toml | ||
| LICENSE-APACHE | ||
| LICENSE-MIT | ||
| PROTOCOL.md | ||
| README.md | ||
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.