diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..b12c229 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,86 @@ +name: release + +# Trigger model: this workflow is triggered ONLY by manual dispatch. +# Click "Run workflow" in the Actions tab on GitHub.com (or run +# `gh workflow run release.yml -f version=2.0.1` +# from the CLI), enter the new version, and the workflow will: +# 1. Bump version files +# 2. Commit + tag + push to main +# 3. Run the test suite +# 4. Build LinkBridge.app +# 5. Package as a zip with ditto +# 6. Create a draft GitHub Release with auto-generated notes +on: + workflow_dispatch: + inputs: + version: + description: 'New version (e.g. 2.0.1, 2.1.0). Do NOT prefix with "v".' + required: true + type: string + +permissions: + contents: write + +jobs: + release: + runs-on: macos-latest + + steps: + - name: Checkout main + uses: actions/checkout@v4 + with: + ref: main + fetch-depth: 0 # full history so auto-generated release notes can diff against the previous tag + + - name: Configure git identity + run: | + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + + - name: Set up Python 3.11 + uses: actions/setup-python@v5 + with: + python-version: '3.11' + + - name: Create venv and install dependencies + run: | + python3.11 -m venv venv + venv/bin/pip install --upgrade pip + venv/bin/pip install -r requirements.txt -r requirements-dev.txt + + - name: Run tests (sanity check) + run: venv/bin/pytest -v + + - name: Bump version files + run: venv/bin/bump-my-version replace --new-version "${{ inputs.version }}" + + - name: Show diff after bump + run: git diff + + - name: Commit, tag, push + run: | + git add linkbridge/__init__.py setup.py + git commit -m "chore: release v${{ inputs.version }}" + git tag -a "v${{ inputs.version }}" -m "Release v${{ inputs.version }}" + git push origin main + git push origin "v${{ inputs.version }}" + + - name: Build LinkBridge.app + run: ./scripts/build_app.sh + + - name: Package .app as a zip with ditto + run: | + cd dist + ditto -c -k --keepParent LinkBridge.app "LinkBridge-v${{ inputs.version }}.zip" + ls -la + + - name: Create draft GitHub Release + env: + GH_TOKEN: ${{ github.token }} + run: | + gh release create "v${{ inputs.version }}" \ + --repo "${{ github.repository }}" \ + --title "LinkBridge v${{ inputs.version }}" \ + --generate-notes \ + --draft \ + "dist/LinkBridge-v${{ inputs.version }}.zip" diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..9926b26 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,28 @@ +name: tests + +on: + push: + branches: [main] + pull_request: + +jobs: + test: + runs-on: macos-latest + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Python 3.11 + uses: actions/setup-python@v5 + with: + python-version: '3.11' + + - name: Create venv and install dependencies + run: | + python3.11 -m venv venv + venv/bin/pip install --upgrade pip + venv/bin/pip install -r requirements.txt -r requirements-dev.txt + + - name: Run pytest + run: venv/bin/pytest -v diff --git a/.gitignore b/.gitignore index c548532..5e4f0fa 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,9 @@ *.so venv/ -TODO \ No newline at end of file +TODO +__pycache__/ +*.py[cod] +build/ +dist/ +assets/LinkBridge.iconset/ +assets/icon-1024.png diff --git a/README.md b/README.md index 0457f58..cbb3d5a 100644 --- a/README.md +++ b/README.md @@ -1,15 +1,261 @@ -# Prerequisits -- Python -- GCC -- ALSA -- ALSA dev libraries (`libasound2-dev`) -- ALSA tools (for using `aconnect` that is part of `alsa-utils` package) - -# Install -1. Copy the `c_lib.c` and `clock.py` -2. Compile `c_lib.c` to be used by `clock.py` with the following command:
-`gcc -O3 -fPIC -shared -o liblinkbridge.so midi_clock_lib.c -lasound` -3. Run the `clock.py`:
-`python3 clock.py` -4. Route the MIDI channel using `aconnect`:
-To list options `aconnect -l` then from the list type the `aconnect ` for example: `aconnect 128 130` \ No newline at end of file +# LinkBridge + +A tiny macOS menu bar app that forwards Ableton Link tempo to a hardware MIDI +output. Drop it next to a Link-enabled DJ app or DAW and any MIDI gear that +syncs to incoming MIDI clock will follow the tempo in real time. + +``` + ┌──────────────┐ Link ┌──────────────┐ MIDI clock ┌──────────────┐ + │ djay Pro / │ ─────────▶ │ LinkBridge │ ───────────────▶ │ Hardware │ + │ Live / etc. │ │ (menu bar) │ (CoreMIDI) │ MIDI device │ + └──────────────┘ └──────────────┘ └──────────────┘ +``` + +## Requirements + +- macOS 15 (Sequoia) or later, Apple Silicon (tested on M2 Pro) +- Python 3.11 or 3.12 (only needed for running from source / building the .app) +- A USB-connected MIDI device with external clock support +- An Ableton Link source on the same machine or LAN + +## Quick start (from source) + +```bash +python3.11 -m venv venv +source venv/bin/activate +pip install -r requirements.txt +python -m linkbridge +``` + +A `♪ ` icon appears in the macOS menu bar within a few seconds. Click +it, choose your MIDI output device from the **Output Device** submenu, and +the clock starts streaming as soon as a Link peer reports a tempo. + +## Build a standalone `.app` + +```bash +source venv/bin/activate +pip install -r requirements-dev.txt +./scripts/build_app.sh +``` + +The bundle is produced at `dist/LinkBridge.app`. Drag it to `/Applications` +or run it in place. + +**On first launch:** + +- macOS asks for **local network permission** ("Allow LinkBridge to find + devices on local networks?"). Click **Allow**, otherwise Ableton Link + cannot discover any peers and the tempo stays at the default 120 BPM + forever. The bundle includes a description string explaining what the + permission is used for. +- The bundle is **unsigned** (no Apple Developer ID). If macOS Gatekeeper + blocks it, right-click the `.app` in Finder, choose **Open**, confirm + the warning, then launch normally afterwards. + +## Menu reference + +``` +♪ 125.0 +──────────── +Output Device ▸ ● Circuit Tracks MIDI + ○ DDJ-FLX4 + ──── + ↻ Refresh devices +Enable Start/Stop events ☐ +──────────── +Quit +``` + +| Item | Behavior | +|---|---| +| `♪ ` | Live tempo from the active Link peer. Shows `♪ --` when no MIDI device is selected, `♪ ERR` if the clock thread crashed. | +| **Output Device** | All available CoreMIDI outputs. Pick one to route clock to it. The choice is remembered for next launch. `↻ Refresh devices` rebuilds the list — useful after plugging in a new USB device. | +| **Enable Start/Stop events** | When ON, sends MIDI `START` on Link play and MIDI `STOP` on Link stop. Default OFF. The clock tick stream itself is **always** running once a device is selected, regardless of this toggle. | +| **Quit** | Sends a final MIDI STOP if needed, closes the port, exits cleanly. | + +## Compatibility — which DJ software works? + +LinkBridge passes through whatever Ableton Link tempo it can see. The +catch is that **not every DJ app broadcasts its deck tempo to Link** — most +of them only listen. + +### "Just works" — fully automatic + +These apps act as Link tempo masters when a deck is playing, so LinkBridge +picks up the tempo automatically with zero manual steps: + +- **djay Pro for Mac** (Algoriddim) — officially supports the DDJ-FLX4 and + many other Pioneer / Numark / Reloop controllers; the playing deck + becomes the Link master automatically. +- **Mixxx 2.5+** — open source, free, ships with a community DDJ-FLX4 + mapping and bidirectional Link support. +- **Ableton Live**, **Logic Pro** with Link, **GarageBand**, and any other + Link-aware DAW — playing the timeline broadcasts tempo to Link. + +### Works with a small manual step — Rekordbox + +**Rekordbox 7.x has a hard one-way Link integration:** the LINK button on +each deck makes that deck *follow* Link, but Rekordbox does **not** +broadcast the playing deck's tempo back to Link. This is a Rekordbox +limitation, not a LinkBridge bug. See Pioneer's +[Ableton Link FAQ](https://rekordbox.com/en/support/faq/ableton-link/) and +this [community feature request](https://forums.pioneerdj.com/hc/en-us/community/posts/900002865663-Ableton-Link-in-Rekordbox-Suggestion-Follow-the-Master-Deck-BPM-option). + +To use LinkBridge with Rekordbox, drive the Link tempo manually from +Rekordbox's Ableton Link sub-window: + +1. Open Rekordbox and open its **Ableton Link** sub-window (the small + window with the BPM display, `TAP`, `+`, `-` buttons). +2. Read the BPM of the track currently loaded on your master deck. +3. Type or click that BPM into the Link sub-window using the `+`/`-` + buttons (or use `TAP` to find it by ear). +4. LinkBridge picks it up immediately and your hardware follows. +5. When you change tracks, repeat — Rekordbox does not auto-update Link. + +> **Note about the tempo slider:** When a hardware controller like the +> DDJ-FLX4 is connected, Rekordbox disables the on-screen TEMPO slider for +> Link control entirely. The Link sub-window's `+`/`-` buttons (or MIDI- +> mapped equivalents) are the only way to drive Link tempo from a +> controller-attached Rekordbox session. + +If the manual workflow is too tedious for your set, the +[`rkbx_link`](https://github.com/grufkork/rkbx_link) project reads +Rekordbox's process memory directly and pushes the master deck's tempo +into Link automatically. It requires re-signing Rekordbox to add the +`get-task-allow` entitlement and running with `sudo` — see its +`MACOS_SETUP.md` for details. LinkBridge does not currently bundle this +behaviour but composes cleanly with `rkbx_link` if you run them +side-by-side. + +## Using with Novation Circuit Tracks + +The Circuit Tracks needs one Setup-view setting before it accepts external +clock — this is a one-time change that persists across reboots. + +1. On the Circuit Tracks, hold **Shift** and press **Save** to enter + Setup view. +2. On the bottom row of pads find the four "MIDI data control" Rx/Tx + pads. The rightmost pair is **MIDI Clock Rx/Tx**. Make sure **Clock + Rx** is lit (factory default is OFF). +3. Press **Play** on the Circuit Tracks to exit Setup view. + +After that: + +- Launch LinkBridge and pick **Circuit Tracks MIDI** from the Output + Device submenu. +- If the Circuit Tracks is **stopped** when LinkBridge starts streaming + clock, it instantly enters external sync mode — the Tempo/Swing view + shows `SYN` in red. Press Play and the pattern follows the incoming + tempo. +- If the Circuit Tracks was **already playing an internal pattern** when + LinkBridge connected, it ignores the incoming clock and keeps playing + at its internal tempo. **Recovery is one button press:** tap **Stop** + on the Circuit Tracks; `SYN` appears immediately because the clock + stream was already flowing. Press Play again and you're locked. +- When LinkBridge quits, the clock stream stops, `SYN` disappears, and + the Circuit Tracks reverts to internal clock — any pattern that was + playing via SYN halts. + +This behaviour is the Circuit Tracks's, not LinkBridge's. Most other +class-compliant USB MIDI gear follows incoming clock without any +external-sync arming step. + +## Files and logs + +| Path | Purpose | +|---|---| +| `~/Library/Application Support/LinkBridge/settings.json` | Last selected device and Start/Stop toggle state | +| `~/Library/Logs/LinkBridge/linkbridge.log` | Rotating log file (1 MB × 3) | + +Set `LINKBRIDGE_DEBUG=1` in the environment for verbose logging: + +```bash +LINKBRIDGE_DEBUG=1 python -m linkbridge +``` + +## Architecture + +A single Python process with three threads sharing a lock-guarded +`ClockState` dataclass: + +| Thread | Module | Job | +|---|---|---| +| Main | `linkbridge/app.py` | rumps menu bar UI, 500 ms label refresh | +| Clock | `linkbridge/clock_engine.py` | 24 ppqn MIDI clock generator with drift compensation | +| Link | `linkbridge/link_monitor.py` | aalink callback loop pushing tempo + transport into ClockState | + +The Settings store and the MidiOutput helpers (`linkbridge/settings.py`, +`linkbridge/midi_output.py`) are stateless utilities used by the threads +above. + +## Tests + +```bash +source venv/bin/activate +pytest +``` + +22 unit tests cover Settings, MidiOutput, and ClockEngine using fake +clocks / fake MIDI sinks — no hardware or Link peer required. The Link +monitor and the menu bar app are validated by manual smoke tests against +real hardware (the smoke procedure is documented in the project plan). + +## Releasing a new version + +Releases are cut by a single click in the **GitHub Actions** tab — no local +commands or git tagging required. + +1. Go to https://github.com/AnyByte/LinkBridge/actions/workflows/release.yml +2. Click **Run workflow** +3. Type the new version (e.g. `2.1.0`, no `v` prefix) +4. Click **Run workflow** + +The CI will then: + +- Bump the version strings in `linkbridge/__init__.py` and `setup.py` +- Commit and tag the bump on `main` (`v2.1.0`) +- Run the test suite +- Build `LinkBridge.app` and zip it via `ditto` +- Create a **draft** GitHub Release with auto-generated notes (built from + the conventional-commit history since the previous tag) and the + `LinkBridge-v2.1.0.zip` attached + +Find the draft release on the +[Releases page](https://github.com/AnyByte/LinkBridge/releases), edit the +notes if you want, then click **Publish release**. + +You can also trigger the workflow from a terminal: + +```bash +gh workflow run release.yml -f version=2.1.0 +gh run watch # follow the build +``` + +The release workflow uses [`bump-my-version`](https://github.com/callowayproject/bump-my-version) +under the hood; the file list is in `pyproject.toml`. + +## Regenerating the app icon + +The icon source is `assets/icon.png` (1024×1024). To rebuild the +`assets/LinkBridge.icns` file from a fresh source PNG: + +```bash +.venv-probe/bin/pip install Pillow # or any venv with Pillow available +python3 scripts/build_icon.py +``` + +The script flood-fills the white background to transparent, crops to the +non-transparent bbox, generates all 10 macOS iconset sizes, and runs +`iconutil` to produce the final `.icns`. + +## Legacy Linux implementation + +The original ALSA / C Linux implementation lives in `legacy/` for +reference and is not maintained. If you want to run that on Linux, the +build commands are at the top of `legacy/midi_clock_lib.c` — but the +macOS port in this branch is the only thing actively developed. + +## License + +See `LICENSE`. diff --git a/assets/LinkBridge.icns b/assets/LinkBridge.icns new file mode 100644 index 0000000..579b13a Binary files /dev/null and b/assets/LinkBridge.icns differ diff --git a/assets/icon-transparent.png b/assets/icon-transparent.png new file mode 100644 index 0000000..e3b4af8 Binary files /dev/null and b/assets/icon-transparent.png differ diff --git a/assets/icon.png b/assets/icon.png new file mode 100644 index 0000000..c2058c6 Binary files /dev/null and b/assets/icon.png differ diff --git a/LinkBridge.service b/legacy/LinkBridge.service similarity index 100% rename from LinkBridge.service rename to legacy/LinkBridge.service diff --git a/clock.py b/legacy/clock.py similarity index 100% rename from clock.py rename to legacy/clock.py diff --git a/debugAutomaticClock.py b/legacy/debugAutomaticClock.py similarity index 100% rename from debugAutomaticClock.py rename to legacy/debugAutomaticClock.py diff --git a/midi_clock_lib.c b/legacy/midi_clock_lib.c similarity index 100% rename from midi_clock_lib.c rename to legacy/midi_clock_lib.c diff --git a/test_midi_sender.c b/legacy/test_midi_sender.c similarity index 100% rename from test_midi_sender.c rename to legacy/test_midi_sender.c diff --git a/test_monitor.c b/legacy/test_monitor.c similarity index 100% rename from test_monitor.c rename to legacy/test_monitor.c diff --git a/linkbridge/__init__.py b/linkbridge/__init__.py new file mode 100644 index 0000000..20cd234 --- /dev/null +++ b/linkbridge/__init__.py @@ -0,0 +1,3 @@ +"""LinkBridge — Ableton Link to MIDI clock bridge for macOS.""" + +__version__ = "2.0.0" diff --git a/linkbridge/__main__.py b/linkbridge/__main__.py new file mode 100644 index 0000000..8e53aef --- /dev/null +++ b/linkbridge/__main__.py @@ -0,0 +1,124 @@ +"""Entry point for `python -m linkbridge`. Wires modules together and starts the app.""" + +from __future__ import annotations + +import logging +import logging.handlers +import os +import sys +import threading +from pathlib import Path + +import mido + +from linkbridge import midi_output +from linkbridge.app import MenuBarApp +from linkbridge.clock_engine import ClockEngine, ClockState +from linkbridge.link_monitor import LinkMonitor +from linkbridge.settings import Settings + +LOG_DIR = Path.home() / "Library" / "Logs" / "LinkBridge" +LOG_FILE = LOG_DIR / "linkbridge.log" + + +def _configure_logging() -> None: + LOG_DIR.mkdir(parents=True, exist_ok=True) + level = logging.DEBUG if os.environ.get("LINKBRIDGE_DEBUG") == "1" else logging.INFO + root = logging.getLogger() + root.setLevel(level) + + # Rotating file handler + file_handler = logging.handlers.RotatingFileHandler( + LOG_FILE, maxBytes=1 * 1024 * 1024, backupCount=3 + ) + file_handler.setFormatter( + logging.Formatter("%(asctime)s %(levelname)-7s %(name)s: %(message)s") + ) + root.addHandler(file_handler) + + # Console handler — useful when running from source + console_handler = logging.StreamHandler(sys.stderr) + console_handler.setFormatter(logging.Formatter("[%(levelname)s] %(name)s: %(message)s")) + root.addHandler(console_handler) + + +def _resolve_initial_device(settings: Settings) -> mido.ports.BaseOutput | None: + """Return an opened mido output port, or None if nothing is available.""" + log = logging.getLogger(__name__) + try: + available = midi_output.list_outputs() + except Exception as e: + log.error("listing MIDI outputs failed at startup: %s", e) + return None + + if not available: + log.warning("no MIDI outputs available at startup") + return None + + if settings.last_device and settings.last_device in available: + try: + return midi_output.open_output(settings.last_device) + except Exception as e: + log.warning( + "failed to reopen last device '%s', falling back: %s", + settings.last_device, + e, + ) + + fallback = available[0] + if settings.last_device: + log.info("last device unavailable, opening: %s", fallback) + else: + log.info("no last device in settings, opening: %s", fallback) + try: + return midi_output.open_output(fallback) + except Exception as e: + log.error("failed to open fallback device '%s': %s", fallback, e) + return None + + +def main() -> int: + _configure_logging() + log = logging.getLogger(__name__) + log.info("starting LinkBridge") + + settings = Settings.load() + initial_port = _resolve_initial_device(settings) + + state = ClockState(lock=threading.Lock()) + state.start_stop_enabled = settings.start_stop_enabled + state.midi_out = initial_port + + clock_engine = ClockEngine(state) + link_monitor = LinkMonitor(state, initial_bpm=state.bpm) + + clock_engine.start() + link_monitor.start() + + app = MenuBarApp( + state=state, + settings=settings, + clock_engine=clock_engine, + link_monitor=link_monitor, + ) + try: + app.run() + finally: + # Normal exit goes through MenuBarApp._on_quit_clicked which already + # called these. Both stop() methods are idempotent so calling them + # again on a clean shutdown is safe; the try/except guards an + # unexpected exit from app.run(). + try: + link_monitor.stop() + except Exception as e: + log.warning("teardown: link monitor stop failed: %s", e) + try: + clock_engine.stop() + except Exception as e: + log.warning("teardown: clock engine stop failed: %s", e) + log.info("LinkBridge exited") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/linkbridge/app.py b/linkbridge/app.py new file mode 100644 index 0000000..f72bbf8 --- /dev/null +++ b/linkbridge/app.py @@ -0,0 +1,162 @@ +"""Menu bar app built on rumps. Runs on the main thread.""" + +from __future__ import annotations + +import logging + +import rumps + +from linkbridge import midi_output +from linkbridge.clock_engine import ClockEngine, ClockState +from linkbridge.link_monitor import LinkMonitor +from linkbridge.settings import Settings + +log = logging.getLogger(__name__) + +MENU_DEVICE_HEADER = "Output Device" +MENU_TOGGLE_TITLE = "Enable Start/Stop events" +MENU_REFRESH_TITLE = "↻ Refresh devices" +MENU_NO_DEVICE = "(no MIDI output)" +LABEL_NO_DEVICE = "♪ --" +LABEL_CRASHED = "♪ ERR" + + +def _format_bpm(bpm: float) -> str: + return f"♪ {bpm:.1f}" + + +class MenuBarApp(rumps.App): + def __init__( + self, + state: ClockState, + settings: Settings, + clock_engine: ClockEngine, + link_monitor: LinkMonitor, + ) -> None: + super().__init__("LinkBridge", title=_format_bpm(state.bpm), quit_button=None) + self.state = state + self.settings = settings + self.clock_engine = clock_engine + self.link_monitor = link_monitor + + self._device_menu = rumps.MenuItem(MENU_DEVICE_HEADER) + self._toggle_item = rumps.MenuItem( + MENU_TOGGLE_TITLE, callback=self._on_toggle_clicked + ) + self._toggle_item.state = 1 if state.start_stop_enabled else 0 + self._quit_item = rumps.MenuItem("Quit", callback=self._on_quit_clicked) + + self.menu = [ + self._device_menu, + self._toggle_item, + None, # separator + self._quit_item, + ] + self._rebuild_device_menu() + + self._refresh_timer = rumps.Timer(self._on_timer_tick, 0.5) + self._refresh_timer.start() + + # ----- menu building ----- + + def _rebuild_device_menu(self) -> None: + try: + outputs = midi_output.list_outputs() + except Exception as e: + log.error("list_outputs failed: %s", e) + outputs = [] + + with self.state.lock: + current_port = self.state.midi_out + current_name = getattr(current_port, "name", None) if current_port else None + + # rumps creates the underlying NSMenu lazily on first add(); only + # call clear() once it exists, otherwise removeAllItems() crashes. + if self._device_menu._menu is not None: + self._device_menu.clear() + if not outputs: + self._device_menu.add(rumps.MenuItem(MENU_NO_DEVICE)) + else: + for name in outputs: + item = rumps.MenuItem( + name, callback=self._make_device_callback(name) + ) + item.state = 1 if name == current_name else 0 + self._device_menu.add(item) + self._device_menu.add(None) + self._device_menu.add( + rumps.MenuItem(MENU_REFRESH_TITLE, callback=self._on_refresh_clicked) + ) + + def _make_device_callback(self, name: str): + def cb(_sender): + self._on_device_selected(name) + return cb + + # ----- callbacks ----- + + def _on_device_selected(self, name: str) -> None: + log.info("user picked device: %s", name) + try: + new_port = midi_output.open_output(name) + except Exception as e: + log.error("failed to open %s: %s", name, e) + rumps.alert("LinkBridge", f"Could not open '{name}':\n{e}") + return + + with self.state.lock: + old_port = self.state.midi_out + self.state.midi_out = new_port + if old_port is not None: + try: + old_port.close() + except Exception as e: + log.warning("closing old port failed: %s", e) + + self.settings.last_device = name + try: + self.settings.save() + except Exception as e: + log.warning("settings save failed: %s", e) + + self._rebuild_device_menu() + + def _on_refresh_clicked(self, _sender) -> None: + log.info("refresh requested") + self._rebuild_device_menu() + + def _on_toggle_clicked(self, sender) -> None: + new_value = not bool(sender.state) + with self.state.lock: + self.state.start_stop_enabled = new_value + sender.state = 1 if new_value else 0 + self.settings.start_stop_enabled = new_value + try: + self.settings.save() + except Exception as e: + log.warning("settings save failed: %s", e) + log.info("start/stop toggle -> %s", "ON" if new_value else "OFF") + + def _on_quit_clicked(self, _sender) -> None: + log.info("quit clicked — shutting down") + try: + self.link_monitor.stop() + except Exception as e: + log.warning("link monitor stop failed: %s", e) + try: + self.clock_engine.stop() + except Exception as e: + log.warning("clock engine stop failed: %s", e) + rumps.quit_application() + + def _on_timer_tick(self, _timer) -> None: + with self.state.lock: + bpm = self.state.bpm + has_port = self.state.midi_out is not None + crashed = self.state.clock_crashed + if crashed: + self.title = LABEL_CRASHED + elif not has_port: + self.title = LABEL_NO_DEVICE + else: + self.title = _format_bpm(bpm) diff --git a/linkbridge/clock_engine.py b/linkbridge/clock_engine.py new file mode 100644 index 0000000..ac32125 --- /dev/null +++ b/linkbridge/clock_engine.py @@ -0,0 +1,152 @@ +"""Shared clock state + the 24 ppqn MIDI clock tick generator.""" + +from __future__ import annotations + +import logging +import threading +import time +from dataclasses import dataclass, field + +import mido + +log = logging.getLogger(__name__) + +PPQN = 24 # MIDI clock pulses per quarter note (fixed by MIDI spec) + + +def _interval_for_bpm(bpm: float) -> float: + """Return seconds-per-tick for a given BPM at 24 ppqn.""" + return 1.0 / (bpm / 60.0 * PPQN) + + +@dataclass +class ClockState: + lock: threading.Lock + bpm: float = 120.0 + tick_interval: float = field(default_factory=lambda: _interval_for_bpm(120.0)) + is_playing: bool = False + start_stop_enabled: bool = False + midi_out: mido.ports.BaseOutput | None = None + clock_crashed: bool = False + + def set_bpm(self, bpm: float) -> None: + """Update bpm and recompute tick_interval. Caller must hold the lock.""" + if bpm <= 0.0: + raise ValueError(f"BPM must be positive, got {bpm!r}") + self.bpm = bpm + self.tick_interval = _interval_for_bpm(bpm) + + +class ClockEngine: + """Runs a 24 ppqn MIDI clock tick loop on a dedicated thread. + + Tests can drive `_tick_once()` directly with a FakeClock / FakeSleeper / + FakeSink — no real threads or real time involved. + """ + + def __init__( + self, + state: ClockState, + clock=None, + sleeper=None, + ) -> None: + self.state = state + self._clock = clock or time.monotonic + self._sleeper = sleeper or time.sleep + self._running = threading.Event() + self._thread: threading.Thread | None = None + self._next_tick_time: float = 0.0 + self._prev_is_playing: bool = False + + # ----- test-facing single-iteration method ----- + + def _tick_once(self) -> None: + # Snapshot state under the lock — keep the critical section tiny. + with self.state.lock: + interval = self.state.tick_interval + is_playing = self.state.is_playing + start_stop_enabled = self.state.start_stop_enabled + port = self.state.midi_out + + # Detect transport transition (runs regardless of toggle — we still + # need to update _prev_is_playing so we don't fire a stale edge later). + transport_edge = is_playing != self._prev_is_playing + if transport_edge: + self._prev_is_playing = is_playing + if start_stop_enabled and port is not None: + msg_type = "start" if is_playing else "stop" + try: + port.send(mido.Message(msg_type)) + log.info("sent MIDI %s", msg_type.upper()) + except Exception as e: + log.warning("transport send failed, dropping device: %s", e) + with self.state.lock: + if self.state.midi_out is port: + self.state.midi_out = None + port = None # skip the clock tick below + + # Clock tick (always emitted when a port is still set) + if port is not None: + try: + port.send(mido.Message("clock")) + except Exception as e: + log.warning("clock send failed, dropping device: %s", e) + with self.state.lock: + if self.state.midi_out is port: + self.state.midi_out = None + + # Timing bookkeeping + self._next_tick_time += interval + sleep_time = self._next_tick_time - self._clock() + if sleep_time > 0: + self._sleeper(sleep_time) + elif sleep_time < -interval: + self._next_tick_time = self._clock() + + # ----- thread lifecycle ----- + + def start(self) -> None: + if self._thread is not None and self._thread.is_alive(): + return + self._running.set() + self._next_tick_time = self._clock() + self._prev_is_playing = False + self._thread = threading.Thread( + target=self._run, name="LinkBridge-Clock", daemon=True + ) + self._thread.start() + log.info("clock thread started") + + def stop(self) -> None: + self._running.clear() + if self._thread is not None: + self._thread.join(timeout=2.0) + # Capture port reference and null state.midi_out atomically under one + # lock, so a menu thread can't swap a new port in between read and null. + with self.state.lock: + port = self.state.midi_out + should_send_stop = ( + self.state.start_stop_enabled and self.state.is_playing and port is not None + ) + self.state.midi_out = None + if should_send_stop: + try: + port.send(mido.Message("stop")) + log.info("sent final MIDI STOP on shutdown") + except Exception as e: + log.warning("final stop send failed: %s", e) + if port is not None: + try: + port.close() + except Exception as e: + log.warning("port close failed: %s", e) + log.info("clock thread stopped") + + def _run(self) -> None: + try: + while self._running.is_set(): + self._tick_once() + except Exception: + log.exception("clock thread crashed") + with self.state.lock: + self.state.clock_crashed = True diff --git a/linkbridge/link_monitor.py b/linkbridge/link_monitor.py new file mode 100644 index 0000000..e5e47cc --- /dev/null +++ b/linkbridge/link_monitor.py @@ -0,0 +1,124 @@ +"""Ableton Link monitor thread backed by aalink. + +Runs an asyncio loop in a dedicated daemon thread. Registers reactive +callbacks on the Link object so tempo and transport updates flow into +`ClockState` without any polling. +""" + +from __future__ import annotations + +import asyncio +import logging +import threading + +from aalink import Link + +from linkbridge.clock_engine import ClockState + +log = logging.getLogger(__name__) + +START_TIMEOUT_SECONDS = 5.0 + + +class LinkMonitor: + def __init__(self, state: ClockState, initial_bpm: float = 120.0, quantum: float = 4.0) -> None: + self.state = state + self.initial_bpm = initial_bpm + self.quantum = quantum + self._loop: asyncio.AbstractEventLoop | None = None + self._thread: threading.Thread | None = None + self._link: Link | None = None + self._ready = threading.Event() + + def start(self) -> None: + if self._thread is not None and self._thread.is_alive(): + return + # Reset readiness so a previous lifecycle's signal can't satisfy this start. + self._ready.clear() + self._thread = threading.Thread( + target=self._run, name="LinkBridge-Link", daemon=True + ) + self._thread.start() + if not self._ready.wait(timeout=START_TIMEOUT_SECONDS): + log.error( + "link monitor did not become ready within %.1f s — proceeding anyway", + START_TIMEOUT_SECONDS, + ) + else: + log.info("link monitor started") + + def stop(self) -> None: + loop = self._loop + if loop is None: + return + # Disable link then stop the loop from within itself. + def _shutdown() -> None: + try: + if self._link is not None: + self._link.enabled = False + except Exception as e: + log.warning("link disable failed: %s", e) + loop.stop() + try: + loop.call_soon_threadsafe(_shutdown) + except RuntimeError as e: + # Loop is already closed (e.g. stop() called twice). Nothing to do. + log.debug("stop() called on closed loop: %s", e) + if self._thread is not None: + self._thread.join(timeout=2.0) + # Clear references so a subsequent start() can run cleanly. + self._loop = None + self._thread = None + self._link = None + log.info("link monitor stopped") + + # ----- internals ----- + + def _run(self) -> None: + try: + self._loop = asyncio.new_event_loop() + asyncio.set_event_loop(self._loop) + self._loop.run_until_complete(self._async_setup()) + self._loop.run_forever() + except Exception: + log.exception("link thread crashed") + self._ready.set() + finally: + try: + if self._loop is not None and not self._loop.is_closed(): + self._loop.close() + except Exception: + pass + log.info("link thread exited") + + async def _async_setup(self) -> None: + self._link = Link(self.initial_bpm) + self._link.quantum = self.quantum + self._link.start_stop_sync_enabled = True + self._link.set_tempo_callback(self._on_tempo) + self._link.set_start_stop_callback(self._on_transport) + self._link.enabled = True + self._ready.set() + + def _on_tempo(self, bpm: float) -> None: + # Guard against non-positive BPM — set_bpm raises on <= 0, and we must + # never let exceptions escape back into aalink's C++ callback dispatcher. + if bpm <= 0.0: + log.warning("link delivered non-positive BPM %.4f, ignoring", bpm) + return + try: + with self.state.lock: + self.state.set_bpm(float(bpm)) + except Exception as e: + log.warning("tempo callback failed: %s", e) + return + log.debug("link tempo -> %.2f", bpm) + + def _on_transport(self, playing: bool) -> None: + try: + with self.state.lock: + self.state.is_playing = bool(playing) + except Exception as e: + log.warning("transport callback failed: %s", e) + return + log.info("link transport -> %s", "PLAY" if playing else "STOP") diff --git a/linkbridge/midi_output.py b/linkbridge/midi_output.py new file mode 100644 index 0000000..35e7064 --- /dev/null +++ b/linkbridge/midi_output.py @@ -0,0 +1,21 @@ +"""Thin wrappers around mido's CoreMIDI output port APIs.""" + +from __future__ import annotations + +import logging + +import mido + +log = logging.getLogger(__name__) + + +def list_outputs() -> list[str]: + """Return the list of available CoreMIDI output port names.""" + return list(mido.get_output_names()) + + +def open_output(name: str) -> mido.ports.BaseOutput: + """Open and return a mido output port by name. Raises on failure.""" + port = mido.open_output(name) + log.info("opened MIDI output: %s", name) + return port diff --git a/linkbridge/settings.py b/linkbridge/settings.py new file mode 100644 index 0000000..f60d200 --- /dev/null +++ b/linkbridge/settings.py @@ -0,0 +1,52 @@ +"""Persistent settings store backed by a JSON file under ~/Library/Application Support/LinkBridge/.""" + +from __future__ import annotations + +import json +import logging +import time +from dataclasses import dataclass +from pathlib import Path + +log = logging.getLogger(__name__) + +DEFAULT_SETTINGS_PATH = ( + Path.home() / "Library" / "Application Support" / "LinkBridge" / "settings.json" +) + + +@dataclass +class Settings: + last_device: str | None = None + start_stop_enabled: bool = False + + @classmethod + def load(cls, path: Path = DEFAULT_SETTINGS_PATH) -> "Settings": + if not path.exists(): + return cls() + try: + data = json.loads(path.read_text(encoding="utf-8")) + if not isinstance(data, dict): + raise ValueError(f"expected JSON object, got {type(data).__name__}") + raw_last_device = data.get("last_device") + last_device = raw_last_device if isinstance(raw_last_device, str) else None + return cls( + last_device=last_device, + start_stop_enabled=bool(data.get("start_stop_enabled", False)), + ) + except (json.JSONDecodeError, OSError, ValueError) as e: + corrupt_path = path.with_suffix(path.suffix + f".corrupt-{int(time.time())}") + try: + path.rename(corrupt_path) + log.warning("settings file corrupt, renamed to %s: %s", corrupt_path, e) + except OSError as rename_err: + log.error("settings file corrupt and rename failed: %s", rename_err) + return cls() + + def save(self, path: Path = DEFAULT_SETTINGS_PATH) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + data = { + "last_device": self.last_device, + "start_stop_enabled": self.start_stop_enabled, + } + path.write_text(json.dumps(data, indent=2), encoding="utf-8") diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..6de7cad --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,25 @@ +# Project metadata used only by tooling — LinkBridge itself is built via setup.py + py2app. + +[tool.bumpversion] +current_version = "2.0.0" +allow_dirty = true +commit = false +tag = false +sign_tags = false +parse = '(?P\d+)\.(?P\d+)\.(?P\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