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"How to Load a parangonada Note Alignment Across Several Performances
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:
- Load the whole export in one call.
- Read the shared logical score, in two units, linked by a .
- Read one performer’s physical timelines, in two units, linked by a .
- Inspect the cross-group s — both the synchronous matches and the sentinels — and read one shared score position across all five performances.
- Read each performance’s measured per-beat tempo and dynamics, carried as events on its own timeline.
Setup
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()
bundleAlignmentBundle[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']}
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>:cpt1carries onsets in seconds.perf:<key>:dpt1carries 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_cptContinuousPhysicalTimeline[perf:1966_Szegedi:cpt1] (358 events)
0 ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 73.1 seconds
szegedi_dpt = bundle.get_timeline("perf:1966_Szegedi:dpt1")
szegedi_dptDiscretePhysicalTimeline[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]| Timeline A | score:clt1 | @40 quarters |
| Timeline B | perf:1966_Szegedi:cpt1 | @38.183334 seconds |
| Metadata | agent=parangonada | |
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]| Timeline A | score:clt1 | @8 quarters |
| Timeline B | perf:1966_Szegedi:cpt1 | |
| Metadata | agent=parangonada | |
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")| ID | Coordinate | Type |
|---|---|---|
| score:clt1 | 40 quarters | anchor |
| score:dlt1 | 1290 ticks | inferred |
| perf:1966_Szegedi:cpt1 | 38.183334 seconds | anchor |
| perf:1966_Szegedi:dpt1 | 1683885 samples | inferred |
| perf:1970_Gould:cpt1 | 63.103645 seconds | anchor |
| perf:1970_Gould:dpt1 | 2782871 samples | inferred |
| perf:1971_Curzon:cpt1 | 41.247395 seconds | anchor |
| perf:1971_Curzon:dpt1 | 1819010 samples | inferred |
| perf:1985_Brendel:cpt1 | 37.84375 seconds | anchor |
| perf:1985_Brendel:dpt1 | 1668909 samples | inferred |
| perf:2023_Hewitt:cpt1 | 47.46875 seconds | anchor |
| perf:2023_Hewitt:dpt1 | 2093371 samples | inferred |
stamp.get_coordinate_for(<tl_id>), stamp.get_coordinates_for(<tl_ids>), stamp.get_unit(<unit>), stamp.get_conversion_for(<key>)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.