HomeMAL' // MANUFACTORUM
datacrypt
Blob Opera MIDI / public / lib / lyrics.js
public/lib/lyrics.js4.7 KB
// Lyrics → phonemes, and the shared "voice builder" that turns a monophonic
// event list into Blob Opera notes.
//
// Algorithm mirrors python-blobopera:
//  - Lyric text is normalized to ascii letters and greedily matched against
//    the phoneme alphabet (longest name first).
//  - A note must start on a vowel, so leading consonants are moved to the
//    previous note's suffix (or the part's startSuffix for the first note).
//  - A note with several vowels ("ave") is subdivided into equal slices, one
//    per vowel-led syllable.
//  - Notes without lyrics keep singing the last vowel heard (melisma).
//  - Rests keep the previous pitch but sing silence, avoiding a decay hum.

export const PHONEMES = {
  i: 0, a: 1, e: 2, o: 3, sil: 4, r: 5, n: 6, l: 7, t: 8, s: 9, d: 10,
  u: 11, m: 12, p: 13, v: 14, ch: 15, c: 16, f: 17, j: 18, b: 19, h: 20,
  g: 21, qu: 22, sh: 23, ny: 24, y: 25, sk: 26, z: 27, tz: 28,
};
export const SILENCE = PHONEMES.sil;
const VOWELS = new Set([PHONEMES.i, PHONEMES.a, PHONEMES.e, PHONEMES.o, PHONEMES.u]);

// Longest-first alphabet for greedy matching ("ch" before "c").
const ALPHABET = Object.keys(PHONEMES)
  .filter((n) => n !== "sil")
  .sort((a, b) => b.length - a.length);

/** Convert lyric text into a list of phoneme ids. Unknown letters are dropped. */
export function textToPhonemes(text) {
  const normalized = text
    .toLowerCase()
    .normalize("NFD")
    .replace(/[̀-ͯ]/g, "") // strip diacritics
    .replace(/[^a-z]/g, "");
  const out = [];
  let i = 0;
  while (i < normalized.length) {
    const match = ALPHABET.find((name) => normalized.startsWith(name, i));
    if (match) {
      out.push(PHONEMES[match]);
      i += match.length;
    } else {
      i++; // letter with no phoneme (k, w, x, …)
    }
  }
  return out;
}

// Vowel lasts 0.1, trailing consonants half that. A lone phoneme is doubled
// (both quirks straight from the reverse-engineered format).
function makeSyllable(phonemes) {
  if (phonemes.length === 0) phonemes = [SILENCE];
  if (phonemes.length === 1) phonemes = [phonemes[0], phonemes[0]];
  return {
    vowel: { phoneme: phonemes[0], duration: 0.1 },
    suffix: phonemes.slice(1).map((p) => ({ phoneme: p, duration: 0.05 })),
  };
}

// Split a phoneme list into syllables, each starting at a vowel:
// [a,m,a,r,e] -> [[a,m],[a,r],[e]]
function splitBeforeVowels(phonemes) {
  const groups = [];
  for (const p of phonemes) {
    if (VOWELS.has(p) || groups.length === 0) groups.push([]);
    groups[groups.length - 1].push(p);
  }
  return groups;
}

/**
 * Build one Blob Opera part from a monophonic event list.
 * @param {Array<{start:number,duration:number,pitch:number|null,lyric?:string|null}>} events
 *        Sorted, non-overlapping. `pitch: null` means an explicit rest.
 * @param {string} fillName phoneme sung when a note has no lyric (until a
 *        lyric vowel replaces it).
 * @returns {{notes:Array, startSuffix:Array}}
 */
export function buildVoice(events, fillName = "a") {
  const notes = [];
  const startSuffix = [];
  let fill = PHONEMES[fillName];
  let lastPitch = null;

  // Insert explicit rests for any gaps between events.
  const seq = [];
  let cursor = null;
  for (const e of events) {
    if (cursor !== null && e.start - cursor > 1e-4) {
      seq.push({ start: cursor, duration: e.start - cursor, pitch: null });
    }
    seq.push(e);
    cursor = Math.max(cursor ?? 0, e.start + e.duration);
  }

  for (const event of seq) {
    let phonemes = event.lyric ? textToPhonemes(event.lyric) : [];

    // Leading consonants belong to the previous note's suffix.
    const lead = [];
    while (phonemes.length > 0 && !VOWELS.has(phonemes[0])) lead.push(phonemes.shift());
    if (lead.length > 0) {
      const timed = lead.map((p) => ({ phoneme: p, duration: 0.05 }));
      if (notes.length > 0) notes[notes.length - 1].suffix.push(...timed);
      else startSuffix.push(...timed);
    }

    let syllables;
    if (event.pitch === null) {
      syllables = [[SILENCE]];
    } else if (phonemes.length > 0) {
      syllables = splitBeforeVowels(phonemes);
      for (const p of phonemes) if (VOWELS.has(p)) fill = p;
    } else {
      syllables = [[fill]];
    }

    const slice = event.duration / syllables.length;
    syllables.forEach((syllable, k) => {
      notes.push({
        time: event.start + k * slice,
        pitch: event.pitch ?? lastPitch ?? 48,
        ...makeSyllable(syllable),
      });
    });
    if (event.pitch !== null) lastPitch = event.pitch;
  }

  // Close with silence so the last note has a defined length.
  const last = seq[seq.length - 1];
  if (last && last.pitch !== null) {
    notes.push({ time: last.start + last.duration, pitch: lastPitch ?? 48, ...makeSyllable([SILENCE]) });
  }

  return { notes, startSuffix };
}