@viritura/score-engine 0.2.1-next.41 → 0.2.1-next.42

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/dist/index.js CHANGED
@@ -358,11 +358,41 @@ init_define_import_meta_env();
358
358
 
359
359
  // packages/core/src/operations/chordSymbols/modifiers.ts
360
360
  init_define_import_meta_env();
361
+ function parseAlteredDegree(text) {
362
+ let match = /^([b#♭♯])(13|11|9|5)/u.exec(text);
363
+ if (match)
364
+ return {
365
+ degree: Number(match[2]),
366
+ alter: match[1] === "b" || match[1] === "\u266D" ? -1 : 1,
367
+ length: match[0].length
368
+ };
369
+ }
370
+ function finishModifiers(result, inGroup, needsDegree, afterDegree, afterComma) {
371
+ if (!(inGroup || needsDegree || !afterDegree || afterComma))
372
+ return {
373
+ added: [...new Set(result.added)].sort((a, b) => a - b),
374
+ omitted: [...new Set(result.omitted)].sort((a, b) => a - b),
375
+ ...result.altered ? {
376
+ altered: result.altered.filter(
377
+ (item, index, items) => items.findIndex((other) => other.degree === item.degree && other.alter === item.alter) === index
378
+ ).sort((a, b) => a.degree - b.degree || a.alter - b.alter)
379
+ } : {}
380
+ };
381
+ }
361
382
  function parseModifiers(text) {
362
383
  let result = { added: [], omitted: [] }, rest = text.trim().toLowerCase();
363
384
  if (!rest) return result;
364
385
  let operation, needsDegree = !1, afterDegree = !1, inGroup = !1, afterComma = !1;
365
386
  for (; rest; ) {
387
+ let altered = parseAlteredDegree(rest);
388
+ if (altered) {
389
+ if (needsDegree) return;
390
+ (result.altered ??= []).push({
391
+ degree: altered.degree,
392
+ alter: altered.alter
393
+ }), operation = void 0, afterDegree = !0, afterComma = !1, rest = rest.slice(altered.length).trimStart();
394
+ continue;
395
+ }
366
396
  let marker = /^(add|omit|no)/.exec(rest)?.[0];
367
397
  if (marker) {
368
398
  if (needsDegree) return;
@@ -386,14 +416,16 @@ function parseModifiers(text) {
386
416
  return;
387
417
  rest = rest.slice(1).trimStart();
388
418
  }
389
- if (!(inGroup || needsDegree || !afterDegree || afterComma))
390
- return {
391
- added: [...new Set(result.added)].sort((a, b) => a - b),
392
- omitted: [...new Set(result.omitted)].sort((a, b) => a - b)
393
- };
419
+ return finishModifiers(result, inGroup, needsDegree, afterDegree, afterComma);
394
420
  }
395
421
  var ADDED_INTERVALS = { 2: 2, 4: 5, 6: 9, 7: 10, 9: 2, 11: 5, 13: 9 };
396
422
  function applyModifiers(degrees, modifiers) {
423
+ let altered = /* @__PURE__ */ new Map();
424
+ for (let { degree, alter } of modifiers?.altered ?? []) {
425
+ let natural = degree === 5 ? 7 : ADDED_INTERVALS[degree];
426
+ altered.set(degree, [...altered.get(degree) ?? [], natural + alter]);
427
+ }
428
+ for (let [degree, intervals] of altered) degrees.set(degree, intervals);
397
429
  for (let degree of modifiers?.added ?? [])
398
430
  degrees.set(degree, [...degrees.get(degree) ?? [], ADDED_INTERVALS[degree]]);
399
431
  for (let degree of modifiers?.omitted ?? []) degrees.delete(degree);
@@ -582,11 +614,13 @@ function parseBaseQuality(text) {
582
614
  return extension === void 0 ? { quality } : { quality, extension };
583
615
  }
584
616
  function parseQuality(text) {
585
- let boundary = text.search(/add|omit|no|\(/i);
617
+ let complete = parseBaseQuality(text);
618
+ if (complete) return complete;
619
+ let boundary = text.search(/add|omit|no|\(|[b#♭♯](?:13|11|9|5)/iu);
586
620
  if (boundary < 0) return parseBaseQuality(text);
587
621
  let base = parseBaseQuality(text.slice(0, boundary).trim()), modifiers = parseModifiers(text.slice(boundary));
588
622
  if (!(!base || !modifiers))
589
- return base.quality === "major" && base.extension === void 0 && modifiers.added.length === 1 && modifiers.added[0] === 6 && modifiers.omitted.length === 0 ? { quality: "major", extension: 6 } : { ...base, modifiers };
623
+ return base.quality === "major" && base.extension === void 0 && modifiers.added.length === 1 && modifiers.added[0] === 6 && modifiers.omitted.length === 0 && !modifiers.altered?.length ? { quality: "major", extension: 6 } : { ...base, modifiers };
590
624
  }
591
625
  function parseChordSymbolText(input, position) {
592
626
  let parts = splitChordSymbolText(input);
@@ -2231,9 +2265,9 @@ function interpolateBeatAnchors(bounds, beat, endX) {
2231
2265
 
2232
2266
  // packages/score-engine/src/buildInfo.ts
2233
2267
  init_define_import_meta_env();
2234
- var PACKAGE_VERSION = "0.2.1-next.41", BUNDLE_WORKER_FILE = "score-engine.worker.js";
2268
+ var PACKAGE_VERSION = "0.2.1-next.42", BUNDLE_WORKER_FILE = "score-engine.worker.js";
2235
2269
  function buildCommit() {
2236
- return "715e88d4c5a7118150857b4e89598e4560d44b58";
2270
+ return "addcef3a89be5d1d76a3f918b2aacbdc0dd7c30a";
2237
2271
  }
2238
2272
  function bundledAssetBase() {
2239
2273
  let moduleUrl = import.meta.url;
@@ -17439,6 +17473,17 @@ function compileNavigation(measures, initialOrder, measureLengths) {
17439
17473
 
17440
17474
  // packages/midi/src/navigation/realization.ts
17441
17475
  init_define_import_meta_env();
17476
+ function navigationNoteKey(event) {
17477
+ return `${event.partIndex}:${event.playbackLaneId ?? event.channel}:${event.midiNote}:${event.drumKitProgram ?? ""}`;
17478
+ }
17479
+ function releaseUncontinuedNotes(activeNotes, continuations, time, out) {
17480
+ let continued = new Set(continuations.map(navigationNoteKey));
17481
+ for (let [key, pending] of activeNotes)
17482
+ if (!continued.has(key)) {
17483
+ for (let onset of pending) out.push({ ...onset, type: "noteOff", velocity: 0, time });
17484
+ pending.length = 0;
17485
+ }
17486
+ }
17442
17487
  function authoredOnsetInSpan(event, start, end) {
17443
17488
  return event.type === "noteOn" && event.scoreBeat !== void 0 && event.scoreBeat >= start - 1e-9 && event.scoreBeat < end - 1e-9;
17444
17489
  }
@@ -17469,7 +17514,7 @@ function spanTempoMap(timeline, index, span, model, outputBeat) {
17469
17514
  }
17470
17515
  return entries;
17471
17516
  }
17472
- function realizeNavigation(timeline, spans) {
17517
+ function realizeNavigation(timeline, spans, crossJumpContinuations = /* @__PURE__ */ new Map()) {
17473
17518
  let sourceSpans = spans.map((span, index) => ({
17474
17519
  startBeat: timeline.measureStartBeats[index] + span.startBeat,
17475
17520
  endBeat: timeline.measureStartBeats[index] + span.endBeat
@@ -17477,7 +17522,7 @@ function realizeNavigation(timeline, spans) {
17477
17522
  for (let index = 0; index < spans.length; index++) {
17478
17523
  let span = spans[index], source = sourceSpans[index], startTime = timeline.model.timeAtBeat(source.startBeat), endTime = timeline.model.timeAtBeat(source.endBeat), controllers = /* @__PURE__ */ new Map(), segmentStart = events.length;
17479
17524
  measureStartBeats.push(outputBeat), tempoMap.push(...spanTempoMap(timeline, index, span, model, outputBeat));
17480
- let translate = (time) => model.timeAtBeat(outputBeat) + Math.min(endTime - startTime, Math.max(0, time - startTime)), noteKey = (event) => `${event.partIndex}:${event.playbackLaneId ?? event.channel}:${event.midiNote}:${event.drumKitProgram ?? ""}`;
17525
+ let translate = (time) => model.timeAtBeat(outputBeat) + Math.min(endTime - startTime, Math.max(0, time - startTime));
17481
17526
  for (let event of timeline.events) {
17482
17527
  let authoredOnset = authoredOnsetInSpan(event, source.startBeat, source.endBeat);
17483
17528
  if (event.time < startTime - 1e-9 && !authoredOnset) {
@@ -17501,10 +17546,10 @@ function realizeNavigation(timeline, spans) {
17501
17546
  )
17502
17547
  };
17503
17548
  events.push(output);
17504
- let key = noteKey(event), pending = activeNotes.get(key) ?? [];
17549
+ let key = navigationNoteKey(event), pending = activeNotes.get(key) ?? [];
17505
17550
  pending.push(output), activeNotes.set(key, pending);
17506
17551
  } else if (event.type === "noteOff") {
17507
- let pending = activeNotes.get(noteKey(event));
17552
+ let pending = activeNotes.get(navigationNoteKey(event));
17508
17553
  if (!pending?.length) continue;
17509
17554
  pending.shift(), events.push({ ...event, time: translate(event.time) });
17510
17555
  } else event.time < endTime - 1e-9 && events.push({ ...event, time: translate(event.time) });
@@ -17515,18 +17560,12 @@ function realizeNavigation(timeline, spans) {
17515
17560
  ...Array.from(controllers.values(), (event) => ({ ...event, time: model.timeAtBeat(outputBeat) }))
17516
17561
  );
17517
17562
  let next = spans[index + 1];
17518
- if (!playedSpansContinue(span, next))
17519
- for (let pending of activeNotes.values()) {
17520
- for (let onset of pending)
17521
- events.push({
17522
- ...onset,
17523
- type: "noteOff",
17524
- velocity: 0,
17525
- time: model.timeAtBeat(outputBeat + span.endBeat - span.startBeat, "release")
17526
- });
17527
- pending.length = 0;
17528
- }
17529
- outputBeat += span.endBeat - span.startBeat;
17563
+ playedSpansContinue(span, next) || releaseUncontinuedNotes(
17564
+ activeNotes,
17565
+ crossJumpContinuations.get(index + 1) ?? [],
17566
+ model.timeAtBeat(outputBeat + span.endBeat - span.startBeat, "release"),
17567
+ events
17568
+ ), outputBeat += span.endBeat - span.startBeat;
17530
17569
  }
17531
17570
  let rank = { noteOff: 0, programChange: 1, controlChange: 2, noteOn: 3 };
17532
17571
  return events.sort((a, b) => a.time - b.time || rank[a.type] - rank[b.type]), {
@@ -18202,6 +18241,44 @@ function applyMeasureTechniques(expressions, ctx, state, caps, ratioForStaff = (
18202
18241
  return { program, muted, ...instrumentKey === void 0 ? {} : { instrumentKey } };
18203
18242
  }
18204
18243
 
18244
+ // packages/midi/src/tiePlayback/index.ts
18245
+ init_define_import_meta_env();
18246
+ function tiePlaybackKey(pitch, partIndex, voice) {
18247
+ return `${pitch}:${partIndex}:${voice}`;
18248
+ }
18249
+ function recordTieTargets(state, key, note) {
18250
+ let targets = note.ties?.filter((tie) => tie.lv !== !0 && tie.target !== void 0) ?? [];
18251
+ targets.length ? state.set(key, targets) : state.delete(key);
18252
+ }
18253
+ function tieContinuesTo(state, key, note) {
18254
+ return note.id !== void 0 && (state.get(key)?.some((tie) => tie.target === note.id) ?? !1);
18255
+ }
18256
+ function notesAtBeat(content, target, notes, offset = 0, ratio = 1) {
18257
+ let beat = offset;
18258
+ for (let item of content) {
18259
+ if (item.type === "event")
18260
+ Math.abs(beat - target) < 1e-9 && notes.push(...item.notes ?? []), beat += durationBeats4(item.duration) * ratio;
18261
+ else if (item.type === "tuplet") {
18262
+ let factor = durationBeats4(item.outer.duration) * item.outer.multiple / (durationBeats4(item.inner.duration) * item.inner.multiple);
18263
+ beat = notesAtBeat(item.content, target, notes, beat, ratio * factor);
18264
+ } else item.type === "space" ? beat += item.duration[0] / item.duration[1] * 4 * ratio : item.type === "tremolo" && (Math.abs(beat - target) < 1e-9 && notes.push(...item.content[0]?.notes ?? []), beat += durationBeats4(item.outer.duration) * item.outer.multiple * ratio);
18265
+ if (beat > target + 1e-9) break;
18266
+ }
18267
+ return beat;
18268
+ }
18269
+ function crossJumpContinuationKeys(state, measure, startBeat, partIndex, ratioForStaff) {
18270
+ let keys = /* @__PURE__ */ new Set();
18271
+ for (let [index, sequence] of (measure?.sequences ?? []).entries()) {
18272
+ let notes = [];
18273
+ notesAtBeat(sequence.content, startBeat / ratioForStaff(sequence.staff ?? 1), notes);
18274
+ for (let note of notes) {
18275
+ let key = tiePlaybackKey(pitchToMidi(note.pitch), partIndex, sequence.voice ?? `v${index}`);
18276
+ note.id !== void 0 && state.get(key)?.some((tie) => tie.targetType === "crossJump" && tie.target === note.id) && keys.add(key);
18277
+ }
18278
+ }
18279
+ return keys;
18280
+ }
18281
+
18205
18282
  // packages/midi/src/instrumentPlayback/index.ts
18206
18283
  init_define_import_meta_env();
18207
18284
  function instrumentPart(part, instrument) {
@@ -18955,7 +19032,15 @@ function generateTimeline(inputScore, options) {
18955
19032
  let expandedGlobal = playbackGlobalMeasures(score, measureOrder), holdSchedule = suppressCadenzaFermataHolds(
18956
19033
  expandedGlobal,
18957
19034
  buildHoldSchedule(score, measureOrder, globalMeasures)
18958
- ), { tempoMap } = buildTempoMap(expandedGlobal, holdSchedule), tempoBuild = buildTempoModel(expandedGlobal, holdSchedule), { model, measureStartTimes, measureStartBeats } = tempoBuild, rhythm = compileRhythmProgram(score), timing = { tempoMap, measureStartTimes, model, measureStartBeats, playedSpans: spans, rhythm }, allEvents = [], partChannels = allocatePartChannels(score.parts);
19035
+ ), { tempoMap } = buildTempoMap(expandedGlobal, holdSchedule), tempoBuild = buildTempoModel(expandedGlobal, holdSchedule), { model, measureStartTimes, measureStartBeats } = tempoBuild, rhythm = compileRhythmProgram(score), crossJumpContinuations = /* @__PURE__ */ new Map(), timing = {
19036
+ tempoMap,
19037
+ measureStartTimes,
19038
+ model,
19039
+ measureStartBeats,
19040
+ playedSpans: spans,
19041
+ rhythm,
19042
+ crossJumpContinuations
19043
+ }, allEvents = [], partChannels = allocatePartChannels(score.parts);
18959
19044
  for (let partIdx = 0; partIdx < score.parts.length; partIdx++)
18960
19045
  processPart(
18961
19046
  score,
@@ -19005,7 +19090,7 @@ function generateTimeline(inputScore, options) {
19005
19090
  measureTimeSignatures,
19006
19091
  diagnostics
19007
19092
  };
19008
- return spans && (globalMeasures.some((measure) => measure.jump !== void 0) || spans.some((span) => span.startBeat > 0 || span.endBeat < span.measureBeats)) ? realizeNavigation(result, spans) : { ...result, playedSpans: spans };
19093
+ return spans && (globalMeasures.some((measure) => measure.jump !== void 0) || spans.some((span) => span.startBeat > 0 || span.endBeat < span.measureBeats)) ? realizeNavigation(result, spans, crossJumpContinuations) : { ...result, playedSpans: spans };
19009
19094
  }
19010
19095
  function assignPhysicalMidiChannels(events, score) {
19011
19096
  let melodicChannels = [0, 1, 2, 3, 4, 5, 6, 7, 8, 10, 11, 12, 13, 14, 15], lanesByPart = /* @__PURE__ */ new Map();
@@ -19036,24 +19121,6 @@ function assignPhysicalMidiChannels(events, score) {
19036
19121
  event.channel = channelByLane.get(event.playbackLaneId ?? `part:${event.partIndex}`) ?? event.channel;
19037
19122
  return diagnostics;
19038
19123
  }
19039
- function collectNoteTieTargets(notes, targets) {
19040
- if (notes) {
19041
- for (let note of notes)
19042
- if (note.ties)
19043
- for (let tie of note.ties)
19044
- tie.target && targets.add(tie.target);
19045
- }
19046
- }
19047
- function collectTieTargets(content, targets) {
19048
- for (let item of content)
19049
- if (item.type === "event")
19050
- collectNoteTieTargets(item.notes, targets);
19051
- else if (item.type === "tuplet")
19052
- collectTieTargets(item.content, targets);
19053
- else if (item.type === "grace")
19054
- for (let evt of item.content)
19055
- collectNoteTieTargets(evt.notes, targets);
19056
- }
19057
19124
  function allocatePartChannels(parts) {
19058
19125
  let channels = [], melodicCounter = 0;
19059
19126
  for (let part of parts)
@@ -19081,18 +19148,17 @@ function buildKitAltProgramMap(part) {
19081
19148
  typeof comp.drumKit == "number" && (map ??= /* @__PURE__ */ new Map()).set(id, comp.drumKit);
19082
19149
  return map;
19083
19150
  }
19084
- function collectPartTieTargets(part, measureOrder) {
19085
- let tieTargets = /* @__PURE__ */ new Set();
19086
- for (let expandedIdx of measureOrder) {
19087
- let partMeasure = part.measures[expandedIdx];
19088
- if (partMeasure)
19089
- for (let seq of partMeasure.sequences)
19090
- collectTieTargets(seq.content, tieTargets);
19151
+ function retainCrossJumpNoteOffs(pending, targets, continuing, cutTime, continued, out) {
19152
+ for (let [key, noteOff] of pending) {
19153
+ if (continuing.has(key) && noteOff.time >= cutTime - 1e-7) {
19154
+ continued.push(noteOff);
19155
+ continue;
19156
+ }
19157
+ noteOff.time = Math.min(noteOff.time, cutTime), out.push(noteOff), pending.delete(key), targets.delete(key);
19091
19158
  }
19092
- return tieTargets;
19093
19159
  }
19094
19160
  function processPart(score, partIdx, options, partChannels, timing, measureOrder, globalMeasures, holdSchedule, impliedDynamics, allEvents) {
19095
- let { tempoMap, measureStartTimes, model, measureStartBeats } = timing, part = score.parts[partIdx], channel = partChannels[partIdx], gmProgram = options?.partPrograms?.[partIdx] ?? -1, kitMidiMap = buildKitMidiMap(score, part), kitAltProgramMap = buildKitAltProgramMap(part), tieTargets = collectPartTieTargets(part, measureOrder), pendingTieOffs = /* @__PURE__ */ new Map(), legatoOutIds = collectPartLegatoOut(part), pendingLegato = /* @__PURE__ */ new Map(), staffMeterTable = resolvePartStaffMeterTable(part, globalMeasures), dynamicProgram = compileDynamicProgram(
19161
+ let { tempoMap, measureStartTimes, model, measureStartBeats } = timing, part = score.parts[partIdx], channel = partChannels[partIdx], gmProgram = options?.partPrograms?.[partIdx] ?? -1, kitMidiMap = buildKitMidiMap(score, part), kitAltProgramMap = buildKitAltProgramMap(part), pendingTieTargets = /* @__PURE__ */ new Map(), pendingTieOffs = /* @__PURE__ */ new Map(), legatoOutIds = collectPartLegatoOut(part), pendingLegato = /* @__PURE__ */ new Map(), staffMeterTable = resolvePartStaffMeterTable(part, globalMeasures), dynamicProgram = compileDynamicProgram(
19096
19162
  part,
19097
19163
  partIdx,
19098
19164
  measureOrder,
@@ -19108,8 +19174,19 @@ function processPart(score, partIdx, options, partChannels, timing, measureOrder
19108
19174
  emitDynamicsCc11(lane.envelope, partIdx, channel, allEvents, lane.id);
19109
19175
  let techniqueState = { program: gmProgram, muted: !1, instrumentKey: "initial" }, instruments = new Map(playbackInstruments(part).map(({ key, part: instrumentPart2 }) => [key, instrumentPart2])), programForInstrument = (key) => key === "initial" ? gmProgram : options?.instrumentPrograms?.get(partIdx)?.get(key) ?? instruments.get(key)?._x?.viritura?.midiProgram ?? -1;
19110
19176
  for (let expandedIdx = 0; expandedIdx < measureOrder.length; expandedIdx++) {
19111
- expandedIdx > 0 && timing.playedSpans && !playedSpansContinue(timing.playedSpans[expandedIdx - 1], timing.playedSpans[expandedIdx]) && (allEvents.push(...pendingTieOffs.values(), ...Array.from(pendingLegato.values()).flat()), pendingTieOffs.clear(), pendingLegato.clear());
19112
19177
  let origMeasureIdx = measureOrder[expandedIdx], partMeasure = part.measures[origMeasureIdx];
19178
+ if (expandedIdx > 0 && timing.playedSpans && !playedSpansContinue(timing.playedSpans[expandedIdx - 1], timing.playedSpans[expandedIdx])) {
19179
+ let continuing = crossJumpContinuationKeys(
19180
+ pendingTieTargets,
19181
+ partMeasure,
19182
+ timing.playedSpans[expandedIdx].startBeat,
19183
+ partIdx,
19184
+ (staff) => staffMeterRatioAt(staffMeterTable, origMeasureIdx, staff)
19185
+ ), cutTime = model.timeAtBeat(
19186
+ measureStartBeats[expandedIdx - 1] + timing.playedSpans[expandedIdx - 1].endBeat
19187
+ ), continued = timing.crossJumpContinuations.get(expandedIdx) ?? [];
19188
+ retainCrossJumpNoteOffs(pendingTieOffs, pendingTieTargets, continuing, cutTime, continued, allEvents), timing.crossJumpContinuations.set(expandedIdx, continued), allEvents.push(...Array.from(pendingLegato.values()).flat()), pendingLegato.clear();
19189
+ }
19113
19190
  if (!partMeasure) continue;
19114
19191
  let measureStartTime = measureStartTimes[expandedIdx], measureStartBeat = measureStartBeats[expandedIdx], instrumentKey = playbackInstrumentKeyAt(part, origMeasureIdx), activeProgram = programForInstrument(instrumentKey), bowCapable = ARCO_CAPABLE_PROGRAMS.has(activeProgram), muteFamily = muteFamilyForProgram(activeProgram), techniqueEnabled = bowCapable || muteFamily !== null;
19115
19192
  instrumentKey !== techniqueState.instrumentKey && (techniqueState = { program: activeProgram, muted: !1, instrumentKey });
@@ -19125,7 +19202,7 @@ function processPart(score, partIdx, options, partChannels, timing, measureOrder
19125
19202
  measureStartBeat,
19126
19203
  partIndex: partIdx,
19127
19204
  channel,
19128
- tieTargets,
19205
+ pendingTieTargets,
19129
19206
  pendingTieOffs,
19130
19207
  legatoOutIds,
19131
19208
  pendingLegato,
@@ -19259,7 +19336,7 @@ function tryProcessSpecialEvent(ctx, event, measureStartTime, beatOffset, baseVe
19259
19336
  }
19260
19337
  function processNoteEvent(ctx, event, measureStartTime, beatOffset, baseVelocity, tupletRatio) {
19261
19338
  ctx.gmProgram = ctx.instrumentAtBeatOffset?.(beatOffset * ctx.staffMeterRatio).program ?? ctx.gmProgram;
19262
- let { partIndex, channel, tieTargets, out, timeSig, kitMidiMap, kitAltProgramMap } = ctx, beats = durationBeats4(event.duration) * tupletRatio;
19339
+ let { partIndex, channel, out, timeSig, kitMidiMap, kitAltProgramMap } = ctx, beats = durationBeats4(event.duration) * tupletRatio;
19263
19340
  if (!beatInPlayedSpan(beatOffset * ctx.staffMeterRatio, ctx.playedSpan)) return beats;
19264
19341
  if (isRest(event))
19265
19342
  return flushVoiceLegato(ctx.pendingLegato, ctx.voiceKey, ctx.out), beats;
@@ -19294,23 +19371,29 @@ function processNoteEvent(ctx, event, measureStartTime, beatOffset, baseVelocity
19294
19371
  for (let note of event.notes ?? []) {
19295
19372
  let midiNote = noteToMidi(note);
19296
19373
  if (midiNote < 0 || midiNote > 127) continue;
19297
- let noteId = note.id ?? "", isTiedTo = tieTargets.has(noteId) && ctx.pendingTieOffs.has(`${midiNote}:${partIndex}:${ctx.voiceKey}`), hasTieOut = note.ties?.some((t) => t.target) ?? !1;
19298
- isTiedTo ? extendOrFinalizeTie(ctx, midiNote, startTime, durationSec, durationScale, hasTieOut) : emitFreshNote(
19299
- ctx,
19300
- midiNote,
19301
- startTime,
19302
- timingOffset,
19303
- durationSec,
19304
- durationScale,
19305
- velocity,
19306
- hasTieOut,
19307
- legatoOut,
19308
- {
19309
- scoreBeat: ctx.measureStartBeat + beatOffset * ctx.staffMeterRatio,
19310
- scoreDurationBeats: beats * ctx.staffMeterRatio,
19311
- scoreEventId: event.id
19312
- }
19313
- );
19374
+ let key = tiePlaybackKey(midiNote, partIndex, ctx.voiceKey), isTiedTo = tieContinuesTo(ctx.pendingTieTargets, key, note) && ctx.pendingTieOffs.has(key), hasTieOut = note.ties?.some((t) => t.lv !== !0 && t.target) ?? !1;
19375
+ if (isTiedTo)
19376
+ extendOrFinalizeTie(ctx, midiNote, startTime, durationSec, durationScale, hasTieOut);
19377
+ else {
19378
+ let previous = ctx.pendingTieOffs.get(key);
19379
+ previous && (out.push(previous), ctx.pendingTieOffs.delete(key)), emitFreshNote(
19380
+ ctx,
19381
+ midiNote,
19382
+ startTime,
19383
+ timingOffset,
19384
+ durationSec,
19385
+ durationScale,
19386
+ velocity,
19387
+ hasTieOut,
19388
+ legatoOut,
19389
+ {
19390
+ scoreBeat: ctx.measureStartBeat + beatOffset * ctx.staffMeterRatio,
19391
+ scoreDurationBeats: beats * ctx.staffMeterRatio,
19392
+ scoreEventId: event.id
19393
+ }
19394
+ );
19395
+ }
19396
+ recordTieTargets(ctx.pendingTieTargets, key, note);
19314
19397
  }
19315
19398
  if (kitMidiMap) {
19316
19399
  let jitteredStart = Math.max(0, startTime + timingOffset);
@@ -19344,7 +19427,7 @@ function processNoteEvent(ctx, event, measureStartTime, beatOffset, baseVelocity
19344
19427
  return beats;
19345
19428
  }
19346
19429
  function extendOrFinalizeTie(ctx, midiNote, startTime, durationSec, durationScale, hasTieOut) {
19347
- let { partIndex, channel, pendingTieOffs, out } = ctx, tieKey = `${midiNote}:${partIndex}:${ctx.voiceKey}`, pending = pendingTieOffs.get(tieKey);
19430
+ let { partIndex, channel, pendingTieOffs, out } = ctx, tieKey = tiePlaybackKey(midiNote, partIndex, ctx.voiceKey), pending = pendingTieOffs.get(tieKey);
19348
19431
  pending && (hasTieOut ? pending.time = startTime + durationSec : (pending.time = startTime + durationSec * durationScale, out.push(pending), pendingTieOffs.delete(tieKey))), hasTieOut && !pendingTieOffs.has(tieKey) && pendingTieOffs.set(tieKey, {
19349
19432
  type: "noteOff",
19350
19433
  time: startTime + durationSec,
@@ -19365,7 +19448,7 @@ function emitFreshNote(ctx, midiNote, startTime, timingOffset, durationSec, dura
19365
19448
  channel,
19366
19449
  ...metadata
19367
19450
  }), hasTieOut) {
19368
- let tieKey = `${midiNote}:${partIndex}:${ctx.voiceKey}`;
19451
+ let tieKey = tiePlaybackKey(midiNote, partIndex, ctx.voiceKey);
19369
19452
  pendingTieOffs.set(tieKey, {
19370
19453
  type: "noteOff",
19371
19454
  time: startTime + durationSec,