Closed
Conversation
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
gp7: extract pick-slide flags (0x40 PickSlideDown, 0x80 PickSlideUp)
`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
gp7: extract LeftHandTapped note property
`_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).
gp7: extract note accidental mode (ConcertPitch / TransposedPitch)
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.
…ation gp7: extract <InstrumentArticulation> sibling element
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.
gp7: propagate note-level <Tapped> property to beat slapEffect
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: map <Fadding> to full FadeType enum (FadeIn/FadeOut/VolumeSwell)
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: extract <Fermatas> / <Fermata> master-bar elements
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.
gp7: extract <Ornament> sibling element on notes
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.
gp7: distinguish Slight vs Wide <Vibrato> on notes
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.
gp7: extract Accent bit 0x10 (Tenuto) as a dedicated flag
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.
gp7: extract <TransposedPitchStemOrientation> beat siblings
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.
…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.
gp7: load <Assets><Asset> raw audio bytes into BackingTrack.rawAudioFile
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).
gp7: fix three audit-surfaced semantic divergences vs alphaTab
…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.
gp7: tighten parity gate — full 365-label snapshot + noteheadSlashedBlack2
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.
…allback gp7: two more audit findings — Whammy offsets + Arpeggio fallback
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.
…ef-ottava gp7: three block-by-block audit findings (chord frets + clef ottava)
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.
gp7: BeatEffect.vibratoType for Slight/Wide enum parity
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.
gp7: block-by-block audit round 2 — five more divergences fixed
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.
…lback gp7: read <Program> from GeneralMidi/MidiConnection (AT fallback path)
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.
gp7: clear hasDoubleBar on last master bar (alphaTab _buildModel fix)
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.
gp7: docstring cleanup — stale Deliberately skipped list
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
Author
|
Replaced by upstream draft against Perlence/PyGuitarPro (so the maintainer sees the WIP and CI runs on the upstream's matrix). Closing this fork-internal duplicate. |
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.
Status
Scope
gpif.py(formerlygp7.py): shared XML parser for GP6/7/8gpx.py: BCFZ/BCFS container reader for GP6io.py: magic-byte dispatch routes GP6 → GpxArchive → GpifFile,GP7/GP8 → ZipFile → GpifFile
Note
Draft until writer is also implemented and code review is complete.