diff --git a/packages/alphatab/src/exporter/GpifWriter.ts b/packages/alphatab/src/exporter/GpifWriter.ts index b0367c85a..ed1fcc3a5 100644 --- a/packages/alphatab/src/exporter/GpifWriter.ts +++ b/packages/alphatab/src/exporter/GpifWriter.ts @@ -19,6 +19,7 @@ import { Duration } from '@coderline/alphatab/model/Duration'; import { DynamicValue } from '@coderline/alphatab/model/DynamicValue'; import { FadeType } from '@coderline/alphatab/model/FadeType'; import { type Fermata, FermataType } from '@coderline/alphatab/model/Fermata'; +import { FingeringAssigner } from '@coderline/alphatab/model/FingeringAssigner'; import { Fingers } from '@coderline/alphatab/model/Fingers'; import { GolpeType } from '@coderline/alphatab/model/GolpeType'; import { GraceType } from '@coderline/alphatab/model/GraceType'; @@ -34,6 +35,7 @@ import type { Note } from '@coderline/alphatab/model/Note'; import { NoteAccidentalMode } from '@coderline/alphatab/model/NoteAccidentalMode'; import { NoteOrnament } from '@coderline/alphatab/model/NoteOrnament'; import { Ottavia } from '@coderline/alphatab/model/Ottavia'; +import { PercussionMapper } from '@coderline/alphatab/model/PercussionMapper'; import { PickStroke } from '@coderline/alphatab/model/PickStroke'; import { Rasgueado } from '@coderline/alphatab/model/Rasgueado'; import type { Score } from '@coderline/alphatab/model/Score'; @@ -43,6 +45,7 @@ import { SlideOutType } from '@coderline/alphatab/model/SlideOutType'; import type { Staff } from '@coderline/alphatab/model/Staff'; import type { Track } from '@coderline/alphatab/model/Track'; import { TripletFeel } from '@coderline/alphatab/model/TripletFeel'; +import { Tuning } from '@coderline/alphatab/model/Tuning'; import { VibratoType } from '@coderline/alphatab/model/VibratoType'; import type { Voice } from '@coderline/alphatab/model/Voice'; import { WahPedal } from '@coderline/alphatab/model/WahPedal'; @@ -63,11 +66,13 @@ export class GpifWriter { private static readonly _sampleRate = 44100; private _rhythmIdLookup: Map = new Map(); + private _tuningByStaff: Map = new Map(); public writeXml(score: Score): string { const xmlDocument = new XmlDocument(); this._rhythmIdLookup = new Map(); + this._tuningByStaff = new Map(); this._writeDom(xmlDocument, score); @@ -107,13 +112,38 @@ export class GpifWriter { for (const tracks of score.tracks) { for (const staff of tracks.staves) { + const needsFingering = ModelUtils.staffNotesAreNotStringed(staff); + const assignersByVoiceIndex = needsFingering ? new Map() : null; + const stringedTuning = needsFingering ? this._tuningByStaff.get(staff)! : null; + // Once the assigner sets note.string/note.fret, note.realValue + // routes through staff.tuning — needs to match the tuning we + // gave the assigner. Save + restore below leaves the input + // model untouched. + const savedTunings = staff.tuning; + if (needsFingering && stringedTuning !== null && savedTunings.length === 0) { + staff.stringTuning.tunings = stringedTuning.slice(); + } + for (const bar of staff.bars) { const activeVoices = this._writeBarNode(bars, bar); for (const voice of activeVoices) { + let assigner: FingeringAssigner | null = null; + if (assignersByVoiceIndex !== null && stringedTuning !== null) { + if (assignersByVoiceIndex.has(voice.index)) { + assigner = assignersByVoiceIndex.get(voice.index)!; + } else { + assigner = new FingeringAssigner(stringedTuning, staff.capo, staff.transpositionPitch); + assignersByVoiceIndex.set(voice.index, assigner); + } + } + this._writeVoiceNode(voices, voice); for (const beat of voice.beats) { + if (assigner !== null) { + assigner.assign(beat); + } this._writeBeatNode(beats, beat, rhythms); for (const note of beat.notes) { @@ -122,6 +152,7 @@ export class GpifWriter { } } } + staff.stringTuning.tunings = savedTunings; } } } @@ -282,7 +313,13 @@ export class GpifWriter { this._writeConcertPitch(properties, note); this._writeTransposedPitch(properties, note); - if (note.isStringed) { + if (note.isPercussion) { + const art = PercussionMapper.getArticulation(note); + const midi = art !== null ? art.outputMidiNumber : 0; + this._writeSimplePropertyNode(properties, 'String', 'String', (note.string - 1).toString()); + this._writeSimplePropertyNode(properties, 'Fret', 'Fret', midi.toString()); + this._writeSimplePropertyNode(properties, 'Midi', 'Number', midi.toString()); + } else if (note.isStringed) { this._writeSimplePropertyNode(properties, 'String', 'String', (note.string - 1).toString()); this._writeSimplePropertyNode(properties, 'Fret', 'Fret', note.fret.toString()); this._writeSimplePropertyNode(properties, 'Midi', 'Number', note.realValue.toString()); @@ -291,10 +328,6 @@ export class GpifWriter { } } - if (note.isPercussion) { - this._writeSimplePropertyNode(properties, 'String', 'String', (note.string - 1).toString()); - } - if (note.isPiano) { this._writeSimplePropertyNode(properties, 'Octave', 'Number', note.octave.toString()); this._writeSimplePropertyNode(properties, 'Tone', 'Step', note.tone.toString()); @@ -393,7 +426,7 @@ export class GpifWriter { private _writeTransposedPitch(properties: XmlNode, note: Note) { if (note.isPercussion) { - this._writePitch(properties, 'ConcertPitch', 'C', '-1', ''); + this._writePitch(properties, 'TransposedPitch', 'C', '-1', ''); } else { this._writePitchForValue( properties, @@ -998,7 +1031,7 @@ export class GpifWriter { initialTempoAutomation.addElement('Bar').innerText = '0'; initialTempoAutomation.addElement('Position').innerText = '0'; initialTempoAutomation.addElement('Visible').innerText = 'true'; - initialTempoAutomation.addElement('Value').innerText = `${score.tempo} 2`; + initialTempoAutomation.addElement('Value').innerText = `${score.tempo | 0} 2`; if (score.tempoLabel) { initialTempoAutomation.addElement('Text').innerText = score.tempoLabel; } @@ -1024,7 +1057,7 @@ export class GpifWriter { tempoAutomation.addElement('Bar').innerText = mb.index.toString(); tempoAutomation.addElement('Position').innerText = automation.ratioPosition.toString(); tempoAutomation.addElement('Visible').innerText = automation.isVisible ? 'true' : 'false'; - tempoAutomation.addElement('Value').innerText = `${automation.value} 2`; + tempoAutomation.addElement('Value').innerText = `${automation.value | 0} 2`; if (automation.text) { tempoAutomation.addElement('Text').innerText = automation.text; } @@ -1271,15 +1304,34 @@ export class GpifWriter { this._writeSimplePropertyNode(properties, 'CapoFret', 'Fret', staff.capo.toString()); this._writeSimplePropertyNode(properties, 'FretCount', 'Fret', '24'); - if (staff.tuning.length > 0) { + // GP7/8 requires every staff to carry a stringed tuning. + let tuning = staff.tuning; + let tuningName = staff.tuningName; + if (tuning.length === 0) { + if (staff.isPercussion) { + tuning = [0, 0, 0, 0, 0, 0]; + tuningName = ''; + } else if (ModelUtils.staffNotesAreNotStringed(staff)) { + const staffTuning = + staff.index === 0 ? Tuning.getDefaultTuningFor(6) : Tuning.getDefaultTuningFor(5); + tuning = staffTuning!.tunings; + tuningName = staffTuning!.name; + } + } + this._tuningByStaff.set(staff, tuning); + + if (tuning.length > 0) { const tuningProperty = properties.addElement('Property'); tuningProperty.attributes.set('name', 'Tuning'); - tuningProperty.addElement('Pitches').innerText = staff.tuning.slice().reverse().join(' '); - tuningProperty.addElement('Label').setCData(staff.tuningName); - tuningProperty.addElement('LabelVisible').innerText = staff.tuningName ? 'true' : 'false'; + tuningProperty.addElement('Pitches').innerText = tuning.slice().reverse().join(' '); + tuningProperty.addElement('Label').setCData(tuningName); + tuningProperty.addElement('LabelVisible').innerText = tuningName ? 'true' : 'false'; tuningProperty.addElement('Flat'); - switch (staff.tuning.length) { + if (staff.isPercussion) { + tuningProperty.addElement('Instrument').innerText = 'Undefined'; + } else { + switch (tuning.length) { case 3: tuningProperty.addElement('Instrument').innerText = 'Shamisen'; break; @@ -1324,6 +1376,7 @@ export class GpifWriter { default: tuningProperty.addElement('Instrument').innerText = 'Guitar'; break; + } } } diff --git a/packages/alphatab/src/importer/Gp3To5Importer.ts b/packages/alphatab/src/importer/Gp3To5Importer.ts index d34b991cd..7ad528482 100644 --- a/packages/alphatab/src/importer/Gp3To5Importer.ts +++ b/packages/alphatab/src/importer/Gp3To5Importer.ts @@ -30,6 +30,7 @@ import { ModelUtils } from '@coderline/alphatab/model/ModelUtils'; import { Note } from '@coderline/alphatab/model/Note'; import { NoteAccidentalMode } from '@coderline/alphatab/model/NoteAccidentalMode'; import { Ottavia } from '@coderline/alphatab/model/Ottavia'; +import { PercussionMapper } from '@coderline/alphatab/model/PercussionMapper'; import { PickStroke } from '@coderline/alphatab/model/PickStroke'; import { PlaybackInformation } from '@coderline/alphatab/model/PlaybackInformation'; import { Rasgueado } from '@coderline/alphatab/model/Rasgueado'; @@ -1480,9 +1481,13 @@ export class Gp3To5Importer extends ScoreImporter { } if (bar.staff.isPercussion) { - newNote.percussionArticulation = Gp3To5Importer._gp5PercussionInstrumentMap.has(newNote.fret) + const midi = Gp3To5Importer._gp5PercussionInstrumentMap.has(newNote.fret) ? Gp3To5Importer._gp5PercussionInstrumentMap.get(newNote.fret)! : newNote.fret; + const knownArticulation = PercussionMapper.getArticulationById(midi); + if (knownArticulation !== null) { + newNote.percussionArticulation = bar.staff.track.getOrRegisterPercussionArticulation(knownArticulation); + } newNote.fret = Number.NaN; } if (swapAccidentals) { diff --git a/packages/alphatab/src/importer/GpifParser.ts b/packages/alphatab/src/importer/GpifParser.ts index eda3fee6e..624284d74 100644 --- a/packages/alphatab/src/importer/GpifParser.ts +++ b/packages/alphatab/src/importer/GpifParser.ts @@ -20,6 +20,7 @@ import { Duration } from '@coderline/alphatab/model/Duration'; import { DynamicValue } from '@coderline/alphatab/model/DynamicValue'; import { FadeType } from '@coderline/alphatab/model/FadeType'; import { Fermata, FermataType } from '@coderline/alphatab/model/Fermata'; +import { FingeringAssigner } from '@coderline/alphatab/model/FingeringAssigner'; import { Fingers } from '@coderline/alphatab/model/Fingers'; import { GolpeType } from '@coderline/alphatab/model/GolpeType'; import { GraceType } from '@coderline/alphatab/model/GraceType'; @@ -134,6 +135,9 @@ export class GpifParser { private _skipApplyLyrics: boolean = false; private _backingTrackPadding: number = 0; + /** Marks the input as a Guitar Pro 6 file. Also auto-detected from the GPIF header. */ + public isGp6: boolean = false; + private _doubleBars: Set = new Set(); private _keySignatures: Map = new Map< number, @@ -174,6 +178,9 @@ export class GpifParser { this._parseDom(dom); this._buildModel(); ModelUtils.consolidate(this.score); + if (this.isGp6) { + this._assignFingeringForGp6(); + } this.score.finish(settings); if (!this._skipApplyLyrics && this._lyricsByTrack.size > 0) { for (const [t, lyrics] of this._lyricsByTrack) { @@ -197,6 +204,16 @@ export class GpifParser { // parse all children for (const n of root.childElements()) { switch (n.localName) { + case 'GPVersion': + if (n.innerText === '6') { + this.isGp6 = true; + } + break; + case 'Encoding': + if (n.findChildElement('EncodingDescription')?.innerText === 'GP6') { + this.isGp6 = true; + } + break; case 'Score': this._parseScoreNode(n); break; @@ -2559,7 +2576,7 @@ export class GpifParser { note.addBendPoint(bendDestination); } - // map GP6 element and variation combos to midi numbers + // Temporary MIDI id; normalised to a track-local index in `_attachNoteToBeat`. if (element !== -1 && variation !== -1) { note.percussionArticulation = PercussionMapper.articulationFromElementVariation(element, variation); } @@ -2733,6 +2750,63 @@ export class GpifParser { if (this._tappedNotes.has(noteId)) { beat.tap = true; } + // Normalise `percussionArticulation` to the track-local index. + if (staff.isPercussion && note.percussionArticulation >= 0) { + const trackArticulations = staff.track.percussionArticulations; + let known: InstrumentArticulation | null = null; + if (note.percussionArticulation < trackArticulations.length) { + known = trackArticulations[note.percussionArticulation]; + } else { + known = PercussionMapper.getArticulationById(note.percussionArticulation); + } + if (known !== null) { + note.percussionArticulation = staff.track.getOrRegisterPercussionArticulation(known); + } + } + } + + private _assignFingeringForGp6(): void { + for (const track of this.score.tracks) { + for (const staff of track.staves) { + const isPercussion = staff.isPercussion; + const isPitchedOnly = + !isPercussion && + staff.stringTuning.tunings.length === 0 && + ModelUtils.staffNotesAreNotStringed(staff); + if (!isPercussion && !isPitchedOnly) { + continue; + } + + if (isPercussion) { + staff.stringTuning.tunings = [0, 0, 0, 0, 0, 0]; + } else { + const fallback = + staff.index === 0 ? Tuning.getDefaultTuningFor(6) : Tuning.getDefaultTuningFor(5); + if (fallback !== null) { + staff.stringTuning.tunings = fallback.tunings.slice(); + staff.stringTuning.name = fallback.name; + } + } + + const assignersByVoiceIndex = new Map(); + for (const bar of staff.bars) { + for (const voice of bar.voices) { + let assigner: FingeringAssigner | undefined = assignersByVoiceIndex.get(voice.index); + if (assigner === undefined) { + assigner = new FingeringAssigner( + staff.stringTuning.tunings, + staff.capo, + staff.transpositionPitch + ); + assignersByVoiceIndex.set(voice.index, assigner); + } + for (const beat of voice.beats) { + assigner.assign(beat); + } + } + } + } + } } private _buildModel(): void { diff --git a/packages/alphatab/src/importer/GpxImporter.ts b/packages/alphatab/src/importer/GpxImporter.ts index 7d180d7a5..421616ee4 100644 --- a/packages/alphatab/src/importer/GpxImporter.ts +++ b/packages/alphatab/src/importer/GpxImporter.ts @@ -63,6 +63,7 @@ export class GpxImporter extends ScoreImporter { // the score information as XML we need to parse. Logger.debug(this.name, 'Start Parsing score.gpif'); const gpifParser: GpifParser = new GpifParser(); + gpifParser.isGp6 = true; gpifParser.parseXml(xml, this.settings); Logger.debug(this.name, 'score.gpif parsed'); const score: Score = gpifParser.score; diff --git a/packages/alphatab/src/importer/MusicXmlImporter.ts b/packages/alphatab/src/importer/MusicXmlImporter.ts index 0217d0780..4513c75e2 100644 --- a/packages/alphatab/src/importer/MusicXmlImporter.ts +++ b/packages/alphatab/src/importer/MusicXmlImporter.ts @@ -2855,7 +2855,7 @@ export class MusicXmlImporter extends ScoreImporter { } else if (note.beat.voice.bar.staff.isPercussion) { const knownArticulation = PercussionMapper.getArticulationById(note.displayValue); if (knownArticulation) { - note.percussionArticulation = knownArticulation.id; + note.percussionArticulation = track.getOrRegisterPercussionArticulation(knownArticulation); } } } diff --git a/packages/alphatab/src/model/FingeringAssigner.ts b/packages/alphatab/src/model/FingeringAssigner.ts new file mode 100644 index 000000000..bf762e4f6 --- /dev/null +++ b/packages/alphatab/src/model/FingeringAssigner.ts @@ -0,0 +1,182 @@ +import type { Beat } from '@coderline/alphatab/model/Beat'; +import { PercussionMapper } from '@coderline/alphatab/model/PercussionMapper'; + +/** + * Cost-function weights for {@link FingeringAssigner}. Defaults tuned for + * six-string guitar. + * @internal + */ +export class FingeringOptions { + public preferredHandPosition: number = 5; + /** Negative = prefer open strings. */ + public openStringBonus: number = -1; + public highFretPenaltyWeight: number = 0.5; + public negativeFretPenaltyWeight: number = 3.0; + /** Soft; heavy weight prefers distinct strings but permits collisions + * for chords with more notes than strings. */ + public collisionPenalty: number = 100; + /** Negative = cluster chord notes on neighbouring strings. */ + public adjacentStringBonus: number = -1.5; + /** Negative = repeated pitches stay on the same string across beats. */ + public stringContinuityBonus: number = -0.75; + /** EWMA weight for the hand-position anchor: + * `hand = α·hand + (1−α)·newHand`. */ + public handPositionMomentum: number = 0.7; +} + +/** + * Assigns (string, fret) to a stream of beats via greedy hand-position + * hysteresis. One instance per (staff, voice); mutates notes in place. + * Not thread-safe. + * @internal + */ +export class FingeringAssigner { + private static readonly _maxStrings: number = 30; + + private readonly _tuning: number[]; + private readonly _capo: number; + private readonly _transpositionPitch: number; + private readonly _options: FingeringOptions; + + private _handPosition: number; + // string numbers are 1-indexed so 0 doubles as the "not yet seen" sentinel + private readonly _lastStringByMidi: Uint8Array; + private _sortedIdx: Int32Array; + + /** + * @param tuning High-to-low MIDI pitches (matches {@link Staff.tuning}). 1..30 entries. + */ + public constructor(tuning: number[], capo: number, transpositionPitch: number, options?: FingeringOptions) { + if (tuning.length < 1 || tuning.length > FingeringAssigner._maxStrings) { + throw new Error( + `FingeringAssigner requires 1..${FingeringAssigner._maxStrings} strings, got tuning.length=${tuning.length}` + ); + } + this._tuning = tuning; + this._capo = capo; + this._transpositionPitch = transpositionPitch; + this._options = options ?? new FingeringOptions(); + + this._handPosition = this._options.preferredHandPosition; + this._lastStringByMidi = new Uint8Array(128); + this._sortedIdx = new Int32Array(16); + } + + /** Reset the hand-position anchor and per-pitch continuity memory. */ + public reset(): void { + this._handPosition = this._options.preferredHandPosition; + this._lastStringByMidi.fill(0); + } + + /** Assigns `(string, fret)` to notes that don't already carry both. */ + public assign(beat: Beat): void { + const notes = beat.notes; + const K = notes.length; + if (K === 0) { + return; + } + + if (this._sortedIdx.length < K) { + this._sortedIdx = new Int32Array(K); + } + const sortedIdx = this._sortedIdx; + + let n = 0; + for (let i = 0; i < K; i++) { + const note = notes[i]; + if (note.isStringed) { + continue; + } + if (note.isPercussion) { + const art = PercussionMapper.getArticulation(note); + if (art !== null) { + if (Number.isNaN(note.string)) { + note.string = Math.max(1, Math.min(6, 7 - art.staffLine)); + } + if (Number.isNaN(note.fret)) { + note.fret = art.outputMidiNumber; + } + } + continue; + } + const tieOrigin = note.tieOrigin; + if (note.isTieDestination && tieOrigin !== null && tieOrigin.isStringed) { + note.string = tieOrigin.string; + note.fret = tieOrigin.fret; + continue; + } + let j = n; + const noteValue = note.realValue; + while (j > 0 && notes[sortedIdx[j - 1]].realValue > noteValue) { + sortedIdx[j] = sortedIdx[j - 1]; + j--; + } + sortedIdx[j] = i; + n++; + } + + if (n === 0) { + return; + } + + const N = this._tuning.length; + const opts = this._options; + let usedStrings = 0; + let newHand = -1; + + for (let k = 0; k < n; k++) { + const note = notes[sortedIdx[k]]; + const realValue = note.realValue; + const target = realValue + this._transpositionPitch; + const continuityString = realValue >= 0 && realValue < 128 ? this._lastStringByMidi[realValue] : 0; + + let bestString = 1; + let bestFret = 0; + let bestCost = Number.POSITIVE_INFINITY; + + for (let s = 1; s <= N; s++) { + const fret = target - (this._capo + this._tuning[N - s]); + const distanceCost = Math.abs(fret - this._handPosition); + const openBonus = fret === 0 ? opts.openStringBonus : 0; + const negFretPenalty = fret < 0 ? -fret * opts.negativeFretPenaltyWeight : 0; + const highFretPenalty = fret > 12 ? (fret - 12) * opts.highFretPenaltyWeight : 0; + const collisionCost = (usedStrings & (1 << (s - 1))) !== 0 ? opts.collisionPenalty : 0; + const leftUsed = s > 1 && (usedStrings & (1 << (s - 2))) !== 0; + const rightUsed = s < N && (usedStrings & (1 << s)) !== 0; + const adjacencyBonus = leftUsed || rightUsed ? opts.adjacentStringBonus : 0; + const continuityBonus = s === continuityString ? opts.stringContinuityBonus : 0; + + const cost = + distanceCost + + openBonus + + negFretPenalty + + highFretPenalty + + collisionCost + + adjacencyBonus + + continuityBonus; + + if (cost < bestCost) { + bestCost = cost; + bestString = s; + bestFret = fret; + } + } + + note.string = bestString; + note.fret = bestFret; + usedStrings |= 1 << (bestString - 1); + if (realValue >= 0 && realValue < 128) { + this._lastStringByMidi[realValue] = bestString; + } + + if (bestFret > 0 && (newHand < 0 || bestFret < newHand)) { + newHand = bestFret; + } + } + + if (newHand >= 0) { + const alpha = opts.handPositionMomentum; + this._handPosition = alpha * this._handPosition + (1 - alpha) * newHand; + } + } +} diff --git a/packages/alphatab/src/model/ModelUtils.ts b/packages/alphatab/src/model/ModelUtils.ts index 18e103bc0..64c93147c 100644 --- a/packages/alphatab/src/model/ModelUtils.ts +++ b/packages/alphatab/src/model/ModelUtils.ts @@ -1145,8 +1145,8 @@ export class ModelUtils { : ModelUtils._majorKeySignatureTonicDegrees[ksi]; } + /** True iff the staff's first note isn't stringed. Empty staves return false. */ public static staffNotesAreNotStringed(staff: Staff) { - // hunt for first actual note for (const bar of staff.bars) { for (const voice of bar.voices) { for (const beat of voice.beats) { @@ -1156,6 +1156,6 @@ export class ModelUtils { } } } - return true; + return false; } } diff --git a/packages/alphatab/src/model/Note.ts b/packages/alphatab/src/model/Note.ts index 9eee13391..ba1715d60 100644 --- a/packages/alphatab/src/model/Note.ts +++ b/packages/alphatab/src/model/Note.ts @@ -669,7 +669,8 @@ export class Note { } if (this.isPercussion) { - return this.percussionArticulation; + const art = PercussionMapper.getArticulation(this); + return art !== null ? art.outputMidiNumber : this.percussionArticulation; } if (this.isStringed) { return this.fret + this.stringTuning - transpositionPitch; diff --git a/packages/alphatab/src/model/Track.ts b/packages/alphatab/src/model/Track.ts index 0dc76bc6e..8c39dabec 100644 --- a/packages/alphatab/src/model/Track.ts +++ b/packages/alphatab/src/model/Track.ts @@ -158,6 +158,23 @@ export class Track { this.staves.push(staff); } + /** + * Returns the index of {@link articulation} in {@link percussionArticulations}, + * appending it (deduplicated by `uniqueId`) when not yet present. Callers store the + * returned index in {@link Note.percussionArticulation}. + */ + public getOrRegisterPercussionArticulation(articulation: InstrumentArticulation): number { + const uniqueId = articulation.uniqueId; + for (let i = 0; i < this.percussionArticulations.length; i++) { + if (this.percussionArticulations[i].uniqueId === uniqueId) { + return i; + } + } + const index = this.percussionArticulations.length; + this.percussionArticulations.push(articulation); + return index; + } + public finish(settings: Settings, sharedDataBag: Map | null = null): void { if (!this.shortName) { this.shortName = this.name; diff --git a/packages/alphatab/test/exporter/Gp7Exporter.test.ts b/packages/alphatab/test/exporter/Gp7Exporter.test.ts index d5b9ab2e2..86760ed5c 100644 --- a/packages/alphatab/test/exporter/Gp7Exporter.test.ts +++ b/packages/alphatab/test/exporter/Gp7Exporter.test.ts @@ -1,4 +1,3 @@ -import { describe, expect, it } from 'vitest'; import { Gp7Exporter } from '@coderline/alphatab/exporter/Gp7Exporter'; import { GpifInstrumentArticulation, @@ -19,6 +18,7 @@ import { XmlDocument } from '@coderline/alphatab/xml/XmlDocument'; import { ZipReader } from '@coderline/alphatab/zip/ZipReader'; import { ComparisonHelpers } from 'test/model/ComparisonHelpers'; import { TestPlatform } from 'test/TestPlatform'; +import { describe, expect, it } from 'vitest'; describe('Gp7ExporterTest', () => { async function loadScore(name: string): Promise { @@ -217,6 +217,194 @@ describe('Gp7ExporterTest', () => { await testRoundTripFolderEqual('guitarpro8', undefined, ['bendpoints', 'bendtype']); }); + // Regression: MusicXML using MuseScore's `staff*4+localVoice` convention + // produces bars whose bar.voices contains sparse voice slots (indices 0..8+). + // Prior to the fix, GpifWriter emitted one token per slot, producing + // -1 -1 -1 -1 5 -1 -1 -1 -1 — invalid GPIF (GP requires + // exactly 4 slots) that crashed Guitar Pro 8 and MuseScore. The writer must + // now always emit exactly 4 slots, place the non-empty voice inside 0..3, + // and skip empty voices so their beats don't leak as orphan nodes. + it('musicxml-sparse-voice-indices-produce-valid-gpif', () => { + const musicXml = ` + + + + Piano + + + + + 1 + 0 + + 2 + G2 + F4 + + + C5 + 4 + 1 + whole + 1 + + 4 + + C3 + 4 + 5 + whole + 2 + + + +`; + + const expected = ScoreLoader.loadScoreFromBytes(IOHelper.stringToBytes(musicXml)); + const exported = exportGp7(expected); + + const settings = new Settings(); + const zip = new ZipReader(ByteBuffer.fromBuffer(exported), settings.importer.maxDecodingBufferSize).read(); + const gpifData = zip.find(e => e.fileName === 'score.gpif')!.data; + const gpif = IOHelper.toString(gpifData, settings.importer.encoding); + const xml = new XmlDocument(); + xml.parse(gpif); + + // Every / must have exactly 4 space-separated tokens. + let barCount = 0; + for (const bar of xml.findChildElement('GPIF')!.findChildElement('Bars')!.childElements()) { + barCount++; + const voices = bar.findChildElement('Voices')!.innerText.trim().split(/\s+/); + expect(voices.length).toBe(4); + } + expect(barCount).toBeGreaterThan(0); + + // No orphan — every declared beat id must be referenced from + // some /. + const referencedBeatIds = new Set(); + for (const voice of xml.findChildElement('GPIF')!.findChildElement('Voices')!.childElements()) { + const beatsList = voice.findChildElement('Beats')!.innerText.trim(); + if (beatsList.length > 0) { + for (const id of beatsList.split(/\s+/)) { + referencedBeatIds.add(id); + } + } + } + for (const beat of xml.findChildElement('GPIF')!.findChildElement('Beats')!.childElements()) { + const id = beat.getAttribute('id'); + expect(referencedBeatIds.has(id)).toBe(true); + } + }); + + // GP7/8 defaults an unset to Int32.MinValue and playback breaks — + // piano notes must carry valid String+Fret consistent with the emitted Tuning. + it('musicxml-piano-notes-get-string-and-fret', () => { + const musicXml = ` + + + + Piano + + + + + 1 + 0 + + G2 + + + C5 + 1 + 1 + quarter + + + D5 + 1 + 1 + quarter + + + E5 + 1 + 1 + quarter + + + F5 + 1 + 1 + quarter + + + +`; + + const expected = ScoreLoader.loadScoreFromBytes(IOHelper.stringToBytes(musicXml)); + const exported = exportGp7(expected); + + const settings = new Settings(); + const zip = new ZipReader(ByteBuffer.fromBuffer(exported), settings.importer.maxDecodingBufferSize).read(); + const gpifData = zip.find(e => e.fileName === 'score.gpif')!.data; + const gpif = IOHelper.toString(gpifData, settings.importer.encoding); + const xml = new XmlDocument(); + xml.parse(gpif); + + // Read the exported tuning + capo from the first staff. + const track = xml.findChildElement('GPIF')!.findChildElement('Tracks')!.findChildElement('Track')!; + const staffProps = track.findChildElement('Staves')!.findChildElement('Staff')!.findChildElement('Properties')!; + let tuning: number[] = []; + let capo = 0; + for (const prop of staffProps.childElements()) { + const name = prop.getAttribute('name'); + if (name === 'Tuning') { + tuning = prop + .findChildElement('Pitches')! + .innerText.trim() + .split(/\s+/) + .map(s => Number.parseInt(s, 10)); + // is written high-to-low reversed (low-to-high). Un-reverse + // to get Staff.tuning's high-to-low convention. + tuning.reverse(); + } else if (name === 'CapoFret') { + capo = Number.parseInt(prop.findChildElement('Fret')!.innerText, 10); + } + } + expect(tuning.length).toBeGreaterThan(0); + + // Every note under the piano track must have String + Fret + Midi + // and satisfy the pitch identity. + let noteCount = 0; + for (const note of xml.findChildElement('GPIF')!.findChildElement('Notes')!.childElements()) { + noteCount++; + const props = note.findChildElement('Properties')!; + let str = Number.NaN; + let fret = Number.NaN; + let midi = Number.NaN; + for (const prop of props.childElements()) { + const name = prop.getAttribute('name'); + if (name === 'String') { + str = Number.parseInt(prop.findChildElement('String')!.innerText, 10) + 1; + } else if (name === 'Fret') { + fret = Number.parseInt(prop.findChildElement('Fret')!.innerText, 10); + } else if (name === 'Midi') { + midi = Number.parseInt(prop.findChildElement('Number')!.innerText, 10); + } + } + expect(Number.isNaN(str)).toBe(false); + expect(Number.isNaN(fret)).toBe(false); + expect(Number.isNaN(midi)).toBe(false); + // Guard against the "Int32.MinValue sentinel" scenario the fix + // exists to prevent. + expect(fret).toBeGreaterThan(-1000); + expect(fret).toBeLessThan(1000); + // Pitch identity: capo + tuning[N-string] + fret === midi + expect(capo + tuning[tuning.length - str] + fret).toBe(midi); + } + expect(noteCount).toBe(4); + }); + /** * This test generates the articulations code needed for the PercussionMapper. * To update the code there, run this test and copy the source code from the written file. @@ -404,7 +592,7 @@ describe('Gp7ExporterTest', () => { it('sound-mapper', async () => { const settings = new Settings(); const zip = new ZipReader( - ByteBuffer.fromBuffer(await TestPlatform.loadFile('test-data/exporter/articulations.gp')), + ByteBuffer.fromBuffer(await TestPlatform.loadFile('test-data/exporter/articulations.gp')), settings.importer.maxDecodingBufferSize ).read(); const gpifData = zip.find(e => e.fileName === 'score.gpif')!.data; @@ -476,9 +664,10 @@ describe('Gp7ExporterTest', () => { for (let i = 0; i < expectedElements.length; i++) { const expectedElement = expectedElements[i]; - expect(actualElements.length, `Element ${i} (${expectedElement.name}) missing in actual file`).toBeGreaterThan( - i - ); + expect( + actualElements.length, + `Element ${i} (${expectedElement.name}) missing in actual file` + ).toBeGreaterThan(i); const actualElement = actualElements[i]; expect(actualElement.name).toBe(expectedElement.name); @@ -494,32 +683,40 @@ describe('Gp7ExporterTest', () => { const actualArticulation = actualElement.articulations[j]; expect(actualArticulation.name).toBe(expectedArticulation.name); - expect(actualArticulation.staffLine, `Wrong staffline for articulation ${actualArticulation.name}`).toBe( - expectedArticulation.staffLine - ); - expect(actualArticulation.noteHeads.map(s => MusicFontSymbol[s]).join(' '), `Wrong noteHeads for articulation ${actualArticulation.name}`).toBe( - expectedArticulation.noteHeads.map(s => MusicFontSymbol[s]).join(' ') - ); - expect(MusicFontSymbol[actualArticulation.techniqueSymbol], `Wrong techniqueSymbol for articulation ${actualArticulation.name}`).toBe( - MusicFontSymbol[expectedArticulation.techniqueSymbol] - ); - expect(TechniqueSymbolPlacement[actualArticulation.techniqueSymbolPlacement], `Wrong techniqueSymbolPlacement for articulation ${actualArticulation.name}`).toBe( - TechniqueSymbolPlacement[expectedArticulation.techniqueSymbolPlacement] - ); - expect(actualArticulation.inputMidiNumbers.map(i => i.toString()).join(','), `Wrong inputMidiNumbers for articulation ${actualArticulation.name}`).toBe( - expectedArticulation.inputMidiNumbers.map(i => i.toString()).join(',') - ); - expect(actualArticulation.outputMidiNumber, `Wrong outputMidiNumber for articulation ${actualArticulation.name}`).toBe( - expectedArticulation.outputMidiNumber - ); - expect(actualArticulation.outputRSESound, `Wrong outputRSESound for articulation ${actualArticulation.name}`).toBe( - expectedArticulation.outputRSESound - ); + expect( + actualArticulation.staffLine, + `Wrong staffline for articulation ${actualArticulation.name}` + ).toBe(expectedArticulation.staffLine); + expect( + actualArticulation.noteHeads.map(s => MusicFontSymbol[s]).join(' '), + `Wrong noteHeads for articulation ${actualArticulation.name}` + ).toBe(expectedArticulation.noteHeads.map(s => MusicFontSymbol[s]).join(' ')); + expect( + MusicFontSymbol[actualArticulation.techniqueSymbol], + `Wrong techniqueSymbol for articulation ${actualArticulation.name}` + ).toBe(MusicFontSymbol[expectedArticulation.techniqueSymbol]); + expect( + TechniqueSymbolPlacement[actualArticulation.techniqueSymbolPlacement], + `Wrong techniqueSymbolPlacement for articulation ${actualArticulation.name}` + ).toBe(TechniqueSymbolPlacement[expectedArticulation.techniqueSymbolPlacement]); + expect( + actualArticulation.inputMidiNumbers.map(i => i.toString()).join(','), + `Wrong inputMidiNumbers for articulation ${actualArticulation.name}` + ).toBe(expectedArticulation.inputMidiNumbers.map(i => i.toString()).join(',')); + expect( + actualArticulation.outputMidiNumber, + `Wrong outputMidiNumber for articulation ${actualArticulation.name}` + ).toBe(expectedArticulation.outputMidiNumber); + expect( + actualArticulation.outputRSESound, + `Wrong outputRSESound for articulation ${actualArticulation.name}` + ).toBe(expectedArticulation.outputRSESound); } - expect(actualElement.articulations.length, `articulation length mismatch on element ${expectedElement.name}`).toBe( - expectedElement.articulations.length - ); + expect( + actualElement.articulations.length, + `articulation length mismatch on element ${expectedElement.name}` + ).toBe(expectedElement.articulations.length); } expect(actualInstrumentSet.elements.length).toBe(expectedInstrumentSet.elements.length); diff --git a/packages/alphatab/test/importer/MusicXmlImporter.test.ts b/packages/alphatab/test/importer/MusicXmlImporter.test.ts index c677909ae..9d9ebd9f1 100644 --- a/packages/alphatab/test/importer/MusicXmlImporter.test.ts +++ b/packages/alphatab/test/importer/MusicXmlImporter.test.ts @@ -286,15 +286,18 @@ describe('MusicXmlImporterTests', () => { it('percussion-articulation', async () => { const score = await MusicXmlImporterTestHelper.loadFile('test-data/musicxml4/percussion-articulation.xml'); const notes = score.tracks[0].staves[0].bars[0].voices[0].beats.flatMap(b => b.notes); + const trackArticulations = score.tracks[0].percussionArticulations; expect(notes).toHaveLength(2); expect(notes[0].displayValue).toBe(38); expect(notes[0].isPercussion).toBe(true); - expect(notes[0].percussionArticulation).toBe(38); + expect(notes[0].percussionArticulation).toBe(0); + expect(trackArticulations[0].outputMidiNumber).toBe(38); expect(notes[1].displayValue).toBe(49); expect(notes[1].isPercussion).toBe(true); - expect(notes[1].percussionArticulation).toBe(49); + expect(notes[1].percussionArticulation).toBe(1); + expect(trackArticulations[1].outputMidiNumber).toBe(49); }); it('percussion-instrument-vs-pitched', async () => { diff --git a/packages/alphatab/test/model/FingeringAssigner.test.ts b/packages/alphatab/test/model/FingeringAssigner.test.ts new file mode 100644 index 000000000..94855329e --- /dev/null +++ b/packages/alphatab/test/model/FingeringAssigner.test.ts @@ -0,0 +1,454 @@ +import { Bar } from '@coderline/alphatab/model/Bar'; +import { Beat } from '@coderline/alphatab/model/Beat'; +import { FingeringAssigner, FingeringOptions } from '@coderline/alphatab/model/FingeringAssigner'; +import { Note } from '@coderline/alphatab/model/Note'; +import { Staff } from '@coderline/alphatab/model/Staff'; +import { Track } from '@coderline/alphatab/model/Track'; +import { Voice } from '@coderline/alphatab/model/Voice'; +import { describe, expect, it } from 'vitest'; + +/** + * @record + * @internal + */ +interface BuildOptions { + transposition?: number; + percussionIndices?: number[]; + tuning?: number[]; + capo?: number; +} + +// Once assign() mutates note.string/fret, note.realValue routes through +// staff.tuning — so the same tuning passed to the assigner must live on staff. +function buildBeat(pitches: number[], opts?: BuildOptions): Beat { + const track = new Track(); + const staff = new Staff(); + staff.transpositionPitch = opts?.transposition ?? 0; + if (opts?.tuning) { + staff.stringTuning.tunings = opts.tuning.slice(); + } + staff.capo = opts?.capo ?? 0; + track.addStaff(staff); + const bar = new Bar(); + staff.addBar(bar); + const voice = new Voice(); + bar.addVoice(voice); + const beat = new Beat(); + voice.addBeat(beat); + const percussionIndices = opts?.percussionIndices ?? []; + for (let i = 0; i < pitches.length; i++) { + const midi = pitches[i]; + const note = new Note(); + if (percussionIndices.indexOf(i) >= 0) { + note.percussionArticulation = midi; + } else { + note.octave = Math.floor(midi / 12); + note.tone = midi - note.octave * 12; + } + beat.addNote(note); + } + return beat; +} + +// Standard tunings (high-to-low, matching Staff.tuning convention). +const GUITAR_6 = [64, 59, 55, 50, 45, 40]; +const BASS_5 = [43, 38, 33, 28, 23]; + +// Capture the MIDI value of each pre-assignment note. Percussion notes get -1. +function capturePitches(beat: Beat): number[] { + return beat.notes.map(n => (n.isPercussion ? -1 : n.realValue)); +} + +// Assert that stringPitch(s) + fret == originalMidi[i] + transposition for +// every assigned note. +function expectPitchIdentity(beat: Beat, tuning: number[], originalMidi: number[], capo = 0, transposition = 0) { + for (let i = 0; i < beat.notes.length; i++) { + const note = beat.notes[i]; + if (note.isPercussion) { + continue; + } + expect(Number.isNaN(note.string)).toBe(false); + expect(Number.isNaN(note.fret)).toBe(false); + const stringPitch = capo + tuning[tuning.length - note.string]; + expect(stringPitch + note.fret).toBe(originalMidi[i] + transposition); + } +} + +describe('FingeringAssignerTests', () => { + describe('A pitch identity', () => { + it('A1 single-note-guitar-range', () => { + const beat = buildBeat([60], { tuning: GUITAR_6 } as BuildOptions); + const midi = capturePitches(beat); + const a = new FingeringAssigner(GUITAR_6, 0, 0); + a.assign(beat); + expectPitchIdentity(beat, GUITAR_6, midi); + expect(beat.notes[0].fret).toBeGreaterThanOrEqual(0); + expect(beat.notes[0].string).toBeGreaterThanOrEqual(1); + expect(beat.notes[0].string).toBeLessThanOrEqual(6); + }); + + it('A2 chord-3-notes-guitar', () => { + const beat = buildBeat([55, 62, 67], { tuning: GUITAR_6 } as BuildOptions); + const midi = capturePitches(beat); + const a = new FingeringAssigner(GUITAR_6, 0, 0); + a.assign(beat); + expectPitchIdentity(beat, GUITAR_6, midi); + const strings = beat.notes.map(n => n.string); + const uniqStrings3: Map = new Map(); + for (const s of strings) { uniqStrings3.set(s, true); } + expect(uniqStrings3.size).toBe(3); + }); + + it('A3 chord-6-notes-guitar-open-tuning', () => { + const beat = buildBeat([40, 45, 50, 55, 59, 64], { tuning: GUITAR_6 } as BuildOptions); + const midi = capturePitches(beat); + const a = new FingeringAssigner(GUITAR_6, 0, 0); + a.assign(beat); + expectPitchIdentity(beat, GUITAR_6, midi); + for (const n of beat.notes) { + expect(n.fret).toBe(0); + } + const strings = beat.notes.map(n => n.string).sort(); + expect(strings).toEqual([1, 2, 3, 4, 5, 6]); + }); + + it('A4 capo-non-zero', () => { + const beat = buildBeat([63], { tuning: GUITAR_6, capo: 3 } as BuildOptions); + const midi = capturePitches(beat); + const a = new FingeringAssigner(GUITAR_6, 3, 0); + a.assign(beat); + expectPitchIdentity(beat, GUITAR_6, midi, 3); + }); + + it('A5 transposition-non-zero', () => { + const beat = buildBeat([60], { tuning: GUITAR_6, transposition: -12 } as BuildOptions); + const midi = capturePitches(beat); + const a = new FingeringAssigner(GUITAR_6, 0, -12); + a.assign(beat); + expectPitchIdentity(beat, GUITAR_6, midi, 0, -12); + }); + + it('A6 capo-and-transposition', () => { + const beat = buildBeat([60], { tuning: GUITAR_6, capo: 2, transposition: -12 } as BuildOptions); + const midi = capturePitches(beat); + const a = new FingeringAssigner(GUITAR_6, 2, -12); + a.assign(beat); + expectPitchIdentity(beat, GUITAR_6, midi, 2, -12); + }); + + it('A7 bass-5-string', () => { + const beat = buildBeat([40], { tuning: BASS_5 } as BuildOptions); + const midi = capturePitches(beat); + const a = new FingeringAssigner(BASS_5, 0, 0); + a.assign(beat); + expectPitchIdentity(beat, BASS_5, midi); + }); + }); + + describe('B chord voicing quality', () => { + it('B1 power-chord-clusters', () => { + const beat = buildBeat([55, 62, 67], { tuning: GUITAR_6 } as BuildOptions); + const a = new FingeringAssigner(GUITAR_6, 0, 0); + a.assign(beat); + const strings = beat.notes.map(n => n.string).sort(); + expect(strings[1] - strings[0]).toBe(1); + expect(strings[2] - strings[1]).toBe(1); + const frets = beat.notes.map(n => n.fret); + expect(Math.max(...frets) - Math.min(...frets)).toBeLessThanOrEqual(4); + }); + + it('B2 wide-piano-chord', () => { + const beat = buildBeat([48, 52, 55, 60], { tuning: GUITAR_6 } as BuildOptions); + const midi = capturePitches(beat); + const a = new FingeringAssigner(GUITAR_6, 0, 0); + a.assign(beat); + expectPitchIdentity(beat, GUITAR_6, midi); + const strings = beat.notes.map(n => n.string); + const uniqStrings4: Map = new Map(); + for (const s of strings) { uniqStrings4.set(s, true); } + expect(uniqStrings4.size).toBe(4); + const frets = beat.notes.map(n => n.fret); + expect(Math.max(...frets) - Math.min(...frets)).toBeLessThanOrEqual(7); + }); + + it('B3 chord-with-open-strings', () => { + const beat = buildBeat([40, 55, 64], { tuning: GUITAR_6 } as BuildOptions); + const midi = capturePitches(beat); + const a = new FingeringAssigner(GUITAR_6, 0, 0); + a.assign(beat); + expectPitchIdentity(beat, GUITAR_6, midi); + // MIDI 40 = string 1 open (E2). The lowest note in a chord where + // an open string is available at the natural position should land + // there — proves the openStringBonus works when the fret matches + // the natural open position. + const byMidi: Map = new Map(); + for (let i = 0; i < beat.notes.length; i++) { + byMidi.set(midi[i], beat.notes[i]); + } + expect(byMidi.get(40)!.fret).toBe(0); + expect(byMidi.get(40)!.string).toBe(1); + // The upper notes (55, 64) may land anywhere consistent with the + // pitch identity — the hand-position anchor pulls them to + // near-fret-5 positions rather than open strings, which is the + // expected behaviour of a strong distance-cost term. + }); + }); + + describe('C streaming state', () => { + it('C1 initial-hand-position', () => { + const beat = buildBeat([62], { tuning: GUITAR_6 } as BuildOptions); + const midi = capturePitches(beat); + const a = new FingeringAssigner(GUITAR_6, 0, 0); + a.assign(beat); + expectPitchIdentity(beat, GUITAR_6, midi); + // With preferredHandPosition=5, either (s=4, f=7) or (s=5, f=3) + // is equidistant from the hand — the tie-break is implementation + // detail. We assert the fret is within reach of the anchor. + expect(Math.abs(beat.notes[0].fret - 5)).toBeLessThanOrEqual(2); + }); + + it('C2 hysteresis-holds', () => { + const b1 = buildBeat([62], { tuning: GUITAR_6 } as BuildOptions); + const b2 = buildBeat([62], { tuning: GUITAR_6 } as BuildOptions); + const a = new FingeringAssigner(GUITAR_6, 0, 0); + a.assign(b1); + a.assign(b2); + expect(b1.notes[0].string).toBe(b2.notes[0].string); + expect(b1.notes[0].fret).toBe(b2.notes[0].fret); + }); + + it('C3 same-pitch-across-beats', () => { + const b1 = buildBeat([60], { tuning: GUITAR_6 } as BuildOptions); + const b2 = buildBeat([60], { tuning: GUITAR_6 } as BuildOptions); + const b3 = buildBeat([60], { tuning: GUITAR_6 } as BuildOptions); + const a = new FingeringAssigner(GUITAR_6, 0, 0); + a.assign(b1); + a.assign(b2); + a.assign(b3); + expect(b1.notes[0].string).toBe(b2.notes[0].string); + expect(b2.notes[0].string).toBe(b3.notes[0].string); + }); + + it('C4 scale-with-spike', () => { + const a = new FingeringAssigner(GUITAR_6, 0, 0); + const pre: Beat[] = []; + for (let i = 0; i < 4; i++) { + const b = buildBeat([50], { tuning: GUITAR_6 } as BuildOptions); + a.assign(b); + pre.push(b); + } + const spike = buildBeat([84], { tuning: GUITAR_6 } as BuildOptions); + a.assign(spike); + const post: Beat[] = []; + for (let i = 0; i < 4; i++) { + const b = buildBeat([50], { tuning: GUITAR_6 } as BuildOptions); + a.assign(b); + post.push(b); + } + // EWMA smoothing must keep the post-spike position closer to the + // pre-spike position than to the spike itself — i.e. the anchor + // is not permanently pinned by a single high note. + const preFret = pre[3].notes[0].fret; + const spikeFret = spike.notes[0].fret; + const postFret = post[3].notes[0].fret; + expect(Math.abs(postFret - preFret)).toBeLessThan(Math.abs(postFret - spikeFret)); + }); + + it('C5 reset-clears-state', () => { + const a = new FingeringAssigner(GUITAR_6, 0, 0); + a.assign(buildBeat([84], { tuning: GUITAR_6 } as BuildOptions)); + a.reset(); + const fresh = buildBeat([60], { tuning: GUITAR_6 } as BuildOptions); + a.assign(fresh); + + const control = new FingeringAssigner(GUITAR_6, 0, 0); + const controlBeat = buildBeat([60], { tuning: GUITAR_6 } as BuildOptions); + control.assign(controlBeat); + expect(fresh.notes[0].string).toBe(controlBeat.notes[0].string); + expect(fresh.notes[0].fret).toBe(controlBeat.notes[0].fret); + }); + + it('C6 open-only-chord-preserves-hand', () => { + const a = new FingeringAssigner(GUITAR_6, 0, 0); + a.assign(buildBeat([60, 64, 67], { tuning: GUITAR_6 } as BuildOptions)); + // All-open chord: none of these notes has a non-zero fret. Anchor + // must not slide back to preferredHandPosition. + a.assign(buildBeat([40, 45, 50, 55, 59, 64], { tuning: GUITAR_6 } as BuildOptions)); + const probe = buildBeat([62], { tuning: GUITAR_6 } as BuildOptions); + a.assign(probe); + // Following note reflects an elevated hand position (not near 3). + expect(probe.notes[0].fret).toBeGreaterThanOrEqual(3); + }); + }); + + describe('D ties', () => { + it('D1 tie-destination-inherits', () => { + const b1 = buildBeat([60], { tuning: GUITAR_6 } as BuildOptions); + const a = new FingeringAssigner(GUITAR_6, 0, 0); + a.assign(b1); + const origin = b1.notes[0]; + const originString = origin.string; + const originFret = origin.fret; + + // Push hand position elsewhere so a fresh greedy would differ. + a.assign(buildBeat([84], { tuning: GUITAR_6 } as BuildOptions)); + + const b2 = buildBeat([60], { tuning: GUITAR_6 } as BuildOptions); + const dest = b2.notes[0]; + dest.tieOrigin = origin; + dest.isTieDestination = true; + a.assign(b2); + expect(dest.string).toBe(originString); + expect(dest.fret).toBe(originFret); + }); + + it('D2 tie-destination-without-stringed-origin', () => { + // Origin is a piano note (no string/fret) — normal assignment path. + const originBeat = buildBeat([60], { tuning: GUITAR_6 } as BuildOptions); + const origin = originBeat.notes[0]; + expect(origin.isStringed).toBe(false); + + const b2 = buildBeat([60], { tuning: GUITAR_6 } as BuildOptions); + const midi = capturePitches(b2); + const dest = b2.notes[0]; + dest.tieOrigin = origin; + dest.isTieDestination = true; + + const a = new FingeringAssigner(GUITAR_6, 0, 0); + a.assign(b2); + expectPitchIdentity(b2, GUITAR_6, midi); + }); + }); + + describe('E edge cases', () => { + it('E1 empty-beat', () => { + const beat = buildBeat([], { tuning: GUITAR_6 } as BuildOptions); + const a = new FingeringAssigner(GUITAR_6, 0, 0); + a.assign(beat); + expect(beat.notes.length).toBe(0); + }); + + // Percussion mapping: fret = articulation MIDI, string derived from + // articulation.staffLine via clamp(7 - staffLine, 1, 6). + // Snare (id 38, staffLine 3) → string 4, fret 38. + // Charley closed (id 42, staffLine -1) → string 6 (clamp), fret 42. + // Kick Drum (id 36, staffLine 7) → string 1 (clamp), fret 36. + it('E2 all-percussion-beat', () => { + const beat = buildBeat([38, 42], { tuning: [0, 0, 0, 0, 0, 0], percussionIndices: [0, 1] } as BuildOptions); + const a = new FingeringAssigner([0, 0, 0, 0, 0, 0], 0, 0); + a.assign(beat); + expect(beat.notes[0].string).toBe(4); + expect(beat.notes[0].fret).toBe(38); + expect(beat.notes[1].string).toBe(6); + expect(beat.notes[1].fret).toBe(42); + }); + + it('E3 mixed-percussion-pitched', () => { + const beat = buildBeat([38, 60], { tuning: GUITAR_6, percussionIndices: [0] } as BuildOptions); + const midi = capturePitches(beat); + const a = new FingeringAssigner(GUITAR_6, 0, 0); + a.assign(beat); + expect(beat.notes[0].string).toBe(4); + expect(beat.notes[0].fret).toBe(38); + expect(Number.isNaN(beat.notes[1].string)).toBe(false); + const stringPitch = GUITAR_6[GUITAR_6.length - beat.notes[1].string]; + expect(stringPitch + beat.notes[1].fret).toBe(midi[1]); + }); + + it('E3b percussion-kick-lands-on-bottom-string', () => { + const beat = buildBeat([36], { tuning: [0, 0, 0, 0, 0, 0], percussionIndices: [0] } as BuildOptions); + const a = new FingeringAssigner([0, 0, 0, 0, 0, 0], 0, 0); + a.assign(beat); + expect(beat.notes[0].string).toBe(1); + expect(beat.notes[0].fret).toBe(36); + }); + + it('E4 pitch-below-range', () => { + const beat = buildBeat([20], { tuning: GUITAR_6 } as BuildOptions); + const midi = capturePitches(beat); + const a = new FingeringAssigner(GUITAR_6, 0, 0); + a.assign(beat); + expectPitchIdentity(beat, GUITAR_6, midi); + expect(beat.notes[0].fret).toBeLessThan(0); + }); + + it('E5 pitch-above-range', () => { + const beat = buildBeat([120], { tuning: GUITAR_6 } as BuildOptions); + const midi = capturePitches(beat); + const a = new FingeringAssigner(GUITAR_6, 0, 0); + a.assign(beat); + expectPitchIdentity(beat, GUITAR_6, midi); + expect(beat.notes[0].fret).toBeGreaterThan(24); + }); + + it('E6 chord-overflow-collides', () => { + const beat = buildBeat([40, 45, 50, 55, 59, 64, 67, 72], { tuning: GUITAR_6 } as BuildOptions); + const a = new FingeringAssigner(GUITAR_6, 0, 0); + a.assign(beat); + for (const n of beat.notes) { + expect(Number.isNaN(n.string)).toBe(false); + expect(Number.isNaN(n.fret)).toBe(false); + } + const uniqKeys: Map = new Map(); + for (const n of beat.notes) { uniqKeys.set(`${n.string},${n.fret}`, true); } + expect(uniqKeys.size).toBe(beat.notes.length); + }); + + it('E7 sortedidx-buffer-growth', () => { + const pitches: number[] = []; + for (let i = 0; i < 20; i++) { + pitches.push(40 + i); + } + const beat = buildBeat(pitches, { tuning: GUITAR_6 } as BuildOptions); + const a = new FingeringAssigner(GUITAR_6, 0, 0); + a.assign(beat); + for (const n of beat.notes) { + expect(Number.isNaN(n.string)).toBe(false); + expect(Number.isNaN(n.fret)).toBe(false); + } + }); + }); + + describe('F constructor validation', () => { + it('F1 tuning-empty', () => { + expect(() => new FingeringAssigner([], 0, 0)).toThrow(); + }); + + it('F2 tuning-oversize', () => { + const oversized = new Array(31).fill(40); + expect(() => new FingeringAssigner(oversized, 0, 0)).toThrow(); + }); + + it('F3 tuning-min-boundary', () => { + const a = new FingeringAssigner([40], 0, 0); + const beat = buildBeat([40], { tuning: [40] } as BuildOptions); + a.assign(beat); + expect(beat.notes[0].string).toBe(1); + expect(beat.notes[0].fret).toBe(0); + }); + + it('F4 tuning-max-boundary', () => { + const wide: number[] = []; + for (let i = 0; i < 30; i++) { + wide.push(60 - i); + } + const a = new FingeringAssigner(wide, 0, 0); + const beat = buildBeat([50], { tuning: wide } as BuildOptions); + a.assign(beat); + expect(Number.isNaN(beat.notes[0].string)).toBe(false); + expect(Number.isNaN(beat.notes[0].fret)).toBe(false); + }); + + it('F5 options-override-openStringBonus', () => { + // With a very negative openStringBonus, open E4 (string 6 fret 0) + // should beat the default choice for MIDI 64. + const opts = new FingeringOptions(); + opts.openStringBonus = -20; + const a = new FingeringAssigner(GUITAR_6, 0, 0, opts); + const beat = buildBeat([64], { tuning: GUITAR_6 } as BuildOptions); + a.assign(beat); + expect(beat.notes[0].fret).toBe(0); + expect(beat.notes[0].string).toBe(6); + }); + }); +}); diff --git a/packages/csharp/src/AlphaTab.Test/Test/Globals.cs b/packages/csharp/src/AlphaTab.Test/Test/Globals.cs index 2b6923e31..06968ea81 100644 --- a/packages/csharp/src/AlphaTab.Test/Test/Globals.cs +++ b/packages/csharp/src/AlphaTab.Test/Test/Globals.cs @@ -398,6 +398,30 @@ public void ToEqual(object? expected, string? message = null) var expectedType = expected.GetType(); var actualType = _actual.GetType(); + // Sequences compare element-wise, ignoring capacity and other implementation details. + if (expected is System.Collections.IEnumerable expectedSeq && _actual is System.Collections.IEnumerable actualSeq) + { + var e = expectedSeq.GetEnumerator(); + var a = actualSeq.GetEnumerator(); + var i = 0; + while (true) + { + var eNext = e.MoveNext(); + var aNext = a.MoveNext(); + if (!eNext && !aNext) + { + return; + } + if (eNext != aNext) + { + Assert.Fail(message ?? _message ?? $"Sequence length mismatch at index {i}"); + return; + } + Assert.AreEqual(e.Current, a.Current, message ?? _message ?? $"Element {i}"); + i++; + } + } + if (expectedType == actualType) { Assert.AreEqual(expected, _actual, message ?? _message); @@ -430,6 +454,11 @@ public void ToThrow(Type expected) Throw(expected); } + public void ToThrow() + { + Throw(typeof(Exception)); + } + public void Ok() { Assert.AreNotEqual(default!, _actual, _message); diff --git a/packages/csharp/src/AlphaTab/Core/EcmaScript/Math.cs b/packages/csharp/src/AlphaTab/Core/EcmaScript/Math.cs index 28726aa83..d681182c5 100644 --- a/packages/csharp/src/AlphaTab/Core/EcmaScript/Math.cs +++ b/packages/csharp/src/AlphaTab/Core/EcmaScript/Math.cs @@ -35,6 +35,11 @@ public static double Min(params double[] items) return items.Min(); } + public static double Min(IEnumerable items) + { + return items.Min(); + } + public static double Max(double a, double b) { return System.Math.Max(a, b); diff --git a/packages/csharp/src/AlphaTab/Core/EcmaScript/Uint8Array.cs b/packages/csharp/src/AlphaTab/Core/EcmaScript/Uint8Array.cs index 5ecb1caf4..0ee7fac4e 100644 --- a/packages/csharp/src/AlphaTab/Core/EcmaScript/Uint8Array.cs +++ b/packages/csharp/src/AlphaTab/Core/EcmaScript/Uint8Array.cs @@ -107,4 +107,22 @@ public void Reverse() (int)Length ); } + + public void Fill(double value) + { + var start = (int)ByteOffset; + var length = (int)Length; + if (value == 0) + { + System.Array.Clear(Buffer.Raw, start, length); + } + else + { + var b = (byte)value; + for (var i = 0; i < length; i++) + { + Buffer.Raw[start + i] = b; + } + } + } } diff --git a/packages/csharp/src/AlphaTab/Core/TypeHelper.cs b/packages/csharp/src/AlphaTab/Core/TypeHelper.cs index 14b9d0ef8..e449d83c9 100644 --- a/packages/csharp/src/AlphaTab/Core/TypeHelper.cs +++ b/packages/csharp/src/AlphaTab/Core/TypeHelper.cs @@ -296,7 +296,7 @@ int Compare(T a, T b) return data; } - public static void Sort(this IList data) + public static IList Sort(this IList data) { switch (data) { @@ -310,6 +310,7 @@ public static void Sort(this IList data) throw new NotSupportedException("Cannot sort list of type " + data.GetType().FullName); } + return data; } [MethodImpl(MethodImplOptions.AggressiveInlining)] 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 7fd70ece2..4a627859f 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 @@ -125,8 +125,9 @@ public class DoubleList : IDoubleIterable { return DoubleList(copy, copy.size) } - public fun sort() { + public fun sort(): DoubleList { _items.sort(0, _size) + return this } internal fun sortDescending() { diff --git a/packages/kotlin/src/android/src/main/java/alphaTab/core/Globals.kt b/packages/kotlin/src/android/src/main/java/alphaTab/core/Globals.kt index af13b0813..57cc8494e 100644 --- a/packages/kotlin/src/android/src/main/java/alphaTab/core/Globals.kt +++ b/packages/kotlin/src/android/src/main/java/alphaTab/core/Globals.kt @@ -58,6 +58,10 @@ internal inline fun String.splitBy(separator: String): List { return List(this.split(separator)) } +internal fun String.splitBy(pattern: RegExp): List { + return pattern.split(this) +} + internal inline fun String.replace(pattern: RegExp, replacement: String): String { return pattern.replace(this, replacement) } diff --git a/packages/kotlin/src/android/src/main/java/alphaTab/core/ecmaScript/Int32Array.kt b/packages/kotlin/src/android/src/main/java/alphaTab/core/ecmaScript/Int32Array.kt index 87db8d675..d55ecc911 100644 --- a/packages/kotlin/src/android/src/main/java/alphaTab/core/ecmaScript/Int32Array.kt +++ b/packages/kotlin/src/android/src/main/java/alphaTab/core/ecmaScript/Int32Array.kt @@ -47,6 +47,10 @@ internal class Int32Array : Iterable { _data.fill(i) } + public fun fill(i: Double) { + _data.fill(i.toInt()) + } + override fun iterator(): IntIterator { return ArrayIntIterator(this) } diff --git a/packages/kotlin/src/android/src/main/java/alphaTab/core/ecmaScript/Math.kt b/packages/kotlin/src/android/src/main/java/alphaTab/core/ecmaScript/Math.kt index 23d1a4e43..0b985f738 100644 --- a/packages/kotlin/src/android/src/main/java/alphaTab/core/ecmaScript/Math.kt +++ b/packages/kotlin/src/android/src/main/java/alphaTab/core/ecmaScript/Math.kt @@ -79,6 +79,10 @@ internal class Math { return v.max() } + public fun min(vararg v: Double): Double { + return v.min() + } + public fun random(): Double { return kotlin.random.Random.nextDouble() } diff --git a/packages/kotlin/src/android/src/main/java/alphaTab/core/ecmaScript/RegExp.kt b/packages/kotlin/src/android/src/main/java/alphaTab/core/ecmaScript/RegExp.kt index 2441f31b9..e468b9648 100644 --- a/packages/kotlin/src/android/src/main/java/alphaTab/core/ecmaScript/RegExp.kt +++ b/packages/kotlin/src/android/src/main/java/alphaTab/core/ecmaScript/RegExp.kt @@ -46,6 +46,10 @@ internal class RegExp { return _regex.matcher(s).find() } + public fun split(s: String): alphaTab.collections.List { + return alphaTab.collections.List(_regex.split(s).toList()) + } + public fun replace(s: String, replacement: String): String { return if (_global) _regex.matcher(s).replaceAll(replacement) diff --git a/packages/kotlin/src/android/src/main/java/alphaTab/core/ecmaScript/Uint8Array.kt b/packages/kotlin/src/android/src/main/java/alphaTab/core/ecmaScript/Uint8Array.kt index 20c63782e..a61fe3e91 100644 --- a/packages/kotlin/src/android/src/main/java/alphaTab/core/ecmaScript/Uint8Array.kt +++ b/packages/kotlin/src/android/src/main/java/alphaTab/core/ecmaScript/Uint8Array.kt @@ -75,4 +75,9 @@ public class Uint8Array : Iterable { this.byteOffset + startByte, endByte - startByte) } + + public fun fill(value: Double) { + val start = byteOffset.toInt() + buffer.fill(value.toInt().toUByte(), start, start + length.toInt()) + } } 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 b2c09a588..23a024f29 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 @@ -311,6 +311,26 @@ class Expector(private val actual: T, private val message: String? = null) { } fun toEqual(expected: Any?, message: String? = null) { + val a = actual + // Sequences compare element-wise; runtime List types don't override equals. + if (a is Iterable<*> && expected is Iterable<*>) { + val ai = a.iterator() + val ei = expected.iterator() + var i = 0 + while (true) { + val aHas = ai.hasNext() + val eHas = ei.hasNext() + if (!aHas && !eHas) { + return + } + if (aHas != eHas) { + Assert.fail(message ?: this.message ?: "Sequence length mismatch at index $i") + return + } + Assert.assertEquals(message ?: this.message ?: "Element $i", ei.next(), ai.next()) + i++ + } + } equal(expected, message) } @@ -376,6 +396,10 @@ class Expector(private val actual: T, private val message: String? = null) { `throw`(expected) } + fun toThrow() { + `throw`(Throwable::class) + } + fun `throw`(expected: KClass) { val actual = actual if (actual is Function0<*>) {