FOSSY 2026 · Vancouver, BC

trail mix: Open tempo, key, and waveform analysis

Pardis Noorzad djpardis.com trailmix.tools
Abstract illustration of nuts, seeds, dried fruit, and chocolate
Cueport

Let's start with Cueport

Cueport is a local-first music player for people who own their music.

Pardis DJing with headphones and a laptop
Pardis DJing a live set at The New Parish in Oakland
Cueport

Cueport is local-first

The desktop and phone pair directly on a local network or hotspot.
The QR code carries connection details; data moves both ways after pairing.

Desktop app canonical library
LAN or hotspot
scan QR to pair
Phone app selected replica
Desktop to phone selected crates, tracks, analysis, cues
Phone to desktop edits return for desktop review
Cueport

Cueport v1 workflow

Cueport v1 uses Serato as the source of truth.
The desktop database imports audio and metadata from Serato.
The mobile database is a cached replica.

Serato library source of truth
Desktop SQLite canonical working copy
Mobile SQLite cached replica
Import from Serato library metadata and existing analysis
Sync to mobile selected library copy
Mobile edits return reviewed on desktop before publishing
Write back to Serato restore point first, then approved library update
One desktop review queue desktop edits enter directly; synced edits join the same queue
The gap

Problem: DJ tool dependence

DJ tools offer tempo, key, and beat analysis for DJ workflows.
Mainstream tools need their own analysis layer.

Tempo, key, and beat grids exist inside DJ tools
Those systems are designed for DJ workflows
Mainstream tools need their own analysis layer
trail mix

Solution: trail mix

trail mix accepts an audio file and returns tempo, key, beat positions, and waveform data as versioned JSON.

Facade calling beat-salad, key-lime, and sampler-platter and merging one versioned JSON result

Decode and normalization

Audio files become a mono sample buffer along with its sample rate

  • Decoding: an audio file becomes evenly spaced amplitude values
  • Downmixing: sample buffers combine into one mono signal
  • f32: a 32-bit floating-point number for storing those amplitudes
  • u32: a non-negative 32-bit integer for storing the sample rate
Input file
MP3 · FLAC · WAV · AIFF · MP4
Decode to samples
left/right sample buffers
Downmix
(left + right) / 2 = one mono buffer
Analyzer input
samples: &[f32] sample_rate: u32
Design

Why Rust for trail mix

trail mix is a Rust library because Cueport is a Rust app and the analysis has to run on-device with the same result every time.

Same language as Cueport
Cueport calls trail mix like any Rust crate, with no cross-language glue code to write or maintain.
No separate runtime
Compiles into Cueport's binary. Unlike Python or Java, no interpreter or garbage collector ships with it.
Memory-safe decoding
Unlike C or C++, a malformed audio file can't corrupt memory or open a security hole.
Native DSP performance
Compiles to native machine code, so the analyzers' heavy per-sample math runs fast.
Design

Why we built our own

The existing libraries in this space are solid, but no single one gave us a permissive, in-process API that also detects mid-track tempo and key changes and emits a versioned format.

Option Language / API License Benchmark baseline Why not used
stratum-dsp Rust MIT OR Apache-2.0 Yes We should reach out about collaboration
S-KEY Python MIT Yes Needs a model runtime
libKeyFinder C++ GPL-3.0 Yes License
Essentia C++ and Python AGPLv3 or paid Yes License
Vocabulary

MIR vocabulary

Four helpful terms relevant to trail mix.

Onset
The start of a sound event. Where energy jumps as a drum hits or a note begins. Onsets are evidence for beats, not beats themselves.
Tempo
The rate of the counted beat, in BPM. It also sets the expected time between beats.
120 BPM = two beats per second = 0.5 seconds per beat.
Chroma
A pitch class is a note category such as C, C#, or D. Chroma is a bar chart of how much the song uses each of the 12 pitch classes.
Key
There are 24 keys (12 major, 12 minor). To find it, compare the track's chroma to a template for each of the 24 keys and pick the closest match.
E.g. A minor
Analyzer 1 of 3

Beat Salad

Tempo and beat positions

Abstract illustration of beet salad ingredients

Onset detection

An onset is where a new sound starts, like a drum hit. It shows up as a sudden jump in energy, so we track the energy frame by frame and add up only the rises. That running total is the onset-strength curve, and its peaks are the candidate beats.

Audio on top with two loud bursts where new sounds start, and the onset-strength curve below spiking into a peak under each burst

Tempo estimation and beat tracking

Tempo estimation finds the beat period that best matches the repeating peaks in the onset-strength curve. To find a beat switch, the same estimator runs on rolling sections of the track.

Match-strength curve with three equal peaks at half time, the true pulse, and double time, a dashed preference bell over the middle of the tempo range, and the true pulse chosen

Beat tracking with dynamic programming (DP)

Beat tracking uses the estimated BPM to place the actual beat timestamps. Tempo estimation gives BPM, but it does not choose the beat grid offset, that is where those beats start. DP helps choose which grid is most plausible across the whole track. It can keep the sequence regular when one beat is weak or missing, and it can skip a loud off-beat onset.

Onset-strength curve with regular beat intervals: dots sit on strong peaks, one beat is kept on a weak spot for timing, and an off-beat peak is skipped

Beat Salad pipeline

Samples mono audio values
Onset detection energy rises
Tempo estimation periodicity score
Beat tracking beat timestamps
BeatAnalysis global_bpm beats tempo_segments
  1. Onset detection uses frequency-band energy to build the onset-strength curve.
  2. Tempo estimation works on that curve over time. The interval histogram counts how often different time gaps occur between onset peaks. For example, if 0.5 s gets a high score, it supports 120 BPM. Autocorrelation checks which time shifts make the onset curve line up with itself.
  3. Then beat tracking uses DP to choose the actual beat timestamps under that tempo.

Beat switches

A track can contain more than one meaningful tempo. Beat Salad reports the dominant BPM and the alternate tempo when the second section is long enough to matter.

Field What it contains Example
global_bpm Dominant tempo for the whole file 120 BPM
multi_tempo Boolean: another tempo covers at least 25% true
alternate_bpm Secondary tempo + coverage fraction 90 BPM, 45%
tempo_segments Timeline of local BPM ranges 120 BPM, then 90 BPM

Threshold: alternate tempo must cover at least 25% of the track.

Multi-tempo

A beat switch

A 60-second demo: 120 BPM for the first half, then 90 BPM.

120 BPM0:00-0:30
90 BPM0:30-1:00
0:00 / 1:00
global_bpm120.08
multi_tempotrue
alternate_bpm90.03
switch detected~33 s

The values are Beat Salad's output; full JSON in the appendix.

Analyzer 2 of 3

Key Lime

Musical key from chroma

Abstract illustration of a sliced lime

Key detection from chroma

Take the 12-note chroma and compare it to a template for each key. The closest match, across all 24 major and minor keys, is the detected key.

\[\operatorname{score}(k) = \frac{\displaystyle\sum_{p=0}^{11}(c_p - \bar{c})\,(T_{p,k} - \bar{T}_k)}{\sqrt{\displaystyle\sum_{p=0}^{11}(c_p - \bar{c})^2 \;\cdot\; \sum_{p=0}^{11}(T_{p,k} - \bar{T}_k)^2}}\]

  • The formula: Pearson correlation; how closely the chroma matches a key template, after mean-centering and normalizing both
  • score(k): correlation for key k; the highest of 24 keys (12 major + 12 minor) wins
  • c_p: chroma energy at pitch class p (0-11, one per semitone)
  • T_{p,k}: expected energy for pitch class p in key template k (K-K, Temperley, EDMA, or an ML model)

Key changes

Key Lime reruns chroma-to-template scoring in rolling windows, groups adjacent windows with the same confident key, and reports an alternate key when it covers at least 25% of the track.

Field What it contains Example
key Primary key for the whole file A minor
multi_key Boolean: another key covers at least 25% true
alternate_key Secondary key + coverage fraction C major, 25% coverage
segments Timeline of local key ranges A minor, then C major
Multi-key

A key change

A 60-second demo: A major for the first half, then C minor.

A major0:00-0:30
C minor0:30-1:00
0:00 / 1:00
keyC minor
multi_keytrue
alternate_keyA major
change detected~33 s

The values are Key Lime's output; full JSON in the appendix.

Analyzer 3 of 3

Sampler Platter

Compact waveform overview

Abstract illustration of a sampler platter
Sampler Platter

Waveform summarization

The min, max, and RMS columns that draw a track lane.

44,100 samples per second reduced to up to 1,500 columns, each storing min, max, and RMS

Waveform implementation

The samples are split into up to 1,500 equally sized buckets. Each bucket becomes one column, summarized by three values.

let slice = &samples[start..end];
let min = slice.iter().copied().fold(f32::INFINITY, f32::min);
let max = slice.iter().copied().fold(f32::NEG_INFINITY, f32::max);
let sum_sq: f32 = slice.iter().map(|s| s * s).sum();
let rms = (sum_sq / slice.len() as f32).sqrt();
  • slice: the samples in this bucket (track length / up to 1,500 buckets)
  • min: lowest sample value; bottom edge of the waveform bar
  • max: highest sample value; top edge of the waveform bar
  • rms: root mean square energy; renderers can draw it as the filled region inside the bar
Sampler Platter

App-style waveform sample

A 60-second segment from the Cueport demo audio, reduced to 200 fixed-width bars. Bar height uses max(|min|, |max|); color follows RMS.

0:00 / 1:00
segment60 s
bars200
heightmax(|min|, |max|)
colorRMS

Evaluation metrics

How to read the accuracy numbers on the next slide.

MIREX weighted score (key)
A wrong key is often musically close, so each track earns partial credit rather than pass or fail: an exact match scores 1.0, a perfect fifth 0.5, a relative major or minor 0.3, a parallel major or minor 0.2, and anything else 0. MIREX is the average of that credit over all tracks.
F1 at 70 ms (beats)
A predicted beat counts as correct when it lands within 70 ms of a real beat. From those matches, precision is the fraction of predicted beats that are correct and recall is the fraction of real beats found. F1 is the harmonic mean of the two, so it is high only when both are high: \(F_1 = \dfrac{2\,\times\,\text{precision}\,\times\,\text{recall}}{\text{precision}\,+\,\text{recall}}\), on a 0 to 1 scale.
Accuracy1 and Accuracy2 (tempo)
Accuracy1 counts a BPM as correct within four percent of the label. Accuracy2 also counts half-time or double-time as correct, because 70 and 140 BPM are the same pulse counted differently.
Hold out vs development
A development corpus is one we looked at while tuning. A hold out corpus is sealed before the run and never used for tuning, so it gives the fair score.

Results and gaps

Tempo leads the hand-written DSP baselines and trails the ML models. Key is the weakest analyzer.

Analyzer Metric trail mix Baseline Status
Tempo Global BPM Acc1 / Acc2 (GiantSteps, dev) 0.68 / 0.83 DSP: stratum-dsp 0.60/0.85, Essentia 0.61/0.80, librosa 0.37/0.52. ML: Beat This 0.86/0.94 Leads DSP, trails ML
Beats F1 @ 70 ms Implemented, unscored Open corpora label BPM, not beat times No beat-time corpus
Key MIREX (FMAKv2, hold out) 0.53 DSP: Essentia 0.66, libKeyFinder 0.62, stratum-dsp 0.48. ML: S-KEY 0.70 Main gap
Performance Key time / track (FMAKv2) 87 ms, 1.7 MB binary Essentia 53, libKeyFinder 101, S-KEY 167 ms Small, embeddable
What's next

Future work and collaboration

What's next for trail mix, and where help from others would make the biggest difference.

1
Publish evaluation numbers
Score key and tempo on hold out corpora and report them next to Essentia, librosa, and S-KEY, so the claims are checkable
2
Ship the S-KEY key backend
A feature-gated ONNX S-KEY model behind the same KeyAnalysis type, to close the key gap. Target: MIREX > 0.7
3
Open music datasets
Permissively licensed audio labeled for key, tempo, and especially beat times
4
Train new ML models
Try a smaller S-KEY-style key model, a tempo-octave classifier, or a beat/bar tracker
5
Integrations
Pressure-test Analysis JSON in other players and tools
Credits

Built with open source

trail mix builds on a number of Rust libraries, plus open datasets and the Rust toolchain.

Decoding
Language and packaging
And many more

Further reading

Papers, methods, and datasets behind trail mix.

Methods

Key estimation

Baselines and datasets

FOSSY 2026

Thank you

Appendix

The analyze() function

One analyze() call runs the three analyzers over the same sample buffer and returns one versioned result.

pub fn analyze(audio: AudioBuffer<'_>) -> Analysis {
    let AudioBuffer { samples, sample_rate } = audio;
    Analysis {
        version: 1,
        beat: beat_salad::analyze(samples, sample_rate),
        key: key_lime::analyze(samples, sample_rate),
        waveform: sampler_platter::generate_overview(samples, sample_rate),
    }
}
  • AudioBuffer: the input, mono samples plus the sample rate
  • Analysis: the output, one versioned result with beat, key, and waveform
  • beat_salad::analyze: the analyze in the beat_salad crate, a different function from this one

Onset detection: spectral flux

The onset-strength curve is spectral flux, the summed rise in band energy from one frame to the next.

\[\operatorname{flux}(t) = \sum_{b=1}^{24} \max\!\bigl(0,\; E_b(t) - E_b(t-1)\bigr)\]

  • flux(t): onset strength at frame t; its peaks are the candidate beats
  • E_b(t): energy in band b at frame t, from 24 pitch-spaced Goertzel filters
  • max(0, ...): half-wave rectification, so only rising energy counts, not fading
  • sum over b: fold the rises across all 24 bands into one number per frame

Tempo estimation

Autocorrelation of the onset curve, together with an inter-onset interval histogram, locates the beat period. An octave prior then nudges the pick toward mid-range tempos to break the half and double time tie.

\[ \begin{aligned} A(\tau) &= \frac{\sum_t O(t)\,O(t-\tau)}{\sum_t O(t)^2} \\ \operatorname{prior}(\mathrm{bpm}) &= \exp\!\left(-\frac{\log_2(\mathrm{bpm}/120)^2}{8}\right) \end{aligned} \]

  • A(τ): normalized autocorrelation of the onset curve at lag τ; peaks mark repeating pulses
  • bpm(τ): tempo for lag τ, \(\mathrm{bpm}(\tau) = 60\,f/\tau\) with onset frame rate f
  • prior(bpm): soft preference near mid-range tempo, applied as \(0.75 + 0.25\,\operatorname{prior}\)
  • pick: the lag with the highest combined score sets the tempo

Beat tracking optimization

Ellis dynamic programming scores a whole beat sequence at once, rewarding strong onsets while keeping regular beat intervals.

\[C(i) = O(i) + \max_{j \lt i}\left[\,C(j) + w\,\exp\!\left(-\frac{\big((i-j)-P\big)^2}{2\sigma^2}\right)\right]\]

\[J(b_1,\ldots,b_m)=\sum_t O(b_t)+0.5\sum_{t>1}\exp\!\left(-\frac{\big((b_t-b_{t-1})-P\big)^2}{2\sigma^2}\right),\quad b_t-b_{t-1}\in[P-0.25P,\;P+0.25P]\]

  • C(i): best cumulative score for a beat ending at frame i
  • O(i): onset strength at frame i
  • P: target beat interval, \(P = 60\,f / \mathrm{BPM}\) with onset frame rate f

Beat tracking inner loop

The forward pass in Rust: for each frame, keep the best predecessor, then add the frame's own onset strength.

// beat interval in onset frames, and how far it may drift
let period = 60.0 * envelope_rate / bpm;
let denom = 2.0 * (period * 0.5).powi(2);
// for a beat at frame i, try each earlier frame j before it
let deviation = (i - j) as f32 - period;
let penalty = (-(deviation * deviation) / denom).exp();
let path = previous_score + penalty * 0.5;
// keep the best predecessor, then add i's own onset strength
let score = onset[i] + best_path;
  • period: ideal beat interval in onset frames, from the estimated BPM
  • penalty: Gaussian weight for a predecessor, highest when the gap i - j is one period
  • path: that predecessor's cumulative score plus the weighted interval reward
  • score: frame i's onset strength plus the best predecessor path

Beat switch: full output

The complete beat object Beat Salad returned for the 120-to-90 BPM click track on the demo slide.

"beat": {
  "global_bpm": 120.08,
  "multi_tempo": true,
  "alternate_bpm": 90.03,
  "alternate_coverage": 0.45,
  "tempo_segments": [
    { "start_seconds": 0.0,  "end_seconds": 33.0, "bpm": 120.09 },
    { "start_seconds": 33.0, "end_seconds": 60.0, "bpm": 90.03 }
  ]
}
  • global_bpm 120.08: the dominant tempo across the whole file
  • alternate_bpm 90.03, alternate_coverage 0.45: the second tempo and the share of the file it covers
  • tempo_segments: the two detected sections, split near 33 s against a true 30 s

Key change: full output

The complete key object Key Lime returned for the A-major to C-minor chord track on the demo slide.

"key": {
  "version": 6,
  "key": { "tonic": "C", "mode": "Minor" },
  "confidence": 0.021617552,
  "chroma": [0.0881601, 0.14320727, 0.0827591, 0.08654915, 0.14292027, 0.06208938, 0.048048906, 0.045049556, 0.06878138, 0.15347181, 0.050051074, 0.02891198],
  "segments": [
    { "start_seconds": 0.0, "end_seconds": 27.0, "key": { "tonic": "A", "mode": "Major" }, "confidence": 0.010805504 },
    { "start_seconds": 27.0, "end_seconds": 33.0, "key": { "tonic": "CSharp", "mode": "Minor" }, "confidence": 0.26016137 },
    { "start_seconds": 33.0, "end_seconds": 60.0, "key": { "tonic": "C", "mode": "Minor" }, "confidence": 0.0045011365 }
  ],
  "multi_key": true,
  "alternate_key": { "tonic": "A", "mode": "Major" },
  "alternate_coverage": 0.45
}