Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
74 changes: 74 additions & 0 deletions .github/workflows/build.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
# The release build. The plugin catalog (sd88me/mpc-vst-plugins) lists only plugins that need glibc 2.32 or
# less, so they load on MPC OS 2.x as well as 3.x: the device build runs in arm32v7/gcc:11-bullseye (GCC 11,
# glibc 2.31) under QEMU, as the catalog's own ports do, profile-guided, and the test suite runs against the
# objects the .so is linked from. The sanitizer suite runs on x86. The zip is checked with the catalog's
# checker and kept as an artifact; a vX.Y.Z tag also publishes it as a GitHub release (a prerelease while the
# version is 0.x: the catalog's beta channel), with CHANGELOG.md's section for the version as its notes.
name: build

on:
push:
branches: ["**"]
tags: ["v*"]
pull_request:

permissions:
contents: read

jobs:
test:
name: Test suite (x86, ASan/UBSan)
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v4
- run: make test

device:
name: Device build (armhf, glibc 2.31, PGO), package, catalog check
runs-on: ubuntu-24.04
permissions:
contents: write # the release, on a tag
steps:
- uses: actions/checkout@v4
# What doesn't depend on the CPU is built here, natively: the generated sources and the skin. The ARM
# container then needs nothing beyond its own compiler (bullseye's package mirrors are going to the archive).
- name: Generated sources and skin
run: |
sudo apt-get update -qq
sudo apt-get install -y -qq --no-install-recommends python3-pil
make surface skin
- uses: docker/setup-qemu-action@v3
with:
platforms: arm
- name: Device build and the suite on ARM, in arm32v7/gcc:11-bullseye
run: >-
docker run --rm --platform linux/arm/v7 -v "$PWD:/w" -w /w arm32v7/gcc:11-bullseye
make ARM_PREFIX= ARM_RUN= PGO=1 arm-plugin test-arm-pgo
- name: Package
run: |
case "$GITHUB_REF" in refs/tags/v*) version="PLUGIN_VERSION=${GITHUB_REF_NAME#v}" ;; *) version= ;; esac
make PGO=1 $version plugin-package # the .so above is up to date; a rebuild here would fail loudly (no ARM compiler)
- name: Catalog check
run: >-
python3 third_party/mpc-vst-plugins/tools/catalog_check.py dist/*-mpc-armv7.zip
--catalog --expect-id subforce --expect-repo Devko/SubForce
- uses: actions/upload-artifact@v4
with:
name: SubForce-mpc-armv7
path: dist/*-mpc-armv7.zip
if-no-files-found: error
# The release's notes are CHANGELOG.md's section for the tag's version ("## X.Y.Z"): the catalog shows
# them. A tag without a section fails here, before anything is published.
- name: Release notes
if: startsWith(github.ref, 'refs/tags/v')
run: |
v="${GITHUB_REF_NAME#v}"
awk -v v="$v" '/^## /{p = ($2 == v); next} p' CHANGELOG.md > release-notes.md
grep -q '[^[:space:]]' release-notes.md || { echo "CHANGELOG.md has no section '## $v'"; exit 1; }
- name: Release
if: startsWith(github.ref, 'refs/tags/v')
uses: softprops/action-gh-release@v2
with:
files: dist/*-mpc-armv7.zip
prerelease: ${{ startsWith(github.ref_name, 'v0.') }}
body_path: release-notes.md
27 changes: 27 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
# Changelog

Releases are built by CI from a `vX.Y.Z` tag (see [Building](docs/BUILDING.md#release-builds)); the
section for the tag's version becomes the release's notes. While the version is 0.x the parameter list
may still change between releases, and releases are prereleases (the plugin catalog's beta channel).

## 0.0.1

The first public preview.

- **Engine:** two morphing oscillators (triangle → saw → square → narrow pulse), 32' to 2', hard sync,
a square sub (−1 or −2 octaves), noise from white through pink to dark, analog drift. A mixer with
its own feedback loop (thicker, then gritty, then the chaos of an overdriven loop). A nonlinear
4-pole transistor ladder (6/12/18/24 dB) that self-oscillates past 70% of the resonance knob and
thins the bass as it rises, with Multidrive (asymmetric, tube-like warmth to hard clipping). Two
DAHDSR envelopes with a linear attack, velocity, key tracking, reset and a loop through the
release. Two mod busses (7 sources, free or locked to MPC's tempo; pitch, cutoff and one of 10
more destinations; mod wheel, aftertouch or velocity). Mono or Duo, note priority, multi or
single trigger, glide (rate, time or exponential). The whole voice at 2× with a halfband
decimator.
- **57 factory presets** in seven categories (Templates, Bass, Lead, Keys, FX, Sequence, Pad), many
for melodic techno; all level-matched. User presets, favorites, a browser, Init and Randomize.
- **Touchscreen pages** (OSC, FILTER, AMP, MOD, BROWSE, KEYS) with a Q-Link set each.
- **On the Force:** about 2.8% of a block for one held note (3.6% with everything on), measured on
the device; the ladder runs on NEON.
- **Builds:** armhf against glibc 2.31 (loads on MPC OS 2.x and 3.x; the pages need 3.x),
profile-guided, checked with the plugin catalog's `catalog_check.py`.
53 changes: 31 additions & 22 deletions Makefile
Original file line number Diff line number Diff line change
@@ -1,13 +1,17 @@
# SubForce: an analog-style monosynth as a VST2 instrument for MPC OS (Force / MPC standalone).
# Builds on Linux or WSL. Native: g++ (tests, x86 bench). Device: arm-linux-gnueabihf-g++ 13 (the
# Force ships GCC 13's libstdc++, so the .so links it dynamically).
# Builds on Linux or WSL. Native: g++ (tests, x86 bench). Device: arm-linux-gnueabihf-g++ 11 or newer
# (libstdc++ is linked dynamically; MPC OS has it). Releases come from CI, built against glibc 2.31 so
# they load on MPC OS 2.x and 3.x; a newer distribution's cross toolchain needs a newer glibc (3.x only).
# Your own settings (FORCE, SSH_KEY, PY) go in local.mk, which git ignores.
-include local.mk

CXX ?= g++
ARM_CXX ?= arm-linux-gnueabihf-g++
# The prefix of strip, readelf and nm for the device build.
ARM_TOOL ?= arm-linux-gnueabihf-
# ARM_PREFIX: the device toolchain's prefix (also for strip, readelf and nm); empty for a native ARM
# build (the release CI builds in arm32v7/gcc:11-bullseye, glibc 2.31, see .github/workflows/build.yml).
# ARM_RUN: how ARM programs run here: qemu-user on x86, nothing on ARM.
ARM_PREFIX ?= arm-linux-gnueabihf-
ARM_CXX ?= $(ARM_PREFIX)g++
ARM_RUN ?= qemu-arm -L /usr/arm-linux-gnueabihf
BUILD := build
# FORCE: the device, root@<ip>, for bench-device and plugin-install. SSH_KEY: the private key for
# it (empty: ssh's own defaults). PY: a Python 3 with Pillow, for skin, preview and plugin-package.
Expand Down Expand Up @@ -93,7 +97,7 @@ $(BUILD)/plugin_test: $(TESTS) $(wildcard test/*.h) $(SRC) $(HDR) $(GEN) | $(BUI
# The same suite cross-compiled for the Force's CPU and run under qemu-user (no sanitizers):
# catches 32-bit and ARM-only code paths (the FPSCR flush, NEON float code).
test-arm: $(BUILD)/arm/plugin_test
qemu-arm -L /usr/arm-linux-gnueabihf $<
$(ARM_RUN) $<

$(BUILD)/arm/plugin_test: $(TESTS) $(wildcard test/*.h) $(SRC) $(HDR) $(GEN)
mkdir -p $(BUILD)/arm
Expand Down Expand Up @@ -135,16 +139,16 @@ ARM_SO_FLAGS = -std=c++17 $(ARM_OPT) -fPIC -fvisibility=hidden -fvisibility-inli
ARM_SO_LINK = -shared -Wl,--no-undefined -Wl,-soname,subforce.so -Wl,--version-script=plugin/exports.map
ARM_SO_CMD = $(ARM_CXX) $(ARM_SO_FLAGS) $(ARM_SO_LINK)

# Profile-guided: on by default when qemu-arm is installed (as test-arm needs); PGO=0 builds
# without. A copy of the plugin compiled with counters is linked into tools/pgo_train.cpp,
# which plays a spread of patches under qemu-arm; then the .so is compiled from the same sources
# Profile-guided: on by default when ARM programs can run here (qemu-arm installed, as test-arm
# needs, or a native ARM build); PGO=0 builds without. A copy of the plugin compiled with counters
# is linked into tools/pgo_train.cpp, which plays a spread of patches; then the .so is compiled from the same sources
# with the same flags plus that profile, which tells the compiler which paths are hot.
# -fprofile-partial-training keeps functions the trainer never ran optimised as usual. Objects
# keep one path (dir_name.o) in both rounds: GCC names the profile files after it. A missing
# profile fails the build instead of quietly building without.
PGO ?= auto
QEMU_ARM := $(shell command -v qemu-arm 2>/dev/null)
PGO_ON := $(if $(filter auto,$(PGO)),$(if $(QEMU_ARM),1,0),$(PGO))
ARM_RUNS := $(if $(strip $(ARM_RUN)),$(shell command -v $(firstword $(ARM_RUN)) 2>/dev/null),native)
PGO_ON := $(if $(filter auto,$(PGO)),$(if $(ARM_RUNS),1,0),$(PGO))
PGO_DIR := $(BUILD)/arm/pgo
PGO_PROF := $(abspath $(PGO_DIR)/profile)
PGO_OBJ := $(PGO_DIR)/obj
Expand All @@ -164,8 +168,8 @@ ifeq ($(PGO_ON),1)
rm -rf $(PGO_DIR) && mkdir -p $(PGO_OBJ) $(PGO_PROF)
for f in $(SRC); do $(ARM_CXX) $(ARM_SO_FLAGS) -fprofile-generate=$(PGO_PROF) -fprofile-update=prefer-atomic \
-c $$f -o $(PGO_O) || exit 1; done
$(ARM_CXX) $(ARM_SO_FLAGS) -fprofile-generate -static tools/pgo_train.cpp $(PGO_OBJ)/*.o -o $(PGO_DIR)/train
SF_DATA_DIR=$(PGO_DIR) SF_PRESET_ROOTS=$(PGO_DIR) qemu-arm $(PGO_DIR)/train
$(ARM_CXX) $(ARM_SO_FLAGS) -fprofile-generate tools/pgo_train.cpp $(PGO_OBJ)/*.o -o $(PGO_DIR)/train
SF_DATA_DIR=$(PGO_DIR) SF_PRESET_ROOTS=$(PGO_DIR) $(ARM_RUN) $(PGO_DIR)/train
@n=$$(ls $(PGO_PROF)/*.gcda 2>/dev/null | wc -l); [ $$n -eq $(words $(SRC)) ] || \
{ echo "PGO: $$n of $(words $(SRC)) profiles written (PGO=0 builds without)"; exit 1; }
for f in $(SRC); do $(ARM_CXX) $(ARM_SO_FLAGS) -fprofile-use=$(PGO_PROF) -fprofile-partial-training -Werror=missing-profile \
Expand All @@ -174,18 +178,18 @@ ifeq ($(PGO_ON),1)
@echo "profile-guided build"
else
$(ARM_SO_CMD) $(SRC) -o $@
@echo "plain build (PGO=$(PGO): qemu-arm $(if $(QEMU_ARM),found,not found))"
@echo "plain build (PGO=$(PGO): $(firstword $(ARM_RUN)) $(if $(ARM_RUNS),found,not found))"
endif
$(ARM_TOOL)strip --strip-unneeded $@
@$(ARM_TOOL)readelf -V $@ | grep -o 'GLIBC_[0-9.]*' | sort -uV | tail -1 | sed 's/^/needs /'
@n=$$($(ARM_TOOL)nm -D --defined-only $@ | wc -l); echo "exported symbols: $$n"; \
[ $$n -eq 1 ] || { $(ARM_TOOL)nm -D --defined-only $@; echo "only VSTPluginMain may be exported"; exit 1; }
$(ARM_PREFIX)strip --strip-unneeded $@
@$(ARM_PREFIX)readelf -V $@ | grep -o 'GLIBC_[0-9.]*' | sort -uV | tail -1 | sed 's/^/needs /'
@n=$$($(ARM_PREFIX)nm -D --defined-only $@ | wc -l); echo "exported symbols: $$n"; \
[ $$n -eq 1 ] || { $(ARM_PREFIX)nm -D --defined-only $@; echo "only VSTPluginMain may be exported"; exit 1; }

# The suite against the objects the shipped .so is linked from (profile-guided), under qemu.
test-arm-pgo: $(ARM_SO)
ifeq ($(PGO_ON),1)
$(ARM_CXX) -std=c++17 $(ARM_OPT) -Wno-psabi -pthread $(INC) $(TESTS) $(PGO_OBJ)/*.o -o $(BUILD)/arm/plugin_test_pgo
qemu-arm -L /usr/arm-linux-gnueabihf $(BUILD)/arm/plugin_test_pgo
$(ARM_RUN) $(BUILD)/arm/plugin_test_pgo
else
@echo "test-arm-pgo: the .so is a plain build here (PGO=$(PGO)); test-arm covers it"
endif
Expand All @@ -198,7 +202,7 @@ $(ARM_SO_STAGES): $(SRC) $(HDR) $(GEN) plugin/exports_stages.map $(ARM_SO_STAMP)
mkdir -p $(BUILD)/arm
$(ARM_CXX) $(ARM_SO_FLAGS) -shared -Wl,--no-undefined -Wl,-soname,subforce.so \
-Wl,--version-script=plugin/exports_stages.map -DSF_STAGE_TIMING $(SRC) -o $@
$(ARM_TOOL)strip --strip-unneeded $@
$(ARM_PREFIX)strip --strip-unneeded $@

arm-bench: $(ARM_BENCH)
$(ARM_BENCH): tools/bench.cpp $(HDR) $(GEN)
Expand All @@ -224,10 +228,15 @@ plugin-package: $(ARM_SO) $(SKIN)
@# Everything shipped runs under BusyBox on the device: a CR in a script breaks it there.
@# grep: 1 = no CR found (good); 0 = found one; 2 = it couldn't read the scripts.
@grep -l "$$(printf '\r')" $(MV)/tools/release/*; r=$$?; [ $$r -eq 1 ] || { echo "error: CRLF in a shipped script, or no scripts"; exit 1; }
@# A newer toolchain's glibc: fine on the Force (MPC OS 3.x), not for a release (the catalog's limit is 2.32).
@g=$$(readelf -V $(ARM_SO) 2>/dev/null | grep -o 'GLIBC_[0-9.]*' | sed 's/GLIBC_//' | sort -uV | tail -1); \
if [ -n "$$g" ] && [ "$$(printf '%s\n2.32\n' "$$g" | sort -V | tail -1)" != 2.32 ]; then \
echo "warning: this .so needs glibc $$g: it loads on MPC OS 3.x only, and the plugin catalog refuses it."; \
echo " Release packages come from CI (glibc 2.31, docs/BUILDING.md#release-builds)."; fi
$(PY) $(MV)/tools/release.py --so $(ARM_SO) --skin "$(SKIN_DIR)" --entry $(SURF_OUT)/pluginlist-entry.xml \
--version $(PLUGIN_VERSION) --repo Devko/SubForce --license MIT \
--about "SubForce analog-style monosynth (preview): 2 oscillators with continuous wave shape and hard sync, sub oscillator, noise, feedback, a 4-pole ladder filter (6-24 dB) with Multidrive, 2 DAHDSR envelopes, 2 mod busses, glide, Duo mode." \
--requires "root SSH (MockbaMod)" \
--about "SubForce analog-style monosynth (preview): 2 oscillators with continuous wave shape and hard sync, sub oscillator, white, pink or dark noise, a mixer feedback loop, a 4-pole ladder filter (6-24 dB) with Multidrive, 2 DAHDSR envelopes, 2 mod busses, glide, Duo mode, 57 presets." \
--requires "root SSH (MockbaMod); MPC OS 3.x for the pages" \
--user-data Presets --user-data preset_favorites.txt --user-data preset_recent.txt \
-o dist

Expand Down
Loading
Loading