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 mentionedglide. Settingglide > 0impliesvoices = 1; writingvoices = N > 1alongside 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 bareglide=180is refused (ms? s?), as is a negative one.glide=0mssteps. 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 80msis 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.legatodecides 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:'e3re-attacks with a pitch step (no glide); to slide without re-attack, tie withc3_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 useglideMode = "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.exponentialis a one-pole ease-out lag (time-constantglide/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.smootheases 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 ofglideMode(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]_ c3is the sliding chord, but[a2,a3]_c3is 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_c3lexes 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]_*2slides 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"):_c3glides in with no re-attack,-c3holds (a pitch step with no re-attack — a slur's finger change),'c3re-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
chopslice 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) peelsglide,glideMode, andglideCurveas directives (likevoices):glidefolds to seconds viaconst_seconds,glideMode/glideCurvevalidate the string.glidedefaultsvoicestoSome(1)and rejectsvoices > 1.ir.rs—Synth.glide: Option<f64>(seconds),Synth.glide_mode: Option<GlideMode>(serialisedglideMode,"always"/"legato"), andSynth.glide_curve: Option<GlideCurve>(serialisedglideCurve,"linear"/"exponential").TrackEvent.slide: bool.mini.rs—split_slide_suffixpeels a trailing_from a pitch atom intoMiniEvent.slide, applied to the pitch payload only (orthogonal to the token's payload, soc2_classifies / validates exactly likec2). The chord form is sugar, not a fourth peel site:parse_primary's bracket arm peels the suffix word after the]once (chord_suffix) andapply_chord_slidestamps_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 patterndetect_payloadkeeps aN_token in the numeric class (so one bad token can't re-classify the pattern and misname the diagnostic) andvalidaterejects it at the atom's own span.refine.rs— advisoryglide ⊂ [0ms, 2s](GlideRange).- Per-note time:
mini.rsparse_param_valuereads(glide=…)as a time intoTrackEvent.params["glide"]in seconds (GLIDE_PARAM; the emitter writes it back asglide=180ms).lower/firstsound.rskeeps a|> glide <pattern>knob as a track lane instead of rewriting the field to atrackParamread, andlower/track.rsscales its values to seconds by the pattern's unit (glide_lane_scale).lower.rsvalidate_ctrl_destinationsrefuses either on a synth withoutglide.
- Engine (
engine/, the one renderer — desktop, browser AudioWorklet and plugin alike)lib.rskeeps 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'sslideflag. Because the trailing_marks the step that slides out, the decision for the current note reads the previous event's flag, together withglideModeand note overlap, and yieldsglide_from+retrigger; the note's ownglide_override(leading sigil) wins over both, andhold/attackpass a 0 s ramp. The ramp time isnote_glide_seconds: the event's mergedglideparam (token, else lane) over the synth's constant — on the mono path, inschedule_glide_step(poly) and in the MPE bend mirror (event_glide_param). All of it is cleared on load / swap / stop / seek.graph.rscarriesglide_exponentialon the synth template (glideCurve = "exponential");SynthTemplatealso carries the glide seconds and mode.voice/eval_nodes.rsrendersNoteParam(Pitch)as a per-sample ramp fromglide_fromto the new pitch, anchored atglide_start(which a legato renote moves independently of the envelope'sstart_sample):linearis a constant-rate ramp in MIDI (exponential in Hz, exact arrival);exponentialis a one-pole Hz lag withτ = glide / GLIDE_SLEW_K(GLIDE_SLEW_K = 4,voice/mod.rs);smoothpasses the linear fraction throughsmooth_glide_frac(graph.rsglide_smooth). A 0 s renote is a step ATglide_start; a renote landing mid-block draws the samples before it from the previous ramp (GlideRamp::pitch_at). Unison detune, vibrato andfmHz 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 viarenote, 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: theadsrcloses the gap between its release tail and the held curve along an RC fall overmax(attack, 5 ms)instead of jumping in one sample (voice/eval_nodes.rseval_adsrdrivingengine/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
unisonstack shares onePitchToHz(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-oscillatorfreq (pitch + 12), or anfmmodulator) 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
renotere-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 atrackParam "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_glidepassedno_event_params()andrenote_glidetook none), so the lane was silently inert on a glided line while the compile and the staticdoctorstayed 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 rideVoice.env_anchor, notstart_sample.start_samplegateseval/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).DecayandLinramfollowenv_anchortoo. - A continued note keeps sustaining.
renotere-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 andrelease_sampleis 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.rspins both. - A glide voice belongs to one track. Mono and poly glide voices are keyed by
(track, synth);renote_glidere-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.
renotekeeps the displaced note-off (prev_release_sample), read throughVoice::release_atbyadsr,noteGateand 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
bowedsource on a glide synth is ONE string for the whole phrase: a renote never resets node state, so theBowedStatekeeps ringing while the pitch moves and each note's(key=value)/trackParamvalues 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 -g4plays 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
wavetableoscillator routes its freq through the same chokepoint, so it glides like any oscillator.engine/src/wavetable.rscrossfades 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)/trackParammap 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 staticgranularcloud glides grain by grain; a synth whose only source is achopread (or a grain-motion cloud) still dropsglide. - A glide the note pitch never reaches warns. On a patch whose graph reads neither
pitchnor a plain sample (Synth::note_pitch_reaches_graph, the engine's own test), the engine dropsglideand 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);indexmatches by sorted-pitch position (parallel motion);holdkeeps common tones in place without re-attacking them.- Inline
::tag—c3::apins a note to the voicea, 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, …]— likestack, but each line is its own voice identity, so each glides independently. The sigil-free spelling; a note's explicit::tagoverrides 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 intonvoice-led lines and stamps eachL0(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:
| Sigil | Forces | Effect |
|---|---|---|
_c3 | glide | ramp in from the matched voice, no re-attack (the 303 slide on demand, even in always mode) |
-c3 | hold | reuse the matched voice with no ramp and no re-attack — a legato pitch step |
'c3 | attack | fresh 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.