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

Commit 74b1303

Browse files
jon-myersclaude
andauthored
fix: correct fractional_beat calculation to use finest level (#33)
* 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> * test: update musical time tests to match corrected fractional_beat behavior - Fix test timing values to land between pulses when expecting fractional values - Update expected fractional_beat values to match pulse-based calculation - Update test comments to reflect corrected behavior where fractional_beat is always between pulses regardless of reference_level 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com> * docs: update musical-time-spec.md to match corrected fractional_beat implementation - Clarify that fractional_beat is ALWAYS calculated between pulses (finest level) - Remove reference level recalculation from Step 5 - Simplify algorithm to remove complex helper functions - Update test case comments to reflect pulse-based calculation - Fix test case expected values to match corrected behavior --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 4766ab5 commit 74b1303

3 files changed

Lines changed: 99 additions & 206 deletions

File tree

docs/musical-time-spec.md

Lines changed: 44 additions & 112 deletions
Original file line numberDiff line numberDiff line change
@@ -56,11 +56,12 @@ getMusicalTime(realTime: number, referenceLevel?: number): MusicalTime | false
5656
- `false` - If time is before start_time or after end_time
5757

5858
**Reference Level Behavior:**
59-
- `referenceLevel=0`: Fractional position within cycle duration (containing unit for beats)
60-
- `referenceLevel=1`: Fractional position within beat duration (containing unit for subdivisions)
61-
- `referenceLevel=2`: Fractional position within subdivision duration (containing unit for sub-subdivisions)
62-
- `referenceLevel=n`: Fractional position within level-(n-1) duration (containing unit for level-n)
63-
- Default: Fractional position within finest subdivision (between pulses)
59+
- `fractionalBeat`: ALWAYS represents fractional position between pulses (finest level), regardless of referenceLevel
60+
- `referenceLevel`: Only affects the truncation of `hierarchicalPosition`:
61+
- `referenceLevel=0`: hierarchicalPosition includes only beat level
62+
- `referenceLevel=1`: hierarchicalPosition includes beat and subdivision levels
63+
- `referenceLevel=n`: hierarchicalPosition includes levels 0 through n
64+
- Default (None): hierarchicalPosition includes all levels
6465

6566
**Boundaries:**
6667
- **Start**: `realTime >= meter.startTime`
@@ -107,63 +108,51 @@ for each level in hierarchy:
107108
remainingTime = remainingTime % subdivisionDuration
108109
```
109110

110-
### Step 4: Fractional Beat Calculation (Level-Based)
111+
### Step 4: Fractional Beat Calculation (Always Pulse-Based)
111112

112-
The fractional beat calculation depends on the specified reference level:
113-
114-
#### Default Behavior (Pulse-Based)
115-
When `referenceLevel` is not specified or equals `hierarchy.length - 1`, calculate fraction between pulses:
113+
The fractional beat ALWAYS represents the position between pulses (finest level), regardless of reference level:
116114

117115
```
118116
currentPulseIndex = hierarchicalPositionToPulseIndex(positions, cycleNumber)
119-
currentPulseTime = meter.allPulses[currentPulseIndex].realTime
120-
121-
// Handle next pulse (accounting for cycle boundaries)
122-
if currentPulseIndex + 1 < meter.allPulses.length:
123-
nextPulseTime = meter.allPulses[currentPulseIndex + 1].realTime
124-
else:
125-
// Last pulse - use next cycle start
126-
nextCycleStart = meter.startTime + (cycleNumber + 1) * meter.cycleDur
127-
nextPulseTime = nextCycleStart
128117
129-
pulseDuration = nextPulseTime - currentPulseTime
130-
if pulseDuration <= 0:
118+
// Bounds checking
119+
if currentPulseIndex < 0 or currentPulseIndex >= meter.allPulses.length:
131120
fractionalBeat = 0.0
132121
else:
133-
timeFromCurrentPulse = realTime - currentPulseTime
134-
fractionalBeat = timeFromCurrentPulse / pulseDuration
122+
currentPulseTime = meter.allPulses[currentPulseIndex].realTime
123+
124+
// Handle next pulse (accounting for cycle boundaries)
125+
if currentPulseIndex + 1 < meter.allPulses.length:
126+
nextPulseTime = meter.allPulses[currentPulseIndex + 1].realTime
127+
else:
128+
// Last pulse - use next cycle start
129+
nextCycleStart = meter.startTime + (cycleNumber + 1) * meter.cycleDur
130+
nextPulseTime = nextCycleStart
131+
132+
pulseDuration = nextPulseTime - currentPulseTime
133+
if pulseDuration <= 0:
134+
fractionalBeat = 0.0
135+
else:
136+
timeFromCurrentPulse = realTime - currentPulseTime
137+
fractionalBeat = timeFromCurrentPulse / pulseDuration
135138
136139
// Clamp to [0, 1] range
137140
fractionalBeat = max(0.0, min(1.0, fractionalBeat))
138141
```
139142

140-
#### Reference Level Behavior
141-
When `referenceLevel` is specified and < `hierarchy.length - 1`:
142-
143-
```
144-
// Truncate hierarchical position to reference level + 1
145-
truncatedPosition = positions[0..referenceLevel]
146-
147-
// Calculate start time of current reference-level unit
148-
currentLevelStartTime = calculateLevelStartTime(truncatedPosition, cycleNumber, referenceLevel)
143+
### Step 5: Handle Reference Level Truncation
149144

150-
// Calculate duration of reference-level unit (accounting for actual pulse timing)
151-
levelDuration = calculateLevelDuration(truncatedPosition, cycleNumber, referenceLevel)
145+
If a reference level is specified, truncate the hierarchical position (fractional_beat remains unchanged):
152146

153-
if levelDuration <= 0:
154-
fractionalBeat = 0.0
155-
else:
156-
timeFromLevelStart = realTime - currentLevelStartTime
157-
fractionalBeat = timeFromLevelStart / levelDuration
158-
159-
// Clamp to [0, 1] range
160-
fractionalBeat = max(0.0, min(1.0, fractionalBeat))
161-
162-
// Update hierarchical position to only include levels up to reference
163-
positions = truncatedPosition
147+
```
148+
if referenceLevel is not None and referenceLevel < len(hierarchy):
149+
// Truncate positions to reference level for final result
150+
positions = positions[0:referenceLevel+1]
151+
152+
// Note: fractionalBeat is NOT recalculated - it always remains pulse-based
164153
```
165154

166-
### Step 5: Result Construction
155+
### Step 6: Result Construction
167156
```
168157
return MusicalTime {
169158
cycleNumber: cycleNumber,
@@ -201,64 +190,7 @@ cycleOffset = cycleNumber * meter.getPulsesPerCycle()
201190
return pulseIndex + cycleOffset
202191
```
203192

204-
### calculateLevelStartTime()
205-
206-
**Purpose:** Calculate the start time of a hierarchical unit at a given reference level.
207-
208-
**Signature:**
209-
```
210-
calculateLevelStartTime(positions: number[], cycleNumber: number, referenceLevel: number): number
211-
```
212-
213-
**Algorithm:**
214-
```
215-
// Find the pulse index for the start of this reference-level unit
216-
startPositions = positions.slice(0, referenceLevel + 1)
217-
// Zero out all positions below the reference level
218-
for i = referenceLevel + 1 to hierarchy.length - 1:
219-
startPositions[i] = 0
220-
221-
startPulseIndex = hierarchicalPositionToPulseIndex(startPositions, cycleNumber)
222-
return meter.allPulses[startPulseIndex].realTime
223-
```
224-
225-
### calculateLevelDuration()
226-
227-
**Purpose:** Calculate the actual duration of a hierarchical unit based on pulse timing.
228-
229-
**Signature:**
230-
```
231-
calculateLevelDuration(positions: number[], cycleNumber: number, referenceLevel: number): number
232-
```
233-
234-
**Algorithm:**
235-
```
236-
// Get start time of current unit
237-
startTime = calculateLevelStartTime(positions, cycleNumber, referenceLevel)
238-
239-
// Calculate start time of next unit at same level
240-
nextPositions = positions.slice()
241-
nextPositions[referenceLevel]++
242-
243-
// Handle overflow - if we've exceeded this level, move to next cycle or higher level
244-
if nextPositions[referenceLevel] >= hierarchy[referenceLevel]:
245-
if referenceLevel == 0:
246-
// Next beat is in next cycle
247-
nextCycleNumber = cycleNumber + 1
248-
if nextCycleNumber >= meter.repetitions:
249-
// Use meter end time
250-
return meter.startTime + meter.repetitions * meter.cycleDur - startTime
251-
nextPositions[0] = 0
252-
return calculateLevelStartTime(nextPositions, nextCycleNumber, referenceLevel) - startTime
253-
else:
254-
// Carry over to higher level
255-
nextPositions[referenceLevel] = 0
256-
nextPositions[referenceLevel - 1]++
257-
return calculateLevelDuration(nextPositions, cycleNumber, referenceLevel - 1)
258-
259-
endTime = calculateLevelStartTime(nextPositions, cycleNumber, referenceLevel)
260-
return endTime - startTime
261-
```
193+
**Note:** The simplified implementation no longer requires the complex `calculateLevelStartTime()` and `calculateLevelDuration()` helper functions that were used in the previous reference-level-based fractional beat calculation.
262194

263195
## Edge Cases & Error Handling
264196

@@ -299,10 +231,10 @@ Query: getMusicalTime(2.375, referenceLevel=0)
299231
300232
Expected:
301233
- cycleNumber: 0
302-
- hierarchicalPosition: [2] (Beat 3)
303-
- fractionalBeat: 0.594 (2.375s / 4.0s cycle duration = 59.4% through cycle)
304-
- toString(): "C0:2+0.594"
305-
- Readable: "Cycle 1: Beat 3 + 0.594 through cycle"
234+
- hierarchicalPosition: [2] (Beat 3 only, due to referenceLevel=0)
235+
- fractionalBeat: 0.0 (exactly on pulse, fractionalBeat always pulse-based)
236+
- toString(): "C0:2+0.000"
237+
- Readable: "Cycle 1: Beat 3"
306238
```
307239

308240
### Test Case 3: Reference Level - Subdivision Level (referenceLevel=1)
@@ -313,9 +245,9 @@ Query: getMusicalTime(2.375, referenceLevel=1)
313245
Expected:
314246
- cycleNumber: 0
315247
- hierarchicalPosition: [2, 1] (Beat 3, Subdivision 2)
316-
- fractionalBeat: 0.375 (0.375s / 1.0s beat duration = 37.5% through beat 2)
317-
- toString(): "C0:2.1+0.375"
318-
- Readable: "Cycle 1: Beat 3, Subdivision 2 + 0.375 through beat"
248+
- fractionalBeat: 0.0 (exactly on pulse, fractionalBeat always pulse-based)
249+
- toString(): "C0:2.1+0.000"
250+
- Readable: "Cycle 1: Beat 3, Subdivision 2"
319251
```
320252

321253
### Test Case 4: Complex Hierarchy with Reference Levels
@@ -326,7 +258,7 @@ Query: getMusicalTime(0.15625, referenceLevel=1)
326258
Expected:
327259
- cycleNumber: 0
328260
- hierarchicalPosition: [1, 0] (Beat 2, Subdivision 1)
329-
- fractionalBeat: 0.25 (0.25 through subdivision duration)
261+
- fractionalBeat: 0.25 (0.25 through finest-level pulse duration)
330262
- toString(): "C0:1.0+0.250"
331263
```
332264

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,

0 commit comments

Comments
 (0)