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