From a749b21e122831816203523dda8b4373a3852008 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jean-Micha=C3=ABl=20Celerier?= Date: Sat, 4 Jul 2026 21:50:09 -0400 Subject: [PATCH] python: audio-path & control fixes, host callbacks, obj.inputs/outputs Python binding improvements previously bundled in the channel-safety PR: - audio.hpp: fix per-sample heap corruption, initialise controls, wire the host callback path, correct the monophonic output channel count. - processor.hpp: wire host callbacks in the process() path and expose obj.inputs / obj.outputs accessor proxies (named p/out_p for unnamed ports). - avendish.cmake: dispatch the python backend for texture and buffer object types. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01JH7HS58wPSA22HztHZsakd --- cmake/avendish.cmake | 2 + include/avnd/binding/python/audio.hpp | 71 +++++++- include/avnd/binding/python/processor.hpp | 191 +++++++++++++++++++++- 3 files changed, 258 insertions(+), 6 deletions(-) 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..29fbc873 100644 --- a/include/avnd/binding/python/audio.hpp +++ b/include/avnd/binding/python/audio.hpp @@ -2,7 +2,14 @@ /* SPDX-License-Identifier: GPL-3.0-or-later */ +#include +#include #include +#include +#include +#include +#include +#include #include #include #include @@ -41,10 +48,21 @@ 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)); + // Monophonic adapters process in.size() channels and write as many out, so + // the output must match the input count; sizing it by output_channels() + // (e.g. 1 for a sample-port object) underflows out_ptrs -> heap corruption. + 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); + + // Default the container's control storage before copying instance values: + // nested inputs/outputs-TYPE ports live in the container, not on `T`, so the + // copy below can't reach them and they'd otherwise be read uninitialised. + avnd::init_controls(c); + for(auto&& st : c.full_state()) { if constexpr(std::is_copy_assignable_v) @@ -53,6 +71,40 @@ py::array_t run_audio( st.inputs = avnd::get_inputs(self); } + // Install no-op/inline handlers so host callables aren't empty when + // prepare()/process() runs them. + 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 +115,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 +142,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..d182da77 100644 --- a/include/avnd/binding/python/processor.hpp +++ b/include/avnd/binding/python/processor.hpp @@ -32,6 +32,83 @@ namespace python { +// Inline worker hand-off so worker objects have a valid callable. A free +// function (not an in-lambda if constexpr) so MSVC discards it cleanly for +// worker-less types. +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)...); + }; + } +} + +// The process() binding calls operator() directly with no host setup, so wire +// obj's host callables to no-ops and give CPU-texture inputs a defined empty +// state -- otherwise an unset std::function or uninitialised texture crashes. +template +void wire_host_callbacks(U& obj) +{ + wire_worker(obj); + + // These callbacks live on get_inputs/get_outputs ports, reachable on a bare + // instance only for VALUE-member ports; nested-TYPE ports are driven through + // run_audio (wired there) and never bind process(), so nothing to do here. + 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 are uninitialised (bytes/width/height); a non-null + // garbage bytes pointer makes bytes==nullptr guards read bad dims -> 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); + } +} + +// Proxies for obj.inputs / obj.outputs: one property per port so an input and +// output sharing a name (obj.inputs.A vs obj.outputs.A) don't collide. +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(); @@ -53,16 +130,40 @@ inline const char* c_str(const std::array& v) noexcept namespace py = pybind11; +// Unnamed ports would all collide on "unnamed" -- fall back to positional +// p (outputs prefixed "out_") to keep them distinct. +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 @@ -679,6 +780,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) { @@ -735,7 +885,12 @@ struct processor { m.doc() = c_str(avnd::get_description()); - class_def.def(py::init<>()); + // Wire the worker hand-off inline so it's never an empty std::function. + class_def.def(py::init([] { + auto t = std::make_unique(); + wire_host_callbacks(*t); + return t; + })); if constexpr(requires { T{}(); }) { class_def.def("process", &T::operator()); @@ -765,6 +920,32 @@ struct processor [this, &m](auto a) { setup_callback(a, m); }); } + // Nested accessors obj.inputs. / obj.outputs.: the flat + // attributes above shadow each other when an input/output share a symbol, + // the proxies don't. + { + 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); });