From c9b959ed50919ce2085179af220b10ac6a2d88f1 Mon Sep 17 00:00:00 2001 From: danielku15 Date: Sat, 21 Feb 2026 14:16:11 +0100 Subject: [PATCH 1/4] feat(playground): add recorder sample --- packages/playground/recorder.html | 18 +++ packages/playground/recorder.ts | 186 ++++++++++++++++++++++++++++++ 2 files changed, 204 insertions(+) create mode 100644 packages/playground/recorder.html create mode 100644 packages/playground/recorder.ts diff --git a/packages/playground/recorder.html b/packages/playground/recorder.html new file mode 100644 index 000000000..82b5ae436 --- /dev/null +++ b/packages/playground/recorder.html @@ -0,0 +1,18 @@ + + + + + + AlphaTab alphaTex Recorder Demo + + + + + +
+ + + + \ No newline at end of file diff --git a/packages/playground/recorder.ts b/packages/playground/recorder.ts new file mode 100644 index 000000000..485184a8f --- /dev/null +++ b/packages/playground/recorder.ts @@ -0,0 +1,186 @@ +import { setupControl } from './control'; +import * as alphaTab from '@coderline/alphatab'; + +const req = new XMLHttpRequest(); +req.onload = () => { + document.getElementById('placeholder')!.outerHTML = req.responseText; + + // this is a demo which builds a "recorder" with alphaTab. + // in this case we do not actually play the song but just use the rendering and player capabilities + // to get a display of the notes and a cursor. + + // the overall recorder goes with various assumptions: + // 1. we only have one track/staff/voice being recorded + // -> would need more complex update of the data model. + // 2. we do not have any tempo, time signature or similar changes as we simply record lineary + // -> would need more complex handling of updating the lookups. + // 3. we do not have any re-recording flows (stop, seek and restart recording) + // -> would need further extensions. + + // we want bars dynamically being added as we record, to achieve this we use following tricks for rendering: + + // 1. we start with one full system + // -> this ensures the player/cursor doesn't think we have an end, but we still continue. + // 2. we add a new system when we reach a 80% of the second-last bar. + // -> this gives us always one empty future bar ensuring correct rendering and cursor behavior. + + // to get the cursor behaving as we want we do following: + + // 1. we generate an empty midi at start. this gives us a base Midi and MidiTickLookup to start with. + // 2. we extend this midi to the expected maximum recording length (multiple minutes of playback). + // this way the player will internally play quasi endlessly and allows us to extend the song. + // 3. When we extend we need to update the MidiTickLookup with the new bars to have correct cursor alignment. + + const api = setupControl('#alphaTab', { + core: { + file: undefined + }, + display: { + // parchment gives the best deterministic system creation without flickering as bars are added + layoutMode: alphaTab.LayoutMode.Parchment, + justifyLastSystem: true + } + }); + + // start with 2 bars to always have 1 future bar buffer + const score = alphaTab.importer.ScoreLoader.loadAlphaTex(''); + score.tracks[0].defaultSystemsLayout = 5; + + // threshold indicating we need to insert a new bar, -1 as marker to not do anything + let insertTickThreshold = -1; + + function insertNewMasterBar() { + const newMasterBar = new alphaTab.model.MasterBar(); + score.addMasterBar(newMasterBar); + + // insert new bar to tick cache for cursor placement + const masterBarTickLookup = new alphaTab.midi.MasterBarTickLookup(); + masterBarTickLookup.tempoChanges.push( + new alphaTab.midi.MasterBarTickLookupTempoChange(newMasterBar.start, score.tempo) + ); + masterBarTickLookup.start = newMasterBar.start; + masterBarTickLookup.end = newMasterBar.start + newMasterBar.calculateDuration(); + masterBarTickLookup.masterBar = newMasterBar; + api.tickCache?.addMasterBar(masterBarTickLookup); + + return newMasterBar; + } + + function insertNewBar(masterBar: alphaTab.model.MasterBar) { + const staff = score.tracks[0].staves[0]; + const previousBar = staff.bars[staff.bars.length - 1]; + const newBar = new alphaTab.model.Bar(); + newBar.clef = previousBar.clef; + newBar.clefOttava = previousBar.clefOttava; + newBar.keySignature = previousBar.keySignature; + newBar.keySignatureType = previousBar.keySignatureType; + + staff.addBar(newBar); + + const initialVoice = new alphaTab.model.Voice(); + newBar.addVoice(initialVoice); + + const emptyBeat = new alphaTab.model.Beat(); + emptyBeat.isEmpty = true; + emptyBeat.duration = alphaTab.model.Duration.Whole; + initialVoice.addBeat(emptyBeat); + + api.tickCache?.addBeat(emptyBeat, 0, masterBar.calculateDuration()); + + return newBar; + } + + function insertNewSystem() { + // clear threshold after we create bar, will be set again after render + insertTickThreshold = -1; + + const currentSystemCount = Math.floor(score.masterBars.length / score.tracks[0].defaultSystemsLayout); + const neededSystemCount = currentSystemCount + 1; + const neededBars = neededSystemCount * score.tracks[0].defaultSystemsLayout; + + let missingBars = neededBars - score.masterBars.length; + + while (missingBars > 0) { + const newMasterBar = insertNewMasterBar(); + const newBar = insertNewBar(newMasterBar); + + const sharedDataBag = new Map(); + newMasterBar.finish(sharedDataBag); + newBar.finish(api.settings, sharedDataBag); + + missingBars--; + } + + // + // update remaining bits and render + + updateInsertTickThreshold(); + + // TODO: hints on edited score + api.renderScore(score, undefined, { + reuseViewport: true + }); + } + + function updateInsertTickThreshold() { + // assumption: due to recording we do not have any repeats but a linear score + const lastBar = score!.masterBars![score.masterBars.length - 2]; + const thresholdPercent = 0.8; + const lastBarDuration = lastBar.calculateDuration(); + insertTickThreshold = lastBar.start + lastBarDuration * thresholdPercent; + } + + api.scoreLoaded.on(() => { + updateInsertTickThreshold(); + }); + + // add second bar and setup + insertNewSystem(); + api.renderScore(score); + + // extend the midi to be very long + api.midiLoad.on(midi => { + // find last rest event as starting point to extend + let rest: alphaTab.midi.AlphaTabRestEvent | undefined = undefined; + for (let i = midi.tracks[0].events.length; i >= 0; i--) { + const e = midi.tracks[0].events[i]; + if (e instanceof alphaTab.midi.AlphaTabRestEvent) { + rest = e; + break; + } + } + + // should never happen assuming we start with an empty song like in this sample + if (!rest) { + return; + } + + // 30mins should be enough for everyone ;) + const midiQuarterTime = 960; + const desiredLengthInMilliseconds = 60 * 30 * 1000; + const tempo = api.tickCache!.masterBars[0].tempoChanges[0].tempo; + const desiredLengthInTicks = (desiredLengthInMilliseconds / (60000.0 / (tempo * midiQuarterTime))) | 0; + + const endOfTrack = midi.tracks[0].events.pop()! as alphaTab.midi.EndOfTrackEvent; + + // add rest events every quarter note + let tick = rest.tick + midiQuarterTime; + while (tick < desiredLengthInTicks) { + midi.tracks[0].events.push(new alphaTab.midi.AlphaTabRestEvent(rest.track, tick, rest.channel)); + tick += midiQuarterTime; + } + + // shift end message + endOfTrack.tick = tick; + }); + + api.playerPositionChanged.on(e => { + if (insertTickThreshold !== -1 && e.currentTick >= insertTickThreshold) { + insertNewSystem(); + } + }); + + (window as any).at = api; +}; +req.open('GET', 'control-template.html'); +req.send(); From e7fe3b79122d19d2f2d3a5510123c8ca2c95b542 Mon Sep 17 00:00:00 2001 From: danielku15 Date: Sun, 1 Mar 2026 11:23:43 +0100 Subject: [PATCH 2/4] feat: allow rendering hint of first changed bar. --- .../platform/javascript/AlphaTabWebWorker.ts | 3 +- .../alphatab/src/rendering/IScoreRenderer.ts | 8 ++++ .../layout/HorizontalScreenLayout.ts | 8 ++++ .../src/rendering/layout/ScoreLayout.ts | 10 +++++ .../rendering/layout/VerticalLayoutBase.ts | 38 +++++++++++++++++-- packages/playground/recorder.ts | 5 ++- 6 files changed, 66 insertions(+), 6 deletions(-) diff --git a/packages/alphatab/src/platform/javascript/AlphaTabWebWorker.ts b/packages/alphatab/src/platform/javascript/AlphaTabWebWorker.ts index 62038c4df..0702dfecb 100644 --- a/packages/alphatab/src/platform/javascript/AlphaTabWebWorker.ts +++ b/packages/alphatab/src/platform/javascript/AlphaTabWebWorker.ts @@ -82,9 +82,10 @@ export class AlphaTabWebWorker { break; case 'alphaTab.renderScore': this._updateFontSizes(data.fontSizes); + const renderHints:RenderHints = data.renderHints; const score: any = data.score == null ? null : JsonConverter.jsObjectToScore(data.score, this._renderer.settings); - this._renderMultiple(score, data.trackIndexes); + this._renderMultiple(score, data.trackIndexes, renderHints); break; case 'alphaTab.updateSettings': this._updateSettings(data.settings); diff --git a/packages/alphatab/src/rendering/IScoreRenderer.ts b/packages/alphatab/src/rendering/IScoreRenderer.ts index 0a071a9c7..da4daa400 100644 --- a/packages/alphatab/src/rendering/IScoreRenderer.ts +++ b/packages/alphatab/src/rendering/IScoreRenderer.ts @@ -19,6 +19,14 @@ export interface RenderHints { * internally it might still be decided to clear the viewport. */ reuseViewport?: boolean; + + /** + * Indicates the index of the first masterbar which was modified in the data model. + * @remarks + * AlphaTab will try to optimize the rendering and other updates to keep unchanged parts. + * At this point only the rendering is affected and the generated MIDI has to be updated separately. + */ + firstChangedMasterBar?: number; } /** diff --git a/packages/alphatab/src/rendering/layout/HorizontalScreenLayout.ts b/packages/alphatab/src/rendering/layout/HorizontalScreenLayout.ts index 5962fab06..85988a89a 100644 --- a/packages/alphatab/src/rendering/layout/HorizontalScreenLayout.ts +++ b/packages/alphatab/src/rendering/layout/HorizontalScreenLayout.ts @@ -44,6 +44,14 @@ export class HorizontalScreenLayout extends ScoreLayout { public doResize(): void { // not supported } + + public override doUpdateForBars(_firstChangedMasterBar: number): boolean { + // not supported yet, modifications likely cause anyhow full updates + // as we do not optimize effect bands yet. with effect bands being more + // isolated in bars we could try updating dynamically + return false; + } + protected doLayoutAndRender(renderHints: RenderHints | undefined): void { const score: Score = this.renderer.score!; diff --git a/packages/alphatab/src/rendering/layout/ScoreLayout.ts b/packages/alphatab/src/rendering/layout/ScoreLayout.ts index 48b41ac17..eb37100a9 100644 --- a/packages/alphatab/src/rendering/layout/ScoreLayout.ts +++ b/packages/alphatab/src/rendering/layout/ScoreLayout.ts @@ -84,9 +84,19 @@ export abstract class ScoreLayout { } public abstract doResize(): void; + public abstract doUpdateForBars(firstChangedMasterBar: number): boolean; + public layoutAndRender(renderHints?: RenderHints): void { this._lazyPartials.clear(); this.slurRegistry.clear(); + + const firstChangedMasterBar = renderHints?.firstChangedMasterBar; + if (firstChangedMasterBar !== undefined) { + if (this.doUpdateForBars(firstChangedMasterBar)) { + return; + } + } + this.beamingRuleLookups.clear(); this._barRendererLookup.clear(); diff --git a/packages/alphatab/src/rendering/layout/VerticalLayoutBase.ts b/packages/alphatab/src/rendering/layout/VerticalLayoutBase.ts index d1b88c302..77f42ac1c 100644 --- a/packages/alphatab/src/rendering/layout/VerticalLayoutBase.ts +++ b/packages/alphatab/src/rendering/layout/VerticalLayoutBase.ts @@ -38,7 +38,7 @@ export abstract class VerticalLayoutBase extends ScoreLayout { y = this._layoutAndRenderChordDiagrams(y, -1); // // 4. One result per StaffSystem - y = this._layoutAndRenderScore(y); + y = this._layoutAndRenderScore(y, this.firstBarIndex); y = this.layoutAndRenderBottomScoreInfo(y); @@ -64,6 +64,39 @@ export abstract class VerticalLayoutBase extends ScoreLayout { return x; } + public override doUpdateForBars(firstModifiedMasterBar: number): boolean { + // first update existing systems as needed + const systemIndex = this._systems.findIndex(s => { + const first = s.masterBarsRenderers[0].masterBar.index; + const last = s.masterBarsRenderers[s.masterBarsRenderers.length - 1].masterBar.index; + return first >= firstModifiedMasterBar && firstModifiedMasterBar <= last; + }); + + if (systemIndex === -1) { + return false; + } + + // for now we do a full relayout from the first modified masterbar + // there is a lot of room for even more performant updates, but they come + // at a risk that features break. + // e.g. we could only shift systems where the content didn't change, + // but we might still have ties/slurs which have to be updated. + const removeSystems = this._systems.splice(systemIndex, this._systems.length - systemIndex); + const system = removeSystems[0]; + let y = system.y; + const firstBarIndex = system.masterBarsRenderers[0].masterBar.index; + + y = this._layoutAndRenderScore(y, firstBarIndex); + + y = this.layoutAndRenderBottomScoreInfo(y); + + y = this.layoutAndRenderAnnotation(y); + + this.height = (y + this.pagePadding![3]) * this.renderer.settings.display.scale; + + return true; + } + public doResize(): void { let y: number = this.pagePadding![1]; this.width = this.renderer.width; @@ -270,8 +303,7 @@ export abstract class VerticalLayoutBase extends ScoreLayout { return y; } - private _layoutAndRenderScore(y: number): number { - const startIndex: number = this.firstBarIndex; + private _layoutAndRenderScore(y: number, startIndex: number): number { let currentBarIndex: number = startIndex; const endBarIndex: number = this.lastBarIndex; diff --git a/packages/playground/recorder.ts b/packages/playground/recorder.ts index 485184a8f..13dcf9926 100644 --- a/packages/playground/recorder.ts +++ b/packages/playground/recorder.ts @@ -97,6 +97,7 @@ req.onload = () => { const currentSystemCount = Math.floor(score.masterBars.length / score.tracks[0].defaultSystemsLayout); const neededSystemCount = currentSystemCount + 1; const neededBars = neededSystemCount * score.tracks[0].defaultSystemsLayout; + const lastMasterBarIndex = score.masterBars.length - 1; let missingBars = neededBars - score.masterBars.length; @@ -118,7 +119,8 @@ req.onload = () => { // TODO: hints on edited score api.renderScore(score, undefined, { - reuseViewport: true + reuseViewport: currentSystemCount > 0, + firstChangedMasterBar: currentSystemCount > 0 ? lastMasterBarIndex : undefined }); } @@ -136,7 +138,6 @@ req.onload = () => { // add second bar and setup insertNewSystem(); - api.renderScore(score); // extend the midi to be very long api.midiLoad.on(midi => { From fd93beea8a242a9d9cf0364edc017de8b1424616 Mon Sep 17 00:00:00 2001 From: danielku15 Date: Sun, 1 Mar 2026 13:21:28 +0100 Subject: [PATCH 3/4] feat: handle dynamic updates --- packages/alphatab/src/AlphaTabApiBase.ts | 3 +- .../layout/HorizontalScreenLayout.ts | 4 +- .../src/rendering/layout/ScoreLayout.ts | 49 +++++++------- .../rendering/layout/VerticalLayoutBase.ts | 50 +++++++++++--- packages/playground/recorder.ts | 66 ++++++++++++++++--- 5 files changed, 129 insertions(+), 43 deletions(-) diff --git a/packages/alphatab/src/AlphaTabApiBase.ts b/packages/alphatab/src/AlphaTabApiBase.ts index 6efea4815..c53aafe8b 100644 --- a/packages/alphatab/src/AlphaTabApiBase.ts +++ b/packages/alphatab/src/AlphaTabApiBase.ts @@ -2434,7 +2434,8 @@ export class AlphaTabApiBase { this._isInitialBeatCursorUpdate || barBounds.y !== previousBeatBounds.barBounds.masterBarBounds.visualBounds.y || startBeatX < previousBeatBounds.onNotesX || - barBoundings.index > previousBeatBounds.barBounds.masterBarBounds.index + 1; + barBoundings.index > previousBeatBounds.barBounds.masterBarBounds.index + 1 || + barBounds.h !== previousBeatBounds.barBounds.masterBarBounds.visualBounds.h; if (jumpCursor) { cursorHandler.placeBeatCursor(beatCursor, beatBoundings, startBeatX); diff --git a/packages/alphatab/src/rendering/layout/HorizontalScreenLayout.ts b/packages/alphatab/src/rendering/layout/HorizontalScreenLayout.ts index 85988a89a..836110a99 100644 --- a/packages/alphatab/src/rendering/layout/HorizontalScreenLayout.ts +++ b/packages/alphatab/src/rendering/layout/HorizontalScreenLayout.ts @@ -45,7 +45,7 @@ export class HorizontalScreenLayout extends ScoreLayout { // not supported } - public override doUpdateForBars(_firstChangedMasterBar: number): boolean { + public override doUpdateForBars(_renderHints: RenderHints): boolean { // not supported yet, modifications likely cause anyhow full updates // as we do not optimize effect bands yet. with effect bands being more // isolated in bars we could try updating dynamically @@ -158,7 +158,7 @@ export class HorizontalScreenLayout extends ScoreLayout { } this.height = this.layoutAndRenderBottomScoreInfo(this.height); - this.height = this.layoutAndRenderAnnotation(this.height); + this.height = this._layoutAndRenderAnnotation(this.height); this.height += this.pagePadding![3]; diff --git a/packages/alphatab/src/rendering/layout/ScoreLayout.ts b/packages/alphatab/src/rendering/layout/ScoreLayout.ts index eb37100a9..cc6585301 100644 --- a/packages/alphatab/src/rendering/layout/ScoreLayout.ts +++ b/packages/alphatab/src/rendering/layout/ScoreLayout.ts @@ -29,14 +29,11 @@ import { Lazy } from '@coderline/alphatab/util/Lazy'; /** * @internal + * @record */ -class LazyPartial { - public args: RenderFinishedEventArgs; - public renderCallback: (canvas: ICanvas) => void; - public constructor(args: RenderFinishedEventArgs, renderCallback: (canvas: ICanvas) => void) { - this.args = args; - this.renderCallback = renderCallback; - } +interface LazyPartial { + args: RenderFinishedEventArgs; + renderCallback: (canvas: ICanvas) => void; } /** @@ -84,34 +81,34 @@ export abstract class ScoreLayout { } public abstract doResize(): void; - public abstract doUpdateForBars(firstChangedMasterBar: number): boolean; + public abstract doUpdateForBars(renderHints: RenderHints): boolean; public layoutAndRender(renderHints?: RenderHints): void { - this._lazyPartials.clear(); this.slurRegistry.clear(); + const score: Score = this.renderer.score!; + + this.firstBarIndex = ModelUtils.computeFirstDisplayedBarIndex(score, this.renderer.settings); + this.lastBarIndex = ModelUtils.computeLastDisplayedBarIndex(score, this.renderer.settings, this.firstBarIndex); + this.multiBarRestInfo = ModelUtils.buildMultiBarRestInfo( + this.renderer.tracks, + this.firstBarIndex, + this.lastBarIndex + ); + const firstChangedMasterBar = renderHints?.firstChangedMasterBar; if (firstChangedMasterBar !== undefined) { - if (this.doUpdateForBars(firstChangedMasterBar)) { + if (this.doUpdateForBars(renderHints!)) { return; } } + this._lazyPartials.clear(); this.beamingRuleLookups.clear(); this._barRendererLookup.clear(); this.profile = Environment.staveProfiles.get(this.renderer.settings.display.staveProfile)!; - const score: Score = this.renderer.score!; - - this.firstBarIndex = ModelUtils.computeFirstDisplayedBarIndex(score, this.renderer.settings); - this.lastBarIndex = ModelUtils.computeLastDisplayedBarIndex(score, this.renderer.settings, this.firstBarIndex); - this.multiBarRestInfo = ModelUtils.buildMultiBarRestInfo( - this.renderer.tracks, - this.firstBarIndex, - this.lastBarIndex - ); - this.pagePadding = this.renderer.settings.display.padding.map(p => p / this.renderer.settings.display.scale); if (!this.pagePadding) { this.pagePadding = [0, 0, 0, 0]; @@ -128,6 +125,10 @@ export abstract class ScoreLayout { private _lazyPartials: Map = new Map(); + protected getExistingPartialArgs(id:string): RenderFinishedEventArgs|undefined { + return this._lazyPartials.has(id) ? this._lazyPartials.get(id)!.args : undefined; + } + protected registerPartial(args: RenderFinishedEventArgs, callback: (canvas: ICanvas) => void) { if (args.height === 0) { return; @@ -147,7 +148,11 @@ export abstract class ScoreLayout { this._internalRenderLazyPartial(args, callback); } else { // in case of lazy loading -> first register lazy, then notify - this._lazyPartials.set(args.id, new LazyPartial(args, callback)); + const partial: LazyPartial = { + args, + renderCallback: callback + }; + this._lazyPartials.set(args.id, partial); (this.renderer.partialLayoutFinished as EventEmitterOfT).trigger(args); } } @@ -510,7 +515,7 @@ export abstract class ScoreLayout { } } - public layoutAndRenderAnnotation(y: number): number { + protected _layoutAndRenderAnnotation(y: number): number { // attention, you are not allowed to remove change this notice within any version of this library without permission! const msg: string = 'rendered by alphaTab'; const resources: RenderingResources = this.renderer.settings.display.resources; diff --git a/packages/alphatab/src/rendering/layout/VerticalLayoutBase.ts b/packages/alphatab/src/rendering/layout/VerticalLayoutBase.ts index 77f42ac1c..dc2d53fc7 100644 --- a/packages/alphatab/src/rendering/layout/VerticalLayoutBase.ts +++ b/packages/alphatab/src/rendering/layout/VerticalLayoutBase.ts @@ -1,13 +1,14 @@ +import type { EventEmitterOfT } from '@coderline/alphatab/EventEmitter'; import { Logger } from '@coderline/alphatab/Logger'; import { ScoreSubElement } from '@coderline/alphatab/model/Score'; import { type ICanvas, TextAlign } from '@coderline/alphatab/platform/ICanvas'; -import type { RenderingResources } from '@coderline/alphatab/RenderingResources'; import type { TextGlyph } from '@coderline/alphatab/rendering/glyphs/TextGlyph'; import type { RenderHints } from '@coderline/alphatab/rendering/IScoreRenderer'; import { ScoreLayout } from '@coderline/alphatab/rendering/layout/ScoreLayout'; import { RenderFinishedEventArgs } from '@coderline/alphatab/rendering/RenderFinishedEventArgs'; import type { MasterBarsRenderers } from '@coderline/alphatab/rendering/staves/MasterBarsRenderers'; import type { StaffSystem } from '@coderline/alphatab/rendering/staves/StaffSystem'; +import type { RenderingResources } from '@coderline/alphatab/RenderingResources'; /** * Base layout for page and parchment style layouts where we have an endless @@ -21,11 +22,18 @@ export abstract class VerticalLayoutBase extends ScoreLayout { private _reuseViewPort: boolean = false; + private _preSystemPartialIds: string[] = []; + private _systemPartialIds: string[] = []; + protected doLayoutAndRender(renderHints: RenderHints | undefined): void { let y: number = this.pagePadding![1]; this.width = this.renderer.width; this._allMasterBarRenderers = []; + this._preSystemPartialIds = []; + this._systemPartialIds = []; + this._reuseViewPort = renderHints?.reuseViewport ?? false; + this._systems = []; // // 1. Score Info @@ -42,7 +50,7 @@ export abstract class VerticalLayoutBase extends ScoreLayout { y = this.layoutAndRenderBottomScoreInfo(y); - y = this.layoutAndRenderAnnotation(y); + y = this._layoutAndRenderAnnotation(y); this.height = (y + this.pagePadding![3]) * this.renderer.settings.display.scale; } @@ -52,6 +60,15 @@ export abstract class VerticalLayoutBase extends ScoreLayout { super.registerPartial(args, callback); } + protected reregisterPartial(id: string) { + const args = this.getExistingPartialArgs(id); + if (!args) { + return; + } + args.reuseViewport = this._reuseViewPort; + (this.renderer.partialLayoutFinished as EventEmitterOfT).trigger(args); + } + public get supportsResize(): boolean { return true; } @@ -64,15 +81,18 @@ export abstract class VerticalLayoutBase extends ScoreLayout { return x; } - public override doUpdateForBars(firstModifiedMasterBar: number): boolean { + public override doUpdateForBars(renderHints: RenderHints): boolean { + this._reuseViewPort = renderHints.reuseViewport ?? false; + const firstModifiedMasterBar = renderHints.firstChangedMasterBar!; + // first update existing systems as needed const systemIndex = this._systems.findIndex(s => { const first = s.masterBarsRenderers[0].masterBar.index; const last = s.masterBarsRenderers[s.masterBarsRenderers.length - 1].masterBar.index; - return first >= firstModifiedMasterBar && firstModifiedMasterBar <= last; + return first <= firstModifiedMasterBar && firstModifiedMasterBar <= last; }); - if (systemIndex === -1) { + if (systemIndex === -1 || !this.renderer.settings.core.enableLazyLoading) { return false; } @@ -82,15 +102,25 @@ export abstract class VerticalLayoutBase extends ScoreLayout { // e.g. we could only shift systems where the content didn't change, // but we might still have ties/slurs which have to be updated. const removeSystems = this._systems.splice(systemIndex, this._systems.length - systemIndex); + this._systemPartialIds.splice(systemIndex, this._systemPartialIds.length - systemIndex); const system = removeSystems[0]; let y = system.y; const firstBarIndex = system.masterBarsRenderers[0].masterBar.index; + // signal all partials which didn't change + for (const preSystemPartial of this._preSystemPartialIds) { + this.reregisterPartial(preSystemPartial); + } + for (let i = 0; i < systemIndex; i++) { + this.reregisterPartial(this._systemPartialIds[i]); + } + + // new partials for all other prats y = this._layoutAndRenderScore(y, firstBarIndex); y = this.layoutAndRenderBottomScoreInfo(y); - y = this.layoutAndRenderAnnotation(y); + y = this._layoutAndRenderAnnotation(y); this.height = (y + this.pagePadding![3]) * this.renderer.settings.display.scale; @@ -118,7 +148,7 @@ export abstract class VerticalLayoutBase extends ScoreLayout { y = this.layoutAndRenderBottomScoreInfo(y); - y = this.layoutAndRenderAnnotation(y); + y = this._layoutAndRenderAnnotation(y); this.height = (y + this.pagePadding![3]) * this.renderer.settings.display.scale; } @@ -148,6 +178,7 @@ export abstract class VerticalLayoutBase extends ScoreLayout { canvas.textAlign = TextAlign.Center; this.tuningGlyph!.paint(0, 0, canvas); }); + this._preSystemPartialIds.push(e.id); return y + tuningHeight; } @@ -176,6 +207,7 @@ export abstract class VerticalLayoutBase extends ScoreLayout { canvas.textAlign = TextAlign.Center; this.chordDiagrams!.paint(0, 0, canvas); }); + this._preSystemPartialIds.push(e.id); return y + diagramHeight; } @@ -230,6 +262,7 @@ export abstract class VerticalLayoutBase extends ScoreLayout { g.paint(0, 0, canvas); } }); + this._preSystemPartialIds.push(e.id); } return y + infoHeight; @@ -238,6 +271,7 @@ export abstract class VerticalLayoutBase extends ScoreLayout { private _resizeAndRenderScore(y: number, oldHeight: number): number { // if we have a fixed number of bars per row, we only need to refit them. const barsPerRowActive = this.getBarsPerSystem(0) > 0; + this._systemPartialIds = []; if (barsPerRowActive) { for (let i: number = 0; i < this._systems.length; i++) { const system: StaffSystem = this._systems[i]; @@ -307,7 +341,6 @@ export abstract class VerticalLayoutBase extends ScoreLayout { let currentBarIndex: number = startIndex; const endBarIndex: number = this.lastBarIndex; - this._systems = []; while (currentBarIndex <= endBarIndex) { // create system and align set proper coordinates const system: StaffSystem = this._createStaffSystem(currentBarIndex, endBarIndex); @@ -349,6 +382,7 @@ export abstract class VerticalLayoutBase extends ScoreLayout { // since we use partial drawing system.paint(0, -(args.y / this.renderer.settings.display.scale), canvas); }); + this._systemPartialIds.push(args.id); // calculate coordinates for next system return height; diff --git a/packages/playground/recorder.ts b/packages/playground/recorder.ts index 13dcf9926..ff02906b2 100644 --- a/packages/playground/recorder.ts +++ b/packages/playground/recorder.ts @@ -7,7 +7,9 @@ req.onload = () => { // this is a demo which builds a "recorder" with alphaTab. // in this case we do not actually play the song but just use the rendering and player capabilities - // to get a display of the notes and a cursor. + // to get a display of the notes and a cursor. for the sake of simplicity we do not have a real recorder but we + // 1. fill a bar with quarter rests + // 2. when pressing a key 0-9 we put a note at this fret in the currently active quarter. // the overall recorder goes with various assumptions: // 1. we only have one track/staff/voice being recorded @@ -43,7 +45,7 @@ req.onload = () => { }); // start with 2 bars to always have 1 future bar buffer - const score = alphaTab.importer.ScoreLoader.loadAlphaTex(''); + const score = alphaTab.importer.ScoreLoader.loadAlphaTex('r.4 r r r'); score.tracks[0].defaultSystemsLayout = 5; // threshold indicating we need to insert a new bar, -1 as marker to not do anything @@ -66,7 +68,7 @@ req.onload = () => { return newMasterBar; } - function insertNewBar(masterBar: alphaTab.model.MasterBar) { + function insertNewBar() { const staff = score.tracks[0].staves[0]; const previousBar = staff.bars[staff.bars.length - 1]; const newBar = new alphaTab.model.Bar(); @@ -80,12 +82,14 @@ req.onload = () => { const initialVoice = new alphaTab.model.Voice(); newBar.addVoice(initialVoice); - const emptyBeat = new alphaTab.model.Beat(); - emptyBeat.isEmpty = true; - emptyBeat.duration = alphaTab.model.Duration.Whole; - initialVoice.addBeat(emptyBeat); + for (let i = 0; i < 4; i++) { + const emptyBeat = new alphaTab.model.Beat(); + emptyBeat.isEmpty = false; + emptyBeat.duration = alphaTab.model.Duration.Quarter; + initialVoice.addBeat(emptyBeat); - api.tickCache?.addBeat(emptyBeat, 0, masterBar.calculateDuration()); + api.tickCache?.addBeat(emptyBeat, i * 960, 960 /* midi quarter time */); + } return newBar; } @@ -103,7 +107,7 @@ req.onload = () => { while (missingBars > 0) { const newMasterBar = insertNewMasterBar(); - const newBar = insertNewBar(newMasterBar); + const newBar = insertNewBar(); const sharedDataBag = new Map(); newMasterBar.finish(sharedDataBag); @@ -117,7 +121,6 @@ req.onload = () => { updateInsertTickThreshold(); - // TODO: hints on edited score api.renderScore(score, undefined, { reuseViewport: currentSystemCount > 0, firstChangedMasterBar: currentSystemCount > 0 ? lastMasterBarIndex : undefined @@ -180,6 +183,49 @@ req.onload = () => { insertNewSystem(); } }); + let currentBeat: alphaTab.model.Beat | undefined = undefined; + api.playedBeatChanged.on(beat => { + currentBeat = beat; + }); + + // very basic recording feature, keyboard digits cause a new beat + // with the fret 0-9 on the first string to be added + // this is likely the trickiest part in a real recording: compute the beat lengths and filling the model + document.addEventListener( + 'keydown', + e => { + if (!currentBeat) { + return; + } + + let fret = -1; + + if (e.code.startsWith('Digit') || e.code.startsWith('Numpad')) { + fret = Number.parseInt(e.code.substring(e.code.length - 1), 10); + } else { + return; + } + + e.preventDefault(); + + if (currentBeat.notes.length === 0) { + const newNote = new alphaTab.model.Note(); + newNote.string = 1; + newNote.fret = fret; + currentBeat.addNote(newNote); + } else { + currentBeat.notes[0].fret = fret; + } + + currentBeat.voice.bar.finish(api.settings, null); + + api.renderScore(score, undefined, { + reuseViewport: true, + firstChangedMasterBar: currentBeat.voice.bar.index + }); + }, + true + ); (window as any).at = api; }; From d8f356e5a28e522c17fd73700ddbb5a31a365199 Mon Sep 17 00:00:00 2001 From: danielku15 Date: Sun, 1 Mar 2026 14:02:23 +0100 Subject: [PATCH 4/4] build: cross compile --- .../csharp/src/AlphaTab/Core/TypeHelper.cs | 14 ++++++++++ .../main/java/alphaTab/collections/List.kt | 26 +++++++++++++++++-- 2 files changed, 38 insertions(+), 2 deletions(-) diff --git a/packages/csharp/src/AlphaTab/Core/TypeHelper.cs b/packages/csharp/src/AlphaTab/Core/TypeHelper.cs index 95dec6afe..da6406b30 100644 --- a/packages/csharp/src/AlphaTab/Core/TypeHelper.cs +++ b/packages/csharp/src/AlphaTab/Core/TypeHelper.cs @@ -70,6 +70,20 @@ public static T Find(this IList list, Func predicate) return list.FirstOrDefault(predicate); } + public static T FindIndex(this IList list, Func predicate) + { + var index = 0; + foreach (var item in list) + { + if (predicate(item)) + { + return index; + } + index++; + } + return -1; + } + public static bool Includes(this IList list, T item) { return list.Contains(item); diff --git a/packages/kotlin/src/android/src/main/java/alphaTab/collections/List.kt b/packages/kotlin/src/android/src/main/java/alphaTab/collections/List.kt index 951ada471..0c0fdff6f 100644 --- a/packages/kotlin/src/android/src/main/java/alphaTab/collections/List.kt +++ b/packages/kotlin/src/android/src/main/java/alphaTab/collections/List.kt @@ -87,6 +87,10 @@ public class List : Iterable { }) } + internal fun findIndex(predicate: (T) -> Boolean): Double { + return _data.indexOfFirst(predicate).toDouble() + } + public fun some(predicate: (T) -> Boolean): Boolean { return _data.any(predicate) } @@ -165,24 +169,42 @@ public class List : Iterable { return _data.removeAt(0) } - public fun splice(start: Double, deleteCount: Double, vararg newElements: T) { + public fun splice(start: Double, deleteCount: Double, vararg newElements: T): List { var actualStart = start.toInt() if (actualStart < 0) { actualStart += _data.size } + val remove = if (deleteCount > 0) List( + ArrayListWithRemoveRange( + _data.subList( + start.toInt(), + _data.size + ) + ) + ) else List() _data.removeRange(start.toInt(), (start + deleteCount).toInt()) _data.addAll(start.toInt(), newElements.toList()) + return remove } - public fun splice(start: Double, deleteCount: Double, newElements: Iterable) { + public fun splice(start: Double, deleteCount: Double, newElements: Iterable): List { var actualStart = start.toInt() if (actualStart < 0) { actualStart += _data.size } + val remove = if (deleteCount > 0) List( + ArrayListWithRemoveRange( + _data.subList( + start.toInt(), + _data.size + ) + ) + ) else List() _data.removeRange(start.toInt(), (start + deleteCount).toInt()) _data.addAll(start.toInt(), newElements.toList()) + return remove } public fun join(separator: String): String {