Oro is an original, clean-room wave terrain synthesizer. A closed path (orbit) is traced across a 2D height map (terrain) once per oscillator cycle; the height under the moving point is the audio sample. Pitch = how fast the path is traced. Timbre = the shape of the land the path crosses. The user places the dot (the orbit centre) anywhere on a 3D map.
This document is the contract between modules. Everything here is binding for implementers; anything not specified is the implementer's choice.
- Vite 8, plain ES modules (no framework, no TypeScript),
three@0.186,@dimforge/rapier3d-compat@0.21(lazy-loaded), Web Audio AudioWorklet, Web MIDI. - Output targets:
dist/(normal web build, relativebase: './', deployable under any sub-path such as/music/oro/),dist-single/index.html(one offline HTML file,npm run build:single), Electron desktop app (electron/,npm run dist). - Tests:
vitest(Node) intests/**/*.test.js; browser end-to-end checks intests/e2e/*.cjsdriven by the globally installed Playwright (/opt/node22/lib/node_modules/playwright, Chromium at/opt/pw-browsers). Launch Chromium with--autoplay-policy=no-user-gesture-requiredand for WebGL in headless use--use-angle=swiftshader --enable-unsafe-swiftshader --ignore-gpu-blocklist.
| Path | Owner | Contents |
|---|---|---|
src/core/params.js, src/core/store.js, src/dsp/catalog.js |
architect (frozen contract; extend only by appending) | parameter registry, store, terrain/path catalog |
src/dsp/ (except catalog) |
DSP | terrains.js, paths.js, terrain-math.js, dsp-core.js, worklet.js |
src/audio/ |
Audio host | engine.js, fx.js, terrain-manager.js, importers.js, recorder.js, wav.js |
src/visual/ |
Visuals | visuals.js + helpers (scene, terrain mesh, path line, dot, physics, sky, post) |
src/ui/, src/styles/, index.html |
UI | layout, knobs, panels, keyboard, sequencer grid, dialogs, theme |
src/music/, src/midi/, src/presets/ |
Music | router/arp, sequencer/transport, MIDI, factory patches & scenes, preset storage |
src/agent/, scripts/oro-score.mjs, scripts/oro-mcp.mjs |
Agent API (2.17) | window.oro, the postMessage bridge, the score command line and MCP server |
electron/, .github/, vite.config.js, public/, README.md |
Packaging | desktop app, CI, icons, PWA manifest |
src/main.js |
architect / integrator | bootstraps everything |
Never edit another owner's files; if you need something from another module, code against the API below and note the requirement in your final report.
- Terrain space is the unit torus:
u, v ∈ [0, 1), wrapping at the edges. Every terrain table tiles seamlessly. - World space (three.js): the terrain is a square of side
W = 10centred on the origin,x = (u - 0.5) * W,z = (v - 0.5) * W, heighty = h * HwithH = 1.6at Lift = 1. Top view:ugrows to the right,vgrows towards the viewer (down the screen). - Terrain value
h ∈ [-1, 1]; tables are normalised somax|h| = 1and the mean is 0.
pathPoint(shape, t, order, param, out) (in src/dsp/paths.js) writes a unit-scale point out.x, out.y ∈ [-1, 1] for t ∈ [0, 1); every path is closed (t = 0 and t = 1 coincide) and continuous. Then:
ax = 2^(stretch * 1.5), ay = 2^(-stretch * 1.5)
px = out.x * ax * size, py = out.y * ay * size
θ = radians(rotate + 360 * spinPhase) // spinPhase advances at `spin` Hz, part-global
u = centerX + px cosθ − py sinθ
v = centerY + px sinθ + py cosθ // wrap u, v mod 1 for lookups
w = warp * 0.06
u' = u + w * ( sin(2π·2v) + 0.5 sin(2π(3v + 2u)) )
v' = v + w * ( sin(2π·2u) + 0.5 sin(2π(3u − 2v)) )
h(u, v) = (1 − morph) · A(u', v') + morph · B(u', v'), bilinear lookups with wrap.
Lift and Fold are audio waveshaping after the lookup (DSP's choice of curve); visuals scale the displayed height by min(lift, 2.5) and may hint fold with colour.
See src/core/params.js. State shape (persisted):
{ version, global: {paramId: number}, parts: [ { id, name, color, patchName,
params: {paramId: number}, // plain units, enums are integer indices
mods: {paramId: {...MOD_DEFAULT, steps: [32 values]}},
seqOn: 0|1, activePattern: index,
patterns: [ {id, name, rate, length, baseOctave, lockGlide, steps: [{on, degree, octave, vel, gate, slide, accent, lock, lx, ly}]} ],
arp: {mode, rate, octaves, gate, hold, rhythm},
dot: {mode: 0 Pin | 1 Roll | 2 Drift, gravity, friction, driftSpeed},
userTerrain: {A: UserTerrain|null, B: UserTerrain|null},
trackFx: {routing, sidechain, slots: [four FX slots]}, noiseRecording: PCM16Recording|null } x 1..MAX_PARTS ] }
state.parts is a variable-length list of 1..MAX_PARTS (16) tracks, each with a stable
string id (t1, t2, ...; never reused within a list). It is the only source of truth for
which tracks exist and in which order. NUM_PARTS is gone; the rule for every per-track
array is:
- Allocation, sized
MAX_PARTS, mapped by index: the DSP's parts, the router's held notes and arps, the transport's step state, dot-lock glides, the terrain manager's slots, the engine's controllers and marble readings, the visuals' height fields and terrain cache, the dot simulation, MIDI channel maps (multiChannels/outChannels, one per track position, track i on channel i), telemetry, the tab elements in the top bar. - Active, the live list (
partCount(store)/parts.length): everything shown, played, scheduled or sent: tabs and mixer strips, sequencer and arp scheduling, Layer key mode, MIDI routing, bounce stems, terrain generation, the sync's params, snapshot and flushes.
When the list changes shape (add, remove, duplicate, move, scene load), src/core/tracks.js
trackPerm(oldIds, newIds) gives a permutation of all MAX_PARTS slots (perm[new] = old):
kept tracks keep their slot contents, new tracks get the slot that has been unused longest
(listed in fresh), removed tracks go to the back. Every module computes the same
permutation from the same ids through watchTracks(store, fn) and moves its per-slot state
with permute(). src/audio/sync.js posts {t:'tracks', count, perm, fresh} to the DSP at
once, ahead of anything else about the new list; the DSP moves its Part objects (voices,
envelopes, LFO phases and terrain tables move with them, so a reorder is sample-identical),
resets fresh parts, releases parts that left the list and fades them out over 80 ms, after
which they are dormant and skipped before any work. A scene load passes
{ replaceTracks: true } in the store meta, so all its tracks start in fresh slots while the
old ones fade. The store ignores writes below parts.N for N >= parts.length.
Patterns: a track plays patterns[activePattern] while seqOn is set; activeSeq(part)
gives that pattern with enabled = seqOn, patternPath(store, p) its store path. Older
saves are migrated: four parts become tracks t1..t4 and each seq becomes pattern p1.
Non-persisted ui branch: see DEFAULT_UI in src/core/store.js.
UserTerrain = { name, kind: 'image'|'wavetable'|'audio', w, h, mirror: 0|1, data: base64 Uint8Array (w*h, row-major, 0..255) }.
For kind: 'wavetable' each row is one single-cycle frame resampled to w samples (periodic in x); mirror reflects the non-periodic axis (or both axes for images) so the table tiles.
Modulation is evaluated in normalised space: n = clamp(toNorm(def, base) + lfo·lfoDepth + envelope·envDepth + four controller slots (+ modwheel on morph), 0, 1), then fromNorm(def, n). LFO output is bipolar −1..1 (S&H / Drift too). rotate wraps instead of clamping. centerX/centerY wrap instead of clamping. Mod wheel adds +wheel · 1.0 normalised to morph. Pitch bend: ±bendRange semitones.
Processor name: 'orograph'. Constructed with numberOfInputs: 0, numberOfOutputs: 4, outputChannelCount: [2, 2, 2, 2] (dry, delay send, reverb send and pedal send; per-part level/pan/sends/mute/solo are applied inside the worklet), processorOptions: { sampleRate, measureLoad: true } for real-time audio. Offline constructors omit load measurement.
Main → worklet (node.port.postMessage):
| message | meaning |
|---|---|
{t:'params', part, p:{id: value, ...}} |
plain values, any subset of PART_PARAMS |
{t:'mods', part, m:{paramId: {...MOD fields}}} |
any subset |
{t:'global', p:{tempo, ...}} |
any subset of GLOBAL_PARAMS the worklet needs (tempo for synced LFOs) |
{t:'terrain', part, slot: 0 (A) | 1 (B), levels: [{size, data: Float32Array}]} |
mip chain, level 0 = full res (512), each next level half size; buffers transferred |
{t:'noteOn', part, note, vel, time} |
time in AudioContext seconds; <= currentTime means now. note may be fractional |
{t:'noteOff', part, note, time} |
|
{t:'allOff', part?} |
release all (part omitted = every part); {t:'panic'} hard-silences |
{t:'bend', part, v} / {t:'wheel', part, v} |
−1..1 / 0..1 |
{t:'trackFx', part, fx} |
four-slot rack, routing and stable sidechain ID |
| `{t:'noiseRecording', part, data: Float32Array | null}` |
{t:'expression', part, v} / {t:'sustainLevel', part, v} / {t:'breath', part, v} |
normalized MIDI CC11/64/2 controller sources |
{t:'watch', part} |
which part to report telemetry for |
{t:'transport', playing, beatTime, beat} |
optional: anchors synced LFO phase to the sequencer |
Worklet → main, about 60 times per second:
{ t:'tele', part, n: {modParamId: normalisedValue, ...}, // modulated values of the watched part's most recent active voice (LFO-only when idle)
spinPhase, voices: [{id, note, amp}], peak: [L, R], activeVoices: [count per part] }
Two protocol extras: a single port message may be an array of messages (applied in order), and processorOptions.init may carry an array of messages applied in the constructor (needed for OfflineAudioContext renders, which start before port messages arrive).
dsp-core.js exports class OroDSP { constructor(sampleRate); handleMessage(msg); process(outL, outR, dlyL, dlyR, revL, revR, frames, currentTime); } plus a postMessage hook, so the same engine runs inside the AudioWorklet, inside a ScriptProcessorNode fallback, and in Node tests.
const engine = await createEngine({ store }); // builds context + graph, does NOT need a gesture
await engine.start(); // resume() on user gesture
engine.context; engine.analyser; engine.mode // mode: 'worklet' | 'script'
engine.noteOn(part, note, vel = 0.8, time = 0); engine.noteOff(part, note, time = 0)
engine.allNotesOff(part?); engine.panic(); engine.bend(part, v); engine.wheel(part, v)
engine.on('terrain', ({part, slot, size, data}) => {}) // slot 'A' | 'B', data = Float32Array level 0
engine.on('tele', tele => {})
engine.getTerrain(part, slot) -> {size, data} | null
engine.importTerrainFile(part, slot, File) -> Promise // image or .wav; sets userTerrain + terrain enum 'user'
engine.startRecording(); engine.stopRecording() -> Promise<Blob> // 24-bit stereo WAV
engine.level() -> 0..1 smoothed output level (for visuals)The engine subscribes to the store and forwards every change itself (params, mods, global, terrain regeneration with debounce, watched part = ui.selectedPart). Effects: stereo ping-pong delay (tempo-synced delayDiv, feedback, tone, return), algorithmic-IR convolution reverb (size, damp, return), master chorus, warmth (saturation), limiter, master volume, analyser.
- Laps (
laps, 1..8, modulatable): the path is tracedlapstimes per oscillator cycle and restarted at every cycle boundary (hard sync; fractional values give sync sweeps). The restart is band-limited with a polyBLEP correction. - Pace (
pace, −1..1, modulatable) with Curve (paceShape: Bend, Skew, Pinch): monotonic phase distortion of the cycle phase before Laps:t = frac(laps · g(φ)). HelperspaceWarp,paceSpeed,syncPhaseare exported fromsrc/dsp/for the visuals. - Sub (
sub, 0..1): one sine an octave below each voice, before the filter. - Dot locks: sequencer steps carry
lock,lx,ly;seq.lockGlidesets the glide time as a fraction of a step. The music transport glides the dot (wrap-aware) when a locked step plays, writingcenterX/centerYwith{source: 'lock'}; any other source cancels the glide.ui.lockRecordrecords dot moves into the sounding step while playing. - Harmonic bars: the UI shows the magnitudes of the first harmonics of the exact single cycle (terrain sampled along the current path), next to the cycle view.
const visuals = await createVisuals(containerEl, { store, engine });
visuals.resize(); visuals.setQuality('high'|'medium'|'low'); visuals.dispose();Renders the selected part's terrain (morph/warp/lift driven by telemetry), its path (live, modulated) and the dot. Pointer: click/tap the terrain to move the dot, drag the dot, orbit-drag the empty space to rotate the camera, wheel to zoom. Writes parts.N.params.centerX/centerY with {source:'visual'}. Roll mode: Rapier marble on a heightfield (fallback: built-in integrator), flick to throw, wraps at edges. Drift mode: smooth wander. Theme aware (see below). Must keep 60 fps on integrated GPUs at quality: 'medium'.
const music = createMusic({ store, engine });
music.router.noteOn(part, note, vel); music.router.noteOff(part, note); music.router.allNotesOff(part?)
music.transport.play(); stop(); toggle(); isPlaying(); onStep((part, stepIndex, time) => {})
music.randomizePattern(part); music.clearPattern(part); music.shiftPattern(part, ±1)
const presets = createPresets({ store }); // src/presets/presets.js
presets.patches(); presets.scenes(); presets.loadPatch(part, idOrObj); presets.savePatch(part, name)
presets.loadScene(idOrObj); presets.saveScene(name); presets.deleteUser(kind, name)
presets.exportJSON() -> Blob; presets.importJSON(File); presets.randomizePatch(part)
const midi = await createMidi({ store, router: music.router, engine });
midi.supported; midi.inputs(); midi.setInput(id | 'all'); midi.setChannelMode('omni' | 'multi'); midi.learn(paramPath)createUI(rootEl, { store, engine, visuals, music, presets, midi });document.documentElement.dataset.themeis always the resolved theme,'dark'or'light'.ui.themeholds the preference ('system' | 'dark' | 'light'), persisted inlocalStorage['orograph.theme'].- Whenever the resolved theme changes the UI dispatches
window.dispatchEvent(new CustomEvent('orograph:theme', { detail: { theme } })). - CSS tokens live on
:root[data-theme=dark]/:root[data-theme=light]insrc/styles/theme.css. Part colours are set as--parton the part's container. - Visuals read
dataset.themeat start and listen fororograph:themeto restyle sky, fog, terrain palette, bloom strength.
- No NaN/Inf/denormal output, hard limit at the master, no clicks on note on/off or parameter jumps (smooth everything).
- Band-limited: oversampled oscillator and mip-mapped terrain so high notes do not alias badly.
- 16 simultaneous voices with unison 2 must run in under 35% of one core.
- Every control reachable by mouse, touch and keyboard; visible focus; respects
prefers-reduced-motion.
All new parameters and state fields are defined in src/core/params.js / migrate.js / store.js. Summary:
| Area | Contract |
|---|---|
| Path | traverse (Natural / Even: Even = constant arc-length speed using cached arc-length tables), direction (Forward / Ping-pong: t goes 0→1→0 within each cycle, applied after Pace and Laps), noteSize (−1..1: size × 2^(noteSize · (note − 60) / 24), clamped to 0..0.5) |
| Voice | air (noise layer after the DC blocker, follows the amp envelope), airTone (−1 dark … +1 bright, a tilt filter on the noise) |
| Filter | filterType adds 5 = Comb (cutoff = comb frequency, resonance = feedback, formant = positive/negative comb blend) and 6 = Vowel (formant filter morphing A–E–I–O–U with formant, resonance sharpens, cutoff shifts the formants ±1 octave around 1 kHz). formant is modulatable. |
| LFO | shape 6 = Steps: 16 values in mods[id].steps (−1..1), held per step, 2 ms slew |
| Links | parts.N.links = [{src, dst, amt, curve}] (≤ 8). src indexes LINK_SOURCES, dst is any modulatable param id, amt −1..1, curve indexes LINK_CURVES. Contribution amt · curve(srcValue) is added in normalised space, per voice, after LFO/Env. The default link (Mod Wheel → Morph, +1) replaces the old hard-wired wheel→morph. |
| Macros | global.macro1..4 (0..1) are Link sources |
| Ceiling | global.ceiling (−6..0 dB) sets the master limiter ceiling |
| Quality | ui.audioQuality: eco (1× oversampling, mip bias +1), standard (2×, current), high (4×, two half-band stages), pristine (2× plus per-voice band-limited single-cycle tables refreshed every ~256 samples with crossfade when the orbit is not audio-rate modulated; falls back to standard per voice otherwise), raw (2×, mips off: deliberate aliasing). Device setting, persisted in orograph.settings. |
| Dot | dot.mode adds 3 = Explore, 4 = Tour. dot.gravity 0..1 → 0..2 g, bounce, tiltX/tiltY (lean the world), flick (throw strength), exploreRate/exploreRange/exploreNotes, waypoints [{x, y, beats}] (≤ 8), tourMode (Loop / Ping-pong / Once). |
| message | meaning |
|---|---|
{t:'links', part, links:[...]} |
replace the part's links |
{t:'pressure', part, v, note?} |
channel pressure (all voices) or per-note pressure when note given (poly AT / MPE) |
{t:'slide', part, v, note?} |
MPE slide (CC74) 0..1 |
{t:'marble', part, speed, height} |
from the visuals' physics, ~30 Hz (speed 0..1, height −1..1) |
{t:'quality', mode} |
'eco' | 'standard' | 'high' | 'pristine' | 'raw' |
{t:'params', part, p, time?, ramp?} |
when time is given the change is applied sample-accurately at that AudioContext time; ramp (s) glides to the new values (wrap-aware for rotate/centerX/centerY). Used by dot locks and offline bounces. |
Telemetry tele.n includes Links contributions. tele.terrainHeight = height under the watched part's modulated dot.
engine.pressure(part, v, note?); engine.slide(part, v, note?); engine.marble(part, speed, height)
engine.setQuality(mode) // also follows store ui.audioQuality
engine.bounce({ bars = 4, stems = false, fx = true, tailSeconds = 2, events }) -> Promise<{ mix: Blob, stems: Blob[] }>
// OfflineAudioContext render of `events` (from music.renderEvents) through the worklet + FX graph;
// stems renders each part solo (with its own sends through the FX). 24-bit WAV blobs. Progress via engine 'bounce' events {done, total}.
engine.importTerrainFile(part, slot, file, { channel = 'luma' | 'r' | 'g' | 'b', smooth = 0.3, tile = 'mirror' | 'wrap' })
// 16-bit grayscale PNG heightmaps (DEMs) are decoded at full precision (own PNG parser + DecompressionStream)music.preview(part = 'sel') // play a short tempo-synced phrase on the part (keyboard shortcut P)
music.exploreNote({ part, kind: 'peak'|'valley', height, x, y }) // Explore mode: in-key note from height, range dot.exploreRange octaves
music.renderEvents(bars, { parts }) -> [{ time, msg }] // sequencer/arp notes + dot-lock param ramps for offline bounce, times from 0
music.on('preview', fn)MIDI: channel pressure and poly aftertouch → engine.pressure; MPE mode (setting mpe: true, lower zone, member channels 2–16 → selected part or Layer): per-note pitch bend (±48 st), CC74 slide → engine.slide, pressure → engine.pressure. Macros and any global param are MIDI-learnable.
visuals.on('extremum', fn({ part, kind, height, x, y })) // Explore mode: the marble passed a local peak or valley
visuals.setRenderStyle('relief'|'wire'|'contour'|'heat'|'points'|'normals'); visuals.setPalette(i); visuals.palettes() -> [{ name, dark, light }]Visuals send engine.marble(part, speed, height) from the physics, draw dot-lock markers (numbered, flashing on 'step' events with a lock), Tour waypoints (editable when ui.editWaypoints: click adds, drag moves, right-click deletes), the base orbit (thin) and modulated orbit (bright), per-voice orbits when voices differ, and a comet trail whose density follows paceSpeed. Dot gestures: Shift-drag = Size, Alt-drag = Rotate, wheel over the dot = Size, [ / ] = Size when the map has focus.
| Module | Contract |
|---|---|
src/audio/looper-core.js |
LooperCore(sampleRate, { emit }): pure looper logic (handle(msg, frame), process(inL, inR, outL, outR, n, frame0)), shared by the worklet and Node tests. States empty / armed / record / play / overdub / paused. Float32 buffers at the context rate; seam, overdub, start/stop and undo fades of FADE_SECONDS (8 ms); softLimit on stored samples; undo snapshots per overdub layer (MAX_LAYERS 8, UNDO_BUDGET_BYTES). |
src/audio/looper-worklet.js |
Processor 'orograph-looper': 1 stereo input, 1 stereo output. |
src/audio/looper.js |
createLooper(ctx, { input, output, worklet }); engine.looper (see below). |
src/audio/resample.js |
resampleToWavetable(L, R, sampleRate, { slice: 'auto'|'tempo'|'root', tempo, rootNote, name }) -> { ok, userTerrain, mode, detail }; nextResampleName(state). |
src/ui/looper-control.js, src/ui/looper-panel.js |
The shared UI control (ctx.looper), the LOOP dock tab and the top-bar loop button. Device settings in localStorage['orograph.looper']. |
Graph: fx.masterTap (master volume, after every effect) -> looper -> fx.masterReturn
(the 1/ceiling stage before the limiter). The return is downstream of the tap, so the loop
never reaches its own input; the recorder (post-limiter) records loop and live together.
Offline bounces do not include the looper.
Worklet messages: {t:'main'|'stop'|'undo'|'clear'}, {t:'bars', v}, {t:'volume', v},
{t:'mute', v}, {t:'feedback', v}, {t:'transport', playing, beatTime, beat, spb} (forwarded
by engine.setTransport; bars are 4 beats and beat 0 is a bar line), {t:'get', id} ->
{t:'loop', id, L, R, len}, {t:'capture', id, bars | frames} -> {t:'captured', id, L, R}.
Out: {t:'state', ...} on changes, {t:'pos', ...} about 30 times a second.
engine.looper.available; engine.looper.reason
engine.looper.main(); stop(); undo(); clear(); setBars(n); setVolume(v); setMute(on); setFeedback(v)
engine.looper.getLoop() -> Promise<{ L, R, len, sampleRate } | null>
engine.looper.capture({ bars }) -> Promise<{ L, R, len, sampleRate } | null>
engine.looper.exportWav({ format: 'pcm24' | 'float32' }) -> Promise<Blob | null> // 24-bit uses TPDF dither
engine.looper.status(); engine.looper.on('change' | 'pos' | 'info' | 'error', fn)MIDI learn accepts action targets { scope: 'action', id } with ids from LEARNABLE_ACTIONS
(looper.main, looper.stop, looper.undo, looper.clear, looper.mute, looper.resample);
a mapped CC rising past 64 emits midi.on('action', { id }). Saved session state is unchanged
(resampled terrains are ordinary userTerrain wavetables with lo planes).
guitarMode: 'single' | 'chords' is a per-computer pedal rig preference. Single is
its default and remains mandatory for Voice plays notes. createGuitarAnalysis
shares detector selection, resets and releases. Single-note analysis stays in the
AudioWorklet; chord sample batches go to a dedicated Worker. The ScriptProcessor
fallback uses that same Worker when available. src/pedals/chords.js is a pure generated-signal-tested
spectral detector; no physical guitar accuracy has been established.
Chord pitch telemetry is {mode:'chords', notes:[MIDI], heard:[MIDI], voiced,time};
Single keeps {mode:'single',freq,midi,clarity,voiced,time}. Chords emits individual
noteOn/noteOff events and no channel-wide bends. The driver keeps each held note's
source route, follows track reorders and removes entries on global or per-track
Panic. Mode changes release held notes through the router, respecting sustain.
Capture remains one held note, regardless of tracking mode.
The append-only parameter registry is now 88 part parameters with 40 modulation targets. Mod records retain flat numeric fields, 32 steps, six-stage envelope settings and four controller source/depth/curve groups. Migration doubles old 16-step cells. Five appended filter types and oscillator helpers preserve the legacy fast paths at default amounts.
src/dsp/oscillator-extras.js, analog-filters.js and modulation-extras.js contain the
new pure DSP. track-fx-config.js owns the persistent four-slot rack schema, normalized
controls and catalogue; track-effects.js processes each part after decimation. Sidechain
sources resolve stable track IDs through store sync. Mix/named-track detectors read
preceding-block raw levels; self detection uses the current rack input.
user-terrain.js validates image/audio/wavetable sources and optional four-channel planes.
terrain-library.js generates the original image catalogue and PNGs. Worker cache keys
include channel blend and mapping. The visual normal shader is render-style index 5.
noise-recording.js packs imported mono loops as PCM16 base64 and resamples for realtime
and offline render rates. durable-storage.js keeps legacy small localStorage documents
and falls back to IndexedDB for larger JSON. Boot awaits session/preset readiness. JSON
exports carry all source planes, rack state and recordings. Favourite references are kept
in the library, with names remapped on portable import. Camera captures belong to device
preferences and are validated by src/visual/camera-view.js.
electron/updates.cjs owns the update state machine with injected network, updater and
timer dependencies. All automatic preferences default to false. electron/updates-host.cjs
provides atomic preference storage and main-frame IPC validation. The sandboxed preload
exposes only window.orographDesktop.updates; the renderer receives no Node or generic IPC
access. src/ui/updates-tab.js awaits ctx.prepareUpdate() before requesting an explicit
restart. That callback flushes and awaits both session and preset storage.
NSIS installed copies and AppImage copies use electron-updater. Other current packages
use the fixed public GitHub release endpoint and manual download link. macOS lacks the
signing identity Squirrel.Mac requires, so by default it is a manual path. From 2.11 the
opt-in autoInstall preference uses electron/mac-update.cjs instead: it downloads the
release zip for process.arch, verifies its sha512 against latest-mac.yml, unpacks it
with ditto, checks CFBundleShortVersionString, and on quit or Restart now starts a
detached shell script that swaps the bundle after the app exits (restoring a backup on
failure, logging to mac-update.log in userData). It refuses translocated, read-only or
unwritable locations. The swap has not yet been exercised on every macOS version.
electron/close-guard.cjs adds the bounce reminder to the window close flow; the preload
exposes orographDesktop.session for it.
.github/workflows/desktop.yml validates and publishes the three platform update manifests
and relevant differential blockmaps beside the existing downloads. A new release is
assembled as a draft before its binary and metadata assets become visible to clients.
src/visual/minimap-sampling.js caches four warped source grids with half-pixel centres.
HeightField.tableVersion changes only when a source is installed, including reinstall
of a modified array. Reference/size/version changes and warp invalidate the grids. Morph
and A/B fade weights are blended each frame; shading, palette, tint and lift stay in
hud.js. The shared sRGB lookup differs by at most one output byte. The cache holds
six fixed 112 by 112 Float64 grids and one 4097-entry lookup, without changing frame rate
or the 512 terrain/audio source resolution.
- Smart controls (
src/core/smart.js, UIsrc/ui/smart-panel.js):parts.N.smart = { knobs: [8 x { name, value, maps: [<= 4 x { id, min, max, curve }] }] }, absent unless a knob has a target or a name (sanitizeSmartreturns null otherwise).min/maxare normalised positions of a modulatable part parameter (min > max inverts). Smart knobs write the target params through the store (applySmartKnob, meta{ source, smart: true }), so sync sends ordinaryparamsmessages and there is no new worklet message. Patches carrysmartwhen present;partWithPatchreplaces the track's smart controls with the patch's. MIDI learn target{ scope: 'smart', part, id: 'smart1'..'smart8' }. - Time stretch (
src/dsp/time-stretch.js): offline WSOLA,timeStretch(channels, ratio, { sampleRate, loop, length }),stretchToLength(L, R, length, opts). Used by the looper's Follow tempo / Fit to tempo (src/ui/looper-control.js) and the noise recording Stretch menu. - Looper protocol:
{t:'replace', id, L, R, base, spb, bars}swaps in new loop audio (only while playing or stopped, and only ifbaseequals the core'seditcounter), answering{t:'replaced', id, ok, edit}.stateandloopreplies now includeedit(changes of the loop audio) andloopSpb(beat length the loop fits, 0 for free length).engine.looper.replaceLoop({ L, R, base, spb, bars })wraps it.
- Send effects (
src/dsp/send-fx.js): part parameterssendA/sendB(post-fader, default 0) and the globalssendA*(reverb: Size, Decay, Damping, Pre-delay, Return) andsendB*(delay: Sync, Time as aDELAY_DIVSindex or ms, Feedback, Tone, Ping-pong, Return).OroDSPsums the sends of every part into two bus inputs and runsSendReturns.processonce per render call, adding the returns to the dry output (so they pass the master chain and appear in bounces). The buses reuse the track rack'sEffectSlotreverb and delay. A bus is built on the first send and sleeps afterSEND_IDLE_SECONDSwithout input and with silent output; while every send is 0 nothing of it runs and the output is bit-identical. - Freeze (
src/audio/freeze.js):{t:'capture', part}makes a DSP render only that part's output after its track effects and before its fader;renderFrozenLoopuses it on the main thread (in slices) fromengine.renderFreeze.{t:'freeze', part, L, R, frames, beats}makes the part play that loop at(transport beat × frames/beats) mod frames, gated bytransport.playing, instead of its voices, rack and kit (note-ons for it are ignored);{t:'freeze', part, L: null}goes back with a 15 ms crossfade. The engine keeps the messages per track slot (permuted with the track list) and replays them into a rebuilt DSP; bounces leave them out.createFreezeController(inctx.freeze) keeps the state per slot and unfreezes a track whosefreezeSignaturechanges. Not persisted. - Chord trigger (
src/music/chord-trigger.js):parts.N.chord = {on, preset, inKey, notes}(absent = off, sanitized bysanitizeChord). The router expands key input before the arpeggiator's pool and sequencer notes (_engineOn/_engineOffwith source'seq'), remembering per key or per sequenced note which chord notes it started.router.rawHeld(part)gives the physically held keys for Learn.
- Session:
operatorat the root of the state (absent while every setting is at its default), cleaned bysanitizeOperatorinsrc/dsp/damage.js(called bymigrateState). The store sync sends{t:'operator', cfg}only once a session changes it, and{t:'operator', cfg: null}when a loaded session has none. - DSP (
MasterOperatorinsrc/dsp/damage.js): created byOroDSPon the first operator message and run on the dry mix after the internal send returns, before the output guard (not while a freeze is captured). The delay, reverb and pedal sends get the same cutouts.process()returns at once while nothing is on and every effect has settled, so the output is bit-identical without it. Actions arrive as{t:'opAction', a: 'drop' | 'spill' | 'repair' | 'tone', v}; telemetry carriesop: {dmg, wet, shock, cents, dir, tone}. The engine keeps the latest reading and replays it as{t:'opState', dmg, wet, dir}into a rebuilt DSP and into bounces (test tones are never bounced). Randomness is a seeded xorshift, so renders repeat exactly. - UI (
src/ui/operator.js):startOperatorHost(inctx.operator) runs Real drops (devicemotion), the static on-screen hint and Bookkeeping (src/core/bookkeeping.js, localStorage only);createOperatorSettingsis Settings > Operator. The MIDI module emitsmonitorevents ({bytes, port, time}) only while someone listens.
Lazy-loading boundaries. src/ui/lazy.js lists every chunk that loads on
first use (chunks): Settings (with its MIDI, Pedals, Voice, Updates and Tuning
tabs and the MPC guide), Help, Golf, the image library, Real
places / night sky, Data, Formula terrain, Imprint and the drum Sound map.
Call sites use chunks.x.run(m => m.open...(...)); Settings and Help go
through deferredDialog, which returns a modal-like handle at once (open
until closed, remembers select(tab)) and opens the real dialog when the
chunk arrives. prefetchWhenIdle warms all chunks a few seconds after
start-up, so later clicks open synchronously. Rapier (physics for Roll) was
already lazy: loadRapier() in src/visual/physics.js imports it when a
track first rolls, with the built-in marble running until it is ready. The
DSP's dsp-core chunk loads only for the ScriptProcessor fallback, offline
bounce and freeze. postcard.js reads VERSION from the Settings chunk with
a dynamic import inside its async functions, so it does not pin Settings in
the main chunk. The single-file build inlines every chunk.
Start-up timing. src/main.js sets performance.mark('oro:boot') when
the main chunk has run and 'oro:ui' when the interface is built.
Shaders. Where KHR_parallel_shader_compile exists, createVisuals
compiles every scene shader in the background (renderer.compileAsync)
and frames skip the GPU work until it finishes (dots, physics and sound keep
running); without the extension nothing changes.
Render budget. src/visual/resolution.js. Settings > General > Map
resolution: Auto (default) caps the 3D canvas's drawing buffer at about
4.5 megapixels (MAX_PIXELS) and the browser scales it up with CSS; a
dynamic scale (0.5 to 1 of that ratio) steps down after slow frames and back
up after steady headroom, judged against the display frame or the frame-rate
cap. Full draws device pixels up to the quality preset's pixel-ratio cap, as
before. The pref is ui.renderScale ('auto' | 'full'). Only the 3D canvas is
scaled; the interface, minimap and 2D panels stay at native resolution.
- Score desk.
src/music/orchestra.js(57 pitched voices, the 8-pad kit and 29 drum pieces, each a factory patch plus offsets),score-styles.js(styles and 20 grooves),score.js(parse, check, compose; receipts witherrors[].fix),desk.js(plays a score on borrowed or added tracks and restores them; emitsscoreeventsstart,cue,endthroughmusic.on('score'); restore/autosave revert only untouched score overrides, preserving live edits on existing tracks and tempo),score-render.js(offline render withOroDSPand a JS master chain: convolution reverb, delay, chorus, warmth, BS.1770 loudness, look-ahead limiter; 5 minute cap) andscore-export.js(MIDI,#score=links). Autosave skips the borrowed tracks throughautosave.addFilter. - Agent API.
src/agent/api.js:createAgentApi(...)iswindow.oro;installBridge(win, api, { allowMutating })answers{ source: 'oro-agent', id, type, args }with{ source: 'oro', id, ok, result }. Calls inMUTATINGneed?agent=1(agentOptIn). Agent edits use store meta source'agent', so they are undoable. - Touch. Worklet message
{ t: 'touch', v: [x, y, height, down], part, fx }(engine.touchMap).TouchBank(src/dsp/touch-sources.js) smooths it into Link sources Touch X, Touch Y, Touch Height and Touch Down (appended toLINK_SOURCES) and, withfx, the rig on that track (cutoff, resonance, drive, fold, delay and reverb sends).src/music/touch.jsturns map touches into strums or the FX rig. - Contracts appended:
LINK_CURVESgains S-curve, Steps, Invert, Rectify and Half;PART_PARAMSgainspitchEnv(semitones times envelope 2);FX_TYPES32 to 40 are the nine new track effects;parts.N.dot.cruise(Roll: Keep rolling, kept only when above 0). - 32-bit float WAV.
wavBlobFromPieces({ format: 'float32' })writes format 3; Bounce, Record and the score render take aformat.
- Visualizers (
src/ui/visualizer.js).ui.visualizer(a device pref) picks what the viewport shows:mapor one of five 2D visualizers on a canvas layer (.viz-layer) above the 3D canvas and below.vp-overlay. The layer tapsengine.analyser(after the limiter, before the listening mode) with analysers of its own that feed nothing onward, so it cannot change the sound or a recording.visuals.setCovered(true)stops the 3D render loop and keeps the physics timer alive (rolling and drifting dots still writecenterX/centerY)..viewport[data-viz]hides the map-only controls. - Worker renders (
src/music/score-render-host.js). Page renders post the score toscore-render-worker.js(bundled with thevirtual:worklet:plugin like the terrain worker) and get the stereo buffers back as transferables; without a worker the samerenderScoreruns on the page.