diff --git a/package-lock.json b/package-lock.json index afc1c85b0..a33716033 100644 --- a/package-lock.json +++ b/package-lock.json @@ -231,6 +231,10 @@ "resolved": "packages/alphatex", "link": true }, + "node_modules/@coderline/alphatab-bench": { + "resolved": "packages/bench", + "link": true + }, "node_modules/@coderline/alphatab-csharp": { "resolved": "packages/csharp", "link": true @@ -5544,6 +5548,35 @@ "node": ">=14.17" } }, + "packages/bench": { + "name": "@coderline/alphatab-bench", + "version": "1.9.0", + "license": "MPL-2.0", + "dependencies": { + "@coderline/alphatab": "*" + }, + "devDependencies": { + "@types/node": "^25.9.1", + "rimraf": "^6.1.3", + "tslib": "^2.8.1", + "typescript": "^6.0.3", + "vite": "^8.0.14" + } + }, + "packages/bench/node_modules/typescript": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", + "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, "packages/csharp": { "name": "@coderline/alphatab-csharp", "version": "1.9.0", diff --git a/packages/alphatab/src/platform/svg/CssFontSvgCanvas.ts b/packages/alphatab/src/platform/svg/CssFontSvgCanvas.ts index cd254be76..add8287ca 100644 --- a/packages/alphatab/src/platform/svg/CssFontSvgCanvas.ts +++ b/packages/alphatab/src/platform/svg/CssFontSvgCanvas.ts @@ -1,6 +1,5 @@ -import { TextAlign } from '@coderline/alphatab/platform/ICanvas'; -import { SvgCanvas } from '@coderline/alphatab/platform/svg/SvgCanvas'; import { MusicFontSymbol } from '@coderline/alphatab/model/MusicFontSymbol'; +import { SvgCanvas } from '@coderline/alphatab/platform/svg/SvgCanvas'; /** * This SVG canvas renders the music symbols by adding a CSS class 'at' to all elements. @@ -43,22 +42,26 @@ export class CssFontSvgCanvas extends SvgCanvas { symbols: string, centerAtPosition?: boolean ): void { - x *= this.scale; - y *= this.scale; - - this.buffer += `${symbols}`; + this.buffer += '>' + symbols + ''; } } diff --git a/packages/alphatab/src/platform/svg/SvgCanvas.ts b/packages/alphatab/src/platform/svg/SvgCanvas.ts index 7932791b9..7f28d723c 100644 --- a/packages/alphatab/src/platform/svg/SvgCanvas.ts +++ b/packages/alphatab/src/platform/svg/SvgCanvas.ts @@ -37,7 +37,7 @@ export abstract class SvgCanvas implements ICanvas { } public beginGroup(identifier: string): void { - this.buffer += ``; + this.buffer += ''; } public endGroup(): void { @@ -51,9 +51,24 @@ export abstract class SvgCanvas implements ICanvas { public fillRect(x: number, y: number, w: number, h: number): void { if (w > 0) { - this.buffer += `\n`; + // scale=1 fast path: skip the 4 multiplies and use `+` concat to avoid template-literal overhead. + const s = this.scale; + if (s === 1) { + this.buffer += + '\n'; + } else { + this.buffer += `\n`; + } } } @@ -77,12 +92,22 @@ export abstract class SvgCanvas implements ICanvas { } public moveTo(x: number, y: number): void { - this._currentPath += ` M${x * this.scale},${y * this.scale}`; + const s = this.scale; + if (s === 1) { + this._currentPath += ' M' + x + ',' + y; + } else { + this._currentPath += ` M${x * s},${y * s}`; + } } public lineTo(x: number, y: number): void { this._currentPathIsEmpty = false; - this._currentPath += ` L${x * this.scale},${y * this.scale}`; + const s = this.scale; + if (s === 1) { + this._currentPath += ' L' + x + ',' + y; + } else { + this._currentPath += ` L${x * s},${y * s}`; + } } public quadraticCurveTo(cpx: number, cpy: number, x: number, y: number): void { @@ -92,9 +117,13 @@ export abstract class SvgCanvas implements ICanvas { public bezierCurveTo(cp1X: number, cp1Y: number, cp2X: number, cp2Y: number, x: number, y: number): void { this._currentPathIsEmpty = false; - this._currentPath += ` C${cp1X * this.scale},${cp1Y * this.scale},${cp2X * this.scale},${cp2Y * this.scale},${ - x * this.scale - },${y * this.scale}`; + const s = this.scale; + if (s === 1) { + this._currentPath += + ' C' + cp1X + ',' + cp1Y + ',' + cp2X + ',' + cp2Y + ',' + x + ',' + y; + } else { + this._currentPath += ` C${cp1X * s},${cp1Y * s},${cp2X * s},${cp2Y * s},${x * s},${y * s}`; + } } public fillCircle(x: number, y: number, radius: number): void { @@ -125,9 +154,9 @@ export abstract class SvgCanvas implements ICanvas { public fill(): void { if (!this._currentPathIsEmpty) { - this.buffer += `"']/; + private static _escapeText(text: string) { + // Short-circuit: most rendered text (bar numbers, fret numbers, etc.) has no escapable chars. + if (!SvgCanvas._escapeTextRegex.test(text)) { + return text; + } return text .replace(/&/g, '&') .replace(/"/g, '"') diff --git a/packages/alphatab/src/profiling/Profiler.ts b/packages/alphatab/src/profiling/Profiler.ts new file mode 100644 index 000000000..d0113b500 --- /dev/null +++ b/packages/alphatab/src/profiling/Profiler.ts @@ -0,0 +1,77 @@ +/** + * Profiling instrumentation. Call sites are stripped from production / + * library / vitest / playground builds by `stripProfilingPlugin` and only + * retained in the bench harness. + */ + +export interface StageStats { + count: number; + totalNs: number; + maxNs: number; +} + +export interface ProfilerSnapshot { + stages: Record; + counters: Record; +} + +const STACK_LIMIT = 64; + +export class Profiler { + private static readonly _stages = new Map(); + private static readonly _counters = new Map(); + private static readonly _stack: { name: string; startNs: number }[] = []; + + static begin(name: string): void { + if (Profiler._stack.length >= STACK_LIMIT) { + throw new Error(`Profiler stack overflow on '${name}'`); + } + Profiler._stack.push({ name, startNs: nowNs() }); + } + + static end(name: string): void { + const top = Profiler._stack.pop(); + if (!top || top.name !== name) { + throw new Error( + `Profiler.end('${name}') mismatched; expected '${top?.name ?? ''}'` + ); + } + const elapsed = nowNs() - top.startNs; + const stats = Profiler._stages.get(name); + if (stats === undefined) { + Profiler._stages.set(name, { count: 1, totalNs: elapsed, maxNs: elapsed }); + } else { + stats.count++; + stats.totalNs += elapsed; + if (elapsed > stats.maxNs) { + stats.maxNs = elapsed; + } + } + } + + static bump(name: string, delta: number = 1): void { + Profiler._counters.set(name, (Profiler._counters.get(name) ?? 0) + delta); + } + + static snapshot(): ProfilerSnapshot { + const stages: Record = {}; + for (const [name, stats] of Profiler._stages) { + stages[name] = { count: stats.count, totalNs: stats.totalNs, maxNs: stats.maxNs }; + } + const counters: Record = {}; + for (const [name, value] of Profiler._counters) { + counters[name] = value; + } + return { stages, counters }; + } + + static reset(): void { + Profiler._stages.clear(); + Profiler._counters.clear(); + Profiler._stack.length = 0; + } +} + +function nowNs(): number { + return Math.round(performance.now() * 1_000_000); +} diff --git a/packages/alphatab/src/rendering/BarRendererBase.ts b/packages/alphatab/src/rendering/BarRendererBase.ts index 3dcb7987d..3d3a240ef 100644 --- a/packages/alphatab/src/rendering/BarRendererBase.ts +++ b/packages/alphatab/src/rendering/BarRendererBase.ts @@ -439,17 +439,28 @@ export class BarRendererBase { return !this.bar || this.bar.index === this.scoreRenderer.layout!.lastBarIndex; } + /** + * Gates the voice-container walk in {@link _registerLayoutingInfo}. + * Broker outputs from the walk are bar-local invariant; only the + * pre/post-beat `max` writes need to run each resize cycle (the broker + * zeroes `preBeatSize` at the head of every resize). + */ + private _voiceWalkDone: boolean = false; + public _registerLayoutingInfo(): void { const info: BarLayoutingInfo = this.layoutingInfo; const preSize: number = this._preBeatGlyphs.width; if (info.preBeatSize < preSize) { info.preBeatSize = preSize; } - const container = this.voiceContainer; - container.registerLayoutingInfo(info); + if (!this._voiceWalkDone) { + const container = this.voiceContainer; + container.registerLayoutingInfo(info); - this.topEffects.registerLayoutingInfo(info); - this.bottomEffects.registerLayoutingInfo(info); + this.topEffects.registerLayoutingInfo(info); + this.bottomEffects.registerLayoutingInfo(info); + this._voiceWalkDone = true; + } const postSize: number = this._postBeatGlyphs.width; if (info.postBeatSize < postSize) { @@ -461,6 +472,9 @@ export class BarRendererBase { this.staff = undefined; this.registerMultiSystemSlurs(undefined); this.isFinalized = false; + // `_layoutInvariantCached` and `_voiceWalkDone` deliberately survive: + // they cache bar-local invariant state and `afterReverted` fires every + // resize cycle — invalidating here would defeat the optimisation. } public afterStaffBarReverted() { @@ -498,6 +512,18 @@ export class BarRendererBase { public isFinalized: boolean = false; + /** + * Set once {@link doLayout} has populated the bar-local invariant state + * (`_preBeatGlyphs.width`, `_postBeatGlyphs.width`, broker per-beat sizes, + * local pre/post-beat skylines). Lets {@link reLayout} skip the bar-local + * re-walk on width-only changes. + */ + private _layoutInvariantCached: boolean = false; + + public invalidateLayoutCache(): void { + this._layoutInvariantCached = false; + } + public registerMultiSystemSlurs(startedTies: Generator | undefined) { if (!startedTies) { this._multiSystemSlurs = undefined; @@ -638,6 +664,8 @@ export class BarRendererBase { this.computedWidth = this.width; this.calculateOverflows(0, this.height); + + this._layoutInvariantCached = true; } protected calculateOverflows(_rendererTop: number, rendererBottom: number) { @@ -882,11 +910,17 @@ export class BarRendererBase { this.recreatePreBeatGlyphs(); } + // Must always re-register: the broker zeroes `preBeatSize` at the head of every resize cycle. this._registerLayoutingInfo(); - this.calculateOverflows(0, this.height); + if (!this._layoutInvariantCached) { + this.calculateOverflows(0, this.height); + this._layoutInvariantCached = true; + } } protected recreatePreBeatGlyphs() { + // Pre-beat composition is changing — invalidate cached bar-local state. + this._layoutInvariantCached = false; this._preBeatGlyphs = new LeftToRightLayoutingGlyphGroup(); this._preBeatGlyphs.renderer = this; this.createPreBeatGlyphs(); diff --git a/packages/alphatab/src/rendering/ScoreRenderer.ts b/packages/alphatab/src/rendering/ScoreRenderer.ts index 5c33e3e11..edd57b69d 100644 --- a/packages/alphatab/src/rendering/ScoreRenderer.ts +++ b/packages/alphatab/src/rendering/ScoreRenderer.ts @@ -1,20 +1,21 @@ -import { LayoutMode } from '@coderline/alphatab/LayoutMode'; import { Environment } from '@coderline/alphatab/Environment'; import { EventEmitter, + EventEmitterOfT, type IEventEmitter, - type IEventEmitterOfT, - EventEmitterOfT + type IEventEmitterOfT } from '@coderline/alphatab/EventEmitter'; +import { LayoutMode } from '@coderline/alphatab/LayoutMode'; +import { Logger } from '@coderline/alphatab/Logger'; import type { Score } from '@coderline/alphatab/model/Score'; import type { Track } from '@coderline/alphatab/model/Track'; import type { ICanvas } from '@coderline/alphatab/platform/ICanvas'; +import { Profiler } from '@coderline/alphatab/profiling/Profiler'; import type { IScoreRenderer, RenderHints } from '@coderline/alphatab/rendering/IScoreRenderer'; import type { ScoreLayout } from '@coderline/alphatab/rendering/layout/ScoreLayout'; import { RenderFinishedEventArgs } from '@coderline/alphatab/rendering/RenderFinishedEventArgs'; import { BoundsLookup } from '@coderline/alphatab/rendering/utils/BoundsLookup'; import type { Settings } from '@coderline/alphatab/Settings'; -import { Logger } from '@coderline/alphatab/Logger'; /** * This is the main wrapper of the rendering engine which @@ -136,8 +137,10 @@ export class ScoreRenderer implements IScoreRenderer { } public render(renderHints?: RenderHints): void { + Profiler.begin('render.total'); if (this.width === 0) { Logger.warning('Rendering', 'AlphaTab skipped rendering because of width=0 (element invisible)', null); + Profiler.end('render.total'); return; } // For partial renders we preserve the existing lookup so bars outside the re-layouted @@ -170,9 +173,11 @@ export class ScoreRenderer implements IScoreRenderer { this._layoutAndRender(renderHints); Logger.debug('Rendering', 'Rendering finished'); } + Profiler.end('render.total'); } public resizeRender(): void { + Profiler.begin('resize.total'); if (this._recreateLayout() || this._recreateCanvas() || this._renderedTracks !== this.tracks || !this.tracks) { Logger.debug('Rendering', 'Starting full rerendering due to layout or canvas change', null); this.render(); @@ -181,13 +186,16 @@ export class ScoreRenderer implements IScoreRenderer { this.boundsLookup = new BoundsLookup(); (this.preRender as EventEmitterOfT).trigger(true); this.canvas!.settings = this.settings; + Profiler.begin('resize.layoutResize'); this.layout!.resize(); + Profiler.end('resize.layoutResize'); this._onRenderFinished(); (this.postRenderFinished as EventEmitter).trigger(); } else { Logger.debug('Rendering', 'Current layout does not support dynamic resizing, nothing was done', null); } Logger.debug('Rendering', 'Resize finished'); + Profiler.end('resize.total'); } private _layoutAndRender(renderHints?: RenderHints): void { @@ -196,7 +204,9 @@ export class ScoreRenderer implements IScoreRenderer { `Rendering at scale ${this.settings.display.scale} with layout ${this.layout!.name}`, null ); + Profiler.begin('render.layoutAndRender'); this.layout!.layoutAndRender(renderHints); + Profiler.end('render.layoutAndRender'); this._renderedTracks = this.tracks; this._onRenderFinished(); (this.postRenderFinished as EventEmitter).trigger(); diff --git a/packages/alphatab/src/rendering/TabBarRenderer.ts b/packages/alphatab/src/rendering/TabBarRenderer.ts index a08f30a37..e420ed510 100644 --- a/packages/alphatab/src/rendering/TabBarRenderer.ts +++ b/packages/alphatab/src/rendering/TabBarRenderer.ts @@ -39,6 +39,18 @@ export class TabBarRenderer extends LineBarRenderer { private _showMultiBarRest: boolean = false; + /** + * Layout-time staff-line gap cache, bucketed by string-line index. + * `_gapBucketEnd[i]` is the exclusive end-offset into the parallel + * `_gapRelXAndWidth` / `_gapBeatRefs` arrays for line `i`. Stores the + * width-invariant payload; absolute x is projected at paint time via + * `beatGlyphsStart + bg.x + relativeX`. + */ + private _gapBucketEnd: Uint32Array | null = null; + private _gapRelXAndWidth: Float32Array | null = null; + private _gapBeatRefs: BeatContainerGlyphBase[] | null = null; + private _gapCount: number = 0; + public override get showMultiBarRest(): boolean { return this._showMultiBarRest; } @@ -86,13 +98,147 @@ export class TabBarRenderer extends LineBarRenderer { public minString = Number.NaN; public maxString = Number.NaN; + /** + * Legacy adapter projecting the gap cache into the base class + * `spaces[][]` shape. The real consumer is {@link paintStaffLines} + * which reads the cache directly. + */ protected override collectSpaces(spaces: Float32Array[][]): void { if (this.additionalMultiRestBars) { return; } + if (this._gapBucketEnd === null) { + this._buildGapCache(); + } + const count = this._gapCount; + if (count === 0) { + return; + } + const bucketEnd = this._gapBucketEnd!; + const relXAndWidth = this._gapRelXAndWidth!; + const beatRefs = this._gapBeatRefs!; + const base = this.beatGlyphsStart; + let line = 0; + for (let i = 0; i < count; i++) { + while (i >= bucketEnd[line]) { + line++; + } + const absX = base + beatRefs[i].x + relXAndWidth[i * 2]; + spaces[line].push(new Float32Array([absX, relXAndWidth[i * 2 + 1]])); + } + } - const padding: number = this.smuflMetrics.staffLineThickness; + protected override paintStaffLines(cx: number, cy: number, canvas: ICanvas): void { + using _ = ElementStyleHelper.bar(canvas, this.staffLineBarSubElement, this.bar, true); + + const lineWidth = this.width; + const lineYOffset = this.smuflMetrics.staffLineThickness / 2; + const thickness = this.smuflMetrics.staffLineThickness; + const drawnLineCount = this.drawnLineCount; + const cxLocal = cx + this.x; + const cyLocal = cy + this.y; + + if (this.additionalMultiRestBars) { + for (let line = 0; line < drawnLineCount; line++) { + const lineY = this.getLineY(line) - lineYOffset; + canvas.fillRect(cxLocal, cyLocal + lineY, lineWidth, thickness); + } + return; + } + + if (this._gapBucketEnd === null) { + this._buildGapCache(); + } + + const count = this._gapCount; + if (count === 0) { + for (let line = 0; line < drawnLineCount; line++) { + const lineY = this.getLineY(line) - lineYOffset; + canvas.fillRect(cxLocal, cyLocal + lineY, lineWidth, thickness); + } + return; + } + + const bucketEnd = this._gapBucketEnd!; + const relXAndWidth = this._gapRelXAndWidth!; + const beatRefs = this._gapBeatRefs!; + const base = this.beatGlyphsStart; + + let cursor = 0; + for (let line = 0; line < drawnLineCount; line++) { + const lineY = this.getLineY(line) - lineYOffset; + const cyLine = cyLocal + lineY; + const end = bucketEnd[line]; + + // Gaps were built in beat-iteration order, which is left-to-right + // except for grace-note edge cases — sort only on inversion. + let lineX = 0; + if (end > cursor + 1) { + let inverted = false; + let prevX = base + beatRefs[cursor].x + relXAndWidth[cursor * 2]; + for (let k = cursor + 1; k < end; k++) { + const xk = base + beatRefs[k].x + relXAndWidth[k * 2]; + if (xk < prevX) { + inverted = true; + break; + } + prevX = xk; + } + if (inverted) { + const segCount = end - cursor; + const xs = new Float32Array(segCount); + const ws = new Float32Array(segCount); + for (let k = 0; k < segCount; k++) { + const ki = cursor + k; + xs[k] = base + beatRefs[ki].x + relXAndWidth[ki * 2]; + ws[k] = relXAndWidth[ki * 2 + 1]; + } + const order = new Uint32Array(segCount); + for (let k = 0; k < segCount; k++) { + order[k] = k; + } + for (let k = 1; k < segCount; k++) { + const cur = order[k]; + const curX = xs[cur]; + let m = k - 1; + while (m >= 0 && xs[order[m]] > curX) { + order[m + 1] = order[m]; + m--; + } + order[m + 1] = cur; + } + for (let k = 0; k < segCount; k++) { + const oi = order[k]; + const gx = xs[oi]; + const gw = ws[oi]; + canvas.fillRect(cxLocal + lineX, cyLine, gx - lineX, thickness); + lineX = gx + gw; + } + canvas.fillRect(cxLocal + lineX, cyLine, lineWidth - lineX, thickness); + cursor = end; + continue; + } + } + + for (let k = cursor; k < end; k++) { + const gx = base + beatRefs[k].x + relXAndWidth[k * 2]; + const gw = relXAndWidth[k * 2 + 1]; + canvas.fillRect(cxLocal + lineX, cyLine, gx - lineX, thickness); + lineX = gx + gw; + } + canvas.fillRect(cxLocal + lineX, cyLine, lineWidth - lineX, thickness); + cursor = end; + } + } + + private _buildGapCache(): void { + const drawnLineCount = this.drawnLineCount; const tuning = this.bar.staff.tuning; + const tuningLen = tuning.length; + + // First pass: count gaps per string-line for exact typed-array sizing. + const bucketCount = new Uint32Array(drawnLineCount); + let total = 0; for (const voice of this.voiceContainer.beatGlyphs.values()) { for (const bg of voice) { const notes: TabBeatGlyph = (bg as TabBeatContainerGlyph).onNotes as TabBeatGlyph; @@ -100,17 +246,81 @@ export class TabBarRenderer extends LineBarRenderer { if (noteNumbers) { for (const [str, noteNumber] of noteNumbers.notesPerString) { if (!noteNumber.isEmpty) { - spaces[tuning.length - str].push( - new Float32Array([ - this.beatGlyphsStart + bg.x + notes.x + noteNumbers!.x - padding, - noteNumbers!.width + padding * 2 - ]) - ); + const lineIdx = tuningLen - str; + if (lineIdx >= 0 && lineIdx < drawnLineCount) { + bucketCount[lineIdx]++; + total++; + } } } } } } + + this._gapCount = total; + if (total === 0) { + this._gapBucketEnd = new Uint32Array(drawnLineCount); + this._gapRelXAndWidth = new Float32Array(0); + this._gapBeatRefs = []; + return; + } + + // CSR-style prefix sum to exclusive end-offsets, with parallel write cursors. + const bucketEnd = new Uint32Array(drawnLineCount); + const fwdCursor = new Uint32Array(drawnLineCount); + let running = 0; + for (let line = 0; line < drawnLineCount; line++) { + fwdCursor[line] = running; + running += bucketCount[line]; + bucketEnd[line] = running; + } + + const relXAndWidth = new Float32Array(total * 2); + const beatRefs: BeatContainerGlyphBase[] = new Array(total); + const padding: number = this.smuflMetrics.staffLineThickness; + + for (const voice of this.voiceContainer.beatGlyphs.values()) { + for (const bg of voice) { + const notes: TabBeatGlyph = (bg as TabBeatContainerGlyph).onNotes as TabBeatGlyph; + const noteNumbers: TabNoteChordGlyph | null = notes.noteNumbers; + if (noteNumbers) { + const relativeXBase = notes.x + noteNumbers.x - padding; + const fullWidth = noteNumbers.width + padding * 2; + for (const [str, noteNumber] of noteNumbers.notesPerString) { + if (!noteNumber.isEmpty) { + const lineIdx = tuningLen - str; + if (lineIdx >= 0 && lineIdx < drawnLineCount) { + const idx = fwdCursor[lineIdx]++; + relXAndWidth[idx * 2] = relativeXBase; + relXAndWidth[idx * 2 + 1] = fullWidth; + beatRefs[idx] = bg; + } + } + } + } + } + } + + this._gapBucketEnd = bucketEnd; + this._gapRelXAndWidth = relXAndWidth; + this._gapBeatRefs = beatRefs; + } + + private _invalidateGapCache(): void { + this._gapBucketEnd = null; + this._gapRelXAndWidth = null; + this._gapBeatRefs = null; + this._gapCount = 0; + } + + public override invalidateLayoutCache(): void { + super.invalidateLayoutCache(); + this._invalidateGapCache(); + } + + protected override recreatePreBeatGlyphs(): void { + super.recreatePreBeatGlyphs(); + this._invalidateGapCache(); } public override doLayout(): void { diff --git a/packages/alphatab/src/rendering/layout/ScoreLayout.ts b/packages/alphatab/src/rendering/layout/ScoreLayout.ts index 3828d17f8..352b8eb29 100644 --- a/packages/alphatab/src/rendering/layout/ScoreLayout.ts +++ b/packages/alphatab/src/rendering/layout/ScoreLayout.ts @@ -9,6 +9,7 @@ import type { Staff } from '@coderline/alphatab/model/Staff'; import { type Track, TrackSubElement } from '@coderline/alphatab/model/Track'; import { NotationElement } from '@coderline/alphatab/NotationSettings'; import { type ICanvas, TextAlign, TextBaseline } from '@coderline/alphatab/platform/ICanvas'; +import { Profiler } from '@coderline/alphatab/profiling/Profiler'; import type { RenderingResources } from '@coderline/alphatab/RenderingResources'; import { BarRendererBase } from '@coderline/alphatab/rendering/BarRendererBase'; import { type EffectBandInfo, EffectBandMode } from '@coderline/alphatab/rendering/BarRendererFactory'; @@ -80,7 +81,9 @@ export abstract class ScoreLayout { public resize(): void { this._lazyPartials.clear(); this.slurRegistry.clear(); + Profiler.begin('layout.doResize'); this.doResize(); + Profiler.end('layout.doResize'); } public abstract doResize(): void; @@ -123,7 +126,9 @@ export abstract class ScoreLayout { } this._createScoreInfoGlyphs(); + Profiler.begin('layout.doLayoutAndRender'); this.doLayoutAndRender(renderHints); + Profiler.end('layout.doLayoutAndRender'); } private _lazyPartials: Map = new Map(); diff --git a/packages/alphatab/src/rendering/skyline/Skyline.ts b/packages/alphatab/src/rendering/skyline/Skyline.ts index 8194de9b1..9739e3d16 100644 --- a/packages/alphatab/src/rendering/skyline/Skyline.ts +++ b/packages/alphatab/src/rendering/skyline/Skyline.ts @@ -205,6 +205,218 @@ export class Skyline { } } + /** + * Three-input variant of {@link unionShifted} fused into a single 4-way + * pair-merge pass: one result list and one segment rebuild instead of + * three. + */ + public unionShifted3(o1: Skyline, dx1: number, o2: Skyline, dx2: number, o3: Skyline, dx3: number): void { + const a: SkylineSegment[] = o1._segments; + const b: SkylineSegment[] = o2._segments; + const c: SkylineSegment[] = o3._segments; + const aLast: number = a.length - 1; + const bLast: number = b.length - 1; + const cLast: number = c.length - 1; + const xMin: number = this.xMin; + const xMax: number = this.xMax; + + // Treat streams that are out-of-range or have no positive height as absent. + let aActive: boolean = !(a[aLast].xStart + dx1 <= xMin || a[0].xStart + dx1 >= xMax); + if (aActive) { + let any: boolean = false; + for (let k: number = 0; k < aLast; k = k + 1) { + if (a[k].height > 0) { + any = true; + break; + } + } + aActive = any; + } + let bActive: boolean = !(b[bLast].xStart + dx2 <= xMin || b[0].xStart + dx2 >= xMax); + if (bActive) { + let any: boolean = false; + for (let k: number = 0; k < bLast; k = k + 1) { + if (b[k].height > 0) { + any = true; + break; + } + } + bActive = any; + } + let cActive: boolean = !(c[cLast].xStart + dx3 <= xMin || c[0].xStart + dx3 >= xMax); + if (cActive) { + let any: boolean = false; + for (let k: number = 0; k < cLast; k = k + 1) { + if (c[k].height > 0) { + any = true; + break; + } + } + cActive = any; + } + if (!aActive && !bActive && !cActive) { + return; + } + + const t: SkylineSegment[] = this._segments; + const tLast: number = t.length - 1; + + const newSegs: SkylineSegment[] = []; + + let i: number = 0; + let ja: number = 0; + let jb: number = 0; + let jc: number = 0; + + // Skip leading segments that end at or before xMin. + if (aActive) { + while (ja < aLast && a[ja + 1].xStart + dx1 <= xMin) { + ja = ja + 1; + } + } + if (bActive) { + while (jb < bLast && b[jb + 1].xStart + dx2 <= xMin) { + jb = jb + 1; + } + } + if (cActive) { + while (jc < cLast && c[jc + 1].xStart + dx3 <= xMin) { + jc = jc + 1; + } + } + + let x: number = xMin; + while (x < xMax) { + const thisH: number = i < tLast ? t[i].height : 0; + let h: number = thisH; + + if (aActive && ja < aLast) { + const oStart: number = a[ja].xStart + dx1; + if (x >= oStart) { + const ah: number = a[ja].height; + if (ah > h) { + h = ah; + } + } + } + if (bActive && jb < bLast) { + const oStart: number = b[jb].xStart + dx2; + if (x >= oStart) { + const bh: number = b[jb].height; + if (bh > h) { + h = bh; + } + } + } + if (cActive && jc < cLast) { + const oStart: number = c[jc].xStart + dx3; + if (x >= oStart) { + const ch: number = c[jc].height; + if (ch > h) { + h = ch; + } + } + } + + // Find next breakpoint across all 4 streams. + let nextX: number = xMax; + if (i < tLast) { + const tNext: number = t[i + 1].xStart; + if (tNext < nextX) { + nextX = tNext; + } + } + if (aActive && ja < aLast) { + const oStart: number = a[ja].xStart + dx1; + if (x < oStart) { + if (oStart < nextX) { + nextX = oStart; + } + } else { + const oExit: number = a[ja + 1].xStart + dx1; + if (oExit < nextX) { + nextX = oExit; + } + } + } + if (bActive && jb < bLast) { + const oStart: number = b[jb].xStart + dx2; + if (x < oStart) { + if (oStart < nextX) { + nextX = oStart; + } + } else { + const oExit: number = b[jb + 1].xStart + dx2; + if (oExit < nextX) { + nextX = oExit; + } + } + } + if (cActive && jc < cLast) { + const oStart: number = c[jc].xStart + dx3; + if (x < oStart) { + if (oStart < nextX) { + nextX = oStart; + } + } else { + const oExit: number = c[jc + 1].xStart + dx3; + if (oExit < nextX) { + nextX = oExit; + } + } + } + if (nextX > xMax) { + nextX = xMax; + } + if (nextX <= x) { + nextX = xMax; + } + + if (newSegs.length > 0 && newSegs[newSegs.length - 1].height === h) { + // coalesce equal-height neighbours + } else { + const ns: SkylineSegment = this._pool.acquire(); + ns.xStart = x; + ns.height = h; + newSegs.push(ns); + } + + x = nextX; + + while (i < tLast && t[i + 1].xStart <= x) { + i = i + 1; + } + if (aActive) { + while (ja < aLast && a[ja + 1].xStart + dx1 <= x) { + ja = ja + 1; + } + } + if (bActive) { + while (jb < bLast && b[jb + 1].xStart + dx2 <= x) { + jb = jb + 1; + } + } + if (cActive) { + while (jc < cLast && c[jc + 1].xStart + dx3 <= x) { + jc = jc + 1; + } + } + } + + const sentinel: SkylineSegment = this._pool.acquire(); + sentinel.xStart = xMax; + sentinel.height = 0; + newSegs.push(sentinel); + + while (this._segments.length > 0) { + const s: SkylineSegment = this._segments.pop()!; + this._pool.release(s); + } + for (let k: number = 0; k < newSegs.length; k = k + 1) { + this._segments.push(newSegs[k]); + } + } + public maxHeightInRange(xStart: number, xEnd: number): number { return this._maxHeightInRange(xStart, xEnd); } diff --git a/packages/alphatab/src/rendering/skyline/SkylineSegmentPool.ts b/packages/alphatab/src/rendering/skyline/SkylineSegmentPool.ts index db200b585..847ea05d3 100644 --- a/packages/alphatab/src/rendering/skyline/SkylineSegmentPool.ts +++ b/packages/alphatab/src/rendering/skyline/SkylineSegmentPool.ts @@ -1,9 +1,11 @@ +import { type IPoolable, ObjectPool } from '@coderline/alphatab/rendering/utils/ObjectPool'; + /** * One segment of a piecewise-constant skyline. segment[i] covers * `[xStart, segments[i+1].xStart)`; final entry is a sentinel. * @internal */ -export class SkylineSegment { +export class SkylineSegment implements IPoolable { public xStart: number = 0; public height: number = 0; @@ -14,27 +16,8 @@ export class SkylineSegment { } /** @internal */ -export class SkylineSegmentPool { - private readonly _free: SkylineSegment[] = []; - private _grown: number = 0; - - public acquire(): SkylineSegment { - let s: SkylineSegment; - if (this._free.length > 0) { - s = this._free.pop()!; - } else { - s = new SkylineSegment(); - this._grown = this._grown + 1; - } - s.reset(); - return s; - } - - public release(s: SkylineSegment): void { - this._free.push(s); - } - - public get grownCount(): number { - return this._grown; +export class SkylineSegmentPool extends ObjectPool { + public constructor() { + super(() => new SkylineSegment()); } } diff --git a/packages/alphatab/src/rendering/staves/RenderStaff.ts b/packages/alphatab/src/rendering/staves/RenderStaff.ts index e4cb9ad11..9d4ba737d 100644 --- a/packages/alphatab/src/rendering/staves/RenderStaff.ts +++ b/packages/alphatab/src/rendering/staves/RenderStaff.ts @@ -1,6 +1,7 @@ import type { Bar } from '@coderline/alphatab/model/Bar'; import type { Staff } from '@coderline/alphatab/model/Staff'; import type { ICanvas } from '@coderline/alphatab/platform/ICanvas'; +import { Profiler } from '@coderline/alphatab/profiling/Profiler'; import type { BarRendererBase } from '@coderline/alphatab/rendering/BarRendererBase'; import { type BarRendererFactory, @@ -242,12 +243,8 @@ export class RenderStaff { // group's final x (settled by scaleToWidth) before unioning. const postBaseX = baseX + renderer.postBeatGroupOffset; - sky.upSky.unionShifted(bar.upSky, baseX); - sky.downSky.unionShifted(bar.downSky, baseX); - sky.upSky.unionShifted(pre.upSky, baseX); - sky.downSky.unionShifted(pre.downSky, baseX); - sky.upSky.unionShifted(post.upSky, postBaseX); - sky.downSky.unionShifted(post.downSky, postBaseX); + sky.upSky.unionShifted3(bar.upSky, baseX, pre.upSky, baseX, post.upSky, postBaseX); + sky.downSky.unionShifted3(bar.downSky, baseX, pre.downSky, baseX, post.downSky, postBaseX); } /** @@ -274,6 +271,7 @@ export class RenderStaff { } public finalizeStaff(): void { + Profiler.begin('layout.finalizeStaff'); this._applyStaffPaddings(); this.height = 0; @@ -311,6 +309,7 @@ export class RenderStaff { this.height = Math.ceil(this.height); this._updateVisibility(); + Profiler.end('layout.finalizeStaff'); } public paint(cx: number, cy: number, canvas: ICanvas, startIndex: number, count: number): void { diff --git a/packages/alphatab/src/rendering/staves/StaffSystem.ts b/packages/alphatab/src/rendering/staves/StaffSystem.ts index 27b77f1a2..02e7915a9 100644 --- a/packages/alphatab/src/rendering/staves/StaffSystem.ts +++ b/packages/alphatab/src/rendering/staves/StaffSystem.ts @@ -12,6 +12,7 @@ import { SimileMark } from '@coderline/alphatab/model/SimileMark'; import { type Track, TrackSubElement } from '@coderline/alphatab/model/Track'; import { NotationElement } from '@coderline/alphatab/NotationSettings'; import { CanvasHelper, type ICanvas, TextAlign, TextBaseline } from '@coderline/alphatab/platform/ICanvas'; +import { Profiler } from '@coderline/alphatab/profiling/Profiler'; import type { RenderingResources } from '@coderline/alphatab/RenderingResources'; import type { BarRendererBase } from '@coderline/alphatab/rendering/BarRendererBase'; import type { LineBarRenderer } from '@coderline/alphatab/rendering/LineBarRenderer'; @@ -1077,6 +1078,7 @@ export class StaffSystem { } public finalizeSystem(): void { + Profiler.begin('layout.finalizeSystem'); const settings = this.layout.renderer.settings; if (this.index === 0) { this.topPadding = settings.display.firstSystemPaddingTop; @@ -1110,6 +1112,7 @@ export class StaffSystem { for (const b of this._brackets!) { b.finalizeBracket(settings.display.resources.engravingSettings); } + Profiler.end('layout.finalizeSystem'); } private _finalizeTrackGroups(onlyFirstGroup: boolean = false) { diff --git a/packages/alphatab/src/rendering/utils/ObjectPool.ts b/packages/alphatab/src/rendering/utils/ObjectPool.ts new file mode 100644 index 000000000..826091de5 --- /dev/null +++ b/packages/alphatab/src/rendering/utils/ObjectPool.ts @@ -0,0 +1,57 @@ +/** + * `reset()` is invoked at acquire-time (not release-time) so callers can skip + * zeroing slots that get immediately overwritten. + * @internal + */ +export interface IPoolable { + reset(): void; +} + +/** + * Bump-allocator / arena-style object pool. Callers may either pair each + * {@link acquire} with a {@link release} or batch-acquire and call + * {@link releaseAll} at a lifecycle boundary. + * @internal + */ +export class ObjectPool { + private readonly _items: T[] = []; + private readonly _recycled: T[] = []; + private _cursor: number = 0; + private _grown: number = 0; + private readonly _factory: () => T; + + public constructor(factory: () => T) { + this._factory = factory; + } + + public acquire(): T { + let obj: T; + if (this._recycled.length > 0) { + obj = this._recycled.pop()!; + } else if (this._cursor < this._items.length) { + obj = this._items[this._cursor]; + this._cursor++; + } else { + obj = this._factory(); + this._items.push(obj); + this._cursor++; + this._grown++; + } + obj.reset(); + return obj; + } + + public release(obj: T): void { + this._recycled.push(obj); + } + + public releaseAll(): void { + this._cursor = 0; + // splice(0) instead of `.length = 0` for transpiler compatibility. + this._recycled.splice(0); + } + + public get grownCount(): number { + return this._grown; + } +} diff --git a/packages/alphatab/test-data/musicxml-samples/MozartPianoSonata.png b/packages/alphatab/test-data/musicxml-samples/MozartPianoSonata.png index 857c82649..771749a71 100644 Binary files a/packages/alphatab/test-data/musicxml-samples/MozartPianoSonata.png and b/packages/alphatab/test-data/musicxml-samples/MozartPianoSonata.png differ diff --git a/packages/alphatab/test-data/visual-tests/notation-legend/full-default-large.png b/packages/alphatab/test-data/visual-tests/notation-legend/full-default-large.png index 3b3fb3292..cfa25373f 100644 Binary files a/packages/alphatab/test-data/visual-tests/notation-legend/full-default-large.png and b/packages/alphatab/test-data/visual-tests/notation-legend/full-default-large.png differ diff --git a/packages/alphatab/test-data/visual-tests/notation-legend/full-default-small.png b/packages/alphatab/test-data/visual-tests/notation-legend/full-default-small.png index 3b170aaac..c362760f9 100644 Binary files a/packages/alphatab/test-data/visual-tests/notation-legend/full-default-small.png and b/packages/alphatab/test-data/visual-tests/notation-legend/full-default-small.png differ diff --git a/packages/alphatab/test-data/visual-tests/notation-legend/resize-sequence-1300.png b/packages/alphatab/test-data/visual-tests/notation-legend/resize-sequence-1300.png index 582f6f827..748da29c3 100644 Binary files a/packages/alphatab/test-data/visual-tests/notation-legend/resize-sequence-1300.png and b/packages/alphatab/test-data/visual-tests/notation-legend/resize-sequence-1300.png differ diff --git a/packages/alphatab/test-data/visual-tests/notation-legend/resize-sequence-1500.png b/packages/alphatab/test-data/visual-tests/notation-legend/resize-sequence-1500.png index dd4f3ba24..6f51a5ed1 100644 Binary files a/packages/alphatab/test-data/visual-tests/notation-legend/resize-sequence-1500.png and b/packages/alphatab/test-data/visual-tests/notation-legend/resize-sequence-1500.png differ diff --git a/packages/alphatab/test-data/visual-tests/notation-legend/resize-sequence-500.png b/packages/alphatab/test-data/visual-tests/notation-legend/resize-sequence-500.png index 2b4ad82d6..017debc56 100644 Binary files a/packages/alphatab/test-data/visual-tests/notation-legend/resize-sequence-500.png and b/packages/alphatab/test-data/visual-tests/notation-legend/resize-sequence-500.png differ diff --git a/packages/alphatab/test-data/visual-tests/notation-legend/resize-sequence-800.png b/packages/alphatab/test-data/visual-tests/notation-legend/resize-sequence-800.png index c79490957..afe3deaf6 100644 Binary files a/packages/alphatab/test-data/visual-tests/notation-legend/resize-sequence-800.png and b/packages/alphatab/test-data/visual-tests/notation-legend/resize-sequence-800.png differ diff --git a/packages/alphatab/test-data/visual-tests/notation-legend/smufl-petaluma-1300.png b/packages/alphatab/test-data/visual-tests/notation-legend/smufl-petaluma-1300.png index bf6fba6ee..9e7dc04e8 100644 Binary files a/packages/alphatab/test-data/visual-tests/notation-legend/smufl-petaluma-1300.png and b/packages/alphatab/test-data/visual-tests/notation-legend/smufl-petaluma-1300.png differ diff --git a/packages/alphatab/test-data/visual-tests/special-notes/grace-notes-alignment.png b/packages/alphatab/test-data/visual-tests/special-notes/grace-notes-alignment.png index 2f0025676..07b371092 100644 Binary files a/packages/alphatab/test-data/visual-tests/special-notes/grace-notes-alignment.png and b/packages/alphatab/test-data/visual-tests/special-notes/grace-notes-alignment.png differ diff --git a/packages/playground/vite.config.ts b/packages/playground/vite.config.ts index 11a0ecd5a..aa3281017 100644 --- a/packages/playground/vite.config.ts +++ b/packages/playground/vite.config.ts @@ -1,11 +1,12 @@ import { defineConfig, type UserConfig } from 'vite'; import { buildTsconfigAliases } from '../tooling/src/vite'; +import { stripProfilingPlugin } from '../tooling/src/vite.plugin.strip-profiling'; import { elementStyleUsingPlugin } from '../tooling/src/vite.plugin.transform'; import server from './vite.plugin.server'; export default defineConfig(_ => { const config: UserConfig = { - plugins: [server(), elementStyleUsingPlugin()], + plugins: [server(), elementStyleUsingPlugin(), stripProfilingPlugin({ enabled: false })], resolve: { tsconfigPaths: true, alias: buildTsconfigAliases(process.cwd()) diff --git a/packages/tooling/src/vite.plugin.strip-profiling.ts b/packages/tooling/src/vite.plugin.strip-profiling.ts new file mode 100644 index 000000000..5c64e8ea2 --- /dev/null +++ b/packages/tooling/src/vite.plugin.strip-profiling.ts @@ -0,0 +1,57 @@ +import MagicString from 'magic-string'; +import type { Plugin } from 'vite'; + +/** + * Strips `Profiler.(...)` statements and the `Profiler` import from + * alphatab source at transform time when `enabled: false`. With the call + * sites gone, tree-shaking drops the {@link Profiler} module from the + * bundle. The Profiler module itself is exempt. With `enabled: true` the + * plugin is a passthrough. + */ +export function stripProfilingPlugin(options: { enabled: boolean }): Plugin { + const importLine = /^[\t ]*import\s*\{\s*Profiler\s*\}\s*from\s*['"][^'"]*profiling\/Profiler['"]\s*;?[\t ]*\r?\n/m; + const callStatement = /^[\t ]*Profiler\.[A-Za-z_$][\w$]*\s*\([^()\n]*\)\s*;?[\t ]*\r?\n/gm; + + return { + name: 'alphatab:strip-profiling', + enforce: 'pre', + transform(code, id) { + if (options.enabled) { + return null; + } + if (!code.includes('Profiler')) { + return null; + } + // Skip the Profiler module itself. + if (/[\\/]profiling[\\/]Profiler\.ts$/.test(id)) { + return null; + } + + const ms = new MagicString(code); + let changed = false; + + const importMatch = importLine.exec(code); + if (importMatch && importMatch.index !== undefined) { + ms.remove(importMatch.index, importMatch.index + importMatch[0].length); + changed = true; + } + + for (const m of code.matchAll(callStatement)) { + if (m.index === undefined) { + continue; + } + ms.remove(m.index, m.index + m[0].length); + changed = true; + } + + if (!changed) { + return null; + } + + return { + code: ms.toString(), + map: ms.generateMap({ hires: 'boundary' }) + }; + } + }; +} diff --git a/packages/tooling/src/vite.plugin.transform.ts b/packages/tooling/src/vite.plugin.transform.ts index a7fcb3a83..99fa8d11c 100644 --- a/packages/tooling/src/vite.plugin.transform.ts +++ b/packages/tooling/src/vite.plugin.transform.ts @@ -101,8 +101,45 @@ function isElementStyleHelperUsing( if (stmt.kind !== 'using' && stmt.kind !== 'await using') { return false; } if (stmt.declarations.length !== 1) { return false; } const init = stmt.declarations[0].init; - if (!init || init.type !== 'CallExpression') { return false; } - const callee = (init as { callee: unknown }).callee as MemberExpression | { type: string }; + if (!init) { return false; } + // Accept a direct `ElementStyleHelper.X(...)` call or a ternary + // whose leaves are each a call or undefined/void 0. + return isElementStyleHelperInit(init as unknown as { type: string }) && hasElementStyleHelperCall(init as unknown as { type: string }); +} + +function isElementStyleHelperInit(expr: { type: string } | null | undefined): boolean { + if (!expr) { return false; } + if (expr.type === 'CallExpression') { + return isElementStyleHelperCall(expr); + } + if (expr.type === 'Identifier') { + return (expr as IdentifierName).name === 'undefined'; + } + if (expr.type === 'UnaryExpression') { + // `void 0`, `void undefined`, etc. + return (expr as unknown as { operator: string }).operator === 'void'; + } + if (expr.type === 'ConditionalExpression') { + const c = expr as unknown as { consequent: { type: string }; alternate: { type: string } }; + return isElementStyleHelperInit(c.consequent) && isElementStyleHelperInit(c.alternate); + } + return false; +} + +function hasElementStyleHelperCall(expr: { type: string } | null | undefined): boolean { + if (!expr) { return false; } + if (expr.type === 'CallExpression') { + return isElementStyleHelperCall(expr); + } + if (expr.type === 'ConditionalExpression') { + const c = expr as unknown as { consequent: { type: string }; alternate: { type: string } }; + return hasElementStyleHelperCall(c.consequent) || hasElementStyleHelperCall(c.alternate); + } + return false; +} + +function isElementStyleHelperCall(expr: { type: string }): boolean { + const callee = (expr as unknown as { callee: unknown }).callee as MemberExpression | { type: string } | undefined; if (!callee || callee.type !== 'MemberExpression') { return false; } const object = (callee as MemberExpression).object; return object.type === 'Identifier' && (object as IdentifierName).name === 'ElementStyleHelper'; diff --git a/packages/tooling/src/vite.ts b/packages/tooling/src/vite.ts index 78d1f51fb..b08537016 100644 --- a/packages/tooling/src/vite.ts +++ b/packages/tooling/src/vite.ts @@ -12,6 +12,7 @@ import { createApiDtsFiles } from './typescript'; import generateDts from './vite.plugin.dts'; import { emitDtsPlugin } from './vite.plugin.emit-dts'; import min from './vite.plugin.min'; +import { stripProfilingPlugin } from './vite.plugin.strip-profiling'; import { elementStyleUsingPlugin } from './vite.plugin.transform'; const terserOptions: MinifyOptions = { @@ -122,7 +123,7 @@ export function buildTsconfigAliases(projectDir: string): Array<{ find: RegExp; export function defaultBuildUserConfig(projectDir: string = process.cwd()): UserConfig { return { - plugins: [licenseHeaderPlugin(), elementStyleUsingPlugin()], + plugins: [licenseHeaderPlugin(), elementStyleUsingPlugin(), stripProfilingPlugin({ enabled: false })], resolve: { tsconfigPaths: true, alias: buildTsconfigAliases(projectDir) diff --git a/packages/tooling/src/vitest.ts b/packages/tooling/src/vitest.ts index 43b438490..9a9886081 100644 --- a/packages/tooling/src/vitest.ts +++ b/packages/tooling/src/vitest.ts @@ -2,6 +2,7 @@ import { appendFileSync, readFileSync } from 'node:fs'; import path from 'node:path'; import { defineConfig } from 'vitest/config'; import { buildTsconfigAliases } from './vite'; +import { stripProfilingPlugin } from './vite.plugin.strip-profiling'; class SummaryLabelReporter { constructor(private readonly label: string) {} @@ -25,6 +26,7 @@ export function defineVitestConfig(options: VitestPackageOptions = {}) { ? ['default', 'github-actions', new SummaryLabelReporter(pkg.name)] : ['default']; return defineConfig({ + plugins: [stripProfilingPlugin({ enabled: false })], resolve: { tsconfigPaths: true, alias: buildTsconfigAliases(process.cwd())