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 |
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. | |
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:
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.