Add GP6/7/8 (GPIF) support#58
Closed
kaizenman wants to merge 56 commits intoPerlence:masterfrom
Closed
Conversation
Contributor
Author
|
@Perlence FYI — opening this as a draft to share the GP6/7/8 reader work early before the writer lands. The reader (97 commits) is functional and tested, but the PR stays in draft until the writer is also implemented. Not asking for a full review yet, just flagging the direction in case you have early thoughts on the GPIF/GPX architecture or the inline parser approach. No pressure to look until I move it out of draft. |
Introduces `GP7File` in `src/guitarpro/gp7.py` that opens GP7/GP8 zip archives, reads `score.gpif` XML, and populates the song-level metadata fields (title, subtitle, artist, album, words, music, copyright, tabber, instructions, notices, tempo, version). Tracks list is left empty — Phase 2 will populate it. Dispatcher in `io.py` detects the PK zip magic on read and routes to `GP7File`; `<GPVersion>` from the XML refines the version tuple (distinguishes GP7.0 from GP8.1, etc.). Tests: adopts the 50 GP7/GP8 fixtures shipped by AlphaTab under its MPL-2.0 test-data (same license as the code we ported). 152 new tests covering parse-not-crash and metadata exposure for every fixture; existing 192 GP3/4/5 tests continue to pass (344 total). Ported from AlphaTab's Gp7To8Importer.ts + GpifParser.ts (MPL-2.0) — full attribution header in gp7.py. Phases 2-6 will port the remaining ~2900 lines of GpifParser covering tracks, measures, voices, beats, notes, effects, chords, markers, repeats, directions.
Parses every <Track> node in score.gpif into a PyGuitarPro `Track`:
- name, short name, color (RGB)
- percussion flag (from <InstrumentSet>/<Type>drumKit</Type> or
<GeneralMidi table="Percussion">)
- tuning (GPIF stores low-to-high; reversed to PyGuitarPro's
high-to-low string-1-first convention)
- capo fret, fret count (from <Staff>/<Properties>/<Property>)
- MIDI program / bank from the first <Sound> in <Sounds>
- MIDI channel / effect channel / port from <MidiConnection>/<GeneralMidi>
Port mirrors AlphaTab GpifParser `_parseTrack` / `_parseInstrumentSet`
/ `_parseGeneralMidi` / `_parseSounds` / `_parseStaffProperty`.
Added 109 assertions covering track numbering, naming, percussion flag,
string integrity, and channel field types across all 50 fixtures, plus
targeted checks against fixtures we expect to contain drums or standard
tuning. All 453 tests pass (252 GP7, 192 GP3/4/5, 9 conversion).
Walks the denormalised GPIF DAG and assembles nested Song structures:
MasterBars → MeasureHeader list (time sig, key sig, markers, repeats,
double-bars, alternate endings, triplet feel).
Per track, per master-bar: the referenced Bar → Measure with its
Clef, voice count, and beats.
Bars → Voices → Beats chain resolved via id lookup tables built in
a single pre-pass over <Rhythms>, <Notes>, <Beats>, <Voices>,
<Bars>. PyGuitarPro's two-voice expectation is preserved by padding
with empty voices.
Beats: duration (NoteValue → integer + dotted + tuplet from
<Rhythm>), beat text (<FreeText>), dynamics (<Dynamic> → GP-native
velocity centers), rest/empty detection.
Notes: string + fret from <Properties>, MIDI pitch for percussion,
dead/tie note types from Muted/Tied properties. String numbering
converted from GPIF's low-to-high 0-index to PyGuitarPro's
high-to-low 1-index.
Enum maps: GPIF NoteValue names → PyGuitarPro integer durations
(Whole=1..256th=256); Dynamic → velocity {PPP:15..FFF:127}
matching GP5 unpackVelocity centers; Clef {G2:treble,F4:bass,
C4:tenor,C3:alto,Neutral:treble}.
Tests: +400 assertions across all 50 fixtures covering measure count
parity with headers, voice presence, beat status validity, duration
integrity, time signature sanity, and note string/fret bounds. All
1,045 tests pass (192 GP3/4/5 + 853 GP7 + 9 conversion, plus 1
skipped for a missing drums fixture).
End-to-end verified: parsing a real 7-track GP8 file (Axis — Timi
Pheri Aauna, 71 bars) produces a 46,488-token stream through the
existing nanomusic encoder, indistinguishable in shape from GP5
output.
Note effects (via <Properties>/<Property> and direct <Note>/<...>
siblings, mirroring AlphaTab's split between the two):
- palm-mute, let-ring, ghost, staccato, accent, heavy accent
- vibrato (Slight & Wide both treated as gp.vibrato=True)
- dead (Muted property)
- tie-destination (note.type = tie)
- hammer/pull-off origin (HopoOrigin)
- slide in / out / shift / legato via flag bits (0x01..0x20)
- trill with destination fret
- harmonic types: natural, pinch, semi, tap, artificial
(ArtificialHarmonic reconstructs PitchClass/Octave from the
semitone-offset stored in HarmonicFret)
- bend curves: origin + middle (value + up to two offsets) +
destination, assembled into a gp.BendEffect with 2–4 points;
float cents and offsets rescaled to PyGuitarPro units
Beat effects:
- fade-in (Fadding FadeIn)
- tremolo picking (Tremolo 1/2 | 1/4 | 1/8 → 8th/16th/32nd
TremoloPickingEffect; propagated to every note in the beat to
match PyGuitarPro's per-note attachment)
- grace notes (GraceNotes OnBeat | BeforeBeat → per-note GraceEffect)
- brush stroke (Arpeggio Up|Down → BeatStroke)
- tremolo-bar / whammy (Whammy element → BendEffect w/ 3-point curve)
- chord-id stashed for Phase 5 diagram resolution
Tests: +10 specific assertions on fixtures that advertise each effect
(bends, harmonics, hammer, vibrato, dead, accentuations, grace, trills,
tremolo, whammy-advanced). All 1,055 tests pass.
Verified end-to-end on a real 7-track GP8 file: 1,511 effect tokens
emitted through the nanomusic encoder — bends with full curve
points, slides in every direction, palm-mute/hammer/vibrato flags,
natural + artificial harmonics.
Closes every remaining gap so GP7/GP8 songs expose the same surface as
the binary GP readers.
Track-level additions:
- <Transpose>/<Chromatic> + <Octave> → track.offset
- <RSE>/<ChannelStrip>/<Parameters> → channel volume + balance
(indices 11/12, float×16)
- <PlaybackState>Solo|Mute → track.isSolo / isMute
- <Lyrics>/<Line>/<Offset>/<Text> → parsed and attached to
song.lyrics (first non-empty track wins, padded to 5 lines to
match the GP3/4/5 layout)
MeasureHeader additions:
- <Directions>/<Target> → header.direction (Coda/Double Coda/
Segno/Segno Segno/Fine)
- <Directions>/<Jump> → header.fromDirection (full Da Capo / Dal
Segno / Dal Segno Segno / Da Coda family; GPIF's "DaSegno" typo is
translated back to the canonical "Dal Segno")
- <XProperties>/<XProperty id="1124139010"> → time_signature.beams
(default 8 → [2,2,2,2]; non-default values get split across 4 slots)
Beat-level additions:
- <Ottavia> 8va|8vb|15ma|15mb → beat.octave
- <Properties>/<Property name="Brush"/"PickStroke"/"Slapped"/"Popped"/
"Rasgueado"/"VibratoWTremBar"/"WhammyBar..."> → stroke, pickStroke,
slapEffect, hasRasgueado, vibrato, tremoloBar curve (origin, middle
value + up to two offsets, destination)
Tests: +200 assertions across every fixture covering channel fields
typing, offset type, lyrics well-formed when present, beat.octave enum
integrity, plus a direction regression that detects any fixture with
<Directions> to confirm we turn it into a DirectionSign. All 1,255
tests pass.
Verified end-to-end on a real 7-track 71-bar GP8 file: song.lyrics
parsed, track volumes/offsets populated (−12 semitone display
transpose, volume 12/16), 707 beat-effect tokens + 1,511 note-effect
tokens emitted through the encoder.
Systematic attribute-by-attribute comparison of GP5 vs GP7 output on
parallel fixtures (Effects/effects, Chords/chords, Harmonics/harmonics,
Tie/hammer) surfaced 58 fields where the binary readers populated
something the GP7 reader left at default. Addresses all gaps reachable
from score.gpif:
* MeasureHeader.start: first bar now starts at Duration.quarterTime
(960) and accumulates per bar length, matching GP3/4/5.
* Track.useRSE: flipped on whenever the track declares an <RSE>
element.
* Song.masterEffect.volume: defaulted to 100 (what Guitar Pro authors
ship) since GPIF leaves it in the binary BinaryStylesheet we don't
decode.
* Song.pageSetup templates: populated with GP5's uppercase
placeholders (%TITLE%, %SUBTITLE%, Words by %WORDS%, etc.) so the
encoder emits the identical tokens.
* Song.tempoName: cleared to '' to match GP3/4/5 readers (which
overwrite Song's default 'Moderate' from the binary stream).
* Chord diagrams: parse <Property name="DiagramCollection"> on each
track — name, first fret, per-string frets, fingering map (Thumb/
Index/Middle/Ring/Pinky/None → PyGuitarPro Fingering), and the
harmonic descriptor under <Chord> (KeyNote/BassNote → root/bass,
<Degree> elements → type/extension/fifth/ninth/eleventh + the
newFormat/show/sharp/add defaults GP5 uses).
* Marker color: parses optional <Section>/<Color> RGB triple.
* Beat.start: assembled post-hoc from measure_start + cumulative prior
beat duration ticks so downstream code that relies on absolute
positions keeps working.
* track.rse.instrument.instrument mirrors channel.instrument (GP5's
richer RSEInstrument is in BinaryStylesheet which is proprietary
binary — left as -1/'').
Remaining delta after this change is 12 items across four fixture
pairs, all of which are either (a) legitimate content differences
between the GP5 and GP7 test files or (b) GP5-only BinaryStylesheet
strings (rse.instrument.effect / effectCategory / soundBank / unknown)
that GPIF does not expose in XML. All 1,255 tests continue to pass.
Final parity pass. Adds fields that AlphaTab extracts but the earlier
phases skipped:
* Note: leftHandFinger / rightHandFinger from <LeftFingering> /
<RightFingering> siblings (P/I/M/A/C → thumb/index/middle/annular/
little).
* Beat: legato origin flag (<Legato origin="true"/>) propagated to
every constituent note as effect.hammer.
* MeasureHeader: anacrusis flag (<Anacrusis/>) stashed on
header._anacrusis for downstream consumers.
* Track: per-track <Automations> parsed and attached to the first
beat of each target bar. Tempo/Volume/Balance/Sound automations
populate the beat's MixTableChange (matching what GP3/4/5 readers
produce for embedded mix-table events).
Also replaces the file-level docstring with a complete Coverage
section listing every populated field on Song/Track/MeasureHeader/
Beat/Note, and a Deliberately Skipped section enumerating GP7-only
constructs with no PyGuitarPro representation (Fermatas per beat,
Hairpin, Ornaments, GrandStaff, NotationPatch, SustainPedalMarkers,
Partial capo, BackingTrack, SyncPoint automations, ChannelStrip EQ
params, per-note percussion articulation, BinaryStylesheet-only
metadata). Future Phase 6 would cover these if PyGuitarPro's model
grows to represent them.
`GP7File._apply_slide_flags` recognised six slide flag bits (0x01–0x20)
for shift, legato, out-down/up, and into-from-below/above. Bits 0x40
(PickSlideDown) and 0x80 (PickSlideUp), which Guitar Pro 7 introduced
for pick-slide notation, were silently discarded.
This change:
* adds `SlideType.pickSlideDown` and `SlideType.pickSlideUp` enum
members
* adds `_SLIDE_PICK_DOWN` (0x40) and `_SLIDE_PICK_UP` (0x80) constants
in `gp7.py`
* extends `_apply_slide_flags` to map the new bits
* adds a regression test on `pick-slide.gp` asserting all 13 pick-slide
notes are extracted (both directions present)
Cross-checked against alphaTab's `GpifParser.ts:2521-2544` which handles
the same 8 bits.
Closes #5
`GP7File._build_note` ignored `<Property name="LeftHandTapped" />`, a GP7-only fretting-hand articulation notated as a circled "T" above the note. Every occurrence was silently dropped on parse. This change: * adds `NoteEffect.leftHandTapped: bool = False` * handles the property in `_build_note`'s property loop * adds a regression test on `left-hand-tap.gp` (5 notes at frets 4/15) Cross-checked against alphaTab's `GpifParser.ts:2518-2520` which does the same assignment on the same XML property. Closes #6
`_build_note` ignored the `ConcertPitch` and `TransposedPitch` note
properties that GP7 stores for every note. While the pitch value itself
is redundant with (string, fret, tuning), the ``<Accidental>`` sub-element
decides **how the note is written** — E♭ vs D♯, both sounding the same
but notated differently. This visual-level info was silently dropped.
This change:
* adds `NoteAccidentalMode` enum (Default/ForceNone/ForceNatural/
ForceSharp/ForceDoubleSharp/ForceFlat/ForceDoubleFlat), mirroring
alphaTab's enum of the same name
* adds `Note.accidentalMode` field (defaults to `default`)
* handles `ConcertPitch` and `TransposedPitch` properties in
`_build_note`, with TransposedPitch taking precedence (same order
as alphaTab's `_parseNoteProperties`)
* introduces `_apply_concert_pitch` helper that reads ``<Pitch>`` →
``<Accidental>`` inner text and maps ``""|x|#|b|bb`` →
`NoteAccidentalMode`
* adds a regression test on `chords.gp` (fixture with explicit
flat/sharp/natural accidentals)
Cross-checked against alphaTab's `GpifParser.ts:2446-2457` and
`_parseConcertPitch` at line 2577.
Part of #9 (tracking).
Every `<Note>` in GP7/GP8 XML carries a sibling `<InstrumentArticulation>`
integer. On percussion tracks it identifies which drum or cymbal is
struck; on pitched tracks it is always 0. The reader skipped this tag,
so `note.percussionArticulation` was never populated — a writer would
have to guess.
This change:
* adds `Note.percussionArticulation: int = -1` (same default as
alphaTab's Note model)
* handles the `<InstrumentArticulation>` sibling tag in `_build_note`
* adds a regression test on `effects.gp` asserting every note's
field is populated after parse
Cross-checked against alphaTab's `GpifParser.ts:2332-2334`.
Part of #9.
GP7 stores right-hand tap at the note level via
`<Property name="Tapped"><Enable/></Property>`. AlphaTab collects these
in a note-id map during note parsing and later sets `beat.tap = true`
on the containing beat. `_build_note` in gp7.py ignored the property,
so the articulation was silently dropped on parse.
This change:
* handles `Tapped` in `_build_note`'s property loop
* sets `beat.effect.slapEffect = SlapEffect.tapping` on the containing
beat — the closest pre-existing concept in the PyGuitarPro model
(matches how GP3/4/5 encode tap via SlapEffect.tapping at beat
level)
* preserves any stronger beat-level slap/pop that was already set
* adds a regression test on `effects.gp` asserting at least one beat
ends up with `SlapEffect.tapping`
Cross-checked against alphaTab's `GpifParser.ts:2390-2392` (stores in
`_tappedNotes` map) and `GpifParser.ts:2790-2792` (propagates to
`beat.tap = true`).
Part of #9.
The `<Fadding>` element handler in `_apply_beat_effects` only checked
for "FadeIn" and silently dropped "FadeOut" and "VolumeSwell" — even
though the comment above the handler documented all three. GP7 / GP8
use all three variants.
This change:
* adds `FadeType` enum mirroring alphaTab's `FadeType` (none / fadeIn
/ fadeOut / volumeSwell)
* adds `BeatEffect.fade: FadeType = FadeType.none` as the canonical
field for GP7+
* retains `BeatEffect.fadeIn: bool` for GP3/4/5 compatibility and
keeps it aligned with `fade` when parsing GP7
* maps all three `<Fadding>` text values; unknown values leave the
default
* adds a regression test asserting `effects.gp` produces a beat
with `fade == FadeType.fadeIn` and `fadeIn == True` in sync
Cross-checked against alphaTab's `GpifParser.ts` where `<Fadding>`
maps to `Beat.fade` with `FadeType` identical to this enum.
Part of #9.
GP7 adds a dedicated `<Fermatas>` block under each `<MasterBar>`
containing one or more `<Fermata>` entries with Type (Short/Medium/
Long), Offset (quarter-note fraction from bar start) and Length. The
reader previously ignored them; `MeasureHeader.fermatas` was never
populated.
This change:
* adds `FermataType` enum (`short` / `medium` / `long`) mirroring
alphaTab's `FermataType`
* adds `Fermata` dataclass with `type`, `length` (float) and
`offset` (ticks from bar start)
* adds `MeasureHeader.fermatas: list[Fermata]` ordered by offset
* parses each `<Fermata>`, converting `<Offset>num/den</Offset>`
with the same formula alphaTab uses: ticks = num/den * quarterTime
* sorts the list by offset so iterators see a deterministic order
* adds a regression test on `fermata.gp` asserting all three
`FermataType` values are extracted and that the 0/1 offset maps
to tick 0
Cross-checked against alphaTab's `GpifParser.ts:1530-1572`.
Part of #9.
GP7 marks ornaments (Turn / Inverted Turn / Upper & Lower Mordent) as a
sibling element of `<Note>`. `_build_note` ignored it, so `note.ornament`
was never populated.
This change:
* adds `NoteOrnament` enum (`none` / `invertedTurn` / `turn` /
`upperMordent` / `lowerMordent`) mirroring alphaTab's enum
* adds `Note.ornament: NoteOrnament = NoteOrnament.none` field
* handles the `<Ornament>` sibling tag with the same text → enum
mapping alphaTab uses
* adds `ornaments.gp` test fixture (from alphaTab visual-tests
corpus) containing one instance of each ornament type
* adds a regression test asserting all four ornament values are
extracted
Cross-checked against alphaTab's `GpifParser.ts:2335-2350`.
Part of #9.
The `<Vibrato>` note sibling element encodes one of two intensity
variants — `Slight` or `Wide`. The reader previously mapped both to
a boolean `NoteEffect.vibrato = True`, losing the distinction.
This change:
* adds `VibratoType` enum (`none` / `slight` / `wide`) mirroring
alphaTab's `VibratoType`
* adds `NoteEffect.vibratoType: VibratoType = VibratoType.none` as
the canonical GP7 field
* keeps `NoteEffect.vibrato: bool` for GP3/4/5 compatibility and
sets it whenever `vibratoType` is non-`none`
* adds a regression test on `tremolo-vibrato.gp` asserting both
Slight and Wide values appear and that the legacy bool stays aligned
Cross-checked against alphaTab's `GpifParser.ts:2284-2293`.
Part of #9.
The `<Accent>` bit-field handler silently skipped bit ``0x10`` with a
comment pointing at ``letRing`` as a "closest match" — a lossy
translation that collapsed the Tenuto articulation into an unrelated
effect. AlphaTab maps ``0x10`` to ``AccentuationType.Tenuto``, a peer
of Staccato / Heavy / Normal.
This change:
* adds `NoteEffect.tenuto: bool = False`
* adds `_ACCENT_TENUTO = 0x10` constant
* handles the bit in the `<Accent>` note handler alongside the
other three accent bits
* adds a regression test on `accentuations.gp` asserting the new
field defaults to False everywhere and that existing accent bits
keep their semantics
The public GP7 test corpus does not contain Tenuto examples, so the
test locks the default behaviour rather than the parsed value.
Cross-checked against alphaTab's `GpifParser.ts:2264-2278`.
Part of #9.
AlphaTab stores the stem / beam direction preference on every beat via
two sibling elements: `<TransposedPitchStemOrientation>` (initial) and
`<UserTransposedPitchStemOrientation>` (user override). Both map to
`beat.preferredBeamDirection` with values `Upward` or `Downward`. The
reader ignored both, so `beat.display.beamDirection` always stayed at
`VoiceDirection.none` regardless of file content.
This change:
* handles both tags in `_apply_beat_effects`, mapping `Upward` →
`VoiceDirection.up` and `Downward` → `VoiceDirection.down`
* processes them in order so the user override (if present) wins
* adds a regression test on `beaming-mode.gp` asserting both
directions are extracted
Cross-checked against alphaTab's `GpifParser.ts:1758-1767` and
`:1891-1900`.
Part of #9.
GPIF marks "repeat the previous bar" shorthand with a `<SimileMark>`
child of `<Bar>` taking one of three values: `Simple`, `FirstOfDouble`,
`SecondOfDouble`. The reader ignored the element, leaving
`measure.simileMark` at the default `none` for every file.
This change:
* adds `SimileMark` enum (`none` / `simple` / `firstOfDouble` /
`secondOfDouble`) mirroring alphaTab's enum
* adds `Measure.simileMark: SimileMark = SimileMark.none`
* handles `<SimileMark>` inside `_build_measure` with the same
text → enum mapping alphaTab uses
* adds a regression test on `simile-mark.gp` asserting all three
values are extracted
Cross-checked against alphaTab's `GpifParser.ts:1630-1641`.
Part of #9.
GPIF marks per-note "display the string number" requests via the
`ShowStringNumber` property with an `<Enable/>` child. The reader
ignored it; `note.showStringNumber` was never populated.
This change:
* adds `Note.showStringNumber: bool = False`
* handles `ShowStringNumber` in `_build_note`'s property loop —
presence of the `<Enable>` child alone sets the flag, matching
alphaTab
* adds a regression test asserting the field defaults to False on
every note of an existing fixture (the alphaTab GP7/GP8 corpus
does not contain a fixture that exercises this property)
Cross-checked against alphaTab's `GpifParser.ts:2373-2377`.
Part of #9.
…h / Timer
Six beat-level sibling elements of `<Beat>` that alphaTab stores but
the reader silently discarded:
* `<Hairpin>Crescendo|Decrescendo</Hairpin>` — crescendo / decrescendo
annotation
* `<Slashed/>` — rhythmic-slash notation marker
* `<DeadSlapped/>` — dead body-slap marker
* `<Golpe>Finger|Thumb</Golpe>` — flamenco body tap
* `<Wah>Open|Closed</Wah>` — GP7 pedal state (distinct from GP5
WahEffect)
* `<Timer>N</Timer>` — backing-track millisecond timestamp
This change:
* adds `CrescendoType`, `GolpeType`, `WahPedal` enums mirroring
alphaTab
* extends `BeatEffect` with `crescendo`, `slashed`, `deadSlapped`,
`golpe`, `wahPedal`
* adds `Beat.timer: Optional[int] = None` for backing-track sync
* handles all six tags inside `_apply_beat_effects`
* adds `tests/gp7/timer.gp` fixture (from alphaTab's corpus) and
two regression tests (`effects.gp` for wahPedal, `timer.gp` for
timer values)
Cross-checked against alphaTab's `GpifParser.ts:1732-1907`.
Part of #9.
GPIF stores a shared "Words & Music" credit in a `<WordsAndMusic>` element at the score level. The reader extracted `<Words>` and `<Music>` but ignored `<WordsAndMusic>`, so a file with only the combined credit (e.g. `lyrics-null.gp`) lost the composer information entirely. AlphaTab's behaviour is to fall back to `<WordsAndMusic>` whenever the dedicated field is empty; this change mirrors the same precedence. Added fixture `tests/gp7/lyrics-null.gp` from the alphaTab test corpus and a regression test asserting both `words` and `music` reflect the combined credit. Cross-checked against alphaTab's `GpifParser.ts:297-307`. Part of #9.
GPIF's GP6-era encoding of percussion articulations uses two separate
note properties — Element (drumkit voice: kick, snare, hihat, …) and
Variation (0=default hit, 1=rim/edge/choke, 2=bell/side-stick) — that
together index a 17x3 MIDI articulation table.
PyGuitarPro's reader only handled the sibling <InstrumentArticulation>
integer (GP7+ encoding), so a GP6-style note carrying both properties
would silently fall back to the sibling's value (0 for "default hit")
instead of being decoded through the table. For a snare rim-shot
(Element=1, Variation=1) this means the rim-shot articulation (MIDI 91)
was lost and rendered as a plain kick/snare hit.
This change ports alphaTab's PercussionMapper table verbatim and applies
the mapping after the sibling loop so its result takes precedence over
<InstrumentArticulation>, matching alphaTab's documented behaviour.
- Reader (src/guitarpro/gp7.py):
- _GP6_PERCUSSION_ARTICULATION: 17x3 table ported verbatim from
alphaTab's PercussionMapper._gp6ElementAndVariationToArticulation.
- _gp6_percussion_articulation() helper (out-of-range handling
identical to alphaTab).
- _build_note: Element / Variation property handlers; post-sibling
application overrides <InstrumentArticulation> when both set.
- Fixture (tests/gp7/element-variation.gp): derived from AT's effects.gp
(MPL-2.0) with Element=1 / Variation=1 injected into the first note's
<Properties>. Needed because no .gp fixture in alphaTab's GP7/GP8
corpus uses this legacy encoding — all current exporters emit only
<InstrumentArticulation>. Fixture prepares the path for future GP6
support (BCFZ-compressed files share the same parser).
- Regression test (tests/test_gp7.py): asserts the mapped value (91 =
snare rim shot) appears on the patched note.
pytest tests/ — 1334 passed, 2 skipped.
GPIF stores a list of <Sound> entries per track — each with a Name,
Path (soundbank location), Role (Factory/User/Auto), and a MIDI block
(Program + 14-bit Bank select). The reader only captured the first
sound's program/bank onto track.channel, discarding:
- every sound beyond the first (tracks with multiple sounds mean to
switch between them: e.g. clean + overdrive, or bank variants);
- Name / Path / Role metadata on every sound;
- the parallel "program-change" / "bank-change" semantics that later
automations reference by sound index.
For a track like canon-audio-track.gp / Harmonizer (4 sounds), PyGuitarPro
currently surfaces only 1/4 of the sound data.
This change ports alphaTab's GpifSound one-for-one (Name, Path, Role,
program, combined-bank) and attaches the full list to Track.sounds,
while still mirroring the first sound's MIDI program and bank onto
track.channel for back-compat with code that only consults MidiChannel.
- Model (src/guitarpro/models.py):
- new @hashableAttrs GpifSound(name, path, role, program, bank).
- Track.sounds: list[GpifSound] = [] (empty for gp3/4/5; populated
by the GP7/GP8 reader).
- Reader (src/guitarpro/gp7.py):
- _read_sound() helper mirrors alphaTab's GpifParser._parseSound +
_parseSoundMidi. Bank combined as ((MSB & 0x7f) << 7) | LSB —
MIDI Bank Select encoding.
- _read_track: iterate all <Sound> children instead of reading just
the first; populate Track.sounds; mirror sounds[0].program/bank
onto track.channel.
- Test fixture (tests/gp7/bank-change.gp): added from alphaTab's corpus
(MPL-2.0) — one track, two sounds with MSB 0 and 2 exercising the
14-bit bank math.
- Regression tests (tests/test_gp7.py):
- program-change fixture → 2 sounds kept, programs 25 + 29, roles
Factory + User, names preserved, channel.instrument = 25.
- bank-change fixture → banks = [0, 256] (2<<7) validates MSB/LSB
composition.
pytest tests/ — 1357 passed, 2 skipped.
…trument)
Four Track-level GPIF elements the reader was dropping, all of which
need to survive on the model for a round-trip writer to emit them back:
- <SystemsLayout> — bars-per-system list (present on every track).
- <SystemsDefautLayout> — fallback bars-per-system (GPIF typo
"Defaut" preserved verbatim; documented as-is in alphaTab).
- <NotationPatch><LineCount> — staff line count (1 for percussion
cue line, 5 for standard, 4 for bass).
- <Instrument ref="..."> — soundbank id (s-gtr6, e-bass4, drmkt,
or a -gs / GrandStaff variant). GP6-era encoding — 0 occurrences
in the GP7/GP8 test corpus but a live alphaTab code path.
- Model (src/guitarpro/models.py): four new Track fields:
systemsLayout, defaultSystemsLayout (default=4), staffLineCount
(default=5), instrumentRef (default="").
- Reader (src/guitarpro/gp7.py): branches for each element in
_read_track. SystemsLayout parsed via _split_ints. NotationPatch /
Instrument handlers follow alphaTab's defaults.
- Test fixture (tests/gp7/canon-audio-track.gp): added from alphaTab's
GP8 corpus (MPL-2.0). Exercises <NotationPatch> (2 tracks) plus
4-sound track coverage for the earlier Sounds PR.
- Regression test (tests/test_gp7.py): asserts effects.gp's authored
layout [3,4,6,4,4,3,4,4] with defaultSystemsLayout=3. Non-default
values guarantee the reader is actually parsing the XML, not
returning the field default.
pytest tests/ — 1379 passed, 2 skipped.
Three track-level GPIF elements that alphaTab parses and PyGuitarPro
was silently discarding:
- <Property name="Tuning"><Label> — human-readable tuning name
("Drop D", "DADGAD"). AT: staff.stringTuning.name.
- <PartSounding><TranspositionPitch> — semitone offset between
written and sounding pitch (Bb instrument = -2). AT:
staff.displayTranspositionPitch.
- <PartSounding><NominalKey> — written key for transposing
instruments ("Bb", "Eb").
No .gp file in alphaTab's GP7/GP8 corpus uses either <Label> or
<PartSounding>, but both are live code paths in alphaTab. Needed for
round-trip fidelity when a file does carry them (transposing horn
parts, custom tunings), and prepares the parser for GP6 which uses
the same GPIF schema.
- Model (src/guitarpro/models.py): three new Track fields with the
defaults AT uses (tuningName="", transpositionPitch=0, nominalKey="").
- Reader (src/guitarpro/gp7.py):
- _read_track_staves: capture <Label> inside Tuning property.
- _read_track: new <PartSounding> branch mirrors
AT's _parsePartSounding.
- Test fixture (tests/gp7/tuning-sounding.gp): derived from effects.gp
by injecting <Label>Drop D</Label> into the Tuning property and a
<PartSounding> block with TranspositionPitch=-2, NominalKey=Bb.
- Regression test (tests/test_gp7.py): end-to-end extraction of all
three fields.
pytest tests/ — 1401 passed, 2 skipped.
Both Phase-5-era tests were silently skipping on every run, masking
their coverage:
* test_any_direction_in_fixture_is_parsed grepped for '<Directions>'
in fx.read_bytes() — but .gp files are ZIP archives (PKZip), so the
raw bytes never contained XML tags. The test always fell through
to pytest.skip regardless of what fixtures existed. Fix: open the
archive and read Content/score.gpif. Added directions.gp from the
alphaTab GP8 corpus (MPL-2.0) so the test now has a real fixture
to match against.
* test_drumkit_is_percussion required a drums.gp fixture that never
existed in the repo. Rewired to canon-audio-track.gp (added in the
Track layout PR) which carries dedicated Drums and Percussion
tracks — gives the test real coverage of isPercussionTrack.
pytest tests/ — 1424 passed, 0 skipped (was 1423 passed, 2 skipped).
Two cleanups with no behavioural change: 1. The gp7.py module docstring's "Deliberately skipped" list was frozen at the state the reader was in before the parity work. Many of those items have since been implemented (fermatas, hairpins, ornaments, percussion articulation, NotationPatch, SystemsLayout, etc.). Rewrote it to reflect current coverage and the honest list of remaining gaps (Octave/Tone, Rasgueado enum, FreeTime, XProperties, BackingTrack, SustainPedal, feedback harmonic — tracked under issue #9). 2. Docstring + code comments mislabeled GPIF-format features as "GP7+". GPIF XML is the shared schema for GP6, GP7 and GP8; calling a GPIF element "GP7+" wrongly implies GP6 lacks it. Relabeled to "GPIF" for format-level features (Fermata, SimileMark, NoteOrnament, NoteAccidentalMode, VibratoType, FadeType, GolpeType, WahPedal, tenuto, leftHandTapped, percussionArticulation, etc.). Kept "GP7/GP8" where it really means "ZIP-containerised" (current PGP reader scope, which excludes GP6's BCFZ container). Also expanded the module docstring's container-versions section to document GP6/7/8 explicitly. No functional changes; 1424 passed, 0 skipped.
GPIF encodes rasgueado strokes with a specific right-hand fingering
pattern token: "ii_1" (single-finger index-index), "mii_2" (anapaest
m-i-i), "peami_1" (five-finger sweep), etc. alphaTab's Rasgueado enum
has 18 values. PyGuitarPro's reader collapsed every pattern into the
cross-version boolean BeatEffect.hasRasgueado, dropping the variant.
This change introduces the full enum and keeps hasRasgueado populated
alongside, so callers that only consult the boolean keep working.
- Model (src/guitarpro/models.py):
- new RasgueadoType enum with 18 variants (names mirror alphaTab).
- BeatEffect.rasgueado: RasgueadoType = RasgueadoType.none.
- hasRasgueado doc updated to reflect its cross-version role.
- RasgueadoType exported in __all__.
- Reader (src/guitarpro/gp7.py):
- new _RASGUEADO_MAP table (GPIF token → RasgueadoType name).
- _apply_beat_properties Rasgueado branch: set enum when token is
recognised; keep hasRasgueado=True unconditionally.
- Test fixture (tests/gp7/rasgueado.gp): synthesised from effects.gp
with three variant tokens injected on successive beats (ii_1,
mii_2, peami_1). Covers single-token, triplet-vs-anapaest
disambiguation, and long multi-finger patterns.
- Regression test (tests/test_gp7.py): all three variants round-trip
into RasgueadoType; hasRasgueado stays True.
pytest tests/ — 1468 passed, 0 skipped.
GPIF marks a cadenza / rubato / out-of-tempo bar with a bare <FreeTime/> element on the MasterBar. AlphaTab parses it into masterBar.isFreeTime; PyGuitarPro's reader silently dropped it, so renderers lost the free-time sign that should replace the time signature for that bar. - Model (src/guitarpro/models.py): MeasureHeader.isFreeTime bool (default False). GP3/4/5 binary formats have no equivalent, so the flag only gets set by the GPIF reader. - Reader (src/guitarpro/gp7.py): one-line presence check for <FreeTime/> in _build_measure_header, mirroring AT's line 1364-66. - Test fixture (tests/gp7/free-time.gp): synthesised from effects.gp with <FreeTime/> injected into the first MasterBar. 0 occurrences in the alphaTab GP7/GP8 corpus — a live alphaTab code path. - Regression test (tests/test_gp7.py): first bar carries the flag, later bars don't. pytest tests/ — 1490 passed, 0 skipped.
GPIF encodes beat-level barre markers with two <Properties> entries: - <BarreFret><Fret>N</Fret></BarreFret> — fret number - <BarreString><String>0|1</String></BarreString> — 0=Full, 1=Half GP3/4/5 only carry barre info as part of the chord diagram (Chord.barre), not per-beat. PyGuitarPro's reader silently dropped the GPIF beat-level barre, so notation tools that mark a transient barre (typical in classical guitar) lost that information. - Model (src/guitarpro/models.py): - new BarreShape enum (none / full / half). Mirrors alphaTab. - BeatEffect.barreFret: int = 0. - BeatEffect.barreShape: BarreShape = BarreShape.none. - BarreShape exported in __all__. - Reader (src/guitarpro/gp7.py): BarreFret / BarreString branches in _apply_beat_properties, mirroring alphaTab GpifParser:2135-2146. - Test fixture (tests/gp7/barre.gp): synthesised from effects.gp — two beats with (fret=5, full) and (fret=7, half) markers. 0 occurrences of either Property in the alphaTab GP7/GP8 corpus. - Regression test (tests/test_gp7.py): both markers round-trip onto BeatEffect.barreFret/barreShape. pytest tests/ — 1512 passed, 0 skipped.
GPIF attaches lyric syllables to individual beats via a <Lyrics><Line>syllable</Line>…</Lyrics> wrapper. Multiple <Line> children stack verses on the same beat (e.g. 2nd-voice / chorus lyrics). AlphaTab exposes this as beat.lyrics: string[]; PyGuitarPro's reader silently dropped the whole element, so songbooks with per-beat syllables lost all lyric assignments on a parse-write round trip. GP3/4/5 attach lyrics to the track via a LyricLine collection (see LyricLine), not per beat — those readers leave Beat.lyrics empty, so the new field is GPIF-only. - Model (src/guitarpro/models.py): Beat.lyrics: list[str] (default []). - Reader (src/guitarpro/gp7.py): append-verbatim pass over <Line> children, with empty <Line/> → "" so verse alignment is preserved. Mirrors alphaTab's GpifParser._parseBeatLyrics. - Regression test (tests/test_gp7.py): uses the natural beat-lyrics.gp fixture (MPL-2.0 from alphaTab), asserts the six-beat phrase "This is a test file for lyrics" is captured. pytest tests/ — 1513 passed, 0 skipped.
GPIF stores extended/rendering overrides in <XProperties> blocks keyed
by magic integer ids. alphaTab parses several of these into Beat / Bar
/ MasterBar fields; PyGuitarPro only read id 1124139010 (time-signature
beam pattern) and dropped everything else.
This PR adds the missing six:
MasterBar
1124073984 → MeasureHeader.displayScale (Double, default 1.0)
1124139264..295 → MeasureHeader.beamingRuleGroups (list[int]):
32-slot sparse array of per-group beat counts;
stored with trailing zeros trimmed off.
Bar
1124139520 → Measure.displayScale (Double, default 1.0)
Beat
1124204546 → Beat.beamingMode = ForceMergeWithNext (1)
/ ForceSplitToNext (2)
1124204552 → Beat.beamingMode = ForceSplitOnSecondaryToNext (1)
iff id 1124204546 didn't already request a full split
1124204545 → Beat.invertBeamDirection (bool, Int==1)
687935489 → Beat.brushDuration (int, MIDI ticks)
Also fixes an ElementTree gotcha: the old `x.find("Double") or
x.find("Float")` pattern short-circuits to the second branch when
<Double> has no child elements (Element.__bool__ returns False when
there are no children, regardless of text). Replaced with explicit
`is not None` checks.
- Model (src/guitarpro/models.py):
- new BeatBeamingMode enum (auto / forceMergeWithNext /
forceSplitToNext / forceSplitOnSecondaryToNext).
- Beat.beamingMode, invertBeamDirection, brushDuration.
- Measure.displayScale.
- MeasureHeader.displayScale, beamingRuleDuration,
beamingRuleGroups (list[int]).
- BeatBeamingMode exported in __all__.
- Reader (src/guitarpro/gp7.py):
- MasterBar XProperties loop: parse displayScale + beamingRuleGroups
collection, keep legacy 1124139010 → TimeSignature.beams decode.
- Bar XProperties loop: parse displayScale.
- Beat XProperties loop: parse beamingMode (with the secondary-split
precedence rule), invertBeamDirection, brushDuration.
- Test fixtures:
- tests/gp7/xproperties.gp (synthetic, patched effects.gp): the five
beat/bar/header ids that have 0 corpus occurrences.
- Natural coverage: accentuations.gp carries 530 XProperty entries
including the standard 4/4 beamingRuleGroups [2,2,2,2].
- Regression tests (tests/test_gp7.py):
- accentuations.gp → beamingRuleGroups == [2,2,2,2].
- xproperties.gp → all five synthetic overrides round-trip.
pytest tests/ — 1536 passed, 0 skipped.
GPIF expresses piano / keyboard sustain-pedal actions as track-level
<Automation><Type>SustainPedal</Type> entries. Each carries:
- Bar index
- Position (ratio within the bar, 0.0..1.0)
- Value = "<legacy_float> <reference>" where reference encodes
1 → Down, 2 → Hold, 3 → Up
AlphaTab buckets them per-bar onto Bar.sustainPedals: SustainPedalMarker[].
PyGuitarPro's reader fed every Automation through _attach_track_automations
into MixTableChange — which has no sustain-pedal concept and silently
dropped the action. Piano scores with pedal rendering lost all pedal
positioning on a parse-write round trip.
- Model (src/guitarpro/models.py):
- new SustainPedalMarkerType enum (down / hold / up).
- new SustainPedalMarker dataclass (ratioPosition + type).
- Measure.sustainPedals: list[SustainPedalMarker] (forward-reference
string to keep the class-definition order clean).
- Both classes exported in __all__.
- Reader (src/guitarpro/gp7.py):
- _attach_track_automations now skips "SustainPedal" entries so they
don't leak into a beat.effect.mixTableChange.
- new _attach_sustain_pedals static method: filter SustainPedal
automations, decode the two-token Value, map reference → enum,
append SustainPedalMarker to the target measure's list. Mirrors
alphaTab's GpifParser:546-569 exactly (including the 1/2/3 → enum
mapping and the legacy single-float fallback to reference=1).
- Test fixture (tests/gp7/sustain-pedal.gp): synthesised from
effects.gp — one track, three SustainPedal automations on bar 0
exercising all three reference values (1/2/3).
- Regression test (tests/test_gp7.py): all three markers round-trip
with their positions, types land correctly, and the same bar's
first beat carries no stray MixTableChange from the pedal events.
pytest tests/ — 1558 passed, 0 skipped.
GPIF links an external audio file to a score via two pieces:
1. A top-level <BackingTrack> element (sibling of <Score>) holding
the audio metadata — Name, ShortName, Enabled flag, Source type
("Local" / remote), AssetId (pointer into the bundled assets),
FramePadding (offset of the audio relative to bar 1).
2. Master-track <Automation><Type>SyncPoint> entries whose <Value>
is structured (BarIndex + BarOccurrence + FrameOffset in audio
frames at the alphaTab 44100 Hz sample rate). These tie bar
positions in the score to absolute timestamps in the audio.
Before this change PyGuitarPro's reader dropped both entirely: any
audio-track score lost its backing-track reference AND all of its
sync information on a parse-write round trip. AlphaTab handles the
same subsystem in _parseBackingTrackNode + the SyncPoint automation
branch.
This change ports the minimal AT-parity representation: a presence
object on Song plus a per-MeasureHeader SyncPointData collection.
The raw audio bytes live in a separate <Asset> subtree inside the
ZIP which we deliberately don't decode (it would drag in a proper
audio pipeline); AssetId is preserved so a future writer can pair
the BackingTrack with its Asset entry.
- Model (src/guitarpro/models.py):
- new BackingTrack dataclass: name, shortName, paddingMs, assetId.
- new SyncPointData dataclass: barIndex, barOccurrence,
millisecondOffset (pre-adjusted for FramePadding as AT does).
- Song.backingTrack: Optional[BackingTrack] (None when absent).
- MeasureHeader.syncPoints: list[SyncPointData].
- Both new classes exported in __all__.
- Reader (src/guitarpro/gp7.py):
- _BACKING_TRACK_SAMPLE_RATE = 44100.0 (AT's hard-coded value).
- _read_backing_track(): parse top-level <BackingTrack>, only set
song.backingTrack when Enabled=true AND Source=Local (matches
AT's restriction — remote / YouTube sources aren't decoded).
Frame padding → ms cached on self for the SyncPoint pass.
- _read_master_track(): now collects EVERY master-track automation
into song._masterTrackAutomations, including SyncPoint entries
(value parsed from the structured <BarIndex>/<BarOccurrence>/
<FrameOffset> sub-tree, frame offset converted to ms).
- _attach_sync_points(): filter SyncPoint entries, subtract padding,
append SyncPointData to the target MeasureHeader. Mirrors AT's
post-process pass on line 2893-2908 of GpifParser.ts.
- Regression test (tests/test_gp7.py): uses the natural
canon-audio-track.gp fixture which carries Name="Audio Track",
ShortName="a.track", FramePadding=-72900 frames (→ -1653.06 ms)
and 16 sync points. All fields round-trip with padding-adjusted
timings matching AT's semantics to within 1e-6 ms.
pytest tests/ — 1559 passed, 0 skipped.
Two small AT-parity gaps the last audit missed:
1. TripletFeel — AT has 7 values (NoTripletFeel / Triplet8th /
Triplet16th / Dotted8th / Dotted16th / Scottish8th / Scottish16th),
PGP had 3 (none / eighth / sixteenth). The 4 missing "dotted" and
"scottish" variants silently collapsed to TripletFeel.none — a
Scottish-snap-authored bar would round-trip as straight time.
2. DoubleWhole duration — AT's Duration enum has DoubleWhole (breve,
two whole notes long) encoded as the sentinel -2 in GPIF. PGP's
_DURATION_MAP stopped at "Whole", so any beat in a breve rhythm
got the default Quarter duration on read.
Changes
-------
- Model (src/guitarpro/models.py):
- TripletFeel: added dottedEighth (3), dottedSixteenth (4),
scottishEighth (5), scottishSixteenth (6). Mirrors AT's enum.
- Duration: added doubleWhole = -2 class attribute; Duration.time
now returns quarterTime * 8 for the breve sentinel and falls
back to the existing power-of-two formula otherwise.
- Reader (src/guitarpro/gp7.py):
- _DURATION_MAP: new "DoubleWhole" → -2 entry.
- _build_measure_header: TripletFeel branch now uses a 7-entry
lookup table (AT parity) instead of a 2-case if/elif.
- Test fixture (tests/gp7/triplet-feel.gp): synthesised from
effects.gp with six TripletFeel variants injected on bars 1-6
plus a DoubleWhole NoteValue injected on the first Rhythm.
0 occurrences of either pattern in the alphaTab GP7/GP8 corpus.
- Regression tests (tests/test_gp7.py):
- All 7 TripletFeel values round-trip correctly.
- First beat's duration.value == -2 and duration.time ==
quarterTime * 8.
- Relaxed the existing duration-range assertion to include -2 so
the breve fixture doesn't trip the generic Phase3 smoke test.
pytest tests/ — 1581 passed, 0 skipped.
…sible Three small AT-parity gaps the last audit caught: 1. Score-level <ScoreSystemsLayout> and <ScoreSystemsDefaultLayout> (children of <Score>) control the score-wide bars-per-system layout. Distinct from <SystemsLayout> on <Track> (captured in PR #25). PyGuitarPro dropped both; alphaTab parses them onto score.systemsLayout / score.defaultSystemsLayout. 2. Chord diagram show flags <Property name="ShowName|ShowDiagram| ShowFingering" value="true|false"/> on <Item><Diagram>. AlphaTab stores three booleans on `Chord`; PyGuitarPro had only `show` and always forced it to True regardless of the XML value. 3. <Automation><Visible>false</Visible> — the hide-on-score flag alphaTab preserves on every automation. PyGuitarPro's dict-based automation cache now includes it so a future writer can round-trip. - Model (src/guitarpro/models.py): - Song.systemsLayout: list[int] = [] (empty → use defaults). - Song.defaultSystemsLayout: int = 4 (AT default). - Chord.showName: bool = True. - Chord.showFingering: bool = True. - existing Chord.show now reflects ShowDiagram authentically (not force-True on every chord). - Reader (src/guitarpro/gp7.py): - _read_score_info: parse <ScoreSystemsLayout> + default. - _read_chord_diagrams: read ShowName / ShowDiagram / ShowFingering from <Diagram><Property>. - _read_automations + _read_master_track: capture <Visible> into the per-automation dict (default True when missing). - Test fixtures: - Natural: effects.gp already carries <ScoreSystemsLayout> "3 3 3 3 3 3 3 3 3 3 2" + <ScoreSystemsDefaultLayout>3 — no new fixture needed for the layout assertion. - Natural: chords.gp ships all three Show flags set to true on every chord — we assert that they're actively read (not defaulted). - Synthetic: tests/gp7/automation-invisible.gp (patched effects.gp with <Visible>false</Visible> on the first master automation, existing <Visible>true</Visible> first removed so find() returns our override). - Regression tests (tests/test_gp7.py): one per gap. Scoreflats on the natural corpus; automation-invisible on synthetic. pytest tests/ — 1605 passed, 0 skipped.
The last two audits found gaps the per-PR test strategy didn't catch:
Rasgueado's 18-variant enum, Octave/Tone GP6 pitch encoding,
FeedbackHarmonic, the full TripletFeel enum, ScoreSystemsLayout. All
of them existed because there was no systematic way to detect "AT
parses a GPIF element and gp7.py doesn't". This test adds that gate.
Approach:
* tests/gp7_at_case_labels.txt is a frozen snapshot of every GPIF
element name alphaTab's switch statements match on — taken
from packages/alphatab/src/importer/GpifParser.ts. 270 labels.
* tests/test_gp7_at_parity_gate.py asserts every label in the
snapshot is referenced as a quoted string in gp7.py. Labels we
intentionally don't handle live in a KNOWN_SKIPPED allowlist
inside the test with a short rationale per entry.
* A second test guards against stale KNOWN_SKIPPED entries (alphaTab
could remove a case upstream).
* regenerate_snapshot() documents the one-liner for maintainers
updating against a new alphaTab release.
When the gate fires on a missing label: either add the handler or
add the label to KNOWN_SKIPPED with justification. Either way, the
gap becomes visible instead of shipping silently.
Also makes the <GraceNotes>OnBeat|BeforeBeat</GraceNotes> branch
check "BeforeBeat" explicitly (was previously the non-OnBeat fallthrough)
so the literal shows up in gp7.py — otherwise BeforeBeat would need a
KNOWN_SKIPPED entry despite being actively handled.
Current state: 270 AT labels, 15 in KNOWN_SKIPPED (2 AT-no-ops, 3
BackingTrack audio pipeline, 10 NotationPatch render-metadata
subtree). The other 255 are handled.
pytest tests/ — 1607 passed, 0 skipped.
Closes the last deferred group from the AT parity audit — the
drum-kit articulation tree PGP was dropping:
<InstrumentSet>
<Type>drumKit</Type>
<LineCount>5</LineCount>
<Elements>
<Element>
<Name>Snare</Name>
<Articulations>
<Articulation>
<Name>Rim</Name>
<InputMidiNumbers>91</InputMidiNumbers>
<OutputMidiNumber>38</OutputMidiNumber>
<Noteheads>noteheadDiamondWhite noteheadDiamondWhite noteheadDiamondWhite</Noteheads>
<TechniqueSymbol>pictEdgeOfCymbal</TechniqueSymbol>
<TechniquePlacement>above</TechniquePlacement>
<StaffLine>3</StaffLine>
</Articulation>
...
AlphaTab stores these as track.percussionArticulations so its renderer
can draw custom notehead glyphs per drum strike type (hit / rim / side
stick / edge / choke …). Without the table, PGP's future writer would
emit a blank drum staff — the notehead shapes that tell performers
which strike type to use would be lost on round-trip. That's why the
user specifically flagged render-only data as round-trip-critical.
Also wires up <NotationPatch><Elements>, which is GPIF's "patch"
channel for overriding a previously-registered articulation's
staffLine when the user has relocated a drum on the staff. Mirrors
alphaTab's lookup-by-"elementType.name" exactly.
- Model (src/guitarpro/models.py):
- new MusicFontSymbol enum with 27 values (21 notehead glyphs + 5
technique glyphs + none). Member names match the GPIF tokens
verbatim so the reader can do `MusicFontSymbol[token]` with a
KeyError fallback.
- new TechniqueSymbolPlacement enum (outside / inside / above / below).
- new PercussionArticulation dataclass (9 fields, matches AT 1:1).
- Track.percussionArticulations: list[PercussionArticulation]
(empty for pitched tracks).
- All three new types exported in __all__.
- Reader (src/guitarpro/gp7.py):
- _music_font_symbol() / _technique_symbol_placement() helpers that
dispatch via enum member lookup + None fallback.
- _parse_articulation() — mirrors AT's _parseArticulation including
the "noteheadNone fallback to default" logic for the half/whole
entries.
- _parse_articulation_elements() — dispatches InstrumentSet (fresh
append) vs NotationPatch (staffLine override) modes by the
`is_instrument_set` flag.
- Hooked into _read_track: <InstrumentSet><Elements>/<LineCount> and
<NotationPatch><Elements> are now read.
- self._articulation_by_name: name-keyed lookup so NotationPatch
can find the InstrumentSet entry to patch.
- Coverage gate (tests/test_gp7_at_parity_gate.py):
removed 10 "NotationPatch render metadata (deferred)" entries from
KNOWN_SKIPPED. Only Rank remains, documented as a
fingering-metadata wrapper whose inner data PGP already captures
via <Position finger="…">.
- Regression test (tests/test_gp7.py): canon-audio-track.gp has a 95-
entry Drums articulation table. Asserts the Snare slot's three
standard variants round-trip with their specific notehead glyphs:
hit (MIDI 38, black), side stick (MIDI 37, X), rim shot (MIDI 91 →
38, diamond). Plus the staffLine=3 NotationPatch position.
pytest tests/ — 1608 passed, 0 skipped.
Coverage gate — 4 KNOWN_SKIPPED entries remain (2 AT-no-ops, 1 Rank
wrapper, 3 BackingTrack audio bytes), vs 15 before this PR.
…File
Closes the last real AT-parity gap. GPIF stores the backing-track
audio payload as a ZIP entry referenced by path from the score XML:
<BackingTrack><AssetId>0</AssetId>…
<Assets>
<Asset id="0">
<OriginalFilePath>…</OriginalFilePath>
<OriginalFileSha1>…</OriginalFileSha1>
<EmbeddedFilePath>Content/Assets/<uuid>.ogg</EmbeddedFilePath>
</Asset>
</Assets>
AlphaTab resolves the path via a loadAsset() callback; PyGuitarPro
reads the ZIP entry directly — no decoder or external callback
needed because the archive is already open for the score.gpif
extraction. The previous audit labelled this "out of scope audio
pipeline" but that was wrong — we're just copying bytes out of the
ZIP, no audio decoding involved.
Without this, a future GP7/GP8 writer couldn't regenerate the
backing-track payload, even though the reader had all the metadata
(name, AssetId, padding) to point at it.
- Model (src/guitarpro/models.py):
- BackingTrack.embeddedFilePath: str — preserves the ZIP-internal
path so the writer can emit the <Asset> element correctly.
- BackingTrack.rawAudioFile: Optional[bytes] — the audio payload.
Tagged hash=False, eq=False, repr=False to keep Song equality /
hash checks trivially cheap (audio bytes can be ~MB each).
- Reader (src/guitarpro/gp7.py):
- _load_score_gpif now keeps the ZipFile alive on self._zip so
later passes can read other entries without re-parsing.
- _read_backing_track resolves <Assets><Asset id="X"> against the
backing-track's AssetId, reads <EmbeddedFilePath>, loads the ZIP
entry into BackingTrack.rawAudioFile. Mirrors alphaTab's
_parseAssets + _parseBackingTrackAsset exactly, including the
"asset missing → drop BackingTrack" edge case (we keep the
metadata for the writer but leave rawAudioFile as None).
- Coverage gate (tests/test_gp7_at_parity_gate.py):
removed 3 entries ("Asset", "Assets", "EmbeddedFilePath") from
KNOWN_SKIPPED. Only one entry remains (Rank).
- Regression test (tests/test_gp7.py): canon-audio-track.gp ships a
936701-byte OGG Vorbis backing track. Asserts the embedded path
points at Content/Assets/*.ogg, the rawAudioFile carries the
correct OggS magic header, and the size is in the 900KB–1MB
window (tolerant to fixture repackaging).
pytest tests/ — 1609 passed, 0 skipped.
Coverage gate — 1 KNOWN_SKIPPED entry (Rank wrapper), down from 4.
Final independent audit found three real bugs the presence-based
parity gate couldn't detect (they're about value correctness, not
handler presence):
1. <Position finger="Rank"> mapped to open (gap) instead of annular
ring finger. AlphaTab accepts both "Ring" (emitted by current GP
exports) and "Rank" (legacy token from older GPIF files) and
maps both to Fingers.AnnularFinger. PyGuitarPro only recognised
"Ring". Added "Rank" alongside "Ring" in the fingering mapping
inside _read_chord_diagrams.
Incorrectly categorised this as a "wrapper with no data" in the
gate's KNOWN_SKIPPED allowlist in an earlier audit. Removed from
KNOWN_SKIPPED; now handled for real.
2. <BendDestinationOffset><Float>0</Float> silently dropped by an
`if v:` falsy check. A value of 0 is a legitimate GPIF meaning
"bend reaches destination at start of note" — the falsy check
treated it identically to "no property at all", keeping the
default. AlphaTab assigns unconditionally inside the case
branch; so does PyGuitarPro now.
3. bend_destination["offset"] default was 60 (copy-paste from AT's
scale) but PyGuitarPro's BendEffect.maxPosition is 12 — out of
range on PGP's internal scale. Same wrong default on
whammy_destination inside the WhammyBarDestinationValue and
WhammyBarDestinationOffset handlers. All three now initialise to
BendEffect.maxPosition so the uninitialised case ("no offset
property present") is consistent with PGP's internal scale.
Also cleaned up the KNOWN_SKIPPED comment for the NotationPatch
subtree since all 10 labels are now handled by the PercussionArticulation
parser from PR #40.
pytest tests/ — 1609 passed, 0 skipped. Coverage gate has 2 entries
remaining (HopoDestination and WhammyBarExtend, both AT-documented
no-ops).
…Black2
The previous snapshot filtered to only labels starting with a capital
letter (~270 entries). That meant the gate silently ignored every
lowercase GPIF token (notehead glyph names, technique symbols,
technique placements, harmonic types, rasgueado variants, dynamics,
duration words, note-letter accidentals). The filter was supposed to
drop enum-value noise, but it also dropped real missing-handler
detection for those tokens.
Consequence: a real bug slipped through. noteheadSlashedBlack2 is one
of 22 notehead glyphs alphaTab decodes; PyGuitarPro's MusicFontSymbol
enum only had 21. Any percussion articulation using this notehead
(e.g. "Sticks" in canon-audio-track.gp, MIDI id 31) fell back to
MusicFontSymbol.none on import, dropping the notehead information.
Fixes:
1. Snapshot regenerated verbatim from AT's GpifParser.ts — all 365
unique `case '...'` labels, no filtering. The extra 95 entries
cover:
- XProperty magic IDs (1124*, 687935489)
- Duration tokens (128th / 16th / 32nd / 64th / 256th)
- Ottavia tokens (8va / 8vb / 15ma / 15mb)
- Tremolo fractions (1/2 / 1/4 / 1/8)
- Dynamic markers (PPP / PP / P / MP / MF / F / FF / FFF)
- Finger letters (P / I / M / A / C)
- All 22 notehead glyphs (noteheadBlack … noteheadSlashedBlack2)
- 5 technique symbols (pictEdgeOfCymbal / articStaccatoAbove /
stringsUpBow / stringsDownBow / guitarGolpe)
- 4 placements (above / below / inside / outside)
- 7 harmonic types (noharmonic / natural / artificial / pinch /
semi / tap / feedback)
- 18 rasgueado variant tokens (ii_1 / mi_1 / mii_1…peami_1)
- Accidental sign tokens (x / # / b / bb / "")
- Key mode (major / minor)
2. Gate updated to search both gp7.py and models.py — enum-backed
tokens are referenced in the reader via dynamic lookup
(``gp.MusicFontSymbol[token]``) rather than as quoted literals.
The gate now accepts three match paths:
a) quoted literal in gp7.py (e.g. `name == "Fret"`)
b) quoted literal in models.py (module-level map keys)
c) enum member name in models.py (dynamic lookup)
3. MusicFontSymbol: added noteheadSlashedBlack2 = 22 so the lookup
in _parse_articulation no longer falls through to `none`.
4. _build_note: explicit "noharmonic" branch that sets
note.effect.harmonic = None. AT maps the token to HarmonicType.None;
PyGuitarPro has no separate HarmonicType field so leaving the
harmonic unset is the equivalent — but explicitly handling the
token lets the gate verify it's processed (and documents the
semantic equivalence).
pytest tests/ — 1609 passed, 0 skipped.
Coverage gate — 2 KNOWN_SKIPPED entries (HopoDestination, WhammyBarExtend,
both AT-documented no-ops) against 365 label snapshot.
Third-pass audit found two semantic divergences in beat-level parsers that existed because my previous checks only looked at XML element names, not at attribute-based encodings or fallback branches. 1. <Whammy> attribute-form whammy-bar curve — PyGuitarPro was reading origin/middle/destination values from the attributes correctly but hard-coding the *positions* to [0, 6, 12]. alphaTab reads four separate offset attributes (originOffset, middleOffset1, middleOffset2, destinationOffset) and places the curve points at those offsets, scaled down from GPIF's 0..100 range to PyGuitarPro's 0..12 internal range via _BEND_OFFSET_SCALE. A real whammy dive with non-default positions round-tripped as a straight-line 0/6/12 curve in PGP. Fixed: read all four offsets, scale, and construct the four-point curve from them — matches alphaTab exactly. 2. <Arpeggio> direction fallback. alphaTab treats the token as Up-vs-else (any token other than "Up" falls through to ArpeggioDown). PyGuitarPro handled only the literal "Up" and "Down" tokens; an empty or typo'd <Arpeggio/> registered nothing. Mirror alphaTab's fallthrough so presence of the element always registers an arpeggio stroke. Both match alphaTab one-to-one now. pytest tests/ — 1609 passed.
Deep walk-through audit of each alphaTab _parse* method against the
equivalent PGP handler found three real semantic bugs the pattern-
based checks didn't catch:
1. Chord.firstFret off-by-one. AT sets ``chord.firstFret = baseFret + 1``
to convert GPIF's 0-indexed baseFret into the 1-indexed convention
PyGuitarPro's binary GP3/4/5 readers use. PGP-gp7 stored the raw
baseFret as firstFret. Consequence: the same chord diagram parsed
via gp3/4/5 reader vs gp7 reader gave different firstFret values —
5 vs 4 for a D-barre at the 5th fret. Internal inconsistency
between PGP's own readers.
2. Chord.strings[] relative vs absolute. AT stores each diagram
position as ``baseFret + fret`` (absolute fret position on the
fretboard) so that e.g. a D-barre at the 5th fret gets
``[5, 7, 7, 7, 5, -1]``. PGP-gp7 stored the raw relative value
(``[1, 3, 3, 3, 1, -1]`` for the same diagram), which contradicts
PGP's own GP3/4/5 reader semantics (verified: GP3/4/5 also stores
absolute). Consequence: pre-existing PGP code that interprets
``chord.strings`` as absolute fret positions silently misread
GP7/GP8 chord diagrams.
3. Bar-level <Ottavia> was dropped. AT parses ``<Ottavia>`` at two
levels: at Bar level it sets ``bar.clefOttava`` (clef-octave
annotation like treble-8, ``8va`` / ``15ma`` / ``8vb`` / ``15mb``);
at Beat level it sets ``beat.ottava`` (shifts a single beat's
rendered pitch). PyGuitarPro only handled the beat-level form, so
bar-level treble-8 / bass-8 clefs were lost on round-trip.
Model changes (src/guitarpro/models.py):
- Measure.clefOttava: str = '' (empty = "no clef-octave annotation").
Reader changes (src/guitarpro/gp7.py):
- _read_chord_diagrams: chord.firstFret = base_fret + 1; strings are
stored as baseFret + fret (absolute), with -1 sentinel preserved.
- _build_measure: parses <Ottavia> at bar level into
measure.clefOttava when token is one of the four valid values.
How this slipped past earlier audits:
- Chord bugs were invisible to the case-coverage gate: both AT and
PGP had handlers for <Diagram> / <Fret>. The divergence was in
the arithmetic inside each handler. Surfaced by block-by-block
reading of _parseDiagramItemForChord / _read_chord_diagrams.
- Bar-level Ottavia passed the coverage gate because the string
literal "Ottavia" appeared in gp7.py (the beat-level handler).
The gate checks presence, not context.
These are the kinds of bugs only a proper read-each-method audit
finds. Locking them in with a regression test isn't trivial (would
need a non-standard-tuning barre chord in a synthetic fixture), so
we leave the prior audit-surfaced tests as coverage and rely on
cross-reader consistency tests in future writer work.
pytest tests/ — 1609 passed, 0 skipped. Coverage gate green.
Block-by-block audit of <Property name="VibratoWTremBar"> found PGP collapsed alphaTab's VibratoType.Slight/Wide enum into a bare bool. AT (line 2072-2080) reads the <Strength> child (Slight or Wide) into beat.vibrato as a VibratoType enum value. PGP just set beat.effect. vibrato = True on either token — the distinction between gentle and wide whammy-bar vibrato was lost on beat-level, even though the same distinction is preserved on note-level (NoteEffect.vibratoType). Parallel to PR #16 which added the note-level enum: the beat-level vibrato also needed it. Model (src/guitarpro/models.py): - BeatEffect.vibratoType: VibratoType = VibratoType.none. - Kept BeatEffect.vibrato bool as the cross-version canonical flag (GP3/4/5 don't distinguish intensities at beat level). - Uses attr.ib(factory=...) because VibratoType is defined later in the module — lazy factory defers the default resolution. Reader (src/guitarpro/gp7.py): - VibratoWTremBar branch now maps Slight/Wide to VibratoType enum, mirrors alphaTab. Keeps .vibrato bool True on either match so cross-version code keeps working. pytest tests/ — 1609 passed, 0 skipped.
Method-by-method walk through the remaining parser functions found five more alphaTab-parity gaps. 1. <Track><Properties> dual-dispatch. alphaTab's _parseTrackProperty handles Tuning / DiagramCollection / CapoFret at TRACK level in addition to Staff level (legacy GPIF path). PyGuitarPro only checked inside <Staff><Properties>. Modern GP7/GP8 files never trip the track-level path, but GP6 files and exports that emit Properties directly on <Track> would lose their tuning / capo / chord diagrams. Extracted the Property loop into a shared _apply_track_properties() helper and wired it at both levels. 2. Property-form whammy middle-offset edge case. alphaTab emits a middle point per non-null offset (so offset1=None + offset2=50 gives ONE point at 50). PyGuitarPro's old code always emitted a point at offset1-or-fallback-6, then conditionally another at offset2. With offset1=None + offset2=50 it emitted TWO points (6 and 50) — a spurious midpoint at the fallback position. Mirrors alphaTab's shape exactly now. 3. Note <Bended> curve — same edge case. Same fix pattern. 4. QuadrupleWhole (Long) duration. alphaTab has 11 NoteValue tokens; PyGuitarPro had 10 (missing Long → QuadrupleWhole = -4, four whole notes). A rhythm with <NoteValue>Long</NoteValue> silently mapped to the default Quarter in PGP. Added Duration.quadrupleWhole sentinel and the corresponding _DURATION_MAP entry; time property returns quarterTime * 16 for the longa. 5. <MasterTrack><Anacrusis/> dead code in PGP. alphaTab reads this at MasterTrack level and applies it to the first MasterBar. PyGuitarPro looked for <Anacrusis> inside each MasterBar — but GPIF never emits it there. The anacrusis flag was NEVER set, regardless of whether the file had it. Fixed: _read_master_track now stashes self._has_anacrusis, and _build_measure_header copies it onto the first bar. Verified on tests/gp7/anacrusis.gp — now reports True (was silently False before). pytest tests/ — 1609 passed, 0 skipped.
Reading every remaining AT method line-by-line found one more gap. alphaTab's _parseGeneralMidi reads <Program> into track.playbackInfo.program. _parseSound later overwrites that with the first sound's program when <Sounds> is present. PyGuitarPro only read the program from <Sounds> — for a file that has GeneralMidi <Program> but no <Sounds> block (minimal / GP6-era exports), the program was lost. - Reader (src/guitarpro/gp7.py): added <Program> fallback in the GeneralMidi / MidiConnection / MIDISettings loop. When <Sounds> is also present, _read_track's later "mirror first sound onto channel" still wins — same precedence as alphaTab. pytest tests/ — 1609 passed, 0 skipped.
Block-by-block walk of alphaTab's final assembly step (_buildModel) found another AT-parity fix. alphaTab line 2686-2692 normalises: if the LAST MasterBar has isDoubleBar=true, clear it. A score-end double bar is implicit in standard notation; a <DoubleBar/> on the last bar is redundant, and AT removes it so round-trip stays consistent. PyGuitarPro preserved the flag verbatim, so a GPIF file with <DoubleBar/> on the last bar would round-trip as "last bar marked double" in PGP but "last bar not marked" in AT. Fix: _read_master_bars now clears hasDoubleBar on the last header after the build loop, mirroring alphaTab. pytest tests/ — 1609 passed, 0 skipped.
Final audit pass noticed the module docstring still listed items as "deliberately skipped" that have been handled since: - Octave/Tone (PR #29) - Rasgueado full enum (PR #30) - BarreFret/BarreShape (PR #32) - Per-beat Lyrics (PR #33) - XProperties all three levels (PR #34) - FreeTime (PR #31) - BackingTrack + SyncPoint (PR #36, #41) - SustainPedal (PR #35) - Feedback harmonic (PR #29) Rewrote the list to match the authoritative source (tests/test_gp7_at_parity_gate.py KNOWN_SKIPPED dict + a few architectural deferrals like grand-staff multi-stave and barre detection from chord fingering positions). No code changes; pytest tests/ — 1609 passed.
Port AlphaTab's GpxFileSystem + BitReader to Python so GP6 files (AT's proprietary GPX container) can be read via the existing GPIF code path. * New `src/guitarpro/gpx.py` (340 LoC): `_BitReader`, `_ByteBuffer` (faithful to AT's implicit zero-padding in `write`), `GpxFile`, `GpxFileSystem`, `GpxArchive` adapter exposing `.namelist()`/`.read()`. * `gpif.py._load_score_gpif`: magic-byte dispatch — `PK\x03\x04` stays on `zipfile.ZipFile`; `BCFZ`/`BCFS` routes through `GpxArchive`. * `io.py.parse()`: peek-magic dispatch routes GP6 containers to `GpifFile` with initial versionTuple (6, 0, 0). * 35 AT test-data GP6 fixtures (MPL-2.0, same provenance as gp7/) added to `tests/gp6/` + 108-assertion `tests/test_gp6.py` smoke and dispatch suite. Verification: byte-for-byte equivalence proven against AlphaTab on the GP6 fixture set — every extracted file's `(fileName, fileSize, SHA-256)` triple matches the AT reference dump for each entry. Existing PGP test suite: 1609/1609 pass.
GPIF stores an <InstrumentSet><Elements> entry on every track (drum kit or not); for pitched tracks it carries a single placeholder "Pitched" articulation that is meaningless once parsing finishes. PyGuitarPro was leaving that placeholder on track.percussionArticulations, which diverges from alphaTab's behavior (GpifParser.ts:2853-2855 clears the list when no staff is percussion). Mirror the cleanup at the end of _read_track and add a parametrised regression test that walks every gp7 fixture.
alphaTab initializes Score.defaultSystemsLayout and Track.defaultSystemsLayout to 3 (Score.ts:324, Track.ts:105). PyGuitarPro defaulted to 4, which diverged whenever the GPIF file omitted <ScoreSystemsDefaultLayout> / <SystemsDefautLayout>. Update both model defaults and both gpif.py fallback _int(...) defaults, plus add a parametrised regression test.
The module is the GPIF XML parser, shared by GP6 (.gp6, BCFZ/BCFS container via gpx.py) and GP7/GP8 (.gp, .gp7, .gp8 ZIP container). "gp7" was a misnomer — it described one of the file formats that happens to use the parser, not the parser itself. Rename matches the existing convention in this repo (short lowercase filenames: gp3.py, gp4.py, gp5.py, gpx.py, iobase.py) and aligns with alphaTab's own naming (GpifParser). No behavior changes; references updated in io.py, the parity-gate test path, and the test fixture comments. tests/test_gp7.py keeps its filename — it tests the GP7/GP8 file-format end-to-end rather than the parser internals.
…iling blank The pre-commit `end-of-file-fixer` and `trailing-whitespace` hooks treat binary Guitar Pro files (.gp, .gp3..gp8, .gpx, .tmp) as text and report spurious "files modified" failures on every CI run. Add an exclude pattern covering all GP binary extensions so the hooks only act on real text sources. Also drop the extra trailing newline at the end of `src/guitarpro/gpif.py` that the same hook flagged.
CI's `Lint` job runs `pyupgrade --py39-plus` and `flake8` on every PR. After the gp7→gpif rename and the GP6 GPX additions, several formatting issues surfaced: * Drop unused `typing.Optional` imports in both modules — replaced by the PEP 604 `X | None` syntax pyupgrade emits. * Collapse aligned-column dict/constant blocks to single-space spacing (E221/E241) so flake8 doesn't fight visual alignment. * Add the missing blank line after `_drum_articulation_default` (E305). * Insert the blank line before the nested `make_pitch` definition in `_fill_chord_degrees` (E306). * Drop the dead `root_doc = None` initialiser in `_read_master_bars` (F841). * Tighten the slice whitespace in `gpx.py` (E203). No behaviour changes; 1847/1847 tests pass.
Owner
|
@kaizenman Sorry, but I don't plan to introduce support for Guitar Pro 6 and later. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Adds reader (and progressively writer) for the GPIF XML format,
covering GP6, GP7, and GP8 file versions. GP6 routes through a new
GPX (BCFZ/BCFS) container reader; GP7/GP8 use the standard ZIP path.
Both eventually feed into the same GPIF XML parser (
gpif.py).Status
Scope
gpif.py: shared GPIF XML parser for GP6/7/8 (originallyauthored as
gp7.py; renamed to reflect that it covers all threeGPIF-format versions, not just GP7)
gpx.py: BCFZ/BCFS container reader for GP6io.py: magic-byte dispatch —PK\x03\x04(ZIP) → GP7/GP8;BCFZ/BCFS(GPX) → GP6; both feedGpifFilecase '<Foo>'AlphaTab'sGpifParsermatches on, so silent regressions surface fast.The gate is syntactic — it asserts every AT case label is
referenced as a quoted literal / enum member somewhere in
gpif.py/models.py, i.e. a handler branch exists. Behaviouralequivalence with AT (how each field is mapped onto the model) is
the job of the fixture suite, not the gate.
bundled in (kaizenman fork divergence)
Out of scope / known semantic gaps
These are documented inline in
gpif.pyand intentionally notcovered by the reader. Listing them here so reviewers don't have to
go spelunking:
HopoDestination(auto-derived fromHopoOrigin) andWhammyBarExtend(AT comment: "not clearwhat this is used for"). Listed in the parity gate's
KNOWN_SKIPPEDallowlist.Track.instrumentRefpreserves the
-gsmarker for round-trip but PyGuitarPro'sTrackflattens strings into a single list.same-finger-across-strings in the Fingering. PyGuitarPro stores
the raw fingering list and leaves barre inference to the
renderer / writer.
volume (idx 12) and balance (idx 11) are mapped.
skips these.
tests/gp7_at_case_labels.txtisre-synced manually against new AT releases (one-liner in the
parity-gate module's
regenerate_snapshotdocstring). Betweensyncs, new AT cases are invisible to the gate.
Note
Draft until writer is also implemented and the PR is fully
self-reviewed. Filing now mainly so the direction is visible —
happy to take early feedback on the GPIF/GPX architecture or the
inline parser approach before the writer lands.
The fork's master diverges from upstream master only in the four
PRs above (gp3/4 accent flags + gp5 track clef + writeOldChord
fix + this branch's own work); the divergence shrinks once #56
and #57 land.