How to Create a Note Alignment

MatchClaims, AlignmentBundle, MatchLines, cross-group MatchStamps

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:

  1. Match performance notes to score notes by shared attributes (pitch, staff)
  2. Create an AlignmentBundle with performance and score groups
  3. Query coordinates across both using get_matchstamp_at()
  4. Create MatchLine objects from both directions
  5. Export a MatchLine to the Vienna .match format

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 -> seconds

1. Setup

import tempfile
from pathlib import Path

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

_notebook_dir = Path(".").resolve()
DATA_DIR = (
    _notebook_dir.parent.parent
    / "tests"
    / "data"
    / "score"
    / "beethoven_op18-4iv_multimodal"
)
NORMAL_DIR = DATA_DIR / "StringQuartetEEP_I_Normal"
ABC_DIR = DATA_DIR / "ABC"

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_group
TimelineGroup[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_group
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
# 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)

bundle
AlignmentBundle[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]
MatchClaim synchronous, instant
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
MatchStamp 2 timelines, 1 edges
ID Coordinate Type
clt1 78 anchor
cpt1 18.230567 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.230567}

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_score
MatchLine(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_perf
MatchLine(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.4083)}

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)