Skip to content

Developer Guide Engine Player

Owen Williams edited this page Feb 22, 2026 · 1 revision

Developer Guide: Engine Player

This page covers the deck and sampler playback pipeline: how audio samples are read from disk, kept in a cache, navigated around loops and cue points, and then rate-adjusted before being handed to EngineMixer. The main classes are EngineBuffer, CachingReader, ReadAheadManager, and the EngineBufferScale family.

EngineBuffer

EngineBuffer is the EngineObject that does all deck-level audio work. It is created by EngineDeck and its process(CSAMPLE* pOut, std::size_t bufferSize) method is called once per audio callback.

EngineControls

EngineBuffer owns a collection of EngineControl sub-objects, each responsible for a distinct aspect of playback:

Control Responsibility
RateControl Rate slider, nudge, and scratch input; computes the final playback rate
BpmControl Beat tracking: local BPM, beat distance, quantize snapping
SyncControl Sync Lock participation; implements the Syncable interface
LoopingControl Loop in/out points, beat loops, loop length controls
CueControl Hot cues, intro/outro markers, default cue behavior
KeyControl Pitch adjustment independent of tempo (key lock)
VinylControlControl Vinyl timecode signal routing into the rate/scratch path
ClockControl Publishes beat clock ticks for controller LED display

Each EngineControl can register hints with the CachingReader to keep relevant portions of the file warm in cache.

process() overview

Each callback EngineBuffer::process does roughly the following:

  1. Consume any queued seek or sync-enable requests.
  2. Ask RateControl for the current rate (including scratch velocity).
  3. Call m_pReadAheadManager->getNextSamples() (via the active scaler) to fill the output buffer at the correct speed and pitch.
  4. Deliver hints to CachingReader so the worker thread pre-fetches upcoming frames.
  5. Update VisualPlayPosition so the waveform display stays in sync.
  6. Call postProcessLocalBpm() / postProcess() to advance BPM and sync state after all decks have been processed.

CachingReader

Because file I/O is forbidden in the audio callback thread, CachingReader splits decoding into two layers:

  • CachingReader (callback thread) — exposes read() and hintAndMaybeWake(). Maintains an LRU cache of decoded chunks.
  • CachingReaderWorker (background QThread) — woken by CachingReader when cache misses are detected. Calls the SoundSource API to decode audio and writes chunks back via a wait-free FIFO.

Chunks and LRU eviction

A chunk is a fixed-size contiguous block of decoded CSAMPLE frames. Chunks are stored in a flat array and tracked in a doubly-linked LRU list. When a chunk is accessed (read or hinted) it moves to the back of the list. When a new chunk needs to be allocated and the cache is full, the front (least-recently-used) chunk is recycled.

The read() method

enum class ReadResult {
    UNAVAILABLE,         // cache miss; buffer untouched
    PARTIALLY_AVAILABLE, // some frames missing; gaps filled with silence
    AVAILABLE,           // all requested frames present
};

ReadResult read(SINT startSample, SINT numSamples, bool reverse,
                CSAMPLE* buffer,
                mixxx::audio::ChannelCount channelCount);

UNAVAILABLE means the caller should try again next callback; the worker has been woken to fetch the missing chunk. PARTIALLY_AVAILABLE means a loop boundary was crossed and the non-missing portion was returned.

Hints

A Hint tells CachingReader which frames will be needed soon — beyond just the current read position. Every EngineControl can add hints to a HintVector that is passed to hintAndMaybeWake() at the end of each callback. Hint types include CurrentPosition, LoopStartEnabled, MainCue, HotCue, IntroStart, etc. The hint system ensures that common jump targets (hot cues, loop points) are already decoded before the user activates them.

typedef struct Hint {
    SINT frame;       // start of the region to warm
    SINT frameCount;  // kFrameCountForward (0) or kFrameCountBackward (-1)
                      // for default forward/backward prefetch
    Type type;
} Hint;

void hintAndMaybeWake(const HintVector& hintList);

ReadAheadManager

ReadAheadManager sits between CachingReader and EngineBufferScale. Its job is to serve consecutive blocks of raw (non-rate-adjusted) frames while correctly handling:

  • Forward and reverse playback — passes reverse=true to CachingReader::read().
  • Loop boundaries — consults LoopingControl before reading to detect when the read head would cross a loop out-point, and wraps back to the loop in-point seamlessly.
  • Quantized cue triggers — consults CueControl to detect whether a cue point should trigger a seek mid-buffer.
SINT getNextSamples(double dRate, CSAMPLE* buffer,
                    SINT requested_samples,
                    mixxx::audio::ChannelCount channelCount);

The dRate sign determines direction. The return value is the number of samples actually written, which may be less than requested_samples if a boundary was hit — the scaler must call getNextSamples again to fill the remainder.

Read log

ReadAheadManager maintains a small internal log of (start, end) pairs for each getNextSamples call. After the scaler finishes consuming samples, getFilePlaypositionFromLog() uses this log to translate the number of scaler output frames consumed back into the exact file frame position, even when the rate changed mid-buffer.

EngineBufferScale (Rate/Pitch Scalers)

EngineBufferScale is the abstract base class for all rate and pitch adjustment implementations. EngineBuffer holds two instances: one for vinyl/scratch (always linear) and one for keylock mode (SoundTouch or RubberBand).

Keylock Engines

The user-selectable keylock algorithm is encoded in KeylockEngine:

enum class KeylockEngine {
    SoundTouch = 0,
    RubberBandFaster = 1,  // __RUBBERBAND__ only
    RubberBandFiner  = 2,  // __RUBBERBAND__ only, requires RubberBand R3
};

The active EngineBufferScale implementation is hot-swapped at runtime when the user changes the keylock engine setting.

class EngineBufferScale : public QObject {
  public:
    // Set rate and pitch parameters before each call to scaleBuffer.
    // base_rate   = track sample rate / output sample rate
    // tempoRatio  = ratio of track time to real time (1.0 = normal speed)
    // pitchRatio  = pitch multiplier (1.0 = no pitch change)
    virtual void setScaleParameters(double base_rate,
                                    double* pTempoRatio,
                                    double* pPitchRatio);

    // Reset internal buffers after a seek.
    virtual void clear() = 0;

    // Fill pOutputBuffer with iOutputBufferSize samples.
    // Returns the number of *input* frames consumed (may be fractional).
    virtual double scaleBuffer(CSAMPLE* pOutputBuffer,
                               SINT iOutputBufferSize) = 0;
};

EngineBufferScaleLinear

src/engine/bufferscalers/enginebufferscalelinear.h

Used for vinyl/scratch and when keylock is off. Performs simple linear interpolation between adjacent frames. Very low CPU cost, no latency, but does not preserve pitch when tempo changes.

EngineBufferScaleST (SoundTouch)

src/engine/bufferscalers/enginebufferscalest.h

Wraps the SoundTouch library. Offers independent tempo and pitch processing via WSOLA (Waveform Similarity Overlap-Add). Lower CPU usage than RubberBand; always available regardless of build configuration.

EngineBufferScaleRubberBand

src/engine/bufferscalers/enginebufferscalerubberband.h

Wraps the RubberBand library. Available when Mixxx is compiled with __RUBBERBAND__. Two quality modes are exposed:

  • RubberBandFaster (RubberBandFaster = 1) — uses RubberBand's faster R2 engine; good quality at moderate CPU cost.
  • RubberBandFiner (RubberBandFiner = 2) — uses RubberBand's R3 engine (near-hi-fi quality); highest CPU cost; only available if the installed RubberBand version supports it.

RubberBand processing is driven via RubberBandWorkerPool, a pool of EngineWorker threads that run DSP work between callbacks to avoid doing it all inline in the callback thread.

Seeks

Seeks are not applied immediately. Instead they are queued atomically and consumed at the start of the next callback. The SeekRequest flags control how the target position is adjusted:

enum SeekRequest {
    SEEK_NONE        = 0,
    SEEK_PHASE       = 1 << 0, // snap to nearest beat (quantize)
    SEEK_EXACT       = 1 << 1, // seek to exact frame, bypass quantize
    SEEK_EXACT_PHASE = SEEK_PHASE | SEEK_EXACT,
    SEEK_STANDARD    = 1 << 2, // SEEK_PHASE if quantize on, else SEEK_EXACT
    SEEK_CLONE       = 1 << 3, // jump to the current position of another deck
};

queueNewPlaypos(FramePos, SeekRequest) is the external entry point. When multiple seek requests arrive in the same callback they are merged using bitwise OR.

After a seek is consumed, EngineBufferScale::clear() is called on the active scaler to flush its internal buffers and avoid any discontinuity-induced artefacts from pre-seek audio data.

Slip Mode

Slip mode maintains a parallel "slip position" that advances at normal 1× speed while scratching or looping is active. When slip mode is disabled the engine jumps to the accumulated slip position, giving the impression that playback continued uninterrupted underneath the manipulation. processSlip() updates the slip position each callback.

Clone this wiki locally