Skip to content

Commit 9625031

Browse files
roscopeecoclaude
andcommitted
feat(rendering): add per-beat rest display position via restDisplayPitch tag
Adds Beat.restDisplayTone (Tone enum) and Beat.restDisplayOctave to allow specifying the vertical position of a rest on the staff on a per-beat basis. The AlphaTex restdisplaypitch tag (e.g. r.4{restdisplaypitch E4}) sets these values. AccidentalHelper.calculateRestDisplaySteps converts the pitch to rendering steps, aligned to match the legacy RestPosition enum spacing (+0.5). Includes 77 visual tests covering treble, bass, alto and tenor clefs across all staff positions, durations, multi-voice scenarios, and variable line counts. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 6719db9 commit 9625031

87 files changed

Lines changed: 277 additions & 6 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

packages/alphatab/src/generated/model/BeatCloner.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,8 @@ export class BeatCloner {
3939
clone.text = original.text;
4040
clone.slashed = original.slashed;
4141
clone.deadSlapped = original.deadSlapped;
42+
clone.restDisplayTone = original.restDisplayTone;
43+
clone.restDisplayOctave = original.restDisplayOctave;
4244
clone.brushType = original.brushType;
4345
clone.brushDuration = original.brushDuration;
4446
clone.tupletDenominator = original.tupletDenominator;

packages/alphatab/src/generated/model/BeatSerializer.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import { Ottavia } from "@coderline/alphatab/model/Ottavia";
1616
import { Duration } from "@coderline/alphatab/model/Duration";
1717
import { Automation } from "@coderline/alphatab/model/Automation";
1818
import { FadeType } from "@coderline/alphatab/model/FadeType";
19+
import { Tone } from "@coderline/alphatab/model/Tone";
1920
import { BrushType } from "@coderline/alphatab/model/BrushType";
2021
import { WhammyType } from "@coderline/alphatab/model/WhammyType";
2122
import { BendPoint } from "@coderline/alphatab/model/BendPoint";
@@ -64,6 +65,8 @@ export class BeatSerializer {
6465
o.set("text", obj.text);
6566
o.set("slashed", obj.slashed);
6667
o.set("deadslapped", obj.deadSlapped);
68+
o.set("restdisplaytone", obj.restDisplayTone as number | null);
69+
o.set("restdisplayoctave", obj.restDisplayOctave);
6770
o.set("brushtype", obj.brushType as number);
6871
o.set("brushduration", obj.brushDuration);
6972
o.set("tupletdenominator", obj.tupletDenominator);
@@ -165,6 +168,12 @@ export class BeatSerializer {
165168
case "deadslapped":
166169
obj.deadSlapped = v! as boolean;
167170
return true;
171+
case "restdisplaytone":
172+
obj.restDisplayTone = JsonHelper.parseEnum<Tone>(v, Tone) ?? null;
173+
return true;
174+
case "restdisplayoctave":
175+
obj.restDisplayOctave = v! as number;
176+
return true;
168177
case "brushtype":
169178
obj.brushType = JsonHelper.parseEnum<BrushType>(v, BrushType)!;
170179
return true;

packages/alphatab/src/importer/alphaTex/AlphaTex1LanguageDefinitions.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -664,6 +664,7 @@ export class AlphaTex1LanguageDefinitions {
664664
]
665665
],
666666
['txt', [[[[17, 10], 0]]]],
667+
['restdisplaypitch', [[[[10, 17], 0]]]],
667668
[
668669
'lyrics',
669670
[

packages/alphatab/src/importer/alphaTex/AlphaTex1LanguageHandler.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,7 @@ import { NoteAccidentalMode } from '@coderline/alphatab/model/NoteAccidentalMode
7070
import { NoteOrnament } from '@coderline/alphatab/model/NoteOrnament';
7171
import { Ottavia } from '@coderline/alphatab/model/Ottavia';
7272
import { PercussionMapper } from '@coderline/alphatab/model/PercussionMapper';
73+
import { Tone } from '@coderline/alphatab/model/Tone';
7374
import { PickStroke } from '@coderline/alphatab/model/PickStroke';
7475
import { BarNumberDisplay, type RenderStylesheet } from '@coderline/alphatab/model/RenderStylesheet';
7576
import { HeaderFooterStyle, Score, ScoreStyle, ScoreSubElement } from '@coderline/alphatab/model/Score';
@@ -1616,6 +1617,25 @@ export class AlphaTex1LanguageHandler implements IAlphaTexLanguageImportHandler
16161617
case 'txt':
16171618
beat.text = (p.arguments!.arguments[0] as AlphaTexTextNode).text;
16181619
return ApplyNodeResult.Applied;
1620+
case 'restdisplaypitch': {
1621+
const pitchText = (p.arguments!.arguments[0] as AlphaTexTextNode).text.toUpperCase();
1622+
const toneChar = pitchText[0] as keyof typeof Tone;
1623+
const octave = parseInt(pitchText.slice(1));
1624+
if (toneChar in Tone && !isNaN(octave)) {
1625+
beat.restDisplayTone = Tone[toneChar];
1626+
beat.restDisplayOctave = octave;
1627+
} else {
1628+
importer.addSemanticDiagnostic({
1629+
code: AlphaTexDiagnosticCode.AT212,
1630+
message: `Invalid pitch value '${pitchText}', expected format like 'C5' or 'G4'`,
1631+
severity: AlphaTexDiagnosticsSeverity.Error,
1632+
start: p.arguments!.arguments[0].start,
1633+
end: p.arguments!.arguments[0].end
1634+
});
1635+
return ApplyNodeResult.NotAppliedSemanticError;
1636+
}
1637+
return ApplyNodeResult.Applied;
1638+
}
16191639
case 'lyrics':
16201640
let lyricsLine = 0;
16211641
let lyricsText = '';

packages/alphatab/src/model/Beat.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import type { Fermata } from '@coderline/alphatab/model/Fermata';
1212
import { GraceType } from '@coderline/alphatab/model/GraceType';
1313
import { Note } from '@coderline/alphatab/model/Note';
1414
import { Ottavia } from '@coderline/alphatab/model/Ottavia';
15+
import { Tone } from '@coderline/alphatab/model/Tone';
1516
import { PickStroke } from '@coderline/alphatab/model/PickStroke';
1617
import type { Slur } from '@coderline/alphatab/model/Slur';
1718
import { TupletGroup } from '@coderline/alphatab/model/TupletGroup';
@@ -424,6 +425,18 @@ export class Beat {
424425
*/
425426
public deadSlapped: boolean = false;
426427

428+
/**
429+
* Gets or sets the tone of the pitch at which this rest should be displayed.
430+
* Use values from the {@link Tone} enum. Null means use the default position formula.
431+
*/
432+
public restDisplayTone: Tone | null = null;
433+
434+
/**
435+
* Gets or sets the octave at which this rest should be displayed.
436+
* Only relevant when {@link restDisplayTone} is set. -1 means use the default position formula.
437+
*/
438+
public restDisplayOctave: number = -1;
439+
427440
/**
428441
* Gets or sets the brush type applied to the notes of this beat.
429442
*/
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
/**
2+
* Represents the diatonic tone (note name) within an octave.
3+
* Used as the value for {@link Beat.restDisplayTone} to specify where a rest should be displayed on the staff.
4+
* @public
5+
*/
6+
export enum Tone {
7+
C = 0,
8+
D = 2,
9+
E = 4,
10+
F = 5,
11+
G = 7,
12+
A = 9,
13+
B = 11
14+
}

packages/alphatab/src/model/_barrel.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ export { Note, NoteSubElement, NoteStyle } from '@coderline/alphatab/model/Note'
4242
export { NoteAccidentalMode } from '@coderline/alphatab/model/NoteAccidentalMode';
4343
export { NoteOrnament } from '@coderline/alphatab/model/NoteOrnament';
4444
export { Ottavia } from '@coderline/alphatab/model/Ottavia';
45+
export { Tone } from '@coderline/alphatab/model/Tone';
4546
export { PickStroke } from '@coderline/alphatab/model/PickStroke';
4647
export { PlaybackInformation } from '@coderline/alphatab/model/PlaybackInformation';
4748
export { Rasgueado } from '@coderline/alphatab/model/Rasgueado';

packages/alphatab/src/rendering/glyphs/ScoreBeatGlyph.ts

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { Logger } from '@coderline/alphatab/Logger';
22
import { AccentuationType } from '@coderline/alphatab/model/AccentuationType';
3+
import { AccidentalHelper } from '@coderline/alphatab/rendering/utils/AccidentalHelper';
34
import { BeatSubElement } from '@coderline/alphatab/model/Beat';
45
import { Duration } from '@coderline/alphatab/model/Duration';
56
import { GraceType } from '@coderline/alphatab/model/GraceType';
@@ -301,17 +302,20 @@ export class ScoreBeatGlyph extends BeatOnNoteGlyphBase {
301302

302303
private _createRestGlyphs() {
303304
const sr = this.renderer as ScoreBarRenderer;
305+
const beat = this.container.beat;
306+
const lineCount = this.renderer.bar.staff.standardNotationLineCount;
304307

305-
let steps = Math.ceil((this.renderer.bar.staff.standardNotationLineCount - 1) / 2) * 2;
308+
let steps: number;
309+
if (beat.restDisplayTone !== null && beat.restDisplayOctave !== -1) {
310+
steps = AccidentalHelper.calculateRestDisplaySteps(sr.bar, beat.restDisplayTone, beat.restDisplayOctave);
311+
} else {
312+
steps = Math.ceil((lineCount - 1) / 2) * 2;
313+
}
306314

307315
// this positioning is quite strange, for most staff line counts
308316
// the whole/rest are aligned as half below the whole rest.
309317
// but for staff line count 1 and 3 they are aligned centered on the same line.
310-
if (
311-
this.container.beat.duration === Duration.Whole &&
312-
this.renderer.bar.staff.standardNotationLineCount !== 1 &&
313-
this.renderer.bar.staff.standardNotationLineCount !== 3
314-
) {
318+
if (beat.duration === Duration.Whole && lineCount !== 1 && lineCount !== 3) {
315319
steps -= 2;
316320
}
317321

packages/alphatab/src/rendering/utils/AccidentalHelper.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import type { Clef } from '@coderline/alphatab/model/Clef';
55
import { ModelUtils, type ResolvedSpelling } from '@coderline/alphatab/model/ModelUtils';
66
import type { Note } from '@coderline/alphatab/model/Note';
77
import { NoteAccidentalMode } from '@coderline/alphatab/model/NoteAccidentalMode';
8+
import { Ottavia } from '@coderline/alphatab/model/Ottavia';
89
import { PercussionMapper } from '@coderline/alphatab/model/PercussionMapper';
910
import type { LineBarRenderer } from '@coderline/alphatab/rendering/LineBarRenderer';
1011
import type { ScoreBarRenderer } from '@coderline/alphatab/rendering/ScoreBarRenderer';
@@ -267,6 +268,28 @@ export class AccidentalHelper {
267268
return steps;
268269
}
269270

271+
public static calculateRestDisplaySteps(bar: Bar, tone: number, octave: number): number {
272+
273+
let noteValue = (octave + 1) * 12 + tone;
274+
switch (bar.clefOttava) {
275+
case Ottavia._15ma:
276+
noteValue -= 24;
277+
break;
278+
case Ottavia._8va:
279+
noteValue -= 12;
280+
break;
281+
case Ottavia._8vb:
282+
noteValue += 12;
283+
break;
284+
case Ottavia._15mb:
285+
noteValue += 24;
286+
break;
287+
}
288+
289+
const spelling = ModelUtils.resolveSpelling(bar.keySignature, noteValue, NoteAccidentalMode.Default);
290+
return AccidentalHelper.calculateNoteSteps(bar.clef, spelling) + 0.5;
291+
}
292+
270293
public getNoteSteps(n: Note): number {
271294
return this._appliedScoreSteps.get(n.id)!;
272295
}
28.6 KB

0 commit comments

Comments
 (0)