Skip to content

Commit

Permalink
Refactor generate-node-python-bindings.py
Browse files Browse the repository at this point in the history
  • Loading branch information
ideoforms committed Nov 9, 2023
1 parent bdc4242 commit f5654c9
Show file tree
Hide file tree
Showing 5 changed files with 137 additions and 81 deletions.
6 changes: 6 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:

```
Expand Down
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
193 changes: 122 additions & 71 deletions auxiliary/scripts/generate-node-python-bindings.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,6 @@

import os
import re
import sys
import glob
import argparse
import subprocess
Expand All @@ -35,16 +34,17 @@ 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"]
omitted_classes = ["VampAnalysis", "GrainSegments", "FFTNoiseGate", "FFTZeroPhase", "FFTOpNode", "FFTNode",
"StochasticNode"]
macos_only_classes = ["MouseX", "MouseY", "MouseDown", "FFTConvolve"]
known_parent_classes = ["Node", "StochasticNode"]
documentation_omit_folders = ["io"]


def get_all_source_files() -> list[str]:
Expand All @@ -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
Expand All @@ -84,14 +81,15 @@ def generate_class_bindings(class_name: str,
.def(py::init<std::vector<NodeRef>>(), "frequency"_a = NodeRef(440.0))
.def(py::init<std::vector<float>>(), "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:
Expand Down Expand Up @@ -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)

Expand Down Expand Up @@ -222,58 +220,111 @@ 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)
{{
/*--------------------------------------------------------------------------------
* 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__":
Expand Down
3 changes: 1 addition & 2 deletions docs/library/index.md
Original file line number Diff line number Diff line change
@@ -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)`
Expand Down Expand Up @@ -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)`
Expand Down
12 changes: 6 additions & 6 deletions source/src/python/nodes.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -3,17 +3,17 @@
void init_python_nodes(py::module &m)
{
/*--------------------------------------------------------------------------------
* Node subclasses
*-------------------------------------------------------------------------------*/
py::class_<AudioIn, Node, NodeRefTemplate<AudioIn>>(m, "AudioIn", "AudioIn")
* Node subclasses
*-------------------------------------------------------------------------------*/
py::class_<AudioIn, Node, NodeRefTemplate<AudioIn>>(m, "AudioIn", "Audio input")
.def(py::init<>());

py::class_<AudioOut_Abstract, Node, NodeRefTemplate<AudioOut_Abstract>>(m, "AudioOut_Abstract", "AudioOut_Abstract");
py::class_<AudioOut_Abstract, Node, NodeRefTemplate<AudioOut_Abstract>>(m, "AudioOut_Abstract", "Abstract audio output");

py::class_<AudioOut_Dummy, AudioOut_Abstract, NodeRefTemplate<AudioOut_Dummy>>(m, "AudioOut_Dummy", "AudioOut_Dummy")
py::class_<AudioOut_Dummy, Node, NodeRefTemplate<AudioOut_Dummy>>(m, "AudioOut_Dummy", "Dummy audio output for offline processing")
.def(py::init<int, int>(), "num_channels"_a = 2, "buffer_size"_a = SIGNALFLOW_DEFAULT_BLOCK_SIZE);

py::class_<AudioOut, AudioOut_Abstract, NodeRefTemplate<AudioOut>>(m, "AudioOut", "AudioOut")
py::class_<AudioOut, Node, NodeRefTemplate<AudioOut>>(m, "AudioOut", "Audio output")
.def(py::init<std::string, std::string, int, int>(), "backend_name"_a = "", "device_name"_a = "", "sample_rate"_a = 0, "buffer_size"_a = 0);

py::class_<CrossCorrelate, Node, NodeRefTemplate<CrossCorrelate>>(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.")
Expand Down

0 comments on commit f5654c9

Please sign in to comment.