diff --git a/common/token.py b/common/token.py
index e665c6eaf..f5f8f8058 100755
--- a/common/token.py
+++ b/common/token.py
@@ -24,6 +24,7 @@
ID = 'id'
LEFT = 'LEFT'
LEFT_RIGHT = 'LEFT_RIGHT'
+METRONOME_ENABLED = 'metronome.enabled'
NAM_CAPTURE_GAIN = 'nam.capture_gain'
NAM_OUTPUT_VOL = 'nam.output_vol'
RIGHT = 'RIGHT'
diff --git a/modalapi/modhandler.py b/modalapi/modhandler.py
index d797b91a9..a0b6c9015 100644
--- a/modalapi/modhandler.py
+++ b/modalapi/modhandler.py
@@ -128,6 +128,7 @@
from pistomp.tuner import TunerPanel, TunerSourceFactory
from pistomp.tuner.client import TunerClient
from pistomp.tuner.engine import TunerBackend, TunerEngine
+from pistomp.metronome.client import MetronomeClient
from rtmidi.midiconstants import CONTROL_CHANGE
from pathlib import Path
@@ -242,6 +243,10 @@ def __init__(self, audiocard: Audiocard, homedir, data_dir="/home/pistomp/data")
self._tuner_source_spec: str = "jack"
self._tuner_muted: bool = False
+ # Metronome
+ self._metronome_enabled: bool = bool(self.settings.get_setting(Token.METRONOME_ENABLED))
+ self._metronome_client: MetronomeClient | None = None
+
# Callback function map. Key is the user specified name, value is function from this handler
# Used for calling handler callbacks pointed to by names which may be user set in the config file
self.callbacks = {
@@ -251,6 +256,7 @@ def __init__(self, audiocard: Audiocard, homedir, data_dir="/home/pistomp/data")
"toggle_bypass": self.system_toggle_bypass,
"toggle_tap_tempo_enable": self.toggle_tap_tempo_enable,
"toggle_tuner_enable": self.toggle_tuner_enable,
+ "toggle_metronome_enable": self.toggle_metronome_enable,
"next_pedalboard": self.next_pedalboard,
"previous_pedalboard": self.previous_pedalboard,
}
@@ -281,6 +287,9 @@ def cleanup(self):
if self._hardware is not None:
self._hardware.cleanup()
self.external_midi.close()
+ if self._metronome_client is not None:
+ self._metronome_client.stop()
+ self._metronome_client = None
self.ws_bridge.stop()
logging.info("WebSocket bridge stopped")
self.ethernet_manager.shutdown()
@@ -2086,6 +2095,35 @@ def system_menu_vu_calibration(self, arg):
value = self.settings.get_setting("analogVU.adc_baseline")
self.lcd.draw_vu_calibration_dialog("analogVU.adc_baseline", value, commit_callback=self.settings_file_commit)
+ # ── metronome ─────────────────────────────────────────────────────────────
+
+ @property
+ def metronome_enabled(self) -> bool:
+ return self._metronome_enabled
+
+ def start_audio_services(self) -> None:
+ """Start background audio subprocesses. Call once after full handler init."""
+ self._start_metronome()
+
+ def _start_metronome(self) -> None:
+ if self._metronome_client is not None:
+ return
+ try:
+ client = MetronomeClient()
+ client.start(enabled=self._metronome_enabled)
+ self._metronome_client = client
+ logging.info("Metronome subprocess started (enabled=%s)", self._metronome_enabled)
+ except Exception as e:
+ logging.warning("metronome: failed to start: %s", e)
+
+ def toggle_metronome_enable(self) -> None:
+ self._metronome_enabled = not self._metronome_enabled
+ if self._metronome_client is not None:
+ self._metronome_client.set_enabled(self._metronome_enabled)
+ self.settings.set_setting(Token.METRONOME_ENABLED, self._metronome_enabled)
+ if self._lcd is not None:
+ self._lcd.update_audio_midi_tile()
+
def settings_file_commit(self, symbol, value):
self.settings.set_setting(symbol, value)
self.hardware.recalibrateVU_baseline(value)
diff --git a/modalapistomp.py b/modalapistomp.py
index 3ac03ac97..cfc07df72 100755
--- a/modalapistomp.py
+++ b/modalapistomp.py
@@ -172,6 +172,7 @@ def main():
# Load system info. This can take a few seconds
handler.system_info_load()
+ handler.start_audio_services()
elif is_emulator:
from emulator.bootstrap import bootstrap_emulator
diff --git a/pistomp/config/schema_v1.py b/pistomp/config/schema_v1.py
index 763355f04..d311a8f96 100644
--- a/pistomp/config/schema_v1.py
+++ b/pistomp/config/schema_v1.py
@@ -125,6 +125,7 @@
"toggle_bypass",
"toggle_tap_tempo_enable",
"toggle_tuner_enable",
+ "toggle_metronome_enable",
"next_pedalboard",
"previous_pedalboard",
]
diff --git a/pistomp/lcd320x240.py b/pistomp/lcd320x240.py
index 5cabfc67b..f7f654a4e 100644
--- a/pistomp/lcd320x240.py
+++ b/pistomp/lcd320x240.py
@@ -1216,6 +1216,8 @@ def update_audio_midi_tile(self) -> None:
muted = jm is not None and jm.is_muted()
if muted:
state = "muted"
+ elif self.handler.metronome_enabled and self.handler.transport_rolling:
+ state = "metronome"
elif self.handler.transport_rolling:
state = "rolling"
else:
diff --git a/pistomp/metronome/__init__.py b/pistomp/metronome/__init__.py
new file mode 100644
index 000000000..c9f1cbd27
--- /dev/null
+++ b/pistomp/metronome/__init__.py
@@ -0,0 +1,16 @@
+# SPDX-License-Identifier: AGPL-3.0-or-later
+#
+# This file is part of pi-stomp.
+#
+# pi-stomp is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# pi-stomp is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU Affero General Public License for more details.
+#
+# You should have received a copy of the GNU Affero General Public License
+# along with pi-stomp. If not, see .
diff --git a/pistomp/metronome/__main__.py b/pistomp/metronome/__main__.py
new file mode 100644
index 000000000..86df179f4
--- /dev/null
+++ b/pistomp/metronome/__main__.py
@@ -0,0 +1,212 @@
+# SPDX-License-Identifier: AGPL-3.0-or-later
+#
+# This file is part of pi-stomp.
+#
+# pi-stomp is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# pi-stomp is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU Affero General Public License for more details.
+#
+# You should have received a copy of the GNU Affero General Public License
+# along with pi-stomp. If not, see .
+
+"""Metronome subprocess entry point.
+
+Spawned by MetronomeClient as ``python -m pistomp.metronome [enabled]``.
+
+Connects to JACK as ``pistomp-metronome``, registers stereo output ports,
+and adds click audio directly to ``system:playback_1/2`` (JACK summing
+means the existing mod-monitor→playback link is untouched).
+
+Transport sync: reads BBT from ``jack_transport_query`` in the process
+callback.
+
+stdin control (one command per line):
+ enable — start clicking
+ disable — silence output (stay connected, write zeros)
+ stop — deactivate and exit
+"""
+
+from __future__ import annotations
+
+import math
+import select
+import signal
+import sys
+
+import numpy as np
+
+# ── click parameters ─────────────────────────────────────────────────────────
+
+_CLICK_DECAY_S = 0.060 # envelope length; 5τ ≈ e^-5 → effectively silent
+_CLICK_VOL_ACCENT = 0.80 # downbeat (beat 1 of bar)
+_CLICK_VOL_NORMAL = 0.55 # all other beats
+_CLICK_HZ_ACCENT = 1000.0
+_CLICK_HZ_NORMAL = 700.0
+
+# ── mutable state — written by main thread, read by RT callback ───────────────
+# CPython bool assignment is atomic under the GIL; no lock needed here.
+
+_running = True
+_enabled = False
+
+
+def _sigterm(_sig, _frame) -> None:
+ global _running
+ _running = False
+
+
+def _precompute_click(sample_rate: int, hz: float, volume: float, decay_s: float) -> np.ndarray:
+ """Decaying sine burst as float32: vol·sin(2π·hz·t)·exp(-t/τ), τ = decay_s/5."""
+ n = int(decay_s * sample_rate)
+ t = np.arange(n, dtype=np.float64) / sample_rate
+ wave = volume * np.sin(2.0 * np.pi * hz * t) * np.exp(-t / (decay_s / 5.0))
+ return wave.astype(np.float32)
+
+
+def main() -> None:
+ global _running, _enabled
+
+ import jack # type: ignore[import-untyped]
+
+ if len(sys.argv) >= 2 and sys.argv[1] == "enabled":
+ _enabled = True
+
+ signal.signal(signal.SIGTERM, _sigterm)
+
+ try:
+ client = jack.Client("pistomp-metronome", no_start_server=True)
+ except Exception as e:
+ print(f"metronome: JACK open failed: {e}", file=sys.stderr)
+ sys.exit(1)
+
+ sr: int = client.samplerate
+ accent = _precompute_click(sr, _CLICK_HZ_ACCENT, _CLICK_VOL_ACCENT, _CLICK_DECAY_S)
+ normal = _precompute_click(sr, _CLICK_HZ_NORMAL, _CLICK_VOL_NORMAL, _CLICK_DECAY_S)
+
+ # Capture constants into the closure — avoids repeated attribute lookups in the RT path.
+ ROLLING = jack.ROLLING
+ POSITION_BBT = jack.POSITION_BBT
+
+ out_L = client.outports.register("out_L")
+ out_R = client.outports.register("out_R")
+
+ # The click state. The callback keeps this state between calls.
+ # A dict lets the closure change the values without `nonlocal` or `global`.
+ # wave: the array that plays now, or None.
+ # pos: the count of samples that went to the output.
+ # beat: the index of the last beat that made a click.
+ # origin: the frame of beat 0 of this tempo segment. None means no lock.
+ cs: dict = {"wave": None, "pos": 0, "beat": -1, "origin": None, "bpm": 0.0, "bpb": 0}
+
+ @client.set_process_callback
+ def process(frames: int) -> None:
+ buf_L = out_L.get_array() # pyright: ignore[reportAttributeAccessIssue]
+ buf_R = out_R.get_array() # pyright: ignore[reportAttributeAccessIssue]
+ buf_L[:] = 0.0
+ buf_R[:] = 0.0
+
+ if not _enabled:
+ cs["wave"] = None
+ cs["origin"] = None
+ return
+
+ state, pos = client.transport_query_struct()
+ if state != ROLLING:
+ cs["wave"] = None
+ cs["origin"] = None
+ return
+ if not (pos.valid & POSITION_BBT):
+ cs["wave"] = None
+ cs["origin"] = None
+ return
+
+ bpm: float = pos.beats_per_minute
+ if bpm <= 0.0:
+ return
+
+ bpb: int = max(1, int(pos.beats_per_bar))
+ tpb: float = float(pos.ticks_per_beat) or 1920.0
+ spb: float = sr * 60.0 / bpm # samples per beat
+ frame: int = int(pos.frame)
+
+ # Step 1. Continue the click that started in an earlier buffer.
+ c_wave = cs["wave"]
+ c_pos: int = cs["pos"]
+ if c_wave is not None and c_pos < len(c_wave):
+ n = min(len(c_wave) - c_pos, frames)
+ buf_L[:n] += c_wave[c_pos : c_pos + n]
+ buf_R[:n] += c_wave[c_pos : c_pos + n]
+ cs["pos"] = c_pos + n
+
+ # Step 2. Find the beat grid.
+ # JACK gives the tick as an integer. One tick is 12.5 samples at
+ # 120 bpm with 1920 ticks in a beat. Thus one read of the BBT data
+ # gives the position of the grid to 12.5 samples only.
+ # The integer tick makes an estimate late. It never makes an estimate
+ # early. Keep the smallest estimate. The smallest estimate moves to the
+ # correct origin, with an error of less than one sample.
+ elapsed: float = (int(pos.bar) - 1) * bpb + (int(pos.beat) - 1) + float(pos.tick) / tpb
+ estimate: float = frame - elapsed * spb
+
+ origin = cs["origin"]
+ # An estimate more than one tick after the origin means the transport
+ # master moved the grid. A tap tempo that starts the beat again does
+ # this, as well as seeking. A new tempo or a new meter also ends the segment.
+ if origin is None or bpm != cs["bpm"] or bpb != cs["bpb"] or estimate > origin + spb / tpb + 1.0:
+ origin = estimate
+ cs["bpm"] = bpm
+ cs["bpb"] = bpb
+ cs["beat"] = math.ceil((frame - origin) / spb) - 1
+ elif estimate < origin:
+ origin = estimate
+ cs["origin"] = origin
+
+ # Step 3. Play each beat that starts in this buffer.
+ index: int = cs["beat"] + 1
+ while True:
+ onset = round(origin + index * spb) - frame
+ if onset >= frames:
+ break
+ if onset < 0:
+ onset = 0 # The origin moved back. Play the click now.
+ new_wave = accent if index % bpb == 0 else normal
+ n = min(len(new_wave), frames - onset)
+ buf_L[onset : onset + n] += new_wave[:n]
+ buf_R[onset : onset + n] += new_wave[:n]
+ cs["wave"] = new_wave
+ cs["pos"] = n
+ cs["beat"] = index
+ index += 1
+
+ client.activate()
+ try:
+ client.connect("pistomp-metronome:out_L", "system:playback_1")
+ client.connect("pistomp-metronome:out_R", "system:playback_2")
+ except Exception as e:
+ print(f"metronome: JACK connect failed: {e}", file=sys.stderr)
+
+ try:
+ while _running:
+ rlist, _, _ = select.select([sys.stdin], [], [], 0.05)
+ if rlist:
+ line = sys.stdin.readline()
+ if not line or line.strip() == "stop":
+ break
+ cmd = line.strip()
+ if cmd == "enable":
+ _enabled = True
+ elif cmd == "disable":
+ _enabled = False
+ finally:
+ client.deactivate()
+ client.close()
+
+
+if __name__ == "__main__":
+ main()
diff --git a/pistomp/metronome/client.py b/pistomp/metronome/client.py
new file mode 100644
index 000000000..ac330c101
--- /dev/null
+++ b/pistomp/metronome/client.py
@@ -0,0 +1,104 @@
+# SPDX-License-Identifier: AGPL-3.0-or-later
+#
+# This file is part of pi-stomp.
+#
+# pi-stomp is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# pi-stomp is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU Affero General Public License for more details.
+#
+# You should have received a copy of the GNU Affero General Public License
+# along with pi-stomp. If not, see .
+
+"""MetronomeClient: spawns and controls the pistomp.metronome subprocess."""
+
+from __future__ import annotations
+
+import os
+import signal
+import subprocess
+import sys
+from pathlib import Path
+
+# Absolute path of the repository root, injected as PYTHONPATH so the
+# subprocess imports from the same source tree as the parent process.
+_SRC_ROOT = str(Path(__file__).resolve().parents[2])
+
+
+class MetronomeClient:
+ """Manages the metronome JACK subprocess lifecycle.
+
+ The subprocess (``pistomp.metronome.__main__``) connects to JACK as
+ ``pistomp-metronome``, registers two output ports, and writes click
+ audio directly to ``system:playback_1/2``. JACK sums multiple sources
+ connected to the same playback port, so the existing
+ mod-monitor→playback links are untouched.
+
+ Control is via stdin, one ASCII command per line::
+
+ enable — start clicking
+ disable — silence (stay connected, write zeros)
+ stop — deactivate and exit
+ """
+
+ def __init__(self) -> None:
+ self._proc: subprocess.Popen[bytes] | None = None
+
+ # ── lifecycle ────────────────────────────────────────────────────────────
+
+ def start(self, *, enabled: bool = False) -> None:
+ """Spawn the subprocess with the given initial click state."""
+ env = os.environ.copy()
+ existing = env.get("PYTHONPATH", "")
+ env["PYTHONPATH"] = (_SRC_ROOT + ":" + existing) if existing else _SRC_ROOT
+ args = [sys.executable, "-m", "pistomp.metronome"]
+ if enabled:
+ args.append("enabled")
+ self._proc = subprocess.Popen(args, stdin=subprocess.PIPE, env=env)
+
+ def stop(self) -> None:
+ """Send stop, wait, escalate to SIGTERM then SIGKILL."""
+ proc = self._proc
+ if proc is None:
+ return
+ try:
+ if proc.stdin:
+ proc.stdin.write(b"stop\n")
+ proc.stdin.flush()
+ proc.stdin.close()
+ except OSError:
+ pass
+ try:
+ proc.wait(timeout=3.0)
+ except subprocess.TimeoutExpired:
+ proc.send_signal(signal.SIGTERM)
+ try:
+ proc.wait(timeout=2.0)
+ except subprocess.TimeoutExpired:
+ proc.kill()
+ proc.wait()
+ self._proc = None
+
+ # ── control ──────────────────────────────────────────────────────────────
+
+ def set_enabled(self, enabled: bool) -> None:
+ """Send ``enable`` or ``disable`` to the subprocess stdin."""
+ proc = self._proc
+ if proc is None or proc.stdin is None:
+ return
+ try:
+ proc.stdin.write(b"enable\n" if enabled else b"disable\n")
+ proc.stdin.flush()
+ except OSError:
+ pass
+
+ def poll(self) -> int | None:
+ """Return the subprocess exit code if it has exited, else None."""
+ if self._proc is None:
+ return None
+ return self._proc.poll()
diff --git a/pistomp/metronome/render.py b/pistomp/metronome/render.py
new file mode 100644
index 000000000..f10aff699
--- /dev/null
+++ b/pistomp/metronome/render.py
@@ -0,0 +1,318 @@
+# SPDX-License-Identifier: AGPL-3.0-or-later
+#
+# This file is part of pi-stomp.
+#
+# pi-stomp is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# pi-stomp is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU Affero General Public License for more details.
+#
+# You should have received a copy of the GNU Affero General Public License
+# along with pi-stomp. If not, see .
+
+from __future__ import annotations
+
+import argparse
+import importlib.util
+import os
+import sys
+import types
+import wave
+from pathlib import Path
+
+import numpy as np
+
+_ROLLING = 1
+_POSITION_BBT = 0x10
+
+
+class _Position:
+ """The part of ``jack_position_t`` that the process callback reads."""
+
+ valid: int
+ frame: int
+ bar: int
+ beat: int
+ tick: float
+ ticks_per_beat: float
+ beats_per_bar: float
+ beats_per_minute: float
+
+
+class _Port:
+ def __init__(self, frames: int) -> None:
+ self.buf = np.zeros(frames, dtype=np.float32)
+
+ def get_array(self) -> np.ndarray:
+ return self.buf
+
+
+class _Ports(list):
+ def __init__(self, frames: int) -> None:
+ super().__init__()
+ self._frames = frames
+
+ def register(self, name: str) -> _Port:
+ port = _Port(self._frames)
+ self.append(port)
+ return port
+
+
+class _FakeClient:
+ """Runs the callback from ``activate()``, after the callback is registered."""
+
+ def __init__(self, spec: RenderSpec) -> None:
+ self.samplerate = spec.samplerate
+ self.blocksize = spec.frames
+ self.outports = _Ports(spec.frames)
+ self._spec = spec
+ self._callback = None
+ self._frame = 0
+ self.audio = np.zeros(0, dtype=np.float32)
+
+ def set_process_callback(self, fn):
+ self._callback = fn
+ return fn
+
+ def transport_query_struct(self) -> tuple[int, _Position]:
+ spec = self._spec
+ pos = _Position()
+ pos.valid = _POSITION_BBT
+ pos.frame = self._frame
+ beats, bpm = spec.beats_at(self._frame)
+ whole = int(beats)
+ frac = beats - whole
+ pos.bar = whole // spec.beats_per_bar + 1
+ pos.beat = whole % spec.beats_per_bar + 1
+ # A jack_position_t holds the tick as an int32. The value "float" for
+ # --tick-mode models a master that does not round the tick. This hides
+ # the error that the integer tick causes.
+ pos.tick = frac * spec.ticks_per_beat
+ if spec.tick_mode == "int":
+ pos.tick = float(int(pos.tick))
+ pos.ticks_per_beat = spec.ticks_per_beat
+ pos.beats_per_bar = float(spec.beats_per_bar)
+ pos.beats_per_minute = bpm
+ return _ROLLING, pos
+
+ def activate(self) -> None:
+ callback = self._callback
+ if callback is None:
+ raise RuntimeError("process callback was never registered")
+ spec = self._spec
+ total = int(spec.duration_s * spec.samplerate)
+ out = np.zeros(total, dtype=np.float32)
+ frame = 0
+ while frame + spec.frames <= total:
+ self._frame = frame
+ for port in self.outports:
+ port.buf[:] = 0.0
+ callback(spec.frames)
+ out[frame : frame + spec.frames] = self.outports[0].buf
+ frame += spec.frames
+ self.audio = out
+
+ def connect(self, source: str, dest: str) -> None:
+ pass
+
+ def deactivate(self) -> None:
+ pass
+
+ def close(self) -> None:
+ pass
+
+
+class RenderSpec:
+ def __init__(
+ self,
+ bpm: float,
+ frames: int,
+ beats_per_bar: int,
+ duration_s: float,
+ samplerate: int,
+ ticks_per_beat: float,
+ tick_mode: str,
+ tempo_changes: list[tuple[float, float]] | None = None,
+ grid_restarts: list[float] | None = None,
+ ) -> None:
+ self.bpm = bpm
+ self.frames = frames
+ self.beats_per_bar = beats_per_bar
+ self.duration_s = duration_s
+ self.samplerate = samplerate
+ self.ticks_per_beat = ticks_per_beat
+ self.tick_mode = tick_mode
+ self._segments = self._build_segments(tempo_changes or [], grid_restarts or [])
+
+ @property
+ def samples_per_beat(self) -> float:
+ return self.samplerate * 60.0 / self.bpm
+
+ def _build_segments(
+ self, tempo_changes: list[tuple[float, float]], grid_restarts: list[float]
+ ) -> list[tuple[int, float, float]]:
+ """Make the list of (start frame, beats at that frame, bpm).
+
+ A tempo change keeps the beat phase. A grid restart sets the beat
+ position back to zero. A real timebase master does the same.
+ """
+ events: list[tuple[int, str, float]] = []
+ for at_s, new_bpm in tempo_changes:
+ events.append((int(at_s * self.samplerate), "tempo", new_bpm))
+ for at_s in grid_restarts:
+ events.append((int(at_s * self.samplerate), "restart", 0.0))
+ events.sort(key=lambda event: event[0])
+
+ segments = [(0, 0.0, self.bpm)]
+ for frame, kind, value in events:
+ start, beats_at_start, bpm = segments[-1]
+ beats = beats_at_start + (frame - start) * bpm / (self.samplerate * 60.0)
+ if kind == "tempo":
+ segments.append((frame, beats, value))
+ else:
+ segments.append((frame, 0.0, bpm))
+ return segments
+
+ def beats_at(self, frame: int) -> tuple[float, float]:
+ """Give the beat position and the tempo at this frame."""
+ start, beats_at_start, bpm = self._segments[0]
+ for segment in self._segments:
+ if segment[0] <= frame:
+ start, beats_at_start, bpm = segment
+ else:
+ break
+ return beats_at_start + (frame - start) * bpm / (self.samplerate * 60.0), bpm
+
+
+def _fake_jack_module(spec: RenderSpec) -> tuple[types.ModuleType, list[_FakeClient]]:
+ module = types.ModuleType("jack")
+ made: list[_FakeClient] = []
+
+ def client_factory(name: str, no_start_server: bool = False) -> _FakeClient:
+ client = _FakeClient(spec)
+ made.append(client)
+ return client
+
+ module.Client = client_factory # pyright: ignore[reportAttributeAccessIssue]
+ module.ROLLING = _ROLLING # pyright: ignore[reportAttributeAccessIssue]
+ module.POSITION_BBT = _POSITION_BBT # pyright: ignore[reportAttributeAccessIssue]
+ return module, made
+
+
+def _load_entry_point(source: Path | None) -> types.ModuleType:
+ if source is None:
+ import pistomp.metronome.__main__ as entry
+
+ return entry
+ spec = importlib.util.spec_from_file_location("metronome_under_test", source)
+ if spec is None or spec.loader is None:
+ raise RuntimeError(f"cannot load {source}")
+ module = importlib.util.module_from_spec(spec)
+ spec.loader.exec_module(module)
+ return module
+
+
+def render(spec: RenderSpec, source: Path | None = None) -> np.ndarray:
+ entry = _load_entry_point(source)
+
+ fake, made = _fake_jack_module(spec)
+ saved_modules = sys.modules.get("jack")
+ saved_argv, saved_stdin = sys.argv, sys.stdin
+
+ # main() waits in select() on stdin for a command. Send "stop" first. Then
+ # main() exits through its own deactivate and close path.
+ read_fd, write_fd = os.pipe()
+ os.write(write_fd, b"stop\n")
+ os.close(write_fd)
+
+ sys.modules["jack"] = fake
+ sys.argv = ["metronome", "enabled"]
+ sys.stdin = os.fdopen(read_fd)
+ try:
+ entry.main() # pyright: ignore[reportAttributeAccessIssue]
+ finally:
+ sys.stdin.close()
+ sys.argv, sys.stdin = saved_argv, saved_stdin
+ if saved_modules is None:
+ del sys.modules["jack"]
+ else:
+ sys.modules["jack"] = saved_modules
+
+ if not made:
+ raise RuntimeError("no JACK client was opened")
+ return made[0].audio
+
+
+def write_wav(path: Path, audio: np.ndarray, samplerate: int) -> None:
+ pcm = (np.clip(audio, -1.0, 1.0) * 32767.0).astype(" None:
+ """Render a metronome WAV file for testing."""
+ parser = argparse.ArgumentParser(description=__doc__)
+ parser.add_argument("-o", "--out", type=Path, default=Path("metronome.wav"))
+ parser.add_argument("--bpm", type=float, default=120.0)
+ parser.add_argument("--frames", type=int, default=256, help="JACK buffer size")
+ parser.add_argument("--beats-per-bar", type=int, default=4)
+ parser.add_argument("--duration", type=float, default=8.5, help="seconds")
+ parser.add_argument("--samplerate", type=int, default=48000)
+ parser.add_argument("--ticks-per-beat", type=float, default=1920.0)
+ parser.add_argument("--tick-mode", choices=("int", "float"), default="int")
+ parser.add_argument(
+ "--source", type=Path, default=None, help="render this copy of __main__.py in place of the installed one"
+ )
+ parser.add_argument(
+ "--tempo-change",
+ action="append",
+ default=[],
+ metavar="SEC:BPM",
+ help="change the tempo at this time. The beat phase stays continuous",
+ )
+ parser.add_argument(
+ "--restart-grid",
+ action="append",
+ default=[],
+ type=float,
+ metavar="SEC",
+ help="set the beat position back to bar 1 beat 1 at this time",
+ )
+ args = parser.parse_args()
+
+ tempo_changes = []
+ for item in args.tempo_change:
+ at_s, _, new_bpm = item.partition(":")
+ tempo_changes.append((float(at_s), float(new_bpm)))
+
+ spec = RenderSpec(
+ args.bpm,
+ args.frames,
+ args.beats_per_bar,
+ args.duration,
+ args.samplerate,
+ args.ticks_per_beat,
+ args.tick_mode,
+ tempo_changes,
+ args.restart_grid,
+ )
+ audio = render(spec, args.source)
+ write_wav(args.out, audio, args.samplerate)
+
+ peak = float(np.abs(audio).max())
+ clipped = int(np.count_nonzero(np.abs(audio) >= 1.0))
+ print(
+ f"{args.out}: {args.bpm} bpm, {args.frames} frames, tick={args.tick_mode} → peak={peak:.4f} clipped={clipped}"
+ )
+
+
+if __name__ == "__main__":
+ main()
diff --git a/plugins/audio_midi/panel.py b/plugins/audio_midi/panel.py
index 0b4bc77ef..1920832a8 100644
--- a/plugins/audio_midi/panel.py
+++ b/plugins/audio_midi/panel.py
@@ -16,7 +16,7 @@
# along with pi-stomp. If not, see .
"""Audio & MIDI menu — the menu-idiom surface for the global EQ, input/output
-levels, clock source, and VU calibration.
+levels, clock source, and metronome.
Composes the reactive ``PluginPanel`` core (via ``ModalDialog``) with a
synthetic ``AudioMidiParamSource`` (no backing ``Plugin``) so the EQ bands
@@ -26,7 +26,7 @@
Layout (``DIMMED_WINDOW`` 304×208 body + title strip + Back footer):
- Left column: the Equalizer on/off row, the 5-band EQ bar curve (compact,
- sized to its box), then Clock Source and VU Calibration drill-in rows.
+ sized to its box), then Clock Source and Metronome drill-in rows.
The rows follow the menu system's padding (line-height spacing,
h_margin=5, v_margin=1) so they read as-of-a-piece with the other menus.
- Right column: Input Gain + Output Volume arc dials (stacked).
@@ -35,10 +35,10 @@
the arcs and the EQ bars carry their own value readouts inline.
NAV reticule scans: Equalizer → Low → L-Mid → Mid → H-Mid → High → Clock
-Source → VU Cal → Input → Output → Back. Tweak1 edits the selection;
+Source → Metronome → Input → Output → Back. Tweak1 edits the selection;
Tweak2 = Input Gain; Tweak3/Vol = Output Volume (per §6). Clock Source
-opens a radio submenu (Internal / Ableton Link / MIDI Clock Slave); VU Cal
-opens the existing VU calibration dialog. Switching the Equalizer off drops
+opens a radio submenu (Internal / Ableton Link / MIDI Clock Slave);
+Metronome toggles the click on/off. Switching the Equalizer off drops
the bands out of the NAV cycle and dims them.
"""
@@ -274,9 +274,9 @@ def _draw(self, ctx) -> None:
class _DiscreteRow(RichTextWidget):
- """A drill-in row (Clock Source / VU Calibration). ``symbol_for`` returns
+ """A drill-in row (Clock Source / Metronome). ``symbol_for`` returns
None so ``SelectionEditEffect`` no-ops on it — the row is NAV-click-only,
- opening a submenu/dialog rather than editing a continuous value."""
+ opening a submenu or toggling a state rather than editing a continuous value."""
def __init__(
self, *, box: Box, segments: list[Segment], action: Callable[[InputEvent], bool], font, parent: Widget
@@ -332,7 +332,7 @@ def _btn(text: str, x: int, action: Callable[..., None]) -> Button:
return (
_btn("Back", BTN_GAP, lambda *_: self._on_dismiss()),
mute_btn,
- _btn("Restart", BTN_GAP * 3 + btn_w * 2, lambda *_: self._on_restart()),
+ _btn("Adjust VU", BTN_GAP * 3 + btn_w * 2, lambda *_: self._on_vu_cal()),
)
def title_text(self) -> str:
@@ -344,7 +344,7 @@ def scheme(self):
def __init__(self, *, handler: "Modhandler", on_dismiss: Callable[[], None]) -> None:
self._handler: Modhandler = handler
self._sync_row: Optional[_DiscreteRow] = None
- self._vu_row: Optional[_DiscreteRow] = None
+ self._metronome_row: Optional[_DiscreteRow] = None
self._eq_row: Optional[_DiscreteRow] = None
self._bar_widget: Optional[_CompactEqWidget] = None
self._in_arc: Optional[ArcKnobWidget] = None
@@ -482,11 +482,10 @@ def _build_rows(self) -> None:
parent=self,
)
y += _ROW_H
- vu_segs: list[Segment] = [TextSeg("VU Calibration"), Spacer()]
- self._vu_row = _DiscreteRow(
+ self._metronome_row = _DiscreteRow(
box=Box.xywh(cb.x0 + _ROWS_X, y, _ROWS_W, _ROW_H),
- segments=vu_segs,
- action=self._on_vu_row,
+ segments=self._metronome_row_segments(),
+ action=self._on_metronome_row,
font=self._row_font,
parent=self,
)
@@ -506,8 +505,11 @@ def _sync_row_segments(self) -> list[Segment]:
IconSeg(PillGlyph(label, height=glyph_h, color=DEFAULT_COLOR)),
]
+ def _metronome_row_segments(self) -> list[Segment]:
+ return [TextSeg("Metronome"), Spacer(), IconSeg(_eq_badge(self._handler.metronome_enabled))]
+
def _select_initial(self) -> None:
- # NAV order: Equalizer → EQ bands (Low..High) → Clock Source → VU Cal
+ # NAV order: Equalizer → EQ bands (Low..High) → Clock Source → Metronome
# → Input arc → Output arc → Back.
if self._eq_row is not None and self._eq_supported:
self.add_sel_widget(self._eq_row)
@@ -518,8 +520,8 @@ def _select_initial(self) -> None:
self.add_sel_widget(sel)
if self._sync_row is not None:
self.add_sel_widget(self._sync_row)
- if self._vu_row is not None:
- self.add_sel_widget(self._vu_row)
+ if self._metronome_row is not None:
+ self.add_sel_widget(self._metronome_row)
if self._in_arc is not None:
self.add_sel_widget(self._in_arc)
if self._out_arc is not None:
@@ -603,10 +605,13 @@ def _on_sync_row(self, event: InputEvent) -> bool:
self._open_clock_source_submenu()
return True
- def _on_vu_row(self, event: InputEvent) -> bool:
+ def _on_metronome_row(self, event: InputEvent) -> bool:
if event != InputEvent.CLICK:
return False
- self._handler.system_menu_vu_calibration(None)
+ self._handler.toggle_metronome_enable()
+ if self._metronome_row is not None:
+ self._metronome_row.segments = self._metronome_row_segments()
+ self._metronome_row.refresh()
return True
def _open_clock_source_submenu(self) -> None:
@@ -674,10 +679,8 @@ def _on_toggle_mute(self) -> None:
# open, but the next dismiss must show the post-toggle state.
self._handler.lcd.update_audio_midi_tile()
- def _on_restart(self) -> None:
- # Mirrors handler.system_menu_restart_sound — restarts jack (which
- # cascades to mod-host/mod-ui). The splash covers the teardown.
- self._handler.system_menu_restart_sound(None)
+ def _on_vu_cal(self) -> None:
+ self._handler.system_menu_vu_calibration(None)
def wants_fast_tick(self) -> bool:
return True
diff --git a/tests/integration/conftest.py b/tests/integration/conftest.py
index 3d2cb5444..71bd695fb 100644
--- a/tests/integration/conftest.py
+++ b/tests/integration/conftest.py
@@ -60,6 +60,7 @@ def _build_stack(
patch("subprocess.check_output", return_value=b"SystemState=running"),
patch("pistomp.lcd320x240.LcdIli9341", return_value=fake_lcd),
patch("modalapi.modhandler.AsyncWebSocketBridge", return_value=fake_bridge),
+ patch("modalapi.modhandler.MetronomeClient"),
):
# Tests don't drive a poll loop, so stub pending_op_count to always return 0 (no pending ops).
mock_wm_cls.return_value.queue.pending_op_count.return_value = 0
diff --git a/tests/snapshots/v3/test_audio_midi_panel/test_clock_source_selected/clock_source_selected.png b/tests/snapshots/v3/test_audio_midi_panel/test_clock_source_selected/clock_source_selected.png
index edf079796..6ad75f209 100644
Binary files a/tests/snapshots/v3/test_audio_midi_panel/test_clock_source_selected/clock_source_selected.png and b/tests/snapshots/v3/test_audio_midi_panel/test_clock_source_selected/clock_source_selected.png differ
diff --git a/tests/snapshots/v3/test_audio_midi_panel/test_eq_switched_off/eq_off.png b/tests/snapshots/v3/test_audio_midi_panel/test_eq_switched_off/eq_off.png
index 6c068b011..dbcbdbcef 100644
Binary files a/tests/snapshots/v3/test_audio_midi_panel/test_eq_switched_off/eq_off.png and b/tests/snapshots/v3/test_audio_midi_panel/test_eq_switched_off/eq_off.png differ
diff --git a/tests/snapshots/v3/test_audio_midi_panel/test_initial_render/initial.png b/tests/snapshots/v3/test_audio_midi_panel/test_initial_render/initial.png
index f87c41465..37a01bc8c 100644
Binary files a/tests/snapshots/v3/test_audio_midi_panel/test_initial_render/initial.png and b/tests/snapshots/v3/test_audio_midi_panel/test_initial_render/initial.png differ
diff --git a/tests/snapshots/v3/test_audio_midi_panel/test_levels_and_v_eq_drives_alsa_writes/gain_set.png b/tests/snapshots/v3/test_audio_midi_panel/test_levels_and_v_eq_drives_alsa_writes/gain_set.png
index 4ccc9707d..7a16f1d17 100644
Binary files a/tests/snapshots/v3/test_audio_midi_panel/test_levels_and_v_eq_drives_alsa_writes/gain_set.png and b/tests/snapshots/v3/test_audio_midi_panel/test_levels_and_v_eq_drives_alsa_writes/gain_set.png differ
diff --git a/tests/snapshots/v3/test_audio_midi_panel/test_levels_and_v_eq_drives_alsa_writes/opened.png b/tests/snapshots/v3/test_audio_midi_panel/test_levels_and_v_eq_drives_alsa_writes/opened.png
index f87c41465..37a01bc8c 100644
Binary files a/tests/snapshots/v3/test_audio_midi_panel/test_levels_and_v_eq_drives_alsa_writes/opened.png and b/tests/snapshots/v3/test_audio_midi_panel/test_levels_and_v_eq_drives_alsa_writes/opened.png differ
diff --git a/tests/snapshots/v3/test_audio_midi_panel/test_levels_and_v_eq_drives_alsa_writes/output_set.png b/tests/snapshots/v3/test_audio_midi_panel/test_levels_and_v_eq_drives_alsa_writes/output_set.png
index 36c3e1f46..debc52b46 100644
Binary files a/tests/snapshots/v3/test_audio_midi_panel/test_levels_and_v_eq_drives_alsa_writes/output_set.png and b/tests/snapshots/v3/test_audio_midi_panel/test_levels_and_v_eq_drives_alsa_writes/output_set.png differ
diff --git a/tests/snapshots/v3/test_audio_midi_panel/test_levels_and_v_eq_drives_alsa_writes/v_eq_shape.png b/tests/snapshots/v3/test_audio_midi_panel/test_levels_and_v_eq_drives_alsa_writes/v_eq_shape.png
index 66c30f58e..cda55c72d 100644
Binary files a/tests/snapshots/v3/test_audio_midi_panel/test_levels_and_v_eq_drives_alsa_writes/v_eq_shape.png and b/tests/snapshots/v3/test_audio_midi_panel/test_levels_and_v_eq_drives_alsa_writes/v_eq_shape.png differ
diff --git a/tests/snapshots/v3/test_audio_midi_panel/test_metronome_tile_snapshot_when_enabled_and_rolling/metronome_rolling.png b/tests/snapshots/v3/test_audio_midi_panel/test_metronome_tile_snapshot_when_enabled_and_rolling/metronome_rolling.png
new file mode 100644
index 000000000..a0f38d7ab
Binary files /dev/null and b/tests/snapshots/v3/test_audio_midi_panel/test_metronome_tile_snapshot_when_enabled_and_rolling/metronome_rolling.png differ
diff --git a/tests/test_handler_cleanup.py b/tests/test_handler_cleanup.py
index 1068516c7..ffb9e36e2 100644
--- a/tests/test_handler_cleanup.py
+++ b/tests/test_handler_cleanup.py
@@ -16,6 +16,7 @@ def test_cleanup_closes_external_midi(self):
h._hardware = None
h.external_midi = MagicMock()
h.ethernet_manager = MagicMock()
+ h._metronome_client = None
h.ws_bridge = MagicMock()
h.cleanup()
h.external_midi.close.assert_called_once()
diff --git a/tests/v3/test_audio_midi_panel.py b/tests/v3/test_audio_midi_panel.py
index 32195b0da..a5f538c92 100644
--- a/tests/v3/test_audio_midi_panel.py
+++ b/tests/v3/test_audio_midi_panel.py
@@ -1,7 +1,7 @@
"""Audio & MIDI menu — snapshot and behaviour tests.
Covers the new ``AudioMidiPanel`` surface (``docs/audio-midi-menu.md``):
-EQ curve + IN/OUT arcs + Clock Source/VU Cal rows, the declared-bindings
+EQ curve + IN/OUT arcs + Clock Source/Metronome rows, the declared-bindings
tweak model, and the ``syncMode`` echo → Clock Source pill repaint.
To regenerate snapshots after intentional UI changes:
@@ -378,7 +378,7 @@ class TestAudioMidiTileGlyph:
def test_each_state_renders_16x16_srcalpha(self):
from uilib.glyphs.audio_midi_tile import audio_midi_tile_glyph
- for state in ("nominal", "muted", "rolling"):
+ for state in ("nominal", "muted", "rolling", "metronome"):
g = audio_midi_tile_glyph(state)
assert g.get_size() == (16, 16), state
assert g.get_flags() & pygame.SRCALPHA, state
@@ -403,6 +403,23 @@ def test_rolling_glyph_has_play_triangle_nominal_lacks(self):
assert nominal.get_at(apex)[3] == 0
assert rolling.get_at(apex)[3] > 0
+ def test_metronome_glyph_has_pendulum_that_rolling_lacks(self):
+ from uilib.glyphs.audio_midi_tile import audio_midi_tile_glyph
+
+ rolling = audio_midi_tile_glyph("rolling")
+ metronome = audio_midi_tile_glyph("metronome")
+ # (12, 0) is in the pendulum arm's outline (tip cap) and is outside the
+ # play-triangle's 1.5px outline by ~0.6px (triangle edge signed dist ≈ -2.6px
+ # at this pixel, more negative than the -2.0 threshold for the band).
+ arm_px = (12, 0)
+ assert metronome.get_at(arm_px)[3] > 0
+ assert rolling.get_at(arm_px)[3] == 0
+ # Play-triangle apex at (15, 5) is present on rolling, absent on metronome
+ # (x=15 is outside the trapezoid body at y=5).
+ apex = (15, 5)
+ assert rolling.get_at(apex)[3] > 0
+ assert metronome.get_at(apex)[3] == 0
+
class TestAudioMidiTileState:
"""The toolbar tile (w_eq) reflects jack_mute / transport_rolling.
@@ -467,3 +484,39 @@ def test_mute_via_panel_swaps_tile_to_muted_glyph(self, audio_midi_system: Syste
# Restore to keep the shared fixture tidy.
jm.unmute()
handler.lcd.update_audio_midi_tile()
+
+ def test_tile_shows_metronome_glyph_when_enabled_and_rolling(self, audio_midi_system: SystemFixture):
+ handler = audio_midi_system.handler
+ handler.lcd.draw_main_panel()
+ handler._metronome_enabled = True # type: ignore[attr-defined]
+ cast(FakeWebSocketBridge, handler.ws_bridge).inject("transport 1 4.0 120.0 link")
+ handler.poll_ws_messages()
+ handler.poll_lcd_updates()
+ cur = self._w_eq_surface(audio_midi_system)
+ # Pendulum tip visible; play-triangle apex absent.
+ # (12, 0) is in the pendulum arm outline; outside play-triangle and all bars.
+ assert cur.get_at((12, 0))[3] > 0
+ assert cur.get_at((15, 5))[3] == 0
+
+ def test_tile_shows_rolling_not_metronome_when_disabled_and_rolling(self, audio_midi_system: SystemFixture):
+ handler = audio_midi_system.handler
+ handler.lcd.draw_main_panel()
+ handler._metronome_enabled = False # type: ignore[attr-defined]
+ cast(FakeWebSocketBridge, handler.ws_bridge).inject("transport 1 4.0 120.0 link")
+ handler.poll_ws_messages()
+ handler.poll_lcd_updates()
+ cur = self._w_eq_surface(audio_midi_system)
+ assert cur.get_at((15, 5))[3] > 0 # play-triangle apex
+ assert cur.get_at((12, 0))[3] == 0 # no pendulum
+
+ def test_metronome_tile_snapshot_when_enabled_and_rolling(
+ self, audio_midi_system: SystemFixture, snapshot
+ ):
+ """Full-display snapshot: rolling + metronome on → metronome glyph in toolbar."""
+ handler = audio_midi_system.handler
+ handler._metronome_enabled = True # type: ignore[attr-defined]
+ handler.lcd.draw_main_panel()
+ cast(FakeWebSocketBridge, handler.ws_bridge).inject("transport 1 4.0 120.0 link")
+ handler.poll_ws_messages()
+ handler.poll_lcd_updates()
+ snapshot("metronome_rolling")
diff --git a/uilib/glyphs/audio_midi_tile.py b/uilib/glyphs/audio_midi_tile.py
index a1bdd92f0..439aec2ba 100644
--- a/uilib/glyphs/audio_midi_tile.py
+++ b/uilib/glyphs/audio_midi_tile.py
@@ -22,20 +22,24 @@
6/14/16/8 px from left so the nominal glyph reads as the familiar EQ
icon) and the muted/rolling states are its visible mutations:
-- ``nominal`` — blue EQ bars (idle, transport stopped).
-- ``muted`` — red bars with a diagonal slash — the universal mute glyph.
-- ``rolling`` — blue bars with a play-triangle overlay — transport is rolling.
-
-The play-triangle is analytically anti-aliased with a 1.5px black outline,
-mirroring ``paint_circle_handle``'s eraser/fill construction (dilated black
-mask pasted first, coloured mask pasted on top — the exposed rim reads as
-the outline). Other primitives use the jaggie pixel look to stay close to
-the original ``eq_blue.png`` silhouette.
+- ``nominal`` — blue EQ bars (idle, transport stopped).
+- ``muted`` — red bars with a diagonal slash — the universal mute glyph.
+- ``rolling`` — blue bars with a play-triangle overlay — transport is rolling.
+- ``metronome`` — blue trapezoid body with a pendulum arm — metronome is enabled
+ and transport is rolling (replaces the EQ-bar silhouette entirely
+ so the icon is instantly recognisable as a click source).
+
+The play-triangle and metronome body are analytically anti-aliased with a 1.5px
+black outline, mirroring ``paint_circle_handle``'s eraser/fill construction
+(coloured fill pasted first, black band pasted on top — the exposed rim reads as
+the outline). Other primitives use the jaggie pixel look to stay close to the
+original ``eq_blue.png`` silhouette.
Rendered at 16×16 with ``pygame.SRCALPHA`` and cached per state. The LCD's
``update_audio_midi_tile()`` swaps these into ``w_eq`` based on the handler's
-``jack_mute`` / ``transport_rolling`` state. ``draw_tools()`` seeds the tile
-with the nominal glyph so the procedural pipeline owns the surface from t=0.
+``jack_mute`` / ``transport_rolling`` / ``metronome_enabled`` state.
+``draw_tools()`` seeds the tile with the nominal glyph so the procedural
+pipeline owns the surface from t=0.
"""
from __future__ import annotations
@@ -48,7 +52,7 @@
import pygame
-State = Literal["nominal", "muted", "rolling"]
+State = Literal["nominal", "muted", "rolling", "metronome"]
_SIZE = 16
# Same blue as the toolbar image; same red as a footswitch-toggled mute.
@@ -75,6 +79,30 @@
)
_PLAY_OUTLINE_PX = 1.5 # black eraser dilation, matching circle handle's outline band
+# Metronome body — isosceles trapezoid rendered as a plain filled polygon
+# (no AA outline; dilated edge-to-edge to compensate). The body occupies
+# y=4..15, full-canvas-width at the base tapering to 6px at the top.
+_METRONOME_BODY_PTS: tuple[tuple[int, int], ...] = (
+ (0, 15), # bottom-left (full canvas width at base)
+ (15, 15), # bottom-right
+ (11, 4), # top-right (6px wide at top, centred on x=8)
+ (5, 4), # top-left
+)
+# Pendulum arm — represented as a thin 2px-wide rectangle so the same
+# _polygon_masks AA+outline construction as the play-triangle applies.
+# Fulcrum at (8, 13): x-centre of the tile, ~80% down the body height
+# (body spans y=4..15 → 80% = y≈13), 2px above the base.
+# Tip at (12, 1): arm swings right at ~18° from vertical.
+#
+# Perpendicular half-width 1.0px → 2px total arm thickness.
+# dx=4, dy=-12, len=√160≈12.649 → perp unit = (12/len, 4/len)
+_PENDULUM_VERTICES: tuple[tuple[float, float], ...] = (
+ (8.949, 13.316), # fulcrum + perp
+ (12.949, 1.316), # tip + perp
+ (11.051, 0.684), # tip - perp
+ ( 7.051, 12.684), # fulcrum - perp
+)
+
def _bar_x(i: int) -> int:
# Centre the 4-bar block (4*2 + 3*2 = 14 px) within the 16-wide glyph.
@@ -183,6 +211,34 @@ def _paste(cov: np.ndarray, rgb: tuple[int, int, int]) -> None:
_paste(band, _ERASER_COLOR) # 1.5px black rim over fill's edge
+def _draw_metronome(surf: pygame.Surface, body_color: tuple[int, int, int]) -> None:
+ """Plain-fill trapezoid body + AA-outlined pendulum arm.
+
+ Body uses ``pygame.draw.polygon`` (no outline, dilated to compensate).
+ Arm uses the same ``_polygon_masks`` AA+outline construction as the play-
+ triangle: white fill pasted first, 1.5px black rim pasted on top.
+ """
+ # Body first so the arm renders on top at the fulcrum overlap
+ pygame.draw.polygon(surf, body_color, _METRONOME_BODY_PTS)
+
+ cov_inner, band, ox, oy = _polygon_masks(_PENDULUM_VERTICES)
+
+ def _paste(cov: np.ndarray, rgb: tuple[int, int, int]) -> None:
+ alpha = (cov * 255).astype(np.uint8)
+ h, w = alpha.shape
+ tmp = pygame.Surface((w, h), pygame.SRCALPHA)
+ pixels = pygame.surfarray.pixels3d(tmp)
+ pixels[:] = rgb
+ del pixels
+ pa = pygame.surfarray.pixels_alpha(tmp)
+ pa[:] = alpha.T
+ del pa
+ surf.blit(tmp, (ox, oy))
+
+ _paste(cov_inner, _TRIANGLE_FILL_WHITE) # white arm fill over body
+ _paste(band, _ERASER_COLOR) # 1.5px black rim
+
+
@lru_cache(maxsize=4)
def _render(state: State) -> pygame.Surface:
surf = pygame.Surface((_SIZE, _SIZE), pygame.SRCALPHA)
@@ -192,6 +248,8 @@ def _render(state: State) -> pygame.Surface:
elif state == "rolling":
_draw_bars(surf, _NOMINAL_COLOR)
_draw_play_triangle(surf, _TRIANGLE_FILL_WHITE)
+ elif state == "metronome":
+ _draw_metronome(surf, _NOMINAL_COLOR)
else:
_draw_bars(surf, _NOMINAL_COLOR)
return surf