diff --git a/src/guitarpro/gp5.py b/src/guitarpro/gp5.py index 16e2d09..63e7e85 100644 --- a/src/guitarpro/gp5.py +++ b/src/guitarpro/gp5.py @@ -7,6 +7,32 @@ class GP5File(gp4.GP4File): """A reader for GuitarPro 5 files.""" + #: Tracks whose lowest string is below this MIDI pitch (B1) are + #: rendered with bass clef, matching the threshold used by Guitar Pro + #: and alphaTab. + _BASS_CLEF_TUNING_THRESHOLD = 35 + + #: Value of ``TrackRSE.clefMode`` that forces bass clef regardless + #: of the track's tuning. + _CLEF_MODE_BASS = 12 + + def _computeMeasureClef(self, track): + """Infer the clef that Guitar Pro displays for ``track``. + + Matches the logic in alphaTab's ``Gp3To5Importer.readTrack``: + percussion tracks use the neutral clef; otherwise bass clef if + the RSE clef mode is ``12`` or the lowest string falls below + MIDI B1, else treble clef. + """ + if track.isPercussionTrack: + return gp.MeasureClef.neutral + clefMode = track.rse.clefMode if track.rse is not None else 0 + if clefMode == self._CLEF_MODE_BASS: + return gp.MeasureClef.bass + if track.strings and track.strings[-1].value < self._BASS_CLEF_TUNING_THRESHOLD: + return gp.MeasureClef.bass + return gp.MeasureClef.treble + # Reading # ======= @@ -471,7 +497,12 @@ def readTrackRSE(self, trackRSE): - Humanize: :ref:`byte`. - - Unknown space: 6 :ref:`Ints `. + - Clef mode: :ref:`int`. A value of ``12`` means the track is + always displayed with bass clef; other values leave the clef + inferred from the tuning. + + - Unknown space: 2 :ref:`Ints ` (typically ``-1`` and + ``100``) followed by 12 unknown :ref:`Bytes `. - RSE instrument. See :meth:`readRSEInstrument`. @@ -480,8 +511,9 @@ def readTrackRSE(self, trackRSE): - RSE instrument effect. See :meth:`readRSEInstrumentEffect`. """ trackRSE.humanize = self.readU8() - for _ in range(3): - self.readI32() # ??? + trackRSE.clefMode = self.readI32() + self.readI32() # ??? + self.readI32() # ??? (typically 100) self.skip(12) # ??? trackRSE.instrument = self.readRSEInstrument() if self.versionTuple > (5, 0, 0): @@ -538,6 +570,7 @@ def readMeasure(self, measure): Sub-measures are followed by a :class:`~guitarpro.models.LineBreak` stored in :ref:`byte`. """ + measure.clef = self._computeMeasureClef(measure.track) start = measure.start for number, voice in enumerate(measure.voices[:gp.Measure.maxVoices]): self._currentVoiceNumber = number + 1 @@ -1152,7 +1185,7 @@ def writeTrack(self, track, number): def writeTrackRSE(self, trackRSE): self.writeU8(trackRSE.humanize) - self.writeI32(0) + self.writeI32(trackRSE.clefMode) self.writeI32(0) self.writeI32(100) self.placeholder(12) diff --git a/src/guitarpro/models.py b/src/guitarpro/models.py index 502d337..bef7c89 100644 --- a/src/guitarpro/models.py +++ b/src/guitarpro/models.py @@ -616,6 +616,9 @@ class TrackRSE: equalizer: RSEEqualizer = attr.Factory(RSEEqualizer) humanize: int = 0 autoAccentuation: Accentuation = Accentuation.none + #: Clef mode selected in the track editor. ``12`` forces bass clef for + #: any tuning; ``0`` lets the clef be inferred from the lowest string. + clefMode: int = 0 def __attrs_post_init__(self): if not self.equalizer.knobs: @@ -672,6 +675,7 @@ class MeasureClef(Enum): bass = 1 tenor = 2 alto = 3 + neutral = 4 class LineBreak(Enum): diff --git a/tests/test_conversion.py b/tests/test_conversion.py index 19b4649..a6958de 100644 --- a/tests/test_conversion.py +++ b/tests/test_conversion.py @@ -126,6 +126,66 @@ def testChord(tmpdir, caplog, filename): assert song == song2 +def testGp5TrackClefRoundtripBass(tmpdir): + """Regression test for GP5 per-track clef extraction. + + `GP5File.readTrackRSE` historically read three consecutive int32s + as "unknown" values. The first of those is the track's clef mode + (``12`` forces bass clef), documented in alphaTab's + ``Gp3To5Importer.readTrack``. Without extracting it, every GP5 + track was reported as treble clef regardless of tuning. + """ + filepath = LOCATION / 'Effects.gp5' + song = gp.parse(filepath) + track = song.tracks[0] + track.rse.clefMode = 12 # force bass clef + + destpath = str(tmpdir.join('bass_clef.gp5')) + gp.write(song, destpath) + song2 = gp.parse(destpath) + + assert song2.tracks[0].rse.clefMode == 12 + assert song2.tracks[0].measures[0].clef is gp.MeasureClef.bass + + +def testGp5PercussionTrackUsesNeutralClef(tmpdir): + """Percussion tracks should use the neutral clef per alphaTab parity.""" + song = gp.Song() + song.tracks[0].isPercussionTrack = True + + destpath = str(tmpdir.join('percussion.gp5')) + gp.write(song, destpath, version=(5, 1, 0)) + song2 = gp.parse(destpath) + + assert song2.tracks[0].isPercussionTrack is True + assert song2.tracks[0].measures[0].clef is gp.MeasureClef.neutral + + +def testGp5LowTuningInfersBassClef(tmpdir): + """A track whose lowest string is below MIDI B1 (35) renders with + bass clef even when clefMode is 0 — matches AT's inference rule.""" + filepath = LOCATION / 'Effects.gp5' + song = gp.parse(filepath) + track = song.tracks[0] + # Detune all strings down by an octave so the lowest falls below B1 + for string in track.strings: + string.value -= 24 + assert track.strings[-1].value < 35 + + destpath = str(tmpdir.join('low_tuning.gp5')) + gp.write(song, destpath) + song2 = gp.parse(destpath) + + assert song2.tracks[0].measures[0].clef is gp.MeasureClef.bass + + +def testGp5GuitarTrackKeepsTrebleClef(): + """Sanity check: a normal guitar track still reports treble clef.""" + filepath = LOCATION / 'Effects.gp5' + song = gp.parse(filepath) + assert song.tracks[0].measures[0].clef is gp.MeasureClef.treble + + @pytest.mark.parametrize('version', ['gp3', 'gp4', 'gp5']) def testReadErrorAnnotation(version): def writeToBytesIO(song):