@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 +2 -0
- package/.github/workflows/test.yml +53 -0
- package/LICENSE +22 -0
- package/README.md +96 -0
- package/eslint.config.js +44 -0
- package/package.json +58 -0
- package/src/contour-sort.js +384 -0
- package/src/index.js +19 -0
- package/src/manipulator.js +480 -0
- package/src/math.js +90 -0
- package/src/parser.js +996 -0
package/.gitattributes
ADDED
|
@@ -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
|
+
|
package/eslint.config.js
ADDED
|
@@ -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
|
+
};
|