-
-
Notifications
You must be signed in to change notification settings - Fork 1.7k
Developer Guide SyncLock
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.
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.
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.
-
src/engine/sync/syncable.h Readers are encouraged to read all comments in
syncable.hfor 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 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.
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 is the central coordinator. It is created by
EngineMixer and lives for the lifetime of the engine.
pickLeader() is called whenever a syncable's play state or sync mode
changes. Selection priority:
- Any
SyncableinLeaderExplicitmode — always wins. - Otherwise, among all syncables in
LeaderSoftorFollowermode, pick a playing, audible deck. - 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.
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.
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.
-
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.
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.
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().
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 is the per-deck implementation of Syncable. It is
created inside EngineBuffer and holds references to BpmControl and
RateControl on the same deck.
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
- Sets the rate slider via the
ControlProxyrate_ratio - Which emits a valueChanged signal (hidden), which triggers it's
signal/slot connection to
SyncControl::slotRateChanged -
SyncControlcallsm_pEngineSync->notifyRateChanged() - Which calls
EngineSync::updateLeaderBpm() -
EngineSynccallsSyncable::updateLeaderBpm()on all other Syncables
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.
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 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()callsm_pEngineSync->notifyInstantaneousBpmChanged(), which callsupdateLeaderInstantaneousBpm()for InternalClock only. - At the end of every audio callback it updates it's beat position;
InternalClock::onCallbackEndcallsm_pEngineSync->notifyBeatDistanceChanged()which callsupdateLeaderBeatDistance()for InternalClock only. -
isPlaying()always returnsfalse— it is never selected as leader while a real deck is available. - Its BPM is exposed via a
[InternalClock],bpmControlObject - 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.cfgin theEngineSyncdestructor 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.
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.
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.
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 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 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.
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 (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.
Mixxx is a free and open-source DJ software.
Manual
Hardware Compatibility
Reporting Bugs
Getting Involved
Contribution Guidelines
Coding Guidelines
Using Git
Developer Guide
Creating Skins
Contributing Mappings
Mixxx Controls
MIDI Scripting
Components JS
HID Scripting