midi-to-svg
scripts/midi-to-svg.js converts a MIDI file into an Oscilla piano-roll SVG, ready to drop into a score. Each MIDI note becomes two osc() cue elements that fire gate-on and gate-off OSC messages as the playhead crosses them.
Usage
node scripts/midi-to-svg.js input.mid [options] -o output.svg
Options
| Flag | Default | Description |
|---|---|---|
--addr |
voice |
OSC address. Comma-separated for per-MIDI-channel routing, e.g. --addr "flute,oboe" (ch0→flute, ch1→oboe). MIDI channels are 0-indexed, so pad with a leading comma if your file uses channels 1+: --addr ",right,left" |
--px-per-sec / -s |
100 |
Pixels per second — must match your score's playback rate, or scale the osc_frame group uniformly after import |
--row-height / -r |
8 |
Height of each semitone row in pixels |
--pitch-min |
24 (C1) |
Lowest MIDI note number shown in the grid |
--pitch-max |
108 (C8) |
Highest MIDI note number shown in the grid |
--pad |
1 |
Seconds of blank space at start/end |
--dark |
off | Dark background (default: white) |
--channel / -c |
all | Filter to a single MIDI channel (0–15) |
-o |
stdout | Output SVG path |
SVG structure
The output SVG contains one group:
<g id="osc_frame">
<rect id="osc-frame-anchor" …/> <!-- fixes bounding box -->
<g id="pitch-grid" inkscape:groupmode="layer" …>
<!-- semitone lines + C-note labels -->
</g>
<g id="midi-roll">
<g class="midi-note" data-pitch="60" data-ch="1">
<rect id="osc(addr:voice, pitch:y, gate:1, uid:n0on, trig:playhead)" …/>
<rect id="osc(addr:voice, pitch:y, gate:0, uid:n0off, trig:playhead)" …/>
</g>
…
</g>
</g>
The pitch-grid group is an Inkscape layer so it can be toggled visible/hidden independently.
Pitch encoding — Y position
Notes do not carry a hardcoded MIDI note number. Instead, pitch is encoded as the vertical position of the rect inside osc_frame (pitch:y). When the playhead crosses a note, Oscilla normalises the rect's Y centre relative to the osc_frame bounding box and sends it as a 0–1 float.
This means:
- Moving a note up or down in Inkscape changes its pitch. The group for each note (
class="midi-note") contains both the on-rect and off-rect, so dragging the group moves both together. - Scaling
osc_framevertically compresses or expands the pitch range — use X-only scaling to stretch time without affecting pitch. - The
osc-frame-anchorrect must not be deleted or resized — it fixes theosc_framebounding box so the normalisation calculation stays correct.
SuperCollider decoding
// pitchType 4 = y-control; msg[1] is the normalised Y (0–1, bottom=0)
~pitchMin = 24;
~pitchMax = 108;
OSCdef(\voice, { |msg|
var midiNote = (~pitchMin + (msg[1] * (~pitchMax - ~pitchMin))).round;
var gate = msg[msg.size - 1]; // 1 = note-on, 0 = note-off
// … synth logic …
}, '/voice');
The pitch range ~pitchMin / ~pitchMax must match the --pitch-min / --pitch-max values used when generating the SVG (default 24–108). If you change the range, regenerate the SVG.
Workflow
-
Generate the SVG:
node scripts/midi-to-svg.js my-piece.mid --addr "violin,cello" -o roll.svg -
Open the target score SVG in Inkscape. Copy the entire
osc_framegroup fromroll.svgand paste it into the score. -
Position and scale the group:
- X scale only → stretches/compresses time (tempo adjustment)
- Y scale only → expands/compresses the pitch range
- Uniform scale → scales both time and pitch range proportionally
-
Adjust individual notes by dragging their
midi-notegroups vertically (pitch) or horizontally (timing). -
In SuperCollider, set
~pitchMin/~pitchMaxto match the--pitch-min/--pitch-maxused at generation time.
Tip: use ← → or ↑ ↓ to navigate the docs