Shape tempo over time and understand how tracks with different clock behavior fit into the song.
What it is#
Tempo is a map — a piecewise-linear (in bpm) function of the global beat number, declared in source, compiled into the IR, and realized by one clock that integrates the closed-form beats↔seconds bijection. Everything that already schedules in beats (pattern events, durations, shred waits, scene quantization) inherits a tempo curve for free. Per-track rational clock ratios give polytempo. Tempo is not an audio-rate signal: finitely many breakpoints, exactly integrable and replayable.
Surface#
``volta norun four alternative spellings of onetempo` declaration tempo = 128bpm -- constant (the common case; empty map)
tempo = tempoMap [ [0, 128bpm], [64, 128bpm], -- hold 128 through beat 64 [96, 140bpm] -- linear ramp 128 → 140 across beats 64..96, holds after ]
tempo = accel 124bpm 140bpm 32bars -- ⇒ tempoMap [[0,124bpm],[128,140bpm]] tempo = ritard 174bpm 87bpm 4bars -- the half-time drag; same map, falling
bells = bell |> play !"c5 e5 g5" |> clock (3/4) -- runs at 3/4 the global tempo padT = pad |> play !"c3 g3" |> clock (1/2) -- genuine half-time
- **`tempoMap [[beat, bpm], …]`** — explicit breakpoints. Beats are the clock's
native coordinate; strictly increasing, positive finite bpm. Beat 0 is implied
at the map's first value (= `Module.tempo`). Between points bpm interpolates
**linearly in bpm**; before the first and after the last it **holds**.
- **`accel`/`ritard from to <n>bars`** — builtin sugar lowering to a one-segment
`tempoMap`. `bars` are the **fixed** authoring bar (`CYCLE_QUANTUM_BEATS` = 4
beats), module-global and track-independent, so `accel … 32bars` is *always*
128 beats. `beats` and a bare number are also accepted (a bare number = bars).
- **`<track> |> clock (p/q)`** — polytempo. `p/q` must be a **literal rational**
(`3/4`, `1/2`; `1/1` is elided). The track runs at `p/q` the global tempo — a
track-beat `b` lands at global beat `b·q/p`. The result remains a `Track`
through aliases, functions, imports and containers, so effects can follow it.
Its receiver must be a track and its ratio must be fixed and dimensionless.
`clocked = clock track` retains the required ratio port; finish it with
`clocked (3/4)` or `clocked {ratio = (3/4)}`. A folded float (`0.75`) is
not a literal rational.
Holds are just repeated values — there is no `hold`/`linear` enum. A *hard*
tempo cut is a very steep 1-beat ramp (equal-beat breakpoints stay rejected —
they would break the beat→time bijection).
### Computed tempo and meter
`tempo` and `meter` are compile-time values like any other: a name, a record
field, an `if`, or a function's result settles to one of the spellings above
(`lower/expr.rs::settle_conductor`, the declaration seam's conductor step).
```volta
bpm = 128
ramp = \a b -> tempoMap [(0, a), (16bars, b)] -- a map from a function
tempo = ramp bpm 140 -- a named constant inside it
- A value that does not settle is an error, not the default 120 — before,
tempo = bpmsilently played at 120. - Rows may be tuples,
(beat, bpm), which is what a computed list builds most easily: a mixed list[8, "x"]does not fold element by element. - A position in
barsis a notational bar, placed through the meter map (beats_at_bar): undermeterMap [[0, 4/4], [2, 7/8]],[4bars, 140]is beat 15. A bare number (orbeats) is still a transport beat. The meter is extracted before the tempo for this. The Studio tempo lane treats a bar-placed point as source-only (workflow_edit.rs::beat_literal). - A time signature is the value
(p, q)—meter = (7, 8),meterMap [(0, (4, 4)), (8, (7, 8))]. A written7/8still works where it is written as the declaration or a map row, but inside a function body or a computed list it is integer division (it folds to 0), and the compiler says so and names(7, 8).
Derived from a song's own schedule (song.arrange, see Arrange a song § Schedules as values), the shipped examples/songs/conductor.volta library gives every section its tempo and meter, so a length edit moves every change after it:
import "conductor" (sectionTempo, sectionMeter)
kick = sound !"bd*4"
pad = sound !"~ cp"
set = song {
scenes = { intro = [pad], build = [pad, kick], drop = [kick], break = [pad] },
arrange = [[8, intro], [16, build], [32, drop], [8, break]]
}
tempo = sectionTempo set.arrange [("intro", 124, 124), ("build", 124, 140), ("drop", 140, 140)]
meter = sectionMeter set.arrange [("break", (7, 8))]
sectionTempo enters each listed section at its first bpm and reaches its second a quarter bar before the section ends (an unlisted scene holds what it was handed); sectionMeter starts a listed section's signature on its first bar. Both are plain .volta over foldl, no builtin. The map is still absolute transport time: a hand-launched scene plays at wherever the clock is.
IR#
Module grows tempo_map: Vec<TempoPoint> (TempoPoint { beat, bpm }), serde skip_serializing_if = "Vec::is_empty", so a constant song is byte-identical to pre-map modules. Module.tempo is redefined as the map's value at beat 0. Track grows clock_ratio: Option<(u32,u32)> (serde-skipped when absent).
The clock#
TempoClock — the sample-domain original in engine/src/transport.rs, and its page-side twin runtime/src/tempo.ts (which paces the embed player and Volta radio) — lays the map into sloped segments { atBeat, bpmStart, bpmSlope }. For a segment with bpm slope k:
seconds(b) − seconds(b₀) = (60/k)·ln(bpm(b)/bpm₀) (k ≠ 0)
= 60·(b − b₀)/bpm₀ (k = 0, the constant path)
Both directions are evaluated as base × correction, where the correction is log1p(x)/x (forward) / expm1(y)/y (inverse), taken as its series limit near zero so the flat and sloped branches agree to a few ULP and the constant path is exactly today's arithmetic. bpm > 0 keeps beats↔seconds a bijection.
Key clock behaviours (settled Tempo Signal §8):
- Scene quantization runs on the clock's musical axes, not on seconds, so scene transitions stay on musical bars through a ramp. The engine (
next_bar_boundaryinengine/src/lib.rs) takes the next whole notational bar on the transport's bar axis, which carries the modulemeter/meterMap; no host passes a bar length. The page-sidenextBarTimequantizes on the beat axis bysongBarBeats(the longest track cycle in beats; on the bar axis under ameterMap), and at constant tempo reproduces the oldstartTime + n·barSecondsboundary bit-for-bit. - Time edges round like onsets: a scene boundary and a loop end use
TempoClock::edge_sample, the onset rounding. reorigin(transport seek) relays the whole piecewise structure — it shifts only segment times, preserving beats and slopes — rather than flattening to a constant. Scrubbing an accelerando no longer wipes it.setMap(recompile) rebases the new map at the current beat: the past is untouched, a fresh anchor carries the new map's instantaneous tempo forward.setTempostays the live-override seam (MIDI clock / estimator).- Removing the map (editing back to a constant) flattens from the current beat even when the new constant equals the map's final held tempo — the recompile seam checks
isConstantAfter/is_constant_after(a curve still in flight) rather than trusting thebpmgetter, which reports the held final value.
What follows the curve#
The scheduler re-evaluates the beat→time map every block, so already: pattern events, durations, shred waits, scene quantization, offline export horizons, and the live arrangement walk (step lengths, transition lead-ins, songPos ramps, the ribbon play-head). The workbench reads the map through ModuleInfo::tempo_map (ui/src/moduleinfo.rs) and paces against the engine's reported transport. NB the map is absolute transport beats — an arranged song travels it from beat 0 on play; a hand-jumped scene sounds at wherever the clock currently sits, not at "its" map tempo (totality.volta demonstrates the aligned authoring). Polytempo tracks divide by their ratio at every beat↔time seam (plan.clock_ratio in engine/src/lib.rs).
Musical effect times and rates follow the map (Stage 3). Write c, beats or bars to follow the tempo; write s/ms to stay absolute by contract. Under a tempoMap:
- a track/bus/master delay time in musical units —
delayTrack (1/4c),pingpong (3/16c),tape,bbdDelay— carriesTrackInsert::tempo_time(its length in beats and the map's slowest bpm). The engine prepares the line for the slowest tempo and moves the head tobeats · 60 / bpmof the transport's tempo, read at each block's edges and interpolated across it (then the line's usual 5 ms glide), so an echo stays a fixed number of beats behind through a ramp without zipper steps; - a lane LFO rate in cycles —
|> pan (lfoSine (8c)),|> gain (lfoSquare (1c))— carriesLaneLfo::per_beat; its phase is the transport's beat times that, re-anchored every block, so the wave stays on the bar; - a voice
delay/feedbackAttime in musical units lowers to1 / PeriodRate(the clock-driven rate node voice LFO periods already use), so the per-voice line follows the map per sample.
At constant tempo none of this exists: the folded seconds are exact, so the IR (and the audio) is what it always was. What still folds at the opening tempo: one-time preparation sizes (grainHistoryT, feedbackN's loop list, dline), grain sizes, envelope and glide times, and chaos/flicker rates, which are fixed at preparation by design. A live tempo override (MIDI clock, tap) moves a tempo-following line too, but only within the length the map's slowest tempo prepared.
Determinism#
The map is source → IR data. Two offline renders of a ramping module are bit-identical (the closed form is deterministic, and one engine renders everywhere). The page-side pacing twin agrees only to a tolerance (~1e-6 s): V8's Math and Rust's libm differ in the last bit of ln/exp. The shared curved-tempo golden vector is asserted by both runtime/src/tempo.test.ts (GOLDEN_POINTS) and engine/src/transport.rs (curved_tempo_golden_vector).
Files#
ir/src/ir.rs—TempoPoint,Module.tempo_map,Track.clock_ratio.compiler/src/lower.rs—extract_tempo(gesture arms + validation),extract_meter/parse_time_signature(p/qand(p, q)).compiler/src/lower/expr.rs—settle_conductor(computedtempo/meter) andsubstitute_schedule_reads(song.arrange).compiler/tests/conductor_values.rs— every spelling above, pinned.compiler/src/lower/track.rs—clock→extract_ratio.compiler/src/builtins/timeline_forms.rs(tempoMap,accel,ritard),compiler/src/builtins/track_modifiers.rs(clock),ir/src/types.rs(theBarsunit).engine/src/transport.rs,engine/src/graph.rs,engine/src/lib.rs,engine/src/offline.rs— the clock + scheduler + polytempo seams + export.ui/src/moduleinfo.rs,ui/src/timeline.rs— the workbench's map view and tempo lane.runtime/src/tempo.ts— the page-side pacing twin (embed player, radio).
Examples#
examples/songs/tempomap.volta (accel-into-drop), examples/songs/polytempo.volta (3-against-4 canon + genuine half-time). Both are in the workbench song catalogue (runtime/public/songs/index.json). examples/songs/conductor.volta is the library that derives tempo and meter from a song's schedule.