Contents

Mono & Stereo Signals

Akkado has two kinds of audio signals: Mono (one channel) and Stereo (two channels, L and R). Channel count isn’t a runtime property — it’s a type the compiler tracks through every expression, so mismatches surface at compile time. Every audio effect is also stereo-native: it processes both channels in one pass, and a mono input automatically widens to stereo. You never duplicate a chain by hand.

Defaults

Generators (osc, noise, pulse) are mono by default; effects are stereo-native and preserve their input’s channel count. To start a chain in stereo, widen at the boundary:

// Mono — single channel
saw(220)

// Stereo — duplicate mono across both channels
saw(220) |> stereo(@)

// Stereo — distinct L and R
stereo(saw(218), saw(222))

out() accepts either:

saw(220) |> out(@)          // Mono → duplicated to L and R
stereo(saw(218), saw(222))
    |> out(@)               // Stereo → split into L and R
out(saw(218), saw(222))     // Two mono signals → L, R explicitly

Conversions

There are exactly two canonical conversions between the representations:

CallDirectionEffect
stereo(x)Mono → StereoDuplicate x to both L and R
stereo(l, r)two Mono → StereoPair l as L, r as R
mono(s)Stereo → MonoSum-to-mono with 0.5 gain: (L + R) * 0.5
left(s)Stereo → MonoExtract the left channel only
right(s)Stereo → MonoExtract the right channel only

Calling a conversion on a signal that’s already the target channel count is a warning, not an error — it almost always means something upstream didn’t do what you thought, but the expression still compiles and evaluates to the input unchanged. Some typical hits:

  • stereo(already_stereo_signal)W182
  • mono(already_mono_signal)W181
  • left(mono_signal) / right(mono_signal)W183 / W184
  • out(stereo_signal, other) as two-arg out()W185 (auto-escalates)

A genuine type mismatch — an audio-rate signal in a non-signal slot, for example — is still a hard error (E186).

Panning a stereo signal

pan() has two signatures dispatched by the channel type of its first argument:

// Mono → Stereo: equal-power mono pan
mono_sig |> pan(@, 0.3)

// Stereo → Stereo: equal-power stereo balance
stereo_sig |> pan(@, 0.3)

The stereo overload is DAW-style balance — L_out = L * cos θ, R_out = R * sin θ with θ = (p + 1) · π/4. At p = 0 both channels drop by ~3 dB (equal-power centre); at p = ±1 one channel is silenced. See the Stereo builtins reference for the math.

Stereo-native effects

Every audio effect is stereo-native: it processes both channels in a single dispatch with one per-channel state struct. A mono input automatically widens — the opcode reads it once and uses it for both the L and R lanes — so you never have to duplicate a chain or insert stereo() to get a stereo result.

bus = saw(220)           // Mono — widens automatically

bus
    |> lp(@, 500, 0.7)   // Stereo: per-channel filter state
    |> delay(@, 0.25, 0.5)      // Stereo: per-channel delay line
    |> out(@)                   // Stereo out

For channel-independent effects (filters, distortion, EQ, plain delays) this is exactly equivalent to writing:

sig = saw(220)

left_out = sig
    |> lp(@, 500, 0.7)
    |> delay(@, 0.25, 0.5)

right_out = sig
    |> lp(@, 500, 0.7)
    |> delay(@, 0.25, 0.5)

out(left_out, right_out)

Identical state handling, identical audio. Stateless effects (saturate, softclip, fold, distort) work the same way. Spatializing effects (reverbs, chorus, phaser, flanger) go further: a mono input widens into a genuinely decorrelated stereo output (cross-coupled reverb tanks, offset L/R LFO phases). A stereo input into a spatializing effect runs through a single cross-coupled instance — dattorro, freeverb, fdn each get one set of tanks with L↔R cross-bleed, not two independent mono reverbs in parallel.

Tuning stereo width on modulation FX

chorus, flanger, and phaser expose an extended parameter lfo_phase — the offset between the L and R LFOs, in turns (0.0–1.0). Default is 0.25 (= 90°), the classic stereo-modulation setting.

// Default: 90° offset, classic stereo chorus
saw(220)
    |> chorus(@, 0.5, 0.4)
    |> out(@)

// 0 = mono-equivalent (L = R); 0.5 = anti-phase
// (max width, may collapse on mono-summing)
saw(220)
    |> chorus(@, 0.5, 0.4, lfo_phase: 0.5)
    |> out(@)

saw(110)
    |> phaser(@, 0.3, 0.8, lfo_phase: 0)
    |> out(@)

phaser also exposes feedback and stages the same way. Named-argument syntax (name: value) skips intervening defaults — pass only what you want to change.

What stereo-native processing doesn’t change

  • Scalar parameters (cutoff, resonance, time, feedback) are shared between L and R. Both channels see the same value. If you want independent per-channel modulation, split the stream explicitly.
  • Pattern events (pat, seq, timeline) are always mono — a stereo synth driven by a mono pattern is the normal case.
  • Generators stay mono. osc, noise, pulse return Mono; the widening happens at the boundary into the first effect.
  • sample() is the exception — always Stereo. A mono file broadcasts to L=R; a stereo file preserves its L/R channels; files with 3+ channels keep the first two and drop the rest. Either way the output type is Stereo, so downstream effects skip the auto-widening step.
  • Cross-channel effects like width, pingpong, ms_encode/ms_decode have their own explicit stereo signatures.

Mixed-channel arithmetic

+, -, *, / on a mono and a stereo operand broadcast the mono side across both channels:

dry = saw(220)               // Mono

// Stereo — freeverb auto-widens
wet = dry |> freeverb(@, 0.9, 0.5)

// Stereo out (mono dry broadcast onto wet)
dry * 0.3 + wet * 0.7 |> out(@)

The dry * 0.3 stays mono, wet * 0.7 stays stereo, and mono + stereo promotes to stereo by dual-reading the mono buffer. No extra instructions.

Mono on mono stays mono. Stereo on stereo stays stereo (L op L, R op R). There’s no implicit mono-to-stereo promotion anywhere else — the compiler will complain if it can’t make sense of a mismatch.

Patterns and Signals

Patterns (n"…", v"…", c"…", s"…") carry a primary value buffer that doubles as a Signal. When you pass a pattern to a slot that expects a Signal — sine(n"c4 e4 g4"), lp(sig, v"<200 800>", 0.7) — the compiler implicitly extracts that buffer.

The Pattern → Signal coerce rule:

Pattern shapeCoerces to Signal?Where the buffer points
Monophonic note (n"…")Yesfreq field — Hz post-mtof
Numeric (v"…")Yesfreq field — raw scalar
Sample (s"…")YesAudio output (post-SAMPLE_PLAY)
Polyphonic chord (c"…")No — E160Use poly() to expand voices
Polyphonic note (e.g. n"[c4,e4]")No — E160Use poly()

Operator type rules

Arithmetic between patterns and other types follows these rules:

LHSRHSResultNotes
PatternPatternPatternPointwise op on freq buffers, longest wins
PatternSignalSignalPattern coerces; sample-rate result
PatternNumberPatternn"c4 e4" + 12 is still a Pattern
SignalSignalSignalStandard
PatternStereoE165Wrap with stereo(scalar(...)) explicitly

scalar() — the explicit cast

scalar(p) unwraps a monophonic pattern’s freq buffer. It’s idempotent on Signals (scalar(scalar(p)) is safe) and errors E161 on sample / polyphonic patterns. See the Pattern Literals reference.

See also