Solitito โ€” guitar chord and note recognition

Recognition models for Solitito, a real-time guitar trainer written in Rust. Code, the trainer and documentation are in the GitHub repository; this repository holds the binary artifacts the app needs.

Solitito, Chords mode: shell voicings, one column per root string
file
best_model_v2_take6_chords.onnx the recognition model: what is sounding, the root and the chord quality, 29 MB
short_onset_masking_v2.onnx the strike model: which pitch class was just struck, 1 MB
dsp_weights.json pseudo-CQT kernel in sparse format, 2 MB

The recognition model

Takes 48 frames of features (144 CQT bins + 12 chroma + 12 bass energy, 16 kHz, 256-sample hop โ€” 0.77 s of audio) and returns three outputs:

output shape meaning
root_logits 13 12 pitch classes + "Noise"
quality_logits 11 maj, min, maj7, dom7, min7, m7b5, dim7, aug, sus, note, N
pitch_logits 12 sigmoid โ€” which pitch classes are sounding

"Sounding" includes a string ringing on, one resonating in sympathy, and the note before โ€” all of them true and none of them played just now. What was actually struck is the strike model's question, below.

CNN with Squeeze-and-Excitation blocks, then a Transformer encoder with a CLS token. 7.3M parameters, CPU inference.

Results

Measured on a validation split grouped by source recording, with solo tracks excluded:

metric
root accuracy 98.1%
pitch F1 0.909
exact match (root and quality) 92.4%

The strike model

The recognition model sees 0.77 s at a time and is asked every 40 ms; that is too coarse to tell whether a note that is already ringing was just struck again. The strike model answers only that question, on every 16 ms hop:

input short_features [batch, 770, time] โ€” two Hann windows of the newest audio (1024 and 2048 samples at 16 kHz), magnitudes up to 4 kHz, ln(1 + 1000ยทa) / ln(1001)
output onset_logits [batch, 12, time] โ€” sigmoid per pitch class: struck at this frame
context causal; 34 past frames plus the current one (0.56 s)
metadata onset_threshold = 0.9, onset_history_frames = 34

A projection of the spectra and of their rise against the previous four frames, then four residual causal convolutions (dilations 1, 2, 4, 8). 260 thousand parameters; 0.7 ms per hop on a CPU.

It was trained as a branch beside the frozen recognition network and is cut out of that combined file, so it does not run the chord network on every hop. The branch never reads the chord input: its answer is identical to the last digit whatever the other input is.

The app uses it for everything that depends on what was struck rather than what is sounding: "Credit only what was struck" in the note modes (a class may be credited only if the model reported it struck in the last 0.35 s), a chord asked for twice in a row (it needs a fresh strike), and repeats โ€” a note credited once counts again only when that pitch class has been struck since, not when it is merely still sounding. The app evens out the input level before the model and ignores a second firing of the same class within 0.6 s. Measured on every reviewed note of a labelled recording, spliced into new signals (the note alone, ringing out, and struck again with the pick stopping the old vibration):

without the strike model with
a repeat allowed while the note only rings 24 / 51 0 / 51
struck again after 0.8 s, counted in time 36 / 51, 11 early 48 / 51, none early
struck again after 1.2 s, counted in time 35 / 51, 14 early 50 / 51, none early
a note ringing into the next chord, credited there 47 / 51 0 / 51

Training data

GuitarSet whole takes (solo and comp, players split whole: 00โ€“03 train, 04 validation, 05 test), Karplusโ€“Strong synthetic plucks with exact attack times (holds, re-plucks of a ringing note, octaves, an added third or fifth, re-struck triads), and masking pairs: a quieter upper note struck over two ringing ones at โˆ’18, โˆ’12, โˆ’6 or 0 dB, each with its background alone and itself alone. Levels vary by ยฑ6 dB in training.

Training

Each model has its own trainer in dist/ of the GitHub repository, one file to paste into a Kaggle GPU notebook: model_trainer.py for the recognition model (it writes best_model_v2_take6_chords.onnx), strike_trainer.py for the strike model, whose defaults are the recipe of short_onset_masking_v2.onnx. See docs/training.md there.

Using it

Put the files next to the Solitito binary; the release packages already contain them. SOLITITO_MODEL points the app at another recognition model file. The strike model is optional: without it the app still starts and judges repeats on a pitch-blind attack detector. ./solitito --check verifies what is there and prints the strike model's threshold.

Feeding these models your own features requires matching the DSP exactly โ€” the input is not raw audio. For the recognition model, dist/gen_weights.py in the GitHub repository produces the kernel, and dsp_weights.json here is its output. For the strike model, src/strike.rs (ShortFeatures) and the trainer compute the same features, and a shared test fixture holds them to the same numbers.

Training data of the recognition model

Two sources: a synthetic set rendered through NAM amp models with exact labels, and GuitarSet for real playing.

Getting GuitarSet right took four runs. Half of it is _solo โ€” monophonic improvisation carrying the accompaniment's chord annotation โ€” and its instructed chord labels contain no maj7 or min7 at all, while calling 500 segments m that were played as m7. Fixing those two moved exact match from 44.8% to 92.4%. The details, with numbers, are in the GitHub README.

License

MIT. GuitarSet is CC BY 4.0 โ€” Qingyang Xi, Rachel M. Bittner, Johan Pauwels, Xuzhou Ye & Juan P. Bello, https://guitarset.weebly.com/.

Downloads last month

-

Downloads are not tracked for this model. How to track
Inference Providers NEW
This model isn't deployed by any Inference Provider. ๐Ÿ™‹ Ask for provider support