How to Encode Song Genesis (Hendrix)

MatchClaim, NOMATCH, synchronous vs conceptual, TiliaJsonLoader

How to Encode Song Genesis Relationships (Hendrix)

This notebook demonstrates how to encode conceptual and temporal relationships between multiple versions of a work using objects, structures, and the NOMATCH sentinel.

The use case comes from a genesis study of Jimi Hendrix’s 1983… (A Merman I Should Turn to Be), comparing three versions:

  • Studio – the studio recording from Electric Ladyland (CPT1)
  • Demo2 – a band demo with Mitch Mitchell on drums (CPT2)
  • Demo1 – a solo demo (CPT3)

Form analyses for each version are encoded as TiLiA hierarchy timelines. A CSV file records which sections correspond across versions, whether those correspondences are synchronous (temporal alignment possible) or merely conceptual (structural equivalence only), and where a section is explicitly absent from a version (NOMATCH).

Key Concepts Demonstrated

  • Loading TiLiA JSON files via TiliaJsonLoader
  • Creating an from independent timelines
  • Parsing a match table with synchronous and NOMATCH columns
  • Creating synchronous vs. conceptual objects
  • Using MatchClaim.nomatch() for explicit structural absence
  • Querying events by name on hierarchy timelines

Setup


import pandas as pd

from timetoalign import AlignmentBundle
from timetoalign.alignment.anchors import MatchClaim, MatchMetadata
from timetoalign.loader.alignment import TiliaJsonLoader
from timetoalign.testdata import ensure_data  # noqa: E402

DATA_DIR = ensure_data("hendrix")
/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 Three Versions

Each version of the song has been annotated in TiLiA, producing a JSON file with hierarchy timelines encoding the form analysis. We load each file and extract the first hierarchy timeline (HIERARCHY_TIMELINE_0), which contains the section-level annotations (Intro, Verse, Bridge, etc.).

names = ["Studio", "Demo2", "Demo1"]

timelines = {}
for name in names:
    loader = TiliaJsonLoader.from_file(DATA_DIR / f"Hendrix_Merman_{name}.json")
    tl = loader.create_timeline("HIERARCHY_TIMELINE_0")
    timelines[name] = tl

timelines
{'Studio': ContinuousPhysicalTimeline(id='HIERARCHY_TIMELINE_0', length=820.0, unit=seconds, events=49, children=0),
 'Demo2': ContinuousPhysicalTimeline(id='HIERARCHY_TIMELINE_0', length=622.0, unit=seconds, events=33, children=0),
 'Demo1': ContinuousPhysicalTimeline(id='HIERARCHY_TIMELINE_0', length=210.0, unit=seconds, events=21, children=0)}

2. Load the Match Data

The file match_data.csv is a tab-separated table recording which sections correspond across the three versions. Each row represents a (M1–M15).

  • Cells contain the event name (section label) on the respective timeline.
  • The value NOMATCH explicitly records that a section has no equivalent in that version.
  • The synchronous column indicates whether the correspondence is temporal (TRUE) or merely conceptual (FALSE).
df = pd.read_csv(DATA_DIR / "match_data.csv", sep="\t")
df
match Studio Demo2 Demo1 synchronous
0 M1 Intro Intro Intro False
1 M3 A: Verse A: Verse A: Verse True
2 M4 A: Verse (rep.) A: Verse (rep.) A: Verse (rep.) True
3 M5 B: Bridge B: Bridge B: Bridge False
4 M6 1 1 1 True
5 M7 2 2 2 True
6 M8 3 3 3 True
7 M9 4 4 4 True
8 M10 A’: Verse NOMATCH A’: Verse False
9 M11 NOMATCH Verse 3 Verse 3 True
10 M12 NOMATCH Riff 4 Interlude True
11 M13 Intrumental Part: Free Soli / Soundscape Part 2: Free Soli / Soundscape NOMATCH False
12 M14 Dissolving Dissolving NOMATCH False
13 M15 Clean Guitar Solo Clean Guitar Solo NOMATCH False

3. Create the AlignmentBundle

We add each hierarchy timeline to a single , assigning human-readable IDs that match the column names in match_data.csv.

bundle = AlignmentBundle(name="Hendrix Song Genesis")
for name, tl in timelines.items():
    bundle.add_timeline(tl, uid=name)

bundle
AlignmentBundle[bundle:AlignmentBundle_1]

  Standalone timelines (3):
    Form             0 ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 820 seconds (49 ev)
    Demo 2           0 ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 622 seconds (33 ev)
    Form             0 ~~~~~~~~~~~ 210 seconds (21 ev)

  MatchClaims: 0

4. Build MatchClaims from the Match Table

For each row in the CSV we:

  1. Look up the named event on each timeline.
  2. Create NOMATCH sentinels where the CSV says NOMATCH.
  3. For the remaining timelines with events present, create pairwise MatchClaims – synchronous or conceptual according to the synchronous column.

The pairwise strategy fans out from the first available timeline to each of the others (a “star” topology).

metadata = MatchMetadata(agent="user", decision_criteria="match_data.csv")
tl_columns = [c for c in df.columns if c not in ("match", "synchronous")]

sync_claims = []
conceptual_claims = []
nomatch_claims = []

for _, row in df.iterrows():
    match_id = row["match"]
    is_synchronous = str(row["synchronous"]).strip().upper() == "TRUE"

    # Collect events and detect NOMATCH sentinels
    present = []  # (timeline_name, event_dict)
    for tl_name in tl_columns:
        val = str(row[tl_name]).strip()

        if val.upper() == "NOMATCH":
            # Create a NOMATCH sentinel for every other timeline
            for other_name in tl_columns:
                if other_name == tl_name:
                    continue
                sentinel = MatchClaim.nomatch(
                    event={},
                    source_tl_id=tl_name,
                    target_tl_id=other_name,
                    metadata=metadata,
                )
                nomatch_claims.append(sentinel)
            continue

        # Look up the event by name
        tl = timelines[tl_name]
        evs = tl.get_events(name=val)
        if len(evs) != 1:
            continue
        event_id = str(evs.table[0][0])
        event = tl.get_event(event_id)
        present.append((tl_name, event))

    # Create pairwise claims from the first present timeline to the others
    if len(present) < 2:
        continue

    tl_a_name, ev_a = present[0]
    for tl_b_name, ev_b in present[1:]:
        if is_synchronous:
            claim = MatchClaim.from_events(
                event_a=ev_a,
                tl_a_id=tl_a_name,
                event_b=ev_b,
                tl_b_id=tl_b_name,
                end_coord_key="end",
                is_synchronous=True,
                metadata=metadata,
            )
            sync_claims.append(claim)
        else:
            claim = MatchClaim(
                timeline_a_id=tl_a_name,
                timeline_b_id=tl_b_name,
                is_synchronous=False,
                metadata=metadata,
            )
            conceptual_claims.append(claim)

all_claims = sync_claims + conceptual_claims + nomatch_claims
bundle.add_match_claims(all_claims)

{
    "synchronous": len(sync_claims),
    "conceptual": len(conceptual_claims),
    "nomatch": len(nomatch_claims),
    "total": len(all_claims),
}
{'synchronous': 13, 'conceptual': 6, 'nomatch': 12, 'total': 31}

5. Inspect the Results

Synchronous claims (with AlignmentAnchors)

These claims carry coordinate pairs (start and end) that enable temporal alignment between versions. Each interval match corresponds to a pair of section boundaries in seconds.

The MatchClaim’s rich display shows timelines, coordinates, events, and metadata — no need to compile info-dicts manually.

# Display an example synchronous claim (shows timeline IDs, coordinates, events)
sync_claims[0]
MatchClaim synchronous, interval
Timeline A Studio [32.573792 – 72.172127]
Event A h0034 "A: Verse"
Timeline B Demo2 [35.839063 – 73.036544]
Event B h0018 "A: Verse"
Metadata agent=user
# Summary of all synchronous claims
{
    "synchronous_claims": len(sync_claims),
    "is_interval": all(c.is_interval for c in sync_claims),
}
{'synchronous_claims': 13, 'is_interval': True}

Conceptual claims (no anchors)

These record structural equivalence without temporal commitment — for instance, “both versions have an Intro” without asserting that the intros can be aligned beat-by-beat.

# Display an example conceptual claim (no coordinates, just timeline connection)
conceptual_claims[0] if conceptual_claims else "No conceptual claims"
MatchClaim NOMATCH
Timeline A Studio
Timeline B Demo2
Metadata agent=user
{"conceptual_claims": len(conceptual_claims)}
{'conceptual_claims': 6}

NOMATCH sentinels

These explicitly record that a section has no equivalent in the target version — a positive assertion of absence, not a mere gap in the data. For instance, the “Instrumental Part” in the studio recording has no equivalent in Demo1.

# Display an example NOMATCH claim
nomatch_claims[0] if nomatch_claims else "No NOMATCH claims"
MatchClaim NOMATCH
Timeline A Demo2
Timeline B Studio
Metadata agent=user
{"nomatch_sentinels": len(nomatch_claims)}
{'nomatch_sentinels': 12}

Summary

This notebook demonstrated how to encode heterogeneous musicological relationships in a single, queryable structure:

Pattern API
Load TiLiA annotations TiliaJsonLoader.from_file()
Look up sections by name tl.get_events(name=...)
Synchronous alignment MatchClaim.from_events(..., is_synchronous=True)
Conceptual correspondence MatchClaim(..., is_synchronous=False)
Explicit absence MatchClaim.nomatch()
Collect in bundle bundle.add_match_claims(claims)