Onda Language Guide
This guide is both a learning path and the complete language reference. It starts by introducing Onda and the shape of a program, then covers execution, runtime code, external resources, reusable processors, messages, graphs, compile-time programming, and modules.
Contents
- What Is Onda?
- Source Files
- Execution and State
- Values and Runtime Code
- Audio and Control Interfaces
- Collections
- Functions with
def - Structs
- External Buffers
- Processors
- Events, Printing, and Delegates
- Tasks
- Graphs
- Compile-Time Programming and Generics
- Modules, Namespaces, and
use - Reference Notes
1. What Is Onda?
Onda is an expressive and performant JIT-compiled audio programming language. An Onda program defines how sound is generated or transformed through successive blocks of samples. It exposes signals and controls, preserves state across processing, and organizes code explicitly by when it runs: during initialization, once per block, or once per sample.
Time is therefore visible in the structure of an Onda program. An init
section constructs long-lived state, block performs work once for a group of
samples, and sample describes what happens to one sample. Where a value is
first introduced determines how long it lives. A phase accumulator introduced
in init, for example, is plainly persistent state rather than a specially
annotated field hidden elsewhere in the program.
Here is a complete sine oscillator:
params:
freq = 440.0 {20.0, 20000.0}
level = 0.5 {0.0, 1.0}
init:
phase = 0.0
block:
phase_incr = freq * TWO_PI / SR
sample:
phase = phase + phase_incr
if phase > TWO_PI:
phase = phase - TWO_PI
out1 = sin(phase) * level
Read the file as a sketch of the running processor. freq and level are
controls exposed to the host. phase is its state. The phase increment is prepared
once per block from the current frequency and sample rate; the nested sample
section then advances the oscillator and writes each result to out1. There is
no entry function or hand-written stream loop: the file itself is the processor
that the host instantiates and repeatedly advances.
Reusable processors follow the same model. A filter, oscillator, or effect can
own state and still be called with ordinary expression syntax, so a signal path
can read as directly as filter(osc(freq = freq)). For programs where topology
is the better description, graph connects those processors declaratively.
Onda can therefore move between equations, imperative control flow, and signal
graphs without splitting the audio system across different languages.
Most Onda files grow from the same recognizable shape: imports and reusable
definitions first, the host-facing interface next, then initialization and
audio execution. It is not a required template. An instrument may have outputs
but no inputs, an effect may need no persistent state, and a library module may
contain only declarations. The next chapter covers the source notation;
Execution and State defines precisely when init,
block, and sample run and how values move between them.
An entry file may alternatively wrap its top-level program in a non-generic
proc Main:
proc Main:
params:
gain = 0.5 {0.0, 1.0}
sample:
out1 = gain
This is only an entry wrapper, not a reusable processor declaration. Its contents use the exact
top-level grammar and semantics, including kins, host parameter domains, config const, defs,
structs, processors, and namespaces. The parser removes the wrapper, so later compiler stages see
the same program as if its contents had been written directly at top level.
Only one top-level proc Main may occur across the entry and its included files, it cannot have
generic parameters, and wrapped and unwrapped host-facing or executable sections cannot be mixed.
Imports and reusable declarations may remain outside the wrapper. An imported declaration module
cannot supply the entry wrapper. A proc Main declared inside a namespace remains an ordinary
reusable processor and has no special entry behavior.
2. Source Files
Onda supports indentation syntax and brace syntax. These two programs are equivalent:
outs:
out1
sample:
out1 = 0.0
outs {
out1
}
sample {
out1 = 0.0
}
Basic source rules:
- Statements can be separated by newlines or
;. - Line comments start with
#. - Newlines are allowed inside parenthesized, bracketed, and angle-bracketed lists. Parenthesized expressions may also place newlines before or after any operator, consistently across every statement context. Outside delimiters, a newline remains a statement separator. Trailing commas are not accepted.
- Names are introduced before they are used.
- Top-level declarations are processed in lexical order.
- A declaration-only source file is valid and does not need an executable
sample,block, orgraphsection. This is the normal shape of an imported library module. import module/pathloadsmodule/path.onda.include "path.onda"orinclude "path.on"inserts another file by quoted path.- Native filesystem-backed entry, import, and include paths must not traverse symbolic links. The loader rejects the path and identifies the offending component; virtual sources and immutable project images are unaffected.
The exhaustive list of top-level forms is in Reference Notes.
Imports, includes, namespaces, and lookup rules are covered after the core
runtime language in Modules, Namespaces, and use.
3. Execution and State
Executable sections determine when code runs:
initcreates persistent state.sampleproduces or processes one audio sample.blocksurrounds the per-sample loop with work performed once per logical block.
Where a name is first bound determines its storage lifetime:
| First binding | Lifetime |
|---|---|
Directly in init |
Persistent owner state. |
| In block-pre | Block-carried owner state, refreshed when block-pre runs and visible to the nested sample and block-post. |
In sample |
A local for the current sample invocation. |
Inside nested if, for, or while flow |
A local scoped to that flow. |
init
init runs when an instance is initialized. It creates persistent state and
usually constructs structs and processors.
init:
phase = 0.0
gain = 0.5
taps: f32[8]
Typical uses include:
- Creating persistent scalar and aggregate state.
- Constructing structs and proc instances.
- Performing one-time setup.
- Reading, writing, or inspecting buffers that are bound for initialization.
Section default scalar types are supported:
init<f64>:
phase = 0.0
last = 0.0
Rules:
- A fresh top-level scalar assignment in
initintroduces persistent owner state. - Assigning to an already visible state symbol updates that state.
constdeclarations are allowed insideinit.- Declaration order is lexical.
- A fresh assignment inside nested control flow in
initis local to that flow, not persistent state.
A fresh binding directly inside init becomes persistent. A fresh binding
inside nested control flow is local to that flow:
params:
use_high_value: bool = false
init:
if use_high_value:
temporary = 2.0
else:
temporary = 1.0
carried = temporary
Both branches create the local temporary, so it is available after the
conditional. carried, introduced directly in init, becomes persistent
state. Reinitialization modes and pinned state are covered in
Reinitialization and Pinned State.
sample
sample is the per-sample executable scope. It is the most direct way to write
audio-rate code.
ins:
input
params:
gain = 0.5
outs:
output
sample:
output = input * gain
For every host sample, Onda reads input, multiplies it by the current control
value, and writes one sample to output.
Rules:
- Fresh assignments in
samplecreate locals. sampledoes not introduce new persistent owner state.returnis valid indefbodies, not in top-levelsample.- Input/output surfaces are available in
sample.
Oversampled sample
Once a normal sample block is clear, you can oversample it with sample N:.
ins 1
outs 1
params:
drive = 8.0
sample 4:
out1 = tanh(in1 * drive)
Rules:
- Supported factors are
1,2,4,8,16,32,64,128,256, and512. sample:is equivalent tosample 1:.- The factor can be any compile-time integer expression resolving to a supported factor.
- Audio input reads are interpolated across oversample substeps.
- Params are control-rate boundaries and are held within the base sample.
- Outputs are filtered and decimated back to the base rate.
SRinside oversampled code is the effective sample rate.HOST_SRand its aliases always mean the host sample rate.BSremains the logical host block size.
Generated signal code runs at the rate of the scope that evaluates it. For example, a host-rate oscillator feeding an oversampled distortion proc is evaluated once per host sample, then interpolated into the distortion proc. An oscillator evaluated inside the oversampled scope runs at the oversampled rate.
block
block runs once per logical audio block. It is useful when a value should be
computed once per block rather than once per sample.
params:
freq = 440.0 {20.0, 20000.0}
outs 1
init:
phase = 0.0
block:
increment = freq * TWO_PI / SR
sample:
out1 = sin(phase)
phase = phase + increment
if phase >= TWO_PI:
phase = phase - TWO_PI
You can think of a block with audio outputs as three regions:
- Block-pre statements before the nested
sample. - The nested per-sample
sample. - Block-post statements after the nested
sample.
Rules:
- With sample-rate outputs, a
blocksection must include a nestedsample. - Top-level statements before nested
sampleare block-pre code. - Statements after nested
sampleare block-post code. - Params and buffers are available throughout
block; sample-rate inputs and audio-output writes are available only inside its nestedsample. koutsare written in block-pre or block-post, never in the nestedsample.- Fresh top-level assignments in block-pre introduce block-carried owner state visible to later
sampleand block-post code. - Fresh top-level assignments in block-post are visible only after that point.
- Fresh nested assignments inside
if,for, andwhilestay local. blockandsampleare mutually exclusive withgraphin the same owner.
kouts programs and processors use block without a nested sample, because
control outputs are block-rate values.
4. Values and Runtime Code
Primitive types:
f32f64i32i64bool
Compound types:
| Type | Example | Notes |
|---|---|---|
| Fixed array | f32[8] |
Length is compile-time. |
| Slice | f32[] |
Read-only or writable view depending on source and call usage. |
| Tuple | (f32, i32) |
Anonymous fixed-length heterogeneous value. |
| Buffer | buffer<f32>, buffer<f32[2]>, buffer<f32[]> |
Host-bound external data. |
| Struct | Voice |
Nominal data type declared with struct. |
| Proc | Gain |
Stateful processing unit declared with proc. |
Numeric Literals and Casts
Numeric literals and pure numeric constant expressions begin without a source
machine width. During semantic analysis they retain the widest supported
literal representation until a concrete numeric context selects f32, f64,
i32, or i64.
A concrete context can come from an annotation, a function parameter or return
type, another concretely typed operand, an interface/state/array element type,
or generic specialization at a call site. Conversion happens once at that
boundary. Runtime arithmetic then executes at the selected width; Onda does not
silently evaluate an f32 expression through f64 intermediates.
When no context exists, first assignment uses Onda defaults:
sample:
x = 0.5 # f32
n = 5 # i32 when it fits, otherwise i64
m = -5 # i32 when it fits, otherwise i64
Unary minus preserves the selected numeric type: it works for f32, f64,
i32, and i64, including generic code specialized to those types. It is not
defined for bool.
Pure numeric expressions adapt directly to their surrounding context:
sample:
narrow: f32 = 0.0
wide: f64 = 0.0
a = narrow + 0.1 # f32 addition
b = wide + 0.1 # f64 addition
c = 0.1 # no context, so f32
Builtin constants such as TWO_PI have an f64 standalone type, but a pure
compile-time expression such as freq * TWO_PI / SR can convert directly into
an f32 context. This does not create an f64 runtime calculation followed by
an f32 truncation.
Use an explicit annotation or cast when wider runtime evaluation is intended:
sample:
narrow: f32 = 0.5
wide = f64(narrow) * 0.1
count = i64(0)
Builtin Constants and Functions
Builtin constants:
| Constant family | Names | Type |
|---|---|---|
| Pi | PI, pi |
f64 |
| Two pi | TWO_PI, TWOPI, two_pi, twopi |
f64 |
| Effective sample rate | SAMPLE_RATE, SAMPLERATE, SR, sample_rate, samplerate |
f32 |
| Host sample rate | HOST_SR, HOST_SAMPLE_RATE, HOST_SAMPLERATE, host_sample_rate, host_samplerate |
f32 |
| Block size | BLOCK_SIZE, BLOCKSIZE, BS, block_size, blocksize |
i32 |
Builtin functions include:
sin cos tan tanh atan atan2 exp log sqrt pow abs fabs
floor ceil round trunc min max fma
Assignment and Declarations
First assignment infers a type:
sample:
x = 0
y = 0.0
Explicit declarations pin the type:
sample:
x: i64 = 0
Assigning to an existing visible symbol updates it. Assigning to a new symbol introduces a symbol according to the storage rules of the current scope.
Compound assignment reads an existing value, applies an operator, and stores the result back into the same binding:
init:
phase = 0.0
remaining = 8
gain = 1.0
total = 4.0
index = 9
sample:
phase += 0.01
remaining -= 1
gain *= 0.5
total /= 2.0
index %= 8
out1 = phase
On the first sample these statements store 0.01, 7, 0.5, 2.0, and 1
respectively.
Arithmetic compound operators are +=, -=, *=, /=, and %=. Integer
bindings additionally support &=, |=, ^=, <<=, and >>=. Every writable
scalar place supports the matching compound operators, including array elements,
slice elements, buffer coordinates, and arbitrarily nested struct/array paths such
as state.phase[voice()].settings.gains[channel()] += amount. Place selectors are
evaluated exactly once, from left to right, before the right-hand side.
Integer locals, state, and scalar struct fields may carry a finite storage domain:
const RingSize = 1024
init:
bank = 0 {8}
taps: i32 = 0 {range = 0..128}
cursor: i32 = 0 {RingSize, wrap}
samples_seen: i64 = 0 {range = 0..=3999999999, mode = clamp}
Domains are supported for both i32 and i64, and every count or range endpoint must be an exact
compile-time integer expression. A single expression is a zero-based count: {1000} and
{count = 1000} both admit 0..1000, while {1000, wrap} uses the same count with wrapping
normalization. Counts must be positive.
An explicit range uses the same endpoint syntax as for: {begin..end} is half-open and
{begin..=end} is inclusive. The named forms are {range = begin..end} and
{range = begin..=end}. Half-open ranges require begin < end; inclusive ranges require
begin <= end. count and range are mutually exclusive, and a positional count or range must
precede named fields and mode. Integer binding domains do not use comma-separated endpoints.
{min, max} instead denotes an inclusive parameter domain inside top-level or processor
params. Top-level parameter domains also support step, scale, and presentation metadata
fields.
clamp is the default mode. wrap performs modular normalization across the same finite domain.
Bare clamp/wrap and the explicit mode = clamp/mode = wrap spellings are equivalent. An
omitted binding type defaults to i32, so both bank = 0 {8} and bank = selected {8} are i32
and follow the regular integer assignment rules. Use an explicit i64 annotation for an i64
ranged binding. General numeric clamping remains the job of clamp(value, lower, upper); binding
domains are integer storage invariants intended primarily for indices and wrapping cursors.
Initialization and every later direct or compound assignment normalize once as the value is stored:
cursor += 1 # wraps 1023 to 0
taps = 200 # clamps to 127
Reading a ranged binding produces an ordinary i32 or i64; arithmetic does not inherit its
storage mode. The compiler retains the numeric fact separately for the resulting value and carries
proven ranges through arithmetic, statically resolved value and read-only-reference arguments, and
scalar returns. All call sites must support an inferred parameter range; an unknown call site or
an uncontracted read-write reference makes it unknown. These facts remove index normalization and
bounds checks when the complete derived range fits a statically sized collection. This applies to
fixed arrays and other fixed-size indexed storage:
const TapCount = 8
init:
taps: f32[TapCount]
tap = 0 {TapCount, wrap}
sample:
out1 = taps[tap]
tap += 1
The access keeps ordinary clamped source semantics, but its selector normalization can disappear from generated code because the ranged binding proves the selector valid. Dynamic lengths generally still require ordinary runtime normalization unless the compiler can establish their bounds by other means. The physical representation and snapshot layout of a ranged binding remain the underlying integer type.
Operators
Supported operators:
| Category | Operators |
|---|---|
| Arithmetic | +, -, *, /, % |
| Comparisons | ==, !=, <, <=, >, >= |
| Logical | !, &&, || |
| Bitwise integer | ~, &, |, ^, <<, >> |
Bitwise operators accept i32 and i64. Mixed i32 and i64 operands widen
to i64. >> is an arithmetic right shift.
Expression precedence, from highest to lowest, is:
- Grouping, calls, indexing, and slicing
- Prefix
-,!,~ *,/,%+,-<<,>>==,!=,<,<=,>,>=&^|&&||
Infix operators at the same tier associate from left to right. Parentheses override this order.
Control Flow
Supported forms:
init:
taps = [0.1, 0.2, 0.3, 0.4]
sample:
x = in1
if x > 0.0:
magnitude = x
elif x < 0.0:
magnitude = -x
else:
magnitude = 0.0
sum = 0.0
for i in 0..4:
sum = sum + taps[i]
loop 4:
sum = sum + 0.1
while sum > 1.0:
sum = sum - 1.0
out1 = magnitude * sum
Explicit induction widths and descending steps are available when needed:
def sum_large_range() -> i64:
total: i64 = 0
for i: i64 in (i64(2147483648))..(i64(2147483650)):
total = total + i
return total
def descending_sum() -> i32:
total = 0
for i: i32 @ -1 in 10..0:
total = total + i
return total
Rules:
for i in A..BexcludesB;for i in A..=BincludesB.- Range bounds and
loopcounts accept ordinary expressions, including calls such asvalues.len(); parentheses are optional. - Loop variables default to
i32; annotate them asi32ori64withfor i: TYPE in ...when an explicit induction width is required. @ STEPdefaults to1;@ 0is invalid.- Descending loops use a negative step.
loop Nis shorthand forfor _ in 0..N.- Loop variables are immutable values local to the loop body. Assign a new local when an iteration-derived value needs to be changed.
- Fresh symbols created inside loops do not escape the loop.
- A fresh symbol created in every continuing branch of an
ifis available afterward. Numeric scalar and tuple-element types join to the smallest type that accepts every branch without narrowing (for example,i32withi64becomesi64, whilef32withi64becomesf64). - Branch-local arrays must have the same element type and fixed length.
Supported aggregate aliases must likewise have one compatible runtime
shape; a branch-dependent shape is a semantic error at the
if. breakandcontinueare supported in loops.returnis valid indefbodies, not in top-levelsample.
Basic Compile-Time Constants
Use const for compile-time values:
const MaxVoices = 8
const Hop: i32 = BLOCK_SIZE / 2
const Scale: f32[3] = [0.5, 1.0, 2.0]
const MoreScale: f32[] = [0.25, 0.5, 1.0, 2.0]
Rules:
const NAME = exprandconst NAME: T = exprare supported.exprmust be compile-time evaluable.- Primitive const arrays are supported at top level and namespace scope.
const NAME: T[N] = [ ... ]declares a fixed-size const array.const NAME: T[] = exprinfers the concrete array length from the initializer.- Const arrays are immutable. Their
.len()and compile-time-indexed elements are themselves available to compile-time expressions. - Inferred-length const array initializers can be literals, existing const arrays, const-array slices, or array-returning
const defcalls. - Untyped scalar const declarations remain contextual compile-time numerics and preserve the widest supported literal representation until each use site selects a concrete scalar type.
- A typed const fixes its scalar type at the declaration. An untyped pure numeric const may
specialize directly to
f32in one context andf64in another. - Once a numeric expression is concretely typed, every runtime operation uses that width and observes that type’s normal rounding semantics. Use an explicit cast to request wider evaluation.
- Reassignment, forward references, recursion, and mutual recursion are rejected.
- Scalar
constdeclarations are also valid inside runtime statement scopes and directly inside a proc. They are lexical compile-time names, not runtime storage. Const arrays remain limited to top-level and namespace scope.
5. Audio and Control Interfaces
The audio and control interface is the part of a processor that its host or parent processor can connect. Inputs carry samples into the processor, parameters carry control values, and outputs carry audio or block-rate values out.
Inputs
ins declares input ports. inputs is an alias.
ins:
in1
side: f64
stereo: f32[2]
These shorthand forms are alternatives:
ins 2
ins<f64>:
left
right
meter: f32
Rules:
- Omitted input types default to
f32, or to the section default inins<T>. ins Nexpands toin1..inN.Ncan be a compile-time integer expression.- If
inNis used without aninsblock, that input is implicitly created asf32. - If a count and explicit list are both present, they must match exactly.
- Scalar inputs can have defaults and ranges:
freq = 440.0 {20.0, 20000.0}. - A single range value only specifies the max range:
freq = 440.0 {20000.0}. - Fixed-size array inputs can have defaults, and array literal defaults must match the declared length.
- Inputs are read-only. A ranged top-level input is clamped once per sample before Onda code reads it; floating NaN maps to the range minimum.
Explicitly declared homogeneous inputs can be indexed:
const N = 4
ins N
outs N
sample:
for i in 0..N:
outs[i] = ins[i] * 0.5
ins[i] is 0-based and runtime indices are clamped. Implicit inputs created by
using in1, in2, and so on cannot be dynamically indexed.
Parameters
params declares host-visible control parameters.
params:
gain = 1.0
mode: i32 = 0
At the top level only, kins is an alias for params.
kins:
cutoff = 1200.0
resonance = 0.5
Rules:
- Omitted param types without defaults become
f32. - Omitted param types with defaults infer from the default.
gain = 0.5becomesf32;mode = 0becomesi32.- Scalar params and fixed-size top-level parameter arrays can have host-control domains.
- Proc parameter arrays support per-element numeric ranges and scalar or list defaults, using the same clamp-on-store rules as scalar proc params. Host-control attributes such as scale, curve, unit, and step remain top-level only.
- An array’s range, scale, curve, unit, and step apply independently to every element.
A scalar default fills the array; a list supplies one default per element and must match its length.
Arrays support
f32,f64,i32,i64, andbool. Boolean arrays use toggles without numeric ranges. params Nexpands toparam1..paramN; top-levelkins Nexpands tokin1..kinN.- Top-level code may declare either
paramsorkins, not both. - Top-level
paramNorkinNusage can implicitly create params up to that ordinal. - Top-level params are read-only to Onda code. Hosts update them; assignment is not a way to modify the host control value.
Host Control Domains
A parameter domain extends the existing range braces with scale, curve,
unit, and step. Positional fields remain ordered as
min, max, scale, unit, step; all fields may be named and named fields may
appear in any order. curve is named-only:
params:
cutoff = 440.0 {20, 20000, log, "Hz"}
resonance = 0.5 {0, 1, unit = "%"}
envelope = 0.5 {0, 1, curve = -4}
voices: i32 = 4 {min = 0, max = 16, step = 1}
gain = 1.0 {max = 2, scale = linear}
offsets: f32[4] = 0.0 {-2000, 2000, unit = "Hz"}
harmonics: i32[3] = [1, 2, 4] {1, 16}
enabled: bool[3] = true
Positional fields must precede named fields, fields cannot be repeated, and
{max} retains the existing maximum-only shorthand. scale defaults to
linear; the other optional fields default to absent.
scale, curve, unit, and step describe external control of explicit
top-level params (and their top-level kins alias). They are not available
on inputs or processor-local params, and do not change the Onda DSP
calculation:
linearmaps normalizedntomin + n * (max - min).logmaps it in logarithmic space toexp(log(min) + n * (log(max) - log(min)))and requires a floating parameter with0 < min < max.curve = capplies SuperCollider-stylelincurvecurvature to the normalized value before linear range mapping. For negativec, this isexpm1(c * n) / expm1(c); positive curves use its mirrored form. Negative values bend towardmax, positive values bend towardmin, and values withabs(c) < 0.001are linear. Unlikelog, curves support zero, negative, and zero-crossing ranges.unitis presentation metadata.stepmust be positive, must divide the range exactly, and requires the default to lie on the resulting grid. External plain and normalized writes are clamped and snapped to that grid.- Ranged
i32andi64params have an implicit step of1. - An
i64control domain and its range width must fit within[-9007199254740991, 9007199254740991], the integer range represented exactly by the shared host-control APIs. Unrangedi64params retain their full width through typed/raw parameter storage. - Logarithmic stepped domains are not supported.
curvemay be combined withstep, but not withscale = log.
The step count is the number of intervals from min to max and must fit the
host descriptor. Normalization, snapping, and units are host-boundary
semantics; Onda code reads the resulting plain parameter value.
onda run groups array elements under their logical name, with addresses such as offsets[0].
Each element can be edited, automated, and reset independently; Reset restores all declared
parameter defaults. CLI writes use the same addresses, for example --set 'offsets[1]=7'.
The range itself is also a DSP boundary invariant. Generated code clamps each
used ranged top-level parameter once at the start of init, once at the start
of each event invocation, and once at the start of each logical process block.
Every read in that entry point uses the resulting typed value. A floating NaN
maps to the range minimum; infinities clamp to the corresponding endpoint.
This protects raw parameter storage writes independently of any host-control
conversion.
Explicitly declared homogeneous params can be indexed directly:
params 4
sample:
out1 = params[0] + params[1]
params, kins, and dynamic param views are not first-class arrays. Use direct
params[i] or kins[i] access in block or sample code rather than assigning,
slicing, passing, returning, or storing the whole surface.
Outputs
outs declares sample-rate audio outputs. outputs is an alias.
outs:
out1
stereo: f32[2]
kouts declares block-rate control outputs.
kouts:
rms: f32
peak: f32
These shorthand forms can be combined when their generated names are disjoint:
outs 2
kouts<f32> 4
The default element type can instead be attached to an explicit section:
outs<f64>:
left
right
Rules:
- Omitted output types default to
f32, or to the section default. outs Nexpands toout1..outN;kouts Nexpands tokout1..koutN.- Using
outNwithout anoutsblock implicitly creates a sample-ratef32output. - Using
koutNwithout akoutsblock implicitly creates a block-ratef32control output. - Top-level
outsandkoutsnames must be disjoint. - Numbered
outNnames are audio outputs; usekoutNfor numbered control outputs. outs[i] = expris valid in sample-rate code when explicit outputs form one scalar type.kouts[i] = expris valid in block-rate code when explicit control outputs form one scalar type.- Dynamic output indices are 0-based and clamped.
- Current-owner outputs are write-only:
out1 = out1and readingstereo[0]are errors. A parent may read a child proc’s most recently produced output, such asvoice.out1. - Audio outputs can be written only in
sample; control outputs can be written only in block-pre or block-post. This timing rule also applies to named output arrays and theouts[i]/kouts[i]views.
6. Collections
Arrays and Slices
Fixed-size arrays can be state or locals:
init:
taps: f32[8] # eight zero-initialized elements
gains: f32[3] = [1.0, 0.5, 0.25] # explicit initializer
sample:
coeffs = [0.5, 0.25, 0.125]
out1 = coeffs[0] + gains[1]
name: T[N] allocates a fixed array and initializes each element with the type’s default value.
An explicit initializer must contain exactly N compatible elements. N is a compile-time integer
expression. The declaration syntax itself is the array constructor; T[N](...) is not an Onda
expression.
An untyped array assignment takes its element type from the first element
using the ordinary first-assignment defaults, then checks every remaining
element against that type. An array literal used directly as a call argument
can instead acquire its element type from the parameter context.
Array literals must contain at least one element and cannot provide backing
storage for an empty slice. Slice an existing array, such as values[:0], to
create an empty view.
Ordinary indexing is 0-based. Runtime selectors are normalized to the valid element range, while a
compile-time out-of-range const-array index is an error. .len() returns the fixed length for arrays
of primitives, structs, or procs, and the current length for a slice:
def sum(values: f32[]):
total = 0.0
for i in 0..values.len():
total += values[i]
return total
Primitive arrays use Python-style slice syntax. These examples slice the
persistent array created in init:
init:
values: f32[8]
sample:
all = values[:]
from_two = values[2:]
without_last = values[:-1]
middle = values[1:-2]
Rules:
- Slice forms are
a[:],a[start:],a[:end], anda[start:end]. - Negative bounds are supported.
- Slice expressions lower to primitive slice views of type
T[]. - Buffer slicing also yields
T[]. - Struct-element arrays follow the same slice rules; see Struct Arrays.
Writable slice assignment is statement-only:
init:
values: f32[8]
source: f32[8]
sample:
values[1:-1] = 0.5
values[:] = source[:]
Scalar fill writes the full target slice. Slice copy writes
min(dst_len, src_len) elements. Overlapping slice copies behave as if copied
through a temporary. Event payload arrays and slices are read-only.
Passing a mutable primitive array or slice to a def passes a view of its storage, so indexed or
slice writes in the callee update the caller’s array. Const arrays may be passed only when the full
callee chain is read-only. A fresh binding from an existing array or slice is a view alias, not a
deep copy; mutate array storage through indexed and slice assignments.
Tuples
Tuples are anonymous fixed-length heterogeneous values. Tuple values and tuple types use parentheses; destructuring targets conventionally do not.
def make_pair() -> (f32, i32): # tuple type
return (1.0, 42) # tuple value
sample:
value, count = make_pair() # destructuring targets
out1 = value + f32(count)
Rules:
- Tuple value syntax is
(value1, value2, ...); the parentheses are required. - Tuple type syntax is
(T1, T2, ...); the parentheses are required. - Tuple syntax requires at least two elements and does not accept a trailing comma.
- Maximum arity is 16.
- Nested tuples are not currently supported.
- Tuple element access uses compile-time integer indices.
- Tuple destructuring uses a bare comma-separated target list:
a, b = (10.0, 20.0). Parentheses around the targets are accepted, but the canonical style omits them. Use_to discard an element without creating a binding, for examplefirst, _, third = make_triple(). - Multi-output processor calls can be destructured directly; see Constructing and Calling Procs.
- Tuples can be locals,
initstate,defparams and returns, and struct fields. - A tuple binding keeps the arity and element types established by its declaration or first assignment. Reassignment accepts compatible values but never changes the binding’s type.
- Tuple parameters are mutable local values and follow the same reassignment rules.
- A fresh tuple assigned at the root of
initor before a block’ssamplesection is persistent state. Fresh tuples introduced in nested control flow are lexical locals.
Unchecked indexing is deliberately kept out of the normal collection workflow. Use ordinary indexing unless a proven hot path requires the escape hatch described in Unchecked Indexed Access.
7. Functions with def
def declares reusable runtime functions.
def wrap_phase(p, upper = TWO_PI):
if p > upper:
return p - upper
return p
Supported features:
- Positional arguments.
- Named arguments.
- Default values.
- Early return.
- Optional explicit return type annotations with
->. - Multi-line argument lists; every comma must be followed by another argument.
- Method-style sugar for ordinary defs:
x.clamp01()rewrites toclamp01(x). - Left-to-right argument evaluation, including named arguments.
Examples:
def wrap_phase(p, upper = TWO_PI) -> f32:
if p > upper:
return p - upper
return p
def pair(x: f32, y: i32) -> (f32, i32):
return (x, y)
Return rules:
- A
defcan return a primitive scalar. - A
defcan return a tuple of primitive scalars. - A runtime
defwith no explicit return type and noreturn EXPRis non-value-returning. It may use barereturnfor early exit and can only be called as a statement. - Bare and value returns cannot be mixed. A
defwith an explicit return type rejects barereturn. - A value-returning
defmust return a value on every reachable path. A return nested only in afororwhileloop is not sufficient because the loop may execute zero times. - Explicit annotations can use primitive scalars, primitive tuples, nominal structs, and fixed primitive or struct arrays, including resolved generic types.
- Struct and fixed-array returns capture independent contents. Unsized slices and buffers cannot be returned.
- Return checking follows ordinary assignment rules: exact match and implicit widening are allowed; narrowing requires an explicit cast.
- Runtime def call graphs must be acyclic. Direct and mutual recursion are rejected because they do not provide a statically bounded realtime workload.
const defremains value-returning and does not accept barereturn.
Top-level def bodies are lexical-local. Top-level runtime symbols such as
inputs, outputs, params, buffers, and init state are not in scope unless
passed explicitly.
Primitive scalar and tuple arguments are values. Arrays, slices, structs, procs, and buffers are reference-like arguments: the callee receives access to the original aggregate or resource, subject to its mutability and lifetime rules. This is why a def can update an array element or struct field without returning the aggregate.
Nominal data parameters cannot have defaults. A defaulted struct or struct-array parameter would need hidden backing storage despite the parameter being a reference to caller-owned data. Create the value explicitly at the call site instead:
struct Settings:
gain = 1.0
def apply(settings: Settings):
settings.gain *= 0.5
sample:
apply(Settings()) # Legal: explicit temporary storage lives through this call.
settings = Settings()
apply(settings)
out1 = settings.gain
Passing Settings() explicitly is different from declaring settings: Settings = Settings() in
the function signature. The call-site constructor is an expression with a clear owner and a
well-defined lifetime: the caller materializes it for the synchronous call. An omitted nominal
default would require the callee or dispatch machinery to invent hidden reference backing. Bind the
constructor to a name first only when its contents must remain observable after the call.
The same restriction applies to methods because self and any additional nominal data parameters
use the ordinary def calling convention. Scalar and tuple defaults remain values. Fixed primitive
array defaults retain their existing constant-array behavior; unsized arrays, buffers, procs,
structs, and fixed or unsized struct arrays do not support defaults.
Names declared as callables by an owner cannot be reused by value bindings in
that owner’s executable scopes. This includes defs, events, tasks, delegates,
and top-level processor and struct constructors. Function and event parameters,
when bindings, local constants, assignment and tuple bindings, and loop
variables all follow this rule. Receiver-qualified methods and callable names
brought into scope from another source file are not owner-local and may still be
shadowed by local values.
Overloads
Top-level defs and struct methods can be overloaded by arity and parameter types.
def sat(x: f32):
return x
def sat(x: f64):
return f32(x)
Resolution rules:
- Exact typed match wins first.
- If no exact typed match exists, numeric widening candidates may be used.
- Explicit typed params outrank generic or duck-typed params.
- Generic or duck-typed params outrank untyped params.
- Default arguments participate in overload matching.
- Return type is not part of overload selection.
- Equally valid candidates are a semantic error.
Proc-local defs are not overloadable. Runtime defs may still be generic with
syntax such as def id<T>(x: T) -> T; those generic defs are specialized from
their call sites.
The complete set of structural, array, buffer, tuple, struct, and proc parameter forms is collected under Advanced Function Parameter Kinds. Generic defs are introduced with the rest of Onda’s generic model in Compile-Time Programming and Generics.
8. Structs
struct declares nominal data types with fields and methods.
struct Voice:
phase: f32
sig: f32
def tick(self, hz):
self.phase = self.phase + hz * TWO_PI / SR
self.sig = sin(self.phase)
Supported features:
- Typed fields, inferred fields, and field defaults.
- Methods.
- Overloaded methods.
- Methods with their own generic type params.
- Tuple fields.
- Nested structs and fixed arrays of primitives or structs.
- Generic structs.
- Every struct must declare at least one data field. Methods do not count as data fields.
- Aggregate nesting is limited to 256 levels so malformed or impractically deep definitions are rejected without exhausting compiler resources.
- Resolved aggregate layouts may contain at most 65,536 expanded shape nodes across a program. Fixed array lengths remain tensor dimensions and do not increase this count.
- One aggregate may lower to at most 512 canonical primitive leaf tensors. Array extents remain within a tensor and do not increase this count. A lowered function may contain at most 1,024 positional parameters and 32,768 locals, including compiler-generated aggregate components.
Field declarations have three forms:
struct Voice:
phase # f32, default 0.0
active = false # bool inferred from the default
gain: f64 = 1.0 # explicit type and default
cursor: i32 {8, wrap} # ranged integer field; implicit default is 0
taps: f32[4] # fixed array, default-filled
A bare field defaults to f32. A field with = expr infers its type from that compile-time
default. Typed scalar and tuple fields accept compatible defaults. Array and nested-struct fields
are initialized from their type’s defaults; supply explicit values through constructor arguments.
An i32 or i64 field may use the same finite storage domain as an integer local or state binding. A ranged field without an explicit default uses the integer default 0. Constructor
arguments and every later field assignment are normalized on storage, and the compiler retains the
domain on flattened state and reference parameters for index-range proofs.
Construction:
init:
a = Voice()
b = Voice(0.25, true, 0.5)
c = Voice(gain = 0.75)
d: Voice
Rules:
selfmust be the first method parameter.- Methods can read and write struct fields through
self. - Call a method with
voice.tick(...); the equivalent explicit form isVoice.tick(voice, ...). - Constructor arguments bind fields positionally or by name. Omitted fields use their defaults.
- Struct constructors and typed struct declarations also work in runtime locals.
- Declaration-only form such as
d: Voicedesugars to default-constructor initialization. - For generic structs, typed declarations require explicit type args when the type is still generic.
- Generic struct type parameters may be used in tuple fields; every tuple element must resolve to a primitive type before specialization completes.
Struct arguments and new untyped aliases refer to existing storage. A typed declaration creates
independent storage; assigning an established struct copies its contents without redirecting it.
The storage-creating declaration must introduce a new name: annotating an existing alias is an
error, including for specialized generic types such as Box<f32>. Returning a fixed struct captures
independent contents, including when returning an argument.
Bind a returned aggregate to a name before indexing it or selecting its fields. Use intermediate
aliases for deeper selections.
| Form | Result |
|---|---|
alias = existing for a new name |
Alias the existing struct or fixed array. |
copy: T = existing |
Create independent fixed storage and copy the contents. |
existing = replacement |
Replace the contents without redirecting existing aliases. |
result = make_value() |
Own the fixed result returned by the helper. |
view: T[] = existing[start:end] |
Capture a slice view; no element storage is copied. |
Use an untyped binding when shared mutation is intentional, and a typed fixed declaration when later changes must be independent. Aggregate parameters are references to their caller’s storage; fixed aggregate returns are independent values.
struct Coefficients:
gain = 1.0
bias = 0.0
def prepare(gain) -> Coefficients:
return Coefficients(gain = gain)
def reset(value: Coefficients):
value = Coefficients()
init:
initial = prepare(0.5)
sample:
current = prepare(0.25)
alias = current
saved: Coefficients = current
reset(alias) # Replaces current; saved stays independent.
out1 = saved.gain + current.gain
These operations require the same nominal struct type. Tuple fields retain scalar-value semantics.
Constructor fields capture their initial contents in argument order, including named arguments.
Fixed primitive and struct arrays can also be returned (-> f32[4], -> Coefficients[4]),
independently initialized from a matching fixed array, and replaced through an existing fixed
binding. Fixed helper parameters retain their declared shapes for these operations. Helper write
permissions are inferred through aliases and transitive calls; independently copied data does
not require write access to its source.
Unsized slices cannot be returned. Init selections retain their captured storage selection until
initialization runs again. Block-owned data and selected views survive segmented processing,
intervening events, and snapshots, including inside procs. Selections are captured once in
block-pre; later changes to a selector do not redirect a view. Each proc instance owns its backing
storage and captured coordinates.
A typed slice initialized directly from fresh array data retains that backing for its declaration’s scope. Fresh init backing is reset by preserve-pinned initialization; explicitly owned pinned roots retain their contents. A view cannot itself be pinned. An alias does not extend the lifetime of an existing init-local owner; use a typed fixed declaration to retain independent contents. Views into external buffer memory cannot cross process boundaries. Structured-message delivery borrows its source only for synchronous dispatch; retain message contents by copying them into owned storage.
Struct Arrays
Fixed arrays of structs are initialized in the same three useful ways as individual structs:
struct Marker:
value: f32 = 1.0
init:
defaults: Marker[2]
listed: Marker[2] = [Marker(value = 2.0), Marker(value = 3.0)]
broadcast: Marker[2] = Marker(value = 4.0)
sample:
selected = listed[1]
selected.value += 0.5
out1 = selected.value + broadcast[0].value
These declarations also work in runtime scopes. Broadcast captures its initializer once and fills
independent elements. An explicit list must contain exactly the declared number of elements;
[Marker()] is a one-element list. Selecting an element produces an alias to that element, so the
compound assignment above updates listed[1].value. Runtime selectors are clamped just like
primitive-array selectors.
Primitive and struct slices can be explicitly typed. The annotation binds a view and requires an initializer. Bounds are captured when the view is selected, and aliases preserve source permissions.
sample:
markers: Marker[4] = Marker(value = 2.0)
selected: Marker[] = markers[1:]
markers[1:] = markers[:3] # Overlap-safe contents copy.
selected[:] = Marker(value = 5.0)
saved: Marker[2] = markers[-2:] # Exact length is proven at compile time.
out1 = saved[0].value
Slice assignment copies the fitting prefix and preserves the destination tail. Struct fill
captures its element once, including for an empty destination. Existing slice bindings cannot be
rebound; use view[:] = source to copy contents. Fixed initialization from a slice requires a proven
matching length and never adds a runtime shape assertion.
Indexed Struct-Array Field Access
For arrays of data structs, an element can be selected from any stable named path and then read or written through one inline field-access dot:
sample:
gain = voices[i].level
voices[i].level = gain * 0.5
tap = voices[i].taps[j]
velocity = current.notes[i].velocity
current.notes[i].velocity = velocity + 1.0
The same target resolution applies in every executable scope, including init, block, sample,
event, task, proc, and def bodies. Assignment targets may continue through any combination of
named fields and fixed-array indices after the first struct-array selection, for example
state.phases[i].settings.bands[j].gain += amount. The usual storage permissions and
rate-specific output rules still apply. Target selectors are evaluated exactly once, from left to
right, before the assigned value. Assigning the field itself uses the ordinary scalar, tuple, fixed-array, or struct
replacement rules. If the field is an array or tuple, an index after the field selects one of its
elements. For example, voices[i].taps[j] = 0.5 selects voice i, then element j of that voice’s
taps field. Fixed-array fields accept runtime numeric selectors. Tuple fields are heterogeneous,
so their selector must be a compile-time integer constant; the selected component keeps its own
declared type.
Common forms:
named.path[idx].fieldnamed.path[idx].field[element_idx]named.path[idx].field.other[element_idx]named.path[idx].field[element_idx].other[leaf_idx]
The path before [idx] can cross ordinary named struct fields, as in
current.notes[0].velocity. The index is what crosses the struct-array boundary; spelling
current.notes.velocity[0] is not the source-level structural model.
Expression reads still use one inline field-access dot. Use an intermediate alias when reading a deeper path:
sample:
v = voices[i]
gain = v.settings.level
The root must be stable named storage. A function-result temporary such as
current().notes[0].velocity cannot be borrowed for field selection; bind the returned struct
first. Constructor arguments are the intentional exception at call boundaries, so
apply(Settings()) is valid because the caller explicitly materializes the constructor result for
the duration of apply.
Proc arrays use their own indexed forms such as voices[i].gain,
voices[i](...), and voices[i].note_on(...).
9. External Buffers
Buffers are host-bound sample data. Unlike ordinary arrays, their storage and runtime dimensions come from the host, and rebinding remains visible to later processing calls.
Declarations and Channel Layout
buffers declares host-bound buffers. The section uses the same scalar and fixed-array type
spelling as the rest of Onda, but the brackets describe the channel layout of each buffer rather
than an Onda array value:
buffers:
src: buffer<f32>
bus: buffer<f32[2]>
any_bus: buffer<f32[]>
The short form is canonical inside a buffers block:
buffers:
mono: f32
stereo: f32[2]
dyn: f32[]
f32is a mono buffer.f32[2]is a buffer with exactly two channels.f32[]is a buffer with any positive runtime channel count.
The explicit buffer<...> form is mainly useful where a buffer is itself a type, such as a
function parameter. buffer<f32[]> is a channel-count wildcard: it accepts mono and exact-channel
buffers. An exact type such as buffer<f32[2]> does not accept a dynamic-channel buffer.
Collections and Shorthands
The following are alternative shorthand styles:
buffers 2
buffers<f32>:
delay
scratch
buffers:
piano: f32 {88}
stereo_layers: f32[2] {4}
named_count: f32 {count = 8}
{N} declares a fixed collection of N independently bound buffers. {count = N} is an optional
named spelling of the same declaration. The count belongs to the resource declaration, not its
element type: stereo_layers is four buffers, each with two channels. It does not introduce a
general multidimensional-array type.
Access and Metadata
Buffer access and metadata:
buffers:
src: f32
bus: f32[2]
piano: f32 {88}
stereo_layers: f32[2] {4}
sample:
mono0 = src[0]
left0 = bus[0, 0]
source_frames = src.len()
source_bound = src.bound()
bus_channels = bus.chans()
source_rate = src.samplerate()
key_count = piano.len()
middle_c_frames = piano[39].len()
middle_c_bound = piano[39].bound()
middle_c0 = piano[39][0]
right0 = stereo_layers[0][1, 0]
The access forms are deliberately limited to one coordinate pair per selected buffer:
| Declaration | Sample access | Slice access |
|---|---|---|
mono: f32 |
mono[frame] |
mono[start:end] |
stereo: f32[2] |
stereo[channel, frame] |
stereo[channel, start:end] |
bank: f32 {N} |
bank[slot][frame] |
bank[slot][start:end] |
layers: f32[2] {N} |
layers[slot][channel, frame] |
layers[slot][channel, start:end] |
bank[slot] and layers[slot] select a first-class buffer and can be passed to a function or used
for metadata queries. A channel alone is not a first-class view; use the channel-and-frame or
channel-and-slice forms above. The flattened layers[slot, channel, frame] form is not supported.
Aliases and Interpolation
A selected buffer can also be bound to an immutable, scoped reference alias:
buffers:
layers: f32[] {4}
block:
source = layers[0]
sample:
out1 = source.readL(0, 0.0)
The selector is evaluated once when the alias is bound. The alias retains resource identity rather
than a sample-data pointer: host descriptor rebinding remains visible on subsequent processing
calls. Buffer-reference aliases can be read, written, queried, used as method receivers, sliced,
and passed to buffer parameters. They cannot be rebound, returned, stored in arrays or structs, or
created in init. An alias introduced inside a conditional or loop is local to that control-flow
scope.
std/lookup provides readL/readC for clamped linear/cubic interpolation and readLW/readCW
for their wrap-aware counterparts. The wrapping variants interpolate across the final-to-first
frame boundary and are intended for cyclic tables and loopers.
Indexing and Binding Rules
All source-level coordinates clamp independently. In stereo[channel, frame], for example, the
channel clamps to the channel range and the frame clamps to the frame range before the address is
formed. Fixed buffer-collection selectors likewise clamp and select a descriptor in constant time.
The compiler removes that normalization when it can prove the complete coordinate range is valid.
Buffers also support the general unchecked indexed access operations. They make every supplied buffer coordinate an explicit programmer responsibility:
sample:
x = src.read_unsafe(0)
y = read_unsafe(bus, 1, 0)
src.write_unsafe(0, x)
write_unsafe(bus, 1, 0, y)
These coordinates are valid even for a neutral unbound buffer: every buffer
has at least one frame, and bus has exactly two channels. With dynamic
coordinates, the program must establish the same guarantees itself.
Free-call and receiver syntax are equivalent. A fixed buffer collection can either be accessed as
one unchecked operation (bank.read_unsafe(slot, frame)) or selected compositionally
(bank[slot].read_unsafe(frame)). In the latter form, bank[slot] retains ordinary clamped/proven
selection and only the frame (and channel, for a multichannel buffer) is unchecked. A buffer
reference alias made from a collection element supports the same receiver methods.
Rules:
buffers Nexpands tobuf1..bufN.- Explicit declarations and count shorthand cannot currently be mixed in one
buffersblock. .len()on a buffer collection returns its declared count. Select an element first to query its frame count:bank[i].len()..bound(),.chans(), and.samplerate()apply to a selected buffer, not to the collection..bound()reports whether that slot currently has a host binding. Exact channel counts are compile-time constants in generated code; dynamic counts come from the bound instance.- Runtime binding validates element type and channel constraints. Each fixed-array slot binds independently and may be omitted.
- Host metadata names physical collection slots
bank[0],bank[1], and so on, while separate collection metadata preserves the logicalbankname and its contiguous slot range. - An unbound slot is a neutral one-frame buffer: reads return the element type’s zero, writes are
discarded,
.len()is1,.samplerate()is the host sample rate, and.chans()is the exact declared channel count or1for a dynamic-channel declaration..bound()isfalse; all other valid bindings reporttrue. - Binding with a zero sample rate unbinds the buffer; the pointer and dimensions are ignored.
- Primitive buffer slices are supported with the same slice syntax as arrays.
10. Processors
proc is Onda’s reusable stateful processing unit. processor is an alias.
Everything in this chapter builds on the top-level sections introduced earlier,
but scoped to a reusable child processor.
proc Gain:
ins:
in1
params:
g = 1.0
outs:
out1
sample:
out1 = in1 * g
A proc uses the same const, ins, params, buffers, outs, kouts,
init, sample, and block forms as the top level. A processing proc has a
sample, block, or graph section. A proc may instead omit all three and
serve through its state, resources, events, delegates, tasks, and helper defs.
Such an event-only proc can be constructed and receive messages, but cannot be
stepped with instance(...). Any declared audio outputs start at their implicit
zero value.
Proc Inputs, Params, Outputs, and Buffers
Proc sections use the same surface syntax as the top level, with these differences:
kinsis not valid inside a proc; proc parameter sections are alwaysparams.- Proc-local scalar constants may be used by section counts, defaults, shapes, and executable code; proc-local const arrays are not supported.
- A processor declares either
outsorkouts, not both. koutsprocessors useblockwith no nestedsample, cannot declareins, and cannot declaregraph.- Proc constructor arguments for params and buffers are named-only.
- Proc inputs are bound by positional proc call args or named input args.
- Undeclared
paramNandkoutNuses infer numbered proc params and control outputs respectively; procs do not inferkinN. - A scalar proc buffer accepts one buffer or a selected collection slot, such as
clip = bank[2]. - A fixed proc buffer collection requires the same count. A larger collection can be passed only
through an exact, compile-time subspan such as
clips = bank[1:7]; both bounds are checked at compile time, and the descriptors are forwarded without copying their sample data.
proc StereoGain:
ins:
input: f32[2]
params:
gain: f32[2] = [1.0, 1.0]
outs:
out: f32[2]
sample:
out[0] = input[0] * gain[0]
out[1] = input[1] * gain[1]
Constructing and Calling Procs
Proc instances are usually created in init:
init:
g = Gain(g = 0.5)
A call steps the processor once. For a single-output proc, the call itself produces that output:
init:
g = Gain(g = 0.5)
sample:
out1 = g(in1)
Destructure a multi-output proc call to step it once and bind every output in declaration order:
sample:
out1, out2 = stereo(in1, in2)
The number of targets must exactly match the processor’s output count. This
form also works for nested processors and indexed processor arrays. A dynamic
array index is evaluated once; the selected processor is stepped once, then
its needed outputs are read. Use _ for an output that does not need a binding.
The following are alternative access forms, not a sequence to copy into one sample body:
g(in1).out1explicitly selects the output from the call.g.g = 0.25updates the stored proc parameter without stepping the proc.g.out1reads the most recently produced output without stepping the proc.
Rules:
- Positional proc call args bind inputs only.
- Named call args can bind inputs or params.
- Named param args store the clamped param value before the call runs.
- Every ranged proc-param write, including construction, builtin
init(...), named call arguments, and direct assignment, is clamped once before storage. Floating NaN maps to the range minimum; later reads use the stored typed value without reclamping. - Generic procs specialize on construction.
- Multiple proc calls in one expression are evaluated in source order.
- Named param args are not supported inside logical
&&/||expressions orwhileconditions. - For
koutsprocs, usekout1or named control outputs. - A sample-rate proc step may be called only from sample-rate code. A block-rate
koutsproc step may be called only from block code (or from a task, which advances at block rate). - A proc with no
sample,block, orgraphsection cannot be stepped, even if it declares outputs. It remains usable through its events and delegates. - Calling a child steps it. Reading
g.out1org.kout1without()returns that child’s most recently produced output and does not step it.
Proc-Local Defs
Processors can declare private helper defs that implicitly see proc state.
proc Filter:
ins 1
outs 1
init:
state = 0.0
coeff = 0.5
def apply(x: f32):
state = state + (x - state) * coeff
return state
sample:
out1 = apply(in1)
Rules:
- Proc-local defs are private to the enclosing proc.
- They can be called from proc
init,block,sample,events, and other proc-local defs. - They can read and write proc state directly, without
self. - They support params, defaults, named args, and returns like normal defs.
- Recursive and mutually recursive proc-local defs are rejected.
- Proc-local defs are not overloadable.
Private Params
Use private when a proc param should be initialized and updated only through
that proc’s controlled code path.
proc Filter:
params:
private cutoff = 1000.0
private q = 0.707
Private params:
- Can be set by the constructor.
- Can be set by the builtin proc
init(...)event. - Can be read or assigned by the owning proc’s own
init,sample,block,event, or proc-localdefbodies. - Cannot be accessed directly from outside through
child.cutoff,child.cutoff = ...,child.coeffs[i],child.coeffs[i] = ..., orchild(cutoff = ...). - Cause external dynamic
child.params[i]access to be rejected for that child proc.
private is a reserved keyword. It is only valid as a proc-param prefix.
Param Update Hooks
A primitive scalar proc param can bind a proc-local update hook with
=> hook_name.
proc Voice:
params:
freq = 440.0 {20.0, 20000.0} => update_freq
init:
phase_inc = 0.0
def update_freq():
phase_inc = freq / SR
sample:
out1 = 0.0
Hook rules:
- The hook target must be a zero-parameter proc-local
defin the same proc. - The hook must have no explicit return type and no
return. - Hooks run after the param store and range clamp.
- Construction and builtin
init(...)run hooks after the procinitbody, in param declaration order. - Hooks are immediate per-param reactions; they are not batched.
- Hooks may read owner params, update init-rooted state, and assign named params on child procs.
- Hooks cannot assign owner params, inputs, outputs, child proc I/O or internal state, child dynamic
params[i], or call child events. - If a proc has bound params, dynamic
params[i] = ...assignments are rejected; assign the named param instead.
Use hooks for single-param derived state. Use an explicit proc event or setter when several params should rebuild shared state once.
Proc Arrays
Arrays of proc instances are supported in init.
proc Voice:
params:
level = 0.0
sample:
out1 = level
params:
selected: i32 = 0 {0, 3}
init:
voices: Voice[4] = Voice()
sample:
out1 = voices[selected](level = 0.5)
Supported forms:
- Literal array construction:
voices: Voice[2] = [Voice(), Voice()]. - Broadcast constructor sugar:
voices: Voice[4] = Voice(). - Compile-time capacity expressions in the array length.
The selected Voice is stepped once and produces 0.5. Other indexed forms
include:
sample:
voices[i](freq)
out1 = voices[i].out1
voices[i].gain = 0.5
voices[i].note_on(220.0)
This second fence is a syntax summary: it assumes i, freq, and the shown
members are declared by the surrounding program.
Rules:
- Runtime indices are clamped to the valid slot range.
- Aliasing such as
v = voices[i], thenv(...), is supported. - Proc-array buffer refs resolve through the current validated buffer tables.
- A proc cannot directly instantiate its own type in its own state.
If the proc defines a block section, indexed proc-array calls use active-slot
block-hook semantics: block-pre runs lazily on the first () call to that slot
in the current block, and block-post runs once at block end for each called slot.
Plain slot retrieval does not trigger hooks.
11. Events, Printing, and Delegates
Events carry commands inward, while delegates and printing carry sparse occurrences outward. All of them execute synchronously on the processing thread; they are not background callbacks.
Top-Level Events
Top-level events are host-triggered handlers that run against an initialized instance. They are useful for musical gestures, one-shot control changes, and stateful commands.
init:
freq_state = 440.0
amp_state = 0.0
gate = false
events:
note_on(freq_hz = 440.0, amp = 1.0):
freq_state = freq_hz
amp_state = amp
gate = true
note_off():
gate = false
Singular event sugar:
event bang():
gate = true
This is equivalent to an events: block with one event. Singular event ...
declarations and an events: block can be mixed in the same owner.
Supported top-level event parameter types:
- Primitive scalars.
- Primitive-only tuples and nominal structs, including nested fixed fields.
- Fixed-size primitive or struct arrays:
T[N]. - Primitive or struct slices:
T[].
Rules:
- Event params without explicit types default to
f32. - Constant defaults work for scalars, tuples, and fixed primitive arrays. Structs and struct arrays are borrowed payloads and cannot have parameter defaults. A no-argument event may construct and forward an explicitly owned default value when that API is useful.
- All payload parameters are read-only. Aggregates are live views for synchronous dispatch. A typed fixed-data declaration captures independent contents when needed.
- Top-level events run immediately on the audio thread.
- Handlers cannot write inputs, outputs, or top-level params.
- Handlers can read, write, and query declared buffers using the instance’s current bindings.
- Aside from declared buffers, top-level handlers may write only existing top-level state rooted in
init. - Unknown top-level event indices are ignored at runtime.
- A known top-level event with the wrong payload size is a runtime error.
- Hosts encode logical values using the recursive message schema. Struct arrays use canonical structure-of-arrays tensors; a struct slice has one logical length followed by its field tensors.
Structured Events and Delegates
Events and delegates can carry a complete nominal object instead of forcing an API to flatten it
into parallel scalar parameters. Nested structs, tuples, fixed primitive arrays, fixed struct
arrays, and struct slices retain their declared shape across top-level events, proc events,
delegates, and when bindings.
struct Note:
frequency = 440.0
velocity = 1.0
struct Patch:
notes: Note[4]
transpose: f32 = 0.0
proc VoiceBank:
init:
current: Patch
delegate configured(patch: Patch)
event configure(patch: Patch):
current = patch # Copy into proc-owned state.
configured(current) # Borrow that state while dispatching.
sample:
out1 = current.notes[0].velocity
init:
bank = VoiceBank()
observed: Patch
when bank.configured(patch):
observed = patch # Retain independent contents after dispatch.
event configure(patch: Patch):
bank.configure(patch) # Forward the same read-only payload view.
event reset_bank():
defaults: Patch
bank.configure(defaults) # Explicit storage replaces a parameter default.
sample:
out1 = bank() + observed.transpose
Without structured payloads, Patch would have to be exposed as separate arrays and scalars, with
the caller, event, proc, delegate, and subscriber independently agreeing on their lengths and field
ordering. The nominal type now carries that contract end to end. Adding a nested field updates one
type and its recursive host schema instead of multiplying parallel parameters throughout the API.
The ownership rules are intentionally visible:
- A top-level host event supplies one logical object or array matching the published recursive schema. Integer field domains are normalized before the handler observes them.
- Event, delegate, and
whenaggregate bindings are read-only references valid for that synchronous dispatch. Forwarding them does not copy every element. - Assignment into an already declared struct or fixed array copies contents and preserves the destination’s identity. This is how a handler retains a payload in state.
local = payloadcreates another alias; it does not retain the data. Uselocal: Patch = payloadfor an independent local copy.- A struct slice carries its runtime length and field views. A fixed struct array additionally requires the exact declared length. Neither may have a parameter default.
- Aggregate parameters are required at every call site. If an API needs default behavior, expose a
separate no-argument event or helper that constructs named local storage, as
reset_bankdoes. - Host output records snapshot delegate contents when published, even though in-language forwarding uses live synchronous views.
Proc Events
Proc events are receiver-only commands called on a proc instance. They are useful for reset, note, trigger, and setter style APIs.
proc Env:
params:
amp = 0.0
event note_on(v: f32):
amp = v
sample:
out1 = amp
Proc-event rules:
- Calls use receiver syntax such as
voice.note_on(...). - Proc-event calls are statements, not expressions.
- Unqualified calls never resolve to proc events.
- A proc cannot call its own event handler as an internal subroutine; put shared logic in a proc-local
def. - Proc handlers may write proc state rooted in
initand proc params. - Proc handlers can read, write, and query their declared buffers using the instance’s current bindings.
- Proc handlers cannot write inputs or outputs.
- Generic proc events can use generic primitive placeholders such as
T,T[N], andT[]. - Struct and struct-array arguments are required and use the structured borrowing rules above.
Every proc also gets a reserved builtin init(...) event. It mirrors the proc
params in declaration order and adds full: bool = false, assigns
provided values into params, reruns that proc instance’s init, then runs bound
param hooks. Omitted args use defaults. The call forwards the proc instance’s
current buffer bindings, so it can explicitly refresh state derived from a
buffer that the host rebound after construction.
By default the call preserves pinned roots while reinitializing resettable
roots. Passing full = true performs the full initialization used by fresh proc
construction. The effect on task continuations is covered in
Tasks, and both initialization modes are detailed in
Reinitialization and Pinned State.
voice.init(0.5)
voice.init(gain = 0.5)
voices[i].init(freq = 220.0, amp = 0.1)
voice.init(full = true)
These call forms assume the corresponding voice or voices instance was
constructed in the surrounding program.
full is reserved as a proc parameter name.
Printing
print is a compiler-known runtime statement. It accepts an optional leading quoted label followed
by zero or more primitive scalar values:
init:
phase = 0.0
print("ready")
event report():
print("phase", phase)
sample:
phase = phase + 0.001
out1 = sin(phase)
Here ready is emitted during initialization. The host-triggered report
event prints the current persistent phase without producing one log occurrence
for every audio sample.
The printable types are exactly f32, f64, i32, i64, and bool. Aggregates, buffers, and
processor values are rejected; print their scalar members or metadata explicitly. The label is
compile-time text rather than an Onda string value. It supports \", \\, \n, \r, and \t
escapes.
Pure numeric literals use the ordinary unconstrained defaults in this statement: print(3) records
an i32, while print(3.0) records an f32. Explicit constructors select i64 or f64, and
already-typed expressions retain their type.
Each execution produces one ordered occurrence. Canonical host text renders a labelled occurrence
as label: value1 value2, joins unlabelled values with one space, and terminates every occurrence
with a newline. Label control characters are escaped in that text so an occurrence always occupies
one physical line. Integer formatting is exact, including i64; floating-point formatting is the
shortest width-correct round-trippable representation, with .0 retained for integral values.
print is valid in authored runtime statement scopes, but it is invalid in compile-time
declarations, const def bodies, expressions, graphs, and declaration names. Arguments are always
evaluated in source order even when the host elects not to collect print output.
Delegates and when
Delegates report sparse typed occurrences in the opposite direction from events. An owner declares them with singular or plural syntax and triggers its own delegate with an ordinary statement call:
init:
last_reason: i32 = 0
delegates:
stopped(reason: i32)
event stop(reason: i32):
stopped(reason)
when stopped(reason):
last_reason = reason
Delegate parameters have the same scalar, tuple, struct, fixed-array, slice, generic specialization,
and argument-binding rules as event parameters; an omitted type defaults to f32. Defaults are
limited to scalar, tuple, and fixed primitive-array values, so every struct or struct-array argument
must be supplied by the emitting call. Delegate calls have no result. They are valid in
sample, structured block code, tasks, event and when handlers, and owner-local runtime defs.
They are invalid in init and in runtime defs reachable from init. Only the declaring owner can
call a delegate; child.finished() is not a callable surface.
when installs a static synchronous subscription. It can observe the current owner or one direct
child ownership layer:
proc Envelope:
delegate finished(reason: i32)
event stop(reason: i32):
finished(reason)
sample:
out1 = 0.0
init:
child_reason: i32 = 0
env = Envelope()
when env.finished(reason):
child_reason = reason
sample:
out1 = env()
A subscription can also target a compile-time-selected proc-array element or a whole fixed proc array:
when voices[0].finished(reason):
first_reason = reason
when voices.finished(index, reason):
voice_reason = reason
A selected proc-array index must be a compile-time constant. A whole fixed proc array adds a
leading inferred i32 element index; use _ to ignore it. Bindings are read-only and otherwise
follow event-handler scope rules. Handlers run immediately in declaration order. Nested delegate
calls are depth-first, and recursive event/delegate dispatch is rejected.
Top-level occurrences are returned to the host to process, and their resolution is at the block level. Currently, delegates are sample-accurate only within Onda via procs.
12. Tasks
The top-level program and procs can declare statically allocated cooperative tasks. They spread divisible preparation work, such as lookup-table construction, over multiple logical blocks without dynamic allocation or worker threads.
init:
table: f32[8]
task prepare():
for i in 0..4:
table[i] = f32(i) / 8.0
yield
for i in 4..8:
table[i] = f32(i) / 8.0
block:
await prepare()
sample:
out1 = table[7]
On the first block, prepare fills half the table and yields, so the block
produces neutral output. On the next block it resumes, finishes the table, and
processing continues after await. Tasks use the same syntax inside a proc.
Several declarations can be grouped under tasks:; the standalone and grouped
forms are equivalent.
Tasks take no arguments and return no values. They implicitly see their
owner’s params, buffers, and init-rooted state, but cannot directly read audio
inputs, write owner outputs, invoke their owner’s event handlers, or invoke
other tasks. They may call builtins and non-yielding defs visible from the
owner, call block-rate processor steps, and synchronously call child-proc
events, including the child’s builtin init(...) event. Sample-rate processor
steps remain sample-only and therefore cannot be called by a task, directly or
through a def.
yield suspends the current task; bare return or reaching the end completes
it. A task runs synchronously on the process thread until one of those points;
yield is a cooperative boundary, not a time budget or preemption point.
await and Scheduling
The owner advances a task with await from block-pre control flow, as in the
complete example above.
If the task yields or has failed, the owner stops that activation and produces
neutral outputs. For a top-level task those are the program outputs; for a proc
task only that proc becomes neutral and its parent continues normally. If the
task completes, execution continues after await in the same logical block. A
task is reset explicitly with prepare.reset() from the owner’s init, event,
block-pre, sample, or block-post scope.
The containing block is not a coroutine. Its block-pre control flow starts from
the beginning on each activation, so statements and conditions before an
await are evaluated again. Completed tasks fall through without rerunning;
the first reached task that yields stops the activation. An incomplete task has
no effect when ordinary control flow bypasses its await, leaving the program
responsible for not exposing partially prepared state.
Each proc instance, including each element of a proc array, owns independent
task continuations. An explicitly called block-rate proc runs its block body,
including any reached await, on every call just like other block-rate proc
code. Sample-rate proc instances scheduled statically run their block-pre
activation at most once at logical-block begin; a runtime-indexed proc-array
element runs it lazily on its first sample-rate call in that logical block.
Splitting a logical block into process segments never grants additional
resumptions to those scheduled activations, and a zero-frame begin-block
segment still advances statically scheduled tasks.
Continuations, Reset, and Reinitialization
Task continuations are compiler-pinned state. Preserve-pinned initialization preserves
them. Proc init(full = true) and host-level init(FULL) restore them to
not-started. An explicit prepare.reset() in an initializer always runs. Tasks
may use both pinned and resettable state; after default initialization, a
suspended task observes the reinitialized resettable values when it resumes.
Snapshots include task status and continuation storage, so restoring a suspended
task resumes it from the captured suspension point.
reset() invalidates the continuation in constant time. It does not eagerly
clear task-frame storage: restarting the task executes its declarations and
initializers before that storage can be observed again. Full initialization
still initializes the complete continuation image.
Locals that are live across a yield, including fixed structs, arrays, and loop
control, become statically allocated continuation state. Data views may cross yield
when their backing storage survives suspension: the continuation retains the selected
coordinates and branch choice, then reconstructs access on resumption. This includes
views into task-owned data, owner state, and persistent init selections. A typed slice
initialized from fresh array data retains its backing through suspension.
Views into external buffer memory cannot cross yield, because the buffer may be
rebound before resumption. Other resource handles retain their existing restrictions;
they must be dead at yield and reacquired after resumption. The compiler rejects
references only when they are live across the boundary.
Tasks read owner params and current buffer mappings whenever they resume.
Changing a parameter or rebinding a buffer does not reset a task automatically;
the program must call reset() when previously prepared or partially prepared
state is no longer valid.
A runtime failure reports through the failing process call and invalidates the processor state. Hosts must emit silence and reject further stateful operations until full initialization or snapshot restoration succeeds. This is the same fail-closed behavior as a runtime failure outside a task.
Tasks are private to their owner and share that owner’s declaration namespace.
They cannot be used with a graph block. await is valid only in structured
block-pre control flow; task reset is valid throughout the owner’s executable
code, but not inside tasks or defs. Neither operation is a first-class callable
value.
13. Graphs
graph gives you a declarative way to wire processor instances and signal flow.
proc GainProc:
ins 1
params:
gain = 1.0
outs 1
sample:
out1 = in1 * gain
ins 1
outs 1
init:
p = GainProc()
graph:
in1 >> p.in1
3.0 >> p.gain
p.out1 >> out1
import std/osc
params:
freq = 220.0 {20.0, 20000.0}
mod = 100.0 {0.0, 1000.0}
outs 1
init:
sine = std::osc::Sine()
graph:
@sample freq + sine.out1 * mod >> sine.freq
sine.out1 >> out1
Supported edge forms:
src >> dst
dst << src
@block src >> dst
@sample src >> dst
src >>[expr] dst
src >> { a, b }
{ a, b } << src
Rules:
graphis mutually exclusive withsampleandblockin the same owner.initmay be used withgraph.- Proc instances used as graph nodes are typically created in
init. - Unannotated edges targeting proc params default to
@block. - Unannotated edges targeting other destinations default to
@sample. @samplecan override the default@blockbehavior for proc param destinations.- Delayed edges use
>>[expr]or<<[expr]. - Delay expressions must be compile-time nonnegative integers.
- Delayed edges are sample-rate only.
- Each destination has one writer.
- Fan-out is allowed.
- Cycles are rejected unless a positive sample delay breaks the cycle.
- Proc nodes are stepped implicitly according to graph reachability and topological order.
- Inspect lowering with
onda compile <file> --dump-graph.
Current graph sources include:
- Top-level inputs and params.
- Proc outputs.
- Proc-array slot outputs.
- Array literals such as
[a, b]. - Indexed reads, sliced reads, and whole-array reads.
- Arithmetic and logical expressions built from supported graph sources.
- Element-wise array expressions when the final shape matches the destination.
Current legal destinations include:
- Top-level outputs.
- Proc inputs.
- Proc params.
- Proc-array slot inputs and params.
Proc-array slots use the same fixed-array port shapes as ordinary proc instances. The processor index follows the instance name; an optional second index selects one element of an output array:
graph:
stereo_in >> processors[1].input
processors[1].output >> stereo_out
processors[1].output[0] >> left_out
processors[1].out1 >> monitor
processors[1].output reads the complete cached output array after that slot has been stepped.
Fan-out reads the same cached result and does not step the processor again. A whole fixed input array
must match the destination port shape; scalar broadcast follows the ordinary graph rules.
Type and scheduling rules:
- Graph edges use strict shape matching.
- Scalar-to-fixed-array broadcast is allowed.
- Proc inputs, params, and outputs are legal graph endpoints.
- Bare proc instances and proc-array slots can route into destination sets.
- Destination sets zip by output order when counts match.
- Single-output procs broadcast to destination sets.
- Otherwise, mismatched bundles are semantic errors.
Current graph limits:
- User-defined function calls and proc calls are not supported inside graph source expressions.
- Typed array declarations are statements rather than expressions and therefore cannot be graph sources; use an array literal or an existing array value.
- Top-level
koutsand block-rate proc outputs are not supported bygraph. - Graph event propagation syntax does not exist; use ordinary
eventsoreventdeclarations.
Given previously constructed reverb and voices nodes, proc bundles use the
same routing syntax:
graph:
reverb >> { out1, out2 }
voices[0] >> { left, right }
14. Compile-Time Programming and Generics
Basic const declarations were introduced with runtime values. This chapter
covers host-selected configuration, compile-time helper functions, and the
generic specialization mechanisms shared by defs, structs, and processors.
Onda specializes explicit type parameters on defs, structs, and procs. Runtime defs can also specialize structurally from untyped parameters. Integer namespace parameters, used for counts and shapes, are introduced with namespaces in the next chapter.
Configuration Constants and const def
Use config const for the explicitly typed subset of root constants that a host may select for one
compilation:
config const Channels: i32 = 2
config const Enabled: bool = true
config const Coefficients: f32[Channels] = [0.5, 1.0]
config const Window: f64[] = [0.0, 0.5, 1.0]
const SampleCount: i32 = Channels * BLOCK_SIZE
- Every
config constrequires an explicit type. It supports exactly the same value types as a typed ordinary const:bool,i32,i64,f32, orf64, and fixed or inferred-length primitive const arrays. - Configuration constants are allowed only at the executable root. The entry file and its
includefiles share that root; declaration modules loaded withimportcannot declare them. - A host override replaces only that declaration’s initializer for one compilation. Derived constants, shapes, specialization, assertions, and generated code are recomputed normally.
- Fixed array lengths are resolved from the complete selected configuration. If
Channelsabove changes,Coefficients—whether supplied by the host or evaluated from its default—must have the new length or compilation fails. - Ordinary constants cannot be overridden. With no host input, the source initializer is used.
The native CLI accepts repeatable --const Name=value inputs using Onda literal syntax and
--list-consts prints the resolved configuration surface. An .ondaproject may provide defaults
in its constants map; explicit CLI inputs override matching project values.
const def declares compile-time helper functions:
const def ramp() -> f32[4]:
values: f32[4]
for i in 0..4:
values[i] = f32(i) * 0.25
return values
const Ramp: f32[4] = ramp()
const def rules:
- Every
const defmust declare an explicit return type. - Params support primitive scalars, fixed-size primitive arrays, typed primitive slices such as
f32[], and untyped slices[]. - Typed slice params accept compile-time arrays of any positive length with the matching element type.
- Untyped slice params accept compile-time arrays of any positive length and primitive element type.
- Slice params support indexed reads and
.len(), but not indexed writes. - Array-returning bodies can use local fixed primitive arrays, indexed local-array reads/writes,
if,for,loop,return, pure builtin math, and calls to earlier visible const defs. - Compile-time loop evaluation is capped at 1,000,000 iterations per loop.
- Scalar-returning const defs can be used by scalar const declarations.
- Fixed-array-returning const defs can be used by const array declarations.
Const arrays and const slices can be passed to ordinary runtime def array
params when the callee treats the param as read-only. Writes through the param,
aliases or forwarding to a mutable callee make the param
mutable and reject const-array arguments.
Generic Defs
Runtime defs can declare type parameters:
def id<T>(x: T) -> T:
return x
def pair<T>(x: T, y: i32) -> (T, i32):
return (x, y)
The compiler monomorphizes generic defs from their call sites. Type arguments can often be inferred:
sample:
a = id(0.5) # T inferred as f32
b = id<f64>(1.0) # T provided explicitly
Rules:
- Generic def type args are restricted to
f32,f64,i32, andi64. boolis not allowed as a generic def type arg.- A type param not constrained by any call argument defaults to
f32; for example,zero<T>()called aszero()specializesTtof32. - Generic type params can appear in scalar params, array params, buffer element params, locals, casts, and supported return annotations.
const defcannot declare type parameters.
Type Generics
Generic structs and procs are monomorphized. The compiler creates a concrete specialized copy for each type combination your program uses.
struct Pair<T>:
a: T
b: T
proc OnePole<T>:
ins<T> 1
outs<T> 1
init:
state: T = 0.0
sample:
state = state + (in1 - state) * 0.1
out1 = state
Specialization:
init:
a = Pair<f32>()
b = Pair<f64>()
lp = OnePole() # unresolved constructor type params default to f32
hp = OnePole<f64>()
Rules:
- Generic type args are restricted to
f32,f64,i32, andi64. boolis not allowed as a generic type arg.- Unresolved generic type params in declaration and type positions are errors.
- For untyped constructor assignments only, unresolved constructor type params default to
f32. T(expr)rewrites to the bound primitive cast.T[]is valid for method anddefarray params where a primitive slice is valid.- Typed generic locals such as
x: T = ...are supported in executable scopes.
Structural Def Specialization
Runtime defs are specialized from call sites. This applies both to explicit
generic defs such as def id<T>(x: T) -> T and to polymorphic parameter shapes
that do not need a named type parameter:
- Untyped scalar params such as
value, specialized to the concrete primitive type at each call site (includingbool). Pure numeric expressions use the ordinary untypedf32/i32defaults, while explicit casts preserve their requested numeric type. - Untyped arrays such as
arr: []. - Bare buffers such as
buf: buffer. - Generic struct and proc params supplied by concrete arguments.
- Unsized processor-array params such as
voices: Voice[]; specialization records the concrete capacity supplied at each call site. A fixedvoices: Voice[N]parameter already has a complete source-level ABI. - Untyped tuple params inferred from tuple literals.
- Untyped structural params inferred from field or method usage.
def first(arr: []):
return arr[0]
def id<T>(x: T) -> T:
return x
sample:
a = [1.0, 2.0]
b = [1, 2]
x = first(a)
y = first(b)
z = id<f64>(1.0)
15. Modules, Namespaces, and use
Imports
Use import to load another module:
import reverb
import std/osc
import std/filter
Rules:
import module/pathresolves asmodule/path.onda.- Each imported file is imported once.
- Built-in std modules are available under
std/.... - Imported files are declaration-only:
const,struct,def,proc,namespace, anduse. std/preludeis auto-imported during semantic analysis.
Current std modules include:
std/prelude std/math std/random std/complex
std/osc std/filter std/env std/dynamics std/delay std/sample std/reverb std/pitch_shift
std/data std/lookup std/fft std/convolution std/gain std/levels std/mix
std/noise std/pitch std/smoothing
std/prelude currently imports std/math, std/lookup, and std/random.
Includes
include inserts another source file by quoted path:
include "shared/reverb.onda"
include "shared/util.on"
Rules:
- The path must be quoted.
- The path must end in
.ondaor.on. - Use
/path separators.
Namespaces
namespace groups declarations under a qualified path.
namespace my::dsp:
def sat(x):
return clamp(x, -1.0, 1.0)
ins 1
outs 1
sample:
out1 = my::dsp::sat(in1)
Namespace-local consts and nested namespaces are supported:
namespace Config:
const MaxVoices = 8
Integer Namespace Params
Namespace template params are compile-time integers.
namespace DSP<Channels = 2>:
proc Gain<T>:
ins<T> Channels
outs<T> Channels
params:
gains: T[Channels]
sample:
for i in 0..Channels:
outs[i] = ins[i] * gains[i]
Use namespace integer params in:
- Fixed array sizes such as
T[N]. - Section counts such as
ins N,outs N,params N, andbuffers N. - Loop bounds.
- Compile-time expressions and namespace
assert(...)checks.
Instantiate inline or through aliases:
namespace Stereo = DSP<2>
init:
g = Stereo::Gain<f32>(gains = [0.5, 0.25])
Rules:
- Namespace template params require defaults.
- Args support positional and named forms.
- Args are normalized as
i32(...)at compile time. - Namespace-local
assert(expr)performs compile-time checks. <>is used for namespace instantiation, generic specialization, and section default type modifiers.[]is used for arrays, indexing, slices, and buffer/channel forms.
Namespace aliases may retain compile-time arguments:
namespace D = std::data<SR, 1>
init:
a = std::data<SR, 1>::Data<f64>()
b = D::Data<f64>()
Use Declarations
use brings namespace members into unqualified lookup. It does not load
modules; use import first when the target lives in another module.
import std/math
import std/random
import std/fft
use std::math
use std::random::Rng
use std::fft<512> as fft512
use std::fft<1024>::FFT as FFT1024
pub use std::lookup
ins 1
outs 1
init:
rng = Rng<f32>()
a = fft512::FFT<f32>()
b = FFT1024<f32>()
sample:
out1 = clamp(in1, -1.0, 1.0)
Forms:
use Namespacebrings direct declarations in that namespace into unqualified lookup.use Namespace::Symbolbrings one declaration into unqualified lookup.use Namespace as Aliascreates a namespace alias.use Namespace::Symbol as Aliascreates a symbol alias.pub use ...re-exports the use declaration through imports.
Rules:
asapplies only to the wholeusedeclaration.useis allowed at top level and insidenamespace.- Plain top-level
useis private to the source file where it appears. - Imported files expose only
pub usedeclarations to the importing file. - Fully qualified paths always work.
- Explicit
usecollisions are errors at unqualified use sites; qualify the name to disambiguate.
16. Reference Notes
Top-Level Forms
| Form | Purpose |
|---|---|
ins, inputs |
Host input ports. |
params, kins |
Host-visible parameters. |
outs, outputs |
Audio-rate output ports. |
kouts |
Block-rate control output ports. |
buffers |
External host-bound buffers. |
events, event |
Host-triggered event handlers. |
delegates, delegate |
Typed occurrences reported to an owner or host. |
when |
Static synchronous delegate subscription. |
print(...) |
Publish bounded host-facing diagnostic output from runtime code. |
init |
Setup and persistent state. |
block |
Per-block code. |
sample |
Per-sample code. |
graph |
Declarative signal routing. |
task, tasks |
Statically allocated cooperative preparation work. |
const, config const, const def |
Compile-time values, host-selected compile inputs, and helpers. |
def |
Runtime helper functions. |
struct |
Nominal data types. |
proc, processor |
Reusable DSP processors. |
top-level proc Main |
Optional non-generic wrapper for the entry file’s top-level program. |
namespace |
Qualified declaration groups and integer templates. |
use, pub use |
Unqualified lookup imports and re-exports. |
Reinitialization and Pinned State
The basic init lifecycle is described in Execution and State.
The following rules matter to hosts that explicitly reinitialize an existing
instance or preserve expensive state.
Host init(PRESERVE_PINNED) preserves pinned roots and task continuations while
rerunning ordinary declaration initializers and every explicit init statement.
Host init(FULL) also reruns the declaration initializers for pinned roots and
task continuations, and is required before stateful operations on an
uninitialized instance. Both modes execute directly against the instance’s
single state image and allocate no memory on the successful path. Their
execution cost depends on the authored initializer; a runtime failure leaves
instance state indeterminate. Initialized convenience creation is equivalent to
allocating storage, writing parameter defaults, and running init(FULL).
Initialization observes the buffer bindings current for that call. Unbound buffers retain their
neutral behavior: reads return zero, writes are discarded, and metadata reports the neutral
one-frame descriptor; .bound() distinguishes that fallback from a real binding. A later rebind
is visible immediately to subsequent block, sample, event,
and init entry points, but does not implicitly rerun initialization or change state that an earlier
initializer derived from the old binding. A host that wants to refresh such derived state requests
initialization explicitly. This allows hosts with buffers available at startup to perform one-time
preprocessing in init instead of adding setup work to block or sample callbacks. Proc init has the
same access to its declared buffer surface, including buffers supplied by its constructor.
A direct persistent binding can opt out of default initialization with the pin
modifier:
init:
pin prepared: f32[4096]
pin generation: i32 = 0
history: f32[128]
init(PRESERVE_PINNED) preserves prepared and generation while reinitializing
history. Initialized construction and init(FULL) initialize every binding. Snapshots
include both policies and restore the captured values; pin affects
initialization, not snapshot semantics. Integer-domain attributes remain
independent, for example
pin partition = 0 {8, wrap}.
The modifier is valid only on a fresh persistent value binding introduced
directly by init. It applies to the complete state root and supports primitive
scalars, fixed arrays, tuples, structs, and arrays of structs. Individual fields
or elements cannot select another policy unless they are separate init roots.
Proc instances and proc arrays cannot be pinned; their child state owns
its pin status. Params, inputs, outputs, buffers, locals, aliases, and constants
cannot be pinned either. pin is not valid on a nested assignment or an update
to an existing binding.
Advanced Function Parameter Kinds
def params support:
- Primitive scalars.
- Untyped scalars inferred and specialized from each call site.
- Explicit struct types.
- Typed primitive slices such as
arr: f32[]. - Fixed primitive arrays such as
arr: f32[4]. - Untyped arrays such as
arr: []. - Struct-array views such as
items: Item[]and fixed contracts such asitems: Item[4]. - Proc-array views such as
voices: Voice[]and fixed contracts such asvoices: Voice[8]. - Typed buffers such as
buf: buffer<f32>. - Bare buffers such as
buf: buffer. - Generic struct and proc parameters specialized at the call site.
- Typed tuple params such as
p: (f32, i32). - Untyped tuple params inferred from the call site.
- Untyped structural params inferred from field and method use.
def sum(arr: f32[]):
total = 0.0
for i in 0..arr.len():
total = total + arr[i]
return total
def first(arr: []):
return arr[0]
def stereo_sum(arr: f32[2]):
return arr[0] + arr[1]
def read_first(buf: buffer):
return buf[0]
An unsized primitive, struct, or proc array parameter accepts any compatible runtime length and
supports .len(). A fixed parameter additionally requires the call-site length to match exactly.
Untyped and unsized aggregate parameters are specialized from their concrete call sites.
Nominal struct and struct-array parameters borrow the caller’s storage and therefore cannot declare
defaults. Their write permission is inferred from the body and transitive calls; pass const or
read-only data only to a fully read-only call chain.
Untyped parameters can be specialized structurally:
struct A:
x: f32
struct B:
x: f32
def read_x(s):
return s.x
read_x can be called with both A and B; the compiler specializes it from
the concrete argument shape and the field access in the body.
Unchecked Indexed Access
read_unsafe(values, index) / values.read_unsafe(index) and
write_unsafe(values, index, value) / values.write_unsafe(index, value) provide unchecked indexed
access across the language; they are not limited to external buffers. Supported primitive storage
includes fixed arrays, slices, named input/parameter/output arrays, the uniform dynamic views ins,
params/kins, outs, and kouts, selected buffers, and fixed buffer collections. Ordinary
direction rules still apply: inputs and parameters are read-only, while output views are write-only.
Buffer forms accept their normal selector, channel, and frame coordinates.
Unchecked coordinates perform no clamp or runtime bounds check. The caller must prove every supplied index, collection selector, channel, and frame is valid. Violating that contract is memory-unsafe. These operations are an optimization escape hatch for a bound the compiler cannot express or prove, not a way to change ordinary clamped indexing semantics. Prefer normal indexing—especially with ranged integer selectors—when the compiler can establish the bound itself.
read_unsafe also selects references from arrays of structs and processors.
Given compatible cells and voices arrays and a proven-valid index, the
forms are:
sample:
cell = cells.read_unsafe(index)
voice = read_unsafe(voices, index)
out1 = inspect(cell) + voice()
The result has the same alias/reference semantics as cells[index] or voices[index]; only selector
normalization is omitted. Struct fields and processor calls, fields, events, and named arguments
therefore continue to operate on the selected element. An aggregate result is only valid when
introducing an alias, passing a reference argument, or supplying an aggregate replacement; it
cannot be used as a scalar value.
For arrays and slices of data structs, write_unsafe(cells, index, replacement) replaces the
selected element using the ordinary exact-type and snapshot rules for aggregate assignment.
Processor elements remain resources and cannot be replaced. An invalid unchecked
aggregate selector has undefined memory-unsafe behavior and may trap, crash, corrupt state, or—for
a processor array—dispatch through an arbitrary state. There is no defined fallback behavior.
Section Shorthands
| Section | Count shorthand | Default type shorthand | Generated names |
|---|---|---|---|
ins |
ins N |
ins<f64>: |
in1..inN |
params |
params N |
params<i32>: |
param1..paramN |
kins |
kins N |
kins<i32>: |
kin1..kinN |
outs |
outs N |
outs<f64>: |
out1..outN |
kouts |
kouts N |
kouts<f32>: |
kout1..koutN |
buffers |
buffers N |
buffers<f32>: |
buf1..bufN |
init |
— | init<f64>: |
Fresh untyped scalar bindings use the section type. |
Section counts can use compile-time integer expressions, ordinary const values,
and namespace integer template params. A bare integer or name needs no grouping; wrap a compound or
call expression, for example outs (channel_count()).
A section default is contextual typing for every otherwise-untyped declaration in that section; it
is not merely a fallback for ambiguous initializers. The initializer spelling does not override it:
for example, mode = 0 in params<f32>: is f32. Add an item type when a declaration needs to
override the section default, such as mode: i32 = 0.
Dynamic Surfaces
Direct indexed access is supported for explicitly declared homogeneous surfaces:
ins[i]
outs[i] = x
kouts[i] = x
params[i]
kins[i]
child.params[i]
These views are not first-class arrays. Do not assign, slice, pass, return, or
store ins, outs, kouts, params, kins, or child proc dynamic views.
Input/output surfaces are executable-rate-bound. inN, outN, koutN, declared I/O arrays, and
synthetic I/O views cannot be read, written, passed, returned, or stored from init, event, task,
or top-level def bodies. Within a structured block, audio inputs and outs are available only in
the nested sample; kouts are available only in block-pre and block-post. Inputs and params are
read-only through these dynamic views, while current-owner outputs are write-only. Named proc params
remain writable by their owning proc according to the processor rules.
Aliases and Reserved Names
inputsaliasesins.outputsaliasesouts.processoraliasesproc.- Top-level
kinsaliasesparams. - Control-flow keywords are reserved:
if,elif,else,for,in,while,loop,break,continue,return, andassert. inseparates the loop variable from its range infor i in A..B; use names such asinputfor ports and variables.import,include,use,as,pub,private,pin, andconfigare reserved for their declaration and modifier syntax.trueandfalseare reserved boolean literals.- Identifiers beginning with
__onda_are reserved for compiler-generated symbols. - Numbered
outNnames are audio outputs; usekoutNfor numbered control outputs.
Common Current Limits
- Proc-local defs are not overloadable.
- Returning unsized slices or buffers from runtime
defis unsupported. graphsource expressions cannot call user-defined functions or procs.graphdoes not supportkoutsor block-rate proc outputs.graphhas no event-routing syntax.