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.
stop(uid:s1) • stop(next:nav(End))
pause(dur:12, count:true) // with countdown
pause(dur:4, next:nav(page3)) // chaining
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]))
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
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), dur:1)
synth(uid:filter, freq:330,
filter:{type:lp,freq:Pseq(400,1200)})
synth(uid:osc, freq:330,
osc:1, oscAddr:/synth/a)
synthStop(uid:p1, rel:0.5)
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 seconds
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>
- Structured UI for editing cue & animation IDs
- see docs docs/inkscape_extension/
File → New Project, Save Project As…
File → Import/Export Project (.oscilla)
Docs: https://oscilla.kompot.si
Repo: https://git.kompot.si/rob/oscilla
Session Layer Cheatsheet: see cheatsheet-session