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

Commit 7337fd8

Browse files
jon-myersclaude
andcommitted
fix: resolve fractional_beat clustering with reference_level=0
Fixes Issue #28 where fractional_beat values were clustering near 0.000 instead of varying smoothly 0.0-1.0 within beats when using reference_level=0. **Root Cause:** When reference_level=0, the hierarchical position was truncated to only include the beat level before calculating fractional_beat. This removed all subdivision information, causing _calculate_level_start_time to only find beat boundaries rather than precise subdivision positions. **Solution:** - Preserve full hierarchical position for fractional_beat calculation - Only truncate position for final MusicalTime result as expected - Maintains backward compatibility and all existing reference level behavior **Testing:** - All 343 tests pass including comprehensive Issue #28 test suite - Validates fractional_beat distribution, range, and uniqueness - Confirms all reference levels (0, 1, 2+) work correctly - Tests both synthetic and real transcription data patterns 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
1 parent 43f9e60 commit 7337fd8

2 files changed

Lines changed: 299 additions & 5 deletions

File tree

idtap/classes/meter.py

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -599,11 +599,14 @@ def get_musical_time(self, real_time: float, reference_level: Optional[int] = No
599599
fractional_beat = max(0.0, min(1.0, fractional_beat))
600600

601601
else:
602-
# Reference level behavior
602+
# Reference level behavior - preserve full positions for fractional_beat calculation
603+
# but truncate for final result
603604
truncated_positions = positions[:ref_level + 1]
604605

605-
current_level_start_time = self._calculate_level_start_time(truncated_positions, cycle_number, ref_level)
606-
level_duration = self._calculate_level_duration(truncated_positions, cycle_number, ref_level)
606+
# Use full positions for accurate fractional_beat calculation
607+
# This prevents clustering when reference_level=0 (Issue #28)
608+
current_level_start_time = self._calculate_level_start_time(positions, cycle_number, ref_level)
609+
level_duration = self._calculate_level_duration(positions, cycle_number, ref_level)
607610

608611
if level_duration <= 0:
609612
fractional_beat = 0.0
@@ -614,7 +617,7 @@ def get_musical_time(self, real_time: float, reference_level: Optional[int] = No
614617
# Clamp to [0, 1] range
615618
fractional_beat = max(0.0, min(1.0, fractional_beat))
616619

617-
# Update positions to only include levels up to reference
620+
# Update positions to only include levels up to reference for final result
618621
positions = truncated_positions
619622

620623
# Step 5: Result construction

idtap/tests/musical_time_test.py

Lines changed: 292 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -501,4 +501,295 @@ def test_defensive_bounds_in_calculate_level_start_time(self):
501501

502502
if result is not False:
503503
assert len(result.hierarchical_position) == 1
504-
# Should not crash and should give reasonable results
504+
# Should not crash and should give reasonable results
505+
506+
def test_fractional_beat_distribution_with_reference_level_zero(self):
507+
"""Test that fractional_beat varies smoothly from 0.0 to 1.0 with reference_level=0 (Issue #28)."""
508+
# Create a simple meter for predictable testing
509+
meter = Meter(hierarchy=[4, 4], tempo=120, start_time=0, repetitions=1)
510+
511+
# Test parameters
512+
beat_duration = 60.0 / 120.0 # 0.5 seconds per beat at 120 BPM
513+
samples_per_beat = 10
514+
515+
print(f"\n=== Testing fractional_beat distribution (Issue #28) ===")
516+
print(f"Meter: hierarchy={meter.hierarchy}, tempo={meter.tempo} BPM")
517+
print(f"Beat duration: {beat_duration:.3f} seconds")
518+
print(f"Cycle duration: {meter.cycle_dur:.3f} seconds")
519+
print()
520+
521+
# Test each beat in the cycle
522+
all_fractional_beats = []
523+
for beat_idx in range(4): # 4 beats in hierarchy [4, 4]
524+
print(f"Beat {beat_idx}:")
525+
beat_fractional_beats = []
526+
527+
# Sample within this beat
528+
beat_start_time = beat_idx * beat_duration
529+
beat_end_time = (beat_idx + 1) * beat_duration
530+
531+
for i in range(samples_per_beat):
532+
# Sample from 10% to 90% through the beat to avoid boundary edge cases
533+
fraction_through_beat = 0.1 + (0.8 * i / (samples_per_beat - 1))
534+
test_time = beat_start_time + fraction_through_beat * beat_duration
535+
536+
result = meter.get_musical_time(test_time, reference_level=0)
537+
if result is not False:
538+
beat_fractional_beats.append(result.fractional_beat)
539+
all_fractional_beats.append(result.fractional_beat)
540+
print(f" {test_time:.3f}s -> beat={result.hierarchical_position[0]}, frac={result.fractional_beat:.3f}")
541+
542+
# Validate this beat's fractional_beat distribution
543+
if beat_fractional_beats:
544+
min_frac = min(beat_fractional_beats)
545+
max_frac = max(beat_fractional_beats)
546+
unique_values = len(set([round(f, 3) for f in beat_fractional_beats]))
547+
548+
print(f" Range: {min_frac:.3f} to {max_frac:.3f}, {unique_values} unique values")
549+
550+
# Critical assertions for Issue #28
551+
assert min_frac >= 0.0, f"Beat {beat_idx}: fractional_beat minimum {min_frac} should be >= 0.0"
552+
assert max_frac <= 1.0, f"Beat {beat_idx}: fractional_beat maximum {max_frac} should be <= 1.0"
553+
554+
# This is the key test for Issue #28: fractional_beat should vary significantly within a beat
555+
range_span = max_frac - min_frac
556+
assert range_span > 0.3, f"Beat {beat_idx}: fractional_beat range {range_span:.3f} is too small. Values clustering near 0.000 (Issue #28 symptom)"
557+
558+
# Should have reasonable variation in values
559+
assert unique_values >= 3, f"Beat {beat_idx}: Only {unique_values} unique fractional_beat values, expected more variation"
560+
561+
print()
562+
563+
# Overall analysis across all beats
564+
if all_fractional_beats:
565+
overall_unique = len(set([round(f, 3) for f in all_fractional_beats]))
566+
overall_min = min(all_fractional_beats)
567+
overall_max = max(all_fractional_beats)
568+
overall_range = overall_max - overall_min
569+
570+
print(f"Overall Analysis:")
571+
print(f" Total samples: {len(all_fractional_beats)}")
572+
print(f" Unique fractional_beat values: {overall_unique}")
573+
print(f" Range: {overall_min:.3f} to {overall_max:.3f} (span: {overall_range:.3f})")
574+
print(f" Distribution: {sorted(set([round(f, 3) for f in all_fractional_beats]))}")
575+
576+
# Key assertions for Issue #28
577+
assert overall_unique >= 10, f"Issue #28: Only {overall_unique} unique fractional_beat values across all samples - should have much more variation"
578+
assert overall_range > 0.5, f"Issue #28: Overall fractional_beat range {overall_range:.3f} is too small - values clustering near 0.000"
579+
580+
# Check for the specific Issue #28 problem: most values near 0.000
581+
near_zero_count = sum(1 for f in all_fractional_beats if f < 0.1)
582+
near_zero_percentage = near_zero_count / len(all_fractional_beats) * 100
583+
print(f" Values near 0.000 (< 0.1): {near_zero_count}/{len(all_fractional_beats)} ({near_zero_percentage:.1f}%)")
584+
585+
# This should NOT happen with the fix
586+
assert near_zero_percentage < 50, f"Issue #28: {near_zero_percentage:.1f}% of fractional_beat values are near 0.000 - indicates clustering problem"
587+
588+
print("✓ fractional_beat distribution test passed - Issue #28 resolved")
589+
590+
def test_fractional_beat_comparison_across_reference_levels(self):
591+
"""Compare fractional_beat behavior across different reference levels."""
592+
meter = Meter(hierarchy=[3, 3], tempo=90, start_time=0)
593+
594+
# Test at a specific time point
595+
test_time = 1.0 # 1 second into the meter
596+
597+
# Get musical time at different reference levels
598+
result_default = meter.get_musical_time(test_time) # Default (finest level)
599+
result_level_0 = meter.get_musical_time(test_time, reference_level=0) # Beat level
600+
result_level_1 = meter.get_musical_time(test_time, reference_level=1) # Subdivision level
601+
602+
print(f"\n=== Reference level comparison at {test_time}s ===")
603+
if result_default:
604+
print(f"Default: {result_default} (frac={result_default.fractional_beat:.3f})")
605+
if result_level_0:
606+
print(f"Level 0: {result_level_0} (frac={result_level_0.fractional_beat:.3f})")
607+
if result_level_1:
608+
print(f"Level 1: {result_level_1} (frac={result_level_1.fractional_beat:.3f})")
609+
610+
# All should return valid results
611+
assert result_default is not False
612+
assert result_level_0 is not False
613+
assert result_level_1 is not False
614+
615+
# fractional_beat should be reasonable for all levels
616+
assert 0.0 <= result_default.fractional_beat <= 1.0
617+
assert 0.0 <= result_level_0.fractional_beat <= 1.0
618+
assert 0.0 <= result_level_1.fractional_beat <= 1.0
619+
620+
# Each reference level should give different hierarchical position lengths
621+
assert len(result_level_0.hierarchical_position) == 1 # Beat only
622+
assert len(result_level_1.hierarchical_position) == 2 # Beat + subdivision
623+
assert len(result_default.hierarchical_position) == 2 # Full hierarchy [3, 3]
624+
625+
def test_issue_28_exact_reproduction(self):
626+
"""Exact reproduction of Issue #28 with hierarchy [4, 4, 2] and similar parameters."""
627+
# Create meter matching the issue description
628+
meter = Meter(hierarchy=[4, 4, 2], tempo=58.3, start_time=4.093, repetitions=1)
629+
630+
print(f"\n=== Issue #28 Exact Reproduction Test ===")
631+
print(f"Hierarchy: {meter.hierarchy}")
632+
print(f"Tempo: {meter.tempo:.1f} BPM")
633+
print(f"Cycle duration: {meter.cycle_dur:.3f} seconds")
634+
print(f"Start time: {meter.start_time:.3f} seconds")
635+
print()
636+
637+
# Sample times similar to the issue description
638+
cycle_start = meter.start_time
639+
cycle_end = meter.start_time + meter.cycle_dur
640+
sample_times = [
641+
cycle_start + 0.0, # Start
642+
cycle_start + 0.216, # ~5% in
643+
cycle_start + 0.432, # ~10% in
644+
cycle_start + 0.649, # ~15% in
645+
cycle_start + 0.865, # ~20% in
646+
cycle_start + 1.081, # ~25% in
647+
cycle_start + 1.297, # ~30% in
648+
cycle_start + 1.513, # ~35% in
649+
cycle_start + 1.729, # ~40% in
650+
cycle_start + 1.946, # ~45% in
651+
cycle_start + 2.162, # ~50% in
652+
cycle_start + 2.378, # ~55% in
653+
cycle_start + 2.594, # ~60% in
654+
cycle_start + 2.810, # ~65% in
655+
cycle_start + 3.026, # ~70% in
656+
cycle_start + 3.242, # ~75% in
657+
cycle_start + 3.459, # ~80% in
658+
cycle_start + 3.675, # ~85% in
659+
cycle_start + 3.891, # ~90% in
660+
cycle_start + 4.100, # ~95% in (just before end)
661+
]
662+
663+
print("Time | Musical Time (ref=0) | fractional_beat | Beat | Analysis")
664+
print("--------- | ------------------------ | --------------- | ---- | --------")
665+
666+
fractional_beats = []
667+
clustering_issues = []
668+
669+
for time_point in sample_times:
670+
if time_point < cycle_end: # Within bounds
671+
try:
672+
result = meter.get_musical_time(time_point, reference_level=0)
673+
if result is not False:
674+
fractional_beats.append(result.fractional_beat)
675+
beat_num = result.hierarchical_position[0] if result.hierarchical_position else "?"
676+
677+
# Check for clustering (Issue #28 symptom)
678+
is_clustered = result.fractional_beat < 0.05
679+
analysis = "CLUSTERED!" if is_clustered else "normal"
680+
if is_clustered:
681+
clustering_issues.append(time_point)
682+
683+
print(f"{time_point:8.3f}s | {str(result):24} | {result.fractional_beat:11.3f} | {beat_num:4} | {analysis}")
684+
else:
685+
print(f"{time_point:8.3f}s | {'Out of bounds':24} | {'N/A':15} | {'N/A':4} | out-of-bounds")
686+
except Exception as e:
687+
print(f"{time_point:8.3f}s | {'ERROR: ' + str(e):24} | {'N/A':15} | {'N/A':4} | error")
688+
689+
# Analysis of results
690+
print(f"\n=== Analysis ===")
691+
if fractional_beats:
692+
unique_values = len(set([round(f, 3) for f in fractional_beats]))
693+
min_frac = min(fractional_beats)
694+
max_frac = max(fractional_beats)
695+
range_span = max_frac - min_frac
696+
697+
clustered_count = sum(1 for f in fractional_beats if f < 0.05)
698+
clustered_percentage = clustered_count / len(fractional_beats) * 100
699+
700+
print(f"Total samples: {len(fractional_beats)}")
701+
print(f"Unique values: {unique_values}")
702+
print(f"Range: {min_frac:.3f} to {max_frac:.3f} (span: {range_span:.3f})")
703+
print(f"Clustered near 0.000 (< 0.05): {clustered_count}/{len(fractional_beats)} ({clustered_percentage:.1f}%)")
704+
print(f"Distribution: {sorted(set([round(f, 3) for f in fractional_beats]))}")
705+
706+
# Detect Issue #28 symptoms
707+
issue_28_detected = False
708+
709+
if clustered_percentage > 60:
710+
print(f"⚠️ ISSUE #28 DETECTED: {clustered_percentage:.1f}% of values clustered near 0.000")
711+
issue_28_detected = True
712+
713+
if unique_values < 8:
714+
print(f"⚠️ ISSUE #28 DETECTED: Only {unique_values} unique fractional_beat values (too few)")
715+
issue_28_detected = True
716+
717+
if range_span < 0.4:
718+
print(f"⚠️ ISSUE #28 DETECTED: fractional_beat range {range_span:.3f} too small")
719+
issue_28_detected = True
720+
721+
if not issue_28_detected:
722+
print("✓ No Issue #28 symptoms detected")
723+
724+
# Assertions for proper functionality (these will fail if Issue #28 exists)
725+
assert clustered_percentage < 60, f"Issue #28: {clustered_percentage:.1f}% of fractional_beat values clustered near 0.000"
726+
assert unique_values >= 8, f"Issue #28: Only {unique_values} unique fractional_beat values, should have more variation"
727+
assert range_span >= 0.4, f"Issue #28: fractional_beat range {range_span:.3f} too small, should span more of [0,1]"
728+
729+
else:
730+
pytest.fail("No fractional_beat values collected - test setup issue")
731+
732+
print("✓ Issue #28 reproduction test passed")
733+
734+
def test_deep_investigation_of_fractional_beat_calculation(self):
735+
"""Deep dive into what happens during fractional_beat calculation with reference_level=0."""
736+
meter = Meter(hierarchy=[4, 4, 2], tempo=60, start_time=0, repetitions=1)
737+
738+
print(f"\n=== Deep Investigation: fractional_beat calculation ===")
739+
print(f"Hierarchy: {meter.hierarchy}")
740+
print(f"Total pulses: {len(meter.all_pulses)}")
741+
print(f"Pulses per cycle: {meter._pulses_per_cycle}")
742+
print()
743+
744+
# Test at specific subdivision positions that might reveal the issue
745+
# If we're at beat 1, subdivision 2, sub-subdivision 1: position [1, 2, 1]
746+
# With reference_level=0, this gets truncated to [1] and extended to [1, 0, 0]
747+
# This might be the source of incorrect fractional_beat calculation
748+
749+
# Let's test at times that would put us in the middle of subdivisions
750+
beat_duration = 60.0 / 60.0 # 1 second per beat at 60 BPM
751+
subdivision_duration = beat_duration / 4 # 0.25 seconds per subdivision
752+
sub_subdivision_duration = subdivision_duration / 2 # 0.125 seconds per sub-subdivision
753+
754+
test_cases = [
755+
# (description, time, expected_beat, expected_subdivision_approx)
756+
("Start of beat 0", 0.0, 0, 0),
757+
("Middle of beat 0, subdivision 1", 0.25 + 0.1, 0, 1),
758+
("Middle of beat 0, subdivision 2", 0.5 + 0.1, 0, 2),
759+
("Middle of beat 0, subdivision 3", 0.75 + 0.1, 0, 3),
760+
("Start of beat 1", 1.0, 1, 0),
761+
("Middle of beat 1, subdivision 2", 1.5 + 0.1, 1, 2),
762+
("Middle of beat 2, subdivision 1", 2.25 + 0.1, 2, 1),
763+
("Middle of beat 3, subdivision 3", 3.75 + 0.1, 3, 3),
764+
]
765+
766+
print("Description | Time | Default Result | Ref=0 Result | Issue?")
767+
print("---------------------------------------- | ------- | --------------------------------- | --------------------------------- | ------")
768+
769+
for desc, time_point, expected_beat, expected_subdiv in test_cases:
770+
# Get both default and reference_level=0 results
771+
result_default = meter.get_musical_time(time_point)
772+
result_ref0 = meter.get_musical_time(time_point, reference_level=0)
773+
774+
if result_default and result_ref0:
775+
default_str = f"{result_default} (frac={result_default.fractional_beat:.3f})"
776+
ref0_str = f"{result_ref0} (frac={result_ref0.fractional_beat:.3f})"
777+
778+
# Check if we're in the middle of a subdivision but fractional_beat is near 0
779+
is_in_subdivision_middle = len(result_default.hierarchical_position) >= 2 and result_default.hierarchical_position[1] > 0
780+
fractional_beat_near_zero = result_ref0.fractional_beat < 0.1
781+
782+
potential_issue = is_in_subdivision_middle and fractional_beat_near_zero
783+
issue_flag = "⚠️ ISSUE" if potential_issue else "OK"
784+
785+
print(f"{desc:40} | {time_point:7.3f} | {default_str:33} | {ref0_str:33} | {issue_flag}")
786+
787+
if potential_issue:
788+
print(f" → DETECTED: In subdivision {result_default.hierarchical_position[1]} but fractional_beat={result_ref0.fractional_beat:.3f}")
789+
790+
else:
791+
print(f"{desc:40} | {time_point:7.3f} | {'None/False':33} | {'None/False':33} | ERROR")
792+
793+
print("\nThis test helps identify if the issue is related to position truncation when")
794+
print("we're in the middle of subdivisions but reference_level=0 calculation starts")
795+
print("from the wrong subdivision boundary.")

0 commit comments

Comments
 (0)