tonus 0.1.2 → 0.1.3

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 CHANGED
@@ -35,6 +35,17 @@ section of the relevant page.
35
35
  semiological approach → [docs/score.md](docs/score.md#sources)
36
36
  - **Desrocquettes, "Gregorian Musical Values"** — Solesmes rhythmic
37
37
  values → [docs/score.md](docs/score.md#sources)
38
+ - **Niedermeyer & d'Ortigue, _Gregorian Accompaniment_ (trans. Goodrich)** —
39
+ the per-mode cadence figures → [docs/tuning.md](docs/tuning.md#sources)
40
+ - **Bragers, _A Short Treatise on Gregorian Accompaniment_ (1934)** —
41
+ cadence figures, cross-check → [docs/tuning.md](docs/tuning.md#sources)
42
+ - **Homan, _Cadence in Gregorian Chant_ (1961)** — analytic study of
43
+ cadences → [docs/score.md](docs/score.md#sources)
44
+ - **Murray, "Accentual Cadences in Gregorian Chant" (1958)** — spondaic and
45
+ dactylic verbal cadences → [docs/score.md](docs/score.md#sources)
46
+ - **Suñol, _Textbook of Gregorian Chant According to the Solesmes Method_
47
+ (1930)** — modes, dominants, psalmody, the Solesmes rhythm doctrine →
48
+ [docs/tuning.md](docs/tuning.md#sources)
38
49
  - **Apel, _Gregorian Chant_ (1958)** — analytic study of the repertoire →
39
50
  [docs/chant.md](docs/chant.md#sources)
40
51
  - **Hiley, _Western Plainchant_ (1993)** — the standard reference →
package/README.md CHANGED
@@ -93,6 +93,11 @@ const harmony = tonus.harmonia(tonus.caelum({ date: feast.date }));
93
93
  // each visible planet voiced as pitch and Greek vowel, after Boethius
94
94
  ```
95
95
 
96
+ ## Roadmap
97
+
98
+ SVG chant engraving, an interactive documentation site,
99
+ and cadence detection is laid out in [ROADMAP.md](ROADMAP.md).
100
+
96
101
  ## Development
97
102
 
98
103
  ```sh
@@ -9,6 +9,7 @@ export interface PsalmTone {
9
9
  intonation: number[];
10
10
  flex: number[];
11
11
  mediant: number[];
12
+ solemnMediant?: number[];
12
13
  defaultDiff: string;
13
14
  differentiae: Differentia[];
14
15
  }
@@ -3,6 +3,10 @@ export interface IntoneOpts {
3
3
  mode?: number;
4
4
  differentia?: string;
5
5
  intonation?: boolean;
6
+ /** Sing in directum: recite straight through to the termination, no mediant. */
7
+ inDirectum?: boolean;
8
+ /** Use the tone's ornamented mediant for solemn occasions, where it has one. */
9
+ solemn?: boolean;
6
10
  }
7
11
  export declare function intone(text: string | PsalmVerse, opts?: IntoneOpts): string;
8
12
  //# sourceMappingURL=intone.d.ts.map
@@ -63,12 +63,16 @@ export function intone(text, opts = {}) {
63
63
  const tone = getTone(mode);
64
64
  const diff = getDifferentia(tone, opts.differentia);
65
65
  const tenorLetter = midiToGabc(tone.tenor, clef);
66
- const mediantLetters = midiListToLetters(tone.mediant, clef);
66
+ const mediant = opts.solemn && tone.solemnMediant ? tone.solemnMediant : tone.mediant;
67
+ const mediantLetters = midiListToLetters(mediant, clef);
67
68
  const terminationLetters = midiListToLetters(diff.termination, clef);
68
69
  const intonationLetters = midiListToLetters(tone.intonation, clef);
69
- const starIdx = rawText.indexOf(" * ");
70
+ // In directum ignores the verse's mediant split: the whole verse recites to
71
+ // the termination as one phrase (a psalm sung with no antiphon framing).
72
+ const starIdx = opts.inDirectum ? -1 : rawText.indexOf(" * ");
70
73
  if (starIdx === -1) {
71
- const syllables = syllabifyPhrase(rawText.trim());
74
+ const text = opts.inDirectum ? rawText.replace(" * ", " ") : rawText;
75
+ const syllables = syllabifyPhrase(text.trim());
72
76
  const body = buildHalf(syllables, tenorLetter, terminationLetters);
73
77
  return CLEF + body + "(::)";
74
78
  }
@@ -27,8 +27,8 @@ function lookupVerses(psalm, verse) {
27
27
  results = results.filter((v) => v.verse === verse);
28
28
  return results;
29
29
  }
30
- function verseToChant(v, mode, differentia, intonation) {
31
- const gabc = intone(v, { mode, differentia, intonation });
30
+ function verseToChant(v, mode, differentia, intonation, inDirectum, solemn) {
31
+ const gabc = intone(v, { mode, differentia, intonation, inDirectum, solemn });
32
32
  return {
33
33
  id: `psalm:${v.psalm}:${v.verse}`,
34
34
  incipit: v.half1.slice(0, 40),
@@ -56,7 +56,7 @@ export function getPsalm(query) {
56
56
  if (!verses.length)
57
57
  return [];
58
58
  const mode = query.mode ?? 8;
59
- return verses.map((v) => verseToChant(v, mode, query.differentia, query.intonatio));
59
+ return verses.map((v) => verseToChant(v, mode, query.differentia, query.intonatio, query.inDirectum, query.solemn));
60
60
  }
61
61
  /**
62
62
  * A contiguous verse range of one psalm, intoned — e.g. `getPsalmRange(30, 2, 6)`
@@ -64,6 +64,10 @@ export interface PsalmusQuery {
64
64
  mode?: number;
65
65
  differentia?: string;
66
66
  intonatio?: boolean;
67
+ /** Sing in directum: straight through to the termination, no mediant. */
68
+ inDirectum?: boolean;
69
+ /** Use the ornamented solemn mediant, where the tone has one. */
70
+ solemn?: boolean;
67
71
  }
68
72
  export interface PsalmVerse {
69
73
  psalm: number;
@@ -1,5 +1,6 @@
1
1
  import { type Imprint } from "../imprint.js";
2
2
  import { type Prosody } from "./prosody.js";
3
+ import { type Cadence } from "./cadence.js";
3
4
  import { type ChantTabulaRow } from "./tabula.js";
4
5
  import { type MidiOpts, type MidiEmitResult } from "./emitters/midi.js";
5
6
  import { type MusicXmlOpts, type MusicXmlEmitResult } from "./emitters/musicxml.js";
@@ -29,6 +30,8 @@ export interface Score {
29
30
  errors: ParseError[];
30
31
  tabula: ChantTabulaRow[];
31
32
  prosody: Prosody;
33
+ /** Mode-specific cadence at each phrase-ending divisio. */
34
+ cadences: Cadence[];
32
35
  imprint: Imprint;
33
36
  /**
34
37
  * Emit a Standard MIDI File from the score's tabula. Returns the file bytes
@@ -49,6 +52,7 @@ export interface Score {
49
52
  */
50
53
  export declare function buildScore(chant: Chant, opts?: ScoreOpts): Score;
51
54
  export type { ParseError };
55
+ export type { Cadence, CadenceTarget, CadenceApproach } from "./cadence.js";
52
56
  export type { MidiOpts, MidiEmitResult, MidiJsonResult, MidiJsonEvent } from "./emitters/midi.js";
53
57
  export type { MusicXmlOpts, MusicXmlEmitResult } from "./emitters/musicxml.js";
54
58
  //# sourceMappingURL=api.d.ts.map
@@ -7,7 +7,9 @@ import { buildRatios } from "../temper/scale.js";
7
7
  import { computeMeta } from "./meta.js";
8
8
  import { computeImprint } from "../imprint.js";
9
9
  import { computeProsody } from "./prosody.js";
10
+ import { detectCadences } from "./cadence.js";
10
11
  import { computeTabula } from "./tabula.js";
12
+ import { MODES } from "../temper/modes.js";
11
13
  import { toMidi } from "./emitters/midi.js";
12
14
  import { toMusicXML } from "./emitters/musicxml.js";
13
15
  const PONDUS_TO_ARTICULATION = {
@@ -56,10 +58,14 @@ export function buildScore(chant, opts) {
56
58
  });
57
59
  const ir = buildIR(parsed, chant, scale);
58
60
  const meta = computeMeta(ir, { mode: modeNum });
61
+ // Cadence detection runs here, where the resolved mode (and its cadence
62
+ // figures) is in hand. Pure data — mirrors the arsis/thesis pass in ir.ts.
63
+ const cadences = detectCadences(ir.phrases, meta.mode != null ? MODES.get(meta.mode) : undefined);
59
64
  const tabula = computeTabula(ir, {
60
65
  mode: meta.mode ?? undefined,
61
66
  a4Hz: opts?.temperamentum?.a4,
62
67
  transpose: opts?.temperamentum?.transpose,
68
+ cadences,
63
69
  // Only pass phrasing when the caller asked for it, so the default
64
70
  // tabula shaping (mode-gated) is unchanged.
65
71
  interpretation: opts?.accentus
@@ -75,6 +81,7 @@ export function buildScore(chant, opts) {
75
81
  errors: ir.errors,
76
82
  tabula,
77
83
  prosody: computeProsody(ir.phrases),
84
+ cadences,
78
85
  imprint: computeImprint(ir.phrases, scale),
79
86
  midi(emitOpts) {
80
87
  return toMidi(tabula, emitOpts);
@@ -23,6 +23,9 @@ const BASE_WEIGHTS = {
23
23
  repercussionPrevWeight: 0.5,
24
24
  repercussionPrevDuration: 0.4,
25
25
  repercussionOriscusWeight: -0.5,
26
+ oriscusWeight: -0.3, // soft, light note
27
+ oriscusDuration: -0.15, // taken slightly faster (accelerando)
28
+ oriscusPrevWeight: 0.3, // rhythmic support on the preceding note
26
29
  breakWeight: 0.6,
27
30
  dashWeight: -0.8,
28
31
  dashDuration: -0.2,
@@ -0,0 +1,38 @@
1
+ import type { Phrase } from "./types.js";
2
+ import type { ModeData } from "../temper/data/modes.js";
3
+ export type CadenceTarget = "finalis" | "tenor" | "other";
4
+ export type CadenceApproach = "descending" | "ascending" | "unison";
5
+ export interface Cadence {
6
+ /** Index of the phrase this cadence closes. */
7
+ phraseIndex: number;
8
+ /** The divisio bar that ends the phrase: "," "`" ";" ":" "::". */
9
+ divisio: string;
10
+ /**
11
+ * Where the phrase came to rest, from the final note's modal role. Medial
12
+ * cadences often rest on the tenor or elsewhere
13
+ */
14
+ target: CadenceTarget;
15
+ /** Melodic contour into the resolution, across the observed window. */
16
+ approach: CadenceApproach;
17
+ /** Matched finalis-cadence figure id (e.g. "mi-re"), or null. */
18
+ formula: string | null;
19
+ /** The observed final pitch-class run — the evidence, resolution note last. */
20
+ pcs: number[];
21
+ /**
22
+ * The window as diatonic steps relative to the resolution target (0 = target,
23
+ * -1 = the note below), resolution last — the surface the catalog matches on.
24
+ * Empty when there is no mode/target; null entries are notes off the scale.
25
+ */
26
+ steps: Array<number | null>;
27
+ /** 0–1: how cleanly the ending lands, raised by a catalog match. */
28
+ confidence: number;
29
+ /** Note positions forming this cadence: [phraseIndex, syllableIndex, noteIndex]. */
30
+ notes: Array<[number, number, number]>;
31
+ }
32
+ /**
33
+ * Detect the cadence closing each phrase. One Cadence per phrase that carries a
34
+ * divisio. With no mode, targets/approach are still classified but no figure is
35
+ * named (formula: null), matching the score's graceful-degradation convention.
36
+ */
37
+ export declare function detectCadences(phrases: Phrase[], modeData: ModeData | undefined): Cadence[];
38
+ //# sourceMappingURL=cadence.d.ts.map
@@ -0,0 +1,179 @@
1
+ // Cadence formulae run four to ten notes (Homan, Cadence in Gregorian Chant,
2
+ // 1961, p. xiii). Take a window at the upper end so the longest figures fit,
3
+ // with room for the approach; tail-matching ignores the extra leading notes.
4
+ const WINDOW = 8;
5
+ /** Last up-to-WINDOW notes of a phrase, resolution note last. */
6
+ function phraseFinalWindow(phrase) {
7
+ const window = [];
8
+ outer: for (let si = phrase.syllables.length - 1; si >= 0; si--) {
9
+ const notes = phrase.syllables[si].notes;
10
+ for (let ni = notes.length - 1; ni >= 0; ni--) {
11
+ const note = notes[ni];
12
+ window.push({
13
+ pc: note.step.pc,
14
+ midi: note.pitch.midi,
15
+ role: note.step.role,
16
+ syllableIndex: si,
17
+ noteIndex: ni,
18
+ });
19
+ if (window.length >= WINDOW)
20
+ break outer;
21
+ }
22
+ }
23
+ window.reverse();
24
+ return window;
25
+ }
26
+ function classifyTarget(final) {
27
+ if (!final)
28
+ return "other";
29
+ if (final.role === "finalis")
30
+ return "finalis";
31
+ if (final.role === "tenor")
32
+ return "tenor";
33
+ return "other";
34
+ }
35
+ function classifyApproach(window) {
36
+ if (window.length < 2)
37
+ return "unison";
38
+ const slope = window[window.length - 1].midi - window[0].midi;
39
+ if (slope < 0)
40
+ return "descending";
41
+ if (slope > 0)
42
+ return "ascending";
43
+ return "unison";
44
+ }
45
+ /**
46
+ * Signed diatonic step of pitch class `pc` relative to target pc `on`, within
47
+ * the mode's 7-note scale: 0 = the target, +1 = one scale step above, -1 = the
48
+ * note below, wrapping by octave so it stays in a small signed range. Returns
49
+ * null for a pc outside the mode's scale (e.g. a chromatic inflection).
50
+ */
51
+ function diatonicStep(pc, on, scalePcs) {
52
+ const iPc = scalePcs.indexOf(((pc % 12) + 12) % 12);
53
+ const iOn = scalePcs.indexOf(((on % 12) + 12) % 12);
54
+ if (iPc === -1 || iOn === -1)
55
+ return null;
56
+ const n = scalePcs.length;
57
+ let d = iPc - iOn;
58
+ // Fold to the nearest octave so a cadence's small leaps read as small steps.
59
+ while (d > n / 2)
60
+ d -= n;
61
+ while (d < -n / 2)
62
+ d += n;
63
+ return d;
64
+ }
65
+ /**
66
+ * Collapse consecutive equal steps to one. A chant that lands on the final and
67
+ * repeats it (a final distropha, say) should still match the plain figure the
68
+ * treatises write with one note per pitch. Figures have no adjacent repeats, so
69
+ * collapsing is a no-op on them.
70
+ */
71
+ function collapseRepeats(steps) {
72
+ const out = [];
73
+ for (const s of steps) {
74
+ if (out.length === 0 || out[out.length - 1] !== s)
75
+ out.push(s);
76
+ }
77
+ return out;
78
+ }
79
+ /**
80
+ * Match a collapsed step-run against a catalog figure. Both end on the
81
+ * resolution (0), so compare from the tail backward. Returns a fraction in
82
+ * 0..1: how much of the figure the ending realises (0 = no tail match).
83
+ */
84
+ function figureMatch(observed, figure) {
85
+ if (figure.length === 0 || observed.length === 0)
86
+ return 0;
87
+ let matched = 0;
88
+ for (let k = 1; k <= figure.length && k <= observed.length; k++) {
89
+ if (observed[observed.length - k] === figure[figure.length - k])
90
+ matched++;
91
+ else
92
+ break;
93
+ }
94
+ return matched / figure.length;
95
+ }
96
+ /**
97
+ * The best catalog figure for an ending. A figure the ending realises in full
98
+ * (frac === 1) is preferred, and among those the longest — the most specific
99
+ * description — wins, so e.g. sol-fa-mi beats its own fa-mi suffix. Failing a
100
+ * full match, the highest partial fraction is kept (a weaker signal).
101
+ */
102
+ function bestFigure(observed, figures) {
103
+ const collapsed = collapseRepeats(observed);
104
+ let best = null;
105
+ for (const figure of figures) {
106
+ const frac = figureMatch(collapsed, figure.steps);
107
+ if (frac === 0)
108
+ continue;
109
+ if (!best) {
110
+ best = { figure, frac };
111
+ continue;
112
+ }
113
+ // Rank: a full match outranks any partial; among full matches the longer
114
+ // figure wins; otherwise the higher fraction.
115
+ const bestFull = best.frac === 1;
116
+ const thisFull = frac === 1;
117
+ if (thisFull && !bestFull)
118
+ best = { figure, frac };
119
+ else if (thisFull &&
120
+ bestFull &&
121
+ figure.steps.length > best.figure.steps.length)
122
+ best = { figure, frac };
123
+ else if (!thisFull && !bestFull && frac > best.frac)
124
+ best = { figure, frac };
125
+ }
126
+ return best;
127
+ }
128
+ /**
129
+ * Detect the cadence closing each phrase. One Cadence per phrase that carries a
130
+ * divisio. With no mode, targets/approach are still classified but no figure is
131
+ * named (formula: null), matching the score's graceful-degradation convention.
132
+ */
133
+ export function detectCadences(phrases, modeData) {
134
+ const cadences = [];
135
+ for (let pi = 0; pi < phrases.length; pi++) {
136
+ const phrase = phrases[pi];
137
+ if (!phrase.divisio)
138
+ continue;
139
+ const window = phraseFinalWindow(phrase);
140
+ if (window.length === 0)
141
+ continue;
142
+ const divisio = phrase.divisio.divisio;
143
+ const finalNote = window[window.length - 1];
144
+ const target = classifyTarget(finalNote);
145
+ const approach = classifyApproach(window);
146
+ const pcs = window.map((w) => w.pc);
147
+ // A clean landing on finalis/tenor is confident on its own; a catalog match
148
+ // raises it further. No modal role → a weak baseline.
149
+ let confidence = target === "other" ? 0.3 : 0.6;
150
+ let formula = null;
151
+ let steps = [];
152
+ if (modeData && (target === "finalis" || target === "tenor")) {
153
+ // Express the window as diatonic steps relative to the note it resolved
154
+ // onto. The catalogue holds final cadences, so only match on the finalis.
155
+ const onPc = target === "finalis" ? modeData.final : modeData.tenor;
156
+ steps = window.map((w) => diatonicStep(w.pc, onPc, modeData.scalePcs));
157
+ if (target === "finalis") {
158
+ const match = bestFigure(steps, modeData.cadences);
159
+ if (match) {
160
+ formula = match.figure.id;
161
+ confidence = Math.min(1, confidence + 0.4 * match.frac);
162
+ }
163
+ }
164
+ }
165
+ cadences.push({
166
+ phraseIndex: pi,
167
+ divisio,
168
+ target,
169
+ approach,
170
+ formula,
171
+ pcs,
172
+ steps,
173
+ confidence: Math.round(confidence * 100) / 100,
174
+ notes: window.map((w) => [pi, w.syllableIndex, w.noteIndex]),
175
+ });
176
+ }
177
+ return cadences;
178
+ }
179
+ //# sourceMappingURL=cadence.js.map
@@ -23,13 +23,22 @@ function rawToNote(raw, scale) {
23
23
  quilisma: raw.quilisma,
24
24
  liquescent: raw.liquescent,
25
25
  strophicus: raw.strophicus,
26
+ oriscus: raw.oriscus,
26
27
  doubleEpisema: raw.doubleEpisema,
27
28
  weight: raw.weight,
28
29
  },
29
30
  };
30
31
  }
32
+ // The salicus ictus note (its second-to-last ascending note) is prolonged —
33
+ // Suñol, Textbook Ch. V. Modest, in the spirit of an episema lengthening.
34
+ const SALICUS_PROLONGATION = 1.3;
31
35
  function makeSyllable(lyric, notes) {
32
- return { lyric, notes, neume: classifyNeume(notes) };
36
+ const neume = classifyNeume(notes);
37
+ if (neume.type === "salicus" && notes.length >= 2) {
38
+ const ictic = notes[notes.length - 2];
39
+ ictic.performance.duration *= SALICUS_PROLONGATION;
40
+ }
41
+ return { lyric, notes, neume };
33
42
  }
34
43
  function partitionByIctus(annotated) {
35
44
  const groups = [];
@@ -14,11 +14,13 @@ export function classifyNeume(notes) {
14
14
  for (let i = 1; i < notes.length; i++) {
15
15
  intervals.push(notes[i].pitch.midi - notes[i - 1].pitch.midi);
16
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) {
17
+ const dirs = intervals.map(toDirection);
18
+ let type = classifyShape(dirs);
19
+ // Salicus: an ascending run whose ictus (GABC `'`) marks it apart from a plain
20
+ // scandicus. The ictus sits on the second-to-last ascending note — the middle
21
+ // note of a three-note salicus, the penultimate of a longer one (Suñol).
22
+ const allAscending = dirs.length >= 2 && dirs.every((d) => d === "up");
23
+ if (allAscending && notes[notes.length - 2].context.ictus) {
22
24
  type = "salicus";
23
25
  }
24
26
  return { type, intervals, hasQuilisma, hasLiquescent, hasStrophicus };
@@ -83,6 +83,7 @@ function parseNeume(notation, context) {
83
83
  let isQuilisma = false;
84
84
  let isLiquescent = false;
85
85
  let isStrophicus = false;
86
+ let isOriscus = false;
86
87
  let isDoubleEpisema = false;
87
88
  // Dash prefix (weak note)
88
89
  if (token[0] === "-") {
@@ -200,6 +201,16 @@ function parseNeume(notation, context) {
200
201
  w += weights.uppercaseWeight;
201
202
  durWeight += weights.uppercaseDuration;
202
203
  }
204
+ // Oriscus (o = soft, light note taken slightly faster; the rhythmic
205
+ // support falls on the note before it). Suñol, Textbook Ch. V.
206
+ if (modifiers.includes("o")) {
207
+ w += weights.oriscusWeight;
208
+ durWeight += weights.oriscusDuration;
209
+ isOriscus = true;
210
+ const before = intermed.length > 0 ? intermed[intermed.length - 1] : null;
211
+ if (before)
212
+ before._weight += weights.oriscusPrevWeight;
213
+ }
203
214
  // Repercussion (same pitch as previous note)
204
215
  const prev = intermed.length > 0 ? intermed[intermed.length - 1] : null;
205
216
  if (prev && step === prev.step) {
@@ -221,6 +232,7 @@ function parseNeume(notation, context) {
221
232
  quilisma: isQuilisma,
222
233
  liquescent: isLiquescent,
223
234
  strophicus: isStrophicus,
235
+ oriscus: isOriscus,
224
236
  doubleEpisema: isDoubleEpisema,
225
237
  _weight: w,
226
238
  _durWeight: durWeight,
@@ -275,6 +287,7 @@ function parseNeume(notation, context) {
275
287
  quilisma: note.quilisma,
276
288
  liquescent: note.liquescent,
277
289
  strophicus: note.strophicus,
290
+ oriscus: note.oriscus,
278
291
  doubleEpisema: note.doubleEpisema,
279
292
  };
280
293
  });
@@ -1,4 +1,5 @@
1
1
  import type { Score, Neume } from "./types.js";
2
+ import type { Cadence } from "./cadence.js";
2
3
  import type { ChantType, InterpretationOptions } from "./types.js";
3
4
  export type NoteRole = "finalis" | "tenor" | "other" | null;
4
5
  export interface ChantTabulaRow {
@@ -40,7 +41,10 @@ export interface ChantTabulaRow {
40
41
  quilisma: boolean;
41
42
  liquescent: boolean;
42
43
  strophicus: boolean;
44
+ oriscus: boolean;
43
45
  divisio: string | null;
46
+ /** Index into score.cadences[] when this note forms a cadence; null otherwise. */
47
+ cadenceRef: number | null;
44
48
  /** Modal role: "final" | "tenor" | "mod" (modulation) | null if no mode or no match */
45
49
  role: NoteRole;
46
50
  /** Guidonian short name (e.g. "g", "aa") — null for chromatic pitches with no gamut entry */
@@ -65,6 +69,8 @@ export interface TabulaOptions {
65
69
  interpretation?: InterpretationOptions;
66
70
  a4Hz?: number;
67
71
  transpose?: number;
72
+ /** Detected cadences; used to stamp each row's cadenceRef. */
73
+ cadences?: Cadence[];
68
74
  }
69
75
  export declare function computeTabula(ir: Score, options?: TabulaOptions): ChantTabulaRow[];
70
76
  //# sourceMappingURL=tabula.d.ts.map
@@ -54,6 +54,15 @@ export function computeTabula(ir, options = {}) {
54
54
  shapedDurations[i] = shaped[i].shapedDuration;
55
55
  }
56
56
  }
57
+ // Map each cadence's constituent notes back to its index, keyed by position.
58
+ const cadenceRefByPos = new Map();
59
+ if (options.cadences) {
60
+ for (let ci = 0; ci < options.cadences.length; ci++) {
61
+ for (const [pi, si, ni] of options.cadences[ci].notes) {
62
+ cadenceRefByPos.set(`${pi}:${si}:${ni}`, ci);
63
+ }
64
+ }
65
+ }
57
66
  // Position of each note within its neume figure — resets when the
58
67
  // (syllableIndex, neumeGroup) pair changes.
59
68
  const neumeIndices = [];
@@ -94,7 +103,9 @@ export function computeTabula(ir, options = {}) {
94
103
  quilisma: n.context.quilisma,
95
104
  liquescent: n.context.liquescent,
96
105
  strophicus: n.context.strophicus,
106
+ oriscus: n.context.oriscus,
97
107
  divisio: a.divisio,
108
+ cadenceRef: cadenceRefByPos.get(`${a.phraseIndex}:${a.syllableIndex}:${a.noteIndex}`) ?? null,
98
109
  role: n.step.role,
99
110
  name: n.step.name,
100
111
  nomen: n.step.nomen,
@@ -20,6 +20,7 @@ export interface Context {
20
20
  quilisma: boolean;
21
21
  liquescent: boolean;
22
22
  strophicus: boolean;
23
+ oriscus: boolean;
23
24
  doubleEpisema: boolean;
24
25
  weight: number;
25
26
  }
@@ -55,6 +56,9 @@ export interface ArticulationWeights {
55
56
  repercussionPrevWeight: number;
56
57
  repercussionPrevDuration: number;
57
58
  repercussionOriscusWeight: number;
59
+ oriscusWeight: number;
60
+ oriscusDuration: number;
61
+ oriscusPrevWeight: number;
58
62
  breakWeight: number;
59
63
  dashWeight: number;
60
64
  dashDuration: number;
@@ -118,6 +122,7 @@ export interface ParsedNote {
118
122
  quilisma: boolean;
119
123
  liquescent: boolean;
120
124
  strophicus: boolean;
125
+ oriscus: boolean;
121
126
  doubleEpisema: boolean;
122
127
  }
123
128
  export interface RestEvent {
@@ -4,7 +4,7 @@ import type { Step, StepVariant, Finger, Region } from "./step.js";
4
4
  import type { Interval, IntervalDirection, IntervalQuality } from "./interval.js";
5
5
  import type { Neume, NeumeShape } from "./neume.js";
6
6
  import type { GamutOptions } from "./gamut.js";
7
- import type { ModeData, ModeProfile } from "./modes.js";
7
+ import type { ModeData, ModeProfile, CadenceFigure } from "./modes.js";
8
8
  import type { GuidonianEntry, GuidonianVariant } from "./guido.js";
9
9
  export type BuiltinTuning = "pythagorean" | "meantone" | "equal" | "ptolemy-intense" | "ptolemy-soft" | "ptolemy-equable";
10
10
  export type Tuning = BuiltinTuning | string;
@@ -56,5 +56,5 @@ export interface Temperamentum {
56
56
  * @throws Error on invalid tuning, scale, or mode input.
57
57
  */
58
58
  export declare function buildTemper(input?: TemperamentumInput): Temperamentum;
59
- export type { Scale, ScaleOpts, ScalaFile, RatioResult, Pitch, PitchInput, Step, StepVariant, Finger, Region, Neume, NeumeShape, Interval, IntervalDirection, IntervalQuality, ModeData, ModeProfile, GamutOptions, GuidonianEntry, GuidonianVariant, };
59
+ export type { Scale, ScaleOpts, ScalaFile, RatioResult, Pitch, PitchInput, Step, StepVariant, Finger, Region, Neume, NeumeShape, Interval, IntervalDirection, IntervalQuality, ModeData, ModeProfile, CadenceFigure, GamutOptions, GuidonianEntry, GuidonianVariant, };
60
60
  //# sourceMappingURL=api.d.ts.map
@@ -1,9 +1,14 @@
1
1
  export interface ModeProfile {
2
2
  mood: string;
3
+ ethos: string;
3
4
  phrasing: "recitative" | "lyrical" | "hymnic" | "solemn";
4
5
  melodic: "rising" | "falling" | "arch" | "neutral";
5
6
  tendency: "melismatic" | "neumatic" | "syllabic" | "neutral";
6
7
  }
8
+ export interface CadenceFigure {
9
+ id: string;
10
+ steps: number[];
11
+ }
7
12
  export interface ModeData {
8
13
  mode: number;
9
14
  nomen: string;
@@ -15,10 +20,7 @@ export interface ModeData {
15
20
  scalePcs: number[];
16
21
  hexachords: ("durum" | "naturale" | "molle")[];
17
22
  profile: ModeProfile;
18
- cadences: {
19
- final: number[];
20
- tenor: number[];
21
- };
23
+ cadences: CadenceFigure[];
22
24
  modulations: {
23
25
  regular: number[];
24
26
  conceded: number[];
@@ -14,8 +14,13 @@ export const MODES = new Map([
14
14
  tenor: 9,
15
15
  scalePcs: [2, 4, 5, 7, 9, 11, 0],
16
16
  hexachords: ["naturale"],
17
- profile: { mood: "serious", phrasing: "lyrical", melodic: "falling", tendency: "melismatic" },
18
- cadences: { final: [5, 4, 2], tenor: [9, 2] },
17
+ profile: { mood: "serious", ethos: "gravis", phrasing: "lyrical", melodic: "falling", tendency: "melismatic" },
18
+ cadences: [
19
+ { id: "mi-re", steps: [1, 0] },
20
+ { id: "ut-re", steps: [-1, 0] },
21
+ { id: "sol-fa-re", steps: [3, 2, 0] },
22
+ { id: "mi-fa-re", steps: [1, 2, 0] },
23
+ ],
19
24
  modulations: { regular: [2, 9, 5, 7], conceded: [12, 4], initials: [2, 5, 7, 9, 14] },
20
25
  ambitus: { lowest: 2, highest: 21, span: 19 },
21
26
  species: { fifth: [2, 9], fourth: [9, 2] },
@@ -33,8 +38,13 @@ export const MODES = new Map([
33
38
  tenor: 5,
34
39
  scalePcs: [2, 4, 5, 7, 9, 11, 0],
35
40
  hexachords: ["naturale"],
36
- profile: { mood: "sad", phrasing: "lyrical", melodic: "arch", tendency: "neumatic" },
37
- cadences: { final: [8, 7, 5], tenor: [0, 5] },
41
+ profile: { mood: "sad", ethos: "tristis", phrasing: "lyrical", melodic: "arch", tendency: "neumatic" },
42
+ cadences: [
43
+ { id: "mi-re", steps: [1, 0] },
44
+ { id: "ut-re", steps: [-1, 0] },
45
+ { id: "sol-fa-re", steps: [3, 2, 0] },
46
+ { id: "mi-fa-re", steps: [1, 2, 0] },
47
+ ],
38
48
  modulations: { regular: [2, 5, 7, 9], conceded: [0, 4], initials: [0, 2, 4, 5, 7, 9] },
39
49
  ambitus: { lowest: 0, highest: 17, span: 17 },
40
50
  species: { fifth: [2, 9], fourth: [9, 2] },
@@ -52,8 +62,12 @@ export const MODES = new Map([
52
62
  tenor: 0,
53
63
  scalePcs: [4, 5, 7, 9, 11, 0, 2],
54
64
  hexachords: ["naturale"],
55
- profile: { mood: "mystic", phrasing: "solemn", melodic: "falling", tendency: "melismatic" },
56
- cadences: { final: [7, 6, 4], tenor: [0, 4] },
65
+ profile: { mood: "mystic", ethos: "mysticus", phrasing: "solemn", melodic: "falling", tendency: "melismatic" },
66
+ cadences: [
67
+ { id: "fa-mi", steps: [1, 0] },
68
+ { id: "re-mi", steps: [-1, 0] },
69
+ { id: "sol-fa-mi", steps: [2, 1, 0] },
70
+ ],
57
71
  modulations: { regular: [4, 0, 7, 9], conceded: [5, 11], initials: [0, 2, 4, 7, 9] },
58
72
  ambitus: { lowest: 4, highest: 16, span: 12 },
59
73
  species: { fifth: [4, 11], fourth: [11, 4] },
@@ -71,8 +85,12 @@ export const MODES = new Map([
71
85
  tenor: 9,
72
86
  scalePcs: [4, 5, 7, 9, 11, 0, 2],
73
87
  hexachords: ["naturale"],
74
- profile: { mood: "harmonious", phrasing: "lyrical", melodic: "arch", tendency: "neumatic" },
75
- cadences: { final: [7, 6, 4], tenor: [9, 4] },
88
+ profile: { mood: "harmonious", ethos: "harmonicus", phrasing: "lyrical", melodic: "arch", tendency: "neumatic" },
89
+ cadences: [
90
+ { id: "fa-mi", steps: [1, 0] },
91
+ { id: "re-mi", steps: [-1, 0] },
92
+ { id: "sol-fa-mi", steps: [2, 1, 0] },
93
+ ],
76
94
  modulations: { regular: [4, 9, 7, 0], conceded: [11], initials: [0, 4, 7, 9] },
77
95
  ambitus: { lowest: 2, highest: 21, span: 19 },
78
96
  species: { fifth: [4, 11], fourth: [11, 4] },
@@ -90,8 +108,12 @@ export const MODES = new Map([
90
108
  tenor: 0,
91
109
  scalePcs: [5, 7, 9, 11, 0, 2, 4],
92
110
  hexachords: ["molle"],
93
- profile: { mood: "happy", phrasing: "solemn", melodic: "rising", tendency: "melismatic" },
94
- cadences: { final: [8, 7, 5], tenor: [0, 5] },
111
+ profile: { mood: "happy", ethos: "laetus", phrasing: "solemn", melodic: "rising", tendency: "melismatic" },
112
+ cadences: [
113
+ { id: "mi-fa", steps: [-1, 0] },
114
+ { id: "fa-mi-fa", steps: [0, -1, 0] },
115
+ { id: "la-sol-fa", steps: [2, 1, 0] },
116
+ ],
95
117
  modulations: { regular: [5, 0, 7, 12], conceded: [9, 2], initials: [5, 7, 9, 0, 12] },
96
118
  ambitus: { lowest: 5, highest: 17, span: 12 },
97
119
  species: { fifth: [5, 0], fourth: [0, 5] },
@@ -109,8 +131,12 @@ export const MODES = new Map([
109
131
  tenor: 9,
110
132
  scalePcs: [5, 7, 9, 11, 0, 2, 4],
111
133
  hexachords: ["molle"],
112
- profile: { mood: "devout", phrasing: "lyrical", melodic: "arch", tendency: "neumatic" },
113
- cadences: { final: [8, 7, 5], tenor: [9, 5] },
134
+ profile: { mood: "devout", ethos: "devotus", phrasing: "lyrical", melodic: "arch", tendency: "neumatic" },
135
+ cadences: [
136
+ { id: "mi-fa", steps: [-1, 0] },
137
+ { id: "fa-mi-fa", steps: [0, -1, 0] },
138
+ { id: "la-sol-fa", steps: [2, 1, 0] },
139
+ ],
114
140
  modulations: { regular: [5, 9, 0, 7], conceded: [2, 12], initials: [0, 2, 4, 5, 7, 9] },
115
141
  ambitus: { lowest: 3, highest: 21, span: 18 },
116
142
  species: { fifth: [5, 0], fourth: [0, 5] },
@@ -128,8 +154,13 @@ export const MODES = new Map([
128
154
  tenor: 2,
129
155
  scalePcs: [7, 9, 11, 0, 2, 4, 5],
130
156
  hexachords: ["durum"],
131
- profile: { mood: "angelical", phrasing: "solemn", melodic: "rising", tendency: "melismatic" },
132
- cadences: { final: [10, 9, 7], tenor: [2, 7] },
157
+ profile: { mood: "angelical", ethos: "angelicus", phrasing: "solemn", melodic: "rising", tendency: "melismatic" },
158
+ cadences: [
159
+ { id: "la-sol", steps: [1, 0] },
160
+ { id: "fa-sol", steps: [-1, 0] },
161
+ { id: "ut-sol", steps: [3, 0] },
162
+ { id: "ut-ti-sol", steps: [3, 2, 0] },
163
+ ],
133
164
  modulations: { regular: [7, 2, 9, 14], conceded: [5, 0], initials: [7, 9, 11, 2, 14] },
134
165
  ambitus: { lowest: 7, highest: 19, span: 12 },
135
166
  species: { fifth: [7, 2], fourth: [2, 7] },
@@ -147,8 +178,13 @@ export const MODES = new Map([
147
178
  tenor: 0,
148
179
  scalePcs: [7, 9, 11, 0, 2, 4, 5],
149
180
  hexachords: ["durum"],
150
- profile: { mood: "perfect", phrasing: "lyrical", melodic: "arch", tendency: "neumatic" },
151
- cadences: { final: [10, 9, 7], tenor: [0, 7] },
181
+ profile: { mood: "perfect", ethos: "perfectus", phrasing: "lyrical", melodic: "arch", tendency: "neumatic" },
182
+ cadences: [
183
+ { id: "la-sol", steps: [1, 0] },
184
+ { id: "fa-sol", steps: [-1, 0] },
185
+ { id: "ut-sol", steps: [3, 0] },
186
+ { id: "ut-ti-sol", steps: [3, 2, 0] },
187
+ ],
152
188
  modulations: { regular: [7, 0, 9, 14], conceded: [2, 5], initials: [0, 2, 4, 7, 9, 12, 14] },
153
189
  ambitus: { lowest: 5, highest: 19, span: 14 },
154
190
  species: { fifth: [7, 2], fourth: [2, 7] },
@@ -1,5 +1,5 @@
1
1
  import type { ModeData } from "./data/modes.js";
2
- export type { ModeProfile, ModeData } from "./data/modes.js";
2
+ export type { ModeProfile, ModeData, CadenceFigure } from "./data/modes.js";
3
3
  export { MODES } from "./data/modes.js";
4
4
  /** Return ModeData for mode 1–8. Throws on unknown mode. */
5
5
  export declare function getMode(mode: number): ModeData;
package/dist/index.d.ts CHANGED
@@ -10,8 +10,8 @@ import { getCosmos } from "./engines/planet/planet.js";
10
10
  import { buildHarmonia } from "./engines/harmonia/api.js";
11
11
  import type { FeastQuery, Feast, Pascha, Season, Grade } from "./engines/cal/types.js";
12
12
  import type { CantusQuery, Chant, OrdinaryChant, PropriumQuery, OrdinariumQuery, OfficiumQuery, PsalmusQuery } from "./engines/chant/types.js";
13
- import type { TemperamentumInput, Temperamentum, Tuning, TemperamentumOpts, Pitch, PitchInput, Step, Neume, NeumeShape, Interval, ModeData, GamutOptions, Tonus, TonusOpts } from "./engines/temper/api.js";
14
- import type { Score, ScoreOpts, PondusInput, PondusOpts, AccentusInput, AccentusOpts, MidiOpts, MidiEmitResult, MidiJsonResult, MidiJsonEvent, MusicXmlOpts, MusicXmlEmitResult } from "./engines/score/api.js";
13
+ import type { TemperamentumInput, Temperamentum, Tuning, TemperamentumOpts, Pitch, PitchInput, Step, Neume, NeumeShape, Interval, ModeData, CadenceFigure, GamutOptions, Tonus, TonusOpts } from "./engines/temper/api.js";
14
+ import type { Score, ScoreOpts, PondusInput, PondusOpts, AccentusInput, AccentusOpts, Cadence, CadenceTarget, CadenceApproach, MidiOpts, MidiEmitResult, MidiJsonResult, MidiJsonEvent, MusicXmlOpts, MusicXmlEmitResult } from "./engines/score/api.js";
15
15
  import type { ChantTabulaRow } from "./engines/score/tabula.js";
16
16
  import type { Imprint, Attractor, VowelAttractor, ModalAffinity } from "./engines/imprint.js";
17
17
  import type { Prosody, RhythmicProfile, NoteRange, CadenceDistribution } from "./engines/score/prosody.js";
@@ -36,5 +36,5 @@ declare const tonus: {
36
36
  };
37
37
  export default tonus;
38
38
  export { SEASON_LABELS, TEMPUS_NAMES, GRADE_ORDER, GRADE_NAMES, gradeOrder, compareGrade, ritusToGrade, } from "./engines/cal/types.js";
39
- export type { Feast, FeastQuery, Pascha, Season, Grade, Chant, CantusQuery, OrdinaryChant, PropriumQuery, OrdinariumQuery, OfficiumQuery, PsalmusQuery, Temperamentum, TemperamentumInput, TemperamentumOpts, Tuning, Pitch, PitchInput, Step, Neume, NeumeShape, Interval, ModeData, GamutOptions, Tonus, TonusOpts, Score, ScoreOpts, PondusInput, PondusOpts, AccentusInput, AccentusOpts, MidiOpts, MidiEmitResult, MidiJsonResult, MidiJsonEvent, MusicXmlOpts, MusicXmlEmitResult, ChantTabulaRow, Note, Performance, Phrase, Syllable, RestEvent, ParseError, ArsisThesis, VoicedPitch, Cosmos, CosmosQuery, Body, BodyName, Aspect, Imprint, Attractor, VowelAttractor, ModalAffinity, Prosody, RhythmicProfile, NoteRange, CadenceDistribution, Harmony, HarmoniaOpts, VoicedBody, VoicedAspect, Frame, Author, HarmonyTabulaRow, PlanetVowel, };
39
+ export type { Feast, FeastQuery, Pascha, Season, Grade, Chant, CantusQuery, OrdinaryChant, PropriumQuery, OrdinariumQuery, OfficiumQuery, PsalmusQuery, Temperamentum, TemperamentumInput, TemperamentumOpts, Tuning, Pitch, PitchInput, Step, Neume, NeumeShape, Interval, ModeData, CadenceFigure, GamutOptions, Tonus, TonusOpts, Score, ScoreOpts, PondusInput, PondusOpts, AccentusInput, AccentusOpts, Cadence, CadenceTarget, CadenceApproach, MidiOpts, MidiEmitResult, MidiJsonResult, MidiJsonEvent, MusicXmlOpts, MusicXmlEmitResult, ChantTabulaRow, Note, Performance, Phrase, Syllable, RestEvent, ParseError, ArsisThesis, VoicedPitch, Cosmos, CosmosQuery, Body, BodyName, Aspect, Imprint, Attractor, VowelAttractor, ModalAffinity, Prosody, RhythmicProfile, NoteRange, CadenceDistribution, Harmony, HarmoniaOpts, VoicedBody, VoicedAspect, Frame, Author, HarmonyTabulaRow, PlanetVowel, };
40
40
  //# sourceMappingURL=index.d.ts.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tonus",
3
- "version": "0.1.2",
3
+ "version": "0.1.3",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "description": "Medieval music analysis and performance: GABC plainchant exports, liturgical calendar, tuning systems, ephemeris, and the harmony of the spheres",