laser-dac
Unified DAC backend abstraction for laser projectors.
This crate provides a complete solution for communicating with various laser DAC hardware:
- Discovery: Automatically find connected DAC devices (USB and network)
- Frame API: Submit complete frames with automatic transition blanking and looping
- Streaming API: Zero-allocation callback API with buffer-driven timing (FIFO backends only)
- Backends: Unified interface across FIFO and frame-swap DAC types
Frame-swap DACs (Helios) use the Frame API exclusively. FIFO DACs (Ether Dream, LaserCube, IDN) work with both Frame and Streaming APIs.
Supported DACs
| DAC | Connection | Backend | Verified | Notes |
|---|---|---|---|---|
| Helios | USB | Frame-swap | ✅ | Frame API only |
| Ether Dream | Network | FIFO | ✅ | |
| IDN (ILDA Digital Network) | Network | FIFO | ✅ | IDN is a standardized protocol. We tested with HeliosPRO |
| LaserCube WiFi | Network | FIFO | ✅ | Recommend to not use through WiFi mode; use LAN only |
| LaserCube USB / Laserdock | USB | FIFO | ✅ | |
| AVB Audio Device | Network | FIFO | ✅ | Uses CoreAudio (macOS), ASIO on Windows by default (disable the asio default feature to use WASAPI and skip the Steinberg SDK), ALSA (Linux). Tested with LaserAnimation Sollinger. |
All DACs have been manually verified to work.
Quick Start
Connect your laser DAC and run an example:
# Frame API (recommended)
# Streaming API (advanced)
# Other
The examples run continuously until you press Ctrl+C.
Frame API (Recommended)
Submit complete frames and let the library handle looping, transition blanking, and transport-appropriate delivery. This is the recommended path for most applications.
use ;
let devices = list_devices?;
let device = open_device?;
let config = new;
let = device.start_frame_session?;
session.control.arm?;
// Submit a frame — it loops automatically
session.send_frame;
// Update content at any time — submission is zero-copy latest-wins:
// if the engine hasn't consumed the previous frame yet, the new one
// replaces it with no buffering or memory growth.
loop
For FIFO DACs, switching happens only at frame boundaries: the current frame is always finished before the next frame is chosen. Latest-wins applies until the next seam/chunk/frame has been materialized for transmission; once composed, that output is retried verbatim until the DAC accepts it.
For advanced output-space processing on that final presented sequence, install
an OutputFilter
with FrameSessionConfig::with_output_filter(...).
Empty Frames
Submitting Frame::new(vec![]) blanks the output (sends a single blanked point at origin).
This is useful for intentionally clearing the display.
Transition Blanking
When frames change, the library automatically inserts blanked transition points between the last point of the outgoing frame and the first point of the incoming frame. The default transition uses a 3-phase blanking sequence: end dwell (~100µs at source), quintic-eased transit (distance-scaled by L-infinity distance, 0-64 points), and start dwell (~400µs at destination). Dwell durations are converted to point counts based on PPS.
You can supply your own transition function for custom blanking strategies.
The callback returns a TransitionPlan describing what to do at each seam:
use ;
let config = new
.with_transition_fn;
TransitionPlan has two variants:
Transition(points)— keep both seam endpoints, insertpointsbetween them.Transition(vec![])keeps both endpoints with nothing in between.Coalesce— the two seam endpoints are the same logical point; emit only one copy. Use this for closed shapes (circles) to avoid a duplicate point at the seam.
Self-loops (A→A) also run through the transition callback, so seam planning is consistent regardless of whether the frame changed.
For perfect loops where the seam endpoints are the same logical point, return
Coalesce to emit that seam point only once and avoid a visible halt.
For frame-swap DACs, if the authored frame plus transition points would exceed the hardware capacity (e.g. Helios 4095 points), the transition prefix is automatically truncated. Authored frame content is never dropped.
Or disable transition blanking entirely:
use ;
let config = new
.with_transition_fn;
Final Output Filter
FrameSession can run an optional OutputFilter on the exact point slice that
is about to be written to hardware.
- FIFO backends call it with
PresentedSliceKind::FifoChunk - Frame-swap backends call it with
PresentedSliceKind::FrameSwapFrame - Frame-swap slices are marked
is_cyclic = truebecause the hardware loops the submitted frame - The filter runs after transition composition, startup/disarm blanking, and color delay
WouldBlockretries reuse the already-filtered buffer verbatim- Pre-first-frame FIFO keepalive blanks do not invoke the filter
use ;
;
let config = new
.with_output_filter;
Frame Session Liveness
FrameSession::metrics() exposes a small read-only liveness handle for
downstream watchdogs.
connected()reports whether the session currently has a connected backendlast_loop_activity()reports the last scheduler-thread progress timestamplast_write_success()reports the last successful backend write timestamp- watchdog cadence, stall thresholds, and emergency-disarm policy remain downstream-owned
use ;
let config = new;
let = device.start_frame_session?;
let metrics: FrameSessionMetrics = session.metrics;
if let Some = metrics.last_loop_activity
Reconnecting Frame Session
For automatic reconnection when the device disconnects:
use ;
use Duration;
let device = open_device?;
let config = new
.with_reconnect;
let = device.start_frame_session?;
session.control.arm?;
session.send_frame;
Streaming API (Advanced)
The streaming API uses buffer-driven timing: your callback is invoked when the buffer needs filling. This provides automatic backpressure handling and zero allocations in the hot path. Use this for custom timing, procedural generation, or audio-reactive content.
use ;
let devices = list_devices?;
let device = open_device?;
let config = new;
let = device.start_stream?;
stream.control.arm?;
let exit = stream.run?;
Return ChunkResult::Filled(n) to continue, ChunkResult::End to stop gracefully.
Coordinate System
All backends use normalized coordinates:
- X: -1.0 (left) to 1.0 (right)
- Y: -1.0 (bottom) to 1.0 (top)
- Colors: 0-65535 for R, G, B, and intensity
Each backend handles conversion to its native format internally.
Data Types
| Type | Description |
|---|---|
Frame |
Immutable frame of laser points for frame-mode output |
FrameSession |
Active frame-mode session with automatic looping |
FrameSessionConfig |
Frame session settings (PPS, transition fn, color delay) |
OutputFilter |
Hook for transforming the final presented output |
OutputFilterContext |
Metadata for a presented FIFO chunk or frame-swap frame |
PresentedSliceKind |
Whether the filter saw a FIFO chunk or frame-swap frame |
OutputResetReason |
Continuity reset reason for stateful output filters |
TransitionFn |
Callback for computing transition plan between frames |
TransitionPlan |
Enum: Transition(points) or Coalesce at seams |
DacInfo |
DAC metadata (name, type, capabilities) |
Dac |
Opened DAC ready for streaming |
Stream |
Active streaming session (callback mode) |
ReconnectConfig |
Configuration for automatic reconnection |
StreamConfig |
Stream settings (PPS, buffering, color delay, blanking) |
ChunkRequest |
Request info for filling point buffer |
LaserPoint |
Single point with position (f32) and color (u16) |
DacType |
Enum of supported DAC hardware |
Advanced Configuration
Color Delay (Scanner Sync Compensation)
Galvo mirrors need time to settle before the laser fires. Color delay shifts RGB+intensity channels relative to XY coordinates so colors arrive after the mirrors are in position.
use Duration;
// Frame mode: set via config
let config = new
.with_color_delay_points;
// Streaming mode: set via config (applied at runtime)
let config = new
.with_color_delay;
Frame mode enables color delay by default (150us equivalent at the configured PPS). It is applied statefully to the emitted point stream:
- FIFO DACs carry delay across chunks
- Frame-swap DACs carry delay across submitted frames
- Transition points between frames are included in the delayed stream
Streaming mode disables color delay by default. It can also be changed at runtime
via stream.control().set_color_delay(...).
Typical values: 50-200us depending on scanner speed.
Startup Blanking
Prevents the "flash on start" artifact by forcing the first points after arming to blank, giving mirrors time to reach their initial position.
use Duration;
let config = new
.with_startup_blank; // default: 1ms
Set to Duration::ZERO to disable.
Features
By default, all DAC protocols are enabled via the all-dacs feature.
DAC Features
| Feature | Description |
|---|---|
all-dacs |
Enable all DAC protocols (default) |
usb-dacs |
Enable USB DACs: helios, lasercube-usb |
network-dacs |
Enable network DACs: ether-dream, idn, lasercube-wifi |
audio-dacs |
Enable audio DACs: avb, oscilloscope |
helios |
Helios USB DAC |
lasercube-usb |
LaserCube USB (LaserDock) DAC |
ether-dream |
Ether Dream network DAC |
idn |
ILDA Digital Network DAC |
lasercube-wifi |
LaserCube WiFi DAC |
avb |
AVB audio-device backend (experimental) |
oscilloscope |
Oscilloscope XY-mode output via stereo audio interface |
asio (default) |
ASIO host on Windows for avb/oscilloscope (requires the Steinberg ASIO SDK plus LIBCLANG_PATH and CPAL_ASIO_DIR at build time). Disable to fall back to the cpal default host (WASAPI on Windows). |
For example, to enable only network DACs:
[]
= { = "*", = false, = ["network-dacs"] }
Other Features
| Feature | Description |
|---|---|
serde |
Enable serde serialization for DacType and EnabledDacTypes |
USB DAC Requirements
USB DACs (helios, lasercube-usb) use rusb which requires CMake to build.
On macOS and Windows libusb is built from bundled source automatically — no Homebrew/vcpkg/system libusb required. This is also what avoids the IOUSBHostFamily kernel panic seen with Helios on macOS 26.x when linking against Homebrew libusb 1.0.29.
On Linux the crate links against the distro-packaged libusb-1.0 (install via your package manager, e.g. apt install libusb-1.0-0-dev).
Development Tools
IDN Simulator
The repository includes a debug simulator (in tools/idn-simulator/) that acts as a virtual IDN laser DAC. This is useful for testing and development without physical hardware.
# Build and run the simulator
# With custom options
Features:
- Responds to IDN discovery (appears as a real DAC)
- Renders received laser frames as connected lines
- Handles blanking (intensity=0 creates gaps between shapes)
- Shows frame statistics (frame count, point count, client address)
Usage:
When the simulator is running, launch your work that scans for IDN devices. You can use this crate, or any other tool that supports IDN!
For a simple test, you can run one of our examples: cargo run --example stream -- circle
CLI Options:
| Option | Description | Default |
|---|---|---|
-n, --hostname |
Hostname in scan responses | IDN-Simulator |
-s, --service-name |
Service name in service map | Simulator Laser |
-p, --port |
UDP port to listen on | 7255 |
Acknowledgements
- Helios DAC: heavily inspired from helios-dac
- Ether Dream DAC: heavily inspired from ether-dream
- Lasercube USB / WIFI: inspired from ildagen (ported from C++ to Rust)
- IDN: inspired from helios_dac (ported from C++ to Rust)
License
MIT