Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions packages/alphatab/src/generated/model/BeatCloner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
8 changes: 8 additions & 0 deletions packages/alphatab/src/generated/model/BeatSerializer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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<BrushType>(v, BrushType)!;
return true;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -664,6 +664,7 @@ export class AlphaTex1LanguageDefinitions {
]
],
['txt', [[[[17, 10], 0]]]],
['restdisplaypitch', [[[[10, 17], 0]]]],
[
'lyrics',
[
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 = '';
Expand Down
12 changes: 12 additions & 0 deletions packages/alphatab/src/model/Beat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*/
Expand Down
30 changes: 18 additions & 12 deletions packages/alphatab/src/rendering/glyphs/ScoreBeatGlyph.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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);
Expand Down
22 changes: 22 additions & 0 deletions packages/alphatab/src/rendering/utils/AccidentalHelper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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)!;
}
Expand Down
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Original file line number Diff line number Diff line change
@@ -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));
}
});
});
104 changes: 104 additions & 0 deletions packages/alphatab/test/visualTests/features/RestPosition.test.ts
Original file line number Diff line number Diff line change
@@ -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'
);
});
});
19 changes: 14 additions & 5 deletions packages/transpiler/src/TransformerHelpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Expand Down
Loading