Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 37 additions & 4 deletions src/guitarpro/gp5.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
# =======

Expand Down Expand Up @@ -471,7 +497,12 @@ def readTrackRSE(self, trackRSE):

- Humanize: :ref:`byte`.

- Unknown space: 6 :ref:`Ints <int>`.
- 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 <int>` (typically ``-1`` and
``100``) followed by 12 unknown :ref:`Bytes <byte>`.

- RSE instrument. See :meth:`readRSEInstrument`.

Expand All @@ -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):
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down
4 changes: 4 additions & 0 deletions src/guitarpro/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -672,6 +675,7 @@ class MeasureClef(Enum):
bass = 1
tenor = 2
alto = 3
neutral = 4


class LineBreak(Enum):
Expand Down
60 changes: 60 additions & 0 deletions tests/test_conversion.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down