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

Commit b412df8

Browse files
jon-myersclaude
andcommitted
fix: correct fractional_beat calculation to always use finest level
The fractional_beat should always represent the position within the finest hierarchical level unit (between pulses), regardless of the reference_level parameter. The reference_level only affects hierarchical_position truncation. Fixed examples: - meter.get_musical_time(0.125) now returns C0:0.2+0.000 (was C0:0.2+0.500) - meter.get_musical_time(0.0625) now returns C0:0.1+0.000 (was C0:0.1+0.250) - meter.get_musical_time(0.03125) now returns C0:0.0+0.500 (was C0:0.0+0.125) 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
1 parent 4766ab5 commit b412df8

2 files changed

Lines changed: 51 additions & 90 deletions

File tree

idtap/classes/meter.py

Lines changed: 27 additions & 86 deletions
Original file line numberDiff line numberDiff line change
@@ -621,99 +621,40 @@ def get_musical_time(self, real_time: float, reference_level: Optional[int] = No
621621
remaining_time = remaining_time % subdivision_duration
622622

623623
# Step 4: Fractional beat calculation
624-
if ref_level == len(self.hierarchy) - 1:
625-
# Default behavior: pulse-based calculation, but check for sparse pulse data
626-
expected_pulses = self._pulses_per_cycle * self.repetitions
627-
if len(self.all_pulses) < expected_pulses * 0.5: # Less than 50% of expected pulses
628-
# Fall back to proportional timing calculation for sparse pulse data
629-
# For fractional_beat, we need the containing unit (parent) duration and start time
630-
if ref_level == 0:
631-
# Ref level 0: fractional position within the cycle
632-
current_level_start_time = self.start_time + cycle_number * self.cycle_dur
633-
level_duration = self.cycle_dur
634-
else:
635-
# Ref level > 0: fractional position within the parent unit
636-
parent_positions = positions[:ref_level] # Parent positions
637-
current_level_start_time = self._calculate_proportional_level_start_time(parent_positions, cycle_number, ref_level - 1)
638-
level_duration = self._calculate_proportional_level_duration(parent_positions, cycle_number, ref_level - 1)
639-
640-
if level_duration <= 0:
641-
fractional_beat = 0.0
642-
else:
643-
time_from_level_start = real_time - current_level_start_time
644-
fractional_beat = time_from_level_start / level_duration
645-
else:
646-
# Use pulse-based calculation for complete pulse data
647-
current_pulse_index = self._hierarchical_position_to_pulse_index(positions, cycle_number)
648-
649-
# Add bounds checking for pulse access
650-
if current_pulse_index < 0 or current_pulse_index >= len(self.all_pulses):
651-
# Fall back to proportional calculation if pulse index out of bounds
652-
# For fractional_beat, we need the containing unit (parent) duration and start time
653-
if ref_level == 0:
654-
# Ref level 0: fractional position within the cycle
655-
current_level_start_time = self.start_time + cycle_number * self.cycle_dur
656-
level_duration = self.cycle_dur
657-
else:
658-
# Ref level > 0: fractional position within the parent unit
659-
parent_positions = positions[:ref_level] # Parent positions
660-
current_level_start_time = self._calculate_proportional_level_start_time(parent_positions, cycle_number, ref_level - 1)
661-
level_duration = self._calculate_proportional_level_duration(parent_positions, cycle_number, ref_level - 1)
662-
663-
if level_duration <= 0:
664-
fractional_beat = 0.0
665-
else:
666-
time_from_level_start = real_time - current_level_start_time
667-
fractional_beat = time_from_level_start / level_duration
668-
else:
669-
# Safe pulse-based calculation - use parent unit logic for reference levels
670-
if ref_level == 0:
671-
# Ref level 0: fractional position within the cycle
672-
current_level_start_time = self.start_time + cycle_number * self.cycle_dur
673-
level_duration = self.cycle_dur
674-
else:
675-
# For ref_level > 0: fractional position within the parent unit
676-
parent_positions = positions[:ref_level] # Truncate to parent level
677-
current_level_start_time = self._calculate_proportional_level_start_time(parent_positions, cycle_number, ref_level - 1)
678-
level_duration = self._calculate_proportional_level_duration(parent_positions, cycle_number, ref_level - 1)
679-
680-
if level_duration <= 0:
681-
fractional_beat = 0.0
682-
else:
683-
time_from_level_start = real_time - current_level_start_time
684-
fractional_beat = time_from_level_start / level_duration
685-
686-
# Clamp to [0, 1] range
687-
fractional_beat = max(0.0, min(1.0, fractional_beat))
688-
624+
# ALWAYS calculate fractional_beat as position within finest level unit (between pulses)
625+
# This is independent of reference_level, which only affects hierarchical_position truncation
626+
current_pulse_index = self._hierarchical_position_to_pulse_index(positions, cycle_number)
627+
628+
# Bounds checking
629+
if current_pulse_index < 0 or current_pulse_index >= len(self.all_pulses):
630+
fractional_beat = 0.0
689631
else:
690-
# Reference level behavior
691-
truncated_positions = positions[:ref_level + 1]
632+
current_pulse_time = self.all_pulses[current_pulse_index].real_time
692633

693-
# For fractional_beat calculation, we need the containing unit (parent) duration and start time
694-
if ref_level == 0:
695-
# Ref level 0: fractional position within the cycle
696-
current_level_start_time = self.start_time + cycle_number * self.cycle_dur
697-
level_duration = self.cycle_dur
634+
# Handle next pulse
635+
if current_pulse_index + 1 < len(self.all_pulses):
636+
next_pulse_time = self.all_pulses[current_pulse_index + 1].real_time
698637
else:
699-
# Ref level > 0: fractional position within the parent unit
700-
parent_positions = truncated_positions[:-1] # Remove the last position for parent unit
701-
current_level_start_time = self._calculate_proportional_level_start_time(parent_positions, cycle_number, ref_level - 1)
702-
level_duration = self._calculate_proportional_level_duration(parent_positions, cycle_number, ref_level - 1)
638+
# Last pulse - use next cycle start
639+
next_cycle_start = self.start_time + (cycle_number + 1) * self.cycle_dur
640+
next_pulse_time = next_cycle_start
703641

704-
if level_duration <= 0:
642+
pulse_duration = next_pulse_time - current_pulse_time
643+
if pulse_duration <= 0:
705644
fractional_beat = 0.0
706645
else:
707-
time_from_level_start = real_time - current_level_start_time
708-
fractional_beat = time_from_level_start / level_duration
709-
710-
# Clamp to [0, 1] range
711-
fractional_beat = max(0.0, min(1.0, fractional_beat))
712-
713-
# Update positions to only include levels up to reference for final result
714-
positions = truncated_positions
646+
time_from_current_pulse = real_time - current_pulse_time
647+
fractional_beat = time_from_current_pulse / pulse_duration
648+
649+
# Clamp to [0, 1] range
650+
fractional_beat = max(0.0, min(1.0, fractional_beat))
651+
652+
# Step 5: Handle reference level truncation (if specified)
653+
if ref_level is not None and ref_level < len(self.hierarchy) - 1:
654+
# Truncate positions to reference level for final result
655+
positions = positions[:ref_level + 1]
715656

716-
# Step 5: Result construction
657+
# Step 6: Result construction
717658
return MusicalTime(
718659
cycle_number=cycle_number,
719660
hierarchical_position=positions,

idtap/tests/musical_time_test.py

Lines changed: 24 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -93,13 +93,13 @@ def test_reference_level_beat(self):
9393
"""Test Case 2 from spec: Reference level at beat level."""
9494
meter = Meter(hierarchy=[4, 4], tempo=240, start_time=0, repetitions=2)
9595

96-
result = meter.get_musical_time(1.375, reference_level=0) # Within bounds: 1.375 = 37.5% through cycle
96+
result = meter.get_musical_time(1.375, reference_level=0) # 1.375s = 2nd cycle, beat 1, subdivision 2 (exactly on pulse)
9797

9898
assert result is not False
9999
assert result.cycle_number == 1 # Second cycle
100-
assert result.hierarchical_position == [1] # Only beat level (beat 2)
101-
assert abs(result.fractional_beat - 0.375) < 0.01 # 37.5% through cycle (ref_level=0 = within cycle)
102-
assert "C1:1+" in str(result)
100+
assert result.hierarchical_position == [1] # Only beat level (beat 2) due to reference_level=0
101+
assert abs(result.fractional_beat - 0.0) < 0.01 # Exactly on pulse (fractional_beat always pulse-based)
102+
assert str(result) == "C1:1+0.000"
103103

104104
def test_reference_level_subdivision(self):
105105
"""Test Case 3 from spec: Reference level at subdivision level."""
@@ -113,6 +113,26 @@ def test_reference_level_subdivision(self):
113113
assert abs(result.fractional_beat - 0.5) < 0.01 # 50% through beat (ref_level=1 = within beat)
114114
assert str(result) == "C0:1.2+0.500"
115115

116+
def test_johns_specific_examples(self):
117+
"""Test Jon's specific examples that revealed the fractional_beat calculation issue."""
118+
meter = Meter(hierarchy=[4, 4], tempo=240, start_time=0, repetitions=3)
119+
120+
# These examples should work correctly after the fix
121+
result = meter.get_musical_time(0.5)
122+
assert str(result) == "C0:2.0+0.000", f"Expected C0:2.0+0.000, got {result}"
123+
124+
result = meter.get_musical_time(0.25)
125+
assert str(result) == "C0:1.0+0.000", f"Expected C0:1.0+0.000, got {result}"
126+
127+
result = meter.get_musical_time(0.125)
128+
assert str(result) == "C0:0.2+0.000", f"Expected C0:0.2+0.000, got {result}"
129+
130+
result = meter.get_musical_time(0.0625)
131+
assert str(result) == "C0:0.1+0.000", f"Expected C0:0.1+0.000, got {result}"
132+
133+
result = meter.get_musical_time(0.03125)
134+
assert str(result) == "C0:0.0+0.500", f"Expected C0:0.0+0.500, got {result}"
135+
116136
def test_complex_hierarchy(self):
117137
"""Test Case 4 from spec: Complex hierarchy with reference levels."""
118138
meter = Meter(hierarchy=[3, 2, 4], tempo=480, start_time=0, repetitions=1) # Slower tempo

0 commit comments

Comments
 (0)