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 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
- # Temporary Holding Version
1
+ # plainscore
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
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
+ ![Manuscript paper and a fountain pen](docs/images/hero.jpg)
6
+
7
+ ![song.score on the left; the sheet plainscore engraves from it on the right](docs/images/score-to-sheet.png)
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
+ ![The form of examples/bgm.score: each section row names the parts that play, and how loud](docs/images/form.png)
54
+
55
+ ![One file, four outputs: check, events, ABC, MIDI](docs/images/outputs.png)
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,4 @@
1
+ import type { Rendering } from '../render.ts';
2
+ export declare const credits: (r: Pick<Rendering, "by" | "after">) => string[];
3
+ export declare const movement: (r: Pick<Rendering, "piece" | "variant">) => string;
4
+ export declare const toAbc: (r: Rendering) => string;
@@ -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
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
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
+ }
@@ -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;