plainscore 0.0.0-stage → 0.1.1
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/LICENSE +21 -0
- package/README.md +84 -2
- package/dist/abc/index.d.ts +4 -0
- package/dist/abc/index.js +111 -0
- package/dist/abc/spell.d.ts +13 -0
- package/dist/abc/spell.js +57 -0
- package/dist/abc/voice.d.ts +17 -0
- package/dist/abc/voice.js +164 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +65 -0
- package/dist/expand.d.ts +61 -0
- package/dist/expand.js +271 -0
- package/dist/families.d.ts +11 -0
- package/dist/families.js +19 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.js +6 -0
- package/dist/legend.d.ts +1 -0
- package/dist/legend.js +19 -0
- package/dist/midi.d.ts +2 -0
- package/dist/midi.js +60 -0
- package/dist/parse.d.ts +92 -0
- package/dist/parse.js +404 -0
- package/dist/render.d.ts +41 -0
- package/dist/render.js +58 -0
- package/dist/staves.d.ts +19 -0
- package/dist/staves.js +49 -0
- package/dist/words.d.ts +65 -0
- package/dist/words.js +139 -0
- package/docs/grammar.md +183 -0
- package/llms.txt +72 -0
- package/package.json +45 -3
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Innei
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,85 @@
|
|
|
1
|
-
#
|
|
1
|
+
# plainscore
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
> A plain-text music score. One `.score` file reads like a composer's sketch, edits line by line, and compiles to ABC notation, MIDI and timed note events. TypeScript, no runtime dependencies.
|
|
4
|
+
|
|
5
|
+

|
|
6
|
+
|
|
7
|
+

|
|
8
|
+
|
|
9
|
+
<details>
|
|
10
|
+
<summary><code>song.score</code> as text</summary>
|
|
11
|
+
|
|
12
|
+
```
|
|
13
|
+
score 0.1
|
|
14
|
+
|
|
15
|
+
title Evening Song
|
|
16
|
+
by You
|
|
17
|
+
|
|
18
|
+
instruments
|
|
19
|
+
piano piano
|
|
20
|
+
cello cello
|
|
21
|
+
|
|
22
|
+
piece song Evening Song
|
|
23
|
+
tempo q = 72
|
|
24
|
+
key G major
|
|
25
|
+
|
|
26
|
+
chords verse — bass | voicing
|
|
27
|
+
G G2 | B3 D4 G4
|
|
28
|
+
D/F# F#2 | A3 D4 F#4
|
|
29
|
+
Em E2 | B3 E4 G4
|
|
30
|
+
C C3 | C4 E4 G4
|
|
31
|
+
|
|
32
|
+
part tune piano mf overlap e lead
|
|
33
|
+
| B4 h A4 q G4 q | A4 w | G4 h B4 h | C5 w |
|
|
34
|
+
|
|
35
|
+
part comp piano
|
|
36
|
+
1 bass w >
|
|
37
|
+
2 upper q soft
|
|
38
|
+
3& upper e softer
|
|
39
|
+
|
|
40
|
+
part pad cello mp
|
|
41
|
+
1 bass+8va w
|
|
42
|
+
|
|
43
|
+
part pedal
|
|
44
|
+
1 Ped.
|
|
45
|
+
|
|
46
|
+
form loop
|
|
47
|
+
A verse
|
|
48
|
+
tune, comp p, pad 80%, pedal
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
</details>
|
|
52
|
+
|
|
53
|
+

|
|
54
|
+
|
|
55
|
+

|
|
56
|
+
|
|
57
|
+
- Install: `pnpm add -D plainscore` (Node 20+).
|
|
58
|
+
- CLI: `plainscore check|abc|midi|events|legend <file> [--piece id] [--variant v] [--seed n] [--passes n] [-o out.mid]`.
|
|
59
|
+
- API: `render(parseScore(text, file), piece, { variant, seed })`, then `toAbc(r)` or `toMidi(r)`.
|
|
60
|
+
- Staves follow `instruments`. The family (`piano harp violins violas cello bass flute oboe clarinet bassoon horn trumpet voice synth`) sets clef, MIDI program and pedal. A piano `lead` gets a Melody staff; `pizz.` gets its own staff.
|
|
61
|
+
- Not in 0.1: meters other than 4/4, tuplets, ties across barlines, tempo changes, hairpins, repeats with endings, imports, percussion.
|
|
62
|
+
|
|
63
|
+
## Docs
|
|
64
|
+
|
|
65
|
+
- [Grammar](docs/grammar.md): every keyword of score 0.1, with examples
|
|
66
|
+
- [Legend](src/legend.ts): the legend a score can carry so readers need no docs; `plainscore legend`
|
|
67
|
+
- [llms.txt](llms.txt): this index, for agents
|
|
68
|
+
|
|
69
|
+
## Examples
|
|
70
|
+
|
|
71
|
+
- [bgm.score](examples/bgm.score): two pieces with variants, loop, ad lib. and mix
|
|
72
|
+
- [road.score](examples/road.score): a full single for piano, harp and strings
|
|
73
|
+
|
|
74
|
+
## API
|
|
75
|
+
|
|
76
|
+
- [plainscore](src/index.ts): `parseScore`, `render`, `checkScore`, `layout`, `seeded`
|
|
77
|
+
- [plainscore/abc](src/abc/index.ts): `toAbc(rendering)`, ABC for abcjs 6
|
|
78
|
+
- [plainscore/midi](src/midi.ts): `toMidi(rendering)`, SMF type 1, 480 PPQ, pedal as CC64
|
|
79
|
+
- [plainscore/legend](src/legend.ts): `LEGEND`
|
|
80
|
+
|
|
81
|
+
## Optional
|
|
82
|
+
|
|
83
|
+
- [Fixtures](fixtures): expected ABC, MIDI and events for the examples; run `pnpm test -u` after an intended change
|
|
84
|
+
- [package.json](package.json): `pnpm test`, `pnpm typecheck`, `pnpm build`
|
|
85
|
+
- [License](LICENSE): MIT
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import { layout } from "../staves.js";
|
|
2
|
+
import { DYNAMICS } from "../words.js";
|
|
3
|
+
import { keyName } from "./spell.js";
|
|
4
|
+
import { pedalVoice, writeVoice } from "./voice.js";
|
|
5
|
+
const NAMES = ['C', 'C#', 'D', 'D#', 'E', 'F', 'F#', 'G', 'G#', 'A', 'A#', 'B'];
|
|
6
|
+
const ROMAN = ['I', 'II', 'III', 'IV', 'V', 'VI', 'VII', 'VIII', 'IX', 'X'];
|
|
7
|
+
const leftHand = (e) => e.role === 'bass' || e.role === 'fifth' || (e.role === null && e.midi < 60);
|
|
8
|
+
const innerGroups = (events) => {
|
|
9
|
+
const pitches = new Map();
|
|
10
|
+
for (const e of events)
|
|
11
|
+
if (!leftHand(e))
|
|
12
|
+
pitches.set(Number(e.inner), [...(pitches.get(Number(e.inner)) ?? []), e.midi]);
|
|
13
|
+
if (!pitches.size)
|
|
14
|
+
return new Set();
|
|
15
|
+
const mean = (g) => pitches.get(g).reduce((a, b) => a + b, 0) / pitches.get(g).length;
|
|
16
|
+
const upper = [...pitches.keys()].reduce((a, b) => (mean(b) > mean(a) ? b : a), [...pitches.keys()][0]);
|
|
17
|
+
return new Set([...pitches.keys()].filter((g) => g !== upper));
|
|
18
|
+
};
|
|
19
|
+
const noteName = (midi) => `${NAMES[midi % 12]}${Math.floor(midi / 12) - 1}`;
|
|
20
|
+
const dynamicName = (value) => Object.entries(DYNAMICS).find(([, v]) => v === value)?.[0] ?? String(value);
|
|
21
|
+
export const credits = (r) => [...(r.by ? [`Music by ${r.by}`] : []), ...(r.after ? [`After ${r.after}`] : [])];
|
|
22
|
+
export const movement = (r) => r.piece.count > 1 ? `${ROMAN[r.piece.index] ?? r.piece.index + 1}. ${r.piece.title}${r.variant ? ` · ${r.variant}` : ''}` : '';
|
|
23
|
+
const remarks = (r, staves) => {
|
|
24
|
+
const out = new Map();
|
|
25
|
+
for (const staff of staves)
|
|
26
|
+
if (staff.pedal && !staff.melody && !r.pedals.some((p) => p.inst === staff.inst))
|
|
27
|
+
out.set(staff.id, 'con Ped.');
|
|
28
|
+
for (const rule of r.adlibs) {
|
|
29
|
+
const articulation = rule.articulation === 'arco' ? null : rule.articulation;
|
|
30
|
+
const staff = staves.find((s) => !s.melody && s.inst === rule.inst && s.articulation === articulation);
|
|
31
|
+
if (!staff)
|
|
32
|
+
continue;
|
|
33
|
+
const range = `${noteName(rule.notes[0])}–${noteName(rule.notes[rule.notes.length - 1])}`;
|
|
34
|
+
out.set(staff.id, `ad lib. on ${Math.round(rule.chance * 100)}% of free eighths: ${NAMES[r.tonic]} ${r.mode} ${range}, 8va ${Math.round(rule.octave * 100)}%, ${dynamicName(rule.dynamic[0])}–${dynamicName(rule.dynamic[1])}`);
|
|
35
|
+
}
|
|
36
|
+
return out;
|
|
37
|
+
};
|
|
38
|
+
export const toAbc = (r) => {
|
|
39
|
+
const { staves, staffOf } = layout(r);
|
|
40
|
+
const inner = new Map(staves.filter((s) => s.grand).map((s) => [s.id, innerGroups(r.events.filter((e) => staffOf(e) === s.id))]));
|
|
41
|
+
const voiceOf = (e) => {
|
|
42
|
+
const staff = staves.find((s) => s.id === staffOf(e));
|
|
43
|
+
if (!staff.grand)
|
|
44
|
+
return staff.id;
|
|
45
|
+
if (leftHand(e))
|
|
46
|
+
return `${staff.id}L`;
|
|
47
|
+
return inner.get(staff.id).has(Number(e.inner)) ? `${staff.id}Inner` : `${staff.id}R`;
|
|
48
|
+
};
|
|
49
|
+
const present = new Set(r.events.map(voiceOf));
|
|
50
|
+
const grandUsed = (staff) => present.has(`${staff.id}R`) || present.has(`${staff.id}L`);
|
|
51
|
+
const voices = staves.flatMap((staff) => {
|
|
52
|
+
if (!staff.grand)
|
|
53
|
+
return present.has(staff.id) ? [{ id: staff.id, staff, hand: null, clef: staff.clef }] : [];
|
|
54
|
+
const hands = [
|
|
55
|
+
{ id: `${staff.id}R`, staff, hand: 'R', clef: 'treble' },
|
|
56
|
+
{ id: `${staff.id}Inner`, staff, hand: 'Inner', clef: 'treble' },
|
|
57
|
+
{ id: `${staff.id}L`, staff, hand: 'L', clef: 'bass' },
|
|
58
|
+
];
|
|
59
|
+
return hands.filter((v) => (v.hand === 'Inner' ? present.has(v.id) : grandUsed(staff)));
|
|
60
|
+
});
|
|
61
|
+
const split = (staff) => present.has(`${staff.id}Inner`);
|
|
62
|
+
const pedals = new Map(staves
|
|
63
|
+
.filter((s) => s.grand && s.pedal && grandUsed(s))
|
|
64
|
+
.map((s) => [s.id, r.pedals.filter((p) => p.inst === s.inst)])
|
|
65
|
+
.filter(([, list]) => list.length));
|
|
66
|
+
const notes = remarks(r, staves);
|
|
67
|
+
const labels = r.sections.length > 1 ? r.sections.map((s) => ({ bar: s.bar, label: s.label === s.mark ? s.mark : `${s.mark} ${s.label}` })) : [];
|
|
68
|
+
const sheet = { keys: r.keys, mode: r.mode, tonic: r.tonic, bars: r.bars, labels };
|
|
69
|
+
const group = voices
|
|
70
|
+
.map((v) => {
|
|
71
|
+
if (v.hand === 'R')
|
|
72
|
+
return `{(${v.id}${split(v.staff) ? ` ${v.staff.id}Inner` : ''}) (${v.staff.id}L${pedals.has(v.staff.id) ? ` ${v.staff.id}Pedal` : ''})}`;
|
|
73
|
+
return v.hand ? '' : `(${v.id})`;
|
|
74
|
+
})
|
|
75
|
+
.filter(Boolean)
|
|
76
|
+
.join(' ');
|
|
77
|
+
const name = (v) => (v.hand === 'R' || !v.hand ? ` name="${v.staff.name}"` : '');
|
|
78
|
+
const stem = (v) => (!split(v.staff) ? '' : v.hand === 'R' ? ' stem=up' : v.hand === 'Inner' ? ' stem=down' : '');
|
|
79
|
+
const title = movement(r);
|
|
80
|
+
const header = [
|
|
81
|
+
'X:1',
|
|
82
|
+
`T:${r.title}`,
|
|
83
|
+
...(title ? [`T:${title}`] : []),
|
|
84
|
+
...credits(r).map((c) => `C:${c}`),
|
|
85
|
+
'M:4/4',
|
|
86
|
+
'L:1/16',
|
|
87
|
+
`Q:1/4=${r.tempo}`,
|
|
88
|
+
`%%score ${group}`,
|
|
89
|
+
...voices.map((v) => `V:${v.id} clef=${v.clef}${name(v)}${stem(v)}`),
|
|
90
|
+
...[...pedals.keys()].map((id) => `V:${id}Pedal clef=bass`),
|
|
91
|
+
`K:${keyName(r.tonic, r.mode)}`,
|
|
92
|
+
];
|
|
93
|
+
const said = new Map();
|
|
94
|
+
const written = voices.map((v, i) => {
|
|
95
|
+
if (!said.has(v.staff.id))
|
|
96
|
+
said.set(v.staff.id, new Map());
|
|
97
|
+
const style = { inner: v.hand === 'Inner', rings: v.staff.rings, clef: v.clef };
|
|
98
|
+
const remark = v.hand === 'Inner' || v.hand === 'L' ? undefined : notes.get(v.staff.id);
|
|
99
|
+
return writeVoice(sheet, style, r.events.filter((e) => voiceOf(e) === v.id), i === 0, said.get(v.staff.id), remark);
|
|
100
|
+
});
|
|
101
|
+
const pedalLines = new Map([...pedals].map(([id, list]) => [id, pedalVoice(list, r.bars)]));
|
|
102
|
+
const body = [];
|
|
103
|
+
for (let bar = 0; bar < r.bars; bar += 4)
|
|
104
|
+
voices.forEach((v, i) => {
|
|
105
|
+
body.push(`[V:${v.id}] ${written[i].slice(bar, bar + 4).join(' | ')} |`);
|
|
106
|
+
const pedal = v.hand === 'L' ? pedalLines.get(v.staff.id) : undefined;
|
|
107
|
+
if (pedal)
|
|
108
|
+
body.push(`[V:${v.staff.id}Pedal] ${pedal.slice(bar, bar + 4).join(' | ')} |`);
|
|
109
|
+
});
|
|
110
|
+
return [...header, ...body].join('\n');
|
|
111
|
+
};
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
export declare const BAR = 16;
|
|
2
|
+
export declare const BEAT = 4;
|
|
3
|
+
export declare const NOTE_LENGTHS: number[];
|
|
4
|
+
export declare const REST_LENGTHS: number[];
|
|
5
|
+
export declare const split: (at: number, span: number, values: number[]) => number[];
|
|
6
|
+
export declare const speller: (tonic: number, mode: string) => {
|
|
7
|
+
bar: () => Map<string, number>;
|
|
8
|
+
note(midi: number, bar: Map<string, number>): string;
|
|
9
|
+
};
|
|
10
|
+
export declare const nearestDynamic: (velocity: number) => string;
|
|
11
|
+
export declare const median: (values: number[]) => number;
|
|
12
|
+
export declare const onGrid: (offset: number) => boolean;
|
|
13
|
+
export declare const keyName: (tonic: number, mode: string) => string;
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { DYNAMICS, MODES } from "../words.js";
|
|
2
|
+
export const BAR = 16;
|
|
3
|
+
export const BEAT = 4;
|
|
4
|
+
export const NOTE_LENGTHS = [16, 12, 8, 6, 4, 3, 2, 1];
|
|
5
|
+
export const REST_LENGTHS = [16, 8, 4, 2, 1];
|
|
6
|
+
const LETTERS = 'CDEFGAB';
|
|
7
|
+
const NATURAL = { C: 0, D: 2, E: 4, F: 5, G: 7, A: 9, B: 11 };
|
|
8
|
+
const MODE_NAMES = { major: '', minor: 'm', dorian: 'Dor', lydian: 'Lyd', mixolydian: 'Mix' };
|
|
9
|
+
export const split = (at, span, values) => {
|
|
10
|
+
const out = [];
|
|
11
|
+
while (span > 0) {
|
|
12
|
+
const room = at % BEAT ? BEAT - (at % BEAT) : at === 0 ? BAR : BAR / 2 - (at % (BAR / 2));
|
|
13
|
+
const l = values.find((x) => x <= Math.min(span, room));
|
|
14
|
+
out.push(l);
|
|
15
|
+
at += l;
|
|
16
|
+
span -= l;
|
|
17
|
+
}
|
|
18
|
+
return out;
|
|
19
|
+
};
|
|
20
|
+
const tonicLetter = (tonic) => LETTERS.indexOf([...LETTERS].find((l) => NATURAL[l] === tonic) ?? [...LETTERS].find((l) => (NATURAL[l] + 1) % 12 === tonic));
|
|
21
|
+
export const speller = (tonic, mode) => {
|
|
22
|
+
const scale = MODES[mode].map((step) => (step + tonic) % 12);
|
|
23
|
+
const first = tonicLetter(tonic);
|
|
24
|
+
const degrees = scale.map((pc, i) => {
|
|
25
|
+
const letter = LETTERS[(first + i) % 7];
|
|
26
|
+
return { pc, letter, acc: ((pc - NATURAL[letter] + 18) % 12) - 6 };
|
|
27
|
+
});
|
|
28
|
+
const sig = Object.fromEntries(degrees.map((d) => [d.letter, d.acc]));
|
|
29
|
+
const spell = (pc) => {
|
|
30
|
+
const degree = degrees.find((d) => d.pc === pc);
|
|
31
|
+
if (degree)
|
|
32
|
+
return degree;
|
|
33
|
+
const natural = [...LETTERS].find((l) => NATURAL[l] === pc);
|
|
34
|
+
if (natural)
|
|
35
|
+
return { letter: natural, acc: 0 };
|
|
36
|
+
return { letter: [...LETTERS].find((l) => (NATURAL[l] + 1) % 12 === pc), acc: 1 };
|
|
37
|
+
};
|
|
38
|
+
return {
|
|
39
|
+
bar: () => new Map(),
|
|
40
|
+
note(midi, bar) {
|
|
41
|
+
const { letter, acc } = spell(midi % 12);
|
|
42
|
+
const octave = Math.floor((midi - acc) / 12) - 1;
|
|
43
|
+
const key = `${letter}${octave}`;
|
|
44
|
+
const current = bar.get(key) ?? sig[letter];
|
|
45
|
+
bar.set(key, acc);
|
|
46
|
+
const mark = acc === current ? '' : acc === 1 ? '^' : acc === -1 ? '_' : '=';
|
|
47
|
+
return mark + (octave >= 5 ? letter.toLowerCase() + "'".repeat(octave - 5) : letter + ','.repeat(4 - octave));
|
|
48
|
+
},
|
|
49
|
+
};
|
|
50
|
+
};
|
|
51
|
+
export const nearestDynamic = (velocity) => Object.entries(DYNAMICS).reduce((best, entry) => (Math.abs(entry[1] - velocity) < Math.abs(best[1] - velocity) ? entry : best))[0];
|
|
52
|
+
export const median = (values) => [...values].sort((a, b) => a - b)[Math.floor(values.length / 2)];
|
|
53
|
+
export const onGrid = (offset) => Math.abs(offset * 2 - Math.round(offset * 2)) < 1e-6;
|
|
54
|
+
export const keyName = (tonic, mode) => {
|
|
55
|
+
const letter = LETTERS[tonicLetter(tonic)];
|
|
56
|
+
return letter + (NATURAL[letter] === tonic ? '' : '#') + MODE_NAMES[mode];
|
|
57
|
+
};
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { ScoreEvent } from '../expand.ts';
|
|
2
|
+
import type { Clef } from '../families.ts';
|
|
3
|
+
import type { Rendering } from '../render.ts';
|
|
4
|
+
import type { Pedal } from '../words.ts';
|
|
5
|
+
export interface Sheet extends Pick<Rendering, 'keys' | 'mode' | 'tonic' | 'bars'> {
|
|
6
|
+
labels: {
|
|
7
|
+
bar: number;
|
|
8
|
+
label: string;
|
|
9
|
+
}[];
|
|
10
|
+
}
|
|
11
|
+
export interface VoiceStyle {
|
|
12
|
+
inner: boolean;
|
|
13
|
+
rings: boolean;
|
|
14
|
+
clef: Clef;
|
|
15
|
+
}
|
|
16
|
+
export declare const pedalVoice: (pedals: Pedal[], bars: number) => string[];
|
|
17
|
+
export declare const writeVoice: (sheet: Sheet, style: VoiceStyle, events: ScoreEvent[], top: boolean, said: Map<number, string>, remark?: string) => string[];
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
import { BAR, BEAT, NOTE_LENGTHS, REST_LENGTHS, keyName, median, nearestDynamic, onGrid, speller, split } from "./spell.js";
|
|
2
|
+
const OTTAVA_TOP = 88;
|
|
3
|
+
const OTTAVA_FLOOR = 72;
|
|
4
|
+
const SIMILE_AFTER = 2;
|
|
5
|
+
const onsets = (events) => {
|
|
6
|
+
const byPos = new Map();
|
|
7
|
+
for (const e of events) {
|
|
8
|
+
const grid = onGrid(e.offset);
|
|
9
|
+
const pos = e.step * 2 + (grid ? e.offset * 2 : 0);
|
|
10
|
+
const onset = byPos.get(pos) ?? { pos, midis: new Set(), end: pos, arpeggio: false, accent: false, levels: [], shift: 0 };
|
|
11
|
+
onset.midis.add(e.midi);
|
|
12
|
+
onset.end = Math.max(onset.end, pos + e.written * 2);
|
|
13
|
+
onset.arpeggio ||= e.rolled && !grid;
|
|
14
|
+
onset.accent ||= e.velocity > e.level + 0.04;
|
|
15
|
+
onset.levels.push(e.level);
|
|
16
|
+
byPos.set(pos, onset);
|
|
17
|
+
}
|
|
18
|
+
return [...byPos.values()].sort((a, b) => a.pos - b.pos);
|
|
19
|
+
};
|
|
20
|
+
const ottava = (list) => {
|
|
21
|
+
let high = false;
|
|
22
|
+
const marks = [];
|
|
23
|
+
for (const o of list) {
|
|
24
|
+
const lo = Math.min(...o.midis);
|
|
25
|
+
const hi = Math.max(...o.midis);
|
|
26
|
+
if (!high && hi >= OTTAVA_TOP && lo >= OTTAVA_FLOOR)
|
|
27
|
+
marks.push([o.pos, '"^8va"']);
|
|
28
|
+
else if (high && lo < OTTAVA_FLOOR)
|
|
29
|
+
marks.push([o.pos, '"^loco"']);
|
|
30
|
+
high = lo >= OTTAVA_FLOOR && (high || hi >= OTTAVA_TOP);
|
|
31
|
+
o.shift = high ? 12 : 0;
|
|
32
|
+
}
|
|
33
|
+
return marks;
|
|
34
|
+
};
|
|
35
|
+
const pedalMarks = (pedals, bars) => {
|
|
36
|
+
const marks = new Map();
|
|
37
|
+
let down = false;
|
|
38
|
+
for (const p of pedals) {
|
|
39
|
+
const pos = Math.round(p.at * 2);
|
|
40
|
+
const text = p.down ? (down ? '✱ Ped.' : 'Ped.') : down ? '✱' : '';
|
|
41
|
+
down = p.down;
|
|
42
|
+
if (!text || pos >= bars * BAR)
|
|
43
|
+
continue;
|
|
44
|
+
marks.set(pos, marks.get(pos) === '✱' && text === 'Ped.' ? '✱ Ped.' : text);
|
|
45
|
+
}
|
|
46
|
+
const routine = (bar) => {
|
|
47
|
+
const inBar = [...marks].filter(([pos]) => Math.floor(pos / BAR) === bar);
|
|
48
|
+
return inBar.length === 1 && inBar[0][0] === bar * BAR && inBar[0][1] === '✱ Ped.';
|
|
49
|
+
};
|
|
50
|
+
let shown = 0;
|
|
51
|
+
for (let bar = 0; bar < bars; bar++) {
|
|
52
|
+
if (!routine(bar))
|
|
53
|
+
continue;
|
|
54
|
+
shown++;
|
|
55
|
+
if (shown === SIMILE_AFTER + 1)
|
|
56
|
+
marks.set(bar * BAR, 'sim.');
|
|
57
|
+
else if (shown > SIMILE_AFTER + 1)
|
|
58
|
+
marks.delete(bar * BAR);
|
|
59
|
+
}
|
|
60
|
+
return marks;
|
|
61
|
+
};
|
|
62
|
+
export const pedalVoice = (pedals, bars) => {
|
|
63
|
+
const marks = pedalMarks(pedals, bars);
|
|
64
|
+
return Array.from({ length: bars }, (_, bar) => {
|
|
65
|
+
const start = bar * BAR;
|
|
66
|
+
const stops = [...new Set([0, ...[...marks.keys()].filter((pos) => Math.floor(pos / BAR) === bar).map((pos) => pos - start), BAR])].sort((a, b) => a - b);
|
|
67
|
+
return stops
|
|
68
|
+
.slice(0, -1)
|
|
69
|
+
.flatMap((at, i) => split(at, stops[i + 1] - at, REST_LENGTHS).map((len, j) => {
|
|
70
|
+
const text = j === 0 ? marks.get(start + at) : undefined;
|
|
71
|
+
return `${text ? `"_${text}"` : ''}x${len === 1 ? '' : len}`;
|
|
72
|
+
}))
|
|
73
|
+
.join(' ');
|
|
74
|
+
});
|
|
75
|
+
};
|
|
76
|
+
export const writeVoice = (sheet, style, events, top, said, remark) => {
|
|
77
|
+
const spellers = new Map(sheet.keys.map((k) => [k.bar, speller(k.tonic, sheet.mode)]));
|
|
78
|
+
let s = spellers.get(0) ?? speller(sheet.tonic, sheet.mode);
|
|
79
|
+
const total = sheet.bars * BAR;
|
|
80
|
+
const list = onsets(events);
|
|
81
|
+
const octaves = style.clef === 'treble' ? ottava(list) : [];
|
|
82
|
+
const bars = Array.from({ length: sheet.bars }, () => []);
|
|
83
|
+
const prefixes = new Map();
|
|
84
|
+
const prefix = (pos, text) => prefixes.set(pos, [...(prefixes.get(pos) ?? []), text]);
|
|
85
|
+
if (remark)
|
|
86
|
+
prefix(0, `"^${remark.replaceAll('%', '\\%')}"`);
|
|
87
|
+
if (top)
|
|
88
|
+
for (const section of sheet.labels)
|
|
89
|
+
prefix(section.bar * BAR, `"^${section.label}"`);
|
|
90
|
+
for (const [pos, mark] of octaves)
|
|
91
|
+
prefix(pos, mark);
|
|
92
|
+
let dynamic = '';
|
|
93
|
+
for (let bar = 0; bar < sheet.bars && !style.inner; bar++) {
|
|
94
|
+
const inBar = list.filter((o) => Math.floor(o.pos / BAR) === bar);
|
|
95
|
+
if (!inBar.length)
|
|
96
|
+
continue;
|
|
97
|
+
const mark = nearestDynamic(median(inBar.flatMap((o) => o.levels)));
|
|
98
|
+
if (mark !== dynamic && said.get(bar) !== mark)
|
|
99
|
+
prefix(inBar[0].pos, `!${mark}!`);
|
|
100
|
+
if (mark !== dynamic)
|
|
101
|
+
said.set(bar, mark);
|
|
102
|
+
dynamic = mark;
|
|
103
|
+
}
|
|
104
|
+
const pieces = [];
|
|
105
|
+
const push = (pos, len, midis, arpeggio = false, accent = false) => {
|
|
106
|
+
let at = pos;
|
|
107
|
+
while (at < pos + len) {
|
|
108
|
+
const start = Math.floor(at / BAR) * BAR;
|
|
109
|
+
const span = Math.min(pos + len, start + BAR) - at;
|
|
110
|
+
for (const l of split(at - start, span, midis ? NOTE_LENGTHS : REST_LENGTHS)) {
|
|
111
|
+
pieces.push({ pos: at, len: l, midis, tie: midis !== null && at + l < pos + len, arpeggio: arpeggio && at === pos, accent: accent && at === pos });
|
|
112
|
+
at += l;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
};
|
|
116
|
+
let t = 0;
|
|
117
|
+
list.forEach((o, i) => {
|
|
118
|
+
if (o.pos >= total)
|
|
119
|
+
return;
|
|
120
|
+
if (o.pos > t)
|
|
121
|
+
push(t, o.pos - t, null);
|
|
122
|
+
const barEnd = (Math.floor(o.pos / BAR) + 1) * BAR;
|
|
123
|
+
let end = Math.min(o.end, list[i + 1]?.pos ?? total, total);
|
|
124
|
+
// A spill of less than an eighth past the barline is a legato overlap in the source, not a written tie.
|
|
125
|
+
if (end > barEnd && end % BAR < 2)
|
|
126
|
+
end -= end % BAR;
|
|
127
|
+
if (style.rings)
|
|
128
|
+
end = o.pos + split(o.pos % BAR, Math.min(end, barEnd) - o.pos, NOTE_LENGTHS)[0];
|
|
129
|
+
push(o.pos, end - o.pos, [...o.midis].map((m) => m - o.shift).sort((a, b) => a - b), o.arpeggio && o.midis.size > 1, o.accent);
|
|
130
|
+
t = end;
|
|
131
|
+
});
|
|
132
|
+
if (t < total)
|
|
133
|
+
push(t, total - t, null);
|
|
134
|
+
let accidentals = s.bar();
|
|
135
|
+
let current = -1;
|
|
136
|
+
let last = null;
|
|
137
|
+
for (const p of pieces) {
|
|
138
|
+
const bar = Math.floor(p.pos / BAR);
|
|
139
|
+
if (bar !== current) {
|
|
140
|
+
const key = sheet.keys.find((k) => k.bar === bar && bar > 0);
|
|
141
|
+
// abcjs crashes on an inline key in the second voice of a staff, which follows the staff's key anyway; and without a clef the key resets the staff to treble.
|
|
142
|
+
if (key && !style.inner)
|
|
143
|
+
bars[bar].push(`[K:${keyName(key.tonic, sheet.mode)} clef=${style.clef}]`);
|
|
144
|
+
s = spellers.get(bar) ?? s;
|
|
145
|
+
accidentals = s.bar();
|
|
146
|
+
current = bar;
|
|
147
|
+
last = null;
|
|
148
|
+
}
|
|
149
|
+
const len = p.len === 1 ? '' : String(p.len);
|
|
150
|
+
const marks = [...(prefixes.get(p.pos) ?? [])].filter((m) => p.midis || m.startsWith('"'));
|
|
151
|
+
prefixes.delete(p.pos);
|
|
152
|
+
if (p.midis && p.accent)
|
|
153
|
+
marks.push('!>!');
|
|
154
|
+
if (p.midis && p.arpeggio)
|
|
155
|
+
marks.push('!arpeggio!');
|
|
156
|
+
const names = p.midis?.map((m) => s.note(m, accidentals));
|
|
157
|
+
const body = !names ? `${style.inner ? 'x' : 'z'}${len}` : names.length === 1 ? `${names[0]}${len}` : `[${names.join('')}]${len}`;
|
|
158
|
+
const token = { beat: Math.floor(p.pos / BEAT), beamable: p.midis !== null && p.len < BEAT };
|
|
159
|
+
const glue = last && last.beamable && token.beamable && last.beat === token.beat ? '' : ' ';
|
|
160
|
+
bars[bar].push((bars[bar].length ? glue : '') + marks.join('') + body + (p.tie ? '-' : ''));
|
|
161
|
+
last = token;
|
|
162
|
+
}
|
|
163
|
+
return bars.map((tokens) => tokens.join(''));
|
|
164
|
+
};
|
package/dist/cli.d.ts
ADDED
package/dist/cli.js
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { readFileSync, writeFileSync } from 'node:fs';
|
|
3
|
+
import { basename } from 'node:path';
|
|
4
|
+
import { parseArgs } from 'node:util';
|
|
5
|
+
import { toAbc } from "./abc/index.js";
|
|
6
|
+
import { checkScore } from "./expand.js";
|
|
7
|
+
import { LEGEND } from "./legend.js";
|
|
8
|
+
import { toMidi } from "./midi.js";
|
|
9
|
+
import { DEFAULT_SEED, render } from "./render.js";
|
|
10
|
+
const USAGE = `usage:
|
|
11
|
+
plainscore check <file>
|
|
12
|
+
plainscore events <file> [--piece id] [--variant v] [--seed n] [--passes n]
|
|
13
|
+
plainscore abc <file> [--piece id] [--variant v]
|
|
14
|
+
plainscore midi <file> [--piece id] [--variant v] [--seed n] [--passes n] -o out.mid
|
|
15
|
+
plainscore legend`;
|
|
16
|
+
const main = () => {
|
|
17
|
+
const { positionals, values } = parseArgs({
|
|
18
|
+
allowPositionals: true,
|
|
19
|
+
options: {
|
|
20
|
+
piece: { type: 'string' },
|
|
21
|
+
variant: { type: 'string' },
|
|
22
|
+
seed: { type: 'string' },
|
|
23
|
+
passes: { type: 'string' },
|
|
24
|
+
output: { type: 'string', short: 'o' },
|
|
25
|
+
help: { type: 'boolean', short: 'h' },
|
|
26
|
+
},
|
|
27
|
+
});
|
|
28
|
+
const [command, file] = positionals;
|
|
29
|
+
if (values.help || !command)
|
|
30
|
+
return void console.log(USAGE);
|
|
31
|
+
if (command === 'legend')
|
|
32
|
+
return void process.stdout.write(LEGEND);
|
|
33
|
+
if (!['check', 'events', 'abc', 'midi'].includes(command))
|
|
34
|
+
throw new Error(`unknown command "${command}"\n${USAGE}`);
|
|
35
|
+
if (!file)
|
|
36
|
+
throw new Error(`${command} needs a score file\n${USAGE}`);
|
|
37
|
+
const score = checkScore(readFileSync(file, 'utf8'), basename(file));
|
|
38
|
+
if (command === 'check')
|
|
39
|
+
return void console.log(`${file}: ok, ${score.pieces.map((p) => p.id).join(', ')}`);
|
|
40
|
+
const number = (value, name) => {
|
|
41
|
+
if (value === undefined)
|
|
42
|
+
return undefined;
|
|
43
|
+
if (!/^\d+$/.test(value))
|
|
44
|
+
throw new Error(`--${name} takes a whole number`);
|
|
45
|
+
return Number(value);
|
|
46
|
+
};
|
|
47
|
+
const piece = values.piece ?? score.pieces[0].id;
|
|
48
|
+
const passes = number(values.passes, 'passes');
|
|
49
|
+
if (command === 'abc')
|
|
50
|
+
return void process.stdout.write(`${toAbc(render(score, piece, { variant: values.variant }))}\n`);
|
|
51
|
+
const seed = number(values.seed, 'seed') ?? DEFAULT_SEED;
|
|
52
|
+
const rendering = render(score, piece, { variant: values.variant, seed, passes });
|
|
53
|
+
if (command === 'events')
|
|
54
|
+
return void process.stdout.write(`${JSON.stringify(rendering, null, 2)}\n`);
|
|
55
|
+
if (!values.output)
|
|
56
|
+
throw new Error('midi needs -o <file.mid>');
|
|
57
|
+
writeFileSync(values.output, toMidi(rendering));
|
|
58
|
+
};
|
|
59
|
+
try {
|
|
60
|
+
main();
|
|
61
|
+
}
|
|
62
|
+
catch (error) {
|
|
63
|
+
console.error(error.message);
|
|
64
|
+
process.exit(1);
|
|
65
|
+
}
|
package/dist/expand.d.ts
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { type AdLib, type Articulation, type ScoreFile } from './parse.ts';
|
|
2
|
+
import { type Chord, type Pedal, type Ref } from './words.ts';
|
|
3
|
+
export interface ScoreEvent {
|
|
4
|
+
step: number;
|
|
5
|
+
offset: number;
|
|
6
|
+
inst: string;
|
|
7
|
+
articulation: Articulation | null;
|
|
8
|
+
part: string;
|
|
9
|
+
role: Ref['ref'] | null;
|
|
10
|
+
midi: number;
|
|
11
|
+
length: number;
|
|
12
|
+
written: number;
|
|
13
|
+
held?: number;
|
|
14
|
+
level: number;
|
|
15
|
+
velocity: number;
|
|
16
|
+
rolled: boolean;
|
|
17
|
+
lead: boolean;
|
|
18
|
+
inner: boolean;
|
|
19
|
+
gain: number;
|
|
20
|
+
attack?: number;
|
|
21
|
+
}
|
|
22
|
+
export interface Voice {
|
|
23
|
+
inst: string;
|
|
24
|
+
articulation: Articulation | null;
|
|
25
|
+
}
|
|
26
|
+
export interface ExpandedSection {
|
|
27
|
+
mark: string;
|
|
28
|
+
label: string;
|
|
29
|
+
bar: number;
|
|
30
|
+
bars: number;
|
|
31
|
+
tonic: number;
|
|
32
|
+
voices: Voice[];
|
|
33
|
+
}
|
|
34
|
+
export interface AdLibRule extends Omit<AdLib, 'avoid'>, Voice {
|
|
35
|
+
part: string;
|
|
36
|
+
gain: number;
|
|
37
|
+
free: number[];
|
|
38
|
+
}
|
|
39
|
+
export interface Expanded {
|
|
40
|
+
id: string;
|
|
41
|
+
title: string;
|
|
42
|
+
variant: string | null;
|
|
43
|
+
tempo: number;
|
|
44
|
+
tonic: number;
|
|
45
|
+
mode: string;
|
|
46
|
+
bars: number;
|
|
47
|
+
loop: {
|
|
48
|
+
from: number;
|
|
49
|
+
to: number;
|
|
50
|
+
} | null;
|
|
51
|
+
sections: ExpandedSection[];
|
|
52
|
+
chords: Chord[];
|
|
53
|
+
events: ScoreEvent[];
|
|
54
|
+
pedals: Pedal[];
|
|
55
|
+
adlibs: AdLibRule[];
|
|
56
|
+
}
|
|
57
|
+
export declare const pedalUntil: (pedals: Pedal[], onset: number, loop: Expanded["loop"]) => number;
|
|
58
|
+
export declare const wrap: (piece: Pick<Expanded, "bars" | "loop">, step: number) => number;
|
|
59
|
+
export declare const expand: (score: ScoreFile, id: string, variant?: string | null) => Expanded;
|
|
60
|
+
export declare const adlibAt: (rule: AdLibRule, step: number, random: () => number) => ScoreEvent | null;
|
|
61
|
+
export declare const checkScore: (text: string, file: string) => ScoreFile;
|