Feature Extraction
Transformers
Safetensors
sheetsage2
audio
music
music-transcription
midi
abc-notation
custom_code
Instructions to use m-a-p/SheetSage2 with libraries, inference providers, notebooks, and local apps. Follow these links to get started.
- Libraries
- Transformers
How to use m-a-p/SheetSage2 with Transformers:
# Use a pipeline as a high-level helper from transformers import pipeline pipe = pipeline("feature-extraction", model="m-a-p/SheetSage2", trust_remote_code=True)# pip install -U transformers accelerate # Load model directly from transformers import AutoModel model = AutoModel.from_pretrained("m-a-p/SheetSage2", trust_remote_code=True, device_map="auto") - Notebooks
- Google Colab
- Kaggle
Correct chord spelling with local keys and canonicalize key labels
Browse filesApply the SheetSage2 LM chord-spelling scorer at each chord midpoint in LAB, ABC and playback exports. Canonicalize key labels using the LM/NoLM key map without changing numeric token IDs or the vocabulary fingerprint. Verify pitch spelling against NoLM, preserve raw decoded chord events, and add regression coverage for key changes, inversions, in-memory exports, paper mode and melody-only mode.
- README.md +21 -0
- chord_spelling_sheetsage2.py +177 -0
- exports_sheetsage2.py +11 -2
- tests/test_spelling.py +128 -0
- tokenization_sheetsage2.py +5 -2
README.md
CHANGED
|
@@ -46,6 +46,12 @@ Built on [MERT-v2-FullSong](https://huggingface.co/m-a-p/MERT-v2-FullSong), with
|
|
| 46 |
|
| 47 |

|
| 48 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 49 |
<a id="quick-start"></a>
|
| 50 |
|
| 51 |
## 🚀 Quick start
|
|
@@ -101,6 +107,21 @@ model = AutoModel.from_pretrained(
|
|
| 101 |
)
|
| 102 |
```
|
| 103 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 104 |
### Melody-only ABC for covers
|
| 105 |
|
| 106 |
Set `melody_only=True` to retain both the `Vocal` and `Ins` melodies while omitting chord symbols from the ABC and chord accompaniment from playback/combined MIDI. Raw predicted annotations remain available; the default full transcription is unchanged.
|
|
|
|
| 46 |
|
| 47 |

|
| 48 |
|
| 49 |
+
**Update log:**
|
| 50 |
+
|
| 51 |
+
**2026-09-21:** Fixed missing enharmonic spelling correction for key and chord labels. Chord spelling now follows the local key. It affects ABC output only.
|
| 52 |
+
|
| 53 |
+
If you downloaded the repository or model before this update (2026-09-21), please update to the latest repository code. **Model weights are unchanged.**
|
| 54 |
+
|
| 55 |
<a id="quick-start"></a>
|
| 56 |
|
| 57 |
## 🚀 Quick start
|
|
|
|
| 107 |
)
|
| 108 |
```
|
| 109 |
|
| 110 |
+
### Key and chord spelling
|
| 111 |
+
|
| 112 |
+
Chord LAB annotations and ABC symbols use the local key at each chord
|
| 113 |
+
interval's midpoint to correct enharmonic root spelling, following SheetSage2 LM
|
| 114 |
+
(for example, D#/G#/A# become Eb/Ab/Bb in C minor). Timing, chord quality,
|
| 115 |
+
inversion and sounding pitches stay unchanged. Without a decoded key, spelling
|
| 116 |
+
is preserved. Decoded events and tokens retain the original model predictions.
|
| 117 |
+
This applies to both in-memory results and saved exports, including `paper=True`.
|
| 118 |
+
|
| 119 |
+
Key labels use the canonical LM/NoLM spelling (D#:major becomes Eb:major;
|
| 120 |
+
C#:major becomes Db:major; A#:minor becomes Bb:minor) in decoded events,
|
| 121 |
+
token display and exported key intervals. Numeric token IDs and the vocabulary
|
| 122 |
+
fingerprint are unchanged. ABC melody spelling follows NoLM's key-relative
|
| 123 |
+
pitch table, including accidental resets at barlines and key changes.
|
| 124 |
+
|
| 125 |
### Melody-only ABC for covers
|
| 126 |
|
| 127 |
Set `melody_only=True` to retain both the `Vocal` and `Ins` melodies while omitting chord symbols from the ABC and chord accompaniment from playback/combined MIDI. Raw predicted annotations remain available; the default full transcription is unchanged.
|
chord_spelling_sheetsage2.py
ADDED
|
@@ -0,0 +1,177 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Local-key chord spelling, ported from SheetSage2 LM."""
|
| 2 |
+
import numpy as np
|
| 3 |
+
import mir_eval.chord
|
| 4 |
+
|
| 5 |
+
KEY_MAP = [
|
| 6 |
+
['C:major', 'Db:major', 'D:major', 'Eb:major', 'E:major', 'F:major', 'F#:major', 'G:major', 'Ab:major', 'A:major', 'Bb:major', 'B:major'],
|
| 7 |
+
['D:dorian', 'Eb:dorian', 'E:dorian', 'F:dorian', 'F#:dorian', 'G:dorian', 'G#:dorian', 'A:dorian', 'Bb:dorian', 'B:dorian', 'C:dorian', 'C#:dorian'],
|
| 8 |
+
['E:phrygian', 'F:phrygian', 'F#:phrygian', 'G:phrygian', 'G#:phrygian', 'A:phrygian', 'A#:phrygian', 'B:phrygian', 'C:phrygian', 'C#:phrygian', 'D:phrygian', 'D#:phrygian'],
|
| 9 |
+
['F:lydian', 'Gb:lydian', 'G:lydian', 'Ab:lydian', 'A:lydian', 'Bb:lydian', 'B:lydian', 'C:lydian', 'Db:lydian', 'D:lydian', 'Eb:lydian', 'E:lydian'],
|
| 10 |
+
['G:mixolydian', 'Ab:mixolydian', 'A:mixolydian', 'Bb:mixolydian', 'B:mixolydian', 'C:mixolydian', 'C#:mixolydian', 'D:mixolydian', 'Eb:mixolydian', 'E:mixolydian', 'F:mixolydian', 'F#:mixolydian'],
|
| 11 |
+
['A:minor', 'Bb:minor', 'B:minor', 'C:minor', 'C#:minor', 'D:minor', 'D#:minor', 'E:minor', 'F:minor', 'F#:minor', 'G:minor', 'G#:minor'],
|
| 12 |
+
['B:locrian', 'C:locrian', 'C#:locrian', 'D:locrian', 'D#:locrian', 'E:locrian', 'E#:locrian', 'F#:locrian', 'G:locrian', 'G#:locrian', 'A:locrian', 'A#:locrian']
|
| 13 |
+
]
|
| 14 |
+
|
| 15 |
+
MODE_NAMES = ['major', 'dorian', 'phrygian', 'lydian', 'mixolydian', 'minor', 'locrian']
|
| 16 |
+
MODE_STARTS = [0, 2, 4, 5, 7, 9, 11]
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
QUALITIES = {
|
| 20 |
+
# 1 2 3 4 5 6 7
|
| 21 |
+
'maj': [2, 0, 0, 0, 1, 0, 0, 1, 0, 0, 0, 0],
|
| 22 |
+
'min': [2, 0, 0, 1, 0, 0, 0, 1, 0, 0, 0, 0],
|
| 23 |
+
'aug': [2, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0, 0],
|
| 24 |
+
'dim': [2, 0, 0, 1, 0, 0, 1, 0, 0, 0, 0, 0],
|
| 25 |
+
'sus4': [2, 0, 0, 0, 0, 1, 0, 1, 0, 0, 0, 0],
|
| 26 |
+
'sus4(b7)':[2, 0, 0, 0, 0, 1, 0, 1, 0, 0, 1, 0],
|
| 27 |
+
'sus4(b7,9)':[2, 0, 1, 0, 0, 1, 0, 1, 0, 0, 1, 0],
|
| 28 |
+
'sus2': [2, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0],
|
| 29 |
+
'7': [2, 0, 0, 0, 1, 0, 0, 1, 0, 0, 1, 0],
|
| 30 |
+
'maj7': [2, 0, 0, 0, 1, 0, 0, 1, 0, 0, 0, 1],
|
| 31 |
+
'min7': [2, 0, 0, 1, 0, 0, 0, 1, 0, 0, 1, 0],
|
| 32 |
+
'minmaj7': [2, 0, 0, 1, 0, 0, 0, 1, 0, 0, 0, 1],
|
| 33 |
+
'maj6': [2, 0, 0, 0, 1, 0, 0, 1, 0, 1, 0, 0],
|
| 34 |
+
'min6': [2, 0, 0, 1, 0, 0, 0, 1, 0, 1, 0, 0],
|
| 35 |
+
'9': [2, 0, 1, 0, 1, 0, 0, 1, 0, 0, 1, 0],
|
| 36 |
+
'maj9': [2, 0, 1, 0, 1, 0, 0, 1, 0, 0, 0, 1],
|
| 37 |
+
'min9': [2, 0, 1, 1, 0, 0, 0, 1, 0, 0, 1, 0],
|
| 38 |
+
'7(b9)': [2, 1, 0, 0, 1, 0, 0, 1, 0, 0, 1, 0],
|
| 39 |
+
'7(#9)': [2, 0, 0, 1, 1, 0, 0, 1, 0, 0, 1, 0],
|
| 40 |
+
'maj6(9)': [2, 0, 1, 0, 1, 0, 0, 1, 0, 1, 0, 0],
|
| 41 |
+
'min6(9)': [2, 0, 1, 1, 0, 0, 0, 1, 0, 1, 0, 0],
|
| 42 |
+
'maj(9)': [2, 0, 1, 0, 1, 0, 0, 1, 0, 0, 0, 0],
|
| 43 |
+
'min(9)': [2, 0, 1, 1, 0, 0, 0, 1, 0, 0, 0, 0],
|
| 44 |
+
'maj(11)': [2, 0, 0, 0, 1, 1, 0, 1, 0, 0, 0, 1],
|
| 45 |
+
'min(11)': [2, 0, 0, 1, 0, 1, 0, 1, 0, 0, 0, 1],
|
| 46 |
+
'11': [2, 0, 1, 0, 1, 1, 0, 1, 0, 0, 1, 0],
|
| 47 |
+
'maj9(11)':[2, 0, 1, 0, 1, 1, 0, 1, 0, 0, 0, 1],
|
| 48 |
+
'min11': [2, 0, 1, 1, 0, 1, 0, 1, 0, 0, 1, 0],
|
| 49 |
+
'13': [2, 0, 1, 0, 1, 1, 0, 1, 0, 1, 1, 0],
|
| 50 |
+
'maj13': [2, 0, 1, 0, 1, 1, 0, 1, 0, 1, 0, 1],
|
| 51 |
+
'min13': [2, 0, 1, 1, 0, 1, 0, 1, 0, 1, 1, 0],
|
| 52 |
+
'dim7': [2, 0, 0, 1, 0, 0, 1, 0, 0, 1, 0, 0],
|
| 53 |
+
'hdim7': [2, 0, 0, 1, 0, 0, 1, 0, 0, 0, 1, 0],
|
| 54 |
+
#'5': [2, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0]
|
| 55 |
+
}
|
| 56 |
+
DEFAULT_SPELLING = ['1', 'b2', '2', 'b3', '3', '4', 'b5', '5', '#5', '6', 'b7', '7']
|
| 57 |
+
CHORD_SPELLING_TABLE = None
|
| 58 |
+
CIRCLE_OF_FIFTH = np.array([3, 0, 5, 1, 6, 2, 7])
|
| 59 |
+
CIRCLE_OF_FIFTH_INV = np.array([1, 3, 5, 0, 2, 4, 6])
|
| 60 |
+
NOTE_NAMES = 'CDEFGAB'
|
| 61 |
+
|
| 62 |
+
def scale_degree_to_tuple(scale_degree):
|
| 63 |
+
offset = 0
|
| 64 |
+
if scale_degree.startswith("#"):
|
| 65 |
+
offset = scale_degree.count("#")
|
| 66 |
+
scale_degree = scale_degree.strip("#")
|
| 67 |
+
elif scale_degree.startswith("b"):
|
| 68 |
+
offset = -1 * scale_degree.count("b")
|
| 69 |
+
scale_degree = scale_degree.strip("b")
|
| 70 |
+
return np.array([int(scale_degree) - 1, offset], dtype=int)
|
| 71 |
+
|
| 72 |
+
def note_name_to_tuple(note_name):
|
| 73 |
+
scale_degree = NOTE_NAMES.index(note_name[0])
|
| 74 |
+
offset = 0
|
| 75 |
+
if note_name.endswith("#"):
|
| 76 |
+
offset = note_name.count("#")
|
| 77 |
+
elif note_name.endswith("b"):
|
| 78 |
+
offset = -1 * note_name.count("b")
|
| 79 |
+
return np.array([scale_degree, offset], dtype=int)
|
| 80 |
+
|
| 81 |
+
def scale_degrees_to_tuple(scale_degrees):
|
| 82 |
+
return np.stack([scale_degree_to_tuple(sd) for sd in scale_degrees], axis=0)
|
| 83 |
+
|
| 84 |
+
|
| 85 |
+
def get_chord_spelling_table():
|
| 86 |
+
global CHORD_SPELLING_TABLE
|
| 87 |
+
if CHORD_SPELLING_TABLE is not None:
|
| 88 |
+
return CHORD_SPELLING_TABLE
|
| 89 |
+
chord_spelling_table = {}
|
| 90 |
+
for quality in QUALITIES:
|
| 91 |
+
quality_spelling = []
|
| 92 |
+
chroma = QUALITIES[quality]
|
| 93 |
+
for i in range(12):
|
| 94 |
+
if chroma[i] > 0:
|
| 95 |
+
if '#9' in quality and i == DEFAULT_SPELLING.index('b3'):
|
| 96 |
+
quality_spelling.append('#2')
|
| 97 |
+
elif 'dim7' in quality and i == DEFAULT_SPELLING.index('6'):
|
| 98 |
+
quality_spelling.append('bb7')
|
| 99 |
+
else:
|
| 100 |
+
quality_spelling.append(DEFAULT_SPELLING[i])
|
| 101 |
+
chord_spelling_table[quality] = scale_degrees_to_tuple(quality_spelling)
|
| 102 |
+
CHORD_SPELLING_TABLE = chord_spelling_table
|
| 103 |
+
return chord_spelling_table
|
| 104 |
+
|
| 105 |
+
def score_spelling_under_key(spelling, key):
|
| 106 |
+
'''
|
| 107 |
+
Score the chord spelling under a given key.
|
| 108 |
+
:param spelling: Numpy array of shape (N, 2)
|
| 109 |
+
:param key: Numpy array of shape (2,) representing the degree of 1 (DO)
|
| 110 |
+
:return: Score of the chord spelling under the key.
|
| 111 |
+
'''
|
| 112 |
+
key_pos = CIRCLE_OF_FIFTH_INV[key[0]] + key[1] * 7
|
| 113 |
+
spelling_pos = CIRCLE_OF_FIFTH_INV[spelling[:, 0] % 7] + spelling[:, 1] * 7
|
| 114 |
+
relative_pos = np.clip(np.abs((spelling_pos - (key_pos + 2))) - 3, 0, None)
|
| 115 |
+
return np.sum(relative_pos) + np.sum(relative_pos[:1]) * 1.0 # root matters more
|
| 116 |
+
|
| 117 |
+
def normalize_key_name(key_name):
|
| 118 |
+
"""Use the same canonical tonal spelling as SheetSage2 LM/NoLM."""
|
| 119 |
+
tonic, mode = key_name.split(':')
|
| 120 |
+
mode_id = MODE_NAMES.index(mode)
|
| 121 |
+
scale = (mir_eval.chord.pitch_class_to_semitone(tonic) - MODE_STARTS[mode_id]) % 12
|
| 122 |
+
return KEY_MAP[mode_id][scale]
|
| 123 |
+
|
| 124 |
+
|
| 125 |
+
def correct_chord_spelling(label, key_name):
|
| 126 |
+
if label == 'N' or label == 'X':
|
| 127 |
+
return label
|
| 128 |
+
key_tonal, key_mode = key_name.split(':')
|
| 129 |
+
key_mode = MODE_NAMES.index(key_mode)
|
| 130 |
+
scale_semitone = (mir_eval.chord.pitch_class_to_semitone(key_tonal) - MODE_STARTS[key_mode]) % 12
|
| 131 |
+
# Tokenizer keys use sharps; map enharmonic aliases to the LM key signature.
|
| 132 |
+
key_spelling = note_name_to_tuple(KEY_MAP[0][scale_semitone].split(':')[0])
|
| 133 |
+
# Ignore all inversions
|
| 134 |
+
inversion = ''
|
| 135 |
+
if '/' in label:
|
| 136 |
+
label, inversion = label.split('/')
|
| 137 |
+
# Separate quality and root
|
| 138 |
+
root, quality = label.split(':')
|
| 139 |
+
root_semitone = mir_eval.chord.pitch_class_to_semitone(root)
|
| 140 |
+
scores = []
|
| 141 |
+
for possible_root_id in range(7):
|
| 142 |
+
possible_root = NOTE_NAMES[possible_root_id]
|
| 143 |
+
root_spelling = np.array([
|
| 144 |
+
possible_root_id,
|
| 145 |
+
(root_semitone - mir_eval.chord.pitch_class_to_semitone(possible_root) + 6) % 12 - 6
|
| 146 |
+
])
|
| 147 |
+
chord_spelling = get_chord_spelling_table()[quality] + root_spelling[None, :]
|
| 148 |
+
scores.append(score_spelling_under_key(chord_spelling, key_spelling))
|
| 149 |
+
best_root_id = np.argmin(scores)
|
| 150 |
+
best_root = NOTE_NAMES[best_root_id]
|
| 151 |
+
best_root_spelling = np.array([
|
| 152 |
+
best_root_id,
|
| 153 |
+
(root_semitone - mir_eval.chord.pitch_class_to_semitone(best_root) + 6) % 12 - 6
|
| 154 |
+
])
|
| 155 |
+
if best_root_spelling[1] < 0:
|
| 156 |
+
best_root = best_root + 'b' * abs(best_root_spelling[1])
|
| 157 |
+
else:
|
| 158 |
+
best_root = best_root + '#' * best_root_spelling[1]
|
| 159 |
+
if inversion:
|
| 160 |
+
return f"{best_root}:{quality}/{inversion}"
|
| 161 |
+
else:
|
| 162 |
+
return f"{best_root}:{quality}"
|
| 163 |
+
|
| 164 |
+
|
| 165 |
+
def correct_chord_rows(chords, keys):
|
| 166 |
+
"""Use the local key at each chord midpoint, as in SheetSage2 LM.
|
| 167 |
+
|
| 168 |
+
Match LM's left-boundary tie rule. Extend the first/last decoded key to
|
| 169 |
+
uncovered edges. Without a decoded key, preserve the original spelling.
|
| 170 |
+
Return new rows without changing event values, timing, quality or inversion.
|
| 171 |
+
"""
|
| 172 |
+
if not keys:
|
| 173 |
+
return [list(row) for row in chords]
|
| 174 |
+
boundaries = np.array([row[1] for row in keys[:-1]])
|
| 175 |
+
return [[start, end, correct_chord_spelling(
|
| 176 |
+
label, keys[np.searchsorted(boundaries, (start + end) / 2.0)][2])]
|
| 177 |
+
for start, end, label in chords]
|
exports_sheetsage2.py
CHANGED
|
@@ -1,4 +1,4 @@
|
|
| 1 |
-
"""
|
| 2 |
from pathlib import Path
|
| 3 |
import json
|
| 4 |
from fractions import Fraction
|
|
@@ -9,6 +9,7 @@ import pretty_midi
|
|
| 9 |
from .notation_sheetsage2 import generate_abc_from_data
|
| 10 |
from .io_sheetsage2 import atomic_write_text
|
| 11 |
from .generation_sheetsage2 import field_text
|
|
|
|
| 12 |
from .midi_sheetsage2 import build_playback, midi_bytes
|
| 13 |
|
| 14 |
|
|
@@ -76,7 +77,7 @@ def export_result(decoded, tokenizer, output_dir=None, duration=None, paper=Fals
|
|
| 76 |
Statistics retain their file-interface names. ``payload`` contains the
|
| 77 |
in-memory ABC text, MIDI bytes, decoded event list, LAB texts and playback.
|
| 78 |
melody_only removes chords from notation and playback without changing
|
| 79 |
-
decoded events or
|
| 80 |
"""
|
| 81 |
if not isinstance(melody_only, bool):
|
| 82 |
raise ValueError("melody_only must be True or False")
|
|
@@ -118,6 +119,10 @@ def export_result(decoded, tokenizer, output_dir=None, duration=None, paper=Fals
|
|
| 118 |
for i, row in enumerate(rows):
|
| 119 |
row[1] = max(row[0], rows[i + 1][0]) if i + 1 < len(rows) else float(duration)
|
| 120 |
intervals[field] = rows
|
|
|
|
|
|
|
|
|
|
|
|
|
| 121 |
keep_rows(f"{field}.lab", rows)
|
| 122 |
if paper:
|
| 123 |
rows = []
|
|
@@ -154,6 +159,10 @@ def export_result(decoded, tokenizer, output_dir=None, duration=None, paper=Fals
|
|
| 154 |
notation_intervals = {}
|
| 155 |
for field, suffix in (("chord", "chords"), ("key", "keys"), ("structure", "structures")):
|
| 156 |
rows = interval_rows(events, field, duration)
|
|
|
|
|
|
|
|
|
|
|
|
|
| 157 |
# Clip to the actual beat domain: events beyond it have no ABC bin.
|
| 158 |
rows = [[max(abc_beats[0][0], a), min(abc_beats[-1][0], b), v]
|
| 159 |
for a, b, v in rows if b > abc_beats[0][0] and a < abc_beats[-1][0]]
|
|
|
|
| 1 |
+
"""Raw decoded events, key-aware chord LAB, MIDI and validated ABC."""
|
| 2 |
from pathlib import Path
|
| 3 |
import json
|
| 4 |
from fractions import Fraction
|
|
|
|
| 9 |
from .notation_sheetsage2 import generate_abc_from_data
|
| 10 |
from .io_sheetsage2 import atomic_write_text
|
| 11 |
from .generation_sheetsage2 import field_text
|
| 12 |
+
from .chord_spelling_sheetsage2 import correct_chord_rows, normalize_key_name
|
| 13 |
from .midi_sheetsage2 import build_playback, midi_bytes
|
| 14 |
|
| 15 |
|
|
|
|
| 77 |
Statistics retain their file-interface names. ``payload`` contains the
|
| 78 |
in-memory ABC text, MIDI bytes, decoded event list, LAB texts and playback.
|
| 79 |
melody_only removes chords from notation and playback without changing
|
| 80 |
+
decoded events or the key-corrected LAB annotations.
|
| 81 |
"""
|
| 82 |
if not isinstance(melody_only, bool):
|
| 83 |
raise ValueError("melody_only must be True or False")
|
|
|
|
| 119 |
for i, row in enumerate(rows):
|
| 120 |
row[1] = max(row[0], rows[i + 1][0]) if i + 1 < len(rows) else float(duration)
|
| 121 |
intervals[field] = rows
|
| 122 |
+
intervals["key"] = [[start, end, normalize_key_name(key)]
|
| 123 |
+
for start, end, key in intervals["key"]]
|
| 124 |
+
intervals["chord"] = correct_chord_rows(intervals["chord"], intervals["key"])
|
| 125 |
+
for field, rows in intervals.items():
|
| 126 |
keep_rows(f"{field}.lab", rows)
|
| 127 |
if paper:
|
| 128 |
rows = []
|
|
|
|
| 159 |
notation_intervals = {}
|
| 160 |
for field, suffix in (("chord", "chords"), ("key", "keys"), ("structure", "structures")):
|
| 161 |
rows = interval_rows(events, field, duration)
|
| 162 |
+
if field == "chord":
|
| 163 |
+
rows = correct_chord_rows(rows, interval_rows(events, "key", duration))
|
| 164 |
+
elif field == "key":
|
| 165 |
+
rows = [[start, end, normalize_key_name(key)] for start, end, key in rows]
|
| 166 |
# Clip to the actual beat domain: events beyond it have no ABC bin.
|
| 167 |
rows = [[max(abc_beats[0][0], a), min(abc_beats[-1][0], b), v]
|
| 168 |
for a, b, v in rows if b > abc_beats[0][0] and a < abc_beats[-1][0]]
|
tests/test_spelling.py
ADDED
|
@@ -0,0 +1,128 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import copy
|
| 2 |
+
import json
|
| 3 |
+
from pathlib import Path
|
| 4 |
+
from tempfile import TemporaryDirectory
|
| 5 |
+
import unittest
|
| 6 |
+
|
| 7 |
+
import mir_eval.chord
|
| 8 |
+
import numpy as np
|
| 9 |
+
|
| 10 |
+
from ..chord_spelling_sheetsage2 import correct_chord_rows, correct_chord_spelling, normalize_key_name
|
| 11 |
+
from ..tokenization_sheetsage2 import SheetSage2Tokenizer as Tokenizer, FULL_CHORD_VOCABULARY
|
| 12 |
+
from ..exports_sheetsage2 import export_result
|
| 13 |
+
from ..notation_sheetsage2 import get_key_accidentals, note_to_abc
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
def example():
|
| 17 |
+
events = []
|
| 18 |
+
for i in range(16):
|
| 19 |
+
values = {'rhythm': {'meter': [4, 4], 'eighth_position': i % 4 * 2},
|
| 20 |
+
'melody': [{'pitch': [63, 68, 70, 67][i % 4], 'track': 1,
|
| 21 |
+
'end_time': (i + 1) * .5, 'duration_bin': 2, 'duration_steps': 4}]}
|
| 22 |
+
if i == 0:
|
| 23 |
+
values.update(key='D#:major', chord='D#:maj/3', structure='verse')
|
| 24 |
+
if i == 4:
|
| 25 |
+
values['chord'] = 'G#:maj'
|
| 26 |
+
if i == 8:
|
| 27 |
+
values.update(key='G#:minor', chord='D#:min')
|
| 28 |
+
events.append({'time': i * .5, 'global_subbeat': i * 4, 'values': values})
|
| 29 |
+
return {'events': events}
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
class SpellingTests(unittest.TestCase):
|
| 33 |
+
def test_cm_chords_and_inversions(self):
|
| 34 |
+
for raw, expected in [('D#:maj', 'Eb:maj'), ('G#:maj', 'Ab:maj'),
|
| 35 |
+
('A#:7/3', 'Bb:7/3'), ('D#:min7/b7', 'Eb:min7/b7'),
|
| 36 |
+
('C:min', 'C:min'), ('N', 'N'), ('X', 'X')]:
|
| 37 |
+
self.assertEqual(correct_chord_spelling(raw, 'C:minor'), expected)
|
| 38 |
+
|
| 39 |
+
def test_local_midpoint_including_boundary_and_missing_keys(self):
|
| 40 |
+
rows = [[0, 2, 'G#:maj'], [2, 6, 'G#:maj'], [6, 8, 'G#:maj']]
|
| 41 |
+
keys = [[0, 4, 'C:minor'], [4, 8, 'E:major']]
|
| 42 |
+
original = copy.deepcopy((rows, keys))
|
| 43 |
+
self.assertEqual([r[2] for r in correct_chord_rows(rows, keys)], ['Ab:maj', 'Ab:maj', 'G#:maj'])
|
| 44 |
+
self.assertEqual((rows, keys), original)
|
| 45 |
+
self.assertEqual(correct_chord_rows(rows, []), rows)
|
| 46 |
+
self.assertEqual(correct_chord_rows([], keys), [])
|
| 47 |
+
|
| 48 |
+
def test_key_labels_keep_ids_and_checkpoint_fingerprint(self):
|
| 49 |
+
tokenizer = Tokenizer(300, 100)
|
| 50 |
+
self.assertEqual(tokenizer.vocab_fingerprint, '5ba3325af0344c7f')
|
| 51 |
+
major = ['C', 'Db', 'D', 'Eb', 'E', 'F', 'F#', 'G', 'Ab', 'A', 'Bb', 'B']
|
| 52 |
+
minor = ['C', 'C#', 'D', 'D#', 'E', 'F', 'F#', 'G', 'G#', 'A', 'Bb', 'B']
|
| 53 |
+
for offset, roots, mode in [(0, major, 'major'), (12, minor, 'minor')]:
|
| 54 |
+
for i, root in enumerate(roots):
|
| 55 |
+
label = f'{root}:{mode}'
|
| 56 |
+
token = tokenizer.key_token_start + offset + i
|
| 57 |
+
self.assertEqual(tokenizer._decode_field('key', [token], ()), label)
|
| 58 |
+
self.assertEqual(tokenizer.describe(token), f'<key_{label}>')
|
| 59 |
+
self.assertEqual(tokenizer.key_to_token(label), token)
|
| 60 |
+
self.assertEqual(normalize_key_name(label), label)
|
| 61 |
+
self.assertEqual(tokenizer.key_to_token('D#:major'), tokenizer.key_to_token('Eb:major'))
|
| 62 |
+
|
| 63 |
+
def test_entire_chord_vocabulary_preserves_sounding_pitches(self):
|
| 64 |
+
for key in ('C:minor', 'E:major', 'A#:minor', 'C#:major', 'F#:major'):
|
| 65 |
+
for chord in FULL_CHORD_VOCABULARY:
|
| 66 |
+
spelled = correct_chord_spelling(chord, key)
|
| 67 |
+
before = mir_eval.chord.encode(chord)
|
| 68 |
+
after = mir_eval.chord.encode(spelled)
|
| 69 |
+
self.assertEqual(before[0], after[0])
|
| 70 |
+
np.testing.assert_array_equal(before[1], after[1])
|
| 71 |
+
self.assertEqual(before[2], after[2])
|
| 72 |
+
if chord != 'N':
|
| 73 |
+
self.assertEqual(chord.split(':', 1)[1], spelled.split(':', 1)[1])
|
| 74 |
+
|
| 75 |
+
def test_nolm_key_relative_pitch_examples(self):
|
| 76 |
+
state = {}
|
| 77 |
+
self.assertEqual([note_to_abc(n, get_key_accidentals('Cm'), state) for n in (63, 68, 70, 75)],
|
| 78 |
+
['E', 'A', 'B', 'e'])
|
| 79 |
+
self.assertEqual(note_to_abc(67, get_key_accidentals('G#m'), {}), '^^F')
|
| 80 |
+
self.assertEqual(note_to_abc(59, get_key_accidentals('Db'), {}), '_C')
|
| 81 |
+
self.assertEqual(note_to_abc(60, get_key_accidentals('C#m'), {}), '^B,')
|
| 82 |
+
state = {}
|
| 83 |
+
self.assertEqual([note_to_abc(n, get_key_accidentals('Cm'), state) for n in (64, 76, 63)],
|
| 84 |
+
['=E', 'e', '_E'])
|
| 85 |
+
self.assertEqual(note_to_abc(63, get_key_accidentals('Cm'), {}), 'E')
|
| 86 |
+
|
| 87 |
+
def test_export_uses_corrected_labels_without_mutating_events(self):
|
| 88 |
+
decoded = example()
|
| 89 |
+
original = copy.deepcopy(decoded)
|
| 90 |
+
with TemporaryDirectory() as td:
|
| 91 |
+
root = Path(td)
|
| 92 |
+
result = export_result(decoded, Tokenizer(300, 100), root, 8)
|
| 93 |
+
self.assertIsNone(result['abc_error'])
|
| 94 |
+
self.assertEqual(decoded, original)
|
| 95 |
+
self.assertEqual(json.loads((root/'events.json').read_text()), original)
|
| 96 |
+
self.assertIn('Eb:major', (root/'key.lab').read_text())
|
| 97 |
+
self.assertNotIn('D#:major', (root/'notation/song_keys.txt').read_text())
|
| 98 |
+
for path in ['chord.lab', 'notation/song_chords.txt']:
|
| 99 |
+
text = (root/path).read_text()
|
| 100 |
+
self.assertIn('Eb:maj/3', text)
|
| 101 |
+
self.assertIn('Ab:maj', text)
|
| 102 |
+
self.assertIn('D#:min', text)
|
| 103 |
+
abc = (root/'score.abc').read_text()
|
| 104 |
+
self.assertIn('K:Eb', abc)
|
| 105 |
+
self.assertIn('"Eb/G"', abc)
|
| 106 |
+
self.assertIn('"Ab"', abc)
|
| 107 |
+
self.assertIn('K:G#m', abc)
|
| 108 |
+
|
| 109 |
+
def test_in_memory_paper_and_melody_only_exports(self):
|
| 110 |
+
for paper in (False, True):
|
| 111 |
+
for melody_only in (False, True):
|
| 112 |
+
decoded = example()
|
| 113 |
+
original = copy.deepcopy(decoded)
|
| 114 |
+
result = export_result(decoded, Tokenizer(300, 100), duration=8,
|
| 115 |
+
paper=paper, melody_only=melody_only)
|
| 116 |
+
self.assertIsNone(result['abc_error'])
|
| 117 |
+
payload = result['payload']
|
| 118 |
+
self.assertEqual(decoded, original)
|
| 119 |
+
self.assertEqual(payload['events'], original['events'])
|
| 120 |
+
self.assertIn('Eb:major', payload['labs']['key'])
|
| 121 |
+
self.assertIn('Eb:maj/3', payload['labs']['chord'])
|
| 122 |
+
self.assertIn('K:Eb', payload['abc'])
|
| 123 |
+
self.assertIn('K:G#m', payload['abc'])
|
| 124 |
+
self.assertEqual('"Eb/G"' in payload['abc'], not melody_only)
|
| 125 |
+
|
| 126 |
+
|
| 127 |
+
if __name__ == '__main__':
|
| 128 |
+
unittest.main()
|
tokenization_sheetsage2.py
CHANGED
|
@@ -5,6 +5,8 @@ import json
|
|
| 5 |
import mir_eval.chord
|
| 6 |
import numpy as np
|
| 7 |
|
|
|
|
|
|
|
| 8 |
from .durations_sheetsage2 import DURATION_TEMPLATES, duration_boundaries
|
| 9 |
from .labels_sheetsage2 import STRUCTURE_LABELS
|
| 10 |
from .schema_sheetsage2 import get_prompt_multitask_schema
|
|
@@ -495,7 +497,7 @@ class SheetSage2Tokenizer:
|
|
| 495 |
if field == "key":
|
| 496 |
key_id = field_tokens[0] - self.key_token_start
|
| 497 |
mode = "minor" if key_id >= 12 else "major"
|
| 498 |
-
return f"{CHROMATIC_SHARPS[key_id % 12]}:{mode}"
|
| 499 |
if field == "chord":
|
| 500 |
if token_types[0] == "chord_majmin":
|
| 501 |
return self.majmin_chord_labels[
|
|
@@ -714,7 +716,8 @@ class SheetSage2Tokenizer:
|
|
| 714 |
if token_type == "key":
|
| 715 |
key_id = token - self.key_token_start
|
| 716 |
mode = "minor" if key_id >= 12 else "major"
|
| 717 |
-
|
|
|
|
| 718 |
if token_type == "chord_majmin":
|
| 719 |
return f"<chord_majmin_{self.majmin_chord_labels[token - self.majmin_chord_token_start]}>"
|
| 720 |
if token_type == "chord_full":
|
|
|
|
| 5 |
import mir_eval.chord
|
| 6 |
import numpy as np
|
| 7 |
|
| 8 |
+
from .chord_spelling_sheetsage2 import normalize_key_name
|
| 9 |
+
|
| 10 |
from .durations_sheetsage2 import DURATION_TEMPLATES, duration_boundaries
|
| 11 |
from .labels_sheetsage2 import STRUCTURE_LABELS
|
| 12 |
from .schema_sheetsage2 import get_prompt_multitask_schema
|
|
|
|
| 497 |
if field == "key":
|
| 498 |
key_id = field_tokens[0] - self.key_token_start
|
| 499 |
mode = "minor" if key_id >= 12 else "major"
|
| 500 |
+
return normalize_key_name(f"{CHROMATIC_SHARPS[key_id % 12]}:{mode}")
|
| 501 |
if field == "chord":
|
| 502 |
if token_types[0] == "chord_majmin":
|
| 503 |
return self.majmin_chord_labels[
|
|
|
|
| 716 |
if token_type == "key":
|
| 717 |
key_id = token - self.key_token_start
|
| 718 |
mode = "minor" if key_id >= 12 else "major"
|
| 719 |
+
label = normalize_key_name(f"{CHROMATIC_SHARPS[key_id % 12]}:{mode}")
|
| 720 |
+
return f"<key_{label}>"
|
| 721 |
if token_type == "chord_majmin":
|
| 722 |
return f"<chord_majmin_{self.majmin_chord_labels[token - self.majmin_chord_token_start]}>"
|
| 723 |
if token_type == "chord_full":
|