diff --git a/README.md b/README.md index d5a93922b5..6864d71013 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ **Music notation, rendered as a first-class SDK.** -alphaTab renders interactive sheet music and guitar tablature from Guitar Pro, MusicXML, Capella and alphaTex files, and plays it back with a built-in synthesizer or in sync with your own audio and video. One TypeScript core ships for the web, .NET and Android, with no runtime dependencies and TypeScript types included. Open source since 2010. +alphaTab renders interactive sheet music and guitar tablature from Guitar Pro, MusicXML, Capella, TablEdit and alphaTex files, and plays it back with a built-in synthesizer or in sync with your own audio and video. One TypeScript core ships for the web, .NET and Android, with no runtime dependencies and TypeScript types included. Open source since 2010. [![npm](https://img.shields.io/npm/v/@coderline/alphatab?label=npm)](https://www.npmjs.com/package/@coderline/alphatab) [![npm downloads](https://img.shields.io/npm/dm/@coderline/alphatab?label=downloads)](https://www.npmjs.com/package/@coderline/alphatab) @@ -29,7 +29,7 @@ alphaTab renders interactive sheet music and guitar tablature from Guitar Pro, M | | Formats | |---|---| -| **Reads** | Guitar Pro 3–8 (`.gp3` `.gp4` `.gp5` `.gpx` `.gp`), MusicXML (`.xml` `.musicxml` `.mxl`), Capella (`.capx`), [alphaTex](https://alphatab.net/docs/alphatex/introduction) | +| **Reads** | Guitar Pro 3–8 (`.gp3` `.gp4` `.gp5` `.gpx` `.gp`), MusicXML (`.xml` `.musicxml` `.mxl`), Capella (`.capx`), TablEdit (`.tef`), [alphaTex](https://alphatab.net/docs/alphatex/introduction) | | **Writes** | Guitar Pro 7+ (`.gp`), alphaTex, MIDI, audio (PCM samples) | ## Quick start diff --git a/packages/alphatab/package.json b/packages/alphatab/package.json index 41a5e733a6..9513a36c7a 100644 --- a/packages/alphatab/package.json +++ b/packages/alphatab/package.json @@ -1,7 +1,7 @@ { "name": "@coderline/alphatab", "version": "1.10.0", - "description": "Render and play interactive sheet music and guitar tablature from Guitar Pro, MusicXML, Capella and alphaTex, in the browser, Node.js, .NET and Android.", + "description": "Render and play interactive sheet music and guitar tablature from Guitar Pro, MusicXML, Capella, TablEdit and alphaTex, in the browser, Node.js, .NET and Android.", "keywords": [ "music-notation", "sheet-music", diff --git a/packages/alphatab/src/Environment.ts b/packages/alphatab/src/Environment.ts index fe6bceb03d..9a32a300ae 100644 --- a/packages/alphatab/src/Environment.ts +++ b/packages/alphatab/src/Environment.ts @@ -7,6 +7,7 @@ import { Gp7To8Importer } from '@coderline/alphatab/importer/Gp7To8Importer'; import { GpxImporter } from '@coderline/alphatab/importer/GpxImporter'; import { MusicXmlImporter } from '@coderline/alphatab/importer/MusicXmlImporter'; import type { ScoreImporter } from '@coderline/alphatab/importer/ScoreImporter'; +import { TablEditImporter } from '@coderline/alphatab/importer/TablEditImporter'; import { LayoutMode } from '@coderline/alphatab/LayoutMode'; import { Logger } from '@coderline/alphatab/Logger'; import type { Font } from '@coderline/alphatab/model/Font'; @@ -22,9 +23,7 @@ import { SkiaCanvas } from '@coderline/alphatab/platform/skia/SkiaCanvas'; import { CssFontSvgCanvas } from '@coderline/alphatab/platform/svg/CssFontSvgCanvas'; import { AlphaSynthWebWorker } from '@coderline/alphatab/platform/worker/AlphaSynthWebWorker'; import { AlphaTabWebWorker } from '@coderline/alphatab/platform/worker/AlphaTabWebWorker'; -import type { - IAlphaTabWorkerGlobalScope -} from '@coderline/alphatab/platform/worker/AlphaTabWorkerProtocol'; +import type { IAlphaTabWorkerGlobalScope } from '@coderline/alphatab/platform/worker/AlphaTabWorkerProtocol'; import { type BarRendererFactory, EffectBandMode } from '@coderline/alphatab/rendering/BarRendererFactory'; import { alternateEndingsEffectInfo } from '@coderline/alphatab/rendering/effects/AlternateEndingsEffectInfo'; import { barNumberEffectInfo } from '@coderline/alphatab/rendering/effects/BarNumberEffectInfo'; @@ -35,6 +34,10 @@ import { chordsEffectInfo } from '@coderline/alphatab/rendering/effects/ChordsEf import { crescendoEffectInfo } from '@coderline/alphatab/rendering/effects/CrescendoEffectInfo'; import { directionsEffectInfo } from '@coderline/alphatab/rendering/effects/DirectionsEffectInfo'; import { dynamicsEffectInfo } from '@coderline/alphatab/rendering/effects/DynamicsEffectInfo'; +import { + hammerPullLabelEffectInfo, + slideLabelEffectInfo +} from '@coderline/alphatab/rendering/effects/EffectSlurLabelEffectInfo'; import { fadeEffectInfo } from '@coderline/alphatab/rendering/effects/FadeEffectInfo'; import { fermataEffectInfo } from '@coderline/alphatab/rendering/effects/FermataEffectInfo'; import { fingeringEffectInfo } from '@coderline/alphatab/rendering/effects/FingeringEffectInfo'; @@ -58,7 +61,6 @@ import { createSlightNoteVibratoEffectInfo } from '@coderline/alphatab/rendering import { sustainPedalEffectInfo } from '@coderline/alphatab/rendering/effects/SustainPedalEffectInfo'; import { tabWhammyEffectInfo } from '@coderline/alphatab/rendering/effects/TabWhammyEffectInfo'; import { tapEffectInfo } from '@coderline/alphatab/rendering/effects/TapEffectInfo'; -import { hammerPullLabelEffectInfo, slideLabelEffectInfo } from '@coderline/alphatab/rendering/effects/EffectSlurLabelEffectInfo'; import { tempoEffectInfo } from '@coderline/alphatab/rendering/effects/TempoEffectInfo'; import { textEffectInfo } from '@coderline/alphatab/rendering/effects/TextEffectInfo'; import { trillEffectInfo } from '@coderline/alphatab/rendering/effects/TrillEffectInfo'; @@ -404,6 +406,7 @@ export class Environment { new Gp7To8Importer(), new MusicXmlImporter(), new CapellaImporter(), + new TablEditImporter(), new AlphaTexImporter() ]; } diff --git a/packages/alphatab/src/importer/TablEditImporter.ts b/packages/alphatab/src/importer/TablEditImporter.ts new file mode 100644 index 0000000000..1e1d4c52fb --- /dev/null +++ b/packages/alphatab/src/importer/TablEditImporter.ts @@ -0,0 +1,466 @@ +import { FormatError } from '@coderline/alphatab/FormatError'; +import { ScoreImporter } from '@coderline/alphatab/importer/ScoreImporter'; +import { TefBarBuilder, TefTrackInfo } from '@coderline/alphatab/importer/tablEdit/TefBarBuilder'; +import { TefBinary } from '@coderline/alphatab/importer/tablEdit/TefBinary'; +import { TefCell } from '@coderline/alphatab/importer/tablEdit/TefCell'; +import { + TefReadingGuide, + TefReadingList, + TefReadingListItem +} from '@coderline/alphatab/importer/tablEdit/TefReadingList'; +import { UnsupportedFormatError } from '@coderline/alphatab/importer/UnsupportedFormatError'; +import { Chord } from '@coderline/alphatab/model/Chord'; +import { Clef } from '@coderline/alphatab/model/Clef'; +import { Duration } from '@coderline/alphatab/model/Duration'; +import type { KeySignature } from '@coderline/alphatab/model/KeySignature'; +import { KeySignatureType } from '@coderline/alphatab/model/KeySignatureType'; +import { BeamingRules, MasterBar } from '@coderline/alphatab/model/MasterBar'; +import { ModelUtils } from '@coderline/alphatab/model/ModelUtils'; +import { Score } from '@coderline/alphatab/model/Score'; +import type { Staff } from '@coderline/alphatab/model/Staff'; +import { Track } from '@coderline/alphatab/model/Track'; + +/** + * This ScoreImporter can read TablEdit (.tef) files of TablEdit 3 (formats 10 to 12). + * @internal + */ +export class TablEditImporter extends ScoreImporter { + private static readonly _headerSize = 256; + private static readonly _firstSupportedFormat = 10; + private static readonly _protectedFormat = 12; + private static readonly _lastSupportedFormat = 12; + private static readonly _wideMeasureRecordHeader = 16; + private static readonly _maxMeasureCount = 10000; + private static readonly _maxInstrumentCount = 64; + private static readonly _maxStringCount = 12; + private static readonly _maxCellCount = 1000000; + private static readonly _cellSize = 12; + private static readonly _drumFlag = 0x80; + private static readonly _pickupFlag = 0x08; + private static readonly _minorFlag = 0x20; + private static readonly _freeBarFlags = 0x06; + private static readonly _halfBarBeamingFlag = 0x04; + private static readonly _maxTextCount = 4096; + private static readonly _maxReadingListCount = 4096; + private static readonly _maxChordCount = 4096; + private static readonly _readingListRecordSize = 0x20; + private static readonly _chordStringBytes = 14; + private static readonly _bankMsbFactor = 128; + private static readonly _letRingOption = 0x0200; + private static readonly _nameOffset = 32; + private static readonly _nameLength = 36; + private static readonly _abbreviationOffset = 26; + private static readonly _abbreviationMark = 0x5e; + private static readonly _cellEnd = 0xffffffff; + /** + * Older files may carry a stale protection code, which TablEdit ignores. + */ + private static readonly _protectionVersion = 0x030400b6; + + private _data!: TefBinary; + private _score!: Score; + private _cells: TefCell[] = []; + private _lyrics: string = ''; + private _readingList: TefReadingListItem[] = []; + private _measureStarts: number[] = []; + private _measureSizes: number[] = []; + private _length: number = 0; + private _tracks: TefTrackInfo[] = []; + private _totalStrings: number = 0; + private _pickupBars: Set = new Set(); + private _minorBars: Set = new Set(); + private _keys: number[] = []; + + public get name(): string { + return 'TablEdit'; + } + + public readScore(): Score { + this._data = new TefBinary(this.data.readAll()); + const format = this._detectFormat(); + if (format < TablEditImporter._firstSupportedFormat) { + throw new FormatError( + 'TablEdit files saved before TablEdit 3 are not supported, open the file in TablEdit 3 and save it again' + ); + } + if (format > TablEditImporter._lastSupportedFormat) { + throw new FormatError(`TablEdit file format ${format} is newer than the supported format`); + } + const d = this._data; + const version = d.u16(2) * 0x10000 + d.u16(4); + const isProtected = version >= TablEditImporter._protectionVersion && d.u32(16) !== 0; + if (isProtected || format === TablEditImporter._protectedFormat) { + throw new FormatError('Protected TablEdit files are not supported'); + } + + this._score = new Score(); + this._readInformation(); + this._readMeasures(d.u32(92)); + this._readInstruments(d.u32(96)); + this._cells = this._readContents(d.u32(60)); + const builder = new TefBarBuilder(this._tracks, this._measureStarts, this._measureSizes); + builder.initialTempo = d.u16(6) > 0 ? d.u16(6) : 120; + builder.initialSyncopation = d.s16(12); + builder.texts = this._readTexts(d.u32(84)); + builder.chords = this._readChords(d.u32(88)); + builder.version = version; + this._lyrics = this._readString(d.u32(80)); + this._readingList = this._readReadingList(d.u32(128)); + builder.pickupBars = this._pickupBars; + builder.build(this._cells); + builder.applyLyrics(this._lyrics); + + for (const info of this._tracks) { + const bars = info.track.staves[0].bars; + for (let b = 0; b < bars.length && b < this._keys.length; b++) { + bars[b].keySignature = TablEditImporter._writtenKey(this._keys[b], info.keyShift); + bars[b].keySignatureType = this._minorBars.has(b) ? KeySignatureType.Minor : KeySignatureType.Major; + } + } + + const readingList = this._readingList; + TefReadingList.apply( + this._score, + readingList, + readingList.length > 0 ? [] : TablEditImporter._readGuides(this._cells, builder) + ); + + ModelUtils.consolidate(this._score); + this._score.finish(this.settings); + this._score.rebuildRepeatGroups(); + TefReadingList.verify(this._score, readingList); + return this._score; + } + + /** + * Returns the format version of a file that looks like a TablEdit file. + * Throws an {@link UnsupportedFormatError} for anything else. + */ + private _detectFormat(): number { + if (this._data.length < TablEditImporter._headerSize) { + throw new UnsupportedFormatError('Data too short for a TablEdit file'); + } + const numerator = this._data.u16(202); + const denominator = this._data.u8(204); + const format = this._data.u8(205); + const isPowerOfTwo = denominator > 0 && (denominator & (denominator - 1)) === 0; + const longestMeasure = isPowerOfTwo ? (64 / denominator) * numerator : 0; + if (!isPowerOfTwo || format === 0 || longestMeasure < 4 || longestMeasure > 256) { + throw new UnsupportedFormatError('Not a TablEdit file'); + } + return format; + } + + private _readInformation(): void { + this._score.title = this._readString(this._data.u32(64)); + this._score.subTitle = this._readString(this._data.u32(68)); + this._score.instructions = this._readString(this._data.u32(72)); + this._score.notices = this._readString(this._data.u32(76)); + this._score.copyright = this._readString(this._data.u32(140)); + } + + private _readMeasures(offset: number): void { + if (offset === 0) { + throw new FormatError('TablEdit file without measures'); + } + const d = this._data; + const recordSize = d.u16(offset) === TablEditImporter._wideMeasureRecordHeader ? 12 : 8; + const count = d.u16(offset + 2); + if (count === 0) { + throw new FormatError('TablEdit file without measures'); + } + TefBinary.ensureLoopBoundary(count, TablEditImporter._maxMeasureCount, 'measure count'); + let p = offset + 8; + for (let i = 0; i < count; i++) { + const denominator = d.u8(p + 4) > 0 ? d.u8(p + 4) : 4; + const numerator = d.u8(p + 5) > 0 ? d.u8(p + 5) : 4; + let size = (64 / denominator) * numerator; + if (size <= 0 || size > 1024 || size !== Math.floor(size)) { + size = 64; + } + const flags = d.u8(p); + const masterBar = new MasterBar(); + masterBar.timeSignatureNumerator = numerator; + masterBar.timeSignatureDenominator = denominator; + if ((flags & TablEditImporter._minorFlag) !== 0) { + this._minorBars.add(i); + } + this._score.addMasterBar(masterBar); + this._keys.push(d.s8(p + 2)); + this._measureStarts.push(this._length); + this._measureSizes.push(size); + this._length += size; + if ((flags & TablEditImporter._pickupFlag) !== 0) { + masterBar.isAnacrusis = true; + this._pickupBars.add(i); + } + if ((flags & TablEditImporter._freeBarFlags) !== 0) { + masterBar.beamingRules = TablEditImporter._freeBarBeaming( + masterBar, + size, + (flags & TablEditImporter._halfBarBeamingFlag) !== 0 + ); + } + p += recordSize; + } + } + + /** + * Free bars beam their eighths across the whole bar or across each half. + */ + private static _freeBarBeaming(masterBar: MasterBar, size: number, halves: boolean): BeamingRules { + const eighths = Math.max(1, Math.floor(size / 8)); + const groups: number[] = []; + if (halves && eighths > 1) { + groups.push(Math.ceil(eighths / 2)); + groups.push(Math.floor(eighths / 2)); + } else { + groups.push(eighths); + } + return BeamingRules.createSimple( + masterBar.timeSignatureNumerator, + masterBar.timeSignatureDenominator, + Duration.Eighth, + groups + ); + } + + private _readInstruments(offset: number): void { + const data = this._data; + if (offset === 0 || data.u16(offset + 2) === 0) { + throw new FormatError('TablEdit file without instruments'); + } + const recordSize = data.u16(offset); + const count = data.u16(offset + 2); + TefBinary.ensureLoopBoundary(count, TablEditImporter._maxInstrumentCount, 'instrument count'); + let p = offset + 4; + for (let i = 0; i < count; i++) { + const stringCount = data.u16(p); + TefBinary.ensureLoopBoundary(stringCount, TablEditImporter._maxStringCount, 'string count'); + + const track = new Track(); + track.ensureStaveCount(1); + const staff: Staff = track.staves[0]; + this._score.addTrack(track); + track.name = data.readCString(p + TablEditImporter._nameOffset, TablEditImporter._nameLength); + const abbreviation = p + TablEditImporter._nameOffset + TablEditImporter._abbreviationOffset; + if (data.u8(abbreviation) === TablEditImporter._abbreviationMark) { + track.shortName = data.readCString( + abbreviation + 1, + TablEditImporter._nameLength - TablEditImporter._abbreviationOffset - 1 + ); + } + track.playbackInfo.program = data.u8(p + 8); + track.playbackInfo.bank = data.u8(p + 9) * TablEditImporter._bankMsbFactor; + + const capo = data.u16(p + 12); + const openPitches: number[] = []; + for (let s = 0; s < stringCount; s++) { + openPitches.push(96 - data.u8(p + 20 + s)); + } + const isPercussion = (data.u8(p + 4) & TablEditImporter._drumFlag) !== 0; + staff.isPercussion = isPercussion; + if (!isPercussion) { + staff.capo = capo; + const tuning: number[] = []; + for (const pitch of openPitches) { + tuning.push(pitch - capo); + } + staff.stringTuning.tunings = tuning; + } + const transposition = data.u8(p + 11) & 0x07; + staff.displayTranspositionPitch = data.s8(p + 14) - 12 - TablEditImporter._transpositionOf(transposition); + + switch ((data.u8(p + 16) >> 4) & 0x03) { + case 1: + staff.showTablature = true; + staff.showStandardNotation = false; + break; + case 2: + staff.showTablature = false; + staff.showStandardNotation = true; + break; + default: + staff.showTablature = true; + staff.showStandardNotation = true; + break; + } + track.playbackInfo.balance = Math.round(((data.u8(p + 17) & 0x0f) * 16) / 15); + track.playbackInfo.volume = Math.round(((15 - (data.u8(p + 18) & 0x0f)) * 16) / 15); + + const info = new TefTrackInfo(); + info.track = track; + info.firstString = this._totalStrings; + info.stringCount = stringCount; + info.openPitches = openPitches; + info.isPercussion = isPercussion; + info.clef = TablEditImporter._clefOf(data.u8(p + 15) & 0x07, openPitches); + info.keyShift = (transposition & 0x03) === 0 ? 0 : 4 - transposition; + info.isLetRing = (data.u16(p + 18) & TablEditImporter._letRingOption) !== 0; + this._tracks.push(info); + this._totalStrings += stringCount; + p += recordSize; + } + if (this._totalStrings === 0) { + throw new FormatError('TablEdit file without strings'); + } + } + + private _readReadingList(offset: number): TefReadingListItem[] { + const items: TefReadingListItem[] = []; + if (offset === 0) { + return items; + } + const recordSize = this._data.u16(offset); + if (recordSize !== TablEditImporter._readingListRecordSize) { + return items; + } + const count = this._data.u16(offset + 2); + TefBinary.ensureLoopBoundary(count, TablEditImporter._maxReadingListCount, 'reading list count'); + let p = offset + 4; + for (let i = 0; i < count; i++) { + const item = new TefReadingListItem(); + item.first = this._data.u16(p) - 1; + item.last = this._data.u16(p + 2) - 1; + item.label = this._data.readCString(p + 4, recordSize - 4); + items.push(item); + p += recordSize; + } + return items; + } + + private static _readGuides(cells: TefCell[], builder: TefBarBuilder): TefReadingGuide[] { + const guides: TefReadingGuide[] = []; + for (const cell of cells) { + if (cell.data[0] === TefCell.readingGuideMarker) { + const guide = new TefReadingGuide(); + guide.bar = builder.barOf(cell.position); + guide.value = cell.data[2]; + guides.push(guide); + } + } + return guides; + } + + private _readTexts(offset: number): string[] { + const texts: string[] = []; + if (offset === 0) { + return texts; + } + const count = this._data.u16(offset); + TefBinary.ensureLoopBoundary(count, TablEditImporter._maxTextCount, 'text count'); + let p = offset + 2; + for (let i = 0; i < count; i++) { + texts.push(this._readString(p)); + p += 2 + this._data.u16(p); + } + return texts; + } + + /** + * Reads the chord diagrams with one fret per string slot. The records do not say which instrument + * they belong to, the strings are cut to the instrument when the chord is attached. + */ + private _readChords(offset: number): Chord[] { + const chords: Chord[] = []; + if (offset === 0) { + return chords; + } + const recordSize = this._data.u16(offset); + const count = this._data.u16(offset + 2); + TefBinary.ensureLoopBoundary(count, TablEditImporter._maxChordCount, 'chord count'); + let p = offset + 4; + for (let i = 0; i < count; i++) { + const chord = new Chord(); + chord.name = this._data.readCString(p + 14, 17); + for (let s = 0; s < TablEditImporter._chordStringBytes; s++) { + const value = this._data.u8(p + s); + chord.strings.push(value === 0xff ? -1 : value & 0x1f); + } + const firstFret = this._data.s8(p + 31); + chord.firstFret = firstFret > 0 ? firstFret : 1; + chords.push(chord); + p += recordSize; + } + return chords; + } + + /** + * The key shown for an instrument whose written key is shifted by the given number of sharps. + */ + private static _writtenKey(key: number, shift: number): KeySignature { + let written = key + shift; + if (written > 7) { + written -= 12; + } else if (written < -7) { + written += 12; + } + return Math.max(-7, Math.min(7, written)) as KeySignature; + } + + /** + * The written pitch shift of a transposing instrument in semitones. + */ + private static _transpositionOf(value: number): number { + if ((value & 0x03) === 0) { + return 0; + } + const shift = ((((4 - value) * 7) % 12) + 12) % 12; + return shift === 9 ? -3 : shift; + } + + private static _clefOf(value: number, openPitches: number[]): Clef { + switch (value) { + case 1: + return Clef.G2; + case 2: + case 3: + return Clef.F4; + case 4: + return Clef.C4; + case 5: + case 6: + case 7: + return Clef.C3; + } + let highest = 0; + let lowest = 127; + for (const pitch of openPitches) { + highest = Math.max(highest, pitch); + lowest = Math.min(lowest, pitch); + } + return openPitches.length > 0 && highest < 46 && lowest < 30 ? Clef.F4 : Clef.G2; + } + + private _readContents(offset: number): TefCell[] { + const cells: TefCell[] = []; + if (offset === 0) { + return cells; + } + let p = offset; + while (true) { + const location = this._data.u32(p); + if (location === TablEditImporter._cellEnd) { + break; + } + TefBinary.ensureLoopBoundary(cells.length, TablEditImporter._maxCellCount, 'cell count'); + const slot = Math.floor(location / 8); + const cell = new TefCell(); + cell.position = Math.floor(slot / this._totalStrings); + cell.row = slot % this._totalStrings; + cell.data = this._data.bytes.subarray(p + 4, p + TablEditImporter._cellSize); + if (cell.position < this._length) { + cells.push(cell); + } + p += TablEditImporter._cellSize; + } + return cells; + } + + private _readString(offset: number): string { + if (offset === 0) { + return ''; + } + return this._data.readString(offset, this.settings.importer.maxDecodingBufferSize); + } +} diff --git a/packages/alphatab/src/importer/tablEdit/TefBarBuilder.ts b/packages/alphatab/src/importer/tablEdit/TefBarBuilder.ts new file mode 100644 index 0000000000..eddd69ab8e --- /dev/null +++ b/packages/alphatab/src/importer/tablEdit/TefBarBuilder.ts @@ -0,0 +1,1975 @@ +import { TefCell } from '@coderline/alphatab/importer/tablEdit/TefCell'; +import { AccentuationType } from '@coderline/alphatab/model/AccentuationType'; +import { Automation, AutomationType } from '@coderline/alphatab/model/Automation'; +import { Bar } from '@coderline/alphatab/model/Bar'; +import { Beat, BeatBeamingMode } from '@coderline/alphatab/model/Beat'; +import { BendPoint } from '@coderline/alphatab/model/BendPoint'; +import { BrushType } from '@coderline/alphatab/model/BrushType'; +import { Chord } from '@coderline/alphatab/model/Chord'; +import { Clef } from '@coderline/alphatab/model/Clef'; +import { CrescendoType } from '@coderline/alphatab/model/CrescendoType'; +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 { Fingers } from '@coderline/alphatab/model/Fingers'; +import { GraceType } from '@coderline/alphatab/model/GraceType'; +import { HarmonicType } from '@coderline/alphatab/model/HarmonicType'; +import type { MasterBar } from '@coderline/alphatab/model/MasterBar'; +import { ModelUtils } from '@coderline/alphatab/model/ModelUtils'; +import { 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 { SlideOutType } from '@coderline/alphatab/model/SlideOutType'; +import type { Staff } from '@coderline/alphatab/model/Staff'; +import type { Track } from '@coderline/alphatab/model/Track'; +import { TremoloPickingEffect } from '@coderline/alphatab/model/TremoloPickingEffect'; +import { TripletFeel } from '@coderline/alphatab/model/TripletFeel'; +import { VibratoType } from '@coderline/alphatab/model/VibratoType'; +import { Voice } from '@coderline/alphatab/model/Voice'; +import { BeamDirection } from '@coderline/alphatab/rendering/utils/BeamDirection'; + +/** + * @internal + */ +export class TefTrackInfo { + public track!: Track; + public firstString: number = 0; + public stringCount: number = 0; + /** + * The open string pitches from the highest string, capo included. + */ + public openPitches: number[] = []; + public isPercussion: boolean = false; + public clef: Clef = Clef.G2; + /** + * The number of sharps the written key differs from the concert key. + */ + public keyShift: number = 0; + public isLetRing: boolean = false; +} + +/** + * A stretch of a bar whose notes are played as an n-tuplet. + * @internal + */ +class TefTupletRange { + public bar: number = 0; + public start: number = 0; + /** + * The last column the range covers. + */ + public end: number = 0; + public numerator: number = 0; + public denominator: number = 0; +} + +/** + * The rhythmic value of a TablEdit duration code. + * @internal + */ +class TefDuration { + public duration: Duration; + public dots: number; + public tupletNumerator: number; + public tupletDenominator: number; + + public constructor(duration: Duration, dots: number, tupletNumerator: number, tupletDenominator: number) { + this.duration = duration; + this.dots = dots; + this.tupletNumerator = tupletNumerator; + this.tupletDenominator = tupletDenominator; + } + + public get isTuplet(): boolean { + return this.tupletNumerator > 0; + } + + /** + * The length of the plain value in grid cells (1/64). + */ + public get plainCells(): number { + return 64 / (this.duration as number); + } + + /** + * The real length in sub-units. + */ + public get length(): number { + let length = this.plainCells * TefBarBuilder.subUnitsPerCell; + if (this.dots === 1) { + length = (length * 3) / 2; + } else if (this.dots === 2) { + length = (length * 7) / 4; + } + if (this.isTuplet) { + length = (length * this.tupletDenominator) / this.tupletNumerator; + } + return length; + } +} + +/** + * A decoded note or rest event. + * @internal + */ +class TefEvent { + public cell!: TefCell; + public isRest: boolean = false; + public stringIndex: number = 0; + public fret: number = 0; + public durationCode: number = 0; + public duration!: TefDuration; + public dynamics: number = 0; + public isTied: boolean = false; + public voiceHint: number = 0; + public accidental: number = 0; + public bar: number = 0; + /** + * The grid column within the bar, in cells. + */ + public column: number = 0; + public realStart: number = 0; + public realEnd: number = 0; + public lane: number = -1; + public tuplet: TefTupletRange | null = null; + /** + * The next note on the same string. + */ + public next: TefEvent | null = null; + /** + * The previous note on the same string, in any voice. + */ + public previous: TefEvent | null = null; + /** + * The first and the last note created for this event (more than one when it is split into tied beats). + */ + public firstNote: Note | null = null; + public lastNote: Note | null = null; + public extension: Uint8Array | null = null; + /** + * A hammer-on or pull-off grace on another string, slurred to this note. + */ + public slurredGrace: Note | null = null; +} + +/** + * Notes of one voice sounding with the same start and length. + * @internal + */ +class TefUnit { + /** + * The order in which the unit was found, which decides between units that sort equal. + */ + public index: number = 0; + public events: TefEvent[] = []; + public start: number = 0; + public end: number = 0; + public bar: number = 0; + public isRest: boolean = false; + public duration: TefDuration | null = null; + public voiceGroup: number = 0; + public lane: number = -1; +} + +/** + * A beat of a bar with its voice and its real start in the bar. + * @internal + */ +class TefBeatRecord { + public beat: Beat; + public voice: number; + public start: number; + /** + * The grid column of the note or rest the beat starts, -1 for a beat that continues one or fills a gap. + */ + public column: number; + + public constructor(beat: Beat, voice: number, start: number, column: number) { + this.beat = beat; + this.voice = voice; + this.start = start; + this.column = column; + } +} + +/** + * Builds the bars, voices and beats of each track from the TablEdit grid. + * @internal + */ +export class TefBarBuilder { + /** + * Sub-units per grid cell. 360360 is the least common multiple of 1..15, + * which keeps the members of every tuplet ratio up to 15 on whole sub-units. + */ + public static readonly subUnitsPerCell = 360360; + /** + * The shortest plain value used to fill gaps, a 1/128 note. + */ + private static readonly _smallestPlain = TefBarBuilder.subUnitsPerCell / 2; + + private static readonly _cellsPerQuarter = 16; + private static readonly _durationMask = 0x1f; + private static readonly _tieDynamics = 7; + private static readonly _upperVoice = 2; + private static readonly _lowerVoice = 3; + private static readonly _automaticGroup = 0; + private static readonly _upperGroup = 1; + private static readonly _lowerGroup = 2; + private static readonly _voiceOrder = [ + TefBarBuilder._automaticGroup, + TefBarBuilder._upperGroup, + TefBarBuilder._lowerGroup + ]; + private static readonly _acciaccaturaFlag = 0x40; + private static readonly _graceDurationMask = 0x07; + private static readonly _extensionParameterFlag = 0x20; + private static readonly _fromStringFlag = 0x80; + private static readonly _wideFretFlag = 0x80; + private static readonly _ornamentDirectionFlag = 0x10; + private static readonly _noEffect = 0x94; + private static readonly _harmonicIntervalTag = 0xb0; + private static readonly _hiddenInNotation = 0x0f; + private static readonly _restSecondaryBeamBreak = 0x07; + private static readonly _excludeFromBeamingMask = 0x88; + /** + * Ticks between two strings of a brush or roll per speed step: 5 TablEdit ticks of 240 per quarter. + */ + private static readonly _strumTicksPerSpeed = 20; + private static readonly _defaultCrescendoAmount = 16; + private static readonly _crescendoAmountVersion = 0x03060000; + private static readonly _maskedSlurLengthVersion = 0x030600a4; + private static readonly _decrescendoFlag = 0x10; + private static readonly _doubleBarFlag = 0x04; + private static readonly _fermataSymbols = [0x6b, 0x6c]; + private static readonly _bracketFlag = 0x80; + private static readonly _connectionLengthMask = 0x3f; + private static readonly _maxSyncopation = 3; + private static readonly _lyricsTextPrefix = '>'; + private static readonly _lyricsSectionStart = '\n>'; + private static readonly _noChord = 0xffff; + /** + * The primary effect matching each combination effect; 3 (roll) and 7 (artificial harmonic) differ. + */ + private static readonly _combinationEffects = [0, 1, 2, 0, 0, 5, 6, 0, 0x11, 0x14, 15, 0, 0, 0, 0, 0]; + + private static readonly _durations: (TefDuration | null)[] = [ + new TefDuration(Duration.Whole, 0, -1, -1), + new TefDuration(Duration.Half, 1, -1, -1), + new TefDuration(Duration.Whole, 0, 3, 2), + new TefDuration(Duration.Half, 0, -1, -1), + new TefDuration(Duration.Quarter, 1, -1, -1), + new TefDuration(Duration.Half, 0, 3, 2), + new TefDuration(Duration.Quarter, 0, -1, -1), + new TefDuration(Duration.Eighth, 1, -1, -1), + new TefDuration(Duration.Quarter, 0, 3, 2), + new TefDuration(Duration.Eighth, 0, -1, -1), + new TefDuration(Duration.Sixteenth, 1, -1, -1), + new TefDuration(Duration.Eighth, 0, 3, 2), + new TefDuration(Duration.Sixteenth, 0, -1, -1), + new TefDuration(Duration.ThirtySecond, 1, -1, -1), + new TefDuration(Duration.Sixteenth, 0, 3, 2), + new TefDuration(Duration.ThirtySecond, 0, -1, -1), + new TefDuration(Duration.SixtyFourth, 1, -1, -1), + new TefDuration(Duration.ThirtySecond, 0, 3, 2), + new TefDuration(Duration.SixtyFourth, 0, -1, -1), + new TefDuration(Duration.Half, 2, -1, -1), + new TefDuration(Duration.Sixteenth, 0, 5, 4), + null, + new TefDuration(Duration.Quarter, 2, -1, -1), + new TefDuration(Duration.Sixteenth, 0, 7, 4), + null, + new TefDuration(Duration.Eighth, 2, -1, -1), + new TefDuration(Duration.ThirtySecond, 0, 9, 8), + null, + new TefDuration(Duration.Sixteenth, 2, -1, -1), + new TefDuration(Duration.ThirtySecond, 0, 10, 8), + null, + new TefDuration(Duration.Whole, 1, -1, -1) + ]; + + private _tracks: TefTrackInfo[]; + private _measureStarts: number[]; + private _measureSizes: number[]; + /** + * Per bar, the beats of the track being built, voice by voice in time order. + */ + private _beats: TefBeatRecord[][] = []; + + /** + * Bars flagged as pick-up measures. They keep their nominal size on the grid. + */ + public pickupBars: Set = new Set(); + private _columnOffsets: Map = new Map(); + public texts: string[] = []; + public chords: Chord[] = []; + public initialTempo: number = 120; + public initialSyncopation: number = 0; + /** + * The version and sub-version of TablEdit which saved the file, as 0xMMmmSSSS. + */ + public version: number = 0; + + public constructor(tracks: TefTrackInfo[], measureStarts: number[], measureSizes: number[]) { + this._tracks = tracks; + this._measureStarts = measureStarts; + this._measureSizes = measureSizes; + } + + public build(cells: TefCell[]): void { + this._trimPickupBars(cells); + const extensions = new Map(); + const rowTracks: number[] = []; + const trackCells: TefCell[][] = []; + for (let t = 0; t < this._tracks.length; t++) { + trackCells.push([]); + for (let s = 0; s < this._tracks[t].stringCount; s++) { + rowTracks.push(t); + } + } + for (const cell of cells) { + if (cell.data[0] === TefCell.extensionMarker && cell.data[1] !== 0) { + extensions.set(`${cell.row}:${cell.position - 1}`, cell.data); + } + if (cell.row < rowTracks.length) { + trackCells[rowTracks[cell.row]].push(cell); + } + } + for (let t = 0; t < this._tracks.length; t++) { + const info = this._tracks[t]; + const tuplets = new Map(); + for (const cell of trackCells[t]) { + const tuplet = this._tupletRangeOf(cell); + if (tuplet) { + if (!tuplets.has(tuplet.bar)) { + tuplets.set(tuplet.bar, []); + } + tuplets.get(tuplet.bar)!.push(tuplet); + } + } + const events: TefEvent[] = []; + for (const cell of trackCells[t]) { + const e = this._decode(cell, info); + if (e) { + const ext = extensions.get(`${cell.row}:${cell.position}`); + if (ext) { + e.extension = ext; + } + e.tuplet = TefBarBuilder._tupletAt(tuplets, e); + e.duration = e.tuplet + ? new TefDuration( + e.duration.duration, + e.duration.dots, + e.tuplet.numerator, + e.tuplet.denominator + ) + : e.duration; + events.push(e); + } + } + this._buildTrack(info, events); + for (const cell of trackCells[t]) { + this._applyTrackMarker(info, cell); + } + } + this._applyGlobalMarkers(cells); + } + + /** + * A numbered connection plays the plain notes it covers as an n-tuplet. + */ + private _tupletRangeOf(cell: TefCell): TefTupletRange | null { + const data = cell.data; + if (data[0] !== TefCell.connectionMarker || (data[7] & TefBarBuilder._bracketFlag) !== 0) { + return null; + } + const numerator = data[2] >> 4; + if (numerator < 3) { + return null; + } + const bar = this.barOf(cell.position); + const masterBar = this._tracks[0].track.score.masterBars[bar]; + const compound = TefBarBuilder._beatCells(masterBar, this._measureSizes[bar]) % 3 === 0; + let stretch = compound ? 3 : 4; + while (stretch < numerator) { + stretch *= 2; + } + if (stretch % 2 !== 0) { + return null; + } + const range = new TefTupletRange(); + range.bar = bar; + range.start = this._columnOf(cell.position, bar); + range.end = range.start + (data[1] & TefBarBuilder._connectionLengthMask) + 1; + range.numerator = numerator; + range.denominator = stretch / 2; + return range; + } + + private static _tupletAt(ranges: Map, e: TefEvent): TefTupletRange | null { + if (e.duration.isTuplet || !ranges.has(e.bar)) { + return null; + } + for (const range of ranges.get(e.bar)!) { + if (e.column >= range.start && e.column <= range.end) { + return range; + } + } + return null; + } + + /** + * Pick-up measures only last from their first to their last sounding note. + */ + private _trimPickupBars(cells: TefCell[]): void { + if (this.pickupBars.size === 0) { + return; + } + const firsts = new Map(); + const ends = new Map(); + for (const cell of cells) { + const type = cell.data[0] & TefCell.typeMask; + const d = TefBarBuilder._durations[cell.data[1] & TefBarBuilder._durationMask]; + if (type === 0 || type >= TefCell.rest || !d) { + continue; + } + const bar = this.barOf(cell.position); + if (!this.pickupBars.has(bar)) { + continue; + } + const column = cell.position - this._measureStarts[bar]; + const end = column + Math.ceil(d.length / TefBarBuilder.subUnitsPerCell); + if (!firsts.has(bar)) { + firsts.set(bar, column); + ends.set(bar, end); + } else { + firsts.set(bar, Math.min(firsts.get(bar)!, column)); + ends.set(bar, Math.max(ends.get(bar)!, end)); + } + } + for (const bar of this.pickupBars) { + if (firsts.has(bar)) { + const first = firsts.get(bar)!; + this._columnOffsets.set(bar, first); + this._measureSizes[bar] = Math.min(this._measureSizes[bar], ends.get(bar)!) - first; + } + } + } + + /** + * The grid position at which the content of the bar starts. + */ + private _gridStart(bar: number): number { + const offset = this._columnOffsets.get(bar); + return this._measureStarts[bar] + (offset === undefined ? 0 : offset); + } + + private _columnOf(position: number, bar: number): number { + return position - this._gridStart(bar); + } + + /** + * Applies the lyrics block: sections are lines starting with '>' and the track letter. TablEdit shows only + * the first section of a track. + */ + public applyLyrics(text: string): void { + const sections = `\n${text}`.split(TefBarBuilder._lyricsSectionStart); + const applied = new Set(); + for (let s = 1; s < sections.length; s++) { + const section = sections[s]; + if (section.length === 0) { + continue; + } + const trackIndex = section.charCodeAt(0) - 65; + if (trackIndex < 0 || trackIndex >= this._tracks.length || applied.has(trackIndex)) { + continue; + } + applied.add(trackIndex); + const lines = section.split('\n'); + const verses: string[] = []; + for (let i = 1; i < lines.length; i++) { + const line = lines[i].split('\r').join(''); + const verseStart = TefBarBuilder._verseStart(line); + if (verseStart > 0) { + verses.push(line.substring(verseStart)); + } else if (verses.length === 0) { + verses.push(line); + } else { + verses[verses.length - 1] += ` ${line}`; + } + } + const track = this._tracks[trackIndex].track; + const beats: Beat[] = []; + for (const bar of track.staves[0].bars) { + const voice = bar.voices.find(v => v.beats.length > 0); + if (voice) { + for (const beat of voice.beats) { + beats.push(beat); + } + } + } + for (let v = 0; v < verses.length; v++) { + TefBarBuilder._applySyllables(beats, 0, v, verses[v]); + } + } + } + + /** + * Returns the length of a leading verse number like "2. ", or 0. + */ + private static _verseStart(line: string): number { + let i = 0; + while (i < line.length && line.charAt(i) === ' ') { + i++; + } + const digits = i; + while (i < line.length && line.charCodeAt(i) >= 48 && line.charCodeAt(i) <= 57) { + i++; + } + if (i === digits || i >= line.length || line.charAt(i) !== '.') { + return 0; + } + i++; + while (i < line.length && line.charAt(i) === ' ') { + i++; + } + return i; + } + + /** + * Assigns one syllable per sounding note, starting at the given beat. Chord names in brackets + * are attached to the note of the syllable that follows them. + */ + private static _applySyllables(beats: Beat[], startIndex: number, verse: number, text: string): void { + const chordNames: string[] = []; + const syllables = TefBarBuilder._syllables(text, chordNames); + let index = 0; + for (let i = startIndex; i < beats.length && index < syllables.length; i++) { + const beat = beats[i]; + if (beat.isRest || beat.graceType !== GraceType.None || beat.notes.every(n => n.isTieDestination)) { + continue; + } + if (!beat.lyrics) { + beat.lyrics = []; + } + while (beat.lyrics.length <= verse) { + beat.lyrics.push(''); + } + beat.lyrics[verse] = syllables[index]; + if (chordNames[index].length > 0 && !beat.chordId) { + TefBarBuilder._attachChordName(beat, chordNames[index]); + } + index++; + } + } + + private static _attachChordName(beat: Beat, name: string): void { + const staff = beat.voice.bar.staff; + const id = `lyrics:${name}`; + if (!staff.hasChord(id)) { + const chord = new Chord(); + chord.name = name; + chord.showDiagram = false; + staff.addChord(id, chord); + } + beat.chordId = id; + } + + /** + * Splits lyrics into one entry per note: spaces and hyphens move to the next note, + * '+' and '_' are a space and a hyphen within the syllable. + * @param chordNames receives the chord name given before each syllable, or an empty string. + */ + private static _syllables(text: string, chordNames: string[]): string[] { + const syllables: string[] = []; + let current = ''; + let inChord = false; + let chordName = ''; + let pendingChord = ''; + let pendingSpace = false; + for (let i = 0; i < text.length; i++) { + const c = text.charAt(i); + if (inChord) { + if (c === ']') { + inChord = false; + pendingChord = chordName.trim(); + } else { + chordName += c; + } + continue; + } + switch (c) { + case '[': + inChord = true; + chordName = ''; + break; + case ' ': + case '\t': + if (current.length > 0) { + syllables.push(current); + chordNames.push(pendingChord); + pendingChord = ''; + current = ''; + } else if (pendingSpace) { + syllables.push(''); + chordNames.push(pendingChord); + pendingChord = ''; + } + pendingSpace = true; + break; + case '-': + case '~': + syllables.push(c === '-' ? `${current}-` : current); + chordNames.push(pendingChord); + pendingChord = ''; + current = ''; + pendingSpace = false; + break; + case '+': + current += ' '; + break; + case '_': + current += '-'; + break; + default: + current += c; + pendingSpace = false; + break; + } + } + if (current.length > 0 || pendingChord.length > 0) { + syllables.push(current); + chordNames.push(pendingChord); + } + return syllables; + } + + private _applyGlobalMarkers(cells: TefCell[]): void { + const score = this._tracks[0].track.score; + const syncopation: number[] = []; + for (let b = 0; b < score.masterBars.length; b++) { + syncopation.push(Number.NaN); + } + score.masterBars[0].tempoAutomations.push(Automation.buildTempoAutomation(false, 0, this.initialTempo, 2)); + let tempo = this.initialTempo; + const lineBreakBars: number[] = []; + const barsPerLine: number[] = []; + for (const cell of cells) { + const data = cell.data; + const bar = this.barOf(cell.position); + switch (data[0]) { + case TefCell.tempoMarker: { + const bpm = data[1] | (data[2] << 8); + if (bpm > 1) { + this._addTempoChange(Math.max(cell.position, this._gridStart(bar)), tempo, bpm, data[3]); + tempo = bpm; + } + break; + } + case TefCell.syncopationMarker: + syncopation[bar] = TefBarBuilder._clampSyncopation(data[1] - 8); + break; + case TefCell.lineBreakMarker: + this._addLineBreak(bar + 1); + if ((data[7] & TefBarBuilder._doubleBarFlag) !== 0) { + score.masterBars[bar].isDoubleBar = true; + } + lineBreakBars.push(bar + 1); + barsPerLine.push(data[5] & 0x0f); + break; + } + } + for (let i = 0; i < lineBreakBars.length; i++) { + const perLine = barsPerLine[i]; + const until = i + 1 < lineBreakBars.length ? lineBreakBars[i + 1] : score.masterBars.length; + if (perLine > 0) { + for (let b = lineBreakBars[i] + perLine; b < until; b += perLine) { + this._addLineBreak(b); + } + } + } + let current = TefBarBuilder._clampSyncopation(this.initialSyncopation); + for (let b = 0; b < score.masterBars.length; b++) { + if (!Number.isNaN(syncopation[b])) { + current = syncopation[b]; + } + const masterBar = score.masterBars[b]; + const compound = masterBar.timeSignatureNumerator % 3 === 0 && masterBar.timeSignatureDenominator >= 8; + masterBar.tripletFeel = compound ? TripletFeel.NoTripletFeel : TefBarBuilder._tripletFeelOf(current); + } + } + + private static _clampSyncopation(value: number): number { + return Math.max(-TefBarBuilder._maxSyncopation, Math.min(TefBarBuilder._maxSyncopation, value)); + } + + private _addLineBreak(bar: number): void { + if (bar > 0 && bar < this._measureStarts.length) { + for (const t of this._tracks) { + t.track.addLineBreaks(bar); + } + } + } + + /** + * Gradual tempo changes are approximated with a hidden tempo change on every quarter note. + * A change at the very start replaces the initial tempo. + */ + private _addTempoChange(position: number, from: number, to: number, rampQuarters: number): void { + const score = this._tracks[0].track.score; + const steps = Math.max(1, rampQuarters); + for (let i = 1; i <= steps; i++) { + const stepPosition = position + (i - 1) * TefBarBuilder._cellsPerQuarter; + const b = this.barOf(stepPosition); + const column = stepPosition - this._gridStart(b); + if (column < 0 || column >= this._measureSizes[b]) { + continue; + } + const value = steps === 1 ? to : Math.round(from + ((to - from) * i) / steps); + const automations = score.masterBars[b].tempoAutomations; + if (b === 0 && column === 0) { + automations[0].value = value; + } else { + automations.push( + Automation.buildTempoAutomation(false, column / this._measureSizes[b], value, 2, i === 1) + ); + } + } + } + + private static _tripletFeelOf(syncopation: number): TripletFeel { + if (syncopation >= 3) { + return TripletFeel.Dotted8th; + } + if (syncopation > 0) { + return TripletFeel.Triplet8th; + } + if (syncopation < 0) { + return TripletFeel.Scottish8th; + } + return TripletFeel.NoTripletFeel; + } + + /** + * The beat sounding at the given column in the first voice of the bar that has content. + */ + private _beatAt(bar: number, column: number): Beat | null { + const records = this._beats[bar]; + const time = column * TefBarBuilder.subUnitsPerCell; + let found: Beat | null = null; + for (const r of records) { + if (r.voice !== records[0].voice) { + break; + } + if (r.start <= time) { + found = r.beat; + } + } + return found; + } + + private _applyTrackMarker(info: TefTrackInfo, cell: TefCell): void { + const data = cell.data; + const bar = this.barOf(cell.position); + const column = Math.max(0, this._columnOf(cell.position, bar)); + const beat = this._beatAt(bar, column); + if (!beat) { + return; + } + const unit = TefBarBuilder.subUnitsPerCell; + switch (data[0]) { + case TefCell.textMarker: { + const index = data[1] | (data[2] << 8); + if (index < this.texts.length) { + const text = TefBarBuilder._plainText(this.texts[index]); + if (text.startsWith(TefBarBuilder._lyricsTextPrefix)) { + const beats = beat.voice.beats; + TefBarBuilder._applySyllables(beats, beats.indexOf(beat), 0, text.substring(1)); + } else if (text.length > 0) { + beat.text = beat.text ? `${beat.text} ${text}` : text; + } + } + break; + } + case TefCell.chordMarker: { + const index = data[1] | (data[2] << 8); + if (index !== TefBarBuilder._noChord && index < this.chords.length) { + const id = `${index}`; + const staff = info.track.staves[0]; + if (!staff.hasChord(id)) { + staff.addChord(id, TefBarBuilder._chordForStaff(this.chords[index], staff)); + } + beat.chordId = id; + } + break; + } + case TefCell.accentMarker: + for (const b of this._beatsAtColumn(bar, column)) { + for (const n of b.notes) { + n.accentuated = AccentuationType.Normal; + } + } + break; + case TefCell.fingeringMarker: { + const stroke = TefBarBuilder._pickStrokeOf(data[6]); + if (stroke !== PickStroke.None) { + for (const b of this._beatsAtColumn(bar, column)) { + if (!b.isRest) { + b.pickStroke = stroke; + } + } + } + break; + } + case TefCell.crescendoMarker: { + const amount = + this.version < TefBarBuilder._crescendoAmountVersion + ? TefBarBuilder._defaultCrescendoAmount + : data[6]; + if (data[2] !== 0 && amount !== 0) { + const type = + (data[1] & TefBarBuilder._decrescendoFlag) !== 0 + ? CrescendoType.Decrescendo + : CrescendoType.Crescendo; + for (const b of this._beatsInRange(bar, column * unit, (column + data[2]) * unit)) { + b.crescendo = type; + } + } + break; + } + case TefCell.symbolMarker: + if (TefBarBuilder._fermataSymbols.indexOf(data[1]) >= 0) { + const fermata = new Fermata(); + fermata.type = FermataType.Medium; + beat.fermata = fermata; + } + break; + case TefCell.connectionMarker: + if ((data[7] & TefBarBuilder._bracketFlag) === 0) { + const tuplet = this._tupletRangeOf(cell); + if (tuplet) { + // the covered notes are spread out, so the slur ends at the last covered column + let lastColumn = tuplet.start; + for (const r of this._beats[bar]) { + if (r.column > lastColumn && r.column <= tuplet.end) { + lastColumn = r.column; + } + } + const stretch = (2 * tuplet.denominator) / tuplet.numerator; + const last = (tuplet.start + (lastColumn - tuplet.start) * stretch) * unit; + TefBarBuilder._applySlur(this._beatsInRange(bar, tuplet.start * unit, last + 1)); + } else { + const length = + this.version < TefBarBuilder._maskedSlurLengthVersion + ? data[1] & TefBarBuilder._connectionLengthMask + : data[1]; + TefBarBuilder._applySlur(this._beatsInRange(bar, column * unit, (column + length + 1) * unit)); + } + } + break; + case TefCell.patchMarker: + beat.automations.push(Automation.buildInstrumentAutomation(false, 0, data[1])); + if (data[2] !== 0 || data[3] !== 0) { + const bank = new Automation(); + bank.type = AutomationType.Bank; + bank.value = data[2] * 128 + data[3]; + beat.automations.push(bank); + } + break; + } + } + + /** + * The beats of all voices whose note or rest starts exactly at the given grid column. + */ + private _beatsAtColumn(bar: number, column: number): Beat[] { + const found: Beat[] = []; + for (const r of this._beats[bar]) { + if (r.column === column) { + found.push(r.beat); + } + } + return found; + } + + /** + * The beats starting in the given stretch of real time, counted from the start of the given bar, + * taken per bar from the first voice that has content. + */ + private _beatsInRange(bar: number, from: number, to: number): Beat[] { + const found: Beat[] = []; + const origin = this._gridStart(bar); + for (let b = bar; b < this._beats.length; b++) { + const offset = (this._gridStart(b) - origin) * TefBarBuilder.subUnitsPerCell; + if (offset >= to) { + break; + } + const records = this._beats[b]; + for (const r of records) { + if (r.voice !== records[0].voice) { + break; + } + const start = offset + r.start; + if (start >= from && start < to) { + found.push(r.beat); + } + } + } + return found; + } + + private static _chordForStaff(chord: Chord, staff: Staff): Chord { + const result = new Chord(); + result.name = chord.name; + result.firstFret = chord.firstFret; + const stringCount = staff.tuning.length > 0 ? staff.tuning.length : chord.strings.length; + for (let s = 0; s < stringCount && s < chord.strings.length; s++) { + result.strings.push(chord.strings[s]); + } + return result; + } + + /** + * Slurs the first to the last of the given beats that have notes. + */ + private static _applySlur(beats: Beat[]): void { + let first: Beat | null = null; + let last: Beat | null = null; + for (const b of beats) { + if (b.notes.length > 0) { + if (!first) { + first = b; + } + last = b; + } + } + if (first && last && first !== last) { + const origin = first.notes[0]; + const destination = last.notes[0]; + origin.slurDestination = destination; + destination.slurOrigin = origin; + destination.isSlurDestination = true; + } + } + + /** + * Removes the formatting prefixes TablEdit allows in texts and drops texts that are commands. + */ + private static _plainText(text: string): string { + let t = text; + while (t.length >= 2 && t.charAt(0) === '&' && 'biu'.indexOf(t.charAt(1)) >= 0) { + t = t.substring(2); + } + if (t.startsWith('%') || t.startsWith('0x')) { + return ''; + } + return t.trim(); + } + + /** + * The note or rest of a cell, null for anything else and for rests outside a trimmed pick-up measure. + */ + private _decode(cell: TefCell, info: TefTrackInfo): TefEvent | null { + const type = cell.data[0] & TefCell.typeMask; + const duration = TefBarBuilder._durations[cell.data[1] & TefBarBuilder._durationMask]; + if (type === 0 || type > TefCell.rest || !duration) { + return null; + } + const e = new TefEvent(); + e.cell = cell; + e.bar = this.barOf(cell.position); + e.column = this._columnOf(cell.position, e.bar); + if (e.column < 0 || e.column >= this._measureSizes[e.bar]) { + return null; + } + e.isRest = type === TefCell.rest; + e.stringIndex = cell.row - info.firstString; + e.fret = type - 1; + e.durationCode = cell.data[1] & TefBarBuilder._durationMask; + e.duration = duration; + e.dynamics = cell.data[1] >> 5; + e.isTied = !e.isRest && e.dynamics === TefBarBuilder._tieDynamics; + e.voiceHint = (cell.data[2] >> 4) & 0x03; + e.accidental = cell.data[2] >> 6; + return e; + } + + /** + * The bar the given grid position falls into. + */ + public barOf(position: number): number { + let lo = 0; + let hi = this._measureStarts.length - 1; + while (lo < hi) { + const mid = (lo + hi + 1) >> 1; + if (this._measureStarts[mid] <= position) { + lo = mid; + } else { + hi = mid - 1; + } + } + return lo; + } + + private _buildTrack(info: TefTrackInfo, events: TefEvent[]): void { + const staff = info.track.staves[0]; + const barCount = this._measureStarts.length; + events.sort((a, b) => { + if (a.cell.position !== b.cell.position) { + return a.cell.position - b.cell.position; + } + return a.cell.row - b.cell.row; + }); + + const lastOnString = new Map(); + for (const e of events) { + if (!e.isRest) { + const previous = lastOnString.get(e.stringIndex); + if (previous) { + previous.next = e; + e.previous = previous; + } + lastOnString.set(e.stringIndex, e); + } + } + + const barEvents: TefEvent[][] = []; + this._beats = []; + for (let i = 0; i < barCount; i++) { + const bar = new Bar(); + bar.clef = info.isPercussion ? Clef.Neutral : info.clef; + staff.addBar(bar); + barEvents.push([]); + this._beats.push([]); + } + for (const e of events) { + barEvents[e.bar].push(e); + } + + const units: TefUnit[] = []; + for (let b = 0; b < barCount; b++) { + for (let group = 0; group < 3; group++) { + const groupEvents = barEvents[b].filter(e => TefBarBuilder._voiceGroupOf(e) === group); + if (groupEvents.length > 0) { + this._assignRealTime(groupEvents, b, info.track); + TefBarBuilder._collectUnits(groupEvents, group, b, units); + } + } + } + + const laneCounts = this._assignLanes(units); + this._createVoices(info, units, laneCounts); + TefBarBuilder._linkTies(events); + } + + /** + * TablEdit ties a note to the previous note on its string, which may be in another voice. + */ + private static _linkTies(events: TefEvent[]): void { + for (const e of events) { + const origin = e.previous ? e.previous.lastNote : null; + if (e.isTied && e.firstNote && origin) { + e.firstNote.tieOrigin = origin; + origin.tieDestination = e.firstNote; + } + } + } + + private static _voiceGroupOf(e: TefEvent): number { + switch (e.voiceHint) { + case TefBarBuilder._upperVoice: + return TefBarBuilder._upperGroup; + case TefBarBuilder._lowerVoice: + return TefBarBuilder._lowerGroup; + } + return TefBarBuilder._automaticGroup; + } + + /** + * Sets the real start and end within the bar of the events of one bar and voice group. + */ + private _assignRealTime(events: TefEvent[], barIndex: number, track: Track): void { + const unit = TefBarBuilder.subUnitsPerCell; + const barEnd = this._measureSizes[barIndex] * unit; + const beat = TefBarBuilder._beatCells(track.score.masterBars[barIndex], this._measureSizes[barIndex]); + let runCode = -1; + let runAnchor = 0; + for (const e of events) { + const d = e.duration; + const column = e.column; + let start = column * unit; + if (e.tuplet) { + runCode = -1; + const anchor = e.tuplet.start; + start = anchor * unit + ((column - anchor) * unit * 2 * e.tuplet.denominator) / e.tuplet.numerator; + } else if (TefBarBuilder._isTriplet(e.durationCode)) { + if (runCode !== e.durationCode) { + runCode = e.durationCode; + runAnchor = column - (column % beat); + } + const span = TefBarBuilder._tripletSpan(e.durationCode); + start = column * unit + ((((column - runAnchor) % span) + span) % span) * (unit / 3); + } else { + runCode = -1; + if (d.isTuplet) { + const anchor = column - (column % beat); + start = anchor * unit + (column - anchor) * TefBarBuilder._tupletStretch(e.durationCode); + } + } + e.realStart = start; + e.realEnd = Math.min(start + d.length, barEnd); + } + } + + private static _isTriplet(code: number): boolean { + return code === 5 || code === 8 || code === 11 || code === 14 || code === 17; + } + + /** + * The window (in cells) after which the stretching of a triplet run starts over: + * the plain value two levels longer than the triplet member. + */ + private static _tripletSpan(code: number): number { + return 64 / Math.pow(2, (code - 5) / 3); + } + + /** + * Real sub-units per grid cell for the members of the other tuplet kinds. + */ + private static _tupletStretch(code: number): number { + const unit = TefBarBuilder.subUnitsPerCell; + switch (code) { + case 23: + return (unit * 8) / 7; + case 26: + return (unit * 16) / 9; + } + return (unit * 8) / 5; + } + + private static _beatCells(masterBar: MasterBar, size: number): number { + const denominator = masterBar.timeSignatureDenominator; + let beat = 64 / denominator; + if (size % 3 === 0 && denominator > 4) { + beat = (3 * 64) / denominator; + } + if (beat < 9) { + beat = size % 16 === 0 ? 16 : size; + } + return beat; + } + + private static _collectUnits(events: TefEvent[], group: number, bar: number, units: TefUnit[]): void { + const byKey = new Map(); + for (const e of events) { + if (e.realStart >= e.realEnd) { + continue; + } + const key = `${e.isRest}:${e.realStart}:${e.realEnd}:${e.durationCode}:${e.tuplet ? e.tuplet.numerator : 0}`; + let unit = byKey.get(key); + if (!unit) { + unit = new TefUnit(); + unit.index = units.length; + unit.start = e.realStart; + unit.end = e.realEnd; + unit.bar = bar; + unit.isRest = e.isRest; + unit.voiceGroup = group; + if (e.duration.length === e.realEnd - e.realStart) { + unit.duration = e.duration; + } + byKey.set(key, unit); + units.push(unit); + } + unit.events.push(e); + } + } + + /** + * Places every unit into a lane of its voice group so that no lane holds overlapping units. + * Ties stay in the lane of the note they continue, since alphaTab resolves them along a voice. + */ + private _assignLanes(units: TefUnit[]): number[] { + const cell = TefBarBuilder.subUnitsPerCell; + const absolute = (u: TefUnit, t: number) => this._measureStarts[u.bar] * cell + t; + units.sort((a, b) => { + const sa = absolute(a, a.start); + const sb = absolute(b, b.start); + if (sa !== sb) { + return sa - sb; + } + if (a.isRest !== b.isRest) { + return a.isRest ? 1 : -1; + } + const length = a.end - a.start - (b.end - b.start); + return length !== 0 ? length : a.index - b.index; + }); + + const laneEnds: number[][] = [[], [], []]; + const lastOnString = new Map(); + for (const u of units) { + const ends = laneEnds[u.voiceGroup]; + const start = absolute(u, u.start); + let preferred = -1; + for (const e of u.events) { + if (e.isTied) { + const origin = lastOnString.get(e.stringIndex); + if (origin && origin.lane >= 0) { + preferred = origin.lane; + break; + } + } + } + let lane = -1; + if (preferred >= 0 && (preferred >= ends.length || ends[preferred] <= start)) { + lane = preferred; + } else { + for (let l = 0; l < ends.length; l++) { + if (ends[l] <= start) { + lane = l; + break; + } + } + } + if (u.isRest && lane !== 0 && ends.length > 0) { + continue; + } + if (lane < 0) { + lane = ends.length; + } + while (ends.length <= lane) { + ends.push(0); + } + ends[lane] = absolute(u, u.end); + u.lane = lane; + for (const e of u.events) { + e.lane = lane; + if (!e.isRest) { + lastOnString.set(e.stringIndex, e); + } + } + } + return [laneEnds[0].length, laneEnds[1].length, laneEnds[2].length]; + } + + private _createVoices(info: TefTrackInfo, units: TefUnit[], laneCounts: number[]): void { + const staff = info.track.staves[0]; + // per lane the automatic, upper and lower voice: alphaTab draws every voice after the first one + // in the secondary colour, the automatic voice usually holds most notes + const order: number[][] = []; + const maxLanes = Math.max(laneCounts[0], laneCounts[1], laneCounts[2]); + for (let l = 0; l < maxLanes; l++) { + for (const group of TefBarBuilder._voiceOrder) { + if (l < laneCounts[group]) { + order.push([group, l]); + } + } + } + if (order.length === 0) { + order.push([TefBarBuilder._automaticGroup, 0]); + } + + const barUnits: TefUnit[][] = []; + for (let b = 0; b < staff.bars.length; b++) { + barUnits.push([]); + } + for (const u of units) { + if (u.lane >= 0) { + barUnits[u.bar].push(u); + } + } + for (let b = 0; b < staff.bars.length; b++) { + const bar = staff.bars[b]; + const barLength = this._measureSizes[b] * TefBarBuilder.subUnitsPerCell; + // stems of the automatic voice point away from an upper voice + const automaticStems: BeamDirection | null = barUnits[b].some( + u => u.voiceGroup === TefBarBuilder._upperGroup + ) + ? BeamDirection.Down + : null; + for (let v = 0; v < order.length; v++) { + const voice = new Voice(); + bar.addVoice(voice); + const group = order[v][0]; + const lane = order[v][1]; + const laneUnits = barUnits[b].filter(u => u.voiceGroup === group && u.lane === lane); + const stems: BeamDirection | null = + group === TefBarBuilder._upperGroup + ? BeamDirection.Up + : group === TefBarBuilder._lowerGroup + ? BeamDirection.Down + : automaticStems; + this._fillVoice(info, voice, v, laneUnits, barLength, stems); + } + } + } + + /** + * Fills a voice with the units of one lane, which follow each other without overlap, and the rests between them. + */ + private _fillVoice( + info: TefTrackInfo, + voice: Voice, + voiceIndex: number, + units: TefUnit[], + barLength: number, + stems: BeamDirection | null + ): void { + if (units.length === 0) { + return; + } + const bar = units[0].bar; + let cursor = 0; + let previous: TefDuration | null = null; + const staff = info.track.staves[0]; + for (const u of units) { + if (u.start > cursor) { + this._addRests(voice, bar, voiceIndex, cursor, u.start, u.duration, previous); + } + const values = + u.duration && u.end - u.start === u.duration.length + ? [u.duration] + : TefBarBuilder._splitPlain(u.start, u.end); + if (!u.isRest) { + this._addGraceBeat(info, voice, u); + } + let beatStart = u.start; + for (let k = 0; k < values.length; k++) { + const beat = TefBarBuilder._createBeat(values[k]); + voice.addBeat(beat); + this._beats[bar].push( + new TefBeatRecord(beat, voiceIndex, beatStart, k === 0 ? u.events[0].column : -1) + ); + beatStart += values[k].length; + if (stems !== null) { + beat.preferredBeamDirection = stems; + } + if (!u.isRest) { + this._addNotes(info, beat, u, k > 0); + } + if (k === 0) { + TefBarBuilder._applyLayoutAttributes(staff, voice, beat, u); + } + } + previous = values.length > 0 ? values[values.length - 1] : previous; + cursor = u.end; + } + if (cursor < barLength) { + this._addRests(voice, bar, voiceIndex, cursor, barLength, null, previous); + } + } + + /** + * Applies beaming and visibility attributes. Hidden notes and rests are only hidden when no + * other displayed staff would still show them. + */ + private static _applyLayoutAttributes(staff: Staff, voice: Voice, beat: Beat, unit: TefUnit): void { + if (unit.isRest) { + let hidden = true; + for (const e of unit.events) { + const flags = e.cell.data[7]; + const inNotation = (flags & TefCell.restHiddenInNotationFlag) !== 0; + const inTab = (flags & TefCell.restHiddenInTabFlag) !== 0; + hidden = + hidden && + (inNotation || inTab) && + (inNotation || !staff.showStandardNotation) && + (inTab || !staff.showTablature); + } + beat.isEmpty = hidden; + if (unit.events[0].cell.data[4] === TefBarBuilder._restSecondaryBeamBreak) { + const before = TefBarBuilder._previousNoteBeat(voice, beat); + if (before && before.beamingMode === BeatBeamingMode.Auto) { + before.beamingMode = BeatBeamingMode.ForceSplitOnSecondaryToNext; + } + } + return; + } + for (const e of unit.events) { + const beaming: number = e.cell.data[7] & TefBarBuilder._excludeFromBeamingMask; + if (beaming === TefCell.excludeFromBeaming) { + beat.beamingMode = BeatBeamingMode.ForceSplitToNext; + const before = TefBarBuilder._previousNoteBeat(voice, beat); + if (before) { + before.beamingMode = BeatBeamingMode.ForceSplitToNext; + } + } + } + if (!staff.showTablature) { + for (let i = 0; i < unit.events.length && i < beat.notes.length; i++) { + if (unit.events[i].cell.data[4] === TefBarBuilder._hiddenInNotation) { + beat.notes[i].isVisible = false; + } + } + } + } + + private static _previousNoteBeat(voice: Voice, beat: Beat): Beat | null { + for (let i = voice.beats.indexOf(beat) - 1; i >= 0; i--) { + const candidate = voice.beats[i]; + if (!candidate.isRest && candidate.graceType === GraceType.None) { + return candidate; + } + } + return null; + } + + /** + * Fills a gap with rests. Gaps off the plain grid follow the tuplet of the neighbouring notes. + */ + private _addRests( + voice: Voice, + bar: number, + voiceIndex: number, + start: number, + end: number, + next: TefDuration | null, + previous: TefDuration | null + ): void { + const gap = end - start; + let values: TefDuration[]; + if (next && next.isTuplet && gap % next.length === 0) { + values = TefBarBuilder._repeat(next, gap / next.length); + } else if (gap % TefBarBuilder._smallestPlain !== 0 && previous && previous.isTuplet) { + // members of the tuplet up to the plain grid, plain values after it + values = []; + let tupletEnd = start; + while (tupletEnd % TefBarBuilder._smallestPlain !== 0 && tupletEnd + previous.length <= end) { + values.push(previous); + tupletEnd += previous.length; + } + const rest = + tupletEnd % TefBarBuilder._smallestPlain === 0 && end % TefBarBuilder._smallestPlain === 0 + ? TefBarBuilder._splitPlain(tupletEnd, end) + : TefBarBuilder._splitTuplet(end - tupletEnd, previous); + for (const v of rest) { + values.push(v); + } + } else { + values = TefBarBuilder._splitPlain(start, end); + } + let t = start; + for (const v of values) { + const beat = TefBarBuilder._createBeat(v); + voice.addBeat(beat); + this._beats[bar].push(new TefBeatRecord(beat, voiceIndex, t, -1)); + t += v.length; + } + } + + private static _repeat(d: TefDuration, count: number): TefDuration[] { + const values: TefDuration[] = []; + for (let i = 0; i < count; i++) { + values.push(d); + } + return values; + } + + /** + * Splits a stretch of real time into plain values aligned to their own length. + */ + private static _splitPlain(start: number, end: number): TefDuration[] { + const cell = TefBarBuilder.subUnitsPerCell; + const values: TefDuration[] = []; + let t = start; + while (end - t >= TefBarBuilder._smallestPlain) { + let cells = 64; + while ( + cells * cell > TefBarBuilder._smallestPlain && + ((t / cell) % cells !== 0 || t + cells * cell > end) + ) { + cells = cells / 2; + } + values.push(new TefDuration((64 / cells) as Duration, 0, -1, -1)); + t += cells * cell; + } + return values; + } + + /** + * Splits a stretch of real time into members of the given tuplet kind, longest first. + */ + private static _splitTuplet(length: number, tuplet: TefDuration): TefDuration[] { + const cell = TefBarBuilder.subUnitsPerCell; + let plain = (length * tuplet.tupletNumerator) / tuplet.tupletDenominator; + if (plain % TefBarBuilder._smallestPlain !== 0) { + return TefBarBuilder._splitPlain(0, length); + } + const values: TefDuration[] = []; + let cells = 64; + while (plain > 0 && cells * cell >= TefBarBuilder._smallestPlain) { + if (cells * cell <= plain) { + values.push( + new TefDuration((64 / cells) as Duration, 0, tuplet.tupletNumerator, tuplet.tupletDenominator) + ); + plain -= cells * cell; + } else { + cells = cells / 2; + } + } + return values; + } + + private static _createBeat(d: TefDuration): Beat { + const beat = new Beat(); + beat.duration = d.duration; + beat.dots = d.dots; + if (d.isTuplet) { + beat.tupletNumerator = d.tupletNumerator; + beat.tupletDenominator = d.tupletDenominator; + } + return beat; + } + + private _addNotes(info: TefTrackInfo, beat: Beat, unit: TefUnit, isContinuation: boolean): void { + let loudest = TefBarBuilder._tieDynamics; + for (const e of unit.events) { + const note = new Note(); + TefBarBuilder._setPosition(info, note, e.stringIndex, e.fret); + note.isTieDestination = e.isTied || isContinuation; + if (isContinuation) { + note.tieOrigin = e.lastNote; + } else { + e.firstNote = note; + } + e.lastNote = note; + switch (e.accidental) { + case 1: + note.accidentalMode = NoteAccidentalMode.ForceSharp; + break; + case 2: + note.accidentalMode = NoteAccidentalMode.ForceFlat; + break; + case 3: + note.accidentalMode = NoteAccidentalMode.ForceNatural; + break; + } + if (!e.isTied && e.dynamics < loudest) { + loudest = e.dynamics; + } + beat.addNote(note); + if (!isContinuation) { + this._applyEffects(info, beat, note, e, unit); + if (e.slurredGrace) { + e.slurredGrace.slurDestination = note; + note.slurOrigin = e.slurredGrace; + note.isSlurDestination = true; + } + } + } + // TablEdit shows no dynamics markings, also not on a beat of tied notes only + beat.showDynamics = false; + if (loudest < TefBarBuilder._tieDynamics) { + beat.dynamics = TefBarBuilder._dynamicsOf(loudest); + } + } + + /** + * Places the note on a string and fret, or on the drum instrument of a line and fret. + */ + private static _setPosition(info: TefTrackInfo, note: Note, stringIndex: number, fret: number): void { + if (info.isPercussion) { + const articulation = PercussionMapper.getArticulationById(info.openPitches[stringIndex] + fret); + if (articulation) { + note.percussionArticulation = info.track.getOrRegisterPercussionArticulation(articulation); + } + note.fret = Number.NaN; + } else { + note.string = info.stringCount - stringIndex; + note.fret = fret; + } + } + + private _addGraceBeat(info: TefTrackInfo, voice: Voice, unit: TefUnit): void { + let grace: Beat | null = null; + for (const e of unit.events) { + const data = e.cell.data; + if ((data[0] & TefCell.graceFlag) === 0) { + continue; + } + const kind = data[3] >> 5; + // complex graces become ornaments + if (kind >= 4) { + continue; + } + const extension = TefBarBuilder._graceExtension(e); + let stringIndex = e.stringIndex; + const fret = TefBarBuilder._graceFret(e); + if (extension && (extension[3] & TefBarBuilder._fromStringFlag) !== 0) { + const other = extension[3] & 0x0f; + if (other < info.stringCount) { + stringIndex = other; + } + } + // a grace on the main note's fret without its own duration is neither shown nor played + const hasDuration = extension !== null && (extension[1] & TefBarBuilder._graceDurationMask) > 1; + if (fret === e.fret && stringIndex === e.stringIndex && !hasDuration) { + continue; + } + if (!grace) { + grace = new Beat(); + grace.duration = Duration.ThirtySecond; + grace.graceType = + extension && (extension[7] & TefBarBuilder._acciaccaturaFlag) !== 0 + ? GraceType.BeforeBeat + : GraceType.OnBeat; + voice.addBeat(grace); + } + const note = new Note(); + TefBarBuilder._setPosition(info, note, stringIndex, fret); + switch (kind) { + case 1: + // alphaTab ties hammer-ons and pull-offs to one string, a slur keeps the connection + if (stringIndex === e.stringIndex) { + note.isHammerPullOrigin = true; + } else { + e.slurredGrace = note; + } + break; + case 2: + note.slideOutType = SlideOutType.Legato; + break; + case 3: + TefBarBuilder._addBend(note, (e.fret - fret) * 2, false); + break; + } + grace.addNote(note); + } + } + + private static _graceExtension(e: TefEvent): Uint8Array | null { + return e.extension && (e.extension[1] & TefBarBuilder._graceDurationMask) !== 0 ? e.extension : null; + } + + /** + * The fret of the grace or ornament note, which the extension record holds when it exceeds 31. + */ + private static _graceFret(e: TefEvent): number { + const extension = TefBarBuilder._graceExtension(e); + if (extension && (extension[4] & TefBarBuilder._wideFretFlag) !== 0) { + return extension[4] & 0x3f; + } + return e.cell.data[3] & 0x1f; + } + + /** + * Whether the first auxiliary note of a double or gruppetto lies below the main note: the grace byte + * holds the intervals with a direction flag, or the extension record holds both auxiliary frets. + */ + private static _turnStartsBelow(e: TefEvent): boolean { + const extension = TefBarBuilder._graceExtension(e); + if ((e.cell.data[3] & 0x1f) === 0 && extension && (extension[4] & TefBarBuilder._wideFretFlag) !== 0) { + return (extension[4] & 0x3f) < e.fret; + } + return (e.cell.data[3] & TefBarBuilder._ornamentDirectionFlag) !== 0; + } + + private _applyEffects(info: TefTrackInfo, beat: Beat, note: Note, e: TefEvent, unit: TefUnit): void { + const data = e.cell.data; + let effect = data[2] & 0x0f; + let combination = data[4] >> 4; + if (effect === 0 && data[4] === TefBarBuilder._noEffect) { + combination = 0; + } else if (effect === 0 && combination !== 3 && (data[4] & 0x0f) !== 0) { + effect = 0x10 + (data[4] & 0x0f); + } + const hasParameter = (data[0] & (TefCell.graceFlag | TefCell.pitchChangeFlag)) === 0; + let parameter = hasParameter ? data[3] : 0; + let hasParameterByte = hasParameter; + if (!hasParameter && e.extension && (e.extension[1] & TefBarBuilder._extensionParameterFlag) !== 0) { + parameter = e.extension[2]; + hasParameterByte = true; + } + + TefBarBuilder._applyEffect(beat, note, e, unit, effect, parameter, hasParameterByte); + switch (combination) { + case 3: + TefBarBuilder._applyBrush( + beat, + e, + unit, + true, + hasParameterByte && (parameter & 0x0f) !== 0 ? parameter & 0x07 : 4 + ); + break; + case 7: + note.harmonicType = HarmonicType.Artificial; + note.harmonicValue = 12; + break; + default: + TefBarBuilder._applyEffect( + beat, + note, + e, + unit, + TefBarBuilder._combinationEffects[combination], + parameter, + hasParameterByte + ); + break; + } + if (info.isLetRing) { + note.isLetRing = true; + } + + if ((data[0] & TefCell.graceFlag) !== 0) { + switch (data[3] >> 5) { + case 4: + note.ornament = + TefBarBuilder._graceFret(e) < e.fret ? NoteOrnament.LowerMordent : NoteOrnament.UpperMordent; + break; + case 5: + case 6: + note.ornament = TefBarBuilder._turnStartsBelow(e) ? NoteOrnament.InvertedTurn : NoteOrnament.Turn; + break; + case 7: { + const trillFret = TefBarBuilder._graceFret(e); + note.trillValue = + trillFret !== e.fret && !info.isPercussion ? trillFret + note.stringTuning : note.realValue + 2; + break; + } + } + } + + if ((data[0] & TefCell.pitchChangeFlag) !== 0) { + const semitones = (data[3] & 0x07) - 4; + if (semitones > 0) { + note.bendPoints = null; + note.addBendPoint(new BendPoint(0, semitones * 2)); + note.addBendPoint(new BendPoint(BendPoint.MaxPosition, semitones * 2)); + } + } + + const fingering = data[6] & 0x1f; + const leftHand = fingering % 6; + const rightHand = Math.floor(fingering / 6); + if (leftHand > 0) { + note.leftHandFinger = TefBarBuilder._fingerOf(leftHand - 1); + } + if (rightHand > 0) { + note.rightHandFinger = TefBarBuilder._fingerOf(rightHand - 1); + } + const stroke = TefBarBuilder._pickStrokeOf(data[6]); + if (stroke !== PickStroke.None) { + beat.pickStroke = stroke; + } + + switch (data[7] & TefCell.ottavaMask) { + case TefCell.ottavaBassa: + beat.ottava = Ottavia._8vb; + break; + case TefCell.ottavaAlta: + beat.ottava = Ottavia._8va; + break; + } + } + + private static _applyEffect( + beat: Beat, + note: Note, + e: TefEvent, + unit: TefUnit, + effect: number, + parameter: number, + hasParameter: boolean + ): void { + switch (effect) { + case 1: + case 2: + note.isHammerPullOrigin = true; + break; + case 3: + // the destination is only struck again when it carries a pick stroke + note.slideOutType = + e.next && TefBarBuilder._hasPickStroke(e.next) ? SlideOutType.Shift : SlideOutType.Legato; + break; + case 4: + TefBarBuilder._addBend(note, Math.max(1, parameter & 0x0f), false); + break; + case 5: + TefBarBuilder._applyBrush(beat, e, unit, false, hasParameter ? ((parameter >> 4) & 0x07) + 1 : 4); + break; + case 14: { + const speed = ((parameter >> 4) & 0x07) + 4; + TefBarBuilder._applyBrush(beat, e, unit, true, !hasParameter ? 4 : speed > 8 ? speed - 8 : speed); + break; + } + case 6: + note.harmonicType = HarmonicType.Natural; + note.harmonicValue = ModelUtils.deltaFretToHarmonicValue(note.fret); + break; + case 7: + note.harmonicType = HarmonicType.Artificial; + note.harmonicValue = TefBarBuilder._artificialHarmonicFret(parameter, e.cell.data[4]); + break; + case 8: + note.isPalmMute = true; + break; + case 9: + beat.tap = true; + break; + case 10: + note.vibrato = VibratoType.Slight; + break; + case 11: { + const tremolo = new TremoloPickingEffect(); + tremolo.marks = 3; + beat.tremoloPicking = tremolo; + break; + } + case 12: + case 13: + if (e.next && e.next.fret !== e.fret) { + TefBarBuilder._addBend(note, (e.next.fret - e.fret) * 2, effect === 13); + } + break; + case 15: + note.isDead = true; + break; + case 0x11: + note.isLetRing = true; + break; + case 0x12: + beat.slap = true; + break; + case 0x13: + beat.rasgueado = Rasgueado.Ii; + break; + case 0x14: + note.isGhost = true; + break; + case 0x15: + case 0x16: + if (e.next && e.next.fret !== e.fret) { + TefBarBuilder._addWhammy(beat, (e.next.fret - e.fret) * 2, effect === 0x16); + } + break; + case 0x17: + note.isStaccato = true; + break; + case 0x18: + beat.fade = FadeType.FadeIn; + break; + case 0x19: + beat.fade = FadeType.FadeOut; + break; + } + } + + /** + * The brush direction follows the string carrying the effect: on the lowest sounding string + * the chord is struck from the bass, on the highest from the treble. + */ + private static _applyBrush(beat: Beat, e: TefEvent, unit: TefUnit, isRoll: boolean, speed: number): void { + let highest = e.stringIndex; + for (const other of unit.events) { + if (other.stringIndex < highest) { + highest = other.stringIndex; + } + } + const fromTreble = e.stringIndex === highest && unit.events.length > 1; + if (isRoll) { + beat.brushType = fromTreble ? BrushType.ArpeggioUp : BrushType.ArpeggioDown; + } else { + beat.brushType = fromTreble ? BrushType.BrushUp : BrushType.BrushDown; + } + beat.brushDuration = speed * TefBarBuilder._strumTicksPerSpeed * Math.max(0, unit.events.length - 1); + } + + private static _hasPickStroke(e: TefEvent): boolean { + return TefBarBuilder._pickStrokeOf(e.cell.data[6]) !== PickStroke.None; + } + + private static _pickStrokeOf(value: number): PickStroke { + switch (value >> 5) { + case 3: + return PickStroke.Up; + case 4: + return PickStroke.Down; + } + return PickStroke.None; + } + + private static _addWhammy(beat: Beat, quarterTones: number, isDiveReturn: boolean): void { + beat.whammyBarPoints = null; + beat.addWhammyBarPoint(new BendPoint(0, 0)); + beat.addWhammyBarPoint(new BendPoint(BendPoint.MaxPosition / 2, quarterTones)); + beat.addWhammyBarPoint(new BendPoint(BendPoint.MaxPosition, isDiveReturn ? 0 : quarterTones)); + } + + private static _addBend(note: Note, quarterTones: number, release: boolean): void { + if (quarterTones === 0) { + return; + } + note.bendPoints = null; + note.addBendPoint(new BendPoint(0, 0)); + if (release) { + note.addBendPoint(new BendPoint(BendPoint.MaxPosition / 4, quarterTones)); + note.addBendPoint(new BendPoint((BendPoint.MaxPosition * 3) / 4, quarterTones)); + note.addBendPoint(new BendPoint(BendPoint.MaxPosition, 0)); + } else { + note.addBendPoint(new BendPoint(BendPoint.MaxPosition / 2, quarterTones)); + note.addBendPoint(new BendPoint(BendPoint.MaxPosition, quarterTones)); + } + } + + /** + * The harmonic fret of an artificial harmonic. The interval is stored with the note or in the + * combination byte; anything else is the octave. + */ + private static _artificialHarmonicFret(parameter: number, combination: number): number { + let value = 0; + const parameterTag: number = parameter & 0xf0; + const combinationTag: number = combination & 0xf0; + if (parameterTag === TefBarBuilder._harmonicIntervalTag) { + value = parameter & 0x0f; + } else if (combinationTag === TefBarBuilder._harmonicIntervalTag) { + value = combination & 0x0f; + } + switch (value) { + case 3: + case 4: + case 5: + case 7: + case 9: + return value; + } + return 12; + } + + private static _fingerOf(value: number): Fingers { + switch (value) { + case 0: + return Fingers.Thumb; + case 1: + return Fingers.IndexFinger; + case 2: + return Fingers.MiddleFinger; + case 3: + return Fingers.AnnularFinger; + } + return Fingers.LittleFinger; + } + + private static _dynamicsOf(value: number): DynamicValue { + switch (value) { + case 0: + return DynamicValue.FFF; + case 1: + return DynamicValue.FF; + case 2: + return DynamicValue.F; + case 3: + return DynamicValue.MF; + case 4: + return DynamicValue.MP; + case 5: + return DynamicValue.P; + } + return DynamicValue.PP; + } +} diff --git a/packages/alphatab/src/importer/tablEdit/TefBinary.ts b/packages/alphatab/src/importer/tablEdit/TefBinary.ts new file mode 100644 index 0000000000..b9faa3d50c --- /dev/null +++ b/packages/alphatab/src/importer/tablEdit/TefBinary.ts @@ -0,0 +1,121 @@ +import { FormatError } from '@coderline/alphatab/FormatError'; +import { IOHelper } from '@coderline/alphatab/io/IOHelper'; +import { OverflowError } from '@coderline/alphatab/io/IReadable'; + +/** + * Bounds-checked little-endian access to the bytes of a TablEdit file. + * @internal + */ +export class TefBinary { + public readonly bytes: Uint8Array; + + public constructor(bytes: Uint8Array) { + this.bytes = bytes; + } + + public get length(): number { + return this.bytes.length; + } + + private _requireRange(offset: number, length: number): void { + if (offset < 0 || offset + length > this.bytes.length) { + throw new FormatError(`TablEdit data at ${offset} (${length} bytes) is outside of the file`); + } + } + + public u8(offset: number): number { + this._requireRange(offset, 1); + return this.bytes[offset]; + } + + public s8(offset: number): number { + const v = this.u8(offset); + return v > 127 ? v - 256 : v; + } + + public u16(offset: number): number { + this._requireRange(offset, 2); + return this.bytes[offset] | (this.bytes[offset + 1] << 8); + } + + public s16(offset: number): number { + const v = this.u16(offset); + return v > 32767 ? v - 65536 : v; + } + + public u32(offset: number): number { + this._requireRange(offset, 4); + const b = this.bytes; + return (b[offset] | (b[offset + 1] << 8) | (b[offset + 2] << 16)) + b[offset + 3] * 16777216; + } + + /** + * Reads a text with a 16-bit length prefix, without trailing NUL bytes. + */ + public readString(offset: number, maxLength: number): string { + const length = this.u16(offset); + TefBinary.ensureLoopBoundary(length, maxLength, 'string length'); + this._requireRange(offset + 2, length); + let end = offset + 2 + length; + while (end > offset + 2 && this.bytes[end - 1] === 0) { + end--; + } + return TefBinary._decode(this.bytes.subarray(offset + 2, end)); + } + + /** + * Reads a NUL-terminated text of at most the given length. + */ + public readCString(offset: number, maxLength: number): string { + this._requireRange(offset, maxLength); + let end = offset; + while (end < offset + maxLength && this.bytes[end] !== 0) { + end++; + } + return TefBinary._decode(this.bytes.subarray(offset, end)); + } + + /** + * Texts are UTF-8, but older files may still carry Windows-1252 text. + */ + private static _decode(data: Uint8Array): string { + return IOHelper.toString(data, TefBinary._isValidUtf8(data) ? 'utf-8' : 'windows-1252'); + } + + private static _isValidUtf8(data: Uint8Array): boolean { + let i = 0; + while (i < data.length) { + const b = data[i]; + let follow = 0; + if (b < 0x80) { + follow = 0; + } else if (b >= 0xc2 && b <= 0xdf) { + follow = 1; + } else if (b >= 0xe0 && b <= 0xef) { + follow = 2; + } else if (b >= 0xf0 && b <= 0xf4) { + follow = 3; + } else { + return false; + } + if (i + follow >= data.length && follow > 0) { + return false; + } + for (let k = 1; k <= follow; k++) { + if ((data[i + k] & 0xc0) !== 0x80) { + return false; + } + } + i += follow + 1; + } + return true; + } + + public static ensureLoopBoundary(value: number, maximumValue: number, label: string) { + if (value > maximumValue) { + throw new OverflowError( + `'${label}' with value ${value} has exceeded the internal safety threshold of ${maximumValue}` + ); + } + } +} diff --git a/packages/alphatab/src/importer/tablEdit/TefCell.ts b/packages/alphatab/src/importer/tablEdit/TefCell.ts new file mode 100644 index 0000000000..91eab48804 --- /dev/null +++ b/packages/alphatab/src/importer/tablEdit/TefCell.ts @@ -0,0 +1,41 @@ +/** + * A raw grid cell of a TablEdit file, with its cell codes and flags. + * @internal + */ +export class TefCell { + public static readonly typeMask = 0x3f; + public static readonly rest = 0x33; + public static readonly graceFlag = 0x40; + public static readonly pitchChangeFlag = 0x80; + + public static readonly chordMarker = 0x35; + public static readonly lineBreakMarker = 0x36; + public static readonly accentMarker = 0x37; + public static readonly crescendoMarker = 0x38; + public static readonly textMarker = 0x39; + public static readonly connectionMarker = 0x3d; + public static readonly fingeringMarker = 0x3e; + public static readonly extensionMarker = 0x7d; + public static readonly patchMarker = 0x7e; + public static readonly symbolMarker = 0xb6; + public static readonly readingGuideMarker = 0xb7; + public static readonly syncopationMarker = 0xfd; + public static readonly tempoMarker = 0xfe; + + public static readonly restHiddenInNotationFlag = 0x01; + public static readonly restHiddenInTabFlag = 0x20; + public static readonly excludeFromBeaming = 0x08; + public static readonly ottavaMask = 0x44; + public static readonly ottavaBassa = 0x40; + public static readonly ottavaAlta = 0x44; + + /** + * The grid position in 1/64 notes from the start of the piece. + */ + public position: number = 0; + /** + * The string row counted over all instruments. + */ + public row: number = 0; + public data!: Uint8Array; +} diff --git a/packages/alphatab/src/importer/tablEdit/TefReadingList.ts b/packages/alphatab/src/importer/tablEdit/TefReadingList.ts new file mode 100644 index 0000000000..9bfa356448 --- /dev/null +++ b/packages/alphatab/src/importer/tablEdit/TefReadingList.ts @@ -0,0 +1,511 @@ +import { Logger } from '@coderline/alphatab/Logger'; +import { MidiPlaybackController } from '@coderline/alphatab/midi/MidiPlaybackController'; +import { Direction } from '@coderline/alphatab/model/Direction'; +import type { MasterBar } from '@coderline/alphatab/model/MasterBar'; +import type { Score } from '@coderline/alphatab/model/Score'; +import { Section } from '@coderline/alphatab/model/Section'; + +/** + * One entry of the TablEdit reading list (0-based, inclusive bar range). + * @internal + */ +export class TefReadingListItem { + public first: number = 0; + public last: number = 0; + public label: string = ''; +} + +/** + * A graphical repeat sign of a TablEdit file. + * @internal + */ +export class TefReadingGuide { + public bar: number = 0; + public value: number = 0; +} + +/** + * A D.C. or D.S. jump of the derived structure and the bars played after it. + * @internal + */ +export class TefJumpPass { + /** + * The bar holding the jump. + */ + public jump: number = 0; + /** + * The bar jumped to, 0 for a D.C. + */ + public target: number = 0; + /** + * The index of the segno kind, -1 for a D.C. + */ + public segnoKind: number = -1; + /** + * The bars played after the jump, in order. + */ + public played: number[] = []; + /** + * The bar holding the To Coda, -1 while playing on. + */ + public end: number = -1; + /** + * The bar of the coda, -1 while playing on. + */ + public coda: number = -1; + /** + * The index of the coda kind, -1 while playing on. + */ + public codaKind: number = -1; +} + +/** + * Expresses the TablEdit reading list, which defines the playback order as a list of bar ranges, + * through repeats, alternate endings and jump directions. + * @internal + */ +export class TefReadingList { + private static readonly _repeatStart = 0x40; + private static readonly _repeatEnd = 0x80; + private static readonly _firstEnding = 0x01; + private static readonly _secondEnding = 0x02; + private static readonly _segnoTargets = [Direction.TargetSegno, Direction.TargetSegnoSegno]; + private static readonly _segnoJumps = [Direction.JumpDalSegno, Direction.JumpDalSegnoSegno]; + private static readonly _codaJumps = [Direction.JumpDaCoda, Direction.JumpDaDoubleCoda]; + private static readonly _codaTargets = [Direction.TargetCoda, Direction.TargetDoubleCoda]; + + /** + * Applies the structure before the score is finished. + */ + public static apply(score: Score, items: TefReadingListItem[], guides: TefReadingGuide[]): void { + const runs = TefReadingList._runs(score, items); + for (const item of items) { + const label = item.label; + if ( + label.length > 0 && + label.charAt(0) !== ' ' && + item.first >= 0 && + item.first < score.masterBars.length + ) { + const section = new Section(); + section.text = label.trim(); + score.masterBars[item.first].section = section; + } + } + if (runs.length === 0) { + TefReadingList._applyGuides(score, guides); + return; + } + TefReadingList._derive(score.masterBars, runs); + } + + /** + * Compares the playback order of the finished score with the reading list. + * @returns true if alphaTab plays the bars in the order of the reading list. + */ + public static verify(score: Score, items: TefReadingListItem[]): boolean { + const runs = TefReadingList._runs(score, items); + if (runs.length === 0) { + return true; + } + const expected = TefReadingList.expand(runs); + const actual = TefReadingList.playbackOrder(score, expected.length * 2 + 16); + let same = expected.length === actual.length; + for (let i = 0; same && i < expected.length; i++) { + same = expected[i] === actual[i]; + } + if (!same) { + Logger.warning( + 'TablEdit', + `The reading list cannot be expressed exactly, playback order differs: expected [${expected.join( + ',' + )}], actual [${actual.join(',')}]` + ); + } + return same; + } + + public static expand(runs: number[][]): number[] { + const order: number[] = []; + for (const run of runs) { + for (let b = run[0]; b <= run[1]; b++) { + order.push(b); + } + } + return order; + } + + public static playbackOrder(score: Score, maxBars: number): number[] { + const controller = new MidiPlaybackController(score); + const order: number[] = []; + while (!controller.finished && order.length < maxBars) { + const index = controller.index; + controller.processCurrent(); + if (controller.shouldPlay) { + order.push(index); + } + controller.moveNext(); + } + return order; + } + + /** + * Validates the items and merges ranges that continue each other. + */ + private static _runs(score: Score, items: TefReadingListItem[]): number[][] { + const runs: number[][] = []; + const barCount = score.masterBars.length; + for (const item of items) { + if (item.first < 0 || item.first >= barCount || item.last < item.first) { + break; + } + const last = Math.min(item.last, barCount - 1); + if (runs.length > 0 && runs[runs.length - 1][1] + 1 === item.first) { + runs[runs.length - 1][1] = last; + } else { + runs.push([item.first, last]); + } + } + // a list which simply plays everything once needs no structure + if (runs.length === 1 && runs[0][0] === 0 && runs[0][1] === barCount - 1) { + return []; + } + return runs; + } + + /** + * Marks repeats, alternate endings and jumps so that alphaTab's playback controller plays the runs in order. + * Before a jump and after a coda playback is normal: going back becomes a repeat, or a D.C./D.S. when a repeat + * cannot express it. After a jump repeats play only their final pass, so skipping the first ending needs no + * marking, any other skip forward is the To Coda and its coda, and the end of the list is the Fine. + */ + private static _derive(bars: MasterBar[], runs: number[][]): void { + const passes: TefJumpPass[] = []; + let pass: TefJumpPass | null = null; + let furthest = -1; + let lastRepeatEnd = -1; + let i = 0; + while (i < runs.length) { + const last = runs[i][1]; + furthest = Math.max(furthest, last); + if (pass) { + for (let b = runs[i][0]; b <= last; b++) { + pass.played.push(b); + } + } + if (i + 1 >= runs.length) { + // the list stops before the end of the piece + if (pass && last < bars.length - 1) { + bars[last].addDirection(Direction.TargetFine); + TefReadingList._replaceJump(bars[pass.jump], Direction.TargetFine); + } + break; + } + const nextFirst = runs[i + 1][0]; + + if (pass) { + // going back cannot be expressed after a jump, the pass goes on + if (nextFirst > last + 1 && !TefReadingList._isFirstEnding(bars, last + 1, nextFirst)) { + TefReadingList._addCoda(bars, passes, pass, nextFirst); + pass = null; + } + i++; + continue; + } + + if (nextFirst > last) { + // skipping bars without a jump cannot be expressed + i++; + continue; + } + + if (last === furthest && nextFirst > lastRepeatEnd) { + const next = TefReadingList._addRepeat(bars, runs, i); + if (next >= 0) { + lastRepeatEnd = last; + i = next; + continue; + } + } + + pass = TefReadingList._addJump(bars, passes, last, nextFirst); + i++; + } + } + + /** + * Expresses going back from the run at the given index as a repeat. + * @returns the index of the run to continue with, or -1 if a repeat cannot express it. + */ + private static _addRepeat(bars: MasterBar[], runs: number[][], i: number): number { + const last = runs[i][1]; + const nextFirst = runs[i + 1][0]; + const nextLast = runs[i + 1][1]; + if (nextLast < last) { + // the second pass skips the first ending, then the bars after the repeat follow + if (i + 2 >= runs.length || runs[i + 2][0] !== last + 1) { + return -1; + } + TefReadingList._startRepeat(bars, nextFirst); + bars[last].repeatCount = 2; + const endingLength = last - nextLast; + for (let b = nextLast + 1; b <= last; b++) { + bars[b].alternateEndings |= TefReadingList._firstEnding; + } + const end = Math.min(runs[i + 2][1], last + endingLength); + for (let b = last + 1; b <= end; b++) { + bars[b].alternateEndings |= TefReadingList._secondEnding; + } + return i + 2; + } + + TefReadingList._startRepeat(bars, nextFirst); + if (nextLast === last) { + let passes = 2; + let k = i + 1; + while (k + 1 < runs.length && runs[k + 1][0] === nextFirst && runs[k + 1][1] === last) { + passes++; + k++; + } + // a last pass merged with the bars after the repeat + if (k + 1 < runs.length && runs[k + 1][0] === nextFirst && runs[k + 1][1] > last) { + passes++; + k++; + runs[k][0] = last + 1; + } + bars[last].repeatCount = passes; + return k; + } + // the second pass continues after the repeat + bars[last].repeatCount = 2; + runs[i + 1][0] = last + 1; + return i + 1; + } + + /** + * Opens a repeat. A second ending of the previous repeat ends before it. + */ + private static _startRepeat(bars: MasterBar[], first: number): void { + bars[first].isRepeatStart = true; + for (let b = first; b < bars.length && bars[b].alternateEndings === TefReadingList._secondEnding; b++) { + bars[b].alternateEndings = 0; + } + } + + /** + * Whether all bars of the range are first endings, which the final pass of their repeat skips. + */ + private static _isFirstEnding(bars: MasterBar[], from: number, to: number): boolean { + for (let b = from; b < to; b++) { + if (bars[b].alternateEndings !== TefReadingList._firstEnding) { + return false; + } + } + return true; + } + + /** + * Adds a D.C. (jump to the first bar) or D.S. to the given target. + * @returns the pass played after the jump, or null if the bar already jumps elsewhere. + */ + private static _addJump( + bars: MasterBar[], + passes: TefJumpPass[], + jump: number, + target: number + ): TefJumpPass | null { + if (passes.some(p => p.jump === jump)) { + // a jump is only taken once + return null; + } + const pass = new TefJumpPass(); + pass.jump = jump; + pass.target = target; + if (target === 0) { + bars[jump].addDirection(Direction.JumpDaCapo); + } else { + pass.segnoKind = TefReadingList._segnoKind(passes, pass); + bars[target].addDirection(TefReadingList._segnoTargets[pass.segnoKind]); + bars[jump].addDirection(TefReadingList._segnoJumps[pass.segnoKind]); + } + passes.push(pass); + return pass; + } + + /** + * A D.S. to a bar with a segno reuses it. Every other target gets the sign not used yet, or else + * the one that all jumps still find, as the playback controller looks for the nearest sign before the jump. + */ + private static _segnoKind(passes: TefJumpPass[], pass: TefJumpPass): number { + for (const p of passes) { + if (p.segnoKind >= 0 && p.target === pass.target) { + return p.segnoKind; + } + } + for (let kind = 0; kind < TefReadingList._segnoTargets.length; kind++) { + if (!passes.some(p => p.segnoKind === kind)) { + return kind; + } + } + for (let kind = 0; kind < TefReadingList._segnoTargets.length; kind++) { + const same = passes.filter(p => p.segnoKind === kind); + same.push(pass); + const targets = same.map(p => p.target); + if (same.every(p => TefReadingList._nearest(targets, p.jump, true) === p.target)) { + return kind; + } + } + return 0; + } + + /** + * Ends the pass with a To Coda on its last bar and continues at the given coda. + */ + private static _addCoda(bars: MasterBar[], passes: TefJumpPass[], pass: TefJumpPass, coda: number): void { + pass.end = pass.played[pass.played.length - 1]; + pass.coda = coda; + let preferred = -1; + for (const p of passes) { + if (p.codaKind >= 0 && p.coda === coda) { + preferred = p.codaKind; + break; + } + } + for (let unused = 0; preferred < 0 && unused < TefReadingList._codaTargets.length; unused++) { + if (!passes.some(p => p.codaKind === unused)) { + preferred = unused; + } + } + if (preferred < 0) { + preferred = 0; + } + let kind = preferred; + for (let k = 0; k < TefReadingList._codaTargets.length; k++) { + const candidate = (preferred + k) % TefReadingList._codaTargets.length; + if (TefReadingList._codasResolve(passes, pass, candidate)) { + kind = candidate; + break; + } + } + pass.codaKind = kind; + bars[pass.end].addDirection(TefReadingList._codaJumps[kind]); + bars[coda].addDirection(TefReadingList._codaTargets[kind]); + TefReadingList._replaceJump(bars[pass.jump], TefReadingList._codaTargets[kind]); + } + + /** + * Whether every pass ending with the given To Coda kind (including the new pass) reaches its own + * To Coda first and finds its own coda from there. + */ + private static _codasResolve(passes: TefJumpPass[], pass: TefJumpPass, kind: number): boolean { + const same = passes.filter(p => p !== pass && p.codaKind === kind); + same.push(pass); + const ends = same.map(p => p.end); + const codas = same.map(p => p.coda); + for (const p of same) { + let first = 0; + while (ends.indexOf(p.played[first]) < 0) { + first++; + } + if (first !== p.played.length - 1 || TefReadingList._nearest(codas, p.end, false) !== p.coda) { + return false; + } + } + return true; + } + + /** + * The target the playback controller finds from the given bar: the nearest one in the preferred + * direction (including the bar itself), else the nearest one in the other direction. + */ + private static _nearest(targets: number[], from: number, backwardsFirst: boolean): number { + let before = -1; + let after = -1; + for (const t of targets) { + if (t <= from && (before < 0 || t > before)) { + before = t; + } + if (t >= from && (after < 0 || t < after)) { + after = t; + } + } + if (backwardsFirst) { + return before >= 0 ? before : after; + } + return after >= 0 ? after : before; + } + + /** + * Turns a plain D.C., D.S. or D.S.S. into its "al Coda", "al Double Coda" or "al Fine" form. + */ + private static _replaceJump(jump: MasterBar, target: Direction): void { + const forms = [ + [ + Direction.JumpDaCapo, + Direction.JumpDaCapoAlCoda, + Direction.JumpDaCapoAlDoubleCoda, + Direction.JumpDaCapoAlFine + ], + [ + Direction.JumpDalSegno, + Direction.JumpDalSegnoAlCoda, + Direction.JumpDalSegnoAlDoubleCoda, + Direction.JumpDalSegnoAlFine + ], + [ + Direction.JumpDalSegnoSegno, + Direction.JumpDalSegnoSegnoAlCoda, + Direction.JumpDalSegnoSegnoAlDoubleCoda, + Direction.JumpDalSegnoSegnoAlFine + ] + ]; + for (const form of forms) { + if (jump.directions && jump.directions.has(form[0])) { + jump.directions.delete(form[0]); + jump.addDirection( + target === Direction.TargetFine ? form[3] : target === Direction.TargetCoda ? form[1] : form[2] + ); + return; + } + } + } + + /** + * Without a reading list, the graphical repeat signs are shown as they are. + */ + private static _applyGuides(score: Score, guides: TefReadingGuide[]): void { + for (const guide of guides) { + if (guide.bar >= score.masterBars.length) { + continue; + } + const bar = score.masterBars[guide.bar]; + if ((guide.value & TefReadingList._repeatStart) !== 0) { + bar.isRepeatStart = true; + } + if ((guide.value & TefReadingList._repeatEnd) !== 0) { + bar.repeatCount = 2; + } + const ending = guide.value & 0x07; + if (ending > 0 && ending < 7) { + bar.alternateEndings |= 1 << (ending - 1); + } + switch ((guide.value >> 3) & 0x07) { + case 1: + bar.addDirection(Direction.TargetFine); + break; + case 2: + bar.addDirection(Direction.JumpDalSegno); + break; + case 3: + bar.addDirection(Direction.TargetSegno); + break; + case 4: + bar.addDirection(Direction.JumpDaCoda); + break; + case 5: + bar.addDirection(Direction.TargetCoda); + break; + } + } + } +} diff --git a/packages/alphatab/src/model/Note.ts b/packages/alphatab/src/model/Note.ts index 5a84b792d7..a1e151ca9f 100644 --- a/packages/alphatab/src/model/Note.ts +++ b/packages/alphatab/src/model/Note.ts @@ -1161,6 +1161,25 @@ export class Note { private static _noteIdLookupKey = 'NoteIdLookup'; + /** + * The origin note with the given id. Origins in earlier voices and bars are registered already, an origin + * in a later voice of the same bar (e.g. a tie from another voice) is not chained yet. + */ + private _linkedNote(noteIdLookup: Map, id: number): Note { + if (!noteIdLookup.has(id)) { + for (const voice of this.beat.voice.bar.voices) { + for (const beat of voice.beats) { + for (const note of beat.notes) { + if (note.id === id) { + noteIdLookup.set(id, note); + } + } + } + } + } + return noteIdLookup.get(id)!; + } + private _noteIdBag: NoteIdBag | null = null; public chain(sharedDataBag: Map | null = null) { // mainly for backwards compat in case we reach this code from somewhere outside. @@ -1191,23 +1210,22 @@ export class Note { noteIdLookup.set(this.id, this); } - // on any effect destiniation, lookup the origin which should already be - // registered + // on any effect destiniation, lookup the origin if (this._noteIdBag.hammerPullOriginNoteId !== -1) { - this.hammerPullOrigin = noteIdLookup.get(this._noteIdBag.hammerPullOriginNoteId)!; + this.hammerPullOrigin = this._linkedNote(noteIdLookup, this._noteIdBag.hammerPullOriginNoteId); this.hammerPullOrigin.hammerPullDestination = this; } if (this._noteIdBag.tieOriginNoteId !== -1) { - this.tieOrigin = noteIdLookup.get(this._noteIdBag.tieOriginNoteId)!; + this.tieOrigin = this._linkedNote(noteIdLookup, this._noteIdBag.tieOriginNoteId); this.tieOrigin.tieDestination = this; } if (this._noteIdBag.slurOriginNoteId !== -1) { - this.slurOrigin = noteIdLookup.get(this._noteIdBag.slurOriginNoteId)!; + this.slurOrigin = this._linkedNote(noteIdLookup, this._noteIdBag.slurOriginNoteId); this.slurOrigin.slurDestination = this; } if (this._noteIdBag.slideOriginNoteId !== -1) { - this.slideOrigin = noteIdLookup.get(this._noteIdBag.slideOriginNoteId)!; + this.slideOrigin = this._linkedNote(noteIdLookup, this._noteIdBag.slideOriginNoteId); this.slideOrigin.slideTarget = this; } diff --git a/packages/alphatab/test-data/corrupt/healthy.tef b/packages/alphatab/test-data/corrupt/healthy.tef new file mode 100644 index 0000000000..dcdf14d5ac Binary files /dev/null and b/packages/alphatab/test-data/corrupt/healthy.tef differ diff --git a/packages/alphatab/test-data/tabledit/attributes.tef b/packages/alphatab/test-data/tabledit/attributes.tef new file mode 100644 index 0000000000..acdd115464 Binary files /dev/null and b/packages/alphatab/test-data/tabledit/attributes.tef differ diff --git a/packages/alphatab/test-data/tabledit/bars-flags.tef b/packages/alphatab/test-data/tabledit/bars-flags.tef new file mode 100644 index 0000000000..6c72f4ed41 Binary files /dev/null and b/packages/alphatab/test-data/tabledit/bars-flags.tef differ diff --git a/packages/alphatab/test-data/tabledit/bars.tef b/packages/alphatab/test-data/tabledit/bars.tef new file mode 100644 index 0000000000..441b28bb86 Binary files /dev/null and b/packages/alphatab/test-data/tabledit/bars.tef differ diff --git a/packages/alphatab/test-data/tabledit/connections.tef b/packages/alphatab/test-data/tabledit/connections.tef new file mode 100644 index 0000000000..d6f1adb6a8 Binary files /dev/null and b/packages/alphatab/test-data/tabledit/connections.tef differ diff --git a/packages/alphatab/test-data/tabledit/drums.tef b/packages/alphatab/test-data/tabledit/drums.tef new file mode 100644 index 0000000000..5574e16dd6 Binary files /dev/null and b/packages/alphatab/test-data/tabledit/drums.tef differ diff --git a/packages/alphatab/test-data/tabledit/durations.tef b/packages/alphatab/test-data/tabledit/durations.tef new file mode 100644 index 0000000000..e9758c0d94 Binary files /dev/null and b/packages/alphatab/test-data/tabledit/durations.tef differ diff --git a/packages/alphatab/test-data/tabledit/effects-b5.tef b/packages/alphatab/test-data/tabledit/effects-b5.tef new file mode 100644 index 0000000000..b392923fea Binary files /dev/null and b/packages/alphatab/test-data/tabledit/effects-b5.tef differ diff --git a/packages/alphatab/test-data/tabledit/effects-chord.tef b/packages/alphatab/test-data/tabledit/effects-chord.tef new file mode 100644 index 0000000000..03dd882330 Binary files /dev/null and b/packages/alphatab/test-data/tabledit/effects-chord.tef differ diff --git a/packages/alphatab/test-data/tabledit/effects-note.tef b/packages/alphatab/test-data/tabledit/effects-note.tef new file mode 100644 index 0000000000..3debc94ac9 Binary files /dev/null and b/packages/alphatab/test-data/tabledit/effects-note.tef differ diff --git a/packages/alphatab/test-data/tabledit/effects-parameters.tef b/packages/alphatab/test-data/tabledit/effects-parameters.tef new file mode 100644 index 0000000000..f9d95ff78e Binary files /dev/null and b/packages/alphatab/test-data/tabledit/effects-parameters.tef differ diff --git a/packages/alphatab/test-data/tabledit/effects-speed.tef b/packages/alphatab/test-data/tabledit/effects-speed.tef new file mode 100644 index 0000000000..cbc1b492ee Binary files /dev/null and b/packages/alphatab/test-data/tabledit/effects-speed.tef differ diff --git a/packages/alphatab/test-data/tabledit/effects-target.tef b/packages/alphatab/test-data/tabledit/effects-target.tef new file mode 100644 index 0000000000..4ac47f18aa Binary files /dev/null and b/packages/alphatab/test-data/tabledit/effects-target.tef differ diff --git a/packages/alphatab/test-data/tabledit/fermata.tef b/packages/alphatab/test-data/tabledit/fermata.tef new file mode 100644 index 0000000000..6cb54bae64 Binary files /dev/null and b/packages/alphatab/test-data/tabledit/fermata.tef differ diff --git a/packages/alphatab/test-data/tabledit/fingering.tef b/packages/alphatab/test-data/tabledit/fingering.tef new file mode 100644 index 0000000000..f4417acd53 Binary files /dev/null and b/packages/alphatab/test-data/tabledit/fingering.tef differ diff --git a/packages/alphatab/test-data/tabledit/grace-kind0.tef b/packages/alphatab/test-data/tabledit/grace-kind0.tef new file mode 100644 index 0000000000..cd5186cfec Binary files /dev/null and b/packages/alphatab/test-data/tabledit/grace-kind0.tef differ diff --git a/packages/alphatab/test-data/tabledit/grace-string.tef b/packages/alphatab/test-data/tabledit/grace-string.tef new file mode 100644 index 0000000000..1deff125fb Binary files /dev/null and b/packages/alphatab/test-data/tabledit/grace-string.tef differ diff --git a/packages/alphatab/test-data/tabledit/grace-x.tef b/packages/alphatab/test-data/tabledit/grace-x.tef new file mode 100644 index 0000000000..c25e517c4a Binary files /dev/null and b/packages/alphatab/test-data/tabledit/grace-x.tef differ diff --git a/packages/alphatab/test-data/tabledit/grace.tef b/packages/alphatab/test-data/tabledit/grace.tef new file mode 100644 index 0000000000..cd9ee0601e Binary files /dev/null and b/packages/alphatab/test-data/tabledit/grace.tef differ diff --git a/packages/alphatab/test-data/tabledit/instruments-patched.tef b/packages/alphatab/test-data/tabledit/instruments-patched.tef new file mode 100644 index 0000000000..ec1f1add0e Binary files /dev/null and b/packages/alphatab/test-data/tabledit/instruments-patched.tef differ diff --git a/packages/alphatab/test-data/tabledit/instruments.tef b/packages/alphatab/test-data/tabledit/instruments.tef new file mode 100644 index 0000000000..6156074f2e Binary files /dev/null and b/packages/alphatab/test-data/tabledit/instruments.tef differ diff --git a/packages/alphatab/test-data/tabledit/line-breaks.tef b/packages/alphatab/test-data/tabledit/line-breaks.tef new file mode 100644 index 0000000000..d287c25f96 Binary files /dev/null and b/packages/alphatab/test-data/tabledit/line-breaks.tef differ diff --git a/packages/alphatab/test-data/tabledit/lyrics-sections2.tef b/packages/alphatab/test-data/tabledit/lyrics-sections2.tef new file mode 100644 index 0000000000..cee7c72478 Binary files /dev/null and b/packages/alphatab/test-data/tabledit/lyrics-sections2.tef differ diff --git a/packages/alphatab/test-data/tabledit/lyrics.tef b/packages/alphatab/test-data/tabledit/lyrics.tef new file mode 100644 index 0000000000..8d83eb770a Binary files /dev/null and b/packages/alphatab/test-data/tabledit/lyrics.tef differ diff --git a/packages/alphatab/test-data/tabledit/markers-layout-mpl.tef b/packages/alphatab/test-data/tabledit/markers-layout-mpl.tef new file mode 100644 index 0000000000..f877613120 Binary files /dev/null and b/packages/alphatab/test-data/tabledit/markers-layout-mpl.tef differ diff --git a/packages/alphatab/test-data/tabledit/markers-layout.tef b/packages/alphatab/test-data/tabledit/markers-layout.tef new file mode 100644 index 0000000000..41a0410ae2 Binary files /dev/null and b/packages/alphatab/test-data/tabledit/markers-layout.tef differ diff --git a/packages/alphatab/test-data/tabledit/markers-patched.tef b/packages/alphatab/test-data/tabledit/markers-patched.tef new file mode 100644 index 0000000000..f77309b0a9 Binary files /dev/null and b/packages/alphatab/test-data/tabledit/markers-patched.tef differ diff --git a/packages/alphatab/test-data/tabledit/markers-playback-syncope.tef b/packages/alphatab/test-data/tabledit/markers-playback-syncope.tef new file mode 100644 index 0000000000..a7481e5167 Binary files /dev/null and b/packages/alphatab/test-data/tabledit/markers-playback-syncope.tef differ diff --git a/packages/alphatab/test-data/tabledit/markers-playback.tef b/packages/alphatab/test-data/tabledit/markers-playback.tef new file mode 100644 index 0000000000..c6752029e4 Binary files /dev/null and b/packages/alphatab/test-data/tabledit/markers-playback.tef differ diff --git a/packages/alphatab/test-data/tabledit/markers-text-chords.tef b/packages/alphatab/test-data/tabledit/markers-text-chords.tef new file mode 100644 index 0000000000..0bb40b0715 Binary files /dev/null and b/packages/alphatab/test-data/tabledit/markers-text-chords.tef differ diff --git a/packages/alphatab/test-data/tabledit/notes-dulcimer.tef b/packages/alphatab/test-data/tabledit/notes-dulcimer.tef new file mode 100644 index 0000000000..b564cdd5c9 Binary files /dev/null and b/packages/alphatab/test-data/tabledit/notes-dulcimer.tef differ diff --git a/packages/alphatab/test-data/tabledit/notes.tef b/packages/alphatab/test-data/tabledit/notes.tef new file mode 100644 index 0000000000..dcdf14d5ac Binary files /dev/null and b/packages/alphatab/test-data/tabledit/notes.tef differ diff --git a/packages/alphatab/test-data/tabledit/reading-guides.tef b/packages/alphatab/test-data/tabledit/reading-guides.tef new file mode 100644 index 0000000000..2519bec3a5 Binary files /dev/null and b/packages/alphatab/test-data/tabledit/reading-guides.tef differ diff --git a/packages/alphatab/test-data/tabledit/reading-list-long.tef b/packages/alphatab/test-data/tabledit/reading-list-long.tef new file mode 100644 index 0000000000..42d586c869 Binary files /dev/null and b/packages/alphatab/test-data/tabledit/reading-list-long.tef differ diff --git a/packages/alphatab/test-data/tabledit/reading-list-segno.tef b/packages/alphatab/test-data/tabledit/reading-list-segno.tef new file mode 100644 index 0000000000..c5107022cf Binary files /dev/null and b/packages/alphatab/test-data/tabledit/reading-list-segno.tef differ diff --git a/packages/alphatab/test-data/tabledit/reading-list-voltas.tef b/packages/alphatab/test-data/tabledit/reading-list-voltas.tef new file mode 100644 index 0000000000..e81c1209b4 Binary files /dev/null and b/packages/alphatab/test-data/tabledit/reading-list-voltas.tef differ diff --git a/packages/alphatab/test-data/tabledit/reading-list.tef b/packages/alphatab/test-data/tabledit/reading-list.tef new file mode 100644 index 0000000000..9e3d39e37a Binary files /dev/null and b/packages/alphatab/test-data/tabledit/reading-list.tef differ diff --git a/packages/alphatab/test-data/tabledit/score-info-url.tef b/packages/alphatab/test-data/tabledit/score-info-url.tef new file mode 100644 index 0000000000..60640d3f74 Binary files /dev/null and b/packages/alphatab/test-data/tabledit/score-info-url.tef differ diff --git a/packages/alphatab/test-data/tabledit/score-info.tef b/packages/alphatab/test-data/tabledit/score-info.tef new file mode 100644 index 0000000000..02cbdb5459 Binary files /dev/null and b/packages/alphatab/test-data/tabledit/score-info.tef differ diff --git a/packages/alphatab/test-data/tabledit/tuplets.tef b/packages/alphatab/test-data/tabledit/tuplets.tef new file mode 100644 index 0000000000..fa0ca97b70 Binary files /dev/null and b/packages/alphatab/test-data/tabledit/tuplets.tef differ diff --git a/packages/alphatab/test-data/tabledit/voices-ties.tef b/packages/alphatab/test-data/tabledit/voices-ties.tef new file mode 100644 index 0000000000..7db3a97907 Binary files /dev/null and b/packages/alphatab/test-data/tabledit/voices-ties.tef differ diff --git a/packages/alphatab/test-data/tabledit/voices.tef b/packages/alphatab/test-data/tabledit/voices.tef new file mode 100644 index 0000000000..dcadcc30f4 Binary files /dev/null and b/packages/alphatab/test-data/tabledit/voices.tef differ diff --git a/packages/alphatab/test-data/visual-tests/tabledit/effects-b5.png b/packages/alphatab/test-data/visual-tests/tabledit/effects-b5.png new file mode 100644 index 0000000000..0871f7f9c7 Binary files /dev/null and b/packages/alphatab/test-data/visual-tests/tabledit/effects-b5.png differ diff --git a/packages/alphatab/test-data/visual-tests/tabledit/effects-b5.tef b/packages/alphatab/test-data/visual-tests/tabledit/effects-b5.tef new file mode 100644 index 0000000000..b392923fea Binary files /dev/null and b/packages/alphatab/test-data/visual-tests/tabledit/effects-b5.tef differ diff --git a/packages/alphatab/test-data/visual-tests/tabledit/markers-text-chords.png b/packages/alphatab/test-data/visual-tests/tabledit/markers-text-chords.png new file mode 100644 index 0000000000..eed3a1a8ba Binary files /dev/null and b/packages/alphatab/test-data/visual-tests/tabledit/markers-text-chords.png differ diff --git a/packages/alphatab/test-data/visual-tests/tabledit/markers-text-chords.tef b/packages/alphatab/test-data/visual-tests/tabledit/markers-text-chords.tef new file mode 100644 index 0000000000..0bb40b0715 Binary files /dev/null and b/packages/alphatab/test-data/visual-tests/tabledit/markers-text-chords.tef differ diff --git a/packages/alphatab/test-data/visual-tests/tabledit/reading-list-voltas.png b/packages/alphatab/test-data/visual-tests/tabledit/reading-list-voltas.png new file mode 100644 index 0000000000..c757ca49f8 Binary files /dev/null and b/packages/alphatab/test-data/visual-tests/tabledit/reading-list-voltas.png differ diff --git a/packages/alphatab/test-data/visual-tests/tabledit/reading-list-voltas.tef b/packages/alphatab/test-data/visual-tests/tabledit/reading-list-voltas.tef new file mode 100644 index 0000000000..e81c1209b4 Binary files /dev/null and b/packages/alphatab/test-data/visual-tests/tabledit/reading-list-voltas.tef differ diff --git a/packages/alphatab/test-data/visual-tests/tabledit/tuplets.png b/packages/alphatab/test-data/visual-tests/tabledit/tuplets.png new file mode 100644 index 0000000000..b547dfbcf0 Binary files /dev/null and b/packages/alphatab/test-data/visual-tests/tabledit/tuplets.png differ diff --git a/packages/alphatab/test-data/visual-tests/tabledit/tuplets.tef b/packages/alphatab/test-data/visual-tests/tabledit/tuplets.tef new file mode 100644 index 0000000000..fa0ca97b70 Binary files /dev/null and b/packages/alphatab/test-data/visual-tests/tabledit/tuplets.tef differ diff --git a/packages/alphatab/test-data/visual-tests/tabledit/voices-ties.png b/packages/alphatab/test-data/visual-tests/tabledit/voices-ties.png new file mode 100644 index 0000000000..d67450d649 Binary files /dev/null and b/packages/alphatab/test-data/visual-tests/tabledit/voices-ties.png differ diff --git a/packages/alphatab/test-data/visual-tests/tabledit/voices-ties.tef b/packages/alphatab/test-data/visual-tests/tabledit/voices-ties.tef new file mode 100644 index 0000000000..7db3a97907 Binary files /dev/null and b/packages/alphatab/test-data/visual-tests/tabledit/voices-ties.tef differ diff --git a/packages/alphatab/test/exporter/AlphaTexExporter.test.ts b/packages/alphatab/test/exporter/AlphaTexExporter.test.ts index 20d839f2b7..ebeb12aacd 100644 --- a/packages/alphatab/test/exporter/AlphaTexExporter.test.ts +++ b/packages/alphatab/test/exporter/AlphaTexExporter.test.ts @@ -1,4 +1,3 @@ -import { describe, expect, it } from 'vitest'; import { AlphaTexExporter } from '@coderline/alphatab/exporter/AlphaTexExporter'; import { AlphaTexErrorWithDiagnostics } from '@coderline/alphatab/importer/AlphaTexImporter'; import { ScoreLoader } from '@coderline/alphatab/importer/ScoreLoader'; @@ -12,6 +11,8 @@ import { TabRhythmMode } from '@coderline/alphatab/NotationSettings'; import { Settings } from '@coderline/alphatab/Settings'; import { ComparisonHelpers } from 'test/model/ComparisonHelpers'; import { TestPlatform } from 'test/TestPlatform'; +import { describe, expect, it } from 'vitest'; + describe('AlphaTexExporterTest', () => { async function loadScore(name: string): Promise { const data = await TestPlatform.loadFile(`test-data/${name}`); @@ -227,6 +228,34 @@ describe('AlphaTexExporterTest', () => { await testRoundTripFolderEqual('visual-tests/special-tracks'); }); + it('visual-tabledit', async () => { + const files: string[] = await TestPlatform.listDirectory('test-data/visual-tests/tabledit'); + for (const file of files.filter(f => !f.endsWith('.png'))) { + // alphaTex cannot express dynamics without a shown marking; it ties a note to an earlier note of its + // own voice, which loses a tie from another voice or takes the fret of another note + await testRoundTripEqual( + `visual-tests/tabledit/${file}`, + file === 'voices-ties.tef' ? ['showdynamics', 'istiedestination', 'fret'] : ['showdynamics'] + ); + } + }); + + it('importer-tabledit', async () => { + const files: string[] = await TestPlatform.listDirectory('test-data/tabledit'); + for (const file of files) { + // alphaTex cannot express dynamics without a shown marking, nor forced line breaks; it puts + // percussion notes on a line, the 8 lines of a TablEdit drum tab have none in alphaTab; it ties a note + // to an earlier note of its own voice, which loses a tie from another voice or takes another fret + const ignored = ['showdynamics', 'linebreaks']; + if (file === 'drums.tef') { + ignored.push('string'); + } else if (file === 'voices-ties.tef') { + ignored.push('istiedestination', 'fret'); + } + await testRoundTripEqual(`tabledit/${file}`, ignored); + } + }); + it('gp4-to-alphaTex', async () => { await testRoundTripEqual(`guitarpro4/fade-to-black.gp4`); }); diff --git a/packages/alphatab/test/importer/TablEditImporter.test.ts b/packages/alphatab/test/importer/TablEditImporter.test.ts new file mode 100644 index 0000000000..97c720eb36 --- /dev/null +++ b/packages/alphatab/test/importer/TablEditImporter.test.ts @@ -0,0 +1,3029 @@ +import { AlphaTexExporter } from '@coderline/alphatab/exporter/AlphaTexExporter'; +import { FormatError } from '@coderline/alphatab/FormatError'; +import { ScoreLoader } from '@coderline/alphatab/importer/ScoreLoader'; +import { TablEditImporter } from '@coderline/alphatab/importer/TablEditImporter'; +import { TefReadingList, TefReadingListItem } from '@coderline/alphatab/importer/tablEdit/TefReadingList'; +import { UnsupportedFormatError } from '@coderline/alphatab/importer/UnsupportedFormatError'; +import { ByteBuffer } from '@coderline/alphatab/io/ByteBuffer'; +import { IOHelper } from '@coderline/alphatab/io/IOHelper'; +import { OverflowError } from '@coderline/alphatab/io/IReadable'; +import { MidiFileGenerator } from '@coderline/alphatab/midi/MidiFileGenerator'; +import { MidiUtils } from '@coderline/alphatab/midi/MidiUtils'; +import { AccentuationType } from '@coderline/alphatab/model/AccentuationType'; +import { AutomationType } from '@coderline/alphatab/model/Automation'; +import { BarLineStyle } from '@coderline/alphatab/model/Bar'; +import { type Beat, BeatBeamingMode } from '@coderline/alphatab/model/Beat'; +import { BendType } from '@coderline/alphatab/model/BendType'; +import { BrushType } from '@coderline/alphatab/model/BrushType'; +import { Clef } from '@coderline/alphatab/model/Clef'; +import { CrescendoType } from '@coderline/alphatab/model/CrescendoType'; +import { Direction } from '@coderline/alphatab/model/Direction'; +import { Duration } from '@coderline/alphatab/model/Duration'; +import { DynamicValue } from '@coderline/alphatab/model/DynamicValue'; +import { FadeType } from '@coderline/alphatab/model/FadeType'; +import { FermataType } from '@coderline/alphatab/model/Fermata'; +import { Fingers } from '@coderline/alphatab/model/Fingers'; +import { GraceType } from '@coderline/alphatab/model/GraceType'; +import { HarmonicType } from '@coderline/alphatab/model/HarmonicType'; +import { KeySignatureType } from '@coderline/alphatab/model/KeySignatureType'; +import type { MasterBar } from '@coderline/alphatab/model/MasterBar'; +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 { PickStroke } from '@coderline/alphatab/model/PickStroke'; +import { Rasgueado } from '@coderline/alphatab/model/Rasgueado'; +import type { Score } from '@coderline/alphatab/model/Score'; +import { SlideOutType } from '@coderline/alphatab/model/SlideOutType'; +import { TripletFeel } from '@coderline/alphatab/model/TripletFeel'; +import { VibratoType } from '@coderline/alphatab/model/VibratoType'; +import { BeamDirection } from '@coderline/alphatab/rendering/utils/BeamDirection'; +import { Settings } from '@coderline/alphatab/Settings'; +import { FlatMidiEventGenerator, FlatNoteEvent } from 'test/audio/FlatMidiEventGenerator'; +import { TestPlatform } from 'test/TestPlatform'; +import { describe, expect, it } from 'vitest'; + +/** + * @record + * @internal + */ +interface ExpectedTupletBeat { + duration: Duration; + ratio: string; + start: number; + tolerance: number; +} + +/** + * @record + * @internal + */ +interface ReadingListStructure { + file?: string; + list: [number, number][]; + tablEdit: number[]; + alphaTab?: number[]; + repeatStart?: number[]; + repeatCount?: [number, number][]; + endings?: [number, number][]; + directions?: [number, Direction[]][]; +} + +/** + * The files in test-data/tabledit were entered in TablEdit 3.06 (unless the file says otherwise a 6-string + * guitar in standard tuning, 4/4); every expected value follows from what was entered and the file format. + * Markers sit on string row 0, so the notes of a marker column are on row 1 or below. + */ +describe('TablEditImporterTest', () => { + const quarter = 960; + const cellsPointer = 60; + const titlePointer = 64; + const textsPointer = 84; + const chordsPointer = 88; + const measuresPointer = 92; + const instrumentsPointer = 96; + const readingListPointer = 128; + const formatOffset = 205; + const cellSize = 12; + const stringCount = 6; + + function loadData(name: string): Promise { + return TestPlatform.loadFile(`test-data/tabledit/${name}.tef`); + } + + function readBytes(data: Uint8Array, settings: Settings | null = null): Score { + const importer = new TablEditImporter(); + importer.init(ByteBuffer.fromBuffer(data), settings ?? new Settings()); + return importer.readScore(); + } + + async function load(name: string): Promise { + const score = ScoreLoader.loadScoreFromBytes(await loadData(name), new Settings()); + expectBarsFilled(score); + return score; + } + + /** + * Every voice with content lasts exactly as long as its bar. alphaTab truncates the ticks of a tuplet + * whose length is no whole number of ticks, each such beat may end the voice one tick early. + */ + function expectBarsFilled(score: Score) { + for (const track of score.tracks) { + for (const staff of track.staves) { + for (const bar of staff.bars) { + for (const voice of bar.voices) { + if (voice.isEmpty) { + continue; + } + let duration = 0; + let truncated = 0; + for (const b of voice.beats) { + duration += b.playbackDuration; + if ( + b.hasTuplet && + (MidiUtils.toTicks(b.duration) * b.tupletDenominator) % b.tupletNumerator !== 0 + ) { + truncated++; + } + } + const missing = bar.masterBar.calculateDuration() - duration; + expect(missing, `bar ${bar.index} voice ${voice.index}`).toBeGreaterThanOrEqual(0); + expect(missing, `bar ${bar.index} voice ${voice.index}`).toBeLessThanOrEqual(truncated); + } + } + } + } + } + + function beats(score: Score, bar: number, voice: number = 0): Beat[] { + return score.tracks[0].staves[0].bars[bar].voices[voice].beats; + } + + function noteOn(beat: Beat, string: number): Note { + const note = beat.notes.find(n => n.string === string); + expect(note, `note on string ${string}`).toBeDefined(); + return note!; + } + + function single(beat: Beat): Note { + expect(beat.notes.length).toBe(1); + return beat.notes[0]; + } + + function frets(beat: Beat): Map { + return new Map(beat.notes.map(n => [n.string, n.fret])); + } + + /** + * Per beat: the fret of its single note (or 'r' for a rest), duration, dots and start in the bar. + */ + function timeline(score: Score, bar: number): unknown[][] { + return beats(score, bar).map( + b => [b.isRest ? 'r' : single(b).fret, b.duration, b.dots, b.playbackStart] as unknown[] + ); + } + + function tuplet(beat: Beat): string { + return beat.hasTuplet ? `${beat.tupletNumerator}:${beat.tupletDenominator}` : ''; + } + + function bendPoints(note: Note): number[][] | null { + return note.bendPoints ? note.bendPoints.map(p => [p.offset, p.value] as number[]) : null; + } + + function whammy(b: Beat): number[][] | null { + return b.whammyBarPoints ? b.whammyBarPoints.map(p => [p.offset, p.value] as number[]) : null; + } + + function expectNoNoteEffect(beat: Beat) { + for (const n of beat.notes) { + expect(n.isHammerPullOrigin).toBe(false); + expect(n.slideOutType).toBe(SlideOutType.None); + expect(n.harmonicType).toBe(HarmonicType.None); + expect(n.bendPoints).toBe(null); + expect(n.isGhost).toBe(false); + expect(n.isDead).toBe(false); + expect(n.isLetRing).toBe(false); + expect(n.isStaccato).toBe(false); + expect(n.isPalmMute).toBe(false); + expect(n.vibrato).toBe(VibratoType.None); + } + expect(beat.brushType).toBe(BrushType.None); + expect(beat.slap).toBe(false); + expect(beat.tap).toBe(false); + expect(beat.fade).toBe(FadeType.None); + expect(beat.whammyBarPoints).toBe(null); + expect(beat.rasgueado).toBe(Rasgueado.None); + } + + function u16(data: Uint8Array, offset: number): number { + return data[offset] | (data[offset + 1] << 8); + } + + function u32(data: Uint8Array, offset: number): number { + return (data[offset] | (data[offset + 1] << 8) | (data[offset + 2] << 16)) + data[offset + 3] * 16777216; + } + + function setU16(data: Uint8Array, offset: number, value: number) { + data[offset] = value & 0xff; + data[offset + 1] = (value >> 8) & 0xff; + } + + function setU32(data: Uint8Array, offset: number, value: number) { + setU16(data, offset, value % 65536); + setU16(data, offset + 2, Math.floor(value / 65536)); + } + + /** + * The offset of the 8 data bytes of the cell at the given 64th and string row. + */ + function cellData(data: Uint8Array, position: number, row: number): number { + let p = u32(data, cellsPointer); + while (u32(data, p) !== 0xffffffff) { + const slot = Math.floor(u32(data, p) / 8); + if (Math.floor(slot / stringCount) === position && slot % stringCount === row) { + return p + 4; + } + p += cellSize; + } + throw new Error(`no cell at ${position}/${row}`); + } + + /** + * Appends a block to the file and points the given header pointer to it. + */ + function withBlock(data: Uint8Array, pointer: number, block: Uint8Array): Uint8Array { + const result = new Uint8Array(data.length + block.length); + result.set(data, 0); + result.set(block, data.length); + setU32(result, pointer, data.length); + return result; + } + + function loadHealthy(): Promise { + return TestPlatform.loadFile('test-data/corrupt/healthy.tef'); + } + + /** + * Appends cells, each given as 64th, string row and data bytes, to the cells of a 6-string file. + */ + function withCells(data: Uint8Array, cells: [number, number, number[]][]): Uint8Array { + const start = u32(data, cellsPointer); + let end = start; + while (u32(data, end) !== 0xffffffff) { + end += cellSize; + } + const block = new Uint8Array(end - start + cells.length * cellSize + 4); + block.set(data.subarray(start, end), 0); + let p = end - start; + for (const [position, row, bytes] of cells) { + setU32(block, p, (position * stringCount + row) * 8); + for (let k = 0; k < bytes.length; k++) { + block[p + 4 + k] = bytes[k]; + } + p += cellSize; + } + setU32(block, p, 0xffffffff); + return withBlock(data, cellsPointer, block); + } + + /** + * Sets the upper voice on the notes and rests of a bar of a 6-string file whose bars up to it are 4/4. + */ + function withUpperVoice(data: Uint8Array, bar: number): Uint8Array { + const result = new Uint8Array(data); + let p = u32(result, cellsPointer); + while (u32(result, p) !== 0xffffffff) { + const position = Math.floor(Math.floor(u32(result, p) / 8) / stringCount); + const type = result[p + 4] & 0x3f; + if (Math.floor(position / 64) === bar && type > 0 && type <= 0x33) { + result[p + 6] = (result[p + 6] & 0xcf) | 0x20; + } + p += cellSize; + } + return result; + } + + function directionsOf(m: MasterBar): Direction[] { + return m.directions ? Array.from(m.directions) : []; + } + + function toTex(score: Score): string { + return new AlphaTexExporter().exportToString(score); + } + + describe('detection', () => { + it('loads-tabledit', async () => { + const score = ScoreLoader.loadScoreFromBytes(await loadData('notes'), new Settings()); + expect(score.masterBars.length).toBe(5); + expect(score.tracks.length).toBe(1); + }); + + it('leaves-other-formats', async () => { + const gp5 = await TestPlatform.loadFile('test-data/guitarpro5/notes.gp5'); + expect(ScoreLoader.loadScoreFromBytes(gp5, new Settings()).tracks.length).toBeGreaterThan(0); + const tex = ScoreLoader.loadScoreFromBytes( + IOHelper.stringToBytes('\\title "TablEdit detection" . 3.3 0.2 1.1 2.2 |'), + new Settings() + ); + expect(tex.masterBars.length).toBe(1); + }); + + it('leaves-alphatex', async () => { + const expectAlphaTex = (name: string, tex: string, title: string | null) => { + const data = IOHelper.stringToBytes(tex); + expect(() => readBytes(data), name).toThrow(UnsupportedFormatError); + const score = ScoreLoader.loadScoreFromBytes(data, new Settings()); + expect(score.masterBars.length, name).toBeGreaterThan(0); + if (title !== null) { + expect(score.title, name).toBe(title); + } + }; + const legend = await TestPlatform.loadFile('test-data/exporter/notation-legend-formatted.atex'); + expectAlphaTex('notation-legend-formatted.atex', IOHelper.toString(legend, 'utf-8'), null); + for (const file of await TestPlatform.listDirectory('test-data/tabledit')) { + const score = readBytes(await loadData(file.substring(0, file.length - 4))); + expectAlphaTex(file, toTex(score), score.title); + } + }); + + it('rejects-foreign-data', () => { + expect(() => readBytes(new Uint8Array(0))).toThrow(UnsupportedFormatError); + expect(() => readBytes(new Uint8Array(10))).toThrow(UnsupportedFormatError); + expect(() => readBytes(new Uint8Array(255))).toThrow(UnsupportedFormatError); + expect(() => readBytes(new Uint8Array(300))).toThrow(UnsupportedFormatError); + }); + + it('rejects-other-score-files', async () => { + for (const folder of ['guitarpro3', 'guitarpro4', 'guitarpro5', 'guitarpro6', 'guitarpro7', 'musicxml3']) { + const files = await TestPlatform.listDirectory(`test-data/${folder}`); + for (const file of files) { + const data = await TestPlatform.loadFile(`test-data/${folder}/${file}`); + expect(() => readBytes(data), `${folder}/${file}`).toThrow(UnsupportedFormatError); + } + } + }); + }); + + describe('corrupt', () => { + async function patched(offset: number, value: number): Promise { + const data = await loadHealthy(); + data[offset] = value; + return data; + } + + it('healthy', async () => { + const score = readBytes(await loadHealthy()); + expect(score.masterBars.length).toBe(5); + expect(score.tracks[0].staves[0].bars[0].voices[0].beats.length).toBe(4); + }); + + it('detection-measure', async () => { + const base = await loadHealthy(); + const patches: [number, number][] = [ + [204, 3], + [204, 0], + [formatOffset, 0] + ]; + for (const [offset, value] of patches) { + const data = new Uint8Array(base); + data[offset] = value; + expect(() => readBytes(data), `[${offset}] = ${value}`).toThrow(UnsupportedFormatError); + } + const longMeasure = new Uint8Array(base); + setU16(longMeasure, 202, 100); + expect(() => readBytes(longMeasure)).toThrow(UnsupportedFormatError); + }); + + it('old-format', () => { + // the header fields of TablEdit 1 and 2 that detection reads: one measure of 4/4 + const header = new Uint8Array(256); + setU16(header, 200, 1); + setU16(header, 202, 4); + header[204] = 4; + for (let format = 1; format < 10; format++) { + header[formatOffset] = format; + try { + readBytes(header); + throw new Error(`Expected format ${format} to fail with a FormatError`); + } catch (e) { + if (e instanceof FormatError) { + expect((e as FormatError).message, `format ${format}`).toContain('TablEdit 3'); + continue; + } + throw e; + } + } + }); + + it('format-11', async () => { + const score = readBytes(await patched(formatOffset, 11)); + expect(toTex(score)).toBe(toTex(readBytes(await loadHealthy()))); + }); + + it('protected', async () => { + { + const protectedData = await patched(formatOffset, 12); + expect(() => readBytes(protectedData)).toThrow(FormatError); + } + const data = await loadHealthy(); + setU32(data, 16, 1); + expect(() => readBytes(data)).toThrow(FormatError); + // TablEdit ignores the protection code of files older than 3.04 b6 (version 0x0304, sub-version 0xB6) + setU16(data, 2, 0x0304); + setU16(data, 4, 0xb5); + expect(readBytes(data).masterBars.length).toBe(5); + setU16(data, 4, 0xb6); + expect(() => readBytes(data)).toThrow(FormatError); + }); + + it('newer-format', async () => { + { + const data = await patched(formatOffset, 13); + expect(() => readBytes(data)).toThrow(FormatError); + } + }); + + it('missing-blocks', async () => { + const noMeasures = await loadHealthy(); + setU32(noMeasures, measuresPointer, 0); + expect(() => readBytes(noMeasures)).toThrow(FormatError); + const noInstruments = await loadHealthy(); + setU32(noInstruments, instrumentsPointer, 0); + expect(() => readBytes(noInstruments)).toThrow(FormatError); + const zeroMeasures = await loadHealthy(); + setU16(zeroMeasures, u32(zeroMeasures, measuresPointer) + 2, 0); + expect(() => readBytes(zeroMeasures)).toThrow(FormatError); + const zeroInstruments = await loadHealthy(); + setU16(zeroInstruments, u32(zeroInstruments, instrumentsPointer) + 2, 0); + expect(() => readBytes(zeroInstruments)).toThrow(FormatError); + const zeroStrings = await loadHealthy(); + setU16(zeroStrings, u32(zeroStrings, instrumentsPointer) + 4, 0); + expect(() => readBytes(zeroStrings)).toThrow(FormatError); + }); + + it('offset-out-of-range', async () => { + const data = await loadHealthy(); + setU32(data, titlePointer, data.length + 10); + expect(() => readBytes(data)).toThrow(FormatError); + }); + + it('truncated', async () => { + const data = await loadHealthy(); + const cut = u32(data, cellsPointer) + 5 * cellSize + 3; + expect(() => readBytes(data.slice(0, cut))).toThrow(FormatError); + }); + + it('counts-overflow', async () => { + const measures = await loadHealthy(); + setU16(measures, u32(measures, measuresPointer) + 2, 0xffff); + expect(() => readBytes(measures)).toThrow(OverflowError); + + const instruments = await loadHealthy(); + setU16(instruments, u32(instruments, instrumentsPointer) + 2, 65); + expect(() => readBytes(instruments)).toThrow(OverflowError); + + const strings = await loadHealthy(); + setU16(strings, u32(strings, instrumentsPointer) + 4, 13); + expect(() => readBytes(strings)).toThrow(OverflowError); + + const texts = await loadData('markers-text-chords'); + setU16(texts, u32(texts, textsPointer), 5000); + expect(() => readBytes(texts)).toThrow(OverflowError); + + const chords = await loadData('markers-text-chords'); + setU16(chords, u32(chords, chordsPointer) + 2, 5000); + expect(() => readBytes(chords)).toThrow(OverflowError); + + const readingList = await loadData('reading-list-voltas'); + setU16(readingList, u32(readingList, readingListPointer) + 2, 5000); + expect(() => readBytes(readingList)).toThrow(OverflowError); + }); + + it('string-length-overflow', async () => { + const data = await loadHealthy(); + setU16(data, u32(data, titlePointer), 0xffff); + const settings = new Settings(); + settings.importer.maxDecodingBufferSize = 1024; + expect(() => readBytes(data, settings)).toThrow(OverflowError); + }); + + it('cell-after-last-measure', async () => { + const data = await loadHealthy(); + const start = u32(data, cellsPointer); + let end = start; + while (u32(data, end) !== 0xffffffff) { + end += cellSize; + } + const cells = new Uint8Array(end - start + 2 * cellSize + 4); + cells.set(data.subarray(start, end), 0); + const extra = end - start; + setU32(cells, extra, 5 * 64 * stringCount * 8); + cells[extra + 4] = 0x06; + cells[extra + 5] = 0x46; + setU32(cells, extra + cellSize, 0xffffffff); + const score = readBytes(withBlock(data, cellsPointer, cells)); + expect(score.masterBars.length).toBe(5); + expect(toTex(score)).toBe(toTex(readBytes(await loadHealthy()))); + }); + + it('cell-without-fret', async () => { + // the first byte of 1.0 s1 f0 with only the grace flag left: no fret, so no note + const data = await loadHealthy(); + data[cellData(data, 0, 5)] = 0x40; + expect(beats(readBytes(data), 0).map(b => b.isRest)).toEqual([true, false, false, false]); + }); + + it('large-file', async () => { + // healthy.tef with its first bar repeated: the import time grows linearly with the bar count + const healthy = await loadHealthy(); + const measures = u32(healthy, measuresPointer); + const cells = u32(healthy, cellsPointer); + const barCells: Uint8Array[] = []; + for (let p = cells; u32(healthy, p) !== 0xffffffff; p += cellSize) { + if (u32(healthy, p) < 64 * stringCount * 8) { + barCells.push(healthy.subarray(p, p + cellSize)); + } + } + const importTime = (bars: number) => { + const measureBlock = new Uint8Array(8 + bars * 8); + measureBlock.set(healthy.subarray(measures, measures + 8), 0); + setU16(measureBlock, 2, bars); + const cellBlock = new Uint8Array(bars * barCells.length * cellSize + 4); + let p = 0; + for (let bar = 0; bar < bars; bar++) { + measureBlock.set(healthy.subarray(measures + 8, measures + 16), 8 + bar * 8); + for (const cell of barCells) { + cellBlock.set(cell, p); + setU32(cellBlock, p, u32(cell, 0) + bar * 64 * stringCount * 8); + p += cellSize; + } + } + setU32(cellBlock, p, 0xffffffff); + const data = withBlock(withBlock(healthy, measuresPointer, measureBlock), cellsPointer, cellBlock); + const start = Date.now(); + const score = readBytes(data); + const time = Date.now() - start; + expect(score.masterBars.length).toBe(bars); + expect(beats(score, bars - 1).length).toBe(4); + return time; + }; + importTime(500); + const small = importTime(1000); + const large = importTime(8000); + // eight times the bars: linear time stays far below the 64 times of quadratic time + expect(large).toBeLessThan(small * 16 + 250); + }); + + it('unknown-duration-codes', async () => { + for (const code of [21, 24, 27, 30]) { + const data = await loadHealthy(); + // 1.16 s4 f3 + const cell = cellData(data, 16, 4); + data[cell + 1] = (data[cell + 1] & 0xe0) | code; + const score = readBytes(data); + const bar = score.tracks[0].staves[0].bars[0].voices[0].beats; + expect( + bar.map(b => b.isRest), + `code ${code}` + ).toEqual([false, true, false, false]); + expect(bar.map(b => b.duration)).toEqual([ + Duration.Quarter, + Duration.Quarter, + Duration.Quarter, + Duration.Quarter + ]); + } + }); + + it('measure-without-time-signature', async () => { + for (const field of [4, 5]) { + const data = await loadHealthy(); + data[u32(data, measuresPointer) + 8 + field] = 0; + const score = readBytes(data); + expect(score.masterBars[0].timeSignatureNumerator).toBe(4); + expect(score.masterBars[0].timeSignatureDenominator).toBe(4); + expect(score.tracks[0].staves[0].bars[0].voices[0].beats.length).toBe(4); + } + }); + + it('wide-measure-records', async () => { + const data = await loadHealthy(); + const offset = u32(data, measuresPointer); + const count = u16(data, offset + 2); + const block = new Uint8Array(8 + count * 12); + block.set(data.subarray(offset, offset + 8), 0); + setU16(block, 0, 16); + for (let i = 0; i < count; i++) { + block.set(data.subarray(offset + 8 + i * 8, offset + 16 + i * 8), 8 + i * 12); + } + const score = readBytes(withBlock(data, measuresPointer, block)); + expect(toTex(score)).toBe(toTex(readBytes(await loadHealthy()))); + }); + + it('text-index-out-of-range', async () => { + const data = await loadData('markers-text-chords'); + // text marker 'Intro' at 1.16 + setU16(data, cellData(data, 16, 0) + 1, 999); + const score = readBytes(data); + expect(beats(score, 0)[1].text).toBe(null); + expect(beats(score, 0)[2].text).toBe('Bold'); + }); + + it('chord-index-out-of-range', async () => { + for (const index of [0xffff, 999]) { + const data = await loadData('markers-text-chords'); + // chord marker Am at 1.0 + setU16(data, cellData(data, 0, 0) + 1, index); + const score = readBytes(data); + expect(beats(score, 0)[0].chordId, `index ${index}`).toBe(null); + expect(beats(score, 1)[0].chord!.name).toBe('C'); + } + }); + + it('text-windows-1252', async () => { + const data = await loadData('markers-text-chords'); + const intro = u32(data, textsPointer) + 2; + // 'Intro' and its terminating zero + expect(u16(data, intro)).toBe(6); + data[intro + 2 + 4] = 0xe9; + const score = readBytes(data); + expect(beats(score, 0)[1].text).toBe('Intré'); + }); + + it('reading-list-header', async () => { + const data = await loadData('reading-list-voltas'); + setU16(data, u32(data, readingListPointer), 0x10); + const score = readBytes(data); + expect(TefReadingList.playbackOrder(score, 100)).toEqual([0, 1, 2, 3, 4, 5, 6, 7]); + expect(score.masterBars.some(m => m.isRepeatStart || m.repeatCount > 0)).toBe(false); + }); + }); + + describe('notes', () => { + it('notes-frets-strings', async () => { + const score = await load('notes'); + const staff = score.tracks[0].staves[0]; + expect(staff.tuning).toEqual([64, 59, 55, 50, 45, 40]); + expect(staff.capo).toBe(0); + expect(staff.bars.every(b => b.voices.length === 1)).toBe(true); + + const bar = beats(score, 0); + expect(bar.length).toBe(4); + expect(bar.every(b => b.duration === Duration.Quarter && b.dots === 0)).toBe(true); + expect(bar.map(b => b.playbackStart)).toEqual([0, quarter, 2 * quarter, 3 * quarter]); + + expect(single(bar[0]).string).toBe(1); + expect(single(bar[0]).fret).toBe(0); + expect(single(bar[0]).realValue).toBe(40); + expect(single(bar[1]).string).toBe(2); + expect(single(bar[1]).fret).toBe(3); + expect(single(bar[1]).realValue).toBe(48); + expect(single(bar[2]).string).toBe(3); + expect(single(bar[2]).fret).toBe(12); + expect(single(bar[2]).realValue).toBe(62); + expect(single(bar[3]).string).toBe(6); + expect(single(bar[3]).fret).toBe(24); + expect(single(bar[3]).realValue).toBe(88); + }); + + it('notes-accidentals', async () => { + const score = await load('notes'); + const bar = beats(score, 1); + expect(bar.length).toBe(4); + + expect(frets(bar[0])).toEqual( + new Map([ + [5, 1], + [4, 0], + [3, 2], + [2, 3] + ]) + ); + expect(bar[0].notes.every(n => n.accidentalMode === NoteAccidentalMode.Default)).toBe(true); + + expect(single(bar[1]).string).toBe(4); + expect(single(bar[1]).fret).toBe(1); + expect(single(bar[1]).accidentalMode).toBe(NoteAccidentalMode.ForceSharp); + expect(single(bar[2]).string).toBe(4); + expect(single(bar[2]).fret).toBe(3); + expect(single(bar[2]).accidentalMode).toBe(NoteAccidentalMode.ForceFlat); + expect(single(bar[3]).string).toBe(5); + expect(single(bar[3]).fret).toBe(1); + expect(single(bar[3]).accidentalMode).toBe(NoteAccidentalMode.ForceNatural); + }); + + it('notes-ties-rests', async () => { + const score = await load('notes'); + const bar3 = beats(score, 2); + const bar4 = beats(score, 3); + expect(bar3.length).toBe(4); + expect(bar4.length).toBe(4); + + expect(single(bar3[0]).isTieDestination).toBe(false); + expect(single(bar3[1]).string).toBe(6); + expect(single(bar3[1]).isTieDestination).toBe(true); + expect(single(bar3[1]).tieOrigin).toBe(single(bar3[0])); + expect(single(bar3[2]).string).toBe(4); + expect(single(bar3[2]).fret).toBe(7); + expect(single(bar3[3]).fret).toBe(7); + expect(single(bar3[3]).isTieDestination).toBe(false); + + expect(single(bar4[0]).string).toBe(4); + expect(single(bar4[0]).isTieDestination).toBe(true); + expect(single(bar4[0]).tieOrigin).toBe(single(bar3[3])); + + expect(frets(bar4[1])).toEqual( + new Map([ + [6, 0], + [5, 1] + ]) + ); + expect(bar4[1].notes.some(n => n.isTieDestination)).toBe(false); + expect(noteOn(bar4[2], 6).isTieDestination).toBe(true); + expect(noteOn(bar4[2], 6).tieOrigin).toBe(noteOn(bar4[1], 6)); + expect(noteOn(bar4[2], 5).isTieDestination).toBe(true); + expect(noteOn(bar4[2], 5).tieOrigin).toBe(noteOn(bar4[1], 5)); + + expect(bar4[3].isRest).toBe(true); + expect(bar4[3].duration).toBe(Duration.Quarter); + }); + + it('notes-dynamics', async () => { + const score = await load('notes'); + const bar = beats(score, 4); + expect(bar.length).toBe(8); + expect(bar.every(b => b.duration === Duration.Eighth)).toBe(true); + expect(bar.map(b => b.dynamics)).toEqual([ + DynamicValue.FFF, + DynamicValue.FF, + DynamicValue.F, + DynamicValue.MF, + DynamicValue.MP, + DynamicValue.P, + DynamicValue.PP, + DynamicValue.F + ]); + expect(bar.every(b => b.showDynamics === false)).toBe(true); + expect(frets(bar[7])).toEqual( + new Map([ + [6, 5], + [5, 6] + ]) + ); + }); + }); + + it('score-info', async () => { + const score = await load('score-info'); + // TablEdit 3.06 stores these texts as UTF-8 + expect(score.title).toBe('Title ÄÖÜ ß'); + expect(score.subTitle).toBe('Author Name'); + expect(score.instructions).toBe('Comment line'); + expect(score.notices).toBe('Note text'); + expect(score.copyright).toBe('(c) Copyright'); + expect(score.tempo).toBe(95); + expect(score.masterBars[0].tempoAutomations.map(a => a.value)).toEqual([95]); + expect(score.masterBars.length).toBe(2); + expect(single(beats(score, 0)[0]).string).toBe(6); + expect(single(beats(score, 0)[0]).fret).toBe(0); + expect(single(beats(score, 0)[0]).realValue).toBe(64); + }); + + it('score-info-url', async () => { + // score-info.tef with an internet link, re-saved in TablEdit; alphaTab has no field for it + const score = await load('score-info-url'); + expect(score.title).toBe('Title ÄÖÜ ß'); + expect(score.subTitle).toBe('Author Name'); + expect(score.instructions).toBe('Comment line'); + expect(score.notices).toBe('Note text'); + expect(score.copyright).toBe('(c) Copyright'); + expect(toTex(score)).not.toContain('example.com'); + }); + + describe('durations', () => { + it('durations-plain', async () => { + const score = await load('durations'); + expect(timeline(score, 0)).toEqual([[1, Duration.Whole, 0, 0]]); + expect(timeline(score, 1)).toEqual([ + [1, Duration.Half, 0, 0], + [2, Duration.Half, 0, 1920] + ]); + expect(timeline(score, 2)).toEqual([ + [1, Duration.Quarter, 0, 0], + [2, Duration.Eighth, 0, 960], + [3, Duration.Eighth, 0, 1440], + [4, Duration.Sixteenth, 0, 1920], + [5, Duration.Sixteenth, 0, 2160], + [6, Duration.Sixteenth, 0, 2400], + [7, Duration.Sixteenth, 0, 2640], + [8, Duration.Quarter, 0, 2880] + ]); + expect(timeline(score, 3)).toEqual([ + [1, Duration.ThirtySecond, 0, 0], + [2, Duration.ThirtySecond, 0, 120], + [3, Duration.ThirtySecond, 0, 240], + [4, Duration.ThirtySecond, 0, 360], + [5, Duration.SixtyFourth, 0, 480], + [6, Duration.SixtyFourth, 0, 540], + [7, Duration.SixtyFourth, 0, 600], + [8, Duration.SixtyFourth, 0, 660], + [9, Duration.Sixteenth, 0, 720], + [10, Duration.Quarter, 0, 960], + [11, Duration.Half, 0, 1920] + ]); + for (let bar = 0; bar < 4; bar++) { + expect(beats(score, bar).every(b => single(b).string === 6)).toBe(true); + } + }); + + it('durations-dotted', async () => { + const score = await load('durations'); + expect(timeline(score, 4)).toEqual([ + [1, Duration.Half, 1, 0], + [2, Duration.Quarter, 0, 2880] + ]); + expect(timeline(score, 5)).toEqual([ + [1, Duration.Quarter, 1, 0], + [2, Duration.Eighth, 0, 1440], + [3, Duration.Quarter, 1, 1920], + [4, Duration.Eighth, 0, 3360] + ]); + expect(timeline(score, 6)).toEqual([ + [1, Duration.Eighth, 1, 0], + [2, Duration.Sixteenth, 0, 720], + [3, Duration.Sixteenth, 1, 960], + [4, Duration.ThirtySecond, 0, 1320], + [5, Duration.ThirtySecond, 1, 1440], + [6, Duration.SixtyFourth, 0, 1620], + [7, Duration.Sixteenth, 0, 1680], + [8, Duration.Half, 0, 1920] + ]); + expect(score.masterBars[7].timeSignatureNumerator).toBe(6); + expect(score.masterBars[7].timeSignatureDenominator).toBe(4); + expect(timeline(score, 7)).toEqual([[1, Duration.Whole, 1, 0]]); + }); + + it('durations-double-dotted', async () => { + const score = await load('durations'); + expect(timeline(score, 8)).toEqual([ + [1, Duration.Half, 2, 0], + [2, Duration.Eighth, 0, 3360] + ]); + expect(timeline(score, 9)).toEqual([ + [1, Duration.Quarter, 2, 0], + [2, Duration.Sixteenth, 0, 1680], + [3, Duration.Quarter, 2, 1920], + [4, Duration.Sixteenth, 0, 3600] + ]); + // nothing entered between the 64th ending at 24/64 and the half at 32/64 + expect(timeline(score, 10)).toEqual([ + [1, Duration.Eighth, 2, 0], + [2, Duration.ThirtySecond, 0, 840], + [3, Duration.Sixteenth, 2, 960], + [4, Duration.SixtyFourth, 0, 1380], + ['r', Duration.Eighth, 0, 1440], + [5, Duration.Half, 0, 1920] + ]); + }); + + it('durations-off-grid', async () => { + const score = await load('durations'); + const bar = timeline(score, 11); + expect(bar[0]).toEqual([1, Duration.SixtyFourth, 1, 0]); + // a dotted 64th ends between two grid slots, a 1/128 rest fills up to the next slot (D15) + expect(bar[1]).toEqual(['r', Duration.OneHundredTwentyEighth, 0, 90]); + expect(bar.slice(1, bar.length - 2).every(b => b[0] === 'r')).toBe(true); + expect(bar.slice(bar.length - 2)).toEqual([ + [2, Duration.Quarter, 0, 960], + [3, Duration.Half, 0, 1920] + ]); + }); + }); + + describe('tuplets', () => { + /** + * Asserts tuplet beats on string 6 fret 5 that start at the given exact (rational) times; alphaTab + * truncates tuplet ticks, so each preceding member of an inexact tuplet may shift a start by one tick. + */ + function expectTuplets(bar: Beat[], expected: ExpectedTupletBeat[]) { + expect(bar.length).toBe(expected.length); + for (let i = 0; i < bar.length; i++) { + const e = expected[i]; + expect(bar[i].duration, `beat ${i}`).toBe(e.duration); + expect(tuplet(bar[i]), `beat ${i}`).toBe(e.ratio); + expect(Math.abs(bar[i].playbackStart - e.start), `beat ${i} start`).toBeLessThanOrEqual(e.tolerance); + expect(single(bar[i]).string).toBe(6); + expect(single(bar[i]).fret).toBe(5); + } + } + + /** + * `shifted` = truncated tuplet beats before the group, `exact` = the members' own ticks are whole. + */ + function group( + duration: Duration, + ratio: string, + from: number, + step: number, + count: number, + exact = true, + shifted = 0 + ) { + const result: ExpectedTupletBeat[] = []; + for (let i = 0; i < count; i++) { + result.push(tupletBeat(duration, ratio, from + i * step, shifted + (exact ? 0 : i))); + } + return result; + } + + function tupletBeat(duration: Duration, ratio: string, start: number, tolerance: number): ExpectedTupletBeat { + return { duration, ratio, start, tolerance }; + } + + it('tuplets-triplets', async () => { + const score = await load('tuplets'); + expectTuplets(beats(score, 0), [ + ...group(Duration.Eighth, '3:2', 0, 320, 6), + ...group(Duration.Quarter, '3:2', 1920, 640, 3) + ]); + expectTuplets(beats(score, 1), [ + ...group(Duration.Sixteenth, '3:2', 0, 160, 6), + ...group(Duration.Quarter, '', 960, 960, 3) + ]); + expectTuplets(beats(score, 2), group(Duration.Half, '3:2', 0, 1280, 3)); + expectTuplets(beats(score, 3), [ + ...group(Duration.ThirtySecond, '3:2', 0, 80, 3), + tupletBeat(Duration.Sixteenth, '', 240, 0), + tupletBeat(Duration.Eighth, '', 480, 0), + tupletBeat(Duration.Quarter, '', 960, 0), + tupletBeat(Duration.Half, '', 1920, 0) + ]); + }); + + it('tuplets-other', async () => { + const score = await load('tuplets'); + expectTuplets(beats(score, 4), [ + ...group(Duration.Sixteenth, '5:4', 0, 192, 5), + ...group(Duration.Sixteenth, '7:4', 960, 960 / 7, 7, false), + tupletBeat(Duration.Quarter, '', 1920, 7), + tupletBeat(Duration.Quarter, '', 2880, 7) + ]); + expectTuplets(beats(score, 5), [ + ...group(Duration.ThirtySecond, '9:8', 0, 960 / 9, 9, false), + ...group(Duration.ThirtySecond, '10:8', 960, 96, 10, true, 9), + tupletBeat(Duration.Half, '', 1920, 9) + ]); + }); + + it('tuplets-whole-triplet', async () => { + const score = await load('tuplets'); + const bar = beats(score, 6); + // the whole triplet ends at 2560 ticks, an eighth triplet rest fills up to the quarter (D15) + expect(bar.map(b => [b.isRest, b.duration, tuplet(b), b.playbackStart])).toEqual([ + [false, Duration.Whole, '3:2', 0], + [true, Duration.Eighth, '3:2', 2560], + [false, Duration.Quarter, '', 2880] + ]); + expect(single(bar[0]).fret).toBe(5); + expect(single(bar[2]).fret).toBe(5); + }); + + it('tuplets-connection-number', async () => { + const score = await load('tuplets'); + // number 6 over 24 64ths: TablEdit spreads the 16ths at 0, 4, .. 20 by 8/6 and plays them as 6:4 + const bar = beats(score, 7); + const notes = bar.filter(b => !b.isRest); + expect(notes.map(b => [b.duration, tuplet(b), b.playbackStart])).toEqual( + [0, 1, 2, 3, 4, 5].map(i => [Duration.Sixteenth, '6:4', i * 320]) + ); + for (const b of notes) { + expect(single(b).string).toBe(5); + expect(single(b).fret).toBe(5); + } + expect(bar.filter(b => b.isRest && b.playbackStart < 1920).map(b => [tuplet(b), b.playbackStart])).toEqual( + [0, 1, 2, 3, 4, 5].map(i => ['6:4', i * 320 + 160]) + ); + // the connection is a slur with a number + expect(single(notes[0]).slurDestination).toBe(single(notes[5])); + }); + + it('tuplets-connections', async () => { + const score = await load('connections'); + const line = (bar: number) => + beats(score, bar).map( + b => [b.isRest ? 'r' : single(b).fret, b.duration, tuplet(b), b.playbackStart] as unknown[] + ); + // bracket with number 3 over three 8ths: drawn only + expect(line(0)).toEqual([ + [5, Duration.Eighth, '', 0], + [7, Duration.Eighth, '', 480], + [8, Duration.Eighth, '', 960], + ['r', Duration.Eighth, '', 1440], + [5, Duration.Quarter, '', 1920], + [5, Duration.Quarter, '', 2880] + ]); + expect(beats(score, 0).some(b => b.notes.some(n => n.isSlurOrigin || n.isSlurDestination))).toBe(false); + + // slur with number 3 over 8ths on the grid of 16ths (0, 4, 8; length 12): m = 4, 3:2 + expect(line(1)).toEqual([ + [5, Duration.Eighth, '3:2', 0], + [7, Duration.Eighth, '3:2', 320], + [8, Duration.Eighth, '3:2', 640], + [5, Duration.Quarter, '', 960], + [5, Duration.Quarter, '', 1920], + [5, Duration.Quarter, '', 2880] + ]); + expect(single(beats(score, 1)[0]).slurDestination).toBe(single(beats(score, 1)[2])); + + // number 4 over the 16ths at 0, 4, 8, 12 (length 15): m = 4, the notes keep their place and sound half as long + expect(line(2)).toEqual([ + [5, Duration.Sixteenth, '4:2', 0], + ['r', Duration.Sixteenth, '4:2', 120], + [7, Duration.Sixteenth, '4:2', 240], + ['r', Duration.Sixteenth, '4:2', 360], + [8, Duration.Sixteenth, '4:2', 480], + ['r', Duration.Sixteenth, '4:2', 600], + [7, Duration.Sixteenth, '4:2', 720], + ['r', Duration.ThirtySecond, '', 840], + [5, Duration.Quarter, '', 960], + [5, Duration.Quarter, '', 1920], + [5, Duration.Quarter, '', 2880] + ]); + expect(single(beats(score, 2)[0]).slurDestination).toBe(single(beats(score, 2)[6])); + }); + }); + + it('voices', async () => { + const score = await load('voices'); + const bars = score.tracks[0].staves[0].bars; + expect(bars.every(b => b.voices.length === 4)).toBe(true); + + // per lane the automatic, upper and lower voice + const automatic = (bar: number) => beats(score, bar, 0); + const upper = (bar: number) => beats(score, bar, 1); + const lower = (bar: number) => beats(score, bar, 2); + const automatic2 = (bar: number) => beats(score, bar, 3); + + // bar 1: upper quarters, lower halves + expect(upper(0).map(b => b.duration)).toEqual([ + Duration.Quarter, + Duration.Quarter, + Duration.Quarter, + Duration.Quarter + ]); + expect(upper(0).map(b => single(b).string)).toEqual([6, 6, 6, 6]); + expect(upper(0).map(b => single(b).fret)).toEqual([5, 7, 8, 7]); + expect(upper(0).every(b => b.preferredBeamDirection === BeamDirection.Up)).toBe(true); + expect(lower(0).map(b => b.duration)).toEqual([Duration.Half, Duration.Half]); + expect(lower(0).map(b => single(b).string)).toEqual([2, 2]); + expect(lower(0).map(b => single(b).fret)).toEqual([3, 2]); + expect(lower(0).every(b => b.preferredBeamDirection === BeamDirection.Down)).toBe(true); + expect(bars[0].voices[0].isEmpty).toBe(true); + expect(bars[0].voices[3].isEmpty).toBe(true); + + // bar 2: automatic voices from different lengths at one position + expect(automatic(1).map(b => b.duration)).toEqual([ + Duration.Quarter, + Duration.Quarter, + Duration.Quarter, + Duration.Quarter + ]); + expect(automatic(1).map(b => single(b).string)).toEqual([5, 5, 5, 5]); + expect(automatic(1).map(b => single(b).fret)).toEqual([1, 3, 1, 0]); + expect(automatic(1).every(b => b.preferredBeamDirection === null)).toBe(true); + expect(automatic2(1).map(b => b.duration)).toEqual([Duration.Half, Duration.Half]); + expect(automatic2(1).map(b => [single(b).string, single(b).fret])).toEqual([ + [1, 0], + [3, 2] + ]); + expect(bars[1].voices[1].isEmpty).toBe(true); + expect(bars[1].voices[2].isEmpty).toBe(true); + + // bar 3: entered rest and empty column are both rests + expect(automatic(2).map(b => b.duration)).toEqual([ + Duration.Quarter, + Duration.Quarter, + Duration.Quarter, + Duration.Quarter + ]); + expect(automatic(2).map(b => b.isRest)).toEqual([true, false, true, false]); + expect(single(automatic(2)[1]).string).toBe(6); + expect(single(automatic(2)[3]).string).toBe(6); + + // bar 4: lower voice starting with an entered rest + expect(upper(3).length).toBe(4); + expect(upper(3).every(b => b.duration === Duration.Quarter && single(b).string === 6)).toBe(true); + expect(upper(3).every(b => b.preferredBeamDirection === BeamDirection.Up)).toBe(true); + expect(lower(3).map(b => b.duration)).toEqual([Duration.Half, Duration.Half]); + expect(lower(3)[0].isRest).toBe(true); + expect(single(lower(3)[1]).string).toBe(2); + expect(single(lower(3)[1]).fret).toBe(3); + expect(lower(3).every(b => b.preferredBeamDirection === BeamDirection.Down)).toBe(true); + }); + + it('voices-ties', async () => { + // TablEdit ties a note to the previous note on its string, in any voice + const score = await load('voices-ties'); + const bars = score.tracks[0].staves[0].bars; + const half = quarter * 2; + const noteAt = (bar: number, start: number, string: number): Note => { + const found: Note[] = []; + for (const voice of bars[bar].voices) { + for (const b of voice.beats) { + for (const n of b.notes) { + if (b.playbackStart === start && n.string === string) { + found.push(n); + } + } + } + } + expect(found.length, `bar ${bar} start ${start} string ${string}`).toBe(1); + return found[0]; + }; + + // [bar, start, string] of the tied note and of the note it continues; all but the third last in other voices + const ties: number[][] = [ + [0, half, 4, 0, 0, 4], + [1, 0, 2, 0, half, 2], + [2, half, 6, 2, 0, 6], + [3, half, 5, 3, 0, 5], + [3, half, 4, 3, 0, 4], + [3, half, 2, 3, 0, 2] + ]; + for (const t of ties) { + const label = `tie in bar ${t[0]} on string ${t[2]}`; + const destination = noteAt(t[0], t[1], t[2]); + const origin = noteAt(t[3], t[4], t[5]); + expect(destination.isTieDestination, label).toBe(true); + expect(destination.tieOrigin, label).toBe(origin); + expect(origin.tieDestination, label).toBe(destination); + expect(destination.fret, label).toBe(origin.fret); + expect(destination.beat.voice.index !== origin.beat.voice.index, label).toBe(t[2] !== 4 || t[0] !== 3); + } + + // no dynamics marking, also on the beats of tied notes only + for (const b of bars) { + for (const v of b.voices) { + expect(v.beats.filter(beat => !beat.isRest).every(beat => beat.showDynamics === false)).toBe(true); + } + } + + // the automatic voice comes first, its stems point away from an upper voice in the same bar + const stems = (bar: number, voice: number) => + bars[bar].voices[voice].beats.filter(b => !b.isRest).map(b => b.preferredBeamDirection); + expect(stems(0, 0)).toEqual([BeamDirection.Down, BeamDirection.Down]); + expect(stems(0, 1)).toEqual([BeamDirection.Up]); + expect(stems(1, 0)).toEqual([null, null]); + expect(stems(1, 2)).toEqual([BeamDirection.Down]); + + // only the untied notes are struck, each tied note extends the note it continues + const handler = new FlatMidiEventGenerator(); + new MidiFileGenerator(score, null, handler).generate(); + const notes: number[][] = []; + for (const e of handler.midiEvents) { + if (e instanceof FlatNoteEvent) { + notes.push([e.tick, e.key, e.length]); + } + } + notes.sort((a, b) => (a[0] !== b[0] ? a[0] - b[0] : a[1] - b[1])); + const bar = quarter * 4; + expect(notes).toEqual([ + [0, 48, half], + [0, 57, bar], + [half, 47, half + bar], + [bar, 60, half], + [bar + half, 62, half], + [2 * bar, 52, half], + [2 * bar, 67, bar], + [3 * bar, 48, bar], + [3 * bar, 55, bar], + [3 * bar, 60, bar] + ]); + }); + + describe('effects', () => { + it('effects-note', async () => { + const score = await load('effects-note'); + for (let bar = 0; bar < 6; bar++) { + expect(beats(score, bar).length).toBe(4); + expect(beats(score, bar).every(b => single(b).string === 6)).toBe(true); + } + + const bar1 = beats(score, 0).map(b => single(b)); + expect(bar1.map(n => n.fret)).toEqual([5, 7, 7, 5]); + expect(bar1.map(n => n.isHammerPullOrigin)).toEqual([true, false, true, false]); + expect(bar1.map(n => n.isHammerPullDestination)).toEqual([false, true, false, true]); + expect(bar1[0].hammerPullDestination).toBe(bar1[1]); + expect(bar1[2].hammerPullDestination).toBe(bar1[3]); + + // a slide whose target carries a pick stroke strikes the target again + const bar2 = beats(score, 1); + expect(bar2.map(b => single(b).fret)).toEqual([5, 7, 5, 7]); + expect(bar2.map(b => single(b).slideOutType)).toEqual([ + SlideOutType.Legato, + SlideOutType.None, + SlideOutType.Shift, + SlideOutType.None + ]); + expect(bar2.map(b => b.pickStroke)).toEqual([ + PickStroke.None, + PickStroke.None, + PickStroke.None, + PickStroke.Down + ]); + + // the bend amount comes from the next note on the string, here fret 9 without a tie + const bar3 = beats(score, 2).map(b => single(b)); + expect(bar3.map(n => n.fret)).toEqual([7, 9, 7, 7]); + expect(bar3[0].bendType).toBe(BendType.Bend); + expect(bendPoints(bar3[0])).toEqual([ + [0, 0], + [60, 4] + ]); + expect(bar3[1].isTieDestination).toBe(false); + expect(bendPoints(bar3[1])).toBe(null); + expect(bar3[3].isTieDestination).toBe(true); + expect(bar3[3].tieOrigin).toBe(bar3[2]); + + const bar4 = beats(score, 3).map(b => single(b)); + expect(bar4.map(n => n.fret)).toEqual([5, 12, 5, 5]); + // choke without an amount: a quarter tone + expect(bendPoints(bar4[0])).toEqual([ + [0, 0], + [60, 1] + ]); + expect(bar4[1].harmonicType).toBe(HarmonicType.Natural); + expect(bar4[1].harmonicValue).toBe(12); + expect(bar4[2].harmonicType).toBe(HarmonicType.Artificial); + expect(bar4[2].harmonicValue).toBe(12); + expect(bar4.map(n => n.isPalmMute)).toEqual([false, false, false, true]); + + const bar5 = beats(score, 4); + expect(bar5.map(b => b.tap)).toEqual([true, false, false, false]); + expect(bar5.map(b => single(b).vibrato)).toEqual([ + VibratoType.None, + VibratoType.Slight, + VibratoType.None, + VibratoType.None + ]); + expect(bar5.map(b => b.tremoloPicking?.marks ?? 0)).toEqual([0, 0, 3, 0]); + expect(bar5.map(b => single(b).isDead)).toEqual([false, false, false, true]); + + const bar6 = beats(score, 5); + expectNoNoteEffect(bar6[0]); + expectNoNoteEffect(bar6[1]); + // a bend towards the same fret has no amount + expect(single(bar6[2]).fret).toBe(7); + expect(bendPoints(single(bar6[2]))).toBe(null); + expect(single(bar6[3]).isTieDestination).toBe(true); + expect(single(bar6[3]).tieOrigin).toBe(single(bar6[2])); + }); + + it('effects-target-fret', async () => { + const score = await load('effects-target'); + const bar1 = beats(score, 0); + const bar2 = beats(score, 1); + expect([...bar1, ...bar2].map(b => single(b).fret)).toEqual([7, 9, 5, 7, 7, 4, 5, 8]); + expect([...bar1, ...bar2].every(b => !single(b).isTieDestination)).toBe(true); + // bend + release from fret 7 towards the next note on the string, fret 9: 2 semitones + expect(bendPoints(single(bar1[0]))).toEqual([ + [0, 0], + [15, 4], + [45, 4], + [60, 0] + ]); + // tremolo bar up/down towards fret 7 (from 5) and fret 4 (from 7), dive-return towards fret 8 (from 5) + expect(whammy(bar1[2])).toEqual([ + [0, 0], + [30, 4], + [60, 4] + ]); + expect(whammy(bar2[0])).toEqual([ + [0, 0], + [30, -6], + [60, -6] + ]); + expect(whammy(bar2[2])).toEqual([ + [0, 0], + [30, 6], + [60, 0] + ]); + for (const b of [bar1[1], bar1[3], bar2[1], bar2[3]]) { + expectNoNoteEffect(b); + } + }); + + it('effects-b5', async () => { + const score = await load('effects-b5'); + const bar1 = beats(score, 0); + expect(single(bar1[0]).isLetRing).toBe(true); + expect(bar1[1].slap).toBe(true); + expect(single(bar1[2]).isGhost).toBe(true); + expect(single(bar1[3]).isStaccato).toBe(true); + + // tremolo bar up/down from fret 5 towards fret 3, dive-return from fret 5 towards fret 7 + const bar2 = beats(score, 1); + expect(bar2.map(b => single(b).fret)).toEqual([5, 3, 5, 7]); + expect(whammy(bar2[0])).toEqual([ + [0, 0], + [30, -4], + [60, -4] + ]); + expect(whammy(bar2[2])).toEqual([ + [0, 0], + [30, 4], + [60, 0] + ]); + for (const b of [bar2[1], bar2[3]]) { + expect(single(b).isTieDestination).toBe(false); + expectNoNoteEffect(b); + } + + const bar3 = beats(score, 2); + expect(bar3[0].fade).toBe(FadeType.FadeIn); + expect(bar3[1].fade).toBe(FadeType.FadeOut); + // ghost combination without a primary effect + expect(single(bar3[2]).isGhost).toBe(true); + expect(single(bar3[2]).slideOutType).toBe(SlideOutType.None); + expectNoNoteEffect(bar3[3]); + }); + + it('effects-combination', async () => { + const score = await load('effects-b5'); + const combined = [...beats(score, 3), ...beats(score, 4), ...beats(score, 5).slice(0, 2)]; + expect(combined.length).toBe(10); + for (const b of combined) { + expect(single(b).slideOutType).toBe(SlideOutType.Legato); + } + const n = combined.map(b => single(b)); + expect(n[0].isHammerPullOrigin).toBe(true); + expect(n[1].isHammerPullOrigin).toBe(true); + expect(combined[2].brushType).toBe(BrushType.ArpeggioDown); + expect(combined[3].brushType).toBe(BrushType.BrushDown); + expect(n[4].harmonicType).toBe(HarmonicType.Natural); + expect(n[4].harmonicValue).toBe(5); + expect(n[4].realValue).toBe(64 + 24); + expect(n[5].harmonicType).toBe(HarmonicType.Artificial); + expect(n[5].harmonicValue).toBe(12); + expect(n[6].isLetRing).toBe(true); + expect(n[7].isGhost).toBe(true); + expect(n[8].isDead).toBe(true); + // variation (D9): nothing besides the slide + expect(n[9].isHammerPullOrigin).toBe(false); + expect(n[9].harmonicType).toBe(HarmonicType.None); + expect(n[9].isGhost || n[9].isDead || n[9].isLetRing).toBe(false); + expect(combined[9].brushType).toBe(BrushType.None); + + for (const b of beats(score, 5).slice(2)) { + expectNoNoteEffect(b); + } + }); + + it('effects-b5-variants', async () => { + // synthesized: B5 bytes TablEdit offers no input for, patched into the file + const base = await loadData('effects-b5'); + { + // staccato on the stem (variation of staccato) at 1.48 + const data = new Uint8Array(base); + data[cellData(data, 48, 0) + 4] = 0xb7; + const n = single(beats(readBytes(data), 0)[3]); + expect(n.isStaccato).toBe(true); + expect(n.isGhost || n.isLetRing || n.isDead).toBe(false); + } + { + // ghost as primary and combination effect is no effect (D12) at 2.16 + const data = new Uint8Array(base); + data[cellData(data, 64 + 16, 0) + 4] = 0x94; + expectNoNoteEffect(beats(readBytes(data), 1)[1]); + } + { + // tremolo bars whose next note on the string is on the same fret have no amount + const data = new Uint8Array(base); + data[cellData(data, 64 + 16, 0)] = 0x06; + data[cellData(data, 64 + 48, 0)] = 0x06; + for (const b of beats(readBytes(data), 1)) { + expect(single(b).fret).toBe(5); + expectNoNoteEffect(b); + } + } + }); + + it('effects-chord', async () => { + const score = await load('effects-chord'); + const cChord = new Map([ + [5, 1], + [4, 0], + [3, 2], + [2, 3] + ]); + const all = [...beats(score, 0), ...beats(score, 1)]; + expect(all.length).toBe(8); + for (const b of all) { + expect(frets(b)).toEqual(cChord); + } + + const bar1 = beats(score, 0); + expect(bar1[0].brushType).toBe(BrushType.BrushDown); + expect(bar1[1].brushType).toBe(BrushType.BrushUp); + expect(bar1[2].brushType).toBe(BrushType.ArpeggioDown); + expect(bar1[3].brushType).toBe(BrushType.ArpeggioUp); + // no speed entered: brush speed 1, roll speed 4; 5 TablEdit ticks (20 here) per step between the 4 strings + expect(bar1.map(b => b.brushDuration)).toEqual([60, 60, 240, 240]); + + const bar2 = beats(score, 1); + expect(bar2[0].rasgueado).toBe(Rasgueado.Ii); + expect(bar2[0].brushType).toBe(BrushType.None); + expectNoNoteEffect(bar2[1]); + // roll combination without a primary effect + expect(bar2[2].brushType).toBe(BrushType.ArpeggioDown); + expect(bar2[2].brushDuration).toBe(240); + expectNoNoteEffect(bar2[3]); + }); + + it('effects-chord-anticipated-roll', async () => { + // synthesized: TablEdit stores Shift+R as the plain roll 0x30, the anticipated roll 0x37 is patched in + const data = await loadData('effects-chord'); + data[cellData(data, 64 + 16, 4) + 4] = 0x37; + const beat = beats(readBytes(data), 1)[1]; + // no roll that ends on the beat in alphaTab: a plain roll from the bass (D2) + expect(beat.brushType).toBe(BrushType.ArpeggioDown); + expect(beat.notes.length).toBe(4); + expect(beat.notes.some(n => n.isStaccato)).toBe(false); + }); + + it('effects-pitch-change', async () => { + const score = await load('effects-parameters'); + const bar = beats(score, 0); + expect(bar.map(b => single(b).fret)).toEqual([5, 5, 5, 5]); + expect(bendPoints(single(bar[0]))).toEqual([ + [0, 2], + [60, 2] + ]); + expect(bendPoints(single(bar[1]))).toEqual([ + [0, 4], + [60, 4] + ]); + expect(bendPoints(single(bar[2]))).toEqual([ + [0, 6], + [60, 6] + ]); + // no downward bends in alphaTab + expect(bendPoints(single(bar[3]))).toBe(null); + }); + + it('effects-parameters', async () => { + const score = await load('effects-parameters'); + + // artificial harmonic interval 0xBn in B4, or in B5 when B4 is 0 + const bar2 = beats(score, 1); + expect(bar2.map(b => single(b).harmonicType)).toEqual([ + HarmonicType.Artificial, + HarmonicType.Artificial, + HarmonicType.Artificial, + HarmonicType.Artificial + ]); + expect(bar2.map(b => single(b).harmonicValue)).toEqual([3, 5, 7, 7]); + + const bar3 = beats(score, 2); + expect(single(bar3[0]).bendType).toBe(BendType.Bend); + expect(bendPoints(single(bar3[0]))).toEqual([ + [0, 0], + [60, 3] + ]); + expect(single(bar3[1]).vibrato).toBe(VibratoType.Slight); + expect(bar3[2].tremoloPicking!.marks).toBe(3); + expect(bar3[3].duration).toBe(Duration.Eighth); + expect(single(bar3[3]).slideOutType).toBe(SlideOutType.Legato); + expect(single(bar3[4]).fret).toBe(7); + + const bar4 = beats(score, 3); + expect(bar4.map(b => b.duration)).toEqual([Duration.Quarter, Duration.Quarter, Duration.Half]); + expect(bar4.map(b => b.isRest)).toEqual([false, true, true]); + expect(frets(bar4[0])).toEqual( + new Map([ + [5, 1], + [4, 0], + [3, 2], + [2, 3] + ]) + ); + expect(bar4[0].brushType).toBe(BrushType.ArpeggioDown); + // roll parameter 0x08: offset 8 for the symbol, speed bits 0, i.e. speed 4 + expect(bar4[0].brushDuration).toBe(240); + }); + + it('effects-speed', async () => { + // effects-chord.tef with speed parameters, re-saved in TablEdit + const score = await load('effects-speed'); + const bar1 = beats(score, 0); + const bar2 = beats(score, 1); + expect([...bar1, bar2[2]].map(b => b.brushType)).toEqual([ + BrushType.BrushDown, + BrushType.BrushUp, + BrushType.ArpeggioDown, + BrushType.ArpeggioUp, + BrushType.ArpeggioDown + ]); + // brush 0x20: speed 3; brush 0x00: 1; roll 0x30: 7; roll 0x50: 1 (roll speeds 1-3 follow 8); + // roll combination 0x02: 2; 20 ticks per speed step between each of the 4 strings + expect([...bar1, bar2[2]].map(b => b.brushDuration)).toEqual([180, 60, 420, 60, 120]); + }); + }); + + describe('grace', () => { + it('grace-notes', async () => { + const score = await load('grace'); + const bar1 = beats(score, 0); + expect(bar1.length).toBe(8); + const graces = [bar1[0], bar1[2], bar1[4], bar1[6]]; + const mains = [bar1[1], bar1[3], bar1[5], bar1[7]]; + for (const main of mains) { + expect(main.graceType).toBe(GraceType.None); + expect(main.duration).toBe(Duration.Quarter); + expect(single(main).string).toBe(5); + expect(single(main).fret).toBe(5); + expect(single(main).realValue).toBe(64); + } + // the grace duration is alphaTab's own (D4) + for (const grace of graces) { + expect(single(grace).string).toBe(5); + expect(single(grace).fret).toBe(3); + } + expect(graces.map(g => g.graceType)).toEqual([ + GraceType.OnBeat, + GraceType.OnBeat, + GraceType.OnBeat, + GraceType.BeforeBeat + ]); + expect(single(graces[0]).isHammerPullOrigin).toBe(true); + expect(single(graces[1]).slideOutType).toBe(SlideOutType.Legato); + expect(single(graces[2]).bendType).toBe(BendType.Bend); + expect(bendPoints(single(graces[2]))).toEqual([ + [0, 0], + [60, 4] + ]); + expect(single(graces[3]).isHammerPullOrigin).toBe(true); + + const bar2 = beats(score, 1); + expect(bar2.map(b => b.graceType)).toEqual([ + GraceType.OnBeat, + GraceType.None, + GraceType.OnBeat, + GraceType.None, + GraceType.None, + GraceType.None + ]); + // own grace duration 1/16 (D4: not representable) + expect(single(bar2[0]).fret).toBe(3); + expect(single(bar2[0]).isHammerPullOrigin).toBe(true); + // grace from string row 2: its fret is the fret on that string, slurred to the main note + expect(single(bar2[2]).string).toBe(4); + expect(single(bar2[2]).fret).toBe(3); + expect(single(bar2[2]).isHammerPullOrigin).toBe(false); + expect(single(bar2[2]).slurDestination).toBe(single(bar2[3])); + for (const main of [bar2[1], bar2[3], bar2[4], bar2[5]]) { + expect(main.duration).toBe(Duration.Quarter); + expect(single(main).string).toBe(5); + expect(single(main).fret).toBe(5); + } + expect(bar2[4].notes[0].isHammerPullOrigin).toBe(false); + }); + + it('grace-from-string', async () => { + const score = await load('grace-string'); + const bar = beats(score, 0); + // hammer-on graces (fret 3) to B-string notes (fret 5), from string 3 (G) and from string 1 (e); + // TablEdit shows the stored fret on the other string + expect(bar.map(b => [b.graceType, single(b).string, single(b).fret])).toEqual([ + [GraceType.OnBeat, 4, 3], + [GraceType.None, 5, 5], + [GraceType.None, 5, 5], + [GraceType.OnBeat, 6, 3], + [GraceType.None, 5, 5], + [GraceType.None, 5, 5] + ]); + expect(single(bar[0]).realValue).toBe(58); + expect(single(bar[3]).realValue).toBe(67); + // alphaTab keeps hammer-ons on one string, the slur TablEdit draws remains + const pairs: [Beat, Beat][] = [ + [bar[0], bar[1]], + [bar[3], bar[4]] + ]; + for (const [grace, main] of pairs) { + expect(single(grace).isHammerPullOrigin).toBe(false); + expect(single(grace).slurDestination).toBe(single(main)); + expect(single(main).isSlurDestination).toBe(true); + } + }); + + it('grace-variants', async () => { + const score = await load('grace-x'); + const bar1 = beats(score, 0); + // the grace dialog's "None" removes the grace (1.0, 1.16, 1.32); a grace on fret 33 before fret 35 + expect(bar1.map(b => [b.graceType, single(b).string, single(b).fret])).toEqual([ + [GraceType.None, 5, 5], + [GraceType.None, 5, 5], + [GraceType.None, 5, 5], + [GraceType.OnBeat, 5, 33], + [GraceType.None, 5, 35] + ]); + expect(single(bar1[3]).isHammerPullOrigin).toBe(true); + + // mordent, double and gruppetto on fret 5 with the auxiliary fret 7 (above) and 3 (below) + const ornaments = [...beats(score, 1), ...beats(score, 2)]; + expect(ornaments.map(b => [b.graceType, single(b).fret, single(b).ornament])).toEqual([ + [GraceType.None, 5, NoteOrnament.UpperMordent], + [GraceType.None, 5, NoteOrnament.LowerMordent], + [GraceType.None, 5, NoteOrnament.Turn], + [GraceType.None, 5, NoteOrnament.InvertedTurn], + [GraceType.None, 5, NoteOrnament.Turn], + [GraceType.None, 5, NoteOrnament.InvertedTurn], + [GraceType.None, 5, NoteOrnament.None], + [GraceType.None, 5, NoteOrnament.None] + ]); + }); + + it('grace-kind-0', async () => { + // grace-x.tef with graces of kind 0 (no articulation), re-saved in TablEdit: fret 3, the main fret 5, fret 0 + const score = await load('grace-kind0'); + const bar1 = beats(score, 0); + // TablEdit's notation leaves out the grace on the main fret + expect(bar1.map(b => [b.graceType, single(b).fret])).toEqual([ + [GraceType.OnBeat, 3], + [GraceType.None, 5], + [GraceType.None, 5], + [GraceType.OnBeat, 0], + [GraceType.None, 5], + [GraceType.OnBeat, 33], + [GraceType.None, 35] + ]); + expectNoNoteEffect(bar1[0]); + expectNoNoteEffect(bar1[3]); + }); + + it('grace-ornaments', async () => { + const score = await load('grace'); + const bar = beats(score, 2); + expect(bar.length).toBe(4); + expect(bar.every(b => b.graceType === GraceType.None && b.duration === Duration.Quarter)).toBe(true); + expect(bar.every(b => single(b).string === 5 && single(b).fret === 5)).toBe(true); + // the mordent's auxiliary fret 3 lies below the main fret; double and gruppetto were entered with + // the first auxiliary note below (fret 3) and the second on the main fret + expect(single(bar[0]).ornament).toBe(NoteOrnament.LowerMordent); + expect(single(bar[1]).ornament).toBe(NoteOrnament.InvertedTurn); + expect(single(bar[2]).ornament).toBe(NoteOrnament.InvertedTurn); + expect(single(bar[3]).ornament).toBe(NoteOrnament.None); + expect(single(bar[3]).isTrill).toBe(true); + expect(single(bar[3]).trillFret).toBe(3); + }); + }); + + it('fingering-strokes', async () => { + const score = await load('fingering'); + const all = [0, 1, 2, 3, 4].flatMap(b => beats(score, b)); + expect(all.length).toBe(20); + const n = all.map(b => single(b)); + expect(n.every(x => x.string === 6 && x.fret === 5)).toBe(true); + + expect(all.map(b => b.pickStroke)).toEqual([ + PickStroke.Down, + PickStroke.Up, + PickStroke.None, + PickStroke.None, + PickStroke.None, + PickStroke.None, + PickStroke.None, + PickStroke.None, + PickStroke.None, + PickStroke.None, + PickStroke.None, + PickStroke.None, + PickStroke.None, + PickStroke.None, + PickStroke.Up, + PickStroke.None, + PickStroke.None, + PickStroke.None, + PickStroke.None, + PickStroke.None + ]); + expect(n.map(x => x.leftHandFinger)).toEqual([ + Fingers.Unknown, + Fingers.Unknown, + Fingers.Unknown, + Fingers.Unknown, + Fingers.Thumb, + Fingers.IndexFinger, + Fingers.MiddleFinger, + Fingers.AnnularFinger, + Fingers.LittleFinger, + Fingers.Unknown, + Fingers.Unknown, + Fingers.Unknown, + Fingers.Unknown, + Fingers.Unknown, + Fingers.Unknown, + Fingers.MiddleFinger, + Fingers.IndexFinger, + Fingers.Unknown, + Fingers.IndexFinger, + Fingers.Unknown + ]); + expect(n.map(x => x.rightHandFinger)).toEqual([ + Fingers.Unknown, + Fingers.Unknown, + Fingers.Unknown, + Fingers.Unknown, + Fingers.Unknown, + Fingers.Unknown, + Fingers.Unknown, + Fingers.Unknown, + Fingers.Unknown, + Fingers.Thumb, + Fingers.IndexFinger, + Fingers.MiddleFinger, + Fingers.AnnularFinger, + Fingers.LittleFinger, + Fingers.AnnularFinger, + Fingers.MiddleFinger, + Fingers.Unknown, + Fingers.IndexFinger, + Fingers.Unknown, + Fingers.Unknown + ]); + }); + + it('note-attributes', async () => { + const score = await load('attributes'); + const bar1 = beats(score, 0); + // hidden in notation: the staff shows tablature, B5 0x0F is no effect + expect(single(bar1[0]).isVisible).toBe(true); + expectNoNoteEffect(bar1[0]); + // the third beat is excluded from beaming, so neither it nor the beat before it beams on; + // the "no stem" attribute of the second beat has no model + expect(bar1[0].beamingMode).toBe(BeatBeamingMode.Auto); + expect(bar1[1].beamingMode).toBe(BeatBeamingMode.ForceSplitToNext); + expect(bar1[2].beamingMode).toBe(BeatBeamingMode.ForceSplitToNext); + expect(bar1[3].beamingMode).toBe(BeatBeamingMode.Auto); + expect(bar1.map(b => b.ottava)).toEqual([Ottavia.Regular, Ottavia.Regular, Ottavia.Regular, Ottavia._8vb]); + + const bar2 = beats(score, 1); + expect(bar2[0].ottava).toBe(Ottavia._8va); + // move left / right are layout offsets + expect(bar2.map(b => b.playbackStart)).toEqual([0, quarter, 2 * quarter, 3 * quarter]); + expect(bar2[1].ottava).toBe(Ottavia.Regular); + expect(bar2[2].ottava).toBe(Ottavia.Regular); + expect(bar2[3].isRest).toBe(true); + // hidden in notation only, the tablature still shows it + expect(bar2[3].isEmpty).toBe(false); + + const bar3 = beats(score, 2); + expect(bar3.map(b => b.duration)).toEqual([ + Duration.Sixteenth, + Duration.Sixteenth, + Duration.Sixteenth, + Duration.Sixteenth, + Duration.Quarter, + Duration.Half + ]); + expect(bar3.map(b => b.isRest)).toEqual([false, false, true, false, true, false]); + // secondary beam break on the rest splits the secondary beam before it + expect(bar3[1].beamingMode).toBe(BeatBeamingMode.ForceSplitOnSecondaryToNext); + expect(bar3[0].beamingMode).toBe(BeatBeamingMode.Auto); + expect(bar3[3].beamingMode).toBe(BeatBeamingMode.Auto); + expect(bar3.some(b => b.notes.some(x => x.isStaccato))).toBe(false); + // hidden in tablature only, the notation still shows it + expect(bar3[4].isEmpty).toBe(false); + + // tie slur up and down are layout only + const bar4 = beats(score, 3); + expect(bar4.map(b => [b.duration, single(b).string, single(b).fret])).toEqual([ + [Duration.Quarter, 6, 5], + [Duration.Quarter, 6, 5], + [Duration.Quarter, 6, 5], + [Duration.Quarter, 6, 5] + ]); + expect(bar4.map(b => single(b).isTieDestination)).toEqual([false, true, false, true]); + expect(single(bar4[1]).tieOrigin).toBe(single(bar4[0])); + expect(single(bar4[3]).tieOrigin).toBe(single(bar4[2])); + for (const b of bar4) { + expect(single(b).isVisible).toBe(true); + expect(b.isEmpty).toBe(false); + expect(b.beamingMode).toBe(BeatBeamingMode.Auto); + expect(b.ottava).toBe(Ottavia.Regular); + expectNoNoteEffect(b); + } + }); + + describe('instruments', () => { + it('instruments-midi', async () => { + const score = await load('instruments'); + expect(score.tracks.length).toBe(2); + const lead = score.tracks[0]; + const clarinet = score.tracks[1]; + expect(lead.name).toBe('Lead Guitar'); + // entered as "Lead Guitar^Ld" + expect(lead.shortName).toBe('Ld'); + // module 2 was added as a copy of module 1 and renamed, its abbreviation removed + expect(clarinet.name).toBe('Clarinet'); + // no abbreviation: alphaTab's own short name + expect(clarinet.shortName).toBe('Clarinet'); + for (const track of score.tracks) { + expect(track.playbackInfo.program).toBe(29); + expect(track.playbackInfo.bank).toBe(0); + // pan and volume not entered: TablEdit's centre pan 7 of 0-15 and no attenuation + expect(track.playbackInfo.balance).toBe(7); + expect(track.playbackInfo.volume).toBe(16); + const staff = track.staves[0]; + // the capo is folded into the stored tuning + expect(staff.capo).toBe(2); + expect(staff.tuning).toEqual([64, 59, 55, 50, 45, 40]); + } + + const leadBar = lead.staves[0].bars[0].voices[0].beats; + expect(leadBar.map(b => b.isRest).slice(0, 2)).toEqual([false, false]); + expect(leadBar.slice(2).every(b => b.isRest)).toBe(true); + expect(single(leadBar[0]).string).toBe(1); + expect(single(leadBar[0]).fret).toBe(0); + expect(single(leadBar[0]).realValue).toBe(42); + expect(single(leadBar[1]).string).toBe(6); + expect(single(leadBar[1]).fret).toBe(0); + expect(single(leadBar[1]).realValue).toBe(66); + expect(leadBar[1].playbackStart).toBe(quarter); + + // the strings of module 2 follow the six strings of module 1 + const clarinetBar = clarinet.staves[0].bars[0].voices[0].beats; + expect(single(clarinetBar[0]).string).toBe(6); + expect(single(clarinetBar[0]).fret).toBe(3); + expect(clarinetBar.slice(1).every(b => b.isRest)).toBe(true); + + // the Let Ring option of the module rings every note + for (const track of score.tracks) { + for (const bar of track.staves[0].bars) { + for (const b of bar.voices[0].beats) { + expect(b.notes.every(n => n.isLetRing)).toBe(true); + } + } + } + }); + + it('instruments-notation', async () => { + const score = await load('instruments'); + const lead = score.tracks[0]; + const clarinet = score.tracks[1]; + // guitar: written an octave higher; middle C standard and B flat: written a major second higher + expect(lead.staves[0].displayTranspositionPitch).toBe(-12); + expect(clarinet.staves[0].displayTranspositionPitch).toBe(-2); + for (const track of score.tracks) { + expect(track.staves[0].showTablature).toBe(true); + expect(track.staves[0].showStandardNotation).toBe(true); + } + // key +1 in bar 1; the B flat instrument shows two sharps more (D11) + expect(lead.staves[0].bars.map(b => b.keySignature)).toEqual([1, 0]); + expect(clarinet.staves[0].bars.map(b => b.keySignature)).toEqual([3, 2]); + + // the text was entered on module 2 only + const texts = (track: number) => + score.tracks[track].staves[0].bars.flatMap(b => b.voices[0].beats.map(x => x.text)); + expect(texts(0).every(t => t === null)).toBe(true); + const clarinetBar = clarinet.staves[0].bars[0].voices[0].beats; + expect(clarinetBar[1].playbackStart).toBe(quarter); + expect(clarinetBar[1].text).toBe('Second'); + expect(clarinetBar.map(b => b.text).filter(t => t !== null).length).toBe(1); + }); + + it('instruments-clefs', async () => { + const score = await load('instruments-patched'); + expect(score.tracks.length).toBe(10); + // auto, G, F, baritone F, tenor C, alto C, mezzo-soprano C, soprano C (nearest alphaTab clefs), + // auto on a bass tuning, and clef 2 with bit 4 set + expect(score.tracks.map(t => t.staves[0].bars[0].clef)).toEqual([ + Clef.G2, + Clef.G2, + Clef.F4, + Clef.F4, + Clef.C4, + Clef.C3, + Clef.C3, + Clef.C3, + Clef.F4, + Clef.F4 + ]); + for (const track of score.tracks) { + const first = track.staves[0].bars[0].voices[0].beats[0]; + expect(single(first).string).toBe(1); + expect(single(first).fret).toBe(0); + } + }); + + it('instruments-tuning', async () => { + const score = await load('instruments-patched'); + const tuning = (track: number) => score.tracks[track].staves[0].tuning; + const lowest = (track: number) => single(score.tracks[track].staves[0].bars[0].voices[0].beats[0]); + expect(tuning(0)).toEqual([64, 59, 55, 50, 45, 40]); + expect(lowest(0).realValue).toBe(40); + // drop D + expect(tuning(1)).toEqual([64, 59, 55, 50, 45, 38]); + expect(lowest(1).realValue).toBe(38); + expect(tuning(8)).toEqual([43, 38, 33, 28, 23, 18]); + expect(lowest(8).realValue).toBe(18); + for (const track of [2, 3, 4, 5, 6, 7, 9]) { + expect(tuning(track)).toEqual([64, 59, 55, 50, 45, 40]); + } + }); + + it('instruments-dulcimer', async () => { + // notes.tef with the module renamed "Dulcimer", re-saved in TablEdit: TablEdit shows the stored frets + // 0, 3, 12, 24 as the dulcimer frets 0, 1+, 7, 14, so they are semitones + const score = await load('notes-dulcimer'); + expect(score.tracks[0].name).toBe('Dulcimer'); + expect(beats(score, 0).map(b => [single(b).string, single(b).fret, single(b).realValue])).toEqual([ + [1, 0, 40], + [2, 3, 48], + [3, 12, 62], + [6, 24, 88] + ]); + }); + + it('drums', async () => { + const score = await load('drums'); + const staff = score.tracks[0].staves[0]; + expect(staff.isPercussion).toBe(true); + expect(staff.bars[0].clef).toBe(Clef.Neutral); + const bar = beats(score, 0); + expect(bar.map(b => [b.isRest, b.duration, b.playbackStart])).toEqual([ + [false, Duration.Quarter, 0], + [false, Duration.Quarter, quarter], + [false, Duration.Quarter, 2 * quarter], + // the drum event marker plays a key of its own, no note + [true, Duration.Quarter, 3 * quarter] + ]); + // GM key = 96 - tuning byte of the line + fret: lines 0, 2 and 4 are 35, 42, 39 + const key = (b: Beat) => + score.tracks[0].percussionArticulations[single(b).percussionArticulation].outputMidiNumber; + expect(bar.slice(0, 3).map(key)).toEqual([61, 54, 60]); + }); + }); + + it('drums-grace', async () => { + // drums.tef (8 lines) with a grace on fret 2 before its first note: a flam on the same drum line + const data = await loadData('drums'); + let p = u32(data, cellsPointer); + while ((data[p + 4] & 0x3f) === 0 || (data[p + 4] & 0x3f) >= 0x33) { + p += cellSize; + } + data[p + 4] |= 0x40; + data[p + 7] = 0x02; + const line = Math.floor(u32(data, p) / 8) % 8; + // GM key = 96 - tuning byte of the line + fret + const pitch = 96 - data[u32(data, instrumentsPointer) + 4 + 20 + line]; + const score = readBytes(data); + const grace = beats(score, 0)[0]; + expect(grace.graceType).toBe(GraceType.OnBeat); + const note = single(grace); + expect(score.tracks[0].percussionArticulations[note.percussionArticulation].outputMidiNumber).toBe(pitch + 2); + }); + + describe('bars', () => { + it('bars-time-key', async () => { + const score = await load('bars'); + expect(score.masterBars.map(m => [m.timeSignatureNumerator, m.timeSignatureDenominator])).toEqual([ + [4, 4], + [3, 4], + [6, 8], + [2, 2], + [5, 4], + [3, 8], + [17, 4] + ]); + const bars = score.tracks[0].staves[0].bars; + expect(bars.map(b => b.keySignature)).toEqual([0, 3, -2, -1, 7, -7, 0]); + expect(bars.map(b => b.keySignatureType)).toEqual([ + KeySignatureType.Major, + KeySignatureType.Major, + KeySignatureType.Minor, + KeySignatureType.Major, + KeySignatureType.Major, + KeySignatureType.Minor, + KeySignatureType.Major + ]); + expect(score.masterBars[2].tripletFeel).toBe(TripletFeel.NoTripletFeel); + for (const bar of bars) { + const first = bar.voices[0].beats[0]; + expect(first.duration).toBe(Duration.Quarter); + expect(single(first).string).toBe(6); + expect(single(first).fret).toBe(0); + expect(bar.voices[0].beats.slice(1).every(b => b.isRest)).toBe(true); + const m = bar.masterBar; + expect(bar.calculateDuration()).toBe( + (m.timeSignatureNumerator * 4 * quarter) / m.timeSignatureDenominator + ); + } + expect(bars[6].calculateDuration()).toBe(17 * quarter); + }); + + it('bars-pickup', async () => { + const score = await load('bars-flags'); + expect(score.masterBars[0].isAnacrusis).toBe(true); + const pickup = beats(score, 0); + expect(pickup.length).toBe(1); + expect(pickup[0].duration).toBe(Duration.Quarter); + expect(single(pickup[0]).string).toBe(6); + expect(single(pickup[0]).fret).toBe(0); + expect(beats(score, 1)[0].absolutePlaybackStart).toBe(quarter); + expect(score.masterBars.slice(1).some(m => m.isAnacrusis)).toBe(false); + }); + + it('bars-pickup-trailing-rests', async () => { + // healthy.tef with bar 1 a pick-up and its notes at 1.32 and 1.48 turned into rests: the pick-up + // lasts from 1.0 to the end of the quarter at 1.16 + const data = await loadHealthy(); + data[u32(data, measuresPointer) + 8] |= 0x08; + data[cellData(data, 32, 3)] = 0x33; + data[cellData(data, 48, 0)] = 0x33; + const score = readBytes(data); + expect(timeline(score, 0)).toEqual([ + [0, Duration.Quarter, 0, 0], + [3, Duration.Quarter, 0, quarter] + ]); + expect(beats(score, 1)[0].absolutePlaybackStart).toBe(2 * quarter); + }); + + it('bars-flags', async () => { + const score = await load('bars-flags'); + // hidden time signature: still read + expect(score.masterBars[3].timeSignatureNumerator).toBe(3); + expect(score.masterBars[3].timeSignatureDenominator).toBe(4); + expect(beats(score, 3).length).toBe(3); + + // free bar: eighths beamed over the whole bar + expect(score.masterBars[4].beamingRules!.groups.get(Duration.Eighth)).toEqual([8]); + for (const index of [1, 2, 3, 5, 6, 7]) { + expect(score.masterBars[index].beamingRules, `bar ${index}`).toBeUndefined(); + } + + const bars = score.tracks[0].staves[0].bars; + for (let i = 1; i < 8; i++) { + expect(score.masterBars[i].isFreeTime).toBe(false); + expect(bars[i].barLineLeft).toBe(BarLineStyle.Automatic); + expect(bars[i].barLineRight).toBe(BarLineStyle.Automatic); + const bar = beats(score, i); + expect(bar.length).toBe(i === 3 ? 3 : 4); + expect(bar.every(b => b.duration === Duration.Quarter && single(b).fret === 5)).toBe(true); + } + }); + }); + + describe('markers', () => { + it('markers-text', async () => { + const score = await load('markers-text-chords'); + const text = (bar: number) => beats(score, bar).map(b => b.text); + expect(text(0)).toEqual([null, 'Intro', 'Bold', null]); + // % and 0x texts are commands + expect(text(1).every(t => t === null)).toBe(true); + // a text starting with > is lyrics from its beat on + // two texts on one beat (rows 0 and 2) + expect(text(2)).toEqual([null, 'One Two', null, null]); + expect(beats(score, 2).map(b => b.lyrics)).toEqual([null, null, ['la'], ['la']]); + // a marker between two beats belongs to the beat before it + expect(text(3)).toEqual(['Mid', null, null, null]); + }); + + it('markers-chords', async () => { + const score = await load('markers-text-chords'); + const staff = score.tracks[0].staves[0]; + expect(staff.chords!.size).toBe(3); + + const am = beats(score, 0)[0]; + expect(am.chord!.name).toBe('Am'); + expect(am.chord!.strings).toEqual([0, 1, 2, 2, 0, -1]); + expect(am.chord!.firstFret).toBe(1); + expect(frets(am)).toEqual( + new Map([ + [5, 1], + [4, 2], + [3, 2], + [2, 0] + ]) + ); + + const c = beats(score, 1)[0]; + expect(c.chord!.name).toBe('C'); + expect(c.chord!.strings).toEqual([0, 1, 0, 2, 3, -1]); + expect(c.chord!.firstFret).toBe(1); + + const xpos = beats(score, 2)[0]; + expect(xpos.chord!.name).toBe('Xpos'); + expect(xpos.chord!.strings).toEqual([5, 6, 6, 6, 5, 0]); + expect(xpos.chord!.firstFret).toBe(5); + + for (let bar = 0; bar < 4; bar++) { + expect( + beats(score, bar) + .slice(1) + .every(b => b.chordId === null) + ).toBe(true); + } + expect(beats(score, 3)[0].chordId).toBe(null); + }); + + it('markers-line-breaks', async () => { + const score = await load('markers-layout'); + // line breaks after bars 2, 4 (with page break) and 6 (with double bar); the one in the last bar has no effect + expect(Array.from(score.tracks[0].lineBreaks!).sort()).toEqual([2, 4, 6]); + expect(score.masterBars.map(m => m.isDoubleBar)).toEqual([ + false, + false, + false, + false, + false, + true, + false, + false, + false + ]); + }); + + it('markers-measures-per-line', async () => { + const score = await load('markers-layout-mpl'); + // as markers-layout plus a line break with 2 measures per line in bar 7; bars 8 and 9 then form the + // last line, so the break of bar 7 is the only one the setting adds here + expect(Array.from(score.tracks[0].lineBreaks!).sort()).toEqual([2, 4, 6, 7]); + expect(score.masterBars.map(m => m.isDoubleBar)).toEqual([ + false, + false, + false, + false, + false, + true, + false, + false, + false + ]); + for (let bar = 0; bar < 9; bar++) { + const b = beats(score, bar); + expect(b.length).toBe(4); + const markerBar = bar === 1 || bar === 3 || bar === 5 || bar === 6 || bar === 8; + expect(single(b[0]).string).toBe(markerBar ? 5 : 6); + expect(b.every(x => single(x).fret === 5)).toBe(true); + } + }); + + it('markers-dynamics', async () => { + const score = await load('markers-playback'); + const bar1 = beats(score, 0); + const bar2 = beats(score, 1); + for (const b of [...bar1, ...bar2]) { + expect(b.duration).toBe(Duration.Eighth); + expect(single(b).fret).toBe(5); + } + // accent at 1.8 + expect(bar1.map(b => single(b).accentuated)).toEqual([ + AccentuationType.None, + AccentuationType.Normal, + AccentuationType.None, + AccentuationType.None, + AccentuationType.None, + AccentuationType.None, + AccentuationType.None, + AccentuationType.None + ]); + expect(bar2.every(b => single(b).accentuated === AccentuationType.None)).toBe(true); + // crescendo 1.16 for 16/64, decrescendo 2.0 for 32/64 + expect(bar1.map(b => b.crescendo)).toEqual([ + CrescendoType.None, + CrescendoType.None, + CrescendoType.Crescendo, + CrescendoType.Crescendo, + CrescendoType.None, + CrescendoType.None, + CrescendoType.None, + CrescendoType.None + ]); + expect(bar2.map(b => b.crescendo)).toEqual([ + CrescendoType.Decrescendo, + CrescendoType.Decrescendo, + CrescendoType.Decrescendo, + CrescendoType.Decrescendo, + CrescendoType.None, + CrescendoType.None, + CrescendoType.None, + CrescendoType.None + ]); + }); + + it('markers-upper-voice', async () => { + // the notes of bar 2 forced into the upper voice: its markers and lyrics stay on its beats, and the + // other bars keep theirs on the automatic voice, which comes before the upper one; alphaTab puts the + // initial instrument on the first beat of the first voice + const markersOf = (b: Beat) => + `${b.text}|${b.chord ? b.chord.name : ''}|${b.lyrics ? b.lyrics.join('/') : ''}|${b.crescendo}|` + + `${b.fermata !== null}|${b.pickStroke}|` + + `${b.voice.bar.index > 0 ? b.automations.map(a => a.value).join(',') : ''}|` + + b.notes.map(n => `${n.accentuated}:${n.isSlurOrigin}:${n.isSlurDestination}`).join(','); + for (const name of ['markers-text-chords', 'markers-playback', 'lyrics', 'fermata']) { + const original = readBytes(await loadData(name)); + const upper = readBytes(withUpperVoice(await loadData(name), 1)); + for (let bar = 0; bar < original.masterBars.length; bar++) { + expect(beats(upper, bar, bar === 1 ? 1 : 0).map(markersOf), `${name} bar ${bar}`).toEqual( + beats(original, bar).map(markersOf) + ); + } + } + }); + + it('markers-across-barlines', async () => { + // healthy.tef: a slur at 1.48 over 32 64ths and a crescendo at 1.32 over 64 64ths + const score = readBytes( + withCells(await loadHealthy(), [ + [48, 1, [0x3d, 31]], + [32, 1, [0x38, 0, 64, 0, 0, 0, 16]] + ]) + ); + const bar1 = beats(score, 0); + const bar2 = beats(score, 1); + expect(single(bar1[3]).slurDestination!.beat).toBe(bar2[0]); + expect(bar1.map(b => b.crescendo)).toEqual([ + CrescendoType.None, + CrescendoType.None, + CrescendoType.Crescendo, + CrescendoType.Crescendo + ]); + expect(bar2.map(b => b.crescendo)).toEqual([ + CrescendoType.Crescendo, + CrescendoType.Crescendo, + CrescendoType.None, + CrescendoType.None + ]); + }); + + it('markers-slur-length', async () => { + // a slur at 1.0 with the length byte 0x9F: files older than 3.06 A4 keep the dashed flag in bit 7, + // so it covers 32 64ths, newer files 160 64ths + const cells: [number, number, number[]][] = [[0, 1, [0x3d, 0x9f]]]; + const older = withCells(await loadHealthy(), cells); + setU16(older, 4, 0xa3); + const olderScore = readBytes(older); + expect(single(beats(olderScore, 0)[0]).slurDestination!.beat).toBe(beats(olderScore, 0)[1]); + const current = readBytes(withCells(await loadHealthy(), cells)); + expect(single(beats(current, 0)[0]).slurDestination!.beat).toBe(beats(current, 2)[1]); + }); + + it('markers-tempo-at-start', async () => { + // healthy.tef with a tempo marker of 90 at 1.0: it replaces the header tempo + const score = readBytes(withCells(await loadHealthy(), [[0, 1, [0xfe, 90, 0, 0]]])); + expect(score.masterBars[0].tempoAutomations.map(a => [a.value, a.ratioPosition] as number[])).toEqual([ + [90, 0] + ]); + expect(score.tempo).toBe(90); + }); + + it('markers-tempo-ramp-from-pickup', async () => { + // healthy.tef with bar 1 a pick-up from 1.32 (its notes at 1.0 and 1.16 turned into rests) and a tempo + // marker at 1.48 going from 120 to 160 within four quarters + const data = await loadHealthy(); + data[u32(data, measuresPointer) + 8] |= 0x08; + data[cellData(data, 0, 5)] = 0x33; + data[cellData(data, 16, 4)] = 0x33; + const score = readBytes(withCells(data, [[48, 1, [0xfe, 160, 0, 4]]])); + const tempo = (bar: number) => + score.masterBars[bar].tempoAutomations.map(a => [a.value, a.ratioPosition] as number[]); + expect(tempo(0)).toEqual([ + [120, 0], + [130, 0.5] + ]); + expect(tempo(1)).toEqual([ + [140, 0], + [150, 0.25], + [160, 0.5] + ]); + }); + + it('markers-tempo', async () => { + const score = await load('markers-playback'); + const tempo = (bar: number) => + score.masterBars[bar].tempoAutomations.map(a => [a.value, a.ratioPosition, a.isVisible] as unknown[]); + // header tempo 120 + expect(score.tempo).toBe(120); + expect(tempo(0)).toEqual([[120, 0, true]]); + expect(tempo(1).length).toBe(0); + expect(tempo(2)).toEqual([ + [80, 0, true], + [120, 0.5, true] + ]); + // 120 to 60 within two quarters, 60 to 100 within four quarters: one hidden step per quarter + expect(tempo(3)).toEqual([ + [90, 0, true], + [60, 0.25, false] + ]); + expect(tempo(4)).toEqual([ + [70, 0, true], + [80, 0.25, false], + [90, 0.5, false], + [100, 0.75, false] + ]); + for (let bar = 5; bar < 14; bar++) { + expect(tempo(bar).length, `bar ${bar}`).toBe(0); + } + }); + + it('markers-syncopation', async () => { + const expected = (header: TripletFeel) => [ + header, + header, + header, + header, + header, + // +1, +3, -2 + TripletFeel.Triplet8th, + TripletFeel.Dotted8th, + TripletFeel.Scottish8th, + // +2 in 6/8: no swing in compound metres, it carries on in the 4/4 bars after it + TripletFeel.NoTripletFeel, + TripletFeel.Triplet8th, + TripletFeel.Triplet8th, + TripletFeel.Triplet8th, + TripletFeel.Triplet8th, + TripletFeel.Triplet8th + ]; + const score = await load('markers-playback'); + expect(score.masterBars[8].timeSignatureNumerator).toBe(6); + expect(score.masterBars[8].timeSignatureDenominator).toBe(8); + expect(beats(score, 8).map(b => b.duration)).toEqual([ + Duration.Quarter, + Duration.Quarter, + Duration.Quarter + ]); + expect(score.masterBars.map(m => m.tripletFeel)).toEqual(expected(TripletFeel.NoTripletFeel)); + // header syncopation +1 + const syncope = await load('markers-playback-syncope'); + expect(syncope.masterBars.map(m => m.tripletFeel)).toEqual(expected(TripletFeel.Triplet8th)); + }); + + it('markers-patch', async () => { + const score = await load('markers-playback'); + const automations = (bar: number) => + beats(score, bar).map(b => b.automations.map(a => [a.type as number, a.value] as number[])); + expect(automations(10)).toEqual([[[AutomationType.Instrument, 29]], [], [], []]); + expect(single(beats(score, 10)[0]).string).toBe(5); + expect(automations(0)[0]).toEqual([[AutomationType.Instrument, 25]]); + for (const bar of [1, 2, 3, 4, 5, 6, 7, 8, 9, 11, 12, 13]) { + expect( + automations(bar).every(a => a.length === 0), + `bar ${bar}` + ).toBe(true); + } + }); + + it('markers-symbols-connections', async () => { + const score = await load('markers-playback'); + // a musical symbol other than the fermata + expect(beats(score, 11).every(b => b.fermata === null)).toBe(true); + expect(single(beats(score, 11)[1]).string).toBe(5); + + // slur connection at 13.0 over 40/64: from the first note of 13.0 to the note at 13.32 + const bar13 = beats(score, 12); + const origin = single(bar13[0]); + const destination = single(bar13[2]); + expect(origin.string).toBe(5); + expect(destination.string).toBe(6); + expect(origin.isSlurOrigin).toBe(true); + expect(origin.slurDestination).toBe(destination); + expect(destination.isSlurDestination).toBe(true); + expect(destination.slurOrigin).toBe(origin); + expect(single(bar13[1]).isSlurOrigin || single(bar13[1]).isSlurDestination).toBe(false); + expect(single(bar13[3]).isSlurOrigin || single(bar13[3]).isSlurDestination).toBe(false); + + for (const bar of [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 13]) { + for (const b of beats(score, bar)) { + expect( + b.notes.some(n => n.isSlurOrigin || n.isSlurDestination), + `bar ${bar}` + ).toBe(false); + } + } + }); + + it('markers-fermata', async () => { + const score = await load('fermata'); + // symbols 0x6B and 0x6C of the Musical Symbols list at 1.16 and 1.48 + expect(beats(score, 0).map(b => (b.fermata ? b.fermata.type : null) as FermataType | null)).toEqual([ + null, + FermataType.Medium, + null, + FermataType.Medium + ]); + expect(beats(score, 1).every(b => b.fermata === null)).toBe(true); + }); + + it('markers-line-break-options', async () => { + // line break at 2.0 with truncate, time signature, indent 2 and spacing; one with justify, which TablEdit + // stores on the last 64th of its bar (3.63) + const score = await load('line-breaks'); + expect(Array.from(score.tracks[0].lineBreaks!).sort()).toEqual([2, 3]); + expect(score.masterBars.map(m => m.timeSignatureNumerator)).toEqual([4, 4, 4, 4]); + expect(score.masterBars.every(m => !m.isDoubleBar)).toBe(true); + for (let bar = 0; bar < 4; bar++) { + expect(timeline(score, bar)).toEqual([[5, Duration.Whole, 0, 0]]); + } + }); + + it('markers-patched', async () => { + // byte patches of TablEdit-made accents, re-saved in TablEdit + const score = await load('markers-patched'); + const bar1 = beats(score, 0); + const bar2 = beats(score, 1); + for (const b of [...bar1, ...bar2]) { + expect(single(b).fret).toBe(5); + expect(b.duration).toBe(Duration.Quarter); + expect(b.notes[0].accentuated).toBe(AccentuationType.None); + expect(b.text).toBe(null); + expect(b.beamingMode).toBe(BeatBeamingMode.Auto); + } + // 1.0 standalone down stroke + expect(bar1.map(b => b.pickStroke)).toEqual([ + PickStroke.Down, + PickStroke.None, + PickStroke.None, + PickStroke.None + ]); + // 1.16 scale diagram, 1.32 beam break, 1.48 stem length, 2.0 special character: not read + expect(bar1.slice(1).every(b => b.fermata === null && b.chordId === null)).toBe(true); + // 2.16 fermata held for 32 64ths: the hold is not read (D7) + expect(bar2.map(b => (b.fermata ? b.fermata.type : null) as FermataType | null)).toEqual([ + null, + FermataType.Medium, + null, + null + ]); + + // 3.0 grace with a choke on its main note; the choke amount 3 comes from the extension record + const bar3 = beats(score, 2); + expect(bar3[0].graceType).toBe(GraceType.OnBeat); + expect(single(bar3[0]).fret).toBe(3); + expect(bendPoints(single(bar3[1]))).toEqual([ + [0, 0], + [60, 3] + ]); + // 3.32 spacing record: not read + expect(timeline(score, 2).slice(2)).toEqual([ + [5, Duration.Quarter, 0, 960], + [5, Duration.Quarter, 0, 1920], + [5, Duration.Quarter, 0, 2880] + ]); + }); + + it('markers-layout', async () => { + const score = await load('markers-layout'); + for (let bar = 0; bar < 9; bar++) { + const b = beats(score, bar); + expect(b.length).toBe(4); + expect(b.every(x => x.duration === Duration.Quarter && single(x).fret === 5)).toBe(true); + const markerBar = bar === 1 || bar === 3 || bar === 5 || bar === 8; + expect(single(b[0]).string).toBe(markerBar ? 5 : 6); + expect(b.every(x => x.text === null && x.chordId === null)).toBe(true); + expect(b.map(x => x.automations.map(a => [a.type, a.value]))).toEqual( + bar === 0 ? [[[AutomationType.Instrument, 25]], [], [], []] : [[], [], [], []] + ); + } + }); + }); + + it('lyrics', async () => { + const score = await load('lyrics'); + const lyrics = (bar: number) => beats(score, bar).map(b => b.lyrics); + expect(lyrics(0)).toEqual([['Hel-', 'Verse'], ['lo', 'two'], ['my-'], ['world']]); + // rest and tie destination get no syllable + expect(lyrics(1)).toEqual([['New York'], ['a'], null, null]); + expect(beats(score, 1)[2].isRest).toBe(true); + expect(single(beats(score, 1)[3]).isTieDestination).toBe(true); + // grace beat first, then the main note + expect(beats(score, 2)[0].graceType).toBe(GraceType.OnBeat); + expect(lyrics(2)).toEqual([null, ['gain'], ['sec-'], ['ond'], ['']]); + expect(lyrics(3)).toEqual([['line'], null, null, null]); + + const chord = beats(score, 2)[2].chord!; + expect(chord.name).toBe('C'); + expect(chord.showDiagram).toBe(false); + }); + + it('lyrics-greater-than', async () => { + // lyrics.tef with 'world' changed to 'wor>d': only a line start begins a section + const data = await loadData('lyrics'); + const lyrics = u32(data, 80); + const length = u16(data, lyrics); + let world = -1; + for (let i = lyrics + 2; i + 5 <= lyrics + 2 + length; i++) { + if (IOHelper.toString(data.subarray(i, i + 5), 'utf-8') === 'world') { + world = i; + } + } + expect(world).toBeGreaterThan(0); + data[world + 3] = 0x3e; + const score = readBytes(data); + expect(beats(score, 0).map(b => b.lyrics)).toEqual([['Hel-', 'Verse'], ['lo', 'two'], ['my-'], ['wor>d']]); + expect(beats(score, 3)[0].lyrics).toEqual(['line']); + }); + + it('lyrics-sections', async () => { + // lyrics.tef with two sections for the module ("One two", then "Five six seven"), re-saved in TablEdit, + // which shows only the first + const score = await load('lyrics-sections2'); + expect(beats(score, 0).map(b => b.lyrics)).toEqual([['One'], ['two'], null, null]); + for (let bar = 1; bar < 4; bar++) { + expect(beats(score, bar).every(b => b.lyrics === null)).toBe(true); + } + }); + + describe('reading-list', () => { + it('reading-list-voltas', async () => { + const score = await load('reading-list-voltas'); + expect([0, 1, 2, 3, 4, 5, 6, 7].map(b => single(beats(score, b)[0]).fret)).toEqual([ + 1, 2, 3, 4, 5, 6, 7, 8 + ]); + expect(TefReadingList.playbackOrder(score, 100)).toEqual([0, 1, 2, 3, 4, 5, 1, 2, 3, 6, 7]); + expect(score.masterBars.map(m => m.isRepeatStart)).toEqual([ + false, + true, + false, + false, + false, + false, + false, + false + ]); + expect(score.masterBars.map(m => m.repeatCount)).toEqual([0, 0, 0, 0, 0, 2, 0, 0]); + expect(score.masterBars.map(m => m.alternateEndings)).toEqual([0, 0, 0, 0, 1, 1, 2, 2]); + expect(score.masterBars.every(m => !m.directions || m.directions.size === 0)).toBe(true); + }); + + /** + * A reading list block with the given 1-based entries. + */ + function readingListBlock(entries: [number, number][]): Uint8Array { + const recordSize = 32; + const block = new Uint8Array(4 + entries.length * recordSize); + setU16(block, 0, recordSize); + setU16(block, 2, entries.length); + for (let i = 0; i < entries.length; i++) { + const p = 4 + i * recordSize; + setU16(block, p, entries[i][0]); + setU16(block, p + 2, entries[i][1]); + } + return block; + } + + async function withReadingList(entries: [number, number][], file: string): Promise { + return readBytes(withBlock(await loadData(file), readingListPointer, readingListBlock(entries))); + } + + it('reading-list-labels', async () => { + const score = await load('reading-list'); + expect( + [0, 1, 2, 3, 4, 5, 6, 7].map(b => { + const beat = beats(score, b); + return [beat.length, beat[0].duration, single(beat[0]).fret]; + }) + ).toEqual([1, 2, 3, 4, 5, 6, 7, 8].map(f => [1, Duration.Whole, f])); + const items = [ + [1, 2], + [3, 4], + [5, 6], + [7, 7], + [8, 8] + ].map(range => { + const item = new TefReadingListItem(); + item.first = range[0] - 1; + item.last = range[1] - 1; + return item; + }); + expect(TefReadingList.verify(score, items)).toBe(true); + // a label starting with a space is hidden + expect(score.masterBars.map(m => m.section?.text ?? null)).toEqual([ + 'Intro', + null, + 'Verse', + null, + null, + null, + 'Outro', + null + ]); + expect(TefReadingList.playbackOrder(score, 100)).toEqual([0, 1, 2, 3, 4, 5, 6, 7]); + expect(score.masterBars.some(m => m.isRepeatStart || m.repeatCount > 0 || m.alternateEndings > 0)).toBe( + false + ); + }); + + it('reading-list-label-before-first-bar', async () => { + // the first entry of reading-list.tef, labelled 'Intro', moved to the non-existing bar 0: the list + // stops before it and the label is not shown + const data = await loadData('reading-list'); + setU16(data, u32(data, readingListPointer) + 4, 0); + const score = readBytes(data); + expect(score.masterBars.map(m => m.section?.text ?? null)).toEqual([ + null, + null, + 'Verse', + null, + null, + null, + 'Outro', + null + ]); + expect(TefReadingList.playbackOrder(score, 100)).toEqual([0, 1, 2, 3, 4, 5, 6, 7]); + }); + + it('reading-guides-no-list', async () => { + const data = await loadData('reading-guides'); + setU16(data, u32(data, readingListPointer) + 2, 0); + const score = readBytes(data); + expect(score.masterBars.map(m => m.isRepeatStart)).toEqual([ + true, + false, + false, + false, + false, + false, + false, + false, + false + ]); + expect(score.masterBars.map(m => m.repeatCount)).toEqual([0, 0, 2, 0, 0, 0, 0, 0, 0]); + expect(score.masterBars.map(m => m.alternateEndings)).toEqual([0, 0, 0b01, 0b10, 0, 0, 0, 0, 0]); + expect(score.masterBars.map(m => directionsOf(m))).toEqual([ + [], + [], + [], + [], + [Direction.TargetSegno], + [Direction.TargetFine], + [Direction.JumpDaCoda], + [Direction.JumpDalSegno], + [Direction.TargetCoda] + ]); + }); + + it('reading-guides-linear-list', async () => { + // with a reading list TablEdit plays the list, the guides are display only (D3) + const score = await load('reading-guides'); + expect( + [0, 1, 2, 3, 4, 5, 6, 7, 8].map(b => { + const beat = beats(score, b); + return [beat.length, beat[0].duration, single(beat[0]).string, single(beat[0]).fret]; + }) + ).toEqual([1, 2, 3, 4, 5, 6, 7, 8, 9].map(f => [1, Duration.Whole, 5, f])); + expect(TefReadingList.playbackOrder(score, 100)).toEqual([0, 1, 2, 3, 4, 5, 6, 7, 8]); + expect( + score.masterBars.some( + m => m.isRepeatStart || m.repeatCount > 0 || m.alternateEndings > 0 || (m.directions?.size ?? 0) > 0 + ) + ).toBe(false); + }); + + it('reading-list-second-segno', async () => { + // 1-4, 3-4, 5-6, 3-4, 7-10, 8-9, 12-14 over 14 bars + const score = await load('reading-list-segno'); + const order = [0, 1, 2, 3, 2, 3, 4, 5, 2, 3, 6, 7, 8, 9, 7, 8, 11, 12, 13]; + expect(TefReadingList.playbackOrder(score, 100)).toEqual(order); + const directions = new Map([ + [2, [Direction.TargetSegno]], + [3, [Direction.JumpDaCoda]], + [5, [Direction.JumpDalSegnoAlCoda]], + [6, [Direction.TargetCoda]], + [7, [Direction.TargetSegnoSegno]], + [8, [Direction.JumpDaDoubleCoda]], + [9, [Direction.JumpDalSegnoSegnoAlDoubleCoda]], + [11, [Direction.TargetDoubleCoda]] + ]); + for (const m of score.masterBars) { + expect(directionsOf(m), `bar ${m.index}`).toEqual(directions.get(m.index) ?? []); + expect(m.isRepeatStart, `bar ${m.index}`).toBe(m.index === 2); + expect(m.repeatCount, `bar ${m.index}`).toBe(m.index === 3 ? 2 : 0); + } + }); + + const range = (from: number, to: number) => { + const result: number[] = []; + for (let i = from; i <= to; i++) { + result.push(i); + } + return result; + }; + + const structures: Record = { + 'S1 repeat': { + list: [ + [1, 2], + [1, 2], + [3, 8] + ], + tablEdit: [0, 1, 0, 1, ...range(2, 7)], + repeatStart: [0], + repeatCount: [[1, 2]] + }, + 'S2 repeat three times at the end': { + list: [ + [1, 6], + [7, 8], + [7, 8], + [7, 8] + ], + tablEdit: [...range(0, 7), 6, 7, 6, 7], + repeatStart: [6], + repeatCount: [[7, 3]] + }, + 'S3 three passes before contiguous bars': { + list: [ + [1, 2], + [1, 2], + [1, 2], + [3, 8] + ], + tablEdit: [0, 1, 0, 1, 0, 1, ...range(2, 7)], + repeatStart: [0], + repeatCount: [[1, 3]] + }, + 'S4 repeat not at bar 1': { + list: [ + [1, 3], + [2, 8] + ], + tablEdit: [0, 1, 2, 1, 2, ...range(3, 7)], + repeatStart: [1], + repeatCount: [[2, 2]] + }, + 'S5 two repeats': { + list: [ + [1, 3], + [2, 5], + [4, 8] + ], + tablEdit: [0, 1, 2, 1, 2, 3, 4, 3, 4, 5, 6, 7], + repeatStart: [1, 3], + repeatCount: [ + [2, 2], + [4, 2] + ] + }, + 'S6 alternate endings': { + list: [ + [1, 6], + [2, 4], + [7, 8] + ], + tablEdit: [0, 1, 2, 3, 4, 5, 1, 2, 3, 6, 7], + repeatStart: [1], + repeatCount: [[5, 2]], + endings: [ + [4, 1], + [5, 1], + [6, 2], + [7, 2] + ] + }, + 'S7 da capo al coda': { + list: [ + [1, 2], + [1, 2], + [3, 4], + [1, 2], + [5, 8] + ], + tablEdit: [0, 1, 0, 1, 2, 3, 0, 1, 4, 5, 6, 7], + repeatStart: [0], + repeatCount: [[1, 2]], + directions: [ + [1, [Direction.JumpDaCoda]], + [3, [Direction.JumpDaCapoAlCoda]], + [4, [Direction.TargetCoda]] + ] + }, + 'S8 da capo al fine': { + list: [ + [1, 2], + [1, 2], + [3, 8], + [1, 2] + ], + tablEdit: [0, 1, 0, 1, ...range(2, 7), 0, 1], + repeatStart: [0], + repeatCount: [[1, 2]], + directions: [ + [1, [Direction.TargetFine]], + [7, [Direction.JumpDaCapoAlFine]] + ] + }, + 'S9 dal segno al coda': { + list: [ + [1, 3], + [2, 3], + [4, 5], + [2, 3], + [6, 8] + ], + tablEdit: [0, 1, 2, 1, 2, 3, 4, 1, 2, 5, 6, 7], + repeatStart: [1], + repeatCount: [[2, 2]], + directions: [ + [1, [Direction.TargetSegno]], + [2, [Direction.JumpDaCoda]], + [4, [Direction.JumpDalSegnoAlCoda]], + [5, [Direction.TargetCoda]] + ] + }, + 'S10 dal segno al fine': { + list: [ + [1, 3], + [2, 3], + [4, 8], + [2, 4] + ], + tablEdit: [0, 1, 2, 1, 2, ...range(3, 7), 1, 2, 3], + repeatStart: [1], + repeatCount: [[2, 2]], + directions: [ + [1, [Direction.TargetSegno]], + [3, [Direction.TargetFine]], + [7, [Direction.JumpDalSegnoAlFine]] + ] + }, + 'S11 last bar beyond the piece': { + list: [ + [1, 2], + [1, 2], + [3, 99] + ], + tablEdit: [0, 1, 0, 1, ...range(2, 7)], + repeatStart: [0], + repeatCount: [[1, 2]] + }, + 'S12 skipped bar': { + list: [ + [1, 2], + [4, 8] + ], + tablEdit: [0, 1, ...range(3, 7)], + alphaTab: range(0, 7) + }, + 'S13 late start': { + list: [[2, 8]], + tablEdit: range(1, 7), + alphaTab: range(0, 7) + }, + 'S14 early stop': { + list: [[1, 4]], + tablEdit: range(0, 3), + alphaTab: range(0, 7) + }, + 'S15 invalid entry ends the list': { + list: [ + [1, 2], + [1, 2], + [0, 0], + [3, 8] + ], + tablEdit: [0, 1, 0, 1], + alphaTab: [0, 1, 0, 1, ...range(2, 7)], + repeatStart: [0], + repeatCount: [[1, 2]] + }, + 'S16 linear': { + list: [[1, 8]], + tablEdit: range(0, 7) + }, + // playback is normal again after the coda, its repeat plays both passes + 'S17 repeat with endings after the coda': { + list: [ + [1, 2], + [1, 4], + [1, 2], + [5, 7], + [5, 6], + [8, 8] + ], + tablEdit: [0, 1, 0, 1, 2, 3, 0, 1, 4, 5, 6, 4, 5, 7], + repeatStart: [0, 4], + repeatCount: [ + [1, 2], + [6, 2] + ], + endings: [ + [6, 1], + [7, 2] + ], + directions: [ + [1, [Direction.JumpDaCoda]], + [3, [Direction.JumpDaCapoAlCoda]], + [4, [Direction.TargetCoda]] + ] + }, + // after the D.S. the repeat plays its final pass, which skips the first ending by itself + 'S18 first ending skipped after a jump': { + list: [ + [1, 4], + [2, 3], + [5, 6], + [2, 3], + [5, 5], + [8, 8] + ], + tablEdit: [0, 1, 2, 3, 1, 2, 4, 5, 1, 2, 4, 7], + repeatStart: [1], + repeatCount: [[3, 2]], + endings: [ + [3, 1], + [4, 2] + ], + directions: [ + [1, [Direction.TargetSegno]], + [4, [Direction.JumpDaCoda]], + [5, [Direction.JumpDalSegnoAlCoda]], + [7, [Direction.TargetCoda]] + ] + }, + // two D.S. to one segno; the second stops before the To Coda of the first, so it needs the double coda + 'S19 segno shared, codas apart': { + list: [ + [1, 3], + [2, 4], + [2, 3], + [5, 6], + [2, 2], + [5, 8] + ], + tablEdit: [0, 1, 2, 1, 2, 3, 1, 2, 4, 5, 1, 4, 5, 6, 7], + repeatStart: [1], + repeatCount: [[2, 2]], + directions: [ + [1, [Direction.TargetSegno, Direction.JumpDaDoubleCoda]], + [2, [Direction.JumpDaCoda]], + [3, [Direction.JumpDalSegnoAlCoda]], + [4, [Direction.TargetCoda, Direction.TargetDoubleCoda]], + [5, [Direction.JumpDalSegnoAlDoubleCoda]] + ] + }, + // the reading list of Calliope Rag (discussion #2963); TablEdit's MIDI export plays it as listed + 'S20 Calliope Rag': { + file: 'reading-list-long', + list: [ + [1, 20], + [5, 19], + [21, 37], + [22, 37], + [5, 19], + [21, 21], + [38, 45], + [38, 43], + [46, 47], + [5, 19], + [48, 49] + ], + tablEdit: [ + ...range(0, 19), + ...range(4, 18), + ...range(20, 36), + ...range(21, 36), + ...range(4, 18), + 20, + ...range(37, 44), + ...range(37, 42), + 45, + 46, + ...range(4, 18), + 47, + 48 + ], + repeatStart: [4, 21, 37], + repeatCount: [ + [19, 2], + [36, 2], + [44, 2] + ], + endings: [ + [19, 1], + [20, 2], + [43, 1], + [44, 1], + [45, 2], + [46, 2] + ], + directions: [ + [4, [Direction.TargetSegno]], + [18, [Direction.JumpDaDoubleCoda]], + [20, [Direction.JumpDaCoda]], + [36, [Direction.JumpDalSegnoAlCoda]], + [37, [Direction.TargetCoda]], + [46, [Direction.JumpDalSegnoAlDoubleCoda]], + [47, [Direction.TargetDoubleCoda]] + ] + } + }; + + it('reading-list-structures', async () => { + for (const [name, s] of Object.entries(structures)) { + const score = await withReadingList(s.list, s.file ?? 'reading-list-voltas'); + const items = s.list.map(entry => { + const item = new TefReadingListItem(); + item.first = entry[0] - 1; + item.last = entry[1] - 1; + return item; + }); + expect(TefReadingList.playbackOrder(score, 1000), name).toEqual(s.alphaTab ?? s.tablEdit); + expect(TefReadingList.verify(score, items), name).toBe(s.alphaTab === undefined); + + const repeatCount = s.repeatCount ? new Map(s.repeatCount) : new Map(); + const endings = s.endings ? new Map(s.endings) : new Map(); + const directions = s.directions ? new Map(s.directions) : new Map(); + for (const m of score.masterBars) { + const label = `${name} bar ${m.index}`; + expect(m.isRepeatStart, label).toBe((s.repeatStart ?? []).includes(m.index)); + expect(m.repeatCount, label).toBe(repeatCount.get(m.index) ?? 0); + expect(m.alternateEndings, label).toBe(endings.get(m.index) ?? 0); + expect(directionsOf(m).sort(), label).toEqual((directions.get(m.index) ?? []).slice().sort()); + } + } + }); + }); +}); diff --git a/packages/alphatab/test/model/JsonConverter.test.ts b/packages/alphatab/test/model/JsonConverter.test.ts index ecf7d2fdb2..f6d1f29857 100644 --- a/packages/alphatab/test/model/JsonConverter.test.ts +++ b/packages/alphatab/test/model/JsonConverter.test.ts @@ -1,10 +1,10 @@ -import { MapObjectReader } from '@coderline/alphatab/io/MapObjectReader'; -import { describe, expect, it } from 'vitest'; import { NoteSerializer } from '@coderline/alphatab/generated/model/NoteSerializer'; +import type { SettingsJson } from '@coderline/alphatab/generated/SettingsJson'; import { SettingsSerializer } from '@coderline/alphatab/generated/SettingsSerializer'; import { AlphaTexImporter } from '@coderline/alphatab/importer/AlphaTexImporter'; import { ScoreLoader } from '@coderline/alphatab/importer/ScoreLoader'; import { ByteBuffer } from '@coderline/alphatab/io/ByteBuffer'; +import { MapObjectReader } from '@coderline/alphatab/io/MapObjectReader'; import { LayoutMode } from '@coderline/alphatab/LayoutMode'; import { LogLevel } from '@coderline/alphatab/LogLevel'; import { BarStyle, BarSubElement } from '@coderline/alphatab/model/Bar'; @@ -18,8 +18,8 @@ import { TrackStyle, TrackSubElement } from '@coderline/alphatab/model/Track'; import { VoiceStyle, VoiceSubElement } from '@coderline/alphatab/model/Voice'; import { FingeringMode, NotationElement, NotationMode, TabRhythmMode } from '@coderline/alphatab/NotationSettings'; import { Settings } from '@coderline/alphatab/Settings'; -import type { SettingsJson } from '@coderline/alphatab/generated/SettingsJson'; import { TestPlatform } from 'test/TestPlatform'; +import { describe, expect, it } from 'vitest'; import { ComparisonHelpers } from './ComparisonHelpers'; describe('JsonConverterTest', () => { @@ -93,6 +93,14 @@ describe('JsonConverterTest', () => { await testRoundTripFolderEqual('visual-tests/music-notation'); }); + it('visual-tabledit', async () => { + await testRoundTripFolderEqual('visual-tests/tabledit'); + }); + + it('importer-tabledit', async () => { + await testRoundTripFolderEqual('tabledit'); + }); + it('visual-notation-legend', async () => { await testRoundTripFolderEqual('visual-tests/notation-legend'); }); diff --git a/packages/alphatab/test/model/JsonConverterJsObject.test.ts b/packages/alphatab/test/model/JsonConverterJsObject.test.ts index b924beca17..096d1db5f1 100644 --- a/packages/alphatab/test/model/JsonConverterJsObject.test.ts +++ b/packages/alphatab/test/model/JsonConverterJsObject.test.ts @@ -20,7 +20,7 @@ import { ComparisonHelpers } from './ComparisonHelpers'; * hand-offs must decode to the same model and the public JSON strings must stay byte-identical. */ describe('JsonConverterJsObjectTest', () => { - const scoreExtensions = /\.(gp|gp[3-5]|gpx|xml|musicxml|mxml|mxl|cap|capx|json)$/i; + const scoreExtensions = /\.(gp|gp[3-5]|gpx|xml|musicxml|mxml|mxl|cap|capx|tef|json)$/i; function mapReplacer(_: string, v: unknown) { return v instanceof Map ? Object.fromEntries(v) : v; diff --git a/packages/alphatab/test/visualTests/features/TablEdit.test.ts b/packages/alphatab/test/visualTests/features/TablEdit.test.ts new file mode 100644 index 0000000000..62f9235731 --- /dev/null +++ b/packages/alphatab/test/visualTests/features/TablEdit.test.ts @@ -0,0 +1,24 @@ +import { VisualTestHelper } from 'test/visualTests/VisualTestHelper'; +import { describe, it } from 'vitest'; + +describe('TablEditTests', () => { + it('effects', async () => { + await VisualTestHelper.runVisualTest('tabledit/effects-b5.tef'); + }); + + it('tuplets', async () => { + await VisualTestHelper.runVisualTest('tabledit/tuplets.tef'); + }); + + it('reading-list', async () => { + await VisualTestHelper.runVisualTest('tabledit/reading-list-voltas.tef'); + }); + + it('chords', async () => { + await VisualTestHelper.runVisualTest('tabledit/markers-text-chords.tef'); + }); + + it('voices-ties', async () => { + await VisualTestHelper.runVisualTest('tabledit/voices-ties.tef'); + }); +}); diff --git a/packages/csharp/src/AlphaTab.Test/Test/Globals.cs b/packages/csharp/src/AlphaTab.Test/Test/Globals.cs index c20cbca491..6eef1a4f5f 100644 --- a/packages/csharp/src/AlphaTab.Test/Test/Globals.cs +++ b/packages/csharp/src/AlphaTab.Test/Test/Globals.cs @@ -34,6 +34,11 @@ public static Expector Expect(Action actual) return new Expector(actual); } + public static Expector Expect(Action actual, string message) + { + return new Expector(actual, message); + } + public static void Fail(object? message) { Assert.Fail(Convert.ToString(message) ?? string.Empty); @@ -422,6 +427,10 @@ public void ToEqual(object? expected, string? message = null) { new Expector(a.Current, elementMessage).ToEqual(e.Current); } + else if (IsNumber(e.Current) && IsNumber(a.Current)) + { + Assert.AreEqual(ToNumber(e.Current), ToNumber(a.Current), elementMessage); + } else { Assert.AreEqual(e.Current, a.Current, elementMessage); @@ -436,6 +445,12 @@ public void ToEqual(object? expected, string? message = null) return; } + if (IsNumber(expected) && IsNumber(_actual)) + { + Assert.AreEqual(ToNumber(expected), ToNumber(_actual), message ?? _message); + return; + } + // Structural comparison: walk expected's properties (e.g. an anonymous object from a // TS object-literal `expect(x).toEqual({...})`) and match against the actual instance's // properties case-insensitively (TS source uses camelCase, C# properties are PascalCase). @@ -457,6 +472,19 @@ public void ToEqual(object? expected, string? message = null) } } + /// + /// Numbers and enum values, which are all plain numbers in JavaScript. + /// + private static bool IsNumber(object? value) + { + return value is double or int or long or float or Enum; + } + + private static double ToNumber(object value) + { + return Convert.ToDouble(value, System.Globalization.CultureInfo.InvariantCulture); + } + public void ToThrow(Type expected) { Throw(expected); diff --git a/packages/csharp/src/AlphaTab/Core/ArrayTuple.cs b/packages/csharp/src/AlphaTab/Core/ArrayTuple.cs index 1f63a27451..dff7a3b491 100644 --- a/packages/csharp/src/AlphaTab/Core/ArrayTuple.cs +++ b/packages/csharp/src/AlphaTab/Core/ArrayTuple.cs @@ -25,3 +25,32 @@ public override string ToString() return $"[{V0}, {V1}]"; } } + +/// +/// A mixed-type array tuple of three items (like [number, number, number[]] in JavaScript). +/// +public class ArrayTuple +{ + public T0 V0 { get; set; } + public T1 V1 { get; set; } + public T2 V2 { get; set; } + + public ArrayTuple(T0 v0, T1 v1, T2 v2) + { + V0 = v0; + V1 = v1; + V2 = v2; + } + + public void Deconstruct(out T0 v0, out T1 v1, out T2 v2) + { + v0 = V0; + v1 = V1; + v2 = V2; + } + + public override string ToString() + { + return $"[{V0}, {V1}, {V2}]"; + } +} diff --git a/packages/csharp/src/AlphaTab/Core/EcmaScript/TextDecoder.cs b/packages/csharp/src/AlphaTab/Core/EcmaScript/TextDecoder.cs index 9840424ec6..057bc0dd25 100644 --- a/packages/csharp/src/AlphaTab/Core/EcmaScript/TextDecoder.cs +++ b/packages/csharp/src/AlphaTab/Core/EcmaScript/TextDecoder.cs @@ -6,10 +6,30 @@ namespace AlphaTab.Core.EcmaScript; internal class TextDecoder { private static readonly ConcurrentDictionary EncodingCache = new(); - private readonly Encoding _encoding; + + /// + /// The characters of the bytes 0x80 to 0x9F in Windows-1252 as the WHATWG Encoding Standard defines them; + /// all other bytes are their own code point. + /// + private static readonly char[] Windows1252C1 = + { + '€', '\u0081', '‚', 'ƒ', '„', '…', '†', '‡', + 'ˆ', '‰', 'Š', '‹', 'Œ', '\u008D', 'Ž', '\u008F', + '\u0090', '‘', '’', '“', '”', '•', '–', '—', + '˜', '™', 'š', '›', 'œ', '\u009D', 'ž', 'Ÿ' + }; + + private readonly Encoding? _encoding; public TextDecoder(string encoding) { + // .NET Core only knows Windows-1252 with a registered code pages provider + if (IsWindows1252(encoding)) + { + _encoding = null; + return; + } + _encoding = EncodingCache.GetOrAdd(encoding, s => { try @@ -23,13 +43,43 @@ public TextDecoder(string encoding) }); } + private static bool IsWindows1252(string encoding) + { + switch (encoding.ToLowerInvariant()) + { + case "windows-1252": + case "cp1252": + case "x-cp1252": + return true; + default: + return false; + } + } + public string Decode(ArrayBuffer data) { - return _encoding.GetString(data.Raw, 0, (int)data.ByteLength); + return Decode(data.Raw, 0, (int)data.ByteLength); } public string Decode(Uint8Array data) { - return _encoding.GetString(data.Buffer.Raw, (int)data.ByteOffset, (int)data.Length); + return Decode(data.Buffer.Raw, (int)data.ByteOffset, (int)data.Length); + } + + private string Decode(byte[] data, int offset, int length) + { + if (_encoding != null) + { + return _encoding.GetString(data, offset, length); + } + + var chars = new char[length]; + for (var i = 0; i < length; i++) + { + var b = data[offset + i]; + chars[i] = b >= 0x80 && b < 0xA0 ? Windows1252C1[b - 0x80] : (char)b; + } + + return new string(chars); } } diff --git a/packages/kotlin/src/android/src/main/java/alphaTab/collections/BooleanList.kt b/packages/kotlin/src/android/src/main/java/alphaTab/collections/BooleanList.kt index 8e0870ee07..4d86c6860e 100644 --- a/packages/kotlin/src/android/src/main/java/alphaTab/collections/BooleanList.kt +++ b/packages/kotlin/src/android/src/main/java/alphaTab/collections/BooleanList.kt @@ -53,6 +53,10 @@ internal class BooleanList : IBooleanIterable { return this } + public fun slice(start: Double, end: Double): BooleanList { + return BooleanList(*_data.copyOfRange(start.toInt(), minOf(end.toInt(), _size))) + } + public fun spread(): BooleanArray { return if (_size == _data.size) _data else _data.copyOf(_size) } 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 de9097912c..3070650468 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 @@ -168,7 +168,7 @@ public class DoubleList : IDoubleIterable { } public fun spread(): DoubleArray { - return _items; + return if (_size == _items.size) _items else _items.copyOf(_size) } public override fun iterator(): DoubleIterator { @@ -223,6 +223,23 @@ public class DoubleList : IDoubleIterable { return accumulator } + public fun includes(value: Double): Boolean { + for (i in 0 until _size) { + if (_items[i] == value) { + return true + } + } + return false + } + + public fun flatMap(transform: (v: Double) -> Iterable): List { + val result = List() + for (i in 0 until _size) { + result.push(transform(_items[i])) + } + return result + } + public fun some(predicate: (Double) -> Boolean): Boolean { for (el in this) { if(predicate(el)) { 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 79be8f90cd..2adc1f5233 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 @@ -185,6 +185,10 @@ public class List : Iterable { return List(ArrayListWithRemoveRange(_data.subList(start.toInt(), _data.size))) } + public fun slice(start: Double, end: Double): List { + return List(ArrayListWithRemoveRange(_data.subList(start.toInt(), minOf(end.toInt(), _data.size)))) + } + public fun shift(): T { return _data.removeAt(0) } diff --git a/packages/kotlin/src/android/src/main/java/alphaTab/core/ArrayTuple.kt b/packages/kotlin/src/android/src/main/java/alphaTab/core/ArrayTuple.kt index 9fb2d0de12..44e31a9b0e 100644 --- a/packages/kotlin/src/android/src/main/java/alphaTab/core/ArrayTuple.kt +++ b/packages/kotlin/src/android/src/main/java/alphaTab/core/ArrayTuple.kt @@ -11,6 +11,19 @@ interface IArrayTuple { data class ArrayTuple(override var v0: T0, override var v1: T1) : IArrayTuple +interface IArrayTuple3 { + var v0: T0 + var v1: T1 + var v2: T2 + + operator fun component1(): T0 + operator fun component2(): T1 + operator fun component3(): T2 +} + +data class ArrayTuple3(override var v0: T0, override var v1: T1, override var v2: T2) : + IArrayTuple3 + // strong typed variants (Kotlin should compile this to non-boxed variants where possible) interface IObjectBooleanArrayTuple : IArrayTuple data class ObjectBooleanArrayTuple(override var v0: T, override var v1: Boolean) : 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 606bdf9653..bdaa6c4855 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 @@ -39,8 +39,8 @@ internal inline fun UByteArray.decodeToDoubleArray(): DoubleArray { return da } -internal inline fun > List.sort() { - this.sort { a, b -> +internal inline fun > List.sort(): List { + return this.sort { a, b -> a.compareTo(b).toDouble() } } 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 a61fe3e914..9a5fb5330e 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 @@ -22,6 +22,13 @@ public class Uint8Array : Iterable { public constructor() : this(UByteArray(0)) { } + public constructor(other: Uint8Array) { + val start = other.byteOffset.toInt() + this.buffer = other.buffer.copyOfRange(start, start + other.length.toInt()) + this.length = other.length + this.byteOffset = 0.0 + } + public constructor(data: UByteArray) { this.buffer = data this.length = data.size.toDouble() 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 1891cd4e09..8d40d03902 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 @@ -310,6 +310,18 @@ class Expector(private val actual: T, private val message: String? = null) { equal(expected) } + /** + * Numbers and enum values compare as the plain numbers they are in JavaScript, map entries by key and value. + */ + private fun normalizeElement(value: Any?): Any? { + return when (value) { + is IAlphaTabEnum -> value.toDouble() + is Int -> value.toDouble() + is alphaTab.collections.DoubleDoubleMapEntry -> kotlin.collections.listOf(value.key, value.value) + else -> value + } + } + fun toEqual(expected: Any?, message: String? = null) { val a = actual // Sequences compare element-wise; runtime List types don't override equals. @@ -333,7 +345,7 @@ class Expector(private val actual: T, private val message: String? = null) { if (expectedElement is Iterable<*> && expectedElement !is String) { Expector(actualElement, elementMessage).toEqual(expectedElement) } else { - Assert.assertEquals(elementMessage, expectedElement, actualElement) + Assert.assertEquals(elementMessage, normalizeElement(expectedElement), normalizeElement(actualElement)) } i++ } @@ -399,6 +411,10 @@ class Expector(private val actual: T, private val message: String? = null) { Assert.assertNull(message, actual) } + fun toBeDefined() { + Assert.assertNotNull(message, actual) + } + fun toThrow(expected: KClass) { `throw`(expected) } diff --git a/packages/playground/src/player/DemoPlayerView.tsx b/packages/playground/src/player/DemoPlayerView.tsx index 0ba1190ef0..19d4f93d67 100644 --- a/packages/playground/src/player/DemoPlayerView.tsx +++ b/packages/playground/src/player/DemoPlayerView.tsx @@ -48,7 +48,7 @@ export interface DemoPlayerViewProps { scoreOverlay?: React.ReactNode; } -const FILE_ACCEPT = '.gp,.gp3,.gp4,.gp5,.gpx,.gp7,.gp8,.xml,.musicxml,.mxl,.capx,.alphatex,.atex,.tex,.txt'; +const FILE_ACCEPT = '.gp,.gp3,.gp4,.gp5,.gpx,.gp7,.gp8,.xml,.musicxml,.mxl,.capx,.tef,.alphatex,.atex,.tex,.txt'; const noop = () => {}; /** diff --git a/packages/transpiler/src/TypeResolver.ts b/packages/transpiler/src/TypeResolver.ts index 19ec468129..505122ea58 100644 --- a/packages/transpiler/src/TypeResolver.ts +++ b/packages/transpiler/src/TypeResolver.ts @@ -241,8 +241,11 @@ export default class TypeResolver { return handleNullablePrimitive(cs.PrimitiveType.Object); } - // undefined -> nullable object - if ((tsType.flags & ts.TypeFlags.Undefined) === ts.TypeFlags.Undefined) { + // undefined or null -> nullable object + if ( + (tsType.flags & ts.TypeFlags.Undefined) === ts.TypeFlags.Undefined || + (tsType.flags & ts.TypeFlags.Null) === ts.TypeFlags.Null + ) { const undefinedType = handleNullablePrimitive(cs.PrimitiveType.Object); undefinedType.isNullable = true; return undefinedType; diff --git a/packages/transpiler/src/kotlin/KotlinAstPrinter.ts b/packages/transpiler/src/kotlin/KotlinAstPrinter.ts index 5610f34696..b6e48db679 100644 --- a/packages/transpiler/src/kotlin/KotlinAstPrinter.ts +++ b/packages/transpiler/src/kotlin/KotlinAstPrinter.ts @@ -897,6 +897,7 @@ export default class KotlinAstPrinter extends AstPrinterBase { this.write(arrayTupleType.types.length.toString()); this.write('<'); this.writeCommaSeparated(arrayTupleType.types, p => this.writeType(p)); + this.write('>'); } else { let arrayTupleName = ''; const newTypeArgs: cs.TypeNode[] = []; diff --git a/packages/transpiler/test/fixtures/expressions/array-of-nulls/expected.cs b/packages/transpiler/test/fixtures/expressions/array-of-nulls/expected.cs new file mode 100644 index 0000000000..8d9e9d4d80 --- /dev/null +++ b/packages/transpiler/test/fixtures/expressions/array-of-nulls/expected.cs @@ -0,0 +1,34 @@ +// +// This code was auto-generated. +// Changes to this file may cause incorrect behavior and will be lost if +// the code is regenerated. +// +#nullable enable annotations + +using System; +using AlphaTab.Core; + +namespace AlphaTab +{ + /// + /// Covers an array literal of nulls only, whose element type is inferred as null. + /// + public class ArrayOfNulls + { + private static T _same(T value) + { + return value; + } + + + public object? Create() + { + return AlphaTab.ArrayOfNulls._same(new AlphaTab.Collections.List{ + null, null} + ); + } + + + } + +} diff --git a/packages/transpiler/test/fixtures/expressions/array-of-nulls/expected.kt b/packages/transpiler/test/fixtures/expressions/array-of-nulls/expected.kt new file mode 100644 index 0000000000..e227d366b2 --- /dev/null +++ b/packages/transpiler/test/fixtures/expressions/array-of-nulls/expected.kt @@ -0,0 +1,65 @@ +// +// This code was auto-generated. +// Changes to this file may cause incorrect behavior and will be lost if +// the code is regenerated. +// + +@file:Suppress( + "KotlinRedundantDiagnosticSuppress", + "RedundantVisibilityModifier", + "RedundantExplicitType", + "RedundantUnitReturnType", + "RemoveRedundantQualifierName", + "RemoveExplicitTypeArguments", + "MemberVisibilityCanBePrivate", + "MoveLambdaOutsideParentheses", + "ConvertSecondaryConstructorToPrimary", + "RemoveRedundantCallsOfConversionMethods", + "MayBeConstant", + "UnusedImport", + "CanBeVal", + "CascadeIf", + "unused", + "LocalVariableName", + "NON_EXHAUSTIVE_WHEN", + "UNCHECKED_CAST", + "USELESS_CAST", + "DEPRECATION", + "PARAMETER_NAME_CHANGED_ON_OVERRIDE", + "UNNECESSARY_NOT_NULL_ASSERTION", + "UNNECESSARY_SAFE_CALL", + "UNUSED_ANONYMOUS_PARAMETER", + "UNUSED_PARAMETER", + "UNUSED_VALUE", + "UNREACHABLE_CODE", + "REDUNDANT_ELSE_IN_WHEN", + "VARIABLE_WITH_REDUNDANT_INITIALIZER" +) +package alphaTab +import alphaTab.core.* + +/** + * Covers an array literal of nulls only, whose element type is inferred as null. + */ +@kotlin.contracts.ExperimentalContracts +@kotlin.ExperimentalUnsignedTypes +public class ArrayOfNulls +{ + public fun create(): Any?{ + return alphaTab.ArrayOfNulls._same(alphaTab.collections.List( + null, null) + ) + } + + companion object{ + /** + */ + @kotlin.jvm.JvmStatic + private fun _same(value: T): T{ + return value + } + + } + public constructor() +} + diff --git a/packages/transpiler/test/fixtures/expressions/array-of-nulls/input.ts b/packages/transpiler/test/fixtures/expressions/array-of-nulls/input.ts new file mode 100644 index 0000000000..6b180c78d4 --- /dev/null +++ b/packages/transpiler/test/fixtures/expressions/array-of-nulls/input.ts @@ -0,0 +1,14 @@ +/** + * Covers an array literal of nulls only, whose element type is inferred as null. + * + * @public + */ +export class ArrayOfNulls { + private static _same(value: T): T { + return value; + } + + public create(): unknown { + return ArrayOfNulls._same([null, null]); + } +} diff --git a/packages/transpiler/test/fixtures/expressions/tuple-three-items/expected.cs b/packages/transpiler/test/fixtures/expressions/tuple-three-items/expected.cs new file mode 100644 index 0000000000..3229c5eba8 --- /dev/null +++ b/packages/transpiler/test/fixtures/expressions/tuple-three-items/expected.cs @@ -0,0 +1,41 @@ +// +// This code was auto-generated. +// Changes to this file may cause incorrect behavior and will be lost if +// the code is regenerated. +// +#nullable enable annotations + +using System; +using AlphaTab.Core; + +namespace AlphaTab +{ + /// + /// Covers tuples of three items as element type and in a destructuring loop. + /// + public class TupleThreeItems + { + public double Sum(System.Collections.Generic.IList>> items) + { + double total = 0; + foreach (var (a, b, c) in items) + { + total += a + b + c.Count; + } + return total; + } + + + public System.Collections.Generic.IList>> Create() + { + return new AlphaTab.Collections.List>>{ + new AlphaTab.Core.ArrayTuple>(1, 2, new AlphaTab.Collections.List{ + 3} + )} + ; + } + + + } + +} diff --git a/packages/transpiler/test/fixtures/expressions/tuple-three-items/expected.kt b/packages/transpiler/test/fixtures/expressions/tuple-three-items/expected.kt new file mode 100644 index 0000000000..143d036589 --- /dev/null +++ b/packages/transpiler/test/fixtures/expressions/tuple-three-items/expected.kt @@ -0,0 +1,69 @@ +// +// This code was auto-generated. +// Changes to this file may cause incorrect behavior and will be lost if +// the code is regenerated. +// + +@file:Suppress( + "KotlinRedundantDiagnosticSuppress", + "RedundantVisibilityModifier", + "RedundantExplicitType", + "RedundantUnitReturnType", + "RemoveRedundantQualifierName", + "RemoveExplicitTypeArguments", + "MemberVisibilityCanBePrivate", + "MoveLambdaOutsideParentheses", + "ConvertSecondaryConstructorToPrimary", + "RemoveRedundantCallsOfConversionMethods", + "MayBeConstant", + "UnusedImport", + "CanBeVal", + "CascadeIf", + "unused", + "LocalVariableName", + "NON_EXHAUSTIVE_WHEN", + "UNCHECKED_CAST", + "USELESS_CAST", + "DEPRECATION", + "PARAMETER_NAME_CHANGED_ON_OVERRIDE", + "UNNECESSARY_NOT_NULL_ASSERTION", + "UNNECESSARY_SAFE_CALL", + "UNUSED_ANONYMOUS_PARAMETER", + "UNUSED_PARAMETER", + "UNUSED_VALUE", + "UNREACHABLE_CODE", + "REDUNDANT_ELSE_IN_WHEN", + "VARIABLE_WITH_REDUNDANT_INITIALIZER" +) +package alphaTab +import alphaTab.core.* + +/** + * Covers tuples of three items as element type and in a destructuring loop. + */ +@kotlin.contracts.ExperimentalContracts +@kotlin.ExperimentalUnsignedTypes +public class TupleThreeItems +{ + /** + */ + public fun sum(items: alphaTab.collections.List>): Double{ + var total: Double = 0.0 + for ((a, b, c) in items) + { + total += a + b + c.length + } + return total + } + + public fun create(): alphaTab.collections.List>{ + return alphaTab.collections.List>( + alphaTab.core.ArrayTuple3(1.0, 2.0, alphaTab.collections.DoubleList( + 3.0) + )) + + } + + public constructor() +} + diff --git a/packages/transpiler/test/fixtures/expressions/tuple-three-items/input.ts b/packages/transpiler/test/fixtures/expressions/tuple-three-items/input.ts new file mode 100644 index 0000000000..7013ed010b --- /dev/null +++ b/packages/transpiler/test/fixtures/expressions/tuple-three-items/input.ts @@ -0,0 +1,18 @@ +/** + * Covers tuples of three items as element type and in a destructuring loop. + * + * @public + */ +export class TupleThreeItems { + public sum(items: [number, number, number[]][]): number { + let total = 0; + for (const [a, b, c] of items) { + total += a + b + c.length; + } + return total; + } + + public create(): [number, number, number[]][] { + return [[1, 2, [3]]]; + } +} diff --git a/packages/ui/src/player/controls/overlays.tsx b/packages/ui/src/player/controls/overlays.tsx index d67f900c20..ee158eb9b6 100644 --- a/packages/ui/src/player/controls/overlays.tsx +++ b/packages/ui/src/player/controls/overlays.tsx @@ -6,7 +6,7 @@ import styles from './overlays.module.scss'; /** The drop target hint shown while a file is dragged over a player view. */ export const DropOverlay: React.FC<{ hint?: React.ReactNode; className?: string }> = ({ - hint = 'Guitar Pro 3–8, MusicXML, Capella or alphaTex', + hint = 'Guitar Pro 3–8, MusicXML, Capella, TablEdit or alphaTex', className }) => (