Each test verifies that signal publish + subscribe works end-to-end after the paramBinding.js dual-path fix. All scenarios assume the SVG elements exist in the score with the matching IDs.
Console commands are provided for each test to verify signal flow.
The simplest case. A single fader path drives a synth's pitch.
<!-- Inkscape: draw a vertical path, id="freqPath" -->
<path id="freqPath" d="M 100 400 L 100 100"
cue="o2p(uid:fSlider, trig:touch, osc:0)" />
<!-- Invisible cue carrier -->
<g cue="synth(uid:tone, wave:sine, freq: follow(fSlider.t, 200, 800), amp:0.1)" />
Verify in console:
// After touching the fader, confirm signal appears:
window.oscillaParamBus.get("fSlider.t") // should return 0-1
window.oscillaParamBus.get("o2p:fSlider.t") // typed path, same value
// Confirm synth is receiving:
window.oscillaParamBus.setDebugMode(true)
// Move fader -- should see "[ParamBus] fSlider.t = 0.xxxx" logs
Expected: dragging the fader sweeps pitch from 200 to 800 Hz.
Two-axis control from a single XY pad.
<rect id="xyRect" x="200" y="100" width="400" height="400"
fill="#111" stroke="#333"
cue="controlXY(uid:pad1, handle:dot1, label:true)" />
<circle id="dot1" cx="0" cy="0" r="10" fill="#ff4444" />
<g cue="synth(uid:xyTone, wave:saw,
freq: follow(pad1.x, 100, 2000),
amp: follow(pad1.y, 0, 0.3),
filter:{type:lp, freq:1000})" />
Verify:
window.oscillaParamBus.get("pad1.x") // 0-1
window.oscillaParamBus.get("pad1.y") // 0-1
Expected: horizontal = pitch, vertical = volume.
Tests the .p channel from a rotation handle.
<rect id="filterRect" x="200" y="100" width="300" height="300"
fill="#111"
cue="controlXY(uid:knob, handle:dot1, hmode:limited, rotrange:270, label:true)" />
<circle id="dot1" cx="0" cy="0" r="12" fill="#44aaff" />
<g cue="synth(uid:filtered, wave:saw, freq:220, amp:0.15,
cutoff: follow(knob.p, 200, 8000), q:2)" />
Verify:
window.oscillaParamBus.get("knob.p") // 0-1 based on rotation
Expected: rotating the handle sweeps filter cutoff 200-8000 Hz.
A spinning visual element drives stereo position.
<g id="spinner"
cue="rotate(uid:spin, dur:4, loop:0)">
<polygon points="0,-30 10,10 -10,10" fill="#ff0" />
</g>
<g cue="synth(uid:panTone, wave:sine, freq:440, amp:0.1,
pan: follow(spin.norm, -1, 1))" />
Verify:
window.oscillaParamBus.get("spin.norm") // 0-1
window.oscillaParamBus.get("spin.angle") // 0-360
Expected: tone pans left-right as the spinner rotates.
No touch. The fader animates automatically with the playhead and the synth binds to its position.
<path id="autoPath" d="M 500 400 C 600 100 700 400 800 100"
cue="o2p(uid:glide, dur:8, loop:1, osc:0)" />
<g cue="synth(uid:glider, wave:triangle,
freq: follow(glide.t, 150, 1200),
amp: follow(glide.y, 0.02, 0.2))" />
Expected: as the dot traverses the curved path, pitch follows t
(position along path) and amplitude follows y (vertical position
in bounding box). The curving path makes the Y modulation non-linear.
A single fader controlling a chord spread.
<path id="chordPath" d="M 300 500 L 300 50"
cue="o2p(uid:master, trig:touch, osc:0)" />
<g cue="synth(uid:bass, wave:sine,
freq: follow(master.t, 55, 220), amp:0.12)" />
<g cue="synth(uid:mid, wave:triangle,
freq: follow(master.t, 220, 880), amp:0.08)" />
<g cue="synth(uid:high, wave:saw,
freq: follow(master.t, 880, 3520), amp:0.05,
filter:{type:lp, freq:2000})" />
Expected: moving the fader sweeps all three synths simultaneously through different frequency ranges, creating an expanding/contracting harmonic texture.
Two faders controlling different parameters of the same synth.
<path id="freqFader" d="M 100 400 L 100 100"
cue="o2p(uid:fCtrl, trig:touch, osc:0)" />
<path id="filterFader" d="M 200 400 L 200 100"
cue="o2p(uid:cCtrl, trig:touch, osc:0)" />
<g cue="synth(uid:lead, wave:saw, amp:0.12,
freq: follow(fCtrl.t, 100, 1000),
cutoff: follow(cCtrl.t, 200, 8000),
q:4)" />
Expected: left fader controls pitch, right fader controls filter independently.
Uses an oscCtrl path (playhead-driven continuous control lane) to modulate a synth.
<path id="ctrlLane" d="M 400 300 C 500 100 600 500 700 200"
cue="oscCtrl(uid:lane1, addr:/ctrl/lane1, min:0, max:1)" />
<g cue="synth(uid:laneTest, wave:sine,
freq: follow(lane1.v, 200, 600), amp:0.1)" />
Verify:
window.oscillaParamBus.get("lane1.v") // 0-1, follows path Y
window.oscillaParamBus.get("lane1.t") // 0-1, playhead position
Expected: as the playhead crosses the control lane, the path's vertical shape modulates the synth frequency.
A pulsing scale animation controlling a synth's amplitude, so the visual size and the audio volume are synchronised.
<circle id="pulser" cx="500" cy="300" r="40" fill="#0af"
cue="scale(uid:pulse, sx:[0.5,1.5], sy:[0.5,1.5], dur:2, loop:0)" />
<g cue="synth(uid:pulseTone, wave:sine, freq:330,
amp: follow(pulse.uniform, 0, 0.2))" />
Verify:
window.oscillaParamBus.get("pulse.uniform") // 0.5-1.5
Expected: volume swells as the circle grows, dips as it shrinks.
Note: uniform is the average of sx and sy, so for equal scaling it
tracks the visual size directly. The binding maps 0-1 input to 0-0.2
output, so the raw 0.5-1.5 values will be clamped -- you may want to
adjust the range mapping depending on what scale values your animation
produces.
Manually push a value and confirm a bound synth responds. Run this after any score is loaded:
// 1. Check ParamBus is alive
window.oscillaParamBus.set("test.t", 0.5);
window.oscillaParamBus.get("test.t"); // 0.5
// 2. Watch for subscribers
window.oscillaParamBus.setDebugMode(true);
// 3. Simulate a fader moving
let t = 0;
const sim = setInterval(() => {
t = (t + 0.01) % 1;
window.oscillaParamBus.set("fSlider.t", t);
}, 30);
// Stop with: clearInterval(sim)
If a synth with freq: follow(fSlider.t, 200, 800) is running, you should
hear the pitch sweep. If no synth is running, the ParamBus debug
logs confirm the values are being written.
| Source | Channel | Range | Notes |
|---|---|---|---|
| o2p | t | 0-1 | position along path |
| o2p | x | 0-1 | normalized X in bbox |
| o2p | y | 0-1 | normalized Y in bbox |
| o2p | angle | degrees | tangent angle |
| controlXY | x | 0-1 | horizontal position |
| controlXY | y | 0-1 | vertical position (0=bottom) |
| controlXY | p | 0-1 | rotation handle (if present) |
| rotate | angle | 0-360 | current angle degrees |
| rotate | rad | 0-2pi | current angle radians |
| rotate | norm | 0-1 | normalized angle |
| scale | sx | varies | scale X factor |
| scale | sy | varies | scale Y factor |
| scale | uniform | varies | average of sx and sy |
| color | hNorm | 0-1 | hue / 360 |
| color | sNorm | 0-1 | saturation / 100 |
| color | lNorm | 0-1 | lightness / 100 |
| fade | opacity | 0-1 | current opacity |
| oscCtrl | t | 0-1 | playhead position in lane |
| oscCtrl | v | 0-1 | normalized Y value |
In the DSL, use follow(uid.channel, min, max). The source type prefix is not
needed -- the agnostic path handles it automatically.