From f5654c9c6693242592fdb6a9655346c046f46173 Mon Sep 17 00:00:00 2001 From: Daniel Jones Date: Thu, 9 Nov 2023 09:31:55 +0000 Subject: [PATCH] Refactor generate-node-python-bindings.py --- CONTRIBUTING.md | 6 + README.md | 4 +- .../scripts/generate-node-python-bindings.py | 193 +++++++++++------- docs/library/index.md | 3 +- source/src/python/nodes.cpp | 12 +- 5 files changed, 137 insertions(+), 81 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index b86dbeb7..56c5cc14 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -34,6 +34,12 @@ python3 setup.py test ## Documentation +To update autogenerated Node documentation: + +``` +auxiliary/scripts/generate-node-python-bindings.py --markdown > docs/library/index.md +``` + To generate and serve the docs: ``` diff --git a/README.md b/README.md index a51fec3c..94a5097d 100644 --- a/README.md +++ b/README.md @@ -163,8 +163,8 @@ The following Node classes are currently included with the base distribution: | **Buffer** | BeatCutter, BufferLooper, BufferPlayer, BufferRecorder, FeedbackBufferReader, FeedbackBufferWriter, GrainSegments, Granulator, SegmentPlayer | | **Control** | MouseX, MouseY, MouseDown | | **Envelope** | ADSREnvelope, ASREnvelope, DetectSilence, Envelope, Line, RectangularEnvelope | -| **Fft** | FFTContinuousPhaseVocoder, FFTConvolve, FFT, FFTNode, FFTOpNode, FFTFindPeaks, IFFT, FFTLPF, FFTNoiseGate, FFTPhaseVocoder, FFTTonality, FFTZeroPhase | -| **Operators** | Add, AmplitudeToDecibels, DecibelsToAmplitude, ChannelArray, ChannelMixer, ChannelSelect, Equal, NotEqual, GreaterThan, GreaterThanOrEqual, LessThan, LessThanOrEqual, Modulo, Abs, If, Divide, FrequencyToMidiNote, MidiNoteToFrequency, Multiply, Pow, RoundToScale, Round, ScaleLinExp, ScaleLinLin, Subtract, Sum, Sin, Cos, Tan, Tanh | +| **FFT** | FFTContinuousPhaseVocoder, FFTConvolve, FFT, FFTNode, FFTOpNode, FFTFindPeaks, IFFT, FFTLPF, FFTNoiseGate, FFTPhaseVocoder, FFTTonality, FFTZeroPhase | +| **Operators** | Add, AmplitudeToDecibels, DecibelsToAmplitude, ChannelArray, ChannelCrossfade, ChannelMixer, ChannelSelect, Equal, NotEqual, GreaterThan, GreaterThanOrEqual, LessThan, LessThanOrEqual, Modulo, Abs, If, Divide, FrequencyToMidiNote, MidiNoteToFrequency, Multiply, Pow, RoundToScale, Round, ScaleLinExp, ScaleLinLin, Subtract, Sum, Sin, Cos, Tan, Tanh | | **Oscillators** | Constant, Impulse, LFO, SawLFO, SawOscillator, SineLFO, SineOscillator, SquareLFO, SquareOscillator, TriangleLFO, TriangleOscillator, Wavetable, Wavetable2D | | **Processors** | Clip, Fold, Smooth, WetDry, Wrap | | **Processors: Delays** | AllpassDelay, CombDelay, OneTapDelay, Stutter | diff --git a/auxiliary/scripts/generate-node-python-bindings.py b/auxiliary/scripts/generate-node-python-bindings.py index 5925831d..f0c706c4 100755 --- a/auxiliary/scripts/generate-node-python-bindings.py +++ b/auxiliary/scripts/generate-node-python-bindings.py @@ -16,7 +16,6 @@ import os import re -import sys import glob import argparse import subprocess @@ -35,9 +34,9 @@ class Parameter: @dataclass class NodeClass: name: str - parent: str - docs: str + parent: Optional[str] constructors: list[list[Parameter]] + docs: str node_superclasses = ["Node", "UnaryOpNode", "BinaryOpNode", "StochasticNode", "FFTNode", "FFTOpNode", "LFO"] @@ -45,6 +44,7 @@ class NodeClass: "StochasticNode"] macos_only_classes = ["MouseX", "MouseY", "MouseDown", "FFTConvolve"] known_parent_classes = ["Node", "StochasticNode"] +documentation_omit_folders = ["io"] def get_all_source_files() -> list[str]: @@ -63,10 +63,7 @@ def get_all_source_files() -> list[str]: return source_files -def generate_class_bindings(class_name: str, - parameter_sets: list[list[Parameter]], - superclass: str = "Node", - class_docs: Optional[str] = None) -> str: +def generate_class_bindings(cls: NodeClass): """ Args: class_name: The full name of the C++ class @@ -84,14 +81,15 @@ def generate_class_bindings(class_name: str, .def(py::init>(), "frequency"_a = NodeRef(440.0)) .def(py::init>(), "frequency"_a = NodeRef(440.0)); """ - if class_name in omitted_classes: + if cls.name in omitted_classes: return "" - if class_docs is None: - class_docs = class_name + if cls.docs is None: + raise ValueError("No docs for class: %s" % cls.name) + parent_class = cls.parent if cls.parent in known_parent_classes else "Node" output = 'py::class_<{class_name}, {superclass}, NodeRefTemplate<{class_name}>>(m, "{class_name}", "{class_docs}")\n'.format( - class_name=class_name, class_docs=class_docs, superclass=superclass) - for parameter_set in parameter_sets: + class_name=cls.name, class_docs=cls.docs, superclass=parent_class) + for parameter_set in cls.constructors: parameter_type_list = ", ".join([parameter.type for parameter in parameter_set]) output += ' .def(py::init<{parameter_type_list}>()'.format(parameter_type_list=parameter_type_list); for parameter in parameter_set: @@ -157,39 +155,39 @@ def folder_name_to_title(folder_name: str) -> str: """ folder_parts = [part.title() for part in folder_name.split("/")] folder_title = ": ".join(folder_parts) + # capitalise all-vowel or all-consonant folder names (io, fft) + if re.search(r"^[aeiou]+$", folder_title) or re.search(r"^[^aeiou]+$", folder_title): + folder_title = folder_title.upper() return folder_title -def generate_all_bindings(source_files): - output_markdown = "" - folder_last = "" - output = "" - output += generate_class_bindings("AudioIn", [[]]) + "\n" - output += generate_class_bindings("AudioOut_Abstract", []) + "\n" - output += generate_class_bindings("AudioOut_Dummy", [[ - Parameter("num_channels", "int", 2), - Parameter("buffer_size", "int", "SIGNALFLOW_DEFAULT_BLOCK_SIZE"), - ]], "AudioOut_Abstract") + "\n" - - output += generate_class_bindings("AudioOut", [[ - Parameter("backend_name", "std::string", ""), - Parameter("device_name", "std::string", ""), - Parameter("sample_rate", "int", 0), - Parameter("buffer_size", "int", 0), - ]], "AudioOut_Abstract") + "\n" - - class_categories = {} +def parse_node_classes(source_files) -> dict[str, list[Parameter]]: + classes = {} + classes["io"] = [ + NodeClass("AudioIn", None, [[]], "Audio input"), + NodeClass("AudioOut_Abstract", None, [], "Abstract audio output"), + NodeClass("AudioOut_Dummy", "AudioOut_Abstract", [[ + Parameter("num_channels", "int", 2), + Parameter("buffer_size", "int", "SIGNALFLOW_DEFAULT_BLOCK_SIZE"), + ]], "Dummy audio output for offline processing"), + NodeClass("AudioOut", "AudioOut_Abstract", [[ + Parameter("backend_name", "std::string", ""), + Parameter("device_name", "std::string", ""), + Parameter("sample_rate", "int", 0), + Parameter("buffer_size", "int", 0), + ]], "Audio output") + ] + class_category = None + folder_last = None for source_file in source_files: folder = re.sub(".*node/", "", source_file) folder = os.path.dirname(folder) if folder != folder_last: - folder_title = folder_name_to_title(folder) - output_markdown += "\n## " + folder_title + "\n\n" folder_last = folder class_category = folder - class_categories[class_category] = [] + classes[class_category] = [] header = CppHeaderParser.CppHeader(source_file) @@ -222,34 +220,38 @@ def generate_all_bindings(source_files): # If the class has at least one valid constructor, generate output. # -------------------------------------------------------------------------------- if constructor_parameter_sets: - if class_name in macos_only_classes: - output += "#ifdef __APPLE__\n\n" - - if parent_class not in known_parent_classes: - parent_class = "Node" - output += generate_class_bindings(class_name=class_name, - parameter_sets=constructor_parameter_sets, - superclass=parent_class, - class_docs=class_docs) - output = output.strip() - output += "\n\n" - if class_name in macos_only_classes: - output += "#endif\n\n" + cls = NodeClass(class_name, + parent_class, + constructor_parameter_sets, + class_docs) + classes[class_category].append(cls) - output_markdown_params = ", ".join( - ("%s=%s" % (param.name, param.default)) for param in constructor_parameter_sets[0]) - output_markdown += "- **%s**: %s `(%s)`\n" % (class_name, class_docs, output_markdown_params) - class_categories[class_category].append(class_name) - return output, output_markdown, class_categories + return classes -def main(args): - source_files = get_all_source_files() - # node_classes = parse_node_classes(source_files) - # bindings = generate_bindings(node_classes) - # markdown = generate_markdown(node_classes) - bindings, markdown, class_categories = generate_all_bindings(source_files) - bindings = re.sub("\n", "\n ", bindings) +def generate_bindings(node_classes) -> str: + """ + Generate the complete set of Python bindings from a dict of all Node classes. + + Args: + node_classes: dict of all Node classes + + Returns: + The complete bindings .cpp file, to output to source/src/python/nodes.cpp + """ + output = "" + for folder, classes in node_classes.items(): + for cls in classes: + if cls.name in macos_only_classes: + output += "#ifdef __APPLE__\n\n" + + output += generate_class_bindings(cls) + output = output.strip() + output += "\n\n" + if cls.name in macos_only_classes: + output += "#endif\n\n" + + output = re.sub("\n", "\n ", output) output = '''#include "signalflow/python/python.h" void init_python_nodes(py::module &m) @@ -257,23 +259,72 @@ def main(args): /*-------------------------------------------------------------------------------- * Node subclasses *-------------------------------------------------------------------------------*/ - {bindings} + {output} }} - '''.format(bindings=bindings) + '''.format(output=output) + + return output + + +def generate_markdown(node_classes) -> str: + """ + Generate Markdown documentation for the Node reference library + + Args: + node_classes: dict of all Node classes + + Returns: + The complete docs/library.md markdown file + """ + output_markdown = "" + + for folder, classes in node_classes.items(): + if folder in documentation_omit_folders: + continue + folder_title = folder_name_to_title(folder) + output_markdown += "\n## " + folder_title + "\n\n" + for cls in classes: + if cls.constructors: + output_markdown_params = ", ".join( + ("%s=%s" % (param.name, param.default)) for param in cls.constructors[0]) + output_markdown += "- **%s**: %s `(%s)`\n" % (cls.name, cls.docs, output_markdown_params) + header = "# Node reference library\n" + output_markdown = header + output_markdown + return output_markdown + + +def generate_node_table(node_classes) -> str: + """ + Generate a tabular list of all Node classes, in Markdown format. + Used for README.md. + + Args: + node_classes: dict of all Node classes + + Returns: + The table of node classes, grouped by category + """ + output = "| Category | Classes |\n" + output += "|:---------|:---------|\n" + for folder, classes in node_classes.items(): + if folder in documentation_omit_folders: + continue + + category_text = folder_name_to_title(folder) + output += "| **%s** | %s |\n" % (category_text, ", ".join([cls.name for cls in classes])) + return output + + +def main(args): + source_files = get_all_source_files() + node_classes = parse_node_classes(source_files) if args.markdown: - print("# Node reference library") - print() - print(markdown) + print(generate_markdown(node_classes)) elif args.table: - output_table = "| Category | Classes |\n" - output_table += "|:---------|:---------|\n" - for category, classes in class_categories.items(): - category_text = ": ".join(text.title() for text in category.split("/")) - output_table += "| **%s** | %s |\n" % (category_text, ", ".join(classes)) - print(output_table) + print(generate_node_table(node_classes)) else: - print(output) + print(generate_bindings(node_classes)) if __name__ == "__main__": diff --git a/docs/library/index.md b/docs/library/index.md index 37fe7df9..77ff243f 100644 --- a/docs/library/index.md +++ b/docs/library/index.md @@ -1,6 +1,5 @@ # Node reference library - ## Analysis - **CrossCorrelate**: Outputs the cross-correlation of the input signal with the given buffer. If hop_size is zero, calculates the cross-correlation every sample. `(input=nullptr, buffer=nullptr, hop_size=0)` @@ -34,7 +33,7 @@ - **Line**: Line segment with the given start/end values and duration. If loop is true, repeats indefinitely. Retriggers on a clock signal. `(from=0.0, to=1.0, time=1.0, loop=0, clock=nullptr)` - **RectangularEnvelope**: Rectangular envelope with the given sustain duration. `(sustain_duration=1.0, clock=nullptr)` -## Fft +## FFT - **FFTContinuousPhaseVocoder**: Continuous phase vocoder. Requires an FFT* input. `(input=nullptr, rate=1.0)` - **FFTConvolve**: Frequency-domain convolution, using overlap-add. Useful for convolution reverb, with the input buffer containing an impulse response. Requires an FFT* input. `(input=nullptr, buffer=nullptr)` diff --git a/source/src/python/nodes.cpp b/source/src/python/nodes.cpp index 201f42e0..3db56575 100644 --- a/source/src/python/nodes.cpp +++ b/source/src/python/nodes.cpp @@ -3,17 +3,17 @@ void init_python_nodes(py::module &m) { /*-------------------------------------------------------------------------------- - * Node subclasses - *-------------------------------------------------------------------------------*/ - py::class_>(m, "AudioIn", "AudioIn") + * Node subclasses + *-------------------------------------------------------------------------------*/ + py::class_>(m, "AudioIn", "Audio input") .def(py::init<>()); - py::class_>(m, "AudioOut_Abstract", "AudioOut_Abstract"); + py::class_>(m, "AudioOut_Abstract", "Abstract audio output"); - py::class_>(m, "AudioOut_Dummy", "AudioOut_Dummy") + py::class_>(m, "AudioOut_Dummy", "Dummy audio output for offline processing") .def(py::init(), "num_channels"_a = 2, "buffer_size"_a = SIGNALFLOW_DEFAULT_BLOCK_SIZE); - py::class_>(m, "AudioOut", "AudioOut") + py::class_>(m, "AudioOut", "Audio output") .def(py::init(), "backend_name"_a = "", "device_name"_a = "", "sample_rate"_a = 0, "buffer_size"_a = 0); py::class_>(m, "CrossCorrelate", "Outputs the cross-correlation of the input signal with the given buffer. If hop_size is zero, calculates the cross-correlation every sample.")