diff --git a/packages/alphatab/src/NotationSettings.ts b/packages/alphatab/src/NotationSettings.ts index fcb6f41b1a..848d8a81ad 100644 --- a/packages/alphatab/src/NotationSettings.ts +++ b/packages/alphatab/src/NotationSettings.ts @@ -534,6 +534,9 @@ export class NotationSettings { * @remarks * This setting allows transposing of tracks for display and playback. * The `transpositionPitches` setting allows defining an additional pitch offset per track, that is then considered when displaying the music sheet. + * + * {@since 1.10.0} Guitar tabs of transposed tracks move notes to other strings when the shifted fret is not playable + * on their string. Notes which fit on no string keep their string and are painted in `display.resources.tabUnplayableFretColor`. */ public transpositionPitches: number[] = []; diff --git a/packages/alphatab/src/RenderingResources.ts b/packages/alphatab/src/RenderingResources.ts index cf393b5e7e..d0cd62a95c 100644 --- a/packages/alphatab/src/RenderingResources.ts +++ b/packages/alphatab/src/RenderingResources.ts @@ -349,6 +349,13 @@ export class RenderingResources { */ public scoreInfoColor: Color = new Color(0, 0, 0, 0xff); + /** + * The color of tab fret numbers which cannot be played on the tuning of the staff after transposition. + * @defaultValue `rgb(200, 0, 0)` + * @since 1.10.0 + */ + public tabUnplayableFretColor: Color = new Color(200, 0, 0, 0xff); + public constructor() { for (const [k, v] of RenderingResources.defaultFonts) { this.elementFonts.set(k, v.withSize(v.size)); diff --git a/packages/alphatab/src/generated/NotationSettingsJson.ts b/packages/alphatab/src/generated/NotationSettingsJson.ts index eef659457b..6bf98f230e 100644 --- a/packages/alphatab/src/generated/NotationSettingsJson.ts +++ b/packages/alphatab/src/generated/NotationSettingsJson.ts @@ -143,6 +143,9 @@ export interface NotationSettingsJson { * @remarks * This setting allows transposing of tracks for display and playback. * The `transpositionPitches` setting allows defining an additional pitch offset per track, that is then considered when displaying the music sheet. + * + * {@since 1.10.0} Guitar tabs of transposed tracks move notes to other strings when the shifted fret is not playable + * on their string. Notes which fit on no string keep their string and are painted in `display.resources.tabUnplayableFretColor`. */ transpositionPitches?: number[]; /** diff --git a/packages/alphatab/src/generated/RenderingResourcesJson.ts b/packages/alphatab/src/generated/RenderingResourcesJson.ts index f4ddafe3d1..e546302c01 100644 --- a/packages/alphatab/src/generated/RenderingResourcesJson.ts +++ b/packages/alphatab/src/generated/RenderingResourcesJson.ts @@ -199,4 +199,10 @@ export interface RenderingResourcesJson { * @since 0.9.6 */ scoreInfoColor?: ColorJson; + /** + * The color of tab fret numbers which cannot be played on the tuning of the staff after transposition. + * @defaultValue `rgb(200, 0, 0)` + * @since 1.10.0 + */ + tabUnplayableFretColor?: ColorJson; } diff --git a/packages/alphatab/src/generated/RenderingResourcesSerializer.ts b/packages/alphatab/src/generated/RenderingResourcesSerializer.ts index 1ebcbe3e41..42851636e3 100644 --- a/packages/alphatab/src/generated/RenderingResourcesSerializer.ts +++ b/packages/alphatab/src/generated/RenderingResourcesSerializer.ts @@ -16,7 +16,7 @@ import type { IObjectWriter } from "@coderline/alphatab/io/IObjectWriter"; * @internal */ export class RenderingResourcesSerializer { - private static readonly _fields: RecordFields = new RecordFields(["smuflfontfamilyname", "engravingsettings", "copyrightfont", "titlefont", "subtitlefont", "wordsfont", "timerfont", "directionsfont", "fretboardnumberfont", "fingeringfont", "inlinefingeringfont", "markerfont", "effectfont", "barnumberfont", "elementfonts", "numberednotationfont", "numberednotationgracefont", "tablaturefont", "gracefont", "stafflinecolor", "barseparatorcolor", "barnumbercolor", "mainglyphcolor", "secondaryglyphcolor", "scoreinfocolor"]); + private static readonly _fields: RecordFields = new RecordFields(["smuflfontfamilyname", "engravingsettings", "copyrightfont", "titlefont", "subtitlefont", "wordsfont", "timerfont", "directionsfont", "fretboardnumberfont", "fingeringfont", "inlinefingeringfont", "markerfont", "effectfont", "barnumberfont", "elementfonts", "numberednotationfont", "numberednotationgracefont", "tablaturefont", "gracefont", "stafflinecolor", "barseparatorcolor", "barnumbercolor", "mainglyphcolor", "secondaryglyphcolor", "scoreinfocolor", "tabunplayablefretcolor"]); public static read(obj: RenderingResources, reader: IObjectReader): void { reader.readRecord(RenderingResourcesSerializer._fields, (id, k, rr) => RenderingResourcesSerializer._readField(obj, id, rr, false)); } @@ -101,6 +101,9 @@ export class RenderingResourcesSerializer { case 24: obj.scoreInfoColor = Color.fromJson(r.readValue())!; return true; + case 25: + obj.tabUnplayableFretColor = Color.fromJson(r.readValue())!; + return true; } return false; } @@ -148,6 +151,7 @@ export class RenderingResourcesSerializer { w.valueField(22, Color.toJson(obj.mainGlyphColor)); w.valueField(23, Color.toJson(obj.secondaryGlyphColor)); w.valueField(24, Color.toJson(obj.scoreInfoColor)); + w.valueField(25, Color.toJson(obj.tabUnplayableFretColor)); w.endObject(); } } diff --git a/packages/alphatab/src/generated/SerializerSchema.ts b/packages/alphatab/src/generated/SerializerSchema.ts index 375b6fbb4d..82ce5af23b 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 = 0xacc550b1; + public static readonly fingerprint: number = 0xa8bdf264; } diff --git a/packages/alphatab/src/rendering/TabBarRenderer.ts b/packages/alphatab/src/rendering/TabBarRenderer.ts index dc633a08f0..0a27c42f1c 100644 --- a/packages/alphatab/src/rendering/TabBarRenderer.ts +++ b/packages/alphatab/src/rendering/TabBarRenderer.ts @@ -25,6 +25,8 @@ import { BeamDirection } from '@coderline/alphatab/rendering/utils/BeamDirection import type { BeamingHelper } from '@coderline/alphatab/rendering/utils/BeamingHelper'; import { ElementStyleHelper } from '@coderline/alphatab/rendering/utils/ElementStyleHelper'; import type { ScrambledPath } from '@coderline/alphatab/rendering/utils/ScrambledPath'; +import { TabFingering } from '@coderline/alphatab/rendering/utils/TabFingering'; +import type { NoteNumberGlyph } from '@coderline/alphatab/rendering/glyphs/NoteNumberGlyph'; /** * This BarRenderer renders a bar using guitar tablature notation @@ -137,7 +139,73 @@ export class TabBarRenderer extends LineBarRenderer { } public override getNoteLine(note: Note): number { - return this.bar.staff.tuning.length - note.string; + return this.bar.staff.tuning.length - this.getDisplayString(note); + } + + /** + * The glyph showing the given note in the tab of its bar, null if the note is not shown. + */ + public getNoteGlyph(note: Note): NoteNumberGlyph | null { + const bar = note.beat.voice.bar; + // a renderer is registered in the layout only after its own layout + const renderer = + bar === this.bar ? this : this.scoreRenderer.layout!.getRendererForBar(TabBarRenderer.StaffId, bar); + const container = renderer instanceof TabBarRenderer ? renderer.getBeatContainer(note.beat) : undefined; + const noteNumbers = container + ? ((container as TabBeatContainerGlyph).onNotes as TabBeatGlyph).noteNumbers + : null; + return noteNumbers ? noteNumbers.findNoteGlyph(note) : null; + } + + /** + * The string (1 = lowest) on which the note is shown, see {@link TabFingering}. + */ + public getDisplayString(note: Note): number { + const glyph = this.getNoteGlyph(note); + return glyph !== null && glyph.displayString > 0 ? glyph.displayString : note.string; + } + + /** + * The fret shown for the note, the node for natural harmonics. + */ + public getDisplayFret(note: Note): number { + const glyph = this.getNoteGlyph(note); + return glyph !== null && glyph.displayString > 0 + ? glyph.displayFret + : note.fret - note.beat.voice.bar.staff.transpositionPitch; + } + + /** + * The stringed note of the beat shown on the highest string. + */ + public getDisplayMaxStringNote(beat: Beat): Note | null { + const container = this.getBeatContainer(beat); + const noteNumbers = container + ? ((container as TabBeatContainerGlyph).onNotes as TabBeatGlyph).noteNumbers + : null; + return noteNumbers ? noteNumbers.maxStringNote : beat.maxStringNote; + } + + /** + * The stringed note of the beat shown on the lowest string. + */ + public getDisplayMinStringNote(beat: Beat): Note | null { + const container = this.getBeatContainer(beat); + const noteNumbers = container + ? ((container as TabBeatContainerGlyph).onNotes as TabBeatGlyph).noteNumbers + : null; + return noteNumbers ? noteNumbers.minStringNote : beat.minStringNote; + } + + /** + * The tab glyph of the note for renderers of other notations, resolved on paint when all tab bars are laid out. + */ + public static findTabNoteGlyph(renderer: BarRendererBase, note: Note): NoteNumberGlyph | null { + if (!TabFingering.isActive(note.beat.voice.bar.staff)) { + return null; + } + const tab = renderer.scoreRenderer.layout!.getRendererForBar(TabBarRenderer.StaffId, note.beat.voice.bar); + return tab instanceof TabBarRenderer ? tab.getNoteGlyph(note) : null; } public minString = Number.NaN; @@ -295,7 +363,7 @@ export class TabBarRenderer extends LineBarRenderer { const noteGlyphs = noteNumbers.noteGlyphs; for (let k = 0, nk = noteGlyphs.length; k < nk; k++) { const g = noteGlyphs[k]; - const str = g.note.string; + const str = g.displayString; if (!g.isEmpty && noteNumbers.getNoteGlyphForString(str) === g) { const lineIdx = tuningLen - str; if (lineIdx >= 0 && lineIdx < drawnLineCount) { @@ -342,7 +410,7 @@ export class TabBarRenderer extends LineBarRenderer { const noteGlyphs = noteNumbers.noteGlyphs; for (let k = 0, nk = noteGlyphs.length; k < nk; k++) { const g = noteGlyphs[k]; - const str = g.note.string; + const str = g.displayString; if (!g.isEmpty && noteNumbers.getNoteGlyphForString(str) === g) { const lineIdx = tuningLen - str; if (lineIdx >= 0 && lineIdx < drawnLineCount) { @@ -402,8 +470,10 @@ export class TabBarRenderer extends LineBarRenderer { // Per-beat half-line overflow, not bar-wide. Strings are 1-indexed: top = tuning.length, bottom = 1. const beat = beatContainer.beat; const stringCount = this.bar.staff.tuning.length; - const hasTop = beat.maxStringNote !== null && beat.maxStringNote.string === stringCount; - const hasBottom = beat.minStringNote !== null && beat.minStringNote.string === 1; + const maxStringNote = this.getDisplayMaxStringNote(beat); + const minStringNote = this.getDisplayMinStringNote(beat); + const hasTop = maxStringNote !== null && this.getDisplayString(maxStringNote) === stringCount; + const hasBottom = minStringNote !== null && this.getDisplayString(minStringNote) === 1; if (!hasTop && !hasBottom) { return; } @@ -516,7 +586,7 @@ export class TabBarRenderer extends LineBarRenderer { } protected override getFlagTopY(beat: Beat, direction: BeamDirection): number { - const maxNote = beat.maxStringNote; + const maxNote = this.getDisplayMaxStringNote(beat); const position = direction === BeamDirection.Up ? NoteYPosition.TopWithStem : NoteYPosition.StemDown; if (maxNote) { return this.getNoteY(maxNote, position); @@ -526,7 +596,7 @@ export class TabBarRenderer extends LineBarRenderer { } protected override getFlagBottomY(beat: Beat, direction: BeamDirection): number { - const maxNote = beat.minStringNote; + const maxNote = this.getDisplayMinStringNote(beat); const position = direction === BeamDirection.Up ? NoteYPosition.StemUp : NoteYPosition.BottomWithStem; if (maxNote) { diff --git a/packages/alphatab/src/rendering/glyphs/FingeringGroupGlyph.ts b/packages/alphatab/src/rendering/glyphs/FingeringGroupGlyph.ts index 188a35b478..331cd5e491 100644 --- a/packages/alphatab/src/rendering/glyphs/FingeringGroupGlyph.ts +++ b/packages/alphatab/src/rendering/glyphs/FingeringGroupGlyph.ts @@ -10,6 +10,8 @@ import type { Settings } from '@coderline/alphatab/Settings'; import type { Beat } from '@coderline/alphatab/model/Beat'; import { Fingers } from '@coderline/alphatab/model/Fingers'; import { GeneralMidi } from '@coderline/alphatab/midi/GeneralMidi'; +import type { ICanvas } from '@coderline/alphatab/platform/ICanvas'; +import { TabBarRenderer } from '@coderline/alphatab/rendering/TabBarRenderer'; /** * @internal @@ -18,6 +20,11 @@ export class FingeringInfo { public line: number = 0; public symbols: MusicFontSymbol[]; public color: Color | undefined; + /** + * The notes of the left-hand fingers in {@link symbols}. + */ + public leftHandNotes: Note[] = []; + public otherSymbolCount: number = 0; public constructor(line: number, symbols: MusicFontSymbol[]) { this.line = line; @@ -30,6 +37,7 @@ export class FingeringInfo { */ export class FingeringGroupGlyph extends GlyphGroup { private _infos: Map = new Map(); + private _glyphInfos: FingeringInfo[] = []; public constructor() { super(0, 0); @@ -72,7 +80,7 @@ export class FingeringGroupGlyph extends GlyphGroup { true ); if (symbolLeft !== MusicFontSymbol.None) { - this._addFinger(note, symbolLeft); + this._addFinger(note, symbolLeft, true); } const symbolRight = FingeringGroupGlyph.fingerToMusicFontSymbol( this.renderer.settings, @@ -81,7 +89,7 @@ export class FingeringGroupGlyph extends GlyphGroup { false ); if (symbolRight !== MusicFontSymbol.None) { - this._addFinger(note, symbolRight); + this._addFinger(note, symbolRight, false); } } @@ -153,18 +161,52 @@ export class FingeringGroupGlyph extends GlyphGroup { } } - private _addFinger(note: Note, symbol: MusicFontSymbol) { + private _addFinger(note: Note, symbol: MusicFontSymbol, isLeftHand: boolean) { const sr = this.renderer as ScoreBarRenderer; const steps: number = sr.getNoteSteps(note); + let info: FingeringInfo; if (!this._infos.has(steps)) { - const info = new FingeringInfo(steps, [symbol]); + info = new FingeringInfo(steps, [symbol]); info.color = ElementStyleHelper.noteColor(sr.resources, NoteSubElement.StandardNotationEffects, note); this._infos.set(steps, info); } else { - const info = this._infos.get(steps)!; + info = this._infos.get(steps)!; info.symbols.push(symbol); } + if (isLeftHand) { + info.leftHandNotes.push(note); + } else { + info.otherSymbolCount++; + } + } + + public override paint(cx: number, cy: number, canvas: ICanvas): void { + const glyphs = this.glyphs; + if (!glyphs) { + return; + } + const x = cx + this.x; + const y = cy + this.y; + for (let i = 0; i < glyphs.length; i++) { + if (!this._isReFingered(this._glyphInfos[i])) { + glyphs[i].paint(x, y, canvas); + } + } + } + + // resolved on paint: the tab of the bar is laid out after this staff + private _isReFingered(info: FingeringInfo): boolean { + if (info.otherSymbolCount > 0 || info.leftHandNotes.length === 0) { + return false; + } + for (const note of info.leftHandNotes) { + const tabGlyph = TabBarRenderer.findTabNoteGlyph(this.renderer, note); + if (tabGlyph === null || !(tabGlyph.isMoved || tabGlyph.displayFret === 0)) { + return false; + } + } + return true; } public override doLayout(): void { @@ -178,6 +220,7 @@ export class FingeringGroupGlyph extends GlyphGroup { g.doLayout(); g.offsetY = g.height / 2; this.addGlyph(g); + this._glyphInfos.push(info); this.width = Math.max(this.width, g.width); } diff --git a/packages/alphatab/src/rendering/glyphs/NoteNumberGlyph.ts b/packages/alphatab/src/rendering/glyphs/NoteNumberGlyph.ts index 1b099a5d94..97a82d7309 100644 --- a/packages/alphatab/src/rendering/glyphs/NoteNumberGlyph.ts +++ b/packages/alphatab/src/rendering/glyphs/NoteNumberGlyph.ts @@ -1,8 +1,9 @@ import { BendType } from '@coderline/alphatab/model/BendType'; +import type { Color } from '@coderline/alphatab/model/Color'; import type { Font } from '@coderline/alphatab/model/Font'; import { HarmonicType } from '@coderline/alphatab/model/HarmonicType'; import { ModelUtils } from '@coderline/alphatab/model/ModelUtils'; -import { type Note, NoteSubElement } from '@coderline/alphatab/model/Note'; +import { Note, NoteSubElement } from '@coderline/alphatab/model/Note'; import { PercussionMapper } from '@coderline/alphatab/model/PercussionMapper'; import { NotationElement, NotationMode } from '@coderline/alphatab/NotationSettings'; import type { ICanvas } from '@coderline/alphatab/platform/ICanvas'; @@ -23,11 +24,35 @@ export class NoteNumberGlyph extends Glyph { public isEmpty: boolean = false; public noteStringWidth: number = 0; + /** + * The string (1 = lowest) on which the note is shown, 0 until it is placed. + */ + public displayString: number = 0; + /** + * The shown fret, the node for natural harmonics. + */ + public displayFret: number = 0; + /** + * Whether the note is shown on another string than in the model. + */ + public isMoved: boolean = false; + /** + * Whether no string of the transposed tab can play the note. + */ + public isUnplayable: boolean = false; + public constructor(x: number, y: number, note: Note) { super(x, y); this._note = note; } + public setDisplay(str: number, fret: number, isMoved: boolean, isUnplayable: boolean): void { + this.displayString = str; + this.displayFret = fret; + this.isMoved = isMoved; + this.isUnplayable = isUnplayable; + } + public get note(): Note { return this._note; } @@ -46,12 +71,10 @@ export class NoteNumberGlyph extends Glyph { public override doLayout(): void { const n: Note = this._note; + const tr = this.renderer as TabBarRenderer; let fret: number; if (n.isStringed) { - fret = n.fret - n.beat.voice.bar.staff.transpositionPitch; - if (n.harmonicType === HarmonicType.Natural && n.harmonicValue !== 0) { - fret = n.harmonicValue - n.beat.voice.bar.staff.transpositionPitch; - } + fret = this.displayFret; } else { const articulation = PercussionMapper.getArticulation(n); if (articulation) { @@ -78,12 +101,15 @@ export class NoteNumberGlyph extends Glyph { ((n.bendType === BendType.Bend || n.bendType === BendType.BendRelease) && this.renderer.settings.notation.isNotationElementVisible(NotationElement.TabNotesOnTiedBends)) ) { - this._noteString = `(${(n.tieOrigin!.fret - n.beat.voice.bar.staff.transpositionPitch).toString()})`; + this._noteString = `(${tr.getDisplayFret(n.tieOrigin!).toString()})`; } else { this._noteString = ''; } if (n.isTrill) { - this._trillNoteString = `(${(n.trillFret - n.beat.voice.bar.staff.transpositionPitch).toString()})`; + const staff = n.beat.voice.bar.staff; + const trillFret = + n.trillValue - staff.transpositionPitch - staff.capo - Note.getStringTuning(staff, this.displayString); + this._trillNoteString = `(${trillFret.toString()})`; } else if (!ModelUtils.isAlmostEqualTo(n.harmonicValue, 0)) { switch (n.harmonicType) { case HarmonicType.Artificial: @@ -141,7 +167,7 @@ export class NoteNumberGlyph extends Glyph { this.paintTrill(x, y, canvas); - const previousColor = ElementStyleHelper.applyNote(canvas, NoteSubElement.GuitarTabFretNumber, this._note); + const previousColor = this._applyColor(canvas); try { canvas.fillText(this._noteString!, x, y); } finally { @@ -155,7 +181,7 @@ export class NoteNumberGlyph extends Glyph { if (!this._trillNoteString) { return; } - const previousColor = ElementStyleHelper.applyNote(canvas, NoteSubElement.GuitarTabFretNumber, this._note); + const previousColor = this._applyColor(canvas); try { const prevFont: Font = this.renderer.scoreRenderer.canvas!.font; this.renderer.scoreRenderer.canvas!.font = this.renderer.resources.graceFont; @@ -166,6 +192,17 @@ export class NoteNumberGlyph extends Glyph { } } + private _applyColor(canvas: ICanvas): Color | undefined { + return this.isUnplayable + ? ElementStyleHelper.applyNoteWithDefault( + canvas, + NoteSubElement.GuitarTabFretNumber, + this._note, + this.renderer.resources.tabUnplayableFretColor + ) + : ElementStyleHelper.applyNote(canvas, NoteSubElement.GuitarTabFretNumber, this._note); + } + public buildBoundingsLookup(writer: IBoundsLookupWriter, cx: number, cy: number) { const scale = this.renderer.settings.display.scale; writer.addNote( diff --git a/packages/alphatab/src/rendering/glyphs/ScoreBeatGlyph.ts b/packages/alphatab/src/rendering/glyphs/ScoreBeatGlyph.ts index 6fdde8a926..5b958971b2 100644 --- a/packages/alphatab/src/rendering/glyphs/ScoreBeatGlyph.ts +++ b/packages/alphatab/src/rendering/glyphs/ScoreBeatGlyph.ts @@ -455,7 +455,7 @@ export class ScoreBeatGlyph extends BeatOnNoteGlyphBase { } else { container = aboveBeatEffects.get('StringNumber')! as StringNumberContainerGlyph; } - container.addString(n.string, n.realValue); + container.addString(n.string, n.realValue, n); } if (n.isPercussion) { const articulation = PercussionMapper.getArticulation(n); diff --git a/packages/alphatab/src/rendering/glyphs/StringNumberContainerGlyph.ts b/packages/alphatab/src/rendering/glyphs/StringNumberContainerGlyph.ts index 85d27f84dd..754dedcce8 100644 --- a/packages/alphatab/src/rendering/glyphs/StringNumberContainerGlyph.ts +++ b/packages/alphatab/src/rendering/glyphs/StringNumberContainerGlyph.ts @@ -2,6 +2,7 @@ import { EffectGlyph } from '@coderline/alphatab/rendering/glyphs/EffectGlyph'; import { MusicFontSymbol } from '@coderline/alphatab/model/MusicFontSymbol'; import { Note } from '@coderline/alphatab/model/Note'; import { CanvasHelper, type ICanvas } from '@coderline/alphatab/platform/ICanvas'; +import { TabBarRenderer } from '@coderline/alphatab/rendering/TabBarRenderer'; /** * @internal @@ -10,13 +11,15 @@ export class StringNumberContainerGlyph extends EffectGlyph { // top to bottom: highest note first private _strings: number[] = []; private _pitches: number[] = []; + private _notes: Note[] = []; - public addString(string: number, pitch: number) { + public addString(string: number, pitch: number, note: Note) { if (this._strings.indexOf(string) >= 0) { return; } this._strings.push(string); this._pitches.push(pitch); + this._notes.push(note); let i = this._strings.length - 1; while ( i > 0 && @@ -24,10 +27,12 @@ export class StringNumberContainerGlyph extends EffectGlyph { ) { this._strings[i] = this._strings[i - 1]; this._pitches[i] = this._pitches[i - 1]; + this._notes[i] = this._notes[i - 1]; i--; } this._strings[i] = string; this._pitches[i] = pitch; + this._notes[i] = note; } public override doLayout(): void { @@ -43,8 +48,10 @@ export class StringNumberContainerGlyph extends EffectGlyph { let y = 0; const circleHeight = this.renderer.smuflMetrics.glyphWidths.get(MusicFontSymbol.GuitarString0)! * this.renderer.smuflMetrics.tuningGlyphCircleNumberScale; - for (const s of this._strings) { - const stringValue = stringCount - s; + for (const note of this._notes) { + // resolved on paint: the tab of the bar is laid out after this staff + const tabGlyph = TabBarRenderer.findTabNoteGlyph(this.renderer, note); + const stringValue = stringCount - (tabGlyph !== null ? tabGlyph.displayString : note.string); const symbol = ((MusicFontSymbol.GuitarString1 as number) + stringValue) as MusicFontSymbol; CanvasHelper.fillMusicFontSymbolSafe(canvas, cx + this.x, diff --git a/packages/alphatab/src/rendering/glyphs/TabBeatContainerGlyph.ts b/packages/alphatab/src/rendering/glyphs/TabBeatContainerGlyph.ts index 204886c698..949a82bf2d 100644 --- a/packages/alphatab/src/rendering/glyphs/TabBeatContainerGlyph.ts +++ b/packages/alphatab/src/rendering/glyphs/TabBeatContainerGlyph.ts @@ -62,12 +62,12 @@ export class TabBeatContainerGlyph extends BeatContainerGlyph { // effect slurs: one arc per side of the beat (see TabSlurGlyph) // start effect slur on first beat if (n.isEffectSlurOrigin && n.effectSlurDestination) { - const direction = TabTieGlyph.getBeamDirectionForNote(n); + const direction = TabTieGlyph.getBeamDirectionForString(renderer.getDisplayString(n)); let slurStarts = this._effectSlurStarts; const group = slurStarts !== null && slurStarts.has(direction) ? null - : TabSlurGlyph.getEffectSlurGroup(this.beat, direction); + : TabSlurGlyph.getEffectSlurGroup(renderer, this.beat, direction); if (group) { if (!slurStarts) { slurStarts = new Set(); @@ -81,12 +81,12 @@ export class TabBeatContainerGlyph extends BeatContainerGlyph { } // end effect slur on last beat if (n.isEffectSlurDestination && n.effectSlurOrigin) { - const direction = TabTieGlyph.getBeamDirectionForNote(n.effectSlurOrigin); + const direction = TabTieGlyph.getBeamDirectionForString(renderer.getDisplayString(n.effectSlurOrigin)); let slurEnds = this._effectSlurEnds; const group = slurEnds !== null && slurEnds.has(direction) ? null - : TabSlurGlyph.getEffectSlurGroup(n.effectSlurOrigin.beat, direction); + : TabSlurGlyph.getEffectSlurGroup(renderer, n.effectSlurOrigin.beat, direction); if (group && group.endNote === n) { if (!slurEnds) { slurEnds = new Set(); diff --git a/packages/alphatab/src/rendering/glyphs/TabBeatGlyph.ts b/packages/alphatab/src/rendering/glyphs/TabBeatGlyph.ts index 646c7ec19d..f47a3505e4 100644 --- a/packages/alphatab/src/rendering/glyphs/TabBeatGlyph.ts +++ b/packages/alphatab/src/rendering/glyphs/TabBeatGlyph.ts @@ -15,6 +15,7 @@ import { TremoloPickingGlyph } from '@coderline/alphatab/rendering/glyphs/Tremol import type { TabBarRenderer } from '@coderline/alphatab/rendering/TabBarRenderer'; import { BeamingHelper } from '@coderline/alphatab/rendering/utils/BeamingHelper'; import type { IBoundsLookupWriter } from '@coderline/alphatab/rendering/utils/IBoundsLookupWriter'; +import { TabFingering } from '@coderline/alphatab/rendering/utils/TabFingering'; /** * @internal @@ -147,13 +148,21 @@ export class TabBeatGlyph extends BeatOnNoteGlyphBase { const tabNoteNumbers = new TabNoteChordGlyph(0, 0, isGrace); this.noteNumbers = tabNoteNumbers; tabNoteNumbers.beat = this.container.beat; + tabNoteNumbers.renderer = this.renderer; for (const note of this.container.beat.notes) { // pitched notes without tab position (e.g. string annotations) have no fret to show if (note.isVisible && (note.isStringed || note.isPercussion)) { - this._createNoteGlyph(note); + const noteNumberGlyph = new NoteNumberGlyph(0, 0, note); + noteNumberGlyph.renderer = this.renderer; + tabNoteNumbers.addNoteGlyph(noteNumberGlyph); } } - tabNoteNumbers.renderer = this.renderer; + TabFingering.assign(tabRenderer, tabNoteNumbers.noteGlyphs); + for (let i = 0; i < tabNoteNumbers.noteGlyphs.length; i++) { + const noteNumberGlyph = tabNoteNumbers.noteGlyphs[i]; + this._layoutNoteGlyph(noteNumberGlyph); + } + tabNoteNumbers.updateStringRange(); } // @@ -230,14 +239,11 @@ export class TabBeatGlyph extends BeatOnNoteGlyphBase { } } - private _createNoteGlyph(n: Note): void { + private _layoutNoteGlyph(noteNumberGlyph: NoteNumberGlyph): void { const tr: TabBarRenderer = this.renderer as TabBarRenderer; - const noteNumberGlyph: NoteNumberGlyph = new NoteNumberGlyph(0, 0, n); - const l: number = tr.getNoteLine(n); + const l: number = this.renderer.bar.staff.tuning.length - noteNumberGlyph.displayString; noteNumberGlyph.y = tr.getLineY(l); - noteNumberGlyph.renderer = this.renderer; noteNumberGlyph.doLayout(); - this.noteNumbers!.addNoteGlyph(noteNumberGlyph, n); if (tr.drawsRhythm) { tr.collisionHelper.reserveBeatSlot( this.container.beat, diff --git a/packages/alphatab/src/rendering/glyphs/TabBendGlyph.ts b/packages/alphatab/src/rendering/glyphs/TabBendGlyph.ts index bc4526ba68..45357f691f 100644 --- a/packages/alphatab/src/rendering/glyphs/TabBendGlyph.ts +++ b/packages/alphatab/src/rendering/glyphs/TabBendGlyph.ts @@ -200,9 +200,10 @@ export class TabBendGlyph extends Glyph implements ITieGlyph { } } this.width = 0; + const tabRenderer = this.renderer as TabBarRenderer; this._notes.sort((a, b) => { if (a.isStringed) { - return a.string - b.string; + return tabRenderer.getDisplayString(a) - tabRenderer.getDisplayString(b); } return a.realValue - b.realValue; }); @@ -691,7 +692,9 @@ export class TabBendGlyph extends Glyph implements ITieGlyph { if (firstPt.value === 0) { y1 = cy + smufl.tabBendStaffPadding; if (secondPt.offset === firstPt.offset) { - y1 += noteRenderer.getNoteY(note.beat.maxStringNote!, NoteYPosition.Top) - noteNumberAndLinePaddingY; + y1 += + noteRenderer.getNoteY(noteRenderer.getDisplayMaxStringNote(note.beat)!, NoteYPosition.Top) - + noteNumberAndLinePaddingY; } else { y1 += noteRenderer.getNoteY(note, NoteYPosition.Center); } @@ -702,7 +705,10 @@ export class TabBendGlyph extends Glyph implements ITieGlyph { const x2 = cx + dX * secondPt.offset; let y2: number; if (secondPt.lineValue === 0) { - y2 = cy + smufl.tabBendStaffPadding + noteRenderer.getNoteY(note.beat.maxStringNote!, NoteYPosition.Center); + y2 = + cy + + smufl.tabBendStaffPadding + + noteRenderer.getNoteY(noteRenderer.getDisplayMaxStringNote(note.beat)!, NoteYPosition.Center); } else { y2 = cy - smufl.tabBendPerValueHeight * secondPt.lineValue; } diff --git a/packages/alphatab/src/rendering/glyphs/TabBrushGlyph.ts b/packages/alphatab/src/rendering/glyphs/TabBrushGlyph.ts index 060d489137..8d2d911d4b 100644 --- a/packages/alphatab/src/rendering/glyphs/TabBrushGlyph.ts +++ b/packages/alphatab/src/rendering/glyphs/TabBrushGlyph.ts @@ -37,8 +37,12 @@ export class TabBrushGlyph extends Glyph { public override paint(cx: number, cy: number, canvas: ICanvas): void { const tabBarRenderer: TabBarRenderer = this.renderer as TabBarRenderer; - const startY: number = cy + this.x + tabBarRenderer.getNoteY(this._beat.maxStringNote!, NoteYPosition.Top); - const endY: number = cy + this.y + tabBarRenderer.getNoteY(this._beat.minStringNote!, NoteYPosition.Bottom); + const startY: number = + cy + this.x + tabBarRenderer.getNoteY(tabBarRenderer.getDisplayMaxStringNote(this._beat)!, NoteYPosition.Top); + const endY: number = + cy + + this.y + + tabBarRenderer.getNoteY(tabBarRenderer.getDisplayMinStringNote(this._beat)!, NoteYPosition.Bottom); const arrowX: number = cx + this.x + this.width / 2; const arrowSize = this.renderer.smuflMetrics.glyphWidths.get(MusicFontSymbol.ArrowheadBlackDown)!; if (this._beat.brushType !== BrushType.None) { diff --git a/packages/alphatab/src/rendering/glyphs/TabNoteChordGlyph.ts b/packages/alphatab/src/rendering/glyphs/TabNoteChordGlyph.ts index c71ca1ad82..a43681ed85 100644 --- a/packages/alphatab/src/rendering/glyphs/TabNoteChordGlyph.ts +++ b/packages/alphatab/src/rendering/glyphs/TabNoteChordGlyph.ts @@ -36,13 +36,30 @@ export class TabNoteChordGlyph extends Glyph { public getNoteGlyphForString(str: number): NoteNumberGlyph | null { const notes = this._notes; for (let i = notes.length - 1; i >= 0; i--) { - if (notes[i].note.string === str) { + if (notes[i].displayString === str) { return notes[i]; } } return null; } + public findNoteGlyph(note: Note): NoteNumberGlyph | null { + const notes = this._notes; + for (let i = notes.length - 1; i >= 0; i--) { + if (notes[i].note === note) { + return notes[i]; + } + } + return null; + } + + /** + * The glyph of the given note, or the glyph shown on its string if the note has none (e.g. a hidden note). + */ + public getNoteGlyph(note: Note): NoteNumberGlyph | null { + return this.findNoteGlyph(note) ?? this.getNoteGlyphForString(note.string); + } + public get beatEffects(): Map { let effects = this._beatEffects; if (!effects) { @@ -64,7 +81,7 @@ export class TabNoteChordGlyph extends Glyph { } public getNoteX(note: Note, requestedPosition: NoteXPosition): number { - const n = this.getNoteGlyphForString(note.string); + const n = this.getNoteGlyph(note); if (n) { let pos = this.x + n.x; switch (requestedPosition) { @@ -92,7 +109,7 @@ export class TabNoteChordGlyph extends Glyph { } public getNoteY(note: Note, requestedPosition: NoteYPosition): number { - const n = this.getNoteGlyphForString(note.string); + const n = this.getNoteGlyph(note); if (n) { let pos = this.y + n.y; @@ -176,17 +193,30 @@ export class TabNoteChordGlyph extends Glyph { /** * Adds a note number that is already laid out. */ - public addNoteGlyph(noteGlyph: NoteNumberGlyph, note: Note): void { + public addNoteGlyph(noteGlyph: NoteNumberGlyph): void { if (this._notes.length === 0) { this._notes = [noteGlyph]; } else { this._notes.push(noteGlyph); } - if (!this.minStringNote || note.string < this.minStringNote.string) { - this.minStringNote = note; - } - if (!this.maxStringNote || note.string > this.maxStringNote.string) { - this.maxStringNote = note; + } + + /** + * Updates {@link minStringNote} and {@link maxStringNote} once the notes are placed on their strings. + */ + public updateStringRange(): void { + let minString = 0; + let maxString = 0; + for (let i = 0; i < this._notes.length; i++) { + const g = this._notes[i]; + if (!this.minStringNote || g.displayString < minString) { + this.minStringNote = g.note; + minString = g.displayString; + } + if (!this.maxStringNote || g.displayString > maxString) { + this.maxStringNote = g.note; + maxString = g.displayString; + } } } diff --git a/packages/alphatab/src/rendering/glyphs/TabSlurGlyph.ts b/packages/alphatab/src/rendering/glyphs/TabSlurGlyph.ts index e5efe08523..a6c2e94304 100644 --- a/packages/alphatab/src/rendering/glyphs/TabSlurGlyph.ts +++ b/packages/alphatab/src/rendering/glyphs/TabSlurGlyph.ts @@ -1,6 +1,7 @@ import type { Beat } from '@coderline/alphatab/model/Beat'; import type { Note } from '@coderline/alphatab/model/Note'; import { TabTieGlyph } from '@coderline/alphatab/rendering/glyphs/TabTieGlyph'; +import type { TabBarRenderer } from '@coderline/alphatab/rendering/TabBarRenderer'; import { BeamDirection } from '@coderline/alphatab/rendering/utils/BeamDirection'; /** @@ -25,7 +26,11 @@ export class TabSlurGlyph extends TabTieGlyph { * Gets the notes the effect slur arc on the given side of the beat connects. * @returns The notes, or null if no effect slur chain starts on this side of the beat. */ - public static getEffectSlurGroup(beat: Beat, direction: BeamDirection): TabEffectSlurGroup | null { + public static getEffectSlurGroup( + renderer: TabBarRenderer, + beat: Beat, + direction: BeamDirection + ): TabEffectSlurGroup | null { const isUp = direction === BeamDirection.Up; let startNote: Note | null = null; let endNote: Note | null = null; @@ -35,7 +40,7 @@ export class TabSlurGlyph extends TabTieGlyph { !n.isVisible || !n.isEffectSlurOrigin || !destination || - TabTieGlyph.getBeamDirectionForNote(n) !== direction + TabTieGlyph.getBeamDirectionForString(renderer.getDisplayString(n)) !== direction ) { continue; } diff --git a/packages/alphatab/src/rendering/glyphs/TabTieGlyph.ts b/packages/alphatab/src/rendering/glyphs/TabTieGlyph.ts index 7b4d0192bc..e2fc3019ee 100644 --- a/packages/alphatab/src/rendering/glyphs/TabTieGlyph.ts +++ b/packages/alphatab/src/rendering/glyphs/TabTieGlyph.ts @@ -1,5 +1,6 @@ import type { Note } from '@coderline/alphatab/model/Note'; import { NoteTieGlyph } from '@coderline/alphatab/rendering/glyphs/TieGlyph'; +import type { TabBarRenderer } from '@coderline/alphatab/rendering/TabBarRenderer'; import { BeamDirection } from '@coderline/alphatab/rendering/utils/BeamDirection'; /** @@ -10,13 +11,22 @@ export class TabTieGlyph extends NoteTieGlyph { if (this.isLeftHandTap) { return BeamDirection.Up; } - return TabTieGlyph.getBeamDirectionForNote(this.startNote); + return TabTieGlyph.getBeamDirectionForString( + (this.renderer as TabBarRenderer).getDisplayString(this.startNote) + ); } /** - * The side of tab ties and slurs: above the upper three strings, below the lower ones. + * The side of ties and slurs of the note on its model string. */ public static getBeamDirectionForNote(note: Note): BeamDirection { - return note.string > 3 ? BeamDirection.Up : BeamDirection.Down; + return TabTieGlyph.getBeamDirectionForString(note.string); + } + + /** + * The side of tab ties and slurs: above the upper three strings, below the lower ones. + */ + public static getBeamDirectionForString(str: number): BeamDirection { + return str > 3 ? BeamDirection.Up : BeamDirection.Down; } } diff --git a/packages/alphatab/src/rendering/layout/VerticalLayoutBase.ts b/packages/alphatab/src/rendering/layout/VerticalLayoutBase.ts index dfef8789e9..3ad3e205e5 100644 --- a/packages/alphatab/src/rendering/layout/VerticalLayoutBase.ts +++ b/packages/alphatab/src/rendering/layout/VerticalLayoutBase.ts @@ -10,6 +10,7 @@ import { ScoreLayout } from '@coderline/alphatab/rendering/layout/ScoreLayout'; import { RenderFinishedEventArgs } from '@coderline/alphatab/rendering/RenderFinishedEventArgs'; import type { MasterBarsRenderers } from '@coderline/alphatab/rendering/staves/MasterBarsRenderers'; import type { StaffSystem } from '@coderline/alphatab/rendering/staves/StaffSystem'; +import { TabFingering } from '@coderline/alphatab/rendering/utils/TabFingering'; /** * How the fill loop chose the bars of a pooled system: the system holds the measured master bars @@ -117,7 +118,11 @@ export abstract class VerticalLayoutBase extends ScoreLayout { public override doUpdateForBars(renderHints: RenderHints): boolean { this._reuseViewPort = renderHints.reuseViewport ?? false; - const firstModifiedMasterBar = renderHints.firstChangedMasterBar!; + // a transposed tab is fingered per segment of linked bars which has to be laid out as a whole + const firstModifiedMasterBar = TabFingering.firstAffectedBarIndex( + this.renderer.tracks!, + renderHints.firstChangedMasterBar! + ); // first update existing systems as needed const systemIndex = this._systems.findIndex(s => { diff --git a/packages/alphatab/src/rendering/utils/ElementStyleHelper.ts b/packages/alphatab/src/rendering/utils/ElementStyleHelper.ts index dd3d05b9c6..57ef4cc05a 100644 --- a/packages/alphatab/src/rendering/utils/ElementStyleHelper.ts +++ b/packages/alphatab/src/rendering/utils/ElementStyleHelper.ts @@ -313,6 +313,19 @@ export class ElementStyleHelper { return ElementStyleHelper._apply(canvas, element, note.style, defaultColor, forceDefault); } + /** + * Applies the color of the given note element like {@link applyNote}, using the given color + * when the note style does not define the element. + */ + public static applyNoteWithDefault( + canvas: ICanvas, + element: NoteSubElement, + note: Note, + defaultColor: Color + ): Color | undefined { + return ElementStyleHelper._apply(canvas, element, note.style, defaultColor, true); + } + /** * Restores the canvas color changed by one of the `apply*` methods. * @param previousColor The value returned by the `apply*` method. diff --git a/packages/alphatab/src/rendering/utils/TabFingering.ts b/packages/alphatab/src/rendering/utils/TabFingering.ts new file mode 100644 index 0000000000..74cafd0f45 --- /dev/null +++ b/packages/alphatab/src/rendering/utils/TabFingering.ts @@ -0,0 +1,320 @@ +import { HarmonicType } from '@coderline/alphatab/model/HarmonicType'; +import { ModelUtils } from '@coderline/alphatab/model/ModelUtils'; +import { Note } from '@coderline/alphatab/model/Note'; +import { SlideInType } from '@coderline/alphatab/model/SlideInType'; +import { SlideOutType } from '@coderline/alphatab/model/SlideOutType'; +import type { Staff } from '@coderline/alphatab/model/Staff'; +import type { Track } from '@coderline/alphatab/model/Track'; +import { VibratoType } from '@coderline/alphatab/model/VibratoType'; +import type { NoteNumberGlyph } from '@coderline/alphatab/rendering/glyphs/NoteNumberGlyph'; +import type { TabBarRenderer } from '@coderline/alphatab/rendering/TabBarRenderer'; + +/** + * Places the notes of a transposed tab on playable strings, beat by beat while the tab is laid out. + * A note keeps its string when its shifted fret is playable there. Tied, slid and hammered notes + * stay on the string of their origin. Other notes take the free string closest to the frets of the beat + * or the previous beat, or keep their string marked as unplayable. + * @internal + */ +export class TabFingering { + private static readonly _maxFret: number = 24; + + // natural harmonic nodes and the semitones they sound above the open string + private static readonly _nodeValues: number[] = [ + 12, 7, 19, 5, 24, 4, 9, 16, 3.2, 2.7, 5.8, 9.6, 14.7, 2.4, 8.2, 17, 21.7 + ]; + private static readonly _nodeIntervals: number[] = [ + 12, 19, 19, 24, 24, 28, 28, 28, 31, 34, 34, 34, 34, 36, 36, 36, 36 + ]; + + public static isActive(staff: Staff): boolean { + return staff.transpositionPitch !== 0 && !staff.isPercussion && staff.tuning.length > 0; + } + + /** + * Sets the displayed string and fret of the note glyphs of one beat. + */ + public static assign(renderer: TabBarRenderer, glyphs: NoteNumberGlyph[]): void { + if (glyphs.length === 0) { + return; + } + const staff = glyphs[0].note.beat.voice.bar.staff; + if (!TabFingering.isActive(staff)) { + for (let i = 0; i < glyphs.length; i++) { + const g = glyphs[i]; + const n = g.note; + g.setDisplay(n.string, TabFingering._modelFret(n), false, false); + } + return; + } + + let usedStrings = 0; + // continuations of ties, slides and hammer-ons/pull-offs follow their origin + for (let i = 0; i < glyphs.length; i++) { + const g = glyphs[i]; + const n = g.note; + const origin = TabFingering._origin(n); + if (origin !== null) { + let str = renderer.getDisplayString(origin) + n.string - origin.string; + if (str < 1 || str > staff.tuning.length) { + str = n.string; + } + TabFingering._place(g, str, n.string); + usedStrings |= 1 << (str - 1); + } + } + // notes playable on their own string keep it + for (let i = 0; i < glyphs.length; i++) { + const g = glyphs[i]; + const n = g.note; + const bit = 1 << (n.string - 1); + if ( + g.displayString === 0 && + (usedStrings & bit) === 0 && + !Number.isNaN(TabFingering._fretOn(n, n.string)) && + TabFingering._chainFits(n, 0) + ) { + TabFingering._place(g, n.string, n.string); + usedStrings |= bit; + } + } + // the remaining notes, highest first, take the free string closest to the hand + while (true) { + let next: NoteNumberGlyph | null = null; + for (let i = 0; i < glyphs.length; i++) { + const g = glyphs[i]; + if (g.displayString === 0 && (next === null || g.note.realValue > next.note.realValue)) { + next = g; + } + } + if (next === null) { + break; + } + const n = next.note; + const hand = TabFingering._handPosition(renderer, glyphs); + let bestString = n.string; + let bestCost = Number.POSITIVE_INFINITY; + for (let str = 1; str <= staff.tuning.length; str++) { + const fret = TabFingering._fretOn(n, str); + if ( + (usedStrings & (1 << (str - 1))) !== 0 || + Number.isNaN(fret) || + !TabFingering._chainFits(n, str - n.string) + ) { + continue; + } + const cost = hand > 0 ? Math.abs(fret - hand) : fret; + if (cost < bestCost) { + bestCost = cost; + bestString = str; + } + } + if (bestCost === Number.POSITIVE_INFINITY) { + bestString = TabFingering._unplayableString(n, usedStrings); + } + TabFingering._place(next, bestString, n.string); + usedStrings |= 1 << (bestString - 1); + } + } + + /** + * The string for a note no string can play: its own string if free, otherwise the free string + * where its fret is closest to the frets of the neck. + */ + private static _unplayableString(n: Note, usedStrings: number): number { + if ((usedStrings & (1 << (n.string - 1))) === 0) { + return n.string; + } + const staff = n.beat.voice.bar.staff; + const maxFret = Math.max(TabFingering._maxFret - staff.capo, n.fret); + let bestString = n.string; + let bestDistance = Number.POSITIVE_INFINITY; + for (let str = 1; str <= staff.tuning.length; str++) { + if ((usedStrings & (1 << (str - 1))) !== 0) { + continue; + } + const fret = TabFingering._unplayableFret(n, str); + const distance = fret < 0 ? -fret : Math.max(0, fret - maxFret); + if (distance < bestDistance) { + bestDistance = distance; + bestString = str; + } + } + return bestString; + } + + private static _place(g: NoteNumberGlyph, str: number, modelString: number): void { + const fret = TabFingering._fretOn(g.note, str); + if (Number.isNaN(fret)) { + g.setDisplay(str, TabFingering._unplayableFret(g.note, str), str !== modelString, true); + } else { + g.setDisplay(str, fret, str !== modelString, false); + } + } + + /** + * The out-of-range fret shown for a note which cannot be played on the given string. + */ + private static _unplayableFret(n: Note, str: number): number { + if (str === n.string || TabFingering._isNaturalHarmonic(n)) { + return TabFingering._modelFret(n); + } + const staff = n.beat.voice.bar.staff; + return n.realValueWithoutHarmonic - staff.capo - Note.getStringTuning(staff, str); + } + + /** + * The model fret shifted by the transposition, shown for untransposed staves and unplayable notes. + */ + private static _modelFret(n: Note): number { + const shown = TabFingering._isNaturalHarmonic(n) ? n.harmonicValue : n.fret; + return shown - n.beat.voice.bar.staff.transpositionPitch; + } + + private static _isNaturalHarmonic(n: Note): boolean { + return n.harmonicType === HarmonicType.Natural && !ModelUtils.isAlmostEqualTo(n.harmonicValue, 0); + } + + /** + * The fret (natural harmonics: the node) playing the note on the given string, NaN if it cannot be played there. + */ + private static _fretOn(n: Note, str: number): number { + const staff = n.beat.voice.bar.staff; + if (str < 1 || str > staff.tuning.length) { + return Number.NaN; + } + const stringPitch = staff.capo + Note.getStringTuning(staff, str); + if (TabFingering._isNaturalHarmonic(n)) { + const interval = n.realValue - stringPitch; + let node = Number.NaN; + for (let i = 0; i < TabFingering._nodeIntervals.length; i++) { + const value = TabFingering._nodeValues[i]; + if ( + TabFingering._nodeIntervals[i] === interval && + (Number.isNaN(node) || Math.abs(value - n.harmonicValue) < Math.abs(node - n.harmonicValue)) + ) { + node = value; + } + } + return node; + } + const fret = n.realValueWithoutHarmonic - stringPitch; + if (n.isDead) { + return fret; + } + const maxFret = Math.max(TabFingering._maxFret - staff.capo, n.fret); + if (fret < 0 || fret > maxFret || (fret === 0 && TabFingering._needsFrettedString(n))) { + return Number.NaN; + } + if (n.isTrill) { + const trillFret = n.trillValue - staff.transpositionPitch - stringPitch; + if (trillFret < 0 || trillFret > maxFret) { + return Number.NaN; + } + } + return fret; + } + + private static _needsFrettedString(n: Note): boolean { + return ( + n.hasBend || + n.isContinuedBend || + n.vibrato !== VibratoType.None || + n.isLeftHandTapped || + n.slideInType !== SlideInType.None || + n.slideOutType === SlideOutType.Shift || + n.slideOutType === SlideOutType.Legato || + n.slideOutType === SlideOutType.OutUp || + n.slideOutType === SlideOutType.OutDown || + n.slideOrigin !== null || + // a hammer-on cannot end on an open string, a pull-off can + (n.hammerPullOrigin !== null && n.hammerPullOrigin.realValue < n.realValue) + ); + } + + /** + * The note which this note continues on the same string. + */ + private static _origin(n: Note): Note | null { + if (n.isTieDestination && n.tieOrigin !== null) { + return n.tieOrigin; + } + return n.slideOrigin ?? n.hammerPullOrigin; + } + + /** + * Whether all notes continuing the given one are playable when moved by the given number of strings. + */ + private static _chainFits(n: Note, stringDelta: number): boolean { + const tie = n.tieDestination; + const slide = + n.slideOutType === SlideOutType.Shift || n.slideOutType === SlideOutType.Legato ? n.slideTarget : null; + const hammer = n.isHammerPullOrigin ? n.hammerPullDestination : null; + return ( + TabFingering._fits(tie, stringDelta) && + TabFingering._fits(slide, stringDelta) && + TabFingering._fits(hammer, stringDelta) + ); + } + + private static _fits(n: Note | null, stringDelta: number): boolean { + return ( + n === null || + (!Number.isNaN(TabFingering._fretOn(n, n.string + stringDelta)) && TabFingering._chainFits(n, stringDelta)) + ); + } + + /** + * The average fret of the notes placed in this beat, or of the previous beat in the voice, 0 if unknown. + */ + private static _handPosition(renderer: TabBarRenderer, glyphs: NoteNumberGlyph[]): number { + let sum = 0; + let count = 0; + for (let i = 0; i < glyphs.length; i++) { + const g = glyphs[i]; + if (g.displayString > 0 && !g.note.isDead && g.displayFret > 0) { + sum += g.displayFret; + count++; + } + } + const previous = glyphs[0].note.beat.previousBeat; + if (count === 0 && previous !== null) { + for (let i = 0; i < previous.notes.length; i++) { + const p = previous.notes[i]; + const fret = renderer.getDisplayFret(p); + if (p.isStringed && !p.isDead && fret > 0) { + sum += fret; + count++; + } + } + } + return count > 0 ? sum / count : 0; + } + + /** + * The first bar whose tab may change when the given bar changes: notes continued in it are + * placed together with their origin. + */ + public static firstAffectedBarIndex(tracks: Track[], barIndex: number): number { + let first = barIndex; + for (const track of tracks) { + for (const staff of track.staves) { + if (!staff.showTablature || !TabFingering.isActive(staff) || barIndex >= staff.bars.length) { + continue; + } + for (const voice of staff.bars[barIndex].voices) { + for (const beat of voice.beats) { + for (const note of beat.notes) { + let origin = TabFingering._origin(note); + while (origin !== null) { + first = Math.min(first, origin.beat.voice.bar.index); + origin = TabFingering._origin(origin); + } + } + } + } + } + } + return first; + } +} diff --git a/packages/alphatab/test-data/visual-tests/guitar-tabs/transpose-bass.png b/packages/alphatab/test-data/visual-tests/guitar-tabs/transpose-bass.png new file mode 100644 index 0000000000..586b07cb68 Binary files /dev/null and b/packages/alphatab/test-data/visual-tests/guitar-tabs/transpose-bass.png differ diff --git a/packages/alphatab/test-data/visual-tests/guitar-tabs/transpose-bends.png b/packages/alphatab/test-data/visual-tests/guitar-tabs/transpose-bends.png new file mode 100644 index 0000000000..6ff2183298 Binary files /dev/null and b/packages/alphatab/test-data/visual-tests/guitar-tabs/transpose-bends.png differ diff --git a/packages/alphatab/test-data/visual-tests/guitar-tabs/transpose-capo.png b/packages/alphatab/test-data/visual-tests/guitar-tabs/transpose-capo.png new file mode 100644 index 0000000000..939def5dd6 Binary files /dev/null and b/packages/alphatab/test-data/visual-tests/guitar-tabs/transpose-capo.png differ diff --git a/packages/alphatab/test-data/visual-tests/guitar-tabs/transpose-chords.png b/packages/alphatab/test-data/visual-tests/guitar-tabs/transpose-chords.png new file mode 100644 index 0000000000..68b77c1f48 Binary files /dev/null and b/packages/alphatab/test-data/visual-tests/guitar-tabs/transpose-chords.png differ diff --git a/packages/alphatab/test-data/visual-tests/guitar-tabs/transpose-dead-notes.png b/packages/alphatab/test-data/visual-tests/guitar-tabs/transpose-dead-notes.png new file mode 100644 index 0000000000..ef16df3464 Binary files /dev/null and b/packages/alphatab/test-data/visual-tests/guitar-tabs/transpose-dead-notes.png differ diff --git a/packages/alphatab/test-data/visual-tests/guitar-tabs/transpose-down.png b/packages/alphatab/test-data/visual-tests/guitar-tabs/transpose-down.png new file mode 100644 index 0000000000..a49df00116 Binary files /dev/null and b/packages/alphatab/test-data/visual-tests/guitar-tabs/transpose-down.png differ diff --git a/packages/alphatab/test-data/visual-tests/guitar-tabs/transpose-grace-notes.png b/packages/alphatab/test-data/visual-tests/guitar-tabs/transpose-grace-notes.png new file mode 100644 index 0000000000..5399ea16cc Binary files /dev/null and b/packages/alphatab/test-data/visual-tests/guitar-tabs/transpose-grace-notes.png differ diff --git a/packages/alphatab/test-data/visual-tests/guitar-tabs/transpose-keep-strings.png b/packages/alphatab/test-data/visual-tests/guitar-tabs/transpose-keep-strings.png new file mode 100644 index 0000000000..ca52646bc2 Binary files /dev/null and b/packages/alphatab/test-data/visual-tests/guitar-tabs/transpose-keep-strings.png differ diff --git a/packages/alphatab/test-data/visual-tests/guitar-tabs/transpose-legato.png b/packages/alphatab/test-data/visual-tests/guitar-tabs/transpose-legato.png new file mode 100644 index 0000000000..dbda3fe954 Binary files /dev/null and b/packages/alphatab/test-data/visual-tests/guitar-tabs/transpose-legato.png differ diff --git a/packages/alphatab/test-data/visual-tests/guitar-tabs/transpose-multi-voice.png b/packages/alphatab/test-data/visual-tests/guitar-tabs/transpose-multi-voice.png new file mode 100644 index 0000000000..aecc1f6916 Binary files /dev/null and b/packages/alphatab/test-data/visual-tests/guitar-tabs/transpose-multi-voice.png differ diff --git a/packages/alphatab/test-data/visual-tests/guitar-tabs/transpose-natural-harmonics-down.png b/packages/alphatab/test-data/visual-tests/guitar-tabs/transpose-natural-harmonics-down.png new file mode 100644 index 0000000000..8e9804bd02 Binary files /dev/null and b/packages/alphatab/test-data/visual-tests/guitar-tabs/transpose-natural-harmonics-down.png differ diff --git a/packages/alphatab/test-data/visual-tests/guitar-tabs/transpose-natural-harmonics-up.png b/packages/alphatab/test-data/visual-tests/guitar-tabs/transpose-natural-harmonics-up.png new file mode 100644 index 0000000000..93455561ef Binary files /dev/null and b/packages/alphatab/test-data/visual-tests/guitar-tabs/transpose-natural-harmonics-up.png differ diff --git a/packages/alphatab/test-data/visual-tests/guitar-tabs/transpose-open-strings.png b/packages/alphatab/test-data/visual-tests/guitar-tabs/transpose-open-strings.png new file mode 100644 index 0000000000..24070f0513 Binary files /dev/null and b/packages/alphatab/test-data/visual-tests/guitar-tabs/transpose-open-strings.png differ diff --git a/packages/alphatab/test-data/visual-tests/guitar-tabs/transpose-pull-off-to-open.png b/packages/alphatab/test-data/visual-tests/guitar-tabs/transpose-pull-off-to-open.png new file mode 100644 index 0000000000..be66974823 Binary files /dev/null and b/packages/alphatab/test-data/visual-tests/guitar-tabs/transpose-pull-off-to-open.png differ diff --git a/packages/alphatab/test-data/visual-tests/guitar-tabs/transpose-repeated-bars.png b/packages/alphatab/test-data/visual-tests/guitar-tabs/transpose-repeated-bars.png new file mode 100644 index 0000000000..25ae8f7221 Binary files /dev/null and b/packages/alphatab/test-data/visual-tests/guitar-tabs/transpose-repeated-bars.png differ diff --git a/packages/alphatab/test-data/visual-tests/guitar-tabs/transpose-ties.png b/packages/alphatab/test-data/visual-tests/guitar-tabs/transpose-ties.png new file mode 100644 index 0000000000..c3c58f26cd Binary files /dev/null and b/packages/alphatab/test-data/visual-tests/guitar-tabs/transpose-ties.png differ diff --git a/packages/alphatab/test-data/visual-tests/guitar-tabs/transpose-trills.png b/packages/alphatab/test-data/visual-tests/guitar-tabs/transpose-trills.png new file mode 100644 index 0000000000..f3834051cb Binary files /dev/null and b/packages/alphatab/test-data/visual-tests/guitar-tabs/transpose-trills.png differ diff --git a/packages/alphatab/test-data/visual-tests/guitar-tabs/transpose-ukulele.png b/packages/alphatab/test-data/visual-tests/guitar-tabs/transpose-ukulele.png new file mode 100644 index 0000000000..dc0098e358 Binary files /dev/null and b/packages/alphatab/test-data/visual-tests/guitar-tabs/transpose-ukulele.png differ diff --git a/packages/alphatab/test-data/visual-tests/guitar-tabs/transpose-up.png b/packages/alphatab/test-data/visual-tests/guitar-tabs/transpose-up.png new file mode 100644 index 0000000000..ac4e113d14 Binary files /dev/null and b/packages/alphatab/test-data/visual-tests/guitar-tabs/transpose-up.png differ diff --git a/packages/alphatab/test/generated/SerializerFill.ts b/packages/alphatab/test/generated/SerializerFill.ts index 003dd8eaf2..d27a047683 100644 --- a/packages/alphatab/test/generated/SerializerFill.ts +++ b/packages/alphatab/test/generated/SerializerFill.ts @@ -566,6 +566,9 @@ export class SerializerFill { { obj.scoreInfoColor = new Color(1, 2, 3, 4); } + { + obj.tabUnplayableFretColor = new Color(1, 2, 3, 4); + } } public static fillSettings(obj: Settings, depth: number = 0): void { if (depth > 8) { diff --git a/packages/alphatab/test/rendering/TabFingeringPartialUpdate.test.ts b/packages/alphatab/test/rendering/TabFingeringPartialUpdate.test.ts new file mode 100644 index 0000000000..bf267b2cce --- /dev/null +++ b/packages/alphatab/test/rendering/TabFingeringPartialUpdate.test.ts @@ -0,0 +1,86 @@ +/** + * A transposed tab is fingered per segment of linked bars. A partial render starting inside a + * segment which began in an earlier system must re-finger the whole segment like a fresh render. + */ +import { ScoreLoader } from '@coderline/alphatab/importer/ScoreLoader'; +import type { Note } from '@coderline/alphatab/model/Note'; +import type { Score } from '@coderline/alphatab/model/Score'; +import type { RenderHints } from '@coderline/alphatab/rendering/IScoreRenderer'; +import { ScoreRenderer } from '@coderline/alphatab/rendering/ScoreRenderer'; +import { TabBarRenderer } from '@coderline/alphatab/rendering/TabBarRenderer'; +import { Settings } from '@coderline/alphatab/Settings'; +import { describe, expect, it } from 'vitest'; + +/** + * @internal + */ +class TabFingeringRenderHints implements RenderHints { + public reuseViewport?: boolean; + public firstChangedMasterBar?: number; +} + +describe('TabFingeringPartialUpdate', () => { + /** + * @static + */ + function createSettings(): Settings { + const settings = new Settings(); + settings.core.engine = 'svg'; + settings.core.enableLazyLoading = true; + settings.display.barsPerRow = 2; + return settings; + } + + /** + * @static + */ + function createScore(settings: Settings): Score { + // the hammer-on crosses from the first into the second system + return ScoreLoader.loadAlphaTex( + '\\track \\staff { tabs } \\transpose -4 :4 5.3 5.3 5.3 5.3 | 5.3 5.3 5.3 2.3{h} | 4.3 5.3 5.3 5.3 | 5.3 5.3 5.3 5.3', + settings + ); + } + + /** + * @static + */ + function render(settings: Settings, score: Score): ScoreRenderer { + const renderer = new ScoreRenderer(settings); + renderer.width = 1000; + renderer.renderScore(score, [0]); + return renderer; + } + + /** + * @static + */ + function displayString(renderer: ScoreRenderer, note: Note): number { + const tab = renderer.layout!.getRendererForBar(TabBarRenderer.StaffId, note.beat.voice.bar) as TabBarRenderer; + return tab.getDisplayString(note); + } + + it('re-fingers the segment started in an earlier system', () => { + const settings = createSettings(); + const score = createScore(settings); + const staff = score.tracks[0].staves[0]; + const origin = staff.bars[1].voices[0].beats[3].notes[0]; + const destination = staff.bars[2].voices[0].beats[0].notes[0]; + + const renderer = render(settings, score); + // moved to the D string together with its destination + expect(displayString(renderer, origin)).toBe(3); + expect(displayString(renderer, destination)).toBe(3); + + // the destination does not fit on the D string anymore, the pair stays on the G string + destination.fret = 24; + const hints = new TabFingeringRenderHints(); + hints.firstChangedMasterBar = 2; + renderer.render(hints); + + const fresh = render(settings, score); + expect(displayString(fresh, origin)).toBe(4); + expect(displayString(renderer, origin)).toBe(displayString(fresh, origin)); + expect(displayString(renderer, destination)).toBe(displayString(fresh, destination)); + }); +}); diff --git a/packages/alphatab/test/visualTests/features/GuitarTabs.test.ts b/packages/alphatab/test/visualTests/features/GuitarTabs.test.ts index 66d5386b17..56737ed803 100644 --- a/packages/alphatab/test/visualTests/features/GuitarTabs.test.ts +++ b/packages/alphatab/test/visualTests/features/GuitarTabs.test.ts @@ -60,4 +60,54 @@ describe('GuitarTabsTests', () => { 'test-data/visual-tests/guitar-tabs/multi-voice-same-string.png' ); }); + + describe('transpose', () => { + // the original and the transposed track, to compare note and tab placement + async function transposeTest(name: string, semitones: number, music: string, staffMetadata?: string) { + const settings = new Settings(); + settings.notation.transpositionPitches = [0, semitones]; + const staff = `\\staff { score tabs } ${staffMetadata ?? ''}`; + const transposed = `Transposed ${semitones > 0 ? '+' : ''}${semitones}`; + await VisualTestHelper.runVisualTestTex( + `\\track "Original" "Original" ${staff} ${music} \\track "${transposed}" "${transposed}" ${staff} ${music}`, + `test-data/visual-tests/guitar-tabs/transpose-${name}.png`, + settings, + o => { + o.tracks = [0, 1]; + } + ); + } + + it('down', async () => + await transposeTest( + 'down', + -4, + `:4 0.6 3.5{string} 2.4 0.3 | (0.1 0.2 0.3) 3.5 3.5 3.5 | 3.5{t} 2.3{h} 4.3 4.3{b (0 4)} | + 12.6{nh} 12.5{nh} 5.3{tr 7 16} 0.6{x}` + )); + it('up', async () => await transposeTest('up', 7, ':4 17.1 20.1 (19.2 20.1) 22.2 | 5.6 7.5 0.1 3.3')); + it('keep-strings', async () => await transposeTest('keep-strings', 2, ':4 3.3 5.2 0.1 12.6')); + it('open-strings', async () => await transposeTest('open-strings', -4, ':4 3.5 2.4 5.3 0.6')); + it('chords', async () => + await transposeTest('chords', -4, ':4 (0.1 0.2) (0.1 0.2 0.3) (3.5 2.4) (0.6 2.5 2.4)')); + it('ties', async () => await transposeTest('ties', -4, ':4 3.5 3.5 3.5 3.5 | -.5 2.4 2.4 -.4')); + it('legato', async () => await transposeTest('legato', -4, ':4 2.3{h} 4.3 2.3{ss} 4.3')); + it('pull-off-to-open', async () => await transposeTest('pull-off-to-open', -2, ':4 6.3{h} 2.3 5.2{h} 2.2')); + it('bends', async () => await transposeTest('bends', -4, ':4 4.3{b (0 4)} 0.1{b (0 4)} 2.2{b (0 4)} 5.3')); + it('natural-harmonics-up', async () => + await transposeTest('natural-harmonics-up', 7, ':4 12.6{nh} 7.5{nh} 5.4{nh} 12.1{nh}')); + it('natural-harmonics-down', async () => + await transposeTest('natural-harmonics-down', -4, ':4 12.6{nh} 7.5{nh} 5.4{nh} 12.1{nh}')); + it('dead-notes', async () => + await transposeTest('dead-notes', -4, ':4 0.6{x} 0.5{x} (0.6{x} 0.5{x} 0.4{x}) 3.5')); + it('trills', async () => await transposeTest('trills', -6, ':2 5.3{tr 7 16} 7.2{tr 8 16}')); + it('ukulele', async () => await transposeTest('ukulele', -6, ':4 2.1 0.2 0.3 0.4', '\\tuning A4 E4 C4 G4')); + it('bass', async () => await transposeTest('bass', -1, ':4 0.3 0.4 3.4 5.4', '\\tuning G2 D2 A1 E1')); + it('capo', async () => await transposeTest('capo', 3, ':4 20.1 15.2 3.3 0.6', '\\capo 2')); + it('grace-notes', async () => await transposeTest('grace-notes', -4, ':4 2.3{ss} {gr ob} 4.3 3.3 3.3')); + it('repeated-bars', async () => + await transposeTest('repeated-bars', -4, ':4 3.5 2.4 0.3 2.4 | 3.5 2.4 0.3 2.4')); + it('multi-voice', async () => + await transposeTest('multi-voice', -4, '\\voice :4 0.1 0.1 0.2 0.2 \\voice :4 1.2 1.2 0.3 0.3')); + }); }); diff --git a/packages/csharp/src/AlphaTab/Core/EcmaScript/Int16Array.cs b/packages/csharp/src/AlphaTab/Core/EcmaScript/Int16Array.cs index 0a018555fc..c85f74ab4b 100644 --- a/packages/csharp/src/AlphaTab/Core/EcmaScript/Int16Array.cs +++ b/packages/csharp/src/AlphaTab/Core/EcmaScript/Int16Array.cs @@ -1,26 +1,66 @@ -using System.Collections; +using System; +using System.Collections; using System.Collections.Generic; +using System.Linq; using System.Runtime.CompilerServices; namespace AlphaTab.Core.EcmaScript; internal class Int16Array : IEnumerable { - private readonly short[] _data; + private readonly ArraySegment _data; - public double Length => _data.Length; + public double Length => _data.Count; public Int16Array(double size) { - _data = new short[(int)size]; + _data = new ArraySegment(new short[(int)size]); + } + + public Int16Array(IEnumerable list) + { + _data = new ArraySegment(list.Select(i => (short)i).ToArray()); + } + + private Int16Array(ArraySegment data) + { + _data = data; } public double this[double index] { [MethodImpl(MethodImplOptions.AggressiveInlining)] - get => _data[(int)index]; + get => _data.Array![_data.Offset + (int)index]; [MethodImpl(MethodImplOptions.AggressiveInlining)] - set => _data[(int)index] = (short)value; + set => _data.Array![_data.Offset + (int)index] = (short)value; + } + + public void Fill(double value) + { + if (value == 0) + { + System.Array.Clear(_data.Array!, _data.Offset, _data.Count); + } + else + { + var v = (short)value; + for (var i = 0; i < _data.Count; i++) + { + _data.Array![_data.Offset + i] = v; + } + } + } + + public Int16Array Subarray(double start, double end) + { + return new Int16Array(new ArraySegment(_data.Array!, _data.Offset + (int)start, + (int)end - (int)start)); + } + + public void Set(Int16Array subarray, double offset) + { + System.Array.Copy(subarray._data.Array!, subarray._data.Offset, _data.Array!, + _data.Offset + (int)offset, subarray._data.Count); } public IEnumerator GetEnumerator() diff --git a/packages/csharp/src/AlphaTab/Core/EcmaScript/Int8Array.cs b/packages/csharp/src/AlphaTab/Core/EcmaScript/Int8Array.cs new file mode 100644 index 0000000000..3df12336d5 --- /dev/null +++ b/packages/csharp/src/AlphaTab/Core/EcmaScript/Int8Array.cs @@ -0,0 +1,75 @@ +using System; +using System.Collections; +using System.Collections.Generic; +using System.Linq; +using System.Runtime.CompilerServices; + +namespace AlphaTab.Core.EcmaScript; + +internal class Int8Array : IEnumerable +{ + private readonly ArraySegment _data; + + public double Length => _data.Count; + + public Int8Array(double size) + { + _data = new ArraySegment(new sbyte[(int)size]); + } + + public Int8Array(IEnumerable list) + { + _data = new ArraySegment(list.Select(i => (sbyte)i).ToArray()); + } + + private Int8Array(ArraySegment data) + { + _data = data; + } + + public double this[double index] + { + [MethodImpl(MethodImplOptions.AggressiveInlining)] + get => _data.Array![_data.Offset + (int)index]; + [MethodImpl(MethodImplOptions.AggressiveInlining)] + set => _data.Array![_data.Offset + (int)index] = (sbyte)value; + } + + public void Fill(double value) + { + if (value == 0) + { + System.Array.Clear(_data.Array!, _data.Offset, _data.Count); + } + else + { + var v = (sbyte)value; + for (var i = 0; i < _data.Count; i++) + { + _data.Array![_data.Offset + i] = v; + } + } + } + + public Int8Array Subarray(double start, double end) + { + return new Int8Array(new ArraySegment(_data.Array!, _data.Offset + (int)start, + (int)end - (int)start)); + } + + public void Set(Int8Array subarray, double offset) + { + System.Array.Copy(subarray._data.Array!, subarray._data.Offset, _data.Array!, + _data.Offset + (int)offset, subarray._data.Count); + } + + public IEnumerator GetEnumerator() + { + return ((IEnumerable)_data).GetEnumerator(); + } + + IEnumerator IEnumerable.GetEnumerator() + { + return GetEnumerator(); + } +} diff --git a/packages/csharp/src/AlphaTab/Core/EcmaScript/Uint16Array.cs b/packages/csharp/src/AlphaTab/Core/EcmaScript/Uint16Array.cs index 7ab2db3b62..d3b9e9ebcd 100644 --- a/packages/csharp/src/AlphaTab/Core/EcmaScript/Uint16Array.cs +++ b/packages/csharp/src/AlphaTab/Core/EcmaScript/Uint16Array.cs @@ -1,26 +1,66 @@ -using System.Collections; +using System; +using System.Collections; using System.Collections.Generic; +using System.Linq; using System.Runtime.CompilerServices; namespace AlphaTab.Core.EcmaScript; internal class Uint16Array : IEnumerable { - private readonly ushort[] _data; + private readonly ArraySegment _data; - public double Length => _data.Length; + public double Length => _data.Count; public Uint16Array(double size) { - _data = new ushort[(int) size]; + _data = new ArraySegment(new ushort[(int)size]); + } + + public Uint16Array(IEnumerable list) + { + _data = new ArraySegment(list.Select(i => (ushort)i).ToArray()); + } + + private Uint16Array(ArraySegment data) + { + _data = data; } public double this[double index] { [MethodImpl(MethodImplOptions.AggressiveInlining)] - get => _data[(int) index]; + get => _data.Array![_data.Offset + (int)index]; [MethodImpl(MethodImplOptions.AggressiveInlining)] - set => _data[(int) index] = (ushort) value; + set => _data.Array![_data.Offset + (int)index] = (ushort)value; + } + + public void Fill(double value) + { + if (value == 0) + { + System.Array.Clear(_data.Array!, _data.Offset, _data.Count); + } + else + { + var v = (ushort)value; + for (var i = 0; i < _data.Count; i++) + { + _data.Array![_data.Offset + i] = v; + } + } + } + + public Uint16Array Subarray(double start, double end) + { + return new Uint16Array(new ArraySegment(_data.Array!, _data.Offset + (int)start, + (int)end - (int)start)); + } + + public void Set(Uint16Array subarray, double offset) + { + System.Array.Copy(subarray._data.Array!, subarray._data.Offset, _data.Array!, + _data.Offset + (int)offset, subarray._data.Count); } public IEnumerator GetEnumerator() @@ -32,4 +72,4 @@ IEnumerator IEnumerable.GetEnumerator() { return GetEnumerator(); } -} \ No newline at end of file +} diff --git a/packages/kotlin/src/android/src/main/java/alphaTab/collections/BooleanList.kt b/packages/kotlin/src/android/src/main/java/alphaTab/collections/BooleanList.kt index b221abfd3c..8e0870ee07 100644 --- a/packages/kotlin/src/android/src/main/java/alphaTab/collections/BooleanList.kt +++ b/packages/kotlin/src/android/src/main/java/alphaTab/collections/BooleanList.kt @@ -48,8 +48,9 @@ internal class BooleanList : IBooleanIterable { return Iterator(this) } - public fun fill(b: Boolean) { + public fun fill(b: Boolean): BooleanList { _data.fill(b, 0, _size) + return this } public fun spread(): BooleanArray { diff --git a/packages/kotlin/src/android/src/main/java/alphaTab/collections/DoubleList.kt b/packages/kotlin/src/android/src/main/java/alphaTab/collections/DoubleList.kt index 5710787452..de9097912c 100644 --- a/packages/kotlin/src/android/src/main/java/alphaTab/collections/DoubleList.kt +++ b/packages/kotlin/src/android/src/main/java/alphaTab/collections/DoubleList.kt @@ -176,17 +176,29 @@ public class DoubleList : IDoubleIterable { } public fun splice(start: Double, deleteCount: Double) { - val firstAfterDelete = (start + deleteCount).toInt() - val itemsAfterDelete = this.length.toInt() - firstAfterDelete; - if(itemsAfterDelete > 0) { - _items.copyInto( - _items, - start.toInt(), - firstAfterDelete, - itemsAfterDelete - ) - } - this._size -= deleteCount.toInt() + val startIndex = start.toInt().coerceIn(0, _size) + val removed = deleteCount.toInt().coerceIn(0, _size - startIndex) + val firstAfterDelete = startIndex + removed + if (firstAfterDelete < _size) { + _items.copyInto(_items, startIndex, firstAfterDelete, _size) + } + this._size -= removed + } + + public fun splice(start: Double, deleteCount: Double, vararg newElements: Double) { + splice(start, deleteCount) + val insertAt = start.toInt().coerceIn(0, _size) + val inserted = newElements.size + if (inserted == 0) { + return + } + val size = _size + if (size + inserted > _items.size) { + grow(size + inserted) + } + _items.copyInto(_items, insertAt + inserted, insertAt, size) + newElements.copyInto(_items, insertAt) + _size = size + inserted } private class Iterator(private val list: DoubleList) : DoubleIterator() { private var _index = 0 diff --git a/packages/kotlin/src/android/src/main/java/alphaTab/collections/List.kt b/packages/kotlin/src/android/src/main/java/alphaTab/collections/List.kt index aa9b453eb0..79be8f90cd 100644 --- a/packages/kotlin/src/android/src/main/java/alphaTab/collections/List.kt +++ b/packages/kotlin/src/android/src/main/java/alphaTab/collections/List.kt @@ -57,8 +57,9 @@ public class List : Iterable { _data.add(item) } - public fun fill(item: T) { + public fun fill(item: T): List { _data.fill(item) + return this } public fun push(vararg items: T) { diff --git a/packages/kotlin/src/android/src/main/java/alphaTab/core/ecmaScript/Int16Array.kt b/packages/kotlin/src/android/src/main/java/alphaTab/core/ecmaScript/Int16Array.kt index 57d05fe2bd..b740055133 100644 --- a/packages/kotlin/src/android/src/main/java/alphaTab/core/ecmaScript/Int16Array.kt +++ b/packages/kotlin/src/android/src/main/java/alphaTab/core/ecmaScript/Int16Array.kt @@ -1,17 +1,31 @@ package alphaTab.core.ecmaScript -import alphaTab.core.byteLength - @Suppress("NOTHING_TO_INLINE") -@ExperimentalUnsignedTypes internal class Int16Array : Iterable { private val _data: ShortArray - + private val _offset: Int public val length: Double - get() = _data.size.toDouble() public constructor(size: Double) { _data = ShortArray(size.toInt()) + _offset = 0 + length = size + } + + public constructor(values: Iterable) { + val list = values.toList() + _data = ShortArray(list.size) + for (i in list.indices) { + _data[i] = list[i].toInt().toShort() + } + _offset = 0 + length = list.size.toDouble() + } + + private constructor(data: ShortArray, offset: Int, length: Int) { + _data = data + _offset = offset + this.length = length.toDouble() } public inline operator fun get(index: Double): Double { @@ -23,14 +37,35 @@ internal class Int16Array : Iterable { } public inline operator fun get(index: Int): Double { - return _data[index].toDouble() + return _data[_offset + index].toDouble() } public inline operator fun set(index: Int, value: Double) { - _data[index] = value.toInt().toShort() + _data[_offset + index] = value.toInt().toShort() + } + + public fun fill(value: Double) { + _data.fill(value.toInt().toShort(), _offset, _offset + length.toInt()) + } + + public fun subarray(start: Double, end: Double): Int16Array { + return Int16Array(_data, _offset + start.toInt(), (end - start).toInt()) + } + + public fun set(values: Int16Array, offset: Double) { + values._data.copyInto( + _data, + _offset + offset.toInt(), + values._offset, + values._offset + values.length.toInt() + ) } - override fun iterator(): ShortIterator { - return _data.iterator() + override fun iterator(): Iterator { + return object : Iterator { + private var _index = 0 + override fun hasNext(): Boolean = _index < length.toInt() + override fun next(): Short = _data[_offset + _index++] + } } } diff --git a/packages/kotlin/src/android/src/main/java/alphaTab/core/ecmaScript/Int8Array.kt b/packages/kotlin/src/android/src/main/java/alphaTab/core/ecmaScript/Int8Array.kt new file mode 100644 index 0000000000..c9471e6f72 --- /dev/null +++ b/packages/kotlin/src/android/src/main/java/alphaTab/core/ecmaScript/Int8Array.kt @@ -0,0 +1,71 @@ +package alphaTab.core.ecmaScript + +@Suppress("NOTHING_TO_INLINE") +internal class Int8Array : Iterable { + private val _data: ByteArray + private val _offset: Int + public val length: Double + + public constructor(size: Double) { + _data = ByteArray(size.toInt()) + _offset = 0 + length = size + } + + public constructor(values: Iterable) { + val list = values.toList() + _data = ByteArray(list.size) + for (i in list.indices) { + _data[i] = list[i].toInt().toByte() + } + _offset = 0 + length = list.size.toDouble() + } + + private constructor(data: ByteArray, offset: Int, length: Int) { + _data = data + _offset = offset + this.length = length.toDouble() + } + + public inline operator fun get(index: Double): Double { + return get(index.toInt()) + } + + public inline operator fun set(index: Double, value: Double) { + set(index.toInt(), value) + } + + public inline operator fun get(index: Int): Double { + return _data[_offset + index].toDouble() + } + + public inline operator fun set(index: Int, value: Double) { + _data[_offset + index] = value.toInt().toByte() + } + + public fun fill(value: Double) { + _data.fill(value.toInt().toByte(), _offset, _offset + length.toInt()) + } + + public fun subarray(start: Double, end: Double): Int8Array { + return Int8Array(_data, _offset + start.toInt(), (end - start).toInt()) + } + + public fun set(values: Int8Array, offset: Double) { + values._data.copyInto( + _data, + _offset + offset.toInt(), + values._offset, + values._offset + values.length.toInt() + ) + } + + override fun iterator(): Iterator { + return object : Iterator { + private var _index = 0 + override fun hasNext(): Boolean = _index < length.toInt() + override fun next(): Byte = _data[_offset + _index++] + } + } +} diff --git a/packages/kotlin/src/android/src/main/java/alphaTab/core/ecmaScript/Uint16Array.kt b/packages/kotlin/src/android/src/main/java/alphaTab/core/ecmaScript/Uint16Array.kt index a9d4d222e3..62ce6b2ac4 100644 --- a/packages/kotlin/src/android/src/main/java/alphaTab/core/ecmaScript/Uint16Array.kt +++ b/packages/kotlin/src/android/src/main/java/alphaTab/core/ecmaScript/Uint16Array.kt @@ -4,33 +4,69 @@ package alphaTab.core.ecmaScript @ExperimentalUnsignedTypes internal class Uint16Array : Iterable { private val _data: UShortArray - + private val _offset: Int public val length: Double - get() { - return _data.size.toDouble() - } public constructor(size: Double) { _data = UShortArray(size.toInt()) + _offset = 0 + length = size + } + + public constructor(values: Iterable) { + val list = values.toList() + _data = UShortArray(list.size) + for (i in list.indices) { + _data[i] = list[i].toInt().toUShort() + } + _offset = 0 + length = list.size.toDouble() + } + + private constructor(data: UShortArray, offset: Int, length: Int) { + _data = data + _offset = offset + this.length = length.toDouble() } public inline operator fun get(index: Double): Double { - return _data[index.toInt()].toDouble() + return get(index.toInt()) } public inline operator fun set(index: Double, value: Double) { - _data[index.toInt()] = value.toInt().toUShort() + set(index.toInt(), value) } public inline operator fun get(index: Int): Double { - return _data[index].toDouble() + return _data[_offset + index].toDouble() } public inline operator fun set(index: Int, value: Double) { - _data[index] = value.toUInt().toUShort() + _data[_offset + index] = value.toInt().toUShort() + } + + public fun fill(value: Double) { + _data.fill(value.toInt().toUShort(), _offset, _offset + length.toInt()) + } + + public fun subarray(start: Double, end: Double): Uint16Array { + return Uint16Array(_data, _offset + start.toInt(), (end - start).toInt()) + } + + public fun set(values: Uint16Array, offset: Double) { + values._data.copyInto( + _data, + _offset + offset.toInt(), + values._offset, + values._offset + values.length.toInt() + ) } override fun iterator(): Iterator { - return _data.iterator() + return object : Iterator { + private var _index = 0 + override fun hasNext(): Boolean = _index < length.toInt() + override fun next(): UShort = _data[_offset + _index++] + } } } diff --git a/packages/transpiler/src/TypeResolver.ts b/packages/transpiler/src/TypeResolver.ts index 8b052866d4..19ec468129 100644 --- a/packages/transpiler/src/TypeResolver.ts +++ b/packages/transpiler/src/TypeResolver.ts @@ -125,6 +125,7 @@ export default class TypeResolver { * ArrayBuffer generic. */ private static readonly _typedArraySymbolNames: ReadonlySet = new Set([ + 'Int8Array', 'Uint8Array', 'Int16Array', 'Uint16Array', @@ -692,14 +693,6 @@ export default class TypeResolver { typeArguments: typeArguments } as cs.TypeReference; - if (symbolName === 'Int8Array' && this.isNodeEmitted(node)) { - this._ctx.addNodeDiagnostics( - node, - 'Int8Array is not supported. The C# runtime has no Int8Array class. Use Uint8Array or a custom sbyte[] wrapper instead.', - ts.DiagnosticCategory.Error - ); - } - // Typed arrays (Int8Array, ..., DataView) wrap an ArrayBuffer that // does not exist as a generic on the target type. if (TypeResolver._typedArraySymbolNames.has(symbolName)) { diff --git a/packages/transpiler/src/transforms/Operators.ts b/packages/transpiler/src/transforms/Operators.ts index 2c0f549e6f..bd429e7c79 100644 --- a/packages/transpiler/src/transforms/Operators.ts +++ b/packages/transpiler/src/transforms/Operators.ts @@ -147,11 +147,17 @@ function _visitBitwiseAssignmentBinaryOp(state: AstTransformer, parent: cs.Node, return null; } - (bitOp.right as cs.ParenthesizedExpression).expression = state.visitExpression(bitOp.right, expression.right)!; - - if (!(bitOp.right as cs.ParenthesizedExpression).expression) { + const right = state.visitExpression(bitOp.right, expression.right)!; + if (!right) { return null; } + const isShift = + expression.operatorToken.kind === ts.SyntaxKind.LessThanLessThanEqualsToken || + expression.operatorToken.kind === ts.SyntaxKind.GreaterThanGreaterThanEqualsToken || + expression.operatorToken.kind === ts.SyntaxKind.GreaterThanGreaterThanGreaterThanEqualsToken; + (bitOp.right as cs.ParenthesizedExpression).expression = _hasBinaryOperationMakeInt(state, right) + ? right + : makeInt(state, right, !isShift); } else { (bitOp.left as cs.ParenthesizedExpression).expression = state.visitExpression(assignment, expression.left)!; @@ -493,7 +499,26 @@ export function visitAsExpression( return castExpression; } +/** + * Whether the expression reads an element of a value-type array through a runtime helper + * which returns the plain element type (e.g. `IList.Pop()`), not a nullable one. + */ +function _isArrayElementRemoval(state: AstTransformer, expression: ts.Expression): boolean { + if (!ts.isCallExpression(expression) || !ts.isPropertyAccessExpression(expression.expression)) { + return false; + } + const name = expression.expression.name.text; + if (name !== 'pop' && name !== 'shift') { + return false; + } + const receiverType = state.context.typeChecker.getTypeAtLocation(expression.expression.expression); + return state.context.typeChecker.isArrayType(receiverType); +} + export function visitNonNullExpression(state: AstTransformer, parent: cs.Node, expression: ts.NonNullExpression) { + if (state.context.isValueTypeExpression(expression) && _isArrayElementRemoval(state, expression.expression)) { + return state.visitExpression(parent, expression.expression); + } if (state.context.isValueTypeExpression(expression)) { const valueAccessExpression = { expression: {} as cs.Expression, diff --git a/packages/transpiler/test/fixtures/errors/int8array/errors.txt b/packages/transpiler/test/fixtures/errors/int8array/errors.txt deleted file mode 100644 index f53bca98c6..0000000000 --- a/packages/transpiler/test/fixtures/errors/int8array/errors.txt +++ /dev/null @@ -1,2 +0,0 @@ -input.ts:5:20: Int8Array is not supported. The C# runtime has no Int8Array class. Use Uint8Array or a custom sbyte[] wrapper instead. -input.ts:5:36: Int8Array is not supported. The C# runtime has no Int8Array class. Use Uint8Array or a custom sbyte[] wrapper instead. diff --git a/packages/transpiler/test/fixtures/errors/int8array/input.ts b/packages/transpiler/test/fixtures/errors/int8array/input.ts deleted file mode 100644 index 20f52085de..0000000000 --- a/packages/transpiler/test/fixtures/errors/int8array/input.ts +++ /dev/null @@ -1,6 +0,0 @@ -/** - * @public - */ -export class Int8ArrayUsage { - public signed: Int8Array = new Int8Array(0); -} diff --git a/packages/transpiler/test/fixtures/expressions/binary/expected.cs b/packages/transpiler/test/fixtures/expressions/binary/expected.cs index 0bd33d0ba0..241d613348 100644 --- a/packages/transpiler/test/fixtures/expressions/binary/expected.cs +++ b/packages/transpiler/test/fixtures/expressions/binary/expected.cs @@ -44,7 +44,7 @@ public double UnsignedShift(double a) public double Mask(double a, double m) { double x = a; - x = (double)(((int)x) & (m)); + x = (double)(((int)x) & ((int)(m))); x = (double)(((int)x) | (16)); x = (double)(((int)x) ^ (255)); x = (double)(((int)x) << (2)); diff --git a/packages/transpiler/test/fixtures/expressions/binary/expected.kt b/packages/transpiler/test/fixtures/expressions/binary/expected.kt index 851327ba43..c49cb88941 100644 --- a/packages/transpiler/test/fixtures/expressions/binary/expected.kt +++ b/packages/transpiler/test/fixtures/expressions/binary/expected.kt @@ -75,7 +75,7 @@ public class Binary */ public fun mask(a: Double, m: Double): Double{ var x: Double = a - x = ((x.toInt()) and (m)).toDouble() + x = ((x.toInt()) and ((m).toInt())).toDouble() x = ((x.toInt()) or (16)).toDouble() x = ((x.toInt()) xor (255)).toDouble() x = ((x.toInt()) shl (2)).toDouble() diff --git a/packages/transpiler/test/fixtures/types/typed-arrays/expected.cs b/packages/transpiler/test/fixtures/types/typed-arrays/expected.cs index 91694154a8..fbb15c5664 100644 --- a/packages/transpiler/test/fixtures/types/typed-arrays/expected.cs +++ b/packages/transpiler/test/fixtures/types/typed-arrays/expected.cs @@ -26,6 +26,27 @@ public AlphaTab.Core.EcmaScript.Float32Array Floats } = new AlphaTab.Core.EcmaScript.Float32Array(0); + public AlphaTab.Core.EcmaScript.Int8Array SignedBytes + { + get; + set; + } + = new AlphaTab.Core.EcmaScript.Int8Array(0); + + public AlphaTab.Core.EcmaScript.Int16Array Shorts + { + get; + set; + } + = new AlphaTab.Core.EcmaScript.Int16Array(0); + + public AlphaTab.Core.EcmaScript.Uint16Array UnsignedShorts + { + get; + set; + } + = new AlphaTab.Core.EcmaScript.Uint16Array(0); + public double ByteAt(double i) { return this.Bytes[(int)(i)]; @@ -38,6 +59,18 @@ public AlphaTab.Core.EcmaScript.Uint8Array MakeBuffer(double size) } + public void ResetSigned(double size) + { + this.SignedBytes = new AlphaTab.Core.EcmaScript.Int8Array(size); + this.SignedBytes.Fill(-1); + this.SignedBytes[(int)(0)] = -128; + this.Shorts = new AlphaTab.Core.EcmaScript.Int16Array(size); + this.Shorts.Fill(-1); + this.Shorts.Set(this.Shorts.Subarray(0, 1), 1); + this.UnsignedShorts.Fill(0); + } + + } } diff --git a/packages/transpiler/test/fixtures/types/typed-arrays/expected.kt b/packages/transpiler/test/fixtures/types/typed-arrays/expected.kt index fa93ba69d8..d3c324f322 100644 --- a/packages/transpiler/test/fixtures/types/typed-arrays/expected.kt +++ b/packages/transpiler/test/fixtures/types/typed-arrays/expected.kt @@ -48,6 +48,15 @@ public class TypedArrays public var floats: alphaTab.core.ecmaScript.Float32Array = alphaTab.core.ecmaScript.Float32Array(0.0) + public var signedBytes: alphaTab.core.ecmaScript.Int8Array = alphaTab.core.ecmaScript.Int8Array(0.0) + + + public var shorts: alphaTab.core.ecmaScript.Int16Array = alphaTab.core.ecmaScript.Int16Array(0.0) + + + public var unsignedShorts: alphaTab.core.ecmaScript.Uint16Array = alphaTab.core.ecmaScript.Uint16Array(0.0) + + /** */ public fun byteAt(i: Double): Double{ @@ -60,6 +69,18 @@ public class TypedArrays return alphaTab.core.ecmaScript.Uint8Array(size) } + /** + */ + public fun resetSigned(size: Double): Unit{ + this.signedBytes = alphaTab.core.ecmaScript.Int8Array(size) + this.signedBytes.fill(-1.0) + this.signedBytes[(0).toInt()] = -128.0 + this.shorts = alphaTab.core.ecmaScript.Int16Array(size) + this.shorts.fill(-1.0) + this.shorts.set(this.shorts.subarray(0.0, 1.0), 1.0) + this.unsignedShorts.fill(0.0) + } + public constructor() } diff --git a/packages/transpiler/test/fixtures/types/typed-arrays/input.ts b/packages/transpiler/test/fixtures/types/typed-arrays/input.ts index 276416ee98..e6b89f38d2 100644 --- a/packages/transpiler/test/fixtures/types/typed-arrays/input.ts +++ b/packages/transpiler/test/fixtures/types/typed-arrays/input.ts @@ -4,6 +4,9 @@ export class TypedArrays { public bytes: Uint8Array = new Uint8Array(0); public floats: Float32Array = new Float32Array(0); + public signedBytes: Int8Array = new Int8Array(0); + public shorts: Int16Array = new Int16Array(0); + public unsignedShorts: Uint16Array = new Uint16Array(0); public byteAt(i: number): number { return this.bytes[i]; @@ -12,4 +15,14 @@ export class TypedArrays { public makeBuffer(size: number): Uint8Array { return new Uint8Array(size); } + + public resetSigned(size: number): void { + this.signedBytes = new Int8Array(size); + this.signedBytes.fill(-1); + this.signedBytes[0] = -128; + this.shorts = new Int16Array(size); + this.shorts.fill(-1); + this.shorts.set(this.shorts.subarray(0, 1), 1); + this.unsignedShorts.fill(0); + } } diff --git a/website/docs/features/play-sync/per-track-mixing.mdx b/website/docs/features/play-sync/per-track-mixing.mdx index 73d7afa7f0..dab4ffa3a1 100644 --- a/website/docs/features/play-sync/per-track-mixing.mdx +++ b/website/docs/features/play-sync/per-track-mixing.mdx @@ -55,5 +55,6 @@ api.scoreLoaded.on((score) => { ## Where to look next - [Master and monitoring volumes](./master-and-monitoring-volumes) — the master fader every track feeds into. +- [Transposed tablature](/docs/features/render/guitar-tablature#transposed-tablature) — transpose what's rendered as well, with tab notes moved to playable strings. - [Selecting and switching tracks](/docs/features/import-load/selecting-and-switching-tracks) — control which tracks render on screen, independent of the mix. - [`changeTrackVolume`](/docs/reference/api/changetrackvolume), [`changeTrackSolo`](/docs/reference/api/changetracksolo), [`changeTrackMute`](/docs/reference/api/changetrackmute), and [`changeTrackTranspositionPitch`](/docs/reference/api/changetracktranspositionpitch) reference entries. diff --git a/website/docs/features/render/guitar-tablature.mdx b/website/docs/features/render/guitar-tablature.mdx index be90572325..2c9b2c391b 100644 --- a/website/docs/features/render/guitar-tablature.mdx +++ b/website/docs/features/render/guitar-tablature.mdx @@ -1,6 +1,6 @@ --- title: "Guitar tablature" -description: "String-and-fret rendering, rhythm modes, small grace tabs." +description: "String-and-fret rendering, rhythm modes, small grace tabs, transposed tabs." sidebar_custom_props: feature_chapter: notation-types feature_order: 20 @@ -8,6 +8,7 @@ keywords: - alphaTab - tablature - guitar + - transpose --- import { AlphaTab } from '@site/src/components/AlphaTab'; @@ -41,6 +42,27 @@ When you hide standard notation, the tab staff loses stems and beams. Turn them Because these are settings on the `notation` group, they apply to every staff in the render. Combine them with per-staff `showTablature` toggles when you need mixed layouts. +## Transposed tablature {#transposed-tablature} + +[`notation.transpositionPitches`](/docs/reference/settings/notation/transpositionpitches) shifts a track by a number of semitones for display and playback. On the tab staff, a note keeps its string and fret position shifted by the transposition while that fret is playable. When the fret would drop below the open string or rise beyond the 24th fret, alphaTab moves the note to another string. It picks the string whose fret is closest to the previous notes and keeps chord notes on separate strings. + +Notes connected by ties, slides, and hammer-ons or pull-offs move together, so they stay on one string. Bends, slides, and vibrato never land on an open string. Natural harmonics move to the node that sounds the transposed pitch. When no string has such a node, the harmonic counts as unplayable. + +The example below is transposed down four semitones. The open strings and the low notes no longer fit on their strings: + +{` +\\track "Guitar" + \\staff{score tabs} + :4 0.6 3.5 2.4 0.3 | (0.1 0.2 0.3) 3.5 3.5 3.5 | 3.5{t} 2.3{h} 4.3 4.3{b (0 4)} +`} + +A note that fits on no string of the tuning keeps its string and shows the out-of-range fret in [`display.resources.tabUnplayableFretColor`](/docs/reference/types/renderingresources/). The low E above is such a note. + +The new positions exist only on the rendered tab. The loaded score keeps its strings and frets, and changing `transpositionPitches` at runtime takes effect on the next `render()`. Frets count from the capo, so a capo lowers the highest usable fret. String number circles in the standard notation follow the moved strings, and left-hand finger numbers of moved notes are hidden. Chord diagrams are not transposed. + ## Show it in code ```js @@ -74,5 +96,5 @@ api.scoreLoaded.on((score) => { - [Standard notation](/docs/features/render/standard-notation) — pair the tab staff with a score staff. - [Slash notation](/docs/features/render/slash-notation) — swap fret numbers for chord-rhythm slashes. - [Drum tablature](/docs/features/render/drum-tabs#drum-tablature) — the same tab staff for percussion tracks. -- [`notation.rhythmMode`](/docs/reference/settings/notation/rhythmmode), [`notation.rhythmHeight`](/docs/reference/settings/notation/rhythmheight), and [`notation.smallGraceTabNotes`](/docs/reference/settings/notation/smallgracetabnotes) in the settings reference. +- [`notation.rhythmMode`](/docs/reference/settings/notation/rhythmmode), [`notation.rhythmHeight`](/docs/reference/settings/notation/rhythmheight), [`notation.smallGraceTabNotes`](/docs/reference/settings/notation/smallgracetabnotes), and [`notation.transpositionPitches`](/docs/reference/settings/notation/transpositionpitches) in the settings reference. - [`Staff.showTablature`](/docs/reference/types/model/staff) in the model reference. diff --git a/website/docs/features/render/score-wide-palette.mdx b/website/docs/features/render/score-wide-palette.mdx index df5f56b10e..35af7b1264 100644 --- a/website/docs/features/render/score-wide-palette.mdx +++ b/website/docs/features/render/score-wide-palette.mdx @@ -28,6 +28,7 @@ Each color on [`RenderingResources`](/docs/reference/types/renderingresources/) - `mainGlyphColor` — noteheads, stems, beams, and effect glyphs of the primary voice. - `secondaryGlyphColor` — the same glyphs when they belong to a secondary voice; defaults to a semi-transparent black so lower voices sit visually behind the main line. - `scoreInfoColor` — the title, subtitle, artist, album, and copyright text. +- `tabUnplayableFretColor` — tab fret numbers that fit on no string after [transposing the track](/docs/features/render/guitar-tablature#transposed-tablature). The [`RenderingResources`](/docs/reference/types/renderingresources/) reference lists every field, including the font counterparts (`barNumberFont`, `copyrightFont`, and so on) that pair with each color. diff --git a/website/docs/releases/release1_10.mdx b/website/docs/releases/release1_10.mdx new file mode 100644 index 0000000000..f1730c1bd2 --- /dev/null +++ b/website/docs/releases/release1_10.mdx @@ -0,0 +1,24 @@ +--- +title: v1.10 +--- + +import { AlphaTab } from '@site/src/components/AlphaTab'; + +## 1.10.0 + +### Music Notation + +#### render: transposed tabs on playable strings + +Guitar tabs of a track transposed with [`notation.transpositionPitches`](/docs/reference/settings/notation/transpositionpitches) (or the alphaTex `\transpose` metadata tag) no longer show negative frets or frets beyond the neck. A note keeps its string while the shifted fret is playable there; otherwise it moves to another string, together with the notes it is tied, slid, or hammered to. Natural harmonics move to the node of the transposed pitch. Notes that fit on no string keep their string and show the fret in the new [`display.resources.tabUnplayableFretColor`](/docs/reference/types/renderingresources/). + +{` +\\track "Guitar" + \\staff{score tabs} + :4 0.6 3.5 2.4 0.3 | (0.1 0.2 0.3) 3.5 3.5 3.5 | 3.5{t} 2.3{h} 4.3 4.3{b (0 4)} +`} + +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). diff --git a/website/static/llms.txt b/website/static/llms.txt index bc8fc31503..d23737ffc9 100644 --- a/website/static/llms.txt +++ b/website/static/llms.txt @@ -60,7 +60,7 @@ alphaTab is not a drop-in widget — it exposes an API you build your own score - [Responsive resize](https://alphatab.net/docs/features/render/responsive-resize): ResizeObserver, triggerResize(), resize event. - [Rendering a slice](https://alphatab.net/docs/features/render/bar-range): startBar / barCount to render partial scores. - [Standard notation](https://alphatab.net/docs/features/render/standard-notation): The traditional five-line staff, showStandardNotation. -- [Guitar tablature](https://alphatab.net/docs/features/render/guitar-tablature): String-and-fret rendering, rhythm modes, small grace tabs. +- [Guitar tablature](https://alphatab.net/docs/features/render/guitar-tablature): String-and-fret rendering, rhythm modes, small grace tabs, transposed tabs. - [Slash notation](https://alphatab.net/docs/features/render/slash-notation): Chord rhythm slashes, showSlash. - [Numbered notation (jiǎnpǔ)](https://alphatab.net/docs/features/render/numbered-notation): Cipher notation, showNumbered. - [Drum and percussion notation](https://alphatab.net/docs/features/render/drum-tabs): Percussion staff with 1/2/3 lines, drum piece heads.