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()