import tempfile
import pandas as pd
from timetoalign.alignment import AlignmentBundle, MatchLine, TimelineGroup
from timetoalign.alignment.match_format import MatchFileContext
from timetoalign.alignment.matching import (
match_notes_by_attributes,
prepare_abc_notes_for_matching,
prepare_eep_notes_for_matching,
)
from timetoalign.loader.physical.eep_notes import EepNotesLoader
from timetoalign.loader.score import TSVLoader
from timetoalign.testdata import ensure_data
DATA_DIR = ensure_data("score") / "beethoven_op18-4iv_multimodal"
NORMAL_DIR = DATA_DIR / "StringQuartetEEP_I_Normal"
ABC_DIR = DATA_DIR / "ABC"How to Create a Note Alignment
How to Create a Note Alignment
This notebook demonstrates the essential pattern for aligning a performance with a score using objects in an .
What you will learn:
- Match performance notes to score notes by shared attributes (pitch, staff)
- Create an
AlignmentBundlewith performance and score groups - Query coordinates across both using
get_matchstamp_at() - Create
MatchLineobjects from both directions - Export a
MatchLineto the Vienna.matchformat
TL;DR
result = match_notes_by_attributes(perf_df, score_df, ["pitch", "staff"], ...)
bundle.add_match_claims(result.match_claims)
stamp = bundle.get_matchstamp_at(78.0, "clt1") # quarterbeat 78 -> seconds1. Setup
2. Load Performance & Score Notes
The performance notes come from .notes files (EEP format with timestamps in seconds). The score notes come from a pre-unfolded TSV (with coordinates in quarterbeats).
# Performance notes from the Normal recording
eep_loader = EepNotesLoader()
eep_loader.load(*sorted(NORMAL_DIR.glob("*_align_*.notes")))
eep_df = eep_loader.events.to_pandas()
# Score notes from the unfolded ABC edition
abc_df = pd.read_csv(ABC_DIR / "n04op18-4_04_unfolded.notes.tsv", sep="\t")
{"EEP notes": len(eep_df), "ABC notes": len(abc_df)}{'EEP notes': 4026, 'ABC notes': 3869}
3. Prepare & Match Notes
Before matching, we filter out rests and tied notes, and explode chords into individual pitches.
eep_prepared = prepare_eep_notes_for_matching(eep_df)
abc_prepared = prepare_abc_notes_for_matching(abc_df)
{"EEP prepared": len(eep_prepared), "ABC prepared": len(abc_prepared)}{'EEP prepared': 3756, 'ABC prepared': 3750}
Now match by pitch name and staff number. The matcher returns a MatchResult containing the matched pairs and the generated MatchClaim objects.
match_result = match_notes_by_attributes(
eep_prepared,
abc_prepared,
match_columns=["pitch", "staff"],
source_coord_column="start",
target_coord_column="quarterbeats_playthrough",
source_timeline_id="cpt1", # performance timeline (seconds)
target_timeline_id="clt1", # score timeline (quarterbeats)
)
match_result.summary(){'matched': 3740,
'unmatched_source': 16,
'unmatched_target': 10,
'match_claims': 3740}
4. Create AlignmentBundle
We need two objects: one for the performance, one for the score. The AlignmentBundle holds both and manages cross-group connections via objects.
# Create the performance timeline (seconds)
perf_tl = eep_loader.create_timeline(uid="cpt1")
perf_group = TimelineGroup(
id="performance",
name="Normal Recording",
timelines=[perf_tl],
)
perf_groupTimelineGroup[performance] (1 timelines, 2 timestamps) ┌────────────────────────────────────────────────────────────────────┐ │ ContinuousPhysicalTimeline[cpt1] (4026 events) │ │ 0 ~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 265.9 seconds │ └────────────────────────────────────────────────────────────────────┘ Timestamps: 2
# Create the score timeline (quarterbeats)
score_loader = TSVLoader.from_file(
ABC_DIR / "n04op18-4_04.notes.tsv",
ABC_DIR / "n04op18-4_04.measures.tsv",
)
clt1 = score_loader.create_timeline(uid="clt1")
score_group = TimelineGroup(
id="score",
name="ABC Score",
timelines=[clt1],
)
score_groupTimelineGroup[score] (1 timelines, 2 timestamps) ┌─────────────────────────────────────────────────────────────────────────┐ │ ContinuousLogicalTimeline[clt1] (3382 events, 2 children, 2 cmaps) │ │ 0 ___________________________ 878.5 quarters │ │ ├─ notes 0 __________________________ 872.5 (3156 events) │ │ └─ measures 0 ___________________________ 878.5 (226 events) │ └─────────────────────────────────────────────────────────────────────────┘ Timestamps: 2
# Create the bundle and add the match claims
bundle = AlignmentBundle(name="Beethoven Op.18/4 — Simple Alignment")
bundle.add_group(perf_group)
bundle.add_group(score_group)
bundle.add_match_claims(match_result.match_claims)
bundleAlignmentBundle[bundle:AlignmentBundle_1] TimelineGroup[performance] (1 timelines, 2 timestamps) ┌────────────────────────────────────────────────────────────────────────────┐ │ ContinuousPhysicalTimeline[cpt1] (4026 events) │ │ 0 ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 265.9 seconds │ └────────────────────────────────────────────────────────────────────────────┘ Timestamps: 2 TimelineGroup[score] (1 timelines, 2 timestamps) ┌─────────────────────────────────────────────────────────────────────────────────┐ │ ContinuousLogicalTimeline[clt1] (3382 events, 2 children, 2 cmaps) │ │ 0 ___________________________________ 878.5 quarters │ │ ├─ notes 0 __________________________________ 872.5 (3156 events) │ │ └─ measures 0 ___________________________________ 878.5 (226 events) │ └─────────────────────────────────────────────────────────────────────────────────┘ Timestamps: 2 MatchClaims: 3740
# Display an example MatchClaim (shows event IDs, timelines, coordinates)
match_result.match_claims[0]| Timeline A | cpt1 | @1.249978 |
| Timeline B | clt1 | @1 |
| Metadata | agent=timetoalign.alignment.matching | |
5. Query Coordinates via MatchStamp
The get_matchstamp_at() method is the primary interface for cross-group coordinate transfer. Given a coordinate on one timeline, it returns the corresponding coordinates on all connected timelines.
# Query from the score side: quarterbeat 78 (a matched note onset)
stamp = bundle.get_matchstamp_at(78.0, "clt1")
stamp| ID | Coordinate | Type |
|---|---|---|
| cpt1 | 18.218322 | anchor |
| clt1 | 78 | anchor |
# The stamp shows coordinates on both timelines
{"score_qb": stamp.get_coordinate("clt1"), "perf_seconds": stamp.get_coordinate("cpt1")}{'score_qb': 78.0, 'perf_seconds': 18.218322}
Reverse lookup: performance to score
We can also query from the performance side. The claims store performance coordinates in seconds (native EEP format).
# Find the score position for a performance coordinate (~100 seconds)
# Using 100.3583 which is an exact matched coordinate
stamp_rev = bundle.get_matchstamp_at(100.3583, "cpt1")
{
"perf_seconds": stamp_rev.get_coordinate("cpt1"),
"score_qb": stamp_rev.get_coordinate("clt1"),
}{'perf_seconds': 100.3583, 'score_qb': 417.0}
6. Create MatchLines
A is an ordered sequence of coordinate pairs for a given source timeline. It is the input for WarpMap generation.
The direction matters: the source timeline determines the ordering.
# Performance-to-score: source is performance, sorted by performance time
perf_to_score = MatchLine.from_claims(
match_result.match_claims,
source_timeline_id="cpt1",
)
perf_to_scoreMatchLine(source='cpt1', stamps=1452, targets=[clt1])
# Score-to-performance: source is score, sorted by score position
score_to_perf = MatchLine.from_claims(
match_result.match_claims,
source_timeline_id="clt1",
)
score_to_perfMatchLine(source='clt1', stamps=1452, targets=[cpt1])
When to use which direction:
perf_to_score: Use when you have a performance coordinate and want to find the corresponding score position. Sorted by performance time.score_to_perf: Use when you have a score coordinate and want to find the corresponding performance time. Sorted by score position.
Both contain the same number of stamps (one per matched note), but the ordering and lookup direction differ.
# Extract coordinate pairs for WarpMap construction
pairs = score_to_perf.get_coordinate_pairs("cpt1")
{
"n_pairs": len(pairs),
"first_pair": pairs[0],
"last_pair": pairs[-1],
}{'n_pairs': 1452, 'first_pair': (0.0, 1.0), 'last_pair': (1109.0, 264.374966)}
7. Export to .match Format
A can be exported to the Vienna .match file format using save_as(). The .match format is the standard interchange format for note-level alignments in MIR.
To produce a rich .match file (with real pitch, duration, and staff data rather than placeholders), supply a MatchFileContext built from the same DataFrames used for matching.
ctx = MatchFileContext.from_dataframes(
score_df=abc_prepared,
perf_df=eep_prepared,
match_result=match_result,
piece="Beethoven Op.18/4-iv",
composer="Ludwig van Beethoven",
performer="StringQuartetEEP Normal",
)
with tempfile.TemporaryDirectory() as tmp:
out_path = score_to_perf.save_as(f"{tmp}/alignment.match", context=ctx)
text = out_path.read_text()
# Show the first 15 lines
for line in text.splitlines()[:15]:
print(line)info(matchFileVersion,1.0.0).
info(piece,Beethoven Op.18/4-iv).
info(composer,Ludwig van Beethoven).
info(performer,StringQuartetEEP Normal).
info(midiClockUnits,480).
info(midiClockRate,500000).
scoreprop(timeSignature,4/4,1:1,0,0.0000).
snote(n1,[E,b],5,1:1,0,1/2,0.0000,0.5000,[staff1])-note(n1482,75,960,1056,64,0,0).
snote(n2,[F,n],5,1:1,1/8,1/2,0.5000,1.0000,[staff1])-note(n1483,77,1056,1167,64,0,0).
snote(n3,[C,n],3,2:1,0,1,1.0000,2.0000,[staff4])-note(n0,48,1199,1311,64,0,0).
snote(n8,[E,b],5,2:1,1/8,1/2,1.5000,2.0000,[staff1])-note(n1485,75,1296,1399,64,0,0).
snote(n9,[F,n],5,2:1,1/4,1/2,2.0000,2.5000,[staff1])-note(n1486,77,1399,1488,64,0,0).
snote(n10,[D,n],5,2:1,3/8,1/2,2.5000,3.0000,[staff1])-note(n1487,74,1488,1599,64,0,0).
snote(n11,[C,n],3,2:1,1/2,1,3.0000,4.0000,[staff4])-note(n643,60,1607,1791,64,0,0).
snote(n16,[C,n],5,2:1,5/8,1/2,3.5000,4.0000,[staff1])-note(n1489,72,1695,1799,64,0,0).
The exported file is a valid .match file that can be loaded back with MatchfileLoader or any tool that reads the Vienna format.
Summary
“MatchClaims connect timelines across groups. The AlignmentBundle manages these connections and provides coordinate transfer via MatchStamps. MatchLines order these stamps for WarpMap generation.”
| Pattern | API |
|---|---|
| Match notes by attributes | match_notes_by_attributes() |
| Add claims to bundle | bundle.add_match_claims(claims) |
| Query by coordinate | bundle.get_matchstamp_at(coord, tl_id) |
| Create MatchLine | MatchLine.from_claims(claims, source_timeline_id) |
Export to .match |
matchline.save_as("out.match", context=ctx) |