From 67d73c35fd4f9c23cdddccd07cab4e2db9aaf248 Mon Sep 17 00:00:00 2001 From: Danielku15 Date: Mon, 2 Nov 2026 22:08:39 +0100 Subject: [PATCH 01/21] fix(kotlin): spread only the items of a DoubleList, not its spare capacity --- .../android/src/main/java/alphaTab/collections/DoubleList.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 de9097912..15969fffa 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 { From 17626fc2b80be2c75e710df6e7e5553d6e6834d1 Mon Sep 17 00:00:00 2001 From: Danielku15 Date: Mon, 2 Nov 2026 22:08:49 +0100 Subject: [PATCH 02/21] fix(csharp): decode Windows-1252 text without a code pages provider .NET only knows Windows-1252 when the host registers the code pages provider; without it the decoder fell back to UTF-8 and replaced every accented character of a Windows-1252 text. --- .../AlphaTab/Core/EcmaScript/TextDecoder.cs | 56 ++++++++++++++++++- 1 file changed, 53 insertions(+), 3 deletions(-) diff --git a/packages/csharp/src/AlphaTab/Core/EcmaScript/TextDecoder.cs b/packages/csharp/src/AlphaTab/Core/EcmaScript/TextDecoder.cs index 9840424ec..057bc0dd2 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); } } From 98c39145563cc37974d097a05d96ded5c762be35 Mon Sep 17 00:00:00 2001 From: Danielku15 Date: Mon, 2 Nov 2026 22:08:49 +0100 Subject: [PATCH 03/21] fix(platform): add the array members of the TablEdit importer and its tests to the C# and Kotlin runtimes - Uint8Array.fill with a start and end (C#, Kotlin) - a Uint8Array copied from another Uint8Array (Kotlin) - slice with a start and end on lists and boolean lists (Kotlin) - includes and flatMap on number lists (Kotlin) - sort without a comparison returns the sorted list (Kotlin) --- .../src/AlphaTab/Core/EcmaScript/Uint8Array.cs | 13 +++++++++---- .../java/alphaTab/collections/BooleanList.kt | 4 ++++ .../java/alphaTab/collections/DoubleList.kt | 17 +++++++++++++++++ .../src/main/java/alphaTab/collections/List.kt | 4 ++++ .../src/main/java/alphaTab/core/Globals.kt | 4 ++-- .../java/alphaTab/core/ecmaScript/Uint8Array.kt | 15 +++++++++++++-- 6 files changed, 49 insertions(+), 8 deletions(-) diff --git a/packages/csharp/src/AlphaTab/Core/EcmaScript/Uint8Array.cs b/packages/csharp/src/AlphaTab/Core/EcmaScript/Uint8Array.cs index 6ed30f1a8..d196925da 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 8e0870ee0..4d86c6860 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 15969fffa..307065046 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 @@ -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 79be8f90c..2adc1f523 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 606bdf965..bdaa6c485 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 a61fe3e91..48de7011b 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()) } } From 907c967a0a0f4e509b0b0e4dc639127ef99a3e80 Mon Sep 17 00:00:00 2001 From: Danielku15 Date: Mon, 2 Nov 2026 22:08:49 +0100 Subject: [PATCH 04/21] test(platform): compare numbers, enums and map entries in toEqual like in JavaScript In the C# and Kotlin test globals, list elements that are numbers or enum values are compared by their numeric value, and Kotlin compares map entries by key and value. C# accepts a message for an expected throwing action, Kotlin has toBeDefined. --- .../csharp/src/AlphaTab.Test/Test/Globals.cs | 28 +++++++++++++++++++ .../test/java/alphaTab/core/TestGlobals.kt | 18 +++++++++++- 2 files changed, 45 insertions(+), 1 deletion(-) diff --git a/packages/csharp/src/AlphaTab.Test/Test/Globals.cs b/packages/csharp/src/AlphaTab.Test/Test/Globals.cs index c20cbca49..6eef1a4f5 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/kotlin/src/android/src/test/java/alphaTab/core/TestGlobals.kt b/packages/kotlin/src/android/src/test/java/alphaTab/core/TestGlobals.kt index 1891cd4e0..8d40d0390 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) } From b526eb5ff404df57ff8432844c65355935ff8c5b Mon Sep 17 00:00:00 2001 From: Danielku15 Date: Sun, 1 Nov 2026 19:00:15 +0100 Subject: [PATCH 05/21] feat(importer): read TablEdit 3 files into bars, voices and beats Detects TablEdit files, reads title fields, measures, instruments and the note grid, and lays the grid out into alphaTab voices with packed tuplets, lanes for overlapping lengths and rest filling. Triplet runs are spread relative to the beat of their first member, other tuplets relative to their beat, as TablEdit times them. Measure sizes follow the time signature, 12-byte measure records and formats 11 and 12 are recognised, and texts that are not valid UTF-8 are read as Windows-1252. --- packages/alphatab/src/Environment.ts | 2 + .../alphatab/src/importer/TablEditImporter.ts | 297 ++++++++ .../src/importer/tablEdit/TefBarBuilder.ts | 653 ++++++++++++++++++ 3 files changed, 952 insertions(+) create mode 100644 packages/alphatab/src/importer/TablEditImporter.ts create mode 100644 packages/alphatab/src/importer/tablEdit/TefBarBuilder.ts diff --git a/packages/alphatab/src/Environment.ts b/packages/alphatab/src/Environment.ts index fe6bceb03..b76c9a0ad 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'; @@ -404,6 +405,7 @@ export class Environment { new Gp7To8Importer(), new MusicXmlImporter(), new CapellaImporter(), + new TablEditImporter(), new AlphaTexImporter() ]; } diff --git a/packages/alphatab/src/importer/TablEditImporter.ts b/packages/alphatab/src/importer/TablEditImporter.ts new file mode 100644 index 000000000..604fb6f5f --- /dev/null +++ b/packages/alphatab/src/importer/TablEditImporter.ts @@ -0,0 +1,297 @@ +import { FormatError } from '@coderline/alphatab/FormatError'; +import { ScoreImporter } from '@coderline/alphatab/importer/ScoreImporter'; +import { TefBarBuilder, TefCell, TefTrackInfo } from '@coderline/alphatab/importer/tablEdit/TefBarBuilder'; +import { UnsupportedFormatError } from '@coderline/alphatab/importer/UnsupportedFormatError'; +import { IOHelper } from '@coderline/alphatab/io/IOHelper'; +import { OverflowError } from '@coderline/alphatab/io/IReadable'; +import { Automation } from '@coderline/alphatab/model/Automation'; +import { 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 format version 3. + * @internal + */ +export class TablEditImporter extends ScoreImporter { + private static readonly _headerSize = 256; + private static readonly _firstSupportedFormat = 10; + private static readonly _protectedFormat = 12; + private static readonly _lastSupportedFormat = 12; + private static readonly _wideMeasureRecordHeader = 16; + private static readonly _maxMeasureCount = 10000; + private static readonly _maxInstrumentCount = 64; + private static readonly _maxStringCount = 12; + private static readonly _maxCellCount = 1000000; + private static readonly _cellSize = 12; + private static readonly _cellEnd = 4294967295; + + private _bytes!: Uint8Array; + private _score!: Score; + private _measureStarts: number[] = []; + private _measureSizes: number[] = []; + private _tracks: TefTrackInfo[] = []; + private _totalStrings: number = 0; + + public get name(): string { + return 'TablEdit'; + } + + public readScore(): Score { + this._bytes = this.data.readAll(); + const format = this._detectFormat(); + if (format < TablEditImporter._firstSupportedFormat) { + throw new FormatError( + 'TablEdit files older than version 3 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`); + } + if (this._u32(16) !== 0 || format === TablEditImporter._protectedFormat) { + throw new FormatError('Protected TablEdit files are not supported'); + } + + this._score = new Score(); + this._readInformation(); + this._readMeasures(this._u32(92)); + this._readInstruments(this._u32(96)); + const cells = this._readContents(this._u32(60)); + + const builder = new TefBarBuilder(this._tracks, this._measureStarts, this._measureSizes); + builder.build(cells); + + if (this._score.masterBars.length > 0) { + this._score.masterBars[0].tempoAutomations.push( + Automation.buildTempoAutomation(false, 0, this._u16(6) > 0 ? this._u16(6) : 120, 2) + ); + } + + ModelUtils.consolidate(this._score); + this._score.finish(this.settings); + this._score.rebuildRepeatGroups(); + return this._score; + } + + /** + * Returns the format version of a file that looks like a TablEdit file. + * Throws an {@link UnsupportedFormatError} for anything else. + */ + private _detectFormat(): number { + if (this._bytes.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; + this._score.addMasterBar(masterBar); + + let size = (64 / masterBar.timeSignatureDenominator) * masterBar.timeSignatureNumerator; + if (size <= 0 || size > 1024 || size !== Math.floor(size)) { + size = 64; + } + this._measureStarts.push(position); + this._measureSizes.push(size); + position += size; + p += recordSize; + } + } + + private _readInstruments(offset: number): void { + if (offset === 0) { + throw new FormatError('TablEdit file without instruments'); + } + const recordSize = this._u16(offset); + const count = this._u16(offset + 2); + this._ensureLoopBoundary(count, TablEditImporter._maxInstrumentCount, 'instrument count'); + let p = offset + 4; + for (let i = 0; i < count; i++) { + const stringCount = this._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 = this._readCString(p + 32, 36); + track.playbackInfo.program = this._u8(p + 8); + track.playbackInfo.bank = this._u8(p + 9); + + const capo = this._u16(p + 12); + staff.capo = capo; + const tuning: number[] = []; + for (let s = 0; s < stringCount; s++) { + tuning.push(96 - this._u8(p + 20 + s) - capo); + } + staff.stringTuning.tunings = tuning; + staff.displayTranspositionPitch = this._s8(p + 14) - 12; + staff.showTablature = true; + staff.showStandardNotation = true; + + const info = new TefTrackInfo(); + info.track = track; + info.firstString = this._totalStrings; + info.stringCount = stringCount; + this._tracks.push(info); + this._totalStrings += stringCount; + p += recordSize; + } + } + + 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._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 ''; + } + const length = this._u16(offset); + this._ensureLoopBoundary(length, this.settings.importer.maxDecodingBufferSize, 'string length'); + this._requireRange(offset + 2, length); + let end = offset + 2 + length; + while (end > offset + 2 && this._bytes[end - 1] === 0) { + end--; + } + return TablEditImporter._decode(this._bytes.subarray(offset + 2, end)); + } + + private _readCString(offset: number, maxLength: number): string { + this._requireRange(offset, maxLength); + let end = offset; + while (end < offset + maxLength && this._bytes[end] !== 0) { + end++; + } + return TablEditImporter._decode(this._bytes.subarray(offset, end)); + } + + /** + * Texts are UTF-8, but older files may still carry Windows-1252 text. + */ + private static _decode(data: Uint8Array): string { + return IOHelper.toString(data, TablEditImporter._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; + } + + private _requireRange(offset: number, length: number): void { + if (offset < 0 || offset + length > this._bytes.length) { + throw new FormatError(`TablEdit data at ${offset} (${length} bytes) is outside of the file`); + } + } + + private _u8(offset: number): number { + this._requireRange(offset, 1); + return this._bytes[offset]; + } + + private _s8(offset: number): number { + const v = this._u8(offset); + return v > 127 ? v - 256 : v; + } + + private _u16(offset: number): number { + this._requireRange(offset, 2); + return this._bytes[offset] | (this._bytes[offset + 1] << 8); + } + + private _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; + } + + private _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/TefBarBuilder.ts b/packages/alphatab/src/importer/tablEdit/TefBarBuilder.ts new file mode 100644 index 000000000..aab220f6f --- /dev/null +++ b/packages/alphatab/src/importer/tablEdit/TefBarBuilder.ts @@ -0,0 +1,653 @@ +import { Bar } from '@coderline/alphatab/model/Bar'; +import { Beat } from '@coderline/alphatab/model/Beat'; +import { Duration } from '@coderline/alphatab/model/Duration'; +import { DynamicValue } from '@coderline/alphatab/model/DynamicValue'; +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 type { Track } from '@coderline/alphatab/model/Track'; +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 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; +} + +/** + * 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. 2520 is the least common multiple of 1..10, + * which keeps the members of every tuplet kind on whole sub-units. + */ + public static readonly subUnitsPerCell = 2520; + + private static readonly _restCode = 0x33; + private static readonly _tieDynamics = 7; + private static readonly _upperVoice = 2; + private static readonly _lowerVoice = 3; + + private _tracks: TefTrackInfo[]; + private _measureStarts: number[]; + private _measureSizes: number[]; + + public constructor(tracks: TefTrackInfo[], measureStarts: number[], measureSizes: number[]) { + this._tracks = tracks; + this._measureStarts = measureStarts; + this._measureSizes = measureSizes; + } + + public build(cells: TefCell[]): void { + for (const info of this._tracks) { + 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) { + events.push(e); + } + } + } + this._buildTrack(info, events); + } + } + + 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 = cell.position - this._measureStarts[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; + }); + + for (let i = 0; i < barCount; i++) { + const bar = new Bar(); + 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._durationOf(e.durationCode)!; + const column = e.column; + let start = column * unit; + 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}`; + 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._durationOf(e.durationCode)!; + 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, lane] = order[v]; + 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); + if (voice.beats.length === 0) { + bar.voices.pop(); + ModelUtils.appendPlaceholderVoice(bar); + } + } + } + } + + private _fillVoice(info: TefTrackInfo, voice: Voice, units: TefUnit[], barLength: number, group: number): void { + let cursor = 0; + let hasContent = false; + 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); + } + 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); + for (let k = 0; k < values.length; k++) { + const beat = TefBarBuilder._createBeat(values[k]); + voice.addBeat(beat); + if (group !== 0) { + beat.preferredBeamDirection = group === 1 ? BeamDirection.Up : BeamDirection.Down; + } + if (!u.isRest) { + this._addNotes(info, beat, u, k > 0); + } + } + hasContent = true; + cursor = end; + } + if (hasContent && cursor < barLength) { + this._addRests(voice, cursor, barLength, null); + } + } + + private _addRests(voice: Voice, start: number, end: number, next: TefDuration | null): void { + const values = + next && next.isTuplet && (end - start) % next.length === 0 + ? TefBarBuilder._repeat(next, (end - start) / next.length) + : TefBarBuilder._splitPlain(start, end); + for (const v of values) { + voice.addBeat(TefBarBuilder._createBeat(v)); + } + } + + 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 >= cell) { + let cells = 64; + while (cells > 1 && ((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; + } + + 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(); + 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 (loudest < 7) { + beat.dynamics = TefBarBuilder._dynamicsOf(loudest); + beat.showDynamics = false; + } + } + + 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; + } +} From e69058c185959f0ea62e3e320746e03e08a27023 Mon Sep 17 00:00:00 2001 From: Danielku15 Date: Sun, 1 Nov 2026 19:03:49 +0100 Subject: [PATCH 06/21] feat(importer): map TablEdit effects, graces, fingerings and strokes Covers the primary and combination effects, the second-column effects, harmonics, bends to the tied target note, pitch changes, grace notes with their extension records, left and right hand fingerings, pick strokes and ottava attributes. --- .../src/importer/tablEdit/TefBarBuilder.ts | 354 ++++++++++++++++++ 1 file changed, 354 insertions(+) diff --git a/packages/alphatab/src/importer/tablEdit/TefBarBuilder.ts b/packages/alphatab/src/importer/tablEdit/TefBarBuilder.ts index aab220f6f..b44d1a57c 100644 --- a/packages/alphatab/src/importer/tablEdit/TefBarBuilder.ts +++ b/packages/alphatab/src/importer/tablEdit/TefBarBuilder.ts @@ -1,12 +1,25 @@ import { Bar } from '@coderline/alphatab/model/Bar'; import { Beat } from '@coderline/alphatab/model/Beat'; +import { BendPoint } from '@coderline/alphatab/model/BendPoint'; +import { BrushType } from '@coderline/alphatab/model/BrushType'; import { Duration } from '@coderline/alphatab/model/Duration'; import { DynamicValue } from '@coderline/alphatab/model/DynamicValue'; +import { FadeType } from '@coderline/alphatab/model/FadeType'; +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 { PickStroke } from '@coderline/alphatab/model/PickStroke'; +import { Rasgueado } from '@coderline/alphatab/model/Rasgueado'; +import { SlideOutType } from '@coderline/alphatab/model/SlideOutType'; import type { Track } from '@coderline/alphatab/model/Track'; +import { TremoloPickingEffect } from '@coderline/alphatab/model/TremoloPickingEffect'; +import { VibratoType } from '@coderline/alphatab/model/VibratoType'; import { Voice } from '@coderline/alphatab/model/Voice'; import { BeamDirection } from '@coderline/alphatab/rendering/utils/BeamDirection'; @@ -102,6 +115,11 @@ class TefEvent { public realStart: number = 0; public realEnd: number = 0; public lane: number = -1; + /** + * The next note on the same string. + */ + public next: TefEvent | null = null; + public extension: Uint8Array | null = null; } /** @@ -134,6 +152,10 @@ export class TefBarBuilder { 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 _tracks: TefTrackInfo[]; private _measureStarts: number[]; @@ -146,12 +168,22 @@ export class TefBarBuilder { } public build(cells: TefCell[]): void { + const extensions = new Map(); + for (const cell of cells) { + if (cell.data[0] === TefBarBuilder._extensionMarker && (cell.data[1] & 0x07) !== 0) { + extensions.set(`${cell.row}:${cell.position - 1}`, cell.data); + } + } for (const info of this._tracks) { 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; + } events.push(e); } } @@ -270,6 +302,17 @@ export class TefBarBuilder { 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(); staff.addBar(bar); @@ -539,6 +582,9 @@ export class TefBarBuilder { u.duration && end - u.start === u.duration.length ? [u.duration] : TefBarBuilder._splitPlain(u.start, end); + if (!u.isRest) { + this._addGraceBeat(info, voice, u); + } for (let k = 0; k < values.length; k++) { const beat = TefBarBuilder._createBeat(values[k]); voice.addBeat(beat); @@ -626,6 +672,9 @@ export class TefBarBuilder { loudest = e.dynamics; } beat.addNote(note); + if (!isContinuation) { + this._applyEffects(beat, note, e, unit); + } } if (loudest < 7) { beat.dynamics = TefBarBuilder._dynamicsOf(loudest); @@ -633,6 +682,311 @@ export class TefBarBuilder { } } + 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; + } + let stringIndex = e.stringIndex; + let fret = data[3] & 0x1f; + if (e.extension) { + if ((e.extension[3] & 0x80) !== 0) { + stringIndex = e.extension[3] & 0x0f; + } + if ((e.extension[4] & 0x80) !== 0) { + fret = e.extension[4] & 0x3f; + } + } + if (kind === 0) { + fret = e.fret; + } + // a grace on the main note's fret without its own duration is neither shown nor played + const hasDuration = e.extension !== null && (e.extension[1] & 0x07) > 1; + if (fret === e.fret && stringIndex === e.stringIndex && !hasDuration) { + continue; + } + if (!grace) { + grace = new Beat(); + grace.duration = Duration.ThirtySecond; + grace.graceType = + e.extension && (e.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: + note.isHammerPullOrigin = true; + break; + case 2: + note.slideOutType = SlideOutType.Legato; + break; + case 3: + TefBarBuilder._addBend(note, (e.fret - fret) * 2, false); + break; + } + grace.addNote(note); + } + } + + /** + * 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(beat: Beat, note: Note, e: TefEvent, unit: TefUnit): void { + const data = e.cell.data; + let effect = data[2] & 0x0f; + const combination = data[4] >> 4; + if (effect === 0 && combination !== 3 && (data[4] & 0x0f) !== 0) { + effect = 0x10 + (data[4] & 0x0f); + } + const hasParameter = (data[0] & (TefBarBuilder._graceFlag | TefBarBuilder._pitchChangeFlag)) === 0; + const parameter = hasParameter ? data[3] : 0; + + TefBarBuilder._applyEffect(beat, note, e, unit, effect, parameter, combination === 11); + switch (combination) { + case 1: + case 2: + note.isHammerPullOrigin = true; + break; + case 3: + TefBarBuilder._applyEffect(beat, note, e, unit, 14, 0, false); + break; + case 5: + TefBarBuilder._applyEffect(beat, note, e, unit, 5, 0, false); + break; + case 6: + note.harmonicType = HarmonicType.Natural; + 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 ((data[0] & TefBarBuilder._graceFlag) !== 0) { + switch (data[3] >> 5) { + case 4: + note.ornament = NoteOrnament.UpperMordent; + break; + case 5: + case 6: + note.ornament = NoteOrnament.Turn; + break; + case 7: + note.trillValue = 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); + } + switch (data[6] >> 5) { + case 3: + beat.pickStroke = PickStroke.Up; + break; + case 4: + beat.pickStroke = PickStroke.Down; + break; + } + + 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, + isVariation: 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: + case 14: + TefBarBuilder._applyBrush(beat, e, unit, effect === 14); + break; + case 6: + note.harmonicType = HarmonicType.Natural; + break; + case 7: + note.harmonicType = HarmonicType.Artificial; + note.harmonicValue = TefBarBuilder._artificialHarmonicOffset(parameter); + 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.isTied && 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 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): 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; + } + } + + private static _hasPickStroke(e: TefEvent): boolean { + const stroke = e.cell.data[6] >> 5; + return stroke === 3 || stroke === 4; + } + + 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)); + } + } + + private static _artificialHarmonicOffset(parameter: number): number { + if ((parameter & 0xf0) === 0xb0) { + switch (parameter & 0x0f) { + case 3: + return 31; + case 4: + case 9: + return 28; + case 5: + return 24; + case 7: + return 19; + } + } + 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: From be4695f52df014b5f1b1e58b6ca94ba74b3ed72c Mon Sep 17 00:00:00 2001 From: Danielku15 Date: Sun, 1 Nov 2026 19:05:42 +0100 Subject: [PATCH 07/21] feat(importer): apply TablEdit markers Reads the text and chord tables and applies text, chord diagram, accent, crescendo, fermata, slur and MIDI patch markers to the beats of their track, plus tempo changes with ramps, syncopation as triplet feel, line breaks and double bars. --- .../alphatab/src/importer/TablEditImporter.ts | 59 +++- .../src/importer/tablEdit/TefBarBuilder.ts | 297 +++++++++++++++++- 2 files changed, 343 insertions(+), 13 deletions(-) diff --git a/packages/alphatab/src/importer/TablEditImporter.ts b/packages/alphatab/src/importer/TablEditImporter.ts index 604fb6f5f..deef2e931 100644 --- a/packages/alphatab/src/importer/TablEditImporter.ts +++ b/packages/alphatab/src/importer/TablEditImporter.ts @@ -4,7 +4,7 @@ import { TefBarBuilder, TefCell, TefTrackInfo } from '@coderline/alphatab/import import { UnsupportedFormatError } from '@coderline/alphatab/importer/UnsupportedFormatError'; import { IOHelper } from '@coderline/alphatab/io/IOHelper'; import { OverflowError } from '@coderline/alphatab/io/IReadable'; -import { Automation } from '@coderline/alphatab/model/Automation'; +import { Chord } from '@coderline/alphatab/model/Chord'; import { MasterBar } from '@coderline/alphatab/model/MasterBar'; import { ModelUtils } from '@coderline/alphatab/model/ModelUtils'; import { Score } from '@coderline/alphatab/model/Score'; @@ -26,6 +26,8 @@ export class TablEditImporter extends ScoreImporter { private static readonly _maxStringCount = 12; private static readonly _maxCellCount = 1000000; private static readonly _cellSize = 12; + private static readonly _maxTextCount = 4096; + private static readonly _maxChordCount = 4096; private static readonly _cellEnd = 4294967295; private _bytes!: Uint8Array; @@ -61,14 +63,12 @@ export class TablEditImporter extends ScoreImporter { const 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.build(cells); - if (this._score.masterBars.length > 0) { - this._score.masterBars[0].tempoAutomations.push( - Automation.buildTempoAutomation(false, 0, this._u16(6) > 0 ? this._u16(6) : 120, 2) - ); - } - ModelUtils.consolidate(this._score); this._score.finish(this.settings); this._score.rebuildRepeatGroups(); @@ -171,6 +171,46 @@ export class TablEditImporter extends ScoreImporter { } } + 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; + } + + private _readChords(offset: number): Chord[] { + const chords: Chord[] = []; + if (offset === 0) { + return chords; + } + const recordSize = this._u16(offset); + const count = this._u16(offset + 2); + this._ensureLoopBoundary(count, TablEditImporter._maxChordCount, 'chord count'); + let p = offset + 4; + for (let i = 0; i < count; i++) { + const chord = new Chord(); + chord.name = this._readCString(p + 14, 17); + const stringCount = this._tracks.length > 0 ? this._tracks[0].stringCount : 6; + for (let s = 0; s < stringCount && s < 14; 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; + } + private _readContents(offset: number): TefCell[] { const cells: TefCell[] = []; if (offset === 0 || this._totalStrings === 0) { @@ -276,6 +316,11 @@ export class TablEditImporter extends ScoreImporter { return v > 127 ? v - 256 : v; } + private _s16(offset: number): number { + const v = this._u16(offset); + return v > 32767 ? v - 65536 : v; + } + private _u16(offset: number): number { this._requireRange(offset, 2); return this._bytes[offset] | (this._bytes[offset + 1] << 8); diff --git a/packages/alphatab/src/importer/tablEdit/TefBarBuilder.ts b/packages/alphatab/src/importer/tablEdit/TefBarBuilder.ts index b44d1a57c..2d94c8eb0 100644 --- a/packages/alphatab/src/importer/tablEdit/TefBarBuilder.ts +++ b/packages/alphatab/src/importer/tablEdit/TefBarBuilder.ts @@ -1,10 +1,15 @@ +import { AccentuationType } from '@coderline/alphatab/model/AccentuationType'; +import { Automation, AutomationType } from '@coderline/alphatab/model/Automation'; import { Bar } from '@coderline/alphatab/model/Bar'; import { Beat } from '@coderline/alphatab/model/Beat'; import { BendPoint } from '@coderline/alphatab/model/BendPoint'; import { BrushType } from '@coderline/alphatab/model/BrushType'; +import type { Chord } from '@coderline/alphatab/model/Chord'; +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'; @@ -19,6 +24,7 @@ import { Rasgueado } from '@coderline/alphatab/model/Rasgueado'; import { SlideOutType } from '@coderline/alphatab/model/SlideOutType'; 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'; @@ -157,9 +163,31 @@ export class TefBarBuilder { private static readonly _pitchChangeFlag = 0x80; private static readonly _acciaccaturaFlag = 0x40; + 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 _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(); + + public texts: string[] = []; + public chords: Chord[] = []; + public initialTempo: number = 120; + public initialSyncopation: number = 0; public constructor(tracks: TefTrackInfo[], measureStarts: number[], measureSizes: number[]) { this._tracks = tracks; @@ -188,10 +216,236 @@ export class TefBarBuilder { } } } + this._beatStarts = new Map(); + this._beatsByBar = 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); + } + + 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; + for (const cell of cells) { + const data = cell.data; + const bar = this._barOf(cell.position); + const column = cell.position - this._measureStarts[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: + if (bar + 1 < score.masterBars.length) { + for (const t of this._tracks) { + t.track.addLineBreaks(bar + 1); + } + } + if ((data[7] & 0x04) !== 0) { + score.masterBars[bar].isDoubleBar = true; + } + break; + } + } + 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); + } + } + + /** + * 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 = cell.position - this._measureStarts[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.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, this.chords[index]); + } + beat.chordId = id; + } + break; + } + case TefBarBuilder._accentMarker: + for (const n of beat.notes) { + n.accentuated = AccentuationType.Normal; + } + break; + case TefBarBuilder._crescendoMarker: { + 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] & 0x80) === 0) { + this._applySlur(bar, column, data[1] + 1); + } + 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; } } + 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]); + } + } + } + + private _applySlur(bar: number, column: number, length: number): void { + let first: Beat | null = null; + let last: Beat | null = null; + this._applyToRange(bar, column, length, b => { + if (b.notes.length > 0) { + if (!first) { + first = b; + } + last = b; + } + }); + if (first && last && first !== last) { + const origin = (first as Beat).notes[0]; + const destination = (last as Beat).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) { @@ -551,7 +805,7 @@ export class TefBarBuilder { } } laneUnits.sort((x, y) => x.start - y.start); - this._fillVoice(info, voice, laneUnits, barLength, group); + this._fillVoice(info, voice, laneUnits, barLength, group, v === 0 ? b : -1); if (voice.beats.length === 0) { bar.voices.pop(); ModelUtils.appendPlaceholderVoice(bar); @@ -560,7 +814,14 @@ export class TefBarBuilder { } } - private _fillVoice(info: TefTrackInfo, voice: Voice, units: TefUnit[], barLength: number, group: number): void { + private _fillVoice( + info: TefTrackInfo, + voice: Voice, + units: TefUnit[], + barLength: number, + group: number, + recordBar: number + ): void { let cursor = 0; let hasContent = false; for (let i = 0; i < units.length; i++) { @@ -569,7 +830,7 @@ export class TefBarBuilder { continue; } if (u.start > cursor) { - this._addRests(voice, cursor, u.start, u.duration); + this._addRests(voice, cursor, u.start, u.duration, recordBar); } let end = u.end; if (i + 1 < units.length && units[i + 1].start < end) { @@ -585,9 +846,14 @@ export class TefBarBuilder { 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; } @@ -599,17 +865,36 @@ export class TefBarBuilder { cursor = end; } if (hasContent && cursor < barLength) { - this._addRests(voice, cursor, barLength, null); + this._addRests(voice, cursor, barLength, null, recordBar); } } - private _addRests(voice: Voice, start: number, end: number, next: TefDuration | null): void { + 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); + } + + private _addRests(voice: Voice, start: number, end: number, next: TefDuration | null, recordBar: number): void { const values = next && next.isTuplet && (end - start) % next.length === 0 ? TefBarBuilder._repeat(next, (end - start) / next.length) : TefBarBuilder._splitPlain(start, end); + let t = start; for (const v of values) { - voice.addBeat(TefBarBuilder._createBeat(v)); + const beat = TefBarBuilder._createBeat(v); + voice.addBeat(beat); + if (recordBar >= 0) { + this._recordBeat(recordBar, t, beat); + } + t += v.length; } } From 665fcbdd62ae66a8d59ee52211007b13d17dedaa Mon Sep 17 00:00:00 2001 From: Danielku15 Date: Mon, 2 Nov 2026 21:29:18 +0100 Subject: [PATCH 08/21] feat(importer): express the TablEdit reading list through repeats and jumps Derives repeats with pass counts, alternate endings and D.C./D.S. jumps with coda and fine targets from the reading list, checks the resulting playback order, turns labels into sections, and shows the graphical repeat signs when a file has no reading list. A repeat pass that continues past the repeat (1-2, 1-2, 1-2, 3-n) counts as a pass of the repeat instead of a D.C., and a second D.S. that skips ahead again ends at the double coda (D.S.S. al Double Coda). --- .../alphatab/src/importer/TablEditImporter.ts | 48 +++ .../src/importer/tablEdit/TefReadingList.ts | 290 ++++++++++++++++++ 2 files changed, 338 insertions(+) create mode 100644 packages/alphatab/src/importer/tablEdit/TefReadingList.ts diff --git a/packages/alphatab/src/importer/TablEditImporter.ts b/packages/alphatab/src/importer/TablEditImporter.ts index deef2e931..3f1d6a8ec 100644 --- a/packages/alphatab/src/importer/TablEditImporter.ts +++ b/packages/alphatab/src/importer/TablEditImporter.ts @@ -1,6 +1,11 @@ import { FormatError } from '@coderline/alphatab/FormatError'; import { ScoreImporter } from '@coderline/alphatab/importer/ScoreImporter'; import { TefBarBuilder, TefCell, TefTrackInfo } from '@coderline/alphatab/importer/tablEdit/TefBarBuilder'; +import { + TefReadingGuide, + TefReadingList, + TefReadingListItem +} from '@coderline/alphatab/importer/tablEdit/TefReadingList'; import { UnsupportedFormatError } from '@coderline/alphatab/importer/UnsupportedFormatError'; import { IOHelper } from '@coderline/alphatab/io/IOHelper'; import { OverflowError } from '@coderline/alphatab/io/IReadable'; @@ -27,6 +32,8 @@ export class TablEditImporter extends ScoreImporter { private static readonly _maxCellCount = 1000000; private static readonly _cellSize = 12; private static readonly _maxTextCount = 4096; + private static readonly _maxReadingListCount = 4096; + private static readonly _readingGuideMarker = 0xb7; private static readonly _maxChordCount = 4096; private static readonly _cellEnd = 4294967295; @@ -69,9 +76,13 @@ export class TablEditImporter extends ScoreImporter { builder.chords = this._readChords(this._u32(88)); builder.build(cells); + const readingList = this._readReadingList(this._u32(128)); + TefReadingList.apply(this._score, readingList, this._readGuides(cells)); + ModelUtils.consolidate(this._score); this._score.finish(this.settings); this._score.rebuildRepeatGroups(); + TefReadingList.verify(this._score, readingList); return this._score; } @@ -171,6 +182,43 @@ export class TablEditImporter extends ScoreImporter { } } + private _readReadingList(offset: number): TefReadingListItem[] { + const items: TefReadingListItem[] = []; + if (offset === 0) { + return items; + } + const recordSize = this._u16(offset); + 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) { diff --git a/packages/alphatab/src/importer/tablEdit/TefReadingList.ts b/packages/alphatab/src/importer/tablEdit/TefReadingList.ts new file mode 100644 index 000000000..02683b823 --- /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; + } + } + } +} From 766723d2989708572bb634c9903101ab85e7ade1 Mon Sep 17 00:00:00 2001 From: Danielku15 Date: Sun, 1 Nov 2026 19:14:11 +0100 Subject: [PATCH 09/21] feat(importer): read TablEdit clefs, display modes, mixer settings, transposition and drum tabs --- .../alphatab/src/importer/TablEditImporter.ts | 77 +++++++++++++++++-- .../src/importer/tablEdit/TefBarBuilder.ts | 45 ++++++++--- 2 files changed, 105 insertions(+), 17 deletions(-) diff --git a/packages/alphatab/src/importer/TablEditImporter.ts b/packages/alphatab/src/importer/TablEditImporter.ts index 3f1d6a8ec..5ab30266e 100644 --- a/packages/alphatab/src/importer/TablEditImporter.ts +++ b/packages/alphatab/src/importer/TablEditImporter.ts @@ -10,6 +10,7 @@ import { UnsupportedFormatError } from '@coderline/alphatab/importer/Unsupported import { IOHelper } from '@coderline/alphatab/io/IOHelper'; import { OverflowError } from '@coderline/alphatab/io/IReadable'; import { Chord } from '@coderline/alphatab/model/Chord'; +import { Clef } from '@coderline/alphatab/model/Clef'; import { MasterBar } from '@coderline/alphatab/model/MasterBar'; import { ModelUtils } from '@coderline/alphatab/model/ModelUtils'; import { Score } from '@coderline/alphatab/model/Score'; @@ -31,6 +32,7 @@ export class TablEditImporter extends ScoreImporter { private static readonly _maxStringCount = 12; private static readonly _maxCellCount = 1000000; private static readonly _cellSize = 12; + private static readonly _drumFlag = 0x80; private static readonly _maxTextCount = 4096; private static readonly _maxReadingListCount = 4096; private static readonly _readingGuideMarker = 0xb7; @@ -162,20 +164,47 @@ export class TablEditImporter extends ScoreImporter { track.playbackInfo.bank = this._u8(p + 9); const capo = this._u16(p + 12); - staff.capo = capo; - const tuning: number[] = []; + const openPitches: number[] = []; for (let s = 0; s < stringCount; s++) { - tuning.push(96 - this._u8(p + 20 + s) - capo); + openPitches.push(96 - this._u8(p + 20 + s)); } - staff.stringTuning.tunings = tuning; - staff.displayTranspositionPitch = this._s8(p + 14) - 12; - staff.showTablature = true; - staff.showStandardNotation = true; + const isPercussion = (this._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; + } + staff.displayTranspositionPitch = + this._s8(p + 14) - 12 - TablEditImporter._transpositionOf(this._u8(p + 11) & 0x07); + + switch ((this._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(((this._u8(p + 17) & 0x0f) * 16) / 15); + track.playbackInfo.volume = Math.round(((15 - (this._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(this._u8(p + 15) & 0x07, openPitches); this._tracks.push(info); this._totalStrings += stringCount; p += recordSize; @@ -259,6 +288,40 @@ export class TablEditImporter extends ScoreImporter { return chords; } + /** + * 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) { diff --git a/packages/alphatab/src/importer/tablEdit/TefBarBuilder.ts b/packages/alphatab/src/importer/tablEdit/TefBarBuilder.ts index 2d94c8eb0..9c401f520 100644 --- a/packages/alphatab/src/importer/tablEdit/TefBarBuilder.ts +++ b/packages/alphatab/src/importer/tablEdit/TefBarBuilder.ts @@ -5,6 +5,7 @@ import { Beat } from '@coderline/alphatab/model/Beat'; import { BendPoint } from '@coderline/alphatab/model/BendPoint'; import { BrushType } from '@coderline/alphatab/model/BrushType'; import type { 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'; @@ -19,6 +20,7 @@ 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'; @@ -52,6 +54,12 @@ 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; } /** @@ -413,19 +421,26 @@ export class TefBarBuilder { } private _applySlur(bar: number, column: number, length: number): 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; let first: Beat | null = null; let last: Beat | null = null; - this._applyToRange(bar, column, length, b => { - if (b.notes.length > 0) { + for (let i = 0; i < starts.length; i++) { + if (starts[i] >= from && starts[i] < to && beats[i].notes.length > 0) { if (!first) { - first = b; + first = beats[i]; } - last = b; + last = beats[i]; } - }); + } if (first && last && first !== last) { - const origin = (first as Beat).notes[0]; - const destination = (last as Beat).notes[0]; + const origin = first.notes[0]; + const destination = last.notes[0]; origin.slurDestination = destination; destination.slurOrigin = origin; destination.isSlurDestination = true; @@ -569,6 +584,7 @@ export class TefBarBuilder { for (let i = 0; i < barCount; i++) { const bar = new Bar(); + bar.clef = info.isPercussion ? Clef.Neutral : info.clef; staff.addBar(bar); } @@ -795,7 +811,8 @@ export class TefBarBuilder { const bar = staff.bars[b]; const barLength = this._measureSizes[b] * TefBarBuilder.subUnitsPerCell; for (let v = 0; v < order.length; v++) { - const [group, lane] = order[v]; + const group = order[v][0]; + const lane = order[v][1]; const voice = new Voice(); bar.addVoice(voice); const laneUnits: TefUnit[] = []; @@ -939,8 +956,16 @@ export class TefBarBuilder { let loudest = 7; for (const e of unit.events) { const note = new Note(); - note.string = info.stringCount - e.stringIndex; - note.fret = e.fret; + 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: From 91cbedc11fa1bf12a4fcaa05cd59d56c7155dd94 Mon Sep 17 00:00:00 2001 From: Danielku15 Date: Sun, 1 Nov 2026 19:17:36 +0100 Subject: [PATCH 10/21] feat(importer): read TablEdit key signatures, minor keys and pick-up measures --- .../alphatab/src/importer/TablEditImporter.ts | 25 ++++++++++ .../src/importer/tablEdit/TefBarBuilder.ts | 46 +++++++++++++++++-- 2 files changed, 68 insertions(+), 3 deletions(-) diff --git a/packages/alphatab/src/importer/TablEditImporter.ts b/packages/alphatab/src/importer/TablEditImporter.ts index 5ab30266e..dea6975e6 100644 --- a/packages/alphatab/src/importer/TablEditImporter.ts +++ b/packages/alphatab/src/importer/TablEditImporter.ts @@ -11,6 +11,8 @@ import { IOHelper } from '@coderline/alphatab/io/IOHelper'; import { OverflowError } from '@coderline/alphatab/io/IReadable'; import { Chord } from '@coderline/alphatab/model/Chord'; import { Clef } from '@coderline/alphatab/model/Clef'; +import type { KeySignature } from '@coderline/alphatab/model/KeySignature'; +import { KeySignatureType } from '@coderline/alphatab/model/KeySignatureType'; import { MasterBar } from '@coderline/alphatab/model/MasterBar'; import { ModelUtils } from '@coderline/alphatab/model/ModelUtils'; import { Score } from '@coderline/alphatab/model/Score'; @@ -33,6 +35,8 @@ export class TablEditImporter extends ScoreImporter { 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 _maxTextCount = 4096; private static readonly _maxReadingListCount = 4096; private static readonly _readingGuideMarker = 0xb7; @@ -45,6 +49,9 @@ export class TablEditImporter extends ScoreImporter { 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'; @@ -72,12 +79,21 @@ export class TablEditImporter extends ScoreImporter { const cells = this._readContents(this._u32(60)); const builder = new TefBarBuilder(this._tracks, this._measureStarts, this._measureSizes); + builder.pickupBars = this._pickupBars; 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.build(cells); + for (const track of this._score.tracks) { + const bars = track.staves[0].bars; + for (let b = 0; b < bars.length && b < this._keys.length; b++) { + bars[b].keySignature = Math.max(-7, Math.min(7, this._keys[b])) as KeySignature; + bars[b].keySignatureType = this._minorBars.has(b) ? KeySignatureType.Minor : KeySignatureType.Major; + } + } + const readingList = this._readReadingList(this._u32(128)); TefReadingList.apply(this._score, readingList, this._readGuides(cells)); @@ -130,6 +146,15 @@ export class TablEditImporter extends ScoreImporter { 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; diff --git a/packages/alphatab/src/importer/tablEdit/TefBarBuilder.ts b/packages/alphatab/src/importer/tablEdit/TefBarBuilder.ts index 9c401f520..62fe383a1 100644 --- a/packages/alphatab/src/importer/tablEdit/TefBarBuilder.ts +++ b/packages/alphatab/src/importer/tablEdit/TefBarBuilder.ts @@ -192,6 +192,11 @@ export class TefBarBuilder { 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; @@ -204,6 +209,7 @@ export class TefBarBuilder { } 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] & 0x07) !== 0) { @@ -236,6 +242,40 @@ export class TefBarBuilder { this._applyGlobalMarkers(cells); } + /** + * 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); + } + private _applyGlobalMarkers(cells: TefCell[]): void { const score = this._tracks[0].track.score; const syncopation: number[] = []; @@ -247,7 +287,7 @@ export class TefBarBuilder { for (const cell of cells) { const data = cell.data; const bar = this._barOf(cell.position); - const column = cell.position - this._measureStarts[bar]; + const column = Math.max(0, this._columnOf(cell.position, bar)); switch (data[0]) { case TefBarBuilder._tempoMarker: { const bpm = data[1] | (data[2] << 8); @@ -341,7 +381,7 @@ export class TefBarBuilder { private _applyTrackMarker(info: TefTrackInfo, cell: TefCell): void { const data = cell.data; const bar = this._barOf(cell.position); - const column = cell.position - this._measureStarts[bar]; + const column = Math.max(0, this._columnOf(cell.position, bar)); const beat = this._beatAt(bar, column); if (!beat) { return; @@ -481,7 +521,7 @@ export class TefBarBuilder { e.voiceHint = (cell.data[2] >> 4) & 0x03; e.accidental = cell.data[2] >> 6; e.bar = this._barOf(cell.position); - e.column = cell.position - this._measureStarts[e.bar]; + e.column = this._columnOf(cell.position, e.bar); return e; } From b2497771ddf687a7fa9bc4df452892f4fb885678 Mon Sep 17 00:00:00 2001 From: Danielku15 Date: Sun, 1 Nov 2026 19:18:37 +0100 Subject: [PATCH 11/21] feat(importer): apply TablEdit lyrics to the beats of their track --- .../alphatab/src/importer/TablEditImporter.ts | 1 + .../src/importer/tablEdit/TefBarBuilder.ts | 129 ++++++++++++++++++ 2 files changed, 130 insertions(+) diff --git a/packages/alphatab/src/importer/TablEditImporter.ts b/packages/alphatab/src/importer/TablEditImporter.ts index dea6975e6..53f4660f9 100644 --- a/packages/alphatab/src/importer/TablEditImporter.ts +++ b/packages/alphatab/src/importer/TablEditImporter.ts @@ -85,6 +85,7 @@ export class TablEditImporter extends ScoreImporter { builder.texts = this._readTexts(this._u32(84)); builder.chords = this._readChords(this._u32(88)); builder.build(cells); + builder.applyLyrics(this._readString(this._u32(80))); for (const track of this._score.tracks) { const bars = track.staves[0].bars; diff --git a/packages/alphatab/src/importer/tablEdit/TefBarBuilder.ts b/packages/alphatab/src/importer/tablEdit/TefBarBuilder.ts index 62fe383a1..d89c75ee1 100644 --- a/packages/alphatab/src/importer/tablEdit/TefBarBuilder.ts +++ b/packages/alphatab/src/importer/tablEdit/TefBarBuilder.ts @@ -276,6 +276,135 @@ export class TefBarBuilder { return position - this._measureStarts[bar] - (offset === undefined ? 0 : offset); } + /** + * Applies the lyrics block: one section per track, introduced by '>' and the track letter. + */ + public applyLyrics(text: string): void { + const sections = text.split('>'); + for (const section of sections) { + if (section.length === 0) { + continue; + } + const trackIndex = section.charCodeAt(0) - 65; + if (trackIndex < 0 || trackIndex >= this._tracks.length) { + continue; + } + 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}`; + } + } + for (let v = 0; v < verses.length; v++) { + this._applyVerse(this._tracks[trackIndex].track, v, TefBarBuilder._syllables(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; + } + + private _applyVerse(track: Track, verse: number, syllables: string[]): void { + let index = 0; + for (const bar of track.staves[0].bars) { + for (const beat of bar.voices[0].beats) { + if (index >= syllables.length) { + return; + } + 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]; + index++; + } + } + } + + /** + * 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. + */ + private static _syllables(text: string): string[] { + const syllables: string[] = []; + let current = ''; + let inChord = false; + let pendingSpace = false; + for (let i = 0; i < text.length; i++) { + const c = text.charAt(i); + if (inChord) { + inChord = c !== ']'; + continue; + } + switch (c) { + case '[': + inChord = true; + break; + case ' ': + case '\t': + if (current.length > 0) { + syllables.push(current); + current = ''; + } else if (pendingSpace) { + syllables.push(''); + } + pendingSpace = true; + break; + case '-': + case '~': + syllables.push(c === '-' ? `${current}-` : current); + current = ''; + pendingSpace = false; + break; + case '+': + current += ' '; + break; + case '_': + current += '-'; + break; + default: + current += c; + pendingSpace = false; + break; + } + } + if (current.length > 0) { + syllables.push(current); + } + return syllables; + } + private _applyGlobalMarkers(cells: TefCell[]): void { const score = this._tracks[0].track.score; const syncopation: number[] = []; From cad7de801f8ac0809d41d3fbb1455fdd4197dd6b Mon Sep 17 00:00:00 2001 From: Danielku15 Date: Mon, 2 Nov 2026 21:29:17 +0100 Subject: [PATCH 12/21] feat(importer): read more of the TablEdit format and follow TablEdit's reading of it Corrections found with files entered in TablEdit: - the instrument bank is the bank MSB - transposing instruments show their written key - chord diagrams take the string count of the instrument they are used on - a reading list with an unknown record size is ignored, and repeat signs are only taken when the file has no reading list - artificial harmonics use the stored harmonic fret, also from the combination byte; natural harmonics get the harmonic value of their fret - grace notes keep their stored fret, also from another string, and a hammer-on grace from another string keeps its slur - trills use the stored trill fret - bends and the tremolo bar take their amount from the next note on the string, tied or not - mordents and turns take their direction from their auxiliary notes - the ghost combination with the ghost effect means no effect - crescendos without length or amount are ignored - accents apply to the notes starting at the marker - gaps after dotted 64ths and incomplete tuplets are filled with 1/128 or tuplet rests - only the first lyrics section of a module is used Additions: - tremolo bar up/down and dive-return as whammy bar - roll and brush speed as brush duration (bits 4-6, 5 TablEdit ticks per step) - excluded-from-beaming notes and secondary beam breaks on rests; free bars are beamed across the bar or its halves - hidden notes and rests where no other shown staff displays them - measures per line from line-break markers - numbered connections as n-tuplets of n : m/2, timed like TablEdit - standalone pick strokes - the let-ring instrument option - chord names in lyrics and lyric text markers - effect parameters stored in the extension record --- .../alphatab/src/importer/TablEditImporter.ts | 86 ++- .../src/importer/tablEdit/TefBarBuilder.ts | 703 +++++++++++++++--- 2 files changed, 665 insertions(+), 124 deletions(-) diff --git a/packages/alphatab/src/importer/TablEditImporter.ts b/packages/alphatab/src/importer/TablEditImporter.ts index 53f4660f9..619fee4d1 100644 --- a/packages/alphatab/src/importer/TablEditImporter.ts +++ b/packages/alphatab/src/importer/TablEditImporter.ts @@ -11,9 +11,10 @@ import { IOHelper } from '@coderline/alphatab/io/IOHelper'; import { OverflowError } from '@coderline/alphatab/io/IReadable'; 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 { MasterBar } from '@coderline/alphatab/model/MasterBar'; +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'; @@ -37,10 +38,17 @@ export class TablEditImporter extends ScoreImporter { 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 _abbreviationSeparator = '^'; private static readonly _cellEnd = 4294967295; private _bytes!: Uint8Array; @@ -84,19 +92,20 @@ export class TablEditImporter extends ScoreImporter { 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); builder.build(cells); builder.applyLyrics(this._readString(this._u32(80))); - for (const track of this._score.tracks) { - const bars = track.staves[0].bars; + 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 = Math.max(-7, Math.min(7, this._keys[b])) as KeySignature; + bars[b].keySignature = TablEditImporter._writtenKey(this._keys[b], info.keyShift); bars[b].keySignatureType = this._minorBars.has(b) ? KeySignatureType.Minor : KeySignatureType.Major; } } const readingList = this._readReadingList(this._u32(128)); - TefReadingList.apply(this._score, readingList, this._readGuides(cells)); + TefReadingList.apply(this._score, readingList, readingList.length > 0 ? [] : this._readGuides(cells)); ModelUtils.consolidate(this._score); this._score.finish(this.settings); @@ -162,6 +171,13 @@ export class TablEditImporter extends ScoreImporter { 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; @@ -169,6 +185,26 @@ export class TablEditImporter extends ScoreImporter { } } + /** + * 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'); @@ -185,9 +221,16 @@ export class TablEditImporter extends ScoreImporter { track.ensureStaveCount(1); const staff: Staff = track.staves[0]; this._score.addTrack(track); - track.name = this._readCString(p + 32, 36); + const name = this._readCString(p + 32, 36); + const separator = name.indexOf(TablEditImporter._abbreviationSeparator); + if (separator >= 0) { + track.name = name.substring(0, separator).trim(); + track.shortName = name.substring(separator + 1).trim(); + } else { + track.name = name; + } track.playbackInfo.program = this._u8(p + 8); - track.playbackInfo.bank = this._u8(p + 9); + track.playbackInfo.bank = this._u8(p + 9) * TablEditImporter._bankMsbFactor; const capo = this._u16(p + 12); const openPitches: number[] = []; @@ -204,8 +247,8 @@ export class TablEditImporter extends ScoreImporter { } staff.stringTuning.tunings = tuning; } - staff.displayTranspositionPitch = - this._s8(p + 14) - 12 - TablEditImporter._transpositionOf(this._u8(p + 11) & 0x07); + const transposition = this._u8(p + 11) & 0x07; + staff.displayTranspositionPitch = this._s8(p + 14) - 12 - TablEditImporter._transpositionOf(transposition); switch ((this._u8(p + 16) >> 4) & 0x03) { case 1: @@ -231,6 +274,8 @@ export class TablEditImporter extends ScoreImporter { info.openPitches = openPitches; info.isPercussion = isPercussion; info.clef = TablEditImporter._clefOf(this._u8(p + 15) & 0x07, openPitches); + info.keyShift = (transposition & 0x03) === 0 ? 0 : 4 - transposition; + info.isLetRing = (this._u16(p + 18) & TablEditImporter._letRingOption) !== 0; this._tracks.push(info); this._totalStrings += stringCount; p += recordSize; @@ -243,6 +288,9 @@ export class TablEditImporter extends ScoreImporter { 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; @@ -289,6 +337,10 @@ export class TablEditImporter extends ScoreImporter { 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) { @@ -301,8 +353,7 @@ export class TablEditImporter extends ScoreImporter { for (let i = 0; i < count; i++) { const chord = new Chord(); chord.name = this._readCString(p + 14, 17); - const stringCount = this._tracks.length > 0 ? this._tracks[0].stringCount : 6; - for (let s = 0; s < stringCount && s < 14; s++) { + for (let s = 0; s < TablEditImporter._chordStringBytes; s++) { const value = this._u8(p + s); chord.strings.push(value === 0xff ? -1 : value & 0x1f); } @@ -314,6 +365,19 @@ export class TablEditImporter extends ScoreImporter { 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. */ diff --git a/packages/alphatab/src/importer/tablEdit/TefBarBuilder.ts b/packages/alphatab/src/importer/tablEdit/TefBarBuilder.ts index d89c75ee1..25cc750f2 100644 --- a/packages/alphatab/src/importer/tablEdit/TefBarBuilder.ts +++ b/packages/alphatab/src/importer/tablEdit/TefBarBuilder.ts @@ -1,10 +1,10 @@ import { AccentuationType } from '@coderline/alphatab/model/AccentuationType'; import { Automation, AutomationType } from '@coderline/alphatab/model/Automation'; import { Bar } from '@coderline/alphatab/model/Bar'; -import { Beat } from '@coderline/alphatab/model/Beat'; +import { Beat, BeatBeamingMode } from '@coderline/alphatab/model/Beat'; import { BendPoint } from '@coderline/alphatab/model/BendPoint'; import { BrushType } from '@coderline/alphatab/model/BrushType'; -import type { Chord } from '@coderline/alphatab/model/Chord'; +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'; @@ -24,6 +24,7 @@ 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'; @@ -60,6 +61,26 @@ export class TefTrackInfo { 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; } /** @@ -129,11 +150,16 @@ class TefEvent { 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; } /** @@ -157,10 +183,14 @@ class TefUnit { */ export class TefBarBuilder { /** - * Sub-units per grid cell. 2520 is the least common multiple of 1..10, - * which keeps the members of every tuplet kind on whole sub-units. + * 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. */ - public static readonly subUnitsPerCell = 2520; + private static readonly _smallestPlain = TefBarBuilder.subUnitsPerCell / 2; private static readonly _restCode = 0x33; private static readonly _tieDynamics = 7; @@ -170,6 +200,29 @@ export class TefBarBuilder { 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; @@ -177,6 +230,7 @@ export class TefBarBuilder { 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; @@ -201,6 +255,15 @@ export class TefBarBuilder { 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; @@ -212,11 +275,20 @@ export class TefBarBuilder { this._trimPickupBars(cells); const extensions = new Map(); for (const cell of cells) { - if (cell.data[0] === TefBarBuilder._extensionMarker && (cell.data[1] & 0x07) !== 0) { + 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) { @@ -226,12 +298,15 @@ export class TefBarBuilder { 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) { @@ -242,6 +317,59 @@ export class TefBarBuilder { 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. */ @@ -277,18 +405,21 @@ export class TefBarBuilder { } /** - * Applies the lyrics block: one section per track, introduced by '>' and the track letter. + * 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) { + 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++) { @@ -302,8 +433,15 @@ export class TefBarBuilder { 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++) { - this._applyVerse(this._tracks[trackIndex].track, v, TefBarBuilder._syllables(verses[v])); + TefBarBuilder._applySyllables(beats, 0, v, verses[v]); } } } @@ -330,60 +468,92 @@ export class TefBarBuilder { return i; } - private _applyVerse(track: Track, verse: number, syllables: string[]): void { + /** + * 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 (const bar of track.staves[0].bars) { - for (const beat of bar.voices[0].beats) { - if (index >= syllables.length) { - return; - } - 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]; - index++; + 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): 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) { - inChord = c !== ']'; + 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; @@ -399,8 +569,9 @@ export class TefBarBuilder { break; } } - if (current.length > 0) { + if (current.length > 0 || pendingChord.length > 0) { syllables.push(current); + chordNames.push(pendingChord); } return syllables; } @@ -413,6 +584,8 @@ export class TefBarBuilder { } 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); @@ -430,17 +603,24 @@ export class TefBarBuilder { syncopation[bar] = Math.max(-3, Math.min(3, data[1] - 8)); break; case TefBarBuilder._lineBreakMarker: - if (bar + 1 < score.masterBars.length) { - for (const t of this._tracks) { - t.track.addLineBreaks(bar + 1); - } - } + 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])) { @@ -452,6 +632,14 @@ export class TefBarBuilder { } } + 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. */ @@ -520,7 +708,10 @@ export class TefBarBuilder { const index = data[1] | (data[2] << 8); if (index < this.texts.length) { const text = TefBarBuilder._plainText(this.texts[index]); - if (text.length > 0) { + 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; } } @@ -532,22 +723,41 @@ export class TefBarBuilder { const id = `${index}`; const staff = info.track.staves[0]; if (!staff.hasChord(id)) { - staff.addChord(id, this.chords[index]); + staff.addChord(id, TefBarBuilder._chordForStaff(this.chords[index], staff)); } beat.chordId = id; } break; } case TefBarBuilder._accentMarker: - for (const n of beat.notes) { - n.accentuated = AccentuationType.Normal; + 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 type = (data[1] & 0x10) !== 0 ? CrescendoType.Decrescendo : CrescendoType.Crescendo; - this._applyToRange(bar, column, data[2], b => { - b.crescendo = type; - }); + 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: @@ -558,8 +768,24 @@ export class TefBarBuilder { } break; case TefBarBuilder._connectionMarker: - if ((data[7] & 0x80) === 0) { - this._applySlur(bar, column, data[1] + 1); + 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: @@ -574,6 +800,34 @@ export class TefBarBuilder { } } + /** + * 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); @@ -589,14 +843,15 @@ export class TefBarBuilder { } } - private _applySlur(bar: number, column: number, length: number): void { + /** + * 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; } - const from = column * TefBarBuilder.subUnitsPerCell; - const to = (column + length) * TefBarBuilder.subUnitsPerCell; let first: Beat | null = null; let last: Beat | null = null; for (let i = 0; i < starts.length; i++) { @@ -809,10 +1064,14 @@ export class TefBarBuilder { let runCode = -1; let runAnchor = 0; for (const e of events) { - const d = TefBarBuilder._durationOf(e.durationCode)!; + const d = TefBarBuilder._eventDuration(e); const column = e.column; let start = column * unit; - if (TefBarBuilder._isTriplet(e.durationCode)) { + 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); @@ -874,7 +1133,7 @@ export class TefBarBuilder { 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}`; + 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(); @@ -883,7 +1142,7 @@ export class TefBarBuilder { unit.bar = bar; unit.isRest = e.isRest; unit.voiceGroup = group; - const d = TefBarBuilder._durationOf(e.durationCode)!; + const d = TefBarBuilder._eventDuration(e); if (d.length === e.realEnd - e.realStart) { unit.duration = d; } @@ -1010,13 +1269,15 @@ export class TefBarBuilder { ): 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, recordBar); + this._addRests(voice, cursor, u.start, u.duration, previous, recordBar); } let end = u.end; if (i + 1 < units.length && units[i + 1].start < end) { @@ -1046,15 +1307,88 @@ export class TefBarBuilder { 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, recordBar); + 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); @@ -1068,11 +1402,39 @@ export class TefBarBuilder { beats.push(beat); } - private _addRests(voice: Voice, start: number, end: number, next: TefDuration | null, recordBar: number): void { - const values = - next && next.isTuplet && (end - start) % next.length === 0 - ? TefBarBuilder._repeat(next, (end - start) / next.length) - : TefBarBuilder._splitPlain(start, end); + /** + * 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); @@ -1099,9 +1461,12 @@ export class TefBarBuilder { const cell = TefBarBuilder.subUnitsPerCell; const values: TefDuration[] = []; let t = start; - while (end - t >= cell) { + while (end - t >= TefBarBuilder._smallestPlain) { let cells = 64; - while (cells > 1 && ((t / cell) % cells !== 0 || t + cells * cell > end)) { + 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)); @@ -1110,6 +1475,30 @@ export class TefBarBuilder { 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; @@ -1152,7 +1541,12 @@ export class TefBarBuilder { } beat.addNote(note); if (!isContinuation) { - this._applyEffects(beat, note, e, unit); + this._applyEffects(info, beat, note, e, unit); + if (e.slurredGrace) { + e.slurredGrace.slurDestination = note; + note.slurOrigin = e.slurredGrace; + note.isSlurDestination = true; + } } } if (loudest < 7) { @@ -1173,21 +1567,18 @@ export class TefBarBuilder { if (kind >= 4) { continue; } + const extension = TefBarBuilder._graceExtension(e); let stringIndex = e.stringIndex; - let fret = data[3] & 0x1f; - if (e.extension) { - if ((e.extension[3] & 0x80) !== 0) { - stringIndex = e.extension[3] & 0x0f; - } - if ((e.extension[4] & 0x80) !== 0) { - fret = e.extension[4] & 0x3f; + 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; } } - if (kind === 0) { - fret = e.fret; - } // a grace on the main note's fret without its own duration is neither shown nor played - const hasDuration = e.extension !== null && (e.extension[1] & 0x07) > 1; + const hasDuration = extension !== null && (extension[1] & TefBarBuilder._graceDurationMask) > 1; if (fret === e.fret && stringIndex === e.stringIndex && !hasDuration) { continue; } @@ -1195,7 +1586,7 @@ export class TefBarBuilder { grace = new Beat(); grace.duration = Duration.ThirtySecond; grace.graceType = - e.extension && (e.extension[7] & TefBarBuilder._acciaccaturaFlag) !== 0 + extension && (extension[7] & TefBarBuilder._acciaccaturaFlag) !== 0 ? GraceType.BeforeBeat : GraceType.OnBeat; voice.addBeat(grace); @@ -1205,7 +1596,12 @@ export class TefBarBuilder { note.fret = fret; switch (kind) { case 1: - note.isHammerPullOrigin = true; + // 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; @@ -1218,34 +1614,75 @@ export class TefBarBuilder { } } + 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(beat: Beat, note: Note, e: TefEvent, unit: TefUnit): void { + private _applyEffects(info: TefTrackInfo, beat: Beat, note: Note, e: TefEvent, unit: TefUnit): void { const data = e.cell.data; let effect = data[2] & 0x0f; - const combination = data[4] >> 4; - if (effect === 0 && combination !== 3 && (data[4] & 0x0f) !== 0) { + 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; - const parameter = hasParameter ? data[3] : 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, combination === 11); + TefBarBuilder._applyEffect(beat, note, e, unit, effect, parameter, hasParameterByte); switch (combination) { case 1: case 2: note.isHammerPullOrigin = true; break; case 3: - TefBarBuilder._applyEffect(beat, note, e, unit, 14, 0, false); + TefBarBuilder._applyBrush( + beat, + e, + unit, + true, + hasParameterByte && (parameter & 0x0f) !== 0 ? parameter & 0x07 : 4 + ); break; case 5: - TefBarBuilder._applyEffect(beat, note, e, unit, 5, 0, false); + 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; @@ -1261,19 +1698,27 @@ export class TefBarBuilder { note.isDead = true; break; } + if (info.isLetRing) { + note.isLetRing = true; + } if ((data[0] & TefBarBuilder._graceFlag) !== 0) { switch (data[3] >> 5) { case 4: - note.ornament = NoteOrnament.UpperMordent; + // 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 = NoteOrnament.Turn; + note.ornament = TefBarBuilder._turnStartsBelow(e) ? NoteOrnament.InvertedTurn : NoteOrnament.Turn; break; - case 7: - note.trillValue = note.realValue + 2; + case 7: { + const trillFret = TefBarBuilder._graceFret(e); + note.trillValue = + trillFret !== e.fret && !info.isPercussion ? trillFret + note.stringTuning : note.realValue + 2; break; + } } } @@ -1295,13 +1740,9 @@ export class TefBarBuilder { if (rightHand > 0) { note.rightHandFinger = TefBarBuilder._fingerOf(rightHand - 1); } - switch (data[6] >> 5) { - case 3: - beat.pickStroke = PickStroke.Up; - break; - case 4: - beat.pickStroke = PickStroke.Down; - break; + const stroke = TefBarBuilder._pickStrokeOf(data[6]); + if (stroke !== PickStroke.None) { + beat.pickStroke = stroke; } switch (data[7] & 0x44) { @@ -1321,7 +1762,7 @@ export class TefBarBuilder { unit: TefUnit, effect: number, parameter: number, - isVariation: boolean + hasParameter: boolean ): void { switch (effect) { case 1: @@ -1337,15 +1778,21 @@ export class TefBarBuilder { TefBarBuilder._addBend(note, Math.max(1, parameter & 0x0f), false); break; case 5: - case 14: - TefBarBuilder._applyBrush(beat, e, unit, effect === 14); + 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._artificialHarmonicOffset(parameter); + note.harmonicValue = TefBarBuilder._artificialHarmonicFret(parameter, e.cell.data[4]); break; case 8: note.isPalmMute = true; @@ -1364,7 +1811,7 @@ export class TefBarBuilder { } case 12: case 13: - if (e.next && e.next.isTied && e.next.fret !== e.fret) { + if (e.next && e.next.fret !== e.fret) { TefBarBuilder._addBend(note, (e.next.fret - e.fret) * 2, effect === 13); } break; @@ -1383,6 +1830,12 @@ export class TefBarBuilder { 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; @@ -1399,7 +1852,7 @@ export class TefBarBuilder { * 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): void { + 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) { @@ -1412,11 +1865,28 @@ export class TefBarBuilder { } 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 { - const stroke = e.cell.data[6] >> 5; - return stroke === 3 || stroke === 4; + 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 { @@ -1435,19 +1905,26 @@ export class TefBarBuilder { } } - private static _artificialHarmonicOffset(parameter: number): number { - if ((parameter & 0xf0) === 0xb0) { - switch (parameter & 0x0f) { - case 3: - return 31; - case 4: - case 9: - return 28; - case 5: - return 24; - case 7: - return 19; - } + /** + * 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; } From c8d86201ea93895dd79e477a63265ae2aba0ef46 Mon Sep 17 00:00:00 2001 From: Danielku15 Date: Mon, 2 Nov 2026 20:59:33 +0100 Subject: [PATCH 13/21] feat(importer): read TablEdit 2 files (formats 3 to 9) TablEdit 2.6x and later 2.x versions save .tef files in formats 3 to 9. Their container has nothing in common with the TablEdit 3 one: a fixed header with counts instead of a pointer table, sequential blocks, 6-byte grid records addressed per measure slot, measures of the header's longest size whose shorter variants are right-aligned and announced by a cell, keys in the header or a per-measure block, and partly different cell codes. The records inside (instruments, chords, texts) and the meaning of the note bytes are the ones of TablEdit 3. So TablEdit 2 gets its own container reader, TefFormat2Reader, which translates the measures and the grid into the measure geometry and cell layout the TablEdit 3 path produces. The importer keeps one entry point, detects the format and hands both paths to the shared instrument and chord record decoding, TefBarBuilder and TefReadingList, which stay unchanged. Byte access and text decoding move into TefBinary for both readers. Formats 1 and 2 (TablEdit 1.x and early 2.x) are still rejected with a hint to re-save the file with TablEdit 3. Protected TablEdit 2 files are rejected like protected TablEdit 3 files. Drum and rhythm patterns of TablEdit 2 are skipped with a warning. --- .../alphatab/src/importer/TablEditImporter.ts | 228 +++--- .../src/importer/tablEdit/TefBinary.ts | 121 ++++ .../src/importer/tablEdit/TefFormat2Reader.ts | 663 ++++++++++++++++++ 3 files changed, 893 insertions(+), 119 deletions(-) create mode 100644 packages/alphatab/src/importer/tablEdit/TefBinary.ts create mode 100644 packages/alphatab/src/importer/tablEdit/TefFormat2Reader.ts diff --git a/packages/alphatab/src/importer/TablEditImporter.ts b/packages/alphatab/src/importer/TablEditImporter.ts index 619fee4d1..f7de2d385 100644 --- a/packages/alphatab/src/importer/TablEditImporter.ts +++ b/packages/alphatab/src/importer/TablEditImporter.ts @@ -1,14 +1,14 @@ 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 { IOHelper } from '@coderline/alphatab/io/IOHelper'; -import { OverflowError } from '@coderline/alphatab/io/IReadable'; import { Chord } from '@coderline/alphatab/model/Chord'; import { Clef } from '@coderline/alphatab/model/Clef'; import { Duration } from '@coderline/alphatab/model/Duration'; @@ -21,11 +21,12 @@ import type { Staff } from '@coderline/alphatab/model/Staff'; import { Track } from '@coderline/alphatab/model/Track'; /** - * This ScoreImporter can read TablEdit (.tef) files of format version 3. + * 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; @@ -51,8 +52,11 @@ export class TablEditImporter extends ScoreImporter { private static readonly _abbreviationSeparator = '^'; private static readonly _cellEnd = 4294967295; - private _bytes!: Uint8Array; + private _data!: TefBinary; private _score!: Score; + private _cells: TefCell[] = []; + private _lyrics: string = ''; + private _readingList: TefReadingListItem[] = []; private _measureStarts: number[] = []; private _measureSizes: number[] = []; private _tracks: TefTrackInfo[] = []; @@ -66,35 +70,23 @@ export class TablEditImporter extends ScoreImporter { } public readScore(): Score { - this._bytes = this.data.readAll(); + this._data = new TefBinary(this.data.readAll()); const format = this._detectFormat(); - if (format < TablEditImporter._firstSupportedFormat) { + if (format < TablEditImporter._firstFormat2) { throw new FormatError( - 'TablEdit files older than version 3 are not supported, re-save the file with TablEdit 3' + '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`); } - if (this._u32(16) !== 0 || format === TablEditImporter._protectedFormat) { - throw new FormatError('Protected TablEdit files are not supported'); - } this._score = new Score(); - this._readInformation(); - this._readMeasures(this._u32(92)); - this._readInstruments(this._u32(96)); - const cells = this._readContents(this._u32(60)); - - const builder = new TefBarBuilder(this._tracks, this._measureStarts, this._measureSizes); + const builder = + format < TablEditImporter._firstSupportedFormat ? this._readFormat2() : this._readFormat3(format); builder.pickupBars = this._pickupBars; - 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); - builder.build(cells); - builder.applyLyrics(this._readString(this._u32(80))); + builder.build(this._cells); + builder.applyLyrics(this._lyrics); for (const info of this._tracks) { const bars = info.track.staves[0].bars; @@ -104,8 +96,8 @@ export class TablEditImporter extends ScoreImporter { } } - const readingList = this._readReadingList(this._u32(128)); - TefReadingList.apply(this._score, readingList, readingList.length > 0 ? [] : this._readGuides(cells)); + 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); @@ -114,12 +106,69 @@ export class TablEditImporter extends ScoreImporter { 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._bytes.length < TablEditImporter._headerSize) { + if (this._data.length < TablEditImporter._headerSize) { throw new UnsupportedFormatError('Data too short for a TablEdit file'); } const numerator = this._u16(202); @@ -209,19 +258,21 @@ export class TablEditImporter extends ScoreImporter { if (offset === 0) { throw new FormatError('TablEdit file without instruments'); } - const recordSize = this._u16(offset); - const count = this._u16(offset + 2); + 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 + 4; + let p = offset; for (let i = 0; i < count; i++) { - const stringCount = this._u16(p); + 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); - const name = this._readCString(p + 32, 36); + const name = data.readCString(p + 32, 36); const separator = name.indexOf(TablEditImporter._abbreviationSeparator); if (separator >= 0) { track.name = name.substring(0, separator).trim(); @@ -229,15 +280,15 @@ export class TablEditImporter extends ScoreImporter { } else { track.name = name; } - track.playbackInfo.program = this._u8(p + 8); - track.playbackInfo.bank = this._u8(p + 9) * TablEditImporter._bankMsbFactor; + track.playbackInfo.program = data.u8(p + 8); + track.playbackInfo.bank = data.u8(p + 9) * TablEditImporter._bankMsbFactor; - const capo = this._u16(p + 12); + const capo = data.u16(p + 12); const openPitches: number[] = []; for (let s = 0; s < stringCount; s++) { - openPitches.push(96 - this._u8(p + 20 + s)); + openPitches.push(96 - data.u8(p + 20 + s)); } - const isPercussion = (this._u8(p + 4) & TablEditImporter._drumFlag) !== 0; + const isPercussion = (data.u8(p + 4) & TablEditImporter._drumFlag) !== 0; staff.isPercussion = isPercussion; if (!isPercussion) { staff.capo = capo; @@ -247,10 +298,10 @@ export class TablEditImporter extends ScoreImporter { } staff.stringTuning.tunings = tuning; } - const transposition = this._u8(p + 11) & 0x07; - staff.displayTranspositionPitch = this._s8(p + 14) - 12 - TablEditImporter._transpositionOf(transposition); + const transposition = data.u8(p + 11) & 0x07; + staff.displayTranspositionPitch = data.s8(p + 14) - 12 - TablEditImporter._transpositionOf(transposition); - switch ((this._u8(p + 16) >> 4) & 0x03) { + switch ((data.u8(p + 16) >> 4) & 0x03) { case 1: staff.showTablature = true; staff.showStandardNotation = false; @@ -264,8 +315,8 @@ export class TablEditImporter extends ScoreImporter { staff.showStandardNotation = true; break; } - track.playbackInfo.balance = Math.round(((this._u8(p + 17) & 0x0f) * 16) / 15); - track.playbackInfo.volume = Math.round(((15 - (this._u8(p + 18) & 0x0f)) * 16) / 15); + 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; @@ -273,9 +324,9 @@ export class TablEditImporter extends ScoreImporter { info.stringCount = stringCount; info.openPitches = openPitches; info.isPercussion = isPercussion; - info.clef = TablEditImporter._clefOf(this._u8(p + 15) & 0x07, openPitches); + info.clef = TablEditImporter._clefOf(data.u8(p + 15) & 0x07, openPitches); info.keyShift = (transposition & 0x03) === 0 ? 0 : 4 - transposition; - info.isLetRing = (this._u16(p + 18) & TablEditImporter._letRingOption) !== 0; + info.isLetRing = (data.u16(p + 18) & TablEditImporter._letRingOption) !== 0; this._tracks.push(info); this._totalStrings += stringCount; p += recordSize; @@ -346,10 +397,13 @@ export class TablEditImporter extends ScoreImporter { if (offset === 0) { return chords; } - const recordSize = this._u16(offset); - const count = this._u16(offset + 2); + 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 + 4; + let p = offset; for (let i = 0; i < count; i++) { const chord = new Chord(); chord.name = this._readCString(p + 14, 17); @@ -433,7 +487,7 @@ export class TablEditImporter extends ScoreImporter { const cell = new TefCell(); cell.position = Math.floor(slot / this._totalStrings); cell.row = slot % this._totalStrings; - cell.data = this._bytes.subarray(p + 4, p + TablEditImporter._cellSize); + cell.data = this._data.bytes.subarray(p + 4, p + TablEditImporter._cellSize); if (cell.position < totalLength) { cells.push(cell); } @@ -446,98 +500,34 @@ export class TablEditImporter extends ScoreImporter { if (offset === 0) { return ''; } - const length = this._u16(offset); - this._ensureLoopBoundary(length, this.settings.importer.maxDecodingBufferSize, 'string length'); - this._requireRange(offset + 2, length); - let end = offset + 2 + length; - while (end > offset + 2 && this._bytes[end - 1] === 0) { - end--; - } - return TablEditImporter._decode(this._bytes.subarray(offset + 2, end)); + return this._data.readString(offset, this.settings.importer.maxDecodingBufferSize); } private _readCString(offset: number, maxLength: number): string { - this._requireRange(offset, maxLength); - let end = offset; - while (end < offset + maxLength && this._bytes[end] !== 0) { - end++; - } - return TablEditImporter._decode(this._bytes.subarray(offset, end)); - } - - /** - * Texts are UTF-8, but older files may still carry Windows-1252 text. - */ - private static _decode(data: Uint8Array): string { - return IOHelper.toString(data, TablEditImporter._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; - } - - private _requireRange(offset: number, length: number): void { - if (offset < 0 || offset + length > this._bytes.length) { - throw new FormatError(`TablEdit data at ${offset} (${length} bytes) is outside of the file`); - } + return this._data.readCString(offset, maxLength); } private _u8(offset: number): number { - this._requireRange(offset, 1); - return this._bytes[offset]; + return this._data.u8(offset); } private _s8(offset: number): number { - const v = this._u8(offset); - return v > 127 ? v - 256 : v; + return this._data.s8(offset); } private _s16(offset: number): number { - const v = this._u16(offset); - return v > 32767 ? v - 65536 : v; + return this._data.s16(offset); } private _u16(offset: number): number { - this._requireRange(offset, 2); - return this._bytes[offset] | (this._bytes[offset + 1] << 8); + return this._data.u16(offset); } private _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; + return this._data.u32(offset); } private _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}` - ); - } + TefBinary.ensureLoopBoundary(value, maximumValue, label); } } diff --git a/packages/alphatab/src/importer/tablEdit/TefBinary.ts b/packages/alphatab/src/importer/tablEdit/TefBinary.ts new file mode 100644 index 000000000..2ba40558d --- /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 000000000..b8d10acbb --- /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; + } + } +} From baf51c7a67a84f14c5bb85906153f592de016eb5 Mon Sep 17 00:00:00 2001 From: Danielku15 Date: Mon, 2 Nov 2026 21:29:17 +0100 Subject: [PATCH 14/21] feat(importer): read the abbreviated name of TablEdit instruments TablEdit keeps the abbreviation at byte 26 of the instrument name, introduced by '^' (a name entered as "Lead Guitar^Ld" is stored as "Lead Guitar", leftovers, "^Ld"); the bytes between the end of the name and byte 26 are leftovers of earlier names. A '^' within the name is part of the name, as TablEdit 3 shows it for TablEdit 2 files. --- .../alphatab/src/importer/TablEditImporter.ts | 20 +++++++++++-------- 1 file changed, 12 insertions(+), 8 deletions(-) diff --git a/packages/alphatab/src/importer/TablEditImporter.ts b/packages/alphatab/src/importer/TablEditImporter.ts index f7de2d385..08eb1e7c4 100644 --- a/packages/alphatab/src/importer/TablEditImporter.ts +++ b/packages/alphatab/src/importer/TablEditImporter.ts @@ -49,7 +49,10 @@ export class TablEditImporter extends ScoreImporter { private static readonly _chordStringBytes = 14; private static readonly _bankMsbFactor = 128; private static readonly _letRingOption = 0x0200; - private static readonly _abbreviationSeparator = '^'; + 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; @@ -272,13 +275,14 @@ export class TablEditImporter extends ScoreImporter { track.ensureStaveCount(1); const staff: Staff = track.staves[0]; this._score.addTrack(track); - const name = data.readCString(p + 32, 36); - const separator = name.indexOf(TablEditImporter._abbreviationSeparator); - if (separator >= 0) { - track.name = name.substring(0, separator).trim(); - track.shortName = name.substring(separator + 1).trim(); - } else { - track.name = name; + 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; From 1881f657fa76e6ec92c59ac31157a8504fea6a59 Mon Sep 17 00:00:00 2001 From: Danielku15 Date: Sun, 1 Nov 2026 19:14:11 +0100 Subject: [PATCH 15/21] style(importer): format the importers touched while adding the TablEdit importer Import order and line wrapping as the formatter writes them; no change in behaviour. --- packages/alphatab/src/Environment.ts | 9 ++++--- .../alphatab/src/importer/AlphaTexImporter.ts | 10 ++++++-- .../alphatab/src/importer/BinaryStylesheet.ts | 14 +++++------ .../alphatab/src/importer/CapellaImporter.ts | 11 +++----- .../alphatab/src/importer/CapellaParser.ts | 25 +++++++++++-------- .../alphatab/src/importer/Gp7To8Importer.ts | 22 ++++++++-------- packages/alphatab/src/importer/GpifParser.ts | 3 +-- packages/alphatab/src/importer/GpxImporter.ts | 11 +++++--- .../src/importer/LayoutConfiguration.ts | 2 +- .../alphatab/src/importer/MusicXmlImporter.ts | 8 ++---- .../alphatab/src/importer/ScoreImporter.ts | 2 +- .../alphatab/src/importer/alphaTex/ATNF.ts | 2 +- .../alphaTex/AlphaTex1EnumMappings.ts | 2 +- .../alphaTex/AlphaTex1LanguageDefinitions.ts | 1 + .../alphaTex/IAlphaTexMetaDataReader.ts | 2 +- 15 files changed, 66 insertions(+), 58 deletions(-) diff --git a/packages/alphatab/src/Environment.ts b/packages/alphatab/src/Environment.ts index b76c9a0ad..9a32a300a 100644 --- a/packages/alphatab/src/Environment.ts +++ b/packages/alphatab/src/Environment.ts @@ -23,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'; @@ -36,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'; @@ -59,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'; diff --git a/packages/alphatab/src/importer/AlphaTexImporter.ts b/packages/alphatab/src/importer/AlphaTexImporter.ts index ce49854b1..e0e9c66ed 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 b0a9b68f0..696e70c8a 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 ad923fb75..f2e12a51d 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 c33343a1f..f4670cb4d 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 6d51ef8c6..8a4363bcf 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 7dae73de8..7007c10ea 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 dbc862168..3d3451ea9 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 27b77c95f..1a1ba8908 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 5e7ce159d..7a7c2b73c 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 e914c29c9..328a355a1 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/alphaTex/ATNF.ts b/packages/alphatab/src/importer/alphaTex/ATNF.ts index a9498f96c..b0de562d7 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 401d24a21..a2c12dd89 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 be1814849..ae128e3fa 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 9916ce13a..1532c4dbb 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'; From cfbb673f1338626c7b3ca1a3734c6d30b8b801d3 Mon Sep 17 00:00:00 2001 From: Danielku15 Date: Mon, 2 Nov 2026 21:29:27 +0100 Subject: [PATCH 16/21] test(importer): prove the TablEdit importer with files entered in TablEdit Files entered in TablEdit 3.06 (and some patched copies of them); each test asserts the entered values per variant: notes, accidentals, ties, dynamics, voices, durations and tuplets, numbered connections, effects and their parameters, grace notes and ornaments, fingering and strokes, attributes, instruments, clefs and drums, time and key signatures, pick-up and flag bits, text, chord, tempo, syncopation and patch markers, line breaks, lyrics and reading lists. The reading list structures and the corrupt file cases patch these files. The tests stay transpilable to C# and Kotlin. --- .../alphatab/test-data/corrupt/healthy.tef | Bin 0 -> 1252 bytes .../test-data/tabledit/attributes.tef | Bin 0 -> 1092 bytes .../test-data/tabledit/bars-flags.tef | Bin 0 -> 1252 bytes packages/alphatab/test-data/tabledit/bars.tef | Bin 0 -> 992 bytes .../test-data/tabledit/connections.tef | Bin 0 -> 1128 bytes .../alphatab/test-data/tabledit/drums.tef | Bin 0 -> 916 bytes .../alphatab/test-data/tabledit/durations.tef | Bin 0 -> 1561 bytes .../test-data/tabledit/effects-b5.tef | Bin 0 -> 1188 bytes .../test-data/tabledit/effects-chord.tef | Bin 0 -> 1252 bytes .../test-data/tabledit/effects-note.tef | Bin 0 -> 1188 bytes .../test-data/tabledit/effects-parameters.tef | Bin 0 -> 1080 bytes .../test-data/tabledit/effects-speed.tef | Bin 0 -> 1252 bytes .../test-data/tabledit/effects-target.tef | Bin 0 -> 964 bytes .../alphatab/test-data/tabledit/fermata.tef | Bin 0 -> 952 bytes .../alphatab/test-data/tabledit/fingering.tef | Bin 0 -> 1132 bytes .../test-data/tabledit/grace-kind0.tef | Bin 0 -> 1032 bytes .../test-data/tabledit/grace-string.tef | Bin 0 -> 940 bytes .../alphatab/test-data/tabledit/grace-x.tef | Bin 0 -> 1032 bytes .../alphatab/test-data/tabledit/grace.tef | Bin 0 -> 1056 bytes .../tabledit/instruments-patched.tef | Bin 0 -> 1600 bytes .../test-data/tabledit/instruments.tef | Bin 0 -> 995 bytes .../test-data/tabledit/line-breaks.tef | Bin 0 -> 948 bytes .../test-data/tabledit/lyrics-sections2.tef | Bin 0 -> 1123 bytes .../alphatab/test-data/tabledit/lyrics.tef | Bin 0 -> 1151 bytes .../test-data/tabledit/markers-layout-mpl.tef | Bin 0 -> 1416 bytes .../test-data/tabledit/markers-layout.tef | Bin 0 -> 1404 bytes .../test-data/tabledit/markers-patched.tef | Bin 0 -> 1116 bytes .../tabledit/markers-playback-syncope.tef | Bin 0 -> 1889 bytes .../test-data/tabledit/markers-playback.tef | Bin 0 -> 1889 bytes .../tabledit/markers-text-chords.tef | Bin 0 -> 1507 bytes .../test-data/tabledit/notes-dulcimer.tef | Bin 0 -> 1252 bytes .../alphatab/test-data/tabledit/notes.tef | Bin 0 -> 1252 bytes .../test-data/tabledit/reading-guides.tef | Bin 0 -> 1164 bytes .../test-data/tabledit/reading-list-segno.tef | Bin 0 -> 1205 bytes .../tabledit/reading-list-voltas.tef | Bin 0 -> 1112 bytes .../test-data/tabledit/reading-list.tef | Bin 0 -> 1176 bytes .../test-data/tabledit/score-info-url.tef | Bin 0 -> 954 bytes .../test-data/tabledit/score-info.tef | Bin 0 -> 928 bytes .../alphatab/test-data/tabledit/tuplets.tef | Bin 0 -> 1768 bytes .../alphatab/test-data/tabledit/voices.tef | Bin 0 -> 1128 bytes .../test/importer/TablEditImporter.test.ts | 2527 +++++++++++++++++ 41 files changed, 2527 insertions(+) create mode 100644 packages/alphatab/test-data/corrupt/healthy.tef create mode 100644 packages/alphatab/test-data/tabledit/attributes.tef create mode 100644 packages/alphatab/test-data/tabledit/bars-flags.tef create mode 100644 packages/alphatab/test-data/tabledit/bars.tef create mode 100644 packages/alphatab/test-data/tabledit/connections.tef create mode 100644 packages/alphatab/test-data/tabledit/drums.tef create mode 100644 packages/alphatab/test-data/tabledit/durations.tef create mode 100644 packages/alphatab/test-data/tabledit/effects-b5.tef create mode 100644 packages/alphatab/test-data/tabledit/effects-chord.tef create mode 100644 packages/alphatab/test-data/tabledit/effects-note.tef create mode 100644 packages/alphatab/test-data/tabledit/effects-parameters.tef create mode 100644 packages/alphatab/test-data/tabledit/effects-speed.tef create mode 100644 packages/alphatab/test-data/tabledit/effects-target.tef create mode 100644 packages/alphatab/test-data/tabledit/fermata.tef create mode 100644 packages/alphatab/test-data/tabledit/fingering.tef create mode 100644 packages/alphatab/test-data/tabledit/grace-kind0.tef create mode 100644 packages/alphatab/test-data/tabledit/grace-string.tef create mode 100644 packages/alphatab/test-data/tabledit/grace-x.tef create mode 100644 packages/alphatab/test-data/tabledit/grace.tef create mode 100644 packages/alphatab/test-data/tabledit/instruments-patched.tef create mode 100644 packages/alphatab/test-data/tabledit/instruments.tef create mode 100644 packages/alphatab/test-data/tabledit/line-breaks.tef create mode 100644 packages/alphatab/test-data/tabledit/lyrics-sections2.tef create mode 100644 packages/alphatab/test-data/tabledit/lyrics.tef create mode 100644 packages/alphatab/test-data/tabledit/markers-layout-mpl.tef create mode 100644 packages/alphatab/test-data/tabledit/markers-layout.tef create mode 100644 packages/alphatab/test-data/tabledit/markers-patched.tef create mode 100644 packages/alphatab/test-data/tabledit/markers-playback-syncope.tef create mode 100644 packages/alphatab/test-data/tabledit/markers-playback.tef create mode 100644 packages/alphatab/test-data/tabledit/markers-text-chords.tef create mode 100644 packages/alphatab/test-data/tabledit/notes-dulcimer.tef create mode 100644 packages/alphatab/test-data/tabledit/notes.tef create mode 100644 packages/alphatab/test-data/tabledit/reading-guides.tef create mode 100644 packages/alphatab/test-data/tabledit/reading-list-segno.tef create mode 100644 packages/alphatab/test-data/tabledit/reading-list-voltas.tef create mode 100644 packages/alphatab/test-data/tabledit/reading-list.tef create mode 100644 packages/alphatab/test-data/tabledit/score-info-url.tef create mode 100644 packages/alphatab/test-data/tabledit/score-info.tef create mode 100644 packages/alphatab/test-data/tabledit/tuplets.tef create mode 100644 packages/alphatab/test-data/tabledit/voices.tef create mode 100644 packages/alphatab/test/importer/TablEditImporter.test.ts diff --git a/packages/alphatab/test-data/corrupt/healthy.tef b/packages/alphatab/test-data/corrupt/healthy.tef new file mode 100644 index 0000000000000000000000000000000000000000..dcdf14d5aced267d573310c494ea76dfa1d7a1b0 GIT binary patch literal 1252 zcmchW!A`sgMgal$j)x=uBb$a1;-7I3Ta#)> zRz;B_Vr%(=TaAQ+o6ErKvy;HzbSpl)$6pq{@Tv3M|Hh^RH_$U|E2Q3|Y7e3?H%wfEz2Ayi) z=e)!NhEU-H#{1mmT{guD!j1v685$3!*;w6i12MKAEg1eILvaS5&w^r_CCVx`=ZWHq zEwJ930VkBs!1^3(QPwxZ2G)|)MRu~jyb4w|wEi=L z3D&dGCUjL}g5$d0CUTR}`6SQ_cat_Mg7XoR3vNX`DuXYTUIE`LeH;9&^cwh0>2+`` M;yZ9k)qKLf0Ghl}MgRZ+ literal 0 HcmV?d00001 diff --git a/packages/alphatab/test-data/tabledit/attributes.tef b/packages/alphatab/test-data/tabledit/attributes.tef new file mode 100644 index 0000000000000000000000000000000000000000..acdd11546452480eab92caa96632a7708909edb1 GIT binary patch literal 1092 zcmcgryGjE=6g^9p7(>LUC_X?2(UAzrPBK1{5W|8YT37@utXFq@6p6A~#nQ^!BA?<1 z_$gNYhThqiTO^(2>|E}db00GUcY$o?11}f{BQs77HCJ8vcRFqWd9pdOBH0pIj1}T- zvbU5L$Nt}&F!PhTPQE}6N*q*tb)z4WtOCrqm2HM}tnW}sHp*O!yqE#lPN^d6p>Xip5@STC{crFy&67<(m{Rj4}ydm&q2vM!ll7etty_C&%z0qj3kGd4-iT#=omQ@R) zI)lWfEi7Flq-YY(T0~PAjr6Ae9!ovvRx$p_EXD`%)28Y9HLr1>kvrr=#yu{1pH1-s z!HGUAGdg%U&4W{K%yxl!y{G?1bAqYIC*nEGCfr6L;T>HR8kpdO6Xzeh9xOGD8Lc literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..6c72f4ed414047ff80e0a2a24b5e7905105ee964 GIT binary patch literal 1252 zcmchV-%A2P5XZ+n><2<31co3R)P})%s~2{PnS_}Tgi#>$c6Mv@pvB!5J@zN~;NQ_x ze^CEP&7FHSfxd-vJNNzU%)FjL&0J7*?=sEN}dTa&c zA^JOs#kv1i1B{JDr!H6Vlnh8Ox7X;_Pn#COXuBK)JcP#E|XR&+k2 zwWakgqi@0AOnL7&?vAnpbANlG9WMj8mXYFsb_2f;k%b+mLwJ_ z1eYY{r6d-mFr+f1XflAHOKNVuLRo52F$2Q`Mgs#wLm)CX00LXXaAPYw3rkBIJ99Un zM1X;bk%57}z7bd!qyi{t(qmw3UitOqyYP`Xf~K!9){h=~Uh51xx%)B|YR)p+z|IC|hw zdr~fo-~i;<7}=gMEW@ryF;)j!zFAoQg080d>VIMnr|MzTqcHL< z0D4-kSoI)r`SK`jm)=Nw>e#LW0-Q3arza*)^i{YXz;}xCY879LqqVEIR4W}O4IBB9J=4tG%=WpY%HaodKa&Buh3d&s z<=lBe*KfBw-OB+d zhO+5OY1?j=O)FRodeTn!NKXcgn-04L%;#y~f9BFqJw_y6$R;seUbIvr9)v42v1y5f tL@Ycp;a?~XSw~G4apR=S#)TIegGEs13Cexq@iCV=YQRjS^91}begPK~J$(QG literal 0 HcmV?d00001 diff --git a/packages/alphatab/test-data/tabledit/durations.tef b/packages/alphatab/test-data/tabledit/durations.tef new file mode 100644 index 0000000000000000000000000000000000000000..e9758c0d94211236a62345a32219f6fbc5f9dce5 GIT binary patch literal 1561 zcmchX+iDX*9LB#i+fA*xs0w0fu_M@#3TY-;u}O$!)zH>+XhjhOyEPl}q6aqX;YL|S z5CowYeE=Up!3XFY_zHa!{Wdd`?nR3TlF1J9+i$-4A0{DyoBxToIBMkbN2yxtgd6dX zbUz22;XKPZ$vMqw#|-&1=dX-r_x}Itz!^U->hT4fLfkEF1ii3Ssg{EIIP9h!4W1&3 zHm2`5ef-yT-EseZuEmtv#%nYgRRIvQbMszJ3(K!w#z7?3;$W*2L>+|a%pv`>!p&{* zI*fY2K~AYkg^#B=u2mj;jm28M-dtQ*;znCtsVY@2SNprEgJQ#1<@Aj5RD)xsNz9DB zz4WEFYo&JZA{25*l!e?fg${L*&t+A{g3DFe6Ec#X+!RBspr0`oCBekKVJ6P>IO3ZZ z{y0Z&8a@r=EuW*lWb`TOw=^fyfMXcvfzi8K-zU341(>rSKVTJx>?e}lfZ}Z-vpYQ1q)CRqf-FsS};A(sE$%DJP&t7bu z4;lZN_M=N))12&NYKy2p(z^d9Qv?5(9jm`P{J!v>{wLc1Eur7sGd|G&RO{`J^qc#{ ZC)RB1`NFep?ZH=`wfDBmv&QqA;}6elW;Fl+ literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..412cc623e0ae2712bf6b52c7fd65610b41414b86 GIT binary patch literal 1188 zcmchW-%A2P5XZ;7><2<31co3R)P})%s~4tXCSlg25Y%gDw?z+{++ES5e}WG_M}I;O z{XzXFHFML|Jt*`L&h0Rt`_As{F3bX?lJD>gvn-LErLyMgelVtd0w9aNiJn8xqeoUi zzC?eCX;JR~qXEYHysGmLkb&UV>^r_TtEN*5^etWJXQUw&m24YEh<}P5ED^2T`mUf> z0K{&wWY<_Yd%O=EPh14f&~-c)G`K~eucHSK;z@fxz(+!LI3po;&ki+Pzi;VI{R%9SB zRHZMZCv7y5V^MrUiHh+gW2^rG&m<91Zvm!Q`d&DTv{A*&Hc|=@g9uwD@ XC&W$WSK?#lH{ugw0<_a#{zUr%aimK< literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..03dd882330f6f93f4b5ff0dce8b2bddb0b0747b6 GIT binary patch literal 1252 zcmchW!A`MeCq2N;Fm#!(Ghb)7hZ2=b-}FJ7qEOmYO>1E^jURNsKvcu3V>Pf&v9dS=cd2B;yqffh<*x6bvn2_g}Cb6dxKH0-ye=HeKaQO z!d1#~+@z}w6bEDF=x3Ct28i1sFc<5!zUh5srAhD<3Hd4UAYo+ zDLpwCJ3K+MV=P(%8?Sb`v3W-ux#*`iRiuL92DoHcfGvLy|3o&vsiNU3n0+w&V9Pvf zmvi&M?1R|{TmDfx|3LdGSHbLq*$4CdN^U-weK7lA?c-QvALo29`(XCLJinIn!R&+C V2WwyH`SqL+W*^KxIGg{A_ygcTN7?`Y literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..3debc94ac95c7bf46da284d3e356e3241c800ba1 GIT binary patch literal 1188 zcmcgrQA+|r5FYcm%#=b93PCog4TI}eFHFTu!mb{Lpq|g}j2^VO+oDH*LJvOoEBZk_ z^*3t9y{&st=pp>>hWYlJ-Pu`~0Wj0=@B;IYO3zc3<_7~gp?C_QfL27?LMx$Zl#ySf zy(Kgq`+skMkzJH^aRHkkht|N00;_I$hcXz_gMR)Tm^x+GFlO{mzK1FF$?Z6USOs8v zl|!eYgo~$p>4oe{dSl-UeF(s>0DZk+^uV5jFar2U2~n-$?+C<6^~Py+8qH?AbL?WE zFOF(L*mi9er5z}?y27S2!VxXRWgBS%(%$~@=rx!m-47qhkby&i-iylNXHf?Sj01m3*uNH!66YGe}*(uMi}f`kL9awGBJx!Uz{08OdJqbJTD_$F%n zI(`$Koox*VxEcQ4>HKzP_Lb}cq|Fa_foU?#X;B>uwJnWPeFul6%_xEPaIZ-aGWY- zRRsncZQ-af!W9kFc@uF$mGpJr3$e30aBjkJhBmU0ojb1AzR*xt*U-dzklFG A2LJ#7 literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..cbc1b492ee5ec8e643d19e4f15f38b92941ca4d5 GIT binary patch literal 1252 zcmchW!A`deB$SlslUF2u3 zm#zJOwa7Z!HFfs^sua(iX%K~u>jbShoa+nmQzkPTIkv0>lX?xMl&+klRUHtC^JdHI zZ5lUk&vCF4cX6pW+Ak zCpx>yN)GK!^0LFszBjWo3t0fC@dfX&307khTX3nE$p@ud03Ebrv=g*5G=p=D`)D5( z&S?MdZLnM0PTfAh5y-ha> zAZ}LAl4s;j@@2}i6a=JQP~Su>UdWrsa)57(G2h1(GQ_oiAI76-Fc^-m5)`KFGGNT} z0pCW#b zf1w{IppvycFI^$&Q3b#UtI+@+VviMrN#2Vf6lAESwJh+%Prx`Xky z;@RB)vkUIdrc*Z$unTG$Ohh4rI1p_mr}{#_s{u88goYoFUGlE1vd~ZnxJz9bsb3PGZKw zFfOyY0?l;5Lj4ZoEJa-OP_AIH(3|AlT4Ql534J0Vp>s26r9^LNOcg=u(1OsMaylaw yPEdAKBp}$hsIsxSqmS&3%m)9L&wJEOsQIX^@8hRrHEd(egQ-DneZTRizY#y>`ab3W literal 0 HcmV?d00001 diff --git a/packages/alphatab/test-data/tabledit/fingering.tef b/packages/alphatab/test-data/tabledit/fingering.tef new file mode 100644 index 0000000000000000000000000000000000000000..f4417acd538afbeba5d1e5be39ec05adc91941d7 GIT binary patch literal 1132 zcmchX(MkeA6o!A(j6x`azz}4DnlM;*bkkH!yb$Xugf6;mZP(~R3*ExY9-#~A0eXfW zqlbuIr2bV;c%fZ|XXkLfIcLr}GcdD2Jo=6y=1C+v&pYvXemfY3@d&U@-JvF_DXK`C zd_jGk@k#9eZ49xEMO7DXU<*OVYP$p9Dp+nd@H^pwen$eUQnDV4jqy+0U}}7tu{*V9 z4NwP}tW%me&K~aqx34Y(x97Qi4?esM!mr_X@70sv9{`^bV{$nT$52jlS5Bo?DwnIZ zlO_W#b6haSw)5j?xPs0~-PmD{aZH7BUM0@RXcSILFXC#sx4zO3%2v8J-RM${zR`Id zD4L=kDBahN?y4p3VB9fKmSW?DAoR}Xyw zeFX6dd<37sH__QiQY*AK>7O0u+nLSI?lJ(m>^t1SB4x6RqH!#c19hqM8Gr&>5v_z) zMoU#e?w~!+#nRmWdjpKkWxFmvz&5C%HSi;8IhJ2ja;Psf8+n*LLDw*7_dmXeOLIxl zoy3cE0Jc}HIc^*{o?IzEWT(m>2YwiUgrExg>&ekIyOm)C@RbpwUdQ1G)KUG+X?5IY zv)wuLFwqzL4Iym1L0P>5gRQQx^*_Q9E!0UHaZb~zp199xsPA7$#&4L-_&7P}N*zDv z4Xzlv10FFRa)*!D3Qs^46|)!?j#gPXxuc2R^Wt_gv8j;Pbjrl`K_;0S0D%WgPh8}C r?86siGf!)tbllt`ejqLozY-UT--t`ZAH-$iPvQzdF0Hlu%ipLU<$Xud literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..1deff125fb34c667f370cd04a1af97e695c3a5f0 GIT binary patch literal 940 zcmZQzU}N6GP{Du=FfwBkg@~o3CY2l(K z76ukBT&Ci7fB-{Dnr>2JajLGNZlYdEY8uExaKyvF1T)fsg$2w6(=I?IY)~0VC~d&b zz@VV2sb_2f5pXZfEJ-X<2rfy?OGzwBVMt|2(PRKYm(<*Rg|gJ5Vg`l>j0OgVhCpO& z00g#%;l@^W7M7MacIIwCi2wr=BLf3{eIu|eNCi;P%E3S%q{qP6zzPUFY=BH8Fl7pe zV=)0H6p)yns-RY&ps%2o2X!c2DYaxZBefC*AZ4ajte{q;W~`R0Kt~sVZ7fmHRRCIC zOizoUe&hhsZf*?9ATB6j)G{(Of!G|tyu;=OVgcE}qy$$3WCPVSfW zsB@_y_fQ`*zIp8btpUcyvRjuoumx&tjr>Sjj^#I$9P0KjRXdx-O zomjC3z;^2m$Bi9FH-3 m1n@j~(%I%F@dI&*_?5U!{6<_M{vfUre-hU+wRV2_8|4Rd@ka>& literal 0 HcmV?d00001 diff --git a/packages/alphatab/test-data/tabledit/grace.tef b/packages/alphatab/test-data/tabledit/grace.tef new file mode 100644 index 0000000000000000000000000000000000000000..cd9ee0601e03b59aaff3a8e92435e0ebe925e000 GIT binary patch literal 1056 zcmchVK}!Nb6vv;qn1xUXf+5I6G~i*~(T!Tg79rU>3LQGH?pPhP*qx$_mx9h6{3OvQ z=@SHfkb3iWwGH$ZzMaSW&HufbH?s^tF8dCTu*@>qWooPia-gntJ_ArhFQJ#wE9i5p zBD?4>DQ%wn|7d`*wQASZ2iO5MG6#MnP22SAN{;k}7NY=*U(huS+Wk-LVQDUDx|>9? z0l*Gwb=!%Bv-=z6hwMW6lfVxHkPy^Be?2+AWe+lp06sH9G#WT;fp*fkwA-%JYIWRW z4-qPI&x3tE&Eum&J|)KIgFGbO{H<}2r}4Ax<_jqu rd5Y_)emkWjWBn89MSz@_)RaS5XUDrtsowxAXEg|gD!0$&9>26gC7-2trrnI=rwIu5o{~XDn5co4?cts z;K2v*Av}sF-$EzJrVOQuMCc@w`R~l^Z+Bq{KqmDJcQDC})TC^b>&muzO`|pdvgnKG zIrL@p*j7+i(La9S#J&HICYWvz3j7bJ8G5Ihap8?akB^Z^+WMKg>M!`0A9 z3|)#t2IfPl88ah>-Us}Nk)@1OGDeOv%w&ue%19?;tWriM8KX!Uv&k5OG9X}VxO|IF4ubItwKRVlVzr4dM zTrmt)?lV5*4)3ukZXsMzF^l2C{xlcXXUtdccAU)3_!{}5 zsqBMwmne~4J~%w_t9hX-!-(BHk5rgYHzJk+_e1o6Fh|j-K*P2%?XSkwQ<#imsVJT+ zK_%0W;|Y6_NlNr(3E}%#fdDT2`=HUP)$7gHc?X4_II9TZd6lfHJCJO&g{R*UfoK4? zO}Gn~&2&(Eo2Po=qhx%_JjP?Q@wE~k@CsK9ROKn-2@m)fx3D&36BTnA2B+&BY)*It XwmNwrK#uYC$Mc(qU)L@8J->k;^u$L4 literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..d287c25f96f28dbafb11025ab188ea49d7b53ca4 GIT binary patch literal 948 zcmZQzU}N6GP{Du=FfwBkg@~o3CY5A>Hp&49Kl(K z76ukBT&Ci700%>=fvH|eY8uERaKyvF0yD~i1;WHiy8yMYK~+jZX#;i!1_f13J!1=q zfO~0XNn(*ga7kibN@7t8Ln=dxCIbk%q~_);l%*CGGcY`0G%zqU1R`StAh0zIH@32~ zu(Y(XGj{_@1Q?hY85rp68-Zm(Du9Ak4hH%lJqE@GRzTok17sqBDN{fkiwQ8HfW-7v z1+@YNeFe2VnAhk@t0k)$sg)=IDKoWV1+^kIW3^lbIywMsV~K*U0?^`OdRh$iBL|RX mbAWPq7#PfeOeO{fP7WX&Ce9&&U|&#%r~&EVP(g_Q2Lb>IXgf>* literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..cee7c72478dc69f9a606a492e8d2e9676e0e489d GIT binary patch literal 1123 zcmchW%}&BV5XXnmpf(VV#zbRc#;9x1Ko{1JLXcoWsuv@P2hWu*h6Ba41$hh)+qXwEjZ`Ju5SO&?@ih=Be2Z@FW^(*WMJmJ{p=<=L?=SmD-`Z@h?!s-qs z`rQ%wawuKMf|l%{p+z_|HmOr4uH23zUjo3xd{LN@a6RY-wukqD?K-ySfCMKG;n$SC zJ`Sbl1AL}9FO_HrPIg&(5Y@U_snqHh7A0Ezw9L6-lqcD+g2ZZr8{t1(@G99&jZm@i zIGmWTiPW;6B(i5Tkh#gx78zNam05rk&Y6$QV}kW?fg6}~46uOI*k5F0<%}6Cp3rlf zGF_i3uPB^Vcvazb0Fhj|P13*OHMamP3-%|o9i@I(;XQ>96h2b;SmDAif0O+HV2w`c literal 0 HcmV?d00001 diff --git a/packages/alphatab/test-data/tabledit/lyrics.tef b/packages/alphatab/test-data/tabledit/lyrics.tef new file mode 100644 index 0000000000000000000000000000000000000000..8d83eb770a487e8b062863e6cc64887d6b024679 GIT binary patch literal 1151 zcmchW-%A2P5XVOpt3xS@pa^23+Az^uy?&^e7>W55LQzCST;3{2dKc%F_0r!^;Gfc4 ze@SmW^>5UidbSVx7S8Q3pZ(6>&MfQ#goE$!1fwhv9HsU(L)^L_UOoVzkVnbq$QQ_c zGl}2GKPAo2{eKKWNEx^5_y(py@ZF><>fS;6;|vOiSl2MlvBuHjQ6-hcifAU9HnyH` zuwy$-15ZRJerY>*sJ}FH(~2rrN7rppOW2ly*fcE>Rnkd37fxHC+p!@6A-}s~sE;FK ziMj|6RxS@Bp8~+mSW?UMh2sa))g3%_b<5Bl0|Xc`@V=60+4x8`0los9r&2UDPIi#G z&~k-LHk&W(mnl)@J88~UHQmd4D@e>0x$6DHHJ&4@AvA7 ka*L#Y5B6LFD3@WqH(Qa`uS&co@rJ}(5^qbK_~Re4Uw^w=1^@s6 literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..f8776131202829bec6a4a3d585164b83022cf2d8 GIT binary patch literal 1416 zcmciBUrWMJ6aes}4qG7AK8oJdH`QBAxb5@4S{i7>KV00)impk zqc#q7#bH?pT`$i@gBh5tHH99m5r(K?bm~YF(`oQfeT!0E>%n3CiRp|_!h^1~;{#si zKEtlUJ;q&b@G)EA31$_2rZFt+t+FtA$7DNv!2e7dKObrQIHgknm2kR>mibNMG;xMF zOPnLl6Ymi3%35(1EoJzL!J|>L literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..41a0410ae2df98e53c2d14c2807c1f7070fe7d08 GIT binary patch literal 1404 zcmciBQA@&56ae634qG7z2^)zfJFQQUSUzh;)}BLobB}d_h3B+kiwD1k-?G05k(F? z!SP9Q(c1sd3MiY)PF>!>HuwXrXL*ihYF5E_2EhZ#D+wFXkqt$e@lSITk4}Uq?u4^g z2Vnd8f>D_#E}loe<+2;!n%I_Wg9CORf?v}aKe89c^#HzNLKKU583N;^*fpy4ifPvB z$1Pmwh@+Aax?Y;C20JiWZ3sR1k1#|Pqg_Lim`;P6%3GA`SVM>LC#Ew#2^(GM#QVI& zeTGw+dyKo>;A6JJ4rUd7rZFt+t+FtA#zZT;!S75OUmt0FIi-^T<#4--miaB>6mgn3 zL!2ef5$_W3$y#eFTGl!sJ|xzN1+hVl&C7F4;+m|bsA#$8l=zJJoVZPVNqj|oO?*q- ZCB7rRCw`EqhEhx`~WRvQ7Zrd literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..f77309b0a928cb5a9050010f53aeb9b7a7a9a000 GIT binary patch literal 1116 zcmcgr%Syvg5Iv=#Mky$TLZwiy6fbm<=C%<{OAQrDbXjoWIyI?^v`UhS8^M)p7yT4* zr{CgFxYo(D5!#(}a)&uNGj|?g0u=IZc*HWv<(IJ$XFDVRI`HR!5^0&VLRuwFq(;~v zeaUc%@Bgg?X=_#1)eY>xA1fova}-Unn!Ym*21-v6=_lw*QkeaZ9Z+Z?F1j0Au>r9C zMpM%x;&gW7TP{2It*LFfHXPUu1kb>k+_4A8^?*Mu5cR}SO%Ol5o;H@a5CFL|5$jLaeT z7b=vv6icBgD^;$rjAmK?^pE*Hb2(^0ZbT5Mhu^IOR8i&x9wO z@}2q(N;%HVPaR{+zL>cIt_@KZhd1LW!=1~uZtDFB&pnsaY~ He@K1-84^cn literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..a7481e516760288fc0573064bae4dc27fb329d08 GIT binary patch literal 1889 zcmciC{cF=e7zgmDTdAi`tqzCc+X)((koDS(tu5UgGUr5)io!q;%$8IJZj&?9v=9`rJKtm*x_p%J3I@N@*!&M|&s@TmP*f zT#mn`?MNC$9YZ~ddKy)ibMSlA?<}U<{{Oj5!S^NM0PD@S1tx`sQlvNAgbWuLphz@!S2}jju+y|@zjcBK%*019W>sR$ zDhXJ}lS9^Y#5%$Bx(^+*PDZWixOFmSO(%$cb@K}oaAhGkonk(VKKXl$1Z?`8XTE?w z9U4lr1#J3UV!q7mGArZ;l)VE4Y;ry3DdrY)hv+B%$828MH<%;lO`@$c#dX1EeB49h zXUtp7&zWBkeZ{@R9vKp_$$iQEn)waU4DNSY1Z?_mGrwhi$NYi$Bl9Qb&(^PghosHU8Q-=ltKG1K<{k7X(!*lX0i7jTr~p1bV#L$^NZ`V(>3(^q8w zBXrn|Iae;H{MVxrmA}uQ#}cycxUKmL)IK74aeTsSW`XOQtFa%+JF&kW_)$P11>>ZD z^WoZtd=f@|q8}xt>UI1*g)&oL^4hb_R;x2Ry?}n|Oj9ZLGJOae|#@O#2Hbc)@IZ1Y=eu#;lTn zO*}bNn2r=qFumzR#|kH-h3RCK?=Bv!tm=}?IFwcc;?oH-f z%(t2EA~*kx2-xN>G2dgp&-?(n`3F_NHg|=&$Gpn?h5Y6Cu>mp#=f1CL&^E>7b%paLQF@G-n8|4pO5_r}C literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..0bb40b0715c2aab3ac635b9fe84579313bf4c39f GIT binary patch literal 1507 zcmchX!EVz)5QfK&|{#1eBTP_6wq_{2Ao{T;ID^(c-)Dv`W=9u9me zQ!NgLJ(8L4^Ll4W$|l{9Oe*ZoGH9y>_Q_)I03s#JM_X zDpjvHXJ(z*9J5OOT)LXOfwR~qC;*_5$BcqS)8 zM$(l-vBC*v9b>U4(0H=S#<_cJrR*S@{bW%=D5PA?u>c#X&M$ulYmN@w*18Ry-)Yvt znSLk7YecPNZ;%7d`hs=+Dp=1Tz?QDRj{5xB;rigL{swetGaa1in>ohKw9-$O11_aC zD><&_Sb%k%r(iuBJwPwsfjk3P=Tun7PGg-Of_dL^4p`^Vqa1I~`FjodcwVz>pUidI L&GFux`GfKoFw%94 literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..b564cdd5c9860a50d4ed36cea1c29fa51b60aad2 GIT binary patch literal 1252 zcmchW&o2W(6vw~SRZEDCL{gE+NEj+vop$|b8>ELuxQLtc(j^i_+9i%Xa&r*>gS$V# z)qmmS-{3pDGxUIy^zFX9&wSsTd2f_W?Y_$Npj1iTqVRT#ln-eW9;+6odB2y5l7HXKZk5&g_^n zQ4IlNzOWorQlW8mc68c`+6d2^VY`V4%>wk(h*}roI%;=-w~SJy65l}4HcJOVZL3nP zZfvb@GqJB$%1RZBsDAww#sQ0Kq)o42##2$Ni(2L5wmG;AvNXSc36!P2*+SeeD zWLd_7VNG^~Y|B8lL?0(eI>y2mY~1N*V{^wv#>{rQ5d4oE!+Cx_V}>ydOc{1ZfdRuF z+3wAgvql$WdyY4*>+4AaZ$j(cgxyK4za)%2kh8s}$?vJ`%>b_LHQU2;WK)BM?-^XO zoefLWO^r()u=PF=DI*~7rrcd2pBjCQd}s7^@{`f4sgMgal$j)x=uBb$a1;-7I3Ta#)> zRz;B_Vr%(=TaAQ+o6ErKvy;HzbSpl)$6pq{@Tv3M|Hh^RH_$U|E2Q3|Y7e3?H%wfEz2Ayi) z=e)!NhEU-H#{1mmT{guD!j1v685$3!*;w6i12MKAEg1eILvaS5&w^r_CCVx`=ZWHq zEwJ930VkBs!1^3(QPwxZ2G)|)MRu~jyb4w|wEi=L z3D&dGCUjL}g5$d0CUTR}`6SQ_cat_Mg7XoR3vNX`DuXYTUIE`LeH;9&^cwh0>2+`` M;yZ9k)qKLf0Ghl}MgRZ+ literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..2519bec3a5935ce21264438b05e127fc4fd00c7f GIT binary patch literal 1164 zcmchWF-yZh6vtoMv`tHgA_$5gH|Sx9wAV(oEwyAQ+OeSEYHCt(Xp|(1tKYz(gP%ed z(Z#{hkKs4b|0OwbXhjhEl9%_-@4b6>2X{pI>=WJ4EYq^HXu@1?6urv4MzjQ9hOfZa z;L6saefaYargAFY|1yWF`CHUUFU_l*zkmuwV{_z$p4m2?TI7x8hQ8l26;(@H*JJsw z*hlMp+Hp0FqCmtpt2L{YfP<^^$O+hCR9h>FHCa_bvAr%Xj}lA$rm5 zSQ{9ZvGIW!WumMdOI%7q&23Txtt-AEc_!h|ImLzK@+PAXbYAhUS5dKPIt0IC3LP2mZF7%LQ(-cihEj<+VSn%LM6jGB)5B(uoYahcy!6)zy zyoe{CMBk*&=0|gA)l()r`6l1YW@capDCA!-!!pU`m$4D&dOi89k(~o}N%u%gr2C|a z9FYA)`jO%iUxM$yGf4d-)9Tk$v@kgY8S`^ zJ5)j;&RmL76ajWvu2^e1(fX|!|rb)*VBq!#j z)o9uEdb4$WN{+6mRfRB&YUouFL^j&OP7i@>#wN_YO_iGuun#$~`d$=RuH{u^Fi;m-ts)9~%eHC8@?YGcEpy3E zM+u8Oz|P7Q$6XpX)1mZ2b}PNH?}a`B_+_Z46O1P8IS3=*BO^q$O3M+1tLnYeXu0)z zvvqk*iJmyG31Qo{IIAkiY_x@~z7dXS5V}p0DJ&N1#hq)b=RF3DKQWu}adObD5`W+| zE*a4Uj~EZR!$)k3Cx|;rW-)3UZL@K5M+RdxD|%{QUcc=0FUVp!ifJ#c^YPUB7(znL s(FhY%h-VZU*><9efT48>Ftt7fcC;2iPU|L+*SZT7w7vs&6a7v20iwZ3KL7v# literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..9e3d39e37adb65deb86ca4ca965643345593c96f GIT binary patch literal 1176 zcmcgsO-}+b5PiV%q2XXmOf)8@2W`Ruwy;EjAmKn1Pm*Zlytu`10L-!weuM`S`5m78 zLH+~1d(bJnl|6tl9(e6!Uf;Bxb~9Zd5qrfHs}PB;Qhm*l9j|B*j{v)5du01$hh!Q@ z#JglKAx+1$pZ$_U&-t4-U~I3Ox&8n<@VeQKJ(Af{*3Nmdt1k4@(@5zp>xQ~5@Gsw> zEfc{`2Z0w!fSqJ=R>>DG$34$>*^Orp9NTpu;bfp%L-vPkBHa=277-$!r)~+tdA@B` zYNc|yT06U-KuZ)0LYQV@5mhrttki|6)(A^f2+b+>s$pwGzeEz;lOz_VoN|hCe|J2JWaH zWLOOGIMo_!_8dnJpz5fPA#&uWWC}@Y*JFQwzocH(ehGy3Jq< zx~^?AhvU?87?fb(a)zAEIqZSSxff`P!sfc|O<8s9+6vc4unmXf3U*!Z(YGchkM5`p z8`*FjS&qwCdm=2R$$qMI695k?Rke{Amotm&K3;R(F?8Pm21W&y#==Jx-$?!qhrciOx`!Y64d+QRc8%M2p5tW)1ZT vL*k)=#7PHTtB^6N6OKYoNPxsADzR~m6T}@k9-(L)tg~_MAEkb>_!0O86gF8S literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..02cbdb54595f0e1da6894be79c4d6f90e9deeb6d GIT binary patch literal 928 zcmcgo!AiqG5S`dip;QrX-sa+B51MTwn#NixEmaUoLBXTMW^DsaQj)Fc-4E!YcoO^r zPyRuE#6QtV(m)OoJoRlJZ}z=iX0ia~(ihxA5tLGqn|ii*kc&810CtH;)QH!_3KcXGDb>viF^cuZI?-+eR6)xs-;s-deTmf6q^<7tZ z5~q$QpaKJ53Y6km!ZsX?YS{H>FM%_j$oM6yu#xrYsA^Is+M}?%O7>RDH|f>(Ye%|~ z8kciNS^?fj%d@S(1_5>r;?)zbk53{90lpQ^>vj5cPB^dM>1Nw#He2n}J_SR5+~8c( z8c7Ni W36hQy50M&&>uj9+N2Q-`eh9ydNl&Q& literal 0 HcmV?d00001 diff --git a/packages/alphatab/test-data/tabledit/tuplets.tef b/packages/alphatab/test-data/tabledit/tuplets.tef new file mode 100644 index 0000000000000000000000000000000000000000..fa0ca97b70182434c3270e94af32b73e7b9d848a GIT binary patch literal 1768 zcmciBO=}ZD7zgktG^|nT3x#5(Q08D)Dx{fYwI(5{i`E)ZB;rd!5O!-e;-OWNRXpy| zV-Ecs;=vEFM?XeBLcf9jH?x!dAKHsZCK=|p&p!LiY-S?jEd7#ia#kxXofS2)L39{T zvil{G74%i~HS~3K+cw}K`VWfPd;fndOZn1yRnK3*HHnAS!*Co`o7HeTj)vI_&A-c1 zu_gU-dB%Sl_fXQwE8WOPeL+NRZf$!_6L|dPV;qju%Q!q9grk8(GT4&rvlktGQeUId zSY%q#x?acE(`eoLf!E$`wpyLtd;7T2*NukOwOV6#H_JeiKdYK@kMo5tba3Ch4L4R8cHFUYN3Q1{sRgcJHe;-+3KNePIWfJX^VD3V z7_1YVNo&FX&N?M0IH5M49XF?E>LfU6)^d@&3_mwH0XZcnmy4V!*}qc6c|Rt zmAnQ&%5&Dq8|0ht3zMVC0Xfb5%;Y4S@RPjeE!eK%HvBM;YxtFMKu*c3UgRWX@en`A zpCzYcb*IQl$WyX^x5$wl*silh?!a%&T$&t^C*+iTN><&XPM@5Rh#G8`6pQ(Am8o_&ZdLwS;L;se-rFg_j)i3S75u>zscWU7xCVk!WHb*JfHjr F?Jx8ec(DKg literal 0 HcmV?d00001 diff --git a/packages/alphatab/test-data/tabledit/voices.tef b/packages/alphatab/test-data/tabledit/voices.tef new file mode 100644 index 0000000000000000000000000000000000000000..dcadcc30f4f3d717dccf4d3e8054cda6b1586cf8 GIT binary patch literal 1128 zcmcgrO;5r=5S`kl7J|`eG#D>qqN|udx2zvPkdQ#oBZ&vk)z*&#Xi7Ey0?!`!DgGe; ziOzPrvb-WOkRp(pz;cMd#)|EwNMQZkqWj03KFqqM?MVx98Z8 z@Fw=hfgc4BLQnyD^~2E%zK78S;K$(H^XL|waOvHOR;STywmTOCDh&B)opaZ%CspYn zvDM|ST*C!#5w6=rbC^!$OXEvRL;o>E_KGgDu^w%inccHGi;>bfn;?srU?W`O0g{d} zR*@P{mf1KrqdicK< { + 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 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 whammy = (b: Beat) => + b.whammyBarPoints ? b.whammyBarPoints.map(p => [p.offset, p.value] as number[]) : null; + 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 / dive-return without a tied target note: no amount, note kept + const bar2 = beats(score, 1); + for (const b of bar2) { + expect(single(b).fret).toBe(5); + 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]); + } + }); + + 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()); + } + } + }); + }); +}); From 2d9aa8bb456dcd8ca7e7692f0204e410fe51d993 Mon Sep 17 00:00:00 2001 From: Danielku15 Date: Mon, 2 Nov 2026 21:29:27 +0100 Subject: [PATCH 17/21] test(importer): compare TablEdit 2 files with TablEdit's conversion The files in test-data/tabledit/v2 are TablEdit 2 files (format 3) written byte by byte; each .v3.tef is the same file opened in TablEdit 3.06 and saved again. The TablEdit 2 import must equal the import of the conversion, plus explicit checks of notes, effects, markers, measures, instruments, the skipped drum and rhythm patterns and protected files. --- .../test-data/tabledit/v2/effects.tef | Bin 0 -> 400 bytes .../test-data/tabledit/v2/effects.v3.tef | Bin 0 -> 1072 bytes .../test-data/tabledit/v2/instruments.tef | Bin 0 -> 408 bytes .../test-data/tabledit/v2/instruments.v3.tef | Bin 0 -> 1064 bytes .../test-data/tabledit/v2/markers.tef | Bin 0 -> 580 bytes .../test-data/tabledit/v2/markers.v3.tef | Bin 0 -> 1397 bytes .../test-data/tabledit/v2/measures.tef | Bin 0 -> 405 bytes .../test-data/tabledit/v2/measures.v3.tef | Bin 0 -> 1065 bytes .../alphatab/test-data/tabledit/v2/notes.tef | Bin 0 -> 437 bytes .../test-data/tabledit/v2/notes.v3.tef | Bin 0 -> 1187 bytes .../alphatab/test-data/tabledit/v2/probes.tef | Bin 0 -> 466 bytes .../test-data/tabledit/v2/probes.v3.tef | Bin 0 -> 1215 bytes .../test/importer/TablEdit2Importer.test.ts | 401 ++++++++++++++++++ 13 files changed, 401 insertions(+) create mode 100644 packages/alphatab/test-data/tabledit/v2/effects.tef create mode 100644 packages/alphatab/test-data/tabledit/v2/effects.v3.tef create mode 100644 packages/alphatab/test-data/tabledit/v2/instruments.tef create mode 100644 packages/alphatab/test-data/tabledit/v2/instruments.v3.tef create mode 100644 packages/alphatab/test-data/tabledit/v2/markers.tef create mode 100644 packages/alphatab/test-data/tabledit/v2/markers.v3.tef create mode 100644 packages/alphatab/test-data/tabledit/v2/measures.tef create mode 100644 packages/alphatab/test-data/tabledit/v2/measures.v3.tef create mode 100644 packages/alphatab/test-data/tabledit/v2/notes.tef create mode 100644 packages/alphatab/test-data/tabledit/v2/notes.v3.tef create mode 100644 packages/alphatab/test-data/tabledit/v2/probes.tef create mode 100644 packages/alphatab/test-data/tabledit/v2/probes.v3.tef create mode 100644 packages/alphatab/test/importer/TablEdit2Importer.test.ts 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 0000000000000000000000000000000000000000..0b1181938a7c50e396fac0a34099c03262a5031d GIT binary patch literal 400 zcmdUqF$%&!5Jms)EI%8;gn*4*ng})$u(8sBDBdHO3q;UzE6?IFw(ww%5(V)D{$l>{ z<~6hHx?_9l@$V5hIQjnK0y6-94!TW%-o8POXvh&@NrT3E6@myoqI1lF4&R|EAKD69Emxf;MvJphfI5$6B^ literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..37172374ecc23f5778c5b2645609039f5a0c8517 GIT binary patch literal 1072 zcmcgqO-sW-5S^H?#8NT%MMRMG&_xfLO(Qi8wFK1CV{cy7ntD>i1oR{xJb3ibpW+Yl zpXf|B8&gWfOC5GHZ)V=^o4^1t^8=m$U=4lJ40AHCX4lD8_DX#NpoYGL?x3%tYuiBF zLVvE*bWg{NUo5v`!=RwpB+wD&GsE3V#JZwtodCeei=N~_crI%j_WQ5l5sJogm z9uo@Rbw0SfNw~P@9v64h`tz@dp=gSXV`zz#iw6;iTRwl;QXyB&_&!JB^go4*2{>+w z@v}|kTvgy;!kF4o2N%ySR?Ed`GCQZ*i^Ne2&JkN|S=H+ReFhCmY%KK{oDg0lYD_$o4ieO->}(MQkvs@D6Dy{Dt-nG3Yd` literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..756dd95c6900c2143ba9c030b9f3b0e798ce93c4 GIT binary patch literal 408 zcmeawD=sN2%}vcKDP|bTfQf;bfrS~>2NevAPysf$1Q#QN2m?bg6HI^u$Y2CXGctil zCT1XcfQ=ETv4I_I9)}c2ga<6n%LudqqL6{Xo4T eEP}8hBnz0bXHbL+f^?W#*}K5hI3*UN1wQ~u_Yn#J literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..0143a4dc7f4ce4e46a9aa576b743945e703c051e GIT binary patch literal 1064 zcmZQzU}N6GP{F{!AOggwfRP!MkIYL+O)5D8l4oRK-~(boAQk~)aUg~PDInVji1#6h z!Q^1{0z7uW)DTCrFtBjpQi7O1!)lm?k$YGv;NQ|^>lTnuC5q8~6C z7#JFY5D3^Bh8tViSy)=y*qOTlB?1ggj0_C)^^J_d(qO{S%E3S%tijm83J5%GAbMdc zrc42`EGEGC0g36U3Tg!k`U+}!xLiR?akXSMBefC*AZ4ajte{q;W~`R0KuZhJ%m&+7 zqM)k)w78g_7DL^k08EgKh?I!tIs}&ynj}4dLX6Pl&cO;yoltQBVBTPcC0l+5255?A YumG}Q;ts$f0G{W8N!u8z{yz`^0D?3)+yDRo literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..fb4556c66d0e984a342db6e7dc7dfd1bbc72243c GIT binary patch literal 580 zcmdT1M7 zBGt}&R3vx;Copf`d=rNEFt1(~)e8R|9{ikXoUw)lXj!z?=O(HPtVAj<$O9B8@%0D< z$R4pxI7}`HYxzrQJvmrIj_RO&b4owius0-|%p)crp83?IK#@9fnR@b*>Y)Co>Ztxs zH9C7{LT6wK;3Ss=GAE6hbOqHhlXjfF_sHi`VyvMxUDnkrTDW^&F62<_zJD-bs0qjQ iH*nv4o=ZpIlLN;kH(+TqBE=KReb^)GfXq`2*&VCbP|w>2SzxJ4E;r#?du zJ{0;IC2x@TDD*j+c9)TWg8dt(03K6sim-34S682BAH$ME zj{wTlHL6Qpr&g)Ys0->Z>R)P}od2gsUQV{(fm6N!t)vp{b(6BKKT9pZm&1k?~ECkcrTltSFbnyC>*^S{gGFX5`3>uc{7M}4P&{~ zsQbgLC)2~3R9|IXs`)K8bmtWHNi|g{Y1(QbRiJ!zBJaFQe!-|?D0d|rFYdE(vL(ss z|!9~Wq(A{0sh#kYBE9E>Q$4xlIX`S^I&IZtqs>7)fr**2= w9PSVQOkd(w^23tEc5)WExpJ;8-c`&Ik~rmTuiVKF@SXWuo)epIUjIL?03knBK>z>% literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..30b0403d3dd0abb5b67a4124dfac2f5d6816b085 GIT binary patch literal 405 zcmeZGO)M@gN-bs>;((Qbg@J`x2*no_42)1d8(bX75@BE{W`YUuGcYhRGBYqVFfs#4 z2DWMe1_lQXW}vbL4pty}fP)dFnP)agoS&7GfgypP8AvwpGXlv40?aHxS)pnLpolON rNWCx%NWCn`0!EMlKw!@x38ons49Xc86jU|!j4hx77!(r|*Z=aA+;4g%qB3+`9CzFw^=^~v?uUvyL<`VG=rXhnwXzO; z4t*QpY|PrHDLrh?Y?cu#Ov;=*K%Op>cKX~&QuQapuSi*QSEk9Bf*Hy5s0Hcel;7tp zqpgmgqEyqMHWUq!FGPH2?;wf?{w(K@BV`b%PsvGe71bJXwOVf+pCQnahoO|dA4WQ? zkD%6+zAlJl4RTQ*)Y^<*PoenT`mOd|!o?%^xp+kdYXYn8XlVnOBmLT9TQp0OaMkreu~V7%8M>=A<$VUckZt zgv_Y!OJQJy3a~+GMg|~Dgn^-$38qq*;Q%8OQ1Ac~h-_eH1d(UM z*`$DM1$HJN+kt}_NFLx|V`pIaz`?=8z>vVn0u*83Wm^nX$;4 zBCJ5tL6q4IC@ao*n1O+jg`2?-9PTBl6(t}i0f9Y(B$#GkFenFVQq|Nmwtxt@mu8kE N7R9-j=s^`Q006rtCGh|N literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..f5986370c0b2a49881c49477d4817e28f4b87e01 GIT binary patch literal 1187 zcmchW%}&BV5XYysA_!_@G=c}S2U$N7p-?}7AR$J;lOfT>n@NEpB!C3gsBhqr1Bu=} zdDoLiUcg5%@j-M-cM*aZFF5IRelt5e|4p+6aLCV40DuMbL6hXcygw_LIreI;O8~Rz zKJ*>*19V|2jxr==obzK$#xZIr;z|IKE_NljMzJ=LuY3RO%(;)X2dy zoZQw}$z4RBZe)9%8eDclvnLt^tUDBcMm@ zLguQ>jK+C}1;ohV_%3N$C}cgB?T60vSRxXQ#uIyKT*zwMVNDAL!+LkQ`wGR9TClr8 z*J5Z#aeJ@mXtynjv_zgYE=)q}BuHp&SbzK#nx|pP2!>tSB(y8QCHz z-26{regc9wMf$!~y>s=$IwlIgP6U4=nX?z8#M$WrnBc7TF*SRvdrnl`XlERkYhs5(I4pQ)s#1sqD70_5!w^!x5t( zp1>{M4sZ77o5g;&-W2%zr4S-i-(wsRl!8AEsailmZ;?omFu@9AIH83X%1t^ml%)$J zb984^JZ;5*Z&kqZ%>s^B@`vZ;T%OSKOC3hXL|tUAR1Nx literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..8a02ef0515c2f6fe0e14fa3864961c1a4955fe01 GIT binary patch literal 1215 zcmcgr%Ps^#6g@TF+F(Qxk`^MBhzbj)X4+#+n2bcka(0%U6C1CXN!VCPtSu~ji0}v2 zKEY2|bED%|zz2`nExdjB?9nJv^Qd>oMt>@3Dz4}h`(#8eQMa@zBr~}kQ zhKT2=7oU7dEU}wE^hh|rvp)RpcFgtzbfR{8yi;%3d!%0u=~PwUcST$5Dyc!-YdE&% z3{VqOGf}DC5a0dJ!k9wAepZ$X8&SDZT3lSJEUYn5GqXiw^7&%q!tPfnS54j}M5atx zUwY3?7&kW-m9CR>ZF|42^ohzVee}Km-+T0~F6ySDnb!@aPj#dZ)!(;GJGhQbHLcjV z@t=+32^2R)d~8!X*H;j|ual4rj3%qaC5!^=fs(|J0mfY(<1xQq5?=-AaQ?2;pOwUc ztz#EC2izafhRB4(9%Gd3ZEuSCcFCEJImC{`*0|5__z_U=KsU%L=PY zkqL>dAE+W50?U2I9w3QDkArw3&cyhd&&P`~Mp#cyx@<}8JH(tZpu!Y^Eg^{mdnd{( FJ^26t literal 0 HcmV?d00001 diff --git a/packages/alphatab/test/importer/TablEdit2Importer.test.ts b/packages/alphatab/test/importer/TablEdit2Importer.test.ts new file mode 100644 index 000000000..3c8024f75 --- /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'); + }); + }); +}); From 071a57a169cc33efc3c721eef90bca0531a35081 Mon Sep 17 00:00:00 2001 From: Danielku15 Date: Sun, 1 Nov 2026 23:28:30 +0100 Subject: [PATCH 18/21] test(rendering): render TablEdit files and round-trip them through JSON, alphaTex and the serializer Visual tests for effects, tuplets, a reading list with voltas and text and chord markers; the TablEdit files also run through the JSON, alphaTex and serializer round trips. --- .../visual-tests/tabledit/effects-b5.tef | Bin 0 -> 1188 bytes .../tabledit/markers-text-chords.tef | Bin 0 -> 1507 bytes .../tabledit/reading-list-voltas.tef | Bin 0 -> 1112 bytes .../visual-tests/tabledit/tuplets.tef | Bin 0 -> 1768 bytes .../test/exporter/AlphaTexExporter.test.ts | 23 +++++++++++++++++- .../alphatab/test/model/JsonConverter.test.ts | 14 ++++++++--- .../test/model/JsonConverterJsObject.test.ts | 2 +- .../visualTests/features/TablEdit.test.ts | 20 +++++++++++++++ 8 files changed, 54 insertions(+), 5 deletions(-) create mode 100644 packages/alphatab/test-data/visual-tests/tabledit/effects-b5.tef create mode 100644 packages/alphatab/test-data/visual-tests/tabledit/markers-text-chords.tef create mode 100644 packages/alphatab/test-data/visual-tests/tabledit/reading-list-voltas.tef create mode 100644 packages/alphatab/test-data/visual-tests/tabledit/tuplets.tef create mode 100644 packages/alphatab/test/visualTests/features/TablEdit.test.ts 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 0000000000000000000000000000000000000000..412cc623e0ae2712bf6b52c7fd65610b41414b86 GIT binary patch literal 1188 zcmchW-%A2P5XZ;7><2<31co3R)P})%s~4tXCSlg25Y%gDw?z+{++ES5e}WG_M}I;O z{XzXFHFML|Jt*`L&h0Rt`_As{F3bX?lJD>gvn-LErLyMgelVtd0w9aNiJn8xqeoUi zzC?eCX;JR~qXEYHysGmLkb&UV>^r_TtEN*5^etWJXQUw&m24YEh<}P5ED^2T`mUf> z0K{&wWY<_Yd%O=EPh14f&~-c)G`K~eucHSK;z@fxz(+!LI3po;&ki+Pzi;VI{R%9SB zRHZMZCv7y5V^MrUiHh+gW2^rG&m<91Zvm!Q`d&DTv{A*&Hc|=@g9uwD@ XC&W$WSK?#lH{ugw0<_a#{zUr%aimK< literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..0bb40b0715c2aab3ac635b9fe84579313bf4c39f GIT binary patch literal 1507 zcmchX!EVz)5QfK&|{#1eBTP_6wq_{2Ao{T;ID^(c-)Dv`W=9u9me zQ!NgLJ(8L4^Ll4W$|l{9Oe*ZoGH9y>_Q_)I03s#JM_X zDpjvHXJ(z*9J5OOT)LXOfwR~qC;*_5$BcqS)8 zM$(l-vBC*v9b>U4(0H=S#<_cJrR*S@{bW%=D5PA?u>c#X&M$ulYmN@w*18Ry-)Yvt znSLk7YecPNZ;%7d`hs=+Dp=1Tz?QDRj{5xB;rigL{swetGaa1in>ohKw9-$O11_aC zD><&_Sb%k%r(iuBJwPwsfjk3P=Tun7PGg-Of_dL^4p`^Vqa1I~`FjodcwVz>pUidI L&GFux`GfKoFw%94 literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..e81c1209b452ab3c0ab8266deadd4f528cd1cb38 GIT binary patch literal 1112 zcmcgq!A`7i@>#wN_YO_iGuun#$~`d$=RuH{u^Fi;m-ts)9~%eHC8@?YGcEpy3E zM+u8Oz|P7Q$6XpX)1mZ2b}PNH?}a`B_+_Z46O1P8IS3=*BO^q$O3M+1tLnYeXu0)z zvvqk*iJmyG31Qo{IIAkiY_x@~z7dXS5V}p0DJ&N1#hq)b=RF3DKQWu}adObD5`W+| zE*a4Uj~EZR!$)k3Cx|;rW-)3UZL@K5M+RdxD|%{QUcc=0FUVp!ifJ#c^YPUB7(znL s(FhY%h-VZU*><9efT48>Ftt7fcC;2iPU|L+*SZT7w7vs&6a7v20iwZ3KL7v# literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..fa0ca97b70182434c3270e94af32b73e7b9d848a GIT binary patch literal 1768 zcmciBO=}ZD7zgktG^|nT3x#5(Q08D)Dx{fYwI(5{i`E)ZB;rd!5O!-e;-OWNRXpy| zV-Ecs;=vEFM?XeBLcf9jH?x!dAKHsZCK=|p&p!LiY-S?jEd7#ia#kxXofS2)L39{T zvil{G74%i~HS~3K+cw}K`VWfPd;fndOZn1yRnK3*HHnAS!*Co`o7HeTj)vI_&A-c1 zu_gU-dB%Sl_fXQwE8WOPeL+NRZf$!_6L|dPV;qju%Q!q9grk8(GT4&rvlktGQeUId zSY%q#x?acE(`eoLf!E$`wpyLtd;7T2*NukOwOV6#H_JeiKdYK@kMo5tba3Ch4L4R8cHFUYN3Q1{sRgcJHe;-+3KNePIWfJX^VD3V z7_1YVNo&FX&N?M0IH5M49XF?E>LfU6)^d@&3_mwH0XZcnmy4V!*}qc6c|Rt zmAnQ&%5&Dq8|0ht3zMVC0Xfb5%;Y4S@RPjeE!eK%HvBM;YxtFMKu*c3UgRWX@en`A zpCzYcb*IQl$WyX^x5$wl*silh?!a%&T$&t^C*+iTN><&XPM@5Rh#G8`6pQ(Am8o_&ZdLwS;L;se-rFg_j)i3S75u>zscWU7xCVk!WHb*JfHjr F?Jx8ec(DKg literal 0 HcmV?d00001 diff --git a/packages/alphatab/test/exporter/AlphaTexExporter.test.ts b/packages/alphatab/test/exporter/AlphaTexExporter.test.ts index 20d839f2b..c42583b46 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/model/JsonConverter.test.ts b/packages/alphatab/test/model/JsonConverter.test.ts index ecf7d2fdb..f6d1f2985 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 b924beca1..096d1db5f 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 000000000..24d4ab2f2 --- /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'); + }); +}); From 9c09468ee4c927ecf1690c3bf613a54be324e8ad Mon Sep 17 00:00:00 2001 From: Danielku15 Date: Mon, 2 Nov 2026 21:29:58 +0100 Subject: [PATCH 19/21] docs: document the TablEdit importer and its compatibility A format page with the feature table of TablEdit 2 and 3 files and their support in alphaTab, a feature page for loading TablEdit files, TablEdit in the lists of supported formats and a release note. --- README.md | 4 +- packages/alphatab/package.json | 2 +- .../playground/src/player/DemoPlayerView.tsx | 2 +- packages/ui/src/player/controls/overlays.tsx | 2 +- .../import-load/direct-importer-use.mdx | 2 +- .../import-load/handling-load-errors.mdx | 2 +- website/docs/features/import-load/index.mdx | 3 +- .../features/import-load/load-from-bytes.mdx | 2 +- .../docs/features/import-load/tabledit.mdx | 47 + .../import-load/tuning-importer-behavior.mdx | 2 +- website/docs/features/index.mdx | 4 +- website/docs/formats/tabledit.mdx | 987 ++++++++++++++++++ website/docs/introduction.mdx | 3 +- website/docs/releases/release1_10.mdx | 8 + website/sidebars.ts | 1 + .../AlphaTexExporterSample/index.tsx | 2 +- website/src/components/Playground/header.tsx | 2 +- .../components/landing/LandingHero/index.tsx | 2 +- .../landing/LandingInfoBand/index.tsx | 1 + website/static/files/tabledit-effects.tef | Bin 0 -> 1819 bytes website/static/llms.txt | 4 +- 21 files changed, 1065 insertions(+), 17 deletions(-) create mode 100644 website/docs/features/import-load/tabledit.mdx create mode 100644 website/docs/formats/tabledit.mdx create mode 100644 website/static/files/tabledit-effects.tef diff --git a/README.md b/README.md index d5a93922b..6864d7101 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 41a5e733a..9513a36c7 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/playground/src/player/DemoPlayerView.tsx b/packages/playground/src/player/DemoPlayerView.tsx index 0ba1190ef..19d4f93d6 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 d67f900c2..ee158eb9b 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 }) => (