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 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
- # 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 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,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;