Skip to content

Benchmarks ​

Performance comparison of libsonare against librosa (Python) for audio analysis tasks.

Use this page as performance context, not as a functional tutorial. If you need to learn the API first, read Getting Started, Feature Map, and the runtime page for your language.

How to read benchmark numbers

Lower latency means the operation finished faster for this exact workload. A speedup such as 2x means "twice as fast in this benchmark," not "twice as fast for every file." Hardware, sample rate, clip length, codec decode time, and whether intermediate features are reused can all change the result.

Native numbers are not browser numbers

Every figure on this page is labeled native or browser (WebAssembly), and they are not interchangeable.

The native build runs its transforms on SIMD FFT kernels: PFFFT serves every transform length it can factor, and KissFFT keeps the rest. The WebAssembly configuration keeps KissFFT alone — CMakeLists.txt forces SONARE_USE_PFFFT=OFF under BUILD_WASM, because carrying both FFTs costs the analysis-only module more than its size budget allows.

So the transform-bound speedups below do not reach a browser. Anything whose cost lives in the FFT — the STFT, and everything built on top of it — is a native figure only. What WebAssembly Costs is where the browser side is measured, and Wins That Do Reach the Browser covers the two that carry over unchanged.

What You Will Learn ​

By the end of this page you should be able to:

  • interpret benchmark numbers as workload-specific measurements rather than universal speed claims;
  • tell a native figure from a browser figure, and know why the transform-bound ones do not carry from one to the other;
  • distinguish all-in-one pipeline speedups from per-feature comparisons;
  • understand why shared intermediates, native execution, and pipeline design matter;
  • read a before/after pair as the effect of one optimisation under stated conditions, and tell which figures grow with input length and which do not;
  • find the benchmark source and reproduce or update the measurements when hardware, inputs, or implementations change.

Methodology

All numbers below are measured standalone from raw audio — every call rebuilds whatever intermediate state it needs (the short-time Fourier transform (STFT), the mel spectrogram, and so on) from the original samples. This is the same code path a one-shot user of either API exercises, so the comparison is apples-to-apples. The full benchmark source and results JSON live in benchmarks/ inside the libsonare repo.

Every case runs 3 times and the tables report the median, on both sides — bench_cpp.cpp takes the median of 3 for libsonare, run_bench.py does the same for librosa. The individual run times are not written to the results JSON, so no spread is published here; with 3 samples a standard deviation would not say much anyway.

Hardware

Measured on Apple M5 Max (18 hardware threads, 128 GB unified memory). Both halves of the benchmark record the load average they ran under, and this run was not idle: the C++ half spanned 2.29 to 2.43, the librosa half 2.49 to 3.09. benchmarks/README.md says not to publish a run from a busy machine, because contention does not scale the two sides evenly — so read these as a run under light background load, and re-measure on a quiet machine before settling an argument with them. Absolute times scale with your hardware; the ratios are what carries over.

Comparison versions

The Python side ran the versions pinned in benchmarks/requirements.lock:

  • librosa 0.11.0
  • scipy 1.17.1
  • numpy 2.4.6
  • numba 0.67.0

on CPython 3.11 or later — benchmarks/pyproject.toml sets the floor and the exact interpreter is not recorded in the results. Two of these move the numbers directly: librosa delegates its FFT to scipy, and numba JIT-compiles the inner loop of pYIN.

All-In-One Pipeline Analysis (Native) ​

All-in-one music analysis: BPM + key + beats + chords + sections + timbre + dynamics + rhythm + melody. Both sides run natively — libsonare as a native C++ binary, the Python pipeline on CPython.

Test audio: synthetic WAV, 73 seconds, 44100 Hz stereo, generated locally by the committed benchmarks/generate_audio.py rather than shipped as a binary. Generation is deterministic, so your copy is the same bytes as the one these timings were measured on — the script prints its SHA-256 and warns if it does not match:

3be88171eb87f8569189b9acef994e18263f89e7adf05119cbb48591c4953cb3

All-In-One Analysis Latency (lower is better)

LibraryLanguageRuntimeTimeRelative
libsonareC++native0.54s1x
bpm-detector 1.1.0 --comprehensive (librosa-based)PythonCPython38.9s~72x slower

What the 72x is against

The comparison target is not librosa. It is bpm-detector 1.1.0 run with --comprehensive, a pipeline built on top of librosa — and it is written by the same author as libsonare, which libsonare supersedes. Read the ratio knowing that both sides of it are ours.

librosa is a feature library rather than a one-shot analyzer — there is no librosa.analyze() to time — so any full-pipeline comparison has to pick some pipeline built on it. bpm-detector computes the same feature set end to end, which makes it comparable.

Read 72x as "against this Python pipeline, on this fixture, natively". A different librosa-based pipeline gives a different ratio, and the per-feature table below is the better guide to what libsonare will do for your own code.

The all-in-one pipeline figure is where libsonare's design pays off most: shared spectrograms, parallel feature paths, automatic 44.1 → 22.05 kHz downsampling done once inside the C++ pipeline (the librosa pipeline resamples too, so the comparison stays apples-to-apples), and no Python boundary inside the pipeline.

Much of that gap is structural rather than a language win — the per-feature table below shows C++ against Python on identical work, and the individual margins there are far smaller than 72x.

Per-Feature Comparison (Native) ​

Individual feature extraction on the same 73-second audio (resampled to 22050 Hz). librosa measured with time.perf_counter, libsonare measured with chrono::steady_clock inside a native sonare_bench build — this is the SIMD-transform configuration, so these rows do not describe the browser. The browser equivalents are further down.

Per-Feature Latency (lower is better)

Featurelibrosalibsonare (native)Speedup
STFT (2048, hop 512)12.6ms6.81ms1.85x
Mel Spectrogram (128 bands)19.9ms15.9ms1.25x
HPSS (harmonic/percussive separation, kernel 31)1,716ms64.4ms26.6x
Onset Strength21.0ms16.8ms1.25x
Chroma (STFT-based)42.9ms8.46ms5.07x
Beat Track34.7ms44.4ms0.78x — slower
MFCC (13 coefficients)21.3ms16.9ms1.26x
pYIN5,745ms364ms15.8x
Spectral Centroid24.7ms12.1ms2.04x

Beat tracking is slower standalone

Beat Track is the one row where libsonare still loses natively: 44.4 ms against librosa's 34.7 ms on the same audio. Note also that sonare beats returns a time signature and downbeats alongside the beat grid, so it is not doing the same work as librosa.beat.beat_track — but if beat times are all you want, that extra work is cost with no return.

If you need beats along with anything else, call analyze() instead. The pipeline computes the onset envelope once and shares it, so beat tracking there does not pay the standalone cost in this table. Calling sonare beats on its own is the case where libsonare has nothing to offer over librosa today.

Is It Right? Accuracy On The Same Fixture ​

Speed says how fast an answer arrives, not whether it is correct. The fixture is synthesized from an explicit tempo, beat grid, chord progression and key, and generate_audio.py writes that description out beside the WAV — so unlike a real recording, this benchmark comes with an answer key.

WhatReferenceResult
Tempo120.00 BPM119.80 BPM — 0.17% error, inside the MIREX 4% window
Beats146 beats145 detected, F-measure 0.997 at the standard ±70 ms tolerance, median offset +25 ms
KeyA minoranalyze() → A minor (exact)
KeyA minorsonare key → C major (the relative — see below)
Chords16 barsanalyze() 0.940 frame-wise, on root and on root+quality alike
Chords16 barssonare chords 0.643, likewise on root and root+quality
bash
python3 benchmarks/generate_audio.py
python3 benchmarks/measure_accuracy.py --cli build-release/bin/sonare-cli

Two of those rows affect how you should call the library:

  • Use the pipeline for chords. analyze() scores 0.940; the standalone sonare chords command scores 0.643. The pipeline has a beat grid and bar segmentation to settle chord boundaries against, and the standalone command does not.
  • The two key detectors can disagree. Here analyze() returns A minor and sonare key returns C major — the relative major. Both are defensible for a progression diatonic to both, but if you need one answer, take it from analyze().

This is a floor, not a benchmark

Synthetic audio has no performance timing, no timbral ambiguity, no production and no ambiguity about where a bar begins. Scoring well here means the analyzers recover a signal that was constructed to be recoverable.

It does not predict accuracy on real recordings. That needs annotated real music, which cannot be redistributed here.

You can score against your own corpus: tests/fixtures/music_eval/ holds manifests for BPM, beat, downbeat, chord, key and meter. Point SONARE_MUSIC_FIXTURE_ROOT at your annotated audio, add rows, and build with SONARE_ENABLE_OPTIONAL_FIXTURE_TESTS=ON.

Otherwise, judge accuracy on your own audio with the browser demo or the CLI.

WASM Mastering ISP Guard ​

An inter-sample peak (ISP) is a peak that falls between two samples — silent in the raw numbers but real once a DAC (digital-to-analog converter) reconstructs the waveform — so the limiter must oversample to catch it. This benchmark confirms that detector is fast enough to run in the browser.

The mastering true-peak path is also checked in WebAssembly with a 48 kHz stereo 1 ms block, 4x oversampling, and the same sliding-max guard used by the final limiter.

BenchmarkRuntimeMedian per 1 ms audioThresholdResult
mastering_isp_4x_stereo_1msWASM / Node0.0062ms5.0msPass

This verifies the inter-sample peak detector has enough headroom for browser rendering. Reproduce with cd bindings/wasm && yarn bench:wasm:isp in the libsonare repository.

What WebAssembly Costs ​

Two things separate the browser build from the native one. It is single-threaded, so the features that fan out across cores lose that advantage; and it keeps KissFFT alone, so it does not get the SIMD transform either. Both columns are the same fixture and the same benchmark, but the browser column comes from its own WebAssembly run rather than from the same pass as the native one — so read the penalty as the shape of the gap, not as a paired measurement.

FeatureNativeBrowser (WASM)Browser penalty
All-in-one analyze541ms3,159ms5.8x
STFT6.81ms16.4ms2.4x
Mel Spectrogram15.9ms47.3ms3.0x
HPSS64.4ms422ms6.6x
Onset Strength16.8ms48.5ms2.9x
Chroma8.46ms20.5ms2.4x
Beat Track44.4ms80.4ms1.8x
MFCC16.9ms48.9ms2.9x
pYIN364ms437ms1.20x
Spectral Centroid12.1ms23.3ms1.9x

The penalty is not uniform, so what the browser costs you depends on which features you use. HPSS pays both prices at once — it is the routine that fans out across cores, and it sits on the transform — so budget roughly 6.6x for harmonic-percussive separation in a tab. The mel-filterbank features sit around 3x.

pYIN is the outlier in the other direction, at 1.20x — the smallest penalty in the table. Its runtime is dominated by the Viterbi lattice (the search for the single most likely pitch path), which is compare-and-add over doubles with the transition weights already stored as logarithms, and WASM runs that at close to native speed. Only the FFT-based YIN difference in front of the lattice notices which transform it got, and that is the smaller half of the work.

For the all-in-one pipeline, expect around 5.8x. On this fixture that is 73 seconds of audio analyzed in 3.2 seconds — still around 23x faster than the audio plays.

What this means for choosing a library

The answer splits by runtime, and that split is the useful part.

Natively, there is no cheap feature on this fixture where librosa comes out ahead: the SIMD transform puts libsonare 1.85x up on the STFT and about 1.25x up on everything that sits on top of it — Mel, MFCC, onset strength. Those three margins are small enough that they are not on their own a reason to switch, but they are not a reason against either.

In the browser, that advantage is absent — no SIMD transform, no threads — and the two tables above put the WebAssembly STFT and mel-filterbank features behind librosa running natively on the same machine. Choosing libsonare for a tab is choosing it because librosa is not available there at all, not because it is the faster of the two.

Either way, libsonare is worth it when you need HPSS (27x native), pitch tracking (16x native), chroma (5.1x native), or several features at once — the last being where shared intermediates and the absence of a Python boundary dominate, and the one reason that survives the trip into a browser. For standalone beat tracking, librosa is faster on both.

What the Browser Downloads ​

The browser pays one cost the native build never does: fetching the module. These figures are read from src/wasm/meta.json, the record this site keeps of the artifact it ships, rewritten every time the WebAssembly build is copied in — so they track the current build rather than a number typed into this page.

AssetRawgzip
sonare.wasm, the full entry~7,808 KB~2,656 KB
sonare.wasm, sonare.js and index.js together~8,500 KB~2,792 KB

The total leaves out the realtime worklet bundle (worklet.js), which loads only on a page that runs the realtime engine in an AudioWorklet. The gzip column is what crosses the wire; the raw column is what the engine compiles and holds in memory. Neither scales with anything you do — the module is the same size for a 10-second clip and a two-hour one, and a page that loads it once keeps it for the session. The analysis-only entry behind @libraz/libsonare/analysis is substantially smaller than the full one; the WebAssembly Guide has the per-file breakdown and the loader trade-offs.

Where the Big Wins Come From (Native) ​

All-in-one pipeline (72x): shared intermediates + no Python ​

libsonare's analyze() computes the STFT and Mel spectrogram once, then reuses them across downstream analyzers.

That reuse matters:

AnalyzerShared input it can reuse
Chord detectionThe same chromagram used by key detection.
Beat trackingThe same onset envelope consumed by section detection.

Independent paths run in parallel across CPU cores. None of this crosses the Python boundary, so per-call dispatch overhead is gone.

bpm-detector (and any other librosa-based pipeline) rebuilds these intermediates per analyzer and orchestrates everything from Python — the cost adds up.

HPSS (26.6x): cache-friendly multithreaded median filter ​

librosa's HPSS calls scipy.ndimage.median_filter once horizontally and once vertically — a general-purpose C implementation processed sequentially per pixel.

libsonare replaces this with a custom sliding median:

  • Sorted flat array with O(log k) binary search + O(k) memmove instead of a tree, which fits in L1 cache for typical kernel sizes
  • Multi-threaded execution — rows and columns processed in parallel across all cores
  • Result: ~27x faster end-to-end than the scipy version on this hardware natively, and the routine that loses the most when the browser build takes the threads away
What is a median filter (and a sliding median)?

A median filter replaces each value with the median of its neighbors in a small window. Unlike averaging, it removes spikes and outliers while keeping edges sharp — which is exactly why HPSS uses it: a horizontal median pass keeps steady (harmonic) lines, a vertical pass keeps sharp (percussive) hits. A sliding median computes this efficiently as the window moves across the data, instead of re-sorting from scratch at every step.

HPSS · FULL MIXIDLE
HPSS — splitting the tune from the drums

Sustained harmonic sounds tend to form horizontal ridges in a spectrogram; transients tend to form vertical streaks. HPSS uses median filters and masks to separate these patterns. Switch between the full mix, harmonic and percussive layers and audition them. This separates spectral structure rather than instrument identities: pitched attacks can enter the percussive layer, and sustained cymbals can enter the harmonic layer.

Layer

pYIN (15.8x): native YIN difference and Viterbi decoding ​

pYIN's cost is per-frame candidate evaluation and the Viterbi decoding step. libsonare implements both in C++, replacing librosa's Numba-JIT'd inner loop, and computes the YIN difference function through an FFT rather than directly.

Decoding dominates: with the default 65–2093 Hz range the lattice has 1,202 states and roughly 100 reachable transitions per state, so a 73-second clip visits the inner update several hundred million times. Everything that can leave that loop has left it — the transition weights are stored as logarithms and the voiced/unvoiced switch costs are constants — leaving a compare-and-add over doubles.

The lattice is single-threaded. Unlike HPSS it gains nothing from extra cores, and for the same reason it gives up almost nothing in a browser — the WASM penalty is 1.20x, and what there is of it comes from the FFT in front of the lattice rather than from the decoding.

Chroma (5.07x): tighter STFT → filterbank path ​

Chroma derives a 12-pitch-class representation from the spectrogram via a pitch-class filterbank over the STFT bins. libsonare's STFT and the filterbank multiplication run as Eigen3-vectorized matrix operations on a single contiguous buffer, avoiding the dispatch overhead of librosa's stack of NumPy operations.

The transform (2.3x): SIMD FFT kernels ​

The one before/after pair in the native table is the FFT backend itself, and it is the pair that explains the shape of everything above it. One tree was built twice — with SONARE_USE_PFFFT on, and with -DSONARE_USE_PFFFT=OFF for the KissFFT-only transform — and sonare_bench run on both, as a native Release build over the same fixture as the rest of this page: 73 seconds at 44.1 kHz resampled to 22.05 kHz, the STFT at 2048 points with hop 512, three runs per case, the median reported, on Apple silicon.

MeasurementKissFFT onlySIMD FFTChange
STFT (2048, hop 512)13.6 ms6.0 ms2.3x
Chroma15.0 ms7.5 ms2.0x
Spectral Centroid19.2 ms10.8 ms1.8x
Mel Spectrogram22.7 ms15.2 ms1.5x
MFCC23.5 ms16.1 ms1.5x
Onset Strength23.1 ms15.5 ms1.5x
HPSS88.2 ms61.9 ms1.4x
pYIN425 ms337 ms1.3x
analyze() pipeline963 ms611 ms1.6x

Every row is the same transform seen through a different amount of other work: the STFT alone moves 2.3x, and each feature moves by exactly the share of its time it spent in the FFT. The measurements that reuse an already-computed spectrogram did not move at all, which is the control — nothing outside the transform changed. Results move by floating-point rounding only, well under a tenth of a decibel.

Three things follow. The gain is per frame, so the ratio holds at any clip length; a longer file just has more frames. The 6.81 ms STFT in the per-feature table is this same row from the tracked benchmarks/results_cpp.json run, taken in a different session under a recorded load average — the gap between 6.0 and 6.8 is session noise, and the 2.3x is the effect. And none of it reaches the browser: the WebAssembly modules keep KissFFT alone, for the size reason given at the top of the page, so a tab's STFT is the KissFFT-only figure scaled by the WebAssembly penalty above.

Wins That Do Reach the Browser ​

The FFT backend is why most of the table above is native-only. These two are not: they are properties of how the work is organized rather than of the instruction set, so a tab gets them in full. Both are before/after pairs, and each is the effect of one change measured on one input; neither is part of the sonare_bench suite (sonare_true_peak_bench measures the realtime meter's streaming path, which is a different thing), so the inputs are stated here in full.

MeasurementInputBeforeAfterWhat grows with input length
sonare::metering::lufs, peak resident memory1 hour, 48 kHz, mono — 172.8 M samples, 691 MB of float input2.09 GB706 MBOnly the caller's input buffer; the measurement's own window is bounded at 3 s
sonare::metering::true_peak at 4x oversampling, wall clock10 seconds, 48 kHz, material with a transient above its body6.1 ms0.6 msBoth figures, linearly; the pruned one also depends on the crest of the material

Offline LUFS does not scale its working set with the clip length. Materializing the K-weighted signal whole costs one double per sample per channel — 1.38 GB for the hour above, which with the 691 MB input buffer and the process's own baseline on top is the 2.09 GB. The measurement never needs all of it at once, so the signal is instead filtered in chunks into a sliding window holding only what the earliest unfinished gating block still owes, bounded by the 3-second short-term window no matter how long the input is. Peak memory is now the input buffer plus about 15 MB, baseline included. The filter state carries across chunks and every block is still summed in one pass over its own window, so the reported loudness is bit-for-bit the same either way.

This is the figure that decides whether a long file fits in a tab. The shipped WebAssembly module is built with ALLOW_MEMORY_GROWTH and no raised MAXIMUM_MEMORY, so its linear memory tops out at Emscripten's 2 GB default: an hour of 48 kHz mono fits with room to spare, and the limit is the decoded input itself — an hour of 48 kHz stereo is 1.38 GB of float before the measurement touches it. The LUFS pass does not need a long file batched into segments for memory's sake; batch for the input buffer, if at all.

True peak interpolates only where a higher peak is still reachable. The interpolator can tell in advance that a stretch of samples cannot exceed the peak already found, and skips it (see True-Peak Safety for what a true peak is). Concretely, each polyphase phase bounds its output by the largest input magnitude its stencil (the short window of input samples one output sample depends on) can reach, so a group of output samples whose bound cannot exceed the peak found so far is skipped instead of computed. The measurement stays exact — only interpolations that provably cannot win are dropped. Material with a transient standing above its own body, which is most material, gets the whole benefit; a signal sitting at full scale throughout has nothing to prune and costs what exhaustive interpolation costs, so read 0.6 ms as the common case and 6.1 ms as the ceiling. Either way it is 0.06 to 0.6 ms per second of audio — a three-minute track measures in well under a second even unpruned, so there is nothing here worth batching, and nothing that changes in a browser.

What's Not Faster (And Where) ​

  • STFT itself: natively libsonare comes out 1.85x ahead, because its transform runs on SIMD kernels while librosa delegates to scipy.fft. In the browser there is no SIMD backend to run on, and the tables above put the WebAssembly STFT behind librosa on the same fixture — so this row reverses depending on where you run it.
  • Mel / MFCC / Onset Strength: dominated by their underlying STFT cost — once that is paid, the per-frame Mel filterbank multiplication and DCT are too cheap for a different language to matter. They therefore inherit whatever the transform does: natively about 1.25x in libsonare's favor on all three, and in the browser roughly 3x the native cost, which puts them behind librosa there.
  • Beat tracking: slower than librosa when called standalone, on either runtime, and it is the only row that loses natively. Use analyze(), which shares the onset envelope, if you need beats alongside anything else.
  • Pipeline use cases: inside analyze() these same features cost between 0.47 ms and 8.75 ms apiece natively — onset strength 0.47 ms, MFCC 0.89 ms, chroma 1.10 ms, spectral centroid 2.65 ms, mel spectrogram 8.75 ms — because the STFT and Mel are computed once and shared. The standalone numbers above represent the "what does it cost to call this in isolation" view, not the in-pipeline cost.

Reproduce These Numbers Yourself ​

The benchmark lives under libsonare/benchmarks/ and is fully reproducible:

bash
# in your local libsonare checkout
cmake -B build-bench -DCMAKE_BUILD_TYPE=Release \
                     -DBUILD_BENCH=ON -DBUILD_TESTING=OFF -DBUILD_CLI=OFF
cmake --build build-bench -j

rye sync --pyproject benchmarks/pyproject.toml
rye run --pyproject benchmarks/pyproject.toml python benchmarks/generate_audio.py

./build-bench/bin/sonare_bench \
    benchmarks/fixtures/bench_73s_44100.wav \
    benchmarks/results_cpp.json

rye run --pyproject benchmarks/pyproject.toml python benchmarks/run_bench.py

The merged benchmarks/results.json contains both the C++ libsonare numbers and the librosa numbers, plus the bpm-detector all-in-one pipeline timing and version if bpm-detector is on PATH.

The same benchmark builds for WebAssembly, so the browser cost of the threaded paths is something you can measure rather than take on trust:

bash
emcmake cmake -S . -B build-wasm-bench -DBUILD_WASM=ON -DBUILD_BENCH=ON \
              -DBUILD_TESTING=OFF -DBUILD_CLI=OFF -DCMAKE_BUILD_TYPE=Release
cmake --build build-wasm-bench --target sonare_bench
node build-wasm-bench/bin/sonare_bench.js benchmarks/fixtures/bench_73s_44100.wav

Run it on an idle machine

Both halves record the one-minute load average before and after the run — sonare_bench for the C++ side, run_bench.py for the librosa side. Contention does not scale the two evenly, so a busy machine changes the ratios rather than just inflating both columns. Check load_average_before in results.json before trusting a comparison, including the one on this page. The WASM bench reports no load average, so check that one yourself.

Calling libsonare from Python

The native column above measures libsonare's C++ performance directly. If you call individual feature functions through the Python binding (e.g. libsonare.stft(samples, sr)), every call marshals the sample buffer across the FFI (foreign function interface) boundary, which dominates the runtime for cheap features. The all-in-one pipeline analyze() is unaffected — it runs end-to-end in C++ and only the small result struct crosses the boundary.

Notes ​

  • Numbers are hardware-dependent. Apple M5 Max here; relative gaps are stable across machines, absolute milliseconds are not. Ratios are the part worth carrying to your own hardware.
  • Synthetic test audio (deterministic chord progression + percussive bursts) is generated locally by a committed script rather than shipped as a binary, along with the ground truth the accuracy section scores against.
  • WASM builds are single-threaded, so the speedups that come from fanning out across cores shrink there — HPSS most of all. They also keep KissFFT alone, so the transform-bound rows shrink for a second, independent reason. The paths that are neither threaded nor transform-bound, the pYIN lattice among them, carry over almost unchanged. Build the WASM bench above and measure it on the runtime you care about rather than scaling the native figures by guesswork.
  • The offline LUFS and true-peak behavior described under Wins That Do Reach the Browser is the exception: it is algorithmic, so it holds on every runtime.