UNPKG

satie

Version:

A sheet music renderer for the web

539 lines (491 loc) 19.4 kB
/** * This file is part of Satie music engraver <https://github.com/jnetterf/satie>. * Copyright (C) Joshua Netterfield <joshua.ca> 2015 - present. * * Satie is free software: you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License as * published by the Free Software Foundation, either version 3 of the * License, or (at your option) any later version. * * Satie is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU Affero General Public License for more details. * * You should have received a copy of the GNU Affero General Public License * along with Satie. If not, see <http://www.gnu.org/licenses/>. */ /** * r_ = a single rest can fit here * * = any combination of rests can go here */ type RestRules = ReadonlyArray<string>; export interface IOptions { dotsAllowed: boolean; } let _stretchRestRuleMemo: {[key: string]: string} = {}; function _stretchRestRule(rule: string, quantization: number) { const key = rule + String(quantization); if (!_stretchRestRuleMemo[key]) { const newRule: string[] = []; for (let i = 0; i < rule.length; ++i) { newRule.push(rule[i]); for (let j = 1; j < quantization; ++j) { newRule.push(rule[i] === "r" ? "_" : rule[i]); } } _stretchRestRuleMemo[key] = newRule.join(""); } return _stretchRestRuleMemo[key]; } function _getValidSubBeatLengths(quantumPerBeats: number, beatsPerMeasure: number, dotsAllowed: boolean) { let validLengths: number[] = []; for (let i = 5; i > 0; --i) { let pow2 = Math.pow(2, i); if (quantumPerBeats % pow2 === 0) { validLengths.push(quantumPerBeats / pow2); } let toAdd = quantumPerBeats / pow2; let dottedLength = quantumPerBeats / pow2; let dots = 0; if (dotsAllowed) { while ((toAdd % 2 === 0) && dots + 1 <= 3) { toAdd /= 2; dottedLength += toAdd; ++dots; validLengths.push(dottedLength); } } } // It's currently from smallest to largest, we want it from largest to smallest. validLengths.reverse(); return validLengths; } class RestSolver { private _ruleBeats: number; private _ruleBeatType: number; private _restRules: RestRules; constructor( ruleBeats: number, ruleBeatType: number, restRules: RestRules) { this._ruleBeats = ruleBeats; this._ruleBeatType = ruleBeatType; this._restRules = restRules; } isSimple() { return this._ruleBeats === 1 || this._ruleBeats === 2 || this._ruleBeats === 3 || this._ruleBeats === 4; } static dotRule = /r__(\.|$)/; checkRests(divisions: number, song: string, options: IOptions): string { const MATCH_CHARS = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ"; const quantization = divisions * 4 / this._ruleBeatType; let myRestRules = this._restRules .filter(rule => options.dotsAllowed || (rule.search(RestSolver.dotRule) === -1)) .map(rule => _stretchRestRule(rule, quantization)); song = _stretchRestRule(song, 1 / quantization); const len = myRestRules[0].length; const quantumPerBeats = len / this._ruleBeats; const validSubBeatLengths = _getValidSubBeatLengths(quantumPerBeats, this._ruleBeats, options.dotsAllowed); const validNonDotSubBeatLengths = _getValidSubBeatLengths(quantumPerBeats, this._ruleBeats, false); if (song.length > len) { return `split ${len} ${myRestRules[0].length}`; } else if (song.length < myRestRules[0].length) { return `apply ${len} ${ song.split("").map(() => ".").join("") + Array(myRestRules[0].length - song.length + 1).join("r") }`; } else if (song.length !== len) { return `ERR: ${song.length} !== ${this._ruleBeats}`; } // Ensure that rests that do not start on beats do not cross beat boundaries. for (let i = 0; i < song.length; ++i) { if (song[i] === "r" && (i % quantumPerBeats) > 0) { let j = i; while (song[j + 1] === "_") { ++j; } let beats = ((j - i + 1)/quantumPerBeats); if (beats >= 1) { let startOfNextBeat = i + 1; while (startOfNextBeat % quantumPerBeats !== 0) { ++startOfNextBeat; } let fillUntil = startOfNextBeat = startOfNextBeat + 1; while (fillUntil % quantumPerBeats !== 0 && song[fillUntil] && song[fillUntil] !== ".") { ++fillUntil; } let patch = Array(startOfNextBeat).join(".") + "r" + Array(fillUntil - startOfNextBeat + 1).join("_") + Array(song.length - fillUntil + 1).join("."); return `apply ${len} ${patch}`; } } } // Apply rules let matches = new Array<string>(song.length + 1).join(" ").split(""); for (let ruleNum = 0; ruleNum < myRestRules.length; ++ruleNum) { const rule = myRestRules[ruleNum]; for (let i = 0; i < rule.length; ++i) { if (rule[i] === "r" && song[i] === "r" && matches[i] === " ") { let ruleMatches = true; let ruleIsApplied = true; ruleMatch: for (let j = i + 1; j < rule.length; ++j) { if (rule[j] === "_" && song[j] === "_") { continue ruleMatch; } if (rule[j] !== "_") { if (song[j] === "_") { // New rule must start here. let k = j; while (song[k + 1] === "_") { ++k; } return `apply ${len} ${ Array(j + 1).join(".") + "r" + Array(k - j + 1).join("_") + Array(song.length - k).join(".") }`; } break ruleMatch; } if (matches[j] !== " ") { ruleMatches = false; ruleIsApplied = false; break ruleMatch; } if (song[j] === "r") { ruleIsApplied = false; } else if (song[j] === ".") { ruleMatches = false; break ruleMatch; } else if (song[j] === "_") { throw new Error("Not reached"); } } // end ruleMatch if (ruleMatches && !ruleIsApplied) { return `apply ${len} ${rule}`; } if (ruleMatches) { for (let j = 0; j < rule.length; ++j) { if (rule[j] !== ".") { if (matches[j] !== " ") { throw new Error("Double match."); } matches[j] = MATCH_CHARS[ruleNum]; } } } } else if (rule[i] === "*" && song[i] === "r" && matches[i] === " ") { // Match subbeat! if (this.isSimple()) { let bestSubbeatReplacement = ""; subbeatOptions: for (let j = 0; j < validSubBeatLengths.length; ++j) { let subbeatLength = validSubBeatLengths[j]; let k = i; while (rule[k + 1] === "*" && matches[k + 1] === " " && song[k + 1] !== ".") { ++k; } let matchedLength = k - i + 1; if (matchedLength >= subbeatLength) { // When it is preferable to show more clearly how the beat is divided, // divide the rest into half-beats. // Improvised extension: we apply this to quarters and eighths of a // beat as well. const isDotted = validNonDotSubBeatLengths.indexOf(subbeatLength) === -1; const atEnd = rule[i + subbeatLength] !== "*"; // XXX: make this a lint warning, instead of a requirement. const ruleSize = rule.split("*").length - 1; const ruleStart = rule.indexOf("*"); let dividesMiddle = false; for (let divisions = 2; divisions <= 16; divisions *= 2) { for (let l = 1; l < divisions; ++l) { const rulePoint = ruleStart + l * ruleSize / divisions; if (i < rulePoint && (i + subbeatLength) > rulePoint) { dividesMiddle = true; } if (i < rulePoint && i > (rulePoint - ruleSize / divisions) && (i + subbeatLength) > rulePoint && (i + subbeatLength) < rulePoint + ruleSize / divisions) { continue subbeatOptions; } } } // Allow, but do not prefer, dotted rests at the end of a beat. // XXX: we should have a "lint warning" suggesting non-dotted at end. const isOptional = isDotted && (atEnd || dividesMiddle); for (let l = i + 1; l < i + subbeatLength; ++l) { if (song[l] === "r") { let pattern = bestSubbeatReplacement || ( Array(i + 1).join(".") + "r" + Array(i + subbeatLength - i).join("_") + Array(song.length - (i + subbeatLength) + 1).join(".") ); if (isOptional) { bestSubbeatReplacement = pattern; continue subbeatOptions; } return `apply ${len} ${pattern}`; } } // Make sure the subbeat actually ends where it is supposed to. if (song[i + subbeatLength] === "_") { let l = i + subbeatLength; while (song[l] === "_") { ++l; } return `apply ${len} ${ Array((i + subbeatLength) + 1).join(".") + "r" + Array(l - (i + subbeatLength)).join("_") + Array(song.length - l + 1).join(".") }`; } // Subbeat match. for (let l = i; l < i + subbeatLength; ++l) { matches[l] = MATCH_CHARS[ruleNum]; } break subbeatOptions; } } } else { subbeatOptions: for (let j = 0; j < validSubBeatLengths.length; ++j) { let subbeatLength = validSubBeatLengths[j]; let k = i; while (rule[k + 1] === "*" && matches[k + 1] === " " && song[k + 1] !== ".") { ++k; } let matchedLength = k - i + 1; if (matchedLength >= subbeatLength) { // Make sure the subbeat actually ends where it is supposed to. if (song[i + subbeatLength] === "_") { let l = i + subbeatLength; while (song[l] === "_") { ++l; } return `apply ${len} ${ Array((i + subbeatLength) + 1).join(".") + "r" + Array(l - (i + subbeatLength)).join("_") + Array(song.length - l + 1).join(".") }`; } // Subbeat match. for (let l = i; l < i + subbeatLength; ++l) { matches[l] = MATCH_CHARS[ruleNum]; } break subbeatOptions; } } } // end subbeatOptions } } }; // Continuations that aren't matched should become rests for (let i = 0; i < song.length; ++i) { if (matches[i] === " " && song[i] !== ".") { let j = i; while (song[j + 1] === "_") { ++j; } return `apply ${len} ${ Array(i + 1).join(".") + "r" + Array(j - i + 1).join("_") + Array(song.length - j).join(".") }`; } } return "GOOD"; } } const REST_RULES_1: RestRules = Object.freeze([ "r", "*", ]); const REST_RULES_2: RestRules = Object.freeze([ "r_", "r.", ".r", "*.", ".*", ]); const REST_RULES_3: RestRules = Object.freeze([ "r__", "r..", ".r.", "..r", "*..", ".*.", "..*", ]); const REST_RULES_4: RestRules = Object.freeze([ "r___", "r_..", "..r_", "r...", ".r..", "..r.", "...r", "*...", ".*..", "..* ", "...*", ]); const REST_RULES_6: RestRules = Object.freeze([ "r_____", "r__...", // if dots allowed "...r__", // if dots allowed "r_....", "...r_.", "r.....", ".r....", "..r...", "...r..", "....r.", ".....r", "*.....", ".*....", "..*...", "...*..", "....*.", ".....*", ]); const REST_RULES_9: RestRules = Object.freeze([ "r________", "r__......", // if dots allowed (may be filtered out) "...r__...", // if dots allowed (may be filtered out) "......r__", // if dots allowed (may be filtered out) "r_.......", "...r_....", "......r_.", "r........", ".r.......", "..r......", "...r.....", "....r....", ".....r...", "......r..", ".......r.", "........r", "*........", ".*.......", "..*......", "...*.....", "....*....", ".....*...", "......*..", ".......*.", "........*", ]); const REST_RULES_12: RestRules = Object.freeze([ "r___________", "r_____......", // if dots allowed (may be filtered out) "......r_____", // if dots allowed (may be filtered out) "r__.........", // if dots allowed (may be filtered out) "...r__......", // if dots allowed (may be filtered out) "......r__...", // if dots allowed (may be filtered out) ".........r__", // if dots allowed (may be filtered out) "r_..........", "...r_.......", "......r_....", ".........r_.", "r...........", ".r..........", "..r.........", "...r........", "....r.......", ".....r......", "......r.....", ".......r....", "........r...", ".........r..", "..........r.", "...........r", "*...........", ".*..........", "..*.........", "...*........", "....*.......", ".....*......", "......*.....", ".......*....", "........*...", ".........*..", "..........*.", ".......... *", ]); const TIME_SIGNATURES: {readonly [name: string]: RestSolver} = Object.freeze({ "4/8": new RestSolver( 4, 8, REST_RULES_4, ), "2/4": new RestSolver( 2, 4, REST_RULES_2, ), "1/2": new RestSolver( 1, 2, REST_RULES_1, ), "4/4": new RestSolver( 4, 4, REST_RULES_4, ), "2/2": new RestSolver( 2, 2, REST_RULES_2, ), "1/1": new RestSolver( 1, 1, REST_RULES_1, ), "6/16": new RestSolver( 6, 16, REST_RULES_6, ), "6/8": new RestSolver( 6, 8, REST_RULES_6, ), "6/4": new RestSolver( 6, 4, REST_RULES_6, ), "12/8": new RestSolver( 12, 8, REST_RULES_12, ), "3/4": new RestSolver( 3, 4, REST_RULES_3, ), "3/8": new RestSolver( 3, 8, REST_RULES_3, ), "9/8": new RestSolver( 9, 8, REST_RULES_9, ), }); /** * $timeSignatureName is a string like "4/4" or "6/8". * * A $song is a string in a song where $barLength divisions make up a bar. * A full $song is made up of $barLength characters. * The string contains three kinds of characters. * - 'r': The start of a beat * - '_': The continuation of a beat * - '.': A note * * See README.md for examples / tests. */ export default function checkRests( timeSignatureName: string, barLength: number, song: string, options: IOptions): string { const ts = TIME_SIGNATURES[timeSignatureName]; if (!ts) { return `ERR: No such time signature ${timeSignatureName}.`; } const numerator = parseInt(timeSignatureName.split("/")[0], 10); if (isNaN(numerator)) { return `ERR: No such time signature ${timeSignatureName}`; } const denominator = parseInt(timeSignatureName.split("/")[1], 10); if (isNaN(denominator)) { return `ERR: No such time signature ${timeSignatureName}`; } const divisions = barLength / numerator / 4 * denominator; if (divisions !== Math.round(divisions)) { return `ERR: Invalid bar length ${barLength}. Divisions per quarter note must be an integer.`; } return ts.checkRests(divisions, song, options); }