Signal processing

Signal processing

Signal processing

Signal generates fixed-format audio or analytic complex samples from oscillators and ordered or branching processor stages. Workbook cells supply controls; one complete settlement applies at a block boundary. RENDER owns delivery, clocks, deadlines and device presentation.

Local composition

Name intermediate stages with an ordinary DO block inside the RENDER source. The final expression selects the chain to render:

AudioOut = RENDER(
  DO
    tone = SIGNAL_OSCILLATOR(ToneFrequency, 0.02, {
      sampleRateHz: 48000, channels: 1, blockSize: 128
    })
    amplified = SIGNAL_GAIN(tone, 0.5)
    filtered = SIGNAL_LOW_PASS(amplified, Cutoff)
    SIGNAL_CLIP(filtered, 0.8)
  END,
  {
    target: "audio:default", clock: "device_callback",
    rateHz: 48000, blockSize: 128,
    output: { kind: "dense_f32", length: 128 }, missPolicy: "silence"
  }
)

ToneFrequency and Cutoff are ordinary model controls. See the complete local composition example. Each binding sees earlier bindings and its outer scope. Names are case insensitive; a same-named binding captures the previous meaning on its right side. Nested DO, pure DO SYNC, LET and existing >> pipes work here. Locals can hold stages, scalar controls, or literal configuration objects. Numeric arithmetic and the admitted numeric functions can derive controls inline. These expressions become ordinary reactive cells; they settle once per model revision, not once per sample.

Compilation checks every binding and emits the returned chain as one native Signal plan. Stage names do not store audio buffers in Workbook cells. The ordinary formula result retains the source's control projection; RENDER still owns delivery and device activation. Locals do not add jobs or clocks. AWAIT, effects, forward stage references and non-stage results are rejected. Pure named Grid helpers expand at compilation under the same depth/work bounds. Their parameters retain stage identities or scalar values, and free model references retain the definition's lexical capture identity. A reused stage can feed alternative chains; only the returned chain is rendered. The existing 1,024-processor limit applies; local composition also has bounded compile-time depth and work.

Additive synthesis

SIGNAL_MIX(first, second, ...) sums two or more oscillator sources into the existing native bank. Local names and nested mixes compose:

DO
  format = {sampleRateHz: 48000, channels: 1, blockSize: 128}
  fundamental = SIGNAL_OSCILLATOR(FundamentalHz, 0.06, format)
  third = SIGNAL_OSCILLATOR(ThirdHz, 0.02, format)
  fifth = SIGNAL_OSCILLATOR(FifthHz, 0.012, format)
  partials = SIGNAL_MIX(fundamental, third, fifth)
  partials >> SIGNAL_LOW_PASS(CutoffHz) >> SIGNAL_GAIN(Level)
END

The complete additive synthesis model derives harmonic frequencies in ordinary formulas. It is useful for designing tones, comparing harmonic content, and studying superposition and filter response. Changing one input settles all derived controls together before the next admitted block. These bank controls update once per block; sample-rate modulation is described below.

Mixing adds amplitudes without normalization, clipping, resampling or channel conversion. All sources must have identical sample rate, channels, block size, sample type and layout. Each voice starts at phase zero and retains phase across blocks. A negative oscillator gain reverses polarity; equal-frequency voices with opposite gains cancel. Repeating a source adds it again. Nested mixes flatten in authored order, including repeats, with at most 4,096 voices. The same bank contract supports real and analytic complex sources.

Mixing independently processed branches uses the Signal v5 acyclic schedule below. Signal v6 adds bounded echo, envelopes and sample-rate modulation; Signal v7 adds bounded file/live inputs and delayed feedback helpers. Arbitrary cyclic graphs remain unsupported. No filter is moved across a mix. Workbook's observable projection of a mix is its first source's projection; the sample sum exists in the Signal plan and is delivered by RENDER.

Reusable helpers and parallel effects

Use ordinary named functions for pure, reusable stage composition:

soften(signal, cutoff, level) = (
  signal >> SIGNAL_LOW_PASS(cutoff) >> SIGNAL_GAIN(level)
)

Inside RENDER, combine the helper with derived controls and parallel branches:

DO
  format = {sampleRateHz: 48000, channels: 1, blockSize: 128}
  tone = SIGNAL_OSCILLATOR(FundamentalHz * 3, 0.2, format)
  dry = tone >> SIGNAL_GAIN(1 - WetLevel)
  wet = soften(tone, CutoffHz, WetLevel)
  SIGNAL_MIX(dry, wet)
END

The complete parallel-effects example contains the inputs and output contract. A shared stage executes once per block; reading it twice reuses its output. Two separately authored calls own separate state. Mix inputs sum in authored order, including repeated inputs. Changing controls retains oscillator phase and filter history; resetting a native plan resets all its state.

Helpers admit scalar model captures, native stages, static configuration, numeric arithmetic, and ABS, MIN, MAX, SQRT, SIN, COS, TAN, EXP, LN, LOG, LOG10, POWER, ROUND, FLOOR, and CEILING. Helpers do not introduce runtime callbacks. Effectful calls, range/relative captures, dynamic configuration, and unsupported higher-order expressions are rejected.

The schedule admits at most 8,192 nodes, 16,384 input references, 4,096 voices, 1,024 processors, and 128 MiB of scratch measured at complex-sample width. Admission allocates buffers before rendering; prepared DSP processing allocates no buffers per block. Fixed-rate delivery retains its existing copy of each queued output frame. Original bank-plus-chain plans retain their linear implementation. The single-chain response inspector refuses branching plans; rendered waveform and spectrum inspection remain available through the ordinary output path.

Equalizers

Signal v4 adds four linear processors, implemented with pinned FunDSP 0.23.0:

Declaration inside RENDER Controls Meaning
SIGNAL_BELL(source, centerHz, Q, gain) Center, Q, amplitude gain Boost or cut a frequency band.
SIGNAL_LOW_SHELF(source, centerHz, Q, gain) Center, Q, amplitude gain Change the low-frequency gain.
SIGNAL_HIGH_SHELF(source, centerHz, Q, gain) Center, Q, amplitude gain Change the high-frequency gain.
SIGNAL_NOTCH(source, centerHz, Q) Center, Q Reject the center frequency.

Gain is a positive linear amplitude ratio: 2 is about +6 dB, 0.5 about −6 dB, and 1 leaves an EQ band's gain unchanged. For a dB control, use 10 ^ (GainDb / 20). Q controls the filter's resonance/bandwidth; a larger Q makes a bell or notch narrower. Shelf Q can produce resonance around its transition, rather than a monotonic shelf.

The admitted bounds are center frequency in sampleRateHz * [0.0001, 0.49], Q in [0.1, 32], and amplitude gain in [0.01, 100]. At 48 kHz, center frequency spans 4.8–23,520 Hz. Constants outside these bounds fail compilation; settled cell controls are checked before the selected processor executes or its response is inspected. Controls execute at f32 precision; internal filter state uses f64. Invalid callback controls use the existing Render miss policy.

Each channel has independent filter state. For complex_f32, each real and imaginary component has its own state under the same real-coefficient filter. Both interleaved and planar layouts preserve the declared shape. Control changes retain filter state; they are discrete settlement changes, without automatic smoothing.

The EQ bench builds shelves, a bell and a notch around a low-amplitude oscillator. Adjust its input cells to design and audition the chain through the existing native audio owner. Signal declarations inside RENDER remain observable Workbook projections of their source/control, rather than storing sample buffers in cells. File and microphone inputs use the explicit source admission described below.

Inspecting the response

The selected native/HTTP inspector returns complex frequency response, magnitude, dB, composed stage phase, phase delay and group delay for an admitted linear chain at one already-published control revision. Existing waveform and spectrum inspection remain available.

The Media workbench can show filter controls and magnitude, phase and group-delay plots above its waveform and spectrum. Add this retained configuration to a Media surface, using the discovered Signal region ID and input names in the catalogue's flattened control order:

[response]
region_id = 1
controls = ["LowCenter", "LowQ", "LowGain", "BellCenter", "BellQ", "BellGain", "HighCenter", "HighQ", "HighGain", "NotchCenter", "NotchQ"]

This list matches the EQ bench. The region ID is illustrative; use the actual admitted ID. Empty names mark fixed controls. Missing or mismatched bindings allow read-only inspection. Only explicitly declared writable inputs with acknowledged write support can be edited. Ordinary Media configurations load no filter panel or response requests. Run and print keep their existing presentation.

Select a frequency with the keyboard to read all plots together, including phase delay. Edit controls retains a draft; Apply controls checks source and input revisions, writes only changed inputs and requires matching receipts and a native publication before updating the plots. An uncertain save keeps the draft and locks retries. Use Refresh response to reconcile it, or Discard control draft to resume from the model. Invalid published controls must first be repaired through the existing model inputs.

Discover a region with GET /api/models/:id/media/response/plans. Discovery also reports each processor's source name, minimum ABI version, linearity and ordered control names from the native catalogue, plus whether the entire chain is linear. Send an explicit bounded sweep to POST /api/models/:id/media/response:

{
  "schemaVersion": 1,
  "signalRegionId": 1,
  "frequenciesHz": [0, 200, 440, 1000, 3000, 8000, 24000]
}

Use the discovered region ID; the example ID is illustrative. The response includes source identity, settled revision, graph format and effective controls. Expected source/revision fields can reject stale requests. Sweep frequencies must be strictly increasing, within [0, Nyquist], with 1–1024 points. The controls are flattened in processor order: legacy nodes contribute one scalar; EQ contributes center, Q and optional gain in that order.

At a notch's effective center, response is exactly zero and dB, phase and delay are null. Phase delay is also null at DC. Group delay is the transfer phase derivative in seconds, distinct from FunDSP's causal latency and from device or Render delay. The response holds controls fixed; it excludes modulation, state transients, oscillator generation and physical device behavior. SIGNAL_CLIP makes its containing chain nonlinear and unavailable for this analytical inspection.

No new Media layout is activated by these endpoints. Existing signal arithmetic and compatibility remain unchanged.

Musical instruments and sample-rate controls

Signal authoring also supports static partial collections, sample-rate arithmetic, smoothed controls, envelopes, bounded echo, and score-driven instruments. These are native forms inside a Signal RENDER expression. They use the existing expression tree, pure helpers and ordinary reactive settlement for model controls.

DO
  format = {sampleRateHz: 48000, channels: 1, blockSize: 128}
  partials = [
    SIGNAL_OSCILLATOR(FundamentalHz * harmonic, 0.2 / harmonic, format)
    FOR harmonic IN 1..16
  ]
  SIGNAL_MIX(partials) >> SIGNAL_LOW_PASS(CutoffHz)
END

One-variable comprehensions and MAP over static arrays or SEQUENCE expand at admission. The collection has 1–4096 elements; mixing requires at least two stages. The existing work, depth and expanded voice bounds still apply. Model-dependent collection bounds, comprehension filters and multi-generator comprehensions are not admitted. A scalar-controlled partial bank keeps the existing bank execution plan. Adding sample-rate operations selects Signal v6.

DO
  format = {sampleRateHz: 48000, channels: 1, blockSize: 128}
  vibrato = SIGNAL_OSCILLATOR(5, 3, format)
  frequency = SIGNAL_SMOOTH(FundamentalHz, 20ms) + vibrato
  envelope = SIGNAL_ADSR(Gate, {
    attack: 10ms, decay: 100ms, sustain: 0.7, release: 200ms
  })
  SIGNAL_OSCILLATOR(frequency, 0.2, format) >> SIGNAL_GAIN(envelope)
END

SIGNAL_SMOOTH(target, time) uses a one-pole smoother. time is the time constant, so a step reaches about 63% after that interval. It starts at the first settled target and retains its state when controls change. Zero time passes the target through. Time constants and envelope durations are static, 0–60 seconds; bare numbers mean seconds. Physical ms, s and us literals and duration literals are accepted.

SIGNAL_ADSR(gate, options) uses linear attack, decay and release. A positive gate starts attack from the current level; a nonpositive gate starts release from the current level. Sustain is a static level in 0–1. State persists across blocks. Stage outputs support +, -, *, / and unary signs. Oscillator frequency and gain and processor controls accept sample-rate operands. Sample plans require one common f32 format. Frequencies outside Nyquist and nonfinite sample arithmetic fail the block through the existing Render miss policy.

DO
  dry = SIGNAL_OSCILLATOR(FundamentalHz, 0.2, format)
  echo = SIGNAL_ECHO(dry, {delay: 250ms, feedback: 0.35, maxDelay: 1s})
  SIGNAL_MIX(dry, echo >> SIGNAL_GAIN(WetLevel))
END

SIGNAL_ECHO returns the delayed signal only. Its internal recurrence writes input + feedback * delayed into a preallocated ring. Delay rounds to the nearest sample, must be at least one sample, and cannot exceed static maxDelay (at most 10 seconds). Feedback magnitude must be below one. Delay and feedback may be scalar model controls; numeric delay controls are seconds. The graph remains acyclic. Changes preserve the ring; changing delay can cause a discontinuity, so this is not a continuously interpolated tape-delay model.

voice(hz, velocity, gate, format) = (
  SIGNAL_OSCILLATOR(hz, velocity * 0.1, format)
    >> SIGNAL_GAIN(SIGNAL_ADSR(gate, {
      attack: 5ms, decay: 80ms, sustain: 0.6, release: 150ms
    }))
)
 
AudioOut = RENDER(
  SIGNAL_SCORE(Tune, voice, {
    maxVoices: 16,
    format: {sampleRateHz: 48000, channels: 1, blockSize: 128},
    tail: 200ms
  }),
  {target: "audio:default", clock: "device_callback", rateHz: 48000,
   blockSize: 128, output: {kind: "dense_f32", length: 128}, missPolicy: "silence"}
)

SIGNAL_SCORE reads a named model Music value (or notation string), a module-level named pure four-argument helper, and static options. Each note supplies frequency in Hz, velocity in 0–1 and a gate of 1 until note-off, then 0. The Music owner's performed timeline supplies tempo, ties, repeats and articulation timing. Note boundaries round to sample frames; a performed note lasts at least one sample. Playback begins at frame zero, runs once, and ends in silence after all voices finish. There is no external MIDI device or wall-clock scheduler.

The pool contains 1–64 voices. It takes the first inactive slot; when full, it steals the oldest voice, breaking ties by slot order. A stolen voice resets its DSP state. tail keeps a voice alive after note-off (default 2 seconds, maximum 60); set it long enough for the authored release/effects. An instrument cannot contain another score. A score has at most 256 parts, fewer than 20,000 performed measures, and a conservative expansion bound of 200,000 events/notes (including chords and ornaments, counted before allocation). It lasts at most 25 minutes. All expanded instruments together must fit 8192 nodes and the 128 MiB conservative state budget.

Scalar settlement updates retain score position, phases and envelopes. A changed performed score restarts that score's voice pool at the next block; other surrounding effects retain their state. The callback consumes prepared numeric events, never notation. Sample schedules expose 0 as the Workbook projection of the Render expression; audio is carried by the Signal handle. Static transfer-response inspection is unavailable for sample schedules.

Complete models: modulated harmonics and score instrument.

Filter modulation, delayed feedback and audio sources

Filters accept sample-rate controls using the same expression and pipeline syntax as gain and oscillator modulation:

envelope = SIGNAL_ADSR(Gate, {attack: 10ms, decay: 100ms, sustain: 0.4, release: 300ms})
dry = SIGNAL_OSCILLATOR(Frequency, 0.2, format)
  >> SIGNAL_LOW_PASS(300 + 4000 * envelope)

Low/high-pass cutoff, bell/shelf/notch controls and clip threshold retain their existing domains. Each channel uses its own control sample and state. Invalid controls or nonfinite results fail the block through Render's miss policy; they are not silently clamped. Scalar-controlled filters retain their current path. Use SIGNAL_SMOOTH or an envelope when a discontinuous model control would click.

A named pure helper can process a delayed feedback return:

damp(signal) = signal >> SIGNAL_LOW_PASS(CutoffHz)
 
# Inside a Signal DO block:
wet = SIGNAL_FEEDBACK(dry, damp, {delay: 80ms, maxDelay: 200ms, feedback: 0.6})
SIGNAL_MIX(dry, wet >> SIGNAL_GAIN(0.3))

The helper expands at compilation. Each sample reads the delayed stored value, processes it through damp, returns that wet value, then stores dry + feedback * wet. The delay is at least one sample and no greater than the static maxDelay (at most ten seconds). Its buffer and helper state are allocated at Render admission and reset with playback. Feedback magnitude must stay below one. This bound alone does not guarantee stability for an arbitrary amplifying helper; nonfinite feedback fails the block. Nested feedback, scores inside a feedback helper, file/device sources inside feedback helpers, and zero-delay cycles are refused. Captured scalar model controls continue to settle normally.

DO
  format = {sampleRateHz: 48000, channels: 1, blockSize: 128}
  vocal = SIGNAL_INPUT("default", {format: format})
  backing = SIGNAL_FILE("backing.wav", {format: format, maxDuration: 30s})
  SIGNAL_MIX(vocal >> SIGNAL_HIGH_PASS(80), backing >> SIGNAL_GAIN(0.2))
END

SIGNAL_FILE names a relative file in the model's Files. Explicit Render startup opens and decodes it once, pins the samples for that renderer's lifetime, and refuses files exceeding maxDuration or the patch memory budget. Supported WAV encodings are PCM 8/16/24/32-bit and IEEE f32. Rate and channel count must exactly match format; there is no implicit conversion. Playback starts at frame zero, emits silence after the end, and rewinds on reset. File edits take effect when playback restarts. maxDuration is required, positive and at most ten minutes; the combined admitted patch is still capped at 128 MiB.

SIGNAL_INPUT admits one input device per patch: "default" or a native input device ID. It requires device-callback Render and the exact authored f32 format. The existing native audio owner starts capture only on explicit playback. Compilation, settlement and value reads do not open files or devices. Detached adapters can supply one finite block through render_with_input; missing or malformed blocks fail through the Render miss policy. The native adapter uses a bounded four-block frame queue, applies the Render miss policy on underflow, and latches device failure or overflow until playback restarts. It performs no clock-drift resampling. Stopping playback also stops capture. Sources belong at the root of the patch, outside score instruments and feedback bodies.

These additions use Signal v7. Existing v6 music/envelope/echo plans and older linear plans retain their contracts. See the complete modulated feedback and file/live-input examples.

Worked instrument

Performance synthesizer combines a six-partial voice, pitch smoothing, vibrato, an amplitude/filter envelope and a damped parallel delay. Load the model, start AudioOut explicitly, then set Gate to 1. Change Note (MIDI note number), BrightnessHz, VibratoHz and WetLevel while playing. Set Gate to 0 to hear the release and delay decay; stop playback to release the renderer. Note 57 is A3 and 69 is A4.

The model clamps its panel inputs deliberately; native Signal validation still rejects invalid controls in models that do not author such bounds. Its named voice(hz, velocity, gate, format) uses ordinary locals and a static harmonic comprehension. The same voice can replace the instrument in SIGNAL_SCORE; keep the score's tail at least 250 ms for this envelope. Put the shared delay after the mixed voices to allocate one effect for the instrument.

Feedback and score composition can also live inside a named helper:

damp(signal) = signal >> SIGNAL_LOW_PASS(1800)
space(signal, amount) = SIGNAL_FEEDBACK(signal, damp, {
  delay: 125ms, maxDelay: 500ms, feedback: amount
})
# Inside the Render composition:
# delayed = dry >> space(DelayFeedback)

The callback resolves in the helper's defining scope, including USE imports. The compiler retains its semantic fingerprint and expands its Signal body during admission; it does not create an executable Workbook closure. Callback names must remain statically known, with the existing pure one-argument feedback or four-argument instrument contract. A parameter or local that shadows the callback is refused. Prepared audio processing keeps the same bounded native program. Canonical source rendering preserves numeric sequence forms such as 1..6 and music literals, so these compositions survive portable owning syntax and rendered source round trips.