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

Commit 2db4ed8

Browse files
jon-myersclaude
andauthored
Fix Issue #38: Resolve cycle boundary failures in get_musical_time() (#39)
* fix: allow exact end time in get_musical_time() boundary validation (#38) - Change boundary condition from >= to > to allow exact end time - Add comprehensive test for cycle boundary scenarios - Update existing boundary test to reflect correct behavior - Resolves trajectory visualization issues at cycle boundaries Fixes issue where get_musical_time() returned False for timestamps at exact cycle boundaries, causing missing trajectory curves in meter-time visualizations. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com> * fix: resolve negative hierarchical positions in pulse correction Fixed _pulse_index_to_hierarchical_position method to prevent negative positions when correcting pulse indices across cycle boundaries. The method now uses modulo arithmetic to correctly determine within-cycle positions, resolving "All hierarchical positions must be non-negative" errors that occurred with offset pulses from real transcription data. This completes the comprehensive fix for Issue #38, ensuring get_musical_time() works correctly with both theoretical and actual pulse timing variations. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com> --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 58ce90c commit 2db4ed8

2 files changed

Lines changed: 76 additions & 9 deletions

File tree

idtap/classes/meter.py

Lines changed: 32 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -474,9 +474,12 @@ def _hierarchical_position_to_pulse_index(self, positions: List[int], cycle_numb
474474

475475
def _pulse_index_to_hierarchical_position(self, pulse_index: int, cycle_number: int) -> List[int]:
476476
"""Convert pulse index back to hierarchical position (reverse of _hierarchical_position_to_pulse_index)."""
477-
# Remove cycle offset
478-
cycle_offset = cycle_number * self._pulses_per_cycle
479-
within_cycle_index = pulse_index - cycle_offset
477+
# Use modulo to get within-cycle index regardless of which cycle the pulse belongs to
478+
within_cycle_index = pulse_index % self._pulses_per_cycle
479+
480+
# Ensure within_cycle_index is non-negative
481+
if within_cycle_index < 0:
482+
within_cycle_index = 0
480483

481484
positions = []
482485
remaining_index = within_cycle_index
@@ -496,9 +499,9 @@ def _pulse_index_to_hierarchical_position(self, pulse_index: int, cycle_number:
496499
inner_size = sum(inner_size)
497500
group_size = group_size // inner_size
498501

499-
position_at_level = remaining_index // group_size
502+
position_at_level = remaining_index // group_size if group_size > 0 else 0
500503
positions.append(position_at_level)
501-
remaining_index = remaining_index % group_size
504+
remaining_index = remaining_index % group_size if group_size > 0 else 0
502505

503506
return positions
504507

@@ -561,8 +564,30 @@ def get_musical_time(self, real_time: float, reference_level: Optional[int] = No
561564
if real_time < self.start_time:
562565
return False
563566

564-
end_time = self.start_time + self.repetitions * self.cycle_dur
565-
if real_time >= end_time:
567+
# Calculate proper end time based on actual pulse timing
568+
# For intermediate cycles: use actual next cycle start pulse
569+
# For final cycle: use theoretical calculation (no next cycle exists)
570+
if self.all_pulses and len(self.all_pulses) > 0:
571+
# Calculate which cycle this time would fall into
572+
relative_time = real_time - self.start_time
573+
potential_cycle = int(relative_time // self.cycle_dur)
574+
575+
if potential_cycle < self.repetitions - 1:
576+
# This is an intermediate cycle - use actual next cycle start pulse
577+
next_cycle_first_pulse_index = (potential_cycle + 1) * self._pulses_per_cycle
578+
if next_cycle_first_pulse_index < len(self.all_pulses):
579+
actual_end_time = self.all_pulses[next_cycle_first_pulse_index].real_time
580+
else:
581+
# Fallback to theoretical if pulse doesn't exist
582+
actual_end_time = self.start_time + self.repetitions * self.cycle_dur
583+
else:
584+
# This is the final cycle - use theoretical end time
585+
actual_end_time = self.start_time + self.repetitions * self.cycle_dur
586+
else:
587+
# No pulses available - use theoretical calculation
588+
actual_end_time = self.start_time + self.repetitions * self.cycle_dur
589+
590+
if real_time > actual_end_time:
566591
return False
567592

568593
# Validate reference level

idtap/tests/musical_time_test.py

Lines changed: 44 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -164,8 +164,11 @@ def test_boundary_conditions(self):
164164
result = meter.get_musical_time(end_time - 0.01)
165165
assert result is not False
166166

167-
# At or after end
168-
assert meter.get_musical_time(end_time) is False
167+
# At end (should be valid after Issue #38 fix)
168+
result = meter.get_musical_time(end_time)
169+
assert result is not False, "Exact end time should be valid (Issue #38 fix)"
170+
171+
# After end (should still be invalid)
169172
assert meter.get_musical_time(end_time + 0.01) is False
170173

171174
def test_reference_level_validation(self):
@@ -844,3 +847,42 @@ def test_issue_36_hierarchical_position_correction(self):
844847

845848
pulse_time = meter.all_pulses[pulse_index].real_time
846849
assert pulse_time <= time, f"Calculated pulse should come at/before query time {time}, got {pulse_time}"
850+
851+
def test_issue_38_cycle_boundary_failures(self):
852+
"""Test fix for Issue #38: get_musical_time() fails at cycle boundaries.
853+
854+
Ensures that get_musical_time() returns valid musical time objects for
855+
timestamps at exact cycle boundaries, including the final meter boundary.
856+
"""
857+
# Create meter with multiple cycles to test all boundary types
858+
meter = Meter(hierarchy=[4, 4, 2], start_time=0.0, tempo=60.0, repetitions=4)
859+
860+
# Test each cycle boundary including the final one
861+
for cycle in range(meter.repetitions + 1):
862+
boundary_time = meter.start_time + cycle * meter.cycle_dur
863+
864+
result = meter.get_musical_time(boundary_time)
865+
866+
# All boundaries should return valid musical time objects
867+
assert result is not False, f"Cycle boundary at {boundary_time} should return valid musical time"
868+
assert 0.0 <= result.fractional_beat < 1.0, f"fractional_beat should be in valid range for boundary {boundary_time}"
869+
870+
# Boundary should be treated as start of next cycle (if not final boundary)
871+
if cycle < meter.repetitions:
872+
assert result.cycle_number == cycle, f"Boundary {boundary_time} should be in cycle {cycle}"
873+
assert result.hierarchical_position[0] == 0, f"Boundary should be at start of hierarchical position"
874+
else:
875+
# Final boundary - should be treated as start of theoretical next cycle
876+
assert result.cycle_number == cycle, f"Final boundary should indicate cycle {cycle}"
877+
878+
# Test times very close to boundaries to ensure they also work
879+
for cycle in range(meter.repetitions):
880+
boundary_time = meter.start_time + (cycle + 1) * meter.cycle_dur
881+
882+
# Test time just before boundary
883+
near_boundary = boundary_time - 0.001
884+
result = meter.get_musical_time(near_boundary)
885+
assert result is not False, f"Time just before boundary {boundary_time} should be valid"
886+
887+
# Should be in the previous cycle
888+
assert result.cycle_number == cycle, f"Time before boundary should be in cycle {cycle}"

0 commit comments

Comments
 (0)