@@ -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 ("\n This 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