From 21b9ae6091698cd576f3f81d0ee2508ffc7d1b2e Mon Sep 17 00:00:00 2001 From: adria Date: Tue, 26 May 2026 12:12:36 +0200 Subject: [PATCH 1/3] docs(mkdocs): setup material mkdocs as site generation framework --- .github/workflows/docs.yml | 35 +- .github/workflows/tests.yml | 72 +++ CHANGELOG.md | 1 + CONTRIBUTORS.md | 417 +++++++++++++++ README.md | 247 +++++++-- docs/api.md | 415 +++++++++++++++ docs/architecture.md | 181 +++++++ docs/index.md | 224 ++++++++ mkdocs.yml | 27 + specs/planning/documentation-prompt.md | 679 +++++++++++++++++++++++++ 10 files changed, 2229 insertions(+), 69 deletions(-) create mode 100644 .github/workflows/tests.yml create mode 100644 CONTRIBUTORS.md create mode 100644 docs/api.md create mode 100644 docs/architecture.md create mode 100644 docs/index.md create mode 100644 mkdocs.yml create mode 100644 specs/planning/documentation-prompt.md diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index b8a68e4..57018d6 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -3,9 +3,9 @@ # SPDX-License-Identifier: GPL-3.0-or-later # *** -# Build Doxygen HTML and deploy to GitHub Pages when `main` is updated. +# Build MkDocs site and deploy to GitHub Pages when `main` is updated. # One-time: repo Settings → Pages → Build and deployment → Source: GitHub Actions. -name: Deploy API documentation +name: Deploy documentation on: push: @@ -27,30 +27,27 @@ jobs: steps: - name: Checkout uses: actions/checkout@v4 + with: + submodules: true + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.11' - - name: Install dependencies - run: | - sudo apt-get update - sudo apt-get install -y --no-install-recommends \ - cmake \ - doxygen \ - g++ \ - graphviz \ - liblo-dev \ - nlohmann-json3-dev \ - libtinyxml2-dev \ - pkg-config + - name: Install system dependencies + run: sudo apt-get update && sudo apt-get install -y doxygen - - name: Configure (library + docs only) - run: cmake -B build -DBUILD_DAEMON=OFF + - name: Install Python dependencies + run: pip install mkdocs==1.6.1 mkdocs-material mkdoxy - - name: Generate Doxygen - run: cmake --build build --target docs + - name: Build site + run: mkdocs build - name: Upload Pages artifact uses: actions/upload-pages-artifact@v3 with: - path: build/docs/html + path: site deploy: needs: build diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 0000000..a62bfed --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,72 @@ +# *** +# SPDX-FileCopyrightText: 2026 Stagelab Coop SCCL +# SPDX-License-Identifier: GPL-3.0-or-later +# *** + +name: Tests + +on: + push: + branches: + - main + pull_request: + +permissions: + contents: read + +jobs: + test: + runs-on: ubuntu-latest + + steps: + - name: Checkout (with submodules) + uses: actions/checkout@v4 + with: + submodules: recursive + + - name: Install build dependencies + run: | + sudo apt-get update + sudo apt-get install -y --no-install-recommends \ + build-essential \ + cmake \ + pkg-config \ + librtmidi-dev \ + liblo-dev \ + nlohmann-json3-dev \ + libtinyxml2-dev \ + lcov + + - name: Configure with coverage + run: | + cmake -B build \ + -DCMAKE_BUILD_TYPE=Debug \ + -DCMAKE_CXX_FLAGS="--coverage" \ + -DCMAKE_EXE_LINKER_FLAGS="--coverage" + + - name: Build + run: cmake --build build -j$(nproc) + + - name: Run tests + run: ctest --test-dir build --output-on-failure + + - name: Generate coverage report + run: | + lcov --capture --directory build \ + --output-file coverage.info \ + --ignore-errors inconsistent + lcov --remove coverage.info \ + '/usr/*' \ + '*/tests/*' \ + '*/mtcreceiver/*' \ + '*/cuemslogger/*' \ + --output-file coverage.info \ + --ignore-errors inconsistent + + - name: Upload coverage to Codecov + uses: codecov/codecov-action@v4 + with: + files: coverage.info + fail_ci_if_error: false + env: + CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} diff --git a/CHANGELOG.md b/CHANGELOG.md index 94e0987..55b95c1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -63,4 +63,5 @@ First public release: timecode-driven motion and gradient evaluation with OSC ou * **Fade / motion path**: motion registry, fade motion implementation, and tick-aligned evaluation loop aligned with the fade-registry feature set. * **Tests**: unit and integration tests for curves, MTC, NNG, lock-free queue, fade/motion registry, and related behaviour. +[0.3.0]: https://github.com/stagesoft/gradient-motion-engine/releases/tag/v0.3.0 [0.1.0]: https://github.com/stagesoft/gradient-motion-engine/releases/tag/v0.1.0 diff --git a/CONTRIBUTORS.md b/CONTRIBUTORS.md new file mode 100644 index 0000000..a2f52f7 --- /dev/null +++ b/CONTRIBUTORS.md @@ -0,0 +1,417 @@ + + +# Contributing to gradient-motion-engine + +Thank you for your interest in `gradient-motion-engine`. This daemon runs on every +CueMS player node and drives fades against MIDI Time Code in real time — a regression +here causes audible / visible artefacts at show time. These guidelines exist to +protect that reliability while keeping the project open to external contributions. + +The authoritative governance document for the rules summarised here is +[`.specify/memory/constitution.md`](.specify/memory/constitution.md). +If this file and the constitution conflict, the constitution wins. + +--- + +## Table of Contents + +1. [Prerequisites](#1-prerequisites) +2. [Development Setup](#2-development-setup) +3. [Contribution Tiers](#3-contribution-tiers) +4. [Branch Naming](#4-branch-naming) +5. [Spec-First Requirement](#5-spec-first-requirement) +6. [TDD Workflow — Non-Negotiable](#6-tdd-workflow--non-negotiable) +7. [Commit Hygiene](#7-commit-hygiene) +8. [Developer Certificate of Origin (DCO)](#8-developer-certificate-of-origin-dco) +9. [Pull Request Requirements](#9-pull-request-requirements) +10. [Acceptance Criteria](#10-acceptance-criteria) +11. [Review Process](#11-review-process) +12. [Changelog Line](#12-changelog-line) +13. [Dependency Governance](#13-dependency-governance) +14. [License](#14-license) + +--- + +## 1. Prerequisites + +| Tool | Version | Notes | +|---|---|---| +| C++ compiler | g++ ≥ 12 (C++17) | clang++ ≥ 14 also supported | +| CMake | ≥ 3.16 | newer is fine | +| pkg-config | any recent | required by the CMake config | +| Git | any recent | DCO sign-off required (see §8) | +| Doxygen + Graphviz | optional | only needed to build the HTML reference | +| lcov / gcov | optional | only needed to produce coverage locally | + +System packages (Debian/Ubuntu): + +```bash +sudo apt-get install -y \ + build-essential cmake pkg-config \ + librtmidi-dev liblo-dev nlohmann-json3-dev libtinyxml2-dev \ + doxygen graphviz lcov +``` + +The submodules are not optional for the daemon build (`mtcreceiver`, `cuemslogger`). +Either clone with `--recursive` or run `git submodule update --init --recursive` +after a normal clone. + +--- + +## 2. Development Setup + +```bash +# Clone with submodules +git clone --recursive https://github.com/stagesoft/gradient-motion-engine.git +cd gradient-motion-engine + +# Configure (debug build, daemon enabled) +cmake -B build -DCMAKE_BUILD_TYPE=Debug + +# Build +cmake --build build -j$(nproc) + +# Run the full test suite +ctest --test-dir build --output-on-failure +``` + +Lint (clang-tidy / clang-format must pass on every changed file): + +```bash +# Format check (will exit non-zero if any tracked file would change) +clang-format --dry-run --Werror $(git ls-files '*.cpp' '*.h') + +# Static analysis on changed files +clang-tidy -p build $(git diff --name-only main -- '*.cpp') +``` + +Library-only build (no MIDI, useful for embedding): + +```bash +cmake -B build -DBUILD_DAEMON=OFF +cmake --build build +``` + +Coverage build: + +```bash +cmake -B build \ + -DCMAKE_BUILD_TYPE=Debug \ + -DCMAKE_CXX_FLAGS="--coverage" \ + -DCMAKE_EXE_LINKER_FLAGS="--coverage" +cmake --build build -j$(nproc) +ctest --test-dir build --output-on-failure +lcov --capture --directory build --output-file coverage.info --ignore-errors inconsistent +lcov --remove coverage.info '/usr/*' '*/tests/*' --output-file coverage.info +``` + +--- + +## 3. Contribution Tiers + +The review requirements depend on what you change. + +### Tier 1 — Trivial + +No change to any file under `src/`, `daemon/`, or `tests/` beyond a single-line +correction. Covers: README edits, doc fixes, comment corrections, adding a test +for already-shipped behaviour, CI/CD config changes, packaging fixes. + +**Gates**: lint + CI green; one owner approval. No spec required. + +### Tier 2 — Non-trivial + +Any addition, modification, or deletion of logic in `src/` or `daemon/`. Includes +bug fixes that change branching behaviour, new features, refactors, new class +introductions, and changes to the OSC wire contract. + +**Gates**: spec + plan + tasks on the branch; failing test before implementation; +CI green (tests + coverage); constitution compliance declaration; one mandatory +owner approval. + +--- + +## 4. Branch Naming + +``` +feat/NNN-short-description ← new feature (NNN = spec number, e.g. 007) +fix/NNN-short-description ← bug fix referencing a spec or issue number +chore/short-description ← non-production changes (CI, tooling, docs) +build/short-description ← packaging / build-system changes +``` + +The `NNN` prefix links the branch to `specs/NNN-feature/` artifacts. Branches +without a valid prefix will not be merged. + +--- + +## 5. Spec-First Requirement + +For **Tier 2** changes, before opening a PR for review you MUST commit these +files on your feature branch: + +``` +specs/NNN-feature/spec.md ← feature specification +specs/NNN-feature/plan.md ← implementation plan with Constitution Check completed +specs/NNN-feature/tasks.md ← task list (generated by /speckit-tasks) +``` + +If you are a first-time contributor unfamiliar with the spec format, open an +issue first and the maintainers will help you scope the work. + +The PR description must link to the spec directory. A PR without it will be marked +as a draft and returned for pre-work. + +The CueMS Spec Kit slash commands (`/speckit-specify`, `/speckit-plan`, +`/speckit-tasks`, `/speckit-analyze`) live in `.claude/commands/` and operate +against the artifacts under `specs/`. + +--- + +## 6. TDD Workflow — Non-Negotiable + +`gradient-motion-engine` enforces Test-Driven Development for all Tier 2 changes. +This is not a style preference — it is a constitutional requirement +(Principle V). + +The mandatory sequence: + +``` +1. Write a failing test that precisely describes the intended behaviour. +2. Confirm CI fails on that commit (or run ctest locally and record the failure). +3. Write the minimum production code required to make the test pass. +4. Refactor without changing observable behaviour, keeping all tests green. +``` + +Your git log on the feature branch MUST show this order. The PR template asks for +the commit SHA of your failing-test commit. Reviewers will check it. + +```bash +# Run the full suite +ctest --test-dir build --output-on-failure + +# Run a single test (e.g., parser changes) +ctest --test-dir build -R test_osc_parse --output-on-failure + +# Run only unit-labeled tests +ctest --test-dir build -L unit --output-on-failure + +# End-to-end deploy tests against a running daemon (manual) +bash dev/deploy_tests/s007_t034_smoke.sh +``` + +Constitution Principle IV (Real-Time Safety) implies an additional gate: any +change to a code path that runs on the MTC tick callback MUST be accompanied by +either a microbenchmark in `tests/bench_*.cpp` or a documented argument for why +the change is heap-free, lock-free, and non-blocking. + +--- + +## 7. Commit Hygiene + +`gradient-motion-engine` uses [Conventional Commits](https://www.conventionalcommits.org/) v1.0. + +``` +[optional scope]: + +[optional body] + +[optional footer(s)] +Signed-off-by: Your Name +``` + +Allowed types: `feat`, `fix`, `test`, `refactor`, `docs`, `chore`, `ci`, `perf`, +`build`, `patch`, `spec`. + +Breaking changes: append `!` after the type and include a `BREAKING CHANGE:` footer. + +Rules: +- Each commit MUST represent one logical change. +- Do not squash unrelated changes into a single commit. +- Force-pushing to `main` is forbidden. Amending published commits on shared + branches is forbidden. +- Spec / plan / tasks commits use the `spec:` type prefix (see `git log`). + +--- + +## 8. Developer Certificate of Origin (DCO) + +Every commit must carry a `Signed-off-by` line, asserting that you have the right to +submit the contribution under GPL-3.0, as per the +[Developer Certificate of Origin](https://developercertificate.org). + +```bash +git commit -s -m "feat: add support for ..." +``` + +To add sign-off to all commits in a branch automatically, set: + +```bash +git config --local format.signOff true +``` + +PRs that contain unsigned commits will not be merged. + +--- + +## 9. Pull Request Requirements + +Open your PR against `main`. Use the PR template — it contains the full +acceptance checklist. + +Every PR MUST include in its description: + +1. **Summary** — what changed and why (2–5 sentences). +2. **Changelog Line** — see §12. +3. **Spec links** (Tier 2 only) — link to `specs/NNN-feature/`. +4. **Failing-test commit SHA** (Tier 2 only) — the commit where CI was red before + implementation began. +5. **Tick-path impact statement** (Tier 2 only) — does the change touch a code + path that runs on the MTC callback thread? If yes, link to the bench result + or argue why the change is heap-free / non-blocking. +6. **Completed PR checklist** — all items in the template ticked. + +Draft PRs are welcome for early feedback on approach. Drafts will not be formally +reviewed. Convert to Ready when all gates pass. + +--- + +## 10. Acceptance Criteria + +A PR is ready to merge when ALL of the following are true: + +| Criterion | How verified | +|---|---| +| `spec.md`, `plan.md`, `tasks.md` committed on branch (Tier 2) | Reviewer reads the files | +| Failing test committed before implementation (Tier 2) | SHA provided; reviewer checks git log | +| All `ctest` targets pass | CI green | +| Coverage ≥ 75% on changed files | CI coverage gate (Codecov) | +| `clang-format --dry-run --Werror` passes on changed files | CI lint gate | +| `clang-tidy` reports no new warnings on changed files | CI lint gate | +| No new build-time dependency without justification | Reviewer checks `CMakeLists.txt` + `debian/control` diff | +| SPDX header on all new source files | Reviewer inspects new files | +| No exceptions thrown across the library boundary | Reviewer checks `noexcept` and return-type signatures | +| Tick-path changes accompanied by a bench or argument (Principle IV) | Reviewer reads PR body | +| DCO sign-off on all commits | GitHub DCO check | +| At least one owner approval | GitHub branch protection | + +--- + +## 11. Review Process + +All PRs to `main` require approval from at least one repository owner: + +- **Ion Reguera** ([@ibiltari](https://github.com/ibiltari)) +- **Adrià Masip** ([@backenv](https://github.com/backenv)) + +This is enforced by `.github/CODEOWNERS` and GitHub branch protection. + +**What owners check:** +- Spec, plan, and tasks are coherent with the implementation (Tier 2). +- TDD sequence is evidenced in the git log. +- All CI gates pass. +- Constitution checklist is ticked accurately, not perfunctorily. +- No new runtime dependency slipped in without justification. +- SPDX header present on all new source files. +- Exceptions are not thrown across the library boundary; errors propagate as + enum returns or `std::optional`. +- The MTC tick path remains heap-free, lock-free, and non-blocking (Principle IV). +- New motion types subclass `IMotion` and register through `MotionFactory`; + they do not require changes to `MotionRegistry` (Principle VII, open/closed). +- SOLID principles respected — single responsibility, dependencies injected not + constructed. + +Expect review turnaround within 5 business days. For urgent fixes, open an issue +first and tag a maintainer — that speeds triage. + +--- + +## 12. Changelog Line + +You do not edit `CHANGELOG.md` — that is the maintainers' responsibility at release +time. Instead, include a **Changelog Line** in your PR description. Maintainers copy +this line verbatim (or lightly edited) when cutting a release. + +Format: + +``` +[TYPE] Past-tense sentence describing what changed and why it matters to users. +``` + +Types: `Added`, `Changed`, `Fixed`, `Removed`, `Security`, `Performance`. + +Examples: + +``` +[Added] FadeMotion supports the new "scurve" curve type via CurveFactory. +[Fixed] MotionRegistry no longer leaks lo_address handles on supersede. +[Changed] FadeCommand renamed fade_id → motion_id for ecosystem consistency. +[Removed] NNG bus client replaced by liblo UDP OSC listener. +``` + +--- + +## 13. Dependency Governance + +No new entry under `find_package(...)` or `pkg_check_modules(...)` in +`CMakeLists.txt`, and no new entry under `Build-Depends:` / `Depends:` in +`debian/control`, may be introduced without: + +1. A written justification in the PR description explaining why the standard + library, the existing dependencies, and a header-only alternative cannot + solve the problem. +2. Explicit acknowledgement from a repository owner in the review. + +Current runtime dependencies (the link closure of `gradient-motiond`): + +- `librtmidi-dev` — MIDI I/O for `mtcreceiver` +- `liblo-dev` — OSC transport (in and out) +- `nlohmann-json3-dev` — `curve_params_json` parsing +- `libtinyxml2-dev` — `settings.xml` parsing in `ConfigurationManager` (planned use) + +Build-time additions for tests only (`tests/CMakeLists.txt`) are lower friction +but still require a one-line justification in the PR description. + +Git submodules (`mtcreceiver`, `cuemslogger`) are tracked at pinned commits. +Bumping a submodule pin requires the same justification as adding a new dependency +plus a re-run of the full test suite against the new pin. + +--- + +## 14. License + +`gradient-motion-engine` is licensed under the GNU General Public License v3.0 +(GPL-3.0). By contributing, you agree that your contributions will be licensed +under GPL-3.0. + +All new source files MUST carry the following SPDX header: + +```cpp +/* + * *** + * SPDX-FileCopyrightText: Stagelab Coop SCCL + * SPDX-License-Identifier: GPL-3.0-or-later + * *** + */ +``` + +For Markdown files: + +```markdown + +``` + +For CMake / shell / Python files: + +```bash +# *** +# SPDX-FileCopyrightText: Stagelab Coop SCCL +# SPDX-License-Identifier: GPL-3.0-or-later +# *** +``` diff --git a/README.md b/README.md index cb94307..f495585 100644 --- a/README.md +++ b/README.md @@ -5,100 +5,247 @@ SPDX-License-Identifier: GPL-3.0-or-later *** --> -# Gradient Motion Engine +# gradient-motion-engine -**Current release: v0.1.0** — see [CHANGELOG.md](./CHANGELOG.md). +**Current release: v0.3.0** — see [CHANGELOG.md](./CHANGELOG.md). -**Timecode-driven motion and gradient evaluation engine with OSC output.** +**Timecode-driven motion and gradient evaluation engine, with localhost UDP OSC input and OSC output.** + +[![License: GPL v3](https://img.shields.io/badge/License-GPLv3-blue.svg)](https://www.gnu.org/licenses/gpl-3.0) +[![Tests](https://github.com/stagesoft/gradient-motion-engine/actions/workflows/tests.yml/badge.svg)](https://github.com/stagesoft/gradient-motion-engine/actions/workflows/tests.yml) +[![Coverage](https://codecov.io/gh/stagesoft/gradient-motion-engine/graph/badge.svg)](https://codecov.io/gh/stagesoft/gradient-motion-engine) +[![Deploy API documentation](https://github.com/stagesoft/gradient-motion-engine/actions/workflows/docs.yml/badge.svg)](https://github.com/stagesoft/gradient-motion-engine/actions/workflows/docs.yml) * **Source / issues:** [stagesoft/gradient-motion-engine](https://github.com/stagesoft/gradient-motion-engine) on GitHub -* **API reference (HTML):** [stagesoft.github.io/gradient-motion-engine](https://stagesoft.github.io/gradient-motion-engine/) (built from `main` via GitHub Pages) +* **API reference (HTML):** [stagesoft.github.io/gradient-motion-engine](https://stagesoft.github.io/gradient-motion-engine/) (Doxygen, built from `main` via GitHub Pages) -`gradient-motion-engine` is a C++ system for defining and evaluating **time-based motion trajectories** and **value gradients**, producing structured **OSC (Open Sound Control)** messages in real time. +`gradient-motion-engine` is the per-node fade and motion engine developed for the **CueMS** (Cue Management System). It evaluates parametric volume/opacity envelopes (and, in time, generalised motion trajectories) locked to **MIDI Time Code**, receiving commands over **localhost UDP OSC** from the local NodeEngine and emitting **OSC** value updates to audio (`cuems-audioplayer /volmaster`) and video (`cuems-videocomposer /videocomposer/layer/{id}/opacity`) players. It is composed of: -* **`libgradient_motion`** — a reusable C++ library providing core primitives (time, motion, gradients, signal evaluation) -* **`gradient-motiond`** — a systemd-managed daemon that runs the evaluation engine and emits OSC +* **`libgradient_motion`** — a reusable C++17 static library providing the evaluation core: curves, motion registry, OSC sender, MTC tick source, and the lock-free command queue. +* **`gradient-motiond`** — a systemd-managed daemon that wires the library to a liblo UDP OSC listener and runs the evaluation pipeline. --- ## Overview -The engine models time-dependent behavior as a deterministic pipeline: +The engine models time-dependent behaviour as a deterministic pipeline: ```text -Timecode → Motion → Gradient → Signal → OSC +NodeEngine ──► OscServer ──► LockFreeQueue ──► MotionRegistry + (UDP) (liblo) (SPSC, drop-oldest) │ + │ per MTC quarter-frame tick + ▼ + IMotion::evalAndSend + │ + ▼ + OSC float ──► AudioPlayer / VideoComposer ``` -* **Timecode** drives the system clock and scheduling -* **Motion** resolves spatial trajectories over time -* **Gradient** maps time or position to interpolated values -* **Signal** structures evaluated data into frames -* **OSC** transports the resulting data to external systems - -This separation allows `libgradient_motion` to be embedded in other applications while `gradient-motiond` provides a ready-to-run runtime for Linux environments. +* **OSC input** — `/gradient/start_fade`, `/gradient/cancel_motion`, `/gradient/cancel_all` arrive on `127.0.0.1:` (default `7100`). +* **Parse + filter** — `parseFadeOscCommand` validates the type-tag, applies the `node_name` filter, and produces a `FadeCommand`. +* **Queue** — `LockFreeQueue` hands the command from the liblo network thread to the MTC tick thread (drop-oldest on overflow). +* **Tick loop** — `MtcTickSource` fires on every MTC quarter frame (100 Hz at 25 fps); `MotionRegistry` drains the queue, applies commands, then calls `IMotion::evalAndSend` on every active motion. +* **OSC output** — `FadeMotion` interpolates `start_value → end_value` along a pre-resampled `Curve` (256-sample LUT) and emits a single OSC float per active motion per tick to the target player. --- ## Architecture -### Library: `libgradient_motion` +### Library: `libgradient_motion` (`src/`) + +#### `gme::time` — [src/time/](src/time/) + +* **`MtcTickSource`** — Thin adapter over `mtcreceiver` v2.0.0 exposing a `void(long mtc_ms)` callback. One-instance-per-process (mtcreceiver uses static state); blocks any in-flight callback in its destructor. Lock-free, non-blocking callback path on the RtMidi thread. +* **`MtcStartError`** — Enum: `kOk`, `kNoPortsAvailable`, `kPortNotFound` (no exceptions across the library boundary). + +#### `gme::gradient` — [src/gradient/](src/gradient/) + +* **`Curve`** — Abstract interface; maps normalised `t ∈ [0,1]` to normalised output `[0,1]`. Concrete types clamp internally; `evaluate(0.0) == 0.0` and `evaluate(1.0) == 1.0` for all bundled types. +* **`LinearCurve`** — Identity mapping. +* **`SigmoidCurve`** — Logistic sigmoid with configurable `steepness` (default 8.0) and `midpoint` (default 0.5). +* **`BezierCurve`** — Cubic Bézier; control points `(cx1, cy1, cx2, cy2)` with documented defaults. +* **`EaseInCurve` / `EaseOutCurve`** — Power-ease shapes (`exponent`, default 2.0). +* **`SCurve`** — Smoothstep variant for symmetric ease-in/ease-out. +* **`ScaledCurve`** — Decorator that scales another curve's output range. +* **`ResampledCurve`** — Decorator that pre-samples any curve into a 256-entry LUT; this is the wrapper applied to every curve returned by `CurveFactory` so that runtime evaluation is constant-time. +* **`CrossfadePair`** — Two-curve container reserved for the deferred crossfade motion type. +* **`CurveFactory`** — Single entry point: `createCurve(type, params) → std::optional>`. Unknown types return `nullopt` so the caller decides the fallback policy. + +#### `gme::motion` — [src/motion/](src/motion/) + +* **`IMotion`** — Abstract base for all motion types. Owns the common lifecycle fields (`motion_id`, `osc_key`, `start_mtc_ms`, `duration_ms`, `completed`, `consecutive_osc_failures`). Three virtuals: `evalAndSend`, `sendSnapToEnd`, `inheritFrom`. New motion kinds extend this, not `MotionRegistry`. +* **`EvalResult`** — POD result from `evalAndSend`: `completed`, `failed`, and a static-storage `failure_reason` string. +* **`FadeMotion`** — Concrete scalar-fade motion. Owns a pre-resampled `Curve`, scalar `start_value`/`end_value`/`last_sent_value`, and a pre-built `lo_address`. `inheritFrom` copies the prior motion's last-sent value to avoid jumps on supersede. +* **`MotionFactory`** — Stateless construction site. `fromCommand(cmd, ctx)` is the single place where `CurveFactory::createCurve` and `lo_address_new` are called. +* **`MotionRegistry`** — Owns every active motion, indexed by `motion_id` (primary) and `"host:port:path"` (secondary, for supersede). Single-threaded API (MTC tick thread). Per-tick: drain, apply, evaluate, remove completed/dead motions. Supersede inherits state; OSC failure threshold is `kOscFailureThreshold = 5` consecutive errors. + +#### `gme::signal` — [src/signal/](src/signal/) -The core library exposes modular components organized by domain: +* **`FadeCommand`** — Plain aggregate carrying every field needed for the four command types (`START_FADE`, `CANCEL_MOTION`, `CANCEL_ALL`, `START_CROSSFADE`). The sole payload moved between the OSC thread and the tick thread. +* **`ParseResult`** — Outcome enum returned by `parseFadeOscCommand` (`Ok`, `NodeMismatch`, `MissingField`, `TypeError`, `UnknownCommand`, …). +* **`StatusKind`** — Discriminates `MotionComplete` from `MotionError` for journal logging. +* **`parseFadeOscCommand`** — Free function that validates the type-tag, applies the `node_name` filter, validates field constraints, parses `curve_params_json`, and partially populates `motion_id` / `type` on `MissingField` / `TypeError` so callers can log the rejection. +* **`LockFreeQueue`** — Fixed-capacity SPSC ring buffer with drop-oldest-on-full. Zero heap allocation after construction; bounded producer path; advisory `size()`/`empty()`. -* `gme::time` — timecode, clocks, scheduling -* `gme::motion` — trajectories and spatial evaluation -* `gme::gradient` — keyframes and interpolation -* `gme::signal` — evaluated value representation -* `gme::osc` — OSC encoding and transport -* `gme::engine` — orchestration and execution pipeline +#### `gme::osc` — [src/osc/](src/osc/) -The library is designed for: +* **`sendFloat(target, path, value)`** — Stateless liblo wrapper. Safe to call from the MTC tick thread under the loopback assumption (UDP fire-and-forget; ~1–5 µs per call). +* **`makeAddress(host, port)`** — Thin C++ wrapper over `lo_address_new`; caller owns the returned handle. + +#### `gme::engine` — [src/engine/](src/engine/) + +* **`GradientEngine`** — Top-level orchestrator. Owns `MtcTickSource`, `LockFreeQueue`, `OscServer` (via forward declaration to keep liblo headers out of library consumers), and `MotionRegistry`. `GradientEngine.cpp` compiles into the daemon binary, not the library, since `OscServer` lives in the daemon layer. + +### Daemon: `gradient-motiond` (`daemon/`) + +* **`GradientEngineApplication`** — Lifecycle orchestrator (`Constructed → Initialized → Running → Shutting Down → Destroyed`). Installs SIGTERM/SIGINT handlers; owns `ConfigurationManager` and the optional `CuemsLogger`. +* **`ConfigurationManager`** — Parses CLI flags via `getopt_long`. Resolves `gradient_osc_port` in priority order: `--osc-port` → `CUEMS_GRADIENT_OSC_PORT` → `settings.xml` `` → compile-time default `7100`. +* **`gme::daemon::comms::OscServer`** — liblo UDP listener bound to `127.0.0.1:` (never a routable interface). Registers handlers for `/gradient/start_fade` (`sssisffhiss`), `/gradient/cancel_motion` (`ss`), `/gradient/cancel_all` (`s`). PIMPL pattern keeps liblo headers out of library consumers. + +--- + +## Core Concepts -* deterministic evaluation -* low-latency execution -* composability and embedding +* **Motion** — A time-bounded transformation of an OSC parameter (currently a scalar fade). Indexed in the registry by a caller-assigned `motion_id`. +* **OSC supersede key** — Composite `"host:port:path"`. At most one active motion per key; a new motion on the same key supersedes the old and inherits its last-sent value. +* **MTC tick** — A quarter-frame callback from `mtcreceiver`; the only thread on which evaluation runs. +* **Curve** — A normalised `[0,1] → [0,1]` shaping function. Every curve handed to a `FadeMotion` is wrapped in a 256-sample LUT for constant-time evaluation. +* **FadeCommand** — The wire-and-queue payload type. Built by the OSC parser, drained by the registry, never persisted. +* **Node name filter** — Every inbound OSC command carries the target `node_name`; the listener silently drops commands targeted at other nodes. --- -### Daemon: `gradient-motiond` +## Design Goals + +* **Deterministic** — Identical MTC inputs and commands produce identical OSC outputs. +* **Real-time capable** — The evaluation tick path is lock-free and zero-allocation; transport handles are pre-built at `START_FADE` time. +* **Exception-free across the library boundary** — Errors propagate as enum return values (`MtcStartError`, `ParseResult`, `EvalResult::failed`), never as thrown exceptions. +* **Open for extension, closed for modification** — New motion kinds subclass `IMotion` and register through `MotionFactory`; `MotionRegistry` does not need to change. +* **Embeddable** — `libgradient_motion` has no daemon dependencies. `GradientEngine.cpp` compiles into the daemon binary so the library remains transport-agnostic at link time. +* **Localhost-only inbound transport** — The OSC listener binds `127.0.0.1` exclusively; commands from the network must traverse into localhost. + +--- + +## Installation + +### Build from source + +System packages (Debian/Ubuntu): + +```bash +sudo apt-get install -y \ + build-essential cmake pkg-config \ + librtmidi-dev liblo-dev nlohmann-json3-dev libtinyxml2-dev +``` + +Configure and build: + +```bash +git clone --recursive https://github.com/stagesoft/gradient-motion-engine.git +cd gradient-motion-engine +mkdir build && cd build +cmake .. -DCMAKE_BUILD_TYPE=Release +make -j$(nproc) +sudo make install +``` + +If you cloned without `--recursive`, fetch the submodules: + +```bash +git submodule update --init --recursive +``` + +### Debian package -`gradient-motiond` is the runtime service built on top of `libgradient_motion`. +The `debian/` directory carries packaging metadata for building a native `.deb`: -Responsibilities: +```bash +git clone https://github.com/stagesoft/gradient-motion-engine.git +cd gradient-motion-engine +dpkg-buildpackage -us -uc +sudo dpkg -i ../cuems-gradient-motiond_*.deb +``` -* load configuration (`/etc/gradient-motion/`) -* manage timecode source -* execute the evaluation pipeline -* emit OSC messages to configured endpoints +The `cuems-gradient-motiond` package installs the binary at `/usr/bin/gradient-motiond`. The matching `cuems-gradient-motiond.service` systemd unit is shipped by the `cuems-common` package (declared as a runtime dependency). -Typical deployment: +### systemd service ```bash -systemctl enable gradient-motiond -systemctl start gradient-motiond +systemctl enable cuems-gradient-motiond +systemctl start cuems-gradient-motiond ``` --- -## Core Concepts +## Development + +### Build with tests and debug symbols -* **Timecode** — the authoritative temporal reference for all evaluation -* **Trajectory** — defines motion through space over time -* **Gradient** — defines value interpolation across time or position -* **Signal Frame** — evaluated state at a given time step -* **OSC Output** — serialized messages emitted to external systems +```bash +mkdir build && cd build +cmake .. -DCMAKE_BUILD_TYPE=Debug +make -j$(nproc) +ctest --output-on-failure +``` + +### Build with coverage + +```bash +mkdir build && cd build +cmake .. \ + -DCMAKE_BUILD_TYPE=Debug \ + -DCMAKE_CXX_FLAGS="--coverage" \ + -DCMAKE_EXE_LINKER_FLAGS="--coverage" +make -j$(nproc) +ctest --output-on-failure +lcov --capture --directory . --output-file coverage.info --ignore-errors inconsistent +lcov --remove coverage.info '/usr/*' '*/tests/*' --output-file coverage.info +``` + +### Library-only build (no MIDI/daemon) + +For embedding the library in another project without RtMidi available: + +```bash +cmake -B build -DBUILD_DAEMON=OFF +cmake --build build +``` + +### Generate API docs (Doxygen) + +```bash +cmake -B build +cmake --build build --target docs +# Output: build/docs/html/index.html +``` + +### Run a specific test + +```bash +ctest --test-dir build -R test_osc_parse --output-on-failure +ctest --test-dir build -R test_osc_server_integration --output-on-failure +ctest --test-dir build -R test_motion_registry --output-on-failure +``` + +### Deploy / smoke tests + +End-to-end scripts that exercise the running daemon over OSC live in [dev/deploy_tests/](dev/deploy_tests/) — `s007_t034_smoke.sh`, `s007_t052_rate_limit.sh`, `s007_t063_multi_node.sh`, `s007_t065_avahi_resilience.sh`. --- -## Design Goals +## Release notes + +See [CHANGELOG.md](./CHANGELOG.md) for the full history. + +### v0.3.0 — 2026-05-13 — OSC Input Transport + +Replaces the NNG bus-client inbound transport with a localhost UDP OSC listener. New `OscServer` (liblo, PIMPL) binds `127.0.0.1:` and handles `/gradient/start_fade`, `/gradient/cancel_motion`, `/gradient/cancel_all`. Adds `parseFadeOscCommand` (pure C++ free function, `nlohmann::json` for `curve_params_json`), `--osc-port` CLI flag (default `7100`), and `CUEMS_GRADIENT_OSC_PORT` env override. Removes the NNG bus client, the status-emit queue, and the JSON `parseFadeCommand`. Renames `fade_id` → `motion_id` and `partner_fade_id` → `partner_motion_id` for ecosystem consistency. 14 parse-level unit tests + 3 real-loopback integration tests. Full spec: [specs/007-osc-input-transport/](specs/007-osc-input-transport/). + +### v0.1.0 — 2026-04-23 — First public release -* **Deterministic** — identical inputs produce identical outputs -* **Modular** — clean separation between motion, gradients, and transport -* **Real-time capable** — suitable for continuous execution under systemd -* **Embeddable** — core logic available via `libgradient_motion` -* **Protocol-agnostic core** — OSC isolated to the output layer +Initial timecode-driven motion and gradient evaluation with OSC output. Ships `libgradient_motion` (modular namespaces `gme::time`, `gme::gradient`, `gme::motion`, `gme::signal`, `gme::osc`, `gme::engine`) with pluggable curve types (linear, sigmoid, bezier, ease-in/out, scurve, resampled, scaled, crossfade pair) and factory-based construction. Includes MTC tick source built on `mtcreceiver` v2.0.0, polymorphic `IMotion` hierarchy with duplicate-id rejection and supersede inheritance, fade-registry tick loop, lock-free SPSC queue, and the `gradient-motiond` systemd daemon. Tests cover curves, MTC, NNG (pre-0.3.0), lock-free queue, fade/motion registry, and OSC latency benchmarks. --- diff --git a/docs/api.md b/docs/api.md new file mode 100644 index 0000000..d3323f0 --- /dev/null +++ b/docs/api.md @@ -0,0 +1,415 @@ + + +# API synopsis + +This page lists the public C++ surface of `libgradient_motion` and the `gradient-motiond` daemon. The generated API reference is published at [stagesoft.github.io/gradient-motion-engine](https://stagesoft.github.io/gradient-motion-engine/), built from the docstrings in the headers themselves. + +Every namespace below is rooted at `gme::`. The header file for each entry is linked. + +--- + +## `gme::time` + +[`src/time/MtcTickSource.h`](https://github.com/stagesoft/gradient-motion-engine/blob/main/src/time/MtcTickSource.h) + +```cpp +namespace gme::time { + +enum class MtcStartError { kOk, kNoPortsAvailable, kPortNotFound }; + +class MtcTickSource { +public: + MtcTickSource(); + ~MtcTickSource(); // blocks until any in-flight callback returns + + void setTickCallback(std::function cb); + MtcStartError start(const std::string& midiPort); + long getMtcMs() const; + bool isRunning() const; +}; + +} // namespace gme::time +``` + +One instance per process — `mtcreceiver` uses process-global static state. + +--- + +## `gme::gradient` + +[`src/gradient/Curve.h`](https://github.com/stagesoft/gradient-motion-engine/blob/main/src/gradient/Curve.h) — abstract base: + +```cpp +namespace gme::gradient { + +class Curve { +public: + virtual double evaluate(double t) const = 0; + virtual ~Curve() = default; + Curve(const Curve&) = delete; + Curve& operator=(const Curve&) = delete; + Curve(Curve&&) = default; + Curve& operator=(Curve&&) = default; +}; + +} // namespace gme::gradient +``` + +Concrete curves: + +| Header | Class | Parameters | +|---|---|---| +| [`LinearCurve.h`](https://github.com/stagesoft/gradient-motion-engine/blob/main/src/gradient/LinearCurve.h) | `LinearCurve` | — | +| [`SigmoidCurve.h`](https://github.com/stagesoft/gradient-motion-engine/blob/main/src/gradient/SigmoidCurve.h) | `SigmoidCurve` | `steepness` (default 8.0), `midpoint` (default 0.5) | +| [`BezierCurve.h`](https://github.com/stagesoft/gradient-motion-engine/blob/main/src/gradient/BezierCurve.h) | `BezierCurve` | `cx1`, `cy1`, `cx2`, `cy2` (defaults `0.25, 0.1, 0.75, 0.9`) | +| [`EaseInCurve.h`](https://github.com/stagesoft/gradient-motion-engine/blob/main/src/gradient/EaseInCurve.h) | `EaseInCurve` | `exponent` (default 2.0) | +| [`EaseOutCurve.h`](https://github.com/stagesoft/gradient-motion-engine/blob/main/src/gradient/EaseOutCurve.h) | `EaseOutCurve` | `exponent` (default 2.0) | +| [`SCurve.h`](https://github.com/stagesoft/gradient-motion-engine/blob/main/src/gradient/SCurve.h) | `SCurve` | — | +| [`ScaledCurve.h`](https://github.com/stagesoft/gradient-motion-engine/blob/main/src/gradient/ScaledCurve.h) | `ScaledCurve` | output min/max scaling | +| [`ResampledCurve.h`](https://github.com/stagesoft/gradient-motion-engine/blob/main/src/gradient/ResampledCurve.h) | `ResampledCurve` | inner curve + 256-sample LUT | +| [`CrossfadePair.h`](https://github.com/stagesoft/gradient-motion-engine/blob/main/src/gradient/CrossfadePair.h) | `CrossfadePair` | two curves (deferred motion type) | + +[`src/gradient/CurveFactory.h`](https://github.com/stagesoft/gradient-motion-engine/blob/main/src/gradient/CurveFactory.h): + +```cpp +namespace gme::gradient { + +class CurveFactory { +public: + static std::optional> + createCurve(const std::string& type, + const nlohmann::json& params = nlohmann::json::object()); + +private: + CurveFactory() = delete; +}; + +} // namespace gme::gradient +``` + +Supported `type` strings: `"linear"`, `"sigmoid"`, `"bezier"`, `"ease_in"`, `"ease_out"`, `"scurve"`. Unknown types return `std::nullopt`. The returned curve is always wrapped in a `ResampledCurve`. + +--- + +## `gme::motion` + +[`src/motion/IMotion.h`](https://github.com/stagesoft/gradient-motion-engine/blob/main/src/motion/IMotion.h): + +```cpp +namespace gme::motion { + +class IMotion { +public: + std::string motion_id; + std::string osc_key; // "host:port:path" + long start_mtc_ms = 0; + float duration_ms = 0.0f; + bool completed = false; + int consecutive_osc_failures = 0; + + virtual ~IMotion() = default; + virtual EvalResult evalAndSend(long mtc_ms) = 0; + virtual void sendSnapToEnd() = 0; + virtual void inheritFrom(const IMotion& prior) = 0; +}; + +} // namespace gme::motion +``` + +[`src/motion/EvalResult.h`](https://github.com/stagesoft/gradient-motion-engine/blob/main/src/motion/EvalResult.h): + +```cpp +struct EvalResult { + bool completed = false; + bool failed = false; + const char* failure_reason = nullptr; // static storage +}; +``` + +[`src/motion/FadeMotion.h`](https://github.com/stagesoft/gradient-motion-engine/blob/main/src/motion/FadeMotion.h): + +```cpp +class FadeMotion final : public IMotion { +public: + using OscSendFn = std::function; + + FadeMotion(std::string motion_id, + std::string osc_key, + long start_mtc_ms, + float duration_ms, + float start_value, + float end_value, + std::unique_ptr curve, + lo_address osc_target, // takes ownership + std::string osc_path, + std::string osc_host, + int osc_port, + OscSendFn oscSend); + ~FadeMotion() override; // lo_address_free(osc_target_) + + EvalResult evalAndSend(long mtc_ms) override; + void sendSnapToEnd() override; + void inheritFrom(const IMotion& prior) override; + + float start_value = 0.0f; + float end_value = 0.0f; + float last_sent_value = 0.0f; + std::string osc_path; + std::string osc_host; + int osc_port = 0; +}; +``` + +[`src/motion/MotionFactory.h`](https://github.com/stagesoft/gradient-motion-engine/blob/main/src/motion/MotionFactory.h): + +```cpp +class MotionFactory { +public: + using OscSendFn = std::function; + + struct Context { + const gme::time::MtcTickSource& mtcSource; + OscSendFn oscSend; + std::function emitStatus; + }; + + static std::unique_ptr fromCommand(const gme::signal::FadeCommand& cmd, + const Context& ctx); + MotionFactory() = delete; +}; +``` + +[`src/motion/MotionRegistry.h`](https://github.com/stagesoft/gradient-motion-engine/blob/main/src/motion/MotionRegistry.h): + +```cpp +class MotionRegistry { +public: + static constexpr int kOscFailureThreshold = 5; + + using OscSendFn = std::function; + + MotionRegistry(const gme::time::MtcTickSource& mtcSource, + std::function statusDirect, + OscSendFn oscSend = nullptr); + + void apply(gme::signal::FadeCommand& cmd); + void addMotion(std::unique_ptr m); + void cancelMotion(const std::string& motion_id, bool snap_to_end); + void cancelAll(); + void tick(long mtc_ms); + std::size_t size() const noexcept; +}; +``` + +--- + +## `gme::signal` + +[`src/signal/FadeCommand.h`](https://github.com/stagesoft/gradient-motion-engine/blob/main/src/signal/FadeCommand.h): + +```cpp +namespace gme::signal { + +struct FadeCommand { + enum class Type { START_FADE, CANCEL_MOTION, CANCEL_ALL, START_CROSSFADE }; + + Type type = Type::START_FADE; + + std::string motion_id; + std::string node_name; + + std::string osc_host; + int osc_port = 0; + std::string osc_path; + + float start_value = 0.0f; + float end_value = 0.0f; + float duration_ms = 0.0f; + + std::string curve_type; + nlohmann::json curve_params; + + long start_mtc_ms = -1; // -1 = "start at current MTC" + + std::string partner_motion_id; + std::string partner_osc_path; + float partner_start_value = 0.0f; + float partner_end_value = 0.0f; +}; + +enum class ParseResult { + Ok, TargetMismatch, NodeMismatch, UnknownCommand, + MissingField, TypeError, MalformedJson +}; + +enum class StatusKind { MotionComplete, MotionError }; + +} // namespace gme::signal +``` + +Required field matrix: + +| Command | Required | +|------------------|---------------------------------------------------------------------------| +| `start_fade` | `motion_id`, `node_name`, `osc_host`, `osc_port`, `osc_path`, `start_value`, `end_value`, `duration_ms`, `curve_type`, `start_mtc_ms` | +| `cancel_motion` | `motion_id`, `node_name` | +| `cancel_all` | `node_name` | +| `start_crossfade`| `start_fade` fields + `partner_motion_id`, `partner_osc_path`, `partner_start_value`, `partner_end_value` | + +[`src/signal/parseFadeOscCommand.h`](https://github.com/stagesoft/gradient-motion-engine/blob/main/src/signal/parseFadeOscCommand.h): + +```cpp +namespace gme::signal { + +ParseResult parseFadeOscCommand(const char* path, + const char* types, + lo_arg** argv, + int argc, + std::string_view this_node_name, + FadeCommand* out_cmd); + +} // namespace gme::signal +``` + +OSC address → type-tag: + +| Address | Type tag | +|-----------------------------|----------------| +| `/gradient/start_fade` | `sssisffhiss` | +| `/gradient/cancel_motion` | `ss` | +| `/gradient/cancel_all` | `s` | + +[`src/signal/LockFreeQueue.h`](https://github.com/stagesoft/gradient-motion-engine/blob/main/src/signal/LockFreeQueue.h): + +```cpp +template +class LockFreeQueue { +public: + LockFreeQueue() noexcept = default; + + bool push(T&& item) noexcept; // drop-oldest on full; false signals drop + bool pop(T& out) noexcept; + std::size_t size() const noexcept; // advisory only + bool empty() const noexcept; // advisory only + static constexpr std::size_t capacity() noexcept { return N; } +}; +``` + +Production instantiation: `LockFreeQueue`. Usable capacity is `N - 1`. + +--- + +## `gme::osc` + +[`src/osc/OscSender.h`](https://github.com/stagesoft/gradient-motion-engine/blob/main/src/osc/OscSender.h): + +```cpp +namespace gme::osc { + +int sendFloat(lo_address target, const char* path, float value) noexcept; +lo_address makeAddress(const std::string& host, int port) noexcept; + +} // namespace gme::osc +``` + +Caller owns the `lo_address` returned by `makeAddress` and must call `lo_address_free` on destruction. In production, `FadeMotion`'s destructor does this. + +--- + +## `gme::engine` + +[`src/engine/GradientEngine.h`](https://github.com/stagesoft/gradient-motion-engine/blob/main/src/engine/GradientEngine.h): + +```cpp +namespace gme::engine { + +struct GradientEngineConfig { + std::string midiPort; + int oscPort; + std::string nodeName; +}; + +class GradientEngine { +public: + GradientEngine(); + ~GradientEngine(); // calls shutdown() if still running + + bool initialize(const GradientEngineConfig& config); + void shutdown(); // idempotent +}; + +} // namespace gme::engine +``` + +`OscServer` is forward-declared in this header; the destructor body lives in `GradientEngine.cpp`, which is compiled into the daemon binary rather than the library. + +--- + +## `gme::daemon::comms` + +[`daemon/comms/OscServer.h`](https://github.com/stagesoft/gradient-motion-engine/blob/main/daemon/comms/OscServer.h): + +```cpp +namespace gme::daemon::comms { + +class OscServer { +public: + OscServer(int port, + std::string node_name, + gme::signal::LockFreeQueue* out_queue); + ~OscServer(); + + bool start(); // returns false on bind failure + void stop(); // idempotent + int getPort() const noexcept; +}; + +} // namespace gme::daemon::comms +``` + +PIMPL pattern — the liblo headers are not exposed to consumers of this header. Binds `127.0.0.1` only. + +--- + +## Daemon-layer classes (no namespace) + +[`daemon/GradientEngineApplication.h`](https://github.com/stagesoft/gradient-motion-engine/blob/main/daemon/GradientEngineApplication.h): + +```cpp +class GradientEngineApplication { +public: + GradientEngineApplication(); + ~GradientEngineApplication(); + + int initialize(int argc, char** argv); // 0 = run; <0 = clean exit; >0 = error + int run(); // blocks until SIGTERM/SIGINT + void shutdown(); +}; +``` + +[`daemon/config/ConfigurationManager.h`](https://github.com/stagesoft/gradient-motion-engine/blob/main/daemon/config/ConfigurationManager.h): + +```cpp +class ConfigurationManager { +public: + ConfigurationManager(); + ~ConfigurationManager() = default; + + int parseArgs(int argc, char** argv); // 0 = ok, -1 = help/version, >0 = error + + const std::string& getMidiPort() const; + const std::string& getLogLevel() const; + const std::string& getConfPath() const; + int getGradientOscPort() const; + const std::string& getNodeName() const; +}; +``` + +CLI flags and resolution order are documented in the header's Doxygen block. OSC-port priority: `--osc-port` → `CUEMS_GRADIENT_OSC_PORT` → `settings.xml` `` → compile-time default `7100`. diff --git a/docs/architecture.md b/docs/architecture.md new file mode 100644 index 0000000..e35ca70 --- /dev/null +++ b/docs/architecture.md @@ -0,0 +1,181 @@ + + +# Architecture + +This page documents the static structure of `gradient-motion-engine`: which components exist, what each owns, and how they depend on each other. For the live signal flow and the design rationale, see [index.md](index.md). + +--- + +## Component graph + +``` +┌────────────────────────────────────────────────────────────────────────────┐ +│ gradient-motiond (daemon binary) │ +│ │ +│ GradientEngineApplication ─── ConfigurationManager │ +│ │ │ +│ │ owns │ +│ ▼ │ +│ gme::engine::GradientEngine │ +│ │ │ +│ ├─ owns ── gme::time::MtcTickSource ───────► mtcreceiver v2 │ +│ │ (RtMidi) │ +│ │ │ +│ ├─ owns ── gme::signal::LockFreeQueue │ +│ │ │ +│ ├─ owns ── gme::daemon::comms::OscServer ──► liblo (UDP) │ +│ │ │ │ +│ │ └─ uses ─ gme::signal::parseFadeOscCommand │ +│ │ │ +│ └─ owns ── gme::motion::MotionRegistry │ +│ │ │ +│ ├─ holds ─ std::unordered_map │ +│ │ │ +│ └─ uses ── gme::motion::MotionFactory │ +│ │ │ +│ ├─ uses ── gme::gradient::CurveFactory │ +│ │ (LinearCurve, SigmoidCurve, │ +│ │ BezierCurve, EaseIn/OutCurve, │ +│ │ SCurve, all wrapped in │ +│ │ ResampledCurve) │ +│ │ │ +│ └─ produces ── gme::motion::FadeMotion │ +│ │ │ +│ └─ uses ─ gme::osc::sendFloat│ +│ (liblo UDP) │ +│ │ +└────────────────────────────────────────────────────────────────────────────┘ +``` + +The daemon (`gradient-motiond`) is the only binary. Tests link `gradient_motion` (the static library) plus a per-test set of mocks and helpers. The library itself does not link liblo for consumers that don't use `OscServer` directly — `OscServer` is forward-declared in `GradientEngine.h` and `GradientEngine.cpp` is compiled into the daemon target. + +--- + +## Module dependency direction + +``` +gme::engine (depends on) + │ + ├──► gme::motion ──► gme::gradient + │ │ │ + │ └──► gme::signal └──► (no further GME deps) + │ │ + │ └──► (nlohmann_json, liblo for argv types) + │ + ├──► gme::time ──► mtcreceiver + │ + └──► gme::osc ──► liblo +``` + +Rules: + +- **No cycles.** `gme::gradient` depends on nothing else in GME; `gme::time` depends on nothing else in GME; `gme::osc` depends on nothing else in GME. +- **`gme::motion` is the integrator** of `gradient` + `osc` + `signal`. It does not depend on `time` directly — the registry receives `mtc_ms` as a `long` from the engine layer. +- **`gme::engine` wires everything.** It is the only namespace that depends on the daemon-layer `OscServer`, and that dependency lives in `GradientEngine.cpp` (compiled into the daemon binary, not the static library). + +--- + +## Threading model + +| Thread | Owner | Responsibility | +|---|---|---| +| Main thread | `GradientEngineApplication` | Lifecycle: `initialize` → `run` (blocks on `pause`) → `shutdown` on signal. | +| RtMidi MIDI callback | `mtcreceiver` (transitively) | Decodes quarter frames; fires `MtcTickSource`'s registered callback. Lock-free, non-blocking. | +| liblo network thread | `gme::daemon::comms::OscServer` | Receives UDP, dispatches to address handlers, parses, pushes to the SPSC queue. | +| (none — no worker pool) | — | Evaluation runs on the RtMidi callback thread directly. | + +**Cross-thread handoff** is exactly the `LockFreeQueue`: +- **Producer** — exactly one (the liblo thread inside `OscServer`). +- **Consumer** — exactly one site at a time (`GradientEngine::onTick` on the RtMidi thread). There is no 100 ms fallback drain in the current implementation; the queue is drained only on tick. + +The queue is wait-free for the consumer (`pop` is a single load + indexed read + store) and bounded for the producer (drop-oldest on full, returns `false` to signal the drop). + +--- + +## Lifecycle + +### Startup + +1. `main()` constructs `GradientEngineApplication` on the stack. +2. `app.initialize(argc, argv)` runs `ConfigurationManager::parseArgs`, sets up the optional `CuemsLogger`, installs signal handlers, constructs `GradientEngine`. +3. `app.run()` calls `GradientEngine::initialize({midiPort, oscPort, nodeName})`: + - `MtcTickSource::start(midiPort)` — opens MIDI; returns `MtcStartError`. On non-`kOk`, the engine logs and the daemon exits non-zero. + - `OscServer::start()` — binds `127.0.0.1:` and starts the liblo thread. On bind failure, returns `false`; the engine logs and exits. + - `MotionRegistry` is constructed with the tick source reference and the status callback. + - `MtcTickSource::setTickCallback([this](long ms){ onTick(ms); })` — registration is the last step so no tick fires against a half-built engine. +4. `app.run()` blocks on `pause()`. + +### Tick + +For each MTC quarter frame the RtMidi thread invokes `MtcTickSource::setTickCallback`'s registered closure → `GradientEngine::onTick(mtc_ms)`: + +1. Drain `queue_`: for each `FadeCommand` popped, `registry_->apply(cmd)`. +2. `registry_->tick(mtc_ms)`: + - For each `IMotion` in `motions_`, call `evalAndSend(mtc_ms)`. + - On `result.failed`: increment `consecutive_osc_failures`; at `kOscFailureThreshold = 5` mark for removal and emit `MotionError:"osc_send_failed"`. Otherwise reset to 0. + - On `result.completed`: mark `completed = true`, emit `MotionComplete`, mark for removal. + - After iterating, remove all marked motions from both `motions_` and `osc_index_`. + +### Shutdown + +SIGTERM / SIGINT → handler sets the loop flag → `app.run()` returns from `pause()` → `app.shutdown()`: + +1. `GradientEngine::shutdown()`: + - Deregister the tick callback (`setTickCallback({})`). The destructor of `MtcTickSource` (when the engine destructs later) blocks until any in-flight callback returns. + - `MotionRegistry::cancelAll()` — removes every motion without `sendSnapToEnd` (final OSC values are NOT sent on shutdown). + - `OscServer::stop()` — joins the liblo thread. +2. `ConfigurationManager` and `CuemsLogger` destruct. +3. Process exits with the code returned by `run()`. + +--- + +## Build targets + +| Target | Type | Sources | Links | +|---|---|---|---| +| `gradient_motion` | static lib | `src/{time,gradient,motion,signal,osc}/*.cpp` | `nlohmann_json::nlohmann_json`, `liblo` | +| `gradient-motiond` | executable | `daemon/{main,GradientEngineApplication}.cpp`, `daemon/config/ConfigurationManager.cpp`, `daemon/comms/OscServer.cpp`, `src/engine/GradientEngine.cpp` | `gradient_motion`, `mtcreceiver`, `rtmidi`, `liblo`, `pthread`, optional `cuemslogger` | +| `mtcreceiver` | static lib (submodule) | `mtcreceiver/*.cpp` | `rtmidi`, optional `cuemslogger` | +| `cuemslogger` | static lib (submodule) | `cuemslogger/*.cpp` | system libs (`syslog`) | +| `test_*` | executables | `tests/test_*.cpp` | `gradient_motion` + per-test extras | + +CMake options: + +- `BUILD_DAEMON` (default `ON`) — set to `OFF` to build only the library and tests that don't need RtMidi. +- `ENABLE_CUEMS_LOGGER` (default `ON`) — set to `OFF` to use the stub logger. +- `MTCRECV_TESTING` (forced `ON` for the daemon build) — exposes `mtcreceiver`'s test-only helpers (`invokeTickForTesting`, `SkipPortOpenTag`). + +--- + +## Test layout + +| Test | Scope | Notes | +|---|---|---| +| [`test_curves`](https://github.com/stagesoft/gradient-motion-engine/blob/main/tests/test_curves.cpp) | `gme::gradient` | Curve boundary postconditions, parameter defaults, LUT continuity. | +| [`test_mtc_tick_source`](https://github.com/stagesoft/gradient-motion-engine/blob/main/tests/test_mtc_tick_source.cpp) | `gme::time` | Uses `invokeTickForTesting`; no MIDI hardware required. | +| [`test_lockfree_queue`](https://github.com/stagesoft/gradient-motion-engine/blob/main/tests/test_lockfree_queue.cpp) | `gme::signal::LockFreeQueue` | SPSC contract, drop-oldest, advisory size. | +| [`test_fade_motion`](https://github.com/stagesoft/gradient-motion-engine/blob/main/tests/test_fade_motion.cpp) | `gme::motion::FadeMotion` | Evaluation, snap-to-end, inheritFrom. | +| [`test_motion_registry`](https://github.com/stagesoft/gradient-motion-engine/blob/main/tests/test_motion_registry.cpp) | `gme::motion::MotionRegistry` | Duplicate-id rejection, supersede, OSC-failure threshold, cancel paths. | +| [`test_motion_registry_bench`](https://github.com/stagesoft/gradient-motion-engine/blob/main/tests/test_motion_registry_bench.cpp) | perf | Per-tick cost at saturation (≤50 active motions). | +| [`test_osc_parse`](https://github.com/stagesoft/gradient-motion-engine/blob/main/tests/test_osc_parse.cpp) | `gme::signal::parseFadeOscCommand` | 14 cases; pure parse-level, no network. | +| [`test_osc_server_integration`](https://github.com/stagesoft/gradient-motion-engine/blob/main/tests/test_osc_server_integration.cpp) | `gme::daemon::comms::OscServer` | 3 real-loopback cases using liblo's client side. | +| [`bench_osc_latency`](https://github.com/stagesoft/gradient-motion-engine/blob/main/tests/bench_osc_latency.cpp) | perf | `sendFloat` syscall latency on loopback. | + +End-to-end deploy tests on the running daemon live in [`dev/deploy_tests/`](https://github.com/stagesoft/gradient-motion-engine/tree/main/dev/deploy_tests): `s007_t034_smoke.sh`, `s007_t052_rate_limit.sh`, `s007_t063_multi_node.sh`, `s007_t065_avahi_resilience.sh`. + +--- + +## Cross-repo coupling + +| Repo | Coupling point | Direction | +|---|---|---| +| [`cuems-engine`](https://github.com/stagesoft/cuems-engine) (NodeEngine) | `GradientClient` sends `/gradient/start_fade` etc. over UDP to `127.0.0.1:`. | engine → gradient-motiond | +| [`cuems-utils`](https://github.com/stagesoft/cuems-utils) | `settings.xsd` defines `` on `NodeType`; `ConfigurationManager` reads it. | utils → gradient-motiond (config schema) | +| [`cuems-audioplayer`](https://github.com/stagesoft/cuems-audioplayer) | Receives `/volmaster` OSC float on its configured port. | gradient-motiond → audioplayer | +| [`cuems-videocomposer`](https://github.com/stagesoft/cuems-videocomposer) | Receives `/videocomposer/layer/{id}/opacity` OSC float. | gradient-motiond → videocomposer | +| [`mtcreceiver`](https://github.com/stagesoft/mtcreceiver) | Git submodule, pinned at `59fc76e`. v2.0.0 API contract. | dependency | +| [`cuemslogger`](https://github.com/stagesoft/cuemslogger) | Git submodule. Optional (`ENABLE_CUEMS_LOGGER=ON`). | dependency | +| [`cuems-common`](https://github.com/stagesoft/cuems-common) | Ships `cuems-gradient-motiond.service` systemd unit. | runtime dep (Debian package). | diff --git a/docs/index.md b/docs/index.md new file mode 100644 index 0000000..278c573 --- /dev/null +++ b/docs/index.md @@ -0,0 +1,224 @@ + + +# gradient-motion-engine + +**Timecode-driven motion and gradient evaluation engine for the CueMS system, with localhost UDP OSC input and OSC output.** + +[![License: GPL v3](https://img.shields.io/badge/License-GPLv3-blue.svg)](https://www.gnu.org/licenses/gpl-3.0) +[![Tests](https://github.com/stagesoft/gradient-motion-engine/actions/workflows/tests.yml/badge.svg)](https://github.com/stagesoft/gradient-motion-engine/actions/workflows/tests.yml) +[![Coverage](https://codecov.io/gh/stagesoft/gradient-motion-engine/graph/badge.svg)](https://codecov.io/gh/stagesoft/gradient-motion-engine) +[![Deploy API documentation](https://github.com/stagesoft/gradient-motion-engine/actions/workflows/docs.yml/badge.svg)](https://github.com/stagesoft/gradient-motion-engine/actions/workflows/docs.yml) + +!!! note "Project README" + For installation instructions, release history, and licensing, see the + [project README](https://github.com/stagesoft/gradient-motion-engine#readme) on GitHub. + +!!! note "API reference" + The full API reference is published at + [stagesoft.github.io/gradient-motion-engine](https://stagesoft.github.io/gradient-motion-engine/) + and built from `main` by [.github/workflows/docs.yml](../.github/workflows/docs.yml). + +--- + +## What is gradient-motion-engine? + +`gradient-motion-engine` is the per-node fade engine of the **CueMS** (Cue Management System). It runs as the `gradient-motiond` systemd service on every player node and drives parametric volume / opacity envelopes for the local audio and video players, locked to **MIDI Time Code**. + +Inbound, it accepts three OSC commands over localhost UDP from the local NodeEngine — `/gradient/start_fade`, `/gradient/cancel_motion`, `/gradient/cancel_all`. Outbound, it emits OSC float updates to `cuems-audioplayer` (`/volmaster`) and `cuems-videocomposer` (`/videocomposer/layer/{id}/opacity`) on every MTC quarter frame for as long as a motion is active. + +The repository ships two artifacts: + +| Component | Role | +|---|---| +| `libgradient_motion` (static library) | Curves, motion registry, OSC sender, MTC tick source, lock-free queue. Transport-agnostic core; no daemon headers. | +| `gradient-motiond` (executable) | Lifecycle orchestrator, CLI/env config, liblo UDP OSC listener, wires the library into the running daemon. | + +The library is link-only — there is no published C ABI. `GradientEngine.cpp` (which owns `OscServer`) is compiled into the daemon binary, not the library, so that consumers of the library are not forced to link liblo. + +--- + +## Signal flow + +``` +NodeEngine (cuems-engine) + │ + │ /gradient/start_fade (OSC, sssisffhiss) + │ /gradient/cancel_motion (OSC, ss) + │ /gradient/cancel_all (OSC, s) + ▼ +127.0.0.1: ◄─── liblo UDP listener + │ (gme::daemon::comms::OscServer, PIMPL) + │ parseFadeOscCommand + │ • type-tag match + │ • node_name filter (silent drop on mismatch) + │ • field validation + │ • curve_params JSON parse + ▼ +LockFreeQueue ◄─── SPSC, drop-oldest on full + │ + │ consumer: MTC tick thread + ▼ +MtcTickSource (mtcreceiver v2) + │ void onTick(long mtc_ms) ◄─── 100 Hz @ 25 fps + ▼ +MotionRegistry + │ 1. drain queue, apply(cmd) + │ • START_FADE → MotionFactory::fromCommand → addMotion + │ • CANCEL_MOTION → cancelMotion(id, snap_to_end) + │ • CANCEL_ALL → cancelAll() + │ 2. for each active motion: evalAndSend(mtc_ms) + │ 3. remove completed / dead (5 consec OSC failures) motions + ▼ +FadeMotion::evalAndSend + │ t = clamp((mtc_ms − start_mtc_ms) / duration_ms, 0, 1) + │ value = start + (end − start) · curve.evaluate(t) + │ oscSend(target, path, value) + ▼ +OSC float (UDP) + │ + ├──► cuems-audioplayer /volmaster + └──► cuems-videocomposer /videocomposer/layer/{id}/opacity +``` + +Numbered sequence for a single fade: + +1. NodeEngine sends `/gradient/start_fade` to `127.0.0.1:7100` with `node_name = "this-node"` and an OSC type tag of `sssisffhiss` (motion_id, node_name, osc_host, osc_port, osc_path, start_value, end_value, duration_ms, start_mtc_ms, curve_type, curve_params_json). +2. `OscServer` calls `parseFadeOscCommand` which type-checks, filters by node, validates required fields, and parses `curve_params_json`. On `ParseResult::Ok` the populated `FadeCommand` is pushed to the SPSC queue. +3. On the next MTC quarter-frame tick, `GradientEngine::onTick` drains the queue, calls `MotionRegistry::apply` per command. +4. For `START_FADE`, `MotionFactory::fromCommand` builds the curve via `CurveFactory::createCurve` (wrapped in a 256-sample LUT) and the `lo_address` via `gme::osc::makeAddress`, then constructs a `FadeMotion`. The registry inserts it; if the `"host:port:path"` key is already active, the prior motion is superseded and the new motion inherits its last-sent value. +5. Every subsequent tick, the registry calls `FadeMotion::evalAndSend(mtc_ms)`. When `t ≥ 1.0`, `EvalResult::completed` is `true`, the registry emits a `MotionComplete` status, and removes the motion. + +--- + +## Architecture + +### `gme::time` — MTC tick source + +[`src/time/`](https://github.com/stagesoft/gradient-motion-engine/tree/main/src/time) + +- **`MtcTickSource`** — Adapter over `mtcreceiver` v2.0.0. Translates v2's `void(long, bool)` to a plain `void(long mtc_ms)` so the engine doesn't see the `isCompleteFrame` flag. Lifecycle: `setTickCallback` → `start("MTC")` → `~MtcTickSource()` (blocks until any in-flight callback returns). One instance per process — `mtcreceiver` keeps process-global state. +- **`MtcStartError`** — Enum return type from `start()`: `kOk`, `kNoPortsAvailable`, `kPortNotFound`. No exceptions cross the library boundary. + +### `gme::gradient` — Curve evaluation + +[`src/gradient/`](https://github.com/stagesoft/gradient-motion-engine/tree/main/src/gradient) + +- **`Curve`** — Abstract interface; `evaluate(double t) const → double`. Concrete implementations clamp `t` to `[0,1]` internally. Boundary postcondition: `evaluate(0.0) == 0.0` and `evaluate(1.0) == 1.0` for all bundled curves. +- **`LinearCurve`**, **`SigmoidCurve`**, **`BezierCurve`**, **`EaseInCurve`**, **`EaseOutCurve`**, **`SCurve`** — Concrete shapes. +- **`ScaledCurve`** — Decorator that scales another curve's output range. +- **`ResampledCurve`** — Decorator that pre-samples any curve into a 256-entry LUT. Every curve returned by `CurveFactory` is wrapped in this so the hot path is constant-time. +- **`CrossfadePair`** — Two-curve container reserved for the deferred crossfade motion type. +- **`CurveFactory`** — Single construction entry point: `createCurve(type, params) → std::optional>`. Unknown types return `nullopt` so the caller decides whether to fall back to linear or reject the command. + +### `gme::motion` — Motion lifecycle and registry + +[`src/motion/`](https://github.com/stagesoft/gradient-motion-engine/tree/main/src/motion) + +- **`IMotion`** — Abstract base. Common public fields: `motion_id`, `osc_key`, `start_mtc_ms`, `duration_ms`, `completed`, `consecutive_osc_failures`. Three virtuals: `evalAndSend`, `sendSnapToEnd`, `inheritFrom`. Type-specific state (curve, transport handle) lives in derived classes only. +- **`EvalResult`** — POD `{completed, failed, failure_reason}` returned from `evalAndSend`. `failure_reason` is a static-storage `const char*` to keep the hot path heap-free. +- **`FadeMotion`** — Concrete scalar-fade. Owns a pre-resampled `Curve`, scalar `start_value` / `end_value` / `last_sent_value`, and a pre-built `lo_address`. `inheritFrom` copies the prior fade's `last_sent_value` into `start_value` and `last_sent_value` to avoid a jump on supersede. Type-mismatched supersede (e.g. a future `VectorMotion<3>` superseding a `FadeMotion`) is a no-op. +- **`MotionFactory`** — Stateless construction site. The single place in the codebase that calls `CurveFactory::createCurve` and `lo_address_new`. On construction failure (`unknown_curve_type`, `osc_address_failed`) it emits a status event and returns `nullptr`; the registry never calls `addMotion(nullptr)`. +- **`MotionRegistry`** — Owns every live motion. Two indexes: `motions_` (`motion_id → IMotion`) and `osc_index_` (`"host:port:path" → motion_id`). `addMotion` runs ordered checks: duplicate-`motion_id` guard, then `osc_key` supersede, then insert. `tick(mtc_ms)` evaluates every motion, increments `consecutive_osc_failures` on failed sends, declares dead at `kOscFailureThreshold = 5` consecutive failures (25 ms of silence at 200 Hz), and removes completed/dead motions in a single pass. + +### `gme::signal` — Commands, parser, status queue + +[`src/signal/`](https://github.com/stagesoft/gradient-motion-engine/tree/main/src/signal) + +- **`FadeCommand`** — Plain aggregate. Carries every field needed for `START_FADE`, `CANCEL_MOTION`, `CANCEL_ALL`, and (deferred) `START_CROSSFADE`. The sole payload type moved between the OSC thread and the tick thread. +- **`ParseResult`** — Outcome enum. `Ok` is the only success; `NodeMismatch` is a silent drop; `MissingField`/`TypeError` log a warning; `UnknownCommand` logs at warning level. +- **`StatusKind`** — Discriminates `MotionComplete` from `MotionError` for journal logging. +- **`parseFadeOscCommand`** — Free function (`gme::signal`). Dispatches on the OSC address, validates the type tag against the address's required signature, applies the `node_name` filter, validates required fields, and parses `curve_params_json` via `nlohmann::json`. Partial-population rule: `motion_id` and `type` are set before any early return so callers can log the offending command. +- **`LockFreeQueue`** — Fixed-capacity SPSC ring buffer. Zero heap allocation after construction. Drop-oldest on full (returns `false` so the caller can log the overflow). Usable capacity is `N - 1` (one slot reserved to distinguish empty from full without a third atomic). + +### `gme::osc` — Transport sender + +[`src/osc/`](https://github.com/stagesoft/gradient-motion-engine/tree/main/src/osc) + +- **`sendFloat(lo_address, const char* path, float)`** — Stateless liblo wrapper, `noexcept`. UDP fire-and-forget on loopback; ~1–5 µs per call. Safe from the MTC tick thread under the documented loopback assumption. +- **`makeAddress(host, port)`** — C++-friendly wrapper over `lo_address_new`. Caller owns the returned handle and must call `lo_address_free` on destruction (in practice, `FadeMotion`'s destructor). + +### `gme::engine` — Orchestrator + +[`src/engine/`](https://github.com/stagesoft/gradient-motion-engine/tree/main/src/engine) + +- **`GradientEngine`** — Owns `MtcTickSource`, `LockFreeQueue`, `OscServer`, and `MotionRegistry`. `initialize({midiPort, oscPort, nodeName})` opens MIDI, binds the OSC socket, and starts the listener; `shutdown()` deregisters the tick callback, cancels every motion (no final OSC), and joins the liblo thread. `OscServer` is forward-declared in the header so consumers of `libgradient_motion` don't transitively pull in liblo or daemon headers. + +### Daemon layer + +[`daemon/`](https://github.com/stagesoft/gradient-motion-engine/tree/main/daemon) + +- **`GradientEngineApplication`** — Lifecycle states `Constructed → Initialized → Running → Shutting Down → Destroyed`. Installs SIGTERM/SIGINT handlers; owns `ConfigurationManager` and the optional `CuemsLogger`. +- **`ConfigurationManager`** — `getopt_long` parser. Flags: `--midi-port`, `--log-level`, `--conf-path`, `--osc-port`, `--node-name`, `--help`, `--version`. OSC port priority: CLI flag → `CUEMS_GRADIENT_OSC_PORT` env → `settings.xml`'s `` → compile-time default `7100`. +- **`gme::daemon::comms::OscServer`** — liblo UDP listener. Binds `127.0.0.1:` only — never a routable interface. PIMPL pattern keeps liblo headers out of library consumers. Three method handlers: `/gradient/start_fade` (type tag `sssisffhiss`), `/gradient/cancel_motion` (`ss`), `/gradient/cancel_all` (`s`). + +--- + +## Key design decisions + +### Real-time safety: zero allocation on the tick path + +The MTC quarter-frame callback is the system's hardest deadline (100 Hz at 25 fps, 200 Hz at 50 fps). On the tick path, `MotionRegistry::tick` calls `IMotion::evalAndSend` for every active motion, which evaluates the curve LUT, computes the lerp, and calls `lo_send`. None of these steps allocate. The LUT is built once at `START_FADE` time; the `lo_address` is built once at the same moment. The SPSC queue is fixed-size in-class storage. + +**Invariant:** no path from `onTick` through `evalAndSend` to `lo_send` allocates or blocks. + +**Why not a thread-pool/futures-based design?** A scheduled pool would shift the deadline from "before next quarter-frame" to "before pool dispatch latency" — opaque, jittery, and untestable. The tick callback is already the right place to do the work because `mtcreceiver` provides the precise scheduling we need. + +### Localhost-only inbound transport + +`OscServer` binds `127.0.0.1` explicitly. Even if the host has a routable interface, the listener will not accept commands from it. + +**Invariant:** `gradient-motiond` accepts commands only from processes running on the same machine. + +**Why not bind 0.0.0.0?** Inbound network commands belong to the NodeEngine layer (`cuems-engine`), which authenticates and authorises against the node fleet topology. Letting `gradient-motiond` accept network OSC directly would duplicate that responsibility and create a bypass. + +### Exceptions do not cross the library boundary + +Every fallible operation in `libgradient_motion` returns an enum or `std::optional`: `MtcStartError`, `ParseResult`, `EvalResult::failed`, `CurveFactory::createCurve → std::optional>`. Constructors that need to fail (e.g. `lo_address_new` returning `nullptr`) are pushed into `MotionFactory::fromCommand`, which returns `nullptr` on failure. + +**Invariant:** `libgradient_motion` callers never need a `try/catch` to use the library safely. + +**Why not exceptions?** The daemon links against `cuemslogger` and `mtcreceiver`, both of which use `noexcept` interfaces. Mixing exception throwers into the tick path would force `noexcept(false)` propagation through every callback and defeat compiler optimisations on the hot path. + +### Open/closed for motion types + +New motion kinds (e.g. `VectorMotion<3>` for RGB cues, `PoseMotion` for spatial trajectories) subclass `IMotion` and register through `MotionFactory::fromCommand`. `MotionRegistry` is sealed — it does not change shape when a new motion type is added. + +**Invariant:** adding a new motion type touches three files: `IMotion`'s subclass header, its `.cpp`, and the switch in `MotionFactory::fromCommand`. + +**Why not a registry of registries?** A type-erased switch in one factory is simpler than a polymorphic registration mechanism. The set of motion types is small and stable; OCP without overengineering. + +### Supersede inherits state via `inheritFrom` + +When a new `FadeMotion` is added on a `"host:port:path"` already covered by an active fade, the old motion is removed and `new_motion->inheritFrom(old_motion)` is called. `FadeMotion::inheritFrom` dynamic-casts to `const FadeMotion*` and, on success, copies `prior.last_sent_value` into `this->start_value` and `this->last_sent_value`. Type-mismatched supersede is a no-op (the new motion starts from its declared `start_value`). + +**Invariant:** the OSC output stream on a given `"host:port:path"` is continuous across supersede — no value jumps. + +**Why not snap to the new motion's `start_value`?** The cue designer's intent is "go from where we are to the new target", not "jump to the new start". Letting the old `last_sent_value` set the new fade's effective `start_value` preserves that intent. + +### `OscServer` lives in the daemon layer, not the library + +`OscServer` depends on liblo and on the `parseFadeOscCommand` parser. `GradientEngine.h` forward-declares `OscServer` and `GradientEngine.cpp` is compiled into the daemon binary, not the library. This means a library consumer that wants to drive the engine with a non-OSC transport can do so without linking liblo. + +**Invariant:** `libgradient_motion`'s link closure does not contain liblo unless an OSC-using consumer pulls it in transitively. + +**Why not put `OscServer` in the library?** Coupling the library's link closure to liblo would force every embedder to depend on it, even when they don't use OSC input. The forward-declaration trick keeps the option open. + +--- + +## API reference + +The API reference is published at [stagesoft.github.io/gradient-motion-engine](https://stagesoft.github.io/gradient-motion-engine/) and built from `main`. Build it locally with: + +```bash +pip install mkdocs mkdocs-material mkdoxy +mkdocs serve +# Browse to http://127.0.0.1:8000 +``` + +The pages below are the hand-written architecture overview: + +- [Architecture](architecture.md) — module-by-module component graph and dependencies. +- [API synopsis](api.md) — public header surface of `libgradient_motion` and the daemon. diff --git a/mkdocs.yml b/mkdocs.yml new file mode 100644 index 0000000..6217d1e --- /dev/null +++ b/mkdocs.yml @@ -0,0 +1,27 @@ +# SPDX-FileCopyrightText: 2026 Stagelab Coop SCCL +# SPDX-License-Identifier: GPL-3.0-or-later + +site_name: gradient-motion-engine +repo_url: https://github.com/stagesoft/gradient-motion-engine +theme: + name: material + +plugins: + - search + - mkdoxy: + projects: + gradient-motion-engine: + src-dirs: "src daemon" + full-doc: True + doxy-cfg: + PROJECT_NAME: "gradient-motion-engine" + PROJECT_BRIEF: "Node-level fade engine with mathematical curve evaluation" + FILE_PATTERNS: "*.h *.hpp" + RECURSIVE: True + EXTRACT_ALL: True + EXTRACT_PRIVATE: False + EXTRACT_STATIC: True + GENERATE_HTML: False + GENERATE_LATEX: False + GENERATE_XML: True + WARN_AS_ERROR: "NO" diff --git a/specs/planning/documentation-prompt.md b/specs/planning/documentation-prompt.md new file mode 100644 index 0000000..535c5e1 --- /dev/null +++ b/specs/planning/documentation-prompt.md @@ -0,0 +1,679 @@ +# Documentation Generation Prompt — CueMS / StageLab Repositories + +**Use this document as a self-contained prompt when generating or updating documentation +for any repository in the CueMS / StageLab family.** + +Read it in full before touching any file. It unifies three sequential tasks: +(1) README + CHANGELOG + docs site, (2) CONTRIBUTORS, (3) CI test + coverage workflow. +All three must be completed in one pass. + +--- + +## 0. Prerequisite reads + +Before writing anything, read these files in the **target repo** and the **reference repos**: + +``` +Target repo (the one you are working in): + README.md — current state + CHANGELOG.md — current state + pyproject.toml OR CMakeLists.txt — package/build metadata, authors, version + docs/index.md — current state (if it exists) + docs/*.md — all existing submodule pages + .github/workflows/*.yml — every existing workflow + mkdocs.yml — if it exists + src/ OR include/ OR lib/ — module/class structure (scan, don't read in full) + git log --oneline -40 — recent commit history + git log --pretty=format:"=== %H %s ===%n%b" -20 — commit messages with bodies + +Reference repos (read for structure and tone — paths are relative to the target repo): + ../cuems-utils/README.md — canonical Python library README + ../cuems-utils/CHANGELOG.md — canonical CHANGELOG format + ../cuems-utils/CONTRIBUTORS.md — canonical CONTRIBUTORS format + ../cuems-utils/docs/index.md — canonical MkDocs index format + ../cuems-utils/.github/workflows/tests.yml — canonical CI + coverage workflow + ../cuems-engine/README.md — canonical Python service README + ../cuems-engine/docs/index.md — canonical service docs index + ../gradient-motion-engine/README.md — canonical C++ daemon README +``` + +Identify the repo's **ecosystem** from the build metadata: + +| Indicator | Ecosystem | +|---|---| +| `pyproject.toml` with `hatch` build backend | Python library (`cuems-utils` pattern) | +| `pyproject.toml` with `poetry` build backend | Python service (`cuems-engine` pattern) | +| `CMakeLists.txt` | C++ daemon (`gradient-motion-engine` pattern) | + +Ecosystem determines which badges, install instructions, test commands, and CI +steps apply. Adaptations are described in [§8 Ecosystem adaptations](#8-ecosystem-adaptations). + +--- + +## 1. Deliverables checklist + +Complete every item. Do not skip any. + +- [ ] `README.md` — full overhaul (§2) +- [ ] `CHANGELOG.md` — add entry for current unreleased changes (§3) +- [ ] `docs/index.md` — architecture + design decisions (§4) +- [ ] `docs/*.md` submodule pages — ensure every public class is referenced (§5) +- [ ] `CONTRIBUTORS.md` — contributing guidelines (§6) +- [ ] `.github/workflows/tests.yml` — CI test + coverage upload (§7) +- [ ] Badge additions — Tests + Coverage badges in both `README.md` and `docs/index.md` +- [ ] `pyproject.toml` or build config — update `Documentation` URL if wrong (§8) +- [ ] `mkdocs.yml` — fix `repo_url` if it points to a wrong org (§8) + +--- + +## 2. README.md + +### 2.1 Required structure + +Follow the exact section order below. Use the canonical examples as reference for +tone and depth. + +``` + + +# + +**Current release: vX.Y.ZrcN** — see [CHANGELOG.md](./CHANGELOG.md). + +**** + + + +* **Source / issues:** [stagesoft/](https://github.com/stagesoft/) on GitHub +* **API reference (HTML):** [stagesoft.github.io/](https://stagesoft.github.io//) + +<2–4 sentence description of what the repo is and what it provides> + +It is composed of: + * **``** — + * ... + +--- + +## Overview + + + +--- + +## Architecture + + +--- + +## Core Concepts + <5–8 key terms that a new reader must understand, one bullet each> + +--- + +## Design Goals + <5–8 principles the code enforces, one bullet each> + +--- + +## Installation + <§2.3 Installation section> + +--- + +## Development + <§2.4 Development section> + +--- + +## Release notes + See [CHANGELOG.md](./CHANGELOG.md) for the full history. + + +--- + +## Copyright notice + + +--- + +## License + +``` + +### 2.2 Required badges + +**Python repos (hatch or poetry):** + +```markdown +[![PyPI - Version](https://img.shields.io/pypi/v/.svg)](https://pypi.org/project/) +[![PyPI - Python Version](https://img.shields.io/pypi/pyversions/.svg)](https://pypi.org/project/) +[![License: GPL v3](https://img.shields.io/badge/License-GPLv3-blue.svg)](https://www.gnu.org/licenses/gpl-3.0) +[![Tests](https://github.com/stagesoft//actions/workflows/tests.yml/badge.svg)](https://github.com/stagesoft//actions/workflows/tests.yml) +[![Coverage](https://codecov.io/gh/stagesoft//graph/badge.svg)](https://codecov.io/gh/stagesoft/) +[![Deploy MkDocs site](https://github.com/stagesoft//actions/workflows/gh-pages.yml/badge.svg)](https://github.com/stagesoft//actions/workflows/gh-pages.yml) +[![Upload Python Package](https://github.com/stagesoft//actions/workflows/pypi-publish.yml/badge.svg)](https://github.com/stagesoft//actions/workflows/pypi-publish.yml) +``` + +**C++ repos (no PyPI, no Python version):** + +```markdown +[![License: GPL v3](https://img.shields.io/badge/License-GPLv3-blue.svg)](https://www.gnu.org/licenses/gpl-3.0) +[![Tests](https://github.com/stagesoft//actions/workflows/tests.yml/badge.svg)](https://github.com/stagesoft//actions/workflows/tests.yml) +[![Coverage](https://codecov.io/gh/stagesoft//graph/badge.svg)](https://codecov.io/gh/stagesoft/) +``` + +Add a workflow CI badge only if the corresponding workflow file exists. Create +`tests.yml` (§7) before adding the Tests and Coverage badges. + +### 2.3 Installation section + +**Python library (hatch):** +```markdown +### PyPI +pip install +Optional extras: pip install "[systemd]" / "[all]" + +### Debian package +git clone --branch debian/bookworm https://github.com/stagesoft/.git +dpkg-buildpackage -us -uc +sudo dpkg -i ../python3-_*.deb +``` + +**Python service (poetry):** +Two packages if applicable (core + mock/dev binaries). List system-package +dependencies installed automatically by the .deb. + +**C++ daemon:** +```markdown +### Build from source +git clone https://github.com/stagesoft/.git +mkdir build && cd build +cmake .. -DCMAKE_BUILD_TYPE=Release +make -j$(nproc) +sudo make install + +### Debian package +git clone --branch debian/bookworm https://github.com/stagesoft/.git +dpkg-buildpackage -us -uc +sudo dpkg -i ../_*.deb + +### systemd service +systemctl enable +systemctl start +``` + +### 2.4 Development section + +**Python (hatch):** +```markdown +pip install -e ".[all]" +cd src && pytest +pytest --cov= +pytest -W error::DeprecationWarning # if applicable +hatch test # full 3.11/3.12/3.13 matrix +ruff check . +``` + +**Python (poetry):** +```markdown +poetry install +poetry run pytest +poetry run pytest --cov=src +poetry run black --check src/ tests/ +poetry run isort --check src/ tests/ +``` + +**C++:** +```markdown +mkdir build && cd build +cmake .. -DCMAKE_BUILD_TYPE=Debug -DENABLE_COVERAGE=ON +make -j$(nproc) +ctest --output-on-failure +``` + +### 2.5 Architecture section depth + +For each top-level module or subdirectory in `src/`, write: +- A `###` heading with the module path +- One bullet per exported class/function: `**ClassName**` — one-sentence role +- Cross-reference any class that is consumed by a sibling repo + +Derive class descriptions from: docstrings, class names, and recent commit messages. +Do not invent behaviour — only document what the code demonstrably does. + +### 2.6 Release notes section + +Condense the last 3–4 CHANGELOG entries into one paragraph each (3–5 sentences max). +Keep the full history in CHANGELOG.md; the README section is a summary for new visitors. + +--- + +## 3. CHANGELOG.md + +### 3.1 Format + +```markdown +# Changelog + +## + +. + +### Added +- . + +### Changed (breaking) +- .: . Migration: . + +### Fixed +- .: . Root cause: . + +### Removed +- — deprecated since . Zero callers confirmed; superseded by . + +### Notes +- . +``` + +### 3.2 Finding unreleased changes + +Run: +```bash +git log --pretty=format:"=== %H %s ===%n%b" -20 +``` + +Look for commits since the last tagged/versioned CHANGELOG entry. Group them by +`feat`, `fix`, `chore`, `patch`, `refactor` prefix into the appropriate sections. +Use commit message bodies — they contain the precise semantic detail needed. + +Today's date is the release date. Read the version string from `__init__.py`, +`pyproject.toml`, or `CMakeLists.txt` (whichever applies). + +--- + +## 4. docs/index.md + +### 4.1 Required structure + +Follow `../cuems-engine/docs/index.md` for a Python service and +`../cuems-utils/docs/index.md` for a Python library as the primary templates. + +``` +# + + + + + +!!! note "Project README" + For installation instructions, release history, and licensing, see the + [project README](https://github.com/stagesoft/#readme) on GitHub. + +--- + +## What is ? + <2–4 paragraph description, more detailed than README overview> + + +--- + +## Signal flow (or Data flow / Pipeline) + + + +--- + +## Architecture + + + +--- + +## Key design decisions + <3–6 subsections, one per non-obvious design choice> + + +--- + +## API reference + +``` + +### 4.2 Signal/data flow diagram + +Capture the primary runtime path as an ASCII diagram. For multi-process systems, +show inter-process transport (NNG, OSC, D-Bus, …). For libraries, show how data +enters and exits the public API. + +Example pattern (adapt to the actual repo): +``` +Input ──► Component A ──► Component B ──► Output + │ │ + (protocol) (storage) +``` + +### 4.3 Key design decisions depth + +Each decision section must contain: +- What the decision is (1 sentence) +- The concrete invariant it enforces (1 sentence, testable) +- Why the alternative was rejected or deferred (1 sentence) + +Do not write generic "we value X" statements. Write specific, verifiable claims +about this codebase. + +--- + +## 5. docs/*.md submodule pages + +### 5.1 Python repos — mkdocstrings + +Each existing `docs/.md` page must contain a `:::` directive for every +public class in the corresponding source module. Audit by listing all `.py` files +in the module directory and comparing against the directives already present. + +Template: +```markdown +# + +::: .. +::: .. +``` + +Add missing directives; do not remove existing ones. Alphabetical order within +a module page is preferred. + +### 5.2 C++ repos — architecture docs + +C++ repos without MkDocs/Doxygen should have at minimum: +- `docs/index.md` (§4 above) +- `docs/architecture.md` — component diagram and dependency graph +- `docs/api.md` — public header synopsis (manually written, not generated) + +If the repo has no `mkdocs.yml`, create one following `../cuems-utils/mkdocs.yml` +as a template. Omit the `mkdocstrings` plugin for C++ (no Python source to parse). + +--- + +## 6. CONTRIBUTORS.md + +### 6.1 Base structure + +Use `../cuems-utils/CONTRIBUTORS.md` as the canonical template. It must contain +these 14 sections in order: + +1. Prerequisites +2. Development Setup +3. Contribution Tiers (Tier 1 trivial / Tier 2 non-trivial) +4. Branch Naming +5. Spec-First Requirement +6. TDD Workflow — Non-Negotiable +7. Commit Hygiene (Conventional Commits v1.0) +8. Developer Certificate of Origin (DCO) +9. Pull Request Requirements +10. Acceptance Criteria +11. Review Process +12. Changelog Line +13. Dependency Governance +14. License + +### 6.2 Ecosystem adaptations + +**Python (hatch)** — copy `../cuems-utils/CONTRIBUTORS.md` verbatim, then change: +- Repo name/URL throughout +- Package name in the examples +- `hatch run test:run-cov` stays as the test command +- `ruff check` stays as the lint command +- Any pinned dependency footnotes specific to this repo (e.g. lxml CVE note) +- Acceptance criteria table: replace hatch-specific rows if the test runner differs + +**Python (poetry)** — use `../cuems-utils/CONTRIBUTORS.md` as a base, then change: +- Test commands to `poetry run pytest` / `poetry run pytest --cov=src` +- Lint commands to `poetry run black`, `poetry run isort`, `poetry run flake8` +- Acceptance criteria: `black --check`, `isort --check`, `flake8` gates instead of `ruff` +- Prerequisites table: `poetry ≥ 1.7` instead of `hatch` + +**C++** — use `../cuems-utils/CONTRIBUTORS.md` as a base, then change: +- Prerequisites: `cmake ≥ 3.20`, `gcc ≥ 12`, no Python tooling +- Development Setup: `cmake .. && make && ctest` +- Lint: `clang-format --dry-run` and/or `clang-tidy` +- Acceptance criteria: replace Python-specific gates with C++ equivalents (AddressSanitizer, valgrind if applicable) +- Remove the "Dependency Governance / pyproject.toml" section; replace with CMake dependency guidance + +### 6.3 Mandatory invariants (all ecosystems) + +These must not change between repos: +- Spec-first requirement for Tier 2 changes +- TDD sequence (failing test → implementation → refactor) +- DCO sign-off on every commit +- PR target is `main` (not `master`) +- Review by Ion Reguera ([@ibiltari](https://github.com/ibiltari)) or Adrià Masip ([@backenv](https://github.com/backenv)) +- SPDX header on all new source files +- SPDX header in CONTRIBUTORS.md itself + +--- + +## 7. .github/workflows/tests.yml + +### 7.1 Python repos (hatch) + +Create `.github/workflows/tests.yml` following `../cuems-utils/.github/workflows/tests.yml` +verbatim, replacing the package name and org references. The canonical file is: + +```yaml +name: Tests + +on: + push: + branches: + - main + pull_request: + +permissions: + contents: read + +jobs: + test: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.11" + cache: "pip" + + - name: Install hatch and coverage + run: pip install hatch coverage[toml] + + - name: Run tests with coverage + run: hatch run test:run-cov + + - name: Generate coverage XML + run: coverage xml + + - name: Upload coverage to Codecov + uses: codecov/codecov-action@v4 + with: + files: coverage.xml + fail_ci_if_error: false + env: + CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} +``` + +**Python repos (poetry):** replace the hatch steps with: +```yaml + - name: Install dependencies + run: poetry install + + - name: Run tests with coverage + run: poetry run pytest --cov=src --cov-report=xml + + - name: Upload coverage to Codecov + uses: codecov/codecov-action@v4 + with: + files: coverage.xml + fail_ci_if_error: false + env: + CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} +``` + +### 7.2 C++ repos + +```yaml +name: Tests + +on: + push: + branches: + - main + pull_request: + +permissions: + contents: read + +jobs: + test: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - name: Install build dependencies + run: sudo apt-get install -y cmake gcc g++ lcov + + - name: Configure with coverage + run: | + cmake -B build \ + -DCMAKE_BUILD_TYPE=Debug \ + -DCMAKE_CXX_FLAGS="--coverage" \ + -DCMAKE_EXE_LINKER_FLAGS="--coverage" + + - name: Build + run: cmake --build build -j$(nproc) + + - name: Run tests + run: ctest --test-dir build --output-on-failure + + - name: Generate coverage report + run: | + lcov --capture --directory build \ + --output-file coverage.info \ + --ignore-errors inconsistent + lcov --remove coverage.info '/usr/*' '*/tests/*' \ + --output-file coverage.info + + - name: Upload coverage to Codecov + uses: codecov/codecov-action@v4 + with: + files: coverage.info + fail_ci_if_error: false + env: + CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} +``` + +Adapt the `cmake` flags and `lcov` invocation to whatever build system the repo +actually uses. If it uses `meson`, `bazel`, or another tool, replace accordingly. + +### 7.3 Existing workflow conflicts + +If a `gh-pages.yml` already exists and uses `mkdocs gh-deploy --force`, do NOT +add a second workflow that also commits to the gh-pages branch — the `--force` +flag will wipe anything the second workflow committed. Keep CI (`tests.yml`) and +docs (`gh-pages.yml`) as separate concerns. + +If the existing `gh-pages.yml` has a wrong `repo_url` in `mkdocs.yml` or deploys +to the wrong org, fix `mkdocs.yml` at the same time as adding `tests.yml`. + +### 7.4 Badge wiring + +After creating `tests.yml`, add both new badges to `README.md` and `docs/index.md` +immediately below the existing badges. Do not reorganise the existing badge order — +append the two new lines after the last current badge. + +```markdown +[![Tests](https://github.com/stagesoft//actions/workflows/tests.yml/badge.svg)](https://github.com/stagesoft//actions/workflows/tests.yml) +[![Coverage](https://codecov.io/gh/stagesoft//graph/badge.svg)](https://codecov.io/gh/stagesoft/) +``` + +**One-time manual step:** the Codecov badge is dark until the repository is +activated at [codecov.io](https://codecov.io). Go to +`https://codecov.io/gh/stagesoft/`, sign in with GitHub, and click +"Activate". The first successful `tests.yml` run will populate the badge. + +--- + +## 8. Ecosystem adaptations — common fixes + +Regardless of ecosystem, always check and fix these if wrong: + +### pyproject.toml (Python repos) + +```toml +[project.urls] +Documentation = "https://stagesoft.github.io//" +``` + +### mkdocs.yml (Python repos with MkDocs) + +```yaml +site_name: +repo_url: https://github.com/stagesoft/ # not cuems/ or any other org +``` + +The `admonition` markdown extension should be enabled if `docs/index.md` uses +`!!! note` blocks: + +```yaml +markdown_extensions: + - admonition +``` + +### SPDX headers (all repos, all new files) + +Every new file created by this documentation pass must start with: + +``` +# SPDX-FileCopyrightText: Stagelab Coop SCCL +# SPDX-License-Identifier: GPL-3.0-or-later +``` + +Use `` wrapping for Markdown files (see README.md canonical example). + +--- + +## 9. Quality checks before finishing + +Run these mentally against every output file: + +1. **All relative links resolve** — every `[text](../path)` or `[text](./path)` points + to a file that actually exists in this repo or a sibling repo. + +2. **No placeholder text left** — search for ``, ``, ``, + `REPLACE-WITH-`, `TODO`, or any angle-bracket placeholder. Replace all. + +3. **Badges match actual workflow file names** — the badge URL contains the exact + filename of the workflow (e.g., `tests.yml` not `test.yml` or `ci.yml`). + +4. **CHANGELOG version matches `__init__.py` / `pyproject.toml` / `CMakeLists.txt`** + — they must agree on the current version string. + +5. **docs/*.md class references are importable** — for Python repos, every + `:::` directive must match a real importable path. Check against the actual + source file structure, not memory. + +6. **CONTRIBUTORS.md review links are live** — the GitHub handles + `@ibiltari` and `@backenv` are unchanged; the repo-specific Discussion and + Issues links use the correct `stagesoft/` org/name. + +7. **No duplication in architecture diagram** — the ASCII box in README.md + `Overview` must not list a class twice (see e.g. `CTimecodeTimer` appearing in + both `tools/` and `CuemsScript` rows — a known regression to avoid). From ae1493d115cc2e541f81b7eb4ce74a4dad981338 Mon Sep 17 00:00:00 2001 From: adria Date: Tue, 26 May 2026 12:42:39 +0200 Subject: [PATCH 2/3] test(mtc): error-proof MtcTickSource for action workers --- src/time/MtcTickSource.cpp | 25 +++++++++++++++---------- 1 file changed, 15 insertions(+), 10 deletions(-) diff --git a/src/time/MtcTickSource.cpp b/src/time/MtcTickSource.cpp index 59ac741..e84aea5 100644 --- a/src/time/MtcTickSource.cpp +++ b/src/time/MtcTickSource.cpp @@ -38,18 +38,23 @@ MtcStartError MtcTickSource::start(const std::string& midiPort) { MtcReceiver::setNetworkMode(true); // Scan available ports for a name containing midiPort. - RtMidiIn probe; - unsigned int nPorts = probe.getPortCount(); - if (nPorts == 0) { - return MtcStartError::kNoPortsAvailable; - } - + // RtMidiIn() throws RtMidiError when the ALSA/MIDI subsystem is absent + // (e.g., headless CI runners with no /dev/snd/seq). unsigned int portIndex = UINT_MAX; - for (unsigned int i = 0; i < nPorts; ++i) { - if (probe.getPortName(i).find(midiPort) != std::string::npos) { - portIndex = i; - break; + try { + RtMidiIn probe; + unsigned int nPorts = probe.getPortCount(); + if (nPorts == 0) { + return MtcStartError::kNoPortsAvailable; } + for (unsigned int i = 0; i < nPorts; ++i) { + if (probe.getPortName(i).find(midiPort) != std::string::npos) { + portIndex = i; + break; + } + } + } catch (const RtMidiError&) { + return MtcStartError::kNoPortsAvailable; } if (portIndex == UINT_MAX) { return MtcStartError::kPortNotFound; From e33d04d2d4b8ddb29ed0490abf686d7be2bd1cd1 Mon Sep 17 00:00:00 2001 From: adria Date: Tue, 26 May 2026 13:04:20 +0200 Subject: [PATCH 3/3] actions(checks): rerun checks on PRs updates --- .github/workflows/docs.yml | 2 ++ .github/workflows/tests.yml | 5 +++-- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index 57018d6..b1bbdc5 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -10,6 +10,8 @@ name: Deploy documentation on: push: branches: [main] + pull_request: + types: [opened, synchronize, reopened, ready_for_review] workflow_dispatch: concurrency: diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index a62bfed..0ffa2e2 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -7,9 +7,10 @@ name: Tests on: push: - branches: - - main + branches: [main] pull_request: + types: [opened, synchronize, reopened, ready_for_review] + workflow_dispatch: permissions: contents: read