@goplayerjuggler/abc-tools 1.0.0

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/.gitattributes ADDED
@@ -0,0 +1,2 @@
1
+ # Auto detect text files and perform LF normalization
2
+ * text=auto
@@ -0,0 +1,53 @@
1
+ name: Tests
2
+
3
+ on:
4
+ push:
5
+ branches: [ main, develop ]
6
+ pull_request:
7
+ branches: [ main ]
8
+
9
+ jobs:
10
+ test:
11
+ runs-on: ubuntu-latest
12
+
13
+ steps:
14
+ - uses: actions/checkout@v3
15
+
16
+ - name: Use Node.js (latest LTS)
17
+ uses: actions/setup-node@v3
18
+ with:
19
+ node-version: 'lts/*'
20
+ cache: 'npm'
21
+
22
+ - name: Install dependencies
23
+ run: npm ci
24
+
25
+ - name: Run linter
26
+ run: npm run lint
27
+
28
+ - name: Run tests
29
+ id: run-tests
30
+ run: npm test
31
+
32
+ # - name: Run tests and collect coverage
33
+ # id: run-tests
34
+ # run: |
35
+ # # Generate Jest coverage (creates coverage/ and coverage/lcov.info)
36
+ # # If your npm test script doesn't pass args to Jest, replace with: npx jest --coverage
37
+ # npm test -- --coverage --coverageReporters=lcov
38
+
39
+ # - name: Upload coverage artifact
40
+ # if: always()
41
+ # uses: actions/upload-artifact@v4
42
+ # with:
43
+ # name: coverage-report
44
+ # path: coverage/
45
+
46
+ # - name: Upload coverage to Codecov
47
+ # if: always()
48
+ # uses: codecov/codecov-action@v3
49
+ # with:
50
+ # files: coverage/lcov.info
51
+ # flags: unittests
52
+ # name: codecov-umbrella
53
+ # fail_ci_if_error: false
package/LICENSE ADDED
@@ -0,0 +1,22 @@
1
+ GNU GENERAL PUBLIC LICENSE
2
+ Version 3, 29 June 2007
3
+
4
+ Copyright (C) 2025 Malcolm Schonfield
5
+
6
+ This program is free software: you can redistribute it and/or modify
7
+ it under the terms of the GNU General Public License as published by
8
+ the Free Software Foundation, either version 3 of the License, or
9
+ (at your option) any later version.
10
+
11
+ This program is distributed in the hope that it will be useful,
12
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
13
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
14
+ GNU General Public License for more details.
15
+
16
+ You should have received a copy of the GNU General Public License
17
+ along with this program. If not, see <https://www.gnu.org/licenses/>.
18
+
19
+ ---
20
+
21
+ For the full text of the GNU General Public License v3.0, see:
22
+ https://www.gnu.org/licenses/gpl-3.0.txt
package/README.md ADDED
@@ -0,0 +1,96 @@
1
+ # abc-tools
2
+
3
+ A JavaScript library with utility functions for tunes written in ABC format. Particularly applicable to Irish and other traditional music.
4
+ # Features :
5
+ ## Modal contour sort algorithm
6
+ Sorts melodies by their modal contour, independent of key and mode.
7
+ The algorithm used for sorting is new/original, as far as I know, and is described [here](./docs/contour_sort.md)
8
+ ## Extracting initial bars and incipits
9
+
10
+ # About this project
11
+ The writing of the sort algorithm, its implementation along with implementation of other features, and the project setup, were all done with the help of Claude.ai and github copilot.
12
+
13
+ ## license
14
+
15
+ This project is licensed under the [GNU General Public License v3.0](LICENSE).
16
+
17
+ This means you are free to use, modify, and distribute this software, but any derivative works must also be distributed under the GPL-3.0 license. See the [LICENSE](LICENSE) file for full details.
18
+
19
+ ## installation
20
+
21
+ ```
22
+ npm install tune-contour-sort
23
+ ```
24
+
25
+ ## usage / sorting
26
+
27
+ ```javascript
28
+ const { getContour, sort, sortArray } = require('tune-contour-sort');
29
+
30
+ // Get sort object for a single tune
31
+ const abc = `X:1
32
+ L:1/8
33
+ K:G
34
+ GBG AGA`;
35
+
36
+ const sortObj = getContour(abc);
37
+ console.log(sortObj);
38
+
39
+
40
+ // Compare two tunes
41
+ const tune1 = getContour(abc1);
42
+ const tune2 = getContour(abc2);
43
+ const comparison = sort(tune1, tune2);
44
+ // Returns: -1 (tune1 < tune2), 0 (equal), or 1 (tune1 > tune2)
45
+
46
+ // Sort an array of tunes
47
+ const tunes = [
48
+ { title: 'Tune A', abc: '...' },
49
+ { title: 'Tune B', abc: '...' },
50
+ { title: 'Tune C', abc: '...' }
51
+ ];
52
+
53
+ const sorted = sortArray(tunes);
54
+ ```
55
+ ## API / sorting
56
+
57
+ ### `getContour(abc)`
58
+
59
+ Generates a sort object from ABC notation.
60
+
61
+ **Parameters:**
62
+ - `abc` (string): ABC notation string with headers (K:, L:, etc.)
63
+
64
+ **Returns:** Object with:
65
+ - `sortKey` (string): Hexadecimal encoding of the modal contour
66
+ - `rhythmicDivisions` (array, optional): Information about subdivided notes
67
+ - `version` (string): Algorithm version
68
+ - `part` (string): Tune part (default 'A')
69
+
70
+ ### `sort(sortObjA, sortObjB)`
71
+
72
+ Compares two sort objects.
73
+
74
+ **Parameters:**
75
+ - `sortObjA` (object): First sort object
76
+ - `sortObjB` (object): Second sort object
77
+
78
+ **Returns:** Number
79
+ - `-1` if A sorts before B
80
+ - `0` if equal
81
+ - `1` if A sorts after B
82
+
83
+ ### `sortArray(tuneArray)`
84
+
85
+ Sorts an array of tune objects.
86
+
87
+ **Parameters:**
88
+ - `tuneArray` (array): Array of objects with `abc` property
89
+
90
+ **Returns:** Sorted array (with `sortObject` added to each item)
91
+
92
+
93
+ ## contributing
94
+
95
+ Issues and pull requests welcome at https://github.com/goplayerjuggler/abc-tools
96
+
@@ -0,0 +1,44 @@
1
+ const js = require("@eslint/js");
2
+ const globals = require("globals");
3
+ const pluginJest = require('eslint-plugin-jest');
4
+
5
+ module.exports = [
6
+ {
7
+ ...js.configs.recommended,
8
+ plugins: { jest: pluginJest },
9
+ languageOptions: {
10
+ ...js.configs.recommended.languageOptions,
11
+ globals: {
12
+ ...globals.node,...pluginJest.environments.globals.globals
13
+ },
14
+ ecmaVersion: 12,
15
+ sourceType: "module",
16
+ },
17
+
18
+ ignores: [
19
+ "**/node_modules/",
20
+ "**/coverage/",
21
+ "**/dist/",
22
+ ],
23
+ rules: {
24
+ ...js.configs.recommended.rules,
25
+ // "arrow-spacing": "error"
26
+ "curly": ["error", "all"],
27
+ // "eol-last": ["error", "always"],
28
+ "eqeqeq": ["error", "always"],
29
+ "no-console": "off",
30
+ "no-eval": "error",
31
+ "no-implied-eval": "error",
32
+ // "no-trailing-spaces": "error",
33
+ "no-unused-vars": ["error", { argsIgnorePattern: "^_" }],
34
+ "no-var": "error",
35
+ "no-with": "error",
36
+ "prefer-const": "error",
37
+ "prefer-template": "error",
38
+ // "comma-dangle": ["error", "never"],
39
+ // "indent": ["error", 2],
40
+ // "quotes": ["error", "single", { avoidEscape: true }],
41
+ // "semi": ["error", "always"],
42
+ }
43
+ }
44
+ ];
package/package.json ADDED
@@ -0,0 +1,58 @@
1
+ {
2
+ "name": "@goplayerjuggler/abc-tools",
3
+ "version": "1.0.0",
4
+ "description": "sorting algorithm and implementation for ABC tunes; plus other tools to come later",
5
+ "main": "src/index.js",
6
+ "scripts": {
7
+ "test": "jest",
8
+ "test:watch": "jest --watch",
9
+ "test:coverage": "jest --coverage",
10
+ "lint": "eslint src test",
11
+ "lint:fix": "eslint src test --fix"
12
+ },
13
+ "keywords": [
14
+ "abc",
15
+ "music",
16
+ "melody",
17
+ "sorting",
18
+ "modal",
19
+ "tune",
20
+ "traditional",
21
+ "irish",
22
+ "folk"
23
+ ],
24
+ "author": "Malcolm Schonfield",
25
+ "license": "GPL-3.0-or-later",
26
+ "repository": {
27
+ "type": "git",
28
+ "url": "https://github.com/goplayerjuggler/abc-tools.git"
29
+ },
30
+ "bugs": {
31
+ "url": "https://github.com/goplayerjuggler/abc-tools/issues"
32
+ },
33
+ "homepage": "https://github.com/goplayerjuggler/abc-tools#readme",
34
+ "engines": {
35
+ "node": ">=12.0.0"
36
+ },
37
+ "devDependencies": {
38
+ "@eslint/eslintrc": "^3.3.1",
39
+ "@eslint/js": "^9.37.0",
40
+ "eslint": "^9.37.0",
41
+ "eslint-plugin-jest": "^29.0.1",
42
+ "globals": "^16.4.0",
43
+ "jest": "^30.2.0",
44
+ "jsdoc": "^4.0.5"
45
+ },
46
+ "jest": {
47
+ "testEnvironment": "node",
48
+ "coverageDirectory": "coverage",
49
+ "collectCoverageFrom": [
50
+ "src/**/*.js",
51
+ "!src/**/*.test.js"
52
+ ],
53
+ "testMatch": [
54
+ "**/test/**/*.test.js",
55
+ "**/?(*.)+(spec|test).js"
56
+ ]
57
+ }
58
+ }
@@ -0,0 +1,384 @@
1
+ const { Fraction } = require("./math.js");
2
+ const {
3
+ getTonalBase,
4
+ getUnitLength,
5
+ parseABCWithBars,
6
+ NOTE_TO_DEGREE,
7
+ } = require("./parser.js");
8
+
9
+ /**
10
+ * Tune Contour Sort - Modal melody sorting algorithm
11
+ * Sorts tunes by their modal contour, independent of key and mode
12
+ */
13
+
14
+ // ============================================================================
15
+ // CORE CONSTANTS
16
+ // ============================================================================
17
+
18
+ const OCTAVE_SHIFT = 7; // 7 scale degrees per octave
19
+
20
+ const baseChar = 0x0420; // middle of cyrillic
21
+ const silenceChar = "_"; // silence character
22
+
23
+ // ============================================================================
24
+ // ENCODING FUNCTIONS
25
+ // ============================================================================
26
+
27
+ /**
28
+ * Calculate modal position and octave offset for a note
29
+ * Returns a compact representation: octave * 7 + degree (both 0-indexed)
30
+ */
31
+ function calculateModalPosition(tonalBase, pitch, octaveShift) {
32
+ const tonalDegree = NOTE_TO_DEGREE[tonalBase];
33
+ const noteDegree = NOTE_TO_DEGREE[pitch.toUpperCase()];
34
+
35
+ // Calculate relative degree (how many scale steps from tonic)
36
+ const relativeDegree = (noteDegree - tonalDegree + 7) % 7;
37
+
38
+ // Adjust octave: lowercase notes are one octave higher
39
+ let octave = octaveShift;
40
+ if (pitch === pitch.toLowerCase()) {
41
+ octave += 1;
42
+ }
43
+
44
+ // Return position as single number: octave * 7 + degree
45
+ // Using offset of 2 octaves to keep values positive
46
+ return (octave + 2) * OCTAVE_SHIFT + relativeDegree;
47
+ }
48
+
49
+ /**
50
+ * Encode position and played/held status as a single character
51
+ * This ensures held notes (even codes) sort before played notes (odd codes)
52
+ *
53
+ * @param {number} position - encodes the degree + octave
54
+ * @param {boolean} isHeld - if the note is held or not
55
+ * @returns the encoded modal degree information (MDI). Format: baseChar + (position * 2) + (isHeld ? 0 : 1)
56
+ */
57
+ function encodeToChar(position, isHeld) {
58
+ const code = baseChar + position * 2 + (isHeld ? 0 : 1);
59
+ return String.fromCharCode(code);
60
+ }
61
+
62
+ /**
63
+ * Decode a character back to position and held status
64
+ */
65
+ function decodeChar(char) {
66
+ if (char === silenceChar) {
67
+ return { isSilence: true, position: null, isHeld: null };
68
+ }
69
+
70
+ const code = char.charCodeAt(0) - baseChar;
71
+ const position = Math.floor(code / 2);
72
+ const isHeld = code % 2 === 0;
73
+ return { position, isHeld, isSilence: false };
74
+ }
75
+
76
+ // ============================================================================
77
+ // SORT OBJECT (contour) GENERATION
78
+ // ============================================================================
79
+
80
+ /**
81
+ * Generate sort object from ABC notation
82
+ * @returns { sortKey: string, durations: Array, version: string, part: string }
83
+ */
84
+ function getContour(abc, options = {}) {
85
+ const tonalBase = getTonalBase(abc);
86
+ const unitLength = getUnitLength(abc);
87
+ const { bars } = parseABCWithBars(abc, options);
88
+
89
+ const sortKey = [];
90
+ const durations = [];
91
+ let index = 0;
92
+ // get the parsed notes - notes are tokens with a duration
93
+ const notes = [];
94
+ for (let i = 0; i < bars.length; i++) {
95
+ const bar = bars[i];
96
+ for (let j = 0; j < bar.length; j++) {
97
+ const token = bar[j];
98
+ if (token.duration) {
99
+ notes.push(token);
100
+ }
101
+ }
102
+ }
103
+
104
+ notes.forEach((note) => {
105
+ const { duration, isSilence } = note;
106
+ const comparison = duration.compare(unitLength);
107
+ const { encoded, encodedHeld } = isSilence
108
+ ? { encoded: silenceChar, encodedHeld: silenceChar }
109
+ : getEncodedFromNote(note, tonalBase);
110
+
111
+ if (comparison > 0) {
112
+ // Held note: duration > unitLength
113
+ const ratio = duration.divide(unitLength);
114
+ const nbUnitLengths = Math.floor(ratio.num / ratio.den);
115
+ const remainingDuration = duration.subtract(
116
+ unitLength.multiply(nbUnitLengths)
117
+ );
118
+
119
+ // const durationRatio = Math.round(ratio.num / ratio.den);
120
+
121
+ // First note is played
122
+ sortKey.push(encoded);
123
+
124
+ // Subsequent notes are held
125
+ for (let i = 1; i < nbUnitLengths; i++) {
126
+ sortKey.push(encodedHeld);
127
+ }
128
+
129
+ index += nbUnitLengths;
130
+ if (remainingDuration.num !== 0) {
131
+ pushShortNote(encoded, unitLength, duration, index, durations, sortKey);
132
+ index++;
133
+ }
134
+ } else if (comparison < 0) {
135
+ pushShortNote(encoded, unitLength, duration, index, durations, sortKey);
136
+
137
+ index++;
138
+ } else {
139
+ // Normal note: duration === unitLength
140
+ sortKey.push(encoded);
141
+ index++;
142
+ }
143
+ });
144
+
145
+ return {
146
+ sortKey: sortKey.join(""),
147
+ durations: durations.length > 0 ? durations : undefined,
148
+ // version: "1.0",
149
+ // part,
150
+ };
151
+ }
152
+
153
+ /**
154
+ * Adds a short note (duration < unitLength) to the contour
155
+ * @param {string} encoded - the encoded representation of the note’s modal degree information (MDI)
156
+ * @param {Fraction} unitLength - the unit length
157
+ * @param {Fraction} duration - the duration of the note
158
+ * @param {number} index - the index of the note
159
+ * @param {Array<object>} durations - the durations array
160
+ * @param {Array<string>} sortKey - array of MDIs
161
+ */
162
+ function pushShortNote(
163
+ encoded,
164
+ unitLength,
165
+ duration,
166
+ index,
167
+ durations,
168
+ sortKey
169
+ ) {
170
+ const relativeDuration = duration.divide(unitLength);
171
+
172
+ durations.push({
173
+ i: index,
174
+ n: relativeDuration.num === 1 ? undefined : relativeDuration.num,
175
+ d: relativeDuration.den,
176
+ });
177
+ sortKey.push(encoded);
178
+ }
179
+
180
+ // ============================================================================
181
+ // COMPARISON FUNCTIONS
182
+ // ============================================================================
183
+
184
+ /**
185
+ * Compare two sort objects using expansion algorithm
186
+ */
187
+ function sort(objA, objB) {
188
+ let keyA = objA.sortKey;
189
+ let keyB = objB.sortKey;
190
+
191
+ const dursA = objA.durations || [];
192
+ const dursB = objB.durations || [];
193
+
194
+ // No durations: simple lexicographic comparison
195
+ if (dursA.length === 0 && dursB.length === 0) {
196
+ return keyA === keyB ? 0 : keyA < keyB ? -1 : 1;
197
+ }
198
+
199
+ // Build maps of position -> {n, d}
200
+ const durMapA = Object.fromEntries(
201
+ dursA.map((dur) => [dur.i, { n: dur.n || 1, d: dur.d }])
202
+ );
203
+ const durMapB = Object.fromEntries(
204
+ dursB.map((dur) => [dur.i, { n: dur.n || 1, d: dur.d }])
205
+ );
206
+
207
+ let posA = 0;
208
+ let posB = 0;
209
+ let logicalIndex = 0;
210
+ let counter = 0;
211
+
212
+ while (posA < keyA.length && posB < keyB.length) {
213
+ if (counter++ > 10000) {
214
+ throw new Error("Sort algorithm iteration limit exceeded");
215
+ }
216
+
217
+ const durA = durMapA[logicalIndex];
218
+ const durB = durMapB[logicalIndex];
219
+
220
+ // Get durations as fractions
221
+ const fracA = durA ? new Fraction(durA.n, durA.d) : new Fraction(1, 1);
222
+ const fracB = durB ? new Fraction(durB.n, durB.d) : new Fraction(1, 1);
223
+
224
+ const comp = fracA.compare(fracB);
225
+
226
+ if (comp === 0) {
227
+ // Same duration, compare characters directly
228
+ const charA = keyA.charAt(posA);
229
+ const charB = keyB.charAt(posB);
230
+
231
+ if (charA < charB) {
232
+ return -1;
233
+ }
234
+ if (charA > charB) {
235
+ return 1;
236
+ }
237
+
238
+ posA++;
239
+ posB++;
240
+ logicalIndex++;
241
+ } else if (comp < 0) {
242
+ // fracA < fracB: expand B by inserting held note
243
+ const charA = keyA.charAt(posA);
244
+ const charB = keyB.charAt(posB);
245
+
246
+ if (charA < charB) {
247
+ return -1;
248
+ }
249
+ if (charA > charB) {
250
+ return 1;
251
+ }
252
+
253
+ // Insert held note into B
254
+ const decodedB = decodeChar(charB);
255
+ const heldChar = decodedB.isSilence
256
+ ? silenceChar
257
+ : encodeToChar(decodedB.position, true);
258
+
259
+ keyB = keyB.substring(0, posB + 1) + heldChar + keyB.substring(posB + 1);
260
+
261
+ // Update duration map for B
262
+ const remainingDur = fracB.subtract(fracA);
263
+ delete durMapB[logicalIndex];
264
+
265
+ // Add new duration entry for the held note
266
+ durMapB[logicalIndex + 1] = { n: remainingDur.num, d: remainingDur.den };
267
+
268
+ // Shift all subsequent B durations by 1
269
+ const newDurMapB = {};
270
+ for (const idx in durMapB) {
271
+ const numIdx = parseInt(idx);
272
+ if (numIdx > logicalIndex + 1) {
273
+ newDurMapB[numIdx + 1] = durMapB[idx];
274
+ } else {
275
+ newDurMapB[numIdx] = durMapB[idx];
276
+ }
277
+ }
278
+ Object.assign(durMapB, newDurMapB);
279
+
280
+ posA++;
281
+ posB++;
282
+ logicalIndex++;
283
+ } else {
284
+ // fracA > fracB: expand A by inserting held note
285
+ const charA = keyA.charAt(posA);
286
+ const charB = keyB.charAt(posB);
287
+
288
+ if (charA < charB) {
289
+ return -1;
290
+ }
291
+ if (charA > charB) {
292
+ return 1;
293
+ }
294
+
295
+ // Insert held note into A
296
+ const decodedA = decodeChar(charA);
297
+ const heldChar = decodedA.isSilence
298
+ ? silenceChar
299
+ : encodeToChar(decodedA.position, true);
300
+
301
+ keyA = keyA.substring(0, posA + 1) + heldChar + keyA.substring(posA + 1);
302
+
303
+ // Update duration map for A
304
+ const remainingDur = fracA.subtract(fracB);
305
+ delete durMapA[logicalIndex];
306
+
307
+ durMapA[logicalIndex + 1] = { n: remainingDur.num, d: remainingDur.den };
308
+
309
+ // Shift all subsequent A durations by 1
310
+ const newDurMapA = {};
311
+ for (const idx in durMapA) {
312
+ const numIdx = parseInt(idx);
313
+ if (numIdx > logicalIndex + 1) {
314
+ newDurMapA[numIdx + 1] = durMapA[idx];
315
+ } else {
316
+ newDurMapA[numIdx] = durMapA[idx];
317
+ }
318
+ }
319
+ Object.assign(durMapA, newDurMapA);
320
+
321
+ posA++;
322
+ posB++;
323
+ logicalIndex++;
324
+ }
325
+ }
326
+
327
+ if (posA >= keyA.length && posB >= keyB.length) {
328
+ return 0;
329
+ }
330
+ return posA >= keyA.length ? -1 : 1;
331
+ }
332
+
333
+ /**
334
+ * Sort an array of objects containing ABC notation
335
+ */
336
+ function sortArray(arr) {
337
+ for (const item of arr) {
338
+ if (!item.sortObject && item.abc) {
339
+ try {
340
+ item.sortObject = getContour(item.abc);
341
+ } catch (err) {
342
+ console.error(`Failed to generate sort object: ${err.message}`);
343
+ item.sortObject = null;
344
+ }
345
+ }
346
+ }
347
+
348
+ arr.sort((a, b) => {
349
+ if (!a.sortObject && !b.sortObject) {
350
+ return 0;
351
+ }
352
+ if (!a.sortObject) {
353
+ return 1;
354
+ }
355
+ if (!b.sortObject) {
356
+ return -1;
357
+ }
358
+ return sort(a.sortObject, b.sortObject);
359
+ });
360
+
361
+ return arr;
362
+ }
363
+
364
+ function getEncodedFromNote(note, tonalBase) {
365
+ // Handle pitched note
366
+ const { pitch, octave } = note;
367
+ const position = calculateModalPosition(tonalBase, pitch, octave);
368
+ const encodedHeld = encodeToChar(position, true);
369
+ const encoded = encodeToChar(position, false);
370
+ return { encoded, encodedHeld };
371
+ }
372
+
373
+ // ============================================================================
374
+ // EXPORTS
375
+ // ============================================================================
376
+
377
+ module.exports = {
378
+ getContour,
379
+ sort,
380
+ sortArray,
381
+ decodeChar,
382
+ encodeToChar,
383
+ calculateModalPosition,
384
+ };
package/src/index.js ADDED
@@ -0,0 +1,19 @@
1
+ /**
2
+ * ABC Music Toolkit
3
+ * Main entry point for ABC parsing, manipulation, and sorting
4
+ */
5
+
6
+ const parser = require('./parser.js');
7
+ const manipulator = require('./manipulator.js');
8
+ const sort = require('./contour-sort.js');
9
+
10
+ module.exports = {
11
+ // Parser functions
12
+ ...parser,
13
+
14
+ // Manipulator functions
15
+ ...manipulator,
16
+
17
+ // Sort functions
18
+ ...sort
19
+ };