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.
Files changed (141) hide show
  1. package/BIBLIOGRAPHY.md +99 -0
  2. package/LICENSE +29 -0
  3. package/README.md +108 -0
  4. package/dist/data/cal.d.ts +10 -0
  5. package/dist/data/cal.js +3862 -0
  6. package/dist/data/commune.d.ts +17 -0
  7. package/dist/data/commune.js +1333 -0
  8. package/dist/data/gr.d.ts +5 -0
  9. package/dist/data/gr.js +13449 -0
  10. package/dist/data/kyriale.d.ts +11 -0
  11. package/dist/data/kyriale.js +971 -0
  12. package/dist/data/la.d.ts +5 -0
  13. package/dist/data/la.js +14229 -0
  14. package/dist/data/lh.d.ts +5 -0
  15. package/dist/data/lh.js +3619 -0
  16. package/dist/data/lu.d.ts +5 -0
  17. package/dist/data/lu.js +23779 -0
  18. package/dist/data/masses.d.ts +18 -0
  19. package/dist/data/masses.js +297 -0
  20. package/dist/data/office-roman.d.ts +19 -0
  21. package/dist/data/office-roman.js +13792 -0
  22. package/dist/data/office.d.ts +12 -0
  23. package/dist/data/office.js +13052 -0
  24. package/dist/data/propers.d.ts +13 -0
  25. package/dist/data/propers.js +7584 -0
  26. package/dist/data/psalms.d.ts +4 -0
  27. package/dist/data/psalms.js +10 -0
  28. package/dist/data/psalms.json +22918 -0
  29. package/dist/data/tones.d.ts +20 -0
  30. package/dist/data/tones.js +153 -0
  31. package/dist/data/types.d.ts +3 -0
  32. package/dist/data/types.js +2 -0
  33. package/dist/engines/cal/calendar.d.ts +21 -0
  34. package/dist/engines/cal/calendar.js +265 -0
  35. package/dist/engines/cal/date.d.ts +31 -0
  36. package/dist/engines/cal/date.js +141 -0
  37. package/dist/engines/cal/types.d.ts +66 -0
  38. package/dist/engines/cal/types.js +189 -0
  39. package/dist/engines/chant/chant.d.ts +10 -0
  40. package/dist/engines/chant/chant.js +135 -0
  41. package/dist/engines/chant/hour.d.ts +8 -0
  42. package/dist/engines/chant/hour.js +135 -0
  43. package/dist/engines/chant/intone.d.ts +8 -0
  44. package/dist/engines/chant/intone.js +84 -0
  45. package/dist/engines/chant/ordinary.d.ts +7 -0
  46. package/dist/engines/chant/ordinary.js +232 -0
  47. package/dist/engines/chant/propers.d.ts +8 -0
  48. package/dist/engines/chant/propers.js +107 -0
  49. package/dist/engines/chant/psalm.d.ts +7 -0
  50. package/dist/engines/chant/psalm.js +60 -0
  51. package/dist/engines/chant/syllabify.d.ts +20 -0
  52. package/dist/engines/chant/syllabify.js +192 -0
  53. package/dist/engines/chant/types.d.ts +76 -0
  54. package/dist/engines/chant/types.js +34 -0
  55. package/dist/engines/epoch.d.ts +2 -0
  56. package/dist/engines/epoch.js +14 -0
  57. package/dist/engines/harmonia/api.d.ts +35 -0
  58. package/dist/engines/harmonia/api.js +90 -0
  59. package/dist/engines/harmonia/aspects.d.ts +8 -0
  60. package/dist/engines/harmonia/aspects.js +15 -0
  61. package/dist/engines/harmonia/data/doctrines.d.ts +16 -0
  62. package/dist/engines/harmonia/data/doctrines.js +154 -0
  63. package/dist/engines/harmonia/data/vowels.d.ts +10 -0
  64. package/dist/engines/harmonia/data/vowels.js +21 -0
  65. package/dist/engines/harmonia/presence.d.ts +13 -0
  66. package/dist/engines/harmonia/presence.js +48 -0
  67. package/dist/engines/harmonia/tabula.d.ts +28 -0
  68. package/dist/engines/harmonia/tabula.js +32 -0
  69. package/dist/engines/harmonia/voice.d.ts +19 -0
  70. package/dist/engines/harmonia/voice.js +51 -0
  71. package/dist/engines/imprint.d.ts +30 -0
  72. package/dist/engines/imprint.js +152 -0
  73. package/dist/engines/planet/appearance.d.ts +40 -0
  74. package/dist/engines/planet/appearance.js +84 -0
  75. package/dist/engines/planet/aspects.d.ts +5 -0
  76. package/dist/engines/planet/aspects.js +41 -0
  77. package/dist/engines/planet/math.d.ts +13 -0
  78. package/dist/engines/planet/math.js +56 -0
  79. package/dist/engines/planet/orbital.d.ts +25 -0
  80. package/dist/engines/planet/orbital.js +223 -0
  81. package/dist/engines/planet/planet.d.ts +13 -0
  82. package/dist/engines/planet/planet.js +198 -0
  83. package/dist/engines/planet/position.d.ts +62 -0
  84. package/dist/engines/planet/position.js +156 -0
  85. package/dist/engines/planet/types.d.ts +61 -0
  86. package/dist/engines/planet/types.js +14 -0
  87. package/dist/engines/score/api.d.ts +54 -0
  88. package/dist/engines/score/api.js +87 -0
  89. package/dist/engines/score/articulation.d.ts +6 -0
  90. package/dist/engines/score/articulation.js +112 -0
  91. package/dist/engines/score/emitters/midi.d.ts +65 -0
  92. package/dist/engines/score/emitters/midi.js +158 -0
  93. package/dist/engines/score/emitters/musicxml.d.ts +18 -0
  94. package/dist/engines/score/emitters/musicxml.js +166 -0
  95. package/dist/engines/score/infer.d.ts +4 -0
  96. package/dist/engines/score/infer.js +77 -0
  97. package/dist/engines/score/ir.d.ts +4 -0
  98. package/dist/engines/score/ir.js +177 -0
  99. package/dist/engines/score/meta.d.ts +19 -0
  100. package/dist/engines/score/meta.js +34 -0
  101. package/dist/engines/score/neume.d.ts +3 -0
  102. package/dist/engines/score/neume.js +26 -0
  103. package/dist/engines/score/parse.d.ts +3 -0
  104. package/dist/engines/score/parse.js +359 -0
  105. package/dist/engines/score/phrasing.d.ts +24 -0
  106. package/dist/engines/score/phrasing.js +257 -0
  107. package/dist/engines/score/prosody.d.ts +35 -0
  108. package/dist/engines/score/prosody.js +109 -0
  109. package/dist/engines/score/tabula.d.ts +70 -0
  110. package/dist/engines/score/tabula.js +109 -0
  111. package/dist/engines/score/types.d.ts +159 -0
  112. package/dist/engines/score/types.js +2 -0
  113. package/dist/engines/temper/api.d.ts +60 -0
  114. package/dist/engines/temper/api.js +130 -0
  115. package/dist/engines/temper/data/constants.d.ts +27 -0
  116. package/dist/engines/temper/data/constants.js +150 -0
  117. package/dist/engines/temper/data/guido.d.ts +14 -0
  118. package/dist/engines/temper/data/guido.js +29 -0
  119. package/dist/engines/temper/data/modes.d.ts +38 -0
  120. package/dist/engines/temper/data/modes.js +158 -0
  121. package/dist/engines/temper/gabc.d.ts +5 -0
  122. package/dist/engines/temper/gabc.js +53 -0
  123. package/dist/engines/temper/gamut.d.ts +9 -0
  124. package/dist/engines/temper/gamut.js +24 -0
  125. package/dist/engines/temper/guido.d.ts +16 -0
  126. package/dist/engines/temper/guido.js +48 -0
  127. package/dist/engines/temper/interval.d.ts +15 -0
  128. package/dist/engines/temper/interval.js +31 -0
  129. package/dist/engines/temper/modes.d.ts +6 -0
  130. package/dist/engines/temper/modes.js +13 -0
  131. package/dist/engines/temper/neume.d.ts +14 -0
  132. package/dist/engines/temper/neume.js +59 -0
  133. package/dist/engines/temper/pitch.d.ts +40 -0
  134. package/dist/engines/temper/pitch.js +129 -0
  135. package/dist/engines/temper/scale.d.ts +37 -0
  136. package/dist/engines/temper/scale.js +217 -0
  137. package/dist/engines/temper/step.d.ts +23 -0
  138. package/dist/engines/temper/step.js +53 -0
  139. package/dist/index.d.ts +40 -0
  140. package/dist/index.js +27 -0
  141. package/package.json +60 -0
@@ -0,0 +1,166 @@
1
+ // ── XML helpers ──
2
+ function xmlEscape(s) {
3
+ return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;")
4
+ .replace(/"/g, "&quot;").replace(/'/g, "&apos;");
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,4 @@
1
+ import type { ChantType, Score } from "./types.js";
2
+ export declare function inferChantType(ir: Score): ChantType | undefined;
3
+ export declare function inferMode(ir: Score): number | undefined;
4
+ //# sourceMappingURL=infer.d.ts.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,4 @@
1
+ import type { Score, ParseResult } from "./types.js";
2
+ import type { Scale } from "../temper/scale.js";
3
+ export declare function buildIR(parsed: ParseResult, chant: Score["chant"], scale: Scale): Score;
4
+ //# sourceMappingURL=ir.d.ts.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,3 @@
1
+ import type { Neume, Note } from "./types.js";
2
+ export declare function classifyNeume(notes: Note[]): Neume;
3
+ //# sourceMappingURL=neume.d.ts.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
@@ -0,0 +1,3 @@
1
+ import type { ParseOptions, ParseResult } from "./types.js";
2
+ export declare function parseGABC(gabc: string, options?: ParseOptions): ParseResult;
3
+ //# sourceMappingURL=parse.d.ts.map