@k-l-lambda/lilylet 0.1.35 → 0.1.37

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.
@@ -816,6 +816,15 @@ type TieState = Record<string, Pitch[]>;
816
816
  type SlurState = Record<string, string | null>; // voice key -> pending slur startId
817
817
  type HairpinState = Record<string, { form: 'cres' | 'dim'; startId: string } | null>; // voice key -> pending hairpin
818
818
 
819
+ // Pending octave span for cross-measure continuation
820
+ interface PendingOctave {
821
+ dis: 8 | 15;
822
+ disPlace: 'above' | 'below';
823
+ startId: string;
824
+ shift: number; // The ottava value (1, -1, 2, -2)
825
+ }
826
+ type OttavaState = Record<string, PendingOctave | null>; // voice key -> pending octave span
827
+
819
828
  // Layer result type
820
829
  interface LayerResult {
821
830
  xml: string;
@@ -837,7 +846,11 @@ interface LayerResult {
837
846
  pendingTiePitches: Pitch[]; // For cross-measure tie tracking
838
847
  pendingSlur: string | null; // For cross-measure slur tracking (startId)
839
848
  pendingHairpin: { form: 'cres' | 'dim'; startId: string } | null; // For cross-measure hairpin tracking
849
+ pendingOctave: PendingOctave | null; // For cross-measure ottava span tracking
850
+ ottavaExplicitlyClosed: boolean; // True if ottava was closed by explicit \ottava #0 in this layer
840
851
  endingClef?: Clef; // For cross-measure clef tracking
852
+ lastNoteId: string | null; // For cross-measure ottava span end tracking
853
+ currentOttavaShift: number; // Current ottava shift for pitch encoding
841
854
  }
842
855
 
843
856
 
@@ -864,7 +877,7 @@ const getEventBeamMarks = (event: NoteEvent | RestEvent | TupletEvent | TremoloE
864
877
  };
865
878
 
866
879
  // Encode a layer (voice)
867
- const encodeLayer = (voice: Voice, layerN: number, indent: string, initialTiePitches: Pitch[] = [], keyFifths: number = 0, initialClef?: Clef, initialSlur: string | null = null, initialHairpin: { form: 'cres' | 'dim'; startId: string } | null = null): LayerResult => {
880
+ const encodeLayer = (voice: Voice, layerN: number, indent: string, initialTiePitches: Pitch[] = [], keyFifths: number = 0, initialClef?: Clef, initialSlur: string | null = null, initialHairpin: { form: 'cres' | 'dim'; startId: string } | null = null, initialOctave: PendingOctave | null = null): LayerResult => {
868
881
  const layerId = generateId("layer");
869
882
  let xml = `${indent}<layer xml:id="${layerId}" n="${layerN}">\n`;
870
883
 
@@ -881,12 +894,14 @@ const encodeLayer = (voice: Voice, layerN: number, indent: string, initialTiePit
881
894
  // Track pedal marks (each is independent, not paired spans)
882
895
  const pedals: PedalMark[] = [];
883
896
 
884
- // Track octave spans
897
+ // Track octave spans - initialize from previous measure if continuing
885
898
  const octaves: OctaveSpan[] = [];
886
- let currentOctave: { dis: 8 | 15; disPlace: 'above' | 'below'; startId: string } | null = null;
899
+ let currentOctave: { dis: 8 | 15; disPlace: 'above' | 'below'; startId: string } | null =
900
+ initialOctave ? { dis: initialOctave.dis, disPlace: initialOctave.disPlace, startId: initialOctave.startId } : null;
887
901
  let pendingOttava: number | null = null; // Track ottava to apply to next note
888
- let currentOttavaShift: number = 0; // Track current ottava shift for pitch encoding
902
+ let currentOttavaShift: number = initialOctave?.shift || 0; // Track current ottava shift for pitch encoding
889
903
  let lastNoteId: string | null = null; // Track last note id for ending ottava spans
904
+ let ottavaExplicitlyClosed: boolean = false; // Track if ottava was explicitly closed by \ottava #0
890
905
 
891
906
  // Track slur spans - slurs must be encoded as control events in MEI
892
907
  const slurs: SlurSpan[] = [];
@@ -961,7 +976,17 @@ const encodeLayer = (voice: Voice, layerN: number, indent: string, initialTiePit
961
976
  if (pendingOttava !== null && pendingOttava !== 0) {
962
977
  const dis: 8 | 15 = Math.abs(pendingOttava) === 2 ? 15 : 8;
963
978
  const disPlace: 'above' | 'below' = pendingOttava > 0 ? 'above' : 'below';
964
- currentOctave = { dis, disPlace, startId: result.elementId };
979
+ // Close existing span first if it has a different value
980
+ if (currentOctave && (currentOctave.dis !== dis || currentOctave.disPlace !== disPlace)) {
981
+ // Different value - close the old span
982
+ // Use the lastNoteId from before this note (which we saved before processing)
983
+ // Note: The span from previous measure will be closed by encodeMeasure
984
+ currentOctave = null;
985
+ }
986
+ // Start new span if we don't already have one with the same value
987
+ if (!currentOctave) {
988
+ currentOctave = { dis, disPlace, startId: result.elementId };
989
+ }
965
990
  pendingOttava = null;
966
991
  }
967
992
 
@@ -1106,11 +1131,23 @@ const encodeLayer = (voice: Voice, layerN: number, indent: string, initialTiePit
1106
1131
  endId: lastNoteId,
1107
1132
  });
1108
1133
  currentOctave = null;
1134
+ ottavaExplicitlyClosed = true; // Mark that we explicitly closed the span
1109
1135
  }
1110
- currentOttavaShift = 0; // Reset the shift
1136
+ // Note: if no lastNoteId (e.g., at measure start), keep currentOctave alive
1137
+ // It may be continued by a subsequent ottava command with the same value
1138
+ currentOttavaShift = 0; // Reset the shift (will be restored if continued)
1111
1139
  } else {
1112
- // Start new ottava span - will be applied to next note
1113
- pendingOttava = ctx.ottava;
1140
+ // Check if this continues an existing span (same value)
1141
+ const dis: 8 | 15 = Math.abs(ctx.ottava) === 2 ? 15 : 8;
1142
+ const disPlace: 'above' | 'below' = ctx.ottava > 0 ? 'above' : 'below';
1143
+ if (currentOctave && currentOctave.dis === dis && currentOctave.disPlace === disPlace) {
1144
+ // Continuation - restore the shift but don't change the span
1145
+ currentOttavaShift = ctx.ottava;
1146
+ } else {
1147
+ // Different value - start new ottava span (will be applied to next note)
1148
+ // If there's an existing span with different value, it will be closed when the note is processed
1149
+ pendingOttava = ctx.ottava;
1150
+ }
1114
1151
  }
1115
1152
  }
1116
1153
  // Check for stem direction changes
@@ -1162,18 +1199,14 @@ const encodeLayer = (voice: Voice, layerN: number, indent: string, initialTiePit
1162
1199
  xml += `${baseIndent}</beam>\n`;
1163
1200
  }
1164
1201
 
1165
- // Close any unclosed ottava span at end of layer
1166
- if (currentOctave && lastNoteId) {
1167
- octaves.push({
1168
- dis: currentOctave.dis,
1169
- disPlace: currentOctave.disPlace,
1170
- startId: currentOctave.startId,
1171
- endId: lastNoteId,
1172
- });
1173
- }
1202
+ // Don't close ottava span at measure end - it may continue in the next measure
1203
+ // Build pending octave state to return
1204
+ const pendingOctave: PendingOctave | null = currentOctave
1205
+ ? { dis: currentOctave.dis, disPlace: currentOctave.disPlace, startId: currentOctave.startId, shift: currentOttavaShift }
1206
+ : null;
1174
1207
 
1175
1208
  xml += `${indent}</layer>\n`;
1176
- return { xml, hairpins, pedals, octaves, slurs, arpeggios, fermatas, trills, mordents, turns, dynamics, fingerings, navigations, harmonies, barlines, markups, pendingTiePitches, pendingSlur: currentSlur?.startId || null, pendingHairpin: currentHairpin, endingClef: currentClef };
1209
+ return { xml, hairpins, pedals, octaves, slurs, arpeggios, fermatas, trills, mordents, turns, dynamics, fingerings, navigations, harmonies, barlines, markups, pendingTiePitches, pendingSlur: currentSlur?.startId || null, pendingHairpin: currentHairpin, pendingOctave, ottavaExplicitlyClosed, endingClef: currentClef, lastNoteId, currentOttavaShift };
1177
1210
  };
1178
1211
 
1179
1212
  // Staff result type
@@ -1197,11 +1230,14 @@ interface StaffResult {
1197
1230
  pendingTies: TieState; // For cross-measure tie tracking
1198
1231
  pendingSlurs: SlurState; // For cross-measure slur tracking
1199
1232
  pendingHairpins: HairpinState; // For cross-measure hairpin tracking
1233
+ pendingOctaves: OttavaState; // For cross-measure ottava span tracking
1234
+ ottavaExplicitlyClosed: Record<string, boolean>; // Track which layers had ottava explicitly closed
1235
+ lastNoteIds: Record<string, string | null>; // For cross-measure ottava span end tracking
1200
1236
  endingClef?: Clef; // For cross-measure clef tracking
1201
1237
  }
1202
1238
 
1203
1239
  // Encode a staff
1204
- const encodeStaff = (voices: Voice[], staffN: number, indent: string, tieState: TieState = {}, slurState: SlurState = {}, hairpinState: HairpinState = {}, keyFifths: number = 0, initialClef?: Clef): StaffResult => {
1240
+ const encodeStaff = (voices: Voice[], staffN: number, indent: string, tieState: TieState = {}, slurState: SlurState = {}, hairpinState: HairpinState = {}, ottavaState: OttavaState = {}, keyFifths: number = 0, initialClef?: Clef): StaffResult => {
1205
1241
  const staffId = generateId("staff");
1206
1242
  let xml = `${indent}<staff xml:id="${staffId}" n="${staffN}">\n`;
1207
1243
  const allHairpins: HairpinSpan[] = [];
@@ -1222,6 +1258,9 @@ const encodeStaff = (voices: Voice[], staffN: number, indent: string, tieState:
1222
1258
  const pendingTies: TieState = {};
1223
1259
  const pendingSlurs: SlurState = {};
1224
1260
  const pendingHairpins: HairpinState = {};
1261
+ const pendingOctaves: OttavaState = {};
1262
+ const ottavaExplicitlyClosed: Record<string, boolean> = {};
1263
+ const lastNoteIds: Record<string, string | null> = {};
1225
1264
  let endingClef: Clef | undefined = initialClef;
1226
1265
 
1227
1266
  if (voices.length === 0) {
@@ -1233,7 +1272,8 @@ const encodeStaff = (voices: Voice[], staffN: number, indent: string, tieState:
1233
1272
  const initialTies = tieState[tieKey] || [];
1234
1273
  const initialSlur = slurState[tieKey] || null;
1235
1274
  const initialHairpin = hairpinState[tieKey] || null;
1236
- const result = encodeLayer(voice, layerN, indent + ' ', initialTies, keyFifths, endingClef, initialSlur, initialHairpin);
1275
+ const initialOctave = ottavaState[tieKey] || null;
1276
+ const result = encodeLayer(voice, layerN, indent + ' ', initialTies, keyFifths, endingClef, initialSlur, initialHairpin, initialOctave);
1237
1277
  xml += result.xml;
1238
1278
  allHairpins.push(...result.hairpins);
1239
1279
  allPedals.push(...result.pedals);
@@ -1262,6 +1302,16 @@ const encodeStaff = (voices: Voice[], staffN: number, indent: string, tieState:
1262
1302
  if (result.pendingHairpin) {
1263
1303
  pendingHairpins[tieKey] = result.pendingHairpin;
1264
1304
  }
1305
+ // Track pending ottava spans for this layer
1306
+ if (result.pendingOctave) {
1307
+ pendingOctaves[tieKey] = result.pendingOctave;
1308
+ }
1309
+ // Track if ottava was explicitly closed in this layer
1310
+ if (result.ottavaExplicitlyClosed) {
1311
+ ottavaExplicitlyClosed[tieKey] = true;
1312
+ }
1313
+ // Track last note IDs for this layer (for closing ottava spans)
1314
+ lastNoteIds[tieKey] = result.lastNoteId;
1265
1315
  // Track ending clef for cross-measure tracking
1266
1316
  if (result.endingClef) {
1267
1317
  endingClef = result.endingClef;
@@ -1290,20 +1340,60 @@ const encodeStaff = (voices: Voice[], staffN: number, indent: string, tieState:
1290
1340
  pendingTies,
1291
1341
  pendingSlurs,
1292
1342
  pendingHairpins,
1343
+ pendingOctaves,
1344
+ ottavaExplicitlyClosed,
1345
+ lastNoteIds,
1293
1346
  endingClef,
1294
1347
  };
1295
1348
  };
1296
1349
 
1297
1350
 
1351
+ // Tempo text to BPM mapping (approximate values based on musical convention)
1352
+ const TEMPO_TEXT_TO_BPM: Record<string, number> = {
1353
+ // Very slow
1354
+ 'grave': 35,
1355
+ 'largo': 50,
1356
+ 'larghetto': 63,
1357
+ 'lento': 52,
1358
+ 'adagio': 70,
1359
+ // Slow to moderate
1360
+ 'andante': 92,
1361
+ 'andantino': 96,
1362
+ 'moderato': 114,
1363
+ // Fast
1364
+ 'allegretto': 116,
1365
+ 'allegro': 138,
1366
+ 'vivace': 166,
1367
+ 'presto': 184,
1368
+ 'prestissimo': 208,
1369
+ };
1370
+
1371
+ // Infer BPM from tempo text
1372
+ const inferBpmFromText = (text: string): number | undefined => {
1373
+ const lowerText = text.toLowerCase();
1374
+ for (const [keyword, bpm] of Object.entries(TEMPO_TEXT_TO_BPM)) {
1375
+ if (lowerText.includes(keyword)) {
1376
+ return bpm;
1377
+ }
1378
+ }
1379
+ return undefined;
1380
+ };
1381
+
1298
1382
  // Generate tempo element
1299
1383
  const generateTempoElement = (tempo: Tempo, indent: string, staff: number = 1): string => {
1300
1384
  let attrs = `xml:id="${generateId('tempo')}" tstamp="1" staff="${staff}"`;
1301
1385
 
1302
- // Add BPM if specified
1303
- if (tempo.bpm) {
1304
- attrs += ` midi.bpm="${tempo.bpm}"`;
1386
+ // Determine BPM: use explicit value or infer from text
1387
+ let bpm = tempo.bpm;
1388
+ if (!bpm && tempo.text) {
1389
+ bpm = inferBpmFromText(tempo.text);
1390
+ }
1391
+
1392
+ // Add BPM if available
1393
+ if (bpm) {
1394
+ attrs += ` midi.bpm="${bpm}"`;
1305
1395
  if (tempo.beat) {
1306
- attrs += ` mm="${tempo.bpm}" mm.unit="${tempo.beat.division}"`;
1396
+ attrs += ` mm="${bpm}" mm.unit="${tempo.beat.division}"`;
1307
1397
  }
1308
1398
  }
1309
1399
 
@@ -1339,8 +1429,8 @@ const BARLINE_TO_MEI: Record<string, string> = {
1339
1429
  };
1340
1430
 
1341
1431
  // Encode a measure
1342
- // encodeMeasure accepts mutable tieState, slurState, hairpinState and clefState that persist across measures
1343
- const encodeMeasure = (measure: Measure, measureN: number, indent: string, totalStaves: number, tieState: TieState, slurState: SlurState, hairpinState: HairpinState, keyFifths: number = 0, partInfos: PartInfo[] = [], clefState: ClefState = {}): string => {
1432
+ // encodeMeasure accepts mutable tieState, slurState, hairpinState, ottavaState and clefState that persist across measures
1433
+ const encodeMeasure = (measure: Measure, measureN: number, indent: string, totalStaves: number, tieState: TieState, slurState: SlurState, hairpinState: HairpinState, ottavaState: OttavaState, keyFifths: number = 0, partInfos: PartInfo[] = [], clefState: ClefState = {}): string => {
1344
1434
  const measureId = generateId("measure");
1345
1435
  let staffContent = ''; // Build staff content first, then add measure tag with barline
1346
1436
  const allHairpins: HairpinSpan[] = [];
@@ -1395,11 +1485,11 @@ const encodeMeasure = (measure: Measure, measureN: number, indent: string, total
1395
1485
  }
1396
1486
  }
1397
1487
 
1398
- // Encode each staff, passing and updating tie state, slur state, hairpin state and clef state
1488
+ // Encode each staff, passing and updating tie state, slur state, hairpin state, ottava state and clef state
1399
1489
  for (let si = 1; si <= totalStaves; si++) {
1400
1490
  const voices = voicesByStaff[si] || [];
1401
1491
  const initialClef = clefState[si];
1402
- const result = encodeStaff(voices, si, indent + ' ', tieState, slurState, hairpinState, keyFifths, initialClef);
1492
+ const result = encodeStaff(voices, si, indent + ' ', tieState, slurState, hairpinState, ottavaState, keyFifths, initialClef);
1403
1493
  staffContent += result.xml;
1404
1494
  allHairpins.push(...result.hairpins);
1405
1495
  allPedals.push(...result.pedals);
@@ -1422,6 +1512,55 @@ const encodeMeasure = (measure: Measure, measureN: number, indent: string, total
1422
1512
  Object.assign(slurState, result.pendingSlurs);
1423
1513
  // Update hairpin state with pending hairpins from this staff
1424
1514
  Object.assign(hairpinState, result.pendingHairpins);
1515
+ // Update ottava state with pending octaves from this staff
1516
+ // Also handle closing spans when ottava ends
1517
+ const currentStaffPrefix = `${si}-`;
1518
+ for (const [key, pending] of Object.entries(result.pendingOctaves)) {
1519
+ if (pending) {
1520
+ // Check if this is a continuation or a new span
1521
+ const prevPending = ottavaState[key];
1522
+ if (prevPending && prevPending.shift === pending.shift) {
1523
+ // Same ottava value continues - keep the original startId
1524
+ ottavaState[key] = { ...pending, startId: prevPending.startId };
1525
+ } else {
1526
+ // Different ottava value - close the old span first if exists
1527
+ if (prevPending) {
1528
+ const lastNoteId = result.lastNoteIds[key];
1529
+ if (lastNoteId) {
1530
+ allOctaves.push({
1531
+ dis: prevPending.dis,
1532
+ disPlace: prevPending.disPlace,
1533
+ startId: prevPending.startId,
1534
+ endId: lastNoteId,
1535
+ });
1536
+ }
1537
+ }
1538
+ // Start new span
1539
+ ottavaState[key] = pending;
1540
+ }
1541
+ }
1542
+ }
1543
+ // For layers in this staff that had pending octaves but didn't in this measure, close the spans
1544
+ for (const [key, pending] of Object.entries(ottavaState)) {
1545
+ // Only process keys for the current staff
1546
+ if (key.startsWith(currentStaffPrefix) && pending && !result.pendingOctaves[key]) {
1547
+ // Check if the span was already explicitly closed in encodeLayer
1548
+ // If so, don't generate another span (it was already pushed to octaves in encodeLayer)
1549
+ if (!result.ottavaExplicitlyClosed[key]) {
1550
+ // Ottava ended without explicit close - generate the closing span
1551
+ const lastNoteId = result.lastNoteIds[key];
1552
+ if (lastNoteId) {
1553
+ allOctaves.push({
1554
+ dis: pending.dis,
1555
+ disPlace: pending.disPlace,
1556
+ startId: pending.startId,
1557
+ endId: lastNoteId,
1558
+ });
1559
+ }
1560
+ }
1561
+ delete ottavaState[key];
1562
+ }
1563
+ }
1425
1564
  // Update clef state with ending clef from this staff
1426
1565
  if (result.endingClef) {
1427
1566
  clefState[si] = result.endingClef;
@@ -1718,6 +1857,9 @@ const encode = (doc: LilyletDoc, options: MEIEncoderOptions = {}): string => {
1718
1857
  // Track hairpin state across measures for cross-measure hairpins
1719
1858
  const hairpinState: HairpinState = {};
1720
1859
 
1860
+ // Track ottava state across measures for cross-measure ottava spans
1861
+ const ottavaState: OttavaState = {};
1862
+
1721
1863
  // Initialize clef state from partInfos (convert local staff to global staff)
1722
1864
  const clefState: ClefState = {};
1723
1865
  for (let pi = 0; pi < partInfos.length; pi++) {
@@ -1776,7 +1918,7 @@ const encode = (doc: LilyletDoc, options: MEIEncoderOptions = {}): string => {
1776
1918
  mei += `${indent}${indent}${indent}${indent}${indent}${indent}<scoreDef xml:id="${generateId('scoredef')}"${meterSymAttr} meter.count="${currentTimeNum}" meter.unit="${currentTimeDen}" />\n`;
1777
1919
  }
1778
1920
  }
1779
- mei += encodeMeasure(measure, mi + 1, `${indent}${indent}${indent}${indent}${indent}${indent}`, totalStaves, tieState, slurState, hairpinState, currentKey, partInfos, clefState);
1921
+ mei += encodeMeasure(measure, mi + 1, `${indent}${indent}${indent}${indent}${indent}${indent}`, totalStaves, tieState, slurState, hairpinState, ottavaState, currentKey, partInfos, clefState);
1780
1922
  });
1781
1923
 
1782
1924
  mei += `${indent}${indent}${indent}${indent}${indent}</section>\n`;
@@ -922,12 +922,12 @@ const directionToContextChange = (
922
922
  if (type === 'stop') {
923
923
  ottava = 0;
924
924
  ottavaTracker.current = 0;
925
- } else if (type === 'up') {
926
- // 8va = 1, 15ma = 2
925
+ } else if (type === 'down') {
926
+ // 8va = 1, 15ma = 2 (type="down" means written notes sound higher)
927
927
  ottava = size === 15 ? 2 : 1;
928
928
  ottavaTracker.current = ottava;
929
- } else if (type === 'down') {
930
- // 8vb = -1, 15mb = -2
929
+ } else if (type === 'up') {
930
+ // 8vb = -1, 15mb = -2 (type="up" means written notes sound lower)
931
931
  ottava = size === 15 ? -2 : -1;
932
932
  ottavaTracker.current = ottava;
933
933
  } else {
@@ -16,6 +16,7 @@ import {
16
16
  ContextChange,
17
17
  TupletEvent,
18
18
  TremoloEvent,
19
+ BarlineEvent,
19
20
  Pitch,
20
21
  Duration,
21
22
  Mark,
@@ -524,6 +525,16 @@ const serializeTremoloEvent = (
524
525
  };
525
526
 
526
527
 
528
+ // Serialize a barline event
529
+ const serializeBarlineEvent = (event: BarlineEvent): string => {
530
+ // Only output non-default barlines
531
+ if (event.style && event.style !== '|') {
532
+ return '\\bar "' + event.style + '"';
533
+ }
534
+ return '';
535
+ };
536
+
537
+
527
538
  // Serialize a single event with pitch environment tracking
528
539
  const serializeEvent = (
529
540
  event: Event,
@@ -541,27 +552,46 @@ const serializeEvent = (
541
552
  return serializeTupletEvent(event as TupletEvent, env);
542
553
  case 'tremolo':
543
554
  return serializeTremoloEvent(event as TremoloEvent, env);
555
+ case 'barline':
556
+ return { str: serializeBarlineEvent(event as BarlineEvent), newEnv: env };
544
557
  default:
545
558
  return { str: '', newEnv: env };
546
559
  }
547
560
  };
548
561
 
549
562
 
550
- // Key/time signature info to inject into first voice
563
+ // Key/time/clef signature info to inject into voices
551
564
  interface MeasureContext {
552
565
  key?: KeySignature;
553
566
  time?: { numerator: number; denominator: number; symbol?: 'common' | 'cut' };
567
+ clef?: Clef;
554
568
  }
555
569
 
570
+ // Find first clef in voice events
571
+ const findVoiceClef = (voice: Voice): Clef | undefined => {
572
+ for (const event of voice.events) {
573
+ if (event.type === 'context') {
574
+ const ctx = event as ContextChange;
575
+ if (ctx.clef) {
576
+ return ctx.clef;
577
+ }
578
+ }
579
+ }
580
+ return undefined;
581
+ };
582
+
556
583
  // Serialize a voice with pitch environment tracking
557
584
  // Takes currentStaff (what parser thinks staff is) and returns { str, newStaff }
558
585
  // If isGrandStaff is true, always output \staff command for clarity
559
- // If measureContext is provided, output key/time after \staff (for first voice only)
586
+ // measureContext provides key/time for first voice
587
+ // staffClef is the clef for this voice's staff (tracked across measures)
560
588
  const serializeVoice = (
561
589
  voice: Voice,
562
590
  currentStaff: number,
563
591
  isGrandStaff: boolean = false,
564
- measureContext?: MeasureContext
592
+ measureContext?: MeasureContext,
593
+ isFirstVoice: boolean = false,
594
+ staffClef?: Clef
565
595
  ): { str: string; newStaff: number } => {
566
596
  const parts: string[] = [];
567
597
  let prevDuration: Duration | undefined;
@@ -574,8 +604,8 @@ const serializeVoice = (
574
604
  parts.push('\\staff "' + voice.staff + '"');
575
605
  }
576
606
 
577
- // Output key/time signatures after \staff (for first voice of first measure)
578
- if (measureContext) {
607
+ // Output key/time signatures after \staff (for first voice only)
608
+ if (measureContext && isFirstVoice) {
579
609
  if (measureContext.key) {
580
610
  let keyStr = String(measureContext.key.pitch);
581
611
  if (measureContext.key.accidental) {
@@ -595,7 +625,25 @@ const serializeVoice = (
595
625
  }
596
626
  }
597
627
 
628
+ // Output clef for every voice (use staff clef tracked across measures, or find from voice events)
629
+ const voiceClef = staffClef || findVoiceClef(voice);
630
+ if (voiceClef) {
631
+ parts.push('\\clef "' + CLEF_MAP[voiceClef] + '"');
632
+ }
633
+
634
+ // Track if we've already output the clef to avoid duplication
635
+ let clefOutputted = !!voiceClef;
636
+
598
637
  for (const event of voice.events) {
638
+ // Skip clef context events if we've already output the clef at the beginning
639
+ if (clefOutputted && event.type === 'context') {
640
+ const ctx = event as ContextChange;
641
+ if (ctx.clef && !ctx.key && !ctx.time && !ctx.ottava && !ctx.stemDirection && !ctx.tempo && !ctx.staff) {
642
+ // This is a clef-only context event, skip it
643
+ continue;
644
+ }
645
+ }
646
+
599
647
  const { str: eventStr, newEnv } = serializeEvent(event, pitchEnv, prevDuration);
600
648
  pitchEnv = newEnv;
601
649
 
@@ -616,12 +664,14 @@ const serializeVoice = (
616
664
 
617
665
 
618
666
  // Serialize a part, tracking staff state across voices
619
- // If measureContext is provided, pass it to the first voice only
667
+ // measureContext is passed to all voices (for clef), but key/time only to first voice
620
668
  const serializePart = (
621
669
  part: Part,
622
670
  currentStaff: number,
623
671
  isGrandStaff: boolean = false,
624
- measureContext?: MeasureContext
672
+ measureContext?: MeasureContext,
673
+ isFirstPart: boolean = false,
674
+ clefsByStaff?: Record<number, Clef>
625
675
  ): { str: string; newStaff: number } => {
626
676
  if (part.voices.length === 0) {
627
677
  return { str: '', newStaff: currentStaff };
@@ -632,9 +682,11 @@ const serializePart = (
632
682
 
633
683
  for (let i = 0; i < part.voices.length; i++) {
634
684
  const voice = part.voices[i];
635
- // Only pass measureContext to first voice
636
- const ctx = i === 0 ? measureContext : undefined;
637
- const { str, newStaff } = serializeVoice(voice, staff, isGrandStaff, ctx);
685
+ // Pass measureContext to all voices, isFirstVoice for key/time
686
+ // Pass staff clef from clefsByStaff map
687
+ const isFirstVoice = isFirstPart && i === 0;
688
+ const staffClef = clefsByStaff?.[voice.staff];
689
+ const { str, newStaff } = serializeVoice(voice, staff, isGrandStaff, measureContext, isFirstVoice, staffClef);
638
690
  voiceStrs.push(str);
639
691
  staff = newStaff;
640
692
  }
@@ -645,19 +697,33 @@ const serializePart = (
645
697
 
646
698
 
647
699
  // Serialize a measure, tracking staff state across parts
648
- const serializeMeasure = (measure: Measure, isFirst: boolean, currentStaff: number, isGrandStaff: boolean = false): { str: string; newStaff: number } => {
700
+ // Always output key/time at start of each measure
701
+ const serializeMeasure = (
702
+ measure: Measure,
703
+ _isFirst: boolean,
704
+ currentStaff: number,
705
+ isGrandStaff: boolean = false,
706
+ currentKey?: KeySignature,
707
+ currentTime?: { numerator: number; denominator: number; symbol?: 'common' | 'cut' },
708
+ staffClefs?: Record<number, Clef>
709
+ ): { str: string; newStaff: number } => {
649
710
  const parts: string[] = [];
650
711
 
651
- // Build measure context for first voice (key/time signatures)
652
- const measureContext: MeasureContext | undefined = isFirst ? {
653
- key: measure.key,
654
- time: measure.timeSig,
655
- } : undefined;
712
+ // Build measure context for all voices (key/time)
713
+ // Key and time are written to first voice, clef to all voices based on staff
714
+ // Use passed currentKey/currentTime which tracks across all measures
715
+ const measureContext: MeasureContext = {
716
+ key: currentKey,
717
+ time: currentTime,
718
+ };
719
+
720
+ // Pass staffClefs to parts for per-voice clef lookup
721
+ const clefsByStaff = staffClefs || {};
656
722
 
657
723
  // Parts
658
724
  let staff = currentStaff;
659
725
  if (measure.parts.length === 1) {
660
- const { str: partStr, newStaff } = serializePart(measure.parts[0], staff, isGrandStaff, measureContext);
726
+ const { str: partStr, newStaff } = serializePart(measure.parts[0], staff, isGrandStaff, measureContext, true, clefsByStaff);
661
727
  if (partStr) {
662
728
  parts.push(partStr);
663
729
  }
@@ -667,9 +733,8 @@ const serializeMeasure = (measure: Measure, isFirst: boolean, currentStaff: numb
667
733
  const partStrs: string[] = [];
668
734
  for (let i = 0; i < measure.parts.length; i++) {
669
735
  const part = measure.parts[i];
670
- // Only pass measureContext to first part
671
- const ctx = i === 0 ? measureContext : undefined;
672
- const { str, newStaff } = serializePart(part, staff, isGrandStaff, ctx);
736
+ // Pass measureContext to all parts, isFirstPart to first part only
737
+ const { str, newStaff } = serializePart(part, staff, isGrandStaff, measureContext, i === 0, clefsByStaff);
673
738
  if (str) {
674
739
  partStrs.push(str);
675
740
  }
@@ -735,10 +800,35 @@ export const serializeLilyletDoc = (doc: LilyletDoc): string => {
735
800
 
736
801
  // Measures with bar lines, measure numbers, and double newlines
737
802
  // Track staff state across measures (parser remembers staff across bar lines)
803
+ // Track key/time/clef across measures to output in every measure
738
804
  const measureStrs: string[] = [];
739
805
  let currentStaff = 1; // Parser starts at staff 1
806
+ let currentKey: KeySignature | undefined;
807
+ let currentTime: { numerator: number; denominator: number; symbol?: 'common' | 'cut' } | undefined;
808
+ const staffClefs: Record<number, Clef> = {}; // Track clef per staff
809
+
740
810
  for (let i = 0; i < doc.measures.length; i++) {
741
- const { str: measureStr, newStaff } = serializeMeasure(doc.measures[i], i === 0, currentStaff, isGrandStaff);
811
+ const measure = doc.measures[i];
812
+ // Update current key/time if measure has them
813
+ if (measure.key) {
814
+ currentKey = measure.key;
815
+ }
816
+ if (measure.timeSig) {
817
+ currentTime = measure.timeSig;
818
+ }
819
+
820
+ // Collect clefs from this measure's voices
821
+ for (const part of measure.parts) {
822
+ for (const voice of part.voices) {
823
+ for (const event of voice.events) {
824
+ if (event.type === 'context' && (event as ContextChange).clef) {
825
+ staffClefs[voice.staff] = (event as ContextChange).clef!;
826
+ }
827
+ }
828
+ }
829
+ }
830
+
831
+ const { str: measureStr, newStaff } = serializeMeasure(measure, i === 0, currentStaff, isGrandStaff, currentKey, currentTime, staffClefs);
742
832
  // Always include measure, even if empty (use space rest for empty measures)
743
833
  measureStrs.push(measureStr || 's1');
744
834
  currentStaff = newStaff;