Temporal Plurality

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.

The Floor of the System

Every scheduling primitive in the previous cards is a coroutine underneath: metro, Timer, EventChain, pattern, line, Trigger, Gate, the pipeline's internal loop.
This card shows what those look like written directly.
A routine has one advantage no callback has: its body can hold as many waits as it likes. A whole phrase becomes straight-line code.

Tutorial: Raw Routines

Four Routines, Four Ways of Asking "When"

MayaFlux has one coroutine model with four routine types. They differ in what resumes them:

  • SoundRoutine: resumed on the sample clock, on the audio thread. Suspends with SampleDelay or BufferDelay.
  • GraphicsRoutine: resumed on the frame clock, on the graphics thread. Suspends with FrameDelay.
  • CrossRoutine: resumed only when every clock it armed has reached its target. Suspends with MultiRateDelay.
  • FreeRoutine: no clock at all. Resumed when a predicate you supply returns true. Suspends with ConditionAwaiter.

Everything scheduled in the earlier cards is one of these with a loop around it. Each card below writes one directly, and each body waits many times, on different durations, in one function.

A Phrase in One Function (SoundRoutine)

Click this card to reveal full explanation

A SoundRoutine is a C++ coroutine function with return type Vruta::SoundRoutine. co_await GetAudioPromise{} gives you a reference to the promise that lives in the coroutine frame. co_await SampleDelay{N} suspends for exactly N samples and the body carries on from the next line.

The body below is a whole phrase. A steady pulse, a three-note figure with uneven gaps, a fill whose gaps shrink by a fixed ratio, then a rest. Each note is a wait for the gate, then a wait for the remainder. Eight bars on screen jump when their note sounds.


void set_bar(const std::shared_ptr<MeshNetwork>& net, size_t i, float h)
{
    const float x = -0.84f + 0.24f * static_cast<float>(i);
    auto& slot = net->slots()[i];
    slot.local_transform = glm::translate(glm::mat4(1.f), glm::vec3(x, -1.f + h, 0.f)) 
                                         * glm::scale(glm::mat4(1.f), 
                                         glm::vec3(1.f, h, 1.f));
    slot.dirty = true;
}

Vruta::SoundRoutine phrase(
    std::shared_ptr<MeshNetwork> bars,
    std::function<void()> play_kick,
    std::function<void()> play_hat)
{
    auto& promise = co_await GetAudioPromise {};
    promise.set_state("tempo", 1.0f);
    promise.set_state("bar", uint32_t { 0 });

    auto wait = [&](double beats) {
        const float tempo = *promise.get_state<float>("tempo");
        return SampleDelay { seconds_to_samples(0.5 * beats / tempo) };
    };
    auto on = [&](size_t i, const std::function<void()>& play) {
        play();
        set_bar(bars, i, 0.5f);
    };
    auto off = [&](size_t i) { set_bar(bars, i, 0.05f); };

    const double figure[] = { 0.75, 0.75, 0.5 };

    while (true) {
        if (promise.should_terminate)
            break;

        for (size_t i = 0; i < 4; ++i) {
            on(i, play_kick);
            co_await wait(0.15);
            off(i);
            co_await wait(0.85);
        }

        for (size_t i = 0; i < 3; ++i) {
            on(4 + i, play_hat);
            co_await wait(0.1);
            off(4 + i);
            co_await wait(figure[i] - 0.1);
        }

        double gap = 0.5;
        for (size_t i = 0; i < 8; ++i) {
            if (i % 2 == 0)
                on(i, play_kick);
            else
                on(i, play_hat);
            co_await wait(gap * 0.4);
            off(i);
            co_await wait(gap * 0.6);
            gap *= 0.78;
        }

        co_await wait(2.0);

        *promise.get_state<uint32_t>("bar") += 1;
    }
}

void compose()
{
    auto window = create_window({ "Phrase", 1200, 800 });

    auto bars = vega.MeshNetwork() | Graphics;

    const std::vector<uint32_t> quad = { 0, 1, 2, 2, 3, 0 };
    for (size_t i = 0; i < 8; ++i) {
        const glm::vec3 c = glm::mix(
            glm::vec3(.2f, .5f, .9f), glm::vec3(.9f, .4f, .2f),
            static_cast<float>(i) / 7.f);
        const float w = 0.08f;
        std::vector<MeshVertex> v = {
            { { -w, -1.f, 0.f }, c, 1.f, {}, { 0, 0, 1 }, { 1, 0, 0 } },
            { { w, -1.f, 0.f }, c, 1.f, {}, { 0, 0, 1 }, { 1, 0, 0 } },
            { { w, 1.f, 0.f }, c, 1.f, {}, { 0, 0, 1 }, { 1, 0, 0 } },
            { { -w, 1.f, 0.f }, c, 1.f, {}, { 0, 0, 1 }, { 1, 0, 0 } },
        };
        auto node = std::make_shared<MeshWriterNode>(4);
        node->set_mesh(v, quad);
        bars->add_slot("bar" + std::to_string(i), node);
    }
    for (size_t i = 0; i < 8; ++i) {
        set_bar(bars, i, 0.05f);
    }

    auto buf = vega.MeshNetworkBuffer(bars) | Graphics;
    buf->setup_rendering({ .target_window = window });
    window->show();

    auto kick = create_sampler("path/to/kick.wav");
    kick->load(0, kick->slice_from_stream());
    auto hat = create_sampler("path/to/hat.wav");
    hat->load(0, hat->slice_from_stream());

    schedule_task("phrase",
        phrase(bars, [kick] { kick->play(0); }, [hat] { hat->play(0); }));

    on_key_pressed(window, IO::Keys::Space, []() {
        if (auto task = get_scheduler()->get_task("phrase")) {
            if (auto* t = task->get_state<float>("tempo")) {
                *t = std::min(3.0f, *t + 0.1f);
            }
        }
    });

    on_key_pressed(window, IO::Keys::S, []() {
        if (auto task = get_scheduler()->get_task("phrase")) {
            if (auto* t = task->get_state<float>("tempo")) {
                *t = std::max(0.3f, *t - 0.1f);
            }
        }
    });
}
      

Run this. Four even kicks, one bar lighting per kick. Three hats on bars five to seven with a long-long-short spacing. Eight alternating hits that tighten until they blur. Two beats of silence, and the phrase starts over. Press Space and the whole phrase speeds up, mid-section. Press S and it slows.

Change 0.78 to 0.9 and the fill tightens slowly instead of collapsing. Change the figure array to { 0.5, 0.5, 0.5 } and the uneven spacing goes even. Add a fifth section by pasting another loop above the rest: nothing else needs to change.

The body is the score. It reads top to bottom in the order the sound happens. There is no step counter and no branching on "which section am I in". The position in the function is that information.

Expansion 1: Many Waits, One Body

Click to expand: What a callback cannot do

The phrase above contains about two dozen co_await expressions across nine different durations. Every one is a line of source, and the source reads in the order things happen.

A schedule_metro callback fires at one fixed interval. To play this phrase from one you would hold a step counter, decide on every tick which section it belongs to, track how much of the current gap has elapsed, and branch. That is a state machine written by hand. A sequence is a flat list of delays built before it starts, so it cannot compute the next gap from a value that only exists when the previous note has finished. The fill does exactly that: gap *= 0.78.

A routine keeps that state for free:

  • Loop counters, the running gap, and the current section are locals in the coroutine frame.
  • The program counter is the state machine. Being on line 40 means "in the fill".
  • for, if and nested loops wrap waits like any other statement. The delay is evaluated when the wait is reached, so it can read anything, including the tempo that a key press changed a moment ago.

A wait is not a sleep. Nothing blocks. The audio thread carries on with everything else, and the scheduler resumes the body on the sample where the delay ends.

Expansion 2: What schedule_task Does

Click to expand: From coroutine call to first resume

Calling phrase(...) does not schedule anything by itself. The coroutine starts running immediately on the calling thread, up to its first suspension. That first suspension is co_await GetAudioPromise{}, which marks the routine as waiting for initialisation and hands you the promise reference.

schedule_task(name, std::move(routine)) takes the routine by rvalue reference and registers it with the scheduler under that name. The coroutine moves into the scheduler, so you do not keep a handle. Its third parameter, initialize, defaults to false. New tasks wait in a pending queue that the scheduler drains at the start of its next processing call. On that pass the routine sees its initialisation marker, stamps next_sample with the current clock position and resumes the body for the first time. From there the routine is resumed whenever the sample clock reaches next_sample.

Every co_await SampleDelay{N} adds N to next_sample and suspends. The scheduler compares the clock against that number once per sample. The routine resumes on the sample where the clock reaches the target.

The same call has overloads for GraphicsRoutine and FreeRoutine, so Cards 8b and 8d use it too. CrossRoutine has no overload yet and goes through the scheduler directly, as Card 8c shows.

Expansion 3: Inputs Are Parameters, Not Captures

Click to expand: Why the coroutine is a function and not a lambda

compose() returns as soon as it has registered everything, and its locals are gone. The coroutine frame is heap-allocated and outlives it. That difference decides where the routine's inputs may live.

Function parameters are copied into the frame. A pointer, a shared_ptr or a reference stays valid because the frame owns the copy. Lambda captures are different: they live in the closure object, the frame only keeps a pointer to that object, and the closure sat on the stack of compose(). A capturing lambda coroutine that is still suspended after compose() returns reads freed memory on its next resume.

So each routine in this card is a function whose inputs arrive as parameters. The small lambdas inside phrase (wait, on, off) are fine because they are locals of the frame. They are not coroutines and they do not outlive it.

The samplers arrive as std::function<void()>. The function parameter is copied into the frame together with the captured shared_ptr inside it, so the sampler lives as long as the routine does. A parameter declared auto would also work, but it turns the routine into a template, and inside a template a call such as promise.get_state<float>("tempo") has to be written promise.template get_state<float>("tempo"). Card 8d has one auto parameter in a routine that never calls get_state, which is why it compiles as written.

Expansion 4: Promise State Is a Map in the Frame

Click to expand: set_state, get_state, and what is safe from outside

set_state<T>(key, value) stores a value in an unordered_map<std::string, std::any> inside the coroutine frame. get_state<T>(key) returns T*, or nullptr if the key is missing or the type does not match.

The phrase keeps "tempo" there so the outside world can reach it, and "bar" as a counter the outside world can read. The frame stays put for the coroutine's lifetime, so a pointer from get_state stays valid across suspensions. The key handlers reach the routine with get_scheduler()->get_task("phrase"), which returns it by name. Before the scheduler has drained its pending queue that lookup returns nullptr, which is why the handlers test it.

What is safe and what is not:

  • Writing through a pointer to an existing key from another thread is a plain float write. Both key handlers do this. The worst outcome is that the coroutine sees the old or the new value on its next wait.
  • Calling set_state with a new key from another thread is a race. It can rehash the map while the coroutine holds pointers into it. Create every key you intend to share inside the coroutine before the first delay, as the phrase does.
  • get_task walks the scheduler's task list without a lock, and the audio thread changes that list when it drains additions and removals. Looking a task up from a key handler is fine in a program that registers its tasks once at start and reads them later. For anything that adds and removes tasks while it runs, share a shared_ptr-owned struct with atomic fields instead. Card 8d does this.

State the routine alone touches does not belong in the map at all. gap is an ordinary local.

Expansion 5: Stopping, Cancelling, Restarting

Click to expand: Three ways to end or rewind a routine
  • task->set_should_terminate(true): the loop checks the flag at the top and breaks. Cooperative. Nothing is interrupted mid-phrase.
  • MayaFlux::cancel_task("phrase"): removes the task from the scheduler and sets the terminate flag. The frame is released when the last reference drops.
  • restart_task("phrase"): sets a "restart" state flag to true and resumes the routine. It does not rewind the coroutine. The body has to read that flag and reset its own state, which is what Kriya::line does when built restartable.

The flag is only checked at the top of the loop, so a terminate request lets the current phrase finish. Move the check inside the sections to make it leave sooner.

Expansion 6: Everything Before This Card Is a Routine Like This

Click to expand: The abstraction stack is transparent
  • metro: one loop with a fixed delay and a callback.
  • Timer: one delay, one callback, return.
  • sequence / EventChain: a loop over a vector of (delay, callback) pairs.
  • Trigger, Gate, Toggle: a loop calling process_sample on a Logic node every sample and letting the node's own callbacks decide.
  • line: a loop writing a running value into promise state under "current_value", read from outside through the handle.

Each of them fixes one shape of waiting. A routine is the layer where the shape is yours.

Expansion 7: Mesh Transforms From the Audio Thread

Click to expand: An honest note about set_bar

set_bar runs on the audio thread and writes a 4x4 matrix that the graphics thread reads later. A read that lands in the middle of a write can draw one frame with a mixed transform. For a bar chart that is invisible, and it does not block the audio callback, which is the constraint that matters.

If a value must arrive whole, publish it through an atomic and apply it from a GraphicsRoutine. Card 8d shows the pattern.


Drawing a Wavetable (GraphicsRoutine)

Click this card to reveal full explanation

A GraphicsRoutine is structurally identical to a SoundRoutine. The differences are the clock and the thread. co_await FrameDelay{N} suspends for N frames of the frame clock, and the routine resumes on the graphics thread.

This one crosses into sound. The routine computes one cycle of a waveform as 480 numbers. Every frame it writes them into a stream that a sampler loops, and draws them as a curve. The picture is the sound. Its body is a script on the frame clock: glide to a shape, hold it, flutter, rest, move to the next.


constexpr size_t kTable = 480;
constexpr double kTau = 6.283185307179586;

double shape_sine(double x)   { return std::sin(kTau * x); }
double shape_saw(double x)    { return 2.0 * x - 1.0; }
double shape_square(double x) { return x < 0.5 ? 0.8 : -0.8; }
double shape_hollow(double x) {
    return 0.6 * std::sin(kTau * 3.0 * x) + 0.4 * std::sin(kTau * 5.0 * x);
}

GraphicsRoutine scribe(
    std::shared_ptr<Kakshya::DynamicSoundStream> stream,
    std::shared_ptr<::PathGeneratorNode> path)
{
    auto& promise = co_await GetGraphicsPromise{};

    using Shape = double (*)(double);
    const Shape shapes[] = { shape_sine, shape_saw, shape_square, shape_hollow };

    std::vector<double> table(kTable, 0.0);
    std::vector<double> out(kTable, 0.0);

    auto publish = [&](double gain) {
        for (size_t i = 0; i < kTable; ++i) {
            out[i] = table[i] * gain * 0.4;
        }
        stream->write_frames(std::span<const double>(out));

        path->clear_path();
        for (size_t i = 0; i < kTable; i += 5) {
            const float x = -0.9f + 1.8f * static_cast<float>(i) / static_cast<float>(kTable);
            const float y = static_cast<float>(table[i] * gain) * 0.6f;
            path->add_control_point({ glm::vec3(x, y, 0.f), glm::vec3(.4f, .8f, 1.f), 3.f });
        }
    };

    while (true) {
        for (const Shape shape : shapes) {
            if (promise.should_terminate) co_return;

            for (int frame = 0; frame < 90; ++frame) {
                for (size_t i = 0; i < kTable; ++i) {
                    const double target = shape(static_cast<double>(i) / kTable);
                    table[i] += (target - table[i]) * 0.06;
                }
                publish(1.0);
                co_await FrameDelay{ 1 };
            }

            co_await FrameDelay{ 150 };

            for (int k = 0; k < 6; ++k) {
                publish(k % 2 == 0 ? 0.25 : 1.0);
                co_await FrameDelay{ 4 };
            }

            publish(1.0);
            co_await FrameDelay{ 60 };
        }
    }
}

void compose() {
    auto window = create_window({ "Wavetable", 1200, 800 });

    auto path = vega.PathGeneratorNode(
        Kinesis::InterpolationMode::CATMULL_ROM, 4, 128) | Graphics;
    auto buffer = vega.GeometryBuffer(path) | Graphics;
    buffer->setup_rendering({
        .target_window = window,
        .topology = Portal::Graphics::PrimitiveTopology::LINE_STRIP });
    window->show();

    auto stream = std::make_shared<Kakshya::DynamicSoundStream>(48000, 1);
    std::vector<double> seed(kTable);
    for (size_t i = 0; i < kTable; ++i) {
        seed[i] = 0.4 * shape_sine(static_cast<double>(i) / kTable);
    }
    stream->write_frames(std::span<const double>(seed));

    auto layer = create_sampler_from_stream(stream, 0);
    layer->play_continuous(0, layer->slice_from_stream());
    store(layer);

    schedule_task("scribe", scribe(stream, path));
}
      

Run this. A low hum starts. On screen a curve rises out of a flat line and settles into one sine cycle. Then it slides toward a saw and the hum brightens as the corner sharpens. It holds, flutters in level four times, rests, and moves on to a square, then a hollow two-harmonic shape, then back to the sine.

Change 0.06 to 0.01 and the glide takes seconds: the timbre slowly opens instead of stepping. Change FrameDelay{ 1 } in the glide to FrameDelay{ 8 } and you hear the table update as a zipper, because you have set the update rate. Change kTable to 240 and the pitch doubles.

The frame clock is writing audio. Nothing in this routine touches the audio thread. It writes numbers into a container, and the audio thread reads that container on its own schedule.

Expansion 1: The Picture Is the Sound

Click to expand: One table, two readers

table holds one cycle of the waveform. publish reads it twice. Once to fill out for the audio side, with a gain and a safety scale of 0.4. Once to place 96 control points for the curve, taking every fifth of the 480 values.

The sampler loops the stream, so the stream's length is the wave's period. 480 samples at 48 kHz is 100 Hz. The curve you see is exactly the shape of that one period, which is why the timbre follows the picture.

The seed write in compose() comes before the sampler is created. The sampler slices the stream as it stands when you ask, and an empty stream slices to nothing.

Expansion 2: A Graphics Thread Writing What the Audio Thread Reads

Click to expand: How the container makes this safe

DynamicSoundStream guards its data with a sequence lock. A write takes the write side. A read that overlaps a write notices and retries. So the audio thread never takes a mutex, and a half-written table is not what it plays.

"Graphics" in GraphicsRoutine names a clock and a thread. It does not name a GPU boundary. Writing samples from it is ordinary programming, and the one constraint in the whole design is that nothing blocks the audio callback.

The write is 480 doubles. It lands once per frame, so a table that changes smoothly is heard as a smooth change. A table that jumps is heard as a step, which is the zipper from the FrameDelay{ 8 } experiment.

Expansion 3: Waits Compose Like Code

Click to expand: Four different delays in one loop

The body waits in four different ways per shape:

  • FrameDelay{ 1 } ninety times: the glide, one step per frame.
  • FrameDelay{ 150 } once: the hold, two and a half seconds.
  • FrameDelay{ 4 } six times: the flutter, fifteen updates a second.
  • FrameDelay{ 60 } once: the rest before the next shape.

Nothing schedules these against each other. Each wait is a line, and the next line runs when it ends. A gate over a sample loop, a hold, a fast gating figure and a rest would each be a separate object in a callback-based design. Here they are consecutive statements, and the loop around them makes the whole thing repeat.

Expansion 4: Locals Are State

Click to expand: What does not need the promise map

table, out and the shape list are locals of the coroutine. They live in the frame, and only this routine touches them, so there is no reason to route them through set_state. The promise map is for values that outside code needs to reach. Everything else can be a variable.

The only object shared with another thread is the stream, and the container owns its own synchronisation.

Expansion 5: FrameDelay Counts Frames, Not Seconds

Click to expand: Converting between domains

FrameDelay{N} is a count of frame-clock ticks. If you want seconds, convert with the frame rate: sched.seconds_to_units(0.25, Vruta::ProcessingToken::FRAME_ACCURATE). seconds_to_samples converts against the audio rate, so it gives the wrong number here.

Time in the scheduler is domain integers with seconds as the bridge between them. Sample counts and frame counts never mix directly. Card 8c is the case where one suspension needs both.


A Score Across Two Clocks (CrossRoutine)

Click this card to reveal full explanation

A SoundRoutine answers to the sample clock and a GraphicsRoutine answers to the frame clock. A CrossRoutine answers to both. co_await MultiRateDelay{ samples, frames } arms the clocks you give a nonzero count, and the routine resumes only after every armed clock has reached its target. A zero on one axis disarms that clock for the suspension.

That gives one body three kinds of wait. Frames only: the code after it runs on the graphics thread. Samples only: it runs on the audio thread. Both: it runs when the slower clock arrives. The score below uses all three. A spiral is drawn one point per frame. A half-second barrier holds until both clocks have passed it. Then a roll of kicks accelerates on the sample clock. CrossRoutine has no schedule_task overload yet, so it is wrapped in a shared_ptr and handed to the scheduler directly.


void align(cross_promise& promise, TaskScheduler& sched) {
    promise.next_sample.store(
        sched.current_units(ProcessingToken::SAMPLE_ACCURATE),
        std::memory_order_release);
    promise.next_frame.store(
        sched.current_units(ProcessingToken::FRAME_ACCURATE),
        std::memory_order_release);
}

CrossRoutine score(
    TaskScheduler& sched,
    std::shared_ptr<::PathGeneratorNode> path,
    std::function<void()> play_kick)
{
    auto& promise = co_await GetCrossPromise{};

    const double gaps[] = { 0.30, 0.22, 0.16, 0.11, 0.08, 0.06 };

    while (true) {
        if (promise.should_terminate) break;

        align(promise, sched);
        co_await MultiRateDelay{ 0, 1 };

        path->clear_path();
        for (int i = 0; i < 90; ++i) {
            const float t = static_cast<float>(i) / 89.f;
            const float angle = 0.21f * static_cast<float>(i);
            const float radius = 0.06f + 0.009f * static_cast<float>(i);
            path->add_control_point({
                glm::vec3(std::cos(angle) * radius, std::sin(angle) * radius, 0.f),
                glm::mix(glm::vec3(.2f, .6f, 1.f), glm::vec3(1.f, .4f, .3f), t),
                2.f + 3.f * t });
            co_await MultiRateDelay{ 0, 1 };
        }

        align(promise, sched);
        co_await MultiRateDelay{ seconds_to_samples(0.5), 30 };

        align(promise, sched);
        co_await MultiRateDelay{ 1, 0 };

        for (const double gap : gaps) {
            play_kick();
            co_await MultiRateDelay{ seconds_to_samples(gap), 0 };
        }
    }
}

void compose() {
    auto window = create_window({ "Score", 1200, 800 });

    auto path = vega.PathGeneratorNode(
        Kinesis::InterpolationMode::CATMULL_ROM, 6, 60) | Graphics;
    auto buffer = vega.GeometryBuffer(path) | Graphics;
    buffer->setup_rendering({
        .target_window = window,
        .topology = Portal::Graphics::PrimitiveTopology::LINE_STRIP });
    window->show();

    auto kick = create_sampler("path/to/kick.wav");
    kick->load(0, kick->slice_from_stream());

    auto routine = std::make_shared<CrossRoutine>(
        score(*get_scheduler(), path, [kick] { kick->play(0); }));
    get_scheduler()->add_task(routine, "score");
}
      

Run this. A spiral unwinds from the centre, one point per frame, blue shifting to orange. Only the newest sixty points survive, so the curve is a comet that chases its own tail. When the last point lands there is half a second of nothing. Then six kicks arrive with shrinking gaps, 300 ms down to 60 ms, and the spiral starts over.

Change the 30 in the barrier to 60 and the rest doubles: the frame clock is now the slower of the two. Change seconds_to_samples(0.5) to seconds_to_samples(2.0) and the sample clock governs instead. Delete the two align calls before the roll and the kicks bunch together into one burst.

This routine hops threads by choosing its waits. The spiral points are added on the graphics thread and the kicks are fired on the audio thread, from one function, with no queue between them.

Expansion 1: Arming Decides the Thread

Click to expand: Who resumes you

A cross routine lives in one task list. Both the sample-clock pump on the audio thread and the frame-clock pump on the graphics thread scan it, and each offers the routine a resume with its own context. What each pump can do with that offer depends on which clocks the current wait armed:

SuspensionArmed clocksBody resumes on
MultiRateDelay{ 0, 1 }frame onlythe graphics thread
MultiRateDelay{ 24000, 0 }sample onlythe audio thread
MultiRateDelay{ 24000, 30 }sample and framewhichever clock arrives last
MultiRateDelay{ 0, 0 }nonedoes not suspend

A pump that has nothing armed on its clock cannot satisfy the wait, so it cannot be the thread that resumes. That is why the spiral, which waits on frames only, runs entirely on the graphics thread, and the roll, which waits on samples only, runs entirely on the audio thread.

Two spots have no guaranteed thread. The code before the first wait runs on whichever pump initialised the routine, so the score does nothing thread-sensitive there. And the code right after the barrier runs on whichever clock was slower, so the score spends the barrier's landing on a one-sample wait to get back onto the audio thread before firing a kick.

Expansion 2: All Armed Clocks, Then One Resume

Click to expand: What the gate does

On each offer the routine:

  1. Marks the offering clock satisfied if the position has reached the target and that clock was armed.
  2. Returns without resuming if any armed clock is still unsatisfied.
  3. Otherwise performs a compare-exchange from MULTIPLE to NONE on its delay context. Only the thread whose exchange succeeds resumes the handle. If both pumps arrive at the same moment, one wins and the other backs off.
  4. Clears both satisfaction flags before resuming, so the next suspension starts clean.

Expansion 3: Unarmed Clocks Fall Behind

Click to expand: Why the score calls align

Each wait adds its counts to two stored targets, next_sample and next_frame. Nothing pulls a target up to the present time. A clock that a wait leaves unarmed keeps its old target while real time moves on.

In the score, the spiral arms only frames, so for a second and a half next_sample stands still. When the roll then waits { 14400, 0 }, its target is the stale value plus 14400, which is already in the past. Every wait is satisfied on the spot and the kicks fire back to back. The same thing happens the other way round when the roll hands back to the spiral.

The fix is the align helper. It stores the current position of each clock into the two targets. Call it before any phase that leaves the axis it last used. The promise fields are public atomics, so it is safe to write them while the routine is running: no pump reads them until the next suspension.

The same mistake shows up at registration. A cross routine is initialised by whichever pump sees it first, and that pump seeds both targets from its own clock. The sample clock counts far faster than the frame clock, so a routine that an audio pump initialises late in a session can start with a frame target thousands of frames ahead and wait effectively forever. The first align in the score overwrites that seed.

Expansion 4: What the Barrier Is For

Click to expand: A wait neither clock can shorten

MultiRateDelay{ seconds_to_samples(0.5), 30 } is half a second on both clocks. While both run at their normal rates the two targets arrive together and the wait behaves like any half-second rest.

The difference appears when one clock is late. If the graphics thread stalls for a moment, the routine holds until the frame clock has actually reached its target, and the roll starts after the picture has caught up. If audio dropouts hold back the sample clock, the roll waits for those too. Neither domain runs ahead of the other across a barrier. A SoundRoutine cannot make that promise about frames, and a GraphicsRoutine cannot make it about samples.

Expansion 5: Fabric Handles This for You

Click to expand: The .use(CrossFactory) path

When a cross routine needs to be owned and cancelled with a Nexus entity, pass a factory that returns a Vruta::CrossRoutine to Fabric's wiring .use(...). Fabric registers it with the scheduler and cancels it when the entity is released, so you do not call add_task or cancel_task yourself. This is the same mechanism Card 7 used for graphics routines, extended to the third type.


Life Off the Clocks (FreeRoutine)

Click this card to reveal full explanation

A FreeRoutine has no clock. It suspends on co_await ConditionAwaiter{ predicate } and a dedicated scheduler thread evaluates the predicate over and over. The moment it returns true, the routine resumes on that thread.

That makes it the place for computation whose pace comes from something other than time. Here it steps Conway's Game of Life on a 48 by 48 grid. Its body waits three times, on three different conditions: not paused, asked for a step, and, if the world has died, told to reseed. A graphics routine draws each generation and asks for the next.


constexpr int kSide = 48;
using Grid = std::array<uint8_t, kSide * kSide>;

struct Life {
    std::array<Grid, 2>   grid {};
    std::atomic<int>      front { 0 };
    std::atomic<bool>     want { false };
    std::atomic<bool>     paused { false };
    std::atomic<bool>     reseed { false };
    std::atomic<uint64_t> generation { 0 };
};

uint32_t randomize(Grid& g, std::mt19937& rng) {
    uint32_t alive = 0;
    for (auto& c : g) {
        c = (rng() % 4) == 0;
        alive += c;
    }
    return alive;
}

uint32_t step(const Grid& in, Grid& out) {
    uint32_t alive = 0;
    for (int y = 0; y < kSide; ++y) {
        for (int x = 0; x < kSide; ++x) {
            int n = 0;
            for (int dy = -1; dy <= 1; ++dy) {
                for (int dx = -1; dx <= 1; ++dx) {
                    if (dx != 0 || dy != 0) {
                        n += in[((y + dy + kSide) % kSide) * kSide + (x + dx + kSide) % kSide];
                    }
                }
            }
            const uint8_t next = in[y * kSide + x] ? (n == 2 || n == 3) : (n == 3);
            out[y * kSide + x] = next;
            alive += next;
        }
    }
    return alive;
}

FreeRoutine evolve(std::shared_ptr<Life> life) {
    std::mt19937 rng(7);

    while (true) {
        co_await ConditionAwaiter{ [life] {
            return !life->paused.load(std::memory_order_acquire);
        } };

        co_await ConditionAwaiter{ [life] {
            return life->want.load(std::memory_order_acquire);
        } };

        const int f = life->front.load(std::memory_order_relaxed);
        uint32_t alive = step(life->grid[f], life->grid[1 - f]);

        if (alive == 0) {
            co_await ConditionAwaiter{ [life] {
                return life->reseed.load(std::memory_order_acquire);
            } };
        }
        if (life->reseed.exchange(false, std::memory_order_acq_rel)) {
            alive = randomize(life->grid[1 - f], rng);
        }

        life->front.store(1 - f, std::memory_order_release);
        life->generation.fetch_add(1, std::memory_order_release);
        life->want.store(false, std::memory_order_release);
    }
}

GraphicsRoutine show(std::shared_ptr<Life> life, auto points) {
    auto& promise = co_await GetGraphicsPromise{};
    uint64_t seen = ~uint64_t { 0 };

    while (true) {
        if (promise.should_terminate) break;

        const uint64_t g = life->generation.load(std::memory_order_acquire);
        if (g != seen) {
            seen = g;
            const Grid& cells = life->grid[life->front.load(std::memory_order_acquire)];

            points->clear_points();
            for (int y = 0; y < kSide; ++y) {
                for (int x = 0; x < kSide; ++x) {
                    if (!cells[y * kSide + x]) continue;
                    points->add_point({
                        glm::vec3(-0.95f + 1.9f * static_cast<float>(x) / (kSide - 1),
                                  -0.95f + 1.9f * static_cast<float>(y) / (kSide - 1), 0.f),
                        glm::vec3(.4f, 1.f, .6f),
                        6.f });
                }
            }

            life->want.store(true, std::memory_order_release);
        }

        co_await FrameDelay{ 3 };
    }
}

void compose() {
    auto window = create_window({ "Life", 1000, 1000 });

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

    auto life = std::make_shared<Life>();
    std::mt19937 seed(1);
    randomize(life->grid[0], seed);

    schedule_task("life", evolve(life));
    schedule_task("life_view", show(life, points));

    on_key_pressed(window, IO::Keys::Space, [life]() {
        life->paused.store(!life->paused.load());
    });

    on_key_pressed(window, IO::Keys::R, [life]() {
        life->reseed.store(true);
    });
}
      

Run this. A field of green cells churns and settles into blinkers, blocks and gliders, about twenty generations a second. Space freezes it and releases it. R scatters a new random world over the current one.

Change FrameDelay{ 3 } to FrameDelay{ 1 } and it runs at frame rate. Change rng() % 4 to rng() % 2 and the world starts crowded and dies back. Change kSide to 128 and the routine does sixteen thousand cells per step on a thread that neither clock owns.

The step ran on a third thread. The graphics routine asked and read. The audio thread never knew.

Expansion 1: Three Waits, Three Predicates

Click to expand: A body that waits on different things in turn

Each iteration of evolve passes through up to three waits:

  1. !paused: block here while the world is frozen.
  2. want: block until the viewer asks for the next generation.
  3. reseed: only if the step produced an empty grid, block until someone says what to do about it.

The predicates share nothing except that each returns a bool. A SoundRoutine waits on time and a GraphicsRoutine waits on frames. A FreeRoutine waits on whatever you can write as a function, and it can wait on something different every line.

When the grid dies, the routine has already computed the empty generation into the back buffer but has not published it. The screen keeps showing the last living generation until R is pressed, then the reseeded grid replaces the empty one. The R key also works on a living world, since the reseed flag is consumed after the extinction check.

Expansion 2: The Handshake Keeps Two Threads Off One Grid

Click to expand: Double buffering with one request per response

The grid exists twice. front names the generation the viewer may read. The free routine only ever writes the other one, and flips front as its last act.

The viewer reads only in a frame where generation has changed, and it sets want only after it has finished reading. So a request always follows a completed read, and a response always follows a completed write. The reader is never looking at a buffer the writer is filling, and there is no lock. The release store on generation and the acquire load in the viewer are what carry the finished grid across.

The initial seed in compose() is written before either routine is scheduled, so it needs no synchronisation.

Expansion 3: The Condition Thread Spins

Click to expand: What the cost is and what pays for it

The scheduler starts one dedicated thread the first time a FreeRoutine is added. That thread loops without sleeping: it drains pending operations, then for every routine in the list it checks whether the routine is armed and, if so, evaluates the predicate. With nothing to do it yields, but it does not block.

In practice that is one core running near full occupancy for as long as any free routine exists. It is a design choice: resume latency is the time to the next pass, not a timer period or a wake-up.

Two things follow from that.

  • The predicate is the throttle. A cheap atomic<bool> load is nearly free. A predicate that allocates or takes a lock is paid for millions of times a second.
  • The routine body is the hot path. Whatever you do after the co_await runs on that thread while every other free routine waits. Long work delays the others.

Expansion 4: The First Predicate Can Be True at Start

Click to expand: A start-up detail specific to free routines

Like every routine, calling the coroutine function runs its body immediately up to the first suspension. For sound, graphics and cross routines the first suspension is the promise request, which always suspends. A FreeRoutine has no promise request. Its first suspension is whatever your first co_await ConditionAwaiter is.

If that predicate is already true, await_ready returns true, the coroutine does not suspend, and the body carries on on the thread that called the function, which is your compose(). In evolve the first predicate, !paused, is true at start, so the body runs through it on the composing thread and arms on the second wait, want. Nothing between them has a side effect, so it is harmless. Anything with side effects before the first false predicate would run on the wrong thread.

Expansion 5: What Belongs Here

Click to expand: Work with no clock of its own

A free routine suits any iterative process whose pace comes from something other than time. Cellular automata that step when a request arrives. A physics integrator that runs as fast as it can and publishes snapshots. A model that infers when input is ready. A search that continues until a target is met.

What does not belong here is anything that has a natural clock. If it should happen every 500 ms, it is a SoundRoutine. If it should happen every frame, it is a GraphicsRoutine. The free routine is for what those two cannot express.

Expansion 6: Cancelling and Ending

Click to expand: Same verbs, one difference

cancel_task("life") and set_should_terminate(true) work as with the other routines. The pump thread checks should_terminate before every resume, so a cancelled routine never resumes again.

Because the body only runs after a predicate is true, a routine parked on want never reaches a should_terminate check inside your loop. That is fine here, since the scheduler stops resuming it. If you need to run cleanup on exit, add the flag to the predicate: return want.load() || promise.should_terminate;, then check it after the co_await.


What You've Learned

  • Many waits, one body: a routine holds as many waits as it likes, and loops, branches and locals carry the state a callback would need a state machine for.
  • SoundRoutine: sample clock, audio thread. SampleDelay, BufferDelay. State in the promise map.
  • GraphicsRoutine: frame clock, graphics thread. FrameDelay. It can write data the audio thread reads, through a container that owns its synchronisation.
  • CrossRoutine: both clocks, all armed clocks satisfied, one thread resumes. Arming frames only lands you on the graphics thread, samples only on the audio thread. Re-align the clocks when you switch axes.
  • FreeRoutine: no clock, a predicate, ConditionAwaiter, a dedicated spinning thread. Different predicates in sequence.
  • schedule_task: registers a Sound, Graphics or Free routine by name. Cross routines go through the scheduler directly for now.
  • Inputs are parameters: coroutine frames copy parameters and do not own lambda captures.
  • Everything earlier is built from these. Metro, Timer, sequence, Trigger, line, and Fabric's wiring are loops over the delays above.

Quick Reference

Pick the highest-level mechanism whose shape fits. Drop a layer only when its shape is in your way.
The time-based entries below are routines with a loop around them. The node hooks are callbacks inside a node's own processing.

Time on the sample clock

  • When you need code to run at a fixed interval: schedule_metro. When the interval has to be computed at each step, or the body needs more than one wait: a SoundRoutine with SampleDelay.
  • When you need code to run once after a delay: Kriya::Timer. It holds one pending callback, and scheduling again replaces it.
  • When you need a bracketed start and end over a duration: Kriya::TimedAction. The start function runs at once and the end function runs after the duration.
  • When you need a node to exist for a bounded duration: node >> Time(N) | Audio.
  • When you need a finite ordered sequence of timed events: schedule_sequence or EventChain.
  • When you need an indefinitely repeating generative sequence: schedule_pattern.
  • When you need a continuously drifting scalar readable by any code: schedule_task with create_line, read via get_line_value.

Signals inside nodes

  • When you need code to run on every sample from a specific node: node->on_tick, or on_tick_if to add a condition. The callback runs inside the node's per-sample processing, so anything slow in it is paid on every sample. Use sparingly.
  • When you need code to run only at a specific moment in a node's cycle: impulse->on_impulse, phasor->on_phase_wrap, phasor->on_threshold, counter->on_count.
  • When you need code to run while a signal condition is true, continuously: Logic while_true.
  • When you need code to run exactly once at a state transition: Logic on_change_to.

External input

  • When you need to react to keyboard or mouse: on_key_pressed, on_mouse_move, on_mouse_pressed. These run on the event thread, not the audio thread, so share values accordingly (see below).
  • When you need to react to MIDI, OSC, or HID: vega.read_midi / vega.read_osc / vega.read_hid, then hook on the node.

Buffers and space

  • When you need audio processing to accumulate across multiple buffer cycles before acting: BufferPipeline with PHASED.
  • When you need audio processing to apply per cycle with minimal latency: BufferPipeline with STREAMING.
  • When you need a spatial entity that fires on an interval, a key, or a choreographed path: Fabric with Wiring.

Raw routines

  • When you need complete control of the coroutine and its state, several waits in one body, or delays computed at the moment they are reached: a SoundRoutine.
  • When you need work at frame rate, or a routine that writes data the audio thread reads: a GraphicsRoutine with FrameDelay.
  • When you need one script that spans both clocks: a CrossRoutine with MultiRateDelay. Wait on frames only to run on the graphics thread, on samples only to run on the audio thread, on both to hold until the slower clock arrives. Use the align helper from Card 8c whenever you change axes.
  • When you need computation whose pace is a condition and not a clock: a FreeRoutine with ConditionAwaiter. It costs one spinning thread, so keep the predicate cheap.
  • When you need to register one: schedule_task takes a Sound, Graphics or Free routine. A Cross routine goes to the scheduler's add_task for now.

Sharing values between mechanisms

Decide by thread, not by domain. A key handler and an audio routine are on different threads even though both are control.

  • When only one routine touches the value: a local variable in the coroutine.
  • When outside code needs to read or nudge one routine's state: promise state through the task handle. Write through existing keys only, and create every key inside the coroutine before its first wait.
  • When two mechanisms on different threads (audio, graphics, event, free) need a value: std::atomic<T> for one number, a shared_ptr-owned struct of atomics for several, published with a generation counter. For sample data, a container that owns its own synchronisation, such as DynamicSoundStream.
  • When the audio thread is one of the two: no mutex, ever.

What This Tutorial Does Not Cover

Three areas sit next to everything here and each needs its own document. Each is another answer to the question this tutorial has asked from the start: what does this code wait for?

Lila and live temporal control. Lila is a JIT environment where you can rewrite a coroutine, replace a pattern function, or swap an Emitter's influence function while the piece is running. It is not a different scheduling mechanism. It is the same mechanisms with the compose() boundary removed. The constraint this tutorial assumed, that temporal structure is declared at startup and then runs, no longer holds. A running coroutine is not edited in place. You cancel it and schedule its replacement under the same name, and the new frame starts with fresh state, promise map included. Three rules from Card 8 stop being advice there. A routine takes its inputs as parameters, because the snippet that created it returns before the routine's next resume. A cross routine added mid-session aligns its clocks first. A free routine whose first predicate is already true runs on the thread that evaluated the snippet.

Yantra and completion time. ComputeMatrix and the granular workflow run computation on background threads and deliver results through callbacks. The callback is a temporal event of its own: it fires when the computation finishes. That is a third axis beside the sample clock and the frame clock, and unlike them it has no rate. Completion time is nondeterministic, and nothing bounds it unless you add a bound, so a design that installs a result decides in advance what happens when the result is late. Installing it into a live pipeline uses what you have already met. Publish it through a generation counter, as Card 8d does, or cancel a routine and schedule a new one that carries the result. restart_task alone will not do it, because it does not rewind the coroutine. A FreeRoutine is the in-tutorial relative: a computation with no clock whose result appears when it appears.

Nexus and spatial triggers. A Sensor's perception function fires on what lies within its query radius at commit() time. The trigger is spatial, not temporal. It combines with everything here: a Sensor that fires only inside a Logic gate's open window, a pattern that emits new Emitter positions, a coroutine that reads Sensor output and drives a Physics operator. The spatial index itself belongs to the Nexus series. The scheduling underneath is what you have used throughout: Fabric's .use(...) runs the same routines you wrote in Card 8, and registers and cancels them with the entity.

Every mechanism in this tutorial waits on something: a sample count, a frame count, both, a condition. The three areas above add three more ways for a routine to be told to go: someone rewrites it, a computation finishes, an entity arrives. The routines themselves do not change.