Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 30 additions & 7 deletions idtap/classes/piece.py
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,20 @@ def __init__(self, options: Optional[dict] = None) -> None:
inst_list.append(i)
self.instrumentation = inst_list

# Initialize trackTitles (after instrumentation is set)
track_titles = opts.get('trackTitles')
if track_titles is not None:
self.track_titles = list(track_titles) # Create a copy
else:
# Create empty strings for each instrument track
self.track_titles = [''] * len(self.instrumentation)

# Ensure trackTitles array matches instrumentation length
while len(self.track_titles) < len(self.instrumentation):
self.track_titles.append('')
while len(self.track_titles) > len(self.instrumentation):
self.track_titles.pop()

self.possible_trajs: Dict[Instrument, List[int]] = {
Instrument.Sitar: list(range(14)),
Instrument.Vocal_M: [0, 1, 2, 3, 4, 5, 6, 12, 13],
Expand Down Expand Up @@ -215,12 +229,13 @@ def _validate_parameters(self, opts: dict) -> None:

# Define allowed parameter names
allowed_keys = {
'raga', 'instrumentation', 'phraseGrid', 'phrases', 'title', 'dateCreated',
'dateModified', 'location', '_id', 'audioID', 'audio_DB_ID', 'userID', 'name',
'family_name', 'given_name', 'permissions', 'soloist', 'soloInstrument',
'explicitPermissions', 'meters', 'sectionStartsGrid', 'sectionStarts',
'sectionCatGrid', 'sectionCategorization', 'adHocSectionCatGrid', 'excerptRange',
'assemblageDescriptors', 'collections', 'durTot', 'durArrayGrid', 'durArray'
'raga', 'instrumentation', 'phraseGrid', 'phrases', 'title', 'dateCreated',
'dateModified', 'location', '_id', 'audioID', 'audio_DB_ID', 'userID', 'name',
'family_name', 'given_name', 'permissions', 'soloist', 'soloInstrument',
'explicitPermissions', 'meters', 'sectionStartsGrid', 'sectionStarts',
'sectionCatGrid', 'sectionCategorization', 'adHocSectionCatGrid', 'excerptRange',
'assemblageDescriptors', 'collections', 'durTot', 'durArrayGrid', 'durArray',
'trackTitles'
}
provided_keys = set(opts.keys())
invalid_keys = provided_keys - allowed_keys
Expand Down Expand Up @@ -343,7 +358,14 @@ def _validate_parameter_types(self, opts: dict) -> None:
raise TypeError(f"Parameter 'collections' must be a list, got {type(opts['collections']).__name__}")
if not all(isinstance(item, str) for item in opts['collections']):
raise TypeError("All items in 'collections' must be strings")


# Validate trackTitles
if 'trackTitles' in opts and opts['trackTitles'] is not None:
if not isinstance(opts['trackTitles'], list):
raise TypeError(f"Parameter 'trackTitles' must be a list, got {type(opts['trackTitles']).__name__}")
if not all(isinstance(title, str) for title in opts['trackTitles']):
raise TypeError("All items in 'trackTitles' must be strings")

# Handle excerptRange specially since it can be dict or list
if 'excerptRange' in opts and opts['excerptRange'] is not None:
if not isinstance(opts['excerptRange'], (list, dict)):
Expand Down Expand Up @@ -1311,6 +1333,7 @@ def to_json(self) -> Dict[str, Any]:
"raga": self.raga.to_json(),
"phraseGrid": [[p.to_json() for p in row] for row in self.phrase_grid],
"instrumentation": [i.value if isinstance(i, Instrument) else i for i in self.instrumentation],
"trackTitles": self.track_titles,
"durTot": self.dur_tot,
"durArrayGrid": self.dur_array_grid,
"meters": [m.to_json() for m in self.meters],
Expand Down
159 changes: 159 additions & 0 deletions idtap/tests/piece_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -906,3 +906,162 @@ def test_piece_serialization_reconnects_groups_and_fixes_slide():
assert reconstructed.trajectories[0] is clone.phrases[0].trajectory_grid[0][0]
assert reconstructed.trajectories[1] is clone.phrases[0].trajectory_grid[0][1]
assert clone.phrases[0].trajectory_grid[0][0].articulations['0.00'].name == 'pluck'


# ----------------------------------------------------------------------
# Track Titles Tests (Issue #44)
# ----------------------------------------------------------------------

def test_track_titles_default_initialization():
"""Test that trackTitles defaults to empty strings matching instrumentation length."""
raga = Raga()
phrase = Phrase({'trajectories': [Trajectory({'dur_tot': 1})], 'raga': raga})

# Single instrument
piece = Piece({
'phrases': [phrase],
'instrumentation': [Instrument.Sitar],
'raga': raga
})
assert piece.track_titles == ['']

# Multiple instruments
piece_multi = Piece({
'phraseGrid': [[phrase], [phrase]],
'instrumentation': [Instrument.Sitar, Instrument.Vocal_M],
'raga': raga
})
assert piece_multi.track_titles == ['', '']


def test_track_titles_explicit_values():
"""Test trackTitles with explicit values provided."""
raga = Raga()
phrase = Phrase({'trajectories': [Trajectory({'dur_tot': 1})], 'raga': raga})

piece = Piece({
'phraseGrid': [[phrase], [phrase], [phrase]],
'instrumentation': [Instrument.Sarangi, Instrument.Sarangi, Instrument.Sarangi],
'trackTitles': ['Lead Melody', 'Harmony', 'Drone'],
'raga': raga
})
assert piece.track_titles == ['Lead Melody', 'Harmony', 'Drone']


def test_track_titles_length_synchronization_shorter():
"""Test that shorter trackTitles array is padded with empty strings."""
raga = Raga()
phrase = Phrase({'trajectories': [Trajectory({'dur_tot': 1})], 'raga': raga})

piece = Piece({
'phraseGrid': [[phrase], [phrase], [phrase]],
'instrumentation': [Instrument.Sarangi, Instrument.Sarangi, Instrument.Sarangi],
'trackTitles': ['Lead'],
'raga': raga
})
assert len(piece.track_titles) == 3
assert piece.track_titles == ['Lead', '', '']


def test_track_titles_length_synchronization_longer():
"""Test that longer trackTitles array is truncated to match instrumentation."""
raga = Raga()
phrase = Phrase({'trajectories': [Trajectory({'dur_tot': 1})], 'raga': raga})

piece = Piece({
'phrases': [phrase],
'instrumentation': [Instrument.Sitar],
'trackTitles': ['Main', 'Extra', 'Another'],
'raga': raga
})
assert len(piece.track_titles) == 1
assert piece.track_titles == ['Main']


def test_track_titles_type_validation_not_list():
"""Test that non-list trackTitles raises TypeError."""
raga = Raga()
phrase = Phrase({'trajectories': [Trajectory({'dur_tot': 1})], 'raga': raga})

with pytest.raises(TypeError, match="Parameter 'trackTitles' must be a list"):
Piece({
'phrases': [phrase],
'instrumentation': [Instrument.Sitar],
'trackTitles': 'not a list',
'raga': raga
})


def test_track_titles_type_validation_non_string_items():
"""Test that trackTitles with non-string items raises TypeError."""
raga = Raga()
phrase = Phrase({'trajectories': [Trajectory({'dur_tot': 1})], 'raga': raga})

with pytest.raises(TypeError, match="All items in 'trackTitles' must be strings"):
Piece({
'phrases': [phrase],
'instrumentation': [Instrument.Sitar],
'trackTitles': [123],
'raga': raga
})


def test_track_titles_serialization_round_trip():
"""Test that trackTitles survives serialization and deserialization."""
raga = Raga()
phrase = Phrase({'trajectories': [Trajectory({'dur_tot': 1})], 'raga': raga})

piece = Piece({
'phraseGrid': [[phrase], [phrase]],
'instrumentation': [Instrument.Sitar, Instrument.Sarangi],
'trackTitles': ['Melody', 'Harmony'],
'raga': raga
})

# Serialize and deserialize
json_obj = piece.to_json()
assert 'trackTitles' in json_obj
assert json_obj['trackTitles'] == ['Melody', 'Harmony']

copy = Piece.from_json(json_obj)
assert copy.track_titles == ['Melody', 'Harmony']

# Round trip again
assert copy.to_json()['trackTitles'] == piece.to_json()['trackTitles']


def test_track_titles_empty_string_values():
"""Test that empty strings are valid trackTitles values."""
raga = Raga()
phrase = Phrase({'trajectories': [Trajectory({'dur_tot': 1})], 'raga': raga})

piece = Piece({
'phraseGrid': [[phrase], [phrase]],
'instrumentation': [Instrument.Sitar, Instrument.Vocal_M],
'trackTitles': ['', ''],
'raga': raga
})
assert piece.track_titles == ['', '']


def test_track_titles_sarangi_trio_use_case():
"""Test the sarangi trio use case from the issue."""
raga = Raga()
phrase = Phrase({'trajectories': [Trajectory({'dur_tot': 1})], 'raga': raga})

piece = Piece({
'phraseGrid': [[phrase], [phrase], [phrase]],
'instrumentation': [Instrument.Sarangi, Instrument.Sarangi, Instrument.Sarangi],
'trackTitles': ['Lead Melody', 'Harmony', 'Drone'],
'raga': raga
})

assert len(piece.track_titles) == len(piece.instrumentation)
assert piece.track_titles[0] == 'Lead Melody'
assert piece.track_titles[1] == 'Harmony'
assert piece.track_titles[2] == 'Drone'

# Verify serialization preserves the titles
json_obj = piece.to_json()
copy = Piece.from_json(json_obj)
assert copy.track_titles == piece.track_titles
Loading