AppImage release / appimage (push) Successful in 1m36s
The foot and the double holes sit at the top of each narrow chart, and the mouthpiece sits at the bottom, matching hand-drawn tabs. Which holes are open or closed is unchanged. A repeat mark is shown only when the score repeats. Version 0.1.2. Co-authored-by: catpersonisme <catpersonisme@gmail.com>
129 lines
3.8 KiB
TypeScript
129 lines
3.8 KiB
TypeScript
import { formatQuarterLengths, quartersMatch } from "@/lib/duration";
|
|
|
|
export type TimeSignature = {
|
|
beats: number;
|
|
beatType: number;
|
|
};
|
|
|
|
export type ScoreEvent = {
|
|
id: string;
|
|
kind: "note" | "rest";
|
|
pitch: string | null;
|
|
quarters: number;
|
|
label: string;
|
|
tied: boolean;
|
|
};
|
|
|
|
export type Measure = {
|
|
id: string;
|
|
events: ScoreEvent[];
|
|
/** A forward repeat barline stands at the start of this measure. */
|
|
repeatForward?: boolean;
|
|
/** A backward repeat ends on this measure. The number is how many times to play the passage. */
|
|
repeatTimes?: number;
|
|
};
|
|
|
|
export type ScoreOrigin = "sample" | "upload" | "blank";
|
|
|
|
export type Score = {
|
|
title: string;
|
|
attribution: string;
|
|
timeSignature: TimeSignature;
|
|
measures: Measure[];
|
|
origin: ScoreOrigin;
|
|
};
|
|
|
|
export const TIME_SIGNATURES: TimeSignature[] = [
|
|
{ beats: 2, beatType: 4 },
|
|
{ beats: 3, beatType: 4 },
|
|
{ beats: 4, beatType: 4 },
|
|
{ beats: 6, beatType: 8 },
|
|
{ beats: 3, beatType: 8 },
|
|
{ beats: 2, beatType: 2 },
|
|
];
|
|
|
|
export function newId(prefix: string): string {
|
|
return `${prefix}-${Math.random().toString(36).slice(2, 10)}`;
|
|
}
|
|
|
|
export function signatureKey(timeSignature: TimeSignature): string {
|
|
return `${timeSignature.beats}/${timeSignature.beatType}`;
|
|
}
|
|
|
|
export function parseSignature(key: string): TimeSignature {
|
|
const [beats, beatType] = key.split("/").map(Number);
|
|
return { beats, beatType };
|
|
}
|
|
|
|
export function expectedQuarters(timeSignature: TimeSignature): number {
|
|
return (timeSignature.beats * 4) / timeSignature.beatType;
|
|
}
|
|
|
|
export function barStatus(
|
|
measure: Measure,
|
|
timeSignature: TimeSignature,
|
|
): { ok: boolean; text: string } {
|
|
const sum = measure.events.reduce((total, event) => total + event.quarters, 0);
|
|
const expected = expectedQuarters(timeSignature);
|
|
const signature = signatureKey(timeSignature);
|
|
if (quartersMatch(sum, expected)) {
|
|
return { ok: true, text: `${signature} · ${formatQuarterLengths(sum)}` };
|
|
}
|
|
return {
|
|
ok: false,
|
|
text: `${formatQuarterLengths(sum)} in this bar; ${signature} holds ${formatQuarterLengths(expected)}.`,
|
|
};
|
|
}
|
|
|
|
export function startBeats(events: ScoreEvent[], timeSignature: TimeSignature): number[] {
|
|
const quartersPerBeat = 4 / timeSignature.beatType;
|
|
let cursor = 0;
|
|
return events.map((event) => {
|
|
const beat = cursor / quartersPerBeat + 1;
|
|
cursor += event.quarters;
|
|
return beat;
|
|
});
|
|
}
|
|
|
|
export function formatBeat(beat: number): string {
|
|
const rounded = Math.round(beat * 100) / 100;
|
|
return Number.isInteger(rounded) ? String(rounded) : String(rounded);
|
|
}
|
|
|
|
/** Extra full beats after the note itself. A half note in 4/4 returns 1. A dotted quarter returns 0. */
|
|
export function extraBeatArrows(quarters: number, timeSignature: TimeSignature): number {
|
|
const perBeat = 4 / timeSignature.beatType;
|
|
if (!(perBeat > 0) || !(quarters > 0)) return 0;
|
|
const wholeBeats = Math.floor(quarters / perBeat + 1e-6);
|
|
return Math.max(0, wholeBeats - 1);
|
|
}
|
|
|
|
/** Group a lead into horizontal rows. A row holds about `budget` notes, and never splits a bar. */
|
|
export function leadRows<T extends { events: { length: number } }>(measures: T[], budget = 14): T[][] {
|
|
const rows: T[][] = [];
|
|
let current: T[] = [];
|
|
let count = 0;
|
|
for (const measure of measures) {
|
|
const notes = Math.max(1, measure.events.length);
|
|
if (current.length > 0 && count + notes > budget) {
|
|
rows.push(current);
|
|
current = [];
|
|
count = 0;
|
|
}
|
|
current.push(measure);
|
|
count += notes;
|
|
}
|
|
if (current.length > 0) rows.push(current);
|
|
return rows;
|
|
}
|
|
|
|
export function blankScore(): Score {
|
|
return {
|
|
title: "Untitled line",
|
|
attribution: "Hand-entered lead line. Add notes, then read the tabs.",
|
|
timeSignature: { beats: 4, beatType: 4 },
|
|
origin: "blank",
|
|
measures: [{ id: newId("m"), events: [] }],
|
|
};
|
|
}
|