diff --git a/README.md b/README.md
index e8e76cfb1..03cedd8be 100644
--- a/README.md
+++ b/README.md
@@ -1,63 +1,112 @@
-# AlphaTab
+# alphaTab
-[](https://www.alphatab.net/)
-[](https://www.alphatab.net/docs/introduction)
-[](https://www.mozilla.org/en-US/MPL/2.0/)
+**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.
+
+[](https://www.npmjs.com/package/@coderline/alphatab)
+[](https://www.npmjs.com/package/@coderline/alphatab)
+[](https://www.nuget.org/packages/AlphaTab)
+[](https://www.nuget.org/packages/AlphaTab)
+[](https://central.sonatype.com/artifact/net.alphatab/alphaTab)
[](https://github.com/CoderLine/alphaTab/actions/workflows/build.yml)
-alphaTab is a cross platform music notation and guitar tablature rendering library. You can use alphaTab within your own website or application to load and display music sheets from data sources like Guitar Pro or the built in markup language named alphaTex.
+[Website](https://alphatab.net) · [Documentation](https://alphatab.net/docs/introduction) · [Playground](https://alphatab.net/playground) · [Features](https://alphatab.net/docs/features/) · [Release notes](https://alphatab.net/docs/releases) · [Discussions](https://github.com/CoderLine/alphaTab/discussions)
+
+[](https://alphatab.net/playground)
+
+*alphaTab draws the score, cursor and selection. The player around it is your own UI, built on alphaTab's API and events.*
+
+## Highlights
+
+- **One score, every notation.** Standard notation, guitar and bass tablature, drum tabs, slash notation and numbered notation (jianpu), alone or side by side.
+- **Plays what it shows.** A built-in SoundFont2/SoundFont3 synthesizer, or playback synced to a backing track, an audio file or a video.
+- **Interactive by design.** Beat-accurate cursor, click to seek, range selection and looping, tempo control, count-in and metronome, all exposed as events you subscribe to.
+- **Layouts that adapt.** Page, horizontal and parchment layouts that reflow on resize, down to phone widths.
+- **Multi-track control.** Show, hide, mute and solo tracks at runtime, with per-track volume and transposition.
+- **Make it yours.** Colors per notation element, interchangeable SMuFL music fonts and fine-grained control over what is displayed.
+- **Edit and export.** Modify the data model in code and export to Guitar Pro, alphaTex, MIDI or rendered audio.
+
+| | Formats |
+|---|---|
+| **Reads** | Guitar Pro 3–8 (`.gp3` `.gp4` `.gp5` `.gpx` `.gp`), MusicXML (`.xml` `.musicxml` `.mxl`), Capella (`.capx`), [alphaTex](https://alphatab.net/docs/alphatex/introduction) |
+| **Writes** | Guitar Pro 7+ (`.gp`), alphaTex, MIDI, audio (PCM samples) |
+
+## Quick start
+
+Install the package:
+
+```bash
+npm install @coderline/alphatab
+```
+
+Or skip the install and try it from a CDN. Save this as an HTML file and open it in a browser:
+
+```html
+
+ \title "Hello alphaTab"
+ .
+ :4 0.6 1.6 3.6 0.5 2.5 3.5 0.4 2.4 |
+ 3.4 0.3 2.3 0.2 1.2 3.2 0.1 1.1 |
+ 3.1.1
+
+
+```
-
+The music inside the `` is [alphaTex](https://alphatab.net/docs/alphatex/introduction), alphaTab's plain-text notation format. Edit it live in the [Playground](https://alphatab.net/playground), or continue with the [web tutorial](https://alphatab.net/docs/tutorials) to add playback and a player UI.
-## Getting Started
+Using a bundler? The [Vite](https://alphatab.net/docs/getting-started/installation-vite) and [webpack](https://alphatab.net/docs/getting-started/installation-webpack) plugins set up the web workers, audio worklets, fonts and SoundFont for you.
-To get started follow our guides and tutorials at:
+## Platforms
-*
-*
+The .NET and Kotlin libraries are transpiled from the same TypeScript source, so features and behavior match across platforms.
-## Features
+| Platform | Package | Install | Guide |
+|---|---|---|---|
+| Evergreen browsers and Node.js | [`@coderline/alphatab`](https://www.npmjs.com/package/@coderline/alphatab) | `npm install @coderline/alphatab` | [Web](https://alphatab.net/docs/getting-started/installation-web) |
+| .NET Standard 2.0 | [`AlphaTab`](https://www.nuget.org/packages/AlphaTab) | `dotnet add package AlphaTab` | [.NET](https://alphatab.net/docs/getting-started/installation-net) |
+| WPF and WinForms (.NET 8) | [`AlphaTab.Windows`](https://www.nuget.org/packages/AlphaTab.Windows) | `dotnet add package AlphaTab.Windows` | [.NET](https://alphatab.net/docs/getting-started/installation-net) |
+| Android (Kotlin, API 26+) | [`net.alphatab:alphaTab`](https://central.sonatype.com/artifact/net.alphatab/alphaTab) | `implementation("net.alphatab:alphaTab:")` | [Android](https://alphatab.net/docs/getting-started/installation-android) |
-alphaTab mostly focuses on web based platforms allowing music notation to be embedded into websites and browser based apps but is also designed to be used on various other platforms like .net and Android either as a platform native integration or through runtime specific JavaScript engines.
+## Packages in this repository
-alphaTab can load music notation from various sources like Guitar Pro 3-7, AlphaTex and MusicXML and render them into beautiful music sheets right within your browser (or application). Using a built in midi synthesizer named alphaSynth the music sheets can also be played in your browser.
+| Package | Description |
+|---|---|
+| [`@coderline/alphatab`](https://www.npmjs.com/package/@coderline/alphatab) | The rendering and playback library. |
+| [`@coderline/alphatab-vite`](https://www.npmjs.com/package/@coderline/alphatab-vite) | Vite plugin that bundles alphaTab's workers, fonts and SoundFont. |
+| [`@coderline/alphatab-webpack`](https://www.npmjs.com/package/@coderline/alphatab-webpack) | webpack plugin that bundles alphaTab's workers, fonts and SoundFont. |
+| [`@coderline/alphatab-language-server`](https://www.npmjs.com/package/@coderline/alphatab-language-server) | alphaTex language server with completion, hover docs and formatting, plus a TextMate grammar. |
+| [`@coderline/alphatab-monaco`](https://www.npmjs.com/package/@coderline/alphatab-monaco) | alphaTex editing in the Monaco editor (experimental). |
-* Load GuitarPro 3-5, GuitarPro 6, Guitar Pro 7, AlphaTex or MusicXML
-* Render as SVG or Raster Graphics (raster graphics depends on platform: HTML5 canvas, GDI+, SkiaSharp, Android Canvas)...
-* Display single or multiple instruments as standard music notation and guitar tablatures consisting of song information, repeats, alternate endings, guitar tunints, clefs, key signatures, time signatures, notes, rests, accidentals, drum tabs, piano grand staff, tied notes, grace notes, dead notes, ghost notes, markers, tempos, lyrics, chords, vibratos, dynamics, tap/slap/pop, fade-in, let-ring, palm-mute, string bends, whammy bar, tremolo picking, strokes, slides, trills, pick strokes, tuplets, fingering, triplet feels,...
-* Adapt to your responsive design by dynamic resizing
-* Play the music sheet via built-in Midi+SoundFont2 Synthesizer (output depends on platform: HTML5 Web Audio, NAudio, Android AudioTrack)
+## Documentation
-## Officially Supported Platforms
+- [Getting started](https://alphatab.net/docs/introduction): installation and configuration for web, .NET and Android.
+- [Feature catalog](https://alphatab.net/docs/features/): every capability with a live example.
+- [alphaTex](https://alphatab.net/docs/alphatex/introduction): the syntax reference for writing music as text.
+- [API reference](https://alphatab.net/docs/reference/api): settings, events, methods and the data model.
+- [Tutorials](https://alphatab.net/docs/tutorials): build a complete player step by step.
+- [llms.txt](https://alphatab.net/llms.txt): an index of the documentation for AI assistants.
- Platform | Support | Availability
-----------|---------|--------------
-Browsers using `script` includes (UMD) | Full experience including low level APIs, Background Workers, Audio Playback, SVG and HTML5 rendering. UI level integration for user interaction and automatic resizing. | 1.0-latest
-Node.js using `require` (UMD) | Access to all low level APIs and SVG rendering | 1.0-latest
-.net standard 2.0 | Access to all low level APIs and multiple render engines (SVG, GDI+, SkiaSharp) | 1.0-latest
-.netcoreapp3.1 (WPF) | Full experience including low level APIs, Background Workers, Audio Playback (through NAudio), SVG and HTML5 rendering. UI level integration for user interaction and automatic resizing. | 1.0-1.2.2
-.netcoreapp3.1 (WinForms) | Full experience including low level APIs, Background Workers, Audio Playback (through NAudio), SVG and HTML5 rendering. UI level integration for user interaction and automatic resizing. Reduced UI level integration related to transparency and animations. | 1.0-1.2.2
-Browsers using ES6 Modules (ESM) | Full experience including low level APIs, Background Workers, Audio Playback, SVG and HTML5 rendering. UI level integration for user interaction and automatic resizing. | 1.3
-Node.js using `import` (ESM) | Access to all low level APIs and SVG rendering | 1.3
-.net8.0-windows (WPF) | Full experience including low level APIs, Background Workers, Audio Playback (through NAudio), SVG and HTML5 rendering. UI level integration for user interaction and automatic resizing. | 1.3
-.net8.0-windows (WinForms) | Full experience including low level APIs, Background Workers, Audio Playback (through NAudio), SVG and HTML5 rendering. UI level integration for user interaction and automatic resizing. Reduced UI level integration related to transparency and animations. | 1.3
-Android (Kotlin) | Full experience including low level APIs, Background Workers, Audio Playback, Android Canvas and SVG rendering. UI level integration for user interaction and automatic resizing. Reduced UI level integration related to transparency and animations. | 1.3
+## Community
-## Thanks to...
+- **Built with alphaTab:** browse community projects, from practice apps to score editors, in [Show and tell](https://github.com/CoderLine/alphaTab/discussions/categories/show-and-tell).
+- **Questions and ideas:** start a thread in [GitHub Discussions](https://github.com/CoderLine/alphaTab/discussions) or ask on [Stack Overflow](https://stackoverflow.com/questions/tagged/alphatab).
+- **Bugs:** open an [issue](https://github.com/CoderLine/alphaTab/issues) using the bug template.
+- **Contributing:** read the [contribution guidelines](https://github.com/CoderLine/alphaTab/blob/develop/CONTRIBUTING.md) and the [code of conduct](https://github.com/CoderLine/alphaTab/blob/develop/code-of-conduct.md). AI-assisted contributions follow [AGENTS.md](https://github.com/CoderLine/alphaTab/blob/develop/AGENTS.md).
-... our friends of BrowserStack for a free plan. This allows me to test alphaTab on all browsers on all operating systems. Only with this I can ensure that alphaTab is shown to all your visitors as expected.
+## Supporting alphaTab
-
-
-
+Due to tax regulations in Austria, where alphaTab is developed, we unfortunately cannot accept donations. If alphaTab is useful to you, please consider donating to a charity of your choice instead. And share what you built in [Show and tell](https://github.com/CoderLine/alphaTab/discussions/categories/show-and-tell): we love to see alphaTab in the wild. A star on GitHub also helps others discover the project.
-... our friends at JetBrains for a Open Source License of their products. This allows me to develop all the flavors of alphaTab with the latest and greatest coding and debugging assistance.
+## License
-
-
-Copyright © 2000-2022 JetBrains s.r.o. JetBrains and the JetBrains logo are registered trademarks of JetBrains s.r.o.
-
+alphaTab is licensed under the [Mozilla Public License 2.0](https://github.com/CoderLine/alphaTab/blob/develop/LICENSE). The bundled music fonts Bravura and Petaluma (© Steinberg Media Technologies) and the Noto fonts are licensed under the SIL Open Font License. The bundled SONiVOX SoundFont is licensed under the Apache License 2.0.
-... [Bernhard Schelling](https://github.com/schellingb/TinySoundFont) the author of TinySoundFont and [Steve Folta](https://github.com/stevefolta/SFZero) the author of SFZero for providing the core of the synthesis engine.
+## Thanks
-... all you people using alphaTab providing new feature ideas and bug reports.
+- [BrowserStack](https://www.browserstack.com) for a free plan to test alphaTab on all browsers and operating systems.
+- [Bernhard Schelling](https://github.com/schellingb/TinySoundFont), author of TinySoundFont, and [Steve Folta](https://github.com/stevefolta/SFZero), author of SFZero, whose work forms the core of alphaTab's synthesizer.
+- Everyone who [contributes code](https://github.com/CoderLine/alphaTab/graphs/contributors), reports bugs and suggests features.
diff --git a/img/hero.svg b/img/hero.svg
new file mode 100644
index 000000000..32997a7f8
--- /dev/null
+++ b/img/hero.svg
@@ -0,0 +1,135 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/package-lock.json b/package-lock.json
index e9b75a852..101792790 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -9,7 +9,10 @@
"version": "1.9.0",
"workspaces": [
"packages/*"
- ]
+ ],
+ "devDependencies": {
+ "opentype.js": "^2.0.0"
+ }
},
"node_modules/@biomejs/biome": {
"version": "2.5.14",
@@ -3771,6 +3774,16 @@
"node": ">= 0.8"
}
},
+ "node_modules/opentype.js": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/opentype.js/-/opentype.js-2.0.0.tgz",
+ "integrity": "sha512-kCyjv6xdDY1W/jLWZ/L3QhhTlKUqDZMQ5+Jdlw12b3dXkKNpYBqqlMMj0YDQPShWFTMwgZI1hG14kN3XUDSg/A==",
+ "dev": true,
+ "license": "MIT",
+ "bin": {
+ "ot": "bin/ot"
+ }
+ },
"node_modules/p-limit": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz",
diff --git a/package.json b/package.json
index 0424d8921..7da06f96a 100644
--- a/package.json
+++ b/package.json
@@ -44,9 +44,14 @@
"nightly-check": "node ./scripts/nightly.mts --mode check",
"nightly-pack": "node ./scripts/nightly.mts --mode pack",
- "nightly-publish": "node ./scripts/nightly.mts --mode publish"
+ "nightly-publish": "node ./scripts/nightly.mts --mode publish",
+
+ "readme-hero": "node ./scripts/readme-hero/generate.mts"
},
"overrides": {
"dompurify": "^3.4.12"
+ },
+ "devDependencies": {
+ "opentype.js": "^2.0.0"
}
}
diff --git a/packages/alphatab/package.json b/packages/alphatab/package.json
index 3fe5b76b4..a06723013 100644
--- a/packages/alphatab/package.json
+++ b/packages/alphatab/package.json
@@ -1,14 +1,22 @@
{
"name": "@coderline/alphatab",
"version": "1.9.0",
- "description": "alphaTab is a music notation and guitar tablature rendering library",
+ "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.",
"keywords": [
- "guitar",
"music-notation",
- "music-sheet",
- "html5",
+ "sheet-music",
+ "guitar-tablature",
+ "tablature",
+ "guitar",
+ "guitar-pro",
+ "musicxml",
+ "alphatex",
+ "score",
+ "midi",
+ "soundfont",
+ "synthesizer",
"svg",
- "guitar-tablature"
+ "canvas"
],
"homepage": "https://alphatab.net",
"bugs": {
diff --git a/scripts/readme-hero/generate.mts b/scripts/readme-hero/generate.mts
new file mode 100644
index 000000000..35a1730df
--- /dev/null
+++ b/scripts/readme-hero/generate.mts
@@ -0,0 +1,760 @@
+/**
+ * Generates `img/hero.svg`, the README hero: a passage of a song rendered by alphaTab inside a player window, as one
+ * self-contained SVG with light and dark colors (`prefers-color-scheme`). All text and music symbols are outlined, so
+ * the image renders identically wherever it is embedded as an image.
+ *
+ * Usage (from the repository root, after `npm run build`):
+ *
+ * npm run readme-hero -- --song "temp/Iron Waltz - alphaTab.gp"
+ *
+ * The song needs a backing track and `ffmpeg` on the PATH for the waveform. Run with `--help` for all options.
+ */
+import { spawnSync } from 'node:child_process';
+import fs from 'node:fs';
+import path from 'node:path';
+import { parseArgs } from 'node:util';
+import { type FontFace, GlyphLibrary, num, registerOutlineEngine } from './outline-canvas.mts';
+
+type AlphaTab = typeof import('@coderline/alphatab');
+type Score = import('@coderline/alphatab').model.Score;
+
+const { values: args } = parseArgs({
+ options: {
+ song: { type: 'string' },
+ out: { type: 'string', default: 'img/hero.svg' },
+ alphatab: { type: 'string', default: '@coderline/alphatab' },
+ track: { type: 'string', default: '0' },
+ start: { type: 'string', default: '77' },
+ bars: { type: 'string', default: '4' },
+ scale: { type: 'string', default: '0.78' },
+ loop: { type: 'string', default: '78-79' },
+ cursor: { type: 'string', default: '79.4' },
+ muted: { type: 'string', default: '4' },
+ subtitle: { type: 'string', default: 'alphaTab demo song' },
+ help: { type: 'boolean', default: false }
+ }
+});
+
+if (args.help || !args.song) {
+ console.log(`Options:
+ --song Score to render (required). Its backing track provides the waveform and timing.
+ --out Output SVG (default: img/hero.svg)
+ --alphatab alphaTab ES module to use (default: @coderline/alphatab, the build in packages/alphatab/dist)
+ --track Track to render (default: 0)
+ --start First rendered bar, 1-based (default: 77)
+ --bars Number of rendered bars (default: 4)
+ --scale alphaTab display scale (default: 0.78)
+ --loop Bars of the highlighted loop range (default: 78-79)
+ --cursor Bar and beat index of the playback cursor (default: 79.4)
+ --muted Comma-separated tracks shown as muted (default: 4)
+ --subtitle Text before the time signature and tempo in the title bar`);
+ process.exit(args.song ? 0 : 1);
+}
+
+const alphaTabSpec =
+ args.alphatab!.startsWith('.') || path.isAbsolute(args.alphatab!) ? path.resolve(args.alphatab!) : args.alphatab!;
+const alphaTab: AlphaTab = await import(alphaTabSpec);
+
+const config = {
+ track: Number(args.track),
+ startBar: Number(args.start),
+ barCount: Number(args.bars),
+ scale: Number(args.scale),
+ loop: args.loop!.split('-').map(Number) as [number, number],
+ cursor: { bar: Number(args.cursor!.split('.')[0]), beat: Number(args.cursor!.split('.')[1] ?? 0) },
+ muted: new Set(args.muted ? args.muted.split(',').map(Number) : [])
+};
+
+const W = 1080;
+const PAD = { left: 24, right: 24, top: 18, bottom: 34 };
+const WIN = { x: PAD.left, y: PAD.top, w: W - PAD.left - PAD.right };
+const HEADER_H = 54;
+const SIDEBAR_W = 200;
+const VIEWPORT_H = 246;
+const TIMELINE_H = 70;
+const WIN_H = HEADER_H + 1 + VIEWPORT_H + 1 + TIMELINE_H;
+const H = PAD.top + WIN_H + PAD.bottom;
+const VIEWPORT = { x: WIN.x + SIDEBAR_W, y: WIN.y + HEADER_H + 1, w: WIN.w - SIDEBAR_W, h: VIEWPORT_H };
+const TIMELINE_Y = VIEWPORT.y + VIEWPORT_H + 1;
+
+const glyphs = new GlyphLibrary();
+const ENGINE = 'outline-svg';
+// The window shows only the systems, not alphaTab's attribution line below them.
+registerOutlineEngine(alphaTab, glyphs, ENGINE, text => text === 'rendered by alphaTab');
+
+const songPath = path.resolve(args.song!);
+const score = alphaTab.importer.ScoreLoader.loadScoreFromBytes(new Uint8Array(fs.readFileSync(songPath)));
+
+// --- timing -------------------------------------------------------------------------------------------------------
+
+/** Score time of every masterbar start (seconds), and a mapping from score time to backing-track time. */
+function computeTiming(score: Score) {
+ const starts: number[] = [];
+ let time = 0;
+ let tempo = score.tempo;
+ for (const mb of score.masterBars) {
+ starts.push(time);
+ const duration = mb.calculateDuration();
+ let ratio = 0;
+ for (const automation of [...mb.tempoAutomations].sort((a, b) => a.ratioPosition - b.ratioPosition)) {
+ time += (((automation.ratioPosition - ratio) * duration) / 960) * (60 / tempo);
+ ratio = automation.ratioPosition;
+ tempo = automation.value;
+ }
+ time += (((1 - ratio) * duration) / 960) * (60 / tempo);
+ }
+ starts.push(time);
+
+ const scoreTimeAt = (barIndex: number, ratio: number) =>
+ starts[barIndex] + (starts[barIndex + 1] - starts[barIndex]) * ratio;
+
+ const anchors: [number, number][] = [];
+ for (const mb of score.masterBars) {
+ for (const sp of mb.syncPoints ?? []) {
+ anchors.push([scoreTimeAt(mb.index, sp.ratioPosition), sp.syncPointValue!.millisecondOffset / 1000]);
+ }
+ }
+ anchors.sort((a, b) => a[0] - b[0]);
+
+ const toAudio = (t: number) => {
+ if (anchors.length === 0) {
+ return t;
+ }
+ if (t <= anchors[0][0] || anchors.length === 1) {
+ return anchors[0][1] + (t - anchors[0][0]);
+ }
+ for (let i = 1; i < anchors.length; i++) {
+ const [s0, a0] = anchors[i - 1];
+ const [s1, a1] = anchors[i];
+ if (t <= s1) {
+ return a0 + ((t - s0) / (s1 - s0)) * (a1 - a0);
+ }
+ }
+ const [sl, al] = anchors[anchors.length - 1];
+ return al + (t - sl);
+ };
+
+ return { scoreTimeAt, toAudio, duration: toAudio(starts[starts.length - 1]) };
+}
+
+/** RMS envelope (0..1) of the backing track and its duration, or `null` without backing track. */
+function decodeBackingTrack(score: Score, fallbackDuration: number): { peaks: number[]; duration: number } | null {
+ const raw = score.backingTrack?.rawAudioFile;
+ if (!raw) {
+ console.warn('Song has no backing track, the timeline shows no waveform.');
+ return null;
+ }
+ const sampleRate = 8000;
+ const result = spawnSync(
+ 'ffmpeg',
+ ['-v', 'error', '-i', 'pipe:0', '-ac', '1', '-ar', `${sampleRate}`, '-f', 's16le', 'pipe:1'],
+ {
+ input: raw,
+ maxBuffer: 256 * 1024 * 1024
+ }
+ );
+ if (result.error || result.status !== 0) {
+ throw new Error(
+ `Decoding the backing track needs ffmpeg on the PATH: ${result.error?.message ?? result.stderr}`
+ );
+ }
+ const pcm = result.stdout;
+ const samples = pcm.length / 2;
+ const bins = 220;
+ const perBin = Math.floor(samples / bins);
+ const peaks: number[] = [];
+ for (let i = 0; i < bins; i++) {
+ let sum = 0;
+ for (let j = 0; j < perBin; j++) {
+ const v = pcm.readInt16LE((i * perBin + j) * 2) / 32768;
+ sum += v * v;
+ }
+ peaks.push(Math.sqrt(sum / perBin));
+ }
+ const max = Math.max(...peaks) || 1;
+ return { peaks: peaks.map(p => p / max), duration: samples / sampleRate || fallbackDuration };
+}
+
+// --- notation -----------------------------------------------------------------------------------------------------
+
+function renderNotation() {
+ const settings = new alphaTab.Settings();
+ settings.core.engine = ENGINE;
+ settings.core.enableLazyLoading = false;
+ settings.core.useWorkers = false;
+ settings.display.layoutMode = alphaTab.LayoutMode.Page;
+ settings.display.startBar = config.startBar;
+ settings.display.barCount = config.barCount;
+ settings.display.barsPerRow = config.barCount;
+ settings.display.scale = config.scale;
+ settings.display.padding = [20, 22, 30, 18];
+ settings.display.lastSystemFillThreshold = 0;
+ for (const element of [
+ 'GuitarTuning',
+ 'ScoreTitle',
+ 'ScoreSubTitle',
+ 'ScoreArtist',
+ 'ScoreAlbum',
+ 'ScoreWords',
+ 'ScoreMusic',
+ 'ScoreWordsAndMusic',
+ 'ScoreCopyright',
+ 'TrackNames'
+ ] as const) {
+ settings.notation.elements.set(alphaTab.NotationElement[element], false);
+ }
+
+ const renderer = new alphaTab.rendering.ScoreRenderer(settings);
+ renderer.width = VIEWPORT.w;
+ const partials: { x: number; y: number; svg: string }[] = [];
+ let failure: unknown;
+ renderer.partialRenderFinished.on(r => {
+ if (typeof r.renderResult === 'string') {
+ partials.push({ x: r.x, y: r.y, svg: r.renderResult });
+ }
+ });
+ renderer.error.on(e => {
+ failure = e;
+ });
+ const warnings: string[] = [];
+ const log = alphaTab.Logger.log;
+ alphaTab.Logger.log = {
+ ...log,
+ warning: (category: string, msg: string, ...details: unknown[]) => {
+ warnings.push(`[${category}] ${msg}`);
+ log.warning(category, msg, ...details);
+ }
+ };
+ renderer.renderScore(score, [config.track]);
+ alphaTab.Logger.log = log;
+ if (failure) {
+ throw failure;
+ }
+ if (warnings.some(w => w.includes('do not fit'))) {
+ console.warn('Notation is compressed below its minimum width; reduce --scale or --bars.');
+ }
+
+ const lookup = renderer.boundsLookup!;
+ const system = lookup.staffSystems[0];
+ const masterBar = (bar: number) => {
+ const bounds = system.bars.find(b => b.index === bar - 1);
+ if (!bounds) {
+ throw new Error(`Bar ${bar} is not part of the rendered range.`);
+ }
+ return bounds;
+ };
+
+ const content = partials
+ .map(p => `${stripSvgRoot(p.svg)} `)
+ .join('');
+
+ const cursorBeat = masterBar(config.cursor.bar).bars[0].beats[config.cursor.beat];
+ if (!cursorBeat) {
+ throw new Error(`Bar ${config.cursor.bar} has no beat ${config.cursor.beat}.`);
+ }
+ const cursorStart = cursorBeat.beat.playbackStart;
+ const highlightedBounds = masterBar(config.cursor.bar)
+ .bars.flatMap(b => b.beats)
+ .filter(
+ b => b.beat.playbackStart <= cursorStart && cursorStart < b.beat.playbackStart + b.beat.playbackDuration
+ );
+ const highlighted = highlightedBounds.map(b => b.beat.id);
+ const inkLeft = Math.min(
+ ...highlightedBounds.flatMap(b => [b.visualBounds.x, ...(b.notes ?? []).map(n => n.noteHeadBounds.x)])
+ );
+
+ let markup = content;
+ for (const id of highlighted) {
+ markup = markup.replaceAll(``, ``);
+ }
+ markup = markup.replace(//g, '').replace(/(\d+\.\d{3,})/g, m => num(Number(m)));
+
+ const from = masterBar(config.loop[0]).realBounds;
+ const to = masterBar(config.loop[1]).realBounds;
+ const cursorMasterBar = score.masterBars[config.cursor.bar - 1];
+ const cursorTime = timing.toAudio(
+ timing.scoreTimeAt(cursorMasterBar.index, cursorStart / cursorMasterBar.calculateDuration())
+ );
+
+ return {
+ markup,
+ system: system.realBounds,
+ loop: { x: from.x, y: from.y, w: to.x + to.w - from.x, h: from.h },
+ cursorX: inkLeft - 7,
+ cursorTime,
+ loopTime: [
+ timing.toAudio(timing.scoreTimeAt(config.loop[0] - 1, 0)),
+ timing.toAudio(timing.scoreTimeAt(config.loop[1], 0))
+ ] as [number, number],
+ colors: settings.display.resources
+ };
+}
+
+function stripSvgRoot(svg: string): string {
+ return svg.replace(/^\s*]*>/, '').replace(/<\/svg>\s*$/, '');
+}
+
+// --- composition --------------------------------------------------------------------------------------------------
+
+function push(markup: string) {
+ out.push(markup);
+}
+
+/** Draws `text` with its baseline at `y`; returns the advance width. */
+function text(
+ value: string,
+ x: number,
+ y: number,
+ size: number,
+ face: FontFace,
+ cls: string,
+ align?: 'start' | 'middle' | 'end',
+ letterSpacing?: number
+): number {
+ const r = glyphs.text({ text: value, x, y, size, face, align, letterSpacing, attributes: `class="${cls}"` });
+ push(r.svg);
+ return r.width;
+}
+
+function rect(x: number, y: number, w: number, h: number, cls: string, rx = 0) {
+ push(
+ ` `
+ );
+}
+
+function compose(): string {
+ const duration = audio?.duration ?? timing.duration;
+
+ push(
+ ` `
+ );
+ push(
+ ` `
+ );
+ push('');
+ rect(WIN.x, WIN.y + HEADER_H, WIN.w, 1, 'line');
+ rect(WIN.x, VIEWPORT.y, SIDEBAR_W - 1, VIEWPORT_H, 'panel');
+ rect(WIN.x + SIDEBAR_W - 1, VIEWPORT.y, 1, VIEWPORT_H, 'line');
+ rect(WIN.x, TIMELINE_Y - 1, WIN.w, 1, 'line');
+ rect(WIN.x, TIMELINE_Y, WIN.w, TIMELINE_H, 'panel');
+
+ header();
+ sidebar();
+ viewport();
+ timeline(duration);
+ push(' ');
+
+ function viewport() {
+ const shiftY = Math.round((VIEWPORT_H + 18 - notation.system.h) / 2 - notation.system.y);
+ const ox = VIEWPORT.x;
+ const oy = VIEWPORT.y + shiftY;
+ const { loop } = notation;
+ push(
+ ` `
+ );
+ const bracketY = oy + loop.y - 17;
+ push(
+ ` `
+ );
+ const label = `Loop · bars ${config.loop[0]}–${config.loop[1]}`;
+ const labelW = glyphs.sansBold.measure(label, 10) + 11 + 4;
+ const pillW = labelW + 13;
+ const pillX = ox + loop.x + loop.w / 2 - pillW / 2;
+ const pillY = bracketY - 9;
+ push(` `);
+ push(icon('loop', pillX + 6, pillY + 3.5, 11, 'icon-inv', 2.6));
+ push(
+ glyphs.text({
+ text: label,
+ x: pillX + 6 + 11 + 4,
+ y: pillY + 12.6,
+ size: 10,
+ face: glyphs.sansBold,
+ attributes: 'class="inv"'
+ }).svg
+ );
+
+ push(`${notation.markup} `);
+
+ const cx = ox + notation.cursorX;
+ push(
+ ` `
+ );
+ push(
+ ` `
+ );
+ }
+
+ const css = buildCss(notation.colors);
+ return (
+ `` +
+ `` +
+ ` ${glyphs.defs()} ` +
+ out.join('') +
+ ' \n'
+ );
+}
+
+function header() {
+ const top = WIN.y;
+ text(score.title, WIN.x + 18, top + 25, 15, glyphs.sansBold, 'ink');
+ const mb = score.masterBars[0];
+ text(
+ `${args.subtitle} · ${mb.timeSignatureNumerator}/${mb.timeSignatureDenominator} · ${score.tempo} bpm`,
+ WIN.x + 18,
+ top + 41,
+ 11.5,
+ glyphs.sans,
+ 'muted'
+ );
+
+ const right = WIN.x + WIN.w - 16;
+ const segH = 28;
+ const segY = top + (HEADER_H - segH) / 2;
+ const segLabels = ['Synthesizer', 'Backing track'];
+ const activeSeg = audio ? 1 : 0;
+ const widths = segLabels.map((l, i) => glyphs.sansBold.measure(l, 11.5) + 20 + (i === 1 ? 12 : 0));
+ const segW = widths.reduce((a, b) => a + b, 0) + 2 + 2 + 2;
+ const segX = right - segW;
+ rect(segX + 0.5, segY + 0.5, segW - 1, segH - 1, 'chip', 8);
+ let x = segX + 2;
+ segLabels.forEach((label, i) => {
+ const on = i === activeSeg;
+ if (on) {
+ rect(x, segY + 2, widths[i], segH - 4, 'seg-on', 6);
+ }
+ let tx = x + 10;
+ if (i === 1) {
+ rect(tx - 3, segY + segH / 2 - 6, 12, 12, audio ? 'live-halo' : 'chip', 6);
+ rect(tx, segY + segH / 2 - 3, 6, 6, audio ? 'live' : 'muted', 3);
+ tx += 12;
+ }
+ text(label, tx, segY + 18, 11.5, on ? glyphs.sansBold : glyphs.sans, on ? 'ink' : 'muted');
+ x += widths[i] + 2;
+ });
+
+ const file = `${score.title}${path.extname(songPath)}`;
+ const fileW = glyphs.sans.measure(file, 11.5) + 16;
+ const fileX = segX - 14 - fileW;
+ rect(fileX + 0.5, top + 14.5, fileW - 1, 25, 'chip', 6);
+ text(file, fileX + 8, top + 31, 11.5, glyphs.sans, 'soft');
+}
+
+function sidebar() {
+ const x0 = WIN.x + 10;
+ const w = SIDEBAR_W - 1 - 20;
+ text('TRACKS', x0 + 8, VIEWPORT.y + 24, 10.5, glyphs.sansBold, 'muted', 'start', 0.08);
+ let y = VIEWPORT.y + 35;
+ // Illustrative mixer levels; the score carries no per-track volume worth showing.
+ const volumes = [0.82, 0.64, 0.7, 0.76, 0.5];
+ score.tracks.slice(0, 5).forEach((track, i) => {
+ const on = i === config.track;
+ const muted = config.muted.has(i);
+ const rowH = 37;
+ if (on) {
+ rect(x0 + 0.5, y + 0.5, w - 1, rowH - 1, 'row-on', 9);
+ }
+ rect(x0 + 8, y + (rowH - 24) / 2, 4, 24, `tc${i % 6}`, 2);
+ const nameX = x0 + 8 + 4 + 10;
+ text(
+ track.name,
+ nameX,
+ y + 6 + 13,
+ 12.5,
+ on ? glyphs.sansBold : glyphs.sans,
+ muted ? 'muted' : on ? 'ink' : 'soft'
+ );
+ const btnX = x0 + w - 8 - 44;
+ ['M', 'S'].forEach((label, j) => {
+ const bx = btnX + j * 24;
+ const by = y + (rowH - 20) / 2;
+ const active = label === 'M' && muted;
+ rect(bx + 0.5, by + 0.5, 19, 19, active ? 'btn-m' : 'btn', 5);
+ text(label, bx + 10, by + 14, 10, glyphs.sansBold, active ? 'danger' : 'muted', 'middle');
+ });
+ const volX = nameX;
+ const volW = btnX - 10 - volX;
+ rect(volX, y + 6 + 22, volW, 3, 'vol-bg', 1.5);
+ rect(volX, y + 6 + 22, volW * (volumes[i] ?? 0.6), 3, `tc${i % 6}`, 1.5);
+ y += rowH + 2;
+ });
+}
+
+function timeline(duration: number) {
+ const cy = TIMELINE_Y + TIMELINE_H / 2;
+ const playX = WIN.x + 18;
+ rect(playX, cy - 19, 38, 38, 'accent', 19);
+ rect(playX + 13, cy - 7.5, 4, 15, 'inv', 1.2);
+ rect(playX + 21, cy - 7.5, 4, 15, 'inv', 1.2);
+
+ const fmt = (s: number) => `${Math.floor(s / 60)}:${String(Math.floor(s % 60)).padStart(2, '0')}`;
+ let x = playX + 38 + 14;
+ x += text(fmt(notation.cursorTime), x, cy + 4.5, 12, glyphs.sansBold, 'soft');
+ x += text(` / ${fmt(duration)}`, x, cy + 4.5, 12, glyphs.sans, 'muted');
+
+ // tools, right to left
+ const toolsRight = WIN.x + WIN.w - 18;
+ const tools: { w: number; draw: (x: number) => void }[] = [
+ {
+ w: 28,
+ draw: tx => {
+ rect(tx + 0.5, cy - 13.5, 27, 27, 'tool-on', 7);
+ push(icon('loop', tx + 6.5, cy - 7.5, 15, 'icon-on'));
+ }
+ },
+ { w: 28, draw: tx => push(icon('metronome', tx + 6.5, cy - 7.5, 15, 'icon')) },
+ {
+ w: glyphs.sansBold.measure('0.75×', 11.5) + 14,
+ draw: tx => text('0.75×', tx + 7, cy + 4, 11.5, glyphs.sansBold, 'soft')
+ },
+ { w: 28, draw: tx => push(icon('volume', tx + 6.5, cy - 7.5, 15, 'icon')) }
+ ];
+ const toolsW = tools.reduce((a, b) => a + b.w, 0) + 6 * (tools.length - 1);
+ let tx = toolsRight - toolsW;
+ for (const tool of tools) {
+ tool.draw(tx);
+ tx += tool.w + 6;
+ }
+
+ const scrubX = x + 14;
+ const scrubW = toolsRight - toolsW - 14 - scrubX;
+ const scrubY = cy - 23;
+ const progress = notation.cursorTime / duration;
+ const [loopFrom, loopTo] = notation.loopTime.map(v => v / duration);
+ const peaks = audio?.peaks ?? new Array(220).fill(0.35);
+ const barW = (scrubW - (peaks.length - 1)) / peaks.length;
+ // One path per color keeps the 200+ waveform bars small.
+ const wave = new Map();
+ peaks.forEach((p, i) => {
+ const pos = (i + 0.5) / peaks.length;
+ const cls = pos >= loopFrom && pos <= loopTo ? 'accent' : pos < progress ? 'wave-p' : 'wave';
+ const h = 26 * Math.max(0.08, p);
+ const bar = `M${num(scrubX + i * (barW + 1))} ${num(scrubY + 4 + (26 - h) / 2)}h${num(barW)}v${num(h)}h${num(-barW)}z`;
+ wave.set(cls, (wave.get(cls) ?? '') + bar);
+ });
+ for (const [cls, d] of wave) {
+ push(` `);
+ }
+
+ const headX = scrubX + progress * scrubW;
+ rect(headX - 1, scrubY, 2, 34, 'ink', 1);
+ rect(headX - 4, scrubY - 3, 8, 8, 'ink', 4);
+
+ // section labels; the current section always shows, others only where they don't collide
+ const sections = score.masterBars
+ .filter(mb => mb.section)
+ .map(mb => ({
+ text: mb.section!.text || mb.section!.marker,
+ time: timing.toAudio(timing.scoreTimeAt(mb.index, 0)),
+ index: mb.index
+ }));
+ const current = sections.filter(s => s.index <= config.cursor.bar - 1).pop();
+ const LABEL_GAP = 10;
+ const candidates = sections.map((s, i) => {
+ const x = scrubX + (s.time / duration) * scrubW;
+ const end = x + 5 + glyphs.sans.measure(s.text, 9.5);
+ const length = (sections[i + 1]?.time ?? duration) - s.time;
+ return { x, end, s, length };
+ });
+ // The current section always shows; the others by descending length, wherever they don't collide.
+ candidates.sort((a, b) => (a.s === current ? -1 : b.s === current ? 1 : b.length - a.length));
+ const placed: typeof candidates = [];
+ for (const c of candidates) {
+ const fits =
+ c.end <= scrubX + scrubW && placed.every(p => c.end + LABEL_GAP <= p.x || p.end + LABEL_GAP <= c.x);
+ if (fits) {
+ placed.push(c);
+ }
+ }
+ const labelsY = scrubY + 46 - 12;
+ for (const p of placed) {
+ const isCurrent = p.s === current;
+ rect(p.x, labelsY, 1, 12, isCurrent ? 'accent' : 'line');
+ text(
+ p.s.text,
+ p.x + 5,
+ labelsY + 9.5,
+ 9.5,
+ isCurrent ? glyphs.sansBold : glyphs.sans,
+ isCurrent ? 'accent' : 'muted'
+ );
+ }
+}
+
+/** Lucide-style stroke icons on a 24 unit grid. */
+function icon(
+ name: 'loop' | 'metronome' | 'volume',
+ x: number,
+ y: number,
+ size: number,
+ cls: string,
+ strokeWidth = 2
+): string {
+ const paths = {
+ loop: 'M17 2l4 4-4 4M3 11v-1a4 4 0 0 1 4-4h14M7 22l-4-4 4-4M21 13v1a4 4 0 0 1-4 4H3',
+ metronome: 'M9 3h6l4 18H5zM12 15 17 6M8 15h8',
+ volume: 'M11 5 6 9H3v6h3l5 4zM16 9a4 4 0 0 1 0 6M19 6a8 8 0 0 1 0 12'
+ };
+ return ` `;
+}
+
+function escapeXml(s: string): string {
+ return s.replace(/&/g, '&').replace(/ /g, '>').replace(/"/g, '"');
+}
+
+// --- theme --------------------------------------------------------------------------------------------------------
+
+function buildCss(resources: import('@coderline/alphatab').RenderingResources): string {
+ const light = {
+ surface: '#ffffff',
+ panel: '#fbfcfe',
+ 'surface-alt': '#eef2f8',
+ line: '#dfe5ee',
+ ink: '#0d1626',
+ soft: '#3a4761',
+ muted: '#697587',
+ accent: '#3b6db8',
+ inv: '#ffffff',
+ 'accent-soft': 'rgba(59,109,184,.10)',
+ 'accent-line': 'rgba(59,109,184,.24)',
+ sel: 'rgba(59,109,184,.16)',
+ halo: 'rgba(59,109,184,.16)',
+ wave: '#c9d2df',
+ 'wave-p': '#8fb0dd',
+ live: '#2f8f6c',
+ 'live-halo': 'rgba(47,143,108,.18)',
+ danger: '#c0392b',
+ 'danger-soft': '#fdecea',
+ 'danger-line': '#f2c4bf',
+ shadow: 'rgba(13,22,38,.28)',
+ 'n-ink': '#0d1626',
+ 'n-staff': '#a3acba',
+ 'n-bar': '#2a3346',
+ 'n-num': '#c0392b',
+ 'n-soft': 'rgba(13,22,38,.4)',
+ tc0: '#3b6db8',
+ tc1: '#7a5cc0',
+ tc2: '#208a73',
+ tc3: '#c07a3e',
+ tc4: '#a64570',
+ tc5: '#3e8eb3'
+ };
+ const dark: typeof light = {
+ surface: '#0f1729',
+ panel: '#0c1424',
+ 'surface-alt': '#162033',
+ line: '#22304a',
+ ink: '#eef2f8',
+ soft: '#bcc6d6',
+ muted: '#7e8aa0',
+ accent: '#6a98e3',
+ inv: '#0a1020',
+ 'accent-soft': 'rgba(106,152,227,.14)',
+ 'accent-line': 'rgba(106,152,227,.32)',
+ sel: 'rgba(106,152,227,.18)',
+ halo: 'rgba(106,152,227,.22)',
+ wave: '#2a3854',
+ 'wave-p': '#4b6aa0',
+ live: '#4cc38a',
+ 'live-halo': 'rgba(76,195,138,.2)',
+ danger: '#ff8a80',
+ 'danger-soft': 'rgba(255,138,128,.12)',
+ 'danger-line': 'rgba(255,138,128,.35)',
+ shadow: 'rgba(0,0,0,.6)',
+ 'n-ink': '#e3eaf3',
+ 'n-staff': '#4f5d78',
+ 'n-bar': '#aab5c8',
+ 'n-num': '#f0716c',
+ 'n-soft': 'rgba(227,234,243,.4)',
+ tc0: '#6a98e3',
+ tc1: '#9d84dc',
+ tc2: '#3fb595',
+ tc3: '#dd9a5e',
+ tc4: '#d0688f',
+ tc5: '#62acd0'
+ };
+ const vars = (theme: typeof light) =>
+ Object.entries(theme)
+ .map(([k, v]) => `--${k}:${v}`)
+ .join(';');
+
+ const fills: [string, string][] = [
+ ['win', 'surface'],
+ ['panel', 'panel'],
+ ['line', 'line'],
+ ['ink', 'ink'],
+ ['soft', 'soft'],
+ ['muted', 'muted'],
+ ['accent', 'accent'],
+ ['inv', 'inv'],
+ ['chip', 'surface-alt'],
+ ['seg-on', 'surface'],
+ ['btn', 'surface'],
+ ['btn-m', 'danger-soft'],
+ ['danger', 'danger'],
+ ['row-on', 'surface'],
+ ['vol-bg', 'line'],
+ ['sel', 'sel'],
+ ['cursor-halo', 'halo'],
+ ['wave', 'wave'],
+ ['wave-p', 'wave-p'],
+ ['live', 'live'],
+ ['live-halo', 'live-halo'],
+ ['tool-on', 'accent-soft'],
+ ['tc0', 'tc0'],
+ ['tc1', 'tc1'],
+ ['tc2', 'tc2'],
+ ['tc3', 'tc3'],
+ ['tc4', 'tc4'],
+ ['tc5', 'tc5']
+ ];
+ const strokes: [string, string][] = [
+ ['win', 'line'],
+ ['chip', 'line'],
+ ['btn', 'line'],
+ ['btn-m', 'danger-line'],
+ ['row-on', 'line'],
+ ['tool-on', 'accent-line']
+ ];
+
+ // alphaTab writes its colors as attributes; attribute selectors retheme them without rewriting the markup.
+ const notationColors: [string, string][] = [
+ [resources.mainGlyphColor.rgba, 'n-ink'],
+ [resources.scoreInfoColor.rgba, 'n-ink'],
+ [resources.staffLineColor.rgba, 'n-staff'],
+ [resources.barSeparatorColor.rgba, 'n-bar'],
+ [resources.barNumberColor.rgba, 'n-num'],
+ [resources.secondaryGlyphColor.rgba, 'n-soft']
+ ];
+
+ return [
+ `svg{${vars(light)}}`,
+ `@media (prefers-color-scheme:dark){svg{${vars(dark)}}}`,
+ ...fills.map(([c, v]) => `.${c}{fill:var(--${v})}`),
+ ...strokes.map(([c, v]) => `.${c}{stroke:var(--${v})}`),
+ '.bracket{fill:none;stroke:var(--accent);stroke-width:1.5}',
+ '.icon,.icon-on,.icon-inv{fill:none;stroke-linecap:round;stroke-linejoin:round}',
+ '.icon{stroke:var(--soft)}.icon-on{stroke:var(--accent)}.icon-inv{stroke:var(--inv)}',
+ '.shadow{flood-color:var(--shadow)}',
+ '.notation{fill:var(--n-ink)}',
+ ...notationColors.flatMap(([color, v]) => [
+ `.notation [fill="${color}"]{fill:var(--${v})}`,
+ `.notation [stroke="${color}"]{stroke:var(--${v})}`
+ ]),
+ '.notation .hl *{fill:var(--accent)}.notation .hl [stroke]{stroke:var(--accent)}'
+ ].join('');
+}
+
+// --- main -------------------------------------------------------------------------------------------------------------
+
+const out: string[] = [];
+const timing = computeTiming(score);
+const audio = decodeBackingTrack(score, timing.duration);
+const notation = renderNotation();
+
+const svg = compose();
+fs.mkdirSync(path.dirname(path.resolve(args.out!)), { recursive: true });
+fs.writeFileSync(path.resolve(args.out!), svg);
+console.log(`Wrote ${args.out} (${(svg.length / 1024).toFixed(1)} KiB)`);
diff --git a/scripts/readme-hero/opentype.d.ts b/scripts/readme-hero/opentype.d.ts
new file mode 100644
index 000000000..72f80b6df
--- /dev/null
+++ b/scripts/readme-hero/opentype.d.ts
@@ -0,0 +1,34 @@
+/** The subset of opentype.js 2 used by the hero generator; the package ships without typings. */
+declare module 'opentype.js' {
+ export interface BoundingBox {
+ x1: number;
+ y1: number;
+ x2: number;
+ y2: number;
+ }
+
+ export interface Path {
+ toPathData(options: { decimalPlaces?: number; flipY?: boolean }): string;
+ }
+
+ export interface Glyph {
+ index: number;
+ advanceWidth?: number;
+ getBoundingBox(): BoundingBox;
+ getPath(x: number, y: number, fontSize: number): Path;
+ }
+
+ export interface Font {
+ unitsPerEm: number;
+ ascender: number;
+ descender: number;
+ tables: { os2?: { sxHeight?: number } };
+ stringToGlyphs(text: string): Glyph[];
+ getKerningValue(left: Glyph, right: Glyph): number;
+ }
+
+ const opentype: {
+ parse(buffer: ArrayBuffer): Font;
+ };
+ export default opentype;
+}
diff --git a/scripts/readme-hero/outline-canvas.mts b/scripts/readme-hero/outline-canvas.mts
new file mode 100644
index 000000000..2372c9ad1
--- /dev/null
+++ b/scripts/readme-hero/outline-canvas.mts
@@ -0,0 +1,282 @@
+///
+import fs from 'node:fs';
+import path from 'node:path';
+import type { platform } from '@coderline/alphatab';
+import opentype, { type Font, type Glyph } from 'opentype.js';
+
+type AlphaTab = typeof import('@coderline/alphatab');
+
+/** The members of alphaTab's `SvgCanvas` used here; the class is exported at runtime but not part of the typings. */
+interface SvgCanvasBase extends platform.ICanvas {
+ buffer: string;
+ scale: number;
+}
+
+const fontDir = path.resolve(import.meta.dirname, '../../packages/alphatab/font');
+
+/**
+ * Text aligned against a font: `x`/`y` is the anchor, the run starts left of it for centered or right-aligned text.
+ */
+export interface TextRun {
+ text: string;
+ x: number;
+ y: number;
+ size: number;
+ face: FontFace;
+ align?: 'start' | 'middle' | 'end';
+ letterSpacing?: number;
+ attributes?: string;
+}
+
+export class FontFace {
+ public readonly id: string;
+ public readonly font: Font;
+
+ public constructor(id: string, file: string) {
+ this.id = id;
+ const buffer = fs.readFileSync(path.join(fontDir, file));
+ this.font = opentype.parse(buffer.buffer.slice(buffer.byteOffset, buffer.byteOffset + buffer.byteLength));
+ }
+
+ public get unitsPerEm(): number {
+ return this.font.unitsPerEm;
+ }
+
+ /** Font-unit offsets of each glyph in `text`, and the total advance in font units. */
+ public layout(text: string, letterSpacing = 0): { glyphs: { glyph: Glyph; x: number }[]; advance: number } {
+ const glyphs = this.font.stringToGlyphs(text);
+ const spacing = letterSpacing * this.unitsPerEm;
+ const placed: { glyph: Glyph; x: number }[] = [];
+ let x = 0;
+ for (let i = 0; i < glyphs.length; i++) {
+ const glyph = glyphs[i];
+ placed.push({ glyph, x });
+ x += (glyph.advanceWidth ?? 0) + spacing;
+ if (i + 1 < glyphs.length) {
+ x += this.font.getKerningValue(glyph, glyphs[i + 1]);
+ }
+ }
+ if (glyphs.length > 0) {
+ x -= spacing;
+ }
+ return { glyphs: placed, advance: x };
+ }
+
+ public measure(text: string, size: number, letterSpacing = 0): number {
+ return (this.layout(text, letterSpacing).advance * size) / this.unitsPerEm;
+ }
+
+ /** Ink height of `text` at `size`, matching what canvas `actualBoundingBox*` reports. */
+ public inkHeight(text: string, size: number): number {
+ let top = 0;
+ let bottom = 0;
+ for (const glyph of this.font.stringToGlyphs(text)) {
+ const box = glyph.getBoundingBox();
+ top = Math.max(top, box.y2);
+ bottom = Math.min(bottom, box.y1);
+ }
+ return ((top - bottom) * size) / this.unitsPerEm;
+ }
+
+ public get ascender(): number {
+ return this.font.ascender / this.unitsPerEm;
+ }
+
+ public get descender(): number {
+ return this.font.descender / this.unitsPerEm;
+ }
+
+ public get xHeight(): number {
+ return (this.font.tables.os2?.sxHeight ?? 500) / this.unitsPerEm;
+ }
+}
+
+/**
+ * Collects every glyph outline used in the image once, so text renders as `` references into shared ``.
+ */
+export class GlyphLibrary {
+ private readonly _paths = new Map();
+
+ public readonly sans = new FontFace('s', 'noto-sans/NotoSans-Regular.otf');
+ public readonly sansBold = new FontFace('sb', 'noto-sans/NotoSans-Bold.otf');
+ public readonly sansItalic = new FontFace('si', 'noto-sans/NotoSans-Italic.otf');
+ public readonly sansBoldItalic = new FontFace('sbi', 'noto-sans/NotoSans-BoldItalic.otf');
+ public readonly serif = new FontFace('r', 'noto-serif/NotoSerif-Regular.otf');
+ public readonly serifBold = new FontFace('rb', 'noto-serif/NotoSerif-Bold.otf');
+ public readonly serifItalic = new FontFace('ri', 'noto-serif/NotoSerif-Italic.otf');
+ public readonly serifBoldItalic = new FontFace('rbi', 'noto-serif/NotoSerif-BoldItalic.otf');
+ public readonly music = new FontFace('m', 'bravura/Bravura.otf');
+
+ private static readonly _serifFamilies = new Set(['georgia', 'times', 'times new roman', 'serif', 'noto serif']);
+
+ /** Maps CSS font families to the bundled Noto faces; unknown families fall back to Noto Sans. */
+ public resolve(families: string[], bold: boolean, italic: boolean): FontFace {
+ const serif = families.some(f => GlyphLibrary._serifFamilies.has(f.replace(/["']/g, '').trim().toLowerCase()));
+ if (serif) {
+ return bold ? (italic ? this.serifBoldItalic : this.serifBold) : italic ? this.serifItalic : this.serif;
+ }
+ return bold ? (italic ? this.sansBoldItalic : this.sansBold) : italic ? this.sansItalic : this.sans;
+ }
+
+ private _ref(face: FontFace, glyph: Glyph): string | undefined {
+ const id = `${face.id}${glyph.index}`;
+ if (!this._paths.has(id)) {
+ const d = glyph.getPath(0, 0, face.unitsPerEm).toPathData({ decimalPlaces: 0, flipY: false });
+ if (!d) {
+ return undefined;
+ }
+ this._paths.set(id, d);
+ }
+ return id;
+ }
+
+ /** SVG markup drawing `run` as glyph references; returns the markup and the run width. */
+ public text(run: TextRun): { svg: string; width: number } {
+ const { glyphs, advance } = run.face.layout(run.text, run.letterSpacing ?? 0);
+ const scale = run.size / run.face.unitsPerEm;
+ let startX = run.x;
+ if (run.align === 'middle') {
+ startX -= (advance * scale) / 2;
+ } else if (run.align === 'end') {
+ startX -= advance * scale;
+ }
+ let uses = '';
+ for (const { glyph, x } of glyphs) {
+ const id = this._ref(run.face, glyph);
+ if (id) {
+ uses += x === 0 ? ` ` : ` `;
+ }
+ }
+ const svg = uses
+ ? `${uses} `
+ : '';
+ return { svg, width: advance * scale };
+ }
+
+ public defs(): string {
+ let s = '';
+ for (const [id, d] of this._paths) {
+ s += ` `;
+ }
+ return s;
+ }
+}
+
+export function num(v: number, digits = 2): string {
+ return String(Number(v.toFixed(digits)));
+}
+
+/**
+ * Registers an alphaTab render engine named `engine` whose SVG output draws all text and music symbols as outlines
+ * from `glyphs`, and measures text with the same fonts so the layout matches the outlines. Text for which `omitText`
+ * returns true is laid out but not drawn.
+ */
+export function registerOutlineEngine(
+ alphaTab: AlphaTab,
+ glyphs: GlyphLibrary,
+ engine: string,
+ omitText: (text: string) => boolean = () => false
+) {
+ const { TextAlign, TextBaseline } = alphaTab.platform;
+ const { FontStyle, FontWeight } = alphaTab.model;
+
+ const SvgCanvas = (alphaTab.platform as unknown as { SvgCanvas: abstract new () => SvgCanvasBase }).SvgCanvas;
+
+ class OutlineSvgCanvas extends SvgCanvas {
+ private _face(): FontFace {
+ return glyphs.resolve(
+ this.font.families,
+ this.font.weight === FontWeight.Bold,
+ this.font.style === FontStyle.Italic
+ );
+ }
+
+ private _fill(): string {
+ return this.color.rgba === '#000000' ? '' : `fill="${this.color.rgba}"`;
+ }
+
+ private _append(markup: string) {
+ this.buffer += markup;
+ }
+
+ public override fillText(text: string, x: number, y: number): void {
+ if (!text || omitText(text)) {
+ return;
+ }
+ const face = this._face();
+ const size = this.font.size * this.scale;
+ let baseline = y * this.scale;
+ switch (this.textBaseline) {
+ case TextBaseline.Top:
+ // Matches Chromium's `hanging` baseline for fonts without a BASE table: 80% of the ascent.
+ baseline += face.ascender * 0.8 * size;
+ break;
+ case TextBaseline.Middle:
+ baseline += (face.xHeight / 2) * size;
+ break;
+ case TextBaseline.Bottom:
+ baseline += face.descender * size;
+ break;
+ }
+ const align =
+ this.textAlign === TextAlign.Center ? 'middle' : this.textAlign === TextAlign.Right ? 'end' : 'start';
+ this._append(
+ glyphs.text({ text, x: x * this.scale, y: baseline, size, face, align, attributes: this._fill() }).svg
+ );
+ }
+
+ public override measureText(text: string) {
+ if (!text) {
+ return new alphaTab.platform.MeasuredText(0, 0);
+ }
+ const face = this._face();
+ return new alphaTab.platform.MeasuredText(
+ face.measure(text, this.font.size),
+ face.inkHeight(text, this.font.size)
+ );
+ }
+
+ public fillMusicFontSymbol(
+ x: number,
+ y: number,
+ relativeScale: number,
+ symbol: number,
+ centerAtPosition?: boolean
+ ): void {
+ this.fillMusicFontSymbols(x, y, relativeScale, [symbol], centerAtPosition);
+ }
+
+ public fillMusicFontSymbols(
+ x: number,
+ y: number,
+ relativeScale: number,
+ symbols: number[],
+ centerAtPosition?: boolean
+ ): void {
+ const text = symbols
+ .filter(s => s !== alphaTab.model.MusicFontSymbol.None)
+ .map(s => String.fromCodePoint(s))
+ .join('');
+ if (!text) {
+ return;
+ }
+ const size = this.settings.display.resources.engravingSettings.musicFontSize * this.scale * relativeScale;
+ this._append(
+ glyphs.text({
+ text,
+ x: x * this.scale,
+ y: y * this.scale,
+ size,
+ face: glyphs.music,
+ align: centerAtPosition ? 'middle' : 'start',
+ attributes: this._fill()
+ }).svg
+ );
+ }
+ }
+
+ alphaTab.Environment.renderEngines.set(
+ engine,
+ new alphaTab.RenderEngineFactory(false, () => new OutlineSvgCanvas())
+ );
+}