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