Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 19 additions & 5 deletions src/opendisplay/models/buzzer_activate.py
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand All @@ -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))


Expand Down Expand Up @@ -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


Expand Down
60 changes: 49 additions & 11 deletions tests/unit/test_models_buzzer_activate.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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()
Expand Down