#!/usr/bin/env node /** * Builds src/vendor/alphatab/alphatab-importer.js: alphaTab's Guitar Pro and MusicXML importers as one CommonJS file * for RiffDex, from alphaTab's own source at a pinned release. See src/vendor/alphatab/README.md. * * node scripts/alphatab/build-importer.mjs --alphatab [--esbuild ] * [--no-patches --out ] (alphaTab's source exactly as released, for compare-importer.cjs) * * The checkout must be https://github.com/CoderLine/alphaTab at the commit below (release 1.8.4); the script refuses * anything else, because the README's checks were made against that release. esbuild isn't a RiffDex dependency: * pass --esbuild, or install it for the run (`npm install --no-save esbuild@0.27.7`). The output is deterministic * for the same checkout and esbuild version, so rebuilding must reproduce the committed file's SHA-256. * * What changes from alphaTab's source, and only in the bundle: * - Environment.ts is replaced by scripts/alphatab/environment.ts (alphaTab's sets up browser rendering and crashes * React Native at start-up); * - the global TextDecoder is replaced by scripts/alphatab/text-decoder.ts, which uses the platform's when it can; * - generated/VersionInfo.ts, which alphaTab's build generates, is generated here the same way; * - copyright and licence comments in alphaTab's files are marked so the bundle keeps them; * - seven changes (PATCHES below), each checked to match alphaTab's source exactly once: four corrections (two in * MusicXmlImporter.ts, one in Gp3To5Importer.ts, one in model/Note.ts), and three records of what alphaTab reads but * doesn't keep (MusicXML tuning and capo changes and mid-bar setup changes, and bend points as the file gives them). */ import fs from 'node:fs'; import path from 'node:path'; import crypto from 'node:crypto'; import zlib from 'node:zlib'; import { execFileSync } from 'node:child_process'; import { fileURLToPath, pathToFileURL } from 'node:url'; export const ALPHATAB_VERSION = '1.8.4'; export const ALPHATAB_COMMIT = '022a45c8e42370f9e12e68949d11eada370da83d'; const here = path.dirname(fileURLToPath(import.meta.url)); const root = path.resolve(here, '..', '..'); const args = process.argv.slice(2); const option = name => { const i = args.indexOf(name); return i >= 0 ? args[i + 1] : undefined; }; const checkout = option('--alphatab'); if (!checkout) { console.error('Usage: node scripts/alphatab/build-importer.mjs --alphatab [--esbuild ]'); process.exit(2); } const esbuildPath = option('--esbuild'); const patched = !args.includes('--no-patches'); const outOption = option('--out'); if (!patched && !outOption) { console.error('--no-patches builds a comparison copy: give it --out , outside src/vendor.'); process.exit(2); } const { build } = esbuildPath ? await import(pathToFileURL(path.resolve(esbuildPath)).href) : await import('esbuild'); const pkgDir = path.resolve(checkout, 'packages', 'alphatab'); const src = path.join(pkgDir, 'src'); const pkg = JSON.parse(fs.readFileSync(path.join(pkgDir, 'package.json'), 'utf8')); const git = (...a) => execFileSync('git', ['-C', checkout, ...a], { encoding: 'utf8' }).trim(); const commit = git('rev-parse', 'HEAD'); if (pkg.version !== ALPHATAB_VERSION || commit !== ALPHATAB_COMMIT) { console.error(`Expected alphaTab ${ALPHATAB_VERSION} at ${ALPHATAB_COMMIT}; found ${pkg.version} at ${commit}.`); process.exit(1); } const changed = git('status', '--porcelain', '--', 'packages/alphatab/src').split('\n').filter(line => line && !line.endsWith('src/generated/VersionInfo.ts')); if (changed.length) { console.error('The alphaTab checkout has local changes:\n' + changed.join('\n')); process.exit(1); } const commitDate = git('log', '-1', '--format=%cI', 'HEAD'); // alphaTab's scripts/generate-typescript.ts writes this file at build time; the date is the commit's, so builds repeat. const versionInfo = `export class VersionInfo { public static readonly version: string = '${pkg.version}'; public static readonly date: string = '${new Date(commitDate).toISOString()}'; public static readonly commit: string = '${commit}'; public static print(print: (message: string) => void) { print(\`alphaTab \${VersionInfo.version}\`); print(\`commit: \${VersionInfo.commit}\`); print(\`build date: \${VersionInfo.date}\`); } } `; /** Marks copyright and licence comments as legal comments (/*! … *\/), which esbuild keeps: a block comment, or a run of * line comments, that names a copyright or a licence. Only comments change, so the code is alphaTab's as written. */ const NOTICE = /Copyright|Licensed under|licensed under MIT|\(MIT\)/; function keepNotices(text) { return text .replace(/\/\*(?!!)[\s\S]*?\*\//g, block => (NOTICE.test(block) ? '/*!' + block.slice(2) : block)) .replace(/(?:^[ \t]*\/\/[^\n]*\n)+/gm, run => { if (!NOTICE.test(run)) return run; const lines = run.trimEnd().split('\n').map(line => ' * ' + line.replace(/^[ \t]*\/\/ ?/, '').replace(/\*\//g, '* /')); return '/*!\n' + lines.join('\n') + '\n */\n'; }); } /** * Corrections to alphaTab 1.8.4, made only in this bundle, each found by checking imports against the files' own * contents (scripts/tab-import-checks). Without them RiffDex would lose or invent notation. tab-import.test.ts checks * the MusicXML ones with qa/tab-files/riffdex-dadgad.musicxml; src/vendor/alphatab/README.md lists every change. */ export const PATCHES = [ { file: 'importer/MusicXmlImporter.ts', why: 'A MusicXML note gets its string from after it has joined its beat, so the beat never listed it ' + 'by string, and everything that looks for "the next note on this string" missed it: hammer-ons and pull-offs were lost.', find: ` case 'string': if (note) { note.string = beat.voice.bar.staff.tuning.length - Number.parseInt(c.innerText, 10) + 1; } break;`, replace: ` case 'string': if (note) { note.string = beat.voice.bar.staff.tuning.length - Number.parseInt(c.innerText, 10) + 1; /*! RiffDex: list the note under its string now it has one (Beat.addNote ran before the string was known). */ if (note.beat && note.isStringed) { note.beat.noteStringLookup.set(note.string, note); } } break;`, }, { file: 'importer/MusicXmlImporter.ts', why: ' and mark the note a hammer-on or pull-off arrives at; alphaTab ' + 'treated them as the start of another one, into whatever note comes next on that string.', find: ` case 'hammer-on': case 'pull-off': if (note) { note.isHammerPullOrigin = true; } break;`, replace: ` case 'hammer-on': case 'pull-off': if (note && c.getAttribute('type', 'start') !== 'stop') { /*! RiffDex: only type="start" begins a hammer-on or pull-off; type="stop" is the note it arrives at. */ note.isHammerPullOrigin = true; } break;`, }, { file: 'importer/Gp3To5Importer.ts', why: 'Note flag 0x02 is a heavy accent from Guitar Pro 5 on. Guitar Pro 3 and 4 have one kind of accent (flag 0x40), and ' + 'PyGuitarPro, a separate reader, ignores 0x02 in them; alphaTab read it as a heavy accent in every version, which gave ' + '899 notes of one Guitar Pro 4 test file accents the other reader doesn\'t see, and hid an accent (0x40) set beside it.', find: ` if ((flags & 0x02) !== 0) { newNote.accentuated = AccentuationType.Heavy; } else if ((flags & 0x40) !== 0) {`, replace: ` /*! RiffDex: a heavy accent (0x02) exists from Guitar Pro 5 on. */ if ((flags & 0x02) !== 0 && this._versionNumber >= 500) { newNote.accentuated = AccentuationType.Heavy; } else if ((flags & 0x40) !== 0) {`, }, { file: 'model/Note.ts', why: 'A tied note with no note before it on its string (within three bars) became an ordinary struck note at whatever fret ' + 'the file stored, which can be 0. The flag lets RiffDex mark it as uncertain instead of showing a note the music may not have.', find: ` if (!tieOrigin) { this.isTieDestination = false; } else {`, replace: ` if (!tieOrigin) { /*! RiffDex: remember a tie that has no note before it, so the import can say so. */ (this as unknown as { riffdexUnresolvedTie: boolean }).riffdexUnresolvedTie = true; this.isTieDestination = false; } else {`, }, // The next three keep what alphaTab reads but doesn't store, for RiffDex to use or report. They change nothing alphaTab // itself does with a file. { file: 'importer/MusicXmlImporter.ts', why: 'MusicXML can change a staff\'s tuning or capo at the start of any bar (). alphaTab keeps one tuning and ' + 'capo per staff, the last ones it reads, so every note was worked out from those. Recording the tuning and capo on each ' + 'bar that sets them lets RiffDex apply each from its own bar on, or say it can\'t.', find: ` case 'staff-details': staffIndex = Number.parseInt(c.getAttribute('number', '1'), 10) - 1; staff = this._getOrCreateStaff(track, staffIndex); this._parseStaffDetails(c, staff); break;`, replace: ` case 'staff-details': staffIndex = Number.parseInt(c.getAttribute('number', '1'), 10) - 1; staff = this._getOrCreateStaff(track, staffIndex); this._parseStaffDetails(c, staff); if (staff.tuning.length > 0) { /*! RiffDex: remember the tuning and capo set in this bar (alphaTab keeps one per staff: the last read). */ (this._getOrCreateBar(staff, masterBar) as unknown as { riffdexSetup: { tuning: number[]; capo: number } }).riffdexSetup = { tuning: staff.tuning.slice(), capo: staff.capo }; } break;`, }, { file: 'importer/MusicXmlImporter.ts', why: 'alphaTab ignores and in the middle of a bar. Recording which staff had one lets RiffDex say ' + 'the notes after it may not be read in the setup the file meant.', find: ` // case 'staff-details': Not supported // case 'transpose': Not supported`, replace: ` // case 'staff-details': Not supported // case 'transpose': Not supported case 'staff-details': case 'transpose': // Always true: the "if" only keeps the mark below, which esbuild drops when it sits straight in a case. if (c.localName === 'staff-details' || c.localName === 'transpose') { /*! RiffDex: alphaTab doesn't apply these in the middle of a bar; remember which staff had one (null: every staff of the part). */ const riffdexBar = masterBar as unknown as { riffdexMidBarSetup?: { track: number; staff: number | null }[] }; const riffdexStaff = c.attributes.has('number') ? Number.parseInt(c.getAttribute('number'), 10) - 1 : c.localName === 'transpose' ? null : 0; riffdexBar.riffdexMidBarSetup = [...(riffdexBar.riffdexMidBarSetup ?? []), { track: track.index, staff: riffdexStaff }]; } break;`, }, { file: 'model/Note.ts', why: 'alphaTab turns a bend of three or four points into one of its bend types and removes the points it doesn\'t need for ' + 'drawing; for some curves (a prebend, then a bend up, then a release) that drops a turn. Keeping the points as the file ' + 'gives them lets RiffDex show every rise and release.', find: ` const points = this.bendPoints; const hasBend = points != null && points.length > 0; `, replace: ` const points = this.bendPoints; const hasBend = points != null && points.length > 0; /*! RiffDex: keep the bend's points as the file gives them, before the simplification below. */ if (hasBend && !(this as unknown as { riffdexBendPoints?: number[][] }).riffdexBendPoints) { (this as unknown as { riffdexBendPoints: number[][] }).riffdexBendPoints = points!.map(p => [p.offset, p.value]); } `, }, ]; function applyPatches(file, text) { if (!patched) return text; for (const patch of PATCHES.filter(p => p.file === file)) { const at = text.indexOf(patch.find); if (at < 0 || text.indexOf(patch.find, at + 1) >= 0) throw new Error(`Patch for ${file} doesn't match alphaTab's source exactly once.`); text = text.slice(0, at) + patch.replace + text.slice(at + patch.find.length); patch.applied = true; } return text; } const inputs = new Map(); const plugin = { name: 'riffdex-alphatab', setup(b) { // alphaTab's files load under the name 'alphatab', with paths inside its src folder, so the output doesn't depend on // where the checkout is. RiffDex's own files are ordinary files, named from the RiffDex folder. b.onResolve({ filter: /^@coderline\/alphatab\/Environment$/ }, () => ({ path: path.join(here, 'environment.ts') })); b.onResolve({ filter: /^@coderline\/alphatab\/generated\/VersionInfo$/ }, () => ({ path: 'generated/VersionInfo.ts', namespace: 'alphatab-generated' })); b.onResolve({ filter: /^@coderline\/alphatab\// }, args => { const rel = args.path.slice('@coderline/alphatab/'.length); for (const candidate of [`${rel}.ts`, `${rel}/index.ts`]) if (fs.existsSync(path.join(src, candidate))) return { path: candidate, namespace: 'alphatab' }; return { errors: [{ text: `Not found in alphaTab's source: ${args.path}` }] }; }); b.onResolve({ filter: /.*/, namespace: 'alphatab' }, args => ({ errors: [{ text: `Unexpected import in alphaTab's source: ${args.path} from ${args.importer}` }] })); b.onLoad({ filter: /.*/, namespace: 'alphatab' }, args => { const text = fs.readFileSync(path.join(src, args.path), 'utf8'); inputs.set(`alphatab/src/${args.path}`, text); return { contents: keepNotices(applyPatches(args.path, text)), loader: 'ts', resolveDir: path.join(src, path.dirname(args.path)) }; }); b.onLoad({ filter: /.*/, namespace: 'alphatab-generated' }, () => { inputs.set('alphatab/src/generated/VersionInfo.ts (generated by this script)', versionInfo); return { contents: versionInfo, loader: 'ts' }; }); b.onLoad({ filter: /\.ts$/ }, args => { const text = fs.readFileSync(args.path, 'utf8'); inputs.set(path.relative(root, args.path).split(path.sep).join('/'), text); return { contents: text, loader: 'ts' }; }); }, }; const banner = `/*! * RiffDex's build of alphaTab's file importers. Generated by scripts/alphatab/build-importer.mjs: don't edit by hand. * * alphaTab ${pkg.version} (commit ${commit}), https://github.com/CoderLine/alphaTab * Copyright © Daniel Kuschny and Contributors. Licensed under the Mozilla Public License 2.0: see LICENSE beside this file. * Source Code Form: https://github.com/CoderLine/alphaTab/tree/${commit}/packages/alphatab/src * RiffDex's additions (scripts/alphatab: the entry, an Environment stand-in and a text decoder) are covered by the same licence. * ${patched ? `RiffDex changes ${PATCHES.length} things in alphaTab's source, marked "RiffDex:" below; build-importer.mjs says why.` : 'Built without RiffDex\'s changes, for comparison with the published package.'} * * Parts of alphaTab are based on other open-source code; their notices are kept below, where that code is. */`; const options = { entryPoints: ['scripts/alphatab/entry.ts'], bundle: true, format: 'cjs', platform: 'neutral', // ES2022 keeps class fields as written, as alphaTab's own published build does. Expo's Babel preset leaves them for // Hermes, which runs them natively; an ES2020 target turned every field into an Object.defineProperty call through a // helper, which made reading files 20–70% slower without a JIT (Node --jitless, 15 Oct 2026). target: 'es2022', define: { TextDecoder: '__riffdexTextDecoder' }, inject: [path.join(here, 'decoder-shim.ts')], plugins: [plugin], absWorkingDir: root, legalComments: 'inline', banner: { js: banner }, charset: 'utf8', logLevel: 'error', write: false, }; const result = await build({ ...options, outfile: 'alphatab-importer.js' }); const code = result.outputFiles[0].text; if (patched && PATCHES.some(p => !p.applied)) throw new Error('A patch was not applied.'); const out = outOption ? path.resolve(outOption) : path.join(root, 'src', 'vendor', 'alphatab', 'alphatab-importer.js'); fs.writeFileSync(out, code); if (!patched) { console.log(`${out}: alphaTab ${pkg.version} as released, without RiffDex's changes (${Buffer.byteLength(code)} bytes)`); process.exit(0); } const minified = await build({ ...options, outfile: 'alphatab-importer.min.js', minify: true, banner: undefined, legalComments: 'none' }); const sha = data => crypto.createHash('sha256').update(data).digest('hex'); const list = [...inputs.keys()].sort(); const sources = list.map(name => `${sha(inputs.get(name))} ${name}`).join('\n') + '\n'; fs.writeFileSync(path.join(root, 'src', 'vendor', 'alphatab', 'SOURCES.txt'), `# Files in alphatab-importer.js, with each one's SHA-256 as read, before RiffDex's changes (alphaTab ${pkg.version}, commit ${commit}).\n` + `# Changed in the bundle: ${[...new Set(PATCHES.map(p => p.file))].map(f => `alphatab/src/${f}`).join(', ')} (see PATCHES in scripts/alphatab/build-importer.mjs).\n` + sources); console.log(`alphatab-importer.js: ${Buffer.byteLength(code)} bytes, sha256 ${sha(code)}`); console.log(`minified (not committed, for size): ${minified.outputFiles[0].contents.length} bytes, ${zlib.gzipSync(minified.outputFiles[0].contents, { level: 9 }).length} gzipped`); console.log(`${list.length} source files listed in SOURCES.txt`);