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

Commit 49e18ae

Browse files
authored
fix: prevent IndexError in get_musical_time with reference_level=0 (#26) (#27)
- Added bounds checking in _calculate_level_start_time to handle pulse indices that exceed the all_pulses array bounds - When pulse index exceeds bounds, gracefully return meter end time instead of crashing with IndexError - Added comprehensive test cases for boundary conditions and edge cases - All existing tests continue to pass Fixes #26: IndexError in get_musical_time() with reference_level=0
1 parent 3383fdc commit 49e18ae

2 files changed

Lines changed: 160 additions & 1 deletion

File tree

idtap/classes/meter.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -473,6 +473,19 @@ def _calculate_level_start_time(self, positions: List[int], cycle_number: int, r
473473
start_positions.append(0)
474474

475475
start_pulse_index = self._hierarchical_position_to_pulse_index(start_positions, cycle_number)
476+
477+
# Add bounds checking to prevent IndexError
478+
if start_pulse_index < 0 or start_pulse_index >= len(self.all_pulses):
479+
# This can happen when calculating duration of the last unit in a level
480+
# In such cases, we should use the meter's end time
481+
if start_pulse_index >= len(self.all_pulses):
482+
# Beyond the last pulse - use meter end time
483+
total_duration = self.repetitions * self.cycle_dur
484+
return self.start_time + total_duration
485+
else:
486+
# Negative index (shouldn't happen but defensive)
487+
return self.start_time
488+
476489
return self.all_pulses[start_pulse_index].real_time
477490

478491
def _calculate_level_duration(self, positions: List[int], cycle_number: int, reference_level: int) -> float:

idtap/tests/musical_time_test.py

Lines changed: 147 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -355,4 +355,150 @@ def test_complex_list_hierarchy_overflow(self):
355355
# Test with reference level on list hierarchy
356356
result = meter.get_musical_time(1.5, reference_level=0)
357357
assert result is not False
358-
assert len(result.hierarchical_position) == 1
358+
assert len(result.hierarchical_position) == 1
359+
360+
def test_reference_level_zero_indexerror_reproduction(self):
361+
"""Test to reproduce IndexError with reference_level=0 (Issue #26)."""
362+
# Try different meter configurations that might trigger the error
363+
test_configs = [
364+
([4, 4, 2], 120),
365+
([2, 3, 4], 60),
366+
([3, 2], 240),
367+
([8], 120),
368+
([2, 2, 2, 2], 180)
369+
]
370+
371+
for hierarchy, tempo in test_configs:
372+
meter = Meter(hierarchy=hierarchy, tempo=tempo, start_time=0)
373+
374+
# Test various time points within the meter
375+
cycle_duration = meter.cycle_dur
376+
test_times = [
377+
0.1, # Near start
378+
cycle_duration * 0.25, # Quarter way through
379+
cycle_duration * 0.5, # Half way
380+
cycle_duration * 0.75, # Three quarters
381+
cycle_duration * 0.99, # Near end
382+
]
383+
384+
for time_point in test_times:
385+
try:
386+
result = meter.get_musical_time(time_point, reference_level=0)
387+
if result is not False: # Only check if within meter bounds
388+
assert len(result.hierarchical_position) == 1, f"Should have 1 position for reference_level=0 with hierarchy {hierarchy}"
389+
assert result.hierarchical_position[0] >= 0, "Position should be non-negative"
390+
except IndexError as e:
391+
pytest.fail(f"IndexError raised with hierarchy {hierarchy}, tempo {tempo}, time {time_point}, reference_level=0: {e}")
392+
except Exception as e:
393+
# Let other exceptions bubble up with context
394+
pytest.fail(f"Unexpected error with hierarchy {hierarchy}, tempo {tempo}, time {time_point}: {e}")
395+
396+
# Test edge case: reference_level=0 with positions that might cause overflow
397+
meter = Meter(hierarchy=[2, 2], tempo=60, start_time=0)
398+
try:
399+
# Test at exact beat boundaries which might cause index issues
400+
result = meter.get_musical_time(1.0, reference_level=0) # Exactly at beat 1
401+
if result is not False:
402+
assert len(result.hierarchical_position) == 1
403+
except IndexError as e:
404+
pytest.fail(f"IndexError at beat boundary with reference_level=0: {e}")
405+
406+
# Test with multi-cycle meter - this might trigger the error
407+
meter = Meter(hierarchy=[4, 4, 2], tempo=120, start_time=0, repetitions=2)
408+
try:
409+
# Test near the end of cycle or at various points
410+
test_times = [meter.cycle_dur - 0.01, meter.cycle_dur + 0.01, meter.cycle_dur * 1.5]
411+
for t in test_times:
412+
result = meter.get_musical_time(t, reference_level=0)
413+
if result is not False:
414+
assert len(result.hierarchical_position) == 1
415+
except IndexError as e:
416+
pytest.fail(f"IndexError with multi-cycle meter and reference_level=0: {e}")
417+
418+
# Test very specific timing that might trigger calculation edge case
419+
meter = Meter(hierarchy=[4, 4, 2], tempo=120, start_time=0)
420+
try:
421+
# Test at the end of each beat - this is where overflow might happen
422+
beat_duration = 60.0 / 120 # 0.5 seconds per beat at 120 BPM
423+
for beat in range(4): # Test each beat in the cycle
424+
time_at_end_of_beat = beat_duration * (beat + 1) - 0.001 # Just before next beat
425+
result = meter.get_musical_time(time_at_end_of_beat, reference_level=0)
426+
if result is not False:
427+
assert len(result.hierarchical_position) == 1
428+
except IndexError as e:
429+
pytest.fail(f"IndexError at beat boundaries with reference_level=0: {e}")
430+
431+
# Test the specific case where next_positions causes pulse index overflow
432+
# This happens when we're at the last beat of a cycle with reference_level=0
433+
meter = Meter(hierarchy=[4, 2], tempo=120, start_time=0, repetitions=1)
434+
try:
435+
# Get close to the end of the last beat (beat 3, index 3 in hierarchy [4, 2])
436+
# With tempo 120, beat duration is 0.5 seconds
437+
# Total cycle duration should be 4 beats * 0.5 = 2.0 seconds
438+
# Let's test at beat 3.9 (just before beat 4, which would overflow)
439+
time_near_end = 3.9 * 0.5 # Should be 1.95 seconds
440+
result = meter.get_musical_time(time_near_end, reference_level=0)
441+
if result is not False:
442+
assert len(result.hierarchical_position) == 1
443+
# This should trigger the duration calculation that tries to find the "next beat"
444+
# which would be beat 4 (index 4), causing overflow since hierarchy[0] = 4 (indices 0,1,2,3)
445+
except IndexError as e:
446+
pytest.fail(f"IndexError when calculating duration near end of cycle with reference_level=0: {e}")
447+
448+
# Even more specific test - try to force the exact overflow scenario
449+
meter = Meter(hierarchy=[2], tempo=60, start_time=0, repetitions=1)
450+
try:
451+
# With hierarchy [2], we have beats 0 and 1
452+
# Test at beat 1 (the last beat) - this should cause next_position[0] = 2, which overflows
453+
beat_1_time = 1.0 * (60.0 / 60.0) * 0.9 # 90% through beat 1
454+
result = meter.get_musical_time(beat_1_time, reference_level=0)
455+
if result is not False:
456+
assert len(result.hierarchical_position) == 1
457+
except IndexError as e:
458+
pytest.fail(f"IndexError with simple [2] hierarchy at last beat with reference_level=0: {e}")
459+
460+
def test_reference_level_zero_bounds_checking(self):
461+
"""Test that bounds checking prevents IndexError when pulse index exceeds bounds."""
462+
# Test with a simple meter where we can predictably hit boundary conditions
463+
meter = Meter(hierarchy=[2, 2], tempo=60, start_time=0, repetitions=1)
464+
465+
# Test at various points including near boundaries
466+
# The key is testing reference_level=0 which might try to calculate duration
467+
# by looking for the "next beat" which could exceed pulse array bounds
468+
test_times = []
469+
beat_duration = 60.0 / 60.0 # 1 second per beat at 60 BPM
470+
471+
# Add times throughout the meter, especially near beat boundaries
472+
for beat in range(2): # 2 beats in hierarchy [2, 2]
473+
for fraction in [0.1, 0.5, 0.9, 0.99]:
474+
test_time = beat * beat_duration + fraction * beat_duration
475+
test_times.append(test_time)
476+
477+
# Test all time points with reference_level=0
478+
for time_point in test_times:
479+
result = meter.get_musical_time(time_point, reference_level=0)
480+
if result is not False:
481+
assert len(result.hierarchical_position) == 1, f"Should have 1 position at time {time_point}"
482+
assert isinstance(result.fractional_beat, float), f"Should have valid fractional_beat at time {time_point}"
483+
assert 0.0 <= result.fractional_beat <= 1.0, f"fractional_beat should be in [0,1] at time {time_point}"
484+
485+
# Test specifically at the boundary that might cause the original IndexError
486+
# When we're in the last beat and try to calculate duration
487+
last_beat_time = 1.8 # Near end of beat 1 (last beat) in a 2-beat cycle
488+
result = meter.get_musical_time(last_beat_time, reference_level=0)
489+
if result is not False:
490+
assert len(result.hierarchical_position) == 1
491+
assert result.hierarchical_position[0] == 1 # Should be in beat 1 (second beat)
492+
493+
def test_defensive_bounds_in_calculate_level_start_time(self):
494+
"""Test that _calculate_level_start_time handles out-of-bounds indices gracefully."""
495+
meter = Meter(hierarchy=[3], tempo=120, start_time=0, repetitions=1)
496+
497+
# This should work without IndexError even if internal calculations go out of bounds
498+
# Test near the end of the cycle where "next beat" calculations might overflow
499+
near_end_time = meter.cycle_dur * 0.95
500+
result = meter.get_musical_time(near_end_time, reference_level=0)
501+
502+
if result is not False:
503+
assert len(result.hierarchical_position) == 1
504+
# Should not crash and should give reasonable results

0 commit comments

Comments
 (0)