git clone https://git.kompot.si/rob/oscilla.git
cd oscilla; npm install; npm start
Open in browser: http://localhost:8001
oscilla/ # cp template dir
├─ public/ # ⟶ rename OR
│ ├─ scores/ # scroll:40000×1024
│ │ └─ myProject/ # page:1366×1024px
│ │ ├─ score.svg ⟶ required
│ │ ├─ audio/ video/ ⟶ optional
│ │ ├─ pages/ text/ ⟶ optional
│ │ └─ preferences.json ⟶ generated
projectTitle, projectAuthor, projectDescription, darkMode, defaultPlaybackSpeed, defaultViewMode, defaultPage, playzoneColor, playheadColor, playheadBorder, playheadWidth, audioSync, oscOutput, overlayMode, loopPlayback
1. Author in Inkscape - shapes,paths etc.
- Save: ~/oscilla-projects/myProject/score.svg
2. Add Behaviour
- XML Editor (Ctrl+Shift+X): data-oscilla = pause(dur:12)
- or Object Properties: type it into the id [e.g. pause(dur:12)]
3. Open Project http://localhost:8001 or
- http://localhost:8001/?project=myProject
4. Edit → Save → Refresh
Both work. data-oscilla is where a cue belongs — an id with parens and
spaces is not valid SVG, and it is the one attribute that must be unique. The
id form exists because Object Properties is quicker than the XML editor; when
Oscilla finds it in your project it offers a one-click Normalize that moves
the DSL to data-oscilla and leaves the uid: as the id. Behaviour is
identical either way, and the bundled demos all ship normalized.
Every time in the DSL -- dur:, tdelay:, fades, holds, glide:, an
envelope's a: d: r:, a delay's or a reverb's time:, pin:, a page's or a
station's stay -- is in beats of tempo(),
in the live console and in a score alike. The tempo is 60 bpm unless set, so a
beat is a second and dur:4 is four seconds; after tempo(120) every time is
half as long. Left in seconds: places in a recording (in: out: seg:), the
score's own clock (seek, the score's length, a stopwatch's countdown), and
rates (Hz, bpm, speed:). A tempo() change reaches the next run of a cue,
not one already going.
tdelay:N — wait N beats, then act. Works on every triggerable cue:
fade(mode:in, dur:2, tdelay:3), text(pick:"now", tdelay:1),
pause(dur:4, tdelay:2), audio(src:bell.flac, tdelay:0.5). On trans,
rotate, scale, color and o2p it delays the motion only — the element
is still armed and primed immediately, so an armed object can be clicked
before it moves.
stop(uid:s1) • stop(next:nav(End))
hush() • hush(uid:pad) • hush(uid:[pad, rain], rel:3) // sound, not the transport
style(agent, "font-size:32px; color:black; text-shadow:none", zoom:12, fade:1500) • text(…, style:agent) • style:"agent; font-size:44px" // a text look, named once
solo(sine, 4, quant:4, boost:2) • solo(off) • synth(uid:sine, …, trig:wait) // everything cut but one, as an event; a stopped one is started for it -- trig:wait: run with its block, it waits for its solo (or bang)
group(pads, [p1, p2, p3]) • synth(uid:pads, freq:*2) • level(pads, *0.5) • pan:+=0.2 / pan:-=0.2 // several at once; relative to now
freq:Pwhite(40, 100) | scale(minor, root:A1) • | notes([E, G, B]) • | edo(19) • | harm(27.5) • | sieve(3@0 + 4@1) • | scale(minor, even) // random pitches through a pitch world
fx(room, reverb:{mix:0.5, time:4}) • fx(room, [a1, a2]) • synth(..., bus:room) • level(room, 0, 8) • fx(room, off) // one room for several sounds
say("bro what") • say(v1, "no shot", reverb:0.4, rate:1.3) • text(pick:[...], say:1) // speech (Piper, local)
shape(sides:3, size:0.15) rotate(dur:4, loop:0) // 0 circle · 1 line · 2 lens · 3+ polygon
shape(m:5, n1:0.3, n2:0.3, n3:0.3) // a superformula: star · m:6, n1:1, n2:7, n3:8 a flower · each can follow()
shape(uid:sh1, sides:4, morph:2) // glide into the new outline over 2 beats
shape(n:12, sides:irand(3, 8), at:[rand(0.1, 0.9), rand(0.2, 0.8)]) rotate(dur:rand(2, 9)) // a cluster
synth(uid:s2, pan:follow(sh1.sin, -1, 1)) // rotate publishes sh1.angle / norm / sin / cos
gui(s3, size:1) • gui(s3, size:[0.5, 0.3], at:[0.75, 0.2]) • gui(s3, stroke:#f06, width:3, fill:#000) • gui(sh1, at:[0.7, 0.4]) • gui(s3, blur:6) • gui(s3, kaleido:6, hue:120, warp:20) • gui(s3, fx:off)
gui(s3, view:polar | phase | xy | spectrum | ridge | waterfall | wave) • gui(s3, view:ridge, rows:40, span:4) • gui(s3, view:xy, y:s4) • gui(s3, palette:live | grey | primary | pastel | neon) • gui(s3, trail:2)
pause(dur:12, count:true) // with countdown
pause(dur:4, next:nav(page3)) // chaining
osc(addr:star, trig:playhead, onscreen:1) // fire only if visible (also: Preferences → UI)
speed(value:0.5, uid:s2) // half speed
speed(add:0.1, uid:s3) // a bit faster
speed(value:1.3, dur:2, uid:s4) //ramp
speed(value:1.4, dur:6, ease:linear)
nav(page3) • nav(scroll@A) // AKA Navigate
nav(scrollPaused@B) // jump and wait
nav(Coda) // jump to object id Coda
nav(scroll@G, repeats:3, uid:g1) // repeat
page(page1) // page*.svg in pages/ dir
page(Pseq([page1:2,page2:2],3))
page(Prand([pageA,pageB,pageC],4))
page(Pchoose([pageA,pageB]))
Map mode — a route of stations and legs (guide)
station(name:reef, stay:75, edge:buoy, align:center)
station(name:harbour, until:click) // continue ▸
station(name:drift, until:"buoy.end", stay:240)
station(…, run:story, state:[outline])
zoom(…, trig:"story.line==5", tdelay:1) // on a text's line
leg(dur:8, lead:2) ui(uid:x, visible:true, trig:manual)
stopwatch(source:new, trig:auto)
stopwatch(source:main, scroll:true)
stopwatch(source:new, hold:6, offsetX:-40)
metro(bpm:90, visual:hex, trig:playhead)
metro(bpm:120,position:scrolling,
target:beat1,uid:scrollA)
metro(bpm:110,beats:3,colour:#3f9,
audio:1,osc:1,uid:green,trig:auto)
synth(freq:m67, dur:0.3, trig:readPH) // fired by the object with uid readPH
rotate(uid:r, dur:1, trig:hand, once:1) // once ever
audio(src:hit.wav, if:"fader1.t>0.7")
scale(dur:2, if:"slider.y>=0.5")
nav(scroll@B, if:"knob.t>0.8")
rotate(dur:3, if:adc.onset) // truthy
Operators: > < >= <= == !=
Reusable fragments
use(name) a reuse() block from this score
use(path:gestures) an SVG file from blocks/gestures/
use(path:gestures, index:2) the third file
use(path:gestures, index:rand(0,3)) a different one each load
use(path:gestures, index:Pseq(0,1,2,inf)) stepped across placeholders
use() INLINES the file, so DSL inside it is live. image(path:…) renders a
picture and cannot see inside. Trailing bare number = repeat count: use inf.
Arithmetic on signals
sx: mul(follow(adc.amp, 0, 2), follow(lfo.norm, 0.5, 1)) // product
sx: add(follow(a.t, 0, 1), 0.25) // sum, number term
sx: mul(add(follow(a.t), follow(b.t)), follow(c.t)) // they nest
Each term carries its own range. Result clamped to the target's range. A term that has not published yet is skipped, not counted as zero.
Random & threshold triggers
random(uid:dice, min:1, max:8, int:1, trig:"adc.onset>0.5")
image(path:plates, index: follow(dice.norm)) // an attack deals a plate
nav(scroll@B, trig:"adc.amp>0.8") // trig: CAUSES, if: only gates
// fires on the rising edge; once:1 = first time only
Outlets: dice.value (the range you asked for) · dice.norm (0-1)
Audio Analysis — start it with adc(), or place a mic from the + button:
adc() adc(uid:room) adc(off:1)
adc(ch:l, uid:left) adc(ch:r, uid:right) // stereo = two cues, two uids
adc.amp adc.peak adc.centroid adc.onset
adc.low adc.mid adc.high
adc.pitch (Hz) adc.note (MIDI) adc.pitchConf
scale(values:[1,1.5,1], dur:2)
scale(min:1, max:1.3, dur:2, loop:0)
scale(values:[1,2,1], dur:2, tdelay:3)
scaleXY([1,1.3],[1,0.6], dur:1)
scale(Pseq([1,1.4,1],inf), dur:Prand([0.5,1],inf))
scale([1,1.5,1],dur:12, mode:loop, osc:1,
oscaddr:"scale/spat/4chPan", hold:0, uid:aC2l)
rotate(dir:1, dur:1)
rotate(values:[0,120,240], dur:2)
rotate(values:Pseq([0,45,10],inf), dur:Pseq([1,0.2,2],inf))
rotate(values:[0,120,240], tdelay:2)
rotate(values:Pshuf([0,180],inf), dur:1, mode:alt)
o2p — Object-to-Path Animation
o2p(path:orbitA, dur:8, tdelay:3, prestate:hide)
o2p(path:spiral, rotate:aligned, rotoffset:-90)
o2p(path:ring, start:0.2, end:0.9, mode:alt)
o2p(path:c1, rotate:spin,rotspeed:2,rotdir:-1)
o2p(path:orbitA, dur:8, loop:3)
color(uid:shape1, vals:[#f00,#0f0], dur:2)
color(uid:bars*, vals:[#f80,#08f], mode:alt, dur:1.2)
color(uid:rect, vals:Pseq([#f00,#ff0,#0ff],3), dur:3)
color(uid:bgStripe, vals:[#000,#444,#888,#ccc], dur:6)
fade() any SVG element (self||target)
fade(mode:out, dur:2, from:1, to:0)
fade(mode:in, dur:1, target:title)
fade(mode:pulse, dur:6, from:0.2, to:1)
ui() show/hide UI & SVG elements
ui("#playhead", opacity:0, dur:0.3)
ui("#playzone", visible:false, dur:0.25)
ui(uid:rot_triangle, visible:false) // SVG anim
ui(uid:rot_pentagon, action:toggle) // toggle
ui(uid:tri, action:toggle, visible:false, click:1) // start hidden
ui() faders, seeks, who may press
ui(uid:spd1, value:0.5, dur:3) // move a touch fader
ui(uid:spd1, step:0.05, click:1) // faster (bipolar: from centre)
ui(uid:spd1, value:flip) // reverse · hold | rest | go
ui(uid:[spd1,spd2], value:[0.6,0.7]) // lists pair up
ui(uid:L1, phase:0.14, next:"ui(uid:spd1, value:0.62)") // seek, then
ui(uid:spd1, step:0.05, click:1, allow:[director], lock:"lock.t>0.5")
text() — pick | cycle | shuffle | scroll | ticker
text(pick:["A","B","C"], dur:2) // random one
text(cycle:"poem.txt", dur:3) // sequential
text(shuffle:"words.txt", dur:1, loop:0) // random order
text(scroll:"speech.txt", speed:60) // teleprompter
text(ticker:"Breaking news", speed:100) // news crawl
text(cycle:"hello world", unit:word, dur:0.5)
text(shuffle:"HELLO", unit:char, dur:0.2, loop:5)
text(pick:"poem.txt", unit:stanza, dur:5)
text(pick:["A","B"], trig:click, target:self, hold:true)
text(shuffle:"a;b;c", dur:0.5, loop:0, trig:toggle)
text(cycle:"x;y;z", dur:0.8, loop:0, trig:extent)
audio(...) — Play a Single File
audio(src:noise.wav,
loop:2, amp:1, fade:1.5, uid:p8)
audio(src:hit, pan:rand(-1,1), uid:a3,
pitch:rand(0.8,1.3), fadeOut:0.3)
audioPool(...) — 1-Shot Select from Dir
audioPool(uid:p1, path:sfx)
audioPool(uid:p2, path:foley, mode:rand)
audioPool(uid:p3, path:perc, amp:rand(0.4,0.9),
pan:rand(-0.7,0.7),pitch:rand(0.5,2),fade:rand(10%,40%))
audioImpulse(...) — Stochastic Process
audioImpulse(uid:i1, path:perc, rate:30)
audioImpulse(uid:i2, path:clicks, rate:20)
audioImpulse(uid:i3, path:metal, rate:12,
pan:rand(-1,1), pitch:rand(0.7,1.4))
audioImpulse(uid:i4, path:textures, rate:6,
jitter:0.8, amp:rand(0.2,0.6))
video(...) — In-Score Video Playback
video(file:intro.mp4, size:fs, clickable:1)
video(file:lum.webm,size:500,in:4,out:12,
opacity:0.5, loop:0, speed:3.2,
target:abc123, location:scroll)
synth() — Web Audio Synth, FX, Filters
synth(uid:ref, wave:sine, freq:440)
synth(uid:tune, wave:sine, freq:~A4)
synth(uid:noise, wave:noise)
synth(uid:fixedDur, freq:220, dur:5)
synth(uid:p1, freq:90, lifetime:process)
synth(uid:env, freq:99, env:{a:0.5,r:1})
synth(uid:chord, freq:[440,477,644])
synth(uid:seq, freq:Pseq(220,330,inf), dur:1)
synth(uid:filter, freq:330,
filter:{type:lp,freq:Pseq(400,1200,inf)})
synth(uid:osc, freq:330,
osc:1, oscAddr:/synth/a)
synthStop(uid:p1, rel:0.5)
tempo(120) // dur: is in beats
synth(degree:Prand(0,2,4,7,inf), scale:minor, root:~A3, dur:0.25)
synth(freq:~A2, fm:{ratio:2, index:3, decay:0.4}, drive:0.3)
synth(wave:kick, dur:1) synth(wave:snare, dur:2) synth(wave:hat, dur:0.25)
osc --- Discrete OSC Event Cue
osc(addr:v1, pitch:y) // y | hz(440) | midi(60) | deg(2,4)
osc(addr:v2, pitch:y, uid:v1) // Y coord. normalised
oscCtrl --- Continuous Control Lanes
oscCtrl(addr:"/fx/pan") // acts like breakpoint function // default 0 → 1
oscCtrl(addr:"/fx/pan", min:-1, max:1, mode:continuous) // mode:event
oscCtrl(addr:"/fx/ring/freq", min:60, max:800) // default 0 → 1
follow --- drive any parameter from any source
freq: follow(fader1.t, 90, 2000) // source 0-1 -> 90..2000
dur: follow(r1.norm, 8, 0.5) // ranges may run high to low
freq: follow(/pedal/1, 90, 2000) // leading slash = OSC address
cutoff: follow(fx.t, 200, 8000, exp2) // lin exp2 exp3 exp4 log sqrt
map --- follow with the INPUT range spelled out
map(source, inMin, inMax, outMin, outMax [, default] [, curve])
sx: map(fx.t, 0, 1, 0.5, 2) // identical to follow(fx.t, 0.5, 2)
sx: map(fx.t, 0.3, 0.35, 0.5, 2) // a 5% slice -> the whole range
sx: map(transport.t, 0.6, 0.67, 0.02, 1) // full sweep over one stretch
Use it when a source is too slow or too narrow to move the parameter — window
it and that slice is stretched across the output. Outside the window the value
pins to the nearer end. follow is map with the input range assumed 0-1. link is the old name and still works.
velocity — everything that moves publishes it
obj.vel // speed, score units/s (rotate: deg/s) -- use map(), it is not 0-1
obj.vx obj.vy // signed components (o2p, trans, drag)
obj.tvel // rate of t: laps / turns per second, geometry only (not on drag)
amp: map(fader.vel, 0, 400, 0, 1) cutoff: map(wheel.tvel, 0, 2, 200, 4000)
oscOut / display — take a signal out, or show it
oscOut(addr:/obj/vel, v: map(fader.vel, 0, 500, 0, 1)) // -> /oscilla/obj/vel [v]
oscOut(addr:/obj, x: follow(a.x), y: follow(a.y)) // -> /oscilla/obj [x y]
display(v: map(cyc.tvel, 0, 1, 0, 1440), fmt:"{v} km/h") // on a <text>: rewritten live
system sources — no cue declares these
transport.t // playhead through the piece, 0-1 transport.elapsed transport.speed
adc.amp // audio analysis: peak centroid onset low mid high pitch note
pin — Keep Element at Playhead
pin(30) // standalone pin for 30 beats
audioImpulse(path:sfx, rate:20, pin:30) // as param
audio(src:drone, loop:0, fade:5, pin:15)
fade(mode:pulse, dur:4, pin:20)
drag — Moveable Score Elements
drag(1) // basic draggable element
drag(1, osc:1) // + send position via OSC
drag(1, osc:/my/addr) // custom OSC address
rotate(dir:1, dur:4) drag(1) // combine cues
button() to Trigger any Cue in Score
button(trigger:nav(page3))
button(trigger:pause(dur:12,count:true))
button(trigger:nav(scroll@A))
button(target:page(home, uid:12),
style(size:"250x50", label:"Home",
font:"Arial Black", fontsize:22))
click:1 — Native SVG Clickable Elements
<g id="ui(uid:rot_tri, action:toggle, click:1)">
<rect fill="#d4af37" .../>
<text>TRI</text>
</g>
propagate — Group-Level Cue Propagation
propagate( // need multiple cues? use this.
scale(values:[${1},${2}], dur:${3}),
rnd(0.8,1.2), rnd(1.2,1.6), rnd(0.4,1.2) )
propagate( // continuous random streams
osc( // all objects in group get the cue
addr:pontalist,
pitch:deg(irand(0,11), irand(0,2)),
env:size, uid:irand1235 ))
propagate(// ------------------------
addr:pontalist, // pitch from Y-axis
pitch:y, env:width,
bright:height, density:area,
trig:playhead ))
propagate( // ------------------------
osc( // ramdomise inputs with ${1}
addr:voice,
pitch:deg(${1}, 3),
env:size, uid:rnd123
), rnd([0,2,4,5,7,9,11]) )
Define and Reuse Collections.
reuse(mainMenu) • use(mainMenu)
reuse(audioSampBtns) • use(audioSampBtns)
Combine Cues by Nesting Groups
// Behaviour stacks outer → inner; shapes inherit all enclosing cues.
<g id="o2p(...)">
<g id="rotate(...)">
<g id="propagate( osc(...) )">
<!-- shapes --> </g></g></g>
File → New Project, Duplicate Project…
File → Import/Export Project (.oscilla)
Docs: https://oscilla.kompot.si
Repo: https://git.kompot.si/rob/oscilla
Rehearsal Tools Cheatsheet: see cheatsheet-session