import pandas as pd
from timetoalign import AlignmentBundle
from timetoalign.alignment.claims import Agent, MatchClaim, MatchMetadata
from timetoalign.core.enums import AgentType
from timetoalign.loader.alignment import TiliaJsonLoader
from timetoalign.testdata import ensure_data # noqa: E402
DATA_DIR = ensure_data("hendrix")How to Encode Song Genesis (Hendrix)
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()to name a section present on one version and absent from another - Reading each claim’s
claim_typeto tell event matches, conceptual links, and NOMATCH claims apart - Querying events by name on hierarchy timelines
Setup
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(uid="HIERARCHY_TIMELINE_0")
timelines[name] = tl
timelines{'Studio': ContinuousPhysicalTimeline(id='cpt1', length=820.0, unit=seconds, events=49, children=0),
'Demo2': ContinuousPhysicalTimeline(id='cpt1', length=622.0, unit=seconds, events=33, children=0),
'Demo1': ContinuousPhysicalTimeline(id='cpt1', 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
NOMATCHexplicitly records that a section has no equivalent in that version. - The
synchronouscolumn 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)
bundleAlignmentBundle[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:
- Split the row into the sections that are present (named on a timeline) and the timelines from which the section is absent (the cell reads
NOMATCH). - Emit a NOMATCH claim from every present timeline to every absent one. The orphaned section lives on the versions that have it, so a genuine NOMATCH names that section and is oriented from the naming timeline to the one that lacks it – not the reverse. A NOMATCH that named no event at all would merely be conceptual.
- For the present timelines, create pairwise MatchClaims – synchronous or conceptual according to the
synchronouscolumn.
The pairwise strategy fans out from the first present timeline to each of the others (a “star” topology).
metadata = MatchMetadata(
agent=Agent(
name="Hendrix analysis",
type=AgentType.human,
identifier="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():
is_synchronous = str(row["synchronous"]).strip().upper() == "TRUE"
# Split the row into sections that ARE present (named on a timeline) and
# timelines from which the section is absent (the cell reads NOMATCH).
present = [] # (timeline_name, event_dict)
absent = [] # timeline_name
for tl_name in tl_columns:
val = str(row[tl_name]).strip()
if val.upper() == "NOMATCH":
absent.append(tl_name)
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))
# A NOMATCH is oriented FROM the version that has the section (naming its
# orphaned event) TO the version that lacks it. Naming that event is what
# makes the claim a genuine NOMATCH rather than a bare conceptual link.
for absent_tl in absent:
for present_tl, present_ev in present:
nomatch_claims.append(
MatchClaim.nomatch(
event=present_ev,
source_tl_id=present_tl,
target_tl_id=absent_tl,
unit=timelines[present_tl].unit,
metadata=metadata,
)
)
# 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,
unit_a=timelines[tl_a_name].unit,
unit_b=timelines[tl_b_name].unit,
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': 8, 'total': 27}
The claim kind is derived, not stored
Every MatchClaim reports a claim_type computed from its structure – whether it is synchronous and how many of its two sides name an event. A synchronous event-to-event match is ClaimType.event_match, a structural link with no temporal commitment is ClaimType.conceptual, and a named-but-absent section is ClaimType.nomatch. This discriminator is what keeps conceptual links and NOMATCH claims – both non-synchronous – visibly distinct.
{
"sync[0]": sync_claims[0].claim_type,
"conceptual[0]": conceptual_claims[0].claim_type,
"nomatch[0]": nomatch_claims[0].claim_type,
}{'sync[0]': "event_match",
'conceptual[0]': "conceptual",
'nomatch[0]': "nomatch"}
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]| Timeline A | Studio | [32.57379150390625 seconds – 72.17212677001953 seconds] |
| Event A | h0034 | "A: Verse" |
| Timeline B | Demo2 | [35.839063497857836 seconds – 73.03654352407655 seconds] |
| Event B | h0018 | "A: Verse" |
| Metadata | agent=Hendrix analysis | |
claim.get_matchstamp()# 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. A conceptual claim names no orphaned event, so it badges as [CONCEPTUAL] — visibly distinct from the [NOMATCH] claims below, even though both are non-synchronous.
# Display an example conceptual claim (no coordinates, just timeline connection)
conceptual_claims[0] if conceptual_claims else "No conceptual claims"| Timeline A | Studio | |
| Timeline B | Demo2 | |
| Metadata | agent=Hendrix analysis | |
claim.get_matchstamp(){"conceptual_claims": len(conceptual_claims)}{'conceptual_claims': 6}
NOMATCH claims
These explicitly record that a section present on one version has no equivalent on another — a positive assertion of absence, not a mere gap in the data. A NOMATCH names the orphaned section and is oriented from the version that has it to the version that lacks it. For instance, the “Instrumental Part” in the studio recording has no equivalent in Demo1, so the claim names that studio section and points at Demo1. Naming the orphaned event is precisely what distinguishes a NOMATCH from a conceptual link.
# Display an example NOMATCH claim (names the present section, points at the
# version that lacks it)
nomatch_claims[0] if nomatch_claims else "No NOMATCH claims"| Timeline A | Demo2 | @164.28427771460488 seconds |
| Event A | h0011 | "Verse 3" |
| Timeline B | Studio | |
| Metadata | agent=Hendrix analysis | |
claim.get_matchstamp(){"nomatch_claims": len(nomatch_claims)}{'nomatch_claims': 8}
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 (names orphaned section) | MatchClaim.nomatch(event=ev, source_tl_id=has, target_tl_id=lacks) |
| Discriminate claim kinds | claim.claim_type |
| Collect in bundle | bundle.add_match_claims(claims) |