BytorioGet started

DOCUMENTATION / sdk/synthesis.md

Synthesis and effects: resource-av-1

Luau and Odin now author the same typed DSP graphs, evaluated by the shared Odin mixer on native and browser using resource protocol 4, published as SDK 0.2.0 / ABI 2 / resource-av-1. Players retain protocols 1–3 through the compatibility adapter. See profile conformance. The PCM audio reference defines clocks, voices, routing, queues, feedback, pause and device delivery shared by this API.

Build an instrument

An instrument is an immutable array of nodes. Node indices are zero-based in both languages, including inside ordinary one-based Luau arrays. Each input must refer to a preceding node; the final node must be output. This makes graph order explicit and rejects forward references and cycles. Only a delay node's own bounded history permits feedback. Definitions share source storage; each playing voice owns its phase, random generator, envelopes, filters and history.

local instrument = audio.instrument({
    { kind = "noise", seed = 1234567 },
    { kind = "adsr", attack_frames = 48, decay_frames = 480,
        sustain = 0.3, release_frames = 2400 },
    { kind = "gain", input = 0, gain = 0, gain_input = 1, depth = 0.15 },
    { kind = "filter", input = 2, cutoff_hz = 2000, resonance = 0.2 },
    { kind = "output", input = 3 },
})
local voice = audio.voice(0)
audio.note_on(voice, instrument, 440, { velocity = 0.8 })
audio.note_off(voice, audio.time() + 48000)
instrument := audio.instrument([]audio.Node {
    {kind = .Noise, seed = 1234567},
    {kind = .ADSR, attack_frames = 48, decay_frames = 480,
        sustain = 0.3, release_frames = 2400},
    {kind = .Gain, input = 0, gain = 0, gain_input = 1, depth = 0.15},
    {kind = .Filter, input = 2, cutoff_hz = 2000, resonance = 0.2},
    {kind = .Output, input = 3},
})
voice := audio.voice(0)
audio.note_on(voice, instrument, 440, {velocity = 0.8})
audio.note_off(voice, audio.time() + 48000)

note_on(voice, instrument, frequency_hz, opts) uses an explicit frequency in Hz, 1–20,000. It is a pitch multiplier relative to 440 Hz for all sample and wavetable nodes: a wavetable declared at 440 Hz plays at the note frequency; one declared at 110 Hz plays two octaves lower. It does not transpose LFOs. Options are at, bus, velocity [0,1] (default 1) and pan [-1,1] (default 0). Starting a note resets that voice's state. A later note_off starts all ADSR release stages and completes the voice when they finish. Without an ADSR it stops immediately. Instruments are explicitly gated, including sample-only instruments; exhaustion of a sample node produces silence until the note ends. Existing stop, explicit fade release, voice gain and pan also work on notes.

Node descriptors and ports

Luau exports byt.SynthNode with a string kind. Odin uses audio.Node and audio.Node_Kind. Omitted input fields mean disconnected; zero is node zero, not a disconnected input. Odin represents optional indices, sources and defaulted scalars with unions, preserving explicit zero. Resource handles remain opaque/distinct Sample, Wavetable, Instrument and Bus types.

Luau kind / Odin kind Inputs and options Output
input / .Input Bus effects only; current mixed bus input Stereo audio
sample / .Sample source: Sample, rate default 1, loop, loop_start, exclusive loop_end Mono/stereo audio
wavetable / .Wavetable source: Wavetable, frequency_hz default 440, wave default 0, phase_cycles default 0 Mono audio
noise / .Noise seed nonzero u32, default 1; independent xorshift32 state per instance Mono audio
constant / .Constant value [-16,16], default 0 Scalar control
mix / .Mix Audio input, audio other; weights gain, secondary_gain, both default 1 Audio, stereo if either input is stereo
gain / .Gain Audio input; gain default 1; optional mono/control gain_input, depth default 1 Same audio type as input
adsr / .ADSR attack_frames, decay_frames, release_frames default 0; sustain default 1 Scalar control
lfo / .LFO Triangle, frequency_hz [0,200], default 5; phase_cycles default 0 Scalar control
filter / .Filter Audio input; cutoff_hz [20,20000], default 1000; resonance [0,0.95], default 0 Same audio type as input
delay / .Delay Audio input; required max_delay_frames; delay_frames defaults to maximum; feedback [-0.95,0.95] default 0, wet [0,1] default 0.5 Same audio type as input
pan / .Pan Audio input, pan [-1,1], default 0; linear balance Stereo audio
output / .Output Audio input; final node Stereo audio

Mono is duplicated when mixed with stereo. Control signals cannot be wired into an ordinary audio input or output. frequency_input and phase_input on wavetable nodes, and gain_input on gain nodes, accept mono audio or scalar control, evaluated every logical frame. Stereo modulation is rejected.

Wavetable modulation uses frequency_hz * note_pitch + frequency_input * fm_depth_hz and accumulated_phase + phase_cycles + phase_input * pm_depth_cycles. Frequency is clamped to [-24,000,24,000] Hz after modulation; negative frequency runs backwards. Depth bounds are ±24,000 Hz and ±16 cycles. The phase accumulator wraps in cycles and continues across controls/source edits. Gain modulation is input * (gain + gain_input * depth); the resulting multiplier is clamped to [-16,16]. A zero base gain plus an ADSR input applies the envelope.

Each node output clamps to [-16,16] before downstream processing; master output retains the PCM mixer clamp [-1,1]. Sample playback uses linear source-rate interpolation. Waveforms interpolate linearly within a cycle and between adjacent wave indices. Noise maps its next u32 to word / 2^31 - 1. Triangle LFO output is 1 - 4*abs(wrapped_phase - 0.5), starting at -1 at phase zero.

Filter modes are "lowpass", "bandpass", "highpass" in Luau and .Lowpass, .Bandpass, .Highpass in Odin, default lowpass. The shared state-variable filter uses two integrators/channel, damping 2 - 1.9*resonance and a fixed rational coefficient (defined in the reference DSP) to avoid platform libm differences. Very small integrator values are cleared below 1e-20. Delay history is stereo, tap lengths are floored to whole frames, and feedback writes clamp to [-16,16]. Changing a tap changes its read position immediately; there is no hidden crossfade. Wet output is dry*(1-wet) + delayed*wet.

ADSR ramps use the current value on the event frame, then advance toward their target. Attack reaches 1, decay reaches sustain, sustain follows its parameter, and note-off releases from the current level to zero. Zero-length stages advance immediately. The decay target and attack/decay/release lengths are read on stage entry; changing a length does not restart a stage already running. ADSR frame durations are floored when their stage starts. Release truncates instrument-local effect tails when the voice completes; put effects on a bus when their tails should continue after a note.

Mutable sources and controls

audio.wavetable({frames=32, waves=2}, pcm) creates mono f32 storage: 32–2,048 frames per cycle, power of two, 1–64 waves, consecutive in the buffer. Odin uses audio.Wavetable_Desc. pcm.frames must equal frames*waves; values must be finite in [-1,1]. Both SDKs destroy these resources with destroy_wavetable and destroy_instrument.

edit_sample(sample, first, pcm, at) and edit_wavetable(table, first, pcm, at) copy a partial replacement into SDK staging immediately, then apply it at the scheduled logical frame. first is a source-frame offset; wave-table offsets span the flattened sequence of waves. Channel counts must match. Existing voices and future notes observe the changed source at that frame without phase or position reset. Equal-frame edits/notes execute in accepted command order. Accepted edit buffers can be reused immediately; queued patches retain their own storage until consumed.

A sample becomes a live shared synthesis source when first used by an instrument or explicitly edited. Its initial contents are captured then. Subsequent updates must use edit_sample; ordinary unscheduled audio.write is rejected for a live source. PCM-only samples keep the 4c snapshot-at-play behavior until explicitly made live. Active PCM voices adopt the live source on an edit while preserving position. Instrument/effects definitions retain their source handles: destroy dependent definitions or clear effects before destroying a source. Destroying an instrument cancels its active/future notes. Source destruction cancels remaining PCM voices, pending edits and future PCM plays submitted after that source became live. Plays accepted before live binding retain their original fallback snapshot if the live source subsequently disappears.

node_control(voice, instrument, node, parameter, value, at, ramp_frames) targets a node in that instrument instance; controls for a replaced voice using a different instrument are ignored. effect_control(bus, node, parameter, value, at, ramp_frames) targets the bus's current chain. The host rejects parameters for the wrong node kind, invalid ranges, and wave indices outside the table. Ramps share the PCM timing rules. Node indices are zero-based.

Parameter string / Odin enum Valid nodes
frequency / .Frequency, phase / .Phase Wavetable, LFO
wave / .Wave Wavetable
rate / .Rate Sample
gain / .Gain Gain, mix, constant
secondary_gain / .Secondary_Gain Mix
depth / .Depth Gain modulation or wavetable FM depth
pan / .Pan Pan
cutoff / .Cutoff, resonance / .Resonance Filter
delay_frames / .Delay_Frames, feedback / .Feedback, wet / .Wet Delay
attack / .Attack, decay / .Decay, sustain / .Sustain, release / .Release ADSR

Bus effects and bounds

effects(bus, nodes, at) replaces a bus's complete chain; an empty array/slice clears it. The graph may use input and cannot use ADSR. Topology replacement must use the current simulation frame (omit at or pass audio.time()). Future parameter changes still use absolute scheduling. Replacement resets the chain's history; parameter controls retain it. Child buses render before parents, with each chain applied before its bus gain. Master effects are supported too.

There are at most 64 immutable instrument definitions, 16 nodes/instrument, eight nodes/bus and 320 active nodes across all voices and buses. A delay may declare at most 96,000 frames (two seconds) of history. All live source PCM, definitions, patches, note history and retired leases share the bounded 8 MiB audio arena. Creation validates graph history; starting a note/chain reserves and clears all its history on the producer. Arena exhaustion fails the entire commit; there is no callback allocation or hidden eviction. A declared maximum per node does not grant every node that much storage simultaneously.

Protocol 4 expands the fixed portable audio reservation to 55,463,952 bytes on both hosts for metadata, PCM arenas, command/transfer/feedback storage and scratch. Ordinary resource storage is charged additionally within the 64 MiB quota. No arbitrary guest DSP callbacks, topology feedback or host-device access is exposed. Profile negotiation is published; minimum hardware and physical device latency remain unqualified.

The matching Luau tone cartridge and Odin tone cartridge demonstrate the interface. After the opening phrase, arrows change pitch/filter and Space morphs the sustained waveform. The stress cartridge uses all voice/bus/node slots with live edits and ramps. Run mise run test:synthesis to build both and retain playable PNG/native/web exports under build/synthesis-verification/<run>/. See verification.