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
3 changes: 0 additions & 3 deletions ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,9 +26,6 @@ way on every interpreter and board.
instead of keeping their ratios (RingMod multiplies; this shifts).
- AmpSim, an amp and DI model after the Tech 21 SansAmp GT2, built from
measurements of a hardware unit rather than a component trace alone.
- A catalog shipped with each release: every effect's patches and macro names
as JSON, MIDNAM and Cakewalk `.ins` files, so a DAW or MIDI host can show
the names without loading the audio stack.

Bugs, and things you need that don't work yet, go to
[issues](https://github.com/PyDevices/audioeffects/issues).
21 changes: 21 additions & 0 deletions docs/reference/metadata.md
Original file line number Diff line number Diff line change
Expand Up @@ -252,3 +252,24 @@ A consumer must use `NAME` as the stable lookup identity and may use
note maps when useful. It must not assume optional metadata exists. It may
also provide a generic UI when macros or patches are empty, although every
provider still declares those fields explicitly.

## The catalog

Every wheel carries `audioeffects/catalog.json`: each effect's name, display
name, categories, macros (label and mode) and patches (index, name and
values), as the classes declare them. A DAW or MIDI host can read it to show
those names without loading the audio stack:

```python
import json, os, audioeffects

with open(os.path.join(os.path.dirname(audioeffects.__file__), "catalog.json")) as f:
catalog = json.load(f)
for effect in catalog["effects"]:
print(effect["display_name"], [p["name"] for p in effect["patches"]])
```

The file is `{"format": 1, "package": "audioeffects", "effects": [...]}`, in
the order of `audioeffects.ALL`. `tools/make_catalog.py` writes it from the
classes, and its `--check` fails a pull request that changes a class without
it.
1,093 changes: 1,093 additions & 0 deletions lib/audioeffects/catalog.json

Large diffs are not rendered by default.

5 changes: 5 additions & 0 deletions lib/audioeffects/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -41,5 +41,10 @@ packages = ["audioeffects"]
[tool.setuptools.package-dir]
audioeffects = "."

# catalog.json names every effect's patches and macros for a DAW or MIDI host;
# tools/make_catalog.py writes it from the classes.
[tool.setuptools.package-data]
audioeffects = ["catalog.json"]

[tool.setuptools.dynamic]
version = {file = "VERSION"}
49 changes: 48 additions & 1 deletion tests/test_catalogue.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,16 @@
name, and the metadata validator walks every one. These used to sit beside
the registry tests that arbitrated between a class under `rebuilt/` and an
older one; every class has one home now, so only the catalogue's own
promises are left to hold.
promises are left to hold, with `catalog.json`, the same names as data in
every wheel.
"""

import array
import json
import os
import re
import subprocess
import sys
import unittest

import audiocore
Expand All @@ -21,6 +27,10 @@
from tools.validate_metadata import MetadataError, validate_effects


ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
CATALOG = os.path.join(os.path.dirname(audioeffects.__file__), "catalog.json")


def source(channels=2, rate=48000):
return audiocore.RawSample(
array.array("h", [4000, -4000] * 2048 * channels),
Expand Down Expand Up @@ -85,5 +95,42 @@ def __getattr__(self, name):
self.assertIn("Compressor", str(caught.exception))


class TheCatalogJson(unittest.TestCase):

def load(self):
with open(CATALOG, encoding="utf-8") as f:
return json.load(f)

def test_it_is_what_the_classes_say(self):
result = subprocess.run(
[sys.executable, os.path.join(ROOT, "tools", "make_catalog.py"), "--check"],
capture_output=True, text=True)
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)

def test_it_lists_every_effect(self):
catalog = self.load()
self.assertEqual(catalog["format"], 1)
self.assertEqual(catalog["package"], "audioeffects")
self.assertEqual([e["name"] for e in catalog["effects"]], list(audioeffects.ALL))

def test_each_entry_is_its_class(self):
for e in self.load()["effects"]:
cls = getattr(audioeffects, e["name"])
self.assertEqual(e["display_name"], cls.DISPLAY_NAME)
self.assertEqual([m["label"] for m in e["macros"]], list(cls.MACRO_LABELS))
self.assertEqual([m["mode"] for m in e["macros"]],
[cls.MACRO_MODES[i] for i in range(len(cls.MACRO_LABELS))])
self.assertEqual(
{p["index"]: (p["name"], tuple(p["values"])) for p in e["patches"]},
{i: (p[0], tuple(p[1])) for i, p in cls.PATCHES.items()})

def test_the_wheel_carries_it(self):
with open(os.path.join(ROOT, "lib", "audioeffects", "pyproject.toml"),
encoding="utf-8") as f:
text = f.read()
section = re.search(r"\[tool\.setuptools\.package-data\]\n(.*?)(\n\[|\Z)", text, re.S)
self.assertTrue(section and "catalog.json" in section.group(1))


if __name__ == "__main__":
unittest.main()
87 changes: 87 additions & 0 deletions tools/make_catalog.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
#!/usr/bin/env python3
# SPDX-FileCopyrightText: 2026 Brad Barnett
#
# SPDX-License-Identifier: MIT

"""Write catalog.json: every effect's patches and macro names, as data.

python tools/make_catalog.py # write lib/audioeffects/catalog.json
python tools/make_catalog.py --check # exit 1 if it is out of date

A DAW or MIDI host reads the file to show an effect's patch and macro names
without loading the audio stack, and the wheel carries it. The classes are the
source: change a class's `PATCHES`, `MACRO_LABELS` or `DISPLAY_NAME` and run
this again.
"""

import json
import os
import sys

ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
sys.path.insert(0, os.path.join(ROOT, "lib"))

import audioeffects # noqa: E402

CATALOG = os.path.join(ROOT, "lib", "audioeffects", "catalog.json")


def entry(name):
"""One effect in the catalog."""
cls = getattr(audioeffects, name)
labels = tuple(getattr(cls, "MACRO_LABELS", ()))
modes = dict(getattr(cls, "MACRO_MODES", {}))
return {
"name": name,
"display_name": getattr(cls, "DISPLAY_NAME", None) or name,
"categories": list(getattr(cls, "CATEGORIES", ())),
"macros": [{"label": label, "mode": modes[i]} for i, label in enumerate(labels)],
"patches": [
{"index": i, "name": p[0], "values": [int(v) for v in p[1]]}
for i, p in sorted(getattr(cls, "PATCHES", {}).items())
],
}


def catalog():
"""The file's text. One line a macro and a patch, so a diff shows the patch
that changed."""
def one(value):
return json.dumps(value, ensure_ascii=False)

def item(e):
fields = []
for key, value in e.items():
if isinstance(value, list) and value and isinstance(value[0], dict):
rows = ",\n".join(" " + one(v) for v in value)
fields.append(' "%s": [\n%s\n ]' % (key, rows))
else:
fields.append(' "%s": %s' % (key, one(value)))
return " {\n%s\n }" % ",\n".join(fields)

items = ",\n".join(item(entry(n)) for n in audioeffects.ALL)
return ('{\n "format": 1,\n "package": "audioeffects",\n "effects": [\n%s\n ]\n}\n'
% items)


def main(argv):
body = catalog()
try:
with open(CATALOG, encoding="utf-8") as f:
current = f.read()
except OSError:
current = None
if current == body:
print("catalog.json is up to date")
return 0
if "--check" in argv:
print("catalog.json is out of date: run tools/make_catalog.py")
return 1
with open(CATALOG, "w", encoding="utf-8") as f:
f.write(body)
print("catalog.json written")
return 0


if __name__ == "__main__":
sys.exit(main(sys.argv[1:]))
6 changes: 6 additions & 0 deletions tools/scoped_tests.py
Original file line number Diff line number Diff line change
Expand Up @@ -246,6 +246,9 @@ def choose(scope, modules):
#: The roster tests that name every effect class, run when one class moves.
_ROSTERS = ("test_metadata_contract", "test_cpython_effects_library")

#: catalog.json and the tool that writes it, which test_catalogue checks.
_CATALOG = ("lib/audioeffects/catalog.json", "tools/make_catalog.py")

#: Paths no test reads, besides the ones the workflow already ignores.
_CI_SILENT = ("docs/", "LICENSE", "VERSION")

Expand Down Expand Up @@ -273,6 +276,9 @@ def choose_ci(changed, modules):
if path.startswith(_CI_SILENT) or (
path.endswith(".md") and not path.startswith("lib/")):
continue
if path in _CATALOG:
# Only the catalogue tests read these, and every change runs them.
continue
name = os.path.basename(path)
if path.startswith("tests/") and "/" not in path[6:] and \
name.endswith(".py"):
Expand Down
6 changes: 4 additions & 2 deletions tools/wheel_gate.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,8 @@

1. copies the package directory aside, writes the repo's VERSION into it (as
the release reusable does) and runs `python -m build --wheel`;
2. fails if any .py file under the source directory is missing from the
wheel;
2. fails if any .py file under the source directory, or catalog.json, is
missing from the wheel;

then it makes one clean venv, installs audiodsp (default: the AUDIODSP_PIN
commit from git, as tests.yml does) and the wheel with --no-deps, and --
Expand Down Expand Up @@ -120,6 +120,8 @@ def build_wheel(package, work):
expected = {package + "/" + path.relative_to(staged).as_posix()
for path in staged.rglob("*.py")
if "__pycache__" not in path.parts}
# catalog.json is the one data file a DAW or MIDI host reads from the wheel.
expected.add(package + "/catalog.json")
out = work / "dist"
run(sys.executable, "-m", "build", "--wheel", "--outdir", out, staged,
check=True, stdout=subprocess.DEVNULL)
Expand Down
Loading