tur-signal Guide

A synthesizer voice is a pipeline: an oscillator produces a waveform, a filter shapes its spectrum, a shaper limits its amplitude, and an envelope sculpts its loudness over time. This spice models that pipeline as typed Signal Functions -- closures you compose left-to-right, sample at a time you choose, and test without a sound card.

This guide walks the things you will do most often:

  1. Signals and sampling
  2. Oscillators, and band-limited oscillators for audio rate
  3. Filters: one-pole, biquad, state-variable and ladder
  4. Shapers and mixers
  5. ADSR envelopes and a complete voice
  6. Delays, combs and allpasses
  7. Noise
  8. Modulation: ring, AM, PM and FM
  9. Buffers and buffer scanning, and reading between samples
  10. Pitches, melodies and cycles
  11. Tunings: Scala scales and keyboard mappings

Every example is a complete program: save it inside a project that depends on the spice and tur run it. The comments show what it prints.


0. Installing the spice

In your project's build.tur:

(defpackage my-app
  :spices #map{
    "signal" #map{:url    "https://github.com/turmeric-lang/turmeric-spices"
                  :ref    "main"
                  :subdir "spices/signal"}
  })

Then tur fetch. There is no signal-v* release tag yet, so track main. Pure Turmeric -- no C dependencies. The modules that call libm carry a __tur_autolink__: -lm directive, so a program importing them links the math library automatically.


The one idea

A Signal A is conceptually a function from time to a value: (fn [t : float] A). The common case is Signal Sample, where a sample is a :float.

An SF A B (Signal Function) maps a signal to a signal. Every oscillator, filter, and shaper constructor returns an SF; apply it to a signal to get a signal back:

(let [tone   ((sine 440.0 0.0) (constant 0.0))   ;; SF applied -> Signal
      shaped ((gain 0.5) tone)]                  ;; SF applied -> Signal
  (sample shaped 0.0))                            ;; sample the result

sample evaluates a signal at a specific time. Because the whole graph is functions, you can test it by sampling at known times and comparing to hand-computed references -- no audio hardware, no real-time loop.

Two kinds of signal behave differently, and it matters everywhere below:


1. Signals

constant makes a signal that always emits the same value. time-signal is the identity -- its value equals the query time. sample evaluates a signal at a time you choose, and map-signal lifts a function on samples to a function on signals. Any (fn [t : float] : float ...) is a signal too, so a signal you need and the library lacks is one lambda away:

(defmodule demo
  (import signal/core :refer [constant time-signal sample map-signal])

  (defn main [] : int
    (println (sample (constant 0.5) 0.0))      ;; 0.5
    (println (time-signal 2.0))                ;; 2
    (let [tripled (map-signal (fn [x : float] : float (* x 3.0)) (constant 2.5))]
      (println (sample tripled 0.0)))          ;; 7.5
    (let [ramp (fn [t : float] : float (* 2.0 t))]
      (println (sample ramp 1.25)))            ;; 2.5
    0))

2. Oscillators

Each oscillator is an SF () Sample -- it ignores its input signal. Apply it to any signal (typically (constant 0.0)) to get a Signal Sample. The pitches here are notes you can hear -- 440 Hz is A4, 220 Hz is A3, 110 Hz is A2 (section 10 converts between note numbers and Hz) -- so each is sampled at a fraction of its own period:

(defmodule demo
  (import signal/core :refer [constant sample])
  (import signal/osc  :refer [sine square sawtooth triangle])

  (defn main [] : int
    (let [z   (constant 0.0)
          s1  ((sine 440.0 0.0) z)
          sq  ((square 220.0 0.5) z)
          saw ((sawtooth 110.0) z)
          tri ((triangle 220.0) z)]
      (println (sample s1 (/ 0.25 440.0)))    ;; 1 (a quarter cycle in: the peak)
      (println (sample sq (/ 0.25 220.0)))    ;; 1 (inside the on-portion)
      (println (sample saw (/ 0.5 110.0)))    ;; 0.5 (half a cycle in: mid-ramp)
      (println (sample tri (/ 0.25 220.0))))  ;; 0 (a quarter cycle in: zero crossing)
    0))
Oscillator Parameters Produces
sine freq, phase sin(2*pi*freq*t + phase)
square freq, dutycycle +1 for the on-portion, -1 otherwise
sawtooth freq rising ramp in [0, 1)
triangle freq rises -1 to 1, falls 1 to -1 each cycle

These four are the naive shapes. A sine is clean at any pitch, but the square, sawtooth and triangle alias at audio pitches like these -- the values above are exact, the sound is not. For a voice, use their band-limited twins in section 2b; the naive ones are right for LFOs and other control signals.

signal/osc also has two oscillators that read their input signal as a modulator, pm-sine and fm-sine; they are in section 8.


2b. Band-limited oscillators (hf-osc)

The oscillators above are the naive shapes, and that is the right thing for a control signal: a 2 Hz sawtooth modulating a filter has nothing near the Nyquist frequency to alias. At audio rate it is the wrong thing. A bare ramp or comparison has sharp edges, so its harmonics run past half the sample rate and fold back down as inharmonic partials -- the harsh, metallic "digital" sound -- that no later filter can remove, because by then they sit at the same frequencies as the real harmonics' neighbours.

signal/hf-osc is the same three shapes for hearing-range pitches. It adds a small polynomial correction around each edge (PolyBLEP for the square and sawtooth, PolyBLAMP for the triangle's corners) so the edge is rounded the way a band-limited signal's would be. A sine needs none -- it has a single partial -- so signal/osc's sine is already fine at any pitch.

(defmodule demo
  (import signal/core   :refer [constant sample])
  (import signal/osc    :refer [sawtooth])
  (import signal/hf-osc :refer [hf-sawtooth hf-square])

  (defn main [] : int
    (let [z     (constant 0.0)
          ;; An LFO: the naive ramp is fine at 2 Hz.
          lfo   ((sawtooth 2.0) z)
          ;; A 440 Hz voice, to be sampled at 48 kHz: band-limit it.
          voice ((hf-sawtooth 440.0 48000.0) z)
          pulse ((hf-square 220.0 0.25 48000.0) z)]
      (println (sample lfo 0.25))                      ;; 0.5
      (println (sample voice 0.0005681818181818182))   ;; -0.5 (a quarter cycle)
      (println (sample pulse 0.0005)))                 ;; 1 (inside the 25% on-portion)
    0))
Oscillator Parameters Produces
hf-sawtooth freq, sample-rate rising ramp in [-1, 1), step smoothed
hf-square freq, dutycycle, sample-rate +1 / -1, both edges smoothed
hf-triangle freq, sample-rate rises -1 to 1, falls back, corners smoothed

Three things differ from signal/osc:

How much it helps, measured as aliased energy relative to the true harmonics (FFT, 48 kHz; lower is better):

3.0 kHz naive 3.0 kHz hf- 7.9 kHz naive 7.9 kHz hf-
sawtooth -13 dB -27 dB -7 dB -27 dB
square -16 dB -36 dB -10 dB -38 dB
triangle -40 dB -48 dB -26 dB -45 dB

That is a cheap two-sample correction, not a brick-wall filter. It removes most of the audible aliasing for a few multiplies per sample, and pitches above roughly a quarter of the sample rate get increasingly impure. For a cleaner oscillator at a known pitch, render one cycle holding only the harmonics below Nyquist into a buffer and scan it (section 9).


3. Filters

Four families, all stateful (each application of a filter SF owns its own state, so sample it once per sample period, in order):

Family Constructors Slope Cutoff
one-pole low-pass, high-pass (coefficient); one-pole-lp, one-pole-hp, dc-block (Hz) 6 dB/oct fixed
biquad (RBJ cookbook) biquad-lp, -hp, -bp, -notch, -allpass, -peak, -low-shelf, -high-shelf; biquad (raw coefficients) 12 dB/oct fixed
state-variable (TPT) svf-lp, svf-hp, svf-bp, svf-notch 12 dB/oct a signal
ladder (ZDF) ladder 24 dB/oct a signal

Everything that takes Hz takes the sample rate first, like signal/delay: (biquad-lp 48000.0 1200.0 0.7071). Sample the result on that grid, t = n / sample-rate. Cutoffs are clamped to [0, 0.49 * sample-rate] and q to at least 0.01; a biquad given a non-positive sample rate passes its input through unchanged.

One-pole filters and the DC blocker

The two original filters take a smoothing coefficient: low-pass alpha is the exponential moving average y = alpha*x + (1-alpha)*y[n-1], and high-pass alpha is x - low-pass(x). one-pole-lp and one-pole-hp are the same filters with a cutoff in Hz. dc-block removes a constant offset -- use it after anything that leaves one behind (a rectifier, an asymmetric clip, ring modulation of an offset signal); 5 to 20 Hz is a typical corner.

Feeding a filter a constant 1.0 (a step) and sampling it repeatedly walks its step response -- the time argument is ignored, the call count is not:

(defmodule demo
  (import signal/core   :refer [constant sample])
  (import signal/filter :refer [low-pass one-pole-lp dc-block])

  (defn main [] : int
    (let [lp ((low-pass 0.5) (constant 1.0))]
      (println (sample lp 0.0))     ;; 0.5
      (println (sample lp 0.0))     ;; 0.75
      (println (sample lp 0.0)))    ;; 0.875
    (let [hz ((one-pole-lp 48000.0 1000.0) (constant 1.0))]
      (println (sample hz 0.0)))    ;; 0.122694 (alpha for 1 kHz at 48 kHz)
    (let [dc ((dc-block 48000.0 10.0) (constant 1.0))]
      (println (sample dc 0.0))     ;; 1
      (println (sample dc 0.0)))    ;; 0.998692 (and on down toward 0)
    0))

Biquads, and measuring a filter

The biquad-* constructors are the Audio-EQ-Cookbook designs (Robert Bristow-Johnson): second-order sections whose coefficients are fixed when the filter is built.

Constructor Parameters Response
biquad-lp / biquad-hp sr, freq, q 12 dB/oct; gain q at freq (0.7071 is maximally flat, -3 dB)
biquad-bp sr, freq, q band-pass, 0 dB at freq; higher q is narrower
biquad-notch sr, freq, q removes freq
biquad-allpass sr, freq, q unit gain everywhere, phase turns through 360 deg around freq
biquad-peak sr, freq, q, gain-db boost or cut gain-db around freq
biquad-low-shelf / biquad-high-shelf sr, freq, q, gain-db boost or cut everything below / above freq
biquad b0, b1, b2, a1, a2 raw coefficients, a0 = 1

Because a signal is a function you can sample, a filter's frequency response is a few lines of code away: run a sine through it, let it settle, and measure the level. level below does that; the printed values are the filters' design gains:

(defmodule demo
  (import signal/core   :refer [constant sample])
  (import signal/osc    :refer [sine])
  (import signal/filter :refer [biquad-lp biquad-hp biquad-bp biquad-notch
                                biquad-peak svf-lp ladder])
  (load "stdlib/math.tur")   ;; sqrt

  ;; level -- amplitude of a filtered tone at 48 kHz: let it settle for 0.1 s,
  ;; then measure sqrt(2 * mean(y^2)) over the next 0.1 s.
  (defn level [^fat y : (fn [float] float)] : float
    (let [^mut k   0
          ^mut acc 0.0]
      (while (< k 9600)
        (let [v (sample y (/ (as float k) 48000.0))]
          (when (>= k 4800) (set! acc (+ acc (* v v)))))
        (set! k (+ k 1)))
      (sqrt (/ acc 2400.0))))

  (defn main [] : int
    (let [tone ((sine 1000.0 0.0) (constant 0.0))
          at1k (constant 1000.0)]
      (println (level ((biquad-lp 48000.0 1000.0 0.7071) tone)))    ;; 0.7071 (-3 dB)
      (println (level ((biquad-lp 48000.0 1000.0 4.0) tone)))       ;; 4 (gain q at the cutoff)
      (println (level ((biquad-lp 48000.0 250.0 0.7071) tone)))     ;; 0.0622119 (2 octaves up: -24 dB)
      (println (level ((biquad-hp 48000.0 250.0 0.7071) tone)))     ;; 0.998062
      (println (level ((biquad-bp 48000.0 1000.0 4.0) tone)))       ;; 1
      (println (level ((biquad-notch 48000.0 1000.0 4.0) tone)))    ;; 6.64907e-14
      (println (level ((biquad-peak 48000.0 1000.0 1.0 6.0) tone))) ;; 1.99526 (+6 dB)
      (println (level ((svf-lp 48000.0 at1k 4.0) tone)))            ;; 4 (same as biquad-lp)
      (println (level ((ladder 48000.0 at1k 0.0) tone))))           ;; 0.25 (-12 dB: four poles)
    0))

tone feeds nine filters here. That is fine because an oscillator is pure; each filter is a fresh instance with its own state. Feeding one filter to nine consumers would not be.

State-variable and ladder filters: the cutoff is a signal

A biquad's coefficients are computed once. Recomputing them every sample to sweep the cutoff works badly: the direct form's stored state was produced by the old coefficients, so a moving cutoff clicks, and a fast resonant sweep can go unstable. The state-variable filter is built to be swept: it is a trapezoidal integrator network (Andrew Simper's form of the topology-preserving SVF) whose state stays meaningful however the cutoff moves. So its cutoff is a signal, read every sample at the same t as the input. Pass (constant hz) to hold it; pass an LFO, an envelope or any lambda to move it.

ladder is the Moog topology: four one-poles in a row with the output fed back to the input, solved without a unit delay in the loop (Vadim Zavalishin's zero-delay-feedback ladder). It is 24 dB/oct, its cutoff is a signal too, and its resonance runs from 0 to 1. It is the linear model -- no saturation -- so its level does not depend on the input's, and the passband drops as the resonance rises (DC gain 1 / (1 + 4*resonance); follow it with gain to make that up). At resonance 1 it self-oscillates.

(defmodule demo
  (import signal/core   :refer [constant sample])
  (import signal/osc    :refer [sine])
  (import signal/filter :refer [svf-bp ladder])
  (load "stdlib/math.tur")   ;; sqrt

  ;; quarters -- render 1 s at 48 kHz, in order, and print the level of each
  ;; quarter second.
  (defn quarters [^fat y : (fn [float] float)] : int
    (let [^mut k   0
          ^mut acc 0.0]
      (while (< k 48000)
        (let [v (sample y (/ (as float k) 48000.0))]
          (set! acc (+ acc (* v v))))
        (set! k (+ k 1))
        (when (= (mod k 12000) 0)
          (println (sqrt (/ acc 6000.0)))
          (set! acc 0.0)))
      0))

  (defn main [] : int
    ;; A band-pass whose center is a ramp from 500 Hz to 4.5 kHz over one
    ;; second, swept across a steady 2 kHz tone. The tone comes through in the
    ;; quarter where the band passes 2 kHz.
    (let [tone   ((sine 2000.0 0.0) (constant 0.0))
          rising (fn [t : float] : float (+ 500.0 (* 4000.0 t)))]
      (quarters ((svf-bp 48000.0 rising 8.0) tone)))
    ;; prints 0.10482, 0.571767, 0.162979, 0.0827114

    ;; At resonance 1 the ladder is an oscillator: kick it once and it rings
    ;; at its cutoff at a constant level, for good.
    (let [kick (fn [t : float] : float (if (< t 0.00001) 1.0 0.0))]
      (quarters ((ladder 48000.0 (constant 1000.0) 1.0) kick)))
    ;; prints 0.0230724, 0.023074, 0.023074, 0.023074
    0))
Constructor Parameters Response
svf-lp / svf-hp sr, cutoff signal, q 12 dB/oct; gain q at the cutoff
svf-bp sr, cutoff signal, q band-pass, 0 dB at the center
svf-notch sr, cutoff signal, q removes the center
ladder sr, cutoff signal, resonance 24 dB/oct low-pass; self-oscillates at resonance 1

With a constant cutoff, svf-lp and biquad-lp are the same filter (both are the bilinear transform of one analog prototype, prewarped at the cutoff) and agree sample for sample; the test suite checks that. Reach for the biquads for fixed EQ, and for the SVF or ladder when anything moves.


4. Shapers

Shapers are stateless sample-wise transforms. Some take a parameter (gain, offset, saturate-tanh, hard-clip, clip); two are captureless (invert, abs-sf) and take no parameters at all -- bind the SF to a name first, then apply it:

(defmodule demo
  (import signal/core   :refer [constant sample])
  (import signal/shaper :refer [gain invert scale])

  (defn main [] : int
    (let [iv (invert)]
      (println (sample (iv (constant 0.5)) 0.0)))   ;; -0.5
    (let [g (gain 2.0)]
      (println (sample (g (constant 3.0)) 0.0)))    ;; 6
    (println (scale 0.0 1.0 -1.0 1.0 0.75))         ;; 0.5 (pure remap, not an SF)
    0))
Shaper Produces
gain g g * x
offset c x + c
invert -x
abs-sf |x|
saturate-tanh drive tanh(drive*x) / tanh(drive) -- soft clipping
hard-clip limit clip to [-limit, +limit]
clip lo hi clip to [lo, hi]
scale in-lo in-hi out-lo out-hi x linear remap of a single value (not an SF)

The Pair-consuming mixers (mix, add, multiply) consume the Signal (Pair Sample Sample) shape produced by pair-signals; add and multiply are captureless, like invert:

(let [mixed ((mix 0.5) (pair-signals sine-a sine-b))]  ;; alpha*x + (1-alpha)*y
  (sample mixed 0.0))
(let [ml   (multiply)
      ring (ml (pair-signals carrier modulator))]      ;; ring modulation (section 8)
  (sample ring 0.0))

5. Envelopes

An ADSR envelope shapes amplitude over the lifetime of a note. Build the parameters as a :copy struct with make-struct, then pass it by value to adsr-fixed:

(defmodule demo
  (import signal/core     :refer [constant sample])
  (import signal/envelope :refer [ADSRParams adsr-fixed])

  (defn main [] : int
    (let [params (make-struct ADSRParams 0.01 0.1 0.7 0.3)
          env    ((adsr-fixed params 0.5) (constant 0.0))]
      (println (sample env 0.005)))     ;; 0.5 (half way up the attack)
    0))

ADSRParams is attack, decay, sustain level, release. adsr-fixed takes a gate duration in seconds; adsr-gen is the convenience that uses a unit (1.0 s) gate. The envelope is computed analytically from t, so it is pure.

A complete voice

An A3 (220 Hz) sawtooth through a gain and a 2 kHz low-pass, multiplied by the envelope of a half-second note. The low-pass is stateful, so the note is rendered on the 48 kHz grid with buffer-render (section 9), and peak reads its level back at four points in its life:

(defmodule demo
  (import signal/core     :refer [constant pair-signals])
  (import signal/hf-osc   :refer [hf-sawtooth])
  (import signal/filter   :refer [biquad-lp])
  (import signal/shaper   :refer [gain multiply])
  (import signal/envelope :refer [ADSRParams adsr-fixed])
  (import signal/compose  :refer [effects-chain])
  (import signal/buffer   :refer [Buffer buffer-render buffer-get])

  ;; peak -- the largest |sample| of `b` in [from, to).
  (defn peak [b : Buffer from : int to : int] : float
    (let [^mut i from
          ^mut m 0.0]
      (while (< i to)
        (let [v (buffer-get b i)
              a (if (< v 0.0) (- 0.0 v) v)]
          (when (> a m) (set! m a)))
        (set! i (+ i 1)))
      m))

  (defn main [] : int
    (let [osc   ((hf-sawtooth 220.0 48000.0) (constant 0.0))
          ^fat chain : (fn [float] float)
                (effects-chain (vec-of (gain 0.8) (biquad-lp 48000.0 2000.0 0.7071)) osc)
          env   ((adsr-fixed (make-struct ADSRParams 0.01 0.1 0.6 0.2) 0.5)
                 (constant 0.0))
          ml    (multiply)
          voice (ml (pair-signals chain env))
          note  (buffer-render 48000.0 36000 voice)]   ;; 0.75 s: gate + release
      (println (peak note 0 240))          ;; 0.385413 (the first 5 ms: rising)
      (println (peak note 12000 14400))    ;; 0.474386 (0.25 .. 0.3 s: sustain)
      (println (peak note 28800 31200))    ;; 0.236298 (0.6 .. 0.65 s: releasing)
      (println (peak note 33600 36000)))   ;; 9.9799e-17 (0.7 .. 0.75 s: done)
    0))

The sustain level is the filtered sawtooth's peak (about 0.99 after the low-pass) times the gain, 0.8, times the sustain, 0.6. effects-chain applies a Vec of SFs left-to-right to an input signal. It returns the chained signal as a raw pointer, so bind the result as a ^fat signal (as chain is here) before using it.


6. Delays, combs and allpasses

signal/delay holds ring-buffer effects. Two layers: the *-n constructors take the delay in whole samples and need no sample rate; the bare ones take (sample-rate seconds ...) and round seconds * sample-rate to the nearest sample. Delays are clamped to [1, 16777216] samples.

Constructor Produces
delay-n n / delay sr secs the input, n samples late (the first n outputs are 0)
comb-ff-n n g / comb-ff sr secs g feedforward comb: y = x + g*x[n-D] (always stable)
comb-n n g / comb sr secs g feedback comb: y = x + g*y[n-D] -- an echo train; needs |g| < 1
allpass-n n g / allpass sr secs g Schroeder allpass: unit gain, smeared phase; needs |g| < 1

Feedback gains are not clamped: |g| >= 1 grows without bound. A unit impulse shows each response:

(defmodule demo
  (import signal/core  :refer [sample])
  (import signal/delay :refer [delay-n comb allpass-n])

  ;; A unit impulse: 1.0 at t = 0, else 0.0. Sampled here at t = 0, 1, 2, ...
  (defn impulse [t : float] : float
    (if (< t 0.5) 1.0 0.0))

  (defn main [] : int
    (let [d ((delay-n 2) impulse)]
      (println (sample d 0.0))     ;; 0
      (println (sample d 1.0))     ;; 0
      (println (sample d 2.0)))    ;; 1 (the impulse, two samples late)
    ;; 100 Hz * 0.02 s = 2 samples, feedback 0.5: echoes 1, 0.5, 0.25 ...
    (let [c ((comb 100.0 0.02 0.5) impulse)]
      (println (sample c 0.0))     ;; 1
      (println (sample c 1.0))     ;; 0
      (println (sample c 2.0))     ;; 0.5
      (println (sample c 3.0))     ;; 0
      (println (sample c 4.0)))    ;; 0.25
    (let [a ((allpass-n 2 0.5) impulse)]
      (println (sample a 0.0))     ;; -0.5
      (println (sample a 1.0))     ;; 0
      (println (sample a 2.0)))    ;; 0.75
    0))

These are the parts of a Schroeder reverb: several feedback combs with unrelated delays (around 30 to 45 ms) in parallel, summed, then two or three short allpasses in series to diffuse the echoes.


7. Noise

Generators, like the oscillators: apply the SF to any signal (it is ignored). Output is always within [-1, 1].

Constructor Spectrum Method
white seed flat SplitMix64, uniform in [-1, 1)
pink seed -3 dB/oct (1/f) Kellet's 7-pole filter on white
brown seed -6 dB/oct leaky integral of white
blue seed +3 dB/oct first difference of pink
violet seed +6 dB/oct first difference of white

The seeded constructors keep their generator state in a private cell, so the signal's effect row is #fx{} and a seed always yields the same samples. The *-from constructors (white-from, pink-from, ...) instead take a source (fn [] #fx{e} float) of raw white samples and return a signal whose row is #fx{e}: pass (rng seed) and it stays pure; pass an entropy read or a live input and it is honestly #fx{IO}, and the compiler rejects using it from a function declared #fx{} (TUR-E0009).

(defmodule demo
  (import signal/core  :refer [constant sample])
  (import signal/noise :refer [rng white pink white-from])

  (defn main [] : int
    (let [z  (constant 0.0)
          w1 ((white 42) z)
          w2 ((white 42) z)
          p  ((pink 42) z)
          w3 ((white-from (rng 42)) z)]
      (println (sample w1 0.0))    ;; 0.48313
      (println (sample w2 0.0))    ;; 0.48313 (same seed, same stream)
      (println (sample p 0.0))     ;; 0.0875385 (the same stream, colored)
      (println (sample w3 0.0)))   ;; 0.48313 (white-from passes it through)
    0))

Noise through a filter is the raw material of most percussion: high-passed white under a short envelope is a hi-hat, band-passed it is a snare's rattle, and a slowly swept svf-bp over pink is wind.


8. Modulation

Modulation is one signal steering a parameter of another. Four kinds cover most of synthesis, and each is a few lines here.

Ring modulation

Multiply two signals. Since sin(a)*sin(b) = (cos(a-b) - cos(a+b)) / 2, ring-modulating a 440 Hz carrier with 100 Hz yields two new tones at 340 Hz and 540 Hz -- and neither of the originals. With non-harmonic ratios that is the metallic, bell-and-robot sound. Lift the modulator to 0.5 +/- 0.5 first and the carrier survives between the sidebands: that is amplitude modulation, and at a few Hz it is tremolo.

(defmodule demo
  (import signal/core   :refer [constant sample pair-signals])
  (import signal/osc    :refer [sine])
  (import signal/shaper :refer [gain offset multiply add])

  (defn main [] : int
    (let [z         (constant 0.0)
          carrier   ((sine 440.0 0.0) z)
          modulator ((sine 100.0 0.0) z)
          ml        (multiply)
          ring      (ml (pair-signals carrier modulator))
          ;; the same thing written as its two sidebands
          quarter   1.5707963267948966
          lower     ((gain 0.5) ((sine 340.0 quarter) z))
          upper     ((gain -0.5) ((sine 540.0 quarter) z))
          ad        (add)
          sides     (ad (pair-signals lower upper))
          ;; AM: the modulator scaled to 0..1
          depth     ((offset 0.5) ((gain 0.5) modulator))
          ml2       (multiply)
          am        (ml2 (pair-signals carrier depth))]
      (println (sample ring 0.0123))   ;; 0.521033
      (println (sample sides 0.0123))  ;; 0.521033 (340 Hz and 540 Hz)
      (println (sample am 0.0025))     ;; 0.587785 (modulator at its peak: all carrier)
      (println (sample am 0.0075)))    ;; 0 (modulator at its trough: silence)
    0))

Ring and AM are products of pure signals, so they are pure. If either input carries a DC offset, follow the product with dc-block.

Phase modulation (PM)

(pm-sine freq) is an SF whose input is a phase offset in radians: it emits sin(2*pi*freq*t + m(t)). With a sine modulator, the modulator's peak is the modulation index, and the spectrum is the carrier plus sidebands at carrier +/- n * modulator Hz whose strengths follow the Bessel functions of the index -- a higher index, a brighter sound. This is what most digital "FM" synthesizers (the Yamaha DX7 included) actually do.

The carrier:modulator ratio picks the timbre. Integer ratios (1:1, 1:2, 2:1) keep the sidebands on a harmonic series; others (1:1.4, 1:3.5) put them off it, which is how FM makes bells and gongs. And an envelope on the index makes the tone start bright and mellow as it decays:

(defmodule demo
  (import signal/core     :refer [constant sample pair-signals])
  (import signal/osc      :refer [sine pm-sine])
  (import signal/shaper   :refer [gain multiply])
  (import signal/envelope :refer [ADSRParams adsr-fixed])
  (load "stdlib/math.tur")   ;; sin, for the hand-computed reference

  (defn main [] : int
    ;; A 110 Hz modulator scaled to 2 radians: index 2.
    (let [z     (constant 0.0)
          index ((gain 2.0) ((sine 110.0 0.0) z))
          pm    ((pm-sine 440.0) index)
          t     0.0123
          pi    3.141592653589793
          phase (+ (* 2.0 pi 440.0 t) (* 2.0 (sin (* 2.0 pi 110.0 t))))]
      (println (sample pm t))       ;; -0.863752
      (println (sin phase)))        ;; -0.863752 (by hand)
    ;; A bell: ratio 1:1.4, index enveloped from 6 down to 0 over 1.5 s.
    (let [z     (constant 0.0)
          decay ((adsr-fixed (make-struct ADSRParams 0.001 1.5 0.0 0.1) 1.6) z)
          depth ((gain 6.0) decay)
          ml    (multiply)
          index (ml (pair-signals depth ((sine 280.0 0.0) z)))
          bell  ((pm-sine 200.0) index)]
      (println (sample depth 0.001))    ;; 6 (the index at the end of the attack)
      (println (sample depth 0.751))    ;; 3 (half way down the decay)
      (println (sample bell 0.0123)))   ;; -0.973631
    0))

pm-sine is pure: it computes its phase from t, so it can be sampled in any order, and its modulator may be any signal -- including another pm-sine, for DX-style stacks of operators.

Frequency modulation (FM)

(fm-sine sample-rate freq) is an SF whose input is a frequency deviation in Hz: the instantaneous frequency is freq + m(t), and the phase is its running integral (trapezoidal, one step per sample). So a modulator that peaks at 220 Hz swings the pitch 220 Hz either way, and a constant modulator simply retunes the carrier -- a negative total frequency runs the phase backwards (through-zero FM). Because it integrates, fm-sine is stateful: sample it once per sample, in order, on its sample-rate grid.

FM and PM are two views of one thing. The phase that FM integrates up from D*cos(2*pi*fm*t) Hz is (D/fm)*sin(2*pi*fm*t) radians, so FM by a cosine of peak D is PM by a sine of index D/fm:

(defmodule demo
  (import signal/core   :refer [constant sample])
  (import signal/osc    :refer [sine fm-sine pm-sine])
  (import signal/shaper :refer [gain])

  ;; walk-to -- walk `y` through n = 0, 1, ..., k on a 48 kHz grid and return
  ;; sample k. A stateful signal has to be walked up to the time you want.
  (defn walk-to [^fat y : (fn [float] float) k : int] : float
    (let [^mut n 0
          ^mut v 0.0]
      (while (<= n k)
        (set! v (y (/ (as float n) 48000.0)))
        (set! n (+ n 1)))
      v))

  (defn main [] : int
    (let [z     (constant 0.0)
          ;; 440 Hz, swung +/- 220 Hz by a 110 Hz cosine ...
          swing ((gain 220.0) ((sine 110.0 1.5707963267948966) z))
          fm    ((fm-sine 48000.0 440.0) swing)
          ;; ... is 440 Hz phase-modulated with index 220 / 110 = 2.
          pm    ((pm-sine 440.0) ((gain 2.0) ((sine 110.0 0.0) z)))
          ;; Slow, shallow FM is vibrato: +/- 6 Hz, five times a second.
          vib   ((fm-sine 48000.0 440.0) ((gain 6.0) ((sine 5.0 0.0) z)))]
      (println (walk-to fm 590))                   ;; -0.855503
      (println (sample pm (/ 590.0 48000.0)))    ;; -0.855517 (the same, to 1e-5)
      (println (walk-to vib 24000)))               ;; 0.675463 = sin(2.4)
    0))

The vibrato line shows what integration means: half a second in, the 6 Hz swing has pushed the phase (6/5) * (1 - cos(5*pi)) = 2.4 radians ahead of a plain 440 Hz sine, and it stays there until the swing brings it back.

Which to use? The sounds are interchangeable for a sine modulator; the parameters differ. PM keeps the index -- and so the timbre -- fixed when you change the modulator's pitch, it is pure, and a DC offset in the modulator does nothing audible. FM is the one to use when the modulator is a pitch (vibrato, glide, pitch envelopes, through-zero effects), because what you specify is a deviation in Hz.


9. Buffers and buffer scanning

signal/buffer holds sample buffers. A Buffer is a fixed-length array of samples, shared by handle. Fill it by rendering a signal into it, or by writing samples:

Function Does
buffer-render sr n sig a new buffer of n samples, sample k = sig at t = k / sr (sampled in order, so stateful signals render correctly)
buffer-new n n silent samples
buffer-len buf the length
buffer-get buf i / buffer-set! buf i x one sample; the index wraps, so -1 is the last
buffer-read buf pos linearly interpolated read at pos buffer lengths (wraps)
scan buf an SF: input = read position, output = the buffer there

scan is where buffers meet signals. Its input signal is the read position in buffer lengths -- 0 is the start, 0.5 the middle, and it wraps, so 1 is the start again and -0.25 is three quarters in. Whatever drives the position decides how the buffer plays, and sawtooth, a ramp in [0, 1), is the natural driver:

(defmodule demo
  (import signal/core   :refer [constant sample pair-signals map-signal])
  (import signal/osc    :refer [sine sawtooth triangle])
  (import signal/shaper :refer [gain add])
  (import signal/buffer :refer [buffer-render buffer-len scan])

  (defn main [] : int
    ;; A wavetable: one cycle of a 1 Hz shape, 2048 points, scanned at 220 Hz.
    (let [z     (constant 0.0)
          ad    (add)
          shape (ad (pair-signals ((sine 1.0 0.0) z)
                                  ((gain 0.3) ((sine 3.0 0.0) z))))
          table (buffer-render 2048.0 2048 shape)
          osc   ((scan table) ((sawtooth 220.0) z))]
      (println (buffer-len table))               ;; 2048
      (println (sample osc 0.001))               ;; 0.72899
      (println (sample shape (* 220.0 0.001))))  ;; 0.728989 (the shape, 220x faster)
    ;; Half a second of a 440 Hz tone at 48 kHz, played four ways.
    (let [z        (constant 0.0)
          tape     (buffer-render 48000.0 24000 ((sine 440.0 0.0) z))
          normal   ((scan tape) ((sawtooth 2.0) z))   ;; once per 0.5 s
          fifth    ((scan tape) ((sawtooth 3.0) z))   ;; 1.5x faster
          backward ((scan tape) ((gain -1.0) ((sawtooth 2.0) z)))
          ;; a triangle is -1..1; map it to 0..0.5 of the tape and back
          rocking  (map-signal (fn [x : float] : float (* 0.25 (+ x 1.0)))
                               ((triangle 0.5) z))
          scrub    ((scan tape) rocking)]
      (println (sample normal 0.01))                 ;; 0.587785
      (println (sample ((sine 440.0 0.0) z) 0.01))   ;; 0.587785 (the original)
      (println (sample fifth 0.01))                  ;; -0.587785
      (println (sample ((sine 660.0 0.0) z) 0.01))   ;; -0.587785 (660 Hz: a fifth up)
      (println (sample backward 0.01))               ;; -0.587785 (the tape at 0.49 s)
      (println (sample rocking 1.0))                 ;; 0.5 (half way in at t = 1 ...)
      (println (sample rocking 2.0)))                ;; 0 (... and back at t = 2)
    0))

scan keeps no state, so a scan driven by an oscillator is pure. Rendering is also how to freeze a stateful signal: (buffer-render 48000.0 n ((white 1) z)) is noise you can sample in any order, loop, or read twice.

A table scanned at a high pitch aliases just as a naive oscillator does whenever it holds harmonics above Nyquist divided by the pitch; render bright tables for low notes, or several tables band-limited per octave.

Reading between samples: four interpolators

A read at a position between two samples has to make a value up, and how is a choice. buffer-read and scan draw a straight line between the two neighbours. Three more pairs make other choices:

Read Scan Between two samples Cost per read
buffer-read-nearest scan-nearest the nearer sample: a step function least
buffer-read scan a straight line between the neighbours --
buffer-read-cubic scan-cubic a smooth curve through four samples (4-point Hermite, Catmull-Rom) a little more than linear
buffer-read-sinc scan-sinc a 32-tap Kaiser-windowed sinc: the band-limited signal the samples came from about 20 times linear

All four return the stored sample on the grid and wrap the same way, so they are interchangeable wherever scan is used. Off the grid they differ in how much of the band survives. Read 0.37 or 0.037 samples further on per sample, against the sine each recording holds, the worst errors are:

Recording at 48 kHz nearest linear cubic sinc
440 Hz 3e-2 4e-4 3e-6 3e-6
16.8 kHz (0.35 cycles per sample) 1.04 0.54 0.36 4e-6

Linear is fine for low content and smooth control tables; cubic buys two orders of magnitude there for almost nothing; only sinc keeps the top of the band, up to 0.4 cycles per sample (19.2 kHz at 48 kHz) within 1e-5. Nearest is for tables whose entries are not samples of anything -- note numbers, steps -- where a value between two of them means nothing:

(defmodule demo
  (import signal/core   :refer [constant sample map-signal])
  (import signal/osc    :refer [sawtooth])
  (import signal/buffer :refer [Buffer buffer-new buffer-set! buffer-read
                                buffer-read-nearest buffer-read-cubic
                                buffer-read-sinc scan-nearest])

  ;; one cycle of a sine, sampled four times: 0, 1, 0, -1
  (defn diamond [] : Buffer
    (let [b (buffer-new 4)]
      (buffer-set! b 1 1.0)
      (buffer-set! b 3 -1.0)
      b))

  (defn main [] : int
    ;; half way from sample 0 to sample 1, where the sine is 0.707107
    (let [d (diamond)]
      (println (buffer-read-nearest d 0.125))   ;; 1 (0.5 rounds up)
      (println (buffer-read d 0.125))           ;; 0.5
      (println (buffer-read-cubic d 0.125))     ;; 0.625
      (println (buffer-read-sinc d 0.125)))     ;; 0.707112
    ;; four notes, one per quarter cycle of a 1 Hz ramp
    (let [notes (buffer-new 4)
          z     (constant 0.0)
          pos   (map-signal (fn [p : float] : float (- p 0.125))
                            ((sawtooth 1.0) z))
          step  ((scan-nearest notes) pos)]
      (buffer-set! notes 0 60.0)
      (buffer-set! notes 1 64.0)
      (buffer-set! notes 2 67.0)
      (buffer-set! notes 3 72.0)
      (println (buffer-read notes 0.4))          ;; 65.8 (no such note)
      (println (sample step 0.3))                ;; 64
      (println (sample step 0.5)))               ;; 67
    0))

Each sample owns the positions within half a sample of it, so the ramp is moved back half a step (- 0.125, half of 1/4): entry k then plays for the quarter cycle [k/4, (k+1)/4) instead of the quarter centred on k/4.

None of the four filters. Scan a buffer faster than one sample per sample period -- transpose a recording up -- and whatever it holds above the new Nyquist frequency aliases, sinc or not: the sinc read reconstructs the recorded band, it does not narrow it.


10. Pitches, melodies and cycles

Note numbers and Hz: mtof and ftom

signal/pitch converts between MIDI note numbers and frequencies, in twelve-tone equal temperament with A4 = 440 Hz = note 69, so middle C (C4) is note 60. mtof goes from note to Hz, ftom back. Note numbers are floats: each 1.0 is a semitone, each 12.0 an octave, and 60.5 is a quarter tone above middle C. Both are plain float -> float functions; map-signal lifts one onto a signal, so a pitch contour in notes becomes a frequency signal:

(defmodule demo
  (import signal/core  :refer [sample map-signal])
  (import signal/pitch :refer [mtof ftom])

  (defn main [] : int
    (println (mtof 69.0))       ;; 440 (A4)
    (println (mtof 60.0))       ;; 261.626 (middle C)
    (println (mtof 72.0))       ;; 523.251 (an octave up: twice the Hz)
    (println (ftom 450.0))      ;; 69.3891 (A4, 39 cents sharp)
    ;; A glide up one whole tone a second, from A4.
    (let [glide (fn [t : float] : float (+ 69.0 (* 2.0 t)))
          hz    (map-signal mtof glide)]
      (println (sample hz 0.5)))  ;; 466.164 (B-flat 4, half way)
    0))

ftom of a frequency that is not a tempered pitch is fractional: round it for the nearest note, and the rest is the offset in semitones (times 100 for cents). The frequency must be positive.

Playing a list of pitches in order

A melody is a list of note numbers and a step length. The note sounding at time t is the one at index t / step, so the pitch is a pure function of time and needs no state of its own. Two details make it sound right:

heard checks the result: it counts the rendered audio's zero crossings while each note is held and converts the count back to a note with ftom.

(defmodule demo
  (import signal/core     :refer [constant sample pair-signals])
  (import signal/osc      :refer [fm-sine])
  (import signal/pitch    :refer [mtof ftom])
  (import signal/shaper   :refer [multiply])
  (import signal/envelope :refer [ADSRParams adsr-fixed])
  (import signal/buffer   :refer [Buffer buffer-render buffer-get])

  ;; note-at -- the note sounding at time t: one note per `step` seconds,
  ;; in order, starting over after the last.
  (defn note-at [notes : (Vec float) step : float t : float] : float
    (vec-get notes (mod (as int (/ t step)) (vec-len notes))))

  ;; heard -- the nearest note to what `b` plays in [from, from + n) at
  ;; 48 kHz: upward zero crossings per second, back to a note number.
  (defn heard [b : Buffer from : int n : int] : int
    (let [^mut i  (+ from 1)
          ^mut up 0]
      (while (< i (+ from n))
        (when (and (< (buffer-get b (- i 1)) 0.0) (>= (buffer-get b i) 0.0))
          (set! up (+ up 1)))
        (set! i (+ i 1)))
      (as int (+ 0.5 (ftom (/ (as float up) (/ (as float n) 48000.0)))))))

  (defn main [] : int
    (let [melody [60.0 64.0 67.0 72.0]   ;; C4 E4 G4 C5
          step   0.25                     ;; seconds per note
          z      (constant 0.0)
          freq   (fn [t : float] : float (mtof (note-at melody step t)))
          tone   ((fm-sine 48000.0 0.0) freq)
          env    ((adsr-fixed (make-struct ADSRParams 0.005 0.05 0.7 0.04) 0.2) z)
          per    (fn [t : float] : float
                   (sample env (- t (* step (as float (as int (/ t step)))))))
          ml     (multiply)
          voice  (ml (pair-signals tone per))
          out    (buffer-render 48000.0 48000 voice)]   ;; one pass: 1 s
      (println (heard out 0 9600))       ;; 60
      (println (heard out 12000 9600))   ;; 64
      (println (heard out 24000 9600))   ;; 67
      (println (heard out 36000 9600)))  ;; 72
    0))

Change melody and step to play something else; past the last note it starts over. To play it once, clamp the index instead of wrapping it. Swap fm-sine for a band-limited sawtooth and the pitch still has to reach it as a frequency signal, which is what fm-sine's input is for.

A cycle: the next value, each time it is asked

note-at answers "what is playing at time t?". Event-driven code asks a different question -- "what comes next?" -- every time a note starts, a step fires or a key is pressed. The natural shape for that is a closure that owns its position: cycle takes a list and returns a function of no arguments that hands out the values in order, one per call, starting over after the last. It is the same pattern as signal/noise's rng: the state lives in a mutable only the closure can reach, so each cycle call makes an independent source.

(defmodule demo
  (import signal/pitch :refer [mtof])

  ;; cycle -- hand out `values` in order, one per call, wrapping around.
  (defn cycle [values : (Vec float)] : (fn [] float)
    (let [^mut i 0]
      (fn [] : float
        (let [v (vec-get values (mod i (vec-len values)))]
          (set! i (+ i 1))
          v))))

  (defn main [] : int
    (let [next-note (cycle [60.0 64.0 67.0])
          next-amp  (cycle [1.0 0.5 0.75 0.5])
          other     (cycle [60.0 64.0 67.0])]
      ;; Three notes against four accents: the pairing repeats every 12.
      (println (next-note))      ;; 60
      (println (next-amp))       ;; 1
      (println (next-note))      ;; 64
      (println (next-amp))       ;; 0.5
      (println (next-note))      ;; 67
      (println (next-amp))       ;; 0.75
      (println (next-note))      ;; 60
      (println (next-amp))       ;; 0.5
      (println (mtof (next-note)))   ;; 329.628 (E4)
      (println (other)))             ;; 60 (its own position)
    0))

A cycle is stateful in the same way a filter or a noise generator is: every call advances it. Do not sample it from inside a signal -- a signal is asked for a value 48,000 times a second, and would race through the list -- and do not share one cycle between two consumers that each expect the whole sequence; give each its own. When the question really is "what is playing at time t", use the pure note-at shape above instead.


11. Tunings: Scala scales and keyboard mappings

mtof knows one tuning. signal/tuning handles any, described the way the Scala program and most microtonal software describe one -- in two parts:

tuning-new joins a scale and a keymap, and mtof-tuned / ftom-tuned are mtof / ftom for that tuning. Note numbers are floats, as before: note 69.5 lies half way, in cents, between notes 69 and 70, so a pitch bend glides through the tuning's own steps. Under (scale-edo 12) and (keymap-linear 60 69 440.0), mtof-tuned is mtof. Partially applied, (mtof-tuned t) is a float -> float function, ready for map-signal:

(defmodule demo
  (import signal/core   :refer [sample map-signal])
  (import signal/pitch  :refer [mtof])
  (import signal/tuning :refer [Pitch Ratio scale-new scale-edo scale-cents
                                keymap-linear tuning-new mtof-tuned
                                ftom-tuned])

  (defn main [] : int
    (let [std (tuning-new (scale-edo 12) (keymap-linear 60 69 440.0))
          t19 (tuning-new (scale-edo 19) (keymap-linear 60 69 440.0))]
      (println (mtof-tuned std 60.0))     ;; 261.626
      (println (mtof 60.0))               ;; 261.626 (the same)
      (println (mtof-tuned t19 70.0))     ;; 456.348 (a 19th of an octave up)
      (println (mtof-tuned t19 88.0))     ;; 880 (19 notes: the octave)
      (println (ftom-tuned t19 500.0))    ;; 72.5041
      (let [bend (map-signal (mtof-tuned t19)
                             (fn [t : float] : float (+ 69.0 t)))]
        (println (sample bend 0.5))))     ;; 448.1 (half a step up)
    ;; 5-limit just intonation, from ratios
    (match (scale-new "5-limit just major"
                      [(Ratio 9 8) (Ratio 5 4) (Ratio 4 3) (Ratio 3 2)
                       (Ratio 5 3) (Ratio 15 8) (Ratio 2 1)])
      (Ok just) (let [t (tuning-new just (keymap-linear 60 60 264.0))]
                  (println (scale-cents just 4))      ;; 701.955 (a pure fifth)
                  (println (mtof-tuned t 64.0))       ;; 396 (degree 4: 264 * 3/2)
                  (println (mtof-tuned t 67.0)))      ;; 528 (degree 7: the 2/1)
      (Err m)   (println "bad scale"))
    0))

Under a linear keymap a scale's degrees fall on consecutive notes, so a 7-note scale's octave is 7 notes wide: note 67 above is degree 7, the 2/1. To lay such a scale out on the white keys instead, map it -- next.

Scala files: .scl and .kbm

Scales and keymaps read and write Scala's text formats, so the thousands of scales in the Scala archive, and the mappings other synths export, load directly. In both, lines starting with ! are comments.

Format Reads Writes Lines, in order
.scl scl-parse text, scl-load path scale->scl scale a description; the number of pitches; one pitch per line -- cents if it holds a . (701.955), else a ratio (3/2) or a whole number (2)
.kbm kbm-parse text, kbm-load path keymap->kbm keymap map size; first and last note to retune; middle note; reference note; its frequency; formal octave degree; then one mapping entry per key, a degree or x

The readers return (Ok value) or (Err message), the message naming the line at fault; the writers return the file's text as an owned String, which reads back to the same value (cents are written in the fewest digits that round-trip). Programs using either load stdlib/string.tur before their defmodule, as signal/tuning does, so the two share one String type.

A mapping pattern maps a run of keys onto degrees and repeats. Its classic use is a 7-note scale on the white keys: a 12-key pattern starting at middle C, x for each black key, repeating a formal octave of 7 degrees higher each time. A key marked x is unmapped: tuning-mapped? says so, and a player would normally leave it silent, but mtof-tuned still gives it the pitch between its mapped neighbours, so that a bend passes through it smoothly.

(load "stdlib/string.tur")

(defmodule demo
  (import signal/tuning :refer [Pitch Ratio Scale Tuning scale-new scl-parse
                                scale->scl kbm-parse keymap-linear tuning-new
                                tuning-mapped? mtof-tuned])

  ;; 12 keys from middle C (60); A4 (69) at 440 Hz; formal octave 7 degrees
  (defn white-keys [s : Scale] : Tuning
    (match (kbm-parse "12\n0\n127\n60\n69\n440.0\n7\n0\nx\n1\nx\n2\n3\nx\n4\nx\n5\nx\n6\n")
      (Ok k)  (tuning-new s k)
      (Err m) (tuning-new s (keymap-linear 60 69 440.0))))

  (defn main [] : int
    (match (scl-parse "! just.scl\n5-limit just major\n 7\n 9/8\n 5/4\n 4/3\n 3/2\n 5/3\n 15/8\n 2/1\n")
      (Ok just) (let [t (white-keys just)]
                  (println (mtof-tuned t 60.0))       ;; 264 (C: A4 * 3/5)
                  (println (mtof-tuned t 62.0))       ;; 297 (D: C * 9/8)
                  (println (mtof-tuned t 72.0))       ;; 528 (C5)
                  (println (tuning-mapped? t 61))     ;; false (C#4 ...)
                  (println (mtof-tuned t 61.0))       ;; 280.014 (... between C and D)
                  (println (string/to-cstr (scale->scl just))))
      (Err m)   (println (string/to-cstr m)))
    (match (scl-parse "Two notes\n 2\n 3/0\n 2/1\n")
      (Ok s)  (println "parsed?")
      (Err m) (println (string/to-cstr m)))   ;; line 3: the ratio '3/0' is not positive
    0))

The scale->scl text is the same scale in a canonical layout:

! Written by tur-signal
!
5-limit just major
 7
!
 9/8
 5/4
 4/3
 3/2
 5/3
 15/8
 2/1

scl-load and kbm-load read a file and carry the #fx{FS} effect row; to write one, hand the text to stdlib fs/write-text: (fs/write-text "just.scl" (string/to-cstr (scale->scl just))). The same pattern is available without a file: keymap-new takes the middle note, the reference note and frequency, the formal octave degree and a list of (Degree d) / (Unmapped) entries, and keymap-entries gives such a list back.

Three details of the mapping follow the Scala format's own definitions:

ftom-tuned inverts mtof-tuned for any tuning whose pitch rises from each note to the next, which is every musical one. For a scale with a degree below the one before it, or a period of zero or fewer cents, there is no single answer, and it returns NaN.


Stateful signals and sample order

low-pass, high-pass and every other filter, every delay effect, every noise generator and fm-sine advance their internal state once per sample call and ignore t. Sample each instance exactly once per sample period, in order, starting at t = 0. Feeding one instance to two consumers (for example both halves of pair-signals) advances it twice, so each consumer sees only part of the stream and a delay's effective length changes. Build one instance per consumer -- applying the SF again is cheap -- or render the signal into a buffer and scan that. The oscillators (other than fm-sine), shapers, envelopes and scans are pure functions of t and have no such restriction.

Calling conventions worth knowing


When not to use this


Examples

Twelve runnable examples live under examples/:

cd spices/signal
tur run examples/01_constant_and_time.tur       # constant + time-signal
tur run examples/02_oscillators.tur             # sine/square/sawtooth/triangle at audible pitches
tur run examples/03_filters_and_shapers.tur     # one-pole filters + scalar shapers
tur run examples/04_envelopes.tur               # ADSR
tur run examples/05_simple_voice.tur            # an A3 voice: oscillator, chain, envelope
tur run examples/06_hf_oscillators.tur          # naive vs band-limited sawtooth at 7.9 kHz
tur run examples/07_delay_and_noise.tur         # delay, comb, allpass, colored noise
tur run examples/08_filters.tur                 # biquads, state-variable sweep, ladder
tur run examples/09_modulation_and_buffers.tur  # ring, AM, PM, FM, wavetable, tape
tur run examples/10_pitch_and_sequences.tur     # mtof/ftom, a melody in order, a cycle
tur run examples/11_interpolation.tur           # nearest/linear/cubic/sinc reads, a note table
tur run examples/12_tunings.tur                 # 19-EDO, just intonation on the white keys, .scl/.kbm

Each prints sample values that match the hand-computed references in its comments.


Tests

cd spices/signal
tur test tests/signal    # 12 tests, all pass

See also