diff --git a/.gitignore b/.gitignore index fde7e150..69c1dd03 100755 --- a/.gitignore +++ b/.gitignore @@ -62,3 +62,4 @@ Documentation/Doc_sources/score-documentation/site .vscode +*.pyc diff --git a/cmake/avendish.max.cmake b/cmake/avendish.max.cmake index b01aac30..7a60fad4 100644 --- a/cmake/avendish.max.cmake +++ b/cmake/avendish.max.cmake @@ -274,6 +274,9 @@ EXPORTS DESTINATION "max/help/$/,>${AVND_C_NAME}.maxhelp" PROPERTY AVND_MAX_HELP OVERRIDE "${AVND_HELP_PATCH}" + # The name the external is actually registered under (the object box must + # use it -- the class's own c_name() may differ from the CMake C_NAME). + EXTRA_ARG "${AVND_C_NAME}" ) endfunction() diff --git a/cmake/avendish.pd.cmake b/cmake/avendish.pd.cmake index b8cf9eba..7ada8244 100644 --- a/cmake/avendish.pd.cmake +++ b/cmake/avendish.pd.cmake @@ -142,6 +142,9 @@ function(avnd_make_pd) DESTINATION "pd/$/,>${AVND_C_NAME}-help.pd" PROPERTY AVND_PD_HELP OVERRIDE "${AVND_HELP_PATCH}" + # The name the external is actually registered under (the object box must + # use it -- the class's own c_name() may differ from the CMake C_NAME). + EXTRA_ARG "${AVND_C_NAME}" ) endfunction() diff --git a/examples/Demos/GeneratePatches.cpp b/examples/Demos/GeneratePatches.cpp index 08e5952f..b2da3459 100644 --- a/examples/Demos/GeneratePatches.cpp +++ b/examples/Demos/GeneratePatches.cpp @@ -45,19 +45,36 @@ struct port_t std::string type; // parameter / audio / midi / texture / message / callback / ... // type == "parameter" - std::string value_type; // float / int / bool / string / enum / unknown + std::string value_type; // float / int / bool / string / enum / list / array / + // aggregate / xy / rgba / ... / unknown bool is_control = false; bool is_value_port = false; std::optional range; std::vector choices; // enum std::optional default_value; std::string widget; + int components = 0; // fixed arity of array / aggregate value types // type == "audio" std::string audio_sample_format; // float / double - std::string audio_port_format; // sample / channel / bus + std::string audio_port_format; // sample / channel / bus / frame + int audio_channels = 0; // statically-known channel count, 0 = dynamic }; +// The number of Pd signal inlets/outlets a set of audio ports expands to: +// each statically-sized port contributes its channels; when only dynamic +// buses are present Pd defaults the whole bus set to a single channel. +int pd_signal_count(const std::vector& audio_ports) +{ + if(audio_ports.empty()) + return 0; + int fixed = 0; + for(const auto* a : audio_ports) + if(a->audio_channels > 0) + fixed += a->audio_channels; + return fixed > 0 ? fixed : 1; +} + struct message_t { std::string name; @@ -79,8 +96,24 @@ struct model_t std::vector inputs; std::vector outputs; std::vector messages; + std::vector init_arguments; // T::initialize signature }; +// Default creation arguments for an object requiring initialize(...) args, as +// a Pd/Max object-box suffix (numbers -> 0, strings -> a placeholder symbol). +std::string default_init_args(const model_t& m) +{ + std::string s; + for(const auto& a : m.init_arguments) + { + if(a == "float" || a == "double" || a == "int" || a == "bool") + s += " 0"; + else + s += " arg"; + } + return s; +} + std::string str(const json& o, const char* key, std::string_view fallback = {}) { if(auto it = o.find(key); it != o.end() && it->is_string()) @@ -103,7 +136,7 @@ range_t parse_range(const json& r) port_t parse_port(const json& p) { port_t out; - out.name = str(p, "name", "port"); + out.name = str(p, "name"); out.description = str(p, "description"); out.type = str(p, "type", "unknown"); @@ -122,11 +155,15 @@ port_t parse_port(const json& p) out.choices.push_back(c.get()); if(auto dit = par.find("default"); dit != par.end()) out.default_value = *dit; + if(auto cit = par.find("components"); cit != par.end() && cit->is_number()) + out.components = cit->get(); } if(auto ait = p.find("audio"); ait != p.end()) { out.audio_sample_format = str(*ait, "sample_format"); out.audio_port_format = str(*ait, "port_format"); + if(auto cit = ait->find("channels"); cit != ait->end() && cit->is_number()) + out.audio_channels = cit->get(); } return out; } @@ -146,12 +183,31 @@ model_t parse_model(const json& j) m.author = str(md, "author"); m.version = str(md, "version"); } + // Unnamed parameter ports get the same positional fallback name the + // runtime bindings and the golden reference files use: p within the + // parameter ports (the pd binding accepts [p value( for them). + auto name_ports = [](std::vector& ports) { + int param_idx = 0; + for(auto& p : ports) + { + if(p.type == "parameter") + { + if(p.name.empty()) + p.name = "p" + std::to_string(param_idx); + param_idx++; + } + else if(p.name.empty()) + p.name = p.type; + } + }; if(auto it = j.find("inputs"); it != j.end() && it->is_array()) for(const auto& p : *it) m.inputs.push_back(parse_port(p)); if(auto it = j.find("outputs"); it != j.end() && it->is_array()) for(const auto& p : *it) m.outputs.push_back(parse_port(p)); + name_ports(m.inputs); + name_ports(m.outputs); if(auto it = j.find("messages"); it != j.end() && it->is_array()) { for(const auto& msg : *it) @@ -165,6 +221,10 @@ model_t parse_model(const json& j) m.messages.push_back(mm); } } + if(auto it = j.find("init"); it != j.end()) + if(auto ait = it->find("arguments"); ait != it->end() && ait->is_array()) + for(const auto& a : *ait) + m.init_arguments.push_back(a.is_string() ? a.get() : a.dump()); return m; } @@ -277,6 +337,44 @@ struct pd_patch } }; +// Number of scalar components a multi-component control's demo message should +// carry: named shapes have a fixed arity, arrays/aggregates declare theirs in +// the dump, resizable lists get three demo elements. 0 = not multi-component. +int component_count(const port_t& c) +{ + if(c.value_type == "xy") + return 2; + if(c.value_type == "xyz" || c.value_type == "rgb") + return 3; + if(c.value_type == "xyzw" || c.value_type == "rgba") + return 4; + if(c.value_type == "array" || c.value_type == "aggregate") + return c.components > 0 ? c.components : 3; + if(c.value_type == "list") + return 3; + return 0; +} + +// A sensible demo value for one component of a control: the range's init or +// midpoint when known, else 0.5. +std::string component_default(const port_t& c) +{ + if(c.range) + { + if(c.range->init) + return trim_num(*c.range->init); + if(c.range->min && c.range->max) + return trim_num(*c.range->min + 0.5 * (*c.range->max - *c.range->min)); + } + return "0.5"; +} + +bool is_impulse(const port_t& c) +{ + return c.widget == "bang" || c.widget == "button" || c.widget == "pushbutton" + || c.widget == "impulse"; +} + // Emit the widget that drives one control. Returns {widget_index, message_index} // where the message [selector ...( is wired to the object's left inlet. Returns // widget_index = -1 when only a message box (no live widget) is produced. @@ -293,27 +391,50 @@ pd_driver pd_emit_control(pd_patch& p, const port_t& c, int x, int y) + ";"); }; - if(c.value_type == "bool" || c.widget == "toggle" || c.widget == "checkbox") + if(is_impulse(c)) + { + // Bang/impulse port: [( engages it, the following bang runs the + // object -- both fired sequentially from one clickable message box + // (the escaped comma is Pd's in-box message separator). + d.widget = p.add( + "#X obj " + std::to_string(x) + " " + std::to_string(y) + + " bng 17 250 50 0 empty empty empty 17 7 0 10 #fcfcfc #000000 " + "#000000;"); + msg(sel + " \\, bang"); + } + else if(c.value_type == "bool" || c.widget == "toggle" || c.widget == "checkbox") { d.widget = p.add( "#X obj " + std::to_string(x) + " " + std::to_string(y) + " tgl 17 0 empty empty empty 17 7 0 10 #fcfcfc #000000 #000000 0 1;"); msg(sel + " \\$1"); } - else if(c.value_type == "enum" && !c.choices.empty()) + else if(c.value_type == "enum") { - const int n = static_cast(c.choices.size()); - d.widget = p.add( - "#X obj " + std::to_string(x) + " " + std::to_string(y) + " hradio 17 1 " - + std::to_string(n) - + " 0 empty empty empty 0 -8 0 10 #fcfcfc #000000 #000000 0;"); + if(!c.choices.empty()) + { + // hradio args: size new_old init NUMBER ... (number of cells is 4th) + const int n = static_cast(c.choices.size()); + d.widget = p.add( + "#X obj " + std::to_string(x) + " " + std::to_string(y) + + " hradio 17 1 0 " + std::to_string(n) + + " empty empty empty 0 -8 0 10 #fcfcfc #000000 #000000 0;"); + } + else + { + d.widget + = p.add("#X floatatom " + std::to_string(x) + " " + std::to_string(y) + + " 5 0 0 0 - - - 0;"); + } msg(sel + " \\$1"); // enum settable by index } else if(c.value_type == "string") { + // A symbol-atom box is its own record type in the Pd file format + // (not an object named "symbolatom" -- that would fail to instantiate). d.widget = p.add( - "#X obj " + std::to_string(x) + " " + std::to_string(y) - + " symbolatom 12 0 0 0 - - - 0;"); + "#X symbolatom " + std::to_string(x) + " " + std::to_string(y) + + " 12 0 0 0 - - - 0;"); msg(sel + " \\$1"); } else if(c.value_type == "int") @@ -340,10 +461,19 @@ pd_driver pd_emit_control(pd_patch& p, const port_t& c, int x, int y) } msg(sel + " \\$1"); } + else if(const int nc = component_count(c); nc > 0) + { + // Multi-component control (xy / rgb / ...): a clickable message prefilled + // with one demo value per component (the binding accepts a value list). + std::string body = sel; + for(int i = 0; i < nc; i++) + body += " " + component_default(c); + msg(body); + } else { - // bang / impulse, or a complex/multi-component control: emit an editable - // message box the user can click, prefilled with the selector. + // A complex/container control: emit an editable message box the user can + // click, prefilled with the selector. msg(sel); } @@ -357,8 +487,21 @@ std::string pd_port_typestr(const port_t& c) if(c.type == "parameter") { std::string s = c.value_type.empty() ? "control" : c.value_type; + if(is_impulse(c)) + s = "bang"; + else if(const int nc = component_count(c); nc > 0 && c.value_type != "list") + s += " (" + std::to_string(nc) + " values)"; + else if(c.value_type == "list") + s += " (any number of values)"; if(c.range && c.range->min && c.range->max) s += " (" + trim_num(*c.range->min) + ".." + trim_num(*c.range->max) + ")"; + if(!c.choices.empty()) + { + s += " ["; + for(std::size_t i = 0; i < c.choices.size(); ++i) + s += (i ? " | " : "") + std::to_string(i) + "=" + c.choices[i]; + s += "]"; + } return s; } if(c.type == "audio") @@ -369,8 +512,10 @@ std::string pd_port_typestr(const port_t& c) // Pure Data netlist help patch: title, description, a live interactive demo // (a widget wired to each control's left-inlet message, osc~ sources for audio // in, clip~/dac~ for audio out, sinks on outlets), and labelled -// inlets/outlets/arguments sections. -void emit_pd(const model_t& m, std::ostream& out) +// inlets/outlets/arguments sections. `external_name` is the name the external +// is registered under (the CMake C_NAME); the class's own c_name() may differ, +// and the object box must use the registered one. +void emit_pd(const model_t& m, std::ostream& out, const std::string& external_name) { pd_patch p; @@ -384,13 +529,15 @@ void emit_pd(const model_t& m, std::ostream& out) p.text(8, 54, blurb(m), 86); // --- the object (created early so its index is known to connections) --- - std::vector controls, audio_in; + std::vector controls, audio_in, other_in; for(const auto& c : m.inputs) { if(c.type == "parameter") controls.push_back(&c); else if(c.type == "audio") audio_in.push_back(&c); + else + other_in.push_back(&c); // midi / texture / buffer / ...: documented below } const int demo_top = 92; @@ -402,7 +549,11 @@ void emit_pd(const model_t& m, std::ostream& out) const int msgs_h = static_cast(m.messages.size()) * row; const int y_obj = demo_top + tallest * row + msgs_h + 50; - const std::string create = m.c_name.empty() ? m.name : m.c_name; + const std::string create + = (!external_name.empty() ? external_name + : m.c_name.empty() ? m.name + : m.c_name) + + default_init_args(m); const int obj_idx = p.add( "#X obj 24 " + std::to_string(y_obj) + " " + pd_escape(create) + ";"); @@ -427,40 +578,52 @@ void emit_pd(const model_t& m, std::ostream& out) y += row; } - // --- audio inputs: osc~ sources into the signal inlets --- + // --- audio inputs: osc~ sources into the signal inlets. The binding turns + // the audio input ports into pd_signal_count() signal inlets, so wire one + // source per signal inlet (not per port). --- + const int in_sig = pd_signal_count(audio_in); int ax = 24; - for(std::size_t k = 0; k < audio_in.size(); ++k) + for(int k = 0; k < in_sig; ++k) { const int osc = p.add( "#X obj " + std::to_string(ax) + " " + std::to_string(y_obj - 40) + " osc~ 220;"); - p.connect(osc, 0, obj_idx, static_cast(k)); + p.connect(osc, 0, obj_idx, k); ax += 70; } - // --- outputs: sinks in dump order (= outlet order) --- + // --- outputs: the audio binding exposes one signal outlet per channel, + // then one message outlet per non-audio output in declaration order. --- + std::vector audio_out; + for(const auto& o : m.outputs) + if(o.type == "audio") + audio_out.push_back(&o); + const int out_sig = pd_signal_count(audio_out); + int dac_idx = -1; int ox = 24, oy = y_obj + 44; - for(std::size_t i = 0; i < m.outputs.size(); ++i) + for(int k = 0; k < out_sig; ++k) + { + const int clip = p.add( + "#X obj " + std::to_string(ox) + " " + std::to_string(oy) + + " clip~ -0.95 0.95;"); + p.connect(obj_idx, k, clip, 0); + if(dac_idx < 0) + dac_idx = p.add("#X obj 24 " + std::to_string(oy + 40) + " dac~;"); + p.connect(clip, 0, dac_idx, k % 2); + ox += 70; + } + int ctl_outlet = out_sig; + for(const auto& o : m.outputs) { - const port_t& o = m.outputs[i]; if(o.type == "audio") - { - const int clip = p.add( - "#X obj " + std::to_string(ox) + " " + std::to_string(oy) - + " clip~ -0.95 0.95;"); - p.connect(obj_idx, static_cast(i), clip, 0); - if(dac_idx < 0) - dac_idx = p.add("#X obj 24 " + std::to_string(oy + 40) + " dac~;"); - p.connect(clip, 0, dac_idx, static_cast(i) % 2); - ox += 70; - } - else if(o.type == "message" || o.type == "callback") + continue; + if(o.type == "message" || o.type == "callback") { const int pr = p.add( "#X obj " + std::to_string(ox) + " " + std::to_string(oy) + " print " + selector(o.name) + ";"); - p.connect(obj_idx, static_cast(i), pr, 0); + p.connect(obj_idx, ctl_outlet, pr, 0); ox += 90; } else @@ -468,9 +631,10 @@ void emit_pd(const model_t& m, std::ostream& out) const int fa = p.add( "#X floatatom " + std::to_string(ox) + " " + std::to_string(oy) + " 5 0 0 0 - - - 0;"); - p.connect(obj_idx, static_cast(i), fa, 0); + p.connect(obj_idx, ctl_outlet, fa, 0); ox += 60; } + ctl_outlet++; } // --- reference sections (text only) below the demo --- @@ -486,17 +650,36 @@ void emit_pd(const model_t& m, std::ostream& out) + (c->description.empty() ? "" : ": " + c->description)); for(const auto& msg : m.messages) sy += p.text(20, sy, selector(msg.name) + " - message"); + for(const auto* c : other_in) + sy += p.text(20, sy, c->name + " - " + pd_port_typestr(*c) + + " (no Pd representation)" + + (c->description.empty() ? "" : ": " + c->description)); if(!m.outputs.empty()) { sy += 10; p.divider(8, sy, "outlets"); sy += 20; - int oi = 0; + int sig_k = 0, oi = out_sig; for(const auto& o : m.outputs) - sy += p.text(20, sy, std::to_string(oi++) + ") " + o.name + " - " - + pd_port_typestr(o) + { + std::string label; + if(o.type == "audio") + { + const int nch = o.audio_channels > 0 ? o.audio_channels + : (out_sig > sig_k ? out_sig - sig_k : 1); + label = std::to_string(sig_k); + if(nch > 1) + label += ".." + std::to_string(sig_k + nch - 1); + sig_k += nch; + } + else + { + label = std::to_string(oi++); + } + sy += p.text(20, sy, label + ") " + o.name + " - " + pd_port_typestr(o) + (o.description.empty() ? "" : ": " + o.description)); + } } const int ncols = nctrl > 0 ? (nctrl + rows_per_col - 1) / rows_per_col : 1; @@ -557,20 +740,22 @@ struct max_patch } }; -void emit_max(const model_t& m, std::ostream& out) +void emit_max(const model_t& m, std::ostream& out, const std::string& external_name) { max_patch p; p.box("comment", m.name, 40, 16, 400, 20, 1, 0); p.box("comment", blurb(m), 40, 38, 600, 20, 1, 0); - std::vector controls, audio_in; + std::vector controls, audio_in, other_in; for(const auto& c : m.inputs) { if(c.type == "parameter") controls.push_back(&c); else if(c.type == "audio") audio_in.push_back(&c); + else + other_in.push_back(&c); // midi / texture / buffer / ...: documented below } const double row = 36; @@ -578,7 +763,11 @@ void emit_max(const model_t& m, std::ostream& out) const double y_obj = demo_top + (controls.size() + m.messages.size()) * row + 60; - const std::string create = m.c_name.empty() ? m.name : m.c_name; + const std::string create + = (!external_name.empty() ? external_name + : m.c_name.empty() ? m.name + : m.c_name) + + default_init_args(m); const std::string obj = p.box( "newobj", create, 40, y_obj, 240, 22, std::max(1, static_cast(audio_in.size())), @@ -590,7 +779,14 @@ void emit_max(const model_t& m, std::ostream& out) const std::string sel = selector(c->name); std::string widget, body = sel + " $1"; std::string maxclass; - if(c->value_type == "bool") + if(is_impulse(*c)) + { + // engage the impulse, then run the object -- both from one click + // (the comma is Max's in-box message separator). + maxclass = "button"; + body = sel + ", bang"; + } + else if(c->value_type == "bool") maxclass = "toggle"; else if(c->value_type == "int" || c->value_type == "enum") maxclass = "number"; @@ -598,10 +794,17 @@ void emit_max(const model_t& m, std::ostream& out) maxclass = "flonum"; else if(c->value_type == "string") maxclass = ""; // message-only + else if(const int nc = component_count(*c); nc > 0) + { + maxclass = ""; + body = sel; + for(int i = 0; i < nc; i++) + body += " " + component_default(*c); + } else { - maxclass = "button"; - body = sel; // bang / impulse + maxclass = ""; + body = sel; } if(!maxclass.empty()) @@ -613,7 +816,9 @@ void emit_max(const model_t& m, std::ostream& out) } else { - const std::string msg = p.box("message", sel + " value", 200, y, 140, 22); + if(c->value_type == "string") + body = sel + " test"; + const std::string msg = p.box("message", body, 200, y, 140, 22); p.line(msg, 0, obj, 0); } p.box("comment", c->name + " - " + pd_port_typestr(*c) @@ -661,6 +866,49 @@ void emit_max(const model_t& m, std::ostream& out) } } + // --- reference sections (comments) below the demo --- + double sy = oy + (dac.empty() ? 50 : 110); + auto section = [&](std::string_view title) { + p.box("comment", title, 40, sy, 500, 20, 1, 0); + sy += 24; + }; + if(!m.messages.empty()) + { + section("messages:"); + for(const auto& msg : m.messages) + { + std::string args; + for(const auto& a : msg.arguments) + args += " <" + a + ">"; + p.box("comment", selector(msg.name) + args, 60, sy, 500, 20, 1, 0); + sy += 22; + } + } + if(!other_in.empty()) + { + section("other inputs:"); + for(const auto* c : other_in) + { + p.box("comment", c->name + " - " + pd_port_typestr(*c) + + (c->description.empty() ? "" : ": " + c->description), + 60, sy, 500, 20, 1, 0); + sy += 22; + } + } + if(!m.outputs.empty()) + { + section("outlets:"); + int oi = 0; + for(const auto& o : m.outputs) + { + p.box("comment", std::to_string(oi++) + ") " + o.name + " - " + + pd_port_typestr(o) + + (o.description.empty() ? "" : ": " + o.description), + 60, sy, 500, 20, 1, 0); + sy += 22; + } + } + p.write(out); } @@ -847,9 +1095,9 @@ int run(const std::string& backend, const std::string& in_path, } if(backend == "pd") - emit_pd(m, out); + emit_pd(m, out, hint); else if(backend == "max" || backend == "maxhelp") - emit_max(m, out); + emit_max(m, out, hint); else if(backend == "godot") emit_godot(m, out, hint); else if(backend == "td" || backend == "touchdesigner") diff --git a/include/avnd/binding/dump/DumpCBOR.hpp b/include/avnd/binding/dump/DumpCBOR.hpp index 425844f9..67bf3d35 100644 --- a/include/avnd/binding/dump/DumpCBOR.hpp +++ b/include/avnd/binding/dump/DumpCBOR.hpp @@ -313,6 +313,19 @@ void print_audio(dump_json::value obj) { obj["port_format"] = "unknown"; } + + // Statically-known channel count (fixed busses / mono ports); dynamic + // busses omit it (the host decides). + if constexpr(avnd::audio_sample_port || avnd::audio_sample_port + || avnd::mono_array_sample_port + || avnd::mono_array_sample_port) + { + obj["channels"] = 1; + } + else if constexpr(requires { type::channels(); }) + { + obj["channels"] = (int)type::channels(); + } } template @@ -369,12 +382,82 @@ void print_geometry(dump_json::value obj) obj["geometry"] = "mesh"; // geometry carried in a `.mesh` member } +// Container classification helpers (plain constexpr functions: MSVC's parser +// is unreliable with nested-requires inside requires-expressions here). +template +constexpr bool dump_scalar_sequence() +{ + if constexpr(requires(VT v) { + v.resize(3); + v[0]; + }) + return std::is_arithmetic_v()[0])>>; + else + return false; +} +template +constexpr bool dump_scalar_fixed_array() +{ + if constexpr(dump_scalar_sequence()) + return false; + else if constexpr(requires(VT v) { + std::tuple_size::value; + v[0]; + }) + return std::is_arithmetic_v()[0])>>; + else + return false; +} +template +constexpr bool dump_scalar_aggregate() +{ + if constexpr( + std::is_aggregate_v && !dump_scalar_sequence() + && !dump_scalar_fixed_array() && !avnd::iterable_ish) + { + if constexpr(avnd::pfr::tuple_size_v > 0) + return boost::mp11::mp_all_of, std::is_arithmetic>::value; + else + return false; + } + else + return false; +} + template void print_parameter(dump_json::value obj) { using type = typename Field::type; obj["value_type"] = parameter_value_type(Field{}); + // Container / aggregate value types: refine the "unknown" classification so + // patch generators and harnesses know how to drive them (a list message). + if constexpr(requires { type{}.value; }) + { + using vt = std::decay_t; + if constexpr(dump_scalar_sequence()) + { + if(std::string_view{"unknown"} == parameter_value_type(Field{})) + obj["value_type"] = "list"; + } + else if constexpr(dump_scalar_fixed_array()) + { + if(std::string_view{"unknown"} == parameter_value_type(Field{})) + { + obj["value_type"] = "array"; + obj["components"] = (int)std::tuple_size::value; + } + } + else if constexpr(dump_scalar_aggregate()) + { + // Named shapes (xy/rgba/...) keep their specific value_type; generic + // scalar aggregates get their component count. + obj["components"] = (int)avnd::pfr::tuple_size_v; + if(std::string_view{"unknown"} == parameter_value_type(Field{})) + obj["value_type"] = "aggregate"; + } + } + if constexpr(avnd::control_port) obj["control"] = true; else @@ -637,6 +720,14 @@ void dump(std::string_view path) print_messages(obj["messages"]); } + // Creation-argument signature (T::initialize), so patch generators can + // emit valid default arguments in the object box. + if constexpr(requires { avnd::function_reflection<&Processor::initialize>::count; }) + { + print_arguments( + avnd::function_reflection<&Processor::initialize>{}, obj["init"]); + } + auto res = doc.dump(); if(path.empty()) diff --git a/include/avnd/binding/golden/prototype.cpp.in b/include/avnd/binding/golden/prototype.cpp.in index e84c7421..bc8f5080 100644 --- a/include/avnd/binding/golden/prototype.cpp.in +++ b/include/avnd/binding/golden/prototype.cpp.in @@ -18,17 +18,13 @@ int main(int argc, char** argv) { using type = decltype(avnd::configure())::type; - // Every object is built for the golden harness (SDK-free, always on), so this - // single check enforces the name invariant for all back-ends at once: when an - // object declares an explicit halp_meta(c_name, ...), the avnd_addon_object / - // avnd_make_* C_NAME must equal it -- they name the same external (the file and - // class Pd/Max load by), and a mismatch ships something that can't be - // instantiated. Objects with no c_name leave the C_NAME authoritative. - static_assert( - avnd::c_name_matches("@AVND_C_NAME@"), - "avendish: the CMake C_NAME does not match this object's halp_meta(c_name, ...). " - "Make the avnd_addon_object / avnd_make_* C_NAME equal the object's c_name."); - - return golden::run(argc > 1 ? argv[1] : std::string_view{"golden.json"}); + // The CMake C_NAME is authoritative end-to-end: it names the external file, + // the class Pd/Max register (binding/pd/prototype.cpp.in), the generated + // help patch's object box and this golden's c_name. An object may declare a + // different halp_meta(c_name, ...) -- e.g. an addon renaming its externals + // in CMake -- and everything stays consistent, so a mismatch is a deliberate + // rename, not an error. run() records the authoritative name. + return golden::run( + argc > 1 ? argv[1] : std::string_view{"golden.json"}, "@AVND_C_NAME@"); } // clang-format on diff --git a/include/avnd/binding/golden/run.hpp b/include/avnd/binding/golden/run.hpp index a0aedd35..9f1bbe99 100644 --- a/include/avnd/binding/golden/run.hpp +++ b/include/avnd/binding/golden/run.hpp @@ -10,6 +10,10 @@ // subset needed to feed inputs and read outputs. #include +#include +#include +#include +#include #include #include #include @@ -21,6 +25,12 @@ #include #include #include +#include + +#include +#include + +#include #include #include @@ -55,6 +65,68 @@ int tex_bpp(const auto& tex) return 4; } +// --- container / aggregate control support --------------------------------- +// Deterministic element for slot k of a numeric container: integers count up +// from 1, floats spread over (0..1) -- bounded, distinct per slot, replayable +// by any backend as a plain list message. +template +ET container_element(int k) +{ + if constexpr(std::is_same_v) + return (k % 2) == 0; + else if constexpr(std::is_integral_v) + return static_cast(k + 1); + else + return static_cast(0.25 * (k + 1)); +} + +// Container classification helpers (plain constexpr functions: MSVC's parser +// is unreliable with nested-requires inside requires-expressions here). +// A resizable sequence of scalars (vector/list-ish). +template +constexpr bool scalar_sequence() +{ + if constexpr(requires(VT v) { + v.resize(3); + v[0]; + }) + return std::is_arithmetic_v()[0])>>; + else + return false; +} + +// A fixed-size indexable pack of scalars (std::array-ish). +template +constexpr bool scalar_fixed_array() +{ + if constexpr(scalar_sequence()) + return false; + else if constexpr(requires(VT v) { + std::tuple_size::value; + v[0]; + }) + return std::is_arithmetic_v()[0])>>; + else + return false; +} + +// An aggregate whose members are all scalars (xy / rgba / custom structs). +template +constexpr bool scalar_aggregate() +{ + if constexpr( + std::is_aggregate_v && !scalar_sequence() && !scalar_fixed_array() + && !avnd::iterable_ish) + { + if constexpr(avnd::pfr::tuple_size_v > 0) + return boost::mp11::mp_all_of, std::is_arithmetic>::value; + else + return false; + } + else + return false; +} + // A clean, matchable name: the port's own name if it has one, else a positional // key (unnamed ports have no reflected name on compilers without std reflection, // and get_name() then yields a compiler-mangled placeholder). @@ -71,16 +143,47 @@ std::string clean_name(int index) // One test case = override control #index with `value` (index < 0 = base case, // all controls at default). We sweep one control at a time: base, then each enum // mode and each numeric range endpoint. `value` is the enum ordinal or endpoint. +// kind extends the sweep beyond plain controls: `impulse` engages the targeted +// bang/impulse port for this case, `string_v` overrides a string control with +// `str`, and `message` invokes declared message port #index with deterministic +// arguments before processing (a backend replays it by sending the message, +// then triggering one processing pass). struct case_override { + enum kind_t + { + control = 0, + impulse, + string_v, + message + }; int index = -1; double value = 0.0; + kind_t kind = control; + std::string_view str{}; }; +constexpr std::string_view case_kind_name(case_override::kind_t k) +{ + switch(k) + { + case case_override::impulse: + return "impulse"; + case case_override::string_v: + return "string"; + case case_override::message: + return "message"; + default: + return "control"; + } +} + // Set a deterministic value on an input control field (honouring `ov` when it // targets this control), then append {index, name, value} to the ordered `arr`. +// Returns whether the field's value was actually written (callers then fire +// field.update(obj) exactly like a binding does when a control message lands). template -void set_and_record_control( +bool set_and_record_control( F& field, dump_json::document& jdoc, dump_json::value arr, int index, const case_override& ov) { @@ -127,19 +230,228 @@ void set_and_record_control( } else if constexpr(std::convertible_to) { - field.value = "test"; + if(over && ov.kind == case_override::string_v) + field.value = vt{ov.str}; + else + field.value = "test"; node["value"] = std::string_view{field.value}; } + else if constexpr(requires { + field.value.reset(); + field.value.emplace(); + }) + { + // Impulse / bang-like port (optional value). Engaged only when this case + // targets it; "bang" tells a backend harness to send a bang to this port. + if(over && ov.kind == case_override::impulse) + { + field.value.emplace(); + node["value"] = "bang"; + } + else + { + field.value.reset(); + node["value"] = "unrecorded"; + } + } + else if constexpr(scalar_sequence()) + { + // Vector/list of scalars: three deterministic elements, recorded as a + // JSON array (a backend replays it as a list message). + field.value.resize(3); + for(int k = 0; k < 3; k++) + field.value[k] = container_element>(k); + node["value"] = field.value; + } + else if constexpr(scalar_fixed_array()) + { + constexpr int N = std::tuple_size::value; + for(int k = 0; k < N; k++) + field.value[k] = container_element>(k); + node["value"] = field.value; + } + else if constexpr(scalar_aggregate()) + { + // Aggregate of scalars (xy / rgba / ...): one deterministic value per + // member, recorded as a JSON array in declaration order. + auto varr = node["value"]; + varr.ensure_array(); + int k = 0; + avnd::pfr::for_each_field(field.value, [&](auto& m) { + m = container_element>(k++); + varr.push_back(m); + }); + } else { - node["value"] = "unrecorded"; // container/struct: structural compare only + node["value"] = "unrecorded"; // opaque type: structural compare only + arr.push_back(node); + return false; } arr.push_back(node); + return true; +} + +// --- message-port support ------------------------------------------------- +// A message case invokes a declared message port with deterministic arguments, +// mirroring what a backend does when the object receives e.g. [add 2( in Pd. +// Only signatures whose (non-self) arguments are all scalars or strings are +// enumerated; anything else has no portable deterministic stimulus. + +template +using golden_msg_arg_ok = std::bool_constant< + std::is_arithmetic_v> + || std::is_same_v, std::string> + || std::is_same_v, std::string_view>>; + +// The argument list a caller must synthesize: the message's reflected argument +// list, minus a leading T& ("self") argument if the function takes one. +template ::value == 0> +struct golden_msg_args +{ + using type = L; +}; +template +struct golden_msg_args +{ + using first = std::decay_t>; + using type = std::conditional_t< + std::is_same_v, boost::mp11::mp_pop_front, L>; +}; +template +using golden_msg_args_t = typename golden_msg_args::type; + +template +consteval bool message_case_supported() +{ + if constexpr(std::is_void_v>) + return false; + else + { + using args = golden_msg_args_t::arguments>; + return boost::mp11::mp_all_of::value; + } +} + +// Deterministic value for one message argument. +template +auto make_msg_arg() +{ + using A = std::decay_t; + if constexpr(std::is_same_v) + return true; + else if constexpr(std::floating_point) + return A(0.5); + else if constexpr(std::is_arithmetic_v) + return A(2); + else + return std::string("msg"); +} + +// Invoke message M on `impl` with the given args, trying the same call shapes +// the pd/max bindings do (functor, member function, free function; with or +// without a leading self argument). +template +bool invoke_message_with_args(T& impl, auto&... args) +{ + if constexpr(requires(M m) { m(impl, args...); }) + { + M{}(impl, args...); + return true; + } + else if constexpr(requires(M m) { m(args...); }) + { + M{}(args...); + return true; + } + else + { + static constexpr auto f = avnd::message_get_func(); + if constexpr(std::is_member_function_pointer_v>) + { + if constexpr(requires { (impl.*f)(args...); }) + { + (impl.*f)(args...); + return true; + } + else if constexpr(requires { (impl.*f)(impl, args...); }) + { + (impl.*f)(impl, args...); + return true; + } + else + return false; + } + else + { + if constexpr(requires { f(impl, args...); }) + { + f(impl, args...); + return true; + } + else if constexpr(requires { f(args...); }) + { + f(args...); + return true; + } + else + return false; + } + } +} + +// Invoke message #target with deterministic args; append {index, name, args} +// to the case's inputs.messages array so a backend can replay it. +template +void run_message_case( + avnd::effect_container& effect, int target, dump_json::document& jdoc, + dump_json::value in_node) +{ + if constexpr(avnd::messages_introspection::size > 0) + { + int idx = 0; + avnd::messages_introspection::for_all( + avnd::get_messages(effect), [&](M& field) { + const int i = idx++; + if(i != target) + return; + auto mnode = jdoc.make_node(); + mnode["index"] = i; + mnode["name"] = clean_name(i); + auto args_arr = mnode["args"]; + args_arr.ensure_array(); + if constexpr(message_case_supported()) + { + using args_t + = golden_msg_args_t::arguments>; + [&](boost::mp11::mp_list*) { + auto tup = std::tuple(make_msg_arg()...); + std::apply( + [&](auto&... as) { + (args_arr.push_back(as), ...); + mnode["status"] = invoke_message_with_args(effect.effect, as...) + ? "invoked" + : "uninvokable"; + }, + tup); + }((args_t*)nullptr); + } + else + { + mnode["status"] = "unsupported"; + } + auto msgs = in_node["messages"]; + msgs.ensure_array(); + msgs.push_back(mnode); + }); + } } // Enumerate the OAT test cases for T: the base case, then for every enum input a -// case per non-zero mode, and for every ranged numeric input a case at min and -// at max. Bounded (sum, not product), so it stays small. +// case per non-zero mode, for every ranged numeric input a case at min and +// at max, a false case per bool, an alternate string per string input, an +// engaged case per impulse input, and one case per invocable message port. +// Bounded (sum, not product), so it stays small. template std::vector enumerate_cases() { @@ -154,7 +466,11 @@ std::vector enumerate_cases() avnd::get_inputs(probe), [&](auto& field) { using F = std::decay_t; using vt = std::decay_t; - if constexpr(std::is_enum_v) + if constexpr(std::is_same_v) + { + cases.push_back({idx, 0.0}); + } + else if constexpr(std::is_enum_v) { constexpr int C = (int)avnd::get_enum_choices_count(); for(int c = 1; c < C; ++c) @@ -171,6 +487,28 @@ std::vector enumerate_cases() cases.push_back({idx, (double)r.max}); } } + else if constexpr(std::convertible_to) + { + cases.push_back({idx, 0.0, case_override::string_v, "avnd"}); + } + else if constexpr(requires { + field.value.reset(); + field.value.emplace(); + }) + { + cases.push_back({idx, 1.0, case_override::impulse}); + } + idx++; + }); + } + if constexpr(avnd::messages_introspection::size > 0) + { + avnd::effect_container probe; + int idx = 0; + avnd::messages_introspection::for_all( + avnd::get_messages(probe), [&](M&) { + if constexpr(message_case_supported()) + cases.push_back({idx, 0.0, case_override::message}); idx++; }); } @@ -191,11 +529,75 @@ void record_output_control(F& field, dump_json::document& jdoc, dump_json::value node["value"] = static_cast(field.value); else if constexpr(std::convertible_to) node["value"] = std::string_view{field.value}; + else if constexpr(scalar_sequence() || scalar_fixed_array()) + node["value"] = field.value; + else if constexpr(scalar_aggregate()) + { + auto varr = node["value"]; + varr.ensure_array(); + avnd::pfr::for_each_field(field.value, [&](auto& m) { varr.push_back(m); }); + } else node["value"] = "unrecorded"; arr.push_back(node); } +// --- callback-output capture ------------------------------------------------ +// Callback outputs (avnd::callback / view_callback) fire during processing; a +// binding turns each firing into an outlet message. Record every firing's +// arguments so backends can diff them. One recorder per callback port; the +// recorder appends one array-of-args per event into the port's `events` node. +struct callback_recorder +{ + dump_json::document* jdoc{}; + dump_json::value events; + + template + void fire(const Args&... args) + { + auto ev = jdoc->make_node(); + ev.ensure_array(); + (push_arg(ev, args), ...); + events.push_back(ev); + } + + void push_arg(dump_json::value arr, const auto& a) + { + using A = std::decay_t; + auto n = jdoc->make_node(); + if constexpr(std::is_same_v || std::is_arithmetic_v) + n = a; + else if constexpr(std::is_enum_v) + n = static_cast(a); + else if constexpr(std::convertible_to) + n = std::string_view{a}; + else + n = "unrecorded"; + arr.push_back(n); + } +}; + +template typename F> +void wire_callback_view(callback_recorder& rec, F& call) +{ + call.context = &rec; + call.function = [](void* ptr, Args... args) -> R { + static_cast(ptr)->fire(args...); + if constexpr(!std::is_void_v) + return R{}; + }; +} + +template typename F> +void wire_callback_func(callback_recorder& rec, F& call) +{ + call = [&rec](Args... args) -> R { + rec.fire(args...); + if constexpr(!std::is_void_v) + return R{}; + }; +} + // Run ONE test case (fresh effect, override `ov` applied) and write its inputs // and outputs into `case_node`. Throws on failure (caught by run()). template @@ -267,6 +669,32 @@ void run_case(dump_json::document& jdoc, dump_json::value case_node, const case_ if constexpr(sizeof(control_buffers) > 1) control_buffers.reserve_space(effect, k_frames); + // Wire callback outputs to recorders (must happen before any message + // invocation or processing so every firing is captured -- and so the + // object never calls a null/empty callable). + std::deque cb_recorders; + std::vector cb_nodes; + if constexpr(avnd::callback_output_introspection::size > 0) + { + int cidx = 0; + avnd::callback_output_introspection::for_all( + avnd::get_outputs(effect), [&](C& port) { + auto node = jdoc.make_node(); + node["index"] = cidx; + node["name"] = clean_name(cidx); + auto ev = node["events"]; + ev.ensure_array(); + auto& rec = cb_recorders.emplace_back(&jdoc, ev); + using call_type = std::decay_t; + if constexpr(avnd::function_view_ish) + wire_callback_view(rec, port.call); + else if constexpr(avnd::function_ish) + wire_callback_func(rec, port.call); + cb_nodes.push_back(node); + cidx++; + }); + } + // --- inputs --- auto in = case_node["inputs"]; auto in_controls = in["controls"]; @@ -276,7 +704,13 @@ void run_case(dump_json::document& jdoc, dump_json::value case_node, const case_ int idx = 0; avnd::parameter_input_introspection::for_all( avnd::get_inputs(effect), [&](auto& field) { - set_and_record_control(field, jdoc, in_controls, idx++, ov); + if(set_and_record_control(field, jdoc, in_controls, idx++, ov)) + { + // Bindings fire the port's update hook when a control message + // lands; mirror that (it may fire callbacks, recorded above). + if constexpr(requires { field.update(effect.effect); }) + field.update(effect.effect); + } }); } @@ -302,7 +736,11 @@ void run_case(dump_json::document& jdoc, dump_json::value case_node, const case_ // have something to read (storage must outlive process()). GPU textures are // not handled (they need a graphics context). std::deque> tex_storage; + auto in_tex = in["texture"]; + in_tex.ensure_array(); if constexpr(avnd::cpu_texture_input_introspection::size > 0) + { + int tin = 0; avnd::cpu_texture_input_introspection::for_all( avnd::get_inputs(effect), [&](auto& port) { const int w = 16, h = 16, bpp = tex_bpp(port.texture); @@ -315,12 +753,44 @@ void run_case(dump_json::document& jdoc, dump_json::value case_node, const case_ = reinterpret_cast(buf.data()); if constexpr(requires { port.texture.changed; }) port.texture.changed = true; + // Record the fed image spec so a backend harness can synthesize + // the identical input: byte i = i mod 256, row-major, no padding. + auto node = jdoc.make_node(); + node["index"] = tin++; + node["width"] = w; + node["height"] = h; + node["bytes_per_pixel"] = bpp; + node["pattern"] = "mod256"; + in_tex.push_back(node); }); + } + + // Message case: deliver the message (like a backend receiving [name args() + // right before the processing pass. + if(ov.kind == case_override::message) + run_message_case(effect, ov.index, jdoc, in); // --- run --- - processor.process( - effect, avnd::span{in_ptrs.data(), (std::size_t)in_N}, - avnd::span{out_ptrs.data(), (std::size_t)out_N}, k_frames); + // Control objects taking a host tick have no audio path at all: the + // process adapters would static_assert. Synthesize one processing pass, + // like the pd/max bindings do on bang (frames = 1 so rate-dependent + // state advances identically across backends). + static constexpr bool tick_only_object + = avnd::has_tick && avnd::audio_input_introspection::size == 0 + && avnd::audio_output_introspection::size == 0; + if constexpr(tick_only_object) + { + typename T::tick t{}; + if constexpr(requires { t.frames; }) + t.frames = 1; + avnd::invoke_effect(effect, t); + } + else + { + processor.process( + effect, avnd::span{in_ptrs.data(), (std::size_t)in_N}, + avnd::span{out_ptrs.data(), (std::size_t)out_N}, k_frames); + } // --- outputs --- auto out = case_node["outputs"]; @@ -344,6 +814,12 @@ void run_case(dump_json::document& jdoc, dump_json::value case_node, const case_ out_audio.push_back(chnode); } + // Callback outputs: every firing captured by the recorders wired above. + auto out_cb = out["callbacks"]; + out_cb.ensure_array(); + for(auto& node : cb_nodes) + out_cb.push_back(node); + // CPU texture outputs: record dimensions + a content hash (exact pixels are // large; the hash is enough for a backend to match against). auto out_tex = out["texture"]; @@ -377,11 +853,14 @@ void run_case(dump_json::document& jdoc, dump_json::value case_node, const case_ } template -int run(std::string_view path) +int run(std::string_view path, std::string_view external_name = {}) { dump_json::document jdoc; auto root = jdoc.root(); - root["c_name"] = avnd::get_c_name(); + // The name backends register the external under (the CMake C_NAME) is what + // differential harnesses look artifacts up by; the introspected c_name is + // the fallback when the caller doesn't pass one. + root["c_name"] = !external_name.empty() ? external_name : avnd::get_c_name(); auto meta = root["meta"]; meta["seed"] = k_seed; meta["frames"] = k_frames; @@ -417,6 +896,7 @@ int run(std::string_view path) cnode["index"] = ci++; cnode["override_control"] = ov.index; cnode["override_value"] = ov.value; + cnode["kind"] = case_kind_name(ov.kind); try { run_case(jdoc, cnode, ov); diff --git a/include/avnd/binding/max/audio_processor.hpp b/include/avnd/binding/max/audio_processor.hpp index b6685279..d593576f 100644 --- a/include/avnd/binding/max/audio_processor.hpp +++ b/include/avnd/binding/max/audio_processor.hpp @@ -6,7 +6,9 @@ #include #include #include +#include #include +#include #include #include #include @@ -23,8 +25,8 @@ * * Inputs and outputs will be created according to the audio channel count. * Non-audio inputs will be processed through messages sent to the first port. - * - * TODO: support non-audio outputs. + * Non-audio outputs (e.g. an analyzer's control value) get message outlets + * after the signal outlet; their current values are emitted on bang. */ namespace max @@ -55,6 +57,11 @@ struct audio_processor : processor_common AVND_NO_UNIQUE_ADDRESS init_arguments init_setup; AVND_NO_UNIQUE_ADDRESS messages messages_setup; + // Message outlets for the non-audio outputs (analyzer values, callbacks...), + // to the right of the signal outlet. Indexed by output port index; audio + // ports keep a null slot. + std::array::size> control_outlets = {}; + int m_runtime_input_count{}; int m_runtime_output_count{}; @@ -72,6 +79,40 @@ struct audio_processor : processor_common // Audio inlet already created as we're a t_pxobject dsp_setup(&x_obj, 1); + // Message outlets for the non-audio outputs (analyzer values, callbacks). + // Max creates outlets right-to-left, so they are created BEFORE the signal + // outlet (ending up to its right), in reverse declaration order. + // Multi-instance containers whose get_outputs() yields a dummy + // (per-sample-port objects) are skipped. + if constexpr( + avnd::output_introspection::size > 0 + && requires { + avnd::output_introspection::for_all( + avnd::get_outputs(implementation), [](auto&) {}); + }) + { + static constexpr auto N = avnd::output_introspection::size; + std::array is_control{}; + int out_k = 0; + avnd::output_introspection::for_all( + avnd::get_outputs(implementation), [&is_control, &out_k](C&) { + is_control[out_k++] = !avnd::audio_port; + }); + for(int i = N; i-- > 0;) + if(is_control[i]) + control_outlets[i] = static_cast(outlet_new(&x_obj, nullptr)); + + // Wire callbacks so the object can fire them during processing. + out_k = 0; + avnd::output_introspection::for_all( + avnd::get_outputs(implementation), [this, &out_k](C& ctl) { + const int idx = out_k++; + if constexpr(avnd::callback) + if(control_outlets[idx]) + outputs::setup(ctl, *control_outlets[idx]); + }); + } + // Create an audio outlet if constexpr(output_channels != 0) { @@ -83,10 +124,61 @@ struct audio_processor : processor_common /// Initialize controls avnd::init_controls(implementation); + // Host-provided callables must never be left empty: prepare()/process() + // calling an empty std::function is a hard crash (e.g. variable_audio_bus + // request_channels). + if constexpr(avnd::audio_bus_input_introspection::size > 0) + avnd::audio_bus_input_introspection::for_all( + avnd::get_inputs(implementation), [](auto& p) { + if constexpr(requires { p.request_channels; }) + if(!p.request_channels) + p.request_channels = [](int) {}; + }); + if constexpr(avnd::audio_bus_output_introspection::size > 0) + avnd::audio_bus_output_introspection::for_all( + avnd::get_outputs(implementation), [](auto& p) { + if constexpr(requires { p.request_channels; }) + if(!p.request_channels) + p.request_channels = [](int) {}; + }); + /// Initialize polyphony implementation.init_channels(input_channels, output_channels); } + // Emit the current value of every non-audio output through its outlet. + // Called on bang: the dsp system runs operator(), so a bang after a signal + // block flushes the control values that block produced. + void commit_control_outputs() + { + if constexpr( + avnd::output_introspection::size > 0 + && requires { + avnd::output_introspection::for_all( + avnd::get_outputs(implementation), [](auto&) {}); + }) + { + int out_k = 0; + avnd::output_introspection::for_all( + avnd::get_outputs(implementation), [this, &out_k](C& ctl) { + const int idx = out_k++; + if(!control_outlets[idx]) + return; + if constexpr( + avnd::parameter_port && !avnd::sample_accurate_parameter_port) + { + value_to_max_dispatch(control_outlets[idx], ctl.value); + } + else if constexpr(avnd::dynamic_sample_accurate_parameter_port) + { + for(auto& [timestamp, val] : ctl.values) + value_to_max_dispatch(control_outlets[idx], val); + ctl.values.clear(); + } + }); + } + } + void destroy() { } void @@ -131,6 +223,20 @@ struct audio_processor : processor_common processor.process( implementation, avnd::span{ins, std::size_t(numins)}, avnd::span{outs, std::size_t(numouts)}, sampleframes); + + // Impulse-like (optional) inputs are one-shot: consumed by this block, + // else a single [Bang< message would fire on every subsequent block. + if constexpr( + avnd::parameter_input_introspection::size > 0 + && requires { + avnd::parameter_input_introspection::for_all( + avnd::get_inputs(implementation), [](auto&) {}); + }) + avnd::parameter_input_introspection::for_all( + avnd::get_inputs(implementation), [](F& field) { + if constexpr(requires { field.value.reset(); }) + field.value.reset(); + }); } void process_inlet_control(t_symbol* s, long argc, t_atom* argv) @@ -222,7 +328,7 @@ struct audio_processor : processor_common // being run in the dsp system. // Just bang the outputs: - // output_setup.commit(implementation); + commit_control_outputs(); } else { @@ -233,10 +339,6 @@ struct audio_processor : processor_common default: { // Apply the data to the inlets. process_inlet_control(s, argc, argv); - - // Then bang - // output_setup.commit(implementation); - break; } } diff --git a/include/avnd/binding/max/from_atoms.hpp b/include/avnd/binding/max/from_atoms.hpp index 645cb6f8..d729989d 100644 --- a/include/avnd/binding/max/from_atoms.hpp +++ b/include/avnd/binding/max/from_atoms.hpp @@ -324,13 +324,33 @@ struct from_atoms template bool operator()(T& v) const noexcept { - if(ac < 1) - return false; + using value_type = typename T::value_type; + if constexpr(std::is_empty_v) + { + // Impulse-like port: presence is the whole payload; any message + // ([Bang], [Bang 1]...) engages it. + v.emplace(); + return true; + } + else + { + if(ac < 1) + { + // Selector-only message: engage the optional with a default value, + // so bang-like ports can be triggered. + if constexpr(requires { v.emplace(); }) + { + v.emplace(); + return true; + } + return false; + } - typename T::value_type res; - (*this)(res); - v = std::move(res); - return true; + value_type res; + (*this)(res); + v = std::move(res); + return true; + } } template @@ -344,6 +364,7 @@ struct from_atoms { from_atom{av[i]}(field); } + ++i; // each field consumes the next atom (e.g. [In x y z( -> x, y, z) }); return true; } diff --git a/include/avnd/binding/max/helpers.hpp b/include/avnd/binding/max/helpers.hpp index c5f3b863..f075db33 100644 --- a/include/avnd/binding/max/helpers.hpp +++ b/include/avnd/binding/max/helpers.hpp @@ -34,6 +34,7 @@ concept convertible_to_atom_list_statically_impl (avnd::set_ish && convertible_to_fundamental_value_type && convertible_to_fundamental_value_type) || (avnd::map_ish && convertible_to_fundamental_value_type && convertible_to_fundamental_value_type) || (avnd::optional_ish && convertible_to_fundamental_value_type) || + (avnd::optional_ish && std::is_empty_v && std::is_default_constructible_v) || (avnd::variant_ish && boost::mp11::mp_all_of::value) || (avnd::tuple_ish && boost::mp11::mp_all_of::value) || (std::is_aggregate_v && !avnd::iterable_ish && boost::mp11::mp_all_of, convertible_to_fundamental_value_type_pred>::value) diff --git a/include/avnd/binding/max/message_processor.hpp b/include/avnd/binding/max/message_processor.hpp index 8d039f9d..9bcc5de3 100644 --- a/include/avnd/binding/max/message_processor.hpp +++ b/include/avnd/binding/max/message_processor.hpp @@ -85,7 +85,16 @@ struct message_processor : processor_common }; } - avnd::prepare(implementation, {}); + // A message object has no DSP context, so rate-dependent objects (e.g. + // tick-driven filters) get a fixed default rate. One frame per processing + // pass (= per bang), matching the synthesized tick in process(). + avnd::prepare( + implementation, + avnd::process_setup{ + .input_channels = 0, + .output_channels = 0, + .frames_per_buffer = 1, + .rate = 44100.}); /// Pass arguments if constexpr(avnd::can_initialize) @@ -109,10 +118,37 @@ struct message_processor : processor_common { // Do our stuff if it makes sense - some objects may not // even have a "processing" method - if_possible(implementation.effect()); + if constexpr(avnd::has_tick) + { + // Objects taking a host tick: synthesize one processing pass (frames=1, + // matching what the golden reference generator does for them). + typename T::tick t{}; + if constexpr(requires { t.frames; }) + t.frames = 1; + if_possible(implementation.effect(t)) else if_possible(implementation.effect()); + } + else + { + if_possible(implementation.effect()); + } // Then bang output_setup.commit(*this); + + // Impulse-like (optional) inputs are one-shot: they are only "present" for + // the processing pass that consumed them, else a single [Bang< message + // would keep firing on every subsequent processing pass. + if constexpr( + avnd::parameter_input_introspection::size > 0 + && requires { + avnd::parameter_input_introspection::for_all( + avnd::get_inputs(implementation), [](auto&) {}); + }) + avnd::parameter_input_introspection::for_all( + avnd::get_inputs(implementation), [](F& field) { + if constexpr(requires { field.value.reset(); }) + field.value.reset(); + }); } template diff --git a/include/avnd/binding/max/prototype.cpp.in b/include/avnd/binding/max/prototype.cpp.in index ab913458..9f598fdc 100644 --- a/include/avnd/binding/max/prototype.cpp.in +++ b/include/avnd/binding/max/prototype.cpp.in @@ -15,10 +15,12 @@ extern "C" AVND_EXPORTED_SYMBOL void ext_main(void* r) common_symbols_init(); [] { - if constexpr( - avnd::monophonic_audio_processor || avnd::polyphonic_audio_processor) + if constexpr(avnd::audio_processor) { - // If we're an audio effect, make a type with the whole DSP stuff + // If we process audio in any shape -- effect, generator or analyzer + // (audio in, control out: NOT covered by the mono/polyphonic effect + // concepts, which require matching in/out shapes) -- make a type with + // the whole DSP stuff. static const max::audio_processor_metaclass instance{}; } else if constexpr(max::max_jit_input_introspection::size > 0 diff --git a/include/avnd/binding/pd/audio_processor.hpp b/include/avnd/binding/pd/audio_processor.hpp index 49f164d8..ab5db096 100644 --- a/include/avnd/binding/pd/audio_processor.hpp +++ b/include/avnd/binding/pd/audio_processor.hpp @@ -6,7 +6,9 @@ #include #include #include +#include #include +#include #include #include #include @@ -23,8 +25,8 @@ * * Inputs and outputs will be created according to the audio channel count. * Non-audio inputs will be processed through messages sent to the first port. - * - * TODO: support non-audio outputs. + * Non-audio outputs (e.g. an analyzer's control value) get message outlets + * after the signal outlets; their current values are emitted on bang. */ namespace pd @@ -67,6 +69,11 @@ struct audio_processor AVND_NO_UNIQUE_ADDRESS init_arguments init_setup; AVND_NO_UNIQUE_ADDRESS messages messages_setup; + // Message outlets for the non-audio outputs (analyzer values, callbacks...), + // created after the signal outlets, in port declaration order. Indexed by + // output port index; audio ports keep a null slot. + std::array::size> control_outlets = {}; + // we don't use ctor / dtor, because // this breaks aggregate-ness... void init(int argc, t_atom* argv) @@ -97,8 +104,58 @@ struct audio_processor avnd::init_controls(implementation); } + // Host-provided callables must never be left empty: prepare()/process() + // calling an empty std::function is a hard crash (e.g. variable_audio_bus + // request_channels). + if constexpr(avnd::audio_bus_input_introspection::size > 0) + avnd::audio_bus_input_introspection::for_all( + avnd::get_inputs(implementation), [](auto& p) { + if constexpr(requires { p.request_channels; }) + if(!p.request_channels) + p.request_channels = [](int) {}; + }); + if constexpr(avnd::audio_bus_output_introspection::size > 0) + avnd::audio_bus_output_introspection::for_all( + avnd::get_outputs(implementation), [](auto& p) { + if constexpr(requires { p.request_channels; }) + if(!p.request_channels) + p.request_channels = [](int) {}; + }); + /// Initialize polyphony implementation.init_channels(input_channels, output_channels); + + // Message outlets for the non-audio outputs, created from the TYPE-level + // introspection so every container shape gets its outlets (multi-instance + // per-channel objects included -- their get_outputs() is a dummy and their + // control values cannot be committed, but the outlets must still exist so + // patches connecting to them stay valid). + if constexpr(avnd::output_introspection::size > 0) + { + avnd::output_introspection::for_all( + [this](avnd::field_reflection) { + if constexpr(!avnd::audio_port) + control_outlets[Idx] = outlet_new(&x_obj, symbol_for_port()); + }); + + // Wire callbacks so the object can fire them during processing (only + // possible when the container exposes single-instance outputs). + if constexpr(requires { + avnd::output_introspection::for_all( + avnd::get_outputs(implementation), [](auto&) {}); + }) + { + int out_k = 0; + avnd::output_introspection::for_all( + avnd::get_outputs(implementation), + [this, &out_k](Field& ctl) { + const int idx = out_k++; + if constexpr(avnd::callback) + if(control_outlets[idx]) + outputs::setup(ctl, *control_outlets[idx]); + }); + } + } } void destroy() { } @@ -164,9 +221,58 @@ struct audio_processor implementation, avnd::span{input, input_channels}, avnd::span{output, output_channels}, n); + // Impulse-like (optional) inputs are one-shot: consumed by this block, + // else a single [Bang< message would fire on every subsequent block. + if constexpr( + avnd::parameter_input_introspection::size > 0 + && requires { + avnd::parameter_input_introspection::for_all( + avnd::get_inputs(implementation), [](auto&) {}); + }) + avnd::parameter_input_introspection::for_all( + avnd::get_inputs(implementation), [](F& field) { + if constexpr(requires { field.value.reset(); }) + field.value.reset(); + }); + return ++w; } + // Emit the current value of every non-audio output through its outlet. + // Called on bang: the dsp system runs operator(), so a bang after a signal + // block flushes the control values that block produced (like [env~] etc.). + void commit_control_outputs() + { + if constexpr( + avnd::output_introspection::size > 0 + && requires { + avnd::output_introspection::for_all( + avnd::get_outputs(implementation), [](auto&) {}); + }) + { + int out_k = 0; + avnd::output_introspection::for_all( + avnd::get_outputs(implementation), + [this, &out_k](Field& ctl) { + const int idx = out_k++; + if(!control_outlets[idx]) + return; + if constexpr( + avnd::parameter_port + && !avnd::sample_accurate_parameter_port) + { + value_to_pd_dispatch(control_outlets[idx], ctl.value); + } + else if constexpr(avnd::dynamic_sample_accurate_parameter_port) + { + for(auto& [timestamp, val] : ctl.values) + value_to_pd_dispatch(control_outlets[idx], val); + ctl.values.clear(); + } + }); + } + } + void process(t_symbol* s, int argc, t_atom* argv) { // First try to process messages handled explicitely in the object @@ -186,7 +292,7 @@ struct audio_processor // being run in the dsp system. // Just bang the outputs: - // output_setup.commit(implementation); + commit_control_outputs(); } else { @@ -197,10 +303,6 @@ struct audio_processor default: { // Apply the data to the inlets. // process_inlet_control(s, argc, argv); // -> done in input_setup.process_inputs - - // Then bang - // output_setup.commit(implementation); - break; } } diff --git a/include/avnd/binding/pd/helpers.hpp b/include/avnd/binding/pd/helpers.hpp index 8cd392a7..82e6c438 100644 --- a/include/avnd/binding/pd/helpers.hpp +++ b/include/avnd/binding/pd/helpers.hpp @@ -32,6 +32,7 @@ concept convertible_to_atom_list_statically_impl (avnd::pair_ish && convertible_to_fundamental_value_type && convertible_to_fundamental_value_type) || (avnd::map_ish && convertible_to_fundamental_value_type && convertible_to_fundamental_value_type) || (avnd::optional_ish && convertible_to_fundamental_value_type) || + (avnd::optional_ish && std::is_empty_v && std::is_default_constructible_v) || (avnd::variant_ish && boost::mp11::mp_all_of::value) || (avnd::tuple_ish && boost::mp11::mp_all_of::value) || (std::is_aggregate_v && !avnd::iterable_ish && boost::mp11::mp_all_of, convertible_to_fundamental_value_type_pred>::value) @@ -53,6 +54,21 @@ static constexpr auto get_name_symbol() return avnd::get_static_symbol(); } +// Whether a port carries an explicit, usable name (name / symbol / c_name +// metadata). Ports without one get a positional p selector: their +// reflected fallback is either "unnamed" or a compiler-mangled placeholder, +// neither of which is addressable or stable. +template +consteval bool has_usable_name() +{ + if constexpr(avnd::has_symbol || avnd::has_c_name) + return true; + else if constexpr(avnd::has_name) + return !std::string_view{avnd::get_name()}.empty(); + else + return false; +} + template t_symbol* symbol_from_name() { diff --git a/include/avnd/binding/pd/inputs.hpp b/include/avnd/binding/pd/inputs.hpp index 259a2559..f326702e 100644 --- a/include/avnd/binding/pd/inputs.hpp +++ b/include/avnd/binding/pd/inputs.hpp @@ -301,13 +301,33 @@ struct from_atoms template bool operator()(T& v) const noexcept { - if(ac < 1) - return false; + using value_type = typename T::value_type; + if constexpr(std::is_empty_v) + { + // Impulse-like port: presence is the whole payload; any message + // ([Bang<, [Bang 1<...) engages it. + v.emplace(); + return true; + } + else + { + if(ac < 1) + { + // Selector-only message: engage the optional with a default value, + // so bang-like ports can be triggered. + if constexpr(requires { v.emplace(); }) + { + v.emplace(); + return true; + } + return false; + } - typename T::value_type res; - (*this)(res); - v = std::move(res); - return true; + value_type res; + (*this)(res); + v = std::move(res); + return true; + } } template @@ -321,6 +341,7 @@ struct from_atoms { from_atom{av[i]}(field); } + ++i; // each field consumes the next atom (e.g. [In x y z( -> x, y, z) }); return true; } @@ -352,8 +373,18 @@ struct inputs void init(avnd::effect_container& implementation, t_object& x_obj) { int k = 0; + int param_idx = 0; avnd::input_introspection::for_all( - avnd::get_inputs(implementation), [&x_obj, &k](M& ctl) { + avnd::get_inputs(implementation), + [&x_obj, &k, ¶m_idx](M& ctl) { + // Unnamed parameter ports would all collide on the "unnamed" symbol (only + // the first would ever be settable); give them a positional selector + // instead -- p within the parameter inputs, the same naming the + // introspection dump and the golden reference files use. + int this_param = -1; + if constexpr(avnd::parameter_port) + this_param = param_idx++; + // Skip the first port if(k++) { @@ -365,7 +396,11 @@ struct inputs // Then the message through this inlet will be received by "process" // as [name 1 2 3) - auto name = pd::symbol_from_name(); + t_symbol* name{}; + if constexpr(!pd::has_usable_name()) + name = gensym(("p" + std::to_string(std::max(this_param, 0))).c_str()); + else + name = pd::symbol_from_name(); inlet_new(&x_obj, &x_obj.ob_pd, port_sym, name); } @@ -420,11 +455,28 @@ struct inputs for(auto state : implementation.full_state()) { + int param_idx = 0; avnd::parameter_input_introspection::for_all( state.inputs, [&,&obj=state.effect](M& field) { + const int idx = param_idx++; if(ok) return; - if(symname == pd::get_name_symbol()) + bool matches = false; + if constexpr(pd::has_usable_name()) + { + matches = (symname == std::string_view{pd::get_name_symbol().data()}); + } + else + { + // Unnamed ports are addressable positionally as p (the + // naming used by the inlets created in init, the introspection + // dump and the golden reference files); "unnamed" kept for + // backwards compatibility. + matches = (symname.size() > 1 && symname[0] == 'p' + && symname.substr(1) == std::to_string(idx)) + || symname == "unnamed"; + } + if(matches) { ok = process_inlet_control(obj, field, argc, argv); } diff --git a/include/avnd/binding/pd/message_processor.hpp b/include/avnd/binding/pd/message_processor.hpp index 0194fca0..917830d2 100644 --- a/include/avnd/binding/pd/message_processor.hpp +++ b/include/avnd/binding/pd/message_processor.hpp @@ -112,7 +112,16 @@ struct message_processor }; } - avnd::prepare(implementation, {}); + // A message object has no DSP context, so rate-dependent objects (e.g. + // tick-driven filters) get a fixed default rate. One frame per processing + // pass (= per bang), matching the synthesized tick below. + avnd::prepare( + implementation, + avnd::process_setup{ + .input_channels = 0, + .output_channels = 0, + .frames_per_buffer = 1, + .rate = 44100.}); /// Pass arguments if constexpr(avnd::can_initialize) @@ -156,13 +165,41 @@ struct message_processor // Do our stuff if it makes sense - some objects may not // even have a "processing" method - if_possible(implementation.effect()) else if_possible(implementation.effect(1)); + if constexpr(avnd::has_tick) + { + // Objects taking a host tick: synthesize one processing pass (frames=1, + // matching what the golden reference generator does for them). + typename T::tick t{}; + if constexpr(requires { t.frames; }) + t.frames = 1; + if_possible(implementation.effect(t)) + else if_possible(implementation.effect()) else if_possible(implementation.effect(1)); + } + else + { + if_possible(implementation.effect()) else if_possible(implementation.effect(1)); + } // Then bang output_setup.commit(*this); // Then clean the inlets if needed this->control_buffers.clear_inputs(this->implementation); + + // Impulse-like (optional) inputs are one-shot: they are only "present" for + // the processing pass that consumed them, else a single [Bang< message + // would keep firing on every subsequent processing pass. + if constexpr( + avnd::parameter_input_introspection::size > 0 + && requires { + avnd::parameter_input_introspection::for_all( + avnd::get_inputs(implementation), [](auto&) {}); + }) + avnd::parameter_input_introspection::for_all( + avnd::get_inputs(implementation), [](F& field) { + if constexpr(requires { field.value.reset(); }) + field.value.reset(); + }); } void process(t_symbol* s, int argc, t_atom* argv) diff --git a/include/avnd/binding/pd/prototype.cpp.in b/include/avnd/binding/pd/prototype.cpp.in index d548c6a5..b64191e7 100644 --- a/include/avnd/binding/pd/prototype.cpp.in +++ b/include/avnd/binding/pd/prototype.cpp.in @@ -17,10 +17,12 @@ extern "C" AVND_EXPORTED_SYMBOL void @AVND_C_NAME@_setup() // Pd never finds it, and its loader retries until it hits the recursion limit. t_symbol* const avnd_class_name = gensym("@AVND_C_NAME@"); [avnd_class_name] { - if constexpr( - avnd::monophonic_audio_processor || avnd::polyphonic_audio_processor) + if constexpr(avnd::audio_processor) { - // If we're an audio effect, make a type with the whole DSP stuff + // If we process audio in any shape -- effect, generator or analyzer + // (audio in, control out: NOT covered by the mono/polyphonic effect + // concepts, which require matching in/out shapes) -- make a type with + // the whole DSP stuff. static const pd::audio_processor_metaclass instance{avnd_class_name}; } else diff --git a/tooling/golden_compare.py b/tooling/golden_compare.py index 94696dcf..ebf5701b 100644 --- a/tooling/golden_compare.py +++ b/tooling/golden_compare.py @@ -45,7 +45,7 @@ def compare_controls(named, golden, atol, rtol): if not isinstance(gc, dict): continue nm, gv = gc.get("name", ""), gc.get("value") - if gv == "unrecorded" or not isinstance(gv, (int, float, str)): + if gv == "unrecorded" or not isinstance(gv, (int, float, str, list)): continue tv = None for k in (nm, nm.lower(), nm.capitalize()): @@ -60,6 +60,25 @@ def compare_controls(named, golden, atol, rtol): if str(tv) != gv and maxdiff == 0.0: maxdiff, worst = 1.0, f"{nm}({tv!r}!={gv!r})" continue + if isinstance(gv, list): + # Container outputs (vector / array / aggregate): elementwise. + tvl = tv if isinstance(tv, list) else [tv] + checked += 1 + if len(tvl) != len(gv): + if maxdiff == 0.0: + maxdiff, worst = float("inf"), f"{nm}(len {len(tvl)}!={len(gv)})" + continue + for a, b in zip(tvl, gv): + if isinstance(a, bool): + a = float(a) + if isinstance(b, bool): + b = float(b) + if isinstance(a, (int, float)) and isinstance(b, (int, float)): + if abs(a - b) > maxdiff: + maxdiff, worst = abs(a - b), nm + elif str(a) != str(b) and maxdiff == 0.0: + maxdiff, worst = 1.0, f"{nm}({a!r}!={b!r})" + continue if isinstance(tv, bool): tv = float(tv) if not isinstance(tv, (int, float)): @@ -69,8 +88,15 @@ def compare_controls(named, golden, atol, rtol): maxdiff, worst = abs(tv - gv), nm if checked == 0: return ("no-name-match", f"got={list(named)[:4]}") - peak = max((abs(gc["value"]) for gc in golden - if isinstance(gc.get("value"), (int, float))), default=0.0) + peak = 0.0 + for gc in golden: + v = gc.get("value") + if isinstance(v, (int, float)) and not isinstance(v, bool): + peak = max(peak, abs(v)) + elif isinstance(v, list): + for x in v: + if isinstance(x, (int, float)) and not isinstance(x, bool): + peak = max(peak, abs(x)) tol = atol + rtol * peak return ("match" if maxdiff <= tol else "MISMATCH", f"{checked} ctrls maxdiff={maxdiff:.2e}@{worst} tol={tol:.2e}") @@ -109,6 +135,38 @@ def compare_textures(textures, golden, atol, rtol, content="hash"): return ("match", f"{n} textures") +def compare_callbacks(events, golden_events, atol, rtol): + """Diff one callback port's captured firings against the golden's recorded + events. Both are lists of argument lists; the event count and each + argument must match (numeric within tolerance, strings exact, positions the + golden recorded as 'unrecorded' skipped).""" + if golden_events is None: + return ("no-golden-callbacks", "") + if len(events) != len(golden_events): + return ("MISMATCH", + f"{len(events)} events vs gold {len(golden_events)}") + for i, (e, g) in enumerate(zip(events, golden_events)): + # An 'unrecorded' golden argument is a container/aggregate whose + # backend representation may expand to several atoms: give up on + # arity for that event and only compare the positions before it. + relaxed = "unrecorded" in g + if not relaxed and len(e) != len(g): + return ("MISMATCH", f"event{i} arity {len(e)} vs {len(g)}") + for j, (a, b) in enumerate(zip(e, g)): + if b == "unrecorded": + if relaxed: + break + continue + if isinstance(b, bool): + b = float(b) + if isinstance(a, (int, float)) and isinstance(b, (int, float)): + if abs(a - b) > atol + rtol * abs(b): + return ("MISMATCH", f"event{i}[{j}] {a} vs {b}") + elif str(a) != str(b): + return ("MISMATCH", f"event{i}[{j}] {a!r} vs {b!r}") + return ("match", f"{len(events)} events") + + def fnv1a64(data): """FNV-1a 64-bit over raw bytes, returned as a SIGNED int64 -- the exact hash the golden generator records (binding/golden/run.hpp).""" diff --git a/tooling/max/avnd_max_driver.js b/tooling/max/avnd_max_driver.js index 27f38073..565d669f 100644 --- a/tooling/max/avnd_max_driver.js +++ b/tooling/max/avnd_max_driver.js @@ -3,12 +3,18 @@ // Loaded by avnd_max_driver.maxpat (loadbang -> "run"). For every golden object // (data staged in avnd_max_data.js by run_max_golden.py) it: // * control objects: instantiates the message_processor external, wires each -// output outlet through [prepend cap] back into this js inlet, replays the -// golden input controls and captures the committed output values; +// output outlet through [prepend cap] back into this js inlet, replays +// the golden input controls (numeric + string by name, unnamed ports by +// proxy inlet), impulse engagements, message-port invocations, and captures +// every committed output value / callback firing (ordered event lists); // * audio objects: builds count~ -> index~(avin) -> mc.pack~ -> object -> // mc.unpack~ -> record~(avout), fills the input buffers with the golden -// samples, runs one real DSP block and reads the recorded output samples; -// * none/texture: instantiate-only smoke (nothing feasible to capture). +// samples, runs one real DSP block, reads the recorded output samples, and +// flushes the non-audio outlets with a post-block bang (analyzers); +// * texture objects: jit.matrix MOP -- feeds filters a deterministic char +// matrix, bangs to cook, reads the output matrix dims through a named +// [jit.matrix @adapt 1]; +// * none: instantiate-only smoke (nothing feasible to capture). // Results are written one JSON line per object to AVND_CFG.report; a per-object // breadcrumb (AVND_CFG.breadcrumb) lets the Python harness relaunch past a // crasher and resume (objects already in the report are skipped). @@ -34,7 +40,9 @@ var doneNames = {}; // objects already reported (resume after crash) var WORK = []; // flattened [{oi, ci, first, last}] var wi = -1; // current work index var cur = []; // graph objects to clean up for the current case -var capture = {}; // cap -> value (control capture, current case) +var curObj = null; // the object under test for the current case +var capture = {}; // cap/dsptime/... -> last value +var events = {}; // cap -> [[args...], ...] (every firing, in order) var objCases = []; // accumulated case-result strings for the current object var TASKS = []; // keep Task refs alive var dspWorks = false; // set by the DSP self-test: does audio actually render? @@ -62,6 +70,26 @@ function jarr(a) { for (var i = 0; i < a.length; i++) p.push(jnum(a[i])); return "[" + p.join(",") + "]"; } +function jval(x) { return (typeof x === "number") ? jnum(x) : jstr(x); } +function jevents(a) { // [[atoms...], ...] -> JSON + var evs = []; + for (var i = 0; i < a.length; i++) { + var p = []; + for (var j = 0; j < a[i].length; j++) p.push(jval(a[i][j])); + evs.push("[" + p.join(",") + "]"); + } + return "[" + evs.join(",") + "]"; +} +// The captured outlet events, keyed by cap index, for the current case. +function jcaps(nCaps) { + var parts = []; + for (var i = 0; i < nCaps; i++) { + var evs = events["cap" + i]; + if (evs && evs.length > 0) + parts.push(jstr("" + i) + ":" + jevents(evs)); + } + return "{" + parts.join(",") + "}"; +} // ---- files ---------------------------------------------------------------- function crumb(s) { @@ -263,13 +291,16 @@ function buildStep() { crumb(obj.name + ":case" + w.ci); if (w.first) objCases = []; capture = {}; + events = {}; cur = []; + curObj = null; var kind = obj.kind; var ok = false; try { if (kind === "audio") ok = buildAudio(obj, obj.cases[w.ci]); else if (kind === "control") ok = buildControl(obj, obj.cases[w.ci]); + else if (kind === "texture") ok = buildTexture(obj, obj.cases[w.ci]); else ok = buildNone(obj, obj.cases[w.ci]); } catch (e) { ok = false; @@ -287,64 +318,149 @@ function buildStep() { defer("readStep", 40); } +// Wire the object's message outlets (capBase..capBase+caps-1) back into this +// js through [prepend cap], so every firing is captured into `events`. +function wireCaps(p, o, obj) { + var driver = p.getnamed("driver"); + var n = obj.caps.length; + for (var i = 0; i < n; i++) { + var pre = p.newdefault(220 + i * 60, 430, "prepend", "cap" + i); + cur.push(pre); + p.connect(o, obj.capBase + i, pre, 0); + p.connect(pre, 0, driver, 0); + } +} + +// Replay the case's string+numeric controls (by name -- works for every named +// port on inlet 0 without triggering processing in the audio/jitter bindings), +// impulse engagements (selector-only message) and message invocations. +function sendNamedInputs(o, c) { + for (var k = 0; k < c.controls.length; k++) { + var nm = c.controls[k][1], val = c.controls[k][2]; + if (nm && nm.length > 0) o.message(nm, val); + } + for (var i = 0; i < c.impulses.length; i++) + if (c.impulses[i] && c.impulses[i].length > 0) + o.message(c.impulses[i]); + for (var m = 0; m < c.messages.length; m++) + sendMessagePort(o, c.messages[m]); +} + +// jsobj.message is a host method: .apply() on it is not reliable across Max +// versions, so dispatch by arity (declared message ports have few args). +function sendMessagePort(o, msg) { + var nm = msg[0], ar = msg[1]; + if (!nm || nm.length === 0) return; + if (ar.length === 0) o.message(nm); + else if (ar.length === 1) o.message(nm, ar[0]); + else if (ar.length === 2) o.message(nm, ar[0], ar[1]); + else if (ar.length === 3) o.message(nm, ar[0], ar[1], ar[2]); + else o.message(nm, ar[0], ar[1], ar[2], ar[3]); +} + function buildControl(obj, c) { var p = this.patcher; var o = p.newdefault(220, 300, obj.name); if (!o) return false; cur.push(o); - var driver = p.getnamed("driver"); - var nout = c.outCtrlNames.length; - for (var i = 0; i < nout; i++) { - var pre = p.newdefault(220 + i * 60, 400, "prepend", "cap" + i); - cur.push(pre); - p.connect(o, i, pre, 0); - p.connect(pre, 0, driver, 0); - } - // Set each explicit control by INLET INDEX, not by name: the message_processor - // maps proxy inlet i -> parameter i, and its name-matching path fails for - // unnamed ports (whose golden name is a positional "p" placeholder). A - // message box feeding inlet i sets exactly parameter i. Inlet 0 also triggers - // process()+commit, so set the higher inlets first, then inlet 0 / a bang. + curObj = o; + wireCaps(p, o, obj); + // Named ports: a " " message on inlet 0 sets the parameter + // WITHOUT triggering processing. Unnamed ports (positional "p" golden + // placeholder) have no name to match in the Max binding: feed the value to + // proxy inlet i through a message box instead (the message_processor maps + // proxy inlet i -> parameter i; inlet 0 also triggers process()+commit). var msgs = []; for (var k = 0; k < c.controls.length; k++) { - var idx = c.controls[k][0], val = c.controls[k][2]; - var mb = p.newdefault(20 + k * 70, 230, "message"); - mb.message("set", val); // the numeric content is NOT taken from a ctor arg - cur.push(mb); - p.connect(mb, 0, o, idx); - msgs.push(mb); - } - for (var m = msgs.length - 1; m >= 0; m--) { - var idx2 = c.controls[m][0]; - if (idx2 !== 0) msgs[m].message("bang"); + var idx = c.controls[k][0], nm = c.controls[k][1], val = c.controls[k][2]; + // Container controls (xy / rgba / array / list) arrive as a JS array: + // spread the components into a list message so every component lands. + var atoms = (val instanceof Array) ? val : [val]; + if (nm && nm.length > 0 && !(/^p\d+$/.test(nm))) { + o.message.apply(o, [nm].concat(atoms)); + } else { + var mb = p.newdefault(20 + k * 70, 230, "message"); + mb.message.apply(mb, ["set"].concat(atoms)); // content not from a ctor arg + cur.push(mb); + p.connect(mb, 0, o, idx); + msgs.push([mb, idx]); + } } + for (var m = msgs.length - 1; m >= 0; m--) + if (msgs[m][1] !== 0) msgs[m][0].message("bang"); for (var m2 = 0; m2 < msgs.length; m2++) - if (c.controls[m2][0] === 0) msgs[m2].message("bang"); + if (msgs[m2][1] === 0) msgs[m2][0].message("bang"); + // Impulse engagements + declared message-port invocations, then the final + // process()+commit. + for (var i = 0; i < c.impulses.length; i++) + if (c.impulses[i] && c.impulses[i].length > 0) + o.message(c.impulses[i]); + for (var mm = 0; mm < c.messages.length; mm++) + sendMessagePort(o, c.messages[mm]); o.message("bang"); // force a final process()+commit on inlet 0 return true; } +function buildTexture(obj, c) { + var p = this.patcher; + var o = p.newdefault(220, 300, obj.name); + if (!o) return false; + cur.push(o); + curObj = o; + // Receiving matrix adapts to whatever the MOP outputs (dims + planes). + var mout = p.newdefault(220, 430, "jit.matrix", "avndtexout", "@adapt", 1); + cur.push(mout); + p.connect(o, 0, mout, 0); + // reset the receiver to a 1x1 so stale dims from a previous case can never + // masquerade as this case's output. + mout.message("dim", 1, 1); + // Texture filters: feed a deterministic char 4-plane matrix of the golden's + // recorded input size (content is informational only -- Jitter plane order + // is ARGB vs the object's RGBA, and the dims diff is what's authoritative). + if (c.texIn && c.texIn.length > 0) { + var w = c.texIn[0][0], h = c.texIn[0][1]; + var jin = new JitterMatrix("avndtexin", 4, "char", w, h); + for (var y = 0; y < h; y++) + for (var x = 0; x < w; x++) { + var base = (y * w + x) * 4; + jin.setcell2d(x, y, base % 256, (base + 1) % 256, (base + 2) % 256, + (base + 3) % 256); + } + } + // Controls (e.g. a generator's Width/Height) by name -- no processing yet. + sendNamedInputs(o, c); + if (c.texIn && c.texIn.length > 0) + o.message("jit_matrix", "avndtexin"); + o.message("bang"); // cook: matrix_calc + outputmatrix + commit + return true; +} + function buildAudio(obj, c) { var p = this.patcher; var o = p.newdefault(420, 300, obj.name); if (!o) return false; cur.push(o); + curObj = o; // If DSP can't render in this session (no audio driver -- Max has no headless // mode), instantiating the audio external is all we can verify. Skip the futile // signal graph; readAudio will report dsp:false so it isn't a false MISMATCH. if (!dspWorks) return true; + wireCaps(p, o, obj); // control/callback outlets right of the signal outlet var nin = c.audioIn.length; - var nout = c.nAudioOut > 0 ? c.nAudioOut : nin; - if (nout < 1) nout = 1; + var nout = c.nAudioOut; // 0 for analyzers: nothing to record~, controls only if (nout > CHANS) nout = CHANS; if (nin > 0) { // count~ only advances while its left inlet carries a NONZERO signal; with // nothing connected its gate is 0 and it stays stuck at index 0 (the object // would see a constant input). Feed it a constant 1 so it counts 0,1,2,... + // Wrap at FRAMES so every block replays the same golden input (the signal + // vector is pinned to FRAMES, so each block is exactly one pass) -- like + // Pd's repeating [tabreceive~]; otherwise index~ runs past the buffer and + // control outputs committed after the first block reflect garbage input. var one = p.newdefault(20, 110, "sig~", 1); cur.push(one); - var cnt = p.newdefault(50, 140, "count~"); + var cnt = p.newdefault(50, 140, "count~", 0, FRAMES); cur.push(cnt); p.connect(one, 0, cnt, 0); var pack = (nin > 1) ? p.newdefault(300, 240, "mc.pack~", nin) : null; @@ -363,9 +479,9 @@ function buildAudio(obj, c) { if (pack) p.connect(pack, 0, o, 0); } // audio_processor has only the signal inlet 0 and matches controls by NAME - // (no proxy inlets / index fallback), so set them via " ". - for (var k = 0; k < c.controls.length; k++) - o.message(c.controls[k][1], c.controls[k][2]); + // (no proxy inlets / index fallback): strings, impulses and message ports + // included. + sendNamedInputs(o, c); var unpack = (nout > 1) ? p.newdefault(420, 400, "mc.unpack~", nout) : null; if (unpack) { cur.push(unpack); p.connect(o, 0, unpack, 0); } @@ -399,6 +515,7 @@ function readStep() { try { if (obj.kind === "audio") line = readAudio(obj, c, w.ci); else if (obj.kind === "control") line = readControl(obj, c, w.ci); + else if (obj.kind === "texture") line = readTexture(obj, c, w.ci); else line = "{\"index\":" + w.ci + ",\"instantiated\":true}"; } catch (e) { line = "{\"index\":" + w.ci + ",\"error\":\"read failed\"}"; @@ -410,23 +527,27 @@ function readStep() { } function readControl(obj, c, ci) { - var parts = []; - var nout = c.outCtrlNames.length; - for (var i = 0; i < nout; i++) { - var key = "cap" + i; - if (capture[key] !== undefined) { - var nm = c.outCtrlNames[i]; - parts.push(jstr(nm) + ":" + jnum(capture[key])); - } - } - return "{\"index\":" + ci + ",\"controls\":{" + parts.join(",") + "}}"; + return "{\"index\":" + ci + ",\"caps\":" + jcaps(obj.caps.length) + "}"; +} + +function readTexture(obj, c, ci) { + var jm = new JitterMatrix("avndtexout"); + var dim = jm.dim; + var w = 0, h = 1; + if (dim instanceof Array) { w = dim[0]; if (dim.length > 1) h = dim[1]; } + else w = dim; + return "{\"index\":" + ci + ",\"texture\":[{\"width\":" + jnum(w) + + ",\"height\":" + jnum(h) + ",\"planes\":" + jnum(jm.planecount) + + "}],\"caps\":" + jcaps(obj.caps.length) + "}"; } function readAudio(obj, c, ci) { if (!dspWorks) return "{\"index\":" + ci + ",\"instantiated\":true,\"dsp\":false}"; - var nout = c.nAudioOut > 0 ? c.nAudioOut : c.audioIn.length; - if (nout < 1) nout = 1; + // Flush the non-audio outputs computed by the rendered block(s): the audio + // binding commits them on bang (analyzers like avnd_peak). + if (curObj && obj.caps.length > 0) curObj.message("bang"); + var nout = c.nAudioOut; if (nout > CHANS) nout = CHANS; var ds = this.patcher.getnamed("dspstop"); var chans = []; @@ -440,14 +561,15 @@ function readAudio(obj, c, ci) { chans.push(jarr(arr)); } if (ds) ds.message("bang"); - return "{\"index\":" + ci + ",\"audio\":[" + chans.join(",") + "]}"; + return "{\"index\":" + ci + ",\"audio\":[" + chans.join(",") + + "],\"caps\":" + jcaps(obj.caps.length) + "}"; } // ---- capture handler: [prepend cap] -> here ----------------------------- function anything() { - // Captures everything wired back into inlet 0: control outputs (cap), the - // DSP self-test signal (stpeak), the audio driver name (curdrv) and the - // rendered-sample count (dsptime). + // Captures everything wired back into inlet 0: control/callback outputs + // (cap), the DSP self-test signal (stpeak), the audio driver name + // (curdrv) and the rendered-sample count (dsptime). var sel = "" + messagename; var a = arrayfromargs(arguments); // Prefer the first numeric arg (scalar control / signal value); fall back to @@ -457,6 +579,12 @@ function anything() { if (typeof a[i] === "number") { v = a[i]; break; } } capture[sel] = v; + // Full event log per capture outlet (callback diffing needs every firing, + // in order, with all its arguments). + if (sel.substring(0, 3) === "cap") { + if (!events[sel]) events[sel] = []; + events[sel].push(a.slice(0)); + } } function msg_float(v) { /* bare float outputs shouldn't reach us (prepended) */ } function msg_int(v) { } diff --git a/tooling/run_gst_golden.py b/tooling/run_gst_golden.py index 4d0e37c4..0e36fc60 100644 --- a/tooling/run_gst_golden.py +++ b/tooling/run_gst_golden.py @@ -296,6 +296,11 @@ def main(): "outputs": g.get("outputs", {})}] verdicts = [] for ci, gcase in enumerate(gcases): + # Impulse-engagement / message-invocation cases have no gst-launch + # driving mechanism; skip them honestly. + if gcase.get("kind") in ("impulse", "message"): + verdicts.append((ci, "unsupported-case-kind", gcase["kind"])) + continue gaud = gcase.get("outputs", {}).get("audio") or [] gtex = gcase.get("outputs", {}).get("texture") or [] rec = {"index": ci} diff --git a/tooling/run_max_golden.py b/tooling/run_max_golden.py index d0be70bc..5cb2b404 100644 --- a/tooling/run_max_golden.py +++ b/tooling/run_max_golden.py @@ -22,13 +22,25 @@ Capture reality (Max's limits, reported honestly per object): * control objects -> message_processor commits output controls to outlets: - real value diff. - * audio objects -> audio_processor exposes only its MC signal outlet (it does - NOT commit control outlets -- known TODO), captured with - record~; real sample diff when DSP runs. - * none/texture -> nothing feasible to capture: instantiate-only smoke - (verdict 'instantiated' / 'no-backend-*'), never a false - pass. + real value diff; string controls, impulse engagements + ([( selector) and declared message-port + invocations are replayed; callback firings are captured + as ordered event lists and diffed per event. + * audio objects -> MC signal outlet captured with record~ (real sample diff + when DSP runs); control/callback outlets (to the right + of the signal outlet) captured after a post-block bang, + which the audio binding answers by committing its + non-audio outputs (analyzers like avnd_peak). + * texture objects -> jit.matrix MOP: filters are fed a 16x16 char matrix, + generators banged; the output matrix dims are read back + via a named [jit.matrix @adapt 1] and diffed in "dims" + mode (Jitter converts content, so bytes are + informational only). + * none -> nothing feasible to capture: instantiate-only smoke + (verdict 'instantiated'), never a false pass. +Outlet -> port mapping comes from the introspection dumps (--dumps, declaration +order); without a dump, positional mapping applies when the object has only one +kind of capturable output. Windows only. Example: @@ -43,6 +55,7 @@ import glob import json import os +import re import shutil import subprocess import sys @@ -50,7 +63,8 @@ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) from golden_compare import (aggregate_case_verdicts, compare_audio, - compare_controls) + compare_callbacks, compare_controls, + compare_textures) user32 = ctypes.windll.user32 if sys.platform == "win32" else None @@ -116,11 +130,71 @@ def _round(x): return 0.0 -def gen_data_js(goldens, out_path): +def max_symbol(name): + """The runtime selector the Max binding matches a port/message name + against: avnd::get_static_symbol / fixup_identifier REPLACES every char + outside [A-Za-z0-9.~] with '_' (see binding/max/helpers.hpp + valid_char_for_name + common/metadatas.hpp fixup_identifier).""" + return re.sub(r"[^A-Za-z0-9.~]", "_", str(name)) + + +def load_dump_outputs(dumps_dir, stem): + """Declaration-ordered output ports [{name, type}] from the introspection + dump JSON for this object (types: parameter/callback/audio/texture/...), + or None if unavailable.""" + if not dumps_dir or not stem: + return None + p = os.path.join(dumps_dir, stem + ".json") + if not os.path.exists(p): + return None + try: + outs = json.load(open(p, encoding="utf-8")).get("outputs") + return outs if isinstance(outs, list) else [] + except Exception: + return None + + +def cap_layout(outs_decl, gcases, kind): + """(capBase, caps) -- which object outlets carry capturable messages. + caps is [[type, name], ...] in outlet order starting at outlet capBase. + For message objects every output port gets an outlet (declaration order, + capBase 0); for audio objects the non-audio ports sit to the RIGHT of the + single mc signal outlet (capBase 1 -- or 0 when the object has no + audio-output port, e.g. analyzers).""" + if outs_decl is None: + # No dump: positional fallback from the golden's recorded outputs + # (valid when the object has only one kind of capturable output). + gc = (gcases[0].get("outputs", {}).get("controls") or []) + gcb = (gcases[0].get("outputs", {}).get("callbacks") or []) + if gc and gcb: + return (0, None) # ambiguous + caps = [["parameter", c.get("name", f"p{i}")] for i, c in enumerate(gc)] + caps += [["callback", c.get("name", f"p{i}")] for i, c in enumerate(gcb)] + return (0, caps) + has_audio_out = any(o.get("type") == "audio" for o in outs_decl) + caps, pi, cbi = [], 0, 0 + for o in outs_decl: + ty = o.get("type", "") + if kind == "audio" and ty == "audio": + continue # folded into the single mc signal outlet + nm = o.get("name") + if ty == "parameter": + caps.append([ty, nm if nm else f"p{pi}"]) + pi += 1 + elif ty == "callback": + caps.append([ty, nm if nm else f"p{cbi}"]) + cbi += 1 + else: + caps.append([ty, nm or ""]) # midi/texture/...: occupies an outlet + base = 1 if (kind == "audio" and has_audio_out) else 0 + return (base, caps) + + +def gen_data_js(goldens, out_path, dumps_dir): """Emit avnd_max_data.js: `var AVND_DATA = [...]` -- the in-Max driver include()s it, so it never has to parse JSON. One entry per object with its - kind and, per case, the input controls/audio and the golden output control - names (so captured outlet values can be matched back to a port name).""" + kind, outlet-capture layout, and per case the input controls (numeric, + string), impulse engagements, message invocations, audio and texture.""" objs = [] for g in goldens: cases_out = [] @@ -129,29 +203,55 @@ def gen_data_js(goldens, out_path): kind = "none" for c in cases: ins, outs = c.get("inputs", {}), c.get("outputs", {}) - controls = [[cc.get("index", k), cc.get("name", ""), cc.get("value")] - for k, cc in enumerate(ins.get("controls", [])) - if cc.get("value") != "unrecorded" - and isinstance(cc.get("value"), (int, float))] + controls, impulses = [], [] + for k, cc in enumerate(ins.get("controls", [])): + v = cc.get("value") + nm = max_symbol(cc.get("name", "")) # runtime selector form + if v == "unrecorded" or v is None: + continue + if v == "bang": + impulses.append(nm) + elif isinstance(v, bool): + controls.append([cc.get("index", k), nm, int(v)]) + elif isinstance(v, list): + # container control (xy / rgba / array / list): feed all + # components as one list message to the inlet. + if all(isinstance(x, (bool, int, float, str)) for x in v): + controls.append([cc.get("index", k), nm, + [int(x) if isinstance(x, bool) else x + for x in v]]) + elif isinstance(v, (int, float, str)): + controls.append([cc.get("index", k), nm, v]) + messages = [[max_symbol(m.get("name", "")), m.get("args", [])] + for m in (ins.get("messages") or []) + if m.get("status") == "invoked"] audio_in = ins.get("audio") or [] - out_ctrl_names = [cc.get("name", "") - for cc in outs.get("controls", [])] + tex_in = [[t.get("width", 16), t.get("height", 16)] + for t in (ins.get("texture") or [])] has_aout = bool(outs.get("audio")) - has_cout = bool(outs.get("controls")) + has_ain = bool(audio_in) + has_cout = bool(outs.get("controls") or outs.get("callbacks")) has_tex = bool(outs.get("texture")) - if has_aout: + if has_aout or has_ain: kind = "audio" - elif has_cout and kind != "audio": - kind = "control" - elif has_tex and kind not in ("audio", "control"): + elif has_tex and kind != "audio": kind = "texture" + elif has_cout and kind not in ("audio", "texture"): + kind = "control" cases_out.append({ "controls": controls, + "impulses": impulses, + "messages": messages, "audioIn": audio_in, + "texIn": tex_in, "nAudioOut": len(outs.get("audio") or []), - "outCtrlNames": out_ctrl_names, + "nTexOut": len(outs.get("texture") or []), }) - objs.append({"name": g["c_name"], "kind": kind, "cases": cases_out}) + outs_decl = load_dump_outputs(dumps_dir, g.get("_stem")) + base, caps = cap_layout(outs_decl, cases, kind) + objs.append({"name": g["c_name"], "kind": kind, "capBase": base, + "caps": caps if caps is not None else [], + "capsAmbiguous": caps is None, "cases": cases_out}) with open(out_path, "w", encoding="utf-8", newline="\n") as f: f.write("// Auto-generated by run_max_golden.py -- golden inputs/outputs\n") f.write("// for the in-Max driver (include()'d, so no JSON parser needed).\n") @@ -167,6 +267,8 @@ def load_goldens(goldens_dir): try: g = json.load(open(f, encoding="utf-8")) if g.get("c_name"): + # file stem = CMake target name = the introspection dump's name + g["_stem"] = os.path.splitext(os.path.basename(f))[0] out.append(g) except Exception: pass @@ -175,16 +277,39 @@ def load_goldens(goldens_dir): def parse_report(path): """Report is JSON-lines (one object per line), written by the js driver and - appended-to by us on crash. Returns {name: entry}.""" + appended-to by us on crash. Returns {name: entry}. + + Max's File('write') flushes its internal buffer every 32 KB with a raw + newline, so a long object line (audio arrays) can be split across several + physical lines. Rejoin greedily: accumulate lines until the buffer parses + as one object, then reset. A stray unparseable fragment is dropped when the + next '{' starts, so a single corrupt object can't swallow the rest.""" entries = {} if not os.path.exists(path): return entries + buf = "" for line in open(path, encoding="utf-8"): - line = line.strip() - if not line: + line = line.rstrip("\n") + if not line.strip() and not buf: continue + # A new object starts here and the buffer never parsed -> drop it. + if buf and line.lstrip().startswith("{"): + try: + e = json.loads(buf) + entries[e["name"]] = e + except Exception: + pass + buf = "" + buf += line + try: + e = json.loads(buf) + entries[e["name"]] = e + buf = "" + except Exception: + pass + if buf: try: - e = json.loads(line) + e = json.loads(buf) entries[e["name"]] = e except Exception: pass @@ -196,12 +321,19 @@ def main(): ap.add_argument("--max", required=True, help="Max.exe") ap.add_argument("--externals", required=True, help="dir of built *.mxe64") ap.add_argument("--goldens", required=True, help="golden JSON dir") + ap.add_argument("--dumps", default=None, + help="introspection dump JSON dir (declaration-ordered " + "outlet mapping); default: goldens dir with 'golden' " + "replaced by 'dump'") ap.add_argument("--atol", type=float, default=1e-3) ap.add_argument("--rtol", type=float, default=1e-3) ap.add_argument("--timeout", type=int, default=240, help="seconds per launch before giving up") ap.add_argument("--max-launches", type=int, default=80) ap.add_argument("--only", help="comma-separated c_names to restrict to") + ap.add_argument("--recompute", action="store_true", + help="skip driving Max; recompute verdicts from an existing " + "report (useful to re-diff after a compare-logic change)") ap.add_argument("--report", default=None, help="report path (default: /avnd_max_report.jsonl)") args = ap.parse_args() @@ -210,6 +342,11 @@ def main(): asset_dir = os.path.join(here, "max") ext_dir = os.path.abspath(args.externals).replace("\\", "/") + dumps_dir = args.dumps + if dumps_dir is None: + cand = os.path.abspath(args.goldens).replace("golden", "dump") + dumps_dir = cand if os.path.isdir(cand) else None + goldens = load_goldens(args.goldens) if args.only: keep = set(args.only.split(",")) @@ -224,7 +361,8 @@ def main(): shutil.copy2(os.path.join(asset_dir, fn), dst) staged.append(dst) data_js = os.path.join(ext_dir, "avnd_max_data.js") - gen_data_js(goldens, data_js) + data_objs = gen_data_js(goldens, data_js, dumps_dir) + datamap = {o["name"]: o for o in data_objs} staged.append(data_js) report = args.report or os.path.join(ext_dir, "avnd_max_report.jsonl") @@ -241,9 +379,10 @@ def main(): f.write(";\n") staged.append(cfg) - for f in (report, breadcrumb): - if os.path.exists(f): - os.remove(f) + if not args.recompute: + for f in (report, breadcrumb): + if os.path.exists(f): + os.remove(f) patch = os.path.join(ext_dir, "avnd_max_driver.maxpat") kill = ["taskkill", "/F", "/IM", "Max.exe"] @@ -254,9 +393,10 @@ def _read(p): except Exception: return "" - subprocess.run(kill, capture_output=True) dismissed, crashers = 0, [] - for attempt in range(1, args.max_launches + 1): + if not args.recompute: + subprocess.run(kill, capture_output=True) + for attempt in range(1, (0 if args.recompute else args.max_launches) + 1): done = parse_report(report) remaining = [g for g in goldens if g["c_name"] not in done] if not remaining: @@ -328,12 +468,70 @@ def _read(p): print(f"(auto-dismissed {dismissed} Max dialog(s))") # ---- diff vs golden ----------------------------------------------------- - goldmap = {g["c_name"]: g for g in goldens} + def fold(results): + """Combine several (verdict, detail) comparisons of one case.""" + if not results: + return ("no-capturable-output", "") + for bad in ("MISMATCH", "error"): + for v, d in results: + if v == bad: + return (v, d) + for v, d in results: + if v == "match": + return ("match", + "; ".join(x[1] for x in results if x[0] == "match")) + return results[0] + + def caps_compare(rc, gc, dobj): + """Diff a case's captured outlet events against the golden's output + controls and callback events. rc['caps'] = {capIdx(str): [event,...]}, + each event a list of atoms ('bang' -> valueless firing).""" + results = [] + gout = gc.get("outputs", {}) + gctl = gout.get("controls") or [] + gcb = gout.get("callbacks") or [] + recordable = [c for c in gctl if isinstance(c, dict) + and c.get("value") != "unrecorded"] + if dobj.get("capsAmbiguous"): + return [("ambiguous-outlets", "params+callbacks but no dump")] + caps = dobj.get("caps") or [] + rcaps = rc.get("caps") or {} + + def events(i): + evs = rcaps.get(str(i)) or [] + return [[] if e == ["bang"] else e for e in evs] + if recordable: + named = {} + for i, (ty, nm) in enumerate(caps): + if ty != "parameter": + continue + evs = events(i) + if evs and evs[-1]: + ev = evs[-1] + named[nm] = ev[0] if len(ev) == 1 else ev + results.append(compare_controls(named, gctl, args.atol, args.rtol)) + cbi = 0 + for i, (ty, nm) in enumerate(caps): + if ty != "callback": + continue + gev = None + for cb in gcb: + if cb.get("name") == nm or cb.get("index") == cbi: + gev = cb.get("events") + break + cbi += 1 + if gev is None: + continue + v, d = compare_callbacks(events(i), gev, args.atol, args.rtol) + results.append((v, f"cb {nm}: {d}")) + return results + entries = parse_report(report) counts, details, rows = {}, [], [] for g in goldens: name = g["c_name"] e = entries.get(name) + dobj = datamap.get(name, {}) gcases = g.get("cases") or [{"inputs": g.get("inputs", {}), "outputs": g.get("outputs", {})}] if e is None: @@ -352,7 +550,8 @@ def _read(p): if rc.get("error"): verdicts.append((ci, "error", str(rc["error"])[:80])) continue - if gout.get("audio"): + results = [] + if gout.get("audio") or gc.get("inputs", {}).get("audio"): if rc.get("dsp") is False: # Max rendered no audio (no live DSP driver in this # session): the external instantiated but we can't do a @@ -360,21 +559,30 @@ def _read(p): verdicts.append((ci, "audio-dsp-unavailable", "instantiated; DSP did not run")) continue - cap = rc.get("audio") - out = ([{"name": f"c{i}", "samples": ch} - for i, ch in enumerate(cap)] if cap else None) - verdicts.append((ci,) + compare_audio( - out, gout["audio"], args.atol, args.rtol)) - elif gout.get("controls"): - verdicts.append((ci,) + compare_controls( - rc.get("controls") or {}, gout["controls"], - args.atol, args.rtol)) + if gout.get("audio"): + cap = rc.get("audio") + out = ([{"name": f"c{i}", "samples": ch} + for i, ch in enumerate(cap)] if cap else None) + results.append(compare_audio( + out, gout["audio"], args.atol, args.rtol)) + if gout.get("controls") or gout.get("callbacks"): + results += caps_compare(rc, gc, dobj) + verdicts.append((ci,) + fold(results)) elif gout.get("texture"): - verdicts.append((ci, "texture-uncaptured", - "jitter matrix not captured")) + rtex = rc.get("texture") + if rtex: + verdicts.append((ci,) + compare_textures( + rtex, gout["texture"], args.atol, args.rtol, + content="dims")) + else: + verdicts.append((ci, "texture-uncaptured", + "jitter matrix not captured")) + elif gout.get("controls") or gout.get("callbacks"): + verdicts.append((ci,) + fold(caps_compare(rc, gc, dobj))) else: # no output to verify: object at least instantiated + ran. - ok = rc.get("instantiated") or rc.get("ok") + ok = rc.get("instantiated") or rc.get("ok") \ + or "caps" in rc verdicts.append((ci, "instantiated" if ok else "error", "" if ok else "did not instantiate")) v, d = aggregate_case_verdicts(verdicts) diff --git a/tooling/run_pd_golden.py b/tooling/run_pd_golden.py index b657515b..ee39bb31 100644 --- a/tooling/run_pd_golden.py +++ b/tooling/run_pd_golden.py @@ -30,24 +30,40 @@ state. Each (object, case) runs in its OWN pd process for crash isolation and so that a stateful object never carries state across cases. -Scope / honest limitations ---------------------------- -Only AUDIO outputs are diffed: - - * The Pd AUDIO processor does not commit output controls (a known binding - TODO), so audio objects that emit only control/value outputs (e.g. Peak, - analyzers) have nothing to read back -- reported ``no-control-commit``. - * Control-only / MIDI / texture / geometry objects have no head-less audio - read-back path here -- reported ``no-audio-output`` (the same graceful gap - the GStreamer harness has for shapes it cannot represent). Message objects - do send their outputs out outlets, but robustly capturing/parsing every - heterogeneous control-output type head-less is out of scope for this harness. - -Also note the Pd audio binding defaults a *dynamic* audio bus to a single -channel, so a stereo golden is compared against Pd's channel 0 only (the -overlapping-channel prefix -- exactly what ``compare_audio`` does). Objects that -sum/route across the golden's extra input channels therefore legitimately -differ; that is reported as MISMATCH, not hidden. +Control / message objects (no DSP) +---------------------------------- +Objects without audio I/O run through the Pd MESSAGE processor: input controls +are set by `` `` messages (impulse ports by a selector-only +```` message, unnamed ports by their positional ``p`` +selector), declared message ports are replayed from the golden's recorded +``inputs.messages``, and a final ``bang`` runs the object once and commits +every output control to its outlet. Each outlet is wired to ``[print cap]`` +and the values are parsed back from pd's stdout (pd -nogui prints there). +All the control sends + the bang live in ONE comma-separated message box, so +their order is guaranteed. Outlet -> port mapping comes from the introspection +dump (``--dumps``, declaration order); without a dump, positional mapping is +used when the object has only one kind of capturable output. + +Audio objects additionally get the same control-input treatment (strings, +impulses, messages), and -- since the audio binding now commits non-audio +outputs on bang -- a ``bang`` is sent after the recorded block and any control +or callback outputs are captured through ``[print]`` and diffed too (Peak-style +analyzers). The control values are read after ~13 blocks of the same repeating +input, so only block-stable control outputs can match; stateful accumulators +legitimately differ and are reported as MISMATCH. + +Remaining honest gaps +--------------------- + * MIDI / texture / geometry / buffer content is not representable in a Pd + message capture; such objects report their instantiation status only. + * The Pd audio binding defaults a *dynamic* audio bus to a single channel, + so a stereo golden is compared against Pd's channel 0 only (the + overlapping-channel prefix -- exactly what ``compare_audio`` does). Objects + that sum/route across the golden's extra input channels legitimately + differ; that is reported as MISMATCH, not hidden. + * For audio objects the control-outlet indices assume Pd exposed as many + signal outlets as the golden recorded; dynamic-bus objects where that + differs miss their control capture (reported, not hidden). Example: @@ -68,14 +84,16 @@ import sys sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) -from golden_compare import aggregate_case_verdicts, compare_audio +from golden_compare import (aggregate_case_verdicts, compare_audio, + compare_callbacks, compare_controls) def pd_symbol(name): """The symbol the Pd input binding matches a control against: - ``pd::get_name_symbol`` keeps only [A-Za-z0-9.~] from the port name (see - binding/pd/helpers.hpp valid_char_for_name).""" - return re.sub(r"[^A-Za-z0-9.~]", "", str(name)) + ``pd::get_name_symbol`` REPLACES every character outside [A-Za-z0-9.~] + with '_' (avnd::fixup_identifier with pd::valid_char_for_name) -- e.g. + port "Combo_A" stays "Combo_A", "with space" becomes "with_space".""" + return re.sub(r"[^A-Za-z0-9.~]", "_", str(name)) def pd_float(x): @@ -86,13 +104,137 @@ def pd_float(x): return repr(v) -def build_driver(c_name, gcase, frames, tmp): - """Return (patch_text, out_paths) for one golden case, or (None, []) if the - case has no audio output to capture.""" +def pd_atom(v): + """One golden value as a Pd message atom.""" + if isinstance(v, bool): + return str(int(v)) + if isinstance(v, float): + return pd_float(v) + return str(v) + + +def control_segments(gcase): + """The ordered message segments that replay this case's control inputs and + message invocations; joined with commas they form one Pd message box whose + parts fire sequentially (guaranteed order).""" + segs = [] + for c in gcase.get("inputs", {}).get("controls", []): + v = c.get("value") + nm = pd_symbol(c.get("name", "")) + if not nm or v == "unrecorded" or v is None: + continue + if v == "bang": + segs.append(nm) # selector-only message engages the impulse + elif isinstance(v, list): + # container control (vector / array / aggregate): one list message + if all(isinstance(x, (bool, int, float, str)) for x in v): + segs.append((nm + " " + " ".join(pd_atom(x) for x in v)).strip()) + elif isinstance(v, (bool, int, float, str)): + segs.append(f"{nm} {pd_atom(v)}") + for m in gcase.get("inputs", {}).get("messages", []) or []: + if m.get("status") != "invoked": + continue + nm = pd_symbol(m.get("name", "")) + if not nm: + continue + args = " ".join(pd_atom(a) for a in m.get("args", [])) + segs.append(f"{nm} {args}".strip()) + return segs + + +def load_dump(dumps_dir, stem): + """Declaration-ordered output ports [{name, type}] from the introspection + dump JSON for this object, or None if unavailable.""" + if not dumps_dir or not stem: + return None + p = os.path.join(dumps_dir, stem + ".json") + if not os.path.exists(p): + return None + try: + outs = json.load(open(p)).get("outputs") + return outs if isinstance(outs, list) else [] + except Exception: + return None + + +def parse_prints(proc): + """Parse ``[print cap]`` lines from a finished pd process into + {outlet_index: [event, ...]} where each event is a list of atoms + (floats where parseable, else strings). Windows pd -nogui routes [print] + to stderr; scan both streams.""" + caps = {} + text = (proc.stdout or "") + "\n" + (proc.stderr or "") + for line in text.splitlines(): + m = re.match(r"^cap(\d+): (.*)$", line.strip()) + if not m: + continue + k, rest = int(m.group(1)), m.group(2).strip() + for prefix in ("symbol ", "list ", "float "): + if rest.startswith(prefix): + rest = rest[len(prefix):] + break + vals = [] + if rest != "bang": + # Pd escapes spaces inside a single symbol atom ("from\ bang"): + # split on unescaped whitespace only, then unescape. + for t in re.split(r"(?] the port is wired to (for audio + objects the harness wires prints only to the non-audio outlets, so + cap indices count non-audio ports in declaration order).""" + gctl = gcase.get("outputs", {}).get("controls") or [] + gcb = gcase.get("outputs", {}).get("callbacks") or [] + if outs_decl is not None: + params, callbacks, k = [], [], 0 + for o in outs_decl: + ty = o.get("type", "") + if audio_obj and ty == "audio": + continue # signal outlets are not wired to prints + nm = o.get("name") + if ty == "callback": + callbacks.append((k, nm)) + elif ty == "parameter": + params.append((k, nm)) + # other types (midi, texture...) still occupy an outlet + k += 1 + # unnamed ports: fall back to the golden's positional naming + params = [(k, nm if nm else f"p{i}") for i, (k, nm) in enumerate(params)] + callbacks = [(k, nm if nm else f"p{i}") + for i, (k, nm) in enumerate(callbacks)] + return (params, callbacks, None) + # No dump: positional mapping is only unambiguous with one kind of output. + if gcb and gctl: + return ([], [], "ambiguous-outlets") + if gcb: + return ([], [(i, cb.get("name", f"p{i}")) for i, cb in enumerate(gcb)], + None) + return ([(i, c.get("name", f"p{i}")) for i, c in enumerate(gctl)], [], None) + + +def build_driver(c_name, gcase, frames, tmp, n_prints=0): + """Audio-object driver: return (patch_text, out_paths) for one golden case, + or (None, []) if there is nothing to capture (no audio output and no + control capture requested). n_prints control/callback outlets -- assumed to + sit after the signal outlets -- are wired to [print cap] and flushed by + a bang sent after the recorded block.""" gin = gcase.get("inputs", {}).get("audio") or [] gout = gcase.get("outputs", {}).get("audio") or [] in_nch, out_nch = len(gin), len(gout) - if out_nch == 0: + if out_nch == 0 and n_prints == 0: return (None, []) lines = ["#N canvas 0 0 900 700 12;"] @@ -129,26 +271,34 @@ def add(s): vals = " ".join(pd_float(x) for x in gin[k]) setmsgs.append(add(f"#X msg 440 {120 + 18 * k} \\; in{k} 0 {vals};")) - # control inputs (numeric; sent to the left inlet) - cmsgs = [] - for c in gcase.get("inputs", {}).get("controls", []): - v = c.get("value") - if v == "unrecorded": - continue - if isinstance(v, bool): - v = int(v) - if not isinstance(v, (int, float)): - continue # strings/other: not sent through the audio left inlet - cmsgs.append(add(f"#X msg 660 {60 + 22 * len(cmsgs)} " - f"{pd_symbol(c.get('name', ''))} {v};")) + # control inputs / message replays: ONE comma-separated message box so the + # sends happen in golden order (multiple connections from one outlet have + # no guaranteed order in Pd). + segs = control_segments(gcase) + ctlmsg = add("#X msg 660 60 " + " \\, ".join(segs) + ";") if segs else None + + # prints capturing the control/callback outlets + prints = [add(f"#X obj 660 {200 + 22 * k} print cap{k};") + for k in range(n_prints)] + bangmsg = add("#X msg 20 440 bang;") if n_prints else None dspon = add("#X msg 20 460 \\; pd dsp 1;") - d1 = add("#X obj 20 500 delay 20;") + # One-block gate: [bang~] fires at the end of every DSP block; a counter + + # [sel 1] triggers exactly once, right after the FIRST block -- so control + # outputs are committed with the same one-block state the golden recorded + # (a wall-clock delay would let ~13 blocks run first). + bng = add("#X obj 700 400 bang~;") + fcnt = add("#X obj 700 430 f 1;") + finc = add("#X obj 760 430 + 1;") + fsel = add("#X obj 700 460 sel 1;") + seq = add("#X obj 700 490 t b b b;") out_paths = [f"{tmp}/pdout_{k}.txt" for k in range(out_nch)] wl = "".join(f"\\; out{k} write {out_paths[k]} cr" for k in range(out_nch)) - savemsg = add(f"#X msg 20 540 {wl};") - d2 = add("#X obj 20 580 delay 60;") + savemsg = add(f"#X msg 20 540 {wl};") if out_nch else None + d2 = add("#X obj 20 580 delay 30;") quitmsg = add("#X msg 20 620 \\; pd quit;") + # watchdog: quit even if DSP never produces a block + wd = add("#X obj 220 580 delay 2000;") conns = [] # audio signal wiring @@ -156,44 +306,86 @@ def add(s): conns.append((trecv[k], 0, obj, k)) for k in range(out_nch): conns.append((obj, k, twrite[k], 0)) + # Control/callback outlets follow the signal outlets; the binding creates + # one signal outlet per audio output channel (none for analyzers), which + # matches the golden's recorded channel count. + for k in range(n_prints): + conns.append((obj, out_nch + k, prints[k], 0)) # loadbang -> trigger conns.append((lb, 0, trig, 0)) # prep (trigger outlet 1): preload inputs, set controls, arm tabwrite~ for sm in setmsgs: conns.append((trig, 1, sm, 0)) - for cm in cmsgs: - conns.append((trig, 1, cm, 0)) - conns.append((cm, 0, obj, 0)) + if ctlmsg is not None: + conns.append((trig, 1, ctlmsg, 0)) + conns.append((ctlmsg, 0, obj, 0)) for tw in twrite: conns.append((trig, 1, tw, 0)) - # go (trigger outlet 0): DSP on, then delayed save + quit + # go (trigger outlet 0): DSP on; the [bang~]->counter->[sel 1] chain fires + # once at the end of the first block, then (right-to-left trigger order) + # commit-bang -> save -> quit. conns.append((trig, 0, dspon, 0)) - conns.append((trig, 0, d1, 0)) - conns.append((d1, 0, savemsg, 0)) - conns.append((d1, 0, d2, 0)) + conns.append((bng, 0, fcnt, 0)) + conns.append((fcnt, 0, finc, 0)) + conns.append((finc, 0, fcnt, 1)) + conns.append((fcnt, 0, fsel, 0)) + conns.append((fsel, 0, seq, 0)) + if bangmsg is not None: + conns.append((seq, 2, bangmsg, 0)) + conns.append((bangmsg, 0, obj, 0)) + if savemsg is not None: + conns.append((seq, 1, savemsg, 0)) + conns.append((seq, 0, d2, 0)) conns.append((d2, 0, quitmsg, 0)) + # watchdog quit if no DSP block ever completes + conns.append((trig, 0, wd, 0)) + conns.append((wd, 0, quitmsg, 0)) for (s, so, d, di) in conns: lines.append(f"#X connect {s} {so} {d} {di};") return ("\n".join(lines) + "\n", out_paths) -def run_case(pd_exe, externals, c_name, gcase, meta, tmp, timeout): - """Run one golden case head-less; return (out_channels, error). - out_channels is [samples or None] in port order (None = channel not - produced, e.g. Pd exposed fewer channels than the golden).""" - frames = int(meta.get("frames", 64)) - patch, out_paths = build_driver(c_name, gcase, frames, tmp) - if patch is None: - return (None, "no-audio-output") +def build_control_driver(c_name, gcase, n_outlets): + """Message-object driver: set the case's controls, replay its messages, + bang once (runs the object and commits every output control), capture all + n_outlets outlets with [print cap], quit.""" + lines = ["#N canvas 0 0 900 700 12;"] + idx = 0 + + def add(s): + nonlocal idx + lines.append(s) + i = idx + idx += 1 + return i + lb = add("#X obj 20 20 loadbang;") + trig = add("#X obj 20 60 trigger bang bang;") + obj = add(f"#X obj 20 220 {c_name};") + prints = [add(f"#X obj {20 + 130 * k} 320 print cap{k};") + for k in range(n_outlets)] + # all sends in ONE comma-separated message box: guaranteed order, + # ending with the bang that runs + commits. + segs = control_segments(gcase) + ["bang"] + msg = add("#X msg 320 60 " + " \\, ".join(segs) + ";") + d1 = add("#X obj 20 100 delay 50;") + quitmsg = add("#X msg 20 140 \\; pd quit;") + + conns = [(lb, 0, trig, 0), (trig, 1, msg, 0), (msg, 0, obj, 0), + (trig, 0, d1, 0), (d1, 0, quitmsg, 0)] + for k, pr in enumerate(prints): + conns.append((obj, k, pr, 0)) + for (s, so, d, di) in conns: + lines.append(f"#X connect {s} {so} {d} {di};") + return "\n".join(lines) + "\n" + + +def launch_pd(pd_exe, externals, patch_text, tmp, timeout): + """Write the driver patch, run pd head-less; return (proc, error).""" driver = f"{tmp}/pd_driver.pd" with open(driver, "w", newline="\n") as f: - f.write(patch) - for p in out_paths: - if os.path.exists(p): - os.remove(p) - + f.write(patch_text) cmd = [pd_exe, "-nogui", "-batch", "-noprefs", "-path", externals, "-open", driver] try: @@ -203,34 +395,148 @@ def run_case(pd_exe, externals, c_name, gcase, meta, tmp, timeout): subprocess.run(["taskkill", "/F", "/IM", "pd.exe"], capture_output=True) return (None, f"timeout {timeout}s") - # Every other object in the driver is stock Pd, so a "couldn't create" means # the external itself failed to instantiate. Catch it: otherwise the never- # written output tables read back as silence and masquerade as a (possibly # matching, if the golden is silent too) capture. if "couldn't create" in (p.stderr or ""): return (None, "object-not-created") - - out = [] + return (p, None) + + +def compare_captured_controls(caps, params, callbacks, gcase, atol, rtol): + """Diff the parsed [print] captures against the golden's output controls + and callback events; returns a list of (verdict, detail).""" + results = [] + gctl = gcase.get("outputs", {}).get("controls") or [] + gcb = gcase.get("outputs", {}).get("callbacks") or [] + recordable = [c for c in gctl + if isinstance(c, dict) and c.get("value") != "unrecorded"] + if recordable and params: + named = {} + for cap_idx, nm in params: + events = caps.get(cap_idx) or [] + if events and events[-1]: + ev = events[-1] + named[nm] = ev[0] if len(ev) == 1 else ev + results.append(compare_controls(named, gctl, atol, rtol)) + for i, (cap_idx, nm) in enumerate(callbacks): + gev = None + for cb in gcb: + if cb.get("name") == nm or cb.get("index") == i: + gev = cb.get("events") + break + if gev is None: + continue + events = caps.get(cap_idx) or [] + v, d = compare_callbacks(events, gev, atol, rtol) + if v == "MISMATCH" and events and all(e for e in events): + # Callbacks with a symbol/c_name are emitted [ args( in Pd; + # the golden records only the args. Retry with the leading symbol + # stripped when every event starts with the same non-golden token. + stripped = [e[1:] for e in events] + v2, d2 = compare_callbacks(stripped, gev, atol, rtol) + if v2 == "match": + v, d = v2, d2 + " (symbol-prefixed)" + results.append((v, f"cb {nm}: {d}")) + return results + + +def run_audio_case(pd_exe, externals, c_name, gcase, meta, tmp, timeout, + outs_decl, atol, rtol): + """One golden case of an audio object: run DSP, capture audio tables and + (via bang + [print]) any control/callback outputs. Returns + (verdict, detail).""" + frames = int(meta.get("frames", 64)) + gout = gcase.get("outputs", {}).get("audio") or [] + params, callbacks, map_err = out_port_map(outs_decl, gcase, audio_obj=True) + n_prints = 0 + if params or callbacks: + n_prints = max(k for k, _ in params + callbacks) + 1 + patch, out_paths = build_driver(c_name, gcase, frames, tmp, n_prints) + if patch is None: + return ("no-capturable-output", map_err or "") for p in out_paths: if os.path.exists(p): - try: - out.append([float(x) for x in open(p).read().split()]) - except Exception: + os.remove(p) + + proc, err = launch_pd(pd_exe, externals, patch, tmp, timeout) + if err: + return ("error", err) + + results = [] + if gout: + out = [] + for p in out_paths: + if os.path.exists(p): + try: + out.append([float(x) for x in open(p).read().split()]) + except Exception: + out.append(None) + else: out.append(None) + if all(x is None for x in out): + results.append(("error", "no-output-captured")) else: - out.append(None) - if all(x is None for x in out): - return (None, "no-output-captured") - return (out, None) - - -def run_object(pd_exe, externals, g, atol, rtol, tmp, timeout): + produced = [{"name": f"c{i}", "samples": s} + for i, s in enumerate(out) if s is not None] + results.append(compare_audio(produced, gout, atol, rtol)) + if n_prints: + caps = parse_prints(proc) + results += compare_captured_controls(caps, params, callbacks, gcase, + atol, rtol) + elif map_err: + results.append((map_err, "")) + return fold_results(results) + + +def run_control_case(pd_exe, externals, c_name, gcase, tmp, timeout, + outs_decl, atol, rtol): + """One golden case of a control/message object: set controls, replay + messages, bang, capture outlets via [print]. Returns (verdict, detail).""" + params, callbacks, map_err = out_port_map(outs_decl, gcase, audio_obj=False) + n_outlets = len(outs_decl) if outs_decl is not None else \ + max([k + 1 for k, _ in params + callbacks] or [0]) + patch = build_control_driver(c_name, gcase, n_outlets) + proc, err = launch_pd(pd_exe, externals, patch, tmp, timeout) + if err: + return ("error", err) if "timeout" in err else (err, "") + + if not (params or callbacks): + # Nothing capturable (midi/texture/geometry-only outputs, or none): + # the object still instantiated and survived the sends + bang. + gtex = gcase.get("outputs", {}).get("texture") or [] + if map_err: + return (map_err, "") + return ("no-pd-representation" if gtex else "instantiated", "") + caps = parse_prints(proc) + results = compare_captured_controls(caps, params, callbacks, gcase, + atol, rtol) + return fold_results(results) + + +def fold_results(results): + """Combine several (verdict, detail) comparisons of one case: any MISMATCH + or error wins, else match if anything matched.""" + if not results: + return ("no-capturable-output", "") + for bad in ("MISMATCH", "error"): + for v, d in results: + if v == bad: + return (v, d) + for v, d in results: + if v == "match": + return ("match", "; ".join(x[1] for x in results if x[0] == "match")) + return results[0] + + +def run_object(pd_exe, externals, g, atol, rtol, tmp, timeout, dumps_dir): c_name = g["c_name"] entry = {"name": c_name, "cases": []} if not os.path.exists(os.path.join(externals, c_name + ".dll")): entry["verdict"] = "no-external" return entry + outs_decl = load_dump(dumps_dir, g.get("_stem")) gcases = g.get("cases") if gcases is None: # old single-case schema @@ -239,28 +545,17 @@ def run_object(pd_exe, externals, g, atol, rtol, tmp, timeout): verdicts = [] for ci, gcase in enumerate(gcases): rec = {"index": ci} - gout = gcase.get("outputs", {}).get("audio") or [] - if not gout: - # No audio output recorded: audio processors don't commit control - # outputs, other families have no head-less audio read-back here. - gc = gcase.get("outputs", {}).get("controls") or [] - gin_a = gcase.get("inputs", {}).get("audio") or [] - v = "no-control-commit" if (gin_a and gc) else "no-audio-output" - verdicts.append((ci, v, "")) - rec["verdict"] = v - entry["cases"].append(rec) - continue - out_ch, err = run_case(pd_exe, externals, c_name, gcase, - g.get("meta", {}), tmp, timeout) - if err: - verdicts.append((ci, "error", err)) - rec["verdict"], rec["error"] = "error", err + gout_a = gcase.get("outputs", {}).get("audio") or [] + gin_a = gcase.get("inputs", {}).get("audio") or [] + if gout_a or gin_a: + v, d = run_audio_case(pd_exe, externals, c_name, gcase, + g.get("meta", {}), tmp, timeout, outs_decl, + atol, rtol) else: - produced = [{"name": f"c{i}", "samples": s} - for i, s in enumerate(out_ch) if s is not None] - v, d = compare_audio(produced, gout, atol, rtol) - verdicts.append((ci, v, d)) - rec["verdict"], rec["detail"] = v, d + v, d = run_control_case(pd_exe, externals, c_name, gcase, tmp, + timeout, outs_decl, atol, rtol) + verdicts.append((ci, v, d)) + rec["verdict"], rec["detail"] = v, d entry["cases"].append(rec) v, detail = aggregate_case_verdicts(verdicts) @@ -275,6 +570,8 @@ def load_goldens(goldens_dir): try: g = json.load(open(f)) if g.get("c_name"): + # file stem = CMake target name = the introspection dump's name + g["_stem"] = os.path.splitext(os.path.basename(f))[0] goldens.append(g) except Exception: pass @@ -288,6 +585,10 @@ def main(): ap.add_argument("--externals", required=True, help="dir of built pd externals (.dll)") ap.add_argument("--goldens", required=True, help="golden JSON dir") + ap.add_argument("--dumps", default=None, + help="introspection dump JSON dir (declaration-ordered " + "outlet mapping); default: goldens dir with 'golden' " + "replaced by 'dump'") ap.add_argument("--atol", type=float, default=1e-3, help="abs tolerance") ap.add_argument("--rtol", type=float, default=1e-3, help="rel tolerance") ap.add_argument("--report", default="pd_golden_report.json") @@ -304,6 +605,11 @@ def main(): tmp = os.path.abspath(args.tmp).replace("\\", "/") os.makedirs(tmp, exist_ok=True) + dumps_dir = args.dumps + if dumps_dir is None: + cand = os.path.abspath(args.goldens).replace("golden", "dump") + dumps_dir = cand if os.path.isdir(cand) else None + goldens = load_goldens(args.goldens) if args.only: goldens = [g for g in goldens if g["c_name"] == args.only] @@ -311,7 +617,7 @@ def main(): counts, mism, report = {}, [], [] for g in goldens: entry = run_object(args.pd, args.externals, g, args.atol, args.rtol, - tmp, args.timeout) + tmp, args.timeout, dumps_dir) report.append(entry) v = entry.get("verdict", "?") counts[v] = counts.get(v, 0) + 1 diff --git a/tooling/run_python_golden.py b/tooling/run_python_golden.py index 351a8872..632db146 100644 --- a/tooling/run_python_golden.py +++ b/tooling/run_python_golden.py @@ -222,6 +222,13 @@ def stage(s): "outputs": g.get("outputs", {})}] verdicts = [] for ci, gcase in enumerate(gcases): + # Impulse-engagement and message-invocation cases need a backend + # driving mechanism this in-process harness doesn't have (yet). + if gcase.get("kind") in ("impulse", "message"): + verdicts.append((ci, "unsupported-case-kind", gcase["kind"])) + entry["cases"].append( + {"index": ci, "verdict": "unsupported-case-kind"}) + continue try: rec = run_case(cls, gcase, g.get("meta", {}), lambda s: stage(f"case{ci}:{s}")) diff --git a/tooling/td/run_td_sweep.py b/tooling/td/run_td_sweep.py index 82ed154b..8e30a366 100644 --- a/tooling/td/run_td_sweep.py +++ b/tooling/td/run_td_sweep.py @@ -322,6 +322,12 @@ def compare_one(rc, gout): ci = rc.get("index", 0) if ci >= len(gcases): continue + # Impulse-engagement / message-invocation cases are not + # replayed by the TD runner; skip them honestly. + if gcases[ci].get("kind") in ("impulse", "message"): + verdicts.append( + (ci, "unsupported-case-kind", gcases[ci]["kind"])) + continue gout = gcases[ci].get("outputs", {}) v, d = compare_one(rc, gout) verdicts.append((ci, v, d))