Spaces:
Sleeping
Sleeping
import {WHOLE_DURATION_MAGNITUDE, FractionNumber, lcmMulti, gcd, MAIN_SCORE_NAME} from "./utils"; | |
import * as idioms from "./idioms"; | |
import {LILYPOND_VERSION} from "../constants"; | |
import * as measureLayout from "../measureLayout"; | |
import ImplicitType from "../lilyNotation/implicitType"; | |
import pick from "../pick"; | |
interface Location { | |
lines: [number, number]; | |
columns: [number, number]; | |
}; | |
abstract class Locator { | |
location: Location; | |
constructor (term: BaseTerm) { | |
term._location = term._location || {lines: [0, 0], columns: [0, 0]}; | |
this.location = term._location; | |
} | |
abstract set (line: number, column: number): void; | |
}; | |
class OpenLocator extends Locator { | |
set (line: number, column: number) { | |
this.location.lines[0] = line; | |
this.location.columns[0] = column; | |
} | |
}; | |
class CloseLocator extends Locator { | |
set (line: number, column: number) { | |
this.location.lines[1] = line; | |
this.location.columns[1] = column; | |
} | |
}; | |
// concat array of array | |
const cc = <T>(arrays: T[][]): T[] => [].concat(...arrays); | |
export class MusicChunk { | |
parent: MusicBlock; | |
terms: BaseTerm[]; | |
static join (chunks: MusicChunk[]): BaseTerm[] { | |
return cc(chunks.map((chunk, i) => i === chunks.length - 1 ? chunk.terms : [...chunk.terms, new Divide({})])); | |
} | |
constructor (parent: MusicBlock, terms: BaseTerm[] = []) { | |
this.parent = parent; | |
this.terms = terms; | |
} | |
push (term: BaseTerm) { | |
this.terms.push(term); | |
} | |
get size () { | |
return this.terms.length; | |
} | |
get durationMagnitude () { | |
return this.terms.reduce((magnitude, term) => magnitude + term.durationMagnitude, 0); | |
} | |
}; | |
interface MusicVoice { | |
name?: string; | |
body: MusicChunk[]; | |
}; | |
type MusicChunkMap = Map<number, MusicChunk>; | |
const isNullItem = item => item === "" || item === undefined || item === null || (Array.isArray(item) && !item.length); | |
const compact = items => cc(items.map((item, index) => isNullItem(item) ? [] : [index > 0 ? "\b" : null, item])); | |
export const getDurationSubdivider = (term: BaseTerm): number => { | |
if (term instanceof MusicEvent) { | |
if (!(term instanceof Rest) || !term.isSpacer) | |
return term.durationValue.subdivider; | |
} | |
else if (term instanceof MusicBlock) | |
return lcmMulti(...term.body.map(getDurationSubdivider)); | |
else if (term instanceof MusicChunk) | |
return lcmMulti(...term.terms.map(getDurationSubdivider)); | |
else if ((term instanceof Times) || (term instanceof Tuplet)) { | |
const divider = term instanceof Tuplet ? term.divider : term.factor.reciprocal; | |
divider.numerator *= getDurationSubdivider(term.music); | |
return divider.reduced.numerator; | |
} | |
else if (term instanceof Repeat) | |
return getDurationSubdivider(term.bodyBlock); | |
else if (term instanceof Relative) | |
return getDurationSubdivider(term.music); | |
else if (term.isMusic) | |
console.warn("[getDurationSubdivider] unexpected music term:", term); | |
return 1; | |
}; | |
export const constructMusicFromMeasureLayout = (layout: measureLayout.MeasureLayout, chunks: MusicChunkMap): MusicChunk => { | |
const joinMeasureSeq = (seq: measureLayout.MeasureSeq): BaseTerm[] => MusicChunk.join(seq.map(sublayout => constructMusicFromMeasureLayout(sublayout, chunks))); | |
if (layout instanceof measureLayout.SingleMLayout) { | |
const chunk = chunks.get(layout.measure); | |
console.assert(!!chunk, "no chunk for measure:", layout.measure); | |
return chunk; | |
} | |
else if (layout instanceof measureLayout.BlockMLayout) { | |
const terms = joinMeasureSeq(layout.seq); | |
return new MusicChunk(null, terms); | |
} | |
else if (layout instanceof measureLayout.VoltaMLayout) { | |
const bodyTerms = joinMeasureSeq(layout.body); | |
const alternative = layout.alternates && layout.alternates.map(alternate => new MusicBlock({body: joinMeasureSeq(alternate)})); | |
const repeat = Repeat.createVolta(layout.times.toString(), new MusicBlock({body: bodyTerms}), alternative); | |
return new MusicChunk(null, [repeat]); | |
} | |
else if (layout instanceof measureLayout.ABAMLayout) { | |
const mainList = constructMusicFromMeasureLayout(layout.main, chunks); | |
const main = mainList.terms.length === 1 ? mainList.terms[0] : new MusicBlock({body: mainList.terms}); | |
const restTerms = joinMeasureSeq(layout.rest); | |
const block = new MusicBlock({body: [main, ...restTerms]}); | |
return new MusicChunk(null, [new Variable({name: "lotusRepeatABA"}), block]); | |
} | |
}; | |
export class BaseTerm { | |
_location?: Location; | |
_measure?: number; | |
_tick?: number; | |
_previous?: BaseTerm; | |
_anchorPitch?: ChordElement; | |
_parent?: BaseTerm; | |
_headComment: Comment; | |
_tailComment: Comment; | |
// lotus extensional function modifier | |
_functional: string; | |
constructor (data: object) { | |
//Object.assign(this, data); | |
for (const key in data) | |
this[key] = parseRaw(data[key]); | |
} | |
serialize (): any[] { | |
console.warn("unimplemented serilization:", this); | |
return []; | |
} | |
join (): string { | |
let words = this.serialize().filter(word => ["string", "number"].includes(typeof word)).map(word => word.toString()) as string[]; | |
words = words.filter((word, i) => !(i && words[i - 1] === "\n" && word === "\n")); | |
let indent = 0; | |
const result: string[] = []; | |
const pop = char => { | |
if (!char || result[result.length - 1] === char) { | |
result.pop(); | |
return true; | |
} | |
}; | |
for (const word of words) { | |
switch (word) { | |
case "\b": | |
// remove last space | |
pop(" "); | |
continue; | |
case "\b\n": | |
// remove last newline | |
while (pop("\t")) {} | |
pop("\n"); | |
continue; | |
case "\n": | |
// no space at line tail | |
pop(" "); | |
} | |
if (/^(\}|>>)/.test(word)) | |
pop("\t"); // remove the last tab | |
result.push(word); | |
if (/\n$/.test(word)) { | |
if (/(\{|<<)\n$/.test(word)) | |
++indent; | |
else if (/^(\}|>>)/.test(word)) | |
--indent; | |
if (indent) | |
result.push(...Array(indent).fill("\t")); | |
} | |
else | |
result.push(" "); | |
} | |
return result.join(""); | |
} | |
relocate (source: string = this.join()) { | |
const words = this.serialize() | |
.filter(word => word !== null && word !== undefined | |
&& (typeof word !== "string" || (/\S/.test(word) && !word.includes("\b")))) | |
.map(word => typeof word === "string" ? word.replace(/\n/g, "") : word); | |
const chars = source.split(""); | |
let line = 1; | |
let column = 0; | |
let wordIndex = 0; | |
for (let i = 0; i < chars.length; ++i) { | |
if (wordIndex >= words.length) | |
break; | |
const char = chars[i]; | |
switch (char) { | |
case "\n": | |
++line; | |
column = 0; | |
break; | |
case " ": | |
case "\t": | |
++column; | |
break; | |
default: | |
let word = words[wordIndex]; | |
while (word instanceof Locator) { | |
word.set(line, column); | |
++wordIndex; | |
word = words[wordIndex]; | |
} | |
if (wordIndex >= words.length) | |
break; | |
word = word.toString(); | |
if (char === word[0]) { | |
i += word.length - 1; | |
column += word.length; | |
++wordIndex; | |
} | |
else { | |
//debugger; | |
throw new Error(`unexpected char in source: [${i}]'${char}', expect: ${word}`); | |
} | |
} | |
} | |
} | |
clone (): this { | |
return parseRaw(JSON.parse(JSON.stringify(this))); | |
} | |
get entries (): BaseTerm[] { | |
return null; | |
} | |
get isMusic (): boolean { | |
return false; | |
} | |
get musicChunks (): MusicChunk[] { | |
if (!this.isMusic || !this.entries) | |
return []; | |
return [].concat(...this.entries.map(entry => entry.musicChunks)); | |
} | |
get measures (): number[] { | |
const indices = [this._measure].concat(...(this.entries || []).map(entry => entry.measures)).filter(index => Number.isInteger(index)); | |
return Array.from(new Set(indices)); | |
} | |
get durationMagnitude (): number { | |
return 0; | |
} | |
get proto () { | |
return termProtoMap.get(Object.getPrototypeOf(this)); | |
} | |
get href (): string { | |
if (this._location) | |
return `${this._location.lines[0]}:${this._location.columns[0]}:${this._location.columns[1]}`; | |
return null; | |
} | |
get measureLayout (): measureLayout.MeasureLayout { | |
return null; | |
} | |
getField (key): any { | |
console.assert(!!this.entries, "[BaseTerm.getField] term's entries is null:", this); | |
for (const entry of this.entries) { | |
const result = entry.query(key); | |
if (result) | |
return result; | |
} | |
} | |
query (key: string): any { | |
void(key); | |
//console.warn("term.query not implemented:", this); | |
} | |
appendAssignment (key, value) { | |
console.assert(!!this.entries, "no entries on this term."); | |
const assign = this.getField(key); | |
if (assign) | |
assign.value = value; | |
else { | |
this.entries.push(parseRaw({ | |
proto: "Assignment", | |
key, | |
value: value, | |
})); | |
} | |
} | |
findFirst (condition: Function): BaseTerm { | |
if (!this.entries) | |
return null; | |
if (BaseTerm.isPrototypeOf(condition)) { | |
const termClass = condition; | |
condition = term => term instanceof termClass; | |
} | |
for (const entry of this.entries) { | |
if (condition(entry)) | |
return entry; | |
if (entry instanceof BaseTerm) { | |
const result = entry.findFirst(condition); | |
if (result) | |
return result; | |
} | |
} | |
} | |
findLast (condition: any): BaseTerm { | |
if (!this.entries) | |
return null; | |
if (BaseTerm.isPrototypeOf(condition)) { | |
const termClass = condition; | |
condition = term => term instanceof termClass; | |
} | |
const reversedEntries = [...this.entries]; | |
reversedEntries.reverse(); | |
for (const entry of reversedEntries) { | |
if (condition(entry)) | |
return entry; | |
if (entry instanceof BaseTerm) { | |
const result = entry.findLast(condition); | |
if (result) | |
return result; | |
} | |
} | |
} | |
findAll (condition: any): any[] { | |
if (!this.entries) | |
return []; | |
if (BaseTerm.isPrototypeOf(condition)) { | |
const termClass = condition; | |
condition = term => term instanceof termClass; | |
} | |
const result = []; | |
for (const entry of this.entries) { | |
if (condition(entry)) | |
result.push(entry); | |
if (entry instanceof BaseTerm) | |
result.push(...entry.findAll(condition)); | |
} | |
return result; | |
} | |
forEachTerm (termClass, handle) { | |
if (!this.entries) | |
return; | |
for (const entry of this.entries) { | |
if (entry instanceof termClass) | |
handle(entry); | |
if (entry instanceof BaseTerm) | |
entry.forEachTerm(termClass, handle); | |
} | |
} | |
forEachTopTerm (termClass, handle) { | |
if (!this.entries) | |
return; | |
for (const entry of this.entries) { | |
if (entry instanceof termClass) | |
handle(entry); | |
else if (entry instanceof BaseTerm) | |
entry.forEachTopTerm(termClass, handle); | |
} | |
} | |
toJSON () { | |
// exlude meta fields in JSON | |
const fields = Object.keys(this).filter(key => !/^_/.test(key)); | |
const data = pick(this, fields); | |
Object.entries(data).forEach(([key, value]) => { | |
if (value && typeof value === "object" && !Array.isArray(value) && !(value instanceof BaseTerm)) | |
data[key] = {proto: "_PLAIN", ...value}; | |
}); | |
return { | |
proto: this.proto, | |
...data, | |
}; | |
} | |
static isTerm (x): boolean { | |
return typeof x === "object" && x instanceof BaseTerm; | |
} | |
static optionalSerialize (item: any): any[] { | |
//return BaseTerm.isTerm(item) ? (item as BaseTerm).serialize() : (item === undefined ? [] : [item]); | |
if (!BaseTerm.isTerm(item)) | |
return item === undefined ? [] : [item]; | |
return [ | |
...BaseTerm.optionalSerialize(item._headComment), | |
...item.serialize(), | |
...(item._tailComment ? ["\b\n", "\t"] : []), | |
...BaseTerm.optionalSerialize(item._tailComment), | |
]; | |
} | |
static serializeScheme (item: any): any[] { | |
if (typeof item === "boolean") | |
item = item ? "#t" : "#f"; | |
return BaseTerm.optionalSerialize(item); | |
} | |
} | |
export class Root extends BaseTerm { | |
sections: BaseTerm[]; | |
serialize () { | |
return cc(this.sections.map(section => [...BaseTerm.optionalSerialize(section), "\n\n"])); | |
} | |
get entries (): BaseTerm[] { | |
return this.sections; | |
} | |
getBlock (head): Block { | |
return this.entries.find((entry: any) => entry.head === head || (entry.head === "\\" + head)) as Block; | |
} | |
get includeFiles (): string[] { | |
return this.sections.filter(section => section instanceof Include).map((include: Include) => include.filename); | |
} | |
static priorityForSection (term: BaseTerm): number { | |
if (term instanceof Version) | |
return 0; | |
if (term instanceof Language) | |
return 1; | |
if (term instanceof Scheme) | |
return 3; | |
if (term instanceof Assignment) | |
return 7; | |
if (term instanceof Block) { | |
switch (term.head) { | |
case "\\header": | |
return 2; | |
case "\\paper": | |
return 4; | |
case "\\layout": | |
return 5; | |
case "\\score": | |
return 10; | |
} | |
} | |
return Infinity; | |
} | |
reorderSections () { | |
this.sections.sort((s1, s2) => Root.priorityForSection(s1) - Root.priorityForSection(s2)); | |
} | |
}; | |
export class Primitive extends BaseTerm { | |
exp: string | number; | |
serialize () { | |
return [this.exp]; | |
} | |
}; | |
export class LiteralString extends BaseTerm { | |
exp: string | |
static fromString (content: string): LiteralString { | |
return new LiteralString({exp: JSON.stringify(content)}); | |
} | |
serialize () { | |
return [this.exp]; | |
} | |
toString () { | |
try { | |
return eval(this.exp); | |
} | |
catch (err) { | |
console.warn("invalid lilypond string exp:", this.exp); | |
return this.exp; | |
} | |
} | |
}; | |
export class Command extends BaseTerm { | |
cmd: string; | |
args: any[]; | |
static createSet (key: string|BaseTerm, value: BaseTerm): Command { | |
return new Command({cmd: "set", args: [new Assignment({key, value})]}); | |
} | |
constructor (data) { | |
super(data); | |
this.args.forEach(term => { | |
if (term instanceof MusicBlock || term instanceof Block) | |
term._parent = this; | |
}); | |
} | |
serialize () { | |
return [ | |
"\\" + this.cmd, | |
...[].concat(...this.args.map(BaseTerm.optionalSerialize)), | |
["break", "pageBreak", "overrideProperty"].includes(this.cmd) ? "\n" : null, | |
]; | |
} | |
get entries () { | |
return this.args.filter(arg => arg instanceof BaseTerm); | |
} | |
get isMusic (): boolean { | |
return this.args.some(arg => arg.isMusic); | |
} | |
get musicChunks (): MusicChunk[] { | |
if (this.cmd === "alternative") | |
return [].concat(...this.args[0].body.map(term => term.musicChunks)); | |
return [].concat(...this.entries.map(entry => entry.musicChunks)); | |
} | |
get isRepeatWithAlternative () { | |
return this.cmd === "repeat" | |
&& this.args[2] instanceof MusicBlock | |
&& this.args[3] | |
&& this.args[3].cmd === "alternative"; | |
} | |
get durationMagnitude (): number { | |
switch (this.cmd) { | |
// TODO: refine this in Times | |
case "times": { | |
const factor = eval(this.args[0]); | |
return this.args[1].durationMagnitude * factor; | |
} | |
// TODO: refine this in Tuplet | |
case "tuplet": { | |
const factor = 1 / eval(this.args[0]); | |
return this.args[this.args.length - 1].durationMagnitude * factor; | |
} | |
case "afterGrace": | |
return this.args[0].durationMagnitude; | |
default: | |
if (this instanceof Grace) | |
return 0; | |
return this.args.filter(arg => arg instanceof BaseTerm).reduce((magnitude, term) => magnitude + term.durationMagnitude, 0); | |
} | |
} | |
get measureLayout (): measureLayout.MeasureLayout { | |
const args = [...this.args].reverse(); | |
for (const arg of args) { | |
const layout = arg instanceof BaseTerm && arg.measureLayout; | |
if (layout) | |
return layout; | |
} | |
return null; | |
} | |
getAssignmentPair (): {key: any, value: any} { | |
if (this.args[0] instanceof Assignment) | |
return {key: this.args[0].key, value: this.args[0].value}; | |
if (this.args[1] instanceof Assignment) | |
return {key: this.args[0], value: this.args[1].value}; | |
if (typeof this.args[0] === "string") | |
return {key: this.args[0], value: ""}; | |
return null; | |
} | |
}; | |
export class Variable extends Command { | |
name: string | |
constructor ({name}) { | |
super({cmd: name, args: []}); | |
this.name = name; | |
} | |
toJSON (): any { | |
return { | |
proto: this.proto, | |
name: this.name, | |
}; | |
} | |
queryValue (dict: BaseTerm): any { | |
const field = dict.getField(this.name); | |
return field && field.value; | |
} | |
get isMusic (): boolean { | |
if ([MAIN_SCORE_NAME].includes(this.name)) | |
return true; | |
return false; | |
} | |
}; | |
export class MarkupCommand extends Command { | |
toString () { | |
const strs = []; | |
this.forEachTerm(LiteralString, term => strs.push(term.toString())); | |
return strs.join("\n"); | |
} | |
}; | |
export class Repeat extends Command { | |
static createVolta (times: string, body: MusicBlock, alternative?: MusicBlock[]): Repeat { | |
const args: any[] = [ | |
"volta", | |
times, | |
body, | |
]; | |
if (alternative) { | |
args.push(new Command({ | |
cmd: "alternative", | |
args: [new MusicBlock({body: alternative})], | |
})); | |
} | |
return new Repeat({cmd: "repeat", args}); | |
} | |
get type (): string { | |
return this.args[0]; | |
} | |
get times () { | |
return Number(this.args[1]); | |
} | |
get bodyBlock (): MusicBlock { | |
return this.args[2]; | |
} | |
get alternativeBlocks (): MusicBlock[] { | |
return this.args[3] && this.args[3].args[0].body; | |
} | |
// this result length equal to times, if not null | |
get completeAlternativeBlocks (): MusicBlock[] { | |
if (!this.alternativeBlocks || !this.alternativeBlocks.length) | |
return null; | |
if (this.alternativeBlocks.length >= this.times) | |
return this.alternativeBlocks.slice(0, this.times); | |
const list = []; | |
for (let i = 0; i < this.times - this.alternativeBlocks.length; ++ i) | |
list.push(this.alternativeBlocks[0]); | |
list.push(...this.alternativeBlocks); | |
return list; | |
} | |
get measureLayout (): measureLayout.MeasureLayout { | |
switch (this.type) { | |
case "volta": { | |
const layout = new measureLayout.VoltaMLayout(); | |
layout.times = this.times; | |
layout.body = this.bodyBlock.measureLayout.seq; | |
layout.alternates = this.alternativeBlocks && this.alternativeBlocks.map(block => block.measureLayout.seq); | |
return layout; | |
} | |
case "tremolo": | |
return this.bodyBlock.measureLayout; | |
default: | |
console.warn("unsupported repeat type:", this.type); | |
} | |
return null; | |
} | |
// for tremolo | |
get sumDuration (): Duration { | |
if (this.bodyBlock instanceof MusicEvent) | |
return Duration.fromMagnitude(this.args[2].durationMagnitude * this.times); | |
else if (this.bodyBlock instanceof MusicBlock) { | |
const events = this.bodyBlock.body.filter(term => term instanceof MusicEvent); | |
const magnitude = events.reduce((m, event) => m + event.durationMagnitude, 0) * this.times; | |
return Duration.fromMagnitude(magnitude); | |
} | |
return null; | |
} | |
get singleTremolo (): boolean { | |
if (this.type === "tremolo") { | |
if (this.bodyBlock instanceof MusicEvent) | |
return true; | |
if (this.bodyBlock instanceof MusicBlock) { | |
const events = this.bodyBlock.body.filter(term => term instanceof MusicEvent); | |
return events.length === 1; | |
} | |
} | |
return false; | |
} | |
// \repeat {body} \alternative {{alter1} {alter2}} => body alter1 body alter2 | |
getUnfoldTerms (): BaseTerm[] { | |
const completeAlternativeBlocks = this.completeAlternativeBlocks; | |
const list = []; | |
for (let i = 0; i < this.times; ++i) { | |
list.push(...this.bodyBlock.clone().body); | |
if (completeAlternativeBlocks) | |
list.push(...completeAlternativeBlocks[i].clone().body); | |
} | |
return list; | |
} | |
// \repeat {body} \alternative {{alter1} {alter2}} => body alter1 alter2 | |
getPlainTerms (): BaseTerm[] { | |
const list = [...this.bodyBlock.clone().body]; | |
const alternativeBlocks = this.alternativeBlocks; | |
if (alternativeBlocks) | |
alternativeBlocks.forEach(block => list.push(...block.clone().body)); | |
return list; | |
} | |
// \repeat {body} \alternative {{alter1} {alter2}} => body alter2 | |
getTailPassTerms (): BaseTerm[] { | |
const list = [...this.bodyBlock.clone().body]; | |
const alternativeBlocks = this.alternativeBlocks; | |
if (alternativeBlocks) | |
list.push(...alternativeBlocks[alternativeBlocks.length - 1].clone().body); | |
return list; | |
} | |
}; | |
export class Relative extends Command { | |
static makeBlock (block: MusicBlock, {anchor}: {anchor?: ChordElement} = {}): Relative { | |
if (!anchor) { | |
const chord = block.findFirst(Chord) as Chord; | |
anchor = chord && chord.anchorPitch; | |
} | |
return new Relative({cmd: "relative", args: [anchor, block].filter(term => term)}); | |
} | |
get anchor (): ChordElement { | |
if (this.args[0] instanceof ChordElement) | |
return this.args[0]; | |
return null; | |
} | |
get music (): BaseTerm { | |
return this.args[this.args.length - 1]; | |
} | |
get headChord (): Chord { | |
return this.findFirst(Chord) as Chord; | |
} | |
get tailPitch (): ChordElement { | |
const tail = this.findLast(Chord) as Chord; | |
return tail && tail.absolutePitch; | |
} | |
// with side effect | |
shiftBody (newAnchor?: ChordElement): BaseTerm[] { | |
const headChord = this.headChord; | |
if (newAnchor && headChord) { | |
headChord.shiftAnchor(newAnchor); | |
headChord._anchorPitch = null; | |
//console.log("shiftAnchor.post:", headChord.join(), headChord); | |
} | |
const music = this.music; | |
if (music instanceof MusicBlock) { | |
//music.clearPitchCache(); | |
return music.body; | |
} | |
return [music]; | |
} | |
} | |
export class ParallelMusic extends Command { | |
get varNames (): string[] { | |
return ((this.args[0].exp as SchemePointer).value as SchemeFunction).asList as string[]; | |
} | |
get body (): MusicBlock { | |
return this.args[1]; | |
} | |
get voices (): MusicVoice[] { | |
const voiceNames = this.varNames; | |
const chunks = this.body.musicChunks; | |
const measureCount = Math.ceil(chunks.length / voiceNames.length); | |
return voiceNames.map((name, index) => ({ | |
name: name.toString(), | |
body: Array(measureCount).fill(null).map((_, m) => chunks[m * voiceNames.length + index]).filter(Boolean), | |
})); | |
} | |
}; | |
export class TimeSignature extends Command { | |
get value (): FractionNumber { | |
return FractionNumber.fromExpression(this.args[0]); | |
} | |
}; | |
export class Partial extends Command { | |
get duration (): Duration { | |
return this.args[0]; | |
} | |
}; | |
export class Times extends Command { | |
get factor (): FractionNumber { | |
return FractionNumber.fromExpression(this.args[0]); | |
} | |
get music (): BaseTerm { | |
return this.args[this.args.length - 1]; | |
} | |
}; | |
export class Tuplet extends Command { | |
get divider (): FractionNumber { | |
return FractionNumber.fromExpression(this.args[0]); | |
} | |
get music (): BaseTerm { | |
return this.args[this.args.length - 1]; | |
} | |
}; | |
export class Grace extends Command { | |
get music (): BaseTerm { | |
return this.args[this.args.length - 1]; | |
} | |
}; | |
export class AfterGrace extends Command { | |
get body (): BaseTerm { | |
return this.args[0]; | |
} | |
get grace (): BaseTerm { | |
return this.args[1]; | |
} | |
get measureLayout (): measureLayout.MeasureLayout { | |
return measureLayout.BlockMLayout.fromSeq([ | |
this.body.measureLayout, | |
this.grace.measureLayout, | |
]); | |
} | |
}; | |
export class Clef extends Command { | |
get clefName (): string { | |
return this.args[0].toString(); | |
} | |
}; | |
export class KeySignature extends Command { | |
get keyPitch (): ChordElement { | |
return new ChordElement({pitch: this.args[0], options: {proto: "_PLAIN"}}); | |
} | |
get key (): number { | |
const keyPitch = this.keyPitch; | |
const minor = this.args[1] === "\\minor"; | |
const phonetOrder = idioms.FIFTH_PHONETS.indexOf(keyPitch.phonet); | |
return phonetOrder + (minor ? -4 : -1) + keyPitch.alterValue * 7; | |
} | |
}; | |
export class OctaveShift extends Command { | |
get value (): number { | |
return this.args[0].exp; | |
} | |
}; | |
export class Include extends Command { | |
static create (filename: string): Include { | |
return new Include({cmd: "include", args: [LiteralString.fromString(filename)]}); | |
} | |
get filename (): string { | |
return this.args[0].toString(); | |
} | |
}; | |
export class Version extends Command { | |
static get default (): Version { | |
return new Version({cmd: "version", args: [LiteralString.fromString(LILYPOND_VERSION)]}); | |
} | |
get version (): string { | |
return this.args[0].toString(); | |
} | |
}; | |
export class Language extends Command { | |
static make (language: string): Language { | |
return new Language({cmd: "language", args: [LiteralString.fromString(language)]}); | |
} | |
get language (): string { | |
return this.args[0].toString(); | |
} | |
}; | |
export class LyricMode extends Command { | |
get block (): MusicBlock { | |
return this.args[0]; | |
} | |
}; | |
export class ChordMode extends Command { | |
get block (): MusicBlock { | |
return this.args[0]; | |
} | |
}; | |
export class Transposition extends Command { | |
get transposition (): number { | |
return this.args[0].pitchValue - 60; | |
} | |
}; | |
export class StemDirection extends Command { | |
get direction (): string { | |
return this.cmd.substr(4); | |
} | |
}; | |
export class Change extends Command { | |
get key (): string { | |
return this.args[0].toString(); | |
} | |
get value (): string { | |
return this.args[1].toString(); | |
} | |
}; | |
export class Block extends BaseTerm { | |
block: string; | |
head: (string|string[]); | |
body: BaseTerm[]; | |
constructor (data) { | |
super(data); | |
this.body = this.body.map(parseRawEnforce); | |
} | |
serialize () { | |
const heads = Array.isArray(this.head) ? this.head : (this.head ? [this.head] : []); | |
return [ | |
...heads, | |
"{\n", | |
...cc(this.body.map(section => [...BaseTerm.optionalSerialize(section), "\n"])), | |
"}\n", | |
]; | |
} | |
get entries () { | |
return this.body; | |
} | |
get isMIDIDedicated () { | |
const subBlocks = this.body.filter(term => term instanceof Block) as Block[]; | |
return subBlocks.some(term => term.head === "\\midi") | |
&& !subBlocks.some(term => term.head === "\\layout"); | |
} | |
get assignmentDict (): {[key: string]: string} { | |
const assignments = this.body.filter(term => term instanceof Assignment) as Assignment[]; | |
return assignments.reduce((dict, assignment) => ((dict[assignment.key.toString()] = assignment.value.toString()), dict), {}); | |
} | |
}; | |
export class InlineBlock extends Block { | |
serialize () { | |
return [ | |
"{", | |
...cc(this.body.map(BaseTerm.optionalSerialize)), | |
"}", | |
]; | |
} | |
}; | |
export class MusicBlock extends BaseTerm { | |
body: BaseTerm[]; | |
static fromTerms (terms: BaseTerm[]): MusicBlock { | |
const block = new MusicBlock({body: [...terms]}); | |
block.clarifyHead(); | |
return block; | |
} | |
constructor (data) { | |
super(data); | |
this.body = this.body.map(parseRawEnforce); | |
} | |
serialize () { | |
return [ | |
"{\n", | |
...cc(this.body.map(BaseTerm.optionalSerialize)), | |
"\n", | |
"}\n", | |
]; | |
} | |
clone (): this { | |
if (this._parent) { | |
const parent = this._parent.clone(); | |
const block = parent.findFirst(MusicBlock); | |
console.assert(block && block._parent === parent, "invalid block-parent relation", parent, block); | |
return block as this; | |
} | |
return BaseTerm.prototype.clone.call(this) as this; | |
} | |
get entries () { | |
return this.body; | |
} | |
get isMusic (): boolean { | |
return true; | |
} | |
get musicChunks (): MusicChunk[] { | |
const chunks = []; | |
let currentChunk = new MusicChunk(this); | |
const dumpChunk = () => { | |
if (currentChunk.size) | |
chunks.push(currentChunk); | |
currentChunk = new MusicChunk(this); | |
}; | |
for (const term of this.entries) { | |
if (term instanceof Repeat) { | |
dumpChunk(); | |
chunks.push(...term.musicChunks); | |
} | |
else if (term instanceof Divide) | |
dumpChunk(); | |
else | |
currentChunk.push(term); | |
} | |
dumpChunk(); | |
return chunks; | |
} | |
// [deprecated] | |
// for parallelMusic only | |
get voiceNames () { | |
const header = this._parent as Command; | |
if (header && header.cmd === "parallelMusic") { | |
if (header.args[0] instanceof Scheme && header.args[0].exp instanceof SchemePointer && header.args[0].exp.value instanceof SchemeFunction) { | |
const voices = header.args[0].exp.value.asList; | |
return voices; | |
} | |
} | |
return null; | |
} | |
// [deprecated] | |
get voices (): MusicVoice[] { | |
const voiceNames = this.voiceNames; | |
if (!voiceNames) | |
return [{body: this.musicChunks}]; | |
const chunks = this.musicChunks; | |
const measureCount = Math.ceil(chunks.length / voiceNames.length); | |
return voiceNames.map((name, index) => ({ | |
name: name.toString(), | |
body: Array(measureCount).fill(null).map((_, m) => chunks[m * voiceNames.length + index]).filter(chunk => chunk), | |
})); | |
} | |
get durationMagnitude (): number { | |
return this.body.reduce((magnitude, term) => magnitude + term.durationMagnitude, 0); | |
} | |
get isRelative (): boolean { | |
return this._parent instanceof Relative; | |
} | |
get anchorPitch (): ChordElement { | |
if (this.isRelative) | |
return (this._parent as Relative).anchor; | |
return null; | |
} | |
get measures (): number[] { | |
// make a continouse indices list | |
const subterms = this.findAll(term => term.isMusic); | |
const subIndices = [].concat(...subterms.map(term => term.measures)).filter(index => Number.isInteger(index)); | |
if (!subIndices.length) | |
return []; | |
const min = Math.min(...subIndices); | |
const max = Math.max(...subIndices); | |
return Array(max + 1 - min).fill(null).map((_, i) => i + min); | |
} | |
get notes (): Chord[] { | |
const notes = this.body.filter(term => term instanceof Chord && !term.isRest) as Chord[]; | |
this.forEachTopTerm(MusicBlock, block => notes.push(...block.notes)); | |
return notes; | |
} | |
get sonicNotes (): Chord[] { | |
return this.notes.filter(note => !note.completeTied); | |
} | |
get noteTicks (): number[] { | |
const ticks = this.sonicNotes.map(note => note._tick); | |
return Array.from(new Set(ticks)).sort((t1, t2) => t1 - t2); | |
} | |
get measureTicks (): [number, number][] { | |
const tickTable: {[key: string]: number} = {}; | |
this.body.forEach(term => { | |
if (Number.isFinite(term._measure) && Number.isFinite(term._tick)) { | |
if (!Number.isFinite(tickTable[term._measure])) | |
tickTable[term._measure] = term._tick; | |
} | |
}); | |
return Object.entries(tickTable).map(([measure, tick]) => [Number(measure), tick]); | |
} | |
get measureLayout (): measureLayout.MeasureLayout { | |
const seq = this.body.filter(term => term.isMusic).map(term => term.measureLayout).filter(Boolean); | |
if (this._functional === "lotusRepeatABA") { | |
const [main, ...rest] = seq; | |
const layout = new measureLayout.ABAMLayout(); | |
layout.main = main; | |
layout.rest = measureLayout.BlockMLayout.trimSeq(rest); | |
return layout; | |
} | |
return measureLayout.BlockMLayout.fromSeq(seq); | |
} | |
get measureChunkMap (): MusicChunkMap { | |
const map = new Map<number, MusicChunk>(); | |
this.body.forEach(term => { | |
if (Number.isInteger(term._measure) && !(term instanceof Divide)) { | |
if (!map.get(term._measure)) | |
map.set(term._measure, new MusicChunk(this)); | |
const chunk = map.get(term._measure); | |
chunk.terms.push(term); | |
} | |
}); | |
return map; | |
} | |
clearPitchCache () { | |
this.forEachTerm(ChordElement, pitch => { | |
pitch._absolutePitch = null; | |
//pitch._previous = null; | |
}); | |
} | |
updateChordAnchors () { | |
const chord = this.findFirst(Chord) as Chord; | |
if (chord) | |
chord._anchorPitch = chord._anchorPitch || this.anchorPitch; | |
this.clearPitchCache(); | |
} | |
// deprecated | |
updateChordChains () { | |
let previous: MusicEvent = null; | |
this.updateChordAnchors(); | |
this.forEachTerm(MusicBlock, block => block.updateChordAnchors()); | |
this.forEachTerm(MusicEvent, event => { | |
event._previous = previous; | |
previous = event; | |
}); | |
} | |
// with side effect | |
spreadRepeatBlocks ({ignoreRepeat = true, keepTailPass = false} = {}): this { | |
this.forEachTerm(MusicBlock, block => block.spreadRepeatBlocks()); | |
this.body = cc(this.body.map(term => { | |
if (term instanceof Repeat) { | |
if (!ignoreRepeat) | |
return term.getUnfoldTerms(); | |
else if (keepTailPass) | |
return term.getTailPassTerms(); | |
else | |
return term.getPlainTerms(); | |
} | |
else | |
return [term]; | |
})); | |
return this; | |
} | |
/*// with side effect | |
spreadRelativeBlocks (): this { | |
this.forEachTerm(MusicBlock, block => block.spreadRelativeBlocks()); | |
let anchorPitch = null; | |
this.body = cc(this.body.map(term => { | |
if (term instanceof Relative) { | |
const list = term.shiftBody(anchorPitch); | |
anchorPitch = term.tailPitch || anchorPitch; | |
return list; | |
} | |
else | |
return [term]; | |
})); | |
return this; | |
}*/ | |
// with side effect | |
unfoldDurationMultipliers (): this { | |
let timeDenominator = 4; | |
const unfoldMultipliers = (term): BaseTerm[] => { | |
if (term instanceof TimeSignature) | |
timeDenominator = term.value.denominator; | |
if (!(term instanceof MusicEvent) || !term.duration || !term.duration.multipliers || !term.duration.multipliers.length) | |
return [term]; | |
const factor = term.duration.multipliers.reduce((factor, multiplier) => factor * Number(multiplier), 1); | |
if (!Number.isInteger(factor) || factor <= 0) | |
return [term]; | |
const denominator = Math.max(term.duration.denominator, timeDenominator); | |
const event = term.clone() as MusicEvent; | |
event.duration.multipliers = []; | |
// break duration into multiple rest events | |
const restCount = (event.duration.magnitude / WHOLE_DURATION_MAGNITUDE) * (factor - 1) * denominator; | |
if (!Number.isInteger(restCount)) | |
console.warn("Rest count is not integear:", restCount, denominator, event.duration.magnitude, factor); | |
const rests = Array(Math.floor(restCount)).fill(null).map(() => | |
new Rest({name: "s", duration: new Duration({number: denominator, dots: 0})})); | |
return [event, ...rests]; | |
}; | |
this.body = cc(this.body.map(unfoldMultipliers)); | |
return this; | |
} | |
/*// pure | |
flatten ({spreadRepeats = false} = {}): Relative { | |
this.updateChordChains(); | |
const chord = this.findFirst(Chord) as Chord; | |
const anchor = this.anchorPitch || (chord && chord.anchorPitch); | |
const block = this.clone(); | |
if (spreadRepeats) | |
block.spreadRepeatBlocks(); | |
block.spreadRelativeBlocks(); | |
block.unfoldDurationMultipliers(); | |
return Relative.makeBlock(block, {anchor: anchor && anchor.clone()}); | |
}*/ | |
// with side effect | |
expandVariables (dict: BaseTerm): this { | |
this.body = this.body.map(term => { | |
if (term instanceof Variable) { | |
const value = term.queryValue(dict); | |
const clonedValue = value instanceof BaseTerm ? value.clone() : value; | |
if (clonedValue instanceof BaseTerm) { | |
clonedValue.forEachTerm(MusicBlock, block => block.expandVariables(dict)); | |
if (clonedValue instanceof MusicBlock) | |
clonedValue.expandVariables(dict); | |
} | |
return clonedValue; | |
} | |
return term; | |
}); | |
return this; | |
} | |
// with side effects | |
redivide ({recursive = true, measureHeads = null}: {recursive?: boolean, measureHeads?: number[]} = {}) { | |
if (recursive) { | |
this.forEachTerm(MusicBlock, block => { | |
if (!block._parent || block._parent.cmd !== "alternative") | |
block.redivide({recursive, measureHeads}); | |
}); | |
} | |
// split rests | |
if (measureHeads) { | |
this.body = [].concat(...this.body.map(term => { | |
if (!(term instanceof Rest) || term.name !== "s" || !Number.isInteger(term._measure)) | |
return [term]; | |
const nextHead = measureHeads[term._measure]; | |
const endTick = term._tick + term.durationMagnitude; | |
if (nextHead > 0 && endTick > nextHead) { | |
const post_events = term.post_events; | |
let startTick = term._tick; | |
const rests = []; | |
let nextMeasure; | |
for (nextMeasure = term._measure; nextMeasure < measureHeads.length && endTick > measureHeads[nextMeasure]; ++nextMeasure) { | |
const duration = Duration.fromMagnitude(measureHeads[nextMeasure] - startTick); | |
if (!duration) { | |
console.warn("invalid middle rest duration, splitting gave up:", measureHeads[nextMeasure] - startTick, term); | |
return [term]; | |
} | |
const rest = new Rest({name: "s", duration, post_events: []}); | |
rest._measure = nextMeasure; | |
rest._lastMeasure = nextMeasure; | |
rests.push(rest); | |
console.assert(!!rest.duration, "middle splitted rest duration invalid:", measureHeads[nextMeasure] - startTick); | |
startTick = measureHeads[nextMeasure]; | |
} | |
const duration = Duration.fromMagnitude(endTick - startTick); | |
if (!duration) { | |
console.warn("invalid tail rest duration, splitting gave up:", endTick - startTick, term); | |
return [term]; | |
} | |
const rest = new Rest({name: "s", duration, post_events: post_events && [...post_events]}); | |
rest._measure = nextMeasure; | |
rest._lastMeasure = nextMeasure; | |
rests.push(rest); | |
console.assert(rests.reduce((sum, rest) => sum + rest.durationMagnitude, 0) === term.durationMagnitude, "duration splitting error:", rests, term); | |
//if (rests.reduce((sum, rest) => sum + rest.durationMagnitude, 0) !== term.durationMagnitude) | |
// debugger; | |
return rests; | |
} | |
return [term]; | |
})); | |
} | |
const isPostTerm = term => !term | |
|| term instanceof PostEvent | |
|| (term as Primitive).exp === "~" | |
|| ["bar", "arpeggio", "glissando", "sustainOff", "sustainOn"].includes((term as Command).cmd) | |
; | |
const list = this.body.filter(term => !(term instanceof Divide)); | |
let measure = null; | |
for (const term of list) { | |
if (Number.isInteger(measure) && isPostTerm(term)) | |
term._measure = measure; | |
else | |
measure = term._measure; | |
} | |
const body: BaseTerm[] = []; | |
const measures = new Set(); | |
list.reverse().forEach(term => { | |
if (term instanceof BaseTerm) { | |
const newMeasures = term.measures.filter(m => !measures.has(m)); | |
if (newMeasures.length) { | |
const comment = " " + newMeasures[0] + (newMeasures.length > 1 ? "-" + Math.max(...newMeasures) : ""); | |
if (body.length) | |
body.push(new Divide({_tailComment: Comment.createSingle(comment)})); | |
newMeasures.forEach(m => measures.add(m)); | |
} | |
} | |
body.push(term); | |
}); | |
this.body = body.reverse(); | |
} | |
clarifyHead () { | |
const terms = this.body; | |
const head = terms.find(term => term.isMusic); | |
if (head instanceof MusicEvent) { | |
// clarify the first music event content | |
const firstEventIndex = terms.indexOf(head); | |
if (firstEventIndex >= 0) { | |
const firstEvent = terms[firstEventIndex] as MusicEvent; | |
//console.log("firstEvent:", firstEvent); | |
if (firstEvent._previous) { | |
const clarified = firstEvent.clarified; | |
terms.splice(firstEventIndex, 1, clarified); | |
//console.log("terms:", firstEventIndex, terms, clarified); | |
} | |
} | |
} | |
else if (head) { | |
const block = head.findFirst(MusicBlock) as MusicBlock; | |
if (block) | |
block.clarifyHead(); | |
else | |
console.warn("[MusicBlock.clarifyHead] unexpected music head:", head); | |
} | |
} | |
absoluteToRelative (): Relative { | |
const anchor = this.findFirst(Chord) as Chord; | |
if (!anchor) | |
return null; | |
const anchorPitch = anchor.absolutePitch; | |
let pitch = anchorPitch; | |
const newBody = this.clone(); | |
newBody.forEachTerm(Chord, chord => { | |
const newPitch = chord.absolutePitch; | |
chord.makeRelativeTo(pitch); | |
pitch = newPitch; | |
}); | |
return Relative.makeBlock(newBody, {anchor: anchorPitch}); | |
} | |
}; | |
export class SimultaneousList extends BaseTerm { | |
list: BaseTerm[]; | |
serialize () { | |
return [ | |
"<<\n", | |
...cc(this.list.map(item => [...BaseTerm.optionalSerialize(item), "\n"])), | |
">>\n", | |
]; | |
} | |
removeStaffGroup () { | |
for (let i = 0; i < this.list.length; ++i) { | |
const item: any = this.list[i]; | |
if (item.head instanceof Command && item.head.args && item.head.args[0] === "StaffGroup") | |
this.list[i] = item.body; | |
} | |
this.list.forEach(item => { | |
if (item instanceof SimultaneousList) | |
item.removeStaffGroup(); | |
}); | |
} | |
get isMusic (): boolean { | |
return true; | |
} | |
get entries () { | |
return this.list; | |
} | |
get durationMagnitude (): number { | |
return Math.max(...this.list.filter(term => term instanceof BaseTerm).map(term => term.durationMagnitude)); | |
} | |
get measureLayout (): measureLayout.MeasureLayout { | |
const track = this.list.find(term => term instanceof BaseTerm && term.measureLayout); | |
return track && track.measureLayout; | |
} | |
}; | |
export class ContextedMusic extends BaseTerm { | |
head: Command; | |
body: BaseTerm; | |
lyrics?: BaseTerm; | |
serialize () { | |
return [ | |
...BaseTerm.optionalSerialize(this.head), | |
...BaseTerm.optionalSerialize(this.body), | |
...BaseTerm.optionalSerialize(this.lyrics), | |
]; | |
} | |
get isMusic (): boolean { | |
return true; | |
} | |
get entries () { | |
return [this.head, this.body]; | |
} | |
get type (): string { | |
return this.head.args[0]; | |
} | |
get durationMagnitude (): number { | |
return this.body.durationMagnitude; | |
} | |
get withClause (): Command { | |
if (this.head.args[2] && this.head.args[2] instanceof Command && this.head.args[2].cmd === "with") | |
return this.head.args[2]; | |
} | |
get contextDict (): {[key: string]: string} { | |
const withEntries = this.withClause ? Object.entries((this.withClause.args[0] as Block).assignmentDict) : []; | |
const entries = withEntries.map(([key, value]) => [`${this.type}.${key}`, value]); | |
const pair = this.head.getAssignmentPair(); | |
if (pair) | |
entries.push([pair.key.toString(), pair.value.toString()]); | |
return entries.reduce((dict, [key, value]) => ((dict[key] = value), dict), {}); | |
} | |
get list (): BaseTerm[] { | |
if (this.body instanceof SimultaneousList) | |
return this.body.list; | |
return null; | |
} | |
set list (value: BaseTerm[]) { | |
if (this.body instanceof SimultaneousList) | |
this.body.list = value; | |
} | |
get measureLayout (): measureLayout.MeasureLayout { | |
return this.body.measureLayout; | |
} | |
}; | |
export class Divide extends BaseTerm { | |
serialize () { | |
return ["|", "\n"]; | |
} | |
} | |
export class Scheme extends BaseTerm { | |
exp: (boolean|BaseTerm); | |
serialize () { | |
if (BaseTerm.isTerm(this.exp)) | |
return ["#", "\b", ...(this.exp as BaseTerm).serialize()]; | |
else if (typeof this.exp === "boolean") | |
return ["#", "\b", this.exp ? "#t" : "#f"]; | |
// TODO: enhance grammar to parse empty scheme list | |
//else if (this.exp === null) | |
// return ["#", "\b", "'()"]; | |
else | |
return ["#", "\b", this.exp]; | |
} | |
query (key: string): any { | |
if (this.exp instanceof SchemeFunction) | |
return this.exp.query(key); | |
} | |
get entries () { | |
if (this.exp instanceof BaseTerm) | |
return [this.exp]; | |
return []; | |
} | |
}; | |
export class SchemeFunction extends BaseTerm { | |
func: (string | BaseTerm); | |
args: (boolean | string | BaseTerm)[]; | |
serialize () { | |
return [ | |
"(", "\b", | |
...BaseTerm.optionalSerialize(this.func), | |
...cc(this.args.map(BaseTerm.serializeScheme)), | |
"\b", ")", | |
]; | |
} | |
query (key: string): any { | |
if (key === this.func) { | |
const term = this; | |
return { | |
get value () { | |
return term.args.length === 1 ? term.args[0] : term.args; | |
}, | |
set value (value) { | |
if (term.args.length === 1) | |
term.args[0] = value as string|BaseTerm; | |
else | |
term.args = value as (string|BaseTerm)[]; | |
}, | |
}; | |
} | |
} | |
get asList (): (boolean | string | BaseTerm)[] { | |
return [this.func, ...this.args]; | |
} | |
get entries () { | |
return this.asList.filter(term => term instanceof BaseTerm) as BaseTerm[]; | |
} | |
}; | |
export class SchemePair extends BaseTerm { | |
left: any; | |
right: any; | |
serialize () { | |
return [ | |
"(", "\b", | |
...BaseTerm.optionalSerialize(this.left), ".", ...BaseTerm.optionalSerialize(this.right), | |
"\b", ")", | |
]; | |
} | |
}; | |
export class SchemePointer extends BaseTerm { | |
value: any; | |
serialize () { | |
const content = this.value === null ? ["()"] : BaseTerm.optionalSerialize(this.value); | |
return [ | |
"'", "\b", ...content, | |
]; | |
} | |
get entries () { | |
if (this.value instanceof BaseTerm) | |
return [this.value]; | |
return []; | |
} | |
}; | |
export class SchemeEmbed extends BaseTerm { | |
value: Root; | |
serialize () { | |
return [ | |
"#{", | |
...BaseTerm.optionalSerialize(this.value), | |
"#}", | |
]; | |
} | |
}; | |
export class Assignment extends BaseTerm { | |
key: (string|any[]); | |
value: any; | |
constructor (data) { | |
super(data); | |
if (this.value instanceof BaseTerm) | |
this.value._parent = this; | |
} | |
serialize () { | |
const keys = (Array.isArray(this.key) ? this.key : [this.key]).map(BaseTerm.optionalSerialize); | |
const values = (Array.isArray(this.value) ? this.value : [this.value]).map(BaseTerm.optionalSerialize); | |
return [ | |
...cc(keys), | |
"=", | |
...cc(values), | |
]; | |
} | |
get entries () { | |
if (this.value instanceof BaseTerm) | |
return [this.value]; | |
return null; | |
} | |
query (key) { | |
if (this.key === key) { | |
const term = this; | |
return { | |
get value () { | |
return term.value; | |
}, | |
set value (value) { | |
term.value = value; | |
}, | |
}; | |
} | |
} | |
}; | |
export class MusicEvent extends BaseTerm { | |
duration?: Duration; | |
post_events?: (string | PostEvent)[]; | |
declare _previous?: MusicEvent; | |
//_anchorPitch?: ChordElement; | |
_lastMeasure?: number; | |
constructor (data: object) { | |
super(data); | |
if (this.post_events) | |
this.post_events = this.post_events.map(parseRaw); | |
} | |
getPreviousT (T) { | |
if (this._previous instanceof T) | |
return this._previous; | |
if (this._previous) | |
return this._previous.getPreviousT(T); | |
} | |
get durationValue (): Duration { | |
return this.duration || (this._previous ? this._previous.durationValue : Duration.default); | |
} | |
get durationMagnitude (): number { | |
return this.durationValue.magnitude; | |
} | |
get division (): number { | |
return this.durationValue.division; | |
} | |
get withMultiplier () { | |
return this.duration && this.duration.withMultiplier; | |
} | |
get isMusic (): boolean { | |
return true; | |
} | |
get isTying (): boolean { | |
return this.post_events && this.post_events.some(event => event instanceof PostEvent && event.isTying); | |
} | |
get isStaccato (): boolean { | |
return this.post_events && this.post_events.some(event => event instanceof PostEvent && event.isStaccato); | |
} | |
// to be implement in derived classes | |
get isRest (): boolean { | |
return null; | |
} | |
get beamOn (): boolean { | |
return this.post_events && this.post_events.includes("["); | |
} | |
get beamOff (): boolean { | |
return this.post_events && this.post_events.includes("]"); | |
} | |
get measures (): number[] { | |
if (!Number.isFinite(this._measure) || !Number.isFinite(this._lastMeasure)) | |
return []; | |
return Array(this._lastMeasure + 1 - this._measure).fill(null).map((_, i) => this._measure + i); | |
} | |
get measureLayout (): measureLayout.MeasureLayout { | |
if (this.measures.length > 1) | |
return measureLayout.BlockMLayout.fromSeq(this.measures.map(measure => measureLayout.SingleMLayout.from(measure))); | |
if (this.measures.length === 1) | |
return measureLayout.SingleMLayout.from(this._measure); | |
return null; | |
} | |
get implicitType (): ImplicitType { | |
if (this.post_events) { | |
for (const event of this.post_events) { | |
if (event instanceof PostEvent && event.arg instanceof Command) { | |
switch (event.arg.cmd) { | |
case "startTrillSpan": | |
case "trill": | |
return ImplicitType.Trill; | |
case "turn": | |
return ImplicitType.Turn; | |
case "mordent": | |
return ImplicitType.Mordent; | |
case "prall": | |
return ImplicitType.Prall; | |
// Arpeggio is not implemented in 'articulate.ly' yet | |
case "arpeggio": | |
return ImplicitType.Arpeggio; | |
} | |
} | |
} | |
} | |
return ImplicitType.None; | |
} | |
get clarified (): MusicEvent { | |
const clarified = this instanceof Chord ? this.clarifiedChord : this.clone(); | |
clarified.duration = this.durationValue && this.durationValue.clone(); | |
return clarified; | |
} | |
}; | |
export class Chord extends MusicEvent { | |
pitches: (ChordElement | Command)[]; | |
options: { | |
exclamations?: string[], | |
questions?: string[], | |
rest?: string, | |
withAngle?: boolean, | |
}; | |
constructor (data) { | |
super(data); | |
this.connectPitches(); | |
} | |
connectPitches () { | |
if (this.basePitch) | |
this.basePitch._parent = this; | |
for (let i = 1; i < this.pitchElements.length; ++i) | |
this.pitchElements[i]._previous = this.pitchElements[i - 1]; | |
} | |
get single (): boolean { | |
return this.pitches.length === 1; | |
} | |
get entries () { | |
const list: any[] = [...this.pitches]; | |
if (Array.isArray(this.post_events)) | |
list.push(...this.post_events); | |
return list; | |
} | |
serialize () { | |
const innerPitches = this.pitches.map(BaseTerm.optionalSerialize); | |
const pitches = (this.single && !this.options.withAngle) ? cc(innerPitches) : [ | |
"<", "\b", ...cc(innerPitches), "\b", ">", | |
]; | |
const {exclamations, questions, rest} = this.options; | |
const postfix = cc([...(exclamations || []), ...(questions || []), ...BaseTerm.optionalSerialize(this.duration), rest] | |
.filter(item => item) | |
.map(item => ["\b", item]), | |
).concat(...(this.post_events || []).map(BaseTerm.optionalSerialize)); | |
return [ | |
new OpenLocator(this), | |
...pitches, | |
...postfix, | |
new CloseLocator(this), | |
]; | |
} | |
get pitchElements (): ChordElement[] { | |
return this.pitches.filter(pitch => pitch instanceof ChordElement) as ChordElement[]; | |
} | |
get pitchNames (): string[] { | |
return this.pitchElements.map((elem: ChordElement) => elem.pitch.replace(/'|,/g, "")); | |
} | |
get basePitch (): ChordElement { | |
return this.pitchElements[0]; | |
} | |
get absolutePitch (): ChordElement { | |
console.assert(!!this.basePitch, "absolutePitch on non pitch:", this.join()); | |
return this.basePitch.absolutePitch; | |
} | |
get anchorPitch (): ChordElement { | |
if (this._anchorPitch) | |
return this._anchorPitch; | |
const previous = this.getPreviousT(Chord); | |
if (previous) | |
return previous.absolutePitch; | |
return this.basePitch; | |
} | |
get isRest (): boolean { | |
return !!this.options.rest; | |
} | |
get completeTied (): boolean { | |
return this.pitchElements.filter(pitch => !pitch._tied).length === 0; | |
} | |
get pitchesValue (): (ChordElement | Command)[] { | |
if (this._previous instanceof Chord && this.basePitch.pitch === "q") { | |
const pitches = this._previous.pitchesValue.map(pitch => { | |
const newPitch = pitch.clone(); | |
if (newPitch instanceof ChordElement) { | |
newPitch._location = this.basePitch._location; | |
newPitch._tied = this.basePitch._tied; | |
newPitch._parent = (pitch as ChordElement)._parent && this; | |
newPitch._previous = (pitch as ChordElement)._previous; | |
} | |
return newPitch; | |
}); | |
const base = pitches.find(pitch => pitch instanceof ChordElement) as ChordElement; | |
if (base) | |
base.pitch = base.pitch.replace(/[,']/g, ""); | |
return pitches; | |
} | |
return this.pitches; | |
} | |
get clarifiedChord (): MusicEvent { | |
const clarified = this.clone(); | |
clarified.pitches = this.pitchesValue.filter(pitch => !(pitch as ChordElement)._tied).map(pitch => pitch.clone()); | |
clarified.connectPitches(); | |
// replace by rest if all pitches tied | |
if (!clarified.pitches.length) | |
return new Rest({name: "r", duration: this.duration}); | |
return clarified; | |
} | |
shiftAnchor (newAnchor: ChordElement) { | |
//console.warn("shiftAnchor:", this.join(), newAnchor.join(), this.absolutePitch.pitchValue, newAnchor.pitchValue, this.anchorPitch.pitchValue); | |
const _location = this.basePitch._location; | |
const shift = idioms.phonetDifferToShift(this.basePitch.phonetStep - newAnchor.phonetStep); | |
const relativeOctave = this.basePitch.absoluteOctave(this.anchorPitch) - newAnchor.octave - shift; | |
//console.log("_location:", _location); | |
this.pitches[0] = ChordElement.from({ | |
phonet: this.basePitch.phonet, | |
alters: this.basePitch.alters, | |
octave: relativeOctave, | |
}); | |
this.pitches[0]._location = _location; | |
this.pitches[0]._parent = this; | |
this.connectPitches(); | |
//console.log("shiftAnchor.1:", this.join(), this.absolutePitch.pitchValue, {relativeOctave, shift, "newAnchor.octave": newAnchor.octave}); | |
} | |
makeRelativeTo (from: ChordElement) { | |
const _location = this.basePitch._location; | |
const octave = this.basePitch.relativeOctave(from); | |
this.pitches[0] = ChordElement.from({ | |
phonet: this.basePitch.phonet, | |
alters: this.basePitch.alters, | |
octave, | |
}); | |
this.pitches[0]._location = _location; | |
this.pitches[0]._parent = this; | |
} | |
}; | |
export class Rest extends MusicEvent { | |
name: string; | |
serialize () { | |
return [ | |
new OpenLocator(this), | |
...compact([ | |
this.name, | |
...BaseTerm.optionalSerialize(this.duration), | |
]), | |
...cc((this.post_events || []).map(BaseTerm.optionalSerialize)), | |
new CloseLocator(this), | |
]; | |
} | |
get isSpacer () { | |
return this.name === "s"; | |
} | |
get isRest (): boolean { | |
return true; | |
} | |
}; | |
export class ChordElement extends BaseTerm { | |
pitch: string; | |
options: { | |
exclamations?: string[], | |
questions?: string[], | |
post_events?: PostEvent[], | |
}; | |
declare _parent?: Chord; | |
declare _previous?: ChordElement; | |
_tied?: MusicEvent; | |
_transposition?: number; | |
// cache for property of absolutePitch | |
_absolutePitch?: ChordElement; | |
static from ({phonet, alters = "", octave, options = {}}): ChordElement { | |
const octaveString = octave ? Array(Math.abs(octave)).fill(octave > 0 ? "'" : ",").join("") : ""; | |
const pitch = phonet + (alters || "") + octaveString; | |
return new ChordElement({pitch, options: {...options, proto: "_PLAIN"}}); | |
} | |
static get default (): ChordElement { | |
return ChordElement.from({phonet: "c", octave: 0}); | |
} | |
constructor (data: object) { | |
super(data); | |
if (this.options.post_events) | |
this.options.post_events = this.options.post_events.map(parseRaw); | |
if (!this.pitch) | |
console.log("null pitch:", this); | |
} | |
serialize () { | |
const {exclamations, questions, post_events} = this.options; | |
const postfix = [].concat(...[...(exclamations || []), ...(questions || [])] | |
.filter(item => item) | |
.map(item => ["\b", item]), | |
).concat(...(post_events || []).map(item => ["\b", ...BaseTerm.optionalSerialize(item)])); | |
return [ | |
new OpenLocator(this), | |
this.pitch, | |
...postfix, | |
new CloseLocator(this), | |
]; | |
} | |
get octave (): number { | |
const positive = (this.pitch.match(/'/g) || []).length; | |
const negative = (this.pitch.match(/,/g) || []).length; | |
return positive - negative; | |
} | |
get phonet (): string { | |
const ph = this.pitch.substr(0, 1); | |
return idioms.PHONETS_ALIAS[ph] || ph; | |
} | |
get phonetStep (): number { | |
return idioms.PHONETS.indexOf(this.phonet); | |
} | |
get alters (): string { | |
const captures = this.pitch.substr(1).match(/^\w+/); | |
return captures && captures[0]; | |
} | |
get alteredPhonet (): string { | |
const captures = this.pitch.match(/^\w+/); | |
return captures && captures[0]; | |
} | |
get anchorPitch (): ChordElement { | |
if (this._previous) | |
return this._previous.absolutePitch; | |
if (this._parent) | |
return this._parent.anchorPitch; | |
return ChordElement.from({phonet: this.phonet, octave: 0}); | |
} | |
getAbsolutePitch (): ChordElement { | |
if (this.phonet === "q") | |
return this.anchorPitch; | |
if (this.anchorPitch === this) | |
return this; | |
const octave = this.absoluteOctave(this.anchorPitch); | |
return ChordElement.from({phonet: this.phonet, alters: this.alters, octave}); | |
} | |
get absolutePitch (): ChordElement { | |
if (!this._absolutePitch) | |
this._absolutePitch = this.getAbsolutePitch(); | |
return this._absolutePitch; | |
} | |
absoluteOctave (anchor: ChordElement): number { | |
if (this.phonet === "q") | |
return anchor.octave; | |
const phonetDiffer = this.phonetStep - anchor.phonetStep; | |
const shift = idioms.phonetDifferToShift(phonetDiffer); | |
return anchor.octave + shift + this.octave; | |
} | |
relativeOctave (from: ChordElement): number { | |
if (this.phonet === "q") { | |
if (this.anchorPitch) | |
return this.anchorPitch.relativeOctave(from); | |
else | |
return 0; | |
} | |
const phonetDiffer = this.phonetStep - from.phonetStep; | |
const shift = idioms.phonetDifferToShift(phonetDiffer); | |
return this.octave - shift - from.octave; | |
} | |
get alterValue (): number { | |
return idioms.ALTER_VALUES[this.alters] || 0; | |
} | |
get pitchValue (): number { | |
const phonetValue = idioms.PHONET_VALUES[this.phonet]; | |
console.assert(Number.isInteger(phonetValue), "invalid phonet:", this.phonet); | |
console.assert(!this.alters || idioms.ALTER_VALUES[this.alters], "invalid alters:", this.alters); | |
return 48 + this.octave * 12 + phonetValue + this.alterValue; | |
} | |
get absolutePitchValue (): number { | |
return this.absolutePitch.pitchValue; | |
} | |
// middle C is zero | |
get notePosition (): number { | |
const phonet = idioms.PHONETS.indexOf(this.phonet); | |
return (this.octave - 1) * 7 + phonet; | |
} | |
get absoluteNotePosition (): number { | |
return this.absolutePitch.notePosition; | |
} | |
get tiedParent (): ChordElement { | |
if (!this._tied || !(this._tied instanceof Chord)) | |
return null; | |
const pitch = this._tied.pitchElements.find(p => p.absolutePitchValue === this.absolutePitchValue); | |
if (!pitch) | |
return null; | |
if (pitch._tied) | |
return pitch.tiedParent; | |
return pitch; | |
} | |
}; | |
export class Duration extends BaseTerm { | |
number: string; | |
dots: number; | |
multipliers?: string[]; | |
static _default: Duration; | |
static get default (): Duration { | |
if (!Duration._default) | |
Duration._default = new Duration({number: 4, dots: 0}); | |
return Duration._default; | |
} | |
static fromMagnitude (magnitude: number): Duration { | |
const MULTI = 1024; | |
const MULTI_DURATION_MAGNITUDE = WHOLE_DURATION_MAGNITUDE * MULTI; | |
const multiMag = magnitude * MULTI; | |
if (!Number.isInteger(multiMag)) { | |
console.warn("magnitude must be integer:", magnitude); | |
return null; | |
} | |
const di = gcd(multiMag, MULTI_DURATION_MAGNITUDE); | |
const denominator = MULTI_DURATION_MAGNITUDE / di; | |
const numerator = multiMag / di; | |
if (!Number.isInteger(Math.log2(denominator))) | |
return new Duration({number: 1, dots: 0, multipliers: [`${numerator}/${denominator}`]}); | |
switch (numerator) { | |
case 1: | |
return new Duration({number: denominator, dots: 0}); | |
case 3: | |
return new Duration({number: denominator / 2, dots: 1}); | |
case 7: | |
return new Duration({number: denominator / 4, dots: 2}); | |
default: | |
return new Duration({number: denominator, dots: 0, multipliers: [numerator.toString()]}); | |
} | |
} | |
serialize () { | |
const dots = Array(this.dots).fill(".").join(""); | |
const multipliers = this.multipliers && this.multipliers.map(multiplier => `*${multiplier}`).join(""); | |
return compact([ | |
this.number, dots, multipliers, | |
]); | |
} | |
get withMultiplier () { | |
return this.multipliers && this.multipliers.length > 0; | |
} | |
get denominator (): number { | |
switch (this.number) { | |
case "\\breve": | |
return 0.5; | |
case "\\longa": | |
return 0.25; | |
} | |
return Number(this.number); | |
} | |
get division (): number { | |
return Math.log2(this.denominator); | |
} | |
// how many smallest rhythm unit in a whole note | |
get subdivider (): number { | |
return this.denominator * (2 ** this.dots); | |
} | |
get magnitude (): number { | |
let value = WHOLE_DURATION_MAGNITUDE / this.denominator; | |
if (this.dots) | |
value *= 2 - 0.5 ** this.dots; | |
if (this.multipliers) | |
this.multipliers.forEach(multiplier => value *= eval(multiplier)); | |
return value; | |
} | |
}; | |
interface BriefChordBody { | |
pitch: string; | |
duration: Duration; | |
separator: string; | |
items: string[]; | |
}; | |
export class BriefChord extends BaseTerm { | |
body: BriefChordBody; | |
post_events: any[]; | |
constructor (data: object) { | |
super(data); | |
if (this.body) | |
this.body.duration = parseRaw(this.body.duration); | |
} | |
serialize () { | |
const {pitch, duration, separator, items} = this.body; | |
return [ | |
...compact(cc([pitch, duration, separator, ...(items || [])].map(BaseTerm.optionalSerialize))), | |
...cc((this.post_events || []).map(BaseTerm.optionalSerialize)), | |
]; | |
} | |
get isMusic (): boolean { | |
return true; | |
} | |
get durationMagnitude (): number { | |
if (this.body.duration) | |
return this.body.duration.magnitude; | |
return 0; | |
} | |
}; | |
export class NumberUnit extends BaseTerm { | |
number: number; | |
unit: string; | |
serialize () { | |
return [this.number, "\b", this.unit]; | |
} | |
set ({number, unit}) { | |
this.number = Number(number.toFixed(2)); | |
if (unit !== undefined) | |
this.unit = unit; | |
} | |
} | |
export class Tempo extends BaseTerm { | |
beatsPerMinute?: number; | |
unit?: Duration; | |
text?: string; | |
static fromNoteBpm (note: number, bpm: number): Tempo { | |
return new Tempo ({ | |
unit: new Duration({number: note.toString(), dots: 0}), | |
beatsPerMinute: bpm, | |
}); | |
} | |
serialize () { | |
const assignment = Number.isFinite(this.beatsPerMinute) ? [...BaseTerm.optionalSerialize(this.unit), "=", this.beatsPerMinute] : []; | |
return [ | |
"\\tempo", | |
...BaseTerm.optionalSerialize(this.text), | |
...assignment, | |
]; | |
} | |
} | |
const DIRECTION_CHAR = { | |
up: "^", | |
down: "_", | |
middle: "-", | |
}; | |
export class PostEvent extends BaseTerm { | |
direction: string; | |
arg: string | BaseTerm; | |
serialize () { | |
const dir = DIRECTION_CHAR[this.direction]; | |
const prefix = dir ? [dir, "\b"] : []; | |
return prefix.concat(BaseTerm.optionalSerialize(this.arg)); | |
} | |
get entries () { | |
if (this.arg instanceof BaseTerm) | |
return [this.arg]; | |
return null; | |
} | |
get isTying (): boolean { | |
return this.arg === "~"; | |
} | |
get isStaccato (): boolean { | |
if (this.arg instanceof Command) | |
return ["staccato", "staccatissimo", "portato"].includes(this.arg.cmd); | |
if ([".", "!"].includes(this.arg as string)) | |
return true; | |
return false; | |
} | |
}; | |
export class Fingering extends BaseTerm { | |
value: number; | |
serialize () { | |
return [this.value]; | |
} | |
}; | |
export class Markup extends BaseTerm { | |
head: any[]; | |
body: (string|BaseTerm); | |
serialize () { | |
return [ | |
...cc(this.head.map(BaseTerm.optionalSerialize)), | |
...BaseTerm.optionalSerialize(this.body), | |
]; | |
} | |
}; | |
export class Lyric extends MusicEvent { | |
content: string | LiteralString; | |
serialize () { | |
return [ | |
...BaseTerm.optionalSerialize(this.content), | |
...BaseTerm.optionalSerialize(this.duration), | |
...cc((this.post_events || []).map(BaseTerm.optionalSerialize)), | |
]; | |
} | |
}; | |
export class Comment extends BaseTerm { | |
text: string; | |
scoped: boolean; | |
serialize () { | |
return [ | |
this.text, | |
"\n", | |
]; | |
} | |
static createSingle (text): Comment { | |
return new Comment({text: "%" + text}); | |
} | |
static createScoped (text): Comment { | |
console.assert(!/%\}/.test(text), "invalid scoped comment text:", text); | |
return new Comment({text: `%{${text}%}`, scoped: true}); | |
} | |
}; | |
export class Unexpect extends BaseTerm { | |
constructor (data) { | |
super(data); | |
console.warn("unexpected term", data); | |
} | |
}; | |
export const termDictionary = { | |
Root, | |
LiteralString, | |
Command, | |
Variable, | |
MarkupCommand, | |
Repeat, | |
Relative, | |
ParallelMusic, | |
TimeSignature, | |
Partial, | |
Times, | |
Tuplet, | |
Grace, | |
AfterGrace, | |
Clef, | |
KeySignature, | |
OctaveShift, | |
Include, | |
Version, | |
Language, | |
LyricMode, | |
ChordMode, | |
Transposition, | |
StemDirection, | |
Change, | |
Block, | |
InlineBlock, | |
Scheme, | |
SchemeFunction, | |
SchemePair, | |
SchemePointer, | |
SchemeEmbed, | |
Assignment, | |
Duration, | |
ChordElement, | |
Chord, | |
Rest, | |
BriefChord, | |
NumberUnit, | |
MusicBlock, | |
SimultaneousList, | |
ContextedMusic, | |
Divide, | |
Tempo, | |
PostEvent, | |
Fingering, | |
Markup, | |
Lyric, | |
Primitive, | |
Comment, | |
}; | |
const termProtoMap: Map<object, string> = Object.entries(termDictionary) | |
.reduce((map, [name, cls]: [string, {prototype: object}]) => (map.set(cls.prototype, name), map), new Map()); | |
const parseRawEnforce = data => { | |
switch (typeof data) { | |
case "string": | |
case "number": | |
return new Primitive({exp: data}); | |
default: | |
return parseRaw(data); | |
} | |
}; | |
export const parseRaw = data => { | |
if (data instanceof BaseTerm) | |
return data; | |
if (!data) | |
return data; | |
switch (typeof data) { | |
case "object": | |
if (Array.isArray(data)) | |
return data.map(item => parseRaw(item)); | |
const {proto, ...fields} = data; | |
if (proto) { | |
if (proto === "_PLAIN") | |
return fields; | |
const termClass = termDictionary[proto]; | |
if (!termClass) | |
throw new Error(`Unexpected term class: ${data.proto}`); | |
return new termClass(fields); | |
} | |
return new Unexpect(data); | |
} | |
return data; | |
}; | |