From 7038de07eb11640e3a9f552d26a1e0bf09cc8a36 Mon Sep 17 00:00:00 2001 From: Kenji Noguchi Date: Thu, 25 Jun 2026 19:53:23 -0700 Subject: [PATCH 1/7] Add reading support for GP6 (.gpx) and GP7 (.gp) files GP6 and GP7 store the score as a score.gpif XML document inside a container: GP6 uses a BCFZ-compressed BCFS virtual filesystem, while GP7 is a plain ZIP archive. This adds a reader for both. - gpx.py: BCFZ bitstream decompression (based on the algorithm from the feature/gpx branch contributed by J. Jorgen von Bargen), the BCFS sector archive reader, and GP7 ZIP extraction. - gpif.py: maps the score.gpif XML into the existing Song model, resolving the format's cross-referenced bars/voices/beats/notes/rhythms lists. Covers song info, tracks, tunings, master bars and time signatures, voices, beats, durations and notes. - io.py: parse() now detects the BCFZ/BCFS/ZIP magic and dispatches to the new reader; the binary GP3/4/5 path is unchanged. Writing GP6/GP7 is not supported. Advanced note/beat effects are not yet translated. Co-Authored-By: Claude Opus 4.8 (1M context) --- CHANGES.rst | 10 ++ README.rst | 3 +- docs/index.rst | 3 +- docs/pyguitarpro/quickstart.rst | 5 +- src/guitarpro/gpif.py | 256 ++++++++++++++++++++++++++++++++ src/guitarpro/gpx.py | 154 +++++++++++++++++++ src/guitarpro/io.py | 9 ++ tests/A Simple Song.gpx | Bin 0 -> 57879 bytes tests/test_gpx.py | 66 ++++++++ 9 files changed, 502 insertions(+), 4 deletions(-) create mode 100644 src/guitarpro/gpif.py create mode 100644 src/guitarpro/gpx.py create mode 100644 tests/A Simple Song.gpx create mode 100644 tests/test_gpx.py diff --git a/CHANGES.rst b/CHANGES.rst index f2af153..031093d 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -1,6 +1,16 @@ Changelog ========= +Unreleased +---------- + +**Changes:** + +- Added reading of GP6 (``.gpx``) and GP7 (``.gp``) files. ``guitarpro.parse`` now + detects the container format and maps the embedded ``score.gpif`` document into + the existing ``Song`` model. Writing these formats is not supported. + + Version 0.11 ------------- diff --git a/README.rst b/README.rst index 3e3b290..9fadff0 100644 --- a/README.rst +++ b/README.rst @@ -9,7 +9,8 @@ PyGuitarPro Introduction ------------ -PyGuitarPro is a package to read, write and manipulate GP3, GP4 and GP5 files. Initially PyGuitarPro is a Python port +PyGuitarPro is a package to read, write and manipulate GP3, GP4 and GP5 files. GP6 (``.gpx``) and GP7 (``.gp``) +files can also be read. Initially PyGuitarPro is a Python port of `AlphaTab `_ which originally was a Haxe port of `TuxGuitar `_. diff --git a/docs/index.rst b/docs/index.rst index df10109..bc1349c 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -3,7 +3,8 @@ PyGuitarPro =========== -PyGuitarPro is a package to read, write and manipulate GP3, GP4 and GP5 files. Initially PyGuitarPro is a Python port +PyGuitarPro is a package to read, write and manipulate GP3, GP4 and GP5 files. GP6 (``.gpx``) and GP7 (``.gp``) files +can also be read. Initially PyGuitarPro is a Python port of `AlphaTab `_ which is a Haxe port of `TuxGuitar `_. To anyone wanting to create their own the best guitar tablature editor in Python this package will be the good thing to diff --git a/docs/pyguitarpro/quickstart.rst b/docs/pyguitarpro/quickstart.rst index 171e93d..86a0096 100644 --- a/docs/pyguitarpro/quickstart.rst +++ b/docs/pyguitarpro/quickstart.rst @@ -36,7 +36,8 @@ Functions :func:`guitarpro.parse` and :func:`guitarpro.write` support not only f .. note:: - PyGuitarPro supports only GP3, GP4 and GP5 files. Support for GPX (Guitar Pro 6) files is out of scope of the - project. + PyGuitarPro reads and writes GP3, GP4 and GP5 files. GP6 (``.gpx``) and GP7 (``.gp``) files can be read but not + written; their parsing covers the core musical content (song info, tracks, tunings, measures, voices, beats, + durations and notes) but not yet every advanced effect. .. vim: tw=120 cc=121 diff --git a/src/guitarpro/gpif.py b/src/guitarpro/gpif.py new file mode 100644 index 0000000..a10a1c5 --- /dev/null +++ b/src/guitarpro/gpif.py @@ -0,0 +1,256 @@ +"""Maps a Guitar Pro ``score.gpif`` XML document into the :class:`Song` model. + +The GPIF format (used by GP6 and GP7) stores the score as a set of +cross-referenced lists -- ``MasterBars`` point at ``Bars`` by id, ``Bars`` +point at ``Voices``, ``Voices`` point at ``Beats``, ``Beats`` point at +``Notes`` and ``Rhythms``. This parser resolves those references and builds +the same object tree the binary readers produce. + +The mapping covers the core musical content: song info, tracks and tunings, +master bars and time signatures, voices, beats, durations and notes. +Advanced effects are not yet translated. +""" +import xml.etree.ElementTree as ET + +from . import models as gp + +__all__ = ('GPIFParser',) + +# GPIF NoteValue -> Duration.value +_NOTE_VALUES = { + 'Whole': gp.Duration.whole, + 'Half': gp.Duration.half, + 'Quarter': gp.Duration.quarter, + 'Eighth': gp.Duration.eighth, + '16th': gp.Duration.sixteenth, + '32nd': gp.Duration.thirtySecond, + '64th': gp.Duration.sixtyFourth, + '128th': gp.Duration.hundredTwentyEighth, +} + +# GPIF Dynamic -> velocity +_DYNAMICS = { + 'PPP': gp.Velocities.pianoPianissimo, + 'PP': gp.Velocities.pianissimo, + 'P': gp.Velocities.piano, + 'MP': gp.Velocities.mezzoPiano, + 'MF': gp.Velocities.mezzoForte, + 'F': gp.Velocities.forte, + 'FF': gp.Velocities.fortissimo, + 'FFF': gp.Velocities.forteFortissimo, +} + + +class GPIFParser: + def __init__(self, data, versionTuple=None): + if isinstance(data, (bytes, bytearray)): + self.root = ET.fromstring(data) + else: + self.root = ET.fromstring(data.encode() if isinstance(data, str) else data) + self.versionTuple = versionTuple + + # -- helpers -------------------------------------------------------- + + def _text(self, path, default=''): + element = self.root.find(path) + if element is not None and element.text is not None: + return element.text.strip() + return default + + @staticmethod + def _index(elements): + """Index a list of elements by their ``id`` attribute.""" + return {e.get('id'): e for e in elements} + + @staticmethod + def _property(element, name): + """Return the ```` child of *element*, or None.""" + if element is None: + return None + for prop in element.findall('./Properties/Property'): + if prop.get('name') == name: + return prop + return None + + # -- entry point ---------------------------------------------------- + + def readSong(self): + root = self.root + self.bars = self._index(root.findall('./Bars/Bar')) + self.voices = self._index(root.findall('./Voices/Voice')) + self.beats = self._index(root.findall('./Beats/Beat')) + self.notes = self._index(root.findall('./Notes/Note')) + self.rhythms = self._index(root.findall('./Rhythms/Rhythm')) + + song = gp.Song(versionTuple=self.versionTuple) + self._readScoreInfo(song) + self._readTempo(song) + + masterBars = root.findall('./MasterBars/MasterBar') + self._readMeasureHeaders(song, masterBars) + self._readTracks(song) + self._readMeasures(song, masterBars) + return song + + # -- score info ----------------------------------------------------- + + def _readScoreInfo(self, song): + song.title = self._text('./Score/Title') + song.subtitle = self._text('./Score/SubTitle') + song.artist = self._text('./Score/Artist') + song.album = self._text('./Score/Album') + song.words = self._text('./Score/Words') + song.music = self._text('./Score/Music') + song.copyright = self._text('./Score/Copyright') + song.tab = self._text('./Score/Tabber') + song.instructions = self._text('./Score/Instructions') + notices = self._text('./Score/Notices') + song.notice = notices.splitlines() if notices else [] + + def _readTempo(self, song): + for automation in self.root.findall('./MasterTrack/Automations/Automation'): + if automation.findtext('Type') == 'Tempo': + value = (automation.findtext('Value') or '').split() + if value: + song.tempo = int(round(float(value[0]))) + break + + # -- measure headers ------------------------------------------------ + + def _readMeasureHeaders(self, song, masterBars): + song.measureHeaders = [] + for number, masterBar in enumerate(masterBars, start=1): + header = gp.MeasureHeader(number=number) + time = masterBar.findtext('Time') + if time and '/' in time: + numerator, denominator = time.split('/') + header.timeSignature = gp.TimeSignature( + numerator=int(numerator), + denominator=gp.Duration(value=int(denominator)), + ) + repeat = masterBar.find('Repeat') + if repeat is not None: + if repeat.get('start') == 'true': + header.isRepeatOpen = True + if repeat.get('end') == 'true': + header.repeatClose = int(repeat.get('count', 0)) + song.addMeasureHeader(header) + if not song.measureHeaders: + song.measureHeaders = [gp.MeasureHeader()] + + # -- tracks --------------------------------------------------------- + + def _readTracks(self, song): + trackElements = self.root.findall('./Tracks/Track') + song.tracks = [] + for number, element in enumerate(trackElements, start=1): + track = gp.Track(song, number=number) + track.name = (element.findtext('Name') or '').strip() or track.name + track.strings = self._readTuning(element) + track.measures = [] + song.tracks.append(track) + if not song.tracks: + song.tracks = [gp.Track(song)] + + def _readTuning(self, trackElement): + tuning = self._property(trackElement, 'Tuning') + pitches = None + if tuning is not None: + text = tuning.findtext('Pitches') + if text: + pitches = [int(p) for p in text.split()] + if not pitches: + # Default standard 6-string tuning, low to high. + pitches = [40, 45, 50, 55, 59, 64] + # GPIF lists pitches low-to-high; GuitarString #1 is the highest. + return [gp.GuitarString(number=i + 1, value=value) + for i, value in enumerate(reversed(pitches))] + + # -- measures / voices / beats / notes ------------------------------ + + def _readMeasures(self, song, masterBars): + for trackIndex, track in enumerate(song.tracks): + for header, masterBar in zip(song.measureHeaders, masterBars): + measure = gp.Measure(track, header) + measure.voices = [] + barIds = (masterBar.findtext('Bars') or '').split() + barId = barIds[trackIndex] if trackIndex < len(barIds) else None + bar = self.bars.get(barId) + self._readVoices(measure, bar) + track.measures.append(measure) + + def _readVoices(self, measure, bar): + voiceIds = [] + if bar is not None: + voiceIds = (bar.findtext('Voices') or '').split() + start = measure.start + for voiceId in voiceIds: + voice = gp.Voice(measure) + if voiceId != '-1': + start = self._readBeats(voice, self.voices.get(voiceId), start) + measure.voices.append(voice) + # The model expects at least ``maxVoices`` voices per measure. + while len(measure.voices) < gp.Measure.maxVoices: + measure.voices.append(gp.Voice(measure)) + + def _readBeats(self, voice, voiceElement, start): + if voiceElement is None: + return start + for beatId in (voiceElement.findtext('Beats') or '').split(): + beatElement = self.beats.get(beatId) + if beatElement is None: + continue + beat = gp.Beat(voice) + beat.start = start + beat.duration = self._readDuration(beatElement) + self._readBeatNotes(beat, beatElement) + beat.status = gp.BeatStatus.normal if beat.notes else gp.BeatStatus.rest + voice.beats.append(beat) + start += beat.duration.time + return start + + def _readDuration(self, beatElement): + rhythm = None + ref = beatElement.find('Rhythm') + if ref is not None: + rhythm = self.rhythms.get(ref.get('ref')) + duration = gp.Duration() + if rhythm is None: + return duration + noteValue = rhythm.findtext('NoteValue') + duration.value = _NOTE_VALUES.get(noteValue, gp.Duration.quarter) + dots = rhythm.find('AugmentationDot') + if dots is not None and int(dots.get('count', 0)) > 0: + duration.isDotted = True + tuplet = rhythm.find('PrimaryTuplet') + if tuplet is not None: + duration.tuplet = gp.Tuplet( + enters=int(tuplet.get('num', 1)), + times=int(tuplet.get('den', 1)), + ) + return duration + + def _readBeatNotes(self, beat, beatElement): + dynamic = beatElement.findtext('Dynamic') + velocity = _DYNAMICS.get(dynamic, gp.Velocities.default) + noteIds = beatElement.findtext('Notes') + if not noteIds: + return + for noteId in noteIds.split(): + noteElement = self.notes.get(noteId) + if noteElement is None: + continue + beat.notes.append(self._readNote(beat, noteElement, velocity)) + + def _readNote(self, beat, noteElement, velocity): + note = gp.Note(beat, velocity=velocity, type=gp.NoteType.normal) + fret = self._property(noteElement, 'Fret') + if fret is not None: + note.value = int(fret.findtext('Fret') or 0) + string = self._property(noteElement, 'String') + if string is not None: + # GPIF strings are 0-based from the highest; the model is 1-based. + note.string = int(string.findtext('String') or 0) + 1 + if noteElement.find('Tie') is not None: + note.type = gp.NoteType.tie + return note diff --git a/src/guitarpro/gpx.py b/src/guitarpro/gpx.py new file mode 100644 index 0000000..5ff52e0 --- /dev/null +++ b/src/guitarpro/gpx.py @@ -0,0 +1,154 @@ +"""Reader for Guitar Pro 6 (``.gpx``) and Guitar Pro 7 (``.gp``) files. + +Both formats wrap a ``score.gpif`` XML document inside a container: + +* GP6 ``.gpx`` -- a ``BCFZ``-compressed ``BCFS`` virtual filesystem. The + ``BCFZ`` layer is a custom bitstream LZ scheme; the ``BCFS`` layer is a + sector-based archive. The decompression algorithm is based on code + contributed by J. Jorgen von Bargen. +* GP7 ``.gp`` -- a plain ZIP archive with the score at + ``Content/score.gpif``. + +This module exposes :class:`GPXFile`, which mirrors the ``readSong`` +interface of the binary readers and delegates the XML-to-:class:`Song` +mapping to :mod:`guitarpro.gpif`. +""" +import io +import struct +import zipfile + +from .gpif import GPIFParser +from .models import GPException + +__all__ = ('GPXFile', 'decompress', 'extractGPIF') + +_HEADER_BCFS = b'BCFS' +_HEADER_BCFZ = b'BCFZ' +_SECTOR_SIZE = 0x1000 + + +class _BitReader: + """Reads individual bits from a byte string, most significant first.""" + + def __init__(self, data): + self.data = data + self.byte = 0 + self.bit = 0 + + def readBit(self): + result = (self.data[self.byte] >> (7 - self.bit)) & 1 + self.bit += 1 + if self.bit == 8: + self.bit = 0 + self.byte += 1 + return result + + def readBits(self, count): + """Read *count* bits, most significant first.""" + result = 0 + for _ in range(count): + result = (result << 1) | self.readBit() + return result + + def readBitsReversed(self, count): + """Read *count* bits, least significant first.""" + result = 0 + for i in range(count): + result |= self.readBit() << i + return result + + +def decompress(data): + """Decompress a ``BCFZ`` payload. + + :param data: the bytes following the ``BCFZ`` magic, starting with the + little-endian uncompressed length. + """ + expectedLength, = struct.unpack_from(' Song: """Open a GP file and read its contents. @@ -94,6 +98,11 @@ def _open(song, stream, mode='rb', version=None, encoding=None): filename = getattr(fp, 'name', '') if mode == 'rb': + magic = fp.read(4) + fp.seek(0) + if magic in _GPX_MAGICS: + gpfile = GPXFile(fp, encoding) + return gpfile, shouldClose gpfilebase = GPFileBase(fp, encoding) versionString = gpfilebase.readVersion() elif mode == 'wb': diff --git a/tests/A Simple Song.gpx b/tests/A Simple Song.gpx new file mode 100644 index 0000000000000000000000000000000000000000..1d993ffc02659a71891d6ad94762db62a4e33f5a GIT binary patch literal 57879 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 Z5M0H^Taw~xdE4^l|L5<5kKerz`9DE|RcinM literal 0 HcmV?d00001 diff --git a/tests/test_gpx.py b/tests/test_gpx.py new file mode 100644 index 0000000..3ac1e48 --- /dev/null +++ b/tests/test_gpx.py @@ -0,0 +1,66 @@ +from pathlib import Path + +import guitarpro as gp +from guitarpro.gpx import decompress, extractGPIF + + +LOCATION = Path(__file__).parent +SAMPLE = LOCATION / 'A Simple Song.gpx' + + +def test_extract_gpif_returns_xml(): + data = SAMPLE.read_bytes() + gpif = extractGPIF(data) + assert gpif.lstrip().startswith(b'' in gpif + + +def test_decompress_roundtrips_length(): + data = SAMPLE.read_bytes() + assert data[:4] == b'BCFZ' + decompressed = decompress(data[4:]) + assert decompressed[:4] == b'BCFS' + + +def test_parse_score_info(): + song = gp.parse(str(SAMPLE)) + assert song.title == 'A Simple Song' + assert song.artist == 'Hirokazu Sato (1966-2016)' + assert song.subtitle == 'www.classclef.com' + assert song.tempo == 65 + + +def test_parse_track_and_tuning(): + song = gp.parse(str(SAMPLE)) + assert len(song.tracks) == 1 + track = song.tracks[0] + assert track.name == 'Nylon Guitar' + # Standard tuning, high E to low E. + assert [s.value for s in track.strings] == [64, 59, 55, 50, 45, 40] + + +def test_parse_measures_and_beats(): + song = gp.parse(str(SAMPLE)) + track = song.tracks[0] + assert len(track.measures) == 31 + + # First measure: two eighth notes, time signature 1/4. + measure = track.measures[0] + assert measure.timeSignature.numerator == 1 + assert measure.timeSignature.denominator.value == 4 + beats = measure.voices[0].beats + assert len(beats) == 2 + assert all(b.duration.value == gp.Duration.eighth for b in beats) + assert beats[0].notes[0].value == 5 + assert beats[0].notes[0].string == 6 + + +def test_parse_counts(): + # GPIF shares voices, beats and notes by reference; the model expands + # those references into concrete objects per measure. + song = gp.parse(str(SAMPLE)) + track = song.tracks[0] + beats = [b for m in track.measures for v in m.voices for b in v.beats] + notes = sum(len(b.notes) for b in beats) + assert len(beats) == 268 + assert notes == 227 From e6935680dfdc951337bf856a617d7e9838357481 Mon Sep 17 00:00:00 2001 From: Kenji Noguchi Date: Thu, 25 Jun 2026 19:57:27 -0700 Subject: [PATCH 2/7] Handle zero-padded final byte in BCFZ stream The BCFZ payload may end mid-byte, so the last compression token can require bits beyond the end of the stream. Yield zero padding bits past end-of-stream instead of raising IndexError, matching the reference decoder's EOF handling. Add "Dear Song.gpx" which exercises this path. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/guitarpro/gpx.py | 4 ++++ tests/Dear Song.gpx | Bin 0 -> 42286 bytes tests/test_gpx.py | 22 ++++++++++++++++++++++ 3 files changed, 26 insertions(+) create mode 100644 tests/Dear Song.gpx diff --git a/src/guitarpro/gpx.py b/src/guitarpro/gpx.py index 5ff52e0..2d3377f 100644 --- a/src/guitarpro/gpx.py +++ b/src/guitarpro/gpx.py @@ -36,6 +36,10 @@ def __init__(self, data): self.bit = 0 def readBit(self): + # The final byte of the payload is zero-padded; past the end we + # keep yielding padding bits so the last token can be decoded. + if self.byte >= len(self.data): + return 0 result = (self.data[self.byte] >> (7 - self.bit)) & 1 self.bit += 1 if self.bit == 8: diff --git a/tests/Dear Song.gpx b/tests/Dear Song.gpx new file mode 100644 index 0000000000000000000000000000000000000000..73e94d574a975d361febb36c7f850b4da987f63a GIT binary patch literal 42286 zcmeFad0dj|`ah15v;dV^3Q}fTwzwe}W|?DIu|YGR#U$$25sH z$FwYM+(NM|w+t26%uvvwGPA`-TO6C|ocUf4&CIFJ()2n0{`tM;pZf(a@I3c(UGHnX z-`5H!`lhilV^G1)!Hupctr(Q!7r*}cW{)!C6n!aXW@T&z!O+oYMvS8!Dkee;)jLK@ zHWr;Uew@*iN&oueNkcI)DDs%W3Eh-nlXYbiMW_I@y{{1phr06*c0LN_8WWD21{aM+kx{5U@Wu$< zjE#Xex$wpu-e8d5sNl_Fcry-uqlJSvE8)!q_>Go5yjcftCL+IK!y6xXgF}9E65a&E z8+?rKsolT)bg%rzzrE%6uU4}`=y+d8xV~`M$;koem;e;KNk<#$?_Box7oXa{KR(PK zH~9B^63G->mZxc7TaWUnv*^(FSYx9=j+`@)oFy$foowq#XKe~!+1$Kebbm?gmvo0S z7T&4#T}jQtqr}}y1m^4s_%BmCi>1Yq;rp20`up9Re8gt6pBh_JSQxr_Jio56phVKq z;h~Z>Y#0(H&+-glofixbxTI9HGSZ(r2y8c*n2DJYA>2tq+qL}63nXgQt4+=3^9Qdh z``Z;tWy*q=6YV#0EQP{jBST4f8O1B7)Avi_JH32FW5m(ipv~LP-%2Q7pItIHud=LQ zp_eD=>eZ{3Og7t+%Mli&7U^3{hVLjbs|3jcL!**;^t8VIy}JqxoLo}Ew)usvIfr)> zw3Z}Iqw1!x*qZ|6I5*K(PX->Ui>@)Qk(j!dI1cu=;sxCLw1oLXn@gk=Tk3=Q(|61W z>+f&d!3$vr-AXg8PfeQ@w_jD~|5c8v>gW_@bZkRJNDybzLB;Iw3++`$r<1U{x`7c6 zH_ZgwYNthgt326FO1VUe3<={j1u1?_;!H{uR>~hAKYq33n~1&_Jx{u4dWjeCNMRf+ z?lZXWvwy#8_mCbZ-8X#HoaIy*Oz%&?|buEYmUnH;u5kPw$ANP2Uc zR#-xxicwW{b*^<5A4zdx21l~LpOg^K7nYPPp?N%_?_TLIi^p~jJrGCkVzbXp5QfQClnYwX;Sqs>m!G={Dh84qPE&re zb(tKeA8wTw>mL>nHfPc|ZOcz5Poc4f`}#M@6^u;`X5H<18$&p`m)U%~)T>DgXu}

cMZ;9V)Obv!xGr^)(ixippjZyiSo) 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 Date: Thu, 25 Jun 2026 20:04:12 -0700 Subject: [PATCH 3/7] Test GP7 (.gp) ZIP container reading Synthesize a GP7 archive from an existing .gpx score's score.gpif and assert it parses into an identical Song, covering the ZIP extraction branch without committing a binary GP7 fixture. Co-Authored-By: Claude Opus 4.8 (1M context) --- tests/test_gpx.py | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/tests/test_gpx.py b/tests/test_gpx.py index 9b60298..471a029 100644 --- a/tests/test_gpx.py +++ b/tests/test_gpx.py @@ -1,3 +1,5 @@ +import io +import zipfile from pathlib import Path import guitarpro as gp @@ -77,6 +79,23 @@ def test_parse_zero_padded_stream(): for b in v.beats) +def test_parse_gp7_zip_container(): + # A GP7 (.gp) file is a ZIP archive with the score at + # Content/score.gpif. Repackage a .gpx score into that layout and + # confirm it produces the same song. + gpif = extractGPIF(SAMPLE.read_bytes()) + buf = io.BytesIO() + with zipfile.ZipFile(buf, 'w', zipfile.ZIP_DEFLATED) as archive: + archive.writestr('VERSION', '7.0') + archive.writestr('Content/score.gpif', gpif) + buf.seek(0) + + song = gp.parse(buf) + reference = gp.parse(str(SAMPLE)) + assert song.title == reference.title + assert song == reference + + def test_parse_counts(): # GPIF shares voices, beats and notes by reference; the model expands # those references into concrete objects per measure. From 50b93acd3f46cee31fe727cb7864468678a924d9 Mon Sep 17 00:00:00 2001 From: Kenji Noguchi Date: Thu, 25 Jun 2026 20:21:29 -0700 Subject: [PATCH 4/7] Add writing support for GP6 (.gpx) and GP7 (.gp) files guitarpro.write can now produce GP6 and GP7 files, completing the read/write parity the library offers for GP3/4/5. - gpif.py: GPIFWriter serializes a Song into a score.gpif document, hoisting the measure/voice/beat/note tree into the format's flat, cross-referenced Bars/Voices/Beats/Notes/Rhythms lists. - gpx.py: BCFZ compression (literal-run encoding, which is valid BCFZ and keeps the encoder linear) and a BCFS image builder; GP7 is written as a ZIP archive. - io.py: write() dispatches to the GPX writer for version (6,0,0)/(7,0,0) or a .gpx/.gp extension. Round-trips parse -> write -> parse with full Song equality. Writes cover the same subset of the model that reading populates; advanced effects are not yet serialized. Also tightened voice reading to skip unused (-1) voice slots so the voice count is deterministic across a round-trip. Co-Authored-By: Claude Opus 4.8 (1M context) --- CHANGES.rst | 10 +- README.rst | 2 +- docs/index.rst | 2 +- docs/pyguitarpro/quickstart.rst | 7 +- src/guitarpro/gpif.py | 196 +++++++++++++++++++++++++++++++- src/guitarpro/gpx.py | 139 ++++++++++++++++++++-- src/guitarpro/io.py | 15 +++ tests/test_gpx.py | 39 +++++++ 8 files changed, 391 insertions(+), 19 deletions(-) diff --git a/CHANGES.rst b/CHANGES.rst index 031093d..14276d5 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -6,9 +6,13 @@ Unreleased **Changes:** -- Added reading of GP6 (``.gpx``) and GP7 (``.gp``) files. ``guitarpro.parse`` now - detects the container format and maps the embedded ``score.gpif`` document into - the existing ``Song`` model. Writing these formats is not supported. +- Added reading and writing of GP6 (``.gpx``) and GP7 (``.gp``) files. + ``guitarpro.parse`` detects the container format and maps the embedded + ``score.gpif`` document into the ``Song`` model; ``guitarpro.write`` rebuilds + the container when given ``version=(6, 0, 0)`` / ``(7, 0, 0)`` or a ``.gpx`` / + ``.gp`` extension. Coverage is the core musical content (song info, tracks, + tunings, measures, voices, beats, durations and notes); advanced effects are + not yet translated. Version 0.11 diff --git a/README.rst b/README.rst index 9fadff0..8293a3c 100644 --- a/README.rst +++ b/README.rst @@ -10,7 +10,7 @@ Introduction ------------ PyGuitarPro is a package to read, write and manipulate GP3, GP4 and GP5 files. GP6 (``.gpx``) and GP7 (``.gp``) -files can also be read. Initially PyGuitarPro is a Python port +files can also be read and written. Initially PyGuitarPro is a Python port of `AlphaTab `_ which originally was a Haxe port of `TuxGuitar `_. diff --git a/docs/index.rst b/docs/index.rst index bc1349c..290e18a 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -4,7 +4,7 @@ PyGuitarPro =========== PyGuitarPro is a package to read, write and manipulate GP3, GP4 and GP5 files. GP6 (``.gpx``) and GP7 (``.gp``) files -can also be read. Initially PyGuitarPro is a Python port +can also be read and written. Initially PyGuitarPro is a Python port of `AlphaTab `_ which is a Haxe port of `TuxGuitar `_. To anyone wanting to create their own the best guitar tablature editor in Python this package will be the good thing to diff --git a/docs/pyguitarpro/quickstart.rst b/docs/pyguitarpro/quickstart.rst index 86a0096..4a37c50 100644 --- a/docs/pyguitarpro/quickstart.rst +++ b/docs/pyguitarpro/quickstart.rst @@ -36,8 +36,9 @@ Functions :func:`guitarpro.parse` and :func:`guitarpro.write` support not only f .. note:: - PyGuitarPro reads and writes GP3, GP4 and GP5 files. GP6 (``.gpx``) and GP7 (``.gp``) files can be read but not - written; their parsing covers the core musical content (song info, tracks, tunings, measures, voices, beats, - durations and notes) but not yet every advanced effect. + PyGuitarPro reads and writes GP3, GP4, GP5 files, as well as GP6 (``.gpx``) and GP7 (``.gp``) files. GP6/GP7 + support covers the core musical content (song info, tracks, tunings, measures, voices, beats, durations and + notes) but not yet every advanced effect. Write the GP6/GP7 formats by passing ``version=(6, 0, 0)`` or + ``version=(7, 0, 0)`` to :func:`guitarpro.write`, or by using a ``.gpx`` / ``.gp`` file extension. .. vim: tw=120 cc=121 diff --git a/src/guitarpro/gpif.py b/src/guitarpro/gpif.py index a10a1c5..a4c312e 100644 --- a/src/guitarpro/gpif.py +++ b/src/guitarpro/gpif.py @@ -14,7 +14,7 @@ from . import models as gp -__all__ = ('GPIFParser',) +__all__ = ('GPIFParser', 'GPIFWriter') # GPIF NoteValue -> Duration.value _NOTE_VALUES = { @@ -40,6 +40,10 @@ 'FFF': gp.Velocities.forteFortissimo, } +# Inverse lookups for writing. +_NOTE_VALUE_NAMES = {value: name for name, value in _NOTE_VALUES.items()} +_DYNAMIC_NAMES = {velocity: name for name, velocity in _DYNAMICS.items()} + class GPIFParser: def __init__(self, data, versionTuple=None): @@ -185,9 +189,10 @@ def _readVoices(self, measure, bar): voiceIds = (bar.findtext('Voices') or '').split() start = measure.start for voiceId in voiceIds: + if voiceId == '-1': + continue voice = gp.Voice(measure) - if voiceId != '-1': - start = self._readBeats(voice, self.voices.get(voiceId), start) + self._readBeats(voice, self.voices.get(voiceId), start) measure.voices.append(voice) # The model expects at least ``maxVoices`` voices per measure. while len(measure.voices) < gp.Measure.maxVoices: @@ -254,3 +259,188 @@ def _readNote(self, beat, noteElement, velocity): if noteElement.find('Tie') is not None: note.type = gp.NoteType.tie return note + + +class GPIFWriter: + """Serializes a :class:`Song` into a ``score.gpif`` XML document. + + The model is a tree (measure -> voice -> beat -> note) while GPIF stores + flat, cross-referenced lists joined by ``id``. This writer hoists beats, + notes and rhythms into global tables, assigns ids and emits the reference + strings the format expects. It writes the same subset of the model that + :class:`GPIFParser` reads back. + """ + + def __init__(self, song): + self.song = song + self.root = ET.Element('GPIF') + # Global id counters and lists for the cross-referenced sections. + self._bars = [] + self._voices = [] + self._beats = [] + self._notes = [] + self._rhythms = [] + # Deduplicate rhythms by (value, dotted, tuplet). + self._rhythmIds = {} + + # -- helpers -------------------------------------------------------- + + @staticmethod + def _sub(parent, tag, text=None, **attrib): + element = ET.SubElement(parent, tag, {k: str(v) for k, v in attrib.items()}) + if text is not None: + element.text = str(text) + return element + + # -- entry point ---------------------------------------------------- + + def write(self): + self._sub(self.root, 'GPVersion', 6) + self._writeScoreInfo() + self._writeMasterTrack() + self._writeTracks() + self._writeMasterBars() + self._writeCollections() + return ET.tostring(self.root, encoding='UTF-8', xml_declaration=True) + + # -- score / master track ------------------------------------------ + + def _writeScoreInfo(self): + song = self.song + score = self._sub(self.root, 'Score') + self._sub(score, 'Title', song.title) + self._sub(score, 'SubTitle', song.subtitle) + self._sub(score, 'Artist', song.artist) + self._sub(score, 'Album', song.album) + self._sub(score, 'Words', song.words) + self._sub(score, 'Music', song.music) + self._sub(score, 'Copyright', song.copyright) + self._sub(score, 'Tabber', song.tab) + self._sub(score, 'Instructions', song.instructions) + self._sub(score, 'Notices', '\n'.join(song.notice)) + + def _writeMasterTrack(self): + master = self._sub(self.root, 'MasterTrack') + trackIds = ' '.join(str(i) for i in range(len(self.song.tracks))) + self._sub(master, 'Tracks', trackIds) + automations = self._sub(master, 'Automations') + automation = self._sub(automations, 'Automation') + self._sub(automation, 'Type', 'Tempo') + self._sub(automation, 'Linear', 'true') + self._sub(automation, 'Bar', 0) + self._sub(automation, 'Position', 0) + self._sub(automation, 'Visible', 'true') + self._sub(automation, 'Value', f'{self.song.tempo} 2') + + # -- tracks --------------------------------------------------------- + + def _writeTracks(self): + tracks = self._sub(self.root, 'Tracks') + for index, track in enumerate(self.song.tracks): + element = self._sub(tracks, 'Track', id=index) + self._sub(element, 'Name', track.name) + properties = self._sub(element, 'Properties') + tuning = self._sub(properties, 'Property', name='Tuning') + # The model orders strings high-to-low; GPIF lists pitches low-to-high. + pitches = ' '.join(str(s.value) for s in reversed(track.strings)) + self._sub(tuning, 'Pitches', pitches) + + # -- master bars / bars / voices / beats / notes / rhythms ---------- + + def _writeMasterBars(self): + masterBars = self._sub(self.root, 'MasterBars') + headers = self.song.measureHeaders + for measureIndex, header in enumerate(headers): + masterBar = self._sub(masterBars, 'MasterBar') + self._sub(masterBar, 'Key') + time = header.timeSignature + self._sub(masterBar, 'Time', f'{time.numerator}/{time.denominator.value}') + if header.isRepeatOpen or header.repeatClose >= 0: + attrib = {} + if header.isRepeatOpen: + attrib['start'] = 'true' + if header.repeatClose >= 0: + attrib['end'] = 'true' + attrib['count'] = str(header.repeatClose) + self._sub(masterBar, 'Repeat', **attrib) + barIds = [self._writeBar(track.measures[measureIndex]) + for track in self.song.tracks + if measureIndex < len(track.measures)] + self._sub(masterBar, 'Bars', ' '.join(str(i) for i in barIds)) + + def _writeBar(self, measure): + barId = len(self._bars) + bar = ET.Element('Bar', id=str(barId)) + self._bars.append(bar) + self._sub(bar, 'Clef', 'G2') + # GPIF always reserves four voice slots; -1 marks an unused one. + voiceIds = ['-1', '-1', '-1', '-1'] + slot = 0 + for voice in measure.voices: + if slot >= 4: + break + if voice.beats: + voiceIds[slot] = str(self._writeVoice(voice)) + slot += 1 + self._sub(bar, 'Voices', ' '.join(voiceIds)) + return barId + + def _writeVoice(self, voice): + voiceId = len(self._voices) + element = ET.Element('Voice', id=str(voiceId)) + self._voices.append(element) + beatIds = [self._writeBeat(beat) for beat in voice.beats] + self._sub(element, 'Beats', ' '.join(str(i) for i in beatIds)) + return voiceId + + def _writeBeat(self, beat): + beatId = len(self._beats) + element = ET.Element('Beat', id=str(beatId)) + self._beats.append(element) + if beat.notes: + velocity = beat.notes[0].velocity + self._sub(element, 'Dynamic', _DYNAMIC_NAMES.get(velocity, 'F')) + rhythmId = self._writeRhythm(beat.duration) + self._sub(element, 'Rhythm', ref=rhythmId) + if beat.notes: + noteIds = [self._writeNote(note) for note in beat.notes] + self._sub(element, 'Notes', ' '.join(str(i) for i in noteIds)) + return beatId + + def _writeNote(self, note): + noteId = len(self._notes) + element = ET.Element('Note', id=str(noteId)) + self._notes.append(element) + properties = self._sub(element, 'Properties') + fret = self._sub(properties, 'Property', name='Fret') + self._sub(fret, 'Fret', note.value) + string = self._sub(properties, 'Property', name='String') + # The model is 1-based from the highest string; GPIF is 0-based. + self._sub(string, 'String', note.string - 1) + if note.type is gp.NoteType.tie: + self._sub(element, 'Tie', origin='true') + return noteId + + def _writeRhythm(self, duration): + key = (duration.value, duration.isDotted, + duration.tuplet.enters, duration.tuplet.times) + if key in self._rhythmIds: + return self._rhythmIds[key] + rhythmId = len(self._rhythms) + element = ET.Element('Rhythm', id=str(rhythmId)) + self._rhythms.append(element) + self._sub(element, 'NoteValue', _NOTE_VALUE_NAMES.get(duration.value, 'Quarter')) + if duration.isDotted: + self._sub(element, 'AugmentationDot', count=1) + if (duration.tuplet.enters, duration.tuplet.times) != (1, 1): + self._sub(element, 'PrimaryTuplet', + num=duration.tuplet.enters, den=duration.tuplet.times) + self._rhythmIds[key] = str(rhythmId) + return str(rhythmId) + + def _writeCollections(self): + for tag, elements in (('Bars', self._bars), ('Voices', self._voices), + ('Beats', self._beats), ('Notes', self._notes), + ('Rhythms', self._rhythms)): + container = self._sub(self.root, tag) + container.extend(elements) diff --git a/src/guitarpro/gpx.py b/src/guitarpro/gpx.py index 2d3377f..2dd5f09 100644 --- a/src/guitarpro/gpx.py +++ b/src/guitarpro/gpx.py @@ -9,18 +9,22 @@ * GP7 ``.gp`` -- a plain ZIP archive with the score at ``Content/score.gpif``. -This module exposes :class:`GPXFile`, which mirrors the ``readSong`` -interface of the binary readers and delegates the XML-to-:class:`Song` -mapping to :mod:`guitarpro.gpif`. +This module exposes :class:`GPXFile`, which mirrors the ``readSong`` and +``writeSong`` interface of the binary readers and delegates the +XML-to-:class:`Song` mapping to :mod:`guitarpro.gpif`. + +GP6 files can be written as well as read; the container is rebuilt as a +``BCFZ``-compressed ``BCFS`` archive holding a single ``score.gpif``. GP7 +files are written as a ZIP archive. """ import io import struct import zipfile -from .gpif import GPIFParser +from .gpif import GPIFParser, GPIFWriter from .models import GPException -__all__ = ('GPXFile', 'decompress', 'extractGPIF') +__all__ = ('GPXFile', 'decompress', 'compress', 'extractGPIF', 'buildGPX', 'buildGP') _HEADER_BCFS = b'BCFS' _HEADER_BCFZ = b'BCFZ' @@ -89,6 +93,62 @@ def decompress(data): return bytes(result) +class _BitWriter: + """Writes individual bits to a byte string, most significant first.""" + + def __init__(self): + self.out = bytearray() + self.current = 0 + self.count = 0 + + def writeBit(self, bit): + self.current = (self.current << 1) | (bit & 1) + self.count += 1 + if self.count == 8: + self.out.append(self.current) + self.current = 0 + self.count = 0 + + def writeBits(self, value, count): + """Write *count* bits of *value*, most significant first.""" + for i in range(count - 1, -1, -1): + self.writeBit((value >> i) & 1) + + def writeBitsReversed(self, value, count): + """Write *count* bits of *value*, least significant first.""" + for i in range(count): + self.writeBit((value >> i) & 1) + + def writeBytes(self, data): + for byte in data: + self.writeBits(byte, 8) + + def getvalue(self): + if self.count: + # Flush the partial final byte, padding the low bits with zeros. + self.out.append(self.current << (8 - self.count)) + self.current = 0 + self.count = 0 + return bytes(self.out) + + +def compress(data): + """Compress *data* into a ``BCFZ`` payload (length prefix + bitstream). + + The BCFZ scheme allows back-references, but a stream of plain literal + runs is equally valid and decodes identically. Emitting literals only + keeps the encoder linear and simple; the modest size overhead (a 3-bit + header per three bytes) is acceptable for written files. + """ + writer = _BitWriter() + for offset in range(0, len(data), 3): + chunk = data[offset:offset + 3] + writer.writeBit(0) + writer.writeBitsReversed(len(chunk), 2) + writer.writeBytes(chunk) + return struct.pack(' Date: Thu, 25 Jun 2026 20:31:00 -0700 Subject: [PATCH 5/7] Harden GP6/GP7 reader edge cases - Buffer non-seekable streams before the magic peek so parse() works on pipes and other non-seekable file-like objects. - Raise GPException (not KeyError) when a GP6 container has no score.gpif. - Emit one Bar per track in every MasterBar so the Bars reference list has a consistent length. - Drop the internal buildBCFS helper from the module's public __all__. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/guitarpro/gpif.py | 4 ++-- src/guitarpro/gpx.py | 8 ++++++-- src/guitarpro/io.py | 4 ++++ tests/test_gpx.py | 24 ++++++++++++++++++++++++ 4 files changed, 36 insertions(+), 4 deletions(-) diff --git a/src/guitarpro/gpif.py b/src/guitarpro/gpif.py index a4c312e..44379c8 100644 --- a/src/guitarpro/gpif.py +++ b/src/guitarpro/gpif.py @@ -363,9 +363,9 @@ def _writeMasterBars(self): attrib['end'] = 'true' attrib['count'] = str(header.repeatClose) self._sub(masterBar, 'Repeat', **attrib) + # One bar per track; every track has a measure per header. barIds = [self._writeBar(track.measures[measureIndex]) - for track in self.song.tracks - if measureIndex < len(track.measures)] + for track in self.song.tracks] self._sub(masterBar, 'Bars', ' '.join(str(i) for i in barIds)) def _writeBar(self, measure): diff --git a/src/guitarpro/gpx.py b/src/guitarpro/gpx.py index 2dd5f09..04b1ed0 100644 --- a/src/guitarpro/gpx.py +++ b/src/guitarpro/gpx.py @@ -24,7 +24,7 @@ from .gpif import GPIFParser, GPIFWriter from .models import GPException -__all__ = ('GPXFile', 'decompress', 'compress', 'extractGPIF', 'buildGPX', 'buildGP') +__all__ = ('GPXFile', 'decompress', 'compress', 'extractGPIF') _HEADER_BCFS = b'BCFS' _HEADER_BCFZ = b'BCFZ' @@ -197,7 +197,11 @@ def extractGPIF(data): if data[:4] == _HEADER_BCFZ: data = decompress(data[4:]) if data[:4] == _HEADER_BCFS: - return GPXFileSystem(data).read('score.gpif') + files = GPXFileSystem(data).files + try: + return files['score.gpif'] + except KeyError: + raise GPException('no score.gpif found in GP6 container') raise GPException('not a Guitar Pro 6/7 container') diff --git a/src/guitarpro/io.py b/src/guitarpro/io.py index 11836b9..b750298 100644 --- a/src/guitarpro/io.py +++ b/src/guitarpro/io.py @@ -1,3 +1,4 @@ +import io import os from .iobase import GPFileBase @@ -98,6 +99,9 @@ def _open(song, stream, mode='rb', version=None, encoding=None): filename = getattr(fp, 'name', '') if mode == 'rb': + if not (hasattr(fp, 'seekable') and fp.seekable()): + # The magic peek below needs to rewind; buffer non-seekable streams. + fp = io.BytesIO(fp.read()) magic = fp.read(4) fp.seek(0) if magic in _GPX_MAGICS: diff --git a/tests/test_gpx.py b/tests/test_gpx.py index c52150a..a923b52 100644 --- a/tests/test_gpx.py +++ b/tests/test_gpx.py @@ -98,6 +98,30 @@ def test_parse_gp7_zip_container(): assert song == reference +def test_parse_non_seekable_stream(): + class NonSeekable: + def __init__(self, data): + self._stream = io.BytesIO(data) + + def read(self, size=-1): + return self._stream.read(size) + + def seekable(self): + return False + + song = gp.parse(NonSeekable(SAMPLE.read_bytes())) + assert song.title == 'A Simple Song' + + +def test_missing_score_raises(): + buf = io.BytesIO() + with zipfile.ZipFile(buf, 'w') as archive: + archive.writestr('VERSION', '7.0') + buf.seek(0) + with pytest.raises(gp.GPException): + gp.parse(buf) + + def test_compress_decompress_roundtrip(): from guitarpro.gpx import compress, decompress gpif = extractGPIF(SAMPLE.read_bytes()) From 8cd568844139a265723a6f4be33ae7ee5b973f69 Mon Sep 17 00:00:00 2001 From: Kenji Noguchi Date: Thu, 25 Jun 2026 20:53:00 -0700 Subject: [PATCH 6/7] Translate GP6/GP7 note effects Map the common note and beat effects between the score.gpif XML and the Song model, using alphaTab's GpifParser as the reference for the flag and type encodings: - hammer-on/pull-off (HopoOrigin) - slides (Slide/Flags bitfield -> SlideType list) - harmonics: natural, artificial, pinch, tap, semi (Harmonic + HarmonicType + HarmonicFret) - left/right-hand fingering (LeftFingering/RightFingering letter codes) - accentuation: accent, heavy accent, staccato (Accent flag bits) - beat text (FreeText) Effects read from the sample files round-trip with full Song equality, and each effect branch is covered by a synthetic parse -> write -> parse test. Bends, grace notes and chord diagrams remain unmapped. Co-Authored-By: Claude Opus 4.8 (1M context) --- CHANGES.rst | 7 ++- src/guitarpro/gpif.py | 118 ++++++++++++++++++++++++++++++++++++++ tests/test_gpx_effects.py | 78 +++++++++++++++++++++++++ 3 files changed, 201 insertions(+), 2 deletions(-) create mode 100644 tests/test_gpx_effects.py diff --git a/CHANGES.rst b/CHANGES.rst index 14276d5..b349aea 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -11,8 +11,11 @@ Unreleased ``score.gpif`` document into the ``Song`` model; ``guitarpro.write`` rebuilds the container when given ``version=(6, 0, 0)`` / ``(7, 0, 0)`` or a ``.gpx`` / ``.gp`` extension. Coverage is the core musical content (song info, tracks, - tunings, measures, voices, beats, durations and notes); advanced effects are - not yet translated. + tunings, measures, voices, beats, durations and notes). +- Added GP6/GP7 note effects: hammer-on/pull-off, slides, harmonics (natural, + artificial, pinch, tap, semi), left/right-hand fingering, accentuation + (accent, heavy accent, staccato) and beat text. Bends, grace notes and + chord diagrams are not yet translated. Version 0.11 diff --git a/src/guitarpro/gpif.py b/src/guitarpro/gpif.py index 44379c8..3a3dacf 100644 --- a/src/guitarpro/gpif.py +++ b/src/guitarpro/gpif.py @@ -44,6 +44,44 @@ _NOTE_VALUE_NAMES = {value: name for name, value in _NOTE_VALUES.items()} _DYNAMIC_NAMES = {velocity: name for name, velocity in _DYNAMICS.items()} +# Note effect encodings, after alphaTab's GpifParser. + +# Slide N: bit -> SlideType. +_SLIDE_FLAGS = { + 1: gp.SlideType.shiftSlideTo, + 2: gp.SlideType.legatoSlideTo, + 4: gp.SlideType.outDownwards, + 8: gp.SlideType.outUpwards, + 16: gp.SlideType.intoFromBelow, + 32: gp.SlideType.intoFromAbove, +} +_SLIDE_BITS = {slide: bit for bit, slide in _SLIDE_FLAGS.items()} + +# Note N: bit -> effect flag. +_ACCENT_STACCATO = 0x01 +_ACCENT_HEAVY = 0x04 +_ACCENT_NORMAL = 0x08 + +# LeftFingering / RightFingering text -> Fingering. +_FINGERING = { + 'P': gp.Fingering.thumb, + 'I': gp.Fingering.index, + 'M': gp.Fingering.middle, + 'A': gp.Fingering.annular, + 'C': gp.Fingering.little, +} +_FINGERING_NAMES = {finger: name for name, finger in _FINGERING.items()} + +# HarmonicType HType -> (HarmonicEffect class, stores a fret value). +_HARMONICS = { + 'natural': (gp.NaturalHarmonic, False), + 'artificial': (gp.ArtificialHarmonic, False), + 'pinch': (gp.PinchHarmonic, False), + 'tap': (gp.TappedHarmonic, True), + 'semi': (gp.SemiHarmonic, False), +} +_HARMONIC_NAMES = {cls: name for name, (cls, _) in _HARMONICS.items()} + class GPIFParser: def __init__(self, data, versionTuple=None): @@ -208,6 +246,7 @@ def _readBeats(self, voice, voiceElement, start): beat = gp.Beat(voice) beat.start = start beat.duration = self._readDuration(beatElement) + beat.text = beatElement.findtext('FreeText') or None self._readBeatNotes(beat, beatElement) beat.status = gp.BeatStatus.normal if beat.notes else gp.BeatStatus.rest voice.beats.append(beat) @@ -258,8 +297,49 @@ def _readNote(self, beat, noteElement, velocity): note.string = int(string.findtext('String') or 0) + 1 if noteElement.find('Tie') is not None: note.type = gp.NoteType.tie + self._readNoteEffects(note, noteElement) return note + def _readNoteEffects(self, note, noteElement): + effect = note.effect + + accent = noteElement.findtext('Accent') + if accent is not None: + flags = int(accent) + effect.staccato = bool(flags & _ACCENT_STACCATO) + effect.heavyAccentuatedNote = bool(flags & _ACCENT_HEAVY) + effect.accentuatedNote = bool(flags & _ACCENT_NORMAL) + + left = noteElement.findtext('LeftFingering') + if left in _FINGERING: + effect.leftHandFinger = _FINGERING[left] + right = noteElement.findtext('RightFingering') + if right in _FINGERING: + effect.rightHandFinger = _FINGERING[right] + + if self._property(noteElement, 'HopoOrigin') is not None: + effect.hammer = True + + slide = self._property(noteElement, 'Slide') + if slide is not None: + flags = int(slide.findtext('Flags') or 0) + effect.slides = [slideType + for bit, slideType in _SLIDE_FLAGS.items() + if flags & bit] + + if self._property(noteElement, 'Harmonic') is not None: + harmonicType = self._property(noteElement, 'HarmonicType') + name = (harmonicType.findtext('HType') if harmonicType is not None else None) + entry = _HARMONICS.get((name or '').lower()) + if entry is not None: + cls, hasFret = entry + harmonic = cls() + if hasFret: + fret = self._property(noteElement, 'HarmonicFret') + if fret is not None: + harmonic.fret = int(float(fret.findtext('HFret') or 0)) + effect.harmonic = harmonic + class GPIFWriter: """Serializes a :class:`Song` into a ``score.gpif`` XML document. @@ -400,6 +480,8 @@ def _writeBeat(self, beat): if beat.notes: velocity = beat.notes[0].velocity self._sub(element, 'Dynamic', _DYNAMIC_NAMES.get(velocity, 'F')) + if beat.text: + self._sub(element, 'FreeText', beat.text) rhythmId = self._writeRhythm(beat.duration) self._sub(element, 'Rhythm', ref=rhythmId) if beat.notes: @@ -411,16 +493,52 @@ def _writeNote(self, note): noteId = len(self._notes) element = ET.Element('Note', id=str(noteId)) self._notes.append(element) + self._writeNoteEffects(element, note) properties = self._sub(element, 'Properties') fret = self._sub(properties, 'Property', name='Fret') self._sub(fret, 'Fret', note.value) string = self._sub(properties, 'Property', name='String') # The model is 1-based from the highest string; GPIF is 0-based. self._sub(string, 'String', note.string - 1) + self._writeNoteProperties(properties, note) if note.type is gp.NoteType.tie: self._sub(element, 'Tie', origin='true') return noteId + def _writeNoteEffects(self, element, note): + """Write the note effects that are direct children of .""" + effect = note.effect + if effect.leftHandFinger in _FINGERING_NAMES: + self._sub(element, 'LeftFingering', _FINGERING_NAMES[effect.leftHandFinger]) + if effect.rightHandFinger in _FINGERING_NAMES: + self._sub(element, 'RightFingering', _FINGERING_NAMES[effect.rightHandFinger]) + flags = ((_ACCENT_STACCATO if effect.staccato else 0) + | (_ACCENT_HEAVY if effect.heavyAccentuatedNote else 0) + | (_ACCENT_NORMAL if effect.accentuatedNote else 0)) + if flags: + self._sub(element, 'Accent', flags) + + def _writeNoteProperties(self, properties, note): + """Write the note effects stored under .""" + effect = note.effect + if effect.hammer: + hopo = self._sub(properties, 'Property', name='HopoOrigin') + self._sub(hopo, 'Enable') + if effect.slides: + flags = sum(_SLIDE_BITS[s] for s in effect.slides if s in _SLIDE_BITS) + slide = self._sub(properties, 'Property', name='Slide') + self._sub(slide, 'Flags', flags) + if effect.harmonic is not None: + name = _HARMONIC_NAMES.get(type(effect.harmonic)) + if name is not None: + enable = self._sub(properties, 'Property', name='Harmonic') + self._sub(enable, 'Enable') + htype = self._sub(properties, 'Property', name='HarmonicType') + self._sub(htype, 'HType', name.capitalize()) + fret = getattr(effect.harmonic, 'fret', None) + hfret = self._sub(properties, 'Property', name='HarmonicFret') + self._sub(hfret, 'HFret', f'{float(fret if fret is not None else note.value):.6f}') + def _writeRhythm(self, duration): key = (duration.value, duration.isDotted, duration.tuplet.enters, duration.tuplet.times) diff --git a/tests/test_gpx_effects.py b/tests/test_gpx_effects.py new file mode 100644 index 0000000..aa70841 --- /dev/null +++ b/tests/test_gpx_effects.py @@ -0,0 +1,78 @@ +import io +from pathlib import Path + +import pytest + +import guitarpro as gp + + +LOCATION = Path(__file__).parent +SAMPLE = LOCATION / 'A Simple Song.gpx' + + +def _roundtrip(song): + buf = io.BytesIO() + gp.write(song, buf, version=(6, 0, 0)) + buf.seek(0) + return gp.parse(buf) + + +def _first_note(song): + for measure in song.tracks[0].measures: + for voice in measure.voices: + for beat in voice.beats: + if beat.notes: + return beat, beat.notes[0] + raise AssertionError('no note found') + + +def test_real_file_effects_are_read(): + song = gp.parse(str(SAMPLE)) + effects = [note.effect + for m in song.tracks[0].measures + for v in m.voices + for b in v.beats + for note in b.notes] + assert any(e.hammer for e in effects) + assert any(e.slides for e in effects) + assert any(e.harmonic is not None for e in effects) + assert any(e.leftHandFinger is not gp.Fingering.open for e in effects) + + +def test_real_file_effects_roundtrip(): + song = gp.parse(str(SAMPLE)) + assert song == _roundtrip(song) + + +@pytest.mark.parametrize('mutate, check', [ + (lambda n: setattr(n.effect, 'heavyAccentuatedNote', True), + lambda n: n.effect.heavyAccentuatedNote), + (lambda n: setattr(n.effect, 'accentuatedNote', True), + lambda n: n.effect.accentuatedNote), + (lambda n: setattr(n.effect, 'staccato', True), + lambda n: n.effect.staccato), + (lambda n: setattr(n.effect, 'hammer', True), + lambda n: n.effect.hammer), + (lambda n: setattr(n.effect, 'rightHandFinger', gp.Fingering.middle), + lambda n: n.effect.rightHandFinger is gp.Fingering.middle), + (lambda n: setattr(n.effect, 'slides', [gp.SlideType.legatoSlideTo, gp.SlideType.intoFromBelow]), + lambda n: set(n.effect.slides) == {gp.SlideType.legatoSlideTo, gp.SlideType.intoFromBelow}), + (lambda n: setattr(n.effect, 'harmonic', gp.NaturalHarmonic()), + lambda n: isinstance(n.effect.harmonic, gp.NaturalHarmonic)), + (lambda n: setattr(n.effect, 'harmonic', gp.TappedHarmonic(fret=7)), + lambda n: isinstance(n.effect.harmonic, gp.TappedHarmonic) and n.effect.harmonic.fret == 7), +]) +def test_note_effect_roundtrip(mutate, check): + song = gp.parse(str(SAMPLE)) + _, note = _first_note(song) + mutate(note) + _, restored = _first_note(_roundtrip(song)) + assert check(restored) + + +def test_beat_text_roundtrip(): + song = gp.parse(str(SAMPLE)) + beat, _ = _first_note(song) + beat.text = 'riff' + restored, _ = _first_note(_roundtrip(song)) + assert restored.text == 'riff' From 281f30a001d8179f281c464d52ba8d1794abe62a Mon Sep 17 00:00:00 2001 From: Kenji Noguchi Date: Thu, 25 Jun 2026 21:20:39 -0700 Subject: [PATCH 7/7] Stop BCFZ decompression at end of stream The declared uncompressed length is only an upper bound: real GP6 streams that use back-references end before reaching it, with the final byte zero-padded. The previous code padded past end-of-stream with zero bits, which decode to empty literal runs and made decompression loop forever on any such file (the literal-heavy sample files happened to hit the declared length exactly and so were unaffected). Raise at end-of-stream and stop, matching the reference decoder. Verified against the alphaTab GP6 test corpus: all 35 files now decompress, parse and round-trip. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/guitarpro/gpx.py | 43 +++++++++++++++++++++++++------------------ tests/test_gpx.py | 12 ++++++++++++ 2 files changed, 37 insertions(+), 18 deletions(-) diff --git a/src/guitarpro/gpx.py b/src/guitarpro/gpx.py index 04b1ed0..c8f4bd7 100644 --- a/src/guitarpro/gpx.py +++ b/src/guitarpro/gpx.py @@ -31,6 +31,10 @@ _SECTOR_SIZE = 0x1000 +class _EndOfStream(Exception): + """Raised when the BCFZ bitstream is exhausted.""" + + class _BitReader: """Reads individual bits from a byte string, most significant first.""" @@ -40,10 +44,8 @@ def __init__(self, data): self.bit = 0 def readBit(self): - # The final byte of the payload is zero-padded; past the end we - # keep yielding padding bits so the last token can be decoded. if self.byte >= len(self.data): - return 0 + raise _EndOfStream result = (self.data[self.byte] >> (7 - self.bit)) & 1 self.bit += 1 if self.bit == 8: @@ -75,21 +77,26 @@ def decompress(data): expectedLength, = struct.unpack_from('