From 528244381a556c41ed4c189ad56d874bb593fbae Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jean-Micha=C3=ABl=20Celerier?= Date: Thu, 2 Jul 2026 19:58:58 -0400 Subject: [PATCH 01/17] wrappers: always hand the effect zero-filled channels (never null) The process adapters (process/ and process_bus/, per-channel and poly) already had storage to point missing channels at silent scratch buffers, but it was gated behind a commented-out AVND_ENABLE_SAFE_BUFFER_STORAGE, so by default a host supplying fewer channels than the object declares left bus.channel = nullptr -- any effect that dereferences its channel then crashes. That null path is a core-invariant violation: an effect must always receive valid, zero-filled channel buffers. Enable the safe buffer storage by default (opt out with -DAVND_ENABLE_SAFE_BUFFER_STORAGE=0). process_bus/base.hpp includes process/base.hpp, so this one definition covers every backend. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_011s7huWR2wFsLFiMJPjx1z2 --- include/avnd/wrappers/process/base.hpp | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/include/avnd/wrappers/process/base.hpp b/include/avnd/wrappers/process/base.hpp index 805023c7..496112d6 100644 --- a/include/avnd/wrappers/process/base.hpp +++ b/include/avnd/wrappers/process/base.hpp @@ -17,7 +17,16 @@ #include #include #include -// #define AVND_ENABLE_SAFE_BUFFER_STORAGE 1 + +// The effect must always receive valid, zero-filled channel buffers. When a host +// supplies fewer channels than the object declares (e.g. an unconnected input), +// the missing channels must point at silent scratch buffers -- otherwise the +// effect dereferences a null channel pointer and crashes. This is a core +// invariant, so it is on by default; define AVND_ENABLE_SAFE_BUFFER_STORAGE=0 +// before including to opt out. +#ifndef AVND_ENABLE_SAFE_BUFFER_STORAGE +#define AVND_ENABLE_SAFE_BUFFER_STORAGE 1 +#endif namespace avnd { From 9c971808195cb34c88f99e43289a109c9255beed Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jean-Micha=C3=ABl=20Celerier?= Date: Fri, 3 Jul 2026 10:40:44 -0400 Subject: [PATCH 02/17] touchdesigner: fix crash on audio->control analyzers (zero audio-output busses) An object with an audio input but no audio-output bus (e.g. an Essentia analyzer: audio in -> a single control/real out) crashed TD on cook. Two independent bugs in the audio processor, either of which alone crashes: - getOutputInfo's output_busses==0 branch returned after only filling the info struct -- it never called allocate_buffers / init_channels / prepare like the >=1 branch does. So the conversion/scratch buffers were unallocated and process() wrote into them. Now it prepares the object from the input. - execute()'s input branch computed num_out_channels via get_output_channels(impl, 0), which indexes output bus 0 -- nonexistent when there are zero output busses -- yielding a bogus count; the out_ptrs loop then ran off the end of output->channels. Use output->numChannels (what TD actually allocated), matching the no-input branch. Verified with the golden differential: avnd_essentia_entropy goes from crashing TD to cooking cleanly (processes its 64-sample input); 0 objects crash. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_011s7huWR2wFsLFiMJPjx1z2 --- .../touchdesigner/chop/audio_processor.hpp | 30 ++++++++++++++++--- 1 file changed, 26 insertions(+), 4 deletions(-) diff --git a/include/avnd/binding/touchdesigner/chop/audio_processor.hpp b/include/avnd/binding/touchdesigner/chop/audio_processor.hpp index 080cda83..a3807425 100644 --- a/include/avnd/binding/touchdesigner/chop/audio_processor.hpp +++ b/include/avnd/binding/touchdesigner/chop/audio_processor.hpp @@ -96,10 +96,27 @@ struct audio_processor : public TD::CHOP_CPlusPlusBase { if constexpr(avnd::bus_introspection::output_busses == 0) { + // No audio output (e.g. an analyzer: audio in -> control out surfaced via + // the Info CHOP). We still MUST prepare the object -- allocate the + // conversion/scratch buffers and set up the input channels, exactly like + // the >= 1 branch below. Skipping it left the dsp buffers unallocated, so + // execute()/process() wrote into them and crashed TD on cook. + auto in0 = inputs->getNumInputs() > 0 ? inputs->getInputCHOP(0) : nullptr; + const int in_ch = in0 ? updateAudioChannels(inputs) : 0; + info->numChannels = 0; - info->numSamples = 0; + info->numSamples = in0 ? in0->numSamples : 0; info->startIndex = 0; - info->sampleRate = 0; + info->sampleRate = in0 ? in0->sampleRate : default_sample_rate; + + avnd::process_setup setup_info{ + .input_channels = in_ch, + .output_channels = 0, + .frames_per_buffer = default_buffer_size, + .rate = in0 ? (float)in0->sampleRate : (float)default_sample_rate}; + processor.allocate_buffers(setup_info, float{}); + implementation.init_channels(in0 ? in0->numChannels : 0, 0); + avnd::prepare(implementation, setup_info); return true; } else if constexpr(avnd::bus_introspection::output_busses >= 1) @@ -240,10 +257,15 @@ struct audio_processor : public TD::CHOP_CPlusPlusBase const TD::OP_CHOPInput* input = inputs->getInputCHOP(0); assert(input); - // Prepare input/output spans for processing + // Prepare input/output spans for processing. Use output->numChannels (what + // TD actually allocated, per getOutputInfo) rather than + // get_output_channels(impl, 0): the latter indexes output bus 0, which does + // not exist for objects with zero audio-output busses (e.g. an audio-> + // control analyzer) -- reading it yields a bogus count and the out_ptrs + // loop below then runs off the end of output->channels and crashes. const int num_samples = output->numSamples; const int num_in_channels = total_input_channels; - const int num_out_channels = channels.get_output_channels(implementation, 0); + const int num_out_channels = output->numChannels; auto in_ptrs = (float**) alloca(sizeof(float*) * num_in_channels + 4); auto out_ptrs = (float**) alloca(sizeof(float*) * num_out_channels + 4); From 34a26d857e17c399203806937c2cf6396816f1b3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jean-Micha=C3=ABl=20Celerier?= Date: Fri, 3 Jul 2026 10:40:44 -0400 Subject: [PATCH 03/17] tooling/td sweep: record the crash stage (created/precook/cooked) When an object crashes TD mid-sweep the breadcrumb now carries a ':stage' suffix so the report shows whether it died on instantiation, during input feeding, or in cook -- which is what localised the essentia analyzer crash. The harness strips the suffix for resume matching and stores the full stage in crash_stage. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_011s7huWR2wFsLFiMJPjx1z2 --- tooling/gen_tester_patches.py | 3 +++ tooling/td/run_td_sweep.py | 11 ++++++++--- 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/tooling/gen_tester_patches.py b/tooling/gen_tester_patches.py index c91af3f0..e86bba60 100644 --- a/tooling/gen_tester_patches.py +++ b/tooling/gen_tester_patches.py @@ -529,6 +529,7 @@ def gen_td_runner(dumps): " entry = {'name': s['name'], 'ok': False}\n" " try:\n" " n = container.create(cls, 'test_' + s['name'])\n" + " open(CUR, 'w').write(s['name'] + ':created')\n" " # Apply golden input controls as params; ones that aren't params\n" " # (value-input ports become input CHANNELS in TD) are collected to\n" " # feed via a source CHOP below.\n" @@ -604,7 +605,9 @@ def gen_td_runner(dumps): " n.inputConnectors[0].connect(src)\n" " except Exception as e:\n" " entry['input_err'] = str(e)\n" + " open(CUR, 'w').write(s['name'] + ':precook')\n" " n.cook(force=True)\n" + " open(CUR, 'w').write(s['name'] + ':cooked')\n" " entry['ok'] = not n.errors()\n" " entry['errors'] = n.errors()\n" " entry['warnings'] = n.warnings()\n" diff --git a/tooling/td/run_td_sweep.py b/tooling/td/run_td_sweep.py index 78b2520c..ab27333d 100644 --- a/tooling/td/run_td_sweep.py +++ b/tooling/td/run_td_sweep.py @@ -267,15 +267,20 @@ def _read(p): culprit = _read(cur) if not culprit or culprit == "DONE": break # timed out with no progress, or actually finished + # The breadcrumb may carry a ':' suffix (created/precook/cooked) + # for diagnostics -- strip it to get the object name used for resume + # matching, but keep the full breadcrumb so we can see where it died. + base = culprit.split(":", 1)[0] crashers.append(culprit) # Record the crasher so the relaunch skips it. try: rep = json.load(open(report, encoding="utf-8")) except Exception: rep = [] - if culprit not in {r["name"] for r in rep}: - rep.append({"name": culprit, "ok": False, - "exception": "crashed TouchDesigner (instantiation/cook)"}) + if base not in {r["name"] for r in rep}: + rep.append({"name": base, "ok": False, + "exception": "crashed TouchDesigner (instantiation/cook)", + "crash_stage": culprit}) open(report, "w", encoding="utf-8").write(json.dumps(rep, indent=2)) print(f" *** crashed on '{culprit}' -> recorded; relaunching to continue") From 45418f668ffd7a63ee2322c089dc2523744fe030 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jean-Micha=C3=ABl=20Celerier?= Date: Fri, 3 Jul 2026 11:31:01 -0400 Subject: [PATCH 04/17] golden: one-at-a-time case sweep (enum modes + range endpoints) per object Each golden now records a 'cases' array instead of a single input point: the base case (all controls at their midpoint/default) plus, for every enum input, one case per non-zero mode, and for every ranged numeric input, a case at the range min and max (sum, not product, so it stays bounded). Each case runs a fresh effect, so state never leaks across cases -- 228 cases over 115 objects vs the previous 1 case per object. The TD runner replays every case against a fresh op (matching the fresh-effect golden semantics) with a per-case crash breadcrumb, and the sweep diffs each case, reporting an object as MISMATCH if any case diverges. Old single-case goldens/reports still compare via a fallback. Validated: full TD differential on this branch is unchanged at 45 match / 7 known mismatches (213 cases replayed vs 110) -- the multi-case sweep introduces no regressions. Co-Authored-By: Claude Fable 5 --- include/avnd/binding/golden/run.hpp | 164 ++++++++++++++----- tooling/gen_tester_patches.py | 236 ++++++++++++++-------------- tooling/td/run_td_sweep.py | 64 ++++++-- 3 files changed, 295 insertions(+), 169 deletions(-) diff --git a/include/avnd/binding/golden/run.hpp b/include/avnd/binding/golden/run.hpp index 9564aa0a..6e464bdb 100644 --- a/include/avnd/binding/golden/run.hpp +++ b/include/avnd/binding/golden/run.hpp @@ -68,23 +68,39 @@ std::string clean_name(int index) return std::string{nm}; } -// Set a deterministic value on an input control field, then append -// {index, name, value} to the ordered `arr` (order matches the dump). +// One test case = "override control #index with `value`" (index < 0 => the base +// case: every control at its default midpoint / first-enum / true). We sweep one +// control at a time (OAT): the base case, then each enum mode and each numeric +// range endpoint, holding the others at their default. `value` carries the enum +// ordinal or the range endpoint as a double; string/bool overrides are unused. +struct case_override +{ + int index = -1; + double value = 0.0; +}; + +// 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`. template -void set_and_record_control(F& field, dump_json::document& jdoc, dump_json::value arr, int index) +void set_and_record_control( + F& field, dump_json::document& jdoc, dump_json::value arr, int index, + const case_override& ov) { using vt = std::decay_t; + const bool over = (ov.index == index); auto node = jdoc.make_node(); node["index"] = index; node["name"] = clean_name(index); if constexpr(std::is_same_v) { - field.value = true; - node["value"] = true; + field.value = over ? (ov.value != 0.0) : true; + node["value"] = (bool)field.value; } else if constexpr(std::floating_point) { - if constexpr(avnd::parameter_with_minmax_range) + if(over) + field.value = static_cast(ov.value); + else if constexpr(avnd::parameter_with_minmax_range) { constexpr auto r = avnd::get_range(); field.value = static_cast(r.min + 0.5 * (r.max - r.min)); @@ -95,12 +111,14 @@ void set_and_record_control(F& field, dump_json::document& jdoc, dump_json::valu } else if constexpr(std::is_enum_v) { - field.value = static_cast(0); + field.value = over ? static_cast((int)ov.value) : static_cast(0); node["value"] = static_cast(field.value); } else if constexpr(std::integral) { - if constexpr(avnd::parameter_with_minmax_range) + if(over) + field.value = static_cast((std::int64_t)ov.value); + else if constexpr(avnd::parameter_with_minmax_range) { constexpr auto r = avnd::get_range(); field.value = static_cast(r.min + (r.max - r.min) / 2); @@ -121,6 +139,46 @@ void set_and_record_control(F& field, dump_json::document& jdoc, dump_json::valu arr.push_back(node); } +// 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. +template +std::vector enumerate_cases() +{ + std::vector cases; + cases.push_back({-1, 0.0}); // base + if constexpr(avnd::parameter_input_introspection::size > 0) + { + avnd::effect_container probe; + avnd::init_controls(probe); + int idx = 0; + avnd::parameter_input_introspection::for_all( + avnd::get_inputs(probe), [&](auto& field) { + using F = std::decay_t; + using vt = std::decay_t; + if constexpr(std::is_enum_v) + { + constexpr int C = (int)avnd::get_enum_choices_count(); + for(int c = 1; c < C; ++c) + cases.push_back({idx, (double)c}); + } + else if constexpr( + (std::floating_point || std::integral)&&avnd:: + parameter_with_minmax_range) + { + constexpr auto r = avnd::get_range(); + if((double)r.min != (double)r.max) + { + cases.push_back({idx, (double)r.min}); + cases.push_back({idx, (double)r.max}); + } + } + idx++; + }); + } + return cases; +} + // Append an output control field's value as {index, name, value}. template void record_output_control(F& field, dump_json::document& jdoc, dump_json::value arr, int index) @@ -140,26 +198,11 @@ void record_output_control(F& field, dump_json::document& jdoc, dump_json::value arr.push_back(node); } +// 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 -int run(std::string_view path) +void run_case(dump_json::document& jdoc, dump_json::value case_node, const case_override& ov) { - dump_json::document jdoc; - auto root = jdoc.root(); - root["c_name"] = avnd::get_c_name(); - auto meta = root["meta"]; - meta["seed"] = k_seed; - meta["frames"] = k_frames; - meta["rate"] = k_rate; - - auto write = [&](std::string_view status) { - root["meta"]["status"] = status; - std::ofstream f{std::string{path}}; - f << jdoc.dump(); - return 0; - }; - - try - { avnd::effect_container effect; avnd::process_adapter processor; avnd::audio_channel_manager channels{effect}; @@ -227,7 +270,7 @@ int run(std::string_view path) control_buffers.reserve_space(effect, k_frames); // --- inputs --- - auto in = root["inputs"]; + auto in = case_node["inputs"]; auto in_controls = in["controls"]; in_controls.ensure_array(); if constexpr(avnd::parameter_input_introspection::size > 0) @@ -235,7 +278,7 @@ int run(std::string_view path) int idx = 0; avnd::parameter_input_introspection::for_all( avnd::get_inputs(effect), [&](auto& field) { - set_and_record_control(field, jdoc, in_controls, idx++); + set_and_record_control(field, jdoc, in_controls, idx++, ov); }); } @@ -282,7 +325,7 @@ int run(std::string_view path) avnd::span{out_ptrs.data(), (std::size_t)out_N}, k_frames); // --- outputs --- - auto out = root["outputs"]; + auto out = case_node["outputs"]; auto out_controls = out["controls"]; out_controls.ensure_array(); if constexpr(avnd::parameter_output_introspection::size > 0) @@ -333,17 +376,68 @@ int run(std::string_view path) out_tex.push_back(node); }); } +} - return write("ok"); - } - catch(const std::exception& e) +template +int run(std::string_view path) +{ + dump_json::document jdoc; + auto root = jdoc.root(); + root["c_name"] = avnd::get_c_name(); + auto meta = root["meta"]; + meta["seed"] = k_seed; + meta["frames"] = k_frames; + meta["rate"] = k_rate; + + auto finish = [&](std::string_view status) { + root["meta"]["status"] = status; + std::ofstream f{std::string{path}}; + f << jdoc.dump(); + return 0; + }; + + auto cases_arr = root["cases"]; + cases_arr.ensure_array(); + + std::vector overrides; + try { - root["meta"]["error"] = std::string_view{e.what()}; - return write("error"); + overrides = enumerate_cases(); } catch(...) { - return write("error"); + // If we cannot even enumerate, fall back to the single base case. + overrides = {{-1, 0.0}}; + } + meta["num_cases"] = (int)overrides.size(); + + bool all_ok = true; + int ci = 0; + for(const auto& ov : overrides) + { + auto cnode = jdoc.make_node(); + cnode["index"] = ci++; + cnode["override_control"] = ov.index; + cnode["override_value"] = ov.value; + try + { + run_case(jdoc, cnode, ov); + cnode["status"] = "ok"; + } + catch(const std::exception& e) + { + cnode["status"] = "error"; + cnode["error"] = std::string_view{e.what()}; + all_ok = false; + } + catch(...) + { + cnode["status"] = "error"; + all_ok = false; + } + cases_arr.push_back(cnode); } + + return finish(all_ok ? "ok" : "partial"); } } diff --git a/tooling/gen_tester_patches.py b/tooling/gen_tester_patches.py index e86bba60..169a5eee 100644 --- a/tooling/gen_tester_patches.py +++ b/tooling/gen_tester_patches.py @@ -526,128 +526,130 @@ def gen_td_runner(dumps): " golden = json.load(open(os.path.join(GDIR, s['file'] + '.json')))\n" " except Exception:\n" " golden = None\n" - " entry = {'name': s['name'], 'ok': False}\n" - " try:\n" - " n = container.create(cls, 'test_' + s['name'])\n" - " open(CUR, 'w').write(s['name'] + ':created')\n" - " # Apply golden input controls as params; ones that aren't params\n" - " # (value-input ports become input CHANNELS in TD) are collected to\n" - " # feed via a source CHOP below.\n" - " in_names = []\n" - " in_vals = []\n" - " applied = {}\n" - " if golden:\n" - " for c in golden.get('inputs', {}).get('controls', []):\n" - " if c.get('value') == 'unrecorded': continue # non-value (pulse/aggregate)\n" - " as_par = False\n" - " try:\n" - " setattr(n.par, c['name'], c['value']); as_par = True\n" - " except Exception:\n" - " try:\n" - " setattr(n.par, c['name'].capitalize(), c['value'])\n" - " as_par = True\n" - " except Exception:\n" - " as_par = False\n" - " if as_par:\n" - " applied[c['name']] = c['value']\n" - " else:\n" - " in_names.append(c['name'])\n" - " in_vals.append(c['value'])\n" + " entry = {'name': s['name'], 'ok': False, 'cases': []}\n" + " # Golden may be multi-case ({cases:[{inputs,outputs},...]}) or, for a\n" + " # missing/old-schema golden, a single implicit case. Replay each: a\n" + " # FRESH op per case (so a stateful object isn't tainted across cases,\n" + " # matching the golden which re-runs a fresh effect per case).\n" + " gcases = (golden or {}).get('cases')\n" + " if not gcases:\n" + " if golden and (golden.get('inputs') or golden.get('outputs')):\n" + " gcases = [{'inputs': golden.get('inputs', {}), 'outputs': golden.get('outputs', {})}]\n" " else:\n" - " for k, v in s['pars'].items():\n" - " try: setattr(n.par, k, v)\n" - " except Exception: pass\n" - " entry['applied'] = applied\n" - " par_names = []\n" - " try:\n" - " for p in n.pars(): par_names.append(p.name)\n" - " except Exception: pass\n" - " entry['par_names'] = par_names[:30]\n" - " gin = None\n" - " if golden: gin = golden.get('inputs', {}).get('audio')\n" - " entry['has_audio_in'] = bool(gin)\n" - " # Feed a source CHOP so value-IO objects can cook. Value inputs\n" - " # (ports that aren't params) become input channels -> Constant CHOP.\n" - " # Audio (time-varying) inputs need a Script CHOP -- via samples in\n" - " # a Table DAT the script reads.\n" - " src = None\n" + " gcases = [{}]\n" + " gi = 0\n" + " for gcase in gcases:\n" + " tag = s['name'] + ':case' + str(gi)\n" + " open(CUR, 'w').write(tag) # crash breadcrumb\n" + " cres = {'index': gi, 'ok': False}\n" " try:\n" - " if gin:\n" - " tab = container.create(tableDAT, 'srcd_' + s['name'])\n" - " tab.clear()\n" - " for chn in gin:\n" - " row = []\n" - " for x in chn: row.append(repr(float(x)))\n" - " tab.appendRow(row)\n" - " src = container.create(scriptCHOP, 'src_' + s['name'])\n" - " cb = container.create(textDAT, 'srccb_' + s['name'])\n" - " cb.text = ('def onCook(scriptOp):\\n'\n" - " ' scriptOp.clear()\\n'\n" - " ' t = op(scriptOp.fetch(\"tab\",\"\"))\\n'\n" - " ' if not t: return\\n'\n" - " ' scriptOp.numSamples = t.numCols\\n'\n" - " ' for r in range(t.numRows):\\n'\n" - " ' c = scriptOp.appendChan(\"c\"+str(r))\\n'\n" - " ' for j in range(t.numCols):\\n'\n" - " ' c[j] = float(t[r,j].val)\\n')\n" - " src.store('tab', tab.path)\n" - " src.par.callbacks = cb\n" - " elif in_vals:\n" - " src = container.create(constantCHOP, 'src_' + s['name'])\n" - " for j in range(len(in_vals)):\n" + " uid = s['name'] + '_c' + str(gi)\n" + " n = container.create(cls, 'test_' + uid)\n" + " in_names = []\n" + " in_vals = []\n" + " applied = {}\n" + " gc = gcase.get('inputs', {}).get('controls', [])\n" + " if gc:\n" + " for c in gc:\n" + " if c.get('value') == 'unrecorded': continue # non-value (pulse/aggregate)\n" + " as_par = False\n" " try:\n" - " setattr(src.par, 'value' + str(j), float(in_vals[j]))\n" - " setattr(src.par, 'name' + str(j), str(in_names[j]))\n" + " setattr(n.par, c['name'], c['value']); as_par = True\n" + " except Exception:\n" + " try:\n" + " setattr(n.par, c['name'].capitalize(), c['value']); as_par = True\n" + " except Exception:\n" + " as_par = False\n" + " if as_par:\n" + " applied[c['name']] = c['value']\n" + " else:\n" + " in_names.append(c['name'])\n" + " in_vals.append(c['value'])\n" + " else:\n" + " for k, v in s['pars'].items():\n" + " try: setattr(n.par, k, v)\n" " except Exception: pass\n" - " if src is not None:\n" - " src.cook(force=True)\n" - " if len(n.inputConnectors) > 0:\n" - " n.inputConnectors[0].connect(src)\n" + " cres['applied'] = applied\n" + " if gi == 0:\n" + " par_names = []\n" + " try:\n" + " for p in n.pars(): par_names.append(p.name)\n" + " except Exception: pass\n" + " entry['par_names'] = par_names[:30]\n" + " gin = gcase.get('inputs', {}).get('audio')\n" + " src = None\n" + " try:\n" + " if gin:\n" + " tab = container.create(tableDAT, 'srcd_' + uid)\n" + " tab.clear()\n" + " for chn in gin:\n" + " row = []\n" + " for x in chn: row.append(repr(float(x)))\n" + " tab.appendRow(row)\n" + " src = container.create(scriptCHOP, 'src_' + uid)\n" + " cb = container.create(textDAT, 'srccb_' + uid)\n" + " cb.text = ('def onCook(scriptOp):\\n'\n" + " ' scriptOp.clear()\\n'\n" + " ' t = op(scriptOp.fetch(\"tab\",\"\"))\\n'\n" + " ' if not t: return\\n'\n" + " ' scriptOp.numSamples = t.numCols\\n'\n" + " ' for r in range(t.numRows):\\n'\n" + " ' c = scriptOp.appendChan(\"c\"+str(r))\\n'\n" + " ' for j in range(t.numCols):\\n'\n" + " ' c[j] = float(t[r,j].val)\\n')\n" + " src.store('tab', tab.path)\n" + " src.par.callbacks = cb\n" + " elif in_vals:\n" + " src = container.create(constantCHOP, 'src_' + uid)\n" + " for j in range(len(in_vals)):\n" + " try:\n" + " setattr(src.par, 'value' + str(j), float(in_vals[j]))\n" + " setattr(src.par, 'name' + str(j), str(in_names[j]))\n" + " except Exception: pass\n" + " if src is not None:\n" + " src.cook(force=True)\n" + " if len(n.inputConnectors) > 0:\n" + " n.inputConnectors[0].connect(src)\n" + " except Exception as e:\n" + " cres['input_err'] = str(e)\n" + " open(CUR, 'w').write(tag + ':precook')\n" + " n.cook(force=True)\n" + " cres['ok'] = not n.errors()\n" + " cres['errors'] = n.errors()\n" + " try:\n" + " cres['dbg_nc'] = n.numChans\n" + " cres['dbg_ns'] = n.numSamples\n" + " except Exception: pass\n" + " td_out = []\n" + " try:\n" + " ns = n.numSamples\n" + " for ch in n.chans():\n" + " samples = []\n" + " for i in range(ns): samples.append(float(ch[i]))\n" + " td_out.append({'name': ch.name, 'samples': samples})\n" + " cres['td_out'] = td_out\n" + " except Exception:\n" + " cres['td_out'] = None\n" + " ti = {}\n" + " try:\n" + " info = container.create(infoCHOP, 'info_' + uid)\n" + " info.par.op = n\n" + " info.cook()\n" + " for ci in info.chans():\n" + " ti[ci.name] = float(ci[0])\n" + " info.destroy()\n" + " except Exception:\n" + " pass\n" + " cres['td_info'] = ti\n" + " n.destroy()\n" " except Exception as e:\n" - " entry['input_err'] = str(e)\n" - " open(CUR, 'w').write(s['name'] + ':precook')\n" - " n.cook(force=True)\n" - " open(CUR, 'w').write(s['name'] + ':cooked')\n" - " entry['ok'] = not n.errors()\n" - " entry['errors'] = n.errors()\n" - " entry['warnings'] = n.warnings()\n" - " # Capture output channels WITH names (control outputs surface as\n" - " # named channels, audio as ordered channels). Read n directly and\n" - " # cook the downstream reads WITHOUT force, so the op is cooked\n" - " # exactly ONCE -- extra forced pulls re-cook it, which corrupts\n" - " # stateful objects (counters, oscillators): they'd advance N times.\n" - " try:\n" - " entry['dbg_nc'] = n.numChans\n" - " entry['dbg_ns'] = n.numSamples\n" - " except Exception: pass\n" - " td_out = []\n" - " try:\n" - " ns = n.numSamples\n" - " for ch in n.chans():\n" - " samples = []\n" - " for i in range(ns): samples.append(float(ch[i]))\n" - " td_out.append({'name': ch.name, 'samples': samples})\n" - " entry['td_out'] = td_out\n" - " except Exception:\n" - " entry['td_out'] = None\n" - " # Control outputs that aren't signal channels surface via\n" - " # getInfoCHOPChan -- read them through an Info CHOP, cooked WITHOUT\n" - " # force so it reuses n's single cook (generic info channels are\n" - " # captured too but ignored by name-matching downstream).\n" - " ti = {}\n" - " try:\n" - " info = container.create(infoCHOP, 'info_' + s['name'])\n" - " info.par.op = n\n" - " info.cook()\n" - " for ci in info.chans():\n" - " ti[ci.name] = float(ci[0])\n" - " info.destroy()\n" - " except Exception:\n" - " pass\n" - " entry['td_info'] = ti\n" - " n.destroy()\n" - " except Exception as e:\n" - " entry['exception'] = str(e)\n" + " cres['exception'] = str(e)\n" + " entry['cases'].append(cres)\n" + " gi += 1\n" + " okall = len(entry['cases']) > 0\n" + " for c in entry['cases']:\n" + " if not c.get('ok'): okall = False\n" + " entry['ok'] = okall\n" " report.append(entry)\n" " open(RP, 'w').write(json.dumps(report, indent=2))\n" "open(CUR, 'w').write('DONE')\n" diff --git a/tooling/td/run_td_sweep.py b/tooling/td/run_td_sweep.py index ab27333d..c70a629d 100644 --- a/tooling/td/run_td_sweep.py +++ b/tooling/td/run_td_sweep.py @@ -328,33 +328,63 @@ def _read(p): goldens[g.get("c_name")] = g except Exception: pass - counts, mism = {}, [] - for r in results: - g = goldens.get(r.get("name")) - if not g: - continue - td_out = r.get("td_out") - gaud = g.get("outputs", {}).get("audio") - gctl = g.get("outputs", {}).get("controls") - # Prefer audio comparison when the golden has audio output, else the - # control outputs (which surface as named channels in TD). + # Compare one captured case (td_out + td_info) against a golden case's + # outputs. Prefer audio when the golden has audio output, else controls. + def compare_one(td_out, td_info, gout): + gaud = (gout or {}).get("audio") + gctl = (gout or {}).get("controls") if gaud: - v, detail = compare_audio(td_out, gaud, args.atol, args.rtol) - elif gctl: - # Prefer named output channels; fall back to Info-CHOP values. + return compare_audio(td_out, gaud, args.atol, args.rtol) + if gctl: named = {} for e in (td_out or []): if e.get("samples"): named[e["name"]] = e["samples"][0] - for k, val in (r.get("td_info") or {}).items(): + for k, val in (td_info or {}).items(): named.setdefault(k, val) - v, detail = compare_controls(named, gctl, args.atol, args.rtol) + return compare_controls(named, gctl, args.atol, args.rtol) + return ("no-golden-output", "") + + counts, mism = {}, [] + for r in results: + g = goldens.get(r.get("name")) + if not g: + continue + gcases = g.get("cases") + rcases = r.get("cases") + verdicts = [] # (case_index, verdict, detail) + if gcases is not None and rcases is not None: + for rc in rcases: + ci = rc.get("index", 0) + if ci >= len(gcases): + continue + gout = gcases[ci].get("outputs", {}) + v, d = compare_one(rc.get("td_out"), rc.get("td_info"), gout) + verdicts.append((ci, v, d)) + else: + # old single-case schema (golden or result without `cases`) + v, d = compare_one(r.get("td_out"), r.get("td_info"), + g.get("outputs", {})) + verdicts.append((0, v, d)) + # Per-object verdict: any case mismatching -> MISMATCH (report the + # first failing case); else match if any case matched; else the + # first non-match category (no-name-match / no-golden-output / ...). + mm = [x for x in verdicts if x[1] == "MISMATCH"] + if mm: + v = "MISMATCH" + detail = f"case{mm[0][0]} {mm[0][2]}" + if len(mm) > 1: + detail += f" (+{len(mm)-1} more cases)" + elif any(x[1] == "match" for x in verdicts): + v, detail = "match", "" + elif verdicts: + v, detail = verdicts[0][1], verdicts[0][2] else: v, detail = "no-golden-output", "" counts[v] = counts.get(v, 0) + 1 if v == "MISMATCH": - mism.append(f" MISMATCH {r['name']}: {detail}") - print(f"\n=== output diff vs golden ({len(goldens)} goldens) ===") + mism.append(f" MISMATCH {r['name']} ({len(verdicts)} cases): {detail}") + print(f"\n=== output diff vs golden ({len(goldens)} goldens, per-case) ===") for k in sorted(counts): print(f" {k}: {counts[k]}") for m in mism[:40]: From 6c644ceece1539111598ccff1b70af932d7566ff Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jean-Micha=C3=ABl=20Celerier?= Date: Fri, 3 Jul 2026 12:24:42 -0400 Subject: [PATCH 05/17] python: golden differential harness + audio-path & binding fixes (phase 3) Phase 3 of the golden differential plan (OUTPUT_VERIFICATION_PLAN.md): drive the Python binding in-process against the golden oracle. Harness: - tooling/golden_compare.py: shared audio/control/texture comparison + case aggregation, extracted from the TD sweep so both backends diff identically. Adds an FNV-1a-64 helper matching the golden texture hash, and string-output comparison. - tooling/run_python_golden.py: imports py, replays every golden case (fresh instance per case), sets input controls as attributes, feeds recorded audio via process_audio() or calls process(), reads output controls/audio/ CPU-texture and diffs. Each object runs in its own subprocess so a crashing native module costs only that object and a stage breadcrumb (construct/apply/run/read) attributes the crash. - run_td_sweep.py now imports the shared comparison helpers instead of its own copies. Binding fixes found via the harness: - audio.hpp: the one-block audio driver never wired the host callables (request_channels / buffer.upload / worker.request) nor reserved control_storage, so any object using them dereferenced an empty std::function or unallocated storage. Wire the same no-op/inline set the example host and golden generator use, reserve sample-accurate control storage, and copy the processed state back so output controls are readable afterwards. - processor.hpp: unnamed input and output ports all mapped to the same "unnamed" attribute and silently overwrote each other -- fall back to positional names (p for inputs, out_p for outputs). Wire the worker hand-off at construction via a free wire_worker() helper (an if constexpr inside the py::init lambda did not discard cleanly under MSVC). - avendish.cmake: dispatch the python backend for texture and buffer objects too (the binding already supports CPU-texture and buffer ports). Result: python differential 51 -> 60 match over 112 objects / 191 cases; no-module 19 -> 10 (texture/buffer now built). Remaining: a per-sample-PORT audio crash cluster (heap corruption in the process path), generators/RNG/ chaotic maps needing structural rather than exact compare, multi-bus layout, and input/output ports sharing a symbol colliding in the attribute namespace. Co-Authored-By: Claude Fable 5 --- cmake/avendish.cmake | 2 + include/avnd/binding/python/audio.hpp | 58 +++++ include/avnd/binding/python/processor.hpp | 59 ++++- tooling/golden_compare.py | 123 +++++++++ tooling/run_python_golden.py | 303 ++++++++++++++++++++++ tooling/td/run_td_sweep.py | 78 +----- 6 files changed, 545 insertions(+), 78 deletions(-) create mode 100644 tooling/golden_compare.py create mode 100644 tooling/run_python_golden.py diff --git a/cmake/avendish.cmake b/cmake/avendish.cmake index 476f6a8d..30d6513d 100644 --- a/cmake/avendish.cmake +++ b/cmake/avendish.cmake @@ -349,6 +349,7 @@ function(avnd_make_texture) avnd_make_golden(${ARGV}) _avnd_dispatch_backend(fuzz ${ARGV}) avnd_make_ossia(${ARGV}) + _avnd_dispatch_backend(python ${ARGV}) _avnd_dispatch_backend(max ${ARGV}) _avnd_dispatch_backend(gstreamer ${ARGV} PROCESSOR_TYPE TEXTURE) _avnd_dispatch_backend(touchdesigner ${ARGV} PROCESSOR_TYPE TOP) @@ -360,6 +361,7 @@ function(avnd_make_buffer) avnd_make_golden(${ARGV}) _avnd_dispatch_backend(fuzz ${ARGV}) + _avnd_dispatch_backend(python ${ARGV}) _avnd_dispatch_backend(godot ${ARGV} PROCESSOR_TYPE BUFFER) endfunction() diff --git a/include/avnd/binding/python/audio.hpp b/include/avnd/binding/python/audio.hpp index 697b49fb..2a6fdba6 100644 --- a/include/avnd/binding/python/audio.hpp +++ b/include/avnd/binding/python/audio.hpp @@ -2,7 +2,12 @@ /* SPDX-License-Identifier: GPL-3.0-or-later */ +#include #include +#include +#include +#include +#include #include #include #include @@ -53,6 +58,42 @@ py::array_t run_audio( st.inputs = avnd::get_inputs(self); } + // Host-provided callables must never be empty std::functions when + // prepare()/process() runs them -- install no-op (or inline, for the + // worker) handlers, the same set the example host and the golden + // generator wire. + if constexpr(avnd::audio_bus_input_introspection::size > 0) + avnd::audio_bus_input_introspection::for_all( + avnd::get_inputs(c), [](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(c), [](auto& p) { + if constexpr(requires { p.request_channels; }) + if(!p.request_channels) + p.request_channels = [](int) {}; + }); + auto wire_buffer = [](auto& p) { + if constexpr(requires { p.buffer.upload; }) + if(!p.buffer.upload) + p.buffer.upload = [](const char*, std::int64_t, std::int64_t) {}; + }; + if constexpr(avnd::buffer_output_introspection::size > 0) + avnd::buffer_output_introspection::for_all(avnd::get_outputs(c), wire_buffer); + if constexpr(avnd::buffer_input_introspection::size > 0) + avnd::buffer_input_introspection::for_all(avnd::get_inputs(c), wire_buffer); + if constexpr(avnd::has_worker) + for(auto& impl : c.effects()) + { + using worker_t = std::decay_t; + impl.worker.request = [](auto&&... args) { + worker_t::work(std::forward(args)...); + }; + } + avnd::process_setup su{ .input_channels = n_in, .output_channels = n_out, @@ -63,6 +104,12 @@ py::array_t run_audio( proc.allocate_buffers(su, double{}); avnd::prepare(c, su); + // Sample-accurate control ports need their per-buffer storage reserved + // before process() (else the object writes into unallocated storage). + avnd::control_storage control_buffers; + if constexpr(sizeof(control_buffers) > 1) + control_buffers.reserve_space(c, frames); + auto* in_base = static_cast(info.ptr); std::vector in_ptrs(n_in); for(int ch = 0; ch < n_in; ch++) @@ -84,6 +131,17 @@ py::array_t run_audio( c, avnd::span{in_ptrs.data(), static_cast(n_in)}, avnd::span{out_ptrs.data(), static_cast(n_out)}, t); + // Copy the processed state back so output controls (e.g. analysis results) + // are readable on the Python instance afterwards. + for(auto&& st : c.full_state()) + { + if constexpr(std::is_copy_assignable_v) + self = st.effect; + else if constexpr(requires { avnd::get_outputs(self) = st.outputs; }) + avnd::get_outputs(self) = st.outputs; + break; + } + return out; } } diff --git a/include/avnd/binding/python/processor.hpp b/include/avnd/binding/python/processor.hpp index 62bcab10..fc5f6e65 100644 --- a/include/avnd/binding/python/processor.hpp +++ b/include/avnd/binding/python/processor.hpp @@ -32,6 +32,22 @@ namespace python { +// Install an inline worker hand-off so an object that offloads to the host's +// thread pool has a valid callable (never an empty std::function). A free +// function template so MSVC reliably discards the branch for worker-less types +// (an `if constexpr` inside the py::init lambda did not discard cleanly). +template +void wire_worker(U& obj) +{ + if constexpr(avnd::has_worker) + { + using worker_t = std::decay_t; + obj.worker.request = [](auto&&... args) { + worker_t::work(std::forward(args)...); + }; + } +} + inline const char* c_str(const std::string& v) noexcept { return v.c_str(); @@ -53,16 +69,42 @@ inline const char* c_str(const std::array& v) noexcept namespace py = pybind11; +// Unnamed ports all map to the same "unnamed" identifier, so their properties +// would silently overwrite each other -- fall back to a positional name (the +// same "p" scheme the golden generator uses; outputs get an "out_" +// prefix so an unnamed input/output pair does not collide either). +inline bool unusable_name(std::string_view nm) +{ + return nm.empty() || nm.find("unnamed") != std::string_view::npos + || nm.find('<') != std::string_view::npos; +} + +template +std::string symbol_string() +{ + auto sym = avnd::get_static_symbol(); + if constexpr(requires { std::string_view{sym}; }) + return std::string{std::string_view{sym}}; + else + return std::string{sym.data()}; +} + template -constexpr auto input_name(avnd::field_reflection) +std::string input_name(avnd::field_reflection) { - return avnd::get_static_symbol(); + std::string nm = symbol_string(); + if(unusable_name(nm)) + return "p" + std::to_string((int)Idx); + return nm; } template -constexpr auto output_name(avnd::field_reflection) +std::string output_name(avnd::field_reflection) { - return avnd::get_static_symbol(); + std::string nm = symbol_string(); + if(unusable_name(nm)) + return "out_p" + std::to_string((int)Idx); + return nm; } template @@ -735,7 +777,14 @@ struct processor { m.doc() = c_str(avnd::get_description()); - class_def.def(py::init<>()); + // Host-provided callables (the worker thread-pool hand-off) must never be + // an empty std::function when the object runs -- wire them at construction, + // executing the work inline. + class_def.def(py::init([] { + auto t = std::make_unique(); + wire_worker(*t); + return t; + })); if constexpr(requires { T{}(); }) { class_def.def("process", &T::operator()); diff --git a/tooling/golden_compare.py b/tooling/golden_compare.py new file mode 100644 index 00000000..91ac5f55 --- /dev/null +++ b/tooling/golden_compare.py @@ -0,0 +1,123 @@ +"""Shared golden-differential comparison helpers. + +Every backend harness (TouchDesigner sweep, Python driver, ...) reads a golden +JSON produced by the `golden` backend, replays its recorded inputs, captures the +backend's outputs and diffs them with these functions, so tolerance and +name-matching semantics stay identical across backends. +""" + + +def compare_audio(out, golden, atol, rtol): + """Diff captured output channels (positional) against the golden audio + channels over their overlapping prefix (a backend's cook sample-count can + differ from the golden's frame count). `out` is [{name, samples}].""" + if not golden: + return ("no-golden-audio", "") + if not out: + return ("no-backend-audio", "") + ch = [e["samples"] for e in out] + nch = min(len(ch), len(golden)) + if nch == 0: + return ("empty", "") + maxdiff = 0.0 + for c in range(nch): + ns = min(len(ch[c]), len(golden[c])) + for i in range(ns): + maxdiff = max(maxdiff, abs(ch[c][i] - golden[c][i])) + peak = max((abs(x) for gch in golden for x in gch), default=0.0) + tol = atol + rtol * peak + verdict = "match" if maxdiff <= tol else "MISMATCH" + detail = (f"maxdiff={maxdiff:.2e} tol={tol:.2e} " + f"got={len(ch)}ch gold={len(golden)}ch") + return (verdict, detail) + + +def compare_controls(named, golden, atol, rtol): + """Diff golden output controls against backend values matched by name. + `named` is {name -> value}; exact, lowercase and capitalized forms of the + golden name are tried.""" + if not golden: + return ("no-golden-controls", "") + if not named: + return ("no-backend-controls", "") + checked, maxdiff, worst = 0, 0.0, "" + for gc in golden: + if not isinstance(gc, dict): + continue + nm, gv = gc.get("name", ""), gc.get("value") + if gv == "unrecorded" or not isinstance(gv, (int, float, str)): + continue + tv = None + for k in (nm, nm.lower(), nm.capitalize()): + if k in named: + tv = named[k] + break + if tv is None: + continue + if isinstance(gv, str): + # String outputs: exact equality. + checked += 1 + if str(tv) != gv and maxdiff == 0.0: + maxdiff, worst = 1.0, f"{nm}({tv!r}!={gv!r})" + continue + if isinstance(tv, bool): + tv = float(tv) + if not isinstance(tv, (int, float)): + continue + checked += 1 + if abs(tv - gv) > maxdiff: + 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) + tol = atol + rtol * peak + return ("match" if maxdiff <= tol else "MISMATCH", + f"{checked} ctrls maxdiff={maxdiff:.2e}@{worst} tol={tol:.2e}") + + +def compare_textures(textures, golden, atol, rtol): + """Diff captured texture outputs against the golden's recorded + {width, height, hash} (FNV-1a over the raw pixel bytes -- see fnv1a64). + `textures` is [{width, height, hash}] in port order.""" + if not golden: + return ("no-golden-texture", "") + if not textures: + return ("no-backend-texture", "") + n = min(len(textures), len(golden)) + for i in range(n): + t, g = textures[i], golden[i] + if (t.get("width"), t.get("height")) != (g.get("width"), g.get("height")): + return ("MISMATCH", + f"tex{i} size {t.get('width')}x{t.get('height')} " + f"vs gold {g.get('width')}x{g.get('height')}") + if t.get("hash") != g.get("hash"): + return ("MISMATCH", f"tex{i} content hash differs") + return ("match", f"{n} textures") + + +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).""" + h = 1469598103934665603 + for b in data: + h ^= b + h = (h * 1099511628211) & 0xFFFFFFFFFFFFFFFF + return h - 0x10000000000000000 if h >= 0x8000000000000000 else h + + +def aggregate_case_verdicts(verdicts): + """Fold per-case (index, verdict, detail) tuples into one per-object + verdict: any MISMATCH wins (reporting the first failing case), else match + if any case matched, else the first non-match category.""" + mm = [x for x in verdicts if x[1] == "MISMATCH"] + if mm: + detail = f"case{mm[0][0]} {mm[0][2]}" + if len(mm) > 1: + detail += f" (+{len(mm)-1} more cases)" + return ("MISMATCH", detail) + if any(x[1] == "match" for x in verdicts): + return ("match", "") + if verdicts: + return (verdicts[0][1], verdicts[0][2]) + return ("no-golden-output", "") diff --git a/tooling/run_python_golden.py b/tooling/run_python_golden.py new file mode 100644 index 00000000..9cd6474e --- /dev/null +++ b/tooling/run_python_golden.py @@ -0,0 +1,303 @@ +#!/usr/bin/env python3 +"""Golden differential testing for the Python binding (in-process). + +For every golden JSON (produced by the `golden` backend at build time), import +the object's Python module (py.pyd), replay each recorded test case -- +set the input controls as attributes, feed the recorded audio through +process_audio() or call process() -- capture the outputs (control attributes, +audio channels, CPU textures) and diff them against the golden's recorded +outputs. A fresh instance is created per case, matching the golden generator's +fresh-effect-per-case semantics. + +A crashing native module takes its whole process down, so the default (parent) +mode spawns one --child subprocess per object: a crash costs that object only, +and the child's stage breadcrumb (construct/apply/run/read) says where it died. + +Example: + + python run_python_golden.py ^ + --modules D:/build/build-avendish-msvc/python/Debug ^ + --goldens D:/build/build-avendish-msvc/golden/Debug +""" + +import argparse +import glob +import importlib +import json +import os +import re +import subprocess +import sys + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +from golden_compare import (aggregate_case_verdicts, compare_audio, + compare_controls, compare_textures, fnv1a64) + + +def norm(name): + """Names differ per backend (golden records the port's display name, the + python binding uses its C identifier): compare lowercase alphanumerics.""" + return re.sub(r"[^a-z0-9]", "", str(name).lower()) + + +def find_class(mod, c_name): + cls = getattr(mod, c_name, None) + if isinstance(cls, type): + return cls + for v in vars(mod).values(): + if isinstance(v, type) and getattr(v, "__module__", "") == mod.__name__: + return v + return None + + +def attr_map(obj): + return {norm(a): a for a in dir(obj) if not a.startswith("_")} + + +def set_control(obj, attrs, name, value): + """Set one golden input control on the instance. Returns (ok, error).""" + a = attrs.get(norm(name)) + if a is None: + return (False, "no-attr") + try: + setattr(obj, a, value) + return (True, None) + except Exception: + pass + # Enum properties reject raw ints; construct the bound enum type. + try: + cur = getattr(obj, a) + if hasattr(type(cur), "__members__"): + setattr(obj, a, type(cur)(int(value))) + return (True, None) + except Exception as e: + return (False, str(e)) + return (False, "type-mismatch") + + +def capture_textures(obj, attrs, count): + """Read CPU-texture outputs: ndarray-valued attributes, hashed exactly like + the golden generator (FNV-1a over the raw row-major pixel bytes).""" + import numpy as np + if count <= 0: + return [] + textures = [] + for a in sorted(attrs.values()): + try: + v = getattr(obj, a) + except Exception: + continue + if isinstance(v, np.ndarray) and v.ndim == 3: + textures.append({ + "width": int(v.shape[1]), + "height": int(v.shape[0]), + "hash": fnv1a64(np.ascontiguousarray(v).tobytes()), + }) + return textures + + +def run_case(cls, gcase, meta, stage): + """Replay one golden case on a fresh instance; returns the case record.""" + import numpy as np + rec = {"ok": False, "applied": {}, "skipped": []} + stage("construct") + obj = cls() + attrs = attr_map(obj) + + stage("apply") + for c in gcase.get("inputs", {}).get("controls", []): + nm, val = c.get("name", ""), c.get("value") + if val == "unrecorded": + continue + ok, err = set_control(obj, attrs, nm, val) + if ok: + rec["applied"][nm] = val + else: + rec["skipped"].append(f"{nm}:{err}") + + gin = gcase.get("inputs", {}).get("audio") or [] + frames = int(meta.get("frames", 64)) + rate = float(meta.get("rate", 44100.0)) + stage("run") + if hasattr(obj, "process_audio"): + arr = (np.asarray(gin, dtype=np.float32) if gin + else np.zeros((0, frames), dtype=np.float32)) + out = obj.process_audio(arr, rate) + rec["audio"] = [{"name": f"c{i}", "samples": [float(x) for x in ch]} + for i, ch in enumerate(np.asarray(out))] + elif hasattr(obj, "process"): + obj.process() + else: + rec["runner"] = "none" + return rec + + stage("read") + named = {} + for gc in gcase.get("outputs", {}).get("controls", []): + nm = gc.get("name", "") + # Unnamed output ports: golden says p, the python binding disambi- + # guates them from unnamed inputs as out_p. Prefer the out_ form so + # an unnamed input p attribute doesn't shadow the output we want. + a = attrs.get(norm("out_" + nm)) or attrs.get(norm(nm)) + if a is None: + continue + try: + v = getattr(obj, a) + except Exception: + continue + if isinstance(v, (bool, int, float)): + named[gc["name"]] = float(v) + elif isinstance(v, str): + named[gc["name"]] = v + rec["controls"] = named + rec["textures"] = capture_textures( + obj, attrs, len(gcase.get("outputs", {}).get("texture", []))) + rec["ok"] = True + return rec + + +def compare_case(rec, gout, atol, rtol): + """Pick the comparison matching what the golden recorded for this case.""" + if rec.get("runner") == "none": + return ("no-runner", "") + if gout.get("audio"): + return compare_audio(rec.get("audio"), gout["audio"], atol, rtol) + if gout.get("controls"): + return compare_controls(rec.get("controls"), gout["controls"], atol, rtol) + if gout.get("texture"): + return compare_textures(rec.get("textures"), gout["texture"], atol, rtol) + return ("no-golden-output", "") + + +def run_object(g, atol, rtol, stagefile): + """Import one object's module and replay all its golden cases.""" + c_name = g["c_name"] + + def stage(s): + open(stagefile, "w").write(f"{c_name}:{s}") + + entry = {"name": c_name, "cases": []} + stage("import") + try: + mod = importlib.import_module("py" + c_name) + except ImportError: + entry["verdict"] = "no-module" + return entry + cls = find_class(mod, c_name) + if cls is None: + entry["verdict"] = "no-class" + return entry + + gcases = g.get("cases") + if gcases is None: # old single-case schema + gcases = [{"inputs": g.get("inputs", {}), + "outputs": g.get("outputs", {})}] + verdicts = [] + for ci, gcase in enumerate(gcases): + try: + rec = run_case(cls, gcase, g.get("meta", {}), + lambda s: stage(f"case{ci}:{s}")) + except Exception as e: + rec = {"ok": False, "exception": str(e)} + rec["index"] = ci + if "exception" in rec: + verdicts.append((ci, "exception", rec["exception"])) + else: + v, d = compare_case(rec, gcase.get("outputs", {}), atol, rtol) + verdicts.append((ci, v, d)) + rec["verdict"] = v + entry["cases"].append(rec) + v, detail = aggregate_case_verdicts(verdicts) + entry["verdict"] = v + entry["detail"] = detail + return entry + + +def load_goldens(goldens_dir): + goldens = [] + for f in sorted(glob.glob(os.path.join(goldens_dir, "*.json"))): + try: + g = json.load(open(f)) + if g.get("c_name"): + goldens.append(g) + except Exception: + pass + return goldens + + +def main(): + ap = argparse.ArgumentParser() + ap.add_argument("--modules", required=True, help="dir of built py*.pyd") + ap.add_argument("--goldens", required=True, help="golden JSON dir") + 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="py_golden_report.json") + ap.add_argument("--only", help="run a single object (c_name)") + ap.add_argument("--child", action="store_true", + help="internal: run --only's object in this process") + ap.add_argument("--timeout", type=int, default=120, + help="per-object subprocess timeout (seconds)") + args = ap.parse_args() + + sys.path.insert(0, args.modules) + goldens = load_goldens(args.goldens) + if args.only: + goldens = [g for g in goldens if g["c_name"] == args.only] + + if args.child: + if not goldens: + return 2 + entry = run_object(goldens[0], args.atol, args.rtol, + args.report + ".stage") + json.dump(entry, open(args.report, "w"), indent=1) + return 0 + + counts, mism, report = {}, [], [] + for g in goldens: + c_name = g["c_name"] + objreport = args.report + ".obj" + stagefile = objreport + ".stage" + for f in (objreport, stagefile): + if os.path.exists(f): + os.remove(f) + cmd = [sys.executable, os.path.abspath(__file__), + "--modules", args.modules, "--goldens", args.goldens, + "--atol", str(args.atol), "--rtol", str(args.rtol), + "--report", objreport, "--only", c_name, "--child"] + try: + p = subprocess.run(cmd, capture_output=True, text=True, + timeout=args.timeout) + crashed = p.returncode != 0 or not os.path.exists(objreport) + how = f"exit {p.returncode}" + except subprocess.TimeoutExpired: + crashed = True + how = f"timeout {args.timeout}s" + if crashed: + st = "" + if os.path.exists(stagefile): + st = open(stagefile).read() + entry = {"name": c_name, "verdict": "CRASH", + "detail": f"{how} at {st or 'startup'}"} + else: + entry = json.load(open(objreport)) + report.append(entry) + v = entry.get("verdict", "?") + counts[v] = counts.get(v, 0) + 1 + if v in ("MISMATCH", "CRASH", "exception"): + mism.append(f" {v} {c_name} ({len(entry.get('cases', []))} cases):" + f" {entry.get('detail', '')[:140]}") + json.dump(report, open(args.report, "w"), indent=1) + + ncases = sum(len(e.get("cases", [])) for e in report) + print(f"\n=== python diff vs golden ({len(report)} objects, " + f"{ncases} cases) ===") + for k in sorted(counts): + print(f" {k}: {counts[k]}") + for m in mism: + print(m) + print(f"report -> {args.report}") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tooling/td/run_td_sweep.py b/tooling/td/run_td_sweep.py index c70a629d..165e534f 100644 --- a/tooling/td/run_td_sweep.py +++ b/tooling/td/run_td_sweep.py @@ -32,6 +32,10 @@ import sys import time +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) +from golden_compare import (aggregate_case_verdicts, compare_audio, + compare_controls) + user32 = ctypes.windll.user32 if sys.platform == "win32" else None # --- Win32: find and OK the "Plugin Load Error" dialog ----------------------- @@ -98,64 +102,6 @@ def gen_runner(dumps, out_dir): sys.exit(f"runner not generated under {out_dir}") -def compare_audio(td_out, golden, atol, rtol): - """Diff TD output channels (positional) against the golden audio channels - over their overlapping prefix (TD's cook sample-count can differ from the - golden's frame count). td_out is [{name, samples}].""" - if not golden: - return ("no-golden-audio", "") - if not td_out: - return ("no-td-audio", "") - td = [e["samples"] for e in td_out] - nch = min(len(td), len(golden)) - if nch == 0: - return ("empty", "") - maxdiff = 0.0 - for c in range(nch): - ns = min(len(td[c]), len(golden[c])) - for i in range(ns): - maxdiff = max(maxdiff, abs(td[c][i] - golden[c][i])) - peak = max((abs(x) for ch in golden for x in ch), default=0.0) - tol = atol + rtol * peak - verdict = "match" if maxdiff <= tol else "MISMATCH" - detail = (f"maxdiff={maxdiff:.2e} tol={tol:.2e} " - f"td={len(td)}ch gold={len(golden)}ch") - return (verdict, detail) - - -def compare_controls(named, golden, atol, rtol): - """Diff golden output controls against TD values matched by name. `named` is - {channel/info-name -> value}, built from output channels + Info-CHOP.""" - if not golden: - return ("no-golden-controls", "") - if not named: - return ("no-td-controls", "") - checked, maxdiff, worst = 0, 0.0, "" - for gc in golden: - if not isinstance(gc, dict): - continue - nm, gv = gc.get("name", ""), gc.get("value") - if not isinstance(gv, (int, float)): - continue - tv = None - for k in (nm, nm.lower(), nm.capitalize()): - if k in named: - tv = named[k] - break - if tv is None: - continue - checked += 1 - if abs(tv - gv) > maxdiff: - maxdiff, worst = abs(tv - gv), nm - if checked == 0: - return ("no-name-match", f"td={list(named)[:4]}") - peak = max((abs(gc["value"]) for gc in golden - if isinstance(gc.get("value"), (int, float))), default=0.0) - tol = atol + rtol * peak - return ("match" if maxdiff <= tol else "MISMATCH", - f"{checked} ctrls maxdiff={maxdiff:.2e}@{worst} tol={tol:.2e}") - - def main(): ap = argparse.ArgumentParser() ap.add_argument("--td", required=True, help="TouchDesigner.exe") @@ -366,21 +312,7 @@ def compare_one(td_out, td_info, gout): v, d = compare_one(r.get("td_out"), r.get("td_info"), g.get("outputs", {})) verdicts.append((0, v, d)) - # Per-object verdict: any case mismatching -> MISMATCH (report the - # first failing case); else match if any case matched; else the - # first non-match category (no-name-match / no-golden-output / ...). - mm = [x for x in verdicts if x[1] == "MISMATCH"] - if mm: - v = "MISMATCH" - detail = f"case{mm[0][0]} {mm[0][2]}" - if len(mm) > 1: - detail += f" (+{len(mm)-1} more cases)" - elif any(x[1] == "match" for x in verdicts): - v, detail = "match", "" - elif verdicts: - v, detail = verdicts[0][1], verdicts[0][2] - else: - v, detail = "no-golden-output", "" + v, detail = aggregate_case_verdicts(verdicts) counts[v] = counts.get(v, 0) + 1 if v == "MISMATCH": mism.append(f" MISMATCH {r['name']} ({len(verdicts)} cases): {detail}") From eb3be97550661925a1642cfbde280ff7520d2fb9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jean-Micha=C3=ABl=20Celerier?= Date: Fri, 3 Jul 2026 12:44:14 -0400 Subject: [PATCH 06/17] packaging: single-object Max/Pd packages now carry the generated help patch The addon packagers (avendish.packaging.cmake) already bundle the auto-generated help/example artifacts per backend: Max .maxhelp into /help/, TouchDesigner .example.py into /examples/, Godot .tscn into /examples/. The single-object packagers did not. Wire the same best-effort copy into the single-object package assemblers so an installed single-object package carries its interactive help alongside the binary: - Max (avnd_create_max_package): copy AVND_MAX_HELP -> /help/.maxhelp (next to the external and the existing .maxref.xml; Max searches help/). - Pd (avnd_create_pd_package): copy AVND_PD_HELP -> /-help.pd (sibling of the external; Pd opens it on right-click). Both reuse avendish.packaging.copy_optional.cmake so a help patch that some toolchain didn't emit never fails packaging, and both add_dependencies() on the per-target _help generation target (not DEPENDS alone) so the patch is generated before the copy under the VS/MSBuild parallel generator. The single-object and addon packagers stay distinct layers, each owning the copy for its own package layout and root, so there is no double-copy of the same file. Co-Authored-By: Claude Fable 5 --- cmake/avendish.max.cmake | 16 ++++++++++++++++ cmake/avendish.pd.cmake | 17 +++++++++++++++++ 2 files changed, 33 insertions(+) diff --git a/cmake/avendish.max.cmake b/cmake/avendish.max.cmake index df42c170..0705ea5d 100644 --- a/cmake/avendish.max.cmake +++ b/cmake/avendish.max.cmake @@ -345,6 +345,22 @@ function(avnd_create_max_package) ) endif() + # Copy the interactive help patch into the package's help/ folder: Max + # searches /help/ for .maxhelp. Best-effort -- the help patch + # may be absent on some toolchains and must not fail packaging. + get_target_property(_maxhelp ${_external} AVND_MAX_HELP) + if(_maxhelp) + if(TARGET "${_external}_help") + add_dependencies("${_external}" "${_external}_help") + endif() + get_property(_pkgdir GLOBAL PROPERTY AVND_PACKAGING_DIR) + add_custom_command(TARGET ${_external} POST_BUILD + COMMAND ${CMAKE_COMMAND} + "-DSRC=${CMAKE_BINARY_DIR}/${_maxhelp}" "-DDST=${_pkg}/help" + -P "${_pkgdir}/avendish.packaging.copy_optional.cmake" + VERBATIM) + endif() + # Copy the external (fairly platform-specific) if(WIN32) diff --git a/cmake/avendish.pd.cmake b/cmake/avendish.pd.cmake index 3bea21c6..fab62be7 100644 --- a/cmake/avendish.pd.cmake +++ b/cmake/avendish.pd.cmake @@ -190,6 +190,23 @@ function(avnd_create_pd_package) add_custom_command(TARGET ${_external} POST_BUILD COMMAND ${CMAKE_COMMAND} -E copy "${_external_bin}" "${_pkg}/" ) + + # Ship the interactive help patch as a sibling of the external: Pd opens + # -help.pd when you right-click the object, and searches the same + # folder the external lives in. Best-effort -- the help patch may be absent + # on some toolchains and must not fail packaging. + get_target_property(_pd_help ${_external} AVND_PD_HELP) + if(_pd_help) + if(TARGET "${_external}_help") + add_dependencies("${_external}" "${_external}_help") + endif() + get_property(_pkgdir GLOBAL PROPERTY AVND_PACKAGING_DIR) + add_custom_command(TARGET ${_external} POST_BUILD + COMMAND ${CMAKE_COMMAND} + "-DSRC=${CMAKE_BINARY_DIR}/${_pd_help}" "-DDST=${_pkg}" + -P "${_pkgdir}/avendish.packaging.copy_optional.cmake" + VERBATIM) + endif() endforeach() # Copy the support files From b830e37b22cad7bbc34db4a17ce84f4c7d3da99a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jean-Micha=C3=ABl=20Celerier?= Date: Fri, 3 Jul 2026 13:17:50 -0400 Subject: [PATCH 07/17] python: fix per-sample audio heap corruption + uninitialised controls in run_audio Monophonic processors (mono per-sample/per-channel, arg OR sample-PORT form) are driven by the *input* channel count: the mono per-sample-port adapter processes in.size() channels and writes that many output channels (it even asserts input_channels == output_channels). run_audio sized the output buffer by output_channels() instead -- for a sample-PORT object that is the static count of output sample ports (e.g. 1), so with 2 input channels the adapter wrote out_ptrs[1] past the 1-element array => heap corruption (surfacing later as the pybind GIL dec_ref assert / hang). Size the output by the input channel count for monophonic processors. Also call avnd::init_controls on the processing container before copying the instance's values over it: objects whose ports are declared as nested inputs/outputs TYPES keep their controls in the container storage (not on the bare T), so the copy cannot reach them and they were read uninitialised (e.g. a garbage gain -> constant saturated output). Mirrors the golden generator. Co-Authored-By: Claude Fable 5 --- include/avnd/binding/python/audio.hpp | 25 ++++++++++++++++++++++++- 1 file changed, 24 insertions(+), 1 deletion(-) diff --git a/include/avnd/binding/python/audio.hpp b/include/avnd/binding/python/audio.hpp index 2a6fdba6..29aca82a 100644 --- a/include/avnd/binding/python/audio.hpp +++ b/include/avnd/binding/python/audio.hpp @@ -2,11 +2,13 @@ /* SPDX-License-Identifier: GPL-3.0-or-later */ +#include #include #include #include #include #include +#include #include #include #include @@ -46,10 +48,31 @@ py::array_t run_audio( "process_audio expects a 1-D or 2-D float array (channels, frames)"); } - const int n_out = std::max(1, avnd::output_channels(n_in)); + // Output channel count. For monophonic processors (mono per-sample / + // per-channel, arg OR sample-PORT form) the process adapter is driven by the + // *input* channel count -- it processes `in.size()` channels and writes that + // many output channels (the mono per-sample-port adapter even asserts + // input_channels == output_channels). So the output buffer MUST have as many + // channels as the input; sizing it by output_channels() (which for a + // sample-PORT object is the static count of output sample ports, e.g. 1) + // leaves out_ptrs too small and the adapter writes past it -> heap + // corruption. Non-monophonic objects keep the introspected/declared count. + const int n_out = avnd::monophonic_audio_processor + ? std::max(1, n_in) + : std::max(1, avnd::output_channels(n_in)); avnd::effect_container c; c.init_channels(n_in, n_out); + + // Initialise the container's control storage to the declared defaults BEFORE + // copying the instance's values over it. This matters for objects whose ports + // are declared as nested `inputs`/`outputs` TYPES (args-style operator()): + // their controls live in the container's own storage, not on the bare `T` + // instance, so the copy below cannot reach them -- without this they would be + // read uninitialised (e.g. a garbage gain -> constant saturated output). The + // golden generator does the same (avnd::init_controls before running). + avnd::init_controls(c); + for(auto&& st : c.full_state()) { if constexpr(std::is_copy_assignable_v) From 1342b31f2b1ece8f2a584b840a722a21ff1fb1d3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jean-Micha=C3=ABl=20Celerier?= Date: Fri, 3 Jul 2026 13:18:02 -0400 Subject: [PATCH 08/17] python: wire host callbacks in process() path + expose obj.inputs/outputs process()-path robustness (fixes two crashes): - wire_host_callbacks() now runs at construction and, for value-declared ports, installs no-op handlers for buffer.upload and audio-bus request_channels (an object driven through plain operator() otherwise invokes an empty std::function -> terminate; e.g. avnd_noisebuffer). - CPU texture inputs have no default member initializers; zero them so an object guarding on texture.bytes==nullptr doesn't read indeterminate width/height and blow up (e.g. oscr_TextureFilterExample). Guarded on inputs_is_value/outputs_is_value so nested-type-port objects (only ever run via run_audio) still compile. Nested unambiguous accessors obj.inputs. / obj.outputs.: an input and an output that share a symbol (Controls has input "A" and output "A") collided in the flat attribute namespace, so the input became unsettable. The proxies expose one property per value port; flat attributes stay as a shortcut. run_python_golden.py now prefers obj.inputs/obj.outputs when present (falls back to flat attributes for older modules), moving avnd_helpers_controls from MISMATCH to match. Co-Authored-By: Claude Fable 5 --- include/avnd/binding/python/processor.hpp | 153 +++++++++++++++++++++- tooling/run_python_golden.py | 40 +++++- 2 files changed, 186 insertions(+), 7 deletions(-) diff --git a/include/avnd/binding/python/processor.hpp b/include/avnd/binding/python/processor.hpp index fc5f6e65..a2660121 100644 --- a/include/avnd/binding/python/processor.hpp +++ b/include/avnd/binding/python/processor.hpp @@ -48,6 +48,79 @@ void wire_worker(U& obj) } } +// Wire every host-provided callable on `obj` to a no-op / inline handler and +// give indeterminate CPU-texture inputs a defined empty state. The `process` +// binding invokes `operator()` directly on the instance (no prepare/host +// setup), so an object that pushes through an unset std::function (buffer +// upload, audio bus request_channels, worker) or reads an uninitialised input +// texture would crash. run_audio wires the same set on its processing +// container; this covers the plain process() path (and is harmless for audio +// objects). Same handlers the example host and golden generator install. +template +void wire_host_callbacks(U& obj) +{ + wire_worker(obj); + + // The remaining callbacks live on ports reached through get_inputs/get_outputs + // -- only valid on a bare instance when the ports are declared as VALUE + // members (inputs_is_value / outputs_is_value). Objects that declare their + // ports as nested TYPES keep their storage in the effect container, are only + // ever driven through run_audio (which wires that container itself), and + // never bind plain process() -- so there is nothing to wire on the instance. + auto wire_req = [](auto& p) { + if constexpr(requires { p.request_channels; }) + if(!p.request_channels) + p.request_channels = [](int) {}; + }; + auto wire_buf = [](auto& p) { + if constexpr(requires { p.buffer.upload; }) + if(!p.buffer.upload) + p.buffer.upload = [](const char*, std::int64_t, std::int64_t) {}; + }; + // CPU texture inputs have no default member initializers (bytes/width/height + // are indeterminate). An object guarding on `texture.bytes == nullptr` reads + // garbage width/height when the pointer happens to be non-null -> crash. + auto zero_tex = [](auto& p) { + p.texture.bytes = nullptr; + p.texture.width = 0; + p.texture.height = 0; + if constexpr(requires { p.texture.changed; }) + p.texture.changed = false; + }; + + if constexpr(avnd::inputs_is_value) + { + if constexpr(avnd::audio_bus_input_introspection::size > 0) + avnd::audio_bus_input_introspection::for_all(avnd::get_inputs(obj), wire_req); + if constexpr(avnd::buffer_input_introspection::size > 0) + avnd::buffer_input_introspection::for_all(avnd::get_inputs(obj), wire_buf); + if constexpr(avnd::cpu_texture_input_introspection::size > 0) + avnd::cpu_texture_input_introspection::for_all(avnd::get_inputs(obj), zero_tex); + } + if constexpr(avnd::outputs_is_value) + { + if constexpr(avnd::audio_bus_output_introspection::size > 0) + avnd::audio_bus_output_introspection::for_all(avnd::get_outputs(obj), wire_req); + if constexpr(avnd::buffer_output_introspection::size > 0) + avnd::buffer_output_introspection::for_all(avnd::get_outputs(obj), wire_buf); + } +} + +// Small proxy objects returned by `obj.inputs` / `obj.outputs`: they expose one +// property per port so an input and an output that share a symbol (e.g. +// Controls has input "A" and output "A") can be addressed unambiguously +// (obj.inputs.A vs obj.outputs.A) instead of colliding in the flat namespace. +template +struct inputs_accessor +{ + T* self{}; +}; +template +struct outputs_accessor +{ + T* self{}; +}; + inline const char* c_str(const std::string& v) noexcept { return v.c_str(); @@ -721,6 +794,55 @@ struct processor }); } + // Per-field property on the obj.inputs proxy (value ports only; tensor and + // other buffer-ish ports keep the flat attribute, which does not collide). + template + void setup_input_value_accessor( + py::class_>& cls, avnd::field_reflection refl, + pybind11::module_& m) + { + using value_type = std::remove_cvref_t; + if constexpr(!avnd::tensor_like) + { + ensure_value_type_registered(m); + cls.def_property( + c_str(input_name(refl)), + [](inputs_accessor& a) { + return avnd::pfr::get(a.self->inputs).value; + }, + [](inputs_accessor& a, decltype(C::value) x) { + avnd::pfr::get(a.self->inputs).value = x; + auto& inputs = avnd::get_inputs(*a.self); + auto& field = boost::pfr::get(inputs); + if_possible(field.update(*a.self)); + }); + } + } + + // Per-field property on the obj.outputs proxy (value ports only). + template + void setup_output_value_accessor( + py::class_>& cls, avnd::field_reflection refl, + pybind11::module_& m) + { + if constexpr(requires { avnd::get_name(); }) + { + using value_type = std::remove_cvref_t; + if constexpr(!avnd::tensor_like) + { + ensure_value_type_registered(m); + cls.def_property( + c_str(output_name(refl)), + [](outputs_accessor& a) { + return avnd::pfr::get(a.self->outputs).value; + }, + [](outputs_accessor& a, decltype(C::value) x) { + avnd::pfr::get(a.self->outputs).value = x; + }); + } + } + } + template void setup_output(avnd::field_reflection refl, pybind11::module_& m) { @@ -782,7 +904,7 @@ struct processor // executing the work inline. class_def.def(py::init([] { auto t = std::make_unique(); - wire_worker(*t); + wire_host_callbacks(*t); return t; })); if constexpr(requires { T{}(); }) @@ -814,6 +936,35 @@ struct processor [this, &m](auto a) { setup_callback(a, m); }); } + // Nested unambiguous accessors: obj.inputs. / obj.outputs.. + // The flat attributes above stay as a convenience shortcut, but when an + // input and an output share a symbol the second registered property + // shadows the first in the flat namespace; the proxies are the reliable + // path (setting obj.inputs. writes the same field run_audio and + // process() read). + { + static py::class_> in_cls( + m, (std::string{c_str(avnd::get_c_identifier())} + "_inputs").c_str()); + static py::class_> out_cls( + m, (std::string{c_str(avnd::get_c_identifier())} + "_outputs").c_str()); + + if constexpr(avnd::inputs_is_value) + avnd::parameter_input_introspection::for_all( + [this, &m](auto a) { setup_input_value_accessor(in_cls, a, m); }); + if constexpr(avnd::outputs_is_value) + avnd::parameter_output_introspection::for_all( + [this, &m](auto a) { setup_output_value_accessor(out_cls, a, m); }); + + class_def.def_property_readonly( + "inputs", py::cpp_function( + [](T& self) { return inputs_accessor{&self}; }, + py::keep_alive<0, 1>())); + class_def.def_property_readonly( + "outputs", py::cpp_function( + [](T& self) { return outputs_accessor{&self}; }, + py::keep_alive<0, 1>())); + } + if constexpr(avnd::cpu_texture_input_introspection::size > 0) avnd::cpu_texture_input_introspection::for_all( [this](auto a) { setup_texture_input(a); }); diff --git a/tooling/run_python_golden.py b/tooling/run_python_golden.py index 9cd6474e..351a8872 100644 --- a/tooling/run_python_golden.py +++ b/tooling/run_python_golden.py @@ -54,6 +54,22 @@ def attr_map(obj): return {norm(a): a for a in dir(obj) if not a.startswith("_")} +def sub_accessor(obj, which): + """Return the obj.inputs / obj.outputs proxy and its attr map, or (None, {}). + + Newer modules expose nested `inputs`/`outputs` sub-objects that carry one + property per port -- the unambiguous path when an input and an output share + a symbol (they collide in the flat namespace). Older modules lack them, so + callers fall back to the flat attributes.""" + proxy = getattr(obj, which, None) + if proxy is None or isinstance(proxy, (bool, int, float, str)): + return (None, {}) + try: + return (proxy, attr_map(proxy)) + except Exception: + return (None, {}) + + def set_control(obj, attrs, name, value): """Set one golden input control on the instance. Returns (ok, error).""" a = attrs.get(norm(name)) @@ -103,13 +119,20 @@ def run_case(cls, gcase, meta, stage): stage("construct") obj = cls() attrs = attr_map(obj) + # Prefer the nested obj.inputs / obj.outputs proxies (unambiguous when an + # input and an output share a symbol); fall back to flat attributes. + ins, in_attrs = sub_accessor(obj, "inputs") + outs, out_attrs = sub_accessor(obj, "outputs") stage("apply") for c in gcase.get("inputs", {}).get("controls", []): nm, val = c.get("name", ""), c.get("value") if val == "unrecorded": continue - ok, err = set_control(obj, attrs, nm, val) + if ins is not None and norm(nm) in in_attrs: + ok, err = set_control(ins, in_attrs, nm, val) + else: + ok, err = set_control(obj, attrs, nm, val) if ok: rec["applied"][nm] = val else: @@ -135,14 +158,19 @@ def run_case(cls, gcase, meta, stage): named = {} for gc in gcase.get("outputs", {}).get("controls", []): nm = gc.get("name", "") - # Unnamed output ports: golden says p, the python binding disambi- - # guates them from unnamed inputs as out_p. Prefer the out_ form so - # an unnamed input p attribute doesn't shadow the output we want. - a = attrs.get(norm("out_" + nm)) or attrs.get(norm(nm)) + # Prefer obj.outputs. (unambiguous even when an input shares the + # symbol). Fall back to the flat attribute: unnamed output ports get an + # "out_" prefix from the binding, so try that before the bare name so an + # unnamed input p doesn't shadow the output we want. + if outs is not None and norm(nm) in out_attrs: + src, a = outs, out_attrs.get(norm(nm)) + else: + src = obj + a = attrs.get(norm("out_" + nm)) or attrs.get(norm(nm)) if a is None: continue try: - v = getattr(obj, a) + v = getattr(src, a) except Exception: continue if isinstance(v, (bool, int, float)): From 93ec667b7817dffaa29dae1dc3893fc669407f08 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jean-Micha=C3=ABl=20Celerier?= Date: Fri, 3 Jul 2026 17:22:25 -0400 Subject: [PATCH 09/17] gstreamer: golden differential harness (headless gst-launch) Phase 3 (GStreamer): replay each golden case through a one-shot gst-launch pipeline and diff the audio output against the oracle. filesrc(golden input, F32LE interleaved) -> rawaudioparse -> audioconvert -> -> filesink(F32LE interleaved) The avendish object registers a GStreamer element named by its c_name; input controls are GObject properties (binding lowercases the port symbol and dashes non-alnum chars -> matched by a normalized name). Audio is F32LE interleaved; the harness interleaves the golden per-channel input, runs the pipeline, de- interleaves the output and diffs via the shared tooling/golden_compare.py. Generators (GstPushSrc, no sink pad) run as a source; control-only objects and non-audio outputs have no headless read-back through gst-launch and are reported no-audio-io (a graceful gap, not a failure -- GStreamer is an A/V framework). Runs the diff under any Python+numpy; the pipeline uses the clang64/MSYS2 gst-launch (the gstreamer binding only compiles under clang64). Result over the 112 goldens: 12 match, 3 MISMATCH (2 are the known multi-bus harness-model class per_bus/sidechain; test_audio_frame per-frame ports also mismatches under Python -- a cross-backend finding), 9 no-audio-io, 88 no-element (non-audio objects). Co-Authored-By: Claude Fable 5 --- tooling/run_gst_golden.py | 283 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 283 insertions(+) create mode 100644 tooling/run_gst_golden.py diff --git a/tooling/run_gst_golden.py b/tooling/run_gst_golden.py new file mode 100644 index 00000000..cda70526 --- /dev/null +++ b/tooling/run_gst_golden.py @@ -0,0 +1,283 @@ +#!/usr/bin/env python3 +r"""Golden differential testing for the GStreamer binding (headless). + +For every golden JSON (produced by the `golden` backend), if the object built a +GStreamer element (element name == c_name), replay each recorded case through a +one-shot gst-launch pipeline: + + filesrc(golden input, raw F32LE interleaved) -> rawaudioparse -> audioconvert + -> -> filesink(out, raw F32LE interleaved) + +then de-interleave the output and diff it against the golden's recorded output +audio. Input controls are set as GObject properties (the binding names them by +the lowercased port symbol). Generators (no input audio) are fed silence of the +golden's output frame count so they cook. + +GStreamer is an audio/video streaming framework: control-only objects (no audio +ports) and non-audio outputs have no headless read-back path through gst-launch, +so they are reported skipped, not failed -- the same graceful gap Pd has for +shapes it can't represent. + +The GStreamer binding only compiles under clang64/MSYS2, so point --gst-bin at +the MSYS2 clang64 bin dir and --plugins at the built plugin dir. The diff runs +under any Python with numpy. + +Example: + + python run_gst_golden.py ^ + --gst-bin D:/msys64/clang64/bin ^ + --plugins D:/build/build-gst-clang64/gstreamer ^ + --goldens D:/build/build-avendish-msvc/golden/Debug +""" + +import argparse +import glob +import json +import os +import re +import subprocess +import sys + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +from golden_compare import aggregate_case_verdicts, compare_audio + +import numpy as np + + +def gst_env(gst_bin, plugins): + env = dict(os.environ) + env["PATH"] = gst_bin + os.pathsep + env.get("PATH", "") + env["GST_PLUGIN_PATH"] = plugins + # Keep the registry out of the user's home so a stale cache can't hide a + # freshly-built plugin. + env["GST_REGISTRY"] = os.path.join(plugins, ".avnd_gst_registry.bin") + return env + + +def norm(name): + """Match a golden control name to a gst property: the binding lowercases the + port symbol and turns every non-alnum char into '-' (canonical_name), so + compare on lowercase-alnum-only ('Combo_A' -> 'comboa' == 'combo-a').""" + return re.sub(r"[^a-z0-9]", "", str(name).lower()) + + +def inspect_element(gst_bin, env, element): + """Return (properties, has_sink) for `element`, or (None, False) if it does + not exist. `has_sink` is False for a pure source (generator). Parses + `gst-inspect-1.0 `.""" + exe = os.path.join(gst_bin, "gst-inspect-1.0.exe") + try: + p = subprocess.run([exe, element], capture_output=True, text=True, + env=env, timeout=30) + except Exception: + return (None, False) + if p.returncode != 0: + return (None, False) + props, in_props = set(), False + has_sink = False + for line in p.stdout.splitlines(): + if re.match(r"^\s+SINK template:", line): + has_sink = True + if line.startswith("Element Properties"): + in_props = True + continue + if not in_props: + continue + m = re.match(r"^ (\w[\w-]*)\s+:", line) + if m: + props.add(m.group(1)) + props -= {"name", "parent", "qos"} + return (props, has_sink) + + +def prop_arg(name, value): + """Format one `name=value` token for gst-launch, or None to skip.""" + if isinstance(value, bool): + return f"{name}={'true' if value else 'false'}" + if isinstance(value, (int, float)): + return f"{name}={value}" + if isinstance(value, str): + if value == "unrecorded": + return None + return f'{name}="{value}"' + return None + + +def interleave(channels): + """[[ch0..],[ch1..]] -> interleaved little-endian float32 bytes.""" + arr = np.asarray(channels, dtype=" [[ch0..],[ch1..]] (nch channels).""" + flat = np.frombuffer(raw, dtype=" gst_in.raw lost). + base = tmp.rstrip("/\\").replace("\\", "/") + out_path = base + "/gst_out.raw" + in_path = base + "/gst_in.raw" + if os.path.exists(out_path): + os.remove(out_path) + + # Only set properties the element actually exposes (matched by normalized + # name; the binding lowercases the port symbol and dashes non-alnum chars). + by_norm = {norm(p): p for p in props} + prop_tokens = [] + for c in gcase.get("inputs", {}).get("controls", []): + real = by_norm.get(norm(c.get("name", ""))) + if real is None: + continue + tok = prop_arg(real, c.get("value")) + if tok: + prop_tokens.append(tok) + + if not has_sink: + # Pure source (generator): no input pad. Produce one block and capture. + out_frames = len(gout[0]) if (gout and gout[0]) else int(meta.get("frames", 64)) + pipeline = [ + exe, "-q", + element, *prop_tokens, "num-buffers=1", "!", + "filesink", f"location={out_path}", + ] + else: + if gin: + in_nch = len(gin) + in_frames = len(gin[0]) if gin[0] else 0 + raw = interleave(gin) + else: + # A filter with no recorded input audio: feed silence of the golden + # output length so it cooks that many frames. + in_nch = 1 + in_frames = len(gout[0]) if (gout and gout[0]) else int(meta.get("frames", 64)) + raw = np.zeros(in_frames * in_nch, dtype=" {args.report}") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) From 9819418cc84ec23788e00ee87c0dda3d7745d497 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jean-Micha=C3=ABl=20Celerier?= Date: Fri, 3 Jul 2026 18:03:21 -0400 Subject: [PATCH 10/17] tooling/max: golden differential harness for the Max/MSP backend Max has no headless mode, so this drives the GUI host exactly like the TouchDesigner sweep: it stages the driver patch + js next to the built externals, launches Max on a loadbang-driven [js] driver that replays every golden's recorded inputs and captures what Max can expose, auto-dismisses modal dialogs (Win32 EnumWindows+BM_CLICK), polls a JSON-lines report, and crash-isolates via a per-object breadcrumb + relaunch/resume loop. Outputs are diffed against the golden oracle through the shared golden_compare helpers. Capture, honestly labelled per object (112 goldens): - control objects: message_processor commits output controls to outlets; captured by wiring each outlet through [prepend cap] back into the js. Controls are set by INLET INDEX (proxy inlets), not name, since unnamed ports' golden names are positional "p" placeholders that don't match Max's message selectors. 37 objects match the oracle. - audio objects: a count~/index~ -> object -> record~ buffer chain renders one real DSP block. A DSP self-test (cycle~ -> snapshot~/record~) detects whether audio can render; in an automated Max session there is no live audio driver and the Non-Real-Time driver can't be switched in via scripting, so the 28 audio objects fall back to instantiate-only (verdict audio-dsp-unavailable), never a false pass. The full capture path activates on an audio-equipped host. - none/texture: instantiate-only smoke. Co-Authored-By: Claude Fable 5 --- tooling/max/avnd_max_driver.js | 431 +++++++++++++++++++++++++++++ tooling/max/avnd_max_driver.maxpat | 144 ++++++++++ tooling/run_max_golden.py | 400 ++++++++++++++++++++++++++ 3 files changed, 975 insertions(+) create mode 100644 tooling/max/avnd_max_driver.js create mode 100644 tooling/max/avnd_max_driver.maxpat create mode 100644 tooling/run_max_golden.py diff --git a/tooling/max/avnd_max_driver.js b/tooling/max/avnd_max_driver.js new file mode 100644 index 00000000..fbf3bad0 --- /dev/null +++ b/tooling/max/avnd_max_driver.js @@ -0,0 +1,431 @@ +// avnd_max_driver.js -- in-Max golden-differential driver. +// +// 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; +// * 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). +// 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). +// +// Everything is deliberately async/Task-driven with a delay between "build" and +// "read" so committed/recorded values have settled regardless of Max's +// synchronous-vs-deferred message timing. + +autowatch = 0; +inlets = 1; +outlets = 1; + +include("avnd_max_config.js"); // -> AVND_CFG {report, breadcrumb, goldenDir} +include("avnd_max_data.js"); // -> AVND_DATA [{name, kind, cases:[...]}] + +var FRAMES = 64; +var BUFSAMPS = 128; +var CHANS = 2; + +var allLines = []; // raw report lines (prior + new), rewritten each object +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 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? + +function post_(s) { post(s + "\n"); } + +// ---- tiny JSON serialization (we only ever emit, never parse, JSON) -------- +function jnum(x) { + if (x === null || x === undefined) return "0"; + var n = Number(x); + if (!isFinite(n)) return "0"; + return "" + n; +} +function jstr(s) { + s = "" + s; + s = s.replace(/\\/g, "\\\\").replace(/"/g, "\\\""); + return "\"" + s + "\""; +} +function jarr(a) { + var p = []; + for (var i = 0; i < a.length; i++) p.push(jnum(a[i])); + return "[" + p.join(",") + "]"; +} + +// ---- files ---------------------------------------------------------------- +function crumb(s) { + // Max's File "write" mode overwrites in place WITHOUT truncating, so a shorter + // string leaves a stale tail; pad to a fixed width so the reader (which + // .strip()s) always sees exactly `s`. + while (s.length < 240) s += " "; + var f = new File(AVND_CFG.breadcrumb, "write"); + if (f.isopen) { f.writestring(s); f.close(); } +} +function loadExisting() { + var f = new File(AVND_CFG.report, "read"); + if (!f.isopen) return; + while (!f.eof) { + var ln = f.readline(2000000); + if (ln && ln.length > 0) { + allLines.push(ln); + var m = ln.indexOf("\"name\":\""); + if (m >= 0) { + var s = m + 8, e = ln.indexOf("\"", s); + if (e > s) doneNames[ln.substring(s, e)] = 1; + } + } + } + f.close(); +} +function writeReport() { + var f = new File(AVND_CFG.report, "write"); + if (!f.isopen) { post_("ERR cannot open report for write"); return; } + for (var i = 0; i < allLines.length; i++) f.writeline(allLines[i]); + f.close(); +} + +// ---- scheduling ------------------------------------------------------------ +// Max js Task callbacks don't reliably capture closures across engine versions, +// so dispatch by mode through a stable named callback + Task.arguments. +function defer(mode, ms) { + var t = new Task(taskCallback, this); + t.arguments = [mode]; + t.schedule(ms); + TASKS.push(t); + if (TASKS.length > 64) TASKS.splice(0, 32); +} +function taskCallback(mode) { + if (mode === "readStep") readStep(); + else if (mode === "buildStep") buildStep(); + else if (mode === "selfTestRead") selfTestRead(); +} + +// One-shot: prove that global DSP + record~ actually capture a signal in this +// automated Max session. Writes the peak sample to .dbg. +function selfTestDSP() { + var p = this.patcher; + capture = {}; + var b = new Buffer("avout0"); + for (var z = 0; z < BUFSAMPS; z++) b.poke(1, z, 0.0); + // report the current audio driver (adstatus reports on bang). + var ads = p.newdefault(400, 520, "adstatus", "driver"); + var adp = p.newdefault(600, 520, "prepend", "curdrv"); + p.connect(ads, 0, adp, 0); + p.connect(adp, 0, p.getnamed("driver"), 0); + ads.message("bang"); + var osc = p.newdefault(40, 520, "cycle~", 441); + var snap = p.newdefault(40, 545, "snapshot~", 20); // live sample every 20ms + var pre = p.newdefault(200, 545, "prepend", "stpeak"); + var rec = p.newdefault(40, 580, "record~", "avout0"); + cur = [ads, adp, osc, snap, pre, rec]; + p.connect(osc, 0, snap, 0); + p.connect(snap, 0, pre, 0); + p.connect(pre, 0, this.patcher.getnamed("driver"), 0); + p.connect(osc, 0, rec, 0); + rec.message(1); + startDSP(); + defer("selfTestRead", 800); +} +var dspDriverSet = false; +function startDSP() { + // No live audio device is guaranteed in an automated Max, so drive the graph + // with the Non-Real-Time driver (renders the DSP chain without hardware). + if (!dspDriverSet) { + var dv = this.patcher.getnamed("dspdriver"); + if (dv) dv.message("bang"); + try { messnamed("dsp", "driver", "NonRealTime"); } catch (e) {} + dspDriverSet = true; + } + var ds = this.patcher.getnamed("dspstart"); + if (ds) ds.message("bang"); + try { messnamed("dsp", "start"); } catch (e) {} +} +function stopDSP() { + var ds = this.patcher.getnamed("dspstop"); + if (ds) ds.message("bang"); + try { messnamed("dsp", "stop"); } catch (e) {} +} +function selfTestRead() { + var b = new Buffer("avout0"); + var peak = 0.0; + for (var i = 0; i < BUFSAMPS; i++) { + var v = b.peek(1, i); + if (v < 0) v = -v; + if (v > peak) peak = v; + } + stopDSP(); + var snap = capture["stpeak"]; + dspWorks = (peak > 0.0) || (typeof snap === "number" && snap !== 0.0); + var dbg = new File(AVND_CFG.breadcrumb + ".dbg", "readwrite"); + if (dbg.isopen) { + dbg.position = dbg.eof; + dbg.writeline("dsp_works=" + dspWorks); + dbg.writeline("selftest_record_peak=" + peak); + dbg.writeline("selftest_snapshot=" + (capture["stpeak"] === undefined ? "none" : capture["stpeak"])); + dbg.writeline("audio_driver=" + (capture["curdrv"] === undefined ? "none" : capture["curdrv"])); + dbg.close(); + } + cleanup(); + buildStep(); +} + +// ---- entry ----------------------------------------------------------------- +function run() { + loadExisting(); + WORK = []; + for (var oi = 0; oi < AVND_DATA.length; oi++) { + var o = AVND_DATA[oi]; + if (doneNames[o.name]) continue; + var nc = o.cases.length; + for (var ci = 0; ci < nc; ci++) + WORK.push({ oi: oi, ci: ci, first: ci === 0, last: ci === nc - 1 }); + } + post_("avnd max driver: " + WORK.length + " cases over " + + (AVND_DATA.length - countKeys(doneNames)) + " objects"); + wi = -1; + // size the static capture buffers once. + for (var ch = 0; ch < CHANS; ch++) { + sizeBuf("buf_avin" + ch); + sizeBuf("buf_avout" + ch); + } + // one-shot debug: which static boxes did we resolve by scripting name? + var dbg = new File(AVND_CFG.breadcrumb + ".dbg", "write"); + if (dbg.isopen) { + dbg.writeline("driver=" + (this.patcher.getnamed("driver") ? "ok" : "NULL")); + dbg.writeline("dspstart=" + (this.patcher.getnamed("dspstart") ? "ok" : "NULL")); + dbg.writeline("dspstop=" + (this.patcher.getnamed("dspstop") ? "ok" : "NULL")); + dbg.writeline("buf_avout0=" + (this.patcher.getnamed("buf_avout0") ? "ok" : "NULL")); + var tb = new Buffer("avout0"); + dbg.writeline("avout0.framecount=" + tb.framecount()); + dbg.close(); + } + selfTestDSP(); +} +function countKeys(o) { var n = 0; for (var k in o) n++; return n; } +function sizeBuf(varname) { + var b = this.patcher.getnamed(varname); + if (b) b.message("sizeinsamps", BUFSAMPS); +} + +// ---- per-case build -------------------------------------------------------- +function buildStep() { + wi++; + if (wi >= WORK.length) { finalize(); return; } + var w = WORK[wi]; + var obj = AVND_DATA[w.oi]; + crumb(obj.name + ":case" + w.ci); + if (w.first) objCases = []; + capture = {}; + cur = []; + + 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 ok = buildNone(obj, obj.cases[w.ci]); + } catch (e) { + ok = false; + } + if (!ok) { + objCases.push("{\"index\":" + w.ci + ",\"error\":\"could not instantiate\"}"); + cleanup(); + if (w.last) commitObject(obj); + defer("buildStep", 20); + return; + } + defer("readStep", kind === "audio" ? 250 : 50); +} + +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. + 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"); + } + for (var m2 = 0; m2 < msgs.length; m2++) + if (c.controls[m2][0] === 0) msgs[m2].message("bang"); + o.message("bang"); // force a final process()+commit on inlet 0 + 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); + // 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; + var nin = c.audioIn.length; + var nout = c.nAudioOut > 0 ? c.nAudioOut : nin; + if (nout < 1) nout = 1; + if (nout > CHANS) nout = CHANS; + + if (nin > 0) { + var cnt = p.newdefault(50, 140, "count~"); + cur.push(cnt); + var pack = (nin > 1) ? p.newdefault(300, 240, "mc.pack~", nin) : null; + if (pack) cur.push(pack); + for (var ch = 0; ch < nin && ch < CHANS; ch++) { + var b = new Buffer("avin" + ch); + var src = c.audioIn[ch]; + for (var i = 0; i < FRAMES && i < src.length; i++) b.poke(1, i, src[i]); + for (var j = src.length; j < BUFSAMPS; j++) b.poke(1, j, 0.0); + var idx = p.newdefault(50, 190 + ch * 40, "index~", "avin" + ch); + cur.push(idx); + p.connect(cnt, 0, idx, 0); + if (pack) p.connect(idx, 0, pack, ch); + else p.connect(idx, 0, o, 0); + } + 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]); + + var unpack = (nout > 1) ? p.newdefault(420, 400, "mc.unpack~", nout) : null; + if (unpack) { cur.push(unpack); p.connect(o, 0, unpack, 0); } + for (var oc = 0; oc < nout; oc++) { + var bo = new Buffer("avout" + oc); + for (var z = 0; z < BUFSAMPS; z++) bo.poke(1, z, 0.0); // clear stale samples + var rec = p.newdefault(420 + oc * 80, 470, "record~", "avout" + oc); + cur.push(rec); + if (unpack) p.connect(unpack, oc, rec, 0); + else p.connect(o, 0, rec, 0); + rec.message(1); // arm: begins recording the moment DSP starts + } + var ds = p.getnamed("dspstart"); + if (ds) ds.message("bang"); + return true; +} + +function buildNone(obj, c) { + var o = this.patcher.newdefault(220, 300, obj.name); + if (!o) return false; + cur.push(o); + return true; +} + +// ---- per-case read --------------------------------------------------------- +function readStep() { + var w = WORK[wi]; + var obj = AVND_DATA[w.oi]; + var c = obj.cases[w.ci]; + var line; + try { + if (obj.kind === "audio") line = readAudio(obj, c, w.ci); + else if (obj.kind === "control") line = readControl(obj, c, w.ci); + else line = "{\"index\":" + w.ci + ",\"instantiated\":true}"; + } catch (e) { + line = "{\"index\":" + w.ci + ",\"error\":\"read failed\"}"; + } + objCases.push(line); + cleanup(); + if (w.last) commitObject(obj); + defer("buildStep", 20); +} + +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(",") + "}}"; +} + +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; + if (nout > CHANS) nout = CHANS; + var ds = this.patcher.getnamed("dspstop"); + var chans = []; + for (var oc = 0; oc < nout; oc++) { + var b = new Buffer("avout" + oc); + var arr = []; + for (var i = 0; i < FRAMES; i++) { + var v = b.peek(1, i); + arr.push((v === undefined || v === null) ? 0.0 : v); + } + chans.push(jarr(arr)); + } + if (ds) ds.message("bang"); + return "{\"index\":" + ci + ",\"audio\":[" + chans.join(",") + "]}"; +} + +// ---- capture handler: [prepend cap] -> here ----------------------------- +function anything() { + var sel = "" + messagename; + if (sel.substring(0, 3) === "cap" || sel === "stpeak" || sel === "curdrv") { + var a = arrayfromargs(arguments); + // Prefer the first numeric arg (scalar control / signal value); fall back to + // the first arg as-is (string/enum/symbol outputs, driver name). + var v = (a.length > 0) ? a[0] : 0; + for (var i = 0; i < a.length; i++) { + if (typeof a[i] === "number") { v = a[i]; break; } + } + capture[sel] = v; + } +} +function msg_float(v) { /* bare float outputs shouldn't reach us (prepended) */ } +function msg_int(v) { } + +// ---- object bookkeeping ---------------------------------------------------- +function commitObject(obj) { + var line = "{\"name\":" + jstr(obj.name) + ",\"kind\":" + jstr(obj.kind) + + ",\"cases\":[" + objCases.join(",") + "]}"; + allLines.push(line); + doneNames[obj.name] = 1; + writeReport(); +} +function cleanup() { + var p = this.patcher; + for (var i = 0; i < cur.length; i++) { + try { p.remove(cur[i]); } catch (e) {} + } + cur = []; +} +function finalize() { + crumb("DONE"); + post_("avnd max driver: DONE (" + allLines.length + " objects)"); +} diff --git a/tooling/max/avnd_max_driver.maxpat b/tooling/max/avnd_max_driver.maxpat new file mode 100644 index 00000000..0b32ebb5 --- /dev/null +++ b/tooling/max/avnd_max_driver.maxpat @@ -0,0 +1,144 @@ +{ + "patcher": { + "fileversion": 1, + "appversion": { "major": 8, "minor": 5, "revision": 0, "architecture": "x64", "modernui": 1 }, + "classnamespace": "box", + "rect": [80.0, 80.0, 900.0, 600.0], + "boxes": [ + { + "box": { + "id": "obj-driver", + "maxclass": "newobj", + "text": "js avnd_max_driver.js", + "varname": "driver", + "numinlets": 1, + "numoutlets": 1, + "outlettype": [""], + "patching_rect": [40.0, 160.0, 180.0, 22.0] + } + }, + { + "box": { + "id": "obj-lb", + "maxclass": "newobj", + "text": "loadbang", + "numinlets": 1, + "numoutlets": 1, + "outlettype": ["bang"], + "patching_rect": [40.0, 40.0, 70.0, 22.0] + } + }, + { + "box": { + "id": "obj-del", + "maxclass": "newobj", + "text": "del 800", + "numinlets": 2, + "numoutlets": 1, + "outlettype": ["bang"], + "patching_rect": [40.0, 80.0, 70.0, 22.0] + } + }, + { + "box": { + "id": "obj-run", + "maxclass": "message", + "text": "run", + "numinlets": 2, + "numoutlets": 1, + "outlettype": [""], + "patching_rect": [40.0, 120.0, 50.0, 22.0] + } + }, + { + "box": { + "id": "obj-dspstart", + "maxclass": "message", + "text": "; dsp start", + "varname": "dspstart", + "numinlets": 2, + "numoutlets": 1, + "outlettype": [""], + "patching_rect": [300.0, 40.0, 90.0, 22.0] + } + }, + { + "box": { + "id": "obj-dspstop", + "maxclass": "message", + "text": "; dsp stop", + "varname": "dspstop", + "numinlets": 2, + "numoutlets": 1, + "outlettype": [""], + "patching_rect": [300.0, 80.0, 90.0, 22.0] + } + }, + { + "box": { + "id": "obj-dspdrv", + "maxclass": "message", + "text": "; dsp driver NonRealTime", + "varname": "dspdriver", + "numinlets": 2, + "numoutlets": 1, + "outlettype": [""], + "patching_rect": [300.0, 120.0, 200.0, 22.0] + } + }, + { + "box": { + "id": "obj-avin0", + "maxclass": "newobj", + "text": "buffer~ avin0", + "varname": "buf_avin0", + "numinlets": 1, + "numoutlets": 2, + "outlettype": ["float", "bang"], + "patching_rect": [500.0, 40.0, 110.0, 22.0] + } + }, + { + "box": { + "id": "obj-avin1", + "maxclass": "newobj", + "text": "buffer~ avin1", + "varname": "buf_avin1", + "numinlets": 1, + "numoutlets": 2, + "outlettype": ["float", "bang"], + "patching_rect": [500.0, 80.0, 110.0, 22.0] + } + }, + { + "box": { + "id": "obj-avout0", + "maxclass": "newobj", + "text": "buffer~ avout0", + "varname": "buf_avout0", + "numinlets": 1, + "numoutlets": 2, + "outlettype": ["float", "bang"], + "patching_rect": [500.0, 120.0, 110.0, 22.0] + } + }, + { + "box": { + "id": "obj-avout1", + "maxclass": "newobj", + "text": "buffer~ avout1", + "varname": "buf_avout1", + "numinlets": 1, + "numoutlets": 2, + "outlettype": ["float", "bang"], + "patching_rect": [500.0, 160.0, 110.0, 22.0] + } + } + ], + "lines": [ + { "patchline": { "source": ["obj-lb", 0], "destination": ["obj-del", 0] } }, + { "patchline": { "source": ["obj-del", 0], "destination": ["obj-run", 0] } }, + { "patchline": { "source": ["obj-run", 0], "destination": ["obj-driver", 0] } } + ] + } +} diff --git a/tooling/run_max_golden.py b/tooling/run_max_golden.py new file mode 100644 index 00000000..d0be70bc --- /dev/null +++ b/tooling/run_max_golden.py @@ -0,0 +1,400 @@ +#!/usr/bin/env python3 +"""Golden-differential testing for the avendish Max/MSP backend. + +Max has NO headless mode (see run_backend_tests.py::run_max), so -- exactly like +the TouchDesigner sweep (tooling/td/run_td_sweep.py) -- this drives the GUI host: + + 1. generate a self-contained data file (avnd_max_data.js) holding every + golden's recorded inputs/outputs, so the in-Max driver needs no JSON parser; + 2. stage the driver patch + js + data next to the built *.mxe64 externals + (Max searches the patcher's own folder, so co-location resolves both the + externals AND the driver's include()/File paths); + 3. launch `Max.exe avnd_max_driver.maxpat`; a loadbang runs the [js] driver, + which for every object instantiates it, replays the golden inputs and + CAPTURES what Max can expose -- control-output objects via their message + outlets, audio objects via a count~/index~ -> object -> record~ buffer chain + driven by a real DSP block -- writing one JSON line per object to a report; + 4. auto-dismiss Max's modal dialogs (reuse the TD Win32 EnumWindows+BM_CLICK); + 5. poll for the report; if an object crashes Max mid-sweep, record it from the + per-object breadcrumb and relaunch -- the driver resumes past everything + already reported, so one bad object doesn't block the rest; + 6. diff every captured output against the golden oracle via golden_compare. + +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. + +Windows only. Example: + + C:/ossia-sdk-x86_64/python/python.exe tooling/run_max_golden.py ^ + --max "C:/Program Files/Cycling '74/Max 8/Max.exe" ^ + --externals D:/build/build-avendish-msvc/max/Debug ^ + --goldens D:/build/build-avendish-msvc/golden/Debug +""" + +import argparse +import ctypes +import glob +import json +import os +import shutil +import subprocess +import sys +import time + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +from golden_compare import (aggregate_case_verdicts, compare_audio, + compare_controls) + +user32 = ctypes.windll.user32 if sys.platform == "win32" else None + +# --- Win32: dismiss Max's modal dialogs (recovery, "already running", errors) -- +if user32: + EnumWindowsProc = ctypes.WINFUNCTYPE( + ctypes.c_bool, ctypes.c_void_p, ctypes.c_void_p) + + def _windows(): + found = [] + + def cb(hwnd, _): + if not user32.IsWindowVisible(hwnd): + return True + buf = ctypes.create_unicode_buffer(256) + user32.GetWindowTextW(hwnd, buf, 256) + found.append((hwnd, buf.value)) + return True + user32.EnumWindows(EnumWindowsProc(cb), 0) + return found + + def _click_button(dlg, labels): + """BM_CLICK the first child Button whose text matches one of labels.""" + target = [] + + def cb(hwnd, _): + cls = ctypes.create_unicode_buffer(64) + user32.GetClassNameW(hwnd, cls, 64) + txt = ctypes.create_unicode_buffer(64) + user32.GetWindowTextW(hwnd, txt, 64) + t = txt.value.replace("&", "").strip().lower() + if cls.value == "Button" and t in labels: + target.append(hwnd) + return False + return True + user32.EnumChildWindows(dlg, EnumWindowsProc(cb), 0) + if target: + user32.SendMessageW(target[0], 0x00F5, 0, 0) # BM_CLICK + return True + return False + + # Dialog titles Max raises during an automated launch, and the button we want. + _OK = {"ok", "continue", "no", "don't save", "dont save", "close"} + + def dismiss_dialogs(): + n = 0 + for hwnd, title in _windows(): + t = (title or "").lower() + if any(k in t for k in ("error", "max", "warning", "recover", + "save", "crash", "unresolved", "missing")): + if _click_button(hwnd, _OK): + n += 1 + return n +else: + def dismiss_dialogs(): + return 0 + + +def _round(x): + try: + return float(x) + except Exception: + return 0.0 + + +def gen_data_js(goldens, out_path): + """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).""" + objs = [] + for g in goldens: + cases_out = [] + cases = g.get("cases") or [{"inputs": g.get("inputs", {}), + "outputs": g.get("outputs", {})}] + 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))] + audio_in = ins.get("audio") or [] + out_ctrl_names = [cc.get("name", "") + for cc in outs.get("controls", [])] + has_aout = bool(outs.get("audio")) + has_cout = bool(outs.get("controls")) + has_tex = bool(outs.get("texture")) + if has_aout: + kind = "audio" + elif has_cout and kind != "audio": + kind = "control" + elif has_tex and kind not in ("audio", "control"): + kind = "texture" + cases_out.append({ + "controls": controls, + "audioIn": audio_in, + "nAudioOut": len(outs.get("audio") or []), + "outCtrlNames": out_ctrl_names, + }) + objs.append({"name": g["c_name"], "kind": kind, "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") + f.write("var AVND_DATA = ") + json.dump(objs, f) + f.write(";\n") + return objs + + +def load_goldens(goldens_dir): + out = [] + for f in sorted(glob.glob(os.path.join(goldens_dir, "*.json"))): + try: + g = json.load(open(f, encoding="utf-8")) + if g.get("c_name"): + out.append(g) + except Exception: + pass + return out + + +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}.""" + entries = {} + if not os.path.exists(path): + return entries + for line in open(path, encoding="utf-8"): + line = line.strip() + if not line: + continue + try: + e = json.loads(line) + entries[e["name"]] = e + except Exception: + pass + return entries + + +def main(): + ap = argparse.ArgumentParser() + 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("--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("--report", default=None, + help="report path (default: /avnd_max_report.jsonl)") + args = ap.parse_args() + + here = os.path.dirname(os.path.abspath(__file__)) + asset_dir = os.path.join(here, "max") + ext_dir = os.path.abspath(args.externals).replace("\\", "/") + + goldens = load_goldens(args.goldens) + if args.only: + keep = set(args.only.split(",")) + goldens = [g for g in goldens if g["c_name"] in keep] + if not goldens: + sys.exit("no goldens matched") + + # 1. stage driver assets next to the externals (Max searches that folder). + staged = [] + for fn in ("avnd_max_driver.maxpat", "avnd_max_driver.js"): + dst = os.path.join(ext_dir, fn) + 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) + staged.append(data_js) + + report = args.report or os.path.join(ext_dir, "avnd_max_report.jsonl") + report = report.replace("\\", "/") + breadcrumb = os.path.join(ext_dir, "avnd_max_current.txt").replace("\\", "/") + goldir = os.path.abspath(args.goldens).replace("\\", "/") + + # config the driver reads (paths + object order). + cfg = os.path.join(ext_dir, "avnd_max_config.js") + with open(cfg, "w", encoding="utf-8", newline="\n") as f: + f.write("var AVND_CFG = ") + json.dump({"report": report, "breadcrumb": breadcrumb, + "goldenDir": goldir}, f) + f.write(";\n") + staged.append(cfg) + + 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"] + + def _read(p): + try: + return open(p, encoding="utf-8").read().strip() + except Exception: + return "" + + subprocess.run(kill, capture_output=True) + dismissed, crashers = 0, [] + for attempt in range(1, args.max_launches + 1): + done = parse_report(report) + remaining = [g for g in goldens if g["c_name"] not in done] + if not remaining: + break + print(f"launch #{attempt}: {len(done)}/{len(goldens)} done, " + f"{len(remaining)} remaining -> next '{remaining[0]['c_name']}'") + proc = subprocess.Popen([args.max, patch]) + deadline = time.time() + args.timeout + last_progress = len(done) + stall_since = time.time() + # Max is slow to start (~30-45s) and may hand off to another process + # instance (so proc.poll() exiting is NOT a reason to stop). Give a + # generous grace period until the driver first writes its breadcrumb, + # then apply a stall timeout on lack of new objects. + started = False + while time.time() < deadline: + dismissed += dismiss_dialogs() + crumb_val = _read(breadcrumb) + if crumb_val == "DONE": + break + if crumb_val and not started: + started = True + stall_since = time.time() + cur = parse_report(report) + if len(cur) != last_progress: + last_progress = len(cur) + stall_since = time.time() + elif started and time.time() - stall_since > 60: + break # driver was running but wedged for 60s -> crash/hang + time.sleep(1) + try: + proc.kill() + except Exception: + pass + subprocess.run(kill, capture_output=True) + time.sleep(2) + + if _read(breadcrumb) == "DONE": + break + # crashed / stalled: record the breadcrumb object so the relaunch skips it. + culprit = _read(breadcrumb) + base = culprit.split(":", 1)[0] if culprit else "" + done_now = parse_report(report) + if base and base not in done_now: + crashers.append(culprit) + with open(report, "a", encoding="utf-8", newline="\n") as f: + f.write(json.dumps({"name": base, "kind": "?", "cases": [], + "crash": culprit}) + "\n") + print(f" *** '{culprit}' crashed/stalled Max -> recorded; relaunching") + elif not base: + print(" no breadcrumb progress; stopping.") + break + + subprocess.run(kill, capture_output=True) + + # clean up staged assets (leave the externals + report). + for _ in range(8): + left = [f for f in staged if os.path.exists(f)] + for f in left: + try: + os.remove(f) + except Exception: + pass + if not [f for f in staged if os.path.exists(f)]: + break + time.sleep(1) + + if dismissed: + print(f"(auto-dismissed {dismissed} Max dialog(s))") + + # ---- diff vs golden ----------------------------------------------------- + goldmap = {g["c_name"]: g for g in goldens} + entries = parse_report(report) + counts, details, rows = {}, [], [] + for g in goldens: + name = g["c_name"] + e = entries.get(name) + gcases = g.get("cases") or [{"inputs": g.get("inputs", {}), + "outputs": g.get("outputs", {})}] + if e is None: + v, d = "no-report", "object never reported" + elif e.get("crash"): + v, d = "CRASH", f"crashed Max at {e.get('crash')}" + else: + rcases = e.get("cases", []) + verdicts = [] + for ci, gc in enumerate(gcases): + rc = next((r for r in rcases if r.get("index") == ci), None) + gout = gc.get("outputs", {}) + if rc is None: + verdicts.append((ci, "no-case", "")) + continue + if rc.get("error"): + verdicts.append((ci, "error", str(rc["error"])[:80])) + continue + if gout.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 + # sample diff -- do NOT report a false mismatch. + 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)) + elif gout.get("texture"): + verdicts.append((ci, "texture-uncaptured", + "jitter matrix not captured")) + else: + # no output to verify: object at least instantiated + ran. + ok = rc.get("instantiated") or rc.get("ok") + verdicts.append((ci, "instantiated" if ok else "error", + "" if ok else "did not instantiate")) + v, d = aggregate_case_verdicts(verdicts) + counts[v] = counts.get(v, 0) + 1 + rows.append((name, v, d)) + if v in ("MISMATCH", "CRASH", "error", "no-report"): + details.append(f" {v:10} {name}: {d}") + + print(f"\n=== Max/MSP golden diff ({len(goldens)} objects) ===") + for k in sorted(counts): + print(f" {k:20} {counts[k]}") + if crashers: + print(f"\n*** {len(crashers)} object(s) crashed/stalled Max: " + + ", ".join(crashers)) + if details: + print("\n--- attention ---") + for line in details[:60]: + print(line) + print(f"\nreport -> {report}") + + +if __name__ == "__main__": + main() From 34ae6cf977b74191082df6e5243ea0de419e37fa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jean-Micha=C3=ABl=20Celerier?= Date: Fri, 3 Jul 2026 18:41:05 -0400 Subject: [PATCH 11/17] tooling/pd: golden differential harness (headless single-block audio diff) Replays each golden case through a headless Pd instance and diffs captured audio against the golden. Per (object, case): a generated driver preloads input blocks into [table]s, feeds the object via [tabreceive~], sets controls on the left inlet, and captures each output channel with [tabwrite~] armed before DSP is enabled so it records the FIRST block of a fresh instance (one block == 64 frames == one golden case). Reuses golden_compare.compare_audio / aggregate_case_verdicts; one pd process per case for crash + state isolation. Only audio outputs are diffed: the Pd audio processor does not commit output controls (no-control-commit), and control-only/MIDI/texture/geometry objects have no headless audio read-back (no-audio-output) -- the same graceful gaps the GStreamer harness has. Detects "couldn't create" so a never-written output table cannot masquerade as a silent match. Co-Authored-By: Claude Fable 5 --- tooling/run_pd_golden.py | 341 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 341 insertions(+) create mode 100644 tooling/run_pd_golden.py diff --git a/tooling/run_pd_golden.py b/tooling/run_pd_golden.py new file mode 100644 index 00000000..b657515b --- /dev/null +++ b/tooling/run_pd_golden.py @@ -0,0 +1,341 @@ +#!/usr/bin/env python3 +r"""Golden differential testing for the Pure Data binding (headless). + +For every golden JSON (produced by the `golden` backend at build time), if the +object built a Pd external (``.dll``), replay each recorded case through +a head-less Pd instance and diff the captured AUDIO output against the golden's +recorded output audio. + +Mechanism (one deterministic DSP block, fresh instance per case) +---------------------------------------------------------------- +Pd's default block size is 64 samples -- exactly the golden's frame count -- so +one DSP block == one golden frame. For each case we generate a driver ``.pd`` +that: + + 1. preloads each golden input channel into a ``[table inK 64]`` via a + ``; inK 0 v0 v1 ... v63`` message fired on ``loadbang``; + 2. feeds the object's signal inlet(s) from those tables with + ``[tabreceive~ inK]`` (reads one block per DSP tick); + 3. sets the input controls by sending `` `` to the left + inlet (the audio binding routes control messages there); + 4. captures each output channel with ``[tabwrite~ outK]`` into a + ``[table outK 64]`` -- armed by a bang BEFORE DSP is turned on so it records + the very FIRST block from the fresh instance (matching the golden, which + runs one block of a freshly-constructed effect); + 5. after the block has run, writes each output table to a text file + (``; outK write cr``) and quits (``; pd quit``). + +Ordering is enforced with a ``[trigger]``: controls are set and ``tabwrite~`` is +armed first, DSP is enabled last, so the recorded block is block 1 with fresh +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. + +Example: + + python run_pd_golden.py ^ + --pd "C:/Program Files/Pd/bin/pd.exe" ^ + --externals D:/build/build-avendish-msvc/pd/Debug ^ + --goldens D:/build/build-avendish-msvc/golden/Debug ^ + --tmp %TEMP%/pd_golden +""" + +import argparse +import glob +import json +import math +import os +import re +import subprocess +import sys + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +from golden_compare import aggregate_case_verdicts, compare_audio + + +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)) + + +def pd_float(x): + """Format a number as a Pd float atom (finite; non-finite -> 0).""" + v = float(x) + if not math.isfinite(v): + return "0" + 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.""" + 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: + return (None, []) + + 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;") + # trigger: right outlet (1) = "prep" (fires first), left outlet (0) = "go". + trig = add("#X obj 20 60 trigger bang bang;") + + trecv = [add(f"#X obj 400 {160 + 18 * k} tabreceive~ in{k};") + for k in range(in_nch)] + twrite = [add(f"#X obj 400 {360 + 18 * k} tabwrite~ out{k};") + for k in range(out_nch)] + add(f"#X obj 400 280 {c_name};") # keep index explicit below + # (obj index computed:) + obj = idx - 1 + + # tables holding input / output blocks + tin = [add(f"#X obj 20 {120 + 18 * k} table in{k} {frames};") + for k in range(in_nch)] + tout = [add(f"#X obj 220 {120 + 18 * k} table out{k} {frames};") + for k in range(out_nch)] + + # preload input blocks (message fired by trigger's prep outlet) + setmsgs = [] + for k in range(in_nch): + 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};")) + + dspon = add("#X msg 20 460 \\; pd dsp 1;") + d1 = add("#X obj 20 500 delay 20;") + 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;") + quitmsg = add("#X msg 20 620 \\; pd quit;") + + conns = [] + # audio signal wiring + for k in range(in_nch): + conns.append((trecv[k], 0, obj, k)) + for k in range(out_nch): + conns.append((obj, k, twrite[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)) + for tw in twrite: + conns.append((trig, 1, tw, 0)) + # go (trigger outlet 0): DSP on, then delayed 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((d2, 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") + + 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) + + cmd = [pd_exe, "-nogui", "-batch", "-noprefs", + "-path", externals, "-open", driver] + try: + p = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout) + except subprocess.TimeoutExpired: + # A wedged pd never reaches `; pd quit`: kill any stragglers. + 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 = [] + 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): + return (None, "no-output-captured") + return (out, None) + + +def run_object(pd_exe, externals, g, atol, rtol, tmp, timeout): + 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 + + gcases = g.get("cases") + if gcases is None: # old single-case schema + gcases = [{"inputs": g.get("inputs", {}), + "outputs": g.get("outputs", {})}] + 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 + 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 + entry["cases"].append(rec) + + v, detail = aggregate_case_verdicts(verdicts) + entry["verdict"] = v + entry["detail"] = detail + return entry + + +def load_goldens(goldens_dir): + goldens = [] + for f in sorted(glob.glob(os.path.join(goldens_dir, "*.json"))): + try: + g = json.load(open(f)) + if g.get("c_name"): + goldens.append(g) + except Exception: + pass + return goldens + + +def main(): + ap = argparse.ArgumentParser() + ap.add_argument("--pd", default="C:/Program Files/Pd/bin/pd.exe", + help="path to pd.exe") + 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("--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") + ap.add_argument("--only", help="run a single object (c_name)") + ap.add_argument("--timeout", type=int, default=30, + help="per-case pd invocation timeout (seconds)") + ap.add_argument("--tmp", default=os.path.join( + os.environ.get("TEMP", "."), "pd_golden"), + help="scratch dir for driver patches + captured output") + args = ap.parse_args() + + # Absolute + forward-slash: Pd resolves an array `write` path relative to the + # patch's own directory, and a backslash gets mangled in message-box parsing. + tmp = os.path.abspath(args.tmp).replace("\\", "/") + os.makedirs(tmp, exist_ok=True) + + goldens = load_goldens(args.goldens) + if args.only: + goldens = [g for g in goldens if g["c_name"] == args.only] + + counts, mism, report = {}, [], [] + for g in goldens: + entry = run_object(args.pd, args.externals, g, args.atol, args.rtol, + tmp, args.timeout) + report.append(entry) + v = entry.get("verdict", "?") + counts[v] = counts.get(v, 0) + 1 + if v in ("MISMATCH", "error"): + mism.append(f" {v} {entry['name']} " + f"({len(entry.get('cases', []))} cases): " + f"{entry.get('detail', '')[:140]}") + json.dump(report, open(args.report, "w"), indent=1) + + ncases = sum(len(e.get("cases", [])) for e in report) + print(f"\n=== pd diff vs golden ({len(report)} objects, " + f"{ncases} cases) ===") + for k in sorted(counts): + print(f" {k}: {counts[k]}") + if mism: + print("mismatches / errors:") + for m in mism: + print(m) + matched = [e["name"] for e in report if e.get("verdict") == "match"] + if matched: + print(f"matched ({len(matched)}): " + ", ".join(matched)) + print(f"report -> {args.report}") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) From f002024223b7e660b0c08852547ec8d1621b984c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jean-Micha=C3=ABl=20Celerier?= Date: Fri, 3 Jul 2026 19:00:39 -0400 Subject: [PATCH 12/17] touchdesigner: capture + diff TOP texture output (surfaces a binding gap) The TD runner now feeds a texture input (noiseTOP) to texture objects so filters cook, and captures each output TOP via numpyArray() -> {width, height} (+ a crc of TD's normalized readback). run_td_sweep routes texture objects through compare_textures with content="dims": TD hands textures back in its own converted representation, so dimensions are authoritative and the byte hash is informational (added that mode to golden_compare; the Python harness keeps strict native-byte hashing). This closes the gap where TD texture objects were cooked but never output- verified. It immediately surfaces a real TD-binding limitation: the Custom TOP binding uploads the texture at its true size but never overrides getOutputFormat, so TD's node resolution stays at the default 128x128 -- every texture object reports 128x128 regardless of its declared output size (tex_generator 512x512, TextureGenerator 480x270, the 16x16 filters, etc). The harness now flags these as MISMATCH with the size delta, the same way the channel-count bugs were surfaced on other backends. Binding fix deferred (its own change); the golden bool fix (has_tex_in as 0/1) is required because SPECS is embedded via json.dumps then exec()'d in TD, where JSON false/true are not valid Python. Co-Authored-By: Claude Fable 5 --- tooling/gen_tester_patches.py | 27 +++++++++++++++++++++++++++ tooling/golden_compare.py | 21 +++++++++++++++++---- tooling/td/run_td_sweep.py | 23 +++++++++++++++-------- 3 files changed, 59 insertions(+), 12 deletions(-) diff --git a/tooling/gen_tester_patches.py b/tooling/gen_tester_patches.py index 169a5eee..c9a11e94 100644 --- a/tooling/gen_tester_patches.py +++ b/tooling/gen_tester_patches.py @@ -472,6 +472,7 @@ def gen_td_runner(dumps): md = dump.get("metadatas", {}) ins = dump.get("inputs", []) is_top = any(port_kind(p) == "texture" for p in ins + dump.get("outputs", [])) + has_tex_in = any(port_kind(p) == "texture" for p in ins) pars = {sanitize_td_name(p["name"]): init_value(p) for p in ins if is_control(p) and "name" in p} # The registered opType is sanitize_td_name(C_NAME) -- the TD binding's @@ -480,6 +481,10 @@ def gen_td_runner(dumps): specs.append({"name": md.get("c_name", name), "type": sanitize_td_name(md.get("c_name", name)), "family": "TOP" if is_top else "CHOP", + # int, not bool: SPECS is embedded via json.dumps and then + # exec()'d in TD, where JSON `false`/`true` are not valid + # Python identifiers. + "has_tex_in": 1 if has_tex_in else 0, "file": name, "pars": pars}) return ( "# Run inside TouchDesigner (an Execute DAT). For each avendish Custom OP:\n" @@ -612,6 +617,17 @@ def gen_td_runner(dumps): " n.inputConnectors[0].connect(src)\n" " except Exception as e:\n" " cres['input_err'] = str(e)\n" + " if s.get('family') == 'TOP' and s.get('has_tex_in'):\n" + " try:\n" + " tsrc = container.create(noiseTOP, 'tsrc_' + uid)\n" + " try:\n" + " tsrc.par.resolutionw = 16; tsrc.par.resolutionh = 16\n" + " except Exception: pass\n" + " tsrc.cook(force=True)\n" + " if len(n.inputConnectors) > 0:\n" + " n.inputConnectors[0].connect(tsrc)\n" + " except Exception as e:\n" + " cres['tex_in_err'] = str(e)\n" " open(CUR, 'w').write(tag + ':precook')\n" " n.cook(force=True)\n" " cres['ok'] = not n.errors()\n" @@ -641,6 +657,17 @@ def gen_td_runner(dumps): " except Exception:\n" " pass\n" " cres['td_info'] = ti\n" + " td_tex = []\n" + " try:\n" + " if s.get('family') == 'TOP':\n" + " import zlib\n" + " a = n.numpyArray() # (h, w, ch), TD's normalized readback\n" + " hh = int(a.shape[0]); ww = int(a.shape[1])\n" + " td_tex.append({'width': ww, 'height': hh,\n" + " 'hash': int(zlib.crc32(a.tobytes()) & 0xffffffff)})\n" + " except Exception as e:\n" + " cres['tex_err'] = str(e)\n" + " cres['td_tex'] = td_tex\n" " n.destroy()\n" " except Exception as e:\n" " cres['exception'] = str(e)\n" diff --git a/tooling/golden_compare.py b/tooling/golden_compare.py index 91ac5f55..94696dcf 100644 --- a/tooling/golden_compare.py +++ b/tooling/golden_compare.py @@ -76,15 +76,23 @@ def compare_controls(named, golden, atol, rtol): f"{checked} ctrls maxdiff={maxdiff:.2e}@{worst} tol={tol:.2e}") -def compare_textures(textures, golden, atol, rtol): +def compare_textures(textures, golden, atol, rtol, content="hash"): """Diff captured texture outputs against the golden's recorded - {width, height, hash} (FNV-1a over the raw pixel bytes -- see fnv1a64). - `textures` is [{width, height, hash}] in port order.""" + {width, height, hash}. `textures` is [{width, height, hash}] in port order. + + content="hash" (default): dimensions AND the FNV-1a byte hash must match -- + valid only when the backend reads pixels back in the object's NATIVE byte + layout (the golden hashes native bytes; the Python binding does too). + content="dims": dimensions are authoritative, the hash is informational -- + for hosts (TouchDesigner, GStreamer) that hand back a normalized/converted + representation whose bytes never equal the native golden hash. Wrong output + SIZE is still a real failure and is caught.""" if not golden: return ("no-golden-texture", "") if not textures: return ("no-backend-texture", "") n = min(len(textures), len(golden)) + hashes_match = True for i in range(n): t, g = textures[i], golden[i] if (t.get("width"), t.get("height")) != (g.get("width"), g.get("height")): @@ -92,7 +100,12 @@ def compare_textures(textures, golden, atol, rtol): f"tex{i} size {t.get('width')}x{t.get('height')} " f"vs gold {g.get('width')}x{g.get('height')}") if t.get("hash") != g.get("hash"): - return ("MISMATCH", f"tex{i} content hash differs") + hashes_match = False + if content == "hash": + return ("MISMATCH", f"tex{i} content hash differs") + if content == "dims": + note = "content byte-identical" if hashes_match else "dims only" + return ("match", f"{n} textures ({note})") return ("match", f"{n} textures") diff --git a/tooling/td/run_td_sweep.py b/tooling/td/run_td_sweep.py index 165e534f..abca1cb9 100644 --- a/tooling/td/run_td_sweep.py +++ b/tooling/td/run_td_sweep.py @@ -34,7 +34,7 @@ sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) from golden_compare import (aggregate_case_verdicts, compare_audio, - compare_controls) + compare_controls, compare_textures) user32 = ctypes.windll.user32 if sys.platform == "win32" else None @@ -276,19 +276,27 @@ def _read(p): pass # Compare one captured case (td_out + td_info) against a golden case's # outputs. Prefer audio when the golden has audio output, else controls. - def compare_one(td_out, td_info, gout): + # A texture output surfaces as a TOP: the runner captures its + # width/height (+ a crc of TD's normalized readback) into td_tex. + # TD hands back a converted representation, so compare dimensions + # authoritatively and treat content as informational (content="dims"). + def compare_one(rc, gout): gaud = (gout or {}).get("audio") gctl = (gout or {}).get("controls") + gtex = (gout or {}).get("texture") if gaud: - return compare_audio(td_out, gaud, args.atol, args.rtol) + return compare_audio(rc.get("td_out"), gaud, args.atol, args.rtol) if gctl: named = {} - for e in (td_out or []): + for e in (rc.get("td_out") or []): if e.get("samples"): named[e["name"]] = e["samples"][0] - for k, val in (td_info or {}).items(): + for k, val in (rc.get("td_info") or {}).items(): named.setdefault(k, val) return compare_controls(named, gctl, args.atol, args.rtol) + if gtex: + return compare_textures(rc.get("td_tex"), gtex, args.atol, + args.rtol, content="dims") return ("no-golden-output", "") counts, mism = {}, [] @@ -305,12 +313,11 @@ def compare_one(td_out, td_info, gout): if ci >= len(gcases): continue gout = gcases[ci].get("outputs", {}) - v, d = compare_one(rc.get("td_out"), rc.get("td_info"), gout) + v, d = compare_one(rc, gout) verdicts.append((ci, v, d)) else: # old single-case schema (golden or result without `cases`) - v, d = compare_one(r.get("td_out"), r.get("td_info"), - g.get("outputs", {})) + v, d = compare_one(r, g.get("outputs", {})) verdicts.append((0, v, d)) v, detail = aggregate_case_verdicts(verdicts) counts[v] = counts.get(v, 0) + 1 From 4667241bebd462faec769316c5ba083694725eda Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jean-Micha=C3=ABl=20Celerier?= Date: Fri, 3 Jul 2026 19:03:40 -0400 Subject: [PATCH 13/17] gstreamer: capture + diff video texture output (surfaces a caps gap) Texture elements (video/x-raw RGBA) are now driven and diffed: a generator (Source/Video) runs to a -v fakesink and its output resolution is read from the negotiated caps; a filter is fed a 16x16 RGBA frame (the golden's synth input size) the same way. compare_textures(content="dims") compares dimensions authoritatively (gst hands pixels back converted, so the native byte hash isn't comparable). Result: the 2 texture generators (tex_generator 512x512, TextureGenerator 480x270) now MATCH their declared output size. The texture FILTERS surface a real binding gap: their sink pad negotiates 16x16 but the src pad fixates to 1x1 -- the gst texture-filter binding never ties output caps to the input resolution (src template is width/height [1,MAX], so GStreamer fixates to the minimum). Flagged as MISMATCH (1x1 vs golden), the analogue of TouchDesigner's missing getOutputFormat. Binding fix deferred. Sweep: 14 match, 8 MISMATCH (2 multi-bus, test_audio_frame, 5 texture filters), 2 no-audio-io, 88 no-element. Co-Authored-By: Claude Fable 5 --- tooling/run_gst_golden.py | 142 ++++++++++++++++++++++++++++---------- 1 file changed, 106 insertions(+), 36 deletions(-) diff --git a/tooling/run_gst_golden.py b/tooling/run_gst_golden.py index cda70526..4d0e37c4 100644 --- a/tooling/run_gst_golden.py +++ b/tooling/run_gst_golden.py @@ -39,7 +39,8 @@ 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_textures) import numpy as np @@ -62,22 +63,28 @@ def norm(name): def inspect_element(gst_bin, env, element): - """Return (properties, has_sink) for `element`, or (None, False) if it does - not exist. `has_sink` is False for a pure source (generator). Parses + """Return (properties, has_sink, media) for `element`, or (None, False, None) + if it does not exist. `has_sink` is False for a pure source (generator); + `media` is 'audio' or 'video' (from the pad caps). Parses `gst-inspect-1.0 `.""" exe = os.path.join(gst_bin, "gst-inspect-1.0.exe") try: p = subprocess.run([exe, element], capture_output=True, text=True, env=env, timeout=30) except Exception: - return (None, False) + return (None, False, None) if p.returncode != 0: - return (None, False) + return (None, False, None) props, in_props = set(), False has_sink = False + media = None for line in p.stdout.splitlines(): if re.match(r"^\s+SINK template:", line): has_sink = True + if "video/x-raw" in line: + media = "video" + elif "audio/x-raw" in line and media is None: + media = "audio" if line.startswith("Element Properties"): in_props = True continue @@ -87,7 +94,7 @@ def inspect_element(gst_bin, env, element): if m: props.add(m.group(1)) props -= {"name", "parent", "qos"} - return (props, has_sink) + return (props, has_sink, media) def prop_arg(name, value): @@ -123,8 +130,68 @@ def deinterleave(raw, nch): return [frames[:, c].tolist() for c in range(nch)] +def build_prop_tokens(props, gcase): + """gst-launch `name=value` tokens for the golden controls the element + actually exposes (matched by normalized name).""" + by_norm = {norm(p): p for p in props} + tokens = [] + for c in gcase.get("inputs", {}).get("controls", []): + real = by_norm.get(norm(c.get("name", ""))) + if real is None: + continue + tok = prop_arg(real, c.get("value")) + if tok: + tokens.append(tok) + return tokens + + +def parse_caps_dims(text): + """Pull the (width, height) of the negotiated fakesink caps out of + gst-launch -v output; fall back to the last width/height pair seen.""" + fakesink_dims, last_dims = None, None + for line in text.splitlines(): + m = re.search(r"width=\(int\)(\d+).*?height=\(int\)(\d+)", line) + if not m: + continue + last_dims = (int(m.group(1)), int(m.group(2))) + if "fakesink" in line.lower(): + fakesink_dims = last_dims + return fakesink_dims or last_dims + + +def run_texture_case(exe, env, element, props, has_sink, gcase, tmp): + """Run one texture case; return ([{width,height}], error). Reads the output + resolution from the negotiated caps (gst-launch -v). Content bytes are not + captured -- a host's readback format isn't the golden's native layout, so + dimensions are the authoritative cross-backend check.""" + prop_tokens = build_prop_tokens(props, gcase) + if not has_sink: + # Generator (Source/Video): output size is the element's own. + pipeline = [exe, "-v", element, *prop_tokens, "num-buffers=1", "!", + "fakesink"] + else: + # Filter: feed a 16x16 RGBA frame -- the size the golden synthesizes for + # texture inputs -- so a size-preserving filter yields 16x16 and a + # resampler yields its own size. + pipeline = [exe, "-v", + "videotestsrc", "num-buffers=1", "!", + "video/x-raw,format=RGBA,width=16,height=16", "!", + "videoconvert", "!", + element, *prop_tokens, "!", "fakesink"] + try: + p = subprocess.run(pipeline, capture_output=True, text=True, env=env, + timeout=60) + except subprocess.TimeoutExpired: + return (None, "timeout") + dims = parse_caps_dims((p.stdout or "") + "\n" + (p.stderr or "")) + if dims is None: + err = (p.stderr or p.stdout or "").strip().splitlines() + return (None, "no-caps: " + (err[-1] if err else "?")) + return ([{"width": dims[0], "height": dims[1], "hash": None}], None) + + def run_case(exe, env, element, props, has_sink, gcase, meta, tmp): - """Run one golden case through gst-launch; return (out_channels, error).""" + """Run one golden AUDIO case through gst-launch; return (out_channels, error).""" gin = gcase.get("inputs", {}).get("audio") or [] gout = gcase.get("outputs", {}).get("audio") or [] out_nch = len(gout) @@ -137,17 +204,7 @@ def run_case(exe, env, element, props, has_sink, gcase, meta, tmp): if os.path.exists(out_path): os.remove(out_path) - # Only set properties the element actually exposes (matched by normalized - # name; the binding lowercases the port symbol and dashes non-alnum chars). - by_norm = {norm(p): p for p in props} - prop_tokens = [] - for c in gcase.get("inputs", {}).get("controls", []): - real = by_norm.get(norm(c.get("name", ""))) - if real is None: - continue - tok = prop_arg(real, c.get("value")) - if tok: - prop_tokens.append(tok) + prop_tokens = build_prop_tokens(props, gcase) if not has_sink: # Pure source (generator): no input pad. Produce one block and capture. @@ -227,7 +284,7 @@ def main(): entry = {"name": c_name, "cases": []} report.append(entry) - props, has_sink = inspect_element(args.gst_bin, env, c_name) + props, has_sink, media = inspect_element(args.gst_bin, env, c_name) if props is None: entry["verdict"] = "no-element" counts["no-element"] = counts.get("no-element", 0) + 1 @@ -239,26 +296,39 @@ def main(): "outputs": g.get("outputs", {})}] verdicts = [] for ci, gcase in enumerate(gcases): - gout = gcase.get("outputs", {}).get("audio") or [] + gaud = gcase.get("outputs", {}).get("audio") or [] + gtex = gcase.get("outputs", {}).get("texture") or [] rec = {"index": ci} - if not gout: - # No audio output to diff -- gst has no headless control/texture - # read-back through gst-launch. + if media == "video" and gtex: + tex, err = run_texture_case(exe, env, c_name, props, has_sink, + gcase, args.tmp) + if err: + verdicts.append((ci, "error", err)) + rec["verdict"], rec["error"] = "error", err + else: + # TD/gst hand textures back converted, so dimensions are + # authoritative and the byte hash is informational. + v, d = compare_textures(tex, gtex, args.atol, args.rtol, + content="dims") + verdicts.append((ci, v, d)) + rec["verdict"], rec["detail"], rec["tex"] = v, d, tex + elif gaud: + out_ch, err = run_case(exe, env, c_name, props, has_sink, gcase, + g.get("meta", {}), args.tmp) + if err: + verdicts.append((ci, "error", err)) + rec["verdict"], rec["error"] = "error", err + else: + v, d = compare_audio( + [{"name": f"c{i}", "samples": s} + for i, s in enumerate(out_ch)], gaud, args.atol, args.rtol) + verdicts.append((ci, v, d)) + rec["verdict"], rec["detail"] = v, d + else: + # No audio/texture output to diff -- gst has no headless + # control read-back through gst-launch. verdicts.append((ci, "no-audio-io", "")) rec["verdict"] = "no-audio-io" - entry["cases"].append(rec) - continue - out_ch, err = run_case(exe, env, c_name, props, has_sink, gcase, - g.get("meta", {}), args.tmp) - if err: - verdicts.append((ci, "error", err)) - rec["verdict"], rec["error"] = "error", err - else: - v, d = compare_audio( - [{"name": f"c{i}", "samples": s} for i, s in enumerate(out_ch)], - gout, args.atol, args.rtol) - verdicts.append((ci, v, d)) - rec["verdict"], rec["detail"] = v, d entry["cases"].append(rec) v, detail = aggregate_case_verdicts(verdicts) From f7dbd507cbca4885e4974f0b62dbd5afb7222c72 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jean-Micha=C3=ABl=20Celerier?= Date: Fri, 3 Jul 2026 19:07:05 -0400 Subject: [PATCH 14/17] tooling/max: render audio via the Non-Real-Time driver (real sample diffs) The previous fallback concluded DSP can't run headless -- too pessimistic. Max's NonRealTime audio driver renders the MSP graph with no physical device, so the driver now enables it and captures real per-sample audio for the audio objects instead of instantiate-only. Recipe (Cycling '74 docs/forums), all scripted from the [js] via [adstatus]: - Overdrive ON (`; max overdrive 1`) AND Scheduler-in-Audio-Interrupt ON ([adstatus scheduler] 1) -- required, else scheduler objects (our poll Tasks, dsptime~) don't advance under NRT. - switch driver: [adstatus driver] <- NonRealTime (+ `; dsp setdriver`), then start; pin sr=44100 and sigvs=64 to the golden's one-block buffer. - DSP is manual under NRT and free-runs, so gate the read on [dsptime~] (samples rendered >= 64) rather than a wall-clock delay. - capture with [record~] into a 64-sample [buffer~] (loop off -> auto-stops at one block from a fresh instance), read out via the js Buffer object. - count~ needs a NONZERO gate signal (sig~ 1) or it stays stuck at index 0 and the object sees a constant input -- this was the last blocker to sample match. A cycle~ -> record~/snapshot~ self-test flips dspWorks true once NRT renders; the instantiate-only fallback remains for any host where it still won't. Scorecard over 112 goldens: 51 match (was 37) -- 14 audio objects now diff sample-accurately, incl. avnd_lowpass / avnd_minimal / avnd_helpers_lowpass (same DSP as the oracle), the oscr audio examples, per-bus/poly/presets, white_noise. The 14 audio mismatches are honest structural differences (channel -count divergence, Max mono-effect polyphony, control names with spaces), not false passes. Co-Authored-By: Claude Fable 5 --- tooling/max/avnd_max_driver.js | 139 ++++++++++++++++++++--------- tooling/max/avnd_max_driver.maxpat | 12 --- 2 files changed, 95 insertions(+), 56 deletions(-) diff --git a/tooling/max/avnd_max_driver.js b/tooling/max/avnd_max_driver.js index fbf3bad0..27f38073 100644 --- a/tooling/max/avnd_max_driver.js +++ b/tooling/max/avnd_max_driver.js @@ -25,7 +25,8 @@ include("avnd_max_config.js"); // -> AVND_CFG {report, breadcrumb, goldenDir} include("avnd_max_data.js"); // -> AVND_DATA [{name, kind, cases:[...]}] var FRAMES = 64; -var BUFSAMPS = 128; +var RATE = 44100; +var BUFSAMPS = 64; // == FRAMES: record~ (loop off) auto-stops after one block var CHANS = 2; var allLines = []; // raw report lines (prior + new), rewritten each object @@ -37,6 +38,10 @@ var capture = {}; // cap -> value (control capture, current case) 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? +var adDriver = null; // [adstatus driver] (persistent) for set + readback +var dsptimeObj = null; // [dsptime~] (persistent): samples rendered since dsp start +var setupObjs = []; // persistent NRT-setup objects (never cleaned up) +var pollTarget = 0, pollThen = "", pollAttempts = 0; // dsptime~ render gate function post_(s) { post(s + "\n"); } @@ -103,53 +108,87 @@ function defer(mode, ms) { function taskCallback(mode) { if (mode === "readStep") readStep(); else if (mode === "buildStep") buildStep(); + else if (mode === "selfTest") selfTestDSP(); else if (mode === "selfTestRead") selfTestRead(); + else if (mode === "poll") pollStep(); } -// One-shot: prove that global DSP + record~ actually capture a signal in this -// automated Max session. Writes the peak sample to .dbg. +// ---- Non-Real-Time DSP: render MSP with no physical audio device ----------- +// Max has no headless mode, but its NonRealTime audio driver computes the DSP +// graph on demand (no hardware). Required companions (else the scheduler -- +// which drives our poll Tasks / metro / dsptime~ -- does not advance under NRT): +// Overdrive ON and Scheduler-in-Audio-Interrupt ON. We also pin the sample rate +// and signal vector size to the golden's (64 frames @ 44100) so block-based +// objects see exactly the oracle's one-block buffer. +function setupAudioNRT() { + var p = this.patcher; + var X = 660; + stopDSP(); + try { messnamed("max", "overdrive", 1); } catch (e) {} + var over = p.newdefault(X, 300, "adstatus", "overdrive"); over.message(1); + var sched = p.newdefault(X, 326, "adstatus", "scheduler"); sched.message(1); // SIAI + adDriver = p.newdefault(X, 352, "adstatus", "driver"); + var adp = p.newdefault(X + 170, 352, "prepend", "curdrv"); + p.connect(adDriver, 0, adp, 0); + p.connect(adp, 0, p.getnamed("driver"), 0); + adDriver.message("NonRealTime"); // set the driver + try { messnamed("dsp", "setdriver", "NonRealTime"); } catch (e) {} + var sr = p.newdefault(X, 378, "adstatus", "sr"); sr.message(RATE); + var vs = p.newdefault(X, 404, "adstatus", "sigvs"); vs.message(FRAMES); + var io = p.newdefault(X, 430, "adstatus", "iovs"); io.message(FRAMES); + dsptimeObj = p.newdefault(X, 456, "dsptime~"); + var dtp = p.newdefault(X + 170, 456, "prepend", "dsptime"); + p.connect(dsptimeObj, 0, dtp, 0); + p.connect(dtp, 0, p.getnamed("driver"), 0); + setupObjs = [over, sched, adDriver, adp, sr, vs, io, dsptimeObj, dtp]; +} +function startDSP() { + var ds = this.patcher.getnamed("dspstart"); + if (ds) ds.message("bang"); + try { messnamed("dsp", "start"); } catch (e) {} +} +function stopDSP() { + var ds = this.patcher.getnamed("dspstop"); + if (ds) ds.message("bang"); + try { messnamed("dsp", "stop"); } catch (e) {} +} +// Gate on dsptime~ (samples rendered), NOT wall-clock: NRT free-runs, so poll +// until at least `target` samples have elapsed, then dispatch `then`. +function startPoll(target, then) { + pollTarget = target; pollThen = then; pollAttempts = 0; + defer("poll", 8); +} +function pollStep() { + pollAttempts++; + if (dsptimeObj) dsptimeObj.message("bang"); + var t = capture["dsptime"]; + if ((typeof t === "number" && t >= pollTarget) || pollAttempts > 120) { + taskCallback(pollThen); + return; + } + defer("poll", 8); +} + +// One-shot: prove NRT DSP + record~ actually capture a signal here. Sets the +// global dspWorks flag; the per-object audio path only activates when true. function selfTestDSP() { var p = this.patcher; capture = {}; var b = new Buffer("avout0"); for (var z = 0; z < BUFSAMPS; z++) b.poke(1, z, 0.0); - // report the current audio driver (adstatus reports on bang). - var ads = p.newdefault(400, 520, "adstatus", "driver"); - var adp = p.newdefault(600, 520, "prepend", "curdrv"); - p.connect(ads, 0, adp, 0); - p.connect(adp, 0, p.getnamed("driver"), 0); - ads.message("bang"); + if (adDriver) adDriver.message("bang"); // read back the driver name var osc = p.newdefault(40, 520, "cycle~", 441); - var snap = p.newdefault(40, 545, "snapshot~", 20); // live sample every 20ms + var snap = p.newdefault(40, 545, "snapshot~", 5); var pre = p.newdefault(200, 545, "prepend", "stpeak"); var rec = p.newdefault(40, 580, "record~", "avout0"); - cur = [ads, adp, osc, snap, pre, rec]; + cur = [osc, snap, pre, rec]; p.connect(osc, 0, snap, 0); p.connect(snap, 0, pre, 0); p.connect(pre, 0, this.patcher.getnamed("driver"), 0); p.connect(osc, 0, rec, 0); rec.message(1); startDSP(); - defer("selfTestRead", 800); -} -var dspDriverSet = false; -function startDSP() { - // No live audio device is guaranteed in an automated Max, so drive the graph - // with the Non-Real-Time driver (renders the DSP chain without hardware). - if (!dspDriverSet) { - var dv = this.patcher.getnamed("dspdriver"); - if (dv) dv.message("bang"); - try { messnamed("dsp", "driver", "NonRealTime"); } catch (e) {} - dspDriverSet = true; - } - var ds = this.patcher.getnamed("dspstart"); - if (ds) ds.message("bang"); - try { messnamed("dsp", "start"); } catch (e) {} -} -function stopDSP() { - var ds = this.patcher.getnamed("dspstop"); - if (ds) ds.message("bang"); - try { messnamed("dsp", "stop"); } catch (e) {} + startPoll(FRAMES, "selfTestRead"); } function selfTestRead() { var b = new Buffer("avout0"); @@ -166,9 +205,9 @@ function selfTestRead() { if (dbg.isopen) { dbg.position = dbg.eof; dbg.writeline("dsp_works=" + dspWorks); - dbg.writeline("selftest_record_peak=" + peak); - dbg.writeline("selftest_snapshot=" + (capture["stpeak"] === undefined ? "none" : capture["stpeak"])); dbg.writeline("audio_driver=" + (capture["curdrv"] === undefined ? "none" : capture["curdrv"])); + dbg.writeline("selftest_record_peak=" + peak); + dbg.writeline("selftest_dsptime=" + (capture["dsptime"] === undefined ? "none" : capture["dsptime"])); dbg.close(); } cleanup(); @@ -205,7 +244,9 @@ function run() { dbg.writeline("avout0.framecount=" + tb.framecount()); dbg.close(); } - selfTestDSP(); + setupAudioNRT(); + // give the driver switch a moment to apply before the self-test starts DSP. + defer("selfTest", 300); } function countKeys(o) { var n = 0; for (var k in o) n++; return n; } function sizeBuf(varname) { @@ -240,7 +281,10 @@ function buildStep() { defer("buildStep", 20); return; } - defer("readStep", kind === "audio" ? 250 : 50); + if (kind === "audio" && dspWorks) + startPoll(FRAMES, "readStep"); // gate the read on dsptime~ (samples rendered) + else + defer("readStep", 40); } function buildControl(obj, c) { @@ -295,8 +339,14 @@ function buildAudio(obj, c) { 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,... + var one = p.newdefault(20, 110, "sig~", 1); + cur.push(one); var cnt = p.newdefault(50, 140, "count~"); cur.push(cnt); + p.connect(one, 0, cnt, 0); var pack = (nin > 1) ? p.newdefault(300, 240, "mc.pack~", nin) : null; if (pack) cur.push(pack); for (var ch = 0; ch < nin && ch < CHANS; ch++) { @@ -395,17 +445,18 @@ function readAudio(obj, c, ci) { // ---- 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). var sel = "" + messagename; - if (sel.substring(0, 3) === "cap" || sel === "stpeak" || sel === "curdrv") { - var a = arrayfromargs(arguments); - // Prefer the first numeric arg (scalar control / signal value); fall back to - // the first arg as-is (string/enum/symbol outputs, driver name). - var v = (a.length > 0) ? a[0] : 0; - for (var i = 0; i < a.length; i++) { - if (typeof a[i] === "number") { v = a[i]; break; } - } - capture[sel] = v; + var a = arrayfromargs(arguments); + // Prefer the first numeric arg (scalar control / signal value); fall back to + // the first arg as-is (string/enum/symbol outputs, driver name). + var v = (a.length > 0) ? a[0] : 0; + for (var i = 0; i < a.length; i++) { + if (typeof a[i] === "number") { v = a[i]; break; } } + capture[sel] = v; } function msg_float(v) { /* bare float outputs shouldn't reach us (prepended) */ } function msg_int(v) { } diff --git a/tooling/max/avnd_max_driver.maxpat b/tooling/max/avnd_max_driver.maxpat index 0b32ebb5..236bbe1a 100644 --- a/tooling/max/avnd_max_driver.maxpat +++ b/tooling/max/avnd_max_driver.maxpat @@ -74,18 +74,6 @@ "patching_rect": [300.0, 80.0, 90.0, 22.0] } }, - { - "box": { - "id": "obj-dspdrv", - "maxclass": "message", - "text": "; dsp driver NonRealTime", - "varname": "dspdriver", - "numinlets": 2, - "numoutlets": 1, - "outlettype": [""], - "patching_rect": [300.0, 120.0, 200.0, 22.0] - } - }, { "box": { "id": "obj-avin0", From 077d2ec32344891aad2e6c9e978ee04ecd06be6b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jean-Micha=C3=ABl=20Celerier?= Date: Fri, 3 Jul 2026 19:30:51 -0400 Subject: [PATCH 15/17] gstreamer: fix texture filters collapsing src caps to 1x1 Texture-filter elements (GstBaseTransform) advertise width/height as [1,MAX] on both pads. Their fixate_caps only called gst_caps_fixate() on the output caps, so GStreamer picked the minimum of the range and the src pad negotiated to 1x1 regardless of the input resolution. A size-preserving filter (e.g. an RGBA passthrough fed a 16x16 frame) thus emitted 1x1. Fix fixate_caps to copy the fixed input width/height/framerate from the sink caps onto the output caps before fixating, so a filter defaults to its input resolution. Effects that genuinely change size (e.g. the downscaler in TextureFilterExample) still renegotiate concrete output caps at runtime in transform(). Co-Authored-By: Claude Fable 5 --- .../avnd/binding/gstreamer/texture_filter.hpp | 36 +++++++++++++++++-- 1 file changed, 33 insertions(+), 3 deletions(-) diff --git a/include/avnd/binding/gstreamer/texture_filter.hpp b/include/avnd/binding/gstreamer/texture_filter.hpp index 41fd1559..f47357cf 100644 --- a/include/avnd/binding/gstreamer/texture_filter.hpp +++ b/include/avnd/binding/gstreamer/texture_filter.hpp @@ -347,9 +347,39 @@ struct element : property_handler { GST_DEBUG_OBJECT(this, "fixate_caps direction=%d", direction); - // For dynamic resolution effects, we cannot predetermine output size - // Start with input size as baseline, actual size will be handled in transform - GstCaps* result = gst_caps_fixate(gst_caps_copy(othercaps)); + // Size-preserving default: the src pad template advertises width/height as + // [1,MAX], so a plain gst_caps_fixate() would collapse the output to 1x1 + // (the minimum of the range). Copy the fixed dimensions from the input + // (`caps`) onto the output (`othercaps`) so that a filter defaults to the + // input resolution. Effects that actually change size (e.g. a downscaler) + // still renegotiate the concrete output caps at runtime in transform(). + othercaps = gst_caps_make_writable(othercaps); + + if(gst_caps_get_size(caps) > 0 && gst_caps_get_size(othercaps) > 0) + { + GstStructure* ins = gst_caps_get_structure(caps, 0); + gint width = 0, height = 0, fps_n = 0, fps_d = 1; + const gboolean have_w = gst_structure_get_int(ins, "width", &width); + const gboolean have_h = gst_structure_get_int(ins, "height", &height); + const gboolean have_fps + = gst_structure_get_fraction(ins, "framerate", &fps_n, &fps_d); + + const guint n = gst_caps_get_size(othercaps); + for(guint i = 0; i < n; i++) + { + GstStructure* outs = gst_caps_get_structure(othercaps, i); + if(have_w) + gst_structure_fixate_field_nearest_int(outs, "width", width); + if(have_h) + gst_structure_fixate_field_nearest_int(outs, "height", height); + if(have_fps) + gst_structure_fixate_field_nearest_fraction( + outs, "framerate", fps_n, fps_d); + } + } + + // Fixate any remaining fields (format etc.) + GstCaps* result = gst_caps_fixate(othercaps); return result; } From 8c3b0c8ccf6bd92f825519003f8e7a13eec59798 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jean-Micha=C3=ABl=20Celerier?= Date: Fri, 3 Jul 2026 19:34:03 -0400 Subject: [PATCH 16/17] wrappers: set dynamic frame-port channel count from the buffer (fixes garbage output) A dynamic audio frame port (halp::dynamic_audio_frame -- FP* frame; int channels) carries its own channel count, which the per-frame process adapter READS (via get_channels) to know how many channels to copy in/out each frame. A host that runs the audio_channel_manager sets that count; one that only calls prepare()/process() -- the Python, GStreamer and Pd one-block golden drivers, and any minimal host -- leaves it 0. The adapter then copies zero channels and the object writes nothing, so the output buffer is returned UNINITIALISED (observed as ~1e28 garbage from the Python binding on avnd_test_audio_frame). Fix: in the adapter, when a dynamic frame port's channel count is unset (<=0), derive it from the buffer actually handed to process() (in.size()/out.size() minus the running offset). Hosts that already set it are unaffected (only the <=0 case is touched). Verified: avnd_test_audio_frame now produces out = gain*in and MATCHES the oracle on GStreamer (which exposes the gain control); the Python garbage is gone (its residual case1/2 mismatch is the separate inputs_is_type control-exposure limitation, not this bug). Full Python sweep unchanged at 61 match / 0 crash -- no regression to the other per-frame/per-sample objects. Co-Authored-By: Claude Fable 5 --- include/avnd/wrappers/process/per_frame_port.hpp | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/include/avnd/wrappers/process/per_frame_port.hpp b/include/avnd/wrappers/process/per_frame_port.hpp index fdfe42e0..24474a0c 100644 --- a/include/avnd/wrappers/process/per_frame_port.hpp +++ b/include/avnd/wrappers/process/per_frame_port.hpp @@ -127,6 +127,13 @@ struct process_adapter avnd::generic_audio_frame_port && avnd::dynamic_poly_audio_port) { + // A dynamic frame port carries its own channel count. A host that ran + // the audio_channel_manager has already set it; one that only calls + // prepare()/process() (e.g. the Python/GStreamer/Pd one-block drivers) + // leaves it 0, which would copy nothing and leave the output + // uninitialised. Derive it from the buffer we were handed. + if(field.channels <= 0) + field.channels = std::max(0, (int)in.size() - offset); const int ch = avnd::get_channels(field); if(offset + ch <= (int)this->m_input_frame_storage.size()) field.frame = this->m_input_frame_storage.data() + offset; @@ -143,6 +150,8 @@ struct process_adapter avnd::generic_audio_frame_port && avnd::dynamic_poly_audio_port) { + if(field.channels <= 0) + field.channels = std::max(0, (int)out.size() - offset); const int ch = avnd::get_channels(field); if(offset + ch <= (int)this->m_output_frame_storage.size()) field.frame = this->m_output_frame_storage.data() + offset; From 4c0774b19e60e0b5dbe70eb91a69c4e483ac86da Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jean-Micha=C3=ABl=20Celerier?= Date: Sat, 4 Jul 2026 13:05:20 -0400 Subject: [PATCH 17/17] touchdesigner: texture output is not verifiable headless (not a binding bug) Correction to the earlier 'surfaces a getOutputFormat gap' framing. Deep diagnosis (Info CHOP + warning/error/popup channels, downstream nullTOP pull, forced Output Resolution, pixel-encoded state, and a stock-TOP control probe) established that the all-black 128x128 TD texture readback is NOT an avendish bug: * the object produces correct output -- the Python binding (which calls operator() exactly as the TD binding's invoke_effect does) yields a real 512x512 texture for pyavnd_test_tex_generator; * the same invoke_effect + get_outputs path drives the WORKING CHOP binding; * built-in TOPs (noiseTOP/constantTOP) DO read back correctly headless via the identical numpyArray-after-cook method (control probe: 256x256, real content); * texture objects work in interactive TD. The only hypothesis consistent with all of the above: TouchDesigner does not render/flush a Custom OP's GPU uploadBuffer for numpyArray() readback in a scripted, no-viewer cook (forcing the node's Output Resolution to Custom yields no surface either). It is a headless-Custom-OP limitation, not a binding defect. So report TD texture outputs as 'no-headless-texture-readback' (an honest gap, like GStreamer's no-audio-io) instead of a false MISMATCH. The object contract is still covered: GStreamer DOES verify texture output (run_gst_golden.py), and the Python binding verifies it in-process. Plugin-side diagnostic scaffolding was reverted; this is the only surviving change. Co-Authored-By: Claude Fable 5 --- tooling/td/run_td_sweep.py | 24 +++++++++++++++++------- 1 file changed, 17 insertions(+), 7 deletions(-) diff --git a/tooling/td/run_td_sweep.py b/tooling/td/run_td_sweep.py index abca1cb9..82ed154b 100644 --- a/tooling/td/run_td_sweep.py +++ b/tooling/td/run_td_sweep.py @@ -34,7 +34,7 @@ sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) from golden_compare import (aggregate_case_verdicts, compare_audio, - compare_controls, compare_textures) + compare_controls) user32 = ctypes.windll.user32 if sys.platform == "win32" else None @@ -276,10 +276,20 @@ def _read(p): pass # Compare one captured case (td_out + td_info) against a golden case's # outputs. Prefer audio when the golden has audio output, else controls. - # A texture output surfaces as a TOP: the runner captures its - # width/height (+ a crc of TD's normalized readback) into td_tex. - # TD hands back a converted representation, so compare dimensions - # authoritatively and treat content as informational (content="dims"). + # + # TEXTURE OUTPUT IS NOT VERIFIABLE HEADLESS. Investigation (2026-07): + # a Custom TOP's GPU upload (createOutputBuffer/uploadBuffer) is NOT + # rendered/flushed for numpyArray() readback in a scripted, no-viewer + # cook -- every avendish TOP reads back as TD's empty 128x128 default + # (identical hash across all objects), and forcing the node's Output + # Resolution to Custom does not produce a surface. This is NOT an + # avendish bug: the object produces correct output (verified via the + # Python binding: pyavnd_test_tex_generator -> real 512x512), the same + # invoke_effect/get_outputs path drives the working CHOP binding, and + # built-in TOPs (noiseTOP/constantTOP) DO read back headless. So it is + # a TouchDesigner headless-Custom-OP limitation. Report it as an honest + # gap rather than a false MISMATCH. (GStreamer texture output IS verified + # -- see run_gst_golden.py -- so the object contract is still covered.) def compare_one(rc, gout): gaud = (gout or {}).get("audio") gctl = (gout or {}).get("controls") @@ -295,8 +305,8 @@ def compare_one(rc, gout): named.setdefault(k, val) return compare_controls(named, gctl, args.atol, args.rtol) if gtex: - return compare_textures(rc.get("td_tex"), gtex, args.atol, - args.rtol, content="dims") + return ("no-headless-texture-readback", + "TD renders Custom-OP GPU uploads only with a viewer") return ("no-golden-output", "") counts, mism = {}, []