Something went wrong. Try again.
Monorepo for Aesthetic.Computer aesthetic.computer
Something went wrong. Try again.
JavaScript
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141// notation.mjs — the spinging choral score (round 4).//// Choir-sheet syllable underlay married to jingle melodies: per NOTE —// { t, dur, midi, word, wordIndex, syllableIndex, syllable,// phonemes: { onset:[{ipa,cls,voiced}…], nucleus:{ipa,…}, coda:[…] },// melisma: "start"|"mid"|"end"|null, // slots sharing one sung vowel// phrase: 0-based phrase index,// articulation: "phraseStart"|"phraseEnd"|"legato" }//// Built from the lyric spec (words + note slots) + definitive pronunciations// (spinging/lib/pronounce.mjs — Wiktionary US first). Both the guided aligner// and the WORLD synthesizer consume this sidecar; nobody guesses phonemes// from spectra alone anymore.//// Round 4: PHRASE GROUPING is owned here — a phrase breaks at lyric// punctuation (. ! ? , ; : —) and at melody rests ≥ REST_PHRASE_S. Within a// phrase the engine bridges inter-word gaps legato (sustained vowel, gliding// f0); breaths happen ONLY at phrase boundaries.//// API:// await buildLineScore({ text, words }) // words: [{ w, slots:[{t,dur,midi}] }]// → { version, text, notes:[…], phrases: n }// readLineScore(path) / writeLineScore(path, score)
import { readFileSync, writeFileSync } from "node:fs";import { pronounce } from "./pronounce.mjs";
const VERSION = 4;export const REST_PHRASE_S = 0.4; // melody rest ≥ this → phrase break (breathe)
// Fit a word's pronounced syllables onto its note slots.// - equal: 1:1// - more syllables than slots: merge the weakest (unstressed/schwa) syllable// into its neighbour — onset joins previous coda, coda joins next onset.// - fewer syllables than slots: melisma — the last syllable's vowel is held// across the extra notes (onset sung on the first, coda on the last).function fitSyllables(sylls, nSlots) { let s = sylls.map((x) => ({ onset: [...x.onset], nucleus: x.nucleus, coda: [...x.coda], stress: x.stress, })); while (s.length > nSlots && s.length > 1) { // weakest = unstressed schwa-ish, shortest phone count, never the first let k = -1, best = Infinity; for (let i = 1; i < s.length; i++) { const weight = (s[i].stress ? 10 : 0) + (/(ə|ɐ|ɘ)/.test(s[i].nucleus.ipa) ? 0 : 4) + s[i].onset.length + s[i].coda.length; if (weight < best) { best = weight; k = i; } } const dead = s[k]; s[k - 1].coda.push(...dead.onset); if (k + 1 < s.length) s[k + 1].onset.unshift(...dead.coda); else s[k - 1].coda.push(...dead.coda); s.splice(k, 1); } // melisma expansion const out = []; for (let i = 0; i < s.length; i++) { const extra = i === s.length - 1 ? nSlots - s.length : 0; if (extra <= 0) { out.push({ ...s[i], melisma: null }); continue; } for (let m = 0; m <= extra; m++) { out.push({ onset: m === 0 ? s[i].onset : [], nucleus: s[i].nucleus, coda: m === extra ? s[i].coda : [], stress: s[i].stress, melisma: m === 0 ? "start" : m === extra ? "end" : "mid", }); } } return out.slice(0, nSlots);}
const syllableText = (s) => [...s.onset, s.nucleus, ...s.coda].map((p) => p.ipa).join("");
// Phrase grouping (round 4): word i STARTS a new phrase when// - it is the first word of the line, or// - the lyric text puts phrase punctuation right after word i-1, or// - the melody rests ≥ REST_PHRASE_S between word i-1's last note end// and word i's first note.// Lyric words are matched to text tokens in order (the specs are written 1:1).function phraseStarts(text, words) { const norm = (s) => (s || "").toLowerCase().replace(/[^a-z0-9']/g, ""); const tokens = (text || "").split(/\s+/).filter(Boolean) .map((tok) => ({ word: norm(tok), breakAfter: /[.!?,;:—…]["')\]]*$/.test(tok) })); const starts = new Array(words.length).fill(false); starts[0] = true; let ti = 0; for (let wi = 0; wi < words.length; wi++) { const w = norm(words[wi].w); while (ti < tokens.length && tokens[ti].word !== w) ti++; const tok = ti < tokens.length ? tokens[ti] : null; ti++; if (tok?.breakAfter && wi + 1 < words.length) starts[wi + 1] = true; if (wi + 1 < words.length) { const prevSlots = words[wi].slots; const last = prevSlots[prevSlots.length - 1]; const rest = words[wi + 1].slots[0].t - (last.t + last.dur); if (rest >= REST_PHRASE_S) starts[wi + 1] = true; } } return starts;}
export async function buildLineScore({ text, words }) { const starts = phraseStarts(text, words); const notes = []; let phrase = -1; for (let wi = 0; wi < words.length; wi++) { const word = words[wi]; if (starts[wi]) phrase++; const phraseEndsHere = wi === words.length - 1 || starts[wi + 1]; const pron = await pronounce(word.w); const fitted = fitSyllables(pron.syllables, word.slots.length); for (let si = 0; si < word.slots.length; si++) { const slot = word.slots[si]; const syl = fitted[si]; const lastOfWord = si === word.slots.length - 1; notes.push({ t: slot.t, dur: slot.dur, midi: slot.midi, word: word.w, wordIndex: wi, syllableIndex: si, syllable: syllableText(syl), ipa: pron.ipa, ipaSource: pron.source, phonemes: { onset: syl.onset, nucleus: syl.nucleus, coda: syl.coda }, stress: syl.stress, melisma: syl.melisma, phrase, articulation: si === 0 && starts[wi] ? "phraseStart" : lastOfWord && phraseEndsHere ? "phraseEnd" : "legato", }); } } return { version: VERSION, text, notes, phrases: phrase + 1 };}
export function writeLineScore(path, score) { writeFileSync(path, JSON.stringify(score, null, 1));}export function readLineScore(path) { return JSON.parse(readFileSync(path, "utf8"));}