tonus 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/BIBLIOGRAPHY.md +99 -0
- package/LICENSE +29 -0
- package/README.md +108 -0
- package/dist/data/cal.d.ts +10 -0
- package/dist/data/cal.js +3862 -0
- package/dist/data/commune.d.ts +17 -0
- package/dist/data/commune.js +1333 -0
- package/dist/data/gr.d.ts +5 -0
- package/dist/data/gr.js +13449 -0
- package/dist/data/kyriale.d.ts +11 -0
- package/dist/data/kyriale.js +971 -0
- package/dist/data/la.d.ts +5 -0
- package/dist/data/la.js +14229 -0
- package/dist/data/lh.d.ts +5 -0
- package/dist/data/lh.js +3619 -0
- package/dist/data/lu.d.ts +5 -0
- package/dist/data/lu.js +23779 -0
- package/dist/data/masses.d.ts +18 -0
- package/dist/data/masses.js +297 -0
- package/dist/data/office-roman.d.ts +19 -0
- package/dist/data/office-roman.js +13792 -0
- package/dist/data/office.d.ts +12 -0
- package/dist/data/office.js +13052 -0
- package/dist/data/propers.d.ts +13 -0
- package/dist/data/propers.js +7584 -0
- package/dist/data/psalms.d.ts +4 -0
- package/dist/data/psalms.js +10 -0
- package/dist/data/psalms.json +22918 -0
- package/dist/data/tones.d.ts +20 -0
- package/dist/data/tones.js +153 -0
- package/dist/data/types.d.ts +3 -0
- package/dist/data/types.js +2 -0
- package/dist/engines/cal/calendar.d.ts +21 -0
- package/dist/engines/cal/calendar.js +265 -0
- package/dist/engines/cal/date.d.ts +31 -0
- package/dist/engines/cal/date.js +141 -0
- package/dist/engines/cal/types.d.ts +66 -0
- package/dist/engines/cal/types.js +189 -0
- package/dist/engines/chant/chant.d.ts +10 -0
- package/dist/engines/chant/chant.js +135 -0
- package/dist/engines/chant/hour.d.ts +8 -0
- package/dist/engines/chant/hour.js +135 -0
- package/dist/engines/chant/intone.d.ts +8 -0
- package/dist/engines/chant/intone.js +84 -0
- package/dist/engines/chant/ordinary.d.ts +7 -0
- package/dist/engines/chant/ordinary.js +232 -0
- package/dist/engines/chant/propers.d.ts +8 -0
- package/dist/engines/chant/propers.js +107 -0
- package/dist/engines/chant/psalm.d.ts +7 -0
- package/dist/engines/chant/psalm.js +60 -0
- package/dist/engines/chant/syllabify.d.ts +20 -0
- package/dist/engines/chant/syllabify.js +192 -0
- package/dist/engines/chant/types.d.ts +76 -0
- package/dist/engines/chant/types.js +34 -0
- package/dist/engines/epoch.d.ts +2 -0
- package/dist/engines/epoch.js +14 -0
- package/dist/engines/harmonia/api.d.ts +35 -0
- package/dist/engines/harmonia/api.js +90 -0
- package/dist/engines/harmonia/aspects.d.ts +8 -0
- package/dist/engines/harmonia/aspects.js +15 -0
- package/dist/engines/harmonia/data/doctrines.d.ts +16 -0
- package/dist/engines/harmonia/data/doctrines.js +154 -0
- package/dist/engines/harmonia/data/vowels.d.ts +10 -0
- package/dist/engines/harmonia/data/vowels.js +21 -0
- package/dist/engines/harmonia/presence.d.ts +13 -0
- package/dist/engines/harmonia/presence.js +48 -0
- package/dist/engines/harmonia/tabula.d.ts +28 -0
- package/dist/engines/harmonia/tabula.js +32 -0
- package/dist/engines/harmonia/voice.d.ts +19 -0
- package/dist/engines/harmonia/voice.js +51 -0
- package/dist/engines/imprint.d.ts +30 -0
- package/dist/engines/imprint.js +152 -0
- package/dist/engines/planet/appearance.d.ts +40 -0
- package/dist/engines/planet/appearance.js +84 -0
- package/dist/engines/planet/aspects.d.ts +5 -0
- package/dist/engines/planet/aspects.js +41 -0
- package/dist/engines/planet/math.d.ts +13 -0
- package/dist/engines/planet/math.js +56 -0
- package/dist/engines/planet/orbital.d.ts +25 -0
- package/dist/engines/planet/orbital.js +223 -0
- package/dist/engines/planet/planet.d.ts +13 -0
- package/dist/engines/planet/planet.js +198 -0
- package/dist/engines/planet/position.d.ts +62 -0
- package/dist/engines/planet/position.js +156 -0
- package/dist/engines/planet/types.d.ts +61 -0
- package/dist/engines/planet/types.js +14 -0
- package/dist/engines/score/api.d.ts +54 -0
- package/dist/engines/score/api.js +87 -0
- package/dist/engines/score/articulation.d.ts +6 -0
- package/dist/engines/score/articulation.js +112 -0
- package/dist/engines/score/emitters/midi.d.ts +65 -0
- package/dist/engines/score/emitters/midi.js +158 -0
- package/dist/engines/score/emitters/musicxml.d.ts +18 -0
- package/dist/engines/score/emitters/musicxml.js +166 -0
- package/dist/engines/score/infer.d.ts +4 -0
- package/dist/engines/score/infer.js +77 -0
- package/dist/engines/score/ir.d.ts +4 -0
- package/dist/engines/score/ir.js +177 -0
- package/dist/engines/score/meta.d.ts +19 -0
- package/dist/engines/score/meta.js +34 -0
- package/dist/engines/score/neume.d.ts +3 -0
- package/dist/engines/score/neume.js +26 -0
- package/dist/engines/score/parse.d.ts +3 -0
- package/dist/engines/score/parse.js +359 -0
- package/dist/engines/score/phrasing.d.ts +24 -0
- package/dist/engines/score/phrasing.js +257 -0
- package/dist/engines/score/prosody.d.ts +35 -0
- package/dist/engines/score/prosody.js +109 -0
- package/dist/engines/score/tabula.d.ts +70 -0
- package/dist/engines/score/tabula.js +109 -0
- package/dist/engines/score/types.d.ts +159 -0
- package/dist/engines/score/types.js +2 -0
- package/dist/engines/temper/api.d.ts +60 -0
- package/dist/engines/temper/api.js +130 -0
- package/dist/engines/temper/data/constants.d.ts +27 -0
- package/dist/engines/temper/data/constants.js +150 -0
- package/dist/engines/temper/data/guido.d.ts +14 -0
- package/dist/engines/temper/data/guido.js +29 -0
- package/dist/engines/temper/data/modes.d.ts +38 -0
- package/dist/engines/temper/data/modes.js +158 -0
- package/dist/engines/temper/gabc.d.ts +5 -0
- package/dist/engines/temper/gabc.js +53 -0
- package/dist/engines/temper/gamut.d.ts +9 -0
- package/dist/engines/temper/gamut.js +24 -0
- package/dist/engines/temper/guido.d.ts +16 -0
- package/dist/engines/temper/guido.js +48 -0
- package/dist/engines/temper/interval.d.ts +15 -0
- package/dist/engines/temper/interval.js +31 -0
- package/dist/engines/temper/modes.d.ts +6 -0
- package/dist/engines/temper/modes.js +13 -0
- package/dist/engines/temper/neume.d.ts +14 -0
- package/dist/engines/temper/neume.js +59 -0
- package/dist/engines/temper/pitch.d.ts +40 -0
- package/dist/engines/temper/pitch.js +129 -0
- package/dist/engines/temper/scale.d.ts +37 -0
- package/dist/engines/temper/scale.js +217 -0
- package/dist/engines/temper/step.d.ts +23 -0
- package/dist/engines/temper/step.js +53 -0
- package/dist/index.d.ts +40 -0
- package/dist/index.js +27 -0
- package/package.json +60 -0
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
// ── XML helpers ──
|
|
2
|
+
function xmlEscape(s) {
|
|
3
|
+
return s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">")
|
|
4
|
+
.replace(/"/g, """).replace(/'/g, "'");
|
|
5
|
+
}
|
|
6
|
+
function elem(tag, content) {
|
|
7
|
+
return `<${tag}>${content}</${tag}>`;
|
|
8
|
+
}
|
|
9
|
+
// ── Pitch / accidental from a tabula row ──
|
|
10
|
+
function xmlStep(row) {
|
|
11
|
+
// spn is e.g. "D4", "Bb3" — the first character is the step letter.
|
|
12
|
+
return row.spn[0] ?? "C";
|
|
13
|
+
}
|
|
14
|
+
function xmlAccidentalGlyph(row) {
|
|
15
|
+
// Only an explicitly-marked accidental prints a glyph.
|
|
16
|
+
if (row.accidentalSource !== "explicit")
|
|
17
|
+
return null;
|
|
18
|
+
if (row.accidental === -1)
|
|
19
|
+
return "flat";
|
|
20
|
+
if (row.accidental === 1)
|
|
21
|
+
return "sharp";
|
|
22
|
+
return "natural";
|
|
23
|
+
}
|
|
24
|
+
const BARLINE_STYLE = {
|
|
25
|
+
",": "dashed", "`": "dotted", ";": "normal", ":": "light-light", "::": "light-heavy",
|
|
26
|
+
};
|
|
27
|
+
function computeSyllabic(lyric) {
|
|
28
|
+
const s = lyric.startsWith("-"), e = lyric.endsWith("-");
|
|
29
|
+
if (!s && !e)
|
|
30
|
+
return "single";
|
|
31
|
+
if (!s && e)
|
|
32
|
+
return "begin";
|
|
33
|
+
if (s && e)
|
|
34
|
+
return "middle";
|
|
35
|
+
return "end";
|
|
36
|
+
}
|
|
37
|
+
function renderNote(row, flags, ctx) {
|
|
38
|
+
const lines = [` <note>`];
|
|
39
|
+
lines.push(` <pitch>`);
|
|
40
|
+
lines.push(` ${elem("step", xmlStep(row))}`);
|
|
41
|
+
if (row.accidental !== 0)
|
|
42
|
+
lines.push(` ${elem("alter", String(row.accidental))}`);
|
|
43
|
+
lines.push(` ${elem("octave", String(row.octave))}`);
|
|
44
|
+
lines.push(` </pitch>`);
|
|
45
|
+
lines.push(` ${elem("duration", "4")}`);
|
|
46
|
+
lines.push(` ${elem("type", "eighth")}`);
|
|
47
|
+
const glyph = xmlAccidentalGlyph(row);
|
|
48
|
+
if (glyph)
|
|
49
|
+
lines.push(` ${elem("accidental", glyph)}`);
|
|
50
|
+
if (row.quilisma)
|
|
51
|
+
lines.push(` ${elem("notehead", "diamond")}`);
|
|
52
|
+
const notations = [];
|
|
53
|
+
// Slurs bind a single neume figure (pes, clivis, torculus …), so a syllable
|
|
54
|
+
// built of several figures shows one arc per figure.
|
|
55
|
+
if (flags.slurStart)
|
|
56
|
+
notations.push(` <slur type="start" number="1"/>`);
|
|
57
|
+
if (flags.slurStop)
|
|
58
|
+
notations.push(` <slur type="stop" number="1"/>`);
|
|
59
|
+
if (row.ictus) {
|
|
60
|
+
notations.push(` <articulations>`, ` <accent/>`, ` </articulations>`);
|
|
61
|
+
}
|
|
62
|
+
const technical = [];
|
|
63
|
+
if (row.quilisma)
|
|
64
|
+
technical.push(` <other-technical>quilisma</other-technical>`);
|
|
65
|
+
if (row.liquescent)
|
|
66
|
+
technical.push(` <other-technical>liquescent</other-technical>`);
|
|
67
|
+
if (row.strophicus)
|
|
68
|
+
technical.push(` <other-technical>strophicus</other-technical>`);
|
|
69
|
+
if (technical.length) {
|
|
70
|
+
notations.push(` <technical>`, ...technical, ` </technical>`);
|
|
71
|
+
}
|
|
72
|
+
if (flags.syllableStart && row.neume.type !== "punctum") {
|
|
73
|
+
notations.push(` <other-notation type="start">${xmlEscape(row.neume.type)}</other-notation>`);
|
|
74
|
+
}
|
|
75
|
+
if (ctx.emitWeights) {
|
|
76
|
+
notations.push(` <other-notation type="start">shape:${row.rhythmicShape} index:${row.rhythmicIndex}</other-notation>`);
|
|
77
|
+
}
|
|
78
|
+
if (notations.length) {
|
|
79
|
+
lines.push(` <notations>`, ...notations, ` </notations>`);
|
|
80
|
+
}
|
|
81
|
+
if (flags.syllableStart) {
|
|
82
|
+
const syllabic = computeSyllabic(row.lyric);
|
|
83
|
+
const clean = row.lyric.replace(/^-+/, "").replace(/-+$/, "").trim();
|
|
84
|
+
lines.push(` <lyric number="1">`, ` ${elem("syllabic", syllabic)}`, ` ${elem("text", xmlEscape(clean))}`, ` </lyric>`);
|
|
85
|
+
}
|
|
86
|
+
lines.push(` </note>`);
|
|
87
|
+
return lines.join("\n");
|
|
88
|
+
}
|
|
89
|
+
function renderMeasure(rows, measureNumber, isFirst, ctx) {
|
|
90
|
+
const lines = [` <measure number="${measureNumber}">`];
|
|
91
|
+
if (isFirst) {
|
|
92
|
+
lines.push(` <attributes>`, ` ${elem("divisions", "8")}`, ` <key>`, ` ${elem("fifths", "0")}`, ` </key>`, ` <time symbol="cut">`, ` ${elem("beats", "4")}`, ` ${elem("beat-type", "4")}`, ` </time>`, ` <clef>`, ` ${elem("sign", "G")}`, ` ${elem("line", "2")}`, ` </clef>`, ` </attributes>`);
|
|
93
|
+
}
|
|
94
|
+
// Walk the phrase's rows. The lyric attaches at each syllable start; slurs
|
|
95
|
+
// bind each neume figure (a syllable may hold several), so they are grouped
|
|
96
|
+
// by (syllableIndex, neumeGroup) and only drawn when a figure has >1 note.
|
|
97
|
+
const sameFigure = (a, b) => a.syllableIndex === b.syllableIndex && a.neumeGroup === b.neumeGroup;
|
|
98
|
+
for (let k = 0; k < rows.length; k++) {
|
|
99
|
+
const row = rows[k];
|
|
100
|
+
const prev = rows[k - 1];
|
|
101
|
+
const next = rows[k + 1];
|
|
102
|
+
const figureStart = !prev || !sameFigure(prev, row);
|
|
103
|
+
const figureEnd = !next || !sameFigure(next, row);
|
|
104
|
+
const figureIsMulti = !(figureStart && figureEnd); // >1 note in this figure
|
|
105
|
+
const syllableStart = !prev || prev.syllableIndex !== row.syllableIndex;
|
|
106
|
+
lines.push(renderNote(row, {
|
|
107
|
+
slurStart: figureIsMulti && figureStart,
|
|
108
|
+
slurStop: figureIsMulti && figureEnd,
|
|
109
|
+
syllableStart,
|
|
110
|
+
}, ctx));
|
|
111
|
+
}
|
|
112
|
+
const divisio = rows[rows.length - 1]?.divisio ?? "::";
|
|
113
|
+
const style = BARLINE_STYLE[divisio] ?? "normal";
|
|
114
|
+
lines.push(` <barline location="right">`, ` <bar-style>${style}</bar-style>`, ` </barline>`);
|
|
115
|
+
lines.push(` </measure>`);
|
|
116
|
+
return lines.join("\n");
|
|
117
|
+
}
|
|
118
|
+
function renderScore(rows, chant, ctx) {
|
|
119
|
+
const parts = [
|
|
120
|
+
`<?xml version="1.0" encoding="UTF-8"?>`,
|
|
121
|
+
`<!DOCTYPE score-partwise PUBLIC "-//Recordare//DTD MusicXML 4.0 Partwise//EN"`,
|
|
122
|
+
` "http://www.musicxml.org/dtds/partwise.dtd">`,
|
|
123
|
+
`<score-partwise version="4.0">`,
|
|
124
|
+
];
|
|
125
|
+
if (chant.incipit) {
|
|
126
|
+
parts.push(` <work>`, ` ${elem("work-title", xmlEscape(chant.incipit))}`, ` </work>`);
|
|
127
|
+
}
|
|
128
|
+
parts.push(` <identification>`);
|
|
129
|
+
parts.push(` <encoding>`);
|
|
130
|
+
parts.push(` ${elem("software", "tonus")}`, ` </encoding>`, ` </identification>`);
|
|
131
|
+
for (const text of [
|
|
132
|
+
chant.mode ? `Mode ${chant.mode}` : null,
|
|
133
|
+
chant.office,
|
|
134
|
+
]) {
|
|
135
|
+
if (text)
|
|
136
|
+
parts.push(` <credit page="1">`, ` ${elem("credit-words", xmlEscape(text))}`, ` </credit>`);
|
|
137
|
+
}
|
|
138
|
+
// Group rows into measures by phrase index.
|
|
139
|
+
const measures = [];
|
|
140
|
+
let i = 0;
|
|
141
|
+
let measureNumber = 1;
|
|
142
|
+
while (i < rows.length) {
|
|
143
|
+
const phraseIndex = rows[i].phraseIndex;
|
|
144
|
+
let j = i;
|
|
145
|
+
while (j < rows.length && rows[j].phraseIndex === phraseIndex)
|
|
146
|
+
j++;
|
|
147
|
+
measures.push(renderMeasure(rows.slice(i, j), measureNumber, measureNumber === 1, ctx));
|
|
148
|
+
measureNumber++;
|
|
149
|
+
i = j;
|
|
150
|
+
}
|
|
151
|
+
parts.push(` <part-list>`, ` <score-part id="P1">`, ` ${elem("part-name", "Chant")}`, ` </score-part>`, ` </part-list>`, ` <part id="P1">`, ...measures, ` </part>`, `</score-partwise>`);
|
|
152
|
+
return parts.join("\n");
|
|
153
|
+
}
|
|
154
|
+
/**
|
|
155
|
+
* Emit a MusicXML 4.0 partwise document from a score's tabula rows plus the
|
|
156
|
+
* source chant (for titling). Returns `{ xml, diagnostics }`.
|
|
157
|
+
*/
|
|
158
|
+
export function toMusicXML(rows, chant, options = {}) {
|
|
159
|
+
const diagnostics = [];
|
|
160
|
+
const ctx = { emitWeights: options.emitWeights ?? false };
|
|
161
|
+
const xml = rows.length
|
|
162
|
+
? renderScore(rows, chant, ctx)
|
|
163
|
+
: renderScore([], chant, ctx);
|
|
164
|
+
return { xml, diagnostics };
|
|
165
|
+
}
|
|
166
|
+
//# sourceMappingURL=musicxml.js.map
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { OFFICE_LABELS } from "../chant/types.js";
|
|
2
|
+
import { MODES } from "../temper/modes.js";
|
|
3
|
+
const ORDINARY_INCIPITS = [
|
|
4
|
+
[/^kyrie/i, "ky"],
|
|
5
|
+
[/^gloria/i, "gl"],
|
|
6
|
+
[/^etinterra/i, "gl"],
|
|
7
|
+
[/^credo/i, "cr"],
|
|
8
|
+
[/^patrem/i, "cr"],
|
|
9
|
+
[/^sanctus/i, "sa"],
|
|
10
|
+
[/^agnus/i, "ag"],
|
|
11
|
+
[/^benedica/i, "be"],
|
|
12
|
+
[/^ite/i, "it"],
|
|
13
|
+
];
|
|
14
|
+
const OFFICE_CODES = new Set(Object.keys(OFFICE_LABELS));
|
|
15
|
+
export function inferChantType(ir) {
|
|
16
|
+
const officePart = ir.chant.office?.toLowerCase().trim();
|
|
17
|
+
if (officePart && OFFICE_CODES.has(officePart))
|
|
18
|
+
return officePart;
|
|
19
|
+
const incipit = collectIncipit(ir, 4);
|
|
20
|
+
if (!incipit)
|
|
21
|
+
return undefined;
|
|
22
|
+
for (const [pattern, type] of ORDINARY_INCIPITS) {
|
|
23
|
+
if (pattern.test(incipit))
|
|
24
|
+
return type;
|
|
25
|
+
}
|
|
26
|
+
return undefined;
|
|
27
|
+
}
|
|
28
|
+
export function inferMode(ir) {
|
|
29
|
+
const headerMode = parseInt(ir.chant.mode ?? "", 10);
|
|
30
|
+
if (headerMode >= 1 && headerMode <= 8)
|
|
31
|
+
return headerMode;
|
|
32
|
+
const midis = [];
|
|
33
|
+
for (const phrase of ir.phrases) {
|
|
34
|
+
for (const syl of phrase.syllables) {
|
|
35
|
+
for (const note of syl.notes)
|
|
36
|
+
midis.push(note.pitch.midi);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
if (midis.length === 0)
|
|
40
|
+
return undefined;
|
|
41
|
+
const finalisPc = ((midis[midis.length - 1] % 12) + 12) % 12;
|
|
42
|
+
const candidates = [];
|
|
43
|
+
for (const [num, data] of MODES) {
|
|
44
|
+
if (data.final === finalisPc)
|
|
45
|
+
candidates.push(num);
|
|
46
|
+
}
|
|
47
|
+
if (candidates.length === 0)
|
|
48
|
+
return undefined;
|
|
49
|
+
if (candidates.length === 1)
|
|
50
|
+
return candidates[0];
|
|
51
|
+
// Authentic vs plagal: melody mean above finalis → authentic
|
|
52
|
+
const finalisStep = midis[midis.length - 1];
|
|
53
|
+
const mean = midis.reduce((s, v) => s + v, 0) / midis.length;
|
|
54
|
+
const offset = mean - finalisStep;
|
|
55
|
+
const authentic = candidates.filter((n) => MODES.get(n)?.type === "authentic");
|
|
56
|
+
const plagal = candidates.filter((n) => MODES.get(n)?.type === "plagal");
|
|
57
|
+
if (offset > 3 && authentic.length > 0)
|
|
58
|
+
return authentic[0];
|
|
59
|
+
if (offset < -1 && plagal.length > 0)
|
|
60
|
+
return plagal[0];
|
|
61
|
+
return (authentic[0] ?? plagal[0]);
|
|
62
|
+
}
|
|
63
|
+
function collectIncipit(ir, maxSyllables) {
|
|
64
|
+
const parts = [];
|
|
65
|
+
outer: for (const phrase of ir.phrases) {
|
|
66
|
+
for (const syl of phrase.syllables) {
|
|
67
|
+
const text = syl.lyric.replace(/-+$/, "").trim();
|
|
68
|
+
if (text) {
|
|
69
|
+
parts.push(text);
|
|
70
|
+
if (parts.length >= maxSyllables)
|
|
71
|
+
break outer;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
return parts.join("");
|
|
76
|
+
}
|
|
77
|
+
//# sourceMappingURL=infer.js.map
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
import { toPitch } from "../temper/pitch.js";
|
|
2
|
+
import { toStep } from "../temper/step.js";
|
|
3
|
+
import { selectVowel } from "../chant/syllabify.js";
|
|
4
|
+
import { classifyNeume } from "./neume.js";
|
|
5
|
+
function rawToNote(raw, scale) {
|
|
6
|
+
const midi = raw.step;
|
|
7
|
+
return {
|
|
8
|
+
pitch: toPitch(midi, scale),
|
|
9
|
+
step: toStep(midi, scale),
|
|
10
|
+
performance: {
|
|
11
|
+
velocity: 0,
|
|
12
|
+
duration: raw.duration,
|
|
13
|
+
rhythmicShape: "arsic",
|
|
14
|
+
rhythmicIndex: 1,
|
|
15
|
+
},
|
|
16
|
+
context: {
|
|
17
|
+
lyric: raw.lyric,
|
|
18
|
+
vowel: selectVowel(raw.lyric).vowel,
|
|
19
|
+
syllableIndex: raw.syllableIndex,
|
|
20
|
+
neumeGroup: raw.neumeGroup,
|
|
21
|
+
ictus: raw.ictus,
|
|
22
|
+
accidentalSource: raw.accidentalSource,
|
|
23
|
+
quilisma: raw.quilisma,
|
|
24
|
+
liquescent: raw.liquescent,
|
|
25
|
+
strophicus: raw.strophicus,
|
|
26
|
+
doubleEpisema: raw.doubleEpisema,
|
|
27
|
+
weight: raw.weight,
|
|
28
|
+
},
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
function makeSyllable(lyric, notes) {
|
|
32
|
+
return { lyric, notes, neume: classifyNeume(notes) };
|
|
33
|
+
}
|
|
34
|
+
function partitionByIctus(annotated) {
|
|
35
|
+
const groups = [];
|
|
36
|
+
let current = [];
|
|
37
|
+
let currentIctusMidi;
|
|
38
|
+
const closeGroup = (items, ictusMidi) => {
|
|
39
|
+
const neumeTypes = new Set();
|
|
40
|
+
let hasDoubleEpisema = false;
|
|
41
|
+
for (const a of items) {
|
|
42
|
+
neumeTypes.add(a.neumeType);
|
|
43
|
+
if (a.note.context.doubleEpisema)
|
|
44
|
+
hasDoubleEpisema = true;
|
|
45
|
+
}
|
|
46
|
+
return {
|
|
47
|
+
notes: items.map((a) => a.note),
|
|
48
|
+
neumeTypes,
|
|
49
|
+
hasDoubleEpisema,
|
|
50
|
+
ictusMidi,
|
|
51
|
+
shape: "arsic",
|
|
52
|
+
};
|
|
53
|
+
};
|
|
54
|
+
for (const a of annotated) {
|
|
55
|
+
if (a.note.context.ictus) {
|
|
56
|
+
if (current.length > 0)
|
|
57
|
+
groups.push(closeGroup(current, currentIctusMidi));
|
|
58
|
+
current = [a];
|
|
59
|
+
currentIctusMidi = a.note.pitch.midi;
|
|
60
|
+
}
|
|
61
|
+
else {
|
|
62
|
+
current.push(a);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
if (current.length > 0)
|
|
66
|
+
groups.push(closeGroup(current, currentIctusMidi));
|
|
67
|
+
return groups;
|
|
68
|
+
}
|
|
69
|
+
function groupSlope(notes) {
|
|
70
|
+
if (notes.length < 2)
|
|
71
|
+
return 0;
|
|
72
|
+
return notes[notes.length - 1].pitch.midi - notes[0].pitch.midi;
|
|
73
|
+
}
|
|
74
|
+
function classifyGroup(group, prev, apexMidi) {
|
|
75
|
+
// Conventional overrides: specific neume shapes have fixed rhythmic quality
|
|
76
|
+
// regardless of melodic context.
|
|
77
|
+
if (group.neumeTypes.has("salicus"))
|
|
78
|
+
return "arsic";
|
|
79
|
+
if (group.hasDoubleEpisema && group.neumeTypes.has("clivis"))
|
|
80
|
+
return "thetic";
|
|
81
|
+
const groupIctusMidi = group.ictusMidi ?? group.notes[0].pitch.midi;
|
|
82
|
+
// Rule 1: incise unity — at or after the apex, everything thetic.
|
|
83
|
+
if (prev && prev.ictusMidi !== undefined && prev.ictusMidi >= apexMidi) {
|
|
84
|
+
return "thetic";
|
|
85
|
+
}
|
|
86
|
+
// Rule 2: relative ictus pitch.
|
|
87
|
+
if (prev && prev.ictusMidi !== undefined) {
|
|
88
|
+
if (groupIctusMidi > prev.ictusMidi)
|
|
89
|
+
return "arsic";
|
|
90
|
+
if (groupIctusMidi < prev.ictusMidi)
|
|
91
|
+
return "thetic";
|
|
92
|
+
}
|
|
93
|
+
// Rule 3: neume slope within the group.
|
|
94
|
+
const slope = groupSlope(group.notes);
|
|
95
|
+
if (slope > 0)
|
|
96
|
+
return "arsic";
|
|
97
|
+
if (slope < 0)
|
|
98
|
+
return "thetic";
|
|
99
|
+
// Default: first group of a piece is arsic (Carroll: never begin with thesis).
|
|
100
|
+
if (!prev)
|
|
101
|
+
return "arsic";
|
|
102
|
+
// Tie-breaker: alternate from previous.
|
|
103
|
+
return prev.shape === "arsic" ? "thetic" : "arsic";
|
|
104
|
+
}
|
|
105
|
+
function classifyCompoundBeats(annotated) {
|
|
106
|
+
if (annotated.length === 0)
|
|
107
|
+
return;
|
|
108
|
+
const groups = partitionByIctus(annotated);
|
|
109
|
+
// Apex = highest-pitched ictus in the incise.
|
|
110
|
+
const apexMidi = annotated
|
|
111
|
+
.filter((a) => a.note.context.ictus)
|
|
112
|
+
.reduce((max, a) => Math.max(max, a.note.pitch.midi), -Infinity);
|
|
113
|
+
for (let gi = 0; gi < groups.length; gi++) {
|
|
114
|
+
const group = groups[gi];
|
|
115
|
+
const prev = gi > 0 ? groups[gi - 1] : null;
|
|
116
|
+
group.shape = classifyGroup(group, prev, apexMidi);
|
|
117
|
+
for (let ni = 0; ni < group.notes.length; ni++) {
|
|
118
|
+
group.notes[ni].performance.rhythmicShape = group.shape;
|
|
119
|
+
group.notes[ni].performance.rhythmicIndex = ni + 1;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
function applyCompoundBeats(phrases) {
|
|
124
|
+
for (const phrase of phrases) {
|
|
125
|
+
const annotated = [];
|
|
126
|
+
for (const syl of phrase.syllables) {
|
|
127
|
+
for (const note of syl.notes) {
|
|
128
|
+
annotated.push({ note, neumeType: syl.neume.type });
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
classifyCompoundBeats(annotated);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
export function buildIR(parsed, chant, scale) {
|
|
135
|
+
const phrases = [];
|
|
136
|
+
let currentPhrase = { syllables: [] };
|
|
137
|
+
let currentNotes = [];
|
|
138
|
+
let currentLyric = null;
|
|
139
|
+
for (const event of parsed.events) {
|
|
140
|
+
if (event.type === "note") {
|
|
141
|
+
const scored = rawToNote(event, scale);
|
|
142
|
+
if (currentLyric === null || event.lyric !== currentLyric) {
|
|
143
|
+
if (currentLyric !== null && currentNotes.length > 0) {
|
|
144
|
+
currentPhrase.syllables.push(makeSyllable(currentLyric, currentNotes));
|
|
145
|
+
}
|
|
146
|
+
currentLyric = event.lyric;
|
|
147
|
+
currentNotes = [scored];
|
|
148
|
+
}
|
|
149
|
+
else {
|
|
150
|
+
currentNotes.push(scored);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
else {
|
|
154
|
+
if (currentLyric !== null && currentNotes.length > 0) {
|
|
155
|
+
currentPhrase.syllables.push(makeSyllable(currentLyric, currentNotes));
|
|
156
|
+
currentLyric = null;
|
|
157
|
+
currentNotes = [];
|
|
158
|
+
}
|
|
159
|
+
currentPhrase.divisio = event;
|
|
160
|
+
phrases.push(currentPhrase);
|
|
161
|
+
currentPhrase = { syllables: [] };
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
if (currentLyric !== null && currentNotes.length > 0) {
|
|
165
|
+
currentPhrase.syllables.push(makeSyllable(currentLyric, currentNotes));
|
|
166
|
+
}
|
|
167
|
+
if (currentPhrase.syllables.length > 0) {
|
|
168
|
+
phrases.push(currentPhrase);
|
|
169
|
+
}
|
|
170
|
+
applyCompoundBeats(phrases);
|
|
171
|
+
return {
|
|
172
|
+
chant,
|
|
173
|
+
phrases,
|
|
174
|
+
errors: parsed.errors,
|
|
175
|
+
};
|
|
176
|
+
}
|
|
177
|
+
//# sourceMappingURL=ir.js.map
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import type { ChantType, Score } from "./types.js";
|
|
2
|
+
export interface ChantMeta {
|
|
3
|
+
mode: number | null;
|
|
4
|
+
modeAlias: string | null;
|
|
5
|
+
family: string | null;
|
|
6
|
+
type: "authentic" | "plagal" | null;
|
|
7
|
+
final: number | null;
|
|
8
|
+
tenor: number | null;
|
|
9
|
+
molle: boolean;
|
|
10
|
+
hexachord: "durum" | "naturale" | "molle" | null;
|
|
11
|
+
office: ChantType | null;
|
|
12
|
+
incipit: string | null;
|
|
13
|
+
}
|
|
14
|
+
export interface ChantMetaOptions {
|
|
15
|
+
mode?: number;
|
|
16
|
+
office?: ChantType;
|
|
17
|
+
}
|
|
18
|
+
export declare function computeMeta(ir: Score, options?: ChantMetaOptions): ChantMeta;
|
|
19
|
+
//# sourceMappingURL=meta.d.ts.map
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { MODES } from "../temper/modes.js";
|
|
2
|
+
import { inferChantType, inferMode } from "./infer.js";
|
|
3
|
+
export function computeMeta(ir, options = {}) {
|
|
4
|
+
const modeNum = options.mode ?? inferMode(ir);
|
|
5
|
+
const modeData = modeNum !== undefined ? MODES.get(modeNum) : undefined;
|
|
6
|
+
const office = options.office ?? inferChantType(ir) ?? null;
|
|
7
|
+
return {
|
|
8
|
+
mode: modeData?.mode ?? null,
|
|
9
|
+
modeAlias: modeData?.alias ?? null,
|
|
10
|
+
family: modeData?.maneria ?? null,
|
|
11
|
+
type: modeData?.type ?? null,
|
|
12
|
+
final: modeData?.final ?? null,
|
|
13
|
+
tenor: modeData?.tenor ?? null,
|
|
14
|
+
molle: modeData?.hexachords[0] === "molle",
|
|
15
|
+
hexachord: modeData?.hexachords[0] ?? null,
|
|
16
|
+
office,
|
|
17
|
+
incipit: collectIncipit(ir),
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
function collectIncipit(ir, maxSyllables = 6) {
|
|
21
|
+
const parts = [];
|
|
22
|
+
outer: for (const phrase of ir.phrases) {
|
|
23
|
+
for (const syl of phrase.syllables) {
|
|
24
|
+
const text = syl.lyric.replace(/-+$/, "").trim();
|
|
25
|
+
if (text) {
|
|
26
|
+
parts.push(text);
|
|
27
|
+
if (parts.length >= maxSyllables)
|
|
28
|
+
break outer;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
return parts.length > 0 ? parts.join(" ") : null;
|
|
33
|
+
}
|
|
34
|
+
//# sourceMappingURL=meta.js.map
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { classifyShape } from "../temper/neume.js";
|
|
2
|
+
function toDirection(delta) {
|
|
3
|
+
if (delta > 0)
|
|
4
|
+
return "up";
|
|
5
|
+
if (delta < 0)
|
|
6
|
+
return "down";
|
|
7
|
+
return "unison";
|
|
8
|
+
}
|
|
9
|
+
export function classifyNeume(notes) {
|
|
10
|
+
const hasQuilisma = notes.some((n) => n.context.quilisma);
|
|
11
|
+
const hasLiquescent = notes.some((n) => n.context.liquescent);
|
|
12
|
+
const hasStrophicus = notes.some((n) => n.context.strophicus);
|
|
13
|
+
const intervals = [];
|
|
14
|
+
for (let i = 1; i < notes.length; i++) {
|
|
15
|
+
intervals.push(notes[i].pitch.midi - notes[i - 1].pitch.midi);
|
|
16
|
+
}
|
|
17
|
+
let type = classifyShape(intervals.map(toDirection));
|
|
18
|
+
// Salicus: three ascending notes with ictus on the middle note (distinguishes
|
|
19
|
+
// from scandicus, which has no middle ictus). GABC marks it with `'` on the
|
|
20
|
+
// middle note.
|
|
21
|
+
if (type === "scandicus" && notes.length === 3 && notes[1].context.ictus) {
|
|
22
|
+
type = "salicus";
|
|
23
|
+
}
|
|
24
|
+
return { type, intervals, hasQuilisma, hasLiquescent, hasStrophicus };
|
|
25
|
+
}
|
|
26
|
+
//# sourceMappingURL=neume.js.map
|