diff --git a/idtap/classes/piece.py b/idtap/classes/piece.py index 2a6cf44..7afdaa3 100644 --- a/idtap/classes/piece.py +++ b/idtap/classes/piece.py @@ -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], @@ -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 @@ -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)): @@ -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], diff --git a/idtap/tests/piece_test.py b/idtap/tests/piece_test.py index 4e69fc0..42a1550 100644 --- a/idtap/tests/piece_test.py +++ b/idtap/tests/piece_test.py @@ -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