diff --git a/packages/alphatab/scripts/Serializer.toJson.ts b/packages/alphatab/scripts/Serializer.toJson.ts index 87a9cd096..ed2c5656b 100644 --- a/packages/alphatab/scripts/Serializer.toJson.ts +++ b/packages/alphatab/scripts/Serializer.toJson.ts @@ -1,5 +1,5 @@ -import * as ts from 'typescript'; import { createNodeFromSource, setMethodBody } from '@coderline/alphatab-transpiler/src/BuilderHelpers'; +import * as ts from 'typescript'; import { findSerializerModule } from './Serializer.common'; import type { TypeSchema } from './TypeSchema'; @@ -153,6 +153,17 @@ function generateToJsonBody(serializable: TypeSchema, importer: (name: string, m }`, ts.SyntaxKind.Block ); + } else if(prop.type.typeArguments![1].isArray && prop.type.typeArguments![1].arrayItemType!.isPrimitiveType) { + serializeBlock = createNodeFromSource( + `{ + const m = new Map(); + o.set(${JSON.stringify(jsonName)}, m); + for(const [k, v] of obj.${fieldName}!) { + m.set(k.toString(), v); + } + }`, + ts.SyntaxKind.Block + ); } else { const itemSerializer = `${prop.type.typeArguments![1].typeAsString}Serializer`; importer(itemSerializer, findSerializerModule(prop.type.typeArguments![1])); diff --git a/packages/alphatab/src/exporter/GpifWriter.ts b/packages/alphatab/src/exporter/GpifWriter.ts index 3bef01db6..b00ed8737 100644 --- a/packages/alphatab/src/exporter/GpifWriter.ts +++ b/packages/alphatab/src/exporter/GpifWriter.ts @@ -1614,6 +1614,10 @@ export class GpifWriter { masterBarNode.addElement('Time').innerText = `${masterBar.timeSignatureNumerator}/${masterBar.timeSignatureDenominator}`; + if (masterBar.actualBeamingRules) { + this._writeBarXProperties(masterBarNode, masterBar); + } + if (masterBar.isFreeTime) { masterBarNode.addElement('FreeTime'); } @@ -1739,6 +1743,39 @@ export class GpifWriter { this._writeFermatas(masterBarNode, masterBar); } + private _writeBarXProperties(masterBarNode: XmlNode, masterBar: MasterBar) { + const properties = masterBarNode.addElement('XProperties'); + + const beamingRules = masterBar.actualBeamingRules; + if (beamingRules) { + // prefer 8th note rule (that's what GP mostly has) + const rule = beamingRules!.findRule(Duration.Eighth); + + // NOTE: it's not clear if guitar pro supports quarter rules + // for that case we better convert this to an "8th" note rule. + let durationProp = rule[0] as number; + let groupSizeFactor = 1; + if (rule[0] === Duration.Quarter) { + durationProp = 8; + groupSizeFactor = 2; + } + + this._writeSimpleXPropertyNode(properties, '1124139010', 'Int', durationProp.toString()); + + const startGroupid = 1124139264; + let i = 0; + while (startGroupid < 1124139295 && i < rule[1].length) { + this._writeSimpleXPropertyNode( + properties, + (startGroupid + i).toString(), + 'Int', + (rule[1][i] * groupSizeFactor).toString() + ); + i++; + } + } + } + private _writeFermatas(parent: XmlNode, masterBar: MasterBar) { const fermataCount = masterBar.fermata?.size ?? 0; if (fermataCount === 0) { diff --git a/packages/alphatab/src/generated/model/BeamingRulesSerializer.ts b/packages/alphatab/src/generated/model/BeamingRulesSerializer.ts new file mode 100644 index 000000000..b0f7c779b --- /dev/null +++ b/packages/alphatab/src/generated/model/BeamingRulesSerializer.ts @@ -0,0 +1,44 @@ +// +// This code was auto-generated. +// Changes to this file may cause incorrect behavior and will be lost if +// the code is regenerated. +// +import { BeamingRules } from "@coderline/alphatab/model/MasterBar"; +import { JsonHelper } from "@coderline/alphatab/io/JsonHelper"; +import { Duration } from "@coderline/alphatab/model/Duration"; +/** + * @internal + */ +export class BeamingRulesSerializer { + public static fromJson(obj: BeamingRules, m: unknown): void { + if (!m) { + return; + } + JsonHelper.forEach(m, (v, k) => BeamingRulesSerializer.setProperty(obj, k, v)); + } + public static toJson(obj: BeamingRules | null): Map | null { + if (!obj) { + return null; + } + const o = new Map(); + { + const m = new Map(); + o.set("groups", m); + for (const [k, v] of obj.groups!) { + m.set(k.toString(), v); + } + } + return o; + } + public static setProperty(obj: BeamingRules, property: string, v: unknown): boolean { + switch (property) { + case "groups": + obj.groups = new Map(); + JsonHelper.forEach(v, (v, k) => { + obj.groups.set(JsonHelper.parseEnum(k, Duration)!, v as number[]); + }); + return true; + } + return false; + } +} diff --git a/packages/alphatab/src/generated/model/MasterBarSerializer.ts b/packages/alphatab/src/generated/model/MasterBarSerializer.ts index e81d3c234..4c9d2b9d5 100644 --- a/packages/alphatab/src/generated/model/MasterBarSerializer.ts +++ b/packages/alphatab/src/generated/model/MasterBarSerializer.ts @@ -5,9 +5,11 @@ // import { MasterBar } from "@coderline/alphatab/model/MasterBar"; import { JsonHelper } from "@coderline/alphatab/io/JsonHelper"; +import { BeamingRulesSerializer } from "@coderline/alphatab/generated/model/BeamingRulesSerializer"; import { SectionSerializer } from "@coderline/alphatab/generated/model/SectionSerializer"; import { AutomationSerializer } from "@coderline/alphatab/generated/model/AutomationSerializer"; import { FermataSerializer } from "@coderline/alphatab/generated/model/FermataSerializer"; +import { BeamingRules } from "@coderline/alphatab/model/MasterBar"; import { TripletFeel } from "@coderline/alphatab/model/TripletFeel"; import { Section } from "@coderline/alphatab/model/Section"; import { Automation } from "@coderline/alphatab/model/Automation"; @@ -35,6 +37,9 @@ export class MasterBarSerializer { o.set("timesignaturenumerator", obj.timeSignatureNumerator); o.set("timesignaturedenominator", obj.timeSignatureDenominator); o.set("timesignaturecommon", obj.timeSignatureCommon); + if (obj.beamingRules) { + o.set("beamingrules", BeamingRulesSerializer.toJson(obj.beamingRules)); + } o.set("isfreetime", obj.isFreeTime); o.set("tripletfeel", obj.tripletFeel as number); if (obj.section) { @@ -87,6 +92,15 @@ export class MasterBarSerializer { case "timesignaturecommon": obj.timeSignatureCommon = v! as boolean; return true; + case "beamingrules": + if (v) { + obj.beamingRules = new BeamingRules(); + BeamingRulesSerializer.fromJson(obj.beamingRules, v); + } + else { + obj.beamingRules = undefined; + } + return true; case "isfreetime": obj.isFreeTime = v! as boolean; return true; diff --git a/packages/alphatab/src/importer/GpifParser.ts b/packages/alphatab/src/importer/GpifParser.ts index 29b310dc0..703399ce4 100644 --- a/packages/alphatab/src/importer/GpifParser.ts +++ b/packages/alphatab/src/importer/GpifParser.ts @@ -18,7 +18,7 @@ import { HarmonicType } from '@coderline/alphatab/model/HarmonicType'; import { KeySignature } from '@coderline/alphatab/model/KeySignature'; import { KeySignatureType } from '@coderline/alphatab/model/KeySignatureType'; import { Lyrics } from '@coderline/alphatab/model/Lyrics'; -import { MasterBar } from '@coderline/alphatab/model/MasterBar'; +import { BeamingRules, MasterBar } from '@coderline/alphatab/model/MasterBar'; import { Note } from '@coderline/alphatab/model/Note'; import { Ottavia } from '@coderline/alphatab/model/Ottavia'; import { PickStroke } from '@coderline/alphatab/model/PickStroke'; @@ -1983,6 +1983,9 @@ export class GpifParser { } private _parseMasterBarXProperties(masterBar: MasterBar, node: XmlNode) { + let beamingRuleDuration: number = Number.NaN; + let beamingRuleGroups: number[] | undefined = undefined; + for (const c of node.childElements()) { switch (c.localName) { case 'XProperty': @@ -1994,10 +1997,40 @@ export class GpifParser { 1 ); break; + case '1124139010': + beamingRuleDuration = GpifParser._parseIntSafe( + c.findChildElement('Int')?.innerText, + Number.NaN + ); + break; + default: + const idNumeric = GpifParser._parseIntSafe(id, 0); + if (idNumeric >= 1124139264 && idNumeric <= 1124139295) { + const groupIndex = idNumeric - 1124139264; + const groupSize = GpifParser._parseIntSafe( + c.findChildElement('Int')?.innerText, + Number.NaN + ); + + if (beamingRuleGroups === undefined) { + beamingRuleGroups = []; + } + while (beamingRuleGroups.length < groupIndex + 1) { + beamingRuleGroups.push(0); + } + beamingRuleGroups[groupIndex] = groupSize; + } + break; } break; } } + + if (!Number.isNaN(beamingRuleDuration) && beamingRuleGroups) { + const rules = new BeamingRules(); + rules.groups.set(beamingRuleDuration as Duration, beamingRuleGroups); + masterBar.beamingRules = rules; + } } private _parseBeatProperties(node: XmlNode, beat: Beat): void { diff --git a/packages/alphatab/src/importer/alphaTex/AlphaTex1LanguageDefinitions.ts b/packages/alphatab/src/importer/alphaTex/AlphaTex1LanguageDefinitions.ts index 5a97c2208..8e84fe7ed 100644 --- a/packages/alphatab/src/importer/alphaTex/AlphaTex1LanguageDefinitions.ts +++ b/packages/alphatab/src/importer/alphaTex/AlphaTex1LanguageDefinitions.ts @@ -480,7 +480,16 @@ export class AlphaTex1LanguageDefinitions { ['spu', [[[[16], 2]]]], ['db', null], ['voicemode', [[[[10, 17], 0, ['staffwise', 'barwise']]]]], - ['barnumberdisplay', [[[[10, 17], 0, ['allbars', 'firstofsystem', 'hide']]]]] + ['barnumberdisplay', [[[[10, 17], 0, ['allbars', 'firstofsystem', 'hide']]]]], + [ + 'beaming', + [ + [ + [[16], 0], + [[16], 5] + ] + ] + ] ]); public static readonly metaDataProperties = AlphaTex1LanguageDefinitions._metaProps([ [ @@ -596,7 +605,8 @@ export class AlphaTex1LanguageDefinitions { ['spu', null], ['db', null], ['voicemode', null], - ['barnumberdisplay', null] + ['barnumberdisplay', null], + ['beaming', null] ]); public static readonly metaDataSignatures = [ AlphaTex1LanguageDefinitions.scoreMetaDataSignatures, diff --git a/packages/alphatab/src/importer/alphaTex/AlphaTex1LanguageHandler.ts b/packages/alphatab/src/importer/alphaTex/AlphaTex1LanguageHandler.ts index 4ca9dd5f8..555793e11 100644 --- a/packages/alphatab/src/importer/alphaTex/AlphaTex1LanguageHandler.ts +++ b/packages/alphatab/src/importer/alphaTex/AlphaTex1LanguageHandler.ts @@ -62,7 +62,7 @@ import { GraceType } from '@coderline/alphatab/model/GraceType'; import { HarmonicType } from '@coderline/alphatab/model/HarmonicType'; import { KeySignatureType } from '@coderline/alphatab/model/KeySignatureType'; import { Lyrics } from '@coderline/alphatab/model/Lyrics'; -import type { MasterBar } from '@coderline/alphatab/model/MasterBar'; +import { BeamingRules, type MasterBar } from '@coderline/alphatab/model/MasterBar'; import { ModelUtils } from '@coderline/alphatab/model/ModelUtils'; import type { Note } from '@coderline/alphatab/model/Note'; import { NoteAccidentalMode } from '@coderline/alphatab/model/NoteAccidentalMode'; @@ -92,6 +92,8 @@ import { SynthConstants } from '@coderline/alphatab/synth/SynthConstants'; export class AlphaTex1LanguageHandler implements IAlphaTexLanguageImportHandler { public static readonly instance = new AlphaTex1LanguageHandler(); + private static readonly _timeSignatureDenominators = new Set([1, 2, 4, 8, 16, 32, 64, 128]); + public applyScoreMetaData( importer: IAlphaTexImporter, score: Score, @@ -597,12 +599,39 @@ export class AlphaTex1LanguageHandler implements IAlphaTexLanguageImportHandler case 'ts': switch (metaData.arguments!.arguments[0].nodeType) { case AlphaTexNodeType.Number: - bar.masterBar.timeSignatureNumerator = ( - metaData.arguments!.arguments[0] as AlphaTexNumberLiteral - ).value; - bar.masterBar.timeSignatureDenominator = ( - metaData.arguments!.arguments[1] as AlphaTexNumberLiteral - ).value; + bar.masterBar.timeSignatureNumerator = + (metaData.arguments!.arguments[0] as AlphaTexNumberLiteral).value | 0; + + if (bar.masterBar.timeSignatureNumerator < 1 || bar.masterBar.timeSignatureNumerator > 32) { + importer.addSemanticDiagnostic({ + code: AlphaTexDiagnosticCode.AT211, + message: `Value is out of valid range. Allowed range: 1-32, Actual Value: ${bar.masterBar.timeSignatureNumerator}`, + start: metaData.arguments!.arguments[0].start, + end: metaData.arguments!.arguments[0].end, + severity: AlphaTexDiagnosticsSeverity.Error + }); + } + + bar.masterBar.timeSignatureDenominator = + (metaData.arguments!.arguments[1] as AlphaTexNumberLiteral).value | 0; + + if ( + !AlphaTex1LanguageHandler._timeSignatureDenominators.has( + bar.masterBar.timeSignatureDenominator + ) + ) { + const valueList = Array.from(AlphaTex1LanguageHandler._timeSignatureDenominators).join( + ', ' + ); + importer.addSemanticDiagnostic({ + code: AlphaTexDiagnosticCode.AT211, + message: `Value is out of valid range. Allowed range: ${valueList}, Actual Value: ${bar.masterBar.timeSignatureDenominator}`, + start: metaData.arguments!.arguments[0].start, + end: metaData.arguments!.arguments[0].end, + severity: AlphaTexDiagnosticsSeverity.Error + }); + } + break; case AlphaTexNodeType.Ident: case AlphaTexNodeType.String: @@ -624,6 +653,8 @@ export class AlphaTex1LanguageHandler implements IAlphaTexLanguageImportHandler break; } return ApplyNodeResult.Applied; + case 'beaming': + return this._parseBeamingRule(importer, metaData, bar.masterBar); case 'ks': const keySignature = AlphaTex1LanguageHandler._parseEnumValue( importer, @@ -884,6 +915,57 @@ export class AlphaTex1LanguageHandler implements IAlphaTexLanguageImportHandler } } + private _parseBeamingRule(importer: IAlphaTexImporter, metaData: AlphaTexMetaDataNode, masterBar: MasterBar) { + let duration = Duration.Eighth; + const groupSizes: number[] = []; + + const durationValue = (metaData.arguments!.arguments[0] as AlphaTexNumberLiteral).value; + switch (durationValue) { + case 4: + duration = Duration.QuadrupleWhole; + break; + case 8: + duration = Duration.Eighth; + break; + case 16: + duration = Duration.Sixteenth; + break; + case 32: + duration = Duration.ThirtySecond; + break; + default: + importer.addSemanticDiagnostic({ + code: AlphaTexDiagnosticCode.AT209, + message: `Value is out of valid range. Allowed range: 4,8,16 or 32, Actual Value: ${durationValue}`, + severity: AlphaTexDiagnosticsSeverity.Error, + start: metaData.arguments!.arguments[0].start, + end: metaData.arguments!.arguments[0].end + }); + return ApplyNodeResult.NotAppliedSemanticError; + } + + for (let i = 1; i < metaData.arguments!.arguments.length; i++) { + const groupSize = (metaData.arguments!.arguments[i] as AlphaTexNumberLiteral).value; + if (groupSize < 1) { + importer.addSemanticDiagnostic({ + code: AlphaTexDiagnosticCode.AT209, + message: `Value is out of valid range. Allowed range: >0, Actual Value: ${durationValue}`, + severity: AlphaTexDiagnosticsSeverity.Error, + start: metaData.arguments!.arguments[i].start, + end: metaData.arguments!.arguments[i].end + }); + return ApplyNodeResult.NotAppliedSemanticError; + } + groupSizes.push((metaData.arguments!.arguments[i] as AlphaTexNumberLiteral).value); + } + + if (!masterBar.beamingRules) { + masterBar.beamingRules = new BeamingRules(); + } + masterBar.beamingRules!.groups.set(duration, groupSizes); + return ApplyNodeResult.Applied; + } + private static _handleAccidentalMode(importer: IAlphaTexImporter, args: AlphaTexArgumentList): ApplyNodeResult { const accidentalMode = AlphaTex1LanguageHandler._parseEnumValue( importer, @@ -2888,6 +2970,17 @@ export class AlphaTex1LanguageHandler implements IAlphaTexLanguageImportHandler } } + if (masterBar.beamingRules) { + for (const [k, v] of masterBar.beamingRules.groups) { + const args = Atnf.args([Atnf.number(k)], true); + for (const i of v) { + args!.arguments.push(Atnf.number(i)); + } + + nodes.push(Atnf.meta('beaming', args)); + } + } + if ( (masterBar.index > 0 && masterBar.tripletFeel !== masterBar.previousMasterBar?.tripletFeel) || (masterBar.index === 0 && masterBar.tripletFeel !== TripletFeel.NoTripletFeel) diff --git a/packages/alphatab/src/model/Bar.ts b/packages/alphatab/src/model/Bar.ts index dbb987c47..32c85e3cd 100644 --- a/packages/alphatab/src/model/Bar.ts +++ b/packages/alphatab/src/model/Bar.ts @@ -9,6 +9,7 @@ import { ElementStyle } from '@coderline/alphatab/model/ElementStyle'; import { KeySignature } from '@coderline/alphatab/model/KeySignature'; import { KeySignatureType } from '@coderline/alphatab/model/KeySignatureType'; import type { BarNumberDisplay } from '@coderline/alphatab/model/RenderStylesheet'; +import { Duration } from '@coderline/alphatab/model/Duration'; /** * The different pedal marker types. @@ -403,6 +404,13 @@ export class Bar { */ public barNumberDisplay?: BarNumberDisplay; + /** + * The shortest duration contained across beats in this bar. + * @internal + * @json_ignore + */ + public shortestDuration: Duration = Duration.DoubleWhole; + /** * The bar line to draw on the left side of the bar with an "automatic" type resolved to the actual one. * @param isFirstOfSystem Whether the bar is the first one in the system. @@ -476,12 +484,16 @@ export class Bar { this._filledVoices.add(0); this._isEmpty = true; this._isRestOnly = true; + this.shortestDuration = Duration.DoubleWhole; for (let i: number = 0, j: number = this.voices.length; i < j; i++) { const voice: Voice = this.voices[i]; voice.finish(settings, sharedDataBag); if (!voice.isEmpty) { this._isEmpty = false; this._filledVoices.add(i); + if (voice.shortestDuration > this.shortestDuration) { + this.shortestDuration = voice.shortestDuration; + } } if (!voice.isRestOnly) { diff --git a/packages/alphatab/src/model/MasterBar.ts b/packages/alphatab/src/model/MasterBar.ts index a3470b107..ed1982268 100644 --- a/packages/alphatab/src/model/MasterBar.ts +++ b/packages/alphatab/src/model/MasterBar.ts @@ -1,15 +1,146 @@ import { MidiUtils } from '@coderline/alphatab/midi/MidiUtils'; import type { Automation } from '@coderline/alphatab/model/Automation'; +import type { Bar } from '@coderline/alphatab/model/Bar'; import type { Beat } from '@coderline/alphatab/model/Beat'; +import type { Direction } from '@coderline/alphatab/model/Direction'; +import { Duration } from '@coderline/alphatab/model/Duration'; import type { Fermata } from '@coderline/alphatab/model/Fermata'; -import type { Bar } from '@coderline/alphatab/model/Bar'; import type { KeySignature } from '@coderline/alphatab/model/KeySignature'; import type { KeySignatureType } from '@coderline/alphatab/model/KeySignatureType'; import type { RepeatGroup } from '@coderline/alphatab/model/RepeatGroup'; import type { Score } from '@coderline/alphatab/model/Score'; import type { Section } from '@coderline/alphatab/model/Section'; import { TripletFeel } from '@coderline/alphatab/model/TripletFeel'; -import type { Direction } from '@coderline/alphatab/model/Direction'; + +/** + * Defines the custom beaming rules which define how beats are beamed together or split apart + * during the automatic beaming when displayed. + * @json + * @json_strict + * @public + * + * @remarks + * The beaming logic works like this: + * + * The time axis of the bar is sliced into even chunks. The chunk-size is defined by the respective group definition. + * Within these chunks groups can then be placed spanning 1 or more chunks. + * + * If beats start within the same "group" they are beamed together. + */ +export class BeamingRules { + private _singleGroupKey?: Duration; + + /** + * The the group for a given "longest duration" within the bar. + * @remarks + * The map key is the duration to which the bar will be sliced into. + * The map value defines the "groups" placed within the sliced. + */ + public groups = new Map(); + + /** + * @internal + * @json_ignore + */ + public uniqueId: string = ''; + + /** + * @internal + * @json_ignore + */ + public timeSignatureNumerator: number = 0; + /** + * @internal + * @json_ignore + */ + public timeSignatureDenominator: number = 0; + + /** + * @internal + */ + public static createSimple( + timeSignatureNumerator: number, + timeSignatureDenominator: number, + duration: Duration, + groups: number[] + ) { + const r = new BeamingRules(); + r.timeSignatureNumerator = timeSignatureNumerator; + r.timeSignatureDenominator = timeSignatureDenominator; + r.groups.set(duration, groups); + r.finish(); + return r; + } + + /** + * @internal + */ + public findRule(shortestDuration: Duration): [Duration, number[]] { + // fast path: one rule -> take it + const singleGroupKey = this._singleGroupKey; + if (singleGroupKey) { + return [singleGroupKey, this.groups.get(singleGroupKey)!]; + } + + if (shortestDuration < Duration.Quarter) { + return [shortestDuration, []]; + } + + // first search shorter + let durationValue = shortestDuration as number; + do { + const duration = durationValue as Duration; + if (this.groups.has(duration)) { + return [duration, this.groups.get(duration)!]; + } + durationValue = durationValue * 2; + } while (durationValue <= (Duration.TwoHundredFiftySixth as number)); + + // then longer + durationValue = (shortestDuration as number) / 2; + do { + const duration = durationValue as Duration; + if (this.groups.has(duration)) { + return [duration, this.groups.get(duration)!]; + } + durationValue = durationValue / 2; + } while (durationValue > (Duration.Half as number)); + + return [shortestDuration, []]; + } + + /** + * @internal + */ + public finish() { + let uniqueId = `${this.timeSignatureNumerator}_${this.timeSignatureDenominator}`; + + for (const [k, v] of this.groups) { + uniqueId += `__${k}`; + + // trim of 0s at the end of the group + let lastZero = v.length; + for (let i = v.length - 1; i >= 0; i--) { + if (v[i] === 0) { + lastZero = i; + } else { + break; + } + } + + if (lastZero < v.length) { + v.splice(lastZero, v.length - lastZero); + } + + uniqueId += `_${v.join('_')}`; + + if (this.groups.size === 1) { + this._singleGroupKey = k; + } + } + this.uniqueId = uniqueId; + } +} /** * The MasterBar stores information about a bar which affects @@ -150,6 +281,18 @@ export class MasterBar { */ public timeSignatureCommon: boolean = false; + /** + * Defines the custom beaming rules which should be applied to this bar and all bars following. + */ + public beamingRules?: BeamingRules; + + /** + * The actual (custom) beaming rules to use for this bar if any were specified. + * @json_ignore + * @internal + */ + public actualBeamingRules?: BeamingRules; + /** * Gets or sets whether the bar indicates a free time playing. */ @@ -180,7 +323,7 @@ export class MasterBar { /** * Gets or sets all tempo automation for this bar. */ - public tempoAutomations: Automation[] = []; + public tempoAutomations: Automation[] = []; /** * The sync points for this master bar to synchronize the alphaTab time axis with the @@ -301,4 +444,34 @@ export class MasterBar { } this.syncPoints!.push(syncPoint); } + + public finish(sharedDataBag: Map) { + let beamingRules = this.beamingRules; + if (beamingRules) { + beamingRules.timeSignatureNumerator = this.timeSignatureNumerator; + beamingRules.timeSignatureDenominator = this.timeSignatureDenominator; + beamingRules.finish(); + } + + if (this.index > 0) { + this.start = this.previousMasterBar!.start + this.previousMasterBar!.calculateDuration(); + + // clear out equal rules to reduce memory consumption. + const previousRules = sharedDataBag.has('beamingRules') + ? (sharedDataBag.get('beamingRules')! as BeamingRules) + : undefined; + + if (previousRules && previousRules.uniqueId === beamingRules?.uniqueId) { + this.beamingRules = undefined; + beamingRules = previousRules; + } else if (!beamingRules) { + beamingRules = previousRules; + } + } + this.actualBeamingRules = beamingRules; + + if (this.beamingRules) { + sharedDataBag.set('beamingRules', beamingRules); + } + } } diff --git a/packages/alphatab/src/model/Score.ts b/packages/alphatab/src/model/Score.ts index 941716f43..8b4259aee 100644 --- a/packages/alphatab/src/model/Score.ts +++ b/packages/alphatab/src/model/Score.ts @@ -361,7 +361,7 @@ export class Score { bar.previousMasterBar.nextMasterBar = bar; // NOTE: this will not work on anacrusis. Correct anacrusis durations are only working // when there are beats with playback positions already computed which requires full finish - // chicken-egg problem here. temporarily forcing anacrusis length here to 0, + // chicken-egg problem here. temporarily forcing anacrusis length here to 0, // .finish() will correct these times bar.start = bar.previousMasterBar.start + @@ -436,9 +436,7 @@ export class Score { // fixup masterbar starts to handle anacrusis lengths for (const mb of this.masterBars) { - if (mb.index > 0) { - mb.start = mb.previousMasterBar!.start + mb.previousMasterBar!.calculateDuration(); - } + mb.finish(sharedDataBag); } } diff --git a/packages/alphatab/src/model/Voice.ts b/packages/alphatab/src/model/Voice.ts index 33adc3c7a..a6631d496 100644 --- a/packages/alphatab/src/model/Voice.ts +++ b/packages/alphatab/src/model/Voice.ts @@ -1,9 +1,10 @@ import type { Bar } from '@coderline/alphatab/model/Bar'; import type { Beat } from '@coderline/alphatab/model/Beat'; +import { Duration } from '@coderline/alphatab/model/Duration'; +import { ElementStyle } from '@coderline/alphatab/model/ElementStyle'; +import { GraceGroup } from '@coderline/alphatab/model/GraceGroup'; import { GraceType } from '@coderline/alphatab/model/GraceType'; import type { Settings } from '@coderline/alphatab/Settings'; -import { GraceGroup } from '@coderline/alphatab/model/GraceGroup'; -import { ElementStyle } from '@coderline/alphatab/model/ElementStyle'; /** * Lists all graphical sub elements within a {@link Voice} which can be styled via {@link Voice.style} @@ -94,6 +95,13 @@ export class Voice { return this._isRestOnly; } + /** + * The shortest duration contained across beats in this bar. + * @internal + * @json_ignore + */ + public shortestDuration = Duration.DoubleWhole; + public insertBeat(after: Beat, newBeat: Beat): void { newBeat.nextBeat = after.nextBeat; if (newBeat.nextBeat) { @@ -191,6 +199,7 @@ export class Voice { let currentDisplayTick: number = 0; let currentPlaybackTick: number = 0; + this.shortestDuration = Duration.DoubleWhole; for (let i: number = 0; i < this.beats.length; i++) { const beat: Beat = this.beats[i]; beat.index = i; @@ -200,6 +209,10 @@ export class Voice { // we need to first steal the duration from the right beat // and place the grace beats correctly if (beat.graceType === GraceType.None) { + if (beat.duration > this.shortestDuration) { + this.shortestDuration = beat.duration; + } + if (beat.graceGroup) { const firstGraceBeat = beat.graceGroup!.beats[0]; const lastGraceBeat = beat.graceGroup!.beats[beat.graceGroup!.beats.length - 1]; diff --git a/packages/alphatab/src/rendering/layout/ScoreLayout.ts b/packages/alphatab/src/rendering/layout/ScoreLayout.ts index ca708fac8..9c29e37d6 100644 --- a/packages/alphatab/src/rendering/layout/ScoreLayout.ts +++ b/packages/alphatab/src/rendering/layout/ScoreLayout.ts @@ -20,6 +20,7 @@ import { RenderFinishedEventArgs } from '@coderline/alphatab/rendering/RenderFin import type { ScoreRenderer } from '@coderline/alphatab/rendering/ScoreRenderer'; import { RenderStaff } from '@coderline/alphatab/rendering/staves/RenderStaff'; import { StaffSystem } from '@coderline/alphatab/rendering/staves/StaffSystem'; +import type { BeamingRuleLookup } from '@coderline/alphatab/rendering/utils/BeamingRuleLookup'; import { ElementStyleHelper } from '@coderline/alphatab/rendering/utils/ElementStyleHelper'; import type { RenderingResources } from '@coderline/alphatab/RenderingResources'; import type { Settings } from '@coderline/alphatab/Settings'; @@ -73,6 +74,7 @@ export abstract class ScoreLayout { public abstract get supportsResize(): boolean; public slurRegistry = new SlurRegistry(); + public beamingRuleLookups = new Map(); public resize(): void { this._lazyPartials.clear(); @@ -84,6 +86,7 @@ export abstract class ScoreLayout { public layoutAndRender(): void { this._lazyPartials.clear(); this.slurRegistry.clear(); + this.beamingRuleLookups.clear(); this._barRendererLookup.clear(); this.profile = Environment.staveProfiles.get(this.renderer.settings.display.staveProfile)!; diff --git a/packages/alphatab/src/rendering/utils/BarHelpers.ts b/packages/alphatab/src/rendering/utils/BarHelpers.ts index 960000b48..02fa5f9e5 100644 --- a/packages/alphatab/src/rendering/utils/BarHelpers.ts +++ b/packages/alphatab/src/rendering/utils/BarHelpers.ts @@ -5,6 +5,10 @@ import { BeamingHelper } from '@coderline/alphatab/rendering/utils/BeamingHelper import type { BarRendererBase } from '@coderline/alphatab/rendering/BarRendererBase'; import { BarCollisionHelper } from '@coderline/alphatab/rendering/utils/BarCollisionHelper'; import type { BeamDirection } from '@coderline/alphatab/rendering/utils/BeamDirection'; +import { Duration } from '@coderline/alphatab/model/Duration'; +import { BeamingRules, type MasterBar } from '@coderline/alphatab/model/MasterBar'; +import { BeamingRuleLookup } from '@coderline/alphatab/rendering/utils/BeamingRuleLookup'; +import { MidiUtils } from '@coderline/alphatab/midi/MidiUtils'; /** * @internal @@ -25,6 +29,21 @@ export class BarHelpers { const barRenderer = this._renderer; const bar = this._renderer.bar; + const masterBar = bar.masterBar; + const beamingRules = masterBar.actualBeamingRules ?? BarHelpers._findOrBuildDefaultBeamingRules(masterBar); + const rule = beamingRules.findRule(bar.shortestDuration); + // NOTE: moste rules have only one group definition, so its better to reuse the unique id + // than compute a potentially shorter id here. + const key = `beaming_${beamingRules.uniqueId}_${rule[0]}`; + + let beamingRuleLookup = this._renderer.scoreRenderer.layout!.beamingRuleLookups.has(key) + ? this._renderer.scoreRenderer.layout!.beamingRuleLookups.get(key)! + : undefined; + if (!beamingRuleLookup) { + beamingRuleLookup = BeamingRuleLookup.build(masterBar, rule[0], rule[1]); + this._renderer.scoreRenderer.layout!.beamingRuleLookups.set(key, beamingRuleLookup); + } + let currentBeamHelper: BeamingHelper | null = null; let currentGraceBeamHelper: BeamingHelper | null = null; for (let i: number = 0, j: number = bar.voices.length; i < j; i++) { @@ -49,7 +68,7 @@ export class BarHelpers { helperForBeat.finish(); } // if not possible, create the next beaming helper - helperForBeat = new BeamingHelper(bar.staff, barRenderer); + helperForBeat = new BeamingHelper(bar.staff, barRenderer, beamingRuleLookup); helperForBeat.preferredBeamDirection = this.preferredBeamDirection; helperForBeat.checkBeat(b); if (b.graceType !== GraceType.None) { @@ -72,6 +91,95 @@ export class BarHelpers { } } + private static _defaultBeamingRules: Map | undefined; + private static _findOrBuildDefaultBeamingRules(masterBar: MasterBar): BeamingRules { + let defaultBeamingRules = BarHelpers._defaultBeamingRules; + if (!defaultBeamingRules) { + defaultBeamingRules = new Map( + [ + BeamingRules.createSimple(2, 16, Duration.Sixteenth, [1, 1]), + BeamingRules.createSimple(1, 8, Duration.Eighth, [1]), + BeamingRules.createSimple(1, 4, Duration.Quarter, [1]), + + BeamingRules.createSimple(3, 16, Duration.Sixteenth, [3]), + + BeamingRules.createSimple(4, 16, Duration.Sixteenth, [2, 2]), + BeamingRules.createSimple(2, 8, Duration.Eighth, [1, 1]), + + BeamingRules.createSimple(5, 16, Duration.Sixteenth, [3, 2]), + + BeamingRules.createSimple(6, 16, Duration.Sixteenth, [3, 3]), + BeamingRules.createSimple(3, 8, Duration.Eighth, [3]), + + BeamingRules.createSimple(4, 8, Duration.Eighth, [2, 2]), + BeamingRules.createSimple(2, 4, Duration.Quarter, [1, 1]), + + BeamingRules.createSimple(9, 16, Duration.Sixteenth, [3, 3, 3]), + + BeamingRules.createSimple(5, 8, Duration.Eighth, [3, 2]), + + BeamingRules.createSimple(12, 16, Duration.Sixteenth, [3, 3, 3, 3]), + BeamingRules.createSimple(6, 8, Duration.Eighth, [3, 3, 3]), + BeamingRules.createSimple(3, 4, Duration.Quarter, [1, 1, 1]), + + BeamingRules.createSimple(7, 8, Duration.Eighth, [4, 3]), + + BeamingRules.createSimple(8, 8, Duration.Eighth, [3, 3, 2]), + BeamingRules.createSimple(4, 4, Duration.Quarter, [1, 1, 1, 1]), + + BeamingRules.createSimple(9, 8, Duration.Eighth, [3, 3, 3]), + + BeamingRules.createSimple(10, 8, Duration.Eighth, [4, 3, 3]), + BeamingRules.createSimple(5, 4, Duration.Quarter, [1, 1, 1, 1, 1]), + + BeamingRules.createSimple(12, 8, Duration.Eighth, [3, 3, 3, 3]), + BeamingRules.createSimple(6, 4, Duration.Quarter, [1, 1, 1, 1, 1, 1]), + + BeamingRules.createSimple(15, 8, Duration.Eighth, [3, 3, 3, 3, 3, 3]), + + BeamingRules.createSimple(8, 4, Duration.Quarter, [1, 1, 1, 1, 1, 1, 1, 1]), + BeamingRules.createSimple(18, 8, Duration.Eighth, [3, 3, 3, 3, 3, 3]) + ].map(r => [`${r.timeSignatureNumerator}_${r.timeSignatureDenominator}`, r] as [string, BeamingRules]) + ); + + BarHelpers._defaultBeamingRules = defaultBeamingRules; + } + + const key = `${masterBar.timeSignatureNumerator}_${masterBar.timeSignatureDenominator}`; + if (defaultBeamingRules.has(key)) { + return defaultBeamingRules.get(key)!; + } + + // NOTE: this is the old alphaTab logic how we used to beamed bars. + // we either group in quarters, or in 3x8ths depending on the key signature + + let divisionLength: number = MidiUtils.QuarterTime; + switch (masterBar.timeSignatureDenominator) { + case 8: + if (masterBar.timeSignatureNumerator % 3 === 0) { + divisionLength += (MidiUtils.QuarterTime / 2) | 0; + } + break; + } + + const numberOfDivisions = Math.ceil(masterBar.calculateDuration(false) / divisionLength); + const notesPerDivision = (divisionLength / MidiUtils.QuarterTime) * 2; + + const fallback = new BeamingRules(); + const groups: number[] = []; + + for (let i = 0; i < numberOfDivisions; i++) { + groups.push(notesPerDivision); + } + + fallback.groups.set(Duration.Eighth, groups); + fallback.timeSignatureNumerator = masterBar.timeSignatureNumerator; + fallback.timeSignatureDenominator = masterBar.timeSignatureDenominator; + fallback.finish(); + defaultBeamingRules.set(key, fallback); + return fallback; + } + public getBeamingHelperForBeat(beat: Beat): BeamingHelper | undefined { return this._beamHelperLookup.has(beat.id) ? this._beamHelperLookup.get(beat.id)! : undefined; } diff --git a/packages/alphatab/src/rendering/utils/BeamingHelper.ts b/packages/alphatab/src/rendering/utils/BeamingHelper.ts index 7c4991c98..17933a77a 100644 --- a/packages/alphatab/src/rendering/utils/BeamingHelper.ts +++ b/packages/alphatab/src/rendering/utils/BeamingHelper.ts @@ -1,4 +1,3 @@ -import { MidiUtils } from '@coderline/alphatab/midi/MidiUtils'; import type { Bar } from '@coderline/alphatab/model/Bar'; import { type Beat, BeatBeamingMode } from '@coderline/alphatab/model/Beat'; import { Duration } from '@coderline/alphatab/model/Duration'; @@ -12,6 +11,7 @@ import type { BarRendererBase } from '@coderline/alphatab/rendering/BarRendererB import { BeatXPosition } from '@coderline/alphatab/rendering/BeatXPosition'; import { AccidentalHelper } from '@coderline/alphatab/rendering/utils/AccidentalHelper'; import type { BeamDirection } from '@coderline/alphatab/rendering/utils/BeamDirection'; +import type { BeamingRuleLookup } from '@coderline/alphatab/rendering/utils/BeamingRuleLookup'; /** * @internal @@ -48,7 +48,7 @@ export class BeamingHelperDrawInfo { export class BeamingHelper { private _staff: Staff; private _renderer: BarRendererBase; - + private _beamingRuleLookup: BeamingRuleLookup; public voice: Voice | null = null; public beats: Beat[] = []; public shortestDuration: Duration = Duration.QuadrupleWhole; @@ -99,10 +99,11 @@ export class BeamingHelper { ); } - public constructor(staff: Staff, renderer: BarRendererBase) { + public constructor(staff: Staff, renderer: BarRendererBase, beamingRuleLookup: BeamingRuleLookup) { this._staff = staff; this._renderer = renderer; this.beats = []; + this._beamingRuleLookup = beamingRuleLookup; } public alignWithBeats() { @@ -161,7 +162,7 @@ export class BeamingHelper { switch (this.beats[this.beats.length - 1].beamingMode) { case BeatBeamingMode.Auto: case BeatBeamingMode.ForceSplitOnSecondaryToNext: - add = BeamingHelper._canJoin(this.beats[this.beats.length - 1], beat); + add = this._canJoin(this.beats[this.beats.length - 1], beat); break; case BeatBeamingMode.ForceSplitToNext: add = false; @@ -240,8 +241,7 @@ export class BeamingHelper { } } - // TODO: Check if this beaming is really correct, I'm not sure if we are connecting beats correctly - private static _canJoin(b1: Beat, b2: Beat): boolean { + private _canJoin(b1: Beat, b2: Beat): boolean { // is this a voice we can join with? if ( !b1 || @@ -263,6 +263,7 @@ export class BeamingHelper { if (m1 !== m2) { return false; } + // get times of those voices and check if the times // are in the same division const start1: number = b1.playbackStart; @@ -281,19 +282,11 @@ export class BeamingHelper { return true; } } - // TODO: create more rules for automatic beaming - let divisionLength: number = MidiUtils.QuarterTime; - switch (m1.masterBar.timeSignatureDenominator) { - case 8: - if (m1.masterBar.timeSignatureNumerator % 3 === 0) { - divisionLength += (MidiUtils.QuarterTime / 2) | 0; - } - break; - } - // check if they are on the same division - const division1: number = ((divisionLength + start1) / divisionLength) | 0 | 0; - const division2: number = ((divisionLength + start2) / divisionLength) | 0 | 0; - return division1 === division2; + + // check if they are on the same group as per rule definitions + const groupId1 = this._beamingRuleLookup.calculateGroupIndex(start1); + const groupId2 = this._beamingRuleLookup.calculateGroupIndex(start2); + return groupId1 === groupId2; } private static _canJoinDuration(d: Duration): boolean { diff --git a/packages/alphatab/src/rendering/utils/BeamingRuleLookup.ts b/packages/alphatab/src/rendering/utils/BeamingRuleLookup.ts new file mode 100644 index 000000000..59e6d1ffe --- /dev/null +++ b/packages/alphatab/src/rendering/utils/BeamingRuleLookup.ts @@ -0,0 +1,68 @@ +import { MidiUtils } from '@coderline/alphatab/midi/MidiUtils'; +import type { Duration } from '@coderline/alphatab/model/Duration'; +import type { MasterBar } from '@coderline/alphatab/model/MasterBar'; + +/** + * @internal + */ +export class BeamingRuleLookup { + private _division: number = 0; + private _slots: number[] = []; + private _barDuration: number; + + public constructor(barDuration: number, division: number, slots: number[]) { + this._division = division; + this._slots = slots; + this._barDuration = barDuration; + } + + public calculateGroupIndex(beatStartTime: number) { + // no slots -> all have their own group based (use the start time as index) + if (this._slots.length === 0) { + return beatStartTime; + } + + // rollover within the bar. + beatStartTime = beatStartTime % this._barDuration; + + const slotIndex = Math.floor(beatStartTime / this._division); + return this._slots[slotIndex]; + } + + public static build(masterBar: MasterBar, ruleDuration: Duration, ruleGroups: number[]): BeamingRuleLookup { + const totalDuration = masterBar.calculateDuration(false); + const division = MidiUtils.toTicks(ruleDuration); + const slotCount = totalDuration / division; + + // should only happen in case of improper data. + if (slotCount < 0 || ruleGroups.length === 0) { + return new BeamingRuleLookup(0, 0, []); + } + + let groupIndex = 0; + let remainingSlots = ruleGroups[groupIndex]; + + const slots: number[] = []; + + for (let i = 0; i < slotCount; i++) { + if (groupIndex < ruleGroups.length) { + slots.push(groupIndex); + remainingSlots--; + + if (remainingSlots <= 0) { + groupIndex++; + if (groupIndex < ruleGroups.length) { + remainingSlots = ruleGroups[groupIndex]; + } + } + } else { + // no groups defined for the remaining slots: all slots are treated + // as unjoined + slots.push(groupIndex); + groupIndex++; + } + } + + return new BeamingRuleLookup(totalDuration, division, slots); + } +} diff --git a/packages/alphatab/test-data/exporter/notation-legend-formatted.atex b/packages/alphatab/test-data/exporter/notation-legend-formatted.atex index 860044a6d..2dea18f19 100644 --- a/packages/alphatab/test-data/exporter/notation-legend-formatted.atex +++ b/packages/alphatab/test-data/exporter/notation-legend-formatted.atex @@ -242,6 +242,8 @@ 12.2{x}.16{sd gr onbeat beam Up} 12.1.1{sd beam Down} | + // Masterbar 37 Metadata + \beaming (8 2 2 2 2) // Bar 37 / Voice 1 contents 5.1{x}.16{sd gr onbeat beam Up} 5.2{x}.16{sd gr onbeat beam Up} @@ -269,6 +271,7 @@ | // Masterbar 41 Metadata \ts (1 4) + \beaming (8 2 2 2 2) // Bar 41 / Voice 1 contents 5.3{sl}.8{beam Down} 7.3.8{beam Down} @@ -299,6 +302,7 @@ | // Masterbar 49 Metadata \ts (4 4) + \beaming (8 2 2 2 2) // Bar 49 / Voice 1 contents 5.4{h}.2{beam Up} 7.4.2{beam Up} @@ -527,6 +531,7 @@ | // Masterbar 83 Metadata \ts (5 8) + \beaming (8 2 2 2 2) // Bar 83 / Voice 1 contents 7.5.8{beam Up} 7.5.8{beam Up} @@ -535,6 +540,7 @@ | // Masterbar 84 Metadata \ts (4 4) + \beaming (8 2 2 2 2) \tempo 60 // Bar 84 / Voice 1 contents 7.5.8{beam Up} @@ -795,6 +801,7 @@ | // Masterbar 117 Metadata \ts (6 8) + \beaming (8 3 3) // Bar 117 / Voice 1 contents 0.6{lr}.8{beam Up} (0.3{lr} 0.6{lr t}).8{beam Up} @@ -839,6 +846,7 @@ | // Masterbar 124 Metadata \ts (4 4) + \beaming (8 2 2 2 2) // Bar 124 / Voice 1 contents 12.3{lf 2}.4{sd beam Down} 14.3{lf 4}.8{sd beam Down} diff --git a/packages/alphatab/test-data/guitarpro8/custom-beaming.gp b/packages/alphatab/test-data/guitarpro8/custom-beaming.gp new file mode 100644 index 000000000..3d3ac1d8e Binary files /dev/null and b/packages/alphatab/test-data/guitarpro8/custom-beaming.gp differ diff --git a/packages/alphatab/test-data/visual-tests/effects-and-annotations/chords.png b/packages/alphatab/test-data/visual-tests/effects-and-annotations/chords.png index 5fb4c0beb..71e03f90c 100644 Binary files a/packages/alphatab/test-data/visual-tests/effects-and-annotations/chords.png and b/packages/alphatab/test-data/visual-tests/effects-and-annotations/chords.png differ diff --git a/packages/alphatab/test-data/visual-tests/guitar-tabs/rhythm.png b/packages/alphatab/test-data/visual-tests/guitar-tabs/rhythm.png index 7c5830f4b..fa37ac2fc 100644 Binary files a/packages/alphatab/test-data/visual-tests/guitar-tabs/rhythm.png and b/packages/alphatab/test-data/visual-tests/guitar-tabs/rhythm.png differ diff --git a/packages/alphatab/test-data/visual-tests/layout/multi-voice.gp b/packages/alphatab/test-data/visual-tests/layout/multi-voice.gp index 7fd76b1c7..723149b48 100644 Binary files a/packages/alphatab/test-data/visual-tests/layout/multi-voice.gp and b/packages/alphatab/test-data/visual-tests/layout/multi-voice.gp differ diff --git a/packages/alphatab/test-data/visual-tests/special-tracks/numbered.png b/packages/alphatab/test-data/visual-tests/special-tracks/numbered.png index 082f72fd8..dd40b2ecc 100644 Binary files a/packages/alphatab/test-data/visual-tests/special-tracks/numbered.png and b/packages/alphatab/test-data/visual-tests/special-tracks/numbered.png differ diff --git a/packages/alphatab/test/importer/AlphaTexImporter.test.ts b/packages/alphatab/test/importer/AlphaTexImporter.test.ts index 1092154ee..1b8308f87 100644 --- a/packages/alphatab/test/importer/AlphaTexImporter.test.ts +++ b/packages/alphatab/test/importer/AlphaTexImporter.test.ts @@ -2653,4 +2653,19 @@ describe('AlphaTexImporterTest', () => { it('hide', () => test('\\defaultBarNumberDisplay allBars C4 | \\barNumberDisplay hide C4 ', BarNumberDisplay.Hide)); }); + + it('custom-beaming', () => { + const score = parseTex(` + \\ts (4 4) + \\beaming (8 2 2 2 2) + C4.8 * 8 | + C4.8 * 8 | + \\ts (4 4) + \\beaming (8 4 4) + C4.8 * 8 + C4.8 * 8 + `); + expect(score).toMatchSnapshot(); + testExportRoundtrip(score); + }); }); diff --git a/packages/alphatab/test/importer/Gp8Importer.test.ts b/packages/alphatab/test/importer/Gp8Importer.test.ts index df7f05c64..9fe94ed4e 100644 --- a/packages/alphatab/test/importer/Gp8Importer.test.ts +++ b/packages/alphatab/test/importer/Gp8Importer.test.ts @@ -3,6 +3,7 @@ import { ByteBuffer } from '@coderline/alphatab/io/ByteBuffer'; import { AutomationType } from '@coderline/alphatab/model/Automation'; import { BeatBeamingMode } from '@coderline/alphatab/model/Beat'; import { Direction } from '@coderline/alphatab/model/Direction'; +import { Duration } from '@coderline/alphatab/model/Duration'; import { BarNumberDisplay, BracketExtendMode, @@ -465,4 +466,42 @@ describe('Gp8ImporterTest', () => { expect(score.stylesheet.barNumberDisplay).to.equal(BarNumberDisplay.FirstOfSystem); }); }); + + it('custom-beaming', async () => { + const score = (await prepareImporterWithFile('guitarpro8/custom-beaming.gp')).readScore(); + + // NOTE: no need to verify all details, we'll have a visual test for that. + + expect(score.masterBars[0].beamingRules).to.be.ok; + expect(score.masterBars[0].beamingRules!.groups.has(Duration.Eighth)).to.be.true; + expect(score.masterBars[0].beamingRules!.groups.get(Duration.Eighth)!.join(',')).to.be.equal('2,2,2,2'); + // equal to previous + expect(score.masterBars[1].beamingRules === undefined, 'expected beamingRules of bar 1 to be undefined').to.be + .true; + expect( + score.masterBars[1].actualBeamingRules === score.masterBars[0].beamingRules, + 'actualBeamingRules of bar 1 incorrect' + ).to.be.true; + expect(score.masterBars[2].beamingRules === undefined, 'expected beamingRules of bar 2 to be undefined').to.be + .true; + expect( + score.masterBars[2].actualBeamingRules === score.masterBars[0].beamingRules, + 'actualBeamingRules of bar 1 incorrect' + ).to.be.true; + expect(score.masterBars[3].beamingRules === undefined, 'expected beamingRules of bar 3 to be undefined').to.be + .true; + expect( + score.masterBars[3].actualBeamingRules === score.masterBars[0].beamingRules, + 'actualBeamingRules of bar 1 incorrect' + ).to.be.true; + expect(score.masterBars[4].beamingRules === undefined, 'expected beamingRules of bar 4 to be undefined').to.be + .true; + expect( + score.masterBars[4].actualBeamingRules === score.masterBars[0].beamingRules, + 'actualBeamingRules of bar 1 incorrect' + ).to.be.true; + + expect(score.masterBars[5].beamingRules!.groups.has(Duration.Eighth)).to.be.true; + expect(score.masterBars[5].beamingRules!.groups.get(Duration.Eighth)!.join(',')).to.be.equal('4,4'); + }); }); diff --git a/packages/alphatab/test/importer/__snapshots__/AlphaTexImporter.test.ts.snap b/packages/alphatab/test/importer/__snapshots__/AlphaTexImporter.test.ts.snap index 39723f215..b70ee1495 100644 --- a/packages/alphatab/test/importer/__snapshots__/AlphaTexImporter.test.ts.snap +++ b/packages/alphatab/test/importer/__snapshots__/AlphaTexImporter.test.ts.snap @@ -5111,6 +5111,654 @@ Map { } `; +exports[`AlphaTexImporterTest custom-beaming 1`] = ` +Map { + "__kind" => "Score", + "masterbars" => Array [ + Map { + "__kind" => "MasterBar", + "beamingrules" => Map { + "groups" => Map { + "8" => Array [ + 2, + 2, + 2, + 2, + ], + }, + }, + "tempoautomations" => Array [ + Map { + "islinear" => false, + "type" => 0, + "value" => 120, + "ratioposition" => 0, + "text" => "", + "isvisible" => false, + }, + ], + }, + Map { + "__kind" => "MasterBar", + "start" => 3840, + }, + Map { + "__kind" => "MasterBar", + "beamingrules" => Map { + "groups" => Map { + "8" => Array [ + 4, + 4, + ], + }, + }, + "start" => 7680, + }, + ], + "tracks" => Array [ + Map { + "__kind" => "Track", + "staves" => Array [ + Map { + "__kind" => "Staff", + "bars" => Array [ + Map { + "__kind" => "Bar", + "id" => 0, + "voices" => Array [ + Map { + "__kind" => "Voice", + "id" => 0, + "beats" => Array [ + Map { + "__kind" => "Beat", + "id" => 0, + "notes" => Array [ + Map { + "__kind" => "Note", + "id" => 0, + "octave" => 5, + "tone" => 0, + }, + ], + "duration" => 8, + "automations" => Array [ + Map { + "islinear" => false, + "type" => 2, + "value" => 25, + "ratioposition" => 0, + "text" => "", + "isvisible" => true, + }, + ], + "displayduration" => 480, + "playbackduration" => 480, + }, + Map { + "__kind" => "Beat", + "id" => 1, + "notes" => Array [ + Map { + "__kind" => "Note", + "id" => 1, + "octave" => 5, + "tone" => 0, + }, + ], + "duration" => 8, + "displaystart" => 480, + "playbackstart" => 480, + "displayduration" => 480, + "playbackduration" => 480, + }, + Map { + "__kind" => "Beat", + "id" => 2, + "notes" => Array [ + Map { + "__kind" => "Note", + "id" => 2, + "octave" => 5, + "tone" => 0, + }, + ], + "duration" => 8, + "displaystart" => 960, + "playbackstart" => 960, + "displayduration" => 480, + "playbackduration" => 480, + }, + Map { + "__kind" => "Beat", + "id" => 3, + "notes" => Array [ + Map { + "__kind" => "Note", + "id" => 3, + "octave" => 5, + "tone" => 0, + }, + ], + "duration" => 8, + "displaystart" => 1440, + "playbackstart" => 1440, + "displayduration" => 480, + "playbackduration" => 480, + }, + Map { + "__kind" => "Beat", + "id" => 4, + "notes" => Array [ + Map { + "__kind" => "Note", + "id" => 4, + "octave" => 5, + "tone" => 0, + }, + ], + "duration" => 8, + "displaystart" => 1920, + "playbackstart" => 1920, + "displayduration" => 480, + "playbackduration" => 480, + }, + Map { + "__kind" => "Beat", + "id" => 5, + "notes" => Array [ + Map { + "__kind" => "Note", + "id" => 5, + "octave" => 5, + "tone" => 0, + }, + ], + "duration" => 8, + "displaystart" => 2400, + "playbackstart" => 2400, + "displayduration" => 480, + "playbackduration" => 480, + }, + Map { + "__kind" => "Beat", + "id" => 6, + "notes" => Array [ + Map { + "__kind" => "Note", + "id" => 6, + "octave" => 5, + "tone" => 0, + }, + ], + "duration" => 8, + "displaystart" => 2880, + "playbackstart" => 2880, + "displayduration" => 480, + "playbackduration" => 480, + }, + Map { + "__kind" => "Beat", + "id" => 7, + "notes" => Array [ + Map { + "__kind" => "Note", + "id" => 7, + "octave" => 5, + "tone" => 0, + }, + ], + "duration" => 8, + "displaystart" => 3360, + "playbackstart" => 3360, + "displayduration" => 480, + "playbackduration" => 480, + }, + ], + }, + ], + }, + Map { + "__kind" => "Bar", + "id" => 1, + "voices" => Array [ + Map { + "__kind" => "Voice", + "id" => 1, + "beats" => Array [ + Map { + "__kind" => "Beat", + "id" => 8, + "notes" => Array [ + Map { + "__kind" => "Note", + "id" => 8, + "octave" => 5, + "tone" => 0, + }, + ], + "duration" => 8, + "displayduration" => 480, + "playbackduration" => 480, + }, + Map { + "__kind" => "Beat", + "id" => 9, + "notes" => Array [ + Map { + "__kind" => "Note", + "id" => 9, + "octave" => 5, + "tone" => 0, + }, + ], + "duration" => 8, + "displaystart" => 480, + "playbackstart" => 480, + "displayduration" => 480, + "playbackduration" => 480, + }, + Map { + "__kind" => "Beat", + "id" => 10, + "notes" => Array [ + Map { + "__kind" => "Note", + "id" => 10, + "octave" => 5, + "tone" => 0, + }, + ], + "duration" => 8, + "displaystart" => 960, + "playbackstart" => 960, + "displayduration" => 480, + "playbackduration" => 480, + }, + Map { + "__kind" => "Beat", + "id" => 11, + "notes" => Array [ + Map { + "__kind" => "Note", + "id" => 11, + "octave" => 5, + "tone" => 0, + }, + ], + "duration" => 8, + "displaystart" => 1440, + "playbackstart" => 1440, + "displayduration" => 480, + "playbackduration" => 480, + }, + Map { + "__kind" => "Beat", + "id" => 12, + "notes" => Array [ + Map { + "__kind" => "Note", + "id" => 12, + "octave" => 5, + "tone" => 0, + }, + ], + "duration" => 8, + "displaystart" => 1920, + "playbackstart" => 1920, + "displayduration" => 480, + "playbackduration" => 480, + }, + Map { + "__kind" => "Beat", + "id" => 13, + "notes" => Array [ + Map { + "__kind" => "Note", + "id" => 13, + "octave" => 5, + "tone" => 0, + }, + ], + "duration" => 8, + "displaystart" => 2400, + "playbackstart" => 2400, + "displayduration" => 480, + "playbackduration" => 480, + }, + Map { + "__kind" => "Beat", + "id" => 14, + "notes" => Array [ + Map { + "__kind" => "Note", + "id" => 14, + "octave" => 5, + "tone" => 0, + }, + ], + "duration" => 8, + "displaystart" => 2880, + "playbackstart" => 2880, + "displayduration" => 480, + "playbackduration" => 480, + }, + Map { + "__kind" => "Beat", + "id" => 15, + "notes" => Array [ + Map { + "__kind" => "Note", + "id" => 15, + "octave" => 5, + "tone" => 0, + }, + ], + "duration" => 8, + "displaystart" => 3360, + "playbackstart" => 3360, + "displayduration" => 480, + "playbackduration" => 480, + }, + ], + }, + ], + }, + Map { + "__kind" => "Bar", + "id" => 2, + "voices" => Array [ + Map { + "__kind" => "Voice", + "id" => 2, + "beats" => Array [ + Map { + "__kind" => "Beat", + "id" => 16, + "notes" => Array [ + Map { + "__kind" => "Note", + "id" => 16, + "octave" => 5, + "tone" => 0, + }, + ], + "duration" => 8, + "displayduration" => 480, + "playbackduration" => 480, + }, + Map { + "__kind" => "Beat", + "id" => 17, + "notes" => Array [ + Map { + "__kind" => "Note", + "id" => 17, + "octave" => 5, + "tone" => 0, + }, + ], + "duration" => 8, + "displaystart" => 480, + "playbackstart" => 480, + "displayduration" => 480, + "playbackduration" => 480, + }, + Map { + "__kind" => "Beat", + "id" => 18, + "notes" => Array [ + Map { + "__kind" => "Note", + "id" => 18, + "octave" => 5, + "tone" => 0, + }, + ], + "duration" => 8, + "displaystart" => 960, + "playbackstart" => 960, + "displayduration" => 480, + "playbackduration" => 480, + }, + Map { + "__kind" => "Beat", + "id" => 19, + "notes" => Array [ + Map { + "__kind" => "Note", + "id" => 19, + "octave" => 5, + "tone" => 0, + }, + ], + "duration" => 8, + "displaystart" => 1440, + "playbackstart" => 1440, + "displayduration" => 480, + "playbackduration" => 480, + }, + Map { + "__kind" => "Beat", + "id" => 20, + "notes" => Array [ + Map { + "__kind" => "Note", + "id" => 20, + "octave" => 5, + "tone" => 0, + }, + ], + "duration" => 8, + "displaystart" => 1920, + "playbackstart" => 1920, + "displayduration" => 480, + "playbackduration" => 480, + }, + Map { + "__kind" => "Beat", + "id" => 21, + "notes" => Array [ + Map { + "__kind" => "Note", + "id" => 21, + "octave" => 5, + "tone" => 0, + }, + ], + "duration" => 8, + "displaystart" => 2400, + "playbackstart" => 2400, + "displayduration" => 480, + "playbackduration" => 480, + }, + Map { + "__kind" => "Beat", + "id" => 22, + "notes" => Array [ + Map { + "__kind" => "Note", + "id" => 22, + "octave" => 5, + "tone" => 0, + }, + ], + "duration" => 8, + "displaystart" => 2880, + "playbackstart" => 2880, + "displayduration" => 480, + "playbackduration" => 480, + }, + Map { + "__kind" => "Beat", + "id" => 23, + "notes" => Array [ + Map { + "__kind" => "Note", + "id" => 23, + "octave" => 5, + "tone" => 0, + }, + ], + "duration" => 8, + "displaystart" => 3360, + "playbackstart" => 3360, + "displayduration" => 480, + "playbackduration" => 480, + }, + Map { + "__kind" => "Beat", + "id" => 24, + "notes" => Array [ + Map { + "__kind" => "Note", + "id" => 24, + "octave" => 5, + "tone" => 0, + }, + ], + "duration" => 8, + "displaystart" => 3840, + "playbackstart" => 3840, + "displayduration" => 480, + "playbackduration" => 480, + }, + Map { + "__kind" => "Beat", + "id" => 25, + "notes" => Array [ + Map { + "__kind" => "Note", + "id" => 25, + "octave" => 5, + "tone" => 0, + }, + ], + "duration" => 8, + "displaystart" => 4320, + "playbackstart" => 4320, + "displayduration" => 480, + "playbackduration" => 480, + }, + Map { + "__kind" => "Beat", + "id" => 26, + "notes" => Array [ + Map { + "__kind" => "Note", + "id" => 26, + "octave" => 5, + "tone" => 0, + }, + ], + "duration" => 8, + "displaystart" => 4800, + "playbackstart" => 4800, + "displayduration" => 480, + "playbackduration" => 480, + }, + Map { + "__kind" => "Beat", + "id" => 27, + "notes" => Array [ + Map { + "__kind" => "Note", + "id" => 27, + "octave" => 5, + "tone" => 0, + }, + ], + "duration" => 8, + "displaystart" => 5280, + "playbackstart" => 5280, + "displayduration" => 480, + "playbackduration" => 480, + }, + Map { + "__kind" => "Beat", + "id" => 28, + "notes" => Array [ + Map { + "__kind" => "Note", + "id" => 28, + "octave" => 5, + "tone" => 0, + }, + ], + "duration" => 8, + "displaystart" => 5760, + "playbackstart" => 5760, + "displayduration" => 480, + "playbackduration" => 480, + }, + Map { + "__kind" => "Beat", + "id" => 29, + "notes" => Array [ + Map { + "__kind" => "Note", + "id" => 29, + "octave" => 5, + "tone" => 0, + }, + ], + "duration" => 8, + "displaystart" => 6240, + "playbackstart" => 6240, + "displayduration" => 480, + "playbackduration" => 480, + }, + Map { + "__kind" => "Beat", + "id" => 30, + "notes" => Array [ + Map { + "__kind" => "Note", + "id" => 30, + "octave" => 5, + "tone" => 0, + }, + ], + "duration" => 8, + "displaystart" => 6720, + "playbackstart" => 6720, + "displayduration" => 480, + "playbackduration" => 480, + }, + Map { + "__kind" => "Beat", + "id" => 31, + "notes" => Array [ + Map { + "__kind" => "Note", + "id" => 31, + "octave" => 5, + "tone" => 0, + }, + ], + "duration" => 8, + "displaystart" => 7200, + "playbackstart" => 7200, + "displayduration" => 480, + "playbackduration" => 480, + }, + ], + }, + ], + }, + ], + "showtablature" => false, + }, + ], + "playbackinfo" => Map { + "program" => 25, + "secondarychannel" => 1, + }, + }, + ], +} +`; + exports[`AlphaTexImporterTest errors at209 accidentalmode: lexer-diagnostics 1`] = `Array []`; exports[`AlphaTexImporterTest errors at209 accidentalmode: parser-diagnostics 1`] = `Array []`; diff --git a/packages/alphatab/test/importer/__snapshots__/Gp8Importer.test.ts.snap b/packages/alphatab/test/importer/__snapshots__/Gp8Importer.test.ts.snap index a2048297d..0fed28a05 100644 --- a/packages/alphatab/test/importer/__snapshots__/Gp8Importer.test.ts.snap +++ b/packages/alphatab/test/importer/__snapshots__/Gp8Importer.test.ts.snap @@ -9,6 +9,16 @@ Map { "masterbars" => Array [ Map { "__kind" => "MasterBar", + "beamingrules" => Map { + "groups" => Map { + "8" => Array [ + 2, + 2, + 2, + 2, + ], + }, + }, "tempoautomations" => Array [ Map { "islinear" => false, @@ -695,6 +705,14 @@ Map { Map { "__kind" => "MasterBar", "timesignaturenumerator" => 6, + "beamingrules" => Map { + "groups" => Map { + "8" => Array [ + 6, + 6, + ], + }, + }, "tempoautomations" => Array [ Map { "islinear" => false, @@ -723,6 +741,16 @@ Map { }, Map { "__kind" => "MasterBar", + "beamingrules" => Map { + "groups" => Map { + "8" => Array [ + 2, + 2, + 2, + 2, + ], + }, + }, "tempoautomations" => Array [ Map { "islinear" => false, @@ -1044,10 +1072,28 @@ Map { Map { "__kind" => "MasterBar", "timesignaturenumerator" => 6, + "beamingrules" => Map { + "groups" => Map { + "8" => Array [ + 6, + 6, + ], + }, + }, "start" => 689280, }, Map { "__kind" => "MasterBar", + "beamingrules" => Map { + "groups" => Map { + "8" => Array [ + 2, + 2, + 2, + 2, + ], + }, + }, "start" => 695040, }, Map { diff --git a/packages/alphatex/src/definitions.ts b/packages/alphatex/src/definitions.ts index 94d527157..1e721ca30 100644 --- a/packages/alphatex/src/definitions.ts +++ b/packages/alphatex/src/definitions.ts @@ -157,6 +157,7 @@ import { showSingleStaffBrackets } from '@coderline/alphatab-alphatex/metadata/s import { instrumentMeta } from '@coderline/alphatab-alphatex/metadata/staff/instrument'; import type { AlphaTexExample, WithDescription, WithSignatures } from '@coderline/alphatab-alphatex/types'; import { barNumberDisplay } from '@coderline/alphatab-alphatex/metadata/bar/barnumberdisplay'; +import { beaming } from '@coderline/alphatab-alphatex/metadata/bar/beamingRule'; export const structuralMetaData = metadata(track, staff, voice); export const scoreMetaData = metadata( @@ -230,7 +231,8 @@ export const barMetaData = metadata( spu, db, voiceMode, - barNumberDisplay + barNumberDisplay, + beaming ); export const allMetadata = new Map([ diff --git a/packages/alphatex/src/metadata/bar/beamingRule.ts b/packages/alphatex/src/metadata/bar/beamingRule.ts new file mode 100644 index 000000000..f6024005e --- /dev/null +++ b/packages/alphatex/src/metadata/bar/beamingRule.ts @@ -0,0 +1,60 @@ +import * as alphaTab from '@coderline/alphatab'; +import type { MetadataTagDefinition } from '@coderline/alphatab-alphatex/types'; + +export const beaming: MetadataTagDefinition = { + tag: '\\beaming', + snippet: '\\beaming ($1 $2) $0', + shortDescription: 'Set the time signature', + longDescription: ` + Defines a custom beaming rule defining how beams of certain durations should be beamed. + + To define how beats should be beamed we need 2 parts: + + 1. A duration with which we splitup the bars + 2. A list of group sizes defining how many split-parts should be beamed together. + + The beaming rules go hand-in-hand with the time signature as the rules need to properly + define the groups for the whole beat. + + Let's take a simple example of a 4/4 time signature. If we want to ensure that the beats within the quarter notes are + beamed together we can write variants like this: + + a. \`\\beaming (4 1 1 1 1)\` + b. \`\\beaming (8 2 2 2 2)\` + c. \`\\beaming (16 4 4 4 4)\` + + We slice the bar into 4, 8 or 16 parts. Then we add "groups" to those parts. If two beats start in the same group, they can be beamed together. + Simple as that. + + There are some common guidelines on how beaming "should be done" and alphaTab ships a wide range of defaults. But in case of more specialized time signatures, + you can also customize the beaming as you need by slicing the bar and grouping the beats as needed. + `, + signatures: [ + { + parameters: [ + { + name: 'duration', + shortDescription: 'The note duration defining the smallest group size', + parseMode: alphaTab.importer.alphaTex.ArgumentListParseTypesMode.Required, + type: alphaTab.importer.alphaTex.AlphaTexNodeType.Number + }, + { + name: 'groups', + shortDescription: 'For every group the number of notes contained in the group.', + parseMode: alphaTab.importer.alphaTex.ArgumentListParseTypesMode.ValueListWithoutParenthesis, + type: alphaTab.importer.alphaTex.AlphaTexNodeType.Number + } + ] + } + ], + + examples: ` + \\ts (4 4) + \\beaming (8 4 2 2) + C4.8 * 8 | + + \\ts (4 4) + \\beaming (8 4 4) + C4.8 * 8 + ` +}; 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 adeb843c6..06466dfbf 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 @@ -152,6 +152,19 @@ public class DoubleList : IDoubleIterable { return Iterator(this) } + 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() + } private class Iterator(private val list: DoubleList) : DoubleIterator() { private var _index = 0 override fun hasNext(): Boolean {