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:

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

  1. Generate the SVG:

    node scripts/midi-to-svg.js my-piece.mid --addr "violin,cello" -o roll.svg
    
  2. Open the target score SVG in Inkscape. Copy the entire osc_frame group from roll.svg and paste it into the score.

  3. 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
  4. Adjust individual notes by dragging their midi-note groups vertically (pitch) or horizontally (timing).

  5. In SuperCollider, set ~pitchMin / ~pitchMax to match the --pitch-min / --pitch-max used at generation time.

Tip: use ← → or ↑ ↓ to navigate the docs