VOLTAreference

Reference / Movement and control

Glide between notes

Movement and control

Open in the workbench →
On this page · 5 sections

Connect pitches with a slide, from a quick bass scoop to a long portamento, and control how notes retrigger.

What it is#

A synth whose pitch ramps between consecutive notes instead of jumping. This is the 808 slide (trap), the 303 slide (acid / psytrance), and the reese glide (DnB) — one primitive. glide = <duration> turns it on; the base pitch then slides from the previous note's frequency to the current note's over the glide time, exponentially in Hz (= linearly in cents, the perceptually even glide).

glide defaults to monophonic (voices is set to 1) — the classic slide, where "the previous note" is a single pitch. Set voices = N (N > 1) to opt into polyphonic glide (pads, keys, chord stabs): each step's chord is assigned voice identities against the previous step, so each voice glides independently. See "Polyphonic glide" below and Poly Glide.

It is deterministic: the ramp is a pure function of consecutive event pitches (no RNG), so a cycle replays bit-identically.

Surface#

-- An 808 that slides 80 ms into every note (implies voices = 1)
eight = patch {
  glide  = 80ms,
  source = sine (freq pitch) |> drive 0.3,
  ampEnv = adsr 2ms 600ms 0 400ms,
  gain   = vel * 0.8
}

-- A 303: glide only on slurred steps, which also sustain (no re-attack)
acid = patch { glide = 60ms, glideMode = "legato", source = saw (freq pitch),
               ampEnv = adsr 3ms 300ms 0.2 150ms, gain = vel * 0.7 }

-- A trailing `_` ties a step into the next note — only those steps slide:
acidLine = play acid !"c2_ e2 g2 c3_  bb2 g2_ d2 c2"
  • glide = <Seconds|Milliseconds> — a constant duration, the synth's DEFAULT ramp (a continuously modulated glide time is meaningless, so it must be a literal; a note can still choose its own — next bullet). glide = 0ms (or omitting the field) is the no-glide default — byte-identical IR to a synth that never mentioned glide. Setting glide > 0 implies voices = 1; writing voices = N > 1 alongside it is a compile error.
  • Per-note glide time (glide=180ms) — a note's own ramp INTO it, in place of the synth's default, on a mono or poly glide synth. The value must be a time (180ms, 0.2s); a bare glide=180 is refused (ms? s?), as is a negative one. glide=0ms steps. It composes with the sigils and other params, so a string player's phrase writes each shift:

    violin = patch { glide = 15ms, source = saw (freq pitch),
                     ampEnv = adsr 12ms 0ms 1 240ms, gain = 0.3 }
    -- a slow expressive shift into a4, a finger change into b4,
    -- the patch's own 15 ms into c5
    phrase = play violin !"d4 _a4(glide=180ms) -b4 c5"

    From the track, a pattern lane sets it per note: |> glide !"<40 180 0>"ms (the pattern needs a time unit). A note's own token beats the lane. A constant |> glide 80ms is still a patch-template override (it changes the default). Either per-note spelling on a synth that does not glide is a compile error, not a silent no-op.

  • glideMode = "always" | "legato" — always (default) glides into every note and re-attacks the amp envelope. legato decides only the RE-ATTACK: an incoming note that overlaps the previous one (its onset is strictly before the previous note's release sample) sustains the envelope through the glide; any other note re-attacks. The pitch glides into every note in both modes. So two notes that merely touch (!"c3 e3", the second starting on the first one's release sample) are not an overlap: the second re-attacks AND bends in. To get the classic fingered-portamento step on a detached note, mark it: 'e3 re-attacks with a pitch step (no glide); to slide without re-attack, tie with c3_ or lengthen the first note past the second's onset. This differs from the design intent in Glide between notes §2.1 (a non-overlapping legato note "restarts — a step"); changing it would re-voice the 229 corpus songs that use glideMode = "legato", so it is left as built and recorded as an owner decision.
  • glideCurve = "linear" | "exponential" | "smooth" — the shape of the pitch slide. linear (default) is a constant-rate ramp, linear in cents (exponential in Hz), reaching the target exactly at the glide time. exponential is a one-pole ease-out lag (time-constant glide/4) — fast at first, easing asymptotically into the target: the analog 303/Moog portamento feel. On a fast passage it may not fully arrive before the next note (authentic); a held note settles in tune. Mirrors hardware's Lin/Exp portamento toggle. smooth eases in AND out — smoothstep (3t² − 2t³) in cents over the glide time: it leaves slowly, moves fastest mid-slide and lands gently, exactly at the glide time. That is the shape of a player's shift; its 10→90 % span is 0.61 × the glide time against linear's 0.8 ×, with the same midpoint.
  • Per-note slide _ — a trailing underscore on a pattern atom (c2_) ties that step into the next note: the step slides out into the following note, which glides in from this pitch and suppresses its own envelope re-attack, independent of glideMode (the TB-303 model). Composes with the velocity suffix (c2_=0.9) and chords.
  • Chord slide [a2,a3]_ — the marker also rides a bracket voicing: a _ after the ] distributes to every member that doesn't carry its own, so [a2,a3]_ ≡ [a2_,a3_] and a wide stack needs one marker, not five. [c4:maj]_ (a chord-quality token) inherits the same way, [a2_,a3]_ keeps the per-member spelling where it is written, and it composes with the chord velocity suffix in the atom's own order — slide first, then velocity: [a2,a3]_=0.9. ([a2,a3]=0.9_ diagnoses and names the right spelling.) Leave a space after the marker: [a2,a3]_ c3 is the sliding chord, but [a2,a3]_c3 is that chord followed by the glide-*in* note _c3 — no chord slide. The two differ only by the space, because a bare _ lexes as the chord suffix while _c3 lexes as one atom whose leading _ is the glide-override sigil. Only a following word can capture the marker this way; a following operator can't ([a2,a3]_*2 slides as written).
  • Per-note override _c3 / -c3 / 'c3 — a leading sigil overrides the retrigger for one note on a mono glide synth exactly as on a poly one (table under "Polyphonic glide"): _c3 glides in with no re-attack, -c3 holds (a pitch step with no re-attack — a slur's finger change), 'c3 re-attacks with a pitch step. The mono voice is kept in all three, so a stateful source keeps ringing.
  • Params after the slide — d4_(expression=0.5): the slide marker rides the atom, so it comes before a (key=value) group; d4(expression=0.5)_ is a parse error that names the right order.
  • Only on pitched atoms — a slide on a numeric token (a chop slice index / control value, !"7 2_ 5 ~") is an error at that token's span: "slide marker on a non-pitch token". There is no pitch to ramp; drop the marker.

How it's implemented#

  • Compiler
    • lower/expr.rs (lower_synth_decl) peels glide, glideMode, and glideCurve as directives (like voices): glide folds to seconds via const_seconds, glideMode / glideCurve validate the string. glide defaults voices to Some(1) and rejects voices > 1.
    • ir.rs — Synth.glide: Option<f64> (seconds), Synth.glide_mode: Option<GlideMode> (serialised glideMode, "always"/"legato"), and Synth.glide_curve: Option<GlideCurve> (serialised glideCurve, "linear"/"exponential"). TrackEvent.slide: bool.
    • mini.rs — split_slide_suffix peels a trailing _ from a pitch atom into MiniEvent.slide, applied to the pitch payload only (orthogonal to the token's payload, so c2_ classifies / validates exactly like c2). The chord form is sugar, not a fourth peel site: parse_primary's bracket arm peels the suffix word after the ] once (chord_suffix) and apply_chord_slide stamps _ onto each member atom lacking one — inside any velocity suffix, since velocity is the atom's last affix — after which the ordinary atom peel does the rest. On a numeric pattern detect_payload keeps a N_ token in the numeric class (so one bad token can't re-classify the pattern and misname the diagnostic) and validate rejects it at the atom's own span.
    • refine.rs — advisory glide ⊂ [0ms, 2s] (GlideRange).
    • Per-note time: mini.rs parse_param_value reads (glide=…) as a time into TrackEvent.params["glide"] in seconds (GLIDE_PARAM; the emitter writes it back as glide=180ms). lower/firstsound.rs keeps a |> glide <pattern> knob as a track lane instead of rewriting the field to a trackParam read, and lower/track.rs scales its values to seconds by the pattern's unit (glide_lane_scale). lower.rs validate_ctrl_destinations refuses either on a synth without glide.
  • Engine (engine/, the one renderer — desktop, browser AudioWorklet and plugin alike)
    • lib.rs keeps the per-synth glide state: glide_voices (synth → the persistent voice's arena slot), last_pitch (surviving rests, so only a synth's very first note is a step), and the previous event's slide flag. Because the trailing _ marks the step that slides out, the decision for the current note reads the previous event's flag, together with glideMode and note overlap, and yields glide_from + retrigger; the note's own glide_override (leading sigil) wins over both, and hold/attack pass a 0 s ramp. The ramp time is note_glide_seconds: the event's merged glide param (token, else lane) over the synth's constant — on the mono path, in schedule_glide_step (poly) and in the MPE bend mirror (event_glide_param). All of it is cleared on load / swap / stop / seek.
    • graph.rs carries glide_exponential on the synth template (glideCurve = "exponential"); SynthTemplate also carries the glide seconds and mode.
    • voice/eval_nodes.rs renders NoteParam(Pitch) as a per-sample ramp from glide_from to the new pitch, anchored at glide_start (which a legato renote moves independently of the envelope's start_sample): linear is a constant-rate ramp in MIDI (exponential in Hz, exact arrival); exponential is a one-pole Hz lag with τ = glide / GLIDE_SLEW_K (GLIDE_SLEW_K = 4, voice/mod.rs); smooth passes the linear fraction through smooth_glide_frac (graph.rs glide_smooth). A 0 s renote is a step AT glide_start; a renote landing mid-block draws the samples before it from the previous ramp (GlideRamp::pitch_at). Unison detune, vibrato and fm Hz terms sum on top of the gliding base.
    • voice/arena.rs — the first note spawns a fresh voice; later notes re-pitch the persistent voice via renote, re-ramping the running oscillators and (unless suppressed) re-firing the envelopes, so phase is continuous with no fade or overlap (true legato). A legato renote that lands after the outgoing note-off (a slide or tie across a rest) re-opens the released envelope: the adsr closes the gap between its release tail and the held curve along an RC fall over max(attack, 5 ms) instead of jumping in one sample (voice/eval_nodes.rs eval_adsr driving engine/src/adsr_core.rs, evolve volta_adsr). The envelope counts every segment, the release included, in whole samples (no f32 note-time grid after a long hold).

Notes & limits#

  • One ramp for a stack. A unison stack shares one PitchToHz (built once and cached), so it glides as a single ramp; the per-voice detune rides on top. A synth with a separate pitch port at a fixed interval (e.g. a sub-oscillator freq (pitch + 12), or an fm modulator) adds its offset in semitones on top of the gliding base pitch, so the interval is a constant Hz ratio and is preserved through the glide.
  • First note / post-seek is a step. A glide synth's first note (and the first note after transport stop / a scrub-seek) has no previous pitch, so it renders as a step. Documented, not configurable.
  • Stage 2 fallback overlap. Before a persistent voice exists, the first note builds a fresh voice; if the previous mono voice is still in its release tail, the existing 12 ms steal-fade briefly overlaps two oscillators (both near the previous pitch). The persistent-voice path (Stage 3) removes this for all subsequent notes.
  • Re-pitch keeps the trigger-time velocity. A renote re-fires the envelopes but does not rebuild the gain graph, so a sustained glide line keeps the velocity it triggered with.
  • Per-event controls travel with every note. A # ctrl "name" !"…" lane or a per-token (name=value) param reaches the gliding voice on the first note and on every note after, so a trackParam "name" read inside the patch follows the lane the way it does on an un-glided track — a 303 accent can be its own control instead of being smuggled in as velocity. A glide synth is one MONO (or reused poly) voice, so the values are re-applied at the renote, not only at the trigger. Until 2026-09-16 both note-on paths dropped them (trigger_glide passed no_event_params() and renote_glide took none), so the lane was silently inert on a glided line while the compile and the static doctor stayed clear; engine/tests/glide_retrigger.rs (a_ctrl_lane_reaches_a_gliding_voice_on_every_event) renders both directions of a two-note legato phrase and reads the level.
  • Retrigger is click-free. A re-attack on a persistent voice (always-mode, or any un-slurred step) ramps the amp/filter envelopes from their current value instead of resetting to 0 — a hard reset would step sustain→0 mid-note and pop. A fresh voice's first attack is unaffected (its value is already 0). Applies to every adsr, not just glide synths, and it is easy to get wrong: the envelopes ride Voice.env_anchor, not start_sample. start_sample gates eval/render (lo) and anchors every other note-local node, so moving it to the re-attack onset silently dropped the still-sounding voice from the block boundary up to that onset (up to one block, ~2.7 ms). Decay and Linram follow env_anchor too.
  • A continued note keeps sustaining. renote re-schedules the note's release on every step, so a note that is continued rather than re-articulated (-/_ override, a legato tie, a slid step) inherits a release sitting exactly at its own onset. The anchor simply never moves and release_sample is pushed out. Without this a "held" voice decays to silence across the very step it was meant to hold — and, since it had already decayed, a broken attack-from-0 on the next step looked continuous. The two defects masked each other; engine/tests/glide_retrigger.rs pins both.
  • A glide voice belongs to one track. Mono and poly glide voices are keyed by (track, synth); renote_glide re-pitches a live voice only on the note's own track. Two tracks playing one glide patch each own a voice, strip and pitch memory (two_tracks_sharing_a_glide_patch_each_keep_their_own_strip).
  • A renote in a release tail stays released until its onset. A voice lives ≈101 ms past note-off, so a glide note after a short rest re-pitches a releasing voice. renote keeps the displaced note-off (prev_release_sample), read through Voice::release_at by adsr, noteGate and the bow clock, so the block before the onset stays released — no step out of near-silence (renote_inside_the_release_tail_does_not_unrelease_the_old_note).
  • Legato bowed strings. A bowed source on a glide synth is ONE string for the whole phrase: a renote never resets node state, so the BowedState keeps ringing while the pitch moves and each note's (key=value) / trackParam values arrive at the boundary (engine/tests/bowed_legato.rs: the slur seam measures max |Δx| 1.1× the sustained median and its quietest 5 ms stays above the sustained RMS; a4 is reached within 15 cents). A re-attack (an un-slurred note) re-fires only the envelopes — adsr, decay, linram — and a slur re-fires none, which is what a per-stroke bow direction is built from:

    bow = trackParam "bow" 1                    -- `(bow=-1)` reverses one stroke
    fiddle = patch {
      glide  = 40ms,
      stroke = adsr 55ms 80ms 0.94 120ms,
      attack = linram 1 0 1ms,                  -- high at each re-attack, never on a slur
      bowDir = (toggle attack * 2 - 1) * bow,   -- +1 down-bow, -1 up-bow
      turn   = smooth 10ms bowDir,              -- the bow change passes through 0 in ~7 ms
      change = 1 - 0.2 * (1 - turn * turn),     -- pressure dips while the bow turns
      source = bowed (freq pitch) (0.7 * stroke * change) 0.25 {
        speed = 0.45 * stroke * turn, body = "violin"
      },
      ampEnv = adsr 12ms 0ms 1 240ms,
      gain   = 0.3
    }
    line = play fiddle !"d4 f4_ a4 -g4"

    d4 f4_ a4 -g4 plays down (d4), up (f4 slurring into a4), and g4 held in that bow with a finger step. A fresh voice — the first note, a note after the mono voice has died in a gap, every note of a non-glide (or growing poly) synth — starts a new string and a down-bow.

  • Wavetable sources glide smoothly. A wavetable oscillator routes its freq through the same chokepoint, so it glides like any oscillator. engine/src/wavetable.rs crossfades adjacent mip levels (continuous band-limiting) so the sweep no longer steps in timbre at octave boundaries — see Wavetable synthesis.
  • Renotes are sample-exact. Mono and poly glide re-pitch at the note's exact onset sample; the samples of that block before the onset read the previous ramp, velocity and envelope anchor (Voice.prev_ramp, prev_vel, prev_env_anchor). So a glide line renders bit-identically at any host callback size (engine/tests/per_note_glide.rs: 128 vs 37 frames with seams inside both partitions' blocks, overlapping ramps, slurs, holds and velocity changes). Still per-block: the voice's pan and its (key=value) / trackParam map switch at the start of the block holding a renote.
  • Sample sources. A plain sample read counts as pitch-carrying, so a pitched (or zoned) sample patch glides its READ RATE; a legato renote keeps its playhead and zone and a re-articulated one restarts the read (The sampler § "Glide and legato"). A static granular cloud glides grain by grain; a synth whose only source is a chop read (or a grain-motion cloud) still drops glide.
  • A glide the note pitch never reaches warns. On a patch whose graph reads neither pitch nor a plain sample (Synth::note_pitch_reaches_graph, the engine's own test), the engine drops glide and with it the persistent voice, so cross-note state (accent, bowed) restarts per note; the compiler says so at the directive (GlideWithoutPitch).
  • Slide on a non-glide synth is currently ignored silently (the compile-time advisory in Glide between notes §6 is not yet wired — it needs track→synth resolution).
  • Tempo-relative / rate glide is deferred — see Glide between notes §8.

Polyphonic glide#

glide defaults to mono, but voices = N (N > 1) makes it polyphonic — chords glide. Design rationale & staging: Poly Glide.

pad = patch {
  glide        = 120ms,
  voices       = 6,
  glideVoicing = "nearest",          -- "nearest" | "index" | "hold"
  source       = saw (freq pitch),
  ampEnv       = adsr 40ms 300ms 0.7 600ms,
  gain         = vel * 0.4
}
-- auto: the matcher assigns voice identity across steps
prog = play pad !"[c3 e3 g3] [d3 f3 a3] [e3 g3 b3] [c3 e3 g3]"

The core question is which sounding voice each new note continues — its voice identity. It is inferred by a policy, or authored by hand:

  • glideVoicing = "nearest" | "index" | "hold" — the auto policy. nearest (default) glides each note from the closest old voice (smooth voice-leading); index matches by sorted-pitch position (parallel motion); hold keeps common tones in place without re-attacking them.
  • Inline ::tag — c3::a pins a note to the voice a, so the same tag glides across steps regardless of pitch distance. Untagged notes fall back to the policy (pin-then-solve). Composes with _ (c3::a_) and =N.
  • voiceLines [v1, v2, …] — like stack, but each line is its own voice identity, so each glides independently. The sigil-free spelling; a note's explicit ::tag overrides its line. Best for monophonic lines.
  • voiceLines n p — the same idea with the lines solved instead of written: the voicing CSP splits a chord progression into n voice-led lines and stamps each L0 (lowest) … L{n-1}, so a gliding pad plays real four-part writing. Same builtin, told apart by arity — Interweaving melodies "The voicing CSP".
-- hand-authored continuity, two ways:
chordView = play pad !"[c3::a e3::b g3::c] [b2::a f3::b a3::c]"
lineView  = play pad (voiceLines [!"c2 g2 e2 f2", !"e4 d4 c4 d4"])

Per-note override (_ / - / '). Under any of the above, each note's behaviour follows from its identity (§ the three states); a leading sigil overrides that for one note, on top of what identity implies — the deepest knob:

SigilForcesEffect
_c3glideramp in from the matched voice, no re-attack (the 303 slide on demand, even in always mode)
-c3holdreuse the matched voice with no ramp and no re-attack — a legato pitch step
'c3attackfresh envelope, break continuity (re-articulate a note the matcher would have continued)

The leading sigil is orthogonal to the trailing _ slide-out, so they compose (_c3_ = glides in and slides out into the next note), and to the ::tag / =vel suffixes. On an unmatched note (a growing chord, or the first step — no source voice) _/- degrade to a fresh attack. The override adjusts only the note's retrigger/ramp — never the voice assignment — so cycles stay deterministic. _c3 glide-in is the self-marked mirror of the predecessor-marked c3_ slide-out; ' (a marcato accent tick) is used because ^ is the scale-degree sigil (^2). See examples/songs/inflection.volta.

-- always-mode pad, sculpted per note: hold the common tone, slide the move,
-- accent the drop — a phrasing no single glideVoicing policy gives you.
sculpt = play pad (voiceLines [
  !"c3  -c3  _eb3  'bb2",     -- L0: attack, hold, glide, accent
  !"eb3 -eb3  g3    d3",      -- L1
  !"g3  _ab3 _bb3  'f3"       -- L2 (melody)
])

Polyphonic glide is deterministic — the assignment is a pure function of the pitch-sets + tags (engine/src/glide_match.rs, driven from engine/src/lib.rs), so a song assigns voices identically everywhere. A new note with no match (a growing chord, or the first step) attacks fresh; a dropped voice releases naturally.

glideMode on a pad. A continued voice is the same sounding voice across the chord change, so glideMode decides whether it re-articulates:

  • "legato" — the continued voice sustains through the glide (no re-attack). Connected chords morph smoothly with no per-step amplitude pulsing. This is what you want for a glide pad, and only fresh voices (a growing chord, the first step) attack.
  • "always" (default) — every continued voice re-attacks each step (the rhythmic / mono-glide articulation). On a sustained chord pad this pulses, so reach for "legato" there.

Authoring gotcha — chords need commas. [c3 eb3 g3] (spaces) is a sub-sequence (an arpeggio across the step); a chord is [c3,eb3,g3] (commas) or a chord token like c3:min. Poly glide assigns identity per step, so a chord must be one stacked step — see examples/songs/glimmer.volta.

Scheduling a step allocates nothing. The engine keeps each (track, synth) pair's identities in an entry reserved at construction (64 pairs, 16 notes a step, PolyGlide in engine/src/lib.rs); a reset empties it in place. Keys (::tag or a minted #n) are stored inline, and the matcher (glide_match::assign_voices_into) works in reused scratch. A note's (name=value) / ctrl / lane values are looked up by name (voice::EventParams), not copied into a map, so rt_safety's cold-path harness holds a gliding or controlled track to zero allocations and frees.