aax
無料Optional AAX support for Pulp, including developer-supplied Avid SDK setup, CMake enablement, DigiShell/AAX Validator workflows, and local AAX builds on macOS or Windows.
日本語の概要は準備中です。原文の説明を表示しています。
Pulp timeline transport, immutable compiled playback programs, bounded arrangement audio rendering, block-level publication latches, stable shells, and ProcessContext projection.
インストール方法を見るインストールする前に、エージェントに与えられる指示の中身を確認できます。
Playback has two independent public surfaces: MasterTransport for the block
clock, and immutable compiled playback programs. Build a
ProgramCompileRequest from one captured immutable Project snapshot, an
external monotonically increasing document revision, a shared precompiled tempo
map, an explicit sample rate, and an explicit DirtyTrackSet. The request rate
is invalid by default; set ProgramCompileRequest::sample_rate from the engine
configuration and compile the tempo map at that same exact RationalRate.
Submission normalizes both and fails synchronously if the request rate is
invalid, above the compiled-rate ceiling, or differs from the map, preventing
one program from mixing sample domains. Drive it with
DeferredCompileExecutor::run_for() on threadless/UI hosts or use
WorkerCompileExecutor on native threaded hosts. The compiler is the sole
publisher to its PlaybackProgramStore.
Use sparse TrackCompilePolicy deltas when a track changes provider selection
or adoption policy. The compiler validates availability, forces that track
through the dirty path, retains omitted published policies, and coalesces
pending deltas with latest-track wins.
For this phase, only ProviderKind::Arrangement with the arrangement-only
availability mask is valid; reject launcher or external-input claims until
their provider payloads are compiled.
For MediaRef clips, prepare a DecodedAudioAssetPool off the audio thread. Use
DecodedAudioAssetPool::decode_wav() for bounded in-memory WAV bytes, then pass
the immutable pool in ProgramCompileRequest::audio_assets. The existing
compiler incrementally lowers media clips into each TrackProgram; do not build
a second playback-program model. TimeConform::None uses the existing bounded
native-rate source mapping after sample-rate conversion. TimeConform::Resample
uses bounded stateless varispeed: map each
rendered musical tick to the same fraction of the referenced source range and
derive the effective source step for anti-aliasing. Use the compiled tempo map's
analytic fractional sample-to-tick inverse for ordinary playback and precise
host ticks for host beat mapping; sample-fraction interpolation across a tempo
ramp is not musical phase. TimeConform::Stretch is compiled off the audio
thread: slice the source, fixed-SRC it into the compiled timeline-rate domain,
drive the finite stretcher with an analysis-boundary tempo schedule, and publish
only an immutable artifact with exactly the authored timeline frame count.
Use the scalar double finite builder for deterministic offline compilation,
then convert its exact result to the public float artifact in bounded blocks.
Document-tempo playback consumes that artifact 1:1. For live host-tempo
projection, prepare a complete RealtimeStretchProgramRuntime off the audio
thread and stream the artifact through its preallocated low-latency processor;
the audio callback may stretch but must never allocate, lock, or prepare DSP.
The runtime publishes one fixed causal latency for all parallel audio and MIDI
paths and resets coherently on transport/program epochs. Keep
source/tempo/algorithm semantic identity in the artifact cache key and document
revision/program generation in separate provenance.
Compiler work-block size is scheduling only and must change neither key nor
output. Never route Stretch through Resample, pad/trim a length mismatch, or
fall back to None.
Gain and anchor-native fade durations live on the immutable Clip. Missing,
mismatched, or over-capacity assets fail compilation instead of creating a
silent placeholder.
When sequence lowering flattens a complete nested media clip, preserve its
authored TimeConform value. A window that trims a Resample clip is lowered,
not refused: the leaf carries a source RANGE rather than a lone offset, as
LoweredClip::source_frame_offset plus source_frame_phase_end, which
AudioClipRendererProgram carries under the same names and
musical_phase_source_position reads as the two ends of its phase map. Both
ends come from the retained window's own fractions of the clip's authored tick
span, because that is what the conform function says; an elapsed-samples offset
agrees only where source frames and timeline frames happen to advance together,
which is exactly the case a real conform is not. A zero phase_end means the
media reference's own end, so an untrimmed leaf lowers to the program it always
did.
Stretch is lowered too, and the range is not what does it. Its audio is a
rendered artifact keyed to an authored tick range, so the artifact stays keyed
to that range — LoweredClip::authored_window_start and authored_duration,
the pair a trim already recorded for generated content, say where the range is —
and the leaf reads the frame span of the render that belongs to its window, as
AudioClipRendererProgram::source_start plus source_frame_count over an
artifact longer than the clip. offline_stretch_artifact_window in
audio_renderer_internal.hpp is the one place that arithmetic lives, and both
the artifact compiler and the program compiler go through it. Two consequences
worth knowing: a trimmed leaf and its untrimmed twin produce the same artifact
key, so they share one cached render and the trimmed leaf is bit-identical to
the untrimmed one over the same ticks; and the authored range can begin before
tick zero when a placement sits earlier than the offset it reads, which the
tempo map extrapolates and no document clip can express.
A stretched leaf keeps its whole media reference — narrowing it would change
what gets stretched rather than which part of the result is heard — so the
lowerer's elapsed-samples rebase is scoped to TimeConform::None. Anything
reading an artifact directly must offset by source_start: the realtime
stretch lane's artifact_sample does, and reading from frame zero there is
silently the wrong audio rather than an error.
Each nested refusal names one cause. A child device chain raises
NestedDeviceChainUnsupported, and an absolute-anchored leaf inside a nested
sequence raises NestedAbsoluteChildUnsupported. A child automation lane
raises one of three, because the constructs that would lift them differ: an
automated pan raises NestedAutomationPanUnsupported on entry to the child
track, since no leaf carries a stereo placement at any level. An automated gain
travels to the leaf and is answered by the leaf's own kind — a leaf that reads
no clip gain raises NestedAutomationGainEventLeafUnsupported and needs a
renderer that scales it before any envelope would matter, while one that does
read clip gain raises NestedAutomationGainMediaUnsupported and needs only
that ClipPlaybackProperties::gain_linear stop being a lone scalar. Do not reach for one
code to cover several constructs: the code is what tells an author which
construct is missing, and a generic one hides that. Two guards in
validate_reference are deliberately not capability codes — a nesting depth
past kMaxSequenceNestingDepth and a non-musical SequenceRef placement both
raise InvalidStructure, because sequence_graph_validation and
Clip::create_absolute already reject them at construction, so reaching either
means the document should not exist.
When host beat mapping intentionally makes musical material follow the host
tempo, keep absolute clips, take-comp segments, and frozen artifacts on
TransportRange::timeline_sample_start; those sources are sample-domain
content and must not inherit the beat projection. Carry precise fractional host
tick endpoints through every callback and nested ProcessContext projection;
integer timeline_tick_* fields remain compatibility metadata and must not
drive host-mapped interpolation, loop admission, note scheduling, automation
refinement, MIDI clock, or metronome enumeration. A precise host-mapping
rejection must not fall back to document-tempo placement. For musical audio,
derive the
effective source-position step per output frame. A converter prepared only for
the asset-rate/timeline-rate ratio cannot anti-alias faster host playback, so
prepare and share a per-MediaRef-range multiresolution audio pyramid off the
audio thread. Build it incrementally inside the compiler work budget, count it
against both converter-count and aggregate prepared-byte limits, including
persistent sinc tables and container storage, and seed unchanged programs back
into the cache. Clamp every pyramid level to the exact
referenced source range so neighboring asset frames cannot bleed into a clip.
Fixed-rate and variable-rate kernel construction are part of that same
incremental budget: initializing a converter must not synchronously populate
every sinc phase before yielding.
Use fixed-size, incrementally allocated prepared chunks whose persistent
footprint is computable; implementation-defined container bookkeeping cannot
sit outside the byte cap.
Each 2:1 stage must low-pass before decimation; select the coarsest level that
leaves a bounded residual step, then use its prebuilt reconstruction kernel. Do
not approximate extreme ratios by clamping a tiny cutoff onto a
fixed-width source-rate kernel: once the sinc support contains too few zero
crossings, normalization turns it into a short moving average and aliases
despite the nominal cutoff. The fixed asset-rate/timeline-rate path therefore
fails compilation beyond its honest kernel range, while host-tempo playback
uses the prepared pyramid to retain its wider bounded contract.
An active take lane replaces the track's arrangement source; zero
active_take_lane_id selects arrangement clips. The compiler lowers each
canonical comp selection to an AudioClipRendererProgram with
SourceKind::TakeCompSegment and a one-based lane ordinal. The typed origin
keeps repeated selections from one take distinct without inventing project
identities. Lower one selection per compile work unit, count arrangement
regions and comp selections against the same whole-program max_clips, and
require the take rate, asset metadata, and decoded audio rate to agree.
Inactive lanes remain document data and contribute no playback regions.
A selected TrackFreeze supersedes both the arrangement and active take comp
with one AudioClipRendererProgram whose SourceKind is FrozenTrack and
whose stable identity is the owning track. It is a sealed post-device artifact:
the compiler emits no authored clip/note events, ordered device placements, or
automation program for that track, while leaving all authored document state
intact for unfreeze. Count the artifact against the same whole-program
max_clips, validate its project asset, decoded audio, media range, and sample
rate exactly, and reject coordinate/SRC overflow before publication. A dirty
freeze/unfreeze edit must rebuild the track program; replay selects the sealed
asset and never re-renders it. Desktop graph binding therefore accepts no
device routes for a frozen track and rejects stale routes as unexpected,
preventing a post-device freeze from traversing the authored chain twice.
On the audio thread, call PlaybackProgramBlockLatch::begin_block() exactly
once per callback and pass that pin to every StableRendererShell. Never cache
a TrackProgram* past the pin. Adoption accepts skipped generations
(candidate > active) for the same ItemId and proves carry-state ownership
against the shell's RendererCarryState SeqLock snapshot.
The host's TimelineGraphBinding is the deliberate exception to independently
latching PlaybackProgramStore: its enclosing immutable binding generation
already owns the exact PlaybackProgram together with the exact graph snapshot
and renderer set. It constructs a non-owning PlaybackProgramBlock only while
that generation is pinned, so program destruction/refcount traffic still never
runs on the audio thread. Content adoption republishes the whole binding
generation; do not reintroduce a separate store latch there.
For arrangement note playback, construct one ArrangementNoteRenderer per
track, call prepare(maximum_events_per_block) off the audio thread, then pass
the shared block pin and the current TransportSnapshot to process(). The
renderer owns a bounded realtime-limited MIDI buffer; inspect events() only
for the current block. The buffer carries a full-resolution native MIDI-2 UMP
sidecar alongside its MIDI-1 compatibility mirror; treat the two lanes as one
atomic event block and propagate either lane's overflow. It consumes both
transport ranges in order, releases
active notes before the second range on a loop wrap, and intentionally resets
without note chase on seek/adoption in Phase 1. TransportSnapshot carries the
non-owning identity of the exact compiled tempo map that resolved its ranges;
the renderer rejects a program compiled against another map. Overlapping
logical notes on one MIDI key are reference-counted into one physical note-on
and one final note-off.
Event-stream delay compensation rides on that same process(). The three-argument
overload takes an EventCompensationShift (samples, std::int64_t) and reads
each transport range from range.timeline_sample_start + shift instead of the
range's own origin, so a chain that delays the events themselves still lands
them on the sample the document authored. Non-obvious parts, in the order they
bite:
PlaybackProgram is immutable and
shared by the offline and realtime paths. Folding a host latency into
NoteProgramEvent::sample makes the program a function of the host graph and
forces a recompile on every device swap. The addend belongs on range_start.is_playing is false, reporting shift_relatch_pending until then. Adopting
mid-playback would displace every later event by the delta.NoteRenderCode::CompensationUnsupported). That range locates events by
authored tick against the host's beat window, so a document-sample shift has
nowhere to land, and converting it to ticks is what the unit rule forbids.plan_compensated_read() splits the
window at the loop point into at most two runs — the loop length is never
shorter than the maximum block, so one window crosses at most once — and each
run carries the loop pass it belongs to, so note modifiers resolve against the
right pass. The renderer releases what was sounding at the stream's wrap,
which arrives a shift before the transport's, and then suppresses the
transport's own discontinuity for a wrap the read-ahead already served:
serving it twice would cut the post-wrap notes read-ahead had already started.
The suppression is scoped to the compensated, looping, non-scrubbing case, so
a scrub-window restart still releases. The whole path is skipped — including
the loop's tempo-map conversion — when nothing compensates.accumulate_event_chain_shift(), which range-checks against the ceiling the
caller supplies rather than declaring a second latency constant.The full contract, including what is still open, is
docs/policies/event-stream-pdc.md.
Compile an unattached AutomationLane with AutomationProgram::compile() on
the control/worker thread. The immutable program owns its exact tempo map and
retains tick-domain segment semantics. Each compile also receives a nonzero
instance token; generation orders adoption, while the token prevents an equal-
generation replacement from masquerading as the active immutable program.
AutomationCursor::process() consumes
the shared transport snapshot and writes plain-domain control points into a
caller-owned span. Each point says whether it seeds a range, steps immediately,
or ramps linearly from the preceding emitted point. Span capacity is the
explicit per-lane budget: range seeds and unique in-range authored knots are
mandatory, remaining capacity refines continuous spans deterministically, and
output never overflows. Keep device-wide budgeting, lane aggregation, parameter
metadata, normalization, and the SignalGraph mailbox write in the host binding;
playback must not depend on pulp::state merely to mirror
ParameterEventQueue.
Group already-compiled lane owners with TrackAutomationProgram::create() on
the compiler thread. The aggregate validates a compiler-supplied track ID,
requires exact tempo-map owner identity, rejects duplicate lane IDs and
device-parameter targets, and stores programs in lane-ItemId order. Preserve
unchanged program owners when rebuilding it: mixed child generations are
intentional because each cursor adopts by its lane program's generation and
instance token.
ProgramCompiler is the attachment boundary for authored automation. It walks
each track's ordered device placements, compiles only lanes owned by that track,
and publishes the resulting TrackAutomationProgram inside the immutable
TrackProgram. Use AutomationPlaybackLimits on every compile request: reject
over-limit device, lane, and point counts before reserving proportional storage,
and use platform_defaults() so wasm/threadless builds receive their lower
budgets. Incremental compilation retains unchanged lane owners; attachment,
target, or point edits dirty only the affected track/lane.
On the audio thread, give one TrackAutomationRenderer the exact immutable
track automation program and the shared transport snapshot. It emits bounded
per-device ParameterEvent batches in device-placement order: seeds become
zero-duration endpoints, linear points preserve their ramp duration, and
immediate points step at their sample offset. Candidate traversal and emitted
events have separate limits. A mandatory event that cannot fit fails the whole
block without exposing partial device batches; optional refinement points may
coalesce deterministically. The renderer owns all scratch storage after
prepare() and performs no allocation in process().
Use this skill when changing core/playback, the master timeline transport, or
the format-layer projection from playback snapshots to ProcessContext.
TickPosition, SamplePosition, and MonotonicBeat
state. Floating-point beat values exist only in the one-way format projection.prepare() rejects a loop shorter than max_buffer_size, which is
what makes the fixed two-range representation complete.MonotonicBeat never wraps or
reanchors on a seek; only a new prepare/reset lifecycle starts a new clock.begin_scrub() /
scrub_to() / end_scrub() make the transport emit repeated windows that
restart on the latest posted anchor, so a dragged playhead is audible without
a single line of scrub-aware code in any renderer: a window restart is
structurally a loop wrap (reposition + discontinuity + block split), which
the note and automation renderers already handle. Do not add a scrub branch to
a renderer; make the transport produce the right ranges instead.max_buffer_size (begin_scrub rejects shorter, and begin_block clamps
anyway) so a block still spans at most two windows and the fixed two-range
representation stays complete.end_scrub(), which parks the playhead on the anchor the drag released on.
This also keeps a loop wrap and a window restart from ever needing a third
range in one block.is_playing is true even when the musical transport is
stopped — consumers that only care whether the playhead moves need no scrub
branch — and scrubbing distinguishes the mode. Entering and leaving scrub
set reset_requested; the window restarts in between deliberately do not,
because they recur many times a second and discontinuity already describes
them.CaptureEngine cancels active takes on a
non-loop-wrap jump, so scrubbing aborts a recording rather than splicing it,
and ExternalSyncOutput emits a song-position/MTC update per window restart,
so slaved gear chases the drag. A scrub block carries at most two ranges, the
same as a loop wrap, so neither exceeds max_messages_per_block. Do not add a
scrub branch to either; if the behavior needs to change, change what the
transport publishes.TempoSyncSource is the backend-neutral session-tempo boundary. Its only
virtual operation is the realtime capture_audio_block() mapping/command
exchange. Backend enablement, peer discovery, and start/stop-sync policy do
not belong on the interface; the desktop AbletonLinkTempoSync adapter owns
those Link-specific controls.TempoSyncSource* is non-owning and must outlive
MasterTransport. It switches callers to the host-time begin_block
overload. Its opaque TempoSyncHostTime is created by the source and tagged
with that source's clock domain; a default token or a token from another
source fails before capture. The timestamp names the first sample at the
output boundary, so the audio-device layer must add output latency before
entering playback. A missing host time, disabled backend, backend failure, or
invalid mapping fails closed and never advances on the document clock.prepare() does not broadcast
initial_position or initially_playing; only later explicit seek(),
set_playing(), or set_tempo_sync_tempo() calls become one-shot commands
on the next audio block. Applied generations advance only after a valid
capture, so a failed block retries the command rather than losing it.begin_scrub()
rejects an active sync source: scrubbing owns a private repeated-window clock
and cannot share authority with a network beat mapping. The audio-thread guard
rejects any impossible mixed state defensively as well.is_playing state becomes effective. project_tempo_sync_playing() applies a
transition at or before the first sample and defers one inside or beyond the
half-open block, because TransportSnapshot::is_playing is block-wide. Keep
this quantization explicit; silently discarding the timestamp makes remote
starts and stops early, while pretending to split them would contradict the
snapshot consumed by renderers.tempo_sync.cpp in PulpPlaybackSources.cmake, which mirrors it into
native, threadless, WAM, and WebCLAP builds. Keep SDK-backed adapters such as
adapters/ableton_link.cpp outside core/playback/src/ and in a separate
non-installed target; the source-closure gate treats every src/*.cpp as
portable, so an SDK-backed translation unit there would be pulled toward the
wasm lanes.SeqLock.
begin_block() is the sole audio-thread consumer and must remain allocation-
and lock-free. It is declared AudioCallbackSafeAfterPrepare, wraps itself
in ScopedNoAlloc, and its test uses ScopedRtProcessProbe so Unix CI traps
both allocations and pthread locks.ProcessContext::transport_jump.web-timeline-source-closure green. This proves wasm compilation only, not a
JavaScript timeline API or host parameter delivery.TransportSnapshot for a
callback. The replay golden uses a varying schedule up to the transport's
prepared max_buffer_size; never cache the first callback size in either
renderer or bypass MasterTransport's upper-bound rejection.StableRendererShell, ArrangementAudioTrackRenderer, and
ArrangementNoteRenderer expose control-thread reset() for a successful
quiesced sample-rate or maximum-block-size lifecycle change. Reset every
bound renderer together after graph reprepare; note reset also clears active
counts, pending flush/overflow state, current event buffers, and block index.CustomNodeType; the host/embedded adapter routes its bounded MIDI output.core/playback must not include pulp/format, pulp/host, or pulp/view.
<pulp/format/playback_context_projection.hpp> owns the one-way adapter.
Keep timeline-engine-dependency-floor green; it allowlists source includes
and CMake links for timebase, timeline (when present), and playback. The link
check reads target_link_libraries dependencies and skips the configured
target plus target-defining commands, so a subsystem-local helper executable
whose own name shares the module prefix (e.g. pulp-timeline-schema-emit) may
link pulp::timeline without tripping the floor.LaunchHandle::last_start() — the
monotonic beat the launch RESOLVED to — never to the monotonic origin and
never to the block that carried the Start. FollowActionTimer builds a
LaunchQuantize whose phase is that beat and walks it with the same
next_launch_boundary() / resolve_launch_sample() pair a launch uses, so
the fire inherits the launch's sample accuracy across a loop wrap for free.
Recovering the launch beat from a Start event's sample offset instead would
round through the tempo map and lose that exactness.initial_position); only then does the fire sample separate the two.clip_content_role() what a clip contributes before it
compiles anything, and that classifier visits timeline::ClipContent through
ClipContentCases — an overload set with no generic fallback. Do not go back
to testing alternatives inline with holds_alternative / get_if. A clip
whose content kind the compiler does not recognize produces no audio program
and no notes, and nothing anywhere reports it: the document is intact, the
compile succeeds, and the track is silent. Routing every content decision
through one exhaustive classifier turns that into a build failure at the point
where somebody has to decide whether the new kind renders. audio_renderer.cpp
carries the matching static_assert on the alternative count, because its
"not a MediaRef means not audio" assumption lives there too.ArrangementAudioRenderer::process() clears output, validates the complete
zero/one-wrap snapshot, and mixes arrangement-selected tracks in stable
PlaybackProgram order. It is immutable-input RT safe, wraps ScopedNoAlloc,
and must remain covered by rt_allocation_probe. Mono duplicates on wider
output, multichannel-to-mono averages, wider sources map by channel, and the
engine does not clip or normalize deterministic float sums.Per-note playback modifiers (probability, pass condition, ratchet) are the worked example. The split is not a style choice; each half sits where its inputs exist:
program_compiler.cpp lowers a ratcheted note into
N on/off pairs that tile the authored span, with the last subdivision landing
on the note's own end so repeats never drift. A subdivision that collapses to
zero samples at the compiled tempo fails the compile rather than emitting an
on with no off.ArrangementNoteRenderer::process() against a pass index.The pass index is transport-owned, never renderer-local. Each
TransportRange carries loop_pass_index; the master transport and host
projector advance it at a wrap and re-anchor it on start, seek/jump, or loop
identity changes (including precise fractional host bounds). A renderer may be
created mid-playback, skip a callback, or fail a bounded output flush and still
observes the authoritative pass on its next range. Do not reconstruct the pass
from MonotonicBeat: its signed tick storage intentionally saturates at the
domain boundary.
Two properties make the gate safe to apply per event. The pass index is constant
across a range, because a wrap always starts a new range — so a note's on and its
off resolve against the same pass and the gate can never admit one without the
other. And the decision is a pure function of (draw key, pass index), so no
draw state crosses blocks and evaluation order cannot change a result. Anything
seeded on the audio thread must have this shape: fold the seed and the identity
into one key at compile time, then mix it with the pass index in process().
Side data a renderer needs per event goes in a sparse table on TrackProgram
looked up by item id, not a field on NoteProgramEvent. That struct is 40
bytes and the scale suite compiles ten million of them; a std::uint32_t index
would not fit the existing padding and would grow every event by eight bytes to
carry data almost no note has. An empty-span check makes the common case free.
Sorting such a table is real work, so it gets its own budgeted
BudgetedStableMergeState stage rather than a bare std::sort inside a compile
slice.
AudioClipRendererProgram is built positionallyThe clip envelope (gain, fade in, fade out, fade shape) is evaluated in
core/playback/src/clip_fade_envelope.hpp and nowhere else. Before it existed
the same arithmetic had three homes — a whole-frame and a fractional overload in
audio_renderer_render.cpp, plus a byte-identical fractional copy in
realtime_stretch_renderer.cpp — so a fade behavior added to the normal render
path silently did not apply under live stretch. The two overloads that survive
are split on numerics, not on contract: the whole-frame one computes the
remaining-frame count as exact integer arithmetic, the fractional one clamps a
long double that can land past the last frame. Anything that reads the
authored shape belongs in fade_gain, which both call.
The fractional overload narrows progress to float before it calls fade_gain,
and that narrowing is load-bearing rather than cosmetic. fade_gain is a
template that deduces its type from the argument, so handing it the long double
position instantiates a double-width sin for EqualPower — once per output
sample on the realtime stretch path — while the gain is narrowed to float on
return regardless, so the width buys nothing. Nothing guards this: the RT probes
look for allocation, and a wider sin does not allocate. It is also easy to
under-read on a Mac, because the lowering is arch-dependent — long double is
double on arm64, so the wide call shows up there as _sin, where x86_64 gets
the 80-bit _sinl. Verify on the emitted object rather than at the source level,
since a cast that deduction discards still compiles:
nm -u build/core/playback/CMakeFiles/pulp-playback.dir/src/realtime_stretch_renderer.cpp.o | grep -i sin
should report _sinf and nothing wider.
Fade progress is measured in frames, in every shape. The compiler converts
authored fade endpoints from ticks to frames
(audio_renderer.cpp, the musical branch of the clip lowering); the renderer
then normalizes position against that frame count. So a nonlinear shape needs no
tempo mapping of its own — it is a reparameterization of a progress value that
is already in the time domain, and it inherits exactly the tempo behavior the
linear ramp always had. This is also the acoustically correct answer: a
constant-power crossfade is a statement about power against time, not against
beats, so measuring progress in ticks would make the same authored fade dip
differently on either side of a tempo change.
AudioClipRendererProgram is brace-initialized positionally in four places
in audio_renderer.cpp (the offline-stretch, native/resample, take-comp, and
frozen-track paths). Inserting a field mid-struct shifts every later initializer.
It fails closed only when the adjacent types differ — two neighbouring
std::uint64_t fields would swap silently and compile. Grep every
AudioClipRendererProgram{ when the struct grows, and prefer adding to the end
of a run of same-typed fields.
TrackProgram::mixer() carries the track's own
gain_linear/pan with any lanes that automate them already resolved to
borrowed AutomationProgram pointers. It is applied inside the clip
accumulate in audio_renderer_render.cpp, so the whole-program mixdown and
the per-track graph renderer stay in agreement — applying it in only one would
break offline/live parity. A lane supersedes the authored constant rather
than multiplying with it, and TrackMixerProgram::transparent() short-circuits
an untouched track back onto the exact pre-mixer code path. Pan is a balance:
it attenuates the opposite side, never boosts, is inert below two channels, and
is exactly unity at centre.TrackAutomationRenderer skips
any lane whose device_target() is null, and so does the admission scan in
core/host/src/timeline_automation_delivery.cpp. A mixer lane still lives in
the track's TrackAutomationProgram; it just has no device to address.select_automation_segment and
evaluate_automation_segment in automation_program.cpp are shared by the
device-delivery cursor and TrackMixerControlCursor, so an automated fader and
an automated plugin parameter cannot read the same curve differently.
TrackMixerControlCursor is forward-only — restart() before revisiting an
earlier position, which the render loop does per channel and per transport
range.MidiContent carries controller/expression lanes beside its notes, and nothing
downstream of the compiler reads them: program_compiler.cpp builds a track's note
program out of notes() alone. Compiling a lane-bearing clip would publish a
program that plays the notes with every authored controller point gone and nothing
to read the loss from — the document keeps the lanes, so authoring, saving,
reloading, and copying all behave while playback quietly ignores them.
Two refusals prevent that, and they are not the same statement:
CompileErrorCode::MidiExpressionLaneUnsupported — raised in
program_compiler.cpp when a clip is first seen in Stage::CompileTracks with a
non-empty lanes(). This is the general gate. Every clip a program is built from
reaches that point, whether authored on the track or generated by lowering a
nested sequence, so it covers the whole surface rather than one path. A renderer
that chases and emits lane values is what removes it.CompileErrorCode::TrimmedMidiLaneUnsupported — raised earlier, in
sequence_content_lowerer.cpp, when a nested clip's content is rebuilt for the
retained window. A controller lane has no correct trim: a point outside the
window can be the value sounding inside it, so dropping it changes what the
controllers say and keeping it puts a point outside the clip. That question
survives the renderer landing, so this refusal outlives the one above.If you are implementing controller chase or expression semantics, both refusals
are your markers. Deleting MidiExpressionLaneUnsupported is correct once the
note program carries lanes; deleting TrimmedMidiLaneUnsupported is not — it needs
a decided boundary value, most likely a point synthesised at the window edge from
the last value at or before it. That is why they are separate codes: collapsing
them into one would delete the trim guard by accident when the renderer lands.
The pair is proved in test_timeline_nesting_playback.cpp (target
pulp-test-timeline-nesting): a flat lane-bearing clip is refused, the same clip
without lanes still compiles — so the guard is not "refuse every MIDI clip" — and a
trimmed nested lane-bearing clip still reports the trim code.
Configuring a fresh build dir for these suites needs
-DPULP_ENABLE_DESIGN_IMPORT=ON passed explicitly whenever the cache has
ever held OFF: PULP_BUILD_TESTS=ON hard-requires it, and a cached OFF survives
a reconfigure that does not name the option, so the configure fails on an option
combination unrelated to anything you changed. Passing it every reconfigure is
cheaper than recognising the error a second time. (The ci skill covers the
other half of this option — the OFF-side link break the release lane guards.)
Build and run pulp-test-playback-automation-cursor,
pulp-test-playback-track-automation-program,
pulp-test-playback-track-automation-renderer, pulp-test-playback-program,
pulp-test-playback-transport, pulp-test-timebase, and
pulp-test-transport-quantizer, plus pulp-test-playback-audio-renderer
(which carries the track-mixer cases, including the proof that a gain lane moves
the rendered samples rather than merely existing in the document). Keep loop-boundary, variable-block, ramp,
negative-preroll, extreme-position, SeqLock hammer, and RT-allocation cases.
Track-freeze changes also require pulp-test-timeline-graph-binding: prove the
artifact routes directly after the authored chain, a stale device mapping is
rejected, and a dirty thaw restores arrangement/device compilation.
pulp-test-playback-note-renderer also fuzzes the no-stuck-notes property:
fixed-seed randomized seek/loop/play sequences over overlapping notes assert
the physical MIDI stream is a per-key on/off toggle (a note-on only for an idle
key, a note-off only for a sounding key), and a terminal stop-flush must leave
has_active_notes() false with every note-on matched by a note-off. Seeds are
hardcoded so a red is a real defect, not a flake; keep the non-vacuity witnesses
(notes held live across seeks and loop wraps) asserting above zero so the
all-clear cannot go vacuous. The toggle invariant and terminal balance are NOT
enough on their own — they are both structurally guaranteed regardless of the
seek/loop flush: emit() folds logical overlaps so the physical stream is a
clean per-key toggle even when a discontinuity strands a note, and the terminal
stop-flush always rebalances the counts. A stranded note is only observable
against an independent coverage oracle: a key may sound only while the playhead
sits inside the union of that key's compiled note extents, so a still-sounding
key whose playhead has moved past every extent is the stuck note. Keep that
oracle (checked at each playing block's last played sample, stuck-direction
only — a note whose onset precedes the new range is deliberately not chased, so
covered-but-silent is legal) when touching this proof; without it, deleting the
range.discontinuity flush in note_renderer.cpp leaves the fuzz green.
The same file carries the scrub counterpart, which reuses that oracle over
randomized begin_scrub/scrub_to/end_scrub/seek/play/loop sequences. Its
non-vacuity witnesses are scrub-specific — window restarts that happened while
notes were sounding, and restarts that split a block — because a scrub fuzz that
never rewinds the playhead under a live note proves nothing. Deleting the
pending_discontinuity_ assignment in start_scrub_window() (transport.cpp)
must red both that fuzz and the deterministic
a scrub window restart releases the notes it strands case; if it does not, the
scrub coverage has gone vacuous.
concurrent playhead readings are never internally inconsistent runs the writer
and the reader on separate threads and asserts that every reading it observes is
internally coherent. Asserting only that the value changes would pass on a torn
implementation, which also changes. The invariant comes from the fixture: a
step tempo map makes tempo_bpm a pure function of position, so a reading
assembled from two different publishes pairs a position on one side of the step
with the tempo from the other, and no legal reading does that.
Two controls keep the test from going vacuous, and both belong in any test of this shape. The same predicate runs single-threaded first, which proves the invariant holds of a coherent reading before it is trusted to detect an incoherent one. And the test asserts it observed at least one publish — without that, a reader that never caught the writer would pass everything.
ScopedRtProcessProbe has two backends. In the counting backend
allocation_count() reports what the harness operator new override saw. In
the trap backend — PULP_NATIVE_CORE_PROCESS_RT_TRAP_TESTS=1, the one every
playback RT suite uses on Unix — it unconditionally returns 0, because a
violation aborts the process before the assertion runs. So in a trap build the
REQUIRE(allocations == 0) line carries no information: the abort is the
signal, and the assertion is only there to keep both backends writing the same
test.
That looks like it should be silently vacuous whenever the trap translation
unit is not linked, since it is a strong override of a weak no-op default in
core/native-components/src/native_core.cpp. It is not, and the reason is worth
protecting. RtNoAllocScope's constructor and destructor are declared in
rt_test_scope.hpp but defined out of line in
test/native_components/rt_intercept_test_support.cpp, so a registration that
sets the define while omitting the source fails at link:
Undefined symbols for architecture arm64:
"pulp::native_components::test::RtNoAllocScope::RtNoAllocScope()", referenced from:
CATCH2_INTERNAL_TEST_20() in test_playback_program.cpp.o
The counting backend fails closed the same way — RtAllocationProbe's
constructor and the operator new override that feeds it live in the same TU
(harness/rt_allocation_probe.cpp), so they can never be split.
Do not inline those constructors into the headers. They look like trivial one-liners begging to be moved, and moving them would convert a hard link error into a probe that returns a hardcoded 0 forever. The out-of-line definition is the guard.
What the link check cannot catch is a probe scope that does not actually
enclose the RT call, or an allocation the optimizer elides because nothing
escapes. Those need a control: put a new inside the scope whose result
escapes through a volatile sink, rebuild, confirm the binary aborts with
[pulp-rt-trap], then remove it. Worth doing whenever you add a probe or doubt
an existing one — cheap, and it is the only way to tell a scope that proves
something from one that merely runs.
Copy the registration shape from pulp-test-playback-program in
test/cmake/timeline_tests.cmake: the $<BOOL:${UNIX}> source split,
pulp::native-components, ${CMAKE_DL_LIBS} (the pthread interposers use
dlsym), and the generator-expression define.
Two things that waste time here. The trap message names the violation kind, so
blocking lock inside no-alloc scope means a lock, not a hidden new — do not
go hunting for an allocation. And restoring a patched test file with mv gives
it an mtime older than the object built from the patched copy, so make skips
the rebuild and you re-run the control binary believing you reverted; touch
after every revert.
When export/install wiring changes, also run the installed SDK consumer smoke.
Also build timeline-program-threadless-no-exceptions-check; it compiles the
program/compiler/executor/shell lane with -fno-exceptions -fno-rtti and the
threadless executor stub. Run the WASI SDK build when /opt/wasi-sdk is
available; the native compile-only gate remains mandatory when it is not.
Keep pulp-test-timeline-replay-golden green: it applies journaled gain, fade,
and note edits, replays from the checkpoint, and compares the audio/MIDI byte
stream with both the committed snapshot and the pinned fixture.
web-timeline-source-closure compares the native timebase, timeline, and
playback source lists with both curated production web ABI lists. Add a portable
engine translation unit to native, WAM, and WebCLAP ownership together.
test/cmake/sampler_runtime_tests.cmake also registers sampler Heritage
runtime tests. Those tests exercise pulp::audio profile/runtime behavior and
do not make Heritage profiles part of the immutable playback-program model;
keep that ownership boundary when extending the shared test inventory.
compile_context_registry.hpp is the invalidation half of the
compile-context subscription contract (the document/read half lives in
core/timeline — see the timeline skill). It exists because the compiler's
dirty set is exact rather than diffed: a renderer that reads a sequence-owned
context lane while compiling has no dirty item of its own when that lane
changes, so without a declaration it would render stale forever.
Three pieces, and the boundaries between them matter:
CompileContextRegistry maps a content schema type name (the identity a
RegisteredContent clip actually carries) to declared subscriptions. It
refuses a duplicate type rather than overwriting — two renderers disagreeing
about what a content kind reads would make invalidation depend on registration
order. An unregistered type reads nothing, which is correct: no renderer
compiles it, so there is no program that could go stale. Built-in MIDI is the
deliberate exception: the program compiler reads its owning sequence groove,
so MidiContent always subscribes to Groove without plugin registration.
Media and empty content read none.CompileInvalidationIndex::build() is the kind → reader-track reverse index.
Rebuild it when the document's structure changes; a context edit alone does
not invalidate it, because editing a lane's contents does not change who reads
it. It walks clips through the exhaustive ClipContentCases visitor, so a new
ClipContent alternative stops the build here until someone decides whether it
can subscribe.resolve_dirty_tracks() is the production translation from
timeline::DirtySet to DirtyTrackSet (tests used to hand-build the latter).
Its precision is documented per dirty-item shape in the header. Two shapes are
deliberately conservative and should stay that way: an item with no owning
sequence is project-scoped (tempo, meter, assets) and sets all, and a
trackless item in this sequence that is not DirtyFlags::Context-flagged is a
structural sequence edit and also sets all.Production callers construct ProgramCompileRequest::invalidation from the
shared registry and exact CommitResult. Its constructor binds the dirty set to
that result's target snapshot, revision, exact predecessor snapshot, and an
immutable registry copy. Sparse reuse is allowed only when the predecessor is
the currently published project; a restored or forked lineage rebuilds in full.
submit() resolves that pinned input and remembers the generation that reached
publication. A different registry generation forces a full compile, including
at the same document revision. Do not resolve
outside the request and then drop the registry generation before submission.
Completion is keyed by CompileTicket::submission_epoch and
CompilerStatus::latest_published_epoch; revision equality is insufficient for
a same-document registry refresh. Treat the latter as a successful-publication
watermark: latest_published_epoch >= submission_epoch is terminal for a ticket,
meaning its request published or was superseded by a later successful
publication. Callers requiring exact-current document identity must also
require epoch equality and compare the published program identity/revision.
Epochs are scoped to one compiler instance: destroying the facade forfeits
completion observation, and a replacement compiler starts a new epoch domain.
If busy is false, an error with the watermark still below the ticket is
terminal failure.
CompileContextRegistry owns the lowering declaration as well as its
invalidation subscriptions. A ContentRendererRegistration binds an exact
registered-content type, schema version, codec provenance, output kind,
fragment-note ceiling, state policy, production declaration, and an
off-realtime noexcept compile hook. Always call declare(registration, schemas) with the immutable SchemaRegistry used to create or load the
content; the declaration records that registry identity and rejects mismatched
or duplicate provenance rather than accepting an order-dependent renderer.
The admitted surface is deliberately narrower than the enums suggest: notes
only, RegisteredRendererStatePolicy::Reset only, and at most 4096 fragment
notes per clip. CarryByItemId is refused until carried state has an exact
identity and lifecycle contract.
The hook receives RegisteredContentCompileInput with relative clip duration,
the validated payload, a CompileContextView narrowed to the declared
subscriptions, and the compiler's bounded note quota. Return an immutable
ContentProgramFragment; do not emit absolute arrangement positions or retain
the view. Missing exact resolution is
CompileErrorCode::UnresolvedRegisteredContent. A hook failure is
RegisteredContentCompileFailed. Output beyond the effective bound is
RegisteredContentFragmentQuotaExceeded, and its CompileError must carry the
offending clip plus exact actual and limit. Never degrade any of these to
silence.
Renderer production declarations participate in the compiled track and program
claims. Aggregate heterogeneous tracks with timeline::weakest; never preserve
the strongest claim just because it compiled first. The installed consumer
examples/timeline-sdk-consumer/registered_chord_renderer.cpp is the canonical
proof: schema and renderer registration, exact deterministic baseline and
semantic hash, exact CommitResult invalidation, epoch completion,
generated-track replacement with ordinary MIDI owner reuse, unresolved and
quota diagnostics, and weakest production aggregation. Keep it running in the
installed SDK smoke whenever this contract changes. Registrations are
process-local; they are not persisted in the Timeline document.
Nondefault renderer production declarations are also process-local:
ProgramWire refuses to serialize a program that carries one. This prevents a
remote process from inheriting a reproducibility claim without the hook that
justified it. A nested reference that trims registered content compiles by
window-after-generate: the hook sees the authored clip duration and an origin
tick rebased to the authored start, so a stateful pattern keeps its phase, and
the compiler windows the returned fragment to the retained span with the same
clamp-and-drop rule a trimmed note leaf uses. The fragment quota is charged
against what the hook generates, which is the authored extent.
Built-in note compilation applies the owning sequence groove at the original
owner-sequence onset. Move note-on/off by one shared displacement, intersect the
pair with the owning clip's half-open window, scale velocity half-up with
saturation, then subdivide the retained span for ratchets. Nested leaves carry
their owner sequence and source onset through lowering; never compose parent and
child groove. A trimmed nested MIDI leaf with authored groove selects over a window widened
by groove_timing_reach, so a note just outside a retained edge is still
available to be chased back in at its full authored length; whether it actually
sounds is decided afterwards by the clamp to the retained window.
Adding a CompileContextKind is a data change, with one trap. Both
CompileInvalidationIndex::build() and the CompileContextSubscriptions bitset
loop over [0, kCompileContextKindCount), so a new kind needs no new case in
either — but it does need kCompileContextKindCount bumped in lockstep with the
enum. Forget that and the new kind is never indexed, never dirtied, and every
test that only checks "my subscriber recompiled" still passes because the
subscriber recompiles for some other reason. The static_assert on the bitset
width catches only the ninth kind, not a stale count. Write the exactness test
so it names the readers of each kind separately: a per-sequence index and a
per-kind index are indistinguishable until two kinds have disjoint readers.
Proving invalidation exactness. PlaybackProgram::find_track() returns the
compiled TrackProgram the published program holds. The compiler reuses an
untouched track's program object outright, so an unchanged pointer is a
direct observation that a track was not recompiled, and a changed pointer that it
was. Assert on that, not on a proxy like a compile counter — and assert the
program generation actually advanced in the same test, or "unchanged pointer"
could just mean no compile happened at all. A dirty-set test that still passes
when the subscription is ignored and everything recompiles is vacuous; break the
resolution both ways (over-dirty and under-dirty) and confirm it goes red.
provider_production_declaration / track_production_declaration /
program_reproducibility (production_class.hpp) derive what a compiled
program may claim about being replayed, rather than storing it on the program,
so the claim cannot drift from what the compiler actually lowered. A render
spanning several classes aggregates with timeline::weakest, never with the
first or the strongest.Launcher or ExternalInput track today.
plan_compile rejects any TrackCompilePolicy whose provider is not exactly
Arrangement with available_mask == 1, so a PlaybackProgram can only ever
carry arrangement tracks even though ProviderSelectorProgram models three
kinds and really does gate rendering. Unit-test per-provider behavior against a
hand-built ProviderSelectorProgram; a test that tries to compile one gets
CompileErrorCode::InvalidRequest and proves nothing.BufferedContentSource composes audio::StreamingSampleSource with a zero
preload window, so every frame travels through the ring where it can be
counted. Deadline mode treats a zero producer return as “not ready yet,” not
permanent EOF: later pumps retry at the same frame or seek to a playhead that
already counted the interval as starved. Count starvation against the
declared frame count. Size the implicit ring for the declared wall-clock
lookahead, the largest audio callback, and any declared preroll, while
StreamingSampleSource independently caps producer read-ahead at the larger
of the lookahead and the preroll. A declared preroll is enforced
synchronously inside prepare/seek — a producer that cannot fill it fails the
call rather than silently under-delivering.BufferedContentSource is epoch-scoped, the
GeneratedEventSource::begin_playback_epoch shape: seek() and
loop_wrap() are control-thread operations that must begin a nonzero,
strictly newer epoch, and they tolerate an in-flight producer call — it is
stopped through its token within one chunk and its frames are discarded with
the old ring before the rebuilt ring is primed at the new position.
cancel_production() interrupts the in-flight chunk without repositioning
and without latching; the next pump retries the same position. The source
never wraps on its own: the transport owns looping and calls loop_wrap()
at the declared boundary.GeneratedEventSource is a bounded push handoff for producer-generated MIDI:
keep revisable staging separate from immutable committed SPSC slots, begin a
nonzero strictly newer playback epoch quiescently on seek/restart, and commit
complete half-open monotonic-tick batches only at the declared quantization
grid. Validate each UMP word count from its message type. Audio pulls never
regress the permanent elapsed frontier; a missing or discarded span reports
exact lag, emits no generated events, and requests active-note flush. A
deadline miss selects only the producer-declared fallback policy.core/playback/src/*.cpp is compiled by
timeline-program-threadless-no-exceptions-check with -fno-exceptions -fno-rtti and PULP_COMPILE_EXECUTOR_DISABLE_THREADS=1, and swept into both
wasm lanes by the closure gate. Anything that owns a std::thread or throws
belongs in a header or a sibling module, not in src/.PlaybackProgram is shared_ptr-woven and cannot leave the process that built
it. pulp/playback/program_wire.hpp is the crossing form: one contiguous,
self-describing byte range that carries indices where the program carries
pointers. Reach for it whenever a consumer does not share the producer's heap —
a Worker publishing to an AudioWorklet, or a helper process — and never try to
hand the program itself over some serialization of pointers.
Things worth knowing before changing it:
ProgramWireAutomationConsumer takes a move-only ProgramWireBytePin,
validates the exact prepared CompiledTempoMap object and its source tempo
points, and returns the candidate pin on reject/unchanged or the retired pin
on adoption. The caller supplies fixed lane state and explicit track/byte
capacity; the consumer also enforces the separate
kProgramWireMaximumAutomationLanes whole-publication ceiling. A rejection
changes neither the active pin nor cursor state, so rejected and retired
buffers may be poisoned immediately after their pins return.AutomationProgramView, then
runs the same AutomationCursor algorithm as an in-process
AutomationProgram. Adoption precomputes fixed-capacity lane/group topology;
block rendering uses the production mandatory-knot, optional coalescing,
per-device event, and aggregate work ceilings without allocation or locks.
This is automation parity only, not whole note/audio render parity.decode_program_wire hands back typed spans
borrowed straight out of the buffer. That is why the format asserts
little-endian at compile time and rejects a misaligned base address instead of
falling back to a copy. Adding a field that is not a fixed-size scalar — a
string, a variable-length blob — breaks that property; give it its own
section with its own (first, count) ranges instead.AudioRendererLimits (mostly offline-stretch and converter budgets governing
the compiler's host). The instance token is not excluded — see below.(producer_epoch, lane_id, generation, instance_token), and no proper subset works. The token's in-process job is
to stop an equal-generation replacement from masquerading as the active
program — AutomationCursor decides Unchanged on the lane key and the
token together — so ProgramWireAutomationLaneRecord carries it and a
consumer gets to reach the same answer the cursor does. producer_epoch
covers a different case and only that case: generation is minted per store
and restarts at 1, so a producer torn down and recreated looks
non-monotonic to a surviving consumer and would be refused forever on
generation alone, and two producers of one document both minting generation 1
would look like one publication without the epoch. Zero is refused for both
rather than acting as a wildcard — the epoch at encode and decode, the token
at decode only, since the compiler owns AutomationProgram's constructor and
always mints a nonzero one, so an encoder-side check would be unreachable.producer_epoch, where they came from one counter. Across epochs they are
incomparable, and across epochs the epoch has already decided. Equality only —
a larger token does not mean newer, since ordering is
(producer_epoch, generation)'s job.kProgramWireSectionOptional is skipped; an unknown section
without it is rejected. Bump min_reader_version only when an older reader
would misread the bytes, not when it would merely miss data — and decide
"merely" per payload, not per format: the controller sections are optional
on a payload that carries none and required, with the floor raised, on one
that carries any, because missing expression is a musical loss and not a
cosmetic one. Do not solve a new section by widening an existing record; a
wider record moves every older reader's stride and forces the floor up for
every payload, controller-free ones included.test/test_playback_program_wire.cpp catches a reordered field. If you change
the layout on purpose, re-pin it in the same change and say so. The digest is
taken over a payload whose lane instance tokens have been normalised to their
ordinals, because the token is minted per compile and would otherwise make the
digest depend on how many programs the process built first. Normalise any
future per-publication field the same way — write a fixed value into it rather
than skipping the bytes, so its offset and width stay covered.TempoPoints, because
CompiledTempoMap's segments are private and derived. encode_program_wire
therefore takes the points and refuses any that did not compile the program's
map — CompiledTempoMap::matches() is what keeps the two honest.The wire shipped without instance_token on the reasoning that producer_epoch
"replaces that guard across a realm." It did not, and the way it failed is worth
keeping.
producer_epoch answers is this a different producer? instance_token
answered is this a different program from the same producer? Adjacent
questions, and the substitution is sound for the case it was written against — a
producer torn down and recreated. It silently dropped the more common one: a
single worker recompiling. generation is caller-supplied, not minted per
compile, so two compiles of one document by one producer at one epoch agreed on
every field the wire carried and encoded to byte-identical payloads. A consumer
computing Unchanged from them reached the opposite answer to the in-process
AutomationCursor — a silently wrong render, not a decode error, which is the
class of bug a validating decoder cannot catch for you because nothing is
malformed.
Two habits come out of it:
tools/scripts/negative_capability_check.py (ctest
playback-negative-capability, selftest
playback-negative-capability-selftest) reads the refusal-shaped members of
CompileErrorCode and of TimelineGraphAdmissionCode — anything spelled
Unsupported, NotSupported, Rejected, Refused, or Disallowed — finds
every site that raises one, and decides whether the refused construct is
reachable from the timeline authoring surface. Both enums are read because a
document is refused in two places, not one: the playback compiler refuses what
it cannot lower, and graph admission refuses a device chain shape it will not
build. A checker pointed at one enum reports a clean registry while the other
enum's refusals accumulate unowned, which is the failure this gate exists to
prevent. An authorable refusal needs an entry in
tools/scripts/negative_capability_allowlist.json carrying an owner, a
status of live-defect or intended, and a reason.
The class it guards is worth naming: a construct a user can author and the compiler then refuses is worse than the construct not existing. The document saves, reloads, copies and round-trips, and only playback says no — with nothing at authoring time to warn anyone. The gate does not forbid these; it forbids adding one for free.
A refusal reads as authorable when the source above the raise reads a symbol
declared in core/timeline/include/pulp/timeline/** or named by
core/timeline/schema/timeline_schema.json — a model accessor, a model type,
an enum constant, a schema field. A refusal that only inspects internal
lowering state passes without an entry.
Naming a code is not raising it. Two mentions are excluded on purpose: a
field whose declared default happens to be a code, and a case label. A table
that maps every CompileErrorCode member to a wire name — the shape any
projection of the codes over a wire needs — mentions all of them at once, and
each label reads its own enumerator, so without the exclusion the table reports
as a raise of every refusal it can spell while raising none. Only the label
text is dropped, never the line, so a raise sharing a line with a label is
still found; the selftest holds both halves of that boundary. If you are adding
such a table, expect the gate to stay quiet about it and keep the real raise
sites in core/playback/src/ as the thing it is watching.
Three things it cannot see, so do not read a pass as "the compiler accepts
everything authorable": a refusal expressed by dropping, clamping, or
substituting rather than by naming a code; a refusal raised through a different
error enum, such as an importer's or a renderer's; and an authored read that
sits further than AUTHORING_LOOKBACK_LINES above the raise or arrives through
an internal struct field that no longer names its model origin.
A case label is a destination, not a raise. The check consumes
case Scope::CompileErrorCode::Enumerator: before it looks for raises, because a
switch that maps every enumerator to its own name for a diagnostic otherwise
reports one refusal per arm — and the enumerator sitting in a neighbouring arm
lends its name to the lookback window, so each of those phantom refusals also
reads as authorable on evidence it never touched. Only the label text is
consumed, never the whole line: a refusal constructed in the arm's body is still
a raise, including on the same line as the label. A label on some other enum is
left alone, because it names no code for the raise pattern to find. One shape it
still reads as a raise, deliberately, because over-flagging asks for an entry
someone must answer rather than dropping one that is owed: a
code == CompileErrorCode::X comparison, which names a code without
constructing one.
Every entry carries a status: live-defect is tracked, not resolved, and
intended says the refusal is the answer. Retiring a refusal is removing its
raise site and its entry in the same change; the gate fails an entry whose
raise site no longer exists, so a reason cannot outlive its code. The selftest
takes the entries it drops from the allowlist document itself rather than from
a list of its own, so a retirement cannot fail it — a gate that goes red when
the code improves teaches people to edit the gate. Its synthetic raise and
case-label fixtures do still name enumerators (MidiExpressionLaneUnsupported,
TrimmedGrooveUnsupported, NestedMixerPanUnsupported) and the header
fixture splices after MidiExpressionLaneUnsupported,; deleting one of those
members from CompileErrorCode fails the selftest loudly and means re-pointing
the fixture, not weakening it.
The inbound sequencer tiers in tools/cmake/PulpLinkFloor.cmake include the
dependency-free music rung. pulp::signal uses that canonical pitch/scale
contract, so a sequencer plugin reaches music through its ordinary audio
closure even when its own sources do not include a music header. Keep music
in sequencer-editor, sequencer-plugin, and sequencer-plugin-editor
together: pulp::timeline also consumes the same theory owner, and the two
positive link-floor fixtures exercise the plugin and plugin-editor tiers. This
is a shared public-contract dependency, not per-target link debt.
playback's floor is declared in MODULE_FLOORS in
tools/scripts/timeline_engine_dependency_floor_check.py, which scans both
#include <pulp/<module>/...> in every source file under core/playback/ and
target_link_libraries in its CMakeLists.txt. Both axes must stay inside the
declared set, so reaching for a format, host, or view type fails the gate even
when the build would have linked.
project_package has its own floor above Timeline: it may reach timeline,
timebase, platform, and runtime, but it must not reach playback. Package
publication or recovery must not widen playback's row.
The link axis is transitive, and playback is the module that shows why. The
check follows what a linked library itself links, to a fixed point, so a row
cannot stay green by depending on a module that breaches it. core/playback
links pulp::audio, which links pulp::state, pulp::signal and
pulp::sample-bank-manifest PUBLIC and, through pulp::state, pulp::events
PRIVATE — four modules the row never named. Those are recorded in
LINK_CLOSURE_DEBT, deliberately not folded into MODULE_FLOORS: a floor row
also governs which headers the module's sources may include, so widening the row
would have granted core/playback the right to #include <pulp/state/...> as a
side effect of writing down a link fact. An entry there is a debt, not a
permission — cut the underlying link and delete the entry, and the gate tightens
with no other edit.
The PUBLIC/PRIVATE split survives the trip and matters for a
pay-for-what-you-use claim: state, signal and sample-bank-manifest arrive
PUBLIC, so their include directories propagate and a playback consumer genuinely
can reach <pulp/state/...> today; events is PRIVATE and link-only; and
signal is an INTERFACE library, so paying for it costs headers rather than
object code. Whether playback should reach the state store is an open design
question the debt list does not answer — it exists so the gate can police
whatever answer is reached.
The table holds every engine-adjacent module, not just playback, and the selftest
is generic over it. Adding a module there is how a new core/ target gets the
same enforcement; it does not widen anyone else's floor.
The rows are not independent, and "cannot reach X" is usually the wrong half of
the argument. timeline_editor's row is a strict superset of timeline's, so a
claim of the form "this type must live in core/timeline because that module
cannot link view" is true and proves nothing — it is equally true one rung up.
When a floor row is offered as the reason for placing something, check which row
excludes which: that is the only asymmetry between two rungs in a chain, and
it is what the gate can actually act on. The worked example is the edit vocabulary
(EditIntent), which sits at the editor rung precisely because timeline's row
excludes timeline_editor and can therefore reject a reducer or serializer that
reaches for a gesture verb.
core/timeline_editor carries a floor that deliberately excludes playback, and
the selftest asserts that pair by name in both the include and the link
direction. An editor learns where the playhead is through
timeline_editor::SequencerUiHost, whose implementation lives with whoever owns
audio — so a plugin that draws a piano roll over its own engine consumes the
editor without acquiring a transport.
The module does not acquire a transport; the plugin binary does. That row
governs core/timeline_editor's own includes and links, and it holds. It says
nothing about what the plugin packaging adds around it, and measuring the other
direction shows the difference. tools/cmake/PulpLinkFloor.cmake walks CMake's
resolved link graph for a consumer; run over StepSequencer_CLAP it reports:
playback: StepSequencer_CLAP -> pulp-view -> pulp-view-script
-> pulp-view-core -> pulp-host -> pulp-playback
VST3, CLAP and AU each link ${_PULP_VIEW_TARGET} unconditionally in
PulpPluginFormats.cmake — drawing or not — and the view stack reaches the
plugin host and, through it, this module. So every Pulp plugin links playback
today, and the outbound gate is right to stay green about it: nothing in
core/playback or core/timeline_editor reached upward to cause it. Cite the
editor row for what a module costs, and a link-floor report for what a
binary costs; they are different claims and only one of them is about the
artifact a host loads. The inbound side is documented in the timeline skill.
"Every Pulp plugin links playback" is true of a desktop configure only.
The chain runs through pulp-host, and core/host is behind NOT IOS — iOS
disallows dlopen of third-party plugins, so hosting is not built there and the
pulp-view-core -> pulp::host edge is dropped too. One guard therefore removes
host, playback and timeline from an iOS closure, because that edge is the
plugin's only route to all three. Anything asserting playback is present in a
plugin binary must say which configure it means; entries a guard can remove
must be appended to PULP_LINK_FLOOR_DEBT_<target> under that same condition
rather than declared unconditionally, which reads their absence as rot. See the
timeline skill for the full rule.
Read that report as an upper bound and nothing more. TIER proves only that
nothing outside it is reached, so a tier can name playback — or the editor
rung — while the binary links neither, and still pass. If what you need to show
is that a module is in the artifact, say so with pulp_assert_link_floor's
REQUIRE list, which fails naming any module that is absent from the measured
closure. StepSequencer_CLAP does link playback, by the chain above and only
by it; it does not link timeline_editor at all. The positive inbound proof is
TimelinePluginProof_CLAP: it requires format timeline timeline_editor timeline_view under the sequencer-plugin-editor tier. That tier includes the
base view and canvas rungs intentionally reached by the concrete piano-roll
view, while packaging-driven playback remains per-target debt. Its processor
implements the host seam and embeds the real piano-roll view, while the editor
and view modules remain independent of playback.
That interface hands out UiPlayhead by value, and the reason is specific to
this module: TransportSnapshot borrows const CompiledTempoMap* from the
compiled program. That is correct for a block renderer, which consumes the
snapshot inside the callback that produced it, and unsafe for a view, which keeps
its copy across frames while the engine may adopt a different program underneath.
Never widen the UI-facing seam by passing a TransportSnapshot — project the
fields a view needs into values, as UiPlayhead does. UiPlayhead::program_generation
is what lets a view tell a stale reading from a live one without holding anything
a program swap can invalidate.
A value type both rungs genuinely need goes in core/timebase, never duplicated
into each. timebase is the whole of what the two floors have in common beyond
platform/runtime, so it is the only home that does not require widening a
row. LoopRegion is the worked example: playback::LoopRegion is an alias of
timebase::LoopRegion beside the existing MeterSignature one, and
UiPlayhead::loop names the same type — a loop set on the transport reaches an
editor reading with nothing to convert. Do not read this as licence to share the
readings themselves: TransportPlayhead and UiPlayhead stay separate
because their fields differ in kind, not merely in spelling.
MasterTransport publishes desired_ toward the audio thread and
TransportPlayhead back toward everyone else. The audio thread writes the
second one for every block it accepts — a block whose ranges failed validation
is one the caller was told not to render, so it must not become the position a
view draws either — and playhead() reads it from a view, a meter, or a test,
allocating nothing and taking no lock. prepare() and reset() publish too, so
a reader between a lifecycle change and the first callback sees the transport's
starting state rather than the previous program's position or a default one.
Four properties of it are decisions rather than accidents:
ranges[0].timeline_tick_start),
not its last. That frame has not left the device yet, so it is the
least-ahead-of-audible position the transport can honestly state; publishing
the block's end would put every reading a whole buffer into the future.timeline_editor::UiPlayhead. The floor
above forbids that include outright, and the split is right independently of
the gate: UiPlayhead::program_generation names a compiled program, which a
transport does not know about. Whoever implements SequencerUiHost owns the
projection and supplies the generation from the program it adopted.sequence survives reset(). Every other field of the reading is cleared
there and the counter is deliberately excluded, because a reader tells
readings apart by sequence and a restarted counter would let a fresh reading
impersonate one the reader already drew. reset() publishes a retired reading
rather than leaving the previous lifecycle's position readable until the next
block, which is exactly the moment a view would otherwise draw a playhead
belonging to a program that is gone.publish_playhead() takes the
reading, assigns the sequence, and writes; no call site assigns the counter
itself. A site that forgot to would publish a reading a reader treats as one
it already handled — a silent stall rather than a build failure, which is why
the stamp is structural rather than a convention.SeqLock is the primitive because the payload is a trivially-copyable
multi-field struct that a reader wants the newest of, whole. TripleBuffer
would also work and costs 3x the storage for nothing at this size; SpscQueue
is wrong in kind — a view wants the latest reading, never every reading.
Publishing from reset() makes the control thread a second writer of a lock the
audio thread otherwise owns. That is the shape reset() already has for
desired_, whose ordinary writer is the control thread, and it is bounded the
same way: a caller that reset a transport concurrently with begin_block()
would be racing the plain assignments in reset() long before it raced this one.
playback, and that absence is the contractMODULE_FLOORS carries a timeline_view row above the editor kernel. It admits
timeline_editor, timeline, timebase, view, canvas, platform, runtime — and
deliberately omits playback.
That omission is the load-bearing part, not an oversight: it keeps a view's only coupling toward
audio the SequencerUiHost interface, so an arranger drawn over somebody else's engine acquires
no transport. If you find yourself wanting to widen that row to reach playback, the thing you
actually want is a host implementing SequencerUiHost — the row is what stops a view reaching past
the seam and binding to this engine specifically.
(It also omits project_package, keeping storage a sibling rung rather than a base: an editor is
proven against a serialize_project round trip, and re-hosting it on a package protocol later is
adapter work above the row rather than a change to it.)
kCompileContextKindCount is an array dimension, so changing it is a struct-layout changeThe invalidation index stores std::array<std::vector<ItemId>, kCompileContextKindCount> in two structs in compile_invalidation_internal.hpp,
and the subscriber walk loops to the same constant. That is convenient — adding
a context kind needs no new reverse-index case, and no exhaustive switch to
extend — but it means bumping the count silently resizes those structs.
Treat it as a struct-layout change: build all targets, not just the timeline and playback ones. Stray positional initializers fail closed at compile time, so they are safe, but only a full build surfaces them, and a partial build pushes that discovery to CI.
Before assuming a new kind needs reverse-index work, check for an exhaustive
switch over CompileContextKind — at time of writing there is none, and the
count-parameterised arrays are why.
FORBIDDEN_LINKS withdraws the link halfMODULE_FLOORS in tools/scripts/timeline_engine_dependency_floor_check.py
governs both what a module's sources may #include and what its build file may
link, from one set. That conflation is fine until a module legitimately needs a
header from a module it must not link.
FORBIDDEN_LINKS in the same file is the escape hatch: it names, per module, a
dependency the floor admits as an include and rejects as a link. timebase
keeps runtime in its floor so pulp/runtime/result.hpp stays reachable, while
the link is withdrawn so libpulp-runtime.a (and the mbedTLS archives its
PRIVATE link items drag along) stay off a consumer's link line.
Two consequences when editing that script. The selftest's fixture generator has
to agree with verify() about which half each name carries, which is what
linkable_floor_names() is for. And an entry reports a missing build file
rather than passing quietly, so it cannot outlive its subject.
nested_clip(id, sequence_id, start, duration, source_start = 0) defaults
source_start to 0, which produces a right trim: the reference admits
the child's first duration ticks. test_timeline_nesting_playback.cpp's
long-standing trimmed_nested_lane_project uses that default, so left_trim
is always zero there.
This matters because controller chase is a left-trim concern: it only runs
when the retained window starts inside the child. A chase implementation
verified only against that fixture has never executed — the tests pass while
the code is unreached. Use source_start > 0 (see
left_trimmed_nested_lane_project) to exercise chase, and keep a right-trim
case too, since dropping points past the window end is the mirror failure.
The retained window is half-open in child-local ticks: points before it set
what sounds on entry, points at or after left_trim + target_duration are
never reached and must not be emitted.
kTicksPerQuarter is 705'600, so one tick is about 0.034 frames at 120 BPM and
48 kHz: roughly 29.4 ticks to a frame. ticks_to_samples() rounds to nearest,
so a trim of up to fourteen ticks moves no frame edge at all. The window a
sub-frame trim produces correctly spans the whole artifact while the clip
covers less than the whole authored tick range.
So a sample-domain trim predicate and a tick-domain one are not equivalent,
and a validator must never assert that they are. validate_clip_program() did,
and refused a correct clip: link_audio_track_program() answered InvalidAsset
for a nested stretched clip nudged by a single tick. Assert the implication that
survives the resolution gap instead -- covering the whole authored range means
reading the whole artifact -- and leave the converse alone, because it is false.
Two consequences for fixtures. A trim written as kTicksPerQuarter / 4 is
exactly 6'000 frames at that tempo and rate, so a table built only from quarter
fractions is frame-aligned throughout and cannot see any of this; spell an
awkward tick count when the frame grid is what is under test. And
link_audio_track_program() is the only caller of validate_clip_program() --
ProgramCompilerTask constructs an AudioTrackRendererProgram directly as a
friend -- so a test that only compiles a program never reaches that validator
and cannot fail on anything it holds.
A groove belongs to a Sequence, not a Track: Sequence::groove() exists, Track
has none, and the compiler reads context_sequence->groove() when it lowers notes. So
the musically obvious request — "straight hats, snare a little early, bass a little
late" — is authored as one nested sequence per lane, each carrying its own
timeline::GrooveTemplate, referenced from an arrangement track by a SequenceRef
clip. There is no per-track feel knob, and looking for one wastes time.
That shape works exactly. At 120 BPM / 48 kHz a quarter is 705'600 ticks and 24'000
samples, so one tick is 5/147 of a sample — exact for any tick that is a multiple of
147. An authored -7'350 ticks lands a note 250 samples early and +11'025 lands it 375
late, at both the compiled NoteProgramEvent.sample and the position the renderer
emits. When asserting this, derive the expected samples by hand rather than by calling
the same conversion under test, and keep a no-groove render as the control: without it
a lowering that silently dropped the groove would pass.
The order-preserving refusal does not run here. timebase::OrderPreservingGrooveKernel
rejects a reordering table with GrooveKernelError::ReordersEvents, but it has no
consumer on the compile path. timeline::GrooveTemplate::create validates only that
each offset is smaller than a step, plus velocity and strength bounds — no monotonicity
check, and the model says the omission is deliberate. So within a lane a two-entry table
leaning opposite ways can push step N past step N+1 with nothing refusing it, and across
lanes independent grooves are unconstrained by construction. Treat "the kernel guarantees
order" as true only of the kernel, never of an authored document groove.
groove_timing_reach() returns a supremum, not an estimate: swing's
displacement map is piecewise linear with its extremum exactly at the pair
midpoint, a step table adds its widest authored offset, and the two compose
additively. It short-circuits to zero when a groove states_no_feel() or its
timing_strength() is zero.
That short-circuit is a cost guard, not a behavioural one, and no black-box
fixture can make it fail — a confirm_failure.sh cycle over it correctly
returns NOT CONFIRMED. The reason is the sounding clamp in
program_compiler.cpp: a note the pad newly admits lies entirely outside
clip.start(), and a groove that displaces nothing has nothing to carry it back
inside, so sounding_end <= sounding_start and the note is dropped as
zero-length. The right edge behaves the same way. Padding a window whose groove
moves nothing therefore changes compiled output by exactly nothing, by
construction.
That was measured, not argued: with the strength-zero clause deleted and a probe note planted squarely in the pad region, every feel-free assertion still passed, while the same note was plainly visible under an authored groove. Keep the clause — padding a window that provably needs none is waste — but do not claim it is covered, and do not add a fixture that appears to cover it. Demanding coverage for a branch with no observable behaviour is a category error, and the usual way it gets "satisfied" is by quietly weakening a neighbouring assertion.
The pad is invisible to most notes for a second reason worth knowing: the
lowerer measures clipped_note.start from the padded window and the
compiler subtracts pad_left back off, so the arithmetic cancels. Only a note
the pad newly admits reads differently.
program_wire_encoded_size rejects programs it has no section for — an audio
program (AudioProgramUnsupported), a non-default production declaration. A
value that is not safe to ignore fails closed rather than returning a copy
that plays thinner than the program meant. Note the tempo-point check fires
before the per-track loop, so a fixture passing no tempo points is refused
for that reason first — an encode-refusal test in a suite without a tempo
fixture will pass for the wrong reason.
Controller events are no longer on that list. Wire version 3 carries them in
two appended sections (ControllerRanges, one (first, count) per track, and
ControllerEvents, one ProgramWireControllerEventRecord per
ControllerProgramEvent), and ControllerEventsUnsupported is retired
because the case it refused works — a retirement earned by the round trip, not
by moving an assertion. Things to know before touching it:
arrangement_controller_events() in the program's sequence and the decoder
preserves it; program_wire_matches compares position for position, so a
swapped tied pair is a mismatch. The wire does not enforce
controller_program_event_less order on decode — see the finding below.kProgramWireVersion is
3 for every payload; min_reader_version is 2 when the program carries no
controller events (both appended sections flagged optional, so a version 2
reader skips them and renders exactly the program) and 3 when it carries any
(sections required, so a version 2 reader refuses at the header). Both are
functions of the counts, which is what keeps one program at one encoding.
The one outcome the format never produces is a floor of 2 over a non-empty
controller section — that is an older reader silently dropping expression,
the loss the old refusal existed to prevent. In the other direction a
version 3 reader accepts a version 2 payload with the sections absent, gated
on header.version < 3; the same bytes stamped 3 are a non-canonical payload
and refuse with MissingSection.ProgramWireTrackRecord, precisely so a
version 2 reader's stride over every section it knows is what it was. The
sizeof asserts in program_wire.hpp are the honest diff: two new lines,
every existing value unchanged.count is judged against
kProgramWireMaximumControllerEventsPerTrack — the compiler's own
kMaximumControllerEventsPerTrack in program.hpp, shared so the two cannot
drift — before its range check, so a corrupted count is InvalidLimits
rather than a walk. An address wider than four bits or a zero clip, lane or
point identity is MalformedControllerEvent, judged by
timeline::midi_lane_address_well_formed rather than a second rule. An
origin outside the enum is InvalidEnum. Each is covered by a resealed
corrupt-and-restore case in test_playback_program_wire.cpp.program.hpp documents arrangement_controller_events() as being in
controller_program_event_less order, but program_compiler.cpp has no
controller sort stage (notes have SortTrackNotes; controllers are pushed
clip by clip, lane by lane, point by point) and nothing in core/ calls the
comparator. Two lanes on one clip therefore emit lane-major, not time-major.
The order is total over distinct events — MidiContent::create makes point
ids unique within a content and addresses unique across its lanes — so the
wire has one sequence to preserve and preserves it; a consumer that needs
time order must sort, and a decoder that enforced sorted order would refuse
every multi-lane program the compiler emits today.Allocate an aligned wire buffer with ::operator new[](bytes, std::align_val_t{N})
and free it with the matching ::operator delete[](p, std::align_val_t{N}). The
new-expression form new (std::align_val_t{N}) std::byte[n] compiles on Clang and
GCC but MSVC rejects it (C2956: the usual aligned operator delete[] would be chosen
as the placement deallocation function), so it breaks only the Windows build.
sequence_content_lowerer.cpp flattens a SequenceRef into leaf clips on the
referring track, so anything the child track owned has to find a home on a leaf
or be refused. Gain has one: nesting stacks gain stages in series, and the
flattened leaf carries their product —
placement.gain * child_track.gain * leaf.gain, composing again at each extra
level. Unity is exactly the identity, so a transparent nesting returns the
float the leaf authored rather than a rounded near-miss, and an equality
assertion on the composed value is measuring composition rather than rounding
when the fixture uses powers of two.
A placement fade composes too, but it cannot fold, and the difference is
the whole design. A product of gains is a gain; a ramp is time-varying, and two
ramps of different shapes reduce to no third shape. So the placement's envelope
travels beside the leaf instead of inside it: LoweredClip::placement_fades
carries every enclosing ramp in owner-timeline ticks, audio_renderer.cpp
converts them to clip-relative frames as
AudioClipRendererProgram::placement_fade, and clip_fade_envelope.hpp
multiplies them into the leaf's own envelope. Nesting a level deeper appends;
nothing collapses.
Four things about that carrier are load-bearing, and each is a way to get it subtly wrong:
Linear, wrong for every EqualPower fade. That error measures
close and sounds wrong.silent_frame is where the ramp is zero, open_frame where it is unity, so
a fade-out is a fade-in with the ends exchanged and there is no direction
flag to get backwards. Past the open end the segment is skipped; past the
silent end it returns zero.detail::clip_envelope has a
call site in realtime_stretch_renderer.cpp that runs once per output
sample, which is why progress is narrowed to float before the shape lookup
there (pinning EqualPower to sinf). placement_fade is null for every
clip that was not nested under a faded placement, so the ordinary clip pays
one predictable branch and no extra transcendental. Do not add an unguarded
second fade_gain call.An inner placement's own fades are lifted out of the clip at
sequence_content_lowerer.cpp's nested-SequenceRef branch: the re-placed
nested_clip is built with its fade durations zeroed and the authored,
untrimmed ramp recorded instead. That is not an optimization. The clip carries
the trimmed window, Clip::create runs valid_playback_properties, and a
fade wider than its clip is rejected — so leaving the fades on the clip turns a
trimmed faded nesting into InvalidStructure before the walk ever reaches it.
Reading the ramp off the untrimmed extent is also the musically correct answer:
a trimmed placement enters its fade part way up.
Two neighbouring cases keep their own refusal rather than being folded away — the code names which obstacle it hit:
NestedMixerPanUnsupported — a clip carries no stereo placement at all, and
the parent track's single pan also serves everything else on that track.
Unlike gain there is no sink for any content kind.NestedGainSinkUnsupported — a composed gain lands on the leaf's clip gain,
and clip gain only reaches a renderer for media content. Note, registered
and opaque leaves compile to events, and nothing scales an event by the gain
of the clip that carried it, so folding a child fader into one would discard
it silently. This is the easy thing to get wrong: the composition looks
correct in the lowerer and is simply never read.NestedPlacementFadeUnsupported still exists and asks that same sink
question about the envelope. A fade is a time-varying gain, so a placement
fade over a note, registered or opaque leaf has nowhere to land either, and it
refuses rather than playing that leaf at full level through an envelope the
author wrote. It is scoped to the leaves a ramp actually reaches: a note leaf
lying wholly past the ramp reads unity and compiles fine. When that refusal
fires it names the leaf, not the placement — which is the tell that it is the
sink case and not the old whole-envelope refusal it replaced.
A leaf's own fade is the case this is all easiest to confuse with, and it behaves differently on purpose. A leaf fade is measured from the clip's edge and a trim moves that edge, so the retained fade is the authored one minus the trim, clamped to what is left of the clip — edge-anchored, not the same ramp entered part-way through. A placement ramp does the opposite: it keeps its authored edges and the leaf is read at whatever progress its position implies. Both are right; they answer different questions.
So a nested child holding notes still refuses a fader, and the fixture that
proves it must use media content to see composition at all. Read the composed
value through TrackProgram::audio_program()->clips()[n].gain_linear, and the
ramps through the same clip's placement_fade.
Four track states look alike in the document and split cleanly once you ask
what reads them. begin_track handles a top-level track and substitutes:
freeze() calls output.clear() and returns Freeze; a valid
active_take_lane_id() clears and returns ActiveTake. Both discard the
arrangement and stand something else in its place. The nested walk does no
such thing — step_reference descends straight into track.clips() and never
calls begin_track at all.
That asymmetry is the whole rule. A nested frozen or comped track would play
precisely the arrangement its author replaced, so each refuses under its own
name: NestedFrozenTrackUnsupported and NestedActiveTakeUnsupported. They
do not share a code, because the construct that would lift them is the same
one but the reason a reader hits them is not — and a shared code sends you to
the wrong half of the document.
A freeze is a rendered artifact anchored in absolute samples, and nothing
about it can be re-derived: it either lands where it was rendered to land or it
is a stale render playing at the wrong time or level. So the question the
lowerer asks is not "can the artifact be mapped through this nesting?" but
"does this nesting transform its child at all?". Where the answer is no, the
artifact is already in the right place and lowers as an absolute MediaRef
leaf carrying the same media over the same samples; everywhere else the
refusal stands, exactly as before.
nesting_is_transparent is the whole of that judgement, and it is built to
fail closed: a NestingTransformation enumerator with no case in
nesting_imposes reaches a trailing return true and is reported as
imposed, so a transformation nobody has reasoned about refuses rather than
permits. Adding an enumerator without answering for it cannot widen the
permit.
Three things about the enumeration are worth knowing before you edit it:
time_conform and a child track's modulators / macros /
modulation_routes are read by neither this walk nor begin_track, so a
child carrying one is neither honoured nor refused anywhere else. Without an
entry here, a modulated fader under a sealed artifact would be silently
permitted.compile_track_freeze_program or compile_take_comp_segment_program, both of
which take the projected timeline span as the renderable length; a lowered
leaf compiles through the generic absolute-clip path, which takes the source
length scaled and rounded up. Those agree only when no rate conversion
happens. Do not delete the check as redundant with either compiler's own rate
validation — that validation runs on a path the lowered leaf never takes.SequenceRef clip cannot carry
a conform or an absolute anchor (Clip::create and create_absolute reject
both), so no refusal test can exercise those entries and none pretends to.NestedActiveTakeUnsupported is narrowed by the same nesting_is_transparent
call — one construct, two payloads, and the eighteen nesting observations are
the same eighteen questions for both. What differs is what gets emitted and
what ArtifactRate has to look at.
SealedArtifact is how the predicate carries the difference: exactly one of
freeze / active_take is set, and neither being set returns imposed, in the
same fail-closed direction as the switch's trailing return true. For a comp,
artifact_rate_differs asks the question once per take a segment draws from
— not per take in the lane, because a lane may hold takes the comp never
selects and a rate those carry is not a rate anything would convert.
emit_sealed_active_take is the payload. Per comp segment: resolve the take the
segment names, read the source offset as the distance from that take's
placement_start to the segment's range.start, and emit an absolute leaf over
MediaRef{take.media.asset_id, take.media.source_start + offset, segment.range.sample_count} at segment.range.start. That is the arithmetic
compile_take_comp_segment_program performs, re-derived on the document side
rather than shared, because the two build different things — a document clip
and a renderer program — and what they owe each other is the rendered samples.
A test asserts that identity; no comment should be trusted to.
Two consequences worth knowing:
begin_track renders an empty comp as no clips too, so the nested and
unnested documents agree on silence.TakeCompSegment ordinal collision is out of reach from this lane.
link_audio_track_program identifies a comp-segment program by a bare ordinal
(segment_index + 1), so two copies of one comp on one track collide. A
lowered comp never produces that program kind — it produces ArrangementClip
leaves carrying generated document identities — and the pair that would have
collided cannot be authored anyway: transparency pins a placement to its
child's origin, so a second transparent placement on the same track would have
to overlap the first, and Track::create rejects the overlap. A second
placement on a different track compiles and sounds its own copy, which is
what a placement means for ordinary child content too.record_armed() and the bare take_lanes() list are read by neither path,
and refusing them rejected documents that already compiled correctly. Three
independent places corroborate this before you trust it:
core/playback — in the guard that used
to refuse it, and nowhere else;sequence_preflight.cpp resolves media for freeze->media and
active_take_lane->comp_segments() only, so a dormant lane is never even
resolved to an asset;timeline_playback.cpp) skips a track for
freeze() || active_take_lane_id().valid() and consults neither of the
other two.So when you are deciding whether a new child-track state may nest, do not ask
whether it is "set". Ask whether anything substitutes on it. If the state only
records intent — arm, an unselected lane — the nested walk lowers the same
clips it would have lowered without it, and the test that proves so should
assert whole-event identity (NoteProgramEvent's operator<=> is
defaulted, so std::equal over the spans compares every field) rather than
merely that no error came back.
One fixture trap: prove a dormant lane inert with a lane holding a real
take against a declared project asset. An empty lane is trivially inert and
proves nothing, and TakeLane::create imposes no non-empty requirement, so
the weak fixture compiles and looks like evidence.
CompilerStatus says how much of a compile was incremental, so do not time itCompilerStatus::active_tracks_completed is partitioned by
active_tracks_recompiled and active_tracks_reused. A track lands in
reused when the dirty set spared it and its TrackProgram was carried over
from the live program untouched; it lands in recompiled when a fresh one was
built. The two always sum to active_tracks_completed, and take_pending
clears all three together through clear_active_track_counts() so the
partition can never describe a previous request.
Read those counters when you need to know an edit stayed incremental. The tempting alternative — assert the compile finished quickly — measures the host as much as the compiler, and a one-track edit that silently started rebuilding the whole sequence still fits inside a generous millisecond ceiling on a fast machine while blowing a tight one on a busy machine. The counters are exact everywhere.
Two things force a reused-looking track back onto the recompile side, so
expect them rather than treating them as a lost optimisation:
requires_generation_refresh (offline Stretch artifacts whose publication
provenance is generation-specific) and any capacity refusal, which fails the
whole request instead of completing the track.
まだレビューはありません。使ってみた感想をお寄せください。
概要と使いどころ
Optional AAX support for Pulp, including developer-supplied Avid SDK setup, CMake enablement, DigiShell/AAX Validator workflows, and local AAX builds on macOS or Windows.
日本語の概要は準備中です。原文の説明を表示しています。
Configure, implement, and test Pulp's optional desktop Ableton Link tempo-sync adapter while preserving the developer-supplied SDK, licensing, realtime, latency-compensation, and no-install boundaries.
日本語の概要は準備中です。原文の説明を表示しています。
Maintain Pulp's installed design-time agent capability manifest and public-surface ledger. Use when adding, removing, renaming, or materially changing public audio, MIDI, signal, timebase, or sequence APIs; registering a new algorithm for generators; changing capability support or deprecation state; or repairing agent-capabilities freshness, schema, fingerprint, tombstone, or installed-SDK tests.
日本語の概要は準備中です。原文の説明を表示しています。
Android platform development for Pulp — NDK cross-compilation, Oboe audio, Dawn/Skia GPU rendering, JNI bridge, touch interaction, emulator workflows, and end-to-end smoke validation. Covers build, deploy, debug, and the gotchas discovered during bringup.
日本語の概要は準備中です。原文の説明を表示しています。
Optional ARA support for Pulp, including developer-supplied ARA SDK setup, CMake enablement, adapter companion APIs, validation, and ARA-aware plugin implementation guidance.
日本語の概要は準備中です。原文の説明を表示しています。
The measurement surface for ALL Pulp DSP and audio-pipeline work — read it BEFORE writing or gating DSP, not only when something already sounds wrong. Covers the C++ harness (signal generators, metrics, assertions, RenderScenario, contracts), the offline Audio Doctor (magnitude/frequency response, THD/THD+N, phase/group delay), and their Python sibling the Audio Quality Lab (tools/audio/quality-lab — null residual + alignment, LTAS log-spectral distance, spectral flux/centroid, HNR, Theil-Sen drift slope, Kaiser-sinc resampling, license-guarded corpus, regression-net ratchet). TRIGGER on AUTHORING work — "build/design an oscillator/filter/synth/effect", "add a DSP module", "what should the acceptance gate be", "how do I measure aliasing / anti-aliasing / alias floor", "null against a reference", "is this DSP correct", "choose a tolerance", "golden/regression corpus for audio", "measure drift or jitter", "A/B two renders" — AND on DEBUGGING work — "is there sound / no audio / I hear nothing", "does this filter/compressor/synth/delay produce the right signal", "prove the DSP / prove the contract", "measure the frequency response", "what's the THD / is it distorting", "what's the group delay / phase response / measured latency", "magnitude response curve", "render a test tone and assert", "audio regression", "64-frame works but 128 is silent", "sample-rate change pitch-shifted it", "describe what's in this buffer", "audio doctor", "compare before/after a DSP refactor". Reach for this BEFORE hand-rolling any FFT, null test, alias measurement, pitch tracker, or golden-render script — most of it already exists in one of the two lanes. Test/tool layer over HeadlessHost — deterministic, no audio device, no speakers. Off the realtime thread entirely.
日本語の概要は準備中です。原文の説明を表示しています。