from pathlib import Path
import pandas as pd
from timetoalign import AlignmentBundle
from timetoalign.alignment.anchors import MatchClaim, MatchMetadata
from timetoalign.loader.alignment import TiliaJsonLoader
# Resolve data paths (works both as script and as notebook)
try:
_notebook_dir = Path(__file__).parent.resolve()
except NameError:
_notebook_dir = Path(".").resolve()
DATA_DIR = _notebook_dir.parent.parent / "tests" / "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()for explicit structural absence - 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("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
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:
- Look up the named event on each timeline.
- Create NOMATCH sentinels where the CSV says
NOMATCH. - For the remaining timelines with events present, create pairwise MatchClaims – synchronous or conceptual according to the
synchronouscolumn.
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]| 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"| 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"| 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) |