diff --git a/packages/alphatab/src/generated/model/BeatCloner.ts b/packages/alphatab/src/generated/model/BeatCloner.ts index ec16d076c..10beecd91 100644 --- a/packages/alphatab/src/generated/model/BeatCloner.ts +++ b/packages/alphatab/src/generated/model/BeatCloner.ts @@ -39,6 +39,8 @@ export class BeatCloner { clone.text = original.text; clone.slashed = original.slashed; clone.deadSlapped = original.deadSlapped; + clone.restDisplayTone = original.restDisplayTone; + clone.restDisplayOctave = original.restDisplayOctave; clone.brushType = original.brushType; clone.brushDuration = original.brushDuration; clone.tupletDenominator = original.tupletDenominator; diff --git a/packages/alphatab/src/generated/model/BeatSerializer.ts b/packages/alphatab/src/generated/model/BeatSerializer.ts index d8e2461b9..eb81fdf37 100644 --- a/packages/alphatab/src/generated/model/BeatSerializer.ts +++ b/packages/alphatab/src/generated/model/BeatSerializer.ts @@ -64,6 +64,8 @@ export class BeatSerializer { o.set("text", obj.text); o.set("slashed", obj.slashed); o.set("deadslapped", obj.deadSlapped); + o.set("restdisplaytone", obj.restDisplayTone); + o.set("restdisplayoctave", obj.restDisplayOctave); o.set("brushtype", obj.brushType as number); o.set("brushduration", obj.brushDuration); o.set("tupletdenominator", obj.tupletDenominator); @@ -168,6 +170,12 @@ export class BeatSerializer { case "deadslapped": obj.deadSlapped = v! as boolean; return true; + case "restdisplaytone": + obj.restDisplayTone = v! as number; + return true; + case "restdisplayoctave": + obj.restDisplayOctave = v! as number; + return true; case "brushtype": obj.brushType = JsonHelper.parseEnum(v, BrushType)!; return true; diff --git a/packages/alphatab/src/importer/alphaTex/AlphaTex1LanguageDefinitions.ts b/packages/alphatab/src/importer/alphaTex/AlphaTex1LanguageDefinitions.ts index 8e84fe7ed..c3db411d2 100644 --- a/packages/alphatab/src/importer/alphaTex/AlphaTex1LanguageDefinitions.ts +++ b/packages/alphatab/src/importer/alphaTex/AlphaTex1LanguageDefinitions.ts @@ -664,6 +664,7 @@ export class AlphaTex1LanguageDefinitions { ] ], ['txt', [[[[17, 10], 0]]]], + ['restdisplaypitch', [[[[10, 17], 0]]]], [ 'lyrics', [ diff --git a/packages/alphatab/src/importer/alphaTex/AlphaTex1LanguageHandler.ts b/packages/alphatab/src/importer/alphaTex/AlphaTex1LanguageHandler.ts index 4b38647d0..408a5060b 100644 --- a/packages/alphatab/src/importer/alphaTex/AlphaTex1LanguageHandler.ts +++ b/packages/alphatab/src/importer/alphaTex/AlphaTex1LanguageHandler.ts @@ -1616,6 +1616,23 @@ export class AlphaTex1LanguageHandler implements IAlphaTexLanguageImportHandler case 'txt': beat.text = (p.arguments!.arguments[0] as AlphaTexTextNode).text; return ApplyNodeResult.Applied; + case 'restdisplaypitch': { + const tuning = ModelUtils.parseTuning((p.arguments!.arguments[0] as AlphaTexTextNode).text); + if (tuning !== null) { + beat.restDisplayTone = tuning.tone.noteValue; + beat.restDisplayOctave = tuning.octave - 1; + } else { + importer.addSemanticDiagnostic({ + code: AlphaTexDiagnosticCode.AT212, + message: `Invalid pitch value '${(p.arguments!.arguments[0] as AlphaTexTextNode).text}', expected format like 'C5' or 'G4'`, + severity: AlphaTexDiagnosticsSeverity.Error, + start: p.arguments!.arguments[0].start, + end: p.arguments!.arguments[0].end + }); + return ApplyNodeResult.NotAppliedSemanticError; + } + return ApplyNodeResult.Applied; + } case 'lyrics': let lyricsLine = 0; let lyricsText = ''; diff --git a/packages/alphatab/src/model/Beat.ts b/packages/alphatab/src/model/Beat.ts index c110b0190..cc17b96d2 100644 --- a/packages/alphatab/src/model/Beat.ts +++ b/packages/alphatab/src/model/Beat.ts @@ -425,6 +425,18 @@ export class Beat { */ public deadSlapped: boolean = false; + /** + * Gets or sets the chromatic tone value (0–11) of the pitch at which this rest should be displayed. + * A value of -1 means use the default position formula. + */ + public restDisplayTone: number = -1; + + /** + * Gets or sets the octave at which this rest should be displayed. + * Only relevant when {@link restDisplayTone} is set. -1 means use the default position formula. + */ + public restDisplayOctave: number = -1; + /** * Gets or sets the brush type applied to the notes of this beat. */ diff --git a/packages/alphatab/src/rendering/glyphs/ScoreBeatGlyph.ts b/packages/alphatab/src/rendering/glyphs/ScoreBeatGlyph.ts index 695fd2478..28d513072 100644 --- a/packages/alphatab/src/rendering/glyphs/ScoreBeatGlyph.ts +++ b/packages/alphatab/src/rendering/glyphs/ScoreBeatGlyph.ts @@ -1,5 +1,6 @@ import { Logger } from '@coderline/alphatab/Logger'; import { AccentuationType } from '@coderline/alphatab/model/AccentuationType'; +import { AccidentalHelper } from '@coderline/alphatab/rendering/utils/AccidentalHelper'; import { BeatSubElement } from '@coderline/alphatab/model/Beat'; import { Duration } from '@coderline/alphatab/model/Duration'; import { GraceType } from '@coderline/alphatab/model/GraceType'; @@ -301,18 +302,23 @@ export class ScoreBeatGlyph extends BeatOnNoteGlyphBase { private _createRestGlyphs() { const sr = this.renderer as ScoreBarRenderer; - - let steps = Math.ceil((this.renderer.bar.staff.standardNotationLineCount - 1) / 2) * 2; - - // this positioning is quite strange, for most staff line counts - // the whole/rest are aligned as half below the whole rest. - // but for staff line count 1 and 3 they are aligned centered on the same line. - if ( - this.container.beat.duration === Duration.Whole && - this.renderer.bar.staff.standardNotationLineCount !== 1 && - this.renderer.bar.staff.standardNotationLineCount !== 3 - ) { - steps -= 2; + const beat = this.container.beat; + const lineCount = this.renderer.bar.staff.standardNotationLineCount; + + let steps: number; + if (beat.restDisplayTone !== -1 && beat.restDisplayOctave !== -1) { + // Per-beat override: same step as a note at that pitch. SMuFL rest glyphs use the same + // baseline convention as note heads, so no further adjustment is applied. + steps = AccidentalHelper.calculateRestDisplaySteps(sr.bar, beat.restDisplayTone, beat.restDisplayOctave); + } else { + // Default placement: centred on the staff. Whole rests sit one line above (per SMuFL/Guitar Pro + // convention) so their hanging body lines up with where half/shorter rest bodies appear. + // 1- and 3-line staves keep the whole rest on the default rest line (Guitar Pro convention; + // see musescore/MuseScore#25279). + steps = Math.ceil((lineCount - 1) / 2) * 2; + if (beat.duration === Duration.Whole && lineCount !== 1 && lineCount !== 3) { + steps -= 2; + } } const restGlyph = new ScoreRestGlyph(0, sr.getScoreY(steps), this.container.beat.duration); diff --git a/packages/alphatab/src/rendering/utils/AccidentalHelper.ts b/packages/alphatab/src/rendering/utils/AccidentalHelper.ts index 23490e441..3b62724a0 100644 --- a/packages/alphatab/src/rendering/utils/AccidentalHelper.ts +++ b/packages/alphatab/src/rendering/utils/AccidentalHelper.ts @@ -5,6 +5,7 @@ import type { Clef } from '@coderline/alphatab/model/Clef'; import { ModelUtils, type ResolvedSpelling } from '@coderline/alphatab/model/ModelUtils'; import type { Note } from '@coderline/alphatab/model/Note'; import { NoteAccidentalMode } from '@coderline/alphatab/model/NoteAccidentalMode'; +import { Ottavia } from '@coderline/alphatab/model/Ottavia'; import { PercussionMapper } from '@coderline/alphatab/model/PercussionMapper'; import type { LineBarRenderer } from '@coderline/alphatab/rendering/LineBarRenderer'; import type { ScoreBarRenderer } from '@coderline/alphatab/rendering/ScoreBarRenderer'; @@ -267,6 +268,27 @@ export class AccidentalHelper { return steps; } + public static calculateRestDisplaySteps(bar: Bar, tone: number, octave: number): number { + let noteValue = (octave + 1) * 12 + tone; + switch (bar.clefOttava) { + case Ottavia._15ma: + noteValue -= 24; + break; + case Ottavia._8va: + noteValue -= 12; + break; + case Ottavia._8vb: + noteValue += 12; + break; + case Ottavia._15mb: + noteValue += 24; + break; + } + + const spelling = ModelUtils.resolveSpelling(bar.keySignature, noteValue, NoteAccidentalMode.Default); + return AccidentalHelper.calculateNoteSteps(bar.clef, spelling); + } + public getNoteSteps(n: Note): number { return this._appliedScoreSteps.get(n.id)!; } diff --git a/packages/alphatab/test-data/visual-tests/rest-position/rest-position-alto-F3.png b/packages/alphatab/test-data/visual-tests/rest-position/rest-position-alto-F3.png new file mode 100644 index 000000000..8a69726f6 Binary files /dev/null and b/packages/alphatab/test-data/visual-tests/rest-position/rest-position-alto-F3.png differ diff --git a/packages/alphatab/test-data/visual-tests/rest-position/rest-position-bass-G2.png b/packages/alphatab/test-data/visual-tests/rest-position/rest-position-bass-G2.png new file mode 100644 index 000000000..f9ab37e11 Binary files /dev/null and b/packages/alphatab/test-data/visual-tests/rest-position/rest-position-bass-G2.png differ diff --git a/packages/alphatab/test-data/visual-tests/rest-position/rest-position-default.png b/packages/alphatab/test-data/visual-tests/rest-position/rest-position-default.png new file mode 100644 index 000000000..55b7cdafe Binary files /dev/null and b/packages/alphatab/test-data/visual-tests/rest-position/rest-position-default.png differ diff --git a/packages/alphatab/test-data/visual-tests/rest-position/rest-position-multi-voice.png b/packages/alphatab/test-data/visual-tests/rest-position/rest-position-multi-voice.png new file mode 100644 index 000000000..60273a266 Binary files /dev/null and b/packages/alphatab/test-data/visual-tests/rest-position/rest-position-multi-voice.png differ diff --git a/packages/alphatab/test-data/visual-tests/rest-position/rest-position-staff-lines-1.png b/packages/alphatab/test-data/visual-tests/rest-position/rest-position-staff-lines-1.png new file mode 100644 index 000000000..88b64f69e Binary files /dev/null and b/packages/alphatab/test-data/visual-tests/rest-position/rest-position-staff-lines-1.png differ diff --git a/packages/alphatab/test-data/visual-tests/rest-position/rest-position-staff-lines-2.png b/packages/alphatab/test-data/visual-tests/rest-position/rest-position-staff-lines-2.png new file mode 100644 index 000000000..4ef84b421 Binary files /dev/null and b/packages/alphatab/test-data/visual-tests/rest-position/rest-position-staff-lines-2.png differ diff --git a/packages/alphatab/test-data/visual-tests/rest-position/rest-position-staff-lines-3.png b/packages/alphatab/test-data/visual-tests/rest-position/rest-position-staff-lines-3.png new file mode 100644 index 000000000..903935f0b Binary files /dev/null and b/packages/alphatab/test-data/visual-tests/rest-position/rest-position-staff-lines-3.png differ diff --git a/packages/alphatab/test-data/visual-tests/rest-position/rest-position-staff-lines-4.png b/packages/alphatab/test-data/visual-tests/rest-position/rest-position-staff-lines-4.png new file mode 100644 index 000000000..f935ccce1 Binary files /dev/null and b/packages/alphatab/test-data/visual-tests/rest-position/rest-position-staff-lines-4.png differ diff --git a/packages/alphatab/test-data/visual-tests/rest-position/rest-position-staff-lines-5.png b/packages/alphatab/test-data/visual-tests/rest-position/rest-position-staff-lines-5.png new file mode 100644 index 000000000..4b1148f4c Binary files /dev/null and b/packages/alphatab/test-data/visual-tests/rest-position/rest-position-staff-lines-5.png differ diff --git a/packages/alphatab/test-data/visual-tests/rest-position/rest-position-tenor-D3.png b/packages/alphatab/test-data/visual-tests/rest-position/rest-position-tenor-D3.png new file mode 100644 index 000000000..2b0d8d290 Binary files /dev/null and b/packages/alphatab/test-data/visual-tests/rest-position/rest-position-tenor-D3.png differ diff --git a/packages/alphatab/test-data/visual-tests/rest-position/rest-position-treble-E4.png b/packages/alphatab/test-data/visual-tests/rest-position/rest-position-treble-E4.png new file mode 100644 index 000000000..1de0c3707 Binary files /dev/null and b/packages/alphatab/test-data/visual-tests/rest-position/rest-position-treble-E4.png differ diff --git a/packages/alphatab/test/rendering/AccidentalHelperRestDisplay.test.ts b/packages/alphatab/test/rendering/AccidentalHelperRestDisplay.test.ts new file mode 100644 index 000000000..ea0c48c3b --- /dev/null +++ b/packages/alphatab/test/rendering/AccidentalHelperRestDisplay.test.ts @@ -0,0 +1,86 @@ +import { Bar } from '@coderline/alphatab/model/Bar'; +import { Clef } from '@coderline/alphatab/model/Clef'; +import { KeySignature } from '@coderline/alphatab/model/KeySignature'; +import { ModelUtils } from '@coderline/alphatab/model/ModelUtils'; +import { NoteAccidentalMode } from '@coderline/alphatab/model/NoteAccidentalMode'; +import { Ottavia } from '@coderline/alphatab/model/Ottavia'; +import { AccidentalHelper } from '@coderline/alphatab/rendering/utils/AccidentalHelper'; +import { describe, expect, it } from 'vitest'; + +// calculateRestDisplaySteps must return the same staff step as calculateNoteSteps for the same pitch. +// Per MusicXML semantics a rest override is displayed where a note of that pitch would render; +// SMuFL rest glyphs share the note-head baseline convention. The whole-rest one-line shift is applied +// by the caller, not by this function. + +// diatonic tones (semitone offsets within an octave): C=0, D=2, E=4, F=5, G=7, A=9, B=11 +const diatonicTones = [0, 2, 4, 5, 7, 9, 11]; +const octaves = [2, 3, 4, 5, 6]; + +function makeBar(clef: Clef, clefOttava: Ottavia = Ottavia.Regular): Bar { + const bar = new Bar(); + bar.clef = clef; + bar.clefOttava = clefOttava; + bar.keySignature = KeySignature.C; + return bar; +} + +function expectedNoteSteps(bar: Bar, tone: number, octave: number): number { + let noteValue = (octave + 1) * 12 + tone; + switch (bar.clefOttava) { + case Ottavia._15ma: + noteValue -= 24; + break; + case Ottavia._8va: + noteValue -= 12; + break; + case Ottavia._8vb: + noteValue += 12; + break; + case Ottavia._15mb: + noteValue += 24; + break; + } + const spelling = ModelUtils.resolveSpelling(bar.keySignature, noteValue, NoteAccidentalMode.Default); + return AccidentalHelper.calculateNoteSteps(bar.clef, spelling); +} + +function expectStepsMatchForClef(clef: Clef): void { + const bar = makeBar(clef); + for (const octave of octaves) { + for (const tone of diatonicTones) { + const restSteps = AccidentalHelper.calculateRestDisplaySteps(bar, tone, octave); + const noteSteps = expectedNoteSteps(bar, tone, octave); + expect(restSteps).toBe(noteSteps); + } + } +} + +describe('AccidentalHelper.calculateRestDisplaySteps', () => { + it('matches calculateNoteSteps for treble clef', () => { + expectStepsMatchForClef(Clef.G2); + }); + + it('matches calculateNoteSteps for bass clef', () => { + expectStepsMatchForClef(Clef.F4); + }); + + it('matches calculateNoteSteps for alto clef', () => { + expectStepsMatchForClef(Clef.C3); + }); + + it('matches calculateNoteSteps for tenor clef', () => { + expectStepsMatchForClef(Clef.C4); + }); + + it('matches calculateNoteSteps for neutral clef', () => { + expectStepsMatchForClef(Clef.Neutral); + }); + + it('applies ottava shifts consistently', () => { + for (const ottava of [Ottavia._15ma, Ottavia._8va, Ottavia.Regular, Ottavia._8vb, Ottavia._15mb]) { + const bar = makeBar(Clef.G2, ottava); + const restSteps = AccidentalHelper.calculateRestDisplaySteps(bar, 0, 4); // C4 + expect(restSteps).toBe(expectedNoteSteps(bar, 0, 4)); + } + }); +}); diff --git a/packages/alphatab/test/visualTests/features/RestPosition.test.ts b/packages/alphatab/test/visualTests/features/RestPosition.test.ts new file mode 100644 index 000000000..a34f2d4a4 --- /dev/null +++ b/packages/alphatab/test/visualTests/features/RestPosition.test.ts @@ -0,0 +1,104 @@ +import { VisualTestHelper } from 'test/visualTests/VisualTestHelper'; +import { describe, it } from 'vitest'; + +// Visual coverage for the rest-display-pitch pipeline. Each test renders the rest and a note at the same +// pitch so the reader can verify they land at the same staff position. The exhaustive step-math invariant +// is covered by AccidentalHelperRestDisplay.test.ts. + +// Renders one bar per rest duration, each pairing the overridden rest with a note at the same pitch. +// The whole-rest bar and the whole-note bar are separate since either occupies a full 4/4 measure. +function restVsNote(tex: string, pitch: string): string { + const prefix = tex ? `${tex} ` : ''; + const rest = (d: string) => `r.${d}{restDisplayPitch ${pitch}}`; + return [ + `${prefix}${rest('1')}`, + `${pitch}.1`, + `${rest('2')} ${pitch}.2`, + `${rest('4')} ${pitch}.4 *3`, + `${rest('8')} ${pitch}.8 *7`, + `${rest('16')} ${pitch}.16 *15`, + `${rest('32')} ${pitch}.32 *31` + ].join(' | '); +} + +describe('RestPositionTests', () => { + it('rest-position-default', async () => { + await VisualTestHelper.runVisualTestTex( + 'r.1 | r.2 e5.2 | r.4 e5.4 *3 | r.8 e5.8 *7 | r.16 e5.16 *15 | r.32 e5.32 *31', + 'test-data/visual-tests/rest-position/rest-position-default.png' + ); + }); + + it('rest-position-treble-E4', async () => { + await VisualTestHelper.runVisualTestTex( + restVsNote('', 'E4'), + 'test-data/visual-tests/rest-position/rest-position-treble-E4.png' + ); + }); + + it('rest-position-bass-G2', async () => { + await VisualTestHelper.runVisualTestTex( + restVsNote('\\clef bass', 'G2'), + 'test-data/visual-tests/rest-position/rest-position-bass-G2.png' + ); + }); + + it('rest-position-alto-F3', async () => { + await VisualTestHelper.runVisualTestTex( + restVsNote('\\clef alto', 'F3'), + 'test-data/visual-tests/rest-position/rest-position-alto-F3.png' + ); + }); + + it('rest-position-tenor-D3', async () => { + await VisualTestHelper.runVisualTestTex( + restVsNote('\\clef tenor', 'D3'), + 'test-data/visual-tests/rest-position/rest-position-tenor-D3.png' + ); + }); + + it('rest-position-staff-lines-1', async () => { + await VisualTestHelper.runVisualTestTex( + `\\staff { score 1 } ${restVsNote('', 'B4')}`, + 'test-data/visual-tests/rest-position/rest-position-staff-lines-1.png' + ); + }); + + it('rest-position-staff-lines-2', async () => { + await VisualTestHelper.runVisualTestTex( + `\\staff { score 2 } ${restVsNote('', 'B4')}`, + 'test-data/visual-tests/rest-position/rest-position-staff-lines-2.png' + ); + }); + + it('rest-position-staff-lines-3', async () => { + await VisualTestHelper.runVisualTestTex( + `\\staff { score 3 } ${restVsNote('', 'B4')}`, + 'test-data/visual-tests/rest-position/rest-position-staff-lines-3.png' + ); + }); + + it('rest-position-staff-lines-4', async () => { + await VisualTestHelper.runVisualTestTex( + `\\staff { score 4 } ${restVsNote('', 'B4')}`, + 'test-data/visual-tests/rest-position/rest-position-staff-lines-4.png' + ); + }); + + it('rest-position-staff-lines-5', async () => { + await VisualTestHelper.runVisualTestTex( + `\\staff { score 5 } ${restVsNote('', 'B4')}`, + 'test-data/visual-tests/rest-position/rest-position-staff-lines-5.png' + ); + }); + + it('rest-position-multi-voice', async () => { + await VisualTestHelper.runVisualTestTex( + '\\voice ' + + 'r.1{restDisplayPitch C5} | r.2{restDisplayPitch C5} C5.2 | r.4{restDisplayPitch C5} C5.4 *3' + + ' \\voice ' + + 'E4.1 | E4.2 r.2{restDisplayPitch E4} | E4.4 *3 r.4{restDisplayPitch E4}', + 'test-data/visual-tests/rest-position/rest-position-multi-voice.png' + ); + }); +}); diff --git a/packages/transpiler/src/TransformerHelpers.ts b/packages/transpiler/src/TransformerHelpers.ts index 11a3bc1fe..6459db3f0 100644 --- a/packages/transpiler/src/TransformerHelpers.ts +++ b/packages/transpiler/src/TransformerHelpers.ts @@ -72,11 +72,20 @@ export function getVisibility(context: EmitterContextBase, node: ts.Node): cs.Vi if (hasTag(node, JsDocTag.internal)) { return cs.Visibility.Internal; } - context.addTsNodeDiagnostics( - node, - 'All types need to define their visibility with @public or @internal', - ts.DiagnosticCategory.Error - ); + + switch (node.kind) { + case ts.SyntaxKind.ClassDeclaration: + case ts.SyntaxKind.InterfaceDeclaration: + case ts.SyntaxKind.EnumDeclaration: + case ts.SyntaxKind.TypeAliasDeclaration: + context.addTsNodeDiagnostics( + node, + 'All types need to define their visibility with @public or @internal', + ts.DiagnosticCategory.Error + ); + break; + } + return cs.Visibility.Internal; }