tonus 0.1.3 → 0.1.6

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 (65) hide show
  1. package/BIBLIOGRAPHY.md +143 -96
  2. package/CHANGELOG.md +62 -0
  3. package/README.md +7 -9
  4. package/dist/data/am.d.ts +5 -0
  5. package/dist/data/am.js +14299 -0
  6. package/dist/data/office-monastic.d.ts +3 -0
  7. package/dist/data/office-monastic.js +6956 -0
  8. package/dist/data/office-psalms-monastic.d.ts +3 -0
  9. package/dist/data/office-psalms-monastic.js +21 -0
  10. package/dist/data/{office-psalms.d.ts → office-psalms-roman.d.ts} +1 -1
  11. package/dist/data/{office-psalms.js → office-psalms-roman.js} +3 -3
  12. package/dist/engines/cal/calendar.js +16 -1
  13. package/dist/engines/cal/date.js +5 -0
  14. package/dist/engines/chant/chant.js +2 -0
  15. package/dist/{data → engines/chant/data}/compline.d.ts +1 -1
  16. package/dist/{data → engines/chant/data}/masses.d.ts +1 -1
  17. package/dist/{data → engines/chant/data}/masses.js +1 -1
  18. package/dist/{data → engines/chant/data}/prime.d.ts +1 -1
  19. package/dist/{data → engines/chant/data}/prime.js +1 -1
  20. package/dist/engines/chant/hour.js +37 -22
  21. package/dist/engines/chant/intone.js +6 -1
  22. package/dist/engines/chant/ordinary.js +1 -1
  23. package/dist/engines/chant/psalm.d.ts +4 -4
  24. package/dist/engines/chant/psalm.js +6 -4
  25. package/dist/engines/chant/types.d.ts +5 -1
  26. package/dist/engines/harmonia/api.js +11 -0
  27. package/dist/engines/harmonia/data/doctrines.js +27 -1
  28. package/dist/engines/imprint.d.ts +8 -7
  29. package/dist/engines/imprint.js +31 -27
  30. package/dist/engines/planet/position.js +51 -5
  31. package/dist/engines/score/api.d.ts +4 -0
  32. package/dist/engines/score/api.js +11 -1
  33. package/dist/engines/score/articulation.js +60 -3
  34. package/dist/engines/score/cadence.js +3 -3
  35. package/dist/engines/score/emitters/midi.js +6 -2
  36. package/dist/engines/score/infer.js +13 -1
  37. package/dist/engines/score/ir.d.ts +2 -1
  38. package/dist/engines/score/ir.js +54 -12
  39. package/dist/engines/score/modulation.d.ts +20 -0
  40. package/dist/engines/score/modulation.js +74 -0
  41. package/dist/engines/score/parse.js +16 -8
  42. package/dist/engines/score/phrasing.js +26 -0
  43. package/dist/engines/score/prosody.js +5 -0
  44. package/dist/engines/score/tabula.d.ts +2 -0
  45. package/dist/engines/score/tabula.js +1 -0
  46. package/dist/engines/score/types.d.ts +11 -2
  47. package/dist/engines/temper/api.d.ts +17 -1
  48. package/dist/engines/temper/api.js +20 -2
  49. package/dist/engines/temper/data/guido.d.ts +3 -2
  50. package/dist/engines/temper/data/guido.js +30 -25
  51. package/dist/engines/temper/data/modes.js +3 -0
  52. package/dist/{data → engines/temper/data}/tones.js +1 -1
  53. package/dist/engines/temper/gabc.js +25 -2
  54. package/dist/engines/temper/guido.d.ts +3 -2
  55. package/dist/engines/temper/interval.js +12 -0
  56. package/dist/engines/temper/modality.d.ts +11 -0
  57. package/dist/engines/temper/modality.js +58 -0
  58. package/dist/engines/temper/scale.js +33 -2
  59. package/dist/engines/temper/step.d.ts +2 -2
  60. package/dist/index.d.ts +5 -5
  61. package/package.json +2 -1
  62. package/dist/data/office.d.ts +0 -12
  63. package/dist/data/office.js +0 -13052
  64. /package/dist/{data → engines/chant/data}/compline.js +0 -0
  65. /package/dist/{data → engines/temper/data}/tones.d.ts +0 -0
@@ -62,6 +62,9 @@ export function buildPhrasing(type = "lyrical", options = {}) {
62
62
  ...(options.overrides ?? {}),
63
63
  };
64
64
  }
65
+ // Authentic modes reach a fifth or more above the final and pull harder toward
66
+ // their tenor/reciting note; plagal modes straddle the final and pull less
67
+ // [biblio: sunol-textbook]. These nudge the tenor and cadence gains accordingly.
65
68
  const MODE_TYPE_TENOR_MULTIPLIER = {
66
69
  authentic: 1.08,
67
70
  plagal: 0.92,
@@ -70,12 +73,20 @@ const MODE_TYPE_CADENCE_MULTIPLIER = {
70
73
  authentic: 1.05,
71
74
  plagal: 0.95,
72
75
  };
76
+ // A more melismatic mode leans into its cadences; a syllabic one under-plays them.
73
77
  const MODE_TENDENCY_CADENCE_MULTIPLIER = {
74
78
  melismatic: 1.06,
75
79
  neumatic: 1.0,
76
80
  syllabic: 0.96,
77
81
  neutral: 1.0,
78
82
  };
83
+ // The modal ethos → performance mapping. Each mode carries a mood epithet (the
84
+ // Niedermeyer & d'Ortigue tradition [biblio: niedermeyer-ortigue], stored on
85
+ // ModeProfile in modes.ts); these translate that mood into small shaping deltas.
86
+ // The interpretation is tonus's own — the epithets are sourced, these numbers
87
+ // are a tuned editorial gesture, deliberately small. Note the coherence: sadder
88
+ // moods drop the velocity floor AND deepen the phrase curve (below), so they
89
+ // read quieter and more arched; brighter moods do the reverse.
79
90
  const MODE_MOOD_BASE_VELOCITY_DELTA = {
80
91
  neutral: 0,
81
92
  serious: -0.01,
@@ -144,9 +155,17 @@ const DURATION_ARCH_FACTOR = 0.2;
144
155
  const DURATION_MIN = 0.2;
145
156
  const DURATION_MAX = 4.0;
146
157
  const TENOR_GAIN = 0.05;
158
+ // Tenor pull falls linearly with pitch-class distance from the reciting tone and
159
+ // reaches zero at 6 semitones (a tritone) — the far side of the octave, past
160
+ // which a note no longer reads as gravitating to the tenor.
147
161
  const TENOR_DISTANCE_DIVISOR = 6;
148
162
  const CADENCE_VELOCITY_FACTOR = 0.5;
149
163
  const CADENCE_DURATION_FACTOR = 0.6;
164
+ // The phrasing-side reading of the divisio (bar-line) hierarchy. This is the same
165
+ // bar hierarchy tabulated canonically in docs/score.md and weighted for analysis
166
+ // by prosody.ts's cadenceWeight ladder — but here the weights differ on purpose:
167
+ // this is a *shaping* factor, so the virgula (`) gets 0 (a breath, no cadential
168
+ // stress), where the analytic ladder still counts it. Do not unify the three.
150
169
  const DIVISIO_STRENGTH = {
151
170
  "::": 1.0,
152
171
  ":": 0.7,
@@ -214,8 +233,15 @@ export function applyPhrasing(events, profile, tenorPc) {
214
233
  const arsis = note.context.weight;
215
234
  const arsisRelative = (arsis - minArsis) / arsisSpan;
216
235
  const contourRelative = (note.pitch.midi - minStep) / stepSpan;
236
+ // A raised-cosine (Hann) window over the phrase: 0 at the ends, 1 at the
237
+ // middle, so each phrase swells and subsides — the Solesmes grand rythme,
238
+ // the phrase-arch [biblio: mocquereau-nombre]. `t` is the note's fractional
239
+ // position through the phrase.
217
240
  const t = (order + 0.5) / (noteEntries.length + 0.0001);
218
241
  const arch = 0.5 - 0.5 * Math.cos(2 * Math.PI * t);
242
+ // Blend the note's intrinsic arsic weight against its positional arch;
243
+ // `curve` is the mix knob (0 = follow the note's own weight, 1 = follow the
244
+ // phrase arch). The result is then re-centred on 0.5 and spread by velSpread.
219
245
  let velocity = arsisRelative * (1 - profile.curve) + arch * profile.curve;
220
246
  velocity += (contourRelative - VELOCITY_CENTER) * profile.contourVel;
221
247
  velocity =
@@ -55,6 +55,11 @@ export function computeProsody(phrases) {
55
55
  }
56
56
  }
57
57
  if (phrase.divisio) {
58
+ // Analytic bar-importance weights, one rung per divisio in the bar-line
59
+ // hierarchy (the canonical table is in docs/score.md). These are a
60
+ // MEASUREMENT — every divisio counts, including the virgula (`) — distinct
61
+ // from phrasing.ts's DIVISIO_STRENGTH, which zeroes the virgula because it
62
+ // is a shaping factor, not a count.
58
63
  const d = phrase.divisio.divisio;
59
64
  if (d === "::") {
60
65
  cadDist.doubleBar++;
@@ -42,6 +42,8 @@ export interface ChantTabulaRow {
42
42
  liquescent: boolean;
43
43
  strophicus: boolean;
44
44
  oriscus: boolean;
45
+ /** Mora vocis count: 0 none, 1 dot '.', 2 double dot '..' (a stronger cadential hold). */
46
+ mora: 0 | 1 | 2;
45
47
  divisio: string | null;
46
48
  /** Index into score.cadences[] when this note forms a cadence; null otherwise. */
47
49
  cadenceRef: number | null;
@@ -104,6 +104,7 @@ export function computeTabula(ir, options = {}) {
104
104
  liquescent: n.context.liquescent,
105
105
  strophicus: n.context.strophicus,
106
106
  oriscus: n.context.oriscus,
107
+ mora: n.context.mora,
107
108
  divisio: a.divisio,
108
109
  cadenceRef: cadenceRefByPos.get(`${a.phraseIndex}:${a.syllableIndex}:${a.noteIndex}`) ?? null,
109
110
  role: n.step.role,
@@ -3,6 +3,11 @@ import type { Step } from "../temper/step.js";
3
3
  import type { NeumeShape } from "../temper/neume.js";
4
4
  import type { OfficeCode, OrdinaryCode } from "../chant/types.js";
5
5
  export type ArsisThesis = "arsic" | "thetic";
6
+ export type RhythmicType = "IV" | "V" | "VI" | "VII" | "VIII" | null;
7
+ export interface CompoundBeat {
8
+ shape: ArsisThesis;
9
+ noteCount: number;
10
+ }
6
11
  export interface Performance {
7
12
  velocity: number;
8
13
  duration: number;
@@ -21,7 +26,7 @@ export interface Context {
21
26
  liquescent: boolean;
22
27
  strophicus: boolean;
23
28
  oriscus: boolean;
24
- doubleEpisema: boolean;
29
+ mora: 0 | 1 | 2;
25
30
  weight: number;
26
31
  }
27
32
  export interface Note {
@@ -123,7 +128,7 @@ export interface ParsedNote {
123
128
  liquescent: boolean;
124
129
  strophicus: boolean;
125
130
  oriscus: boolean;
126
- doubleEpisema: boolean;
131
+ mora: 0 | 1 | 2;
127
132
  }
128
133
  export interface RestEvent {
129
134
  type: "rest";
@@ -151,6 +156,10 @@ export interface Syllable {
151
156
  export interface Phrase {
152
157
  syllables: Syllable[];
153
158
  divisio?: RestEvent;
159
+ /** The phrase's compound beats in order — the A/T sequence of the incise. */
160
+ beats: CompoundBeat[];
161
+ /** Le Guennant/Carroll rhythmic type of the incise; null if none fits. */
162
+ rhythmicType: RhythmicType;
154
163
  }
155
164
  export interface Score {
156
165
  chant: {
@@ -28,6 +28,22 @@ export interface Tonus {
28
28
  mediatio: Pitch[];
29
29
  terminatio: Pitch[];
30
30
  }
31
+ /** A pitch resolved through the tuning, with its Guidonian annotation. */
32
+ export interface TunedNote {
33
+ pitch: Pitch;
34
+ step: Step;
35
+ }
36
+ /**
37
+ * A mode's reference data (ModeData), enriched with its structural pitches
38
+ * tuned through the temperamentum that returned it. `modus()`. Cadence figures
39
+ * stay in their diatonic-step form on `cadences` — they are transposition-
40
+ * relative by design.
41
+ */
42
+ export interface Modus extends ModeData {
43
+ finalis: TunedNote;
44
+ reciting: TunedNote;
45
+ ambitusNotes: TunedNote[];
46
+ }
31
47
  export interface Temperamentum {
32
48
  tuning: Tuning;
33
49
  mode: number | "auto";
@@ -45,7 +61,7 @@ export interface Temperamentum {
45
61
  step: Step | null;
46
62
  };
47
63
  gamut(opts?: GamutOptions): Pitch[];
48
- modus(mode: number): ModeData;
64
+ modus(mode: number): Modus;
49
65
  tonus(opts?: TonusOpts): Tonus;
50
66
  }
51
67
  /**
@@ -8,7 +8,7 @@ import { classifyInterval } from "./interval.js";
8
8
  import { buildNeume } from "./neume.js";
9
9
  import { buildGamut } from "./gamut.js";
10
10
  import { getMode } from "./modes.js";
11
- import { getTone, getDifferentia } from "../../data/tones.js";
11
+ import { getTone, getDifferentia } from "./data/tones.js";
12
12
  function resolveOpts(input) {
13
13
  if (!input)
14
14
  return {};
@@ -110,7 +110,25 @@ export function buildTemper(input) {
110
110
  return buildGamut(scala, gamutOpts);
111
111
  },
112
112
  modus(mode) {
113
- return getMode(mode);
113
+ const data = getMode(mode);
114
+ // The mode's degrees are stored as semitone offsets from C (pc 0), with
115
+ // values past 12 in the upper octave. Anchor them at C4 (MIDI 60).
116
+ const tuned = (offset) => ({
117
+ pitch: toPitch(60 + offset, scala),
118
+ step: toStep(60 + offset, scala),
119
+ });
120
+ const scaleSet = new Set(data.scalePcs);
121
+ const ambitusNotes = [];
122
+ for (let off = data.ambitus.lowest; off <= data.ambitus.highest; off++) {
123
+ if (scaleSet.has(((off % 12) + 12) % 12))
124
+ ambitusNotes.push(tuned(off));
125
+ }
126
+ return {
127
+ ...data,
128
+ finalis: tuned(data.final),
129
+ reciting: tuned(data.tenor),
130
+ ambitusNotes,
131
+ };
114
132
  },
115
133
  tonus(tonusOpts) {
116
134
  if (modeVal === "auto")
@@ -1,11 +1,12 @@
1
+ import type { Finger, Region } from "../step.js";
1
2
  export interface GuidonianVariant {
2
3
  hexachord: "durum" | "naturale" | "molle";
3
4
  solmization: "UT" | "RE" | "MI" | "FA" | "SOL" | "LA";
4
5
  }
5
6
  export interface GuidonianEntry {
6
7
  hand: {
7
- finger: string;
8
- region: string;
8
+ finger: Finger;
9
+ region: Region;
9
10
  };
10
11
  name: [string, string];
11
12
  variants: GuidonianVariant[];
@@ -1,29 +1,34 @@
1
- // ---------------------------------------------------------------------------
2
- // engines/temper/data/guido — Guidonian gamut and hexachord variants
3
- // ---------------------------------------------------------------------------
1
+ // The hand loci follow the CANONICAL Guidonian spiral, not a linear fill:
2
+ // begin at the thumb tip, come down the thumb, cross the finger BASES
3
+ // index→little, climb the little finger, return across the finger TIPS
4
+ // little→index, descend the index, then spiral inward; ee (76) floats above the
5
+ // middle fingertip (region "super"). The two b/♮ pairs (58/59, 70/71) share a
6
+ // locus by design — one joint, two readings (fa in molle, mi in durum), the
7
+ // tradition, not a duplication to fix. After Suñol [biblio: sunol-textbook] and
8
+ // Apel [biblio: apel-chant]; realized in working/diagram-hand-08.html.
4
9
  // prettier-ignore
5
10
  export const GUIDONIAN_DATA = new Map([
6
- [43, { hand: { finger: "base", region: "palm" }, name: ["Γ", "Gammaut"], variants: [{ hexachord: "durum", solmization: "UT" }] }],
7
- [45, { hand: { finger: "wrist", region: "center" }, name: ["A", "Are"], variants: [{ hexachord: "durum", solmization: "RE" }] }],
8
- [47, { hand: { finger: "wrist", region: "inner" }, name: ["B", "Bemi"], variants: [{ hexachord: "durum", solmization: "MI" }] }],
9
- [48, { hand: { finger: "palm", region: "base" }, name: ["C", "Cefaut"], variants: [{ hexachord: "durum", solmization: "FA" }, { hexachord: "naturale", solmization: "UT" }] }],
10
- [50, { hand: { finger: "palm", region: "middle" }, name: ["D", "Desolre"], variants: [{ hexachord: "durum", solmization: "SOL" }, { hexachord: "naturale", solmization: "RE" }] }],
11
- [52, { hand: { finger: "palm", region: "top" }, name: ["E", "Elami"], variants: [{ hexachord: "durum", solmization: "LA" }, { hexachord: "naturale", solmization: "MI" }] }],
12
- [53, { hand: { finger: "thumb", region: "base" }, name: ["F", "Fefaut"], variants: [{ hexachord: "naturale", solmization: "FA" }, { hexachord: "molle", solmization: "UT" }] }],
13
- [55, { hand: { finger: "thumb", region: "mid" }, name: ["G", "Gesolreut"], variants: [{ hexachord: "durum", solmization: "UT" }, { hexachord: "naturale", solmization: "SOL" }, { hexachord: "molle", solmization: "RE" }] }],
14
- [57, { hand: { finger: "thumb", region: "tip" }, name: ["a", "Alamire"], variants: [{ hexachord: "durum", solmization: "RE" }, { hexachord: "naturale", solmization: "LA" }, { hexachord: "molle", solmization: "MI" }] }],
15
- [58, { hand: { finger: "index", region: "base" }, name: ["b", "Befa"], variants: [{ hexachord: "molle", solmization: "FA" }] }],
16
- [59, { hand: { finger: "index", region: "mid" }, name: ["♮", "Bemi"], variants: [{ hexachord: "durum", solmization: "MI" }] }],
17
- [60, { hand: { finger: "index", region: "tip" }, name: ["c", "Cesolfaut"], variants: [{ hexachord: "durum", solmization: "FA" }, { hexachord: "naturale", solmization: "UT" }, { hexachord: "molle", solmization: "SOL" }] }],
18
- [62, { hand: { finger: "middle", region: "base" }, name: ["d", "Delasolre"], variants: [{ hexachord: "durum", solmization: "SOL" }, { hexachord: "naturale", solmization: "RE" }, { hexachord: "molle", solmization: "LA" }] }],
19
- [64, { hand: { finger: "middle", region: "mid" }, name: ["e", "Elami"], variants: [{ hexachord: "durum", solmization: "LA" }, { hexachord: "naturale", solmization: "MI" }] }],
20
- [65, { hand: { finger: "middle", region: "tip" }, name: ["f", "Fefaut"], variants: [{ hexachord: "naturale", solmization: "FA" }, { hexachord: "molle", solmization: "UT" }] }],
21
- [67, { hand: { finger: "ring", region: "base" }, name: ["g", "Gesolreut"], variants: [{ hexachord: "durum", solmization: "UT" }, { hexachord: "naturale", solmization: "SOL" }, { hexachord: "molle", solmization: "RE" }] }],
22
- [69, { hand: { finger: "ring", region: "mid" }, name: ["aa", "Alamire"], variants: [{ hexachord: "durum", solmization: "RE" }, { hexachord: "naturale", solmization: "LA" }, { hexachord: "molle", solmization: "MI" }] }],
23
- [70, { hand: { finger: "ring", region: "tip" }, name: ["bb", "Befa"], variants: [{ hexachord: "molle", solmization: "FA" }] }],
24
- [71, { hand: { finger: "pinky", region: "base" }, name: ["♮♮", "Bemi"], variants: [{ hexachord: "durum", solmization: "MI" }] }],
25
- [72, { hand: { finger: "pinky", region: "mid" }, name: ["cc", "Cesolfa"], variants: [{ hexachord: "durum", solmization: "FA" }, { hexachord: "naturale", solmization: "UT" }, { hexachord: "molle", solmization: "SOL" }] }],
26
- [74, { hand: { finger: "pinky", region: "top" }, name: ["dd", "Delasol"], variants: [{ hexachord: "durum", solmization: "SOL" }, { hexachord: "naturale", solmization: "RE" }, { hexachord: "molle", solmization: "LA" }] }],
27
- [76, { hand: { finger: "pinky", region: "tip" }, name: ["ee", "Ela"], variants: [{ hexachord: "durum", solmization: "LA" }, { hexachord: "naturale", solmization: "MI" }] }],
11
+ [43, { hand: { finger: "thumb", region: "tip" }, name: ["Γ", "Gammaut"], variants: [{ hexachord: "durum", solmization: "UT" }] }],
12
+ [45, { hand: { finger: "thumb", region: "mid" }, name: ["A", "Are"], variants: [{ hexachord: "durum", solmization: "RE" }] }],
13
+ [47, { hand: { finger: "thumb", region: "base" }, name: ["B", "Bemi"], variants: [{ hexachord: "durum", solmization: "MI" }] }],
14
+ [48, { hand: { finger: "index", region: "base" }, name: ["C", "Cefaut"], variants: [{ hexachord: "durum", solmization: "FA" }, { hexachord: "naturale", solmization: "UT" }] }],
15
+ [50, { hand: { finger: "middle", region: "base" }, name: ["D", "Desolre"], variants: [{ hexachord: "durum", solmization: "SOL" }, { hexachord: "naturale", solmization: "RE" }] }],
16
+ [52, { hand: { finger: "ring", region: "base" }, name: ["E", "Elami"], variants: [{ hexachord: "durum", solmization: "LA" }, { hexachord: "naturale", solmization: "MI" }] }],
17
+ [53, { hand: { finger: "pinky", region: "base" }, name: ["F", "Fefaut"], variants: [{ hexachord: "naturale", solmization: "FA" }, { hexachord: "molle", solmization: "UT" }] }],
18
+ [55, { hand: { finger: "pinky", region: "mid" }, name: ["G", "Gesolreut"], variants: [{ hexachord: "durum", solmization: "UT" }, { hexachord: "naturale", solmization: "SOL" }, { hexachord: "molle", solmization: "RE" }] }],
19
+ [57, { hand: { finger: "pinky", region: "top" }, name: ["a", "Alamire"], variants: [{ hexachord: "durum", solmization: "RE" }, { hexachord: "naturale", solmization: "LA" }, { hexachord: "molle", solmization: "MI" }] }],
20
+ [58, { hand: { finger: "pinky", region: "tip" }, name: ["b", "Befa"], variants: [{ hexachord: "molle", solmization: "FA" }] }],
21
+ [59, { hand: { finger: "pinky", region: "tip" }, name: ["♮", "Bemi"], variants: [{ hexachord: "durum", solmization: "MI" }] }],
22
+ [60, { hand: { finger: "ring", region: "tip" }, name: ["c", "Cesolfaut"], variants: [{ hexachord: "durum", solmization: "FA" }, { hexachord: "naturale", solmization: "UT" }, { hexachord: "molle", solmization: "SOL" }] }],
23
+ [62, { hand: { finger: "middle", region: "tip" }, name: ["d", "Delasolre"], variants: [{ hexachord: "durum", solmization: "SOL" }, { hexachord: "naturale", solmization: "RE" }, { hexachord: "molle", solmization: "LA" }] }],
24
+ [64, { hand: { finger: "index", region: "tip" }, name: ["e", "Elami"], variants: [{ hexachord: "durum", solmization: "LA" }, { hexachord: "naturale", solmization: "MI" }] }],
25
+ [65, { hand: { finger: "index", region: "top" }, name: ["f", "Fefaut"], variants: [{ hexachord: "naturale", solmization: "FA" }, { hexachord: "molle", solmization: "UT" }] }],
26
+ [67, { hand: { finger: "index", region: "mid" }, name: ["g", "Gesolreut"], variants: [{ hexachord: "durum", solmization: "UT" }, { hexachord: "naturale", solmization: "SOL" }, { hexachord: "molle", solmization: "RE" }] }],
27
+ [69, { hand: { finger: "middle", region: "mid" }, name: ["aa", "Alamire"], variants: [{ hexachord: "durum", solmization: "RE" }, { hexachord: "naturale", solmization: "LA" }, { hexachord: "molle", solmization: "MI" }] }],
28
+ [70, { hand: { finger: "ring", region: "mid" }, name: ["bb", "Befa"], variants: [{ hexachord: "molle", solmization: "FA" }] }],
29
+ [71, { hand: { finger: "ring", region: "mid" }, name: ["♮♮", "Bemi"], variants: [{ hexachord: "durum", solmization: "MI" }] }],
30
+ [72, { hand: { finger: "middle", region: "top" }, name: ["cc", "Cesolfa"], variants: [{ hexachord: "durum", solmization: "FA" }, { hexachord: "naturale", solmization: "UT" }, { hexachord: "molle", solmization: "SOL" }] }],
31
+ [74, { hand: { finger: "ring", region: "top" }, name: ["dd", "Delasol"], variants: [{ hexachord: "durum", solmization: "SOL" }, { hexachord: "naturale", solmization: "RE" }, { hexachord: "molle", solmization: "LA" }] }],
32
+ [76, { hand: { finger: "middle", region: "super" }, name: ["ee", "Ela"], variants: [{ hexachord: "durum", solmization: "LA" }, { hexachord: "naturale", solmization: "MI" }] }],
28
33
  ]);
29
34
  //# sourceMappingURL=guido.js.map
@@ -39,6 +39,9 @@ export const MODES = new Map([
39
39
  scalePcs: [2, 4, 5, 7, 9, 11, 0],
40
40
  hexachords: ["naturale"],
41
41
  profile: { mood: "sad", ethos: "tristis", phrasing: "lyrical", melodic: "arch", tendency: "neumatic" },
42
+ // Shared with mode 1 (both Protus). An earlier seed here was wrong — a copy
43
+ // of mode 5's figures, landing on F with an Ab outside hypodorian's scale;
44
+ // Suñol's tables surfaced it, and it was corrected to the Protus descent.
42
45
  cadences: [
43
46
  { id: "mi-re", steps: [1, 0] },
44
47
  { id: "ut-re", steps: [-1, 0] },
@@ -1,4 +1,4 @@
1
- // humana/data/tones — psalm tones (Graduale Romanum appendix)
1
+ // temper/data/tones — psalm tones (Graduale Romanum appendix)
2
2
  // Pitches as MIDI numbers; tone codes follow DO convention: "1g", "6F", "4e".
3
3
  // c4 clef reference: f=53 g=55 h=57 i=59 j=60 k=62 l=64 (F3–F4)
4
4
  const f = 53, g = 55, h = 57, i = 59, j = 60, k = 62, l = 64;
@@ -1,15 +1,35 @@
1
1
  // ---------------------------------------------------------------------------
2
2
  // engines/temper/gabc — GABC pitch letter utilities
3
3
  // ---------------------------------------------------------------------------
4
+ // GABC pitch letters (a–m) are DIATONIC STAFF POSITIONS, not pitch classes: the
5
+ // thirteen letters are the thirteen slots of the four-line staff and its ledger
6
+ // space, spanning roughly two octaves of white-key steps [biblio: gregorio-gabc].
7
+ // A letter has no fixed pitch on its own — the clef fixes it. So converting to
8
+ // and from MIDI is staff-position arithmetic, done in two moduli: 7 for the
9
+ // diatonic staff (steps per octave) and 12 for MIDI (semitones per octave).
10
+ //
11
+ // DIATONIC maps a diatonic step (0–6, do re mi fa sol la si) to its pitch class.
12
+ // LETTERS is the staff-slot alphabet, low to high.
4
13
  const DIATONIC = [0, 2, 4, 5, 7, 9, 11];
5
14
  const LETTERS = "abcdefghijklm";
15
+ // A GABC clef names the staff line that carries "do" (c-clefs) or "fa" (f-clefs)
16
+ // and thereby anchors every letter. `doIdx` is the LETTERS slot that line falls
17
+ // on; `doMidi` is the MIDI pitch of "do" there (60 = middle C; 53 = the F below).
18
+ // A higher c-clef (c4 vs c1) moves "do" up the staff, so the same letter reads a
19
+ // lower pitch — hence doIdx climbs 3→5→7→9 across c1→c4. The f-clefs anchor on
20
+ // fa (MIDI 53) and are used for lower-tessitura chant.
6
21
  const CLEFS = {
7
22
  c1: { doMidi: 60, doIdx: 3 },
8
23
  c2: { doMidi: 60, doIdx: 5 },
9
24
  c3: { doMidi: 60, doIdx: 7 },
10
25
  c4: { doMidi: 60, doIdx: 9 },
11
- f3: { doMidi: 53, doIdx: 5 },
12
- f4: { doMidi: 53, doIdx: 3 },
26
+ // f-clefs anchor fa on the named line. Staff lines (bottom→top) sit at
27
+ // letters d/f/h/j (per the Gregorio spec: 2-line staff = a–i, 3-line = a–k,
28
+ // 4-line = a–m, pinning the lines at slots 3/5/7/9), so f3 puts fa at 'h'
29
+ // (7) and f4 at 'j' (9). Previous values (5 and 3) were off by a third and
30
+ // read every f-clef chant at the wrong staff position.
31
+ f3: { doMidi: 53, doIdx: 7 },
32
+ f4: { doMidi: 53, doIdx: 9 },
13
33
  };
14
34
  export function midiToGabc(midi, clef = "c4") {
15
35
  const def = CLEFS[clef];
@@ -34,6 +54,9 @@ export function gabcToMidi(letter, clef = "c4") {
34
54
  const staffPos = LETTERS.indexOf(letter.toLowerCase());
35
55
  if (staffPos === -1)
36
56
  throw new Error(`Unknown GABC letter: ${letter}`);
57
+ // Diatonic steps from "do", split into whole octaves (÷7) and the step within
58
+ // the octave. The `((x % 7) + 7) % 7` form keeps the step in 0–6 for letters
59
+ // below "do", where stepsFromDo is negative and JS `%` would return negative.
37
60
  const stepsFromDo = staffPos - def.doIdx;
38
61
  const octOffset = Math.floor(stepsFromDo / 7);
39
62
  const diatStep = ((stepsFromDo % 7) + 7) % 7;
@@ -1,3 +1,4 @@
1
+ import type { Finger, Region } from "./step.js";
1
2
  export type { GuidonianVariant, GuidonianEntry } from "./data/guido.js";
2
3
  export declare function lookupGuido(midi: number, mode?: number): {
3
4
  name: string | null;
@@ -9,8 +10,8 @@ export declare function lookupGuido(midi: number, mode?: number): {
9
10
  solmization: string;
10
11
  }[];
11
12
  hand: {
12
- finger: string;
13
- region: string;
13
+ finger: Finger;
14
+ region: Region;
14
15
  } | null;
15
16
  };
16
17
  //# sourceMappingURL=guido.d.ts.map
@@ -2,6 +2,12 @@
2
2
  // engines/temper/interval — interval classification between pitches
3
3
  // ---------------------------------------------------------------------------
4
4
  import { INTERVAL, UNISONUS } from "./data/constants.js";
5
+ // The three-tier consonance taxonomy [biblio: schulter-harmony] (the same table
6
+ // stated at docs/heavens.md and docs/tuning.md). Note that the perfect fourth is
7
+ // deliberately NOT perfect here: in medieval counterpoint the P4 above the bass
8
+ // is treated as a dissonance, unlike the melodic P4. So P1/P5/P8 are perfect,
9
+ // the thirds and sixths imperfect, and everything else — including P4 and the
10
+ // tritone — dissonant.
5
11
  const PERFECT_CLASSES = new Set(["P1", "P5", "P8"]);
6
12
  const IMPERFECT_CLASSES = new Set(["m3", "M3", "m6", "M6"]);
7
13
  function classifyConsonance(intervalClass) {
@@ -16,6 +22,9 @@ export function classifyInterval(a, b) {
16
22
  const abs = Math.abs(semitones);
17
23
  const simple = abs % 12;
18
24
  const direction = semitones > 0 ? "up" : semitones < 0 ? "down" : "unison";
25
+ // A true zero-distance unison (abs === 0) is UNISONUS; a compound octave
26
+ // (simple === 0 but abs a nonzero multiple of 12) folds to INTERVAL[0], the
27
+ // octave entry. The two share simple === 0 but name different intervals.
19
28
  const entry = abs === 0 ? UNISONUS : (simple === 0 ? INTERVAL[0] : INTERVAL[simple]);
20
29
  return {
21
30
  nomen: entry.latin,
@@ -24,6 +33,9 @@ export function classifyInterval(a, b) {
24
33
  class: entry.class,
25
34
  direction,
26
35
  semitones,
36
+ // Nominal equal-tempered cents (100 per semitone), NOT the tuned distance —
37
+ // the actual sounding interval depends on the temperament (see the ratio a
38
+ // temperamentum reports for a nota, and docs/tuning.md on nominal vs tuned).
27
39
  cents: semitones * 100,
28
40
  consonance: classifyConsonance(entry.class),
29
41
  };
@@ -0,0 +1,11 @@
1
+ export interface ModalAffinity {
2
+ mode: number;
3
+ alias: string;
4
+ score: number;
5
+ }
6
+ /**
7
+ * Rank a pitch-class distribution against the eight modes, best fit first.
8
+ * `firstNotePc`, when given, applies the rank-weighted initials bonus.
9
+ */
10
+ export declare function computeModalAffinity(pcDistribution: Record<number, number>, firstNotePc?: number): ModalAffinity[];
11
+ //# sourceMappingURL=modality.d.ts.map
@@ -0,0 +1,58 @@
1
+ // ---------------------------------------------------------------------------
2
+ // engines/temper/modality — how well pitch content fits each church mode
3
+ // ---------------------------------------------------------------------------
4
+ // Modal theory, not tied to any one caller: the imprint uses it to fingerprint a
5
+ // whole chant, modulation detection to read each phrase. A pure function of a
6
+ // pitch-class distribution (and, optionally, the chant's opening note).
7
+ import { MODES } from "./modes.js";
8
+ // A mode's structural degrees are not equal: the finalis defines it, the tenor
9
+ // anchors its recitation, and modulation degrees are only secondary colour. Time
10
+ // spent on each pitch counts toward the mode in that proportion.
11
+ const FINALIS_WEIGHT = 3;
12
+ const TENOR_WEIGHT = 2;
13
+ const REGULAR_MOD_WEIGHT = 1;
14
+ const CONCEDED_MOD_WEIGHT = 0.5;
15
+ // A chant's opening note is a modal signal: each mode lists its valid initials
16
+ // in rank order, most characteristic first (Rockstro's Grove ordering
17
+ // [biblio: rockstro-grove]). Opening on a mode's primary initial boosts it more
18
+ // than opening on a lower-ranked one — which is what separates an authentic mode
19
+ // from its plagal partner, since the two share a finalis but rank the same
20
+ // opening pitch differently.
21
+ const INITIAL_BONUS = 0.3;
22
+ /**
23
+ * Rank a pitch-class distribution against the eight modes, best fit first.
24
+ * `firstNotePc`, when given, applies the rank-weighted initials bonus.
25
+ */
26
+ export function computeModalAffinity(pcDistribution, firstNotePc) {
27
+ const results = [];
28
+ for (let m = 1; m <= 8; m++) {
29
+ const data = MODES.get(m);
30
+ if (!data)
31
+ continue;
32
+ // Weight each degree by its modal role; a pc that fills more than one role
33
+ // (e.g. a modulation degree that is also the tenor) takes the strongest.
34
+ const degreeWeight = new Map();
35
+ const set = (pc, w) => {
36
+ degreeWeight.set(pc, Math.max(degreeWeight.get(pc) ?? 0, w));
37
+ };
38
+ for (const pc of data.modulations.conceded)
39
+ set(pc % 12, CONCEDED_MOD_WEIGHT);
40
+ for (const pc of data.modulations.regular)
41
+ set(pc % 12, REGULAR_MOD_WEIGHT);
42
+ set(data.tenor, TENOR_WEIGHT);
43
+ set(data.final, FINALIS_WEIGHT);
44
+ let score = 0;
45
+ for (const [pc, w] of degreeWeight)
46
+ score += (pcDistribution[pc] ?? 0) * w;
47
+ // Initials bonus, scaled by how highly the mode ranks the opening pitch.
48
+ if (firstNotePc != null) {
49
+ const initials = data.modulations.initials;
50
+ const rank = initials.findIndex((pc) => pc % 12 === firstNotePc);
51
+ if (rank !== -1)
52
+ score += (INITIAL_BONUS * (initials.length - rank)) / initials.length;
53
+ }
54
+ results.push({ mode: m, alias: data.alias, score });
55
+ }
56
+ return results.sort((a, b) => b.score - a.score);
57
+ }
58
+ //# sourceMappingURL=modality.js.map
@@ -1,6 +1,24 @@
1
1
  // ---------------------------------------------------------------------------
2
2
  // engines/temper/scale — tuning ratio builder
3
3
  // ---------------------------------------------------------------------------
4
+ // Builds the 12 pitch-class ratios for a temperament. The default is pure
5
+ // Pythagorean — all intervals from the 3/2 fifth and the octave — because that
6
+ // is the tuning of medieval theory from Boethius through the Guidonian gamut
7
+ // [biblio: boethius-institutione, guidonian-gamut], and it is correct for
8
+ // unaccompanied chant: melodic fifths/fourths are perfect, the narrow limma
9
+ // (256/243) gives half-steps a keen leading quality, and the wide Pythagorean
10
+ // third (81/64) never has to serve as a consonance.
11
+ //
12
+ // `comma` tempers the fifth toward meantone by narrowing it by a fraction of
13
+ // the syntonic comma (81/80, the gap between the Pythagorean and pure 5/4
14
+ // third): comma "1/4" stacks four fifths to a pure major third — quarter-comma
15
+ // meantone, the 16th-century sound.
16
+ //
17
+ // The `steps` presets supply just-intonation genera in place of the tempered
18
+ // fifth: the three Ptolemaic diatonics (intense/soft/equable, his χρόαι
19
+ // "shades") come straight from Ptolemy's tetrachord divisions [biblio:
20
+ // ptolemy-harmonics, Harmonics I.15–16]. See expandDiatonicSteps for how a
21
+ // 7-ratio genus is laid onto the fixed gamut, and why.
4
22
  import { MODES } from "./modes.js";
5
23
  // Stern-Brocot rational approximation — finds nearest simple fraction
6
24
  function approximate(value, maxDen = 1000) {
@@ -110,7 +128,14 @@ export function parseScala(input) {
110
128
  }
111
129
  const PURE_FIFTH = 3 / 2;
112
130
  const SYNTONIC_COMMA = 81 / 80;
131
+ // The circle of fifths, as chromatic pitch classes: C G D A E B F♯ … stacking
132
+ // twelve 3/2s. buildPythagoreanRatios walks this and octave-folds each.
113
133
  const FIFTH_TO_CHROM = [0, 7, 2, 9, 4, 11, 6, 1, 8, 3, 10, 5];
134
+ // Ptolemy's three diatonic genera [biblio: ptolemy-harmonics, Harmonics I.15–16],
135
+ // each a tetrachord (1/1 … 4/3) doubled up a 3/2 to fill the octave:
136
+ // intense (syntonon) — classical just intonation: pure 5/4 major, 6/5 minor.
137
+ // soft (malakon) — septimal: the 7th harmonic gives a large 8/7 whole tone.
138
+ // equable (homalon) — undecimal: near-equal ~150–182¢ steps.
114
139
  const PTOLEMAIC = {
115
140
  "ptolemy-intense": ["1/1", "9/8", "5/4", "4/3", "3/2", "5/3", "15/8"],
116
141
  "ptolemy-soft": ["1/1", "8/7", "80/63", "4/3", "3/2", "12/7", "40/21"],
@@ -143,8 +168,14 @@ const NATURAL_PCS = [0, 2, 4, 5, 7, 9, 11];
143
168
  // pitch order — NOT onto the mode's scalePcs. A church mode is an octave
144
169
  // species of this one gamut, so its interval qualities emerge from *where the
145
170
  // final sits within the fixed tuning*, handled downstream by normalizeToRoot.
146
- // (Mapping degree-per-mode instead would force major-scale qualities — e.g.
147
- // a 5/4 major third above every final — onto every mode; see docs/tuning.md.)
171
+ // This yields the authentic per-mode qualities (a Dorian minor third, a
172
+ // Mixolydian ♭7) and the honest syntonic wolf (D–A = 40/27 under
173
+ // ptolemy-intense).
174
+ //
175
+ // Mapping the genus degree-per-mode onto scalePcs instead would force
176
+ // major-scale qualities — a 5/4 major third above *every* final — onto every
177
+ // mode. Re-deriving a per-mode just intonation would attribute a modern just
178
+ // tuning to Ptolemy, who described tetrachord divisions, not modal scales.
148
179
  function expandDiatonicSteps(diatonic) {
149
180
  const base = buildPythagoreanRatios(0);
150
181
  const out = base.slice();
@@ -1,6 +1,6 @@
1
1
  import type { Scale } from "./scale.js";
2
- export type Finger = "wrist" | "palm" | "thumb" | "index" | "middle" | "ring" | "pinky";
3
- export type Region = "base" | "mid" | "tip" | "top";
2
+ export type Finger = "thumb" | "index" | "middle" | "ring" | "pinky";
3
+ export type Region = "base" | "mid" | "top" | "tip" | "super";
4
4
  export interface StepVariant {
5
5
  hexachord: "durum" | "naturale" | "molle";
6
6
  solmization: string;
package/dist/index.d.ts CHANGED
@@ -9,16 +9,16 @@ import { buildScore } from "./engines/score/api.js";
9
9
  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
- 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, 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";
12
+ import type { CantusQuery, Chant, OrdinaryChant, PropriumQuery, OrdinariumQuery, OfficiumQuery, PsalmusQuery, Rite } from "./engines/chant/types.js";
13
+ import type { TemperamentumInput, Temperamentum, Tuning, TemperamentumOpts, Pitch, PitchInput, Step, Neume, NeumeShape, Interval, ModeData, CadenceFigure, Modus, TunedNote, GamutOptions, Tonus, TonusOpts } from "./engines/temper/api.js";
14
+ import type { Score, ScoreOpts, PondusInput, PondusOpts, AccentusInput, AccentusOpts, Cadence, CadenceTarget, CadenceApproach, Modulation, 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";
18
18
  import type { Harmony, HarmoniaOpts, VoicedBody, VoicedAspect, Frame, Author } from "./engines/harmonia/api.js";
19
19
  import type { HarmonyTabulaRow } from "./engines/harmonia/tabula.js";
20
20
  import type { PlanetVowel } from "./engines/harmonia/data/vowels.js";
21
- import type { Note, Performance, Phrase, Syllable, RestEvent, ParseError, ArsisThesis } from "./engines/score/types.js";
21
+ import type { Note, Performance, Phrase, Syllable, RestEvent, ParseError, ArsisThesis, RhythmicType, CompoundBeat } from "./engines/score/types.js";
22
22
  import type { VoicedPitch } from "./engines/harmonia/voice.js";
23
23
  import type { Cosmos, CosmosQuery, Body, BodyName, Aspect } from "./engines/planet/types.js";
24
24
  declare const tonus: {
@@ -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, 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, };
39
+ export type { Feast, FeastQuery, Pascha, Season, Grade, Chant, CantusQuery, OrdinaryChant, PropriumQuery, OrdinariumQuery, OfficiumQuery, PsalmusQuery, Rite, Temperamentum, TemperamentumInput, TemperamentumOpts, Tuning, Pitch, PitchInput, Step, Neume, NeumeShape, Interval, ModeData, CadenceFigure, Modus, TunedNote, GamutOptions, Tonus, TonusOpts, Score, ScoreOpts, PondusInput, PondusOpts, AccentusInput, AccentusOpts, Cadence, CadenceTarget, CadenceApproach, Modulation, MidiOpts, MidiEmitResult, MidiJsonResult, MidiJsonEvent, MusicXmlOpts, MusicXmlEmitResult, ChantTabulaRow, Note, Performance, Phrase, Syllable, RestEvent, ParseError, ArsisThesis, RhythmicType, CompoundBeat, 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.3",
3
+ "version": "0.1.6",
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",
@@ -42,6 +42,7 @@
42
42
  "!dist/**/*.js.map",
43
43
  "!dist/**/*.d.ts.map",
44
44
  "LICENSE",
45
+ "CHANGELOG.md",
45
46
  "BIBLIOGRAPHY.md"
46
47
  ],
47
48
  "scripts": {
@@ -1,12 +0,0 @@
1
- export interface OfficeDay {
2
- feastId: string;
3
- doFile: string;
4
- antLaudes: string[];
5
- antBenedictus: string | null;
6
- antVespera: string[];
7
- antMagnificat: string | null;
8
- hymnLaudes: string | null;
9
- hymnVespera: string | null;
10
- }
11
- export declare const OFFICE: OfficeDay[];
12
- //# sourceMappingURL=office.d.ts.map