VOLTAreference

Reference / Design a sound

Match timbre to tuning

Design a sound

Open in the workbench →
On this page · 7 sections

Explore how a sound's partials and a scale's intervals interact, and shape them together for different kinds of consonance.

tuned (compile-time partial snap) and spectralTune (live STFT lattice snap). Design spec: Match timbre to tuning. Demo song: examples/songs/lamellae.volta.

Sethares' observation, made executable: consonance is not a property of a tuning alone but of timbre × tuning — a sound is consonant in a scale when its partials land on the scale's interval lattice. DAWs cannot act on this (tuning and synthesis live in different boxes). Volta has both in one algebra: tuning = … is ambient compile-time context (Tuning systems) and an additive bank's spectrum is compile-time data, so the two can be joined.

1. Surface#

key    = "c major"
tuning = tuningOf [0, 120, 190, 258, 400, 539, 600, 675, 785, 860, 943, 1060]

-- Voice level: rewrite the bank's partial ratios at compile time.
bar    = patch { source = additiveAt (freq pitch)
                   [[1, 1], [2.76, 0.6], [5.4, 0.4], [8.9, 0.25]] |> tuned }
soft   = patch { source = additive (freq pitch) [const 1, const 0.5] |> tuned 0.6 }
bloom  = patch { source = additive (freq pitch) (sawSpectrum 24) |> tuned }
echoed = patch { source = additiveOf (sample "bowl") (freq pitch) |> tuned }

-- Track level: snap any audio's spectral peaks onto the same lattice.
lattice   = macro "lattice" 0.0
droneTrack = play drone bassline |> spectralTune lattice
mixer { master = { inserts = [spectralTune 0.5] } }
builtinwhereargrangeParam (macro-live)
tuned [amount]voice, pipe modifier on an additive sourceconstant, optional[0, 1], default 1✗ (compile-time)
spectralTune amounttrack effect and master insertParam[0, 1]✓

tuned accepts additive, additiveAt, a spectrum-generator additive (additive f (sawSpectrum 32)), and additiveOf. Anything else is a compile error naming the four.

2. Semantics: the snap map#

Both surfaces share one kernel, compiler/src/tuning.rs::snap_ratio. Given the tuning's cents table C = {c₀ … cₙ₋₁} (per octave, root-relative) and a partial ratio r > 0:

octave  = floor(1200·log2 r / 1200)
pc      = 1200·log2 r − 1200·octave            -- cents in [0, 1200)
target  = argmin over c ∈ C ∪ {1200} of |pc − c|
snapped = 2^((1200·octave + target) / 1200)
result  = 2^( lerp(1200·log2 r, 1200·log2 snapped, amount) / 1200 )

Three things follow from that shape and matter in practice:

  • Octave-preserving. A partial never jumps octaves; only its pitch class moves. The octave itself (1200) is always a snap candidate, so a partial just under the octave rounds up to the next root rather than back down to the table's top degree.
  • Root-agnostic. The map is about interval structure above the fundamental, so partial 1 (ratio 1) never moves and the answer does not depend on which pitch class the tuning is rooted on.
  • Interpolation is in cents, i.e. log-frequency — amount = 0.5 is the perceptual midpoint, not the arithmetic midpoint of the two ratios.

The lattice per constructor#

tuning::lattice_cents normalizes all four constructors to (root_pc, cents):

tuning = …rootdegrees
edo nA (pc 9){1200k/n} — A-anchored, matching retune's A4 = 440 Hz anchor
just ~XX's pitch classthe 5-limit table (just_cents_5limit)
tuningOf [c…]the ambient key's root (C when unkeyed)the declared cents, folded into [0, 1200), sorted, deduped
adaptivethe key's rootthe 5-limit table

adaptive has no static lattice — it retunes each chord to just ratios of its own root, which is a per-event decision. Its documented stand-in here is the 5-limit just table of the key root; a bank tuned under adaptive is therefore tuned to just intonation, which is what its chords will mostly be.

No tuning = … declared#

  • tuned is the identity, and records a TunedNoTuning warning diagnostic naming the three constructors. (12-TET is a lattice too, but an explicit no-op beats a silent surprise; also, a harmonic bank left alone keeps the engine's cheap shared-phase recurrence.)
  • spectralTune falls back to 12-TET rooted at C. That is still musically useful — it is a hard-tune for partials — so the effect is never a no-op.

3. tuned per operand shape#

operandwhat happens
additive f […]a 1..n harmonic base is synthesised, then snapped into the node's ratios
additiveAt f [[r, a], …]the bank's existing ratios are snapped (not overwritten)
additive f (sawSpectrum n)the generator is expanded into an explicit Additive bank (below)
additiveOf (sample …) ffills AdditiveResynth.ratios; the engine replays harmonic h at ratios[h]×

Composition with inharmonic#

inharmonic B and tuned both rewrite the same ratios table, so the last one in the pipe wins per partial. The useful order is |> inharmonic B |> tuned: the Railsback stretch produces the ratios, and the snap then pulls them onto the lattice. The reverse order snaps first and then stretches straight back off the lattice.

Expanding a spectrum generator (Stage 2)#

A procedural bank (AdditiveSpectrum) has no ratio table to rewrite, so under tuned the compiler expands it: it evaluates the generator's magnitude formula — a literal mirror of the engine's voice/additive.rs::fill_spectrum + dsp.rs::*_spectrum_mag — bakes one Const amplitude node per partial, and emits an ordinary Additive node with the snapped ratios. Capped at 128 partials (TUNED_SPECTRUM_MAX); a generator may declare up to 512, and each expanded partial costs an IR node.

Two generators cannot be frozen into constants, and diagnose instead of silently changing meaning:

  • formantSpectrum — its Gaussian peaks are sampled at each partial's absolute frequency, which is only known at note time. Write the bank out with additiveAt if you need it retuned.
  • pulseSpectrum / gaussSpectrum with a live width / center signal — the sliding gaussSpectrum center is the Shepard-tone idiom; freezing it would delete the effect. A constant width / center expands fine.

Cost#

A retuned bank is inharmonic, so it leaves the engine's harmonic Chebyshev recurrence for the per-partial phase-accumulator path (one phase per partial). That is the same path additiveAt and ensemble already take, and the constant-pitch block fast path (voice/eval.rs) still applies. Under no tuning the harmonic bank is left with an empty ratios table precisely so it keeps the cheap path.

4. spectralTune — the live lattice snap#

Implementation: SpectralMode::SnapTune in engine/src/spectral_fx.rs, inside the same streaming STFT the rest of the spectral family uses (periodic Hann, FFT 1024, hop 256, overlap-add ÷ 1.5 — see Spectral effects §2). Per frame:

  1. True-frequency estimation per bin, from the hop phase deviation — the same estimator freeze uses so a held frame evolves at each partial's real frequency rather than the bin grid.
  2. 5-point peak picking with regions of influence, adapted from the offline Laroche–Dolson phase locking in engine/src/stretch.rs: a bin is a peak when it dominates its two neighbours on each side, and it owns the bins out to the midpoints of its neighbouring peaks.
  3. Each peak's true frequency is snapped to the nearest lattice frequency (binary search, nearest in log frequency — the split point between two neighbours is their geometric mean), and the peak's whole region moves there as one complex unit: an integer bin offset taken from the frequency delta (so an already-in-tune peak gets exactly zero offset), plus a single phase rotation for the region, read destination-first. The gather is what closes the holes — neighbouring regions move by different integer amounts, so a plain per-bin translation leaves the bins between them empty; a gap now takes the region-boundary value, which is the valley between two partials. Within a region every bin keeps its analysis-phase offset from the peak (identity phase locking), so peak and sidelobes arrive as one coherent partial. The ratio is clamped to [0.5, 2]; peaks below 20 Hz pass through. A region always covers its own destination bin: two peaks can snap to the same lattice point (an octave lattice pulls A4 and C♯5 both onto C5) and the midpoint rule would otherwise hand the second one an empty span and drop the partial — measured −4.85 dB on A4+C♯5, and −1.23 dB on a 10-harmonic tone whose partials collide in pairs. Colliding regions now merge and their magnitudes sum at the shared destination. A peak whose destination is past the last bin has been snapped out of band and emits nothing: the lattice runs to 32 kHz for 96 kHz devices, so at 44.1 kHz its top degrees sit above Nyquist, and clamping them onto the last bin leaked the partial back at −2.46 dB instead of −128.81. The peak stays in the list so its neighbours' regions are still bounded by it; the merge clamp comes first and the band clip second, so a region still always covers its own destination bin.
  4. Resynthesis from the region phases, then a complex-domain crossfade by amount — so 0 is the exact identity resynthesis and a macro sweeping up from 0 can never click or comb (the spectral house rule). Only a cold start anchors the synthesis phases on the analysis phases.

    vocal::PitchShifter's spectral-flux onset re-anchor is deliberately not carried over: the shifter moves a whole spectrum by one ratio, so re-landing the analysis phases reproduces the input waveform, whereas spectralTune moves every peak by its own ratio and has no waveform to re-land — the re-anchor only injects a phase discontinuity. Turning it off improves or holds every fixture (right-hand column below).

Steps 3–4 are vocal::PitchShifter's treatment, ported; spectralTune used to scatter bins one at a time (idx = round(k·ratio)) into free-running per-bin accumulators. Measured across the port and its repair:

fixturescatterregion port+ merge, − flux
level error, lone sine−1.34 dB−0.17 dB−0.18 dB
level error, 10 harmonics−0.32 dB−1.23 dB+0.90 dB
level error, two partials onto one lattice point—−4.85 dB+0.23 dB
energy landing off the lattice, 10 harmonics−13.7 dB−25.9 dBbelow the rig's floor
transient concentration vs dry0.54×0.74×0.84×

The −0.17 dB headline is a lone-sine figure — a single partial has no neighbour to collide with. On real multi-partial material the level error is the ±1 dB band in the table, set by the collision case. The transient figure cannot reach the fixed shifter's 0.92×: a broadband click's peaks each snap to a different lattice point, so a hard-tune necessarily tears one apart. Pinned by snap_tune_moves_a_region_as_one_unit and snap_tune_merges_colliding_destinations.

The lattice is precomputed control-side (spectral_fx::snap_lattice, called from EffectState::build, like the convolve kernel prep): the tuning's degrees laid out from an octave below MIDI 0 up past 16 kHz, filtered to 8 Hz … 32 kHz, sorted and deduped — a few hundred f32s. The audio thread only binary-searches it. process never allocates: every scratch buffer lives on the per-channel ChanStft.

Deterministic (no randomness anywhere in the path), so offline renders and replays are bit-identical. Fixed ~21 ms bus latency, like the rest of the STFT family; the mixer's PDC accounts for it.

5. The IR#

tuned is a pure compile-time rewrite — no new node kind, and the only IR shape change is the optional ratios on AdditiveResynth:

{ "op": "Additive", "freq": "n1", "partials": ["n2", …],
  "ratios": [1.0, 2.7305, 5.4609, 8.9282] }          // snapped
{ "op": "AdditiveResynth", "freq": "n1", "sample": "bowl", "count": 48,
  "ratios": [1.0, 2.0, 2.9900, …] }                  // Stage 2, serde-default

spectralTune needs the tuning at run time, so the module carries it:

{ "tuning": { "rootPc": 0, "cents": [0.0, 120.0, 190.0, …] },
  "tracks": [ { "effects": [ { "op": "SpectralTune",
                               "amount": { "macro_ref": "lattice" } } ] } ] }

Module.tuning (ir::TuningInfo) is #[serde(default, skip_serializing_if = "Option::is_none")], so a module with no tuning = … serializes exactly as before — only a tuning-declaring module's IR carries the field. It travels the exact path Module.key takes: graph.rs::CompiledModule → TrackFx::build(… tuning …) → EffectState::build, mirrored in runtime/src/types.ts.

Note that pitch never needs this: notes ship as pre-retuned fractional MIDI (Tuning systems), so the tuning is a compile-time concern for everything else. TuningInfo exists for the live surface alone.

6. Where it lives#

filerole
compiler/src/tuning.rslattice_cents, snap_ratio — the shared kernel + its unit tests
compiler/src/lower/expr.rsLowerer::lower_tuned, spectrum_magnitudes, the TunedNoTuning warning channel
compiler/src/lower/track.rslower_spectral_tune (an #[inline(never)] arm — lower_track_expr is stack-constrained)
compiler/src/lower.rsTuningInfo normalization at emit; the spectralTune master insert
ir/src/ir.rsModule.tuning, TuningInfo, TrackEffect::SpectralTune, AdditiveResynth.ratios
engine/src/spectral_fx.rsSpectralMode::SnapTune, snap_tune, nearest_lattice, snap_lattice
engine/src/voice/eval.rsthe resynth render arm's per-harmonic ratio
runtime/src/types.tsthe IR mirror (Module.tuning, SpectralTune, AdditiveResynth.ratios)

Tests: compiler/src/tuning.rs (snap map: EDO / just / table / adaptive / amount lerp / degenerate input), compiler/src/lower/tests.rs (tuned_*, module_tuning_lands_in_the_ir, spectral_tune_lowers_as_track_effect_and_master_insert), engine/src/spectral_fx.rs (lattice_shape, snap_tune_moves_the_partial, identity_at_zero), engine/tests/spectral_tuning.rs (the whole TrackFx path: dominant-peak movement + the amount-0 null), compiler/tests/snapshots/lamellae.json.

7. Non-goals / deferred#

  • Wavetables. WavetableDef::Frames is indexed by harmonic number and cannot carry inharmonic ratios — a documented non-goal, not an oversight.
  • Non-octave periods (Bohlen–Pierce). The snap map assumes octave equivalence at 1200 ¢; parameterizing the period waits on the same work in Tuning systems.
  • Dissonance-curve introspection (plotting Sethares roughness across the octave in the patch inspector) and true dissonance minimization (optimize partial placement rather than nearest-degree snap) live in Ideas.
  • tuned on Analyze and rebuild a sound resynthesis — shipped. A partials source has no compile-time ratio table (the tracks only exist after the load-time bake), so the node carries the resolved cents lattice and the engine snaps each track's mean ratio-to-f0 at latch, shifting the whole trajectory by that one constant cents delta — vibrato and glissandi survive. The kernel is a faithful engine-side twin of snap_ratio (engine/src/partials.rs::snap_ratio_cents). See Analyze and rebuild a sound §3.