Skip to content

Event pipeline

This page describes the full data path from a CDJ’s UDP broadcasts to a fired cue action.

CDJ/XDJ hardware
│ Pro DJ Link UDP (port 50000–50002)
┌─────────────────────┐
│ Rust UDP listener │ Parses Pioneer binary protocol, emits typed packets
└────────┬────────────┘
│ Tauri events → frontend
┌───▼─────────────┐
│ ProDjLinkAdapter │ JS adapter receives discovery + telemetry events
└───┬─────────────┘
┌──────▼──────────────┐ ┌──────────────────┐
│ DeckRegistry │────►│ Svelte stores │ Reactive UI state
│ PlayerTelemetry │ └──────────────────┘
└──────┬──────────────┘
│ DjEvents (track-loaded, track-play, timeline-update, on-air …)
┌───▼───────────┐
│ CueEngine │ Matches events → assignments → cues → actions
└───┬───────────┘
┌──────▼──────────────────────────────────────────────┐
│ Output integrations │
│ MidiOutput · TimecodeStreamer · ExternalTargets │
│ InternalActions · MidiClockManager │
└─────────────────────────────────────────────────────┘

The Rust backend opens a raw UDP socket and listens for Pro DJ Link packets on all active network interfaces. It:

  • Decodes Pioneer’s binary wire format (CDJ status, keep-alives, beat packets, on-air packets)
  • Emits discovery events when a new device is seen or its identity changes
  • Emits telemetry events at the CDJ’s broadcast rate (~8 Hz for status, ~4 Hz for beat)
  • Maintains a virtual CDJ presence (vCDJ) so that NXS2+ decks share metadata over the link

The listener is epoch-gated — if the user switches network interface, a new epoch flushes stale state.

The JS adapter subscribes to Tauri events and routes them to registered handlers. In offline mode, a MockProDjLinkAdapter replaces the network adapter with events driven by HTML5 audio position.

Packet types emitted:

EventWhen
discoveryNew deck found, or deck identity/on-air changes
telemetryPer-frame state: transport, BPM, track ID, waveform, loop markers

The deck registry maintains the authoritative list of known decks in memory. It:

  • Upserts decks from discovery packets
  • Marks decks stale if no packet has arrived in 4 000 ms
  • Preserves the last-known on-air state through a stale cycle

The registry feeds the djStore Svelte store, which drives all UI.

The telemetry reducer processes each telemetry packet and:

  • Updates PlayerState in djStore (transport, BPM, elapsed/remaining seconds, waveform)
  • Derives DjEvents from state transitions:
State transitionEvent emitted
No track → track presenttrack-loaded
Stopped/paused → playingtrack-play
Track present → no tracktrack-unloaded
Off-air → on-airdeck-on-air
On-air → off-airdeck-off-air
Every telemetry tick while playingtimeline-update

Events are published on the event bus — a simple typed pub/sub shared across features.

When a track ID arrives in telemetry, the track repository creates or updates a Track record with title, artist, BPM, and waveform data. Tracks are stored in djStore and persist across sessions.

The assignment service maps (list_id, track_key)TrackAssignment. When a track-loaded event arrives, the service either returns the existing assignment or creates a new empty one.

The cue engine is the heart of Helm DJ. For every DjEvent it:

  1. Looks up the assignment for the loaded track
  2. Filters cues to those matching the event’s trigger type
  3. For each matching cue, checks:
    • Is automation armed? (session.runtime.automation_armed)
    • Is panic engaged? (session.runtime.panic_engaged)
    • Is this deck’s override on? (session.runtime.deck_overrides[deck_id])
    • Does the cue’s player_routing filter pass?
    • Is live_only satisfied? (if set, deck must be on-air)
    • Has the debounce window passed?
    • For timeline triggers: is the playhead within the trigger window?
  4. Fires or skips the cue, writing an audit entry either way

Timeline events are rate-limited in logging (one diagnostic log per deck per 2 s) to avoid burying the signal under 30 Hz noise.

Each cue has a debounce_ms field (default: 500 ms). A cue cannot re-fire within this window for the same deck. This prevents double-fires on momentary state bounces (e.g. a CDJ blipping its play state during a loop).

Every cue evaluation writes a TriggerAuditEntry:

outcome: 'fired' | 'skipped' | 'blocked'
reason: string (on skip/block)

The audit log is shown in the Cue History panel in the UI.

Once the cue engine decides to fire a cue, it dispatches to the matching integration:

Action typeIntegrationTransport
midiMidiOutputRust MIDI via midir
midi with fade_secondsMidiOutputCC value ramp at ~30 Hz
commandExternalTargetsShell subprocess via Tauri
internalInternalActionsDirect JS/Rust command
marker(logged only)Audit log + history panel

MIDI per-deck overrides: before dispatching a MIDI action, the engine resolves any *-flagged fields from per_deck[player_number]. The player number used is the display player number (what the user sees in the UI), not the raw CDJ-reported number.

These run independently of the cue engine:

  • TimecodeStreamer watches the selected source deck’s transport state and calls TimecodeOutput (Rust) to start/stop/reposition MTC or LTC streams.
  • MidiClockManager tracks the source deck’s BPM and sends 24 PPQ MIDI Clock ticks (plus Start/Stop if follow_transport is on).

Both are gated by GLOBAL_PANIC in Rust — when panic is engaged, LTC writes silence and MTC/clock freeze.