Skip to content

Channel-safety: zero-filled channels invariant + audio-analyzer crash fix#143

Closed
jcelerier wants to merge 17 commits into
mainfrom
fix/core-zero-filled-channels
Closed

Channel-safety: zero-filled channels invariant + audio-analyzer crash fix#143
jcelerier wants to merge 17 commits into
mainfrom
fix/core-zero-filled-channels

Conversation

@jcelerier

Copy link
Copy Markdown
Member

Fixes crashes from channel handling, found while investigating the avnd_essentia_entropy TouchDesigner crash. Branched off current main.

1. Core invariant: 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. Enabled by default (opt out with -DAVND_ENABLE_SAFE_BUFFER_STORAGE=0); process_bus/base.hpp includes process/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's output_busses == 0 branch returned after only filling the info struct — it never called allocate_buffers/init_channels/prepare like the >= 1 branch. The dsp buffers stayed unallocated and process() wrote into them.
  • execute()'s input branch computed num_out_channels via get_output_channels(impl, 0) — indexing output bus 0, which doesn't exist with zero output busses — a bogus count, and the out_ptrs loop ran off the end of output->channels. Now uses output->numChannels (what TD allocated), matching the no-input branch.

3. Harness: record the crash stage

The sweep breadcrumb now carries a :created/:precook/:cooked suffix so a crash report shows where an object died — this is what localised the analyzer crash to execute via bisection.

Verification

Golden differential: avnd_essentia_entropy goes from crashing TD to cooking cleanly (processes its 64-sample input). 0 objects crash (was 1).

🤖 Generated with Claude Code

jcelerier and others added 17 commits July 3, 2026 09:05
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>
@jcelerier

Copy link
Copy Markdown
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 (base.hpp) is already covered verbatim by #142 (confirmed byte-identical), so it's dropped here.

@jcelerier jcelerier closed this Jul 5, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant