VOLTAreference

Reference / Compose and perform

Tempo changes and independent clocks

Compose and perform

Open in the workbench →
On this page · 8 sections

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 = bpm silently 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 bars is a notational bar, placed through the meter map (beats_at_bar): under meterMap [[0, 4/4], [2, 7/8]], [4bars, 140] is beat 15. A bare number (or beats) 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 written 7/8 still 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_boundary in engine/src/lib.rs) takes the next whole notational bar on the transport's bar axis, which carries the module meter/meterMap; no host passes a bar length. The page-side nextBarTime quantizes on the beat axis by songBarBeats (the longest track cycle in beats; on the bar axis under a meterMap), and at constant tempo reproduces the old startTime + n·barSeconds boundary 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. setTempo stays 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 the bpm getter, 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 — carries TrackInsert::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 to beats · 60 / bpm of 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)) — carries LaneLfo::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/feedbackAt time in musical units lowers to 1 / 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/q and (p, q)).
  • compiler/src/lower/expr.rs — settle_conductor (computed tempo / meter) and substitute_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 (the Bars unit).
  • 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.