Skip to content

(AI) Preserve microtones through Interval.transpose (fix accidental corruption / AccidentalException)#1962

Open
gaoflow wants to merge 1 commit into
cuthbertLab:masterfrom
gaoflow:preserve-microtone-transpose
Open

(AI) Preserve microtones through Interval.transpose (fix accidental corruption / AccidentalException)#1962
gaoflow wants to merge 1 commit into
cuthbertLab:masterfrom
gaoflow:preserve-microtone-transpose

Conversation

@gaoflow

@gaoflow gaoflow commented Jun 30, 2026

Copy link
Copy Markdown

The bug

Transposing a Pitch (or Note) that carries a microtone corrupts its spelling, and for larger microtones raises an exception — even when the transposition is a no-op:

from music21 import pitch, interval

p = pitch.Pitch('C4'); p.microtone = 30
p.transpose(interval.Interval('P1'))     # C~4(-20c)   -- expected C4(+30c)

p = pitch.Pitch('G#5'); p.microtone = 50
p.transpose(interval.Interval('A1'))     # AccidentalException: 2.5 is not a supported accidental type

Transposing by a perfect unison must be the identity, so C4(+30c)P1C4(+30c) is unambiguous. The corruption kicks in above 25¢ (25¢ survives, 26¢ flips to a half-sharp) and affects ordinary intervals too — Note('C4', microtone=30).transpose('P5') gives G~4(-20c) instead of G4(+30c). It's also internally inconsistent: a 25¢ microtone is preserved by P5, and a 30¢ microtone is preserved by intervals that resolve to a sharp/flat — so identical inputs behave differently depending on the interval.

Root cause

music21/interval.py, Interval._diatonicTransposePitch:

pitch2.microtone = None
interval2 = Interval(pitch1, pitch2)                       # pitch1 still carries its microtone
halfStepsToFix = self.chromatic.semitones - interval2.chromatic.semitones
...
pitch2.accidental = halfStepsToFix                         # fractional alter -> corrupt accidental

interval2 is measured from the microtonal pitch1, so the source cents (0.30 semitone) leak into halfStepsToFix. That fractional value is then assigned to the accidental, which snaps to the nearest quarter-tone (half-sharp = 0.5) and dumps the −0.20 remainder into a spurious microtone — or, when the alter lands on an unsupported value like 2.5, raises AccidentalException.

The method even contains a dead comment, # centsOrigin = pitch1.microtone.cents # unused!!, marking the intended-but-never-wired-up fix.

The fix

Resurrect centsOrigin, peel it off halfStepsToFix before the accidental is set, and restore it as a microtone afterward. The whole thing is guarded by if centsOrigin: so the common, non-microtonal (performance-critical) path is byte-identical:

centsOrigin = pitch1.microtone.cents
...
halfStepsToFix = self.chromatic.semitones - interval2.chromatic.semitones
if centsOrigin:
    halfStepsToFix = round(halfStepsToFix - centsOrigin / 100.0, 9)
...
if centsOrigin:
    pitch2.microtone = pitch2.microtone.cents + centsOrigin

Now C4(+30c)P1C4(+30c), G#5(+50c)A1G##5(+50c) (no crash), and a combined quarter-tone-plus-microtone pitch like C~4(+30c) round-trips through a unison unchanged.

Tests

testTransposeMicrotonePreserved covers the unison identity, the formerly-crashing A1 case, a combined accidental+microtone pitch, and a sweep over eight intervals × four microtone magnitudes asserting the cents are preserved, the sounding pitch (ps) moves by exactly the interval, and no quarter-tone accidental is introduced on an integer-semitone interval. It fails on master and passes with the fix. The existing interval, pitch, and note test/doctest suites stay green; ruff, pylint -j0, and mypy are clean.


Disclosure (per CONTRIBUTING.md): prepared with AI assistance under my direction. I reproduced the bug, wrote and reviewed the fix and tests, and verified it locally — full interval/pitch/note suites, ruff/pylint/mypy, and an ~1,200-case oracle (intervals × microtones × accidentals) with zero microtone/ps failures and zero crashes.

Pitch.transpose() corrupted, or crashed on, pitches carrying a microtone.
_diatonicTransposePitch measured interval2 from the microtonal source
pitch, so the source cents leaked into halfStepsToFix and were dumped onto
the accidental: e.g. C4(+30c) transposed by a perfect unison became
C~4(-20c), and G#5(+50c) by an augmented unison raised AccidentalException
('2.5 is not a supported accidental type'). The dead 'centsOrigin' comment
in the method showed this was an unfinished intent.

Peel the source microtone off halfStepsToFix before it sets the accidental,
then restore it as a microtone, guarded by 'if centsOrigin' so the common
non-microtonal (performance-critical) path is unchanged.

@mscuthbert mscuthbert left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Great PR -- a few smaller changes requested, with guidance to you and agent for future PRs.

Btw -- version 11 or a future version might deprecate the ~ and ` representations of half-flats and sharps, so if you want to make future microtonal contributions please use Accidental('half-flat') instead of ``` etc. Thanks!

Comment thread music21/interval.py
pitch2 = copy.deepcopy(pitch1)
oldDiatonicNum = pitch1.diatonicNoteNum
# centsOrigin = pitch1.microtone.cents # unused!!
centsOrigin = pitch1.microtone.cents

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this should gate on if not pitch1.isTwelveTone(): ... else: centsOrigin = 0.0

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I have a patch that should make isTwelveTone run faster -- will push in next PR. isTwelveTone guards against creating Microtone objects unnecessarily.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

See #1963

Comment thread music21/interval.py
# We have the right note name but not the right accidental
interval2 = Interval(pitch1, pitch2)
# halfStepsToFix already has any microtones
# interval2 is measured from the microtonal pitch1, so halfStepsToFix

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

pitch1 isn't necessarily microtonal. This comments should be trimmed to essentials and moved into the if centsOrigin: clause -- in general agents write too much about their particular task and leave too much baggage for people interested in other tasks. Think about 10-15 words

Comment thread music21/interval.py
and oldPitch2Accidental.name == 'natural'):
pitch2.accidental = oldPitch2Accidental

# restore the source microtone that was peeled off halfStepsToFix above

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this does not restore the source microtone, it restores the microtone's .cents. And again comment inside the if.

Comment thread music21/interval.py

# restore the source microtone that was peeled off halfStepsToFix above
if centsOrigin:
pitch2.microtone = pitch2.microtone.cents + centsOrigin

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

pitch2.microtone.cents = ...

i = interval.Interval(note.Note('c4'), note.Note('c~4'))
self.assertEqual(str(i), '<music21.interval.Interval A1 (-50c)>')

def testTransposeMicrotonePreserved(self):

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

good test

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants