Home Manual Reference Source Test

src/controller/tracks.js

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