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 };
