import {POS_PRECISION, constants} from "./utils"; import type StaffToken from "./staffToken"; import type {SheetStaff} from "./sheetDocument"; import type TextSource from "../textSource"; interface Rect { left: number; right: number; top: number; bottom: number; }; type IConnection = {y: number, height: number}; class LineStack { lines: StaffToken[]; translation = {x: 0, y: 0}; systemIndex?: number; staffIndex?: number; _rect?: Rect; constructor (root) { this.lines = [root]; } // the bottom line get tip (): StaffToken { return this.lines[this.lines.length - 1]; } get rect (): {left: number, right: number, top: number, bottom: number} { if (!this._rect) { const ys = this.lines.map(token => token.y + token.height / 2); this._rect = { left: Math.min(...this.lines.map(token => token.x)), right: Math.max(...this.lines.map(token => token.x + token.width)), top: ys[0], bottom: ys[ys.length - 1], }; } return this._rect; } tryAppend (line: StaffToken): boolean { if (line.ry - this.tip.ry === 1 && Math.abs(line.x - this.tip.x) < 2) { this.lines.push(line); this._rect = null; return true; } return false; } tryAttachConnection (connection: IConnection, index: number): boolean { const {top, bottom} = this.rect; //console.log("connection:", connection.y + connection.height, top - 1.2); const y = connection.y + this.translation.y; if (bottom + 1.6 > y && top - 1.6 < y + connection.height) { this.systemIndex = index; return true; } return false; } tryAttachStaff (y: number, index: number): boolean { const {top, bottom} = this.rect; y += this.translation.y; if (bottom + 3.2 > y && top - 3.2 < y) { this.staffIndex = index; return true; } return false; } contains (token: StaffToken) { const {left, right, top, bottom} = this.rect; const x = token.x + this.translation.x; const y = token.y + this.translation.y; return x > left - 1.6 && x < right - 1 && y > top - 0.6 && y < bottom + 0.6; } translate ({x = 0, y = 0} = {}) { this.translation.x += x; this.translation.y += y; } }; const parseAdditionalLineStacks = (tokens: StaffToken[]): LineStack[] => { const lines = tokens.filter(token => token.is("ADDITIONAL_LINE")).sort((t1, t2) => t1.y - t2.y); const stacks: LineStack[] = []; lines.forEach(line => { for (const stack of stacks) { if (stack.tryAppend(line)) return; } stacks.push(new LineStack(line)); }); return stacks; }; const tokensSystemsSplit = (tokens: StaffToken[], logger) => { if (!tokens.length) { logger.append("tokensSystemsSplit.emptyTokens"); return []; } const pageHeight = Math.max(...tokens.map(token => token.y)); const pageTile = Array(Math.round(pageHeight)).fill(-1); let crossedCount = 0; const connections: IConnection[] = tokens.filter(token => token.is("STAVES_CONNECTION")) as IConnection[]; if (!connections.length) { // single line system, split by staff lines const lines = tokens.filter(token => token.is("STAFF_LINE")); lines.forEach(line => pageTile[Math.round(line.y)] = 0); // non-staff page if (!lines.length) { logger.append("tokensSystemsSplit.noConnetionsOrLines", {tokens}); return []; } let index = -1; let outStaff = true; for (let y = 0; y < pageTile.length; ++y) { const out = pageTile[y] < 0; if (outStaff && !out) { ++index; // append connection placeholder connections.push({y, height: 4}); } if (!out) pageTile[y] = index; outStaff = out; } } else { connections.forEach((connection, i) => { const start = Math.round(connection.y) - 1; const end = Math.round(connection.y + connection.height) + 1; let index = i - crossedCount; for (let y = start; y <= end; ++y) { if (pageTile[y] >= 0) { index = pageTile[y]; ++crossedCount; break; } } for (let y = start; y <= end; ++y) pageTile[y] = index; }); } //logger.append("tokensSystemsSplit.pageTile.0", [...pageTile]); //logger.append("tokensSystemsSplit.connections", connections); const lineStacks = parseAdditionalLineStacks(tokens); lineStacks.forEach(stack => { for (let i = 0; i < connections.length; ++i) { if (stack.tryAttachConnection(connections[i], i)) break; } }); //logger.append("tokensSystemsSplit.lineStacks", lineStacks); const validLineStacks = lineStacks.filter(stack => stack.systemIndex >= 0); if (validLineStacks.length < lineStacks.length) logger.append("tokensSystemsSplit.invalidLineStacks", lineStacks.filter(stack => !(stack.systemIndex >= 0))); // fill page tile by line stacks validLineStacks.forEach(stack => { const {top, bottom} = stack.rect; for (let y = Math.floor(top) - 1; y < Math.ceil(bottom); ++y) { if (pageTile[y] < 0) pageTile[y] = stack.systemIndex; } }); // fill interval between top tokens and system top const topTokens = tokens.filter(token => token.topAtSystem); topTokens.forEach(token => { const nextIndex = pageTile.find((index, y) => y > token.y && index >= 0); for (let y = Math.floor(token.y) - 1; y < pageHeight; ++y) { if (pageTile[y] >= 0) break; pageTile[y] = nextIndex; } }); //logger.append("tokensSystemsSplit.octaveAs", octaveAs); // enlarge page tile by intersection stems const interStems = tokens.filter(token => token.is("NOTE_STEM") && pageTile[Math.round(token.y)] === -1 && pageTile[Math.round(token.y)] !== pageTile[Math.round(token.y + token.height)]); interStems.forEach(stem => { const bottomIndex = pageTile[Math.round(stem.y + stem.height)]; if (bottomIndex > 0) { for (let y = Math.round(stem.y + stem.height) - 1; y >= Math.round(stem.y); --y) pageTile[y] = bottomIndex; } }); //logger.append("tokensSystemsSplit.pageTile.2", pageTile); const systemBoundaries = pageTile.reduce((boundaries, index, y) => { if (index >= boundaries.length) boundaries.push(y - 1); return boundaries; }, []); systemBoundaries[0] = -Infinity; //logger.append("tokensSystemsSplit.systemBoundaries", systemBoundaries); const systems = Array(systemBoundaries.length).fill(null).map(() => ({tokens: [], stacks: []})); validLineStacks.forEach(stack => systems[stack.systemIndex] && systems[stack.systemIndex].stacks.push(stack)); //logger.append("tokensSystemsSplit.validLineStacks", {systems, validLineStacks}); tokens.forEach(token => { for (const stack of validLineStacks) { if (stack.contains(token)) { if (systems[stack.systemIndex]) { systems[stack.systemIndex].tokens.push(token); return; } else logger.append("tokensSystemsSplit.invalidStackSystemIndex", {stack, systems}); } } if (token.withUp || token.withDown) { let index = 0; if (token.withUp) index = connections.filter(c => c.y + c.height < token.y).length; else index = Math.max(connections.filter(c => c.y < token.y).length - 1, 0); if (systems[index]) systems[index].tokens.push(token); else console.warn("tokensSystemsSplit: invalid system index:", index, systems.length, token.source); return; } const y = token.logicY; for (let i = 0; i < systemBoundaries.length; ++i) { if (y >= systemBoundaries[i] && (i >= systemBoundaries.length - 1 || y < systemBoundaries[i + 1])) { systems[i].tokens.push(token); return; } } }); systems.forEach(system => system.tokens = system.tokens.sort((t1, t2) => t1.logicX - t2.logicX)); return systems; }; const parseChordsByStems = (tokens: StaffToken[], logger) => { const stems = tokens.filter(token => token.is("NOTE_STEM")); const notes = tokens.filter(token => token.is("NOTEHEAD") || token.is("TEMPO_NOTEHEAD")); stems.forEach(stem => { const rightAttached = notes.filter(note => stem.stemAttached({ x: note.x, y: note.y + constants.NOTE_TYPE_JOINT_Y[note.noteType] * (note.scale || 1), href: note.href, })); const leftAttached = notes.filter(note => stem.stemAttached({ x: note.x + constants.NOTE_TYPE_WIDTHS[note.noteType] * (note.scale || 1), y: note.y - constants.NOTE_TYPE_JOINT_Y[note.noteType] * (note.scale || 1), href: note.href, })); if (rightAttached.length + leftAttached.length <= 0) { logger.append("parseChordsByStems.baldStem:", stem); //console.warn("bald stem:", stem); stem.addSymbol("BALD"); return; } const ys = [...rightAttached.map(n => n.y), ...leftAttached.map(n => n.y)]; const top = Math.abs(stem.y - Math.min(...ys)); const bottom = Math.abs(stem.y + stem.height - Math.max(...ys)); const up = top < bottom; //console.assert(up || leftAttached.length, "unexpected stem, downwards but no left-attached notes."); //console.assert(!up || rightAttached.length, "unexpected stem, upwards but no right-attached notes."); stem.stemUp = !up; const anchorNote = up ? rightAttached[0] : leftAttached[0]; const anchorToken = anchorNote || stem; const assign = note => { note.stemX = anchorToken.x; note.stemUp = !up; note.stems = note.stems || []; note.stems.push(stem.index); }; rightAttached.forEach(assign); leftAttached.forEach(assign); if (!anchorNote) { stem.addSymbol("NOTICE"); logger.append("parseChordsByStems.unexpectedStem", {stem, ys, rightAttached, leftAttached}); } else if (anchorNote.is("HALF")) stem.division = 1; }); }; const isSystemToken = token => token.is("STAVES_CONNECTION") || token.is("BRACE") || token.is("VERTICAL_LINE"); //const roundJoin = (x, y) => `${Math.round(x)},${Math.round(y)}`; const parseTokenSystem = (tokens: StaffToken[], stacks: LineStack[], logger) => { const separatorYs : Set = new Set(); const measureSeparators = tokens.filter(token => token.is("MEASURE_SEPARATOR")); measureSeparators.forEach(token => separatorYs.add(token.ry)); //logger.append("parseTokenSystem.measureSeparators", Array.from(measureSeparators)); // remove separator Y from fake MEASURE_SEPARATOR for (const y of Array.from(separatorYs).sort()) { if (separatorYs.has(y - 4) && separatorYs.has(y + 4)) { separatorYs.delete(y); measureSeparators.filter(token => token.ry === y).forEach(token => { token.removeSymbol("MEASURE_SEPARATOR"); token.addSymbol("VERTICAL_LINE"); }); } } //logger.append("parseTokenSystem.separatorYs", Array.from(separatorYs)); const staffLines = tokens.filter(token => token.is("STAFF_LINE")).reduce((lines, token) => { if (!lines[token.ry] || lines[token.ry].x > token.x) lines[token.ry] = token; return lines; }, {}); //logger.append("parseTokenSystem.staffLines", Object.keys(staffLines)); // construct staff Y from staff lines when no separators if (!separatorYs.size) { const ys = Object.keys(staffLines).map(Number); const topLineYs = ys.filter(y => staffLines[y + 3] && staffLines[y + 4]); topLineYs.forEach(y => separatorYs.add(y)); } const staffYs = Array.from(separatorYs) .filter(y => staffLines[y] || staffLines[y + POS_PRECISION]) .map(y => staffLines[y] ? y : y + POS_PRECISION).map(y => y + 2) .sort((y1, y2) => y1 - y2) .filter(y => staffLines[y - 2] && staffLines[y] && staffLines[y + 2]); //logger.append("parseTokenSystem.staffYs", staffYs); const additionalLines = tokens.filter(token => token.is("ADDITIONAL_LINE")).sort((l1, l2) => l1.y - l2.y); const additionalLinesYs = additionalLines.reduce((ys, token) => { ys.add(token.ry); return ys; }, new Set()); //logger.append("parseTokenSystem.additionalLinesYs", Array.from(additionalLinesYs)); /*for (const y of staffYs) { console.assert(staffLines[y - 2] && staffLines[y] && staffLines[y + 2], "no corresponding staff lines for separator", y - 2, Object.keys(staffLines)); }*/ const systemY = staffYs[0] - 2; const systemX = staffLines[systemY] && staffLines[systemY].rx; const noteYs = tokens .filter(token => token.is("NOTE") && !token.is("TEMPO_NOTEHEAD")) .map(token => token.ry) .concat(Object.keys(staffLines).map(Number)); const top = Math.min(...noteYs) - systemY; const bottom = Math.max(...noteYs) - systemY; //console.log("additionalLinesYs:", additionalLinesYs); const splitters = []; for (let i = 0; i < staffYs.length - 1; ++i) { let up = staffYs[i] + 2; while (additionalLinesYs.has(up + 1)) ++up; let down = staffYs[i + 1] - 2; while (additionalLinesYs.has(down - 1)) --down; //const splitter = Math.min(Math.max((staffYs[i] + staffYs[i + 1]) / 2, up + 1), down - 1) - systemY; const splitter = (up + down) / 2 - systemY; splitters.push(splitter); //logger.append("parseTokenSystem.splitter", {splitter, up, down, systemY}); } splitters.push(Infinity); stacks.forEach(stack => { for (let i = 0; i < staffYs.length; ++i) { if (stack.tryAttachStaff(staffYs[i], i)) return; } }); const findStaffByStacks = token => { for (const stack of stacks) { if (stack.contains(token)) return stack.staffIndex; } }; const localTokens = tokens.map(token => token.translate({x: -systemX, y: -systemY})); const stems = localTokens.filter(token => token.is("NOTE_STEM")); stems.forEach(stem => stem.division = 2); const slashes = localTokens.filter(token => token.is("LINE") && token.target && token.target.x > 0 && token.target.y < 0); const backSlashes = localTokens.filter(token => token.is("LINE") && token.target && token.target.x > 0 && token.target.y > 0); const staffTokens = []; //console.log("splitters:", splitters); const appendToken = (token: StaffToken) => { if (token.is("BEAM")) { const jointStems = stems.filter(stem => Math.abs(stem.centerX - token.x) < 0.1 && (Math.abs(token.y - stem.y) < 0.2 || Math.abs(token.y - (stem.y + stem.height)) < 0.2)); const k = (token.target.y - token.start.y) / (token.target.x - token.start.x); const contactedStems = stems.filter(stem => { const dy = (stem.x - (token.x + token.start.x)) * k; return stem.centerX - (token.x + token.start.x) > -0.1 && stem.centerX - (token.x + token.target.x) < 0.1 && token.y + dy - stem.y > -0.2 && token.y + dy - (stem.y + stem.height) < 0.2; }); if (!contactedStems.length) { token.removeSymbol("NOTETAIL"); token.removeSymbol("JOINT"); } else { token.stems = contactedStems.map(stem => stem.index); if (jointStems.length) token.addSymbol("CAPITAL_BEAM"); const k = (token.target.y - token.start.y) / (token.target.x - token.start.x); // append stem division const crossedStems = stems.filter(stem => stem.centerX > token.x - 0.1 && stem.centerX < token.x + token.target.x + 0.1 && stem.y < Math.max(token.y, token.y + token.target.y) + 0.2 && stem.y + stem.height > Math.min(token.y, token.y + token.target.y) - 0.2); crossedStems.forEach(stem => { const beamY = (stem.centerX - token.x + token.start.x) * k + token.y + token.start.y; if (beamY > stem.y - 0.2 && beamY < stem.y + stem.height + 0.2) { const atTip = stem.stemUp ? beamY < stem.y + 3.2 : beamY > stem.y + stem.height - 3.2; if (atTip) { ++stem.division; if (token.is("CAPITAL_BEAM")) stem.beam = token.index; } } }); } } if (token.is("FLAG UP")) { const stem = stems.find(stem => Math.abs(stem.x + stem.width - token.x) < 0.04 && Math.abs(stem.y - token.y) < 0.1); if (stem) { token.stem = stem.index; stem.division = token.flagNumber; } else token.addSymbol("SUSPENDED"); } if (token.is("FLAG DOWN")) { const stem = stems.find(stem => Math.abs(stem.x + stem.width - token.x) < 0.04 && Math.abs(stem.y + stem.height - token.y) < 0.1); if (stem) { token.stem = stem.index; stem.division = token.flagNumber; } else token.addSymbol("SUSPENDED"); } if (slashes.includes(token)) { const partner = backSlashes.find(t => t.x === token.x && t.target.y === - token.target.y); if (partner) { if (token.y <= partner.y) { token.addSymbol("WEDGE CRESCENDO TOP"); partner.addSymbol("WEDGE CRESCENDO BOTTOM"); } else if (token.y > partner.y) { token.addSymbol("WEDGE DECRESCENDO BOTTOM"); partner.addSymbol("WEDGE DECRESCENDO TOP"); } } } let index = 0; if (token.withUp || token.withDown) { if (token.withUp) index = staffYs.filter(sy => sy + 2 < token.y + systemY).length; else if (token.withDown) index = Math.max(staffYs.filter(sy => sy - 2 < token.y + systemY).length - 1, 0); } else { let y = token.logicY; //const indexInMap = indicesMap[roundJoin(token.x + systemX, y + systemY)]; const indexByStacks = findStaffByStacks(token); if (Number.isInteger(indexByStacks)) index = indexByStacks; else { // affiliate beam to a stem if (token.is("NOTETAIL") && token.is("JOINT")) { const stem = stems.find(stem => Math.abs(stem.centerX - token.x) < 0.1 && token.y > stem.y - 0.2 && token.y < stem.y + stem.height + 0.2); if (stem) y = stem.logicY; //else // console.debug("isolated beam:", token); } //if (token.is("NOTEHEAD")) // console.log("omit note:", token.href, roundJoin(token.x + systemX, y + systemY)); while (y > splitters[index]) ++index; } } staffTokens[index] = staffTokens[index] || []; staffTokens[index].push(token); }; stacks.forEach(stack => stack.translate({x: systemX, y: systemY})); //logger.append("parseTokenSystem.stacks", stacks); parseChordsByStems(localTokens, logger); localTokens .filter(token => !isSystemToken(token)) .forEach(appendToken); // measure ranges const notes = localTokens.filter(token => token.is("NOTE")); const separatorXsRaw = Array.from(new Set(localTokens .filter(token => token.is("MEASURE_SEPARATOR")) .map(token => token.logicX))).sort((x1: number, x2: number) => x1 - x2); // supplement for empty measure separator staff, maybe some lilypond bug if not at end. if (!separatorXsRaw.length) separatorXsRaw.push(localTokens[localTokens.length - 1].x + 1); const measureRanges = separatorXsRaw.map((x, i) => { const left = i > 0 ? separatorXsRaw[i - 1] : -Infinity; return { x, notes: notes.filter(note => note.x > left && note.x < x), }; }).filter(({notes}) => notes.length).map(({x, notes}) => ({ headX: notes[0].x - 1.5, noteRange: {begin: notes[0].x, end: x}, })); //logger.append("parseTokenSystem.measureRanges", measureRanges); return { x: systemX, y: systemY, top, bottom, tokens: localTokens.filter(isSystemToken), staves: staffYs.map((y, i) => staffTokens[i] && parseTokenStaff({ tokens: staffTokens[i], y: y - systemY, top: splitters[i] - (y - systemY), measureRanges, logger, })), }; }; const isStaffToken = token => token.is("STAFF_LINE") || token.is("MEASURE_SEPARATOR"); const parseTokenStaff = ({tokens, y, top, measureRanges, logger}): SheetStaff => { const localTokens = tokens.map(token => token.translate({y: -y})); const notes = localTokens.filter(token => token.is("NOTE")); //logger.append("parseTokenStaff.localTokens", localTokens); const headX = measureRanges[0] ? measureRanges[0].headX : 0; const alters = localTokens.filter(token => token.is("ALTER")); let lastAlter = null; // mark key alters for (const alter of alters) { // far distance alter may be chordmode element if (alter.y > 3 || alter.y < -3) continue; if ((alter.source && alter.source.substr(0, 4) === "\\key")) lastAlter = alter; // break key chain at large gap else if (lastAlter && alter.x - lastAlter.x > 2) break; else if (alter.x < headX) lastAlter = alter; // continue key chain else if (lastAlter && alter.x - lastAlter.x < 1.2) lastAlter = alter; else break; alter.addSymbol("KEY"); } // affiliate accidental alters to notes const accs = alters.filter(alter => !alter.is("KEY") && !alter.href); accs.forEach(alter => { const notehead = notes.find(note => note.ry === alter.ry && note.x > alter.x && note.x - alter.x < 5); if (notehead) alter.stemX = notehead.logicX - constants.EPSILON; else { alter.addSymbol("NOTICE"); logger.append("orphanAlter", alter); } }); //logger.append("measureRanges:", {measureRanges, accs}); const measures = measureRanges.map((range, i) => { const left = i > 0 ? measureRanges[i - 1].noteRange.end : -Infinity; const tokens = localTokens.filter(token => !isStaffToken(token) && token.logicX > left && (token.logicX < range.noteRange.end || i === measureRanges.length - 1)) .sort((t1, t2) => t1.logicX - t2.logicX); const leftNoteX = Math.min(...tokens.filter(token => token.is("NOTE")).map(note => note.x), left + 2.9); // mark volta repeat dots const dots = tokens.filter(token => token.is("DOT") && Math.abs(token.ry) === 0.5); const dotsL = dots.filter(dot => dot.x < leftNoteX); // double lines will enlarge left line interval const dotsR = dots.filter(dot => dot.x > range.noteRange.end - 1); [dotsL, dotsR].forEach((pair, i) => { if (pair.length === 2 && pair[0].ry * pair[1].ry < 0) { pair.forEach(dot => { dot.addSymbol(i ? "RIGHT" : "LEFT"); dot.addSymbol("VOLTA"); }); } }); return { tokens, noteRange: range.noteRange, headX: range.headX, }; }); const headWidth = measures[0] ? measures[0].headX : 0; return { x: 0, y, headWidth, top, tokens: localTokens.filter(isStaffToken), measures, }; }; const isPageToken = token => token.is("TEXT") && !token.source; const organizeTokens = (tokens: StaffToken[], source: TextSource, {logger, viewBox, width, height}: any = {}) => { //logger.append("organizeTokens", tokens); // added source on tokens tokens.forEach(token => { const pos = token.sourcePosition; if (pos) { //token.source = lyLines[pos.line - 1].substr(pos.start, Math.max(pos.end - pos.start, 8)); token.source = source.slice(pos.line, [pos.start, Math.max(pos.end, pos.start + 8)]); // enlarge token source range for command tokens if (/^\\/.test(token.source)) { for (let len = token.source.length + 1; len < 80; ++len) { const captures = token.source.match(/\s+/g); if (captures && captures.length >= 2) break; token.source = source.slice(pos.line, [pos.start, pos.start + len]); } } } }); const meaningfulTokens = tokens.filter(token => !token.is("NULL")); //logger.append("organizeTokens.meaningfulTokens", meaningfulTokens); const pageTokens = meaningfulTokens.filter(isPageToken); meaningfulTokens.forEach(token => { if (token.source) { // process tempo noteheads if (token.source.substr(0, 6) === "\\tempo" && token.is("NOTEHEAD")) { token.removeSymbol("NOTEHEAD"); token.addSymbol("TEMPO_NOTEHEAD"); } // process ped dot if (token.is("DOT") && /^\\sustain/.test(token.source)) { token.removeSymbol("DOT"); token.addSymbol("SUSTAIN", "PED_DOT"); } // tremolo beams pierced if (token.is("BEAM") && /^:\d+/.test(token.source)) { token.removeSymbol("NOTETAIL"); token.removeSymbol("JOINT"); token.addSymbol("TREMOLO_BEAM"); token.addSymbol("PIERCED"); } // tremolo beams paired if (token.is("BEAM") && /repeat tremolo/.test(token.source)) { token.removeSymbol("NOTETAIL"); token.addSymbol("TREMOLO_BEAM"); token.addSymbol("TREMOLO_PAIR"); } // glissando if (/^\\glissando/.test(token.source)) { token.removeSymbol("TR_WAVE"); token.addSymbol("GLISSANDO"); } // arpeggio if (/^\\arpeggio/.test(token.source)) token.addSymbol("ARPEGGIO"); } }); const systemDatas = tokensSystemsSplit(meaningfulTokens.filter(token => !isPageToken(token)), logger); //logger.append("organizeTokens.systemDatas", systemDatas); const systems = systemDatas.map(({tokens, stacks}) => parseTokenSystem(tokens, stacks, logger)) .filter(system => system.staves.length > 0); return { tokens: pageTokens, systems, viewBox, width, height, }; }; export default organizeTokens;