HomeMAL' // MANUFACTORUM
datacrypt
Blob Opera MIDI / public / lib / arrange.js
public/lib/arrange.js7.4 KB
// Turn parsed tracks (MIDI or MusicXML) into four Blob Opera voices.
//
// Blob Opera has exactly four singers (soprano, alto, tenor, bass) and each is
// strictly monophonic. There are two paths:
//
//  - assembleParts: manual, per-track role assignment (used for MusicXML, or
//    MIDI when the user wants explicit control). Overlapping notes within an
//    assigned voice are flattened to the highest sounding note.
//  - foldToFourVoices: MIDI only. Pools *every* note from *every* track and
//    packs them into four monophonic voices, so nothing is thrown away unless
//    more than four notes sound at the exact same instant (which four blobs
//    physically cannot sing).
//
// Both paths end in `buildVoice`, which handles lyrics, melisma and rests.

import { buildVoice } from "./lyrics.js";

const ROLES = ["soprano", "alto", "tenor", "bass"];

// Pack a pool of notes into four monophonic voices, losing a note only when
// more than four sound simultaneously. Voices are returned soprano→bass.
// Notes are processed in onset order (highest pitch first within a chord) and
// each is dropped into the free voice whose register fits best, so the four
// lines stay roughly pitch-ordered. Slight legato overlaps are trimmed rather
// than forcing the note into another voice.
function allocateFourVoices(notes) {
  const empty = { voices: [[], [], [], []], dropped: 0 };
  if (notes.length === 0) return empty;

  const TOLERANCE = 0.02; // seconds of overlap tolerated before trimming
  const sorted = notes.slice().sort((a, b) => a.start - b.start || b.pitch - a.pitch);
  const min = Math.min(...notes.map((n) => n.pitch));
  const max = Math.max(...notes.map((n) => n.pitch));
  // Register centre for an as-yet-unused voice: voice 0 high … voice 3 low.
  const band = (v) => max - ((max - min) * (v + 0.5)) / 4;

  const voices = [[], [], [], []];
  const lastEnd = [-Infinity, -Infinity, -Infinity, -Infinity];
  let dropped = 0;

  for (const n of sorted) {
    const note = { start: n.start, end: n.end, pitch: n.pitch };
    const free = [0, 1, 2, 3].filter((v) => lastEnd[v] <= note.start + TOLERANCE);
    if (free.length === 0) {
      dropped++; // five or more notes at once — unavoidable with four blobs
      continue;
    }
    let best = free[0];
    let bestScore = Infinity;
    for (const v of free) {
      const anchor = voices[v].length ? voices[v][voices[v].length - 1].pitch : band(v);
      const score = Math.abs(anchor - note.pitch);
      if (score < bestScore) {
        bestScore = score;
        best = v;
      }
    }
    const prev = voices[best][voices[best].length - 1];
    if (prev && prev.end > note.start) prev.end = note.start; // trim legato overlap
    voices[best].push(note);
    lastEnd[best] = note.end;
  }

  // Order the four lines by average pitch so soprano→bass is consistent.
  const ranked = voices
    .map((v) => ({ v, avg: v.length ? v.reduce((s, n) => s + n.pitch, 0) / v.length : -Infinity }))
    .sort((a, b) => b.avg - a.avg);
  return { voices: ranked.map((x) => x.v), dropped };
}

/**
 * MIDI only: fold every track's notes into four monophonic voices without
 * discarding notes (except where more than four sound at once).
 * @param {Array<{notes:Array}>} tracks parsed MIDI tracks
 * @returns {{parts:Array<{notes:Array,startSuffix:Array}>, dropped:number, total:number}}
 */
export function foldToFourVoices(tracks) {
  let earliest = Infinity;
  for (const t of tracks) for (const n of t.notes) earliest = Math.min(earliest, n.start);
  if (!isFinite(earliest)) earliest = 0;

  const pool = tracks
    .flatMap((t) => t.notes)
    .map((n) => ({ start: n.start - earliest, end: n.end - earliest, pitch: n.pitch }));

  const { voices, dropped } = allocateFourVoices(pool);
  const built = voices.map((notes) => {
    if (notes.length === 0) return null;
    const events = notes.map((n) => ({ start: n.start, duration: n.end - n.start, pitch: n.pitch, lyric: null }));
    return buildVoice(events);
  });

  const fallback = built.find((v) => v !== null);
  if (!fallback) throw new Error("no notes to sing");
  const parts = built.map((v) => v ?? structuredClone(fallback));
  return { parts, dropped, total: pool.length };
}

// Reduce a bag of (possibly overlapping) MIDI notes to monophonic events.
function reduceToEvents(notes) {
  if (notes.length === 0) return [];
  const bounds = new Set();
  for (const n of notes) {
    bounds.add(n.start);
    bounds.add(n.end);
  }
  const times = [...bounds].sort((a, b) => a - b);

  const segments = [];
  for (let i = 0; i < times.length - 1; i++) {
    const t = times[i];
    let pitch = null;
    for (const n of notes) {
      if (n.start <= t && n.end > t && (pitch === null || n.pitch > pitch)) pitch = n.pitch;
    }
    const prev = segments[segments.length - 1];
    if (prev && prev.pitch === pitch) prev.end = times[i + 1];
    else segments.push({ start: t, end: times[i + 1], pitch });
  }
  // Gaps become silence downstream, so only sounding segments matter.
  return segments
    .filter((s) => s.pitch !== null)
    .map((s) => ({ start: s.start, duration: s.end - s.start, pitch: s.pitch, lyric: null }));
}

/**
 * Assemble four voices from role assignments.
 * @param {Array<{notes:Array, events?:Array}>} tracks parsed tracks
 * @param {Array<string>} assignments assignments[i] is the role for tracks[i]
 *        ("soprano"|"alto"|"tenor"|"bass"|"ignore")
 * @returns {Array<{notes:Array,startSuffix:Array}>} four parts in [S,A,T,B] order
 */
export function assembleParts(tracks, assignments) {
  // Normalise the whole arrangement to start at t=0.
  let earliest = Infinity;
  tracks.forEach((track, i) => {
    if (!ROLES.includes(assignments[i])) return;
    for (const n of track.notes) earliest = Math.min(earliest, n.start);
  });
  if (!isFinite(earliest)) earliest = 0;

  const byRole = { soprano: [], alto: [], tenor: [], bass: [] };
  tracks.forEach((track, i) => {
    byRole[assignments[i]]?.push(track);
  });

  const voices = ROLES.map((role) => {
    const group = byRole[role];
    if (group.length === 0) return null;
    let events;
    if (group.every((t) => t.events)) {
      // MusicXML: already monophonic per track; merge and sort.
      events = group
        .flatMap((t) => t.events)
        .map((e) => ({ ...e, start: e.start - earliest }))
        .sort((a, b) => a.start - b.start);
    } else {
      // MIDI: flatten all assigned notes together, then reduce.
      const notes = group.flatMap((t) => t.notes).map((n) => ({ ...n, start: n.start - earliest, end: n.end - earliest }));
      events = reduceToEvents(notes);
    }
    const voice = buildVoice(events);
    return voice.notes.length > 0 ? voice : null;
  });

  // Blob Opera needs all four voices. Clone the first non-empty voice into any
  // empty slot so a one- or two-track file still sings.
  const fallback = voices.find((v) => v !== null);
  if (!fallback) throw new Error("no notes to sing — assign at least one track to a voice");
  return voices.map((v) => v ?? structuredClone(fallback));
}

// Suggest S/A/T/B roles by ordering tracks from highest to lowest average pitch.
export function autoAssign(tracks) {
  const avg = (t) => t.notes.reduce((s, n) => s + n.pitch, 0) / t.notes.length;
  const ranked = tracks
    .map((t, i) => ({ i, a: avg(t) }))
    .sort((x, y) => y.a - x.a)
    .map((x) => x.i);
  const assignments = tracks.map(() => "ignore");
  ranked.slice(0, 4).forEach((trackIndex, rank) => {
    assignments[trackIndex] = ROLES[rank];
  });
  return assignments;
}

export { ROLES };