From 761f64f0565785fb21e114e8b4d3c2ba318c6a49 Mon Sep 17 00:00:00 2001 From: Brad Barnett <127794626+bdbarnett@users.noreply.github.com> Date: Tue, 10 Nov 2026 19:57:03 -0600 Subject: [PATCH] Sound module: patch, key and macro names for DAWs (MIDNAM and .ins) python -m soundmodule.names writes a MIDNAM file and a Cakewalk instrument definition that name every instrument's patches under the Bank Select and Program Change that play them, the drum machines' keys, General MIDI's programs, CC91 and CC93 after the module's reverb and chorus, and CC102 to CC117 after each instrument's macros. The names come from the catalog.json that audioinstruments and audioeffects carry. Checked: tests/test_soundmodule_names.py builds both files from the real catalogs and parses them back (the MIDNAM with an XML parser, the .ins section by section), and fails without soundmodule.names. The MIDNAM also validates against the MIDI Manufacturers Association's DTD. --- .github/workflows/tests.yml | 22 +++ ROADMAP.md | 2 - docs/guides/play-the-sound-module.md | 22 +++ docs/reference/sound-module-midi.md | 4 + lib/soundmodule/names.py | 275 +++++++++++++++++++++++++++ tests/test_soundmodule_names.py | 176 +++++++++++++++++ 6 files changed, 499 insertions(+), 2 deletions(-) create mode 100644 lib/soundmodule/names.py create mode 100644 tests/test_soundmodule_names.py diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 57ae2eb..09f6598 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -37,6 +37,10 @@ env: # Both are the v0.5.0 tags. AUDIOINSTRUMENTS_REF: 256662f69dc18d9bcd59f395b95a562ae74504f9 AUDIOEFFECTS_REF: c0b05edc79e54b3d944417d983030676b80cfca3 + # The catalogs the sound module's DAW names are built from aren't in a + # release of either package yet. + AUDIOINSTRUMENTS_CATALOG_REF: a6d394852f3957fc188cea9ba03e23c771677587 + AUDIOEFFECTS_CATALOG_REF: 3a39612f4fc0057c7742e82f636188b6af47ce69 # The instrument panel isn't in an audioinstruments release yet. AUDIOINSTRUMENTS_PANEL_REF: a32dc9891f9e2d8f12692e73fe835cdc6d54e069 # hmilink isn't in a pydevices release yet. @@ -210,6 +214,22 @@ jobs: ref: ${{ env.AUDIOEFFECTS_REF }} path: audioeffects + - uses: actions/checkout@v7 + with: + repository: PyDevices/audioinstruments + ref: ${{ env.AUDIOINSTRUMENTS_CATALOG_REF }} + path: catalogs/audioinstruments + sparse-checkout: lib/audioinstruments/catalog.json + sparse-checkout-cone-mode: false + + - uses: actions/checkout@v7 + with: + repository: PyDevices/audioeffects + ref: ${{ env.AUDIOEFFECTS_CATALOG_REF }} + path: catalogs/audioeffects + sparse-checkout: lib/audioeffects/catalog.json + sparse-checkout-cone-mode: false + - uses: actions/checkout@v7 with: repository: PyDevices/pydevices @@ -275,6 +295,8 @@ jobs: AUDIOINSTRUMENTS_PANEL_DIR: ${{ github.workspace }}/audioinstruments-panel PANEL_REQUIRE: "1" AUDIOEFFECTS_DIR: ${{ github.workspace }}/audioeffects + AUDIOINSTRUMENTS_CATALOG: ${{ github.workspace }}/catalogs/audioinstruments/lib/audioinstruments/catalog.json + AUDIOEFFECTS_CATALOG: ${{ github.workspace }}/catalogs/audioeffects/lib/audioeffects/catalog.json HMILINK_PATH: ${{ github.workspace }}/pydevices/hmilink VAMP_REQUIRE: "1" run: python -m unittest discover -s tests -v diff --git a/ROADMAP.md b/ROADMAP.md index c0a3d3f..6d45647 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -8,8 +8,6 @@ longer list keeps its own roadmap beside its code, linked below. The sound module (`lib/soundmodule`) plays whatever a DAW, sequencer or keyboard sends it over MIDI, the way a hardware tone module does. -- Patch and macro names that DAWs can read, from the catalog files - audioinstruments publishes with each release. - A SysEx request that returns the module's instruments, patches, effects and controls, shaped after MIDI-CI Property Exchange. - On an ESP32-P4 as a USB MIDI device, with 24 voices at 48 kHz stereo. diff --git a/docs/guides/play-the-sound-module.md b/docs/guides/play-the-sound-module.md index 2911a48..dab63be 100644 --- a/docs/guides/play-the-sound-module.md +++ b/docs/guides/play-the-sound-module.md @@ -51,3 +51,25 @@ The bridge stops when a file named `stop` appears in the folder, or after Send MIDI's Identity Request (`F0 7E 06 01 F7`) and the module answers with an Identity Reply, so a DAW can find it. The reply's fields are in the [reference](../reference/sound-module-midi.md#system-exclusive). + +## Show the patch names in your DAW + +A DAW can list every instrument's patches by name, and its macros on CC102 to +CC117, once it has a name file for the module. Write both kinds: + +```sh +python -m soundmodule.names --out names/ +``` + +That writes `PyDevices Sound Module.midnam`, for DAWs that read MIDNAM files +(Ardour and Mixbus among them), and `PyDevices Sound Module.ins`, a Cakewalk +instrument definition (Cakewalk, SONAR). Put the file where your DAW looks +for them, choose "PyDevices Sound Module" as the port's instrument, and each +patch's entry sends the Bank Select and Program Change that play it. + +The names come from the `catalog.json` that audioinstruments and audioeffects +carry, so they match the versions you have installed. To write them from +another catalog, pass its file with `--instruments` or `--effects`. Each +instrument with macros also has a mode of its own (a device mode in the +MIDNAM, an instrument definition in the `.ins`) that names CC102 to CC117 +after its macros; the default names them Macro 0 to Macro 15. diff --git a/docs/reference/sound-module-midi.md b/docs/reference/sound-module-midi.md index 20fb5f5..786b4e9 100644 --- a/docs/reference/sound-module-midi.md +++ b/docs/reference/sound-module-midi.md @@ -17,6 +17,10 @@ module's parts do. | 80 | the *n*-th of audioinstruments' instruments (`engine.INSTRUMENTS`) | the instrument's patch | | 81 | the *n*-th drum machine (`engine.DRUM_MACHINES`) | the drum machine's patch | +Every bank, program and patch has a name a DAW can show: +[Show the patch names in your DAW](../guides/play-the-sound-module.md#show-the-patch-names-in-your-daw) +writes the MIDNAM and Cakewalk files. + Channel 10 plays the SoundFont's drums. The lists `INSTRUMENTS` and `DRUM_MACHINES` are in `engine.py`; new entries go on the end, so a song's program numbers keep their sound. A `None` in a list is a program the diff --git a/lib/soundmodule/names.py b/lib/soundmodule/names.py new file mode 100644 index 0000000..0ce60cf --- /dev/null +++ b/lib/soundmodule/names.py @@ -0,0 +1,275 @@ +# SPDX-FileCopyrightText: 2026 PyDevices +# +# SPDX-License-Identifier: MIT +"""Patch, note and controller names a DAW can read: a MIDNAM file and a +Cakewalk instrument definition (``.ins``) for the sound module. + + python -m soundmodule.names --out names/ + +Both are built from the catalogs audioinstruments and audioeffects carry +(``catalog.json`` in each package), so the names match the instruments you +have installed. ``--instruments`` and ``--effects`` read a catalog from a +file instead. + +Every instrument's patches are listed under it, with the Bank Select and +Program Change that play them on the sound module (bank ``BANK_INSTRUMENTS`` +or ``BANK_DRUM_MACHINES``, the instrument's program, its patch as the LSB), +and General MIDI's programs are bank 0. A drum machine's patches name its +keys. The macros are CC 102 to 117: the default mode names them "Macro 0" +and on, and each instrument has a mode of its own (a ``CustomDeviceMode`` in +the MIDNAM, an instrument definition in the ``.ins``) that gives them its +names. CC91 and CC93 are named after the reverb and chorus the module plays. +""" + +import json +import sys + +from . import engine + +MANUFACTURER = "PyDevices" +MODEL = "Sound Module" +#: The CCs every part answers, beside the macros. +CONTROLS = ( + (1, "Modulation"), + (7, "Volume"), + (10, "Pan"), + (11, "Expression"), + (64, "Sustain"), +) +#: The master effects CC91 and CC93 send to, by their catalog name. +SENDS = ((engine.CC_REVERB, "Reverb"), (engine.CC_CHORUS, "Chorus")) + + +def load(path): + """A catalog read from ``path``.""" + with open(path, encoding="utf-8") as f: + return json.load(f) + + +def installed(package): + """The catalog that the installed ``package`` carries.""" + module = __import__(package) + folder = module.__file__.replace("\\", "/").rsplit("/", 1)[0] + try: + return load(folder + "/catalog.json") + except OSError: + raise SystemExit("%s in %s has no catalog.json: install a newer release, or pass its" + " file with --%s" % (package, folder, package[5:])) + + +def _by_name(catalog, key): + return {e["name"]: e for e in catalog[key]} + + +def parts(instruments): + """``(bank MSB, program, entry)`` for each instrument the module plays, + melodic first, in program order.""" + found = _by_name(instruments, "instruments") + out = [] + for msb, table in ((engine.BANK_INSTRUMENTS, engine.INSTRUMENTS), + (engine.BANK_DRUM_MACHINES, engine.DRUM_MACHINES)): + for program, name in enumerate(table): + if name is None: + continue + if name not in found: + raise ValueError("the catalog has no instrument %r" % name) + out.append((msb, program, found[name])) + return out + + +def controls(effects, macros=None): + """``(cc, name)`` for every controller: the fixed ones, the two sends and + the macros, named from ``macros`` (a catalog entry's) where given.""" + found = _by_name(effects, "effects") + out = list(CONTROLS) + for cc, name in SENDS: + if name not in found: + raise ValueError("the effects catalog has no %r" % name) + out.append((cc, "%s send" % found[name]["display_name"])) + labels = [m["label"] for m in macros] if macros else [] + for i in range(engine.MACRO_CCS): + label = labels[i] if i < len(labels) else "Macro %d" % i + out.append((engine.MACRO_CC + i, label)) + return sorted(out) + + +def _xml(text): + return (str(text).replace("&", "&").replace("<", "<") + .replace(">", ">").replace('"', """)) + + +def midnam(instruments, effects): + """The MIDNAM document, as text.""" + entries = parts(instruments) + out = [ + '', + '', + "", + " %s" % MANUFACTURER, + " ", + " %s" % MANUFACTURER, + " %s" % MODEL, + ] + modes = [("Default", "All", None)] + for msb, program, e in entries: + if e["macros"]: + modes.append((e["display_name"], e["name"], e)) + for mode, nameset, _ in modes: + out.append(' ' % _xml(mode)) + out.append(" ") + for ch in range(1, 17): + out.append(' ' + % (ch, _xml(nameset))) + out.append(" ") + out.append(" ") + for mode, nameset, only in modes: + out.append(' ' % _xml(nameset)) + out.append(" ") + for ch in range(1, 17): + out.append(' ' % ch) + out.append(" ") + out.append(' ' % _xml(nameset)) + if only is None: + out.append(' ') + out.append(" ") + out.append(' ') + out.append(' ') + out.append(" ") + out.append(" ") + for program, name in enumerate(engine.GM_NAMES): + out.append(' ' + % (program + 1, _xml(name), program)) + out.append(" ") + out.append(" ") + for msb, program, e in entries: + if only is not None and e is not only: + continue + out.append(' ' % _xml(e["display_name"])) + out.append(" ") + out.append(' ' % msb) + out.append(' ') + out.append(" ") + out.append(" ") + for p in e["patches"]: + out.append(' ' + % (p["index"] + 1, _xml(p["name"]), program)) + out.append(" ") + out.append(' ' % msb) + out.append(' ' % p["index"]) + out.append(' ' % program) + out.append(" ") + if e.get("notes"): + out.append(' ' % _xml(e["name"])) + out.append(" ") + out.append(" ") + out.append(" ") + out.append(" ") + for msb, program, e in entries: + if e.get("notes"): + out.append(' ' % _xml(e["name"])) + for n in e["notes"]: + out.append(' ' % (n["key"], _xml(n["name"]))) + out.append(" ") + for mode, nameset, only in modes: + out.append(' ' % _xml(nameset)) + for cc, name in controls(effects, only["macros"] if only else None): + out.append(' ' % (cc, _xml(name))) + out.append(" ") + out.append(" ") + out.append("") + return "\n".join(out) + "\n" + + +def cakewalk_ins(instruments, effects): + """The Cakewalk instrument definition file, as text. + + An ``.ins`` bank is one MSB and LSB, so the module's patches are listed a + bank per patch number: bank 80/k holds patch k of each instrument, at the + instrument's program. + """ + entries = parts(instruments) + title = "%s %s" % (MANUFACTURER, MODEL) + out = ["; %s: patch, key and controller names." % title, "", ".Patch Names", ""] + out.append("[%s General MIDI]" % MODEL) + out += ["%d=%s" % (i, name) for i, name in enumerate(engine.GM_NAMES)] + out.append("") + banks = [] + for msb in (engine.BANK_INSTRUMENTS, engine.BANK_DRUM_MACHINES): + group = [(program, e) for m, program, e in entries if m == msb] + most = max(len(e["patches"]) for _, e in group) + kind = "Instruments" if msb == engine.BANK_INSTRUMENTS else "Drum Machines" + for lsb in range(most): + name = "%s %s, patch %d" % (MODEL, kind, lsb + 1) + banks.append((msb * 128 + lsb, name, msb)) + out.append("[%s]" % name) + for program, e in group: + for p in e["patches"]: + if p["index"] == lsb: + out.append("%d=%s: %s" % (program, e["display_name"], p["name"])) + out.append("") + out += [".Note Names", ""] + for _, _, e in entries: + if e.get("notes"): + out.append("[%s]" % e["display_name"]) + out += ["%d=%s" % (n["key"], n["name"]) for n in e["notes"]] + out.append("") + out += [".Controller Names", ""] + defs = [(title, MODEL, None)] + for _, _, e in entries: + if e["macros"]: + defs.append(("%s: %s" % (title, e["display_name"]), + "%s %s" % (MODEL, e["display_name"]), e)) + for _, control, e in defs: + out.append("[%s]" % control) + out += ["%d=%s" % (cc, name) for cc, name in controls(effects, e["macros"] if e else None)] + out.append("") + out += [".Instrument Definitions", ""] + for definition, control, _ in defs: + out.append("[%s]" % definition) + out.append("Control=%s" % control) + out.append("BankSelMethod=0") + out.append("Patch[0]=%s General MIDI" % MODEL) + for bank, name, _ in banks: + out.append("Patch[%d]=%s" % (bank, name)) + for msb, program, e in entries: + if e.get("notes"): + for bank, _, bank_msb in banks: + if bank_msb == msb: + out.append("Key[%d,%d]=%s" % (bank, program, e["display_name"])) + out.append("Drum[%d,%d]=1" % (bank, program)) + out.append("") + return "\n".join(out) + + +def main(argv): + folder = "." + instruments = effects = None + i = 1 + while i < len(argv): + if argv[i] == "--out": + folder = argv[i + 1] + elif argv[i] == "--instruments": + instruments = load(argv[i + 1]) + elif argv[i] == "--effects": + effects = load(argv[i + 1]) + else: + print(__doc__) + return 2 + i += 2 + instruments = instruments or installed("audioinstruments") + effects = effects or installed("audioeffects") + import os + + os.makedirs(folder, exist_ok=True) + for filename, text in (("PyDevices Sound Module.midnam", midnam(instruments, effects)), + ("PyDevices Sound Module.ins", cakewalk_ins(instruments, effects))): + path = folder.rstrip("/") + "/" + filename + with open(path, "w", encoding="utf-8") as f: + f.write(text) + print(path) + return 0 + + +if __name__ == "__main__": + sys.exit(main(sys.argv)) diff --git a/tests/test_soundmodule_names.py b/tests/test_soundmodule_names.py new file mode 100644 index 0000000..b77d517 --- /dev/null +++ b/tests/test_soundmodule_names.py @@ -0,0 +1,176 @@ +# SPDX-FileCopyrightText: 2026 PyDevices +# +# SPDX-License-Identifier: MIT +"""``soundmodule.names``: the MIDNAM and the Cakewalk ``.ins`` it builds from +the catalogs audioinstruments and audioeffects carry, parsed back. + + python -m unittest tests.test_soundmodule_names + +The catalogs are ``$AUDIOINSTRUMENTS_CATALOG`` and ``$AUDIOEFFECTS_CATALOG``, +else ``catalog.json`` in the checkouts the engine tests use. A catalog that +isn't there fails the test: the names are only worth checking against the +real ones. +""" + +import contextlib +import io +import json +import os +import sys +import tempfile +import unittest +import xml.etree.ElementTree as ET +from pathlib import Path + +TESTS = Path(__file__).resolve().parent +sys.path.insert(0, str(TESTS.parent / "lib")) +sys.path.insert(0, str(TESTS)) + +from soundmodule import engine, names # noqa: E402 +from test_engine import audio_checkout # noqa: E402 + + +def catalog(package): + path = os.environ.get(package.upper() + "_CATALOG") + if not path: + checkout = audio_checkout(package) + if checkout is None: + raise unittest.SkipTest("no %s checkout" % package) + path = os.path.join(checkout, "lib", package, "catalog.json") + if not os.path.isfile(path): + raise AssertionError("%s has no catalog.json at %s" % (package, path)) + return names.load(path) + + +def ins_sections(text): + """The ``.ins`` file as {section: {[list]: [(key, value)]}}, refusing any + line that isn't a comment, a section, a list or ``key=value``.""" + sections = {} + section = block = None + for number, line in enumerate(text.splitlines(), 1): + line = line.strip() + if not line or line.startswith(";"): + continue + if line.startswith("."): + section = sections.setdefault(line, {}) + block = None + elif line.startswith("[") and line.endswith("]"): + if section is None: + raise ValueError("line %d: a list before any section" % number) + if line[1:-1] in section: + raise ValueError("line %d: %s twice" % (number, line)) + block = section[line[1:-1]] = [] + elif "=" in line and block is not None: + key, value = line.split("=", 1) + block.append((key, value)) + else: + raise ValueError("line %d: %r" % (number, line)) + return sections + + +class TheNames(unittest.TestCase): + + @classmethod + def setUpClass(cls): + cls.instruments = catalog("audioinstruments") + cls.effects = catalog("audioeffects") + cls.by_name = {e["name"]: e for e in cls.instruments["instruments"]} + cls.reverb = {e["name"]: e for e in cls.effects["effects"]}["Reverb"]["display_name"] + + def played(self): + """(msb, program, entry) for every instrument the engine plays.""" + out = [] + for msb, table in ((engine.BANK_INSTRUMENTS, engine.INSTRUMENTS), + (engine.BANK_DRUM_MACHINES, engine.DRUM_MACHINES)): + for program, name in enumerate(table): + if name is not None: + out.append((msb, program, self.by_name[name])) + return out + + def test_the_midnam_parses_and_selects_each_patch(self): + root = ET.fromstring(names.midnam(self.instruments, self.effects).encode()) + self.assertEqual(root.tag, "MIDINameDocument") + device = root.find("MasterDeviceNames") + sets = {s.get("Name"): s for s in device.findall("ChannelNameSet")} + notes = {n.get("Name") for n in device.findall("NoteNameList")} + controls = {c.get("Name"): c for c in device.findall("ControlNameList")} + for mode in device.findall("CustomDeviceMode"): + for assign in mode.iter("ChannelNameSetAssign"): + self.assertIn(assign.get("NameSet"), sets) + for s in sets.values(): + self.assertIn(s.find("UsesControlNameList").get("Name"), controls) + for uses in s.iter("UsesNoteNameList"): + self.assertIn(uses.get("Name"), notes) + banks = {b.get("Name"): b for b in sets["All"].findall("PatchBank")} + self.assertEqual(len(list(banks["General MIDI"].iter("Patch"))), 128) + for msb, program, e in self.played(): + bank = banks[e["display_name"]] + got = [] + for patch in bank.iter("Patch"): + commands = patch.find("PatchMIDICommands") + cc = {int(c.get("Control")): int(c.get("Value")) + for c in commands.findall("ControlChange")} + self.assertEqual(cc[0], msb) + self.assertEqual(int(commands.find("ProgramChange").get("Number")), program) + got.append((cc[32], patch.get("Name"))) + self.assertEqual(patch.find("UsesNoteNameList") is not None, bool(e.get("notes"))) + self.assertEqual(got, [(p["index"], p["name"]) for p in e["patches"]]) + + def test_the_midnam_names_the_macros_and_sends(self): + root = ET.fromstring(names.midnam(self.instruments, self.effects).encode()) + controls = {c.get("Name"): {int(x.get("Number")): x.get("Name") for x in c} + for c in root.iter("ControlNameList")} + default = controls["All"] + self.assertEqual(default[engine.CC_REVERB], "%s send" % self.reverb) + self.assertEqual(default[engine.MACRO_CC], "Macro 0") + self.assertEqual(len([cc for cc in default if cc >= engine.MACRO_CC]), engine.MACRO_CCS) + for _, _, e in self.played(): + if e["macros"]: + own = controls[e["name"]] + for i, m in enumerate(e["macros"]): + self.assertEqual(own[engine.MACRO_CC + i], m["label"]) + + def test_the_ins_parses_and_every_reference_resolves(self): + sections = ins_sections(names.cakewalk_ins(self.instruments, self.effects)) + patches = sections[".Patch Names"] + keys = sections[".Note Names"] + controllers = sections[".Controller Names"] + definitions = sections[".Instrument Definitions"] + self.assertTrue(definitions) + for definition in definitions.values(): + fields = dict(definition) + self.assertIn(fields["Control"], controllers) + for key, value in definition: + if key.startswith("Patch["): + self.assertIn(value, patches) + elif key.startswith("Key["): + self.assertIn(value, keys) + for block in list(patches.values()) + list(keys.values()) + list(controllers.values()): + for key, _ in block: + self.assertTrue(0 <= int(key) <= 127, key) + default = dict(definitions["%s %s" % (names.MANUFACTURER, names.MODEL)]) + for msb, program, e in self.played(): + for p in e["patches"]: + bank = "Patch[%d]" % (msb * 128 + p["index"]) + listed = dict(patches[default[bank]]) + self.assertEqual(listed[str(program)], "%s: %s" % (e["display_name"], p["name"])) + + def test_it_writes_both_files(self): + with tempfile.TemporaryDirectory() as folder: + with open(os.path.join(folder, "i.json"), "w", encoding="utf-8") as f: + f.write(json.dumps(self.instruments)) + with open(os.path.join(folder, "e.json"), "w", encoding="utf-8") as f: + f.write(json.dumps(self.effects)) + with contextlib.redirect_stdout(io.StringIO()): + code = names.main(["names", "--out", folder, + "--instruments", os.path.join(folder, "i.json"), + "--effects", os.path.join(folder, "e.json")]) + self.assertEqual(code, 0) + written = sorted(f for f in os.listdir(folder) if not f.endswith(".json")) + self.assertEqual(written, ["PyDevices Sound Module.ins", + "PyDevices Sound Module.midnam"]) + ET.parse(os.path.join(folder, "PyDevices Sound Module.midnam")) + + +if __name__ == "__main__": + unittest.main()