diff --git a/packages/alphatab/src/EngravingSettings.ts b/packages/alphatab/src/EngravingSettings.ts index dc8d7081b..ad52b7a37 100644 --- a/packages/alphatab/src/EngravingSettings.ts +++ b/packages/alphatab/src/EngravingSettings.ts @@ -503,6 +503,7 @@ export class EngravingSettings { this.leftHandTabTieWidth = 2.2 * this.oneStaffSpace; this.numberedDashGlyphWidth = 1.5 * this.oneStaffSpace; + this.numberedChordNoteGap = 0.5 * this.oneStaffSpace; this.deadSlappedLineWidth = 0.25 * this.oneStaffSpace; this.simpleSlideWidth = 1.3 * this.oneStaffSpace; @@ -827,6 +828,13 @@ export class EngravingSettings { */ public stemFlagHeight: Map = new Map(); + /** + * The vertical gap between the stacked numbers of a chord in numbered notation, + * measured between the octave dots of the lower and the higher number. + * @since 1.10.0 + */ + public numberedChordNoteGap = 0; + // Idea: maybe we can encode and pack this large metadata into a more compact format (e.g. BSON or a custom binary blob?) // This metadata below is updated automatically from the bravura_metadata.json via npm script diff --git a/packages/alphatab/src/generated/EngravingSettingsCloner.ts b/packages/alphatab/src/generated/EngravingSettingsCloner.ts index d1509e8bd..6585c0b64 100644 --- a/packages/alphatab/src/generated/EngravingSettingsCloner.ts +++ b/packages/alphatab/src/generated/EngravingSettingsCloner.ts @@ -97,6 +97,7 @@ export class EngravingSettingsCloner { clone.beatContentPadding = original.beatContentPadding; clone.barlineContentPadding = original.barlineContentPadding; clone.stemFlagHeight = new Map(original.stemFlagHeight); + clone.numberedChordNoteGap = original.numberedChordNoteGap; return clone; } } diff --git a/packages/alphatab/src/generated/EngravingSettingsJson.ts b/packages/alphatab/src/generated/EngravingSettingsJson.ts index e30c24131..6f54013f0 100644 --- a/packages/alphatab/src/generated/EngravingSettingsJson.ts +++ b/packages/alphatab/src/generated/EngravingSettingsJson.ts @@ -430,4 +430,10 @@ export interface EngravingSettingsJson { * The space needed by flags on the stem-side from top to bottom to place. */ stemFlagHeight?: Map, number>; + /** + * The vertical gap between the stacked numbers of a chord in numbered notation, + * measured between the octave dots of the lower and the higher number. + * @since 1.10.0 + */ + numberedChordNoteGap?: number; } diff --git a/packages/alphatab/src/generated/EngravingSettingsSerializer.ts b/packages/alphatab/src/generated/EngravingSettingsSerializer.ts index 30f79bd44..f83c695ab 100644 --- a/packages/alphatab/src/generated/EngravingSettingsSerializer.ts +++ b/packages/alphatab/src/generated/EngravingSettingsSerializer.ts @@ -16,7 +16,7 @@ import type { IObjectWriter } from "@coderline/alphatab/io/IObjectWriter"; * @internal */ export class EngravingSettingsSerializer { - private static readonly _fields: RecordFields = new RecordFields(["musicfontsize", "onestaffspace", "tablinespacing", "arrowshaftthickness", "barlineseparation", "beamspacing", "beamthickness", "bracketthickness", "dashedbarlinedashlength", "dashedbarlinegaplength", "dashedbarlinethickness", "hairpinthickness", "legerlinethickness", "legerlineextension", "octavelinethickness", "pedallinethickness", "repeatbarlinedotseparation", "repeatendinglinethickness", "slurmidpointthickness", "stafflinethickness", "stemthickness", "thickbarlinethickness", "thinbarlinethickness", "thinthickbarlineseparation", "tiemidpointthickness", "tupletbracketthickness", "stemup", "stemdown", "repeatoffsetx", "standardstemlength", "stemflagoffsets", "glyphtop", "glyphbottom", "glyphwidths", "glyphheights", "numberedbarrendererbarsize", "numberedbarrendererbarspacing", "numbereddashglyphpadding", "numbereddashglyphwidth", "linerangedglyphdashgap", "linerangedglyphdashsize", "prenoteeffectpadding", "postnoteeffectpadding", "onnoteeffectpadding", "stringnumbercirclepadding", "rowcontainerpadding", "rowcontainergap", "alternateendingspadding", "sustainpedallinepadding", "tieheight", "beattimerpadding", "bendnoteheadelementpadding", "ghostparenthesiswidth", "ghostparenthesispadding", "brokenbeamwidth", "tabwhammytextpadding", "tabwhammyperhalfheight", "tabwhammydashsize", "songbookwhammydipheight", "deadslappedlinewidth", "lefthandtabtiewidth", "tabbenddashsize", "tabbendstaffpadding", "tabbendpervalueheight", "tabbendlabelpadding", "simpleslidewidth", "simpleslideheight", "chorddiagrampaddingx", "chorddiagrampaddingy", "chorddiagramstringspacing", "chorddiagramfretspacing", "chorddiagramnutheight", "chorddiagramfretheight", "chorddiagramlinewidth", "tripletfeelbracketpadding", "accidentalpadding", "prebeatglyphspacing", "temponotescale", "tuningglyphcirclenumberscale", "tuningglyphstringcolumnscale", "tuningglyphstringrowpadding", "directionsscale", "multivoicedisplacednoteheadspacing", "staffcontentpadding", "beatcontentpadding", "barlinecontentpadding", "stemflagheight"]); + private static readonly _fields: RecordFields = new RecordFields(["musicfontsize", "onestaffspace", "tablinespacing", "arrowshaftthickness", "barlineseparation", "beamspacing", "beamthickness", "bracketthickness", "dashedbarlinedashlength", "dashedbarlinegaplength", "dashedbarlinethickness", "hairpinthickness", "legerlinethickness", "legerlineextension", "octavelinethickness", "pedallinethickness", "repeatbarlinedotseparation", "repeatendinglinethickness", "slurmidpointthickness", "stafflinethickness", "stemthickness", "thickbarlinethickness", "thinbarlinethickness", "thinthickbarlineseparation", "tiemidpointthickness", "tupletbracketthickness", "stemup", "stemdown", "repeatoffsetx", "standardstemlength", "stemflagoffsets", "glyphtop", "glyphbottom", "glyphwidths", "glyphheights", "numberedbarrendererbarsize", "numberedbarrendererbarspacing", "numbereddashglyphpadding", "numbereddashglyphwidth", "linerangedglyphdashgap", "linerangedglyphdashsize", "prenoteeffectpadding", "postnoteeffectpadding", "onnoteeffectpadding", "stringnumbercirclepadding", "rowcontainerpadding", "rowcontainergap", "alternateendingspadding", "sustainpedallinepadding", "tieheight", "beattimerpadding", "bendnoteheadelementpadding", "ghostparenthesiswidth", "ghostparenthesispadding", "brokenbeamwidth", "tabwhammytextpadding", "tabwhammyperhalfheight", "tabwhammydashsize", "songbookwhammydipheight", "deadslappedlinewidth", "lefthandtabtiewidth", "tabbenddashsize", "tabbendstaffpadding", "tabbendpervalueheight", "tabbendlabelpadding", "simpleslidewidth", "simpleslideheight", "chorddiagrampaddingx", "chorddiagrampaddingy", "chorddiagramstringspacing", "chorddiagramfretspacing", "chorddiagramnutheight", "chorddiagramfretheight", "chorddiagramlinewidth", "tripletfeelbracketpadding", "accidentalpadding", "prebeatglyphspacing", "temponotescale", "tuningglyphcirclenumberscale", "tuningglyphstringcolumnscale", "tuningglyphstringrowpadding", "directionsscale", "multivoicedisplacednoteheadspacing", "staffcontentpadding", "beatcontentpadding", "barlinecontentpadding", "stemflagheight", "numberedchordnotegap"]); public static read(obj: EngravingSettings, reader: IObjectReader): void { reader.readRecord(EngravingSettingsSerializer._fields, (id, k, rr) => EngravingSettingsSerializer._readField(obj, id, rr, false)); } @@ -287,6 +287,9 @@ export class EngravingSettingsSerializer { case 86: EngravingSettingsSerializer._readStemFlagHeight(obj, r, flexible); return true; + case 87: + obj.numberedChordNoteGap = r.readNumber(); + return true; } return false; } @@ -514,6 +517,7 @@ export class EngravingSettingsSerializer { w.writeNumber(v); } w.endMap(); + w.numberField(87, obj.numberedChordNoteGap); w.endObject(); } } diff --git a/packages/alphatab/src/generated/SerializerSchema.ts b/packages/alphatab/src/generated/SerializerSchema.ts index 82ce5af23..f403f4ec2 100644 --- a/packages/alphatab/src/generated/SerializerSchema.ts +++ b/packages/alphatab/src/generated/SerializerSchema.ts @@ -12,5 +12,5 @@ export class SerializerSchema { /** * A hash of the field ids, keys and types of all serialized classes (50 classes). */ - public static readonly fingerprint: number = 0xa8bdf264; + public static readonly fingerprint: number = 0xb9644837; } diff --git a/packages/alphatab/src/generated/model/NumberedStaffConfigSerializer.ts b/packages/alphatab/src/generated/model/NumberedStaffConfigSerializer.ts index 5f8501e3e..0e2a4dc99 100644 --- a/packages/alphatab/src/generated/model/NumberedStaffConfigSerializer.ts +++ b/packages/alphatab/src/generated/model/NumberedStaffConfigSerializer.ts @@ -9,12 +9,13 @@ import { RecordFields } from "@coderline/alphatab/io/RecordFields"; import { ElementDisplaySerializer } from "@coderline/alphatab/generated/model/ElementDisplaySerializer"; import { BarNumberDisplay } from "@coderline/alphatab/model/RenderStylesheet"; import { JsonHelper } from "@coderline/alphatab/io/JsonHelper"; +import { NumberedNoteMode } from "@coderline/alphatab/model/StaffConfigs"; import type { IObjectWriter } from "@coderline/alphatab/io/IObjectWriter"; /** * @internal */ export class NumberedStaffConfigSerializer { - private static readonly _fields: RecordFields = new RecordFields(["timesignature", "barnumber"]); + private static readonly _fields: RecordFields = new RecordFields(["timesignature", "barnumber", "notes"]); public static read(obj: NumberedStaffConfig, reader: IObjectReader): void { reader.readRecord(NumberedStaffConfigSerializer._fields, (id, k, rr) => NumberedStaffConfigSerializer._readField(obj, id, rr, false)); } @@ -36,6 +37,9 @@ export class NumberedStaffConfigSerializer { case 1: obj.barNumber = JsonHelper.parseEnum(r.readValue(), BarNumberDisplay); return true; + case 2: + obj.notes = JsonHelper.parseEnum(r.readValue(), NumberedNoteMode); + return true; } return false; } @@ -69,6 +73,7 @@ export class NumberedStaffConfigSerializer { ElementDisplaySerializer.write(obj.timeSignature, w); } w.valueField(1, obj.barNumber as number | undefined); + w.valueField(2, obj.notes as number | undefined); w.endObject(); } } diff --git a/packages/alphatab/src/importer/alphaTex/AlphaTex1EnumMappings.ts b/packages/alphatab/src/importer/alphaTex/AlphaTex1EnumMappings.ts index 401d24a21..04ca53a3e 100644 --- a/packages/alphatab/src/importer/alphaTex/AlphaTex1EnumMappings.ts +++ b/packages/alphatab/src/importer/alphaTex/AlphaTex1EnumMappings.ts @@ -1,4 +1,5 @@ import type { TabRhythmMode } from '@coderline/alphatab/NotationSettings'; +import type { NumberedNoteMode } from '@coderline/alphatab/model/StaffConfigs'; import type { AlphaTexAccidentalMode, AlphaTexStaffType, @@ -456,6 +457,13 @@ export class AlphaTex1EnumMappings { ['automatic', 3] ]); public static readonly tabRhythmModeReversed = AlphaTex1EnumMappings._reverse(AlphaTex1EnumMappings.tabRhythmMode); + public static readonly numberedNoteMode = new Map([ + ['allnotes', 0], + ['singlenote', 1] + ]); + public static readonly numberedNoteModeReversed = AlphaTex1EnumMappings._reverse( + AlphaTex1EnumMappings.numberedNoteMode + ); public static readonly keySignaturesMinorReversed = new Map([ [-7, 'abminor'], [-6, 'ebminor'], diff --git a/packages/alphatab/src/importer/alphaTex/AlphaTex1LanguageDefinitions.ts b/packages/alphatab/src/importer/alphaTex/AlphaTex1LanguageDefinitions.ts index be1814849..52e1a97f8 100644 --- a/packages/alphatab/src/importer/alphaTex/AlphaTex1LanguageDefinitions.ts +++ b/packages/alphatab/src/importer/alphaTex/AlphaTex1LanguageDefinitions.ts @@ -582,7 +582,8 @@ export class AlphaTex1LanguageDefinitions { ['restsplacement', [[[[100, 107], 0, ['allstaves', 'primary']]]]], ['restssystems', [[[[100, 107], 0, ['allsystems', 'firstsystemonly']]]]], ['barnumber', [[[[100, 107], 0, ['allbars', 'firstofsystem', 'hide']]]]], - ['rhythm', [[[[100, 107], 0, ['hidden', 'showwithbeams', 'showwithbars', 'automatic']]]]] + ['rhythm', [[[[100, 107], 0, ['hidden', 'showwithbeams', 'showwithbars', 'automatic']]]]], + ['notes', [[[[100, 107], 0, ['allnotes', 'singlenote']]]]] ] ], [ @@ -645,7 +646,8 @@ export class AlphaTex1LanguageDefinitions { ['restsplacement', [[[[100, 107], 0, ['allstaves', 'primary']]]]], ['restssystems', [[[[100, 107], 0, ['allsystems', 'firstsystemonly']]]]], ['barnumber', [[[[100, 107], 0, ['allbars', 'firstofsystem', 'hide']]]]], - ['rhythm', [[[[100, 107], 0, ['hidden', 'showwithbeams', 'showwithbars', 'automatic']]]]] + ['rhythm', [[[[100, 107], 0, ['hidden', 'showwithbeams', 'showwithbars', 'automatic']]]]], + ['notes', [[[[100, 107], 0, ['allnotes', 'singlenote']]]]] ] ], ['ts', null], diff --git a/packages/alphatab/src/importer/alphaTex/AlphaTex1LanguageHandler.ts b/packages/alphatab/src/importer/alphaTex/AlphaTex1LanguageHandler.ts index e3ef265bf..8b0c97f97 100644 --- a/packages/alphatab/src/importer/alphaTex/AlphaTex1LanguageHandler.ts +++ b/packages/alphatab/src/importer/alphaTex/AlphaTex1LanguageHandler.ts @@ -86,11 +86,12 @@ import { SimileMark } from '@coderline/alphatab/model/SimileMark'; import { SlideInType } from '@coderline/alphatab/model/SlideInType'; import { SlideOutType } from '@coderline/alphatab/model/SlideOutType'; import { Staff } from '@coderline/alphatab/model/Staff'; -import type { - NumberedStaffConfig, - ScoreStaffConfig, - SlashStaffConfig, - TabStaffConfig +import { + NumberedNoteMode, + type NumberedStaffConfig, + type ScoreStaffConfig, + type SlashStaffConfig, + type TabStaffConfig } from '@coderline/alphatab/model/StaffConfigs'; import { Track } from '@coderline/alphatab/model/Track'; import { TremoloPickingEffect, TremoloPickingStyle } from '@coderline/alphatab/model/TremoloPickingEffect'; @@ -1456,6 +1457,10 @@ export class AlphaTex1LanguageHandler implements IAlphaTexLanguageImportHandler const c: NumberedStaffConfig = staff?.numberedConfig ?? stylesheet?.numberedConfig ?? {}; c.timeSignature = element('ts', c.timeSignature); c.barNumber = barNumber(c.barNumber); + if (name === 'notes') { + c.notes = AlphaTex1EnumMappings.numberedNoteMode.get(value())!; + applied = true; + } if (staff && applied) { staff.numberedConfig = c; } @@ -3168,6 +3173,9 @@ export class AlphaTex1LanguageHandler implements IAlphaTexLanguageImportHandler element(slash, 'ks', slc?.keySignature, defaults?.slashConfig?.keySignature); element(slash, 'ts', slc?.timeSignature, defaults?.slashConfig?.timeSignature); element(numbered, 'ts', nc?.timeSignature, defaults?.numberedConfig?.timeSignature); + if (nc?.notes !== undefined && nc!.notes !== defaults?.numberedConfig?.notes) { + Atnf.prop(numbered.properties, 'notes', Atnf.identValue(NumberedNoteMode[nc!.notes!])); + } barNumbers.push(sc?.barNumber); barNumbers.push(tc?.barNumber); barNumbers.push(slc?.barNumber); diff --git a/packages/alphatab/src/model/RenderStylesheet.ts b/packages/alphatab/src/model/RenderStylesheet.ts index d3971dd72..ac8df7a15 100644 --- a/packages/alphatab/src/model/RenderStylesheet.ts +++ b/packages/alphatab/src/model/RenderStylesheet.ts @@ -1,10 +1,11 @@ import { TabRhythmMode } from '@coderline/alphatab/NotationSettings'; import { StaffPlacement, SystemDisplay } from '@coderline/alphatab/model/ElementDisplay'; -import type { - NumberedStaffConfig, - ScoreStaffConfig, - SlashStaffConfig, - TabStaffConfig +import { + NumberedNoteMode, + type NumberedStaffConfig, + type ScoreStaffConfig, + type SlashStaffConfig, + type TabStaffConfig } from '@coderline/alphatab/model/StaffConfigs'; import type { Track } from '@coderline/alphatab/model/Track'; @@ -291,6 +292,7 @@ export class RenderStylesheet { */ public numberedConfig: NumberedStaffConfig = { timeSignature: { isVisible: true, staffPlacement: StaffPlacement.Primary, systemDisplay: SystemDisplay.AllSystems }, - barNumber: BarNumberDisplay.FirstOfSystem + barNumber: BarNumberDisplay.FirstOfSystem, + notes: NumberedNoteMode.AllNotes }; } diff --git a/packages/alphatab/src/model/StaffConfigs.ts b/packages/alphatab/src/model/StaffConfigs.ts index 400bad33e..d64857dc8 100644 --- a/packages/alphatab/src/model/StaffConfigs.ts +++ b/packages/alphatab/src/model/StaffConfigs.ts @@ -44,6 +44,23 @@ export interface SlashStaffConfig { barNumber?: BarNumberDisplay; } +/** + * Lists the modes how the notes of a beat are written on the numbered (jianpu) staff. + * @since 1.10.0 + * @public + */ +export enum NumberedNoteMode { + /** + * Every voice is written on its own row and chords are written as stacked numbers, + * the lowest note at the bottom. + */ + AllNotes = 0, + /** + * Only the first voice is written, with a single number per beat showing the highest note of a chord. + */ + SingleNote = 1 +} + /** * Per-staff-type display configuration for the numbered (jianpu) staff. * The "1=X" key designation is rendered as an above-staff effect-band @@ -56,4 +73,9 @@ export interface SlashStaffConfig { export interface NumberedStaffConfig { timeSignature?: ElementDisplay; barNumber?: BarNumberDisplay; + /** + * How the notes of the voices and chords are written. + * @since 1.10.0 + */ + notes?: NumberedNoteMode; } diff --git a/packages/alphatab/src/model/_barrel.ts b/packages/alphatab/src/model/_barrel.ts index 5edccbd36..e68b6e194 100644 --- a/packages/alphatab/src/model/_barrel.ts +++ b/packages/alphatab/src/model/_barrel.ts @@ -76,11 +76,12 @@ export { SystemDisplay, type ElementDisplay } from '@coderline/alphatab/model/ElementDisplay'; -export type { - ScoreStaffConfig, - TabStaffConfig, - SlashStaffConfig, - NumberedStaffConfig +export { + NumberedNoteMode, + type ScoreStaffConfig, + type TabStaffConfig, + type SlashStaffConfig, + type NumberedStaffConfig } from '@coderline/alphatab/model/StaffConfigs'; export type { ScoreBarOverride, diff --git a/packages/alphatab/src/rendering/BarRendererBase.ts b/packages/alphatab/src/rendering/BarRendererBase.ts index 461882d7f..c02782280 100644 --- a/packages/alphatab/src/rendering/BarRendererBase.ts +++ b/packages/alphatab/src/rendering/BarRendererBase.ts @@ -499,6 +499,24 @@ export class BarRendererBase { this.isFinalized = true; } + /** + * Measures the height and the overflows of the bar again after its glyphs moved vertically. Only valid + * right before {@link scaleToWidth}, which emits the skylines and registers the overflows on the staff. + */ + protected remeasureVertically(): void { + this.updateSizes(); + for (const v of this.helpers.beamHelpers) { + for (const h of v) { + h.invalidateDrawingInfos(); + } + } + this._contentTopOverflow = 0; + this._contentBottomOverflow = 0; + this._hasPositionedOverflow = false; + this.calculateOverflows(0, this.height); + this._emitPreBeatOverflows(this.height); + } + protected emitHelperSkyline(_h: BeamingHelper): void {} public emitBeatSkyline(_beatContainer: BeatContainerGlyphBase): void {} @@ -519,6 +537,13 @@ export class BarRendererBase { protected wasFirstOfStaff: boolean = false; + /** + * The geometry of the header glyphs of the bar; null before the first layout. + */ + protected get headerProfile(): HeaderProfile | null { + return this._headerProfile; + } + public get isFirstOfStaff(): boolean { return this.index === 0; } @@ -789,6 +814,7 @@ export class BarRendererBase { private _registerStaffOverflow() { this.staff!.registerOverflowTop(this.topOverflow); this.staff!.registerOverflowBottom(this.bottomOverflow); + this.staff!.registerStaffBottom(this.height); } /** Public wrapper for `_registerStaffOverflow`. */ diff --git a/packages/alphatab/src/rendering/LineBarRenderer.ts b/packages/alphatab/src/rendering/LineBarRenderer.ts index 871cfd519..51aa4ad63 100644 --- a/packages/alphatab/src/rendering/LineBarRenderer.ts +++ b/packages/alphatab/src/rendering/LineBarRenderer.ts @@ -61,6 +61,29 @@ export abstract class LineBarRenderer extends BarRendererBase { public abstract get heightLineCount(): number; public abstract get drawnLineCount(): number; + /** + * The lines one staff row spans. Staff-sized glyphs (header bar lines, repeat dots, dead-slapped crosses) are + * laid out in the first row, so they do not depend on the height of renderers with several rows. + */ + public get staffRowLineCount(): number { + return this.heightLineCount; + } + + /** + * The number of staff rows stacked in the bar. Bar-level glyphs of a row (time signatures, repeat dots) are + * written in front of every row. + */ + public get staffRowCount(): number { + return 1; + } + + /** + * The vertical offset of the staff row with the given index from the first row. + */ + public getStaffRowOffset(_index: number): number { + return 0; + } + protected get topGlyphOverflow() { return 0; } @@ -86,7 +109,7 @@ export abstract class LineBarRenderer extends BarRendererBase { } protected updateFirstLineY() { - const fullLineHeight = this.lineOffset * (this.heightLineCount - 1); + const fullLineHeight = this.lineOffset * (this.staffRowLineCount - 1); const actualLineHeight = this.drawnLineCount === 0 ? 0 : (this.drawnLineCount - 1) * this.lineOffset; const lineYOffset = this.smuflMetrics.staffLineThickness / 2; diff --git a/packages/alphatab/src/rendering/NumberedBarRenderer.ts b/packages/alphatab/src/rendering/NumberedBarRenderer.ts index a23b2447f..fef8e710b 100644 --- a/packages/alphatab/src/rendering/NumberedBarRenderer.ts +++ b/packages/alphatab/src/rendering/NumberedBarRenderer.ts @@ -1,4 +1,3 @@ -import { EngravingSettings } from '@coderline/alphatab/EngravingSettings'; import { MidiUtils } from '@coderline/alphatab/midi/MidiUtils'; import { BarSubElement } from '@coderline/alphatab/model/Bar'; import { type Beat, BeatSubElement } from '@coderline/alphatab/model/Beat'; @@ -9,15 +8,21 @@ import { ModelUtils } from '@coderline/alphatab/model/ModelUtils'; import { MusicFontSymbol } from '@coderline/alphatab/model/MusicFontSymbol'; import type { Note } from '@coderline/alphatab/model/Note'; import type { BarNumberDisplay } from '@coderline/alphatab/model/RenderStylesheet'; +import type { Staff } from '@coderline/alphatab/model/Staff'; +import { NumberedNoteMode } from '@coderline/alphatab/model/StaffConfigs'; import type { Voice } from '@coderline/alphatab/model/Voice'; import type { ICanvas } from '@coderline/alphatab/platform/ICanvas'; +import { NoteYPosition } from '@coderline/alphatab/rendering/BarRendererBase'; import { BeatXPosition } from '@coderline/alphatab/rendering/BeatXPosition'; import { BarLineGlyph } from '@coderline/alphatab/rendering/glyphs/BarLineGlyph'; +import type { NumberedBeatGlyph } from '@coderline/alphatab/rendering/glyphs/NumberedBeatGlyph'; import { NumberedDashBeatContainerGlyph, NumberedNoteBeatContainerGlyphBase } from '@coderline/alphatab/rendering/glyphs/NumberedDashBeatContainerGlyph'; -import { ScoreTimeSignatureGlyph } from '@coderline/alphatab/rendering/glyphs/ScoreTimeSignatureGlyph'; +import { NumberedNoteHeadGlyph } from '@coderline/alphatab/rendering/glyphs/NumberedNoteHeadGlyph'; +import { NumberedTimeSignatureGlyph } from '@coderline/alphatab/rendering/glyphs/NumberedTimeSignatureGlyph'; +import { TimeSignatureGlyph } from '@coderline/alphatab/rendering/glyphs/TimeSignatureGlyph'; import { LineBarRenderer } from '@coderline/alphatab/rendering/LineBarRenderer'; import { NumberedBeatContainerGlyph } from '@coderline/alphatab/rendering/NumberedBeatContainerGlyph'; import type { HeaderKey } from '@coderline/alphatab/rendering/staves/HeaderProfile'; @@ -32,11 +37,148 @@ import { ElementStyleHelper } from '@coderline/alphatab/rendering/utils/ElementS */ export class NumberedBarRenderer extends LineBarRenderer { public static readonly StaffId: string = 'numbered'; + private static readonly _systemSlotsKey: string = 'numbered.systemSlots'; public simpleWhammyOverflow: number = 0; public shortestDuration = Duration.QuadrupleWhole; + /** + * Whether only a single number of the first voice is written per beat. + */ + public isSingleNote = false; + + /** + * How the notes of the staff are written, from the staff or the score stylesheet. + */ + public static resolveNoteMode(staff: Staff): NumberedNoteMode { + return staff.numberedConfig?.notes ?? staff.track.score.stylesheet.numberedConfig.notes!; + } + + /** + * The slots the numbers of this bar need on their own (chords, further voices); + * null if every number of the bar sits on the line of single numbers. + */ + private _slotNeeds: NumberedSlotLayout | null = null; + private _slots: NumberedSlotLayout = NumberedSlotLayout.single; + private _numberHeight: number = 0; + // the last voice whose row the slot needs hold, and the height its numbers hang below the row + private _neededRowVoice: number = 0; + private _neededRowHang: number = 0; + + public override doLayout(): void { + this._slotNeeds = null; + this._slots = NumberedSlotLayout.single; + this._numberHeight = 0; + this._neededRowVoice = 0; + this._neededRowHang = 0; + this.isSingleNote = NumberedBarRenderer.resolveNoteMode(this.bar.staff) === NumberedNoteMode.SingleNote; + super.doLayout(); + } + + /** + * Places the numbers on the slots of the staff in its system before the bar is scaled: all bars of the + * staff in the system share the slots their numbers need, as they share the staff lines. + */ + public override scaleToWidth(width: number): void { + this._alignToSlots(this._getSystemSlots()); + super.scaleToWidth(width); + } + + /** + * The slots holding the numbers of all bars of the staff in the system. The layouts scale the bars of a + * staff in their order once the system holds all of them, so the first bar resolves the slots for the others. + */ + private _getSystemSlots(): NumberedSlotLayout { + const staff = this.staff!; + const key = NumberedBarRenderer._systemSlotsKey; + if (this.index > 0) { + return staff.getSharedLayoutData(key, NumberedSlotLayout.single); + } + let layout = NumberedSlotLayout.single; + for (const r of staff.barRenderers) { + const needs = (r as NumberedBarRenderer)._slotNeeds; + if (needs) { + layout = NumberedSlotLayout.merge(layout, needs); + } + } + if (layout !== NumberedSlotLayout.single) { + staff.setSharedLayoutData(key, layout); + } + return layout; + } + + /** + * Moves the numbers of this bar vertically onto the given slots and measures the bar again, + * without a new layout. The result depends only on the bar and the given slots. + */ + private _alignToSlots(layout: NumberedSlotLayout): void { + const own = this._slotNeeds ?? NumberedSlotLayout.single; + // relayout passes restore the overflows measured with the own slots, so bars off their own slots + // are measured again on every pass + if (layout.matches(own) && layout.matches(this._slots)) { + return; + } + this._slots = layout; + for (const voice of this.voiceContainer.beatGlyphs) { + for (const c of voice) { + if (c instanceof NumberedBeatContainerGlyph) { + c.updateVerticalPositions(); + } + } + } + this.remeasureVertically(); + } + + private _getNumberHeight(): number { + if (this._numberHeight === 0) { + const canvas = this.scoreRenderer.canvas!; + canvas.font = this.resources.numberedNotationFont; + this._numberHeight = this.scoreRenderer.layout!.textMeasures.measureText(canvas, '1').height; + } + return this._numberHeight; + } + + /** + * The note written for the beat when only a single number is written per beat: the highest visible note. + */ + public getSingleNote(beat: Beat): Note | null { + return beat.maxNote; + } + + /** + * Whether the given note is written as a number of its beat. + */ + public isNoteDisplayed(note: Note): boolean { + return this.isSingleNote ? this.getSingleNote(note.beat) === note : note.isVisible; + } + + /** + * The y position of the k-th number from the bottom of the chords in the row of the given voice. + */ + public getNumberY(voiceIndex: number, chordIndex: number): number { + const slots = this._slots; + return this.getLineY((slots.getRowStep(voiceIndex) - slots.getChordStep(voiceIndex, chordIndex)) / 2); + } + + /** + * The y position of the numbers of the given voice: every voice is written on its own row. + */ + public getVoiceLineY(voiceIndex: number): number { + return this.getNumberY(voiceIndex, 0); + } + + private _isVoiceWritten(voiceIndex: number): boolean { + return !this.isSingleNote || voiceIndex === 0; + } + + /** + * Whether the row of the given voice holds content in this bar. + */ + public isRowWritten(voiceIndex: number): boolean { + return this._isVoiceWritten(voiceIndex) && this.bar.filledVoices.has(voiceIndex); + } + public override resolveTimeSignatureDisplay(): ElementDisplay { return StaffDisplayResolver.merge( this.bar.numberedDisplay?.timeSignature, @@ -78,13 +220,40 @@ export class NumberedBarRenderer extends LineBarRenderer { } public override get heightLineCount(): number { - return 5; + return this._slots.lineCount; } public override get drawnLineCount(): number { return 0; } + public override get staffRowLineCount(): number { + return 5; + } + + public override get staffRowCount(): number { + let count = 0; + for (let v = 0; v < this.bar.voices.length; v++) { + if (this.isRowWritten(v)) { + count++; + } + } + return count; + } + + public override getStaffRowOffset(index: number): number { + let row = 0; + for (let v = 0; v < this.bar.voices.length; v++) { + if (this.isRowWritten(v)) { + if (row === index) { + return this.getVoiceLineY(v) - this.getLineY(0); + } + row++; + } + } + return 0; + } + protected override get bottomGlyphOverflow(): number { return 0; } @@ -130,16 +299,11 @@ export class NumberedBarRenderer extends LineBarRenderer { const previousColor = ElementStyleHelper.applyBeat(canvas, flagsElement, beat); try { - const direction: BeamDirection = this.getBeamDirection(h); - const isGrace: boolean = h.graceType !== GraceType.None; - const scaleMod: number = isGrace ? EngravingSettings.GraceScale : 1; - - let barSpacing: number = (this.beamSpacing + this.beamThickness) * scaleMod; - let barSize = this.beamThickness * scaleMod; - if (direction === BeamDirection.Down) { - barSpacing = -barSpacing; - barSize = -barSize; - } + const container = this.voiceContainer.getBeatContainer(beat) as NumberedBeatContainerGlyph | undefined; + // the underlines hang below the numbers: the first line spans the whole group, + // shorter durations add their lines below it + const barSpacing: number = this.beamSpacing + this.beamThickness; + const barSize = this.beamThickness; let barCount: number = ModelUtils.getIndex(beat.duration) - 2; let beatLineX: number = this.getBeatX(beat, BeatXPosition.PreNotes); @@ -154,7 +318,13 @@ export class NumberedBarRenderer extends LineBarRenderer { barEndX = this.getBeatX(h.beats[i + 1], BeatXPosition.PreNotes); } - const barStart: number = cy + this.y + this.calculateBeamY(h, beatLineX); + // the underline block starts with a gap below the numbers, followed by a spacing before every line + const barStart: number = + cy + + this.y + + this.calculateBeamY(h, beatLineX) - + (container ? container.underlinesHeight : 0) + + 2 * this.beamSpacing; for (let barIndex: number = 0; barIndex < barCount; barIndex++) { const barY: number = barStart + barIndex * barSpacing; @@ -169,7 +339,6 @@ export class NumberedBarRenderer extends LineBarRenderer { } // dashes for additional numbers - const container = this.voiceContainer.getBeatContainer(beat) as NumberedBeatContainerGlyph | undefined; if (container && container.hasAdditionalNumbers) { for (const additionalNumber of container.iterateAdditionalNumbers()) { barCount = additionalNumber.barCount; @@ -213,16 +382,21 @@ export class NumberedBarRenderer extends LineBarRenderer { } private _calculateBarHeight(beat: Beat) { - const barCount: number = ModelUtils.getIndex(beat.duration) - 2; - let barHeight = 0; - if (barCount > 0) { - const smufl = this.smuflMetrics; - barHeight = - smufl.numberedBarRendererBarSpacing + - barCount * (smufl.numberedBarRendererBarSpacing + smufl.numberedBarRendererBarSize); - } + return this.getUnderlinesHeightForCount(ModelUtils.getIndex(beat.duration) - 2); + } - return barHeight; + /** + * The height of the given number of duration underlines including the gap to the number above. + */ + public getUnderlinesHeightForCount(barCount: number): number { + if (barCount <= 0) { + return 0; + } + const smufl = this.smuflMetrics; + return ( + smufl.numberedBarRendererBarSpacing + + barCount * (smufl.numberedBarRendererBarSpacing + smufl.numberedBarRendererBarSize) + ); } protected override getFlagTopY(beat: Beat, direction: BeamDirection): number { @@ -242,20 +416,23 @@ export class NumberedBarRenderer extends LineBarRenderer { } protected override getFlagBottomY(beat: Beat, direction: BeamDirection): number { - const barHeight: number = this._calculateBarHeight(beat); - - const container = this.voiceContainer.getBeatContainer(beat); + const container = this.voiceContainer.getBeatContainer(beat) as NumberedBeatContainerGlyph | undefined; if (!container) { if (direction === BeamDirection.Down) { - return this.voiceContainer.getBoundingBoxBottom() + barHeight; + return this.voiceContainer.getBoundingBoxBottom() + this._calculateBarHeight(beat); } return this.getLineY(0); } if (direction === BeamDirection.Down) { - return container.getBoundingBoxBottom() + barHeight; + // the underlines sit directly below the numbers, low octave dots go below them + const numbersBottom = + beat.isEmpty || beat.deadSlapped + ? container.getBoundingBoxBottom() + : container.getLowestNoteY(NoteYPosition.Bottom); + return numbersBottom + container.underlinesHeight; } - return this.getLineY(0); + return this.getVoiceLineY(beat.voice.index); } protected override getBeamDirection(_helper: BeamingHelper): BeamDirection { @@ -307,7 +484,7 @@ export class NumberedBarRenderer extends LineBarRenderer { private _createTimeSignatureGlyphs(): void { const masterBar = this.bar.masterBar; - const g = new ScoreTimeSignatureGlyph( + const g = new NumberedTimeSignatureGlyph( 0, this.getLineY(0), masterBar.timeSignatureNumerator, @@ -328,16 +505,173 @@ export class NumberedBarRenderer extends LineBarRenderer { } } + /** + * The height of the time signature the bar writes in front of its rows; 0 if it writes none. + */ + private _getTimeSignatureHeight(): number { + const profile = this.headerProfile; + if (!profile) { + return 0; + } + for (const g of profile.glyphs) { + if (g.rank === TimeSignatureGlyph.HeaderRank) { + const masterBar = this.bar.masterBar; + const heights = this.smuflMetrics.glyphHeights; + if (masterBar.timeSignatureCommon) { + const numerator = masterBar.timeSignatureNumerator; + const denominator = masterBar.timeSignatureDenominator; + if (numerator === 2 && denominator === 2) { + return heights.get(MusicFontSymbol.TimeSigCutCommon)!; + } + if (numerator === 4 && denominator === 4) { + return heights.get(MusicFontSymbol.TimeSigCommon)!; + } + } + return 2 * heights.get(MusicFontSymbol.TimeSig0)!; + } + } + return 0; + } + + /** + * The steps between the centres of two stacked chord numbers with the given octave dots, so that the dots + * of both fit between them. + */ + private _getChordGapSteps(lowerDots: number, upperDots: number): number { + const settings = this.smuflMetrics; + const numberHeight = this._getNumberHeight(); + const distance = + NumberedNoteHeadGlyph.getTopExtent(settings, numberHeight, lowerDots) + + settings.numberedChordNoteGap + + NumberedNoteHeadGlyph.getBottomExtent(settings, numberHeight, upperDots, 0); + return Math.ceil(distance / (this.lineSpacing / 2)); + } + + /** + * Records the slots the numbers of the voice need. The rows of the voices are recorded in their order: per row + * boundary the hanging part of the upper row, a separation of two staff spaces and the rising part of the lower + * row, both from this bar. Where the bar writes its time signature in front of both rows, the two copies keep + * the content padding of adjacent staves. + * @param gapSteps Per chord position, the most steps the chords of the voice need; null without chords. + * @param underlines The most duration underlines of the voice. + * @param lowDots The most low octave dots of the lowest numbers. + * @param highDots The most high octave dots of the top numbers. + * @param hasArcs Whether ties, effect slurs or tuplets are drawn above the numbers. + * @param graceTop The distance from the row to the top of its highest grace chord. + */ + private _addSlotNeeds( + voiceIndex: number, + gapSteps: number[] | null, + underlines: number, + lowDots: number, + highDots: number, + hasArcs: boolean, + graceTop: number + ): void { + let needs = this._slotNeeds; + if (!needs) { + // numbers of a single voice without chords stay on the line of single numbers + if (!gapSteps && (this.isSingleNote || !this.bar.isMultiVoice)) { + return; + } + needs = new NumberedSlotLayout(); + this._slotNeeds = needs; + this._slots = needs; + } + if (gapSteps) { + needs.setChordGapSteps(voiceIndex, gapSteps); + } + + const settings = this.smuflMetrics; + const numberHeight = this._getNumberHeight(); + if (voiceIndex > 0) { + const stepHeight = this.lineSpacing / 2; + // rows read as separate parts, not as chords + const rowSeparation = 2 * settings.oneStaffSpace; + let chordSteps = 0; + if (gapSteps) { + for (const g of gapSteps) { + chordSteps += g; + } + } + let rising = chordSteps * stepHeight + NumberedNoteHeadGlyph.getTopExtent(settings, numberHeight, highDots); + if (hasArcs) { + rising += Math.max( + settings.tieHeight, + settings.glyphHeights.get(MusicFontSymbol.Tuplet0)! + this.tupletOffset + ); + } + if (graceTop > rising) { + rising = graceTop; + } + // voices between without content keep a blank row + const filledVoices = this.bar.filledVoices; + for (let v = this._neededRowVoice + 1; v <= voiceIndex; v++) { + const hanging = v === this._neededRowVoice + 1 ? this._neededRowHang : numberHeight / 2; + let steps = Math.ceil( + (hanging + rowSeparation + (v === voiceIndex ? rising : numberHeight / 2)) / stepHeight + ); + if (filledVoices.has(v - 1) && filledVoices.has(v)) { + const timeSignatureHeight = this._getTimeSignatureHeight(); + if (timeSignatureHeight > 0) { + const timeSignatureSteps = Math.ceil( + (timeSignatureHeight + settings.staffContentPadding) / stepHeight + ); + if (timeSignatureSteps > steps) { + steps = timeSignatureSteps; + } + } + } + needs.addRowGapSteps(steps); + } + } + const underlinesHeight = this.getUnderlinesHeightForCount(underlines); + this._neededRowVoice = voiceIndex; + this._neededRowHang = Math.max( + numberHeight / 2 + underlinesHeight, + NumberedNoteHeadGlyph.getBottomExtent(settings, numberHeight, -lowDots, underlinesHeight) + ); + } + protected override createVoiceGlyphs(v: Voice): void { - if (v.index > 0) { + if (!this._isVoiceWritten(v.index)) { return; } super.createVoiceGlyphs(v); + // what the numbers of the voice need on the slots; grace chords are stacked on their own and only + // need room above their row + let gapSteps: number[] | null = null; + let underlines = 0; + let lowDots = 0; + let highDots = 0; + let hasArcs = false; + let graceTop = 0; + + let helper: BeamingHelper | null = null; + let helperUnderlinesHeight = 0; const absoluteStart = this.bar.masterBar.start; for (const b of v.beats) { const mainContainer = new NumberedBeatContainerGlyph(b); + const onNotes = mainContainer.onNotes as NumberedBeatGlyph; + onNotes.createNotes(this, b); + if (b.graceType === GraceType.None) { + // the underlines of beats beamed together are drawn from a shared bottom line + const h = this.helpers.getBeamingHelperForBeat(b); + if (!h) { + mainContainer.underlinesHeight = this._calculateBarHeight(b); + } else { + if (h !== helper) { + helper = h; + helperUnderlinesHeight = 0; + for (const hb of h.beats) { + helperUnderlinesHeight = Math.max(helperUnderlinesHeight, this._calculateBarHeight(hb)); + } + } + mainContainer.underlinesHeight = helperUnderlinesHeight; + } + } this.addBeatGlyph(mainContainer); // create dashes and filler glyphs @@ -356,8 +690,8 @@ export class NumberedBarRenderer extends LineBarRenderer { absoluteStart + dashTick, MidiUtils.QuarterTime ); - this.addBeatGlyph(restNumber); - mainContainer.addNotes(restNumber); + this._addRepeatedNumber(mainContainer, restNumber); + underlines = Math.max(underlines, restNumber.barCount); } else if (isFullTick) { const dash = new NumberedDashBeatContainerGlyph(v.index, absoluteStart + dashTick); this.addBeatGlyph(dash); @@ -370,14 +704,88 @@ export class NumberedBarRenderer extends LineBarRenderer { absoluteStart + dashTick, endTick - dashTick ); - this.addBeatGlyph(remainingTickNumber); - mainContainer.addNotes(remainingTickNumber); + this._addRepeatedNumber(mainContainer, remainingTickNumber); + underlines = Math.max(underlines, remainingTickNumber.barCount); } dashTick += MidiUtils.QuarterTime; } } + + if (b.graceType !== GraceType.None) { + // the first row rises into the space above the staff + const graceChord = onNotes.chord; + if (graceChord && v.index > 0) { + // grace numbers are stacked by their own extents, which the layout of the chord measures + graceChord.renderer = this; + graceChord.doLayout(); + graceTop = Math.max(graceTop, -graceChord.highestHead.getBoundingBoxTop()); + } + continue; + } + if (b.isEmpty) { + continue; + } + if (b.hasTuplet) { + hasArcs = true; + } + underlines = Math.max(underlines, ModelUtils.getIndex(b.duration) - 2); + const chord = onNotes.chord; + if (chord) { + const heads = chord.heads; + lowDots = Math.max(lowDots, -heads[0].octaveDots); + highDots = Math.max(highDots, heads[heads.length - 1].octaveDots); + if (!gapSteps) { + gapSteps = []; + } + for (let k = 0; k < heads.length; k++) { + if (NumberedBarRenderer._hasArcs(heads[k].note!)) { + hasArcs = true; + } + if (k === 0) { + continue; + } + const gap = this._getChordGapSteps(heads[k - 1].octaveDots, heads[k].octaveDots); + if (k > gapSteps.length) { + gapSteps.push(gap); + } else if (gap > gapSteps[k - 1]) { + gapSteps[k - 1] = gap; + } + } + } else { + const head = onNotes.noteHead; + const note = head ? head.note : null; + if (head && note) { + lowDots = Math.max(lowDots, -head.octaveDots); + highDots = Math.max(highDots, head.octaveDots); + if (NumberedBarRenderer._hasArcs(note)) { + hasArcs = true; + } + } + } } + + this._addSlotNeeds(v.index, gapSteps, underlines, lowDots, highDots, hasArcs, graceTop); + } + + private _addRepeatedNumber(main: NumberedBeatContainerGlyph, repeated: NumberedNoteBeatContainerGlyphBase): void { + (repeated.onNotes as NumberedBeatGlyph).createNotes(this, repeated.beat); + repeated.underlinesHeight = this.getUnderlinesHeightForCount(repeated.barCount); + this.addBeatGlyph(repeated); + main.addNotes(repeated); + } + + /** + * Whether ties or slurs are drawn above the number of the note. + */ + private static _hasArcs(note: Note): boolean { + return ( + note.isTieOrigin || + note.isTieDestination || + note.isEffectSlurOrigin || + note.isEffectSlurDestination || + note.isLeftHandTapped + ); } protected override paintBeamingStem( @@ -405,7 +813,8 @@ export class NumberedBarRenderer extends LineBarRenderer { flagsElement: BeatSubElement, beamsElement: BeatSubElement ): void { - if (h.voice?.index === 0) { + const voice = h.voice; + if (voice && this._isVoiceWritten(voice.index)) { super.paintBeamHelper(cx, cy, canvas, h, flagsElement, beamsElement); } } @@ -439,3 +848,220 @@ export class NumberedBarRenderer extends LineBarRenderer { super.paintTuplets(cx, cy, canvas, beatElement, true); } } + +/** + * The slots of a numbered staff in steps (half staff spaces) of its lines: the rows of the voices below the first + * row and the chord positions above each row. All bars of a staff in a system share one layout; a bar first holds + * the slots its own numbers need. A layout does not change once it is shared. + * @internal + */ +export class NumberedSlotLayout { + /** + * The layout of a staff whose numbers all sit on the line of single numbers. + */ + public static readonly single: NumberedSlotLayout = new NumberedSlotLayout(); + + /** + * Per row boundary v (between the rows of voice v and v + 1), the steps between the rows; null with one row. + */ + private _rowGapSteps: number[] | null = null; + + /** + * Per voice and chord position k, the steps between the centres of number k and k + 1 from the bottom; + * null without chords. + */ + private _chordGapSteps: (number[] | null)[] | null = null; + + /** + * The number of staff lines spanning all rows. + */ + public lineCount: number = 5; + + public setChordGapSteps(voiceIndex: number, gapSteps: number[]): void { + let chords = this._chordGapSteps; + if (!chords) { + chords = []; + this._chordGapSteps = chords; + } + while (chords.length < voiceIndex) { + chords.push(null); + } + chords.push(gapSteps); + } + + /** + * Adds the row of the next voice below the last row. + */ + public addRowGapSteps(steps: number): void { + const rows = this._rowGapSteps; + if (rows) { + rows.push(steps); + } else { + this._rowGapSteps = [steps]; + } + this.lineCount += steps / 2; + } + + /** + * The step of the row of the voice, downwards from the first row; voices without a row are on the first row. + */ + public getRowStep(voiceIndex: number): number { + const rows = this._rowGapSteps; + let step = 0; + if (rows && voiceIndex <= rows.length) { + for (let v = 0; v < voiceIndex; v++) { + step += rows[v]; + } + } + return step; + } + + /** + * The step of the k-th number of a chord (from the bottom) above the row of the voice. + */ + public getChordStep(voiceIndex: number, chordIndex: number): number { + const chords = this._chordGapSteps; + const rows = this._rowGapSteps; + if (!chords || voiceIndex >= chords.length || voiceIndex > (rows ? rows.length : 0)) { + return 0; + } + const gaps = chords[voiceIndex]; + let step = 0; + if (gaps) { + for (let k = 0; k < chordIndex && k < gaps.length; k++) { + step += gaps[k]; + } + } + return step; + } + + /** + * Whether both layouts place every number on the same step. + */ + public matches(other: NumberedSlotLayout): boolean { + if (other === this) { + return true; + } + if (!NumberedSlotLayout._stepsMatch(this._rowGapSteps, other._rowGapSteps)) { + return false; + } + const a = this._chordGapSteps; + const b = other._chordGapSteps; + const count = Math.max(a ? a.length : 0, b ? b.length : 0); + for (let v = 0; v < count; v++) { + const gapsA = a && v < a.length ? a[v] : null; + const gapsB = b && v < b.length ? b[v] : null; + if (!NumberedSlotLayout._stepsMatch(gapsA, gapsB)) { + return false; + } + } + return true; + } + + private static _stepsMatch(a: number[] | null, b: number[] | null): boolean { + if (a === b) { + return true; + } + if (!a || !b || a.length !== b.length) { + return false; + } + for (let i = 0; i < a.length; i++) { + if (a[i] !== b[i]) { + return false; + } + } + return true; + } + + /** + * The layout holding the numbers of both layouts: per chord position and row boundary the most steps. + * Returns one of them if it already holds the other. + */ + public static merge(a: NumberedSlotLayout, b: NumberedSlotLayout): NumberedSlotLayout { + if (a._holds(b)) { + return a; + } + if (b._holds(a)) { + return b; + } + const merged = new NumberedSlotLayout(); + const rows = NumberedSlotLayout._mergeSteps(a._rowGapSteps, b._rowGapSteps); + merged._rowGapSteps = rows; + if (rows) { + for (const steps of rows) { + merged.lineCount += steps / 2; + } + } + const chordsA = a._chordGapSteps; + const chordsB = b._chordGapSteps; + if (!chordsA || !chordsB) { + merged._chordGapSteps = chordsA ?? chordsB; + } else { + const chords: (number[] | null)[] = []; + const count = Math.max(chordsA.length, chordsB.length); + for (let v = 0; v < count; v++) { + chords.push( + NumberedSlotLayout._mergeSteps( + v < chordsA.length ? chordsA[v] : null, + v < chordsB.length ? chordsB[v] : null + ) + ); + } + merged._chordGapSteps = chords; + } + return merged; + } + + /** + * Whether every row boundary and chord position of the other layout is at most as many steps here. + */ + private _holds(other: NumberedSlotLayout): boolean { + if (!NumberedSlotLayout._stepsHold(this._rowGapSteps, other._rowGapSteps)) { + return false; + } + const chords = other._chordGapSteps; + if (!chords) { + return true; + } + const own = this._chordGapSteps; + for (let v = 0; v < chords.length; v++) { + if (!NumberedSlotLayout._stepsHold(own && v < own.length ? own[v] : null, chords[v])) { + return false; + } + } + return true; + } + + private static _stepsHold(own: number[] | null, other: number[] | null): boolean { + if (!other || own === other) { + return true; + } + if (!own || own.length < other.length) { + return false; + } + for (let i = 0; i < other.length; i++) { + if (other[i] > own[i]) { + return false; + } + } + return true; + } + + private static _mergeSteps(a: number[] | null, b: number[] | null): number[] | null { + if (!a) { + return b; + } + if (!b) { + return a; + } + const longer = a.length >= b.length ? a : b; + const shorter = a.length >= b.length ? b : a; + const merged = longer.slice(); + for (let i = 0; i < shorter.length; i++) { + if (shorter[i] > merged[i]) { + merged[i] = shorter[i]; + } + } + return merged; + } +} diff --git a/packages/alphatab/src/rendering/NumberedBeatContainerGlyph.ts b/packages/alphatab/src/rendering/NumberedBeatContainerGlyph.ts index 8eb512d2b..03936dfe2 100644 --- a/packages/alphatab/src/rendering/NumberedBeatContainerGlyph.ts +++ b/packages/alphatab/src/rendering/NumberedBeatContainerGlyph.ts @@ -10,6 +10,7 @@ import { } from '@coderline/alphatab/rendering/glyphs/NumberedDashBeatContainerGlyph'; import { NumberedSlurGlyph } from '@coderline/alphatab/rendering/glyphs/NumberedSlurGlyph'; import type { TieGlyph } from '@coderline/alphatab/rendering/glyphs/TieGlyph'; +import type { NumberedBarRenderer } from '@coderline/alphatab/rendering/NumberedBarRenderer'; /** * @internal @@ -57,12 +58,30 @@ export class NumberedBeatContainerGlyph extends BeatContainerGlyph { this.hasAdditionalNumbers = true; } + /** + * The height of the duration underlines below the numbers of this beat. + */ + public underlinesHeight: number = 0; + public override doLayout(): void { this._slurs.clear(); this._effectSlurs = []; + const onNotes = this.onNotes as NumberedBeatGlyph; + onNotes.renderer = this.renderer; + onNotes.container = this; + onNotes.prepareNotes(); super.doLayout(); } + /** + * Moves the numbers with their accidentals and dots to the current slots of the renderer; + * the horizontal layout stays as it is. + */ + public updateVerticalPositions(): void { + (this.onNotes as NumberedBeatGlyph).updateVerticalPositions(); + this.invalidateNoteExtent(); + } + protected override getBoundsVisualWidth(visualX: number, width: number): number { // extend bounds to include dashes const dashes = this._dashes; @@ -88,13 +107,13 @@ export class NumberedBeatContainerGlyph extends BeatContainerGlyph { return; } - if (n.isTieOrigin && n.tieDestination!.isVisible && !this._slurs.has('numbered.tie')) { - const tie = new NumberedTieGlyph(`numbered.tie.${n.beat.id}`, n, n.tieDestination!, false); + const renderer = this.renderer as NumberedBarRenderer; + if (n.isTieOrigin && n.tieDestination!.isVisible && renderer.isNoteDisplayed(n)) { + const tie = new NumberedTieGlyph(`numbered.tie.${n.id}`, n, n.tieDestination!, false); this.addTie(tie); - this._slurs.set(tie.slurEffectId, tie); } - if (n.isTieDestination) { - const tie = new NumberedTieGlyph(`numbered.tie.${n.tieOrigin!.beat.id}`, n.tieOrigin!, n, true); + if (n.isTieDestination && renderer.isNoteDisplayed(n.tieOrigin!)) { + const tie = new NumberedTieGlyph(`numbered.tie.${n.tieOrigin!.id}`, n.tieOrigin!, n, true); this.addTie(tie); } if ( @@ -118,7 +137,7 @@ export class NumberedBeatContainerGlyph extends BeatContainerGlyph { if (!expanded) { const effectSlur = new NumberedSlurGlyph( - `numbered.slur.effect`, + `numbered.slur.effect.${n.beat.id}`, n, n.effectSlurDestination, false, @@ -126,8 +145,6 @@ export class NumberedBeatContainerGlyph extends BeatContainerGlyph { ); this._effectSlurs.push(effectSlur); this.addTie(effectSlur); - this._slurs.set(effectSlur.slurEffectId, effectSlur); - this._slurs.set('numbered.slur.effect', effectSlur); } } @@ -141,11 +158,15 @@ export class NumberedBeatContainerGlyph extends BeatContainerGlyph { } } if (!expanded) { - const effectSlur = new NumberedSlurGlyph(`numbered.slur.effect`, n.effectSlurOrigin, n, false, true); + const effectSlur = new NumberedSlurGlyph( + `numbered.slur.effect.${n.effectSlurOrigin.beat.id}`, + n.effectSlurOrigin, + n, + false, + true + ); this._effectSlurs.push(effectSlur); this.addTie(effectSlur); - this._slurs.set(effectSlur.slurEffectId, effectSlur); - this._slurs.set('numbered.slur.effect', effectSlur); } } } diff --git a/packages/alphatab/src/rendering/glyphs/BarLineGlyph.ts b/packages/alphatab/src/rendering/glyphs/BarLineGlyph.ts index 435825e47..38ab0ebf9 100644 --- a/packages/alphatab/src/rendering/glyphs/BarLineGlyph.ts +++ b/packages/alphatab/src/rendering/glyphs/BarLineGlyph.ts @@ -117,7 +117,7 @@ class BarLineRepeatDotsGlyph extends BarLineGlyphBase { public override paintExtended(cx: number, cy: number, canvas: ICanvas, _newHeight: number): void { const renderer = this.renderer as LineBarRenderer; - const lineOffset = renderer.heightLineCount % 2 === 0 ? 1 : 0.5; + const lineOffset = renderer.staffRowLineCount % 2 === 0 ? 1 : 0.5; const exactCenter = cy + this.y + this.height / 2; const lineHeight = renderer.getLineHeight(lineOffset); @@ -129,20 +129,23 @@ class BarLineRepeatDotsGlyph extends BarLineGlyphBase { const dotOffset = dotTop - dotHeight / 2; - CanvasHelper.fillMusicFontSymbolSafe( - canvas, - cx + this.x, - exactCenter + dotOffset - lineHeight, - 1, - MusicFontSymbol.RepeatDot - ); - CanvasHelper.fillMusicFontSymbolSafe( - canvas, - cx + this.x, - exactCenter + dotOffset + lineHeight, - 1, - MusicFontSymbol.RepeatDot - ); + for (let row = 0, count = renderer.staffRowCount; row < count; row++) { + const center = exactCenter + renderer.getStaffRowOffset(row) + dotOffset; + CanvasHelper.fillMusicFontSymbolSafe( + canvas, + cx + this.x, + center - lineHeight, + 1, + MusicFontSymbol.RepeatDot + ); + CanvasHelper.fillMusicFontSymbolSafe( + canvas, + cx + this.x, + center + lineHeight, + 1, + MusicFontSymbol.RepeatDot + ); + } } } @@ -329,7 +332,12 @@ export class BarLineGlyph extends LeftToRightLayoutingGlyphGroup { (this._isRight && lineRenderer.isLastOfStaff) ) { top -= lineYOffset; - bottom += lineRenderer.height; + // without staff lines the layout covers the first staff row, so cached header profiles do not depend + // on the bar height; the painted length follows the renderer height at paint time + bottom += + lineRenderer.drawnLineCount === 0 + ? Math.ceil(lineRenderer.getLineHeight(lineRenderer.staffRowLineCount - 1)) + : lineRenderer.height; } else { top += lineRenderer.getLineY(0) - lineYOffset / 2; bottom += lineRenderer.getLineY(lineRenderer.drawnLineCount - 1) + lineYOffset / 2; @@ -422,6 +430,10 @@ export class BarLineGlyph extends LeftToRightLayoutingGlyphGroup { const lineBottom = nextStaff.y + nextStaff.topOverflow + renderer.smuflMetrics.staffLineThickness; actualLineHeight = lineBottom - lineTop; isExtended = true; + } else if (renderer.drawnLineCount === 0) { + // the height of the renderer is final only once the staff is finalized (e.g. numbered voice rows) + actualLineHeight = renderer.smuflMetrics.staffLineThickness + renderer.height; + isExtended = true; } if (this._isSingleLightLine) { diff --git a/packages/alphatab/src/rendering/glyphs/DeadSlappedBeatGlyph.ts b/packages/alphatab/src/rendering/glyphs/DeadSlappedBeatGlyph.ts index 1f856c26e..f3a2b2830 100644 --- a/packages/alphatab/src/rendering/glyphs/DeadSlappedBeatGlyph.ts +++ b/packages/alphatab/src/rendering/glyphs/DeadSlappedBeatGlyph.ts @@ -13,18 +13,18 @@ export class DeadSlappedBeatGlyph extends Glyph { } public override getBoundingBoxTop(): number { - return this._topY; + return this.y + this._topY; } public override getBoundingBoxBottom(): number { - return this._topY + this.height; + return this.y + this._topY + this.height; } public override doLayout(): void { this.width = this.renderer.smuflMetrics.glyphWidths.get(MusicFontSymbol.NoteheadSlashWhiteHalf)!; const renderer = this.renderer as LineBarRenderer; - const crossHeight = renderer.getLineHeight(renderer.heightLineCount - 1); + const crossHeight = renderer.getLineHeight(renderer.staffRowLineCount - 1); const staffTop = renderer.getLineY(0); const staffHeight = renderer.drawnLineCount > 0 ? renderer.getLineHeight(renderer.drawnLineCount - 1) : 0; @@ -39,7 +39,7 @@ export class DeadSlappedBeatGlyph extends Glyph { public override paint(cx: number, cy: number, canvas: ICanvas): void { const crossHeight = this.height; - const topY = this._topY; + const topY = this.y + this._topY; const lw = canvas.lineWidth; canvas.lineWidth = this.renderer.smuflMetrics.deadSlappedLineWidth; diff --git a/packages/alphatab/src/rendering/glyphs/NumberedBeatGlyph.ts b/packages/alphatab/src/rendering/glyphs/NumberedBeatGlyph.ts index fdb7cd869..ae982f951 100644 --- a/packages/alphatab/src/rendering/glyphs/NumberedBeatGlyph.ts +++ b/packages/alphatab/src/rendering/glyphs/NumberedBeatGlyph.ts @@ -1,10 +1,12 @@ import { EngravingSettings } from '@coderline/alphatab/EngravingSettings'; import { AccidentalType } from '@coderline/alphatab/model/AccidentalType'; -import { BeatSubElement } from '@coderline/alphatab/model/Beat'; +import type { Bar } from '@coderline/alphatab/model/Bar'; +import { type Beat, BeatSubElement } from '@coderline/alphatab/model/Beat'; import { Duration } from '@coderline/alphatab/model/Duration'; import { GraceType } from '@coderline/alphatab/model/GraceType'; +import type { KeySignature } from '@coderline/alphatab/model/KeySignature'; import { KeySignatureType } from '@coderline/alphatab/model/KeySignatureType'; -import { ModelUtils } from '@coderline/alphatab/model/ModelUtils'; +import { ModelUtils, type ResolvedSpelling } from '@coderline/alphatab/model/ModelUtils'; import { type Note, NoteSubElement } from '@coderline/alphatab/model/Note'; import { NoteAccidentalMode } from '@coderline/alphatab/model/NoteAccidentalMode'; import { NoteXPosition, NoteYPosition } from '@coderline/alphatab/rendering/BarRendererBase'; @@ -15,8 +17,11 @@ import { BeatGlyphBase } from '@coderline/alphatab/rendering/glyphs/BeatGlyphBas import { BeatOnNoteGlyphBase } from '@coderline/alphatab/rendering/glyphs/BeatOnNoteGlyphBase'; import { DeadSlappedBeatGlyph } from '@coderline/alphatab/rendering/glyphs/DeadSlappedBeatGlyph'; import type { Glyph } from '@coderline/alphatab/rendering/glyphs/Glyph'; +import { GlyphGroup } from '@coderline/alphatab/rendering/glyphs/GlyphGroup'; +import { NumberedNoteChordGlyph } from '@coderline/alphatab/rendering/glyphs/NumberedNoteChordGlyph'; import { NumberedNoteHeadGlyph } from '@coderline/alphatab/rendering/glyphs/NumberedNoteHeadGlyph'; import type { NumberedBarRenderer } from '@coderline/alphatab/rendering/NumberedBarRenderer'; +import type { NumberedBeatContainerGlyph } from '@coderline/alphatab/rendering/NumberedBeatContainerGlyph'; import { ElementStyleHelper } from '@coderline/alphatab/rendering/utils/ElementStyleHelper'; import type { IBoundsLookupWriter } from '@coderline/alphatab/rendering/utils/IBoundsLookupWriter'; @@ -24,8 +29,6 @@ import type { IBoundsLookupWriter } from '@coderline/alphatab/rendering/utils/IB * @internal */ export class NumberedBeatPreNotesGlyph extends BeatGlyphBase { - public accidental: AccidentalType = AccidentalType.None; - public skipLayout = false; protected override get effectElement() { @@ -37,62 +40,60 @@ export class NumberedBeatPreNotesGlyph extends BeatGlyphBase { return; } this.beginPlacement(); - if (!this.container.beat.isRest && !this.container.beat.isEmpty) { - const accidentals: AccidentalGroupGlyph = new AccidentalGroupGlyph(); - accidentals.renderer = this.renderer; + const beat = this.container.beat; + if (beat.isRest || beat.isEmpty || beat.notes.length === 0) { + return; + } - if (this.container.beat.notes.length > 0) { - const note = this.container.beat.notes[0]; + const onNotes = this.container.onNotes as NumberedBeatGlyph; + const chord = onNotes.chord; + let accidentals: AccidentalGroupGlyph | null = null; + if (chord) { + for (const head of chord.heads) { + accidentals = this._addAccidental(accidentals, head, chord.y + head.y); + } + } else if (onNotes.noteHead?.note) { + accidentals = this._addAccidental(accidentals, onNotes.noteHead, onNotes.noteHead.y); + } - const spelling = ModelUtils.resolveSpelling( - this.renderer.bar.keySignature, - note.displayValue, - note.accidentalMode - ); + if (accidentals) { + this.placeNormal(accidentals); + this.placeSpacing(this.renderer.smuflMetrics.preNoteEffectPadding); + } + } - const ksOffset = ModelUtils.getKeySignatureAccidentalOffset( - this.renderer.bar.keySignature, - spelling.degree - ); - const requiredOffset = spelling.accidentalOffset - ksOffset; - - let accidentalToSet: AccidentalType = AccidentalType.None; - if (note.accidentalMode !== NoteAccidentalMode.ForceNone) { - if (note.hasQuarterToneOffset) { - if (requiredOffset > 0) { - accidentalToSet = AccidentalType.SharpQuarterNoteUp; - } else if (requiredOffset < 0) { - accidentalToSet = AccidentalType.FlatQuarterNoteUp; - } else { - accidentalToSet = AccidentalType.NaturalQuarterNoteUp; - } - } else if (requiredOffset !== 0) { - accidentalToSet = ModelUtils.accidentalOffsetToType(requiredOffset); - } - } + private _addAccidental( + accidentals: AccidentalGroupGlyph | null, + head: NumberedNoteHeadGlyph, + y: number + ): AccidentalGroupGlyph | null { + if (head.accidentalType === AccidentalType.None) { + return accidentals; + } - // do we need an accidental on the note? - if (accidentalToSet !== AccidentalType.None) { - this.accidental = accidentalToSet; - const sr: NumberedBarRenderer = this.renderer as NumberedBarRenderer; - const color = ElementStyleHelper.noteColor(sr.resources, NoteSubElement.NumberedAccidentals, note); - - const g = new AccidentalGlyph( - 0, - sr.getLineY(0), - accidentalToSet, - note.beat.graceType !== GraceType.None - ? EngravingSettings.GraceScale * EngravingSettings.GraceScale - : EngravingSettings.GraceScale - ); - g.colorOverride = color; - g.renderer = this.renderer; - accidentals.addGlyph(g); - this.placeNormal(accidentals); - this.placeSpacing(this.renderer.smuflMetrics.preNoteEffectPadding); - } - } + if (!accidentals) { + accidentals = new AccidentalGroupGlyph(); + accidentals.renderer = this.renderer; } + + const note = head.note!; + const g = new AccidentalGlyph( + 0, + y, + head.accidentalType, + note.beat.graceType !== GraceType.None + ? EngravingSettings.GraceScale * EngravingSettings.GraceScale + : EngravingSettings.GraceScale + ); + g.colorOverride = ElementStyleHelper.noteColor( + this.renderer.resources, + NoteSubElement.NumberedAccidentals, + note + ); + g.renderer = this.renderer; + accidentals.addGlyph(g); + head.accidental = g; + return accidentals; } } @@ -100,22 +101,89 @@ export class NumberedBeatPreNotesGlyph extends BeatGlyphBase { * @internal */ export class NumberedBeatGlyph extends BeatOnNoteGlyphBase { - public noteHeads: NumberedNoteHeadGlyph | null = null; + /** + * The number of a rest or of a beat with a single (visible) note. + */ + public noteHead: NumberedNoteHeadGlyph | null = null; + + /** + * The stacked numbers of a beat with multiple visible notes. + */ + public chord: NumberedNoteChordGlyph | null = null; + public deadSlapped: DeadSlappedBeatGlyph | null = null; + /** + * Moves the numbers with their accidentals and augmentation dots to the current slots of the renderer. + */ + public updateVerticalPositions(): void { + const beat = this.container.beat; + if (beat.isEmpty) { + return; + } + const sr = this.renderer as NumberedBarRenderer; + const glyphY = sr.getVoiceLineY(beat.voice.index); + const deadSlapped = this.deadSlapped; + if (deadSlapped) { + deadSlapped.y = glyphY - sr.getLineY(0); + } + const chord = this.chord; + if (chord) { + chord.y = glyphY; + chord.placeHeads(); + for (const head of chord.heads) { + head.updateAttachedY(chord.y); + } + return; + } + const noteHead = this.noteHead; + if (noteHead) { + noteHead.y = glyphY; + noteHead.updateAttachedY(0); + } + // the dots of a beat without chord sit on the line of its row + const glyphs = this.glyphs; + if (glyphs) { + for (const g of glyphs) { + if (g instanceof AugmentationDotGlyph) { + g.y = glyphY; + } + } + } + } + protected override get effectElement() { return BeatSubElement.NumberedEffects; } - public override getNoteX(_note: Note, requestedPosition: NoteXPosition): number { + + private get _mainGlyph(): Glyph | null { + if (this.chord) { + return this.chord; + } + if (this.noteHead) { + return this.noteHead; + } + return this.deadSlapped; + } + + public override getNoteX(note: Note, requestedPosition: NoteXPosition): number { + const chord = this.chord; let g: Glyph | null = null; - if (this.noteHeads) { - g = this.noteHeads; - } else if (this.deadSlapped) { - g = this.deadSlapped; + let x = 0; + if (chord) { + const head = chord.getHead(note); + if (head) { + g = head; + x = chord.x; + } else { + g = chord; + } + } else { + g = this._mainGlyph; } if (g) { - let pos = g.x; + let pos = x + g.x; switch (requestedPosition) { case NoteXPosition.Left: break; @@ -132,12 +200,23 @@ export class NumberedBeatGlyph extends BeatOnNoteGlyphBase { } public override buildBoundingsLookup(writer: IBoundsLookupWriter, cx: number, cy: number) { - if (this.noteHeads && this.container.beat.notes.length > 0) { - const scale = this.renderer.settings.display.scale; + const scale = this.renderer.settings.display.scale; + const chord = this.chord; + if (chord) { + for (const head of chord.heads) { + writer.addNote( + head.note!, + (cx + this.x + chord.x + head.x) * scale, + (cy + this.y + chord.y + head.y - head.height / 2) * scale, + head.width * scale, + head.height * scale + ); + } + } else if (this.noteHead?.note) { writer.addNote( - this.container.beat.notes[0], - (cx + this.x + this.noteHeads.x) * scale, - (cy + this.y + this.noteHeads.y - this.noteHeads.height / 2) * scale, + this.noteHead.note, + (cx + this.x + this.noteHead.x) * scale, + (cy + this.y + this.noteHead.y - this.noteHead.height / 2) * scale, this.width * scale, this.height * scale ); @@ -145,52 +224,70 @@ export class NumberedBeatGlyph extends BeatOnNoteGlyphBase { } public override getLowestNoteY(requestedPosition: NoteYPosition): number { - return this._internalGetNoteY(requestedPosition); + const chord = this.chord; + if (chord) { + return NumberedBeatGlyph._getHeadY(this.y + chord.y, chord.lowestHead, requestedPosition); + } + return this._getMainY(requestedPosition); } public override getHighestNoteY(requestedPosition: NoteYPosition): number { - return this._internalGetNoteY(requestedPosition); + const chord = this.chord; + if (chord) { + return NumberedBeatGlyph._getHeadY(this.y + chord.y, chord.highestHead, requestedPosition); + } + return this._getMainY(requestedPosition); } - public override getNoteY(_note: Note, requestedPosition: NoteYPosition): number { - return this._internalGetNoteY(requestedPosition); + public override getNoteY(note: Note, requestedPosition: NoteYPosition): number { + const chord = this.chord; + if (chord) { + const head = chord.getHead(note); + if (head) { + return NumberedBeatGlyph._getHeadY(this.y + chord.y, head, requestedPosition); + } + } + return this._getMainY(requestedPosition); } public override getRestY(requestedPosition: NoteYPosition): number { - return this._internalGetNoteY(requestedPosition); + return this._getMainY(requestedPosition); } - private _internalGetNoteY(requestedPosition: NoteYPosition): number { - let g: Glyph | null = null; - if (this.noteHeads) { - g = this.noteHeads; - } else if (this.deadSlapped) { - g = this.deadSlapped; + private _getMainY(requestedPosition: NoteYPosition): number { + let g: Glyph | null = this.deadSlapped; + if (this.chord) { + g = this.chord.lowestHead; + } else if (this.noteHead) { + g = this.noteHead; } + if (!g) { + return 0; + } + return NumberedBeatGlyph._getHeadY(this.chord ? this.y + this.chord.y : this.y, g, requestedPosition); + } - if (g) { - let pos = this.y + g.y; - - switch (requestedPosition) { - case NoteYPosition.Top: - case NoteYPosition.TopWithStem: - pos -= g.height / 2; - break; - case NoteYPosition.Center: - break; - case NoteYPosition.Bottom: - case NoteYPosition.BottomWithStem: - pos += g.height / 2; - break; - - case NoteYPosition.StemUp: - case NoteYPosition.StemDown: - break; - } - - return pos; + private static _getHeadY(offsetY: number, g: Glyph, requestedPosition: NoteYPosition): number { + let pos = offsetY + g.y; + + switch (requestedPosition) { + case NoteYPosition.Top: + case NoteYPosition.TopWithStem: + pos -= g.height / 2; + break; + case NoteYPosition.Center: + break; + case NoteYPosition.Bottom: + case NoteYPosition.BottomWithStem: + pos += g.height / 2; + break; + + case NoteYPosition.StemUp: + case NoteYPosition.StemDown: + break; } - return 0; + + return pos; } private static readonly _majorKeySignatureOneValues: Array = [ @@ -211,92 +308,199 @@ export class NumberedBeatGlyph extends BeatOnNoteGlyphBase { 64, 71, 66, 61, 68, 63, 70 ]; - public override doLayout(): void { - // create glyphs - const sr = this.renderer as NumberedBarRenderer; + /** + * The octave of the note relative to the "1" of the bar's key: positive values are dots above + * the number, negative values dots below. + */ + private static _getOctave(bar: Bar, note: Note): number { + const oneNoteValues = + bar.keySignatureType === KeySignatureType.Minor + ? NumberedBeatGlyph._minorKeySignatureOneValues + : NumberedBeatGlyph._majorKeySignatureOneValues; + const oneNoteValue = oneNoteValues[(bar.keySignature as number) + 7]; + return Math.floor((note.displayValue - oneNoteValue) / 12); + } - if (sr.shortestDuration < this.container.beat.duration) { - sr.shortestDuration = this.container.beat.duration; + /** + * Creates the numbers of the beat; the renderer reads the slots they need before the layout. + */ + public createNotes(renderer: NumberedBarRenderer, beat: Beat): void { + if (beat.isEmpty || beat.deadSlapped) { + return; + } + const bar = renderer.bar; + const isGrace = beat.graceType !== GraceType.None; + if (beat.notes.length === 0) { + this.noteHead = NumberedBeatGlyph._createNoteHead(bar, beat, null, isGrace); + return; + } + if (renderer.isSingleNote) { + const single = renderer.getSingleNote(beat); + if (single) { + this.noteHead = NumberedBeatGlyph._createNoteHead(bar, beat, single, isGrace); + } + return; } - let octaveDots = 0; - - if (!this.container.beat.isEmpty) { - const glyphY = sr.getLineY(0); - let numberWithinOctave = '0'; - if (this.container.beat.notes.length > 0) { - const note = this.container.beat.notes[0]; - if (note.isDead) { - numberWithinOctave = 'X'; - } else { - const ks = this.renderer.bar.keySignature; - const kst = this.renderer.bar.keySignatureType; - const ksi = (ks as number) + 7; + let first: NumberedNoteHeadGlyph | null = null; + let heads: NumberedNoteHeadGlyph[] | null = null; + for (const n of beat.notes) { + if (!n.isVisible) { + continue; + } + const head = NumberedBeatGlyph._createNoteHead(bar, beat, n, isGrace); + if (!first) { + first = head; + } else if (!heads) { + heads = [first, head]; + } else { + heads.push(head); + } + } + if (heads) { + this.chord = new NumberedNoteChordGlyph(0, 0, heads, beat.voice.index, isGrace); + } else { + this.noteHead = first; + } + } - const oneNoteValues = - kst === KeySignatureType.Minor - ? NumberedBeatGlyph._minorKeySignatureOneValues - : NumberedBeatGlyph._majorKeySignatureOneValues; + /** + * Creates the number of the note, or of a rest when no note is given. + */ + private static _createNoteHead(bar: Bar, beat: Beat, note: Note | null, isGrace: boolean): NumberedNoteHeadGlyph { + if (!note) { + return new NumberedNoteHeadGlyph(0, 0, '0', isGrace, beat, null, 0, 0); + } + const ks = bar.keySignature; + const kst = bar.keySignatureType; + const spelling = ModelUtils.resolveSpelling(ks, note.displayValue, note.accidentalMode); + const tonicDegree = ModelUtils.getKeySignatureTonicDegree(ks, kst); + const effectiveTonic = + kst === KeySignatureType.Minor + ? (tonicDegree + 2) % 7 // relative major + : tonicDegree; + const degree = (spelling.degree - effectiveTonic + 7) % 7; + const octave = NumberedBeatGlyph._getOctave(bar, note); + const head = new NumberedNoteHeadGlyph( + 0, + 0, + note.isDead ? 'X' : (degree + 1).toString(), + isGrace, + beat, + note, + note.isDead ? 0 : octave, + octave * 7 + degree + ); + head.accidentalType = NumberedBeatGlyph._resolveAccidental(ks, note, spelling); + return head; + } - const oneNoteValue = oneNoteValues[ksi]; + private static _resolveAccidental( + keySignature: KeySignature, + note: Note, + spelling: ResolvedSpelling + ): AccidentalType { + if (note.accidentalMode === NoteAccidentalMode.ForceNone) { + return AccidentalType.None; + } - const spelling = ModelUtils.resolveSpelling(ks, note.displayValue, note.accidentalMode); + const ksOffset = ModelUtils.getKeySignatureAccidentalOffset(keySignature, spelling.degree); + const requiredOffset = spelling.accidentalOffset - ksOffset; - const tonicDegree = ModelUtils.getKeySignatureTonicDegree(ks, kst); + if (note.hasQuarterToneOffset) { + if (requiredOffset > 0) { + return AccidentalType.SharpQuarterNoteUp; + } + if (requiredOffset < 0) { + return AccidentalType.FlatQuarterNoteUp; + } + return AccidentalType.NaturalQuarterNoteUp; + } + if (requiredOffset !== 0) { + return ModelUtils.accidentalOffsetToType(requiredOffset); + } + return AccidentalType.None; + } - const effectiveTonic = - kst === KeySignatureType.Minor - ? (tonicDegree + 2) % 7 // relative major - : tonicDegree; + /** + * Places the numbers on their row ahead of {@link doLayout}, + * as the accidentals of the pre-notes need their vertical positions. + */ + public prepareNotes(): void { + const sr = this.renderer as NumberedBarRenderer; + const glyphY = sr.getVoiceLineY(this.container.beat.voice.index); + const underlinesHeight = (this.container as NumberedBeatContainerGlyph).underlinesHeight; + const chord = this.chord; + if (chord) { + chord.y = glyphY; + chord.lowestHead.underlinesHeight = underlinesHeight; + chord.renderer = this.renderer; + chord.doLayout(); + } else if (this.noteHead) { + this.noteHead.y = glyphY; + this.noteHead.underlinesHeight = underlinesHeight; + } + } - const degreeDistance = (spelling.degree - effectiveTonic + 7) % 7; - numberWithinOctave = (degreeDistance + 1).toString(); + public override doLayout(): void { + // create glyphs + const sr = this.renderer as NumberedBarRenderer; + const beat = this.container.beat; - const noteValue = note.displayValue - oneNoteValue; - octaveDots = Math.floor(noteValue / 12); - } - } + if (sr.shortestDuration < beat.duration) { + sr.shortestDuration = beat.duration; + } - if (this.container.beat.deadSlapped) { + if (!beat.isEmpty) { + const glyphY = sr.getVoiceLineY(beat.voice.index); + if (beat.deadSlapped) { const deadSlapped = new DeadSlappedBeatGlyph(); + deadSlapped.y = glyphY - sr.getLineY(0); deadSlapped.renderer = this.renderer; deadSlapped.doLayout(); this.deadSlapped = deadSlapped; this.addEffect(deadSlapped); - } else { - const isGrace: boolean = this.container.beat.graceType !== GraceType.None; - const noteHeadGlyph = new NumberedNoteHeadGlyph( - 0, - glyphY, - numberWithinOctave, - isGrace, - this.container.beat, - octaveDots - ); - this.noteHeads = noteHeadGlyph; - - this.addNormal(noteHeadGlyph); + } else if (this.chord) { + this.addNormal(this.chord); + } else if (this.noteHead) { + this.addNormal(this.noteHead); } // // Note dots - if (this.container.beat.dots > 0 && this.container.beat.duration >= Duration.Quarter) { - for (let i: number = 0; i < this.container.beat.dots; i++) { - const dot = new AugmentationDotGlyph(0, glyphY); - dot.renderer = this.renderer; - this.addEffect(dot); + if (beat.dots > 0 && beat.duration >= Duration.Quarter) { + const chord = this.chord; + for (let i: number = 0; i < beat.dots; i++) { + if (chord) { + const column = new GlyphGroup(0, 0); + column.renderer = this.renderer; + for (const head of chord.heads) { + const dot = new AugmentationDotGlyph(0, chord.y + head.y); + column.addGlyph(dot); + const dots = head.dots; + if (dots) { + dots.push(dot); + } else { + head.dots = [dot]; + } + } + this.addEffect(column); + } else { + const dot = new AugmentationDotGlyph(0, glyphY); + dot.renderer = this.renderer; + this.addEffect(dot); + } } } } super.doLayout(); - if (this.container.beat.isEmpty) { + const main = this._mainGlyph; + if (beat.isEmpty) { this.onTimeX = this.width / 2; - } else if (this.noteHeads) { - this.onTimeX = this.noteHeads.x + this.noteHeads.width / 2; - } else if (this.deadSlapped) { - this.onTimeX = this.deadSlapped.x + this.deadSlapped.width / 2; + } else if (main) { + this.onTimeX = main.x + main.width / 2; } this.middleX = this.onTimeX; this.stemX = this.middleX; diff --git a/packages/alphatab/src/rendering/glyphs/NumberedDashBeatContainerGlyph.ts b/packages/alphatab/src/rendering/glyphs/NumberedDashBeatContainerGlyph.ts index 01f0ffbc4..8e2d84f80 100644 --- a/packages/alphatab/src/rendering/glyphs/NumberedDashBeatContainerGlyph.ts +++ b/packages/alphatab/src/rendering/glyphs/NumberedDashBeatContainerGlyph.ts @@ -97,6 +97,9 @@ export class NumberedNoteBeatContainerGlyphBase extends NumberedBeatContainerGly } public override buildBoundingsLookup(_writer: IBoundsLookupWriter, _cx: number, _cy: number): void {} + + // ties and slurs belong to the main number of the beat + protected override createTies(_n: Note): void {} } /** @@ -185,7 +188,7 @@ export class NumberedDashBeatContainerGlyph extends BeatContainerGlyphBase imple const renderer = this.renderer as NumberedBarRenderer; const dashWidth = renderer.smuflMetrics.numberedDashGlyphWidth; const dashHeight = renderer.smuflMetrics.numberedBarRendererBarSize; - const dashY = Math.ceil(cy + renderer.getLineY(0) - dashHeight); + const dashY = Math.ceil(cy + renderer.getVoiceLineY(this._voiceIndex) - dashHeight); canvas.fillRect(cx + this.x, dashY, dashWidth, dashHeight); } } diff --git a/packages/alphatab/src/rendering/glyphs/NumberedNoteChordGlyph.ts b/packages/alphatab/src/rendering/glyphs/NumberedNoteChordGlyph.ts new file mode 100644 index 000000000..24509367f --- /dev/null +++ b/packages/alphatab/src/rendering/glyphs/NumberedNoteChordGlyph.ts @@ -0,0 +1,118 @@ +import type { Note } from '@coderline/alphatab/model/Note'; +import type { ICanvas } from '@coderline/alphatab/platform/ICanvas'; +import { Glyph } from '@coderline/alphatab/rendering/glyphs/Glyph'; +import type { NumberedNoteHeadGlyph } from '@coderline/alphatab/rendering/glyphs/NumberedNoteHeadGlyph'; +import type { NumberedBarRenderer } from '@coderline/alphatab/rendering/NumberedBarRenderer'; + +/** + * The numbers of a chord in numbered notation, stacked vertically with the lowest note at y=0: + * on the chord slots of the voice row, grace chords right above the octave dots of the note below. + * @internal + */ +export class NumberedNoteChordGlyph extends Glyph { + /** + * The note numbers ordered from the lowest (bottom) to the highest (top) note. + */ + public readonly heads: NumberedNoteHeadGlyph[]; + + private _voiceIndex: number; + private _isGrace: boolean; + + public constructor(x: number, y: number, heads: NumberedNoteHeadGlyph[], voiceIndex: number, isGrace: boolean) { + super(x, y); + NumberedNoteChordGlyph._sortBottomUp(heads); + this.heads = heads; + this._voiceIndex = voiceIndex; + this._isGrace = isGrace; + } + + private static _sortBottomUp(heads: NumberedNoteHeadGlyph[]) { + for (let i = 1, j = heads.length; i < j; i++) { + const head = heads[i]; + let k = i - 1; + while (k >= 0 && NumberedNoteChordGlyph._isAbove(heads[k], head)) { + heads[k + 1] = heads[k]; + k--; + } + heads[k + 1] = head; + } + } + + private static _isAbove(a: NumberedNoteHeadGlyph, b: NumberedNoteHeadGlyph): boolean { + if (a.step !== b.step) { + return a.step > b.step; + } + return a.note!.displayValue > b.note!.displayValue; + } + + public getHead(note: Note): NumberedNoteHeadGlyph | null { + for (const h of this.heads) { + if (h.note === note) { + return h; + } + } + return null; + } + + public get lowestHead(): NumberedNoteHeadGlyph { + return this.heads[0]; + } + + public get highestHead(): NumberedNoteHeadGlyph { + return this.heads[this.heads.length - 1]; + } + + public override getBoundingBoxTop(): number { + return this.y + this.highestHead.getBoundingBoxTop(); + } + + public override getBoundingBoxBottom(): number { + return this.y + this.lowestHead.getBoundingBoxBottom(); + } + + public override doLayout(): void { + let width = 0; + for (const h of this.heads) { + h.renderer = this.renderer; + h.doLayout(); + if (h.width > width) { + width = h.width; + } + } + for (const h of this.heads) { + h.x = (width - h.width) / 2; + } + this.width = width; + this.placeHeads(); + } + + /** + * Places the numbers above the lowest one on the chord positions of the row, grace numbers right above + * the number below. The numbers must be laid out. + */ + public placeHeads(): void { + const sr = this.renderer as NumberedBarRenderer; + const gap = sr.resources.engravingSettings.numberedChordNoteGap; + const baseY = sr.getNumberY(this._voiceIndex, 0); + const heads = this.heads; + heads[0].y = 0; + for (let index = 1; index < heads.length; index++) { + const h = heads[index]; + if (this._isGrace) { + // grace numbers are small and do not line up with other chords + const previous = heads[index - 1]; + h.y = 0; + h.y = previous.y - (previous.y - previous.getBoundingBoxTop() + gap + h.getBoundingBoxBottom()); + } else { + h.y = sr.getNumberY(this._voiceIndex, index) - baseY; + } + } + this.height = this.lowestHead.getBoundingBoxBottom() - this.highestHead.getBoundingBoxTop(); + } + + public override paint(cx: number, cy: number, canvas: ICanvas): void { + for (const h of this.heads) { + h.paint(cx + this.x, cy + this.y, canvas); + } + } +} diff --git a/packages/alphatab/src/rendering/glyphs/NumberedNoteHeadGlyph.ts b/packages/alphatab/src/rendering/glyphs/NumberedNoteHeadGlyph.ts index f2b90e8ae..87a2c6959 100644 --- a/packages/alphatab/src/rendering/glyphs/NumberedNoteHeadGlyph.ts +++ b/packages/alphatab/src/rendering/glyphs/NumberedNoteHeadGlyph.ts @@ -1,54 +1,159 @@ +import type { EngravingSettings } from '@coderline/alphatab/EngravingSettings'; +import { AccidentalType } from '@coderline/alphatab/model/AccidentalType'; import { type Beat, BeatSubElement } from '@coderline/alphatab/model/Beat'; import { MusicFontSymbol } from '@coderline/alphatab/model/MusicFontSymbol'; -import { NoteSubElement } from '@coderline/alphatab/model/Note'; +import { type Note, NoteSubElement } from '@coderline/alphatab/model/Note'; import { CanvasHelper, type ICanvas, TextAlign, TextBaseline } from '@coderline/alphatab/platform/ICanvas'; +import type { AccidentalGlyph } from '@coderline/alphatab/rendering/glyphs/AccidentalGlyph'; +import type { AugmentationDotGlyph } from '@coderline/alphatab/rendering/glyphs/AugmentationDotGlyph'; import { Glyph } from '@coderline/alphatab/rendering/glyphs/Glyph'; import { ElementStyleHelper } from '@coderline/alphatab/rendering/utils/ElementStyleHelper'; /** + * The number of a single note (or rest) in numbered notation including its octave dots. * @internal */ export class NumberedNoteHeadGlyph extends Glyph { private _isGrace: boolean; private _beat: Beat; private _number: string; - private _octaveDots: number; private _octaveDotsY: number = 0; private _octaveDotHeight: number = 0; - public constructor(x: number, y: number, number: string, isGrace: boolean, beat: Beat, octaveDots: number) { + /** + * The note this number represents, null for rests. + */ + public readonly note: Note | null; + + /** + * The octave dots of the number: positive values above the number, negative values below. + */ + public readonly octaveDots: number; + + /** + * The position of the number on the jianpu scale (octave * 7 + degree), used to stack chords. + */ + public readonly step: number; + + /** + * The space between the number and its low octave dots, taken by the duration underlines. + */ + public underlinesHeight: number = 0; + + /** + * The accidental the note needs in front of the number. + */ + public accidentalType: AccidentalType = AccidentalType.None; + + /** + * The accidental written in front of the number, positioned at the number. + */ + public accidental: AccidentalGlyph | null = null; + + /** + * The augmentation dots written after the number of a chord, positioned at the number. + */ + public dots: AugmentationDotGlyph[] | null = null; + + public constructor( + x: number, + y: number, + number: string, + isGrace: boolean, + beat: Beat, + note: Note | null, + octaveDots: number, + step: number + ) { super(x, y); this._isGrace = isGrace; this._number = number; this._beat = beat; - this._octaveDots = octaveDots; + this.note = note; + this.octaveDots = octaveDots; + this.step = step; } - public override getBoundingBoxTop(): number { - let y = -this.height / 2; - - if (this._octaveDots > 0 && this._octaveDotsY < y) { - y = this._octaveDotsY; + /** + * Moves the accidental and the augmentation dots to the number. + * @param offsetY The position of the parent of the number within the beat glyph. + */ + public updateAttachedY(offsetY: number): void { + const y = offsetY + this.y; + const accidental = this.accidental; + if (accidental) { + accidental.y = y; + } + const dots = this.dots; + if (dots) { + for (const d of dots) { + d.y = y; + } } - return this.y + y; } - public override getBoundingBoxBottom(): number { - let y = this.height / 2; + /** + * The distance from the vertical center of a number to the top of the number or its octave dots above. + */ + public static getTopExtent(settings: EngravingSettings, numberHeight: number, octaveDots: number): number { + const extent = numberHeight / 2; + if (octaveDots <= 0) { + return extent; + } + const dotsExtent = + extent + + octaveDots * settings.glyphHeights.get(MusicFontSymbol.AugmentationDot)! * 2 + + settings.glyphTop.get(MusicFontSymbol.AugmentationDot)!; + return Math.max(dotsExtent, extent); + } - const dotsBottom = this._octaveDotsY + Math.abs(this._octaveDots) * this._octaveDotHeight * 2; - if (this._octaveDots < 0 && y < dotsBottom) { - y = dotsBottom; + /** + * The distance from the vertical center of a number to the bottom of the number or its octave dots below. + */ + public static getBottomExtent( + settings: EngravingSettings, + numberHeight: number, + octaveDots: number, + underlinesHeight: number + ): number { + const extent = numberHeight / 2; + if (octaveDots >= 0) { + return extent; } + const dotHeight = settings.glyphHeights.get(MusicFontSymbol.AugmentationDot)!; + const dotsExtent = + extent + + underlinesHeight + + dotHeight + + settings.glyphTop.get(MusicFontSymbol.AugmentationDot)! + + -octaveDots * dotHeight * 2; + return Math.max(dotsExtent, extent); + } - return this.y + y; + public override getBoundingBoxTop(): number { + return ( + this.y - + NumberedNoteHeadGlyph.getTopExtent(this.renderer.resources.engravingSettings, this.height, this.octaveDots) + ); + } + + public override getBoundingBoxBottom(): number { + return ( + this.y + + NumberedNoteHeadGlyph.getBottomExtent( + this.renderer.resources.engravingSettings, + this.height, + this.octaveDots, + this.underlinesHeight + ) + ); } public override paint(cx: number, cy: number, canvas: ICanvas): void { - const previousColor = this._beat.isRest - ? ElementStyleHelper.applyBeat(canvas, BeatSubElement.NumberedRests, this._beat) - : this._beat.notes.length > 0 - ? ElementStyleHelper.applyNote(canvas, NoteSubElement.NumberedNumber, this._beat.notes[0]) + const previousColor = this.note + ? ElementStyleHelper.applyNote(canvas, NoteSubElement.NumberedNumber, this.note) + : this._beat.isRest + ? ElementStyleHelper.applyBeat(canvas, BeatSubElement.NumberedRests, this._beat) : undefined; try { const res = this.renderer.resources; @@ -59,7 +164,7 @@ export class NumberedNoteHeadGlyph extends Glyph { canvas.fillText(this._number.toString(), cx + this.x, cy + this.y); canvas.textBaseline = baseline; - const dotCount = Math.abs(this._octaveDots); + const dotCount = Math.abs(this.octaveDots); let dotsY = this._octaveDotsY + res.engravingSettings.glyphTop.get(MusicFontSymbol.AugmentationDot)!; for (let d = 0; d < dotCount; d++) { CanvasHelper.fillMusicFontSymbolSafe( @@ -82,11 +187,11 @@ export class NumberedNoteHeadGlyph extends Glyph { const font = this._isGrace ? res.numberedNotationGraceFont : res.numberedNotationFont; const c = this.renderer.scoreRenderer.canvas!; c.font = font; - const size = c.measureText(`${this._number}`); + const size = this.renderer.scoreRenderer.layout!.textMeasures.measureText(c, this._number); this.height = size.height; this.width = size.width; - const dotCount = this._octaveDots; + const dotCount = this.octaveDots; const dotHeight = res.engravingSettings.glyphHeights.get(MusicFontSymbol.AugmentationDot)!; const allDotsHeight = Math.abs(dotCount) * dotHeight * 2; @@ -98,6 +203,8 @@ export class NumberedNoteHeadGlyph extends Glyph { } else if (dotCount < 0) { this._octaveDotsY = this.height / 2 + + // low octave dots go below the duration underlines + this.underlinesHeight + // one for the padding dotHeight + // align the dots diff --git a/packages/alphatab/src/rendering/glyphs/NumberedSlurGlyph.ts b/packages/alphatab/src/rendering/glyphs/NumberedSlurGlyph.ts index 0f43e0f2a..d25f736d4 100644 --- a/packages/alphatab/src/rendering/glyphs/NumberedSlurGlyph.ts +++ b/packages/alphatab/src/rendering/glyphs/NumberedSlurGlyph.ts @@ -1,6 +1,5 @@ import type { Note } from '@coderline/alphatab/model/Note'; import { TabSlurGlyph } from '@coderline/alphatab/rendering/glyphs/TabSlurGlyph'; -import { TabTieGlyph } from '@coderline/alphatab/rendering/glyphs/TabTieGlyph'; import { BeamDirection } from '@coderline/alphatab/rendering/utils/BeamDirection'; /** @@ -34,28 +33,12 @@ export class NumberedSlurGlyph extends TabSlurGlyph { if (isForEnd !== forEnd) { return false; } - // same draw direction - if (this.tieDirection !== TabTieGlyph.getBeamDirectionForNote(startNote)) { - return false; + // slurs arc above the numbers, so they attach to the highest note + if (startNote.realValue > this.startNote.realValue) { + this.startNote = startNote; } - // if we can expand, expand in correct direction - switch (this.tieDirection) { - case BeamDirection.Up: - if (startNote.realValue > this.startNote.realValue) { - this.startNote = startNote; - } - if (endNote.realValue > this.endNote.realValue) { - this.endNote = endNote; - } - break; - case BeamDirection.Down: - if (startNote.realValue < this.startNote.realValue) { - this.startNote = startNote; - } - if (endNote.realValue < this.endNote.realValue) { - this.endNote = endNote; - } - break; + if (endNote.realValue > this.endNote.realValue) { + this.endNote = endNote; } return true; } diff --git a/packages/alphatab/src/rendering/glyphs/NumberedTimeSignatureGlyph.ts b/packages/alphatab/src/rendering/glyphs/NumberedTimeSignatureGlyph.ts new file mode 100644 index 000000000..69dffefb8 --- /dev/null +++ b/packages/alphatab/src/rendering/glyphs/NumberedTimeSignatureGlyph.ts @@ -0,0 +1,32 @@ +import { VoiceSubElement } from '@coderline/alphatab/model/Voice'; +import type { ICanvas } from '@coderline/alphatab/platform/ICanvas'; +import { ScoreTimeSignatureGlyph } from '@coderline/alphatab/rendering/glyphs/ScoreTimeSignatureGlyph'; +import type { NumberedBarRenderer } from '@coderline/alphatab/rendering/NumberedBarRenderer'; +import { ElementStyleHelper } from '@coderline/alphatab/rendering/utils/ElementStyleHelper'; + +/** + * The time signature of a numbered staff, written in front of every voice row of the bar. + * The copies in front of further voices take the colour of their voice, a bar style applies to all copies. + * Its bounding box covers the first row only. + * @internal + */ +export class NumberedTimeSignatureGlyph extends ScoreTimeSignatureGlyph { + public override paint(cx: number, cy: number, canvas: ICanvas): void { + // the rows are final only once the staff is aligned in its system + const renderer = this.renderer as NumberedBarRenderer; + const firstRowY = renderer.getLineY(0); + const voices = renderer.bar.voices; + for (let v = 0; v < voices.length; v++) { + if (!renderer.isRowWritten(v)) { + continue; + } + const previousColor = + v > 0 ? ElementStyleHelper.applyVoice(canvas, VoiceSubElement.Glyphs, voices[v], true) : undefined; + try { + super.paint(cx, cy + renderer.getVoiceLineY(v) - firstRowY, canvas); + } finally { + ElementStyleHelper.restore(canvas, previousColor); + } + } + } +} diff --git a/packages/alphatab/src/rendering/layout/HorizontalScreenLayout.ts b/packages/alphatab/src/rendering/layout/HorizontalScreenLayout.ts index f2ac50939..22e8d2b70 100644 --- a/packages/alphatab/src/rendering/layout/HorizontalScreenLayout.ts +++ b/packages/alphatab/src/rendering/layout/HorizontalScreenLayout.ts @@ -12,7 +12,6 @@ import type { StaffSystem } from '@coderline/alphatab/rendering/staves/StaffSyst * @internal */ export class HorizontalScreenLayoutPartialInfo { - public x: number = 0; public width: number = 0; public masterBars: MasterBar[] = []; public results: MasterBarsRenderers[] = []; @@ -113,11 +112,8 @@ export class HorizontalScreenLayout extends ScoreLayout { currentPartial = this._completePartial(partials, currentPartial); } - this._scaleBars(result); - currentPartial.results.push(result); currentPartial.masterBars.push(score.masterBars[currentBarIndex]); - currentPartial.width += result.width; currentBarIndex++; } @@ -125,6 +121,15 @@ export class HorizontalScreenLayout extends ScoreLayout { if (currentPartial.masterBars.length > 0) { this._completePartial(partials, currentPartial); } + + // as in the page layouts, the bars are scaled once the system holds all of them + this._system.resetAllStavesOverflows(); + for (const partial of partials) { + for (const result of partial.results) { + this._scaleBars(result); + partial.width += result.width; + } + } this._firstPartialBarCount = partials.length > 0 ? partials[0].masterBars.length : 0; this._finalizeStaffSystem(); @@ -222,9 +227,7 @@ export class HorizontalScreenLayout extends ScoreLayout { ); // start new partial - const newPartial = new HorizontalScreenLayoutPartialInfo(); - newPartial.x = currentPartial.x + currentPartial.width; - return newPartial; + return new HorizontalScreenLayoutPartialInfo(); } private _finalizeStaffSystem() { diff --git a/packages/alphatab/src/rendering/staves/RenderStaff.ts b/packages/alphatab/src/rendering/staves/RenderStaff.ts index a641bd93f..1ae56f513 100644 --- a/packages/alphatab/src/rendering/staves/RenderStaff.ts +++ b/packages/alphatab/src/rendering/staves/RenderStaff.ts @@ -348,6 +348,7 @@ export class RenderStaff implements IStaffDisplayContext { public resetOverflows() { this.topOverflow = 0; this.bottomOverflow = 0; + this.staffBottom = 0; } public topOverflow = 0; diff --git a/packages/alphatab/src/rendering/staves/StaffSystem.ts b/packages/alphatab/src/rendering/staves/StaffSystem.ts index fdec84c8d..8ada149b4 100644 --- a/packages/alphatab/src/rendering/staves/StaffSystem.ts +++ b/packages/alphatab/src/rendering/staves/StaffSystem.ts @@ -855,8 +855,8 @@ export class StaffSystem { } /** - * Clears the staff overflows before the bars of a fully assembled system are scaled; the bars - * register them again from their positioned content, the effect placement adds the bands. + * Clears the staff overflows and the staff bottom before the bars of a fully assembled system are + * scaled; the bars register them again from their positioned content, the effect placement adds the bands. */ public resetAllStavesOverflows(): void { for (let i = 0; i < this.allStaves.length; i++) { diff --git a/packages/alphatab/test-data/visual-tests/effects-and-annotations/tremolo-flags-mixed.png b/packages/alphatab/test-data/visual-tests/effects-and-annotations/tremolo-flags-mixed.png index 237b5d9db..a92daeaf4 100644 Binary files a/packages/alphatab/test-data/visual-tests/effects-and-annotations/tremolo-flags-mixed.png and b/packages/alphatab/test-data/visual-tests/effects-and-annotations/tremolo-flags-mixed.png differ diff --git a/packages/alphatab/test-data/visual-tests/general/colors-disabled.png b/packages/alphatab/test-data/visual-tests/general/colors-disabled.png index ab629cf9b..c4fa8443c 100644 Binary files a/packages/alphatab/test-data/visual-tests/general/colors-disabled.png and b/packages/alphatab/test-data/visual-tests/general/colors-disabled.png differ diff --git a/packages/alphatab/test-data/visual-tests/general/colors-voice-cascade.png b/packages/alphatab/test-data/visual-tests/general/colors-voice-cascade.png index 35e37788e..93d648081 100644 Binary files a/packages/alphatab/test-data/visual-tests/general/colors-voice-cascade.png and b/packages/alphatab/test-data/visual-tests/general/colors-voice-cascade.png differ diff --git a/packages/alphatab/test-data/visual-tests/general/colors.png b/packages/alphatab/test-data/visual-tests/general/colors.png index 1110e770f..07ae517f8 100644 Binary files a/packages/alphatab/test-data/visual-tests/general/colors.png and b/packages/alphatab/test-data/visual-tests/general/colors.png differ diff --git a/packages/alphatab/test-data/visual-tests/special-tracks/numbered-chord-ties.png b/packages/alphatab/test-data/visual-tests/special-tracks/numbered-chord-ties.png new file mode 100644 index 000000000..027d879b6 Binary files /dev/null and b/packages/alphatab/test-data/visual-tests/special-tracks/numbered-chord-ties.png differ diff --git a/packages/alphatab/test-data/visual-tests/special-tracks/numbered-chords-grace-dead.png b/packages/alphatab/test-data/visual-tests/special-tracks/numbered-chords-grace-dead.png new file mode 100644 index 000000000..91b4d64ca Binary files /dev/null and b/packages/alphatab/test-data/visual-tests/special-tracks/numbered-chords-grace-dead.png differ diff --git a/packages/alphatab/test-data/visual-tests/special-tracks/numbered-chords-tuplets.png b/packages/alphatab/test-data/visual-tests/special-tracks/numbered-chords-tuplets.png new file mode 100644 index 000000000..e14697529 Binary files /dev/null and b/packages/alphatab/test-data/visual-tests/special-tracks/numbered-chords-tuplets.png differ diff --git a/packages/alphatab/test-data/visual-tests/special-tracks/numbered-chords.png b/packages/alphatab/test-data/visual-tests/special-tracks/numbered-chords.png new file mode 100644 index 000000000..c71b46570 Binary files /dev/null and b/packages/alphatab/test-data/visual-tests/special-tracks/numbered-chords.png differ diff --git a/packages/alphatab/test-data/visual-tests/special-tracks/numbered-durations.png b/packages/alphatab/test-data/visual-tests/special-tracks/numbered-durations.png index c5a32fc05..9e06351e5 100644 Binary files a/packages/alphatab/test-data/visual-tests/special-tracks/numbered-durations.png and b/packages/alphatab/test-data/visual-tests/special-tracks/numbered-durations.png differ diff --git a/packages/alphatab/test-data/visual-tests/special-tracks/numbered-single-note.png b/packages/alphatab/test-data/visual-tests/special-tracks/numbered-single-note.png new file mode 100644 index 000000000..22e5b583a Binary files /dev/null and b/packages/alphatab/test-data/visual-tests/special-tracks/numbered-single-note.png differ diff --git a/packages/alphatab/test-data/visual-tests/special-tracks/numbered-tuplets.png b/packages/alphatab/test-data/visual-tests/special-tracks/numbered-tuplets.png index 631ff6f59..166e71af1 100644 Binary files a/packages/alphatab/test-data/visual-tests/special-tracks/numbered-tuplets.png and b/packages/alphatab/test-data/visual-tests/special-tracks/numbered-tuplets.png differ diff --git a/packages/alphatab/test-data/visual-tests/special-tracks/numbered-voices-4.png b/packages/alphatab/test-data/visual-tests/special-tracks/numbered-voices-4.png new file mode 100644 index 000000000..00d02b8c8 Binary files /dev/null and b/packages/alphatab/test-data/visual-tests/special-tracks/numbered-voices-4.png differ diff --git a/packages/alphatab/test-data/visual-tests/special-tracks/numbered-voices-sparse.png b/packages/alphatab/test-data/visual-tests/special-tracks/numbered-voices-sparse.png new file mode 100644 index 000000000..46fc3feaa Binary files /dev/null and b/packages/alphatab/test-data/visual-tests/special-tracks/numbered-voices-sparse.png differ diff --git a/packages/alphatab/test-data/visual-tests/special-tracks/numbered-voices.png b/packages/alphatab/test-data/visual-tests/special-tracks/numbered-voices.png new file mode 100644 index 000000000..1047b3a01 Binary files /dev/null and b/packages/alphatab/test-data/visual-tests/special-tracks/numbered-voices.png differ diff --git a/packages/alphatab/test-data/visual-tests/special-tracks/numbered-whole-rests.png b/packages/alphatab/test-data/visual-tests/special-tracks/numbered-whole-rests.png index 1f1dc1d7e..4c6a537df 100644 Binary files a/packages/alphatab/test-data/visual-tests/special-tracks/numbered-whole-rests.png and b/packages/alphatab/test-data/visual-tests/special-tracks/numbered-whole-rests.png differ diff --git a/packages/alphatab/test-data/visual-tests/special-tracks/numbered.png b/packages/alphatab/test-data/visual-tests/special-tracks/numbered.png index 894f413fb..ca928b2c1 100644 Binary files a/packages/alphatab/test-data/visual-tests/special-tracks/numbered.png and b/packages/alphatab/test-data/visual-tests/special-tracks/numbered.png differ diff --git a/packages/alphatab/test/exporter/AlphaTexExporter.test.ts b/packages/alphatab/test/exporter/AlphaTexExporter.test.ts index 20d839f2b..771148afe 100644 --- a/packages/alphatab/test/exporter/AlphaTexExporter.test.ts +++ b/packages/alphatab/test/exporter/AlphaTexExporter.test.ts @@ -4,6 +4,7 @@ import { AlphaTexErrorWithDiagnostics } from '@coderline/alphatab/importer/Alpha import { ScoreLoader } from '@coderline/alphatab/importer/ScoreLoader'; import { DynamicValue } from '@coderline/alphatab/model/DynamicValue'; import { StaffPlacement, SystemDisplay } from '@coderline/alphatab/model/ElementDisplay'; +import { NumberedNoteMode } from '@coderline/alphatab/model/StaffConfigs'; import { GraceType } from '@coderline/alphatab/model/GraceType'; import { PercussionMapper } from '@coderline/alphatab/model/PercussionMapper'; import { BarNumberDisplay } from '@coderline/alphatab/model/RenderStylesheet'; @@ -332,4 +333,21 @@ describe('AlphaTexExporterTest', () => { const reimported = ScoreLoader.loadAlphaTex(exportAlphaTex(score)); ComparisonHelpers.alphaTexExportRoundtripEqual('staff-display', reimported, score); }); + + it('staff-display-numbered-notes', () => { + const score = ScoreLoader.loadAlphaTex('\\track \\staff {numbered} C4 | \\track \\staff {numbered} C4'); + score.stylesheet.numberedConfig.notes = NumberedNoteMode.SingleNote; + score.tracks[1].staves[0].numberedConfig = { notes: NumberedNoteMode.AllNotes }; + + const exported = exportAlphaTex(score) + .split('\n') + .map(l => l.trim()) + .join(' '); + + expect(exported).toContain('\\defaultStaffDisplay numbered { notes SingleNote }'); + expect(exported).toContain('\\staffDisplay numbered { notes AllNotes }'); + + const reimported = ScoreLoader.loadAlphaTex(exportAlphaTex(score)); + ComparisonHelpers.alphaTexExportRoundtripEqual('staff-display-numbered-notes', reimported, score); + }); }); diff --git a/packages/alphatab/test/generated/SerializerFill.ts b/packages/alphatab/test/generated/SerializerFill.ts index d27a04768..2410b1017 100644 --- a/packages/alphatab/test/generated/SerializerFill.ts +++ b/packages/alphatab/test/generated/SerializerFill.ts @@ -162,6 +162,7 @@ import { ScoreStaffConfigSerializer } from "@coderline/alphatab/generated/model/ import { TabStaffConfigSerializer } from "@coderline/alphatab/generated/model/TabStaffConfigSerializer"; import { SlashStaffConfigSerializer } from "@coderline/alphatab/generated/model/SlashStaffConfigSerializer"; import { NumberedStaffConfigSerializer } from "@coderline/alphatab/generated/model/NumberedStaffConfigSerializer"; +import { NumberedNoteMode } from "@coderline/alphatab/model/StaffConfigs"; import { TrackStyle } from "@coderline/alphatab/model/Track"; import { TrackStyleSerializer } from "@coderline/alphatab/generated/model/TrackStyleSerializer"; import { TrackSubElement } from "@coderline/alphatab/model/Track"; @@ -435,6 +436,7 @@ export class SerializerFill { obj.stemFlagHeight = new Map(); obj.stemFlagHeight!.set(SerializerFill.otherEnumValue(Duration, undefined) as Duration, 7.25); } + obj.numberedChordNoteGap = 7.25; } public static fillExporterSettings(obj: ExporterSettings, depth: number = 0): void { if (depth > 8) { @@ -1265,6 +1267,7 @@ export class SerializerFill { obj.timeSignature = v0; } obj.barNumber = SerializerFill.otherEnumValue(BarNumberDisplay, obj.barNumber) as BarNumberDisplay; + obj.notes = SerializerFill.otherEnumValue(NumberedNoteMode, obj.notes) as NumberedNoteMode; } public static fillTrackStyle(obj: TrackStyle, depth: number = 0): void { if (depth > 8) { diff --git a/packages/alphatab/test/importer/AlphaTexImporter.test.ts b/packages/alphatab/test/importer/AlphaTexImporter.test.ts index 325ad4496..517db5d33 100644 --- a/packages/alphatab/test/importer/AlphaTexImporter.test.ts +++ b/packages/alphatab/test/importer/AlphaTexImporter.test.ts @@ -17,6 +17,7 @@ import { Direction } from '@coderline/alphatab/model/Direction'; import { Duration } from '@coderline/alphatab/model/Duration'; import { DynamicValue } from '@coderline/alphatab/model/DynamicValue'; import { StaffPlacement, SystemDisplay } from '@coderline/alphatab/model/ElementDisplay'; +import { NumberedNoteMode } from '@coderline/alphatab/model/StaffConfigs'; import { FadeType } from '@coderline/alphatab/model/FadeType'; import { FermataType } from '@coderline/alphatab/model/Fermata'; import { Fingers } from '@coderline/alphatab/model/Fingers'; @@ -2789,6 +2790,22 @@ describe('AlphaTexImporterTest', () => { ); }); + it('numbered-notes', () => { + const score = parseTex(` + \\defaultStaffDisplay numbered { notes singleNote } + \\track \\staff {score numbered} + \\staffDisplay numbered { notes allNotes } + C4 + \\track \\staff {numbered} + C4 + `); + expect(score.stylesheet.numberedConfig.notes).toBe(NumberedNoteMode.SingleNote); + expect(score.tracks[0].staves[0].numberedConfig!.notes).toBe(NumberedNoteMode.AllNotes); + expect(score.tracks[1].staves[0].numberedConfig).toBeUndefined(); + + testExportRoundtrip(score); + }); + it('legacy-shorthands', () => { const score = parseTex('\\defaultBarNumberDisplay hide C4 | \\barNumberDisplay allBars C4'); expect(score.stylesheet.tabConfig.barNumber).toBe(BarNumberDisplay.Hide); diff --git a/packages/alphatab/test/rendering/IncrementalResize.test.ts b/packages/alphatab/test/rendering/IncrementalResize.test.ts index 239e8ac01..7333847e4 100644 --- a/packages/alphatab/test/rendering/IncrementalResize.test.ts +++ b/packages/alphatab/test/rendering/IncrementalResize.test.ts @@ -2,7 +2,9 @@ * Resizing in small steps (most systems keep their bars between passes) must lay out like a fresh * render at the same width. */ +import { AlphaTexImporter } from '@coderline/alphatab/importer/AlphaTexImporter'; import { ScoreLoader } from '@coderline/alphatab/importer/ScoreLoader'; +import type { Score } from '@coderline/alphatab/model/Score'; import { ScoreRenderer } from '@coderline/alphatab/rendering/ScoreRenderer'; import type { Bounds } from '@coderline/alphatab/rendering/utils/Bounds'; import type { BoundsLookup } from '@coderline/alphatab/rendering/utils/BoundsLookup'; @@ -22,14 +24,23 @@ describe('IncrementalResize', () => { return settings; } + async function loadScore(source: string, settings: Settings): Promise { + if (source.startsWith('\\')) { + const importer = new AlphaTexImporter(); + importer.initFromString(source, settings); + return importer.readScore(); + } + return ScoreLoader.loadScoreFromBytes(await TestPlatform.loadFile(source), settings); + } + async function createRenderer( - file: string, + source: string, width: number, hideEmptyStaves: boolean, barCount: number ): Promise { const settings = createSettings(barCount); - const score = ScoreLoader.loadScoreFromBytes(await TestPlatform.loadFile(file), settings); + const score = await loadScore(source, settings); score.stylesheet.hideEmptyStaves = hideEmptyStaves; const renderer = new ScoreRenderer(settings); renderer.width = width; @@ -94,12 +105,15 @@ describe('IncrementalResize', () => { expect(`step ${step}: ${firstDifference}`).toBe(`step ${step}: -1`); } - async function checkSequence(file: string, hideEmptyStaves: boolean, barCount: number) { - const resized = await createRenderer(file, 1300, hideEmptyStaves, barCount); + /** + * @param source A test data file path, or alphaTex starting with a backslash. + */ + async function checkSequence(source: string, hideEmptyStaves: boolean, barCount: number) { + const resized = await createRenderer(source, 1300, hideEmptyStaves, barCount); for (let i = 0; i < widths.length; i++) { resized.width = widths[i]; resized.resizeRender(); - const fresh = await createRenderer(file, widths[i], hideEmptyStaves, barCount); + const fresh = await createRenderer(source, widths[i], hideEmptyStaves, barCount); expect(resized.layout!.systems.length).toBe(fresh.layout!.systems.length); expectNear(flatten(resized.boundsLookup!), flatten(fresh.boundsLookup!), i); } @@ -116,4 +130,31 @@ describe('IncrementalResize', () => { it('whammy-bar-helper-notes', async () => { await checkSequence('test-data/visual-tests/effects-and-annotations/tremolo-bar.gp', false, -1); }); + + it('numbered-chord-slots', async () => { + // chords with and without octave dots in their gaps spread over the bars, a second voice in the first + // bars only: the bars change systems on resize and with them the slots of their system + const bars = + '(C4 E4).4 (C5 G5).4 (D4 F4).4 r.4 | (C4 E4 G4).2 (D4 G4).2 | (E4 G4).4 (C4 E4).4 r.2 | (C6 E6).2 r.2 |'; + await checkSequence( + `\\track \\staff {numbered} ${bars.repeat(5)} \\voice C3.1 | C3.1 | C3.1 | C3.1`, + false, + -1 + ); + }); + + it('numbered-voices', async () => { + // the second voice ends after bar 6 (later bars have an empty second voice), so systems with and + // without its row re-form on resize + const upper = 'C5.4 D5 (E5 G5) F5 | G5.2 E5.8 D5 C5.4 |'; + const lower = 'C4.8 D4 E4 F4 G4.2 | (C3 G3).2 r.2 |'; + const single = 'C5.1 | D5.2 E5.2 |'; + await checkSequence( + `\\track \\staff {score numbered} + \\voice ${upper.repeat(3)} ${single.repeat(4)} + \\voice ${lower.repeat(3)}`, + false, + -1 + ); + }); }); diff --git a/packages/alphatab/test/rendering/NumberedChords.test.ts b/packages/alphatab/test/rendering/NumberedChords.test.ts new file mode 100644 index 000000000..281a72487 --- /dev/null +++ b/packages/alphatab/test/rendering/NumberedChords.test.ts @@ -0,0 +1,509 @@ +import { AlphaTexImporter } from '@coderline/alphatab/importer/AlphaTexImporter'; +import type { Beat } from '@coderline/alphatab/model/Beat'; +import { MusicFontSymbol } from '@coderline/alphatab/model/MusicFontSymbol'; +import { NotationMode } from '@coderline/alphatab/NotationSettings'; +import { NoteYPosition } from '@coderline/alphatab/rendering/BarRendererBase'; +import type { NumberedBeatGlyph } from '@coderline/alphatab/rendering/glyphs/NumberedBeatGlyph'; +import type { TieGlyph } from '@coderline/alphatab/rendering/glyphs/TieGlyph'; +import type { NumberedBarRenderer } from '@coderline/alphatab/rendering/NumberedBarRenderer'; +import type { NumberedBeatContainerGlyph } from '@coderline/alphatab/rendering/NumberedBeatContainerGlyph'; +import { ScoreRenderer } from '@coderline/alphatab/rendering/ScoreRenderer'; +import type { BoundsLookup } from '@coderline/alphatab/rendering/utils/BoundsLookup'; +import type { NoteBounds } from '@coderline/alphatab/rendering/utils/NoteBounds'; +import { Settings } from '@coderline/alphatab/Settings'; +import { describe, expect, it } from 'vitest'; +import { VisualTestHelper } from '../visualTests/VisualTestHelper'; + +/** + * Chords on numbered staves stack one number per note, the lowest note at the bottom; + * every voice is written on its own row below the previous voice. + */ +describe('NumberedChords', () => { + /** + * Renders with the fonts of the visual tests, so the measured number height (12 px) is the same on all platforms. + */ + async function render(tex: string, barsPerRow: number = -1, songBook: boolean = false): Promise { + await VisualTestHelper.prepareAlphaSkia(); + const settings = new Settings(); + VisualTestHelper.prepareSettingsForTest(settings); + settings.display.barsPerRow = barsPerRow; + if (songBook) { + // let ring notes tied to a note are hidden + settings.notation.notationMode = NotationMode.SongBook; + } + settings.core.includeNoteBounds = true; + const importer = new AlphaTexImporter(); + importer.initFromString(tex, settings); + const score = importer.readScore(); + const renderer = new ScoreRenderer(settings); + renderer.width = 1300; + renderer.renderScore(score, [0]); + return renderer; + } + + function noteBounds(lookup: BoundsLookup, beat: Beat): NoteBounds[] { + const bounds = lookup.findBeat(beat); + expect(bounds).not.toBeNull(); + return bounds!.notes ?? []; + } + + function boundsOfNote(all: NoteBounds[], realValue: number): NoteBounds { + const found = all.filter(n => n.note.realValue === realValue); + expect(found.length).toBe(1); + return found[0]; + } + + /** + * The top of the bounds of the number of the note. + */ + function topOf(lookup: BoundsLookup, beat: Beat, realValue: number): number { + return boundsOfNote(noteBounds(lookup, beat), realValue).noteHeadBounds.y; + } + + /** + * The vertical centre of the number of the note in the rendered score: the line of its slot. + */ + function centerOf(renderer: ScoreRenderer, beat: Beat, realValue: number): number { + const lookup = renderer.boundsLookup!; + const note = boundsOfNote(noteBounds(lookup, beat), realValue).note; + const barRenderer = renderer.layout!.getRendererForBar('numbered', beat.voice.bar)!; + return lookup.findBeat(beat)!.barBounds.realBounds.y + barRenderer.getNoteY(note, NoteYPosition.Center); + } + + function numberedRenderer(renderer: ScoreRenderer, beat: Beat): NumberedBarRenderer { + return renderer.layout!.getRendererForBar('numbered', beat.voice.bar) as NumberedBarRenderer; + } + + function numbersOf(renderer: ScoreRenderer, beat: Beat): NumberedBeatGlyph { + const container = numberedRenderer(renderer, beat).getBeatContainer(beat) as NumberedBeatContainerGlyph; + return container.onNotes as NumberedBeatGlyph; + } + + // one step (half a staff space) in the default settings + const step = 4.5; + + it('stacks the numbers by pitch regardless of the note order', async () => { + // G4 C4 E4 is entered top-first; the numbers must still read 1 (bottom), 3, 5 (top) + const renderer = await render('\\track \\staff {numbered} (G4 C4 E4).4 (C4 E4 G4).4 C4.2'); + const lookup = renderer.boundsLookup!; + const beats = renderer.score!.tracks[0].staves[0].bars[0].voices[0].beats; + + for (const beat of [beats[0], beats[1]]) { + const all = noteBounds(lookup, beat); + expect(all.length).toBe(3); + + const c4 = boundsOfNote(all, 60).noteHeadBounds; + const e4 = boundsOfNote(all, 64).noteHeadBounds; + const g4 = boundsOfNote(all, 67).noteHeadBounds; + + // each higher note sits fully above the note below, without overlapping it + expect(e4.y + e4.h).toBeLessThanOrEqual(c4.y); + expect(g4.y + g4.h).toBeLessThanOrEqual(e4.y); + + // the numbers are centered on the same column + expect(e4.x + e4.w / 2).toBeCloseTo(c4.x + c4.w / 2, 3); + expect(g4.x + g4.w / 2).toBeCloseTo(c4.x + c4.w / 2, 3); + } + + // the lowest number of a chord stays on the row of single notes + const single = noteBounds(lookup, beats[2]); + expect(single.length).toBe(1); + expect(boundsOfNote(noteBounds(lookup, beats[0]), 60).noteHeadBounds.y).toBeCloseTo( + single[0].noteHeadBounds.y, + 3 + ); + }); + + it('writes the second voice on its own row aligned in time', async () => { + const renderer = await render( + '\\track \\staff {numbered} \\voice C5.4 D5 E5 F5 | C5.1 \\voice C4.2 G4.2 | C4.1' + ); + const lookup = renderer.boundsLookup!; + const bars = renderer.score!.tracks[0].staves[0].bars; + const upper = bars[0].voices[0].beats; + const lower = bars[0].voices[1].beats; + + const upperC5 = boundsOfNote(noteBounds(lookup, upper[0]), 72).noteHeadBounds; + const upperE5 = boundsOfNote(noteBounds(lookup, upper[2]), 76).noteHeadBounds; + const lowerC4 = boundsOfNote(noteBounds(lookup, lower[0]), 60).noteHeadBounds; + const lowerG4 = boundsOfNote(noteBounds(lookup, lower[1]), 67).noteHeadBounds; + + // the second voice sits fully below the first voice + expect(lowerC4.y).toBeGreaterThan(upperC5.y + upperC5.h); + // both rows are horizontal lines + expect(centerOf(renderer, lower[1], 67)).toBeCloseTo(centerOf(renderer, lower[0], 60), 3); + // beats starting at the same time share their horizontal position + expect(lowerC4.x).toBeCloseTo(upperC5.x, 3); + expect(lowerG4.x).toBeCloseTo(upperE5.x, 3); + + // the row of the second voice is kept in the next bar + const nextLower = boundsOfNote(noteBounds(lookup, bars[1].voices[1].beats[0]), 60).noteHeadBounds; + expect(nextLower.y).toBeCloseTo(lowerC4.y, 3); + }); + + // the step counts follow the measured number height of Noto Sans in the visual test setup (12 px) + it('lines up the chord numbers along the system', async () => { + // system 1: C5 carries a high dot below G5 (6 steps), bar 2 is dot-free on its own (4 steps) + // system 2: G3 carries a low dot above C3 (7 steps), C5 below G5 needs 6: the position takes 7 + // system 3: no octave dots in the gaps (4 steps) + const renderer = await render( + `\\track \\staff {numbered} + (C4 E4).4 (C5 G5).4 r.2 | (D4 F4).4 D4.4 r.2 | + (C4 E4).4 (C3 G3).4 r.2 | (C5 G5).4 r.4 r.2 | + (C4 E4).4 r.4 r.2 | (D4 F4).2 r.2`, + 2 + ); + const bars = renderer.score!.tracks[0].staves[0].bars; + const distance = (barIndex: number, beatIndex: number, lower: number, upper: number) => + centerOf(renderer, bars[barIndex].voices[0].beats[beatIndex], lower) - + centerOf(renderer, bars[barIndex].voices[0].beats[beatIndex], upper); + + expect(distance(0, 0, 60, 64)).toBeCloseTo(6 * step, 3); + expect(distance(0, 1, 72, 79)).toBeCloseTo(6 * step, 3); + expect(distance(1, 0, 62, 65)).toBeCloseTo(6 * step, 3); + + expect(distance(2, 0, 60, 64)).toBeCloseTo(7 * step, 3); + expect(distance(2, 1, 48, 55)).toBeCloseTo(7 * step, 3); + expect(distance(3, 0, 72, 79)).toBeCloseTo(7 * step, 3); + + expect(distance(4, 0, 60, 64)).toBeCloseTo(4 * step, 3); + expect(distance(5, 0, 62, 65)).toBeCloseTo(4 * step, 3); + + // the lowest number of a moved chord stays on the line of single numbers + expect(centerOf(renderer, bars[1].voices[0].beats[0], 62)).toBeCloseTo( + centerOf(renderer, bars[1].voices[0].beats[1], 62), + 3 + ); + }); + + it('places the voice rows by their content', async () => { + // system 1: plain numbers, the time signature written in front of both rows sets the distance: + // 2 * 18.036 (numerator and denominator) + 9 (staff content padding) = 45.07 px -> 11 steps + // system 2: low dots below two underlines in the first row, a chord in the second row: + // 30.03 (number half, underlines, low dot) + 18 (row separation) + 18 + 6 (chord) = 72.03 px -> 17 steps + // system 3: as system 2 with a tie in the second row: + 18.29 (tuplet number and offset) = 90.32 px -> 21 steps + const renderer = await render( + `\\track \\staff {numbered} + \\voice C5.4 D5 E5 F5 | C3.16 D3 E3 F3 C4.4 C4.2 | C3.16 D3 E3 F3 C4.4 C4.2 + \\voice C4.4 D4 E4 F4 | (C4 E4).4 r.4 r.2 | (C4 E4).4 E4{t}.4 r.2`, + 1 + ); + const lookup = renderer.boundsLookup!; + const bars = renderer.score!.tracks[0].staves[0].bars; + const rowDistance = (barIndex: number, upper: number, lower: number) => + centerOf(renderer, bars[barIndex].voices[1].beats[0], lower) - + centerOf(renderer, bars[barIndex].voices[0].beats[0], upper); + + expect(rowDistance(0, 72, 60)).toBeCloseTo(11 * step, 3); + expect(rowDistance(1, 48, 60)).toBeCloseTo(17 * step, 3); + expect(rowDistance(2, 48, 60)).toBeCloseTo(21 * step, 3); + + // beats starting at the same time share their horizontal position + expect(boundsOfNote(noteBounds(lookup, bars[1].voices[1].beats[0]), 60).noteHeadBounds.x).toBeCloseTo( + boundsOfNote(noteBounds(lookup, bars[1].voices[0].beats[0]), 48).noteHeadBounds.x, + 3 + ); + }); + + it('keeps the copies of the time signature apart only in systems writing it', async () => { + // systems 1 and 3 write 4/4 and 3/4 in front of both rows; the copies keep the staff content padding: + // 2 * 18.036 (numerator and denominator) + 9 = 45.07 px -> 11 steps + // system 2 writes none, the plain rows need 6 (number half) + 18 (row separation) + 6 = 30 px -> 7 steps + const renderer = await render( + `\\track \\staff {numbered} + \\voice C4.4 D4 E4 F4 | C4.4 D4 E4 F4 | \\ts (3 4) C4.4 D4 E4 + \\voice C4.4 D4 E4 F4 | C4.4 D4 E4 F4 | C4.4 D4 E4`, + 1 + ); + const bars = renderer.score!.tracks[0].staves[0].bars; + const rowDistance = (barIndex: number) => + centerOf(renderer, bars[barIndex].voices[1].beats[0], 60) - + centerOf(renderer, bars[barIndex].voices[0].beats[0], 60); + expect(rowDistance(0)).toBeCloseTo(11 * step, 3); + expect(rowDistance(1)).toBeCloseTo(7 * step, 3); + expect(rowDistance(2)).toBeCloseTo(11 * step, 3); + }); + + it('spaces the time signature copies by the glyph written in front of the rows', async () => { + const rowDistance = (renderer: ScoreRenderer, barIndex: number) => { + const bar = renderer.score!.tracks[0].staves[0].bars[barIndex]; + return centerOf(renderer, bar.voices[1].beats[0], 60) - centerOf(renderer, bar.voices[0].beats[0], 60); + }; + + // the time signature of `score numbered` is written on the standard notation staff only: 7 steps + const cascade = await render( + `\\track \\staff {score numbered} + \\voice C4.4 D4 E4 F4 \\voice C4.4 D4 E4 F4` + ); + expect(rowDistance(cascade, 0)).toBeCloseTo(7 * step, 3); + + // the common time glyph is 18 px high: 18 + 9 = 27 px -> 6 steps, the plain rows (7 steps) decide + const common = await render( + `\\track \\staff {numbered} + \\ts common \\voice C4.4 D4 E4 F4 \\voice C4.4 D4 E4 F4` + ); + expect(rowDistance(common, 0)).toBeCloseTo(7 * step, 3); + + // bar 2 changes to 3/4 with an empty second voice: the time signature is written in front of the first + // and third row only, which the plain rows already keep apart (2 * 7 steps) + const gap = await render( + `\\track \\staff {numbered} + \\voice C4.4 D4 E4 F4 | \\ts (3 4) C4.4 D4 E4 + \\voice C4.1 + \\voice C4.4 D4 E4 F4 | C4.4 D4 E4`, + 1 + ); + const gapBar = gap.score!.tracks[0].staves[0].bars[1]; + expect(gapBar.masterBar.timeSignatureNumerator).toBe(3); + expect(gapBar.filledVoices.has(1)).toBe(false); + expect(centerOf(gap, gapBar.voices[2].beats[0], 60) - centerOf(gap, gapBar.voices[0].beats[0], 60)).toBeCloseTo( + 14 * step, + 3 + ); + }); + + it('pairs the parts of a row distance within each bar', async () => { + // bar 1: deep first row, plain second row: 30.03 + 18 + 6 = 54.03 px -> 13 steps + // bar 2: plain first row, chord with a tie in the second row: 6 + 18 + 18 + 6 + 18.29 = 66.29 px -> 15 steps + // the system takes 15, not the deepest first row of bar 1 plus the highest second row of bar 2 + const renderer = await render( + `\\track \\staff {numbered} + \\voice C3.16 D3 E3 F3 C4.4 C4.2 | C5.4 D5 E5 F5 + \\voice C4.4 D4 E4 F4 | (C4 E4).4 E4{t}.4 r.2`, + 2 + ); + const bars = renderer.score!.tracks[0].staves[0].bars; + expect( + centerOf(renderer, bars[0].voices[1].beats[0], 60) - centerOf(renderer, bars[0].voices[0].beats[0], 48) + ).toBeCloseTo(15 * step, 3); + expect( + centerOf(renderer, bars[1].voices[1].beats[0], 60) - centerOf(renderer, bars[1].voices[0].beats[0], 72) + ).toBeCloseTo(15 * step, 3); + }); + + it('writes a system without the second voice like a single voice', async () => { + const withVoice = await render( + '\\track \\staff {numbered} \\voice C5.4 D5 E5 F5 | C5.4 D5 E5 F5 \\voice C4.1', + 1 + ); + const single = await render('\\track \\staff {numbered} C5.4 D5 E5 F5 | C5.4 D5 E5 F5', 1); + const a = withVoice.boundsLookup!.staffSystems; + const b = single.boundsLookup!.staffSystems; + expect(a.length).toBe(2); + expect(b.length).toBe(2); + expect(a[1].realBounds.h).toBeCloseTo(b[1].realBounds.h, 3); + expect(a[1].bars[0].bars[0].realBounds.h).toBeCloseTo(b[1].bars[0].bars[0].realBounds.h, 3); + const aBeat = withVoice.score!.tracks[0].staves[0].bars[1].voices[0].beats[0]; + const bBeat = single.score!.tracks[0].staves[0].bars[1].voices[0].beats[0]; + expect(centerOf(withVoice, aBeat, 72) - a[1].realBounds.y).toBeCloseTo( + centerOf(single, bBeat, 72) - b[1].realBounds.y, + 3 + ); + // the first system has both rows + expect(a[0].realBounds.h).toBeGreaterThan(b[0].realBounds.h); + }); + + it('keeps the row of an empty first voice blank', async () => { + // bar 2 has no first voice; its second voice stays on the second row, 11 steps apart as bar 1 writes + // the time signature in front of both rows + const renderer = await render('\\track \\staff {numbered} \\voice C5.1 | | \\voice C4.1 | C4.1'); + const lookup = renderer.boundsLookup!; + const bars = renderer.score!.tracks[0].staves[0].bars; + const firstRow = centerOf(renderer, bars[0].voices[0].beats[0], 72); + expect(centerOf(renderer, bars[0].voices[1].beats[0], 60) - firstRow).toBeCloseTo(11 * step, 3); + expect(centerOf(renderer, bars[1].voices[1].beats[0], 60) - firstRow).toBeCloseTo(11 * step, 3); + expect(noteBounds(lookup, bars[1].voices[0].beats[0]).length).toBe(0); + }); + + it('ends a tie of the second voice across a system break on the second row', async () => { + // the bounds of the tied notes, which the tie is laid out from after the alignment; + // with the tie reserve the rows need 6 + 18 + 6 + 18.29 = 48.29 px -> 11 steps in both systems, with or + // without the time signature + const renderer = await render('\\track \\staff {numbered} \\voice C5.1 | C5.1 \\voice C4.1 | C4{t}.1', 1); + const lookup = renderer.boundsLookup!; + const bars = renderer.score!.tracks[0].staves[0].bars; + for (const bar of bars) { + const firstRow = centerOf(renderer, bar.voices[0].beats[0], 72); + expect(centerOf(renderer, bar.voices[1].beats[0], 60) - firstRow).toBeCloseTo(11 * step, 3); + } + const beat = bars[1].voices[1].beats[0]; + const r = renderer.layout!.getRendererForBar('numbered', bars[1])!; + const barY = lookup.findBeat(beat)!.barBounds.realBounds.y; + expect(topOf(lookup, beat, 60)).toBeCloseTo(barY + r.getNoteY(beat.notes[0], NoteYPosition.Top), 3); + }); + + it('reports the aligned positions of moved bars', async () => { + // bar 1 is dot-free on its own and moves onto the wider slots of bar 2 (C5 under G5) in the same system; + // its tied E4 is laid out after the alignment from the same positions as the bounds + const renderer = await render('\\track \\staff {numbered} (C4 E4).2 (C4 E4).2 | (C4 E4{t}).4 (C5 G5).4 r.2'); + const lookup = renderer.boundsLookup!; + const bars = renderer.score!.tracks[0].staves[0].bars; + const distance = (barIndex: number, beatIndex: number, lower: number, upper: number) => + centerOf(renderer, bars[barIndex].voices[0].beats[beatIndex], lower) - + centerOf(renderer, bars[barIndex].voices[0].beats[beatIndex], upper); + expect(distance(0, 0, 60, 64)).toBeCloseTo(6 * step, 3); + expect(distance(0, 1, 60, 64)).toBeCloseTo(6 * step, 3); + expect(distance(1, 0, 60, 64)).toBeCloseTo(6 * step, 3); + expect(distance(1, 1, 72, 79)).toBeCloseTo(6 * step, 3); + + const r = renderer.layout!.getRendererForBar('numbered', bars[0])!; + for (const beat of bars[0].voices[0].beats) { + const barY = lookup.findBeat(beat)!.barBounds.realBounds.y; + for (const note of beat.notes) { + expect(topOf(lookup, beat, note.realValue)).toBeCloseTo(barY + r.getNoteY(note, NoteYPosition.Top), 3); + } + } + }); + + it('writes the highest visible note in single note mode', async () => { + // the let ring G4 tied into beat 2 is hidden: beat 2 writes E4; the tie of the hidden G4 is not drawn + const renderer = await render( + `\\track \\staff {numbered} \\staffDisplay numbered { notes singleNote } + (C4 E4 G4{lr}).4 (C4 E4 G4{lr t}).4 (C4 G4).4 (C4{t} G4{t}).4`, + -1, + true + ); + const lookup = renderer.boundsLookup!; + const beats = renderer.score!.tracks[0].staves[0].bars[0].voices[0].beats; + expect(beats[1].notes[2].isVisible).toBe(false); + + const written = [67, 64, 67, 67]; + for (let i = 0; i < beats.length; i++) { + const notes = noteBounds(lookup, beats[i]); + expect(notes.length).toBe(1); + expect(notes[0].note.realValue).toBe(written[i]); + } + + // only the written G4 of beats 3 and 4 is tied, once from its start and once to its end + const g4 = beats[2].notes[1]; + const ties = numberedRenderer(renderer, beats[0]).ties; + expect(ties.length).toBe(2); + for (const tie of ties) { + expect((tie as unknown as TieGlyph).slurEffectId).toBe(`numbered.tie.${g4.id}`); + } + }); + + it('writes no number for a beat with only hidden notes', async () => { + // the let ring C#4 and E4 tied into beat 2 are hidden, which makes the beat empty: no number and no + // accidental is written + const renderer = await render( + '\\track \\staff {numbered} (C#4{lr} E4{lr}).4 (C#4{lr t} E4{lr t}).4 C4.2', + -1, + true + ); + const lookup = renderer.boundsLookup!; + const beats = renderer.score!.tracks[0].staves[0].bars[0].voices[0].beats; + expect(beats[1].isEmpty).toBe(true); + expect(noteBounds(lookup, beats[0]).length).toBe(2); + expect(noteBounds(lookup, beats[1]).length).toBe(0); + const numbers = numbersOf(renderer, beats[1]); + expect(numbers.noteHead).toBeNull(); + expect(numbers.chord).toBeNull(); + // a beat without accidentals has no pre-notes glyph + expect(numbers.container.preNotes).toBeNull(); + expect(numbersOf(renderer, beats[0]).container.preNotes).not.toBeNull(); + }); + + it('writes the accidental and the augmentation dot of every chord number at the number', async () => { + // dot-free numbers 4 steps apart; C#4 and G#4 carry a sharp, E4 none; every number gets one dot + const renderer = await render('\\track \\staff {numbered} (C#4 E4 G#4).4{d} r.8 r.2'); + const lookup = renderer.boundsLookup!; + const beat = renderer.score!.tracks[0].staves[0].bars[0].voices[0].beats[0]; + const barY = lookup.findBeat(beat)!.barBounds.realBounds.y; + const chord = numbersOf(renderer, beat).chord!; + const container = numbersOf(renderer, beat).container; + expect(chord.heads.length).toBe(3); + + const values = [61, 64, 68]; + const accidentals = [true, false, true]; + for (let k = 0; k < 3; k++) { + const head = chord.heads[k]; + expect(head.note!.realValue).toBe(values[k]); + const bounds = boundsOfNote(noteBounds(lookup, beat), values[k]).noteHeadBounds; + const center = bounds.y + bounds.h / 2 - barY - container.y; + expect(center).toBeCloseTo(chord.y + head.y, 3); + if (k > 0) { + expect(chord.heads[k - 1].y - head.y).toBeCloseTo(4 * step, 3); + } + if (accidentals[k]) { + expect(head.accidental!.y).toBeCloseTo(center, 3); + } else { + expect(head.accidental).toBeNull(); + } + expect(head.dots!.length).toBe(1); + expect(head.dots![0].y).toBeCloseTo(center, 3); + } + }); + + it('writes the time signature and the repeat dots in front of every row with content', async () => { + // voices 1 and 3 are filled, voice 2 is empty: the rows of voices 1 and 3 are written, 14 steps apart + // (twice the 7 steps of plain rows, see the time signature spacing above) + const renderer = await render( + `\\track \\staff {numbered} + \\voice \\ro C4.4 D4 E4 F4 | \\ts (3 4) C4.4 D4 E4 + \\voice C4.1 + \\voice C4.4 D4 E4 F4 | C4.4 D4 E4`, + 1 + ); + const bar = renderer.score!.tracks[0].staves[0].bars[1]; + const r = numberedRenderer(renderer, bar.voices[0].beats[0]); + expect(r.isRowWritten(0)).toBe(true); + expect(r.isRowWritten(1)).toBe(false); + expect(r.isRowWritten(2)).toBe(true); + expect(r.staffRowCount).toBe(2); + expect(r.getStaffRowOffset(0)).toBe(0); + expect(r.getStaffRowOffset(1)).toBeCloseTo(14 * step, 3); + + // the first bar starts a repeat in front of all three rows + const first = numberedRenderer(renderer, renderer.score!.tracks[0].staves[0].bars[0].voices[0].beats[0]); + expect(first.bar.masterBar.isRepeatStart).toBe(true); + expect(first.staffRowCount).toBe(3); + expect(first.getStaffRowOffset(1)).toBeCloseTo(11 * step, 3); + expect(first.getStaffRowOffset(2)).toBeCloseTo(22 * step, 3); + }); + + it('keeps a grace chord of a lower row below the row above', async () => { + // system 2 writes no time signature. The grace numbers 1, 3 and 5 are 10, 12 and 11 px high and stacked + // 4.5 px apart, so the top of the grace chord is 5 + 4.5 + 12 + 4.5 + 11 = 37 px above its row: + // 6 (number half) + 18 (row separation) + 37 = 61 px -> 14 steps; the plain rows of bar 3 need 7 steps + const renderer = await render( + `\\track \\staff {numbered} + \\voice C4.4 D4 E4 F4 | C4.4 D4 E4 F4 | C4.4 D4 E4 F4 + \\voice C4.4 D4 E4 F4 | (C4 E4 G4).16{gr} C4.4 D4 E4 F4 | C4.4 D4 E4 F4`, + 1 + ); + const bars = renderer.score!.tracks[0].staves[0].bars; + expect( + centerOf(renderer, bars[1].voices[1].beats[1], 60) - centerOf(renderer, bars[1].voices[0].beats[0], 60) + ).toBeCloseTo(14 * step, 3); + expect( + centerOf(renderer, bars[2].voices[1].beats[0], 60) - centerOf(renderer, bars[2].voices[0].beats[0], 60) + ).toBeCloseTo(7 * step, 3); + }); + + it('rests the bar number above the bracket spike also on a first bar moved onto the system slots', async () => { + // the chords of bars 2 and 4 move the numbers of bars 1 and 3, the first bars of the two systems; + // the bracket of the two staves curls into the first bar of the top staff + const renderer = await render( + `\\track \\staff {numbered} + \\voice C5.1 | (C5 E5 G5).4 C5 D5 E5 | C5.1 | (C5 E5 G5).4 C5 D5 E5 + \\voice r.1 | C4.1 | r.1 | C4.1 + \\staff {numbered} C3.1 | C3.1 | C3.1 | C3.1`, + 2 + ); + const smufl = renderer.settings.display.resources.engravingSettings; + const spikeHeight = smufl.glyphHeights.get(MusicFontSymbol.BracketTop)! + smufl.oneStaffSpace * 0.25; + const bars = renderer.score!.tracks[0].staves[0].bars; + for (const index of [0, 2]) { + const r = numberedRenderer(renderer, bars[index].voices[0].beats[0]); + expect(r.index).toBe(0); + let barNumberMagnitude = -1; + for (const band of r.topEffects.bands) { + if (band.info.effectId === 'BarNumber' && !band.isEmpty) { + barNumberMagnitude = band.placedMagnitude; + } + } + expect(barNumberMagnitude).toBeGreaterThan(spikeHeight); + } + }); +}); diff --git a/packages/alphatab/test/rendering/ScaleToWidthCallCount.test.ts b/packages/alphatab/test/rendering/ScaleToWidthCallCount.test.ts index d10c2f0c2..3d936c74e 100644 --- a/packages/alphatab/test/rendering/ScaleToWidthCallCount.test.ts +++ b/packages/alphatab/test/rendering/ScaleToWidthCallCount.test.ts @@ -131,4 +131,21 @@ describe('ScaleToWidthCallCount', () => { expect(counts.get(r) ?? 0).toBe(1); } }); + + it('numbered staff: aligning the voice rows and chord numbers along the system adds no scaleToWidth', async () => { + // the chord and the second voice of the first bar move the numbers of the other bars + const tex = ` + \\track "T1" + \\staff {numbered} + \\voice (C4 E4 G4).4 C4.4 C4.4 C4.4 | C4.4 *4 | C4.4 *4 + \\voice C3.1 | r.1 | r.1 + `; + for (const layoutMode of [LayoutMode.Page, LayoutMode.Horizontal]) { + const result = await ScaleToWidthCallCountHelper.renderAndCount(tex, 1500, layoutMode); + expect(result.renderers.length).toBe(3); + for (const r of result.renderers) { + expect(result.counts.get(r) ?? 0).toBe(1); + } + } + }); }); diff --git a/packages/alphatab/test/visualTests/features/SpecialTracks.test.ts b/packages/alphatab/test/visualTests/features/SpecialTracks.test.ts index 086fe82d7..35c14f7ed 100644 --- a/packages/alphatab/test/visualTests/features/SpecialTracks.test.ts +++ b/packages/alphatab/test/visualTests/features/SpecialTracks.test.ts @@ -123,6 +123,175 @@ describe('SpecialTracksTests', () => { ); }) + it('numbered-chord-ties', async () => { + // bar 1: chord tied within the bar, bar 2 -> 3: chord tied across a system break, + // bar 4: hammer-on chord taking a single slur arc + await VisualTestHelper.runVisualTestTex( + ` + \\track "Piano" + \\staff {score numbered} + (C4 E4 G4).2 (C4{t} E4{t} G4{t}).2 | + D4.2 (D4 F4 A4).2 | + (D4{t} F4{t} A4{t}).2 r.2 | + \\track "Guitar" + \\staff {score numbered tabs} + (5.5{h} 5.4{h} 5.3{h}).4 (7.5 7.4 7.3).4 r.2 | + `, + 'test-data/visual-tests/special-tracks/numbered-chord-ties.png', + undefined, + o => { + o.settings.display.barsPerRow = 2; + o.tracks = o.score.tracks.map(t => t.index); + } + ); + }); + + it('numbered-chords', async () => { + // bar 1: root position and inversions in the middle octave + // bar 2: chords spanning octaves (inner notes with high and low octave dots) + // bar 3: accidentals on individual chord notes + // bar 4: dotted quarter, eighths and sixteenths (underlines only below the lowest number) + // bar 5: double-dotted half (dashes and extra number belong to the whole chord) + await VisualTestHelper.runVisualTestTex( + ` + \\track "Piano" + \\staff {score numbered} + (C4 E4 G4).4 (E4 G4 C5).4 (G4 C5 E5).4 (C3 G3 E4).4 | + (A3 C4 E4).4 (B4 D5 G5).4 (C4 C5 C6).4 (G2 G3 G4 G5).4 | + (C4 E4 G#4).4 (Db4 F4 Ab4).4 (D4 F#4 A4).4 (C4 Eb4 G4 Bb4).4 | + (C4 E4 G4).4{d} (D4 F4).8 (C4 E4).8 (D4 F4).8 (E4 G4).16 (F4 A4).16 (G4 B4).16 (A4 C5).16 | + (C4 E4 G4).2{dd} (D4 F4).8 + `, + 'test-data/visual-tests/special-tracks/numbered-chords.png', + undefined, + o => { + o.tracks = o.score.tracks.map(t => t.index); + } + ); + }); + + it('numbered-chords-grace-dead', async () => { + // bar 1: grace chords before chords and before a single note + // bar 2: chords with dead notes, a fully dead chord and a dead-slapped beat + await VisualTestHelper.runVisualTestTex( + ` + \\track "Guitar" + \\staff {score numbered tabs} + (3.5 2.4).16{gr} (3.5 2.4 0.3).4 (5.4 5.3).16{gr} 7.3.4 (0.3 1.2).16{gr} (2.4 2.3 1.2).2 | + (3.5 2.4{x} 0.3).4 (3.5{x} 2.4{x} 0.3{x}).4 r.4{ds} (3.5 2.4 0.3).4 + `, + 'test-data/visual-tests/special-tracks/numbered-chords-grace-dead.png', + undefined, + o => { + o.tracks = o.score.tracks.map(t => t.index); + } + ); + }); + + it('numbered-chords-tuplets', async () => { + // eighth and quarter triplets of chords, the tuplet bracket must clear the highest number + await VisualTestHelper.runVisualTestTex( + ` + \\track "Piano" + \\staff {score numbered} + (C4 E4 G4).8{tu 3} (D4 F4 A4).8{tu 3} (E4 G4 B4).8{tu 3} (C5 E5 G5).4{tu 3} (D5 F5 A5).4{tu 3} (E5 G5 B5).4{tu 3} (C4 C5).4 | + (G3 B3 D4 G4).8{tu 3} (A3 C4 E4).8{tu 3} (B3 D4 F4).8{tu 3} (C4 E4 G4 C5).2{d} + `, + 'test-data/visual-tests/special-tracks/numbered-chords-tuplets.png', + undefined, + o => { + o.tracks = o.score.tracks.map(t => t.index); + } + ); + }); + + it('numbered-voices', async () => { + // two voices with different rhythms, each on its own row aligned in time: + // bar 1: quarters against eighths, bar 2: chords and low octave dots in the upper voice, + // bar 3: ties and a tuplet in the lower voice, bar 4: rests in both voices + await VisualTestHelper.runVisualTestTex( + ` + \\track "Piano" + \\staff {score numbered} + \\voice + C5.4 D5 E5 F5 | (E5 G5).8 F5 E5.4 D4.16 C4 B3 A3 G3.4 | C5.2 E5.2 | r.2 G5.2 + \\voice + C4.8 D4 E4 F4 G4 A4 B4 C5 | C3.2 G3.2 | A3.4 A3{t}.4 B3.8{tu 3} C4.8{tu 3} D4.8{tu 3} E4.4 | C4.2 r.2 + `, + 'test-data/visual-tests/special-tracks/numbered-voices.png', + undefined, + o => { + o.tracks = o.score.tracks.map(t => t.index); + } + ); + }); + + it('numbered-voices-sparse', async () => { + // the second voice rests in bar 1, has notes in bar 2 and is empty afterwards: + // bar 3 keeps its row blank with the bar lines spanning both rows, + // the second system (bars 4 and 5) has a single voice and collapses to one row + await VisualTestHelper.runVisualTestTex( + ` + \\track "Piano" + \\staff {numbered} + \\voice + C5.4 D5 E5 F5 | G5.2 E5.2 | C5.1 | D5.4 E5 F5 G5 | C5.1 + \\voice + r.1 | C4.4 E4 G4 C4 + `, + 'test-data/visual-tests/special-tracks/numbered-voices-sparse.png', + undefined, + o => { + o.settings.display.barsPerRow = 3; + o.tracks = o.score.tracks.map(t => t.index); + } + ); + }); + + it('numbered-voices-4', async () => { + // four voices, one row each from the first voice (top) to the fourth voice (bottom) + await VisualTestHelper.runVisualTestTex( + ` + \\track "Choir" + \\staff {score numbered} + \\voice + E5.4 F5 G5.2 | C6.1 + \\voice + C5.2 D5.2 | E5.1 + \\voice + G4.4 A4 B4 G4 | G4.1 + \\voice + C4.8 D4 E4 F4 G4.2 | C3.1 + `, + 'test-data/visual-tests/special-tracks/numbered-voices-4.png', + undefined, + o => { + o.tracks = o.score.tracks.map(t => t.index); + } + ); + }); + + it('numbered-single-note', async () => { + // single note mode: one number per beat showing the highest note of a chord, the second voice + // is not written; the tie of the chord continues from the highest note only + await VisualTestHelper.runVisualTestTex( + ` + \\track "Piano" + \\staff {score numbered} + \\staffDisplay numbered { notes singleNote } + \\voice + (C4 E4 G4).4 (E4 C5).4 (C4 E4 G4).2 | (C4{t} E4{t} G4{t}).2 (D4 F4 A4).2 + \\voice + C3.2 G3.2 | C3.1 + `, + 'test-data/visual-tests/special-tracks/numbered-single-note.png', + undefined, + o => { + o.tracks = o.score.tracks.map(t => t.index); + } + ); + }); + it('numbered-whole-rests', async () => { const rests = '\\ts (4 4) r.1 | \\ts (8 4) r.1 r.1 | \\ts (6 4) r.1 C4.2 | C4.2 r.1 | \\ts (8 4) r.1 {tu 3} * 3'; diff --git a/packages/alphatex/src/enum.ts b/packages/alphatex/src/enum.ts index 3937c06f6..67f9c11b3 100644 --- a/packages/alphatex/src/enum.ts +++ b/packages/alphatex/src/enum.ts @@ -34,7 +34,8 @@ export const alphaTexMappedEnumLookup = { BarNumberDisplay: alphaTab.model.BarNumberDisplay, StaffPlacement: alphaTab.model.StaffPlacement, SystemDisplay: alphaTab.model.SystemDisplay, - TabRhythmMode: alphaTab.TabRhythmMode + TabRhythmMode: alphaTab.TabRhythmMode, + NumberedNoteMode: alphaTab.model.NumberedNoteMode }; export type AlphaTexMappedEnumName = keyof typeof alphaTexMappedEnumLookup; @@ -488,6 +489,16 @@ export const alphaTexMappedEnumMapping: { ShowWithBeams: { snippet: 'showWithBeams', shortDescription: 'Individual beams per beat' }, ShowWithBars: { snippet: 'showWithBars', shortDescription: 'Connected bars like standard notation' }, Automatic: { snippet: 'automatic', shortDescription: 'Shown if no standard notation is shown' } + }, + NumberedNoteMode: { + AllNotes: { + snippet: 'allNotes', + shortDescription: 'Every voice on its own row, chords as stacked numbers' + }, + SingleNote: { + snippet: 'singleNote', + shortDescription: 'Only the first voice, one number per beat showing the highest note' + } } }; diff --git a/packages/alphatex/src/staffDisplay.ts b/packages/alphatex/src/staffDisplay.ts index ff8dd10a0..ee307c985 100644 --- a/packages/alphatex/src/staffDisplay.ts +++ b/packages/alphatex/src/staffDisplay.ts @@ -130,6 +130,12 @@ export function staffDisplayProperties(tag: string, level: StaffDisplayLevel): M 'Set how the rhythm notation is shown. Supported staff types: `tabs`.', enumSignatures('mode', 'The mode to use', 'TabRhythmMode'), 'showWithBeams' + ), + prop( + 'notes', + 'Set how the notes of the voices and chords are written. Supported staff types: `numbered`.', + enumSignatures('mode', 'The mode to use', 'NumberedNoteMode'), + 'singleNote' ) ); } diff --git a/website/docs/features/render/numbered-notation.mdx b/website/docs/features/render/numbered-notation.mdx index a7c2c91e2..d06964034 100644 --- a/website/docs/features/render/numbered-notation.mdx +++ b/website/docs/features/render/numbered-notation.mdx @@ -34,11 +34,44 @@ Numbered notation (jiǎnpǔ, 简谱) prints digits 1–7 for the seven scale deg ## How the staff reads - Digits `1` through `7` map to scale degrees relative to the current key signature. A rest is `0`. -- Dots above or below a digit shift it up or down an octave. Sharp and flat markers appear as prefixes when the pitch falls outside the key. +- Dots above or below a digit shift it up or down an octave; below a digit with duration lines, the dots go below the lines. Sharp and flat markers appear as prefixes when the pitch falls outside the key. - Duration is drawn as underlines below the digit — one line for eighths, two for sixteenths, and so on. Dotted rhythms add a trailing dot; ties and slurs curve above the digits exactly as they do on a standard staff. The numbered staff carries every rhythmic and structural detail the standard staff carries — ties, tuplets, repeats, dynamics, chord names — so you can hand a reader a jiǎnpǔ-only chart and preserve the full arrangement. Set `showNumbered = true` on the `Staff` and turn off the notations you don't need. +## Chords and multiple voices + +A chord is written as a stack of digits with the lowest note at the bottom; the rows of the stack line up across all chords of a line. Every digit keeps its own octave dots, accidental and augmentation dot; the duration lines and the dashes of longer notes belong to the lowest digit. Each note of a tied chord gets its own tie. + +When a staff has more than one voice, every voice is written on its own row: the first voice on top, the further voices below, with beats lined up in time across the rows. A voice keeps its row on the whole staff. Systems in which only the first voice has notes are written as a single row. The distance between the rows follows their content: octave dots, duration lines, chords, ties and tuplets move the rows apart, and so does a time signature, which is written in front of every row. + +{` +\\track "Piano" + \\staff {score numbered} + \\voice + :4 (E4 G4 C5) (F4 A4 C5) (G4 B4 D5) (E4 G4 C5) | :2 (D4 F4 B4) (C4 E4 C5) + \\voice + :8 C3 G3 E4 G3 F3 C4 A3 C4 | :4 G2 G3 :2 C3 +`} + +## Single note mode + +Guitar Pro writes a single digit per beat and only the first voice. To get this display, set the `notes` mode of the numbered staff to `NumberedNoteMode.SingleNote`: each beat then shows the highest note of its chord. The mode can be set for the whole score on `score.stylesheet.numberedConfig`, or per staff on `staff.numberedConfig`, which takes precedence. In alphaTex, use the `notes` property of [`\defaultStaffDisplay`](/docs/alphatex/score-metadata#defaultstaffdisplay) or [`\staffDisplay`](/docs/alphatex/staff-metadata#staffdisplay). + +{` +\\track "Piano" + \\staff {score numbered} + \\staffDisplay numbered { notes singleNote } + \\voice + :4 (E4 G4 C5) (F4 A4 C5) (G4 B4 D5) (E4 G4 C5) | :2 (D4 F4 B4) (C4 E4 C5) + \\voice + :8 C3 G3 E4 G3 F3 C4 A3 C4 | :4 G2 G3 :2 C3 +`} + +```js +score.stylesheet.numberedConfig.notes = alphaTab.model.NumberedNoteMode.SingleNote; +``` + ## Show it in code ```js @@ -67,4 +100,4 @@ api.scoreLoaded.on((score) => { - [Standard notation](/docs/features/render/standard-notation) — the western five-line staff, often shown alongside jiǎnpǔ. - [Slash notation](/docs/features/render/slash-notation) — another single-line rhythm notation. -- [`Staff.showNumbered`](/docs/reference/types/model/staff) and [`NumberedStaffConfig`](/docs/reference/types/model/numberedstaffconfig) in the model reference. +- [`Staff.showNumbered`](/docs/reference/types/model/staff), [`NumberedStaffConfig`](/docs/reference/types/model/numberedstaffconfig) and [`NumberedNoteMode`](/docs/reference/types/model/numberednotemode) in the model reference. diff --git a/website/docs/releases/release1_10.mdx b/website/docs/releases/release1_10.mdx index f1730c1bd..abedf0e97 100644 --- a/website/docs/releases/release1_10.mdx +++ b/website/docs/releases/release1_10.mdx @@ -22,3 +22,26 @@ Guitar tabs of a track transposed with [`notation.transpositionPitches`](/docs/r `} The loaded score is not changed: the new positions exist only on the rendered tab. Details: [Transposed tablature](/docs/features/render/guitar-tablature#transposed-tablature). + +#### render: chords and multiple voices on numbered staves +https://github.com/CoderLine/alphaTab/issues/2437 + +Numbered staves write every note instead of a single digit per beat. A chord is a stack of digits with the lowest note at the bottom, each with its own octave dots, accidental and augmentation dot; the duration lines stay with the lowest digit. Every voice is written on its own row, the first voice on top, with beats lined up in time. + +{` +\\track "Piano" + \\staff {score numbered} + \\voice + :4 (E4 G4 C5) (F4 A4 C5) (G4 B4 D5) (E4 G4 C5) | :2 (D4 F4 B4) (C4 E4 C5) + \\voice + :8 C3 G3 E4 G3 F3 C4 A3 C4 | :4 G2 G3 :2 C3 +`} + +The previous display with a single digit of the first voice, as in Guitar Pro, is available as the new [`NumberedNoteMode.SingleNote`](/docs/reference/types/model/numberednotemode); it now shows the highest note of a chord. Details: [Chords and multiple voices](/docs/features/render/numbered-notation#chords-and-multiple-voices). + +### Upgrade Notes + +#### Visual changes + +* Low octave dots on numbered staves sit below the duration lines instead of between digit and lines, as in printed jiǎnpǔ. +* Numbered staves show all notes of chords and all voices. Set `notes` of the numbered staff configuration to `NumberedNoteMode.SingleNote` (alphaTex: `\defaultStaffDisplay numbered { notes singleNote }`) for one digit per beat; see [Single note mode](/docs/features/render/numbered-notation#single-note-mode).