diff --git a/packages/alphatab/src/generated/model/BeatCloner.ts b/packages/alphatab/src/generated/model/BeatCloner.ts index 10beecd914..b07503169c 100644 --- a/packages/alphatab/src/generated/model/BeatCloner.ts +++ b/packages/alphatab/src/generated/model/BeatCloner.ts @@ -66,6 +66,7 @@ export class BeatCloner { clone.overrideDisplayDuration = original.overrideDisplayDuration; clone.golpe = original.golpe; clone.dynamics = original.dynamics; + clone.showDynamics = original.showDynamics; clone.invertBeamDirection = original.invertBeamDirection; clone.preferredBeamDirection = original.preferredBeamDirection; clone.isEffectSlurOrigin = original.isEffectSlurOrigin; diff --git a/packages/alphatab/src/generated/model/BeatSerializer.ts b/packages/alphatab/src/generated/model/BeatSerializer.ts index 1b7b918e1b..41e122a233 100644 --- a/packages/alphatab/src/generated/model/BeatSerializer.ts +++ b/packages/alphatab/src/generated/model/BeatSerializer.ts @@ -94,6 +94,7 @@ export class BeatSerializer { o.set("overridedisplayduration", obj.overrideDisplayDuration); o.set("golpe", obj.golpe as number); o.set("dynamics", obj.dynamics as number); + o.set("showdynamics", obj.showDynamics); o.set("invertbeamdirection", obj.invertBeamDirection); o.set("preferredbeamdirection", obj.preferredBeamDirection as number | null); o.set("beamingmode", obj.beamingMode as number); @@ -256,6 +257,9 @@ export class BeatSerializer { case "dynamics": obj.dynamics = JsonHelper.parseEnum(v, DynamicValue)!; return true; + case "showdynamics": + obj.showDynamics = v as boolean | undefined; + return true; case "invertbeamdirection": obj.invertBeamDirection = v! as boolean; return true; diff --git a/packages/alphatab/src/importer/MusicXmlImporter.ts b/packages/alphatab/src/importer/MusicXmlImporter.ts index 8a8f8a1673..5e7ce159d8 100644 --- a/packages/alphatab/src/importer/MusicXmlImporter.ts +++ b/packages/alphatab/src/importer/MusicXmlImporter.ts @@ -97,7 +97,12 @@ interface MusicXmlHarmonicGroup { * @internal */ class StaffContext { - public currentDynamics = DynamicValue.F; + /** + * The dynamics of a direction, shown on the next note of the staff (and of the voice, if given). + */ + public pendingDynamics: DynamicValue | null = null; + public pendingDynamicsVoice: string = ''; + public lastDynamics: DynamicValue | null = null; public transpose: number = 0; public isExplicitlyBeamed = false; } @@ -283,7 +288,6 @@ export class MusicXmlImporter extends ScoreImporter { * Whether the exporter writes the stop of octave shifts before the last shifted note instead of after it. */ private _octaveShiftEndsBeforeLastNote: boolean = false; - private _currentDynamics = DynamicValue.F; public get name(): string { return 'MusicXML'; @@ -303,6 +307,7 @@ export class MusicXmlImporter extends ScoreImporter { this._parseDom(dom); this._applySpans(); + this._applyDynamics(); ModelUtils.consolidate(this._score); this._score.finish(this.settings); this._score.rebuildRepeatGroups(); @@ -2263,7 +2268,9 @@ export class MusicXmlImporter extends ScoreImporter { case 'dynamics': const newDynamics = this._parseDynamics(direction); if (newDynamics !== null) { - this._currentDynamics = newDynamics; + const context = this._getStaffContext(staff); + context.pendingDynamics = newDynamics; + context.pendingDynamicsVoice = voice; this._score.stylesheet.hideDynamics = false; } break; @@ -2653,6 +2660,15 @@ export class MusicXmlImporter extends ScoreImporter { beat.addNote(note!); } else { beat = this._createBeat(staff, masterBar, voiceRaw, note); + const context = this._getStaffContext(staff); + if ( + note !== null && + context.pendingDynamics !== null && + (context.pendingDynamicsVoice.length === 0 || context.pendingDynamicsVoice === voiceRaw) + ) { + this._markDynamics(context, beat, context.pendingDynamics!, false); + context.pendingDynamics = null; + } } if (note !== null) { @@ -2995,7 +3011,6 @@ export class MusicXmlImporter extends ScoreImporter { // need an empty placeholder beat for the gap if (gap > 0) { const placeholder = new Beat(); - placeholder.dynamics = this._currentDynamics; placeholder.isEmpty = true; placeholder.duration = Duration.TwoHundredFiftySixth; // smallest we have placeholder.overrideDisplayDuration = gap; @@ -3013,7 +3028,6 @@ export class MusicXmlImporter extends ScoreImporter { this._beats.push(newBeat); this._harmonicGroups.clear(); newBeat.isEmpty = false; - newBeat.dynamics = this._currentDynamics; const automations = this._nextBeatAutomations; this._nextBeatAutomations = null; @@ -3516,7 +3530,6 @@ export class MusicXmlImporter extends ScoreImporter { } const placeholder = new Beat(); - placeholder.dynamics = this._currentDynamics; placeholder.isEmpty = false; placeholder.duration = preferredDuration; placeholder.overrideDisplayDuration = preferredDurationTicks; @@ -3710,8 +3723,8 @@ export class MusicXmlImporter extends ScoreImporter { case 'dynamics': const dynamics = this._parseDynamics(c); if (dynamics !== null) { - beat.dynamics = dynamics; - this._currentDynamics = dynamics; + this._markDynamics(this._getStaffContext(beat.voice.bar.staff), beat, dynamics, true); + this._score.stylesheet.hideDynamics = false; } break; case 'fermata': @@ -3747,6 +3760,83 @@ export class MusicXmlImporter extends ScoreImporter { } } + /** + * A note-attached marking repeating the previous one of the staff is not shown, as some exporters (e.g. Guitar Pro) + * write the dynamics on every note. + */ + private _markDynamics(context: StaffContext, beat: Beat, value: DynamicValue, isNoteAttached: boolean) { + beat.dynamics = value; + beat.showDynamics = !isNoteAttached || context.lastDynamics !== value; + context.lastDynamics = value; + } + + /** + * Carries the dynamics markings forward in time to all beats of their part, e.g. a single marking for both hands + * of a piano. Of markings at the same position, the one on the own staff wins. + */ + private _applyDynamics() { + if (this._score.stylesheet.hideDynamics) { + return; + } + for (const track of this._score.tracks) { + const markings: Beat[] = []; + for (const staff of track.staves) { + for (const bar of staff.bars) { + for (const voice of bar.voices) { + for (const beat of voice.beats) { + if (beat.showDynamics !== undefined) { + markings.push(beat); + } + } + } + } + } + + for (const staff of track.staves) { + const ordered = markings.slice(); + ordered.sort((a, b) => { + const position = MusicXmlImporter._comparePosition(a, b); + if (position !== 0) { + return position; + } + return (a.voice.bar.staff === staff ? 1 : 0) - (b.voice.bar.staff === staff ? 1 : 0); + }); + + // per voice: index of the next marking and the dynamics in effect + const nextMarking: number[] = []; + const current: DynamicValue[] = []; + for (const bar of staff.bars) { + for (const voice of bar.voices) { + while (nextMarking.length <= voice.index) { + nextMarking.push(0); + current.push(DynamicValue.F); + } + for (const beat of voice.beats) { + let next = nextMarking[voice.index]; + while ( + next < ordered.length && + MusicXmlImporter._comparePosition(ordered[next], beat) <= 0 + ) { + current[voice.index] = ordered[next].dynamics; + next++; + } + nextMarking[voice.index] = next; + if (beat.showDynamics === undefined) { + beat.dynamics = current[voice.index]; + beat.showDynamics = false; + } + } + } + } + } + } + } + + private static _comparePosition(a: Beat, b: Beat): number { + const bar = a.voice.bar.index - b.voice.bar.index; + return bar !== 0 ? bar : a.displayStart - b.displayStart; + } + private _getStaffContext(staff: Staff) { if (!this._staffToContext.has(staff)) { const context = new StaffContext(); diff --git a/packages/alphatab/src/model/Beat.ts b/packages/alphatab/src/model/Beat.ts index bc12d7d7ba..b59812f2e8 100644 --- a/packages/alphatab/src/model/Beat.ts +++ b/packages/alphatab/src/model/Beat.ts @@ -673,6 +673,16 @@ export class Beat { */ public dynamics: DynamicValue = DynamicValue.F; + /** + * Whether a dynamics marking with the {@link dynamics} of this beat is shown. + * @remarks + * If not set, a marking is shown where the dynamics change. + * Formats encoding the written markings (e.g. MusicXML) set it, as the dynamics in effect can differ + * from the shown markings: e.g. a single marking on a piano applies to both staves. + * @since 1.9.0 + */ + public showDynamics?: boolean; + /** * Gets or sets a value indicating whether the beam direction should be inverted. */ @@ -1185,6 +1195,9 @@ export class Beat { const cloneBeat: Beat = BeatCloner.clone(this); cloneBeat.id = Beat._globalBeatId++; cloneBeat.pickStroke = PickStroke.None; + if (cloneBeat.showDynamics === true) { + cloneBeat.showDynamics = false; + } for (let i: number = 0, j: number = cloneBeat.notes.length; i < j; i++) { const cloneNote: Note = cloneBeat.notes[i]; const note: Note = this.notes[i]; diff --git a/packages/alphatab/src/rendering/effects/DynamicsEffectInfo.ts b/packages/alphatab/src/rendering/effects/DynamicsEffectInfo.ts index 5ca8ed4a93..2db8887f6f 100644 --- a/packages/alphatab/src/rendering/effects/DynamicsEffectInfo.ts +++ b/packages/alphatab/src/rendering/effects/DynamicsEffectInfo.ts @@ -23,6 +23,10 @@ function shouldCreateDynamicsGlyph(beat: Beat): boolean { return false; } + if (beat.showDynamics !== undefined) { + return beat.showDynamics!; + } + const previousBeat = getPreviousDynamicsBeat(beat); let show: boolean = (beat.voice.index === 0 && !previousBeat) || beat.dynamics !== previousBeat?.dynamics; diff --git a/packages/alphatab/test-data/musicxml-samples/BeetAnGeSample.png b/packages/alphatab/test-data/musicxml-samples/BeetAnGeSample.png index 9243aa70c1..20b306f854 100644 Binary files a/packages/alphatab/test-data/musicxml-samples/BeetAnGeSample.png and b/packages/alphatab/test-data/musicxml-samples/BeetAnGeSample.png differ diff --git a/packages/alphatab/test-data/musicxml-samples/BrahWiMeSample.png b/packages/alphatab/test-data/musicxml-samples/BrahWiMeSample.png index 40909f75ec..2e5aab8ea4 100644 Binary files a/packages/alphatab/test-data/musicxml-samples/BrahWiMeSample.png and b/packages/alphatab/test-data/musicxml-samples/BrahWiMeSample.png differ diff --git a/packages/alphatab/test-data/musicxml-samples/DebuMandSample.png b/packages/alphatab/test-data/musicxml-samples/DebuMandSample.png index 10f20a686f..b192d90806 100644 Binary files a/packages/alphatab/test-data/musicxml-samples/DebuMandSample.png and b/packages/alphatab/test-data/musicxml-samples/DebuMandSample.png differ diff --git a/packages/alphatab/test-data/musicxml-samples/Dichterliebe01.png b/packages/alphatab/test-data/musicxml-samples/Dichterliebe01.png index 27a472b72a..7991f16331 100644 Binary files a/packages/alphatab/test-data/musicxml-samples/Dichterliebe01.png and b/packages/alphatab/test-data/musicxml-samples/Dichterliebe01.png differ diff --git a/packages/alphatab/test-data/musicxml-samples/Echigo.png b/packages/alphatab/test-data/musicxml-samples/Echigo.png index cfa6dbd9f7..79fd197b09 100644 Binary files a/packages/alphatab/test-data/musicxml-samples/Echigo.png and b/packages/alphatab/test-data/musicxml-samples/Echigo.png differ diff --git a/packages/alphatab/test-data/musicxml-samples/FaurReveSample.png b/packages/alphatab/test-data/musicxml-samples/FaurReveSample.png index ed1a9a190f..7709f869af 100644 Binary files a/packages/alphatab/test-data/musicxml-samples/FaurReveSample.png and b/packages/alphatab/test-data/musicxml-samples/FaurReveSample.png differ diff --git a/packages/alphatab/test-data/musicxml-samples/MahlFaGe4Sample.png b/packages/alphatab/test-data/musicxml-samples/MahlFaGe4Sample.png index 3666f96966..a52ff25a09 100644 Binary files a/packages/alphatab/test-data/musicxml-samples/MahlFaGe4Sample.png and b/packages/alphatab/test-data/musicxml-samples/MahlFaGe4Sample.png differ diff --git a/packages/alphatab/test-data/musicxml-samples/MozaChloSample.png b/packages/alphatab/test-data/musicxml-samples/MozaChloSample.png index 3930590223..3bbce29b07 100644 Binary files a/packages/alphatab/test-data/musicxml-samples/MozaChloSample.png and b/packages/alphatab/test-data/musicxml-samples/MozaChloSample.png differ diff --git a/packages/alphatab/test-data/musicxml-samples/MozaVeilSample.png b/packages/alphatab/test-data/musicxml-samples/MozaVeilSample.png index 8d6053a45a..b35b95041f 100644 Binary files a/packages/alphatab/test-data/musicxml-samples/MozaVeilSample.png and b/packages/alphatab/test-data/musicxml-samples/MozaVeilSample.png differ diff --git a/packages/alphatab/test-data/musicxml-samples/MozartTrio.png b/packages/alphatab/test-data/musicxml-samples/MozartTrio.png index 7eac178753..9d8826268a 100644 Binary files a/packages/alphatab/test-data/musicxml-samples/MozartTrio.png and b/packages/alphatab/test-data/musicxml-samples/MozartTrio.png differ diff --git a/packages/alphatab/test-data/musicxml-samples/SchbAvMaSample.png b/packages/alphatab/test-data/musicxml-samples/SchbAvMaSample.png index 8e919f226f..4065856c7d 100644 Binary files a/packages/alphatab/test-data/musicxml-samples/SchbAvMaSample.png and b/packages/alphatab/test-data/musicxml-samples/SchbAvMaSample.png differ diff --git a/packages/alphatab/test-data/musicxml-testsuite/21f-Chord-ElementInBetween.png b/packages/alphatab/test-data/musicxml-testsuite/21f-Chord-ElementInBetween.png index c738e4fc26..2e6eed5ea4 100644 Binary files a/packages/alphatab/test-data/musicxml-testsuite/21f-Chord-ElementInBetween.png and b/packages/alphatab/test-data/musicxml-testsuite/21f-Chord-ElementInBetween.png differ diff --git a/packages/alphatab/test-data/musicxml-testsuite/23e-Tuplets-Tremolo.png b/packages/alphatab/test-data/musicxml-testsuite/23e-Tuplets-Tremolo.png index 1fc2ed6759..62fd0917a1 100644 Binary files a/packages/alphatab/test-data/musicxml-testsuite/23e-Tuplets-Tremolo.png and b/packages/alphatab/test-data/musicxml-testsuite/23e-Tuplets-Tremolo.png differ diff --git a/packages/alphatab/test-data/musicxml-testsuite/24h-GraceNote-Simultaneous.png b/packages/alphatab/test-data/musicxml-testsuite/24h-GraceNote-Simultaneous.png index 1e56635390..929ad08118 100644 Binary files a/packages/alphatab/test-data/musicxml-testsuite/24h-GraceNote-Simultaneous.png and b/packages/alphatab/test-data/musicxml-testsuite/24h-GraceNote-Simultaneous.png differ diff --git a/packages/alphatab/test-data/musicxml-testsuite/31a-Directions.png b/packages/alphatab/test-data/musicxml-testsuite/31a-Directions.png index fb7708336b..64629c85cf 100644 Binary files a/packages/alphatab/test-data/musicxml-testsuite/31a-Directions.png and b/packages/alphatab/test-data/musicxml-testsuite/31a-Directions.png differ diff --git a/packages/alphatab/test-data/musicxml-testsuite/31b-Directions-Order.png b/packages/alphatab/test-data/musicxml-testsuite/31b-Directions-Order.png index ce738f88ca..18c598826f 100644 Binary files a/packages/alphatab/test-data/musicxml-testsuite/31b-Directions-Order.png and b/packages/alphatab/test-data/musicxml-testsuite/31b-Directions-Order.png differ diff --git a/packages/alphatab/test-data/musicxml-testsuite/31d-Directions-Compounds.png b/packages/alphatab/test-data/musicxml-testsuite/31d-Directions-Compounds.png index 54fa0a09ce..0670cbd013 100644 Binary files a/packages/alphatab/test-data/musicxml-testsuite/31d-Directions-Compounds.png and b/packages/alphatab/test-data/musicxml-testsuite/31d-Directions-Compounds.png differ diff --git a/packages/alphatab/test-data/musicxml-testsuite/32a-Notations.png b/packages/alphatab/test-data/musicxml-testsuite/32a-Notations.png index d08c7d8ba4..e5188cbebd 100644 Binary files a/packages/alphatab/test-data/musicxml-testsuite/32a-Notations.png and b/packages/alphatab/test-data/musicxml-testsuite/32a-Notations.png differ diff --git a/packages/alphatab/test-data/musicxml-testsuite/34b-Colors.png b/packages/alphatab/test-data/musicxml-testsuite/34b-Colors.png index ffbad0a677..604f347551 100644 Binary files a/packages/alphatab/test-data/musicxml-testsuite/34b-Colors.png and b/packages/alphatab/test-data/musicxml-testsuite/34b-Colors.png differ diff --git a/packages/alphatab/test-data/musicxml-testsuite/34c-Font-Size.png b/packages/alphatab/test-data/musicxml-testsuite/34c-Font-Size.png index 0eceb0294d..e1839ebc41 100644 Binary files a/packages/alphatab/test-data/musicxml-testsuite/34c-Font-Size.png and b/packages/alphatab/test-data/musicxml-testsuite/34c-Font-Size.png differ diff --git a/packages/alphatab/test-data/musicxml-testsuite/42a-MultiVoice-TwoVoicesOnStaff-Lyrics.png b/packages/alphatab/test-data/musicxml-testsuite/42a-MultiVoice-TwoVoicesOnStaff-Lyrics.png index 77d66a7073..c3ff7b910e 100644 Binary files a/packages/alphatab/test-data/musicxml-testsuite/42a-MultiVoice-TwoVoicesOnStaff-Lyrics.png and b/packages/alphatab/test-data/musicxml-testsuite/42a-MultiVoice-TwoVoicesOnStaff-Lyrics.png differ diff --git a/packages/alphatab/test-data/musicxml-testsuite/42b-MultiVoice-MidMeasureClefChange.png b/packages/alphatab/test-data/musicxml-testsuite/42b-MultiVoice-MidMeasureClefChange.png index e3b9541376..fbe695e340 100644 Binary files a/packages/alphatab/test-data/musicxml-testsuite/42b-MultiVoice-MidMeasureClefChange.png and b/packages/alphatab/test-data/musicxml-testsuite/42b-MultiVoice-MidMeasureClefChange.png differ diff --git a/packages/alphatab/test-data/musicxml-testsuite/43e-Multistaff-ClefDynamics.png b/packages/alphatab/test-data/musicxml-testsuite/43e-Multistaff-ClefDynamics.png index f55a9cb018..f16c74e217 100644 Binary files a/packages/alphatab/test-data/musicxml-testsuite/43e-Multistaff-ClefDynamics.png and b/packages/alphatab/test-data/musicxml-testsuite/43e-Multistaff-ClefDynamics.png differ diff --git a/packages/alphatab/test-data/musicxml3/chord-diagram.png b/packages/alphatab/test-data/musicxml3/chord-diagram.png index b9ae4f97b0..ddac90e138 100644 Binary files a/packages/alphatab/test-data/musicxml3/chord-diagram.png and b/packages/alphatab/test-data/musicxml3/chord-diagram.png differ diff --git a/packages/alphatab/test/importer/MusicXmlImporter.test.ts b/packages/alphatab/test/importer/MusicXmlImporter.test.ts index cfa67c57ab..85b89019a5 100644 --- a/packages/alphatab/test/importer/MusicXmlImporter.test.ts +++ b/packages/alphatab/test/importer/MusicXmlImporter.test.ts @@ -7,6 +7,7 @@ import type { Beat } from '@coderline/alphatab/model/Beat'; import { BendType } from '@coderline/alphatab/model/BendType'; import { CrescendoType } from '@coderline/alphatab/model/CrescendoType'; import { Direction } from '@coderline/alphatab/model/Direction'; +import { DynamicValue } from '@coderline/alphatab/model/DynamicValue'; import { Fingers } from '@coderline/alphatab/model/Fingers'; import { HarmonicType } from '@coderline/alphatab/model/HarmonicType'; import { JsonConverter } from '@coderline/alphatab/model/JsonConverter'; @@ -1561,6 +1562,180 @@ describe('MusicXmlImporterTests', () => { }); }); + describe('dynamics', () => { + const dynamic = (value: string, extra: string) => + direction(`<${value}/>`, extra); + const rest = (voice: number, staff: number) => + `1${voice}quarter${staff > 0 ? `${staff}` : ''}`; + const backup = '4'; + const dynamicsOf = (beats: Beat[]) => beats.map(b => b.dynamics); + const shownOf = (beats: Beat[]) => beats.map(b => b.showDynamics === true); + + it('not-other-parts', () => { + const withoutMarkings = load([[quarters(4)]], 2); + expect(withoutMarkings.stylesheet.hideDynamics).toBe(true); + expect(beatsOf(withoutMarkings)[0].showDynamics).toBeUndefined(); + + const score = load( + [ + [quarters(3) + dynamic('pp', '') + quarters(1), quarters(4)], + [quarters(4), quarters(4)] + ], + 2 + ); + const f = DynamicValue.F; + const pp = DynamicValue.PP; + expect(dynamicsOf(beatsOf(score, 0, 0, 0, 0))).toEqual([f, f, f, pp]); + expect(dynamicsOf(beatsOf(score, 1, 0, 0, 0))).toEqual([pp, pp, pp, pp]); + expect(shownOf(beatsOf(score, 0, 0, 0, 0))).toEqual([false, false, false, true]); + for (let bar = 0; bar < 2; bar++) { + expect(dynamicsOf(beatsOf(score, bar, 0, 0, 1))).toEqual([f, f, f, f]); + expect(shownOf(beatsOf(score, bar, 0, 0, 1))).toEqual([false, false, false, false]); + } + }); + + it('own-staff-at-same-position', () => { + // separate markings for both hands, a later single marking applies to both again + const score = load( + [ + [ + dynamic('mf', '1') + + quarters(2, 'C5', 1, 1) + + dynamic('f', '1') + + quarters(2, 'C5', 1, 1) + + backup + + dynamic('p', '2') + + quarters(4, 'C3', 5, 2) + ] + ], + 2 + ); + const mf = DynamicValue.MF; + const p = DynamicValue.P; + const f = DynamicValue.F; + expect(dynamicsOf(beatsOf(score, 0, 0))).toEqual([mf, mf, f, f]); + expect(dynamicsOf(beatsOf(score, 0, 1))).toEqual([p, p, f, f]); + expect(shownOf(beatsOf(score, 0, 0))).toEqual([true, false, true, false]); + expect(shownOf(beatsOf(score, 0, 1))).toEqual([true, false, false, false]); + }); + + it('struck-before-marking', () => { + // a note started before the marking keeps the previous dynamics + const half = (pitch: string) => note(pitch, 5, 2, '', 2, 'half'); + const score = load( + [ + [ + quarters(1, 'C5', 1, 1) + + dynamic('p', '1') + + quarters(3, 'C5', 1, 1) + + backup + + half('C3') + + half('D3') + ] + ], + 2 + ); + expect(dynamicsOf(beatsOf(score, 0, 1))).toEqual([DynamicValue.F, DynamicValue.P]); + }); + + it('shown-on-first-note', () => { + const score = load([[dynamic('p', '1') + rest(1, 0) + quarters(3)]], 2); + expect(dynamicsOf(beatsOf(score))).toEqual([ + DynamicValue.F, + DynamicValue.P, + DynamicValue.P, + DynamicValue.P + ]); + expect(shownOf(beatsOf(score))).toEqual([false, true, false, false]); + }); + + it('note-notations', () => { + const score = load( + [ + [ + quarters(4, 'C5', 1, 1) + + backup + + note('C3', 5, 2) + + note('C3', 5, 2, '') + + quarters(2, 'C3', 5, 2) + ] + ], + 2 + ); + const f = DynamicValue.F; + const pp = DynamicValue.PP; + expect(score.stylesheet.hideDynamics).toBe(false); + expect(dynamicsOf(beatsOf(score, 0, 0))).toEqual([f, pp, pp, pp]); + expect(dynamicsOf(beatsOf(score, 0, 1))).toEqual([f, pp, pp, pp]); + expect(shownOf(beatsOf(score, 0, 0))).toEqual([false, false, false, false]); + expect(shownOf(beatsOf(score, 0, 1))).toEqual([false, true, false, false]); + }); + + it('repeated-on-staff', () => { + // Guitar Pro writes the dynamics on every note, the marking of the other staff is not repeated + const score = load( + [ + [ + dynamic('p', '1') + + note('C5', 1, 1, '') + + note('C5', 1, 1, '') + + quarters(2, 'C5', 1, 1) + + backup + + quarters(2, 'C3', 5, 2) + + note('C3', 5, 2, '') + + note('C3', 5, 2, '') + ] + ], + 2 + ); + expect(shownOf(beatsOf(score, 0, 0))).toEqual([true, false, false, false]); + expect(shownOf(beatsOf(score, 0, 1))).toEqual([false, false, true, false]); + }); + + it('restated-direction', () => { + const wedge = (type: string) => direction(``); + const score = load( + [ + [ + dynamic('p', '') + + wedge('crescendo') + + quarters(1) + + wedge('stop') + + wedge('diminuendo') + + quarters(1) + + wedge('stop') + + dynamic('p', '') + + quarters(2) + ] + ], + 2 + ); + expect(shownOf(beatsOf(score))).toEqual([true, false, true, false]); + }); + + it('43e-Multistaff-ClefDynamics', async () => { + const score = await MusicXmlImporterTestHelper.loadFile( + 'test-data/musicxml-testsuite/43e-Multistaff-ClefDynamics.xml' + ); + // the markings of staff 1 are played on both staves of the piano, but only shown on staff 1 + const ffff = DynamicValue.FFFF; + const p = DynamicValue.P; + for (const staff of score.tracks[0].staves) { + expect(staff.bars.map(bar => dynamicsOf(bar.voices[0].beats))).toEqual([ + [ffff, ffff, ffff, p], + [p, p, p, p], + [p, p, p, p], + [p] + ]); + } + const none = [false, false, false, false]; + const shown = (staff: number) => + score.tracks[0].staves[staff].bars.map(bar => shownOf(bar.voices[0].beats)); + expect(shown(0)).toEqual([[true, false, false, true], none, none, [false]]); + expect(shown(1)).toEqual([none, none, none, [false]]); + }); + }); + describe('octave-shifts', () => { const shift = (type: string, size: number, number: string, extra: string) => direction( diff --git a/packages/alphatab/test/model/Beat.test.ts b/packages/alphatab/test/model/Beat.test.ts index fb34910b22..928b260e21 100644 --- a/packages/alphatab/test/model/Beat.test.ts +++ b/packages/alphatab/test/model/Beat.test.ts @@ -1,6 +1,15 @@ import { describe, expect, it } from 'vitest'; +import { Bar } from '@coderline/alphatab/model/Bar'; import { Beat } from '@coderline/alphatab/model/Beat'; +import { BendPoint } from '@coderline/alphatab/model/BendPoint'; +import { GraceType } from '@coderline/alphatab/model/GraceType'; +import { MasterBar } from '@coderline/alphatab/model/MasterBar'; import { Note } from '@coderline/alphatab/model/Note'; +import { Score } from '@coderline/alphatab/model/Score'; +import { Track } from '@coderline/alphatab/model/Track'; +import { Tuning } from '@coderline/alphatab/model/Tuning'; +import { Voice } from '@coderline/alphatab/model/Voice'; +import { Settings } from '@coderline/alphatab/Settings'; describe('BeatTests', () => { it('add-stringed-note', () => { const beat = new Beat(); @@ -22,4 +31,33 @@ describe('BeatTests', () => { expect(beat.hasNoteOnString(2)).toBe(false); expect(beat.getNoteOnString(2)).toBe(null); }); + + it('songbook-bend-shows-dynamics-once', () => { + const score = new Score(); + score.addMasterBar(new MasterBar()); + const track = new Track(); + track.ensureStaveCount(1); + track.staves[0].stringTuning = Tuning.getDefaultTuningFor(6)!; + score.addTrack(track); + const bar = new Bar(); + track.staves[0].addBar(bar); + const voice = new Voice(); + bar.addVoice(voice); + const beat = new Beat(); + beat.showDynamics = true; + voice.addBeat(beat); + const note = new Note(); + note.string = 3; + note.fret = 5; + note.addBendPoint(new BendPoint(0, 0)); + note.addBendPoint(new BendPoint(BendPoint.MaxPosition, 4)); + beat.addNote(note); + + const settings = new Settings(); + settings.setSongBookModeSettings(); + score.finish(settings); + + expect(voice.beats.map(b => b.graceType)).toEqual([GraceType.BendGrace, GraceType.None]); + expect(voice.beats.map(b => b.showDynamics)).toEqual([true, false]); + }); }); diff --git a/packages/csharp/src/AlphaTab.Test/Test/Globals.cs b/packages/csharp/src/AlphaTab.Test/Test/Globals.cs index 06968ea81a..d6b4f750ee 100644 --- a/packages/csharp/src/AlphaTab.Test/Test/Globals.cs +++ b/packages/csharp/src/AlphaTab.Test/Test/Globals.cs @@ -417,7 +417,15 @@ public void ToEqual(object? expected, string? message = null) Assert.Fail(message ?? _message ?? $"Sequence length mismatch at index {i}"); return; } - Assert.AreEqual(e.Current, a.Current, message ?? _message ?? $"Element {i}"); + var elementMessage = message ?? _message ?? $"Element {i}"; + if (e.Current is System.Collections.IEnumerable and not string) + { + new Expector(a.Current, elementMessage).ToEqual(e.Current); + } + else + { + Assert.AreEqual(e.Current, a.Current, elementMessage); + } i++; } } 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 8d1dbc0ba3..43b669f1c8 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 @@ -151,6 +151,15 @@ public class List : Iterable { return mapped } + @kotlin.jvm.JvmName("mapToBoolean") + internal fun map(transform: (v: T) -> Boolean): BooleanList { + val mapped = BooleanList(_data.size) + _data.forEachIndexed { index, item -> + mapped[index] = transform(item) + } + return mapped + } + internal fun reduce(operation: (acc: Double, v: T) -> Double, initial: Double): Double { var accumulator = initial diff --git a/packages/kotlin/src/android/src/test/java/alphaTab/core/TestGlobals.kt b/packages/kotlin/src/android/src/test/java/alphaTab/core/TestGlobals.kt index 23a024f293..1891cd4e09 100644 --- a/packages/kotlin/src/android/src/test/java/alphaTab/core/TestGlobals.kt +++ b/packages/kotlin/src/android/src/test/java/alphaTab/core/TestGlobals.kt @@ -327,7 +327,14 @@ class Expector(private val actual: T, private val message: String? = null) { Assert.fail(message ?: this.message ?: "Sequence length mismatch at index $i") return } - Assert.assertEquals(message ?: this.message ?: "Element $i", ei.next(), ai.next()) + val elementMessage = message ?: this.message ?: "Element $i" + val expectedElement = ei.next() + val actualElement = ai.next() + if (expectedElement is Iterable<*> && expectedElement !is String) { + Expector(actualElement, elementMessage).toEqual(expectedElement) + } else { + Assert.assertEquals(elementMessage, expectedElement, actualElement) + } i++ } }