How to Load a parangonada Note Alignment Across Several Performances

ParangonadaLoader, multimodal AlignmentBundle, divs/quarters and samples/seconds C-Maps, synchronous and NOMATCH MatchClaims

How to Load a parangonada Note Alignment Across Several Performances

parangonar aligns a symbolic score to a performance and exports the result as a parangonada triple of CSV files — the score notes, the performed notes, and the note-level correspondences between them. This guide loads such an export for one work performed five different times into a single multimodal .

We load an existing alignment; nothing here runs an aligner. The correspondences were computed once, offline, and written to disk; the loader’s job is to read them faithfully and arrange them as timelines and s.

The work is Beethoven’s Eroica Variations, Op. 35 — Var. XIV — in five recordings spanning six decades: Szegedi (1966), Gould (1970), Curzon (1971), Brendel (1985), and Hewitt (2023). One score, five performances, and the note-by-note alignment that ties each performance back to the score.

The arc:

  1. Load the whole export in one call.
  2. Read the shared logical score, in two units, linked by a .
  3. Read one performer’s physical timelines, in two units, linked by a .
  4. Inspect the cross-group s — both the synchronous matches and the sentinels — and read one shared score position across all five performances.
  5. Read each performance’s measured per-beat tempo and dynamics, carried as events on its own timeline.

Setup

from __future__ import annotations

import pandas as pd

from timetoalign.core import TimeUnit
from timetoalign.loader.alignment import ParangonadaLoader
from timetoalign.testdata import ensure_data

root = ensure_data("parangonar")
dataset_dir = root / "Beethoven_Eroica_op35-cpjku"
/home/laser/miniconda3/envs/timetoalign/lib/python3.11/site-packages/partitura/__init__.py:9: UserWarning: pkg_resources is deprecated as an API. See https://setuptools.pypa.io/en/latest/pkg_resources.html. The pkg_resources package is slated for removal as early as 2025-11-30. Refrain from using this package or pin to Setuptools<81.
  import pkg_resources

1. Load the export in one call

ParangonadaLoader discovers every performance under the export’s directory, parses the shared score once and each performance’s notes and alignment, and binds each recording’s audio for its sample rate. from_file() is the one-line form of the standard two-phase loader pattern.

bundle = ParangonadaLoader.from_file(dataset_dir).create_bundle()
bundle
AlignmentBundle[bundle:AlignmentBundle_1]

  TimelineGroup[score] (2 timelines, 2 timestamps)
  ┌────────────────────────────────────────────────────────────────────────────┐
  │ ContinuousLogicalTimeline[score:clt1] (251 events)                         │
  │                       0 ______________________________________ 64 quarters │
  │                                                                            │
  │ DiscreteLogicalTimeline[score:dlt1] (251 events, 1 cmaps)                  │
  │                       0 ,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,, 2064 ticks │
  └────────────────────────────────────────────────────────────────────────────┘
  Timestamps: 2

  TimelineGroup[perf:1966_Szegedi] (2 timelines, 2 timestamps)
  ┌────────────────────────────────────────────────────────────────────────────┐
  │ ContinuousPhysicalTimeline[perf:1966_Szegedi:cpt1] (358 events)            │
  │                       0 ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 73.1 seconds │
  │                                                                            │
  │ DiscretePhysicalTimeline[perf:1966_Szegedi:dpt1] (232 events, 1 cmaps)     │
  │                         0 ⋅⋅⋅⋅⋅⋅⋅⋅⋅⋅⋅⋅⋅⋅⋅⋅⋅⋅⋅⋅⋅⋅⋅⋅⋅⋅⋅⋅⋅⋅⋅⋅ 3222035 samples │
  └────────────────────────────────────────────────────────────────────────────┘
  Timestamps: 2

  TimelineGroup[perf:1970_Gould] (2 timelines, 2 timestamps)
  ┌────────────────────────────────────────────────────────────────────────────┐
  │ ContinuousPhysicalTimeline[perf:1970_Gould:cpt1] (379 events)              │
  │                       0 ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 122.9 seconds │
  │                                                                            │
  │ DiscretePhysicalTimeline[perf:1970_Gould:dpt1] (253 events, 1 cmaps)       │
  │                         0 ⋅⋅⋅⋅⋅⋅⋅⋅⋅⋅⋅⋅⋅⋅⋅⋅⋅⋅⋅⋅⋅⋅⋅⋅⋅⋅⋅⋅⋅⋅⋅⋅ 5419538 samples │
  └────────────────────────────────────────────────────────────────────────────┘
  Timestamps: 2

  TimelineGroup[perf:1971_Curzon] (2 timelines, 2 timestamps)
  ┌────────────────────────────────────────────────────────────────────────────┐
  │ ContinuousPhysicalTimeline[perf:1971_Curzon:cpt1] (372 events)             │
  │                       0 ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 77.8 seconds │
  │                                                                            │
  │ DiscretePhysicalTimeline[perf:1971_Curzon:dpt1] (246 events, 1 cmaps)      │
  │                         0 ⋅⋅⋅⋅⋅⋅⋅⋅⋅⋅⋅⋅⋅⋅⋅⋅⋅⋅⋅⋅⋅⋅⋅⋅⋅⋅⋅⋅⋅⋅⋅⋅ 3430226 samples │
  └────────────────────────────────────────────────────────────────────────────┘
  Timestamps: 2

  TimelineGroup[perf:1985_Brendel] (2 timelines, 2 timestamps)
  ┌────────────────────────────────────────────────────────────────────────────┐
  │ ContinuousPhysicalTimeline[perf:1985_Brendel:cpt1] (375 events)            │
  │                       0 ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 74.9 seconds │
  │                                                                            │
  │ DiscretePhysicalTimeline[perf:1985_Brendel:dpt1] (249 events, 1 cmaps)     │
  │                         0 ⋅⋅⋅⋅⋅⋅⋅⋅⋅⋅⋅⋅⋅⋅⋅⋅⋅⋅⋅⋅⋅⋅⋅⋅⋅⋅⋅⋅⋅⋅⋅⋅ 3302297 samples │
  └────────────────────────────────────────────────────────────────────────────┘
  Timestamps: 2

  TimelineGroup[perf:2023_Hewitt] (2 timelines, 2 timestamps)
  ┌────────────────────────────────────────────────────────────────────────────┐
  │ ContinuousPhysicalTimeline[perf:2023_Hewitt:cpt1] (370 events)             │
  │                       0 ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 89.9 seconds │
  │                                                                            │
  │ DiscretePhysicalTimeline[perf:2023_Hewitt:dpt1] (244 events, 1 cmaps)      │
  │                         0 ⋅⋅⋅⋅⋅⋅⋅⋅⋅⋅⋅⋅⋅⋅⋅⋅⋅⋅⋅⋅⋅⋅⋅⋅⋅⋅⋅⋅⋅⋅⋅⋅ 3966486 samples │
  └────────────────────────────────────────────────────────────────────────────┘
  Timestamps: 2

  MatchClaims: 1275

Twelve timelines arranged in six groups: one shared "score" group, and one "perf:<key>" group per recording. Everything below reads from this single bundle.

{
    "timelines": bundle.n_timelines,
    "groups": bundle.n_groups,
    "groups_listed": bundle.group_ids,
}
{'timelines': 12,
 'groups': 6,
 'groups_listed': ['score',
  'perf:1966_Szegedi',
  'perf:1970_Gould',
  'perf:1971_Curzon',
  'perf:1985_Brendel',
  'perf:2023_Hewitt']}

2. The shared logical score

The score is identical across all five performances, so it is parsed once and placed in its own group. It appears in two logical units — the same notes, measured two ways:

  • score:clt1 carries quarter-note onsets (an exact rational).
  • score:dlt1 carries division-grid onsets (the integer tick grid the score was notated on).
score_clt = bundle.get_timeline("score:clt1")
score_clt
ContinuousLogicalTimeline[score:clt1] (251 events)
                      0 __________________________________ 64 quarters
score_dlt = bundle.get_timeline("score:dlt1")
score_dlt
DiscreteLogicalTimeline[score:dlt1] (251 events, 1 cmaps)
                      0 ,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,, 2064 ticks

Both hold the same 251 notes, each carrying its MIDI pitch and voice. A look at the first few notes shows the pitch and voice the export recorded (the first note sits at quarter −0.5, the score’s anacrusis):

score_clt.get_events().table.slice(0, 5).to_pandas()[["id", "pitch", "voice"]]
id pitch voice
0 nwqgcz5 {'midi_number': 63} 3
1 niis0bt {'midi_number': 66} 3
2 nbwxzb1 {'midi_number': 75} 1
3 n1axox90 {'midi_number': 65} 3
4 nz2irm6 {'midi_number': 63} 3

The division grid and the quarter grid are one conversion apart

The two logical timelines are not independent: a single on score:dlt1 carries the division grid to quarters. It is a LinearMap — 32 divisions to the quarter, shifted by the half-quarter anacrusis — and it reproduces the quarter onsets exactly, because divisions are the discrete grid the quarters were notated on:

divs_to_quarters = score_dlt.get_conversion_map(TimeUnit.quarters)

{
    "div 16 -> quarters": divs_to_quarters(16),
    "div 48 -> quarters": divs_to_quarters(48),
    "div 1296 -> quarters": divs_to_quarters(1296),
}
{'div 16 -> quarters': Fraction(0, 1),
 'div 48 -> quarters': Fraction(1, 1),
 'div 1296 -> quarters': Fraction(40, 1)}

The same conversion is what a exposes. Querying score:dlt1 at division 48 and asking for the quarter reading reports 1.0, the continuous↔︎discrete link of the logical domain made visible at a single coordinate:

score_dlt.get_timestamp(48).get_unit(TimeUnit.quarters)
1.0

3. One performer’s physical timelines

Each performance lives in its own group, again in two units — the same performed notes, measured two ways:

  • perf:<key>:cpt1 carries onsets in seconds.
  • perf:<key>:dpt1 carries the same onsets as sample indices into the recording’s audio.

Take Szegedi’s 1966 recording:

szegedi_cpt = bundle.get_timeline("perf:1966_Szegedi:cpt1")
szegedi_cpt
ContinuousPhysicalTimeline[perf:1966_Szegedi:cpt1] (358 events)
                      0 ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 73.1 seconds
szegedi_dpt = bundle.get_timeline("perf:1966_Szegedi:dpt1")
szegedi_dpt
DiscretePhysicalTimeline[perf:1966_Szegedi:dpt1] (232 events, 1 cmaps)
                        0 ⋅⋅⋅⋅⋅⋅⋅⋅⋅⋅⋅⋅⋅⋅⋅⋅⋅⋅⋅⋅⋅⋅⋅⋅⋅⋅⋅⋅ 3222035 samples

The seconds grid and the samples grid are, again, one apart — here a SamplesToSeconds map carrying the recording’s 44.1 kHz sample rate. It is the physical-domain counterpart of the logical divs→quarters map: 44 100 samples is one second.

samples_to_seconds = szegedi_dpt.get_conversion_map(TimeUnit.seconds)

{
    "sample_rate (Hz)": samples_to_seconds.sample_rate,
    "44100 samples -> seconds": samples_to_seconds(44100),
    "88200 samples -> seconds": samples_to_seconds(88200),
}
{'sample_rate (Hz)': 44100,
 '44100 samples -> seconds': 1.0,
 '88200 samples -> seconds': 2.0}

4. The cross-group alignment

The score group and the five performance groups are tied together by cross-group s — one per row of each performance’s alignment file. A matched note becomes a synchronous claim relating a score quarter to a performed second; an unmatched note becomes a claim, which records the dangling note rather than discarding it.

claims = bundle.cross_group_claims

{
    "total claims": len(claims),
    "synchronous (matched)": sum(1 for c in claims if c.is_synchronous),
    "NOMATCH (score-only or performance-only)": sum(
        1 for c in claims if not c.is_synchronous
    ),
}
{'total claims': 1275,
 'synchronous (matched)': 1208,
 'NOMATCH (score-only or performance-only)': 67}

A synchronous claim carries an anchor: a score quarter on one side, the performed second on the other. This is Szegedi’s note at score quarter 40, played 38.18 seconds into the recording:

szegedi_synchronous = [
    c
    for c in claims
    if c.is_synchronous
    and c.connects("perf:1966_Szegedi:cpt1")
    and c.start_anchor is not None
    and float(c.start_anchor.coordinate_a) == 40.0
]
szegedi_synchronous[0]
MatchClaim ANCHOR
Timeline A score:clt1 @40
Timeline B perf:1966_Szegedi:cpt1 @38.183334
Metadata agent=parangonada
Try: claim.get_matchstamp()

A claim carries no anchor — there is no second coordinate to record — but it keeps the unmatched side’s coordinate so the dangling note stays legible. Its text repr prints that coordinate inline (...@<coord>) with a [NOMATCH] flag:

szegedi_nomatch = [
    c for c in claims if not c.is_synchronous and c.connects("perf:1966_Szegedi:cpt1")
]
szegedi_nomatch[0]
MatchClaim CONCEPTUAL
Timeline A score:clt1
Timeline B perf:1966_Szegedi:cpt1
Metadata agent=parangonada
Try: claim.get_matchstamp()

One score position across all five performances

Because every performance is anchored back to the same score, a single score coordinate resolves across the whole bundle. get_matchstamp_at takes a coordinate on score:clt1 and returns the corresponding coordinate on every timeline connected to it — here, score quarter 40 mapped to the second at which each of the five pianists played it:

bundle.get_matchstamp_at(40.0, "score:clt1")
MatchStamp 12 timelines, 11 edges
ID Coordinate Type
perf:1971_Curzon:cpt1 41.247395 anchor
perf:2023_Hewitt:cpt1 47.46875 anchor
perf:1966_Szegedi:cpt1 38.183334 anchor
perf:1985_Brendel:cpt1 37.84375 anchor
perf:1970_Gould:cpt1 63.103645 anchor
score:clt1 40 anchor
score:dlt1 1290 inferred
perf:1970_Gould:dpt1 2782871 inferred
perf:1985_Brendel:dpt1 1668909 inferred
perf:1966_Szegedi:dpt1 1683885 inferred
perf:2023_Hewitt:dpt1 2093371 inferred
perf:1971_Curzon:dpt1 1819010 inferred
Try: stamp.get(<tl_id>), stamp.get_coordinate(<tl_id>), stamp.get_unit(<unit>)

Read across that : the same notated moment falls at 37.8 s for Brendel and 38.2 s for Szegedi, but at 63.1 s for Gould — the raw material of a tempo comparison, drawn straight from the loaded alignment with no aligner ever run.


5. Measured tempo and dynamics, as events on the performance

The alignment ties each performance to the score note by note, but parangonar also measured, for every recording, a per-beat tempo and dynamics profile. These are not a model or a synthesised constant-tempo grid: they are the variable tempo and loudness actually measured beat by beat in each recording. The loader places them on the performance’s own seconds timeline — perf:<key>:cpt1 — as Beat and Dynamics s, sitting alongside the Note events at their measured onsets. A performance timeline therefore carries three kinds of event: the notes that were played, and the measured tempo and dynamics that describe how.

We read them with the same event query used everywhere else — filter by event_type. The coordinate (start) comes back as a number; the remaining measured columns currently round-trip as strings, so we cast them as we read.

Tempo: the Beat events

Each Beat event carries the measured beat-per-minute reading at its onset, together with the measure_number and beat it falls on. There are 63 of them — one per measured beat of the variation. Take Szegedi’s:

szegedi_beats = szegedi_cpt.get_events().filter(event_type="Beat").to_dataframe()

szegedi_tempo = szegedi_beats[["start", "measure_number", "beat", "bpm"]].copy()
szegedi_tempo["onset_sec"] = szegedi_tempo.pop("start").astype(float)
szegedi_tempo["measure_number"] = szegedi_tempo["measure_number"].astype(int)
szegedi_tempo["beat"] = szegedi_tempo["beat"].astype(int)
szegedi_tempo["bpm"] = szegedi_tempo["bpm"].astype(float)

{
    "beat events": len(szegedi_tempo),
    "first onset (s)": szegedi_tempo["onset_sec"].iloc[0],
    "measured BPM: min / median / max": (
        round(szegedi_tempo["bpm"].min(), 1),
        round(szegedi_tempo["bpm"].median(), 1),
        round(szegedi_tempo["bpm"].max(), 1),
    ),
}
{'beat events': 63,
 'first onset (s)': np.float64(0.796354),
 'measured BPM: min / median / max': (np.float64(20.7),
  np.float64(67.5),
  np.float64(309.9))}

The tempo is anything but constant. Reading the first measures of the variation shows the measured BPM moving from beat to beat — the local rubato of the playing, recorded as data:

szegedi_tempo.head(8)
measure_number beat bpm onset_sec
0 1 1 83.660126 0.796354
1 1 2 79.558022 1.513542
2 2 1 80.559433 2.267708
3 2 2 82.324928 3.012500
4 3 1 87.894203 3.741319
5 3 2 84.705917 4.423958
6 4 1 86.356804 5.132291
7 4 2 67.466995 5.827083

Dynamics: the Dynamics events

At the same measured onsets sit 63 Dynamics events, each carrying the mean and peak MIDI velocity measured over its beat. The .dyn source rows have no onset of their own; the loader joined each to the Beat row sharing its (measure_number, beat) key, so the dynamics land at exactly the same seconds as the tempo readings:

szegedi_dyn = szegedi_cpt.get_events().filter(event_type="Dynamics").to_dataframe()

szegedi_dynamics = szegedi_dyn[
    ["start", "measure_number", "beat", "velocity_mean", "velocity_max"]
].copy()
szegedi_dynamics["onset_sec"] = szegedi_dynamics.pop("start").astype(float)
szegedi_dynamics["measure_number"] = szegedi_dynamics["measure_number"].astype(int)
szegedi_dynamics["beat"] = szegedi_dynamics["beat"].astype(int)
szegedi_dynamics["velocity_mean"] = szegedi_dynamics["velocity_mean"].astype(float)
szegedi_dynamics["velocity_max"] = szegedi_dynamics["velocity_max"].astype(float)

szegedi_dynamics.head(8)
measure_number beat velocity_mean velocity_max onset_sec
0 1 1 48.500000 58.0 0.796354
1 1 2 47.000000 47.0 1.513542
2 2 1 59.500000 77.0 2.267708
3 2 2 43.000000 43.0 3.012500
4 3 1 47.333333 65.0 3.741319
5 3 2 53.000000 53.0 4.423958
6 4 1 45.250000 63.0 5.132291
7 4 2 51.000000 51.0 5.827083

Five interpretations, five tempo profiles

Because every performance carries its own measured Beat events, the “five interpretations of one work” claim becomes concrete: the same 63 beats of the variation, measured in each recording. Summarising each performer’s measured tempo shows how differently they take it — Gould stretches the variation across nearly twice the span of Brendel, at roughly half the median tempo:

tempo_by_performer = {}
for key in bundle.group_ids:
    if not key.startswith("perf:"):
        continue
    cpt = bundle.get_timeline(f"{key}:cpt1")
    beats = cpt.get_events().filter(event_type="Beat").to_dataframe()
    bpm = beats["bpm"].astype(float)
    onset = beats["start"].astype(float)
    tempo_by_performer[key.removeprefix("perf:")] = {
        "beats": len(beats),
        "median BPM": round(bpm.median(), 1),
        "span (s)": round(onset.max() - onset.min(), 1),
    }

pd.DataFrame(tempo_by_performer).T
beats median BPM span (s)
1966_Szegedi 63.0 67.5 68.9
1970_Gould 63.0 38.0 108.7
1971_Curzon 63.0 58.9 69.4
1985_Brendel 63.0 67.3 66.0
2023_Hewitt 63.0 51.4 79.2

These profiles are measured features carried on the performance timelines, not anything TimeToAlign! inferred. Loaded next to the notes and the cross-group s, they let one bundle hold both what was played (the aligned notes) and how it was played (the measured tempo and dynamics), for every one of the five recordings.

Recap

What the bundle expresses How
Score, two logical units score:clt1 (quarters) + score:dlt1 (divs), one divs→quarters LinearMap
Performance, two physical units perf:<key>:cpt1 (s) + perf:<key>:dpt1 (samples), one SamplesToSeconds
Score ↔︎ performance a synchronous per match; a per gap
A shared position read everywhere bundle.get_matchstamp_at(coord, "score:clt1")
Measured tempo and dynamics Beat / Dynamics events on perf:<key>:cpt1, read via filter(event_type=...)

Two logical units for the score, two physical units per performance, each pair linked by a , the whole tied together by cross-group s, and each performance additionally carrying its measured per-beat tempo and dynamics as events — one carrying an existing note alignment of one work across five performances, and a faithful record of how each was played.