\d+)(?:-(?P[A-Za-z0-9.]+))?'
+serialize = ["{major}.{minor}.{patch}-{pre}", "{major}.{minor}.{patch}"]
+
+[[tool.bumpversion.files]]
+filename = "linkbridge/__init__.py"
+search = '__version__ = "{current_version}"'
+replace = '__version__ = "{new_version}"'
+
+[[tool.bumpversion.files]]
+filename = "setup.py"
+search = '"CFBundleVersion": "{current_version}"'
+replace = '"CFBundleVersion": "{new_version}"'
+
+[[tool.bumpversion.files]]
+filename = "setup.py"
+search = '"CFBundleShortVersionString": "{current_version}"'
+replace = '"CFBundleShortVersionString": "{new_version}"'
diff --git a/pytest.ini b/pytest.ini
new file mode 100644
index 0000000..228fcfb
--- /dev/null
+++ b/pytest.ini
@@ -0,0 +1,4 @@
+[pytest]
+testpaths = tests
+python_files = test_*.py
+addopts = -ra --strict-markers
diff --git a/requirements-dev.txt b/requirements-dev.txt
new file mode 100644
index 0000000..00458d9
--- /dev/null
+++ b/requirements-dev.txt
@@ -0,0 +1,3 @@
+bump-my-version==1.3.0
+py2app==0.28.10
+pytest==9.0.3
diff --git a/requirements.txt b/requirements.txt
index 71f2dab..d513d5b 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -1 +1,4 @@
-aalink==0.1.1
+aalink==0.2.1
+mido==1.3.3
+python-rtmidi==1.5.8
+rumps==0.4.0
diff --git a/scripts/build_app.sh b/scripts/build_app.sh
new file mode 100755
index 0000000..a0ec1d8
--- /dev/null
+++ b/scripts/build_app.sh
@@ -0,0 +1,24 @@
+#!/usr/bin/env bash
+# Build LinkBridge.app with py2app.
+set -euo pipefail
+
+cd "$(dirname "$0")/.."
+
+if [[ -z "${VIRTUAL_ENV:-}" ]]; then
+ if [[ -f venv/bin/activate ]]; then
+ # shellcheck disable=SC1091
+ source venv/bin/activate
+ else
+ echo "Error: no venv/ found and VIRTUAL_ENV is not set" >&2
+ exit 1
+ fi
+fi
+
+echo "Cleaning previous build artifacts..."
+rm -rf build dist
+
+echo "Running py2app..."
+python setup.py py2app
+
+echo
+echo "Built: dist/LinkBridge.app"
diff --git a/scripts/build_icon.py b/scripts/build_icon.py
new file mode 100755
index 0000000..12a0384
--- /dev/null
+++ b/scripts/build_icon.py
@@ -0,0 +1,109 @@
+#!/usr/bin/env python3
+"""Regenerate assets/LinkBridge.icns from assets/icon.png.
+
+Pipeline:
+ 1. Load assets/icon.png (1024x1024 with a white margin around the artwork)
+ 2. Flood-fill the four corners to transparent — also writes
+ assets/icon-transparent.png so a transparent PNG is available standalone
+ 3. Detect the bounding box of non-transparent pixels and crop to it
+ 4. Center in a square canvas (longer side wins) and resize back to 1024x1024
+ so the artwork fills the canvas — saved as assets/icon-1024.png
+ 5. Generate assets/LinkBridge.iconset/ with 10 PNG sizes (16, 32, 64, 128,
+ 256, 512, 1024, plus the @2x retina variants required by macOS)
+ 6. Run `iconutil -c icns assets/LinkBridge.iconset -o assets/LinkBridge.icns`
+
+Requires Pillow:
+ python3 -m pip install Pillow
+
+Usage (from the repo root):
+ python3 scripts/build_icon.py
+"""
+
+from __future__ import annotations
+
+import shutil
+import subprocess
+import sys
+from pathlib import Path
+
+try:
+ from PIL import Image, ImageDraw
+except ImportError:
+ sys.exit(
+ "error: Pillow is required for icon regeneration.\n"
+ " install it with: python3 -m pip install Pillow"
+ )
+
+REPO = Path(__file__).resolve().parent.parent
+SOURCE = REPO / "assets" / "icon.png"
+TRANSPARENT = REPO / "assets" / "icon-transparent.png"
+CROPPED_1024 = REPO / "assets" / "icon-1024.png"
+ICONSET = REPO / "assets" / "LinkBridge.iconset"
+ICNS = REPO / "assets" / "LinkBridge.icns"
+
+TRANSPARENT_RGBA = (0, 0, 0, 0)
+FLOOD_THRESHOLD = 35
+
+ICONSET_SIZES = [
+ (16, ""),
+ (16, "@2x"),
+ (32, ""),
+ (32, "@2x"),
+ (128, ""),
+ (128, "@2x"),
+ (256, ""),
+ (256, "@2x"),
+ (512, ""),
+ (512, "@2x"),
+]
+
+
+def main() -> int:
+ if not SOURCE.exists():
+ sys.exit(f"error: source icon not found at {SOURCE}")
+
+ img = Image.open(SOURCE).convert("RGBA")
+ w, h = img.size
+ print(f"loaded {SOURCE.name} ({w}x{h})")
+
+ for corner in [(0, 0), (w - 1, 0), (0, h - 1), (w - 1, h - 1)]:
+ ImageDraw.floodfill(img, corner, TRANSPARENT_RGBA, thresh=FLOOD_THRESHOLD)
+
+ img.save(TRANSPARENT)
+ print(f" wrote {TRANSPARENT.name}")
+
+ bbox = img.getbbox()
+ if bbox is None:
+ sys.exit("error: no opaque pixels remain after flood fill")
+ print(f" bbox: {bbox} ({bbox[2] - bbox[0]} x {bbox[3] - bbox[1]})")
+
+ cropped = img.crop(bbox)
+ cw, ch = cropped.size
+ square_size = max(cw, ch)
+ square = Image.new("RGBA", (square_size, square_size), TRANSPARENT_RGBA)
+ square.paste(cropped, ((square_size - cw) // 2, (square_size - ch) // 2))
+
+ final_1024 = square.resize((1024, 1024), Image.LANCZOS)
+ final_1024.save(CROPPED_1024)
+ print(f" wrote {CROPPED_1024.name}")
+
+ if ICONSET.exists():
+ shutil.rmtree(ICONSET)
+ ICONSET.mkdir(parents=True)
+
+ for logical, suffix in ICONSET_SIZES:
+ pixel_size = logical * 2 if suffix == "@2x" else logical
+ out = ICONSET / f"icon_{logical}x{logical}{suffix}.png"
+ final_1024.resize((pixel_size, pixel_size), Image.LANCZOS).save(out)
+ print(f" wrote {len(ICONSET_SIZES)} sizes to {ICONSET.name}/")
+
+ subprocess.run(
+ ["iconutil", "-c", "icns", str(ICONSET), "-o", str(ICNS)],
+ check=True,
+ )
+ print(f" wrote {ICNS.name} ({ICNS.stat().st_size // 1024} KB)")
+ return 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/setup.py b/setup.py
new file mode 100644
index 0000000..1e769d2
--- /dev/null
+++ b/setup.py
@@ -0,0 +1,47 @@
+"""py2app build config for LinkBridge.
+
+Usage:
+ python setup.py py2app
+
+Output:
+ dist/LinkBridge.app
+"""
+
+from setuptools import setup
+
+APP = ["linkbridge/__main__.py"]
+OPTIONS = {
+ "argv_emulation": False,
+ "iconfile": "assets/LinkBridge.icns",
+ "plist": {
+ "CFBundleName": "LinkBridge",
+ "CFBundleDisplayName": "LinkBridge",
+ "CFBundleIdentifier": "com.linkbridge.app",
+ "CFBundleVersion": "2.0.0",
+ "CFBundleShortVersionString": "2.0.0",
+ "LSUIElement": True,
+ "LSMinimumSystemVersion": "15.0",
+ "NSHighResolutionCapable": True,
+ "NSLocalNetworkUsageDescription": (
+ "LinkBridge uses the local network to discover Ableton Link peers "
+ "(such as Rekordbox, djay Pro, or other Link-enabled apps) so it "
+ "can forward their tempo to your MIDI output."
+ ),
+ },
+ "packages": ["rumps", "mido", "rtmidi", "linkbridge"],
+ "includes": [
+ "logging.handlers",
+ ],
+ "excludes": [
+ "tkinter",
+ "_tkinter",
+ "Tkinter",
+ ],
+}
+
+setup(
+ name="LinkBridge",
+ app=APP,
+ options={"py2app": OPTIONS},
+ setup_requires=["py2app"],
+)
diff --git a/tests/__init__.py b/tests/__init__.py
new file mode 100644
index 0000000..8b13789
--- /dev/null
+++ b/tests/__init__.py
@@ -0,0 +1 @@
+
diff --git a/tests/test_clock_engine.py b/tests/test_clock_engine.py
new file mode 100644
index 0000000..40c2d82
--- /dev/null
+++ b/tests/test_clock_engine.py
@@ -0,0 +1,292 @@
+"""Tests for linkbridge.clock_engine."""
+
+import threading
+import time
+
+import pytest
+
+from linkbridge.clock_engine import ClockEngine, ClockState
+
+
+def test_clock_state_defaults():
+ s = ClockState(lock=threading.Lock())
+ assert s.bpm == 120.0
+ assert abs(s.tick_interval - (1.0 / (120.0 / 60.0 * 24))) < 1e-12
+ assert s.is_playing is False
+ assert s.start_stop_enabled is False
+ assert s.midi_out is None
+ assert s.clock_crashed is False
+
+
+def test_set_bpm_recomputes_tick_interval():
+ s = ClockState(lock=threading.Lock())
+ s.set_bpm(140.0)
+ assert s.bpm == 140.0
+ expected = 1.0 / (140.0 / 60.0 * 24)
+ assert abs(s.tick_interval - expected) < 1e-12
+
+
+def test_set_bpm_rejects_non_positive():
+ s = ClockState(lock=threading.Lock())
+ with pytest.raises(ValueError, match="must be positive"):
+ s.set_bpm(0.0)
+ with pytest.raises(ValueError, match="must be positive"):
+ s.set_bpm(-60.0)
+
+
+# ------------------------------------------------------------
+# Fixtures for fake time and fake MIDI sink
+# ------------------------------------------------------------
+
+class FakeClock:
+ """Monotonic-like clock you can advance manually."""
+
+ def __init__(self, start: float = 0.0) -> None:
+ self.now = start
+
+ def __call__(self) -> float:
+ return self.now
+
+ def advance(self, dt: float) -> None:
+ self.now += dt
+
+
+class FakeSleeper:
+ """Sleep stand-in that advances a FakeClock instead of blocking."""
+
+ def __init__(self, clock: FakeClock) -> None:
+ self.clock = clock
+ self.calls: list[float] = []
+
+ def __call__(self, dt: float) -> None:
+ self.calls.append(dt)
+ if dt > 0:
+ self.clock.advance(dt)
+
+
+class FakeSink:
+ """Stand-in for a mido output port that records sent message types."""
+
+ def __init__(self) -> None:
+ self.sent: list[str] = []
+ self.closed: bool = False
+ self.fail_with: Exception | None = None
+
+ def send(self, msg) -> None:
+ if self.fail_with is not None:
+ raise self.fail_with
+ self.sent.append(msg.type)
+
+ def close(self) -> None:
+ self.closed = True
+
+
+def _make_state(sink: FakeSink | None, bpm: float = 120.0, is_playing: bool = False,
+ start_stop_enabled: bool = False) -> ClockState:
+ s = ClockState(lock=threading.Lock())
+ s.set_bpm(bpm)
+ s.is_playing = is_playing
+ s.start_stop_enabled = start_stop_enabled
+ s.midi_out = sink
+ return s
+
+
+def test_tick_once_sends_clock_message_to_sink():
+ clock = FakeClock()
+ sleeper = FakeSleeper(clock)
+ sink = FakeSink()
+ state = _make_state(sink)
+ engine = ClockEngine(state, clock=clock, sleeper=sleeper)
+ engine._next_tick_time = clock()
+
+ engine._tick_once()
+ engine._tick_once()
+ engine._tick_once()
+
+ assert sink.sent == ["clock", "clock", "clock"]
+
+
+def test_tick_cadence_matches_bpm():
+ # At 120 BPM with 24 ppqn, tick interval is 1 / (120/60 * 24) = 1/48 s ≈ 20.833 ms
+ clock = FakeClock()
+ sleeper = FakeSleeper(clock)
+ sink = FakeSink()
+ state = _make_state(sink, bpm=120.0)
+ engine = ClockEngine(state, clock=clock, sleeper=sleeper)
+ engine._next_tick_time = clock()
+
+ for _ in range(48):
+ engine._tick_once()
+
+ # 48 ticks at 120 BPM = 1 second of wall-clock time
+ assert abs(clock() - 1.0) < 1e-9
+ assert sink.sent == ["clock"] * 48
+
+
+def test_tempo_change_mid_run_applies_on_next_tick():
+ clock = FakeClock()
+ sleeper = FakeSleeper(clock)
+ sink = FakeSink()
+ state = _make_state(sink, bpm=120.0)
+ engine = ClockEngine(state, clock=clock, sleeper=sleeper)
+ engine._next_tick_time = clock()
+
+ # First tick at 120 BPM — expected interval 1/48 s
+ engine._tick_once()
+ t_after_first = clock()
+
+ # Change BPM to 240 — expected interval halves (1/96 s)
+ with state.lock:
+ state.set_bpm(240.0)
+
+ engine._tick_once()
+ delta = clock() - t_after_first
+
+ assert abs(delta - (1.0 / (240.0 / 60.0 * 24))) < 1e-12
+
+
+def test_transport_start_emitted_before_next_clock_when_toggle_on():
+ clock = FakeClock()
+ sleeper = FakeSleeper(clock)
+ sink = FakeSink()
+ state = _make_state(sink, is_playing=False, start_stop_enabled=True)
+ engine = ClockEngine(state, clock=clock, sleeper=sleeper)
+ engine._next_tick_time = clock()
+
+ # First tick: not playing. Only clock.
+ engine._tick_once()
+ assert sink.sent == ["clock"]
+
+ # Transport goes True
+ with state.lock:
+ state.is_playing = True
+
+ engine._tick_once()
+ # Expect START emitted before the CLOCK tick
+ assert sink.sent == ["clock", "start", "clock"]
+
+ # Next tick should not re-emit START
+ engine._tick_once()
+ assert sink.sent == ["clock", "start", "clock", "clock"]
+
+
+def test_transport_stop_emitted_before_next_clock_when_toggle_on():
+ clock = FakeClock()
+ sleeper = FakeSleeper(clock)
+ sink = FakeSink()
+ state = _make_state(sink, is_playing=True, start_stop_enabled=True)
+ engine = ClockEngine(state, clock=clock, sleeper=sleeper)
+ engine._next_tick_time = clock()
+ engine._prev_is_playing = True # already playing before first tick
+
+ engine._tick_once()
+ assert sink.sent == ["clock"]
+
+ with state.lock:
+ state.is_playing = False
+
+ engine._tick_once()
+ assert sink.sent == ["clock", "stop", "clock"]
+
+
+def test_transport_transitions_do_not_emit_start_stop_when_toggle_off():
+ clock = FakeClock()
+ sleeper = FakeSleeper(clock)
+ sink = FakeSink()
+ state = _make_state(sink, is_playing=False, start_stop_enabled=False)
+ engine = ClockEngine(state, clock=clock, sleeper=sleeper)
+ engine._next_tick_time = clock()
+
+ engine._tick_once()
+ with state.lock:
+ state.is_playing = True
+ engine._tick_once()
+ with state.lock:
+ state.is_playing = False
+ engine._tick_once()
+
+ # Only clock ticks — no start/stop ever.
+ assert sink.sent == ["clock", "clock", "clock"]
+
+
+def test_tick_once_with_no_device_does_not_raise():
+ clock = FakeClock()
+ sleeper = FakeSleeper(clock)
+ state = _make_state(sink=None) # midi_out = None
+ engine = ClockEngine(state, clock=clock, sleeper=sleeper)
+ engine._next_tick_time = clock()
+
+ # Should not raise and should still advance timing.
+ engine._tick_once()
+ engine._tick_once()
+ assert clock() > 0
+
+
+def test_send_failure_nulls_midi_out_and_keeps_running():
+ clock = FakeClock()
+ sleeper = FakeSleeper(clock)
+ sink = FakeSink()
+ sink.fail_with = IOError("unplugged")
+ state = _make_state(sink)
+ engine = ClockEngine(state, clock=clock, sleeper=sleeper)
+ engine._next_tick_time = clock()
+
+ engine._tick_once() # raises internally, caught, drops the port
+
+ with state.lock:
+ assert state.midi_out is None
+
+ # Subsequent ticks must not raise even though port is None now.
+ engine._tick_once()
+ engine._tick_once()
+
+
+def test_transport_send_failure_nulls_midi_out_and_skips_clock():
+ clock = FakeClock()
+ sleeper = FakeSleeper(clock)
+ sink = FakeSink()
+ sink.fail_with = IOError("unplugged")
+ state = _make_state(sink, is_playing=True, start_stop_enabled=True)
+ engine = ClockEngine(state, clock=clock, sleeper=sleeper)
+ engine._next_tick_time = clock()
+ engine._prev_is_playing = False # forces transport edge on first tick
+
+ engine._tick_once() # transport send raises, port is dropped, clock skipped
+
+ with state.lock:
+ assert state.midi_out is None
+ # The failing send was attempted but never recorded by FakeSink.
+ assert sink.sent == []
+ # Subsequent ticks must not raise (port is None).
+ engine._tick_once()
+
+
+def test_start_and_stop_real_thread_streams_clock():
+ sink = FakeSink()
+ state = _make_state(sink, bpm=240.0) # fast so the test is quick
+ engine = ClockEngine(state)
+ engine.start()
+ time.sleep(0.1) # ~9-10 ticks at 240 BPM
+ engine.stop()
+
+ # We can't assert an exact count, but there should be SOMETHING and no crashes.
+ assert "clock" in sink.sent
+ assert not state.clock_crashed
+ assert sink.closed is True
+ assert state.midi_out is None
+
+
+def test_run_sets_clock_crashed_on_unhandled_exception():
+ state = _make_state(sink=None)
+ engine = ClockEngine(state)
+
+ # Force _tick_once to explode on first call.
+ def boom():
+ raise RuntimeError("simulated")
+ engine._tick_once = boom # type: ignore[assignment]
+
+ engine._running.set()
+ engine._run() # runs on current thread until exception
+
+ with state.lock:
+ assert state.clock_crashed is True
diff --git a/tests/test_midi_output.py b/tests/test_midi_output.py
new file mode 100644
index 0000000..e229eb3
--- /dev/null
+++ b/tests/test_midi_output.py
@@ -0,0 +1,27 @@
+"""Tests for linkbridge.midi_output."""
+
+import pytest
+from unittest.mock import MagicMock, patch
+
+from linkbridge import midi_output
+
+
+def test_list_outputs_returns_mido_output_names():
+ with patch.object(midi_output.mido, "get_output_names", return_value=["A", "B"]):
+ result = midi_output.list_outputs()
+ assert isinstance(result, list)
+ assert result == ["A", "B"]
+
+
+def test_open_output_delegates_to_mido():
+ fake_port = MagicMock(name="fake_port")
+ with patch.object(midi_output.mido, "open_output", return_value=fake_port) as mock_open:
+ result = midi_output.open_output("Circuit Tracks MIDI")
+ mock_open.assert_called_once_with("Circuit Tracks MIDI")
+ assert result is fake_port
+
+
+def test_open_output_propagates_exceptions():
+ with patch.object(midi_output.mido, "open_output", side_effect=IOError("nope")):
+ with pytest.raises(IOError, match="nope"):
+ midi_output.open_output("Ghost Device")
diff --git a/tests/test_settings.py b/tests/test_settings.py
new file mode 100644
index 0000000..1273553
--- /dev/null
+++ b/tests/test_settings.py
@@ -0,0 +1,68 @@
+"""Tests for linkbridge.settings."""
+
+from pathlib import Path
+
+from linkbridge.settings import Settings
+
+
+def test_load_returns_defaults_when_file_missing(tmp_path: Path):
+ settings_file = tmp_path / "settings.json"
+ assert not settings_file.exists()
+
+ s = Settings.load(settings_file)
+
+ assert s.last_device is None
+ assert s.start_stop_enabled is False
+
+
+def test_save_and_load_round_trip(tmp_path: Path):
+ settings_file = tmp_path / "subdir" / "settings.json" # parent dir doesn't exist yet
+
+ original = Settings(last_device="Circuit Tracks MIDI", start_stop_enabled=True)
+ original.save(settings_file)
+
+ assert settings_file.exists()
+
+ loaded = Settings.load(settings_file)
+ assert loaded.last_device == "Circuit Tracks MIDI"
+ assert loaded.start_stop_enabled is True
+
+
+def test_load_recovers_from_corrupt_json(tmp_path: Path):
+ settings_file = tmp_path / "settings.json"
+ settings_file.write_text("{ this is not json", encoding="utf-8")
+
+ s = Settings.load(settings_file)
+
+ assert s.last_device is None
+ assert s.start_stop_enabled is False
+ # The corrupt file should have been renamed out of the way.
+ assert not settings_file.exists()
+ corrupt_files = list(tmp_path.glob("settings.json.corrupt-*"))
+ assert len(corrupt_files) == 1
+
+
+def test_load_recovers_from_non_object_json(tmp_path: Path):
+ settings_file = tmp_path / "settings.json"
+ settings_file.write_text("[]", encoding="utf-8") # valid JSON, wrong shape
+
+ s = Settings.load(settings_file)
+
+ assert s.last_device is None
+ assert s.start_stop_enabled is False
+ # Should also be quarantined like other corrupt files.
+ assert not settings_file.exists()
+ corrupt_files = list(tmp_path.glob("settings.json.corrupt-*"))
+ assert len(corrupt_files) == 1
+
+
+def test_load_coerces_non_string_last_device_to_none(tmp_path: Path):
+ settings_file = tmp_path / "settings.json"
+ settings_file.write_text(
+ '{"last_device": 42, "start_stop_enabled": true}', encoding="utf-8"
+ )
+
+ s = Settings.load(settings_file)
+
+ assert s.last_device is None # 42 is not a str — coerced to None
+ assert s.start_stop_enabled is True