instr3 commited on
Commit
e8b16e3
·
verified ·
1 Parent(s): 24154de

Correct chord spelling with local keys and canonicalize key labels

Browse files

Apply 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 CHANGED
@@ -46,6 +46,12 @@ Built on [MERT-v2-FullSong](https://huggingface.co/m-a-p/MERT-v2-FullSong), with
46
 
47
  ![SheetSage2 architecture](assets/architecture.png)
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
  ![SheetSage2 architecture](assets/architecture.png)
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
- """Lossless decoded events/LAB/MIDI, then independently validated ABC."""
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 raw LAB annotations.
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
- return f"<key_{CHROMATIC_SHARPS[key_id % 12]}:{mode}>"
 
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":