koru/signal: External Clock, Named Models, and Nanosecond Ticks

· 10 min read

External clock and named models

koru/signal lives in koru-libs as import koru/signal. Stateful signal processing with named models, an external clock, and compile-to-Zig backends. Each tick reads inputs, updates filter state, writes outputs — init/block rhythm from the JSFX emit lineage, applied to scalar wires like pass-rate snapshots.

Downstream of a JSFX transpiler

The emit backend descends from a JSFX/EEL2 → Zig transpiler built for audio DSP — @sample rate, dylib ABI, ULP-exact checks against the NSEEL2 reference. koru/signal uses the event-rate profile: scalar wires, external clock, no JSFX transport globals (srate, spl0, …).

LayerRole
Audio emitJSFX/EEL2 sections → Zig at sample rate
Signal emitSame opcode → Zig with include_transport = false
Koru surface:shape, :init, :tick, named models, selective link via std/build:requires

The design copies std/store + std/kernel:

Store + kernelkoru/signal
std/store:new(world) { fields }:shape(Name) { ports }
std/store(world) bare referencekoru/signal(Breath) → registry
std/store:insert(world) / :initkoru/signal:init(Breath)
external compute over named instancekoru/signal:tick(Breath, pass_rate: r)

:init is the compile boundary. :tick is the external-clock join — your loop, your clock, explicit ticks by name.

Why this exists

The machinery descends from a JSFX/EEL2 → Zig transpiler — plugin DSP with ULP-exact checks against the NSEEL2 reference. We kept the init/block rhythm and opcode lowering; we dropped sample-rate transport for typed scalar wires at event rate. Same emit belt, different clock — closer to WMFX world-model instruments than to a JSFX host.

The aspiration is a third registry beside std/store and std/kernel: store names entities, kernel names compute over collections, signal names stateful models you tick on your clock. Games, monitors, sims — the host owns time, not a buffer callback.

v0 is deliberately narrow. The host program (:shape, :init, :tick, bind-first wiring) is real Koru that compiles today. Model physics ships pre-emitted until tick bodies land as host syntax. The bet is not throughput marketing — it is correctness and wiring: oracle parity across emit paths, suite pins on init/tick/shape, selective link so only the models you name land in the binary. Wire and tick a named model in seconds now; author new physics in the same surface once the weld closes.

v0 models

Breath (sustained drawdown) and Floor (instant threshold). Filter logic ships as pre-emitted models/breath.zig / models/floor.zig, regenerated by the codegen weld:

cd ~/src/koru-libs/signal/codegen && bash run.sh
# PASS: koru/signal codegen matches breath oracle
# PASS: Koru→EEL emit ≡ reference emit

Host program (the Koru that compiles)

Complete runnable reference — koru-libs/signal/examples/breath_demo.k:

import koru/signal
import std/io

koru/signal:shape(Breath) {
    pass_rate[percent]
    drawdown[percent]
    alarm[1]
    surprise[1]
}

const {
    depth_floor: 8
    dur_threshold: 18
}

koru/signal:init(Breath)
| ok |> for(0..20)
    ! each i |> snapshot-rate(i): r
        |> koru/signal:tick(Breath, pass_rate: r)
            | out s |> check-step(i, s.alarm, dip-ticks: s.dip_ticks)
            | err e |> std/io:print.ln("tick failed: {{ e:s }}")
| err e |> std/io:print.ln("init failed: {{ e:s }}")

tor snapshot-rate { i: usize } -> f64

proc snapshot-rate|zig {
    return switch (i) {
        0 => 90.0,
        1 => 85.0,
        else => 70.0,
    };
}

tor check-step { i: usize, alarm: f64, dip-ticks: f64 }

proc check-step|zig {
    const std = @import("std");
    if (i != 19) return;
    if (alarm < 0.5) @panic("sustained inhale never tripped alarm");
    if (dip_ticks < 18.0) @panic("dip_ticks below dur_threshold");
    std.debug.print(
        "PASS: breath demo — alarm={d}, dip_ticks={d}\n",
        .{ alarm, dip_ticks },
    );
}
cd ~/src/koru-libs/signal/examples
KORU_HOME=~/src/koru koruc breath_demo.k
./a.out
# PASS: breath demo — alarm=1, dip_ticks=18
three layers per tick — host bind-first clock, runtime slot, emitted block(); Breath oracle at tick 19

Bind-first: snapshot-rate(i): r then pass_rate: r. Nested calls in argument positions are KORU104 (pin 320_127_nested_call_argument).

Suite pins: signal/tests/external_tick/input.kz, signal/tests/shape_surface/input.kz, signal/tests/tick_before_init/input.kz.

Tick before init

import koru/signal
import std/io

koru/signal:tick(Breath, pass_rate: 90.0)
| out _ |> std/io:print.ln("unexpected tick success")
| err e |> std/io:print.ln("expected err: {{ e:s }}")

Runtime message: koru/signal:tick — koru/signal:init(Breath) required before tick.

Where model tick logic lives

v0 does not expose model tick bodies as Koru host syntax. Breath’s state machine is in emitted models/breath.zig (plain Zig init / block your runtime calls through signal_rt).

Maintainers regenerate that file via codegen/run.sh. The weld reads Koru-shaped text from models/breath.koru.kz through signal-model-emit — text scraping, not koruc. Do not copy from that file into a host program.

Backend wiring

  • ${REL_TO_ROOT}/../koru-libs/signal/models/breath.zigsignal_breath import
  • Per-model runtime slots in koru-libs/signal_rt/
  • C3 selective link — name only Breath and only signal_breath lands in build_output.zig

Same std/build:requires / std/compiler:requires machinery as MLIR and other AOT artifacts.

Performance

Measured on emitted Breath at ReleaseFast (signal/codegen/bench_tick.zig). The Koru runtime slot adds nothing measurable above calling emitted block() directly — the cost is in the emit path (~8 ns/tick on the machine that ran the bench), mostly inherited audio-era assign hygiene. Reproduce: cd ~/src/koru-libs/signal/codegen && zig build bench.

Verify

cd ~/src/koru-libs/signal && bash verify.sh
cd ~/src/koru-libs && KORU_HOME=~/src/koru koruc suite.k test   # nine signal * cases

See signal/AUTHORING.md, signal/README.md, signal/THIRD_PARTY.md.

Credits and licenses

koru/signal (@korulang/signal) is MIT. Codegen weld vendors Cockos WDL/NSEEL2 (via ysfx) and ysfx (Apache-2.0). Details: koru-libs/signal/THIRD_PARTY.md.

What’s next

  • Dynamic registry (third model without editing index.kz)
  • Graph surface at event rate (wires, sidechain routing)
  • Model tick bodies as real Koru host syntax (today: emit weld only)