All files / src/controller tracks.js

100% Statements 63/63
100% Branches 26/26
100% Functions 6/6
100% Lines 60/60

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168                                          15x 2x   13x 13x 27x   13x 13x 13x 14x 14x       13x                   28x 28x   28x         28x 28x 28x 28x 28x       28x           28x 28x 28x 44x 44x 6x   44x           4x 4x 4x   40x 40x 40x   28x                 28x 28x 36x 36x 36x 118x               28x               40x 8x 8x     8x                             40x 40x 40x 40x 40x           74x 2x   74x 74x   40x 40x         40x 2x   40x 40x    
/** @external {midi-writer-js} https://www.npmjs.com/package/midi-writer-js */
import MidiWriter from "../../assets/vendor/midi-writer-js.esm.js";
import { noteNames } from "../model/theory.js";
 
/**
 * Builds MIDI from part notes solutions N
 * (N=1 for per-part preview, N=|parts| for full piece).
 * Per-part time-signature and key/mode changes are emitted as mid-track meta events,
 * so a multi-section piece (e.g. C major 4/4 -> A minor 3/4)
 * renders with correct meta events at every part boundary.
 * @param {Object[]} parts
 *  Per-part records: solution, subdivision, numSubBeats, numerator, denominator, key, mode.
 *  Empty input returns null.
 * @param {{voices: number, voice: {instrument: number}[]}} voiceConfig
 *  Global voice config.
 * @param {number} tempo - BPM for the entire combined track.
 * @return {?{combinedB64: string, voicesB64: string[]}}
 *  Note: midi-writer-js hardcodes 128 PPQ,
 *  so triplet sub-beats (subdivision=3) drift by ~1 tick per quarter.
 */
export function generateMidi(parts, voiceConfig, tempo) {
  if (parts.length === 0) {
    return null;
  }
  const { voices, voice } = voiceConfig;
  const writeB64 = (tracks) =>
    new MidiWriter.Writer(tracks).dataUri().split(",")[1];
 
  const allTracks = [];
  const voicesB64 = [];
  for (let s = 0; s < voices; s++) {
    allTracks.push(makeVoiceTrack(s, parts, voice[s].instrument, tempo));
    voicesB64.push(
      writeB64([makeVoiceTrack(s, parts, voice[s].instrument, tempo)]),
    );
  }
  return { combinedB64: writeB64(allTracks), voicesB64 };
}
 
/**
 * @private Build one voice's track across all parts.
 * Ties scan across part boundaries (held notes survive);
 * TimeSig+KeySig meta events emit just before the next NoteEvent
 * (slightly late if a held note spans the boundary).
 */
function makeVoiceTrack(s, parts, instrument, tempo) {
  const track = new MidiWriter.Track();
  const p0 = parts[0];
  // Melodic MIDI channel nibbles: all 16 except 9 (GM percussion).
  const MELODIC_CHANNELS = [0, 1, 2, 3, 4, 5, 6, 7, 8, 10, 11, 12, 13, 14, 15];
  // Give each voice its own MIDI channel so per-voice instruments do not collide
  // (otherwise all voices share channel 1 and the last ProgramChange wins).
  // midi-writer-js quirk: ProgramChange takes a 0-indexed channel,
  // NoteEvent a 1-indexed one; both land on nibble `ch`.
  const ch = MELODIC_CHANNELS[s % MELODIC_CHANNELS.length];
  track.setTempo(tempo);
  track.setTimeSignature(p0.numerator, p0.denominator);
  track.setKeySignature(noteNames()[p0.key] + (p0.mode === "minor" ? "m" : ""));
  track.addEvent(
    new MidiWriter.ProgramChangeEvent({ instrument, channel: ch }),
  );
 
  const ctx = {
    track,
    grid: buildGrid(parts, s),
    queuedParts: [],
    channel: ch,
  };
  let pendingWait = 0;
  let i = 0;
  while (i < ctx.grid.length) {
    const cell = ctx.grid[i];
    if (cell.newPart) {
      ctx.queuedParts.push(cell.newPart);
    }
    if (
      cell.val === -1 ||
      cell.val === -2 ||
      cell.val === undefined ||
      cell.val === null
    ) {
      pendingWait += cell.ticks;
      i++;
      continue;
    }
    flushQueuedParts(ctx);
    i += emitNoteFrom(ctx, i, pendingWait);
    pendingWait = 0;
  }
  return track;
}
 
/**
 * @private Flatten parts into per-sub-beat cells.
 * Cells of new parts (beyond the first) carry the part,
 * so meta events can be emitted at boundaries.
 */
function buildGrid(parts, s) {
  const grid = [];
  for (let m = 0; m < parts.length; m++) {
    const p = parts[m];
    const ticks = Math.round(512 / (p.denominator * p.subdivision));
    for (let t = 0; t < p.numSubBeats; t++) {
      grid.push({
        val: p.solution[`note_${s}_${t}`],
        ticks,
        keyIndex: p.key,
        newPart: t === 0 && m > 0 ? p : null,
      });
    }
  }
  return grid;
}
 
/**
 * @private Drain queued part-boundary meta events
 * (TimeSignature + KeySignature) into the track at the current cursor.
 */
function flushQueuedParts(ctx) {
  while (ctx.queuedParts.length > 0) {
    const p = ctx.queuedParts.shift();
    ctx.track.addEvent(
      new MidiWriter.TimeSignatureEvent(p.numerator, p.denominator),
    );
    ctx.track.addEvent(
      new MidiWriter.KeySignatureEvent(
        noteNames()[p.key] + (p.mode === "minor" ? "m" : ""),
      ),
    );
  }
}
 
/**
 * @private Emit one NoteEvent starting at grid[i].
 * Ties (incl. cross-part) extend its duration;
 * meta events crossed during the scan are queued for the next flush;
 * returns scan length.
 */
function emitNoteFrom(ctx, i, pendingWait) {
  const { track, grid, queuedParts } = ctx;
  const cell = grid[i];
  let durTicks = cell.ticks;
  let scan = 1;
  while (
    i + scan < grid.length &&
    (grid[i + scan].val === -2 ||
      grid[i + scan].val === undefined ||
      grid[i + scan].val === null)
  ) {
    if (grid[i + scan].newPart) {
      queuedParts.push(grid[i + scan].newPart);
    }
    durTicks += grid[i + scan].ticks;
    scan++;
  }
  const names = noteNames(cell.keyIndex);
  const opts = {
    pitch: [`${names[cell.val % 12]}${Math.floor(cell.val / 12) - 1}`],
    duration: `T${durTicks}`,
    channel: ctx.channel + 1,
  };
  if (pendingWait > 0) {
    opts.wait = `T${pendingWait}`;
  }
  track.addEvent(new MidiWriter.NoteEvent(opts));
  return scan;
}