From 64c9c76b918eabcba93b5b33cdc45e98f567fd34 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 1/5] 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 eaff3b06d97d6acfff22dfca32fade90bf14c57f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jean-Micha=C3=ABl=20Celerier?= Date: Fri, 3 Jul 2026 20:41:41 -0400 Subject: [PATCH 2/5] fix #153/#154: conformance fixes across the clap/vst3/vintage bindings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Debugged with cdb against clap-validator and the Steinberg validator; seven distinct defects, taking the examples from 53/85 fully passing clap-validator runs to 84/85: - clap: 32/64-bit sample format is a per-process() host decision (SUPPORTS_64BITS is capability, not contract): read whichever of data32/data64 the host filled; the adapters convert. Was: double processors dereferenced a null data64 on every float host. - clap: allocate float AND double conversion storage in start() (the float->double path had a null destination — the second crash class). - clap: null channel rows inside host buses are pointed at binding-owned zero/trash scratch (sized at activate) — no adapter dereferences null. - clap: note ports declared no dialects (preferred_dialect = 0 is invalid): supported = MIDI|CLAP, preferred MIDI. - clap: multi-bus plug-ins reported the plug-in-wide channel total and CLAP_PORT_STEREO on every port: each port now reports its own count. - clap: values-list controls (combos) reported min=max=0: 0..N-1 stepped. - clap+vst3: channel-port effects (mono .channel members — analyzers like essentia Entropy) used the *bus* introspection in bus_info, reporting zero buses (#154); use the channel introspection. The vst3 no-match default also derives its buses from the wrappers' channel counts now. - all three bindings: sample-accurate control ports get their per-buffer storage reserved at activate and cleared around process() — effects read the `values` arrays which were dangling (heap-garbage crash in SampleAccurateControls). - clap: host-callback members are never left as empty std::functions (bad_function_call terminates under -fno-exceptions): workers run inline, variable-channel requests are accepted as no-ops until CLAP port renegotiation is implemented. 107/107 tests pass. Remaining: avnd_test_midi_out (3 clap failures), avnd_test_audio_variable.vst3 (validator crash), param-only test objects under vst3 (no buses by design). Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_014Zm2k4dsLp2jMa47zyLcSZ --- include/avnd/binding/clap/audio_effect.hpp | 136 ++++++++++++++++-- include/avnd/binding/clap/bus_info.hpp | 44 ++++-- include/avnd/binding/vintage/audio_effect.hpp | 13 ++ include/avnd/binding/vst3/bus_info.hpp | 60 ++++++-- include/avnd/binding/vst3/component.hpp | 13 ++ 5 files changed, 235 insertions(+), 31 deletions(-) diff --git a/include/avnd/binding/clap/audio_effect.hpp b/include/avnd/binding/clap/audio_effect.hpp index cf7ca3f8..8152d70d 100644 --- a/include/avnd/binding/clap/audio_effect.hpp +++ b/include/avnd/binding/clap/audio_effect.hpp @@ -10,6 +10,7 @@ #include #include #include +#include #include #include #include @@ -126,6 +127,9 @@ struct SimpleAudioEffect : clap_plugin AVND_NO_UNIQUE_ADDRESS avnd_clap::audio_bus_info audio_busses; AVND_NO_UNIQUE_ADDRESS avnd::process_adapter processor; + + // Per-buffer storage backing sample-accurate control ports + AVND_NO_UNIQUE_ADDRESS avnd::control_storage control_buffers; AVND_NO_UNIQUE_ADDRESS midi_processor midi; float sample_rate{44100.}; @@ -147,6 +151,7 @@ struct SimpleAudioEffect : clap_plugin auto& p = *self(plugin); p.sample_rate = sample_rate; p.buffer_size = max_frames_count; + p.ensure_scratch(max_frames_count); p.start(); return true; @@ -190,6 +195,41 @@ struct SimpleAudioEffect : clap_plugin { avnd::init_controls(effect); } + + wire_effect_callbacks(); + } + + // Host-callback members on the effect must never be left as empty + // std::functions: calling one terminates the process under + // -fno-exceptions (bad_function_call cannot unwind). + void wire_effect_callbacks() + { + // Workers offload a job to the host; CLAP has no generic offloading + // hook wired here yet, so run the job inline (same result, no + // background thread). + if constexpr(avnd::has_worker) + { + for(auto& impl : effect.effects()) + { + using worker_t = std::decay_t; + impl.worker.request = [](auto&&... args) { + worker_t::work(std::forward(args)...); + }; + } + } + + // Runtime channel-count requests (variable_audio_bus): CLAP only + // renegotiates port layouts across a restart; accept and ignore until + // that is implemented rather than crash on an empty function. + for(auto state : effect.full_state()) + { + auto wire = [](auto& port) { + if constexpr(requires { port.request_channels = std::function{}; }) + port.request_channels = [](int) {}; + }; + avnd::for_each_field_ref(state.inputs, wire); + avnd::for_each_field_ref(state.outputs, wire); + } } void start() @@ -201,9 +241,17 @@ struct SimpleAudioEffect : clap_plugin .frames_per_buffer = buffer_size, .rate = sample_rate}; + // Hosts choose float or double per process() call (data32 / data64): + // conversion storage must exist for both source sample types. + processor.allocate_buffers(setup_info, float{}); processor.allocate_buffers(setup_info, double{}); effect.init_channels(setup_info.input_channels, setup_info.output_channels); + // Sample-accurate control ports need their per-buffer storage reserved + // before process() (else the effect reads unallocated `values` arrays). + if constexpr(sizeof(control_buffers) > 1) + control_buffers.reserve_space(effect, buffer_size); + // Setup buffers for storing MIDI messages if constexpr(midi_in_info::size > 0) { @@ -251,15 +299,52 @@ struct SimpleAudioEffect : clap_plugin } using samples_t = std::decay_t; + + // Hosts may hand buses whose channel rows are null (clap-validator + // does): point those at binding-owned scratch so no adapter shape ever + // dereferences null — silence in, discarded writes out. + ensure_scratch(process.frames_count); + for(int i = 0; i < in_N; i++) + if(!inputs[i]) + inputs[i] = zero_buffer(); + for(int i = 0; i < out_N; i++) + if(!outputs[i]) + outputs[i] = trash_buffer(); + processor.process( effect, avnd::span{inputs, std::size_t(in_N)}, avnd::span{outputs, std::size_t(out_N)}, process.frames_count); } + // Scratch rows substituted for null host channel pointers. Sized in + // activate(); the process-time call only allocates if a non-conformant + // host processes more frames than it activated for (or never activated). + void ensure_scratch(uint32_t frames) + { + if(m_zero64.size() < frames) + { + m_zero64.assign(frames, 0.); + m_trash64.resize(frames); + } + } + template + Sample* zero_buffer() noexcept + { + // A double row of N zeros is also a valid float row of 2N zeros. + return reinterpret_cast(m_zero64.data()); + } + template + Sample* trash_buffer() noexcept + { + return reinterpret_cast(m_trash64.data()); + } + std::vector m_zero64, m_trash64; + void process(const clap_process& process) { - // Clear the control out ports - // FIXME + // Clear the sample-accurate control out ports + if constexpr(sizeof(control_buffers) > 1) + control_buffers.clear_outputs(this->effect); // Clear the midi out ports midi.clear_outputs(this->effect); @@ -272,33 +357,52 @@ struct SimpleAudioEffect : clap_plugin int in_N = avnd::input_channels(2); int out_N = avnd::output_channels(2); - if constexpr(avnd::float_processor) + // SUPPORTS_64BITS is a per-process() negotiation: the host decides + // each call which of data32 / data64 it filled. Read what is actually + // there — the adapters convert between float and double effects and + // buffers as needed. + const bool host_64 = [&] { + if(process.audio_inputs_count + process.audio_outputs_count == 0) + return avnd::double_processor; + for(uint32_t b = 0; b < process.audio_inputs_count; b++) + if(process.audio_inputs[b].channel_count > 0 + && !process.audio_inputs[b].data64) + return false; + for(uint32_t b = 0; b < process.audio_outputs_count; b++) + if(process.audio_outputs[b].channel_count > 0 + && !process.audio_outputs[b].data64) + return false; + return true; + }(); + + if(host_64) { - auto inputs = (float**)alloca(sizeof(float*) * std::max(in_N, 1)); - auto outputs = (float**)alloca(sizeof(float*) * std::max(out_N, 1)); + auto inputs = (double**)alloca(sizeof(double*) * std::max(in_N, 1)); + auto outputs = (double**)alloca(sizeof(double*) * std::max(out_N, 1)); // Null the slots so the zero-filled-channels invariant substitutes // silent buffers rather than the effect dereferencing garbage. std::fill_n(inputs, in_N, nullptr); std::fill_n(outputs, out_N, nullptr); - process_impl<&clap_audio_buffer::data32>(process, inputs, in_N, outputs, out_N); + process_impl<&clap_audio_buffer::data64>(process, inputs, in_N, outputs, out_N); } - else if constexpr(avnd::double_processor) + else { - auto inputs = (double**)alloca(sizeof(double*) * std::max(in_N, 1)); - auto outputs = (double**)alloca(sizeof(double*) * std::max(out_N, 1)); + auto inputs = (float**)alloca(sizeof(float*) * std::max(in_N, 1)); + auto outputs = (float**)alloca(sizeof(float*) * std::max(out_N, 1)); std::fill_n(inputs, in_N, nullptr); std::fill_n(outputs, out_N, nullptr); - process_impl<&clap_audio_buffer::data64>(process, inputs, in_N, outputs, out_N); + process_impl<&clap_audio_buffer::data32>(process, inputs, in_N, outputs, out_N); } } // Process the output events process_out_events(process); - // Clear the control in ports - // FIXME + // Clear the sample-accurate control in ports + if constexpr(sizeof(control_buffers) > 1) + control_buffers.clear_inputs(this->effect); // Clear the midi in ports midi.clear_inputs(this->effect); @@ -433,6 +537,14 @@ struct SimpleAudioEffect : clap_plugin info->max_value = avnd::get_enum_choices_count() - 1; info->flags |= CLAP_PARAM_IS_STEPPED; } + else if constexpr(requires { std::size(range.values); }) + { + // Values-list controls (combo boxes...): the value is an index + // into range.values; there is no range.min / range.max. + info->min_value = 0; + info->max_value = double(std::size(range.values)) - 1.; + info->flags |= CLAP_PARAM_IS_STEPPED; + } else { if constexpr(requires { diff --git a/include/avnd/binding/clap/bus_info.hpp b/include/avnd/binding/clap/bus_info.hpp index 50e8ab26..956991b8 100644 --- a/include/avnd/binding/clap/bus_info.hpp +++ b/include/avnd/binding/clap/bus_info.hpp @@ -36,6 +36,11 @@ struct event_bus_info [&](const avnd::field_reflection& port) { copy_string(info.name, C::name()); }); + // process_in_events consumes both note events and raw MIDI; the + // ports are MIDI-message-based, so prefer that dialect. (A zero + // preferred_dialect is invalid and makes hosts reject the config.) + info.supported_dialects = CLAP_NOTE_DIALECT_MIDI | CLAP_NOTE_DIALECT_CLAP; + info.preferred_dialect = CLAP_NOTE_DIALECT_MIDI; return true; } else @@ -56,6 +61,8 @@ struct event_bus_info [&](const avnd::field_reflection& port) { copy_string(info.name, C::name()); }); + info.supported_dialects = CLAP_NOTE_DIALECT_MIDI | CLAP_NOTE_DIALECT_CLAP; + info.preferred_dialect = CLAP_NOTE_DIALECT_MIDI; return true; } else @@ -286,10 +293,15 @@ struct audio_bus_info = avnd::poly_array_sample_port ? (CLAP_AUDIO_PORT_SUPPORTS_64BITS | CLAP_AUDIO_PORT_PREFERS_64BITS) : 0; + // Each port reports its own channel count, not the plug-in total + // (a stereo main + stereo sidechain is two 2-channel ports, not + // two 4-channel ones). Dynamic buses default to stereo. + const int channels = avnd::channels_in_bus(); + info.channel_count = channels > 0 ? channels : 2; + info.port_type = info.channel_count == 1 ? CLAP_PORT_MONO + : info.channel_count == 2 ? CLAP_PORT_STEREO + : nullptr; }); - - info.channel_count = default_input_channel_count(); - info.port_type = CLAP_PORT_STEREO; info.in_place_pair = CLAP_INVALID_ID; // FIXME return true; @@ -311,10 +323,12 @@ struct audio_bus_info = avnd::poly_array_sample_port ? (CLAP_AUDIO_PORT_SUPPORTS_64BITS | CLAP_AUDIO_PORT_PREFERS_64BITS) : 0; + const int channels = avnd::channels_in_bus(); + info.channel_count = channels > 0 ? channels : 2; + info.port_type = info.channel_count == 1 ? CLAP_PORT_MONO + : info.channel_count == 2 ? CLAP_PORT_STEREO + : nullptr; }); - - info.channel_count = default_output_channel_count(); - info.port_type = CLAP_PORT_STEREO; info.in_place_pair = CLAP_INVALID_ID; // FIXME return true; @@ -393,8 +407,11 @@ struct audio_bus_info template struct audio_bus_info { - using input_refl = avnd::audio_bus_input_introspection; - using output_refl = avnd::audio_bus_output_introspection; + // Channel ports (mono `.channel` members), not bus ports: the bus + // introspection is empty for these, hiding the plug-in's real audio + // shape from hosts (issue #154). + using input_refl = avnd::audio_channel_input_introspection; + using output_refl = avnd::audio_channel_output_introspection; static constexpr int input_count() noexcept { return input_refl::size; } static constexpr int output_count() noexcept { return output_refl::size; } static constexpr int default_input_channel_count() noexcept { return input_count(); } @@ -408,7 +425,11 @@ struct audio_bus_info input_refl::for_nth_mapped( index, [&](const avnd::field_reflection& port) { - copy_string(info.name, C::name()); + // name() may be a static_string NTTP rather than string_view-like + if constexpr(requires { copy_string(info.name, C::name()); }) + copy_string(info.name, C::name()); + else + copy_string(info.name, std::string_view{C::name().value}); info.flags = avnd::mono_array_sample_port ? (CLAP_AUDIO_PORT_SUPPORTS_64BITS | CLAP_AUDIO_PORT_PREFERS_64BITS) @@ -433,7 +454,10 @@ struct audio_bus_info output_refl::for_nth_mapped( index, [&](const avnd::field_reflection& port) { - copy_string(info.name, C::name()); + if constexpr(requires { copy_string(info.name, C::name()); }) + copy_string(info.name, C::name()); + else + copy_string(info.name, std::string_view{C::name().value}); info.flags = avnd::mono_array_sample_port ? (CLAP_AUDIO_PORT_SUPPORTS_64BITS | CLAP_AUDIO_PORT_PREFERS_64BITS) diff --git a/include/avnd/binding/vintage/audio_effect.hpp b/include/avnd/binding/vintage/audio_effect.hpp index 1e735415..1c2c6165 100644 --- a/include/avnd/binding/vintage/audio_effect.hpp +++ b/include/avnd/binding/vintage/audio_effect.hpp @@ -12,6 +12,7 @@ #include #include #include +#include #include namespace vintage @@ -44,6 +45,9 @@ struct SimpleAudioEffect : vintage::Effect AVND_NO_UNIQUE_ADDRESS midi_processor midi; + // Per-buffer storage backing sample-accurate control ports + AVND_NO_UNIQUE_ADDRESS avnd::control_storage control_buffers; + float sample_rate{44100.}; int buffer_size{512}; vintage::ProcessPrecision precision = avnd::double_processor @@ -129,6 +133,11 @@ struct SimpleAudioEffect : vintage::Effect if constexpr(avnd::double_processor) processor.allocate_buffers(setup_info, double{}); + // Sample-accurate control ports need their per-buffer storage reserved + // before process() (else the effect reads unallocated `values` arrays). + if constexpr(sizeof(control_buffers) > 1) + control_buffers.reserve_space(effect, buffer_size); + // Setup buffers for storing MIDI messages if constexpr(midi_input_introspection::size > 0) { @@ -166,6 +175,8 @@ struct SimpleAudioEffect : vintage::Effect // Clear our midi outputs midi.clear_outputs(effect); + if constexpr(sizeof(control_buffers) > 1) + control_buffers.clear_outputs(effect); // Before processing starts, we copy all our atomics back into the struct controls.write(effect); @@ -178,6 +189,8 @@ struct SimpleAudioEffect : vintage::Effect // Clear our midi inputs midi.clear_inputs(effect); + if constexpr(sizeof(control_buffers) > 1) + control_buffers.clear_inputs(effect); } void event_input(const vintage::Events* evs) diff --git a/include/avnd/binding/vst3/bus_info.hpp b/include/avnd/binding/vst3/bus_info.hpp index fc7e7721..69047c88 100644 --- a/include/avnd/binding/vst3/bus_info.hpp +++ b/include/avnd/binding/vst3/bus_info.hpp @@ -157,47 +157,86 @@ default_speaker_arrangement(int channels, Steinberg::Vst::SpeakerArrangement& ar // thus either the number of channels is set explicitely, or we default to 2 as // that is the expectation for VSTs. +// Fallback: no specialized bus concept matched. The core wrappers may +// still see audio channels the concepts above don't cover (array-style +// value ports carrying a .channel, analyzers without audio outputs...): +// declare one main bus per direction that actually has channels, so +// hosts/validators see the plug-in's real audio shape instead of +// "no buses at all". Effects with no audio in either direction keep +// declaring nothing (parameter-only utility objects). template struct audio_bus_info { - static constexpr int inputCount() noexcept { return 0; } - static constexpr int outputCount() noexcept { return 0; } - static constexpr int defaultInputChannelCount() noexcept { return 0; } - static constexpr int defaultOutputChannelCount() noexcept { return 0; } + static constexpr int inputCount() noexcept + { + return avnd::input_channels(0) > 0 ? 1 : 0; + } + static constexpr int outputCount() noexcept + { + return avnd::output_channels(0) > 0 ? 1 : 0; + } + static constexpr int defaultInputChannelCount() noexcept + { + return avnd::input_channels(0); + } + static constexpr int defaultOutputChannelCount() noexcept + { + return avnd::output_channels(0); + } static Steinberg::tresult inputInfo(Steinberg::int32 index, Steinberg::Vst::BusInfo& info) { + if(index == 0 && inputCount() > 0) + { + info.channelCount = defaultInputChannelCount(); + setStr(info.name, u16_str "In"); + info.busType = Steinberg::Vst::BusTypes::kMain; + return Steinberg::kResultTrue; + } return Steinberg::kInvalidArgument; } static Steinberg::tresult outputInfo(Steinberg::int32 index, Steinberg::Vst::BusInfo& info) { + if(index == 0 && outputCount() > 0) + { + info.channelCount = defaultOutputChannelCount(); + setStr(info.name, u16_str "Out"); + info.busType = Steinberg::Vst::BusTypes::kMain; + return Steinberg::kResultTrue; + } return Steinberg::kInvalidArgument; } static Steinberg::tresult inputArrangement(Steinberg::int32 index, Steinberg::Vst::SpeakerArrangement& arr) { + if(index == 0 && inputCount() > 0) + return default_speaker_arrangement(defaultInputChannelCount(), arr); return Steinberg::kInvalidArgument; } static Steinberg::tresult outputArrangement(Steinberg::int32 index, Steinberg::Vst::SpeakerArrangement& arr) { + if(index == 0 && outputCount() > 0) + return default_speaker_arrangement(defaultOutputChannelCount(), arr); return Steinberg::kInvalidArgument; } static Steinberg::tresult activateInput(Steinberg::int32 index, Steinberg::TBool state) { - return Steinberg::kInvalidArgument; + return (index == 0 && inputCount() > 0) ? Steinberg::kResultTrue + : Steinberg::kInvalidArgument; } static Steinberg::tresult activateOutput(Steinberg::int32 index, Steinberg::TBool state) { - return Steinberg::kInvalidArgument; + return (index == 0 && outputCount() > 0) ? Steinberg::kResultTrue + : Steinberg::kInvalidArgument; } Steinberg::tresult setBusArrangements( @@ -206,7 +245,7 @@ struct audio_bus_info Steinberg::int32 numOuts) { using namespace Steinberg; - if(numIns == 0 && numOuts == 0) + if(numIns == inputCount() && numOuts == outputCount()) return kResultTrue; return kResultFalse; } @@ -751,8 +790,11 @@ struct audio_bus_info template struct audio_bus_info { - using input_refl = avnd::audio_bus_input_introspection; - using output_refl = avnd::audio_bus_output_introspection; + // Channel ports (mono `.channel` members), not bus ports: the bus + // introspection is empty for these — that made analyzers such as + // essentia Entropy report zero buses (issue #154). + using input_refl = avnd::audio_channel_input_introspection; + using output_refl = avnd::audio_channel_output_introspection; static constexpr int inputCount() noexcept { return input_refl::size; } static constexpr int outputCount() noexcept { return output_refl::size; } static constexpr int defaultInputChannelCount() noexcept { return inputCount(); } diff --git a/include/avnd/binding/vst3/component.hpp b/include/avnd/binding/vst3/component.hpp index a5932998..e9be4317 100644 --- a/include/avnd/binding/vst3/component.hpp +++ b/include/avnd/binding/vst3/component.hpp @@ -10,6 +10,7 @@ #include #include #include +#include #include namespace stv3 @@ -76,6 +77,9 @@ struct Component final AVND_NO_UNIQUE_ADDRESS avnd::midi_storage midi; + // Per-buffer storage backing sample-accurate control ports + AVND_NO_UNIQUE_ADDRESS avnd::control_storage control_buffers; + AVND_NO_UNIQUE_ADDRESS stv3::audio_bus_info audio_busses; AVND_NO_UNIQUE_ADDRESS stv3::event_bus_info event_busses; @@ -302,6 +306,11 @@ struct Component final processor.allocate_buffers(setup_info, float{}); processor.allocate_buffers(setup_info, double{}); + // Sample-accurate control ports need their per-buffer storage reserved + // before process() (else the effect reads unallocated `values` arrays). + if constexpr(sizeof(control_buffers) > 1) + control_buffers.reserve_space(effect, newSetup.maxSamplesPerBlock); + effect.init_channels( audio_busses.runtime_input_channel_count, audio_busses.runtime_output_channel_count); @@ -529,6 +538,8 @@ struct Component final // Clear outputs this->midi.clear_outputs(effect); + if constexpr(sizeof(control_buffers) > 1) + control_buffers.clear_outputs(effect); processControls(data); processEvents(data); @@ -541,6 +552,8 @@ struct Component final // Clear inputs this->midi.clear_inputs(effect); + if constexpr(sizeof(control_buffers) > 1) + control_buffers.clear_inputs(effect); return kResultOk; } From 6bca5942c4e8953b9d79bd362bae698b14982fe6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jean-Micha=C3=ABl=20Celerier?= Date: Fri, 3 Jul 2026 20:52:26 -0400 Subject: [PATCH 3/5] wrappers: install safe fallbacks for host-callback members everywhere MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit worker.request and variable_audio_bus::request_channels are std::function members the effect calls expecting the binding to have wired them; left empty, the call terminates the process under -fno-exceptions (bad_function_call cannot unwind — this crashed TestWorker and TestAudioVariableChannels under both clap-validator and the Steinberg validator). New avnd::wire_fallback_callbacks (wrappers/prepare.hpp) runs workers inline and accepts channel requests as no-ops; clap, vst3 and vintage call it at construction, richer per-binding wiring can override. Final validator status: clap 84/85 examples fully pass (the one failure is a clap-validator bug — note_ports output ports are queried with is_input=true, upstream report pending); Steinberg validator: zero crashes, all audio-bearing plug-ins pass all tests; the 58 remaining single-failure objects are parameter-only test utilities that export no buses by design. 107/107 tests. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_014Zm2k4dsLp2jMa47zyLcSZ --- include/avnd/binding/clap/audio_effect.hpp | 38 ++-------------- include/avnd/binding/vintage/audio_effect.hpp | 5 +++ include/avnd/binding/vst3/component.hpp | 5 +++ include/avnd/wrappers/prepare.hpp | 45 +++++++++++++++++++ 4 files changed, 59 insertions(+), 34 deletions(-) diff --git a/include/avnd/binding/clap/audio_effect.hpp b/include/avnd/binding/clap/audio_effect.hpp index 8152d70d..506cac3e 100644 --- a/include/avnd/binding/clap/audio_effect.hpp +++ b/include/avnd/binding/clap/audio_effect.hpp @@ -12,6 +12,7 @@ #include #include #include +#include #include #include #include @@ -196,40 +197,9 @@ struct SimpleAudioEffect : clap_plugin avnd::init_controls(effect); } - wire_effect_callbacks(); - } - - // Host-callback members on the effect must never be left as empty - // std::functions: calling one terminates the process under - // -fno-exceptions (bad_function_call cannot unwind). - void wire_effect_callbacks() - { - // Workers offload a job to the host; CLAP has no generic offloading - // hook wired here yet, so run the job inline (same result, no - // background thread). - if constexpr(avnd::has_worker) - { - for(auto& impl : effect.effects()) - { - using worker_t = std::decay_t; - impl.worker.request = [](auto&&... args) { - worker_t::work(std::forward(args)...); - }; - } - } - - // Runtime channel-count requests (variable_audio_bus): CLAP only - // renegotiates port layouts across a restart; accept and ignore until - // that is implemented rather than crash on an empty function. - for(auto state : effect.full_state()) - { - auto wire = [](auto& port) { - if constexpr(requires { port.request_channels = std::function{}; }) - port.request_channels = [](int) {}; - }; - avnd::for_each_field_ref(state.inputs, wire); - avnd::for_each_field_ref(state.outputs, wire); - } + // Safe no-op handlers for worker.request / request_channels members: + // an empty std::function call terminates under -fno-exceptions. + avnd::wire_fallback_callbacks(effect); } void start() diff --git a/include/avnd/binding/vintage/audio_effect.hpp b/include/avnd/binding/vintage/audio_effect.hpp index 1c2c6165..55029aa9 100644 --- a/include/avnd/binding/vintage/audio_effect.hpp +++ b/include/avnd/binding/vintage/audio_effect.hpp @@ -13,6 +13,7 @@ #include #include #include +#include #include namespace vintage @@ -100,6 +101,10 @@ struct SimpleAudioEffect : vintage::Effect buffer_size = request(HostOpcodes::GetBlockSize, 0, 0, nullptr, 0.f); /// Read the initial state of the controls + // Safe no-op handlers for worker.request / request_channels members: + // an empty std::function call terminates under -fno-exceptions. + avnd::wire_fallback_callbacks(effect); + if constexpr(avnd::has_inputs) { // First the default value diff --git a/include/avnd/binding/vst3/component.hpp b/include/avnd/binding/vst3/component.hpp index e9be4317..88de85a6 100644 --- a/include/avnd/binding/vst3/component.hpp +++ b/include/avnd/binding/vst3/component.hpp @@ -11,6 +11,7 @@ #include #include #include +#include #include namespace stv3 @@ -106,6 +107,10 @@ struct Component final // First the default value avnd::init_controls(effect); + + // Safe no-op handlers for worker.request / request_channels members: + // an empty std::function call terminates under -fno-exceptions. + avnd::wire_fallback_callbacks(effect); } virtual ~Component() { } diff --git a/include/avnd/wrappers/prepare.hpp b/include/avnd/wrappers/prepare.hpp index 687eaf89..399894e6 100644 --- a/include/avnd/wrappers/prepare.hpp +++ b/include/avnd/wrappers/prepare.hpp @@ -2,10 +2,14 @@ /* SPDX-License-Identifier: GPL-3.0-or-later */ +#include #include #include +#include #include +#include + namespace avnd { struct process_setup @@ -29,6 +33,47 @@ struct process_setup double model_to_samples{1.}; }; +// Host-callback members on the effect (worker.request, request_channels...) +// must never be left as empty std::functions: calling one terminates the +// process under -fno-exceptions. Bindings without a richer implementation +// call this to install safe fallbacks; bindings that support a concept +// natively wire their own handler afterwards. +template +void wire_fallback_callbacks(avnd::effect_container& implementation) +{ + // Workers offload a job to the host: run it inline (same result, no + // background thread). + if constexpr(avnd::has_worker) + { + for(auto& impl : implementation.effects()) + { + using worker_t = std::decay_t; + impl.worker.request = [](auto&&... args) { + worker_t::work(std::forward(args)...); + }; + } + } + + // Runtime channel-count requests (variable_audio_bus): most plug-in APIs + // only renegotiate layouts across a restart; accept and ignore. + if constexpr(avnd::has_inputs || avnd::has_outputs) + { + for(auto state : implementation.full_state()) + { + auto wire = [](auto& port) { + if constexpr(requires { + port.request_channels = std::function{}; + }) + port.request_channels = [](int) {}; + }; + if constexpr(avnd::has_inputs) + avnd::for_each_field_ref(state.inputs, wire); + if constexpr(avnd::has_outputs) + avnd::for_each_field_ref(state.outputs, wire); + } + } +} + template void prepare(avnd::effect_container& implementation, process_setup setup) { From de2725ce2aac7ccc6dae089285cfa67697f37ba9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jean-Micha=C3=ABl=20Celerier?= Date: Mon, 6 Jul 2026 08:51:53 -0400 Subject: [PATCH 4/5] cmake: default FMT_MODULE=OFF so fmt >= 12 doesn't require a module toolchain Co-Authored-By: Claude Fable 5 --- cmake/avendish.dependencies.cmake | 3 +++ 1 file changed, 3 insertions(+) diff --git a/cmake/avendish.dependencies.cmake b/cmake/avendish.dependencies.cmake index 34ef9305..b1daf02a 100644 --- a/cmake/avendish.dependencies.cmake +++ b/cmake/avendish.dependencies.cmake @@ -1,6 +1,9 @@ include(FetchContent) if(NOT TARGET fmt::fmt AND NOT TARGET fmt::fmt_header_only) + # fmt >= 12 auto-enables its C++-modules target when CMake supports it and + # then hard-requires a module-scanning toolchain; we never want that here. + set(FMT_MODULE OFF CACHE BOOL "Build fmt as a C++ module") FetchContent_Declare( fmt GIT_REPOSITORY "https://github.com/fmtlib/fmt" From dcf66fa32f95ab72134bd45b4a19a84d1dcd1ae9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jean-Micha=C3=ABl=20Celerier?= Date: Mon, 6 Jul 2026 09:43:00 -0400 Subject: [PATCH 5/5] cmake: fetch CLAP headers and (optionally) the VST3 SDK; fix SDK link order MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - CLAP: the "SDK" is plain C headers — fetch them when find_path fails. - VST3: AVND_FETCH_VST3_SDK=ON fetches v3.7.14_build_55 (plug-in library only) when no VST3_SDK_ROOT is given. - Declare base -> pluginterfaces in the fetched SDK: base uses FUID/FUnknown symbols but never links pluginterfaces, so with a single-pass linker (GNU ld) every plug-in shipped with undefined Steinberg::FUID::* symbols and failed to load. Co-Authored-By: Claude Fable 5 --- cmake/avendish.clap.cmake | 18 ++++++++++++++++++ cmake/avendish.vst3.cmake | 34 ++++++++++++++++++++++++++++++++++ 2 files changed, 52 insertions(+) diff --git a/cmake/avendish.clap.cmake b/cmake/avendish.clap.cmake index dcb0338a..be63f90d 100644 --- a/cmake/avendish.clap.cmake +++ b/cmake/avendish.clap.cmake @@ -7,6 +7,24 @@ if(DEFINED AVND_ENABLE_CLAP AND NOT AVND_ENABLE_CLAP) endif() find_path(CLAP_HEADER NAMES clap/clap.h) +if(NOT CLAP_HEADER) + # The CLAP "SDK" is a set of plain C headers: fetch them when absent. + include(FetchContent) + FetchContent_Declare( + avnd_clap_headers + GIT_REPOSITORY "https://github.com/free-audio/clap" + GIT_TAG 1.2.6 + GIT_PROGRESS true + ) + FetchContent_GetProperties(avnd_clap_headers) + if(NOT avnd_clap_headers_POPULATED) + FetchContent_Populate(avnd_clap_headers) + endif() + if(EXISTS "${avnd_clap_headers_SOURCE_DIR}/include/clap/clap.h") + set(CLAP_HEADER "${avnd_clap_headers_SOURCE_DIR}/include" CACHE PATH "clap headers" FORCE) + endif() +endif() + if(NOT CLAP_HEADER) message(STATUS "Clap not found, skipping bindings...") diff --git a/cmake/avendish.vst3.cmake b/cmake/avendish.vst3.cmake index 3a1ad192..91fe1f8c 100644 --- a/cmake/avendish.vst3.cmake +++ b/cmake/avendish.vst3.cmake @@ -7,6 +7,32 @@ if(DEFINED AVND_ENABLE_VST3 AND NOT AVND_ENABLE_VST3) endif() set(VST3_SDK_ROOT "" CACHE PATH "VST3 SDK path") +option(AVND_FETCH_VST3_SDK "Fetch the VST3 SDK when VST3_SDK_ROOT is not set" OFF) + +if(NOT VST3_SDK_ROOT AND AVND_FETCH_VST3_SDK) + include(FetchContent) + FetchContent_Declare( + avnd_vst3sdk + GIT_REPOSITORY "https://github.com/steinbergmedia/vst3sdk" + GIT_TAG v3.7.14_build_55 + GIT_SUBMODULES base cmake pluginterfaces public.sdk + GIT_PROGRESS true + ) + FetchContent_GetProperties(avnd_vst3sdk) + if(NOT avnd_vst3sdk_POPULATED) + FetchContent_Populate(avnd_vst3sdk) + endif() + if(EXISTS "${avnd_vst3sdk_SOURCE_DIR}/pluginterfaces/base/funknown.h") + set(VST3_SDK_ROOT "${avnd_vst3sdk_SOURCE_DIR}" CACHE PATH "VST3 SDK path" FORCE) + # Plug-in library only: no samples, hosting tools, validator or vstgui + set(SMTG_ENABLE_VST3_PLUGIN_EXAMPLES OFF CACHE BOOL "" FORCE) + set(SMTG_ENABLE_VST3_HOSTING_EXAMPLES OFF CACHE BOOL "" FORCE) + set(SMTG_ENABLE_VSTGUI_SUPPORT OFF CACHE BOOL "" FORCE) + set(SMTG_CREATE_PLUGIN_LINK OFF CACHE BOOL "" FORCE) + set(SMTG_RUN_VST_VALIDATOR OFF CACHE BOOL "" FORCE) + endif() +endif() + if(NOT VST3_SDK_ROOT) function(avnd_make_vst3) endfunction() @@ -53,6 +79,14 @@ endif() add_subdirectory("${VST3_SDK_ROOT}" "${CMAKE_BINARY_DIR}/vst3_sdk") +# The SDK's `base` target uses pluginterfaces symbols (FUID, FUnknown, ...) +# but never declares the dependency; with a single-pass linker (GNU ld) base +# then ends up after pluginterfaces on the link line and the plug-ins ship +# with undefined Steinberg::FUID::* symbols. Declare it for them. +if(TARGET base AND TARGET pluginterfaces) + target_link_libraries(base PUBLIC pluginterfaces) +endif() + function(avnd_make_vst3) cmake_parse_arguments(AVND "" "TARGET;MAIN_FILE;MAIN_CLASS;C_NAME" "" ${ARGN}) set(AVND_FX_TARGET "${AVND_TARGET}_vst3")