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/AlphaTexImporter.ts b/packages/alphatab/src/importer/AlphaTexImporter.ts index ce49854b13..e0e9c66ed7 100644 --- a/packages/alphatab/src/importer/AlphaTexImporter.ts +++ b/packages/alphatab/src/importer/AlphaTexImporter.ts @@ -300,7 +300,10 @@ export class AlphaTexImporter extends ScoreImporter implements IAlphaTexImporter } for (const [sustainPedal, beat] of this._state.sustainPedalToBeat) { if (sustainPedal.ratioPosition < 1) { - sustainPedal.ratioPosition = ModelUtils.tickToRatioPosition(beat.voice.bar.masterBar, beat.playbackStart); + sustainPedal.ratioPosition = ModelUtils.tickToRatioPosition( + beat.voice.bar.masterBar, + beat.playbackStart + ); } } return this._state.score; @@ -718,7 +721,10 @@ export class AlphaTexImporter extends ScoreImporter implements IAlphaTexImporter if (node.noteString) { const percussionNoteString: number = node.noteString!.value; - if (percussionNoteString < 1 || percussionNoteString > this._state.currentStaff!.tuning.length) { + if ( + percussionNoteString < 1 || + percussionNoteString > this._state.currentStaff!.tuning.length + ) { this.addSemanticDiagnostic({ code: AlphaTexDiagnosticCode.AT208, message: `Note string is out of range. Available range: 1-${this._state.currentStaff!.tuning.length}`, diff --git a/packages/alphatab/src/importer/BinaryStylesheet.ts b/packages/alphatab/src/importer/BinaryStylesheet.ts index b0a9b68f00..696e70c8a4 100644 --- a/packages/alphatab/src/importer/BinaryStylesheet.ts +++ b/packages/alphatab/src/importer/BinaryStylesheet.ts @@ -1,10 +1,11 @@ -import { type HeaderFooterStyle, type Score, ScoreSubElement } from '@coderline/alphatab/model/Score'; +import { AlphaTabError, AlphaTabErrorType } from '@coderline/alphatab/AlphaTabError'; +import { GpBinaryHelpers } from '@coderline/alphatab/importer/Gp3To5Importer'; import { ByteBuffer } from '@coderline/alphatab/io/ByteBuffer'; import { IOHelper } from '@coderline/alphatab/io/IOHelper'; -import { GpBinaryHelpers } from '@coderline/alphatab/importer/Gp3To5Importer'; +import type { IWriteable } from '@coderline/alphatab/io/IWriteable'; import { BendPoint } from '@coderline/alphatab/model/BendPoint'; -import { Bounds } from '@coderline/alphatab/rendering/utils/Bounds'; import { Color } from '@coderline/alphatab/model/Color'; +import { ModelUtils } from '@coderline/alphatab/model/ModelUtils'; import { BarNumberDisplay, type BracketExtendMode, @@ -13,10 +14,9 @@ import { TrackNamePolicy, TuningDisplayMode } from '@coderline/alphatab/model/RenderStylesheet'; -import type { IWriteable } from '@coderline/alphatab/io/IWriteable'; -import { AlphaTabError, AlphaTabErrorType } from '@coderline/alphatab/AlphaTabError'; -import { ModelUtils } from '@coderline/alphatab/model/ModelUtils'; +import { type HeaderFooterStyle, type Score, ScoreSubElement } from '@coderline/alphatab/model/Score'; import { TextAlign } from '@coderline/alphatab/platform/ICanvas'; +import { Bounds } from '@coderline/alphatab/rendering/utils/Bounds'; /** * @internal @@ -625,7 +625,7 @@ export class BinaryStylesheet { binaryStylesheet.addValue('Global/TuningDisplayMode', 0, DataType.Integer); break; } - + const writer = ByteBuffer.withCapacity(128); binaryStylesheet.writeTo(writer); return writer.toArray(); diff --git a/packages/alphatab/src/importer/CapellaImporter.ts b/packages/alphatab/src/importer/CapellaImporter.ts index ad923fb751..f2e12a51d6 100644 --- a/packages/alphatab/src/importer/CapellaImporter.ts +++ b/packages/alphatab/src/importer/CapellaImporter.ts @@ -1,14 +1,11 @@ +import { CapellaParser } from '@coderline/alphatab/importer/CapellaParser'; import { ScoreImporter } from '@coderline/alphatab/importer/ScoreImporter'; import { UnsupportedFormatError } from '@coderline/alphatab/importer/UnsupportedFormatError'; - -import type { Score } from '@coderline/alphatab/model/Score'; - +import { IOHelper } from '@coderline/alphatab/io/IOHelper'; import { Logger } from '@coderline/alphatab/Logger'; - -import { ZipReader } from '@coderline/alphatab/zip/ZipReader'; +import type { Score } from '@coderline/alphatab/model/Score'; import type { ZipEntry } from '@coderline/alphatab/zip/ZipEntry'; -import { IOHelper } from '@coderline/alphatab/io/IOHelper'; -import { CapellaParser } from '@coderline/alphatab/importer/CapellaParser'; +import { ZipReader } from '@coderline/alphatab/zip/ZipReader'; /** * This ScoreImporter can read Capella (cap/capx) files. diff --git a/packages/alphatab/src/importer/CapellaParser.ts b/packages/alphatab/src/importer/CapellaParser.ts index c33343a1f1..f4670cb4d4 100644 --- a/packages/alphatab/src/importer/CapellaParser.ts +++ b/packages/alphatab/src/importer/CapellaParser.ts @@ -1,4 +1,5 @@ import { UnsupportedFormatError } from '@coderline/alphatab/importer/UnsupportedFormatError'; +import { Logger } from '@coderline/alphatab/Logger'; import { AccentuationType } from '@coderline/alphatab/model/AccentuationType'; import { Automation, AutomationType } from '@coderline/alphatab/model/Automation'; import { Bar, BarLineStyle } from '@coderline/alphatab/model/Bar'; @@ -7,25 +8,23 @@ 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 { Fermata, FermataType } from '@coderline/alphatab/model/Fermata'; +import type { KeySignature } from '@coderline/alphatab/model/KeySignature'; import { MasterBar } from '@coderline/alphatab/model/MasterBar'; +import { ModelUtils } from '@coderline/alphatab/model/ModelUtils'; import { Note } from '@coderline/alphatab/model/Note'; +import { Ottavia } from '@coderline/alphatab/model/Ottavia'; import { Score } from '@coderline/alphatab/model/Score'; import type { Staff } from '@coderline/alphatab/model/Staff'; import { Track } from '@coderline/alphatab/model/Track'; import { VibratoType } from '@coderline/alphatab/model/VibratoType'; import { Voice } from '@coderline/alphatab/model/Voice'; +import { TextAlign } from '@coderline/alphatab/platform/ICanvas'; +import { BeamDirection } from '@coderline/alphatab/rendering/utils/BeamDirection'; import type { Settings } from '@coderline/alphatab/Settings'; import { XmlDocument } from '@coderline/alphatab/xml/XmlDocument'; - import type { XmlNode } from '@coderline/alphatab/xml/XmlNode'; -import { BeamDirection } from '@coderline/alphatab/rendering/utils/BeamDirection'; -import { TextAlign } from '@coderline/alphatab/platform/ICanvas'; -import { ModelUtils } from '@coderline/alphatab/model/ModelUtils'; -import { Logger } from '@coderline/alphatab/Logger'; -import { Fermata, FermataType } from '@coderline/alphatab/model/Fermata'; -import { DynamicValue } from '@coderline/alphatab/model/DynamicValue'; -import { Ottavia } from '@coderline/alphatab/model/Ottavia'; -import type { KeySignature } from '@coderline/alphatab/model/KeySignature'; /** * @internal @@ -500,7 +499,13 @@ export class CapellaParser { break; } } - private _parseVoices(staffId: string, staff: Staff, systemElement: XmlNode, firstBarIndex: number, element: XmlNode) { + private _parseVoices( + staffId: string, + staff: Staff, + systemElement: XmlNode, + firstBarIndex: number, + element: XmlNode + ) { let voiceIndex = 0; for (const c of element.childElements()) { switch (c.localName) { diff --git a/packages/alphatab/src/importer/Gp7To8Importer.ts b/packages/alphatab/src/importer/Gp7To8Importer.ts index 6d51ef8c61..8a4363bcf1 100644 --- a/packages/alphatab/src/importer/Gp7To8Importer.ts +++ b/packages/alphatab/src/importer/Gp7To8Importer.ts @@ -1,17 +1,14 @@ import { BinaryStylesheet } from '@coderline/alphatab/importer/BinaryStylesheet'; import { GpifParser } from '@coderline/alphatab/importer/GpifParser'; +import { LayoutConfiguration } from '@coderline/alphatab/importer/LayoutConfiguration'; import { PartConfiguration } from '@coderline/alphatab/importer/PartConfiguration'; import { ScoreImporter } from '@coderline/alphatab/importer/ScoreImporter'; import { UnsupportedFormatError } from '@coderline/alphatab/importer/UnsupportedFormatError'; - -import type { Score } from '@coderline/alphatab/model/Score'; - +import { IOHelper } from '@coderline/alphatab/io/IOHelper'; import { Logger } from '@coderline/alphatab/Logger'; - -import { ZipReader } from '@coderline/alphatab/zip/ZipReader'; +import type { Score } from '@coderline/alphatab/model/Score'; import type { ZipEntry } from '@coderline/alphatab/zip/ZipEntry'; -import { IOHelper } from '@coderline/alphatab/io/IOHelper'; -import { LayoutConfiguration } from '@coderline/alphatab/importer/LayoutConfiguration'; +import { ZipReader } from '@coderline/alphatab/zip/ZipReader'; /** * This ScoreImporter can read Guitar Pro 7 and 8 (gp) files. @@ -66,10 +63,10 @@ export class Gp7To8Importer extends ScoreImporter { // the score information as XML we need to parse. Logger.trace(this.name, 'Start Parsing score.gpif'); const gpifParser: GpifParser = new GpifParser(); - gpifParser.loadAsset = (fileName) => { - if(entryLookup.has(fileName)) { + gpifParser.loadAsset = fileName => { + if (entryLookup.has(fileName)) { return entryLookup.get(fileName)!.data; - }; + } return undefined; }; gpifParser.parseXml(xml, this.settings); @@ -78,7 +75,10 @@ export class Gp7To8Importer extends ScoreImporter { if (binaryStylesheetData) { Logger.trace(this.name, 'Start Parsing BinaryStylesheet'); - const stylesheet: BinaryStylesheet = new BinaryStylesheet(binaryStylesheetData, this.settings.importer.maxDecodingBufferSize); + const stylesheet: BinaryStylesheet = new BinaryStylesheet( + binaryStylesheetData, + this.settings.importer.maxDecodingBufferSize + ); stylesheet.apply(score); Logger.trace(this.name, 'BinaryStylesheet parsed'); } diff --git a/packages/alphatab/src/importer/GpifParser.ts b/packages/alphatab/src/importer/GpifParser.ts index 7dae73de87..7007c10ea9 100644 --- a/packages/alphatab/src/importer/GpifParser.ts +++ b/packages/alphatab/src/importer/GpifParser.ts @@ -2817,8 +2817,7 @@ export class GpifParser { if (isPercussion) { staff.stringTuning.tunings = [0, 0, 0, 0, 0, 0]; } else { - const fallback = - staff.index === 0 ? Tuning.getDefaultTuningFor(6) : Tuning.getDefaultTuningFor(5); + const fallback = staff.index === 0 ? Tuning.getDefaultTuningFor(6) : Tuning.getDefaultTuningFor(5); if (fallback !== null) { staff.stringTuning.tunings = fallback.tunings.slice(); staff.stringTuning.name = fallback.name; diff --git a/packages/alphatab/src/importer/GpxImporter.ts b/packages/alphatab/src/importer/GpxImporter.ts index dbc862168f..3d3451ea99 100644 --- a/packages/alphatab/src/importer/GpxImporter.ts +++ b/packages/alphatab/src/importer/GpxImporter.ts @@ -1,13 +1,13 @@ import { BinaryStylesheet } from '@coderline/alphatab/importer/BinaryStylesheet'; import { GpifParser } from '@coderline/alphatab/importer/GpifParser'; import { GpxFileSystem } from '@coderline/alphatab/importer/GpxFileSystem'; +import { LayoutConfiguration } from '@coderline/alphatab/importer/LayoutConfiguration'; import { PartConfiguration } from '@coderline/alphatab/importer/PartConfiguration'; import { ScoreImporter } from '@coderline/alphatab/importer/ScoreImporter'; -import type { Score } from '@coderline/alphatab/model/Score'; -import { Logger } from '@coderline/alphatab/Logger'; import { UnsupportedFormatError } from '@coderline/alphatab/importer/UnsupportedFormatError'; import { IOHelper } from '@coderline/alphatab/io/IOHelper'; -import { LayoutConfiguration } from '@coderline/alphatab/importer/LayoutConfiguration'; +import { Logger } from '@coderline/alphatab/Logger'; +import type { Score } from '@coderline/alphatab/model/Score'; /** * This ScoreImporter can read Guitar Pro 6 (gpx) files. @@ -70,7 +70,10 @@ export class GpxImporter extends ScoreImporter { if (binaryStylesheetData) { Logger.trace(this.name, 'Start Parsing BinaryStylesheet'); - const binaryStylesheet: BinaryStylesheet = new BinaryStylesheet(binaryStylesheetData, this.settings.importer.maxDecodingBufferSize); + const binaryStylesheet: BinaryStylesheet = new BinaryStylesheet( + binaryStylesheetData, + this.settings.importer.maxDecodingBufferSize + ); binaryStylesheet.apply(score); Logger.trace(this.name, 'BinaryStylesheet parsed'); } diff --git a/packages/alphatab/src/importer/LayoutConfiguration.ts b/packages/alphatab/src/importer/LayoutConfiguration.ts index 27b77c95f9..1a1ba8908d 100644 --- a/packages/alphatab/src/importer/LayoutConfiguration.ts +++ b/packages/alphatab/src/importer/LayoutConfiguration.ts @@ -1,7 +1,7 @@ +import type { PartConfiguration } from '@coderline/alphatab/importer/PartConfiguration'; import { ByteBuffer } from '@coderline/alphatab/io/ByteBuffer'; import { IOHelper } from '@coderline/alphatab/io/IOHelper'; import type { Score } from '@coderline/alphatab/model/Score'; -import type { PartConfiguration } from '@coderline/alphatab/importer/PartConfiguration'; // PartConfiguration File Format Notes. // Based off Guitar Pro 8 diff --git a/packages/alphatab/src/importer/MusicXmlImporter.ts b/packages/alphatab/src/importer/MusicXmlImporter.ts index 5e7ce159d8..7a7c2b73cc 100644 --- a/packages/alphatab/src/importer/MusicXmlImporter.ts +++ b/packages/alphatab/src/importer/MusicXmlImporter.ts @@ -24,7 +24,7 @@ import { Clef } from '@coderline/alphatab/model/Clef'; import { Direction } from '@coderline/alphatab/model/Direction'; import { Duration } from '@coderline/alphatab/model/Duration'; import { DynamicValue } from '@coderline/alphatab/model/DynamicValue'; -import { FermataType, Fermata } from '@coderline/alphatab/model/Fermata'; +import { Fermata, FermataType } from '@coderline/alphatab/model/Fermata'; import { Fingers } from '@coderline/alphatab/model/Fingers'; import { GolpeType } from '@coderline/alphatab/model/GolpeType'; import { GraceType } from '@coderline/alphatab/model/GraceType'; @@ -3084,11 +3084,7 @@ export class MusicXmlImporter extends ScoreImporter { // on a staff with tuning, string and pitch define the tab position of a note written without fret // (e.g. dead notes written by Guitar Pro 5) - if ( - !note.isStringed && - (note.isDead || !note.isPercussion) && - note.beat.voice.bar.staff.tuning.length > 0 - ) { + if (!note.isStringed && (note.isDead || !note.isPercussion) && note.beat.voice.bar.staff.tuning.length > 0) { const fret = this._calculatePitchedNoteValue(note) - note.stringTuning; if (fret >= 0) { note.fret = fret; diff --git a/packages/alphatab/src/importer/ScoreImporter.ts b/packages/alphatab/src/importer/ScoreImporter.ts index e914c29c90..328a355a16 100644 --- a/packages/alphatab/src/importer/ScoreImporter.ts +++ b/packages/alphatab/src/importer/ScoreImporter.ts @@ -1,4 +1,4 @@ -import { ThrowingReadable, type IReadable } from '@coderline/alphatab/io/IReadable'; +import { type IReadable, ThrowingReadable } from '@coderline/alphatab/io/IReadable'; import { Score } from '@coderline/alphatab/model/Score'; import type { Settings } from '@coderline/alphatab/Settings'; diff --git a/packages/alphatab/src/importer/TablEditImporter.ts b/packages/alphatab/src/importer/TablEditImporter.ts new file mode 100644 index 0000000000..08eb1e7c40 --- /dev/null +++ b/packages/alphatab/src/importer/TablEditImporter.ts @@ -0,0 +1,537 @@ +import { FormatError } from '@coderline/alphatab/FormatError'; +import { ScoreImporter } from '@coderline/alphatab/importer/ScoreImporter'; +import { TefBarBuilder, TefCell, TefTrackInfo } from '@coderline/alphatab/importer/tablEdit/TefBarBuilder'; +import { TefBinary } from '@coderline/alphatab/importer/tablEdit/TefBinary'; +import { TefFormat2Reader } from '@coderline/alphatab/importer/tablEdit/TefFormat2Reader'; +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 2.6x and later (formats 3 to 12). + * @internal + */ +export class TablEditImporter extends ScoreImporter { + private static readonly _headerSize = 256; + private static readonly _firstFormat2 = 3; + 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 _readingGuideMarker = 0xb7; + 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 = 4294967295; + + private _data!: TefBinary; + private _score!: Score; + private _cells: TefCell[] = []; + private _lyrics: string = ''; + private _readingList: TefReadingListItem[] = []; + private _measureStarts: number[] = []; + private _measureSizes: number[] = []; + 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._firstFormat2) { + throw new FormatError( + 'TablEdit files older than version 2.6 are not supported, re-save the file with TablEdit 3' + ); + } + if (format > TablEditImporter._lastSupportedFormat) { + throw new FormatError(`TablEdit file format ${format} is newer than the supported format`); + } + + this._score = new Score(); + const builder = + format < TablEditImporter._firstSupportedFormat ? this._readFormat2() : this._readFormat3(format); + 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 ? [] : this._readGuides(this._cells)); + + ModelUtils.consolidate(this._score); + this._score.finish(this.settings); + this._score.rebuildRepeatGroups(); + TefReadingList.verify(this._score, readingList); + return this._score; + } + + private _readFormat3(format: number): TefBarBuilder { + if (this._u32(16) !== 0 || format === TablEditImporter._protectedFormat) { + throw new FormatError('Protected TablEdit files are not supported'); + } + this._readInformation(); + this._readMeasures(this._u32(92)); + this._readInstruments(this._u32(96)); + this._cells = this._readContents(this._u32(60)); + + const builder = new TefBarBuilder(this._tracks, this._measureStarts, this._measureSizes); + builder.initialTempo = this._u16(6) > 0 ? this._u16(6) : 120; + builder.initialSyncopation = Math.max(-3, Math.min(3, this._s16(12))); + builder.texts = this._readTexts(this._u32(84)); + builder.chords = this._readChords(this._u32(88)); + builder.version = this._u16(2); + this._lyrics = this._readString(this._u32(80)); + this._readingList = this._readReadingList(this._u32(128)); + return builder; + } + + private _readFormat2(): TefBarBuilder { + const reader = new TefFormat2Reader(this._data, this.settings.importer.maxDecodingBufferSize); + reader.read(); + this._score.title = reader.title; + this._score.subTitle = reader.author; + this._score.instructions = reader.comment; + this._score.notices = reader.notes; + + let position = 0; + for (let i = 0; i < reader.measures.length; i++) { + const measure = reader.measures[i]; + const masterBar = new MasterBar(); + masterBar.timeSignatureNumerator = measure.numerator; + masterBar.timeSignatureDenominator = measure.denominator; + this._score.addMasterBar(masterBar); + this._keys.push(measure.key); + if (measure.isMinor) { + this._minorBars.add(i); + } + this._measureStarts.push(position); + this._measureSizes.push(measure.size); + position += measure.size; + } + this._readInstrumentRecords(reader.instruments, 0, reader.instrumentCount, reader.instrumentRecordSize); + this._cells = reader.cells; + + const builder = new TefBarBuilder(this._tracks, this._measureStarts, this._measureSizes); + builder.initialTempo = reader.tempo; + builder.initialSyncopation = Math.max(-3, Math.min(3, reader.syncopation)); + builder.texts = reader.texts; + builder.chords = this._readChordRecords(reader.chordsOffset, reader.chordCount, reader.chordRecordSize); + builder.version = reader.version; + this._lyrics = reader.lyrics; + this._readingList = reader.readingList; + return builder; + } + + /** + * 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._u16(202); + const denominator = this._u8(204); + const format = this._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._u32(64)); + this._score.subTitle = this._readString(this._u32(68)); + this._score.instructions = this._readString(this._u32(72)); + this._score.notices = this._readString(this._u32(76)); + this._score.copyright = this._readString(this._u32(140)); + } + + private _readMeasures(offset: number): void { + if (offset === 0) { + throw new FormatError('TablEdit file without measures'); + } + const recordSize = this._u16(offset) === TablEditImporter._wideMeasureRecordHeader ? 12 : 8; + const count = this._u16(offset + 2); + this._ensureLoopBoundary(count, TablEditImporter._maxMeasureCount, 'measure count'); + let position = 0; + let p = offset + 8; + for (let i = 0; i < count; i++) { + const masterBar = new MasterBar(); + const denominator = this._u8(p + 4); + const numerator = this._u8(p + 5); + masterBar.timeSignatureNumerator = numerator > 0 ? numerator : 4; + masterBar.timeSignatureDenominator = denominator > 0 ? denominator : 4; + const flags = this._u8(p); + if ((flags & TablEditImporter._pickupFlag) !== 0) { + masterBar.isAnacrusis = true; + this._pickupBars.add(i); + } + if ((flags & TablEditImporter._minorFlag) !== 0) { + this._minorBars.add(i); + } + this._keys.push(this._s8(p + 2)); + this._score.addMasterBar(masterBar); + + let size = (64 / masterBar.timeSignatureDenominator) * masterBar.timeSignatureNumerator; + if (size <= 0 || size > 1024 || size !== Math.floor(size)) { + size = 64; + } + if ((flags & TablEditImporter._freeBarFlags) !== 0) { + masterBar.beamingRules = TablEditImporter._freeBarBeaming( + masterBar, + size, + (flags & TablEditImporter._halfBarBeamingFlag) !== 0 + ); + } + this._measureStarts.push(position); + this._measureSizes.push(size); + position += size; + 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 { + if (offset === 0) { + throw new FormatError('TablEdit file without instruments'); + } + this._readInstrumentRecords(this._data, offset + 4, this._u16(offset + 2), this._u16(offset)); + } + + private _readInstrumentRecords(data: TefBinary, offset: number, count: number, recordSize: number): void { + this._ensureLoopBoundary(count, TablEditImporter._maxInstrumentCount, 'instrument count'); + let p = offset; + for (let i = 0; i < count; i++) { + const stringCount = data.u16(p); + this._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); + // the bytes between the name and the abbreviation are leftovers of earlier names + 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; + } + } + + private _readReadingList(offset: number): TefReadingListItem[] { + const items: TefReadingListItem[] = []; + if (offset === 0) { + return items; + } + const recordSize = this._u16(offset); + if (recordSize !== TablEditImporter._readingListRecordSize) { + return items; + } + const count = this._u16(offset + 2); + this._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._u16(p) - 1; + item.last = this._u16(p + 2) - 1; + item.label = this._readCString(p + 4, recordSize - 4); + items.push(item); + p += recordSize; + } + return items; + } + + private _readGuides(cells: TefCell[]): TefReadingGuide[] { + const guides: TefReadingGuide[] = []; + for (const cell of cells) { + if (cell.data[0] === TablEditImporter._readingGuideMarker) { + const guide = new TefReadingGuide(); + let bar = 0; + while (bar + 1 < this._measureStarts.length && this._measureStarts[bar + 1] <= cell.position) { + bar++; + } + guide.bar = bar; + 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._u16(offset); + this._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._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; + } + return this._readChordRecords(offset + 4, this._u16(offset + 2), this._u16(offset)); + } + + private _readChordRecords(offset: number, count: number, recordSize: number): Chord[] { + const chords: Chord[] = []; + this._ensureLoopBoundary(count, TablEditImporter._maxChordCount, 'chord count'); + let p = offset; + for (let i = 0; i < count; i++) { + const chord = new Chord(); + chord.name = this._readCString(p + 14, 17); + for (let s = 0; s < TablEditImporter._chordStringBytes; s++) { + const value = this._u8(p + s); + chord.strings.push(value === 0xff ? -1 : value & 0x1f); + } + const firstFret = this._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 || this._totalStrings === 0) { + return cells; + } + const totalLength = + this._measureStarts.length > 0 + ? this._measureStarts[this._measureStarts.length - 1] + + this._measureSizes[this._measureSizes.length - 1] + : 0; + let p = offset; + while (true) { + const location = this._u32(p); + if (location === TablEditImporter._cellEnd) { + break; + } + this._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 < totalLength) { + 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); + } + + private _readCString(offset: number, maxLength: number): string { + return this._data.readCString(offset, maxLength); + } + + private _u8(offset: number): number { + return this._data.u8(offset); + } + + private _s8(offset: number): number { + return this._data.s8(offset); + } + + private _s16(offset: number): number { + return this._data.s16(offset); + } + + private _u16(offset: number): number { + return this._data.u16(offset); + } + + private _u32(offset: number): number { + return this._data.u32(offset); + } + + private _ensureLoopBoundary(value: number, maximumValue: number, label: string) { + TefBinary.ensureLoopBoundary(value, maximumValue, label); + } +} diff --git a/packages/alphatab/src/importer/alphaTex/ATNF.ts b/packages/alphatab/src/importer/alphaTex/ATNF.ts index a9498f96cd..b0de562d77 100644 --- a/packages/alphatab/src/importer/alphaTex/ATNF.ts +++ b/packages/alphatab/src/importer/alphaTex/ATNF.ts @@ -1,4 +1,5 @@ import { + type AlphaTexArgumentList, type AlphaTexBackSlashTokenNode, type AlphaTexBraceCloseTokenNode, type AlphaTexBraceOpenTokenNode, @@ -12,7 +13,6 @@ import { type AlphaTexPropertiesNode, type AlphaTexPropertyNode, type AlphaTexStringLiteral, - type AlphaTexArgumentList, type IAlphaTexArgumentValue } from '@coderline/alphatab/importer/alphaTex/AlphaTexAst'; diff --git a/packages/alphatab/src/importer/alphaTex/AlphaTex1EnumMappings.ts b/packages/alphatab/src/importer/alphaTex/AlphaTex1EnumMappings.ts index 401d24a217..a2c12dd894 100644 --- a/packages/alphatab/src/importer/alphaTex/AlphaTex1EnumMappings.ts +++ b/packages/alphatab/src/importer/alphaTex/AlphaTex1EnumMappings.ts @@ -1,4 +1,3 @@ -import type { TabRhythmMode } from '@coderline/alphatab/NotationSettings'; import type { AlphaTexAccidentalMode, AlphaTexStaffType, @@ -31,6 +30,7 @@ import type { SimileMark } from '@coderline/alphatab/model/SimileMark'; import type { TremoloPickingStyle } from '@coderline/alphatab/model/TremoloPickingEffect'; import type { TripletFeel } from '@coderline/alphatab/model/TripletFeel'; import type { WhammyType } from '@coderline/alphatab/model/WhammyType'; +import type { TabRhythmMode } from '@coderline/alphatab/NotationSettings'; import type { TextAlign } from '@coderline/alphatab/platform/ICanvas'; /** * @internal diff --git a/packages/alphatab/src/importer/alphaTex/AlphaTex1LanguageDefinitions.ts b/packages/alphatab/src/importer/alphaTex/AlphaTex1LanguageDefinitions.ts index be18148493..ae128e3fa2 100644 --- a/packages/alphatab/src/importer/alphaTex/AlphaTex1LanguageDefinitions.ts +++ b/packages/alphatab/src/importer/alphaTex/AlphaTex1LanguageDefinitions.ts @@ -1,5 +1,6 @@ import type { AlphaTexNodeType } from '@coderline/alphatab/importer/alphaTex/AlphaTexAst'; import type { ArgumentListParseTypesMode } from '@coderline/alphatab/importer/alphaTex/AlphaTexShared'; + /** * @target web */ diff --git a/packages/alphatab/src/importer/alphaTex/IAlphaTexMetaDataReader.ts b/packages/alphatab/src/importer/alphaTex/IAlphaTexMetaDataReader.ts index 9916ce13a9..1532c4dbb5 100644 --- a/packages/alphatab/src/importer/alphaTex/IAlphaTexMetaDataReader.ts +++ b/packages/alphatab/src/importer/alphaTex/IAlphaTexMetaDataReader.ts @@ -1,5 +1,5 @@ import type { - AlphaTexArgumentList, + AlphaTexArgumentList, AlphaTexMetaDataTagNode, AlphaTexPropertyNode } from '@coderline/alphatab/importer/alphaTex/AlphaTexAst'; diff --git a/packages/alphatab/src/importer/tablEdit/TefBarBuilder.ts b/packages/alphatab/src/importer/tablEdit/TefBarBuilder.ts new file mode 100644 index 0000000000..25cc750f2b --- /dev/null +++ b/packages/alphatab/src/importer/tablEdit/TefBarBuilder.ts @@ -0,0 +1,1963 @@ +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'; + +/** + * A raw grid cell of a TablEdit file. + * @internal + */ +export class TefCell { + /** + * 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; +} + +/** + * @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 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; + 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 { + 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; +} + +/** + * 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 _restCode = 0x33; + private static readonly _tieDynamics = 7; + private static readonly _upperVoice = 2; + private static readonly _lowerVoice = 3; + private static readonly _extensionMarker = 0x7d; + private static readonly _graceFlag = 0x40; + private static readonly _pitchChangeFlag = 0x80; + 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 _restHiddenInNotationFlag = 0x01; + private static readonly _restHiddenInTabFlag = 0x20; + private static readonly _excludeFromBeamingMask = 0x88; + private static readonly _excludeFromBeaming = 0x08; + /** + * 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 = 0x0306; + private static readonly _bracketFlag = 0x80; + private static readonly _connectionLengthMask = 0x3f; + private static readonly _maxTupletNumber = 15; + private static readonly _lyricsTextPrefix = '>'; + + private static readonly _chordMarker = 0x35; + private static readonly _lineBreakMarker = 0x36; + private static readonly _accentMarker = 0x37; + private static readonly _crescendoMarker = 0x38; + private static readonly _textMarker = 0x39; + private static readonly _connectionMarker = 0x3d; + private static readonly _fingeringMarker = 0x3e; + private static readonly _patchMarker = 0x7e; + private static readonly _symbolMarker = 0xb6; + private static readonly _syncopationMarker = 0xfd; + private static readonly _tempoMarker = 0xfe; + private static readonly _noChord = 0xffff; + + private _tracks: TefTrackInfo[]; + private _measureStarts: number[]; + private _measureSizes: number[]; + /** + * Per bar, the beats of the first voice of the track being built with their real start. + */ + private _beatStarts: Map = new Map(); + private _beatsByBar: Map = new Map(); + + /** + * 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 of TablEdit which saved the file, as 0xMMmm. + */ + public version: number = 0; + /** + * Per bar, the beats of all voices that start a note or rest with their grid column. + */ + private _columnStarts: Map = new Map(); + private _columnBeats: Map = new Map(); + + 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(); + for (const cell of cells) { + if (cell.data[0] === TefBarBuilder._extensionMarker && cell.data[1] !== 0) { + extensions.set(`${cell.row}:${cell.position - 1}`, cell.data); + } + } + for (const info of this._tracks) { + const tuplets: TefTupletRange[] = []; + for (const cell of cells) { + if (cell.row >= info.firstString && cell.row < info.firstString + info.stringCount) { + const tuplet = this._tupletRangeOf(cell); + if (tuplet) { + tuplets.push(tuplet); + } + } + } + const events: TefEvent[] = []; + for (const cell of cells) { + if (cell.row >= info.firstString && cell.row < info.firstString + info.stringCount) { + 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); + events.push(e); + } + } + } + this._beatStarts = new Map(); + this._beatsByBar = new Map(); + this._columnStarts = new Map(); + this._columnBeats = new Map(); + this._buildTrack(info, events); + for (const cell of cells) { + if (cell.row >= info.firstString && cell.row < info.firstString + info.stringCount) { + 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] !== TefBarBuilder._connectionMarker || (data[7] & TefBarBuilder._bracketFlag) !== 0) { + return null; + } + const numerator = data[2] >> 4; + if (numerator < 3 || numerator > TefBarBuilder._maxTupletNumber) { + 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; + // the covered notes sit on the grid of half their value: n of them take the time of m / 2 notes, + // with m = 3 (compound beat) or 4 doubled until it reaches n + 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: TefTupletRange[], e: TefEvent): TefTupletRange | null { + if (TefBarBuilder._durationOf(e.durationCode)!.isTuplet) { + return null; + } + for (const range of ranges) { + if (range.bar === e.bar && e.column >= range.start && e.column <= range.end) { + return range; + } + } + return null; + } + + private static _eventDuration(e: TefEvent): TefDuration { + const d = TefBarBuilder._durationOf(e.durationCode)!; + if (e.tuplet) { + return new TefDuration(d.duration, d.dots, e.tuplet.numerator, e.tuplet.denominator); + } + return d; + } + + /** + * Pick-up measures only last from their first to their last sounding note. + */ + private _trimPickupBars(cells: TefCell[]): void { + for (const bar of this.pickupBars) { + const start = this._measureStarts[bar]; + const size = this._measureSizes[bar]; + let first = size; + let end = 0; + for (const cell of cells) { + const type = cell.data[0] & 0x3f; + if (type >= TefBarBuilder._restCode || cell.position < start || cell.position >= start + size) { + continue; + } + const d = TefBarBuilder._durationOf(cell.data[1] & 0x1f); + if (!d) { + continue; + } + const column = cell.position - start; + first = Math.min(first, column); + end = Math.max(end, column + Math.ceil(d.length / TefBarBuilder.subUnitsPerCell)); + } + if (first < end) { + this._columnOffsets.set(bar, first); + this._measureSizes[bar] = Math.min(size, end) - first; + } + } + } + + private _columnOf(position: number, bar: number): number { + const offset = this._columnOffsets.get(bar); + return position - this._measureStarts[bar] - (offset === undefined ? 0 : offset); + } + + /** + * Applies the lyrics block: sections introduced by '>' and the track letter. TablEdit shows only + * the first section of a track. + */ + public applyLyrics(text: string): void { + const sections = text.split('>'); + const applied = new Set(); + for (const section of sections) { + 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) { + for (const beat of bar.voices[0].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); + const column = Math.max(0, this._columnOf(cell.position, bar)); + switch (data[0]) { + case TefBarBuilder._tempoMarker: { + const bpm = data[1] | (data[2] << 8); + if (bpm > 1) { + this._addTempoChange(bar, column, tempo, bpm, data[3]); + tempo = bpm; + } + break; + } + case TefBarBuilder._syncopationMarker: + syncopation[bar] = Math.max(-3, Math.min(3, data[1] - 8)); + break; + case TefBarBuilder._lineBreakMarker: + this._addLineBreak(bar + 1); + if ((data[7] & 0x04) !== 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 = 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 _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. + */ + private _addTempoChange(bar: number, column: number, from: number, to: number, rampQuarters: number): void { + const score = this._tracks[0].track.score; + const steps = Math.max(1, rampQuarters); + const start = this._measureStarts[bar] + column; + for (let i = 1; i <= steps; i++) { + const position = start + (i - 1) * 16; + const b = this._barOf(position); + if (b >= score.masterBars.length || position >= this._measureStarts[b] + this._measureSizes[b]) { + break; + } + const value = steps === 1 ? to : Math.round(from + ((to - from) * i) / steps); + score.masterBars[b].tempoAutomations.push( + Automation.buildTempoAutomation( + false, + (position - this._measureStarts[b]) / 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; + } + + private _beatAt(bar: number, column: number): Beat | null { + const starts = this._beatStarts.get(bar); + const beats = this._beatsByBar.get(bar); + if (!starts || !beats) { + return null; + } + const time = column * TefBarBuilder.subUnitsPerCell; + let found: Beat | null = null; + for (let i = 0; i < starts.length; i++) { + if (starts[i] <= time) { + found = beats[i]; + } + } + 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; + } + switch (data[0]) { + case TefBarBuilder._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 TefBarBuilder._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 TefBarBuilder._accentMarker: + for (const b of this._beatsAtColumn(bar, column)) { + for (const n of b.notes) { + n.accentuated = AccentuationType.Normal; + } + } + break; + case TefBarBuilder._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 TefBarBuilder._crescendoMarker: { + const amount = + this.version < TefBarBuilder._crescendoAmountVersion + ? TefBarBuilder._defaultCrescendoAmount + : data[6]; + if (data[2] !== 0 && amount !== 0) { + const type = (data[1] & 0x10) !== 0 ? CrescendoType.Decrescendo : CrescendoType.Crescendo; + this._applyToRange(bar, column, data[2], b => { + b.crescendo = type; + }); + } + break; + } + case TefBarBuilder._symbolMarker: + if (data[1] === 0x6b || data[1] === 0x6c) { + const fermata = new Fermata(); + fermata.type = FermataType.Medium; + beat.fermata = fermata; + } + break; + case TefBarBuilder._connectionMarker: + if ((data[7] & TefBarBuilder._bracketFlag) === 0) { + const tuplet = this._tupletRangeOf(cell); + const unit = TefBarBuilder.subUnitsPerCell; + if (tuplet) { + // the covered notes are spread out, so the slur ends at the last covered column + let lastColumn = tuplet.start; + const columns = this._columnStarts.get(bar) ?? []; + for (const c of columns) { + if (c > lastColumn && c <= tuplet.end) { + lastColumn = c; + } + } + const stretch = (2 * tuplet.denominator) / tuplet.numerator; + const last = (tuplet.start + (lastColumn - tuplet.start) * stretch) * unit; + this._applySlur(bar, tuplet.start * unit, last + 1); + } else { + this._applySlur(bar, column * unit, (column + data[1] + 1) * unit); + } + } + break; + case TefBarBuilder._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[] = []; + const starts = this._columnStarts.get(bar); + const beats = this._columnBeats.get(bar); + if (starts && beats) { + for (let i = 0; i < starts.length; i++) { + if (starts[i] === column) { + found.push(beats[i]); + } + } + } + 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; + } + + private _applyToRange(bar: number, column: number, length: number, action: (beat: Beat) => void): void { + const starts = this._beatStarts.get(bar); + const beats = this._beatsByBar.get(bar); + if (!starts || !beats) { + return; + } + const from = column * TefBarBuilder.subUnitsPerCell; + const to = (column + length) * TefBarBuilder.subUnitsPerCell; + for (let i = 0; i < starts.length; i++) { + if (starts[i] >= from && starts[i] < to) { + action(beats[i]); + } + } + } + + /** + * Slurs the first to the last note starting in the given stretch of sub-units. + */ + private _applySlur(bar: number, from: number, to: number): void { + const starts = this._beatStarts.get(bar); + const beats = this._beatsByBar.get(bar); + if (!starts || !beats) { + return; + } + let first: Beat | null = null; + let last: Beat | null = null; + for (let i = 0; i < starts.length; i++) { + if (starts[i] >= from && starts[i] < to && beats[i].notes.length > 0) { + if (!first) { + first = beats[i]; + } + last = beats[i]; + } + } + 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(); + } + + private _decode(cell: TefCell, info: TefTrackInfo): TefEvent | null { + const type = cell.data[0] & 0x3f; + if (type > TefBarBuilder._restCode) { + return null; + } + const code = cell.data[1] & 0x1f; + if (!TefBarBuilder._durationOf(code)) { + return null; + } + const e = new TefEvent(); + e.cell = cell; + e.isRest = type === TefBarBuilder._restCode; + e.stringIndex = cell.row - info.firstString; + e.fret = type - 1; + e.durationCode = code; + 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; + e.bar = this._barOf(cell.position); + e.column = this._columnOf(cell.position, e.bar); + return e; + } + + private _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 static _durationOf(code: number): TefDuration | null { + switch (code) { + case 0: + return new TefDuration(Duration.Whole, 0, -1, -1); + case 1: + return new TefDuration(Duration.Half, 1, -1, -1); + case 2: + return new TefDuration(Duration.Whole, 0, 3, 2); + case 3: + return new TefDuration(Duration.Half, 0, -1, -1); + case 4: + return new TefDuration(Duration.Quarter, 1, -1, -1); + case 5: + return new TefDuration(Duration.Half, 0, 3, 2); + case 6: + return new TefDuration(Duration.Quarter, 0, -1, -1); + case 7: + return new TefDuration(Duration.Eighth, 1, -1, -1); + case 8: + return new TefDuration(Duration.Quarter, 0, 3, 2); + case 9: + return new TefDuration(Duration.Eighth, 0, -1, -1); + case 10: + return new TefDuration(Duration.Sixteenth, 1, -1, -1); + case 11: + return new TefDuration(Duration.Eighth, 0, 3, 2); + case 12: + return new TefDuration(Duration.Sixteenth, 0, -1, -1); + case 13: + return new TefDuration(Duration.ThirtySecond, 1, -1, -1); + case 14: + return new TefDuration(Duration.Sixteenth, 0, 3, 2); + case 15: + return new TefDuration(Duration.ThirtySecond, 0, -1, -1); + case 16: + return new TefDuration(Duration.SixtyFourth, 1, -1, -1); + case 17: + return new TefDuration(Duration.ThirtySecond, 0, 3, 2); + case 18: + return new TefDuration(Duration.SixtyFourth, 0, -1, -1); + case 19: + return new TefDuration(Duration.Half, 2, -1, -1); + case 20: + return new TefDuration(Duration.Sixteenth, 0, 5, 4); + case 22: + return new TefDuration(Duration.Quarter, 2, -1, -1); + case 23: + return new TefDuration(Duration.Sixteenth, 0, 7, 4); + case 25: + return new TefDuration(Duration.Eighth, 2, -1, -1); + case 26: + return new TefDuration(Duration.ThirtySecond, 0, 9, 8); + case 28: + return new TefDuration(Duration.Sixteenth, 2, -1, -1); + case 29: + return new TefDuration(Duration.ThirtySecond, 0, 10, 8); + case 31: + return new TefDuration(Duration.Whole, 1, -1, -1); + } + return null; + } + + 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; + } + lastOnString.set(e.stringIndex, e); + } + } + + for (let i = 0; i < barCount; i++) { + const bar = new Bar(); + bar.clef = info.isPercussion ? Clef.Neutral : info.clef; + staff.addBar(bar); + } + + const units: TefUnit[] = []; + for (let b = 0; b < barCount; b++) { + const barEvents: TefEvent[] = []; + for (const e of events) { + if (e.bar === b) { + barEvents.push(e); + } + } + for (let group = 0; group < 3; group++) { + const groupEvents: TefEvent[] = []; + for (const e of barEvents) { + if (TefBarBuilder._voiceGroupOf(e) === group) { + groupEvents.push(e); + } + } + 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); + } + + /** + * 0 = automatic, 1 = upper, 2 = lower. + */ + private static _voiceGroupOf(e: TefEvent): number { + switch (e.voiceHint) { + case TefBarBuilder._upperVoice: + return 1; + case TefBarBuilder._lowerVoice: + return 2; + } + return 0; + } + + /** + * Converts the grid columns of a bar and voice into real time. Tuplet members are stored + * closer together than they sound and are spread out again relative to an anchor: + * triplets relative to the beat of the first member of a run, other tuplets relative to + * the beat they are in. + */ + 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 = TefBarBuilder._eventDuration(e); + 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, unit); + } + } + e.realStart = start; + e.realEnd = start + d.length; + if (e.realEnd > barEnd) { + e.realEnd = 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, unit: number): number { + 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) { + 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.start = e.realStart; + unit.end = e.realEnd; + unit.bar = bar; + unit.isRest = e.isRest; + unit.voiceGroup = group; + const d = TefBarBuilder._eventDuration(e); + if (d.length === e.realEnd - e.realStart) { + unit.duration = d; + } + 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; + } + return a.end - a.start - (b.end - b.start); + }); + + 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]; + // upper voices first, then lower ones, keeping alphaTab's stem convention for voice 0 and 1 + const order: number[][] = []; + const maxLanes = Math.max(laneCounts[0], laneCounts[1], laneCounts[2]); + for (let l = 0; l < maxLanes; l++) { + for (const group of [1, 0, 2]) { + if (l < laneCounts[group]) { + order.push([group, l]); + } + } + } + if (order.length === 0) { + order.push([0, 0]); + } + + for (let b = 0; b < staff.bars.length; b++) { + const bar = staff.bars[b]; + const barLength = this._measureSizes[b] * TefBarBuilder.subUnitsPerCell; + for (let v = 0; v < order.length; v++) { + const group = order[v][0]; + const lane = order[v][1]; + const voice = new Voice(); + bar.addVoice(voice); + const laneUnits: TefUnit[] = []; + for (const u of units) { + if (u.bar === b && u.voiceGroup === group && u.lane === lane) { + laneUnits.push(u); + } + } + laneUnits.sort((x, y) => x.start - y.start); + this._fillVoice(info, voice, laneUnits, barLength, group, v === 0 ? b : -1); + if (voice.beats.length === 0) { + bar.voices.pop(); + ModelUtils.appendPlaceholderVoice(bar); + } + } + } + } + + private _fillVoice( + info: TefTrackInfo, + voice: Voice, + units: TefUnit[], + barLength: number, + group: number, + recordBar: number + ): void { + let cursor = 0; + let hasContent = false; + let previous: TefDuration | null = null; + const staff = info.track.staves[0]; + for (let i = 0; i < units.length; i++) { + const u = units[i]; + if (u.start < cursor) { + continue; + } + if (u.start > cursor) { + this._addRests(voice, cursor, u.start, u.duration, previous, recordBar); + } + let end = u.end; + if (i + 1 < units.length && units[i + 1].start < end) { + end = units[i + 1].start; + } + if (end > barLength) { + end = barLength; + } + const values = + u.duration && end - u.start === u.duration.length + ? [u.duration] + : TefBarBuilder._splitPlain(u.start, 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); + if (recordBar >= 0) { + this._recordBeat(recordBar, beatStart, beat); + } + beatStart += values[k].length; + if (group !== 0) { + beat.preferredBeamDirection = group === 1 ? BeamDirection.Up : BeamDirection.Down; + } + if (!u.isRest) { + this._addNotes(info, beat, u, k > 0); + } + if (k === 0) { + this._recordColumn(u, beat); + TefBarBuilder._applyLayoutAttributes(staff, voice, beat, u); + } + } + previous = values.length > 0 ? values[values.length - 1] : previous; + hasContent = true; + cursor = end; + } + if (hasContent && cursor < barLength) { + this._addRests(voice, cursor, barLength, null, previous, recordBar); + } + } + + private _recordColumn(unit: TefUnit, beat: Beat): void { + let starts = this._columnStarts.get(unit.bar); + let beats = this._columnBeats.get(unit.bar); + if (!starts || !beats) { + starts = []; + beats = []; + this._columnStarts.set(unit.bar, starts); + this._columnBeats.set(unit.bar, beats); + } + starts.push(unit.events[0].column); + beats.push(beat); + } + + /** + * 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 & TefBarBuilder._restHiddenInNotationFlag) !== 0; + const inTab = (flags & TefBarBuilder._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 === TefBarBuilder._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; + } + + private _recordBeat(bar: number, start: number, beat: Beat): void { + let starts = this._beatStarts.get(bar); + let beats = this._beatsByBar.get(bar); + if (!starts || !beats) { + starts = []; + beats = []; + this._beatStarts.set(bar, starts); + this._beatsByBar.set(bar, beats); + } + starts.push(start); + beats.push(beat); + } + + /** + * Fills a gap with rests. Gaps off the plain grid follow the tuplet of the neighbouring notes. + */ + private _addRests( + voice: Voice, + start: number, + end: number, + next: TefDuration | null, + previous: TefDuration | null, + recordBar: number + ): 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); + if (recordBar >= 0) { + this._recordBeat(recordBar, t, beat); + } + 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 = 7; + for (const e of unit.events) { + const note = new Note(); + if (info.isPercussion) { + const articulation = PercussionMapper.getArticulationById(info.openPitches[e.stringIndex] + e.fret); + if (articulation) { + note.percussionArticulation = info.track.getOrRegisterPercussionArticulation(articulation); + } + note.fret = Number.NaN; + } else { + note.string = info.stringCount - e.stringIndex; + note.fret = e.fret; + } + note.isTieDestination = e.isTied || isContinuation; + 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; + } + } + } + if (loudest < 7) { + beat.dynamics = TefBarBuilder._dynamicsOf(loudest); + beat.showDynamics = false; + } + } + + 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] & TefBarBuilder._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) { + // the fret is given on the other string + 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(); + note.string = info.stringCount - stringIndex; + note.fret = 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; + } + + /** + * The primary effect sits in the low nibble of the third byte, effects of the second dialog + * column in the low nibble of the fifth byte; its high nibble holds the combination effect. + */ + 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] & (TefBarBuilder._graceFlag | TefBarBuilder._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 1: + case 2: + note.isHammerPullOrigin = true; + break; + case 3: + TefBarBuilder._applyBrush( + beat, + e, + unit, + true, + hasParameterByte && (parameter & 0x0f) !== 0 ? parameter & 0x07 : 4 + ); + break; + case 5: + TefBarBuilder._applyEffect(beat, note, e, unit, 5, parameter, hasParameterByte); + break; + case 6: + note.harmonicType = HarmonicType.Natural; + note.harmonicValue = ModelUtils.deltaFretToHarmonicValue(note.fret); + break; + case 7: + note.harmonicType = HarmonicType.Artificial; + note.harmonicValue = 12; + break; + case 8: + note.isLetRing = true; + break; + case 9: + note.isGhost = true; + break; + case 10: + note.isDead = true; + break; + } + if (info.isLetRing) { + note.isLetRing = true; + } + + if ((data[0] & TefBarBuilder._graceFlag) !== 0) { + switch (data[3] >> 5) { + case 4: + // the grace byte holds the fret of the auxiliary note + 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] & TefBarBuilder._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] & 0x44) { + case 0x40: + beat.ottava = Ottavia._8vb; + break; + case 0x44: + 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: { + // speeds 1 to 3 follow 8 in the stored order + 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..2ba40558df --- /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; + } + + public 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. + */ + public 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/TefFormat2Reader.ts b/packages/alphatab/src/importer/tablEdit/TefFormat2Reader.ts new file mode 100644 index 0000000000..b8d10acbbd --- /dev/null +++ b/packages/alphatab/src/importer/tablEdit/TefFormat2Reader.ts @@ -0,0 +1,663 @@ +import { FormatError } from '@coderline/alphatab/FormatError'; +import { TefCell } from '@coderline/alphatab/importer/tablEdit/TefBarBuilder'; +import { TefBinary } from '@coderline/alphatab/importer/tablEdit/TefBinary'; +import { TefReadingListItem } from '@coderline/alphatab/importer/tablEdit/TefReadingList'; +import { Logger } from '@coderline/alphatab/Logger'; + +/** + * A measure of a TablEdit 2 file. + * @internal + */ +export class TefFormat2Measure { + public numerator: number = 4; + public denominator: number = 4; + /** + * The size in 1/64 notes. + */ + public size: number = 64; + /** + * The 1/64 notes the measure is shorter than the longest measure. Its content is right-aligned. + */ + public cut: number = 0; + public key: number = 0; + public isMinor: boolean = false; +} + +/** + * Reads the blocks of a TablEdit 2 file (formats 3 to 9) and translates its grid into the cells of the + * current format, so that the bar builder and the reading list apply unchanged. + * @internal + */ +export class TefFormat2Reader { + private static readonly _stringAreaSize = 200; + private static readonly _cellsOffset = 0x100; + private static readonly _cellRecordSize = 6; + private static readonly _indexUnitsPerCell = 4; + private static readonly _indexWrap = 0x10000; + private static readonly _maxReadingListCount = 96; + private static readonly _wideReadingListMeasures = 255; + private static readonly _maxInstrumentIndex = 16; + private static readonly _patternRecordSize = 108; + private static readonly _chordRecordSize = 32; + private static readonly _instrumentRecordSize = 50; + private static readonly _instrumentNameLength = 18; + private static readonly _layoutBlockMarker = 0x1e0; + private static readonly _layoutBlockSize = 480; + private static readonly _accordionBlockMarker = 0x10; + private static readonly _wideAccordionMarker = 0x20; + private static readonly _accordionHeaderSize = 50; + private static readonly _accordionButtonsSize = 100; + private static readonly _wideAccordionButtonsSize = 200; + private static readonly _maxLabelEntry = 0x60; + private static readonly _bitmapTag = 0x101; + private static readonly _bitmapHeaderSize = 20; + private static readonly _trackSettingsFlag = 0x01; + private static readonly _trackSettingsSize = 28; + private static readonly _printSettingsFlag = 0x02; + private static readonly _extendedSettingsFlag = 0x80; + private static readonly _printSettingsSizes = [0x74, 0x74, 0x74, 0x74, 0x74, 0x74, 0x74, 0x11c, 300, 300]; + private static readonly _extendedSettingsSize = 0x28e; + private static readonly _minorKeyOffset = 8; + private static readonly _perMeasureKeyOffset = 16; + private static readonly _defaultTempo = 120; + private static readonly _maxStringCount = 12; + private static readonly _maxMeasureSize = 255; + + private static readonly _lastFretField = 0x19; + private static readonly _restCell = 0x1a; + private static readonly _measureCell = 0x1b; + private static readonly _chordCell = 0x1c; + private static readonly _textCell = 0x1d; + private static readonly _accentCell = 0x1e; + private static readonly _crescendoCell = 0x9e; + private static readonly _connectionCell = 0x3d; + private static readonly _strokeCell = 0x3e; + private static readonly _readingGuideCell = 0x5e; + private static readonly _lineBreakCell = 0x9d; + private static readonly _symbolCell = 0xdd; + private static readonly _tempoCell = 0xfe; + private static readonly _syncopationCell = 0xfd; + private static readonly _patchCell = 0x7e; + private static readonly _bitmapTextFont = 8; + + private static readonly _noteKindMask = 0xe0; + private static readonly _fingeringKind = 0x20; + private static readonly _graceKind = 0x40; + private static readonly _fontKind = 0x60; + private static readonly _attributeKind = 0x80; + private static readonly _pitchChangeKind = 0xa0; + private static readonly _gracePitchKind = 0xc0; + private static readonly _graceExtendedKind = 0xe0; + private static readonly _graceFlag = 0x40; + private static readonly _pitchChangeFlag = 0x80; + private static readonly _voiceMask = 0x30; + private static readonly _stabiloVoice = 0x10; + private static readonly _stabiloFlag = 0x10; + + private static readonly _noBeamAttribute = 0x0b; + private static readonly _noStemAttribute = 0x0c; + private static readonly _ottavaBassaAttribute = 0xc0; + private static readonly _ottavaAltaAttribute = 0xd0; + private static readonly _noBeamFlag = 0x08; + private static readonly _noStemFlag = 0x04; + private static readonly _ottavaBassaFlag = 0x40; + private static readonly _ottavaAltaFlag = 0x44; + private static readonly _restHiddenInNotationBits = 0x42; + private static readonly _restHiddenInTabBit = 0x10; + private static readonly _restHiddenInNotationFlag = 0x01; + private static readonly _restHiddenInTabFlag = 0x20; + private static readonly _symbolNumberOffset = 100; + private static readonly _securityCodeOffset = 208; + private static readonly _defaultCrescendoAmount = 16; + + private static readonly _currentRest = 0x33; + private static readonly _currentChord = 0x35; + private static readonly _currentLineBreak = 0x36; + private static readonly _currentAccent = 0x37; + private static readonly _currentCrescendo = 0x38; + private static readonly _currentText = 0x39; + private static readonly _currentSymbol = 0xb6; + private static readonly _currentReadingGuide = 0xb7; + private static readonly _currentRecordSize = 68; + private static readonly _currentDrumFlag = 0x80; + private static readonly _drumOutput = 89; + private static readonly _displayModeMask = 0x30; + + private _data: TefBinary; + private _maxStringLength: number; + private _p: number = 0; + private _cells: TefCell[] = []; + + public title: string = ''; + public author: string = ''; + public comment: string = ''; + public notes: string = ''; + public lyrics: string = ''; + public tempo: number = TefFormat2Reader._defaultTempo; + public syncopation: number = 0; + public version: number = 0; + public totalStrings: number = 0; + public measures: TefFormat2Measure[] = []; + public texts: string[] = []; + public chordsOffset: number = 0; + public chordCount: number = 0; + public readonly chordRecordSize: number = TefFormat2Reader._chordRecordSize; + /** + * The instrument records in the layout of the current format. + */ + public instruments: TefBinary = new TefBinary(new Uint8Array(0)); + public instrumentCount: number = 0; + public readonly instrumentRecordSize: number = TefFormat2Reader._currentRecordSize; + public readingList: TefReadingListItem[] = []; + + public constructor(data: TefBinary, maxStringLength: number) { + this._data = data; + this._maxStringLength = maxStringLength; + } + + public get cells(): TefCell[] { + return this._cells; + } + + public read(): void { + const d = this._data; + if (d.u16(TefFormat2Reader._securityCodeOffset) !== 0) { + throw new FormatError('Protected TablEdit files are not supported'); + } + this._readHeaderStrings(); + const measureCount = d.u16(200); + const numerator = d.u16(202); + const denominator = d.u8(204); + const format = d.u8(205); + const longestMeasure = (64 / denominator) * numerator; + let key = d.s16(206); + const printOptions = d.u8(214); + this.tempo = d.u16(220) > 0 ? d.u16(220) : TefFormat2Reader._defaultTempo; + const readingListCount = d.u8(222); + this.syncopation = d.s8(224); + const labelCount = d.u8(225); + const hasLayout = d.u16(226) === TefFormat2Reader._layoutBlockMarker; + const textCount = d.u16(228); + const accordion = d.u8(232); + const drumPatterns = d.u8(234); + const rhythmPatterns = d.u8(235); + this.chordCount = d.u16(236); + const hasNotes = d.u8(238) !== 0; + this.totalStrings = d.u8(240); + const lastInstrument = d.u8(241); + const hasMeasureFlags = d.u16(244) !== 0; + this.version = d.u16(246); + const hasBitmap = (d.u8(248) & 0x01) !== 0; + const hasLyrics = d.u8(250) !== 0; + + if (measureCount === 0) { + throw new FormatError('TablEdit file without measures'); + } + if (longestMeasure > TefFormat2Reader._maxMeasureSize) { + throw new FormatError(`TablEdit 2 measures of ${longestMeasure} 1/64 notes are not supported`); + } + if (this.totalStrings === 0 || lastInstrument > TefFormat2Reader._maxInstrumentIndex) { + throw new FormatError('TablEdit file without instruments'); + } + + const hasMeasureKeys = Math.abs(key) >= TefFormat2Reader._perMeasureKeyOffset; + if (hasMeasureKeys) { + key += key > 0 ? -TefFormat2Reader._perMeasureKeyOffset : TefFormat2Reader._perMeasureKeyOffset; + } + const isMinor = Math.abs(key) >= TefFormat2Reader._minorKeyOffset; + if (isMinor) { + key += key > 0 ? -TefFormat2Reader._minorKeyOffset : TefFormat2Reader._minorKeyOffset; + } + for (let i = 0; i < measureCount; i++) { + const measure = new TefFormat2Measure(); + measure.numerator = numerator; + measure.denominator = denominator; + measure.size = longestMeasure; + measure.key = key; + measure.isMinor = isMinor; + this.measures.push(measure); + } + + this._p = TefFormat2Reader._cellsOffset; + const raw = this._readRawCells(longestMeasure); + this._readReadingList(readingListCount, measureCount); + for (let i = 0; i < textCount; i++) { + this.texts.push(this._readString()); + } + if (drumPatterns > 0) { + Logger.warning('TablEdit', 'Drum patterns of TablEdit 2 files are not imported'); + this._skip(drumPatterns * TefFormat2Reader._patternRecordSize + measureCount); + } + this.chordsOffset = this._p; + this._skip(this.chordCount * TefFormat2Reader._chordRecordSize); + if (rhythmPatterns > 0) { + Logger.warning('TablEdit', 'Rhythm patterns of TablEdit 2 files are not imported'); + this._skip(rhythmPatterns * TefFormat2Reader._patternRecordSize + measureCount); + } + if (hasNotes) { + this.notes = this._readString(); + } + this._readInstruments(lastInstrument + 1, hasLayout); + if (hasMeasureKeys) { + for (let i = 0; i < measureCount; i++) { + this.measures[i].key = d.s8(this._p + i); + } + this._skip(measureCount); + } + if (hasLayout) { + this._skip(TefFormat2Reader._layoutBlockSize); + } + if (accordion >= TefFormat2Reader._accordionBlockMarker) { + this._skip( + TefFormat2Reader._accordionHeaderSize + + (accordion < TefFormat2Reader._wideAccordionMarker + ? TefFormat2Reader._accordionButtonsSize + : TefFormat2Reader._wideAccordionButtonsSize) + ); + } + if (hasMeasureFlags) { + this._skip(measureCount * 2); + } + if ((printOptions & TefFormat2Reader._trackSettingsFlag) !== 0) { + this._skip(TefFormat2Reader._trackSettingsSize); + } + if ((printOptions & TefFormat2Reader._printSettingsFlag) !== 0) { + this._skip(TefFormat2Reader._printSettingsSize(format)); + } + this._readLabels(labelCount); + if (hasBitmap) { + this._skipBitmap(); + } + if (hasLyrics) { + this.lyrics = this._readString(); + } + if ((printOptions & TefFormat2Reader._extendedSettingsFlag) !== 0) { + const size = d.u16(this._p); + this._skip(Math.max(size, TefFormat2Reader._extendedSettingsSize)); + } + + this._applyMeasureChanges(raw, longestMeasure); + this._convertCells(raw); + } + + private static _printSettingsSize(format: number): number { + return TefFormat2Reader._printSettingsSizes[Math.min(format, TefFormat2Reader._printSettingsSizes.length - 1)]; + } + + private _readHeaderStrings(): void { + const strings: string[] = []; + let start = 0; + for (let i = 0; i < 3; i++) { + let end = start; + while (end < TefFormat2Reader._stringAreaSize && this._data.u8(end) !== 0) { + end++; + } + strings.push(TefBinary.decode(this._data.bytes.subarray(start, end))); + start = Math.min(end + 1, TefFormat2Reader._stringAreaSize); + } + this.title = strings[0]; + this.author = strings[1]; + this.comment = strings[2]; + } + + private _skip(length: number): void { + this._data.requireRange(this._p, length); + this._p += length; + } + + private _readString(): string { + const value = this._data.readString(this._p, this._maxStringLength); + this._p += 2 + this._data.u16(this._p); + return value; + } + + /** + * Reads the grid records as measure, row, position and the four data bytes. + */ + private _readRawCells(longestMeasure: number): number[] { + const d = this._data; + const count = d.u16(this._p); + this._p += 2; + d.requireRange(this._p, count * TefFormat2Reader._cellRecordSize); + const raw: number[] = []; + const measureSlots = longestMeasure * this.totalStrings; + let wrap = 0; + let previous = 0; + for (let i = 0; i < count; i++) { + const stored = d.u16(this._p); + if (stored < previous) { + wrap += TefFormat2Reader._indexWrap; + } + previous = stored; + const slot = Math.floor((stored + wrap) / TefFormat2Reader._indexUnitsPerCell); + const measure = Math.floor(slot / measureSlots); + const inMeasure = slot % measureSlots; + raw.push(measure); + raw.push(Math.floor(inMeasure / longestMeasure)); + raw.push(inMeasure % longestMeasure); + for (let k = 2; k < TefFormat2Reader._cellRecordSize; k++) { + raw.push(d.u8(this._p + k)); + } + this._p += TefFormat2Reader._cellRecordSize; + } + return raw; + } + + private _readReadingList(count: number, measureCount: number): void { + if (count === 0 || count > TefFormat2Reader._maxReadingListCount) { + return; + } + const wide = measureCount > TefFormat2Reader._wideReadingListMeasures; + for (let i = 0; i < count; i++) { + const item = new TefReadingListItem(); + if (wide) { + item.first = this._data.u16(this._p) - 1; + item.last = this._data.u16(this._p + 2) - 1; + this._p += 4; + } else { + item.first = this._data.u8(this._p) - 1; + item.last = this._data.u8(this._p + 1) - 1; + this._p += 2; + } + this.readingList.push(item); + } + } + + private _readLabels(count: number): void { + for (let i = 0; i < count; i++) { + const length = this._data.u16(this._p); + const entry = this._data.u16(this._p + 2); + this._p += 4; + if (length > 0 && entry < TefFormat2Reader._maxLabelEntry) { + const label = this._data.readCString(this._p, length); + if (entry < this.readingList.length) { + this.readingList[entry].label = label; + } + this._p += length; + } + } + } + + private _skipBitmap(): void { + const size = this._data.u32(this._p); + const tag = this._data.u32(this._p + 4); + this._p += 8; + if (tag === TefFormat2Reader._bitmapTag) { + const height = this._data.u32(this._p + 8); + const rowBytes = this._data.u32(this._p + 12); + const pixels = height * rowBytes; + this._skip(TefFormat2Reader._bitmapHeaderSize); + this._skip(Math.max(0, size - pixels - TefFormat2Reader._bitmapHeaderSize) + pixels); + } + } + + /** + * Copies the instrument records into the record layout of the current format. + */ + private _readInstruments(count: number, hasLayout: boolean): void { + const size = TefFormat2Reader._currentRecordSize; + const records = new Uint8Array(count * size); + this._data.requireRange(this._p, count * TefFormat2Reader._instrumentRecordSize); + let strings = 0; + for (let i = 0; i < count; i++) { + const source = this._p; + const target = i * size; + records.set(this._data.bytes.subarray(source, source + TefFormat2Reader._instrumentRecordSize), target); + const stringCount = this._data.u16(source); + TefBinary.ensureLoopBoundary(stringCount, TefFormat2Reader._maxStringCount, 'string count'); + strings += stringCount; + records[target + 8] &= 0x7f; + const middleC = this._data.s8(source + 14); + if (middleC !== 24 && middleC !== 12 && middleC !== -12) { + records[target + 14] = 0; + } + const clef = this._data.u8(source + 15); + if (clef < 1 || clef > 24) { + records[target + 15] = 0; + } + if (!hasLayout) { + // without a layout block every instrument shows notation and tablature + records[target + 16] &= ~TefFormat2Reader._displayModeMask; + } + records[target + 4] = + this._data.s16(source + 6) > TefFormat2Reader._drumOutput ? TefFormat2Reader._currentDrumFlag : 0; + records.fill(0, target + 32 + TefFormat2Reader._instrumentNameLength, target + size); + this._p += TefFormat2Reader._instrumentRecordSize; + } + if (strings !== this.totalStrings) { + throw new FormatError(`TablEdit instruments have ${strings} strings instead of ${this.totalStrings}`); + } + this.instruments = new TefBinary(records); + this.instrumentCount = count; + } + + /** + * Measures shorter than the longest one carry their size and denominator in a cell at their start. + */ + private _applyMeasureChanges(raw: number[], longestMeasure: number): void { + for (let i = 0; i < raw.length; i += 7) { + const measure = raw[i]; + const kind: number = raw[i + 3] & 0x1f; + if ( + kind !== TefFormat2Reader._measureCell || + raw[i + 1] !== 0 || + raw[i + 2] >= 2 || + measure >= this.measures.length + ) { + continue; + } + const cut = raw[i + 4]; + const denominatorCode = raw[i + 3] >> 5; + const m = this.measures[measure]; + if (cut >= longestMeasure) { + continue; + } + let denominator = denominatorCode > 0 ? 1 << (denominatorCode - 1) : m.denominator; + const size = longestMeasure - cut; + while (denominator < 64 && size % (64 / denominator) !== 0) { + denominator *= 2; + } + m.cut = cut; + m.size = size; + m.denominator = denominator; + m.numerator = size / (64 / denominator); + } + } + + private _convertCells(raw: number[]): void { + const starts: number[] = []; + let position = 0; + for (const m of this.measures) { + starts.push(position); + position += m.size; + } + const byLocation = new Map(); + for (let i = 0; i < raw.length; i += 7) { + const measure = raw[i]; + const row = raw[i + 1]; + const column = raw[i + 2]; + if (measure >= this.measures.length || row >= this.totalStrings) { + continue; + } + const m = this.measures[measure]; + if (column < m.cut) { + continue; + } + const cellPosition = starts[measure] + column - m.cut; + const b1 = raw[i + 3]; + const b2 = raw[i + 4]; + const b3 = raw[i + 5]; + const b4 = raw[i + 6]; + if (b1 === TefFormat2Reader._strokeCell && b2 === 0 && b3 === 0) { + const previous = byLocation.get(`${row}:${cellPosition - 1}`); + if (previous && TefFormat2Reader._isNote(previous) && previous.data[6] === 0) { + previous.data[6] = b4; + continue; + } + } + const data = TefFormat2Reader._convert(b1, b2, b3, b4); + if (data) { + const cell = new TefCell(); + cell.position = cellPosition; + cell.row = row; + cell.data = data; + this._cells.push(cell); + byLocation.set(`${row}:${cellPosition}`, cell); + } + } + } + + private static _isNote(cell: TefCell): boolean { + return (cell.data[0] & 0x3f) < TefFormat2Reader._currentRest; + } + + /** + * The eight cell bytes of the current format for a 2.x cell, null for cells without a counterpart. + */ + private static _convert(b1: number, b2: number, b3: number, b4: number): Uint8Array | null { + const fretField = b1 & 0x1f; + if (fretField > 0 && fretField <= TefFormat2Reader._lastFretField) { + return TefFormat2Reader._convertNote(b1, b2, b3, b4); + } + const data = new Uint8Array(8); + switch (b1) { + case TefFormat2Reader._restCell: + data[0] = TefFormat2Reader._currentRest; + data[1] = b2; + data[2] = b3 & TefFormat2Reader._voiceMask; + if ((b3 & TefFormat2Reader._restHiddenInNotationBits) !== 0) { + data[7] |= TefFormat2Reader._restHiddenInNotationFlag; + } + if ((b3 & TefFormat2Reader._restHiddenInTabBit) !== 0) { + data[7] |= TefFormat2Reader._restHiddenInTabFlag; + } + return data; + case TefFormat2Reader._chordCell: + data[0] = TefFormat2Reader._currentChord; + data[1] = b2; + return data; + case TefFormat2Reader._textCell: { + const font: number = b3 & 0x0f; + if (font === TefFormat2Reader._bitmapTextFont) { + return null; + } + data[0] = TefFormat2Reader._currentText; + data[1] = b2; + return data; + } + case TefFormat2Reader._accentCell: + data[0] = TefFormat2Reader._currentAccent; + return data; + case TefFormat2Reader._crescendoCell: + data[0] = TefFormat2Reader._currentCrescendo; + data[1] = b2; + data[2] = ((b2 & 0x0f) * 4 + b3) * 4; + data[6] = TefFormat2Reader._defaultCrescendoAmount; + return data; + case TefFormat2Reader._connectionCell: + data[0] = b1; + data[1] = b2 & 0x3f; + data[2] = b3; + data[3] = b4; + return data; + case TefFormat2Reader._strokeCell: + data[0] = b1; + data[3] = b3; + data[4] = b2; + data[6] = b4; + return data; + case TefFormat2Reader._readingGuideCell: + data[0] = TefFormat2Reader._currentReadingGuide; + data[2] = b3; + return data; + case TefFormat2Reader._lineBreakCell: + data[0] = TefFormat2Reader._currentLineBreak; + data[5] = b4 & 0x0f; + data[7] = b2; + return data; + case TefFormat2Reader._symbolCell: + data[0] = TefFormat2Reader._currentSymbol; + data[1] = ((b2 & 0x7f) + TefFormat2Reader._symbolNumberOffset) & 0xff; + return data; + case TefFormat2Reader._tempoCell: + case TefFormat2Reader._syncopationCell: + case TefFormat2Reader._patchCell: + data[0] = b1; + data[1] = b2; + data[2] = b3; + data[3] = b4; + return data; + } + return null; + } + + /** + * Notes keep fret, duration, dynamics, effect, voice and accidental. The top bits of the first byte say + * what the fourth byte holds. + */ + private static _convertNote(b1: number, b2: number, b3: number, b4: number): Uint8Array { + const data = new Uint8Array(8); + let fretField = b1 & 0x1f; + data[1] = b2; + data[2] = b3; + const voice: number = b3 & TefFormat2Reader._voiceMask; + if (voice === TefFormat2Reader._stabiloVoice) { + data[2] = b3 & ~TefFormat2Reader._voiceMask; + data[5] = TefFormat2Reader._stabiloFlag; + } + let flags = 0; + switch (b1 & TefFormat2Reader._noteKindMask) { + case TefFormat2Reader._fingeringKind: + data[6] = b4; + break; + case TefFormat2Reader._graceKind: + flags = TefFormat2Reader._graceFlag; + data[3] = b4; + break; + case TefFormat2Reader._fontKind: + break; + case TefFormat2Reader._attributeKind: + TefFormat2Reader._applyAttribute(data, b4); + break; + case TefFormat2Reader._pitchChangeKind: + case TefFormat2Reader._graceExtendedKind: + flags = b1 & (TefFormat2Reader._graceFlag | TefFormat2Reader._pitchChangeFlag); + if (fretField === TefFormat2Reader._lastFretField) { + fretField += b4 & 0x1f; + flags &= ~TefFormat2Reader._pitchChangeFlag; + } else { + data[3] = b4; + } + break; + case TefFormat2Reader._gracePitchKind: + flags = TefFormat2Reader._graceFlag | TefFormat2Reader._pitchChangeFlag; + data[3] = b4; + break; + default: + data[3] = b4; + break; + } + data[0] = fretField | flags; + return data; + } + + private static _applyAttribute(data: Uint8Array, value: number): void { + switch (value) { + case TefFormat2Reader._noBeamAttribute: + data[7] = TefFormat2Reader._noBeamFlag; + break; + case TefFormat2Reader._noStemAttribute: + data[7] = TefFormat2Reader._noStemFlag; + break; + case TefFormat2Reader._ottavaBassaAttribute: + data[7] = TefFormat2Reader._ottavaBassaFlag; + break; + case TefFormat2Reader._ottavaAltaAttribute: + data[7] = TefFormat2Reader._ottavaAltaFlag; + break; + default: + data[4] = value; + break; + } + } +} diff --git a/packages/alphatab/src/importer/tablEdit/TefReadingList.ts b/packages/alphatab/src/importer/tablEdit/TefReadingList.ts new file mode 100644 index 0000000000..02683b8236 --- /dev/null +++ b/packages/alphatab/src/importer/tablEdit/TefReadingList.ts @@ -0,0 +1,290 @@ +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; +} + +/** + * 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; + + /** + * 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 < 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; + } + + private static _derive(bars: MasterBar[], runs: number[][]): void { + let furthest = -1; + let lastRepeatEnd = -1; + let jump: MasterBar | null = null; + let jumpToStart = false; + let segnoCount = 0; + let codaCount = 0; + let i = 0; + while (i < runs.length) { + const last = runs[i][1]; + furthest = Math.max(furthest, last); + if (i + 1 >= runs.length) { + // the list stops before the end of the piece + if (jump && last < bars.length - 1) { + bars[last].addDirection(Direction.TargetFine); + TefReadingList._replaceJump(jump, Direction.TargetFine); + } + break; + } + const nextFirst = runs[i + 1][0]; + const nextLast = runs[i + 1][1]; + + if (nextFirst <= last) { + if (!jump && last === furthest && nextFirst > lastRepeatEnd) { + bars[nextFirst].isRepeatStart = true; + lastRepeatEnd = last; + 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; + i = k; + continue; + } + bars[last].repeatCount = 2; + if (nextLast < last) { + const endingLength = last - nextLast; + for (let b = nextLast + 1; b <= last; b++) { + bars[b].alternateEndings |= 0x01; + } + if (i + 2 < runs.length && runs[i + 2][0] === last + 1) { + const end = Math.min(runs[i + 2][1], last + endingLength); + for (let b = last + 1; b <= end; b++) { + bars[b].alternateEndings |= 0x02; + } + } + i += 2; + continue; + } + // the second pass continues after the repeat + runs[i + 1][0] = last + 1; + i++; + continue; + } + + jump = bars[last]; + jumpToStart = nextFirst === 0; + if (jumpToStart) { + jump.addDirection(Direction.JumpDaCapo); + } else { + bars[nextFirst].addDirection(segnoCount === 0 ? Direction.TargetSegno : Direction.TargetSegnoSegno); + jump.addDirection(segnoCount === 0 ? Direction.JumpDalSegno : Direction.JumpDalSegnoSegno); + segnoCount++; + } + i++; + continue; + } + + if (nextFirst > last + 1 && jump) { + bars[last].addDirection(codaCount === 0 ? Direction.JumpDaCoda : Direction.JumpDaDoubleCoda); + bars[nextFirst].addDirection(codaCount === 0 ? Direction.TargetCoda : Direction.TargetDoubleCoda); + TefReadingList._replaceJump(jump, codaCount === 0 ? Direction.TargetCoda : Direction.TargetDoubleCoda); + codaCount++; + } + i++; + } + } + + /** + * 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/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-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/v2/effects.tef b/packages/alphatab/test-data/tabledit/v2/effects.tef new file mode 100644 index 0000000000..0b1181938a Binary files /dev/null and b/packages/alphatab/test-data/tabledit/v2/effects.tef differ diff --git a/packages/alphatab/test-data/tabledit/v2/effects.v3.tef b/packages/alphatab/test-data/tabledit/v2/effects.v3.tef new file mode 100644 index 0000000000..37172374ec Binary files /dev/null and b/packages/alphatab/test-data/tabledit/v2/effects.v3.tef differ diff --git a/packages/alphatab/test-data/tabledit/v2/instruments.tef b/packages/alphatab/test-data/tabledit/v2/instruments.tef new file mode 100644 index 0000000000..756dd95c69 Binary files /dev/null and b/packages/alphatab/test-data/tabledit/v2/instruments.tef differ diff --git a/packages/alphatab/test-data/tabledit/v2/instruments.v3.tef b/packages/alphatab/test-data/tabledit/v2/instruments.v3.tef new file mode 100644 index 0000000000..0143a4dc7f Binary files /dev/null and b/packages/alphatab/test-data/tabledit/v2/instruments.v3.tef differ diff --git a/packages/alphatab/test-data/tabledit/v2/markers.tef b/packages/alphatab/test-data/tabledit/v2/markers.tef new file mode 100644 index 0000000000..fb4556c66d Binary files /dev/null and b/packages/alphatab/test-data/tabledit/v2/markers.tef differ diff --git a/packages/alphatab/test-data/tabledit/v2/markers.v3.tef b/packages/alphatab/test-data/tabledit/v2/markers.v3.tef new file mode 100644 index 0000000000..d00777ab6e Binary files /dev/null and b/packages/alphatab/test-data/tabledit/v2/markers.v3.tef differ diff --git a/packages/alphatab/test-data/tabledit/v2/measures.tef b/packages/alphatab/test-data/tabledit/v2/measures.tef new file mode 100644 index 0000000000..30b0403d3d Binary files /dev/null and b/packages/alphatab/test-data/tabledit/v2/measures.tef differ diff --git a/packages/alphatab/test-data/tabledit/v2/measures.v3.tef b/packages/alphatab/test-data/tabledit/v2/measures.v3.tef new file mode 100644 index 0000000000..4208cda94a Binary files /dev/null and b/packages/alphatab/test-data/tabledit/v2/measures.v3.tef differ diff --git a/packages/alphatab/test-data/tabledit/v2/notes.tef b/packages/alphatab/test-data/tabledit/v2/notes.tef new file mode 100644 index 0000000000..c72ba626f7 Binary files /dev/null and b/packages/alphatab/test-data/tabledit/v2/notes.tef differ diff --git a/packages/alphatab/test-data/tabledit/v2/notes.v3.tef b/packages/alphatab/test-data/tabledit/v2/notes.v3.tef new file mode 100644 index 0000000000..f5986370c0 Binary files /dev/null and b/packages/alphatab/test-data/tabledit/v2/notes.v3.tef differ diff --git a/packages/alphatab/test-data/tabledit/v2/probes.tef b/packages/alphatab/test-data/tabledit/v2/probes.tef new file mode 100644 index 0000000000..c5584cbeb7 Binary files /dev/null and b/packages/alphatab/test-data/tabledit/v2/probes.tef differ diff --git a/packages/alphatab/test-data/tabledit/v2/probes.v3.tef b/packages/alphatab/test-data/tabledit/v2/probes.v3.tef new file mode 100644 index 0000000000..8a02ef0515 Binary files /dev/null and b/packages/alphatab/test-data/tabledit/v2/probes.v3.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/exporter/AlphaTexExporter.test.ts b/packages/alphatab/test/exporter/AlphaTexExporter.test.ts index 20d839f2b7..c42583b46f 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,26 @@ 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 + await testRoundTripEqual(`visual-tests/tabledit/${file}`, ['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 + await testRoundTripEqual( + `tabledit/${file}`, + file === 'drums.tef' ? ['showdynamics', 'linebreaks', 'string'] : ['showdynamics', 'linebreaks'] + ); + } + }); + it('gp4-to-alphaTex', async () => { await testRoundTripEqual(`guitarpro4/fade-to-black.gp4`); }); diff --git a/packages/alphatab/test/importer/TablEdit2Importer.test.ts b/packages/alphatab/test/importer/TablEdit2Importer.test.ts new file mode 100644 index 0000000000..3c8024f757 --- /dev/null +++ b/packages/alphatab/test/importer/TablEdit2Importer.test.ts @@ -0,0 +1,401 @@ +import { FormatError } from '@coderline/alphatab/FormatError'; +import { ScoreLoader } from '@coderline/alphatab/importer/ScoreLoader'; +import { TablEditImporter } from '@coderline/alphatab/importer/TablEditImporter'; +import { ByteBuffer } from '@coderline/alphatab/io/ByteBuffer'; +import { AccentuationType } from '@coderline/alphatab/model/AccentuationType'; +import { AutomationType } from '@coderline/alphatab/model/Automation'; +import { type Beat, BeatBeamingMode } from '@coderline/alphatab/model/Beat'; +import { BrushType } from '@coderline/alphatab/model/BrushType'; +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 { Fingers } from '@coderline/alphatab/model/Fingers'; +import { GraceType } from '@coderline/alphatab/model/GraceType'; +import { JsonConverter } from '@coderline/alphatab/model/JsonConverter'; +import { KeySignature } from '@coderline/alphatab/model/KeySignature'; +import { KeySignatureType } from '@coderline/alphatab/model/KeySignatureType'; +import { Ottavia } from '@coderline/alphatab/model/Ottavia'; +import { PickStroke } from '@coderline/alphatab/model/PickStroke'; +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 { Settings } from '@coderline/alphatab/Settings'; +import { ComparisonHelpers } from 'test/model/ComparisonHelpers'; +import { TestPlatform } from 'test/TestPlatform'; +import { describe, expect, it } from 'vitest'; + +/** + * The files in test-data/tabledit/v2 are TablEdit 2 files (format 3) written byte by byte for these tests + * (6-string guitar in standard tuning unless noted). Each `.v3.tef` is the same file opened in + * TablEdit 3.06 and saved again, so the TablEdit 2 import must equal the import of TablEdit's conversion. + * String rows count from the highest string of the first instrument, the model counts strings from the lowest. + */ +describe('TablEdit2ImporterTest', () => { + const formatOffset = 205; + + function loadData(name: string): Promise { + return TestPlatform.loadFile(`test-data/tabledit/v2/${name}.tef`); + } + + function readBytes(data: Uint8Array): Score { + const importer = new TablEditImporter(); + importer.init(ByteBuffer.fromBuffer(data), new Settings()); + return importer.readScore(); + } + + async function load(name: string): Promise { + const score = ScoreLoader.loadScoreFromBytes(await loadData(name), new Settings()); + expectBarsFilled(score); + return score; + } + + function expectBarsFilled(score: Score) { + for (const track of score.tracks) { + for (const bar of track.staves[0].bars) { + for (const voice of bar.voices) { + if (voice.isEmpty) { + continue; + } + let duration = 0; + for (const b of voice.beats) { + duration += b.playbackDuration; + } + expect(duration, `bar ${bar.index} voice ${voice.index}`).toBe(bar.masterBar.calculateDuration()); + } + } + } + } + + function beats(score: Score, bar: number, voice: number = 0, track: number = 0): Beat[] { + return score.tracks[track].staves[0].bars[bar].voices[voice].beats; + } + + function noteNames(beat: Beat): string[] { + return beat.notes.map(n => `${n.fret}.${n.string}`).sort(); + } + + describe('format', () => { + it('loads-format-2', async () => { + const score = await load('notes'); + expect(score.masterBars.length).toBe(4); + expect(score.tracks.length).toBe(1); + }); + + it('formats-3-to-9', async () => { + const expected = JsonConverter.scoreToJsObject(readBytes(await loadData('markers'))); + for (let format = 4; format <= 9; format++) { + const data = await loadData('markers'); + data[formatOffset] = format; + ComparisonHelpers.expectJsonEqual( + expected, + JsonConverter.scoreToJsObject(readBytes(data)), + ``, + null + ); + } + }); + + it('rejects-older-formats', async () => { + for (const format of [1, 2]) { + const data = await loadData('notes'); + data[formatOffset] = format; + expect(() => readBytes(data), `format ${format}`).toThrow(FormatError); + } + }); + + it('truncated', async () => { + const data = await loadData('markers'); + for (const length of [300, data.length - 40, data.length - 1]) { + expect(() => readBytes(data.slice(0, length)), `${length} bytes`).toThrow(FormatError); + } + }); + + it('string-count-mismatch', async () => { + const data = await loadData('notes'); + data[240] = 7; + expect(() => readBytes(data)).toThrow(FormatError); + }); + + it('skips-patterns', async () => { + const plain = await loadData('notes'); + const expected = JsonConverter.scoreToJsObject(readBytes(plain)); + // notes.tef has neither texts nor chords, so both pattern blocks follow the reading list + const patternsAt = 256 + 2 + (plain[256] | (plain[257] << 8)) * 6 + 2; + const patternBlock = 108 + 4; + const data = new Uint8Array(plain.length + 2 * patternBlock); + data.set(plain.subarray(0, patternsAt), 0); + data.set(plain.subarray(patternsAt, plain.length), patternsAt + 2 * patternBlock); + data[234] = 1; + data[235] = 1; + ComparisonHelpers.expectJsonEqual( + expected, + JsonConverter.scoreToJsObject(readBytes(data)), + '', + null + ); + }); + + it('protected', async () => { + const data = await loadData('notes'); + // security code (u16 at 208), non-zero when TablEdit 2 protects the file + data[208] = 0x68; + data[209] = 0x16; + expect(() => readBytes(data)).toThrow(FormatError); + }); + + it('longest-measure', async () => { + const data = await loadData('notes'); + data[202] = 0; + data[203] = 1; + data[204] = 64; + expect(() => readBytes(data)).toThrow(FormatError); + }); + }); + + describe('content', () => { + it('notes', async () => { + const score = await load('notes'); + expect(score.title).toBe('Notes café'); + expect(score.subTitle).toBe('alphaTab'); + expect(score.instructions).toBe('Synthetic TablEdit 2 file'); + expect(score.notices).toBe('Notes text'); + expect(score.tempo).toBe(100); + // TablEdit 3 keeps a TablEdit 2 name as it is, an abbreviation needs its own place in the record + expect(score.tracks[0].name).toBe('Guitar^Gt.'); + expect(score.tracks[0].shortName).toBe('Guitar^Gt.'); + + const bar0 = beats(score, 0); + expect(bar0.map(b => noteNames(b))).toEqual([['3.1'], ['2.2'], ['0.3'], ['1.4', '1.5']]); + expect(bar0.every(b => b.duration === Duration.Quarter)).toBe(true); + + const bar1 = beats(score, 1); + expect(bar1.map(b => (b.isRest ? 'r' : noteNames(b).join(' ')))).toEqual([ + '0.6', + '1.5', + '2.4', + '3.3', + 'r', + '5.4', + '7.4' + ]); + expect(bar1.map(b => b.duration)).toEqual([ + Duration.Eighth, + Duration.Eighth, + Duration.Eighth, + Duration.Eighth, + Duration.Quarter, + Duration.Eighth, + Duration.Sixteenth + ]); + expect(bar1[5].dots).toBe(1); + + const bar2 = beats(score, 2); + expect(bar2.map(b => b.duration)).toEqual([Duration.Half, Duration.Half]); + expect(bar2[0].dynamics).toBe(DynamicValue.P); + expect(bar2[1].notes[0].isTieDestination).toBe(true); + expect(bar2[1].notes[0].tieOrigin).toBe(bar2[0].notes[0]); + + const bar3 = beats(score, 3); + expect(bar3.map(b => noteNames(b))).toEqual([['0.4'], ['2.4'], ['4.4'], ['2.3'], ['0.1']]); + expect(bar3.slice(0, 3).every(b => b.tupletNumerator === 3 && b.tupletDenominator === 2)).toBe(true); + expect(bar3.map(b => b.dynamics)).toEqual([ + DynamicValue.FFF, + DynamicValue.FFF, + DynamicValue.FFF, + DynamicValue.F, + DynamicValue.PP + ]); + }); + + it('effects', async () => { + const score = await load('effects'); + const bar0 = beats(score, 0, 1); + expect(bar0[0].notes[0].isHammerPullOrigin).toBe(true); + expect(bar0[2].notes[0].isPalmMute).toBe(true); + const chord = bar0[3].notes.sort((a, b) => a.string - b.string); + expect(chord[0].isDead).toBe(true); + expect(chord[1].vibrato).toBe(VibratoType.Slight); + + const bar1 = beats(score, 1, 1); + expect(bar1.map(b => b.graceType)).toEqual([ + GraceType.OnBeat, + GraceType.None, + GraceType.OnBeat, + GraceType.None, + GraceType.None, + GraceType.None + ]); + expect(bar1.map(b => noteNames(b).join(' '))).toEqual(['5.5', '7.5', '3.5', '5.5', '5.4', '2.3']); + expect(bar1[0].notes[0].isHammerPullOrigin).toBe(true); + expect(bar1[2].notes[0].slideOutType).toBe(SlideOutType.Legato); + expect(bar1[4].notes[0].leftHandFinger).toBe(Fingers.IndexFinger); + expect(bar1[5].pickStroke).toBe(PickStroke.Down); + + const middle = beats(score, 2, 1); + expect(middle[0].ottava).toBe(Ottavia._8vb); + expect(middle[1].notes[0].bendPoints!.map(p => [p.offset, p.value])).toEqual([ + [0, 4], + [60, 4] + ]); + expect(noteNames(beats(score, 2, 0)[1])).toEqual(['1.5']); + expect(beats(score, 2, 2).map(b => (b.isRest ? 'r' : noteNames(b).join(' ')))).toEqual(['r', '0.1', '3.1']); + }); + + it('markers', async () => { + const score = await load('markers'); + const staff = score.tracks[0].staves[0]; + expect(staff.bars.every(b => b.keySignature === KeySignature.G)).toBe(true); + + const bar0 = beats(score, 0); + expect(staff.chords!.get(bar0[0].chordId!)!.name).toBe('C'); + expect(staff.chords!.get(bar0[0].chordId!)!.strings).toEqual([0, 1, 0, 2, 3, -1]); + expect(bar0[0].text).toBe('Intro'); + expect(score.masterBars[0].tempoAutomations.map(a => [a.ratioPosition, a.value])).toEqual([ + [0, 120], + [0.25, 80] + ]); + expect(bar0[2].notes[0].accentuated).toBe(AccentuationType.Normal); + expect(bar0[3].fermata).not.toBe(null); + + const bar1 = beats(score, 1); + expect(bar1[0].notes[0].slurDestination).toBe(bar1[1].notes[0]); + expect(bar1.map(b => b.crescendo)).toEqual([ + CrescendoType.None, + CrescendoType.None, + CrescendoType.Crescendo, + CrescendoType.Crescendo, + CrescendoType.Decrescendo, + CrescendoType.Decrescendo, + CrescendoType.None, + CrescendoType.None + ]); + expect(score.masterBars[1].isDoubleBar).toBe(true); + expect(Array.from(score.tracks[0].lineBreaks!)).toEqual([2]); + + expect(score.masterBars.map(m => m.tripletFeel)).toEqual([ + TripletFeel.NoTripletFeel, + TripletFeel.NoTripletFeel, + TripletFeel.Triplet8th, + TripletFeel.Triplet8th + ]); + const bar2 = beats(score, 2); + expect(staff.chords!.get(bar2[0].chordId!)!.name).toBe('G'); + expect(bar2[2].text).toBe('Fine'); + + const patch = beats(score, 3)[0].automations.find(a => a.type === AutomationType.Instrument); + expect(patch!.value).toBe(24); + + expect(score.masterBars.map(m => m.isRepeatStart)).toEqual([true, false, false, false]); + expect(score.masterBars.map(m => m.repeatCount)).toEqual([0, 2, 0, 0]); + expect(score.masterBars[0].section!.text).toBe('Start'); + }); + + it('measures', async () => { + const score = await load('measures'); + expect(score.masterBars.map(m => `${m.timeSignatureNumerator}/${m.timeSignatureDenominator}`)).toEqual([ + '4/4', + '3/4', + '6/8', + '2/4', + '4/4' + ]); + const bars = score.tracks[0].staves[0].bars; + // keys beyond 7 sharps wrap around the circle of fifths + expect(bars.map(b => b.keySignature)).toEqual([2, 2, -2, -1, -1]); + expect(bars.every(b => b.keySignatureType === KeySignatureType.Major)).toBe(true); + expect(beats(score, 1).map(b => noteNames(b))).toEqual([['2.4'], ['4.4'], ['0.4']]); + expect(beats(score, 2).map(b => noteNames(b))).toEqual([['4.3'], ['2.3'], ['0.3'], ['2.2']]); + expect(beats(score, 3).map(b => noteNames(b))).toEqual([['1.5'], ['3.5']]); + expect(beats(score, 4).map(b => b.duration)).toEqual([Duration.Whole]); + }); + + it('instruments', async () => { + const score = await load('instruments'); + expect(score.tracks.map(t => t.name)).toEqual(['Guitar', 'Bass']); + const guitar = score.tracks[0].staves[0]; + expect(guitar.capo).toBe(2); + expect(guitar.tuning).toEqual([64, 59, 55, 50, 45, 40]); + const bass = score.tracks[1].staves[0]; + expect(bass.tuning).toEqual([43, 38, 33, 28]); + expect(score.tracks[1].playbackInfo.program).toBe(33); + expect(bass.bars[0].clef).toBe(Clef.F4); + expect(beats(score, 0).map(b => noteNames(b))).toEqual([['0.6'], ['1.5'], ['2.4']]); + expect(beats(score, 0, 0, 1).map(b => noteNames(b))).toEqual([['0.1']]); + expect(beats(score, 0, 0, 1)[0].dots).toBe(1); + expect(beats(score, 1, 0, 1).map(b => noteNames(b))).toEqual([['2.3'], ['0.2']]); + }); + + it('cell-kinds', async () => { + const score = await load('probes'); + const bar0 = beats(score, 0); + expect(bar0.map(b => noteNames(b).join(' '))).toEqual(['27.4', '3.4', '5.4', '5.4', '5.4']); + expect(bar0[1].graceType).toBe(GraceType.OnBeat); + expect(bar0[3].notes[0].bendPoints!.map(p => [p.offset, p.value])).toEqual([ + [0, 2], + [60, 2] + ]); + expect(bar0[4].notes[0].bendPoints).toBe(null); + + const bar1 = beats(score, 1); + expect(bar1[0].fermata).not.toBe(null); + expect(bar1[0].beamingMode).toBe(BeatBeamingMode.ForceSplitToNext); + expect(bar1[3].ottava).toBe(Ottavia._8va); + expect(bar1[4].brushType).toBe(BrushType.ArpeggioDown); + + // the connection (number 3, length 16) also covers the quarter at its end + const bar2 = beats(score, 2); + expect(bar2.map(b => [b.isRest, b.tupletNumerator, b.playbackStart])).toEqual([ + [false, 3, 0], + [false, 3, 320], + [false, 3, 640], + [true, 3, 960], + [false, 3, 1280], + [false, -1, 1920] + ]); + expect(bar2[4].notes[0].accentuated).toBe(AccentuationType.Normal); + expect(bar2[5].pickStroke).toBe(PickStroke.Up); + }); + }); + + describe('tabledit-conversion', () => { + async function expectSameAsConversion(name: string) { + const v2 = await load(name); + const v3 = ScoreLoader.loadScoreFromBytes( + await TestPlatform.loadFile(`test-data/tabledit/v2/${name}.v3.tef`), + new Settings() + ); + ComparisonHelpers.expectJsonEqual( + JsonConverter.scoreToJsObject(v3), + JsonConverter.scoreToJsObject(v2), + `<${name}>`, + null + ); + } + + it('notes', async () => { + await expectSameAsConversion('notes'); + }); + + it('effects', async () => { + await expectSameAsConversion('effects'); + }); + + it('markers', async () => { + await expectSameAsConversion('markers'); + }); + + it('measures', async () => { + await expectSameAsConversion('measures'); + }); + + it('instruments', async () => { + await expectSameAsConversion('instruments'); + }); + + it('cell-kinds', async () => { + await expectSameAsConversion('probes'); + }); + }); +}); diff --git a/packages/alphatab/test/importer/TablEditImporter.test.ts b/packages/alphatab/test/importer/TablEditImporter.test.ts new file mode 100644 index 0000000000..f99ab024df --- /dev/null +++ b/packages/alphatab/test/importer/TablEditImporter.test.ts @@ -0,0 +1,2550 @@ +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 { 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 { 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 { MasterBar } from '@coderline/alphatab/model/MasterBar'; +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 { 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 { + 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 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', () => { + function loadHealthy(): Promise { + return TestPlatform.loadFile('test-data/corrupt/healthy.tef'); + } + + 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', async () => { + { + const data = await patched(formatOffset, 2); + expect(() => readBytes(data)).toThrow(FormatError); + } + { + const data = await patched(formatOffset, 1); + expect(() => readBytes(data)).toThrow(FormatError); + } + }); + + 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); + }); + + 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); + }); + + 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('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); + + const upper = (bar: number) => beats(score, bar, 0); + const automatic = (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[1].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[0].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); + }); + + 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]); + }); + }); + + 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-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-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-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][]): Promise { + return readBytes( + withBlock(await loadData('reading-list-voltas'), 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-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) + } + }; + + it('reading-list-structures', async () => { + for (const [name, s] of Object.entries(structures)) { + const score = await withReadingList(s.list); + 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, 100), 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..24d4ab2f28 --- /dev/null +++ b/packages/alphatab/test/visualTests/features/TablEdit.test.ts @@ -0,0 +1,20 @@ +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'); + }); +}); 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/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/csharp/src/AlphaTab/Core/EcmaScript/Uint8Array.cs b/packages/csharp/src/AlphaTab/Core/EcmaScript/Uint8Array.cs index 6ed30f1a86..d196925daf 100644 --- a/packages/csharp/src/AlphaTab/Core/EcmaScript/Uint8Array.cs +++ b/packages/csharp/src/AlphaTab/Core/EcmaScript/Uint8Array.cs @@ -115,18 +115,23 @@ public void Reverse() public void Fill(double value) { - var start = (int)ByteOffset; - var length = (int)Length; + Fill(value, 0, Length); + } + + public void Fill(double value, double start, double end) + { + var length = (int)end - (int)start; + var from = (int)ByteOffset + (int)start; if (value == 0) { - System.Array.Clear(Buffer.Raw, start, length); + System.Array.Clear(Buffer.Raw, from, length); } else { var b = (byte)value; for (var i = 0; i < length; i++) { - Buffer.Raw[start + i] = b; + Buffer.Raw[from + i] = b; } } } 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/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..48de7011b6 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() @@ -77,7 +84,11 @@ public class Uint8Array : Iterable { } public fun fill(value: Double) { - val start = byteOffset.toInt() - buffer.fill(value.toInt().toUByte(), start, start + length.toInt()) + fill(value, 0.0, length) + } + + public fun fill(value: Double, start: Double, end: Double) { + val offset = byteOffset.toInt() + buffer.fill(value.toInt().toUByte(), offset + start.toInt(), offset + end.toInt()) } } diff --git a/packages/kotlin/src/android/src/test/java/alphaTab/core/TestGlobals.kt b/packages/kotlin/src/android/src/test/java/alphaTab/core/TestGlobals.kt index 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/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 }) => (