@k-l-lambda/lilylet 0.1.88 → 0.1.90

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.
@@ -2,6 +2,7 @@ import { Clef, Accidental, OrnamentType, StemDirection, HairpinType, PedalType,
2
2
  import { parseStaffLayout, StaffGroupType } from "./staffLayout.js";
3
3
  import { gmProgramOf } from "./gmInstruments.js";
4
4
  import { parseMeasureLayout, expandMeasureLayout, decomposeToSegments } from "./measureLayout.js";
5
+ import { clefBaseName, clefSuffixTransposition } from "./clefTransposition.js";
5
6
  // MEI key signatures: positive = sharps, negative = flats
6
7
  const KEY_SIGS = {
7
8
  0: "0",
@@ -49,36 +50,27 @@ const CLEF_SHAPES = {
49
50
  };
50
51
  // Semitone offsets of the major/perfect intervals within one diatonic octave,
51
52
  // indexed by diatonic step (0 = unison, 1 = 2nd, … 6 = 7th).
52
- const DIATONIC_SEMITONES = [0, 2, 4, 5, 7, 9, 11];
53
53
  // Convert a LilyPond clef interval-number suffix into an MEI written→sounding
54
54
  // transposition. The number N is a diatonic interval number (2 = 2nd, 3 = 3rd,
55
55
  // 5 = 5th, 8 = octave, 15 = two octaves); "_" lowers the sounding pitch, "^"
56
56
  // raises it. Returns { diat, semi } where diat is the diatonic step shift
57
57
  // (N - 1, signed) and semi is the corresponding chromatic shift in semitones,
58
58
  // extended octave-wise for compound intervals.
59
- const clefTransposition = (intervalNumber, up) => {
60
- const k = intervalNumber - 1; // diatonic steps
61
- const semis = DIATONIC_SEMITONES[k % 7] + 12 * Math.floor(k / 7);
62
- const sign = up ? 1 : -1;
63
- return { diat: sign * k, semi: sign * semis };
64
- };
65
59
  // Resolve a clef string into MEI shape/line plus optional written→sounding
66
- // transposition. Per the LilyPond convention a "_N"/"^N" suffix transposes the
67
- // clef down/up by the diatonic interval N (e.g. "treble_8" octave down,
68
- // "treble_5" fifth down, "treble^3" third up). MEI's clef.dis only covers octave
69
- // displacement (8|15|22), so all clef transposition — octaves included — is
70
- // encoded uniformly via att.transposition (trans.diat / trans.semi) on staffDef.
60
+ // transposition. A "_N"/"^N" suffix transposes the clef down/up by the diatonic
61
+ // interval N ("treble_8" octave down, "treble_5" fifth down), and "_mN"/"^mN"
62
+ // selects that interval's minor form ("treble_m3" = minor third down) — see
63
+ // clefTransposition.ts. MEI's clef.dis only covers octave displacement
64
+ // (8|15|22), so all clef transposition — octaves included — is encoded uniformly
65
+ // via att.transposition (trans.diat / trans.semi) on staffDef. A minor interval is
66
+ // exactly why both fields are needed: a minor third is {diat:-2, semi:-3}.
71
67
  const resolveClef = (clefStr) => {
72
- const match = clefStr.match(/^(.*?)([_^])(\d+)$/);
73
- const base = match ? match[1] : clefStr;
74
- const clefInfo = CLEF_SHAPES[base] || CLEF_SHAPES.treble;
75
- if (!match)
76
- return { shape: clefInfo.shape, line: clefInfo.line };
77
- const trans = clefTransposition(Number(match[3]), match[2] === "^");
68
+ const clefInfo = CLEF_SHAPES[clefBaseName(clefStr)] || CLEF_SHAPES.treble;
69
+ const trans = clefSuffixTransposition(clefStr);
78
70
  return {
79
71
  shape: clefInfo.shape,
80
72
  line: clefInfo.line,
81
- trans,
73
+ ...(trans ? { trans } : {}),
82
74
  };
83
75
  };
84
76
  // Attributes for a standalone <clef> element (mid-measure clef change).
@@ -6,6 +6,7 @@
6
6
  */
7
7
  import { DOMParser } from '@xmldom/xmldom';
8
8
  import { HairpinType, NavigationMarkType, OrnamentType, Placement, } from "./types.js";
9
+ import { withClefTransposition } from "./clefTransposition.js";
9
10
  import { getElementText, getElementInt, getElements, getDirectChildren, getChildElements, getAttribute, getAttributeNumber, hasElement, convertPitch, convertDuration, convertKeySignature, convertClef, convertStemDirection, convertArticulation, convertOrnament, convertDynamic, convertPedal, convertBarlineStyle, convertHarmonyToText, createFraction, TYPE_TO_DIVISION, } from "./musicXmlUtils.js";
10
11
  import { measureLayoutFromPart } from "./measureLayoutFromXml.js";
11
12
  // ============ Spanner Tracker ============
@@ -491,6 +492,20 @@ const parseAttributes = (attrEl) => {
491
492
  }
492
493
  }
493
494
  }
495
+ // Instrument transposition. This is what carries a transposing part's sounding
496
+ // pitch in MusicXML (<clef-octave-change> is only the glyph), and it was never
497
+ // read, so every transposing instrument decoded as sounding-as-written.
498
+ const transposeEl = getElements(attrEl, 'transpose')[0];
499
+ if (transposeEl) {
500
+ const chromatic = getElementInt(transposeEl, 'chromatic');
501
+ if (chromatic != null) {
502
+ result.transpose = {
503
+ diatonic: getElementInt(transposeEl, 'diatonic') ?? undefined,
504
+ chromatic,
505
+ octaveChange: getElementInt(transposeEl, 'octave-change') ?? undefined,
506
+ };
507
+ }
508
+ }
494
509
  // Staves
495
510
  const staves = getElementInt(attrEl, 'staves');
496
511
  if (staves !== undefined) {
@@ -1038,6 +1053,35 @@ const forwardGapToRests = (gapTicks, divisions) => {
1038
1053
  }
1039
1054
  return rests;
1040
1055
  };
1056
+ /**
1057
+ * Fold a MusicXML transposition onto a lilylet clef as a suffix.
1058
+ *
1059
+ * MusicXML keeps the two apart: <transpose> is the sounding shift and moves pitch,
1060
+ * <clef-octave-change> is the "8" glyph under the clef and moves nothing. A lilylet
1061
+ * suffix means sounding shift, so only ONE of them may be folded in — adding both
1062
+ * would double an octave clef that also declares <transpose> (the shape this
1063
+ * encoder itself now emits) into two octaves.
1064
+ *
1065
+ * <transpose> wins when present, being the explicit statement of sounding pitch.
1066
+ * Absent it, an octave clef is read as implying its own shift, which is what a
1067
+ * tenor-voice or guitar treble_8 part means.
1068
+ *
1069
+ * A shift with no exact suffix (only the tritone) leaves the clef plain rather than
1070
+ * rounding: a silently wrong sounding pitch is worse than a visibly absent shift.
1071
+ */
1072
+ const clefWithShift = (clef, transposeSemitones, clefOctaveChange) => {
1073
+ if (!clef)
1074
+ return clef;
1075
+ const semitones = transposeSemitones !== 0 ? transposeSemitones : 12 * (clefOctaveChange ?? 0);
1076
+ if (semitones === 0)
1077
+ return clef;
1078
+ const shifted = withClefTransposition(clef, semitones);
1079
+ if (shifted === undefined) {
1080
+ console.warn(`musicXmlDecoder: transposition ${semitones} has no exact clef suffix; dropped for clef "${clef}"`);
1081
+ return clef;
1082
+ }
1083
+ return shifted;
1084
+ };
1041
1085
  /**
1042
1086
  * Total time a TupletEvent advances the voice, in voiceTracker duration units.
1043
1087
  * Sum the inner note/rest values then apply the tuplet ratio (triplet etc.).
@@ -1054,7 +1098,10 @@ const tupletAdvanceDuration = (tupletEvent, divisions) => {
1054
1098
  /**
1055
1099
  * Convert a MusicXML measure to Lilylet events, grouped by voice
1056
1100
  */
1057
- const convertMeasure = (measureEl, voiceTracker, spannerTracker, ottavaTracker, tupletTracker) => {
1101
+ const convertMeasure = (measureEl, voiceTracker, spannerTracker, ottavaTracker, tupletTracker,
1102
+ // A part's <transpose> is declared once, usually in measure 1, and stays in force
1103
+ // for the whole part, so it has to survive across measures like the ottava does.
1104
+ transposeTracker) => {
1058
1105
  let key;
1059
1106
  let timeSig;
1060
1107
  let barline;
@@ -1136,10 +1183,15 @@ const convertMeasure = (measureEl, voiceTracker, spannerTracker, ottavaTracker,
1136
1183
  if (attrs.time) {
1137
1184
  timeSig = createFraction(attrs.time.beats, attrs.time.beatType);
1138
1185
  }
1186
+ // Instrument transposition, in force from here to the end of the part.
1187
+ if (attrs.transpose) {
1188
+ const { chromatic, octaveChange } = attrs.transpose;
1189
+ transposeTracker.semitones = chromatic + 12 * (octaveChange ?? 0);
1190
+ }
1139
1191
  // Clefs - store by staff number
1140
1192
  if (attrs.clefs) {
1141
1193
  for (const clefEntry of attrs.clefs) {
1142
- const clef = convertClef(clefEntry.clef.sign, clefEntry.clef.line);
1194
+ const clef = clefWithShift(convertClef(clefEntry.clef.sign, clefEntry.clef.line), transposeTracker.semitones, clefEntry.clef.clefOctaveChange);
1143
1195
  if (clef) {
1144
1196
  const staff = clefEntry.staff;
1145
1197
  if (staffHasNotes.has(staff)) {
@@ -1508,6 +1560,7 @@ const convertPart = (partEl) => {
1508
1560
  const voiceTracker = new VoiceTracker();
1509
1561
  const spannerTracker = new SpannerTracker();
1510
1562
  const ottavaTracker = { current: 0 };
1563
+ const transposeTracker = { semitones: 0 };
1511
1564
  const tupletTracker = new TupletTracker();
1512
1565
  let lastKey;
1513
1566
  let lastTimeSig;
@@ -1523,7 +1576,7 @@ const convertPart = (partEl) => {
1523
1576
  const measureEls = getDirectChildren(partEl, 'measure');
1524
1577
  for (const measureEl of measureEls) {
1525
1578
  voiceTracker.reset();
1526
- const { voiceMap, key, timeSig, barline, leadingBarline, harmonies, clefs } = convertMeasure(measureEl, voiceTracker, spannerTracker, ottavaTracker, tupletTracker);
1579
+ const { voiceMap, key, timeSig, barline, leadingBarline, harmonies, clefs } = convertMeasure(measureEl, voiceTracker, spannerTracker, ottavaTracker, tupletTracker, transposeTracker);
1527
1580
  // A forward-repeat (or any `location="left"`) barline belongs at the END of
1528
1581
  // the PREVIOUS measure in lilylet's "\bar = current measure's end bar" model.
1529
1582
  // Append it to the previous measure's first voice; if this is the first
@@ -7,6 +7,7 @@
7
7
  import { StemDirection, Accidental, HairpinType, PedalType, NavigationMarkType, } from "./types.js";
8
8
  import { DIVISIONS, DIVISION_TO_TYPE, calculateDuration, } from "./musicXmlUtils.js";
9
9
  import { parseStaffLayout } from "./staffLayout.js";
10
+ import { clefBaseName, clefSuffixSemitones, clefSuffixTransposition } from "./clefTransposition.js";
10
11
  // === Constants and Reverse Mappings ===
11
12
  // Phonet to MusicXML step
12
13
  const PHONET_TO_STEP = {
@@ -210,13 +211,44 @@ const encodeAttributes = (level, options) => {
210
211
  xml += `${indent(level + 1)}<staves>${options.staves}</staves>\n`;
211
212
  }
212
213
  if (options.clef) {
213
- const clefInfo = CLEF_TO_SIGN[options.clef];
214
+ // Look the sign/line up under the BASE name. A lilylet clef may carry a
215
+ // transposition suffix ("treble_8", "treble_m3"), and keying the table on the
216
+ // whole string missed every one of them — `clefInfo` came back undefined and
217
+ // the <clef> element was dropped entirely, sign and line with it.
218
+ const clefStr = options.clef;
219
+ const clefInfo = CLEF_TO_SIGN[clefBaseName(clefStr)];
214
220
  if (clefInfo) {
215
221
  xml += `${indent(level + 1)}<clef>\n`;
216
222
  xml += `${indent(level + 2)}<sign>${clefInfo.sign}</sign>\n`;
217
223
  xml += `${indent(level + 2)}<line>${clefInfo.line}</line>\n`;
224
+ // A whole-octave shift also has a notational form: the "8" drawn under or
225
+ // over the clef. This is the glyph only — sounding pitch rides on
226
+ // <transpose> below, exactly as in LilyPond.
227
+ const semitones = clefSuffixSemitones(clefStr);
228
+ if (semitones % 12 === 0 && semitones !== 0)
229
+ xml += `${indent(level + 2)}<clef-octave-change>${semitones / 12}</clef-octave-change>\n`;
218
230
  xml += `${indent(level + 1)}</clef>\n`;
219
231
  }
232
+ // The written->sounding transposition. MusicXML carries the diatonic step count
233
+ // and the chromatic semitone count separately, which is what lets a minor third
234
+ // (-2 steps, -3 semitones) be expressed exactly. <transpose> follows <clef> in
235
+ // the attributes content order.
236
+ const trans = clefSuffixTransposition(clefStr);
237
+ if (trans) {
238
+ // <diatonic> and <chromatic> hold the WITHIN-OCTAVE remainder and whole
239
+ // octaves go in <octave-change>, so both must be reduced by the same octave
240
+ // count: 7 diatonic steps and 12 semitones each. Reducing only the semitones
241
+ // would make an octave clef read as two octaves.
242
+ const octaveChange = Math.trunc(trans.semi / 12);
243
+ const diatonic = trans.diat - 7 * octaveChange;
244
+ const chromatic = trans.semi - 12 * octaveChange;
245
+ xml += `${indent(level + 1)}<transpose>\n`;
246
+ xml += `${indent(level + 2)}<diatonic>${diatonic}</diatonic>\n`;
247
+ xml += `${indent(level + 2)}<chromatic>${chromatic}</chromatic>\n`;
248
+ if (octaveChange !== 0)
249
+ xml += `${indent(level + 2)}<octave-change>${octaveChange}</octave-change>\n`;
250
+ xml += `${indent(level + 1)}</transpose>\n`;
251
+ }
220
252
  }
221
253
  xml += `${indent(level)}</attributes>\n`;
222
254
  return xml;
@@ -31,6 +31,11 @@ export interface MusicXmlClef {
31
31
  line?: number;
32
32
  clefOctaveChange?: number;
33
33
  }
34
+ export interface MusicXmlTranspose {
35
+ diatonic?: number;
36
+ chromatic: number;
37
+ octaveChange?: number;
38
+ }
34
39
  export interface MusicXmlAttributes {
35
40
  divisions?: number;
36
41
  key?: MusicXmlKey;
@@ -40,6 +45,7 @@ export interface MusicXmlAttributes {
40
45
  clef: MusicXmlClef;
41
46
  }[];
42
47
  staves?: number;
48
+ transpose?: MusicXmlTranspose;
43
49
  }
44
50
  export type MusicXmlStemDirection = 'up' | 'down';
45
51
  export type MusicXmlLyricSyllabic = 'single' | 'begin' | 'middle' | 'end';
@@ -9,10 +9,11 @@
9
9
  * Shared by tools/astServer.ts (the HTTP onset API) and the clef-onset unit test.
10
10
  */
11
11
  /**
12
- * The written->sounding semitone shift a clef string declares. Per the LilyPond convention a
13
- * "_N"/"^N" suffix transposes DOWN/UP by the diatonic interval N: "treble_8" = octave down
14
- * (-12), "treble^8" = +12, "treble_15" = two octaves down (-24), "treble_5" = fifth down (-7).
15
- * A plain clef (no suffix) declares 0.
12
+ * The written->sounding semitone shift a clef string declares. A "_N"/"^N" suffix transposes
13
+ * DOWN/UP by the diatonic interval N ("treble_8" = octave down, -12; "treble_5" = fifth down,
14
+ * -7), and an "m" before the number selects that interval's MINOR form ("treble_m3" = minor
15
+ * third down, -3). A plain clef declares 0. See clefTransposition.ts for the grammar; this is
16
+ * a thin alias kept because clefShift is the name the onset API and its tests already use.
16
17
  */
17
18
  export declare const clefShift: (clefStr: string) => number;
18
19
  /**
@@ -10,6 +10,7 @@
10
10
  */
11
11
  import { calculateDuration } from "./musicXmlUtils.js";
12
12
  import { Accidental } from "./types.js";
13
+ import { clefSuffixSemitones } from "./clefTransposition.js";
13
14
  // Onset resolution in ticks per quarter note. calculateDuration() rounds its result to an
14
15
  // integer, so it must run at a resolution fine enough to represent every note value exactly:
15
16
  // the shared DIVISIONS=4 (MusicXML export) rounds a 32nd (0.5) and a dotted 16th (1.5) to
@@ -21,22 +22,14 @@ const ACCIDENTAL_SEMITONE = {
21
22
  [Accidental.natural]: 0, [Accidental.sharp]: 1, [Accidental.flat]: -1,
22
23
  [Accidental.doubleSharp]: 2, [Accidental.doubleFlat]: -2,
23
24
  };
24
- // Diatonic-step -> semitones within an octave (unison..seventh), for "_N"/"^N" clef suffixes.
25
- const DIATONIC_SEMITONES = [0, 2, 4, 5, 7, 9, 11];
26
25
  /**
27
- * The written->sounding semitone shift a clef string declares. Per the LilyPond convention a
28
- * "_N"/"^N" suffix transposes DOWN/UP by the diatonic interval N: "treble_8" = octave down
29
- * (-12), "treble^8" = +12, "treble_15" = two octaves down (-24), "treble_5" = fifth down (-7).
30
- * A plain clef (no suffix) declares 0.
26
+ * The written->sounding semitone shift a clef string declares. A "_N"/"^N" suffix transposes
27
+ * DOWN/UP by the diatonic interval N ("treble_8" = octave down, -12; "treble_5" = fifth down,
28
+ * -7), and an "m" before the number selects that interval's MINOR form ("treble_m3" = minor
29
+ * third down, -3). A plain clef declares 0. See clefTransposition.ts for the grammar; this is
30
+ * a thin alias kept because clefShift is the name the onset API and its tests already use.
31
31
  */
32
- export const clefShift = (clefStr) => {
33
- const m = /^.*?([_^])(\d+)$/.exec(clefStr || "");
34
- if (!m)
35
- return 0;
36
- const k = parseInt(m[2], 10) - 1; // diatonic steps above unison
37
- const semis = DIATONIC_SEMITONES[k % 7] + 12 * Math.floor(k / 7);
38
- return (m[1] === "^" ? 1 : -1) * semis;
39
- };
32
+ export const clefShift = (clefStr) => clefSuffixSemitones(clefStr);
40
33
  /**
41
34
  * Sounding MIDI pitch of a resolved (absolute-octave) Lilylet pitch. Octave 0 = middle-C
42
35
  * octave = MIDI 60 (see parser.resolveRelativePitch). `shift` is the active clef's
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@k-l-lambda/lilylet",
3
- "version": "0.1.88",
3
+ "version": "0.1.90",
4
4
  "description": "Lilylet is a lilyopnd-like sheet music language designed for Markdown rendering and symbolic music representation in AIGC applications.",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
@@ -55,6 +55,9 @@
55
55
  "test:abc-instrument": "tsx ./tests/unit/abcInstrumentName.test.ts",
56
56
  "test:abc-partial": "tsx ./tests/unit/abcPartialPickup.test.ts",
57
57
  "test:clef-onset": "tsx ./tests/unit/clefOnsetTranspose.test.ts",
58
+ "test:abc-transpose": "tsx ./tests/unit/abcTransposingClef.test.ts",
59
+ "test:clef-transposition": "tsx ./tests/unit/clefTransposition.test.ts",
60
+ "test:clef-translation": "tsx ./tests/unit/clefTranslation.test.ts",
58
61
  "test:fine-rhythm-onset": "tsx ./tests/unit/fineRhythmOnset.test.ts",
59
62
  "test:transpose": "tsx ./tests/unit/astTranspose.test.ts",
60
63
  "build:tests": "tsc -p tsconfig.tests.json; cp source/lilylet/grammar.jison.js lib-tests/source/lilylet/ && cp source/abc/grammar.jison.js lib-tests/source/abc/ && node tools/fixEsmExtensions.cjs lib-tests && ln -sfn ../../tests/assets lib-tests/tests/assets",
@@ -213,6 +213,7 @@ SPECIAL [:!^_,'/<>={}()\[\]|.\-+~&]
213
213
 
214
214
  ^[T][:][\s]* { var pre = this.matched.slice(0, this.matched.length - yytext.length); if (pre === '' || pre.slice(-1) === '\n') { this.pushState('title_string'); return 'T:'; } this.unput(yytext.slice(1)); yytext = 'T'; return 'T'; }
215
215
  ^[C][:][\s]* { var pre = this.matched.slice(0, this.matched.length - yytext.length); if (pre === '' || pre.slice(-1) === '\n') { this.pushState('title_string'); return 'C:'; } this.unput(yytext.slice(1)); yytext = 'C'; return 'A'; }
216
+ ^[ABDFGHINORSWZ][:](?![|])[ \t]* { var pre = this.matched.slice(0, this.matched.length - yytext.length); if (pre === '' || pre.slice(-1) === '\n') { this.pushState('title_string'); yytext = yytext[0]; return 'TEXT_FIELD'; } this.unput(yytext.slice(1)); yytext = yytext[0]; return 'H'; }
216
217
  <title_string>\n { this.popState(); }
217
218
  <title_string>[^\n]+ return 'STR_CONTENT'
218
219
 
@@ -379,6 +380,7 @@ head_line
379
380
  | 'K:' key_signature -> header('K', $2)
380
381
  | 'V:' header_value -> header('V', $2)
381
382
  | H ':' header_value -> header($1, $3)
383
+ | TEXT_FIELD string_content -> header($1, $2)
382
384
  ;
383
385
 
384
386
  header_value