Skip to content

Developer Guide SyncLock

Owen Williams edited this page Mar 13, 2026 · 3 revisions

Developer Guide: Sync Lock

WARNING

This guide is currently AI-generated and under manual review. Until this review is complete all information should be carefully confirmed by comparing statements to the codebase and comments within the code itself.

We welcome review comments, corrections, and further documentation contributions on Zulip!

Mixxx's Sync Lock system keeps multiple decks playing in time by synchronising their BPM and beat phase. This page describes the Syncable / SyncableListener interfaces, the EngineSync orchestrator, SyncControl (the per-deck implementation), and the InternalClock.

Overview

EngineSync  (SyncableListener)
  ├── InternalClock   (Syncable + Clock)
  ├── SyncControl[1]  (Syncable, inside EngineBuffer for Deck 1)
  ├── SyncControl[2]  (Syncable, inside EngineBuffer for Deck 2)
  └── SyncControl[N]  ...

Every participant in sync — decks, samplers, and the internal clock — implements Syncable. EngineSync implements SyncableListener and is notified whenever a Syncable changes its BPM, beat distance, rate, or play state. EngineSync is responsible for deciding which Syncable is the current leader and for propagating the leader's BPM and beat distance to all followers.

SyncMode

enum class SyncMode {
    None          = 0, // not participating in sync
    Follower      = 1, // following the leader's BPM and phase
    LeaderSoft    = 2, // auto-selected leader; can be reassigned
    LeaderExplicit = 3,// user explicitly set this deck as leader
};

Soft leader (LeaderSoft) is a leader that EngineSync chose automatically. It may be reassigned if the deck stops, is ejected, or another deck requests explicit leader. Explicit leader (LeaderExplicit) is stickier: EngineSync will only remove it if the track stops or is ejected.

Helper predicates: isLeader(mode) returns true for both leader variants; isFollower(mode) returns true for Follower; toSynchronized(mode) returns true for any mode other than None.

Syncable — the interface each participant implements

  • src/engine/sync/syncable.h Readers are encouraged to read all comments in syncable.h for further use limitations and instructions.
class Syncable {
  public:
    virtual const QString& getGroup() const = 0;
    virtual EngineChannel* getChannel() const = 0;

    // EngineSync calls these to push new state to this syncable:
    virtual void setSyncMode(SyncMode mode) = 0;
    virtual void notifyUniquePlaying() = 0;  // only playing syncable
    virtual void requestSync() = 0;          // re-phase immediately

    virtual SyncMode getSyncMode() const = 0;

    // State that EngineSync reads to make decisions:
    virtual bool isPlaying() const = 0;
    virtual bool isAudible() const = 0;
    virtual bool isQuantized() const = 0;
    virtual mixxx::Bpm getBpm() const = 0;  // BPM * rate, no scratch
    virtual mixxx::Bpm getBaseBpm() const = 0;  // BPM at 1.0× rate
    virtual double getBeatDistance() const = 0; // [0.0, 1.0)

    // EngineSync calls these to push leader state:
    virtual void updateLeaderBpm(mixxx::Bpm bpm) = 0;
    virtual void updateLeaderBeatDistance(double beatDistance) = 0;
    virtual void updateInstantaneousBpm(mixxx::Bpm bpm) = 0;
    virtual void reinitLeaderParams(double beatDistance,
            mixxx::Bpm baseBpm, mixxx::Bpm bpm) = 0;
};

Beat Distance

Beat distance is a fractional value, typically in [0.0, 1.0). It indicates how far through the current beat the playhead is. 0.0 means exactly on a beat; 0.5 means halfway between two beats. Followers use the leader's beat distance to compute a phase correction applied to their rate slider so they snap their beat grid into alignment.

While the beat distance is typically in [0.0, 1.0) for Sync, it can also be any value when it is produced as a beatJump distance. In that case, the integer value is how large the jump is in beats.

SyncableListener — EngineSync's inbound interface

class SyncableListener {
  public:
    // Syncable → EngineSync notifications:
    virtual void requestSyncMode(Syncable*, SyncMode) = 0;
    virtual void notifyBaseBpmChanged(Syncable*, mixxx::Bpm) = 0;
    virtual void notifyRateChanged(Syncable*, mixxx::Bpm) = 0;
    virtual void notifyInstantaneousBpmChanged(Syncable*, mixxx::Bpm) = 0;
    virtual void notifyBeatDistanceChanged(Syncable*, double) = 0;
    virtual void notifyPlayingAudible(Syncable*, bool) = 0;
    virtual void notifyScratching(Syncable*, bool) = 0;
    virtual void notifySeek(Syncable*, mixxx::audio::FramePos) = 0;
    virtual void requestBpmUpdate(Syncable*, mixxx::Bpm) = 0;
    virtual Syncable* getLeaderSyncable() = 0;
};

Crucially, implementations of these methods must never call back into the notification they correspond to or signal loops could result (documented on every relevant method in the header). For example, updateLeaderBpm() must not result in a call to notifyBpmChanged() on the same callback.

EngineSync

EngineSync is the central coordinator. It is created by EngineMixer and lives for the lifetime of the engine.

Leader selection: pickLeader()

pickLeader() is called whenever a syncable's play state or sync mode changes. Selection priority:

  1. Any Syncable in LeaderExplicit mode — always wins.
  2. Otherwise, among all syncables in LeaderSoft or Follower mode, pick a playing, audible deck.
  3. If no playing deck exists, fall back to the internal clock.

activateLeader(pSyncable, leaderType) sets the chosen syncable to the appropriate leader mode and calls activateFollower() on all other synchronized syncables.

BPM propagation

Syncables notify EngineSync when they change their BPM. One way this happens is via SyncControl. SyncControl slots and various functions in the SyncControl class include m_pEngineSync->notifyXYZ calls. SyncControl::slotRateChanged is the main slot for events from the DJ adjusting the rate, which calls m_pEngineSync->notifyRateChanged().

When a leader's BPM changes (notifyBaseBpmChanged or notifyRateChanged), EngineSync calls updateLeaderBpm(source, bpm) which iterates over all registered syncables and calls pSyncable->updateLeaderBpm(bpm) on each one except the source. Followers adjust their rate slider so their effective BPM matches the leader.

Half/double BPM adjustment

When a follower's base BPM is far from the leader's BPM, SyncControl automatically applies a ×0.5 or ×2 multiplier so that a 75 BPM track can follow a 150 BPM track by running at 2× its original tempo. reinitLeaderParams() recalculates this multiplier when the leader changes.

Instantaneous BPM vs base BPM

  • Base BPM (getBaseBpm): the BPM that the track would have at a 1.0 rate ratio; derived from the beat grid around the current position. Changes only when the beat grid or the playhead position changes significantly.
  • Effective BPM (getBpm): base BPM × rate slider. Updated every callback.
  • Instantaneous BPM (notifyInstantaneousBpmChanged): includes scratch velocity. Used to flash controller LEDs but not used to drive other followers to avoid jitter-induced feedback loops.

SyncLockAlgorithm

A user-configurable algorithm selection (stored in [BPM] sync_lock_algorithm):

  • PREFER_SOFT_LEADER (default) — the new behavior designed for reliable, accurate beat grids.
  • PREFER_LOCK_BPM — a legacy fallback that works around poor beat grid quality, primarily for Auto DJ scenarios.

Seek handling

When a syncable seeks (notifySeek), EngineSync re-phases all followers against the new leader beat distance. If the seeking syncable is itself a follower, its new beat distance is computed and the follower applies a phase correction on the next callback via requestSync().

onCallbackStart / onCallbackEnd

EngineSync::onCallbackStart() is called at the beginning of each EngineMixer::process() pass to snapshot the current state of all syncables. onCallbackEnd() is called after all channels have been processed.

SyncControl

SyncControl is the per-deck implementation of Syncable. It is created inside EngineBuffer and holds references to BpmControl and RateControl on the same deck.

Rate adjustment

When EngineSync calls updateLeaderBpm(bpm) on a follower deck's SyncControl, SyncControl computes the required rate ratio:

required_rate = (leader_bpm / deck_base_bpm) * halve_double_factor
  1. Sets the rate slider via the ControlProxy rate_ratio
  2. Which emits a valueChanged signal (hidden), which triggers it's signal/slot connection to SyncControl::slotRateChanged
  3. SyncControl calls m_pEngineSync->notifyRateChanged()
  4. Which calls EngineSync::updateLeaderBpm()
  5. EngineSync calls Syncable::updateLeaderBpm() on all other Syncables

Phase correction (updateTargetBeatDistance)

updateTargetBeatDistance() is called from SyncControl::updateLeaderBeatDistance() and stores the target beat distance (the leader's current beat distance) in m_dSyncTargetBeatDistance on BpmControl.

The actual rate correction is then computed inside BpmControl::calcSyncAdjustment() (src/engine/controls/bpmcontrol.cpp), which is called from BpmControl::getRate(). calcSyncAdjustment computes the phase error as:

const double error = shortestPercentageChange(syncTargetBeatDistance, thisBeatDistance);

and returns a multiplicative rate adjustment:

  • Error below 0.01 — no adjustment; the decks are in sync.
  • Error in [0.01, 0.2) — proportional adjustment capped at ±5% rate change, with a per-callback delta cap to prevent jerky corrections.
  • Error above 0.2 ("train wreck") — maximum +5% adjustment to catch up as fast as possible.

Phase correction is only active when quantize mode is on. If quantize is disabled, BpmControl::getRate() returns the raw rate without calling calcSyncAdjustment at all:

// If we are not quantized, or there are no beats, or we're leader,
// or we're in reverse, just return the rate as-is.
if (!m_quantize.toBool() || !m_pBeats || m_reverseButton.toBool()) {
    m_resetSyncAdjustment = true;
    return rate + userTweak;
}

After a seek, updateTargetBeatDistance(FramePos) is called with the new position to recalculate the target.

Local BPM

For beat-map tracks, the BPM around the current position is not constant. SyncControl::setLocalBpm() is called by BpmControl via postProcessLocalBpm() (once all decks have been processed) to update the base BPM value. EngineSync is then notified of the change so it can adjust followers if this deck is the leader.

InternalClock

InternalClock is a synthetic Syncable that provides a stable tempo reference when no real deck is playing. It implements both Syncable and Clock.

Key properties:

  • At the start of every audio callback it updates it's bpm; InternalClock::onCallbackStart() calls m_pEngineSync->notifyInstantaneousBpmChanged(), which calls updateLeaderInstantaneousBpm() for InternalClock only.
  • At the end of every audio callback it updates it's beat position; InternalClock::onCallbackEnd calls m_pEngineSync->notifyBeatDistanceChanged() which calls updateLeaderBeatDistance() for InternalClock only.
  • isPlaying() always returns false — it is never selected as leader while a real deck is available.
  • Its BPM is exposed via a [InternalClock],bpm ControlObject
  • Its beat distance advances automatically each callback based on the current BPM and buffer size, via onCallbackStart() / onCallbackEnd().
  • The last-used BPM is saved to mixxx.cfg in the EngineSync destructor so it persists across sessions.

When all decks stop, EngineSync may activate InternalClock as the soft leader so that the next deck to start and enable sync will snap to the clock's beat grid rather than starting from an arbitrary phase.

Development In Progress

Get involved in testing and finalizing implementation of the following ongoing development projects related to Sync in Mixxx. You can find out more and find opportunities to contribute on Zulip, Github, or get involved by testing and submitting bugs for active PRs on Github.

Exposing InternalClock in the main UI

InternalClock already exposes a [InternalClock],bpm ControlObject, but there is no first-class widget for it in the default skins. A natural improvement would be a dedicated BPM spinbox or tap-tempo button in the main toolbar or master section that writes directly to that CO. This would give DJs a reliable way to set a reference tempo before loading any tracks, and would make the internal clock visible and discoverable rather than an implicit fallback. See the latest 2.7 pre-Alpha builds for completed tap tempo implementation.

Related work would be surfacing the internal clock's beat distance as a visual beat indicator — similar to the beat LEDs on hardware controllers — so the DJ can see the clock "ticking" and verify it is at the right tempo before enabling sync on a deck. This is what Midi For Lights does.

The existing [InternalClock],bpm CO already persists across sessions via mixxx.cfg, so value persistence is already handled; only the skin/widget layer needs to be added.

External Clock Input and Output

InternalClock is currently driven entirely by Mixxx's own audio callback. A useful extension would be to make it drivable by — and able to drive — external clock sources:

MIDI Clock

MIDI Beat Clock (status byte 0xF8) fires 24 times per quarter note. An input path would listen for clock pulses from a MIDI controller or DAW and translate them into a running BPM estimate and beat distance update fed into InternalClock. Because MIDI clock pulses arrive on a background MIDI thread, the update would need to go through an atomic or lock-free hand-off before InternalClock::onCallbackStart() consumes it (the same pattern already used by EffectsMessenger).

An output path would emit 24 MIDI clock pulses per beat, derived from the current BPM. This would let Mixxx act as a MIDI clock master for hardware synths, drum machines, and sequencers. See Issue 14395 for work in progress.

MIDI Timecode (MTC)

MIDI Timecode encodes absolute position (hours/minutes/seconds/frames) as a quarter-frame message stream. MTC is implemented in the Midi For Lights script as an output.

Accepting MTC as input would allow Mixxx to lock to a DAW or video system timeline rather than just a free-running BPM grid. This is a more complex integration because it involves translating absolute timecode into a beat position, which requires knowing the BPM in advance.

Link (Ableton Link)

Ableton Link is a peer-to-peer protocol that synchronises tempo and beat phase across devices on a LAN without designating a single master. InternalClock would be a natural host for a Link session: Link would continuously update InternalClock's BPM and beat distance, and InternalClock would push changes back to Link when the DJ taps tempo or adjusts BPM manually. The Link SDK is open source and permissively licensed; a Link-backed InternalClock would enable tight integration with Ableton Live and other Link-aware software. See PR 10999 for work in progress.

OSC

OSC (Open Sound Control) is commonly used in live performance and installation contexts. An OSC input path could accept /clock/bpm and /clock/beat messages to drive InternalClock, while an OSC output path could broadcast the current BPM and beat distance for use by lighting controllers, VJ software, or other networked systems. See Github for work in progress.

Clone this wiki locally