laser-dac 0.11.8

Unified laser DAC abstraction supporting multiple protocols
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
# laser-dac

[![Crates.io](https://img.shields.io/crates/v/laser-dac.svg)](https://crates.io/crates/laser-dac)
[![Documentation](https://docs.rs/laser-dac/badge.svg)](https://docs.rs/laser-dac)
[![CI](https://github.com/ModulaserApp/laser-dac-rs/actions/workflows/ci.yml/badge.svg)](https://github.com/ModulaserApp/laser-dac-rs/actions/workflows/ci.yml)
[![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE)
[![MSRV](https://img.shields.io/badge/MSRV-1.87-blue.svg)](https://blog.rust-lang.org/2025/05/15/Rust-1.87.0.html)

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]https://bitlasers.com/heliospro-laser-dac/ |
| 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]https://laseranimation.com. |

All DACs have been manually verified to work.

## Quick Start

Connect your laser DAC and run an example:

```bash
# Frame API (recommended)
cargo run --example frame -- circle            # animated shape orbiting center
cargo run --example frame -- triangle
cargo run --example transitions -- default     # transition blanking between shapes
cargo run --example transitions -- animated    # 3 shapes cycling with transitions

# Streaming API (advanced)
cargo run --example stream -- circle
cargo run --example reconnect -- circle        # auto-reconnect on disconnect

# Other
cargo run --example audio                      # audio-reactive (requires microphone)
cargo run --example avb_file -- circle         # write 6ch AVB-mapped WAV for validation
```

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.

```rust
use laser_dac::{list_devices, open_device, Frame, FrameSessionConfig, LaserPoint};

let devices = list_devices()?;
let device = open_device(&devices[0].id)?;

let config = FrameSessionConfig::new(30_000);
let (session, info) = device.start_frame_session(config)?;

session.control().arm()?;

// Submit a frame — it loops automatically
session.send_frame(Frame::new(vec![
    LaserPoint::new(-0.5, -0.5, 65535, 0, 0, 65535),
    LaserPoint::new( 0.5, -0.5, 0, 65535, 0, 65535),
    LaserPoint::new( 0.0,  0.5, 0, 0, 65535, 65535),
]));

// 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 {
    let frame = generate_next_frame();
    session.send_frame(Frame::new(frame));
    std::thread::sleep(std::time::Duration::from_millis(16)); // ~60fps
}
```

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`](https://docs.rs/laser-dac/latest/laser_dac/trait.OutputFilter.html)
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:

```rust
use laser_dac::{FrameSessionConfig, LaserPoint, TransitionPlan};

let config = FrameSessionConfig::new(30_000)
    .with_transition_fn(Box::new(|from: &LaserPoint, to: &LaserPoint| {
        // Custom blanking: 4 linearly interpolated blank points
        TransitionPlan::Transition(
            (0..4).map(|i| {
                let t = (i + 1) as f32 / 5.0;
                LaserPoint::blanked(
                    from.x + (to.x - from.x) * t,
                    from.y + (to.y - from.y) * t,
                )
            }).collect()
        )
    }));
```

`TransitionPlan` has two variants:

- **`Transition(points)`** — keep both seam endpoints, insert `points` between 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:

```rust
use laser_dac::{FrameSessionConfig, LaserPoint, TransitionPlan};

let config = FrameSessionConfig::new(30_000)
    .with_transition_fn(Box::new(|_: &LaserPoint, _: &LaserPoint| {
        TransitionPlan::Transition(vec![])
    }));
```

### 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 = true` because the hardware loops the
  submitted frame
- The filter runs after transition composition, startup/disarm blanking, and
  color delay
- `WouldBlock` retries reuse the already-filtered buffer verbatim
- Pre-first-frame FIFO keepalive blanks do not invoke the filter

```rust
use laser_dac::{
    FrameSessionConfig, LaserPoint, OutputFilter, OutputFilterContext, OutputResetReason,
};

struct SafetyFilter;

impl OutputFilter for SafetyFilter {
    fn reset(&mut self, _reason: OutputResetReason) {}

    fn filter(&mut self, points: &mut [LaserPoint], _ctx: &OutputFilterContext) {
        for point in points {
            if point.x.abs() > 1.0 || point.y.abs() > 1.0 {
                *point = LaserPoint::blanked(point.x.clamp(-1.0, 1.0), point.y.clamp(-1.0, 1.0));
            }
        }
    }
}

let config = FrameSessionConfig::new(30_000)
    .with_output_filter(Box::new(SafetyFilter));
```

### Frame Session Liveness

`FrameSession::metrics()` exposes a small read-only liveness handle for
downstream watchdogs.

- `connected()` reports whether the session currently has a connected backend
- `last_loop_activity()` reports the last scheduler-thread progress timestamp
- `last_write_success()` reports the last successful backend write timestamp
- watchdog cadence, stall thresholds, and emergency-disarm policy remain
  downstream-owned

```rust
use laser_dac::{FrameSessionConfig, FrameSessionMetrics};

let config = FrameSessionConfig::new(30_000);
let (session, _info) = device.start_frame_session(config)?;
let metrics: FrameSessionMetrics = session.metrics();

if let Some(last) = metrics.last_loop_activity() {
    println!("scheduler last progressed {:?} ago", last.elapsed());
}
```

### Reconnecting Frame Session

For automatic reconnection when the device disconnects:

```rust
use laser_dac::{open_device, Frame, FrameSessionConfig, LaserPoint, ReconnectConfig};
use std::time::Duration;

let device = open_device("my-device")?;
let config = FrameSessionConfig::new(30_000)
    .with_reconnect(
        ReconnectConfig::new()
            .max_retries(5)
            .backoff(Duration::from_secs(1))
            .on_disconnect(|err| eprintln!("Lost connection: {}", err))
            .on_reconnect(|info| println!("Reconnected to {}", info.name))
    );

let (session, _info) = device.start_frame_session(config)?;

session.control().arm()?;

session.send_frame(Frame::new(vec![
    LaserPoint::new(0.0, 0.0, 65535, 0, 0, 65535),
]));
```

## 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.

```rust
use laser_dac::{list_devices, open_device, ChunkRequest, ChunkResult, LaserPoint, StreamConfig};

let devices = list_devices()?;
let device = open_device(&devices[0].id)?;

let config = StreamConfig::new(30_000);
let (stream, info) = device.start_stream(config)?;

stream.control().arm()?;

let exit = stream.run(
    // Producer callback - fills buffer with points
    |req: &ChunkRequest, buffer: &mut [LaserPoint]| {
        let n = req.target_points;
        for i in 0..n {
            buffer[i] = generate_point(i);
        }
        ChunkResult::Filled(n)
    },
    // Error callback
    |err| eprintln!("Stream error: {}", err),
)?;
```

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.

```rust
use std::time::Duration;

// Frame mode: set via config
let config = FrameSessionConfig::new(30_000)
    .with_color_delay_points(5);

// Streaming mode: set via config (applied at runtime)
let config = StreamConfig::new(30_000)
    .with_color_delay(Duration::from_micros(100));
```

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.

```rust
use std::time::Duration;

let config = FrameSessionConfig::new(30_000)
    .with_startup_blank(Duration::from_millis(2)); // 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:

```toml
[dependencies]
laser-dac = { version = "*", default-features = false, features = ["network-dacs"] }
```

### Other Features

| Feature | Description                                                    |
| ------- | -------------------------------------------------------------- |
| `serde` | Enable serde serialization for `DacType` and `EnabledDacTypes` |

### USB DAC Requirements

USB DACs (`helios`, `lasercube-usb`) use [rusb](https://crates.io/crates/rusb) which requires CMake to build.

## 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.

```bash
# Build and run the simulator
cargo run -p idn-simulator

# With custom options
cargo run -p idn-simulator -- --hostname "Test-DAC" --service-name "Simulator" --port 7255
```

**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]https://github.com/maxjoehnk/helios-dac-rs
- Ether Dream DAC: heavily inspired from [ether-dream]https://github.com/nannou-org/ether-dream
- Lasercube USB / WIFI: inspired from [ildagen]https://github.com/Grix/ildagen (ported from C++ to Rust)
- IDN: inspired from [helios_dac]https://github.com/Grix/helios_dac (ported from C++ to Rust)

## License

MIT