audio-mixer-engine 1.3.1 → 1.3.2

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "audio-mixer-engine",
3
- "version": "1.3.1",
3
+ "version": "1.3.2",
4
4
  "description": "Audio engine library for audio mixer applications with MIDI parsing, playback, and synthesis",
5
5
  "main": "dist/audio-mixer-engine.cjs.js",
6
6
  "module": "dist/audio-mixer-engine.es.js",
@@ -38,6 +38,10 @@ export class TimingEngine {
38
38
 
39
39
  // Time signature changes: array of { tick, numerator, denominator }
40
40
  this.timeSigChanges = [{ tick: 0, numerator: 4, denominator: 4 }];
41
+
42
+ // Swing map: array of { tick, first, second, swingType } or { tick, straight: true }
43
+ // Built during the main walk; always starts empty and is populated measure by measure.
44
+ this.swingMap = [];
41
45
  }
42
46
 
43
47
  /**
@@ -87,6 +91,69 @@ export class TimingEngine {
87
91
  }
88
92
  }
89
93
 
94
+ /**
95
+ * Record a swing directive at the current tick position.
96
+ * Called when a <swing> element is found inside a <sound> direction.
97
+ *
98
+ * @param {Object} params
99
+ * @param {boolean} [params.straight] - If true, turns swing off (play straight)
100
+ * @param {number} [params.first] - Ratio numerator for the on-beat note
101
+ * @param {number} [params.second] - Ratio denominator for the off-beat note
102
+ * @param {string} [params.swingType] - 'eighth' (default) or 'sixteenth'
103
+ */
104
+ processSwing(params) {
105
+ this.swingMap.push({ tick: this.currentTick, ...params });
106
+ }
107
+
108
+ /**
109
+ * Apply swing timing transformation to a straight tick position.
110
+ *
111
+ * For each beat (defined by the swing type), the straight midpoint is shifted
112
+ * to the swing ratio point:
113
+ * swingPoint = swingUnit × first / (first + second)
114
+ * Notes before the midpoint are stretched; notes after are compressed.
115
+ * Beat boundaries (multiples of swingUnit) are never moved.
116
+ *
117
+ * @param {number} tick - Original (straight) canonical tick
118
+ * @returns {number} Swung canonical tick
119
+ */
120
+ applySwingToTick(tick) {
121
+ // Find the active swing parameters at this tick
122
+ let active = null;
123
+ for (const entry of this.swingMap) {
124
+ if (entry.tick > tick) break;
125
+ active = entry.straight ? null : entry;
126
+ }
127
+ if (!active) return tick; // No swing active — return unchanged
128
+
129
+ const { first, second, swingType } = active;
130
+
131
+ // Swing unit: the note-pair duration that gets swung
132
+ // 'eighth' → quarter note = ticksPerBeat; 'sixteenth' → eighth note = ticksPerBeat/2
133
+ const swingUnit = swingType === 'sixteenth'
134
+ ? this.ticksPerBeat / 2
135
+ : this.ticksPerBeat;
136
+
137
+ const straightHalf = swingUnit / 2;
138
+ const swingPoint = swingUnit * first / (first + second);
139
+
140
+ const beatIndex = Math.floor(tick / swingUnit);
141
+ const beatOffset = tick - beatIndex * swingUnit;
142
+
143
+ let mappedOffset;
144
+ if (beatOffset <= straightHalf) {
145
+ // First half: stretch from [0, straightHalf] → [0, swingPoint]
146
+ mappedOffset = beatOffset * swingPoint / straightHalf;
147
+ } else {
148
+ // Second half: compress from (straightHalf, swingUnit] → (swingPoint, swingUnit]
149
+ mappedOffset = swingPoint +
150
+ (beatOffset - straightHalf) * (swingUnit - swingPoint) / straightHalf;
151
+ }
152
+
153
+ return Math.round(beatIndex * swingUnit + mappedOffset);
154
+ }
155
+
156
+
90
157
  /**
91
158
  * Convert a MusicXML duration value (in source divisions) to canonical ticks.
92
159
  * @param {number} xmlDuration - Duration from <duration> element
@@ -10,7 +10,7 @@
10
10
  * const parsedData = converter.convert(musicXmlString, { defaultTempo: 120 });
11
11
  * await playbackManager.load(parsedData);
12
12
  */
13
- import { parseXml, getChild, getChildren, getAttr } from './musicxml/xml-helpers.js';
13
+ import { parseXml, getChild, getChildren, getAttr, getChildText, getChildNumber } from './musicxml/xml-helpers.js';
14
14
  import { extractMetadata } from './musicxml/metadata-extractor.js';
15
15
  import { TimingEngine, DEFAULT_TICKS_PER_BEAT } from './musicxml/timing-engine.js';
16
16
  import { NoteExtractor } from './musicxml/note-extractor.js';
@@ -147,13 +147,28 @@ class MusicXmlConverter {
147
147
  }
148
148
  }
149
149
  }
150
- // Check for tempo in directions
150
+ // Check for tempo and swing in directions
151
151
  const directions = getChildren(m, 'direction');
152
152
  for (const dir of directions) {
153
153
  const sound = getChild(dir, 'sound');
154
154
  if (sound) {
155
155
  const tempo = parseFloat(sound.getAttribute('tempo'));
156
156
  if (!isNaN(tempo) && tempo > 0) cache.tempo = tempo;
157
+
158
+ // Detect <swing> element
159
+ const swingEl = getChild(sound, 'swing');
160
+ if (swingEl) {
161
+ if (getChild(swingEl, 'straight')) {
162
+ cache.swing = { straight: true };
163
+ } else {
164
+ const first = getChildNumber(swingEl, 'first');
165
+ const second = getChildNumber(swingEl, 'second');
166
+ const swingType = getChildText(swingEl, 'swing-type') || 'eighth';
167
+ if (first !== null && second !== null && first > 0 && second > 0) {
168
+ cache.swing = { first, second, swingType };
169
+ }
170
+ }
171
+ }
157
172
  }
158
173
  }
159
174
  measureTimingCache.set(mi, cache);
@@ -182,6 +197,9 @@ class MusicXmlConverter {
182
197
  if (cachedTiming.tempo) {
183
198
  timingEngine.processTempo(cachedTiming.tempo);
184
199
  }
200
+ if (cachedTiming.swing) {
201
+ timingEngine.processSwing(cachedTiming.swing);
202
+ }
185
203
 
186
204
  // Generate barStructure entry at current tick position
187
205
  // Use actual beats from unrolled entry (handles partial bars)
@@ -223,6 +241,34 @@ class MusicXmlConverter {
223
241
  parts[partName].notes.push(...remainingNotes);
224
242
  }
225
243
 
244
+ // Sort notes by startTick within each part.
245
+ // Multi-staff parts (e.g. piano) use <backup> elements which produce
246
+ // unsorted notes when staves/voices are interleaved in the XML.
247
+ for (const part of Object.values(parts)) {
248
+ part.notes.sort((a, b) => a.startTick - b.startTick || a.pitch - b.pitch);
249
+ }
250
+
251
+ // Apply swing timing if any swing directives were found.
252
+ // Transforms note and lyric tick positions; does not affect barStructure.
253
+ if (timingEngine.swingMap.length > 0) {
254
+ for (const part of Object.values(parts)) {
255
+ for (const note of part.notes) {
256
+ const s = timingEngine.applySwingToTick(note.startTick);
257
+ const e = timingEngine.applySwingToTick(note.endTick);
258
+ note.startTick = s;
259
+ note.endTick = e;
260
+ note.duration = e - s;
261
+ note.startTime = timingEngine.tickToTime(s);
262
+ note.endTime = timingEngine.tickToTime(e);
263
+ }
264
+ for (const lyric of part.lyrics) {
265
+ const t = timingEngine.applySwingToTick(lyric.tick);
266
+ lyric.tick = t;
267
+ lyric.time = timingEngine.tickToTime(t);
268
+ }
269
+ }
270
+ }
271
+
226
272
  // 7. Build the parsedData output
227
273
  const partNames = metadata.partInfos.map((p, i) => ({
228
274
  index: i,