Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 33 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

23 changes: 13 additions & 10 deletions packages/alphatab/src/platform/svg/CssFontSvgCanvas.ts
Original file line number Diff line number Diff line change
@@ -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.
Expand Down Expand Up @@ -43,22 +42,26 @@ export class CssFontSvgCanvas extends SvgCanvas {
symbols: string,
centerAtPosition?: boolean
): void {
x *= this.scale;
y *= this.scale;

this.buffer += `<g transform="translate(${x} ${y})" class="at" ><text`;
const scale = this.scale * relativeScale;
const s = this.scale;
if (s === 1) {
this.buffer += '<g transform="translate(' + x + ' ' + y + ')" class="at" ><text';
} else {
const sx = x * s;
const sy = y * s;
this.buffer += `<g transform="translate(${sx} ${sy})" class="at" ><text`;
}
const scale = s * relativeScale;
if (scale !== 1) {
this.buffer += ` style="font-size: ${scale * 100}%; stroke:none"`;
} else {
this.buffer += ' style="stroke:none"';
}
if (this.color.rgba !== '#000000') {
this.buffer += ` fill="${this.color.rgba}"`;
this.buffer += ' fill="' + this.color.rgba + '"';
}
if (centerAtPosition) {
this.buffer += ` text-anchor="${this.getSvgTextAlignment(TextAlign.Center)}"`;
this.buffer += ' text-anchor="middle"';
}
this.buffer += `>${symbols}</text></g>`;
this.buffer += '>' + symbols + '</text></g>';
}
}
78 changes: 63 additions & 15 deletions packages/alphatab/src/platform/svg/SvgCanvas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ export abstract class SvgCanvas implements ICanvas {
}

public beginGroup(identifier: string): void {
this.buffer += `<g class="${identifier}">`;
this.buffer += '<g class="' + identifier + '">';
}

public endGroup(): void {
Expand All @@ -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 += `<rect x="${x * this.scale}" y="${y * this.scale}" width="${
w * this.scale
}" height="${h * this.scale}" fill="${this.color.rgba}" />\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 +=
'<rect x="' +
x +
'" y="' +
y +
'" width="' +
w +
'" height="' +
h +
'" fill="' +
this.color.rgba +
'" />\n';
} else {
this.buffer += `<rect x="${x * s}" y="${y * s}" width="${w * s}" height="${h * s}" fill="${this.color.rgba}" />\n`;
}
}
}

Expand All @@ -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 {
Expand All @@ -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 {
Expand Down Expand Up @@ -125,9 +154,9 @@ export abstract class SvgCanvas implements ICanvas {

public fill(): void {
if (!this._currentPathIsEmpty) {
this.buffer += `<path d="${this._currentPath}"`;
this.buffer += '<path d="' + this._currentPath + '"';
if (this.color.rgba !== '#000000') {
this.buffer += ` fill="${this.color.rgba}"`;
this.buffer += ' fill="' + this.color.rgba + '"';
}
this.buffer += ' style="stroke: none"/>';
}
Expand All @@ -137,7 +166,7 @@ export abstract class SvgCanvas implements ICanvas {

public stroke(): void {
if (!this._currentPathIsEmpty) {
let s: string = `<path d="${this._currentPath}" stroke="${this.color.rgba}"`;
let s: string = '<path d="' + this._currentPath + '" stroke="' + this.color.rgba + '"';
if (this.lineWidth !== 1 || this.scale !== 1) {
s += ` stroke-width="${this.lineWidth * this.scale}"`;
}
Expand All @@ -152,9 +181,22 @@ export abstract class SvgCanvas implements ICanvas {
if (text === '') {
return;
}
let s: string = `<text x="${x * this.scale}" y="${
y * this.scale
}" style='stroke: none; font:${this.font.toCssString(this.settings.display.scale)}; ${this.getSvgBaseLine()}'`;
const sc = this.scale;
let s: string;
if (sc === 1) {
s =
'<text x="' +
x +
'" y="' +
y +
'" style=\'stroke: none; font:' +
this.font.toCssString(this.settings.display.scale) +
'; ' +
this.getSvgBaseLine() +
"'";
} else {
s = `<text x="${x * sc}" y="${y * sc}" style='stroke: none; font:${this.font.toCssString(this.settings.display.scale)}; ${this.getSvgBaseLine()}'`;
}
if (this.color.rgba !== '#000000') {
s += ` fill="${this.color.rgba}"`;
}
Expand All @@ -165,7 +207,13 @@ export abstract class SvgCanvas implements ICanvas {
this.buffer += s;
}

private static readonly _escapeTextRegex = /[&<>"']/;

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, '&amp;')
.replace(/"/g, '&quot;')
Expand Down
77 changes: 77 additions & 0 deletions packages/alphatab/src/profiling/Profiler.ts
Original file line number Diff line number Diff line change
@@ -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<string, StageStats>;
counters: Record<string, number>;
}

const STACK_LIMIT = 64;

export class Profiler {
private static readonly _stages = new Map<string, StageStats>();
private static readonly _counters = new Map<string, number>();
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 ?? '<empty>'}'`
);
}
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<string, StageStats> = {};
for (const [name, stats] of Profiler._stages) {
stages[name] = { count: stats.count, totalNs: stats.totalNs, maxNs: stats.maxNs };
}
const counters: Record<string, number> = {};
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);
}
44 changes: 39 additions & 5 deletions packages/alphatab/src/rendering/BarRendererBase.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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() {
Expand Down Expand Up @@ -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<TieGlyph> | undefined) {
if (!startedTies) {
this._multiSystemSlurs = undefined;
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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();
Expand Down
Loading
Loading