diff --git a/idtap/classes/meter.py b/idtap/classes/meter.py index 622931e..f3e8d56 100644 --- a/idtap/classes/meter.py +++ b/idtap/classes/meter.py @@ -346,6 +346,85 @@ def _pulse_dur(self) -> float: def cycle_dur(self) -> float: return self._pulse_dur * self._pulses_per_cycle + def _get_hierarchy_mult(self, layer: int) -> int: + """Get the multiplier for a given hierarchy layer. + + Handles both simple numbers and complex arrays like [3, 2] -> 5 + + Args: + layer: The hierarchy layer index + + Returns: + The multiplier for that layer (sum if array, value if int) + """ + if layer >= len(self.hierarchy): + return 1 + h = self.hierarchy[layer] + if isinstance(h, int): + return h + else: + return sum(h) + + @property + def display_tempo(self) -> float: + """Get the tempo as displayed in performance practice (at matra/beat level). + + In Hindustani music, the internal tempo is stored at the finest pulse level, + but musicians typically think of tempo at layer 1 (the matra/beat level). + + For a hierarchy like [[4,4,4,4], 4] (Tintal) with internal tempo 60 BPM: + - Layer 1 multiplier = 4 + - Display tempo = 60 * 4 = 240 BPM + + For complex hierarchies where layer 1 is an array like [3, 2], + sum the elements to get the multiplier (5). + + Returns: + The tempo at the matra/beat level (layer 1) + """ + if len(self.hierarchy) < 2: + return self.tempo + return self.tempo * self._get_hierarchy_mult(1) + + @display_tempo.setter + def display_tempo(self, new_tempo: float) -> None: + """Set the tempo using the performance practice tempo (at matra/beat level). + + Converts the matra-level tempo back to internal pulse tempo. + + Args: + new_tempo: The desired tempo at the matra/beat level + """ + if len(self.hierarchy) < 2: + # For single-layer hierarchies, display tempo equals internal tempo + self.tempo = new_tempo + self._generate_pulse_structures() + else: + internal_tempo = new_tempo / self._get_hierarchy_mult(1) + self.tempo = internal_tempo + self._generate_pulse_structures() + + def get_tempo_at_layer(self, layer: int) -> float: + """Get the tempo at a specific hierarchical layer. + + Args: + layer: The hierarchy layer (0 = coarsest/vibhag, higher = finer subdivisions) + + Returns: + The tempo (BPM) at that layer + + Raises: + ValueError: If layer is out of bounds + """ + if layer < 0 or layer >= len(self.hierarchy): + raise ValueError(f"Layer {layer} is out of bounds for hierarchy with {len(self.hierarchy)} layers") + + # Start with base tempo and multiply by each layer's subdivision + result_tempo = self.tempo + for i in range(1, layer + 1): + result_tempo *= self._get_hierarchy_mult(i) + return result_tempo + def _generate_pulse_structures(self) -> None: self.pulse_structures = [[]] # single layer of pulses for simplified implementation diff --git a/idtap/tests/meter_test.py b/idtap/tests/meter_test.py index 1dcd83c..d785fbe 100644 --- a/idtap/tests/meter_test.py +++ b/idtap/tests/meter_test.py @@ -157,3 +157,78 @@ def test_add_time_points(): m.add_time_points(new_times, 1) for nt in new_times: assert includes_with_tolerance(m.real_times, nt, 1e-8) + + +# display_tempo tests + +def test_display_tempo_simple_hierarchy(): + """Test display_tempo with a simple 2-layer hierarchy.""" + # Tintal-like: [[4,4,4,4], 4] with internal tempo 60 BPM + m = Meter(hierarchy=[[4, 4, 4, 4], 4], tempo=60) + # Display tempo should be 60 * 4 = 240 + assert m.display_tempo == 240 + + +def test_display_tempo_single_layer(): + """Test display_tempo with single-layer hierarchy.""" + m = Meter(hierarchy=[4], tempo=120) + # For single layer, display tempo equals internal tempo + assert m.display_tempo == 120 + + +def test_display_tempo_three_layer(): + """Test display_tempo with a 3-layer hierarchy.""" + # hierarchy [4, 4, 2] with internal tempo 60 BPM + # Layer 1 multiplier = 4 + # Display tempo = 60 * 4 = 240 + m = Meter(hierarchy=[4, 4, 2], tempo=60) + assert m.display_tempo == 240 + + +def test_display_tempo_setter(): + """Test setting display_tempo.""" + m = Meter(hierarchy=[[4, 4, 4, 4], 4], tempo=60) + assert m.display_tempo == 240 + + # Set display tempo to 480 + m.display_tempo = 480 + # Internal tempo should now be 480 / 4 = 120 + assert m.tempo == 120 + assert m.display_tempo == 480 + + +def test_display_tempo_setter_single_layer(): + """Test setting display_tempo with single-layer hierarchy.""" + m = Meter(hierarchy=[4], tempo=120) + m.display_tempo = 180 + assert m.tempo == 180 + assert m.display_tempo == 180 + + +def test_get_tempo_at_layer(): + """Test get_tempo_at_layer helper.""" + m = Meter(hierarchy=[[4, 4, 4, 4], 4], tempo=60) + # Layer 0 tempo = internal tempo = 60 + assert m.get_tempo_at_layer(0) == 60 + # Layer 1 tempo = 60 * 4 = 240 + assert m.get_tempo_at_layer(1) == 240 + + +def test_get_tempo_at_layer_out_of_bounds(): + """Test get_tempo_at_layer with invalid layer.""" + m = Meter(hierarchy=[4, 4], tempo=60) + with pytest.raises(ValueError): + m.get_tempo_at_layer(2) + with pytest.raises(ValueError): + m.get_tempo_at_layer(-1) + + +def test_get_hierarchy_mult(): + """Test _get_hierarchy_mult helper.""" + m = Meter(hierarchy=[[4, 4, 4, 4], 4]) + # Layer 0: [4,4,4,4] -> sum = 16 + assert m._get_hierarchy_mult(0) == 16 + # Layer 1: 4 + assert m._get_hierarchy_mult(1) == 4 + # Out of bounds returns 1 + assert m._get_hierarchy_mult(5) == 1