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
14 changes: 12 additions & 2 deletions idtap/classes/phrase.py
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,9 @@ def __init__(self, options: Optional[Dict[str, Any]] = None) -> None:
self.piece_idx = opts.get('piece_idx')
ad_hoc_cat = opts.get('ad_hoc_categorization_grid')

# Initialize is_section_start (optional boolean)
self.is_section_start = opts.get('is_section_start')

trajs: List[Trajectory] = []
for t in trajectories_in:
if not isinstance(t, Trajectory):
Expand Down Expand Up @@ -161,7 +164,8 @@ def _validate_parameters(self, opts: Dict[str, Any]) -> None:
allowed_keys = {
'trajectories', 'start_time', 'raga', 'instrumentation', 'trajectory_grid',
'chikari_grid', 'chikaris', 'groups_grid', 'categorization_grid',
'unique_id', 'piece_idx', 'ad_hoc_categorization_grid', 'dur_tot', 'dur_array'
'unique_id', 'piece_idx', 'ad_hoc_categorization_grid', 'dur_tot', 'dur_array',
'is_section_start'
}
provided_keys = set(opts.keys())
invalid_keys = provided_keys - allowed_keys
Expand Down Expand Up @@ -261,7 +265,12 @@ def _validate_parameter_types(self, opts: Dict[str, Any]) -> None:
raise TypeError(f"Parameter 'dur_array' must be a list, got {type(opts['dur_array']).__name__}")
if not all(isinstance(item, (int, float)) for item in opts['dur_array']):
raise TypeError("All items in 'dur_array' must be numbers")


# Validate is_section_start
if 'is_section_start' in opts and opts['is_section_start'] is not None:
if not isinstance(opts['is_section_start'], bool):
raise TypeError(f"Parameter 'is_section_start' must be a boolean, got {type(opts['is_section_start']).__name__}")

def _validate_parameter_values(self, opts: Dict[str, Any]) -> None:
"""Validate that parameter values are in valid ranges."""
if 'start_time' in opts and opts['start_time'] is not None:
Expand Down Expand Up @@ -569,6 +578,7 @@ def to_json(self) -> Dict[str, Any]:
'categorizationGrid': self.categorization_grid,
'uniqueId': self.unique_id,
'adHocCategorizationGrid': self.ad_hoc_categorization_grid,
'isSectionStart': self.is_section_start,
}

@staticmethod
Expand Down
10 changes: 10 additions & 0 deletions idtap/classes/piece.py
Original file line number Diff line number Diff line change
Expand Up @@ -177,6 +177,16 @@ def __init__(self, options: Optional[dict] = None) -> None:
ss_grid.append([0])
self.section_starts_grid: List[List[float]] = [sorted(list(s)) for s in ss_grid]

# Migrate old sectionStartsGrid to phrase-level is_section_start properties
# This enables phrase-based section tracking while maintaining backward compatibility
if self.section_starts_grid and self.phrase_grid:
for inst_idx, phrases in enumerate(self.phrase_grid):
if inst_idx < len(self.section_starts_grid):
starts = self.section_starts_grid[inst_idx]
for phrase_idx, phrase in enumerate(phrases):
# Convert indices to integers for comparison
phrase.is_section_start = phrase_idx in [int(s) for s in starts]

sc_grid = opts.get("sectionCatGrid")
if sc_grid is None:
section_cat = opts.get("sectionCategorization")
Expand Down
86 changes: 86 additions & 0 deletions idtap/tests/phrase_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -351,3 +351,89 @@ def test_missing_bol_alap_initialized():
del custom['Elaboration']['Bol Alap']
phrase = Phrase({'categorization_grid': [custom]})
assert phrase.categorization_grid[0]['Elaboration']['Bol Alap'] is False


# ----------------------------------------------------------------------
# is_section_start Tests (Issue #47)
# ----------------------------------------------------------------------

def test_is_section_start_true():
"""Test phrase with is_section_start = True."""
phrase = Phrase({
'trajectories': [],
'is_section_start': True
})
assert phrase.is_section_start is True


def test_is_section_start_false():
"""Test phrase with is_section_start = False."""
phrase = Phrase({
'trajectories': [],
'is_section_start': False
})
assert phrase.is_section_start is False


def test_is_section_start_none_default():
"""Test phrase without is_section_start (defaults to None)."""
phrase = Phrase({
'trajectories': []
})
assert phrase.is_section_start is None


def test_is_section_start_type_validation():
"""Test that non-boolean is_section_start raises TypeError."""
with pytest.raises(TypeError, match="Parameter 'is_section_start' must be a boolean"):
Phrase({
'trajectories': [],
'is_section_start': 'true' # String instead of bool
})

with pytest.raises(TypeError, match="Parameter 'is_section_start' must be a boolean"):
Phrase({
'trajectories': [],
'is_section_start': 1 # Integer instead of bool
})


def test_is_section_start_serialization():
"""Test that is_section_start is included in serialization."""
phrase_true = Phrase({
'trajectories': [],
'is_section_start': True
})
json_true = phrase_true.to_json()
assert 'isSectionStart' in json_true
assert json_true['isSectionStart'] is True

phrase_false = Phrase({
'trajectories': [],
'is_section_start': False
})
json_false = phrase_false.to_json()
assert 'isSectionStart' in json_false
assert json_false['isSectionStart'] is False

phrase_none = Phrase({
'trajectories': []
})
json_none = phrase_none.to_json()
assert 'isSectionStart' in json_none
assert json_none['isSectionStart'] is None


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

json_obj = phrase.to_json()
copy = Phrase.from_json(json_obj)

assert copy.is_section_start is True
assert copy.to_json()['isSectionStart'] == phrase.to_json()['isSectionStart']
68 changes: 68 additions & 0 deletions idtap/tests/piece_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -1065,3 +1065,71 @@ def test_track_titles_sarangi_trio_use_case():
json_obj = piece.to_json()
copy = Piece.from_json(json_obj)
assert copy.track_titles == piece.track_titles


# ----------------------------------------------------------------------
# is_section_start Migration Tests (Issue #47)
# ----------------------------------------------------------------------

def test_section_starts_grid_migration_to_phrases():
"""Test migration from old sectionStartsGrid to phrase-level is_section_start."""
raga = Raga()
phrase1 = Phrase({'trajectories': [Trajectory({'dur_tot': 1})], 'raga': raga})
phrase2 = Phrase({'trajectories': [Trajectory({'dur_tot': 1})], 'raga': raga})
phrase3 = Phrase({'trajectories': [Trajectory({'dur_tot': 1})], 'raga': raga})

# Create piece with old-style sectionStartsGrid
piece = Piece({
'phraseGrid': [[phrase1, phrase2, phrase3]],
'sectionStartsGrid': [[0, 2]], # First and third phrases are section starts
'raga': raga,
'instrumentation': [Instrument.Sitar]
})

# Verify migration happened
assert piece.phrase_grid[0][0].is_section_start is True
assert piece.phrase_grid[0][1].is_section_start is False
assert piece.phrase_grid[0][2].is_section_start is True


def test_section_starts_grid_migration_multi_track():
"""Test migration for multi-track pieces."""
raga = Raga()
p1 = Phrase({'trajectories': [Trajectory({'dur_tot': 1})], 'raga': raga})
p2 = Phrase({'trajectories': [Trajectory({'dur_tot': 1})], 'raga': raga})
p3 = Phrase({'trajectories': [Trajectory({'dur_tot': 1})], 'raga': raga})
p4 = Phrase({'trajectories': [Trajectory({'dur_tot': 1})], 'raga': raga})

piece = Piece({
'phraseGrid': [[p1, p2], [p3, p4]],
'sectionStartsGrid': [[0, 1], [1]], # Different section starts per track
'raga': raga,
'instrumentation': [Instrument.Sitar, Instrument.Vocal_M]
})

# Track 0
assert piece.phrase_grid[0][0].is_section_start is True
assert piece.phrase_grid[0][1].is_section_start is True

# Track 1
assert piece.phrase_grid[1][0].is_section_start is False
assert piece.phrase_grid[1][1].is_section_start is True


def test_phrases_with_is_section_start_preserved():
"""Test that phrases created with is_section_start keep their values."""
raga = Raga()
phrase1 = Phrase({'trajectories': [Trajectory({'dur_tot': 1})], 'is_section_start': True, 'raga': raga})
phrase2 = Phrase({'trajectories': [Trajectory({'dur_tot': 1})], 'is_section_start': False, 'raga': raga})

piece = Piece({
'phraseGrid': [[phrase1, phrase2]],
'raga': raga,
'instrumentation': [Instrument.Sitar]
})

# Migration should not override existing is_section_start values
# Since sectionStartsGrid defaults to [[0]], phrase1 should remain True
assert piece.phrase_grid[0][0].is_section_start is True
# phrase2 will be set based on sectionStartsGrid (which has 0 but not 1)
assert piece.phrase_grid[0][1].is_section_start is False
Loading