Channel-safety: zero-filled channels invariant + audio-analyzer crash fix#143
Closed
jcelerier wants to merge 17 commits into
Closed
Channel-safety: zero-filled channels invariant + audio-analyzer crash fix#143jcelerier wants to merge 17 commits into
jcelerier wants to merge 17 commits into
Conversation
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) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011s7huWR2wFsLFiMJPjx1z2
…ut 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) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011s7huWR2wFsLFiMJPjx1z2
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) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011s7huWR2wFsLFiMJPjx1z2
…bject 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 <noreply@anthropic.com>
…se 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<c_name>, 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<i> for inputs, out_p<i> 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 <noreply@anthropic.com>
… patch The addon packagers (avendish.packaging.cmake) already bundle the auto-generated help/example artifacts per backend: Max .maxhelp into <pkg>/help/, TouchDesigner .example.py into <pkg>/examples/, Godot .tscn into <pkg>/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 -> <pkg>/help/<c_name>.maxhelp (next to the external and the existing .maxref.xml; Max searches help/). - Pd (avnd_create_pd_package): copy AVND_PD_HELP -> <pkg>/<c_name>-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 <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 <noreply@anthropic.com>
… 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<T>() 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 <noreply@anthropic.com>
…puts 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.<name> / obj.outputs.<name>: 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 <noreply@anthropic.com>
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
-> <element prop=value ...> -> 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 <noreply@anthropic.com>
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<i>] back into the js. Controls are set by INLET INDEX (proxy inlets), not name, since unnamed ports' golden names are positional "p<i>" 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 <noreply@anthropic.com>
…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 <noreply@anthropic.com>
…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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
…iffs) 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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
… 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 <noreply@anthropic.com>
…ng 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 <noreply@anthropic.com>
702d3ef to
4c0774b
Compare
This was referenced Jul 5, 2026
Member
Author
|
Closing in favor of concern-separated PRs. This PR bundled ~5 unrelated concerns; it has been split into:
The zero-fill channel invariant ( |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes crashes from channel handling, found while investigating the
avnd_essentia_entropyTouchDesigner crash. Branched off currentmain.1. Core invariant: always hand the effect zero-filled channels (never null)
The process adapters (
process/andprocess_bus/, per-channel and poly) already had storage to point missing channels at silent scratch buffers, but it was gated behind a commented-outAVND_ENABLE_SAFE_BUFFER_STORAGE, so by default a host supplying fewer channels than the object declares leftbus.channel = nullptr— any effect that dereferences its channel then crashes. Enabled by default (opt out with-DAVND_ENABLE_SAFE_BUFFER_STORAGE=0);process_bus/base.hppincludesprocess/base.hpp, so one definition covers every backend.2. TouchDesigner: crash on audio→control analyzers (zero audio-output busses)
An object with an audio input but no audio-output bus (an analyzer: audio in → a single control/real out, e.g. Essentia
Entropy) crashed TD on cook. Two independent bugs, either of which alone crashes:getOutputInfo'soutput_busses == 0branch returned after only filling the info struct — it never calledallocate_buffers/init_channels/preparelike the>= 1branch. The dsp buffers stayed unallocated andprocess()wrote into them.execute()'s input branch computednum_out_channelsviaget_output_channels(impl, 0)— indexing output bus 0, which doesn't exist with zero output busses — a bogus count, and theout_ptrsloop ran off the end ofoutput->channels. Now usesoutput->numChannels(what TD allocated), matching the no-input branch.3. Harness: record the crash stage
The sweep breadcrumb now carries a
:created/:precook/:cookedsuffix so a crash report shows where an object died — this is what localised the analyzer crash toexecutevia bisection.Verification
Golden differential:
avnd_essentia_entropygoes from crashing TD to cooking cleanly (processes its 64-sample input). 0 objects crash (was 1).🤖 Generated with Claude Code