Skip to content

Commit 4ff9fc4

Browse files
jon-myersclaude
andcommitted
fix: correct hierarchical position calculation in multi-cycle meters (#36)
- Fix bug where hierarchical position calculation pointed to pulses after query time - Add pulse time validation and correction logic in get_musical_time() - Implement _pulse_index_to_hierarchical_position() for reverse calculation - Add comprehensive test for Issue #36 scenario - Ensure fractional_beat values are calculated correctly for trajectory analysis Resolves fractional_beat=0.0 clamping issue that affected musical time analysis and trajectory curve visualization in multi-cycle meters with timing variations. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
1 parent 4c4a16b commit 4ff9fc4

File tree

2 files changed

+85
-2
lines changed

2 files changed

+85
-2
lines changed

idtap/classes/meter.py

Lines changed: 46 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -472,6 +472,36 @@ def _hierarchical_position_to_pulse_index(self, positions: List[int], cycle_numb
472472
cycle_offset = cycle_number * self._pulses_per_cycle
473473
return pulse_index + cycle_offset
474474

475+
def _pulse_index_to_hierarchical_position(self, pulse_index: int, cycle_number: int) -> List[int]:
476+
"""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
480+
481+
positions = []
482+
remaining_index = within_cycle_index
483+
484+
# Work from coarsest to finest level
485+
for level in range(len(self.hierarchy)):
486+
hierarchy_size = self.hierarchy[level]
487+
488+
if isinstance(hierarchy_size, list):
489+
hierarchy_size = sum(hierarchy_size)
490+
491+
# Calculate how many pulses are in each group at this level
492+
group_size = self._pulses_per_cycle
493+
for inner_level in range(level + 1):
494+
inner_size = self.hierarchy[inner_level]
495+
if isinstance(inner_size, list):
496+
inner_size = sum(inner_size)
497+
group_size = group_size // inner_size
498+
499+
position_at_level = remaining_index // group_size
500+
positions.append(position_at_level)
501+
remaining_index = remaining_index % group_size
502+
503+
return positions
504+
475505
def _calculate_level_start_time(self, positions: List[int], cycle_number: int, reference_level: int) -> float:
476506
"""Calculate start time of hierarchical unit at reference level."""
477507

@@ -550,7 +580,7 @@ def get_musical_time(self, real_time: float, reference_level: Optional[int] = No
550580
total_finest_subdivisions = self._pulses_per_cycle
551581
current_group_size = total_finest_subdivisions
552582

553-
for level, size in enumerate(self.hierarchy):
583+
for size in self.hierarchy:
554584
if isinstance(size, list):
555585
size = sum(size)
556586

@@ -572,12 +602,26 @@ def get_musical_time(self, real_time: float, reference_level: Optional[int] = No
572602
# This is independent of reference_level, which only affects hierarchical_position truncation
573603
current_pulse_index = self._hierarchical_position_to_pulse_index(positions, cycle_number)
574604

575-
# Bounds checking
605+
# Bounds checking and hierarchical position correction
576606
if current_pulse_index < 0 or current_pulse_index >= len(self.all_pulses):
577607
fractional_beat = 0.0
578608
else:
579609
current_pulse_time = self.all_pulses[current_pulse_index].real_time
580610

611+
# FIX: If the calculated pulse comes after the query time, find the correct pulse
612+
# This happens when hierarchical position calculation rounds up due to timing variations
613+
if current_pulse_time > real_time:
614+
# Find the pulse that comes at or before the query time
615+
corrected_pulse_index = current_pulse_index
616+
while corrected_pulse_index > 0 and self.all_pulses[corrected_pulse_index].real_time > real_time:
617+
corrected_pulse_index -= 1
618+
current_pulse_index = corrected_pulse_index
619+
current_pulse_time = self.all_pulses[current_pulse_index].real_time
620+
621+
# Update positions to reflect the corrected pulse index
622+
# This ensures consistency between hierarchical_position and actual pulse used
623+
positions = self._pulse_index_to_hierarchical_position(current_pulse_index, cycle_number)
624+
581625
# Handle next pulse
582626
if current_pulse_index + 1 < len(self.all_pulses):
583627
next_pulse_time = self.all_pulses[current_pulse_index + 1].real_time

idtap/tests/musical_time_test.py

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -805,3 +805,42 @@ def test_deep_investigation_of_fractional_beat_calculation(self):
805805
print("\nThis test helps identify if the issue is related to position truncation when")
806806
print("we're in the middle of subdivisions but reference_level=0 calculation starts")
807807
print("from the wrong subdivision boundary.")
808+
809+
def test_issue_36_hierarchical_position_correction(self):
810+
"""Test fix for Issue #36: hierarchical position calculation bug in multi-cycle meters.
811+
812+
Ensures that the hierarchical position calculation always finds a pulse that comes
813+
at or before the query time, preventing negative fractional_beat values that get
814+
clamped to 0.0.
815+
"""
816+
# Create meter with timing variations that expose the hierarchical calculation bug
817+
meter = Meter(hierarchy=[4, 4, 2], start_time=4.0, tempo=60.0, repetitions=4)
818+
819+
# Introduce pulse timing variations that can trigger the bug
820+
for i, pulse in enumerate(meter.all_pulses):
821+
if i % 7 == 0: # Every 7th pulse gets adjusted timing
822+
pulse.real_time += 0.05 # 50ms later - enough to cause issues
823+
824+
# Re-sort pulses by time after timing modifications
825+
meter.pulse_structures[0][0].pulses.sort(key=lambda p: p.real_time)
826+
827+
# Test times that would previously cause fractional_beat=0.0 due to the bug
828+
test_times = [5.0, 8.5, 12.2, 16.8]
829+
830+
for time in test_times:
831+
result = meter.get_musical_time(time)
832+
833+
# Should be within meter boundaries
834+
assert result is not False, f"Time {time} should be within meter boundaries"
835+
836+
# The fix ensures fractional_beat is reasonable (not clamped to 0.0 due to bug)
837+
assert 0.0 <= result.fractional_beat < 1.0, f"fractional_beat out of range for time {time}"
838+
839+
# Verify hierarchical position points to correct pulse
840+
pulse_index = meter._hierarchical_position_to_pulse_index(
841+
result.hierarchical_position, result.cycle_number
842+
)
843+
assert 0 <= pulse_index < len(meter.all_pulses), f"Pulse index should be valid for time {time}"
844+
845+
pulse_time = meter.all_pulses[pulse_index].real_time
846+
assert pulse_time <= time, f"Calculated pulse should come at/before query time {time}, got {pulse_time}"

0 commit comments

Comments
 (0)