Draw with the same source that plays. vis.* builds an image from fields of space and time, musical controls move it, feedback remembers it, and a fixed simulation tick makes the live picture and the offline one the same picture.
What it is#
Timings and node counts marked measured were taken on one Apple M1 Max Metal adapter in the week of 2026-09-24 and no test re-runs them.
A Field a is a function of position and tick. An Image is Field (Color Linear). Every vis function is pure over fields, takes its subject image first (so img |> vis.blur 0.01 reads naturally), and its optional knobs are a trailing brace record. vis.layer gives an image an activation identity; vis.output [layers] declares a canvas. The compiler collects outputs as the roots of a visual program, infers each expression's domain (const, tick or fragment) and lowers to Module.visuals, a typed field graph the shared volta-visual runtime fuses into GPU passes or evaluates on the CPU.
tempo = 120bpm
timingVersion = 2
kick = play "bass" !"c2 ~ c2 ~ c2 ~ [c2 c2] ~"
punch = tapLive "kick" |> follow { attack = 5ms, release = 180ms }
ring = vis.sdfCircle (0.3 + 0.2 * punch) |> vis.abs |> vis.sdfSub 0.01 |> vis.aa
@identity "tunnel"
trail = vis.feedback "tunnel" (\prev ->
ring |> vis.over (prev
|> vis.zoom 0.98
|> vis.rotate (0.6 * vis.dt)
|> vis.hueShift (0.5 * vis.dt)
|> vis.decay 1.5))
screen = vis.output [vis.layer trail]
punch is an ordinary control; it reaches the renderer as one uniform per tick, the same instance the audio uses. 0.3 + 0.2 * punch is inferred tick-domain and evaluated once on the CPU, not per pixel.
Surface#
- Names are qualified only.
vis.osc,vis.noise,vis.scaleand the rest never shadow the audio builtins of the same name; a barevisOscis refused with the qualified spelling. 188 names ship; the family table is the Visuals section of the builtin docs. - Leaves:
vis.uv(centred, aspect-corrected: the shorter axis spans[-1, 1],+yup),vis.time,vis.beat,vis.bar,vis.phase,vis.tick,vis.dt(the constant1 / simRate),vis.pixelSize,vis.aspect. - Sources:
solid,osc { stripes, rate, shape, phase }(ratein Hz or a musical period such as4c),ramp/rampY/rampR/rampA,noise { scale, octaves, seed }(a stable field: animate it by warping its coordinate),hash(re-randomizes per tick),checker,grid,rings { count, rate, phase }(concentric,countper uv of radius) andspokes { count, rate, phase }— both½ + ½·cosand faded to grey where they would pass the pixel Nyquist (theoscfade), so neither aliases. - Media sources (the host provides the pixels;
visual/src/sources.rs,visual/src/spectrum.rs,visual/src/capture.rs):vis.image "path" { fit, edge }— a PNG or JPEG, resolved relative to the song file, then the host's asset root (volta-vis --assets DIR). Decoded ONCE to straight-alpha linear RGBA (sRGB removed through an exact 256-entry table; gamma/ICC chunks ignored), cached by the SHA-256 of the file bytes, so two loads give identical texels. Every filter tap premultiplies (CPU and GPU alike); the GPU stores the frame asRgba16Float.fit=fit(whole image, letterboxed, default) /fill(cover, cropped) /stretch;edge=transparent(default: the enclosing warp's policy) /clamp/repeat/mirror. A missing or undecodable file still compiles: the host shows the declared placeholder (a 64×64 magenta/black checker of 8-texel squares) and reports a warning naming the path and the directories searched.vis.text "words" { size, font, align, weight }— typography as an image: white, alpha = coverage (ab_glyph's exact-area rasterizer), rasterized at the OUTPUT resolution into a canvas-sized texture and re-rasterized when the resolution changes.size(default 0.2) is the face's ascent-to-descent height as a fraction of the shorter axis;\nbreaks lines; the block is centred vertically on the canvas centre andalign=center/left(lines start at the centre) /right(lines end there). The bundled face is Barlow (OFL;weight=regular,medium,bold= SemiBold);font = "x.ttf"resolves like an image and falls back to Barlow with a warning.trackingadds that fraction ofsizebetween glyphs;glyph = idraws only glyphi(newlines not counted) at its place in the layout, so one layer per glyph re-assembles the word (kinetic type). Aleft-aligned block starts at the canvas centre in a canvas-sized texture, so text wider than the right half is clipped before anyvis.translate: move centred text instead.vis.video "path" { fit, fps, loop, start, speed }— a film. Framekshows while film timestart + speed·t(t= transport seconds) lies in[k/fps, (k+1)/fps), looping (default) or holding the last;fps = 0(default) is the file's own rate (a frame-sequence directory: 30). The path is a frame-sequence DIRECTORY (its PNG/JPEG files in name order), a still image, or a container (.mp4,.mov, …). A container is extracted by theffmpeg/ffprobeonPATH— no codec ships in process — into the same frame-sequence form under the OS cache (~/Library/Caches/volta/video,$XDG_CACHE_HOME/volta/video;VOLTA_MEDIA_CACHEoverrides): one directory per<content SHA-256>-<fps>fps-<w>x<h>(the long edge capped at 1280, even sizes,fpsfilter so variable-rate files become constant-rate), finished when itsmeta.jsonexists; the file's hash is memoized by path, length and mtime, so a second run re-reads nothing. An offline host (headless, export) extracts the first 24 frames before the source returns, streams the rest on a thread, waits for the exact frame and decodes it inline, so the film renders the same every run. A live host (a window, Studio) never blocks its paint thread on a film: opening a container (hash, ffprobe, first chunk) runs on a thread behind the placeholder, it shows the newest extracted frame while the rest streams, and film frames are decoded a few (4) ahead of the playhead on a worker into an 8-frame ring — until the wanted frame lands the previous one stays up. Decoded film frames are not otherwise cached (a film does not fit). Without ffmpeg the container issource unavailable in this hostwith the install command (brew install ffmpeg,sudo apt install ffmpeg).vis.camera N { fit }— cameraNof the host's device list (default 0). Nothing starts it but an explicit host action (§10):volta-vis --camera N(window only) or Studio'sstart camera Nin the Visuals panel / Window ▸ Start Camera. Until then it is the placeholder withcamera N: not activated — open it from the host; under--headless,--export,--scriptandvolta export videoit issource unavailable in this host(a camera never runs offline; the CLI build does not even link nokhwa). Capture runs on its own thread (nokhwa: AVFoundation on macOS, V4L2 on Linux; featurecamera, on forvolta-visand Studio), asks for 1280×720 at 30 fps 4:2:2, and converts each frame there to straight-alpha linear RGBA — 8-bit video range Y'CbCr through the §4.6Color YUV(Rec.709 matrix, BT.709 inverse OETF, clamped), MJPEG throughjpeg-decoder. A frame is published with its arrival instant; the first tick the host runs after it hands it to the executor and the CPU reference alike (a new frame re-uploads, an unchanged one does not). The status names the device, size and rate (camera 0: FaceTime HD Camera · 1280×720 · 30 fps); a missing device lists the cameras there are; on macOS a first start shows the system dialog "“<terminal or Volta Studio>” would like to access the camera" and waits for it, and a denial says to allow the app in System Settings ▸ Privacy & Security ▸ Camera.vis.spectrum "track" { size, window, smooth, scale }— aField Float @fragmentacross the canvas: left edge = DC, right edge = Nyquist (scale = "log": 20 Hz → Nyquist, log-spaced), constant iny(sampledstretch+clamp)."master"is also a bus. The engine copies the track's post-fader mono downmix (½(L+R), rescaled byVOICE_GAINto its contribution to the master; the master is post-inserts, pre-limiter) into a lock-free ring every block (engine/src/pcm_tap.rs, the scan tap's storage and reader). At each tick the host drains exactly that tick's samples and runs a2·sizeFFT (defaultsize512, 8–8192) over the latest2·sizesamples ending attick_sample(n)— hop one tick, windowhann(default),blackmanorrect. Bin amplitude2|X|/Σw(a full-scale bin-centred sine reads 1), shown in dB over 80 dB:clamp((dB + 80)/80, 0, 1);smooth∈ [0, 1) is a per-tick one-pole on that value. The same samples feed live, headless and export hosts, and the CPU reference and the GPU sample the samesize × 1frame.- Host textures are declared in the IR but not authorable.
- Signed distance and coverage:
sdfCircle,sdfBox,sdfLine,sdfPoly,sdfShell,sdfUnion/sdfSub/sdfInter/sdfSmooth,aa,aaWidth; and, for mechanisms (orrery, 2026-09-25):sdfRing r w(||p| − r| − w),sdfArc { r, from, to, width }(the unsigned distance to the arc fromfromtototurns counter-clockwise from +x, minuswidth: inside the span||p| − r|, outside it the nearer endpoint; a span of a turn or more is the circle) andgear { teeth, r, depth }(a signed distance with flat-topped teeth: tip radiusr + depth·(½ + ½·clamp(2cos(nθ), −1, 1)·g), the teeth faded to their mean radius by the spokes' Nyquist fadeg, the radial gap divided by the local polar gradient so an edge is one pixel on every face).aadifferentiates its input by the centred difference (four more evaluations of it per pixel); inside avis.emitbody, where that would be per note, its footprint is the hardware's instead —fwidthFine(d)across the 2×2 pixel quad,dcomputed once (the CPU reference evaluatesdat the quad partner's pixel, one footprint away on the side the quad puts it). Anaathere costs whataaWidth (0.6 * vis.x vis.pixelSize)does:orrery-filmwith every edgeaameasured 7.7 ms of GPU per tick before, 4.0 ms after (the film as written,aaWidth, 3.6–3.8 ms). AField Floatused where an image is expected becomes white with alpha equal to the value, so coverage composites over what is behind it;vis.greyis the opaque spelling. The way back is not implicit:vis.maskreturns an Image, so masked coverage fed on to a coverage parameter is "a colour image cannot silently become a scalar" — multiply (cov * k) to keep aField Float, and take an image's coverage (text, emitters) withvis.luma.vis.chromaKeyis a keep matte, 0 on the key colour:img |> vis.mask (vis.chromaKey img (vis.rgb 0 1 0))drops the green. - Vector paths and shapes (
lower/visual/vector.rs, existing ops only):vis.sdfPath [(x, y), …] { from, to, width, closed, smooth }is the distance to a polyline, round-capped, minuswidth;from/totrim it to fractions of its arc length (tick values, computed on the CPU:to = tdraws it on), aclosedpath's window wraps past 1,smooth = k(0–32) makes it a Catmull–Rom curve through the vertices; ≤ 256 segments. Fixed vertices trim per pixel (projection → arc length → clamped to the window, then to the segment), so a path reads two uniforms per window; moving vertices trim on the CPU, five uniforms a segment. Tick-valued vertices are uniforms read per pixel, so a many-segment outline whose SIZE moves is far cheaper as one constant unit outline under a scalingvis.warp(pyre-film's flames 426 → 6.5 ms GPU, measured).vis.sdfShape [(x, y), …] { smooth }is a signed filled outline (even–odd, either winding),vis.sdfStar { points, r, inner, smooth }a star tip-up,vis.sdfRoundBox size { radius }a rounded box. Vertices and radii may be tick values. On many-segment shapes prefervis.aaWidth d (0.6 * vis.x vis.pixelSize):vis.aaevaluatesdfive times (147 → 6.2 ms GPU per tick forshowreel-film's morph, 1280×720, measured). - Easing and keyframes:
vis.ease t "curve"clampstto [0, 1] throughlinear,in/out/inOut(cubic),sine,smooth,smoother,in/out/inOutExpo,in/out/inOutBack,outElastic,outBounceorspring, each exactly 0 at 0 and 1 at 1.vis.keys t [(time, value), (time, value, "curve"), …] { ease }holds the first value, then adds each key's step eased over its interval (the curve eases the move INTO a key; times constant and increasing). Both are plain arithmetic, so a per-pixeltmakes a travelling wave. - Shaping, colour, keyers, compositing:
threshold,smoothThreshold,gamma,quantize,fold,wrap,mix,luma,colorize,palette "name"(the sixteen of Palettes below),gradient,curve [(x, y), …](piecewise-linear, held past the ends; on an image, per rgb channel; a constant scalar input folds to a constant),channelMix [[rr, rg, rb], [gr, gg, gb], [br, bg, bb]](rows are output channels, straight rgb, alpha kept),hueShift(turns, in OKLab),saturate,contrast,levels,posterize,invert,gain,toOKLab/fromOKLab/toYUV/fromYUV,over,blend,add,multiply,screen,difference,mask,keyMix,lumaKey,chromaKey,alphaKey. - Looks (pointwise finishing, fused):
vignette { radius, soft }(rgb ×1 − smoothstep(radius, radius + soft, |uv|)),grain { amount, seed }(one per-tick hash per canvas pixel added to rgb in linear light; likehashandnoiseits lattice is salted by the node's ordinal among the program's computed nodes, so sharing constants never reseeds it),scanlines { count, depth }(countdark lines across the unit height, faded to their mean near Nyquist),dither { levels }(ordered 4×4 Bayer per canvas pixel, the pixel-art look),chromatic { amount, dir }(red shiftedamountalongdir, blue the other way: three samples of one fused image merged by channel). - Geometry (all precomposition of the coordinate, fused into one shader):
rotate,zoom,translate,scale,mirrorX,mirrorY,repeat,kaleid,polar,unpolar,swirl,pinch,lens,wave,displace,rgFlow,placePolar angle radius(moves the origin toradius·(cos τa, sin τa),anglein turns — an orbiting body's placement),rotateAbout centre radians,pixelate n(point-samples once per square cell,nacross the unit square — no resample pass; inside it the footprint is the coordinate's own difference, zero within a cell, so feed it ramps, palettes, images oraaWidthshapes rather thanaa/osc),mirror n(the wedge[0, π/n]kept unrotated and reflected around the circle),ripple { freq, amp, decay, speed, origin }(a radial wave travelling out atspeedper second), andwarp (\p -> …)for any coordinate function. Composition additions (2026-09-25):polarTile n(n rotated copies of the wedge centred on +x —mod(θ + s/2, s) − s/2,s = 2π/n— no reflection, unlikekaleid/mirror);flowWarp img field amount(samples atp + amount·field(p)for anyField Vec2— a flow, a curl, a gradient; an image's red/green is one throughrgFlow);domainWarp img { amount, octaves, field }(Quilez's warp of a warp:q₀ = 0,qₖ₊₁ = 2·(f(p + a·qₖ + oₖ), f(p + a·qₖ + oₖ′)) − 1foroctaveslevels (1–4, default 2) with fixed offsets, then the image atp + a·q;fieldaField Float, defaultnoise { scale = 2, octaves = 2 }; two fused lookups per level);trailTo img dir amount(a directional smear: the running mean of 8 samples atp − amount·(k/7)·dir(p), fused — the subject is evaluated 8 times per pixel). Band-limitednoiseunder a steep warp picks its octave count per pixel from the warped footprint, so keep a warp field smooth (few octaves) where the picture must be stable to the pixel. - Filters and reductions:
blur,blurDir,sharpen,edge,sobel,emboss,dilate,erode,median,radialBlur { amount, origin }(composition: 16 bilinear taps towardoriginato + (q − o)·(1 − amount·k/15), premultiplied, a Neighborhood pass likeblurDir),bloom,glow { threshold, radius, mix, knee }(bloom with a soft knee:smoothstep (t − k) (t + k)of luma, so no hard edge shimmers);mean,maxOf,minOf,normalizegive atickvalue in the same tick (normalizeon a linear colour flattens it over black, then gains its rgb by 1 / the flattened peak luma; the result is opaque);resample w h,supersample n. - Time and memory:
feedback "label" (\prev -> …) { init, persist },delay n,trails { halfLife },decay rate(prev * exp (-rate * vis.dt), so an authored per-second rate is exact live and offline),select;motionBlur { frames = 4, decay = 0 }(§7.5's feedback accumulator,blend (prev |> decay) img (1/frames)under the identity<owner>/motionBlur— an exponential smear over aboutframesticks, a stylistic look, not an anti-strobing tool; underportableits tail stains like a slowdecay, so give itprofile = "hdr") andecho { delay = 8, mix = 0.5 }(the image over its own copydelayticks ago masked bymix: onevis.delayline, identity<owner>/echo). A delay line straight on a texture leaf (avis.canvasImage, a media source, a scan or simulation, another feedback's read) is written through an identity warp at the reader's resolution (texture_bodyin the lowering). - Musical instances:
vis.emit "t" (\inst -> image) { max, blend, release }draws one prepared image per event the scheduler ACCEPTED on trackt(after branch choice, probability and activation — not a re-evaluation of the pattern).inst.vel,inst.pitch(Hz, 0 when none),inst.ageandinst.release(seconds since the note started / since its gate ended, 0 while held),inst.index(0 = oldest live) andinst.seed(in[0, 1), fixed for the instance's life) read that note;vis.pitchClass hz(0–11, C = 0) andvis.octave hz(C4 = 4), both of the rounded key⌊69 + 12·log₂(hz/440) + ½⌋and 0 for no pitch, turninst.pitchinto a place. An instance lives untilreleaseseconds past its note's end (default 0.25);max(a constant, 1–64, default 16) bounds the live set and a new note steals the oldest; instances composite oldest first withblend"over"(default) or"add".vis.events "t" |> vis.emit (\inst -> …)is the same thing —vis.eventsis not a field of its own yet, only the emitter's subject. The body compiles once: a loop over a fixed instance array in the shader, never a pipeline per note. The body must be pointwise (no blur, reduction, resample or feedback may read an instance VALUE), but the emitter's whole IMAGE is an ordinary image: it feeds avis.feedback(a comet as one continuous tail: heads over last tick's turned, decayed image), a filter (materialized once as the pass input) or avis.resample. One level of nesting (composition, 2026-09-25): avis.emitinside another's body is a child loop inside the parent's — its body reads both instance records (\p -> … vis.emit "moons" (\m -> … p.vel … m.age …)),parent.max × child.max ≤ 256instances per pixel (the cost is that product of body evaluations wherever the parent's body is drawn), and a third level is refused. - Bridges:
vis.level "t",vis.peak "t",vis.bands "t",vis.onset "t" { decay }(1.0 on the tick an accepted event ontstarts, from the accepted-event stream, thenexp(-decay·dt)); anySig Control(follow,pitchOf, macros,@sharedcontrols) is used directly. Every bridge (andvis.voice/held/count/spectrum/events/emit) names its track by the track's BINDING too —vis.onset kickT,vis.emit d.lead (\inst -> …)— which the lowering reads as the track's link name (lower/visual.rs::top_level), so a rename or an import reaches it; the string is legacy, and inside an imported library it is not qualified (Language reference §12). - Held notes (rotating-hands, 2026-09-26;
UniformSource::Voice,Held,Count, read invisual/src/transport.rs), tick values from the same accepted-event stream asvis.onset, so live, headless and film hosts agree:vis.voice "t" index { from, glide }— the pitch in Hz of one note held ontat the tick:index(a constant, 0–127) counts up from the lowest held pitch, or down from the highest withfrom = "high". A note is held from the first tick whose boundary is past its start until its gate ends (start < boundary < start + duration, samples), so a chord that starts on a bar line is read on the tick after it and the old chord's gates have closed on the line itself. With fewer notes held the value holds (a hand stays where it was); it is 0 only before the track's first note since a start or seek.glide(seconds, a constant, default- moves the value toward each new note exponentially in pitch —
m ← t + (m − t)·exp(−dt/glide)in log₂ Hz, time constantglide— and keeps moving while nothing is held; the first note always jumps. Voices are sorted by pitch, not tracked by identity:index 0is whichever note is lowest now.
- moves the value toward each new note exponentially in pitch —
vis.held "t"— how many notes (pitched or not) are held ont.vis.count "t"— how many accepted eventsthas played since the bridge started; a seek or stop resets it (as it resets feedback), so it is a position-independent count only from a clean start.vis.smooth x { time }— a one-pole over any per-tick value (a macro, a bridge, an expression ofvis.bar):y ← x + (y − x)·exp(−dt/time),timeread per tick (0 passes through), the first tick after a start, seek or edit takingxitself. It is aUniformSource::Smooth { input, time }whose nodes the bridge evaluates against each tick's context (UniformBridge::context, again after a host moves the clock — the same tick recomputes, never advances twice), so the GPU, the CPU reference and a film read one value; its input may not read a per-pixel field, an instance or a reduction of the tick (refused at compile time), and the pruning pass keeps and renumbers the nodes only it reads. A constant is returned as is.rotating-hands-filmuses it to re-dealvis.voiceranks to the song's own voices at each rotation, so the lifted voice's hand swings to the top instead of the ranks jumping.- All three survive a live edit: the new program's bridge carries the open gates, the counts and any events accepted ahead of the clock (
EventFeed::carried); avis.voicethe old program also read keeps its value and glide target. - Pitch helpers:
vis.midi hzis the continuous MIDI note69 + 12·log₂(hz/440)(not rounded; 0 forhz ≤ 0), the angle a glide can sweep smoothly wherevis.pitchClasssteps.vis.log x(natural, ofmax x 1e-20),vis.exp x,vis.pow x p(ofmax x 0) andvis.atan2 y x(radians, −π to π) expose the IR's existing point ops;vis.midiis lowered from them, so the CPU reference and the WGSL agree by construction.
- The accepted-event stream (
engine/src/events_stream.rs): every accepted event is a fixed-size record — sequence, module generation, transport epoch, track, activation, start sample, duration, velocity, pitch in Hz (NaNwhen none; unpitched hits currently report the scheduler's MIDI 60 default), note id, origin (event index, chooser branch), beat — written by the audio thread into a preallocated 4096-slot ring without allocating or waiting. Readers keep their own cursors (EngineHandle::events_since) and get every record still held plus an exactdroppedcount when the ring lapped them; the newest are kept and nothing is replayed. Every host's bridge (visual/src/transport.rs) drains it at every tick boundary. - Scan mode:
scan { x, y, z, persistence, beamWidth, amount, displace }draws audio-rate deflections as a beam (see Scan mode below);rasterX n/rasterY nare its audio-rate raster pair. - Colour spaces live in the type:
Color Linear(all arithmetic),Color OKLab,Color YUV. Adding two YUV colours is a type error naming the conversion. sRGB is not a value type;vis.outputencodes once.
Canvases and layers#
(Composition, 2026-09-25; §5.3 "each logical output owns a state world", Stage 8 "several logical canvases".)
ground = vis.solid (vis.rgb 0.02 0.02 0.03)
ring = vis.sdfCircle 0.3 |> vis.aa
rings = vis.canvas "rings" [vis.layer (vis.feedback "trail" (\prev ->
ring |> vis.over (prev |> vis.zoom 0.97 |> vis.decay 3.0)))] { width = 128, height = 72 }
blurred = vis.canvas "blurred" [vis.layer (vis.canvasImage "rings" |> vis.blur 0.05)]
{ width = 96, height = 54, fit = "fill" }
screen = vis.output [vis.layer ground, vis.layer blurred { blend = "add" },
vis.layer rings { blend = "screen", transform = { zoom = 1.2, rotate = 0.3 * vis.time } }]
vis.canvas "name" [layers] { width, height, simRate, profile, fit, background }declares a logical canvas that is not presented: its own layers, resolution, precision profile and feedbacks (its own state world), transparent where nothing is drawn. The binding's value is the canvas's completed image this tick;vis.canvasImage "name" { fit }reads it by name (fit=fit, the default /fill/stretch, as forvis.image). Outputs and canvases share one namespace; everyvis.canvasbinding of the entry program is a root, so a canvas only read by name still exists. The IR is anOutputDecl { canvas: true }read through aSourceKind::Canvas { name }source.- One step per tick, dependencies first. The planner makes one
FramePlanper canvas the presented output depends on (VisualProgram::canvas_order: depth-first, so a canvas two readers share appears once) and carries them asFramePlan::canvasesstages, the presented plan last (schedule::plan_canvas_stages). A stage's composite is stored straight and linear inRgba16Float(clipped by its own profile), the form a host source has; the executor runs each stage at the start oftick_programwith the tick's context at the canvas's size, then copies its image into theSourcetexture of every later stage and of the presented plan (the CPU reference renders each stage with its ownCpuRendererand hands the frame over, binary16-rounded as the GPU stores it). So two readers see the same completed texture and a canvas never steps twice. Each stage meets the §8 pass and feedback ceilings as an output of its own; resident bytes and the estimated frame time (the cost model) are totalled across the set against the presented output's one budget (budgetMs), and a refusal names the costliest passes across it (canvas rings: …). - Rules (compile errors): a canvas that reads itself, directly or through others, in the same tick is
value dependency cycle: canvas a -> b -> a(carry its past with avis.feedbackinside it instead); an unknowncanvasImagename lists the canvases; a canvas steps at its readers'simRate(a mismatch names both rates). A canvas read straight into a pass boundary (blur, a reduction, …) goes through an identity warp so the boundary works on the reader's own grid. Host sources inside a canvas are provided at the presented output's size. - Hosts.
outputslists presented outputs first, so a host's default is one;volta-vis --output namepresents any output or canvas (a canvas shown directly is encoded like an output) and logs the stages it steps. Studio's Visuals panel lists every output, then each canvas as acanvas namechip that previews it. Under an arrangement a layer only canvases list is always on — the layer that reads the canvas is the one scenes arrange. A live edit keeps each canvas's feedbacks by identity (a stage of the same name swaps in place or by identity; a new canvas starts fresh, a dropped one is released). - Per-layer controls:
vis.layer img { opacity, blend, transform, fadeIn, fadeOut }. A fade is seconds, or bars when written as a cycles literal (fadeIn = 2c, rotating-hands 2026-09-26): a bar fade counts the tick context's tempo-map-awarebarfrom the bar at which the layer's activation changed (LayerDecl::fade_in_bars;activation.rsplaces a change stamped a few ticks back on the bar axis at the last observed bar rate), so it lasts two bars at any tempo and across a tempo map; a seconds fade still counts ticks of the fixed clock.blend=over(default) /add/screen/multiply/difference/maskcomposites the layer onto everything below it in the composite (premultiplied;add,screen,multiply,differenceare thePointOpformulas of those names,maskscales what is below by the layer's alpha —eval::layer_blend).transform = { zoom, rotate, translate }places the layer at composite time — zoom about the origin, then rotate (radians), then move — as fused warps, so each may be a tick value (a macro orvis.timemoves a layer) but not a per-pixel field. - A layer at tick-rate opacity 0 is skipped by the composite (a uniform branch, as for an arranged layer's gain), so time-windowed layers cost nothing hidden; admission still counts every layer. Generated passes declare no samplers (
gpu::cache::SAMPLED_INPUTS), so a pass binds up to the device's texture limit.
Palettes#
vis.palette "name" — linear-light stops interpolated in OKLab, every one dark to light except ink (paper to ink). Unknown names list them all.
| Name | Reads as | Name | Reads as |
|---|---|---|---|
cobalt | navy → blue → pale blue | teal | black → deep teal → pale aqua |
ember | black-red → orange → cream | verdigris | bronze → copper → patina green → pale mineral |
dusk | violet → coral → peach | blueprint | Prussian blue held saturated → white line |
heat | black → red → white-yellow | neon | black → magenta → cyan → white |
phosphor | black-green → P1 green → pale | moss | black → olive → lichen |
ink | paper → blue-black ink | ash | neutral cool greys (no hue) |
paper | charcoal → warm grey → paper | rose | black → deep rose → blush |
sodium | warm black → deep orange → amber → pale yellow | sepia | black → brown → cream |
Library#
Importable visual modules in examples/songs/, registered like the audio libraries (runtime/public/lib/index.json, golden IR). A library function takes its subject first and an options record second; omitted fields take their declared defaults (img |> ink {}), so every control is ordinary source at the call site. Signatures use the annotation spellings Image (any field — coverage or colour; the elaborator checks the element) and VisualLayer. Controls typed Linear also take a live control (tapLive "t" |> follow), but not a macro or an expression over one (Signal Control Normalized, refused as Linear; scale the subject by the macro instead); palette names a built-in palette. Each material keeps its memory in one named feedback whose identity is the calling binding plus the material's label (drawn/ink), so @identity on that binding keeps it through renames. A material's function result, like a direct vis.layer, takes the declaration's name as its layer name.
vis-materials — ink, phosphor, glass, contour, paper: image (or coverage) → image.
| Material | Controls (default) | Focal gesture | Feedback |
|---|---|---|---|
ink | bleed 0.5, dry 0.35 /s, palette "ink" | the bleeding edge of the freshest mark on grained paper | ink |
phosphor | persistence 5 /s, bloom 0.7, palette "phosphor" | the bright head of the trace over its fading tail; scanlines, soft-knee glow | phosphor |
glass | refraction 0.006, glint 0.12 Hz, palette "teal" | one narrow glint sweeping a clear body with a refracting rim | trails (the glint's after-image) |
contour | lines 10, drift 0.04, palette "blueprint" | the subject's outline as the one bright index contour over quiet drifting terrain | trails (ghost lines) |
paper | fiber 0.5, press 0.6, palette "paper" | the pressed edge in raking light; marks soak in and linger | soak |
vis-instruments — paired audio/visual instruments, each Str -> options -> VisualLayer, the event mapping written in its body:
| Instrument | Controls (default) | Mapping |
|---|---|---|
glassDrum "track" | palette "teal", size 0.35, release 1.2 s | one glass ring per accepted hit: inst.vel → radius, inst.age → growth and fade |
inkLine "track" | palette "ink", center 440 Hz, speed 0.25 uv/s, height 0.6 | an ink stroke per note: pitch → y (center mid-page), age → left end, release → right end, velocity → weight |
phosphorScope "track" | palette "phosphor", gain 0.8 (a constant), persistence 6 /s | X/Y scope of the track's own audio (tapLive) against itself 1.25 ms later, on phosphor |
vis.scan's deflections are lowered from source at module level, so inside a function they may name its string or constant parameters (substituted as literals) but not a record field or a live control — phosphorScope binds let g = cfg.gain in first.
Starters (song picker, tagged visual): vis-glass-drum (kick rings in teal glass, snare sparks written out inline in sodium; dark until the first hit), vis-ink-line (a pluck melody as ink strokes rising and falling right to left), vis-phosphor-scope (a sine voice as a phosphor Lissajous per interval), vis-survey (a ring swelling with the pad, surveyed as contour lines). Their stills at ticks 0/120/480 were looked at, headless through volta-vis; nothing has been watched in motion.
Showcase: ink-fugue (tagged visual, showcase) — a 2:13 three-voice fugue in D minor as a live title film: vis.text pressed into paper, then three vis.emit pens writing one hdr feedback page (pitch → height, an eight-bar page left to right, so imitative entries land as displaced copies), the stretto thickening every line through one macro, the coda drying the ink away to the title (Ink Fugue Demo 2026 09 25).
Showcase: orrery-film (tagged visual, showcase) — the owner's orrery with a picture: the song IMPORTED unchanged (its film's audio is byte-identical to volta export of orrery.volta) and drawn as a brass planetarium in three composited canvases (second pass, 2026-09-26): a mechanism (tine planets on the orbits of their octaves at the angles of their pitch classes, melody moons as a nested vis.emit round the chord planets, a gear train, spring barrel and stepping escapement, the lead a comet with one continuous tail through vis.feedback), an atmosphere (a vis.fluid every planet, kick and comet stirs, sparks off the gear teeth as vis.particles riding vis.fluidVelocity and vis.curlNoise) and the drop's glass sphere (the mechanism lensed and fringed through it, kick glints rayed by vis.radialBlur in a quarter-size canvas), over a vis.reactionDiffusion verdigris patina seeded by the mechanism's own image and wiped from the sun in the rewind; three rotation rates from the integral of the picture's wind (a vis.curve of bars, the slowest a turn per four bars), layer transforms on breath, shine and wind, paper at both ends (Orrery Film 2026 09 25, Orrery Film Pass2 2026 09 26).
Showcase: cathode-film (tagged visual, showcase) — the owner's cathode directed for density, the song IMPORTED unchanged (byte-identical audio: its scans read tap, not tapLive, which would take the engine off its fast mix path): six canvases — the keys as an X/Y vis.scan beam (signal), the signal printed once a bar and re-printed from the last print onto three small screens that corrode teal → ash → sodium (memory, with vis.echo), Gray–Scott rust seeded where the prints are (rust, a multiply layer), ash vis.fluid smoke stirred per keys note and vis.particles sparks thrown harder as corrode drives (smoke, sparks) — under a 24-line Rutt/Etra raster lifted per vertex by the memory's light and the keys' spectrum; one shared domain-warped flow bends every layer by corrode; a difference front denies the peak; the title returns in grain (title) (Cathode Film 2026 09 26).
Showcase: rotating-hands-film (tagged visual, showcase) — spin-06-rotating-hands (a future-bass voice-leading canon: one progression, its voices lifted an octave one at a time by invertVoicing) IMPORTED unchanged, drawn as a clock on a pitch spiral (angle = pitch class, C at twelve o'clock; radius = register). Its hands are the chord's four held voices read with vis.voice (pad, the stabs in drop 1, the final chord at the end) gliding to each new chord, so the voice leading is the hands' sweep and each head leaves beads along the spiral; the chord's shape joins the tips and every supersaw stab throws echoes of it toward the pivot; fan fans each hand into its unison, bloom lights them. Five bezel rings light one per rotation; in the builds the swell's voices (the NEXT rotation) are dashed ghost hands gated by vis.held; the hats and the build's snare roll step the second hand through vis.count; the bell rings from the top voice's note and the chops spark down the spiral (vis.particles). Behind it a square record canvas is a polar piano roll written by a moving pen and turned back by its layer transform (a feedback turned every tick resamples and smears), zoomed 1.8× so its rings fill the frame's margins and step outward as the song rotates (Rotating Hands Film 2026 09 26).
Showcase: weather-system-film (tagged visual, showcase) — em-12-a-weather-system-dreams (66 BPM generative ambient; a recurring eight-bar melody in three voicings, orbit/flock/wander modulation) IMPORTED unchanged and drawn as one dreamed day of sky over a sea: the sun crosses once, from below the horizon before dawn to a sunset under fade, while two gradients walked by one curve of the bar move the sky night → dawn → cloud → rain → storm → sun → dream → dusk. A cloud deck is noise on a plane seen in perspective (vis.warp of (x, 1)/h), thickened along the weather's arc, lit and self-shadowed by sampling it a step toward the sun, stirred by a vis.fluid of wind, chord notes and kicks, and wound into a cyclone round a wandering low (the song's planet and weather) by vis.swirl in the storm; isobars are one-pixel level lines of the pressure (a tent of aa of the signed offset from the level — aa of the unsigned distance cancels at the line), swept in round the low in the first section. The melody's three voices (vis.voice, vis.held, a nib swelling with vis.level) write a contrail across the sky once per phrase, on a page that drifts up and softens so each pass hangs above the next; a second page that barely fades returns in the dream as a receding tunnel of every earlier pass. The kick sends pressure rings and a ripple from the low and seeds a Gray–Scott maze read through the deck's plane (a mackerel sky); rain is vis.particles on a gust field; the snare is lightning with a flash; the sun opens a clearing, rays and a glitter path; stars and an aurora breathing with airT hold the night (Weather System Film 2026 09 26).
Showcase: circuitmoth-film — circuitmoth imported unchanged, as a psychedelic vector moth: mirrored wings (sdfShape with tick-valued vertices) snapped by the wingbeat, the palette stepped every third kick (vis.count), kick pulses along printed traces (trimmed sdfPath), a hue-turning feedback tunnel. Composed without a visual review.
Showcase: glass-parliament-film — fs-01-glass-parliament imported unchanged, its chamber a polar score: four glass tiers, a pen sweeping each once per four bars (the subject's length) at its voice's pitch (vis.voice, vis.held), so every entry draws the subject's curve on its tier; the album scorebook's entry map lights the stating tier and names its form; a vis.scale flip for the mirror debate. 6.0–7.9 ms of GPU per tick (measured); audio byte-identical to the song's master.
Showcase: rotating-hands-motion — the poster's clock set moving: a camera (macro pull-back, a full turn per rotation, bass-up counter-rotation in the drops), hands trailed by vis.smooth lags, a scrolling score (a feedback canvas shifted two pixels a tick), the title's glyphs swinging in, speed lines into each drop. Every radial pattern is a phase of one shared angle and the ink marks are placed as one group: 13.1 → 8.7 ms of GPU per tick at 1280×720 (measured).
Showcase: rotating-hands-poster — spin-06-rotating-hands imported unchanged and set as a Swiss concert poster: the film's clock (hands = the chord's voices by vis.voice) in flat vector inks — a radar chord (tips sorted by angle, five comparators), pitch names that take a voice's colour, a type column with the section's name, the bar in seven-segment digits and live register meters, an iris to ink for each drop. 7.5–9.4 ms of GPU per tick at 1280×720 (measured); stills inspected.
Showcase: showreel-film — a 15-second motion-design reel, one scene per bar at 128 BPM (the loop is the reel): trim-path strokes, per-glyph type, a spring morph with echoes, a tile wave, a fluid, a kaleidoscope drop, an end card; each scene a layer gated by opacity. Admits at 11.3 ms (measured); stills inspected, not watched in motion.
Showcase, favourite films: machina-film (a gear train ticking with the throb's limp), heliograph-film (a halftone sun-print, a solargraph of the fade), pipeworks-film (the tubulum as pipes lit by mode shape), hoarfrost-film (a frost dendrite per spectral move), sinew-film (the Reese as a muscle above the crossover, the sub a bone slab), gullet-film (the growl's chain as lips), lodestar-film (a compass that is the mixing desk), moonspindle-film (a harp on a spinning moon), subject-evaporates-film (a riso fugue page that evaporates), pyre-film (a fire in the mode's heptagram), frostbyte-film (a snowflake counting glitch hits in binary): each imports its song unchanged (master audio sample-identical), 6.6–28 ms GPU per tick offline (measured), several with a raised budgetMs; stills inspected only.
Showcase: glass-percussion-suite — §1's glass percussion as a 72-bar 124 BPM techno piece: glass rings per kick over a feedback tunnel, a scene per picture, and a dial whose hand every 2 rev reverses against steady drums.
The frame budget (§8). Live, a program is admitted by its estimated milliseconds per tick, not by node count: 12 ms at 60 ticks per second (16.7 ms less headroom), scaled by 60 / simRate, or the output's own vis.output [...] { budgetMs = 20 }. Offline (film export, headless volta-vis) never refuses for time. The planner's cost model (visual/src/cost.rs) walks every pass the way codegen emits it: each node a shader evaluates costs an op weight × the pass's pixels × every loop around it (a supersample n n², an emitter's max instances, the four extra evaluations of a centred aa, a select's costliest option), plus texel fetches (a bilinear sample is four textureLoads, a blur axis 2⌈3σ⌉+1, a minifying zoom/scale multiplies them), bytes written, and a fixed cost per pass. Its constants are fitted to GPU timestamps (volta-vis --bench) on an Apple M1 Max at 1280×720 and 640×360; every fixture and showcase estimates within 2× of the measured mean (worst 1.42×; Visual Cost Model 2026 09 26). The admission figure is the worst case — every emitter full, every layer shown — so orrery-film (four canvases, three simulations; measured 3.9–8.9 ms mean per scene) admits at 10.7 ms, ink-fugue (3.5 ms) at 4.2, glass-percussion-suite (1.0 ms) at 3.4. Over budget is a plan error, frame budget: estimated 14.2 ms per tick at 1280x720 > 12.0 ms; costliest passes: composite screen 13.9 ms, …, naming three passes and the knobs (an emitter's max, a supersample factor, the output size, budgetMs); LiveSession reports it as a rejected edit and keeps the running program. The hard ceilings are resources: 16 render passes, 8 feedbacks, 256 MiB of resident textures per output. Above 1024 live nodes the planner only warns. GraphBuilder interns constants — one node per value and type (per source site for literals, so a warm literal edit stays warm; a function called four times shares its literals) and one per identical uniform declaration (measured): orrery-film 705 → 611 nodes, ink-fugue 254 → 235, glass-percussion-suite 303 → 296. A slow decay (a long memory) needs profile = "hdr": an 8-bit portable feedback stops decaying once v · (1 − e^(−rate·dt)) is under half a code step (at 0.4/s, below ≈ 76/255), leaving a permanent stain.
Diagnostics#
no common domain (an audio-rate signal where an image parameter is expected; the message names follow, tapLive … |> follow, vis.bands, vis.onset, pitchOf), value dependency cycle (an image binding that refers to itself; the hint is the vis.feedback rewrite), same-tick cycle (a reduction inside the feedback body it modulates), colour arithmetic, feedback unused (warning), note-local vel in a field (the hint is vis.emit), a per-pixel field or an instance value where a tick value is required (layer opacity, select index, a layer transform, radialBlur's origin), duplicate feedback identities; for vis.emit: an unknown track, a non-constant or out-of-range max, a body that is not an image, an unknown inst field, a filter, reduction, resample or feedback reading an instance value, a second level of nesting and a nested max product over 256; for canvases: a canvas cycle, an unknown canvas name, a simRate mismatch, an output/canvas name declared twice; an unknown layer blend or transform field; flowWarp by a field that is not a Field Vec2. alias (a warning): vis.fold, vis.wrap or vis.threshold written on a per-pixel field that is not a band-limited generator (osc, noise, checker, grid, rings, spokes, gear) or aa/aaWidth coverage, and not inside a vis.supersample; the message offers vis.supersample 4. vis.scan inverts the domain rule: its x/y must be audio rate, and a control or constant there is no common domain naming rasterX/sine. scan budget (runtime, logged by the host once): more than 65,536 scan samples in one tick. frame budget (live plan error): the estimated tick over the output's budget, naming the three costliest passes (above).
Scan mode#
beam = vis.scan { x = 0.8 * sine 220Hz, y = 0.8 * sine 330.1Hz, z = 1.0 }
screen = vis.output [vis.layer (beam |> vis.bloom { radius = 0.03 })]
x and y are audio-rate signals in uv units; they lower to ordinary persistent graphs in Module.control_graphs whose output is ControlScope { scope: "scan" } (free-running, and the one control output allowed to carry audio), so the engine evaluates them per sample on the audio thread whether or not the transport plays. z (default 1) is the intensity; z <= 0 blanks the beam. persistence (per second, default 8) and beamWidth (uv, default 0.01) are constants; amount (default 0.3) is a tick value.
The three deflection paths of the design (§9.3): a Volta signal on the audio thread (y = rasterY 240 + 0.1 * level); a picture's value on the GPU, per vertex (displace = field, each vertex's y gains amount · field(x, y), the field materialized and sampled bilinearly at the vertex); and a picture read back into sound, which does not exist (a later vis.bake).
relief = vis.noise { scale = 3 } |> vis.translate (vis.vec2 (0.15 * vis.time) 0)
raster = vis.scan { x = rasterX 32, y = 0.8 * rasterY 32, persistence = 12,
beamWidth = 0.008, amount = 0.35, displace = relief }
rasterX n sweeps −aspect..aspect n times per simulation tick; rasterY n steps through the n line centres (−1..1) once per tick. Both read the absolute sample position, u = frac(s · simRate / sampleRate) in exact integers, so they restart on the very sample the host's SimClock starts a tick; the aspect and tick rate come from the output that shows the scan (16:9 and 60 elsewhere).
Delivery. A host attaches a tap to the compiled module (CompiledModule::attach_scan_tap, engine/src/scan_tap.rs): a ring of 4 × 65,536 samples per lane (x, y, z per scan), written by the engine at the end of every block at the block's absolute position — atomic stores into host-allocated storage, no allocation, no lock (rt_safety pins it). Tick n draws the samples of [tick_sample(n − 1), tick_sample(n)), the interval that just elapsed (800 at 48 kHz / 60 ticks/s; tick 0 draws none); a reader the writer lapped reports the lost positions as dropped and never replays one.
The beam (visual/src/scan.rs, the CPU and the GPU the same model). σ = beamWidth / 2, widened for a half-pixel footprint to σe = √(σ² + (½ px)²) with peak (σ/σe)². A segment a → b of length L with weight w = ½(z_a + z_b) · (48000 / sampleRate) (samples of beam time, so the device rate changes nothing) deposits at p, t along and h across it, w · (σ/σe)² · exp(−h²/2σe²) · σe√(π/2)/L · [erf((L−t)/σe√2) + erf(t/σe√2)] — the time average of the Gaussian travelling along it: fast moves are dim, dwell is bright, consecutive segments join without beads. Outside the capsule of radius 4σe a segment deposits exactly nothing (the GPU draws one instanced quad per capsule, never hardware lines). The phosphor is f16: I_n = f16(I_{n−1} · exp(−persistence · dt) + Σ segments), the sum accumulated in an R32Float target first where the adapter blends it. The scan's value is a Float intensity; vis.scan returns it as light, straight (max(I,1), max(I,1), max(I,1), min(I,1)).
Budget. Past 65,536 samples in one tick across every scan, each keeps every ceil(total / 65536)-th sample at that multiple of the weight, so the picture keeps its brightness, and the host logs scan budget.
Simulations#
Stage 8's bounded compute sources (visual/src/compute/): stateful sources like a feedback. Each steps ONCE per tick — after the per-pixel fields it samples are materialized, before any reader — from last tick's state, at its own resolution, and publishes fields that read stretched over the canvas.
sway = 0.35 * vis.sin (1.7 * vis.time)
smoke = vis.fluid "smoke" { resolution = 54, vorticity = 2.5, dissipation = 0.25 }
|> vis.fluidForce (vis.vec2 sway (-0.75)) (vis.vec2 0 1) 4 { radius = 0.18 }
|> vis.fluidDye (vis.vec2 sway (-0.8)) (vis.rgb 1 0.45 0.12) 3 { radius = 0.1 }
coral = vis.reactionDiffusion "coral" { resolution = 120, feed = 0.0545, kill = 0.062 }
|> vis.rdSeed (vis.noise { scale = 5 } |> vis.smoothThreshold 0.72 0.8) (vis.clamp (1 - vis.tick) 0 1)
sparks = vis.particles "sparks" { count = 4096, life = 4, drag = 2.5 }
|> vis.particleEmit (vis.vec2 0 (-0.7)) (vis.vec2 0 0.3) 0.35 900
|> vis.particleForce (vis.curlNoise { scale = 2 }) { gain = 0.8 }
screen = vis.output [vis.layer smoke, vis.layer (sparks |> vis.particleTrail { palette = "ember", gain = 0.3 })]
| Source | State and step | Inputs | Reads as |
|---|---|---|---|
vis.fluid "l" { resolution, viscosity, dissipation, vorticity, persist } | velocity + premultiplied dye on resolution cells (shorter axis); Stam: advect velocity → diffuse (20 Jacobi, viscosity uv²/s) → forces + vorticity confinement → project (div, 40 Jacobi from p = 0, subtract ∇p) → advect dye, splat, exp(−dissipation·dt) | vis.fluidForce fl pos dir strength { radius }, vis.fluidDye fl pos colour amount { radius }: Gaussian splats per second | an Image (dye, straight); vis.fluidVelocity fl a Field Vec2 (uv/s) |
vis.reactionDiffusion "l" { resolution, feed, kill, diffA, diffB, steps, persist } | Gray–Scott (A, B) from (1, 0), steps unit sub-steps per tick of viz's kernel (Sims' 3×3 Laplacian, toroidal, clamped) | vis.rdSeed rd field amount: B += amount·field per tick (an image seeds by its luma) | Field Float = B |
vis.particles "l" { count, life, drag, seed, persist } | count slots, spawn g takes slot g mod count (the oldest is stolen); vel ← f + (vel − f)·exp(−drag·dt), pos += vel·dt; streaks prev → pos weighted 1 − age/life through the scan beam model into a canvas persistence | vis.particleEmit ps pos vel spread rate (rate/s carried fractionally; lattice_hash(seed, sim, g, k) jitters velocity by ±spread and birth along the tick); vis.particleForce ps field { gain } (a flow in uv/s) | Field Float = trail intensity; vis.particleTrail ps { size, decay, palette, gain } colours it (straight palette colour, alpha = min(gain·I, 1)) |
vis.curl f { step } is (∂f/∂y, −∂f/∂x) by centred differences at uv ± step (four fused warps of f); vis.curlNoise { scale, octaves, seed, step } is the curl of vis.noise. Tick inputs (positions, forces, rates) are evaluated on the CPU at the tick boundary; inside a vis.emit body they may read inst.…, and the body returning the stirred simulation (vis.emit "kick" (\inst -> smoke |> vis.fluidForce … (8 * inst.vel))) evaluates them once per live note — at most 64 impulses per kind per tick. An impulse whose value is not finite that tick is skipped (a tick value divided by a macro expression is 0/0 on the tick before the macro's first value arrives; one NaN splat would poison the state for the rest of the run). Per-pixel inputs (seeds, flows) are materialized unclipped in Rgba32Float at the canvas size and sampled bilinearly (transparent edges); at most four per simulation. A simulation cannot read its own field in its inputs (same-tick cycle) nor a reduction of the tick.
State is transport-scoped and keyed by identity like a feedback (@identity or the owner, plus the label): a hard stop resets it, a seek resets it unless persist, a live edit keeps it when the identity, kind, parameters, grid and variant match (SwapReport::kept, the CPU renderer's inherit). Nothing depends on draw order.
Variants (§8). A program with a simulation declares requires: ["compute"]; the plan records PassKind::Simulation { variant } from the host's compute capability and volta-vis (stderr, manifest.json simulations) and the browser panel's notes say which runs (Executor::variants):
Compute | Portable (no compute, e.g. WebGL2) | |
|---|---|---|
| fluid | compute kernels over Rgba32Float | the same kernels as fragment passes without pressure projection — no longer incompressible, the plume puffs rather than curls |
| reaction–diffusion | compute kernels | the same kernels as fragment passes (identical) |
| particles | spawn + update compute kernels writing the streak instance buffer | the CPU update (the reference code) uploaded per tick, flows evaluated at each particle; above 4,096 particles, or a flow reading GPU state, source unavailable in this host |
Publishing (Rgba16Float fields) is a fragment pass; streaks are instanced capsules from a vertex buffer (no vertex-stage storage), summed and combined beam + exp(−decay·dt)·trail with explicit round-to-nearest-even into f16. The CPU reference (CpuRenderer::set_portable_sims selects the substitute) runs grids in f64 and particles in f32; it materializes the per-pixel inputs as the GPU does.
Cost and residency (§8). A step is a plan pass the cost model charges (cost::sim_pass): SIM_LAUNCH_MS (0.019) per kernel launch (compute::step_launches: 50 for the default fluid, steps + 2 for reaction–diffusion, 4 for particles) plus FLUID_MS_PER_MCELL_ITER (0.14) per million cell-launches, RD_MS_PER_MCELL_STEP (0.35) per million cell sub-steps, or PARTICLE_MS_PER_K (0.0166) per thousand particles plus PARTICLE_TRAIL_MS_PER_MPX (1.0) per canvas megapixel — fitted with volta-vis --bench at two sizes each (1280×720, Apple M1 Max, 2026-09-26; the dense-emitter trail over-estimates a sparse one about 3×). Its state (compute::state_bytes: eight f32 grids for the fluid, two for reaction–diffusion, 96 bytes a particle plus three f16 canvases) counts in the plan's resident_bytes beside its published fields, and the executor reports the same figure. In a canvas, a simulation steps in that canvas's plan stage, once per tick however many layers read the canvas; the variant line says canvas NAME: ….
Running it#
In Studio. A song whose module has an output grows one chip, visuals, at the right end of the right column's 02 header (an audio-only song grows nothing); it opens the Visuals panel (volta-shot --views visuals, shell token visuals; Window ▸ Visuals in the native menu bar; Visuals and Open Audience Display in the palette and the command bar while the song draws). The panel shows the selected output letterboxed on black (a chip per output when there are several), a status strip — tick N · 60/s · 2 passes · 10.5 MiB · 1280×720; a stall past the clock's lag limit resyncs at once (feedback reset) and says resynced after a stall: N ticks not simulated, as the browser panel and the volta-vis window do — and the output's layer list (name, envelope gain, opacity at the last tick). It is volta-vis's host over Studio's one engine: the shell attaches the taps to every compiled module before its engine gets it (StudioTaps::attach) — the clocked uniform ring (one ring per engine rate, re-subscribed under a fresh generation per module), the scan tap, and the PCM tap behind the media sources, whose paths resolve against the song's directory — and the device callback mirrors the arrangement walk's layer table (engine-host's block observer; volta-shot reads it off the engine it owns). EngineBridge::visual_meters hands over the meter snapshot, the accepted events, the audible sample and that table. A LiveSession over the shell's engine (StudioEngine; Source::hosted — the shell installs the audio) runs on Studio's wgpu device on the UI thread, ticking on the engine's absolute sample grid up to the audible sample (sample_clock − output_latency) minus the ms picture delay, with shader time counted from ▶; each tick reads its own boundary's values from the ring, and scans and media are fed per tick. A song with no tracks runs its picture from ▶ on the engine's sample clock (the engine renders silence; ■ stops it). Pause holds the picture and resume continues it; stop clears every feedback; a seek (a jump in the song clock's beat) clears the non-persist ones; nothing ticks while neither the panel nor the audience display shows, and coming back resyncs with a reset. Edits go through the session: a plan equal to the running one is updated in place, a visual-only edit commits on the next tick boundary, one whose audio changed (its IR outside visuals, playback, diagnostics, provenance differs) commits on the tick the ring reports its module's generation, a superseded candidate is discarded. A plan or shader error keeps the last good picture and lands in the footer's hot-swap line (with --> line, col when the planner knows the span) and a toast; a commit appends in place, 0 pipelines compiled or N pipelines compiled, the feedback kept / reset / released and, for a shared edit, with the audio (generation N). Without a wgpu device (kittest) the same tick contexts run without an executor, so the clock, ring reads and layer list stay testable. Audience display (open audience display in the panel, or Window ▸ Open Audience Display): a second, chrome-free native window presenting the same completed texture — the simulation never steps twice. It first opens windowed; drag it to the projector and press F for full screen, Esc or close returns. Its position (which picks the display), full-screen flag and the picture delay persist in the shell state (audpos, audfull, audoffset).
Tracing an instance to its note. A song with a vis.emit adds an instances · N live chip under the layer list. Open it and each emitter gets a line (lead · emitter 0 · line 5 · 3 live; inside 0 for a nested one; the line only when the call is written in the song itself) and a chip per live instance, #4 c4: its spawn ordinal and the written note whose accepted event spawned it. Choosing a chip (click, or Tab to it and Enter) links that note exactly as picking the same played note on the Timeline does: Source, the Timeline and the card light its atom, and the card marks that one occurrence. The note is read from the index of the program that was playing when the event was accepted, never from whatever runs now: an instance spawned by an earlier run reads an earlier run's c4 and is not selectable, and one the inspection history can no longer answer says why (its record is no longer held, its program was evicted, …); an instance with no accepted event behind it says so. A nested emitter's instance comes from its own track's event, not its parent's. A seek, stop or visual swap restarts the instance tables, and nothing from before is listed again. The list shows the first 48 instances (showing 48 of N past that). This traces instances only: a pixel after blending, blur or feedback has many sources and is never attributed to one note. Under the hood each instance carries its record's sequence and install run beside its drawn values (visual/src/instances.rs; the drawn values and seeds are unchanged), and the shell resolves it through the same occurrence history the card uses (ui/src/visuals.rs, ui/src/occurrences.rs); the wire identity is volta_inspect::InstanceRef.
In the browser. The workbench (studio.html / index.html, the same Rust UI as wasm) shows the same panel with the same executor, clock and uniform bridge, on egui-wgpu's device — WebGPU where the page has it, else WebGL2 through wgpu's GL backend (ui/src/visuals_web.rs). What differs:
- No threads. The plan is made on the UI thread when the program changes; pipelines compile as a
spawn_localfuture that awaits each validation scope (PipelineCache::compile_asyncon wasm32) and commit after a frame's ticks, the native tick boundary. No call blocks on a GPU future: a tick's validation scope resolves on the event loop and its error surfaces on the next tick. - The engine is in the worklet. Controls come from the worklet's viz frame (level, peak, bands, macros, control values, the song clock). While the panel shows, the page polls
WasmEngine::visual_bridgeonce per animation frame and hands the bytes to the workbench: one boundedVSB1message (engine/src/visual_bridge.rs) of tagged, length-prefixed sections —EV01accepted events (≤ 1024 per message, 50-byte records, the stream ring's exact lap count),UR01clocked-ring records (≤ 2048, 41 bytes each, plus each generation's lane table; the engine attaches the program's controls to its ring at install, on the first output's schedule),LA01the walk's layer table (≤ 64 layers, published by the audio half after every block through a seqlock),SC01the scan tap's x/y/z samples andPC01eachvis.spectrumbus's post-fader mono PCM, both at absolute engine positions (≤ 65,536 positions × scans = 768 KiB and ≤ 65,536 samples = 256 KiB per message; the engine attaches both taps at install, asvolta-visdoes). The engine keeps the cursors, so a burst continues in the next message and only a ring or tap lap loses anything (counted in the status). Plain messages: nothing needs a SharedArrayBuffer or cross-origin isolation. Sovis.emit/vis.onset, ring-clocked controls and scene activation move in the browser (a ring value is read at the newest absolute boundary at or before the tick's sample; the panel's origin is where ▶ was seen, so this is within a tick, not the silent pump's exact alignment). - Scans and spectra. The panel keeps the tap samples by position and hands tick
nexactly[position(n−1), position(n))— the scan's points (WebGPU; WebGL2 has no scan pass) and each spectrum bus's history, which the samevisual::spectrumFFT reads on the page (never in the worklet). Positions a tick has no samples for count asshortin the status. - Media.
vis.image(and avis.videothat names a PNG/JPEG) files are fetched by the page relative to the song's URL (/songs/x.volta→/songs/<path>) and decoded in the workbench bysources.rs's decoder;vis.textrasterizes with the bundled Barlow. While a file is in flight the source is transparent; a missing one shows the placeholder and the panel names the URL it fetched (image "…" not found at http://…/x.png (HTTP 404); showing the placeholder). A frame-sequence directory is unavailable (a page cannot list a directory), as is a container. - Visual-only songs (no tracks, so no viz frames) run on the tap's frontier — the engine's rendered sample — when the program has a scan or a spectrum, and on egui's clock otherwise, while the panel shows, as
volta-vis --silentdoes. A change of clock (audio song ↔ visual-only, egui ↔ frontier) restarts the picture's transport rather than jumping it: two clocks' positions are not comparable, and a jump of seconds is past the clock's lag limit, which freezes it as Behind. A page's main thread stalls (a compile, a GC, a background tab) past that limit in ordinary use, so the browser panel resyncs at once instead of waiting for the chip — the feedback resets, as a resync does, and the panel saysresynced after a stall: N ticks not simulated, feedback reset. - Capabilities, not browser names. The adapter is measured once (
visual/src/gpu/caps.rs); a profile the device refuses is degraded before planning (exact → hdr → portable) with the reason in the panel, reductions run compute or the half-size render chain, and a scan on a device without vertex-stage storage buffers isscan <id>: source unavailable in this hostin the panel while the rest draws.
Measured 2026-09-25 (Chromium headless shell 149, Apple M1 Max, ANGLE Metal for WebGL2 — runtime/scripts/visuals-browser-test.mjs):
| WebGPU | WebGL2 | |
|---|---|---|
| reductions | compute | render chain |
Rgba16Float render + filter + blend | yes | yes (EXT_color_buffer_float) |
Rgba32Float render / filter | yes / yes | yes / yes (OES_texture_float_linear) |
| vertex-stage storage (scan) | yes | no → scan unavailable |
scan tap across the worklet (SC01) | draws | arrives; no scan pass |
PCM tap + page-side FFT (vis.spectrum) | yes | yes |
vis.image / vis.text | yes | yes |
vis.video frame-sequence directory | no (placeholder) | no (placeholder) |
portable | runs | runs |
hdr (grow-normalize.volta) | runs, no degrade | runs, no degrade |
exact | not exercised | not exercised |
A WebGL2 device without float render targets would degrade hdr to portable (unit-tested with synthetic capabilities; not seen on hardware).
The standalone host is volta-vis:
cargo run -p volta-visual --bin volta-vis -- song.volta # window + sound, hot reload on save
cargo run -p volta-visual --bin volta-vis -- song.volta --silent # window, no audio device
cargo run -p volta-visual --bin volta-vis -- song.volta --export film/ --fps 30 --mux film.mp4
volta export video song.volta --fps 30 --size 1920x1080 --out film.mp4
cargo run -p volta-visual --bin volta-vis -- song.volta --headless --ticks 0,30,60 --out frames/
cargo run -p volta-visual --bin volta-vis -- song.volta --headless --ticks 0,60 --compare-cpu
cargo run -p volta-visual --bin volta-vis -- song.volta --headless --ticks 0,60 --export-reference ref/ # contexts.json + CPU frames for another host
volta --dump-field-graph song.volta # the typed graph
cargo run -p volta-visual --bin volta-vis -- song.volta --dump-passes
cargo run --profile fast -p volta-visual --bin volta-vis -- --bench song.volta --ticks 600 --at 1000 # per-pass GPU ms beside the estimate, canvas stages' passes first
cargo run --profile fast -p volta-visual --bin volta-vis -- --bench tunnel --size 640x360 --bench-json t.json # a fixture: every emitter full
cargo run -p volta-visual --bin volta-vis -- song.volta --assets media/ # vis.image/text/video paths after the song's dir
cargo run -p volta-visual --bin volta-vis -- rutt-etra.volta --camera 0 # start camera 0 for `vis.camera 0` (window only)
--camera N (repeatable) is the explicit host action for vis.camera N; it is refused with --headless, --export and --script. In Studio the Visuals panel lists each declared camera with its status and a start camera N / stop camera N chip (Window ▸ Start Camera does the same for all of them). Studio remembers per song (a hash of its path, the shell state's camsongs=) that the camera was on and says so on the next load, but never starts it; a camera the panel started stops when the song no longer declares it.
The window plays the song through the default output device with one engine: the cpal callback pulls blocks from it exactly as volta-viz does, and the simulation steps at the output's simRate (60 by default) up to the audible sample — the last buffer's start plus the time since its callback, minus the device-reported output latency, minus --av-offset-ms (positive delays the picture) — and each tick reads the clocked ring at its sample (else the newest meter snapshot the listener has reached). It logs every 2 s the frames handed to the device, the latency estimate, the audible sample and the ticks run. On CoreAudio, cpal's estimate is the buffer duration (10.7 ms at 512 frames, measured); converter and hardware latency beyond it are not reported, which is what --av-offset-ms is for. Space pauses sound and picture together (the engine does not advance while paused); the sound starts when the window is up. --silent (or a fixture/JSON source, or no output device) runs the silent pump instead, paced by the monotonic clock. The window presents every frame, and on a file change compiles off-thread, commits at a tick boundary, transfers feedback textures whose identity (@identity or the declaration path, plus the label, element type and size) matches, resets the rest and reports it; a failed edit keeps the last good program. Keys: space pause, r reset feedback, s save PNG, d dump passes, Esc. Headless mode writes tick-NNNN.png and a manifest.json (source hash, seed, size, rates, profile, adapter, per-frame hash) and exits non-zero on any error.
Finished film. volta export video (and volta-vis --export DIR) writes a bundle — frames/frame-NNNNNN.png, audio.wav (float32 stereo, 48 kHz, the whole mix under the −1 dBTP print ceiling, no loudness target) and manifest.json (source hash, seed, sample assets and hashes, Volta / execution / visual-IR versions, sample and simulation rates, fps as a rational, canvas, profile, colour transform "linear→sRGB at output", audio hash, frame count, per-frame sample/tick/hash, tick count, droppedTicks: 0, complete, and totals: events {accepted, dropped}, scans {segments, decimated} — samples drawn and ticks thinned by the budget — and layers [{name, transitions}]). One offline engine renders the audio (the flat 8-bar render, or the arrangement's whole form, plus the 2 s ring-out — volta export stems's master schedule; --duration S cuts, extending a flat render first) and, through a per-block tap (render_export_tapped), drives the executor exactly as the silent pump does. One block grid for every host: the render keeps volta export's fixed 128-sample blocks, so audio.wav is byte-identical to volta export stems --target-lufs off --bit-depth float's master.wav (same render, −1 dBTP ceiling, float32, marker cues); the silent pump, LiveSession and the audible device pump render the same grid. A tick falls inside a block, and runs after the block containing its sample; every tick input is read tick-exactly — controls, macros and @shared values from the clocked uniform ring (the value at the sample before the boundary), accepted events (vis.emit, vis.onset) by start_sample (an event is seen by the first tick whose boundary is after its start), scan-tap samples by sample range, media sources (resolved against the song's directory, ExportInput::song_dir; offline, so a film waits for its exact frames), the arrangement layer table (a shadow of the walk's runner over the export's own segment walk) by since_sample — and the block meters (vis.level, vis.peak, vis.bands) are the block containing the tick's sample: up to 127 samples (2.6 ms) of meter latency, the same in every host (the ring carries them as meter lanes; without a subscription a host reads the viz snapshot of that same block). Frame n presents sample ceil(n · 48000 · den / num) (carried remainder; 29.97 is 30000/1001) and is read back after every tick due by it. Strict: the event stream is drained after every block and a lap of its ring aborts with the count and the densest track; a lapped uniform ring or scan tap aborts; a scan budget thinning warns once on stderr. Any abort — or Ctrl-C — leaves the frames so far and a manifest.json with complete: false and the error. Flags: --output, --fps (an integer, 23.976, 29.97, 59.94, N/D), --size, --duration, --profile portable|hdr|exact, --seed, --samples DIR, --frames-dir (bundle location; default <out>.bundle, else <score>.film), --out film.mp4|.mov, --progress (a line per second of film). With ffmpeg on PATH, --out muxes a complete bundle as H.264 High yuv420p CRF 16 plus AAC 320k (.mp4) or PCM s16le (.mov), -framerate from the rational fps, every frame kept (-frames:v) and the audio padded to the picture's end (apad; -shortest used to drop the last frame); without it the exact command is printed, exit 0, bundle intact. The CLI builds volta-visual with export only (wgpu, no winit, no cpal).
The audible window feeds the same three inputs: the device pump reads the accepted-event stream and places each event on the AUDIBLE timeline (its start sample against the tick's audible sample, not the rendered frontier), drains the scan tap per tick, and reads the walk's layer table, which the cpal callback copies out after every buffer (a seqlock over preallocated atomics, up to 64 layers).
Arrangement#
Layers are scene members (spec §3.4). Name a vis.layer binding in a scene and the arrangement decides when it shows:
tempo = 120bpm
bass = play "bass" !"c2 ~ c2 ~"
light = vis.layer (vis.osc { stripes = 12 } |> vis.kaleid 5)
halo = vis.layer (vis.sdfCircle 0.35 |> vis.abs |> vis.sdfSub 0.02 |> vis.aa) { fadeIn = 500ms, fadeOut = 250ms }
screen = vis.output [light, halo] -- compositing order lives here
set = song {
scenes = { intro = [light], body = [bass, light, halo], outro = [light] },
arrange = arrange [[4, intro], [16, body], [4, outro]]
}
- Without a song every layer an output lists is always shown. With an arranged song (
arrangeorsongGraph; the program is markedarranged), a layer shows only in the scenes that name it; a layer no scene names warns (VisualLayerNeverArranged) and never shows; a scene layer no output lists warns (VisualLayerNoOutput); an output as a member is an error. Scene algebra, lead-in fills and quantization work on layers as on tracks; a visual-only scene keeps the transport running. - Fades are layer schema fields (
fadeIn,fadeOut, default 0 = a step): seconds counted in ticks of the output's clock (round(s · simRate)), or bars (2c). Audio gains never become opacity; a scenewithcan still move a macro the image reads. - Protocol. The engine's walk stamps each layer's
{active, since_sample}on the scene's boundary sample (the one its tracks change on). The host reads it at each tick's sample, converts the sample to the first tick at or after it, runs the envelope (linear; a re-entry during a release rises from where it was) and writesTickContext::layer_gain. The CPU reference and the GPU executor read the same gains: opacity × gain, a layer at gain 0 skipped in the composite (a uniform branch in the shader), and every boundary or pass only hidden layers need not run. - State. A layer kept across adjacent scenes keeps its activation and its feedback. A feedback only one layer reaches is suspended while that layer is hidden and restarts from
initon the tick it shows again (the observable form of §5.3 "clear its private state"); a feedback shared with a shown layer keeps running. volta-viswith a song arms the walk, follows it, and logs each change:tick 480: scene layers [light, halo] (off: []).
Live edits and state#
The clocked uniform ring (engine/src/uniform_ring.rs). Every control-like uniform — a control graph (follow, signal, …), an @shared instance, a macro — reaches the picture through a ring the audio thread fills, not through the latest-value meter snapshot. The host subscribes a module's lanes under a generation tag (CompiledModule::attach_uniform_ring) and a tick schedule (sim rate, sample rate — TickSchedule, the one rational formula SimClock also uses). At the end of every block the engine writes, for each tick boundary b inside it, each lane's value at sample b − 1 as a fixed-size record {tick, sample, lane, value, stepped, generation, epoch} into 8192 preallocated slots: relaxed stores under a per-slot seqlock, no allocation (rt_safety pins it). Readers keep cursors; a lapped reader keeps the newest and gets an exact drop count. stepped marks a discrete change (the lane held still for at least a quarter of the tick and moved one way — a pattern-held signal, declicked by the engine over 5 ms, or a macro set); a transport-epoch, generation or schedule change writes a marker record and steps the next value. Nothing interpolates across either. UniformBridge::update_at(n) reads tick n's own record, so the four catch-up ticks after a slow frame each see their value; a lane not yet delivered holds its previous value. Block meters (level/peak/bands) are ring lanes (the block holding the tick's sample, never stepped); onset (the accepted-event stream) and the transport beat are not: the beat is carried from the snapshot to the tick's boundary at the snapshot's own rate. The silent, device and export pumps drain a ring too; a source without one (EngineMeters::clocked → None) reads latest-value per tick. Studio reads its own ring (the panel's StudioEngine, its EngineMeters).
The live session (visual/src/live.rs, LiveSession) is what edits go through. Each edit is a versioned candidate: planned, pipelines compiled (off-thread, or at once for tests and --script). A newer edit cancels older candidates; a cancelled candidate's late completion is discarded, never committed. A visual-only edit commits at the next tick boundary: the old program rendered tick n − 1, the new renders n from the inherited feedback. An edit whose audio changed too (its IR outside visuals differs, or it reads a control the running subscription lacks) loads the module under a fresh ring generation and keeps rendering the old picture until the ring reports that generation; the visual commits on that tick (pinned by visual/tests/live_edit.rs: module taken at sample 32000 after tick 40, picture switched at tick 41). A generation never reported within 120 ticks commits anyway and says so. A plan equal to the running one up to source positions (shader // n<id> <span> comments) is a parameter-only edit: updated in place, nothing compiled or rebuilt (SwapReport::in_place); under the live policy any literal value change is one (below). Failures (syntax, plan, shader, swap) keep the last good program and are reported.
| Operation | Behaviour |
|---|---|
| Parameter-only edit | In place: every feedback, scan, held selection kept; zero pipeline compiles |
| Graph edit | Feedback kept when identity, element type, history, size and sim rate match; else reset with the reason |
| Rename | Declaration path changes the identity (reset); @identity pins it (kept, state bit-identical) |
| Pause / resume | Clock and engine frozen; resume continues bit-identically |
| Hard stop | Transport restarts; every feedback, onset and instance cleared |
| Seek | Non-persist feedbacks reset; persist kept; the ring marks the epoch |
| Canvas size / sim rate | Re-planned; the swap resets and reports WxH fmt -> WxH fmt / simulation rate a -> b |
The session's silent engine renders whole 128-sample blocks on the absolute grid (the SilentPump default — the one grid of export and every host; set_block_grid(false) only for a truth reference stopped at exact boundaries): some engine paths (followers) depend on the block partition, so this plus the ring (and the pump keeping each tick-holding block's snapshot for the viewed tick) is what makes the state independent of presentation cadence. volta-vis <song> --script FILE replays a script headless over the same session and exits non-zero on a failed expectation: tick N, edit PATH, seek S, stop, pause, resume, size WxH, rate N, expect-reset NAME, expect-kept NAME, expect-error, expect-in-place. Studio's panel runs its edits through the session too (over its own engine, Source::hosted); the volta-vis window's watch-and-reload loop does not use it yet.
What counts as parameter-only is the plan. A tick-domain expression (the 0.3 + 0.2 * punch radius) is one uniform slot. Under the live policy (SchedulePolicy::literals_as_uniforms: Studio, browser, volta-vis's window, --policy live) every literal a shader reads is a uniform slot too (k<id>, packed last in the block): a Const leaf such as the 0.98 in vis.zoom 0.98, or an all-constant subtree the compiler left unfolded (1.0 - 0.94), which is ONE slot evaluated on the CPU. A constant the compiler marks derived (a trimmed path's segment geometry, never written in the source) is inlined instead — lifted, a path's ~1,000 of them cost 40 ms of GPU per tick at 1280×720 against 3.6 ms (measured). So changing a number's value leaves every shader byte and hash unchanged and is in place; undoing it is too. Structural constants stay in the shader and a change to one compiles: a vis.colorize palette or vis.curve table, and everything that is an op field rather than a value — a supersample factor, a resample size, a delay depth, a select option count, an oscillator shape or ramp axis. The literal slots are bounded: past 16 KiB of uniform block (std140 guarantees 64 KiB) the planner inlines leaf literals again — vectors and colours first, then the latest-built node — and names them in the struct's first line and --dump-passes. Offline (volta-vis --headless, film export) inlines every literal; the picture is the same (every fixture's tick-60 frame equals live within the §12.2 gate). The shader hash, the pipeline-cache key, leaves out whole-line comments, so an edit that only moves spans hits the cache.
How it is built#
Module.visuals (ir/src/visual.rs) is a DAG of typed nodes, each carrying its element type and inferred domain, grouped by scheduling class: Point (fuses), Warp (one coordinate leaf Uv; every transform is Warp { input, coord }), Neighborhood, Global, Source, Feedback, Uniform, Resample. fuse.rs cuts fusion regions, schedule.rs orders passes, pools textures by live range, packs one std140 uniform block and admits the plan by cost.rs's estimate (FramePlan::cost), codegen/ emits one naga-validated WGSL shader per region from a typed expression IR (sampling is exact in-shader via textureLoad). A Scan node is a Source-class texture written by its own PassKind::Scan (the beam into an accumulation target, then one blend into the phosphor, before any reader); the executor allocates one texture per plan pool slot and hands matching storage to the next plan on a swap. eval.rs is the f64 definition of every op; cpu.rs rasterizes whole outputs with it. gpu/ executes a plan on wgpu 25 (pipeline cache by shader hash, async compile, feedback rings rotated together after the last pass, reductions by compute or a half-size render chain). Precision profiles: portable (RGBA8, the default), hdr (RGBA16F), exact.
Verification#
cargo test -p volta-visual --no-default-features— plan, codegen, CPU goldens (visual/tests/snapshots/cpu/*.txt, previews inpreview/, re-bless withUPDATE_SNAPSHOTS=1), pinned--dump-passes.cargo test -p volta-visual— adds the GPU suite: every fixture rendered on the adapter and compared with the CPU reference at ticks 0, 1, 2, 30, 60 (max ≤ 2/255 pointwise, ≤ 8/255 with feedback or neighborhood passes; mean ≤ 0.5/255). Measured 2026-09-24 on one Apple Metal adapter: the six fixtures then and the.voltaprograms undercompiler/tests/fixtures/visual/pass, typically max ≈ 1.1/255 (the output's 8-bit encode) and mean < 0.25/255.glass_percussionis fed a scripted note list (fixtures::glass_percussion_script) throughTickContext::instances, identically on both sides; its engine-driven.voltatwin measured max 1.7/255 under--compare-cpu.gpu::grow_normalize_matches_the_cpu_reference_at_full_resolution(slow, ≈ 11 s at 640×360 + 45 s at 1280×720 in the dev profile, required): every tick 0–121 ofgrow_normalizepasses the §12.2 gate at both sizes, worst max 1.57/255 (measured 2026-09-25, Apple Metal). Two things make it pass by construction. Half-float stores round explicitly in the shader (half_rne: nearest-even in f32 bits, so the adapter's own conversion never applies; Apple GPUs round toward zero, a bias a feedback loop compounds). Feedback rings and blur first axes also flush below 2⁻¹⁴, as the CPU does (eval::store_half). Andnormalizenever reads straight rgb at alpha ≈ 0 (below).cargo test -p volta-engine --test suite events_stream::— a 64-hit ratchet per 96 samples arrives whole with no sequence gap; a poll gap of twice the ring returns exactly the newest 4096 and the exact loss.--test rt_safetykeeps the callback allocation-free with the stream on.cargo test -p volta-visual --test suite scan::— the scan plan, the CPU beam (a bright figure on exact black, the persistence decay per tick, blanking, decimation that keeps brightness, per-vertex displacement, a noise-displaced raster) and, with a GPU, the §12.2 gate on the same sample arrays: measured 2026-09-24 on one Apple Metal adapter over ticks 0/1/2/30/60, lissajous max ≤ 2.4/255 (mean ≤ 0.06/255), the Rutt/Etra raster max ≤ 1.7/255 (mean ≤ 0.1/255).volta-vis --compare-cpuon the two.voltafixtures, through the real engine tap: max ≤ 6.3/255, mean ≤ 0.3/255.cargo test -p volta-engine --test suite scan_tap::and the tap's unit tests: 800 samples per tick of the authored sines, rasters restarting on the tick's first sample, a lapped reader dropping and never replaying.cargo test -p volta-visual --test suite plan::— the frame budget (frame_budget_admits_by_estimated_milliseconds,the_cost_model_counts_loops_not_nodes) and constant interning: every fixture, and withhostthe three showcases, built with and without interning renders the same CPU frame hashes.gpu::a_live_edit_over_the_frame_budget_is_rejected_and_namedchecks the live report.volta-vis --benchis the measurement (GPU timestamp queries where the adapter hasTIMESTAMP_QUERY; wall time per tick otherwise; each canvas stage's passes are timed and listed ascanvas NAME: …, and the admission line totals them); each pass is charged from the later of its begin stamp and the previous pass's end, since a tile GPU stamps a dependent pass's begin early.cargo test -p volta-compiler --test suite visual::— the spec programs compile, their dumps are pinned (compiler/tests/snapshots/visual/), the forms of §3.1 agree, the diagnostics fire;golden_iris unchanged for every audio-only song.visual/tests/export.rs— the film clock (every rate from 23.976 to 60 at 44.1/48 kHz: ten minutes of carried-remainder frame samples equal the closed form, each within one sample ofn / fps; 60 tps at 24 and 29.97 fps for ten minutes, block by block: every tick in order, each frame showing exactly the ticks due by its sample) and, on an adapter, a 2 stunnel.voltafilm at 24 fps 256×144 — 48 frames, 96 000 audio frames, 121 ticks, manifest round-trip, two exports byte-identical in frames and WAV. Host parity: every exported frame hash-equals an in-process silent pump render of its tick — glass percussion 48/48 (9 accepted events; the frame after each hit is lit), lissajous 48/48 (96 000 scan samples), arranged 243/243 over 10.1 s withhaloappearing at tick 480 (frames at ticks 480, 495, 600),title-film.volta24/24 over 1 s with itsvis.texttitle lit (media in the film, 2026-09-25). A three-track unison against a two-slot event ring fails naming the loss; a cancelled export leavescomplete: false; ffprobe counts exactly the exported frames in.movand.mp4, audio to the picture's end (skipped without ffmpeg); the tap keeps the 128-sample grid, is deterministic and leaves the audio sample-identical (flat and arranged). Tunnel and a meter score (vis.level/vis.peak/a band) are frame-identical to the silent render too.- Measured 2026-09-25:
audio.wavequalsvolta export stems --target-lufs off --bit-depth float'smaster.wavbyte for byte —tunnel.volta6 713 876 bytes,glass-percussion.volta7 016 204 bytes (in-processthe_film_audio_is_volta_exports_master, CLIexport_video_audio_is_the_stems_master). The audible window (volta-vis --frames N --out, 48 kHz device, 512-frame callbacks) saved frames hash-equal to--headless --ticks 30,60,120on both songs (tunnel12ece4b7…/84a0c87c…/2672f31d…), stable over repeated runs. - Measured 2026-09-25 (not watched): the audible window on
glass-percussion.voltaspawned each ring on the first tick whose audible sample reached the hit (e.g. start 23 105 → tick at 23 200 while the device frontier was at 24 064), and its tick-124 frame shows the rings; onarranged.voltait loggedtick 486: scene layers [light, halo](bar 4 plus the device's 100 ms start grace — since removed: the device pump now starts on sample 0 like the silent pump);lissajous.voltashowed the figure. - Measured 2026-09-24 (offline, not watched): in that film the ring's radius jumps within one frame of each kick onset in
audio.wav; ffprobe reads the muxed.mp4as 48 H.264 frames and 2.000 s of AAC. The audible window rantunnel.voltafor 6.7 s: 506 callbacks, the tick count within one ofticks_through(audible sample)at every log line. cargo test -p volta-visual --test suite live_edit::— the §12.4 harness on the adapter and the CPU reference (measured 2026-09-25, one Apple Metal adapter, 96×54): a renamed declaration resets (new, old released) and the state at tick 90 differs; under@identityit is kept and tick 90 equals an unedited run bit for bit, GPU and CPU; a resize reports96x54 Rgba8Unorm -> 64x36 Rgba8Unorm; a syntax error is surfaced and tick 90 equals the unedited run; 30/60/120 Hz presentation (120/240/480 presents) reach bit-identical state at tick 240 on both (with latest-value controls instead of the ring, 30 Hz and 60 Hz differ); a parameter-only edit is in place with 0 new pipelines; under the live policyvis.zoom 0.98 → 0.9is in place with 0 pipelines compiled, the feedback kept and the frame hash changed, and its undo is in place with 0 compiles (offline the same edit compiles and its undo hits the pipeline cache); every fixture's tick-60 frame under live (literal slots) equals offline within the gate widened to one 8-bit code step (max 2.27/255 on two fixtures, 0 on seven); a 10-tick-delayed cancelled candidate is discarded; a shared edit commits on the engine's boundary; seek/stop/pause per the table above; a script replays and checks 6 expectations. Without a GPU: a 16-stepsignalread four ticks behind the engine equals the block-rate truth on all 240 ticks with all 31 steps marked and nothing else, where a latest-value read is wrong on 48.cargo test -p volta-engine --test suite uniform_ring::proves the same on the bare engine plus the seek and swap markers.- Media (measured 2026-09-25, one Apple Metal adapter):
sources/spectrumunit tests (sRGB codes 0/128/255 → 0 / 0.21586 / 1, alpha linear; 16-bit and grey-alpha PNGs; content-hash cache and byte-identical re-decode; the placeholder and its status; text coverage centred, deterministic and re-rasterized on resize; frame-sequence stepping and looping; a bin-centred full-scale sine reads 1.0, −20 dBFS reads 0.75; log placement; the smoothing pole). The builder fixturesmedia_image_grid(every fit and every edge),media_textandmedia_spectrum(both sides fed the same synthetic frames) pass the §12.2 gate at max ≤ 1.13/255, mean ≤ 0.19/255; the CPU rounds source texels to binary16 as the GPU stores them.volta-vis --compare-cpuontitle-film.volta(ticks 0/60/240) max ≤ 1.95/255 andspectrum-bars.volta(real engine tap) max 1.13/255.cargo test -p volta-engine --test suite pcm_tap::: 800 contiguous samples per tick carrying the track's sine, track lane = master for a lone track, a lapped reader dropping exactly the lost positions;--test rt_safetykeeps the callback allocation-free with the tap attached. - Studio (measured 2026-09-25, native, one Apple Metal adapter for the shots):
cargo test -p volta-ui --test suite visuals_panel::drives the panel over a real engine attached like Studio's (kittest, no GPU) — a 16-stepsignalread through Studio's ring four ticks per frame equals an engine stopped at each boundary on all 284 ticks with all 38 steps marked and nothing else;arranged.voltalistshaloat gain 0 until tick 480, lit at 481 (1/30) and full at 510;small-instrument.volta(no tracks) sits at tick 0, runs 29 ticks in 30 frames after ▶ and stops on ■; the palette offers both commands only while the song draws.cargo test -p volta-visual --test suite live_edit::a_hosted_edit— a hosted shared edit commits on the tick the host's generation took over, a visual-only one on the next tick in place, restart counts shader ticks from its position, resume continues the held tick.volta-shot --views visualsat 1600×1000 after ▶ (read as stills, not watched):title-film.voltashows the title over the dusk plate with its feedback smear,spectrum-bars.voltathe lead's log spectrum as bars,lissajous.voltathe figure with its phosphor trail,rutt-etra.voltathe noise-displaced raster. A--driveedit oftunnel.voltareadvisuals: 2 pipelines compiled; kept tunnel/tunnelforvis.zoom 0.98 → 0.9before literals became uniforms (not re-driven in Studio since; the harness above shows it in place) andvisuals: in place, 0 pipelines compiled; kept tunnel/tunnelfor0.3 → 0.35in the ring's radius. Frame cost (VOLTA_SHOT_PERF=1, five runs, frames 60–300 medians): an audio-only song 0.479 → 0.473 ms UI, 0.335 → 0.388 ms submit;tunnel.voltain the panel 0.459 ms UI and 0.240 ms submit (the ticks moved from the paint callback into the UI pass: 0.336 + 0.382 before). - Browser (measured 2026-09-25,
make browser-test T=visuals, Chromium headless shell 149, both backends above):tunnel,glass-percussion,ramp-and-key,grow-normalize,lissajous,rutt-etra,spectrum-barsandtitle-filmload instudio.html(one page context each), play into a{type:"none"}sink, and the panel passes tick 120 with no tick error; the preview rect is lit (except a scan on WebGL2, which reportsscan beam/scan: source unavailable in this host). The live checks: each scan tick on WebGPU drew 800 points from the worklet's tap;spectrum-barsread PCM (history peak 0.19–0.28);title-filmdecoded its 960×540 plate fetched from the song's URL. The §12.2 gate runs each program on the page's GPU over the tick contexts the native host exported, fed the native host's scan points and spectrum frames (inputs.jsonofvolta-vis --export-reference; the page decodes the image and rasterizes the text itself), and compares pinned ticks 0/60/121 with the native CPU reference (max/mean per 255): tunnel ≤ 1.79/0.015, glass percussion ≤ 1.84/0.04, ramp-and-key 1.13/0.15, grow-normalize ≤ 1.14/0.001, spectrum-bars ≤ 1.14/0.14, title-film ≤ 1.92/0.06 on both backends, lissajous ≤ 3.40/0.07 and rutt-etra (its camera is the placeholder in a headless reference and in the browser) ≤ 7.85/0.32 on WebGPU. A variant oftitle-filmwhose plate does not exist keeps ticking, shows the placeholder and nameshttp://…/assets/no-such-plate.png (HTTP 404). The song switch in ONE page (openSong, both backends):tunnel→small-instrument(visual-only) →lissajous(visual-only, scan) →tunnel, then a reload ontosmall-instrumentwithtunnelin the restored tabs — every stage runs 121–134 ticks in ~2.0–2.3 s. The WebGL2 run removesnavigator.gpubefore page scripts (the headless shell's--disable-features=WebGPUdid not hide it). - Simulations (measured 2026-09-25, one Apple M1 Max Metal adapter):
cargo test -p volta-visual --test suite compute::—fluid_stir,rd_coralandparticles_curlagainst the CPU reference at 256×144 over ticks 0–240, both variants: fluid max ≤ 0.87/255 compute (≤ 3.5/255 portable at tick 240), mean ≤ 0.04/255; reaction–diffusion max ≤ 1.52/255, mean ≤ 0.18/255; particles max ≤ 5.6/255, mean ≤ 0.05/255 (f32 against the f64 fluid: the drift stays under 1/255 through tick 240, so the gate runs every fixture to 60 inevery_fixture_matches_the_cpu_referencetoo); two runs bit-identical on the CPU and hash-identical on the device; a seek resets,persistsurvives it bit for bit, a rebuilt plan keeps the state (kept), a renamed one resets; the portable fluid differs visibly from the projected one; 4,097 portable particles report unavailable. Stills throughvolta-vis --headlessat 640×360, ticks 30/120/240 of the threecompiler/tests/fixtures/visual/programs, looked at: the plume rises into a mushroom with two curling vortices, then spirals; the coral grows from seeded rings into a branching labyrinth; the sparks stream into filaments along the curl. Not watched in motion; not run in a browser. - Composition (2026-09-25): builder fixtures
two_canvas,layer_blends,comet(instances into a feedback tail and a blur),nested_moons(nested emitters, and instances into a resample),domain_warp,motion_blur(withecho) andradial_blur(withpolarTileandtrailTo), each with a CPU golden and the §12.2 gate;.voltatwins undercompiler/tests/fixtures/visual/with pinned dumps. Measured on one Apple Metal adapter at 256×144, ticks 0–60: max 1.0–2.8/255, mean ≤ 0.36/255 (domain_warp1.13 / 0.28,radial_blur1.51 / 0.36,motion_blur2.82 / 0.008,two_canvas1.29 / 0.14). The stagedringscanvas equals that canvas rendered on its own, bit for bit on the CPU and within one sRGB code on the GPU, before and after an edit to the screen (a_shared_canvas_steps_once_per_tick_in_its_own_state_world,canvas_stages_run_once_and_keep_their_state_across_a_swap); the Studio selector listsscreen,canvas rings,canvas blurredand previews a canvas (visuals_panel, kittest, no pixels). Tick-60 stills of each fixture were looked at; none has been watched in motion. - What none of this proves: that the pictures look right in motion, or that a listener sees the kick land with the sound. Frames were rendered, hashed, diffed and looked at as stills; nothing has been watched.
Not yet#
Simulations: a WebGL2 run of the portable variants (unit-tested only on Metal with compute: false), a warm-started pressure solve and boundary walls (edges clamp), two presented outputs sharing one simulation (each steps its own; put it in a vis.canvas to share it within one output), a vis.particleForce from a constant (use the emitter's vel), a simulation's own Rgba32Float renderability probe (the executor assumes it), SPH/cloth and user-authored kernels.
In Studio: monitor choice by name rather than by window position, and pausing a song with no tracks (the transport has nothing to hold). In the browser: the audience display (how a second browser window presents the texture is spec §15's open question), vis.video frame-sequence directories (a page cannot list one), re-fetching an edited image, a shared-memory fast path for the bridge (the taps ride plain messages: ~9.6 KiB per scan and ~3.2 KiB per spectrum bus each animation frame at 48 kHz), the picture delay and reset chips, an exact program on hardware, and a device-loss recovery; the browser host duplicates the native host's code (one host for both is a follow-up). Beyond Studio: the volta-vis window's reload loop going through LiveSession (Studio, --script and tests do); onsets and the beat on the ring; visual crossfades on a swap and last-good recovery from a device loss; a vis.events field value of its own (it is only vis.emit's subject); unpitched hits reporting no pitch (they carry the MIDI 60 default); a second level of nested vis.emit; canvases stepping at their own simRate (one steps per reader tick, so rates must match), a canvas reading another's PREVIOUS tick across a cycle (a cycle is refused), a canvas's pipelines compiled on the executor's async path (a stage adopts what the owner's cache absorbed and compiles the rest when swapped), a host source rasterized at a canvas's own size; a pass input read off its pixel centres by another region (a node that is both blurred and resampled is sampled from its materialized texture on the GPU and evaluated exactly on the CPU — comet keeps them apart); a film's layer shadow on a songGraph makes its own edge decisions (the export's timeline resolves the walk separately); a persist-style "keep a hidden layer's feedback advancing"; layer phrase origins; a bar fade in activation::scripted_gains (the §12.2 gate's helper counts seconds as bars); captured-take replay of a camera (a take records no camera frames, so a camera under export or headless is only ever the placeholder), camera device choice by name, the camera and container video in the browser, extracting a film at the output resolution rather than capped at 1280; the runtime half of frame budget (a GPU-timed tick over budget for 30 ticks; timing exists only in volta-vis --bench), cost constants for any adapter but the one Apple M1 Max they were fitted on (the browser and WebGL2 are not calibrated), a texel-footprint term for media sources beyond constant zoom/scale minification; a checked vis.spectrum film (the export runs the media feed and its PCM tap, but only vis.text/vis.image films were compared); vis.spectrumBands; hot-reloading an edited image file (a source is re-read only when the program changes); export of a live take, from the middle (pre-roll checkpoints) or with plugin instruments, wavetable/zone assets or captured input, and a Studio progress/cancel surface (the film is the score from a clean start); per-vertex oversampling of a scan path (a tick draws its own samples, 800 at 48 kHz, so a 240-line raster has ~3 vertices per line), so rutt-etra.volta's camera relief is 32 coarse lines; a WebGL2 scan path (its vertices come from a storage buffer in the vertex stage, which WebGL2 lacks — reported as unavailable); the portable-versus-hdr feedback default (measured: an RGBA8 store rounds a slow blur's sub-1/255 growth away, so §13.5 runs under hdr). The executor allocates one texture per plan pool slot and reuses matching storage across a swap; it does not yet share storage between two outputs. Vocabulary: vis.histogramEq (needs a histogram reduction), vis.paletteList (the Palettes table is the list), a derivative-correct footprint inside vis.pixelate, and a record field declared Float with a literal default (lines: Float = 14.0 is refused as Int at the call site; the library declares such fields Linear).