plainscore 0.0.0-stage → 0.1.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/LICENSE +21 -0
- package/README.md +110 -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/package.json +29 -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,111 @@
|
|
|
1
|
-
#
|
|
1
|
+
# plainscore
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
A plain-text score format that a musician can read without docs and an AI can edit line by line. One file holds the music; plainscore turns it into timed note events, MIDI and ABC notation.
|
|
4
|
+
|
|
5
|
+
```
|
|
6
|
+
score 0.1
|
|
7
|
+
|
|
8
|
+
title Small Waltz in 4
|
|
9
|
+
by You
|
|
10
|
+
|
|
11
|
+
instruments
|
|
12
|
+
piano piano
|
|
13
|
+
cello cello
|
|
14
|
+
|
|
15
|
+
piece waltz Small Waltz
|
|
16
|
+
tempo q = 72
|
|
17
|
+
key G major
|
|
18
|
+
|
|
19
|
+
chords verse — bass | voicing
|
|
20
|
+
G G2 | B3 D4 G4
|
|
21
|
+
D/F# F#2 | A3 D4 F#4
|
|
22
|
+
Em E2 | B3 E4 G4
|
|
23
|
+
C C3 | C4 E4 G4
|
|
24
|
+
|
|
25
|
+
part tune piano mf overlap e lead
|
|
26
|
+
| B4 h A4 q G4 q | A4 w | G4 h B4 h | C5 w |
|
|
27
|
+
|
|
28
|
+
part comp piano
|
|
29
|
+
1 bass w >
|
|
30
|
+
2 upper q soft
|
|
31
|
+
3& upper e softer
|
|
32
|
+
|
|
33
|
+
part pad cello mp
|
|
34
|
+
1 bass+8va w
|
|
35
|
+
|
|
36
|
+
part pedal
|
|
37
|
+
1 Ped.
|
|
38
|
+
|
|
39
|
+
form loop
|
|
40
|
+
A verse
|
|
41
|
+
tune, comp p, pad 80%, pedal
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
## What it can say
|
|
45
|
+
|
|
46
|
+
- **Pitches as written**, durations `w h q e s` with dots and ties, bars separated by `|`.
|
|
47
|
+
- **Chord-relative patterns**: write a figure once with `bass`, `t1`–`t4`, `top`, `upper`, and it follows the chords.
|
|
48
|
+
- **Form as an orchestration table**: each section row names the parts that play and how loud (`85%`, `92%-75%`, `bars 3-4`, `on 1 and 3`, `8va`, `in E`).
|
|
49
|
+
- **Loop**: `form loop`, or `form loop from A1` for an intro that plays once.
|
|
50
|
+
- **Variants**: versions of one piece that share a timeline (`drop P`, `Q for P`, `P <options>`).
|
|
51
|
+
- **Doubles**: `part strings violins doubles tune 8vb` plays another part's notes on its own instrument.
|
|
52
|
+
- **Pedal, rolls, staggered entries, ad lib.** (seeded random notes), and a **mix** block for playback balance that notation and MIDI ignore.
|
|
53
|
+
|
|
54
|
+
The full reference is [docs/grammar.md](docs/grammar.md). Every score can carry the standard legend (`plainscore legend`) so a reader never needs this README.
|
|
55
|
+
|
|
56
|
+
## Use
|
|
57
|
+
|
|
58
|
+
```bash
|
|
59
|
+
pnpm add -D plainscore
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
```bash
|
|
63
|
+
plainscore check song.score
|
|
64
|
+
plainscore abc song.score --piece waltz > song.abc
|
|
65
|
+
plainscore midi song.score --piece waltz -o song.mid
|
|
66
|
+
plainscore events song.score --variant quiet --seed 7 --passes 2
|
|
67
|
+
plainscore legend
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
```ts
|
|
71
|
+
import { parseScore, render } from 'plainscore'
|
|
72
|
+
import { toAbc } from 'plainscore/abc'
|
|
73
|
+
import { toMidi } from 'plainscore/midi'
|
|
74
|
+
|
|
75
|
+
const score = parseScore(text, 'song.score')
|
|
76
|
+
const rendering = render(score, 'waltz', { variant: null, seed: 1 })
|
|
77
|
+
const abc = toAbc(rendering)
|
|
78
|
+
const midi = toMidi(rendering)
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
`render` returns plain data: metadata, sections, key changes, every note with its onset in eighths, offset, length, level and velocity, pedal changes, and the ad lib. rules. A player only needs that.
|
|
82
|
+
|
|
83
|
+
## Notation and MIDI
|
|
84
|
+
|
|
85
|
+
Staves come from the declared instruments, in declaration order:
|
|
86
|
+
|
|
87
|
+
- each instrument gets a staff, and `pizz.` parts get their own staff right after it;
|
|
88
|
+
- a `lead` part on a piano gets a Melody staff on top;
|
|
89
|
+
- pianos get a grand staff with pedal marks, and an `inner` part becomes a voice of its own;
|
|
90
|
+
- clefs, MIDI programs and pedal come from the instrument's family (`piano harp violins violas cello bass flute oboe clarinet bassoon horn trumpet voice synth`).
|
|
91
|
+
|
|
92
|
+
The ABC targets [abcjs](https://www.abcjs.net/) 6.x. plainscore has no runtime dependencies and does not render; pass the ABC to abcjs, or to any ABC tool.
|
|
93
|
+
|
|
94
|
+
## Limits of 0.1
|
|
95
|
+
|
|
96
|
+
4/4 only; the smallest grid is the eighth (with `late s` for sixteenth offsets); no tuplets, ties across barlines, tempo changes, hairpins, articulations beyond `pizz.`/`arco`, repeats with endings, imports or percussion.
|
|
97
|
+
|
|
98
|
+
## Develop
|
|
99
|
+
|
|
100
|
+
```bash
|
|
101
|
+
pnpm install
|
|
102
|
+
pnpm test
|
|
103
|
+
pnpm typecheck
|
|
104
|
+
pnpm build
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
`fixtures/` holds the expected ABC, MIDI and events for `examples/`. After an intended output change, run `pnpm test -u` and review the fixture diff.
|
|
108
|
+
|
|
109
|
+
## License
|
|
110
|
+
|
|
111
|
+
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;
|