Skip to content
This repository was archived by the owner on Aug 11, 2026. It is now read-only.

Commit 7bb9cc5

Browse files
jon-myersclaude
andcommitted
feat: add trackTitles support to Piece class
Resolves Issue #44 Add support for the trackTitles attribute to enable custom labeling of instrument tracks in multi-track recordings. This feature is particularly useful for recordings with multiple instances of the same instrument (e.g., sarangi trios with "Lead Melody", "Harmony", and "Drone" labels). ## Changes: - Add 'trackTitles' to allowed parameters in Piece constructor - Initialize track_titles with automatic length synchronization - Add type validation (must be list of strings) - Include trackTitles in serialization (to_json) - Maintain backward compatibility (defaults to empty strings) ## Testing: - 9 comprehensive test cases covering: - Default initialization - Explicit values - Length synchronization (padding/truncation) - Type validation - Serialization round-trip - Real-world use cases (sarangi trio) - All 356 existing tests continue to pass 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
1 parent e174122 commit 7bb9cc5

2 files changed

Lines changed: 189 additions & 7 deletions

File tree

idtap/classes/piece.py

Lines changed: 30 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,20 @@ def __init__(self, options: Optional[dict] = None) -> None:
102102
inst_list.append(i)
103103
self.instrumentation = inst_list
104104

105+
# Initialize trackTitles (after instrumentation is set)
106+
track_titles = opts.get('trackTitles')
107+
if track_titles is not None:
108+
self.track_titles = list(track_titles) # Create a copy
109+
else:
110+
# Create empty strings for each instrument track
111+
self.track_titles = [''] * len(self.instrumentation)
112+
113+
# Ensure trackTitles array matches instrumentation length
114+
while len(self.track_titles) < len(self.instrumentation):
115+
self.track_titles.append('')
116+
while len(self.track_titles) > len(self.instrumentation):
117+
self.track_titles.pop()
118+
105119
self.possible_trajs: Dict[Instrument, List[int]] = {
106120
Instrument.Sitar: list(range(14)),
107121
Instrument.Vocal_M: [0, 1, 2, 3, 4, 5, 6, 12, 13],
@@ -215,12 +229,13 @@ def _validate_parameters(self, opts: dict) -> None:
215229

216230
# Define allowed parameter names
217231
allowed_keys = {
218-
'raga', 'instrumentation', 'phraseGrid', 'phrases', 'title', 'dateCreated',
219-
'dateModified', 'location', '_id', 'audioID', 'audio_DB_ID', 'userID', 'name',
220-
'family_name', 'given_name', 'permissions', 'soloist', 'soloInstrument',
221-
'explicitPermissions', 'meters', 'sectionStartsGrid', 'sectionStarts',
222-
'sectionCatGrid', 'sectionCategorization', 'adHocSectionCatGrid', 'excerptRange',
223-
'assemblageDescriptors', 'collections', 'durTot', 'durArrayGrid', 'durArray'
232+
'raga', 'instrumentation', 'phraseGrid', 'phrases', 'title', 'dateCreated',
233+
'dateModified', 'location', '_id', 'audioID', 'audio_DB_ID', 'userID', 'name',
234+
'family_name', 'given_name', 'permissions', 'soloist', 'soloInstrument',
235+
'explicitPermissions', 'meters', 'sectionStartsGrid', 'sectionStarts',
236+
'sectionCatGrid', 'sectionCategorization', 'adHocSectionCatGrid', 'excerptRange',
237+
'assemblageDescriptors', 'collections', 'durTot', 'durArrayGrid', 'durArray',
238+
'trackTitles'
224239
}
225240
provided_keys = set(opts.keys())
226241
invalid_keys = provided_keys - allowed_keys
@@ -343,7 +358,14 @@ def _validate_parameter_types(self, opts: dict) -> None:
343358
raise TypeError(f"Parameter 'collections' must be a list, got {type(opts['collections']).__name__}")
344359
if not all(isinstance(item, str) for item in opts['collections']):
345360
raise TypeError("All items in 'collections' must be strings")
346-
361+
362+
# Validate trackTitles
363+
if 'trackTitles' in opts and opts['trackTitles'] is not None:
364+
if not isinstance(opts['trackTitles'], list):
365+
raise TypeError(f"Parameter 'trackTitles' must be a list, got {type(opts['trackTitles']).__name__}")
366+
if not all(isinstance(title, str) for title in opts['trackTitles']):
367+
raise TypeError("All items in 'trackTitles' must be strings")
368+
347369
# Handle excerptRange specially since it can be dict or list
348370
if 'excerptRange' in opts and opts['excerptRange'] is not None:
349371
if not isinstance(opts['excerptRange'], (list, dict)):
@@ -1311,6 +1333,7 @@ def to_json(self) -> Dict[str, Any]:
13111333
"raga": self.raga.to_json(),
13121334
"phraseGrid": [[p.to_json() for p in row] for row in self.phrase_grid],
13131335
"instrumentation": [i.value if isinstance(i, Instrument) else i for i in self.instrumentation],
1336+
"trackTitles": self.track_titles,
13141337
"durTot": self.dur_tot,
13151338
"durArrayGrid": self.dur_array_grid,
13161339
"meters": [m.to_json() for m in self.meters],

idtap/tests/piece_test.py

Lines changed: 159 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -906,3 +906,162 @@ def test_piece_serialization_reconnects_groups_and_fixes_slide():
906906
assert reconstructed.trajectories[0] is clone.phrases[0].trajectory_grid[0][0]
907907
assert reconstructed.trajectories[1] is clone.phrases[0].trajectory_grid[0][1]
908908
assert clone.phrases[0].trajectory_grid[0][0].articulations['0.00'].name == 'pluck'
909+
910+
911+
# ----------------------------------------------------------------------
912+
# Track Titles Tests (Issue #44)
913+
# ----------------------------------------------------------------------
914+
915+
def test_track_titles_default_initialization():
916+
"""Test that trackTitles defaults to empty strings matching instrumentation length."""
917+
raga = Raga()
918+
phrase = Phrase({'trajectories': [Trajectory({'dur_tot': 1})], 'raga': raga})
919+
920+
# Single instrument
921+
piece = Piece({
922+
'phrases': [phrase],
923+
'instrumentation': [Instrument.Sitar],
924+
'raga': raga
925+
})
926+
assert piece.track_titles == ['']
927+
928+
# Multiple instruments
929+
piece_multi = Piece({
930+
'phraseGrid': [[phrase], [phrase]],
931+
'instrumentation': [Instrument.Sitar, Instrument.Vocal_M],
932+
'raga': raga
933+
})
934+
assert piece_multi.track_titles == ['', '']
935+
936+
937+
def test_track_titles_explicit_values():
938+
"""Test trackTitles with explicit values provided."""
939+
raga = Raga()
940+
phrase = Phrase({'trajectories': [Trajectory({'dur_tot': 1})], 'raga': raga})
941+
942+
piece = Piece({
943+
'phraseGrid': [[phrase], [phrase], [phrase]],
944+
'instrumentation': [Instrument.Sarangi, Instrument.Sarangi, Instrument.Sarangi],
945+
'trackTitles': ['Lead Melody', 'Harmony', 'Drone'],
946+
'raga': raga
947+
})
948+
assert piece.track_titles == ['Lead Melody', 'Harmony', 'Drone']
949+
950+
951+
def test_track_titles_length_synchronization_shorter():
952+
"""Test that shorter trackTitles array is padded with empty strings."""
953+
raga = Raga()
954+
phrase = Phrase({'trajectories': [Trajectory({'dur_tot': 1})], 'raga': raga})
955+
956+
piece = Piece({
957+
'phraseGrid': [[phrase], [phrase], [phrase]],
958+
'instrumentation': [Instrument.Sarangi, Instrument.Sarangi, Instrument.Sarangi],
959+
'trackTitles': ['Lead'],
960+
'raga': raga
961+
})
962+
assert len(piece.track_titles) == 3
963+
assert piece.track_titles == ['Lead', '', '']
964+
965+
966+
def test_track_titles_length_synchronization_longer():
967+
"""Test that longer trackTitles array is truncated to match instrumentation."""
968+
raga = Raga()
969+
phrase = Phrase({'trajectories': [Trajectory({'dur_tot': 1})], 'raga': raga})
970+
971+
piece = Piece({
972+
'phrases': [phrase],
973+
'instrumentation': [Instrument.Sitar],
974+
'trackTitles': ['Main', 'Extra', 'Another'],
975+
'raga': raga
976+
})
977+
assert len(piece.track_titles) == 1
978+
assert piece.track_titles == ['Main']
979+
980+
981+
def test_track_titles_type_validation_not_list():
982+
"""Test that non-list trackTitles raises TypeError."""
983+
raga = Raga()
984+
phrase = Phrase({'trajectories': [Trajectory({'dur_tot': 1})], 'raga': raga})
985+
986+
with pytest.raises(TypeError, match="Parameter 'trackTitles' must be a list"):
987+
Piece({
988+
'phrases': [phrase],
989+
'instrumentation': [Instrument.Sitar],
990+
'trackTitles': 'not a list',
991+
'raga': raga
992+
})
993+
994+
995+
def test_track_titles_type_validation_non_string_items():
996+
"""Test that trackTitles with non-string items raises TypeError."""
997+
raga = Raga()
998+
phrase = Phrase({'trajectories': [Trajectory({'dur_tot': 1})], 'raga': raga})
999+
1000+
with pytest.raises(TypeError, match="All items in 'trackTitles' must be strings"):
1001+
Piece({
1002+
'phrases': [phrase],
1003+
'instrumentation': [Instrument.Sitar],
1004+
'trackTitles': [123],
1005+
'raga': raga
1006+
})
1007+
1008+
1009+
def test_track_titles_serialization_round_trip():
1010+
"""Test that trackTitles survives serialization and deserialization."""
1011+
raga = Raga()
1012+
phrase = Phrase({'trajectories': [Trajectory({'dur_tot': 1})], 'raga': raga})
1013+
1014+
piece = Piece({
1015+
'phraseGrid': [[phrase], [phrase]],
1016+
'instrumentation': [Instrument.Sitar, Instrument.Sarangi],
1017+
'trackTitles': ['Melody', 'Harmony'],
1018+
'raga': raga
1019+
})
1020+
1021+
# Serialize and deserialize
1022+
json_obj = piece.to_json()
1023+
assert 'trackTitles' in json_obj
1024+
assert json_obj['trackTitles'] == ['Melody', 'Harmony']
1025+
1026+
copy = Piece.from_json(json_obj)
1027+
assert copy.track_titles == ['Melody', 'Harmony']
1028+
1029+
# Round trip again
1030+
assert copy.to_json()['trackTitles'] == piece.to_json()['trackTitles']
1031+
1032+
1033+
def test_track_titles_empty_string_values():
1034+
"""Test that empty strings are valid trackTitles values."""
1035+
raga = Raga()
1036+
phrase = Phrase({'trajectories': [Trajectory({'dur_tot': 1})], 'raga': raga})
1037+
1038+
piece = Piece({
1039+
'phraseGrid': [[phrase], [phrase]],
1040+
'instrumentation': [Instrument.Sitar, Instrument.Vocal_M],
1041+
'trackTitles': ['', ''],
1042+
'raga': raga
1043+
})
1044+
assert piece.track_titles == ['', '']
1045+
1046+
1047+
def test_track_titles_sarangi_trio_use_case():
1048+
"""Test the sarangi trio use case from the issue."""
1049+
raga = Raga()
1050+
phrase = Phrase({'trajectories': [Trajectory({'dur_tot': 1})], 'raga': raga})
1051+
1052+
piece = Piece({
1053+
'phraseGrid': [[phrase], [phrase], [phrase]],
1054+
'instrumentation': [Instrument.Sarangi, Instrument.Sarangi, Instrument.Sarangi],
1055+
'trackTitles': ['Lead Melody', 'Harmony', 'Drone'],
1056+
'raga': raga
1057+
})
1058+
1059+
assert len(piece.track_titles) == len(piece.instrumentation)
1060+
assert piece.track_titles[0] == 'Lead Melody'
1061+
assert piece.track_titles[1] == 'Harmony'
1062+
assert piece.track_titles[2] == 'Drone'
1063+
1064+
# Verify serialization preserves the titles
1065+
json_obj = piece.to_json()
1066+
copy = Piece.from_json(json_obj)
1067+
assert copy.track_titles == piece.track_titles

0 commit comments

Comments
 (0)