diff --git a/src.csharp/AlphaTab/Core/TypeHelper.cs b/src.csharp/AlphaTab/Core/TypeHelper.cs index bee284939..5ff36f5c7 100644 --- a/src.csharp/AlphaTab/Core/TypeHelper.cs +++ b/src.csharp/AlphaTab/Core/TypeHelper.cs @@ -3,6 +3,7 @@ using System.Globalization; using System.Linq; using System.Runtime.CompilerServices; +using System.Text; using System.Threading.Tasks; using AlphaTab.Core.EcmaScript; @@ -487,6 +488,23 @@ public static string[] Split(this string value, RegExp pattern) return pattern.Split(value); } + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static string Repeat(this string value, double count) + { + var icount = (int)count; + if (icount == 0) + { + return ""; + } + + var builder = new StringBuilder(value.Length * icount); + for (var i = 0; i < icount; i++) + { + builder.Append(value); + } + return builder.ToString(); + } + public static Task CreatePromise(Action<Action, Action<object>> run) { var taskCompletionSource = new TaskCompletionSource<object?>(); diff --git a/src.kotlin/alphaTab/android/src/main/java/alphaTab/core/Globals.kt b/src.kotlin/alphaTab/android/src/main/java/alphaTab/core/Globals.kt index 154f8045c..652a9a83a 100644 --- a/src.kotlin/alphaTab/android/src/main/java/alphaTab/core/Globals.kt +++ b/src.kotlin/alphaTab/android/src/main/java/alphaTab/core/Globals.kt @@ -276,3 +276,8 @@ internal fun <T> Deferred<T>.catch(callback: (alphaTab.core.ecmaScript.Error) -> @OptIn(ExperimentalUnsignedTypes::class) internal val ArrayBuffer.byteLength: Int get() = this.size + +internal fun String.repeat(count:Double): String { + return this.repeat(count.toInt()) +} + diff --git a/src/Environment.ts b/src/Environment.ts index 01aac4d81..e5ecff464 100644 --- a/src/Environment.ts +++ b/src/Environment.ts @@ -68,6 +68,7 @@ import { SustainPedalEffectInfo } from './rendering/effects/SustainPedalEffectIn import { GolpeEffectInfo } from './rendering/effects/GolpeEffectInfo'; import { GolpeType } from './model/GolpeType'; import { WahPedalEffectInfo } from './rendering/effects/WahPedalEffectInfo'; +import { BeatBarreEffectInfo } from './rendering/effects/BeatBarreEffectInfo'; export class LayoutEngineFactory { public readonly vertical: boolean; @@ -509,7 +510,8 @@ export class Environment { // Score (standard notation) new EffectBarRendererFactory(Environment.StaffIdBeforeScoreAlways, [ new FermataEffectInfo(), - new WahPedalEffectInfo() + new BeatBarreEffectInfo(), + new WahPedalEffectInfo(), ]), new EffectBarRendererFactory( Environment.StaffIdBeforeScoreHideable, diff --git a/src/NotationSettings.ts b/src/NotationSettings.ts index a9dfddbb1..9386672ae 100644 --- a/src/NotationSettings.ts +++ b/src/NotationSettings.ts @@ -311,7 +311,12 @@ export enum NotationElement { /** * The Wah effect signs above and below the staff. */ - EffectWahPedal + EffectWahPedal, + + /** + * The Beat barre effect signs above and below the staff "1/2B IV ─────┐" + */ + BeatBarre } /** diff --git a/src/exporter/GpifWriter.ts b/src/exporter/GpifWriter.ts index 5338bd127..2cee50d8d 100644 --- a/src/exporter/GpifWriter.ts +++ b/src/exporter/GpifWriter.ts @@ -3,6 +3,7 @@ import { MidiUtils } from '@src/midi/MidiUtils'; import { AccentuationType } from '@src/model/AccentuationType'; import { AutomationType } from '@src/model/Automation'; import { Bar, SustainPedalMarkerType } from '@src/model/Bar'; +import { BarreShape } from '@src/model/BarreShape'; import { Beat } from '@src/model/Beat'; import { BendPoint } from '@src/model/BendPoint'; import { BrushType } from '@src/model/BrushType'; @@ -819,6 +820,18 @@ export class GpifWriter { this.writeSimplePropertyNode(beatProperties, 'VibratoWTremBar', 'Strength', 'Slight'); break; } + + if (beat.isBarre) { + this.writeSimplePropertyNode(beatProperties, 'BarreFret', 'Fret', beat.barreFret.toString()); + switch (beat.barreShape) { + case BarreShape.Full: + this.writeSimplePropertyNode(beatProperties, 'BarreString', 'String', '0'); + break; + case BarreShape.Half: + this.writeSimplePropertyNode(beatProperties, 'BarreString', 'String', '1'); + break; + } + } } private writeRhythm(parent: XmlNode, beat: Beat, rhythms: XmlNode) { diff --git a/src/generated/model/BeatCloner.ts b/src/generated/model/BeatCloner.ts index 2bd5ba934..ec686c3bf 100644 --- a/src/generated/model/BeatCloner.ts +++ b/src/generated/model/BeatCloner.ts @@ -65,6 +65,8 @@ export class BeatCloner { clone.isEffectSlurOrigin = original.isEffectSlurOrigin; clone.beamingMode = original.beamingMode; clone.wahPedal = original.wahPedal; + clone.barreFret = original.barreFret; + clone.barreShape = original.barreShape; return clone; } } diff --git a/src/generated/model/BeatSerializer.ts b/src/generated/model/BeatSerializer.ts index 2f3b28f61..46e4afedd 100644 --- a/src/generated/model/BeatSerializer.ts +++ b/src/generated/model/BeatSerializer.ts @@ -26,6 +26,7 @@ import { DynamicValue } from "@src/model/DynamicValue"; import { BeamDirection } from "@src/rendering/utils/BeamDirection"; import { BeatBeamingMode } from "@src/model/Beat"; import { WahPedal } from "@src/model/WahPedal"; +import { BarreShape } from "@src/model/BarreShape"; export class BeatSerializer { public static fromJson(obj: Beat, m: unknown): void { if (!m) { @@ -81,6 +82,8 @@ export class BeatSerializer { o.set("preferredbeamdirection", obj.preferredBeamDirection as number | null); o.set("beamingmode", obj.beamingMode as number); o.set("wahpedal", obj.wahPedal as number); + o.set("barrefret", obj.barreFret); + o.set("barreshape", obj.barreShape as number); return o; } public static setProperty(obj: Beat, property: string, v: unknown): boolean { @@ -225,6 +228,12 @@ export class BeatSerializer { case "wahpedal": obj.wahPedal = JsonHelper.parseEnum<WahPedal>(v, WahPedal)!; return true; + case "barrefret": + obj.barreFret = v! as number; + return true; + case "barreshape": + obj.barreShape = JsonHelper.parseEnum<BarreShape>(v, BarreShape)!; + return true; } return false; } diff --git a/src/importer/AlphaTexImporter.ts b/src/importer/AlphaTexImporter.ts index e1b39b3b5..7074e42d1 100644 --- a/src/importer/AlphaTexImporter.ts +++ b/src/importer/AlphaTexImporter.ts @@ -42,6 +42,7 @@ import { NoteAccidentalMode } from '@src/model'; import { GolpeType } from '@src/model/GolpeType'; import { FadeType } from '@src/model/FadeType'; import { WahPedal } from '@src/model/WahPedal'; +import { BarreShape } from '@src/model/BarreShape'; /** * A list of terminals recognized by the alphaTex-parser @@ -1639,6 +1640,29 @@ export class AlphaTexImporter extends ScoreImporter { } else if (syData === 'wahc') { this._sy = this.newSy(); beat.wahPedal = WahPedal.Closed; + return true; + } else if (syData === 'barre') { + this._sy = this.newSy(); + + if (this._sy !== AlphaTexSymbols.Number) { + this.error('beat-barre', AlphaTexSymbols.Number, true); + } + beat.barreFret = this._syData as number; + this._sy = this.newSy(); + + if (this._sy === AlphaTexSymbols.String) { + switch ((this._syData as string).toLowerCase()) { + case 'full': + beat.barreShape = BarreShape.Full; + this._sy = this.newSy(); + break; + case 'half': + beat.barreShape = BarreShape.Half; + this._sy = this.newSy(); + break; + } + } + return true; } else { // string didn't match any beat effect syntax diff --git a/src/importer/GpifParser.ts b/src/importer/GpifParser.ts index b77708af3..90040cd01 100644 --- a/src/importer/GpifParser.ts +++ b/src/importer/GpifParser.ts @@ -49,6 +49,7 @@ import { Logger } from '@src/Logger'; import { GolpeType } from '@src/model/GolpeType'; import { FadeType } from '@src/model/FadeType'; import { WahPedal } from '@src/model/WahPedal'; +import { BarreShape } from '@src/model/BarreShape'; /** * This structure represents a duration within a gpif @@ -1826,6 +1827,19 @@ export class GpifParser { parseFloat(c.findChildElement('Float')!.innerText) ); break; + case 'BarreFret': + beat.barreFret = parseInt(c.findChildElement('Fret')!.innerText); + break; + case 'BarreString': + switch (c.findChildElement('String')!.innerText) { + case '0': + beat.barreShape = BarreShape.Full; + break; + case '1': + beat.barreShape = BarreShape.Half; + break; + } + break; } break; } diff --git a/src/model/BarreShape.ts b/src/model/BarreShape.ts new file mode 100644 index 000000000..db17c0ff6 --- /dev/null +++ b/src/model/BarreShape.ts @@ -0,0 +1,19 @@ +/** + * Lists all beat barré types. + */ +export enum BarreShape { + /** + * No Barré + */ + None, + + /** + * Full Barré (play all strings) + */ + Full, + + /** + * 1/2 Barré (play only half the strings) + */ + Half +} diff --git a/src/model/Beat.ts b/src/model/Beat.ts index 714077bb4..f797c14e3 100644 --- a/src/model/Beat.ts +++ b/src/model/Beat.ts @@ -25,6 +25,7 @@ import { GraceGroup } from '@src/model/GraceGroup'; import { GolpeType } from './GolpeType'; import { FadeType } from './FadeType'; import { WahPedal } from './WahPedal'; +import { BarreShape } from './BarreShape'; /** * Lists the different modes on how beaming for a beat should be done. @@ -476,7 +477,24 @@ export class Beat { /** * Whether the wah pedal should be used when playing the beat. */ - public wahPedal:WahPedal = WahPedal.None; + public wahPedal: WahPedal = WahPedal.None; + + /** + * The fret of a barré being played on this beat. + */ + public barreFret: number = -1; + + /** + * The shape how the barre should be played on this beat. + */ + public barreShape: BarreShape = BarreShape.None; + + /** + * Gets a value indicating whether the beat should be played as Barré + */ + public get isBarre() { + return this.barreShape !== BarreShape.None && this.barreFret >= 0; + } public addWhammyBarPoint(point: BendPoint): void { let points = this.whammyBarPoints; diff --git a/src/rendering/effects/BeatBarreEffectInfo.ts b/src/rendering/effects/BeatBarreEffectInfo.ts new file mode 100644 index 000000000..4ca31ff29 --- /dev/null +++ b/src/rendering/effects/BeatBarreEffectInfo.ts @@ -0,0 +1,81 @@ +import { Beat } from '@src/model/Beat'; +import { BarRendererBase } from '@src/rendering/BarRendererBase'; +import { EffectBarGlyphSizing } from '@src/rendering/EffectBarGlyphSizing'; +import { EffectGlyph } from '@src/rendering/glyphs/EffectGlyph'; +import { LineRangedGlyph } from '@src/rendering/glyphs/LineRangedGlyph'; +import { EffectBarRendererInfo } from '@src/rendering/EffectBarRendererInfo'; +import { Settings } from '@src/Settings'; +import { NotationElement } from '@src/NotationSettings'; +import { BarreShape } from '@src/model/BarreShape'; + +export class BeatBarreEffectInfo extends EffectBarRendererInfo { + public get notationElement(): NotationElement { + return NotationElement.EffectLetRing; + } + + public get canShareBand(): boolean { + return false; + } + + public get hideOnMultiTrack(): boolean { + return false; + } + + public shouldCreateGlyph(settings: Settings, beat: Beat): boolean { + return beat.isBarre; + } + + public get sizingMode(): EffectBarGlyphSizing { + return EffectBarGlyphSizing.GroupedOnBeat; + } + + public createNewGlyph(renderer: BarRendererBase, beat: Beat): EffectGlyph { + let barre = ''; + switch (beat.barreShape) { + case BarreShape.None: + case BarreShape.Full: + break; + case BarreShape.Half: + barre += '1/2'; + break; + } + + barre += 'B ' + BeatBarreEffectInfo.toRoman(beat.barreFret); + + return new LineRangedGlyph(barre, false); + } + + private static readonly RomanLetters = new Map<string, number>([ + // ['M', 1000], + // ['CM', 900], + // ['D', 500], + // ['CD', 400], + // ['C', 100], + // ['XC', 90], + ['L', 50], + ['XL', 40], + ['X', 10], + ['IX', 9], + ['V', 5], + ['IV', 4], + ['I', 1] + ]); + + public static toRoman(num: number): string { + let str = ''; + + if (num > 0) { + for (var [romanLetter, romanNumber] of BeatBarreEffectInfo.RomanLetters) { + const q = Math.floor(num / romanNumber); + num -= q * romanNumber; + str += romanLetter.repeat(q); + } + } + + return str; + } + + public canExpand(from: Beat, to: Beat): boolean { + return from.barreFret === to.barreFret && from.barreShape === to.barreShape; + } +} diff --git a/src/rendering/glyphs/LineRangedGlyph.ts b/src/rendering/glyphs/LineRangedGlyph.ts index 1abcaf6af..e56e0b245 100644 --- a/src/rendering/glyphs/LineRangedGlyph.ts +++ b/src/rendering/glyphs/LineRangedGlyph.ts @@ -9,10 +9,12 @@ export class LineRangedGlyph extends GroupedEffectGlyph { public static readonly LineTopOffset: number = 5; public static readonly LineSize: number = 8; private _label: string; + private _dashed: boolean; - public constructor(label: string) { + public constructor(label: string, dashed: boolean = true) { super(BeatXPosition.OnNotes); this._label = label; + this._dashed = dashed; } public override doLayout(): void { @@ -40,17 +42,25 @@ export class LineRangedGlyph extends GroupedEffectGlyph { let startX: number = cx + this.x + textWidth / 2 + lineSpacing; let lineY: number = cy + this.y + 4 * this.scale; let lineSize: number = 8 * this.scale; - if (endX > startX) { - let lineX: number = startX; - while (lineX < endX) { + if (this._dashed) { + if (endX > startX) { + let lineX: number = startX; + while (lineX < endX) { + canvas.beginPath(); + canvas.moveTo(lineX, lineY | 0); + canvas.lineTo(Math.min(lineX + lineSize, endX), lineY | 0); + lineX += lineSize + lineSpacing; + canvas.stroke(); + } canvas.beginPath(); - canvas.moveTo(lineX, lineY | 0); - canvas.lineTo(Math.min(lineX + lineSize, endX), lineY | 0); - lineX += lineSize + lineSpacing; + canvas.moveTo(endX, (lineY - 5 * this.scale) | 0); + canvas.lineTo(endX, (lineY + 5 * this.scale) | 0); canvas.stroke(); } + } else { canvas.beginPath(); - canvas.moveTo(endX, (lineY - 5 * this.scale) | 0); + canvas.moveTo(startX, lineY | 0); + canvas.lineTo(endX, lineY | 0); canvas.lineTo(endX, (lineY + 5 * this.scale) | 0); canvas.stroke(); } diff --git a/test-data/visual-tests/effects-and-annotations/barre.gp b/test-data/visual-tests/effects-and-annotations/barre.gp new file mode 100644 index 000000000..abe128b1d Binary files /dev/null and b/test-data/visual-tests/effects-and-annotations/barre.gp differ diff --git a/test-data/visual-tests/effects-and-annotations/barre.png b/test-data/visual-tests/effects-and-annotations/barre.png new file mode 100644 index 000000000..57359bbf6 Binary files /dev/null and b/test-data/visual-tests/effects-and-annotations/barre.png differ diff --git a/test/visualTests/features/EffectsAndAnnotations.test.ts b/test/visualTests/features/EffectsAndAnnotations.test.ts index f8fdef0eb..c5f0bf830 100644 --- a/test/visualTests/features/EffectsAndAnnotations.test.ts +++ b/test/visualTests/features/EffectsAndAnnotations.test.ts @@ -1,7 +1,9 @@ import { AlphaTexImporter } from '@src/importer/AlphaTexImporter'; import { ByteBuffer } from '@src/io/ByteBuffer'; +import { BeatBarreEffectInfo } from '@src/rendering/effects/BeatBarreEffectInfo'; import { Settings } from '@src/Settings'; import { VisualTestHelper } from '@test/visualTests/VisualTestHelper'; +import { expect } from 'chai'; describe('EffectsAndAnnotationsTests', () => { it('markers', async () => { @@ -191,4 +193,41 @@ describe('EffectsAndAnnotationsTests', () => { it('golpe-tab', async () => { await VisualTestHelper.runVisualTest('effects-and-annotations/golpe-tab.gp'); }); + + it('roman-numbers', () => { + expect(BeatBarreEffectInfo.toRoman(0)).to.equal(''); + expect(BeatBarreEffectInfo.toRoman(1)).to.equal('I'); + expect(BeatBarreEffectInfo.toRoman(2)).to.equal('II'); + expect(BeatBarreEffectInfo.toRoman(3)).to.equal('III'); + expect(BeatBarreEffectInfo.toRoman(4)).to.equal('IV'); + expect(BeatBarreEffectInfo.toRoman(5)).to.equal('V'); + expect(BeatBarreEffectInfo.toRoman(6)).to.equal('VI'); + expect(BeatBarreEffectInfo.toRoman(7)).to.equal('VII'); + expect(BeatBarreEffectInfo.toRoman(8)).to.equal('VIII'); + expect(BeatBarreEffectInfo.toRoman(9)).to.equal('IX'); + expect(BeatBarreEffectInfo.toRoman(10)).to.equal('X'); + expect(BeatBarreEffectInfo.toRoman(11)).to.equal('XI'); + expect(BeatBarreEffectInfo.toRoman(12)).to.equal('XII'); + expect(BeatBarreEffectInfo.toRoman(13)).to.equal('XIII'); + expect(BeatBarreEffectInfo.toRoman(14)).to.equal('XIV'); + expect(BeatBarreEffectInfo.toRoman(15)).to.equal('XV'); + expect(BeatBarreEffectInfo.toRoman(16)).to.equal('XVI'); + expect(BeatBarreEffectInfo.toRoman(17)).to.equal('XVII'); + expect(BeatBarreEffectInfo.toRoman(18)).to.equal('XVIII'); + expect(BeatBarreEffectInfo.toRoman(19)).to.equal('XIX'); + expect(BeatBarreEffectInfo.toRoman(20)).to.equal('XX'); + expect(BeatBarreEffectInfo.toRoman(21)).to.equal('XXI'); + expect(BeatBarreEffectInfo.toRoman(22)).to.equal('XXII'); + expect(BeatBarreEffectInfo.toRoman(23)).to.equal('XXIII'); + expect(BeatBarreEffectInfo.toRoman(24)).to.equal('XXIV'); + expect(BeatBarreEffectInfo.toRoman(25)).to.equal('XXV'); + expect(BeatBarreEffectInfo.toRoman(26)).to.equal('XXVI'); + expect(BeatBarreEffectInfo.toRoman(27)).to.equal('XXVII'); + expect(BeatBarreEffectInfo.toRoman(28)).to.equal('XXVIII'); + expect(BeatBarreEffectInfo.toRoman(29)).to.equal('XXIX'); + }); + + it('barre', async () => { + await VisualTestHelper.runVisualTest('effects-and-annotations/barre.gp'); + }); });