PulpSampler¶
PulpSampler is Pulp's production-shaped sampler example. It combines
eight-voice MIDI playback, resident sample publication, strict file-backed
streaming, loop and interpolation policies, starvation handling, and an
optional synthetic heritage-processing chain. The validated plugin format is
CLAP.
The implementation is in examples/PulpSampler/. It is an example integration,
not a promise that every record in sampler_api.hpp is already exposed through
every plugin-format adapter.
This example uses the shared polyphonic/random-access page-service model. See the sampler playback chooser before adopting it for a sequential single-consumer or fully resident product.
Build and load¶
cmake -S . -B build -DCMAKE_BUILD_TYPE=Release \
-DPULP_BUILD_EXAMPLES=ON
tools/ci/governed-build.sh cmake --build build --target PulpSampler_CLAP
The bundle is written under build/CLAP/. The processor accepts MIDI and has a
stereo output. It reports an indefinite tail because a held or looping voice
can sustain until note release.
Playback controls¶
The example exposes gain, ADSR, pitch (-24 to +24 semitones), loop, reverse,
and interpolation parameters. The interpolation integer contract is stable:
| Value | Policy |
|---|---|
| 0 | Hold |
| 1 | Nearest |
| 2 | Linear (default) |
| 3 | Cubic Hermite |
| 4 | Cubic Lagrange |
| 5 | Ratio-tracking sinc |
Resident and streamed sources support forward and reverse one-shots and
forward/reverse wrap-crossfade loops. Hermite/Lagrange forward one-shots may
select an available exact positive-octave mip; streamed .pulpmip sidecars
provide at most two levels. Loop, reverse, non-exact, and missing-level playback
stays on the base asset. When a polynomial policy would consume the selected or
base source above 1x, it is promoted to ratio-tracking sinc. If the sinc bank
has no prepared table for that ratio, playback falls back to Hermite instead of
dropping the voice. The coherent diagnostics() snapshot exposes the lifetime
number of these selections as interpolation.sinc_fallback_selections, so
quality degradation is observable. Down-pitched fractional ratios do not
require that anti-alias promotion.
Loading samples¶
Call all loading APIs off the audio thread and after prepare():
bool mono_ok = processor.load_sample(mono, frame_count, sample_rate);
bool stereo_ok = processor.load_sample_stereo(
interleaved_stereo, frame_count, sample_rate);
bool file_ok = processor.load_sample_file("samples/kick.wav");
auto file = processor.load_sample_file_result("samples/piano.wav");
if (!file.loaded()) {
std::cerr << pulp::examples::pulp_sampler_load_status_name(file.status)
<< '\n';
}
The file path admits only true ranged readers: WAV and uncompressed
AIFF/AIFF-C NONE, at one or two channels and no more than 192 kHz. FLAC is
available to the general audio-file layer as a decode-once fallback, not as a
ranged sampler reader, so the sampler rejects it transactionally instead of
silently loading the entire asset. The same rule applies to any other codec
that lacks ranged reads: a failed replacement leaves the previously published
source usable. A successful open streams from an immutable private disk
snapshot retained for that publication, so replacing, overwriting, or
truncating the original path afterward does not change its audio. Snapshot
creation is a control-thread disk-copy cost bounded by the mapped-source byte
limit; it does not decode the whole source into RAM. Admission derives the
resident preload and page geometry from
the 20 ms certified I/O latency, 5 ms scheduler margin, 5 ms decoder allowance,
host block size, interpolation guard, loop guard, and source rate. Streamed
playback admits an effective consumption ratio of at most 4x: the note/key-map
pitch ratio multiplied by the active heritage clock ratio.
Resident mono and interleaved-stereo loads accept at most 2,880,000 frames. Automatic resident mips are attempted only up to 96,000 total samples (96,000 mono frames or 48,000 stereo frames). Larger valid resident buffers still publish and use the base/sinc policy.
PulpSamplerProcessor::load_sample_file() is the boolean convenience form.
Use load_sample_file_result() when the caller needs the codec capability,
sidecar status, preload sizes, memory demand, selection generation, or precise
failure status. last_load_result() returns the most recent file-load attempt
result. Concurrent control-plane operations are serialized; a failed
replacement leaves the previously published source and its generation usable.
Build one or two persisted streamed mip levels next to a source with:
The command uses the sampler's 140 dB-design decimator, writes hash-addressed
float32 WAV payloads, and atomically publishes and verifies the .pulpmip
manifest. An invalid sidecar is ignored and reported by the detailed runtime
result; it never replaces the base source.
Threads, memory, and failure behavior¶
The callback owns voice state and pushes bounded demand/cancellation commands. One non-audio owner thread mutates file, source, and cache state; two decode workers perform bounded page reads. The callback does not allocate, wait, open files, or invoke a decoder. See the shared sample-stream service for the multi-voice cache and worker contract, and the narrower sequential streaming source for the preload-plus-ring primitive.
The example derives its default memory capacity during prepare. To impose a
smaller non-zero streaming cap, supply PulpSamplerConfig at construction or
through set_config() before prepare:
pulp::examples::PulpSamplerConfig config{
.streaming_memory_budget_bytes = 64ull * 1024ull * 1024ull,
};
pulp::examples::PulpSamplerProcessor processor(config);
// After the host calls prepare():
const auto prepared = processor.prepare_result();
if (!prepared.prepared()) {
std::cerr << pulp::examples::pulp_sampler_prepare_status_name(prepared.status)
<< '\n';
}
Zero selects the certified derived default. set_config() fails after a
successful prepare, and an undersized cap fails transactionally with
StreamingMemoryBudgetTooSmall. The general prepare-resource estimate also
accounts for resident-bank, interpolation, heritage, voice, and service
storage; the streaming cap does not pretend to cover those separate owners.
The principal streamed storage is:
page bytes = 2 channels * page_frames * source_capacity * cache_pages_per_source * sizeof(float)
preload bytes = 2 channels * required_preload_frames * source_capacity * sizeof(float)
decode scratch bytes = 2 channels * page_frames * 2 workers * 8 jobs/source * sizeof(float)
page_frames is derived from the certified worst case and is at least 2048.
source_capacity is six (two publication bundles with a base plus at most two
mip members), and cache_pages_per_source is 128 (eight voices times the
16-page maximum block footprint).
Those six physical slot/member identities are stable across replacement loads;
reuse advances their non-zero generations, so the service's stale-token history
does not grow with the number of files loaded. Source or selection generation
exhaustion fails closed rather than publishing the reserved zero value.
Each registered source or mip member receives a shared page budget sized for
the bounded aggregate demand footprint of eight independently positioned
voices; voices do not own duplicate caches. Resident sample and resident-mip
ownership are outside this streaming figure.
Stream misses never replay stale data. A predicted miss uses a 64-frame
equal-power fade to silence; recovery uses a 64-frame fade from silence.
stream_stats() separates service starvation, starved output frames, decode
failure, invalid preload contract, stale generation, normal end of source,
invalid render contract, memory high-water marks, and source/page lifecycle
counters. diagnostics() returns the top-level PulpSamplerDiagnostics
snapshot with the last prepare and load results, the matching preload policy,
callback-published starvation-envelope state, heritage status, and streaming
memory capacity/current/peak values. Its snapshot_epoch advances with each
coherent diagnostic publication; a successful load's selection_generation
matches the preload record from the same source publication. These inspection
APIs are for a control or diagnostic thread, never the audio callback.
The stream service also enforces aggregate decode throughput. Across all active streamed voices it conservatively computes:
Clock-driven note-on rejection increments heritage
rate_admission_rejections; a live pitch change that invalidates an active
streamed voice increments heritage rate_automation_rejections. Those counters
are distinct from the legacy aggregate-rate counters in stream_stats().
Control and inspection API¶
The example exposes typed control-plane results rather than collapsing load,
prepare, codec, or sidecar failures into booleans. Lifecycle is method-specific:
load APIs run off the audio thread after prepare and may publish concurrently
with process(); set_config() is pre-prepare; heritage replacement or disable
requires stopped audio; inspection belongs on a non-audio control/diagnostic
thread.
| API | Purpose |
|---|---|
set_config(PulpSamplerConfig) / config() |
Set or inspect the pre-prepare streaming-memory budget; configuration changes are rejected after prepare |
prepare_result() |
Inspect typed prepare status, exceeded resource limit, and required/configured streaming bytes |
load_sample_file_result(path) / last_load_result() |
Load a strict ranged asset and inspect codec capability, sidecar status, channel/rate/frame metadata, preload, memory request, mip count, and selection generation |
has_sample() / sample_length() |
Inspect whether a source is published and its saturated integer frame length |
stream_stats() |
Read detailed starvation, decode, admission, memory, and source/page lifecycle counters |
diagnostics() |
Read one coherent prepare/load/preload/envelope/interpolation/heritage/memory snapshot, including sinc fallback selections |
set_heritage_profile(profile) / disable_heritage() |
Replace or disable synthetic heritage processing and its latency contract |
heritage_diagnostics() |
Inspect profile identity, clock/rate state, reported latency, failures, and admission counters |
For example, return to the transparent zero-latency sampler path while stopped:
if (processor.disable_heritage() !=
pulp::examples::PulpSamplerHeritageStatus::Disabled) {
// Inspect processor.heritage_diagnostics() before resuming audio.
}
PulpSamplerPrepareStatus, PulpSamplerLoadStatus,
PulpSamplerCodecCapability, PulpSamplerSidecarStatus, and
PulpSamplerHeritageStatus all have stable string-name helpers in
sampler_api.hpp. A built .pulpmip sidecar does not override runtime source
admission: PulpSampler still requires one or two channels and a source rate no
greater than 192 kHz.
Synthetic heritage processing¶
An optional typed profile applies ordered, independently bypassable stages for machine-rate conversion, quantization, clock/pitch, DAC hold, reconstruction filtering, noise, and output. Profile IDs are neutral identifiers; no named hardware profile or capture-matched claim ships.
Configure or replace a profile only while audio is stopped:
pulp::audio::SampleHeritageProfile profile{
.schema_version = pulp::audio::kSampleHeritageProfileSchemaVersion,
.profile_id = "neutral.example-two-leg-v2",
.host_sample_rate = 48000.0,
.stages = {
{false, pulp::audio::SampleHeritageMachineDomainStage{32000.0}},
{false, pulp::audio::SampleHeritageClockPitchStage{1.25}},
},
};
auto status = processor.set_heritage_profile(profile);
auto diagnostics = processor.heritage_diagnostics();
The engine validates and prepares off-thread. Active host-to-machine and machine-to-host conversion legs each contribute the causal 24-tap-half-width delay in their output coordinate; the processor rounds the resulting nominal host-frame latency up and notifies the host when it changes. An all-bypassed profile is transparent and reports zero latency. Render-plan or render failure fails closed to silence and increments heritage diagnostics.
Prepare binds streaming to the heritage processing input domain. Its output sample rate is the host rate multiplied by the active clock ratio, and its maximum block is the heritage engine's maximum input-frame requirement. The preload contract therefore records this clocked stream rate, not always the host's external output rate. An all-bypassed profile uses a clock ratio of one.
Canonical JSON readers/writers exist for profiles and bounded runtime state.
Runtime persistence captures only RNG continuation for quantization/noise
stages that opt into ContinueSerializedState; it does not capture SRC phase
or history, DAC-hold state, or reconstruction-filter transients. The example's
sampler_heritage_state.hpp adds a strict, versioned envelope for the profile
and optional runtime JSON, and PulpSamplerProcessor uses that envelope for its
outer plugin state. At callback end, the processor publishes a bounded RNG
continuation snapshot through a SeqLock; same-rate restore applies it before
the first callback. Restoring at a different host rate keeps the profile but
resets runtime state and reports ReadyRuntimeResetForHostRate. SRC history,
DAC-hold state, and reconstruction-filter transients always restart rather than
pretending to continue across a session restore.
Verification¶
Focused sampler tests:
cmake -S . -B build -DCMAKE_BUILD_TYPE=Release \
-DPULP_BUILD_TESTS=ON -DPULP_BUILD_EXAMPLES=ON
tools/ci/governed-build.sh cmake --build build --target pulp-sampler-test
./build/examples/PulpSampler/pulp-sampler-test
The dependency-bearing interpolation and heritage reference gates use separate render executables and independent Python oracles. Ordinary pytest collection skips these renderer-dependent cases; configure the CMake gate to require them:
python3 -m venv .venv-aql
.venv-aql/bin/python -m pip install -e 'tools/audio/quality-lab[test]'
cmake -S . -B build-aql -DCMAKE_BUILD_TYPE=Release \
-DPULP_BUILD_TESTS=ON \
-DPULP_AUDIO_QUALITY_LAB_GATE=ON \
-DPULP_AUDIO_QUALITY_LAB_PYTHON="$PWD/.venv-aql/bin/python"
tools/ci/governed-build.sh cmake --build build-aql \
--target pulp-sampler-render-wav pulp-sampler-heritage-render-wav
ctest --test-dir build-aql \
-R '^sampler-(quality-lab|heritage-quality-lab)-' \
--output-on-failure
The checked-in interpolation benchmark under
docs/validation/sampler-interpolation/ is a current 108-row Release capture.
Its verifier checks the complete matrix, acceptance budgets,
source bundle, binary hash, environment, and negative controls. Later sampler
integration commits do not make it stale when the verifier's content-addressed
interpolation source bundle remains unchanged; the current source-only verifier
passes that exact digest.
Current gap¶
- Named machine profiles remain intentionally unshipped until research, provenance, capture, and listening gates are satisfied.