Pulp 1.88.0
Cross-platform audio plugin framework
Loading...
Searching...
No Matches
program_wire.hpp File Reference

The flat, pointer-free byte layout one compiled program generation is published in, plus its native encoder and validating decoder. More...

#include <pulp/playback/automation_cursor.hpp>
#include <pulp/playback/automation_limits.hpp>
#include <pulp/playback/program.hpp>
#include <pulp/runtime/result.hpp>
#include <pulp/timebase/compiled_tempo_map.hpp>
#include <pulp/timebase/rational_time.hpp>
#include <pulp/timebase/tick.hpp>
#include <pulp/timeline/item_id.hpp>
#include <array>
#include <bit>
#include <cstddef>
#include <cstdint>
#include <span>
#include <type_traits>

Go to the source code of this file.

Classes

struct  pulp::playback::ProgramWireError
struct  pulp::playback::ProgramWireHeader
 The fixed prefix every payload starts with. More...
struct  pulp::playback::ProgramWireSectionEntry
struct  pulp::playback::ProgramWireProgramRecord
 The whole-program scalars, including the tempo map's sample rate and the automation ceilings the adopting realm must render within. More...
struct  pulp::playback::ProgramWireTempoPointRecord
 The editable tempo points, not the compiled segments. More...
struct  pulp::playback::ProgramWireTrackRecord
 One track. More...
struct  pulp::playback::ProgramWireIdRecord
struct  pulp::playback::ProgramWireNoteEventRecord
 This happens to hold the same fields in the same order as NoteProgramEvent, and that correspondence is coincidental and must not be relied on. More...
struct  pulp::playback::ProgramWireNoteModifierRecord
struct  pulp::playback::ProgramWireControllerRangeRecord
 One track's range into the ControllerEvents section. More...
struct  pulp::playback::ProgramWireControllerEventRecord
 One ControllerProgramEvent, field by field — never cast between the two, for the reason given on ProgramWireNoteEventRecord. More...
struct  pulp::playback::ProgramWireAutomationLaneRecord
 instance_token is the producer's own AutomationProgram::instance_token() carried verbatim, because lane identity is not (lane_id, generation) alone. More...
struct  pulp::playback::ProgramWireAutomationSegmentRecord
 Deliberately not laid out like AutomationProgramSegment: this groups the three floats and puts interpolation last so the record packs to 48 bytes with one run of padding, where the program type interleaves them. More...
class  pulp::playback::ProgramWireView
 A validated, zero-copy view over one encoded program generation. More...
class  pulp::playback::ProgramWireBytePin
 A unique lease over immutable publication bytes. More...
struct  pulp::playback::ProgramWireAdoptionResult
struct  pulp::playback::ProgramWireLaneIdentity
struct  pulp::playback::ProgramWireLaneState
 One caller-provided fixed-capacity state slot. More...
struct  pulp::playback::ProgramWireAutomationLaneOutput
struct  pulp::playback::ProgramWireAutomationRenderResult
class  pulp::playback::ProgramWireAutomationConsumer
 Allocation-free automation consumer over one uniquely pinned wire payload. More...

Namespaces

namespace  pulp
namespace  pulp::playback
namespace  pulp::playback::detail

Enumerations

enum class  pulp::playback::ProgramWireSection : std::uint32_t {
  pulp::playback::Program = 1 , pulp::playback::TempoPoints = 2 , pulp::playback::Tracks = 3 , pulp::playback::ClipIds = 4 ,
  pulp::playback::NoteEvents = 5 , pulp::playback::NoteModifiers = 6 , pulp::playback::DevicePlacementIds = 7 , pulp::playback::AutomationLanes = 8 ,
  pulp::playback::AutomationSegments = 9 , pulp::playback::ControllerRanges = 10 , pulp::playback::ControllerEvents = 11
}
enum class  pulp::playback::AutomationEvaluationRate : std::uint8_t { pulp::playback::SampleAccurate = 0 , pulp::playback::BlockRate = 1 }
 How often the adopting realm must evaluate one automation lane. More...
enum class  pulp::playback::ProgramWireTargetKind : std::uint8_t { pulp::playback::DeviceParameter = 0 , pulp::playback::TrackMixer = 1 }
enum class  pulp::playback::ProgramWireErrorCode : std::uint8_t {
  pulp::playback::InsufficientCapacity , pulp::playback::MisalignedBuffer , pulp::playback::TempoMapMismatch , pulp::playback::AudioProgramUnsupported ,
  pulp::playback::ProductionDeclarationUnsupported , pulp::playback::MixerAutomationUnresolved , pulp::playback::CountOverflow , pulp::playback::ShortBuffer ,
  pulp::playback::BadMagic , pulp::playback::UnsupportedVersion , pulp::playback::BadHeaderSize , pulp::playback::ChecksumMismatch ,
  pulp::playback::SectionOutOfBounds , pulp::playback::SectionsNotTiled , pulp::playback::SectionMisaligned , pulp::playback::NonCanonicalSectionOrder ,
  pulp::playback::DuplicateSection , pulp::playback::UnknownSection , pulp::playback::MissingSection , pulp::playback::TrailingData ,
  pulp::playback::BadRecordSize , pulp::playback::BadSectionCardinality , pulp::playback::RangeOutOfBounds , pulp::playback::NonCanonicalRangeOwnership ,
  pulp::playback::AutomationTargetUnresolved , pulp::playback::DuplicateAutomationTarget , pulp::playback::StaleLaneGeneration , pulp::playback::InvalidEnum ,
  pulp::playback::MalformedNoteModifier , pulp::playback::MalformedSegment , pulp::playback::MalformedControllerEvent , pulp::playback::InvalidSampleRate ,
  pulp::playback::InvalidLimits , pulp::playback::InvalidProducerEpoch , pulp::playback::InvalidInstanceToken
}
enum class  pulp::playback::ProgramWireConsumerCode : std::uint8_t {
  pulp::playback::Ok , pulp::playback::DecodeRejected , pulp::playback::TempoMapMismatch , pulp::playback::StateCapacityExceeded ,
  pulp::playback::StalePublication , pulp::playback::MissingProgram , pulp::playback::InvalidTransport , pulp::playback::OutputCapacityExceeded ,
  pulp::playback::CursorRejected
}
enum class  pulp::playback::ProgramWireAdoption : std::uint8_t { pulp::playback::Adopted , pulp::playback::Unchanged , pulp::playback::Rejected }

Functions

constexpr std::uint64_t pulp::playback::program_wire_checksum (std::span< const std::byte > bytes) noexcept
 FNV-1a over 64 bits.
std::uint64_t pulp::playback::program_wire_process_epoch () noexcept
 A nonzero value that is stable for this process and differs between processes.
runtime::Result< std::size_t, ProgramWireError > pulp::playback::program_wire_encoded_size (const PlaybackProgram &program, std::span< const timebase::TempoPoint > tempo_points) noexcept
 Bytes encode_program_wire needs for this program.
runtime::Result< std::size_t, ProgramWireError > pulp::playback::encode_program_wire (const PlaybackProgram &program, std::span< const timebase::TempoPoint > tempo_points, std::uint64_t producer_epoch, std::span< std::byte > out) noexcept
 Writes program into out and returns the bytes written.
runtime::Result< ProgramWireView, ProgramWireError > pulp::playback::decode_program_wire (std::span< const std::byte > bytes) noexcept
 Validates bytes and returns a borrowing view.
bool pulp::playback::program_wire_matches (const ProgramWireView &view, const PlaybackProgram &program, std::span< const timebase::TempoPoint > tempo_points, std::uint64_t producer_epoch) noexcept
 Whether view carries exactly program compiled from tempo_points.

Variables

constexpr std::uint16_t pulp::playback::kProgramWireVersion = 3
 The format this build writes and is able to read.
constexpr std::uint16_t pulp::playback::kProgramWireMinReaderVersion = 2
 The floor written into a payload that carries no controller events: the version whose readers can still render it exactly.
constexpr std::uint16_t pulp::playback::kProgramWireControllerMinReaderVersion = 3
 The floor written into a payload that carries at least one controller event.
constexpr std::uint32_t pulp::playback::kProgramWireMagic = 0x5750'4C50u
 Spells PLPW in the first four bytes of every payload.
constexpr std::uint32_t pulp::playback::kProgramWireNoLane = 0xFFFF'FFFFu
 A track's mixer control that no automation lane supersedes.
constexpr std::size_t pulp::playback::kProgramWireMaximumTracks = 1'024
 Decoder work and fixed scratch remain bounded even for untrusted payloads.
constexpr std::size_t pulp::playback::kProgramWireMaximumAutomationLanes = 1'024
 Total automation lanes one publication consumer can prepare across all tracks.
constexpr std::size_t pulp::playback::kProgramWireMaximumControllerEventsPerTrack
 Controller events one track may carry on the wire.
constexpr std::uint32_t pulp::playback::kProgramWireSectionOptional = 1u << 0u
 The section is safe to ignore: a reader that does not know this id still renders the program the writer meant.
constexpr std::uint8_t pulp::playback::kProgramWireTrackHasAutomationProgram = 1u << 0u
constexpr std::size_t pulp::playback::kProgramWireAlignment = 8
 Every record is a multiple of eight bytes, so tiling sections back to back keeps each one eight-byte aligned without a padding section between them.

Detailed Description

The flat, pointer-free byte layout one compiled program generation is published in, plus its native encoder and validating decoder.

PlaybackProgram is a graph of shared_ptr and std::vector. That is the right shape for a process-local renderer and an impossible shape for any consumer that does not share the producer's address space: an AudioWorklet and its Worker are separate linear memories, and a helper process shares no heap with its host. Neither can follow a pointer the compiler wrote. This wire is the crossing form — one contiguous, self-describing byte range that carries indices where the program carries pointers, so publication becomes a copy of bytes plus a generation counter rather than a graph walk.

Byte order and alignment. Records are native-layout structs with explicit padding, written and read by memcpy, so the layout is little-endian by construction and a static_assert below refuses to compile on a big-endian host rather than emitting bytes a reader would silently misread. Every record is a multiple of eight bytes and every section starts at an eight-byte payload offset, so a decoder can borrow typed spans directly out of the buffer with no copy and no unaligned load; a buffer whose base address is not eight-byte aligned is rejected rather than read. This is the deliberate trade: portability to a hypothetical big-endian target is spent to buy a decode that allocates nothing at all, which is what the adopting realtime realm actually needs.

Trailing data. Sections tile the payload exactly — ascending, gapless, starting at zero and ending at payload_bytes. There is no slack a writer could hide meaning in and no trailing region a reader would have to decide about, so a length that disagrees with the content is a rejection rather than an interpretation.

Versioning. The header carries both the writer's version and the lowest min_reader_version that can read the payload correctly. A reader rejects a payload whose min_reader_version exceeds its own version and otherwise proceeds; it never guesses. Additive growth does not need either number to move, because unknown sections marked kSectionOptional are skipped while unknown sections without that flag are rejected — the writer states whether ignoring the new data still renders the right audio, and a reader that cannot honour it refuses instead of rendering a program it only partly understands. This posture is deliberately not the document command schema's exact-equality gate: a command schema mismatch stops an edit, while a program wire mismatch would stop playback in a realm that cannot be upgraded in lockstep with its producer, so this format needs a migration story and gets one.

Canonical encoding. Every known section is always emitted, in ascending id order, even when empty. One program, one tempo-point sequence, and one producer epoch have exactly one byte encoding, which is what makes a byte-level golden a real guard rather than a record of whatever the encoder happened to do. Note the unit: one program, not one document. Each automation lane carries its program's instance token, which is minted per compile, so two compiles of one document are two programs and encode to two byte ranges — deliberately, since that difference is the identity a consumer needs. A golden over a fixed document therefore normalises those tokens before hashing; everything else in the payload is still fixed by the input.

Adoption contract — (producer_epoch, generation) decides the producer and the ordering; the lane's instance_token decides sameness. A consumer decides three ways about an arriving payload:

  • producer_epoch differs from the adopted one → a different producer. Reset any carried cursor state and adopt unconditionally. Comparing generations across producers is meaningless.
  • Same epoch, generation greater → the same producer advancing. Adopt.
  • Same epoch, same generation → not necessarily the same publication. generation is supplied by the caller rather than minted per compile, so one producer can publish two different programs under one generation. Compare each lane's instance_token: equal means genuinely the same program, so carried cursor state is still valid and is kept; different means a new program that happens to share a generation, and the lane is re-adopted. Deciding this case on (lane_id, generation) alone is what renders a stale curve with nothing malformed for a decoder to reject.

Neither program-level component is sufficient alone, because the two counters have different scopes. generation is per store and restarts: a fresh PlaybackProgramStore publishes generation 1, so a producer that is torn down and recreated — a page rebuilding a Worker while the AudioWorklet survives — begins again at 1 while the consumer sits at N. Judged on generation alone that is not monotonic, so every publish is refused and the consumer renders a stale program indefinitely, with nothing distinguishing "refused once" from "refused forever". Judged on the epoch first, it is simply a new producer. In the other direction, two producers of one document both mint generation 1 for the same lane, and without an epoch a consumer would read that as the same publication.

The epoch answers both of those and only those: they are questions about producers. It does not separate two programs from one producer, which is the more common case. So the full lane identity a consumer compares is (producer_epoch, lane_id, generation, instance_token), and no proper subset of it is sufficient — see ProgramWireAutomationLaneRecord.

What this format is sufficient for. A note, controller, automation, and mixer program. It structurally cannot express an audio-clip program: there is no section for clip audio regions and no way to name decoded media, and the encoder refuses such a program rather than emitting a thinner one. A consumer can therefore rely on "decoded successfully" meaning "no audio content was dropped on the way here", and a producer that needs audio needs a format change rather than an extra channel alongside this one.

What the wire carries, and what it does not. It carries the note, controller, automation, and mixer spine a realtime consumer needs: identity and generation, the tempo points and sample rate, per-track note events, note modifiers, and controller/expression events, clip and device-placement ordering, the mixer's constants and which lane supersedes them, and every automation lane's compiled segments in both the tick and sample domains. Controller events travel in their program's sequence, verbatim: a ControllerRanges section holds one (first, count) range per track into a ControllerEvents section, each record carries the full MIDI wire address plus the authored clip, lane, and point identity and the chased/authored origin, and the decoder preserves the producer's order rather than re-sorting, so a consumer reads exactly the sequence TrackProgram::arrangement_controller_events() held — ties at one sample and tick included. It does not carry decoded audio: the asset pool is bulk media that is already content-hash addressed, and a generation wire that inlined it would republish gigabytes per edit. It does not carry the audio clip programs either — those are derived from the document and the asset pool, and three of their members are derived-cache pointers whose types are still moving; the encoder refuses a program with an audio track rather than dropping it silently. It does not carry per-track production declarations. Those remain process-local in this wire version; a track whose declaration is not the default synchronous, deterministic, zero-lookahead contract is refused rather than encoded with a stronger replay claim than it earned. It does not carry AudioRendererLimits, whose two dozen fields are mostly offline-stretch and converter-cache budgets governing the compiler's host rather than the adopting realm. AutomationPlaybackLimits is carried, because those ceilings size the arrays a block render writes into.