diff --git a/src/opendisplay/models/buzzer_activate.py b/src/opendisplay/models/buzzer_activate.py index ee3d6d3..0bd40bc 100644 --- a/src/opendisplay/models/buzzer_activate.py +++ b/src/opendisplay/models/buzzer_activate.py @@ -2,12 +2,12 @@ from __future__ import annotations +import math import re from collections.abc import Sequence from dataclasses import dataclass -_MIN_HZ = 400 -_MAX_HZ = 12000 +_ANCHOR_HZ = 13.75 _DURATION_UNIT_MS = 5 # Quarter-tone note model (see BUZZER_MUSIC_PROTOCOL_REFERENCE.md §4). @@ -24,10 +24,24 @@ def hz_to_index(hz: int) -> int: - """Convert frequency in Hz to firmware tone index (0-255). 0 Hz → silence.""" + """Convert Hz to firmware quarter-tone index (0-255). 0/negative Hz -> 0 (silence). + + Inverse of the firmware scale Freq(idx) = 13.75 * 2**(idx/24) Hz. + + Examples: + idx 1 -> ~14.15 Hz (nAm1p, the bottom note in the table) + idx 120 -> 440.00 Hz (nA4, concert pitch A) + idx 255 -> ~21714.33 Hz (nE10p, the top note in the table) + + Indices outside the firmware's playable window [117, 234] are octave-folded + by the firmware itself (preserving pitch class) before being driven onto the + speaker -- this protects the buzzer hardware from being driven outside its + safe operating range. This helper only produces a valid 0-255 index; it does + not need to replicate that folding. + """ if hz <= 0: return 0 - idx = round(1 + (hz - _MIN_HZ) * 254 / (_MAX_HZ - _MIN_HZ)) + idx = round(_STEPS_PER_OCTAVE * math.log2(hz / _ANCHOR_HZ)) return max(1, min(255, idx)) @@ -171,7 +185,7 @@ def _parse_token(token: str, *, tempo: int, default_ms: int, default_length: int class BuzzerStep: """A single tone step: one frequency for one duration.""" - frequency_index: int # 0=silence, 1–255 → 400–12000 Hz + frequency_index: int # 0=silence; 1–255 → quarter-tone note, Freq = 13.75 * 2**(idx/24) Hz duration_units: int # ×5 ms each; range 1–255 diff --git a/tests/unit/test_models_buzzer_activate.py b/tests/unit/test_models_buzzer_activate.py index 9655498..04508c9 100644 --- a/tests/unit/test_models_buzzer_activate.py +++ b/tests/unit/test_models_buzzer_activate.py @@ -21,24 +21,56 @@ def test_silence_at_zero(self): def test_silence_at_negative(self): assert hz_to_index(-100) == 0 - def test_minimum_frequency(self): - assert hz_to_index(400) == 1 + def test_low_landmark_frequency(self): + # 400 Hz maps to index 117 (bottom of the firmware's playable window) + assert hz_to_index(400) == 117 - def test_maximum_frequency(self): - assert hz_to_index(12000) == 255 + def test_high_landmark_frequency(self): + # 12000 Hz maps to index 234 (top of the firmware's playable window) + assert hz_to_index(12000) == 234 - def test_midpoint_frequency(self): - # 6200 Hz is the midpoint → index ~128 - idx = hz_to_index(6200) - assert 126 <= idx <= 130 + def test_6200_hz(self): + assert hz_to_index(6200) == 212 def test_clamps_above_max(self): assert hz_to_index(99999) == 255 - def test_clamps_below_min_non_zero(self): - # Values between 1 and 399 Hz should still produce index 1 (not 0) + def test_low_non_zero(self): + # Any positive Hz below the anchor still produces at least index 1 (not 0) assert hz_to_index(1) == 1 - assert hz_to_index(399) == 1 + # 399 Hz is not "below min" any more; it maps to 117 like 400 Hz + assert hz_to_index(399) == 117 + + def test_concert_a4(self): + # Reference doc §4.2: nA4 = 440 Hz exactly at idx 120 + assert hz_to_index(440) == 120 + + def test_a5_octave(self): + # nA5 = 880 Hz at idx 144; one octave up is exactly +24 quarter-tones + assert hz_to_index(880) == 144 + assert hz_to_index(880) - hz_to_index(440) == 24 + + def test_landmark_1000_hz(self): + assert hz_to_index(1000) == 148 + + def test_landmark_2000_hz(self): + assert hz_to_index(2000) == 172 + + def test_landmark_523_hz(self): + assert hz_to_index(523) == 126 + + def test_landmark_11840_hz(self): + assert hz_to_index(11840) == 234 + + def test_landmark_21714_hz(self): + assert hz_to_index(21714) == 255 + + @pytest.mark.parametrize("idx", [117, 120, 126, 144, 148, 172, 212, 234]) + def test_round_trip(self, idx): + # Feeding the firmware's frequency for an index back through hz_to_index + # recovers that index. + hz = round(13.75 * 2 ** (idx / 24)) + assert hz_to_index(hz) == idx class TestMsToUnits: @@ -89,6 +121,12 @@ def test_single_tone_wire_format(self): assert data[2] == 1 # 1 step assert len(data) == 5 + def test_single_tone_a4_wire_bytes(self): + # Reference doc §7.1 worked example: 440 Hz / 200 ms -> freq_idx 120 (0x78), + # dur_units 40 (0x28). + config = BuzzerActivateConfig.single_tone(frequency_hz=440, duration_ms=200) + assert config.to_bytes() == bytes([1, 1, 1, 120, 40]) + def test_silence_tone(self): config = BuzzerActivateConfig.single_tone(frequency_hz=0, duration_ms=50) data = config.to_bytes()