Skip to content

Creative Timeline Engine SDK

The Creative Timeline Engine is usable as three layered C++ libraries from an installed Pulp SDK. It does not require Pulp's UI, GPU renderer, plugin-format adapters, SignalGraph, standalone shell, or plugin host.

For task-focused integration recipes, start with the Timeline cookbook. The C++ API reference documents individual public methods, the module reference describes subsystem boundaries, the example gallery points to runnable Timeline examples, and the interchange matrix states format capabilities and losses.

Configure an external project

Use the cookbook's library-selection recipe for the minimal find_package() and target_link_libraries() setup. The lowercase aliases pulp::timebase, pulp::timeline, pulp::project-package, and pulp::playback are also available. Components validate that the installed SDK contains each requested target; the package still defines its complete set of installed targets.

Dependency boundary

The three requested engine targets produce this installed Pulp link closure:

Library Why it is present
Pulp::timebase Editable and compiled tempo/meter maps
Pulp::timeline Immutable project model, commands, persistence
Pulp::playback Transport, compilation, note/audio/automation rendering
Pulp::platform Runtime's portable platform primitives
Pulp::runtime Results, queues, slots, files, and worker primitives
Pulp::audio Audio buffers, decoded assets, and file support
Pulp::midi MIDI event types and scheduling
Pulp::state Parameter-event and state primitives used by audio
Pulp::signal Header-only signal utilities used by audio
Pulp::events Static implementation dependency of Pulp::state listener dispatch

That is ten first-party Pulp libraries. Pulp::events is a static LINK_ONLY dependency of Pulp::state; omitting it from the count would not describe the installed target graph accurately.

The complete static-link closure also contains six exported implementation archives: Pulp::hwy, Pulp::mbedcrypto, Pulp::mbedx509, Pulp::mbedtls, Pulp::everest, and Pulp::p256m. They support runtime SIMD and cryptography; they are not additional timeline APIs. The external consumer smoke audits all sixteen targets and fails if canvas, view, GPU, graph, format, standalone, or host targets enter the closure.

Plugin hosting is deliberately outside the engine. A desktop integration adapts its own instrument/effect ports; the caller owns audio-device I/O.

Optional durable project publication

Canonical Timeline JSON remains a document-model concern. When an application needs to publish that JSON together with package-relative assets, request the separate project-package component. It no-replace publishes hash-verified, fenced content-addressed blobs, then validates their references before atomically replacing project.json inside the stable package root. Unpublished staging remains unreachable:

find_package(Pulp REQUIRED COMPONENTS project-package)
target_link_libraries(my_timeline_app PRIVATE Pulp::project-package)

This component does not add an archive format or interchange policy. Those stay in the DAWproject, SMF, and interchange components below. Its lower-level generic file and directory publisher is also no-replace; that guarantee does not apply to the stable root's replaceable project.json generation. Configuring Pulp from source with -DPULP_ENABLE_PROJECT_PACKAGE=OFF omits this component and the Timeline authoring tools that depend on it. An SDK installed from that build cannot satisfy find_package(Pulp REQUIRED COMPONENTS project-package).

Optional DAWproject importer

Foreign-format import stays outside the dependency-minimal Timeline model. Applications that ingest DAWproject project.xml files request and link the dedicated importer:

find_package(Pulp REQUIRED COMPONENTS dawproject-import)
target_link_libraries(my_timeline_app PRIVATE Pulp::dawproject-import)

Optional DAWproject exporter

Writing DAWproject is a separate component from reading it, because the importer is an installed find_package component and overloading it would silently change what an existing consumer links:

find_package(Pulp REQUIRED COMPONENTS dawproject-export)
target_link_libraries(my_timeline_app PRIVATE Pulp::dawproject-export)

Export is bounded, not Tier-1 support for the format. The writer emits the same subset the importer reads — flat tracks, beats-timed clips, inline notes, referenced audio, one tempo and one time signature — and everything else the document holds is declared lost in the capability table.

There is no one-shot entry point. A plan is produced first, and run_export refuses before touching the writer unless the caller has accepted each lost concept by name:

const auto plan = interchange::plan_export(project, interchange::Format::DawProject);
interchange::ExportOptions options;
options.accepted_losses = plan.required_consent();   // review these, do not paste blindly
auto artifacts = interchange::run_export(plan, options, dawproject::writer());

The former dawproject::writer(project, options) overload remains temporarily available for source compatibility but is deprecated. Its project argument is deliberately ignored: the writer serializes the snapshot owned by the ExportPlan, so callers should migrate to dawproject::writer(options).

There is deliberately no force flag: a pipeline pins the exact losses it reviewed, so a loss kind introduced later stops that pipeline instead of riding in on consent given for something else.

Two artifacts come back: project.xml, and pulp-loss-manifest.json naming every concept that was dropped. The manifest travels inside the package rather than scrolling past in a console, because an export whose losses are invisible to whoever opens the file next is the failure this contract exists to prevent. Packing those entries plus media into a .dawproject zip is the caller's step.

pulp-loss-manifest.json schema version 1 is shared by every interchange adapter. Its root contains numeric schema_version, string format, boolean lossless, and a losses array. Each loss contains string concept, level, class, count, and detail, plus an owners array of decimal strings; degraded_to is present only for a degradation. Counts and owner IDs are decimal strings so 64-bit values survive JSON consumers without precision loss. owners is a bounded diagnostic sample controlled by CensusLimits, while count is the complete occurrence count. The filename is reserved to run_export() and adapters cannot replace it. Changing this schema requires a new schema_version; version 1 replaces the older unversioned DAWproject manifest shape.

Each exported track carries a neutral <Channel> — unity volume, centre pan. A receiving DAW registers the track from that element, and a file without one is rejected. It is not an export of the document's authored mixer state, which the manifest still reports as dropped.

Pulp::dawproject-import adds the importer implementation and its audio/WAV dependency to the closure, and exposes pulp::timeline::import_dawproject_xml. Applications that only create or deserialize native Pulp projects do not link that importer implementation.

The importer intentionally accepts a bounded linear subset rather than silently approximating an arbitrary DAW session:

  • DAWproject major version 1, one tempo and meter, flat tracks, and beats-timed <Notes> or <Audio> clips are supported.
  • Nested group tracks, <Warps>, seconds-timed lanes, unknown timeline constructs, and unsupported clips/tracks/notes fail the whole import.
  • Audio imports require a caller-supplied package-media resolver. Rooted, drive-qualified, and parent-traversing paths are rejected; resolved WAV bytes are size-bounded, inspected, hashed, and retained only as sealed MediaAssets with safe package-relative locator hints.
  • DawProjectImportLimits bounds XML bytes, tracks, clips, notes, media assets, resolver calls, locator length, and per-call/total media bytes before importer-owned collections grow.

The import function consumes the project.xml entry, not the .dawproject ZIP container itself. Package readers and resolver allocations remain application responsibilities. See test/fixtures/timeline/dawproject/linear_subset.dawproject.xml for a representative supported document; malformed or out-of-subset input returns a typed DawProjectImportError rather than a partial project.

That raw-member contract belongs to the low-level installed SDK. The higher-level pulp seq and Timeline MCP interchange operations own bounded package I/O: they import a standard .dawproject ZIP, require a safe root project.xml, resolve only safe package-relative media, and export a standard .dawproject ZIP containing the XML, manifest, and referenced media.

Optional Standard MIDI File interop

Standard MIDI File (SMF) import and export live in their own target for the same reason DAWproject import does — foreign-format I/O stays out of the dependency-minimal Timeline model:

find_package(Pulp REQUIRED COMPONENTS smf-interop)
target_link_libraries(my_timeline_app PRIVATE Pulp::smf-interop)

Pulp::smf-interop exposes pulp::timeline::import_smf and pulp::timeline::export_smf from <pulp/timeline/smf.hpp>.

For the same plan/consent contract as DAWproject export, request the separate adapter and include <pulp/smf/interchange.hpp>:

find_package(Pulp REQUIRED COMPONENTS smf-interchange)
target_link_libraries(my_timeline_app PRIVATE Pulp::smf-interchange)
const auto plan = interchange::plan_export(project, interchange::Format::Smf);
interchange::ExportOptions options;
options.accepted_losses = plan.required_consent();   // review these, do not paste blindly
auto artifacts = interchange::run_export(plan, options, smf::writer());

The format-bound writer can only be invoked by run_export, and the plan owns the project snapshot it measured. On success, project.mid and the centrally generated pulp-loss-manifest.json are returned. The adapter may omit only the concepts named by consent, strips consented note modifiers, and represents a consented continuous tempo ramp as Set Tempo steps at authored tempo points.

Conversion runs entirely in the musical domain. An SMF carries its own timebase — a header division in ticks per quarter note plus Set Tempo and Time Signature meta-events — so import scales those ticks onto the canonical timebase::kTicksPerQuarter grid and turns the meta-events into the project's TempoMap and MeterMap. Nothing is flattened to seconds in either direction, so a tempo change mid-file survives the round trip as a tempo point.

  • Tick scaling is exact for every position when the division divides kTicksPerQuarter (96, 120, 192, 240, 480, 960, …; not 384 or 1920). Otherwise import rounds to the nearest canonical tick and reports both exact_tick_conversion and the error bound. Export is exact by default: a canonical tick the requested division cannot represent is an error unless allow_lossy_tick_rounding is set.
  • Import accepts format 0 and 1 with a metrical division, MTrk chunks only, Note On/Note Off (including the zero-velocity Note On that means Note Off), and the Set Tempo, Time Signature, Sequence/Track Name, and End of Track meta events. SMPTE divisions, format 2, unknown chunks, malformed running status, unbalanced notes, zero-length notes, and any other event fail the whole import. SmfUnsupportedEventPolicy::IgnoreNonNote is the caller's explicit opt-in to discard non-note channel messages, system-exclusive blocks, and out-of-subset meta events.
  • SmfImportLimits bounds file bytes, tracks, events, notes, simultaneously sounding notes, tempo and meter points, meta payload bytes, track-name bytes, and the absolute tick ceiling before the corresponding state grows.
  • Raw export_smf writes a format-1 file whose track 0 is a conductor track carrying the tempo and meter maps. Note velocity is scaled to the 7-bit MIDI domain; a velocity that would scale to zero is rejected rather than rewritten, because a zero-velocity Note On reads as a Note Off. A tempo ramp, a sample-anchored clip, and registered, opaque, nested-sequence, or media content are errors, not approximations. Empty clips are silently absent from the raw event codec; use the census-backed adapter when that loss must require consent.
  • Round trips through a dividing division preserve note start, duration, pitch, channel, and 7-bit-representable velocity exactly. Tempo returns within the Set Tempo event's whole-microsecond resolution.

Optional plugin-format adapter

Pulp::sequence is an exported integration layer for applications that need to present a compiled timeline as a VST3, AU, or CLAP processor. It is not part of the engine-only dependency closure above:

find_package(Pulp REQUIRED COMPONENTS sequence)
target_link_libraries(my_sequence_plugin PRIVATE Pulp::sequence)

pulp::sequence::SequenceProcessor adapts a caller-owned PlaybackProgramStore to pulp::format::Processor. It projects host transport into the timeline clock, executes the compiled track graph, and emits rendered audio and MIDI without owning the project, compiler, media resolver, plugin host, editor, or device I/O. The caller must publish a compatible immutable program before processing and rebuild it away from the audio thread when the document changes.

Choose Pulp::timebase, Pulp::timeline, and Pulp::playback for an editor, standalone application, or custom host integration. Choose Pulp::sequence only at the plugin-format boundary; it intentionally adds the heavier Pulp::format, Pulp::graph, and Pulp::state closure required by a format-facing processor.

Ownership and state flow

Timeline applications move immutable values through a small set of owners:

  1. Project is the canonical document snapshot. Model values do not mutate in place.
  2. DocumentSession owns the current snapshot, revision, command journal, and undo/redo state. A WriterToken supplies ordered transaction and command identities.
  3. JournalSink acknowledges a complete transaction only after it is durable. Native applications can open a FileJournal and restore the returned checkpoint and revision into a session.
  4. PlaybackProgramCompiler lowers one immutable snapshot plus its resolved media into a PlaybackProgramStore. Compilation and media resolution stay off the audio thread.
  5. MasterTransport creates each callback's TransportSnapshot. Renderers read one pinned playback-program block and that transport snapshot without allocating.

The cookbook provides the compile-backed transaction, journal recovery, and compile/publish/render recipes for that ownership flow.

For continuous editor input, create one timeline_editor::EditGestureIdentityAllocator from the session's move-only WriterToken. The allocator owns one undo group, issues opaque move-only tickets, lowers their phase and identity internally, and calls DocumentSession::submit() itself. Pre-submit validation and lowering failures leave the ticket retryable; an actual session submission consumes it, and only the session's returned result may advance the allocator lifecycle. Use it on one control thread and route every submission for its writer/group through it; do not race it with direct same-group session submissions. If a pending ticket is abandoned without submission, discard that allocator.

Idempotent submission can return a cached success after a newer gesture transaction has already published. The allocator therefore validates the cached result with DocumentSession::is_current_publication(). If a cached Begin is stale, it asks the session whether the exact opaque writer/group still owns the authoritative open gesture: a later Update leaves the allocator Open so End or Cancel remains issuable; a later End or Cancel leaves it AwaitingBegin. Callers receive SubmissionResultMismatch with the exact cached CommitResult and must not declare or infer the lifecycle themselves.

Audio clip time-conform intent

Clip::time_conform() records how authored media is intended to adapt when a musical clip's duration and its source-media duration differ:

  • TimeConform::None is the default and preserves the existing, unconformed behavior.
  • TimeConform::Resample requests varispeed, coupling duration and pitch.
  • TimeConform::Stretch requests tempo-preserving time stretch.

Pass the intent as the final argument to Clip::create() or derive a new immutable snapshot with with_time_conform(). Resample and Stretch are valid only for musical clips whose content is a MediaRef; absolute clips and non-media content reject non-default intent with InvalidTimeConform. Clip schema v2 persists the required values as none, resample, and stretch; v1 clips load as None, and release downgrade to v1 refuses an authored non-default value rather than discarding it.

Playback consumes Resample as bounded realtime varispeed: source phase spans the clip's musical tick interval, so tempo ramps and precise host beat mapping change duration and pitch together. None retains native-rate playback. Stretch compiles off the audio thread. The compiler takes the exact slice from the decoded asset pool, converts it to the compiled timeline sample rate, applies the authored tempo schedule at stretch-analysis boundaries, and publishes an immutable audio artifact with exactly the clip's compiled timeline frame count. On the document clock the renderer consumes that artifact 1:1. With a precise host-beat mapping, TimelineGraphPlaybackBinding and SequenceProcessor prepare a program-wide live Stretch runtime off the audio thread. It streams the immutable artifact through a bounded causal stretcher, reports one fixed latency for graph compensation, and delays the track's conventional audio and mixer automation by that same amount. Publication is object-, generation-, and tempo-map-exact. Identity changes and discontinuities are reported explicitly while a charged FIFO preserves the old segment through the delayed cut and a bounded finalization hands off to the reset stream. Missing or stale state, impossible ratios, backpressure, and underflow fail closed with distinct status rather than falling back to None or Resample. Host-mapped Stretch scrubbing is explicitly unsupported. Capacity, ratio, or exact-length failures in the document-clock artifact still fail compilation.

Reusing and diverging sequences

A musical clip may contain SequenceRef{sequence_id, source_start} instead of notes or media. The clip places the referenced sequence's source window at the clip start; multiple clips can share one sequence. Project construction and command reduction reject missing targets, cycles, and nesting deeper than eight reference edges.

Edits to a shared sequence intentionally affect every placement. Before a placement-specific edit, call build_diverge_transaction() with the reference clip's ItemLocation and two command IDs allocated from the session's WriterToken. It allocates a complete clone from next_item_id and returns one atomic CloneSequence plus SetClipSequenceRef transaction, so journal replay and undo reproduce the exact copy-on-edit boundary without synthesizing or reusing writer-scoped command IDs.

PlaybackProgramCompiler expands supported child notes and audio away from the audio thread. Stage 1 fails closed when a child contains device processing, automation, takes, freeze/record state, absolute clips, or when a reference has gain/fades. A source window that cuts through a child audio fade also fails closed. Complete nested media clips retain their authored time-conform intent; a source window that trims a Resample or Stretch clip fails with NestedSequenceUnsupported until playback can map that partial conforming source range without changing its authored phase. Set ProgramCompileRequest::max_expanded_note_events to bound note expansion and ProgramCompileRequest::max_expanded_clips to bound total clip materialization and reference traversal, including charges carried by reused track programs. AudioRendererLimits::max_clips remains the separate ceiling for compiled audio regions. When compiling incrementally, keep one shared CompileContextRegistry and put it plus the committed transaction DirtySet in ProgramCompileRequest::invalidation by constructing it from that exact CommitResult. PlaybackProgramCompiler::submit() builds the snapshot-scoped CompileInvalidationIndex and resolves the exact DirtyTrackSet in the same production path that accepts the compile. The index combines direct edits, transitive sequence dependencies, built-in MIDI groove reads, and registered/nested context readers. The input must carry the exact request snapshot pointer, document revision, and exact predecessor snapshot. Sparse reuse occurs only when that predecessor is the currently published project; a restored or forked lineage rebuilds the cumulative target in full. It copies an immutable registry snapshot, so later control-thread registry changes affect the next request; a changed registry generation forces a full compile even at the same document revision. Because such a refresh can publish the same document revision twice, wait for CompilerStatus::latest_published_epoch >= CompileTicket::submission_epoch; document-revision equality alone is not a terminal signal. The epoch is a successful-publication watermark: reaching a ticket means its request published or was superseded by a later successful publication. Callers that require the exact ticket to be the latest successful publication require epoch equality and must also compare the published program identity/revision for their document. Epochs are scoped to one compiler instance; destroying its facade forfeits completion observation, and a replacement compiler starts a new epoch domain. Once busy is false, an error with the watermark still below the ticket means that ticket did not terminate successfully. Callers that need to inspect a prospective delta may still build an index and call resolve_dirty_tracks() directly, but must not use a separately resolved set as a substitute for the request's generation-pinned invalidation input.

Compile registered content with a trusted renderer

RegisteredContent is a versioned, schema-governed clip payload. Playback does not guess how to lower it. The application first builds one immutable SchemaRegistry containing the built-in Timeline schemas and its content schema, creates content against that same registry, then declares a ContentRendererRegistration in the shared CompileContextRegistry. A registration binds the exact type, schema version and codec provenance to an off-realtime noexcept compile hook, its context subscriptions, output kind, fragment-note ceiling, renderer state policy, and production declaration. The current public lane admits note output and RegisteredRendererStatePolicy::Reset only; CarryByItemId is deliberately refused until state carry has an exact identity contract. A registration may choose a lower fragment ceiling, but it cannot exceed the global 4096-note-per-clip cap.

The hook receives a narrowed CompileContextView: undeclared sequence context reads return null, which keeps invalidation honest. It emits relative clip ticks as an immutable ContentProgramFragment; the compiler maps those notes into the owning arrangement. Both the registration ceiling and the request's remaining per-track budget are enforced. Returning too much fails with RegisteredContentFragmentQuotaExceeded, whose diagnostic reports the clip item, actual, and limit. Missing exact registration fails with UnresolvedRegisteredContent rather than producing silence. Likewise, a nested SequenceRef that trims registered content fails with TrimmedRegisteredContentUnsupported: the hook input has no source-window offset with which to preserve pattern phase.

The installed-SDK registered chord renderer is the complete executable example. It uses register_chord_pattern_content_schema(), create_chord_pattern_content(), and declare_chord_pattern_renderer(); proves exact initial note values and a deterministic semantic hash; passes the exact CommitResult to incremental compilation; waits on the submission epoch; and observes a rebuilt generated track beside pointer reuse for an unrelated MIDI track. Its negative cases prove unresolved-content and quota diagnostics, and its mixed-track case proves that program_reproducibility() aggregates the weakest production declaration rather than the first or strongest one. That production proof is in-memory. Nondefault renderer production declarations remain process-local, and ProgramWire refuses to serialize programs carrying them rather than implying that a remote process can reproduce an undeclared hook.

Registrations are process-local declarations, not persisted document data. Rebuild the schema and compile registries before loading/compiling content, keep the shared compile registry alive with requests, and treat a changed registry generation as a full-compile boundary.

Built-in MIDI clips render their owning sequence's GrooveTemplate: one timing displacement from the authored onset moves note-on and note-off together, and the authored-onset accent scales velocity with deterministic half-up rounding and saturation. Ratchets inherit that single displacement and accent. A root leaf reads root groove; a nested leaf reads child groove exactly once. A SequenceRef that trims a grooved MIDI leaf currently fails with TrimmedGrooveUnsupported, because chasing displaced material across the retained source-window boundary has no defined contract yet.

pulp seq apply, pulp seq explain, and pulp render expose the same load/edit/compile/render path for headless workflows. Their source-tree CLI/MCP facade uses pulp::tools::timeline::ProjectSource to distinguish inline JSON from native file paths; that tooling facade is not part of the installed SDK. Installed embedders should deserialize through pulp::timeline, compile through pulp::playback, and render through the public playback program APIs described above.

Preview and commit pure note transforms

NoteTransformRegistry registers control-thread, pure note functions by SchemaIdentity. A function receives the clip's immutable note span, canonical parameter JSON, and an explicit seed, and returns a zero-to-many note array. Preparation requires an object parameter payload, parses it under the registry's 1 MiB bound, and canonicalizes it before invoking the function. Returning an input note's ItemId preserves that identity; returning an invalid ID asks the engine to allocate a fresh one. Foreign and duplicate output IDs, expected plus output note arrays exceeding the five-million-note durable-command quota, invalid notes, missing clips, and non-note clips fail closed.

An ApplyNoteTransform is a typed preparation request, not a journal entry:

auto preview = transforms.preview(
    session->current(), writer,
    pulp::timeline::ApplyNoteTransform{
        .sequence_id = sequence_id,
        .track_id = track_id,
        .clip_id = clip_id,
        .transform = {"example.note.octave_echo", 1},
        .canonical_params_json = R"({"interval":12})",
        .seed = 42,
    });

// Render or inspect preview.value().snapshot without changing the session.
auto committed =
    session->submit(writer, std::move(preview.value().transaction));

Preparation invokes extension code exactly once and lowers its result to an ordinary ReplaceNoteContent transaction. The preview snapshot is the result of reducing that exact transaction without publishing it. If another edit lands first, submission rejects the preview as stale; it never reruns the transform with a different input. The durable journal therefore contains only canonical expected/replacement note arrays, and undo/redo use ordinary inverse commands with the identity directory's tombstone rules.

An inverse additionally restates the clip's modifiers, because a modifier keys on a note identity and vanishes with the note its edit removed — nothing in the edited clip could bring it back. The expected_modifiers and replacement_modifiers fields are optional: authoring code omits them and the reducer carries the surviving modifiers across on its own. Expression lanes need no such field. They key on a channel-voice address that names no note, so an edit to the note set never filters them.

Scrubbing the playhead

Dragging the playhead is audible through MasterTransport itself, not through a renderer. Scrub mode makes the transport emit short repeated windows that start at the latest posted position:

// Mouse down on the ruler: 2048 frames is roughly a 43 ms grain at 48 kHz.
transport.begin_scrub(2048, position_ticks);

// Every mouse move during the drag.
transport.scrub_to(new_position_ticks);

// Mouse up.
transport.end_scrub();

The rules that matter when wiring a UI to it:

  • The window length must be at least the transport's max_buffer_size; begin_scrub() returns ScrubWindowTooShortForMaximumBlock otherwise.
  • A position posted by scrub_to() is latched and takes effect at the next window boundary, so the grain rate is the window length rather than the rate your UI emits mouse moves at. Call it as often as you like.
  • TransportSnapshot::is_playing is true while scrubbing even when the musical transport is stopped, and TransportSnapshot::scrubbing distinguishes the mode. Each window restart arrives as a range discontinuity, which is the same signal a loop wrap produces — renderers need no scrub-specific handling, and notes left sounding by a restart are released at the boundary.
  • Scrubbing suspends loop wrapping. A drag is a direct statement of position, so the transport will not pull the audible window back to the loop start, and positions outside the loop stay reachable. The loop is still reported in the snapshot so a UI keeps drawing it, and wrapping resumes on the first block after end_scrub(), which parks the playhead on the position the drag was released at.
  • A note whose onset precedes a window is not retriggered, matching the seek behavior of ArrangementNoteRenderer.
  • Scrubbing reaches the same consumers a seek does: an in-progress capture take is cancelled, and external MIDI sync emits a song-position update per window, so slaved gear chases the drag.

One typed edit through CLI and MCP

CLI and MCP consume the same generated schema and versioned command envelopes. They edit canonical snapshots rather than owning realtime device I/O, capture buffers, plugin instances, or a durable FileJournal session. Use the cookbook's CLI/MCP recipe for the exact commands and operation names.

Takes, comps, freeze, and capture

The document types live in <pulp/timeline/model.hpp> and their mutations in <pulp/timeline/command.hpp>. A TakeLane owns recorded Take values and an ordered non-overlapping comp selection. SetActiveTakeLane chooses that comp as the track source; zero selects the original arrangement. Removing an active lane or a take referenced by the comp fails closed.

TrackFreeze selects a sealed media artifact plus a render-plan content hash. Publish a freeze in one transaction ordered as CreateAsset followed by SetTrackFreeze. The authored clips, takes, automation, and device chain stay in the document for thaw; playback merely selects the frozen artifact. Clear the freeze before removing its asset. Replay never re-renders a freeze.

A track also carries a TrackMixergain_linear and a pan balance in [-1, 1] — replaced with SetTrackMixer under an exact optimistic gate. An AutomationLane may target one of those controls with TrackMixerTarget instead of a device parameter; such a lane references no device placement and supersedes the authored constant while it plays. Both values are refused outside their range, including a NaN, so a document can never hold a level that has no meaning. The desktop graph binding applies this mixer after hosted devices. For a device chain with a nontransparent mixer, set post_device_audio_source and post_mixer_audio_destination on TimelineTrackGraphRoute; the binding transactionally replaces the direct device-to-bus edge and restores it when the route is removed. Adoption fails closed when that post-device route is absent.

The realtime recorder is <pulp/playback/capture_engine.hpp>. It owns bounded, preallocated callback-time capture slots; completed handles remain immutable until explicit release. recording_commit.hpp seals audio into ordinary asset and take commands, while midi_capture_materializer.hpp maps captured MIDI through the exact capture-rate tempo map. Capture never mutates the project or journal directly. Follow the cookbook's capture-to-take recipe for the required ordering and failure checks.

The application owns device I/O, media-file publication, and plugin/device instantiation. The timeline owns editing intent and durable identity; playback owns immutable compiled artifacts; capture owns bounded callback-time buffers.

Durable journals

Include <pulp/timeline/file_journal.hpp> for native crash-consistent sessions. FileJournal::open() returns the sink, exact checkpoint and revision, whether existing state was recovered, and whether it repaired a torn trailing frame. The revision is zero for a new journal and nonzero after recovered commits. Restore the recovered checkpoint/revision with DocumentSession::restore(), or create a new session with the fallback checkpoint and sink. The session retains shared ownership of the sink. A transaction is not published until the sink reports its whole frame durable.

Checkpoint only a revision the application has durably acknowledged. A sink error is ambiguous—it may have reached storage—so the session rejects later durable writes instead of guessing. Recovery discards only a torn final frame and fails on earlier corruption. Symlink aliases share one lock identity, while multiply linked journal files are rejected because atomic replacement cannot preserve their identity.

See the cookbook's open-or-restore recipe for the capability branch and checkpoint call-site rules.

Sample-rate conversion

Audio clips, take-comp segments, and frozen tracks may use a different sample rate from the prepared tempo map. Playback compiles one shared 64-tap, 512-phase Kaiser-windowed sinc table for each distinct source/target rate pair. That allocation happens during program compilation; rendering only reads the immutable table. AudioRendererLimits::max_sample_rate_converters bounds the number of distinct tables (64 by default), and compilation rejects excess rates. Equal-rate audio bypasses the converter and retains its exact sample path.

The deterministic offline quality gate covers both directions. For 96→48 kHz conversion, a 20 kHz passband tone measures within 0.1 dB and a 30 kHz stopband tone must fold below −60 dB. A deliberately unfiltered linear decimator is the negative control and must expose that alias above −1 dB. For 44.1→48 kHz conversion, an 18 kHz tone must remain within 0.1 dB with residual energy below −70 dB. Those named thresholds are the portable contract.

Peek before loading

Project browsers and background media resolvers should inspect a snapshot with peek_project_summary() before constructing an editable document:

#include <pulp/timeline/serialize.hpp>

auto summary = pulp::timeline::peek_project_summary(snapshot_json, registry);
if (!summary)
    return report(summary.error());

show_project(summary->name, summary->counts.tracks, summary->counts.clips);

The peek scans the complete JSON under the same depth, input-size, and authored collection quotas as structural deserialization preflight, so malformed or oversized structural arrays still fail closed. It decodes only the four root scalar values needed for the summary and does not build the generic JSON DOM, identity tree, clips, notes, or automation model.

Use deserialize_project() only when the project must become editable. Media references may remain unresolved at that point; asset resolution belongs on a background path.

External MIDI synchronization

pulp/playback/external_sync.hpp keeps MIDI device I/O outside the engine while providing the timing machinery needed by an integration:

  • MtcChaser decodes coherent MIDI Time Code quarter-frame cycles and universal-realtime full-frame locate messages without allocating.
  • ExternalSyncOutput projects a TransportSnapshot into sample-offset MIDI Clock (24 PPQN), Song Position Pointer/start/continue/stop, MTC quarter-frame, and full-frame locate messages.
  • MTC conversion covers 24, 25, 29.97 drop-frame, and 30 fps. Invalid drop-frame labels fail closed.

Reserve the destination MidiBuffer for the worst-case block before entering the audio callback, enable its realtime capacity limit, call ExternalSyncOutput::process(), then stable-sort the combined MIDI output at the adapter boundary. An OutputOverflow result means at least one sync message was dropped and must be surfaced rather than hidden. ExternalSyncOutputConfig::max_messages_per_block also caps work when a caller forgets to capacity-limit its buffer; keep that limit sized to the integration's worst supported tempo, sample rate, and callback size.

The deterministic software suite verifies conversion, chase lock and discontinuity behavior, callback-partition invariance, and exact event placement. A physical loopback remains opt-in because its acceptance tolerances must be fixed before collecting the trace. Put those user-approved numbers in a spec:

{
  "schema": "pulp.timeline-sync-soak-spec.v1",
  "fixed_at": "2030-01-01T00:00:00Z",
  "min_duration_seconds": 3600,
  "max_abs_offset_samples": 0,
  "max_drift_ppm": 0,
  "min_points_per_stream": 1000
}

The zero values above are placeholders and intentionally invalid; replace them with the agreed limits before the run. Capture reference/observed sample pairs as:

{
  "schema": "pulp.timeline-sync-soak-trace.v1",
  "captured_at": "2030-01-02T00:00:00Z",
  "sample_rate": 48000,
  "points": [
    {"stream": "midi_clock", "expected_sample": 0, "observed_sample": 0},
    {"stream": "midi_clock", "expected_sample": 48000, "observed_sample": 48000},
    {"stream": "mtc", "expected_sample": 0, "observed_sample": 0},
    {"stream": "mtc", "expected_sample": 48000, "observed_sample": 48000}
  ]
}

Then run the non-gating hardware proof explicitly:

PULP_TIMELINE_SYNC_SOAK_SPEC=/path/to/spec.json \
PULP_TIMELINE_SYNC_SOAK_TRACE=/path/to/trace.json \
ctest --test-dir build -R '^timeline-sync-hardware-soak$' --output-on-failure

Without both files, CTest reports the hardware proof as a loud skip. Providing only one file or a malformed/unfixed spec is a failure, not a skip.