Temporal Plurality (WIP)

Time as Material

Note: This tutorial is a work in progress. More cards, examples and demos will be updated soon. The first three tutorials in this series taught you what data is, how it transforms, and how it becomes geometry. Every example ran continuously: nodes processed samples, buffers cycled, the engine drove everything at its own rate. You declared structure and the system ran it.

This tutorial takes that question back. Not by overriding the engine, but by choosing precisely when your code runs, for how long, under what conditions, and in response to what. The mechanisms range from a single callback attached to a node that was already computing, to a coroutine you write from scratch that suspends and resumes at sample-accurate positions in time. Between those two ends is everything you need to make computation temporal rather than just continuous.

The eight cards that follow move from the simplest attachment (a callback on an existing node's tick) through increasingly direct control, ending at the raw coroutine infrastructure that everything else is built from. By the end, none of the earlier cards will be opaque: you will know what runs underneath `schedule_metro`, `EventChain`, `line`, `Trigger`, and `Fabric`, because you will have written equivalents yourself.


Timed Points

Card 1: Declare Once vs. Control

Click this card to reveal full explanation

Tutorial: Metro

void compose() {
    auto window = MayaFlux::create_window({ "Time", 1200, 800 });
    auto points = vega.PointCollectionNode() | Graphics;
    auto buffer = vega.GeometryBuffer(points) | Graphics;
    buffer->setup_rendering({ .target_window = window });
    window->show();

    auto& angle       = make_persistent(0.0f);
    auto& point_count = make_persistent(0U);

    MayaFlux::schedule_metro(0.016, [points, &angle, &point_count]() {
        float spiral_offset = 0.25f * point_count;
        float r = 0.6f + 0.3f * std::sin((angle + spiral_offset) * 0.4f);
        float x = r * std::cos(angle + spiral_offset);
        float y = r * std::sin(angle + spiral_offset);
        float hue = (angle + spiral_offset) / (2.0f * M_PI);
        glm::vec3 color(
            std::abs(std::sin(hue * 3.14f)),
            std::abs(std::sin(hue * 3.14f + 2.09f)),
            std::abs(std::sin(hue * 3.14f + 4.19f)));
        points->add_point({ glm::vec3(x, y, 0.0f), color, 6.0f });
        angle += 0.18f;
        ++point_count;
    });
}

Run this. A spiral of colored points grows across the window at a fixed pace. One point every 16ms, regardless of anything else in the system. The rate is a wall-time promise measured in samples internally, but from the outside it behaves like a fixed interval.

Tutorial: Impulse node with on_impulse

void compose() {
    auto window = MayaFlux::create_window({ "Time", 1200, 800 });
    auto points = vega.PointCollectionNode() | Graphics;
    auto buffer = vega.GeometryBuffer(points) | Graphics;
    buffer->setup_rendering({ .target_window = window });
    window->show();

    auto clock    = vega.Impulse(2.0f) | Graphics;
    auto rate_mod = vega.Sine(0.1f) | Graphics;
    clock->set_frequency_modulator(rate_mod);

    auto& angle = make_persistent(0.0f);
    auto& count = make_persistent(0U);

    clock->on_impulse([points, clock, &angle, &count](const Nodes::NodeContext& ctx) {
        float r = 0.6f + 0.3f * std::sin(angle * 0.4f);
        float x = r * std::cos(angle);
        float y = r * std::sin(angle);
        float hue = angle / (2.0f * M_PI);
        glm::vec3 color(
            std::abs(std::sin(hue * 3.14f)),
            std::abs(std::sin(hue * 3.14f + 2.09f)),
            std::abs(std::sin(hue * 3.14f + 4.19f)));
        points->add_point({ glm::vec3(x, y, 0.0f), color, 6.0f });
        angle += 0.18f;

        ++count;
        if (count == 200) {
            clock->set_frequency(6.0f);
        }
    });
}

Run this. The same spiral grows, but the pace breathes. It slows down, speeds up, slows again. After 200 points the frequency jumps from 2 Hz to 6 Hz and stays there, triple the original rate, with no teardown and no new callback. The clock capture lets the callback reach back into the node that fired it and change its own future rate on the fly.

Change 0.1f to 0.5f on the Sine. The breathing is faster and more dramatic. Change the Impulse base frequency from 2.0f to 6.0f. More points, still breathing. The modulator and the base rate are independent parameters, both live, both modifiable without touching the callback.

Metro does not know about frequency. It knows about seconds. You give it 0.016 and it fires every 0.016 seconds, period. It has no concept of modulation. To change its rate you would have to cancel and reschedule it, or manage the timing yourself inside the callback.

on_impulse fires when the Impulse node fires. The Impulse node is a signal. Its frequency is a parameter that can be modulated by any other node, changed at any time with set_frequency(), or driven by an external input. The callback rate is a consequence of that signal, not a separately managed thing.

Metro is a scheduled timer. on_impulse is a side effect of computation that was already happening. The Impulse node processes samples regardless of whether anything is listening. on_impulse attaches to it.

Expansion 1: What metro actually is

Metro is a coroutine. When you call schedule_metro(0.016, callback), MayaFlux creates a Vruta::SoundRoutine that runs on the TaskScheduler:

Vruta::SoundRoutine metro_body(Vruta::TaskScheduler& scheduler,
                               double interval_seconds,
                               std::function<void()> callback) {
    uint64_t interval_samples = scheduler.seconds_to_samples(interval_seconds);
    auto& promise = co_await Kriya::GetAudioPromise{};

    while (true) {
        if (promise.should_terminate) break;
        callback();
        co_await Kriya::SampleDelay{ interval_samples };
    }
}

seconds_to_samples(0.016) at 48000 Hz gives 768 samples. The coroutine fires, suspends for exactly 768 samples, fires again. Time is counted in samples because the audio engine is the timing substrate. The scheduler advances a SampleClock every buffer cycle, and the coroutine resumes when the clock passes its target.

This is why metro is sample-accurate but not frequency-aware. It sleeps for N samples. That N does not change unless you cancel and reschedule.

angle and point_count are declared with make_persistent rather than as plain locals. The metro callback outlives compose() - the function returns immediately after scheduling the coroutine, destroying all stack variables. make_persistent allocates the value in a store with process lifetime and returns a reference the callback can safely hold across that boundary.

Expansion 2: What on_impulse actually is

Impulse::on_impulse registers a callback that fires inside notify_tick only when m_impulse_occurred is true. That flag is set in process_sample when m_phase < m_phase_inc, which is the sample where the phase accumulator crosses the cycle boundary.

The node is processed every sample at audio rate by the buffer and scheduler infrastructure. Your callback fires on the precise sample where the impulse occurs. That sample is determined by m_phase_inc = frequency / sample_rate. When frequency is modulated, m_phase_inc changes on each sample, so the interval between impulses changes continuously.

set_frequency() writes directly to m_frequency and recomputes m_phase_inc in the same call. This takes effect on the next call to process_sample. No coroutine is involved. No teardown. The node's next cycle boundary simply arrives sooner or later depending on the new m_phase_inc.

Metro lives in scheduler time. on_impulse lives in signal time. Signal time can be warped by any node, modulated by any source, driven by external input, or driven from inside the callback itself. Scheduler time cannot.

Expansion 3: Reading the context

The NodeHook type is declared against the base NodeContext, so the callback signature is:

clock->on_impulse([](const Nodes::NodeContext& ctx) {
    // ctx is available here
});

The actual object passed is always a GeneratorContext for any node that derives from Generator, including Impulse. GeneratorContext extends NodeContext with three additional fields: frequency, amplitude, and phase. These reflect the node's state at the exact sample the impulse fired, after any modulation has been applied for that sample.

ctx.value is the output at impulse time - for Impulse this is the amplitude, typically 1.0. This is enough for most callbacks.

When you need the generator fields, use as<GeneratorContext>():

clock->on_impulse([](const Nodes::NodeContext& ctx) {
    if (auto* gen = ctx.as<Nodes::Generator::GeneratorContext>()) {
        float current_freq  = gen->frequency;
        double current_phase = gen->phase;
    }
});

phase at an impulse is always near zero - the accumulator has just crossed the cycle boundary and been reset. frequency is the effective frequency after modulation, not the base parameter. If a Sine modulator is pushing the frequency around, gen->frequency gives you the actual Hz value at the moment that impulse fired, not what you passed to the constructor.

as<T>() returns nullptr if the type does not match. For Impulse, the concrete type is always GeneratorContext, so the null check is a safety idiom rather than a branch you expect to take.

Expansion 4: When to use which

Metro is appropriate when the interval is a fixed design decision: "update this visualization every 16ms," "poll this resource every second." The interval is a system parameter, not a signal.

on_impulse is appropriate when the rate should be a function of other computation: rhythms derived from data, tempos that drift, event densities that respond to material. It is also appropriate when you want a single node to serve as a clock for multiple listeners, each attached independently without coordination overhead.

The two mechanisms are not mutually exclusive. Metro and on_impulse can run simultaneously in the same compose, writing into the same geometry, at their own independent rates.

A useful heuristic: if you find yourself computing a new interval inside a metro callback and wishing you could just pass a node, switch to on_impulse with a modulator.

Try It: Both at once

void compose() {
    auto window = MayaFlux::create_window({ "Time", 1200, 800 });
    auto points = vega.PointCollectionNode() | Graphics;
    auto buffer = vega.GeometryBuffer(points) | Graphics;
    buffer->setup_rendering({ .target_window = window });
    window->show();

    auto& angle       = make_persistent(0.0f);
    auto& imp_count   = make_persistent(0U);
    auto& point_count = make_persistent(0U);

    MayaFlux::schedule_metro(0.016, [points, &angle, &point_count]() {
        float spiral_offset = 0.25f * point_count;
        float r = 0.6f + 0.3f * std::sin((angle + spiral_offset) * 0.4f);
        float x = r * std::cos(angle + spiral_offset);
        float y = r * std::sin(angle + spiral_offset);
        float hue = (angle + spiral_offset) / (2.0f * M_PI);
        glm::vec3 color(
            std::abs(std::sin(hue * 3.14f)),
            std::abs(std::sin(hue * 3.14f + 2.09f)),
            std::abs(std::sin(hue * 3.14f + 4.19f)));
        points->add_point({ glm::vec3(x, y, 0.0f), color, 6.0f });
        angle += 0.18f;
        ++point_count;
    });

    auto clock    = vega.Impulse(20.0f) | Audio[0];
    auto rate_mod = vega.Sine(0.01f, 80) | Graphics;
    clock->set_frequency_modulator(rate_mod);

    clock->on_impulse([points, clock, &angle, &imp_count](const Nodes::NodeContext& ctx) {
        float r = 0.6f + 0.3f * std::sin(angle * 0.4f);
        float x = r * std::cos(angle);
        float y = r * std::sin(angle);
        float hue = angle / (2.0f * M_PI);
        glm::vec3 color(
            std::abs(std::sin(hue * 3.14f)),
            std::abs(std::sin(hue * 3.14f + 2.09f)),
            std::abs(std::sin(hue * 3.14f + 4.19f)));
        points->add_point({ glm::vec3(x, y, 0.0f), color, 12.0f });
        angle += 0.78f;
        ++imp_count;
        if (imp_count == 200) {
            clock->set_frequency(6.0f);
        }
    });
}

Metro lays down small points at a fixed 16ms grid. The Impulse fires at 20 Hz, modulated by a slow Sine, writing larger points at a different angular step. Both share angle and points - the spiral they build is the product of two clocks running at different rates into the same collection. At 200 impulses the Impulse rate drops to 6 Hz; the metro does not notice and does not change.


Timed Lines

Card 2: Control as Side Effect

Click this card to reveal full explanation

Important: Quick explaination of timing models
void compose() {
    auto sine = vega.Sine(2.0f) | Audio[0];

    sine->on_tick([](auto& ctx) {
        // fires 48000 times per second
        // (void)ctx;
        std::cout << ctx.value << " at audio rate\n";
    });

    auto point_node = vega.PointNode() | Graphics;

    point_node->on_tick([](auto& ctx) {
        // fires 60 times per second
        // (void)ctx;
        std::cout << ctx.value << " at graphics rate\n";
    });
}

The Audio node ticks at sample rate. The Graphics node ticks at frame rate. Same method, two orders of magnitude apart in frequency. | Audio[0] and | Graphics do not just register the node with different subsystems; they determine the entire temporal contract of every hook attached to that node. on_tick on a Graphics node is frame-rate code. on_tick on an Audio node is DSP-rate code. Putting expensive work into an audio-rate on_tick stalls the audio thread. This is why on_impulse, on_count, and on_increment exist: they give you audio-rate precision with call frequency you can reason about.

The previous example with the metro and on_impulse did not explore the side effects of time. For metro, there is none as time is fixed. But for nodes, while on_tick itself does not beyond rate of the backend/subsystem responsible for ticking, on_impulse and similar hooks are dependent on the logic of the signal. For instance, the impulse rate is not constant if it were frequency modulated. Or if the node in question were inherently dependent on other signals, input sources, non deterministic logic, or any other factor that could affect the timing of the used hook callback.

Tutorial: Counter driving a path

void compose() {
    auto window = MayaFlux::create_window({ "Time", 1200, 800 });
    window->show();

    auto path = vega.PathGeneratorNode(
        Kinesis::InterpolationMode::CATMULL_ROM, 32, 128
    ) | Graphics;
    path->set_path_color(glm::vec3(0.4f, 0.8f, 1.0f));
    path->set_path_thickness(2.0f);

    auto buffer = vega.GeometryBuffer(path) | Graphics;
    buffer->setup_rendering({ .target_window = window });

    auto clock = vega.Impulse(0.5f);
    clock->set_frequency_modulator(vega.Sine(0.08f));

    auto counter = vega.Counter(64) | Audio[0];
    counter->set_reset_trigger(clock);

    counter->on_increment([path](auto& ctx) {
        float t     = static_cast<float>(ctx.phase);
        float angle = t * 2.0f * M_PI / 64.0f;
        float r     = 0.3f + 0.5f * std::sin(t * 0.4f);
        glm::vec3 pos(r * std::cos(angle), r * std::sin(angle), 0.0f);
        path->add_control_point({ pos, glm::vec3(1.0f - t / 64.0f, t / 64.0f, 0.6f), 2.0f });
    });

    counter->on_wrap([path](auto&) {
        path->set_path_color(glm::vec3(
            get_uniform_random(0.3f, 1.0f),
            get_uniform_random(0.3f, 1.0f),
            get_uniform_random(0.3f, 1.0f)
        ));
    });
}

Run this. A curved path grows across the window, tracing a slowly shifting orbit. The PathGeneratorNode ring holds 128 points; old ones age out as new ones arrive. On each wrap the color randomizes - a structural moment with no geometry destroyed.

The Impulse and its Sine modulator need no domain registration - they are processed because the Counter registers the Impulse as its reset trigger, and the Impulse registers the Sine as its frequency modulator. Only the Counter needs | Audio[0].

on_increment fires once per counter tick. on_wrap fires exactly once at the modulo boundary. Neither is audio-rate in practice: the Impulse fires at 0.5Hz modulated, so on_increment fires at that rate regardless of the 48000Hz substrate underneath.

Example: Counter with on_count for structural moments
void compose() {
    auto window = MayaFlux::create_window({ "Time", 1200, 800 });
    window->show();

    auto path = vega.PathGeneratorNode(
        Kinesis::InterpolationMode::CATMULL_ROM, 32, 128
    ) | Graphics;
    path->set_path_color(glm::vec3(1.0f, 0.5f, 0.2f));
    path->set_path_thickness(2.5f);

    auto buffer = vega.GeometryBuffer(path) | Graphics;
    buffer->setup_rendering({ .target_window = window });

    auto clock   = vega.Impulse(0.5f);
    auto counter = vega.Counter(32) | Audio[0];
    counter->set_reset_trigger(clock);

    counter->on_increment([path](auto& ctx) {
        float t     = static_cast<float>(ctx.phase);
        float angle = t * 2.0f * M_PI / 32.0f;
        float r     = 0.5f + 0.2f * std::cos(t * 1.3f);
        path->add_control_point({
            glm::vec3(r * std::cos(angle), r * std::sin(angle), 0.0f),
            glm::vec3(0.9f, 0.5f, 0.2f),
            2.0f
        });
    });

    counter->on_count(8,  [path](auto&) { path->set_path_color(glm::vec3(0.2f, 0.9f, 0.5f)); });
    counter->on_count(16, [path](auto&) { path->set_path_color(glm::vec3(0.5f, 0.2f, 0.9f)); });
    counter->on_count(24, [path](auto&) { path->set_path_color(glm::vec3(0.9f, 0.2f, 0.2f)); });

    counter->on_wrap([path](auto&) {
        path->set_path_color(glm::vec3(1.0f, 0.5f, 0.2f));
    });
}

The path grows in four color phases: orange through 8 steps, then green, then purple, then red, then wraps and resets the color. Change the Impulse frequency and the phase structure stays identical - only the wall-time duration of each phase changes. Sequence structure is decoupled from speed.

Example: Temporal Logic as a gate
void compose() {
    auto window = MayaFlux::create_window({ "Time", 1200, 800 });
    window->show();

    auto path = vega.PathGeneratorNode(
        Kinesis::InterpolationMode::CATMULL_ROM, 20, 256
    ) | Graphics;
    auto gbuf = vega.GeometryBuffer(path) | Graphics;
    gbuf->setup_rendering({ .target_window = window });

    auto lfo = vega.Sine(0.3f) | Audio[0];

    auto gate = vega.Logic(
        [](double input, double elapsed) -> bool {
            double wn = std::fmod(elapsed, 6.0);
            return wn > 1.0 && wn < 4.0;
        }
    ) | Audio[1];
    gate->set_input_node(lfo);

    auto& n     = make_persistent(0U);
    auto& x_pos = make_persistent(-0.8f);

    gate->while_true([&n, path, lfo, &x_pos](auto&) {
        if (++n % 800 != 0) return;
        float v = static_cast<float>(lfo->get_last_output());
        float x = std::fmod(static_cast<float>(n) / 800.0f, 533.0f) / 533.0f * 1.6f - 0.8f;
        path->add_control_point({
            .position  = glm::vec3((x * x_pos) + get_uniform_random(-0.05f, 0.5f),
                                    v * 0.6f + get_uniform_random(-0.05f, 0.05f), 0.0f),
            .color     = glm::vec3(0.9f, get_exponential_random(), 0.5f + 0.4f * std::abs(v)),
            .thickness = (float)get_uniform_random(0.02f, 1.6f)
        });
        x_pos += 0.006f;
    });

    gate->on_change_to(false, [path, &n, &x_pos](auto&) {
        auto pts = path->get_control_points();
        for (auto& pt : pts) {
            pt.color = glm::vec3(
                get_uniform_random(0.3f, 1.0f),
                get_uniform_random(0.3f, 1.0f),
                get_uniform_random(0.3f, 1.0f));
        }
        path->set_control_points(pts);
        n     = 0;
        x_pos = -0.8f;
    });
}

For the first second of every six nothing draws. From second 1 to 4 the gate opens and the path accumulates. At second 4 the gate closes: existing points are recolored in place and cursors reset. The LFO is not the criterion - elapsed time is. The signal is only the material being drawn.

gate needs | Audio[1] because Logic is a Generator the scheduler owns independently. It pulls its source via set_input_node. The elapsed parameter is m_temporal_time, which increments by 1.0 / sample_rate on every sample - sample-counted elapsed time, not wall-clock.


Metro is a timer. on_impulse is a side effect of a signal. on_increment fires at sequence positions. while_true fires for as long as a condition holds. on_change_to fires exactly once at the transition. These are not variations on a theme - they are different shapes of time: periodic, positional, conditional, durational.

The Logic node makes the condition explicit and composable. The criterion can be a threshold, a pattern across a history window, a conjunctive test across multiple signals, or purely temporal. The signal fed into the Logic node is the material; the lambda is the gate.

Expansion 1: What ctx.phase and ctx.value carry in Counter

For Counter, ctx.phase carries the raw integer count cast to double: 0 at the first increment, 1 at the second, up to modulo - 1 before the wrap. Use it for positional logic inside on_increment - angle calculations, array indexing, color interpolation keyed to position.

ctx.value carries the normalized output: count / modulo when modulo is nonzero, raw count as double when modulo is zero. This is what the Counter outputs as a signal to downstream nodes. The two fields serve different consumers: ctx.phase for callback positional logic, ctx.value for signal routing downstream.

Expansion 2: Reset trigger edge detection

The reset trigger is edge-sensitive: Counter watches for a rising edge on the trigger node's output, not a sustained nonzero value. An Impulse node produces exactly one nonzero sample per cycle, so it is a natural trigger source.

A Logic node with sustained while_true output would re-trigger every sample it is high, effectively freezing the counter at zero. If you want a reset tied to a Logic condition, use on_change_to(true, ...) to fire a one-shot event rather than connecting sustained output directly as a trigger.

Any node whose output crosses zero on the event you care about serves as a valid trigger: a threshold-crossing on an envelope, a Logic node detecting a specific condition, an InputNode receiving a MIDI note.

Expansion 3: on_change_to vs while_true vs on_tick

on_tick fires on every sample the node processes. For a Logic node at Audio rate that is 48000 times per second. Use it when you need the raw boolean stream as data.

on_change_to(true, ...) fires exactly once at the sample where the output transitions from false to true. It is edge-sensitive: sustained true output does not re-fire it. Use it for "begin a new thing": start a new path, change a color, record a timestamp.

while_true(...) fires on every sample where the output is currently true. It is level-sensitive: it fires continuously for as long as the condition holds. Use it for "continue doing a thing while the condition holds": accumulating geometry, writing to a buffer, advancing a cursor.

The temporal Logic tutorial uses both: on_change_to(false, ...) recolors at the moment the gate closes, while_true accumulates during the open window. One fires once at the edge, the other fires continuously until the edge closes.

Expansion 4: Why not on_tick for path control?

on_tick on an Audio node fires 48000 times per second. add_control_point on a PathGeneratorNode pushes to a ring buffer and sets dirty flags. Calling it 48000 times per second to build a path that renders at 60Hz produces 800 control points per frame, almost all redundant. The path geometry regenerates from those control points each frame; excess control points increase CPU work for no visual benefit.

on_increment driven by a 0.5Hz Impulse or while_true decimated by a sample counter produces only the points that are visually meaningful. on_tick is appropriate when the node's per-sample value is itself the material: waveshaping, sample-by-sample analysis, building a data array for FFT input. For anything that interacts with visual geometry, a coarser hook is almost always correct.

Expansion 5: The four Logic modes

DIRECT mode evaluates each sample independently. No memory. Use it when the criterion is purely about the current value: amplitude gates, polarity checks, range membership.

SEQUENTIAL mode receives a sliding window of past boolean states derived by thresholding the input against m_threshold. The lambda receives a std::span<bool> of already-binarized values - it pattern-matches on those, it does not control what enters the history. Use it for run detection, alternation patterns, specific boolean sequences.

TEMPORAL mode receives both the current input and elapsed time in seconds. Use it when the criterion involves duration or periodic scheduling: "only during the second half of each N-second period," "true if enough time has elapsed."

MULTI_INPUT mode receives a vector of simultaneous inputs from multiple sources. Use it when the condition requires agreement across signals: two signals both above threshold, majority voting, cross-signal correlation.


Timed Particles

Card 3: Tiered Control

Click this card to reveal full explanation

Tutorial: Sequence

void compose() {
    auto window = MayaFlux::create_window({ "Time", 1200, 800 });

    auto particles = vega.ParticleNetwork(
        300,
        glm::vec3(-1.5f, -1.5f, -0.5f),
        glm::vec3(1.5f, 1.5f, 0.5f),
        Kinesis::SpatialDistribution::RANDOM_VOLUME
    ) | Graphics;

    auto* physics = particles->create_operator<PhysicsOperator>();
    physics->set_drag(0.01f);
    physics->set_bounds_mode(PhysicsOperator::BoundsMode::BOUNCE);

    auto buffer = vega.NetworkGeometryBuffer(particles) | Graphics;
    buffer->setup_rendering({ .target_window = window });
    window->show();

    MayaFlux::schedule_sequence({
        { 0.0, [physics]() {
            physics->set_turbulence_strength(0.8f);
        }},
        { 2.0, [physics]() {
            physics->set_turbulence_strength(0.0f);
            physics->enable_spatial_interactions(true);
            physics->set_interaction_radius(0.4f);
            physics->set_spring_stiffness(0.6f);
        }},
        { 2.5, [physics]() {
            physics->set_repulsion_strength(2.0f);
            physics->set_interaction_radius(0.2f);
        }},
        { 2.0, [physics]() {
            physics->enable_spatial_interactions(false);
            physics->set_drag(0.08f);
        }},
        { 2.0, [physics]() {
            physics->set_drag(0.01f);
            physics->set_turbulence_strength(0.4f);
        }},
    });
}

Run this. Particles start in chaotic turbulence, then settle as spatial interactions switch on and spring forces pull them into loose clusters. Repulsion tightens, clusters compress. Interactions cut and drag bleeds off momentum. Then turbulence returns at half strength and the arc ends.

The sequence is a vector of (delay, callback) pairs. Each delay is the interval after the previous event. Cumulative time: 0 + 2.0 + 2.5 + 2.0 + 2.0 = 8.5 seconds total.

Tutorial: EventChain

void compose() {
    auto window = MayaFlux::create_window({ "Time", 1200, 800 });

    auto particles = vega.ParticleNetwork(
        300,
        glm::vec3(-1.5f, -1.5f, -0.5f),
        glm::vec3(1.5f, 1.5f, 0.5f),
        Kinesis::SpatialDistribution::RANDOM_VOLUME
    ) | Graphics;

    auto* physics = particles->create_operator<PhysicsOperator>();
    physics->set_drag(0.01f);
    physics->set_bounds_mode(PhysicsOperator::BoundsMode::BOUNCE);

    auto buffer = vega.NetworkGeometryBuffer(particles) | Graphics;
    buffer->setup_rendering({ .target_window = window });
    window->show();

    Kriya::EventChain chain(*MayaFlux::get_scheduler());

    chain.then([physics]() {
             physics->set_turbulence_strength(0.8f);
         })
         .then([physics]() {
             physics->set_turbulence_strength(0.0f);
             physics->enable_spatial_interactions(true);
             physics->set_interaction_radius(0.4f);
             physics->set_spring_stiffness(0.6f);
         }, 2.0)
         .then([physics]() {
             physics->set_repulsion_strength(2.0f);
             physics->set_interaction_radius(0.2f);
         }, 2.5)
         .then([physics]() {
             physics->enable_spatial_interactions(false);
             physics->set_drag(0.08f);
         }, 2.0)
         .then([physics]() {
             physics->set_drag(0.01f);
             physics->set_turbulence_strength(0.4f);
         }, 2.0)
         .start();
}

Run this. The same arc as the sequence above, same timing, same result.

The structural difference is readability and composability. EventChain is a builder: each .then() returns the same chain, so you can read the choreography top to bottom. For short sequences the difference is minor; for longer choreographies with repetition or a completion callback, the chain's additional methods become significant.

Example: EventChain with repetition and completion
void compose() {
    auto window = MayaFlux::create_window({ "Time", 1200, 800 });

    auto particles = vega.ParticleNetwork(
        300,
        glm::vec3(-1.5f, -1.5f, -0.5f),
        glm::vec3(1.5f, 1.5f, 0.5f),
        Kinesis::SpatialDistribution::RANDOM_VOLUME
    ) | Graphics;

    auto* physics = particles->create_operator<PhysicsOperator>();
    physics->set_drag(0.02f);
    physics->set_bounds_mode(PhysicsOperator::BoundsMode::WRAP);

    auto buffer = vega.NetworkGeometryBuffer(particles) | Graphics;
    buffer->setup_rendering({ .target_window = window });
    window->show();

    Kriya::EventChain chain(*MayaFlux::get_scheduler());

    chain.then([physics]() {
             physics->set_attraction_point(glm::vec3(
                 get_uniform_random(-0.8f, 0.8f),
                 get_uniform_random(-0.8f, 0.8f),
                 0.0f
             ));
         }, 1.2)
         .repeat(3)
         .then([physics]() {
             physics->clear_attraction_point();
             physics->set_turbulence_strength(0.6f);
         }, 1.5)
         .on_complete([physics]() {
             physics->set_turbulence_strength(0.0f);
             physics->set_drag(0.02f);
         })
         .times(3)
         .start();
}

The attraction point jumps to four random positions 1.2 seconds apart, pulling the cloud toward each in sequence. Then attraction clears and turbulence scatters everything. That full arc runs 3 times. After the third pass on_complete cuts the turbulence and resets drag.

.repeat(3) appends 3 more copies of the first event: 1 + 3 = 4 attraction moves total. .times(3) runs the whole list 3 times from the start.


Metro and node hooks repeat indefinitely - they have no concept of a beginning or an end. Sequence and EventChain are for computation with a defined shape over time: a finite set of moments, each happening once, in order. The difference between them is lifecycle management, not timing precision.

Expansion 1: Delays are relative, not absolute

Both schedule_sequence and EventChain use relative delays: each delay is the interval after the previous event, not a position on a global timeline. The first event's delay is measured from start() or from when the sequence is scheduled.

This has a practical consequence when inserting events mid-sequence. Adding a new event between two existing ones only requires specifying its delay from the preceding event - no other delay values need updating. If you need absolute timing, convert manually: t=0, t=2.5, t=4.0 becomes delays 0, 2.5, 1.5.

Expansion 2: on_complete fires on cancel too

on_complete fires regardless of how the chain stops - after the final event completes normally, and also when cancel() is called. An internal flag prevents it firing twice. This means on_complete is suitable for cleanup that must happen whether the chain runs to conclusion or is interrupted: restoring physics state, releasing a resource, enabling a UI element.

If you only want a callback on normal completion, put it as the final .then() with zero delay instead.

Expansion 3: wait() and every()

.wait(seconds) inserts a pause with no action - equivalent to .then([](){}, seconds) but expresses intent clearly:

chain.then([physics]() { physics->set_turbulence_strength(1.2f); })
     .wait(3.0)
     .then([physics]() { physics->set_turbulence_strength(0.0f); })
     .start();

.every(interval, action) is syntactic sugar for .then(action, interval) - clearer when describing a repeated periodic action rather than a one-time transition.

Expansion 4: When to use which

Metro has no end. Use it when the work is ongoing and structurally uniform - polling, continuous accumulation, periodic display updates. It knows nothing about what came before or after.

schedule_sequence has an end but no handle. Use it when you have a fixed choreography that will not need to be cancelled, repeated, or observed from outside. Declare it, fire it, forget it.

EventChain has an end and a handle. Use it when the sequence is part of a larger system: you need to know when it finishes, restart it from a key event, cancel it in response to something else, or compose it with another chain via on_complete. The fluent API is a secondary benefit; the lifecycle management is the reason to reach for it.

Node hooks (on_impulse, on_increment, while_true) have no inherent temporal shape. They fire as a consequence of computation that is already happening. Use them when the rate or condition is itself a signal, not a design decision made at construction time.

Expansion 5: schedule_sequence vs EventChain at a glance

Both are built on the same coroutine: one SoundRoutine that iterates its event list and co_await SampleDelay{N} between entries. The abstraction difference is entirely in the API surface, not in timing precision or scheduling behavior.

schedule_sequence is the minimal form: flat vector, fire-and-forget, no handle returned, runs once. EventChain gives you .cancel(), .is_active(), .times(), .repeat(), .on_complete(), .wait(), .every().

Expansion 6: Audio-reactive particles via map_parameter

ParticleNetwork exposes named parameters driveable by any node output via map_parameter. The physics are not limited to what you set at construction:

auto lfo = vega.Sine(0.15f) | Audio[0];
auto chaos = vega.Random();
lfo->set_frequency_modulator(chaos);
particles->map_parameter("turbulence", lfo, MappingMode::BROADCAST);

auto env = vega.Sine(0.05f);
particles->map_parameter("interaction_radius", env, MappingMode::BROADCAST);

Available broadcast parameters: gravity_x, gravity_y, gravity_z, drag, turbulence, interaction_radius, spring_stiffness. BROADCAST applies the same value to all particles. ONE_TO_ONE maps per-particle outputs from another network directly to per-particle parameters.

This composes with EventChain: the chain handles large structural transitions while mapped nodes handle continuous variation between those transitions.


Card 4: ONLYWHENs

Click this card to reveal full explanation

Tutorial: TimedAction : bracketed state

void compose() {
    const std::vector<float> freqs = { 220.0f, 277.0f, 330.0f, 370.0f, 440.0f, 554.0f, 660.0f };

    std::vector<std::shared_ptr<Sine>> sines;
    for (auto f : freqs) {
        auto s = vega.Sine(f) | Audio[0];
        s->set_amplitude(0.08);
        sines.push_back(s);
    }

    auto action = std::make_shared<Kriya::TimedAction>(*get_scheduler());
    store(action);

    auto clock = vega.Impulse(0.15f) | Audio[0];
    clock->on_impulse([sines, freqs, action](const Nodes::NodeContext&) {
        action->execute(
            [sines]() {
                for (auto& s : sines)
                    s->set_frequency(220.0f);
            },
            [sines, freqs]() {
                for (int i = 0; i < (int)sines.size(); ++i)
                    sines[i]->set_frequency(freqs[i]);
            },
            get_uniform_random(0.5, 2.0)
        );
    });
}

Seven sines tuned to a major seventh chord run continuously. Every ~6.5 seconds the Impulse fires and the chord collapses: all voices snap to 220Hz. After a random 0.5 to 2 second window they restore to their original frequencies. The collapse and restore are sample-accurate. Rapid impulses do not stack brackets; a new execute call cancels the pending restore and restarts the duration from the new trigger point.

Try: change the Impulse to 0.08f and the duration to get_uniform_random(4.0, 8.0). The bracket now outlasts the impulse period. The next impulse fires while still collapsed, restarting from unison again. The chord barely has time to breathe before the next collapse.

Tutorial: >> Time(N) — rotating partials

void compose() {
    auto base = vega.Sine(110.0f) | Audio[0];
    base->set_amplitude(0.3);

    const std::vector<float> partials = { 330.0f, 550.0f, 770.0f, 990.0f, 1320.0f, 1650.0f, 2200.0f };

    auto clock = vega.Impulse(1.2f) | Audio[0];
    clock->set_frequency_modulator(vega.Sine(0.05f));

    clock->on_impulse([partials](auto&) {
        float dur = get_uniform_random(0.3, 1.5);
        auto idx = (int)get_uniform_random(0, 6);

        auto s0 = vega.Sine(partials[idx]);
        auto s1 = vega.Sine(partials[(idx + 2) % 7]);
        auto s2 = vega.Sine(partials[(idx + 4) % 7]);

        s0->set_amplitude(0.10);
        s1->set_amplitude(0.07);
        s2->set_amplitude(0.05);

        s0 >> Time(dur)         | Audio[0];
        s1 >> Time(dur * 0.6f)  | Audio[1];
        s2 >> Time(dur * 0.35f) | Audio[0];
    });
}

A 110Hz base sustains permanently. Each impulse fires three partials from the array, selected by stepping two positions apart so they always form a consistent intervallic relationship. Each partial has its own duration: the longest on channel 0, a mid-length on channel 1, the shortest back on channel 0. They enter together and leave at different times, so the gesture decays in layers rather than cutting simultaneously.

The slow Sine modulating the clock breathes the impulse density over roughly 20 second cycles. At peak density, partial clusters overlap: several are still alive when the next impulse fires its own set. Change (idx + 2) % 7 and (idx + 4) % 7 to (idx + 1) % 7 and (idx + 6) % 7 for a tighter cluster plus a distant partial instead of even thirds.

Everything in the previous cards repeats or runs indefinitely. Metro fires until cancelled. on_impulse fires as long as the node processes. EventChain has a fixed arc but no handle on individual moments within it. None of them express "do this, then undo it, after exactly N seconds" or "this node exists in the graph for this duration and no longer."

TimedAction is a start/end pair with a sample-accurate duration between them. >> Time(N) is graph membership for a bounded duration. Both are about computation that is conditionally present in time, not continuously running.

Expansion 1: What TimedAction::execute actually does

execute(start, end, duration) calls start immediately, then schedules a Timer to call end after duration seconds. The timer is a coroutine that suspends for exactly that many samples, so the end fires at a sample-accurate position regardless of buffer size or scheduling jitter.

One TimedAction holds one bracket at a time. Calling execute while a bracket is active cancels the pending end and starts a new one immediately; the previous end function never fires. Rapid re-triggers therefore reset the duration from the new trigger point rather than queuing multiple restores. To get stacked brackets that each fire their own end independently, use separate TimedAction instances.

Expansion 2: Why not EventChain for this

EventChain is declared once with a fixed shape and runs from a known start point. It cannot be re-fired mid-execution in response to a signal, and its duration is set at construction time rather than at trigger time.

TimedAction has no predetermined choreography. The same object can be re-executed from different callbacks with different durations computed at trigger time. Here the duration is get_uniform_random(0.5, 2.0): a different value on every impulse, determined by when the impulse fires, not by anything declared in advance.

Use EventChain when the sequence is known in advance and has multiple steps. Use TimedAction when you have a single reversible state change that needs to be triggered from anywhere, at any time, with a duration determined by the trigger context.

Expansion 3: What >> Time(N) | Audio[ch] actually does

The expression creates a TemporalActivation that registers the node with the audio graph on the specified channel immediately, then schedules a timer to unregister it after N seconds. The node is held alive by the graph registration for that duration with no explicit store needed. When the timer fires, the node leaves the graph and is released.

The node has no awareness of its bounded lifetime. It processes normally for the duration, and any modulators or hooks attached to it continue to work. The graph simply stops calling it after N seconds.

This is not the same as setting amplitude to zero. A node at zero amplitude still runs process_sample on every sample. A node outside the graph does not. At large counts of simultaneous transient nodes the CPU difference is meaningful.

Expansion 4: The layered decay shape

Three partials enter the graph simultaneously with durations dur, dur * 0.6, and dur * 0.35. The shortest expires first, leaving a pair. Then the middle one expires, leaving only the longest. Then silence until the next impulse.

This decay shape (full chord, then subset, then single, then silence) emerges from the duration arithmetic alone. No envelope node, no amplitude ramp, no explicit sequencing. The shape is determined entirely by which nodes are alive in the graph at each moment. Change the scale factors to 1.0, 0.98, 0.96 and the three partials expire almost simultaneously: a tight staccato cut rather than a layered decay.

Expansion 5: Gate vs Trigger vs Toggle

Gate fires its callback on every sample where the Logic output is currently true (level-sensitive). Trigger fires once per rising edge: the single sample where output transitions from false to true. Toggle fires on any state change, rising or falling.

All three are coroutines built on the same loop: drive the Logic node one sample at a time, check its output, fire the registered hook when the condition matches. The difference is which hook type is registered (while_true, on_change_to, or on_change).

Gate needs decimation inside the callback when the intended event rate is coarser than sample rate. Trigger does not, because it fires at most once per gate opening by definition. Replacing Gate with Trigger in the bowl example below would fire one partial per Phasor cycle rather than a burst of them over the open window.

Try It: Singing bowl

void compose() {
    auto fundamental = vega.Sine(196.0f) | Audio[0];
    fundamental->set_amplitude(0.25);
    auto wobble = vega.Sine(0.13f);
    auto wobble_depth = vega.Polynomial([](double x) { return x * 2.8; });
    wobble >> wobble_depth;
    fundamental->set_frequency_modulator(wobble_depth);

    const std::vector<float> bowl_harmonics = {
        392.3f,   // ~2x + asymmetry
        588.7f,   // ~3x
        789.1f,   // ~4x - more inharmonic
        1179.4f,  // ~6x
    };
    for (int i = 0; i < 4; ++i) {
        auto h = vega.Sine(bowl_harmonics[i]) | Audio[0];
        h->set_amplitude(0.12f / (i + 1));
        auto beat = vega.Sine(get_uniform_random(0.03, 0.19));
        auto bdep = vega.Polynomial([i](double x) { return x * (3.0 - i * 0.5); });
        beat >> bdep;
        h->set_frequency_modulator(bdep);
        h->set_amplitude_modulator(
            vega.Sine(static_cast<float>(get_uniform_random(0.07, 0.31))));
    }

    auto rotor     = vega.Phasor(0.11f) | Audio[0];
    auto gate_logic = std::make_shared<Nodes::Generator::Logic>(0.5);
    gate_logic->set_input_node(rotor);

    const std::vector<float> spiral = { 980.0f, 1372.0f, 1568.0f, 2156.0f, 2940.0f };

    auto gate = std::make_shared<Vruta::SoundRoutine>(
        Kriya::Gate(*get_scheduler(), [spiral]() {
            static uint64_t n = 0;
            if (++n % 24000 != 0) return;
            auto s = vega.Sine(spiral[(int)get_uniform_random(0, 4)]);
            s->set_amplitude(get_uniform_random(0.04, 0.09));
            s >> Time(get_uniform_random(0.4, 1.2)) | Audio[0];
        }, gate_logic));
    get_scheduler()->add_task(gate, "bowl_gate");
}

A fundamental at 196Hz sustains with a slow 2.8Hz frequency wobble. Four harmonics at slightly inharmonic ratios (mimicking a real bowl's asymmetric modes) each have independent slow amplitude and frequency modulators at incommensurate rates; their beating pattern never locks into a repeating cycle.

A Phasor at 0.11Hz (roughly a 9 second cycle) feeds a threshold Logic node. The Gate coroutine drives that Logic node sample-by-sample. While the Phasor is above 0.5 (roughly half of each cycle) the gate is open. Every 24000 samples (0.5 seconds at 48kHz) a partial from the spiral array enters the graph for 0.4 to 1.2 seconds. These upper partials overlap with each other and with the bowl harmonics, creating a texture that waxes and wanes with the Phasor rotation.

Change the Phasor frequency to 0.04f for a slower rotation (25 second cycles, longer open and closed windows). Change the threshold from 0.5 to 0.8 and the gate is open for only 20% of each cycle: brief dense bursts of spiral partials separated by long silences.


External Face External Sun

Card 5: External Time

Click this card to reveal full explanation

All previous cards drew time from internal sources: oscillators, counters, coroutines, signal conditions. This card is about input that arrives from outside the processing graph entirely: a key press, a mouse position, an OSC message. None of these has a sample-accurate clock. They arrive when they arrive.

The mechanism is the same regardless of source. External events convert into either window event coroutines or InputNode values. From that point the hook vocabulary from the previous cards applies normally.

Tutorial: Keyboard events reshaping mesh slot transforms

void compose() {
    auto window = MayaFlux::create_window({ "External", 1200, 800 });

    auto make_face = [](glm::vec3 color, glm::vec3 normal, glm::vec3 tangent,
                         std::array<glm::vec3, 4> corners)
        -> std::pair<std::vector<MeshVertex>, std::vector<uint32_t>> {
        std::vector<MeshVertex> verts;
        verts.reserve(4);
        const std::array<glm::vec2, 4> uvs = { glm::vec2{0,0},{1,0},{1,1},{0,1} };
        for (int i = 0; i < 4; ++i)
            verts.push_back({ corners[i], color, 1.f, uvs[i], normal, tangent });
        return { verts, { 0, 1, 2, 2, 3, 0 } };
    };

    constexpr float H = 0.5f;

    struct FaceSpec {
        std::string name;
        glm::vec3 color, normal, tangent;
        std::array<glm::vec3, 4> corners;
    };
    const std::vector<FaceSpec> specs = {
        { "front",  {0.9f,0.3f,0.2f}, { 0, 0, 1}, { 1, 0, 0}, {{{-H,-H, H},{ H,-H, H},{ H, H, H},{-H, H, H}}} },
        { "back",   {0.2f,0.5f,0.9f}, { 0, 0,-1}, {-1, 0, 0}, {{{ H,-H,-H},{-H,-H,-H},{-H, H,-H},{ H, H,-H}}} },
        { "top",    {0.2f,0.9f,0.3f}, { 0, 1, 0}, { 1, 0, 0}, {{{-H, H,-H},{ H, H,-H},{ H, H, H},{-H, H, H}}} },
        { "bottom", {0.9f,0.8f,0.1f}, { 0,-1, 0}, { 1, 0, 0}, {{{-H,-H, H},{ H,-H, H},{ H,-H,-H},{-H,-H,-H}}} },
        { "right",  {0.7f,0.2f,0.9f}, { 1, 0, 0}, { 0, 0,-1}, {{{ H,-H, H},{ H,-H,-H},{ H, H,-H},{ H, H, H}}} },
        { "left",   {0.9f,0.5f,0.1f}, {-1, 0, 0}, { 0, 0, 1}, {{{-H,-H,-H},{-H,-H, H},{-H, H, H},{-H, H,-H}}} },
    };

    auto net = vega.MeshNetwork() | Graphics;

    struct SlotState {
        glm::vec3 normal;
        std::atomic<float> offset   { 0.f };
        std::atomic<float> velocity { 0.f };
        std::atomic<bool>  held     { false };
    };
    auto states = std::make_shared<std::vector<SlotState>>(specs.size());

    for (size_t i = 0; i < specs.size(); ++i) {
        auto [verts, indices] = make_face(
            specs[i].color, specs[i].normal, specs[i].tangent, specs[i].corners);
        auto node = std::make_shared<Nodes::GpuSync::MeshWriterNode>(4);
        node->set_mesh(verts, indices);
        net->add_slot(specs[i].name, node);
        (*states)[i].normal = specs[i].normal;
    }

    auto buf = vega.MeshNetworkBuffer(net) | Graphics;
    buf->setup_rendering({ .target_window = window });
    buf->get_render_processor()->set_view_transform(
        Kinesis::look_at_perspective(
            {2.5f, 2.0f, 3.5f}, {0,0,0},
            glm::radians(50.f), 1200.f/800.f, 0.01f, 1000.f));
    window->show();

    MayaFlux::schedule_metro(1.0 / 60.0, [net, states]() {
        auto& slots = net->slots();
        for (size_t i = 0; i < slots.size(); ++i) {
            auto& s = (*states)[i];
            float v = s.velocity.load();
            float o = s.offset.load();
            if (s.held.load()) v += 0.012f;
            o += v;
            v *= 0.88f;
            o *= 0.94f;
            s.offset.store(o);
            s.velocity.store(v);
            slots[i].local_transform = glm::translate(glm::mat4(1.f), s.normal * o);
            slots[i].dirty = true;
        }
    });

    MayaFlux::on_key_pressed(window,  IO::Keys::I, [states]() { (*states)[0].held.store(true,  std::memory_order_relaxed); });
    MayaFlux::on_key_released(window, IO::Keys::I, [states]() { (*states)[0].held.store(false, std::memory_order_relaxed); });
    MayaFlux::on_key_pressed(window,  IO::Keys::O, [states]() { (*states)[1].held.store(true,  std::memory_order_relaxed); });
    MayaFlux::on_key_released(window, IO::Keys::O, [states]() { (*states)[1].held.store(false, std::memory_order_relaxed); });
    MayaFlux::on_key_pressed(window,  IO::Keys::K, [states]() { (*states)[2].held.store(true,  std::memory_order_relaxed); });
    MayaFlux::on_key_released(window, IO::Keys::K, [states]() { (*states)[2].held.store(false, std::memory_order_relaxed); });
    MayaFlux::on_key_pressed(window,  IO::Keys::J, [states]() { (*states)[3].held.store(true,  std::memory_order_relaxed); });
    MayaFlux::on_key_released(window, IO::Keys::J, [states]() { (*states)[3].held.store(false, std::memory_order_relaxed); });
    MayaFlux::on_key_pressed(window,  IO::Keys::H, [states]() { (*states)[4].held.store(true,  std::memory_order_relaxed); });
    MayaFlux::on_key_released(window, IO::Keys::H, [states]() { (*states)[4].held.store(false, std::memory_order_relaxed); });
    MayaFlux::on_key_pressed(window,  IO::Keys::L, [states]() { (*states)[5].held.store(true,  std::memory_order_relaxed); });
    MayaFlux::on_key_released(window, IO::Keys::L, [states]() { (*states)[5].held.store(false, std::memory_order_relaxed); });

    bind_viewport_preset(window,
        buf->get_render_processor(), ViewportPresetMode::Fly, {}, "cube_keys");
}

Run this. Six colored faces sit assembled as a cube. Press I and the front face slides outward along its normal. Hold it and it continues to push. Release and it decays back. H/J/K/L/I/O each control one face independently. Multiple keys held simultaneously push multiple faces outward. The Fly preset binds its own camera keys separately; both sets of bindings coexist and do different things.

Tutorial: Mouse position deforming mesh vertices

void compose() {
    auto window = MayaFlux::create_window({ "External", 1200, 800 });

    constexpr int   N    = 24;
    constexpr float SIZE = 2.0f;
    constexpr float STEP = SIZE / static_cast<float>(N - 1);

    std::vector<uint32_t> indices;
    for (int row = 0; row < N - 1; ++row)
        for (int col = 0; col < N - 1; ++col) {
            uint32_t tl = row * N + col;
            indices.insert(indices.end(), { tl, tl+N, tl+1, tl+1, tl+N, tl+N+1 });
        }

    std::vector<MeshVertex> verts(N * N);
    for (int row = 0; row < N; ++row)
        for (int col = 0; col < N; ++col) {
            auto& v   = verts[row * N + col];
            v.position = { col * STEP - SIZE * 0.5f, 0.f, row * STEP - SIZE * 0.5f };
            v.color    = { 0.3f, 0.6f, 0.9f };
            v.normal   = { 0.f, 1.f, 0.f };
            v.tangent  = { 1.f, 0.f, 0.f };
            v.uv       = { float(col)/(N-1), float(row)/(N-1) };
            v.weight   = 0.f;
        }

    auto mesh = vega.MeshWriterNode(N * N) | Graphics;
    mesh->set_mesh(verts, indices);

    auto buf = vega.GeometryBuffer(mesh) | Graphics;
    buf->setup_rendering({ .target_window = window });
    buf->get_render_processor()->set_view_transform(
        Kinesis::look_at_perspective(
            {0.f, 3.f, 3.f}, {0.f, 0.f, 0.f},
            glm::radians(50.f), 1200.f/800.f, 0.1f, 100.f));
    window->show();

    auto mouse_x = std::make_shared<std::atomic<float>>(0.f);
    auto mouse_y = std::make_shared<std::atomic<float>>(0.f);

    MayaFlux::on_mouse_move(window, [mouse_x, mouse_y, window](double x, double y) {
        const auto& ws = window->get_state();
        mouse_x->store(float(x / ws.current_width)  * 2.f - 1.f, std::memory_order_relaxed);
        mouse_y->store(float(y / ws.current_height) * 2.f - 1.f, std::memory_order_relaxed);
    });

    MayaFlux::schedule_metro(1.0 / 60.0, [mesh, verts, mouse_x, mouse_y]() mutable {
        const float mx = mouse_x->load(std::memory_order_relaxed);
        const float my = mouse_y->load(std::memory_order_relaxed);
        for (auto& v : verts) {
            const float dx = v.position.x - mx;
            const float dz = v.position.z - my;
            const float y  = std::exp(-(dx*dx + dz*dz) * 2.5f) * 0.6f;
            v.position.y   = y;
            v.weight       = y / 0.6f;
            v.color        = glm::mix(
                glm::vec3(0.1f, 0.3f, 0.8f),
                glm::vec3(0.9f, 0.7f, 0.2f),
                v.weight);
        }
        mesh->set_mesh_vertices(verts);
    });
}

Run this. A flat grid deforms under a Gaussian hill centered on the mouse cursor. Move the mouse and the hill follows, coloring from blue to gold at the peak. Change 2.5f in the exponent to 8.0f for a sharper, narrower peak. Change 0.6f for a taller hill.

Tutorial: OSC driving mesh slot transforms

OSC example with optional internal sender for testing without hardware

// Not needed if you have an actual OSC hardware sending messages to port 9000
void osc_sender() {
    auto sink = make_persistent_shared<Portal::Network::NetworkSink>(
        Portal::Network::StreamConfig {
            .name      = "osc_loopback",
            .endpoint  = { .address = "127.0.0.1", .port = 9000 },
            .profile   = Portal::Network::StreamProfile::REALTIME_SMALL,
            .transport = Portal::Network::NetworkTransportHint::UDP,
        });
    float t = 0.f;
    schedule_metro(0.05, [&t, sink]() {
        if (!sink->is_open()) return;
        float rotate_val = (std::sin(t * 0.4f)  + 1.f) * 0.5f;
        float scale_val  = (std::sin(t * 0.17f) + 1.f) * 0.5f;
        auto r = Portal::Network::serialize_osc("/rotate", {{ rotate_val }});
        auto s = Portal::Network::serialize_osc("/scale",  {{ scale_val  }});
        sink->send({ r.data(), r.size() });
        sink->send({ s.data(), s.size() });
        t += 0.05f;
    }, "osc_loopback_sender");
}

void settings() {
    auto& cfg       = MayaFlux::Config::get_global_stream_info();
    cfg.osc.enabled = true;
    cfg.osc.port    = 9000;
}

void compose() {
    osc_sender(); // ONLY NEEDED if you do not want to use an actual OSC hardware

    auto window = MayaFlux::create_window({ "External", 1200, 800 });

    auto make_tetra = [](glm::vec3 color)
        -> std::pair<std::vector<MeshVertex>, std::vector<uint32_t>>
    {
        std::vector<MeshVertex> v = {
            {{ 0.f,  0.6f, 0.f }, color, 1.f, {0.5f,1.f}, { 0, 1, 0}, {1,0,0}},
            {{-0.5f,-0.3f,-0.5f}, color, 1.f, {0.f, 0.f}, { 0,-1, 0}, {1,0,0}},
            {{ 0.5f,-0.3f,-0.5f}, color, 1.f, {1.f, 0.f}, { 0,-1, 0}, {1,0,0}},
            {{ 0.f, -0.3f, 0.5f}, color, 1.f, {0.5f,0.f}, { 0,-1, 0}, {1,0,0}},
        };
        return { v, { 0,1,2, 0,2,3, 0,3,1, 1,3,2 } };
    };

    auto net = vega.MeshNetwork() | Graphics;

    for (auto [color, tx] : std::array{
             std::pair{ glm::vec3{0.8f,0.3f,0.2f}, -0.8f },
             std::pair{ glm::vec3{0.2f,0.5f,0.9f},  0.8f } })
    {
        auto [verts, indices] = make_tetra(color);
        auto node = std::make_shared<Nodes::GpuSync::MeshWriterNode>(4);
        node->set_mesh(verts, indices);
        net->add_slot("", node);
        net->get_slot(net->slot_count() - 1).local_transform =
            glm::translate(glm::mat4(1.f), {tx, 0.f, 0.f});
    }

    auto buf = vega.MeshNetworkBuffer(net) | Graphics;
    buf->setup_rendering({ .target_window = window });
    buf->get_render_processor()->set_view_transform(
        Kinesis::look_at_perspective(
            {0.f, 2.f, 4.f}, {0.f, 0.f, 0.f},
            glm::radians(45.f), 1200.f/800.f, 0.1f, 100.f));
    window->show();

    auto rotate_ctrl = vega.read_osc(
        OSCConfig::normalized(0.0, 1.0),
        Core::InputBinding::osc("/rotate"));

    auto scale_ctrl = vega.read_osc(
        OSCConfig::normalized(0.0, 1.0),
        Core::InputBinding::osc("/scale"));

    float angle = 0.f;
    MayaFlux::schedule_metro(1.0 / 60.0, [net, rotate_ctrl, scale_ctrl, &angle]() {
        const float speed = static_cast<float>(rotate_ctrl->get_last_output());
        const float scale = 0.4f + static_cast<float>(scale_ctrl->get_last_output()) * 1.2f;
        angle += speed * 0.08f;

        auto& slot_a = net->get_slot(0);
        slot_a.local_transform =
            glm::translate(glm::mat4(1.f), {-0.8f, 0.f, 0.f}) *
            glm::rotate(glm::mat4(1.f), angle, {0.f, 1.f, 0.f});
        slot_a.dirty = true;

        auto& slot_b = net->get_slot(1);
        slot_b.local_transform =
            glm::translate(glm::mat4(1.f), {0.8f, 0.f, 0.f}) *
            glm::scale(glm::mat4(1.f), glm::vec3(scale));
        slot_b.dirty = true;
    });
}

Run this. The left tetrahedron spins at a rate driven by /rotate, the right one breathes in scale driven by /scale. The loopback sender in osc_sender() generates both values internally so no external client is needed. Replace it with any OSC sender targeting port 9000 and the same addresses to control it from a phone, another program, or hardware.

read_osc registers the node internally before returning. No | Audio pipe is needed. get_last_output() on the graphics metro thread is a lock-free read of an atomic written by the OSC receive thread.


Key events, mouse movement, and OSC messages all arrive on threads that have nothing to do with the audio scheduler or the graphics frame loop. The pattern for all three is the same: the external event writes to an atomic or an InputNode internal value, and the scheduler or metro reads it on its own cadence. The two sides never share a lock.

The only thing that changes between input sources is the binding call. Once the value is in an atomic or a node, everything downstream is identical.

Expansion 1: Event domain vs scheduler domain

on_key_pressed, on_mouse_move, and on_mouse_pressed create Vruta::Event coroutines managed by EventManager, not by TaskScheduler. They run on the windowing thread when GLFW delivers events. The TaskScheduler runs on the audio thread. These are different execution contexts with no shared lock.

Atomics are the correct bridge: write in the event coroutine, read in the scheduler task. Never call mesh mutation methods directly from event callbacks. set_mesh_vertices is not thread-safe against the graphics processor uploading the same buffer. Write to atomics in the event callback and apply the mutation in the metro.

Expansion 2: The held-state accumulator pattern

The keyboard tutorial does not route key events to velocity directly. It routes them to a boolean flag, and a separate metro reads that flag each frame to accumulate velocity. This split is intentional.

Key events are sparse and irregular: they arrive when the OS delivers them. The metro is regular at 60Hz. If you wrote velocity directly in the key callback, the push rate would be event-rate (unpredictable) rather than frame-rate (uniform). Two keys held for the same duration would push different amounts depending on how many events the OS generated.

The flag-and-accumulate pattern converts asynchronous signal to time-uniform force. The OS delivers events, the events write a state, the regular integrator reads that state on a fixed cadence.

Expansion 3: on_mouse_pressed vs on_mouse_move vs on_key_pressed

on_key_pressed fires once per physical key-down event. It does not repeat. Holding a key does not re-fire it; that is what on_key_released brackets. Use it for one-shot state changes: begin a sequence, toggle a flag, fire a TimedAction.

on_mouse_pressed fires once per button-down, with a position argument. Use it for picking or anchoring: record the cursor position at click time, start measuring drag distance from there.

on_mouse_move fires on every cursor movement event the OS generates. At high mouse sensitivity and low system load this can exceed 1000 events per second. The callback must be cheap. Writing two atomics is cheap. Calling set_mesh_vertices from inside it is not.

All three create Vruta::Event coroutines. None of them block or sleep: they run their callback and yield immediately.

Expansion 4: OSC input is asynchronous then synchronous

The OSC message arrives over UDP on a network thread, gets pushed into a lock-free queue in InputManager, dispatched to the matching OSCNode, and written to a std::atomic<double> inside the node. From that point it behaves like any other node: get_last_output() reads the atomic. The asynchrony is fully contained inside the input infrastructure. The processing graph never sees a thread boundary.

read_osc calls register_input_node internally before returning, so no | Audio pipe is needed. This is different from nodes created via the macro-generated vega.Sine() path, which only constructs and returns without registering.

Expansion 5: MIDI and HID follow the same pattern
// MIDI CC 74 -> filter cutoff
auto filter_ctrl = vega.read_midi(
    MIDIConfig::cc(74, 0.0, 1.0),
    Core::InputBinding::midi_cc(74));

// HID gamepad left stick X axis -> rotation speed
auto stick_x = vega.read_hid(
    HIDConfig::axis(0, -1.0, 1.0),
    Core::InputBinding::hid(0, HIDAxis::LEFT_X));

Both return nodes. get_last_output() reads their current value the same way as an OSC node. The binding call registers with InputManager to route incoming hardware events to the node's internal atomic. The processing graph sees no difference between a MIDI CC value, a gamepad axis, and a Sine oscillator. They are all numbers.

Expansion 6: Gaussian deformation as a general pattern

The mouse example uses a Gaussian: exp(-dist^2 * falloff) * height. The falloff and height parameters are decoupled. Falloff controls how far the influence spreads; height controls its magnitude. Changing falloff from 2.5 to 12.0 produces a sharp, nearly binary on/off at close range.

A sum of multiple Gaussians with different centers, falloffs, and signs produces terrain: positive centers are hills, negative are craters, overlapping terms produce ridges. The cursor position is just one source; OSC coordinates, counter phase, or any computed value works equally.

// Two cursors: one hill, one crater
const float y_hill   =  std::exp(-(dx1*dx1 + dz1*dz1) * 3.0f) * 0.5f;
const float y_crater = -std::exp(-(dx2*dx2 + dz2*dz2) * 5.0f) * 0.3f;
v.position.y = y_hill + y_crater;

Card 6: Creative Variations

Click this card to reveal full explanation

The previous cards all react: to clocks, to conditions, to external events. This card generates. schedule_pattern and line produce values according to computational rules, not in response to anything. Everything downstream reads from them.

Tutorial: Pattern as a generative sequencer

void compose() {
    auto window = MayaFlux::create_window({ "Time", 1400, 900 });
    window->show();

    // Kick: pitched envelope on a sine
    auto kick_env   = vega.Phasor(0.0f) | Audio[0];
    auto kick_shape = vega.Polynomial([](double x) {
        return std::exp(-x * 18.0);
    }) | Audio[0];
    kick_shape->set_input_node(kick_env);
    auto kick = vega.Sine(55.0f) | Audio[0];
    kick->set_amplitude_modulator(kick_shape);

    // Snare: filtered noise with exponential decay
    auto noise = vega.Random();
    noise->set_amplitude(0.08);
    std::vector<double> b = { 0.0675, 0.0, -0.0675 };
    std::vector<double> a = { 1.0, -1.1430, 0.4128 };
    auto snare_iir = vega.IIR(noise, b, a);
    snare_iir->set_gain(0.15);
    auto snare_shape = vega.Polynomial([](double x) { return std::exp(-x * 30.0); });
    snare_shape->set_input_node(snare_iir);
    auto snare_env = vega.Phasor(0.0f) | Audio[1];
    snare_env->set_amplitude_modulator(snare_shape);
    snare_env->set_frequency_modulator(snare_shape);

    // Hat: high-frequency sine, very short decay
    auto hat_env   = vega.Phasor(0.0f) | Audio[0];
    auto hat_shape = vega.Polynomial([](double x) { return std::exp(-x * 60.0); }) | Audio[0];
    hat_shape->set_input_node(hat_env);
    auto hat = vega.Sine(6000.0f) | Audio[0];
    hat->set_amplitude_modulator(hat_shape);

    // Visual: three geometry layers in one composite buffer
    auto hits       = vega.PointCollectionNode() | Graphics;
    auto connector  = vega.PathGeneratorNode(Kinesis::InterpolationMode::CATMULL_ROM, 16, 64) | Graphics;
    auto sweep_mesh = vega.MeshWriterNode(64) | Graphics;

    {
        std::vector<MeshVertex> v;
        std::vector<uint32_t> idx;
        const int SEGS = 16;
        v.push_back({ {0.f,0.f,0.f}, {0.5f,0.5f,1.f}, 1.f, {}, {0,0,1}, {1,0,0} });
        for (int i = 0; i <= SEGS; ++i) {
            float a = float(i) / SEGS * 2.f * M_PI;
            v.push_back({ {0.35f*std::cos(a), 0.35f*std::sin(a), 0.f},
                          {0.2f,0.6f,0.9f}, 0.f, {}, {0,0,1}, {1,0,0} });
            if (i < SEGS)
                idx.insert(idx.end(), { 0u, uint32_t(i+1), uint32_t(i+2) });
        }
        sweep_mesh->set_mesh(v, idx);
    }

    auto composite = vega.CompositeGeometryBuffer() | Graphics;
    composite->add_geometry("hits",  hits,
        Portal::Graphics::PrimitiveTopology::POINT_LIST,    window);
    composite->add_geometry("path",  connector,
        Portal::Graphics::PrimitiveTopology::LINE_STRIP,    window);
    composite->add_geometry("sweep", sweep_mesh,
        Portal::Graphics::PrimitiveTopology::TRIANGLE_LIST, window);

    struct StepData {
        bool kick, snare, hat;
        float x, y;
        glm::vec3 color;
    };

    float orbit_angle = 0.f;

    MayaFlux::schedule_pattern(
        [](uint64_t step) -> std::any {
            const uint64_t s = step % 8;
            StepData d;
            d.kick  = (s == 0 || s == 3 || s == 6);
            d.snare = (s == 2 || s == 6);
            d.hat   = (s % 2 == 1);
            const float angle = float(step) * 0.37f;
            const float r = 0.5f + 0.2f * std::sin(float(step) * 0.13f);
            d.x = r * std::cos(angle);
            d.y = r * std::sin(angle);
            d.color = {
                std::abs(std::sin(float(step) * 0.11f)),
                std::abs(std::sin(float(step) * 0.07f + 1.f)),
                std::abs(std::sin(float(step) * 0.05f + 2.f)),
            };
            return d;
        },
        [kick_env, snare_env, hat_env,
         hits, connector, sweep_mesh, &orbit_angle](std::any val) {
            const auto& d = std::any_cast<const StepData&>(val);

            if (d.kick)  kick_env->reset();
            if (d.snare) snare_env->reset();
            if (d.hat)   hat_env->reset();

            hits->add_point({ glm::vec3(d.x, d.y, 0.f), d.color, 8.f });
            connector->add_control_point({ glm::vec3(d.x, d.y, 0.f), d.color, 1.5f });

            orbit_angle += d.kick ? 0.4f : 0.08f;
            if (d.kick) {
                auto verts = sweep_mesh->get_mesh_vertices();
                verts[0].color = d.color;
                sweep_mesh->set_mesh_vertices(verts);
            }
        },
        0.125
    );
}

Run this. You hear a Euclidean rhythm: kick on beats 0, 3, 6 of 8; snare on 2 and 6; hat on odd steps. Simultaneously, colored points accumulate at positions computed from the same step index, a Catmull-Rom path connects them, and a triangle fan rotates faster on kick steps. The rhythm and the visual share one source: the pattern function.

Change the step period from 0.125 to 0.0833. Both audio and visual accelerate together. Change % 8 to % 12. The rhythm changes shape and so does the visual orbit. The function is the single point of compositional control.

Tutorial: line as a parametric parameter

void compose() {
    auto window = MayaFlux::create_window({ "Time", 1200, 800 });

    auto points = vega.PointCollectionNode() | Graphics;
    auto buf    = vega.GeometryBuffer(points) | Graphics;
    buf->setup_rendering({ .target_window = window });
    window->show();

    auto phasor = vega.Phasor(0.0f) | Audio[0];
    auto env    = vega.Polynomial([](double x) { return std::exp(-x * 20.0); }) | Audio[0];
    env->set_input_node(phasor);
    auto drum   = vega.Sine(110.0f) | Audio[0];
    drum->set_amplitude_modulator(env);

    // Threshold rises from 0.0 to 1.0 over 8 seconds
    MayaFlux::schedule_task("reveal",
        MayaFlux::create_line(0.0f, 1.0f, 8.0f, 128, true));

    MayaFlux::schedule_pattern(
        [](uint64_t step) -> std::any {
            auto is_prime = [](uint64_t n) {
                if (n < 2) return false;
                for (uint64_t i = 2; i * i <= n; ++i)
                    if (n % i == 0) return false;
                return true;
            };
            const float density = is_prime(step % 32) ? 0.9f : 0.3f;
            const float angle   = float(step) * 0.41f;
            const float r       = 0.4f + 0.35f * std::abs(std::sin(float(step) * 0.17f));
            return std::make_tuple(density, r * std::cos(angle), r * std::sin(angle));
        },
        [points, phasor](std::any val) {
            auto [density, x, y] =
                std::any_cast<std::tuple<float, float, float>>(val);

            phasor->reset();

            float* threshold = MayaFlux::get_line_value("reveal");
            if (threshold && density > *threshold) {
                points->add_point({
                    glm::vec3(x, y, 0.f),
                    glm::vec3(density, 1.f - density, 0.3f),
                    6.f + density * 8.f
                });
            }
        },
        0.1
    );
}

Run this. You hear a steady rhythm. The window starts empty. Over eight seconds, points appear progressively: first only the sparse non-prime steps (low density, revealed early), then gradually the prime steps emerge (high density, revealed later). Audio and visual were always computing the same thing; only the threshold determined visibility.

The line is not an event. It does not call anything. get_line_value("reveal") is a pointer into the coroutine frame advancing on each step. No push, no subscription, no coordination. The parametric value is ambient state.

schedule_pattern separates the question of what a step produces from the question of what happens when it fires. The generator function is pure: it takes a step index and returns a value. The callback is the side effect: it reads that value and acts on it. Because the generator has no side effects, it can be reasoned about in isolation, tested, or replaced without touching the callback.

line is orthogonal to all of this: it is not a callback mechanism, not a signal, not a hook. It is a continuously-advancing scalar that ambient code can poll. Anything that can call get_line_value can read it, from any thread that the scheduler owns.

Expansion 1: std::any return type

The pattern function returns std::any, which accepts any copyable type: a plain float, a struct, a std::tuple, a std::vector. The callback receives the same std::any and casts it with std::any_cast. The cast must match the returned type exactly; a mismatch throws at runtime.

The idiomatic form is to define a local struct for step data, return it, and cast with std::any_cast<const YourStruct&>(val). The reference cast avoids a copy. For structs that both the generator lambda and the callback need to name, define at file scope rather than inside compose().

For simple cases, std::tuple works without defining a struct:

// Generator
return std::make_tuple(frequency, amplitude, duration);

// Callback
auto [freq, amp, dur] = std::any_cast<std::tuple<float,float,float>>(val);
Expansion 2: Step index as the only state

The generator receives a uint64_t that increments by one on each step and never resets. All sequence structure must be derived from this single value. Periodicity comes from modulo: step % 8 gives an 8-step cycle. Variation over longer timescales comes from secondary modulo or from direct arithmetic on the raw index.

This has a useful property: the sequence is deterministic and replayable. Given any step index, you can compute exactly what that step produced without running all previous steps. There is no hidden state that drifts over time.

Layering two modulos produces a phrase structure: steps with a large-period cycle can change the mode or register while the small-period cycle handles beat content.

[](uint64_t step) -> std::any {
    const uint64_t beat   = step % 8;   // 8-step rhythm cycle
    const uint64_t phrase = step % 32;  // 32-step phrase cycle

    float root_hz = (phrase < 16) ? 220.f : 330.f; // phrase changes the root
    bool on_beat  = (beat == 0 || beat == 4);
    // ...
}
Expansion 3: line is a pointer into a coroutine frame

get_line_value("reveal") returns float*, pointing directly into the coroutine frame of the named line task. The coroutine writes to this float on each scheduler step; the callback reads it with a pointer dereference. There is no synchronisation cost because both the coroutine and the metro callback run on the scheduler thread.

If you read get_line_value from a thread outside the scheduler (e.g. an event callback), take a copy rather than holding the pointer across a yield point.

The pointer is valid until the task is cancelled or the scheduler is destroyed. After the line reaches its end value and restartable is false, the coroutine suspends permanently but the frame is not destroyed while the scheduler holds the shared_ptr. The pointer remains valid and readable; it just stops changing.

Expansion 4: CompositeGeometryBuffer draw order and topology

Geometry collections inside a CompositeGeometryBuffer are drawn in insertion order: the first add_geometry call renders first, the last renders on top. Points drawn last appear above lines drawn earlier. For the audio-visual examples here, hits on top of path is the correct order.

Each collection has its own RenderProcessor with independent topology and shader. POINT_LIST, LINE_STRIP, and TRIANGLE_LIST can coexist in the same composite buffer because each processor has its own pipeline. They share one VKBuffer for upload efficiency but issue separate draw calls.

The nodes passed to add_geometry must already be registered via | Graphics before the call. The composite buffer does not own their registration; it only aggregates their draw calls. This means the same node can appear in multiple composite buffers targeting different windows without duplication of upload work.

Expansion 5: Pattern vs metro for generative work

Metro fires a void callback at a fixed interval. It has no concept of sequence position. If you need to know "which step is this" inside a metro callback, you have to maintain that counter yourself, and it becomes part of the callback's captured state.

schedule_pattern externalises that counter. The step index is provided by the infrastructure, not managed by the callback. The separation means the generator can be stateless: given the same index it always returns the same value. Callbacks that modify shared state (adding points, triggering audio) stay in the callback where they belong.

Metro is appropriate when the work is ongoing and uniform and has no positional identity. Pattern is appropriate when each firing is a distinct position in a sequence, even if that sequence is infinite.

Expansion 6: Combining line with pattern for reveal and collapse arcs

A rising line used as a threshold produces a reveal: early steps are sparse, later steps dense. A falling line produces a collapse: all content is initially visible, then progressively filtered out. A line that oscillates (via a restartable ping-pong) produces a breathing density that rises and falls without any explicit control mechanism.

The key insight is that the line and the pattern are not coordinated. The line advances on its own schedule; the pattern fires on its own schedule. The callback is the only point of contact, and it reads the line value at the moment it fires. Any phase relationship between them emerges from the ratio of their rates, not from explicit synchronisation.

// 6-second oscillating threshold, restarts indefinitely
MayaFlux::schedule_task("breath",
    MayaFlux::create_line(0.0f, 1.0f, 6.0f, 256, true));

// Callback polls it at each pattern step
float* thr = MayaFlux::get_line_value("breath");
if (thr && density > *thr) { /* draw */ }

Try It: Multi-dependency composition

Pattern, line, and Logic-as-clock from Card 2 acting on the same CompositeGeometryBuffer simultaneously. Three independent temporal mechanisms, no coordination between them.

void compose() {
    auto window = MayaFlux::create_window({ "Time", 1400, 900 });

    auto hits = vega.PointCollectionNode() | Graphics;
    auto path = vega.PathGeneratorNode(Kinesis::InterpolationMode::CATMULL_ROM, 20, 96) | Graphics;

    auto composite = vega.CompositeGeometryBuffer() | Graphics;
    composite->add_geometry("hits", hits,
        Portal::Graphics::PrimitiveTopology::POINT_LIST,  window);
    composite->add_geometry("path", path,
        Portal::Graphics::PrimitiveTopology::LINE_STRIP,  window);
    window->show();

    auto phasor_a = vega.Phasor(0.0f) | Audio[0];
    auto env_a    = vega.Polynomial([](double x) { return std::exp(-x * 15.0); }) | Audio[0];
    env_a->set_input_node(phasor_a);
    auto tone_a   = vega.Sine(220.0f) | Audio[0];
    tone_a->set_amplitude_modulator(env_a);

    auto phasor_b = vega.Phasor(0.0f) | Audio[1];
    auto env_b    = vega.Polynomial([](double x) { return std::exp(-x * 25.0); }) | Audio[1];
    env_b->set_input_node(phasor_b);
    auto tone_b   = vega.Sine(330.0f) | Audio[1];
    tone_b->set_amplitude_modulator(env_b);

    struct Hit {
        float x, y, size;
        glm::vec3 color;
        bool secondary;
    };

    // Mechanism 1: pattern drives hit positions and audio triggers
    MayaFlux::schedule_pattern(
        [](uint64_t step) -> std::any {
            const uint64_t s = step % 16;
            const bool primary   = (s == 0 || s == 5 || s == 8 || s == 13);
            const bool secondary = (s == 3 || s == 10);
            const float angle    = float(step) * 0.29f;
            const float r        = primary ? 0.6f : secondary ? 0.4f : 0.25f;
            return Hit {
                r * std::cos(angle), r * std::sin(angle),
                primary ? 14.f : secondary ? 9.f : 5.f,
                { std::abs(std::sin(float(step) * 0.09f)),
                  std::abs(std::sin(float(step) * 0.13f + 1.f)),
                  std::abs(std::sin(float(step) * 0.07f + 2.f)) },
                secondary
            };
        },
        [phasor_a, phasor_b, hits](std::any val) {
            const auto& h = std::any_cast<const Hit&>(val);
            hits->add_point({ glm::vec3(h.x, h.y, 0.f), h.color, h.size });
            if (h.secondary) phasor_b->reset();
            else             phasor_a->reset();
        },
        1.0 / 12.0
    );

    // Mechanism 2: line controls path color saturation over 12 seconds
    MayaFlux::schedule_task("saturation",
        MayaFlux::create_line(0.1f, 1.0f, 12.0f, 256, false));

    // Mechanism 3: Logic with sequential criterion gates path writing
    auto lfo  = vega.Sine(0.11f) | Graphics;
    auto gate = std::make_shared<Nodes::Generator::Logic>(
        [](std::span<bool> history) -> bool {
            if (history.size() < 6) return false;
            int alt = 0;
            for (size_t i = 1; i < 6; ++i)
                if (history[i] != history[i-1]) ++alt;
            return alt >= 4;
        }, 6) | Audio[0];
    lfo >> gate;

    gate->while_true([path, lfo](const Nodes::NodeContext&) {
        const float v = float(lfo->get_last_output());
        const float r = 0.5f + 0.3f * v;
        const float a = float(std::chrono::duration<double>(
            std::chrono::steady_clock::now().time_since_epoch()).count()) * 0.8f;

        float* sat = MayaFlux::get_line_value("saturation");
        const float s = sat ? *sat : 0.5f;

        path->add_control_point({ glm::vec3(r * std::cos(a), r * std::sin(a), 0.f),
            glm::vec3(s, 0.4f + 0.4f * v * s, 1.f - s * 0.6f), 1.8f });
    });

    gate->on_change_to(false, [path](const Nodes::NodeContext&) {
        path->clear_path();
    });
}

Run this. Two tones fire at different pitches in a sparse Euclidean pattern. Points accumulate in orbiting positions. When the LFO's recent history satisfies the zigzag criterion, a path grows alongside the points; its color shifts from desaturated toward fully saturated over twelve seconds via the line. When the criterion fails the path clears.

Three mechanisms act on the same scene without coordinating with each other: the pattern determines when and where; the line determines the color envelope; the Logic determines whether path writing is currently happening. None of them knows the others exist.

The composability here is structural, not incidental. Pattern, line, and Logic hooks are all designed to have no awareness of each other. They share targets (the same geometry nodes, the same audio phasors) but share no state and no coordination mechanism. The scene is the product of their independent action.

This is the digital counterpart to the analog notion of independent voices: each mechanism is a voice, and the result is their superposition. The difference is that here, the voices operate on different temporal substrates (scheduler steps, coroutine frames, signal samples) and interact only through the shared values they write into.

Expansion 1: Why the three mechanisms do not need to be aware of each other

The pattern writes hit positions and triggers audio. The line advances a float. The Logic gate opens and closes path writing. Each of these is a unidirectional write into a shared target. None of them reads from the others.

This is only possible because the targets (geometry nodes, phasors) are designed to accept concurrent writes. add_point and add_control_point are safe to call from multiple scheduling contexts because geometry nodes use internal dirty flags and deferred upload rather than immediate GPU mutation. The scheduler thread owns all three mechanisms, so there are no actual concurrent writes here, but the architecture permits it.

Expansion 2: The while_true decimation pattern

The Logic gate runs at audio rate (48000 Hz). while_true fires on every sample where the output is true. add_control_point called 48000 times per second would produce 800 control points per frame against a 60Hz renderer: nearly all redundant.

The example avoids this by using std::chrono::steady_clock to compute an angle that advances smoothly over time rather than per sample. The path grows at the rate meaningful to the visual, not at audio rate. An alternative decimation is a sample counter inside the callback, as used in the Card 2 gate example:

gate->while_true([path, &n](const Nodes::NodeContext&) {
    if (++n % 800 != 0) return; // ~60Hz at 48kHz
    path->add_control_point(/* ... */);
});
Expansion 3: Pattern step rate and visual density

The pattern fires at 1.0 / 12.0 seconds per step, which is 12 steps per second. At this rate, the 16-step cycle completes roughly every 1.3 seconds. Point density on screen is a function of both the rate and the geometry's ring buffer capacity.

Increasing the rate to 1.0 / 24.0 doubles the point density without changing the rhythmic structure. Decreasing it to 0.25 spreads the same pattern over 4 seconds per cycle. The audio and visual both change because both are driven by the same step rate.


Card 7: Declarative Compositions

Every card so far has been imperative. You decide when things run, you hold the state, you write the callbacks. This card takes a different posture. You describe a flow or a spatial relationship and the system runs it. You do not call process(). You do not count cycles. You declare what should happen and step back.

Two systems. BufferPipeline for data flows that span more than one buffer cycle. Fabric for spatial entities whose position is the only thing audio and graphics share.

Pipelines: declared data flows

Click this card to reveal full explanation

Tutorial: Live waveshaper, one cycle in one cycle out

void settings() {
    auto& stream = MayaFlux::Config::get_global_stream_info();
    stream.input.enabled  = true;
    stream.input.channels = 1;
}

void compose() {
    auto mic = MayaFlux::create_input_listener_buffer(0, true);

    auto pipeline = MayaFlux::create_buffer_pipeline();
    pipeline->with_strategy(Kriya::ExecutionStrategy::STREAMING);

    pipeline
        >> BufferOperation::capture_from(mic).for_cycles(1)
        >> BufferOperation::modify_buffer(mic,
            [](const std::shared_ptr<Buffers::AudioBuffer>& buf) {
                for (auto& s : buf->get_data()) {
                    s = std::tanh(s * 6.0) * 0.7;
                }
            }).as_streaming();

    pipeline->execute_buffer_rate();
}

Run this with a microphone connected. You hear your input through heavy tanh saturation. The pipeline captures one buffer cycle from the mic, modifies it in place, and the modified data leaves through the mic buffer's normal routing. No explicit loop, no cycle counter, no callback registered on any node.

Change 6.0 to 1.5 and the saturation softens toward gentle clipping. Change it to 20.0 for hard rectangular clipping. The lambda is the only thing that changes. The pipeline structure stays identical.

Tutorial: Mouse-painted accumulating looper

void settings() {
    auto& stream = MayaFlux::Config::get_global_stream_info();
    stream.input.enabled  = true;
    stream.input.channels = 1;
}

void compose() {
    auto window = MayaFlux::create_window({ "Looper", 800, 600 });
    window->show();

    auto mic = MayaFlux::create_input_listener_buffer(0, true);

    const uint32_t buf_size = MayaFlux::get_buffer_manager()
        ->get_buffer_size(Buffers::ProcessingToken::AUDIO_BACKEND);
    const uint64_t loop_frames = static_cast<uint64_t>(buf_size) * 64;

    auto loop = std::make_shared<Kakshya::DynamicSoundStream>(48000, 1);
    loop->set_auto_resize(false);
    loop->ensure_capacity(loop_frames);

    auto layer = MayaFlux::create_sampler_from_stream(loop, 0);
    layer->play_continuous(0, layer->slice_from_stream());
    store(layer);

    auto& brush_pos   = make_persistent(0.5f);
    auto& brush_width = make_persistent(0.5f);
    auto& wipe        = make_persistent(false);

    MayaFlux::on_mouse_move(window, [&brush_pos, &brush_width, window]
    (double x, double y) {
        const auto ndc = normalize_coords(x, y, window);
        brush_pos = ndc.x;
        brush_width = 0.05f + 0.6f * ndc.y;
    });

    MayaFlux::on_key_pressed(window, IO::Keys::Space, [&wipe]() { wipe = true; });

    auto pipeline = MayaFlux::create_buffer_pipeline();
    pipeline->with_strategy(Kriya::ExecutionStrategy::PHASED)
             .capture_timing(Vruta::DelayContext::BUFFER_BASED);

    pipeline
        >> BufferOperation::capture_from(mic).for_cycles(64)
        >> BufferOperation::dispatch_to(
            [loop, loop_frames, &brush_pos, &brush_width, &wipe]
            (Kakshya::DataVariant& data, uint32_t) {
                const auto& fresh = std::get<std::vector<double>>(data);

                std::vector<double> existing(loop_frames, 0.0);
                loop->get_channel_frames(existing, 0, 0);

                if (wipe) {
                    wipe = false;
                    std::ranges::fill(existing, 0.0);
                }

                const size_t n = std::min<size_t>(fresh.size(), loop_frames);
                std::vector<double> mixed(loop_frames, 0.0);
                for (size_t i = 0; i < loop_frames; ++i) {
                    const double pos   = static_cast<double>(i) / loop_frames;
                    const double d     = std::abs(pos - brush_pos);
                    const double brush = std::exp(-(d * d) / (brush_width * brush_width));

                    const double keep = 0.98 - 0.5 * brush;
                    const double prev = existing[i] * keep;
                    const double live = (i < n) ? fresh[i] * brush : 0.0;
                    mixed[i] = std::tanh(prev + live);
                }

                loop->write_frames(
                    std::span<const double>(mixed.data(), mixed.size()), 0, 0);
            });

    pipeline->execute_buffer_rate();
}

Run this with a microphone and make some sound. An empty window opens. The loop is a strip of time laid left to right across the window, played back continuously underneath you. Wherever the pointer sits, that region of the loop receives the fresh input painted in while the rest decays. Drag across the window and you smear new sound into the part of the loop the cursor passes over, leaving the rest to fade. Move down for a wider brush, up for a narrow one. Space wipes the loop.

Stop moving and the loop stabilises into whatever you last painted, repeating. Keep painting the same spot and that region thickens toward saturation while the rest thins out. The texture is the record of where your cursor has been.

A BufferPipeline is a chain of operations joined by >> that runs on the scheduler. You build the chain once and call execute_buffer_rate. From that point the coroutine infrastructure handles every cycle. The two examples differ in one decision: the execution strategy.

STREAMING sends each captured cycle straight through the operations that follow it. Latency is one buffer cycle. It is the right choice for live effects where the processed output should appear the moment the input arrives. The waveshaper is exactly this: capture one cycle, modify it in place, done.

PHASED accumulates across all the for_cycles(N) iterations before any processing operation runs. The looper captures 64 buffer cycles into one block before the dispatch sees it. This is the strategy for anything that needs a span of time at once rather than one cycle: accumulation, reversal, block analysis, anything where the operation needs context beyond a single buffer.

Expansion 1: Why the looper cannot be a node

A node processes one sample at a time. It has no access to a block of past samples unless it maintains its own ring buffer internally. The looper needs the whole 64-cycle block in hand to paint a brush across it: the brush at the cursor position touches samples thousands of indices apart, and the falloff is computed across the entire block at once.

PHASED gives you that block as a single std::vector<double>. The dispatch reads the whole loop, mixes the fresh input into the brushed region, applies the decay everywhere, and writes the whole thing back. There is no per-sample formulation of this that produces the same result, because the decay and the brush both depend on a sample's position within the block, not on its value.

Expansion 2: The loop is a stream, the playback reads it, they never coordinate

loop is a DynamicSoundStream sized once via ensure_capacity. A second sampler, layer, plays it on a continuous loop. The pipeline writes new contents into the stream every 64 cycles. The playback reads whatever is currently there on its next pass.

Nothing connects the two. The pipeline does not tell the sampler anything changed. The sampler does not ask the pipeline for data. The stream is the only shared state, and the write and the read meet there. This is the same decoupling the whole card is about: declare two flows against a shared target and let them run at their own rates.

Expansion 3: dispatch_to versus modify_buffer versus transform

modify_buffer attaches a processor to a buffer and mutates it in place during that buffer's normal processing. It is the streaming path: the waveshaper uses it because the modification belongs to the live signal as it flows through.

dispatch_to hands the accumulated data to a handler that does whatever it likes with it and produces no routed output of its own. The looper uses it because the endpoint is a write into a stream, not a value flowing to a next operation. The handler is a sink.

transform sits between them: it takes the data, returns a new DataVariant, and that result flows to the next operation in the chain, typically a route_to_buffer or route_to_container. Use it when the processed block needs to continue down the pipeline rather than terminate in a side effect.

Expansion 4: make_persistent and the painting controls

brush_pos, brush_width, and wipe are declared with make_persistent because the pipeline dispatch and the mouse callbacks both outlive compose(). The function returns immediately after building the chain and registering the events, destroying all stack variables. make_persistent allocates them with process lifetime and hands back references both sides can hold.

The mouse callback writes the cursor's normalized X into brush_pos and maps the Y to a brush width. The dispatch reads both each pass. The window does nothing visual here; it is purely a surface for the pointer to move across, a control space rather than a display. Card 5 covered this pattern: external events write a value, the processing side reads it on its own cadence.

Expansion 5: What for_cycles means in each strategy

On a capture operation, for_cycles(N) means the capture executes N times per pipeline cycle. Under STREAMING with for_cycles(1) that is one capture flowing straight through. Under PHASED with for_cycles(64) the capture runs 64 times and the results concatenate into one block before the dispatch runs once on the whole thing.

Raising the looper's count from 64 lengthens the loop, lowering it shortens it. At 64 cycles and a typical buffer size the loop is on the order of a second. The brush, the decay, and the playback all scale with it automatically because they are expressed as fractions of the block, not as absolute sample counts.

Fabric: one gesture for sound and light

Click this card to reveal full explanation

Tutorial: Fabric, one gesture for sound and light

Vruta::GraphicsRoutine weave_mode(
    Vruta::TaskScheduler&,
    std::shared_ptr<Nodes::GpuSync::PathGeneratorNode> path,
    std::shared_ptr<Nodes::Network::ModalNetwork> modal,
    size_t mode_index,
    glm::vec3 color) {
    auto& p = co_await Kriya::GetGraphicsPromise{};
    glm::vec3 pos(0.0f);

    while (!p.should_terminate) {
        const auto& modes = modal->get_modes();
        if (mode_index < modes.size()) {
            const float a = static_cast<float>(modes[mode_index].amplitude);
            const float angle = static_cast<float>(mode_index) / modes.size()
                * glm::two_pi<float>();
            const glm::vec3 target(
                std::cos(angle) * (0.3f + a * 2.5f),
                std::sin(angle) * (0.3f + a * 2.5f),
                0.0f);
            pos = glm::mix(pos, target, 0.05f);
            path->add_control_point({ pos, color, 1.0f + a * 4.0f });
        }
        co_await Kriya::FrameDelay { .frames_to_wait = 2 };
    }
}

void compose() {
    auto window = MayaFlux::create_window({ "Gesture", 1200, 800 });
    window->show();

    auto modal = vega.ModalNetwork(8, 110.0,
        Nodes::Network::ModalNetwork::Spectrum::INHARMONIC, 8.0) | Audio[{ 0, 1 }];
    modal->set_coupling_enabled(true);

    auto composite = vega.CompositeGeometryBuffer() | Graphics;

    std::vector<std::shared_ptr<Nodes::GpuSync::PathGeneratorNode>> paths;
    for (size_t i = 0; i < 8; ++i) {
        const glm::vec3 color = glm::mix(
            glm::vec3(0.2f, 0.4f, 0.9f),
            glm::vec3(0.9f, 0.3f, 0.2f),
            static_cast<float>(i) / 7.0f);

        auto path = vega.PathGeneratorNode(
            Kinesis::InterpolationMode::CATMULL_ROM, 4, 256) | Graphics;
        path->add_control_point({ glm::vec3(0.0f), color, 1.0f });
        paths.push_back(path);

        composite->add_geometry("m" + std::to_string(i), path,
            Portal::Graphics::PrimitiveTopology::LINE_STRIP,
            { .target_window   = window,
              .vertex_shader   = "line_lit.vert",
              .fragment_shader = "line_lit.frag",
              .geometry_shader = "line_lit.geom" });
    }

    auto& sched = *MayaFlux::get_scheduler();
    auto& evmgr = *MayaFlux::get_event_manager();
    auto fabric = std::make_shared<Nexus::Fabric>(sched, evmgr);

    for (size_t i = 0; i < 8; ++i) {
        auto driver = std::make_shared<Nexus::Emitter>(
            [](const Nexus::InfluenceContext&) {});
        fabric->wire(driver)
            .use([paths, modal, i](Vruta::TaskScheduler& s) -> Vruta::GraphicsRoutine {
                const glm::vec3 c = glm::mix(
                    glm::vec3(0.2f, 0.4f, 0.9f),
                    glm::vec3(0.9f, 0.3f, 0.2f),
                    static_cast<float>(i) / 7.0f);
                return weave_mode(s, paths[i], modal, i, c);
            })
            .finalise();
    }

    auto cursor = std::make_shared<Nexus::Emitter>(
        [modal](const Nexus::InfluenceContext& ctx) {
            const float t = (ctx.position.x + 1.0f) * 0.5f;
            const float strength = (ctx.position.y + 1.0f) * 0.5f;
            modal->excite_at_position(t, strength * 0.6f);
        });

    cursor->set_position(glm::vec3(0.0f));
    cursor->set_color(glm::vec3(1.0f, 0.9f, 0.7f));
    cursor->set_radius(1.5f);
    for (const auto& proc : composite->get_render_processors()) {
        cursor->set_influence_target(proc);
    }

    fabric->wire(cursor).every(1.0 / 60.0).finalise();

    MayaFlux::on_mouse_move(window, [cursor, window](double x, double y) {
        const auto& ws = window->get_state();
        cursor->set_position(glm::vec3(
            static_cast<float>(x / ws.current_width) * 2.0f - 1.0f,
            1.0f - static_cast<float>(y / ws.current_height) * 2.0f,
            0.0f));
    });

    auto commit_loop = [](Vruta::TaskScheduler&,
                          std::shared_ptr<Nexus::Fabric> fab) -> Vruta::GraphicsRoutine {
        auto& p = co_await Kriya::GetGraphicsPromise {};
        while (!p.should_terminate) {
            fab->commit();
            co_await Kriya::FrameDelay { .frames_to_wait = 1 };
        }
    };

    MayaFlux::schedule_task("gesture_commit",
        commit_loop(sched, fabric), false);
}

Run this and move the mouse across the window. You strike a resonant body. Where the cursor sits decides which modes ring and how hard: horizontal position sweeps which part of the body is struck, vertical position sets how hard. The eight curves are woven from the network's own mode amplitudes, so the louder a mode rings the further its curve reaches out. And the geometry lights up around the cursor, because the same position that strikes the sound also drives the shader.

One Emitter does all of this. Its position is the only input. From that single position the sound is struck, the curves are shaped by what rings, and the light falls where the strike lands. Move the cursor and all three move together because they are the same number.

Expansion 6: What an Emitter actually is

An Emitter is a point in space with an influence function. It is not a thing you draw. When the Fabric commits, it reads the Emitter's position and calls the influence function with that position in an InfluenceContext. The cursor Emitter's function takes ctx.position and calls excite_at_position on the modal network. That is the entire audio side: position in, excitation out.

The position is not a convenience. It is the shared variable. The audio reads it as a strike location. The shader reads it as a light position. The curves read the consequence of the strike. Nothing else connects these three domains. In an analog setup they would be three separate signals you route by hand. Here they are one number, read three ways.

Expansion 7: set_influence_target and the shader UBO

cursor->set_influence_target(proc) creates a uniform buffer matching the Influence block the line_lit shaders declare at set = 1, binding = 0, registers it on the render processor, and binds it. On every commit, the Emitter's position, color, intensity, and radius are packed into that buffer automatically. No descriptor wiring appears in user code.

The fragment shader reads position from that block and lights each fragment by its distance from it. So the cursor's world position, written once per commit, becomes the lit point on screen with no glue between the Emitter and the shader beyond this one call. The loop over get_render_processors() binds the same Emitter to all eight curve processors, so every curve is lit by the same gesture.

Expansion 8: weave_mode draws the sound

Each of the eight curves is driven by a GraphicsRoutine that reads one mode's amplitude every other frame and pushes a control point. The point's distance from center and its thickness both scale with that amplitude. When you strike the body and a mode rings, its curve reaches outward and thickens, then retracts as the mode decays.

The routine is wired through a silent Emitter via .use(...), which is how Fabric attaches an arbitrary graphics coroutine to an entity's lifecycle. The Emitter here carries no influence of its own; it exists so the Fabric owns and cancels the coroutine. The curves are not told what the sound is doing. They read the network's mode state directly. The image is a reading of the same physical model the cursor is exciting.

Expansion 9: Why commit runs on a GraphicsRoutine

fabric->commit() reads positions, publishes the spatial snapshot, and fires every wired entity, including the influence-target UBO upload, which touches GPU resources. GPU work belongs on the graphics thread. So commit is driven by a GraphicsRoutine that resumes once per frame via FrameDelay{1}, scheduled with schedule_task, not by an audio-rate metro.

This is the same thread boundary Card 5 described from the other side. There, external events on the windowing thread wrote values the audio thread read. Here, a graphics-thread coroutine drives the commit so the UBO upload and any render-sink dispatch happen where the GPU work is safe. The cursor Emitter's audio side, the modal excitation, runs through that same commit but only touches network amplitudes, which is cheap and safe to set from there.

Expansion 10: Declaring against a shared target, in both halves of the card

The two systems look different but share a shape. In the looper, two flows, the pipeline and the playback sampler, meet at one stream and never coordinate. In the Fabric example, three flows, the audio excitation, the curve weaving, and the shader lighting, meet at one position and never coordinate.

Neither half has a controller orchestrating the parts. You declare each flow against the shared thing, a stream or a position, and the result is their superposition. This is the digital counterpart to independent voices in an ensemble, except the voices here run on different substrates, the buffer scheduler and the frame clock, and interact only through the value they read and write in common.